diff --git a/.jjconflict-base-0/.dockerignore b/.jjconflict-base-0/.dockerignore new file mode 100644 index 0000000000..2edecc5c93 --- /dev/null +++ b/.jjconflict-base-0/.dockerignore @@ -0,0 +1,26 @@ +# Npm +node_modules +npm-debug.log + +# Git +.gitkeep + +# Lint +.eslintrc.js +.eslintrc.cjs +.eslintrc.json +.eslintignore +.stylelintrc.js +.stylelintrc.cjs + +# Tests +.turbo/ +coverage/ + +# Docker +**/Dockerfile +.dockerignore +docker/ + +# Env +.env* \ No newline at end of file diff --git a/.jjconflict-base-0/.gitattributes b/.jjconflict-base-0/.gitattributes new file mode 100644 index 0000000000..2e7bbd226d --- /dev/null +++ b/.jjconflict-base-0/.gitattributes @@ -0,0 +1,2 @@ +# Generated API clients — collapse in diffs and exclude from language stats. +plugins/harbor/src/api/Api.ts linguist-generated=true diff --git a/.jjconflict-base-0/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.jjconflict-base-0/.github/ISSUE_TEMPLATE/BUG-REPORT.yml new file mode 100644 index 0000000000..f87368e72d --- /dev/null +++ b/.jjconflict-base-0/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -0,0 +1,98 @@ +name: 🐛 Rapport de bug +description: Créer un ticket pour un bug. +title: "🐛 [BUG] - " +labels: [bug] +body: + - type: textarea + id: description + attributes: + label: Description + description: Veuillez saisir une description explicite de votre problème. + placeholder: Description courte et explicite de votre incident... + validations: + required: true + + - type: textarea + id: reprod + attributes: + label: Etapes de reproduction + description: Veuillez saisir une description explicite pour reproduire votre problème. + value: | + 1. Aller à '...' + 2. Cliquer sur '....' + 3. Scroller jusqu'à '....' + 4. Voir l'erreur + render: bash + validations: + required: true + + - type: textarea + id: screenshot + attributes: + label: "Captures d'écran" + description: Si nécessaire, ajoutez des captures d'écran pour expliquer votre problème. + value: | + ![DESCRIPTION](LINK.png) + render: bash + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Logs + description: Veuillez copier/coller tous les logs pertinents. Ces données seront automatiquement formatées en code. + validations: + required: false + + - type: dropdown + id: browsers + attributes: + label: Navigateurs + description: Quels sont les navigateurs sur lesquels vous constatez le problème ? + multiple: true + options: + - Firefox + - Chrome + - Chromium + - Brave + - Safari + - Microsoft Edge + - Opera + validations: + required: false + + - type: dropdown + id: os + attributes: + label: OS + description: Quels sont les systèmes d'exploitation sur lesquels vous constatez le problème ? + multiple: true + options: + - Windows + - Linux + - Mac + validations: + required: false + + - type: input + id: affected-versions + attributes: + label: Version de la console impactée + description: Quelle version de la console est impactée par le bug ? + placeholder: v1.2.3 + validations: + required: false + + - type: checkboxes + id: dod + attributes: + label: Définition du fini + description: Cochez les différents élément de la Definition Of Done. + options: + - label: Le correctif est terminé + required: false + - label: Les tests liés à ce correctif ont été ajoutés + required: false + - label: La communication avec les autres équipes impliquées par ce correctif a été faite + required: false diff --git a/.jjconflict-base-0/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.jjconflict-base-0/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml new file mode 100644 index 0000000000..2d55aae3ad --- /dev/null +++ b/.jjconflict-base-0/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml @@ -0,0 +1,64 @@ +name: 💡 Demande de fonctionnalité +description: Créer un ticket pour demander une nouvelle fonctionnalité. +title: "💡 [REQUEST] - <title>" +labels: [enhancement] +body: + - type: textarea + id: summary + attributes: + label: Description + description: Veuillez saisir une brève explication de la fonctionnalité. + placeholder: Décrivez en quelques lignes la fonctionnalité désirée. + validations: + required: true + + - type: textarea + id: implementation_pr + attributes: + label: PRs liées + description: Pull request qui répond à la fonctionnalité. + placeholder: "#Pull Request ID" + validations: + required: false + + - type: textarea + id: reference_issues + attributes: + label: Issues liées + description: Issues associée à cette demande de fonctionnalité. + placeholder: "#Issues IDs" + validations: + required: false + + - type: textarea + id: basic_example + attributes: + label: Exemples simples + description: Indiquez ici quelques exemples de base de votre fonctionnalité. + placeholder: Quelques mots sur votre demande de fonctionnalité. + validations: + required: false + + - type: textarea + id: tech + attributes: + label: Spécifications techniques + description: Spcifiez les détails techniques liés à votre demande. + placeholder: Une description technique de la fonctionnalité. + validations: + required: false + + - type: checkboxes + id: dod + attributes: + label: Définition du fini + description: Cochez les différents élément de la Definition Of Done. + options: + - label: La fonctionnalité est terminée + required: false + - label: Les tests liés à cette fonctionnalité ont été ajoutés + required: false + - label: La documentation liée à cette fonctionnalité a été ajoutée (cf. https://github.com/cloud-pi-native/documentation) + required: false + - label: La communication avec les autres équipes impliquées par cette fonctionnalité a été faite + required: false diff --git a/.jjconflict-base-0/.github/ISSUE_TEMPLATE/config.yml b/.jjconflict-base-0/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..b565340e24 --- /dev/null +++ b/.jjconflict-base-0/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: 📚 Documentation + url: https://cloud-pi-native.fr + about: Cet outil de suivi des problèmes n'est pas destiné aux problèmes de documentation. Veuillez ouvrir un ticket sur le dépôt de documentation. + - name: 🚀 Déploiement + url: https://github.com/cloud-pi-native/socle + about: Cet outil de suivi des problèmes n'est pas destiné aux problèmes de déploiement de la plateforme. Veuillez ouvrir un ticket sur le dépôt de socle. diff --git a/.jjconflict-base-0/.github/PULL_REQUEST_TEMPLATE.md b/.jjconflict-base-0/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..1730631ac9 --- /dev/null +++ b/.jjconflict-base-0/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +## Issues liées + +Issues numéro: + +--------- + +<!-- Ne soumettez pas de mises à jour des dépendances à moins qu'elles ne corrigent un problème. --> + +<!-- Veuillez essayer de limiter votre Pull Request à un seul type (correction de bogue, fonctionnalité, etc.). Soumettez plusieurs PRs si nécessaire. --> + +## Quel est le comportement actuel ? +<!-- Veuillez décrire le comportement actuel que vous modifiez. --> + +## Quel est le nouveau comportement ? +<!-- Veuillez décrire le comportement ou les changements apportés par cette PR. --> + +## Cette PR introduit-elle un breaking change ? +<!-- Si un breaking change est introduit, veuillez décrire ci-dessous l'impact et la procédure de migration pour les applications existantes. --> + +## Autres informations +<!-- Toute autre information importante pour la PR, telle que des captures d'écran montrant l'aspect du composant avant et après la modification. --> diff --git a/.jjconflict-base-0/.github/SECURITY.md b/.jjconflict-base-0/.github/SECURITY.md new file mode 100644 index 0000000000..9aaaf8698f --- /dev/null +++ b/.jjconflict-base-0/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +Contact: cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-base-0/.github/labeler/build.yml b/.jjconflict-base-0/.github/labeler/build.yml new file mode 100644 index 0000000000..68a15e21bc --- /dev/null +++ b/.jjconflict-base-0/.github/labeler/build.yml @@ -0,0 +1,3 @@ +built: + - changed-files: + - any-glob-to-any-file: "**" diff --git a/.jjconflict-base-0/.github/workflows/job-build.yml b/.jjconflict-base-0/.github/workflows/job-build.yml new file mode 100644 index 0000000000..f1a1f96e51 --- /dev/null +++ b/.jjconflict-base-0/.github/workflows/job-build.yml @@ -0,0 +1,256 @@ +name: Build + +permissions: + contents: read + packages: write + +on: + workflow_call: + inputs: + REGISTRY: + required: true + type: string + NAMESPACE: + required: true + type: string + BUILD_AMD64: + required: true + type: boolean + BUILD_ARM64: + required: true + type: boolean + TAG: + required: true + type: string + MAJOR_TAG: + required: false + type: string + MINOR_TAG: + required: false + type: string + PATCH_TAG: + required: false + type: string + USE_QEMU: + required: true + type: boolean + PR_NUMBER: + required: false + type: string + secrets: + ARGOCD_TOKEN: + required: false + workflow_dispatch: + inputs: + REGISTRY: + description: Target registry to push images + required: true + type: string + default: ghcr.io + NAMESPACE: + description: Target namespace to the given registry + required: true + type: string + default: cloud-pi-native/console + BUILD_AMD64: + description: Build for amd64 + required: true + type: boolean + default: true + BUILD_ARM64: + description: Build for arm64 + required: true + type: boolean + default: true + USE_QEMU: + description: Use QEMU emulator for arm64 + required: true + type: boolean + default: false + PR_NUMBER: + description: ID number of the pull request to use for deploy preview app + required: false + type: string + +jobs: + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + lower-branch: ${{ steps.lower-branch.outputs.LOWER_BRANCH }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + - name: Get lowercase branch name + id: lower-branch + run: | + echo "LOWER_BRANCH=$(echo '${{ github.head_ref || github.ref_name }}' | sed 's/\//-/g' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + + build: + name: Build application + runs-on: ${{ matrix.runners }} + needs: + - matrix + strategy: + fail-fast: false + matrix: + runners: ${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && !inputs.USE_QEMU && fromJson('["ubuntu-latest", "ARM64"]')) || (inputs.BUILD_ARM64 && !inputs.USE_QEMU && fromJson('["ARM64"]')) || fromJson('["ubuntu-latest"]') }} + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Get short SHA + id: short-sha + run: | + echo "SHORT_SHA=sha-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Cache Docker layers + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: /tmp/.buildx-cache + key: ${{ matrix.images.name == 'server' && format('buildx-{0}-{1}-{2}-{3}', runner.os, runner.arch, matrix.images.name, hashFiles(format('apps/{0}/**', matrix.images.name),'packages/shared/**','plugins/**')) || format('buildx-{0}-{1}-{2}-{3}', runner.os, runner.arch, matrix.images.name, hashFiles(format('apps/{0}/**', matrix.images.name),'packages/shared/**')) }} + restore-keys: | + buildx-${{ runner.os }}-${{ runner.arch }}-${{ matrix.images.name }}- + + - name: Set up QEMU (for multi platform build) + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + if: ${{ inputs.USE_QEMU }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ inputs.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + logout: true + + - name: Build and push docker image + id: build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: ${{ matrix.images.build.context }} + file: ${{ matrix.images.build.dockerfile }} + provenance: false + platforms: ${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && inputs.USE_QEMU && 'linux/amd64,linux/arm64') || (inputs.BUILD_ARM64 && 'linux/arm64') || (inputs.BUILD_AMD64 && 'linux/amd64') }} + outputs: type=image,name=${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }},push-by-digest=true,name-canonical=true,push=true + build-args: | + APP_VERSION=${{ inputs.TAG || steps.short-sha.outputs.SHORT_SHA }} + # cache-from: type=gha + # cache-to: type=gha,mode=max + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new + + # Necessary to avoid forever growing cache + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + + - name: Export digest + run: | + mkdir -p /tmp/digests/${{ matrix.images.name }} + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${{ matrix.images.name }}/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: digests-${{ matrix.images.name }}.${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && inputs.USE_QEMU && 'multiarch') || (inputs.BUILD_ARM64 && 'arm64') || (inputs.BUILD_AMD64 && 'amd64') }}.digest + path: /tmp/digests/${{ matrix.images.name }}/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge digest + runs-on: ubuntu-latest + needs: + - matrix + - build + strategy: + fail-fast: false + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Download artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: digests-${{ matrix.images.name }}.*.digest + path: /tmp/digests/${{ matrix.images.name }} + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Docker meta + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + with: + images: ${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }} + tags: | + type=raw,value=${{ needs.matrix.outputs.lower-branch }},enable=${{ github.head_ref != 'main' }} + type=raw,value=${{ inputs.TAG }},enable=${{ inputs.TAG != '' }} + type=raw,value=${{ inputs.MAJOR_TAG }}.${{ inputs.MINOR_TAG }},enable=${{ inputs.MAJOR_TAG != '' && inputs.MINOR_TAG != '' }} + type=raw,value=${{ inputs.MAJOR_TAG }},enable=${{ inputs.MAJOR_TAG != '' }} + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ inputs.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + logout: true + + - name: Create manifest list and push + working-directory: /tmp/digests/${{ matrix.images.name }} + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ steps.meta.outputs.version }} + + preview: + name: Redeploy preview app + if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'preview') && inputs.PR_NUMBER != '' }} + runs-on: ubuntu-latest + needs: + - merge + steps: + - name: Replace template fields + id: feed-template + run: | + echo "ARGOCD_APP_NAME=$(echo '${{ vars.ARGOCD_APP_NAME_TEMPLATE }}' | sed 's|<pr_number>|${{ inputs.PR_NUMBER }}|g')" >> $GITHUB_OUTPUT + echo "ARGOCD_SYNC_PAYLOAD=$(echo '${{ vars.ARGOCD_SYNC_PAYLOAD_TEMPLATE }}' | sed 's|<pr_number>|${{ inputs.PR_NUMBER }}|g')" >> $GITHUB_OUTPUT + + - name: Deploy new image + run: | + set -e + + RESPONSE_CODE=$(curl -s -w "%{http_code}" -o >(RESPONSE_BODY=$(cat)) -X POST ${{ vars.ARGOCD_URL }}/api/v1/applications/${{ steps.feed-template.outputs.ARGOCD_APP_NAME }}/sync \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${{ secrets.ARGOCD_TOKEN }}" \ + -d '${{ steps.feed-template.outputs.ARGOCD_SYNC_PAYLOAD }}') + + echo "HTTP response code: $RESPONSE_CODE" + + if [ "$RESPONSE_CODE" -ne 200 ]; then + echo "Error: Non-200 HTTP response detected." + echo "Response body: $RESPONSE_BODY" + exit 1 + fi + continue-on-error: true diff --git a/.jjconflict-base-0/.github/workflows/job-label.yml b/.jjconflict-base-0/.github/workflows/job-label.yml new file mode 100644 index 0000000000..1fd944015f --- /dev/null +++ b/.jjconflict-base-0/.github/workflows/job-label.yml @@ -0,0 +1,25 @@ +name: Add labels on PR + +permissions: + contents: read + pull-requests: write + +on: + workflow_call: + inputs: + CONF_PATH: + required: true + type: string + +jobs: + label: + name: Add labels on PR + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Add pull request label + uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + with: + configuration-path: ${{ inputs.CONF_PATH }} diff --git a/.jjconflict-base-0/.github/workflows/job-lint.yml b/.jjconflict-base-0/.github/workflows/job-lint.yml new file mode 100644 index 0000000000..bbbae077d6 --- /dev/null +++ b/.jjconflict-base-0/.github/workflows/job-lint.yml @@ -0,0 +1,79 @@ +name: Lint + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: false + type: string + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + +jobs: + lint: + name: Lint codebase + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check lint error + run: | + ./ci/scripts/run-tests.sh -l + + - name: Validate nginx-strangler config + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends nginx gettext-base + # Préparer un répertoire de test isolé avec la config substituée + mkdir -p /tmp/nginx-test/conf.d /tmp/nginx-test/logs + envsubst '${LEGACY_UPSTREAM} ${NESTJS_UPSTREAM}' \ + < apps/nginx-strangler/conf.d/routing.conf \ + > /tmp/nginx-test/conf.d/routing.conf + # Adapter nginx.conf pour l'environnement CI (user www-data, paths accessibles) + sed \ + -e 's|^user .*|user www-data;|' \ + -e 's|pid .*|pid /tmp/nginx-test/nginx.pid;|' \ + -e 's|error_log .*|error_log /tmp/nginx-test/logs/error.log notice;|' \ + -e 's|access_log .*|access_log /tmp/nginx-test/logs/access.log main;|' \ + -e 's|include /etc/nginx/conf\.d/\*\.conf|include /tmp/nginx-test/conf.d/*.conf|' \ + apps/nginx-strangler/nginx.conf > /tmp/nginx-test/nginx.conf + nginx -t -c /tmp/nginx-test/nginx.conf + env: + LEGACY_UPSTREAM: "127.0.0.1:8080" + NESTJS_UPSTREAM: "127.0.0.1:3001" diff --git a/.jjconflict-base-0/.github/workflows/job-npm.yml b/.jjconflict-base-0/.github/workflows/job-npm.yml new file mode 100644 index 0000000000..51e4f3eedc --- /dev/null +++ b/.jjconflict-base-0/.github/workflows/job-npm.yml @@ -0,0 +1,247 @@ +name: Publish npm + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + PUBLISH_APPS: + required: true + type: boolean + PUBLISH_PACKAGES: + required: true + type: boolean + PUBLISH_PLUGINS: + required: true + type: boolean + CHECK_CHANGES: + required: true + type: boolean + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used + required: true + type: string + PUBLISH_APPS: + description: Publish apps modules + required: true + type: boolean + default: false + PUBLISH_PACKAGES: + description: Publish packages modules + required: true + type: boolean + default: false + PUBLISH_PLUGINS: + description: Publish plugins modules + required: true + type: boolean + default: true + CHECK_CHANGES: + required: true + type: boolean + default: true + +jobs: + path-filter: + if: ${{ inputs.CHECK_CHANGES }} + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.filter.outputs.changes }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + eslint-config: + - 'packages/eslintconfig/src/**' + shared: + - 'packages/shared/src/**' + hooks: + - 'packages/hooks/src/**' + ts-config: + - 'packages/tsconfig/**' + logger: + - 'packages/logger/**' + argocd: + - 'plugins/argocd/src/**' + gitlab: + - 'plugins/gitlab/src/**' + harbor: + - 'plugins/harbor/src/**' + keycloak: + - 'plugins/keycloak/src/**' + nexus: + - 'plugins/nexus/src/**' + sonarqube: + - 'plugins/sonarqube/src/**' + vault: + - 'plugins/vault/src/**' + + check-update: + name: Check for package version update + if: ${{ fromJson(needs.path-filter.outputs.packages).include[0] }} # skip if the matrix is empty! + runs-on: ubuntu-latest + needs: + - path-filter + strategy: + matrix: + packages: ${{ fromJSON(needs.path-filter.outputs.packages) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Check changes + id: check + run: | + PACKAGE_NAME=$(jq -cr --arg p "${{ matrix.packages }}" '[.packages[], .plugins[]][] | select(.name | test($p)?) | .name' < ./ci/matrix-npm.json) + PACKAGE_PATH=$(jq -cr --arg p "${{ matrix.packages }}" '[.packages[], .plugins[]][] | select(.name | test($p)?) | .path' < ./ci/matrix-npm.json) + PACKAGE_VERSION=$(jq -cr '.version' < ${PACKAGE_PATH}/package.json) + if [ "$(curl -s -o /dev/null -I -w %{http_code} https://registry.npmjs.org/${PACKAGE_NAME})" = 404 ]; then + REMOTE=false + else + REMOTE=$(pnpm view ${PACKAGE_NAME} --json | jq --arg v "$PACKAGE_VERSION" 'any(.versions[] == $v; .)') + fi + if [ "$REMOTE" = "true" ]; then + echo "WARNING=true" >> $GITHUB_OUTPUT + echo "PACKAGE_NAME=$PACKAGE_NAME" >> $GITHUB_OUTPUT + echo "PACKAGE_PATH=$PACKAGE_PATH" >> $GITHUB_OUTPUT + echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_OUTPUT + else + echo "WARNING=false" >> $GITHUB_OUTPUT + fi + + matrix: + name: Generate publish matrix + needs: + - path-filter + if: ${{ needs.path-filter.outputs.packages != '' && (inputs.PUBLISH_APPS || inputs.PUBLISH_PACKAGES || inputs.PUBLISH_PLUGINS) }} + runs-on: ubuntu-latest + outputs: + publish-matrix: ${{ steps.packages-matrix.outputs.PUBLISH_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: packages-matrix + run: | + PUBLISH_MATRIX="[]" + APPS_MATRIX="$(jq -c '.apps' < ./ci/matrix-npm.json)" + PACKAGES_MATRIX="$(jq -c '.packages' < ./ci/matrix-npm.json)" + PLUGINS_MATRIX="$(jq -c '.plugins' < ./ci/matrix-npm.json)" + if [ "${{ inputs.PUBLISH_APPS }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$APPS_MATRIX" '$acc + $cur')" + fi + if [ "${{ inputs.PUBLISH_PACKAGES }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$PACKAGES_MATRIX" '$acc + $cur')" + fi + if [ "${{ inputs.PUBLISH_PLUGINS }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$PLUGINS_MATRIX" '$acc + $cur')" + fi + echo "PUBLISH_MATRIX=$PUBLISH_MATRIX" >> $GITHUB_OUTPUT + + publish-npm: + name: Publish on npm + if: ${{ needs.path-filter.outputs.packages != '' && (inputs.PUBLISH_APPS || inputs.PUBLISH_PACKAGES || inputs.PUBLISH_PLUGINS) }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + needs: + - path-filter + - matrix + strategy: + # Allow jobs to be processed independently of their siblings status + fail-fast: false + matrix: + modules: ${{ fromJSON(needs.matrix.outputs.publish-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.ref }} + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Check for new package version + id: check-version + run: | + PACKAGE_VERSION=$(jq -cr '.version' < ${{ matrix.modules.path }}/package.json) + if [ "$(curl -s -o /dev/null -I -w %{http_code} https://registry.npmjs.org/${{ matrix.modules.name }})" = 404 ]; then + REMOTE=false + else + REMOTE=$(pnpm view ${{ matrix.modules.name }} --json | jq --arg v "$PACKAGE_VERSION" 'any(.versions[] == $v; .)') + fi + if [ "$REMOTE" = "true" ]; then + echo "Package ${{ matrix.modules.name }} - version $PACKAGE_VERSION already exists" + else + echo "Package ${{ matrix.modules.name }} - version $PACKAGE_VERSION does not exist" + fi + echo "REMOTE=$REMOTE" >> $GITHUB_OUTPUT + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + with: + node-version: "${{ inputs.NODE_VERSION }}" + registry-url: "https://registry.npmjs.org" + + - name: Get pnpm store directory + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install base dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: pnpm install --frozen-lockfile + + - name: Build internal dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter "./packages/**" run build && pnpm --filter "./plugins/**" run build + + - name: Update PNPM internal links with built internal dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm install --frozen-lockfile + + - name: Build package + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter ${{ matrix.modules.name }} run build + + - name: Publish package + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter ${{ matrix.modules.name }} publish --provenance --no-git-checks --report-summary diff --git a/.jjconflict-base-0/.github/workflows/job-playwright.yml b/.jjconflict-base-0/.github/workflows/job-playwright.yml new file mode 100644 index 0000000000..ec0a3fe695 --- /dev/null +++ b/.jjconflict-base-0/.github/workflows/job-playwright.yml @@ -0,0 +1,114 @@ +name: Playwright Tests + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + TAG: + required: true + type: string + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + TAG: + description: Container images version to use for console client and server/server-nestjs + required: true + type: string + default: latest +jobs: + test: + timeout-minutes: 60 + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache Node.js files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install Node.js dependencies + run: pnpm install --frozen-lockfile + + - name: Build internal dependencies + run: pnpm --filter "./packages/**" build + + - name: Get used Playwright version + id: playwright-version + run: echo "PLAYWRIGHT_VERSION=$(pnpm --dir playwright exec playwright -V | awk '{ print $2 }')" >> $GITHUB_ENV + + - name: Restore cache - Playwright browser binaries + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + id: playwright-cache + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} + + - name: Install Playwright system dependencies + run: pnpm --dir playwright exec playwright install --with-deps + + - name: Save cache - Playwright browser binaries + if: always() && steps.playwright-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} + + - name: Initialize application environment for tests + run: | + ./ci/scripts/init-env.sh + docker pull ghcr.io/cloud-pi-native/console/server:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/server:${{ inputs.TAG }} dso-console/server:ci + docker pull ghcr.io/cloud-pi-native/console/nginx-strangler:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/nginx-strangler:${{ inputs.TAG }} dso-console/nginx-strangler:ci + docker pull ghcr.io/cloud-pi-native/console/server-nestjs:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/server-nestjs:${{ inputs.TAG }} dso-console/server-nestjs:ci + docker pull ghcr.io/cloud-pi-native/console/client:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/client:${{ inputs.TAG }} dso-console/client:ci + docker pull ghcr.io/cloud-pi-native/console/opencds-mockoon:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/opencds-mockoon:${{ inputs.TAG }} dso-console/opencds-mockoon:ci + docker compose -f ./docker/docker-compose.ci.yml up --no-build -d --remove-orphans + + - name: Run Playwright tests + run: pnpm --dir playwright exec playwright test --grep @e2e + + - name: Clean up docker resources (containers, volumes) + run: docker compose -f ./docker/docker-compose.ci.yml down -v --remove-orphans + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright/playwright-report/ + retention-days: 30 diff --git a/.jjconflict-base-0/.github/workflows/job-release-please.yml b/.jjconflict-base-0/.github/workflows/job-release-please.yml new file mode 100644 index 0000000000..b4789c2cbb --- /dev/null +++ b/.jjconflict-base-0/.github/workflows/job-release-please.yml @@ -0,0 +1,84 @@ +name: Release + +permissions: + contents: write + pull-requests: write + +on: + workflow_call: + outputs: + release-created: + description: Has the releease been created + value: ${{ jobs.release.outputs.release-created }} + major-tag: + description: Major version tag + value: ${{ jobs.release.outputs.major-tag }} + minor-tag: + description: Minor version tag + value: ${{ jobs.release.outputs.minor-tag }} + patch-tag: + description: Patch version tag + value: ${{ jobs.release.outputs.patch-tag }} + +jobs: + release: + name: Create new release + runs-on: ubuntu-latest + outputs: + release-created: ${{ steps.release.outputs.release_created }} + major-tag: ${{ steps.release.outputs.major }} + minor-tag: ${{ steps.release.outputs.minor }} + patch-tag: ${{ steps.release.outputs.patch }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Update release-please config for hotfix branches + if: startsWith(github.ref_name, 'hotfix/') + run: | + # Update versioning to always-bump-patch for hotfix branches + jq '.versioning = "always-bump-patch"' release-please-config.json > tmp.json && mv tmp.json release-please-config.json + echo "Updated release-please-config.json versioning to always-bump-patch" + shell: bash + + - name: Create merge request for new release + uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 + id: release + with: + config-file: release-please-config.json + target-branch: ${{ github.ref_name }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Edit packages versions + if: ${{ ! steps.release.outputs.release_created && steps.release.outputs.pr }} + run: | + export BRANCH=release-please--branches--main--components--console + git fetch + git checkout "$BRANCH" + VERSION=$(jq -r .version package.json) + for pkg in $(jq -r '.apps | map(.path + "/package.json") | .[]' < ./ci/matrix-npm.json); do + yq e -i ".version |= \"$VERSION\"" $pkg + yq e -i ".publishConfig.tag |= \"latest\"" $pkg + done + git add . + # Emulate the author of the existing commit (the bot). + git config user.email "$(git log -n 1 --format='%ae')" + git config user.name "$(git log -n 1 --format='%an')" + # Update the PR. + git commit --amend --no-edit + git push -f + + - name: Tag major and minor versions + if: ${{ steps.release.outputs.release_created }} + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git remote add gh-token "https://${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" + git tag -d v${{ steps.release.outputs.major }} || true + git tag -d v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} || true + git push origin :v${{ steps.release.outputs.major }} || true + git push origin :v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} || true + git tag -a v${{ steps.release.outputs.major }} -m "Release v${{ steps.release.outputs.major }}" + git tag -a v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} -m "Release v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}" + git push origin v${{ steps.release.outputs.major }} + git push origin v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} diff --git a/.jjconflict-base-0/.github/workflows/job-scan.yml b/.jjconflict-base-0/.github/workflows/job-scan.yml new file mode 100644 index 0000000000..3efab69fc5 --- /dev/null +++ b/.jjconflict-base-0/.github/workflows/job-scan.yml @@ -0,0 +1,148 @@ +name: Vulnerability scan + +permissions: + contents: read + pull-requests: write + +on: + workflow_call: + inputs: + REGISTRY: + required: true + type: string + NAMESPACE: + required: true + type: string + TAG: + required: true + type: string + workflow_dispatch: + inputs: + REGISTRY: + description: Target registry to push images + required: true + type: string + default: ghcr.io + NAMESPACE: + description: Target namespace to the given registry + required: true + type: string + default: cloud-pi-native/console + TAG: + description: Tag used to scan images + required: true + type: string + default: latest + +jobs: + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + images-scan: + name: Scan images vulnerabilities + runs-on: ubuntu-latest + needs: + - matrix + strategy: + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run Trivy vulnerability scanner on images + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: "${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ inputs.TAG }}" + format: template + template: "@/contrib/sarif.tpl" + vuln-type: "os,library" + ignore-unfixed: true + output: trivy-results.sarif + github-pat: ${{ secrets.GITHUB_TOKEN }} + cache: "false" + continue-on-error: true + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: trivy-results.sarif + continue-on-error: true + + - name: Warn about error + if: failure() + run: | + echo ":warning: The Trivy scan report for image `${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ inputs.TAG }}` was not successfully completed and could not be uploaded to the Github Security Panel." >> $GITHUB_STEP_SUMMARY + + config-scan: + name: Scan config files vulnerabilities + runs-on: ubuntu-latest + needs: + - matrix + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run Trivy vulnerability scanner on config files + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + scan-ref: . + scan-type: config + format: template + template: "@/contrib/sarif.tpl" + skip-dirs: "**/node_modules,ci" + ignore-unfixed: true + output: trivy-results.sarif + github-pat: ${{ secrets.GITHUB_TOKEN }} + cache: "false" + continue-on-error: true + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: trivy-results.sarif + continue-on-error: true + + - name: Warn about error + if: failure() + run: | + echo ":warning: The Trivy scan report for configuration was not successfully completed and could not be uploaded to the Github Security Panel." >> $GITHUB_STEP_SUMMARY + + scan-notif: + name: Notify users + runs-on: ubuntu-latest + needs: + - matrix + - images-scan + - config-scan + if: ${{ github.event_name == 'pull_request' }} + env: + MERGE_REQUEST_ID: ${{ github.event.pull_request.number }} + COMMENT: | + 🤖 Hey ! + + The security scan report for the current pull request is available [here](https://github.com/${{ github.repository }}/security/code-scanning?query=is%3Aopen+branch%3Amain+pr%3A${{ github.event.pull_request.number || github.event.number }}). + steps: + - name: Add comment on Merge Request + id: add-comment-on-merge-request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.MERGE_REQUEST_ID, + body: process.env.COMMENT, + }); diff --git a/.jjconflict-base-0/.github/workflows/job-tests-unit.yml b/.jjconflict-base-0/.github/workflows/job-tests-unit.yml new file mode 100644 index 0000000000..993ff302cf --- /dev/null +++ b/.jjconflict-base-0/.github/workflows/job-tests-unit.yml @@ -0,0 +1,177 @@ +name: Tests - unit + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + secrets: + SONAR_HOST_URL: + required: false + SONAR_TOKEN: + required: false + SONAR_PROJECT_KEY: + required: false + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + +jobs: + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm -r install --frozen-lockfile + + - name: Build internal dependencies (packages) + run: pnpm --filter "./packages/**" build + + - name: Build internal plugins + run: pnpm --filter "./plugins/**" build + + - name: Run unit tests + run: | + ./ci/scripts/init-env.sh + ./ci/scripts/run-tests.sh -u + + - name: Upload vitest coverage artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: unit-tests-coverage + path: | + ./apps/**/coverage/lcov.info + ./packages/**/coverage/lcov.info + retention-days: 1 + + check-secrets: + name: Check if Sonar secrets are set + runs-on: ubuntu-latest + needs: + - unit-tests + outputs: + run-scan: ${{ steps.check-secrets.outputs.run-scan }} + steps: + - name: Check for sonar secrets + id: check-secrets + run: | + if [ "${{ secrets.SONAR_HOST_URL }}" != "" ] && [ "${{ secrets.SONAR_TOKEN }}" != "" ] && [ "${{ secrets.SONAR_PROJECT_KEY }}" != "" ]; then + echo "run-scan=true" >> $GITHUB_OUTPUT + else + echo "run-scan=false" >> $GITHUB_OUTPUT + fi + + code-scan: + name: Run code quality analysis + runs-on: ubuntu-latest + needs: + - unit-tests + - check-secrets + if: ${{ needs.check-secrets.outputs.run-scan == 'true' }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Download artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unit-tests-coverage + path: ./coverage + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set sonarqube args + id: sonar-args + run: | + echo "SONAR_ARGS_PR=-Dsonar.pullrequest.provider=github -Dsonar.pullrequest.key=${{ github.event.number }} -Dsonar.pullrequest.branch=${{ github.head_ref }} -Dsonar.pullrequest.base=${{ github.base_ref }} -Dsonar.pullrequest.github.repository=${{ github.repository }}" >> $GITHUB_OUTPUT + echo "SONAR_ARGS_BRANCH=-Dsonar.branch.name=${{ github.ref_name }}" >> $GITHUB_OUTPUT + + - name: SonarQube Scan + uses: sonarsource/sonarqube-scan-action@299e4b793aaa83bf2aba7c9c14bedbb485688ec4 # v7.1.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + args: > + -Dsonar.projectKey=${{ secrets.SONAR_PROJECT_KEY }} + -Dsonar.sources=apps,packages + -Dsonar.javascript.node.maxspace=4096 + -Dsonar.javascript.lcov.reportPaths=coverage/apps/server/coverage/lcov.info,coverage/apps/client/coverage/lcov.info,coverage/packages/shared/coverage/lcov.info + -Dsonar.coverage.exclusions=**/*.spec.js,**/*.spec.ts,**/*.vue,**/assets/**,**/packages/test-utils/**,apps/server/src/plugins/** + -Dsonar.cpd.exclusions=**/*.spec.js,**/*.spec.ts + -Dsonar.scm.provider=git + ${{ github.event_name == 'pull_request' && steps.sonar-args.outputs.SONAR_ARGS_PR || steps.sonar-args.outputs.SONAR_ARGS_BRANCH }} + continue-on-error: true + + - name: SonarQube Quality Gate check + id: sonarqube-quality-gate-check + uses: sonarsource/sonarqube-quality-gate-action@cf038b0e0cdecfa9e56c198bbb7d21d751d62c3b # v1.2.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + timeout-minutes: 5 + continue-on-error: true diff --git a/.jjconflict-base-0/.github/workflows/workflow-clean-cache.yml b/.jjconflict-base-0/.github/workflows/workflow-clean-cache.yml new file mode 100644 index 0000000000..d1e5b60e95 --- /dev/null +++ b/.jjconflict-base-0/.github/workflows/workflow-clean-cache.yml @@ -0,0 +1,94 @@ +name: Clean cache + +permissions: + contents: read + # For cache cleaning + actions: write + # For artifacts deletion + packages: write + +on: + pull_request: + types: + - closed + workflow_dispatch: + inputs: + PR_NUMBER: + description: Related pull request Id + required: false + type: number + BRANCH_NAME: + description: Related branch name + required: false + type: string + +jobs: + cleanup-cache: + name: Delete github cache + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Clean cache for closed branch + run: | + REPO=${{ github.repository }} + if [ -n "${{ inputs.BRANCH_NAME }}" ]; then + BRANCH="${{ inputs.BRANCH_NAME }}" + else + BRANCH="refs/pull/${{ github.event.pull_request.number || inputs.PR_NUMBER }}/merge" + fi + + echo "Fetching list of cache key" + cacheKeysForPR=$(gh cache list -R $REPO --ref $BRANCH -L 100 | cut -f 1 ) + + ## Setting this to not fail the workflow while deleting cache keys. + set +e + echo "Deleting caches..." + for cacheKey in $cacheKeysForPR; do + gh cache delete $cacheKey -R $REPO + done + echo "Done" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + cleanup-image: + name: Delete image from ghcr.io + runs-on: ubuntu-latest + needs: + - matrix + strategy: + fail-fast: false + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Get repository owner and name + id: image-infos + run: | + echo "ORG_NAME=$(echo '${{ github.repository }}' | cut -d "/" -f 1)" >> $GITHUB_OUTPUT + echo "REPO_NAME=$(echo '${{ github.repository }}' | cut -d "/" -f 2)" >> $GITHUB_OUTPUT + + - name: Delete ${{ matrix.images.name }} image + run: | + ./ci/scripts/delete-image.sh \ + -o "${{ steps.image-infos.outputs.ORG_NAME }}" \ + -i "${{ steps.image-infos.outputs.REPO_NAME }}/${{ matrix.images.name }}" \ + -t "pr-${{ github.event.pull_request.number || github.event.number }}" \ + -g "${{ secrets.GITHUB_TOKEN }}" diff --git a/.jjconflict-base-0/.github/workflows/workflow-continuous-integration.yml b/.jjconflict-base-0/.github/workflows/workflow-continuous-integration.yml new file mode 100644 index 0000000000..1553fddc93 --- /dev/null +++ b/.jjconflict-base-0/.github/workflows/workflow-continuous-integration.yml @@ -0,0 +1,150 @@ +name: Continuous Integration + +permissions: + contents: read + # Needed for build job + packages: write + # Needed for build-label and scan-vuln jobs + pull-requests: write + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + branches: + - "**" + workflow_dispatch: + +env: + NODE_VERSION: 24.13.1 + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: false + USE_QEMU: false + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ steps.filter.outputs.apps }} + packages: ${{ steps.filter.outputs.packages }} + e2e: ${{ steps.filter.outputs.e2e }} + ci: ${{ steps.filter.outputs.ci }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + ci: + - '.github/workflows/**' + e2e: + - 'playwright/**' + + expose-vars: + runs-on: ubuntu-latest + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + lint: + uses: ./.github/workflows/job-lint.yml + needs: + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + + unit-tests: + uses: ./.github/workflows/job-tests-unit.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' }} + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + secrets: + SONAR_HOST_URL: "${{ secrets.SONAR_HOST_URL }}" + SONAR_TOKEN: "${{ secrets.SONAR_TOKEN }}" + SONAR_PROJECT_KEY: "${{ secrets.SONAR_PROJECT_KEY }}" + + build: + uses: ./.github/workflows/job-build.yml + if: ${{ github.event.pull_request.number && (needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true') }} + needs: + - path-filter + - expose-vars + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.pull_request.number || github.event.number }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.number }} + secrets: + ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }} + + build-label: + uses: ./.github/workflows/job-label.yml + needs: + - expose-vars + - build + with: + CONF_PATH: ./.github/labeler/build.yml + + scan-vuln: + uses: ./.github/workflows/job-scan.yml + needs: + - expose-vars + - build + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.pull_request.number || github.event.number }} + + # Workaround for required status check in protection branches (see. https://github.com/orgs/community/discussions/13690) + all-jobs-passed: + name: Check jobs status + runs-on: ubuntu-latest + if: ${{ always() }} + needs: + - path-filter + - expose-vars + - lint + - unit-tests + - build + - scan-vuln + steps: + - name: Check status of all required jobs + run: |- + NEEDS_CONTEXT='${{ toJson(needs) }}' + JOB_IDS=$(echo "$NEEDS_CONTEXT" | jq -r 'keys[]') + for JOB_ID in $JOB_IDS; do + RESULT=$(echo "$NEEDS_CONTEXT" | jq -r ".[\"$JOB_ID\"].result") + echo "$JOB_ID job result: $RESULT" + if [[ $RESULT != "success" && $RESULT != "skipped" ]]; then + echo "***" + echo "Error: The $JOB_ID job did not pass." + exit 1 + fi + done + echo "All jobs passed or were skipped." diff --git a/.jjconflict-base-0/.github/workflows/workflow-create-or-update-release.yml b/.jjconflict-base-0/.github/workflows/workflow-create-or-update-release.yml new file mode 100644 index 0000000000..70d77d68c7 --- /dev/null +++ b/.jjconflict-base-0/.github/workflows/workflow-create-or-update-release.yml @@ -0,0 +1,93 @@ +name: Create/update next release + +permissions: + contents: write + pull-requests: write + # Needed for build job + packages: write + +on: + push: + branches: + - main + - hotfix/* + +env: + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: true + USE_QEMU: false + NODE_VERSION: 24.13.1 + +jobs: + expose-vars: + runs-on: ubuntu-latest + outputs: + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + NODE_VERSION: ${{ env.NODE_VERSION }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars" + + release: + uses: ./.github/workflows/job-release-please.yml + + build-amd64: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.release.outputs.release-created == 'true' }} + needs: + - expose-vars + - release + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: ${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} + MAJOR_TAG: ${{ needs.release.outputs.major-tag }} + MINOR_TAG: ${{ needs.release.outputs.minor-tag }} + PATCH_TAG: ${{ needs.release.outputs.patch-tag }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: false + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + + build-arm64: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.release.outputs.release-created == 'true' }} + needs: + - expose-vars + - release + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: ${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} + MAJOR_TAG: ${{ needs.release.outputs.major-tag }} + MINOR_TAG: ${{ needs.release.outputs.minor-tag }} + PATCH_TAG: ${{ needs.release.outputs.patch-tag }} + BUILD_AMD64: false + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + + update-chart: + runs-on: ubuntu-latest + needs: + - expose-vars + - release + - build-amd64 + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: helm-charts + - name: Trigger helm-charts update + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + run: | + gh workflow --repo cloud-pi-native/helm-charts run update-chart.yaml -f APP_VERSION=${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} -f CHART_NAME=dso-console diff --git a/.jjconflict-base-0/.github/workflows/workflow-create-preview-comment.yml b/.jjconflict-base-0/.github/workflows/workflow-create-preview-comment.yml new file mode 100644 index 0000000000..e91b3731b1 --- /dev/null +++ b/.jjconflict-base-0/.github/workflows/workflow-create-preview-comment.yml @@ -0,0 +1,43 @@ +name: Add preview comment + +permissions: + pull-requests: write + +on: + pull_request: + types: + - labeled + branches: + - "**" + +jobs: + preview-comment: + name: Add comment with preview infos + if: contains(github.event.pull_request.labels.*.name, 'preview') + runs-on: ubuntu-latest + steps: + - name: Generate app url + id: generate-url + run: | + echo "CONSOLE_URL=$(echo "${{ vars.ARGOCD_CONSOLE_URL_TEMPLATE }}" | sed 's|<pr_number>|${{ github.event.number }}|g')" >> $GITHUB_OUTPUT + + - name: Add comment on Merge Request + id: add-comment-on-merge-request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MERGE_REQUEST_ID: ${{ github.event.pull_request.number }} + COMMENT: | + 🤖 Hey ! + + A preview of the application is available at : ${{ steps.generate-url.outputs.CONSOLE_URL }} + + *Please be patient, deployment may take a few minutes.* + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.MERGE_REQUEST_ID, + body: process.env.COMMENT, + }); diff --git a/.jjconflict-base-0/.github/workflows/workflow-merge-queue.yml b/.jjconflict-base-0/.github/workflows/workflow-merge-queue.yml new file mode 100644 index 0000000000..cca23047d1 --- /dev/null +++ b/.jjconflict-base-0/.github/workflows/workflow-merge-queue.yml @@ -0,0 +1,166 @@ +name: Merge Queue + +permissions: + contents: read + # Needed for build job + packages: write + # Needed for build-label job + pull-requests: write + +on: + workflow_dispatch: + inputs: + PR_NUMBER: + description: Pull request number to run checks for + required: true + type: string + merge_group: + types: [checks_requested] + +env: + NODE_VERSION: 24.13.1 + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: false + USE_QEMU: false + MERGE_GROUP_HEAD_SHA: "${{ github.event.merge_group.head_sha || github.sha }}" + MERGE_GROUP_HEAD_REF: "${{ github.event.merge_group.head_ref || github.ref_name }}" + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.apps }} + packages: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.packages }} + e2e: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.e2e }} + ci: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.ci }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + if: ${{ github.event_name != 'workflow_dispatch' }} + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + ci: + - '.github/workflows/**' + e2e: + - 'playwright/**' + + expose-vars: + runs-on: ubuntu-latest + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + MERGE_GROUP_HEAD_REF: ${{ env.MERGE_GROUP_HEAD_REF }} + MERGE_GROUP_HEAD_SHA: ${{ env.MERGE_GROUP_HEAD_SHA }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + lint: + uses: ./.github/workflows/job-lint.yml + needs: + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + + unit-tests: + uses: ./.github/workflows/job-tests-unit.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + secrets: + SONAR_HOST_URL: "${{ secrets.SONAR_HOST_URL }}" + SONAR_TOKEN: "${{ secrets.SONAR_TOKEN }}" + SONAR_PROJECT_KEY: "${{ secrets.SONAR_PROJECT_KEY }}" + + build: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + PR_NUMBER: ${{ github.event.inputs.PR_NUMBER || github.event.number || '' }} + secrets: + ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }} + + build-label: + uses: ./.github/workflows/job-label.yml + needs: + - expose-vars + - build + with: + CONF_PATH: ./.github/labeler/build.yml + + playwright-tests: + uses: ./.github/workflows/job-playwright.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + - build + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + + scan-vuln: + uses: ./.github/workflows/job-scan.yml + needs: + - expose-vars + - build + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + + # Workaround for required status check in protection branches (see. https://github.com/orgs/community/discussions/13690) + all-jobs-passed: + name: Check jobs status + runs-on: ubuntu-latest + if: ${{ always() }} + needs: + - path-filter + - expose-vars + - lint + - unit-tests + - build + - playwright-tests + - scan-vuln + steps: + - name: Check status of all required jobs + run: |- + NEEDS_CONTEXT='${{ toJson(needs) }}' + JOB_IDS=$(echo "$NEEDS_CONTEXT" | jq -r 'keys[]') + for JOB_ID in $JOB_IDS; do + RESULT=$(echo "$NEEDS_CONTEXT" | jq -r ".[\"$JOB_ID\"].result") + echo "$JOB_ID job result: $RESULT" + if [[ $RESULT != "success" && $RESULT != "skipped" ]]; then + echo "***" + echo "Error: The $JOB_ID job did not pass." + exit 1 + fi + done + echo "All jobs passed or were skipped." diff --git a/.jjconflict-base-0/.github/workflows/workflow-post-merge.yml b/.jjconflict-base-0/.github/workflows/workflow-post-merge.yml new file mode 100644 index 0000000000..da61baa341 --- /dev/null +++ b/.jjconflict-base-0/.github/workflows/workflow-post-merge.yml @@ -0,0 +1,56 @@ +name: Post-merge + +on: + push: + branches: + - main + +env: + NODE_VERSION: 24.13.1 + +permissions: + contents: read + id-token: write + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ steps.filter.outputs.apps }} + packages: ${{ steps.filter.outputs.packages }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + + expose-vars: + runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.draft }} + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + npm-publish: + uses: ./.github/workflows/job-npm.yml + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + PUBLISH_PACKAGES: true + PUBLISH_PLUGINS: true + CHECK_CHANGES: true + # Deprecated variable: we never publish the apps on NPM registry + PUBLISH_APPS: false diff --git a/.jjconflict-base-0/.gitignore b/.jjconflict-base-0/.gitignore new file mode 100644 index 0000000000..0d127c9b66 --- /dev/null +++ b/.jjconflict-base-0/.gitignore @@ -0,0 +1,77 @@ +# code +.vscode/* +!.vscode/vue-dsfr.code-snippets +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + + +# modules +node_modules +**/node_modules + +# env +.env +.env.ci +.env.test +.env.prod +.env.int +.env.integ +.env.codespace +.env.docker + +# init-db +apps/server/src/init/db/imports/data + +# act +**/act/artifacts +**/act/docker/registry/auth +**/act/docker/registry/data + +# vitest +coverage +apps/client/vite.config.*.timestamp-*.mjs +apps/server/vite.config.*.timestamp-*.mjs + +# sonarqube +.scannerwork + +# keycloak +keycloak/data + +# turbo +.turbo + +# kube config +.kubeconfig + +# dist +dist +build +components.d.ts +*.tsbuildinfo + +# types +types/ + +# plugins +apps/server/src/plugins/external + +# helm +charts/ + +# integration helm values +**/env/dso-values-integ.yaml +**/env/kind-values.yml + +# tmp dev files +ci/kind/configs/rendered + +external-plugins + +# Playwright +playwright/test-results/ +playwright/playwright-report/ +playwright/blob-report/ +playwright/playwright/.cache/ diff --git a/.jjconflict-base-0/.husky/commit-msg b/.jjconflict-base-0/.husky/commit-msg new file mode 100755 index 0000000000..8761c9bac9 --- /dev/null +++ b/.jjconflict-base-0/.husky/commit-msg @@ -0,0 +1 @@ +pnpx commitlint --edit ${1} diff --git a/.jjconflict-base-0/.lintstagedrc b/.jjconflict-base-0/.lintstagedrc new file mode 100644 index 0000000000..741d5e9ca3 --- /dev/null +++ b/.jjconflict-base-0/.lintstagedrc @@ -0,0 +1,8 @@ +{ + "**/apps/client/**/*.{vue,css}": "pnpm --filter=@cpn-console/client run lint:style --", + "**/apps/client/**/*": "pnpm --filter=@cpn-console/client run lint:ts --", + "**/apps/server/**/*": "pnpm --filter=@cpn-console/server run lint --", + "**/packages/**/*": "pnpm --filter=./packages/* run lint --", + "**/plugins/**/*": "pnpm --filter=./plugins/* run lint --", + "**/*": "pnpm run lint:root --" +} diff --git a/.jjconflict-base-0/.pinact.yaml b/.jjconflict-base-0/.pinact.yaml new file mode 100644 index 0000000000..3b20588ab3 --- /dev/null +++ b/.jjconflict-base-0/.pinact.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/pinact/refs/heads/main/json-schema/pinact.json +# pinact - https://github.com/suzuki-shunsuke/pinact +version: 3 +# files: +# - pattern: action.yaml +# - pattern: */action.yaml + +# separator: " # " + +ignore_actions: +# - name: slsa-framework/slsa-github-generator/\.github/workflows/generator_generic_slsa3\.yml +# ref: v\d+\.\d+\.\d+ +# - name: actions/.* +# ref: main +# - name: suzuki-shunsuke/.* +# ref: release-.* diff --git a/.jjconflict-base-0/.release-please-manifest.json b/.jjconflict-base-0/.release-please-manifest.json new file mode 100644 index 0000000000..18953d7442 --- /dev/null +++ b/.jjconflict-base-0/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "9.22.2" +} diff --git a/.jjconflict-base-0/.vscode/extensions.json b/.jjconflict-base-0/.vscode/extensions.json new file mode 100644 index 0000000000..3795e131e1 --- /dev/null +++ b/.jjconflict-base-0/.vscode/extensions.json @@ -0,0 +1,30 @@ +{ + "recommendations": [ + // Cross-editor config + "editorconfig.editorconfig", + // Git + "mhutchie.git-graph", + "eamodio.gitlens", + // Node / npm + "christian-kohler.npm-intellisense", + // JavaScript + "dbaeumer.vscode-eslint", + // Vue.js + "vue.volar", + "vue.vscode-typescript-vue-plugin", + // Vitejs + "antfu.vite", + "antfu.browse-lite", + // Firefox Debugger + "firefox-devtools.vscode-firefox-debug", + // CSS + "csstools.postcss", + "stylelint.vscode-stylelint", + // YAML + "redhat.vscode-yaml", + // Makdown + "yzhang.markdown-all-in-one", + // Kubernetes + "ms-kubernetes-tools.vscode-kubernetes-tools" + ] +} diff --git a/.jjconflict-base-0/.vscode/launch.json b/.jjconflict-base-0/.vscode/launch.json new file mode 100644 index 0000000000..fbfb2f1013 --- /dev/null +++ b/.jjconflict-base-0/.vscode/launch.json @@ -0,0 +1,326 @@ +{ + // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. + // Pointez pour afficher la description des attributs existants. + // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Server (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server", + "run", + "integ" + ], + "envFile": "${workspaceFolder}/apps/server/.env.integ", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server", + "run", + "dev" + ], + "envFile": "${workspaceFolder}/apps/server/.env", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Infra (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "run", + "integ" + ], + "console": "internalConsole", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Infra (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "run", + "dev" + ], + "console": "internalConsole", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server NestJS (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server-nestjs", + "run", + "start:dev" + ], + "envFile": "${workspaceFolder}/apps/server-nestjs/.env.integ", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server NestJS (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server-nestjs", + "run", + "start:dev" + ], + "envFile": "${workspaceFolder}/apps/server-nestjs/.env", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Attach Server", + "type": "node", + "request": "attach", + "port": 9229, + "address": "127.0.0.1", + "localRoot": "${workspaceFolder}/apps/server", + "remoteRoot": "/app/apps/server", + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ], + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Chrome) (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "integ", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env.integ", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Chrome)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Chrome) (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "dev", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Chrome)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Firefox) (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "integ", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env.integ", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Firefox)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Firefox) (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "dev", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Firefox)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Launch Client (Chrome)", + "type": "chrome", + "request": "launch", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}/apps/client", + "presentation": { + "hidden": true + } + }, + { + "name": "Launch Client (Firefox)", + "type": "firefox", + "request": "launch", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}/apps/client", + "presentation": { + "hidden": true + } + } + ], + "compounds": [ + { + "name": "Launch (Chrome) (Integ)", + "configurations": [ + "Launch Infra (Integ)", + "Launch Server (Integ)", + "Launch Server NestJS (Integ)", + "Serve Client (Chrome) (Integ)" + ] + }, + { + "name": "Launch (Chrome) (Dev)", + "configurations": [ + "Launch Infra (Dev)", + "Launch Server (Dev)", + "Launch Server NestJS (Dev)", + "Serve Client (Chrome) (Dev)" + ] + }, + { + "name": "Launch (Firefox) (Integ)", + "configurations": [ + "Launch Infra (Integ)", + "Launch Server (Integ)", + "Launch Server NestJS (Integ)", + "Serve Client (Firefox) (Integ)" + ] + }, + { + "name": "Launch (Firefox) (Dev)", + "configurations": [ + "Launch Infra (Dev)", + "Launch Server (Dev)", + "Launch Server NestJS (Dev)", + "Serve Client (Firefox) (Dev)" + ] + } + ] +} diff --git a/.jjconflict-base-0/.vscode/settings.json b/.jjconflict-base-0/.vscode/settings.json new file mode 100644 index 0000000000..1b93a34e04 --- /dev/null +++ b/.jjconflict-base-0/.vscode/settings.json @@ -0,0 +1,120 @@ +{ + // visuals + "window.autoDetectColorScheme": true, + "editor.fontLigatures": "'ss01', 'ss02', 'ss03', 'ss06', 'zero'", + "editor.cursorBlinking": "phase", + "editor.suggestSelection": "first", + "editor.wordWrap": "on", + "editor.bracketPairColorization.enabled": true, + "editor.tabSize": 2, + "search.exclude": { + "**/.git": true, + "**/.nuxt": true, + "**/.pnpm": true, + "**/.yarn": true, + "**/.turbo": true, + "**/dist/**": true, + "**/out/**": true, + "**/logs": true, + "**/node_modules": true, + "**/package-lock.json": true, + "**/pnpm-lock.yaml": true, + "**/yarn.lock": true + }, + // general + "editor.unicodeHighlight.invisibleCharacters": false, + "workbench.startupEditor": "none", + // git + "git.autofetch": true, + "git.untrackedChanges": "separate", + // github + "githubPullRequests.pullBranch": "never", + // eslint + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "vue", + "html", + "markdown", + "json", + "jsonc", + "yaml", + "github-actions-workflow", + "toml", + "xml", + "gql", + "graphql", + "astro", + "svelte", + "css", + "less", + "scss", + "pcss", + "postcss" + ], + // stylelint + "css.validate": true, + "less.validate": true, + "scss.validate": true, + "files.associations": { + "*.css": "css" + }, + // languages + "[javascript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "javascript.preferences.quoteStyle": "single", + "javascript.preferences.importModuleSpecifierEnding": "js", + "javascript.suggest.completeJSDocs": true, + "javascript.suggest.jsdoc.generateReturns": true, + "[typescript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "typescript.preferences.quoteStyle": "single", + "typescript.preferences.importModuleSpecifierEnding": "js", + "typescript.suggest.completeJSDocs": true, + "typescript.suggest.jsdoc.generateReturns": true, + "[vue]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[css]": { + "editor.defaultFormatter": "stylelint.vscode-stylelint" + }, + "[html]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[markdown]": { + "editor.quickSuggestions": { + "other": true, + "comments": true, + "strings": true + }, + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.defaultFormatter": "yzhang.markdown-all-in-one" + }, + // conventional commits + "conventionalCommits.showNewVersionNotes": false, + // kubernetes + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "vscode-kubernetes.log-viewer.follow": true, + "vscode-kubernetes.log-viewer.timestamp": false, + "vscode-kubernetes.log-viewer.since": -1, + "vscode-kubernetes.log-viewer.tail": -1, + "vscode-kubernetes.log-viewer.destination": "Webview", + "vscode-kubernetes.log-viewer.wrap": true, + // sonarqube + "sonarlint.connectedMode.project": { + "connectionId": "https-sonarqube-fabrique-numerique-fr", + "projectKey": "cloud-pi-native_console_AYsa46O31ebDtzs-pYyZ" + }, + "prisma-smart-formatter.typescript.defaultFormatter": "dbaeumer.vscode-eslint", + "prisma-smart-formatter.prisma.defaultFormatter": "Prisma.prisma" +} diff --git a/.jjconflict-base-0/.vscode/vue-dsfr.code-snippets b/.jjconflict-base-0/.vscode/vue-dsfr.code-snippets new file mode 100644 index 0000000000..2533f522d8 --- /dev/null +++ b/.jjconflict-base-0/.vscode/vue-dsfr.code-snippets @@ -0,0 +1,823 @@ +{ + "template": { + "scope": "vue", + "prefix": "template", + "body": [ + "<template>", + " $1", + "</template>" + ], + "description": "Create <template> block" + }, + "script": { + "scope": "vue", + "prefix": "script", + "body": [ + "<script>", + "import { defineComponent } from 'vue';", + "", + "export default defineComponent({", + " $2", + "});", + "</script>" + ], + "description": "Create <script> block" + }, + "script setup": { + "scope": "vue", + "prefix": "script setup", + "body": [ + "<script setup>", + "defineProps({", + " $1", + "});", + "</script>" + ], + "description": "Create <script setup> + <script> blocks" + }, + "style": { + "scope": "vue", + "prefix": "scoped-style", + "body": [ + "<style scoped>", + "$1", + "</style>" + ], + "description": "Create <style> block" + }, + "v-for": { + "scope": "html", + "prefix": "v-for", + "body": [ + "<template v-for=\"$1 in $2\">", + " $3", + "</template>" + ], + "description": "Create html tag with v-for" + }, + "v-if": { + "scope": "html", + "prefix": "v-if", + "body": [ + "<template v-if=\"$1\">", + " $2", + "</template>" + ], + "description": "Create html tag with v-if" + }, + "v-else-if": { + "scope": "html", + "prefix": "v-else-if", + "body": [ + "<template v-else-if=\"$1\">", + " $2", + "</template>" + ], + "description": "Create html tag with v-else-if" + }, + "v-else": { + "scope": "html", + "prefix": "v-else", + "body": [ + "<template v-else>", + " $1", + "</template>" + ], + "description": "Create html tag with v-else" + }, + "DsfrAccordion": { + "scope": "html", + "prefix": "DsfrAccordion", + "body": [ + "<DsfrAccordion", + " :expanded-id=\"${1:expandedId}\"", + " :title=\"${2:accordionTitle}\"", + ">", + " $3", + "</DsfrAccordion>", + ], + "description": "Create DsfrAccordion boilerplate" + }, + "DsfrAccordionsGroup": { + "scope": "html", + "prefix": "DsfrAccordionsGroup", + "body": [ + "<DsfrAccordionsGroup>", + " <li>", + " <DsfrAccordion", + " :expanded-id=\"${1:expandedId}\"", + " :title=\"${2:accordionTitle}\"", + " @expand=\"expandedId = $event\"", + " >", + " </li>", + "</DsfrAccordionsGroup>", + ], + "description": "Create DsfrAccordionsGroup boilerplate" + }, + "DsfrAlert": { + "scope": "html", + "prefix": "DsfrAlert", + "body": [ + "<DsfrAlert", + " :title=\"${1:alertTitle}\"", + " :description=\"${2:alertDescription}\"", + " :type=\"${3:alertType}\"", + " :closed=\"${4:!openAlert}\"", + " ${5:closeable}", + " @close=\"${6:closeAlert()}\"", + "/>", + ], + "description": "Create DsfrAlert boilerplate" + }, + "DsfrBadge": { + "scope": "html", + "prefix": "DsfrBadge", + "body": [ + "<DsfrBadge", + " :label=\"${1}\"", + " :type=\"${2:success}\"", + "/>", + ], + "description": "Create DsfrBadge boilerplate" + }, + "DsfrBreadcrumb": { + "scope": "html", + "prefix": "DsfrBreadcrumb", + "body": [ + "<DsfrBreadcrumb", + " :links=\"${1:links}\"", + "/>", + ], + "description": "Create DsfrBreadcrumb boilerplate" + }, + "DsfrButton": { + "scope": "html", + "prefix": "DsfrButton", + "body": [ + "<DsfrButton", + " type=\"${1:buttonType}\"", + " :label=\"${2:buttonLabel}\"", + " ${3:secondary}", + " @click=\"${4:onClick()}\"", + "/>", + ], + "description": "Create DsfrButton boilerplate" + }, + "DsfrButtonGroup": { + "scope": "html", + "prefix": "DsfrButtonGroup", + "body": [ + "<DsfrButtonGroup", + " :buttons=\"${1:buttons}\"", + " :label=\"${2:buttonLabel}\"", + " ${3:secondary}", + " @click=\"${4:onClick()}\"", + "/>", + ], + "description": "Create DsfrButtonGroup boilerplate" + }, + "DsfrCallout": { + "scope": "html", + "prefix": "DsfrCallout", + "body": [ + "<DsfrCallout", + " :title=\"${1:title}\"", + " :content=\"${2:content}\"", + " :button=\"${3:button}\"", + " :icon=\"${4:icon}\"", + "/>", + ], + "description": "Create DsfrCallout boilerplate" + }, + "DsfrCard": { + "scope": "html", + "prefix": "DsfrCard", + "body": [ + "<DsfrCard", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :detail=\"${3:detail}\"", + " :link=\"${4:link}\"", + " :alt-img=\"${5:altImg}\"", + " :img-src=\"${6:imgSrc}\"", + "/>", + ], + "description": "Create DsfrCard boilerplate" + }, + "DsfrCheckbox": { + "scope": "html", + "prefix": "DsfrCheckbox", + "body": [ + "<DsfrCheckbox", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + " :name=\"${3:name}\"", + " :modelValue=\"${4:checked}\"", + " @update:model-value=\"updateChecked($event)\"", + "/>", + ], + "description": "Create DsfrCheckbox boilerplate" + }, + "DsfrCheckboxSet": { + "scope": "html", + "prefix": "DsfrCheckboxSet", + "body": [ + "<DsfrCheckboxSet", + " v-model=\"${1:selectedCheckbox}\"", + " :legend=\"${2:legend}\"", + " :options=\"${3:options}\"", + "/>", + ], + "description": "Create DsfrCheckboxSet boilerplate" + }, + "DsfrConsent": { + "scope": "html", + "prefix": "DsfrConsent", + "body": [ + "<DsfrConsent", + " :url=\"${1:url}\"", + " @accept-all=\"${2:onAcceptAll()}\"", + " @refuse-all=\"${3:onRefuseAll()}\"", + " @customize=\"${3:onCustomize()}\"", + "/>", + ], + "description": "Create DsfrConsent boilerplate" + }, + "DsfrFieldset": { + "scope": "html", + "prefix": "DsfrFieldset", + "body": [ + "<DsfrFieldset", + " :legend=\"${1:legend}\"", + " :hint=\"${2:hint}\"", + " :legend-class=\"${1:legendClass}\"", + " :hint-class=\"${1:hintClass}\"", + " :legend-id=\"${1:legendId}\"", + "/>", + ], + "description": "Create DsfrFieldset boilerplate" + }, + "DsfrFileUpload": { + "scope": "html", + "prefix": "DsfrFileUpload", + "body": [ + "<DsfrFileUpload", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + "/>", + ], + "description": "Create DsfrFileUpload boilerplate" + }, + "DsfrFollow": { + "scope": "html", + "prefix": "DsfrFollow", + "body": [ + "<DsfrFollow", + " :networks=\"${1:networks}\"", + " :newsletter-data=\"${2:newsletterData}\"", + "/>", + ], + "description": "Create DsfrFollow boilerplate" + }, + "DsfrFranceConnect": { + "scope": "html", + "prefix": "DsfrFranceConnect", + "body": [ + "<DsfrFranceConnect ${1:secure} />", + ], + "description": "Create DsfrFranceConnect boilerplate" + }, + "DsfrNewsLetter": { + "scope": "html", + "prefix": "DsfrNewsLetter", + "body": [ + "<DsfrNewsLetter", + " v-model:email=\"${1:email}\"", + " :title=\"${2:title}\"", + " :description=\"${3:description}\"", + " :labelEmail=\"${4:labelEmail}\"", + " :inputTitle=\"${5:inputTitle}\"", + " :placeholder=\"${6:placeholder}\"", + " :hintText=\"${7:hintText}\"", + " :buttonText=\"${8:buttonText}\"", + "/>", + ], + "description": "Create DsfrNewsLetter boilerplate" + }, + "DsfrSocialNetworks": { + "scope": "html", + "prefix": "DsfrSocialNetworks", + "body": [ + "<DsfrSocialNetworks", + " :networks=\"${1:networks}\"", + "/>", + ], + "description": "Create DsfrSocialNetworks boilerplate" + }, + "DsfrFooter": { + "scope": "html", + "prefix": "DsfrFooter", + "body": [ + "<DsfrFooter", + " :a11y-compliance=\"${1:a11yCompliance}\"", + " :logo-text=\"${2:logoText}\"", + " :legal-link=\"${3:legalLink}\"", + " :personal-data-link=\"${4:personalDataLink}\"", + " :cookies-link=\"${5:cookiesLink}\"", + " :a11y-compliance-link=\"${6:a11yComplianceLink}\"", + " :desc-text=\"${7:descText}\"", + " :home-link=\"${8:homeLink}\"", + " :partners=\"${9:partners}\"", + " :ecosystem-links=\"${10:ecosystemLinks}\"", + "/>", + ], + "description": "Create DsfrFooter boilerplate" + }, + "DsfrHeader": { + "scope": "html", + "prefix": "DsfrHeader", + "body": [ + "<DsfrHeader", + " :service-title=\"${1:serviceTitle}\"", + " :service-description=\"${2:serviceDescription}\"", + " :logo-text=\"${3:logoText}\"", + " :quick-links=\"${4:quickLinks}\"", + " ${5:show-search}", + "/>", + ], + "description": "Create DsfrHeader boilerplate" + }, + "DsfrHighlight": { + "scope": "html", + "prefix": "DsfrHighlight", + "body": [ + "<DsfrHighlight", + " :text=\"${1:text}\"", + "/>", + ], + "description": "Create DsfrHighlight boilerplate" + }, + "DsfrInput": { + "scope": "html", + "prefix": "DsfrInput", + "body": [ + "<DsfrInput", + " v-model=\"${1:inputValue}\"", + " type=\"${2:inputType}\"", + " :label=\"${3:inputLabel}\"", + " ${4:label-visible}", + " :placeholder=\"${5:inputPlaceholder}\"", + "/>", + ], + "description": "Create DsfrInput boilerplate" + }, + "DsfrLogo": { + "scope": "html", + "prefix": "DsfrLogo", + "body": [ + "<DsfrLogo", + " logo-text=\"$1\"", + "/>", + ], + "description": "Create DsfrLogo boilerplate" + }, + "DsfrPicture": { + "scope": "html", + "prefix": "DsfrPicture", + "body": [ + "<DsfrPicture", + " :src=\"${1:src}\"", + " :alt=\"${2:alt}\"", + " :title=\"${3:title}\"", + " :legend=\"${4:legend}\"", + "/>", + ], + "description": "Create DsfrPicture boilerplate" + }, + "DsfrVideo": { + "scope": "html", + "prefix": "DsfrVideo", + "body": [ + "<DsfrVideo", + " :src=\"${1:src}\"", + " :legend=\"${2:legend}\"", + "/>", + ], + "description": "Create DsfrVideo boilerplate" + }, + "DsfrModal": { + "scope": "html", + "prefix": "DsfrModal", + "body": [ + "<DsfrButton", + " label=\"${1:Ouvre la modale}\"", + " ref=\"modalOrigin\"", + " @click=\"${2:open()}\"", + "/>", + "<DsfrModal", + " :opened=\"${3:opened}\"", + " :title=\"${4:title}\"", + " :origin=\"$refs.modalOrigin\"", + ">", + " $5", + "</DsfrModal>", + ], + "description": "Create DsfrModal boilerplate" + }, + "DsfrNavigation": { + "scope": "html", + "prefix": "DsfrNavigation", + "body": [ + "<DsfrNavigation", + " :nav-items=\"${1:navItems}\"", + "/>", + ], + "description": "Create DsfrNavigation boilerplate" + }, + "DsfrNavigationItem": { + "scope": "html", + "prefix": "DsfrNavigationItem", + "body": [ + "<DsfrNavigationItem :active=\"${1:active}\">", + " $2", + "</DsfrNavigationItem>", + ], + "description": "Create DsfrNavigationItem boilerplate" + }, + "DsfrNavigationMegaMenu": { + "scope": "html", + "prefix": "DsfrNavigationMegaMenu", + "body": [ + "<DsfrNavigationMegaMenu", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :link=\"${3:link}\"", + " :menus=\"${4:menus}\"", + " :expanded-id=\"${5:expandedId}\"", + " :@click=\"${6:toggle($event)}\"", + ">", + " $7", + "</DsfrNavigationMegaMenu>", + ], + "description": "Create DsfrNavigationMegaMenu boilerplate" + }, + "DsfrNavigationMegaMenuCategory": { + "scope": "html", + "prefix": "DsfrNavigationMegaMenuCategory", + "body": [ + "<DsfrNavigationMegaMenuCategory", + " :title=\"${1:title}\"", + " :links=\"${2:links}\"", + "/>", + ], + "description": "Create DsfrNavigationMegaMenuCategory boilerplate" + }, + "DsfrNavigationMenu": { + "scope": "html", + "prefix": "DsfrNavigationMenu", + "body": [ + "<DsfrNavigationMenu", + " :title=\"${1:title}\"", + " :links=\"${2:links}\"", + " :expanded-id=\"${3:expandedId}\"", + " :@click=\"${4:toggle($event)}\"", + "/>", + ], + "description": "Create DsfrNavigationMenu boilerplate" + }, + "DsfrNavigationMenuItem": { + "scope": "html", + "prefix": "DsfrNavigationMenuItem", + "body": [ + "<DsfrNavigationMenuItem", + " :active=\"${1:active}\"", + ">", + "</DsfrNavigationMenuItem>", + ], + "description": "Create DsfrNavigationMenuItem boilerplate" + }, + "DsfrNavigationMenuLink": { + "scope": "html", + "prefix": "DsfrNavigationMenuLink", + "body": [ + "<DsfrNavigationMenuLink", + " :to=\"${1:to}\"", + " :text=\"${2:text}\"", + "/>", + ], + "description": "Create DsfrNavigationMenuLink boilerplate" + }, + "DsfrPagination": { + "scope": "html", + "prefix": "DsfrPagination", + "body": [ + "<DsfrPagination", + " :pages=\"${1:pages}\"", + " v-model:current-page=\"${2:currentPage}\"", + "/>", + ], + "description": "Create DsfrPagination boilerplate" + }, + "DsfrQuote": { + "scope": "html", + "prefix": "DsfrQuote", + "body": [ + "<DsfrQuote", + " :quote=\"${1:quote}\"", + " :author=\"${2:author}\"", + " :details=\"${3:details}\"", + " :illustration=\"${4:illustration}\"", + " :source=\"${5:source}\"", + " :sourceUrl=\"${6:sourceUrl}\"", + " :quoteImage=\"${7:quoteImage}\"", + "/>", + ], + "description": "Create DsfrQuote boilerplate" + }, + "DsfrRadioButton": { + "scope": "html", + "prefix": "DsfrRadioButton", + "body": [ + "<DsfrRadioButton", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + " :name=\"${3:name}\"", + " :value=\"${4:value}\"", + " :model-value=\"${5:modelValue}\"", + " @update:model-value=\"${6:updateCheckedValue($event)}\"", + "/>", + ], + "description": "Create DsfrRadioButton boilerplate" + }, + "DsfrRadioButtonSet": { + "scope": "html", + "prefix": "DsfrRadioButtonSet", + "body": [ + "<DsfrRadioButtonSet", + " v-model=\"${1:modelValue}\"", + " :legend=\"${2:legend}\"", + " name=\"${2:radio-set}\"", + " :options=\"${4:options}\"", + " @update:model-value=\"${6:updateCheckedValue($event)}\"", + "/>", + ], + "description": "Create DsfrRadioButtonSet boilerplate" + }, + "DsfrSearchBar": { + "scope": "html", + "prefix": "DsfrSearchBar", + "body": [ + "<DsfrSearchBar", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :placeholder=\"${3:placeholder}\"", + " ${4:label-visible}", + " :button-text=\"${5:buttonText}\"", + "/>", + ], + "description": "Create DsfrSearchBar boilerplate" + }, + "DsfrSelect": { + "scope": "html", + "prefix": "DsfrSelect", + "body": [ + "<DsfrSelect", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :options=\"${3:options}\"", + "/>", + ], + "description": "Create DsfrSelect boilerplate" + }, + "DsfrShare": { + "scope": "html", + "prefix": "DsfrShare", + "body": [ + "<DsfrShare", + " :title=\"${1:title}\"", + " :networks=\"${2:networks}\"", + " :mail=\"${3:mail}\"", + " :copy-label=\"${4:copyLabel}\"", + "/>", + ], + "description": "Create DsfrShare boilerplate" + }, + "DsfrSideMenu": { + "scope": "html", + "prefix": "DsfrSideMenu", + "body": [ + "<DsfrSideMenu", + " :heading-title=\"${1:headingTitle}\"", + " :button-label=\"${2:buttonLabel}\"", + " :menu-items=\"${3:menuItems}\"", + " @toggle-expand=\"${4:toggleExpand($event)}\"", + "/>", + ], + "description": "Create DsfrSideMenu boilerplate" + }, + "DsfrSideMenuButton": { + "scope": "html", + "prefix": "DsfrSideMenuButton", + "body": [ + "<DsfrSideMenuButton", + " :expanded=\"${1:expanded}\"", + " :button-label=\"${2:buttonLabel}\"", + " :control-id=\"${3:controlId}\"", + " @toggle-expand=\"${4:toggleExpand($event)}\"", + "/>", + ], + "description": "Create DsfrSideMenuButton boilerplate" + }, + "DsfrSideMenuLink": { + "scope": "html", + "prefix": "DsfrSideMenuLink", + "body": [ + "<DsfrSideMenuLink", + " :active=\"${1:active}\"", + " :to=\"${2:to}\"", + "/>", + ], + "description": "Create DsfrSideMenuLink boilerplate" + }, + "DsfrSideMenuList": { + "scope": "html", + "prefix": "DsfrSideMenuList", + "body": [ + "<DsfrSideMenuList", + " :menu-items=\"${1:menuItems}\"", + " @toggle-expand=\"${2:toggleExpand}\"", + "/>", + ], + "description": "Create DsfrSideMenuList boilerplate" + }, + "DsfrSideMenuListItem": { + "scope": "html", + "prefix": "DsfrSideMenuListItem", + "body": [ + "<DsfrSideMenuListItem", + " :active=\"${1:active}\"", + ">", + " $2", + "</DsfrSideMenuListItem>", + ], + "description": "Create DsfrSideMenuListItem boilerplate" + }, + "DsfrSkipLinks": { + "scope": "html", + "prefix": "DsfrSkipLinks", + "body": [ + "<DsfrSkipLinks", + " :links=\"${1:links}\"", + "/>", + ], + "description": "Create DsfrSkipLinks boilerplate" + }, + "DsfrTable": { + "scope": "html", + "prefix": "DsfrTable", + "body": [ + "<DsfrTable", + " :title=\"${1:title}\"", + " :headers=\"${2:headers}\"", + " :rows=\"${3:rows}\"", + "/>", + ], + "description": "Create DsfrTable boilerplate" + }, + "DsfrTableCell": { + "scope": "html", + "prefix": "DsfrTableCell", + "body": [ + "<DsfrTableCell", + " :field=\"${1:field}\"", + "/>", + ], + "description": "Create DsfrTableCell boilerplate" + }, + "DsfrTableHeader": { + "scope": "html", + "prefix": "DsfrTableHeader", + "body": [ + "<DsfrTableHeader", + " :header=\"${1:header}\"", + " :header-attrs=\"${2:headerAttrs}\"", + "/>", + ], + "description": "Create DsfrTableHeader boilerplate" + }, + "DsfrTableHeaders": { + "scope": "html", + "prefix": "DsfrTableHeaders", + "body": [ + "<DsfrTableHeaders", + " :headers=\"${1:headers}\"", + "/>", + ], + "description": "Create DsfrTableHeaders boilerplate" + }, + "DsfrTableRow": { + "scope": "html", + "prefix": "DsfrTableRow", + "body": [ + "<DsfrTableRow", + " :row-data=\"${1:rowData}\"", + "/>", + ], + "description": "Create DsfrTableRow boilerplate" + }, + "DsfrTabContent": { + "scope": "html", + "prefix": "DsfrTabContent", + "body": [ + "<DsfrTabContent", + " :panel-id=\"${1:panelId}\"", + " :tab-id=\"${2:tabId}\"", + " :selected=\"${3:selected}\"", + " :asc=\"${4:asc}\"", + ">", + " $5", + "</DsfrTabContent>", + ], + "description": "Create DsfrTabContent boilerplate" + }, + "DsfrTabItem": { + "scope": "html", + "prefix": "DsfrTabItem", + "body": [ + "<DsfrTabItem", + " :tab-id=\"${2:tabId}\"", + " :panel-id=\"${1:panelId}\"", + " :selected=\"${3:selected}\"", + " :icon=\"${4:icon}\"", + ">", + " $5", + "</DsfrTabItem>", + ], + "description": "Create DsfrTabItem boilerplate" + }, + "DsfrTabs": { + "scope": "html", + "prefix": "DsfrTabs", + "body": [ + "<DsfrTabs", + " :tab-list-name=\"${1:tabListName}\"", + " :tab-titles=\"${2:tabTitles}\"", + " :tab-contents=\"${3:tabContents}\"", + "/>", + ], + "description": "Create DsfrTabs boilerplate" + }, + "DsfrTag": { + "scope": "html", + "prefix": "DsfrTag", + "body": [ + "<DsfrTag", + " :label=\"${1:label}\"", + "/>", + ], + "description": "Create DsfrTag boilerplate" + }, + "DsfrTags": { + "scope": "html", + "prefix": "DsfrTags", + "body": [ + "<DsfrTags", + " :tags=\"${1:tags}\"", + "/>", + ], + "description": "Create DsfrTags boilerplate" + }, + "DsfrTile": { + "scope": "html", + "prefix": "DsfrTile", + "body": [ + "<DsfrTile", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :to=\"${3:to}\"", + " :rows=\"${4:rows}\"", + "/>", + ], + "description": "Create DsfrTile boilerplate" + }, + "DsfrTiles": { + "scope": "html", + "prefix": "DsfrTiles", + "body": [ + "<DsfrTiles :tiles=\"${1:tiles}\" />", + ], + "description": "Create DsfrTiles boilerplate" + }, + "DsfrToggleSwitch": { + "scope": "html", + "prefix": "DsfrToggleSwitch", + "body": [ + "<DsfrToggleSwitch", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :hint=\"${2:hint}\"", + "/>", + ], + "description": "Create DsfrToggleSwitch boilerplate" + }, +} \ No newline at end of file diff --git a/.jjconflict-base-0/AGENTS.md b/.jjconflict-base-0/AGENTS.md new file mode 100644 index 0000000000..77866c7703 --- /dev/null +++ b/.jjconflict-base-0/AGENTS.md @@ -0,0 +1,85 @@ +# Console Cloud Pi Native + +pnpm monorepo. Node >= 24, pnpm v11.8 + +## Structure + +- `apps/client` : Vue 3 + Vite + vue-dsfr (French gov design system), Pinia, UnoCSS +- `apps/server` : Fastify 4 + Prisma 6 (PostgreSQL), contract-first API via @ts-rest +- `apps/server-nestjs` : NestJS rewrite (in progress) +- `plugins/*` : argocd, gitlab, harbor, keycloak, kubernetes, nexus, sonarqube, vault +- `packages/shared` : API contracts (@ts-rest), types, permissions (BigInt bitmasks) +- `packages/hooks` : plugin hook system (core of plugin architecture) +- `packages/test-utils`, `packages/eslintconfig`, `packages/tsconfig` +- `playwright/` : E2E tests (dedicated workspace) +- `docker/` : docker-compose files (local, dev, integ, prod, ci) +- `ci/scripts/` : init-env.sh, setup.sh, cleanup.sh, run-tests.sh +- `keycloak/` : DSFR theme and dev realm data + +## Server architecture (Fastify) + +Resource-based organization in `apps/server/src/resources/`. Each resource follows a 3-file pattern: +- `router.ts` : route handlers (auth, permissions, delegates to business) +- `business.ts` : business logic, orchestrates queries + hook calls +- `queries.ts` : Prisma database queries + +API contracts defined in `@cpn-console/shared`, shared with client via @ts-rest. +Auth: Keycloak + Fastify session. Permissions: BigInt bitmasks (`ProjectAuthorized`, `AdminAuthorized`). + +## Plugin / Hook system + +Hook lifecycle: `pre` -> `main` -> `post` (sequential steps, parallel plugin execution). On failure: `revert`. +Plugins are statically imported in `apps/server/src/plugins.ts`, then external plugins dynamically loaded from `/plugins`. +Each plugin: `index.ts` (Plugin interface), `infos.ts` (metadata/config), `functions.ts` (hook handlers). +Plugins use TS module augmentation to extend `ProjectStore` and `Config` interfaces. + +## Database (Prisma) + +Multi-file schema in `apps/server/src/prisma/schema/*.prisma` (project, user, token, admin, topography). +Migrations: standard Prisma Migrate. Major version data migrations in `migrations/v9/`. + +## Environment config + +- Files: `.env`, `.env.docker`, `.env.integ` in `apps/client/`, `apps/server/`, `apps/server-nestjs/` +- Templates: `*-example` suffix (git-tracked), active files gitignored +- Override chain (weakest to strongest): `.env` < `.env.docker` (if DOCKER=true) < `.env.integ` (if INTEGRATION=true) < explicit env vars +- Server loading: `apps/server/src/utils/env.ts` | Client: `apps/client/vite.config.ts` + +## Testing + +- **Vitest**: unit tests everywhere (server, client, packages, plugins) — colocated `*.spec.ts` files +- **Playwright**: E2E in `playwright/` (Chromium + Firefox, parallel) +- Commands: `pnpm test` (all unit), `pnpm playwright:test` + +## Code quality + +- ESLint 9 flat config based on `@antfu/eslint-config` (no Prettier, except server-nestjs) +- Stylelint for CSS/Vue in client +- Husky hooks: pre-commit (lint-staged), commit-msg (commitlint), pre-push (unit tests) +- Conventional commits enforced: `feat`, `fix`, `chore`, `docs`, `refactor`, `revert`, `build` + +## TypeScript + +- Shared base: `packages/tsconfig/tsconfig.base.json` — ESNext, NodeNext, strict, `@/* -> src/*` alias +- Server: extends shared base, uses `ts-patch`/`tspc` for path transform in emitted JS +- Client: does NOT extend shared base, uses `Bundler` module resolution +- server-nestjs: standalone config with `emitDecoratorMetadata` + `experimentalDecorators` + +## Main commands + +- `pnpm dev` : local Docker infra (keycloak, postgres, pgadmin) + server/client +- `pnpm docker:dev` : fully containerized with Docker Compose Watch +- `pnpm docker:integ` / `pnpm integ` : integration mode (remote env) +- `pnpm fullsetup` : full install (deps, prisma generate, build, Docker images) +- `pnpm fullclean` : full cleanup (builds, node_modules, Docker images) + +## Git & Release + +- Branches: `main` (protected) + `hotfix/*` +- Release Please for automated versioning, changelogs, npm publish, Docker images, Helm chart updates +- PR template: `.github/PULL_REQUEST_TEMPLATE.md` + +## Conventions + +- Template env files use `-example` suffix (not `.example`) +- `ci/scripts/init-env.sh` copies `*-example` to active equivalents (non-destructive) diff --git a/.jjconflict-base-0/CHANGELOG.md b/.jjconflict-base-0/CHANGELOG.md new file mode 100644 index 0000000000..642437ef86 --- /dev/null +++ b/.jjconflict-base-0/CHANGELOG.md @@ -0,0 +1,3507 @@ +# Changelog + +## [9.22.2](https://github.com/cloud-pi-native/console/compare/v9.22.1...v9.22.2) (2026-07-24) + + +### Bug Fixes + +* ArgoCD internal url for health check ([9429667](https://github.com/cloud-pi-native/console/commit/942966785e23fca986246948033afe60d40d7232)) + +## [9.22.1](https://github.com/cloud-pi-native/console/compare/v9.22.0...v9.22.1) (2026-07-21) + + +### Bug Fixes + +* **plugins/nexus:** remove invalid cleanup policy from repo provisioning to fix Nexus 3.85+ ([8b99cb6](https://github.com/cloud-pi-native/console/commit/8b99cb6a7807dac1f6ba525a6ee3164f3fe13141)) + +## [9.22.0](https://github.com/cloud-pi-native/console/compare/v9.21.0...v9.22.0) (2026-07-21) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** restrict deployment visibility to console admins ([def61e7](https://github.com/cloud-pi-native/console/commit/def61e76b3a867445c5ddb1c08e0f3af59b188ae)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([52c8173](https://github.com/cloud-pi-native/console/commit/52c8173f5610e83836b661d7472c0fe3ff22f70f)) +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* :construction: make all new cluster external by default ([dcd203f](https://github.com/cloud-pi-native/console/commit/dcd203f3569d93a74e0d627f6cf3eb898d6b338d)) +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* add old-server new Nest.js services to CpinModule ([a17c109](https://github.com/cloud-pi-native/console/commit/a17c109616270115af701366ae8014e05cb41e7c)) +* add server-nestjs README to explain what we are doing ([57a4ca5](https://github.com/cloud-pi-native/console/commit/57a4ca51086b7c7af44a1df83cd272b82eb9a9f8)) +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* apply prettier on old-server ([6a781ac](https://github.com/cloud-pi-native/console/commit/6a781ac80c756924c53e28815064197b63508ff4)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* bump turbo to v2.7.1 ([d955e52](https://github.com/cloud-pi-native/console/commit/d955e52e65ed829053fdb120b217cb2744bd02c2)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** trigger Playwright E2E tests before Cypress E2E tests ([2262f37](https://github.com/cloud-pi-native/console/commit/2262f3717101833217d5963580e07fb4f69e9847)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* **client:** commit auto-imports.d.ts ([4252266](https://github.com/cloud-pi-native/console/commit/42522663edf5151ce7e38c36d4eb5d2d13b24a48)) +* **client:** do not install pnpm twice ([43ff3a3](https://github.com/cloud-pi-native/console/commit/43ff3a3e6690c901d4905236b627016ee8b4f043)) +* **client:** ensure build fails if typing system is not sound ([97297b5](https://github.com/cloud-pi-native/console/commit/97297b58a80bee7167171d39b16404f077fef03b)) +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* **client:** fix types ([131b2b6](https://github.com/cloud-pi-native/console/commit/131b2b64926493d94ba1e07c3501e331d0fcac8c)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* convert cypress dashboard e2e tests to playwright ([2badd91](https://github.com/cloud-pi-native/console/commit/2badd91181d4960d40898cfa01fbcfd0b13667aa)) +* convert plugin manager to NestJS service ([2ae46e3](https://github.com/cloud-pi-native/console/commit/2ae46e38787b3521911786898bee881c23fdb08b)) +* disable tests from nest.js for now ([5137c10](https://github.com/cloud-pi-native/console/commit/5137c10ed9296c761c3d31877bf54a411bbee140)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **e2e:** avoid launching Playwright and Cypress E2E tests at the same time ([1f203a8](https://github.com/cloud-pi-native/console/commit/1f203a8d35d6b1e4e69076f289256c7192a356da)) +* **e2e:** migrate Admin Role tests from Cypress to Playwright ([2112398](https://github.com/cloud-pi-native/console/commit/2112398f85b1ab7a5e332b798f68d4f04894d51d)) +* **e2e:** migrate Duplicate Zone test from Cypress to Playwright ([748ec80](https://github.com/cloud-pi-native/console/commit/748ec80917af7ef34b1f7922a13703706e5e41cc)) +* **e2e:** migrate last Role tests from Cypress to Playwright ([176ea34](https://github.com/cloud-pi-native/console/commit/176ea3481fc5b282497ea82a7562d735b11b325f)) +* **e2e:** migrate last Zone tests from Cypress to Playwright ([dcff338](https://github.com/cloud-pi-native/console/commit/dcff3384e695e95c9d2ae2b08486ce4ca47fa769)) +* **e2e:** migrate Project archive test from Cypress to Playwright ([0567931](https://github.com/cloud-pi-native/console/commit/0567931723cccf2fc54cb5560c6dd00043211f58)) +* **e2e:** migrate repository tests from Cypress to Playwright ([8370b8b](https://github.com/cloud-pi-native/console/commit/8370b8b4adbe5c1b65bc5f45bd50c860beeba96c)) +* **e2e:** migrate some Zone tests from Cypress to Playwright ([425e8c9](https://github.com/cloud-pi-native/console/commit/425e8c9672b276507983b6b0685bcf475bdb7c6c)) +* **e2e:** migrate Token tests from Cypress to Playwright ([86cb815](https://github.com/cloud-pi-native/console/commit/86cb8158285be552a1d0e94ebd30600858b9193a)) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* fix all typescript import errors by massively creating NestJs services ([68b7cb7](https://github.com/cloud-pi-native/console/commit/68b7cb75407dbb8d630dd4a50e4832e97796008a)) +* fix any type in AppService ([627e1e9](https://github.com/cloud-pi-native/console/commit/627e1e9f0cac82bcaa9b3b3a6337c3f2b447e1e5)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) +* **gitlab-plugin:** interalize awaited import to avoid top-level-await errors ([6472c74](https://github.com/cloud-pi-native/console/commit/6472c742c9ec74f834da994db697fcfb7d385c7e)) +* **gitlab:** purge useless file ([2637156](https://github.com/cloud-pi-native/console/commit/263715641844bb7d766730a3de9240531c9f26e3)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **hotfix/revert-server-nestjs-offload:** Release v9.20.1 ([8ebeae7](https://github.com/cloud-pi-native/console/commit/8ebeae7717d0aeac93ce14102a137f4aac62c409)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* initialize nestjs dedicated app ([a533963](https://github.com/cloud-pi-native/console/commit/a53396329455c9c856274f3c419e2ff377e12dba)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) +* **main:** release 9.10.0 ([c87ad31](https://github.com/cloud-pi-native/console/commit/c87ad3114149c4af995be42e130d03efd2e301d9)) +* **main:** release 9.10.1 ([c4b4649](https://github.com/cloud-pi-native/console/commit/c4b464985cfafc7d60246621e9beb82760b73200)) +* **main:** release 9.10.2 ([20a233a](https://github.com/cloud-pi-native/console/commit/20a233af09a3ef918a7b1de36057374dd4817726)) +* **main:** release 9.11.0 ([c03e2ef](https://github.com/cloud-pi-native/console/commit/c03e2ef8c4e448784528ae7dee68edff36e48930)) +* **main:** release 9.11.1 ([7580384](https://github.com/cloud-pi-native/console/commit/7580384525c7cdddbd51e7b0b9b4154ca624b058)) +* **main:** release 9.11.2 ([c7de3a1](https://github.com/cloud-pi-native/console/commit/c7de3a15bd54730655e3a75523c29e9f313a18d9)) +* **main:** release 9.11.3 ([9d2df48](https://github.com/cloud-pi-native/console/commit/9d2df48e3588a819fe5a63467dee773c5e1963c3)) +* **main:** release 9.11.4 ([9c873e1](https://github.com/cloud-pi-native/console/commit/9c873e1b5d1ec3d59b86b6ae19b0fe055f3ed669)) +* **main:** release 9.11.5 ([5fc68de](https://github.com/cloud-pi-native/console/commit/5fc68de5cf2a22f33171f03132798df20701df1b)) +* **main:** release 9.8.1 ([bb94301](https://github.com/cloud-pi-native/console/commit/bb9430196e042847931719ec518d5b9685a67585)) +* **main:** release 9.9.0 ([aa755da](https://github.com/cloud-pi-native/console/commit/aa755dac97ffd563bb0684fe669268b95c6d1362)) +* **main:** Release v9.12.0 ([3a14b03](https://github.com/cloud-pi-native/console/commit/3a14b03a7284ee8ee8a5e9d54d7ba842832c4017)) +* **main:** Release v9.12.0 ([4f6e1a8](https://github.com/cloud-pi-native/console/commit/4f6e1a8d8b630335c89a12b4daf4941e1dcdc32a)) +* **main:** Release v9.13.0 ([4daaa1d](https://github.com/cloud-pi-native/console/commit/4daaa1dce01276f83c6870ffe1c13af096547621)) +* **main:** Release v9.13.0 ([613ba30](https://github.com/cloud-pi-native/console/commit/613ba30b9f7f41e6db64fa8957709bca96e53847)) +* **main:** Release v9.13.1 ([0b39ff1](https://github.com/cloud-pi-native/console/commit/0b39ff1747d65fae9d4d4c6409421a30b1c57fc4)) +* **main:** Release v9.13.1 ([96ed6a3](https://github.com/cloud-pi-native/console/commit/96ed6a318a74978a9c2b8713d65100f1c0ca2f3d)) +* **main:** Release v9.13.2 ([400e9a7](https://github.com/cloud-pi-native/console/commit/400e9a71b28879c2bcd382ed4a43f55757d7aa2e)) +* **main:** Release v9.13.2 ([e2d09e3](https://github.com/cloud-pi-native/console/commit/e2d09e3f476fc958967089bc0ac9c5054b7eb5ac)) +* **main:** Release v9.14.0 ([5c62e19](https://github.com/cloud-pi-native/console/commit/5c62e191e0ab426dff4b5c390817a15345e3bdb0)) +* **main:** Release v9.14.1 ([8bccc81](https://github.com/cloud-pi-native/console/commit/8bccc81517f8cf39ddc5e1be972d4efa701b5e41)) +* **main:** Release v9.15.0 ([7b50776](https://github.com/cloud-pi-native/console/commit/7b50776d35fa73d1a28f6fb21010ffad85bafaf1)) +* **main:** Release v9.16.0 ([68abf2e](https://github.com/cloud-pi-native/console/commit/68abf2e5afd1fac52fad95c1319a4b50e8ac6275)) +* **main:** Release v9.16.1 ([3ba4c0b](https://github.com/cloud-pi-native/console/commit/3ba4c0bf08d899b614875cac99af7345a61998c8)) +* **main:** Release v9.17.0 ([a4061a6](https://github.com/cloud-pi-native/console/commit/a4061a61137c40c5b496e6649645b0b017723407)) +* **main:** Release v9.17.1 ([f8f7952](https://github.com/cloud-pi-native/console/commit/f8f795208ce32b5539e4d06c84f8579d1ae7ad2d)) +* **main:** Release v9.18.0 ([775bd1e](https://github.com/cloud-pi-native/console/commit/775bd1e82a598278ae2317bb775054b9e0627a36)) +* **main:** Release v9.18.1 ([261d9a3](https://github.com/cloud-pi-native/console/commit/261d9a367b71e8598c7956e4b76b612550244ffd)) +* **main:** Release v9.18.2 ([0a75369](https://github.com/cloud-pi-native/console/commit/0a753695320428332119401d6de53dc576538d72)) +* **main:** Release v9.18.3 ([f8cadc4](https://github.com/cloud-pi-native/console/commit/f8cadc47d40393c8fd040b0740957cff39ae6cb4)) +* **main:** Release v9.18.4 ([71dc32b](https://github.com/cloud-pi-native/console/commit/71dc32bf1f3d16a87a39f5d6378f3b7876bf2e42)) +* **main:** Release v9.19.0 ([c7d0f41](https://github.com/cloud-pi-native/console/commit/c7d0f41d7ecfd5d4645872de743d83d90b97bdab)) +* **main:** Release v9.20.0 ([9ee41b3](https://github.com/cloud-pi-native/console/commit/9ee41b36597cdd79c632ae3b2d3eb5b032781819)) +* **main:** Release v9.20.2 ([4075691](https://github.com/cloud-pi-native/console/commit/4075691de972a9d181dbed02a9aa2d801dc026f2)) +* **main:** Release v9.21.0 ([27e7f3a](https://github.com/cloud-pi-native/console/commit/27e7f3acff20927d8753d6bc67cebc4ed686f5ab)) +* move pnpm config to pnpm-workspace.yaml to avoid future issues with .npmrc ([00974c9](https://github.com/cloud-pi-native/console/commit/00974c9fcb98d223923c5062ea854ffd5ddeeb95)) +* move the entirety of apps/server in server-nestjs ([8437837](https://github.com/cloud-pi-native/console/commit/8437837fadca7dd79ec76cd3f004a83881891c29)) +* **nest-js:** add basic config and logger modules based on existing implementation ([429e4ba](https://github.com/cloud-pi-native/console/commit/429e4ba046f076e65320c3b4714aea878c99b754)) +* **nest-js:** initialize a bunch of module/services to move old-server code ([935f1d7](https://github.com/cloud-pi-native/console/commit/935f1d7fdef9ea68a404f01be07fa1b62b670dfd)) +* **nest-js:** update old-server code from main ([09f3729](https://github.com/cloud-pi-native/console/commit/09f3729078482abce704a2e885f6230134071f57)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** don't bother with webkit for now ([7d80b33](https://github.com/cloud-pi-native/console/commit/7d80b33f6ed5d364b526475e312679330825bd3c)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **playwright:** migrate E2E Project logs tests from Cypress ([089f0bc](https://github.com/cloud-pi-native/console/commit/089f0bcb6f443bc43c2936d039088b76bab10a01)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* **plugins:** update formatting ([86f8bad](https://github.com/cloud-pi-native/console/commit/86f8badfc617809d6f10e9c869b9fbce201de953)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* remove .js extensions from imports ([c8774f3](https://github.com/cloud-pi-native/console/commit/c8774f323c45ac85269d5c47eecf0813c632f879)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) +* remove configuration for tools that are not explicitely supported on the project ([e1be3d5](https://github.com/cloud-pi-native/console/commit/e1be3d5903a8a85d651785b6ef747040b9f29a41)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* rename nest.js base AppModule to MainModule for clarification with our own AppService ([8a20228](https://github.com/cloud-pi-native/console/commit/8a2022815fe363aa5626b0b4fcc8ab832dca12de)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) +* **server-nestjs:** add code to DatabaseService and thus finalize ApplicationInitializationService ([b12c207](https://github.com/cloud-pi-native/console/commit/b12c2079aaa944282ff5365504baf0095bd14e84)) +* **server-nestjs:** add code to PluginManagementService ([2d3133d](https://github.com/cloud-pi-native/console/commit/2d3133d5203fc94e71d3e4a2dfb5d4fc4e6bd784)) +* **server-nestjs:** add database initialization code in DatabaseInitializationService ([73c27d9](https://github.com/cloud-pi-native/console/commit/73c27d93ab779155a7e2c1c353909f3e5338b818)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) +* **server-nestjs:** convert all router services to NestJS classes ([2dd2c3e](https://github.com/cloud-pi-native/console/commit/2dd2c3ea697c6b508e89e107a689f79a8b39a6a1)) +* **server-nestjs:** delete unneeded code ([bbe07b2](https://github.com/cloud-pi-native/console/commit/bbe07b27bfd3d6c198e17a642cc441c919392d85)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** fix a fastify type issue ([868f1bc](https://github.com/cloud-pi-native/console/commit/868f1bc813f880963d13bccffd7814a54166f17a)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* **server-nestjs:** fix pnpm format command ([133254c](https://github.com/cloud-pi-native/console/commit/133254c1d285f45bb0385fda11d2f5b700f4ab20)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* **server-nestjs:** initialize core module and its AppService ([91d9596](https://github.com/cloud-pi-native/console/commit/91d9596765ced2a941f8e31c514e2b6c3969ef95)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove now useless old-server directory ([47d7536](https://github.com/cloud-pi-native/console/commit/47d7536a66e152b64fd69b0c57f72ab23a27d0e0)) +* **server-nestjs:** remove Router module ([8104360](https://github.com/cloud-pi-native/console/commit/8104360de9236f0f2c61dba2273b6d8cb368a356)) +* **server-nestjs:** remove test scripts (we'll migrate to vitest soon) ([afc4e91](https://github.com/cloud-pi-native/console/commit/afc4e912e88084a073b00e5213b2eed182b6c78b)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rename vite file to avoid compilation issues for now ([bd15699](https://github.com/cloud-pi-native/console/commit/bd15699e39d11ddbb6304366a41d67b8564c425a)) +* **server-nestjs:** retrieve prisma directory from old-server as-is ([a89fd36](https://github.com/cloud-pi-native/console/commit/a89fd368a1bc1c4d3eb1e535b0d4d776322e622f)) +* **server-nestjs:** rework configuration service to properly instanciate logger service ([64a113d](https://github.com/cloud-pi-native/console/commit/64a113d6591739557d459562acc57456fc3f74b9)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **server-nestjs:** update README with a plan for the future ([fc336d4](https://github.com/cloud-pi-native/console/commit/fc336d4b6793898a9190f585b39a9ba63c5e528b)) +* **server-nestjs:** update README with the modularization target ([d98bdfa](https://github.com/cloud-pi-native/console/commit/d98bdfa428290e7a5358a7caf33137a13815239c)) +* **server-nestjs:** use old-server eslint config ([d0f80ee](https://github.com/cloud-pi-native/console/commit/d0f80ee7bf88b7d26407b78a9792af699fb463e8)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* start integration old-server into nestjs ([d83b1cd](https://github.com/cloud-pi-native/console/commit/d83b1cdf47ba140a50a83da02c1c6a8fb024c9b9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) +* update browserlist/caniuse-lite ([23617f9](https://github.com/cloud-pi-native/console/commit/23617f9e051caf7e600360a92bae76ee6c8af575)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* update old-server import aliases ([d89735e](https://github.com/cloud-pi-native/console/commit/d89735e299b541efa4967a835c3cd0ece7d1ffbb)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) +* **vault:** explicit that VaultApi base class is abstract and must not be exported ([3673c4c](https://github.com/cloud-pi-native/console/commit/3673c4cf96bfc40bfac114fe7b67dee425b372da)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Docs + +* :memo: add notes regarding services communication ([ca4a057](https://github.com/cloud-pi-native/console/commit/ca4a0578d3e815f93aa1e57cbb880601354d600d)) +* :memo: Ajout du lien vers le MODOP de gestion des versions de dépendances ([7151293](https://github.com/cloud-pi-native/console/commit/715129312a6150f887298d1dc493ef7876c51e28)) +* :memo: update release documentation ([cea92a9](https://github.com/cloud-pi-native/console/commit/cea92a9aa651af68499ae318e4a7c27e037c706c)) +* :memo: use Nexus internal url for CI jobs ([2193ec3](https://github.com/cloud-pi-native/console/commit/2193ec3041fb0d78cc7a1fbe841fd451d2a7b095)) +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + + +### Refactoring + +* :bookmark: add tracing information on Argo apps ([340299b](https://github.com/cloud-pi-native/console/commit/340299b58e702a4258c5d767e3fdf4fc692f7d73)) +* :fire: remove AppRole enabled check to simplify the code ([95c7159](https://github.com/cloud-pi-native/console/commit/95c71595d7aac32fa211d18880fd5e7ed3bf4c53)) +* add PluginResultBuilder for improved result management in project upsert ([80d5bc4](https://github.com/cloud-pi-native/console/commit/80d5bc498422c1eb79134c54c9facc9c7a15e279)) +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) +* split Gitlab classes in separate files ([0d673a5](https://github.com/cloud-pi-native/console/commit/0d673a5d282e9dc82a816d16ac18c8452f740c1f)) +* split Vault classes in separate files ([15bc0ce](https://github.com/cloud-pi-native/console/commit/15bc0ce32dc74e8719c7a202a2e3e406aee50762)) + + +### Reverted commits + +* "feat(server-nestjs): add autoSync and suspended flags to plugin services" ([2116663](https://github.com/cloud-pi-native/console/commit/2116663621cf1d522ca73a0e76ea6b9ed4f9ff6d)) +* "fix: playwright test expect mock" ([d2a746c](https://github.com/cloud-pi-native/console/commit/d2a746c237af4d1cbfe38e8d090599bf26176525)) +* "refactor(server-nestjs): offload project route to new implementation" ([8a964bc](https://github.com/cloud-pi-native/console/commit/8a964bc5d2d223e9c2f2d975cc703a18be81fc45)) +* **client:** switch environments back from API v2 ([933977c](https://github.com/cloud-pi-native/console/commit/933977c8b3434d3db97c0f4300bb2d4bcc393588)) + +## [9.21.0](https://github.com/cloud-pi-native/console/compare/v9.20.2...v9.21.0) (2026-07-21) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** restrict deployment visibility to console admins ([def61e7](https://github.com/cloud-pi-native/console/commit/def61e76b3a867445c5ddb1c08e0f3af59b188ae)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([52c8173](https://github.com/cloud-pi-native/console/commit/52c8173f5610e83836b661d7472c0fe3ff22f70f)) +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* :construction: make all new cluster external by default ([dcd203f](https://github.com/cloud-pi-native/console/commit/dcd203f3569d93a74e0d627f6cf3eb898d6b338d)) +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* add old-server new Nest.js services to CpinModule ([a17c109](https://github.com/cloud-pi-native/console/commit/a17c109616270115af701366ae8014e05cb41e7c)) +* add server-nestjs README to explain what we are doing ([57a4ca5](https://github.com/cloud-pi-native/console/commit/57a4ca51086b7c7af44a1df83cd272b82eb9a9f8)) +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* apply prettier on old-server ([6a781ac](https://github.com/cloud-pi-native/console/commit/6a781ac80c756924c53e28815064197b63508ff4)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* bump turbo to v2.7.1 ([d955e52](https://github.com/cloud-pi-native/console/commit/d955e52e65ed829053fdb120b217cb2744bd02c2)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** trigger Playwright E2E tests before Cypress E2E tests ([2262f37](https://github.com/cloud-pi-native/console/commit/2262f3717101833217d5963580e07fb4f69e9847)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* **client:** commit auto-imports.d.ts ([4252266](https://github.com/cloud-pi-native/console/commit/42522663edf5151ce7e38c36d4eb5d2d13b24a48)) +* **client:** do not install pnpm twice ([43ff3a3](https://github.com/cloud-pi-native/console/commit/43ff3a3e6690c901d4905236b627016ee8b4f043)) +* **client:** ensure build fails if typing system is not sound ([97297b5](https://github.com/cloud-pi-native/console/commit/97297b58a80bee7167171d39b16404f077fef03b)) +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* **client:** fix types ([131b2b6](https://github.com/cloud-pi-native/console/commit/131b2b64926493d94ba1e07c3501e331d0fcac8c)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* convert cypress dashboard e2e tests to playwright ([2badd91](https://github.com/cloud-pi-native/console/commit/2badd91181d4960d40898cfa01fbcfd0b13667aa)) +* convert plugin manager to NestJS service ([2ae46e3](https://github.com/cloud-pi-native/console/commit/2ae46e38787b3521911786898bee881c23fdb08b)) +* disable tests from nest.js for now ([5137c10](https://github.com/cloud-pi-native/console/commit/5137c10ed9296c761c3d31877bf54a411bbee140)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **e2e:** avoid launching Playwright and Cypress E2E tests at the same time ([1f203a8](https://github.com/cloud-pi-native/console/commit/1f203a8d35d6b1e4e69076f289256c7192a356da)) +* **e2e:** migrate Admin Role tests from Cypress to Playwright ([2112398](https://github.com/cloud-pi-native/console/commit/2112398f85b1ab7a5e332b798f68d4f04894d51d)) +* **e2e:** migrate Duplicate Zone test from Cypress to Playwright ([748ec80](https://github.com/cloud-pi-native/console/commit/748ec80917af7ef34b1f7922a13703706e5e41cc)) +* **e2e:** migrate last Role tests from Cypress to Playwright ([176ea34](https://github.com/cloud-pi-native/console/commit/176ea3481fc5b282497ea82a7562d735b11b325f)) +* **e2e:** migrate last Zone tests from Cypress to Playwright ([dcff338](https://github.com/cloud-pi-native/console/commit/dcff3384e695e95c9d2ae2b08486ce4ca47fa769)) +* **e2e:** migrate profile tests from Cypress to Playwright ([d7201b6](https://github.com/cloud-pi-native/console/commit/d7201b6d253d342f6a1656d179d0a9d322745c33)) +* **e2e:** migrate Project archive test from Cypress to Playwright ([0567931](https://github.com/cloud-pi-native/console/commit/0567931723cccf2fc54cb5560c6dd00043211f58)) +* **e2e:** migrate repository tests from Cypress to Playwright ([8370b8b](https://github.com/cloud-pi-native/console/commit/8370b8b4adbe5c1b65bc5f45bd50c860beeba96c)) +* **e2e:** migrate some Zone tests from Cypress to Playwright ([425e8c9](https://github.com/cloud-pi-native/console/commit/425e8c9672b276507983b6b0685bcf475bdb7c6c)) +* **e2e:** migrate Token tests from Cypress to Playwright ([86cb815](https://github.com/cloud-pi-native/console/commit/86cb8158285be552a1d0e94ebd30600858b9193a)) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* fix all typescript import errors by massively creating NestJs services ([68b7cb7](https://github.com/cloud-pi-native/console/commit/68b7cb75407dbb8d630dd4a50e4832e97796008a)) +* fix any type in AppService ([627e1e9](https://github.com/cloud-pi-native/console/commit/627e1e9f0cac82bcaa9b3b3a6337c3f2b447e1e5)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) +* **gitlab-plugin:** interalize awaited import to avoid top-level-await errors ([6472c74](https://github.com/cloud-pi-native/console/commit/6472c742c9ec74f834da994db697fcfb7d385c7e)) +* **gitlab:** purge useless file ([2637156](https://github.com/cloud-pi-native/console/commit/263715641844bb7d766730a3de9240531c9f26e3)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **hotfix/revert-server-nestjs-offload:** Release v9.20.1 ([8ebeae7](https://github.com/cloud-pi-native/console/commit/8ebeae7717d0aeac93ce14102a137f4aac62c409)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* initialize nestjs dedicated app ([a533963](https://github.com/cloud-pi-native/console/commit/a53396329455c9c856274f3c419e2ff377e12dba)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) +* **main:** release 9.10.0 ([c87ad31](https://github.com/cloud-pi-native/console/commit/c87ad3114149c4af995be42e130d03efd2e301d9)) +* **main:** release 9.10.1 ([c4b4649](https://github.com/cloud-pi-native/console/commit/c4b464985cfafc7d60246621e9beb82760b73200)) +* **main:** release 9.10.2 ([20a233a](https://github.com/cloud-pi-native/console/commit/20a233af09a3ef918a7b1de36057374dd4817726)) +* **main:** release 9.11.0 ([c03e2ef](https://github.com/cloud-pi-native/console/commit/c03e2ef8c4e448784528ae7dee68edff36e48930)) +* **main:** release 9.11.1 ([7580384](https://github.com/cloud-pi-native/console/commit/7580384525c7cdddbd51e7b0b9b4154ca624b058)) +* **main:** release 9.11.2 ([c7de3a1](https://github.com/cloud-pi-native/console/commit/c7de3a15bd54730655e3a75523c29e9f313a18d9)) +* **main:** release 9.11.3 ([9d2df48](https://github.com/cloud-pi-native/console/commit/9d2df48e3588a819fe5a63467dee773c5e1963c3)) +* **main:** release 9.11.4 ([9c873e1](https://github.com/cloud-pi-native/console/commit/9c873e1b5d1ec3d59b86b6ae19b0fe055f3ed669)) +* **main:** release 9.11.5 ([5fc68de](https://github.com/cloud-pi-native/console/commit/5fc68de5cf2a22f33171f03132798df20701df1b)) +* **main:** release 9.8.1 ([bb94301](https://github.com/cloud-pi-native/console/commit/bb9430196e042847931719ec518d5b9685a67585)) +* **main:** release 9.9.0 ([aa755da](https://github.com/cloud-pi-native/console/commit/aa755dac97ffd563bb0684fe669268b95c6d1362)) +* **main:** Release v9.12.0 ([3a14b03](https://github.com/cloud-pi-native/console/commit/3a14b03a7284ee8ee8a5e9d54d7ba842832c4017)) +* **main:** Release v9.12.0 ([4f6e1a8](https://github.com/cloud-pi-native/console/commit/4f6e1a8d8b630335c89a12b4daf4941e1dcdc32a)) +* **main:** Release v9.13.0 ([4daaa1d](https://github.com/cloud-pi-native/console/commit/4daaa1dce01276f83c6870ffe1c13af096547621)) +* **main:** Release v9.13.0 ([613ba30](https://github.com/cloud-pi-native/console/commit/613ba30b9f7f41e6db64fa8957709bca96e53847)) +* **main:** Release v9.13.1 ([0b39ff1](https://github.com/cloud-pi-native/console/commit/0b39ff1747d65fae9d4d4c6409421a30b1c57fc4)) +* **main:** Release v9.13.1 ([96ed6a3](https://github.com/cloud-pi-native/console/commit/96ed6a318a74978a9c2b8713d65100f1c0ca2f3d)) +* **main:** Release v9.13.2 ([400e9a7](https://github.com/cloud-pi-native/console/commit/400e9a71b28879c2bcd382ed4a43f55757d7aa2e)) +* **main:** Release v9.13.2 ([e2d09e3](https://github.com/cloud-pi-native/console/commit/e2d09e3f476fc958967089bc0ac9c5054b7eb5ac)) +* **main:** Release v9.14.0 ([5c62e19](https://github.com/cloud-pi-native/console/commit/5c62e191e0ab426dff4b5c390817a15345e3bdb0)) +* **main:** Release v9.14.1 ([8bccc81](https://github.com/cloud-pi-native/console/commit/8bccc81517f8cf39ddc5e1be972d4efa701b5e41)) +* **main:** Release v9.15.0 ([7b50776](https://github.com/cloud-pi-native/console/commit/7b50776d35fa73d1a28f6fb21010ffad85bafaf1)) +* **main:** Release v9.16.0 ([68abf2e](https://github.com/cloud-pi-native/console/commit/68abf2e5afd1fac52fad95c1319a4b50e8ac6275)) +* **main:** Release v9.16.1 ([3ba4c0b](https://github.com/cloud-pi-native/console/commit/3ba4c0bf08d899b614875cac99af7345a61998c8)) +* **main:** Release v9.17.0 ([a4061a6](https://github.com/cloud-pi-native/console/commit/a4061a61137c40c5b496e6649645b0b017723407)) +* **main:** Release v9.17.1 ([f8f7952](https://github.com/cloud-pi-native/console/commit/f8f795208ce32b5539e4d06c84f8579d1ae7ad2d)) +* **main:** Release v9.18.0 ([775bd1e](https://github.com/cloud-pi-native/console/commit/775bd1e82a598278ae2317bb775054b9e0627a36)) +* **main:** Release v9.18.1 ([261d9a3](https://github.com/cloud-pi-native/console/commit/261d9a367b71e8598c7956e4b76b612550244ffd)) +* **main:** Release v9.18.2 ([0a75369](https://github.com/cloud-pi-native/console/commit/0a753695320428332119401d6de53dc576538d72)) +* **main:** Release v9.18.3 ([f8cadc4](https://github.com/cloud-pi-native/console/commit/f8cadc47d40393c8fd040b0740957cff39ae6cb4)) +* **main:** Release v9.18.4 ([71dc32b](https://github.com/cloud-pi-native/console/commit/71dc32bf1f3d16a87a39f5d6378f3b7876bf2e42)) +* **main:** Release v9.19.0 ([c7d0f41](https://github.com/cloud-pi-native/console/commit/c7d0f41d7ecfd5d4645872de743d83d90b97bdab)) +* **main:** Release v9.20.0 ([9ee41b3](https://github.com/cloud-pi-native/console/commit/9ee41b36597cdd79c632ae3b2d3eb5b032781819)) +* **main:** Release v9.20.2 ([4075691](https://github.com/cloud-pi-native/console/commit/4075691de972a9d181dbed02a9aa2d801dc026f2)) +* move pnpm config to pnpm-workspace.yaml to avoid future issues with .npmrc ([00974c9](https://github.com/cloud-pi-native/console/commit/00974c9fcb98d223923c5062ea854ffd5ddeeb95)) +* move the entirety of apps/server in server-nestjs ([8437837](https://github.com/cloud-pi-native/console/commit/8437837fadca7dd79ec76cd3f004a83881891c29)) +* **nest-js:** add basic config and logger modules based on existing implementation ([429e4ba](https://github.com/cloud-pi-native/console/commit/429e4ba046f076e65320c3b4714aea878c99b754)) +* **nest-js:** initialize a bunch of module/services to move old-server code ([935f1d7](https://github.com/cloud-pi-native/console/commit/935f1d7fdef9ea68a404f01be07fa1b62b670dfd)) +* **nest-js:** update old-server code from main ([09f3729](https://github.com/cloud-pi-native/console/commit/09f3729078482abce704a2e885f6230134071f57)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** don't bother with webkit for now ([7d80b33](https://github.com/cloud-pi-native/console/commit/7d80b33f6ed5d364b526475e312679330825bd3c)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **playwright:** migrate E2E Project logs tests from Cypress ([089f0bc](https://github.com/cloud-pi-native/console/commit/089f0bcb6f443bc43c2936d039088b76bab10a01)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* **plugins:** update formatting ([86f8bad](https://github.com/cloud-pi-native/console/commit/86f8badfc617809d6f10e9c869b9fbce201de953)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* remove .js extensions from imports ([c8774f3](https://github.com/cloud-pi-native/console/commit/c8774f323c45ac85269d5c47eecf0813c632f879)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) +* remove configuration for tools that are not explicitely supported on the project ([e1be3d5](https://github.com/cloud-pi-native/console/commit/e1be3d5903a8a85d651785b6ef747040b9f29a41)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* rename nest.js base AppModule to MainModule for clarification with our own AppService ([8a20228](https://github.com/cloud-pi-native/console/commit/8a2022815fe363aa5626b0b4fcc8ab832dca12de)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) +* **server-nestjs:** add code to DatabaseService and thus finalize ApplicationInitializationService ([b12c207](https://github.com/cloud-pi-native/console/commit/b12c2079aaa944282ff5365504baf0095bd14e84)) +* **server-nestjs:** add code to PluginManagementService ([2d3133d](https://github.com/cloud-pi-native/console/commit/2d3133d5203fc94e71d3e4a2dfb5d4fc4e6bd784)) +* **server-nestjs:** add database initialization code in DatabaseInitializationService ([73c27d9](https://github.com/cloud-pi-native/console/commit/73c27d93ab779155a7e2c1c353909f3e5338b818)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) +* **server-nestjs:** convert all router services to NestJS classes ([2dd2c3e](https://github.com/cloud-pi-native/console/commit/2dd2c3ea697c6b508e89e107a689f79a8b39a6a1)) +* **server-nestjs:** delete unneeded code ([bbe07b2](https://github.com/cloud-pi-native/console/commit/bbe07b27bfd3d6c198e17a642cc441c919392d85)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** fix a fastify type issue ([868f1bc](https://github.com/cloud-pi-native/console/commit/868f1bc813f880963d13bccffd7814a54166f17a)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* **server-nestjs:** fix pnpm format command ([133254c](https://github.com/cloud-pi-native/console/commit/133254c1d285f45bb0385fda11d2f5b700f4ab20)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* **server-nestjs:** initialize core module and its AppService ([91d9596](https://github.com/cloud-pi-native/console/commit/91d9596765ced2a941f8e31c514e2b6c3969ef95)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove now useless old-server directory ([47d7536](https://github.com/cloud-pi-native/console/commit/47d7536a66e152b64fd69b0c57f72ab23a27d0e0)) +* **server-nestjs:** remove Router module ([8104360](https://github.com/cloud-pi-native/console/commit/8104360de9236f0f2c61dba2273b6d8cb368a356)) +* **server-nestjs:** remove test scripts (we'll migrate to vitest soon) ([afc4e91](https://github.com/cloud-pi-native/console/commit/afc4e912e88084a073b00e5213b2eed182b6c78b)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rename vite file to avoid compilation issues for now ([bd15699](https://github.com/cloud-pi-native/console/commit/bd15699e39d11ddbb6304366a41d67b8564c425a)) +* **server-nestjs:** retrieve prisma directory from old-server as-is ([a89fd36](https://github.com/cloud-pi-native/console/commit/a89fd368a1bc1c4d3eb1e535b0d4d776322e622f)) +* **server-nestjs:** rework configuration service to properly instanciate logger service ([64a113d](https://github.com/cloud-pi-native/console/commit/64a113d6591739557d459562acc57456fc3f74b9)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **server-nestjs:** update README with a plan for the future ([fc336d4](https://github.com/cloud-pi-native/console/commit/fc336d4b6793898a9190f585b39a9ba63c5e528b)) +* **server-nestjs:** update README with the modularization target ([d98bdfa](https://github.com/cloud-pi-native/console/commit/d98bdfa428290e7a5358a7caf33137a13815239c)) +* **server-nestjs:** use old-server eslint config ([d0f80ee](https://github.com/cloud-pi-native/console/commit/d0f80ee7bf88b7d26407b78a9792af699fb463e8)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* start integration old-server into nestjs ([d83b1cd](https://github.com/cloud-pi-native/console/commit/d83b1cdf47ba140a50a83da02c1c6a8fb024c9b9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) +* update browserlist/caniuse-lite ([23617f9](https://github.com/cloud-pi-native/console/commit/23617f9e051caf7e600360a92bae76ee6c8af575)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* update old-server import aliases ([d89735e](https://github.com/cloud-pi-native/console/commit/d89735e299b541efa4967a835c3cd0ece7d1ffbb)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) +* **vault:** explicit that VaultApi base class is abstract and must not be exported ([3673c4c](https://github.com/cloud-pi-native/console/commit/3673c4cf96bfc40bfac114fe7b67dee425b372da)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Docs + +* :memo: add notes regarding services communication ([ca4a057](https://github.com/cloud-pi-native/console/commit/ca4a0578d3e815f93aa1e57cbb880601354d600d)) +* :memo: Ajout du lien vers le MODOP de gestion des versions de dépendances ([7151293](https://github.com/cloud-pi-native/console/commit/715129312a6150f887298d1dc493ef7876c51e28)) +* :memo: update release documentation ([cea92a9](https://github.com/cloud-pi-native/console/commit/cea92a9aa651af68499ae318e4a7c27e037c706c)) +* :memo: use Nexus internal url for CI jobs ([2193ec3](https://github.com/cloud-pi-native/console/commit/2193ec3041fb0d78cc7a1fbe841fd451d2a7b095)) +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + + +### Refactoring + +* :bookmark: add tracing information on Argo apps ([340299b](https://github.com/cloud-pi-native/console/commit/340299b58e702a4258c5d767e3fdf4fc692f7d73)) +* :fire: remove AppRole enabled check to simplify the code ([95c7159](https://github.com/cloud-pi-native/console/commit/95c71595d7aac32fa211d18880fd5e7ed3bf4c53)) +* add PluginResultBuilder for improved result management in project upsert ([80d5bc4](https://github.com/cloud-pi-native/console/commit/80d5bc498422c1eb79134c54c9facc9c7a15e279)) +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) +* split Gitlab classes in separate files ([0d673a5](https://github.com/cloud-pi-native/console/commit/0d673a5d282e9dc82a816d16ac18c8452f740c1f)) +* split Vault classes in separate files ([15bc0ce](https://github.com/cloud-pi-native/console/commit/15bc0ce32dc74e8719c7a202a2e3e406aee50762)) + + +### Reverted commits + +* "feat(server-nestjs): add autoSync and suspended flags to plugin services" ([2116663](https://github.com/cloud-pi-native/console/commit/2116663621cf1d522ca73a0e76ea6b9ed4f9ff6d)) +* "fix: playwright test expect mock" ([d2a746c](https://github.com/cloud-pi-native/console/commit/d2a746c237af4d1cbfe38e8d090599bf26176525)) +* "refactor(server-nestjs): offload project route to new implementation" ([8a964bc](https://github.com/cloud-pi-native/console/commit/8a964bc5d2d223e9c2f2d975cc703a18be81fc45)) +* **client:** switch environments back from API v2 ([933977c](https://github.com/cloud-pi-native/console/commit/933977c8b3434d3db97c0f4300bb2d4bcc393588)) + +## [9.20.2](https://github.com/cloud-pi-native/console/compare/v9.20.1...v9.20.2) (2026-07-21) + + +### Bug Fixes + +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) + +## [9.20.1](https://github.com/cloud-pi-native/console/compare/v9.20.0...v9.20.1) (2026-07-17) + + +### Bug Fixes + +* **client:** restrict deployment visibility to console admins ([d836948](https://github.com/cloud-pi-native/console/commit/d836948e2c6689445d473c562a2be189873c75af)) + + +### Reverted commits + +* **client:** switch environments back from API v2 ([9c1245a](https://github.com/cloud-pi-native/console/commit/9c1245a56f650d4819f27f926457810f33b67753)) +* feat(server-nestjs): add autoSync and suspended flags to plugin services ([a48697d](https://github.com/cloud-pi-native/console/commit/a48697d46fad4fbbf54567988d0e99433cb5c4cc)) +* fix: playwright test expect mock ([828db42](https://github.com/cloud-pi-native/console/commit/828db421f6ce4f7eda293a744fda9f22882f7a8c)) +* refactor(server-nestjs): offload project route to new implementation ([99a7f5d](https://github.com/cloud-pi-native/console/commit/99a7f5d29becefcdad24331004263756a6950774)) + +## [9.20.0](https://github.com/cloud-pi-native/console/compare/v9.19.0...v9.20.0) (2026-07-17) + +### Features + +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) + + +### Bug Fixes + +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) + + +### Docs + +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) + + +### Refactoring + +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) + +## [9.19.0](https://github.com/cloud-pi-native/console/compare/v9.18.4...v9.19.0) (2026-06-19) + + +### Features + +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) + + +### Bug Fixes + +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) + + +### Miscellaneous Chores + +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) + + +### Refactoring + +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) + +## [9.18.4](https://github.com/cloud-pi-native/console/compare/v9.18.3...v9.18.4) (2026-06-10) + + +### Bug Fixes + +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) + +## [9.18.3](https://github.com/cloud-pi-native/console/compare/v9.18.2...v9.18.3) (2026-06-01) + + +### Bug Fixes + +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) + + +### Docs + +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) + +## [9.18.2](https://github.com/cloud-pi-native/console/compare/v9.18.1...v9.18.2) (2026-05-22) + + +### Bug Fixes + +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) + + +### Miscellaneous Chores + +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) + + +### Docs + +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) + + +### Refactoring + +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) + +## [9.18.1](https://github.com/cloud-pi-native/console/compare/v9.18.0...v9.18.1) (2026-05-20) + + +### Bug Fixes + +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) + +## [9.18.0](https://github.com/cloud-pi-native/console/compare/v9.17.1...v9.18.0) (2026-05-18) + + +### Features + +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) + + +### Bug Fixes + +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) + + +### Miscellaneous Chores + +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) + +## [9.17.1](https://github.com/cloud-pi-native/console/compare/v9.17.0...v9.17.1) (2026-05-05) + + +### Bug Fixes + +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) + +## [9.17.0](https://github.com/cloud-pi-native/console/compare/v9.16.1...v9.17.0) (2026-04-30) + + +### Features + +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) + + +### Bug Fixes + +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) + + +### Miscellaneous Chores + +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) + + +### Docs + +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) + + +### Refactoring + +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) + +## [9.16.1](https://github.com/cloud-pi-native/console/compare/v9.16.0...v9.16.1) (2026-04-13) + + +### Bug Fixes + +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) + + +### Miscellaneous Chores + +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) + +## [9.16.0](https://github.com/cloud-pi-native/console/compare/v9.15.0...v9.16.0) (2026-04-10) + + +### Features + +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) + + +### Bug Fixes + +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) + + +### Miscellaneous Chores + +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) + +## [9.15.0](https://github.com/cloud-pi-native/console/compare/v9.14.1...v9.15.0) (2026-04-09) + + +### Features + +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) + + +### Bug Fixes + +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) + + +### Miscellaneous Chores + +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Refactoring + +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) + +## [9.14.1](https://github.com/cloud-pi-native/console/compare/v9.14.0...v9.14.1) (2026-03-25) + + +### Bug Fixes + +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) + + +### Miscellaneous Chores + +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) + + +### Docs + +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + +## [9.14.0](https://github.com/cloud-pi-native/console/compare/v9.13.2...v9.14.0) (2026-03-23) + + +### Features + +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) + + +### Miscellaneous Chores + +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) + + +### Docs + +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) + +## [9.13.2](https://github.com/cloud-pi-native/console/compare/v9.13.1...v9.13.2) (2026-02-24) + + +### Bug Fixes + +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) + + +### Miscellaneous Chores + +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) + + +### Docs + +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) + +## [9.13.1](https://github.com/cloud-pi-native/console/compare/v9.13.0...v9.13.1) (2026-02-17) + + +### Miscellaneous Chores + +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) + +## [9.13.0](https://github.com/cloud-pi-native/console/compare/v9.12.0...v9.13.0) (2026-02-16) + + +### Features + +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) + + +### Bug Fixes + +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) + + +### Miscellaneous Chores + +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) + + +### Docs + +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) + +## [9.12.0](https://github.com/cloud-pi-native/console/compare/v9.11.5...v9.12.0) (2026-02-03) + + +### Features + +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) + + +### Miscellaneous Chores + +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) + + +### Docs + +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) + +## [9.11.5](https://github.com/cloud-pi-native/console/compare/v9.11.4...v9.11.5) (2026-01-26) + + +### Bug Fixes + +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) + +## [9.11.4](https://github.com/cloud-pi-native/console/compare/v9.11.3...v9.11.4) (2026-01-20) + + +### Bug Fixes + +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) + +## [9.11.3](https://github.com/cloud-pi-native/console/compare/v9.11.2...v9.11.3) (2026-01-13) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) + +## [9.11.2](https://github.com/cloud-pi-native/console/compare/v9.11.1...v9.11.2) (2026-01-08) + + +### Bug Fixes + +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) + +## [9.11.1](https://github.com/cloud-pi-native/console/compare/v9.11.0...v9.11.1) (2026-01-06) + + +### Bug Fixes + +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) + +## [9.11.0](https://github.com/cloud-pi-native/console/compare/v9.10.2...v9.11.0) (2025-12-22) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) + +## [9.10.2](https://github.com/cloud-pi-native/console/compare/v9.10.1...v9.10.2) (2025-12-18) + + +### Bug Fixes + +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) + +## [9.10.1](https://github.com/cloud-pi-native/console/compare/v9.10.0...v9.10.1) (2025-12-18) + + +### Bug Fixes + +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) + +## [9.10.0](https://github.com/cloud-pi-native/console/compare/v9.9.0...v9.10.0) (2025-12-12) + + +### Features + +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) + + +### Bug Fixes + +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) + +## [9.9.0](https://github.com/cloud-pi-native/console/compare/v9.8.1...v9.9.0) (2025-11-27) + + +### Features + +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) + +## [9.8.1](https://github.com/cloud-pi-native/console/compare/v9.8.0...v9.8.1) (2025-11-21) + + +### Bug Fixes + +* :bug: display associated project on cluster form ([488ff24](https://github.com/cloud-pi-native/console/commit/488ff24dab0d90a6af83e96bdbe815a0f955220e)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* **ci:** remove PNPM_VERSION from Github Actions ([bd1f4af](https://github.com/cloud-pi-native/console/commit/bd1f4af2592658f7edcbcada13f1ea6cafcab3d4)) +* rely on packageManager property to retrieve the right version of pnpm ([7b20b83](https://github.com/cloud-pi-native/console/commit/7b20b832b6f83d367c33573d87c444a8da315703)) +* **shared:** explicitely import Zod type ([2a8a4d8](https://github.com/cloud-pi-native/console/commit/2a8a4d8074e45742769fb2f451672777c4f4afa5)) +* **tests:** ensure Cypress tests are green again ([5b7042b](https://github.com/cloud-pi-native/console/commit/5b7042b0be48697ce9dfa4a69377a04ceae8fb5c)) + +## [9.8.0](https://github.com/cloud-pi-native/console/compare/v9.7.2...v9.8.0) (2025-11-17) + + +### Features + +* :fire: de-duplicates Argo CD links for default zone ([2db26cd](https://github.com/cloud-pi-native/console/commit/2db26cdfce71ffbf330bdbe07a48f1276bad7cd9)) +* :poop: change the naming convention of cluster secret ([a471766](https://github.com/cloud-pi-native/console/commit/a47176643cb9a97f41c1345477da60bce5935f85)) + + +### Bug Fixes + +* :bug: improve number inputs for decimal values ([d69a8ba](https://github.com/cloud-pi-native/console/commit/d69a8ba547174be521ac8cab0e47db2448415a61)) +* **#1640:** :bug: only regenerate robot secret if host changes ([e5d3ba8](https://github.com/cloud-pi-native/console/commit/e5d3ba87f895451795be1d7fa42a389e63521a68)) + + +### Performance Improvements + +* :zap: direct service communication for Gitlab pipelines ([4991451](https://github.com/cloud-pi-native/console/commit/4991451f3d0c7dbac32cf5fed1fc880b29a37a1c)) + +## [9.7.2](https://github.com/cloud-pi-native/console/compare/v9.7.1...v9.7.2) (2025-10-27) + + +### Bug Fixes + +* release please condition ([c293866](https://github.com/cloud-pi-native/console/commit/c29386619ff3624d22a2a72f9753bc443045b5c0)) + +## [9.7.1](https://github.com/cloud-pi-native/console/compare/v9.7.0...v9.7.1) (2025-10-27) + + +### Bug Fixes + +* :bug: use the renamed path of repository/group being deleted ([38b8cff](https://github.com/cloud-pi-native/console/commit/38b8cffaff3484257104d325dd9e034bfa2611fa)) + +## [9.7.0](https://github.com/cloud-pi-native/console/compare/v9.6.0...v9.7.0) (2025-10-27) + + +### Features + +* :sparkles: add support for SonarQube and Harbor internal url ([43ce886](https://github.com/cloud-pi-native/console/commit/43ce88690883d84214c29a4cb910f81609e8cb4d)) + +## [9.6.0](https://github.com/cloud-pi-native/console/compare/v9.5.0...v9.6.0) (2025-10-14) + + +### Features + +* :sparkles: allow cluster name update ([161086d](https://github.com/cloud-pi-native/console/commit/161086dfd7c226eac65b3f5e3c835b7f923cf511)) +* :sparkles: allow cluster name update ([0cbc53e](https://github.com/cloud-pi-native/console/commit/0cbc53eb7f3b0e41f128e1066f584aadb55b9766)), closes [#1582](https://github.com/cloud-pi-native/console/issues/1582) + +## [9.5.0](https://github.com/cloud-pi-native/console/compare/v9.4.0...v9.5.0) (2025-10-10) + + +### Features + +* :sparkles: add project usage on resources tab ([1ee66ce](https://github.com/cloud-pi-native/console/commit/1ee66ce8211c2017b328887b7800a2e1ab537300)) +* :sparkles: add resources occupancy on clusters ([75ce5e9](https://github.com/cloud-pi-native/console/commit/75ce5e9450ca68d45d2635b29e0ff8bd8d8614d7)) +* :sparkles: add tags retention policy rules ([a0e3f98](https://github.com/cloud-pi-native/console/commit/a0e3f9839d18c9459264e452b14cc87359a3f6c8)) +* :sparkles: check cluster resources when adding an environment ([826f1e9](https://github.com/cloud-pi-native/console/commit/826f1e9d8f46bf2aed419c7c395ef336131849dc)) +* :sparkles: save resources constraints for the project ([58e6d7a](https://github.com/cloud-pi-native/console/commit/58e6d7ad2d06fbb9e766fa755ccd7ab44556409e)) +* **client:** add service chain details form ([d56e68c](https://github.com/cloud-pi-native/console/commit/d56e68c626ee854fd12e60694acbc87d178ee270)) +* move environments and stages scenario from cypress to playwright ([c4e703f](https://github.com/cloud-pi-native/console/commit/c4e703f92f964e5850ca16ae7d066dc6e7d692cf)) +* **opencds:** add 'list all service chains' section in admin side menu ([f58de6e](https://github.com/cloud-pi-native/console/commit/f58de6e55ad23ed8f8e81aa769f76f9adc62b7b1)) +* **opencds:** add client-side service chain details page ([478bc35](https://github.com/cloud-pi-native/console/commit/478bc35408c43da95da875b105f1cd8222dfa217)) +* **opencds:** add feature toggling default value ([5dc6071](https://github.com/cloud-pi-native/console/commit/5dc607178179c5ab76dedf0d84ab22d3e08cabf5)) +* **opencds:** add flows retrieval and display as a stepper ([7194218](https://github.com/cloud-pi-native/console/commit/71942182464d6c42da0ab818f28ee60024c2e056)) +* **opencds:** add mockoon docker image for local dev and ci considerations ([0cf8de5](https://github.com/cloud-pi-native/console/commit/0cf8de5cd04860cdcfe2259640abffd48881ac5b)) +* **opencds:** adjust service chain list view ([fa0f5c3](https://github.com/cloud-pi-native/console/commit/fa0f5c3b91d940547ef132ad443f3b0a187238aa)) +* **opencds:** clean up mockoon json ([8bd1f74](https://github.com/cloud-pi-native/console/commit/8bd1f7483d5b772705c317510cded0552cad40c6)) +* **opencds:** handle OpenCDS API token-based security ([409ae6e](https://github.com/cloud-pi-native/console/commit/409ae6e40a2e42b54fd95a05904c35e1efd18371)) +* **opencds:** use a badge for state on service-chain list ([e3c1340](https://github.com/cloud-pi-native/console/commit/e3c134095e0dc7fb8009bfdb8fc14e6e8d62959b)) +* remove quota table ([1561d8d](https://github.com/cloud-pi-native/console/commit/1561d8da82ab1413047a3573269ddc4a133360ca)) +* remove Quota table references to use Environment attributes instead ([03297ec](https://github.com/cloud-pi-native/console/commit/03297ec88fb69834c6d76f61b25309ce1a363f4f)) +* update Cluster model to store resources capability ([6167743](https://github.com/cloud-pi-native/console/commit/6167743743c91f5e109da7297de275ea9d4d23d3)) +* update Environment form to use new resources attributes ([ff58b26](https://github.com/cloud-pi-native/console/commit/ff58b26662db51f385ce49ac14b4073bd5cf71cf)) + + +### Bug Fixes + +* :bug: update zone values when modifying a cluster ([253acb9](https://github.com/cloud-pi-native/console/commit/253acb9d6106727fc11b22c8268cfcd07320001a)), closes [#1642](https://github.com/cloud-pi-native/console/issues/1642) +* :lipstick: show resources in clusters and environment tables ([8494e2a](https://github.com/cloud-pi-native/console/commit/8494e2ad19afc7e0c8afb65ccc2ee216d7399615)) +* config and locator due to keycloak upgrade ([516d9d5](https://github.com/cloud-pi-native/console/commit/516d9d5bc34b5261490170f2c356c050168f54e5)) +* **opencds:** fix validatedBy pseudo-null value conflicting with OpenCDS contract in server ([cde51a2](https://github.com/cloud-pi-native/console/commit/cde51a2f08cfd64e57714f6096a6ec2d6bb8962f)) +* **playwright:** bump playwright to 1.54.2 ([44ea5e3](https://github.com/cloud-pi-native/console/commit/44ea5e3f5093a45cf95266c0691dad60ca33de26)) +* **playwright:** fix flaky test on cluster informations by adding a loader ([ffc7b43](https://github.com/cloud-pi-native/console/commit/ffc7b436efa7af16a1a7d166c52d02317c26ab74)) + +## [9.4.0](https://github.com/cloud-pi-native/console/compare/v9.3.3...v9.4.0) (2025-08-25) + + +### Features + +* :rocket: empty Vault url to use the default VaultConnection ([e2d3b28](https://github.com/cloud-pi-native/console/commit/e2d3b287964952b0f6dd0a00ea97499827cb7198)) +* :sparkles: create contact email environment variable ([779cb4a](https://github.com/cloud-pi-native/console/commit/779cb4a83492d4159a7efa868822adc5640dd6f8)) +* ✨ Add the repo UID display on console ([2ed10e4](https://github.com/cloud-pi-native/console/commit/2ed10e4b612687b9a18441458749d2821bcff43e)) +* ✨ Variabilize clipboard timeout ([2ed10e4](https://github.com/cloud-pi-native/console/commit/2ed10e4b612687b9a18441458749d2821bcff43e)) +* add Playwright E2E tests for Keycloak ([1524bba](https://github.com/cloud-pi-native/console/commit/1524bba8d6adcc8b360d740e85b51fcf8db71964)) +* add README for ArgoCD plugin ([5d66223](https://github.com/cloud-pi-native/console/commit/5d66223c5e6af45fdc02d3f1e062a6424b0e6cdc)) +* allow cluster informations property to be up to 1000 characters long ([9fe3182](https://github.com/cloud-pi-native/console/commit/9fe3182a5c771552f23f1e147475acd606efe67a)) +* configure Playwright Github workflow ([4ac7f55](https://github.com/cloud-pi-native/console/commit/4ac7f55f6fc6d3d747bafad20efa7184e608b146)) +* **playwright:** add Project page tests ([419ea3c](https://github.com/cloud-pi-native/console/commit/419ea3c735dd3ece8e02ce2d77beaa815170f7cd)) + + +### Bug Fixes + +* :bug: adapt status analysis to Nexus 3.83.1 response ([4efa720](https://github.com/cloud-pi-native/console/commit/4efa720c50e766068d3c8773df83e3754d29c9f1)) +* **#1681:** :bug: missing kv creation if not exists ([e862c5c](https://github.com/cloud-pi-native/console/commit/e862c5ca51dee5a0216f2c66040f90d510579df4)) +* **1638:** add validation for repo names in Zod schema ([48621bd](https://github.com/cloud-pi-native/console/commit/48621bdc63446be7cd5e38ca13b98762af322c16)) +* **client:** ensure branch name is reset to default value after synchronization on a repository ([af0ea44](https://github.com/cloud-pi-native/console/commit/af0ea44186dd8c22c370c5c7edd77b4807fb9e80)) +* **client:** fix Teams E2E cypress being broken after keycloak realm update ([9c95fdf](https://github.com/cloud-pi-native/console/commit/9c95fdffbb208cae9b930fc0d2ca9516bb2cbe9b)) +* fix docker build permissions to allow local path import ([c0aaa2f](https://github.com/cloud-pi-native/console/commit/c0aaa2f29017845575c38ef83d18850f6ae741cb)) +* fix docker build permissions to allow local path import ([6b9461d](https://github.com/cloud-pi-native/console/commit/6b9461dd5e481486a5d1e479f4364348e1cf4fda)) +* fix formatting issue ([662a834](https://github.com/cloud-pi-native/console/commit/662a8342bd83242a61eceae900eb328b1eab5cc5)) +* fix typo ([c20bd60](https://github.com/cloud-pi-native/console/commit/c20bd60e462f59222b1d873d65dcb2ec980b6c33)) +* **playwright:** ensure keycloak tests work for all languages ([44e27a3](https://github.com/cloud-pi-native/console/commit/44e27a3a8d666949ad29d26b8cbd77da50b067bb)) +* **playwright:** fix format command not working ([578e4b3](https://github.com/cloud-pi-native/console/commit/578e4b3e5a0538ac41e9410d09b47f45b98f30bb)) +* remove cluster resources to avoid confusion on its usage ([2ecf9d7](https://github.com/cloud-pi-native/console/commit/2ecf9d70330581b9cb757fa5822a85c6464e0bec)) +* **tests:** fix admin id in pgsql import data not being consistent with keycloak ([3ee2583](https://github.com/cloud-pi-native/console/commit/3ee2583c6f1f7761b5586047b6a93d40c3fb5d58)) + +## [9.3.3](https://github.com/cloud-pi-native/console/compare/v9.3.2...v9.3.3) (2025-06-12) + + +### Bug Fixes + +* :bug: fix gitlab deletion API request ([7acd71b](https://github.com/cloud-pi-native/console/commit/7acd71bc9aed370882b41f31c37dcbb8122bdd3d)) + +## [9.3.2](https://github.com/cloud-pi-native/console/compare/v9.3.1...v9.3.2) (2025-06-12) + + +### Bug Fixes + +* :bug: add missing custom source repositories in argocd values (pulling mode) ([5330f7a](https://github.com/cloud-pi-native/console/commit/5330f7ae16565d7f6e0840fab7bb86691c6aeb4a)) +* fix admin roles not being retrieved when a new user logs in for the first time ([e40c00b](https://github.com/cloud-pi-native/console/commit/e40c00b1b25ecb4026612018737a6a0a1abd5aec)) +* fix hiding tile header for all tiles instead of just the projects ones ([156e75a](https://github.com/cloud-pi-native/console/commit/156e75a215bd18a90b9abe6adee5a300ea180bbd)) + +## [9.3.1](https://github.com/cloud-pi-native/console/compare/v9.3.0...v9.3.1) (2025-05-23) + + +### Bug Fixes + +* :bug: adapt status analysis to Nexus 3.78 response ([95001c2](https://github.com/cloud-pi-native/console/commit/95001c2d96afa101e9867da177d5eed8dcc6b40d)) + +## [9.3.0](https://github.com/cloud-pi-native/console/compare/v9.2.1...v9.3.0) (2025-05-19) + + +### Features + +* :zap: optimization of gitlab API calls ([d9b236e](https://github.com/cloud-pi-native/console/commit/d9b236ebe032d4976094fed757467165ac40df25)) + + +### Bug Fixes + +* :bug: add missing scope when creating keycloak client ([ec24df1](https://github.com/cloud-pi-native/console/commit/ec24df116469237ad85e67d183560976283fc1ec)) +* :wrench: duplicate changelog entries ([#1587](https://github.com/cloud-pi-native/console/issues/1587)) ([ea9fdec](https://github.com/cloud-pi-native/console/commit/ea9fdec8e15132d537f888aec0369ccb655a3667)) +* allowed sources repositories ([a5ce7ad](https://github.com/cloud-pi-native/console/commit/a5ce7ad7a6a3cc17fa8726a6be42cdf53828034b)) + +## [9.2.1](https://github.com/cloud-pi-native/console/compare/v9.2.0...v9.2.1) (2025-03-25) + + +### Bug Fixes + +* :bug: update condition to allow private quota modification ([f4c34bb](https://github.com/cloud-pi-native/console/commit/f4c34bb1e462a9d393c4af231a0f524eb20fcad1)) + +## [9.2.0](https://github.com/cloud-pi-native/console/compare/v9.1.0...v9.2.0) (2025-03-21) + + +### Features + +* :zap: limit gitlab api calls to list cicd variables in sonar plugin ([c1b3299](https://github.com/cloud-pi-native/console/commit/c1b32998f4f340c945c657d2dbbb81160fc054f8)) + +## [9.1.0](https://github.com/cloud-pi-native/console/compare/v9.0.0...v9.1.0) (2025-02-19) + + +### Features + +* :sparkles: display last refresh monitor and display cause to admins ([054ea37](https://github.com/cloud-pi-native/console/commit/054ea37d556b1c6e0fabf0e52f4611395aec18b4)) + + +### Bug Fixes + +* :alien: fix nexus monitoring ([8bb7448](https://github.com/cloud-pi-native/console/commit/8bb74480deaa2b88628d2a715496e93aa4d27629)) +* :bug: missing icons ([d2b98c2](https://github.com/cloud-pi-native/console/commit/d2b98c201fb9f4ce3181c2d2b55fdac2a85f11f5)) + +## [9.0.0](https://github.com/cloud-pi-native/console/compare/v8.24.2...v9.0.0) (2025-02-14) + + +### ⚠ BREAKING CHANGES + +* :triangular_flag_on_post: remove organizations final part + +### Features + +* :sparkles: auto create projectRootDir in Gitlab ([b4f1212](https://github.com/cloud-pi-native/console/commit/b4f12120e81d8d689b1d65de12ed06c65a95279b)) +* :triangular_flag_on_post: remove organizations final part ([73ce9a9](https://github.com/cloud-pi-native/console/commit/73ce9a9497f44bb4d831aed8c25f9ee2e8a337f5)) +* search cluster, table display ([d21be53](https://github.com/cloud-pi-native/console/commit/d21be53e168a820f500334d97faa73ae30aeaf6e)) +* unique dashboard ([d21be53](https://github.com/cloud-pi-native/console/commit/d21be53e168a820f500334d97faa73ae30aeaf6e)) + + +### Bug Fixes + +* :ambulance: bad migration ([baa0712](https://github.com/cloud-pi-native/console/commit/baa071203693db68154ffda61f99a66a72a2c1c8)) +* :ambulance: forgot to use slug in gitlab group ([aa22f19](https://github.com/cloud-pi-native/console/commit/aa22f1963d0d87d28d1a9d74fbf13ac207545382)) +* :bug: auto re login when session expiry ([6c16e88](https://github.com/cloud-pi-native/console/commit/6c16e884d505372a8d2142d5870aed635ac4afbd)) + +## [8.24.2](https://github.com/cloud-pi-native/console/compare/v8.24.1...v8.24.2) (2025-02-06) + + +### Bug Fixes + +* :recycle: add projectId to url params ([3986376](https://github.com/cloud-pi-native/console/commit/3986376d418c017f8edc802ad237df4e3a29df25)) + +## [8.24.1](https://github.com/cloud-pi-native/console/compare/v8.24.0...v8.24.1) (2025-02-04) + + +### Bug Fixes + +* :ambulance: owner of a project cannot see attached private quota ([297a4ac](https://github.com/cloud-pi-native/console/commit/297a4ac410c739142d3ef88f1da58492881ddb6f)) + +## [8.24.0](https://github.com/cloud-pi-native/console/compare/v8.23.4...v8.24.0) (2025-01-28) + + +### Features + +* :sparkles: add an 'external' boolean properties on clusters ([4a64abc](https://github.com/cloud-pi-native/console/commit/4a64abccea40d2675f435b6cec21bb4f9aa3aa43)) +* :sparkles: vault plugin, switch to deploy or not vaultconnections ([cc9c4e6](https://github.com/cloud-pi-native/console/commit/cc9c4e6ad40412921a8d7afc888f014d92ef46c8)) + + +### Bug Fixes + +* :alien: gitlab response changed on upgrade ([d595f3a](https://github.com/cloud-pi-native/console/commit/d595f3ac80e84070e27ed257d4b8879ee2611d46)) +* :bug: names displayed twice at project creation ([079a20a](https://github.com/cloud-pi-native/console/commit/079a20a4e5d910e64e43a6df2925acee5ea7368c)) + +## [8.23.4](https://github.com/cloud-pi-native/console/compare/v8.23.3...v8.23.4) (2025-01-24) + + +### Bug Fixes + +* :bug: duplicates name should not be already allowed ([eb2a4db](https://github.com/cloud-pi-native/console/commit/eb2a4db44596ad2f9f6affbbdb2dea84cb28067c)) +* fix gitlab, no first commit on cloned repository ([2d7038f](https://github.com/cloud-pi-native/console/commit/2d7038fd1f5aefb67259666086048d11a6d5537f)) + +## [8.23.3](https://github.com/cloud-pi-native/console/compare/v8.23.2...v8.23.3) (2025-01-21) + + +### Bug Fixes + +* :ambulance: fix gitlab ci config file ([2c2a287](https://github.com/cloud-pi-native/console/commit/2c2a287a76723dfffecd9ae892a8897f03aef161)) + +## [8.23.2](https://github.com/cloud-pi-native/console/compare/v8.23.1...v8.23.2) (2025-01-21) + + +### Bug Fixes + +* :bug: add a better patch capability for kubernetes ([0050394](https://github.com/cloud-pi-native/console/commit/005039450701f9d77a4479487ba872af602ec689)) +* :bug: can't retrieved enc ns name for gitops cluster ([dcf3c1a](https://github.com/cloud-pi-native/console/commit/dcf3c1a147755ae698ba09f7bb7d1d79d13cc1d1)) +* :triangular_flag_on_post: no more cloned repository at gitlab createProject ([211d070](https://github.com/cloud-pi-native/console/commit/211d070d77802d894c70c584fa2c41e86a297295)) + +## [8.23.1](https://github.com/cloud-pi-native/console/compare/v8.23.0...v8.23.1) (2025-01-20) + + +### Bug Fixes + +* :ambulance: bad gitlab url fn ([2ec5332](https://github.com/cloud-pi-native/console/commit/2ec5332ce320e40da131ff537974c169716e3ac5)) + +## [8.23.0](https://github.com/cloud-pi-native/console/compare/v8.22.1...v8.23.0) (2025-01-20) + + +### Features + +* :fire: remove organization feature ([3b331f6](https://github.com/cloud-pi-native/console/commit/3b331f680716880e7ae4e33b6e441452e2816f3c)) +* :sparkles: store cluster secret in Vault ([cea79ef](https://github.com/cloud-pi-native/console/commit/cea79efc6ecb4c33d868d7f5044ddc7825c69ed5)) + + +### Bug Fixes + +* :bug: test if description is string before using replaceAll function ([d5b1234](https://github.com/cloud-pi-native/console/commit/d5b12342c236dca1231d05528f6ccd7746f62309)) + +## [8.22.1](https://github.com/cloud-pi-native/console/compare/v8.22.0...v8.22.1) (2025-01-09) + + +### Bug Fixes + +* :zap: package icons in client ([73fa90d](https://github.com/cloud-pi-native/console/commit/73fa90dd8aa0fe9c7fa8075ada31eea39af808e5)) +* patch vue-dsfr ([02079aa](https://github.com/cloud-pi-native/console/commit/02079aaf569b80f1a6a245504757524826a7ab3c)) + +## [8.22.0](https://github.com/cloud-pi-native/console/compare/v8.21.0...v8.22.0) (2025-01-08) + + +### Features + +* :sparkles: store cluster secret in Vault ([3935ca6](https://github.com/cloud-pi-native/console/commit/3935ca68cb0927bf150e15c3b205baebafa5cb98)) +* track project version provisioning ([227baf6](https://github.com/cloud-pi-native/console/commit/227baf6802687c9555d3931071a7e049d2756db1)) + + +### Bug Fixes + +* :bug: downgrade gitlab lib to avoid upsert user warning ([90faeff](https://github.com/cloud-pi-native/console/commit/90faeff2978e0ee2db7624b895550db278aa9c17)) +* :bug: nexus auto disable repo config on delete ([1295ece](https://github.com/cloud-pi-native/console/commit/1295ece0312a6c7b319c379e88be1ee570d75499)) + +## [8.21.0](https://github.com/cloud-pi-native/console/compare/v8.20.0...v8.21.0) (2024-12-16) + + +### Features + +* :construction: zone kv store management ([628a6e5](https://github.com/cloud-pi-native/console/commit/628a6e57cb439c8b899acdb8bf1995eedbe0a184)) + + +### Bug Fixes + +* :bug: fix not displaying swagger ([6f708a1](https://github.com/cloud-pi-native/console/commit/6f708a150402835c4ef044bdb8e69f20dfe3ee32)) +* :bug: retry delete project is failed ([c5856fa](https://github.com/cloud-pi-native/console/commit/c5856fadc49acd238ee61d289cf8441a5b3a2e18)) + +## [8.20.0](https://github.com/cloud-pi-native/console/compare/v8.19.1...v8.20.0) (2024-11-25) + + +### Features + +* :sparkles: bulk actions on projects ([951fc15](https://github.com/cloud-pi-native/console/commit/951fc152016e00e3bac6cb1d9920998e3cfb4a80)) + +## [8.19.1](https://github.com/cloud-pi-native/console/compare/v8.19.0...v8.19.1) (2024-11-23) + + +### Bug Fixes + +* :ambulance: broken admin token ([a1eff7f](https://github.com/cloud-pi-native/console/commit/a1eff7f96c6793338019eea0676b8f6dea4c0056)) + +## [8.19.0](https://github.com/cloud-pi-native/console/compare/v8.18.2...v8.19.0) (2024-11-22) + + +### Features + +* :art: use upsert zone hook to manage infra repo ([6fe47d1](https://github.com/cloud-pi-native/console/commit/6fe47d1922359c4934b5364fa3d87dd86cec129b)) +* :sparkles: easy in-cluster config ([01435e2](https://github.com/cloud-pi-native/console/commit/01435e2595383b4be00bdafb45970161765da6b9)) +* :sparkles: enable pwa on frontend ([c6c03f3](https://github.com/cloud-pi-native/console/commit/c6c03f3dadf9e954353ad11c29c19d50538d1e2a)) + + +### Bug Fixes + +* :bug: vault forgot to set kv to v2 ([90dca64](https://github.com/cloud-pi-native/console/commit/90dca64aca9d64b8491b332d0151f1be68dcbccc)) + +## [8.18.2](https://github.com/cloud-pi-native/console/compare/v8.18.1...v8.18.2) (2024-11-20) + + +### Bug Fixes + +* :bug: fix vault detect api hashicorp ([86959f5](https://github.com/cloud-pi-native/console/commit/86959f5874f60e898442ba4f734ff1a5581998a6)) + +## [8.18.1](https://github.com/cloud-pi-native/console/compare/v8.18.0...v8.18.1) (2024-11-20) + + +### Bug Fixes + +* :ambulance: fix pat migration ([df3f699](https://github.com/cloud-pi-native/console/commit/df3f699ddc972a2b2001a20d5e31dd5ba9ad7482)) + +## [8.18.0](https://github.com/cloud-pi-native/console/compare/v8.17.2...v8.18.0) (2024-11-20) + + +### Features + +* :sparkles: build project kv with config for projects ([f1c687f](https://github.com/cloud-pi-native/console/commit/f1c687f55ee4c8b8c2f342e431fb3f0982ffd72c)) +* ✨ add PAT ([2c35cd7](https://github.com/cloud-pi-native/console/commit/2c35cd72a1d0fb14f8c1a633b86e7ef00da80bab)) + + +### Bug Fixes + +* :bug: fix weird front behviours ([e06cf11](https://github.com/cloud-pi-native/console/commit/e06cf117c0fef4f49749d80a7ac5b8de813c6ed7)) + +## [8.17.2](https://github.com/cloud-pi-native/console/compare/v8.17.1...v8.17.2) (2024-11-14) + + +### Bug Fixes + +* :zap: disallow recursive list to avoid big latency ([1fed78c](https://github.com/cloud-pi-native/console/commit/1fed78c1742b70918c9bab1ff616f0cf681d66fa)) + +## [8.17.1](https://github.com/cloud-pi-native/console/compare/v8.17.0...v8.17.1) (2024-11-08) + + +### Bug Fixes + +* :ambulance: fix not displayed repositories in admin view ([48c0a92](https://github.com/cloud-pi-native/console/commit/48c0a92339db999c598fe24cdc9302b28cffa14d)) +* :ambulance: internal gitlab url ([16f195e](https://github.com/cloud-pi-native/console/commit/16f195e55222de023046aaabefa9b6d7f79062ee)) + +## [8.17.0](https://github.com/cloud-pi-native/console/compare/v8.16.0...v8.17.0) (2024-11-07) + + +### Features + +* :sparkles: config items section ([7c41eea](https://github.com/cloud-pi-native/console/commit/7c41eeaec2855355c0fa683d4b7ec81210eae6af)) +* :sparkles: hooks returned store accept null ([1255a32](https://github.com/cloud-pi-native/console/commit/1255a32237a6775e465fc0efe4dfe10bc9463816)) +* :sparkles: nexus fine write policy ([14897e6](https://github.com/cloud-pi-native/console/commit/14897e685ff6665da35464d93571a972e2c8dae9)) +* :sparkles: record and display last time a user logged ([1e8323b](https://github.com/cloud-pi-native/console/commit/1e8323bbfcd611891fe741434d4cb5f780f45dec)) +* gitlab optional internal url ([c1643a1](https://github.com/cloud-pi-native/console/commit/c1643a1eaae350220c4ecf114c7fa16bfb1983a1)) + +## [8.16.0](https://github.com/cloud-pi-native/console/compare/v8.15.1...v8.16.0) (2024-11-06) + + +### Features + +* :lipstick: disable cluster selection when creating a zone ([a8bb19c](https://github.com/cloud-pi-native/console/commit/a8bb19cb4ff50b69b892547e05b1541e322b0ecd)) +* :lipstick: user list rework ([5c13b9d](https://github.com/cloud-pi-native/console/commit/5c13b9d756c549a2f12d99e7f7384690f3166553)) +* :sparkles: nexus customizable write policy ([78a004b](https://github.com/cloud-pi-native/console/commit/78a004bc72e97b3a98c963560e8c11db45b34b67)) + + +### Bug Fixes + +* :bug: minors plugins fix and front ([22937c8](https://github.com/cloud-pi-native/console/commit/22937c875480dc0b6bf67f0784311f98300df04d)) + +## [8.15.1](https://github.com/cloud-pi-native/console/compare/v8.15.0...v8.15.1) (2024-10-29) + + +### Bug Fixes + +* :bug: update depracated import assertion type syntax ([d8f2072](https://github.com/cloud-pi-native/console/commit/d8f20728a0d96c6799e5bba7e821ed12356d6393)) + +## [8.15.0](https://github.com/cloud-pi-native/console/compare/v8.14.0...v8.15.0) (2024-10-29) + + +### Features + +* :lipstick: adding the project id in namespace labels ([eace611](https://github.com/cloud-pi-native/console/commit/eace611838cd2a5ce0e457971914c9d3bf29e6d0)) +* 🚸 rework project selection ([1641fc9](https://github.com/cloud-pi-native/console/commit/1641fc974f455734bedeb0cf2fdaf7356c9b850b)) + +## [8.14.0](https://github.com/cloud-pi-native/console/compare/v8.13.2...v8.14.0) (2024-10-17) + + +### Features + +* :lipstick: add argocd urls for each zone ([cf467ae](https://github.com/cloud-pi-native/console/commit/cf467aeaf82c44099f8b964dd4ef2e00a5323a90)) +* :sparkles: add npm support in npm ([60b7752](https://github.com/cloud-pi-native/console/commit/60b7752fd31ca36860e64df9b320d8468163d0d4)) +* :sparkles: introduce warn status on project ([9d69357](https://github.com/cloud-pi-native/console/commit/9d69357fde2014c1fe134803f8361680bac1d3fc)) +* :sparkles: keycloak client management for configuring sso oidc of each argocd zone ([44b3b06](https://github.com/cloud-pi-native/console/commit/44b3b06e3882fe2a60691a8bd29572e023b32146)) +* :sparkles: return warns and errors message from plugins to user ([be4443f](https://github.com/cloud-pi-native/console/commit/be4443fc10db5669578279fbcbac2b4d67adcb52)) +* :sparkles: show short logs about project to users ([4888e64](https://github.com/cloud-pi-native/console/commit/4888e64527d2627fc2e320300a39745e2571b73c)) + +## [8.13.2](https://github.com/cloud-pi-native/console/compare/v8.13.1...v8.13.2) (2024-10-02) + + +### Bug Fixes + +* :ambulance: no check on api response ([cfc56db](https://github.com/cloud-pi-native/console/commit/cfc56db5b4cdab03b3515d2cae1477e7546dfef8)) + +## [8.13.1](https://github.com/cloud-pi-native/console/compare/v8.13.0...v8.13.1) (2024-09-27) + + +### Bug Fixes + +* :loud_sound: change log logic in sonarqube plugin ([5006562](https://github.com/cloud-pi-native/console/commit/500656244f12d0d1edeb995e30be8cfa95ca8a4f)) + +## [8.13.0](https://github.com/cloud-pi-native/console/compare/v8.12.3...v8.13.0) (2024-09-27) + + +### Features + +* :loud_sound: add logger object ([02c6715](https://github.com/cloud-pi-native/console/commit/02c67150d738ae200f8937adb88bf32001ff2b8c)) +* :sparkles: plugins can return warning to not interupt hook ([3e132c3](https://github.com/cloud-pi-native/console/commit/3e132c3238cc98d1d75ec956ce55efebe2c5304a)) + + +### Bug Fixes + +* :bug: change imports exports to not use z.lazy, fix swagger-ui ([64a60fe](https://github.com/cloud-pi-native/console/commit/64a60fe6bc84ea9de51519049252957726963afa)) +* :bug: dsfr tabs require rewrite DsoHome.vue ([ea03623](https://github.com/cloud-pi-native/console/commit/ea0362303757ac239e5b753af6777514cb714b03)) + +## [8.12.3](https://github.com/cloud-pi-native/console/compare/v8.12.2...v8.12.3) (2024-09-19) + + +### Bug Fixes + +* :passport_control: allow more actions to admin token ([81fa606](https://github.com/cloud-pi-native/console/commit/81fa60653e2bfbcb37251bda448ea3d2f9805eb3)) + +## [8.12.2](https://github.com/cloud-pi-native/console/compare/v8.12.1...v8.12.2) (2024-09-19) + + +### Bug Fixes + +* :ambulance: cbx not checked cause vuedsfr breaking change ([c7e607f](https://github.com/cloud-pi-native/console/commit/c7e607fa6a373866489d7e72e7234107d76a3b7e)) + +## [8.12.1](https://github.com/cloud-pi-native/console/compare/v8.12.0...v8.12.1) (2024-09-17) + + +### Bug Fixes + +* :bug: fix behaviour of complex querying on user list ([b63bd9a](https://github.com/cloud-pi-native/console/commit/b63bd9a56c118fe52ab38589a28c90777c5a557e)) + +## [8.12.0](https://github.com/cloud-pi-native/console/compare/v8.11.1...v8.12.0) (2024-09-17) + + +### Features + +* :sparkles: add admins api tokens ([e141642](https://github.com/cloud-pi-native/console/commit/e141642eb2b717c29cf91f482e91761e7ce4bfa8)) +* :sparkles: add users search filter (admin) ([cc1ab56](https://github.com/cloud-pi-native/console/commit/cc1ab56b2ac496ffd01196ee4d97362ab7a08e03)) + +## [8.11.1](https://github.com/cloud-pi-native/console/compare/v8.11.0...v8.11.1) (2024-09-02) + + +### Bug Fixes + +* :lipstick: better repo ui ([084fc91](https://github.com/cloud-pi-native/console/commit/084fc91332fbdd6052cadbde204df77cd772a07d)) + +## [8.11.0](https://github.com/cloud-pi-native/console/compare/v8.10.1...v8.11.0) (2024-08-29) + + +### Features + +* :sparkles: standalone repo ([ea9a2ba](https://github.com/cloud-pi-native/console/commit/ea9a2bac3fc9696fde6f1ae56b916e180ae19039)) + + +### Bug Fixes + +* :ambulance: edit previous migrations ([2a87288](https://github.com/cloud-pi-native/console/commit/2a87288ea99c19df706fe2f68173ba4bea919a17)) + + +### Performance Improvements + +* :zap: avoid unuseful keycloak call and auto-refresh ([8dcb1c2](https://github.com/cloud-pi-native/console/commit/8dcb1c24e9cce760e278222dcb84c62dff4118a1)) + +## [8.10.1](https://github.com/cloud-pi-native/console/compare/v8.10.0...v8.10.1) (2024-08-27) + + +### Bug Fixes + +* :ambulance: missed admin authorized to remove a member ([15eafa9](https://github.com/cloud-pi-native/console/commit/15eafa9996d7e1f827f5eb1cf6aef425f4620a2b)) + +## [8.10.0](https://github.com/cloud-pi-native/console/compare/v8.9.4...v8.10.0) (2024-08-26) + + +### Features + +* :sparkles: add project dedicated approle and policy in vault, to be used by argocd ([e354978](https://github.com/cloud-pi-native/console/commit/e354978392b8c5d7a4c3591388f0f0a9aa3d7865)) +* :sparkles: introduce fine grained perms and and roles ([ab0a7a5](https://github.com/cloud-pi-native/console/commit/ab0a7a55e42ef94a18e529b168d5f299687ca526)) +* :sparkles: maintenance mode ([fdbd5f0](https://github.com/cloud-pi-native/console/commit/fdbd5f0356a16c24ef677f8a06309aac89af3a4c)) +* :sparkles: option to mirror all branches ([655b032](https://github.com/cloud-pi-native/console/commit/655b0329599fe7e0857e487e3b0fd3ecb49b94ac)) + +## [8.9.4](https://github.com/cloud-pi-native/console/compare/v8.9.3...v8.9.4) (2024-08-09) + + +### Bug Fixes + +* :ambulance: handle missing commit in gitlab repo ([2ef9930](https://github.com/cloud-pi-native/console/commit/2ef993001770e76be5bdf2795a0cbe8e302cf71a)) + +## [8.9.3](https://github.com/cloud-pi-native/console/compare/v8.9.2...v8.9.3) (2024-08-08) + + +### Bug Fixes + +* :ambulance: fix error on getArgoRepo when the infra repository is empty (zero commit) ([bf3f3f1](https://github.com/cloud-pi-native/console/commit/bf3f3f10c0e50eaf2f2b788b587c04e27f1eff00)) + +## [8.9.2](https://github.com/cloud-pi-native/console/compare/v8.9.1...v8.9.2) (2024-07-19) + + +### Bug Fixes + +* :ambulance: clone private repositories in gitlab plugin ([d42bbf8](https://github.com/cloud-pi-native/console/commit/d42bbf8a02950a6f9d0d3218bd7bc40cec203843)) + +## [8.9.1](https://github.com/cloud-pi-native/console/compare/v8.9.0...v8.9.1) (2024-07-18) + + +### Bug Fixes + +* :bug: search on HEAD ref instead of main ([ac13b5f](https://github.com/cloud-pi-native/console/commit/ac13b5fca8c952841e162ed300fdce099c74ac9b)) + +## [8.9.0](https://github.com/cloud-pi-native/console/compare/v8.8.2...v8.9.0) (2024-07-17) + + +### Features + +* :rocket: add helm detection and list values files ([006ccba](https://github.com/cloud-pi-native/console/commit/006ccbaa6d4710af8f1495ae8dadccf729b7dcc6)) + +## [8.8.2](https://github.com/cloud-pi-native/console/compare/v8.8.1...v8.8.2) (2024-07-15) + + +### Bug Fixes + +* :art: harmonize hook calling in business ([4effaab](https://github.com/cloud-pi-native/console/commit/4effaab7f9c80561d0fd52e365822d806a5426f5)) +* :bug: don't ignore user token when testing cluster deployment mode ([cc488c3](https://github.com/cloud-pi-native/console/commit/cc488c3053fadffba040f8396eebfa27bf6c1f79)) +* :bug: env name error not displayed ([d2f84e8](https://github.com/cloud-pi-native/console/commit/d2f84e822b94074615df3385f0c088582ceaf3cf)) +* :bug: require tls serverName ([9d4dea2](https://github.com/cloud-pi-native/console/commit/9d4dea22e68aab7ef6ee6b74a16e92a397002709)) + +## [8.8.1](https://github.com/cloud-pi-native/console/compare/v8.8.0...v8.8.1) (2024-07-09) + + +### Bug Fixes + +* :ambulance: not ready for project reponse validation ([c77dd68](https://github.com/cloud-pi-native/console/commit/c77dd6889886b3c867294437e5377573771a7ff0)) +* :ambulance: second try to fix ([1ef6244](https://github.com/cloud-pi-native/console/commit/1ef6244d7536de5704ec6fc9a83b0cfc5d806147)) + +## [8.8.1](https://github.com/cloud-pi-native/console/compare/v8.8.0...v8.8.1) (2024-07-09) + + +### Bug Fixes + +* :ambulance: not ready for project reponse validation ([c77dd68](https://github.com/cloud-pi-native/console/commit/c77dd6889886b3c867294437e5377573771a7ff0)) + +## [8.8.0](https://github.com/cloud-pi-native/console/compare/v8.7.1...v8.8.0) (2024-07-09) + + +### Features + +* :sparkles: query filter admin projects ([feb701d](https://github.com/cloud-pi-native/console/commit/feb701d9d36db0120032f0c1f8888a893c799495)) + +## [8.7.1](https://github.com/cloud-pi-native/console/compare/v8.7.0...v8.7.1) (2024-07-05) + + +### Bug Fixes + +* :bug: insert user when first accessing admin pages ([095b9de](https://github.com/cloud-pi-native/console/commit/095b9de4e282712919e932bf67b15fdaeec96932)) +* :lipstick: repo should be able to change infra, not infra ([1069728](https://github.com/cloud-pi-native/console/commit/10697280499b44b1a1eef28a71d510bfe8916cf3)) + +## [8.7.0](https://github.com/cloud-pi-native/console/compare/v8.6.0...v8.7.0) (2024-07-03) + + +### Features + +* :necktie: hide private quotas for all on environment form ([ef08f29](https://github.com/cloud-pi-native/console/commit/ef08f29080d2be32236839be1e402fe76f44f465)) + + +### Bug Fixes + +* :lock: serialize sevice monitor response ([26ee98f](https://github.com/cloud-pi-native/console/commit/26ee98fa21984e57eaae8251dc14394f0e94c4b9)) + +## [8.6.0](https://github.com/cloud-pi-native/console/compare/v8.5.0...v8.6.0) (2024-06-28) + + +### Features + +* :art: change subpath of deployments ([d7b57ca](https://github.com/cloud-pi-native/console/commit/d7b57ca095bcb1ae8fcde5ead7a533b0f0ce81e5)) +* :sparkles: add user leo poumailloux ([0147e07](https://github.com/cloud-pi-native/console/commit/0147e071bbdfbb8c666d20247bd95938f645f152)) +* :sparkles: give project ownership ([dc6fa79](https://github.com/cloud-pi-native/console/commit/dc6fa798fe2fe37395c521482bbc857eb74b50d1)) +* :sparkles: truncate description ([8191a41](https://github.com/cloud-pi-native/console/commit/8191a41ae808980d400f1e65499e012d0d192c28)) + + +### Bug Fixes + +* :bug: handle vault ha for status monitor ([3692e01](https://github.com/cloud-pi-native/console/commit/3692e010d9d4c0c03e8403b4d5df3591f0312209)) +* :bug: interface suppression & add explication ([4476c9b](https://github.com/cloud-pi-native/console/commit/4476c9bacdcc748ca0d2d1029f8980392a2ea068)) +* :bug: isolation des groupes d'envs dans keycloak ([91b5d55](https://github.com/cloud-pi-native/console/commit/91b5d551a88f5059868a80271be5b37c451839d0)) +* :bug: prevent env deletion if project is locked ([d75a5cf](https://github.com/cloud-pi-native/console/commit/d75a5cfc4e86741a91ba31d0c2d2fc2b21fa5226)) +* :bug: prevent env deletion if project is locked ([a273869](https://github.com/cloud-pi-native/console/commit/a273869ba421ee37fc342c585c38c978aab5d411)) +* :bug: support slashes in syncRepository branchName ([d304427](https://github.com/cloud-pi-native/console/commit/d304427c8bd0af8e7080ec9480143d0abab3bb76)) + +## [8.5.0](https://github.com/cloud-pi-native/console/compare/v8.4.1...v8.5.0) (2024-06-10) + + +### Features + +* :sparkles: add text filter for admin user list ([d44f142](https://github.com/cloud-pi-native/console/commit/d44f142e9f559f7714e3eb71650c1dd3c8a325cd)) +* :sparkles: allow harbor quota limit ([9644d03](https://github.com/cloud-pi-native/console/commit/9644d037ea2642ba9e456e362c18a1eed520b66b)) +* :sparkles: argocd extra repositories ([303f4a2](https://github.com/cloud-pi-native/console/commit/303f4a2fac89ce511aeac9cf87dbf6221908e373)) + + +### Bug Fixes + +* :bug: keycloak crashed when more than 10 environments ([f3d4f5d](https://github.com/cloud-pi-native/console/commit/f3d4f5d3cc5b49946cc4e1ab969895229524f335)) +* :bug: manage if no users in project ([cd23a59](https://github.com/cloud-pi-native/console/commit/cd23a5940eddf1d8509bf7594842db5bcc5fa1ae)) +* :bug: why is it useful ? ([ee2bf5c](https://github.com/cloud-pi-native/console/commit/ee2bf5ce588a92b303b3af8db2bf24ed999822eb)) + +## [8.4.1](https://github.com/cloud-pi-native/console/compare/v8.4.0...v8.4.1) (2024-05-31) + + +### Bug Fixes + +* :bug: add try catch to parsing plugin error ([b958529](https://github.com/cloud-pi-native/console/commit/b95852942863f75eda7458e7593e09579c7811d5)) + +## [8.4.0](https://github.com/cloud-pi-native/console/compare/v8.3.0...v8.4.0) (2024-05-29) + + +### Features + +* :sparkles: create a user usable robot for harbor ([a71dfc9](https://github.com/cloud-pi-native/console/commit/a71dfc992b62a6c3c69dcc6d7de98b8d2d72a79c)) +* :sparkles: customize keycloak theme ([7c21375](https://github.com/cloud-pi-native/console/commit/7c21375d1ad56a03f84c25d04cbe0ada7eac8d5c)) +* :sparkles: option for gitlab display secret ([28d3ebc](https://github.com/cloud-pi-native/console/commit/28d3ebce4d6912904e1f21cd3c126ca342fc3cfb)) + + +### Bug Fixes + +* :art: squash const ([cd1be8c](https://github.com/cloud-pi-native/console/commit/cd1be8c8f282be045ffce473387b21ed95355f5e)) +* :bug: fallback if no owners are found ([629ef40](https://github.com/cloud-pi-native/console/commit/629ef40afb3c6ff0a22b0f17c2c86a343463a8ba)) +* :bug: missing await and ambiguous message ([0530b45](https://github.com/cloud-pi-native/console/commit/0530b4520a5bc05d6a45f9f76029c898f960ee34)) +* :bug: update cluster logic ([65ba7e1](https://github.com/cloud-pi-native/console/commit/65ba7e10f0d7dd9e1675da633c922fff8499ee95)) + +## [8.3.0](https://github.com/cloud-pi-native/console/compare/v8.2.2...v8.3.0) (2024-05-16) + + +### Features + +* :sparkles: handle keycloak admin group membership from console ([4741878](https://github.com/cloud-pi-native/console/commit/4741878fdfa4d784ed2e7695407ebce6b00b4850)) + + +### Bug Fixes + +* :ambulance: possible missed clusters in payload ([47efcc8](https://github.com/cloud-pi-native/console/commit/47efcc893f272823b5d7d7f4b78b9c29ddad9095)) +* :ambulance: unhandled promise gitlab ([592035c](https://github.com/cloud-pi-native/console/commit/592035c95605f004e62cba71faff36ea189d6173)) + +## [8.2.2](https://github.com/cloud-pi-native/console/compare/v8.2.1...v8.2.2) (2024-05-13) + + +### Bug Fixes + +* :bug: fix update cluster ([f02958c](https://github.com/cloud-pi-native/console/commit/f02958ca3888fdb452a545fd94b8d20f9ebb93ed)) +* :bug: stores should have unique names ([6e8032a](https://github.com/cloud-pi-native/console/commit/6e8032a68b71d7ea86b8153d0984013e5bd4c8df)) + +## [8.2.1](https://github.com/cloud-pi-native/console/compare/v8.2.0...v8.2.1) (2024-05-06) + + +### Bug Fixes + +* :fire: remove unjustified refine ([9b1d41e](https://github.com/cloud-pi-native/console/commit/9b1d41ec402446678518bb39c0f3cff543afceb3)) +* :mute: remove problematic log ([639e982](https://github.com/cloud-pi-native/console/commit/639e982df49ccb5888adb26f3a0b1ca62fe53973)) + +## [8.2.0](https://github.com/cloud-pi-native/console/compare/v8.1.1...v8.2.0) (2024-05-03) + + +### Features + +* :sparkles: adding the value file commit ([6ea872b](https://github.com/cloud-pi-native/console/commit/6ea872b498e658113983cdc2b80d9d2ca3cf770d)) +* :sparkles: can configure plugins globally or by project ([15b66c1](https://github.com/cloud-pi-native/console/commit/15b66c1be31311e3b6062c3d771fb3ecc3dcbfa8)) + + +### Bug Fixes + +* :bug: override plugins unzip if already exists ([ff65133](https://github.com/cloud-pi-native/console/commit/ff651332fcee6ae752bdf4533ebbb88f216bfe95)) + +## [8.1.1](https://github.com/cloud-pi-native/console/compare/v8.1.0...v8.1.1) (2024-04-24) + + +### Bug Fixes + +* :ambulance: correct sql query for zoneId ([c47dae4](https://github.com/cloud-pi-native/console/commit/c47dae4a4f74cd51e6c9fe19856d4b8eea08cbb8)) + +## [8.1.0](https://github.com/cloud-pi-native/console/compare/v8.0.3...v8.1.0) (2024-04-24) + + +### Features + +* :safety_vest: allow unlogged users to access services health ([e2fc942](https://github.com/cloud-pi-native/console/commit/e2fc9423b107f1afd9fdbe1b017b7dd1f3d2eab1)) +* :safety_vest: check existing zone slug before creating one ([072fffb](https://github.com/cloud-pi-native/console/commit/072fffbd1d4788329fb76b927242854feb25d31b)) +* :sparkles: add zones ([53d155f](https://github.com/cloud-pi-native/console/commit/53d155f244a846e0cf23d3cf75528e6d937fe0ce)) +* :sparkles: allow repo sync from console ui ([1cd1c93](https://github.com/cloud-pi-native/console/commit/1cd1c93a9e1d95663e51f4de24a5b518fa2bee1a)) +* :sparkles: display project services in admin view ([a5d53fd](https://github.com/cloud-pi-native/console/commit/a5d53fd586dd912b13b72196ed99c2354c022448)) + + +### Bug Fixes + +* :bug: app version in client footer ([ac2f26e](https://github.com/cloud-pi-native/console/commit/ac2f26e88ca3fa593668d4f9641465b09855c663)) +* :bug: get only client app version from env on production build ([3bb3017](https://github.com/cloud-pi-native/console/commit/3bb3017f968e8e93b955b33cb494d51ecce5521a)) +* :bug: handle stage and zone changes in environment form ([5aa0a58](https://github.com/cloud-pi-native/console/commit/5aa0a585e9903e0a29f7bec29e4a08090109542b)) + +## [8.0.3](https://github.com/cloud-pi-native/console/compare/v8.0.2...v8.0.3) (2024-04-16) + + +### Bug Fixes + +* :ambulance: gitlab secret ([0c03a95](https://github.com/cloud-pi-native/console/commit/0c03a9551377705fced2dedf1b9e8063df9d2748)) + +## [8.0.2](https://github.com/cloud-pi-native/console/compare/v8.0.1...v8.0.2) (2024-04-11) + + +### Bug Fixes + +* :lock: remove old robot permission that harbor does not support anymore ([1d988ce](https://github.com/cloud-pi-native/console/commit/1d988ce86b6b338a576d517816f013295b2f18d3)) + +## [8.0.1](https://github.com/cloud-pi-native/console/compare/v8.0.0...v8.0.1) (2024-04-04) + + +### Bug Fixes + +* :ambulance: change kubernetes logic ([3679c22](https://github.com/cloud-pi-native/console/commit/3679c2221c961b4887e1191229edaacd6e34649b)) + +## [8.0.0](https://github.com/cloud-pi-native/console/compare/v7.0.1...v8.0.0) (2024-04-02) + + +### ⚠ BREAKING CHANGES + +* :sparkles: simplify hooks for idempotency + +### Features + +* :safety_vest: allow + sign for repo externalUserName ([4a695ef](https://github.com/cloud-pi-native/console/commit/4a695eff71ea6a03f839d19d2e2526180ea5161b)) +* :sparkles: add replay hooks button for project ([9147231](https://github.com/cloud-pi-native/console/commit/91472317ebd112b4f360d859d25d8793c89eb331)) +* :sparkles: add sonar properties var file to gitlab ([0462a36](https://github.com/cloud-pi-native/console/commit/0462a364b54e66639ade6997f6f4c48e9fe305c6)) +* :sparkles: simplify hooks for idempotency ([aac0a2d](https://github.com/cloud-pi-native/console/commit/aac0a2d331acd4e86531591de99cc944584f7684)) +* 🚧 création du repo infra-apps à la création du projet ([17007f7](https://github.com/cloud-pi-native/console/commit/17007f7eb0bb8c9ba612cf70536236b428b801da)) + + +### Bug Fixes + +* :ambulance: gitlab plugin deleted public repo secret ([46002b0](https://github.com/cloud-pi-native/console/commit/46002b007e9dfd69ce5daeee33cfd61164e0ed8e)) +* :ambulance: gitlab plugin deleted public repo secret ([353f43e](https://github.com/cloud-pi-native/console/commit/353f43eba74d5d0b0a95ace2ee65d9c51c0685b7)) +* :bug: reload user projects after hooks replay ([ae57ed0](https://github.com/cloud-pi-native/console/commit/ae57ed017dac4e5601cdf80a02598967bf75364b)) +* :lipstick: improve logs ui ([ec6ecb2](https://github.com/cloud-pi-native/console/commit/ec6ecb283f0c7c37589c02e194f1b101e9dc10c2)) +* :zap: manage hook concurrency execution ([6af135f](https://github.com/cloud-pi-native/console/commit/6af135f0548d232ef5bd190375057da0968d8f94)) + +## [7.0.1](https://github.com/cloud-pi-native/console/compare/v7.0.0...v7.0.1) (2024-03-18) + + +### Bug Fixes + +* :bug: add a common error parser for logs ([2391439](https://github.com/cloud-pi-native/console/commit/2391439ef6147119863278bd69ac01797b368f76)) +* :bug: add a common error parser for logs ([b6f28d7](https://github.com/cloud-pi-native/console/commit/b6f28d738c03c3c023d16cf658de8cdc384aa6df)) +* :bug: correctly handle external plugins init ([91e5d7f](https://github.com/cloud-pi-native/console/commit/91e5d7f27f90c1e249ed9e295b05400f06565c2d)) +* :green_heart: fix ci-cd build and release ([a036562](https://github.com/cloud-pi-native/console/commit/a036562f005ba0550c4bf25248227aee02357371)) + +## [7.0.0](https://github.com/cloud-pi-native/console/compare/console-v6.5.1...console-v7.0.0) (2024-03-13) + + +### ⚠ BREAKING CHANGES + +* :art: split plugins +* :boom: environment management + +### Features + +* :alien: synch controllers with plugin calls ([d0a271d](https://github.com/cloud-pi-native/console/commit/d0a271d188840d8682b6abe412ee8da4db7992b1)) +* :boom: environment management ([b4f3793](https://github.com/cloud-pi-native/console/commit/b4f379315803aee1264192c16851af049fa661ee)) +* :children_crossing: add confirm box for updating an organization ([5c3cd20](https://github.com/cloud-pi-native/console/commit/5c3cd207221a22ed5d93bdf8638b23c9849897f6)) +* :children_crossing: add default branch to sync in mirror pipeline ([45dbc01](https://github.com/cloud-pi-native/console/commit/45dbc01c4a95d04a201b2c5e198e3b192e1e5fe2)) +* :children_crossing: add filtering on admin projects view ([dc5412c](https://github.com/cloud-pi-native/console/commit/dc5412c1a80ce84cd563376a0b7f72da7926a858)) +* :children_crossing: better ux for forms in tiles ([da68243](https://github.com/cloud-pi-native/console/commit/da68243a91865732db46b9ae610a82eeec5c9bf2)) +* :children_crossing: improve home page and update doc link ([038d982](https://github.com/cloud-pi-native/console/commit/038d982ead7bbfa70debdf902d5c46e8518c1e60)) +* :children_crossing: inform users that clipboard works only with https ([909c3b6](https://github.com/cloud-pi-native/console/commit/909c3b672bcae1401d2133c8fff94cdca91f61bc)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :iphone: more responsive sideMenu and permissionForm ([c840306](https://github.com/cloud-pi-native/console/commit/c8403067224b5ad90e34b8233a1830ceeec8b715)) +* :loud_sound: add payload and full hook response in validation hooks ([adabb02](https://github.com/cloud-pi-native/console/commit/adabb02fd0b8028b1fcdf1113744234a925d72ce)) +* :loud_sound: add reqId in db logs ([2b7f22b](https://github.com/cloud-pi-native/console/commit/2b7f22b7e24748dc37c8623bbda3e914ed7cae00)) +* :necktie: do not fail and lock project for membership reasons ([91038d5](https://github.com/cloud-pi-native/console/commit/91038d5ed3533e4d4eacb8eac3539f29a60ab091)) +* :passport_control: update harbor project member role ([4419dd5](https://github.com/cloud-pi-native/console/commit/4419dd5f395f79a9eb99e8c0f305a7433ad84f7e)) +* :safety_vest: update password character support ([c82f35f](https://github.com/cloud-pi-native/console/commit/c82f35fb7f20ad06c0a63eb518407d17ddb88b51)) +* :sparkles: add backoffice for admins ([fadfdfc](https://github.com/cloud-pi-native/console/commit/fadfdfc43c427261adc3e31dbb1907d6dcefc5cb)) +* :sparkles: add infos key for cluster ([25ab8b8](https://github.com/cloud-pi-native/console/commit/25ab8b8d821c37f4491bd8fea075bdc7fbfcfc93)) +* :sparkles: add or remove a team member via keycloak ([0563d20](https://github.com/cloud-pi-native/console/commit/0563d20ca93a25decc7dbb5d945e2b738b0d575a)) +* :sparkles: add project environments to hook payload ([7aa8117](https://github.com/cloud-pi-native/console/commit/7aa81179ac95afdddf711e1a0a4b3c3a45a35c5a)) +* :sparkles: add quota and stage management for admin ([80ce642](https://github.com/cloud-pi-native/console/commit/80ce642b69a5eaaf1b53c7d884b70889c4e71d9d)) +* :sparkles: add quotas selection for environment ([bb97f5f](https://github.com/cloud-pi-native/console/commit/bb97f5f17c5e7b497eccb655a6eb5da692f7df73)) +* :sparkles: add stage in setPermission payload ([e920805](https://github.com/cloud-pi-native/console/commit/e920805a3181ae98bf98b774d19e02d1e8a2139c)) +* :sparkles: add stage management for admin ([e133853](https://github.com/cloud-pi-native/console/commit/e13385347610014a4edcc0b2ff12252386328cb8)) +* :sparkles: add tlsServerName input in cluster form ([4bdc01e](https://github.com/cloud-pi-native/console/commit/4bdc01ef9dd5b2041a34491e46b9759e5c6652f0)) +* :sparkles: better integration of sonarqube ([c0a0517](https://github.com/cloud-pi-native/console/commit/c0a05173a158b74d752aafaccdff58fc3b29c382)) +* :sparkles: delete cluster if no environment suscribed ([8edc50a](https://github.com/cloud-pi-native/console/commit/8edc50a85f266f1135b340d6df39a48ea7ac746b)) +* :sparkles: disallow hyphen on project, environment and stage ([f9b05ee](https://github.com/cloud-pi-native/console/commit/f9b05eefee41f7d514d749a0ac3fa1c1d063f051)) +* :sparkles: display cluster infos to users on environment form ([4afdf34](https://github.com/cloud-pi-native/console/commit/4afdf34fc40df7789a00fe6d159ee73370b94cb9)) +* :sparkles: download projects data button for admins ([cb38e75](https://github.com/cloud-pi-native/console/commit/cb38e7573314ba0980c4161d1a77a9e83fddb236)) +* :sparkles: enable admin cluster update ([f81008e](https://github.com/cloud-pi-native/console/commit/f81008e97c25bc73466b6257bd4cb2dc0bde9163)) +* :sparkles: handle gitlab membership ([7ff68bd](https://github.com/cloud-pi-native/console/commit/7ff68bdf8f9a16562cfc00fbdb0c322b0f09943d)) +* :sparkles: lock and unlock projects according to their org status ([3b9444a](https://github.com/cloud-pi-native/console/commit/3b9444aeaa2fab76461946e9a4d00e8afe4a62f1)) +* :sparkles: retrieve user from kc if does not exist in db ([3d4461a](https://github.com/cloud-pi-native/console/commit/3d4461ae7d82e6f64a1644921893d89f41e9a2d9)) +* :sparkles: services secrets improvement ([40e3ddd](https://github.com/cloud-pi-native/console/commit/40e3ddd2acd7e000b03f5bfa5663533449d315bf)) +* :sparkles: to fn, more infos, multi tiles ([8c4de83](https://github.com/cloud-pi-native/console/commit/8c4de83cf3b4bc0e335292d437ddf7d22305ff93)) +* :speech_balloon: translate stage in front for better understanding ([5a66479](https://github.com/cloud-pi-native/console/commit/5a6647980319524098a72fcbd2be6affbda3f3d0)) +* :technologist: add images auto-scan to harbor settings ([507c6e6](https://github.com/cloud-pi-native/console/commit/507c6e6a541b19502084276db30cf834e586d955)) +* :technologist: add swagger for server api ([abe15d2](https://github.com/cloud-pi-native/console/commit/abe15d2ceb93039ecd795389156f8730e5327841)) + + +### Bug Fixes + +* :alien: add owner in archiveProject hookpayload ([5c99183](https://github.com/cloud-pi-native/console/commit/5c99183f03afe0c20980c9649ce37788b833a351)) +* :alien: change logo for svg ([aa8315b](https://github.com/cloud-pi-native/console/commit/aa8315b63617c8413a448499e2ab0e68c216faef)) +* :alien: fetch does not support proxy natively ([20d531d](https://github.com/cloud-pi-native/console/commit/20d531d622e6fe9e2869e13db0d205b44ad602ce)) +* :alien: sync controllers require bigger axios timeout ([5116bbd](https://github.com/cloud-pi-native/console/commit/5116bbdb8f49b73c920ac04421ca05ccabef902c)) +* :ambulance: bad logic register plugins ([049040e](https://github.com/cloud-pi-native/console/commit/049040eafda26214afbc2118cb56542b133feca5)) +* :ambulance: preserve status colum in project ([febfaff](https://github.com/cloud-pi-native/console/commit/febfaff702d6a55210723ce213d603ad059ceaed)) +* :ambulance: register cluster destination via kubeconfig ([0e8d310](https://github.com/cloud-pi-native/console/commit/0e8d3102267268c595d172808e30183dbe4b3aad)) +* :ambulance: send internalRepoName to plugins on updateRepository hook ([2bde004](https://github.com/cloud-pi-native/console/commit/2bde0045592f2898975633c0c6f959311234f7f3)) +* :art: can disble plugins by name, and some fixes ([f2bd397](https://github.com/cloud-pi-native/console/commit/f2bd397e0526d76d439ef8f38b5dcebdfbaa8ede)) +* :art: refactor of admin project types and refs ([be0684f](https://github.com/cloud-pi-native/console/commit/be0684f378696a24304d7abecd6b90c0738e1785)) +* :bento: update MultiSelector component ([9a44bb3](https://github.com/cloud-pi-native/console/commit/9a44bb35d8aa0da1aaf8f96a745c28f97f6ea53c)) +* :bug: add conditional chaining to avoid error in dsobadge ([56ad040](https://github.com/cloud-pi-native/console/commit/56ad0403242222f50ef8abe898b46c241d40b66d)) +* :bug: add missing await in gitlab createProjectMirror ([f1bf907](https://github.com/cloud-pi-native/console/commit/f1bf9072b88abec3f08654720f64571aa421db34)) +* :bug: change appproject policy ([e20b6c4](https://github.com/cloud-pi-native/console/commit/e20b6c431348b08bed7ea215a967c51d0e6187f0)) +* :bug: clusters related bugs ([c2ba63e](https://github.com/cloud-pi-native/console/commit/c2ba63e94d87bdf1741e9a51e4522c3f77b354de)) +* :bug: create appropriate folders for server image ([3027fef](https://github.com/cloud-pi-native/console/commit/3027fef08ebc8a58e2e705645febaf40af7314f5)) +* :bug: filter archived project from cluster form ([50913f5](https://github.com/cloud-pi-native/console/commit/50913f57def7d8e3e36f63a5876e476e64884828)) +* :bug: fix endless redirect on login if authenticated ([461c6bd](https://github.com/cloud-pi-native/console/commit/461c6bd385f44a551fe9af1027c6beca1e7fa9ec)) +* :bug: fix gitlab group deletion ([12a35d0](https://github.com/cloud-pi-native/console/commit/12a35d08619fbeb231d4e7931fb7fdb51ef63fde)) +* :bug: fix missing extensions ([4cd4b21](https://github.com/cloud-pi-native/console/commit/4cd4b212e88d30b447d5674cf157ce71862fe14a)) +* :bug: fix not updated argocd cluster and not using insecure in config ([69e7a9a](https://github.com/cloud-pi-native/console/commit/69e7a9ac7a584d76ce047b8330356ce2387218d7)) +* :bug: fix types, add void and handle it ([110cfed](https://github.com/cloud-pi-native/console/commit/110cfedfcb2a58757b5ad47f96f6323cbe034550)) +* :bug: import new shared package ([ff0b0db](https://github.com/cloud-pi-native/console/commit/ff0b0db9dcc96c10b409b51bd4180c03b5b01478)) +* :bug: launch e2e test in open mode without turbo ([d3497d1](https://github.com/cloud-pi-native/console/commit/d3497d13e3862d7701cbbd2da65cb7a571032eb2)) +* :bug: manage cancel emit on update repo form ([16df7b4](https://github.com/cloud-pi-native/console/commit/16df7b43d584fb1edfa5a01938356b04b785429b)) +* :bug: missed limits ([f2ba02d](https://github.com/cloud-pi-native/console/commit/f2ba02d41e923b0add154fc7cb5ae7d8a2c89370)) +* :bug: missing check in search group ([7fb65c1](https://github.com/cloud-pi-native/console/commit/7fb65c12754eea955c7ba942329cf5bfa612f8b7)) +* :bug: missing check nexus find user ([993dd2a](https://github.com/cloud-pi-native/console/commit/993dd2ab30687d0ea23000596f909b77b3be70e5)) +* :bug: missing check nexus find user ([e0f1674](https://github.com/cloud-pi-native/console/commit/e0f167424df8e0c01619a0da38bf3e5723014ee1)) +* :bug: now the admin can chosse skip tls api kube ([e09aa2a](https://github.com/cloud-pi-native/console/commit/e09aa2ac659e1439f59050c56256f530b9f8cae6)) +* :bug: optional paramater to call repeatFn ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: pin pnpm version in dockerfiles ([b494a2f](https://github.com/cloud-pi-native/console/commit/b494a2f0d190e3da81f9765dded76187333e61af)) +* :bug: pnpm lock ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: retrieve kc user bug fix ([aaf61c5](https://github.com/cloud-pi-native/console/commit/aaf61c51a288cd7998a5d3883cf467471b42c3cb)) +* :bug: run.sh js to ts reference ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: send cluster kubeconfig in hook paylaod ([e21c692](https://github.com/cloud-pi-native/console/commit/e21c6923d016bb59a69be457d5d4efc08243a771)) +* :bug: share loadingct display knowledge ([8520a5c](https://github.com/cloud-pi-native/console/commit/8520a5c827d05316e9dedc59dd90a92fb3f513d4)) +* :bug: should not set caData if skipTLSVerify is true ([ccd66f8](https://github.com/cloud-pi-native/console/commit/ccd66f83be75850ffe47e87ff405fcf9cf136e3c)) +* :bug: update compile cmd to build in plugins pkgs ([b9e4424](https://github.com/cloud-pi-native/console/commit/b9e44246c22238c3cdb383515816951ace4f65c2)) +* :bug: use argoCD regex for internalRepoName ([6c2ed3f](https://github.com/cloud-pi-native/console/commit/6c2ed3f682514b947e66547ce136d148b9e2d686)) +* :bug: various fixes around plugins and controllers ([6271c67](https://github.com/cloud-pi-native/console/commit/6271c67f3d8471ee6afc3409c5d753f7ade950f0)) +* :card_file_box: set all project status to failed for v6 release ([b689c35](https://github.com/cloud-pi-native/console/commit/b689c356f27e769b18aabab2f2510dd24354d95f)) +* :construction: fix configs ([ff77d7a](https://github.com/cloud-pi-native/console/commit/ff77d7ab10aecdf92643f9bcece9ef3360bc4c46)) +* :goal_net: add try catch in checkApi func ([baf38af](https://github.com/cloud-pi-native/console/commit/baf38af3f96fe227a934228d7875181e1407c8e9)) +* :goal_net: allow deleting environment if not found ([a2dc7da](https://github.com/cloud-pi-native/console/commit/a2dc7daf96942121e9291063569ff9c6612bd345)) +* :goal_net: throw error if catched in business ([1790ec2](https://github.com/cloud-pi-native/console/commit/1790ec274e965d5d62a0693937eec5ee765602e2)) +* :goal_net: validate schema before creating repo or env ([23535c3](https://github.com/cloud-pi-native/console/commit/23535c3e0c9923df0cc8adb5fb646dc2dd21fb7c)) +* :heavy_plus_sign: fix pnpm-lock file ([93aa64e](https://github.com/cloud-pi-native/console/commit/93aa64e146e11f30c8305f85d7ff03b696b0297b)) +* :label: add missing fastify keycloak adapter patch to improve types ([2d4ca1f](https://github.com/cloud-pi-native/console/commit/2d4ca1fc64c374cd06cef5b27842502db59a0c1c)) +* :lipstick: add icons ([ed1887b](https://github.com/cloud-pi-native/console/commit/ed1887baf6a65d0d58c6e399991a1419351c3883)) +* :lipstick: fix padding ([907d424](https://github.com/cloud-pi-native/console/commit/907d424a94f2dfc7e7110882438130bdb5dc4cd8)) +* :lipstick: restyle logs interface ([fc362cf](https://github.com/cloud-pi-native/console/commit/fc362cf461c65f6e4e8e7b6b2d92c83a41844bdf)) +* :lock: root token is not used in new repo credential when synced ([c92ea8b](https://github.com/cloud-pi-native/console/commit/c92ea8b5ef95097b07c2bbda435ce433f5bba53d)) +* :pushpin: pin axios version to avoid vite error ([88c6d30](https://github.com/cloud-pi-native/console/commit/88c6d30b5ab14cd9ca899fdf7cfcd17faa067903)) +* :rewind: fix bugs introduced during my vacations ([9ee467c](https://github.com/cloud-pi-native/console/commit/9ee467c69b590a4630dae19f42f2fc5c9321953e)) +* :safety_vest: validate user schema before trying to insert it in db ([ab8e6ef](https://github.com/cloud-pi-native/console/commit/ab8e6efdb00ec29c6a9a1e0f89c58ffbc999c966)) +* :technologist: change domains for dev and int ([0de00ab](https://github.com/cloud-pi-native/console/commit/0de00abf78543bc769f80be2f0e59795cfefefa5)) +* :technologist: fix docker volumes for integ mode ([80af072](https://github.com/cloud-pi-native/console/commit/80af072bfc0affa8ab0b415a3f6d63d17ceaf2c2)) +* :technologist: fix import kubeconfig in integration ([51af3dd](https://github.com/cloud-pi-native/console/commit/51af3dd16128a6d4a1e676921937005e295acfc9)) +* :test_tube: trying to fix ct test ([c67366e](https://github.com/cloud-pi-native/console/commit/c67366e0b319de7edd681b9ba9265091be5c9e32)) +* :test_tube: trying to optimize dependancies for failing modules ([ec97602](https://github.com/cloud-pi-native/console/commit/ec9760236a380efdcabc5bc0f0cb7e9b23c07adb)) +* :wrench: handle no values for disabledPlugins ([d0d46a3](https://github.com/cloud-pi-native/console/commit/d0d46a38570db6f626f5879da180829216826fa8)) + + +### Performance Improvements + +* :construction: skip failing test for merge ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) + + +### Reverts + +* :card_file_box: add missing default for projectStatus ([2263988](https://github.com/cloud-pi-native/console/commit/22639888442da48b097f936d0afb62652d262b02)) +* :card_file_box: add missing default for projectStatus ([5653165](https://github.com/cloud-pi-native/console/commit/565316504a1baa90d2c7c999c0ba6810151324f5)) +* :poop: comment harbor projet member role update ([63926ae](https://github.com/cloud-pi-native/console/commit/63926ae9c5a5fe6b9e6e8a2d0007f55d186b0f36)) + + +### Build System + +* :art: split plugins ([f4ac305](https://github.com/cloud-pi-native/console/commit/f4ac30587917b78d6d0f3e58cdda1b8a08123b48)) + +## [6.5.2](https://github.com/cloud-pi-native/console/compare/v6.5.1...v6.5.2) (2024-03-13) + + +### Bug Fixes + +* :bug: missing check in search group ([7fb65c1](https://github.com/cloud-pi-native/console/commit/7fb65c12754eea955c7ba942329cf5bfa612f8b7)) +* :bug: missing check nexus find user ([e0f1674](https://github.com/cloud-pi-native/console/commit/e0f167424df8e0c01619a0da38bf3e5723014ee1)) + +## [6.5.1](https://github.com/cloud-pi-native/console/compare/v6.5.0...v6.5.1) (2024-01-25) + + +### Bug Fixes + +* :bug: share loadingct display knowledge ([8520a5c](https://github.com/cloud-pi-native/console/commit/8520a5c827d05316e9dedc59dd90a92fb3f513d4)) + +## [6.5.0](https://github.com/cloud-pi-native/console/compare/v6.4.0...v6.5.0) (2024-01-25) + + +### Features + +* :children_crossing: better ux for forms in tiles ([da68243](https://github.com/cloud-pi-native/console/commit/da68243a91865732db46b9ae610a82eeec5c9bf2)) +* :loud_sound: add payload and full hook response in validation hooks ([adabb02](https://github.com/cloud-pi-native/console/commit/adabb02fd0b8028b1fcdf1113744234a925d72ce)) +* :loud_sound: add reqId in db logs ([2b7f22b](https://github.com/cloud-pi-native/console/commit/2b7f22b7e24748dc37c8623bbda3e914ed7cae00)) +* :sparkles: add project environments to hook payload ([7aa8117](https://github.com/cloud-pi-native/console/commit/7aa81179ac95afdddf711e1a0a4b3c3a45a35c5a)) +* :sparkles: disallow hyphen on project, environment and stage ([f9b05ee](https://github.com/cloud-pi-native/console/commit/f9b05eefee41f7d514d749a0ac3fa1c1d063f051)) +* :sparkles: download projects data button for admins ([cb38e75](https://github.com/cloud-pi-native/console/commit/cb38e7573314ba0980c4161d1a77a9e83fddb236)) +* :sparkles: to fn, more infos, multi tiles ([8c4de83](https://github.com/cloud-pi-native/console/commit/8c4de83cf3b4bc0e335292d437ddf7d22305ff93)) + + +### Bug Fixes + +* :goal_net: validate schema before creating repo or env ([23535c3](https://github.com/cloud-pi-native/console/commit/23535c3e0c9923df0cc8adb5fb646dc2dd21fb7c)) +* :pushpin: pin axios version to avoid vite error ([88c6d30](https://github.com/cloud-pi-native/console/commit/88c6d30b5ab14cd9ca899fdf7cfcd17faa067903)) + +## [6.4.0](https://github.com/cloud-pi-native/console/compare/v6.3.1...v6.4.0) (2023-12-20) + + +### Features + +* :children_crossing: add filtering on admin projects view ([dc5412c](https://github.com/cloud-pi-native/console/commit/dc5412c1a80ce84cd563376a0b7f72da7926a858)) +* :sparkles: display cluster infos to users on environment form ([4afdf34](https://github.com/cloud-pi-native/console/commit/4afdf34fc40df7789a00fe6d159ee73370b94cb9)) +* :speech_balloon: translate stage in front for better understanding ([5a66479](https://github.com/cloud-pi-native/console/commit/5a6647980319524098a72fcbd2be6affbda3f3d0)) + + +### Bug Fixes + +* :art: refactor of admin project types and refs ([be0684f](https://github.com/cloud-pi-native/console/commit/be0684f378696a24304d7abecd6b90c0738e1785)) + +## [6.3.1](https://github.com/cloud-pi-native/console/compare/v6.3.0...v6.3.1) (2023-12-12) + + +### Bug Fixes + +* :bug: missed limits ([f2ba02d](https://github.com/cloud-pi-native/console/commit/f2ba02d41e923b0add154fc7cb5ae7d8a2c89370)) + +## [6.3.0](https://github.com/cloud-pi-native/console/compare/v6.2.0...v6.3.0) (2023-12-11) + + +### Features + +* :sparkles: add backoffice for admins ([fadfdfc](https://github.com/cloud-pi-native/console/commit/fadfdfc43c427261adc3e31dbb1907d6dcefc5cb)) + + +### Bug Fixes + +* :bug: fix gitlab group deletion ([12a35d0](https://github.com/cloud-pi-native/console/commit/12a35d08619fbeb231d4e7931fb7fdb51ef63fde)) + +## [6.2.0](https://github.com/cloud-pi-native/console/compare/v6.1.0...v6.2.0) (2023-11-20) + + +### Features + +* :sparkles: delete cluster if no environment suscribed ([8edc50a](https://github.com/cloud-pi-native/console/commit/8edc50a85f266f1135b340d6df39a48ea7ac746b)) + + +### Bug Fixes + +* :alien: add owner in archiveProject hookpayload ([5c99183](https://github.com/cloud-pi-native/console/commit/5c99183f03afe0c20980c9649ce37788b833a351)) +* :bug: use argoCD regex for internalRepoName ([6c2ed3f](https://github.com/cloud-pi-native/console/commit/6c2ed3f682514b947e66547ce136d148b9e2d686)) + +## [6.1.0](https://github.com/cloud-pi-native/console/compare/v6.0.0...v6.1.0) (2023-11-13) + + +### Features + +* :sparkles: add quota and stage management for admin ([80ce642](https://github.com/cloud-pi-native/console/commit/80ce642b69a5eaaf1b53c7d884b70889c4e71d9d)) +* :sparkles: add stage management for admin ([e133853](https://github.com/cloud-pi-native/console/commit/e13385347610014a4edcc0b2ff12252386328cb8)) + + +### Bug Fixes + +* :bug: add missing await in gitlab createProjectMirror ([f1bf907](https://github.com/cloud-pi-native/console/commit/f1bf9072b88abec3f08654720f64571aa421db34)) +* :bug: fix endless redirect on login if authenticated ([461c6bd](https://github.com/cloud-pi-native/console/commit/461c6bd385f44a551fe9af1027c6beca1e7fa9ec)) +* :bug: should not set caData if skipTLSVerify is true ([ccd66f8](https://github.com/cloud-pi-native/console/commit/ccd66f83be75850ffe47e87ff405fcf9cf136e3c)) +* :lock: root token is not used in new repo credential when synced ([c92ea8b](https://github.com/cloud-pi-native/console/commit/c92ea8b5ef95097b07c2bbda435ce433f5bba53d)) + +## [6.0.0](https://github.com/cloud-pi-native/console/compare/v5.11.0...v6.0.0) (2023-10-27) + + +### ⚠ BREAKING CHANGES + +* :boom: environment management + +### Features + +* :boom: environment management ([b4f3793](https://github.com/cloud-pi-native/console/commit/b4f379315803aee1264192c16851af049fa661ee)) +* :sparkles: services secrets improvement ([40e3ddd](https://github.com/cloud-pi-native/console/commit/40e3ddd2acd7e000b03f5bfa5663533449d315bf)) + + +### Bug Fixes + +* :alien: change logo for svg ([aa8315b](https://github.com/cloud-pi-native/console/commit/aa8315b63617c8413a448499e2ab0e68c216faef)) +* :bug: add conditional chaining to avoid error in dsobadge ([56ad040](https://github.com/cloud-pi-native/console/commit/56ad0403242222f50ef8abe898b46c241d40b66d)) +* :bug: launch e2e test in open mode without turbo ([d3497d1](https://github.com/cloud-pi-native/console/commit/d3497d13e3862d7701cbbd2da65cb7a571032eb2)) +* :card_file_box: set all project status to failed for v6 release ([b689c35](https://github.com/cloud-pi-native/console/commit/b689c356f27e769b18aabab2f2510dd24354d95f)) + +## [5.11.0](https://github.com/cloud-pi-native/console/compare/v5.10.1...v5.11.0) (2023-10-13) + + +### Features + +* :children_crossing: add default branch to sync in mirror pipeline ([45dbc01](https://github.com/cloud-pi-native/console/commit/45dbc01c4a95d04a201b2c5e198e3b192e1e5fe2)) + + +### Bug Fixes + +* :goal_net: add try catch in checkApi func ([baf38af](https://github.com/cloud-pi-native/console/commit/baf38af3f96fe227a934228d7875181e1407c8e9)) + +## [5.10.1](https://github.com/cloud-pi-native/console/compare/v5.10.0...v5.10.1) (2023-10-10) + +### Bug Fixes + +* :ambulance: send internalRepoName to plugins on updateRepository hook ([2bde004](https://github.com/cloud-pi-native/console/commit/2bde0045592f2898975633c0c6f959311234f7f3)) + +## [5.10.0](https://github.com/cloud-pi-native/console/compare/v5.9.1...v5.10.0) (2023-10-03) + +### Features + +* :iphone: more responsive sideMenu and permissionForm ([c840306](https://github.com/cloud-pi-native/console/commit/c8403067224b5ad90e34b8233a1830ceeec8b715)) +* :sparkles: enable admin cluster update ([f81008e](https://github.com/cloud-pi-native/console/commit/f81008e97c25bc73466b6257bd4cb2dc0bde9163)) + +## [5.9.1](https://github.com/cloud-pi-native/console/compare/v5.9.0...v5.9.1) (2023-09-28) + +### Bug Fixes + +* :bug: fix not updated argocd cluster and not using insecure in config ([69e7a9a](https://github.com/cloud-pi-native/console/commit/69e7a9ac7a584d76ce047b8330356ce2387218d7)) + +## [5.9.0](https://github.com/cloud-pi-native/console/compare/v5.8.2...v5.9.0) (2023-09-25) + +### Features + +* :technologist: add swagger for server api ([abe15d2](https://github.com/cloud-pi-native/console/commit/abe15d2ceb93039ecd795389156f8730e5327841)) + +### Bug Fixes + +* :ambulance: register cluster destination via kubeconfig ([0e8d310](https://github.com/cloud-pi-native/console/commit/0e8d3102267268c595d172808e30183dbe4b3aad)) +* :safety_vest: validate user schema before trying to insert it in db ([ab8e6ef](https://github.com/cloud-pi-native/console/commit/ab8e6efdb00ec29c6a9a1e0f89c58ffbc999c966)) + +## [5.8.2](https://github.com/cloud-pi-native/console/compare/v5.8.1...v5.8.2) (2023-09-20) + +### Bug Fixes + +* :wrench: handle no values for disabledPlugins ([d0d46a3](https://github.com/cloud-pi-native/console/commit/d0d46a38570db6f626f5879da180829216826fa8)) + +## [5.8.1](https://github.com/cloud-pi-native/console/compare/v5.8.0...v5.8.1) (2023-09-18) + +### Bug Fixes + +* :ambulance: bad logic register plugins ([049040e](https://github.com/cloud-pi-native/console/commit/049040eafda26214afbc2118cb56542b133feca5)) + +## [5.8.0](https://github.com/cloud-pi-native/console/compare/v5.7.0...v5.8.0) (2023-09-18) + +### Features + +* :passport_control: update harbor project member role ([4419dd5](https://github.com/cloud-pi-native/console/commit/4419dd5f395f79a9eb99e8c0f305a7433ad84f7e)) +* :sparkles: add infos key for cluster ([25ab8b8](https://github.com/cloud-pi-native/console/commit/25ab8b8d821c37f4491bd8fea075bdc7fbfcfc93)) +* :sparkles: add tlsServerName input in cluster form ([4bdc01e](https://github.com/cloud-pi-native/console/commit/4bdc01ef9dd5b2041a34491e46b9759e5c6652f0)) +* :technologist: add images auto-scan to harbor settings ([507c6e6](https://github.com/cloud-pi-native/console/commit/507c6e6a541b19502084276db30cf834e586d955)) + +### Bug Fixes + +* :alien: fetch does not support proxy natively ([20d531d](https://github.com/cloud-pi-native/console/commit/20d531d622e6fe9e2869e13db0d205b44ad602ce)) +* :art: can disble plugins by name, and some fixes ([f2bd397](https://github.com/cloud-pi-native/console/commit/f2bd397e0526d76d439ef8f38b5dcebdfbaa8ede)) +* :bug: clusters related bugs ([c2ba63e](https://github.com/cloud-pi-native/console/commit/c2ba63e94d87bdf1741e9a51e4522c3f77b354de)) +* :bug: now the admin can chosse skip tls api kube ([e09aa2a](https://github.com/cloud-pi-native/console/commit/e09aa2ac659e1439f59050c56256f530b9f8cae6)) +* :goal_net: throw error if catched in business ([1790ec2](https://github.com/cloud-pi-native/console/commit/1790ec274e965d5d62a0693937eec5ee765602e2)) +* :heavy_plus_sign: fix pnpm-lock file ([93aa64e](https://github.com/cloud-pi-native/console/commit/93aa64e146e11f30c8305f85d7ff03b696b0297b)) + +### Reverts + +* :poop: comment harbor projet member role update ([63926ae](https://github.com/cloud-pi-native/console/commit/63926ae9c5a5fe6b9e6e8a2d0007f55d186b0f36)) + +## [5.7.0](https://github.com/cloud-pi-native/console/compare/v5.6.0...v5.7.0) (2023-09-07) + +### Features + +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :safety_vest: update password character support ([c82f35f](https://github.com/cloud-pi-native/console/commit/c82f35fb7f20ad06c0a63eb518407d17ddb88b51)) +* :sparkles: handle gitlab membership ([7ff68bd](https://github.com/cloud-pi-native/console/commit/7ff68bdf8f9a16562cfc00fbdb0c322b0f09943d)) + +### Bug Fixes + +* :bug: change appproject policy ([e20b6c4](https://github.com/cloud-pi-native/console/commit/e20b6c431348b08bed7ea215a967c51d0e6187f0)) +* :bug: import new shared package ([ff0b0db](https://github.com/cloud-pi-native/console/commit/ff0b0db9dcc96c10b409b51bd4180c03b5b01478)) + +### Reverts + +* :card_file_box: add missing default for projectStatus ([2263988](https://github.com/cloud-pi-native/console/commit/22639888442da48b097f936d0afb62652d262b02)) +* :card_file_box: add missing default for projectStatus ([5653165](https://github.com/cloud-pi-native/console/commit/565316504a1baa90d2c7c999c0ba6810151324f5)) + +## [5.6.0](https://github.com/cloud-pi-native/console/compare/v5.5.0...v5.6.0) (2023-08-28) + +### Features + +* :sparkles: better integration of sonarqube ([c0a0517](https://github.com/cloud-pi-native/console/commit/c0a05173a158b74d752aafaccdff58fc3b29c382)) + +### Bug Fixes + +* :ambulance: preserve status colum in project ([febfaff](https://github.com/cloud-pi-native/console/commit/febfaff702d6a55210723ce213d603ad059ceaed)) +* :bug: optional paramater to call repeatFn ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: pnpm lock ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: run.sh js to ts reference ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :lipstick: fix padding ([907d424](https://github.com/cloud-pi-native/console/commit/907d424a94f2dfc7e7110882438130bdb5dc4cd8)) +* :lipstick: restyle logs interface ([fc362cf](https://github.com/cloud-pi-native/console/commit/fc362cf461c65f6e4e8e7b6b2d92c83a41844bdf)) + +### Performance Improvements + +* :construction: skip failing test for merge ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) + +## [5.5.0](https://github.com/cloud-pi-native/console/compare/v5.4.0...v5.5.0) (2023-08-22) + +### Features + +* :children_crossing: inform users that clipboard works only with https ([909c3b6](https://github.com/cloud-pi-native/console/commit/909c3b672bcae1401d2133c8fff94cdca91f61bc)) +* :sparkles: retrieve user from kc if does not exist in db ([3d4461a](https://github.com/cloud-pi-native/console/commit/3d4461ae7d82e6f64a1644921893d89f41e9a2d9)) + +### Bug Fixes + +* :alien: sync controllers require bigger axios timeout ([5116bbd](https://github.com/cloud-pi-native/console/commit/5116bbdb8f49b73c920ac04421ca05ccabef902c)) +* :bento: update MultiSelector component ([9a44bb3](https://github.com/cloud-pi-native/console/commit/9a44bb35d8aa0da1aaf8f96a745c28f97f6ea53c)) +* :bug: filter archived project from cluster form ([50913f5](https://github.com/cloud-pi-native/console/commit/50913f57def7d8e3e36f63a5876e476e64884828)) +* :bug: fix types, add void and handle it ([110cfed](https://github.com/cloud-pi-native/console/commit/110cfedfcb2a58757b5ad47f96f6323cbe034550)) +* :bug: manage cancel emit on update repo form ([16df7b4](https://github.com/cloud-pi-native/console/commit/16df7b43d584fb1edfa5a01938356b04b785429b)) +* :bug: retrieve kc user bug fix ([aaf61c5](https://github.com/cloud-pi-native/console/commit/aaf61c51a288cd7998a5d3883cf467471b42c3cb)) +* :bug: various fixes around plugins and controllers ([6271c67](https://github.com/cloud-pi-native/console/commit/6271c67f3d8471ee6afc3409c5d753f7ade950f0)) +* :goal_net: allow deleting environment if not found ([a2dc7da](https://github.com/cloud-pi-native/console/commit/a2dc7daf96942121e9291063569ff9c6612bd345)) +* :lipstick: add icons ([ed1887b](https://github.com/cloud-pi-native/console/commit/ed1887baf6a65d0d58c6e399991a1419351c3883)) +* :rewind: fix bugs introduced during my vacations ([9ee467c](https://github.com/cloud-pi-native/console/commit/9ee467c69b590a4630dae19f42f2fc5c9321953e)) +* :technologist: fix import kubeconfig in integration ([51af3dd](https://github.com/cloud-pi-native/console/commit/51af3dd16128a6d4a1e676921937005e295acfc9)) + +## [5.4.0](https://github.com/cloud-pi-native/console/compare/v5.3.0...v5.4.0) (2023-08-02) + +### Features + +* :children_crossing: improve home page and update doc link ([038d982](https://github.com/cloud-pi-native/console/commit/038d982ead7bbfa70debdf902d5c46e8518c1e60)) +* :sparkles: add or remove a team member via keycloak ([0563d20](https://github.com/cloud-pi-native/console/commit/0563d20ca93a25decc7dbb5d945e2b738b0d575a)) + +### Bug Fixes + +* :technologist: change domains for dev and int ([0de00ab](https://github.com/cloud-pi-native/console/commit/0de00abf78543bc769f80be2f0e59795cfefefa5)) + +## [5.3.0](https://github.com/cloud-pi-native/console/compare/v5.2.1...v5.3.0) (2023-08-01) + +### Features + +* :alien: synch controllers with plugin calls ([d0a271d](https://github.com/cloud-pi-native/console/commit/d0a271d188840d8682b6abe412ee8da4db7992b1)) +* :children_crossing: add confirm box for updating an organization ([5c3cd20](https://github.com/cloud-pi-native/console/commit/5c3cd207221a22ed5d93bdf8638b23c9849897f6)) +* :sparkles: lock and unlock projects according to their org status ([3b9444a](https://github.com/cloud-pi-native/console/commit/3b9444aeaa2fab76461946e9a4d00e8afe4a62f1)) + +### Bug Fixes + +* :bug: create appropriate folders for server image ([3027fef](https://github.com/cloud-pi-native/console/commit/3027fef08ebc8a58e2e705645febaf40af7314f5)) +* :bug: fix missing extensions ([4cd4b21](https://github.com/cloud-pi-native/console/commit/4cd4b212e88d30b447d5674cf157ce71862fe14a)) +* :bug: pin pnpm version in dockerfiles ([b494a2f](https://github.com/cloud-pi-native/console/commit/b494a2f0d190e3da81f9765dded76187333e61af)) + +## [5.2.1](https://github.com/cloud-pi-native/console/compare/v5.2.0...v5.2.1) (2023-07-21) + +### Bug Fixes + +* :adhesive_bandage: update kaniko stage name in gitlab ci ([7a71712](https://github.com/cloud-pi-native/console/commit/7a71712075d7aed5aeeb00adb1d2ec51b6d717ab)) + +## [5.2.0](https://github.com/cloud-pi-native/console/compare/v5.1.0...v5.2.0) (2023-07-20) + +### Features + +* :children_crossing: improve gitlab mirroring by using a single mirror repo ([914bbe4](https://github.com/cloud-pi-native/console/commit/914bbe4546a3e876a6cd8847523f18e95d688d44)) +* :sparkles: add id in admin user table ([d1ebcea](https://github.com/cloud-pi-native/console/commit/d1ebcea1768993b6ab31e5f45a6642ac3577cfc5)) + +### Bug Fixes + +* :bug: add missing data to add argo cluster ([fc53f54](https://github.com/cloud-pi-native/console/commit/fc53f54d8f2d211857aa82c43589b9ee26e214b8)) +* :bug: delete kube resources when deleting a project ([5e8cd51](https://github.com/cloud-pi-native/console/commit/5e8cd514edc8a0eb4f5d49208cb17b6da82f4fd7)) +* :bug: fix argo plugin ([b1dc1fb](https://github.com/cloud-pi-native/console/commit/b1dc1fb60ba89b534c3e3cd357b89a4e08b74838)) + +## [5.1.0](https://github.com/cloud-pi-native/console/compare/v5.0.0...v5.1.0) (2023-07-18) + +### Features + +* :alien: add project infos for canel plugin ([a4516a1](https://github.com/cloud-pi-native/console/commit/a4516a14b2439dd3a74540168aee6daa586c7308)) +* :alien: add updateProject hook for description update for example ([7e97505](https://github.com/cloud-pi-native/console/commit/7e97505b5a75c3f6c680d5b1bb6f18a9e45b87b3)) + +### Bug Fixes + +* :bug: fix projectLimit for gitlab plugin ([13fca89](https://github.com/cloud-pi-native/console/commit/13fca89f37a16b980dce7f16e87151510d6f0b63)) +* :bug: missing await before plugins hook and logs recording on archiveProject ([e221341](https://github.com/cloud-pi-native/console/commit/e221341a7db27d67bfa40e4231d47de1e5e8648f)) +* :bug: missing await before plugins hook and logs recording on archiveProject ([2515c62](https://github.com/cloud-pi-native/console/commit/2515c624afd1d89718a98085c5e41c1c996157b8)) + +## [5.0.0](https://github.com/cloud-pi-native/console/compare/v4.1.0...v5.0.0) (2023-07-13) + +### ⚠ BREAKING CHANGES + +* :boom: migrate from sequelize to prisma + +### Features + +* :children_crossing: add organization to differenciate project ([5814d7e](https://github.com/cloud-pi-native/console/commit/5814d7e00b5a6a335a16059b62f7af456d3b4a58)) +* :sparkles: allow updating repo ([27b59cf](https://github.com/cloud-pi-native/console/commit/27b59cfc5cacdf0e96b0fd22b090ce4ab038792d)) +* :sparkles: always show ci form ([753d64b](https://github.com/cloud-pi-native/console/commit/753d64bc011701dcc9297097b1a0aec87cef6c03)) +* :tada: introduce multi-cluster deployment ([0e6c065](https://github.com/cloud-pi-native/console/commit/0e6c065d421c117050e00944ca68114414a05dc7)) +* :technologist: add config for codespace ([1ff7996](https://github.com/cloud-pi-native/console/commit/1ff79966454be7961ba80049be65815cfce1d4cf)) + +### Bug Fixes + +* :alien: change gitlab api calls ([f04dc87](https://github.com/cloud-pi-native/console/commit/f04dc87208ca19ee2b06ff2c35535e51a72f84cd)) +* :art: finish pr ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :art: quote fixes ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :bug: add module on shared ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :bug: fix imports, change dependencies ([41f427f](https://github.com/cloud-pi-native/console/commit/41f427fe17daef7ef1b3c330ec5635338edfda59)) +* :bug: fix logs pagination bug ([7f3a990](https://github.com/cloud-pi-native/console/commit/7f3a99085f701d02367fab04f194dc0c0e85356d)) +* :bug: forgot await in argo plugin ([f4504d4](https://github.com/cloud-pi-native/console/commit/f4504d4b1ccc999274c3267508c943f23fd8690b)) +* :bug: retrieve also inactive orgs on admin project list page ([4b8977e](https://github.com/cloud-pi-native/console/commit/4b8977e01214295f3090ab16a92bde4b7d7a64f9)) +* :construction_worker: fix CI: tsconfig is needed ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :construction: in progress ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :lipstick: more contrasted icon color for archived project ([790d276](https://github.com/cloud-pi-native/console/commit/790d2761d3f77756144172e3ccd80bcb0df4572d)) + +### Code Refactoring + +* :boom: migrate from sequelize to prisma ([37b065d](https://github.com/cloud-pi-native/console/commit/37b065d6cff844dfd1b9d2276831eddefa64a71e)) + +## [4.1.0](https://github.com/cloud-pi-native/console/compare/v4.0.0...v4.1.0) (2023-06-12) + +### Features + +* :sparkles: add admin log page ([41058ee](https://github.com/cloud-pi-native/console/commit/41058ee51899d4ec351ae7adad10eb3fefa99f8b)) + +### Bug Fixes + +* :bug: harbor, fix remove repos from unknown project ([8a18381](https://github.com/cloud-pi-native/console/commit/8a18381c2197634d90649a8520dd068a59e5369c)) +* :bug: test if external dir exists ([3276400](https://github.com/cloud-pi-native/console/commit/3276400a00b3435eb87e225e0e8deadd5a311b0d)) +* :zap: gitlab, stabilize user search and lifecycle ([179c912](https://github.com/cloud-pi-native/console/commit/179c91269ecf04ff6a489a5d92f490b940446e70)) + +## [4.0.0](https://github.com/dnum-mi/dso-console/compare/v3.4.1...v4.0.0) (2023-06-08) + +### ⚠ BREAKING CHANGES + +* :boom: clarify business logic on status and locking + +### Features + +* :alien: add hook to synchronize organizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :alien: add hook to synchronize organizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :alien: add keys to source organizations ([626c031](https://github.com/dnum-mi/dso-console/commit/626c03135571199c3a1f1e72e19132bc651288a3)) +* :boom: if status: failed (aka plugins failed), project remains locked ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) +* :children_crossing: improve user experience on dso-console ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :children_crossing: improve ux for danger zone ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :children_crossing: improve UX on permission form ([fb72076](https://github.com/dnum-mi/dso-console/commit/fb72076f16f79df456392fc7f9a7129ab1655832)) +* :children_crossing: redirect to dashboard when selecting a project ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :necktie: allow only one plugin to register on hook fetchOrganizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :sparkles: add project admin view ([b49ff96](https://github.com/dnum-mi/dso-console/commit/b49ff967853e31ca6cbcd8cd2ff78b299023788e)) + +### Bug Fixes + +* :art: temp fix labels, add check, refacto get ns ([a43f50c](https://github.com/dnum-mi/dso-console/commit/a43f50cb93b2453f025b9d4284f2b65b6134161a)) +* :bug: fix payload, fix check step ([ff76b27](https://github.com/dnum-mi/dso-console/commit/ff76b2750352cbe7b2d49ff5fb6e4d3e424b328f)) +* :bug: remove harbor repos before projects ([7239329](https://github.com/dnum-mi/dso-console/commit/72393297dcfaf44938e2814cccfd8366613ba75c)) +* :necktie: clarify business logic on status and locking ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) + +### Code Refactoring + +* :boom: clarify business logic on status and locking ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) + +## [3.4.1](https://github.com/dnum-mi/dso-console/compare/v3.4.0...v3.4.1) (2023-05-22) + +### Bug Fixes + +* :art: change ns labels ([ae14d67](https://github.com/dnum-mi/dso-console/commit/ae14d6736a3b3276b3406562709cf77e3aab0668)) +* :construction: hooks payload send full owner object, bug getSingleOwnerByProjectId ([0e74895](https://github.com/dnum-mi/dso-console/commit/0e74895695b42d4e13a645ef4005afbffec59b64)) + +## [3.4.0](https://github.com/dnum-mi/dso-console/compare/v3.3.1...v3.4.0) (2023-05-15) + +### Features + +* :alien: owner name is required to create Canel user ([e4aa5e9](https://github.com/dnum-mi/dso-console/commit/e4aa5e9662fb0494ef43b7f52140c9c11702613f)) + +## [3.3.1](https://github.com/dnum-mi/dso-console/compare/v3.3.0...v3.3.1) (2023-05-11) + +### Bug Fixes + +* :ambulance: keycloak now sends group path ([913f402](https://github.com/dnum-mi/dso-console/commit/913f40295f7f0eab17964e72e38e61200523456e)) + +## [3.3.0](https://github.com/dnum-mi/dso-console/compare/v3.2.3...v3.3.0) (2023-05-11) + +### Features + +* :sparkles: add organization page for admins ([5e6b7fa](https://github.com/dnum-mi/dso-console/commit/5e6b7fa252285b432ef5869aedf8881b642eff53)) + +### Bug Fixes + +* :ambulance: handle unocss migration for media queries ([8fdf445](https://github.com/dnum-mi/dso-console/commit/8fdf4455a34a274c3723685928549bb30bb04762)) +* :sparkles: set ingress secretName ([8be3479](https://github.com/dnum-mi/dso-console/commit/8be34796b15219a6b4f77585662b21d8d04c76c8)) + +## [3.2.3](https://github.com/dnum-mi/dso-console/compare/v3.2.2...v3.2.3) (2023-05-09) + +### Bug Fixes + +* :ambulance: keycloak group id, typo ([1aedf77](https://github.com/dnum-mi/dso-console/commit/1aedf77b4a74698bb1d52cbfe6863bd249a7eadb)) + +## [3.2.2](https://github.com/dnum-mi/dso-console/compare/v3.2.1...v3.2.2) (2023-05-09) + +### Bug Fixes + +* :ambulance: missing ownerid at env init ([14696ff](https://github.com/dnum-mi/dso-console/commit/14696ffc24a2bfe5345b135e99f48502ba0d2fae)) + +## [3.2.1](https://github.com/dnum-mi/dso-console/compare/v3.2.0...v3.2.1) (2023-05-09) + +### Bug Fixes + +* :ambulance: habor missing slash on oidc group ([e85b903](https://github.com/dnum-mi/dso-console/commit/e85b9031f77c7feba05f04b9444c5d6862342246)) +* :ambulance: keycloak do not add owner in group env ([f20b3da](https://github.com/dnum-mi/dso-console/commit/f20b3da4a8666d3b2912c3b7147819818a96c8ed)) + +## [3.2.0](https://github.com/dnum-mi/dso-console/compare/v3.1.0...v3.2.0) (2023-05-05) + +### Features + +* :lipstick: better ui for permission form ([56e0739](https://github.com/dnum-mi/dso-console/commit/56e0739d935d96c4eedd55807151f72f8fafcb6b)) +* :sparkles: add description field for a project ([e9d65ed](https://github.com/dnum-mi/dso-console/commit/e9d65eda3084ac46b48f8e7a4a7e78324cf8d351)) +* :sparkles: add download link for includes.zip ([0e067d1](https://github.com/dnum-mi/dso-console/commit/0e067d1965e39084d3f21191e27aa3b82948df17)) +* :sparkles: introduce users admin view ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) + +### Bug Fixes + +* :alien: refacto plugins to check services before save in database ([c58f3da](https://github.com/dnum-mi/dso-console/commit/c58f3dab057fd39f62e20ca3951278a87f6cfad0)) +* :bug: forget default value for log table ([ade449b](https://github.com/dnum-mi/dso-console/commit/ade449b0a834f850d4f64c94cfb489174b946463)) +* :construction: fix e2e test admin (temporary) ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) +* :construction: use ingress ([621d5ee](https://github.com/dnum-mi/dso-console/commit/621d5eee80adfb5f14459b93c7f51b431dd61c31)) +* :fire: remove routes ([65dabcb](https://github.com/dnum-mi/dso-console/commit/65dabcb28f8ede298975a2cec3e7ff5623ee4d8c)) +* :loud_sound: log database sync error ([3e73dac](https://github.com/dnum-mi/dso-console/commit/3e73dac0fc470051c5aaf410ebbbf28cafc9ee0e)) +* :wrench: update helm values ([16d3223](https://github.com/dnum-mi/dso-console/commit/16d32233c84001b8aa206a76b8ac3b772875cb64)) + +### Performance Improvements + +* :art: admin users no custom exception ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) + +## [3.1.0](https://github.com/dnum-mi/dso-console/compare/v3.0.0...v3.1.0) (2023-04-18) + +### Features + +* :children_crossing: add deployment infos below environment tiles ([992b34e](https://github.com/dnum-mi/dso-console/commit/992b34e070401ae330cab239d73bf27b56ba516c)) +* :sparkles: add custom ca to server ([8583577](https://github.com/dnum-mi/dso-console/commit/85835775d1bf21dfadffe5094dc1a513d6782525)) +* :sparkles: introduce environment management page ([3e77a67](https://github.com/dnum-mi/dso-console/commit/3e77a679446853b981d9bf98f1bd62795ce5cf1d)) + +### Bug Fixes + +* :bug: fix git output url ([e3402b4](https://github.com/dnum-mi/dso-console/commit/e3402b44dddbd71805293305df181321af04dafe)) +* :bug: forget delete mirror repository secret ([f00aac7](https://github.com/dnum-mi/dso-console/commit/f00aac7f6315a1f27ee05ebaa6863feb466ac654)) + +## [3.0.0](https://github.com/dnum-mi/dso-console/compare/v2.3.0...v3.0.0) (2023-04-14) + +### ⚠ BREAKING CHANGES + +* **api:** :building_construction: replace ansible with plugins + +### Features + +* :art: load kubeconfig only if if a path is set ([a9d9338](https://github.com/dnum-mi/dso-console/commit/a9d93387cc7ff407ea71edb848e664fb651fc09a)) +* :bento: add Marianne police ([c5af057](https://github.com/dnum-mi/dso-console/commit/c5af05774332d97fc5b669aeaa60f9fca6075998)) +* :boom: change helm deployment ([c1368c6](https://github.com/dnum-mi/dso-console/commit/c1368c69de93357c66709110f3c57d22a62af8ad)) +* :lipstick: add dark theme ([cfc16d8](https://github.com/dnum-mi/dso-console/commit/cfc16d81391c28447d02297a9ebdf22c1f142c6f)) +* :lipstick: add dark theme ([cfc16d8](https://github.com/dnum-mi/dso-console/commit/cfc16d81391c28447d02297a9ebdf22c1f142c6f)) +* :loud_sound: add a short description for database logging ([4146f5a](https://github.com/dnum-mi/dso-console/commit/4146f5ae7048fa280a654b5471a32968af159b2f)) +* :technologist: do not drop database in integration dev ([521d503](https://github.com/dnum-mi/dso-console/commit/521d503cdf5e2c24f9e148bc6435db485e1252e7)) + +### Bug Fixes + +* :ambulance: add kaniko proxy ([1564b6f](https://github.com/dnum-mi/dso-console/commit/1564b6f7a696c13846799b38557b1003d2cb87c8)) +* :ambulance: stabilize gitlab search and vault secret name ([044da15](https://github.com/dnum-mi/dso-console/commit/044da1511ff8a5f16136807c70194d662c08db26)) +* :bug: display hyphen only if service message exists ([ba5ed65](https://github.com/dnum-mi/dso-console/commit/ba5ed659954235f8b00e78df24029f1186d23b7a)) +* :bug: fix controllers logic ([251fba4](https://github.com/dnum-mi/dso-console/commit/251fba44ebd93e645487da63710add4d9382521d)) +* :bug: use proxy on services healthcheck ([85eef14](https://github.com/dnum-mi/dso-console/commit/85eef144f8c87760c61203de7944229af2828047)) +* :lipstick: make snackbar bakground -color adaptive to theme variations ([851a004](https://github.com/dnum-mi/dso-console/commit/851a004837eb901d02bbf65e20697e457ecde6e2)) +* :wrench: rename kubeconfig var ([61fdfbf](https://github.com/dnum-mi/dso-console/commit/61fdfbf3bb47b51225d2cf3c00b167c46ff1bedf)) + +### Code Refactoring + +* **api:** :building_construction: replace ansible with plugins ([59cd5fe](https://github.com/dnum-mi/dso-console/commit/59cd5fef0799ff3dd5df7ec42ef879647a6de78a)) + +## [2.3.0](https://github.com/dnum-mi/dso-console/compare/v2.2.1...v2.3.0) (2023-03-22) + +### Features + +* :alien: fetch services to get their statuses ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) +* :art: review tobi ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) +* :monocle_face: sort projects by ASC name ([ccb7fbb](https://github.com/dnum-mi/dso-console/commit/ccb7fbb5066da6ebc1776a893c86d8d80a292856)) +* :sparkles: build project services url in api ([2173f8a](https://github.com/dnum-mi/dso-console/commit/2173f8abade9848c83cc68e4a68da02982d4a887)) +* :sparkles: new page dedicated to services ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) + +### Bug Fixes + +* :bug: fix container env ([f9fd457](https://github.com/dnum-mi/dso-console/commit/f9fd45715d94789e02d8c88991b02fe9831e4460)) + +## [2.2.1](https://github.com/dnum-mi/dso-console/compare/v2.2.0...v2.2.1) (2023-03-14) + +### Bug Fixes + +* :bug: token should authorize "-" ([277f292](https://github.com/dnum-mi/dso-console/commit/277f29232fd5713c0a6e5c42f29ba257b71cdb75)) + +### Performance Improvements + +* :zap: no need to call /account endpoint ([3b278f7](https://github.com/dnum-mi/dso-console/commit/3b278f7e8a2fc22cf3a9aec8b1319c83a934a7f6)) + +## [2.2.0](https://github.com/dnum-mi/dso-console/compare/v2.1.1...v2.2.0) (2023-03-13) + +### Features + +* :sparkles: display project and repositories statuses in dashboard and repo pages ([ba27167](https://github.com/dnum-mi/dso-console/commit/ba2716798e8d39e5470b6fe0e97acc3a54480612)) + +### Bug Fixes + +* :bug: add joi schema validation in repo form ([8524e50](https://github.com/dnum-mi/dso-console/commit/8524e50cca3a97965b6cc2eb104620aec2d3e5dd)) +* :bug: do nothing when tile is disabled ([e09043c](https://github.com/dnum-mi/dso-console/commit/e09043c34a21315abd8c0c3d1570488057a3d1a6)) +* :bug: wrong key for project archived ([8e6b27b](https://github.com/dnum-mi/dso-console/commit/8e6b27b576e7ad28bde1b8a00b84acf366531993)) + +### Performance Improvements + +* :fire: no external token in database, no crypto needed ([0a3fb8c](https://github.com/dnum-mi/dso-console/commit/0a3fb8c5403215e1ae3d24fb8ea7cfcf43a8060f)) + +## [2.1.1](https://github.com/dnum-mi/dso-console/compare/v2.1.0...v2.1.1) (2023-02-27) + +### Bug Fixes + +* :globe_with_meridians: translate error messages in french ([a2827c3](https://github.com/dnum-mi/dso-console/commit/a2827c3746aad94293de183725bd2e77ac34f525)) + +## [2.1.0](https://github.com/dnum-mi/dso-console/compare/v2.0.0...v2.1.0) (2023-02-27) + +### Features + +* :children_crossing: redirect to services when selecting a project ([3849dad](https://github.com/dnum-mi/dso-console/commit/3849dad734e07ae0250962c8a71c1eb3470614c2)) +* :lipstick: whoami in sidemenu rather than snackbar ([70fcbf5](https://github.com/dnum-mi/dso-console/commit/70fcbf537fa32a04538d10673a1d0b4246fddd65)) +* :memo: ajout d'un schéma d'architecture ([72f61fb](https://github.com/dnum-mi/dso-console/commit/72f61fbf6060455f56d578003e927196c0292a50)) +* :necktie: add constraint for project name length ([f2d3975](https://github.com/dnum-mi/dso-console/commit/f2d3975f8cf5124142edc38a64f219ac9a223985)) +* :sparkles: add snackbar to handle errors ([0b5fa71](https://github.com/dnum-mi/dso-console/commit/0b5fa71b5d993fb7529f4fa2b74d78dacb0b33d3)) +* add active column on organization table, and client get only active: true ([de7b9d0](https://github.com/dnum-mi/dso-console/commit/de7b9d01bf878ee1cf298736e83410905390a5a3)) + +### Bug Fixes + +* :ambulance: bad owner w/ new queries ([5153b62](https://github.com/dnum-mi/dso-console/commit/5153b621c0707a438f191dd2b06d8c801804f014)) +* :ambulance: log more of ansible output ([e2b10d6](https://github.com/dnum-mi/dso-console/commit/e2b10d6875605c836606cb5ba79c963787d4c177)) +* :bug: error in ansible route ([a470677](https://github.com/dnum-mi/dso-console/commit/a470677a5d5c3b2b262434adc3f399f948caccd2)) +* :bug: error in ansible route ([1565411](https://github.com/dnum-mi/dso-console/commit/1565411524a2c17d0e7de79e5965562053a2024b)) +* :goal_net: catch fetch errors and send it to front ([fcca40b](https://github.com/dnum-mi/dso-console/commit/fcca40b2efbc5d38ae8fe0001043671756ebda34)) +* :goal_net: optional chaining to avoid errors on selected project ([6750077](https://github.com/dnum-mi/dso-console/commit/67500771939efb2e2329d8ab94407588aa76fa5b)) +* :goal_net: return if user does not have project ([f2a3d74](https://github.com/dnum-mi/dso-console/commit/f2a3d7449abc729b5506aaaf43a163e600c51bcb)) +* :lipstick: enforce ui on disabled dsfrtiles ([4de5d25](https://github.com/dnum-mi/dso-console/commit/4de5d257d847c4ecff137face3cbb276d543b779)) +* :truck: rename generated gitlab ci file ([d3e637e](https://github.com/dnum-mi/dso-console/commit/d3e637e04ef008c5316b7e720a9fc905c0ab0000)) +* :wrench: update postgres image ([16e5d55](https://github.com/dnum-mi/dso-console/commit/16e5d557c52e149def7f7c216371ba23fc721bbf)) + +### Reverts + +* :rewind: rebase error ([728a704](https://github.com/dnum-mi/dso-console/commit/728a704522a3f5cfcc2dc5879671a83a051a82c6)) + +## [2.0.0](https://github.com/dnum-mi/dso-console/compare/v1.0.0...v2.0.0) (2023-02-22) + +### ⚠ BREAKING CHANGES + +* **database:** :card_file_box: new database model, queries, + +### Features + +* :art: easier generation of files object ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :art: specific routes, add validation schema ([69645b9](https://github.com/dnum-mi/dso-console/commit/69645b9b392f8f56b8d3606780dfa63098fa5240)) +* :art: use only one get route ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: mob review ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :card_file_box: add an association table for users and projects ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: add destroy functions for db ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: wip : working with @ArnaudTa on liveshare ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: add delete repo button ([ad64daf](https://github.com/dnum-mi/dso-console/commit/ad64dafce222ea572e6169c502aaed08562a70e7)) +* :construction: Add download and copy, missing format ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: add status and locked for project ([1bb55b8](https://github.com/dnum-mi/dso-console/commit/1bb55b8758eecced9204aa7b77f404ac6a3d45b0)) +* :construction: adding api implementation ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :construction: all controllers writtend ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: applying review requested changes ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: commit à ecraser - wip fs file generator ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: feature to achieve later, keep asking for project status ([57846f9](https://github.com/dnum-mi/dso-console/commit/57846f923f15771186b8a7816324df42d55d86cb)) +* :construction: first database model ([69f51f2](https://github.com/dnum-mi/dso-console/commit/69f51f2eb16b052d4fddbb909ac7a5f0913b2c94)) +* :construction: new project controller ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: remove done todos ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: uncomment path for delete repo and projects ([8fb7cd9](https://github.com/dnum-mi/dso-console/commit/8fb7cd9b5c67857862cebfde091a3b5bbba54c93)) +* :construction: wip - ci files generated by server ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: wip downloading file ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: wip fix unit tests ([31c3a86](https://github.com/dnum-mi/dso-console/commit/31c3a863104f553adcefb5e78d64613e32f65704)) +* :lipstick: fix snackbar on bottom left ([7823c99](https://github.com/dnum-mi/dso-console/commit/7823c991b4adf62e380193d7f1d9fc33108dc368)) +* :lock: add crypto module to encrypt externalToken or other data if needed ([c5806b3](https://github.com/dnum-mi/dso-console/commit/c5806b392ec2b203f0d54a62527c9d96570f2918)) +* :necktie: cannot update nor delete permission if not permitted - controller side ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :necktie: disable delete permission for owner ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :necktie: permissions should be enabled only for permitted users ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :passport_control: organization controller ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :sparkles: add archiveProject btn ([e412223](https://github.com/dnum-mi/dso-console/commit/e41222346a4e3d9d60100d59b4edf160a2319c08)) +* :sparkles: add backend function to generate random passwords ([5244597](https://github.com/dnum-mi/dso-console/commit/52445972b7be13862d395059f82fae81b7efa495)) +* :sparkles: add delete playbook call in deleteRepo controller ([8abe695](https://github.com/dnum-mi/dso-console/commit/8abe6950e8f26aa01b7b1a95e4fbc4627f26d925)) +* :sparkles: add delete trigger for environment ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :sparkles: add environment and permissions management ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :sparkles: add environment page ([a5a5129](https://github.com/dnum-mi/dso-console/commit/a5a5129e89cb9594204b08c9cc76c3a30bb04eb7)) +* :sparkles: Add GitLab ci generation in repoForm ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: Add gitlabCi form for repo ([839f1e1](https://github.com/dnum-mi/dso-console/commit/839f1e18bf99f1bef53ed3ab39a64ab097c6a0d8)) +* :sparkles: add GitlabCI generated file ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: add helm charts ([e540325](https://github.com/dnum-mi/dso-console/commit/e5403252cd7d888c839be63758c15c1fdce76c71)) +* :sparkles: add log table in model ([642b245](https://github.com/dnum-mi/dso-console/commit/642b245a4a0ffa8877c900fa786345fd484f60f8)) +* :sparkles: add misc routes, healthz/version ([520976d](https://github.com/dnum-mi/dso-console/commit/520976ddfebba2d05385ba66d27af809e8736287)) +* :sparkles: ajout d'un système de role propre à la console avec authentification admin ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :sparkles: ansible-api respond playbook rc, server stores it in db ([7774045](https://github.com/dnum-mi/dso-console/commit/7774045ae6404e1f0de810bb09a7832c0292baba)) +* :sparkles: call delete repo and archive project playbooks ([97a7b13](https://github.com/dnum-mi/dso-console/commit/97a7b1300847fafa5053d58fb05079ef52a9d041)) +* :sparkles: ciForm ok ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: display current user name in closeable alert ([349ade9](https://github.com/dnum-mi/dso-console/commit/349ade9f5b8017bf206aff2c894a56cc675d6873)) +* :sparkles: get user's group from keycloak ([8311622](https://github.com/dnum-mi/dso-console/commit/83116226e7a3e172a94b4f988d037ec1dcc8aaf4)) +* :sparkles: record ansible responses in database ([5753883](https://github.com/dnum-mi/dso-console/commit/5753883ae32f36e28a128f396df3f9669e5a854b)) +* :sparkles: wip components for environment managing ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :test_tube: ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: rewrite random utils for tests ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* **Helm:** :art: add env per container ([48b41cf](https://github.com/dnum-mi/dso-console/commit/48b41cfea037ce90812197990c54d2726fb75ac7)) +* **Helm:** :art: variabilize server imports ([0973646](https://github.com/dnum-mi/dso-console/commit/09736463d3011f96348f94cf3ad8cc69c9c0f07e)) + +### Bug Fixes + +* :alembic: changement du keycloak flow ([1af0d85](https://github.com/dnum-mi/dso-console/commit/1af0d8504766f3f0ebab92feff8f4795e4e1c67e)) +* :art: variabilize pvc name ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: add await on start server functions and fix tests ([c8e66f8](https://github.com/dnum-mi/dso-console/commit/c8e66f87f4ccd4be1775b32729f12d9c0e919f35)) +* :bug: add refresh keycloak token ([b758e44](https://github.com/dnum-mi/dso-console/commit/b758e44241c868a1e8a1546271f1806069370964)) +* :bug: bind do not always works ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: change create project and change message logs ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: controller response before ansible fetch ([8983c45](https://github.com/dnum-mi/dso-console/commit/8983c457cd8de96d117d51881668b37c3b4f9407)) +* :bug: delete default argo repo at deleting ([5fafe99](https://github.com/dnum-mi/dso-console/commit/5fafe9919b7d08447e356eb260795a6cb91f3ba3)) +* :bug: fix projectAddUserController ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: flatMap didn't work as expected ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: forgot to load repositories data ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: import env ([98c1632](https://github.com/dnum-mi/dso-console/commit/98c16329e8fbd3b0bcabf2ac132d53931ef7f9bf)) +* :bug: init db doit être séquentiel pour ne pas violer l'intégrité des tables ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: isInfra & isPrivate false if undefined ([2fa987d](https://github.com/dnum-mi/dso-console/commit/2fa987d0872f6884627d6060c64d27d932a2db5c)) +* :bug: isInfra not send to ansible ([b049746](https://github.com/dnum-mi/dso-console/commit/b049746b97e5529d6f5368e045eb96a266bb432b)) +* :bug: mode dégradé si payload n'est pas bon ([14309cf](https://github.com/dnum-mi/dso-console/commit/14309cf41835f59168c72b6a4840fcf1ae8264d2)) +* :bug: repositories may be empty ([584798f](https://github.com/dnum-mi/dso-console/commit/584798f1ebba5894b2f9181cbe0f9bdbd49cdd6c)) +* :bug: review changes ([5e4bdb7](https://github.com/dnum-mi/dso-console/commit/5e4bdb73414102728e68d56fe30d65a7f718a0f7)) +* :bug: send proper data to ansible ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: send200 if code is OK ([08ca7eb](https://github.com/dnum-mi/dso-console/commit/08ca7ebdf0b24dabc60baa631c4887854780465c)) +* :bug: show permissionForm only when environment is created ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: stabilize playbookDir in different execution execution ([69645b9](https://github.com/dnum-mi/dso-console/commit/69645b9b392f8f56b8d3606780dfa63098fa5240)) +* :bug: update ansible calls ([04ee9ff](https://github.com/dnum-mi/dso-console/commit/04ee9fff7e78bbe4305ad7a1923df842a80a49ad)) +* :bug: update orgName generation to new db schema ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: use flatMap to iterate over two arrays ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: wip bug ansible repositoryInitializing ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: wip working on todo's ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: working on todo's ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: organization should be a FK in projects ([d014387](https://github.com/dnum-mi/dso-console/commit/d014387c8d09182675ddf98d381e27b78bc28545)) +* :card_file_box: turn envList into array of objects ([41f0278](https://github.com/dnum-mi/dso-console/commit/41f027858f108fb056958c118bf664a141e7623c)) +* :construction_worker: Copy test-utils in client docker ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: trying to log details for ansible api call ([ae944ae](https://github.com/dnum-mi/dso-console/commit/ae944aea77d0f13798c63e9bf9690363f75e9bc9)) +* :construction: wip ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :fire: remove bad log and comms ([748c43d](https://github.com/dnum-mi/dso-console/commit/748c43d93212a79b2dd04e527f97da5dd5d585b5)) +* :goal_net: add front error management for createProject ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :goal_net: catch errors in front when calling back ([5331773](https://github.com/dnum-mi/dso-console/commit/53317734961895bf348c758774d23c2505f812a8)) +* :lipstick: update UI for new DSFR version ([62750c4](https://github.com/dnum-mi/dso-console/commit/62750c478ecac453542e5933d1464af2a752e2d4)) +* :lock: filter users sensitive infos ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :lock: improve security using node utility function to compare hashes ([ea0e730](https://github.com/dnum-mi/dso-console/commit/ea0e73084a04c85d69e8d556e73241982df5d40f)) +* :loud_sound: mute logs for health route ([b1eaad5](https://github.com/dnum-mi/dso-console/commit/b1eaad5701ac73b131b0cb8692111ae5fd0a8d84)) +* :mute: remove sequelize logs to preserve data ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :necktie: permission level goes from 0 to 3, 0 by default, 3 for owner ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :pencil2: typo on var name ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :recycle: change where directive to through associations ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :recycle: fix ([8d2c911](https://github.com/dnum-mi/dso-console/commit/8d2c911b855f19b0a6568e2857129c9c62fcfcc9)) +* :recycle: use sequelize querytypes ([d45472a](https://github.com/dnum-mi/dso-console/commit/d45472a89a5be2a74c1af2da14d41ceb5d118284)) +* :rotating_light: Sonar warnings ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :rotating_light: SonarQube warning ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: fix TU ([26bd4f1](https://github.com/dnum-mi/dso-console/commit/26bd4f1477bd3664472ca4d8989630f09c2a7e0b)) +* :test_tube: use dedicated en var for ct tests ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: wip fix repo ansible fetch failed ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :truck: fix generateCIFiles feature ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :truck: rename folder for generating files ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) + +### Performance Improvements + +* :art: use map instead of forEach for array generation ([03c78fa](https://github.com/dnum-mi/dso-console/commit/03c78fa5f28bb86fad7e3e1b142a1c71d1639054)) +* :stethoscope: add health probes in templates ([d645f2e](https://github.com/dnum-mi/dso-console/commit/d645f2e35460ba92a67aeb0ea65ac9356ed36381)) + +### Reverts + +* :egg: hide environments and permissions feature waiting for playbooks ([edebee9](https://github.com/dnum-mi/dso-console/commit/edebee944905f5723518aaca0c37a51deeac80a6)) +* :rewind: hide snackbar for now ([6afe9e8](https://github.com/dnum-mi/dso-console/commit/6afe9e81bc5b32cb544456d7e697be66ea0349be)) + +### Code Refactoring + +* **database:** :card_file_box: new database model, queries, ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) + +## 1.0.0 (2022-12-16) + +### Features + +* :alembic: clone in entrypoint ([f97edb0](https://github.com/dnum-mi/dso-console/commit/f97edb07c31036cb1fc1882610e2f78185106028)) +* :art: baudoin's review ([656aac6](https://github.com/dnum-mi/dso-console/commit/656aac693a4c21a64402dd9bcd8b978f2fb63b55)) +* :art: introduce CLONE_DIR, where to clone ([263ea79](https://github.com/dnum-mi/dso-console/commit/263ea79deef5d9f58e2fc10ffcf98e31a4b41bc2)) +* :art: spread instead of push ([530b366](https://github.com/dnum-mi/dso-console/commit/530b366368b8eb1a2f2597362ecc2c5aa2bc1145)) +* :bento: Add images for services logos in public folder ([6d8e91c](https://github.com/dnum-mi/dso-console/commit/6d8e91ca9837dd60f603d5ed364e11fb3485a5b5)) +* :bento: Definitive logos for services ([e2e6270](https://github.com/dnum-mi/dso-console/commit/e2e627081224433e0d190876a8791aa1d2ea8d6b)) +* :boom: first query successfull ([61a7cd5](https://github.com/dnum-mi/dso-console/commit/61a7cd5857a6e6c648219d20399c19e8672a37db)) +* :boom: first query successfull ([36c973e](https://github.com/dnum-mi/dso-console/commit/36c973ebaa9f3a52669d0f921eedfb8aee6c4a9f)) +* :bricks: change routes ([af30bd8](https://github.com/dnum-mi/dso-console/commit/af30bd895bef8203b3e06facfa04d93be83b86f9)) +* :card_file_box: Add api getProjectById ([98cd231](https://github.com/dnum-mi/dso-console/commit/98cd23165e72add49762bc31b81c54526f42b768)) +* :card_file_box: Add api getProjectById ([98976d0](https://github.com/dnum-mi/dso-console/commit/98976d0a33cf65ee3be4baad629dae107410c6e3)) +* :card_file_box: Add createProject query ([9bcfb05](https://github.com/dnum-mi/dso-console/commit/9bcfb059d2c3c51c64ee223b777da5d999339e44)) +* :card_file_box: Add owner from keycloak in project ([5aa9aad](https://github.com/dnum-mi/dso-console/commit/5aa9aad9f12b5c5060139baebd5b2869ea91b1f4)) +* :card_file_box: Add owner from keycloak in project ([cadb474](https://github.com/dnum-mi/dso-console/commit/cadb4745648ecdfc0ffbbc045ca4a054ee062615)) +* :card_file_box: Add postgres and pgadmin ([76374e6](https://github.com/dnum-mi/dso-console/commit/76374e6acd72ba89ab15bdf4c7e1ff82f00e110d)) +* :card_file_box: Add user id in project queries ([48949df](https://github.com/dnum-mi/dso-console/commit/48949df562179493e2b524b541129ff520913d86)) +* :card_file_box: Create database & table on server start if not exists ([14b9f6b](https://github.com/dnum-mi/dso-console/commit/14b9f6b5359e33bd345eccb1be081698fbda8b59)) +* :card_file_box: Wip add createProject api ([1414bc0](https://github.com/dnum-mi/dso-console/commit/1414bc0ed59f63fd41f21dcc76dcebebd1a5c4cf)) +* :card_file_box: Wip app and server js ([2d4efdf](https://github.com/dnum-mi/dso-console/commit/2d4efdf91f98a82fcd18c3887c1943a283c653c8)) +* :construction: (wip) add project mongoose model ([76e8d77](https://github.com/dnum-mi/dso-console/commit/76e8d77a3342ff58707b47977a8cdd14a721339d)) +* :construction: (wip) trying to connect to keycloak with server ([2401fda](https://github.com/dnum-mi/dso-console/commit/2401fdaa383a38632e95d1097e4a3766ef7f6563)) +* :construction: Add check in addRepo and addUser queries ([c2bad66](https://github.com/dnum-mi/dso-console/commit/c2bad663b9aba71dffd9e023cbd098a38353ee0d)) +* :construction: Add DsfrTable for users ([8b63e84](https://github.com/dnum-mi/dso-console/commit/8b63e84bd6923f5128efbd427e659418794f7eaf)) +* :construction: Add DsoTeam table ([ca3e2c3](https://github.com/dnum-mi/dso-console/commit/ca3e2c3462d9e17d90e10afed4432b24735ded24)) +* :construction: Add final todos ([c224592](https://github.com/dnum-mi/dso-console/commit/c2245927e6a2b61cd3bf4fbef4fddf5708ccd20f)) +* :construction: Add OrderProject ([f4d2962](https://github.com/dnum-mi/dso-console/commit/f4d2962ec7b76a13c7f9849302f3a0279e79a442)) +* :construction: Add postgres and pgadmin ([a7ce8c5](https://github.com/dnum-mi/dso-console/commit/a7ce8c5ff9aee0003f7d2066a6f2e172b9d5bd08)) +* :construction: Add postgres and pgadmin ([6fdf560](https://github.com/dnum-mi/dso-console/commit/6fdf56045839636324015739852ee85d5685d584)) +* :construction: Add removeUserFromProject function ([9430fc6](https://github.com/dnum-mi/dso-console/commit/9430fc697e9761c3e79f0d37bef16dcf52f1e889)) +* :construction: Add routes for projects ([cd14601](https://github.com/dnum-mi/dso-console/commit/cd146017e1a6646777451e0ed711b9d6556e5393)) +* :construction: Add SideMenu to app ([3373f8c](https://github.com/dnum-mi/dso-console/commit/3373f8cf18607293f1081172ed93e8dabd69fd28)) +* :construction: Configure keycloak for frontend ([d8074ac](https://github.com/dnum-mi/dso-console/commit/d8074ac468a562322a8d44ed5d08b26be56cc7e8)) +* :construction: Failing specs projects ([a56cb8c](https://github.com/dnum-mi/dso-console/commit/a56cb8cb0685e3173b867519d09f4ca41b217959)) +* :construction: Func removeUserFromProject ([95fdc7f](https://github.com/dnum-mi/dso-console/commit/95fdc7f8cd8285bf18257364f833b9eb84588cf1)) +* :construction: Handle menu collapse in sm mode ([5237e89](https://github.com/dnum-mi/dso-console/commit/5237e892f86f5c4abd170562867a4745e2121bb9)) +* :construction: trying to access server /api/v-4/version ([a27483c](https://github.com/dnum-mi/dso-console/commit/a27483c6525091eca564b9ad615636edf79c5283)) +* :construction: trying to access server /api/v1/version ([2e43c8f](https://github.com/dnum-mi/dso-console/commit/2e43c8fd0c13f59bd40567912815a605c43bf0dc)) +* :construction: wip add node-postgres ([50ef526](https://github.com/dnum-mi/dso-console/commit/50ef526f2e647d5a16254468e9e83996e71baab1)) +* :construction: wip business rules orderProject ([24f7daa](https://github.com/dnum-mi/dso-console/commit/24f7daaae5cba5d7fdb6ee1ebc700c9aba1a0830)) +* :construction: wip pg ([391bccb](https://github.com/dnum-mi/dso-console/commit/391bccbbf34c9a365eed847824edb718643a8d73)) +* :construction: working on api x keycloak ([183f4d8](https://github.com/dnum-mi/dso-console/commit/183f4d8c9e49407f500444210f2f143dcf1433c3)) +* :hammer: Add turbo ([a3fdc96](https://github.com/dnum-mi/dso-console/commit/a3fdc96e23fbd3c66dea95ce89829dbe873b835a)) +* :hammer: Update docker compose scripts for pnpm ([537ba52](https://github.com/dnum-mi/dso-console/commit/537ba52f8c75659a8204beb5e48fa2ff11572be2)) +* :heavy_plus_sign: Add axios for api management ([591b012](https://github.com/dnum-mi/dso-console/commit/591b012df344e0842ce3848eaae4aa0c3beceb73)) +* :heavy_plus_sign: Add fastify keycloak adapter ([575e8d7](https://github.com/dnum-mi/dso-console/commit/575e8d71aa5d4fe9a0f2da94c484de2867ca5c9a)) +* :lipstick: Resize sidemenu and content ([61670d8](https://github.com/dnum-mi/dso-console/commit/61670d816aa3b70aa164e3d8531b12b3f835fcc6)) +* :lipstick: thiner projects subheader ([4427945](https://github.com/dnum-mi/dso-console/commit/442794546e8977dc3298a5ec92e68a30ef3775eb)) +* :lipstick: Update navigation in frontend ([5751ed3](https://github.com/dnum-mi/dso-console/commit/5751ed36dbdbeceb832c9bcc0c9972fca2abd26c)) +* :lock: Add keycloak token check on ansible api ([afe18f5](https://github.com/dnum-mi/dso-console/commit/afe18f58dc7c041adb40b1dee7617cb6b38ef9f5)) +* :loud_sound: forward request id to ansible api ([b6107bb](https://github.com/dnum-mi/dso-console/commit/b6107bbc322d6740bfbe7345e5865af6df4cb219)) +* :memo: Add documentation inside application ([0d70a0e](https://github.com/dnum-mi/dso-console/commit/0d70a0e8c6b4248e427e63d987dce7498f0c64ee)) +* :necktie: Add removeuser func ([3da7701](https://github.com/dnum-mi/dso-console/commit/3da77019f350b4dee5031ffbf5a97735299b0df9)) +* :necktie: users[].email must be unique ([d74b99d](https://github.com/dnum-mi/dso-console/commit/d74b99d642cdb3dea3fcd18116f644aa34542dbf)) +* :poop: dinindex turbo ([d7e78cb](https://github.com/dnum-mi/dso-console/commit/d7e78cb1e15c7008f761af42961bdc2754b117d0)) +* :poop: Pass current location to redirectUri ([ee65e42](https://github.com/dnum-mi/dso-console/commit/ee65e42daaff2fb20c4a9142a5ba87357e415088)) +* :poop: test 2 ([5c3b1da](https://github.com/dnum-mi/dso-console/commit/5c3b1da4c4d95ce3a9294c175f4826e174ae6436)) +* :poop: try to remove .tubro/ ([01ba3ef](https://github.com/dnum-mi/dso-console/commit/01ba3efaa8efacbb97638c5e89aebd3255149d2b)) +* :poop: turbo unindexation test ([2e7ba79](https://github.com/dnum-mi/dso-console/commit/2e7ba79848aae2fc174e364de867ff59159ed39c)) +* :recycle: Baudoin's review, userProfile in store ([c1a34b4](https://github.com/dnum-mi/dso-console/commit/c1a34b4c2c48455874829966d220b0b3137a010c)) +* :recycle: Harmonize api's names ([71ed577](https://github.com/dnum-mi/dso-console/commit/71ed577474dbe431cc1ebdb83336510ac93c58c8)) +* :recycle: Merge conf and init in a single keycloak file ([e777b02](https://github.com/dnum-mi/dso-console/commit/e777b028ce94df3288ab03bc6bdeea2ba2408fde)) +* :recycle: service url construction ([6b25ab3](https://github.com/dnum-mi/dso-console/commit/6b25ab3d40449da2b50ecd12604c6030aa0b287d)) +* :see_no_evil: Ignore .tubro ([d927f05](https://github.com/dnum-mi/dso-console/commit/d927f0559ecff8d3805709d9eca0487786295b52)) +* :sparkles: Add allTiles for all services, adapt projectTiles to selectedProject ([d207065](https://github.com/dnum-mi/dso-console/commit/d207065a628e26293f441a38ae9dd17950188d7c)) +* :sparkles: Add DsoService content ([024cbd0](https://github.com/dnum-mi/dso-console/commit/024cbd0c50475413a2a41097e301c88c87885363)) +* :sparkles: Add envList to project ([02e5215](https://github.com/dnum-mi/dso-console/commit/02e5215ec25a9dc7decd6c7d4d26c68a3a1b5fa5)) +* :sparkles: Add infra repo checkbox ([b0c0602](https://github.com/dnum-mi/dso-console/commit/b0c06020ea9f2aa1580514aa9498aec38cb3824b)) +* :sparkles: Add sidemenu and tmp routes ([be02a0c](https://github.com/dnum-mi/dso-console/commit/be02a0c2184b8ff92b13745efdfdda5626d91ec6)) +* :sparkles: Add specific endpoints to console api ([a35b9cc](https://github.com/dnum-mi/dso-console/commit/a35b9ccf452db0db438990633eded6b8d5ae0be7)) +* :sparkles: Add user to project ([85d5a1c](https://github.com/dnum-mi/dso-console/commit/85d5a1c98f9374ba9c7fb9b4f179bb8f40a6b0f3)) +* :sparkles: Add users[i].id in query ([35a36b4](https://github.com/dnum-mi/dso-console/commit/35a36b437d224b3d4b490a7c0ff2fdfe42f80410)) +* :sparkles: Add vault url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :sparkles: Add windicss to project ([8db02f5](https://github.com/dnum-mi/dso-console/commit/8db02f531f6f4dbc768ae726597b957cb8cb8e3a)) +* :sparkles: Create projects view ([aa0d833](https://github.com/dnum-mi/dso-console/commit/aa0d83356ab679261893cb563237062cdbbc04e6)) +* :sparkles: Finish v1 orderProject form ([590eb74](https://github.com/dnum-mi/dso-console/commit/590eb744f9daab7b9e34e6e2e9a376257f8290da)) +* :sparkles: Prepare ansible call for project provisioning ([a3d0918](https://github.com/dnum-mi/dso-console/commit/a3d09185837675f7954dad3185651ca300373bb1)) +* :sparkles: Separate ansible wrapper in a new api called ansible-api ([bcd64db](https://github.com/dnum-mi/dso-console/commit/bcd64dbdfa3c043bcf7c77f783ce1d36e43e4d05)) +* :speech_balloon: Correct services url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :speech_balloon: Update app's title ([869e2a3](https://github.com/dnum-mi/dso-console/commit/869e2a34455f1adb80d52e7fc5c4bc4c884368dd)) +* :speech_balloon: Update services url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :technologist: Add husky unit tests for server ([75e3a23](https://github.com/dnum-mi/dso-console/commit/75e3a23199ba15de9229809558a2b2569e38bdd4)) +* :technologist: Add watch src for hot reload ([e4e129d](https://github.com/dnum-mi/dso-console/commit/e4e129db4803f3bc5ed5a1ee3fc759ebcef38d89)) +* :truck: Better images for logos ([8ce9112](https://github.com/dnum-mi/dso-console/commit/8ce911280348e49d9f197dc9947d0d7d5bf5cee4)) +* :truck: Create folder projects in views, create store project ([a1df0f6](https://github.com/dnum-mi/dso-console/commit/a1df0f6bfcdbdba96d9b8c1c77e8500074f59512)) +* login on keycloack TEST ([20ab492](https://github.com/dnum-mi/dso-console/commit/20ab492a78f5e3e81a3295a3416a4422422d4244)) +* page login ([0c3946b](https://github.com/dnum-mi/dso-console/commit/0c3946b0ff04889b8b0fcf7b5160fea4409b3e8d)) +* **projectInit:** :building_construction: Add client folder ([9f3e90d](https://github.com/dnum-mi/dso-console/commit/9f3e90d305aebc6d9508d4570ffc0d1fac9502f2)) +* **projectInit:** :heavy_plus_sign: Add Stylelint ([21f33e0](https://github.com/dnum-mi/dso-console/commit/21f33e0554b04932a35ca723934c24bd93122513)) +* **projectInit:** :tada: Vite vue3 basic installation ([41aeb82](https://github.com/dnum-mi/dso-console/commit/41aeb824d02ba387bc8c842b07351fd55b221782)) + +### Bug Fixes + +* :alembic: edit code to run with new ansible directories ([8822d95](https://github.com/dnum-mi/dso-console/commit/8822d953e93db6f4ddc00383e7ad4a8c01e8cbaf)) +* :alembic: edit server image, kubeconfig fix for ansible ([5e02c5e](https://github.com/dnum-mi/dso-console/commit/5e02c5ef97a5fc67cfd30d296b36bf339fff8251)) +* :alembic: Try to fix keycloak redirect ([954bc51](https://github.com/dnum-mi/dso-console/commit/954bc513be0ff9a1e46b140af334273f710b9ec2)) +* :ambulance: fix a commit written too late in the night ([ee0cc99](https://github.com/dnum-mi/dso-console/commit/ee0cc99fab9a243ffddcc5ed874cbcd449d319f4)) +* :art: add env for keycloak in FRONT ([dea4e38](https://github.com/dnum-mi/dso-console/commit/dea4e38b36d9bb672f4f9e1079e9540d0e93d5af)) +* :art: rename file user in stores ([86be41e](https://github.com/dnum-mi/dso-console/commit/86be41e2ee7d6c8d0a0baad9507fe46fb8a527b4)) +* :bug: Add case sensitivity for init deb script ([699dedd](https://github.com/dnum-mi/dso-console/commit/699dedd5c8b149d8de83b632184fdb28b15784fc)) +* :bug: Add tagName (type) so it's closeable ([8aa65d5](https://github.com/dnum-mi/dso-console/commit/8aa65d53d03641ae30b0f989874f4acb85889b3f)) +* :bug: duplicated line ([2dad819](https://github.com/dnum-mi/dso-console/commit/2dad819540e76852251ef3ac8b7c3618abec0c71)) +* :bug: Finish removeUser feature ([25a4f36](https://github.com/dnum-mi/dso-console/commit/25a4f363ca99b3dd5b9a9adf6cf940021c509a5e)) +* :bug: Fix after dsfr's upgrade ([9273618](https://github.com/dnum-mi/dso-console/commit/92736182d04f6a965b15d0514a8c18e329ebec7a)) +* :bug: Fix bugs revealed by e2e tests ([f9ff1fc](https://github.com/dnum-mi/dso-console/commit/f9ff1fc8c633b894864489cb7b8c51eaf733e012)) +* :bug: Fix db init if not exists ([d3a7b77](https://github.com/dnum-mi/dso-console/commit/d3a7b779fb00399a94e6db570583bed77e4fc355)) +* :bug: Fix endless redirection ([7762a83](https://github.com/dnum-mi/dso-console/commit/7762a8358b6a76879e41ac6ea5a29765ec36a2e1)) +* :bug: Fix init db error ([9b50685](https://github.com/dnum-mi/dso-console/commit/9b5068519d7d26126c9730d32b375cb9e9243e92)) +* :bug: Fix multiples bugs ([fc94d99](https://github.com/dnum-mi/dso-console/commit/fc94d992ee0495113f7e5f4cd165f90b2460dd9d)) +* :bug: Fix potentital bugs ([ec04eee](https://github.com/dnum-mi/dso-console/commit/ec04eeeeb745be5e4a4ab5ad8a1c60e08cb74168)) +* :bug: Handle cases where no users ([47befb9](https://github.com/dnum-mi/dso-console/commit/47befb9e0dcfa07e733596aa5ccd9323e7447ad6)) +* :bug: Handle cases where users key does not exist in project ([98381dc](https://github.com/dnum-mi/dso-console/commit/98381dc3a746993c563b0b817cd6e985c40d4cc6)) +* :bug: Improve backend & fix in adequation of front changes ([a4df195](https://github.com/dnum-mi/dso-console/commit/a4df19533cd2e634149c549e5a717bf7b5bd1280)) +* :bug: kc logout ([c3621b9](https://github.com/dnum-mi/dso-console/commit/c3621b9f4db92b1a73a21b53d3894d79f2bdeb1d)) +* :bug: Last reviews ([4f39f8a](https://github.com/dnum-mi/dso-console/commit/4f39f8aea216aee29ff1128a7c5b60174e98b489)) +* :bug: multiple fixes, increase some log verbosity ([39e543b](https://github.com/dnum-mi/dso-console/commit/39e543bc8071d8323af96e85a6f6c8b7beffce19)) +* :bug: Reinit projects in store before pushing query result ([53d6c3f](https://github.com/dnum-mi/dso-console/commit/53d6c3f94e1211a69034c580abc6bd2731d7e717)) +* :bug: remove extra properties, it failed tests ([98abead](https://github.com/dnum-mi/dso-console/commit/98abeadf4ad5aad0eaab3f706500e27c98444ce3)) +* :bug: Remove unintentionaly added import ([8c6cab6](https://github.com/dnum-mi/dso-console/commit/8c6cab6fa05b57639003ea1e514da973d79fad3b)) +* :bug: Review tobi, an array even empty is always true ([9fe7068](https://github.com/dnum-mi/dso-console/commit/9fe706894c930fa5f2d187c0ce1500d75a797e29)) +* :bug: update for production ready ([4e80d61](https://github.com/dnum-mi/dso-console/commit/4e80d61fd3f9ec0526d99b3f3fec65a4ddfa78b0)) +* :coffin: Remove test class ([38a39ed](https://github.com/dnum-mi/dso-console/commit/38a39ed849fdcc7f62bdb24223f5cd74407e5aa9)) +* :coffin: Remove unused function ([b557bdc](https://github.com/dnum-mi/dso-console/commit/b557bdcb5b8fcb927ffc30cf9e4c0830d35b5363)) +* :construction_worker: Remove prod flag for dev stage in server dockerfile ([3e28a00](https://github.com/dnum-mi/dso-console/commit/3e28a00d1a652d46fcb0072471090ee2ad0fb899)) +* :construction: broken install in test yml ([1121d8b](https://github.com/dnum-mi/dso-console/commit/1121d8b7f90fc301a303c8705a039e1175d55eae)) +* :construction: Fix init keycloak in frontend ([0b41546](https://github.com/dnum-mi/dso-console/commit/0b41546b355e3583dd02fc1793c24c30af860030)) +* :construction: Wip reinit newUser object ([699a0bb](https://github.com/dnum-mi/dso-console/commit/699a0bbf8600efed4d1bf62a07d02f78c0e1c38c)) +* :construction: working on keyclock + pinia ([95d5822](https://github.com/dnum-mi/dso-console/commit/95d5822b23277b24a87f6f6d6c8ca77fc6a49926)) +* :fire: Remove useless silentchecksso & move cypress/vue to optional dep ([3b85f7a](https://github.com/dnum-mi/dso-console/commit/3b85f7a7f792d6fcb0b7f9bf8b94fe82b02ed577)) +* :globe_with_meridians: All english for dso-console ([6d45cba](https://github.com/dnum-mi/dso-console/commit/6d45cba12a6540dd13a933f6940b91701dbdab34)) +* :green_heart: Fix e2e tests in ci & improve code structure ([0857f70](https://github.com/dnum-mi/dso-console/commit/0857f703f0445cd49940d3337c4d94c9af4c70ea)) +* :lock: add binding in sql request to avoid sql injection ([46f5d04](https://github.com/dnum-mi/dso-console/commit/46f5d048e9df384c8f116bfdd8c9b7005160b5bf)) +* :loud_sound: Add logs for investigation ([b8f4867](https://github.com/dnum-mi/dso-console/commit/b8f48679f350ef34e856925fc9ff42ecfa1f281a)) +* :memo: Last review ([f37ad55](https://github.com/dnum-mi/dso-console/commit/f37ad5549d4e863cc78ed970c0525b4ed421b7f1)) +* :package: add jmespath lib ([5fcdd41](https://github.com/dnum-mi/dso-console/commit/5fcdd41c74ca678ce8cdfa79e5626d4eaeaba14c)) +* :pencil2: Typo in js ([ef99847](https://github.com/dnum-mi/dso-console/commit/ef99847908ce21cd28bddc8787101d9448c1976d)) +* :recycle: Refacto brainstorm thibault ([4e24417](https://github.com/dnum-mi/dso-console/commit/4e24417f7ac7a09dd57aa93f19d174ff679dcf74)) +* :rewind: Readd vitest config in shared ([2ec8b14](https://github.com/dnum-mi/dso-console/commit/2ec8b149a8d47136ac8305359688679e1eac1316)) +* :rewind: Rebase bug fix ([dbfaa2b](https://github.com/dnum-mi/dso-console/commit/dbfaa2b4732f51d6e1de0f731c55fa25518cc91c)) +* :rotating_light: Warn in console for missing required id ([db26c58](https://github.com/dnum-mi/dso-console/commit/db26c587106f1be4bf4765a0ea207adde71bb0ff)) +* :technologist: Add env exemple ([64fec78](https://github.com/dnum-mi/dso-console/commit/64fec783624dd4e3a625dbdd16f0bf9736adcc0d)) +* :test_tube: (to remove) failing tests skipped ([aeaded1](https://github.com/dnum-mi/dso-console/commit/aeaded1671f5de18d9e613fa32fda162a6147a27)) +* :white_check_mark: Correct api call in repo e2e test ([64d5196](https://github.com/dnum-mi/dso-console/commit/64d51968bad97f50f36a1619916b7c2b82378078)) +* :white_check_mark: Fix e2e tests on repo ([de0c0a5](https://github.com/dnum-mi/dso-console/commit/de0c0a59e02b639a67066c28b9532a5d38e12e98)) +* :zap: remove ansible legacy from server ([68c9dc9](https://github.com/dnum-mi/dso-console/commit/68c9dc9100aff3e71ccdab18608f61f9420e720f)) +* conf keycloak for cors from localhost:8080 ([1e06ff0](https://github.com/dnum-mi/dso-console/commit/1e06ff033094024d4e1a4f66bab5443b4505247c)) +* login on keycloack TEST ([c983eed](https://github.com/dnum-mi/dso-console/commit/c983eedccdba49d024ba6e5726f69afd33e577a1)) +* **projectInit:** :bricks: Add ports in vite config ([5de6e86](https://github.com/dnum-mi/dso-console/commit/5de6e86e02343ff910023681960e3bf7a788becd)) +* **projectInit:** :bug: Add context in docker-compose ([3ac3a72](https://github.com/dnum-mi/dso-console/commit/3ac3a7297c1b81fdb93ed099605ceeb2044ad617)) +* **projectInit:** :hammer: Fix run test script for client ([272f007](https://github.com/dnum-mi/dso-console/commit/272f007803d35e2113646d57b715e84249fa65bf)) +* remove double code in dockerfile ([2951672](https://github.com/dnum-mi/dso-console/commit/29516725acf793168854296f6f35088ef5b3447c)) +* rename and add volumes for keycloak ([3d0d999](https://github.com/dnum-mi/dso-console/commit/3d0d999630906223e8cb89fa51b31022e04e1659)) +* run cypress in container ([4589169](https://github.com/dnum-mi/dso-console/commit/4589169d0bb0fd1c368c08869e80251d0d5f691a)) +* run with delay 10 second to wait keycloak initialize ([b736991](https://github.com/dnum-mi/dso-console/commit/b736991208b9a5fc0880fba294fa96dc073f4c8a)) +* update gitignore for all node_modules ([3988743](https://github.com/dnum-mi/dso-console/commit/39887438eceefe4b08b80ad056f2992baaf78494)) +* update implicit flow in config keycloak ([06d2f77](https://github.com/dnum-mi/dso-console/commit/06d2f77fcc07db2de780867b0bda1f49294b349e)) +* update pnpm lock ([344261d](https://github.com/dnum-mi/dso-console/commit/344261d2e1b37e8df9eed0cca90ed8e21697d17a)) + +### Reverts + +* :recycle: Move code to another git branch ([1c632c7](https://github.com/dnum-mi/dso-console/commit/1c632c715ffc01100d75c46c1e84a565195a10b3)) diff --git a/.jjconflict-base-0/CONTRIBUTING.md b/.jjconflict-base-0/CONTRIBUTING.md new file mode 100644 index 0000000000..50318b28ee --- /dev/null +++ b/.jjconflict-base-0/CONTRIBUTING.md @@ -0,0 +1,115 @@ +# Contribuer à Console Cloud Pi Native + +Merci de contribuer à la Console Cloud Pi Native. Ce guide s'applique aux +contributions internes et externes, qu'elles concernent du code, de la +configuration, des dépendances ou de la documentation. + +## Principes + +La Console fournit une interface unifiée pour piloter les services de l'offre +Cloud Pi Native, automatiser le cycle de vie des projets et appliquer les +exigences DevSecOps de sécurité, de qualité, de maintenabilité et +d'interopérabilité. + +Toute évolution doit démontrer son alignement avec cette vision avant toute +implémentation. Une contribution qui ne sert pas l'offre, son architecture ou +sa feuille de route ne peut pas être acceptée. + +## Qualifier le sujet avant de développer + +Toute contribution de code doit être adossée à un ticket. Ce ticket décrit : + +- le besoin traité et son alignement avec la vision produit ou la feuille de route ; +- le périmètre fonctionnel et technique de la contribution ; +- les critères d'acceptation ; +- les impacts connus sur la sécurité, les contrats API, les données, l'exploitation et la documentation. + +Pour une correction de bug, une procédure de reproduction, un test automatisé +de non-régression et, pour une interface, des captures d'écran sont fortement +recommandés lorsqu'ils sont pertinents. Leur absence ne bloque pas la +contribution lorsqu'ils ne peuvent pas s'appliquer. Pour une fonctionnalité, +joindre des maquettes ou croquis, même rudimentaires, est encouragé. + +Les corrections strictement documentaires ou typographiques, sans impact +fonctionnel, sont les seules contributions qui ne nécessitent pas de ticket. +Elles restent soumises au contrôle de périmètre et aux exigences de qualité +adaptées. + +## Respecter le périmètre + +Une pull request ou merge request ne contient que les changements nécessaires +au sujet décrit par son ticket. Les commits, fichiers ou changements sans +rapport direct avec ce sujet sont rejetés et doivent être proposés séparément, +avec leur propre ticket. + +Utilisez le [template de pull request](.github/PULL_REQUEST_TEMPLATE.md) pour +présenter le comportement modifié et les informations utiles à la revue. Son +évolution est traitée séparément de ce guide. + +## Obtenir les validations requises + +Une approbation explicite de l'équipe Socle est obligatoire pour toute +contribution de code qui n'est ni une correction de bug ni un changement +trivial. + +L'approbation explicite de l'équipe Socle est obligatoire dans tous les cas +pour : + +- l'ajout, la suppression ou la mise à niveau d'une dépendance Node.js ; +- l'ajout ou la mise à niveau d'un outil installé dans une image Docker ; +- tout autre changement de dépendance ayant un impact sur la chaîne d'approvisionnement, la sécurité ou la maintenance. + +Les corrections de bugs et les changements triviaux ne dispensent ni du +contrôle de périmètre, ni de la revue de code, ni des contrôles qualité. Une +dépendance ajoutée pour corriger un bug doit donc recevoir l'approbation +explicite de l'équipe Socle. + +## Cibler le backend approuvé + +`apps/server` est le backend historique et ne reçoit plus de contribution. +Toute pull request ou merge request qui modifie ce dossier est rejetée, y +compris pour une correction de bug. + +Les évolutions backend ciblent exclusivement `apps/server-nestjs`. Elles +suivent le [plan de modularisation backend vers NestJS](apps/server-nestjs/documentation/Modularisation-de-console-server/README.md), l'[état de migration](apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md), les conventions NestJS et la stratégie de tests associée. Toute dérogation est une décision explicite de l'équipe Socle, hors du flux normal de contribution. + +## Respecter l'architecture et la qualité + +Respectez les frontières et conventions de la zone modifiée : + +- le frontend Vue 3 et le système de design existant ; +- les modules NestJS et Prisma dans `apps/server-nestjs` pour le backend ; +- les contrats partagés, permissions et schémas de données ; +- les plugins et leurs mécanismes de cycle de vie lorsqu'ils sont concernés. + +Ajoutez ou adaptez les tests proportionnés au risque et au périmètre : tests +unitaires ou d'intégration pour la logique et les contrats, et tests Playwright +pour les parcours utilisateurs affectés. Mettez à jour la documentation lorsqu'un +comportement, une API, une configuration, une opération ou une migration est +affectée. + +Avant soumission, exécutez les contrôles adaptés à votre modification : + +```bash +pnpm lint +pnpm test +pnpm playwright:test +``` + +Le test Playwright est requis lorsque la contribution affecte un parcours +utilisateur. Respectez aussi TypeScript strict, ESLint, Stylelint pour le +client, les hooks Husky et la CI. Les messages de commit suivent Conventional +Commits et sont rédigés en anglais. Rebasez votre branche sur `origin/main` +avant la demande de fusion. + +## Documents de référence + +Consultez et citez les références appropriées dans le ticket : + +- la [documentation publique Cloud Pi Native](https://github.com/cloud-pi-native/documentation), notamment l'[introduction de la plateforme](https://github.com/cloud-pi-native/documentation/blob/main/docs/platform/introduction.md) et la [feuille de route](https://github.com/cloud-pi-native/documentation/blob/main/docs/platform/roadmap.md) ; +- l'[architecture et les conventions du dépôt](README.md) ; +- le [plan de modularisation NestJS](apps/server-nestjs/documentation/Modularisation-de-console-server/README.md) ; + +La feuille de route publique est actuellement en cours de rédaction. Son +indisponibilité ne bloque pas la qualification : le ticket doit alors exposer +une justification concise au regard de la vision produit. diff --git a/.jjconflict-base-0/ENVIRONMENTS.md b/.jjconflict-base-0/ENVIRONMENTS.md new file mode 100644 index 0000000000..011939c593 --- /dev/null +++ b/.jjconflict-base-0/ENVIRONMENTS.md @@ -0,0 +1,244 @@ +# Fichiers de configuration d'environnements + +Cette documentation a pour but de détailler tout ce qui concerne la gestion des configuration d'environnements (appelé couramment "fichiers .env"). + +Comme vous aurez pu le constater, il y a beaucoup de choses à configurer pour un projet d'ampleur comme l'est la Console de CPiN, et il y a des cas d'usages très spécifiques qui seront décrits ici. Vous avez également la possibilité de composer votre propre manière de gérer vous variables d'environments en vous basant sur ce qui a été fait pour nous. + +## Contexte : migration progressive vers NestJS (Strangler Fig) + +Le backend de la console est en cours de migration de `apps/server` (Fastify/legacy) vers `apps/server-nestjs` (NestJS). Cette migration est progressive, route par route, selon le [Strangler Fig Pattern](https://martinfowler.com/bliki/StranglerFigApplication.html). + +Pour orchestrer cette migration, un **nginx-strangler** a été introduit (ticket [#1885](https://github.com/cloud-pi-native/console/issues/1885)). Il s'intercale entre le `client` et les backends, et route chaque requête API vers le bon service : + +``` +[client :8080] + │ proxy /api → nginx-strangler:8080 + ▼ +[nginx-strangler :8080] + ├── /api/[routes migrées] ──→ server-nestjs:3001 (NestJS) + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +**Conséquences pratiques pour les développeurs :** + +- `server-nestjs` est désormais présent dans tous les `docker-compose` aux côtés de `server`. +- Le `nginx-strangler` est le nouveau point d'entrée unique pour les appels API — le `client` ne pointe plus directement vers `server`. +- Consultez [`MODULARISATION-STATUT.md`](apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md) avant de développer sur un module backend, pour savoir s'il est en cours de migration (zones en feature freeze). +- Si vous travaillez sur un module déjà migré vers `server-nestjs`, développez dans `apps/server-nestjs`, pas dans `apps/server`. + +Pour plus de détails sur la stratégie de migration : [`apps/nginx-strangler/README.md`](apps/nginx-strangler/README.md). + +--- + +## Cas d'usage supportés + +Avant de décrire dans le détail comment configurer les différents environments, il est important de rappeler les Cas d'Usage qui sont supportés par les scripts de CPiN: + +- **Développement totalement en local** : vous déployez l'entièreté de l'écosystème de `console`, sans considération d'un branchement à l'extérieur, et vous développez directement en mode "serve" sur `client`, `server`, `server-nestjs`, un `plugin` en particulier, ou peut-être même une combinaison des trois. +- **Développement conteneurisé en local** : vous déployez l'entièreté de l'écosystème de `console`, sans considération d'un branchement à l'extérieur, et même les composants fondamentaux de console (comme `client`, `server` et `server-nestjs`) dans des conteneurs à l'aide d'images construites précédemment (en local, ou alors tirée depuis le registre de l'organisation CPiN) +- **Développement conteneurisé hybride** : vous déployez localement seulement une partie de l'écosystème de `console` (`client` et/ou `server`/`server-nestjs`, de manière à utiliser votre code local) et pour le reste (Base de données de `console`, `keycloak`, `gitlab`, les clusters applicatifs, etc.), vous vous branchez à un environnement existant (appelé `integ` pour `INTEGRATION`). Ce cas d'usage est très pratique pour tester votre code avec de "vraies données" d'un environnement fonctionnel (comme notre environnement interne `cpin-hp`) +- Les cas de déploiements finaux du système complet, qui sont eux adressé par un chart Helm stocké dans [le dépôt `helm-charts`](https://github.com/cloud-pi-native/helm-charts) + +Maintenant que ces définitions sont établies, passons à la configuration pour chacun des cas d'usage + +## Considérations communes à tous les cas + +Le mode de fonctionnement de la configuration des environnements est assez classique : les applications `client`, `server` et `server-nestjs` ont besoin d'avoir certaines variables d'environnements définies. + +Le mécanisme de surcharge des différentes configurations fonctionne de cette manière : + +``` +->: «surcharge» + +var d'env settée explicitement -> fichier .env.docker (si contexte docker) -> fichier .env.integ (si INTEGRATION=true) -> fichier .env +``` + +## Prégénération des fichiers .env, .env.docker, et .env.integ + +Un script permet de copier facilement les fichiers `.env*-example` en leur équivalent `.env*`: [`./ci/scripts/init-env.sh`](./ci/scripts/init-env.sh). + +> Il faut ensuite remplir ces fichiers, car ils ne sont là que simplement copiés avec les valeurs par défaut + +## Configuration pour le développement entièrement en local + +Le développement entièrement en local suit cette organisation des composants: + +```mermaid +--- +title: Architecture mode entièrement local +--- +graph LR + + subgraph DEV_LOCAL["DEV LOCAL"] + direction LR + NAV_L["Navigateur"] + NGINX_L(("nginx (client)\n:8080\nLOCAL")) + STRANGLER_L(("nginx-strangler\n:8080\n🐳 exposé :4000")) + SERVER_L(("server\n:4001\nLOCAL")) + NESTJS_L(("server-nestjs\n:3001\nLOCAL")) + + NAV_L --> NGINX_L + NGINX_L -- "/api" --> STRANGLER_L + STRANGLER_L -- "défaut\n(pas migré)" --> SERVER_L + STRANGLER_L -- "migré" --> NESTJS_L + end + + style DEV_LOCAL fill:#fff,stroke:#333,stroke-width:2px + style NAV_L fill:#fff,stroke:#333 +``` + +Docker Compose utilisé : [`docker/docker-compose.local.yml`](docker/docker-compose.local.yml) (infrastructure uniquement : Keycloak, PostgreSQL, pgAdmin, OpenCDS mock, **nginx-strangler, et Jaeger**) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/server/.env` +- `apps/server-nestjs/.env` *(si vous travaillez sur server-nestjs)* + +Les valeurs par défaut, disponibles dans les fichiers `.env-example`, sont suffisantes dans 99% des cas. Cela dit, c'est un cas d'usage assez restreint car la console se reposant sur quelques composants externes comme une base de données PostgreSQL ou un serveur IAM comme `keycloak`, il faut configurer manuellement les `.env` pour pointer sur les bonnes URLs, etc. Pas infaisable, mais pas très pratique au quotidien, hors des cas simple de build des applications. + +**Commandes de lancement :** + +```bash +# Lance l'infrastructure (Keycloak, PostgreSQL, pgAdmin, OpenCDS mock, nginx-strangler, Jaeger) +pnpm dev + +# Puis dans d'autres terminaux, lancer les serveurs et le client manuellement : +pnpm --filter server run dev +pnpm --filter server-nestjs run start:dev # nouveau backend NestJS +pnpm --filter client run dev +``` + +### Faire transiter les appels API par le nginx-strangler en dev local + +Par défaut, le proxy Vite du `client` pointe directement vers `server` (port `4000`). Pour faire transiter les appels via le `nginx-strangler` (utile pour tester le routage de migration ou travailler sur `server-nestjs`), une seule variable suffit dans `apps/client/.env` : + +```bash +# apps/client/.env +SERVER_PORT=8082 # port du nginx-strangler exposé par docker-compose.local.yml + # (au lieu de 4000 qui pointe directement vers server) +``` + +Le `nginx-strangler` est automatiquement lancé par `pnpm dev` via `docker-compose.local.yml`. Il écoute sur `localhost:8082` et redirige vers les deux backends natifs (`server:4000` et `server-nestjs:3001`) selon la configuration de [`apps/nginx-strangler/conf.d/routing.conf`](apps/nginx-strangler/conf.d/routing.conf). + +> **Note :** si vous ne travaillez pas sur la migration NestJS, vous n'avez pas besoin de changer `SERVER_PORT` — le comportement par défaut (proxy direct vers `server:4000`) reste identique. + +### Observabilité : Jaeger + OpenTelemetry (traces) + +Le fichier [`docker/docker-compose.local.yml`](docker/docker-compose.local.yml) démarre un service `jaeger` (image `jaegertracing/all-in-one`) pour collecter et visualiser les traces. + +- UI Jaeger : http://localhost:16686 +- Endpoints de collecte exposés sur la machine hôte : + - OTLP gRPC : `localhost:4317` + - OTLP HTTP (protobuf) : `http://localhost:4318` + +Dans `apps/server-nestjs`, l'instrumentation OpenTelemetry est initialisée au démarrage via [`src/instrumentation.ts`](apps/server-nestjs/src/instrumentation.ts) (appelée depuis `main.ts`) et exporte via OTLP. + +Pour vérifier rapidement : + +1. Démarrer l'infra : `pnpm dev` (Jaeger inclus). +2. Démarrer `server-nestjs` : `pnpm --filter server-nestjs run start:dev`. +3. Exécuter une requête sur une route backend (depuis le client ou un `curl`). +4. Ouvrir http://localhost:16686 et chercher le service `cloud-pi-native-console`. + +## Configuration pour le développement conteneurisé en local + +Docker Compose utilisé : [`docker/docker-compose.dev.yml`](docker/docker-compose.dev.yml) (tout conteneurisé avec Docker Compose Watch) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/client/.env.docker` +- `apps/server/.env` +- `apps/server/.env.docker` +- `apps/server-nestjs/.env` +- `apps/server-nestjs/.env.docker` + +Cette configuration est déjà plus intéressante, car elle s'appuie sur les conteneurs définis dans [ce docker-compose](docker/docker-compose.dev.yml), qui lance notamment une base de données PostreSQL (ainsi qu'un `pgadmin`), et un serveur Keycloak préchargé avec un royaume qui contient un jeu de données. Le docker-compose contient des instructions `develop` qui permettent soit de synchroniser certains fichiers, soit de carrément reconstruire l'image et de relancer le service concerné. De cette manière vous pouvez développer en laissant les conteneurs tourner. C'est un peu moins performant qu'un travail totalement en local, mais ça a le mérite d'être plus proche du déploiement cible. + +Le `nginx-strangler` et `server-nestjs` sont inclus dans ce docker-compose et démarrent automatiquement. Le `client` pointe vers `nginx-strangler` — le routage API est donc toujours actif, même si aucune route n'est encore basculée vers NestJS (tout passe par `server` en fallback). + +**Commande de lancement :** + +```bash +# Lance l'ensemble des conteneurs (client, server, server-nestjs, nginx-strangler, +# keycloak, postgres, pgadmin, opencds mock) avec Docker Compose Watch +pnpm docker:dev +``` + +## Configuration pour le développement hybride avec un environnement d'intégration existant + +Le développement hybride branché sur l'environnement d'intégration suit cette organisation des composants: + +```mermaid +--- +title: Architecture mode hybride branché sur l'environment d'intégration +--- +graph LR + + subgraph DEV_INTEG["DEV INTEG"] + direction LR + NAV_I["Navigateur"] + CURL_I["curl directs\n(tests)"] + INTEG["INTEG\n(keycloak, gitlab, etc.)"] + + subgraph DOCKER_NET["🐳 Réseau Docker"] + direction LR + NGINX_I(("nginx (client)\n:8080\nexposé :8080")) + STRANGLER_I(("nginx-strangler\n:8080\nexposé :4000")) + SERVER_I(("server\n:8080\nexposé :4001")) + NESTJS_I(("server-nestjs\n:3001\nexposé :3001")) + + NGINX_I -- "/api" --> STRANGLER_I + STRANGLER_I -- "pas migré" --> SERVER_I + STRANGLER_I -- "migré" --> NESTJS_I + end + + NAV_I --> NGINX_I + SERVER_I -. "accès externe" .-> INTEG + NESTJS_I -. "accès externe" .-> INTEG + + CURL_I -- ":8080" --> NGINX_I + CURL_I -- ":4000" --> STRANGLER_I + CURL_I -- ":4001" --> SERVER_I + CURL_I -- ":3001" --> NESTJS_I + end + + style DEV_INTEG fill:#fff,stroke:#333,stroke-width:2px + style DOCKER_NET fill:#e6f3ff,stroke:#2196F3,stroke-width:2px + style NAV_I fill:#fff,stroke:#333 + style CURL_I fill:#fff,stroke:#333 + style INTEG fill:#fff,stroke:#333,stroke-dasharray: 5 5 +``` + +Docker Compose utilisé : [`docker/docker-compose.integ.yml`](docker/docker-compose.integ.yml) (sans Keycloak, branché sur l'environnement d'intégration distant) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/client/.env.docker` +- `apps/client/.env.integ` +- `apps/server/.env` +- `apps/server/.env.docker` +- `apps/server/.env.integ` +- `apps/server-nestjs/.env` +- `apps/server-nestjs/.env.docker` +- `apps/server-nestjs/.env.integ` *(si vous travaillez sur server-nestjs en mode integ)* + +Cette configuration est une itération de la précédente. Dans ce cas d'usage le Keycloak n'est pas créé en tant que conteneur, car on est supposé se brancher sur l'environnement d'intégration défini dans les fichiers `.env.integ`. Le contenu de ces fichiers (en particulier celui de `apps/server`) est donc clé. + +Le `nginx-strangler` et `server-nestjs` sont également inclus dans ce docker-compose. + +**Commandes de lancement :** + +```bash +# Option 1 : Tout conteneurisé, branché sur l'environnement d'intégration +pnpm docker:integ + +# Option 2 : Seulement l'infra en Docker (postgres, pgadmin), server et client en local avec mode integ +pnpm integ +# Puis dans d'autres terminaux : +pnpm --filter server run dev +pnpm --filter server-nestjs run start:dev +pnpm --filter client run dev +``` diff --git a/.jjconflict-base-0/LICENSE b/.jjconflict-base-0/LICENSE new file mode 100644 index 0000000000..4b139aa8bc --- /dev/null +++ b/.jjconflict-base-0/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Ministère de l’Intérieur et des Outre-Mer + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/.jjconflict-base-0/README.md b/.jjconflict-base-0/README.md new file mode 100644 index 0000000000..3a89503a2b --- /dev/null +++ b/.jjconflict-base-0/README.md @@ -0,0 +1,409 @@ +# Console Cloud π Native + +La console Cloud π Native est une application web ayant pour but de piloter des services dans un cluster Kubernetes afin de fournir une plateforme cloud qui accompagne les produits numériques lors de toutes les phases de leur cycle de vie. + +Cette console offre une interface unifiée vers un ensemble de services tout en garantissant une cohérence globale du système avec la création automatique d'un certain nombre de ressources comme les comptes d'accès, les robots ou encore des ressources Kubernetes. +En addition du provisionnement automatique, elle garantit aussi le contrôle d'accès aux ressources du projet à l'aide d'une gestion d'équipe, de permissions, de quotas, etc. + +<img src="./misc/images/projects.png" width="30%"></img> <img src="./misc/images/repositories.png" width="30%"></img> <img src="./misc/images/environments.png" width="30%"></img> <img src="./misc/images/services.png" width="30%"></img> <img src="./misc/images/members.png" width="30%"></img> <img src="./misc/images/status.png" width="30%"></img> + +## Démarrage Rapide (OnBoarding) + +Afin de démarrer rapidement l'application pour la découvrir il vous faudra d'abord cloner ce dépôt, puis satisfaire à un certain nombre de prérequis. + +### Cloner le projet + +```bash +git clone https://github.com/cloud-pi-native/console.git + +# Se rendre dans le dossier du projet +cd console +``` + +### Prérequis + +#### Prérequis techniques du projet + +- [Docker >= v27](https://docs.docker.com/get-docker/) Orchestrateur de conteneurs + - [Plugin compose >= v2.35](https://github.com/docker/compose) (attention à ne pas avoir une vieille version qui traînerait dans `~/.docker/cli-plugins/` !). Permet de composer plusieurs conteneurs Docker + - [Plugin buildx](https://github.com/docker/buildx) Permet d'étendre les capacités de Docker à l'aide de BuildKit +- [Node.js >= v24](https://nodejs.org/en/download/) Environnement d'exécution JavaScript +- [PnPM >= v10](https://pnpm.io/installation) Gestionnaire de paquets pour JavaScript + +#### Prérequis de configuration du projet + +Afin de pouvoir démarrer l'application, il vous faudra également récupérer (ou bien créer vous-même) les fichiers : + +- `apps/client/.env.docker` +- `apps/client/.env.integ` +- `apps/server/.env.docker` +- `apps/server/.env.integ` + +Si vous ne les avez pas déjà, référez-vous à [./ENVIRONMENTS.md](./ENVIRONMENTS.md) qui est la documentation consacrée au sujet. + +### Lancement rapide utilisant l'application en local et les services sur un environnement distant + +Maintenant que vous avez les prérequis projets (prérequis techniques et de configuration), vous pouvez construire et lancer l'application localement, en la couplant avec les services (par exemple Keycloak) d'un environnement d'intégration : + +```bash +# Installer toutes les dépendances +$ pnpm install + +# Initialiser la base de données PostgreSQL lancée localement +$ pnpm db:generate + +# Construire les applications client et server +$ pnpm build + +# Construire les images dockers client et server, puis lancer la stack docker compose d'intégration +$ export KUBECONFIG_HOST_PATH="$HOME/.kube/config" && pnpm run docker:integ +``` + +Cette série de commandes va installer les diverses dépendances, construire l'intégralité de l'application Console (client et serveur) dans des images docker locales, et finalement lancer l'application en la connectant dans votre infrastructure d'Intégration existante. + +Si vous voulez une vue plus approfondie du lancement de l'application, vous pouvez vous référer à la [section détaillée à ce sujet](#développement). + +## Architecture + +Ce projet est construit avec [NodeJS](https://nodejs.org/), [VueJS](https://vuejs.org/), [Postgres](https://www.postgresql.org/) et [Keycloak](https://www.keycloak.org/). +Le serveur et le client sont livrés sous forme d'images [Docker](https://www.docker.com/) et sont déployées à l'aide de [Helm](https://helm.sh/) dans [Kubernetes](https://kubernetes.io/). + +### API + +Le serveur est construit selon une architecture __core / plugins__ pour favoriser l'évolutivité et l'ajout de nouvelles fonctionnalités / la gestion de nouveaux services. Pour ce faire, les plugins s'enregistrent auprès de différents `hooks` (qui suivent le cycle de vie d'un projet au sein de l'application), ces derniers seront déclenchés par les contrôleurs de l'application. + +Plusieurs plugins sont nativement enregistrés auprès du serveur pour assurer le bon fonctionnement de la plateforme, à savoir : + +- [Argocd](https://argo-cd.readthedocs.io/en/stable/) +- [Gitlab](https://about.gitlab.com/) +- [Harbor](https://goharbor.io/) +- [Keycloak](https://www.keycloak.org/) +- [Kubernetes](https://kubernetes.io/) +- [Nexus](https://www.sonatype.com/products/sonatype-nexus-repository) +- [Sonarqube](https://www.sonarsource.com/products/sonarqube/) +- [Vault](https://www.vaultproject.io/) + +> Pour plus d'informations sur le développement d'un plugin, voir : +> - [documentation des plugins](./misc/plugins.md). +> - [documentation du module hooks](./packages/hooks/README.md). +> - [exemple de plugin](https://github.com/cloud-pi-native/console-plugin-helloworld). + +### Gestion des rôles + +La console gère le contrôle d'accès via des rôles (administration de la console et rôles au niveau projet) composés d'un nom, d'un ensemble de permissions (bitmask) et d'un type (`type`). + +Chaque rôle est associé à un groupe OIDC/Keycloak (`oidcGroup`) mais la source de vérité peut varier selon le type : + +- `managed` : la console est source de vérité pour les membres et les permissions, puis synchronise l'appartenance avec le groupe `oidcGroup`. +- `external` : le groupe OIDC est la source de vérité pour l'appartenance ; la console s'aligne dessus. +- `global` : rôle d'administration destiné à porter des permissions transverses telle que le role "Tout le monde". + +Le préfixe `system:` est un qualifiant (pas un type à part entière) utilisé pour marquer les rôles fournis/maintenus par la plateforme, par exemple `system:managed`, `system:external`, `system:global`. + +Dans l'interface, un rôle dont le type commence par `system:` est considéré comme non modifiable : ses champs (nom/type/groupe/permissions) sont en lecture seule et la suppression est désactivée. Selon l'écran, la gestion des membres peut rester disponible. + +## Développement + +Le développement s'effectue à l'aide de Docker *(le client et le serveur peuvent tourner en local ou dans Docker)*. + +### Construire l'application + +Une fois le projet cloné, lancez les commandes suivantes dans votre terminal pour installer le projet et construire l'ensemble de l'application `console` : + +```shell +# Installer les dépendances du projet +pnpm install + +# Créer les fichiers d'environnement exemples +./ci/scripts/init-env.sh + +# Générer le client Prisma côté serveur +pnpm --filter @cpn-console/server run db:generate + +# Construire tous les paquets applicatifs +pnpm build +``` + +#### Lancer l'application + +L'application peut se lancer de plusieurs manières, à savoir : + +__Déploiement local du client et du serveur de la console, le reste dans des conteneurs :__ + +```shell +# Lancer keycloak, postgres et pgadmin dans des conteneurs +pnpm run dev + +# Lancer le serveur dans un shell (la commande va afficher les logs) +pnpm --filter @cpn-console/server run dev + +# Lancer le client dans un shell (la commande va afficher les logs) +pnpm --filter @cpn-console/client run dev + +# …une fois que vous terminé d'utiliser l'application: + +# Supprimer les conteneurs keycloak, postgres et pgadmin +pnpm run dev:clean + +# Supprimer les conteneurs keycloak, postgres et pgadmin (supprime les volumes docker) +pnpm run dev:delete +``` + +__Tout dans des conteneurs :__ + +```shell +# Lancer l'application +pnpm run docker:dev + +# Supprimer les conteneurs +pnpm run docker:dev:clean + +# Supprimer les conteneurs (supprime également les volumes !) +pnpm run docker:dev:delete +``` + +> Pour lancer le debugger Node.js sur le serveur, dans les fichiers `docker-compose` remplacer la directive `command: ["dev"]` par `command: ["debug"]`. + +#### Intégration + +L'application peut se lancer de plusieurs manières, à savoir : + +__Déploiement local du client et du serveur de la console, le reste dans des conteneurs :__ + +```shell +# Lancer postgres et pgadmin dans des conteneurs +# (le keycloak utilisé devra être configuré dans les .env.integ) +pnpm run integ + +# Lancer le serveur +pnpm --filter @cpn-console/server run integ + +# Lancer le client +pnpm --filter @cpn-console/client run integ + +# Supprimer les conteneurs postgres et pgadmin +pnpm run integ:clean + +# Supprimer les conteneurs postgres et pgadmin (supprime les volumes docker) +pnpm run integ:delete +``` + +__Tout dans des conteneurs :__ + +```shell +# Lancer l'application +pnpm run docker:integ + +# Supprimer les conteneurs +pnpm run docker:integ:clean + +# Supprimer les conteneurs (supprime les volumes docker) +pnpm run docker:integ:delete +``` + +#### Utilitaires + +Les commandes utilitaires de l'application : + +```shell +# Formattage du code +pnpm run format + +# Lancer la vérification syntaxique +pnpm run lint + +# Lancer les tests unitaires +pnpm run test + +# Lancer les tests de composants +pnpm run test:ct +``` + +L'intégralité des commandes est disponibles dans le fichier [package.json](./package.json) à la racine du projet, vous pouvez lancer ces dernières à l'aide de la commande `pnpm run <le_nom_du_script>`. + +### Tests Playwright + +Se référer à la [documentation concernée](./playwright/README.md). + +### Accès aux services + +Les services sont disponibles via les ports suivants : + +| Service | Url (local/docker) | +| ---------------------------------------------- | ----------------------- | +| Interface graphique *- (client)* | <http://localhost:8080> | +| Serveur *- (api)* | <http://localhost:4000> | +| Interface d'administration de base de données | <http://localhost:8081> | +| Interface d'administration du serveur keycloak | <http://localhost:8090> | + +*__Notes:__ ⚠ Il est possible que le navigateur utilisé (particulièrement Brave ou Firefox) bloque les cookies utilisés entre le frontend et keycloak, il est nécessaire de désactiver les protections de ce type dans votre navigateur (ex: Brave Shield).* + +### Informations de connexion + +Les comptes utilisés pendant le développement sont les suivants : + +| Service | Nom d'utilisateur | Mot de passe | +| ------------------ | ----------------- | ------------ | +| Keycloak *(admin)* | `admin` | `admin` | +| Keycloak *(user)* | `test` | `test` | +| PgAdmin | `admin@dso.fr` | `admin` | +| Postgres | `admin@dso.fr` | `admin` | + +> La liste complète des comptes Keycloak pré-créés est disponible [ici](./keycloak/realms/realm-dev.json). + +> Le nom de la base de données est : `dso-console-db`. + +### Variables d'environnements + +__Local / Docker:__ + +Les variables d'environnements sont gérées localement via des fichiers `.env` (local) / `.env.docker` (docker) dans les dossiers `./apps/server` et `./apps/client`, aux précédents fichiers s'ajoute un fichier `.env.integ` utilisé pour le mode intégration (local et docker). + +### Base de données + +Pour faciliter les opérations de migrations de base de données via [Prisma](https://www.prisma.io/), un script est disponible : + +```shell +# Lancer le script +pnpm --filter @cpn-console/server run db:wrapper + +# Voir l'aide du script +pnpm --filter @cpn-console/server run db:wrapper -h +``` + +### Débogage avec VS Code + +Le projet est configuré pour être débogué directement dans VS Code via le fichier `.vscode/launch.json`. +Rendez-vous dans l'onglet "Run and Debug" (ou `Ctrl+Shift+D`) pour voir les configurations disponibles. + +#### Configurations disponibles + +- **Launch Server** : Lance le serveur en local (hors Docker) et attache le débogueur. + - *Note : Assurez-vous que les bases de données (Postgres) et Keycloak sont accessibles (ex: lancés via `pnpm run dev` dans un autre terminal pour les dépendances).* +- **Attach Server** : S'attache au processus Node.js du serveur. + - Utile lorsque le serveur tourne dans un conteneur Docker. + - Pour que cela fonctionne, le serveur doit être lancé avec le mode debug activé. +- **Serve Client (Chrome/Firefox)** : Lance l'application client en local et ouvre une instance de navigateur dédiée au débogage. +- **Launch (Chrome/Firefox)** : Lance simultanément le serveur et le client pour une session de débogage complète (Compound task). + +#### Déboguer avec d'autres outils (Chrome DevTools, WebStorm, etc.) + +Le serveur Node.js expose le port de débogage `9229` lorsqu'il est lancé en mode debug (ou avec l'option `--inspect`). Vous pouvez utiliser n'importe quel client de débogage compatible avec le protocole Inspector de Node.js. + +**Exemple avec Chrome DevTools :** + +1. Lancez le serveur en mode debug (voir section précédente pour Docker, ou `pnpm run debug` en local). +2. Ouvrez Chrome et naviguez vers `chrome://inspect`. +3. Cliquez sur "Open dedicated DevTools for Node" ou attendez que votre target apparaisse sous "Remote Target". +4. Vous pouvez maintenant mettre des points d'arrêt, inspecter les variables, etc. + +### Construction des images + +Ce dépôt utilise des fichiers docker-compose pour construire les images docker: + +- [docker-compose.local.yml](./docker/docker-compose.local.yml) pour le développement local (`client`/`server`/`server-nestjs` lancés localement, et une "glue" docker pour les joindre) +- [docker-compose.dev.yml](./docker/docker-compose.dev.yml) pour la construction des images docker du mode développement. +- [docker-compose.ci.yml](./docker/docker-compose.ci.yml) pour la construction des images docker utilisées dans nos pipelines d'Intégration Continue. + +## Configuration du Keycloak + +Pour pouvoir gérer les droits utilisateurs des services le pod `server` doit accéder aux groupes des users. Cela signifie modifier le clientScope `profile`: + +- Onglet `Mappers` +- `Add Mappers > By configuration > Group Membership` + - Name: `groups` + - Token Claim Name: `groups` + - Full group path: `off` + - Add to ID token: `on` + - Add to access token: `on` + - Add to userinfo: `off` + +> En environnement de dev l'import par défaut prévoit déjà cette modification. + +Les utilisateurs faisant parti du group `admin` ont également accès à l'interface administrateur de la console une fois connectés via un onglet supplémentaire `Administration` dans le menu latéral de l'application. + +## Tableau des ressources, terminologie + +| Console Cloud Pi | Projet | Environnement | Dépots | Utilisateur / membre | +| ---------------- | ---------------------------- | ------------- | --------------------------------------- | -------------------- | +| __Openshift__ | | Namespace | | | +| __ArgoCD__ | | | (infra) Secret, AppProject, Application | | +| __Gitlab__ | Group | | Repository (Dépôt) | User | +| __Harbor__ | Project | | Repository [1] | | +| __Keycloak__ | | Group | | User / member | +| __Sonar__ | User | | | | +| __Nexus__ | Repositories, role, user ... | | | | + +[1] N'est pas crée par la console mais par le produit de la CI + +## Architecture du dépôt + +La gestion des dépendances est effectuée à l'aide de [pnpm](https://pnpm.io/) selon la structure de dossiers suivante : + +- Les différentes briques applicatives se trouvent dans le dossier `apps/`. +- Les bibliothèques additionnelles se trouvent dans le dossier `packages/`. +- Les plugins core se trouvent dans le dossier `plugins/`. + +*Schema de l'architecture du monorepo :* + +```shell +./ +├── apps +│ ├── client +│ └── server +├── packages +│ ├── eslintconfig +│ ├── hooks +│ ├── shared +│ ├── test-utils +│ └── tsconfig +├── plugins +│ ├── argocd +│ ├── gitlab +│ ├── harbor +│ ├── keycloak +│ ├── kubernetes +│ ├── nexus +│ ├── sonarqube +│ └── vault +├── package.json +├── pnpm-lock.yaml +├── pnpm-workspace.yaml +└── README.md +``` + +## Organisation avec Git + +Une requête de fusion ("merge request") doit être faite avec la branche `main` comme destination. + +La branche de base **doit** être à jour avec `origin/main` (utiliser `git pull origin/main && git rebase origin/main` ou l'option de rebasage dans l'interface Web si besoin) avant la demande de fusion (MR). + +## Conventions de nommage + +Cf. [Conventions - MIOM Fabrique Numérique](https://docs.fabrique-numerique.fr/conventions/nommage.html). + +Les commits doivent suivre la spécification des [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). Cette norme est utilisée pour construire la nouvelle version, voir ci-après. + +Il est possible d'ajouter l'[extension VSCode](https://github.com/vivaxy/vscode-conventional-commits) pour faciliter la création des commits. + +> Les messages de commits sont rédigés en anglais. + +## Gestion des versions + +Se référer à [./RELEASE.md](./RELEASE.md). + +## Gestion des dépendances + +### Mise à jour des dépendances + +À propos du cycle de vie des dépendances, et notamment leur mise à jour, un document spécifique est disponible [ici](https://github.com/cloud-pi-native/documentation-interne-socle/blob/main/Divers/Mode-op%C3%A9ratoire-mont%C3%A9es-version-librairies.md). Ce document détaille la stratégie choisie en interne de mise à jour des dépendances de la Console CPiN. + +Concernant la mise à jour de `browserlist` et de `caniuse-lite`, utiliser la commande suivante sachant qu'on utilise `pnpm` : + +```shell +$ pnpm update caniuse-lite -r +``` + +### Dépendances Helm + +La console embarque une dépendance avec le Helm Chart utilisé par ArgoCD pour consommer le fichier values qu'elle construit. Afin d'assurer la compatibilité, il faut toujours que la version du Helm chart associé ait été publiée au préalable (voir [la documentation dédiée](https://github.com/cloud-pi-native/helm-charts?tab=readme-ov-file#contribution)). diff --git a/.jjconflict-base-0/RELEASE.md b/.jjconflict-base-0/RELEASE.md new file mode 100644 index 0000000000..300f31fd01 --- /dev/null +++ b/.jjconflict-base-0/RELEASE.md @@ -0,0 +1,110 @@ +# Gestion des versions + +Ce document décrit comment est géré le versionnement de `console`, c'est-à-dire : + +- Préparer la création d'une nouvelle version de `console` au fur et à mesure des ajouts +- Créer effectivement la nouvelle version +- Mettre à jour le chart Helm concerné +- Publier les nouvelles versions de modules NPM concernés + +## Incrémentation des versions + +Afin d'éviter une confusion entre les hotfixes, qui sont des versions `PATCH` qui ensuite rétroportées sur la branche principale (`main`), et les versions régulières qui n'ont que des commits `fix:` (et donc produisent par défaut, elles aussi, des versions `PATCH`), nous avons décidé d'adopter le protocole de versionnement suivant : + +- Les versions régulières sur `main` sont par défaut des `MINOR` (et très rarement des `MAJOR`) +- Les versions de hotfix d'une version en particulier sont **forcément** des `PATCH` (ceci est forcé dans le [flux de travail GitHub `Create new release`](https://github.com/cloud-pi-native/console/tree/main/.github/workflows/job-release-please.yml#L39)) + +La structure de versionnement de `console` est donc **`MAJOR`.`MINOR`.`HOTFIX`** + +## Schéma récapitulatif + +Les sections suivantes vont expliciter ce schéma: + +```mermaid +gitGraph + commit id: "…previous commits" + commit id: "Add basic features" + + branch release-please-main + checkout release-please-main + commit id:"bump to v1.2.0" tag: "v1.2.0" + + checkout main + merge release-please-main + checkout release-please-main + branch "hotfix/urgent-fix-for-v1.2.0" + commit id: "Fix stuff" + branch release-please-hotfix_v1.2.1 + commit id:"bump to v1.2.1" tag: "v1.2.1" + checkout "hotfix/urgent-fix-for-v1.2.0" + merge release-please-hotfix_v1.2.1 + + checkout main + commit id: "Add features" + checkout release-please-main + commit id:"bump to v1.3.0" tag: "v1.3.0" + merge main + commit id:"bump to v1.3.0" tag: "v1.3.0" + checkout main + commit id: "More features" + checkout release-please-main + merge main + commit id:"bump to v1.3.0 (recreated)" tag: "v1.3.0 (recreated)" + checkout main + merge release-please-main + + checkout main + merge "hotfix/urgent-fix-for-v1.2.0" + commit id:"backport v1.2.1 fix onto main" + checkout release-please-main + commit id:"bump to v1.4.0" tag: "v1.4.0" + merge main + commit id:"bump to v1.4.0" tag: "v1.4.0" + checkout main + merge release-please-main +``` + +## Versionnement de Console + +Le flux de travail qui créé les nouvelles versions s'intitule [`create-or-update-release`](https://github.com/cloud-pi-native/console/blob/main/.github/workflows/workflow-create-or-update-release.yml) et est déclenché à chaque nouveau commit sur `main` (soit lorsqu'on fusionne une requête de fusion, soit un commit poussé en outrepassant l'interdiction de pousser sur `main`) ou sur une branche `hotfix/*`. + +Le flux de travail utilise [release-please-action](https://github.com/googleapis/release-please-action) pour automatiquement générer les tags Git ainsi que les nouvelles versions sur GitHub. À chaque fois que du code est poussé dans la branche `main` ou un branche `hotfix/*`, une requête de fusion de version est créée en analysant les messages de commits pour déterminer le numéro de version à créer (`PATCH`, `MINOR`, ou `MAJOR`). Si une requête de fusion de nouvelle version existe déjà, elle est mise à jour afin de refléter les nouveaux changements ajoutés à la future nouvelle version. + +Les différent types de commits (`chore:`, `feat:`, `fix:` etc.) vont alimenter différentes sections de la `CHANGELOG`. Ces sections sont décrites dans la configuration de release-please, [`./release-please-config.json`](./release-please-config.json) + +Lorsqu'une requête de fusion de version (sur `main` ou `hotfix/*`) est fusionnée, les images de conteneur des applications (`client`, `server`, etc.) sont alors créées et hébergées dans la [registry Github associée au dépôt](https://github.com/orgs/cloud-pi-native/packages?repo_name=console) avec les tags appropriés (qui reflètent les tags git concernés). + +> Seuls les tags "complets" (`vX.Y.Z`) sont immutables, les tags "partiels" (`vX` et `vX.Y`) sont recréés pour relier la dernière version concernée. C'est pour ça que lorsque vous tirez les changements de `main` il est recommandé de faire un `git pull --tags --force` afin de forcer la recréation de vos tags locaux pour ces tags partiels. + +## Versionnement du chart Helm `dso-console` + +Le déploiement de `console` se fait préférablement à l'aide de son chart Helm, nommé [`dso-console`](https://github.com/cloud-pi-native/helm-charts/tree/main/charts/dso-console). + +La dernière étape du flux de travail de création de nouvelles versions (cf. section ci-dessus) est la création automatique d'une requête de fusion dans le dépôt `helm-charts` pour la mise à jour du chart Helm `dso-console`. Une fusion manuelle sur ce dépôt est alors nécessaire pour déclencher la publication de la nouvelle version du chart Helm (embarquant donc la nouvelle version de `console`). Exemple d'une telle requête de fusion de nouvelle version du chart Helm : https://github.com/cloud-pi-native/helm-charts/pull/204. + +> Les versions "régulières" (`MAJOR` ou `MINOR` depuis `main`) et les versions "hotfixes" (`PATCH` depuis `hotfix/*`) produisent le même type de requête de fusion côté `helm-charts`, car du point de vue de ce dépôt toute mise à jour de l'application est un `PATCH` bump côté chart. + +## Versionnement des modules NPM + +La publication des nouvelles versions de modules npm du dépôt est automatique et est inclus dans le flux de travail de création d'une nouvelle version. Il analyse les numéros de version présents dans les différents fichiers `package.json` pour déterminer si une nouvelle version du module doit être créée et publiée. + +> Il est possible de créer une version de pré-release d'un module npm en modifiant la clé `publishConfig.tag` dans le `package.json` avec par exemple `beta` pour générer une version beta. + +## Hotfixes + +Autant que faire se peut il vaut mieux privilégier le "Fix Forward" avec de nouvelles versions, afin d'éviter la charger de générer/rétroporter un hotfix. + +Ceci étant dit, il arrivera, hélas, qu'un hotfix soit nécessaire sur une version livrée. + +Voici donc le processus compatible avec l'utilisation de `release-please`: + +- Se placer localement sur le tag de la version concernée: `$ git checkout v1.2.0` (`v1.2.0` est ici la version à hotfixer) +- En tirer une branche dédiée au hotfix: `$ git checkout -b hotfix/my-urgent-hotfix-for-v1.2.0` (Note: Il n'est pas nécessaire de spécifier la version dans le nom de la branche, mais ça peut aider à la lecture et ainsi confirmer la version concernée) +- Faire les modifications nécessaires, committer, etc. +- Pousser la nouvelle branche sur le dépôt Github +- ⚠ Si vous voulez faire une "preview" de cette branche il faudra très probablement créer une **autre branche** qui cible `main` et y résoudre les conflits avant de faire une `preview`. En effet il est fortement probable que si vous faites un hotfix, la branche `main` aura déjà une nouvelle version (sinon vous ne feriez pas un hotfix, vous feriez simplement une nouvelle version 😁). Or il faut savoir que [Github ne permet pas l'exécution des workflows en cas de conflits](https://github.com/orgs/community/discussions/26304). Donc si vous voulez une `preview` du hotfix il faudra une MR dédiée, et dont vous aurez résolu les hotfixes (ça devrait être normalement limité aux fichiers contenant la version comme `CHANGELOG.md`). C'est pénible, mais c'est comme ça. +- Une fois la nouvelle branche poussée, `release-please` va être déclenché par le flux de travail Github `create-or-update-release` afin de créer une requête de fusion pour la nouvelle version hotfixée (avec comme cible la branche de hotfix). Il est d'ailleurs à noter que dans le cas d'un hotfix **on ne fait qu'une montée du "PATCH"** (ici on obtiendra donc la version `v1.2.1`, qui est alors le premier hotfix de la version `v1.2.0`) quelque soit les commits (donc même un `feat!` ne fera pas de montée majeure) +- Valider la MR de version hotfixée (créée donc par `release-please`) à l'aide du flux de travail Github `Continuous Integration` +- Une fois la MR de version hotfixée validée et fusionnée, la nouvelle version est créée et, comme pour les versions traditionnelles, une requête de fusion est crée dans le dépôt `helm-charts` pour avoir là aussi une version hotfixée (mais, pour le chart Helm, c'est considéré comme une version classique) +- Il faudra ensuite faire des picorages (`git cherry-pick`) ou une MR de la branche de hotfix vers `main` afin d'intégrer le ou les commits de hotfix dans la prochaine version officielle +- ⚠ Il faut retagger le commit de hotfix quand on a backporté le fix + le commit de bump de la release hotfix sur `main`, car sinon la Release côté GitHub pointe vers un tag dont le commit n'est pas sur `main` (le SHA a logiquement changé lorsqu'il a été backporté). Refaire donc localement un `git tag vX.Y.Z commit-de-release-backporté-sur-main` et pusher avec `git push --tags --force` pour forcer la mise à jour du tag. SI VOUS NE FAITES PAS ÇA LES CHANGELOG VONT TOTALEMENT PARTIR EN VRILLE ⚠ diff --git a/.jjconflict-base-0/apps/client/.env-example b/.jjconflict-base-0/apps/client/.env-example new file mode 100644 index 0000000000..2f23f06f97 --- /dev/null +++ b/.jjconflict-base-0/apps/client/.env-example @@ -0,0 +1,24 @@ +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Hôte du serveur backend vers lequel le client proxifie les appels API +SERVER_HOST=localhost +# Port d'écoute du serveur backend +# En développement local avec le nginx-strangler (pnpm dev +# + docker-compose.local.yml), il vaut mieux mettre SERVER_PORT=4000 pour que +# le proxy Vite passe par nginx-strangler plutôt que directement par server (4001) +# ou server-nestjs (3001). +SERVER_PORT=4000 +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté frontend +KEYCLOAK_CLIENT_ID=dso-console-frontend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# Active/désactive le module OpenCDS (true | false) +OPENCDS_ENABLED=false +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-base-0/apps/client/.env.docker-example b/.jjconflict-base-0/apps/client/.env.docker-example new file mode 100644 index 0000000000..1f05a462c6 --- /dev/null +++ b/.jjconflict-base-0/apps/client/.env.docker-example @@ -0,0 +1,21 @@ +# Spécifique à ce ficher. Permet de dire que la stack est conteneurisée +DOCKER=true +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Hôte du nginx vers lequel le client proxifie les appels API +SERVER_HOST=nginx-strangler +SERVER_PORT=8080 +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté frontend +KEYCLOAK_CLIENT_ID=dso-console-frontend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# Active/désactive le module OpenCDS (true | false) +OPENCDS_ENABLED=false +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-base-0/apps/client/.env.integ-example b/.jjconflict-base-0/apps/client/.env.integ-example new file mode 100644 index 0000000000..4f870dfd2f --- /dev/null +++ b/.jjconflict-base-0/apps/client/.env.integ-example @@ -0,0 +1,11 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Domaine du Keycloak de l'environnement d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak de l'environnement d'intégration +KEYCLOAK_REALM= +# En intégration, Keycloak est généralement exposé en HTTPS +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak frontend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= diff --git a/.jjconflict-base-0/apps/client/.eslintrc-auto-import.json b/.jjconflict-base-0/apps/client/.eslintrc-auto-import.json new file mode 100644 index 0000000000..253b4b4683 --- /dev/null +++ b/.jjconflict-base-0/apps/client/.eslintrc-auto-import.json @@ -0,0 +1,93 @@ +{ + "globals": { + "Component": true, + "ComponentPublicInstance": true, + "ComputedRef": true, + "DirectiveBinding": true, + "EffectScope": true, + "ExtractDefaultPropTypes": true, + "ExtractPropTypes": true, + "ExtractPublicPropTypes": true, + "InjectionKey": true, + "MaybeRef": true, + "MaybeRefOrGetter": true, + "PropType": true, + "Ref": true, + "VNode": true, + "WritableComputedRef": true, + "acceptHMRUpdate": true, + "computed": true, + "createApp": true, + "createPinia": true, + "customRef": true, + "defineAsyncComponent": true, + "defineComponent": true, + "defineStore": true, + "effectScope": true, + "getActivePinia": true, + "getCurrentInstance": true, + "getCurrentScope": true, + "h": true, + "inject": true, + "isProxy": true, + "isReactive": true, + "isReadonly": true, + "isRef": true, + "mapActions": true, + "mapGetters": true, + "mapState": true, + "mapStores": true, + "mapWritableState": true, + "markRaw": true, + "nextTick": true, + "onActivated": true, + "onBeforeMount": true, + "onBeforeRouteLeave": true, + "onBeforeRouteUpdate": true, + "onBeforeUnmount": true, + "onBeforeUpdate": true, + "onDeactivated": true, + "onErrorCaptured": true, + "onMounted": true, + "onRenderTracked": true, + "onRenderTriggered": true, + "onScopeDispose": true, + "onServerPrefetch": true, + "onUnmounted": true, + "onUpdated": true, + "onWatcherCleanup": true, + "provide": true, + "reactive": true, + "readonly": true, + "ref": true, + "resolveComponent": true, + "setActivePinia": true, + "setMapStoreSuffix": true, + "shallowReactive": true, + "shallowReadonly": true, + "shallowRef": true, + "storeToRefs": true, + "toRaw": true, + "toRef": true, + "toRefs": true, + "toValue": true, + "triggerRef": true, + "unref": true, + "useAttrs": true, + "useCssModule": true, + "useCssVars": true, + "useId": true, + "useLink": true, + "useModel": true, + "useRoute": true, + "useRouter": true, + "useScheme": true, + "useSlots": true, + "useTabs": true, + "useTemplateRef": true, + "watch": true, + "watchEffect": true, + "watchPostEffect": true, + "watchSyncEffect": true + } +} diff --git a/.jjconflict-base-0/apps/client/.stylelintrc.js b/.jjconflict-base-0/apps/client/.stylelintrc.js new file mode 100644 index 0000000000..86614a8803 --- /dev/null +++ b/.jjconflict-base-0/apps/client/.stylelintrc.js @@ -0,0 +1,40 @@ +export default { + root: true, + extends: [ + 'stylelint-config-standard', + 'stylelint-config-html/vue', + 'stylelint-config-html', + 'stylelint-config-recommended-vue', + ], + ignoreDisables: false, + rules: { + 'at-rule-no-deprecated': [ + true, + { + ignoreAtRules: [ + 'windi', + 'apply', + 'include', + 'variants', + 'responsive', + 'screen', + ], + }, + ], + 'at-rule-no-unknown': [ + true, + { + ignoreAtRules: [ + 'windi', + 'apply', + 'include', + 'variants', + 'responsive', + 'screen', + ], + }, + ], + 'no-descending-specificity': null, + 'selector-class-pattern': '^((sm|md|lg|xl|2xl):)?[a-z][-_/a-z0-9]*$', + }, +} diff --git a/.jjconflict-base-0/apps/client/Dockerfile b/.jjconflict-base-0/apps/client/Dockerfile new file mode 100644 index 0000000000..b6784f3a82 --- /dev/null +++ b/.jjconflict-base-0/apps/client/Dockerfile @@ -0,0 +1,48 @@ +# Dev stage ------------------------------------------------------------------------ +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Install pnpm version defined in package.json "packageManager" property +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY --chown=node:root apps/client/package.json ./apps/client/package.json +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +RUN pnpm install --ignore-scripts # --no-optional + +COPY --chown=node:root packages/ ./packages/ +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +COPY --chown=node:root apps/client/ ./apps/client/ +RUN pnpm --filter client run icons + +ENTRYPOINT [ "pnpm", "--filter", "client", "run", "dev" ] + + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +ARG APP_VERSION +RUN pnpm --filter @cpn-console/client run build + + +# Prod stage ----------------------------------------------------------------------- +FROM nginxinc/nginx-unprivileged:1.31-alpine AS prod + +COPY --chown=nginx:root --from=build /app/apps/client/dist /etc/nginx/html/ +COPY --chown=nginx:root --chmod=660 ./apps/client/nginx/default.conf /etc/nginx/conf.d/default.conf +COPY --chown=nginx:root --chmod=750 ./apps/client/nginx/entrypoint.sh /docker-entrypoint.d/99-envsubst-js-assets.sh + +RUN chmod -R g=u /etc/nginx/html + +EXPOSE 8080 diff --git a/.jjconflict-base-0/apps/client/README.md b/.jjconflict-base-0/apps/client/README.md new file mode 100644 index 0000000000..00810a3778 --- /dev/null +++ b/.jjconflict-base-0/apps/client/README.md @@ -0,0 +1,56 @@ +# Console Cloud π Native - Client + +## Installation + +```sh +npm install +``` + +## Lancement de l'app + +```sh +npm run dev +``` + +## Lancement des tests unitaires + +```sh +npm run test +``` + +## Formattage du code + +```sh +# Lister les problèmes de formatage +npm run lint + +# Régler automatiquement les problèmes de formatage +npm run format +``` + +## Build de l'app + +```sh +npm run build +``` + +## Prévisualisation du code de production + +```sh +npm run preview +``` + +## Récupérer toutes les icones du projets +`grep -r -oh "ri:[a-z0-9-]*" . | sort | uniq | cut -d ':' -f 2 | awk NF | awk '{print " \047" $1 "\047,"}'` + +## Activation OpenCDS + +Se référer à [la documentation dédiée](../../packages/opencds/README.adoc) + +## Crédits + +- [vue](https://github.com/vuejs/) +- [vue-dsfr](https://github.com/dnum-mi/vue-dsfr) +- [vite](https://github.com/vitejs/vite) +- [stylelint](https://github.com/stylelint/stylelint) +- [eslint](https://github.com/eslint/eslint) diff --git a/.jjconflict-base-0/apps/client/eslint.config.js b/.jjconflict-base-0/apps/client/eslint.config.js new file mode 100644 index 0000000000..88c06179d3 --- /dev/null +++ b/.jjconflict-base-0/apps/client/eslint.config.js @@ -0,0 +1,6 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase.overrideRules({ + 'e18e/prefer-static-regex': 'off', + 'vue/no-required-prop-with-default': 'off', +}) diff --git a/.jjconflict-base-0/apps/client/index.html b/.jjconflict-base-0/apps/client/index.html new file mode 100644 index 0000000000..b57fbfc4ab --- /dev/null +++ b/.jjconflict-base-0/apps/client/index.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html lang="fr"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" href="/favicon.ico" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Console Cloud π Native + + +
+ + + diff --git a/.jjconflict-base-0/apps/client/jsconfig.json b/.jjconflict-base-0/apps/client/jsconfig.json new file mode 100644 index 0000000000..a5cee14f16 --- /dev/null +++ b/.jjconflict-base-0/apps/client/jsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "jsx": "preserve", + "baseUrl": ".", + "module": "ESNext", + "moduleResolution": "node", + "paths": { + "@/*": [ + "./src/*" + ] + }, + "sourceMap": true + }, + "exclude": [ + "node_modules" + ] +} diff --git a/.jjconflict-base-0/apps/client/nginx/README.md b/.jjconflict-base-0/apps/client/nginx/README.md new file mode 100644 index 0000000000..b2e55f8ea1 --- /dev/null +++ b/.jjconflict-base-0/apps/client/nginx/README.md @@ -0,0 +1,5 @@ +# À propos + +Ce nginx est celui utilisé à fin d'exposer les fichiers de l'application "SPA" (Single Page Application) de la Console. + +**À ne pas confondre avec [`nginx-strangler`](../../nginx-strangler) qui est le composant permettant le transfert progressif des routes de `server` vers `server-nestjs`** diff --git a/.jjconflict-base-0/apps/client/nginx/default.conf b/.jjconflict-base-0/apps/client/nginx/default.conf new file mode 100644 index 0000000000..60cfae693b --- /dev/null +++ b/.jjconflict-base-0/apps/client/nginx/default.conf @@ -0,0 +1,16 @@ +server { + listen 8080; + server_name localhost; + root /etc/nginx/html; + index index.html; + + large_client_header_buffers 4 32k; + + location / { + try_files $uri $uri/ @rewrites; + } + + location @rewrites { + rewrite "^(.+)$" /index.html last; + } +} diff --git a/.jjconflict-base-0/apps/client/nginx/default.docker.conf b/.jjconflict-base-0/apps/client/nginx/default.docker.conf new file mode 100644 index 0000000000..74bca2057d --- /dev/null +++ b/.jjconflict-base-0/apps/client/nginx/default.docker.conf @@ -0,0 +1,35 @@ +upstream api { + # Toutes les requêtes API transitent par nginx-strangler qui orchestre + # la bascule progressive vers server-nestjs (Strangler Fig Pattern, ticket #1885) + server nginx-strangler:8080; +} + +server { + listen 8080; + server_name localhost; + root /etc/nginx/html; + index index.html; + + large_client_header_buffers 4 32k; + + location / { + try_files $uri $uri/ @rewrites; + } + + location @rewrites { + rewrite "^(.+)$" /index.html last; + } + + location ~ ^/(api|swagger-ui) { + rewrite "^(.*)$" $1 break; + add_header Access-Control-Allow-Origin '$http_origin'; + proxy_pass http://api; + proxy_redirect off; + proxy_buffering off; + proxy_http_version 1.1; + + # For WebSockets + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/.jjconflict-base-0/apps/client/nginx/entrypoint.sh b/.jjconflict-base-0/apps/client/nginx/entrypoint.sh new file mode 100755 index 0000000000..98dfe3bcd9 --- /dev/null +++ b/.jjconflict-base-0/apps/client/nginx/entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +ASSETS_DIR=/etc/nginx/html/assets + +make_envsubst_vars() { + fmt= + for name do + fmt="${fmt}${fmt:+ }\${$name}" + done + + printf '%s\n' "$fmt" +} + +ENV_VARS=$(make_envsubst_vars \ + SERVER_HOST \ + SERVER_PORT \ + OPENCDS_ENABLED \ + KEYCLOAK_PROTOCOL \ + KEYCLOAK_DOMAIN \ + KEYCLOAK_REALM \ + KEYCLOAK_CLIENT_ID \ + KEYCLOAK_REDIRECT_URI \ + CONTACT_EMAIL \ +) + +echo "Replacing env variables in JavaScript asset files..." +for file in $ASSETS_DIR/*.js; do + echo "Processing $file ..."; + envsubst "${ENV_VARS}" \ + < $file \ + > $file-out && \ + mv $file-out $file +done +echo "Done !" diff --git a/.jjconflict-base-0/apps/client/package.json b/.jjconflict-base-0/apps/client/package.json new file mode 100644 index 0000000000..a897dccf6f --- /dev/null +++ b/.jjconflict-base-0/apps/client/package.json @@ -0,0 +1,88 @@ +{ + "name": "@cpn-console/client", + "type": "module", + "version": "9.22.1", + "private": false, + "description": "cpn-console client", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "scripts": { + "build": "pnpm run icons && pnpm run type-check && pnpm run build-only", + "build-only": "vite --mode=production build", + "build:clean": "rimraf ./dist ./tsconfig.tsbuildinfo", + "dev": "vite --mode=development", + "format": "NODE_OPTIONS='--no-warnings=ExperimentalWarning' eslint ./ --fix", + "format:style": "stylelint ./src/**/*.{css,vue} --fix", + "icons": "vue-dsfr-icons -s scripts/icons.js -t src/icon-collections.ts", + "integ": "vite --mode=integration", + "lint": "pnpm run --parallel \"/^lint:.*/\"", + "lint:style": "stylelint ./src/**/*.{css,vue}", + "lint:ts": "NODE_OPTIONS='--no-warnings=ExperimentalWarning' eslint ./", + "preview": "vite preview --port 8080", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "type-check": "vue-tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@gouvfr/dsfr": "catalog:runtime", + "@gouvminint/vue-dsfr": "catalog:runtime", + "@iconify-json/ri": "catalog:runtime", + "@iconify/vue": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@vue/tsconfig": "catalog:build", + "axios": "catalog:runtime", + "javascript-time-ago": "catalog:runtime", + "jszip": "catalog:runtime", + "keycloak-js": "catalog:runtime", + "nanoid": "catalog:runtime", + "p-debounce": "catalog:runtime", + "pinia": "catalog:runtime", + "vue": "catalog:runtime", + "vue-router": "catalog:runtime", + "vue3-json-viewer": "catalog:runtime", + "xbytes": "catalog:runtime", + "yaml": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@iconify/types": "catalog:build", + "@types/jsdom": "catalog:types", + "@types/node": "catalog:types", + "@unocss/transformer-directives": "catalog:build", + "@vitejs/plugin-vue": "catalog:build", + "@vitest/coverage-v8": "catalog:test", + "@vue/eslint-config-typescript": "catalog:tools", + "chalk": "catalog:tools", + "eslint": "catalog:tools", + "eslint-plugin-vue": "catalog:tools", + "jsdom": "catalog:test", + "rimraf": "catalog:tools", + "stylelint": "catalog:tools", + "stylelint-config-html": "catalog:tools", + "stylelint-config-recommended-vue": "catalog:tools", + "stylelint-config-standard": "catalog:tools", + "typescript": "catalog:build", + "unocss": "catalog:build", + "unplugin-auto-import": "catalog:build", + "unplugin-vue-components": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vite-plugin-pwa": "catalog:build", + "vitest": "catalog:test", + "vue-eslint-parser": "catalog:tools", + "vue-tsc": "catalog:build", + "workbox-window": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/apps/client/public/browserconfig.xml b/.jjconflict-base-0/apps/client/public/browserconfig.xml new file mode 100644 index 0000000000..4826e1357a --- /dev/null +++ b/.jjconflict-base-0/apps/client/public/browserconfig.xml @@ -0,0 +1,2 @@ + +#ffffff diff --git a/.jjconflict-base-0/apps/client/public/favicon.ico b/.jjconflict-base-0/apps/client/public/favicon.ico new file mode 100644 index 0000000000..d5b9b0fcb9 Binary files /dev/null and b/.jjconflict-base-0/apps/client/public/favicon.ico differ diff --git a/.jjconflict-base-0/apps/client/public/img/architecture.png b/.jjconflict-base-0/apps/client/public/img/architecture.png new file mode 100644 index 0000000000..46a52e9c98 Binary files /dev/null and b/.jjconflict-base-0/apps/client/public/img/architecture.png differ diff --git a/.jjconflict-base-0/apps/client/public/img/argocd.svg b/.jjconflict-base-0/apps/client/public/img/argocd.svg new file mode 100644 index 0000000000..fb7bc6fe84 --- /dev/null +++ b/.jjconflict-base-0/apps/client/public/img/argocd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.jjconflict-base-0/apps/client/public/img/gitlab.svg b/.jjconflict-base-0/apps/client/public/img/gitlab.svg new file mode 100644 index 0000000000..602ffcc063 --- /dev/null +++ b/.jjconflict-base-0/apps/client/public/img/gitlab.svg @@ -0,0 +1 @@ +GitLab home page \ No newline at end of file diff --git a/.jjconflict-base-0/apps/client/public/img/harbor.svg b/.jjconflict-base-0/apps/client/public/img/harbor.svg new file mode 100644 index 0000000000..c5d042f6b6 --- /dev/null +++ b/.jjconflict-base-0/apps/client/public/img/harbor.svg @@ -0,0 +1,482 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.jjconflict-base-0/apps/client/public/img/logo-marianne.png b/.jjconflict-base-0/apps/client/public/img/logo-marianne.png new file mode 100644 index 0000000000..786e3d61f4 Binary files /dev/null and b/.jjconflict-base-0/apps/client/public/img/logo-marianne.png differ diff --git a/.jjconflict-base-0/apps/client/public/img/nexus.png b/.jjconflict-base-0/apps/client/public/img/nexus.png new file mode 100644 index 0000000000..809e5fbe1b Binary files /dev/null and b/.jjconflict-base-0/apps/client/public/img/nexus.png differ diff --git a/.jjconflict-base-0/apps/client/public/img/sonarqube.svg b/.jjconflict-base-0/apps/client/public/img/sonarqube.svg new file mode 100644 index 0000000000..c6bb30720f --- /dev/null +++ b/.jjconflict-base-0/apps/client/public/img/sonarqube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.jjconflict-base-0/apps/client/public/img/vault.svg b/.jjconflict-base-0/apps/client/public/img/vault.svg new file mode 100644 index 0000000000..d5be8c74d2 --- /dev/null +++ b/.jjconflict-base-0/apps/client/public/img/vault.svg @@ -0,0 +1 @@ +Vault \ No newline at end of file diff --git a/.jjconflict-base-0/apps/client/public/manifest.json b/.jjconflict-base-0/apps/client/public/manifest.json new file mode 100644 index 0000000000..a99d9409c7 --- /dev/null +++ b/.jjconflict-base-0/apps/client/public/manifest.json @@ -0,0 +1,41 @@ +{ + "name": "VueDsfr App", + "icons": [ + { + "src": "/icons/android-icon-36x36.png", + "sizes": "36x36", + "type": "image/png", + "density": "0.75" + }, + { + "src": "/icons/android-icon-48x48.png", + "sizes": "48x48", + "type": "image/png", + "density": "1.0" + }, + { + "src": "/icons/android-icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "density": "1.5" + }, + { + "src": "/icons/android-icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "density": "2.0" + }, + { + "src": "/icons/android-icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "density": "3.0" + }, + { + "src": "/icons/android-icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "density": "4.0" + } + ] +} diff --git a/.jjconflict-base-0/apps/client/scripts/create-collections.js b/.jjconflict-base-0/apps/client/scripts/create-collections.js new file mode 100644 index 0000000000..da1b4a6ef3 --- /dev/null +++ b/.jjconflict-base-0/apps/client/scripts/create-collections.js @@ -0,0 +1,18 @@ +#!env node +import path from 'node:path' + +import { createCustomCollectionFile } from '@gouvminint/vue-dsfr/meta' +import { Command } from 'commander' + +const program = new Command() + +program + .option('-s, --source ', 'Chemin vers le fichier de tuples [IconifyJSON, string[]]') + .option('-t, --target ', 'Chemin vers le fichier destination (src/icons.ts par défaut)') + .parse(process.argv) + +const options = program.opts() + +if (options.source && options.target) { + createCustomCollectionFile(path.resolve(process.cwd(), options.source), path.resolve(process.cwd(), options.target)) +} diff --git a/.jjconflict-base-0/apps/client/scripts/icons.js b/.jjconflict-base-0/apps/client/scripts/icons.js new file mode 100644 index 0000000000..c275232c2a --- /dev/null +++ b/.jjconflict-base-0/apps/client/scripts/icons.js @@ -0,0 +1,75 @@ +// @ts-check +import { icons as riCollection } from '@iconify-json/ri' + +/** + * @type {string[]} + */ +const riIconNames = [ + 'account-circle-line', + 'add-line', + 'admin-line', + 'arrow-drop-left-line', + 'arrow-drop-right-line', + 'arrow-go-back-line', + 'arrow-left-double-line', + 'arrow-right-double-line', + 'arrow-right-line', + 'arrow-right-s-line', + 'award-line', + 'building-line', + 'checkbox-blank-circle-fill', + 'checkbox-circle-line', + 'clipboard-line', + 'close-line', + 'code-s-slash-line', + 'dashboard-line', + 'delete-bin-7-line', + 'exchange-line', + 'eye-line', + 'eye-off-line', + 'file-download-line', + 'file-info-line', + 'filter-line', + 'filter-off-line', + 'flow-chart', + 'focus-3-line', + 'folder-line', + 'folder-shield-2-line', + 'folders-line', + 'folder-user-line', + 'github-line', + 'git-merge-line', + 'git-repository-private-line', + 'global-line', + 'key-2-line', + 'loader-4-line', + 'lock-line', + 'lock-unlock-line', + 'mail-line', + 'moon-clear-line', + 'newspaper-line', + 'pencil-line', + 'question-line', + 'refresh-line', + 'restart-line', + 'send-plane-line', + 'server-line', + 'settings-3-line', + 'shapes-line', + 'shield-check-line', + 'sound-module-line', + 'stock-line', + 'sun-line', + 'team-line', + 'tools-line', + 'upload-cloud-line', + 'user-add-line', + 'user-shared-2-line', +] + +/** + * @type {[import('@iconify/vue').IconifyJSON, string[]][]} + */ +export const collectionsToFilter = [ + [riCollection, riIconNames], +] diff --git a/.jjconflict-base-0/apps/client/src/App.vue b/.jjconflict-base-0/apps/client/src/App.vue new file mode 100644 index 0000000000..91e0e48742 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/App.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/api/xhr-client.ts b/.jjconflict-base-0/apps/client/src/api/xhr-client.ts new file mode 100644 index 0000000000..427c6b36ba --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/api/xhr-client.ts @@ -0,0 +1,57 @@ +import type { ApiFetcherArgs } from '@ts-rest/core' +import { logger } from '@cpn-console/logger/browser' +import { apiPrefix, getApiClient } from '@cpn-console/shared' +import { tsRestFetchApi } from '@ts-rest/core' +import { getKeycloak } from '@/utils/keycloak/keycloak.js' + +export const apiClient = await getApiClient( + '', + {}, + async (args: ApiFetcherArgs): Promise<{ status: number, body: { status: number, error: string } | unknown, headers: Headers }> => { + // Paths that do not require token + const validPaths = [`${apiPrefix}/version`, '/login', `${apiPrefix}/services`] + if (validPaths.some(validPath => args.path?.startsWith(validPath))) { + return tsRestFetchApi(args) + } + + // Envs that do not require token + if (process.env.NODE_ENV === 'test' && process.env.CT === 'true') { + return tsRestFetchApi(args) + } + + // Generate token + const keycloak = getKeycloak() + const token = keycloak.token + if (token) { + args.headers.Authorization = `Bearer ${token}` + } + + return tsRestFetchApi(args) + }, +) + +export function extractData(response: T, expectedStatus: S): Extract['body'] { + if (response.status === 401) { + const keycloak = getKeycloak() + if (!keycloak.authenticated) { + keycloak.login() + } + } + if (response.status >= 400) { + // `message` d'abord : les erreurs gérées du legacy (ErrorResType) et celles de NestJS + // (HttpException) portent le vrai message dans `message` — chez NestJS, `error` ne contient + // que la raison HTTP générique ("Forbidden", "Bad Request"). `error` reste en repli pour + // les erreurs non gérées du legacy (setErrorHandler), qui n'ont pas de champ `message`. + // @ts-ignore + throw new Error(response.body?.message ?? response.body?.error ?? 'Erreur inconnue') + } + if (response.status === expectedStatus) return response.body + try { + throw new Error(`Erreur lors de la requete, reçu ${response.status}, attendu ${expectedStatus}`) + } catch (error: unknown) { + if (error instanceof Error) { + logger.error({ err: error }, 'Request failed') + } + throw error + } +} diff --git a/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Bold.otf b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Bold.otf new file mode 100755 index 0000000000..4a051bcd7a Binary files /dev/null and b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Bold.otf differ diff --git a/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-BoldItalic.otf b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-BoldItalic.otf new file mode 100755 index 0000000000..d24f18d012 Binary files /dev/null and b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-BoldItalic.otf differ diff --git a/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-ExtraBold.otf b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-ExtraBold.otf new file mode 100755 index 0000000000..77b5d8ad1d Binary files /dev/null and b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-ExtraBold.otf differ diff --git a/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf new file mode 100755 index 0000000000..b5a48d1946 Binary files /dev/null and b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf differ diff --git a/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Light.otf b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Light.otf new file mode 100755 index 0000000000..5a7b8aad90 Binary files /dev/null and b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Light.otf differ diff --git a/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-LightItalic.otf b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-LightItalic.otf new file mode 100755 index 0000000000..15747f0a79 Binary files /dev/null and b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-LightItalic.otf differ diff --git a/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Medium.otf b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Medium.otf new file mode 100755 index 0000000000..cd15454d1b Binary files /dev/null and b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Medium.otf differ diff --git a/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-MediumItalic.otf b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-MediumItalic.otf new file mode 100755 index 0000000000..d7069ddb95 Binary files /dev/null and b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-MediumItalic.otf differ diff --git a/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Regular.otf b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Regular.otf new file mode 100755 index 0000000000..b8aa87d247 Binary files /dev/null and b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Regular.otf differ diff --git a/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-RegularItalic.otf b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-RegularItalic.otf new file mode 100755 index 0000000000..e0b98c8956 Binary files /dev/null and b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-RegularItalic.otf differ diff --git a/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Thin.otf b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Thin.otf new file mode 100755 index 0000000000..055d889cab Binary files /dev/null and b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-Thin.otf differ diff --git a/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-ThinItalic.otf b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-ThinItalic.otf new file mode 100755 index 0000000000..23fe8e8a97 Binary files /dev/null and b/.jjconflict-base-0/apps/client/src/assets/fonts/Marianne-ThinItalic.otf differ diff --git a/.jjconflict-base-0/apps/client/src/auto-imports.d.ts b/.jjconflict-base-0/apps/client/src/auto-imports.d.ts new file mode 100644 index 0000000000..4372345fd5 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/auto-imports.d.ts @@ -0,0 +1,173 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + const EffectScope: typeof import('vue')['EffectScope'] + const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] + const computed: typeof import('vue')['computed'] + const createApp: typeof import('vue')['createApp'] + const createPinia: typeof import('pinia')['createPinia'] + const customRef: typeof import('vue')['customRef'] + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] + const defineComponent: typeof import('vue')['defineComponent'] + const defineStore: typeof import('pinia')['defineStore'] + const effectScope: typeof import('vue')['effectScope'] + const getActivePinia: typeof import('pinia')['getActivePinia'] + const getCurrentInstance: typeof import('vue')['getCurrentInstance'] + const getCurrentScope: typeof import('vue')['getCurrentScope'] + const h: typeof import('vue')['h'] + const inject: typeof import('vue')['inject'] + const isProxy: typeof import('vue')['isProxy'] + const isReactive: typeof import('vue')['isReactive'] + const isReadonly: typeof import('vue')['isReadonly'] + const isRef: typeof import('vue')['isRef'] + const mapActions: typeof import('pinia')['mapActions'] + const mapGetters: typeof import('pinia')['mapGetters'] + const mapState: typeof import('pinia')['mapState'] + const mapStores: typeof import('pinia')['mapStores'] + const mapWritableState: typeof import('pinia')['mapWritableState'] + const markRaw: typeof import('vue')['markRaw'] + const nextTick: typeof import('vue')['nextTick'] + const onActivated: typeof import('vue')['onActivated'] + const onBeforeMount: typeof import('vue')['onBeforeMount'] + const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave'] + const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate'] + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] + const onDeactivated: typeof import('vue')['onDeactivated'] + const onErrorCaptured: typeof import('vue')['onErrorCaptured'] + const onMounted: typeof import('vue')['onMounted'] + const onRenderTracked: typeof import('vue')['onRenderTracked'] + const onRenderTriggered: typeof import('vue')['onRenderTriggered'] + const onScopeDispose: typeof import('vue')['onScopeDispose'] + const onServerPrefetch: typeof import('vue')['onServerPrefetch'] + const onUnmounted: typeof import('vue')['onUnmounted'] + const onUpdated: typeof import('vue')['onUpdated'] + const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] + const provide: typeof import('vue')['provide'] + const reactive: typeof import('vue')['reactive'] + const readonly: typeof import('vue')['readonly'] + const ref: typeof import('vue')['ref'] + const resolveComponent: typeof import('vue')['resolveComponent'] + const setActivePinia: typeof import('pinia')['setActivePinia'] + const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] + const shallowReactive: typeof import('vue')['shallowReactive'] + const shallowReadonly: typeof import('vue')['shallowReadonly'] + const shallowRef: typeof import('vue')['shallowRef'] + const storeToRefs: typeof import('pinia')['storeToRefs'] + const toRaw: typeof import('vue')['toRaw'] + const toRef: typeof import('vue')['toRef'] + const toRefs: typeof import('vue')['toRefs'] + const toValue: typeof import('vue')['toValue'] + const triggerRef: typeof import('vue')['triggerRef'] + const unref: typeof import('vue')['unref'] + const useAttrs: typeof import('vue')['useAttrs'] + const useCssModule: typeof import('vue')['useCssModule'] + const useCssVars: typeof import('vue')['useCssVars'] + const useId: typeof import('vue')['useId'] + const useLink: typeof import('vue-router')['useLink'] + const useModel: typeof import('vue')['useModel'] + const useRoute: typeof import('vue-router')['useRoute'] + const useRouter: typeof import('vue-router')['useRouter'] + const useScheme: typeof import('@gouvminint/vue-dsfr')['useScheme'] + const useSlots: typeof import('vue')['useSlots'] + const useTabs: typeof import('@gouvminint/vue-dsfr')['useTabs'] + const useTemplateRef: typeof import('vue')['useTemplateRef'] + const watch: typeof import('vue')['watch'] + const watchEffect: typeof import('vue')['watchEffect'] + const watchPostEffect: typeof import('vue')['watchPostEffect'] + const watchSyncEffect: typeof import('vue')['watchSyncEffect'] +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') +} + +// for vue template auto import +import { UnwrapRef } from 'vue' +declare module 'vue' { + interface GlobalComponents {} + interface ComponentCustomProperties { + readonly EffectScope: UnwrapRef + readonly acceptHMRUpdate: UnwrapRef + readonly computed: UnwrapRef + readonly createApp: UnwrapRef + readonly createPinia: UnwrapRef + readonly customRef: UnwrapRef + readonly defineAsyncComponent: UnwrapRef + readonly defineComponent: UnwrapRef + readonly defineStore: UnwrapRef + readonly effectScope: UnwrapRef + readonly getActivePinia: UnwrapRef + readonly getCurrentInstance: UnwrapRef + readonly getCurrentScope: UnwrapRef + readonly h: UnwrapRef + readonly inject: UnwrapRef + readonly isProxy: UnwrapRef + readonly isReactive: UnwrapRef + readonly isReadonly: UnwrapRef + readonly isRef: UnwrapRef + readonly mapActions: UnwrapRef + readonly mapGetters: UnwrapRef + readonly mapState: UnwrapRef + readonly mapStores: UnwrapRef + readonly mapWritableState: UnwrapRef + readonly markRaw: UnwrapRef + readonly nextTick: UnwrapRef + readonly onActivated: UnwrapRef + readonly onBeforeMount: UnwrapRef + readonly onBeforeRouteLeave: UnwrapRef + readonly onBeforeRouteUpdate: UnwrapRef + readonly onBeforeUnmount: UnwrapRef + readonly onBeforeUpdate: UnwrapRef + readonly onDeactivated: UnwrapRef + readonly onErrorCaptured: UnwrapRef + readonly onMounted: UnwrapRef + readonly onRenderTracked: UnwrapRef + readonly onRenderTriggered: UnwrapRef + readonly onScopeDispose: UnwrapRef + readonly onServerPrefetch: UnwrapRef + readonly onUnmounted: UnwrapRef + readonly onUpdated: UnwrapRef + readonly onWatcherCleanup: UnwrapRef + readonly provide: UnwrapRef + readonly reactive: UnwrapRef + readonly readonly: UnwrapRef + readonly ref: UnwrapRef + readonly resolveComponent: UnwrapRef + readonly setActivePinia: UnwrapRef + readonly setMapStoreSuffix: UnwrapRef + readonly shallowReactive: UnwrapRef + readonly shallowReadonly: UnwrapRef + readonly shallowRef: UnwrapRef + readonly storeToRefs: UnwrapRef + readonly toRaw: UnwrapRef + readonly toRef: UnwrapRef + readonly toRefs: UnwrapRef + readonly toValue: UnwrapRef + readonly triggerRef: UnwrapRef + readonly unref: UnwrapRef + readonly useAttrs: UnwrapRef + readonly useCssModule: UnwrapRef + readonly useCssVars: UnwrapRef + readonly useId: UnwrapRef + readonly useLink: UnwrapRef + readonly useModel: UnwrapRef + readonly useRoute: UnwrapRef + readonly useRouter: UnwrapRef + readonly useScheme: UnwrapRef + readonly useSlots: UnwrapRef + readonly useTabs: UnwrapRef + readonly useTemplateRef: UnwrapRef + readonly watch: UnwrapRef + readonly watchEffect: UnwrapRef + readonly watchPostEffect: UnwrapRef + readonly watchSyncEffect: UnwrapRef + } +} \ No newline at end of file diff --git a/.jjconflict-base-0/apps/client/src/components/AdminRoleForm.vue b/.jjconflict-base-0/apps/client/src/components/AdminRoleForm.vue new file mode 100644 index 0000000000..bf129c8ad7 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/AdminRoleForm.vue @@ -0,0 +1,304 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/Badge.vue b/.jjconflict-base-0/apps/client/src/components/Badge.vue new file mode 100644 index 0000000000..1dd98c5cda --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/Badge.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ChoiceSelector.vue b/.jjconflict-base-0/apps/client/src/components/ChoiceSelector.vue new file mode 100644 index 0000000000..b615ab6750 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ChoiceSelector.vue @@ -0,0 +1,272 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ClusterForm.vue b/.jjconflict-base-0/apps/client/src/components/ClusterForm.vue new file mode 100644 index 0000000000..81f51ebcaf --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ClusterForm.vue @@ -0,0 +1,532 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ConfigParam.vue b/.jjconflict-base-0/apps/client/src/components/ConfigParam.vue new file mode 100644 index 0000000000..b36d3820c7 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ConfigParam.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/components/DeleteForm.vue b/.jjconflict-base-0/apps/client/src/components/DeleteForm.vue new file mode 100644 index 0000000000..3716065a14 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/DeleteForm.vue @@ -0,0 +1,73 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/DeploymentCard.vue b/.jjconflict-base-0/apps/client/src/components/DeploymentCard.vue new file mode 100644 index 0000000000..cceb650e6f --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/DeploymentCard.vue @@ -0,0 +1,46 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/DeploymentModal.vue b/.jjconflict-base-0/apps/client/src/components/DeploymentModal.vue new file mode 100644 index 0000000000..2323b7cea9 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/DeploymentModal.vue @@ -0,0 +1,182 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/DeploymentRepoOption.vue b/.jjconflict-base-0/apps/client/src/components/DeploymentRepoOption.vue new file mode 100644 index 0000000000..0aba4f3fc3 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/DeploymentRepoOption.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/components/DeploymentRepoSelect.vue b/.jjconflict-base-0/apps/client/src/components/DeploymentRepoSelect.vue new file mode 100644 index 0000000000..9af01fb8c6 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/DeploymentRepoSelect.vue @@ -0,0 +1,65 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/DeploymentResources.vue b/.jjconflict-base-0/apps/client/src/components/DeploymentResources.vue new file mode 100644 index 0000000000..948c34c059 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/DeploymentResources.vue @@ -0,0 +1,96 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/DsoBadge.vue b/.jjconflict-base-0/apps/client/src/components/DsoBadge.vue new file mode 100644 index 0000000000..0f33785b50 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/DsoBadge.vue @@ -0,0 +1,52 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/DsoSnackbar.vue b/.jjconflict-base-0/apps/client/src/components/DsoSnackbar.vue new file mode 100644 index 0000000000..a91ff46ee8 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/DsoSnackbar.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/components/EnvironmentForm.vue b/.jjconflict-base-0/apps/client/src/components/EnvironmentForm.vue new file mode 100644 index 0000000000..1035945aca --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/EnvironmentForm.vue @@ -0,0 +1,396 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ErrorGoBackToProjects.vue b/.jjconflict-base-0/apps/client/src/components/ErrorGoBackToProjects.vue new file mode 100644 index 0000000000..3dc6c155f1 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ErrorGoBackToProjects.vue @@ -0,0 +1,25 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/Loader.vue b/.jjconflict-base-0/apps/client/src/components/Loader.vue new file mode 100644 index 0000000000..5ba76adde9 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/Loader.vue @@ -0,0 +1,21 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/LoadingCt.vue b/.jjconflict-base-0/apps/client/src/components/LoadingCt.vue new file mode 100644 index 0000000000..a36af47cc3 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/LoadingCt.vue @@ -0,0 +1,29 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/LogsViewer.vue b/.jjconflict-base-0/apps/client/src/components/LogsViewer.vue new file mode 100644 index 0000000000..2ce24862f4 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/LogsViewer.vue @@ -0,0 +1,218 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/OperationPanel.vue b/.jjconflict-base-0/apps/client/src/components/OperationPanel.vue new file mode 100644 index 0000000000..e308f41fb7 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/OperationPanel.vue @@ -0,0 +1,36 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/PaginationCt.vue b/.jjconflict-base-0/apps/client/src/components/PaginationCt.vue new file mode 100644 index 0000000000..ef0e1e2c73 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/PaginationCt.vue @@ -0,0 +1,74 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ProjectAction.vue b/.jjconflict-base-0/apps/client/src/components/ProjectAction.vue new file mode 100644 index 0000000000..e05dc0bbd2 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ProjectAction.vue @@ -0,0 +1,51 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ProjectArchiveButton.vue b/.jjconflict-base-0/apps/client/src/components/ProjectArchiveButton.vue new file mode 100644 index 0000000000..1d9fe1b0f5 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ProjectArchiveButton.vue @@ -0,0 +1,74 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ProjectBanner.vue b/.jjconflict-base-0/apps/client/src/components/ProjectBanner.vue new file mode 100644 index 0000000000..05ab11ea11 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ProjectBanner.vue @@ -0,0 +1,185 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ProjectClustersInfos.vue b/.jjconflict-base-0/apps/client/src/components/ProjectClustersInfos.vue new file mode 100644 index 0000000000..d7af0874ed --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ProjectClustersInfos.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ProjectLockButton.vue b/.jjconflict-base-0/apps/client/src/components/ProjectLockButton.vue new file mode 100644 index 0000000000..ac700f5cc4 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ProjectLockButton.vue @@ -0,0 +1,32 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ProjectLogsViewer.vue b/.jjconflict-base-0/apps/client/src/components/ProjectLogsViewer.vue new file mode 100644 index 0000000000..fba2129414 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ProjectLogsViewer.vue @@ -0,0 +1,47 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ProjectReplayButton.vue b/.jjconflict-base-0/apps/client/src/components/ProjectReplayButton.vue new file mode 100644 index 0000000000..efb2a90886 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ProjectReplayButton.vue @@ -0,0 +1,62 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ProjectResources.vue b/.jjconflict-base-0/apps/client/src/components/ProjectResources.vue new file mode 100644 index 0000000000..0c0d25e2a5 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ProjectResources.vue @@ -0,0 +1,478 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ProjectRoleForm.vue b/.jjconflict-base-0/apps/client/src/components/ProjectRoleForm.vue new file mode 100644 index 0000000000..d20f31c5d9 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ProjectRoleForm.vue @@ -0,0 +1,201 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ProjectRoles.vue b/.jjconflict-base-0/apps/client/src/components/ProjectRoles.vue new file mode 100644 index 0000000000..5760069994 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ProjectRoles.vue @@ -0,0 +1,162 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ProjectSecretsButton.vue b/.jjconflict-base-0/apps/client/src/components/ProjectSecretsButton.vue new file mode 100644 index 0000000000..05f1f423e4 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ProjectSecretsButton.vue @@ -0,0 +1,93 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ProjectSettings.vue b/.jjconflict-base-0/apps/client/src/components/ProjectSettings.vue new file mode 100644 index 0000000000..4bcd1dc66a --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ProjectSettings.vue @@ -0,0 +1,164 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ReloadPrompt.vue b/.jjconflict-base-0/apps/client/src/components/ReloadPrompt.vue new file mode 100644 index 0000000000..aa914e8ecb --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ReloadPrompt.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/components/RepoForm.vue b/.jjconflict-base-0/apps/client/src/components/RepoForm.vue new file mode 100644 index 0000000000..0a38b379cd --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/RepoForm.vue @@ -0,0 +1,397 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/SelectProject.vue b/.jjconflict-base-0/apps/client/src/components/SelectProject.vue new file mode 100644 index 0000000000..5c98b13e21 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/SelectProject.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ServiceChainForm.vue b/.jjconflict-base-0/apps/client/src/components/ServiceChainForm.vue new file mode 100644 index 0000000000..eec5f94223 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ServiceChainForm.vue @@ -0,0 +1,423 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ServicesConfig.vue b/.jjconflict-base-0/apps/client/src/components/ServicesConfig.vue new file mode 100644 index 0000000000..1cd4a36f9b --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ServicesConfig.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/components/SideMenu.vue b/.jjconflict-base-0/apps/client/src/components/SideMenu.vue new file mode 100644 index 0000000000..ecde0c9aef --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/SideMenu.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/components/StageForm.vue b/.jjconflict-base-0/apps/client/src/components/StageForm.vue new file mode 100644 index 0000000000..afca19b513 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/StageForm.vue @@ -0,0 +1,212 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/SuggestionInput.vue b/.jjconflict-base-0/apps/client/src/components/SuggestionInput.vue new file mode 100644 index 0000000000..fb704e409a --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/SuggestionInput.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/components/TeamCt.vue b/.jjconflict-base-0/apps/client/src/components/TeamCt.vue new file mode 100644 index 0000000000..d850569e77 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/TeamCt.vue @@ -0,0 +1,259 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/TokenForm.vue b/.jjconflict-base-0/apps/client/src/components/TokenForm.vue new file mode 100644 index 0000000000..2d743348ec --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/TokenForm.vue @@ -0,0 +1,159 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/components/ZoneForm.vue b/.jjconflict-base-0/apps/client/src/components/ZoneForm.vue new file mode 100644 index 0000000000..57c0b8db5d --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/components/ZoneForm.vue @@ -0,0 +1,225 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/env.d.ts b/.jjconflict-base-0/apps/client/src/env.d.ts new file mode 100644 index 0000000000..c725a2cc66 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/env.d.ts @@ -0,0 +1,37 @@ +/// +/// + +declare module 'virtual:pwa-register/vue' { + import type { Ref } from 'vue' + import type { RegisterSWOptions } from 'vite-plugin-pwa/types' + + export type { RegisterSWOptions } + + export function useRegisterSW(options?: RegisterSWOptions): { + needRefresh: Ref + offlineReady: Ref + updateServiceWorker: (reloadPage?: boolean) => Promise + } +} + +declare module 'vue3-json-viewer' { + import { AllowedComponentProps, App, Component, ComponentCustomProps, VNodeProps } from 'vue'; + + interface JsonViewerProps { + value: Record | Array | string | number | boolean; + expanded: boolean; + expandDepth: number; + copyable: boolean | object; + sort: boolean; + boxed: boolean; + theme: string; //"dark" | "light" + previewMode: boolean; + timeformat: (value: any) => string; + } + + type JsonViewerType = JsonViewerProps & VNodeProps & AllowedComponentProps & ComponentCustomProps; + const JsonViewer: Component; + export { JsonViewer }; + const def: { install: (app: App) => void }; + export default def; +} diff --git a/.jjconflict-base-0/apps/client/src/icon-collections.ts b/.jjconflict-base-0/apps/client/src/icon-collections.ts new file mode 100644 index 0000000000..de8e82fe94 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/icon-collections.ts @@ -0,0 +1,5 @@ +import type { IconifyJSON } from '@iconify/vue' + +const collections: IconifyJSON[] = [{ prefix: 'ri', icons: { 'account-circle-line': { body: '' }, 'add-line': { body: '' }, 'admin-line': { body: '' }, 'arrow-drop-left-line': { body: '' }, 'arrow-drop-right-line': { body: '' }, 'arrow-go-back-line': { body: '' }, 'arrow-left-double-line': { body: '' }, 'arrow-right-double-line': { body: '' }, 'arrow-right-line': { body: '' }, 'arrow-right-s-line': { body: '' }, 'award-line': { body: '' }, 'building-line': { body: '' }, 'checkbox-blank-circle-fill': { body: '' }, 'checkbox-circle-line': { body: '' }, 'clipboard-line': { body: '' }, 'close-line': { body: '' }, 'code-s-slash-line': { body: '' }, 'dashboard-line': { body: '' }, 'delete-bin-7-line': { body: '' }, 'exchange-line': { body: '' }, 'eye-line': { body: '' }, 'eye-off-line': { body: '' }, 'file-download-line': { body: '' }, 'file-info-line': { body: '' }, 'filter-line': { body: '' }, 'filter-off-line': { body: '' }, 'flow-chart': { body: '' }, 'focus-3-line': { body: '' }, 'folder-line': { body: '' }, 'folder-shield-2-line': { body: '' }, 'folder-user-line': { body: '' }, 'folders-line': { body: '' }, 'git-merge-line': { body: '' }, 'git-repository-private-line': { body: '' }, 'github-line': { body: '' }, 'global-line': { body: '' }, 'key-2-line': { body: '' }, 'loader-4-line': { body: '' }, 'lock-line': { body: '' }, 'lock-unlock-line': { body: '' }, 'mail-line': { body: '' }, 'moon-clear-line': { body: '' }, 'newspaper-line': { body: '' }, 'pencil-line': { body: '' }, 'question-line': { body: '' }, 'refresh-line': { body: '' }, 'restart-line': { body: '' }, 'send-plane-line': { body: '' }, 'server-line': { body: '' }, 'settings-3-line': { body: '' }, 'shapes-line': { body: '' }, 'shield-check-line': { body: '' }, 'sound-module-line': { body: '' }, 'stock-line': { body: '' }, 'sun-line': { body: '' }, 'team-line': { body: '' }, 'tools-line': { body: '' }, 'upload-cloud-line': { body: '' }, 'user-add-line': { body: '' }, 'user-shared-2-line': { body: '' } }, width: 24, height: 24 }] +export const ri = { accountCircleLine: 'ri:account-circle-line', addLine: 'ri:add-line', adminLine: 'ri:admin-line', arrowDropLeftLine: 'ri:arrow-drop-left-line', arrowDropRightLine: 'ri:arrow-drop-right-line', arrowGoBackLine: 'ri:arrow-go-back-line', arrowLeftDoubleLine: 'ri:arrow-left-double-line', arrowRightDoubleLine: 'ri:arrow-right-double-line', arrowRightLine: 'ri:arrow-right-line', arrowRightSLine: 'ri:arrow-right-s-line', awardLine: 'ri:award-line', buildingLine: 'ri:building-line', checkboxBlankCircleFill: 'ri:checkbox-blank-circle-fill', checkboxCircleLine: 'ri:checkbox-circle-line', clipboardLine: 'ri:clipboard-line', closeLine: 'ri:close-line', codeSSlashLine: 'ri:code-s-slash-line', dashboardLine: 'ri:dashboard-line', deleteBin7Line: 'ri:delete-bin-7-line', exchangeLine: 'ri:exchange-line', eyeLine: 'ri:eye-line', eyeOffLine: 'ri:eye-off-line', fileDownloadLine: 'ri:file-download-line', fileInfoLine: 'ri:file-info-line', filterLine: 'ri:filter-line', filterOffLine: 'ri:filter-off-line', flowChart: 'ri:flow-chart', focus3Line: 'ri:focus-3-line', folderLine: 'ri:folder-line', folderShield2Line: 'ri:folder-shield-2-line', folderUserLine: 'ri:folder-user-line', foldersLine: 'ri:folders-line', gitMergeLine: 'ri:git-merge-line', gitRepositoryPrivateLine: 'ri:git-repository-private-line', githubLine: 'ri:github-line', globalLine: 'ri:global-line', key2Line: 'ri:key-2-line', loader4Line: 'ri:loader-4-line', lockLine: 'ri:lock-line', lockUnlockLine: 'ri:lock-unlock-line', mailLine: 'ri:mail-line', moonClearLine: 'ri:moon-clear-line', newspaperLine: 'ri:newspaper-line', pencilLine: 'ri:pencil-line', questionLine: 'ri:question-line', refreshLine: 'ri:refresh-line', restartLine: 'ri:restart-line', sendPlaneLine: 'ri:send-plane-line', serverLine: 'ri:server-line', settings3Line: 'ri:settings-3-line', shapesLine: 'ri:shapes-line', shieldCheckLine: 'ri:shield-check-line', soundModuleLine: 'ri:sound-module-line', stockLine: 'ri:stock-line', sunLine: 'ri:sun-line', teamLine: 'ri:team-line', toolsLine: 'ri:tools-line', uploadCloudLine: 'ri:upload-cloud-line', userAddLine: 'ri:user-add-line', userShared2Line: 'ri:user-shared-2-line' } as const +export default collections diff --git a/.jjconflict-base-0/apps/client/src/main.css b/.jjconflict-base-0/apps/client/src/main.css new file mode 100644 index 0000000000..58f1adca7f --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/main.css @@ -0,0 +1,194 @@ +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-Regular.otf'); +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-RegularItalic.otf'); + font-style: italic; +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-Bold.otf'); + font-weight: 700; +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-BoldItalic.otf'); + font-weight: 700; + font-style: italic; +} + + +html, +body, +#app { + font-family: Marianne, sans-serif; + height: 100%; +} + +.h-full { + height: 100%; +} + +.disabled { + color: var(--text-disabled-grey); + cursor: not-allowed; +} + +.fr-fieldset { + margin: 1rem 0 0; + align-items: flex-start; + min-width: 0; +} + +.fr-checkbox-group { + margin-bottom: 1rem; +} + +.dso-footer.fr-footer { + box-shadow: inset 0 2px 0 0 var(--border-plain-blue-france); +} + +.dso-footer .fr-footer__bottom { + display: none; +} + +.dso-footer .fr-footer__content-desc { + text-align: right; + margin-right: 25px; +} + +.danger-zone { + border-bottom: solid var(--border-plain-warning); + margin-top: 2rem; + padding: 2rem; + background-color: var(--background-alt-grey); +} + +.fr-alert { + background-color: var(--background-alt-grey); +} + +.fr-alert pre { + white-space: normal; +} + +.log-panel, .log-btn { + background-color: var(--background-default-grey); +} + +.log-panel { + box-shadow: inset 0 0 0 1px var(--border-action-high-blue-france); +} + +.log-box--success{ + border-color: var(--background-contrast-success); +} + +.log-box--warning{ + border-color: var(--background-contrast-warning); +} + +.log-box--error{ + border-color: var(--background-contrast-error); +} + +.danger-zone-btns { + @apply flex justify-between items-center gap-4 { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + describe('detect project slug helper', () => { + let projectStore: ReturnType + let slug: string + let uuid: string + + beforeEach(() => { + projectStore = useProjectStore() + slug = faker.lorem.slug() + uuid = faker.string.uuid() + const recent = faker.date.recent() + const ownerId = faker.string.uuid() + const project: ProjectV2 = { + id: uuid, + clusterIds: [], + description: '', + everyonePerms: '0', + name: slug, + slug, + locked: false, + owner: { + type: 'human', + id: ownerId, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + createdAt: recent.toString(), + updatedAt: recent.toString(), + lastLogin: recent.toString(), + }, + ownerId, + roles: [], + members: [], + createdAt: recent.toString(), + updatedAt: recent.toString(), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + status: 'created', + lastSuccessProvisionningVersion: null, + } + projectStore.updateStore([project]) + }) + + it('it should return project\'slug with uuid passed', () => { + const slugFound = detectProjectslug({ + params: { + slug: uuid, + }, + }) + expect(slugFound).toEqual(slug) + }) + + it('it should return project\'slug with slug passed', () => { + const slugFound = detectProjectslug({ + params: { + slug, + }, + }) + expect(slugFound).toEqual(slug) + }) + }) + + describe('navigation with real router instance', () => { + it('renders home and navigates to projects', async () => { + const router = createAppRouter('') + const userStore = useUserStore() + const systemStore = useSystemSettingsStore() + + // Ensure global guard does not redirect to /login + // by simulating an authenticated user. + userStore.isLoggedIn = true + userStore.setIsLoggedIn = async () => {} + systemStore.listSystemSettings = async () => {} + router.push('/') + await router.isReady() + + expect(router.currentRoute.value.name).toEqual('Home') + + await router.push('/projects') + await router.isReady() + + expect(router.currentRoute.value.matched.some(r => r.name === 'Projects')).toBe(true) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/client/src/router/index.ts b/.jjconflict-base-0/apps/client/src/router/index.ts new file mode 100644 index 0000000000..002e8421a5 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/router/index.ts @@ -0,0 +1,326 @@ +import type { + RouteLocationNormalizedGeneric, + RouteRecordRaw, +} from 'vue-router' +import { logger } from '@cpn-console/logger/browser' +import { AdminAuthorized, swaggerUiPath } from '@cpn-console/shared' +import { + createRouter, + createWebHistory, +} from 'vue-router' +import { useProjectStore } from '@/stores/project.js' +import { useSystemSettingsStore } from '@/stores/system-settings.js' + +import { useUserStore } from '@/stores/user.js' +import { uuid } from '@/utils/regex.js' +import DsoHome from '@/views/DsoHome.vue' +import NotFound from '@/views/NotFound.vue' + +const AdminCluster = () => import('@/views/admin/AdminCluster.vue') +const AdminServiceChain = () => import('@/views/admin/AdminServiceChain.vue') +const ServicesHealth = () => import('@/views/ServicesHealth.vue') +const CreateProject = () => import('@/views/CreateProject.vue') +const ProfileWrapper = () => import('@/views/profile/ProfileWrapper.vue') +const UserInfo = () => import('@/views/profile/UserInfo.vue') +const PersonalAccessTokens = () => import('@/views/profile/PersonalAccessTokens.vue') +const DsoProjects = () => import('@/views/projects/DsoProjects.vue') +const DsoAdmin = () => import('@/views/admin/DsoAdmin.vue') +const ListUser = () => import('@/views/admin/ListUser.vue') +const ProjectDashboard = () => import('@/views/ProjectDashboard.vue') +const ListProjects = () => import('@/views/admin/ListProjects.vue') +const ListLogs = () => import('@/views/admin/ListLogs.vue') +const AdminRoles = () => import('@/views/admin/AdminRoles.vue') +const ListClusters = () => import('@/views/admin/ListClusters.vue') +const ListServiceChains = () => import('@/views/admin/ListServiceChains.vue') +const ListStages = () => import('@/views/admin/ListStages.vue') +const ListZones = () => import('@/views/admin/ListZones.vue') +const ListPlugins = () => import('@/views/admin/ListPlugins.vue') +const SystemSettings = () => import('@/views/admin/SystemSettings.vue') +const Maintenance = () => import('@/views/DsoMaintenance.vue') +const AdminTokens = () => import('@/views/admin/AdminTokens.vue') + +const MAIN_TITLE = 'Console Cloud π Native' + +export function detectProjectslug(to: Pick) { + const slugParam = to.params.slug as string + return uuid.test(slugParam) + ? useProjectStore().projects.find(project => project.id === slugParam)?.slug + : slugParam +} + +export const routes: Readonly = [ + { + path: '/login', + name: 'Login', + beforeEnter: async (_to, _from) => { + const userStore = useUserStore() + await userStore.login() + }, + component: DsoProjects, + }, + { + path: '/logout', + name: 'Logout', + beforeEnter: async (_to, _from) => { + const userStore = useUserStore() + await userStore.logout() + }, + component: DsoHome, + }, + { + path: '/', + name: 'Home', + component: DsoHome, + }, + { + path: '/profile', + name: 'Profile', + component: ProfileWrapper, + children: [ + { + path: 'info', + name: 'UserInfo', + component: UserInfo, + }, + { + path: 'tokens', + name: 'PersonalAccessTokens', + component: PersonalAccessTokens, + }, + ], + }, + { + path: '/maintenance', + name: 'Maintenance', + component: Maintenance, + }, + { + path: '/services-health', + name: 'ServicesHealth', + component: ServicesHealth, + }, + + { + path: '/projects', + children: [ + { + path: '', + name: 'Projects', + component: DsoProjects, + }, + { + path: ':slug', + name: 'Project', + component: ProjectDashboard, + async beforeEnter(to, _from, next) { + if (typeof to.params.slug !== 'string') { + return next({ name: 'Projects' }) + } + if (!(to.params.slug in useProjectStore().projectsBySlug)) { + await useProjectStore().getProject(to.params.slug).catch(async () => { + logger.warn({ slug: to.params.slug }, 'Unable to find project information, redirecting to /projects') + return next({ name: 'Projects' }) + }) + } + useProjectStore().lastSelectedProjectSlug = to.params.slug + return next() + }, + props(to) { + return { + projectSlug: detectProjectslug(to), + parentRoute: 'Projects', + asProfile: 'user', + tab: to.query.tab, + } + }, + }, + ], + }, + { + path: '/create-project', + name: 'CreateProject', + component: CreateProject, + }, + { + name: 'ParentAdmin', + path: '/admin', + component: DsoAdmin, + children: [ + { + path: 'users', + name: 'ListUser', + component: ListUser, + }, + { + path: 'projects', + children: [ + { + path: '', + name: 'ListProjects', + component: ListProjects, + }, + { + path: ':slug', + name: 'AdminProject', + component: ProjectDashboard, + async beforeEnter(to, _from, next) { + if (typeof to.params.slug !== 'string') { + return next('/admin/projects') + } + await useProjectStore().getProject(to.params.slug) + return next() + }, + props(to) { + return { + projectSlug: detectProjectslug(to), + parentRoute: 'ListProjects', + asProfile: 'admin', + } + }, + }, + ], + }, + { + path: 'service-chains', + children: [ + { + path: '', + name: 'ListServiceChains', + component: ListServiceChains, + }, + { + path: ':id', + name: 'AdminServiceChain', + component: AdminServiceChain, + props(to) { + return { + id: to.params.id, + } + }, + }, + ], + }, + { + path: 'logs', + name: 'ListLogs', + component: ListLogs, + }, + { + path: 'clusters', + children: [ + { + path: '', + name: 'ListClusters', + component: ListClusters, + }, + { + path: ':id', + name: 'AdminCluster', + component: AdminCluster, + props(to) { + return { + id: to.params.id, + } + }, + }, + ], + }, + { + path: 'stages', + name: 'ListStages', + component: ListStages, + }, + { + path: 'zones', + name: 'ListZones', + component: ListZones, + }, + { + path: 'plugins', + name: 'ListPlugins', + component: ListPlugins, + }, + { + path: 'system-settings', + name: 'SystemSettings', + component: SystemSettings, + }, + { + path: 'roles', + name: 'AdminRoles', + component: AdminRoles, + }, + { + path: 'tokens', + name: 'AdminTokens', + component: AdminTokens, + }, + ], + }, + { + path: `${swaggerUiPath}-server`, + name: 'Swagger-server', + // no component, swagger plugin + components: {}, + strict: false, + }, + { + path: `${swaggerUiPath}-server-nestjs`, + name: 'Swagger-server-nestjs', + // no component, swagger plugin + components: {}, + strict: false, + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: NotFound, + }, +] + +export function createAppRouter(base?: string) { + const router = createRouter({ + history: createWebHistory(base ?? (import.meta.env?.BASE_URL || '')), + scrollBehavior: (to) => { if (to.hash && !to.hash.startsWith('#state=')) return ({ el: to.hash }) }, + routes, + }) + router.beforeEach((to) => { + const specificTitle = to.meta.title ? `${to.meta.title} - ` : '' + document.title = `${specificTitle}${MAIN_TITLE}` + }) + router.beforeEach(async (to, _from, next) => { + const validPath = new Set(['Login', 'Home', 'Doc', 'NotFound', 'ServicesHealth', 'Maintenance', 'Logout', 'Swagger-server', 'Swagger-server-nestjs']) + const userStore = useUserStore() + const systemStore = useSystemSettingsStore() + await userStore.setIsLoggedIn() + if ( + !validPath.has(to.name?.toString() ?? '') + && !userStore.isLoggedIn + ) { + return next('/login') + } + if (to.name === 'Login' && userStore.isLoggedIn) { + return next('/') + } + if ( + !validPath.has(to.name?.toString() ?? '') + && userStore.isLoggedIn + ) { + await systemStore.listSystemSettings() + if (systemStore.systemSettingsByKey.maintenance?.value === 'on' && !AdminAuthorized.Manage(userStore.adminPerms)) return next('/maintenance') + } + next() + }) + return router +} + +const router = createAppRouter() + +export const isInProject = computed(() => router.currentRoute.value.matched.some(route => route.name === 'Project')) +export const selectedProjectSlug = computed(() => { + if (router.currentRoute.value.matched.some(route => route.name === 'Project')) { + return router.currentRoute.value.params.slug as string + } + return undefined +}) +export default router diff --git a/.jjconflict-base-0/apps/client/src/stores/admin-role.ts b/.jjconflict-base-0/apps/client/src/stores/admin-role.ts new file mode 100644 index 0000000000..fa70004e0f --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/admin-role.ts @@ -0,0 +1,61 @@ +import type { AdminRole, adminRoleContract } from '@cpn-console/shared' +import { AdminAuthorized } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '../api/xhr-client.js' +import { useUserStore } from './user.js' + +export const useAdminRoleStore = defineStore('adminRole', () => { + const userStore = useUserStore() + const roles = ref([]) + const memberCounts = ref>({}) + + const countMembersRoles = async () => { + memberCounts.value + = await apiClient.AdminRoles.adminRoleMemberCounts().then((res: any) => + extractData(res, 200), + ) + return memberCounts.value + } + + const listRoles = async () => { + roles.value = await apiClient.AdminRoles.listAdminRoles().then((response: any) => + extractData(response, 200), + ) + if (AdminAuthorized.ListRoles(userStore.adminPerms)) { + await countMembersRoles() + } + return roles.value + } + + const createRole = async () => { + roles.value = await apiClient.AdminRoles.createAdminRole({ + body: { name: 'Nouveau rôle' }, + }).then((res: any) => extractData(res, 201)) + } + + const deleteRole = async (roleId: AdminRole['id']) => { + await apiClient.AdminRoles.deleteAdminRole({ params: { roleId } }).then( + (res: any) => extractData(res, 204), + ) + await listRoles() + } + + const patchRoles = async ( + body: typeof adminRoleContract.patchAdminRoles.body._type, + ) => { + roles.value = await apiClient.AdminRoles.patchAdminRoles({ + body, + }).then((res: any) => extractData(res, 200)) + } + + return { + roles, + memberCounts, + countMembersRoles, + listRoles, + createRole, + deleteRole, + patchRoles, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/admin-token.spec.ts b/.jjconflict-base-0/apps/client/src/stores/admin-token.spec.ts new file mode 100644 index 0000000000..96a91a1b94 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/admin-token.spec.ts @@ -0,0 +1,57 @@ +import type { ExposedAdminToken } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useAdminTokenStore } from './admin-token.js' + +const apiClientGet = vi.spyOn(apiClient.AdminTokens, 'listAdminTokens') +const apiClientPost = vi.spyOn(apiClient.AdminTokens, 'createAdminToken') +const apiClientDelete = vi.spyOn(apiClient.AdminTokens, 'deleteAdminToken') + +describe('cluster Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get tokens list by api call', async () => { + const data = [] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminTokenStore = useAdminTokenStore() + + await adminTokenStore.listTokens() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should add token by api call', async () => { + const data: ExposedAdminToken = { + name: 'test2', + expirationDate: null, + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + lastUse: null, + password: 'password', + permissions: '2', + } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: data })) + const adminTokenStore = useAdminTokenStore() + + const res = await adminTokenStore.createToken({ name: data.name, permissions: data.permissions }) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should delete token by api call', async () => { + const tokenId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204 })) + const adminTokenStore = useAdminTokenStore() + + await adminTokenStore.deleteToken(tokenId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/admin-token.ts b/.jjconflict-base-0/apps/client/src/stores/admin-token.ts new file mode 100644 index 0000000000..3b76dc2fe8 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/admin-token.ts @@ -0,0 +1,33 @@ +import type { AdminToken, adminTokenContract } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '../api/xhr-client.js' + +export const useAdminTokenStore = defineStore('adminToken', () => { + const listTokens = async ( + query: typeof adminTokenContract.listAdminTokens.query._type = {}, + ) => { + return apiClient.AdminTokens.listAdminTokens({ query }).then( + (response: any) => extractData(response, 200), + ) + } + + const createToken = async ( + body: typeof adminTokenContract.createAdminToken.body._type, + ) => { + return apiClient.AdminTokens.createAdminToken({ body }).then((res: any) => + extractData(res, 201), + ) + } + + const deleteToken = async (tokenId: AdminToken['id']) => { + await apiClient.AdminTokens.deleteAdminToken({ params: { tokenId } }).then( + (res: any) => extractData(res, 204), + ) + } + + return { + listTokens, + createToken, + deleteToken, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/cluster.spec.ts b/.jjconflict-base-0/apps/client/src/stores/cluster.spec.ts new file mode 100644 index 0000000000..903c45450e --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/cluster.spec.ts @@ -0,0 +1,128 @@ +import { ClusterPrivacy } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useClusterStore } from './cluster.js' + +const apiClientGet = vi.spyOn(apiClient.Clusters, 'getClusterDetails') +const apiClientGetClusterEnvironments = vi.spyOn(apiClient.Clusters, 'getClusterEnvironments') +const apiClientPost = vi.spyOn(apiClient.Clusters, 'createCluster') +const apiClientPut = vi.spyOn(apiClient.Clusters, 'updateCluster') +const apiClientDelete = vi.spyOn(apiClient.Clusters, 'deleteCluster') + +describe('cluster Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get clusters list by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b7', + label: 'cluster2', + infos: 'infos', + kubeconfig: { + cluster: { + tlsServerName: '8790044f-8414-569f-9c4a-2df42a879879', + }, + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + }, + clusterResources: true, + privacy: 'public', + } + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + await adminClusterStore.getClusterDetails(data.id) + + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should get cluster\'s associated environments by api call', async () => { + const clusterId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + const data = { + environments: [], + } + + apiClientGetClusterEnvironments.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.getClusterAssociatedEnvironments(clusterId) + + expect(res).toBe(data) + expect(apiClientGetClusterEnvironments).toHaveBeenCalledTimes(1) + }) + + it('should add cluster by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + label: 'cluster1', + projectIds: [ + '22e7044f-8414-435d-9c4a-2df42a65034b', + ], + kubeconfig: { + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + }, + clusterResources: true, + privacy: ClusterPrivacy.DEDICATED, + } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.addCluster(data) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update cluster by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + projectIds: [ + '22e7044f-8414-435d-9c4a-2df42a65034b', + ], + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + clusterResources: true, + privacy: ClusterPrivacy.DEDICATED, + } + apiClientPut.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.updateCluster(data) + + expect(res).toBe(data) + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete cluster by api call', async () => { + const clusterId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204 })) + const adminClusterStore = useClusterStore() + + await adminClusterStore.deleteCluster(clusterId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/cluster.ts b/.jjconflict-base-0/apps/client/src/stores/cluster.ts new file mode 100644 index 0000000000..3f94777f2b --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/cluster.ts @@ -0,0 +1,65 @@ +import type { CleanedCluster, Cluster, CreateClusterBody, UpdateClusterBody } from '@cpn-console/shared' +import { + resourceListToDict, + +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useClusterStore = defineStore('cluster', () => { + const clusters = ref>([]) + const clustersById = computed(() => resourceListToDict(clusters.value)) + + const getClusters = async () => { + clusters.value = await apiClient.Clusters.listClusters().then( + (response: any) => extractData(response, 200), + ) + return clusters.value + } + + const getClusterDetails = async (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterDetails({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const getClusterUsage = async (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterUsage({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const getClusterAssociatedEnvironments = (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterEnvironments({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const addCluster = (cluster: CreateClusterBody) => + apiClient.Clusters.createCluster({ body: cluster }).then((response: any) => + extractData(response, 201), + ) + + const updateCluster = ({ + id, + ...body + }: UpdateClusterBody & { id: Cluster['id'] }) => + apiClient.Clusters.updateCluster({ body, params: { clusterId: id } }).then( + (response: any) => extractData(response, 200), + ) + + const deleteCluster = (clusterId: Cluster['id']) => + apiClient.Clusters.deleteCluster({ params: { clusterId } }).then( + (response: any) => extractData(response, 204), + ) + + return { + clusters, + clustersById, + getClusterAssociatedEnvironments, + addCluster, + updateCluster, + deleteCluster, + getClusterDetails, + getClusterUsage, + getClusters, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/log.spec.ts b/.jjconflict-base-0/apps/client/src/stores/log.spec.ts new file mode 100644 index 0000000000..e9b9d0b796 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/log.spec.ts @@ -0,0 +1,32 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useLogStore } from './log.js' + +const apiClientGet = vi.spyOn(apiClient.Logs, 'getLogs') + +describe('log Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get logs list and total by api call', async () => { + const data = { + total: 1, + logs: [ + { id: 'thisIsAnId', data: {}, action: 'Create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565' }, + ], + } + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminLogStore = useLogStore() + + await adminLogStore.getAllLogs({ offset: 5, limit: 10 }) + + expect(adminLogStore.count).toEqual(data.total) + expect(adminLogStore.logs).toStrictEqual(data.logs) + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/log.ts b/.jjconflict-base-0/apps/client/src/stores/log.ts new file mode 100644 index 0000000000..3b1266cc48 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/log.ts @@ -0,0 +1,30 @@ +import type { GetLogsQuery, Log } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useLogStore = defineStore('log', () => { + const logs = ref([]) + const count = ref(undefined) + const needRefresh = ref(false) + + const getAllLogs = async ({ offset, limit }: GetLogsQuery = { offset: 0, limit: 100 }) => { + const res = await apiClient.Logs.getLogs({ query: { offset, limit, clean: false } }) + .then((response: any) => extractData(response, 200)) + count.value = res.total + logs.value = res.logs as Log[] + } + + const listLogs = async ({ offset, limit, clean, projectId }: GetLogsQuery = { offset: 0, limit: 10 }) => { + return apiClient.Logs.getLogs({ query: { offset, limit, clean, projectId } }) + .then((response: any) => extractData(response, 200)) + } + + return { + logs, + count, + needRefresh, + getAllLogs, + listLogs, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/plugins.ts b/.jjconflict-base-0/apps/client/src/stores/plugins.ts new file mode 100644 index 0000000000..99dd9f7abd --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/plugins.ts @@ -0,0 +1,19 @@ +import type { PluginsUpdateBody, ProjectService } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const usePluginsConfigStore = defineStore('plugins', () => { + const services: ProjectService[] = [] + + const getPluginsConfig = () => apiClient.SystemPlugin.getPluginsConfig() + .then((response: any) => extractData(response, 200)) + + const updatePluginsConfig = (body: PluginsUpdateBody) => apiClient.SystemPlugin.updatePluginsConfig({ body }) + .then((response: any) => extractData(response, 204)) + + return { + services, + getPluginsConfig, + updatePluginsConfig, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/project.spec.ts b/.jjconflict-base-0/apps/client/src/stores/project.spec.ts new file mode 100644 index 0000000000..6e57c0e9c4 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/project.spec.ts @@ -0,0 +1,94 @@ +import { createRandomDbSetup, getRandomMember, getRandomProject } from '@cpn-console/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client' +import { useProjectStore } from './project' + +const getProject = vi.spyOn(apiClient.Projects, 'getProject') +const listProjects = vi.spyOn(apiClient.Projects, 'listProjects') +const listEnvironments = vi.spyOn(apiClient.Environments, 'listEnvironments') +const listRepositories = vi.spyOn(apiClient.Repositories, 'listRepositories') +const apiClientPost = vi.spyOn(apiClient.Projects, 'createProject') + +describe('project Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should retrieve projects by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.environments + delete randomDbSetup.project.repositories + delete randomDbSetup.project.clusters + const projects = [randomDbSetup.project] + + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: projects, headers: {} })) + + await projectStore.listProjects() + + expect(listProjects).toHaveBeenCalledTimes(1) + expect(projectStore.projects).toMatchObject(projects) + }) + + it('should retrieve user\'s projects by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.clusters + const projects = [randomDbSetup.project] + + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: projects, headers: {} })) + listEnvironments.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.environments, headers: {} })) + listRepositories.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.repositories, headers: {} })) + + await projectStore.listMyProjects() + + expect(listProjects).toHaveBeenCalledTimes(1) + }) + + it('should retrieve one project by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.clusters + const project = randomDbSetup.project + + getProject.mockReturnValueOnce(Promise.resolve({ status: 200, body: project, headers: {} })) + listEnvironments.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.environments, headers: {} })) + listRepositories.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.repositories, headers: {} })) + + await projectStore.getProject('foo') + + expect(getProject).toHaveBeenCalledTimes(1) + }) + + it('should create a project by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + const projects = [randomDbSetup.project] + const newProject = getRandomProject() + newProject.members = [getRandomMember(randomDbSetup.users[0].id, 'owner')] + + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: newProject, headers: {} })) + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: [...projects, newProject], headers: {} })) + + await projectStore.createProject(newProject) + + expect(apiClientPost).toHaveBeenCalledTimes(1) + expect(projectStore.myProjects).toHaveLength(1) + }) +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/project.ts b/.jjconflict-base-0/apps/client/src/stores/project.ts new file mode 100644 index 0000000000..44e59d56a8 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/project.ts @@ -0,0 +1,119 @@ +import type { + CreateProjectBody, + projectContract, + ProjectV2, +} from '@cpn-console/shared' +import pDebounce from 'p-debounce' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' +import { Project } from '@/utils/project-utils.js' +import { useUserStore } from './user.js' + +export const useProjectStore = defineStore('project', () => { + const userStore = useUserStore() + + const amIPartOf = (project: ProjectV2) => + project.status !== 'archived' + && (project.ownerId === userStore.userProfile?.id + || project.members.some( + member => member.userId === userStore.userProfile?.id, + )) + + // mostly for admin views + const projectsBySlug = ref>({}) + + const projects = computed(() => + Object.values(projectsBySlug.value).sort((p1, p2) => + p1.slug.localeCompare(p2.slug), + ), + ) + + const myProjects = computed(() => + Object.values(projectsBySlug.value).filter( + project => project.status !== 'archived' && amIPartOf(project), + ), + ) + + const updateStore = async (projectsRecieved: ProjectV2[]) => { + return projectsRecieved.map((project) => { + if (project.slug in projectsBySlug.value) { + return projectsBySlug.value[project.slug].Commands.updateData(project) + } + const newProject = new Project(project) + projectsBySlug.value[project.slug] = newProject + return newProject + }) + } + + const selectFromStore = (slugs: ProjectV2['slug'][]) => { + return slugs + .filter(slug => slug in projectsBySlug.value) + .map(slug => projectsBySlug.value[slug]) + } + + const getProject = async (projectId: ProjectV2['id']) => { + const res = await apiClient.Projects.getProject({ + params: { projectId }, + }).then((response: any) => extractData(response, 200)) + return (await updateStore([res]))[0] + } + + const listProjects = async ( + query: typeof projectContract.listProjects.query._type = { + filter: 'member', + statusNotIn: 'archived', + }, + ) => { + const res = await apiClient.Projects.listProjects({ query }).then( + (response: any) => extractData(response, 200), + ) + await updateStore(res) + return selectFromStore(res.map((project: any) => project.slug)) + } + + const listMyProjects = pDebounce(async () => { + const res = await apiClient.Projects.listProjects({ + query: { filter: 'member', statusNotIn: 'archived' }, + }).then((response: any) => extractData(response, 200)) + for (const storedProject of myProjects.value) { + if ( + !res.some((responseProject: any) => responseProject.id === storedProject.id) + ) { + delete projectsBySlug.value[storedProject.slug] + } + } + await updateStore(res) + return selectFromStore(res.map((project: any) => project.slug)) + }, 200) + + const createProject = async (body: CreateProjectBody) => { + const project = await apiClient.Projects.createProject({ body }).then( + (response: any) => extractData(response, 201), + ) + projectsBySlug.value[project.slug] = new Project(project) + return project + } + + const generateProjectsData = () => + apiClient.Projects.getProjectsData().then((response: any) => + extractData(response, 200), + ) + + // Should only be used for components and vue outside of Project route and its children, consider using the props instead + // Should only be update by beforeEnter() of Project route + const lastSelectedProjectSlug = ref() + + return { + projects, + projectsBySlug, + myProjects, + lastSelectedProjectSlug, + getProject, + listProjects, + listMyProjects, + createProject, + generateProjectsData, + updateStore, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/service-chain.ts b/.jjconflict-base-0/apps/client/src/stores/service-chain.ts new file mode 100644 index 0000000000..802a594e0d --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/service-chain.ts @@ -0,0 +1,53 @@ +import type { ServiceChain, ServiceChainList } from '@cpn-console/shared' +import { + resourceListToDict, + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useServiceChainStore = defineStore('serviceChain', () => { + const serviceChains = ref([]) + const serviceChainsById = computed(() => + resourceListToDict(serviceChains.value), + ) + + const getServiceChainsList = async () => { + serviceChains.value + = await apiClient.ServiceChains.listServiceChains().then((response: any) => + extractData(response, 200), + ) + return serviceChains.value + } + + const getServiceChainDetails = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.getServiceChainDetails({ + params: { serviceChainId }, + }).then((response: any) => + ServiceChainDetailsSchema.parse(extractData(response, 200)), + ) + + const getServiceChainFlows = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.getServiceChainFlows({ + params: { serviceChainId }, + }).then((response: any) => + ServiceChainFlowsSchema.parse(extractData(response, 200)), + ) + + const retryServiceChain = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.retryServiceChain({ + params: { serviceChainId }, + }) + + return { + serviceChains, + serviceChainsById, + getServiceChainDetails, + getServiceChainFlows, + getServiceChainsList, + retryServiceChain, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/services-monitor.spec.ts b/.jjconflict-base-0/apps/client/src/stores/services-monitor.spec.ts new file mode 100644 index 0000000000..a2d72c8417 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/services-monitor.spec.ts @@ -0,0 +1,60 @@ +import type { ServiceBody } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useServiceStore } from './services-monitor.js' + +const apiClientGet = vi.spyOn(apiClient.Services, 'getServiceHealth') + +describe('service Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get services health by api call (healthy)', async () => { + const data: ServiceBody = [ + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'OK', name: 'Keycloak', status: 'OK' }, + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'Service perdu', name: 'Gitlab', status: 'OK' }, + ] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const serviceStore = useServiceStore() + + expect(serviceStore.servicesHealth).toMatchObject({}) + expect(serviceStore.services).toMatchObject([]) + + await serviceStore.checkServicesHealth() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(serviceStore.servicesHealth).toMatchObject({ + message: 'Tous les services fonctionnent normalement', + serviceStatus: 'OK', + status: 'success', + }) + expect(serviceStore.services).toMatchObject(data) + }) + + it('should get services health by api call (unhealthy)', async () => { + const data = [ + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'OK', name: 'Keycloak', status: 'OK' }, + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'Service perdu', name: 'Gitlab', status: 'Inconnu' }, + ] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const serviceStore = useServiceStore() + + expect(serviceStore.servicesHealth).toMatchObject({}) + expect(serviceStore.services).toMatchObject([]) + + await serviceStore.checkServicesHealth() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(serviceStore.servicesHealth).toMatchObject({ + message: 'Échec lors de la dernière vérification', + serviceStatus: 'Inconnu', + status: undefined, + }) + expect(serviceStore.services).toMatchObject(data) + }) +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/services-monitor.ts b/.jjconflict-base-0/apps/client/src/stores/services-monitor.ts new file mode 100644 index 0000000000..d810ef328b --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/services-monitor.ts @@ -0,0 +1,125 @@ +import type { ServiceBody } from '@cpn-console/shared' +// @ts-ignore '@gouvminint/vue-dsfr' missing types +import type { DsfrAlertType } from '@gouvminint/vue-dsfr' +import { MonitorStatus } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export type ServicesHealth = { + message: string + status: DsfrAlertType | undefined + dotColor: 'gray' | 'red' | 'green' | 'orange' | 'blue' +} | Record + +interface ServiceHealthOption { message: string, status: DsfrAlertType | undefined, serviceStatus: MonitorStatus, dotColor: ServicesHealth['dotColor'] } + +export const alertTypeMapper: Record = { + [MonitorStatus.OK]: 'success', + [MonitorStatus.WARNING]: 'warning', + [MonitorStatus.ERROR]: 'error', + [MonitorStatus.UNKNOW]: undefined, +} + +const serviceHealthOptions = { + fetching: { + message: 'Vérification de l\'état des services...', + status: 'info', + serviceStatus: MonitorStatus.UNKNOW, + dotColor: 'blue', + }, + fetchError: { + message: 'Échec lors de la dernière vérification', + status: alertTypeMapper[MonitorStatus.UNKNOW], + serviceStatus: MonitorStatus.UNKNOW, + dotColor: 'gray', + }, + error: { + message: 'Un ou plusieurs services dysfonctionnent', + status: alertTypeMapper[MonitorStatus.ERROR], + serviceStatus: MonitorStatus.ERROR, + dotColor: 'red', + }, + warn: { + message: 'Un ou plusieurs services sont partiellement dégradés', + status: alertTypeMapper[MonitorStatus.WARNING], + serviceStatus: MonitorStatus.WARNING, + dotColor: 'orange', + }, + ok: { + message: 'Tous les services fonctionnent normalement', + status: alertTypeMapper[MonitorStatus.OK], + serviceStatus: MonitorStatus.OK, + dotColor: 'green', + }, +} as const satisfies Record + +export const useServiceStore = defineStore('serviceMonitor', () => { + const callStastus = ref<'ok' | 'fetching' | 'error'>('fetching') + + const displayCause = ref(false) + const services = ref([]) + + const serviceHealthIndex = computed(() => { + if (callStastus.value === 'fetching') { + return 'fetching' + } + if (services.value.some(({ status }) => status === 'Dégradé')) { + return 'warn' + } + if (services.value.some(({ status }) => status === 'En échec')) { + return 'error' + } + if (services.value.some(({ status }) => status === 'Inconnu')) { + return 'fetchError' + } + return 'ok' + }) + + const servicesHealth = computed(() => serviceHealthOptions[serviceHealthIndex.value]) + let interval: NodeJS.Timeout + + const clear = () => interval && clearInterval(interval) + + const checkServicesHealth = async () => { + callStastus.value = 'fetching' + try { + services.value = await (displayCause.value + ? apiClient.Services.getCompleteServiceHealth() + : apiClient.Services.getServiceHealth()) + .then((res: any) => extractData(res, 200)) + callStastus.value = 'ok' + } catch { + callStastus.value = 'error' + } + } + + const refreshServicesHealth = async () => { + await apiClient.Services.refreshServiceHealth() + .then((res: any) => extractData(res, 200)) + return checkServicesHealth() + } + + const startHealthPolling = async () => { + if (!interval) return + + clear() + await checkServicesHealth() + interval = setInterval(checkServicesHealth, 300_000_000) + } + + async function toggleDisplayCause() { + displayCause.value = !displayCause.value + await checkServicesHealth() + } + + return { + displayCause, + servicesHealth, + services, + checkServicesHealth, + refreshServicesHealth, + toggleDisplayCause, + startHealthPolling, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/snackbar.spec.ts b/.jjconflict-base-0/apps/client/src/stores/snackbar.spec.ts new file mode 100644 index 0000000000..4fe623ecb3 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/snackbar.spec.ts @@ -0,0 +1,155 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSnackbarStore } from './snackbar.js' + +describe('snackbar Store', () => { + beforeEach(() => { + vi.clearAllTimers() + vi.useFakeTimers() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should display message in snackbar with default values', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(6000) + + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display message in snackbar with custom timer', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message, 'info', 10000) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(6000) + + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(4000) + + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display message in snackbar with custom type', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + const type = 'warning' + snackbarStore.setMessage(message, type) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + }) + + it('should display message in snackbar and don\'t hide it if type error', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + const type = 'error' + snackbarStore.setMessage(message, type) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type) + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display last message in snackbar', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message1 = 'this is a message' + const type1 = 'warning' + snackbarStore.setMessage(message1, type1) + + expect(snackbarStore.message).toEqual(message1) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type1) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + const message2 = 'this is another message' + const type2 = 'info' + snackbarStore.setMessage(message2, type2) + + expect(snackbarStore.message).toEqual(message2) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type2) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + }) + + it('should hide message in snackbar', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + snackbarStore.hideMessage() + + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/snackbar.ts b/.jjconflict-base-0/apps/client/src/stores/snackbar.ts new file mode 100644 index 0000000000..badfb2e072 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/snackbar.ts @@ -0,0 +1,42 @@ +import type { ErrorTypes } from '@cpn-console/shared' +import type { Ref } from 'vue' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useSnackbarStore = defineStore('snackbar', () => { + const defaultTimeout: number = 6000 + const message: Ref = ref(undefined) + const isOpen: Ref = ref(false) + const type: Ref = ref('info') + const timeoutId: Ref | undefined> = ref(undefined) + const isWaitingForResponse = ref(false) + + const hideMessage = () => { + isOpen.value = false + clearTimeout(timeoutId.value) + timeoutId.value = undefined + } + + const setMessage = (errorMessage: string, errorType: ErrorTypes = 'info', timeout: number = defaultTimeout) => { + if (timeoutId.value) { + clearTimeout(timeoutId.value) + timeoutId.value = undefined + } + if (errorType !== 'error') { + timeoutId.value = setTimeout(hideMessage, timeout) + } + message.value = errorMessage + isOpen.value = true + type.value = errorType + } + + return { + message, + isOpen, + type, + timeoutId, + isWaitingForResponse, + setMessage, + hideMessage, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/stage.spec.ts b/.jjconflict-base-0/apps/client/src/stores/stage.spec.ts new file mode 100644 index 0000000000..542c5c4c1c --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/stage.spec.ts @@ -0,0 +1,96 @@ +import type { Stage } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useStageStore } from './stage.js' + +const apiClientListStages = vi.spyOn(apiClient.Stages, 'listStages') +const apiClientGet = vi.spyOn(apiClient.Stages, 'getStageEnvironments') +const apiClientPost = vi.spyOn(apiClient.Stages, 'createStage') +const apiClientPut = vi.spyOn(apiClient.Stages, 'updateStage') +const apiClientDelete = vi.spyOn(apiClient.Stages, 'deleteStage') + +describe('stage Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get stages list by api call', async () => { + const data = [ + { id: 'id1', name: 'dev' }, + { id: 'id2', name: 'int' }, + { id: 'id3', name: 'prod' }, + ] as Stage[] + apiClientListStages.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + await stageStore.getAllStages() + + expect(stageStore.stages).toEqual(data) + expect(apiClientListStages).toHaveBeenCalledTimes(1) + }) + + it('should get a stage\'s associated environments by api call', async () => { + const stageId = 'stageId' + const data = [ + { name: 'env1' }, + { name: 'env2' }, + { name: 'env3' }, + ] as { name: string, project: string, cluster: string, owner: string }[] + apiClientGet.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.getStageAssociatedEnvironments(stageId) + + expect(res).toBe(data) + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should create a stage by api call', async () => { + const data = { + name: 'int', + } as Stage + + apiClientPost.mockResolvedValueOnce({ status: 201, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.addStage(data) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update a stage\'s quotas and clusters associated by api call', async () => { + const stageId = 'stageId' + const quotaIds = ['stage1'] + const clusterIds = ['cluster1'] + const data = { + name: 'stageA', + quotaIds, + clusterIds, + id: stageId, + } + + apiClientPut.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.updateStage(stageId, { clusterIds, name: 'stageA' }) + + expect(res).toBe(data) + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete a stage by api call', async () => { + const stageId = 'stageId' + + apiClientDelete.mockResolvedValueOnce({ status: 204, body: null, headers: null }) + const stageStore = useStageStore() + + await stageStore.deleteStage(stageId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/stage.ts b/.jjconflict-base-0/apps/client/src/stores/stage.ts new file mode 100644 index 0000000000..de6d3824bf --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/stage.ts @@ -0,0 +1,45 @@ +import type { CreateStageBody, Stage, UpdateStageBody } from '@cpn-console/shared' +import { + + resourceListToDict, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useStageStore = defineStore('stage', () => { + const stages = ref([]) + const stagesById = computed(() => resourceListToDict(stages.value)) + + const getAllStages = async () => { + stages.value = await apiClient.Stages.listStages() + .then((response: any) => extractData(response, 200)) + return stages.value + } + + const getStageAssociatedEnvironments = (stageId: string) => + apiClient.Stages.getStageEnvironments({ params: { stageId } }) + .then((response: any) => extractData(response, 200)) + + const addStage = (body: CreateStageBody) => + apiClient.Stages.createStage({ body }) + .then((response: any) => extractData(response, 201)) + + const updateStage = (stageId: string, body: UpdateStageBody) => + apiClient.Stages.updateStage({ params: { stageId }, body }) + .then((response: any) => extractData(response, 200)) + + const deleteStage = (stageId: string) => + apiClient.Stages.deleteStage({ params: { stageId } }) + .then((response: any) => extractData(response, 204)) + + return { + stages, + stagesById, + getAllStages, + getStageAssociatedEnvironments, + addStage, + updateStage, + deleteStage, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/system-settings.spec.ts b/.jjconflict-base-0/apps/client/src/stores/system-settings.spec.ts new file mode 100644 index 0000000000..0cf6eca2eb --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/system-settings.spec.ts @@ -0,0 +1,55 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useSystemSettingsStore } from './system-settings.js' + +const listSystemSettings = vi.spyOn(apiClient.SystemSettings, 'listSystemSettings') +const upsertSystemSetting = vi.spyOn(apiClient.SystemSettings, 'upsertSystemSetting') + +describe('system Settings Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get system settings list by api call', async () => { + const data = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'miom' }, + ] + listSystemSettings.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const systemSettingsStore = useSystemSettingsStore() + + await systemSettingsStore.listSystemSettings() + + expect(systemSettingsStore.systemSettings).toEqual(data) + expect(listSystemSettings).toHaveBeenCalledTimes(1) + }) + + it('should upsert a system setting by api call', async () => { + const data = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'miom' }, + ] + const newSystemSetting = { key: 'organisation', value: 'mj' } + const newData = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'mj' }, + ] + + upsertSystemSetting.mockReturnValueOnce(Promise.resolve({ status: 201, body: newSystemSetting })) + const systemSettingsStore = useSystemSettingsStore() + systemSettingsStore.systemSettings = data + + const res = await systemSettingsStore.upsertSystemSetting(data) + + expect(res).toEqual(newSystemSetting) + expect(systemSettingsStore.systemSettings).toEqual(newData) + expect(upsertSystemSetting).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/system-settings.ts b/.jjconflict-base-0/apps/client/src/stores/system-settings.ts new file mode 100644 index 0000000000..8eebac262e --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/system-settings.ts @@ -0,0 +1,36 @@ +import type { + SystemSettings, + systemSettingsContract, + UpsertSystemSettingBody, +} from '@cpn-console/shared' +import { + resourceListToDictByKey, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useSystemSettingsStore = defineStore('systemSettings', () => { + const systemSettings = ref([]) + const systemSettingsByKey = computed(() => resourceListToDictByKey(systemSettings.value)) + + const listSystemSettings = async (query: typeof systemSettingsContract.listSystemSettings.query._type = {}) => { + systemSettings.value = await apiClient.SystemSettings.listSystemSettings(query) + .then((response: any) => extractData(response, 200)) + } + + const upsertSystemSetting = async (newSystemSetting: UpsertSystemSettingBody) => { + const res = await apiClient.SystemSettings.upsertSystemSetting({ body: newSystemSetting }) + .then((response: any) => extractData(response, 201)) + systemSettings.value = systemSettings.value + .toSpliced(systemSettings.value + .findIndex(systemSetting => systemSetting.key === res.key), 1, res) + return res + } + + return { + systemSettings, + systemSettingsByKey, + listSystemSettings, + upsertSystemSetting, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/token.ts b/.jjconflict-base-0/apps/client/src/stores/token.ts new file mode 100644 index 0000000000..d19b832333 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/token.ts @@ -0,0 +1,36 @@ +import type { + PersonalAccessToken, + personalAccessTokenContract, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '../api/xhr-client.js' + +export const useTokenStore = defineStore('token', () => { + const listPersonalAccessTokens = async () => { + return apiClient.PersonalAccessTokens.listPersonalAccessTokens().then( + (response: any) => extractData(response, 200), + ) + } + + const createPersonalAccessToken = async ( + body: typeof personalAccessTokenContract.createPersonalAccessToken.body._type, + ) => { + return apiClient.PersonalAccessTokens.createPersonalAccessToken({ + body, + }).then((res: any) => extractData(res, 201)) + } + + const deletePersonalAccessToken = async ( + tokenId: PersonalAccessToken['id'], + ) => { + await apiClient.PersonalAccessTokens.deletePersonalAccessToken({ + params: { tokenId }, + }).then((res: any) => extractData(res, 204)) + } + + return { + listPersonalAccessTokens, + createPersonalAccessToken, + deletePersonalAccessToken, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/user.spec.ts b/.jjconflict-base-0/apps/client/src/stores/user.spec.ts new file mode 100644 index 0000000000..8ca9e7a521 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/user.spec.ts @@ -0,0 +1,80 @@ +import Keycloak from 'keycloak-js' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useUserStore } from './user.js' + +const authMock = vi.spyOn(apiClient.Users, 'auth') +const listAdminRoles = vi.spyOn(apiClient.AdminRoles, 'listAdminRoles') + +authMock.mockResolvedValue(Promise.resolve({ status: 200, body: {} })) +listAdminRoles.mockResolvedValue(Promise.resolve({ status: 200, body: [] })) + +vi.mock('keycloak-js', () => { + const Keycloak = vi.fn() + Keycloak.prototype.authenticated = true + Keycloak.prototype.idTokenParsed = { + email: 'michel.michel@test.com', + sub: 'userId', + given_name: 'Michel', + family_name: 'MICHEL', + groups: ['/admin'], + } + Keycloak.prototype.login = vi.fn() + Keycloak.prototype.logout = vi.fn() + + return { default: Keycloak } +}) +const keycloak = new Keycloak() + +describe('user Store', () => { + beforeEach(() => { + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + afterEach(() => { + vi.clearAllMocks() + }) + + it('should retrieve isLoggedIn from Keycloak (true)', async () => { + const userStore = useUserStore() + + expect(userStore.isLoggedIn).toBeUndefined() + + await userStore.setIsLoggedIn() + + expect(userStore.isLoggedIn).toEqual(true) + }) + + it('should retrieve userProfile from Keycloak', async () => { + const userStore = useUserStore() + + expect(userStore.userProfile).toMatchObject({}) + + userStore.setUserProfile() + + expect(userStore.userProfile).toMatchObject({ + email: 'michel.michel@test.com', + id: 'userId', + firstName: 'Michel', + lastName: 'MICHEL', + }) + }) + + it('should call Keycloak login function', async () => { + const userStore = useUserStore() + + await userStore.login() + + expect(keycloak.login).toBeCalledTimes(1) + }) + + it('should call Keycloak logout function', async () => { + const userStore = useUserStore() + + await userStore.logout() + + expect(keycloak.logout).toBeCalledTimes(1) + }) +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/user.ts b/.jjconflict-base-0/apps/client/src/stores/user.ts new file mode 100644 index 0000000000..f860e6d037 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/user.ts @@ -0,0 +1,58 @@ +import type { AdminRole, User, UserProfile } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' +import { getKeycloak, getUserProfile, keycloakLogin, keycloakLogout } from '@/utils/keycloak/keycloak.js' +import { useAdminRoleStore } from './admin-role.js' +import { useSystemSettingsStore } from './system-settings.js' + +export const useUserStore = defineStore('user', () => { + const adminRoleStore = useAdminRoleStore() + const systemSettingsStore = useSystemSettingsStore() + const isLoggedIn = ref() + const userProfile = ref() + const apiAuthInfos = ref() + + const myAdminRoles = computed(() => adminRoleStore.roles?.filter(adminRole => apiAuthInfos.value?.adminRoleIds.includes(adminRole.id)) ?? []) + + const adminPerms = computed(() => { + if (!apiAuthInfos.value) return null + const globalRoles = adminRoleStore.roles?.filter(role => role.type === 'global') ?? [] + const globalPerms = globalRoles.reduce((acc, role) => acc | BigInt(role.permissions), 0n) + const adminPerms = myAdminRoles.value.reduce((acc, role) => acc | BigInt(role.permissions), 0n) + return globalPerms | adminPerms + }) + + const setUserProfile = async () => { + userProfile.value = getUserProfile() + await systemSettingsStore.listSystemSettings().catch(() => undefined) + await apiClient.Users.auth() + .then((res: any) => apiAuthInfos.value = extractData(res, 200)) + } + + const setIsLoggedIn = async () => { + const keycloak = getKeycloak() + if (keycloak.authenticated !== isLoggedIn.value) { + isLoggedIn.value = keycloak.authenticated + if (isLoggedIn.value) { + await setUserProfile() + } + } + } + + const login = () => keycloakLogin() + + const logout = () => keycloakLogout() + + return { + isLoggedIn, + setIsLoggedIn, + userProfile, + apiAuthInfos, + myAdminRoles, + adminPerms, + setUserProfile, + login, + logout, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/users.ts b/.jjconflict-base-0/apps/client/src/stores/users.ts new file mode 100644 index 0000000000..4bacc644da --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/users.ts @@ -0,0 +1,23 @@ +import type { userContract } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useUsersStore = defineStore('users', () => { + const listUsers = async (query: typeof userContract.getAllUsers.query._input) => + apiClient.Users.getAllUsers({ query }) + .then((res: any) => extractData(res, 200)) + + const listMatchingUsers = async (query: typeof userContract.getMatchingUsers.query._type) => + apiClient.Users.getMatchingUsers({ query }) + .then((res: any) => extractData(res, 200)) + + const patchUsers = async (body: typeof userContract.patchUsers.body._type) => + apiClient.Users.patchUsers({ body }) + .then((res: any) => extractData(res, 200)) + + return { + listUsers, + listMatchingUsers, + patchUsers, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/zone.spec.ts b/.jjconflict-base-0/apps/client/src/stores/zone.spec.ts new file mode 100644 index 0000000000..087ee45034 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/zone.spec.ts @@ -0,0 +1,67 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useZoneStore } from './zone.js' + +const apiClientGet = vi.spyOn(apiClient.Zones, 'listZones') +const apiClientPost = vi.spyOn(apiClient.Zones, 'createZone') +const apiClientPut = vi.spyOn(apiClient.Zones, 'updateZone') +const apiClientDelete = vi.spyOn(apiClient.Zones, 'deleteZone') + +describe('zone Store', () => { + beforeEach(() => { + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + afterEach(() => { + vi.clearAllMocks() + }) + + it('should retrieve all zones', async () => { + const zoneStore = useZoneStore() + + expect(zoneStore.zones).toEqual([]) + + const zones = [{ id: 'zoneId' }, { id: 'anotherZoneId' }] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: zones })) + + await zoneStore.getAllZones() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(zoneStore.zones).toMatchObject(zones) + }) + + it('should create a new zone', async () => { + const adminZoneStore = useZoneStore() + + const zone = { label: 'Zone à défendre', slug: 'zad' } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, data: zone })) + + await adminZoneStore.createZone(zone) + + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update a zone', async () => { + const adminZoneStore = useZoneStore() + + const zone = { id: 'zoneId', label: 'Zod à update' } + apiClientPut.mockReturnValueOnce(Promise.resolve({ status: 200, data: zone })) + + await adminZoneStore.updateZone(zone.id, zone) + + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete a zone', async () => { + const adminZoneStore = useZoneStore() + + const zoneId = 'zoneId' + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204, data: null })) + + await adminZoneStore.deleteZone(zoneId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-base-0/apps/client/src/stores/zone.ts b/.jjconflict-base-0/apps/client/src/stores/zone.ts new file mode 100644 index 0000000000..0c81a3e0f1 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/stores/zone.ts @@ -0,0 +1,40 @@ +import type { CreateZoneBody, UpdateZoneBody, Zone } from '@cpn-console/shared' +import { + + resourceListToDict, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useZoneStore = defineStore('zone', () => { + const zones = ref([]) + const zonesById = computed(() => resourceListToDict(zones.value)) + + const getAllZones = async () => { + zones.value = await apiClient.Zones.listZones() + .then((response: any) => extractData(response, 200)) + return zones.value + } + + const createZone = (body: CreateZoneBody) => + apiClient.Zones.createZone({ body }) + .then((response: any) => extractData(response, 201)) + + const updateZone = (zoneId: Zone['id'], data: UpdateZoneBody) => + apiClient.Zones.updateZone({ body: data, params: { zoneId } }) + .then((response: any) => extractData(response, 200)) + + const deleteZone = (zoneId: Zone['id']) => + apiClient.Zones.deleteZone({ params: { zoneId } }) + .then((response: any) => extractData(response, 204)) + + return { + zones, + zonesById, + getAllZones, + createZone, + updateZone, + deleteZone, + } +}) diff --git a/.jjconflict-base-0/apps/client/src/utils/env.ts b/.jjconflict-base-0/apps/client/src/utils/env.ts new file mode 100644 index 0000000000..189eb7143e --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/utils/env.ts @@ -0,0 +1,20 @@ +/* eslint-disable no-template-curly-in-string */ +export const serverHost: string = process.env.SERVER_HOST ?? '${SERVER_HOST}' + +export const serverPort: string = process.env.SERVER_PORT ?? '${SERVER_PORT}' + +export const clientPort: string = process.env.CLIENT_PORT ?? '${CLIENT_PORT}' + +export const keycloakProtocol: string = process.env.KEYCLOAK_PROTOCOL ?? '${KEYCLOAK_PROTOCOL}' + +export const keycloakDomain: string = process.env.KEYCLOAK_DOMAIN ?? '${KEYCLOAK_DOMAIN}' + +export const keycloakRealm: string = process.env.KEYCLOAK_REALM ?? '${KEYCLOAK_REALM}' + +export const keycloakClientId: string = process.env.KEYCLOAK_CLIENT_ID ?? '${KEYCLOAK_CLIENT_ID}' + +export const keycloakRedirectUri: string = process.env.KEYCLOAK_REDIRECT_URI ?? '${KEYCLOAK_REDIRECT_URI}' + +export const contactEmail: string = process.env.CONTACT_EMAIL ?? '${CONTACT_EMAIL}' + +export const openCDSEnabled: string = process.env.OPENCDS_ENABLED ?? '${OPENCDS_ENABLED}' diff --git a/.jjconflict-base-0/apps/client/src/utils/func.spec.ts b/.jjconflict-base-0/apps/client/src/utils/func.spec.ts new file mode 100644 index 0000000000..75ef96cead --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/utils/func.spec.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('localeParseFloat EN tests', () => { + let localeParseFloatEN: (s: string) => number + beforeEach(async () => { + vi.resetModules() + vi.resetAllMocks() + Object.defineProperty(navigator, 'language', { + value: 'en-EN', + configurable: true, + }); + ({ localeParseFloat: localeParseFloatEN } = await import('./func.js')) + }) + it('should parse invalid float FR in locale EN', async () => { + const result = localeParseFloatEN('4,25') + expect(result).toBe(425) + }) + + it('should parse valid float EN in locale EN', async () => { + const result = localeParseFloatEN('4.25') + expect(result).toBe(4.25) + }) +}) + +describe('localeParseFloat FR tests', () => { + let localeParseFloatFR: (s: string) => number + beforeEach(async () => { + vi.resetModules() + vi.resetAllMocks() + Object.defineProperty(navigator, 'language', { + value: 'fr-FR', + configurable: true, + }); + ({ localeParseFloat: localeParseFloatFR } = await import('./func.js')) + }) + it('should parse valid float FR in locale FR', async () => { + const result = localeParseFloatFR('4,25') + expect(result).toBe(4.25) + }) + it('should parse valid float EN in locale FR', async () => { + const result = localeParseFloatFR('4.25') + expect(result).toBe(4.25) + }) +}) diff --git a/.jjconflict-base-0/apps/client/src/utils/func.ts b/.jjconflict-base-0/apps/client/src/utils/func.ts new file mode 100644 index 0000000000..71f1a61b5e --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/utils/func.ts @@ -0,0 +1,103 @@ +import type { Ref } from 'vue' +import { useSnackbarStore } from '@/stores/snackbar.js' + +const LOCALE = navigator.language.slice(0, 2) +// Get the thousands and decimal separator characters used in the locale. +const [,THOUSANDS_SEPARATOR,,,,DECIMAL_SEPARATOR] = 1111.1.toLocaleString(LOCALE) +// 0.1 number as local string to use in labels and placeholders +export const ONE_TENTH_STR = 0.1.toLocaleString() + +export async function copyContent(content: string): Promise { + const snackbarStore = useSnackbarStore() + try { + await navigator.clipboard.writeText(content) + snackbarStore.setMessage('Donnée copiée', 'success') + } catch (error: any) { + snackbarStore.setMessage(error?.message, 'error') + } +} + +export function toCodeComponent(value: string) { + return { + component: 'code', + text: value, + title: 'Copier la valeur', + class: 'fr-text-default--info text-xs cursor-pointer', + onClick: () => copyContent(value), + } +} + +const maxDescriptionLength = 60 +export function truncateDescription(description: string) { + let innerHTML: string + + if (description.length <= maxDescriptionLength) { + innerHTML = description + } else { + const lastSpaceIndex = description.slice(0, maxDescriptionLength).lastIndexOf(' ') + innerHTML = `${description.slice(0, lastSpaceIndex > 0 ? lastSpaceIndex : maxDescriptionLength)} ...` + } + + return { + id: 'description', + 'data-testid': 'description', + component: 'span', + open: false, + title: description, + innerHTML, + } +} + +export function clickInDialog(e?: MouseEvent | TouchEvent, fn?: () => void) { + // @ts-ignore + if (e && e.target?.tagName !== 'DIALOG') { + return + } + fn?.() +} + +function randomId() { + String.fromCharCode(97) + return (Array.from({ length: 6 })).map(() => String.fromCharCode(97 + Math.floor(Math.random() * 26))) +} +export function getRandomId(suffix?: string, prefix?: string) { + return (prefix ? (`${prefix}-`) : '') + randomId() + (suffix ? (`-${suffix}`) : '') +} + +export function toKebabCase(value: string) { + return value + .trim() + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +/** + * Replace current locale separators occurences before parsing a floating Number. + * See https://stackoverflow.com/a/59679285 + * @param s String to parse + * @returns Number + */ +export function localeParseFloat(s: string): number { + // Remove thousand separators, and put a point where the decimal separator occurs + const delocalizedInput = s.replaceAll(THOUSANDS_SEPARATOR, '').replaceAll(DECIMAL_SEPARATOR, '.') + // Now it can be parsed + return Number.parseFloat(delocalizedInput) +} + +export async function scrollToFirstError(container: Ref) { + await nextTick() + + if (!container.value) return + + const firstErrorElement = container.value.querySelector('.fr-error-text, .fr-input-group--error') + + if (firstErrorElement) { + firstErrorElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }) + } +} diff --git a/.jjconflict-base-0/apps/client/src/utils/keycloak/keycloak.spec.ts b/.jjconflict-base-0/apps/client/src/utils/keycloak/keycloak.spec.ts new file mode 100644 index 0000000000..63760697cf --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/utils/keycloak/keycloak.spec.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getKeycloak, getUserProfile, keycloakInit, keycloakLogin, keycloakLogout } from './keycloak.js' + +const userToken = { + email: 'test@test.com', + sub: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + given_name: 'Jean', + family_name: 'DUPOND', +} +const userStored = { + email: 'test@test.com', + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', +} +const keycloak = getKeycloak() + +vi.mock('keycloak-js', () => { + class Keycloak { + idTokenParsed: Record + init: () => void + login: () => void + logout: () => void + + constructor() { + this.idTokenParsed = userToken + this.init = vi.fn() + this.login = vi.fn() + this.logout = vi.fn() + } + } + return { default: Keycloak } +}) + +const mockedKeycloakInit = vi.spyOn(keycloak, 'init') +const mockedKeycloakLogin = vi.spyOn(keycloak, 'login') +const mockedKeycloakLogout = vi.spyOn(keycloak, 'logout') + +describe('keycloak-init', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return Keycloak instance', () => { + const keycloak = getKeycloak() + + expect(keycloak).toBeInstanceOf(Object) + }) + + it('should return keycloak user profile', () => { + const keycloakUser = getUserProfile() + + expect(keycloakUser).toBeInstanceOf(Object) + expect(keycloakUser).toMatchObject(userStored) + }) + + it('should init keycloak', async () => { + await keycloakInit() + + expect(mockedKeycloakInit.mock.calls).toHaveLength(1) + }) + + it('should throw an error if keycloak can\'t be initialize', async () => { + const error = new Error('Failed to init keycloak') + try { + mockedKeycloakInit.mockReturnValueOnce(Promise.reject(error)) + await keycloakInit() + } catch (e) { + expect(mockedKeycloakInit.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) + + it('should start login process to keycloak', async () => { + await keycloakLogin() + + expect(mockedKeycloakLogin.mock.calls).toHaveLength(1) + }) + + it('should throw an error if login process to keycloak failed', async () => { + const error = new Error('Failed to start login process keycloak') + try { + mockedKeycloakLogin.mockReturnValueOnce(Promise.reject(error)) + await keycloakLogin() + } catch (e) { + expect(mockedKeycloakLogin.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) + + it('should start logout process to keycloak', async () => { + await keycloakLogout() + + expect(mockedKeycloakLogout.mock.calls).toHaveLength(1) + }) + + it('should throw an error if logout process to keycloak failed', async () => { + const error = new Error('Failed to start logout process keycloak') + try { + mockedKeycloakLogout.mockReturnValueOnce(Promise.reject(error)) + await keycloakLogout() + } catch (e) { + expect(mockedKeycloakLogout.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) +}) diff --git a/.jjconflict-base-0/apps/client/src/utils/keycloak/keycloak.ts b/.jjconflict-base-0/apps/client/src/utils/keycloak/keycloak.ts new file mode 100644 index 0000000000..2d36a097c5 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/utils/keycloak/keycloak.ts @@ -0,0 +1,101 @@ +import type { UserProfile } from '@cpn-console/shared' +import type { KeycloakConfig, KeycloakInitOptions } from 'keycloak-js' +import { logger } from '@cpn-console/logger/browser' +import Keycloak from 'keycloak-js' +import { + keycloakClientId, + keycloakDomain, + keycloakProtocol, + keycloakRealm, + keycloakRedirectUri, +} from '../env.js' + +export const keycloakInitOptions: KeycloakInitOptions = { + onLoad: 'check-sso', + flow: 'standard', + redirectUri: keycloakRedirectUri, +} + +export const keycloakConfig: KeycloakConfig = { + url: `${keycloakProtocol}://${keycloakDomain}`, + realm: keycloakRealm, + clientId: keycloakClientId, +} + +let keycloak: Keycloak + +export function getKeycloak() { + if (!keycloak) { + keycloak = new Keycloak(keycloakConfig) + keycloak.onAuthSuccess = () => { + if (!(keycloak.refreshTokenParsed?.exp && keycloak.tokenParsed?.exp && keycloak.refreshTokenParsed.exp > keycloak.tokenParsed.exp)) { + return + } + logger.warn('Keycloak misconfiguration: refreshToken should not expire before token') + const refreshTokenDelay = (keycloak.tokenParsed.exp * 1000 - Date.now()) / 2 + setTimeout(() => { + keycloak.updateToken() + }, refreshTokenDelay) + } + keycloak.onTokenExpired = () => { + keycloak.updateToken(30) + } + } + return keycloak +} + +export function getUserProfile(): UserProfile { + try { + const keycloak = getKeycloak() + const { email, sub: id, given_name: firstName, family_name: lastName, groups } = keycloak.idTokenParsed as { email: string, sub: string, given_name: string, firstName: string, family_name: string, lastName: string, groups: string[] } + return { + email, + id, + firstName, + lastName, + groups, + } + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de récupération du profil keycloak de l\'utilisateur') + } +} + +export async function keycloakInit() { + const currentUrl = new URL(window.location.href) + const redirectUri = `${window.location.origin}${currentUrl.pathname}${currentUrl.search}` + try { + const { onLoad, flow } = keycloakInitOptions + const keycloak = getKeycloak() + await keycloak.init({ + onLoad, + flow, + redirectUri, + }) + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec d\'initialisation du keycloak') + } +} + +export async function keycloakLogin() { + try { + const keycloak = getKeycloak() + const currentUrl = new URL(window.location.href) + const redirectUri = `${window.location.origin}${currentUrl.pathname}${currentUrl.search}` + await keycloak.login({ redirectUri }) + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de connexion au keycloak') + } +} + +export async function keycloakLogout() { + try { + const keycloak = getKeycloak() + await keycloak.logout() + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de déconnexion du keycloak') + } +} diff --git a/.jjconflict-base-0/apps/client/src/utils/project-utils.ts b/.jjconflict-base-0/apps/client/src/utils/project-utils.ts new file mode 100644 index 0000000000..e45746f660 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/utils/project-utils.ts @@ -0,0 +1,414 @@ +import type { + CreateDeploymentBody, + CreateEnvironmentBody, + CreateRepositoryBody, + Deployment, + Environment, + GetLogsQuery, + PermissionTarget, + PluginsUpdateBody, + projectContract, + projectMemberContract, + projectRoleContract, + ProjectService, + ProjectV2, + Repo, + RepositoryParams, + Role, + UpdateDeploymentBody, + UpdateEnvironmentBody, + UpdateRepositoryBody, + User, +} from '@cpn-console/shared' +import type { Ref } from 'vue' +import { + getPermsByUserRoles, + objectKeys, + PROJECT_PERMS, + resourceListToDict, +} from '@cpn-console/shared' + +import { + apiClient, + extractData, +} from '@/api/xhr-client.js' +import { useLogStore } from '@/stores/log.js' +import { useUserStore } from '@/stores/user.js' +import { getRandomId } from './func.js' + +export type ProjectOperations = 'create' + | 'delete' + | 'deploymentManagement' + | 'envManagement' + | 'repoManagement' + | 'teamManagement' + | 'searchSecret' + | 'replay' + | 'update' + | 'lockHandling' + | 'saveServices' + | 'roleManagement' + +function calculateProjectPerms(project: ProjectV2 | undefined, userId: string | undefined) { + if (!project || !userId) return 0n + if (userId === project?.ownerId) return PROJECT_PERMS.MANAGE + const selfMember = project.members.find(member => member.userId === userId) + if (!selfMember) return 0n + + return getPermsByUserRoles(selfMember.roleIds, resourceListToDict(project.roles), project.everyonePerms) +} + +export class Project implements ProjectV2 { + id: string + clusterIds: string[] + description: string | undefined + everyonePerms: string + name: string + slug: string + locked: boolean + owner: Omit + ownerId: string + roles: { id: string, name: string, permissions: string, position: number, projectId: string, oidcGroup?: string, type?: string }[] + members: ({ userId: string, firstName: string, lastName: string, email: string, roleIds: string[] } | { updatedAt: string, createdAt: string, firstName: string, lastName: string, email: string, userId: string, roleIds: string[] })[] + createdAt: string + updatedAt: string + limitless: boolean + hprodMemory: number + hprodCpu: number + hprodGpu: number + prodMemory: number + prodCpu: number + prodGpu: number + status: ProjectV2['status'] + operationsInProgress: Ref + myPerms: bigint + repositories: Ref + environments: Ref + deployments: Ref + services: ProjectService[] = [] + lastSuccessProvisionningVersion: string | null + needReplay: Ref + + constructor(project: ProjectV2) { + this.id = project.id + this.clusterIds = project.clusterIds + this.description = project.description + this.everyonePerms = project.everyonePerms + this.name = project.name + this.slug = project.slug + this.locked = project.locked + this.owner = project.owner + this.ownerId = project.ownerId + this.roles = project.roles + this.members = project.members + this.createdAt = project.createdAt + this.updatedAt = project.updatedAt + this.limitless = project.limitless + this.hprodMemory = project.hprodMemory + this.hprodCpu = project.hprodCpu + this.hprodGpu = project.hprodGpu + this.prodMemory = project.prodMemory + this.prodCpu = project.prodCpu + this.prodGpu = project.prodGpu + this.lastSuccessProvisionningVersion = project.lastSuccessProvisionningVersion + this.status = project.status + this.myPerms = calculateProjectPerms(this, useUserStore().userProfile?.id) + this.operationsInProgress = ref([]) + this.environments = ref([]) + this.repositories = ref([]) + this.needReplay = ref(false) + this.deployments = ref([]) + } + + private removeOperation(operationName: ProjectOperations) { + setTimeout(() => { + useLogStore().needRefresh = true + }, 100) + this.operationsInProgress.value = this.operationsInProgress.value.filter(op => op !== operationName) + return this.operationsInProgress.value + } + + private addOperation(operationName: ProjectOperations): () => void { + this.needReplay.value = ['teamManagement', 'saveServices', 'roleManagement'].includes(operationName) + if (this.operationsInProgress.value.includes(operationName)) { + operationName += getRandomId() + } + if (this.operationsInProgress.value.length <= 1) { + this.operationsInProgress.value.push(operationName) + } else { + return () => {} + } + return () => { this.removeOperation(operationName) } + } + + private computePerms() { + this.myPerms = calculateProjectPerms(this, useUserStore().userProfile?.id) + } + + Commands = { + update: async (data: typeof projectContract.updateProject.body._type) => { + const callback = this.addOperation('update') + try { + const project = await apiClient.Projects.updateProject({ body: data, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + .finally(() => callback()) + return this.Commands.updateData(project) + } finally { + callback() + } + }, + updateData: (project: Partial) => { + for (const key of objectKeys(project)) { + // @ts-ignore + this[key] = project[key] + } + this.computePerms() + return undefined + }, + refresh: async () => { + const project = await apiClient.Projects.getProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.Commands.updateData(project) + await Promise.all([ + this.Repositories.list(), + this.Environments.list(), + ]) + return undefined + }, + replay: async () => { + const callback = this.addOperation('update') + try { + await apiClient.Projects.replayHooksForProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 204)) + return this.Commands.refresh() + } finally { + callback() + } + }, + delete: async () => { + const callback = this.addOperation('delete') + try { + await apiClient.Projects.archiveProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 204)) + this.status = 'archived' + } catch { + await this.Commands.refresh() + } finally { + callback() + } + }, + } + + Members = { + list: async () => { + this.members = await apiClient.ProjectsMembers.listMembers({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.members + }, + create: async (email: string) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.addMember({ params: { projectId: this.id }, body: { email } }) + .then((response: any) => extractData(response, 201)) + return this.Members.list() + } finally { callback() } + }, + delete: async (userId: User['id']) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.removeMember({ params: { projectId: this.id, userId } }) + .then((response: any) => extractData(response, 200)) + return this.Members.list() + } finally { callback() } + }, + patch: async (body: typeof projectMemberContract.patchMembers.body._type) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.patchMembers({ params: { projectId: this.id }, body }) + .then((response: any) => extractData(response, 200)) + return this.Members.list() + } finally { callback() } + }, + getCandidateUsers: async (letters: string) => { + return apiClient.Users.getMatchingUsers({ query: { letters, notInProjectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + } + + Deployments = { + list: async () => { + this.deployments.value = await apiClient.Deployments.listDeployments({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.deployments.value + }, + create: async (deploymentData: Omit) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.createDeployment({ + params: { projectId: this.id }, + body: { ...deploymentData, projectId: this.id }, + }) + .then((response: any) => extractData(response, 201)) + } finally { callback() } + }, + update: async (id: Deployment['id'], deployment: UpdateDeploymentBody) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.updateDeployment({ + params: { projectId: this.id, deploymentId: id }, + body: deployment, + }) + .then((response: any) => extractData(response, 200)) + } finally { callback() } + }, + delete: async (deploymentId: Deployment['id']) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.deleteDeployment({ + params: { projectId: this.id, deploymentId }, + }) + .then((response: any) => extractData(response, 204)) + } finally { callback() } + }, + } + + Environments = { + list: async () => { + this.environments.value = await apiClient.Environments.listEnvironments({ query: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.environments.value + }, + create: async (envData: Omit) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.createEnvironment({ body: { ...envData, projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + return this.Environments.list() + } finally { callback() } + }, + update: async (id: Environment['id'], environment: UpdateEnvironmentBody) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.updateEnvironment({ body: environment, params: { environmentId: id } }) + .then((response: any) => extractData(response, 200)) + await this.Environments.list() + return this.environments + } finally { callback() } + }, + delete: async (environmentId: Environment['id']) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.deleteEnvironment({ params: { environmentId } }) + .then((response: any) => extractData(response, 204)) + await this.Environments.list() + return this.environments + } finally { callback() } + }, + } + + Repositories = { + list: async () => { + this.repositories.value = await apiClient.Repositories.listRepositories({ query: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.repositories.value + }, + sync: async (repositoryId: Repo['id'], { branchName, syncAllBranches = false }: { branchName?: string, syncAllBranches?: boolean }) => { + const callback = this.addOperation('repoManagement') + try { + return apiClient.Repositories.syncRepository({ + params: { repositoryId }, + body: { branchName, syncAllBranches }, + }) + .then((response: any) => extractData(response, 204)) + } finally { callback() } + }, + create: async (repoData: Omit) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.createRepository({ body: { ...repoData, projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + return this.Repositories.list() + } finally { callback() } + }, + update: async (id: RepositoryParams['repositoryId'], repoData: Omit) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.updateRepository({ body: { ...repoData, projectId: this.id }, params: { repositoryId: id } }) + .then((response: any) => extractData(response, 200)) + return this.Repositories.list() + } finally { callback() } + }, + delete: async (repositoryId: Repo['id']) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.deleteRepository({ params: { repositoryId } }) + .then((response: any) => extractData(response, 204)) + return this.Repositories.list() + } finally { callback() } + }, + } + + Roles = { + countMembers: async () => { + return apiClient.ProjectsRoles.projectRoleMemberCounts({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + list: async () => { + this.roles = await apiClient.ProjectsRoles.listProjectRoles({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.computePerms() + return this.roles + }, + patch: async (body: typeof projectRoleContract.patchProjectRoles.body._type) => { + const callback = this.addOperation('roleManagement') + try { + this.roles = await apiClient.ProjectsRoles.patchProjectRoles({ body, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.computePerms() + return this.roles + } finally { callback() } + }, + create: async (body: typeof projectRoleContract.createProjectRole.body._type) => { + this.roles = await apiClient.ProjectsRoles.createProjectRole({ body, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + this.computePerms() + return this.roles + }, + delete: async (roleId: Role['id']) => { + const callback = this.addOperation('roleManagement') + try { + await apiClient.ProjectsRoles.deleteProjectRole({ params: { projectId: this.id, roleId } }) + .then((response: any) => extractData(response, 204)) + this.computePerms() + return this.Roles.list() + } finally { callback() } + }, + } + + Services = { + getSecrets: async () => { + return apiClient.Projects.getProjectSecrets({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + list: async (permissionTarget: PermissionTarget = 'user') => { + this.services = await apiClient.ProjectServices.getServices({ params: { projectId: this.id }, query: { permissionTarget } }) + .then((response: any) => extractData(response, 200)) + return this.services + }, + update: async (body: PluginsUpdateBody) => { + const callback = this.addOperation('saveServices') + try { + await apiClient.ProjectServices.updateProjectServices({ params: { projectId: this.id }, body }) + .then((response: any) => extractData(response, 204)) + return this.Services.list() + } finally { callback() } + }, + } + + Logs = { + list: async ({ offset, limit, clean }: GetLogsQuery = { offset: 0, limit: 10 }) => { + return apiClient.Logs.getLogs({ query: { offset, limit, clean, projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + } +} diff --git a/.jjconflict-base-0/apps/client/src/utils/regex.spec.ts b/.jjconflict-base-0/apps/client/src/utils/regex.spec.ts new file mode 100644 index 0000000000..353338ee8c --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/utils/regex.spec.ts @@ -0,0 +1,25 @@ +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { email, noSpace, uuid } from '@/utils/regex.js' + +describe('regex', () => { + it('should return false (noSpace)', () => { + expect(noSpace.test('te st')).toStrictEqual(false) + }) + it('should return true (noSpace)', () => { + expect(noSpace.test('test')).toStrictEqual(true) + }) + + it('should return false (email)', () => { + expect(email.test('prenom.nom')).toStrictEqual(false) + }) + it('should return true (email)', () => { + expect(email.test('prenom.nom@interieur.gouv.fr')).toStrictEqual(true) + }) + it('should return true (uuid)', () => { + expect(uuid.test(faker.string.uuid())).toStrictEqual(true) + }) + it('should return false (uuid)', () => { + expect(uuid.test('abc')).toStrictEqual(false) + }) +}) diff --git a/.jjconflict-base-0/apps/client/src/utils/regex.ts b/.jjconflict-base-0/apps/client/src/utils/regex.ts new file mode 100644 index 0000000000..38ded36071 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/utils/regex.ts @@ -0,0 +1,5 @@ +export const noSpace: RegExp = /^\S*$/ + +export const email: RegExp = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/ + +export const uuid: RegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i diff --git a/.jjconflict-base-0/apps/client/src/views/CreateProject.vue b/.jjconflict-base-0/apps/client/src/views/CreateProject.vue new file mode 100644 index 0000000000..797df6602d --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/CreateProject.vue @@ -0,0 +1,148 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/DsoHome.vue b/.jjconflict-base-0/apps/client/src/views/DsoHome.vue new file mode 100644 index 0000000000..6e141e518b --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/DsoHome.vue @@ -0,0 +1,183 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/DsoMaintenance.vue b/.jjconflict-base-0/apps/client/src/views/DsoMaintenance.vue new file mode 100644 index 0000000000..e220e5965c --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/DsoMaintenance.vue @@ -0,0 +1,36 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/NotFound.vue b/.jjconflict-base-0/apps/client/src/views/NotFound.vue new file mode 100644 index 0000000000..a0e59fb9cf --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/NotFound.vue @@ -0,0 +1,22 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/ProjectDashboard.vue b/.jjconflict-base-0/apps/client/src/views/ProjectDashboard.vue new file mode 100644 index 0000000000..d411ed7e78 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/ProjectDashboard.vue @@ -0,0 +1,344 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/ServicesHealth.vue b/.jjconflict-base-0/apps/client/src/views/ServicesHealth.vue new file mode 100644 index 0000000000..ca9f021061 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/ServicesHealth.vue @@ -0,0 +1,107 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/admin/AdminCluster.vue b/.jjconflict-base-0/apps/client/src/views/admin/AdminCluster.vue new file mode 100644 index 0000000000..fe87036105 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/admin/AdminCluster.vue @@ -0,0 +1,101 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/admin/AdminRoles.vue b/.jjconflict-base-0/apps/client/src/views/admin/AdminRoles.vue new file mode 100644 index 0000000000..3c4357c340 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/admin/AdminRoles.vue @@ -0,0 +1,127 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/admin/AdminServiceChain.vue b/.jjconflict-base-0/apps/client/src/views/admin/AdminServiceChain.vue new file mode 100644 index 0000000000..cee1e4baa2 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/admin/AdminServiceChain.vue @@ -0,0 +1,42 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/admin/AdminTokens.vue b/.jjconflict-base-0/apps/client/src/views/admin/AdminTokens.vue new file mode 100644 index 0000000000..e4cb9e9e6c --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/admin/AdminTokens.vue @@ -0,0 +1,147 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/admin/DsoAdmin.vue b/.jjconflict-base-0/apps/client/src/views/admin/DsoAdmin.vue new file mode 100644 index 0000000000..415c8d7276 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/admin/DsoAdmin.vue @@ -0,0 +1,14 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/admin/ListClusters.vue b/.jjconflict-base-0/apps/client/src/views/admin/ListClusters.vue new file mode 100644 index 0000000000..7074ceee8a --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/admin/ListClusters.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/views/admin/ListLogs.vue b/.jjconflict-base-0/apps/client/src/views/admin/ListLogs.vue new file mode 100644 index 0000000000..2fab771995 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/admin/ListLogs.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/views/admin/ListPlugins.vue b/.jjconflict-base-0/apps/client/src/views/admin/ListPlugins.vue new file mode 100644 index 0000000000..250e3938ec --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/admin/ListPlugins.vue @@ -0,0 +1,140 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/admin/ListProjects.vue b/.jjconflict-base-0/apps/client/src/views/admin/ListProjects.vue new file mode 100644 index 0000000000..171f4ea557 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/admin/ListProjects.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/views/admin/ListServiceChains.vue b/.jjconflict-base-0/apps/client/src/views/admin/ListServiceChains.vue new file mode 100644 index 0000000000..2894d83443 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/admin/ListServiceChains.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/src/views/admin/ListStages.vue b/.jjconflict-base-0/apps/client/src/views/admin/ListStages.vue new file mode 100644 index 0000000000..060aec61fb --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/admin/ListStages.vue @@ -0,0 +1,169 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/admin/ListUser.vue b/.jjconflict-base-0/apps/client/src/views/admin/ListUser.vue new file mode 100644 index 0000000000..6fd88db2d3 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/admin/ListUser.vue @@ -0,0 +1,261 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/admin/ListZones.vue b/.jjconflict-base-0/apps/client/src/views/admin/ListZones.vue new file mode 100644 index 0000000000..18698cadfa --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/admin/ListZones.vue @@ -0,0 +1,173 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/admin/SystemSettings.vue b/.jjconflict-base-0/apps/client/src/views/admin/SystemSettings.vue new file mode 100644 index 0000000000..8054ef29f5 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/admin/SystemSettings.vue @@ -0,0 +1,29 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/profile/PersonalAccessTokens.vue b/.jjconflict-base-0/apps/client/src/views/profile/PersonalAccessTokens.vue new file mode 100644 index 0000000000..f97b54cb59 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/profile/PersonalAccessTokens.vue @@ -0,0 +1,128 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/profile/ProfileWrapper.vue b/.jjconflict-base-0/apps/client/src/views/profile/ProfileWrapper.vue new file mode 100644 index 0000000000..d31d761dfd --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/profile/ProfileWrapper.vue @@ -0,0 +1,7 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/profile/UserInfo.vue b/.jjconflict-base-0/apps/client/src/views/profile/UserInfo.vue new file mode 100644 index 0000000000..c3b9e4a1b5 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/profile/UserInfo.vue @@ -0,0 +1,66 @@ + + + diff --git a/.jjconflict-base-0/apps/client/src/views/projects/DsoProjects.vue b/.jjconflict-base-0/apps/client/src/views/projects/DsoProjects.vue new file mode 100644 index 0000000000..d4174d1337 --- /dev/null +++ b/.jjconflict-base-0/apps/client/src/views/projects/DsoProjects.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/.jjconflict-base-0/apps/client/tsconfig.eslint.json b/.jjconflict-base-0/apps/client/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-0/apps/client/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-0/apps/client/tsconfig.json b/.jjconflict-base-0/apps/client/tsconfig.json new file mode 100644 index 0000000000..7f8a3442bf --- /dev/null +++ b/.jjconflict-base-0/apps/client/tsconfig.json @@ -0,0 +1,62 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + + "jsx": "preserve", + "lib": ["DOM", "ESNext"], + // Base + "baseUrl": "./", + "rootDir": "./src", + "module": "Preserve", + "moduleResolution": "Bundler", + "paths": { + "@/*": [ + "src/*" + ] + }, + "resolveJsonModule": true, + "types": [ + "vitest", + "vite/client", + "vite-plugin-pwa/vue", + "vite-plugin-pwa/client" + ], + "allowImportingTsExtensions": false, + "allowJs": true, + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useUnknownInCatchVariables": true, + "declaration": true, + "declarationDir": "./types", + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "./dist", + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "vueCompilerOptions": {}, + "include": [ + "./src/**/*.ts", + "./src/**/*.js", + "./src/**/*.vue", + "./src/**/*.d.ts" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-0/apps/client/uno.config.ts b/.jjconflict-base-0/apps/client/uno.config.ts new file mode 100644 index 0000000000..6035f5b009 --- /dev/null +++ b/.jjconflict-base-0/apps/client/uno.config.ts @@ -0,0 +1,23 @@ +import { + defineConfig, + presetIcons, + presetUno, + transformerDirectives, + transformerVariantGroup, +} from 'unocss' + +export default defineConfig({ + presets: [ + presetUno(), + presetIcons({ + scale: 1.2, + extraProperties: { + display: 'inline-block', + }, + }), + ], + transformers: [ + transformerDirectives(), + transformerVariantGroup(), + ], +}) diff --git a/.jjconflict-base-0/apps/client/vite.config.ts b/.jjconflict-base-0/apps/client/vite.config.ts new file mode 100644 index 0000000000..817f1306c6 --- /dev/null +++ b/.jjconflict-base-0/apps/client/vite.config.ts @@ -0,0 +1,145 @@ +import fs from 'node:fs' +import { fileURLToPath, URL } from 'node:url' +import { + vueDsfrAutoimportPreset, + vueDsfrComponentResolver, +} from '@gouvminint/vue-dsfr/meta' +import vue from '@vitejs/plugin-vue' +import UnoCSS from 'unocss/vite' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { defineConfig } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) + process.loadEnvFile('.env.integ') + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) + process.loadEnvFile('.env') + +const serverHost = process.env.SERVER_HOST ?? 'localhost' +const serverPort = process.env.SERVER_PORT ?? 4000 +const clientPort = process.env.CLIENT_PORT ?? 8080 + +const define = process.env.NODE_ENV === 'production' + ? { 'process.env': { APP_VERSION: process.env.APP_VERSION } } + : { 'process.env': process.env } + +// https://vitejs.dev/config/ +export default defineConfig({ + server: { + host: '0.0.0.0', + port: Number(clientPort) || 8080, + proxy: { + '^/api': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + '^/swagger-ui-server': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + '^/swagger-ui-server-nestjs': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + }, + }, + define, + plugins: [ + vue(), + AutoImport({ + imports: [ + // @ts-ignore + 'vue', + // @ts-ignore + 'vue-router', + // @ts-ignore + 'pinia', + // @ts-ignore + vueDsfrAutoimportPreset, + ], + vueTemplate: true, + dts: './src/auto-imports.d.ts', + eslintrc: { + enabled: true, + filepath: './.eslintrc-auto-import.json', + globalsPropValue: true, + }, + }), + Components({ + extensions: ['vue'], + dirs: [ + './src/components', + './src/views', + ], + include: [/\.vue$/, /\.vue\?vue/], + dts: './src/components.d.ts', + resolvers: [ + vueDsfrComponentResolver, + ], + }), + UnoCSS({ + extendTheme: (theme) => { + return { + ...theme, + breakpoints: { + ...theme.breakpoints, + dsfrmenu: '992px', + }, + } + }, + }), + VitePWA({ + registerType: 'prompt', // autoUpdate + // disable: true, + // selfDestroying: true, + workbox: { + maximumFileSizeToCacheInBytes: 5_000_000, + cleanupOutdatedCaches: true, + navigateFallbackDenylist: [ + /^\/api/, + /^\/swagger-ui-server/, + /^\/swagger-ui-server-nestjs/, + ], + }, + devOptions: { + enabled: false, + }, + manifest: { + name: 'Console Cloud Pi Native', + short_name: 'CPiN', + description: 'Une console web pour les controler tous', + start_url: '/', + display: 'standalone', + background_color: '#ffffff', + theme_color: '#42b883', + icons: [ + { + src: '/favicon.ico', + sizes: '16x16', + type: 'image/png', + }, + ], + }, + }), + ], + base: '/', + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + dedupe: ['vue'], + }, + build: { + target: 'ESNext', + }, + optimizeDeps: { + include: [ + 'jszip', + ], + }, +}) diff --git a/.jjconflict-base-0/apps/client/vitest.config.ts b/.jjconflict-base-0/apps/client/vitest.config.ts new file mode 100644 index 0000000000..7aa0e9fda4 --- /dev/null +++ b/.jjconflict-base-0/apps/client/vitest.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import { mergeConfig } from 'vite' +import { configDefaults, defineConfig } from 'vitest/config' +import viteConfig from './vite.config' + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + reporters: ['default', 'hanging-process'], + environment: 'jsdom', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: [ + '**/types', + '**/mocks', + '**/*.spec.ts', + '**/*.d.ts', + '**/*.vue', + 'src/main.ts', + 'src/icons.ts', + 'src/router/index.ts', + ], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + root: fileURLToPath(new URL('./', import.meta.url)), + pool: 'forks', + }, + }), +) diff --git a/.jjconflict-base-0/apps/nginx-strangler/Dockerfile b/.jjconflict-base-0/apps/nginx-strangler/Dockerfile new file mode 100644 index 0000000000..ec2c6753fd --- /dev/null +++ b/.jjconflict-base-0/apps/nginx-strangler/Dockerfile @@ -0,0 +1,32 @@ +FROM nginxinc/nginx-unprivileged:1.31-alpine AS prod + +USER root + +# On supprime la config par défaut +RUN rm /etc/nginx/conf.d/default.conf + +# Config principale +COPY apps/nginx-strangler/nginx.conf /etc/nginx/nginx.conf + +# Template de routing (sera substitué au démarrage) +COPY apps/nginx-strangler/conf.d/routing.conf /etc/nginx/templates/routing.conf.template + +# Donner à l'utilisateur nginx et au groupe root les droits d'écriture sur conf.d/ (pour subst au démarrage) +# et sur les répertoires de logs/pid nécessaires en mode non-root +# OpenShift exécute les conteneurs avec un UID arbitraire appartenant au groupe root. +RUN chown -R nginx:root \ + /etc/nginx/nginx.conf \ + /etc/nginx/conf.d \ + /etc/nginx/templates \ + /etc/nginx/mime.types \ + /var/cache/nginx \ + /var/log/nginx \ + && touch /var/run/nginx.pid \ + && chown nginx:root /var/run/nginx.pid && chmod g+w /var/run/nginx.pid + +USER nginx + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/health || exit 1 + +EXPOSE 8080 diff --git a/.jjconflict-base-0/apps/nginx-strangler/README.md b/.jjconflict-base-0/apps/nginx-strangler/README.md new file mode 100644 index 0000000000..f7bd5af63e --- /dev/null +++ b/.jjconflict-base-0/apps/nginx-strangler/README.md @@ -0,0 +1,120 @@ +# nginx-strangler + +Reverse proxy dédié à la migration progressive du backend `server` +(Fastify/legacy) vers `server-nestjs` (NestJS), selon le [Strangler FigPattern](https://martinfowler.com/bliki/StranglerFigApplication.html). + +Ticket de référence : [#1885](https://github.com/cloud-pi-native/console/issues/1885) + +--- + +## Architecture + +``` +[Client nginx :8080] + │ proxy_pass → nginx-strangler:8080 + ▼ +[nginx-strangler :8080] + ├── /api/[routes migrées] ──→ server-nestjs:3001 + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +--- + +## Variables d'environnement + +| Variable | Description | Exemple Docker | Exemple local | +|---|---|---|---| +| `LEGACY_UPSTREAM` | Adresse du backend Fastify legacy | `server:8080` | `127.0.0.1:4000` | +| `NESTJS_UPSTREAM` | Adresse du backend NestJS | `server-nestjs:3001` | `127.0.0.1:3001` | + +Ces variables sont substituées dans `conf.d/routing.conf` via `envsubst` au démarrage du conteneur. + +--- + +## Basculer une route vers NestJS + +Éditer `conf.d/routing.conf` et ajouter un bloc `location` **avant** le bloc fallback `/api/`, en respectant le format suivant : + +```nginx +# [Vague/Itération X - nom-du-module] AAAA-MM-JJ +location = /api/v1/ma-route { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Puis recharger nginx sans downtime : + +```bash +docker compose exec nginx-strangler nginx -t # valider la config +docker compose exec nginx-strangler nginx -s reload +``` + +### Modificateurs de location + +| Modificateur | Usage | +|---|---| +| `=` | Route exacte (ex: `GET /api/v1/system/health`) | +| `^~` | Préfixe prioritaire (ex: tout un module `/api/v1/users/`) | +| *(aucun)* | Préfixe standard | + +--- + +## Rollback d'une route + +En cas de problème sur une route basculée : + +```bash +# 1. Dans conf.d/routing.conf, commenter le bloc concerné : +# # [Vague X - module] AAAA-MM-JJ ← ROLLBACK AAAA-MM-JJ +# # location = /api/v1/xxx { ... } + +# 2. Recharger nginx sans downtime +docker compose exec nginx-strangler nginx -s reload + +# 3. Vérifier les logs +docker compose logs -f nginx-strangler | grep "/api/v1/xxx" +``` + +--- + +## Lancer en dev local (pnpm dev) + +Quand `server` et `server-nestjs` tournent nativement (hors Docker), le +`nginx-strangler` reste lui en Docker avec des upstreams pointant vers les +ports "locaux" des deux services: + +```bash +# Dans docker/docker-compose.local.yml, le service nginx-strangler utilise : +# LEGACY_UPSTREAM=127.0.0.1:4000 +# NESTJS_UPSTREAM=127.0.0.1:3001 + +docker compose -f docker/docker-compose.local.yml up nginx-strangler +``` + +--- + +## Vérification de la configuration + +```bash +# Tester la syntaxe (depuis l'hôte) +docker compose exec nginx-strangler nginx -t + +# Voir la config résolue (après substitution envsubst) +docker compose exec nginx-strangler cat /etc/nginx/conf.d/routing.conf + +# Logs en temps réel +docker compose logs -f nginx-strangler +``` + +--- + +## Liens + +- Plan de mise en place : [`apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md`](../server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md) +- Stratégie de modularisation : [`apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md`](../server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md) +- Cartographie des modules : [`apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md`](../server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) diff --git a/.jjconflict-base-0/apps/nginx-strangler/conf.d/routing.conf b/.jjconflict-base-0/apps/nginx-strangler/conf.d/routing.conf new file mode 100644 index 0000000000..25bf6f7192 --- /dev/null +++ b/.jjconflict-base-0/apps/nginx-strangler/conf.d/routing.conf @@ -0,0 +1,106 @@ +# ============================================================================= +# nginx-strangler — Configuration de routage "as-code" +# +# Ce fichier est le cœur du pattern Strangler Fig. +# Il route progressivement les routes de server (legacy) vers server-nestjs. +# +# Variables d'environnement substituées au démarrage (via envsubst) : +# LEGACY_UPSTREAM — adresse du backend Fastify legacy (ex: server:8080) +# NESTJS_UPSTREAM — adresse du backend NestJS (ex: server-nestjs:3001) +# +# Format d'une bascule : +# # [Vague X - module] AAAA-MM-JJ +# location [= | ^~ | ~] /api/v1/xxx { +# proxy_pass http://server-nestjs; +# include /etc/nginx/conf.d/_proxy_headers.conf; +# } +# +# Procédure de rollback d'une route : +# 1. Commenter le bloc location concerné (ajouter "# ROLLBACK AAAA-MM-JJ") +# 2. docker compose exec nginx-strangler nginx -s reload +# ============================================================================= + +upstream server-legacy { + server ${LEGACY_UPSTREAM}; +} + +upstream server-nestjs { + server ${NESTJS_UPSTREAM}; +} + +server { + listen 8080; + + # Taille des headers (nécessaire pour les tokens Keycloak) + large_client_header_buffers 4 32k; + + # Healthcheck de nginx-strangler lui-même + location = /health { + access_log off; + add_header 'Content-Type' 'application/json'; + return 200 '{"status":"UP"}'; + } + + # ── Swagger UI ──────────────────────────────────────────────────────────────── + location /swagger-ui-server-nestjs { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /swagger-ui-server { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── Routes migrées vers NestJS ──────────────────────────────────────────────── + location /api/v1/service-chains { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ~ ^/api/v1/projects/[^/]+/deployments(.*)$ { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Les routes /api/v2 n'existent que sur server-nestjs + location /api/v2/ { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── Routes par défaut (pour l'instant dans le legacy) ───────────────────────── + location /api/ { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_redirect off; + } + +} diff --git a/.jjconflict-base-0/apps/nginx-strangler/conf.d/routing.local.conf.example b/.jjconflict-base-0/apps/nginx-strangler/conf.d/routing.local.conf.example new file mode 100644 index 0000000000..b8f62e2e8b --- /dev/null +++ b/.jjconflict-base-0/apps/nginx-strangler/conf.d/routing.local.conf.example @@ -0,0 +1,64 @@ +# ============================================================================= +# Exemple de configuration nginx-strangler pour le développement local +# (pnpm dev — server et server-nestjs tournent nativement, hors Docker) +# +# Ce fichier est fourni à titre documentaire. +# En pratique, le docker-compose.local.yml injecte directement les variables +# d'environnement LEGACY_UPSTREAM et NESTJS_UPSTREAM dans le conteneur +# nginx-strangler via envsubst, sans qu'il soit nécessaire de modifier ce fichier. +# +# Pour lancer le nginx-strangler en mode local : +# +# docker compose -f docker/docker-compose.local.yml up nginx-strangler +# +# Les valeurs par défaut dans docker-compose.local.yml : +# LEGACY_UPSTREAM=host.docker.internal:4000 (server Fastify natif) +# NESTJS_UPSTREAM=host.docker.internal:3001 (server-nestjs natif) +# +# Si vos servers écoutent sur des ports différents, surcharger les variables : +# +# LEGACY_UPSTREAM=host.docker.internal:4000 \ +# NESTJS_UPSTREAM=host.docker.internal:3001 \ +# docker compose -f docker/docker-compose.local.yml up nginx-strangler +# +# Le nginx-strangler est alors accessible sur http://localhost:8082 +# (le client Vue.js en dev natif doit pointer son proxy /api vers localhost:8082) +# ============================================================================= + +upstream server-legacy { + server host.docker.internal:4000; +} + +upstream server-nestjs { + server host.docker.internal:3001; +} + +server { + listen 8080; + + # ── Routes migrées vers NestJS ──────────────────────────────────────────── + # (vide au démarrage — toutes les routes sont en fallback sur server-legacy) + + # ── Fallback : tout le reste vers le server legacy ──────────────────────── + location /api/ { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_redirect off; + } + + location /swagger-ui { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/.jjconflict-base-0/apps/nginx-strangler/nginx.conf b/.jjconflict-base-0/apps/nginx-strangler/nginx.conf new file mode 100644 index 0000000000..657e2100dd --- /dev/null +++ b/.jjconflict-base-0/apps/nginx-strangler/nginx.conf @@ -0,0 +1,28 @@ +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + 'upstream=$upstream_addr rt=$request_time'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + + # Taille des headers (nécessaire pour les tokens Keycloak) + large_client_header_buffers 4 32k; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/.jjconflict-base-0/apps/server-nestjs/.env-example b/.jjconflict-base-0/apps/server-nestjs/.env-example new file mode 100644 index 0000000000..1da8adba5c --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/.env-example @@ -0,0 +1,61 @@ +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Activation des plugins externes (true | false). Actif par défaut en développement. +USE_PLUGINS=true +# HOME=/home/node +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# Identifiant de l'administrateur Keycloak sur le royaume KEYCLOAK_REALM +KEYCLOAK_ADMIN=admin +# Mot de passe de l'administrateur Keycloak sur le royaume KEYCLOAK_REALM +KEYCLOAK_ADMIN_PASSWORD=admin +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 +# Interface d'écoute du serveur backend +SERVER_HOST=localhost +# Port d'écoute du serveur backend +SERVER_PORT=3001 +# URL de connexion à la base de données PostgreSQL (format Prisma) +DB_URL=postgresql://admin:admin@localhost:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +OPENCDS_URL= +# URL interne de l'API OpenCDS +OPENCDS_INTERNAL_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_GITLAB=false +USE_NEXUS=false +USE_SONARQUBE=false +USE_REGISTRY=false +USE_HARBOR=false +USE_OPENCDS=false +USE_ARGOCD=false diff --git a/.jjconflict-base-0/apps/server-nestjs/.env.docker-example b/.jjconflict-base-0/apps/server-nestjs/.env.docker-example new file mode 100644 index 0000000000..d0cf596b3a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/.env.docker-example @@ -0,0 +1,64 @@ +# Indique que l'application tourne dans un conteneur Docker +DOCKER=true +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Activation des plugins externes (true | false). +USE_PLUGINS=fasle +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine du Keycloak (nom du service Docker Compose + port interne) +KEYCLOAK_DOMAIN=keycloak:8080 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http en Docker local) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# Identifiant de l'administrateur Keycloak +KEYCLOAK_ADMIN=admin +# Mot de passe de l'administrateur Keycloak +KEYCLOAK_ADMIN_PASSWORD=admin +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 +# Interface d'écoute du serveur backend +SERVER_HOST=0.0.0.0 +# Port d'écoute du serveur dans le réseau Docker +SERVER_PORT=3001 +# URL de connexion PostgreSQL (utilise le nom de service Docker 'postgres') +DB_URL=postgresql://admin:admin@postgres:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +# Par défaut configuré sur le conteneur Mockoon (à changer sur envs non-PAX) +OPENCDS_URL= +# URL interne de l'API OpenCDS +OPENCDS_INTERNAL_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_GITLAB=false +USE_VAULT=false +USE_NEXUS=false +USE_SONARQUBE=false +USE_REGISTRY=false +USE_HARBOR=false +USE_OPENCDS=false +USE_ARGOCD=false diff --git a/.jjconflict-base-0/apps/server-nestjs/.env.integ-example b/.jjconflict-base-0/apps/server-nestjs/.env.integ-example new file mode 100644 index 0000000000..f0bf743326 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/.env.integ-example @@ -0,0 +1,130 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Désactive l'initialisation des données de démo (l'intégration a ses propres données) +DEV_SETUP="false" +# Active le mode intégration (charge ce fichier en surcharge) +INTEGRATION=true +# Activation des plugins externes (true | false). Désactivé en mode intégration. +USE_PLUGINS=false + +# --- Keycloak --- +# Protocole (généralement https en intégration) +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= +# Secret du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_SECRET= +# Domaine du Keycloak d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak d'intégration +KEYCLOAK_REALM= + +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 + +# --- ArgoCD --- +# Namespace Kubernetes dans lequel ArgoCD est déployé +ARGO_NAMESPACE= +# URL de l'API ArgoCD +ARGOCD_URL= +# URL interne de l'API ArgoCD +ARGOCD_INTERNAL_URL= + +# --- GitLab --- +# Token d'accès personnel GitLab (scope api) +GITLAB_TOKEN= +# URL de l'instance GitLab +GITLAB_URL= +# URL interne de l'API Gitlab +GITLAB_INTERNAL_URL= + +# --- Harbor --- +# Nom d'utilisateur admin Harbor +HARBOR_ADMIN= +# Mot de passe admin Harbor +HARBOR_ADMIN_PASSWORD= +# URL de l'instance Harbor +HARBOR_URL= +# URL interne de l'API Harbor +HARBOR_INTERNAL_URL= + +# --- Keycloak (admin) --- +# Utilisateur admin Keycloak pour la gestion des royaumes/clients +KEYCLOAK_ADMIN= +# Mot de passe admin Keycloak +KEYCLOAK_ADMIN_PASSWORD= +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL complète de Keycloak (peut différer de KEYCLOAK_DOMAIN) +KEYCLOAK_URL= + +# --- Nexus --- +# Nom d'utilisateur admin Nexus +NEXUS_ADMIN= +# Mot de passe admin Nexus +NEXUS_ADMIN_PASSWORD= +# URL de l'instance Nexus +NEXUS_URL= +# URL interne de l'API Nexus +NEXUS_INTERNAL_URL= + +# --- Divers --- +# Répertoire racine des projets dans Gitlab +PROJECTS_ROOT_DIR= + +# --- SonarQube --- +# Token d'API SonarQube +SONAR_API_TOKEN= +# URL de l'instance SonarQube +SONARQUBE_URL= +# URL interne de l'API SonarQube +SONARQUBE_INTERNAL_URL= + +# --- Vault --- +# Token d'authentification Vault +VAULT_TOKEN= +# URL de l'instance Vault +VAULT_URL= +# URL interne de l'API Vault +VAULT_INTERNAL_URL= + +# --- Kubeconfig --- +# Chemin vers le kubeconfig sur la machine hôte (monté en volume dans Docker) +KUBECONFIG_HOST_PATH= +# Chemin vers le kubeconfig à l'intérieur du conteneur +KUBECONFIG_PATH=$HOME/.kube/config +# Contexte Kubernetes à utiliser +KUBECONFIG_CTX= + +# Répertoire contenant les plugins externes (monté en volume dans Docker) +EXTERNAL_PLUGINS_DIR_HOST_PATH=/path/to/plugins + +# Informations pour le plugin Observabilité +GRAFANA_URL= +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 + +# --- Configuration proxy (si nécessaire pour accéder aux services externes) --- +# HTTP_PROXY= +# HTTPS_PROXY= +# NO_PROXY= + +# Configuration OpenCDS +OPENCDS_URL= +OPENCDS_INTERNAL_URL= +OPENCDS_API_TOKEN=token +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false + +# --- Activation des plugins externes --- +# Désactivés par défaut en intégration : renseignez les variables et passez à "true" pour activer. +USE_KEYCLOAK=true +USE_GITLAB=true +USE_VAULT=true +USE_NEXUS=true +USE_SONARQUBE=true +USE_REGISTRY=true +USE_HARBOR=true +USE_OPENCDS=true +USE_ARGOCD=true diff --git a/.jjconflict-base-0/apps/server-nestjs/.gitignore b/.jjconflict-base-0/apps/server-nestjs/.gitignore new file mode 100644 index 0000000000..4b56acfbeb --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/.gitignore @@ -0,0 +1,56 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/.jjconflict-base-0/apps/server-nestjs/.prettierrc b/.jjconflict-base-0/apps/server-nestjs/.prettierrc new file mode 100644 index 0000000000..8deec5d940 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/.prettierrc @@ -0,0 +1,12 @@ +{ + "plugins": ["@trivago/prettier-plugin-sort-imports"], + "importOrderParserPlugins": ["typescript", "decorators"], + "importOrder": ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true, + "printWidth": 80, + "semi": true, + "singleQuote": true, + "tabWidth": 4, + "trailingComma": "all" +} diff --git a/.jjconflict-base-0/apps/server-nestjs/Dockerfile b/.jjconflict-base-0/apps/server-nestjs/Dockerfile new file mode 100644 index 0000000000..6eb7f5f180 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/Dockerfile @@ -0,0 +1,106 @@ +# Base / dev stage ---------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Activer pnpm via corepack (version définie dans package.json "packageManager") +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +# Copier les manifestes workspace (nécessaires pour que pnpm résolve les dépendances) +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ + +# Manifestes des packages workspace dont server-nestjs dépend +COPY --chown=node:root apps/server-nestjs/package.json ./apps/server-nestjs/package.json + +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +COPY --chown=node:root plugins/argocd/package.json ./plugins/argocd/package.json +COPY --chown=node:root plugins/gitlab/package.json ./plugins/gitlab/package.json +COPY --chown=node:root plugins/harbor/package.json ./plugins/harbor/package.json +COPY --chown=node:root plugins/keycloak/package.json ./plugins/keycloak/package.json +COPY --chown=node:root plugins/nexus/package.json ./plugins/nexus/package.json +COPY --chown=node:root plugins/sonarqube/package.json ./plugins/sonarqube/package.json +COPY --chown=node:root plugins/vault/package.json ./plugins/vault/package.json + +# Installer toutes les dépendances afin de permettre le build des libs partagées +RUN pnpm install --frozen-lockfile + +# Copier les sources des packages workspace +COPY --chown=node:root plugins/ ./plugins/ +COPY --chown=node:root packages/ ./packages/ +COPY --chown=node:root apps/server-nestjs/ ./apps/server-nestjs/ + +# Build des libs partagées (génère dist/ et types/) +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +# Build hooks (génère dist/ et types/ nécessaires aux imports) +RUN pnpm --filter @cpn-console/hooks run build + +# Réinjecter les libs buildées dans node_modules (injectWorkspacePackages copie au moment de l'install) +RUN pnpm --filter @cpn-console/server-nestjs install --frozen-lockfile + +# Générer le client Prisma (schéma multi-fichiers : pointer sur le dossier) +RUN pnpm --filter @cpn-console/server-nestjs exec prisma generate \ + --schema=src/prisma/schema + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/api/v1/healthz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" + +ENTRYPOINT ["pnpm", "--filter", "server-nestjs", "run"] +CMD ["start:dev"] + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +# Build shared (génère dist/ et types/) +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +# Build hooks (génère dist/ et types/ nécessaires aux imports) +RUN pnpm --filter @cpn-console/hooks run build + +# Réinjecter shared buildé dans node_modules (injectWorkspacePackages copie au moment de l'install) +RUN pnpm --filter @cpn-console/server-nestjs install --frozen-lockfile + +# Build server-nestjs +RUN pnpm --filter @cpn-console/server-nestjs run build + +# Export @cpn-console/server-nestjs to target build directory +RUN pnpm --filter @cpn-console/server-nestjs --prod deploy build + +# Prod stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS prod + +ARG APP_VERSION +ENV APP_VERSION=$APP_VERSION + +VOLUME ["/plugins"] +WORKDIR /app + +RUN mkdir -p /home/node/logs && chmod 770 -R /home/node/logs \ + && mkdir -p /home/node/.npm && chmod 770 -R /home/node/.npm \ + && chown node:root /app + +# Copier l'artefact de build et les node_modules de production +COPY --chown=node:root --from=build /app/build . +COPY --chown=node:root --from=build /app/apps/server-nestjs/dist ./dist +# COPY --chown=node:root --from=build /app/build/node_modules ./node_modules + +# Regénérer le client Prisma dans l'image de production +RUN npm run db:generate + +USER node +EXPOSE 3001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/api/v1/healthz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" + +ENTRYPOINT ["node", "dist/src/main"] diff --git a/.jjconflict-base-0/apps/server-nestjs/README.md b/.jjconflict-base-0/apps/server-nestjs/README.md new file mode 100644 index 0000000000..62673164cc --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/README.md @@ -0,0 +1,543 @@ +# À propos + +Ce dossier contient une nouvelle version de `server`, basée sur NestJS. + +## Objectifs + +Rappel : L'objectif principal de cette démarche est de préparer l'extraction de +chaque "plugin" en tant que module NestJS incluant à la fois la partie Front et +la partie Backend pour une meilleure composition de la Console. + +On va profiter de cette vision pour passer, côté `server` d'un "Back For +Front", à un backend qui va gérer à la fois le Front et le Back de différentes +fonctionnalités (appelée "Plugins"), et ce parfois de manière dynamique (mais +déjà en statique, ce sera pas mal 😅). + +## Conséquences pour apps/server + +On va donc passer de ça : + +```mermaid +flowchart TD + + %% --- Top-level --- + NestJS["Nest.js"] + FutursModules["Futurs modules
Nest.js correctement
découpés..."] + MainModule["MainModule"] + + NestJS --> MainModule + MainModule --> CPinModule + MainModule -.-> FutursModules + + CPinModule["CPinModule
(Contient TOUT le code actuel de 'server')"] + + %% --- Core services --- + ConnectionService["ConnectionService"] + LoggerService["LoggerService"] + FastifyService["FastifyService"] + ServerService["ServerService"] + AppService["AppService"] + ResourceService["ResourceService"] + PrepareAppService["PrepareAppService"] + InitDbService["InitDbService"] + PluginService["PluginService"] + + %% --- Router services --- + AdminRoleRouterService["AdminRoleRouterService"] + AdminTokenRouterService["AdminTokenRouterService"] + ClusterRouterService["ClusterRouterService"] + OtherRouterService["...RouterService"] + + %% --- External services --- + Gitlab["Gitlab"] + ArgoCD["ArgoCD"] + Dots["..."] + + %% --- CPinModule connections --- + CPinModule --> ConnectionService + CPinModule --> AppService + CPinModule --> LoggerService + CPinModule --> FastifyService + CPinModule --> ServerService + CPinModule --> ResourceService + CPinModule --> PrepareAppService + + %% --- AppService central connections --- + ConnectionService --> AppService + LoggerService --> AppService + FastifyService --> AppService + ServerService --> AppService + + AppService --> ResourceService + AppService --> PrepareAppService + AppService --> ServerService + AppService --> FastifyService + AppService --> LoggerService + AppService --> ConnectionService + + %% --- ResourceService to routers --- + ResourceService --> AdminRoleRouterService + ResourceService --> AdminTokenRouterService + ResourceService --> ClusterRouterService + ResourceService --> OtherRouterService + + %% --- PrepareAppService --- + PrepareAppService --> InitDbService + PrepareAppService --> PluginService + PrepareAppService --> ServerService + + %% --- PluginService external interactions --- + PluginService --> Gitlab + PluginService --> ArgoCD + PluginService --> Dots + + %% --- Bounding box (visual grouping only) --- + subgraph CPinBlock[" "] + CPinModule + ConnectionService + LoggerService + FastifyService + ServerService + AppService + ResourceService + PrepareAppService + InitDbService + PluginService + AdminRoleRouterService + AdminTokenRouterService + ClusterRouterService + OtherRouterService + end +``` + +à ça : + +```mermaid +flowchart TD + + %% --- Top-level Nest module --- + NestJS["Point d'entrée de NestJS"] + MainModule["MainModule"] + CPinModule["CPinModule"] + FutursModules["Futurs modules
NestJS correctement
découpés..."] + + NestJS --> MainModule + MainModule --> CPinModule + MainModule -.-> FutursModules + + %% --- Layering for clarity --- + subgraph LayerInit["Initialisation de l'application"] + ApplicationInitializationService["ApplicationInitializationService"] + DatabaseInitializationService["DatabaseInitializationService"] + PluginManagementService["PluginManagementService"] + end + + subgraph LayerCore["Coeur de l'application"] + AppService["AppService"] + RouterService["RouterService"] + ServerService["ServerService"] + end + + subgraph LayerInfra["Couche Infrastructure"] + LoggerService["LoggerService"] + ConfigurationService["ConfigurationService"] + DatabaseService["DatabaseService"] + FastifyService["FastifyService"] + HTTPClientService["HTTPClientService"] + end + + OtherAPIService["APIs externes
(par ex. OpenCDS)"] + HTTPClientService --> OtherAPIService + + subgraph LayerBusiness["Modules métiers"] + subgraph AdminRole["Admin Roles"] + AdminRoleRouterService["AdminRoleRouterService"] + AdminRoleBusinessService["AdminRoleBusinessService"] + AdminRoleDTOService["AdminRoleDTOService"] + AdminRoleRouterService --> AdminRoleBusinessService + AdminRoleRouterService --> LoggerService + AdminRoleBusinessService --> AdminRoleDTOService + AdminRoleBusinessService --> LoggerService + AdminRoleDTOService --> LoggerService + AdminRoleDTOService --> DatabaseService + end + subgraph AdminToken["Admin Tokens"] + AdminTokenRouterService["AdminTokenRouterService"] + AdminTokenBusinessService["AdminTokenBusinessService"] + AdminTokenDTOService["AdminTokenDTOService"] + AdminTokenRouterService --> AdminTokenBusinessService + AdminTokenRouterService --> LoggerService + AdminTokenBusinessService --> AdminTokenDTOService + AdminTokenBusinessService --> LoggerService + AdminTokenDTOService --> DatabaseService + AdminTokenDTOService --> LoggerService + end + subgraph ServiceChain["Service chains"] + ServiceChainRouterService["ServiceChainRouterService"] + ServiceChainBusinessService["ServiceChainBusinessService"] + ServiceChainRouterService --> ServiceChainBusinessService + ServiceChainRouterService --> LoggerService + ServiceChainBusinessService --> HTTPClientService + ServiceChainBusinessService --> LoggerService + + end + subgraph Cluster["Clusters"] + ClusterRouterService["ClusterRouterService"] + ClusterBusinessService["ClusterBusinessService"] + ClusterDTOService["ClusterDTOService"] + ClusterRouterService --> ClusterBusinessService + ClusterRouterService --> LoggerService + ClusterBusinessService --> ClusterDTOService + ClusterBusinessService --> LoggerService + ClusterDTOService --> DatabaseService + ClusterDTOService --> LoggerService + end + OtherBusinessModules["...Other Business Modules"] + end + + RouterService --> AdminRoleRouterService + RouterService --> AdminTokenRouterService + RouterService --> ClusterRouterService + RouterService --> ServiceChainRouterService + RouterService --> OtherBusinessModules + RouterService --> LoggerService + + subgraph LayerPlugins["Plugins compatibles CPiN"] + Gitlab["Gitlab"] + ArgoCD["ArgoCD"] + Kubernetes["Kubernetes"] + Dots["..."] + end + + %% --- Module wiring --- + CPinModule --> ApplicationInitializationService + + %% Application initialization + ApplicationInitializationService --> LoggerService + ApplicationInitializationService --> ConfigurationService + ApplicationInitializationService --> FastifyService + ApplicationInitializationService --> AppService + ApplicationInitializationService --> DatabaseInitializationService + DatabaseInitializationService --> DatabaseService + DatabaseInitializationService --> LoggerService + ApplicationInitializationService --> PluginManagementService + ApplicationInitializationService --> LoggerService + + %% App Core internal flow + AppService --> RouterService + AppService --> ServerService + AppService --> LoggerService + ServerService --> LoggerService + + %% Plugin Management + PluginManagementService --> Gitlab + PluginManagementService --> ArgoCD + PluginManagementService --> Kubernetes + PluginManagementService --> Dots + PluginManagementService --> LoggerService + Gitlab --> LoggerService + ArgoCD --> LoggerService + Kubernetes --> LoggerService + Dots --> LoggerService +``` + +et enfin à ça : + +```mermaid +flowchart TD + + %% --- Top-level Nest module --- + NestJS["Point d'entrée de NestJS"] + MainModule["MainModule"] + + NestJS --> MainModule + + subgraph CoreModule["CoreModule"] + ApplicationInitializationService["ApplicationInitializationService"] + end + + subgraph InfrastructureModule["InfrastructureModule"] + LoggerService["LoggerService"] + ConfigurationService["ConfigurationService"] + DatabaseService["DatabaseService"] + HTTPClientService["HTTPClientService"] + end + + OtherAPIService["APIs externes
(par ex. OpenCDS)"] + HTTPClientService --> OtherAPIService + + subgraph MandatoryModules["Modules obligatoires CPiN"] + subgraph GitlabModule["GitlabModule"] + GitlabController["GitlabController"] + GitlabBusinessService["GitlabBusinessService"] + GitlabDTOService["GitlabDTOService"] + GitlabController --> GitlabBusinessService + GitlabController --> LoggerService + GitlabBusinessService --> GitlabDTOService + GitlabBusinessService --> LoggerService + GitlabDTOService --> DatabaseService + GitlabDTOService --> LoggerService + end + + subgraph ArgoCDModule["ArgoCDModule"] + ArgoCDController["ArgoCDController"] + ArgoCDBusinessService["ArgoCDBusinessService"] + ArgoCDDTOService["ArgoCDDTOService"] + ArgoCDController --> ArgoCDBusinessService + ArgoCDController --> LoggerService + ArgoCDBusinessService --> ArgoCDDTOService + ArgoCDBusinessService --> LoggerService + ArgoCDDTOService --> DatabaseService + ArgoCDDTOService --> LoggerService + end + + subgraph KubernetesModule["KubernetesModule"] + KubernetesController["KubernetesController"] + KubernetesBusinessService["KubernetesBusinessService"] + KubernetesDTOService["KubernetesDTOService"] + KubernetesController --> KubernetesBusinessService + KubernetesController --> LoggerService + KubernetesBusinessService --> KubernetesDTOService + KubernetesBusinessService --> LoggerService + KubernetesDTOService --> DatabaseService + KubernetesDTOService --> LoggerService + end + + subgraph AdminRoleModule["AdminRoleModule"] + AdminRoleController["AdminRoleController"] + AdminRoleBusinessService["AdminRoleBusinessService"] + AdminRoleDTOService["AdminRoleDTOService"] + AdminRoleController --> AdminRoleBusinessService + AdminRoleController --> LoggerService + AdminRoleBusinessService --> AdminRoleDTOService + AdminRoleBusinessService --> LoggerService + AdminRoleDTOService --> LoggerService + AdminRoleDTOService --> DatabaseService + end + + subgraph AdminTokenModule["AdminTokenModule"] + AdminTokenController["AdminTokenController"] + AdminTokenBusinessService["AdminTokenBusinessService"] + AdminTokenDTOService["AdminTokenDTOService"] + AdminTokenController --> AdminTokenBusinessService + AdminTokenController --> LoggerService + AdminTokenBusinessService --> AdminTokenDTOService + AdminTokenBusinessService --> LoggerService + AdminTokenDTOService --> DatabaseService + AdminTokenDTOService --> LoggerService + end + + subgraph ClusterModule["ClusterModule"] + ClusterController["ClusterController"] + ClusterBusinessService["ClusterBusinessService"] + ClusterDTOService["ClusterDTOService"] + ClusterController --> ClusterBusinessService + ClusterController --> LoggerService + ClusterBusinessService --> ClusterDTOService + ClusterBusinessService --> LoggerService + ClusterDTOService --> DatabaseService + ClusterDTOService --> LoggerService + end + OtherBusinessModules["...Other Business Modules"] + end + + CoreModule --> GitlabModule + CoreModule --> ArgoCDModule + CoreModule --> KubernetesModule + CoreModule --> AdminRoleModule + CoreModule --> AdminTokenModule + CoreModule --> ClusterModule + + subgraph ThirdPartyModules["Modules optionnels de CPin"] + subgraph ServiceChainModule["ServiceChainModule"] + ServiceChainController["ServiceChainController"] + ServiceChainBusinessService["ServiceChainBusinessService"] + ServiceChainController --> ServiceChainBusinessService + ServiceChainController --> LoggerService + ServiceChainBusinessService --> HTTPClientService + ServiceChainBusinessService --> LoggerService + end + end + + CoreModule --> ServiceChainModule + + %% --- Module wiring --- + MainModule --> ApplicationInitializationService + + %% Application initialization + ApplicationInitializationService --> LoggerService + ApplicationInitializationService --> ConfigurationService + ApplicationInitializationService --> LoggerService +``` + +## Architecture du module ServiceChain (Vague 1) + +Le module ServiceChain est le **premier module métier migré** depuis le legacy +vers server-nestjs via le pattern Strangler Fig. Il sert de proxy HTTP vers +l'API externe OpenCDS (gestion des chaînes de service réseau). + +### Flux de proxying OpenCDS + +``` +┌──────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ Client │────▶│ nginx-strangler │────▶│ server-nestjs │────▶│ API OpenCDS │ +│ (browser/ │ │ │ │ │ │ (externe) │ +│ script) │ │ /api/v1/ │ │ ServiceChain │ │ │ +│ │◀────│ service-chains │◀────│ Controller │◀────│ /requests │ +│ │ │ ──▶ nestjs │ │ ──▶ Service │ │ /validate │ +└──────────┘ │ │ │ ──▶ axios │ │ /flows │ + │ /api/* (reste) │ └──────────────────┘ └──────────────┘ + │ ──▶ legacy │ + └─────────────────┘ +``` + +### Structure du module + +``` +src/cpin-module/ +├── infrastructure/ +│ └── auth/ # Auth transverse (réutilisable) +│ ├── auth.module.ts # Module NestJS +│ ├── auth.service.ts # Lookup token SHA256 → Prisma +│ ├── admin-permission.guard.ts # Guard : vérifie x-dso-token + permissions +│ ├── admin-permission.decorator.ts @RequireAdminPermission('ListSystem') +│ └── admin-permission.guard.spec.ts +│ +└── service-chain/ # Module métier + ├── service-chain.module.ts # Imports: ConfigurationModule, AuthModule + ├── service-chain.controller.ts # 5 endpoints sous /api/v1/service-chains + ├── service-chain.service.ts # Proxy axios → OpenCDS + validation Zod + ├── service-chain.controller.spec.ts + └── service-chain.service.spec.ts +``` + +### Authentification + +Seule l'**auth par token** (`x-dso-token`) est supportée pour l'instant. +L'auth par session Keycloak sera ajoutée lors de la migration globale du +mécanisme de session vers NestJS (les cookies de session Fastify ne sont pas +déchiffrables par Express). + +``` + Requête HTTP + │ + ▼ + ┌──────────────────────┐ + │ AdminPermissionGuard │ + │ │ + │ 1. Lire header │ + │ x-dso-token │──── absent ──▶ 401 Unauthorized + │ │ + │ 2. SHA256(token) │ + │ → Prisma lookup │──── invalide ──▶ 401 Unauthorized + │ (PersonalAccess │ (expiré, révoqué, introuvable) + │ Token ou Admin │ + │ Token) │ + │ │ + │ 3. Vérifier perms │ + │ bitwise via │──── insuffisant ──▶ 403 Forbidden + │ AdminAuthorized │ + │ │ + │ 4. OK → continuer │──────────────────▶ Controller + └──────────────────────┘ +``` + +### Endpoints + +| Méthode | Route | Permission | Description | +|---------|-------|------------|-------------| +| `GET` | `/api/v1/service-chains` | `ListSystem` | Liste toutes les chaînes | +| `GET` | `/api/v1/service-chains/:id` | `ListSystem` | Détails d'une chaîne | +| `GET` | `/api/v1/service-chains/:id/flows` | `ListSystem` | Flux d'une chaîne | +| `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | Relancer une chaîne | +| `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | Valider une chaîne | + +### Différences avec le legacy + +- **403 systématique** : le legacy retournait `[]` sur `GET /` sans permission ; + le NestJS retourne 403 pour tous les endpoints sans permission. +- **Pas d'auth session** : seul `x-dso-token` fonctionne (le legacy supportait + aussi les sessions Keycloak). +- **Validation UUID** : les paramètres d'URL sont validés via `ParseUUIDPipe` + (400 si format invalide). + +### Variables d'environnement + +| Variable | Description | Défaut | +|----------|-------------|--------| +| `OPENCDS_URL` | URL de base de l'API OpenCDS | _(vide = désactivé)_ | +| `OPENCDS_API_TOKEN` | Token d'API OpenCDS (header `X-API-Key`) | — | +| `OPENCDS_API_TLS_REJECT_UNAUTHORIZED` | Vérification TLS (`true`/`false`) | `true` | + +--- + +Pour mettre à jour `old-server` (après avoir rebasé sur `origin/master`, par exemple) : + +```bash +server-nestjs/$ rm -rf src/cpin-module/old-server +server-nestjs/$ cp -r ../server src/cpin-module/old-server +server-nestjs/$ find src/cpin-module/old-server -type f -iname "*.ts" -exec sed -i -e "s#@/#@old-server/#g" {} \; +server-nestjs/$ find src/cpin-module/old-server -type f -iname "*.ts" -exec sed -i -e "s#\.[jt]s'#'#g" {} \; +``` + +## Fichiers à supprimer dans le futur + +Certains fichiers de `old-server` servait de "framework" pour le backend, et +vont donc être réécrits en tant que modules/services NestJS. On va garder la +liste ici, ce qui permettra de ne pas être constamment en conflit sur le code +de `server`. En attendant de pouvoir s'en débarrasser, et afin de s'assurer que +leur code n'est pas utilisé dans d'autres parties du backend, on va commenter +l'intégralité de ces fichiers (comme ça pas d'erreur d'import quand on les +supprimera). + +Voilà donc la liste des fichiers "dépréciés" : + +``` +old-server/src/app.ts -> Réécrit en AppService +old-server/src/connect.ts -> Réécrit en DatabaseService +old-server/src/init/db/* (à part dump.ts) -> Réécrit en DatabaseInitializationService +old-server/src/resources/**/router.ts -> Réécrit en **RouterService +old-server/src/resources/index.ts -> Réécrit en RouterService +old-server/src/server.ts -> Intégré à ApplicationInitializationService +old-server/src/utils/env.ts -> Réécrit en ConfigurationModule +old-server/src/utils/fastify.ts -> Réécrit en FastifyService +old-server/src/utils/keycloak-utils.ts -> Intégré dans AppService +old-server/src/utils/keycloak.ts -> Intégré dans AppService +old-server/src/utils/logger.ts -> Réécrit en LoggerModule +old-server/src/utils/plugin.ts -> Réécrit en PluginManagementService +``` + +## Prochaines itérations sur le sujet + +Tâches à réaliser par la suite dans d'autres itérations/tickets/etc. : + +- Migrer une fonctionnalité "verticale" complète (Route, Contract, Controller, + Business, Queries, Prisma schema) dans son propre module NestJS qui sera + importé dans `MainModule` (et pas dans `CpinModule` qui devra disparaître + à terme). +- Définir la liste de ces fonctionnalités verticales, et planifier l'extraction + de certaines d'entre elles (OpenCDS, typiquement, qui n'a rien à faire dans + le code de base de la Console) +- Migrer la base actuelle de NestJS de Jest vers Vitest et s'assurer que les + tests unitaires passent à nouveaux (attention, certains d'entre eux devront + être adaptés vu qu'on a commencé à réécrire du code au standard NestJS) +- Intérgrer `server-nestjs` dans tous les `docker compose` et les différents + scripts PNPM/Bash qui font tout le sel de nos process de dev +- Revoir les imports de données (le fameux `dump.ts`). C'était déjà une + mauvaise idée à l'époque, ça l'est encore plus aujourd'hui. On ferait mieux + d'utiliser un side-container pour ça +- Être capable de déployer `server-nestjs` en parallèle de `server`, avec + probablement un reverse proxy dédié à la migration des routes de l'ancien + vers le nouveau (à rediscuter plus concrètement) + +Les étapes d'après-après (quand on sera sereins sur la migration de `server` +vers `server-nestjs`) : + +- Tester les capacités de NestJS SSR (Server Side Rendering), notamment + vis-à-vis de nos composants VueJS. Il y aura probablement des sujets autour + de l'isolation du code VueJS des différentes fonctionnalités afin de + faciliter leur extraction de `client` vers `server` +- Implémenter une fonctionnalité du Front en tant que module NestJS SSR + (OpenCDS est un très bon cas d'étude grâce à son côté très isolé dans le + code). Idéalement ce code "Front" ajouté "côté Backend" devra être colocalisé + avec le code "Backend" correspondant. diff --git a/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md b/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md new file mode 100644 index 0000000000..1973831edb --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md @@ -0,0 +1,214 @@ +# Modularisation Backend : Node.js Server → NestJS + +## 🎯 Objectifs de la modularisation + +### Principes directeurs + +- La Console a évolué très rapidement. Depuis sa création jusqu'à récemment, la + priorité a été donnée à l'ajout de fonctionnalités, sans forcément avoir le + temps de consolider le socle technique. La modularisation de sa partie + serveur, à l'aide d'un framework robuste comme NestJS, permettra une + évolution saine de la plate-forme. +- Il y a une volonté de permettre davantage de souplesse dans la configuration + de la console (utilisation d'un plugin en remplacement d'un autre), afin de + permettre aux projets qui voudraient avoir "leur" console de la configurer + comme ils veulent +- Le code du serveur de la console est assez daté, dans ses paradigmes et ses + dépendances. Un nettoyage de ce code par une remise à plat de la cartographie + des interdépendances entre les différents modules permettra d'améliorer les + choses module par module + +### Objectifs techniques +- **Modularisation** : Découpage en modules métier cohérents et maintenables +- **Architecture propre** : Injection de dépendances, séparation des responsabilités +- **Scalabilité** : Base solide pour les évolutions futures +- **Qualité code** : Standards NestJS, TypeScript strict + +### Objectifs organisationnels +- **Continuité produit** : Zéro impact sur les développements en cours +- **Montée en compétence** : Équipe formée progressivement à NestJS +- **Risque maîtrisé** : Modularisation progressive sans interruption de service + +## 📊 Contexte technique + +### Stack actuelle +- **Backend** : Node.js (avec ts-rest/fastify et injection de dépendance manuelle des plugins) +- **ORM** : Prisma avec PostgreSQL +- **Auth** : OIDC avec Keycloak +- **Tests** : Playwright (E2E), Vitest (unitaires) +- **Déploiement** : Docker Compose +- **CI/CD** : Pipeline existante + +### Stack cible +- **Backend** : NestJS (TypeScript) +- **ORM** : Prisma (conservé, partagé) +- **Auth** : OIDC Keycloak +- **Tests** : Playwright + Vitest (conservés) +- **Déploiement** : Docker Compose (phase transition) → NestJS seul +- **CI/CD** : Adaptée pour build et le déploiement des 2 images en parallèle + +### Métriques du projet +- **Routes** : ~100 +- **Équipe** : ~5 développeurs +- **Timeline** : 3 mois (12 sprints) +- **Couverture tests E2E** : 33% → objectif 50% + +## 🏗️ Stratégie : Strangler Fig Pattern + +### Principe +Le nouveau backend `server-nestjs` coexiste avec l'ancien et "étouffe" progressivement `server` en reprenant route par route ses fonctionnalités. + +### Pourquoi cette approche ? +✅ **Zero downtime** : Service continu pendant la modularisation +✅ **Rollback facile** : Retour arrière immédiat via configuration nginx +✅ **Équipe non bloquée** : Développements possibles en parallèle +✅ **Validation progressive** : Tests en prod sur petit périmètre +✅ **Risque maîtrisé** : Pas de big bang + +### Architecture de transition + +``` +┌─────────────┐ +│ Nginx │ ← Point d'entrée unique (port 80) +│ (Reverse │ +│ Proxy) │ +└──────┬──────┘ + │ + ├─────────────┐ + │ │ + Migré Pas encore + │ │ + │ │ +┌──────▼──────┐ ┌────▼───────┐ +│ NestJS API │ │ Server API │ +│ (3001) │ │ (3000) │ +└──────┬──────┘ └────┬───────┘ + │ │ + └─────────────│ + │ + ┌──────▼─────┐ + │ PostgreSQL │ + │ (Prisma) │ + └────────────┘ +``` + +## 📋 Documents de référence + +Cette modularisation est documentée dans plusieurs fichiers : + +1. **[00-OVERVIEW.md](00-OVERVIEW.md)** *(ce document)* - Vue d'ensemble +3. **[01-MODULARISATION-STRATEGY.md](01-MODULARISATION-STRATEGY.md)** - Stratégie et méthodologie +7. **[MODULARISATION_STATUS.md](MODULARISATION_STATUS.md)** - Suivi en temps réel (fichier vivant) +8. **[MODULARISATION_MAP.md](MODULARISATION_MAP.md)** - Cartographie des modules (fichier vivant) + +## 🚀 Démarrage rapide + +### Pour l'équipe de développement +1. Lire ce document (OVERVIEW) +2. Consulter [MODULARISATION\_STATUS.md](MODULARISATION_STATUS.md) pour connaître l'état actuel +3. Suivre le canal Mattermost `#backend-modularisation` +4. Respecter les zones en modularisation (🚧) avant de développer + +### Pour le lead technique de la modularisation +1. Lire tous les documents dans l'ordre +2. Personnaliser [MODULARISATION\_MAP.md](MODULARISATION_MAP.md) avec la cartographie réelle +3. Configurer l'infrastructure +4. Lancer la communication + +## 🎯 Principes directeurs + +### Technique +- **Parité fonctionnelle** : Chaque route migrée doit être strictement équivalent à son prédécesseur +- **Tests de contrat** : Validation automatique de la parité comportementale +- **Monitoring** : Surveillance continue des performances et erreurs +- **Documentation** : Chaque décision architecturale documentée (ADR) + +### Organisationnel +- **Communication proactive** : Annonce claire des zones en modularisation +- **Collaboration** : "Pair programming" encouragé pour montée en compétence +- **Pragmatisme** : Adapter le planning selon les contraintes business +- **Célébration** : Reconnaître les étapes franchies + +## ⚠️ Règles critiques + +### À FAIRE +✅ Annoncer début/fin de modularisation d'un module sur Mattermost +✅ Mettre à jour MODULARISATION\_STATUS.md à chaque changement +✅ Écrire des tests de contrat avant de migrer une route +✅ Déployer progressivement (intégration → staging → prod) +✅ Monitorer 24-48 h après chaque modularisation + +### À NE PAS FAIRE + +❌ Migrer un module pendant qu'une fonctionnalité y est développée +❌ Modifier le schéma Prisma pendant la modularisation (sauf critique) +❌ Déployer sans tests E2E passants +❌ Oublier de mettre à jour la config nginx pour indiquer la bascule d'une route +❌ Développer sur `server` (et pas sur `server-nestjs`) un module déjà migré + +## 📞 Contacts + +### Responsable modularisation +- **Lead technique** : @stephane.trebel +- **Canal Mattermost** : #backend-modularisation +- **Réunions** : Hebdo modularisation (les jeudis à 10h, 30 min) + +### Escalade +- **Problème technique bloquant** : Contacter @lead-dev immédiatement +- **Conflit de développement** : Contacter @lead-dev immédiatement +- **Incident de Production** : Retour en arrière Nginx sur la route concernée + +## 📈 Métriques de succès + +### Fin de modularisation (sprint 12) +- [ ] 100% des routes migrées sur NestJS +- [ ] code de `server` supprimé du dépôt Git de `console` +- [ ] Couverture des Tests E2E ≥ 50% des cas d'usage de `console` +- [ ] Documentation technique complète des modules (cartographie, dépendances inter-modules, etc.) +- [ ] 0 régression fonctionnelle +- [ ] Équipe autonome sur NestJS + +### Indicateurs intermédiaires +- **S4** : 20% migré (Modules transverses (logger, configuration, etc.)) +- **S6** : 40% migré (Modules "verticaux" comme Zone, Cluster, etc.) +- **S8** : 60% migré +- **S10** : 80% migré (Plugins, comme ArgoCD, Gitlab, etc.) +- **S12** : 100% migré + +### Migrations anticipées +- **ServiceChain (OpenCDS)** : Migré le 2026-04-09 (prévu V3/S8). Ce module + isolé (proxy HTTP vers API externe, aucune + dépendance entrante) a servi de premier test de bout en bout du pipeline de + migration : module NestJS + `InfrastructureModule` + `UserGuard` + + nginx-strangler + Docker. + +## 🗓️ Dates clés + +- **S1-S2** : Cartographie et setup +- **S3-S4** : Modularisation des modules transverses +- **S5-S6** : Modularisation des modules métiers "cœur" (Zone, Cluster, etc.) +- **S7-S10** : Modularisation modules plugins (ArgoCD, Gitlab, etc.) +- **S11-S12** : Finalisation et nettoyage du code (suppression reverse proxy, suppression de `server`, etc.) + +*Dates indicatives, ajustables selon les contraintes business* + +## 📚 Ressources + +### Documentation NestJS + +- [Documentation officielle NestJS](https://docs.nestjs.com/) +- [Prisma avec NestJS](https://docs.nestjs.com/recipes/prisma) +- [Authentification OIDC NestJS](https://docs.nestjs.com/recipes/passport) + +### Outils + +- [Madge](https://github.com/pahen/madge) - Analyse de dépendances +- [Compodoc](https://compodoc.app/) - Documentation auto +- [Playwright](https://playwright.dev/) - Tests E2E +- [Vitest](https://vitest.dev/) - Tests unitaires + +--- + +**Version** : 1.2 +**Dernière mise à jour** : 2026-06-16 +**Prochaine revue** : Fin S2 diff --git a/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md b/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md new file mode 100644 index 0000000000..10c05e2525 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md @@ -0,0 +1,313 @@ +# Stratégie de Modularisation - console/server → console/server-nestjs + +## 🎯 Approche globale + +Sachant que nous avons des fonctionalités assez bien identifiées (AdminTokens, Plugin Gitlab, etc.), nous allons utiliser une approche « Modularisation par verticaux ». + +### Principe + +Plutôt que de migrer par couches horizontales (tous les DTOs, puis tous les services, puis tous les controllers, puis tous les plugins), nous migrerons **module complet par module complet**. + +### Pourquoi cette approche ? + +✅ **Valeur métier immédiate** : Chaque itération livre un module fonctionnel +✅ **Tests E2E possibles** : Validation complète de la non-régression dès la fin du module +✅ **Risque isolé** : Un problème n'affecte qu'un seul domaine métier +✅ **Rollback granulaire** : Retour arrière possible module par module +✅ **Équipe non bloquée** : Autres modules restent disponibles pour développement +✅ **Reprise du typage** : Beaucoup de modules ont un typage Typescript lacunaire, voire inexistant (`any`). Migrer un module sera l'opportunité de reprendre ces aspects et définir clairement l'"API" du Module + +### Anatomie d'un vertical + +``` +ZoneModule (exemple) +├── Model (Prisma) ← Déjà existant, partagé +├── Repository/Service ← Modularisation du code métier +├── Controller/Routes ← Modularisation des endpoints +├── DTOs & Validation ← Ajout validation NestJS +├── Tests unitaires ← Nouveaux tests Vitest +└── Tests E2E ← Adaptation tests Playwright si besoin (normalement jamais) +``` + +## Phase 1: 📊 Cartographie et priorisation du code de console/server actuel + +### Objectifs de la cartographie + +1. **Inventaire exhaustif** : Lister tous les endpoints et leurs dépendances +2. **Graphe de dépendances** : Identifier les modules couplés entre eux (typiquement Vault et…tout le reste) +3. **Priorisation** : Définir l'ordre optimal de modularisation (de "bas en haut", donc de ceux qui ont le moins de dépendances à ceux qui dépendent de presque tout le monde) +4. **Estimation** : Évaluer grosse maille la charge de chaque module + +### Outils d'analyse automatique + +Une manière de faire qui combine une approche automatique (on parcourt tout ce qu'on peut) et une approche empirique (on regarde ce que ça donne) peut se faire de la manière suivante : + +```bash +# 1. Installer les outils +npm install -D madge dependency-cruiser + +# 2. Générer le graphe de dépendances +npx madge --image dependency-graph.png --extensions js,ts server/src + +# 3. Détecter les cycles de dépendances +npx madge --circular --extensions js,ts server/src + +# 4. Analyser les violations architecturales +npx depcruise src --output-type err +``` + +### Ordre de modularisation recommandé + +**Stratégie en 3 phases** : + +1. **Phase d'apprentissage (S3-S4)** : Modules simples mais critiques + - Auth : Critique mais bien délimité + - Objectif : Valider l'approche, former l'équipe + +2. **Phase de vélocité (S5-S8)** : Modules métier principaux + - Cluster, Zone, etc. + - Objectif : Migrer le gros du trafic + +3. **Phase de finalisation (S9-S12)** : Modules périphériques + - Plugins qui doivent exister impérativement (Kubernetes, Gitlab, etc. -LISTE À DEFINIR-) + - Objectif : Compléter la modularisation, suppression de `server` et renommage de `server-nestjs` en `server` + +## 🔄 Gestion du passage simple singleton node.js → Injection de dépendances dans des services singleton + +### Le problème des singletons purement "node.js" + +Les singletons actuellement utilisés dans le code sont ceux de Node.js. Ce sont +des instances certes uniques, mais qui reposent intégralement sur une logique +d'import des fichiers. Cette logique n'est pas mauvaise en soi (et fonctionne +encore maintenant), mais elle est très limitée, car elle ne nous permet pas de +configurer les "modules" importés (changer une dépendance par une autre, par +exemple). Par essence les modules node.js ne permettent pas d'injection de +dépendance, en particulier au runtime. + +```javascript +// ❌ Pattern singleton dans le code server +const prisma = require('../db').default; + +async findById(id) { + return prisma.user.findUnique({ where: { id } }); +} + +async create(data) { + return prisma.user.create({ data }); +} + +// Export d'une instance unique (singleton) +module.exports = { findById, create }; +``` + +**Problèmes** : +- État partagé mutable +- Difficile à tester (mocking complexe) +- Pas de contrôle du cycle de vie +- Couplage fort avec les dépendances +- Typage souvent lacunaire + +### Solution : Migrer vers un vrai service singleton de type NestJS + +```typescript +// nest-backend/src/users/users.service.ts +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { User } from '@prisma/client'; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id } }); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + return user; + } + + async create(createUserDto: CreateUserDto): Promise { + return this.prisma.user.create({ + data: createUserDto, + }); + } + + // Ajouter validation, transformation, logique métier +} +``` + +### Checklist de modularisation d'un singleton node.js -> singleton NestJS + +Pour chaque service singleton node.js à migrer : + +- [ ] **Identifier** : Lister tous les singletons node.js (grep `module.exports =` ou `export default`) +- [ ] **Analyser** : Documenter l'interface publique (méthodes exposées) +- [ ] **État mutable** : Identifier les propriétés d'instance partagées +- [ ] **Dépendances** : Lister ce dont le singleton dépend +- [ ] **Créer le service** : Reproduire l'interface du singleton en NestJS +- [ ] **Tests de contrat** : Valider la parité comportementale avant/après +- [ ] **Migrer progressivement** : Remplacer les usages un par un +- [ ] **Nettoyage** : Supprimer le singleton node.js une fois inutilisé + +## 📝 Méthodologie de modularisation d'un module + +### Processus en 7 étapes + +#### 1. Analyse et préparation (Jour 1) + +```bash +# Analyser les dépendances du module +npx madge --depends my-module old-server/src + +# Lister les endpoints concernés +grep -r "router.*mymodule" old-server/src/routes + +# Identifier les tests E2E existants +grep -r "my-module" playwright/tests/e2e +``` + +**Livrables** : +- Liste des routes/endpoints à migrer +- Graphe des dépendances +- Plan de tests + +#### 2. Setup du nouveau module NestJS (Jour 1) + +**Note**: Afin d'accélérer la création des entités NestJS (services, modules, +contrôleurs, etc.), on pourra utiliser [la CLI `nest`](https://docs.nestjs.com/cli/usages#nest-generate) +pour générer des modèles (*templates*). + +```bash +# Générer le module avec NestJS CLI (par ex pour un module UserModule) +cd nest-backend +nest g module users +nest g controller users +nest g service users +``` + +**Structure créée** : +``` +src/users/ +├── users.module.ts +├── users.controller.ts +├── users.service.ts +├── dto/ +│ ├── create-user.dto.ts +│ └── update-user.dto.ts +├── entities/ +│ └── user.entity.ts +└── users.controller.spec.ts +``` + +#### 3. Modularisation de la logique métier (Jours 2-3) + +**Ordre de modularisation** : +1. DTOs et contrat de validation, suivant les besoins (certains services ont + besoin d'un DTO puisqu'ils utilisent la BDD, d'autres non) +2. Service (logique métier du module) +3. Controller (routes du module) +4. Guards et interceptors pour les contrôles qui peuvent être factorisés + +#### 4. Écriture des tests unitaires pour le nouveau service (Jour 3) + +#### 5. Adaptation des tests E2E si nécessaire (Jour 4) + +#### 6. Configuration du nginx étrangleur et déploiement (Jour 5) + +**Mise à jour de `apps/nginx-strangler/routing.conf`** : + +```nginx +# [S5-S6] Users - 2026-02-23 +location /api/xxxxx { + proxy_pass http://server-nest; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location /api/yyyyy { + proxy_pass http://server-nest; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Note: Prévoir un "blue-green" afin de permettre un passage souple et serein +entre les anciens et nouveaux services. + +**Déploiement progressif** : + +```bash +# 1. Backup de la config actuelle +cp apps/nginx-strangler/routing.conf apps/nginx-strangler/routing.conf.backup + +# 2. Mise à jour de la config +# (éditer apps/nginx-strangler/routing.conf) + +# 3. Validation de la config nginx +docker-compose exec nginx nginx -t + +# 4. Rechargement sans downtime +docker-compose exec nginx nginx -s reload + +# 5. Monitoring (surveiller les logs pendant 5-10min) +docker-compose logs -f nginx | grep users +``` + +Note: Prévoir un retour arrière en cas de problème ⚠ + +#### 7. Monitoring et validation (Jours 5-7) + +Note: Faire un point sur la métrologie existante. Envisager si besoin de +fiabiliser la stack d'observabilité, implémenter Loki et remonter des métriques +si besoin). + +**Métriques à surveiller** : + +```bash +# Logs en temps réel +docker-compose logs -f nest-api | grep users + +# Taux d'erreur +docker-compose logs nest-api | grep ERROR | grep users | wc -l + +# Temps de réponse moyen (exemple avec awk) +docker-compose logs nginx | grep "/api/users" | \ + awk '{print $NF}' | awk -F'=' '{sum+=$2; count++} END {print sum/count}' +``` + +**Validation** : +- [ ] Aucune erreur 5xx pendant 24h +- [ ] Temps de réponse comparable au code `server` (±20%) +- [ ] Tests E2E passants +- [ ] Recette utilisateurs (si applicable) + +## 🚨 Gestion des situations complexes + +### Cas 1 : Module avec beaucoup de dépendances + +**Stratégie** : +1. Migrer d'abord les dépendances (Users, Products) +2. Créer des adaptateurs temporaires (des services "passe-plat") pour les modules qui ne sont pas encore migrés +3. Tester intensivement les interactions entre modules (typage fort, tests d'intégration, E2E, etc.) + +## ✅ Checklist de fin de module + +Avant de considérer un module comme "migré" : + +- [ ] Tous les endpoints du module redirigent vers `server-nestjs` +- [ ] Tests de contrat passants (parité avec le code `server`) +- [ ] Tests E2E passants (éventuellement adaptés) +- [ ] Configuration nginx mise à jour et déployée +- [ ] Monitoring actif +- [ ] Aucune régression détectée +- [ ] Documentation technique à jour +- [ ] MODULARISATION_STATUS.md mis à jour +- [ ] Annonce de fin sur Mattermost diff --git a/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md b/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md new file mode 100644 index 0000000000..257b260401 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md @@ -0,0 +1,120 @@ +# Architecture d’un module (pattern `apps/server-nestjs/src/modules/*`) + +Les modules NestJS métier vivent dans `src/modules//` et suivent un découpage “vertical slice” avec des responsabilités explicites : **client**, **service (API publique)**, **controller service (orchestration)**, **datastore**, **utils** et **tests**. + +Exemples concrets : + +- Module GitLab : `src/modules/gitlab/` +- Module Keycloak : `src/modules/keycloak/` + +## Structure type + +```txt +src/modules// +├── .module.ts +├── .constants.ts +├── -client.service.ts +├── .service.ts +├── -datastore.service.ts +├── .utils.ts +├── -testing.utils.ts +└── *.spec.ts +``` + +## Sens des dépendances (flow recommandé) + +Objectif : un flux de dépendances lisible et sans cycles. + +```txt +.service.ts + ↙ ↘ +-client.service.ts -datastore.service.ts +``` + +Règles pratiques : + +- Le `.service.ts` est un entrypoint interne (cron, events, reconcile) et orchestre en appelant directement le `client` (et le `datastore` si nécessaire), sans dépendre du `service`. +- Le `service` contient les règles métier (décisions, transformations, validations) et s’appuie sur le `client` (et le `datastore` quand la lecture/écriture DB fait partie du cas d’usage). +- Le `client` encapsule l’accès à une API externe (initialisation + appels + erreurs bas niveau). +- Le `datastore` encapsule l’accès DB (Prisma) et expose des méthodes de lecture/écriture typées. +- Les `utils` restent “purs” (pas d’IO, pas d’injection Nest). +- Les `testing utils` centralisent les factories/fixtures pour réduire la duplication dans les tests. + +## Composants + +### `.module.ts` + +Rôle : + +- Déclare les providers, imports, exports du module. +- Exporte le `service` du module (`.service.ts`) qui constitue l’API publique. + +### `-client.service.ts` + +Rôle : + +- Adapter vers le système externe (SDK HTTP, client Keycloak, client GitLab, etc.). +- Conserver un contrat stable pour le reste du module. +- Mapper/normaliser les erreurs externes si nécessaire. + +À éviter : + +- Décisions métier (permissions, synchronisation, règles de purge) : elles vont dans `.service.ts` ou le controller service. + +### `.service.ts` + +Rôle : + +- Orchestrateur de workflows : `@Cron`, `@OnEvent`, reconcile périodique, tâches “batch”. +- Coordination entre `client` et `datastore` (sans dépendre du `service`). +- Garde-fous “safety” avant opérations destructrices (ex: suppression de groupes orphelins). + +### `-datastore.service.ts` + +Rôle : + +- Accès DB via Prisma (select/include, transactions, pagination). +- Exposition de types agrégés utiles au domaine (ex: `ProjectWithDetails`). + +À éviter : + +- Appliquer des règles métier (ex: calcul de permissions) : on garde le datastore centré persistence. + +### `.utils.ts` + +Rôle : + +- Fonctions utilitaires pures : mapping, helpers de collections, types partagés. +- Aucune dépendance Nest, aucune lecture/écriture DB, aucun appel réseau. + +### `-testing.utils.ts` + +Rôle : + +- Factories typées pour les structures fréquemment utilisées en tests. +- Support d’`overrides` pour construire rapidement des variantes. +- Centralisation des erreurs/fake responses spécifiques au module (quand utile). + +## Tests (Vitest) + +### `.service.spec.ts` + +Cible : + +- Orchestration : séquences d’appels, side-effects attendus, reconcile. + +Approche : + +- Mock du `service`, du `datastore`, et des appels externes. +- Vérification des appels effectués et des paramètres attendus. + +### `-datastore.service.spec.ts` (si présent) + +Cible : + +- Forme des requêtes Prisma, mapping de résultat, typage de l’agrégat renvoyé. + +Approche : + +- Mock de Prisma/DatabaseService, pas de logique métier. + diff --git a/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md b/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md new file mode 100644 index 0000000000..a5e6b10c1d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md @@ -0,0 +1,1276 @@ +# Cartographie des modules - Modularisation Backend + +> Derniere mise a jour : **2026-06-16** (refonte Infrastructure/Auth/Permission) + +--- + +## Objectif de ce document + +Cartographier l'ensemble des modules de l'application backend actuelle pour : +1. Identifier les dependances entre modules +2. Prioriser l'ordre de modularisation +3. Estimer la charge de chaque module +4. Faciliter la communication sur les zones en modularisation + +--- + +## Decisions structurantes + +| Decision | Choix | +|----------|-------| +| Approche migration | Bottom-up (feuilles d'abord, puis remontée vers les modules couples) | +| Contrats API | Decorateurs NestJS natifs + class-validator (abandon de ts-rest) | +| queries-index.ts | Supprime des le depart : chaque module NestJS possede ses propres queries Prisma | +| Systeme d'evenements | `@nestjs/event-emitter` (remplacement progressif de `@cpn-console/hooks`) | +| Plugins | Deviennent des modules NestJS (encapsulation puis reecriture progressive) | +| Module project | Decoupe en 3 sous-modules : ProjectCore, ProjectSecrets, ProjectBulk | +| Equipe dediee | 2 developpeurs en parallele | + +--- + +## Vue synthetique + +**75 routes metier** reparties en **18 modules metier** (dont project eclate en 3), +plus **5 couches transverses** et **7 plugins** a encapsuler. + +### Legende des statuts + +- FAIT : Deja implemente dans `server-nestjs` +- A CREER : N'existe pas encore +- A MIGRER : Existe dans `server`, a migrer vers `server-nestjs` + +--- + +## Architecture lasagnes - Etat des lieux + +``` + Couche Etat actuel server-nestjs ++--------------------------------------------------------------+ +| Couche 6 : Modules metier (controllers, DTOs) | A MIGRER ++--------------------------------------------------------------+ +| Couche 5 : Plugins (modules NestJS injectables) | A CREER ++--------------------------------------------------------------+ +| Couche 4 : Evenements (EventEmitter, remplacement hooks) | A CREER ++--------------------------------------------------------------+ +| Couche 3 : Securite (AuthService, UserGuard, ProjectGuard, Filters) | PARTIEL ++--------------------------------------------------------------+ +| Couche 2 : Core (AppService, FastifyService) | FAIT ++--------------------------------------------------------------+ +| Couche 1 : Infrastructure (Config, Logger, DB, HTTP) | FAIT ++--------------------------------------------------------------+ +``` + +Les couches 1 et 2 sont en place. La couche 3 est **partiellement** en place : +l'auth par token (`x-dso-token`) et le bearer JWT Keycloak passent désormais +par `AuthService`. La couche de permissions est structurée autour de +`InfrastructureModule -> PermissionModule -> UserModule / ProjectModule` +avec `UserGuard`, `ProjectGuard`, `UserService` et `ProjectService`. +Il reste a homogénéiser les usages des decorateurs de contexte utilisateur +(`@AuthUser()`), et le `GlobalExceptionFilter`. +Les couches 4 et 5 restent a creer. + +--- + +## Couche 0 - Socle transverse (S3, priorite absolue) + +Ces elements ne sont pas dans la matrice de scoring car ce ne sont pas des +modules metier. Ils conditionnent cependant la migration de **tous** les modules +qui suivent. Les creer en premier permet que chaque migration de module soit un +exercice de "remplissage" des couches superieures, sans reinventer +l'infrastructure a chaque fois. + +### 0a. AuthService + decorateur @AuthUser() — PARTIEL + +**Sprint** : S3 (Dev A) +**Remplace** : `authUser()` dans `apps/server/src/utils/controller.ts` + +**Etat actuel (2026-04-09)** : La partie auth par token est implementee dans +`infrastructure/auth/` : +- ✅ `AuthService` : validation token SHA256, lookup Prisma + (PersonalAccessToken + AdminToken), verification status/expiration, calcul + permissions bitwise (y compris roles globaux) +- ✅ `AuthService` : route `x-dso-token` et bearer JWT Keycloak vers + `DsoTokenService` / `KeycloakJwtService` +- ✅ `UserGuard` : applique les requirements de permission et injecte le + contexte utilisateur dans la request +- ✅ `@AuthUser()` : decorateur de parametre pour le contexte utilisateur +- ✅ `@RequireAdminPermission()` / `@RequireProjectPermission()` : metadata + de permissions admin et projet + +**Reste a faire** : +- Harmonisation des controles d'accès entre modules restants +- Chargement optionnel du projet (par slug, id, environmentId, repositoryId) +- Formalisation des types de contexte utilisateur/projet partages + +### 0b. GlobalExceptionFilter + +**Sprint** : S3 (Dev B) +**Remplace** : Les classes de `apps/server/src/utils/errors.ts` + +Mapping des erreurs : + +| Classe actuelle (server) | Equivalent NestJS | +|--------------------------|-------------------| +| `BadRequest400` | `BadRequestException` | +| `Unauthorized401` | `UnauthorizedException` | +| `Forbidden403` | `ForbiddenException` | +| `NotFound404` | `NotFoundException` | +| `Unprocessable422` | `UnprocessableEntityException` | +| `Internal500` | `InternalServerErrorException` | + +Le filtre global intercepte les `HttpException` NestJS et les formate dans le +schema de reponse attendu par le client (`{ status, body: { message } }`). + +### 0c. PermissionModule + decorateurs de permissions + +**Sprint** : S3-S4 (Dev A) +**Remplace** : `getProjectPermissions`, `AdminAuthorized`, `ProjectAuthorized` dans `utils/controller.ts` + +Fonctionnalites : +- Decorateurs `@RequireAdminPermission()` et `@RequireProjectPermission()` sur les methodes de controller +- Guard `UserGuard` qui verifie les bitmasks de permissions admin et projet +- Support des deux niveaux : `AdminRole` (global) et `ProjectRole` (scope projet) + +### 0d. ValidationPipe global + +**Sprint** : S3 (Dev B) +**Remplace** : La validation Zod des contrats ts-rest + +Configuration : +- `class-validator` + `class-transformer` en pipe global +- `whitelist: true` (strip des proprietes non declarees) +- `forbidNonWhitelisted: true` (erreur si proprietes inconnues) +- `transform: true` (transformation automatique des types) + +### 0e. EventEmitterModule + +**Sprint** : S4-S5 (Dev A) +**Remplace** : `@cpn-console/hooks` (premiere iteration) + +Mise en place de `@nestjs/event-emitter` avec : +- Evenements types pour chaque hook actuel (ProjectUpserted, ClusterDeleted, etc.) +- Pattern `@OnEvent('project.upserted')` pour les abonnements +- Pas de steps pre/check/main/post dans cette v1 : orchestration lineaire + +Ce module sera utilise par les modules metier des la Vague 2 (quand les modules +avec hooks commencent a etre migres). + +--- + +## Matrice de priorisation + +### Criteres de scoring + +| Critere | Poids | Description | +|---------|-------|-------------| +| Dependances faibles | 40% | Moins de dependances sortantes = plus facile a extraire (bottom-up) | +| Criticite | 30% | Balance risque/valeur. Un module critique et bien delimite score haut | +| Trafic | 20% | Volume raisonnable pour validation de la migration | +| Difficulte (inversee) | 10% | Complexite technique inversee (facile = score eleve) | + +### Formule + +``` +Score = (Dependances x 0.4) + (Criticite x 0.3) + (Trafic x 0.2) + (Difficulte x 0.1) + +Echelle : 1-10 pour chaque critere. +Plus le score est eleve, plus le module est prioritaire. +``` + +### Resultats ordonnes par score + +| Rang | Module | Type | Score | Vague | Sprint | Statut | +|------|--------|------|-------|-------|--------|--------| +| 1 | vault (encapsulation) | Plugin | 8.5 | V3 | S7-S8 | ✅ MIGRE | +| 2 | system (health/version) | Metier | 7.8 | V1 | S3 | ✅ MIGRE | +| 3 | system/settings | Metier | 7.4 | V1 | S3 | ✅ MIGRE | +| 4 | system/config | Metier | 7.4 | V1 | S3-S4 | | +| 5 | keycloak (encapsulation) | Plugin | 7.4 | V3 | S8 | ✅ MIGRE | +| 6 | admin-token | Metier | 7.1 | V1 | S3-S4 | | +| 7 | user/tokens | Metier | 7.1 | V1 | S3-S4 | | +| 8 | gitlab (encapsulation) | Plugin | 6.7 | V4 | S9 | ✅ MIGRE | +| 9 | service-monitor | Metier | 6.6 | V2 | S5 | | +| 10 | user | Metier | 6.6 | V2 | S5 | | +| 11 | stage | Metier | 6.5 | V2 | S5-S6 | | +| 12 | log | Metier | 6.5 | V1 | S4 | | +| 13 | zone | Metier | 6.4 | V2 | S6 | | +| 14 | environment | Metier | 6.3 | V3 | S7 | | +| 15 | admin-role | Metier | 6.1 | V2 | S5 | | +| 16 | project-core | Metier | 5.8 | V4 | S9 | | +| 17 | service-chain | Metier | 5.9 | V3 | S8 | ✅ MIGRE | +| 18 | repository | Metier | 5.8 | V3 | S7-S8 | | +| 19 | cluster | Metier | 5.7 | V3 | S7 | | +| 20 | harbor (encapsulation) | Plugin | 5.6 | V4 | S9-S10 | ✅ MIGRE | +| 21 | project-service | Metier | 5.6 | V3 | S8 | | +| 22 | argocd (encapsulation) | Plugin | 5.3 | V5 | S10-S11 | ✅ MIGRE | +| 23 | project-role | Metier | 5.2 | V3 | S7-S8 | | +| 24 | nexus (encapsulation) | Plugin | 5.1 | V4 | S10 | ✅ MIGRE | +| 25 | project-member | Metier | 4.7 | V3 | S8 | | +| 26 | project-secrets | Metier | 4.6 | V4 | S9 | | +| 27 | project-bulk | Metier | 4.2 | V4 | S9-S10 | | +| 28 | sonarqube (encapsulation) | Plugin | 4.2 | V5 | S11 | | + +**Note** : Le score brut ne dicte pas directement l'ordre de migration. +L'ordre reel est contraint par le graphe de dependances (bottom-up), les +pre-requis transverses, et la parallelisation a 2 devs. C'est pourquoi `vault` +(score 8.5) est en Vague 3 et non en Vague 1 : son encapsulation NestJS +necessite l'EventEmitter et un pattern valide sur les modules metier simples. + +--- + +## Vague 1 - Quick wins et validation du pattern (S3-S4) + +**Objectif** : Valider le pattern NestJS complet (Controller, Service, Prisma, +AuthGuard, DTOs class-validator, ExceptionFilter). Former l'equipe. Deployer +les premiers modules via Nginx. + +**Livrable fin S4** : 13 routes migrees (~17%) + +### 1. system (health/version) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.8 | +| **Sprint** | S3 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/version` - Version de l'application +- `GET /api/v1/healthz` - Health check + +**Dependances sortantes** : Aucune (lecture de variable d'env) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Module le plus simple du codebase. Ideal pour valider le pipeline complet : + creation controller NestJS, deploiement Docker, bascule Nginx +- Pas d'authentification requise sur ces routes + +**Estimation** : 0.5 jour + +--- + +### 2. system/settings — ✅ MIGRE (2026-04-28) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.4 | +| **Sprint** | S3 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/system/settings` - Liste des parametres systeme +- `PUT /api/v1/system/settings` - Mise a jour d'un parametre + +**Dependances sortantes** : Aucune (queries propres, Prisma direct) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Premiere occasion de valider le pattern CRUD NestJS avec DTOs +- Auth requise (admin uniquement) + +**Estimation** : 1 jour + +--- + +### 3. system/config + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.4 | +| **Sprint** | S3-S4 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/plugins-config` - Configuration des plugins +- `PUT /api/v1/admin/plugins-config` - Mise a jour de la configuration + +**Dependances sortantes** : Aucune (queries propres) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth admin requise +- Gestion de la configuration JSON des plugins (AdminPlugin en BDD) + +**Estimation** : 1 jour + +--- + +### 4. admin-token + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 7.1 | +| **Sprint** | S3-S4 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/admin/tokens` - Liste des tokens de service admin +- `POST /api/v1/admin/tokens` - Creation d'un token +- `DELETE /api/v1/admin/tokens/:tokenId` - Suppression d'un token + +**Dependances sortantes** : Aucune (Prisma direct, pas de queries-index) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth admin requise +- Premier module avec CRUD complet et generation de token (hash bcrypt) +- Pas de hook-wrapper utilise + +**Estimation** : 1 jour + +--- + +### 5. user/tokens + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 7.1 | +| **Sprint** | S3-S4 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/users/me/tokens` - Liste des tokens personnels +- `POST /api/v1/users/me/tokens` - Creation d'un token personnel +- `DELETE /api/v1/users/me/tokens/:tokenId` - Suppression d'un token + +**Dependances sortantes** : Aucune (Prisma direct) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth utilisateur requise (scope personnel : l'utilisateur gere ses propres tokens) +- Similaire a admin-token, bon pour paralleliser +- Hash bcrypt des tokens + +**Estimation** : 1 jour + +--- + +### 6. log + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 1 | +| **Score** | 6.5 | +| **Sprint** | S4 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/logs` - Logs d'audit du projet + +**Dependances sortantes** : `queries-index` (lecture seule via `getAllLogs`) +**Dependances entrantes** : Aucune (beaucoup de modules *ecrivent* des logs +via `addLogs`, mais ce module ne fait que *lire*) + +**Points d'attention** : +- Premiere rencontre avec queries-index. La strategie est de creer les queries + Prisma directement dans le module NestJS (pas de barrel partage) +- Auth utilisateur requise, scope projet +- Read-only : pas de hooks + +**Estimation** : 0.5 jour + +--- + +## Vague 2 - Fondations metier et premiers hooks (S5-S6) + +**Objectif** : Migrer les modules qui sont des dependances pour d'autres. +Introduction de l'EventEmitter dans les modules avec hooks. `stage`, `zone` +et `user` sont des pre-requis pour la Vague 3 (cluster, project-member). + +**Livrable fin S6** : 34 routes migrees (~45%) + +### 7. service-monitor + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 6.6 | +| **Sprint** | S5 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/services` - Sante des services (plugins) +- `GET /api/v1/services/all` - Sante complete de tous les services +- `POST /api/v1/services/refresh` - Rafraichissement des statuts + +**Dependances sortantes** : `@cpn-console/hooks` (appel direct des moniteurs de plugins) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Premier module utilisant le systeme de hooks (via les moniteurs de plugins) +- Bon premier test de l'EventEmitter : les plugins exposent leur moniteur, + le service-monitor les interroge +- Pas de queries Prisma (pas d'interaction BDD) + +**Estimation** : 1 jour + +--- + +### 8. user + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.6 | +| **Sprint** | S5 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/users` - Recherche d'utilisateurs (matching) +- `POST /api/v1/auth` - Authentification / synchronisation session +- `GET /api/v1/admin/users` - Liste complete des utilisateurs (admin) +- `PATCH /api/v1/admin/users` - Modification utilisateurs (admin) + +**Dependances sortantes** : `queries-index` (getMatchingUsers, getUsers), hooks (adminRole.upsert) +**Dependances entrantes** : `project-member` (importe `logViaSession`), `utils/controller.ts` (importe `logViaSession`, `logViaToken`) + +**Points d'attention** : +- Module critique : `logViaSession` et `logViaToken` sont consommes par le + systeme d'auth. Ces fonctions auront ete portees dans l'AuthGuard (Couche 0), + donc la dependance entrante est deja resolue +- Hooks utilises (adminRole.upsert) : necessite l'EventEmitter +- Pre-requis pour `project-member` (Vague 3) + +**Estimation** : 2 jours + +--- + +### 9. admin-role + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 6.1 | +| **Sprint** | S5 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/roles` - Liste des roles admin +- `POST /api/v1/admin/roles` - Creation d'un role +- `PATCH /api/v1/admin/roles` - Modification des roles +- `GET /api/v1/admin/roles/member-counts` - Comptage des membres par role +- `DELETE /api/v1/admin/roles/:roleId` - Suppression d'un role + +**Dependances sortantes** : `queries-index` (getAdminRoleById, listAdminRoles), hooks (adminRole.upsert, adminRole.delete) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Valide le pattern hooks + EventEmitter dans un contexte admin +- Permissions admin (bitmask) : valide `UserGuard` +- Queries a internaliser dans le module + +**Estimation** : 1.5 jours + +--- + +### 10. stage + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 6.5 | +| **Sprint** | S5-S6 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/stages` - Liste des stages (dev, staging, prod, etc.) +- `GET /api/v1/stages/:stageId/environments` - Environnements d'un stage +- `POST /api/v1/stages` - Creation d'un stage +- `PUT /api/v1/stages/:stageId` - Modification d'un stage +- `DELETE /api/v1/stages/:stageId` - Suppression d'un stage + +**Dependances sortantes** : `queries-index` (nombreuses queries stage + cluster) +**Dependances entrantes** : `cluster/business.ts` (importe directement `linkClusterToStages`) + +**Points d'attention** : +- Pre-requis critique pour `cluster` (Vague 3). La fonction `linkClusterToStages` + exportee par stage est consommee directement par cluster. Dans l'architecture + NestJS, ce sera un service injectable du StageModule exporte et importe par + ClusterModule +- Pas de hooks directs +- Beaucoup de queries a internaliser (11 fonctions dans queries-index) + +**Estimation** : 2 jours + +--- + +### 11. zone + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.4 | +| **Sprint** | S6 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/zones` - Liste des zones +- `POST /api/v1/zones` - Creation d'une zone +- `PUT /api/v1/zones/:zoneId` - Modification d'une zone +- `DELETE /api/v1/zones/:zoneId` - Suppression d'une zone + +**Dependances sortantes** : `queries-index` (addLogs), `./queries` (linkZoneToClusters), hooks (zone.upsert, zone.delete) +**Dependances entrantes** : Aucune directe (mais les hooks zone declenchent des actions dans les plugins keycloak, vault, gitlab) + +**Points d'attention** : +- Hooks utilises : zone.upsert et zone.delete declenchent des operations dans + 5 plugins (vault, keycloak, gitlab, argocd). C'est le premier module ou + l'EventEmitter sera teste en profondeur +- Interaction avec clusters via `linkZoneToClusters` +- Gestion des logs d'audit (addLogs) + +**Estimation** : 2 jours + +--- + +## Vague 3 - Modules couples + debut encapsulation plugins (S7-S8) + +**Objectif** : Migrer les modules a dependances croisees directes. En parallele, +debut d'encapsulation des plugins feuilles (vault, keycloak) dans des modules +NestJS injectables. + +**Livrable fin S8** : 66 routes migrees (~88%) + plugins vault et keycloak encapsules + +### 12. cluster + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 7 | +| **Score** | 5.7 | +| **Sprint** | S7 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/clusters` - Liste des clusters +- `GET /api/v1/clusters/:clusterId` - Details d'un cluster +- `GET /api/v1/clusters/:clusterId/usage` - Utilisation d'un cluster +- `POST /api/v1/clusters` - Creation d'un cluster +- `GET /api/v1/clusters/:clusterId/environments` - Environnements d'un cluster +- `PUT /api/v1/clusters/:clusterId` - Modification d'un cluster +- `DELETE /api/v1/clusters/:clusterId` - Suppression d'un cluster + +**Dependances sortantes** : +- `queries-index` (15+ fonctions : addLogs, createCluster, deleteCluster, + getClusterById, getClusterByLabel, getClusterDetails, getClusterEnvironments, + getProjectsByClusterId, linkClusterToProjects, linkZoneToClusters, listClusters, + listStagesByClusterId, removeClusterFromProject, removeClusterFromStage, updateCluster) +- **`stage/business.ts`** (import direct : `linkClusterToStages`) +- hooks (cluster.upsert, cluster.delete) +- `utils/business.ts` (Result monad) + +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Module le plus complexe en termes de dependances sortantes +- La dependance directe vers `stage/business` sera resolue par un import du + StageModule dans ClusterModule (stage migre en Vague 2) +- 15+ queries a internaliser dans le module +- Hooks cluster declenchent des operations dans vault et argocd +- Gestion des kubeconfigs (donnees sensibles) + +**Estimation** : 3 jours + +--- + +### 13. environment + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.3 | +| **Sprint** | S7 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/environments` - Liste des environnements +- `POST /api/v1/projects/:projectId/environments` - Creation d'un environnement +- `PUT /api/v1/environments/:environmentId` - Modification d'un environnement +- `DELETE /api/v1/environments/:environmentId` - Suppression d'un environnement + +**Dependances sortantes** : `queries-index` (addLogs, deleteEnvironment, getEnvironmentsByProjectId, initializeEnvironment, updateEnvironment), hooks +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Utilise le pattern `Result` (monad Success/Failure) de `utils/business.ts`. + A decider si on conserve ce pattern dans NestJS ou si on passe aux exceptions +- Scope projet : necessite `ProjectGuard` avec `ProjectAuthorized` +- Hooks environnement declenchent des operations dans les plugins + +**Estimation** : 2 jours + +--- + +### 14. repository + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.8 | +| **Sprint** | S7-S8 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/repositories` - Liste des depots +- `POST /api/v1/projects/:projectId/repositories/sync` - Synchronisation d'un depot +- `POST /api/v1/projects/:projectId/repositories` - Creation d'un depot +- `PUT /api/v1/repositories/:repositoryId` - Modification d'un depot +- `DELETE /api/v1/repositories/:repositoryId` - Suppression d'un depot + +**Dependances sortantes** : `queries-index` (addLogs, deleteRepository, getProjectInfosAndRepos, getProjectRepositories, initializeRepository, updateRepository), hooks (project.upsert, misc.syncRepository) +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Le hook `misc.syncRepository` est specifique a ce module (declenchement de + pipeline miroir GitLab) +- Scope projet : `ProjectGuard` requis +- Gestion des credentials de miroir (donnees sensibles dans Vault) + +**Estimation** : 2 jours + +--- + +### 15. project-role + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.2 | +| **Sprint** | S7-S8 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects/:projectId/roles` - Liste des roles projet +- `POST /api/v1/projects/:projectId/roles` - Creation d'un role +- `PATCH /api/v1/projects/:projectId/roles` - Modification des roles +- `GET /api/v1/projects/:projectId/roles/member-counts` - Comptage des membres +- `DELETE /api/v1/projects/:projectId/roles/:roleId` - Suppression d'un role + +**Dependances sortantes** : `queries-index` (deleteRole, listMembers, listRoles, updateRole), hooks (projectRole.upsert, projectRole.delete) +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Pattern similaire a admin-role (migre en Vague 2) mais scope projet +- Bitmask de permissions projet +- Hooks projectRole declenchent des operations dans Keycloak + +**Estimation** : 1.5 jours + +--- + +### 16. project-member + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 4.7 | +| **Sprint** | S8 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects/:projectId/members` - Liste des membres +- `POST /api/v1/projects/:projectId/members` - Ajout d'un membre +- `PATCH /api/v1/projects/:projectId/members` - Modification des membres +- `DELETE /api/v1/projects/:projectId/members/:memberId` - Retrait d'un membre + +**Dependances sortantes** : +- **`user/business.ts`** (import direct : `logViaSession`) +- `queries-index` (addLogs, deleteMember, listMembers, upsertMember) +- hooks (user.retrieveUserByEmail, projectMember.upsert, projectMember.delete) + +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- La dependance vers `user/business.ts` (`logViaSession`) aura ete resolue + par l'AuthGuard (Couche 0). Pas de dependance residuelle vers UserModule +- Hooks projectMember declenchent des operations dans Keycloak +- Recherche d'utilisateur par email via Keycloak (hook user.retrieveUserByEmail) + +**Estimation** : 1.5 jours + +--- + +### 17. project-service + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 5.6 | +| **Sprint** | S8 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/services` - Liste des services/plugins du projet +- `PUT /api/v1/projects/:projectId/services` - Mise a jour des services + +**Dependances sortantes** : `queries-index` (getAdminPlugin, getProjectInfosByIdOrThrow, getProjectStore, getPublicClusters, saveProjectStore) +**Dependances entrantes** : `utils/hook-wrapper.ts` (importe `ConfigRecords` type et `dbToObj`) + +**Points d'attention** : +- Ce module exporte des types et fonctions utilitaires consommes par hook-wrapper. + Dans l'architecture NestJS, ces types seront definis dans un module partage + ou dans le ProjectServiceModule exporte +- Gestion de la configuration plugin par projet (JSON store) +- Pas de hooks directs + +**Estimation** : 1 jour + +--- + +### 18. service-chain — ✅ MIGRE (2026-04-09) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.9 | +| **Sprint prevu** | S8 | +| **Migration effective** | 2026-04-09 | +| **Dev** | @stephane.trebel | + +**Routes (implementation reelle)** : +- `GET /api/v1/service-chains` - Liste des chaines de service +- `GET /api/v1/service-chains/:id` - Details d'une chaine +- `GET /api/v1/service-chains/:id/flows` - Flux de la chaine +- `POST /api/v1/service-chains/:id/retry` - Relance d'une chaine +- `POST /api/v1/service-chains/validate/:id` - Validation + +**Dependances sortantes** : API externe OpenCDS (HTTP via axios) +**Dependances entrantes** : Aucune + +**Implementation** : +- Module place dans la catégorie `ThirdPartyModules` (futur module optionnel CPin) comme envisagé +- Auth par token et bearer JWT via `AuthService` et `UserGuard` +- Validation UUID sur les parametres d'URL (`ParseUUIDPipe`) +- Validation des reponses OpenCDS via schemas Zod de `@cpn-console/shared` +- Telemetrie via `@StartActiveSpan()` (OpenTelemetry) +- Utilisation d'un service dédié `OpenCdsClientService` afin de gérer les requêtes vers le service OpenCDS externe + +**Differences avec le legacy** : +- 403 systematique si permissions insuffisantes (le legacy renvoyait `[]` sur GET /) +- Pas d'auth session Keycloak (token uniquement) +- Validation UUID stricte (400 si format invalide) +- Client OpenCDS dédié dans server-nestjs + +**Fichiers** : +- `src/cpin-module/service-chain/service-chain.*.ts` +- `src/cpin-module/service-chain/open-cds-client.*.ts` +- `src/modules/infrastructure/auth/` (AuthModule partage) + +--- + +### Plugin : vault (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 7 | +| **Score** | 8.5 | +| **Sprint** | S7-S8 | +| **Dev** | A (en parallele de cluster) | + +**Encapsulation** : Wrapper les 3 classes existantes (VaultApi, VaultProjectApi, +VaultZoneApi) dans un `VaultModule` NestJS avec des services `@Injectable()`. +La logique interne reste intacte. Le module expose `VaultService`, +`VaultProjectService`, `VaultZoneService` via DI. + +**Dependances sortantes (plugins)** : Aucune (feuille du graphe de plugins) +**Dependances entrantes (plugins)** : gitlab, keycloak, harbor, nexus, sonarqube, argocd (tous) + +**Points d'attention** : +- Plugin fondation de toute la chaine. Doit etre encapsule en premier +- Les autres plugins consomment les API classes de vault via le systeme de hooks. + Dans NestJS, ils importeront VaultModule et injecteront les services +- Configuration par variables d'environnement (a integrer dans ConfigurationService) + +**Estimation encapsulation** : 2 jours +**Estimation reecriture complete** : 5 jours (ulterieur) + +--- + +### Plugin : keycloak (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 11 (le plus grand nombre) | +| **Score** | 7.4 | +| **Sprint** | S8 | +| **Dev** | B (en parallele de project-member/service-chain) | + +**Encapsulation** : Wrapper `KeycloakProjectApi` et les fonctions de `functions.ts` +dans un `KeycloakModule` NestJS. + +**Dependances sortantes (plugins)** : vault (runtime via `payload.apis.vault`) +**Dependances entrantes (plugins)** : argocd, harbor, sonarqube + +**Points d'attention** : +- 11 hooks souscrits couvrant projets, zones, roles, membres, utilisateurs +- Le plus grand nombre de fonctions exportees (12) +- Utilise `@keycloak/keycloak-admin-client` (dependance externe) +- Necessite vault encapsule avant (depend de VaultModule) + +**Estimation encapsulation** : 2 jours +**Estimation reecriture complete** : 5-7 jours (ulterieur) + +--- + +## Vague 4 - Module project (decoupe) + plugins intermediaires (S9-S10) + +**Objectif** : Migrer le coeur metier (project) en 3 sous-modules independants. +Encapsuler les plugins intermediaires. + +**Livrable fin S10** : 75 routes migrees (100% des routes metier) + plugins +vault, keycloak, gitlab, harbor, nexus encapsules + +### 19. project-core + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.8 | +| **Sprint** | S9 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects` - Liste des projets +- `GET /api/v1/projects/:projectId` - Details d'un projet +- `POST /api/v1/projects` - Creation d'un projet +- `PUT /api/v1/projects/:projectId` - Modification d'un projet +- `DELETE /api/v1/projects/:projectId` - Archivage d'un projet + +**Dependances sortantes** : `queries-index` (addLogs, getProjectOrThrow, getSlugs, initializeProject, listProjects, lockProject, updateProject), hooks (project.upsert, project.delete, projectRole.upsert) +**Dependances entrantes** : Presque tous les modules a scope projet + +**Points d'attention** : +- Coeur absolu du systeme. Toute erreur ici impacte tout +- Le CRUD projet declenche des hooks vers TOUS les plugins (upsertProject, + deleteProject). C'est le plus gros consommateur de l'EventEmitter +- Gestion du statut projet (created/failed/warning/archived) via state machine +- Verrouillage projet (`lockProject`) pendant les operations longues +- La creation declenche : Keycloak (groupes), Vault (KV store), GitLab (groupe + + repos), Harbor (registre), Nexus (repos), SonarQube (projets), ArgoCD (config) + +**Estimation** : 3 jours + +--- + +### 20. project-secrets + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 1 | +| **Score** | 4.6 | +| **Sprint** | S9 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/secrets` - Secrets du projet + +**Dependances sortantes** : hooks (project.getSecrets) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Appelle le hook `getProjectSecrets` qui interroge chaque plugin pour ses + secrets (Vault, GitLab mirror token, Harbor robot, Nexus credentials, etc.) +- Donnees sensibles : attention au logging et a la serialisation +- Necessite que les plugins soient encapsules pour fonctionner correctement + +**Estimation** : 1 jour + +--- + +### 21. project-bulk + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 4.2 | +| **Sprint** | S9-S10 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/projects/data` - Export bulk des donnees projets +- `POST /api/v1/admin/projects/bulk` - Action bulk sur les projets +- `POST /api/v1/projects/:projectId/replay-hooks` - Rejeu des hooks d'un projet + +**Dependances sortantes** : `queries-index` (getAllProjectsDataForExport, deleteAllEnvironmentForProject, deleteAllRepositoryForProject), hooks (project.upsert pour replay) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Routes admin uniquement +- Le bulk action itere sur les projets avec une limite de parallelisme + (`parallelBulkLimit` dans la config). Attention aux performances +- Le replay-hooks re-execute toute la chaine de hooks pour un projet +- L'export data peut generer de gros payloads + +**Estimation** : 2 jours + +--- + +### Plugin : gitlab (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 8 | +| **Score** | 6.7 | +| **Sprint** | S9 | +| **Dev** | B | + +**Encapsulation** : Wrapper les 3 classes (GitlabApi, GitlabZoneApi, +GitlabProjectApi) dans un `GitlabModule` NestJS. + +**Dependances sortantes (plugins)** : vault (VaultProjectApi pour secrets) +**Dependances entrantes (plugins)** : argocd, nexus, sonarqube + +**Points d'attention** : +- Plugin le plus complexe (13 fichiers source, classe de 570 lignes) +- Utilise `@gitbeaker/core` et `@gitbeaker/rest` (dependances externes) +- Systeme de "pending commits" (commits bufferises puis envoyes en batch) +- Necessite vault encapsule avant + +**Estimation encapsulation** : 2-3 jours +**Estimation reecriture complete** : 7-10 jours (ulterieur) + +--- + +### Plugin : harbor (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 3 | +| **Score** | 5.6 | +| **Sprint** | S9-S10 | +| **Dev** | A | + +**Encapsulation** : Wrapper dans un `HarborModule` NestJS. + +**Dependances sortantes (plugins)** : keycloak (getProjectGroupPath), vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- API generee (fichier `api/Api.ts`) +- Gestion des quotas (parsing de tailles humaines "100MB", "1.2GB") +- Necessite keycloak et vault encapsules avant + +**Estimation encapsulation** : 1.5 jours + +--- + +### Plugin : nexus (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 3 | +| **Score** | 5.1 | +| **Sprint** | S10 | +| **Dev** | B | + +**Encapsulation** : Wrapper dans un `NexusModule` NestJS. + +**Dependances sortantes (plugins)** : vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Gestion Maven + NPM (repos group/hosted/snapshot) +- Configuration riche (activation par techno) +- Necessite vault encapsule avant + +**Estimation encapsulation** : 1.5 jours + +--- + +## Vague 5 - Plugins terminaux + finalisation (S10-S12) + +**Objectif** : Encapsuler les derniers plugins. Demarrer le nettoyage. + +**Livrable fin S11** : Tous les plugins encapsules en modules NestJS + +### Plugin : argocd (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 5 | +| **Score** | 5.3 | +| **Sprint** | S10-S11 | +| **Dev** | A | + +**Encapsulation** : Wrapper dans un `ArgoCDModule` NestJS. + +**Dependances sortantes (plugins)** : gitlab (GitlabProjectApi), keycloak (KeycloakProjectApi), vault (VaultProjectApi, VaultZoneApi) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Le plugin le plus couple (depend de 3 autres plugins) +- Generation YAML complexe (value files pour Helm) +- Necessite gitlab, keycloak et vault encapsules avant +- Dernier plugin avec dependances, donc naturellement parmi les derniers + +**Estimation encapsulation** : 2 jours + +--- + +### Plugin : sonarqube (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 2 | +| **Score** | 4.2 | +| **Sprint** | S11 | +| **Dev** | B | + +**Encapsulation** : Wrapper dans un `SonarQubeModule` NestJS. + +**Dependances sortantes (plugins)** : gitlab (GitlabProjectApi pour CI vars), keycloak (getProjectGroupPath), vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Depend de 3 autres plugins (comme argocd) +- Interaction avec l'API SonarQube + ecriture de variables CI dans GitLab +- Necessite gitlab, keycloak et vault encapsules avant + +**Estimation encapsulation** : 1.5 jours + +--- + +## Vague 6 - Nettoyage (S11-S12) + +- Suppression du reverse proxy Nginx (tout pointe vers `server-nestjs`) +- Suppression du code `apps/server` +- Renommage `server-nestjs` en `server` +- Suppression de `@cpn-console/hooks` (remplace par EventEmitter NestJS) +- Suppression de `ts-rest` cote server (remplace par decorateurs NestJS) +- Mise a jour de tous les `docker-compose` et scripts PNPM/Bash +- Reecriture progressive des plugins (au-dela de l'encapsulation) -- planifiee + dans des iterations futures + +--- + +## Graphe de dependances + +### Graphe actuel (genere par Madge) + +![Graphe des dependances de `server`](./graph-dependances-server-avant.png) + +Commande : + +``` +npx madge --image dependency-graph.png --extensions js,ts apps/server/src +``` + +### Graphe cible des modules NestJS + +```mermaid +flowchart TD + subgraph Transverse["Couche 0 : Socle transverse"] + AuthGuard["AuthService + @AuthUser()"] + ExceptionFilter["GlobalExceptionFilter"] + PermissionsGuard["UserGuard + ProjectGuard"] + ValidationPipe["ValidationPipe global"] + EventEmitter["EventEmitterModule"] + end + + subgraph Infrastructure["Couche 1-2 : Infrastructure (FAIT)"] + ConfigModule["ConfigurationModule"] + LoggerModule["LoggerModule"] + DatabaseService["DatabaseService (Prisma)"] + AppService["AppService"] + FastifyService["FastifyService"] + end + + subgraph Feuilles["Vague 1 : Modules feuilles"] + SystemModule["SystemModule (health, version)"] + SettingsModule["SystemSettingsModule"] + ConfigPluginModule["SystemConfigModule"] + AdminTokenModule["AdminTokenModule"] + UserTokensModule["UserTokensModule"] + LogModule["LogModule"] + end + + subgraph Fondations["Vague 2 : Fondations metier"] + ServiceMonitorModule["ServiceMonitorModule"] + UserModule["UserModule"] + AdminRoleModule["AdminRoleModule"] + StageModule["StageModule"] + ZoneModule["ZoneModule"] + end + + subgraph Couples["Vague 3 : Modules couples"] + ClusterModule["ClusterModule"] + EnvironmentModule["EnvironmentModule"] + RepositoryModule["RepositoryModule"] + ProjectRoleModule["ProjectRoleModule"] + ProjectMemberModule["ProjectMemberModule"] + ProjectServiceModule["ProjectServiceModule"] + ServiceChainModule["ServiceChainModule"] + end + + subgraph ProjectGroup["Vague 4 : Project (decoupe)"] + ProjectCoreModule["ProjectCoreModule"] + ProjectSecretsModule["ProjectSecretsModule"] + ProjectBulkModule["ProjectBulkModule"] + end + + subgraph Plugins["Vague 3-5 : Plugins NestJS"] + VaultModule["VaultModule"] + KeycloakModule["KeycloakModule"] + GitlabModule["GitlabModule"] + HarborModule["HarborModule"] + NexusModule["NexusModule"] + ArgoCDModule["ArgoCDModule"] + SonarQubeModule["SonarQubeModule"] + end + + %% Dependances infrastructure + Feuilles --> AuthGuard + Feuilles --> ExceptionFilter + Feuilles --> ValidationPipe + Fondations --> EventEmitter + Fondations --> PermissionsGuard + + %% Dependances inter-modules metier + ClusterModule --> StageModule + ProjectMemberModule -.-> UserModule + + %% Dependances plugins + KeycloakModule --> VaultModule + GitlabModule --> VaultModule + HarborModule --> KeycloakModule + HarborModule --> VaultModule + NexusModule --> VaultModule + ArgoCDModule --> GitlabModule + ArgoCDModule --> KeycloakModule + ArgoCDModule --> VaultModule + SonarQubeModule --> GitlabModule + SonarQubeModule --> KeycloakModule + SonarQubeModule --> VaultModule + + %% Evenements + ProjectCoreModule -.->|evenements| Plugins + ZoneModule -.->|evenements| Plugins + ClusterModule -.->|evenements| Plugins +``` + +### Dependances directes inter-modules (code actuel) + +| Module source | Importe depuis | Fonction | Resolution NestJS | +|---------------|----------------|----------|-------------------| +| cluster | stage/business | `linkClusterToStages` | ClusterModule importe StageModule | +| project-member | user/business | `logViaSession` | Resolu par AuthService / AuthUser (Couche 0) | +| utils/controller | user/business | `logViaSession`, `logViaToken` | Resolu par AuthService / AuthUser (Couche 0) | +| utils/hook-wrapper | project-service/business | `ConfigRecords`, `dbToObj` | Types partages ou ProjectServiceModule exporte | + +### Dependances inter-plugins + +``` +vault (feuille -- aucune dependance plugin) + | + +--- keycloak (vault en runtime) + | | + | +--- harbor (keycloak + vault) + | +--- argocd (gitlab + keycloak + vault) + | +--- sonarqube (gitlab + keycloak + vault) + | + +--- gitlab (vault) + | + +--- nexus (vault, gitlab en devDependency) + +--- argocd (gitlab + keycloak + vault) + +--- sonarqube (gitlab + keycloak + vault) +``` + +--- + +## Modules transverses + +### Middlewares globaux + +| Middleware | Statut | Module NestJS | +|-----------|--------|---------------| +| CORS | A configurer | AppService (Fastify) | +| Helmet (securite) | FAIT | AppService | +| Cookie + Session | FAIT | AppService | +| Body parser | FAIT | Fastify (natif) | +| Compression | A evaluer | Fastify compression plugin | +| Logging (requetes) | FAIT | LoggerModule (nestjs-pino) | +| Configuration (.env) | FAIT | ConfigurationModule | +| Validation (DTOs) | A CREER | ValidationPipe global (Couche 0) | + +### Services partages + +| Service | Statut | Module NestJS | +|---------|--------|---------------| +| PrismaService (DB) | FAIT | DatabaseService | +| LoggerService | FAIT | LoggerModule | +| ConfigService (env) | FAIT | ConfigurationModule | +| HttpClientService | STUB (vide) | A implementer dans InfrastructureModule | +| ServerService (ts-rest) | STUB | A supprimer (abandon ts-rest) | + +--- + +## Checklist de cartographie + +### Sprint 1 + +- [x] Installer outils d'analyse (Madge) +- [x] Generer graphe de dependances automatique +- [x] Lister toutes les routes (75 routes identifiees) +- [x] Identifier les modules metier principaux (16 domaines + 7 plugins) +- [x] Premiere version de cette cartographie + +### Sprint 2 + +- [x] Affiner les dependances inter-modules +- [x] Calculer les scores de priorisation (28 unites scorees) +- [x] Definir l'ordre de modularisation (6 vagues, bottom-up) +- [x] Documenter les points d'attention par module +- [x] Finaliser les estimations de charge +- [x] Completer ce fichier +- [ ] Valider l'ordre de modularisation avec l'equipe + +--- + +## Estimations de charge par vague + +| Vague | Sprint | Routes | Modules | Charge estimee (j/p) | Objectif | +|-------|--------|--------|---------|----------------------|----------| +| V0 | S3 | 0 | 5 transverses | 5 j/p | Socle (Guards, Filters, Pipes, EventEmitter) | +| V1 | S3-S4 | 13 | 6 | 5 j/p | Quick wins, validation pattern | +| V2 | S5-S6 | 21 | 5 | 9 j/p | Fondations metier, premiers hooks | +| V3 | S7-S8 | 32 | 7 metier + 2 plugins | 17 j/p | Modules couples, vault + keycloak | +| V4 | S9-S10 | 9 | 3 metier + 3 plugins | 13 j/p | Project decoupe, gitlab + harbor + nexus | +| V5 | S10-S11 | 0 | 2 plugins | 3.5 j/p | argocd + sonarqube | +| V6 | S11-S12 | 0 | 0 | 5 j/p | Nettoyage, suppression server | +| **Total** | **S3-S12** | **75** | **28** | **~57.5 j/p** | | + +Avec 2 developpeurs dedies : **~29 jours calendaires** (soit ~6 sprints d'une +semaine), ce qui s'inscrit dans la fenetre S3-S12 (10 sprints disponibles). +La marge de 4 sprints absorbe les imprevisibles, la montee en competence +NestJS, et les eventuelles sessions de pair programming sur les modules +complexes. + +--- + +## Propagation de la modularisation + +Mettre a jour dans ce document : +- Le chemin parcouru par un module migre (quel environnement, a quel + moment/jalon, et avec quel resultat succes/echec) +- Tracer les manques de tests d'integration ou E2E sur un module suivant les + retours effectues +- Expliciter les regles de suivi dans la metrologie de la migration d'un module + (avant/apres, payload et requetes concernees, etc.) + +--- + +## Contact + +**Responsable cartographie** : @stephane.trebel +**Questions** : #backend-modularisation sur Mattermost + +--- + +**Version** : 2.0 +**Derniere mise a jour** : 2026-02-23 +**Prochaine revision majeure** : Fin S4 (premier bilan post-Vague 1) diff --git a/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md b/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md new file mode 100644 index 0000000000..fe899d0bdf --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md @@ -0,0 +1,214 @@ +# État de la modularisation Backend → NestJS + +> 📋 **Ce fichier est mis à jour en temps réel** +> Dernière mise à jour : **2026-07-23** + +--- + +## 🎯 Progression globale + +![Progress](https://progress-bar.dev/7/?title=modularisation&width=400) + +**~60%** complété (16/18 modules métier migrés, 45/75 routes) + +--- + +## 📊 Vue d'ensemble + +| Statut | Nombre de modules | % du total | +|--------|-------------------|------------| +| ✅ Migré | 16 | ~89% | +| 🚧 En cours | 0 | 0% | +| 📅 Planifié | 2 | ~11% | +| ⏳ En attente de cartographie | 0 | 0% | + +--- + +## ✅ Modules migrés + +### ServiceChain (OpenCDS) — migré le 2026-04-09 + +Module proxy HTTP vers l'API externe OpenCDS (gestion des chaînes de service +réseau). Migré en avance de phase par rapport au planning initial (prévu V3/S8), +profitant de son isolement complet vis-à-vis du reste du codebase. + +- **Routes** : 5 (`/api/v1/service-chains/...`) +- **Auth** : `AuthService` unifie `x-dso-token` et bearer JWT Keycloak +- **Nginx** : Bascule effectuée dans `nginx-strangler/conf.d/routing.conf` +- **Tests** : Controller + Service couverts (Vitest) +- **Différences avec le legacy** : + - 403 systématique si permissions insuffisantes (le legacy renvoyait `[]` sur GET /) + - Validation UUID sur les paramètres d'URL (400 si format invalide) + +| Méthode | Route | Permission | +|---------|-------|------------| +| `GET` | `/api/v1/service-chains` | `ListSystem` | +| `GET` | `/api/v1/service-chains/:id` | `ListSystem` | +| `GET` | `/api/v1/service-chains/:id/flows` | `ListSystem` | +| `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | +| `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | + +### Infrastructure transverse déployée + +En support de cette migration, les éléments d'infrastructure suivants ont été +créés : + +- **AuthModule** (`infrastructure/auth/`) : `AuthService` (validation token + SHA256 via Prisma + bearer JWT Keycloak), `DsoTokenModule`, + `KeycloakJwtModule` et décorateur `@AuthUser()` +- **PermissionModule** (`infrastructure/permission/`) : `UserModule` + (`UserGuard`, `UserService`, `UserPolicy`) + `ProjectModule` + (`ProjectGuard`, `ProjectLoaderService`, `ProjectService`, `ProjectPolicy`) +- **Nginx strangler** : Reverse proxy configuré pour router les routes migrées + vers server-nestjs, le reste vers le legacy +- **Docker** : Build order corrigé (shared avant server-nestjs) + +> **Limitation connue** : l'auth par session Keycloak et le flux bearer JWT ont +> désormais la même entrée publique (`AuthService.authenticate(request, ...)`), +> mais les usages côté contrôleurs restent encore à homogénéiser au fil des +> modules migrés. + +--- + +## 🚧 En cours de modularisation + +### Aucune modularisation en cours + +--- + +## 📅 Modules planifiés + +> Ces informations seront affinées après la cartographie (fin S2) + +### Sprint 3-4 (27 janvier - 9 février) +- **Module** : Auth +- **Responsable** : @stephane.trebel +- **Routes** : ~5 +- **Criticité** : 🔴 Haute +- **Statut** : 📅 Planifié + +### Sprint 5-6 (10-23 février) +- **Module** : Users +- **Responsable** : TBD +- **Routes** : ~10 +- **Criticité** : 🔴 Haute +- **Statut** : 📅 Planifié + +### Sprint 7-8 (24 février - 9 mars) +- **Module** : À définir (selon cartographie) +- **Responsable** : TBD +- **Routes** : TBD +- **Statut** : 📅 Planifié + +### Sprint 9-10 (10-23 mars) +- **Module** : À définir (selon cartographie) +- **Responsable** : TBD +- **Routes** : TBD +- **Statut** : 📅 Planifié + +### Sprint 11-12 (24 mars - 6 avril) +- **Modules** : Finalisation + cleanup +- **Responsable** : Équipe +- **Objectif** : Suppression du legacy + +--- + +## 🚫 Zones en feature freeze + +### Aucune zone gelée actuellement + +**Règle** : Quand un module passe en status 🚧 (En cours), il est automatiquement en feature freeze. + +**Que faire si vous devez travailler sur une zone gelée ?** +1. Vérifier l'urgence réelle (Critique / Importante / Normale) +2. Consulter la [matrice de décision](04-COMMUNICATION-PLAN.md#matrice-de-décision-pour-développeurs) +3. Coordonner avec le responsable de la modularisation +4. Annoncer sur #backend-modularisation + +--- + +## 📈 Métriques de qualité + +### Couverture de tests + +| Type | Initial | Actuel | Objectif | +|------|---------|--------|----------| +| E2E Playwright | 33% | 33% | 50% | +| Unitaires Vitest | ? | ? | 70% | +| Tests de contrat | 0% | 0% | 100% | + +### Routes par statut + +- **Total** : ~75 routes métier +- **Migrés** : 45 (~60%) +- **En cours** : 0 (0%) +- **Restants** : ~30 (~40%) + +--- + +## 🗓️ Dates clés + +| Date | Événement | +|------|-----------| +| 07/01/2026 | Début du projet (S1) | +| 26/01/2026 | Fin de la cartographie (S2) | +| 27/01/2026 | Début modularisation Auth (S3) | +| 09/02/2026 | Fin modularisation Auth (S4) - 20% complété | +| 09/03/2026 | Point mi-parcours - 60% complété | +| 26/03/2026 | Migration ServiceChain (OpenCDS) finalisée — 1er module métier migré | +| 06/04/2026 | Fin de modularisation - 100% complété | + +--- + +## 📞 Contacts & Communication + +### Canaux +- **Slack** : #backend-modularisation +- **Meeting hebdo** : Vendredi 16h (30min) +- **Lead technique** : @stephane.trebel + +### Besoin d'aide ? +- **Question technique** : Poser sur #backend-modularisation +- **Conflit de développement** : Contacter @stephane.trebel +- **Urgence production** : Suivre la procédure de rollback (voir [01-TECHNICAL-SETUP.md](01-TECHNICAL-SETUP.md)) + +--- + +## 📚 Documentation + +- [00-OVERVIEW.md](00-OVERVIEW.md) - Vue d'ensemble du projet +- [01-TECHNICAL-SETUP.md](01-TECHNICAL-SETUP.md) - Configuration technique +- [02-modularisation-STRATEGY.md](02-modularisation-STRATEGY.md) - Stratégie de modularisation +- [03-PLANNING.md](03-PLANNING.md) - Planning détaillé 12 sprints +- [04-COMMUNICATION-PLAN.md](04-COMMUNICATION-PLAN.md) - Plan de communication +- [05-TESTING-STRATEGY.md](05-TESTING-STRATEGY.md) - Stratégie de tests +- [modularisation_MAP.md](modularisation_MAP.md) - Cartographie des modules + +--- + +## 🔄 Historique des changements + +### 2026-04-09 +- ✅ Migration du module **ServiceChain (OpenCDS)** — 5 routes, proxy HTTP vers API externe +- ✅ Création de l'**AuthModule** (infrastructure/auth/) : auth par token `x-dso-token` +- ✅ Configuration **nginx-strangler** pour les routes service-chain +- ✅ Fix Docker : build order shared → server-nestjs +- ✅ Mise à jour de ce fichier de suivi + +### 2026-01-07 (S1) +- ✅ Création du fichier de suivi +- ✅ Initialisation de la documentation +- ✅ Kickoff du projet + +--- + +## 📝 Notes + +> Ce fichier sera mis à jour régulièrement (minimum une fois par sprint, idéalement en temps réel). +> Toute l'équipe peut consulter ce fichier pour connaître l'état actuel de la modularisation. +> Pour proposer des modifications ou signaler des incohérences, utiliser #backend-modularisation. + +--- + +**Version du fichier** : 1.1 +**Responsable de mise à jour** : Lead technique backend diff --git a/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md b/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md new file mode 100644 index 0000000000..811aa04f0c --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md @@ -0,0 +1,120 @@ +# Documentation de Modularisation du Backend de Console (apps/server) vers NestJS + +Ce dossier contient toute la documentation nécessaire pour mener à bien la modularisation du backend Node.js vers NestJS. + +## 📁 Structure de la documentation + +### Documents principaux (à lire dans l'ordre) + +1. **00-OVERVIEW.md** - Vue d'ensemble du projet de modularisation + - Objectifs et contexte + - Stratégie "Strangler Pattern" + - Principes directeurs + - Métriques de succès + +2. **01-TECHNICAL-SETUP.md** - Configuration technique détaillée + - Configuration Nginx (reverse proxy) qui va progressivement "étrangler" `server` au profit de `server-nestjs` + - Gestion Prisma/BDD partagée entre `server` et `server-nestjs` + - Déploiement `docker compose` + +3. **02-MODULARISATION-STRATEGY.md** - Stratégie et méthodologie + - Modularisation par slices verticales + - Cartographie et priorisation + - Gestion singletons → Injection de dépendances + - Méthodologie en 7 étapes par module + - Architecture Decision Records (ADR) + +4. **03-PLANNING.md** - Planning détaillé sur les prochains sprints + - Découpage sprint par sprint + - Sprints 1-2 : Fondations + - Sprints 3-4 : Modules transverses de `server` (logger, etc.) + - Sprints 5-6 : Modules applicatifs (Zones, Clusters, etc.) + - Sprints 7-10 : Modules métier (les fameux plugins) + - Sprints 11-12 : Finalisation + - Métriques et KPIs + +5. **04-COMMUNICATION-PLAN.md** - Plan de communication équipe + - Canaux de communication (Mattermost, fichiers, réunions) + - Gestion des zones en modularisation + - Modèles d'annonces concernant la modularisation + - Matrice de décision pour développeurs + - Procédure en cas de conflit + +6. **05-TESTING-STRATEGY.md** - Stratégie de tests + - Tests de contrat (nouveaux, critiques) + - Tests E2E Playwright + - Tests unitaires Vitest + - Tests d'intégration + - Workflow de tests pendant modularisation + - Intégration CI/CD + +### Fichiers vivants (mis à jour régulièrement) + +7. **MODULARISATION_STATUT.md** - Suivi en temps réel de l'avancement + - Progression globale + - Modules migrés + - Modules en cours + - Zones en "feature freeze" du fait de leur modularisation en cours 🚧 + - Dates clés + +8. **MODULARISATION_CARTOGRAPHIE.md** - Cartographie des modules + - Liste de tous les modules avec métadonnées + - Graphe de dépendances + - Scoring et priorisation des modules + - Estimation de charge + +## 🚀 Par où commencer ? + +### Si vous êtes le lead technique de la modularisation + +1. Lire **00-OVERVIEW.md** pour la vue d'ensemble +2. Consulter **01-TECHNICAL-SETUP.md** pour préparer l'infrastructure +3. Étudier **02-MODULARISATION-STRATEGY.md** pour la méthodologie +4. Planifier avec **03-PLANNING.md** +5. Organiser la communication avec **04-COMMUNICATION-PLAN.md** +6. Mettre en place les tests avec **05-TESTING-STRATEGY.md** +7. Compléter **MODULARISATION_MAP.md** lors de la cartographie (S1-S2) +8. Maintenir **MODULARISATION_STATUT.md** à jour tout au long de la modularisation + +### Si vous êtes développeur dans l'équipe + +1. Lire **00-OVERVIEW.md** pour comprendre le projet +2. Consulter **MODULARISATION_STATUT.md** AVANT de traiter un ticket, pour éviter de toucher à une zone en cours de modularisation +3. Suivre le canal Mattermost #backend-modularisation qui sera créé dans cet objectif +4. Respecter les zones en modularisation (🚧) +5. Consulter **04-COMMUNICATION-PLAN.md** en cas de conflit + +## 📊 Contexte du projet + +- **Durée** : 3 mois (12 semaines) +- **Équipe** : 5 développeurs +- **Endpoints** : ~100 +- **Approche** : Strangler Fig Pattern (modularisation progressive) +- **Stack cible** : NestJS + Prisma + Keycloak OIDC + +## 🎯 Objectifs + +- ✅ 100% des endpoints migrés sur NestJS +- ✅ 0 régression fonctionnelle +- ✅ Couverture tests E2E ≥ 50% +- ✅ Équipe autonome sur NestJS +- ✅ Documentation complète + +## 📞 Contact + +- **Lead technique** : @stephane.trebel +- **Canal Mattermost** : #backend-modularisation +- **Réunion hebdo** : Jeudi 10h (30min) + +## 📚 Ressources externes + +- [NestJS Documentation](https://docs.nestjs.com/) +- [Prisma Documentation](https://www.prisma.io/docs) +- [Playwright Documentation](https://playwright.dev/) +- [Vitest Documentation](https://vitest.dev/) + +--- + +**Bonne modularisation ! 🚀** + +*Version 1.0 - Dernière mise à jour : 2026-01-07* diff --git a/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png b/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png new file mode 100644 index 0000000000..569dd60e19 Binary files /dev/null and b/.jjconflict-base-0/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png differ diff --git a/.jjconflict-base-0/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md b/.jjconflict-base-0/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md new file mode 100644 index 0000000000..b7ba2c4258 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md @@ -0,0 +1,271 @@ +# Plan : Nginx "étrangleur" pour la transition `server` → `server-nestjs` + +> Ticket de référence : [#1885](https://github.com/cloud-pi-native/console/issues/1885) +> PR en cours : [#1951](https://github.com/cloud-pi-native/console/pull/1951) — branche `add-server-nextjs-to-local-dev` +> Dernière mise à jour : 2026-02-26 + +--- + +## Contexte et état des lieux + +| Élément | État | +|---|---| +| `server` (Fastify) | Port `8080` interne, exposé `4001` sur l'hôte | +| Nginx actuel | Embarqué dans l'image `client`, proxifie `/api` + `/swagger-ui` vers `server:8080` — upstream changé vers `nginx-strangler:8080` | +| `server-nestjs` | Dockerisé (port `3001`), ajouté dans tous les docker-compose | +| `nginx-strangler` | Service dédié créé dans `apps/nginx-strangler/`, fallback total sur `server` legacy, exposé sur le port `4000` pour se "substituer" à `server` | +| Envs couverts | Local dev, docker-compose (dev/ci/integ) | + +--- + +## Architecture cible + +``` +[Navigateur / API client] + │ + ▼ +┌────────────────────┐ +│ Client (8080) │ ← nginx embarqué dans l'image client +│ nginx "front" │ SPA + proxy /api → nginx-strangler +└────────┬───────────┘ + │ proxy_pass http://nginx-strangler:8080 + ▼ +┌────────────────────┐ +│ nginx-strangler │ ← service dédié (port 8080 interne, non-root) +│ (routing as-code) │ +└──────┬─────────────┘ + │ + ├── /api/[routes migrées] ──→ server-nestjs:3001 + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +**Principe clé** : le nginx client existant reste inchangé dans son rôle SPA, mais son upstream `api` est redirigé vers `nginx-strangler` plutôt que directement vers `server`. Le `nginx-strangler` devient l'unique point de routage des appels API. + +--- + +## Décisions structurantes + +| Décision | Choix | +|---|---| +| Emplacement du nginx-strangler | Répertoire `apps/nginx-strangler/` à la racine du dépôt | +| Port interne de `nginx-strangler` | `8080` (non-root, Trivy DS002) | +| Port interne de `server-nestjs` | `3001` (distinct du legacy sur `8080`) | +| Envs PAX / MinInt | Couverts via docker-compose uniquement dans ce ticket | +| Config nginx | `envsubst` pour substituer les upstreams selon l'environnement | +| Rollback | Commenter une `location` dans `routing.conf` + `nginx -s reload` | +| Sécurité image nginx | `USER nginx`, `HEALTHCHECK`, `chown` sur les répertoires système nginx | +| Sécurité image server-nestjs | `USER node`, `HEALTHCHECK` sur le stage `prod` (Trivy DS026) | + +--- + +## Découvertes en cours de réalisation + +- Le nginx existant dans l'image `client` proxifiait directement vers `server:8080` — upstream changé vers `nginx-strangler:8080` +- `server-nestjs/src/main.ts` utilisait `process.env.PORT ?? 0` au lieu de `ConfigurationService.port` — corrigé +- Le schéma Prisma de `server-nestjs` est multi-fichiers dans `src/prisma/schema/` — `prisma generate` doit pointer sur le dossier, pas sur `schema.prisma` +- En CI (`job-lint.yml`), `nginx -t` échoue si `apt-get update -qq` n'est pas exécuté avant l'install, et si les chemins `pid`/`error_log`/`access_log` ne sont pas patchés vers `/tmp/` (runner non-root) +- En CI (`job-playwright.yml`), les images `server-nestjs:ci` et `nginx-strangler:ci` doivent être buildées localement avant le `docker compose up --no-build` (même pattern que `opencds-mockoon`) + +--- + +## Phase 1 — Socle nginx-strangler ✅ + +### Tâche 1.1 : Créer `apps/nginx-strangler/` ✅ + +``` +apps/nginx-strangler/ +├── Dockerfile # image nginx:1.27-alpine, USER nginx, HEALTHCHECK, EXPOSE 8080, envsubst entrypoint +├── nginx.conf # directives globales (worker_processes, logs, include conf.d/) +├── conf.d/ +│ ├── routing.conf # fichier "vivant" des routes (commenté par vague de migration) +│ └── routing.local.conf.example # template documentaire pour le mode local natif +└── README.md # procédures opérationnelles (déploiement, rollback, ajout de route, dev local) +``` + +### Tâche 1.2 : Contenu initial de `routing.conf` ✅ + +Le fichier utilise des variables d'environnement substituées via `envsubst` pour permettre la réutilisation de la même image dans tous les contextes (Docker, local natif) : + +```nginx +upstream server-legacy { + server ${LEGACY_UPSTREAM}; +} + +upstream server-nestjs { + server ${NESTJS_UPSTREAM}; +} + +server { + listen 8080; + + # ── Routes migrées vers NestJS ────────────────────────────────────────── + # Format : # [Vague X - Module] AAAA-MM-JJ + # (vide au démarrage — toutes les routes sont en fallback sur server-legacy) + + # ── Fallback : tout le reste vers le server legacy ─────────────────────── + location /api/ { + proxy_pass http://server-legacy; + ... + } + + location /swagger-ui { + proxy_pass http://server-legacy; + ... + } +} +``` + +Exemple de bascule future (à ajouter au fil des vagues) : +```nginx +# [Vague 1 - system/health] 2026-03-XX +location = /api/v1/system/health { + proxy_pass http://server-nestjs; + ... +} +``` + +--- + +## Phase 2 — Dockerisation de `server-nestjs` ✅ + +### Tâche 2.1 : Fix du port dans `main.ts` ✅ + +`apps/server-nestjs/src/main.ts` utilisait `process.env.PORT ?? 0` — câblé sur `ConfigurationService.port`. + +### Tâche 2.2 : Créer `apps/server-nestjs/Dockerfile` ✅ + +Multi-stage : `base` → `deps` → `build` → `prod`. `prisma generate --schema=src/prisma/schema` (dossier multi-fichiers). `USER node`, `HEALTHCHECK`, `EXPOSE 3001`. + +### Tâche 2.3 : `apps/server-nestjs/.env.docker-example` ✅ + +`SERVER_PORT=3001` présent. + +--- + +## Phase 3 — Mise à jour des docker-compose ✅ + +### Tâche 3.1 : `docker/docker-compose.dev.yml` ✅ + +Services `server-nestjs` (avec Docker Compose Watch) et `nginx-strangler` ajoutés. `client` dépend de `nginx-strangler`. + +### Tâche 3.2 : `docker/docker-compose.prod.yml` ✅ + +Idem sans Watch. Bug `depends_on` vide sur `opencds-mockoon` corrigé. + +### Tâche 3.3 : `docker/docker-compose.ci.yml` ✅ + +Idem prod. + +### Tâche 3.4 : `docker/docker-compose.integ.yml` ✅ + +Idem dev (avec Watch) + volumes kubeconfig. + +### Tâche 3.5 : `docker/docker-compose.local.yml` ✅ + +`nginx-strangler` ajouté avec `host.docker.internal`, port `8082:8080`, `extra_hosts: host-gateway`. + +### Tâche 3.6 : `apps/client/nginx/default.docker.conf` ✅ + +Upstream `server:8080` → `nginx-strangler:8080`. + +--- + +## Phase 4 — Dev local (pnpm dev natif) ✅ + +### Tâche 4.1 : Template de config pour le mode local ✅ + +`apps/nginx-strangler/conf.d/routing.local.conf.example` créé avec `host.docker.internal` comme valeurs d'exemple. + +### Tâche 4.2 : Documentation ✅ + +- `ENVIRONMENTS.md` : refonte complète avec section Strangler Fig, `server-nestjs` mentionné partout, sous-section dédiée au geste manuel `SERVER_PORT=8082` +- `apps/client/.env-example` : note sur `SERVER_PORT=8082` pour passer par le nginx-strangler en local + +Variables par contexte : + +| Contexte | `LEGACY_UPSTREAM` | `NESTJS_UPSTREAM` | +|---|---|---| +| Docker (dev/prod/ci/integ) | `server:8080` | `server-nestjs:3001` | +| Local (pnpm dev) | `127.0.0.1:4001` | `127.0.0.1:3001` | +| PAX / MinInt | À adapter selon la topologie réseau | + +--- + +## Phase 5 — Validation et outillage + +### Tâche 5.1 : Validation `nginx -t` dans le CI ✅ + +Étape ajoutée dans `.github/workflows/job-lint.yml` : +- `apt-get update -qq` + install `nginx gettext-base` +- `envsubst` sur `routing.conf` +- `sed` pour patcher `user`, `pid`, `error_log`, `access_log` vers des chemins accessibles en non-root +- `nginx -t -c /tmp/nginx-test/nginx.conf` + +### Tâche 5.2 : Smoke test ❌ (à faire) + +Vérifier dans les tests Playwright que `/api/v1/system/health` répond correctement en passant par le nginx-strangler (et non plus directement par `server`). + +### Tâche 5.3 : Procédure de rollback documentée ✅ + +Documentée dans `apps/nginx-strangler/README.md` : + +```bash +# 1. Commenter la location concernée dans routing.conf +# 2. Recharger nginx sans downtime +docker compose exec nginx-strangler nginx -s reload +# 3. Vérifier les logs +docker compose logs -f nginx-strangler | grep /api/v1/xxx +``` + +### Tâche 5.4 (ajout) : Build des images CI dans job-playwright ✅ + +`docker buildx build -t dso-console/server-nestjs:ci` et `nginx-strangler:ci` ajoutés dans `.github/workflows/job-playwright.yml` avant le `docker compose up --no-build`. + +--- + +## Critères d'acceptation + +- [x] Le service `nginx-strangler` démarre correctement dans tous les docker-compose +- [x] Les appels `/api/*` continuent de fonctionner sans régression (fallback total sur `server`) +- [x] `nginx -t` passe en CI +- [x] La config est documentée et le format de bascule des routes est clairement défini +- [x] La procédure de rollback est documentée +- [x] Le dev local (`pnpm dev`) fonctionne avec le nginx-strangler en Docker +- [ ] Smoke test Playwright sur `/api/v1/system/health` via nginx-strangler + +--- + +## Ce qui reste à faire (post-merge PR #1951) + +1. **Tâche 5.2** : Smoke test Playwright (optionnel pour la PR courante, recommandé avant Vague 1) +2. **Vague 1** : Basculer les premiers modules dans `apps/nginx-strangler/conf.d/routing.conf` : + - `system` (health, config, settings) + - `admin-token` + - `user/tokens` + - `log` + - Référence : [`MODULARISATION-CARTOGRAPHIE.md`](../Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) + +--- + +## Séquençage initial et estimation de charge + +``` +Phase 1 : Créer apps/nginx-strangler/ (structure + routing.conf) Dev A ~0.5j ✅ +Phase 2 : Dockeriser server-nestjs (Dockerfile + port) Dev B ~1j ✅ + + Modifier upstream nginx client Dev A ~0.5h ✅ +Phase 3 : Mettre à jour tous les docker-compose Dev A+B ~1j ✅ +Phase 4 : Config locale (envsubst + doc) Dev A ~0.5j ✅ +Phase 5 : CI + smoke test + rollback doc Dev B ~0.5j 🔄 (5.1 ✅, 5.2 ❌, 5.3 ✅) +───────────────────────────────────────────────────────────────────── +Total estimé ~3.5 j/p +``` + +--- + +## Liens + +- Ticket GitHub : [#1885](https://github.com/cloud-pi-native/console/issues/1885) +- PR : [#1951](https://github.com/cloud-pi-native/console/pull/1951) +- Documentation Nginx strangler dans la stratégie de modularisation : [`01-MODULARISATION-STRATEGIE.md`](../Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md) +- Cartographie des modules (vagues de migration) : [`MODULARISATION-CARTOGRAPHIE.md`](../Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) +- Suivi de l'avancement : [`MODULARISATION-STATUT.md`](../Modularisation-de-console-server/MODULARISATION-STATUT.md) diff --git a/.jjconflict-base-0/apps/server-nestjs/eslint.config.mjs b/.jjconflict-base-0/apps/server-nestjs/eslint.config.mjs new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/eslint.config.mjs @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-0/apps/server-nestjs/nest-cli.json b/.jjconflict-base-0/apps/server-nestjs/nest-cli.json new file mode 100644 index 0000000000..f9aa683b1a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/package.json b/.jjconflict-base-0/apps/server-nestjs/package.json new file mode 100644 index 0000000000..79126d32e2 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/package.json @@ -0,0 +1,111 @@ +{ + "name": "@cpn-console/server-nestjs", + "version": "9.22.1", + "private": true, + "description": "", + "author": "", + "license": "UNLICENSED", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "scripts": { + "prebuild": "pnpm i --frozen-lockfile", + "build": "nest build", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev --name dso", + "db:reset": "prisma migrate reset", + "format": "eslint ./ --fix", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "start": "nest start", + "start:debug": "nest start --debug --watch", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "pretest": "pnpm run db:generate", + "test": "vitest run", + "test:watch": "vitest", + "pretest:cov": "pnpm run db:generate", + "test:cov": "vitest run --coverage", + "test:debug": "vitest --inspect" + }, + "dependencies": { + "@cpn-console/argocd-plugin": "workspace:^", + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/harbor-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/nexus-plugin": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/sonarqube-plugin": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/requester-utils": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "@keycloak/keycloak-admin-client": "catalog:runtime", + "@kubernetes-models/argo-cd": "catalog:runtime", + "@nestjs/cache-manager": "catalog:runtime", + "@nestjs/common": "catalog:runtime", + "@nestjs/config": "catalog:runtime", + "@nestjs/core": "catalog:runtime", + "@nestjs/event-emitter": "catalog:runtime", + "@nestjs/jwt": "catalog:runtime", + "@nestjs/platform-fastify": "catalog:runtime", + "@nestjs/schedule": "catalog:runtime", + "@nestjs/swagger": "catalog:runtime", + "@nestjs/terminus": "catalog:runtime", + "@opentelemetry/api": "catalog:otel", + "@opentelemetry/auto-instrumentations-node": "catalog:otel", + "@opentelemetry/exporter-metrics-otlp-proto": "catalog:otel", + "@opentelemetry/exporter-trace-otlp-proto": "catalog:otel", + "@opentelemetry/instrumentation-nestjs-core": "catalog:otel", + "@opentelemetry/instrumentation-pino": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-node": "catalog:otel", + "@prisma/client": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@ts-rest/fastify": "catalog:runtime", + "@ts-rest/open-api": "catalog:runtime", + "cache-manager": "catalog:runtime", + "date-fns": "catalog:runtime", + "fastify": "catalog:runtime", + "fastify-keycloak-adapter": "catalog:runtime", + "json-2-csv": "catalog:runtime", + "mustache": "catalog:runtime", + "nestjs-pino": "catalog:runtime", + "prisma": "catalog:runtime", + "reflect-metadata": "catalog:runtime", + "rxjs": "catalog:runtime", + "undici": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime", + "yaml": "catalog:runtime", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@eslint/eslintrc": "catalog:tools", + "@eslint/js": "catalog:tools", + "@faker-js/faker": "catalog:test", + "@nestjs/cli": "catalog:build", + "@nestjs/schematics": "catalog:build", + "@nestjs/testing": "catalog:build", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "eslint": "catalog:tools", + "globals": "catalog:test", + "msw": "catalog:test", + "pino-pretty": "catalog:tools", + "rimraf": "catalog:tools", + "ts-patch": "catalog:build", + "typescript": "catalog:build", + "typescript-eslint": "catalog:tools", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/prisma.config.ts b/.jjconflict-base-0/apps/server-nestjs/prisma.config.ts new file mode 100644 index 0000000000..ff5aaf9186 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/prisma.config.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' +import { defineConfig } from 'prisma/config' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +export default defineConfig({ + schema: path.join('src', 'prisma', 'schema'), + migrations: { + path: path.join('src', 'prisma', 'migrations'), + }, +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/__mocks__/prisma.ts b/.jjconflict-base-0/apps/server-nestjs/src/__mocks__/prisma.ts new file mode 100644 index 0000000000..2a871fd476 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/__mocks__/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-base-0/apps/server-nestjs/src/config/argocd.config.ts b/.jjconflict-base-0/apps/server-nestjs/src/config/argocd.config.ts new file mode 100644 index 0000000000..a7f1ecc094 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/config/argocd.config.ts @@ -0,0 +1,25 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const argocdFeatureSchema = z.object({ + ARGO_NAMESPACE: z.string().default('argocd'), + ARGOCD_URL: z.string().url(), + ARGOCD_INTERNAL_URL: z.string().url().optional(), + ARGOCD_EXTRA_REPOSITORIES: z.string().min(1), + DSO_ENV_CHART_VERSION: z.string().default('dso-env-1.6.0'), + DSO_NS_CHART_VERSION: z.string().default('dso-ns-1.1.5'), + VAULT__DEPLOY_VAULT_CONNECTION_IN_NS: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform(raw => ({ + namespace: raw.ARGO_NAMESPACE, + url: raw.ARGOCD_URL, + internalUrl: raw.ARGOCD_INTERNAL_URL, + extraRepositories: raw.ARGOCD_EXTRA_REPOSITORIES, + dsoEnvChartVersion: raw.DSO_ENV_CHART_VERSION, + dsoNsChartVersion: raw.DSO_NS_CHART_VERSION, + vaultDeployVaultConnectionInNs: raw.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS, + internalOrPublicUrl: raw.ARGOCD_INTERNAL_URL ?? raw.ARGOCD_URL, + probeUrl: new URL('/api/version', raw.ARGOCD_URL).toString(), +})) + +export const argocdConfigFactory = registerAs('argocd', () => argocdFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/config/base.config.ts b/.jjconflict-base-0/apps/server-nestjs/src/config/base.config.ts new file mode 100644 index 0000000000..38f05bac01 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/config/base.config.ts @@ -0,0 +1,50 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, truthySchema } from './config.utils' + +const baseFeatureSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).optional(), + INTEGRATION: flag(truthySchema.default('false')), + CI: flag(truthySchema.default('false')), + DEV_SETUP: flag(truthySchema.default('false')), + DOCKER: flag(truthySchema.default('false')), + SERVER_HOST: z.string().default('localhost'), + SERVER_PORT: z.string().transform(Number).default('0'), + APP_VERSION: z.string().optional().default('unknown'), + DB_URL: z.string().url().optional(), + SESSION_SECRET: z.string().min(32).optional(), + CONTACT_EMAIL: z.string().email().default('cloudpinative-relations@interieur.gouv.fr'), + MOCK_PLUGINS: flag(truthySchema.default('false')), + PROJECTS_ROOT_DIR: z.string().min(1, 'PROJECTS_ROOT_DIR is required'), + PLUGINS_DIR: z.string().default('/plugins'), + HTTP_PROXY: z.string().url().optional(), + HTTPS_PROXY: z.string().url().optional(), +}).transform((raw) => { + const nodeEnv = raw.NODE_ENV ?? 'production' + return { + nodeEnv, + isTest: raw.NODE_ENV === 'test', + isDev: raw.NODE_ENV === 'development', + isCI: raw.CI, + isProd: nodeEnv === 'production', + integration: raw.INTEGRATION, + ci: raw.CI, + devSetup: raw.DEV_SETUP, + docker: raw.DOCKER, + serverHost: raw.SERVER_HOST, + serverPort: raw.SERVER_PORT, + appVersion: raw.NODE_ENV === 'production' ? raw.APP_VERSION : 'dev', + dbUrl: raw.DB_URL, + sessionSecret: raw.SESSION_SECRET, + contactEmail: raw.CONTACT_EMAIL, + mockPlugins: raw.MOCK_PLUGINS, + projectsRootDir: raw.PROJECTS_ROOT_DIR, + pluginsDir: raw.PLUGINS_DIR, + httpProxy: raw.HTTP_PROXY, + httpsProxy: raw.HTTPS_PROXY, + } +}) + +export type BaseConfig = z.infer + +export const baseConfigFactory = registerAs('base', () => baseFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/config/config.utils.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/config/config.utils.spec.ts new file mode 100644 index 0000000000..155c025349 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/config/config.utils.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import z from 'zod' +import { csv, flag, truthySchema } from './config.utils' + +describe('config.utils', () => { + describe('flag', () => { + it('coerces true/1 (case-insensitive) to true', () => { + const schema = flag(truthySchema.default('false')) + expect(schema.parse('true')).toBe(true) + expect(schema.parse('1')).toBe(true) + expect(schema.parse('TRUE')).toBe(true) + }) + + it('coerces false/0 to false', () => { + const schema = flag(truthySchema.default('true')) + expect(schema.parse('false')).toBe(false) + expect(schema.parse('0')).toBe(false) + }) + + it('falls back to the schema default when missing', () => { + expect(flag(truthySchema.default('false')).parse(undefined)).toBe(false) + expect(flag(truthySchema.default('true')).parse(undefined)).toBe(true) + }) + }) + + describe('csv', () => { + const schema = csv(z.string()) + + it('splits, trims and drops empty parts', () => { + expect(schema.parse('a, b ,,c')).toEqual(['a', 'b', 'c']) + }) + + it('maps missing/empty to an empty array', () => { + expect(schema.parse(undefined)).toEqual([]) + expect(schema.parse('')).toEqual([]) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/config/config.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/config/config.utils.ts new file mode 100644 index 0000000000..f06cde73f8 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/config/config.utils.ts @@ -0,0 +1,27 @@ +import z from 'zod' + +// Shared truthy enum for flag(): 'true'/'false'/'1'/'0'. +export const truthySchema = z.enum(['true', 'false', '1', '0']) + +// Boolean flag. Strictly takes a z string schema (e.g. truthySchema.default('true')). +// Lowercases, then coerces 'true'/'1' -> true, 'false'/'0' -> false. +// Missing value falls back to the schema's default. +export function flag(schema: z.ZodType) { + return z + .preprocess( + val => (typeof val === 'string' ? val.toLowerCase() : val), + schema, + ) + .transform(val => val === 'true' || val === '1') +} + +// Comma-separated string -> array of schema-validated, trimmed non-empty parts. +// Empty/missing -> []. Strictly takes a z string schema (applied per element). +export function csv>(schema: T) { + return z + .preprocess( + val => (typeof val === 'string' ? val : val ?? ''), + z.string().transform(value => value.split(',').map(part => part.trim()).filter(Boolean)), + ) + .pipe(z.array(schema)) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/config/gitlab.config.ts b/.jjconflict-base-0/apps/server-nestjs/src/config/gitlab.config.ts new file mode 100644 index 0000000000..d376279d4f --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/config/gitlab.config.ts @@ -0,0 +1,28 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const gitlabFeatureSchema = z.object({ + GITLAB_TOKEN: z.string().min(1), + GITLAB_URL: z.string().url(), + GITLAB_INTERNAL_URL: z.string().url().optional(), + GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS: z.coerce.number().int().positive().default(180), + GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + GITLAB__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), + PROJECTS_ROOT_DIR: z.string().min(1), +}).transform((raw) => { + const urlBase = raw.GITLAB_INTERNAL_URL ?? raw.GITLAB_URL + return { + token: raw.GITLAB_TOKEN, + url: raw.GITLAB_URL, + internalUrl: raw.GITLAB_INTERNAL_URL, + secretExposeInternalUrl: raw.GITLAB__SECRET_EXPOSE_INTERNAL_URL, + mirrorTokenExpirationDays: raw.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS, + mirrorTokenRotationThresholdDays: raw.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS, + projectRootDir: raw.PROJECTS_ROOT_DIR, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/-/health', urlBase).toString(), + } +}) + +export const gitlabConfigFactory = registerAs('gitlab', () => gitlabFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/config/harbor.config.ts b/.jjconflict-base-0/apps/server-nestjs/src/config/harbor.config.ts new file mode 100644 index 0000000000..3e4d093272 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/config/harbor.config.ts @@ -0,0 +1,34 @@ +import type { HarborConfig } from '../modules/registry/harbor.module-definition' +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const harborFeatureSchema = z.object({ + HARBOR_URL: z.string().url(), + HARBOR_INTERNAL_URL: z.string().url().optional(), + HARBOR_ADMIN: z.string().min(1), + HARBOR_ADMIN_PASSWORD: z.string().min(1), + HARBOR_RULE_TEMPLATE: z.string().optional(), + HARBOR_RULE_COUNT: z.string().optional(), + HARBOR_RETENTION_CRON: z.string().default('0 22 2 * * *'), + HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + HARBOR_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform((raw) => { + const urlBase = raw.HARBOR_INTERNAL_URL ?? raw.HARBOR_URL + return { + url: raw.HARBOR_URL, + internalUrl: raw.HARBOR_INTERNAL_URL, + admin: raw.HARBOR_ADMIN, + adminPassword: raw.HARBOR_ADMIN_PASSWORD, + ruleTemplate: raw.HARBOR_RULE_TEMPLATE, + ruleCount: raw.HARBOR_RULE_COUNT, + retentionCron: raw.HARBOR_RETENTION_CRON, + robotRotationThresholdDays: raw.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.HARBOR_PROJECT_SLUG_CACHE_TTL_MS, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/api/v2.0/ping', urlBase).toString(), + } +}) + +export type HarborAppConfig = HarborConfig + +export const harborConfigFactory = registerAs('harbor', () => harborFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/config/keycloak.config.ts b/.jjconflict-base-0/apps/server-nestjs/src/config/keycloak.config.ts new file mode 100644 index 0000000000..576cd9c996 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/config/keycloak.config.ts @@ -0,0 +1,50 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const keycloakFeatureSchema = z.object({ + KEYCLOAK_PROTOCOL: z.enum(['http', 'https']).default('https'), + KEYCLOAK_DOMAIN: z.string().min(1), + KEYCLOAK_PUBLIC_PROTOCOL: z.enum(['http', 'https']).default('https'), + KEYCLOAK_PUBLIC_DOMAIN: z.string().min(1), + KEYCLOAK_REALM: z.string().min(1), + KEYCLOAK_CLIENT_ID: z.string().min(1), + KEYCLOAK_CLIENT_SECRET: z.string().min(1), + KEYCLOAK_ADMIN: z.string().min(1), + KEYCLOAK_ADMIN_PASSWORD: z.string().min(1), + KEYCLOAK_ADMIN_CLIENT_ID: z.string().default('admin-cli'), + KEYCLOAK_REDIRECT_URI: z.string().url(), + KEYCLOAK_JWKS_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + KEYCLOAK_JWKS_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), + KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + ADMIN_KC_USER_ID: z.preprocess( + value => (typeof value === 'string' ? value.split(',').map(part => part.trim()).filter(Boolean) : value), + z.array(z.string()).default([]), + ), +}).transform((raw) => { + const keycloakUrl = `${raw.KEYCLOAK_PROTOCOL}://${raw.KEYCLOAK_DOMAIN}` + const keycloakRealmUrl = `${keycloakUrl}/realms/${raw.KEYCLOAK_REALM}` + return { + protocol: raw.KEYCLOAK_PROTOCOL, + domain: raw.KEYCLOAK_DOMAIN, + publicProtocol: raw.KEYCLOAK_PUBLIC_PROTOCOL, + publicDomain: raw.KEYCLOAK_PUBLIC_DOMAIN, + realm: raw.KEYCLOAK_REALM, + clientId: raw.KEYCLOAK_CLIENT_ID, + clientSecret: raw.KEYCLOAK_CLIENT_SECRET, + admin: raw.KEYCLOAK_ADMIN, + adminPassword: raw.KEYCLOAK_ADMIN_PASSWORD, + adminClientId: raw.KEYCLOAK_ADMIN_CLIENT_ID, + redirectUri: raw.KEYCLOAK_REDIRECT_URI, + jwksCacheTtlMs: raw.KEYCLOAK_JWKS_CACHE_TTL_MS, + jwksTimeoutMs: raw.KEYCLOAK_JWKS_TIMEOUT_MS, + openidConfigurationCacheTtlMs: raw.KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS, + adminKcUserId: raw.ADMIN_KC_USER_ID, + url: keycloakUrl, + realmUrl: keycloakRealmUrl, + openidConfigurationUrl: `${keycloakRealmUrl}/.well-known/openid-configuration`, + } +}) + +export type KeycloakConfig = z.infer + +export const keycloakConfigFactory = registerAs('keycloak', () => keycloakFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/config/nexus.config.ts b/.jjconflict-base-0/apps/server-nestjs/src/config/nexus.config.ts new file mode 100644 index 0000000000..cfdc23000b --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/config/nexus.config.ts @@ -0,0 +1,24 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const nexusFeatureSchema = z.object({ + NEXUS_URL: z.string().url(), + NEXUS_INTERNAL_URL: z.string().url().optional(), + NEXUS_ADMIN: z.string().min(1), + NEXUS_ADMIN_PASSWORD: z.string().min(1), + NEXUS__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + const urlBase = raw.NEXUS_INTERNAL_URL ?? raw.NEXUS_URL + return { + url: raw.NEXUS_URL, + internalUrl: raw.NEXUS_INTERNAL_URL, + admin: raw.NEXUS_ADMIN, + adminPassword: raw.NEXUS_ADMIN_PASSWORD, + secretExposeInternalUrl: raw.NEXUS__SECRET_EXPOSE_INTERNAL_URL, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/service/rest/v1/status', urlBase).toString(), + } +}) + +export const nexusConfigFactory = registerAs('nexus', () => nexusFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/config/opencds.config.ts b/.jjconflict-base-0/apps/server-nestjs/src/config/opencds.config.ts new file mode 100644 index 0000000000..fb1da23b96 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/config/opencds.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const opencdsFeatureSchema = z.object({ + OPENCDS_URL: z.string().url(), + OPENCDS_INTERNAL_URL: z.string().url().optional(), + OPENCDS_API_TOKEN: z.string().min(1), + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + const probeBase = raw.OPENCDS_INTERNAL_URL ?? raw.OPENCDS_URL + return { + url: raw.OPENCDS_URL, + internalUrl: raw.OPENCDS_INTERNAL_URL, + probeUrl: new URL('/api/v1/health', probeBase).toString(), + apiToken: raw.OPENCDS_API_TOKEN, + apiTlsRejectUnauthorized: raw.OPENCDS_API_TLS_REJECT_UNAUTHORIZED, + } +}) + +export const openCdsConfigFactory = registerAs('opencds', () => opencdsFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/config/registry.config.ts b/.jjconflict-base-0/apps/server-nestjs/src/config/registry.config.ts new file mode 100644 index 0000000000..c30ef12c50 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/config/registry.config.ts @@ -0,0 +1,30 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const registryFeatureSchema = z.object({ + REGISTRY_URL: z.string().url(), + REGISTRY_INTERNAL_URL: z.string().url().optional(), + REGISTRY_ADMIN: z.string().min(1), + REGISTRY_ADMIN_PASSWORD: z.string().min(1), + REGISTRY_RULE_TEMPLATE: z.string().min(1), + REGISTRY_RULE_COUNT: z.coerce.number().int().nonnegative().optional(), + REGISTRY_RETENTION_CRON: z.string().default('0 22 2 * * *'), + REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + REGISTRY_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform((raw) => { + const urlBase = raw.REGISTRY_INTERNAL_URL ?? raw.REGISTRY_URL + return { + url: raw.REGISTRY_URL, + internalUrl: raw.REGISTRY_INTERNAL_URL, + admin: raw.REGISTRY_ADMIN, + adminPassword: raw.REGISTRY_ADMIN_PASSWORD, + ruleTemplate: raw.REGISTRY_RULE_TEMPLATE, + ruleCount: raw.REGISTRY_RULE_COUNT, + retentionCron: raw.REGISTRY_RETENTION_CRON, + robotRotationThresholdDays: raw.REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.REGISTRY_PROJECT_SLUG_CACHE_TTL_MS, + internalOrPublicUrl: urlBase, + } +}) + +export const registryConfigFactory = registerAs('registry', () => registryFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/config/sonarqube.config.ts b/.jjconflict-base-0/apps/server-nestjs/src/config/sonarqube.config.ts new file mode 100644 index 0000000000..78fdd00939 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/config/sonarqube.config.ts @@ -0,0 +1,19 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const sonarqubeFeatureSchema = z.object({ + SONARQUBE_URL: z.string().url(), + SONARQUBE_INTERNAL_URL: z.string().url().optional(), + SONAR_API_TOKEN: z.string().min(1), +}).transform((raw) => { + const urlBase = raw.SONARQUBE_INTERNAL_URL ?? raw.SONARQUBE_URL + return { + url: raw.SONARQUBE_URL, + internalUrl: raw.SONARQUBE_INTERNAL_URL, + apiToken: raw.SONAR_API_TOKEN, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/api/system/health', urlBase).toString(), + } +}) + +export const sonarqubeConfigFactory = registerAs('sonarqube', () => sonarqubeFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/config/vault.config.ts b/.jjconflict-base-0/apps/server-nestjs/src/config/vault.config.ts new file mode 100644 index 0000000000..83aca3cb62 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/config/vault.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const vaultFeatureSchema = z.object({ + VAULT_TOKEN: z.string().min(1), + VAULT_URL: z.string().url(), + VAULT_INTERNAL_URL: z.string().url().optional(), + VAULT_KV_NAME: z.string().default('forge-dso'), +}).transform((raw) => { + const urlBase = raw.VAULT_INTERNAL_URL ?? raw.VAULT_URL + return { + token: raw.VAULT_TOKEN, + url: raw.VAULT_URL, + internalUrl: raw.VAULT_INTERNAL_URL, + kvName: raw.VAULT_KV_NAME, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/v1/sys/health', urlBase).toString(), + } +}) + +export const vaultConfigFactory = registerAs('vault', () => vaultFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/main.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/main.module.ts new file mode 100644 index 0000000000..87f8cb8180 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/main.module.ts @@ -0,0 +1,53 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { ScheduleModule } from '@nestjs/schedule' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from './config/base.config' +import { DeploymentModule } from './modules/deployment/deployment.module' +import { EnvironmentModule } from './modules/environment/environment.module' +import { HealthzModule } from './modules/healthz/healthz.module' +import { InfrastructureModule } from './modules/infrastructure/infrastructure.module' +import { KeycloakModule } from './modules/keycloak/keycloak.module' +import { LogModule } from './modules/log/log.module' +import { ProjectBulkModule } from './modules/project-bulk/project-bulk.module' +import { ProjectHooksModule } from './modules/project-hooks/project-hooks.module' +import { ProjectMembersModule } from './modules/project-members/project-members.module' +import { ProjectRolesModule } from './modules/project-roles/project-roles.module' +import { ProjectSecretsModule } from './modules/project-secrets/project-secrets.module' +import { ProjectServicesModule } from './modules/project-services/project-services.module' +import { ProjectModule } from './modules/project/project.module' +import { ServiceChainModule } from './modules/service-chain/service-chain.module' +import { SystemSettingsModule } from './modules/system-settings/system-settings.module' +import { VersionModule } from './modules/version/version.module' +import { getDotenvPaths } from './utils/dotenv.utils' + +@Module({ + imports: [ + ConfigModule.forRoot({ + envFilePath: getDotenvPaths(), + isGlobal: true, + load: [baseConfigFactory], + }), + TerminusModule.forRoot(), + InfrastructureModule, + HealthzModule, + KeycloakModule, + ScheduleModule.forRoot(), + SystemSettingsModule, + ServiceChainModule, + ProjectModule, + ProjectHooksModule, + ProjectSecretsModule, + ProjectServicesModule, + ProjectBulkModule, + ProjectMembersModule, + ProjectRolesModule, + LogModule, + DeploymentModule, + EnvironmentModule, + VersionModule, + ], + controllers: [], + providers: [], +}) +export class MainModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/main.ts b/.jjconflict-base-0/apps/server-nestjs/src/main.ts new file mode 100644 index 0000000000..600f7232f7 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/main.ts @@ -0,0 +1,66 @@ +import type { NestFastifyApplication } from '@nestjs/platform-fastify' +import type { BaseConfig } from './modules/infrastructure/config/base.config' +import { NestFactory } from '@nestjs/core' +import { FastifyAdapter } from '@nestjs/platform-fastify' +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' +import { NodeSDK, resources } from '@opentelemetry/sdk-node' +import { Logger } from 'nestjs-pino' +import { MainModule } from './main.module' +import { BASE_CONFIG } from './modules/infrastructure/config/base.module' + +const SERVICE_NAME = 'console-pi-native-console' + +const telemetry = new NodeSDK({ + traceExporter: new OTLPTraceExporter({}), + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + }), + instrumentations: [ + getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-nestjs-core': { enabled: true }, + '@opentelemetry/instrumentation-pino': { enabled: true }, + }), + ], + resource: resources.resourceFromAttributes({ + 'service.name': SERVICE_NAME, + }), +}) + +async function bootstrap() { + telemetry.start() + + const app = await NestFactory.create(MainModule, new FastifyAdapter(), { + bufferLogs: true, + }) + + app.useLogger(app.get(Logger)) + app.flushLogs() + app.enableShutdownHooks() + app.getHttpAdapter().getInstance().addHook('onClose', async () => { + await telemetry.shutdown() + }) + + const config = app.get(BASE_CONFIG) + + // Setup swagger-ui route + const swaggerConfig = new DocumentBuilder() + .setTitle('Console Cloud π Native') + .setDescription('Description de l\' API Cloud π Native') + .setVersion('1.0') + .addTag('console') + .build() + const documentFactory = () => SwaggerModule.createDocument(app, swaggerConfig) + SwaggerModule.setup('swagger-ui-server-nestjs', app, documentFactory) + + await app.listen(config.serverPort, config.serverHost) + + const serverUrl = await app.getUrl() + const logger = app.get(Logger) + logger.log(`NestJS server running on: ${serverUrl}`) +} + +void bootstrap() diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts new file mode 100644 index 0000000000..d3103a2e31 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts @@ -0,0 +1,104 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + helmValuesFiles: true, + deployRevision: true, + deployPath: true, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + deployments: { + select: { + id: true, + name: true, + autosync: true, + environment: { + select: { + id: true, + name: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + deploymentSources: { + select: { + type: true, + path: true, + targetRevision: true, + helmValuesFiles: true, + repository: { + select: { + id: true, + internalRepoName: true, + }, + }, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class ArgoCDDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts new file mode 100644 index 0000000000..f79fc15424 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts @@ -0,0 +1,23 @@ +import type { ArgocdConfig } from './argocd.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ARGOCD_CONFIG } from './argocd.module-definition' + +@Injectable() +export class ArgoCDHealthService { + constructor( + @Inject(ARGOCD_CONFIG) private readonly argocdConfig: ArgocdConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.argocdConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts new file mode 100644 index 0000000000..7388fc6dd8 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts @@ -0,0 +1,169 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Injectable } from '@nestjs/common' +import { + DEFAULT_DSO_ENV_CHART_VERSION, + DEFAULT_DSO_NS_CHART_VERSION, + DSO_ENV_CHART_VERSION_PLUGIN_KEY, + DSO_NS_CHART_VERSION_PLUGIN_KEY, + EXTRA_REPOSITORIES_PLUGIN_KEY, + PLATFORM_ADMIN_GROUP_PATH, + PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + PLATFORM_READONLY_GROUP_PATH, + PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY, + PLATFORM_SECURITY_GROUP_PATH, + PROJECT_ADMIN_GROUP_PATH_SUFFIX, + PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_READONLY_GROUP_PATH_SUFFIX, + PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_GROUP_PATH_SUFFIX, +} from './argocd.constants' + +@Injectable() +export class ArgoCDPluginService { + infos(): ServiceInfos { + const extraRepositoriesDesc = 'appproject.spec.sourceRepos supplémentaires, séparés par des virgules (https://a.com/repo.git,https://b.com/' + + return { + name: 'argocd', + to: ({ zones, project }) => zones.map(z => ({ + to: new URL(`applications?showFavorites=false&proj=&sync=&health=&namespace=&cluster=&labels=&search=${project.slug}`, z.argocdUrl).toString(), + title: `ArgoCD ${z.label}`, + })), + title: 'ArgoCD', + imgSrc: '/img/argocd.svg', + description: 'ArgoCD est un outil déclaratif de livraison continue GitOps pour Kubernetes', + config: { + global: [{ + key: EXTRA_REPOSITORIES_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }, { + key: PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Admin Group Path', + value: PLATFORM_ADMIN_GROUP_PATH, + description: 'Chemin du groupe administrateur de plateforme', + }, { + key: PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Readonly Group Path', + value: PLATFORM_READONLY_GROUP_PATH, + description: 'Chemin du groupe lecture seule de plateforme', + }, { + key: 'platformSecurityGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Security Group Path', + value: PLATFORM_SECURITY_GROUP_PATH, + description: 'Chemin du groupe sécurité de plateforme', + }, { + key: PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Admin Group Path Suffix', + value: PROJECT_ADMIN_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe administrateur de projet', + }, { + key: PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project DevOps Group Path Suffix', + value: PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe devops de projet', + }, { + key: PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Developer Group Path Suffix', + value: PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe développeur de projet', + }, { + key: PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Readonly Group Path Suffix', + value: PROJECT_READONLY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe lecture seule de projet', + }, { + key: 'projectSecurityGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Security Group Path Suffix', + value: PROJECT_SECURITY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe sécurité de projet', + }, { + key: DSO_ENV_CHART_VERSION_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Env Chart Version', + value: DEFAULT_DSO_ENV_CHART_VERSION, + description: 'Version du chart Helm dso-env', + placeholder: DEFAULT_DSO_ENV_CHART_VERSION, + }, { + key: DSO_NS_CHART_VERSION_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Namespace Chart Version', + value: DEFAULT_DSO_NS_CHART_VERSION, + description: 'Version du chart Helm dso-ns', + placeholder: DEFAULT_DSO_NS_CHART_VERSION, + }], + project: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts new file mode 100644 index 0000000000..65d58f8955 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts @@ -0,0 +1,94 @@ +import type { ProjectWithDetails } from './argocd-datastore.service' + +import { faker } from '@faker-js/faker' + +export function makeProjectDeploymentSource( + overrides: Partial = {}, +): ProjectWithDetails['deployments'][number]['deploymentSources'][number] { + return { + type: 'git', + path: '.', + targetRevision: 'HEAD', + helmValuesFiles: '', + repository: makeProjectRepository(), + ...overrides, + } satisfies ProjectWithDetails['deployments'][number]['deploymentSources'][number] +} + +export function makeProjectDeployment( + overrides: Partial = {}, +): ProjectWithDetails['deployments'][number] { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + environment: makeProjectEnvironment(), + autosync: true, + deploymentSources: [ + { + type: 'git', + path: '.', + targetRevision: 'HEAD', + helmValuesFiles: '', + repository: makeProjectRepository(), + }, + ], + ...overrides, + } satisfies ProjectWithDetails['deployments'][number] +} + +export function makeProjectRepository( + overrides: Partial = {}, +): ProjectWithDetails['repositories'][number] { + return { + id: faker.string.uuid(), + internalRepoName: faker.word.noun(), + isInfra: false, + deployRevision: 'HEAD', + deployPath: '.', + helmValuesFiles: '', + ...overrides, + } satisfies ProjectWithDetails['repositories'][number] +} + +export function makeCluster( + overrides: Partial = {}, +): ProjectWithDetails['environments'][number]['cluster'] { + return { + id: faker.string.uuid(), + label: faker.word.noun(), + zone: { + slug: faker.word.noun(), + }, + ...overrides, + } satisfies ProjectWithDetails['environments'][number]['cluster'] +} + +export function makeProjectEnvironment( + overrides: Partial = {}, +): ProjectWithDetails['environments'][number] { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + cluster: makeCluster(), + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + ...overrides, + } satisfies ProjectWithDetails['environments'][number] +} + +export function makeProjectWithDetails( + overrides: Partial = {}, +): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(faker.word.words({ count: 2 })).toLowerCase(), + name: faker.word.noun(), + environments: [makeProjectEnvironment()], + repositories: [makeProjectRepository()], + plugins: [], + deployments: [makeProjectDeployment()], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.constants.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.constants.ts new file mode 100644 index 0000000000..d856cf98bf --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.constants.ts @@ -0,0 +1,30 @@ +// ArgoCD plugin constants +export const PLUGIN_NAME = 'argocd' + +// Platform-level group paths +export const CONSOLE_ADMIN_GROUP_PATH = '/console/admin' +export const PLATFORM_ADMIN_GROUP_PATH = '/console/admin' +export const PLATFORM_READONLY_GROUP_PATH = '/console/readonly' +export const PLATFORM_SECURITY_GROUP_PATH = '/console/security' + +// Suffix for project group path +export const PROJECT_ADMIN_GROUP_PATH_SUFFIX = '/console/admin' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const PROJECT_READONLY_GROUP_PATH_SUFFIX = '/console/readonly' +export const PROJECT_SECURITY_GROUP_PATH_SUFFIX = '/console/security' + +// Default chart versions +export const DEFAULT_DSO_ENV_CHART_VERSION = 'dso-env-1.6.0' +export const DEFAULT_DSO_NS_CHART_VERSION = 'dso-ns-1.1.5' + +// Plugin configuration keys +export const EXTRA_REPOSITORIES_PLUGIN_KEY = 'extraRepositories' +export const PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY = 'platformAdminGroupPath' +export const PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY = 'platformReadonlyGroupPath' +export const PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectAdminGroupPathSuffix' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevopsGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevelopperGroupPathSuffix' +export const PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReadonlyGroupPathSuffix' +export const DSO_ENV_CHART_VERSION_PLUGIN_KEY = 'dsoEnvChartVersion' +export const DSO_NS_CHART_VERSION_PLUGIN_KEY = 'dsoNsChartVersion' diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts new file mode 100644 index 0000000000..a134c72f4d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts @@ -0,0 +1,19 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface ArgocdConfig { + namespace: string + url: string + internalUrl: string + extraRepositories: string + dsoEnvChartVersion: string + dsoNsChartVersion: string + vaultDeployVaultConnectionInNs: boolean + internalOrPublicUrl?: string +} + +export interface ArgoCDModuleOptions extends ArgocdConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('argocd') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as ARGOCD_CONFIG } diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts new file mode 100644 index 0000000000..0fb39bcc53 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts @@ -0,0 +1,25 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ArgoCDModule } from './argocd.module' +import { ArgoCDService } from './argocd.service' + +describe('argocdModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('registers ArgoCDService when USE_ARGOCD=true', async () => { + vi.stubEnv('USE_ARGOCD', 'true') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD')], + }).compile() + expect(module.get(ArgoCDService)).toBeInstanceOf(ArgoCDService) + }) + + it('omits ArgoCDService when USE_ARGOCD=false', async () => { + vi.stubEnv('USE_ARGOCD', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD')], + }).compile() + expect(() => module.get(ArgoCDService)).toThrow() + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.module.ts new file mode 100644 index 0000000000..71979a5023 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { argocdConfigFactory } from '../../config/argocd.config' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' +import { GitlabModule } from '../gitlab/gitlab.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { ArgoCDHealthService } from './argocd-health.service' +import { ArgoCDPluginService } from './argocd-plugin.service' +import { ArgoCDService } from './argocd.service' + +@Module({ + imports: [DatabaseModule, ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), TerminusModule, VaultModule, ConfigModule.forFeature(argocdConfigFactory), ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + providers: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService, ArgoCDDatastoreService], + exports: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService], +}) +export class ArgoCDModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts new file mode 100644 index 0000000000..2c0511c4bf --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -0,0 +1,541 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { ArgocdConfig } from './argocd.module-definition' +import { generateNamespaceName } from '@cpn-console/shared' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { stringify } from 'yaml' +import { GitlabClientService } from '../gitlab/gitlab-client.service' +import { makeCommitAction, makeProjectSchema, makeRepositoryTreeSchema } from '../gitlab/gitlab-testing.utils' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { makeProjectDeployment, makeProjectDeploymentSource, makeProjectEnvironment, makeProjectRepository, makeProjectWithDetails } from './argocd-testing.utils' +import { ARGOCD_CONFIG } from './argocd.module-definition' +import { ArgoCDService } from './argocd.service' + +describe('argoCDService', () => { + let service: ArgoCDService + let datastore: DeepMockProxy + let gitlab: DeepMockProxy + let vault: DeepMockProxy + let argocdConfig: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + datastore = mockDeep() + gitlab = mockDeep() + vault = mockDeep() + argocdConfig = mockDeep({ + namespace: 'argocd', + url: 'https://argocd.internal', + internalUrl: undefined, + extraRepositories: 'repo3', + dsoEnvChartVersion: 'dso-env-1.6.0', + dsoNsChartVersion: 'dso-ns-1.1.5', + vaultDeployVaultConnectionInNs: false, + internalOrPublicUrl: undefined, + }) + baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep({ + url: 'https://vault.internal', + kvName: 'kv', + internalUrl: undefined, + }) + + const module = await Test.createTestingModule({ + providers: [ + ArgoCDService, + { provide: ArgoCDDatastoreService, useValue: datastore }, + { provide: ARGOCD_CONFIG, useValue: argocdConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: GitlabClientService, useValue: gitlab }, + { provide: VaultClientService, useValue: vault }, + ], + }).compile() + + service = module.get(ArgoCDService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('should return OK result when ensureProject succeeds', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [], + repositories: [], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) + gitlab.maybeCreateCommit.mockResolvedValue(undefined) + + const result = await service.handleUpsert(mockProject) + + expect(result).toEqual({ + argocd: expect.objectContaining({ + status: 'OK', + message: 'Up to date', + executionTime: expect.any(Number), + }), + }) + }) + + it('should return KO result when ensureProject throws', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const error = new Error('GitLab unreachable') + gitlab.getOrCreateInfraGroupRepo.mockRejectedValue(error) + + const result = await service.handleUpsert(mockProject) + + expect(result).toEqual({ + argocd: expect.objectContaining({ + status: 'KO', + message: 'GitLab unreachable', + executionTime: expect.any(Number), + error, + }), + }) + }) + }) + + it('should sync project environments', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + makeProjectEnvironment({ name: 'prod', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + // Verify Gitlab calls + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'dev', + 'dso/environment.id': mockProject.environments[0].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-dev-6293', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/dev/values.yaml', + roGroup: '/project-1/console/dev/RO', + rwGroup: '/project-1/console/dev/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockProject.environments[0].id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + id: mockProject.repositories[0].id, + name: 'infra-repo', + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: 'HEAD', + path: '.', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/dev/values.yaml', + }, + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'prod', + 'dso/environment.id': mockProject.environments[1].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-prod-c626', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/prod/values.yaml', + roGroup: '/project-1/console/prod/RO', + rwGroup: '/project-1/console/prod/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockProject.environments[1].id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + id: mockProject.repositories[0].id, + name: 'infra-repo', + repoURL: 'https://gitlab.internal/group/project-1/infra-repo.git', + targetRevision: 'HEAD', + path: '.', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/prod/values.yaml', + }, + ]), + ) + + expect(gitlab.listFiles).toHaveBeenCalledWith(infraProject, { + path: 'Project 1/', + recursive: true, + }) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(2) + }) + + it('should delete values file when an environment is removed', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([ + makeRepositoryTreeSchema( + { name: 'values.yaml', path: 'Project 1/cluster-1/dev/values.yaml' }, + ), + makeRepositoryTreeSchema( + { name: 'values.yaml', path: 'Project 1/cluster-1/prod/values.yaml' }, + ), + ]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + expect.objectContaining({ + action: 'create', + filePath: 'Project 1/cluster-1/dev/values.yaml', + }), + { + action: 'delete', + filePath: 'Project 1/cluster-1/prod/values.yaml', + }, + ]), + ) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(1) + }) + + it('should not commit when there is no diff', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + + gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) + + await expect(service.handleCron()).resolves.not.toThrow() + + expect(gitlab.maybeCreateCommit).not.toHaveBeenCalled() + }) + + it('should sync project deployments', async () => { + const mockRepo = makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true }) + const mockDevEnv = makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }) + const mockProdEnv = makeProjectEnvironment({ name: 'prod', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }) + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + mockDevEnv, + mockProdEnv, + ], + repositories: [mockRepo], + plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], + deployments: [ + makeProjectDeployment({ + environment: mockDevEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: 'dev' })], + }), + makeProjectDeployment({ + environment: mockDevEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: '1.0.0', path: 'service-1' })], + }), + makeProjectDeployment({ + environment: mockProdEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: 'prod' })], + }), + ], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + // Verify Gitlab calls + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'dev', + 'dso/environment.id': mockProject.environments[0].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-dev-6293', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/dev/values.yaml', + roGroup: '/project-1/console/dev/RO', + rwGroup: '/project-1/console/dev/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockDevEnv.id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + name: 'infra-repo', + id: mockRepo.id, + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: 'dev', + path: '.', + valueFiles: [], + }, + { + name: 'infra-repo', + id: mockRepo.id, + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: '1.0.0', + path: 'service-1', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/dev/values.yaml', + }, + ]), + ) + + expect(gitlab.listFiles).toHaveBeenCalledWith(infraProject, { + path: 'Project 1/', + recursive: true, + }) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(4) // 2 environments + 2 deployments + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.service.ts new file mode 100644 index 0000000000..cbfa862247 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -0,0 +1,662 @@ +import type { CommitAction, CondensedProjectSchema, ProjectSchema, SimpleProjectSchema } from '@gitbeaker/core' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { ProjectWithDetails } from './argocd-datastore.service' +import type { ArgocdConfig } from './argocd.module-definition' +import { createHmac } from 'node:crypto' +import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { stringify } from 'yaml' +import { GitlabClientService } from '../gitlab/gitlab-client.service' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { + CONSOLE_ADMIN_GROUP_PATH, + PLATFORM_ADMIN_GROUP_PATH, + PLATFORM_READONLY_GROUP_PATH, + PLATFORM_SECURITY_GROUP_PATH, + PROJECT_ADMIN_GROUP_PATH_SUFFIX, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + PROJECT_READONLY_GROUP_PATH_SUFFIX, + PROJECT_SECURITY_GROUP_PATH_SUFFIX, +} from './argocd.constants' +import { ARGOCD_CONFIG } from './argocd.module-definition' + +@Injectable() +export class ArgoCDService { + private readonly logger = new Logger(ArgoCDService.name) + + constructor( + @Inject(ArgoCDDatastoreService) private readonly datastore: ArgoCDDatastoreService, + @Inject(ARGOCD_CONFIG) private readonly argocdConfig: ArgocdConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('ArgoCDService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('argocd', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`ArgoCD sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('argocd', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`ArgoCD sync completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + this.logger.log('Starting ArgoCD reconciliation') + const projects = await this.datastore.getAllProjects() + const span = trace.getActiveSpan() + span?.setAttribute('argocd.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for ArgoCD reconciliation`) + await this.ensureProjects(projects) + this.logger.log(`ArgoCD reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('argocd.projects.count', projects.length) + this.logger.verbose(`Reconciling ArgoCD projects (count=${projects.length})`) + await Promise.all(projects.map(project => this.ensureProject(project))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling ArgoCD project ${project.slug}`) + await this.ensureZones(project) + this.logger.verbose(`ArgoCD project reconciled (${project.slug})`) + } + + @StartActiveSpan() + private async ensureZones( + project: ProjectWithDetails, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const zones = getDistinctZones(project) + span?.setAttribute('argocd.zones.count', zones.length) + this.logger.verbose(`Reconciling ArgoCD zones for project ${project.slug} (count=${zones.length})`) + await Promise.all(zones.map(zoneSlug => this.ensureZone(project, zoneSlug))) + } + + @StartActiveSpan() + private async ensureZone( + project: ProjectWithDetails, + zoneSlug: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const infraProject = await this.gitlab.getOrCreateInfraGroupRepo(zoneSlug) + span?.setAttributes({ + 'argocd.repo.id': infraProject.id, + 'argocd.repo.path': infraProject.path_with_namespace, + 'zone.slug': zoneSlug, + }) + this.logger.verbose(`Reconciling ArgoCD zone for project ${project.slug} in zone ${zoneSlug} (repoId=${infraProject.id})`) + + const actions = await this.generateEnvironmentActions(project, infraProject, zoneSlug) + + span?.setAttribute('argocd.repo.actions.count', actions.length) + if (actions.length === 0) { + this.logger.verbose(`No ArgoCD changes need to be committed for project ${project.slug} in zone ${zoneSlug}`) + return + } + + this.logger.log(`Applying ArgoCD changes for project ${project.slug} in zone ${zoneSlug} (actions=${actions.length})`) + await this.gitlab.maybeCreateCommit(infraProject, `ci: :robot_face: Sync ${project.slug}`, actions) + } + + private async generateEnvironmentActions( + project: ProjectWithDetails, + infraProject: ProjectSchema, + zoneSlug: string, + ) { + const environmentActions = await this.generateEnvironmentsUpdateActions( + project, + project.environments, + infraProject, + zoneSlug, + ) + const deploymentActions = await this.generateDeploymentsUpdateActions( + project, + infraProject, + zoneSlug, + ) + const purgeEnvironmentActions = await this.generatePurgeEnvironmentActions( + project, + infraProject, + zoneSlug, + ) + return [ + ...(deploymentActions.length ? deploymentActions : environmentActions), + ...purgeEnvironmentActions, + ] satisfies CommitAction[] + } + + private async generatePurgeEnvironmentActions( + project: ProjectWithDetails, + infraProject: CondensedProjectSchema, + zoneSlug: string, + ): Promise { + const neededFiles = new Set() + const clusterLabelsInZone = new Set( + project.environments + .filter(e => e.cluster.zone.slug === zoneSlug) + .map(e => e.cluster.label), + ) + + project.environments.forEach((env) => { + if (env.cluster?.zone.slug !== zoneSlug) return + neededFiles.add(formatEnvironmentValuesFilePath(project, env.cluster, env)) + }) + + const existingFiles = await this.gitlab.listFiles(infraProject, { + path: `${project.name}/`, + recursive: true, + }) + + const projectPrefix = `${project.name}/` + const actions = existingFiles + .filter((existingFile) => { + if (existingFile.name !== 'values.yaml') return false + if (!existingFile.path.startsWith(projectPrefix)) return false + + const remaining = existingFile.path.slice(projectPrefix.length) + const clusterLabel = remaining.split('/')[0] + if (!clusterLabel || !clusterLabelsInZone.has(clusterLabel)) return false + + return !neededFiles.has(existingFile.path) + }) + .map(existingFile => ({ action: 'delete', filePath: existingFile.path } satisfies CommitAction)) + + this.logger.verbose(`Computed ArgoCD purge actions for project ${project.slug} in zone ${zoneSlug} (actions=${actions.length})`) + + return actions + } + + private async generateEnvironmentsUpdateActions( + project: ProjectWithDetails, + environments: ProjectWithDetails['environments'], + infraProject: SimpleProjectSchema, + zoneSlug: string, + ): Promise { + this.logger.verbose(`Computing ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug} (environments=${environments.length})`) + const actions = await Promise.all( + environments + .filter(env => env.cluster?.zone.slug === zoneSlug) + .map(env => this.generateEnvironmentUpdateAction(project, env, infraProject)), + ) + const filteredActions: CommitAction[] = actions.filter(a => !!a) + this.logger.verbose(`Computed ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug} (actions=${filteredActions.length})`) + return filteredActions + } + + private async generateEnvironmentUpdateAction( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + infraProject: SimpleProjectSchema, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const vaultValues = await this.generateVaultValues(project.slug) + const cluster = environment.cluster + if (!cluster) { + this.logger.warn(`Cluster not found for environment ${environment.id} in project ${project.slug}`) + throw new Error(`Cluster not found for environment ${environment.id}`) + } + span?.setAttribute('zone.slug', cluster.zone.slug) + + const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment) + + const gitlabPublicProjectUrl = `${(await this.gitlab.getOrCreateProjectGroupPublicUrl())}/${project.slug}` + + const values = formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories: this.argocdConfig.extraRepositories, + infraProject, + valueFilePath, + vaultValues, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, + }) + + return this.gitlab.generateCreateOrUpdateAction( + infraProject, + 'main', + valueFilePath, + stringify(values), + ) + } + + private async generateDeploymentsUpdateActions( + project: ProjectWithDetails, + infraProject: SimpleProjectSchema, + zoneSlug: string, + ): Promise { + this.logger.verbose(`Computing ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug} (environments=${project.deployments.length})`) + const environments = project.deployments.reduce< + Record + >((acc, deployment) => { + const environment = deployment.environment + if (acc[environment.id]) acc[environment.id].deployments.push(deployment) + else acc[environment.id] = { environment, deployments: [deployment] } + return acc + }, {}) + + const actions = await Promise.all( + Object.values(environments) + .filter(({ environment }) => environment.cluster?.zone.slug === zoneSlug) + .map(({ environment, deployments }) => this.generateEnvironmentWithDeploymentsUpdateAction(project, environment, deployments, infraProject)), + ) + + const filteredActions: CommitAction[] = actions.filter(a => !!a) + this.logger.verbose(`Computed ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug} (actions=${filteredActions.length})`) + return filteredActions + } + + private async generateEnvironmentWithDeploymentsUpdateAction( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + deployments: ProjectWithDetails['deployments'][number][], + infraProject: SimpleProjectSchema, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const vaultValues = await this.generateVaultValues(project.slug) + const cluster = environment.cluster + if (!cluster) { + this.logger.warn(`Cluster not found for environment ${environment.id} in project ${project.slug}`) + throw new Error(`Cluster not found for environment ${environment.id}`) + } + span?.setAttribute('zone.slug', cluster.zone.slug) + + const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment) + + const gitlabPublicProjectUrl = `${(await this.gitlab.getOrCreateProjectGroupPublicUrl())}/${project.slug}` + + const values = formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories: this.argocdConfig.extraRepositories, + infraProject, + valueFilePath, + vaultValues, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, + deployments, + }) + + return this.gitlab.generateCreateOrUpdateAction( + infraProject, + 'main', + valueFilePath, + stringify(values), + ) + } + + private async generateVaultValues(projectSlug: string): Promise> { + this.logger.verbose(`Generating Vault project values for projectSlug=${projectSlug}`) + const roleId = await this.vault.getAuthApproleRoleRoleId(projectSlug).catch(() => { + this.logger.warn(`Couldn't find app role (project=${projectSlug})`) + return undefined + }) + const secretId = await this.vault.createAuthApproleRoleSecretId(projectSlug).catch(() => { + this.logger.warn(`Couldn't find secret (project=${projectSlug})`) + return undefined + }) + return { + projectsRootDir: this.baseConfig.projectsRootDir, + url: this.argocdConfig.vaultDeployVaultConnectionInNs ? this.vaultConfig.url : '', + coreKvName: this.vaultConfig.kvName, + roleId: roleId ?? 'none', + secretId: secretId ?? 'none', + } + } +} + +interface ValuesSchema { + common: { + 'dso/project': string + 'dso/project.id': string + 'dso/project.slug': string + 'dso/environment': string + 'dso/environment.id': string + } + argocd: { + cluster: string + namespace: string + project: string + envChartVersion: string + nsChartVersion: string + } + environment: { + valueFileRepository: string + valueFileRevision: string + valueFilePath: string + roGroup: string + rwGroup: string + consoleAdminGroup: string + platformAdminGroup: string + platformReadonlyGroup: string + platformSecurityGroup: string + projectAdminGroup: string + projectDevopsGroup: string + projectDevelopperGroup: string + projectSecurityGroup: string + projectReadonlyGroup: string + } + application: { + quota: { + cpu: number + gpu: number + memory: string + } + sourceRepositories: string[] + destination: { + namespace: string + name: string + } + autosync: boolean + vault: Record + repositories: { + name: string + id: string + repoURL: string + targetRevision: string + path: string + valueFiles: string[] + }[] + } + features: { + fineGrainedRoles: { + enabled: boolean + } + } +} + +function formatReadOnlyGroupName(projectSlug: string, environmentName: string) { + return generateEnvironmentConsoleGroupPath(projectSlug, environmentName, 'RO') +} + +function formatReadWriteGroupName(projectSlug: string, environmentName: string) { + return generateEnvironmentConsoleGroupPath(projectSlug, environmentName, 'RW') +} + +function generateEnvironmentConsoleGroupPath(projectSlug: string, environmentName: string, access: 'RO' | 'RW') { + return `/${projectSlug}/console/${environmentName}/${access}` +} + +function generateProjectConsoleGroupPath(projectSlug: string, suffix: string) { + return `/${projectSlug}${suffix}` +} + +function formatAppProjectName(projectSlug: string, env: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${envHash}` +} + +function formatEnvironmentValuesFilePath(project: { name: string }, cluster: { label: string }, env: { name: string }): string { + return `${project.name}/${cluster.label}/${env.name}/values.yaml` +} + +function getDistinctZones(project: ProjectWithDetails) { + const zones = new Set() + project.environments.forEach(e => zones.add(e.cluster.zone.slug)) + return [...zones] +} + +function splitExtraRepositories(extraRepositories: string | undefined): string[] { + if (!extraRepositories) return [] + return extraRepositories.split(',').map(r => r.trim()).filter(r => r.length > 0) +} + +function formatRepositoriesValues( + repositories: ProjectWithDetails['repositories'], + gitlabPublicProjectUrl: string, + envName: string, +) { + return repositories + .filter(repo => repo.isInfra) + .map((repository) => { + const valueFiles = splitExtraRepositories(repository.helmValuesFiles?.replaceAll('', envName)) + return { + id: repository.id, + name: repository.internalRepoName, + repoURL: `${gitlabPublicProjectUrl}/${repository.internalRepoName}.git`, + targetRevision: repository.deployRevision || 'HEAD', + path: repository.deployPath || '.', + valueFiles, + } satisfies ValuesSchema['application']['repositories'][number] + }) +} + +function formatRepositoriesValuesFromDeployments( + deployments: ProjectWithDetails['deployments'][number][], + gitlabPublicProjectUrl: string, + envName: string, +) { + return deployments.flatMap(deployment => + deployment.deploymentSources + .map((source) => { + const valueFiles = splitExtraRepositories(source.helmValuesFiles?.replaceAll('', envName)) + return { + name: source.repository.internalRepoName, + id: source.repository.id, + repoURL: `${gitlabPublicProjectUrl}/${source.repository.internalRepoName}.git`, + targetRevision: source.targetRevision || 'HEAD', + path: source.path || '.', + valueFiles, + } satisfies ValuesSchema['application']['repositories'][number] + }), + ) +} + +function formatEnvironmentValues( + project: ProjectWithDetails, + infraProject: SimpleProjectSchema, + valueFilePath: string, + roGroup: string, + rwGroup: string, +) { + return { + valueFileRepository: infraProject.http_url_to_repo, + valueFileRevision: 'HEAD', + valueFilePath, + roGroup, + rwGroup, + consoleAdminGroup: CONSOLE_ADMIN_GROUP_PATH, + platformAdminGroup: PLATFORM_ADMIN_GROUP_PATH, + platformReadonlyGroup: PLATFORM_READONLY_GROUP_PATH, + platformSecurityGroup: PLATFORM_SECURITY_GROUP_PATH, + projectAdminGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_ADMIN_GROUP_PATH_SUFFIX), + projectDevopsGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_DEVOPS_GROUP_PATH_SUFFIX), + projectDevelopperGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX), + projectSecurityGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_SECURITY_GROUP_PATH_SUFFIX), + projectReadonlyGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_READONLY_GROUP_PATH_SUFFIX), + } satisfies ValuesSchema['environment'] +} + +interface FormatSourceRepositoriesValuesOptions { + gitlabPublicProjectUrl: string + argocdExtraRepositories?: string + projectPlugins?: ProjectWithDetails['plugins'] +} + +function formatSourceRepositoriesValues( + { gitlabPublicProjectUrl, argocdExtraRepositories, projectPlugins }: FormatSourceRepositoriesValuesOptions, +): string[] { + let projectExtraRepositories = '' + if (projectPlugins) { + const argocdPlugin = projectPlugins.find(p => p.pluginName === 'argocd' && p.key === 'extraRepositories') + if (argocdPlugin) projectExtraRepositories = argocdPlugin.value + } + + return [ + `${gitlabPublicProjectUrl}/**`, + ...splitExtraRepositories(argocdExtraRepositories), + ...splitExtraRepositories(projectExtraRepositories), + ] +} + +interface FormatCommonOptions { + project: ProjectWithDetails + environment: ProjectWithDetails['environments'][number] +} + +function formatCommon({ project, environment }: FormatCommonOptions) { + return { + 'dso/project': project.name, + 'dso/project.id': project.id, + 'dso/project.slug': project.slug, + 'dso/environment': environment.name, + 'dso/environment.id': environment.id, + } satisfies ValuesSchema['common'] +} + +interface FormatArgoCDValuesOptions { + namespace: string + project: string + envChartVersion: string + nsChartVersion: string +} + +function formatArgoCDValues(options: FormatArgoCDValuesOptions) { + const { namespace, project, envChartVersion, nsChartVersion } = options + return { + cluster: inClusterLabel, + namespace, + project, + envChartVersion, + nsChartVersion, + } satisfies ValuesSchema['argocd'] +} + +interface FormatValuesOptions { + project: ProjectWithDetails + environment: ProjectWithDetails['environments'][number] + cluster: ProjectWithDetails['environments'][number]['cluster'] + gitlabPublicProjectUrl: string + argocdExtraRepositories?: string + vaultValues: Record + infraProject: SimpleProjectSchema + valueFilePath: string + argoNamespace: string + envChartVersion: string + nsChartVersion: string + deployments?: ProjectWithDetails['deployments'][number][] +} + +function formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories, + vaultValues, + infraProject, + valueFilePath, + argoNamespace, + envChartVersion, + nsChartVersion, + deployments, +}: FormatValuesOptions) { + return { + common: formatCommon({ project, environment }), + argocd: formatArgoCDValues({ + namespace: argoNamespace, + project: formatAppProjectName(project.slug, environment.name), + envChartVersion, + nsChartVersion, + }), + environment: formatEnvironmentValues( + project, + infraProject, + valueFilePath, + formatReadOnlyGroupName(project.slug, environment.name), + formatReadWriteGroupName(project.slug, environment.name), + ), + application: { + quota: { + cpu: environment.cpu, + gpu: environment.gpu, + memory: `${environment.memory}Gi`, + }, + sourceRepositories: formatSourceRepositoriesValues({ + gitlabPublicProjectUrl, + argocdExtraRepositories, + projectPlugins: project.plugins, + }), + destination: { + namespace: generateNamespaceName(project.id, environment.id), + name: cluster.label, + }, + autosync: environment.autosync, + vault: vaultValues, + repositories: deployments + ? formatRepositoriesValuesFromDeployments(deployments, gitlabPublicProjectUrl, environment.name) + : formatRepositoriesValues( + project.repositories, + gitlabPublicProjectUrl, + environment.name, + ), + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + } satisfies ValuesSchema +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/configurable-feature-module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/configurable-feature-module.ts new file mode 100644 index 0000000000..85b2418316 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/configurable-feature-module.ts @@ -0,0 +1,78 @@ +import type { DynamicModule } from '@nestjs/common' +import { ConfigurableModuleBuilder } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' + +// Minimal shape of the generated configurable class, so consumers (e.g. `XModule.forRoot(...)`) +// see the `forRoot` static method. The arg is `TOptions` (plain object, for tests) OR a +// `ConfigFactory` (e.g. `registerAs('', ...)`), whose `.asProvider()` is wired by +// `forRoot` into this module's options token. +export interface ConfigurableCls { + new(...args: any[]): any + forRoot: (arg: TOptions | any) => DynamicModule +} + +// Builds a configurable module that OWNS its config contract (interface embedded in-module, +// no ../../config / registerAs dependency inside the module). The module is wired from the +// outside via `XModule.forRoot(xxxConfigFactory)`, where `xxxConfigFactory` is the +// `registerAs('', ...)` result from `config/*.config.ts`. `` MUST equal +// `token` so `ConfigModule.forFeature` registers it under `CONFIGURATION()`. +// `isGlobal` makes the module (and its config token) available app-wide via @Global(). +// ponytail: native forRoot only takes a plain options object; we wrap it so it also accepts +// a `ConfigFactory` and wires it (via ConfigModule.forFeature + a provider under the token). +function isConfigFactory(arg: unknown): boolean { + return typeof arg === 'function' && 'asProvider' in (arg as any) + && typeof (arg as any).asProvider === 'function' +} + +function isConfigProvider(arg: unknown): arg is { provide: unknown, useFactory?: unknown, useValue?: unknown } { + return Boolean(arg) && typeof arg === 'object' + && 'provide' in (arg as Record) + && ('useFactory' in (arg as Record) || 'useValue' in (arg as Record)) +} + +export function createConfigurableModule(token: string, isGlobal = false) { + const { ConfigurableModuleClass } = new ConfigurableModuleBuilder({ optionsInjectionToken: token }) + .setClassMethodName('forRoot') + .setFactoryMethodName('useFactory') + .setExtras( + { isGlobal }, + (definition: DynamicModule, extras) => ({ + ...definition, + exports: [...(definition.exports ?? []), token], + global: extras.isGlobal, + }), + ) + .build() + + class WrappedModuleClass extends (ConfigurableModuleClass as any) { + static forRoot(arg: any): DynamicModule { + const def = (super.forRoot as (o: unknown) => DynamicModule)({}) as DynamicModule + if (isConfigFactory(arg)) { + const provider = (arg as any).asProvider() + return { + ...def, + imports: [(ConfigModule as any).forFeature(arg), ...(def.imports ?? [])], + providers: [ + ...(def.providers ?? []), + { provide: token, useFactory: provider.useFactory, inject: provider.inject }, + ], + exports: [...(def.exports ?? []), token], + } + } + if (isConfigProvider(arg)) { + const provider = arg as any + return { + ...def, + providers: [...(def.providers ?? []), provider], + exports: [...(def.exports ?? []), provider.provide], + } + } + return (super.forRoot as (o: unknown) => DynamicModule)(arg) + } + } + + return { + ConfigurableModuleClass: WrappedModuleClass as unknown as ConfigurableCls, + MODULE_OPTIONS_TOKEN: token, + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts new file mode 100644 index 0000000000..01267db740 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts @@ -0,0 +1,155 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' + +const mockDeployment = { + name: 'dep1', + id: 'dep1', + projectId: 'id', + memory: 3, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: 'envId', +} + +describe('deploymentDatastoreService', () => { + let module: TestingModule + let service: DeploymentDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + DeploymentDatastoreService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(DeploymentDatastoreService) + }) + + describe('getDeploymentById', () => { + it('should return a deployment with sources and repository', async () => { + const deployment = { ...mockDeployment } + + prisma.deployment.findUnique.mockResolvedValue(deployment) + + const result = await service.getDeploymentById('dep1') + + expect(prisma.deployment.findUnique).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + }) + expect(result).toEqual(deployment) + }) + }) + + describe('getDeploymentsByProjectId', () => { + it('should return deployments for a project', async () => { + const deployments = [{ ...mockDeployment }] + + prisma.deployment.findMany.mockResolvedValue(deployments) + + const result = await service.getDeploymentsByProjectId('project1') + + expect(prisma.deployment.findMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }) + expect(result).toEqual(deployments) + }) + }) + + describe('createDeployment', () => { + it('should create a deployment', async () => { + const created = { + ...mockDeployment, + name: 'test', + } + + const data = { + ...created, + project: { connect: { id: 'id' } }, + environment: { connect: { id: 'envId' } }, + } + + prisma.deployment.create.mockResolvedValue(data) + + const result = await service.createDeployment({ + ...data, + }) + + expect(prisma.deployment.create).toHaveBeenCalledWith({ data }) + expect(result).toEqual(data) + }) + }) + + describe('updateDeployment', () => { + it('should update a deployment', async () => { + const updated = { ...mockDeployment, name: 'updated' } + + prisma.deployment.update.mockResolvedValue(updated) + + const result = await service.updateDeployment('dep1', { + ...mockDeployment, + name: 'updated', + }) + + expect(prisma.deployment.update).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + data: { ...mockDeployment, name: 'updated' }, + }) + expect(result).toEqual(updated) + }) + }) + + describe('deleteDeployment', () => { + it('should delete a deployment', async () => { + const deleted = { ...mockDeployment } + + prisma.deployment.delete.mockResolvedValue(deleted) + + const result = await service.deleteDeployment('dep1') + + expect(prisma.deployment.delete).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + }) + expect(result).toEqual(deleted) + }) + }) + + describe('deleteAllDeploymentsByProjectId', () => { + it('should delete all deployments for a project', async () => { + const response = { count: 3 } + + prisma.deployment.deleteMany.mockResolvedValue(response) + + const result = await service.deleteAllDeploymentsByProjectId('project1') + + expect(prisma.deployment.deleteMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + }) + expect(result).toEqual(response) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts new file mode 100644 index 0000000000..859a299494 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts @@ -0,0 +1,56 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +@Injectable() +export class DeploymentDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + getDeploymentById(deploymentId: string) { + return this.prisma.deployment.findUnique({ + where: { id: deploymentId }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + }) + } + + getDeploymentsByProjectId(projectId: string) { + return this.prisma.deployment.findMany({ + where: { projectId }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }) + } + + createDeployment(data: Prisma.DeploymentCreateInput) { + return this.prisma.deployment.create({ data }) + } + + updateDeployment(deploymentId: string, data: Prisma.DeploymentUpdateInput) { + return this.prisma.deployment.update({ + where: { id: deploymentId }, + data, + }) + } + + deleteDeployment(deploymentId: string) { + return this.prisma.deployment.delete({ + where: { id: deploymentId }, + }) + } + + deleteAllDeploymentsByProjectId(projectId: string) { + return this.prisma.deployment.deleteMany({ + where: { projectId }, + }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts new file mode 100644 index 0000000000..20f7e249a3 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts @@ -0,0 +1,85 @@ +import type { Deployment, DeploymentSource, Environment, Prisma, Repository } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeDeployment(overrides: Partial = {}): Deployment { + return { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + autosync: true, + environmentId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } +} + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + projectId: faker.string.uuid(), + memory: 2, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export function makeRepository(overrides: Partial = {}): Repository { + return { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: faker.string.alphanumeric(8).toLowerCase(), + externalRepoUrl: '', + externalUserName: '', + isInfra: false, + isPrivate: false, + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } +} + +type DeploymentSourceWithRepository = DeploymentSource & { repository: Repository } + +export function makeDeploymentSource(overrides: Partial = {}): DeploymentSourceWithRepository { + const repositoryId = overrides.repositoryId ?? faker.string.uuid() + return { + id: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + deploymentId: faker.string.uuid(), + repositoryId, + type: 'git', + targetRevision: 'main', + path: '/app', + helmValuesFiles: '', + repository: makeRepository({ id: repositoryId }), + ...overrides, + } +} + +export type DeploymentWithRelations = Prisma.DeploymentGetPayload<{ + include: { + environment: true + deploymentSources: { include: { repository: true } } + } +}> + +export function makeDeploymentWithRelations(overrides: Partial = {}): DeploymentWithRelations { + const base = makeDeployment(overrides) + return { + ...base, + environment: overrides.environment ?? makeEnvironment({ id: base.environmentId, projectId: base.projectId }), + deploymentSources: overrides.deploymentSources ?? [makeDeploymentSource({ deploymentId: base.id })], + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts new file mode 100644 index 0000000000..53f28c259f --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts @@ -0,0 +1,139 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { makeDeployment, makeDeploymentWithRelations } from './deployment-testing.utils' +import { DeploymentController } from './deployment.controller' +import { DeploymentService } from './deployment.service' + +describe('deploymentController', () => { + let module: TestingModule + let controller: DeploymentController + let service: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + + const project: ProjectContext = { id: projectId, slug: 'my-project' } + const user: UserContext = { userId } + const request = { id: requestId } as FastifyRequest + + const validCreateDeployment = { + name: 'dev', + projectId, + environmentId: '22222222-2222-2222-2222-222222222222', + autosync: true, + deploymentSources: [ + { + repositoryId: '33333333-3333-3333-3333-333333333333', + type: 'git', + targetRevision: 'main', + path: '/app', + }, + ], + } satisfies CreateDeployment + + const validUpdateDeployment = { + ...validCreateDeployment, + deploymentSources: [ + { + id: '44444444-4444-4444-4444-444444444444', + repositoryId: '33333333-3333-3333-3333-333333333333', + type: 'git', + targetRevision: 'develop', + path: '/updated-app', + }, + ], + } satisfies UpdateDeployment + + beforeEach(async () => { + service = mockDeep() + + module = await Test.createTestingModule({ + controllers: [DeploymentController], + providers: [ + { provide: DeploymentService, useValue: service }, + ], + }) + .overrideGuard(ProjectGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(DeploymentController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call deploymentService.listByProjectId with projectId', async () => { + const expectedResult = [makeDeploymentWithRelations({ projectId })] + + service.listByProjectId.mockResolvedValue(expectedResult) + + const result = await controller.list(project) + + expect(service.listByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('create', () => { + it('should validate body and call deploymentService.createDeployment', async () => { + const expectedResult = makeDeployment() + + service.createDeployment.mockResolvedValue(expectedResult) + + const result = await controller.create(validCreateDeployment, project, user, request) + + expect(service.createDeployment).toHaveBeenCalledWith( + projectId, + validCreateDeployment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('update', () => { + it('should validate body and call deploymentService.updateDeployment', async () => { + const deploymentId = '55555555-5555-5555-5555-555555555555' + const expectedResult = makeDeployment({ id: deploymentId }) + + service.updateDeployment.mockResolvedValue(expectedResult) + + const result = await controller.update(deploymentId, validUpdateDeployment, project, user, request) + + expect(service.updateDeployment).toHaveBeenCalledWith( + projectId, + deploymentId, + validUpdateDeployment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('delete', () => { + it('should call deploymentService.deleteDeployment with deploymentId', async () => { + const deploymentId = '55555555-5555-5555-5555-555555555555' + + service.deleteDeployment.mockResolvedValue(undefined) + + const result = await controller.delete(deploymentId, project, user, request) + + expect(service.deleteDeployment).toHaveBeenCalledWith(projectId, deploymentId, userId, requestId) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.controller.ts new file mode 100644 index 0000000000..d3a8706e01 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.controller.ts @@ -0,0 +1,63 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { CreateDeploymentSchema, UpdateDeploymentSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { DeploymentService } from './deployment.service' + +@Controller('api/v1/projects/:projectId/deployments') +@UseGuards(ProjectGuard) +export class DeploymentController { + constructor(@Inject(DeploymentService) private readonly deploymentService: DeploymentService) {} + + @Get('') + @RequireAdminPermission('ListProjects') + @RequireProjectPermission('ListDeployments') + list(@Project() project: ProjectContext) { + return this.deploymentService.listByProjectId(project.id) + } + + @Post('') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.CREATED) + create( + @Body(new ZodValidationPipe(CreateDeploymentSchema)) data: CreateDeployment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.createDeployment(project.id, data, user.userId, request.id) + } + + @Put(':deploymentId') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.OK) + update( + @Param('deploymentId') deploymentId: string, @Body(new ZodValidationPipe(UpdateDeploymentSchema)) + data: UpdateDeployment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.updateDeployment(project.id, deploymentId, data, user.userId, request.id) + } + + @Delete(':deploymentId') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.NO_CONTENT) + delete( + @Param('deploymentId') deploymentId: string, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.deleteDeployment(project.id, deploymentId, user.userId, request.id) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.module.ts new file mode 100644 index 0000000000..cc0f6e1a91 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { ProjectModule } from '../project/project.module' +import { DeploymentDatastoreService } from './deployment-datastore.service' +import { DeploymentController } from './deployment.controller' +import { DeploymentService } from './deployment.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, EventsModule, ProjectModule, ProjectPermissionModule], + controllers: [DeploymentController], + providers: [ + DeploymentDatastoreService, + DeploymentService, + ], +}) +export class DeploymentModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts new file mode 100644 index 0000000000..3b9e19c058 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts @@ -0,0 +1,222 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' +import { makeDeployment, makeDeploymentSource, makeDeploymentWithRelations } from './deployment-testing.utils' +import { DeploymentService } from './deployment.service' + +describe('deploymentService', () => { + let module: TestingModule + let service: DeploymentService + let datastore: DeepMockProxy + let appEvents: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + const deploymentId = '22222222-2222-2222-2222-222222222222' + + const okArgoCDResults = { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } } + + const validCreateDeployment = { + name: 'mydeployment', + projectId, + environmentId: '33333333-3333-3333-3333-333333333333', + autosync: true, + deploymentSources: [ + { + repositoryId: '44444444-4444-4444-4444-444444444444', + type: 'git', + targetRevision: 'main', + path: '/app', + helmValuesFiles: 'values.yaml', + }, + ], + } satisfies CreateDeployment + + const validUpdateDeployment = { + ...validCreateDeployment, + deploymentSources: [ + { + id: '55555555-5555-5555-5555-555555555555', + repositoryId: '44444444-4444-4444-4444-444444444444', + type: 'git', + targetRevision: 'develop', + path: '/updated-app', + helmValuesFiles: 'updated-values.yaml', + }, + ], + } satisfies UpdateDeployment + + beforeEach(async () => { + datastore = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + DeploymentService, + { provide: DeploymentDatastoreService, useValue: datastore }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(DeploymentService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('listByProjectId', () => { + it('should return deployments by projectId', async () => { + const deployments = [makeDeploymentWithRelations({ id: deploymentId, projectId })] + datastore.getDeploymentsByProjectId.mockResolvedValue(deployments) + + const result = await service.listByProjectId(projectId) + + expect(datastore.getDeploymentsByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(deployments) + }) + }) + + describe('createDeployment', () => { + it('should create deployment and trigger a project reconciliation', async () => { + const createdDeployment = makeDeployment({ id: deploymentId, projectId }) + + datastore.createDeployment.mockResolvedValue(createdDeployment) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + const result = await service.createDeployment(projectId, validCreateDeployment, userId, requestId) + + expect(datastore.createDeployment).toHaveBeenCalledWith({ + name: validCreateDeployment.name, + project: { connect: { id: projectId } }, + autosync: validCreateDeployment.autosync, + environment: { connect: { id: validCreateDeployment.environmentId } }, + deploymentSources: { + createMany: { + data: validCreateDeployment.deploymentSources.map(source => ({ + type: source.type, + repositoryId: source.repositoryId, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + })), + }, + }, + }) + + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Create Deployment', userId, requestId }) + expect(result).toEqual(createdDeployment) + }) + + it('should return the deployment even when the reconciliation fails', async () => { + const createdDeployment = makeDeployment({ id: deploymentId, projectId }) + datastore.createDeployment.mockResolvedValue(createdDeployment) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + const result = await service.createDeployment(projectId, validCreateDeployment, userId, requestId) + + expect(result).toEqual(createdDeployment) + }) + }) + + describe('updateDeployment', () => { + it('should update deployment and trigger a project reconciliation', async () => { + const existingDeployment = makeDeploymentWithRelations({ + id: deploymentId, + projectId, + deploymentSources: [ + makeDeploymentSource({ id: '55555555-5555-5555-5555-555555555555', deploymentId }), + makeDeploymentSource({ id: '66666666-6666-6666-6666-666666666666', deploymentId }), + ], + }) + + const updatedDeployment = makeDeployment({ id: deploymentId, projectId }) + + datastore.getDeploymentById.mockResolvedValue(existingDeployment) + datastore.updateDeployment.mockResolvedValue(updatedDeployment) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + const result = await service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId) + + expect(datastore.updateDeployment).toHaveBeenCalledWith( + deploymentId, + expect.objectContaining({ + name: validUpdateDeployment.name, + deploymentSources: { + deleteMany: { + id: { in: ['66666666-6666-6666-6666-666666666666'] }, + }, + upsert: expect.any(Array), + }, + }), + ) + + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Update Deployment', userId, requestId }) + expect(result).toEqual(updatedDeployment) + }) + + it('should throw if deployment does not exist', async () => { + datastore.getDeploymentById.mockResolvedValue(null) + + await expect( + service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId), + ).rejects.toThrow(`Deployment with id ${deploymentId} not found`) + + expect(datastore.updateDeployment).not.toHaveBeenCalled() + }) + + it('should return the deployment even when the reconciliation fails', async () => { + const existingDeployment = makeDeploymentWithRelations({ + id: deploymentId, + projectId, + deploymentSources: [ + makeDeploymentSource({ id: '55555555-5555-5555-5555-555555555555', deploymentId }), + ], + }) + const updatedDeployment = makeDeployment({ id: deploymentId, projectId }) + datastore.getDeploymentById.mockResolvedValue(existingDeployment) + datastore.updateDeployment.mockResolvedValue(updatedDeployment) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + const result = await service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId) + + expect(result).toEqual(updatedDeployment) + }) + }) + + describe('deleteDeployment', () => { + it('should delete deployment and trigger a project reconciliation', async () => { + datastore.deleteDeployment.mockResolvedValue(makeDeployment({ id: deploymentId, projectId })) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + await service.deleteDeployment(projectId, deploymentId, userId, requestId) + + expect(datastore.deleteDeployment).toHaveBeenCalledWith(deploymentId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Delete Deployment', userId, requestId }) + }) + + it('should resolve even when the reconciliation fails', async () => { + datastore.deleteDeployment.mockResolvedValue(makeDeployment({ id: deploymentId, projectId })) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + await expect(service.deleteDeployment(projectId, deploymentId, userId, requestId)).resolves.toBeUndefined() + }) + }) + + describe('deleteAllDeploymentsByProjectId', () => { + it('should delete all deployments and upsert project', async () => { + datastore.deleteAllDeploymentsByProjectId.mockResolvedValue({ count: 1 }) + + await service.deleteAllDeploymentsByProjectId(projectId) + + expect(datastore.deleteAllDeploymentsByProjectId).toHaveBeenCalledWith(projectId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Delete all project deployments' }) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.service.ts new file mode 100644 index 0000000000..beec47e562 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/deployment/deployment.service.ts @@ -0,0 +1,108 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { EventLogAction } from '../events/app-events.service' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' + +@Injectable() +export class DeploymentService { + private readonly logger = new Logger(DeploymentService.name) + + constructor( + @Inject(DeploymentDatastoreService) private readonly deploymentDatastoreService: DeploymentDatastoreService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async listByProjectId(projectId: string) { + return this.deploymentDatastoreService.getDeploymentsByProjectId(projectId) + } + + async createDeployment(projectId: string, deploymentToCreate: CreateDeployment, userId: string, requestId: string) { + const deployment = await this.deploymentDatastoreService.createDeployment({ + name: deploymentToCreate.name, + project: { connect: { id: projectId } }, + autosync: deploymentToCreate.autosync, + environment: { connect: { id: deploymentToCreate.environmentId } }, + deploymentSources: { + createMany: { + data: deploymentToCreate.deploymentSources.map(({ type, repositoryId, targetRevision, path, helmValuesFiles }) => ({ + type, + repositoryId, + targetRevision, + path, + helmValuesFiles, + })), + }, + }, + }) + + this.reconcileProject(projectId, 'Create Deployment', userId, requestId) + return deployment + } + + async updateDeployment(projectId: string, deploymentId: string, deploymentToUpdate: UpdateDeployment, userId: string, requestId: string) { + const existing = await this.deploymentDatastoreService.getDeploymentById(deploymentId) + if (!existing) throw new Error(`Deployment with id ${deploymentId} not found`) + + const incomingDeploymentSourceIds = new Set( + deploymentToUpdate.deploymentSources + .filter(s => s.id) + .map(s => s.id), + ) + + const deploymentSourcesToDelete = existing.deploymentSources.filter( + e => !incomingDeploymentSourceIds.has(e.id), + ) + + const deployment = await this.deploymentDatastoreService.updateDeployment(deploymentId, { + name: deploymentToUpdate.name, + autosync: deploymentToUpdate.autosync, + environment: { connect: { id: deploymentToUpdate.environmentId } }, + deploymentSources: { + deleteMany: { + id: { in: deploymentSourcesToDelete.map(s => s.id) }, + }, + upsert: deploymentToUpdate.deploymentSources.map(source => ({ + where: { id: source.id ?? crypto.randomUUID() }, + update: { + repository: { connect: { id: source.repositoryId } }, + type: source.type, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + }, + create: { + repository: { connect: { id: source.repositoryId } }, + type: source.type, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + }, + })), + }, + }) + this.reconcileProject(projectId, 'Update Deployment', userId, requestId) + return deployment + } + + async deleteDeployment(projectId: string, deploymentId: string, userId: string, requestId: string) { + await this.deploymentDatastoreService.deleteDeployment(deploymentId) + this.reconcileProject(projectId, 'Delete Deployment', userId, requestId) + } + + async deleteAllDeploymentsByProjectId(projectId: string) { + await this.deploymentDatastoreService.deleteAllDeploymentsByProjectId(projectId) + await this.appEvents.emitProjectEvent('project.upsert', projectId, { action: 'Delete all project deployments' }) + } + + /** + * Triggers the project reconciliation without blocking the response: listener + * outcomes (including failures) are persisted in the admin log by AppEventsService. + */ + private reconcileProject(projectId: string, action: EventLogAction, userId: string, requestId: string) { + this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) + .catch((error: unknown) => { + this.logger.error(`project.upsert reconciliation failed (projectId=${projectId})`, error instanceof Error ? error.stack : String(error)) + }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts new file mode 100644 index 0000000000..6189f20316 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts @@ -0,0 +1,181 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeCluster, makeEnvironment, makeStage } from './environment-testing.utils' + +describe('environmentDatastoreService', () => { + let module: TestingModule + let service: EnvironmentDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentDatastoreService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(EnvironmentDatastoreService) + }) + + describe('getProjectEnvironment', () => { + it('should return an environment with its cluster scoped to the project', async () => { + const environment = makeEnvironment({ id: 'env1', projectId: 'project1' }) + prisma.environment.findFirst.mockResolvedValue(environment) + + const result = await service.getProjectEnvironment('project1', 'env1') + + expect(prisma.environment.findFirst).toHaveBeenCalledWith({ + where: { id: 'env1', projectId: 'project1' }, + include: { cluster: true }, + }) + expect(result).toEqual(environment) + }) + }) + + describe('getEnvironmentsByProjectId', () => { + it('should return environments for a project with their stage', async () => { + const environments = [makeEnvironment({ projectId: 'project1' })] + prisma.environment.findMany.mockResolvedValue(environments) + + const result = await service.getEnvironmentsByProjectId('project1') + + expect(prisma.environment.findMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + include: { stage: true }, + }) + expect(result).toEqual(environments) + }) + }) + + describe('getEnvironmentByName', () => { + it('should look the environment up by its project unique name', async () => { + prisma.environment.findUnique.mockResolvedValue(null) + + const result = await service.getEnvironmentByName('project1', 'dev') + + expect(prisma.environment.findUnique).toHaveBeenCalledWith({ + where: { projectId_name: { projectId: 'project1', name: 'dev' } }, + }) + expect(result).toBeNull() + }) + }) + + describe('getStageById', () => { + it('should return the stage', async () => { + const stage = makeStage({ id: 'stage1' }) + prisma.stage.findUnique.mockResolvedValue(stage) + + const result = await service.getStageById('stage1') + + expect(prisma.stage.findUnique).toHaveBeenCalledWith({ where: { id: 'stage1' } }) + expect(result).toEqual(stage) + }) + }) + + describe('getProdStageIds', () => { + it('should return the ids of the prod stages', async () => { + prisma.stage.findMany.mockResolvedValue([makeStage({ id: 'stage1', name: 'prod' })]) + + await service.getProdStageIds() + + expect(prisma.stage.findMany).toHaveBeenCalledWith({ + select: { id: true }, + where: { name: 'prod' }, + }) + }) + }) + + describe('getAvailableCluster', () => { + it('should only match public clusters or dedicated clusters of the project', async () => { + const cluster = makeCluster({ id: 'cluster1' }) + prisma.cluster.findFirst.mockResolvedValue(cluster) + + const result = await service.getAvailableCluster('cluster1', 'project1') + + expect(prisma.cluster.findFirst).toHaveBeenCalledWith({ + where: { + OR: [{ + id: 'cluster1', + privacy: 'public', + }, { + id: 'cluster1', + privacy: 'dedicated', + projects: { some: { id: 'project1' } }, + }], + }, + }) + expect(result).toEqual(cluster) + }) + }) + + describe('sumEnvironmentResources', () => { + it('should aggregate cpu, gpu and memory', async () => { + await service.sumEnvironmentResources({ clusterId: 'cluster1' }) + + expect(prisma.environment.aggregate).toHaveBeenCalledWith({ + _sum: { + cpu: true, + gpu: true, + memory: true, + }, + where: { clusterId: 'cluster1' }, + }) + }) + }) + + describe('createEnvironment', () => { + it('should create an environment', async () => { + const environment = makeEnvironment() + prisma.environment.create.mockResolvedValue(environment) + + const result = await service.createEnvironment({ + projectId: environment.projectId, + name: environment.name, + clusterId: environment.clusterId, + stageId: environment.stageId, + cpu: environment.cpu, + gpu: environment.gpu, + memory: environment.memory, + autosync: environment.autosync, + }) + + expect(prisma.environment.create).toHaveBeenCalled() + expect(result).toEqual(environment) + }) + }) + + describe('updateEnvironment', () => { + it('should update the environment resources', async () => { + const environment = makeEnvironment({ id: 'env1' }) + prisma.environment.update.mockResolvedValue(environment) + + const result = await service.updateEnvironment('env1', { cpu: 2, gpu: 0, memory: 4, autosync: false }) + + expect(prisma.environment.update).toHaveBeenCalledWith({ + where: { id: 'env1' }, + data: { cpu: 2, gpu: 0, memory: 4, autosync: false }, + }) + expect(result).toEqual(environment) + }) + }) + + describe('deleteEnvironment', () => { + it('should delete the environment', async () => { + const environment = makeEnvironment({ id: 'env1' }) + prisma.environment.delete.mockResolvedValue(environment) + + const result = await service.deleteEnvironment('env1') + + expect(prisma.environment.delete).toHaveBeenCalledWith({ where: { id: 'env1' } }) + expect(result).toEqual(environment) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts new file mode 100644 index 0000000000..1dab19eb23 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts @@ -0,0 +1,99 @@ +import type { Cluster, Environment, Prisma, Project, Stage } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PROD_STAGE_NAME } from './environment.constants' + +export type EnvironmentWithCluster = Environment & { cluster: Cluster } +export type EnvironmentWithStage = Environment & { stage: Stage } + +export interface EnvironmentResourcesSum { + _sum: { + cpu: number | null + gpu: number | null + memory: number | null + } +} + +@Injectable() +export class EnvironmentDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + getProjectEnvironment(projectId: string, environmentId: string): Promise { + return this.prisma.environment.findFirst({ + where: { id: environmentId, projectId }, + include: { cluster: true }, + }) + } + + getEnvironmentsByProjectId(projectId: string): Promise { + return this.prisma.environment.findMany({ + where: { projectId }, + include: { stage: true }, + }) + } + + getEnvironmentByName(projectId: string, name: string): Promise { + return this.prisma.environment.findUnique({ + where: { projectId_name: { projectId, name } }, + }) + } + + getStageById(stageId: string): Promise { + return this.prisma.stage.findUnique({ where: { id: stageId } }) + } + + getProdStageIds(): Promise<{ id: string }[]> { + return this.prisma.stage.findMany({ + select: { id: true }, + where: { name: PROD_STAGE_NAME }, + }) + } + + /** Only public clusters or dedicated clusters attached to the project are usable. */ + getAvailableCluster(clusterId: string, projectId: string): Promise { + return this.prisma.cluster.findFirst({ + where: { + OR: [{ + id: clusterId, + privacy: 'public', + }, { + id: clusterId, + privacy: 'dedicated', + projects: { some: { id: projectId } }, + }], + }, + }) + } + + getProjectById(projectId: string): Promise { + return this.prisma.project.findUniqueOrThrow({ where: { id: projectId } }) + } + + sumEnvironmentResources(where: Prisma.EnvironmentWhereInput): Promise { + return this.prisma.environment.aggregate({ + _sum: { + cpu: true, + gpu: true, + memory: true, + }, + where, + }) + } + + createEnvironment(data: Prisma.EnvironmentUncheckedCreateInput): Promise { + return this.prisma.environment.create({ data }) + } + + updateEnvironment(environmentId: string, data: Pick): Promise { + return this.prisma.environment.update({ + where: { id: environmentId }, + data, + }) + } + + deleteEnvironment(environmentId: string): Promise { + return this.prisma.environment.delete({ + where: { id: environmentId }, + }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts new file mode 100644 index 0000000000..5f0d0ee58d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts @@ -0,0 +1,87 @@ +import type { Cluster, Environment, Project, Stage } from '@prisma/client' +import type { EnvironmentWithCluster, EnvironmentWithStage } from './environment-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + projectId: faker.string.uuid(), + memory: 2, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export function makeCluster(overrides: Partial = {}): Cluster { + return { + id: faker.string.uuid(), + label: faker.string.alphanumeric(8).toLowerCase(), + privacy: 'public', + secretName: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + infos: null, + memory: 0, + cpu: 0, + gpu: 0, + zoneId: faker.string.uuid(), + ...overrides, + } +} + +export function makeStage(overrides: Partial = {}): Stage { + return { + id: faker.string.uuid(), + name: 'dev', + ...overrides, + } +} + +export function makeProject(overrides: Partial = {}): Project { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + description: '', + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.string.alphanumeric(8).toLowerCase(), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } +} + +export function makeEnvironmentWithCluster(overrides: Partial = {}): EnvironmentWithCluster { + const base = makeEnvironment(overrides) + return { + ...base, + cluster: overrides.cluster ?? makeCluster({ id: base.clusterId }), + } +} + +export function makeEnvironmentWithStage(overrides: Partial = {}): EnvironmentWithStage { + const base = makeEnvironment(overrides) + return { + ...base, + stage: overrides.stage ?? makeStage({ id: base.stageId }), + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts new file mode 100644 index 0000000000..1933528eb8 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts @@ -0,0 +1,222 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { BadRequestException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeCluster, makeEnvironment, makeEnvironmentWithCluster, makeProject, makeStage } from './environment-testing.utils' +import { EnvironmentValidationService } from './environment-validation.service' + +describe('environmentValidationService', () => { + let module: TestingModule + let service: EnvironmentValidationService + let datastore: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const environmentId = '22222222-2222-2222-2222-222222222222' + const clusterId = '33333333-3333-3333-3333-333333333333' + const stageId = '44444444-4444-4444-4444-444444444444' + + const validCreateEnvironment = { + name: 'dev', + clusterId, + stageId, + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + datastore = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentValidationService, + { provide: EnvironmentDatastoreService, useValue: datastore }, + ], + }).compile() + + service = module.get(EnvironmentValidationService) + }) + + describe('validateCreate', () => { + it('should pass when the stage, name and cluster are valid and no limit applies', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + // cpu/memory at 0 means the cluster resources are not configured + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)).resolves.toBeUndefined() + }) + + it('should reject when the environment name is already taken', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(makeEnvironment({ projectId, name: validCreateEnvironment.name })) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow(BadRequestException) + }) + + it('should reject when the stage does not exist', async () => { + datastore.getStageById.mockResolvedValue(null) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Type d\'environnement invalide.') + }) + + it('should reject when the cluster is not available for the project', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(null) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Cluster invalide.') + }) + + it('should reject when the cluster does not have enough resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 4, gpu: 0, memory: 8 })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le cluster ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ clusterId }) + }) + + it('should reject when the project does not have enough prod resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'prod' })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + prodCpu: 2, + prodGpu: 0, + prodMemory: 4, + })) + datastore.getProdStageIds.mockResolvedValue([{ id: stageId }]) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 1, gpu: 0, memory: 2 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le projet ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + + it('should reject when the project does not have enough non-prod resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'dev' })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + hprodCpu: 2, + hprodGpu: 0, + hprodMemory: 4, + })) + datastore.getProdStageIds.mockResolvedValue([{ id: '55555555-5555-5555-5555-555555555555' }]) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 1, gpu: 0, memory: 2 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le projet ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + }) + + describe('validateUpdate', () => { + it('should pass when the project is limitless and the cluster limits are not configured', async () => { + const environment = makeEnvironmentWithCluster({ + projectId, + cluster: makeCluster({ id: clusterId, cpu: 0, memory: 0 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + }) + + it('should reject when the cluster does not have enough resources', async () => { + const environment = makeEnvironmentWithCluster({ + projectId, + cluster: makeCluster({ id: clusterId, cpu: 4, gpu: 0, memory: 8 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)) + .rejects.toThrow('Le cluster ne dispose pas de suffisamment de ressources : CPU, GPU, Mémoire.') + }) + + it('should not count the environment being updated in the cluster consumed resources', async () => { + const environment = makeEnvironmentWithCluster({ + id: environmentId, + projectId, + cluster: makeCluster({ id: clusterId, cpu: 8, gpu: 2, memory: 16 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + // the sum only covers the other environments of the cluster + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ + clusterId, + id: { not: environmentId }, + }) + }) + + it('should not count the environment being updated in the project consumed resources', async () => { + const environment = makeEnvironmentWithCluster({ + id: environmentId, + projectId, + stageId, + cluster: makeCluster({ id: clusterId, cpu: 0, memory: 0 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + prodCpu: 8, + prodGpu: 2, + prodMemory: 16, + })) + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'prod' })) + datastore.getProdStageIds.mockResolvedValue([{ id: stageId }]) + // the sum only covers the other prod environments of the project + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ + projectId, + stageId: { in: [stageId] }, + id: { not: environmentId }, + }) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-validation.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-validation.service.ts new file mode 100644 index 0000000000..f81672410a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment-validation.service.ts @@ -0,0 +1,152 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Cluster, Prisma, Project } from '@prisma/client' +import type { EnvironmentWithCluster } from './environment-datastore.service' +import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { PROD_STAGE_NAME } from './environment.constants' + +interface EnvironmentResources { + cpu: number + gpu: number + memory: number +} + +/** cpu and memory both at 0 mean the limits were never configured, so quota checks do not apply. */ +function areResourceLimitsConfigured(limit: EnvironmentResources): boolean { + return limit.cpu !== 0 || limit.memory !== 0 +} + +/** + * Business rules for environments: name unicity, stage and cluster validity, + * cluster and project resource quotas. Existence/ownership (404) concerns stay + * in EnvironmentService; this service only decides whether a request is allowed (400). + */ +@Injectable() +export class EnvironmentValidationService { + constructor( + @Inject(EnvironmentDatastoreService) private readonly environmentDatastoreService: EnvironmentDatastoreService, + ) {} + + async validateCreate(projectId: string, input: CreateEnvironment): Promise { + const errorMessages: string[] = [] + const [stage, sameNameEnvironment, cluster] = await Promise.all([ + this.environmentDatastoreService.getStageById(input.stageId), + this.environmentDatastoreService.getEnvironmentByName(projectId, input.name), + this.environmentDatastoreService.getAvailableCluster(input.clusterId, projectId), + ]) + + if (sameNameEnvironment) errorMessages.push('Ce nom d\'environnement est déjà pris.') + if (!stage) errorMessages.push('Type d\'environnement invalide.') + if (cluster) { + const clusterError = await this.checkClusterResources(input, cluster) + if (clusterError) errorMessages.push(clusterError) + + const project = await this.environmentDatastoreService.getProjectById(projectId) + const projectError = await this.checkProjectResources(input, input.stageId, project) + if (projectError) errorMessages.push(projectError) + } else { + errorMessages.push('Cluster invalide.') + } + + if (errorMessages.length > 0) { + throw new BadRequestException(errorMessages.join('\n')) + } + } + + async validateUpdate(environment: EnvironmentWithCluster, input: UpdateEnvironment): Promise { + const errorMessages: string[] = [] + const clusterError = await this.checkClusterResources(input, environment.cluster, environment.id) + if (clusterError) errorMessages.push(clusterError) + + const project = await this.environmentDatastoreService.getProjectById(environment.projectId) + const projectError = await this.checkProjectResources(input, environment.stageId, project, environment.id) + if (projectError) errorMessages.push(projectError) + + if (errorMessages.length > 0) { + throw new BadRequestException(errorMessages.join('\n')) + } + } + + private async checkClusterResources(request: EnvironmentResources, cluster: Cluster, excludedEnvironmentId?: string): Promise { + const overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: cluster.cpu, gpu: cluster.gpu, memory: cluster.memory }, + where: { clusterId: cluster.id }, + excludedEnvironmentId, + }) + if (overflowResources.length > 0) { + return `Le cluster ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.` + } + return undefined + } + + private async checkProjectResources(request: EnvironmentResources, stageId: string, project: Project, excludedEnvironmentId?: string): Promise { + if (project.limitless) { + // No limits + return undefined + } + const [stage, prodStages] = await Promise.all([ + this.environmentDatastoreService.getStageById(stageId), + this.environmentDatastoreService.getProdStageIds(), + ]) + const prodStageIds = prodStages.map(s => s.id) + + let overflowResources: string[] + if (stage?.name === PROD_STAGE_NAME) { + overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: project.prodCpu, gpu: project.prodGpu, memory: project.prodMemory }, + where: { + projectId: project.id, + stageId: { in: prodStageIds }, + }, + excludedEnvironmentId, + }) + } else { // hprod + overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: project.hprodCpu, gpu: project.hprodGpu, memory: project.hprodMemory }, + where: { + projectId: project.id, + stageId: { notIn: prodStageIds }, + }, + excludedEnvironmentId, + }) + } + if (overflowResources.length > 0) { + return `Le projet ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.` + } + return undefined + } + + /** + * On update, the environment being updated must be excluded from the consumed-resources + * sum: its requested resources replace its current ones. Counting both would reject + * legitimate updates within the quota (#1911). + */ + private async getOverflowResources({ request, limit, where, excludedEnvironmentId }: { + request: EnvironmentResources + limit: EnvironmentResources + where: Prisma.EnvironmentWhereInput + excludedEnvironmentId?: string + }): Promise { + if (!areResourceLimitsConfigured(limit)) { + return [] + } + const environmentResources = await this.environmentDatastoreService.sumEnvironmentResources( + excludedEnvironmentId === undefined ? where : { ...where, id: { not: excludedEnvironmentId } }, + ) + // A null sum means no environment matched: nothing is consumed yet. + const insufficientResources: string[] = [] + if ((environmentResources._sum.cpu ?? 0) + request.cpu > limit.cpu) { + insufficientResources.push('CPU') + } + if ((environmentResources._sum.gpu ?? 0) + request.gpu > limit.gpu) { + insufficientResources.push('GPU') + } + if ((environmentResources._sum.memory ?? 0) + request.memory > limit.memory) { + insufficientResources.push('Mémoire') + } + return insufficientResources + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.constants.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.constants.ts new file mode 100644 index 0000000000..b55c418847 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.constants.ts @@ -0,0 +1,2 @@ +/** Name of the stage whose environments consume the project's production resource quotas. */ +export const PROD_STAGE_NAME = 'prod' diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts new file mode 100644 index 0000000000..1449e91a3a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts @@ -0,0 +1,128 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { makeEnvironment, makeEnvironmentWithStage } from './environment-testing.utils' +import { EnvironmentController } from './environment.controller' +import { EnvironmentService } from './environment.service' + +describe('environmentController', () => { + let module: TestingModule + let controller: EnvironmentController + let service: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + + const project: ProjectContext = { id: projectId, slug: 'my-project' } + const user: UserContext = { userId } + const request = { id: requestId } as FastifyRequest + + const validCreateEnvironment = { + name: 'dev', + clusterId: '22222222-2222-2222-2222-222222222222', + stageId: '33333333-3333-3333-3333-333333333333', + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + service = mockDeep() + + module = await Test.createTestingModule({ + controllers: [EnvironmentController], + providers: [ + { provide: EnvironmentService, useValue: service }, + ], + }) + .overrideGuard(ProjectGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(EnvironmentController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call environmentService.listByProjectId with projectId', async () => { + const expectedResult = [makeEnvironmentWithStage({ projectId })] + + service.listByProjectId.mockResolvedValue(expectedResult) + + const result = await controller.list(project) + + expect(service.listByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('create', () => { + it('should validate body and call environmentService.createEnvironment', async () => { + const expectedResult = makeEnvironment({ projectId }) + + service.createEnvironment.mockResolvedValue(expectedResult) + + const result = await controller.create(validCreateEnvironment, project, user, request) + + expect(service.createEnvironment).toHaveBeenCalledWith( + projectId, + validCreateEnvironment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('update', () => { + it('should validate body and call environmentService.updateEnvironment', async () => { + const environmentId = '44444444-4444-4444-4444-444444444444' + const expectedResult = makeEnvironment({ id: environmentId, projectId }) + + service.updateEnvironment.mockResolvedValue(expectedResult) + + const result = await controller.update(environmentId, validUpdateEnvironment, project, user, request) + + expect(service.updateEnvironment).toHaveBeenCalledWith( + projectId, + environmentId, + validUpdateEnvironment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('delete', () => { + it('should call environmentService.deleteEnvironment with environmentId', async () => { + const environmentId = '44444444-4444-4444-4444-444444444444' + + service.deleteEnvironment.mockResolvedValue(undefined) + + const result = await controller.delete(environmentId, project, user, request) + + expect(service.deleteEnvironment).toHaveBeenCalledWith(projectId, environmentId, userId, requestId) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.controller.ts new file mode 100644 index 0000000000..7589f241f8 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.controller.ts @@ -0,0 +1,73 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Environment } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { EnvironmentWithStage } from './environment-datastore.service' +import { CreateEnvironmentSchema, UpdateEnvironmentSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { EnvironmentService } from './environment.service' + +@Controller('api/v2/projects/:projectId/environments') +@UseGuards(ProjectGuard) +export class EnvironmentController { + constructor(@Inject(EnvironmentService) private readonly environmentService: EnvironmentService) {} + + @Get('') + @RequireAdminPermission('ListProjects') + @RequireProjectPermission('ListEnvironments') + list(@Project() project: ProjectContext): Promise { + return this.environmentService.listByProjectId(project.id) + } + + @Post('') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.CREATED) + create( + @Body(new ZodValidationPipe(CreateEnvironmentSchema)) data: CreateEnvironment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.createEnvironment(project.id, data, user.userId, request.id) + } + + @Put(':environmentId') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.OK) + update( + @Param('environmentId', ParseUUIDPipe) environmentId: string, + @Body(new ZodValidationPipe(UpdateEnvironmentSchema)) data: UpdateEnvironment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.updateEnvironment(project.id, environmentId, data, user.userId, request.id) + } + + @Delete(':environmentId') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.NO_CONTENT) + delete( + @Param('environmentId', ParseUUIDPipe) environmentId: string, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.deleteEnvironment(project.id, environmentId, user.userId, request.id) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.module.ts new file mode 100644 index 0000000000..a9c4f597d4 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { EnvironmentValidationService } from './environment-validation.service' +import { EnvironmentController } from './environment.controller' +import { EnvironmentService } from './environment.service' + +@Module({ + imports: [AppEventsModule, InfrastructureModule], + controllers: [EnvironmentController], + providers: [ + EnvironmentDatastoreService, + EnvironmentValidationService, + EnvironmentService, + ], +}) +export class EnvironmentModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.service.spec.ts new file mode 100644 index 0000000000..d9baf3db40 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.service.spec.ts @@ -0,0 +1,183 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeEnvironment, makeEnvironmentWithCluster, makeEnvironmentWithStage } from './environment-testing.utils' +import { EnvironmentValidationService } from './environment-validation.service' +import { EnvironmentService } from './environment.service' + +describe('environmentService', () => { + let module: TestingModule + let service: EnvironmentService + let datastore: DeepMockProxy + let validation: DeepMockProxy + let appEvents: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + const environmentId = '22222222-2222-2222-2222-222222222222' + const clusterId = '33333333-3333-3333-3333-333333333333' + const stageId = '44444444-4444-4444-4444-444444444444' + + const validCreateEnvironment = { + name: 'dev', + clusterId, + stageId, + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + datastore = mockDeep() + validation = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentService, + { provide: EnvironmentDatastoreService, useValue: datastore }, + { provide: EnvironmentValidationService, useValue: validation }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(EnvironmentService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('listByProjectId', () => { + it('should return environments by projectId', async () => { + const environments = [makeEnvironmentWithStage({ id: environmentId, projectId })] + datastore.getEnvironmentsByProjectId.mockResolvedValue(environments) + + const result = await service.listByProjectId(projectId) + + expect(datastore.getEnvironmentsByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(environments) + }) + }) + + describe('createEnvironment', () => { + it('should validate, create the environment and trigger the project reconciliation', async () => { + const environment = makeEnvironment({ id: environmentId, projectId, clusterId, stageId }) + validation.validateCreate.mockResolvedValue(undefined) + datastore.createEnvironment.mockResolvedValue(environment) + appEvents.emitProjectEvent.mockResolvedValue({}) + + const result = await service.createEnvironment(projectId, validCreateEnvironment, userId, requestId) + + expect(validation.validateCreate).toHaveBeenCalledWith(projectId, validCreateEnvironment) + expect(datastore.createEnvironment).toHaveBeenCalledWith({ + projectId, + name: validCreateEnvironment.name, + clusterId, + stageId, + cpu: validCreateEnvironment.cpu, + gpu: validCreateEnvironment.gpu, + memory: validCreateEnvironment.memory, + autosync: validCreateEnvironment.autosync, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Create Environment', + userId, + requestId, + }) + expect(result).toEqual(environment) + }) + + it('should not create the environment when the validation fails', async () => { + validation.validateCreate.mockRejectedValue(new BadRequestException('Cluster invalide.')) + + await expect(service.createEnvironment(projectId, validCreateEnvironment, userId, requestId)) + .rejects.toThrow(BadRequestException) + expect(datastore.createEnvironment).not.toHaveBeenCalled() + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) + }) + + describe('updateEnvironment', () => { + it('should validate, update the environment and trigger the project reconciliation', async () => { + const existing = makeEnvironmentWithCluster({ id: environmentId, projectId }) + const updated = makeEnvironment({ id: environmentId, projectId, ...validUpdateEnvironment }) + datastore.getProjectEnvironment.mockResolvedValue(existing) + validation.validateUpdate.mockResolvedValue(undefined) + datastore.updateEnvironment.mockResolvedValue(updated) + appEvents.emitProjectEvent.mockResolvedValue({}) + + const result = await service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId) + + expect(validation.validateUpdate).toHaveBeenCalledWith(existing, validUpdateEnvironment) + expect(datastore.updateEnvironment).toHaveBeenCalledWith(environmentId, validUpdateEnvironment) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Update Environment', + userId, + requestId, + }) + expect(result).toEqual(updated) + }) + + it('should reject when the environment is not found in the project', async () => { + datastore.getProjectEnvironment.mockResolvedValue(null) + + await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) + .rejects.toThrow(NotFoundException) + expect(datastore.getProjectEnvironment).toHaveBeenCalledWith(projectId, environmentId) + expect(validation.validateUpdate).not.toHaveBeenCalled() + expect(datastore.updateEnvironment).not.toHaveBeenCalled() + }) + + it('should not update the environment when the validation fails', async () => { + datastore.getProjectEnvironment.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId, projectId })) + validation.validateUpdate.mockRejectedValue(new BadRequestException('Le cluster ne dispose pas de suffisamment de ressources : CPU.')) + + await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) + .rejects.toThrow(BadRequestException) + expect(datastore.updateEnvironment).not.toHaveBeenCalled() + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) + }) + + describe('deleteEnvironment', () => { + it('should delete the environment and trigger the project reconciliation', async () => { + datastore.getProjectEnvironment.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId, projectId })) + datastore.deleteEnvironment.mockResolvedValue(makeEnvironment({ id: environmentId, projectId })) + appEvents.emitProjectEvent.mockResolvedValue({}) + + await service.deleteEnvironment(projectId, environmentId, userId, requestId) + + expect(datastore.deleteEnvironment).toHaveBeenCalledWith(environmentId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Delete Environment', + userId, + requestId, + }) + }) + + it('should reject when the environment is not found in the project', async () => { + datastore.getProjectEnvironment.mockResolvedValue(null) + + await expect(service.deleteEnvironment(projectId, environmentId, userId, requestId)) + .rejects.toThrow(NotFoundException) + expect(datastore.getProjectEnvironment).toHaveBeenCalledWith(projectId, environmentId) + expect(datastore.deleteEnvironment).not.toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.service.ts new file mode 100644 index 0000000000..d181c84966 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/environment/environment.service.ts @@ -0,0 +1,81 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Environment } from '@prisma/client' +import type { EventLogAction } from '../events/app-events.service' +import type { EnvironmentWithCluster, EnvironmentWithStage } from './environment-datastore.service' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { EnvironmentValidationService } from './environment-validation.service' + +@Injectable() +export class EnvironmentService { + private readonly logger = new Logger(EnvironmentService.name) + + constructor( + @Inject(EnvironmentDatastoreService) private readonly environmentDatastoreService: EnvironmentDatastoreService, + @Inject(EnvironmentValidationService) private readonly environmentValidationService: EnvironmentValidationService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async listByProjectId(projectId: string): Promise { + return this.environmentDatastoreService.getEnvironmentsByProjectId(projectId) + } + + async createEnvironment(projectId: string, environmentToCreate: CreateEnvironment, userId: string, requestId: string): Promise { + await this.environmentValidationService.validateCreate(projectId, environmentToCreate) + + const environment = await this.environmentDatastoreService.createEnvironment({ + projectId, + name: environmentToCreate.name, + clusterId: environmentToCreate.clusterId, + stageId: environmentToCreate.stageId, + cpu: environmentToCreate.cpu, + gpu: environmentToCreate.gpu, + memory: environmentToCreate.memory, + autosync: environmentToCreate.autosync, + }) + + this.reconcileProject(projectId, 'Create Environment', userId, requestId) + return environment + } + + async updateEnvironment(projectId: string, environmentId: string, environmentToUpdate: UpdateEnvironment, userId: string, requestId: string): Promise { + const existingEnvironment = await this.getProjectEnvironmentOrThrow(projectId, environmentId) + await this.environmentValidationService.validateUpdate(existingEnvironment, environmentToUpdate) + + const environment = await this.environmentDatastoreService.updateEnvironment(environmentId, { + cpu: environmentToUpdate.cpu, + gpu: environmentToUpdate.gpu, + memory: environmentToUpdate.memory, + autosync: environmentToUpdate.autosync, + }) + + this.reconcileProject(projectId, 'Update Environment', userId, requestId) + return environment + } + + async deleteEnvironment(projectId: string, environmentId: string, userId: string, requestId: string): Promise { + await this.getProjectEnvironmentOrThrow(projectId, environmentId) + await this.environmentDatastoreService.deleteEnvironment(environmentId) + this.reconcileProject(projectId, 'Delete Environment', userId, requestId) + } + + private async getProjectEnvironmentOrThrow(projectId: string, environmentId: string): Promise { + const environment = await this.environmentDatastoreService.getProjectEnvironment(projectId, environmentId) + if (!environment) { + throw new NotFoundException('Environnement introuvable') + } + return environment + } + + /** + * Triggers the project reconciliation without blocking the response: listener + * outcomes (including failures) are persisted in the admin log by AppEventsService. + */ + private reconcileProject(projectId: string, action: EventLogAction, userId: string, requestId: string): void { + this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) + .catch((error: unknown) => { + this.logger.error(`project.upsert reconciliation failed (projectId=${projectId})`, error instanceof Error ? error.stack : String(error)) + }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.module.ts new file mode 100644 index 0000000000..08130cfc7b --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { LogModule } from '../log/log.module' +import { AppEventsService } from './app-events.service' + +@Module({ + imports: [InfrastructureModule, LogModule], + providers: [AppEventsService], + exports: [AppEventsService], +}) +export class AppEventsModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.service.spec.ts new file mode 100644 index 0000000000..7bd6fa2ea2 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.service.spec.ts @@ -0,0 +1,178 @@ +import type { EventEmitter2 as EventEmitter2Type } from '@nestjs/event-emitter' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { projectSelect } from '../project/project-queries.utils' +import { makeProject } from '../project/project-testing.utils' +import { AppEventsService } from './app-events.service' + +describe('appEventsService', () => { + let module: TestingModule + let service: AppEventsService + let prisma: DeepMockProxy + let eventEmitter: DeepMockProxy + let logs: DeepMockProxy + let config: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + eventEmitter = mockDeep({ emitAsync: vi.fn().mockResolvedValue([]) }) + logs = mockDeep() + config = mockDeep({ appVersion: 'test-version' }) + + module = await Test.createTestingModule({ + providers: [ + AppEventsService, + { provide: PrismaService, useValue: prisma }, + { provide: EventEmitter2, useValue: eventEmitter }, + { provide: LogService, useValue: logs }, + { provide: BASE_CONFIG, useValue: config }, + ], + }).compile() + + service = module.get(AppEventsService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('loads the project by id, emits and logs the merged listener results', async () => { + const project = makeProject() + prisma.project.findUnique.mockResolvedValue(project) + eventEmitter.emitAsync.mockResolvedValue([ + undefined, // listener not yet migrated to capturePluginResult + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + { argocd: { status: 'KO', message: 'Sync failed', executionTime: 20, error: new Error('Sync failed') } }, + ]) + + const results = await service.emitProjectEvent('project.upsert', project.id, { + action: 'Create Project', + userId: 'user-id', + requestId: 'request-id', + }) + + expect(prisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: project.id }, + select: projectSelect, + }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', project) + expect(results).toEqual({ + gitlab: expect.objectContaining({ status: 'OK' }), + argocd: expect.objectContaining({ status: 'KO' }), + }) + expect(logs.addLog).toHaveBeenCalledWith({ + action: 'Create Project', + userId: 'user-id', + requestId: 'request-id', + projectId: project.id, + data: { + args: project, + failed: ['argocd'], + results: { + gitlab: { + status: { result: 'OK', message: 'Up to date' }, + executionTime: { main: 10 }, + }, + argocd: { + status: { result: 'KO', message: 'Sync failed' }, + executionTime: { main: 20 }, + error: expect.stringContaining('"message":"Sync failed"'), + }, + }, + totalExecutionTime: expect.any(Number), + messageResume: 'Errors:\nargocd: Sync failed;', + }, + }) + }) + + it('marks the project as failed when a listener reports a KO result', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { argocd: { status: 'KO', message: 'Sync failed', executionTime: 20, error: new Error('Sync failed') } }, + ]) + + await service.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: project.id }, + data: { status: 'failed' }, + }) + }) + + it('marks the project as created with the provisioning version after a successful upsert', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + ]) + + await service.emitProjectEvent('project.upsert', project, { action: 'Create Project' }) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: project.id }, + data: { status: 'created', lastSuccessProvisionningVersion: 'test-version' }, + }) + }) + + it('does not touch the project status after a successful delete', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + ]) + + await service.emitProjectEvent('project.delete', project, { action: 'Delete all project resources' }) + + expect(prisma.project.update).not.toHaveBeenCalled() + }) + + it('uses a provided project snapshot without fetching it again', async () => { + const project = makeProject() + + await service.emitProjectEvent('project.delete', project, { action: 'Delete all project resources' }) + + expect(prisma.project.findUnique).not.toHaveBeenCalled() + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.delete', project) + }) + + it('defaults the log userId and requestId to null', async () => { + const project = makeProject() + + await service.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Update Project', + userId: null, + requestId: null, + })) + }) + + it('skips emitting and logging when the project cannot be found', async () => { + prisma.project.findUnique.mockResolvedValue(null) + + const results = await service.emitProjectEvent('project.upsert', 'missing-id', { action: 'Update Project' }) + + expect(results).toEqual({}) + expect(eventEmitter.emitAsync).not.toHaveBeenCalled() + expect(logs.addLog).not.toHaveBeenCalled() + }) + + it('emits and logs project member events with their payload as args', async () => { + const payload = { projectId: 'project-id', userId: 'user-id' } + + await service.emitProjectMemberEvent('projectMember.upsert', payload, { action: 'Add Project Member' }) + + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.upsert', payload) + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Add Project Member', + projectId: 'project-id', + data: expect.objectContaining({ args: payload }), + })) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.service.ts new file mode 100644 index 0000000000..7de8bcf562 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -0,0 +1,138 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { PluginResults } from '../plugin/plugin.utils' +import type { ProjectWithDetails } from '../project/project-queries.utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { getFailedPlugins, mergePluginResults } from '../plugin/plugin.utils' +import { getProject } from '../project/project-queries.utils' +import { formatEventLogData, isPluginResults } from './app-events.utils' + +export type ProjectEventName = 'project.upsert' | 'project.delete' +export type ProjectMemberEventName = 'projectMember.upsert' | 'projectMember.delete' + +export interface ProjectMemberEventPayload { + projectId: string + userId: string +} + +/** Admin-log action labels (legacy hooks wording). */ +export type EventLogAction + = | 'Create Project' | 'Update Project' | 'Delete all project resources' + | 'Replay hooks for Project' | 'Upsert Project Role' + | 'Create Deployment' | 'Update Deployment' | 'Delete Deployment' + | 'Delete all project deployments' + | 'Create Environment' | 'Update Environment' | 'Delete Environment' + | 'Add Project Member' | 'Update Project Member' | 'Remove Project Member' + +export interface EventContext { + /** Action label persisted in the admin log. */ + action: EventLogAction + userId?: string | null + requestId?: string | null +} + +/** + * Single entry point to emit domain events: emits through EventEmitter2, merges the + * `PluginResults` returned by every `capturePluginResult` listener and persists one admin + * log per event. Emitters only provide an id and the log context. + */ +@Injectable() +export class AppEventsService { + private readonly logger = new Logger(AppEventsService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, + @Inject(LogService) private readonly logs: LogService, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + /** + * Emits a project event and logs the merged listener results. + * + * Pass a project id in the general case: the project is loaded here, after the + * emitting transaction commits, so every listener sees the same committed state. + * Pass an already-loaded snapshot when the row no longer reflects what listeners + * must act on (e.g. archiving renames the slug before emitting `project.delete`). + */ + async emitProjectEvent( + event: ProjectEventName, + projectOrId: string | ProjectWithDetails, + context: EventContext, + ): Promise { + const project = typeof projectOrId === 'string' + ? await getProject(this.prisma, projectOrId) + : projectOrId + + if (!project) { + this.logger.warn(`${event} skipped: project not found (projectId=${String(projectOrId)})`) + return {} + } + + const results = await this.emitAndLog(event, project, project.id, context) + await this.updateProjectStatus(event, project.id, results) + return results + } + + async emitProjectMemberEvent( + event: ProjectMemberEventName, + payload: ProjectMemberEventPayload, + context: EventContext, + ): Promise { + return this.emitAndLog(event, payload, payload.projectId, context) + } + + private async emitAndLog( + event: string, + payload: unknown, + projectId: string, + context: EventContext, + ): Promise { + const start = process.hrtime.bigint() + const responses = await this.eventEmitter.emitAsync(event, payload) + const totalExecutionTime = Number(process.hrtime.bigint() - start) / 1_000_000 + + const results = mergePluginResults(responses.filter(isPluginResults)) + this.logger.log(`${event} completed (projectId=${projectId}, services=${Object.keys(results).join(',') || 'none'})`) + + await this.logs.addLog({ + action: context.action, + data: formatEventLogData(payload, results, totalExecutionTime), + userId: context.userId ?? null, + requestId: context.requestId ?? null, + projectId, + }) + + return results + } + + /** + * Reflects the listeners' outcome on the project row (legacy hooks behavior): + * any KO result marks the project `failed`; a fully successful upsert marks it + * `created` and records the provisioning version. A successful `project.delete` + * leaves the `archived` status set when the project was archived. + */ + private async updateProjectStatus( + event: ProjectEventName, + projectId: string, + results: PluginResults, + ): Promise { + const failed = getFailedPlugins(results) + + if (failed.length) { + this.logger.warn(`${event} marked project as failed (projectId=${projectId}, failed=${failed.join(',')})`) + await this.prisma.project.update({ where: { id: projectId }, data: { status: 'failed' } }) + return + } + + if (event === 'project.upsert') { + await this.prisma.project.update({ + where: { id: projectId }, + data: { status: 'created', lastSuccessProvisionningVersion: this.baseConfig.appVersion }, + }) + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts new file mode 100644 index 0000000000..3c1ba22a7a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts @@ -0,0 +1,96 @@ +import type { PluginResults } from '../plugin/plugin.utils' +import { NetworkError } from '@keycloak/keycloak-admin-client' +import { describe, expect, it } from 'vitest' +import { formatEventLogData, isPluginResults, serializeError } from './app-events.utils' + +describe('isPluginResults', () => { + it('should accept an object produced by a capturePluginResult handler', () => { + expect(isPluginResults({ gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } })).toBe(true) + expect(isPluginResults({ argocd: { status: 'KO', message: 'boom', executionTime: 10, error: new Error('boom') } })).toBe(true) + }) + + it('should reject non-result responses', () => { + expect(isPluginResults(undefined)).toBe(false) + expect(isPluginResults(null)).toBe(false) + expect(isPluginResults('ok')).toBe(false) + expect(isPluginResults({})).toBe(false) + expect(isPluginResults({ gitlab: { foo: 'bar' } })).toBe(false) + expect(isPluginResults({ gitlab: { status: 'MAYBE' } })).toBe(false) + }) +}) + +describe('serializeError', () => { + it('should serialize an Error with name, message and stack', () => { + const parsed = JSON.parse(serializeError(new Error('boom'))) + expect(parsed).toEqual({ name: 'Error', message: 'boom', stack: expect.any(String) }) + }) + + it('should include HTTP details from errors carrying a fetch Response', () => { + const error = new NetworkError('Network response was not OK.', { + response: new Response('conflict', { status: 409 }), + responseData: { errorMessage: 'Sibling group named \'admin\' already exists.' }, + }) + const parsed = JSON.parse(serializeError(error)) + expect(parsed).toEqual({ + name: 'Error', + message: 'Network response was not OK.', + status: 409, + url: expect.any(String), + responseData: { errorMessage: 'Sibling group named \'admin\' already exists.' }, + stack: expect.any(String), + }) + }) + + it('should serialize non-Error values', () => { + expect(serializeError({ code: 42 })).toBe('{"code":42}') + expect(serializeError('boom')).toBe('"boom"') + }) +}) + +describe('formatEventLogData', () => { + const args = { id: 'project-1', slug: 'project-1' } + + it('should format results in the LogSchema shape', () => { + const results: PluginResults = { + gitlab: { status: 'OK', message: 'Up to date', executionTime: 12.4 }, + argocd: { status: 'KO', message: 'Sync failed', executionTime: 100.9, error: new Error('Sync failed') }, + } + + expect(formatEventLogData(args, results, 120.6)).toEqual({ + args, + failed: ['argocd'], + results: { + gitlab: { + status: { result: 'OK', message: 'Up to date' }, + executionTime: { main: 12 }, + }, + argocd: { + status: { result: 'KO', message: 'Sync failed' }, + executionTime: { main: 101 }, + error: expect.stringContaining('"message":"Sync failed"'), + }, + }, + totalExecutionTime: 121, + messageResume: 'Errors:\nargocd: Sync failed;', + }) + }) + + it('should omit the failed list and produce a success resume when everything is OK', () => { + const results: PluginResults = { + gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 }, + } + + const data = formatEventLogData(args, results, 10) + expect(data).not.toHaveProperty('failed') + expect(data.messageResume).toBe('Success') + }) + + it('should handle events without any listener', () => { + expect(formatEventLogData(args, {}, 1)).toEqual({ + args, + results: {}, + totalExecutionTime: 1, + messageResume: 'Success', + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.utils.ts new file mode 100644 index 0000000000..a9cf62706b --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/events/app-events.utils.ts @@ -0,0 +1,77 @@ +import type { LogData } from '../log/log.service' +import type { PluginName, PluginResult, PluginResults } from '../plugin/plugin.utils' +import { getErrorHttpDetails } from '../../utils/http.utils' +import { getFailedPlugins } from '../plugin/plugin.utils' + +/** Per-service result as persisted in the admin logs (legacy hooks format, parsed by LogSchema). */ +export interface LoggablePluginResult { + status: { + result: 'OK' | 'KO' + message?: string + } + executionTime: { + main: number + } + error?: string +} + +function isPluginResult(value: unknown): value is PluginResult { + return typeof value === 'object' + && value !== null + && 'status' in value + && ((value as PluginResult).status === 'OK' || (value as PluginResult).status === 'KO') +} + +/** Narrows an `emitAsync` response to a `PluginResults` object produced by `capturePluginResult` handlers. */ +export function isPluginResults(response: unknown): response is PluginResults { + if (typeof response !== 'object' || response === null) return false + const values = Object.values(response) + return values.length > 0 && values.every(isPluginResult) +} + +export function serializeError(error: unknown): string { + if (error instanceof Error) { + return JSON.stringify({ + name: error.name, + message: error.message, + ...getErrorHttpDetails(error), + stack: error.stack, + }) + } + try { + return JSON.stringify(error) + } catch { + return String(error) + } +} + +function toLoggableResult(result: PluginResult): LoggablePluginResult { + return { + status: { + result: result.status, + ...(result.message ? { message: result.message } : {}), + }, + executionTime: { main: Math.round(result.executionTime) }, + ...(result.status === 'KO' ? { error: serializeError(result.error) } : {}), + } +} + +function buildMessageResume(results: PluginResults, failed: PluginName[]): string { + if (!failed.length) return 'Success' + const errorLines = failed.map(service => `${service}: ${results[service]?.message};`).join('\n') + return `Errors:\n${errorLines}` +} + +/** Builds the log `data` payload for an event: the emitted args plus every listener's result. */ +export function formatEventLogData(args: unknown, results: PluginResults, totalExecutionTime: number): LogData { + const failed = getFailedPlugins(results) + const entries = Object.entries(results) as [PluginName, PluginResult][] + + return { + args, + ...(failed.length ? { failed } : {}), + results: Object.fromEntries(entries.map(([service, result]) => [service, toLoggableResult(result)])), + totalExecutionTime: Math.round(totalExecutionTime), + messageResume: buildMessageResume(results, failed), + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts new file mode 100644 index 0000000000..0db65f4630 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts @@ -0,0 +1,689 @@ +import type { ExpandedGroupSchema, Gitlab as GitlabApi, ProjectSchema } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { MockedFunction } from 'vitest' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' +import { + makeAccessTokenExposedSchema, + makeAccessTokenSchema, + makeExpandedGroupSchema, + makeExpandedUserSchema, + makeGitbeakerRequestError, + makeGroupSchema, + makeMemberSchema, + makeOffsetPagination, + makePipelineTriggerToken, + makeProjectSchema, + makeRepositoryFileExpandedSchema, + makeRepositoryTreeSchema, +} from './gitlab-testing.utils' +import { + GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, + MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, + PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, + USER_ID_CUSTOM_ATTRIBUTE_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +describe('gitlab-client', () => { + let service: GitlabClientService + let gitlabApi: DeepMockProxy + + beforeEach(async () => { + gitlabApi = mockDeep() + const config = mockDeep({ + url: 'https://gitlab.internal', + token: 'token', + internalUrl: 'https://gitlab.internal', + projectRootDir: 'forge', + mirrorTokenExpirationDays: 30, + internalOrPublicUrl: 'https://gitlab.internal', + }) + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GitlabClientService, + { provide: GITLAB_REST_CLIENT, useValue: gitlabApi }, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + service = module.get(GitlabClientService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('getOrCreateInfraProject', () => { + it('should create infra project if not exists', async () => { + const zoneSlug = 'zone-1' + const rootId = 123 + const infraGroupId = 456 + const projectId = 789 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + + gitlabApi.Groups.show.mockResolvedValueOnce({ id: rootId, full_path: 'forge' } as ExpandedGroupSchema) + + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + gitlabApi.Groups.create.mockResolvedValue({ id: infraGroupId, full_path: 'forge/infra' } as ExpandedGroupSchema) + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.create.mockResolvedValue({ + id: projectId, + path_with_namespace: 'forge/infra/zone-1', + http_url_to_repo: 'https://gitlab.internal/infra/zone-1.git', + } as ProjectSchema) + + const result = await service.getOrCreateInfraGroupRepo(zoneSlug) + + expect(result).toEqual({ + id: projectId, + http_url_to_repo: 'https://gitlab.internal/infra/zone-1.git', + path_with_namespace: 'forge/infra/zone-1', + }) + expect(gitlabApi.Groups.create).toHaveBeenCalledWith('infra', 'infra', expect.any(Object)) + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(rootId, GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(infraGroupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(infraGroupId, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ + name: zoneSlug, + path: zoneSlug, + namespaceId: infraGroupId, + })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + }) + + describe('commitCreateOrUpdate', () => { + it('should create commit if file not exists', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'content' + const filePath = 'file.txt' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + const notFoundError = makeGitbeakerRequestError({ description: '404 File Not Found' }) + gitlabRepositoryFilesShowMock.mockRejectedValue(notFoundError) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + message, + [{ action: 'create', filePath, content }], + ) + }) + + it('should update commit if content differs', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'new content' + const filePath = 'file.txt' + const oldHash = 'oldhash' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + gitlabRepositoryFilesShowMock.mockResolvedValue(makeRepositoryFileExpandedSchema({ content_sha256: oldHash })) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + message, + [{ action: 'update', filePath, content }], + ) + }) + + it('should do nothing if content matches', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'content' + const filePath = 'file.txt' + const hash = 'ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + gitlabRepositoryFilesShowMock.mockResolvedValue(makeRepositoryFileExpandedSchema({ content_sha256: hash })) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).not.toHaveBeenCalled() + }) + }) + + describe('getOrCreateProjectGroup', () => { + it('should create project group if not exists', async () => { + const projectSlug = 'project-1' + const rootId = 123 + const groupId = 456 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce(makeExpandedGroupSchema({ id: rootId, full_path: 'forge' })) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.create.mockResolvedValue(makeExpandedGroupSchema({ id: groupId, name: projectSlug, path: projectSlug, full_path: `forge/${projectSlug}` })) + + const result = await service.getOrCreateProjectSubGroup(projectSlug) + + expect(result).toEqual(expect.objectContaining({ id: groupId, name: projectSlug })) + expect(gitlabApi.Groups.create).toHaveBeenCalledWith(projectSlug, projectSlug, expect.objectContaining({ + parentId: rootId, + })) + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(groupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(groupId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + }) + + it('should return existing group', async () => { + const projectSlug = 'project-1' + const rootId = 123 + const groupId = 456 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce(makeExpandedGroupSchema({ id: rootId, full_path: 'forge' })) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: rootId, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectSubGroup(projectSlug) + + expect(result).toEqual({ id: groupId, name: projectSlug, parent_id: rootId, full_path: 'forge/project-1' }) + expect(gitlabApi.Groups.create).not.toHaveBeenCalled() + }) + }) + + describe('repositories', () => { + it('should return internal repo url', async () => { + const projectSlug = 'project-1' + const repoName = 'repo-1' + const rootId = 123 + const groupId = 1 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectGroupInternalRepoUrl(projectSlug, repoName) + expect(result).toBe('https://gitlab.internal/forge/project-1/repo-1.git') + }) + + it('should upsert mirror repo', async () => { + const projectSlug = 'project-1' + const repoId = 1 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValue({ + data: [{ id: repoId, path_with_namespace: 'forge/project-1/mirror' }], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: 'mirror' } as ProjectSchema) + + const result = await service.upsertProjectMirrorRepo(projectSlug) + + expect(result).toEqual({ id: repoId, name: 'mirror' }) + expect(gitlabApi.Projects.edit).toHaveBeenCalledWith(repoId, expect.objectContaining({ + name: 'mirror', + path: 'mirror', + })) + }) + + it('should set managed custom attribute when upserting a project repo', async () => { + const projectSlug = 'project-1' + const repoName = 'repo-1' + const repoId = 101 + + gitlabApi.Projects.show.mockResolvedValue(makeProjectSchema({ id: repoId })) + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: repoName } as ProjectSchema) + + const result = await service.upsertProjectGroupRepo(projectSlug, repoName, 'desc') + + expect(result).toEqual({ id: repoId, name: repoName }) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(repoId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + + describe('upsertUser', () => { + it('should create user and set custom attribute if not exists', async () => { + const consoleUser = { id: 'u1', email: 'new@example.com', firstName: 'New', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'new', + name: 'New User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([]) + gitlabApi.Users.create.mockResolvedValue(makeExpandedUserSchema({ id: 999, email: consoleUser.email })) + + const result = await service.upsertUser(gitlabUser, { cpnUserId: consoleUser.id }) + + expect(result).toEqual(expect.objectContaining({ id: 999, email: consoleUser.email })) + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'new@example.com', + username: 'new', + name: 'New User', + externUid: 'new@example.com', + provider: 'openid_connect', + skipConfirmation: true, + })) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(999, USER_ID_CUSTOM_ATTRIBUTE_KEY, consoleUser.id) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(999, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + + it('should set custom attribute if user exists', async () => { + const consoleUser = { id: 'u1', email: 'existing@example.com', firstName: 'Existing', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'existing', + name: 'Existing User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([makeExpandedUserSchema({ id: 1000, email: consoleUser.email })]) + + const result = await service.upsertUser(gitlabUser, { cpnUserId: consoleUser.id }) + + expect(result).toEqual(expect.objectContaining({ id: 1000, email: consoleUser.email })) + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.objectContaining({ + email: 'existing@example.com', + username: 'existing', + name: 'Existing User', + externUid: 'existing@example.com', + provider: 'openid_connect', + })) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(1000, USER_ID_CUSTOM_ATTRIBUTE_KEY, consoleUser.id) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(1000, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.Users.create).not.toHaveBeenCalled() + }) + + it('should set admin flag when provided', async () => { + const consoleUser = { id: 'u1', email: 'admin@example.com', firstName: 'Admin', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'admin', + name: 'Admin User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([]) + gitlabApi.Users.create.mockResolvedValue(makeExpandedUserSchema({ id: 999, email: consoleUser.email })) + + await service.upsertUser({ ...gitlabUser, admin: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'admin@example.com', + username: 'admin', + name: 'Admin User', + externUid: 'admin@example.com', + provider: 'openid_connect', + admin: true, + skipConfirmation: true, + })) + }) + + it('should not disable existing admin flag when enabling auditor flag', async () => { + const consoleUser = { id: 'u1', email: 'admin-auditor@example.com', firstName: 'Admin', lastName: 'Auditor' } + const gitlabUser = { + email: consoleUser.email, + username: 'admin-auditor', + name: 'Admin Auditor', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([ + makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_admin: true }), + ]) + + await service.upsertUser({ ...gitlabUser, auditor: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.not.objectContaining({ + admin: true, + })) + }) + + it('should not disable existing auditor flag when enabling admin flag', async () => { + const consoleUser = { id: 'u1', email: 'auditor-admin@example.com', firstName: 'Auditor', lastName: 'Admin' } + const gitlabUser = { + email: consoleUser.email, + username: 'auditor-admin', + name: 'Auditor Admin', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([ + makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_auditor: true }), + ]) + + await service.upsertUser({ ...gitlabUser, admin: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.not.objectContaining({ + auditor: true, + })) + }) + }) + + it('should create pipeline trigger token if not exists', async () => { + const projectSlug = 'project-1' + const repoId = 1 + const tokenDescription = 'mirroring-from-external-repo' + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValue({ + data: [{ id: repoId, path_with_namespace: 'forge/project-1/mirror' }], + paginationInfo: { next: null }, + }) + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: 'mirror' } as ProjectSchema) + + const gitlabPipelineTriggerTokensAllMock = gitlabApi.PipelineTriggerTokens.all as MockedFunction + gitlabPipelineTriggerTokensAllMock.mockResolvedValue({ + data: [], + paginationInfo: makeOffsetPagination({ next: null }), + }) + + gitlabApi.PipelineTriggerTokens.create.mockResolvedValue(makePipelineTriggerToken({ id: 2, description: tokenDescription })) + + const result = await service.getOrCreateMirrorPipelineTriggerToken(projectSlug) + + expect(result).toEqual(expect.objectContaining({ id: 2, description: tokenDescription })) + expect(gitlabApi.PipelineTriggerTokens.create).toHaveBeenCalledWith(repoId, tokenDescription) + }) + }) + + describe('group Members', () => { + it('should get group members', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const members = [makeMemberSchema({ id: 1, name: 'user' })] + const gitlabGroupMembersAllMock = gitlabApi.GroupMembers.all as MockedFunction + gitlabGroupMembersAllMock.mockResolvedValue(members) + + const result = await service.getGroupMembers(group) + expect(result).toEqual(members) + expect(gitlabApi.GroupMembers.all).toHaveBeenCalledWith(groupId) + }) + + it('should add group member', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const userId = 2 + const accessLevel = 30 + gitlabApi.GroupMembers.add.mockResolvedValue(makeMemberSchema({ id: userId })) + + await service.addGroupMember(group, userId, accessLevel) + expect(gitlabApi.GroupMembers.add).toHaveBeenCalledWith(groupId, userId, accessLevel) + }) + + it('should remove group member', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const userId = 2 + gitlabApi.GroupMembers.remove.mockResolvedValue(undefined) + + await service.removeGroupMember(group, userId) + expect(gitlabApi.GroupMembers.remove).toHaveBeenCalledWith(groupId, userId) + }) + }) + + describe('createProjectMirrorAccessToken', () => { + it('should create project access token with correct scopes', async () => { + const projectSlug = 'project-1' + const groupId = 456 + const tokenName = `${projectSlug}-bot` + const token = makeAccessTokenExposedSchema({ id: 1, name: tokenName, token: 'secret-token' }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: 123, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce({ id: 123, full_path: 'forge' } as ExpandedGroupSchema) + + const gitlabGroupsAllSubgroupsMock = gitlabApi.Groups.allSubgroups as MockedFunction + gitlabGroupsAllSubgroupsMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: 123, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + gitlabApi.GroupAccessTokens.create.mockResolvedValue(token) + + const result = await service.createMirrorAccessToken(projectSlug) + + expect(result).toEqual(token) + expect(gitlabApi.GroupAccessTokens.create).toHaveBeenCalledWith( + groupId, + tokenName, + ['write_repository', 'read_repository', 'read_api'], + expect.any(String), + ) + }) + }) + + describe('getOrCreateProjectGroupRepo', () => { + it('should return existing repo', async () => { + const subGroupPath = 'project-1' + const repoName = 'repo-1' + const fullPath = `${subGroupPath}/${repoName}` + const projectId = 789 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [{ id: projectId, path_with_namespace: `forge/${fullPath}` }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectGroupRepo(subGroupPath, fullPath) + + expect(result).toEqual(expect.objectContaining({ id: projectId })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, 'project-1') + }) + + it('should create repo if not exists', async () => { + const subGroupPath = 'project-1' + const repoName = 'repo-1' + const fullPath = `${subGroupPath}/${repoName}` + const projectId = 789 + const groupId = 456 + const rootId = 123 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: subGroupPath, parent_id: rootId, full_path: `forge/${subGroupPath}` }], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.create.mockResolvedValue({ id: projectId, name: repoName } as ProjectSchema) + + const result = await service.getOrCreateProjectGroupRepo(subGroupPath, fullPath) + + expect(result).toEqual(expect.objectContaining({ id: projectId })) + expect(gitlabApi.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ + name: repoName, + path: repoName, + namespaceId: groupId, + })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, 'project-1') + }) + }) + + describe('getFile', () => { + it('should return file content', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const file = makeRepositoryFileExpandedSchema({ content: 'content' }) + + gitlabApi.RepositoryFiles.show.mockResolvedValue(file) + + const result = await service.getFile(repo, filePath, ref) + expect(result).toEqual(file) + }) + + it('should return undefined on 404', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const error = makeGitbeakerRequestError({ description: '404 File Not Found' }) + + gitlabApi.RepositoryFiles.show.mockRejectedValue(error) + + const result = await service.getFile(repo, filePath, ref) + expect(result).toBeUndefined() + }) + + it('should throw on other errors', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const error = new Error('Some other error') + + gitlabApi.RepositoryFiles.show.mockRejectedValue(error) + + await expect(service.getFile(repo, filePath, ref)).rejects.toThrow(error) + }) + }) + + describe('listFiles', () => { + it('should return files', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const files = [makeRepositoryTreeSchema({ path: 'file.txt' })] + + gitlabApi.Repositories.allRepositoryTrees.mockResolvedValue(files) + + const result = await service.listFiles(repo) + expect(result).toEqual(files) + }) + + it('should return empty array on 404', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const error = makeGitbeakerRequestError({ description: '404 Tree Not Found' }) + + gitlabApi.Repositories.allRepositoryTrees.mockRejectedValue(error) + + const result = await service.listFiles(repo) + expect(result).toEqual([]) + }) + }) + + describe('getProjectToken', () => { + it('should return specific token', async () => { + const projectSlug = 'project-1' + const groupId = 456 + const tokenName = `${projectSlug}-bot` + const token = makeAccessTokenSchema({ id: 1, name: tokenName }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: 123, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce({ id: 123, full_path: 'forge' } as ExpandedGroupSchema) + const gitlabGroupsAllSubgroupsMock = gitlabApi.Groups.allSubgroups as MockedFunction + gitlabGroupsAllSubgroupsMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: 123, full_path: `forge/${projectSlug}` }], + paginationInfo: { next: null }, + }) + + const gitlabGroupAccessTokensAllMock = gitlabApi.GroupAccessTokens.all as MockedFunction + gitlabGroupAccessTokensAllMock.mockResolvedValue({ + data: [token], + paginationInfo: makeOffsetPagination({ next: null }), + }) + + const result = await service.getProjectToken(projectSlug) + expect(result).toEqual(token) + }) + }) + + describe('createUser', () => { + it('should create user', async () => { + const email = 'user@example.com' + const username = 'user' + const name = 'User Name' + const user = makeExpandedUserSchema({ id: 1, username }) + + gitlabApi.Users.create.mockResolvedValue(user) + + const result = await service.createUser({ email, username, name }) + + expect(result).toEqual(user) + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email, + username, + name, + skipConfirmation: true, + })) + }) + }) + + describe('commitMirror', () => { + it('should create mirror commit', async () => { + const repoId = 1 + + await service.commitMirror(repoId) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + expect.any(String), + expect.arrayContaining([ + expect.objectContaining({ filePath: '.gitlab-ci.yml', action: 'create' }), + expect.objectContaining({ filePath: 'mirror.sh', action: 'create' }), + ]), + ) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts new file mode 100644 index 0000000000..d1a755fc9a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -0,0 +1,554 @@ +import type { + AccessLevel, + AccessTokenScopes, + BaseRequestOptions, + CommitAction, + CondensedGroupSchema, + CondensedProjectSchema, + EditUserOptions, + ExpandedUserSchema, + Gitlab, + GroupSchema, + OffsetPagination, + PaginationRequestOptions, + PipelineTriggerTokenSchema, + SimpleUserSchema, +} from '@gitbeaker/core' +import type { GitlabConfig } from './gitlab.module-definition' +import { join } from 'node:path' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { find } from '../../utils/iterable.utils' +import { + GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_PATH, + MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, + MIRROR_REPO_NAME, + PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, + TOKEN_DESCRIPTION, + TOPIC_PLUGIN_MANAGED, + USER_ID_CUSTOM_ATTRIBUTE_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { generateGitlabCIConfigContent, generateMirrorScriptContent, hasFileContentChanged } from './gitlab.utils' + +export const GITLAB_REST_CLIENT = Symbol('GITLAB_REST_CLIENT') + +type With = T & Required> +export type CondensedGroupSchemaWith = With +export type CondensedProjectSchemaWith = With +export type EditUserOptionsWith = With +type UserSchema = SimpleUserSchema | ExpandedUserSchema + +export interface OffsetPaginateOptions { + startPage?: number + perPage?: number + maxPages?: number +} + +@Injectable() +export class GitlabClientService { + private readonly logger = new Logger(GitlabClientService.name) + + constructor( + @Inject(GITLAB_CONFIG) readonly config: GitlabConfig, + @Inject(GITLAB_REST_CLIENT) private readonly client: Gitlab, + ) { + } + + async upsertGroupCustomAttribute(groupId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab group custom attribute (groupId=${groupId}, key=${key})`) + try { + await this.client.GroupCustomAttributes.set(groupId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab group custom attribute (groupId=${groupId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + async upsertProjectCustomAttribute(projectId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab project custom attribute (projectId=${projectId}, key=${key})`) + try { + await this.client.ProjectCustomAttributes.set(projectId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab project custom attribute (projectId=${projectId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + async upsertUserCustomAttribute(userId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab user custom attribute (userId=${userId}, key=${key})`) + try { + await this.client.UserCustomAttributes.set(userId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab user custom attribute (userId=${userId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + private async setManagedUserAttributes(userId: number, cpnUserId: string) { + await this.upsertUserCustomAttribute(userId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + await this.upsertUserCustomAttribute(userId, USER_ID_CUSTOM_ATTRIBUTE_KEY, cpnUserId) + } + + private async setManagedInfraProjectAttributes(projectId: number) { + await this.upsertProjectCustomAttribute(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedProjectAttributes(projectId: number, projectSlug: string) { + await this.upsertProjectCustomAttribute(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + await this.upsertProjectCustomAttribute(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + } + + private async setManagedGroupAttributes(groupId: number) { + await this.upsertGroupCustomAttribute(groupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedRootGroupAttributes(groupId: number) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedInfraGroupAttributes(groupId: number) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedProjectGroupAttributes(groupId: number, projectSlug: string) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + } + + async getGroupByPath(path: string) { + this.logger.verbose(`Looking up a GitLab group by path ${path}`) + return find( + this.offsetPaginate(opts => this.client.Groups.all({ search: path, orderBy: 'path', ...opts })), + g => g.full_path === path, + ) + } + + async createGroup(path: string) { + this.logger.log(`Creating a GitLab group at path ${path}`) + const created = await this.client.Groups.create(path, path) + if (this.config.projectRootDir && created.full_path === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(created.id) + } + if (this.config.projectRootDir && created.full_path === `${this.config.projectRootDir}/${INFRA_GROUP_PATH}`) { + await this.setManagedInfraGroupAttributes(created.id) + } + return created + } + + async createSubGroup(parentGroup: CondensedGroupSchemaWith<'id' | 'full_path'>, name: string, fullPath: string) { + this.logger.log(`Creating a GitLab subgroup ${fullPath} (parentId=${parentGroup.id})`) + const created = await this.client.Groups.create(name, name, { parentId: parentGroup.id }) + if (this.config.projectRootDir && fullPath === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(created.id) + } else if (this.config.projectRootDir && fullPath === `${this.config.projectRootDir}/${INFRA_GROUP_PATH}`) { + await this.setManagedInfraGroupAttributes(created.id) + } else if (this.config.projectRootDir && fullPath.startsWith(`${this.config.projectRootDir}/`) && !fullPath.slice(this.config.projectRootDir.length + 1).includes('/')) { + const projectSlug = fullPath.slice(this.config.projectRootDir.length + 1) + if (projectSlug && projectSlug !== INFRA_GROUP_PATH) { + await this.setManagedProjectGroupAttributes(created.id, projectSlug) + } + } + return created + } + + async getOrCreateGroupByPath(path: string) { + const parts = path.split('/') + const rootGroupPath = parts.shift() + if (!rootGroupPath) throw new Error('Invalid projects root dir') + + this.logger.verbose(`Resolving GitLab group path ${path} (depth=${1 + parts.length})`) + let parentGroup = await this.getGroupByPath(rootGroupPath) ?? await this.createGroup(rootGroupPath) + if (this.config.projectRootDir && parentGroup.full_path === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(parentGroup.id) + } + + let currentFullPath: string + for (const part of parts) { + currentFullPath = `${parentGroup.full_path}/${part}` + parentGroup = await this.getGroupByPath(currentFullPath) ?? await this.createSubGroup(parentGroup, part, currentFullPath) + } + + this.logger.verbose(`GitLab group path resolved (path=${path}, groupId=${parentGroup.id})`) + return parentGroup + } + + async getOrCreateProjectGroup() { + if (!this.config.projectRootDir) throw new Error('projectRootDir not configured') + return this.getOrCreateGroupByPath(this.config.projectRootDir) + } + + async getOrCreateProjectSubGroup(subGroupPath: string) { + const fullPath = this.config.projectRootDir + ? `${this.config.projectRootDir}/${subGroupPath}` + : subGroupPath + return this.getOrCreateGroupByPath(fullPath) + } + + async getOrCreateProjectGroupPublicUrl(): Promise { + const projectGroup = await this.getOrCreateProjectGroup() + return new URL(projectGroup.full_path, this.config.url).toString() + } + + async getOrCreateInfraGroupRepoPublicUrl(repoName: string): Promise { + const projectGroup = await this.getOrCreateProjectGroup() + return new URL(`${projectGroup.full_path}/${INFRA_GROUP_PATH}/${repoName}.git`, this.config.url).toString() + } + + async getOrCreateProjectGroupInternalRepoUrl(subGroupPath: string, repoName: string): Promise { + const projectGroup = await this.getOrCreateProjectSubGroup(subGroupPath) + const urlBase = this.config.internalOrPublicUrl + if (!urlBase) throw new Error('GITLAB_URL is required') + return `${urlBase}/${projectGroup.full_path}/${repoName}.git` + } + + private async getOrCreateRepo(subGroupPath: string) { + const fullPath = this.config.projectRootDir + ? `${this.config.projectRootDir}/${subGroupPath}` + : subGroupPath + this.logger.verbose(`Resolving a GitLab project repository by path ${fullPath}`) + try { + const existingRepo = await this.client.Projects.show(fullPath) + if (existingRepo) { + this.logger.verbose(`Found a GitLab project repository (path=${fullPath}, repoId=${existingRepo.id})`) + return existingRepo + } + } catch (error) { + if (!(error instanceof GitbeakerRequestError) || !error.cause?.description?.includes('404')) { + throw error + } + } + const repo = await find( + this.offsetPaginate(opts => this.client.Projects.all({ + search: fullPath, + orderBy: 'path', + ...opts, + })), + p => p.path_with_namespace === fullPath, + ) + if (repo) { + this.logger.verbose(`Found a GitLab project repository via search (path=${fullPath}, repoId=${repo.id})`) + return repo + } + const parts = subGroupPath.split('/') + const repoName = parts.pop() + if (!repoName) throw new Error('Invalid repo path') + const parentGroup = await this.getOrCreateProjectSubGroup(parts.join('/')) + try { + const created = await this.client.Projects.create({ + name: repoName, + path: repoName, + namespaceId: parentGroup.id, + defaultBranch: 'main', + }) + this.logger.log(`Created a GitLab project repository (path=${fullPath}, repoId=${created.id})`) + return created + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('has already been taken')) { + this.logger.warn(`GitLab project repository already exists (race); reloading ${fullPath}`) + const reloaded = await this.client.Projects.show(fullPath) + return reloaded + } + throw error + } + } + + async getOrCreateProjectGroupRepo(projectSlug: string, subGroupPath: string) { + const repo = await this.getOrCreateRepo(subGroupPath) + await this.setManagedProjectAttributes(repo.id, projectSlug) + return repo + } + + async getOrCreateInfraGroupRepo(path: string) { + const fullPath = join(INFRA_GROUP_PATH, path) + const repo = await this.getOrCreateRepo(fullPath) + await this.setManagedInfraProjectAttributes(repo.id) + return repo + } + + async getFile(repo: CondensedProjectSchemaWith<'id'>, filePath: string, ref: string = 'main') { + try { + return await this.client.RepositoryFiles.show(repo.id, filePath, ref) + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('Not Found')) { + this.logger.debug(`GitLab file not found (repoId=${repo.id}, ref=${ref}, filePath=${filePath})`) + return + } + throw error + } + } + + async maybeCreateCommit( + repo: CondensedProjectSchemaWith<'id'>, + message: string, + actions: CommitAction[], + ref: string = 'main', + ): Promise { + if (actions.length === 0) { + this.logger.debug(`No GitLab commit actions to create (repoId=${repo.id}, ref=${ref})`) + return + } + this.logger.log(`Creating a GitLab commit (repoId=${repo.id}, ref=${ref}, actions=${actions.length})`) + await this.client.Commits.create(repo.id, ref, message, actions) + this.logger.verbose(`GitLab commit created (repoId=${repo.id}, ref=${ref}, actions=${actions.length})`) + } + + async generateCreateOrUpdateAction(repo: CondensedProjectSchemaWith<'id'>, ref: string, filePath: string, content: string): Promise { + const file = await this.getFile(repo, filePath, ref) + if (file && !hasFileContentChanged(file, content)) { + this.logger.debug(`GitLab file is up to date; skipping commit action (repoId=${repo.id}, ref=${ref}, filePath=${filePath})`) + return null + } + this.logger.verbose(`Prepared a GitLab commit action (repoId=${repo.id}, ref=${ref}, filePath=${filePath}, action=${file ? 'update' : 'create'})`) + return { + action: file ? 'update' : 'create', + filePath, + content, + } + } + + async listFiles(repo: CondensedProjectSchemaWith<'id'>, options: { path?: string, recursive?: boolean, ref?: string } = {}) { + try { + const path = options.path ?? '/' + const recursive = options.recursive ?? false + const ref = options.ref ?? 'main' + this.logger.verbose(`Listing GitLab repository tree (repoId=${repo.id}, ref=${ref}, path=${path}, recursive=${recursive})`) + const files = await this.client.Repositories.allRepositoryTrees(repo.id, { + path: options.path ?? '/', + recursive: options.recursive ?? false, + ref: options.ref ?? 'main', + }) + this.logger.verbose(`Listed GitLab repository tree (repoId=${repo.id}, ref=${ref}, path=${path}, count=${files.length})`) + return files + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('Not Found')) { + return [] + } + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('404 Tree Not Found')) { + return [] + } + throw error + } + } + + async getProjectGroup(projectSlug: string): Promise { + const parentGroup = await this.getOrCreateProjectGroup() + return find( + this.offsetPaginate(opts => this.client.Groups.allSubgroups(parentGroup.id, opts)), + g => g.name === projectSlug, + ) + } + + async deleteGroup(group: CondensedGroupSchemaWith<'id' | 'full_path'>): Promise { + this.logger.verbose(`Deleting GitLab group ${group.full_path} (groupId=${group.id})`) + await this.client.Groups.remove(group.id) + } + + async getGroupMembers(group: CondensedGroupSchemaWith<'id'>) { + this.logger.verbose(`Loading GitLab group members (groupId=${group.id})`) + return this.client.GroupMembers.all(group.id) + } + + async addGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number, accessLevel: Exclude) { + this.logger.verbose(`Adding a GitLab group member (groupId=${group.id}, userId=${userId}, accessLevel=${accessLevel})`) + return this.client.GroupMembers.add(group.id, userId, accessLevel) + } + + async editGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number, accessLevel: Exclude) { + this.logger.verbose(`Editing a GitLab group member (groupId=${group.id}, userId=${userId}, accessLevel=${accessLevel})`) + return this.client.GroupMembers.edit(group.id, userId, accessLevel) + } + + async removeGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number) { + this.logger.verbose(`Removing a GitLab group member (groupId=${group.id}, userId=${userId})`) + return this.client.GroupMembers.remove(group.id, userId) + } + + async getUserByEmail(email: string) { + const users = await this.client.Users.all({ search: email, orderBy: 'username' }) + if (users.length === 0) return null + return users[0] as UserSchema + } + + async createUser(user: EditUserOptions) { + this.logger.log(`Creating a GitLab user (email=${user.email}, username=${user.username})`) + return await this.client.Users.create({ + ...user, + skipConfirmation: true, + }) as UserSchema + } + + async upsertUser( + user: Omit, 'externUid' | 'provider'>, + options: { cpnUserId: string }, + ) { + const existing = await this.getUserByEmail(user.email) + + const editOptions: EditUserOptions = { + ...user, + externUid: user.email, + provider: 'openid_connect', + } + const gitlabUser = existing ?? await this.createUser(editOptions) + + if (existing) { + const hasDiff = Object.entries(editOptions).some(([key, value]) => { + if (value === undefined) return false + return (existing as Record)[key] !== value + }) + if (hasDiff) { + await this.client.Users.edit(gitlabUser.id, editOptions) + } + } + await this.setManagedUserAttributes(gitlabUser.id, options.cpnUserId) + return gitlabUser + } + + async* getRepos(projectSlug: string) { + const group = await this.getOrCreateProjectSubGroup(projectSlug) + const repos = this.offsetPaginate(opts => this.client.Groups.allProjects(group.id, { simple: false, ...opts })) + for await (const repo of repos) { + yield repo + } + } + + async upsertProjectGroupRepo(projectSlug: string, repoName: string, description?: string) { + const fullPath = `${projectSlug}/${repoName}` + const repo = await this.getOrCreateProjectGroupRepo(projectSlug, fullPath) + const updated = await this.client.Projects.edit(repo.id, { + name: repoName, + path: repoName, + topics: [TOPIC_PLUGIN_MANAGED], + description, + }) + return updated + } + + async deleteProjectGroupRepo(projectSlug: string, repoName: string) { + const fullPath = `${projectSlug}/${repoName}` + const repo = await this.getOrCreateProjectGroupRepo(projectSlug, fullPath) + return this.client.Projects.remove(repo.id) + } + + async commitMirror(repoId: number) { + this.logger.log(`Creating a GitLab mirror bootstrap commit (repoId=${repoId})`) + const actions: CommitAction[] = [ + { + action: 'create', + filePath: '.gitlab-ci.yml', + content: generateGitlabCIConfigContent(), + execute_filemode: false, + }, + { + action: 'create', + filePath: 'mirror.sh', + content: generateMirrorScriptContent(), + execute_filemode: true, + }, + ] + + await this.client.Commits.create( + repoId, + 'main', + 'ci: :construction_worker: first mirror', + actions, + ) + this.logger.verbose(`GitLab mirror bootstrap commit created (repoId=${repoId}, actions=${actions.length})`) + } + + async upsertProjectMirrorRepo(projectSlug: string) { + return this.upsertProjectGroupRepo(projectSlug, MIRROR_REPO_NAME) + } + + async getProjectToken(projectSlug: string) { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + return find( + this.offsetPaginate<{ name: string }>( + opts => this.client.GroupAccessTokens.all(group.id, opts) as unknown as Promise<{ data: { name: string }[], paginationInfo: OffsetPagination }>, + ), + token => token.name === `${projectSlug}-bot`, + ) + } + + async createProjectToken(projectSlug: string, tokenName: string, scopes: AccessTokenScopes[]) { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + const expirationDays = Number(this.config.mirrorTokenExpirationDays) + const effectiveExpirationDays = Number.isFinite(expirationDays) && expirationDays > 0 ? expirationDays : 30 + const expiryDate = new Date(Date.now() + effectiveExpirationDays * 24 * 60 * 60 * 1000) + this.logger.log(`Creating a GitLab group access token (projectSlug=${projectSlug}, tokenName=${tokenName}, expiry=${expiryDate.toISOString().slice(0, 10)})`) + return this.client.GroupAccessTokens.create(group.id, tokenName, scopes, expiryDate.toISOString().slice(0, 10)) + } + + async createMirrorAccessToken(projectSlug: string) { + const tokenName = `${projectSlug}-bot` + return this.createProjectToken(projectSlug, tokenName, ['write_repository', 'read_repository', 'read_api']) + } + + async getOrCreateMirrorPipelineTriggerToken(projectSlug: string): Promise { + const mirrorRepo = await this.upsertProjectMirrorRepo(projectSlug) + this.logger.verbose(`Resolving a GitLab pipeline trigger token (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + const currentTriggerToken = await find( + this.offsetPaginate(opts => this.client.PipelineTriggerTokens.all(mirrorRepo.id, opts)), + token => token.description === TOKEN_DESCRIPTION, + ) + if (currentTriggerToken) { + this.logger.verbose(`GitLab pipeline trigger token found (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + return currentTriggerToken + } + const created = await this.client.PipelineTriggerTokens.create(mirrorRepo.id, TOKEN_DESCRIPTION) + this.logger.log(`GitLab pipeline trigger token created (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + return created + } + + private async* offsetPaginate( + request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, + options?: OffsetPaginateOptions, + ): AsyncGenerator { + let page: number | null = options?.startPage ?? 1 + let pagesFetched = 0 + let total = 0 + + this.logger.debug(`Pagination start (page=${page})`) + + while (page !== null) { + if (options?.maxPages && pagesFetched >= options.maxPages) { + page = null + continue + } + + try { + const { data, paginationInfo } = await request({ + page, + perPage: options?.perPage, + maxPages: options?.maxPages, + showExpanded: true, + pagination: 'offset', + }) + + pagesFetched += 1 + total += data.length + + const nextPage = paginationInfo.next ?? null + this.logger.debug(`Pagination page fetched (page=${page}, nextPage=${nextPage ?? 'null'}, items=${data.length}, total=${total})`) + + for (const item of data) { + yield item + } + + page = nextPage + } catch (error) { + if (error instanceof Error) { + this.logger.error(`Pagination request failed (page=${page}): ${error.message}`, error.stack) + } else { + this.logger.error(`Pagination request failed (page=${page}): ${String(error)}`) + } + throw error + } + } + + this.logger.debug(`Pagination done (total=${total})`) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts new file mode 100644 index 0000000000..5f72414b87 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts @@ -0,0 +1,133 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + owner: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + roles: { + select: { + id: true, + oidcGroup: true, + }, + }, + members: { + select: { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + }, + }, + roleIds: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + isPrivate: true, + externalRepoUrl: true, + externalUserName: true, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class GitlabDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: 'gitlab', + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } + + async getAdminRolesByOidcGroups(oidcGroups: string[]): Promise<{ id: string, oidcGroup: string }[]> { + return this.prisma.adminRole.findMany({ + where: { + oidcGroup: { + in: oidcGroups, + }, + }, + select: { + id: true, + oidcGroup: true, + }, + }) + } + + async getUser(id: string) { + return this.prisma.user.findUnique({ + where: { + id, + }, + }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts new file mode 100644 index 0000000000..0c4bdf8c41 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts @@ -0,0 +1,23 @@ +import type { GitlabConfig } from './gitlab.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +@Injectable() +export class GitlabHealthService { + constructor( + @Inject(GITLAB_CONFIG) private readonly gitlabConfig: GitlabConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.gitlabConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts new file mode 100644 index 0000000000..7f4d85f694 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { GitlabPluginService } from './gitlab-plugin.service' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +describe('gitlabPluginService', () => { + let service: GitlabPluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://gitlab.public', + projectRootDir: 'forge', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + GitlabPluginService, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(GitlabPluginService) + }) + + it('should expose the legacy project url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams({ project: { id: '', name: '', slug: 'dulei' } })) + + expect(url).toBe('https://gitlab.public/forge/dulei') + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts new file mode 100644 index 0000000000..3ce12a442c --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts @@ -0,0 +1,113 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { GitlabConfig } from './gitlab.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { DEFAULT_ADMIN_GROUP_PATH, DEFAULT_AUDITOR_GROUP_PATH, DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, PURGE_PLUGIN_KEY } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +@Injectable() +export class GitlabPluginService { + constructor( + @Inject(GITLAB_CONFIG) + private readonly gitlabConfig: GitlabConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'gitlab', + to: ({ project }) => new URL(`${this.gitlabConfig.projectRootDir}/${project.slug}`, this.gitlabConfig.url).toString(), + title: 'Gitlab', + imgSrc: '/img/gitlab.svg', + description: 'GitLab est un service d\'hébergement de code source et de pipeline CI/CD', + config: { + global: [ + { + kind: 'switch', + key: PURGE_PLUGIN_KEY, + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Purger les utilisateurs non synchronisés', + value: DISABLED, + description: 'Purger les utilisateurs non synchronisés de GitLab lors de la synchronisation', + }, + { + kind: 'switch', + key: 'displayTriggerHint', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Afficher l\'aide de déclenchement de pipeline', + value: ENABLED, + description: 'Afficher l\'aide de déclenchement de pipeline aux utilisateurs lorsqu\'ils souhaitent afficher les secrets du projet', + }, + { + kind: 'text', + key: 'adminGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Admin', + value: DEFAULT_ADMIN_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'administrateur GitLab', + placeholder: DEFAULT_ADMIN_GROUP_PATH, + }, + { + kind: 'text', + key: 'auditorGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Auditeur', + value: DEFAULT_AUDITOR_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'auditeur GitLab', + placeholder: DEFAULT_AUDITOR_GROUP_PATH, + }, + { + kind: 'text', + key: 'projectMaintainerGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Maintainer', + value: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Maintainer', + placeholder: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectDeveloperGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Developer', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Developer', + placeholder: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectReporterGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Reporter', + value: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Reporter', + placeholder: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + }, + ], + project: [], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts new file mode 100644 index 0000000000..678e3e1e36 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts @@ -0,0 +1,432 @@ +import type { + AccessTokenExposedSchema, + AccessTokenSchema, + CommitAction, + ExpandedGroupSchema, + ExpandedUserSchema, + GroupSchema, + MemberSchema, + OffsetPagination, + PipelineTriggerTokenSchema, + ProjectSchema, + RepositoryFileExpandedSchema, + RepositoryTreeSchema, +} from '@gitbeaker/core' +import type { AdminRole, Project, User } from '@prisma/client' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { faker } from '@faker-js/faker' +import { AccessLevel } from '@gitbeaker/core' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { ProjectStatus } from '@prisma/client' +import { PLUGIN_NAME } from './gitlab.constants' + +export function makeExpandedUserSchema(overrides: Partial = {}): ExpandedUserSchema { + const isoDate = faker.date.past().toISOString() + return { + id: 1, + name: 'User', + username: 'user', + state: 'active', + avatar_url: '', + web_url: 'https://gitlab.example/users/user', + created_at: isoDate, + locked: null, + bio: null, + bot: false, + location: null, + public_email: null, + skype: null, + linkedin: null, + twitter: null, + discord: null, + website_url: null, + pronouns: null, + organization: null, + job_title: null, + work_information: null, + followers: null, + following: null, + local_time: null, + is_followed: null, + is_admin: null, + last_sign_in_at: isoDate, + confirmed_at: isoDate, + last_activity_on: isoDate, + email: 'user@example.com', + theme_id: 1, + color_scheme_id: 1, + projects_limit: 0, + current_sign_in_at: null, + note: null, + identities: null, + can_create_group: false, + can_create_project: false, + two_factor_enabled: false, + external: false, + private_profile: null, + namespace_id: null, + created_by: null, + ...overrides, + } satisfies ExpandedUserSchema +} + +export function makeMemberSchema(overrides: Partial = {}) { + return { + id: 1, + username: 'user', + name: 'User', + state: 'active', + avatar_url: '', + web_url: 'https://gitlab.example/users/user', + expires_at: faker.date.future().toISOString(), + access_level: 30, + email: 'user@example.com', + group_saml_identity: { + extern_uid: '', + provider: '', + saml_provider_id: 1, + }, + ...overrides, + } satisfies MemberSchema +} + +export function makeGroupSchema(overrides: Partial = {}) { + return { + id: 123, + web_url: 'https://gitlab.example/groups/forge', + name: 'forge', + avatar_url: '', + full_name: 'forge', + full_path: 'forge', + path: 'forge', + description: '', + visibility: 'private', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: 'maintainer', + subgroup_creation_level: 'maintainer', + lfs_enabled: true, + default_branch_protection: 0, + request_access_enabled: false, + created_at: faker.date.past().toISOString(), + parent_id: 0, + ...overrides, + } satisfies GroupSchema +} + +export function makeExpandedGroupSchema(overrides: Partial = {}): ExpandedGroupSchema { + return { + id: 123, + web_url: 'https://gitlab.example/groups/forge', + name: 'forge', + avatar_url: '', + full_name: 'forge', + full_path: 'forge', + path: 'forge', + description: '', + visibility: 'private', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: 'maintainer', + subgroup_creation_level: 'maintainer', + lfs_enabled: true, + default_branch_protection: 0, + request_access_enabled: false, + created_at: faker.date.past().toISOString(), + parent_id: 0, + runners_token: 'runners-token', + file_template_project_id: 1, + shared_with_groups: null, + ...overrides, + } satisfies ExpandedGroupSchema +} + +export function makeProjectSchema(overrides: Partial = {}) { + return { + id: 1, + web_url: 'https://gitlab.example/projects/1', + name: 'repo', + path: 'repo', + description: '', + name_with_namespace: 'forge / repo', + path_with_namespace: 'forge/repo', + created_at: faker.date.past().toISOString(), + default_branch: 'main', + topics: [], + ssh_url_to_repo: 'ssh://gitlab.example/forge/repo.git', + http_url_to_repo: 'https://gitlab.example/forge/repo.git', + readme_url: '', + forks_count: 0, + avatar_url: null, + star_count: 0, + last_activity_at: faker.date.future().toISOString(), + namespace: { id: 1, name: 'forge', path: 'forge', kind: 'group', full_path: 'forge', avatar_url: '', web_url: 'https://gitlab.example/groups/forge' }, + description_html: '', + visibility: 'private', + empty_repo: false, + owner: { id: 1, name: 'Owner', created_at: faker.date.past().toISOString() }, + issues_enabled: true, + open_issues_count: 0, + merge_requests_enabled: true, + jobs_enabled: true, + wiki_enabled: true, + snippets_enabled: true, + can_create_merge_request_in: true, + resolve_outdated_diff_discussions: false, + container_registry_access_level: 'enabled', + security_and_compliance_access_level: 'enabled', + container_expiration_policy: { + cadence: '1d', + enabled: false, + keep_n: null, + older_than: null, + name_regex_delete: null, + name_regex_keep: null, + next_run_at: faker.date.future().toISOString(), + }, + updated_at: faker.date.past().toISOString(), + creator_id: 1, + import_url: null, + import_type: null, + import_status: 'none', + import_error: null, + permissions: { + project_access: { access_level: 0, notification_level: 0 }, + group_access: { access_level: 0, notification_level: 0 }, + }, + archived: false, + license_url: '', + license: { key: 'mit', name: 'MIT', nickname: 'MIT', html_url: '', source_url: '' }, + shared_runners_enabled: true, + group_runners_enabled: true, + runners_token: '', + ci_default_git_depth: 0, + ci_forward_deployment_enabled: false, + ci_forward_deployment_rollback_allowed: false, + ci_allow_fork_pipelines_to_run_in_parent_project: false, + ci_separated_caches: false, + ci_restrict_pipeline_cancellation_role: '', + public_jobs: false, + shared_with_groups: null, + repository_storage: '', + only_allow_merge_if_pipeline_succeeds: false, + allow_merge_on_skipped_pipeline: false, + restrict_user_defined_variables: false, + only_allow_merge_if_all_discussions_are_resolved: false, + remove_source_branch_after_merge: false, + printing_merge_requests_link_enabled: false, + request_access_enabled: false, + merge_method: '', + squash_option: '', + auto_devops_enabled: false, + auto_devops_deploy_strategy: '', + mirror: false, + mirror_user_id: 1, + mirror_trigger_builds: false, + only_mirror_protected_branches: false, + mirror_overwrites_diverged_branches: false, + external_authorization_classification_label: '', + packages_enabled: false, + service_desk_enabled: false, + service_desk_address: 'service-desk@example.com', + service_desk_reply_to: 'service-desk@example.com', + autoclose_referenced_issues: false, + suggestion_commit_message: 'Add suggestion commit message', + enforce_auth_checks_on_uploads: false, + merge_commit_template: 'Add suggestion commit message', + squash_commit_template: 'Add suggestion commit message', + issue_branch_template: 'Add suggestion commit message', + marked_for_deletion_on: faker.date.future().toISOString(), + compliance_frameworks: [], + warn_about_potentially_unwanted_characters: false, + container_registry_image_prefix: 'registry.gitlab.example/forge/repo', + _links: { + self: 'https://gitlab.example/projects/1', + issues: 'https://gitlab.example/projects/1/issues', + merge_requests: 'https://gitlab.example/projects/1/merge_requests', + repo_branches: 'https://gitlab.example/projects/1/repository/branches', + labels: 'https://gitlab.example/projects/1/labels', + events: 'https://gitlab.example/projects/1/events', + members: 'https://gitlab.example/projects/1/members', + cluster_agents: 'https://gitlab.example/projects/1/cluster_agents', + }, + ...overrides, + } satisfies ProjectSchema +} + +export function makeProject(overrides: Partial = {}) { + return { + id: faker.string.uuid(), + slug: faker.lorem.slug(), + name: faker.company.name(), + description: faker.lorem.sentence(), + status: faker.helpers.enumValue(ProjectStatus), + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + everyonePerms: 0n, + ownerId: faker.string.uuid(), + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } satisfies Project +} + +export function makeProjectWithDetails(overrides: Partial = {}) { + return { + id: faker.string.uuid(), + slug: faker.lorem.slug(), + name: faker.company.name(), + description: faker.lorem.sentence(), + owner: { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + }, + plugins: [], + roles: [], + members: [], + repositories: [], + clusters: [], + ...overrides, + } satisfies ProjectWithDetails +} +export function makePipelineTriggerToken(overrides: Partial = {}) { + return { + id: 1, + description: 'mirroring-from-external-repo', + created_at: faker.date.past().toISOString(), + last_used: null, + token: 'trigger-token', + updated_at: faker.date.past().toISOString(), + owner: null, + repoId: 1, + ...overrides, + } satisfies PipelineTriggerTokenSchema +} + +export function makeOffsetPagination(overrides: Partial = {}) { + return { + total: 1, + next: null, + current: 1, + previous: null, + perPage: 20, + totalPages: 1, + ...overrides, + } satisfies OffsetPagination +} + +export function makeAccessTokenSchema(overrides: Partial = {}) { + const isoDate = faker.date.past().toISOString() + return { + id: 1, + user_id: 1, + name: 'token', + expires_at: isoDate, + active: true, + created_at: isoDate, + revoked: false, + access_level: AccessLevel.DEVELOPER, + ...overrides, + } satisfies AccessTokenSchema +} + +export function makeAccessTokenExposedSchema(overrides: Partial = {}) { + return { + ...makeAccessTokenSchema(), + token: 'secret-token', + ...overrides, + } satisfies AccessTokenExposedSchema +} + +export function makeRepositoryFileExpandedSchema(overrides: Partial = {}) { + return { + file_name: 'file.txt', + file_path: 'file.txt', + size: 7, + encoding: 'base64', + content: 'content', + content_sha256: 'sha256', + ref: 'main', + blob_id: 'blob', + commit_id: 'commit', + last_commit_id: 'last-commit', + ...overrides, + } satisfies RepositoryFileExpandedSchema +} + +export function makeRepositoryTreeSchema(overrides: Partial = {}) { + return { + id: 'id', + name: 'file.txt', + type: 'blob', + path: 'file.txt', + mode: '100644', + ...overrides, + } satisfies RepositoryTreeSchema +} + +export function makeGitbeakerRequestError(params: { message?: string, status?: number, statusText?: string, description: string }) { + const request = new Request('https://gitlab.internal.example/api') + const response = new Response(null, { status: params.status ?? 404, statusText: params.statusText ?? 'Not Found' }) + return new GitbeakerRequestError(params.message ?? params.statusText ?? 'Error', { + cause: { + description: params.description, + request, + response, + }, + }) +} + +export function makeCommitAction(overrides: Partial = {}) { + return { + action: 'create', + filePath: 'file.txt', + content: 'content', + ...overrides, + } satisfies CommitAction +} + +export function makeAdminPlugin(overrides: { value?: string, pluginName?: string, key?: string } = {}) { + return { + pluginName: overrides.pluginName ?? PLUGIN_NAME, + key: overrides.key ?? 'token', + value: overrides.value ?? faker.string.alphanumeric(20), + } +} + +export function makeAdminRole(overrides: { id?: string, oidcGroup?: string } = {}) { + return { + id: overrides.id ?? faker.string.uuid(), + name: faker.word.words(), + permissions: 0n, + position: 0, + oidcGroup: overrides.oidcGroup ?? faker.word.words(), + type: 'managed', + } satisfies AdminRole +} + +export function makeUser(overrides: { id?: string, email?: string, firstName?: string, lastName?: string } = {}) { + return { + id: overrides.id ?? faker.string.uuid(), + email: overrides.email ?? faker.internet.email(), + firstName: overrides.firstName ?? faker.person.firstName(), + lastName: overrides.lastName ?? faker.person.lastName(), + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: null, + adminRoleIds: [], + type: 'human' as const, + } satisfies User +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts new file mode 100644 index 0000000000..d4d331cd2e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts @@ -0,0 +1,34 @@ +// GitLab plugin constants +export const PLUGIN_NAME = 'gitlab' + +// Infrastructure integration +export const INFRA_GROUP_NAME = 'Infra' +export const INFRA_GROUP_PATH = 'infra' +export const INFRA_APPS_REPO_NAME = 'infra-apps' +export const MIRROR_REPO_NAME = 'mirror' + +// Managed resources sentinel +export const TOPIC_PLUGIN_MANAGED = 'plugin-managed' +export const TOKEN_DESCRIPTION = 'mirroring-from-external-repo' + +// Default group paths for console roles +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly,/console/security' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin,/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly,/console/security' + +// Plugin configuration keys +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const AUDITOR_GROUP_PATH_PLUGIN_KEY = 'auditorGroupPath' +export const PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReporterGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDeveloperGroupPathSuffix' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectMaintainerGroupPathSuffix' +export const PURGE_PLUGIN_KEY = 'purge' + +// Custom attribute keys used in GitLab groups +export const GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY = 'cpn_projects_root_dir' +export const INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY = 'cpn_infra_group' +export const PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY = 'cpn_project_slug' +export const USER_ID_CUSTOM_ATTRIBUTE_KEY = 'cpn_user_id' +export const MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY = 'cpn_managed_by_console' diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts new file mode 100644 index 0000000000..3bb17e6b57 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts @@ -0,0 +1,19 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface GitlabConfig { + token?: string + url: string + internalUrl: string + mirrorTokenExpirationDays: number + mirrorTokenRotationThresholdDays: number + projectRootDir: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface GitlabModuleOptions extends GitlabConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('gitlab') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as GITLAB_CONFIG } diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts new file mode 100644 index 0000000000..f08dc6f312 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts @@ -0,0 +1,33 @@ +import type { GitlabConfig } from './gitlab.module-definition' +import { Gitlab } from '@gitbeaker/rest' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { GitlabHealthService } from './gitlab-health.service' +import { GitlabPluginService } from './gitlab-plugin.service' +import { ConfigurableModuleClass, GITLAB_CONFIG } from './gitlab.module-definition' +import { GitlabService } from './gitlab.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule], + providers: [ + { + provide: GITLAB_REST_CLIENT, + inject: [GITLAB_CONFIG], + useFactory: (config: GitlabConfig) => new Gitlab({ + token: config.token, + host: config.internalOrPublicUrl, + }), + }, + GitlabClientService, + GitlabDatastoreService, + GitlabHealthService, + GitlabPluginService, + GitlabService, + ], + exports: [GitlabClientService, GitlabHealthService, GitlabPluginService, GitlabService], +}) +export class GitlabModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts new file mode 100644 index 0000000000..da46c5fe4f --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -0,0 +1,492 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { AccessLevel } from '@gitbeaker/core' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { VaultClientService } from '../vault/vault-client.service' +import { GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { makeAccessTokenExposedSchema, makeExpandedUserSchema, makeGroupSchema, makeMemberSchema, makePipelineTriggerToken, makeProjectSchema, makeProjectWithDetails } from './gitlab-testing.utils' +import { PLUGIN_NAME, TOPIC_PLUGIN_MANAGED } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { GitlabService } from './gitlab.service' + +describe('gitlabService', () => { + let service: GitlabService + let gitlab: DeepMockProxy + let vault: DeepMockProxy + let datastore: DeepMockProxy + + beforeEach(async () => { + gitlab = mockDeep() + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + getAdminRolesByOidcGroups: vi.fn().mockResolvedValue([]), + }) + vault = mockDeep({ + writeGitlabMirrorCreds: vi.fn().mockResolvedValue(undefined), + deleteGitlabMirrorCreds: vi.fn().mockResolvedValue(undefined), + writeTechReadOnlyCreds: vi.fn().mockResolvedValue(undefined), + writeMirrorTriggerToken: vi.fn().mockResolvedValue(undefined), + readTechnReadOnlyCreds: vi.fn().mockResolvedValue(null), + readGitlabMirrorCreds: vi.fn().mockResolvedValue(null), + }) + const config = mockDeep({ projectRootDir: 'forge' }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + GitlabService, + { provide: GitlabClientService, useValue: gitlab }, + { provide: GitlabDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(GitlabService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('should reconcile project members and repositories', async () => { + const project = makeProjectWithDetails() + const group = makeGroupSchema({ + id: 123, + full_path: 'forge/console/project-1', + full_name: 'forge/console/project-1', + name: 'project-1', + path: 'project-1', + parent_id: 1, + }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectGroupRepo.mockResolvedValue(makeProjectSchema({ id: 1 })) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/repo') + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + gitlab.upsertUser.mockResolvedValue(makeExpandedUserSchema({ id: 123, username: 'user' })) + + await service.handleUpsert(project) + + expect(gitlab.getOrCreateProjectSubGroup).toHaveBeenCalledWith(project.slug) + expect(gitlab.getGroupMembers).toHaveBeenCalledWith(group) + expect(gitlab.getRepos).toHaveBeenCalledWith(project.slug) + }) + + it('should remove orphan member if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 999, username: 'orphan' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).toHaveBeenCalledWith(group, 999) + }) + + it('should not remove managed user (bot) even if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 888, username: 'group_123_bot' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).not.toHaveBeenCalled() + }) + + it('should not remove orphan member if purge disabled', async () => { + const project = makeProjectWithDetails() + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 999, username: 'orphan' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).not.toHaveBeenCalled() + }) + + it('should delete orphan repositories if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepo = makeProjectSchema({ name: 'orphan-repo', topics: [TOPIC_PLUGIN_MANAGED] }) + const unmanagedRepo = makeProjectSchema({ name: 'unmanaged-repo', topics: [] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepo + yield unmanagedRepo + })()) + gitlab.deleteProjectGroupRepo.mockResolvedValue(undefined) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).toHaveBeenCalledWith(project.slug, 'orphan-repo') + expect(gitlab.deleteProjectGroupRepo).toHaveBeenCalledTimes(1) + }) + + it('should not delete orphan repositories if purge disabled', async () => { + const project = makeProjectWithDetails({ + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepo = makeProjectSchema({ name: 'orphan-repo', topics: [TOPIC_PLUGIN_MANAGED] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepo + })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).not.toHaveBeenCalled() + }) + + it('should not delete orphan repositories without the correct topic even if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepoWithoutTopic = makeProjectSchema({ name: 'orphan-repo', topics: [] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepoWithoutTopic + })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).not.toHaveBeenCalled() + }) + + it('should create gitlab user if not exists', async () => { + const project = makeProjectWithDetails({ + owner: { id: 'o1', email: 'owner@example.com', firstName: 'Owner', lastName: 'User', adminRoleIds: [] }, + members: [{ user: { id: 'u1', email: 'new@example.com', firstName: 'New', lastName: 'User', adminRoleIds: [] }, roleIds: [] }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'new@example.com' ? 999 : 998, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'new@example.com' }), + expect.objectContaining({ cpnUserId: 'u1' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'owner@example.com' }), + expect.objectContaining({ cpnUserId: 'o1' }), + ) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 999, AccessLevel.GUEST) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 998, AccessLevel.OWNER) + }) + + it('should map roles to access levels and apply highest level', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-reporter', oidcGroup: '/project-1/console/readonly' }, + { id: 'r-developer', oidcGroup: '/project-1/console/developer' }, + { id: 'r-devops', oidcGroup: '/project-1/console/devops' }, + { id: 'r-maintainer', oidcGroup: '/project-1/console/admin' }, + { id: 'r-unknown', oidcGroup: '/other/group' }, + ], + members: [ + { user: { id: 'u1', email: 'reporter@example.com', firstName: 'Rep', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter'] }, + { user: { id: 'u2', email: 'developer@example.com', firstName: 'Dev', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-developer'] }, + { user: { id: 'u3', email: 'devops@example.com', firstName: 'Ops', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-devops'] }, + { user: { id: 'u4', email: 'maintainer@example.com', firstName: 'Main', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-maintainer'] }, + { user: { id: 'u5', email: 'mixed@example.com', firstName: 'Mixed', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter', 'r-developer'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + const idByEmail: Record = { + 'reporter@example.com': 101, + 'developer@example.com': 102, + 'devops@example.com': 103, + 'maintainer@example.com': 104, + 'mixed@example.com': 105, + 'owner@example.com': 100, + } + return makeExpandedUserSchema({ + id: idByEmail[user.email] ?? 999, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 101, AccessLevel.REPORTER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 102, AccessLevel.DEVELOPER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 103, AccessLevel.MAINTAINER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 104, AccessLevel.MAINTAINER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.DEVELOPER) + }) + + it('should prioritize higher access level when oidc group appears in multiple paths', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-devops', oidcGroup: '/project-1/console/devops' }, + ], + members: [ + { user: { id: 'u1', email: 'devops@example.com', firstName: 'Ops', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-devops'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'devops@example.com' ? 101 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 101, AccessLevel.MAINTAINER) + }) + + it('should map security project role to reporter access level', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-security', oidcGroup: '/project-1/console/security' }, + ], + members: [ + { user: { id: 'u1', email: 'security@example.com', firstName: 'Sec', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-security'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'security@example.com' ? 105 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.REPORTER) + }) + + it('should downgrade existing member to guest when no role maps to an access level', async () => { + const project = makeProjectWithDetails({ + roles: [{ id: 'r-unknown', oidcGroup: '/other/group' }], + members: [{ user: { id: 'u1', email: 'no-access@example.com', firstName: 'No', lastName: 'Access', adminRoleIds: [] }, roleIds: ['r-unknown'] }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 105, username: 'no-access', access_level: AccessLevel.REPORTER })]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'no-access@example.com' ? 105 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.editGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.GUEST) + expect(gitlab.removeGroupMember).not.toHaveBeenCalledWith(group, 105) + }) + + it('should bind builtin roles (admin/auditor) when role ids are resolved', async () => { + const project = makeProjectWithDetails({ + owner: { id: 'o1', email: 'owner@example.com', firstName: 'Owner', lastName: 'User', adminRoleIds: ['admin-role-id'] }, + members: [ + { user: { id: 'u1', email: 'admin@example.com', firstName: 'Admin', lastName: 'User', adminRoleIds: ['admin-role-id'] }, roleIds: [] }, + { user: { id: 'u2', email: 'auditor@example.com', firstName: 'Auditor', lastName: 'User', adminRoleIds: ['auditor-role-id'] }, roleIds: [] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + datastore.getAdminPluginConfig.mockImplementation(async (_pluginName: string, key: string) => { + if (key === 'adminGroupPath') return '/console/admin' + if (key === 'auditorGroupPath') return '/console/readonly' + return null + }) + datastore.getAdminRolesByOidcGroups.mockResolvedValue([ + { id: 'admin-role-id', oidcGroup: '/console/admin' }, + { id: 'auditor-role-id', oidcGroup: '/console/readonly' }, + ]) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: faker.number.int(), + email: user.email, + username: user.email.split('@')[0], + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'admin@example.com', admin: true, auditor: false }), + expect.objectContaining({ cpnUserId: 'u1' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'auditor@example.com', admin: false, auditor: true }), + expect.objectContaining({ cpnUserId: 'u2' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'owner@example.com', admin: true, auditor: false }), + expect.objectContaining({ cpnUserId: 'o1' }), + ) + }) + + it('should configure repository mirroring if external url is present', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + repositories: [{ + id: 'r1', + internalRepoName: 'repo-1', + externalRepoUrl: 'https://github.com/org/repo.git', + isPrivate: true, + externalUserName: 'user', + isInfra: false, + }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const gitlabRepo = makeProjectSchema({ id: 101, name: 'repo-1', path: 'repo-1', path_with_namespace: 'forge/console/project-1/repo-1' }) + const accessToken = makeAccessTokenExposedSchema({ + name: 'bot', + scopes: ['read_api'], + access_level: 40, + }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { yield gitlabRepo })()) + gitlab.getOrCreateProjectGroupInternalRepoUrl.mockResolvedValue('https://gitlab.internal/group/repo-1.git') + gitlab.createMirrorAccessToken.mockResolvedValue(accessToken) + vault.readTechnReadOnlyCreds.mockResolvedValue(null) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.createMirrorAccessToken).toHaveBeenCalledWith('project-1') + expect(gitlab.upsertProjectMirrorRepo).toHaveBeenCalledWith('project-1') + + expect(vault.writeGitlabMirrorCreds).toHaveBeenCalledWith( + 'project-1', + 'repo-1', + expect.objectContaining({ + GIT_INPUT_URL: 'github.com/org/repo.git', + GIT_OUTPUT_USER: 'bot', + GIT_OUTPUT_PASSWORD: accessToken.token, + }), + ) + expect(vault.writeTechReadOnlyCreds).toHaveBeenCalledWith('project-1', { + MIRROR_USER: 'bot', + MIRROR_TOKEN: accessToken.token, + }) + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects', async () => { + const projects = [makeProjectWithDetails({ id: 'p1', slug: 'project-1' })] + datastore.getAllProjects.mockResolvedValue(projects) + + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(gitlab.getOrCreateProjectSubGroup).toHaveBeenCalledWith('project-1') + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts new file mode 100644 index 0000000000..fd214154c1 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -0,0 +1,528 @@ +import type { CondensedGroupSchema, MemberSchema, ProjectSchema } from '@gitbeaker/core' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultSecret } from '../vault/vault-client.service' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import type { GitlabConfig } from './gitlab.module-definition' +import { specificallyEnabled } from '@cpn-console/hooks' +import { AccessLevel } from '@gitbeaker/core' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { getAll } from '../../utils/iterable.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + AUDITOR_GROUP_PATH_PLUGIN_KEY, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + INFRA_APPS_REPO_NAME, + PLUGIN_NAME, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PURGE_PLUGIN_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { + adminRoleFlag, + daysAgoFromNow, + generateAccessLevelMapping, + generateAdminRoleMapping, + generateName, + generateProjectRoleGroupPath, + generateUsername, + generateUsernameCandidates, + getProjectPluginConfig, + isOwnedRepo, + isOwnedUser, + isSystemRepo, +} from './gitlab.utils' + +type ProjectAccessLevel = Exclude + +@Injectable() +export class GitlabService { + private readonly logger = new Logger(GitlabService.name) + + constructor( + @Inject(GitlabDatastoreService) private readonly datastore: GitlabDatastoreService, + @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(GITLAB_CONFIG) private readonly gitlabConfig: GitlabConfig, + ) { + this.logger.log('GitLabService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('gitlab', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`GitLab sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('gitlab', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`GitLab sync completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + span?.setAttribute('gitlab.projects.count', 0) + this.logger.log('Starting GitLab reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('gitlab.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for GitLab reconciliation`) + await this.ensureProjectGroups(projects) + this.logger.log(`GitLab reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('gitlab.projects.count', projects.length) + this.logger.verbose(`Reconciling GitLab project groups (${projects.length})`) + await Promise.all(projects.map(p => this.ensureProjectGroup(p))) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling GitLab project group (${project.slug})`) + const group = await this.gitlab.getOrCreateProjectSubGroup(project.slug) + const members = await this.gitlab.getGroupMembers(group) + this.logger.verbose(`Loaded GitLab project group state (${project.slug}): groupId=${group.id} members=${members.length}`) + await this.ensureProjectGroupMembers(project, group, members) + await this.ensureProjectRepos(project) + await this.purgeOrphanRepos(project) + await this.ensureSystemRepos(project) + this.logger.verbose(`GitLab project group reconciled (${project.slug})`) + } + + @StartActiveSpan() + private async ensureProjectGroupMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling GitLab group members for project ${project.slug} (groupId=${group.id}, members=${members.length})`) + const { adminRoleId, auditorRoleId } = await this.getAdminRoleIds(project) + await this.addMissingMembers(project, group, members, adminRoleId, auditorRoleId) + await this.addMissingOwnerMember(project, group, members, adminRoleId, auditorRoleId) + await this.purgeOrphanMembers(project, group, members) + } + + private async addMissingMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + adminRoleId?: string, + auditorRoleId?: string, + ) { + const membersById = new Map(members.map(m => [m.id, m])) + const groupPaths = await this.getProjectRoleGroupPaths(project) + const accessLevelByUserId = generateAccessLevelMapping(project, groupPaths) + + await Promise.all(project.members.map(async ({ user }) => { + const gitlabUser = await this.gitlab.upsertUser({ + email: user.email, + username: generateUsername(user.email), + name: generateName(user.firstName, user.lastName), + admin: adminRoleFlag(user, adminRoleId), + auditor: adminRoleFlag(user, auditorRoleId), + }, { + cpnUserId: user.id, + }) + if (!gitlabUser) { + this.logger.warn(`Unable to resolve a GitLab user for a project member (project=${project.slug}, userId=${user.id}, email=${user.email})`) + return + } + const accessLevel = accessLevelByUserId.get(user.id) ?? AccessLevel.GUEST + await this.ensureGroupMemberAccessLevel(group, gitlabUser.id, accessLevel, membersById) + })) + } + + private async ensureGroupMemberAccessLevel( + group: CondensedGroupSchema, + gitlabUserId: number, + accessLevel: ProjectAccessLevel, + membersById: Map, + ) { + const existingMember = membersById.get(gitlabUserId) + + if (accessLevel === AccessLevel.NO_ACCESS) { + if (existingMember) { + await this.gitlab.removeGroupMember(group, gitlabUserId) + } + return + } + + if (!existingMember) { + await this.gitlab.addGroupMember(group, gitlabUserId, accessLevel) + return + } + + if (existingMember.access_level !== accessLevel) { + await this.gitlab.editGroupMember(group, gitlabUserId, accessLevel) + } + } + + private async addMissingOwnerMember( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + adminRoleId?: string, + auditorRoleId?: string, + ) { + const gitlabUser = await this.gitlab.upsertUser({ + email: project.owner.email, + username: generateUsername(project.owner.email), + name: generateName(project.owner.firstName, project.owner.lastName), + admin: adminRoleFlag(project.owner, adminRoleId), + auditor: adminRoleFlag(project.owner, auditorRoleId), + }, { + cpnUserId: project.owner.id, + }) + if (!gitlabUser) { + this.logger.warn(`Unable to resolve the GitLab owner account (project=${project.slug}, ownerId=${project.owner.id}, email=${project.owner.email})`) + return + } + const membersById = new Map(members.map(m => [m.id, m])) + await this.ensureGroupMemberAccessLevel(group, gitlabUser.id, AccessLevel.OWNER, membersById) + } + + private async getAdminRoleIds(project: ProjectWithDetails): Promise<{ adminRoleId?: string, auditorRoleId?: string }> { + const adminGroupPath = await this.getAdminGroupPath(project) + const auditorGroupPath = await this.getAuditorGroupPath(project) + const roles = await this.datastore.getAdminRolesByOidcGroups([adminGroupPath, auditorGroupPath]) + return generateAdminRoleMapping(roles, adminGroupPath, auditorGroupPath) + } + + private async getAdminGroupPath(project: ProjectWithDetails): Promise { + return await this.getAdminOrProjectPluginConfig(project, ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getAuditorGroupPath(project: ProjectWithDetails): Promise { + return await this.getAdminOrProjectPluginConfig(project, AUDITOR_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_AUDITOR_GROUP_PATH + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + if (!project) return undefined + return getProjectPluginConfig(project, key) ?? undefined + } + + private async getProjectRoleGroupPaths(project: ProjectWithDetails): Promise<{ reporter: string[], developer: string[], maintainer: string[] }> { + const [reporter, developer, maintainer] = await Promise.all([ + this.getProjectReporterGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectMaintainerGroupPaths(project), + ]) + + return { + reporter, + developer, + maintainer, + } + } + + private async getProjectReporterGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectMaintainerGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + @StartActiveSpan() + private async purgeOrphanMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'group.id': group.id, + 'members.total': members.length, + }) + const purgeConfig = getProjectPluginConfig(project, PURGE_PLUGIN_KEY) + const usernames = new Set([ + ...generateUsernameCandidates(project.owner.email), + ...project.members.flatMap(m => generateUsernameCandidates(m.user.email)), + ]) + const emails = new Set([ + project.owner.email.toLowerCase(), + ...project.members.map(m => m.user.email.toLowerCase()), + ]) + + const orphans = members.filter((member) => { + if (isOwnedUser(member)) return false + if (usernames.has(member.username)) return false + if (member.email && emails.has(member.email.toLowerCase())) return false + return true + }) + span?.setAttribute('orphans.count', orphans.length) + + if (specificallyEnabled(purgeConfig)) { + span?.setAttribute('purge.enabled', true) + let removedCount = 0 + await Promise.all(orphans.map(async (orphan) => { + await this.gitlab.removeGroupMember(group, orphan.id) + removedCount++ + this.logger.log(`Removed a user from the GitLab group (groupId=${group.id}, username=${orphan.username})`) + })) + span?.setAttribute('orphans.removed.count', removedCount) + } else { + span?.setAttribute('purge.enabled', false) + let warnedCount = 0 + for (const orphan of orphans) { + warnedCount++ + this.logger.warn(`User is in the GitLab group but not in the project (purge disabled, username=${orphan.username})`) + } + span?.setAttribute('orphans.warned.count', warnedCount) + } + } + + @StartActiveSpan() + private async ensureProjectRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'repositories.count': project.repositories.length, + }) + const gitlabRepositories = await getAll(this.gitlab.getRepos(project.slug)) + span?.setAttribute('gitlab.repositories.count', gitlabRepositories.length) + let mirroringEnabledCount = 0 + let mirroringDisabledCount = 0 + for (const repo of project.repositories) { + const externalHost = this.getExternalRepoHost(repo.externalRepoUrl) + span?.addEvent('gitlab.repo.reconcile', { + 'repository.name': repo.internalRepoName, + 'repository.isPrivate': repo.isPrivate, + ...(externalHost ? { 'repository.external.host': externalHost } : {}), + 'repository.external': !!repo.externalRepoUrl, + }) + await this.ensureRepository(project, repo, gitlabRepositories) + + if (repo.externalRepoUrl) { + span?.setAttribute('repository.mirroring', true) + mirroringEnabledCount++ + await this.configureRepositoryMirroring(project, repo) + } else { + span?.setAttribute('repository.mirroring', false) + mirroringDisabledCount++ + await this.vault.deleteGitlabMirrorCreds(project.slug, repo.internalRepoName) + } + } + span?.setAttribute('repositories.mirroring.enabled.count', mirroringEnabledCount) + span?.setAttribute('repositories.mirroring.disabled.count', mirroringDisabledCount) + } + + @StartActiveSpan() + private async purgeOrphanRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const gitlabRepositories = await getAll(this.gitlab.getRepos(project.slug)) + span?.setAttribute('gitlab.repositories.count', gitlabRepositories.length) + + const orphanRepos = gitlabRepositories.filter(r => isOwnedRepo(r) && !isSystemRepo(project, r)) + span?.setAttribute('orphan.repositories.count', orphanRepos.length) + + if (specificallyEnabled(getProjectPluginConfig(project, PURGE_PLUGIN_KEY))) { + span?.setAttribute('purge.enabled', true) + let removedCount = 0 + await Promise.all(orphanRepos.map(async (orphan) => { + await this.gitlab.deleteProjectGroupRepo(project.slug, orphan.name) + removedCount++ + this.logger.log(`Removed a repository from the GitLab project (project=${project.slug}, repoName=${orphan.name})`) + })) + + span?.setAttribute('orphan.repositories.removed.count', removedCount) + } else { + span?.setAttribute('purge.enabled', false) + let warnedCount = 0 + for (const orphan of orphanRepos) { + warnedCount++ + this.logger.warn(`Repository is in GitLab but not in the project definition (purge disabled, project=${project.slug}, repoName=${orphan.name})`) + } + span?.setAttribute('managed.repositories.warned.count', warnedCount) + } + } + + private async ensureRepository( + project: ProjectWithDetails, + repo: ProjectWithDetails['repositories'][number], + gitlabRepositories: ProjectSchema[], + ) { + return gitlabRepositories.find(r => r.name === repo.internalRepoName) + ?? await this.gitlab.upsertProjectGroupRepo( + project.slug, + repo.internalRepoName, + ) + } + + @StartActiveSpan() + private async configureRepositoryMirroring( + project: ProjectWithDetails, + repo: ProjectWithDetails['repositories'][number], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('repository.internalRepoName', repo.internalRepoName) + span?.setAttribute('repository.isPrivate', repo.isPrivate) + const externalHost = this.getExternalRepoHost(repo.externalRepoUrl) + if (externalHost) span?.setAttribute('repository.external.host', externalHost) + + const currentVaultSecret = await this.vault.readGitlabMirrorCreds(project.slug, repo.internalRepoName) + span?.setAttribute('vault.secret.exists', !!currentVaultSecret) + if (!currentVaultSecret) { + this.logger.warn(`No existing mirror credentials found in Vault; rotating new credentials (project=${project.slug}, repoName=${repo.internalRepoName})`) + } + + const internalRepoUrl = await this.gitlab.getOrCreateProjectGroupInternalRepoUrl(project.slug, repo.internalRepoName) + const externalRepoUrn = repo.externalRepoUrl.split('://')[1] + const internalRepoUrn = internalRepoUrl.split('://')[1] + span?.setAttribute('repository.externalRepoUrn', externalRepoUrn) + span?.setAttribute('repository.internalRepoUrn', internalRepoUrn) + + const projectMirrorCreds = await this.getOrRotateMirrorCreds(project.slug) + + const mirrorSecretData = { + GIT_INPUT_URL: externalRepoUrn, + GIT_INPUT_USER: repo.isPrivate ? repo.externalUserName : undefined, + GIT_INPUT_PASSWORD: currentVaultSecret?.data?.GIT_INPUT_PASSWORD, // Preserve existing password as it's not in DB + GIT_OUTPUT_URL: internalRepoUrn, + GIT_OUTPUT_USER: projectMirrorCreds.MIRROR_USER, + GIT_OUTPUT_PASSWORD: projectMirrorCreds.MIRROR_TOKEN, + } + + // Write to vault if changed + // Using simplified check + await this.vault.writeGitlabMirrorCreds(project.slug, repo.internalRepoName, mirrorSecretData) + span?.setAttribute('vault.secret.written', true) + } + + @StartActiveSpan() + private async ensureSystemRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + await Promise.all([ + this.ensureInfraAppsRepo(project.slug), + this.ensureMirrorRepo(project.slug), + ]) + } + + private async ensureInfraAppsRepo(projectSlug: string) { + await this.gitlab.upsertProjectGroupRepo(projectSlug, INFRA_APPS_REPO_NAME) + } + + private async ensureMirrorRepo(projectSlug: string) { + const mirrorRepo = await this.gitlab.upsertProjectMirrorRepo(projectSlug) + if (mirrorRepo.empty_repo) { + await this.gitlab.commitMirror(mirrorRepo.id) + } + await this.ensureMirrorRepoTriggerToken(projectSlug) + } + + @StartActiveSpan() + private async ensureMirrorRepoTriggerToken(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const triggerToken = await this.gitlab.getOrCreateMirrorPipelineTriggerToken(projectSlug) + const gitlabSecret = { + PROJECT_SLUG: projectSlug, + GIT_MIRROR_PROJECT_ID: triggerToken.repoId, + GIT_MIRROR_TOKEN: triggerToken.token, + } + await this.vault.writeMirrorTriggerToken(gitlabSecret) + span?.setAttribute('vault.secret.written', true) + } + + @StartActiveSpan() + private async getOrRotateMirrorCreds(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const vaultSecret = await this.vault.readTechnReadOnlyCreds(projectSlug) + if (!vaultSecret) return this.createMirrorAccessToken(projectSlug) + + const isExpiring = this.isMirrorCredsExpiring(vaultSecret) + span?.setAttribute('mirror.creds.expiring', isExpiring) + if (!isExpiring) { + span?.setAttribute('mirror.creds.rotated', false) + return vaultSecret.data as { MIRROR_USER: string, MIRROR_TOKEN: string } + } + return this.createMirrorAccessToken(projectSlug) + } + + @StartActiveSpan() + private async createMirrorAccessToken(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('mirror.creds.rotated', true) + const token = await this.gitlab.createMirrorAccessToken(projectSlug) + const creds = { + MIRROR_USER: token.name, + MIRROR_TOKEN: token.token, + } + await this.vault.writeTechReadOnlyCreds(projectSlug, creds) + span?.setAttribute('vault.secret.written', true) + return creds + } + + private isMirrorCredsExpiring(vaultSecret: VaultSecret): boolean { + if (!vaultSecret?.metadata?.created_time) return false + const createdTime = new Date(vaultSecret.metadata.created_time) + return daysAgoFromNow(createdTime) > this.gitlabConfig.mirrorTokenRotationThresholdDays + } + + private getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { + if (!externalRepoUrl) return undefined + try { + return new URL(externalRepoUrl).host + } catch { + return undefined + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts new file mode 100644 index 0000000000..63f574bd3c --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts @@ -0,0 +1,233 @@ +import type { MemberSchema, ProjectSchema } from '@gitbeaker/core' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { createHash } from 'node:crypto' +import { AccessLevel } from '@gitbeaker/core' +import { stringify } from 'yaml' +import { TOPIC_PLUGIN_MANAGED } from './gitlab.constants' + +export type ProjectAccessLevel = Exclude + +export function getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { + if (!externalRepoUrl) return undefined + try { + return new URL(externalRepoUrl).host + } catch { + return undefined + } +} + +export function hasFileContentChanged(file: { content_sha256?: string } | null | undefined, content: string): boolean { + return file?.content_sha256 !== digestContent(content) +} + +export function digestContent(content: string): string { + return createHash('sha256').update(content).digest('hex') +} + +export function generateUsername(email: string): string { + const localPart = email.split('@')[0] + return localPart.replaceAll(/[^\w-]/g, '') +} + +export function generateUsernameCandidates(email: string): string[] { + const username = generateUsername(email) + return [username, `${username}_1`, `${username}_2`, `${username}_3`] +} + +export function generateName(firstName: string | null, lastName: string | null): string { + const parts = [firstName, lastName].filter(Boolean) + return parts.length > 0 ? parts.join(' ') : 'User' +} + +export function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string): string[] { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${projectSlug}${path}`) +} + +export function generateAdminRoleMapping( + roles: ProjectWithDetails['roles'], + adminGroupPath: string, + auditorGroupPath: string, +): { adminRoleId?: string, auditorRoleId?: string } { + const roleIdByOidcGroup = new Map(roles.map(r => [r.oidcGroup, r.id] as [string | null, string])) + return { + adminRoleId: roleIdByOidcGroup.get(adminGroupPath), + auditorRoleId: roleIdByOidcGroup.get(auditorGroupPath), + } +} + +export function generateAccessLevelMapping( + project: ProjectWithDetails, + groupPaths: { reporter: string[], developer: string[], maintainer: string[] }, +): Map { + const getAccessLevelFromOidcGroup = (oidcGroup: string | null): ProjectAccessLevel | null => { + if (!oidcGroup) return null + if (groupPaths.maintainer.includes(oidcGroup)) return AccessLevel.MAINTAINER + if (groupPaths.developer.includes(oidcGroup)) return AccessLevel.DEVELOPER + if (groupPaths.reporter.includes(oidcGroup)) return AccessLevel.REPORTER + return null + } + + const roleAccessLevelById = new Map( + project.roles.map(role => [role.id, getAccessLevelFromOidcGroup(role.oidcGroup)]), + ) + + return project.members.reduce((acc, membership) => { + const highest = membership.roleIds.reduce((highest: ProjectAccessLevel | null, roleId) => { + const level = roleAccessLevelById.get(roleId) + if (level !== null && level !== undefined && (highest === null || level > highest)) return level + return highest + }, null) + acc.set(membership.user.id, highest ?? AccessLevel.GUEST) + return acc + }, new Map()) +} + +export function generateGitlabCIConfigContent() { + return stringify({ + variables: { + PROJECT_NAME: { + description: 'Nom du dépôt (dans ce Gitlab) à synchroniser.', + }, + GIT_BRANCH_DEPLOY: { + description: 'Nom de la branche à synchroniser.', + value: 'main', + }, + SYNC_ALL: { + description: 'Synchroniser toutes les branches.', + value: 'false', + }, + }, + include: [ + { + project: '$CATALOG_PATH', + file: 'mirror.yml', + ref: 'main', + }, + ], + repo_pull_sync: { + extends: '.repo_pull_sync', + only: [ + 'api', + 'triggers', + 'web', + 'schedules', + ], + }, + }) +} + +export function generateMirrorScriptContent() { + return `#!/bin/bash + +set -e + +# Colorize terminal +red='\\e[0;31m' +no_color='\\033[0m' + +# Console step increment +i=1 + +# Default values +BRANCH_TO_SYNC=main + +print_help() { + TEXT_HELPER="\\nThis script aims to send a synchronization request to DSO.\\nFollowing flags are available: + -a Api url to send the synchronization request + -b Branch which is wanted to be synchronize for the given repository (default '$BRANCH_TO_SYNC') + -g GitLab token to trigger the pipeline on the gitlab mirror project + -i Gitlab mirror project id + -r Gitlab repository name to mirror + -h Print script help\\n" + printf "$TEXT_HELPER" +} + +print_args() { + printf "\\nArguments received: + -a API_URL: $API_URL + -b BRANCH_TO_SYNC: $BRANCH_TO_SYNC + -g GITLAB_TRIGGER_TOKEN length: \${#GITLAB_TRIGGER_TOKEN} + -i GITLAB_MIRROR_PROJECT_ID: $GITLAB_MIRROR_PROJECT_ID + -r REPOSITORY_NAME: $REPOSITORY_NAME\\n" +} + +# Parse options +while getopts :ha:b:g:i:r: flag +do + case "\${flag}" in + a) + API_URL=\${OPTARG};; + b) + BRANCH_TO_SYNC=\${OPTARG};; + g) + GITLAB_TRIGGER_TOKEN=\${OPTARG};; + i) + GITLAB_MIRROR_PROJECT_ID=\${OPTARG};; + r) + REPOSITORY_NAME=\${OPTARG};; + h) + printf "\\nHelp requested.\\n" + print_help + printf "\\nExiting.\\n" + exit 0;; + *) + printf "\\nInvalid argument \${OPTARG} (\${flag}).\\n" + print_help + print_args + exit 1;; + esac +done + +# Test if arguments are missing +if [ -z \${API_URL} ] || [ -z \${BRANCH_TO_SYNC} ] || [ -z \${GITLAB_TRIGGER_TOKEN} ] || [ -z \${GITLAB_MIRROR_PROJECT_ID} ] || [ -z \${REPOSITORY_NAME} ]; then + printf "\\nArgument(s) missing!\\n" + print_help + print_args + exit 2 +fi + +# Print arguments +print_args + +# Send synchronization request +printf "\\n\${red}\${i}.\${no_color} Send request to DSO api.\\n\\n" + +curl \\ + -X POST \\ + --fail \\ + -F token=\${GITLAB_TRIGGER_TOKEN} \\ + -F ref=main \\ + -F variables[GIT_BRANCH_DEPLOY]=\${BRANCH_TO_SYNC} \\ + -F variables[PROJECT_NAME]=\${REPOSITORY_NAME} \\ + "\${API_URL}/api/v4/projects/\${GITLAB_MIRROR_PROJECT_ID}/trigger/pipeline" +` +} + +const ownedUserRegex = /group_\d+_bot/u + +export function isOwnedUser(member: MemberSchema) { + return ownedUserRegex.test(member.username) +} + +export function isOwnedRepo(repo: ProjectSchema) { + return repo.topics?.includes(TOPIC_PLUGIN_MANAGED) ?? false +} + +export function isSystemRepo(project: ProjectWithDetails, repo: ProjectSchema) { + return project.repositories.some(r => r.internalRepoName === repo.name) +} + +export function getProjectPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} +export function daysAgoFromNow(date: Date) { + return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) +} + +export function adminRoleFlag(user: ProjectWithDetails['members'][0]['user'], adminRoleId?: string) { + return adminRoleId ? user.adminRoleIds?.includes(adminRoleId) : undefined +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/healthz/healthz.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/healthz/healthz.controller.ts new file mode 100644 index 0000000000..b18ec7aadd --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/healthz/healthz.controller.ts @@ -0,0 +1,14 @@ +import type { HealthzService } from './healthz.service' +import { Controller, Get } from '@nestjs/common' +import { HealthCheck } from '@nestjs/terminus' + +@Controller('api/v1/healthz') +export class HealthzController { + constructor(private readonly healthz: HealthzService) {} + + @Get() + @HealthCheck() + check() { + return this.healthz.check() + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/healthz/healthz.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/healthz/healthz.module.ts new file mode 100644 index 0000000000..2b2cfaa615 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/healthz/healthz.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { ArgoCDModule } from '../argocd/argocd.module' +import { GitlabModule } from '../gitlab/gitlab.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { NexusModule } from '../nexus/nexus.module' +import { OpenCdsModule } from '../opencds/opencds.module' +import { RegistryModule } from '../registry/registry.module' +import { VaultModule } from '../vault/vault.module' +import { HealthzController } from './healthz.controller' +import { HealthzService } from './healthz.service' + +@Module({ + imports: [ + TerminusModule.forRoot(), + ConfigModule.forRoot(), + DatabaseModule, + KeycloakModule, + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + VaultModule, + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(RegistryModule, 'USE_REGISTRY'), + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(OpenCdsModule, 'USE_OPENCDS'), + ], + controllers: [HealthzController], + providers: [HealthzService], +}) +export class HealthzModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts new file mode 100644 index 0000000000..288e80d353 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts @@ -0,0 +1,106 @@ +import { HealthCheckService, TerminusModule } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { OpenCdsHealthService } from '../opencds/opencds-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { VaultHealthService } from '../vault/vault-health.service' +import { HealthzService } from './healthz.service' + +describe('healthzService', () => { + afterEach(() => vi.clearAllMocks()) + + describe('with all optional probes registered', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let keycloak: ReturnType> + let gitlab: ReturnType> + let vault: ReturnType> + let nexus: ReturnType> + let registry: ReturnType> + let argocd: ReturnType> + let opencds: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + keycloak = mockDeep() + gitlab = mockDeep() + vault = mockDeep() + nexus = mockDeep() + registry = mockDeep() + argocd = mockDeep() + opencds = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + { provide: KeycloakHealthService, useValue: keycloak }, + { provide: GitlabHealthService, useValue: gitlab }, + { provide: VaultHealthService, useValue: vault }, + { provide: NexusHealthService, useValue: nexus }, + { provide: RegistryHealthService, useValue: registry }, + { provide: ArgoCDHealthService, useValue: argocd }, + { provide: OpenCdsHealthService, useValue: opencds }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('checks every injected probe, including those wired via @Optional', async () => { + await service.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(keycloak.check).toHaveBeenCalledWith('keycloak') + expect(gitlab.check).toHaveBeenCalledWith('gitlab') + expect(vault.check).toHaveBeenCalledWith('vault') + expect(nexus.check).toHaveBeenCalledWith('nexus') + expect(registry.check).toHaveBeenCalledWith('registry') + expect(argocd.check).toHaveBeenCalledWith('argocd') + expect(opencds.check).toHaveBeenCalledWith('opencds') + }) + }) + + describe('without optional probes registered (@Optional -> undefined)', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('omits probes whose services are not registered', async () => { + await service.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(healthCheck.check).toHaveBeenCalledWith([expect.any(Function)]) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/healthz/healthz.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/healthz/healthz.service.ts new file mode 100644 index 0000000000..67619844e0 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/healthz/healthz.service.ts @@ -0,0 +1,41 @@ +import type { HealthIndicatorFunction } from '@nestjs/terminus' +import { Inject, Injectable, Optional } from '@nestjs/common' +import { HealthCheckService } from '@nestjs/terminus' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { OpenCdsHealthService } from '../opencds/opencds-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { VaultHealthService } from '../vault/vault-health.service' + +@Injectable() +export class HealthzService { + // Optional modules (gated by USE_*) resolve to undefined when not registered. + constructor( + @Inject(HealthCheckService) private readonly health: HealthCheckService, + @Inject(DatabaseHealthService) @Optional() private readonly database?: DatabaseHealthService, + @Inject(KeycloakHealthService) @Optional() private readonly keycloak?: KeycloakHealthService, + @Inject(GitlabHealthService) @Optional() private readonly gitlab?: GitlabHealthService, + @Inject(VaultHealthService) @Optional() private readonly vault?: VaultHealthService, + @Inject(NexusHealthService) @Optional() private readonly nexus?: NexusHealthService, + @Inject(RegistryHealthService) @Optional() private readonly registry?: RegistryHealthService, + @Inject(ArgoCDHealthService) @Optional() private readonly argocd?: ArgoCDHealthService, + @Inject(OpenCdsHealthService) @Optional() private readonly opencds?: OpenCdsHealthService, + ) {} + + check() { + const checks: HealthIndicatorFunction[] = [] + // Each health service's own check() reports 'Not configured' when its URL is absent. + if (this.database) checks.push(() => this.database!.check('database')) + if (this.keycloak) checks.push(() => this.keycloak!.check('keycloak')) + if (this.gitlab) checks.push(() => this.gitlab!.check('gitlab')) + if (this.vault) checks.push(() => this.vault!.check('vault')) + if (this.nexus) checks.push(() => this.nexus!.check('nexus')) + if (this.registry) checks.push(() => this.registry!.check('registry')) + if (this.argocd) checks.push(() => this.argocd!.check('argocd')) + if (this.opencds) checks.push(() => this.opencds!.check('opencds')) + return this.health.check(checks) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts new file mode 100644 index 0000000000..d170ff59ee --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts @@ -0,0 +1,86 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { HttpArgumentsHost } from '@nestjs/common/interfaces' +import type { AdminToken, PersonalAccessToken, User } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from './auth-user.decorator' +import { faker } from '@faker-js/faker' +import { mockDeep } from 'vitest-mock-extended' + +export function makeAuthRequest(headers: FastifyRequest['headers'] = {}): FastifyRequest { + const request = mockDeep() + request.headers = headers + return request +} + +export function makeExecutionContext(headers: FastifyRequest['headers'] = {}): DeepMockProxy { + const context = mockDeep() + const httpArgumentsHost = mockDeep() + const request = mockDeep() + + request.headers = headers + httpArgumentsHost.getRequest.mockReturnValue(request) + context.switchToHttp.mockReturnValue(httpArgumentsHost) + + return context +} + +export function makePersonalAccessToken(overrides: { + id?: string + userId?: string + adminRoleIds?: string[] + type?: User['type'] + status?: PersonalAccessToken['status'] + expirationDate?: PersonalAccessToken['expirationDate'] + lastUse?: PersonalAccessToken['lastUse'] +} = {}): PersonalAccessToken & { owner: { id: string, adminRoleIds: string[], type: string } } { + const id = overrides.id ?? 'pat-id' + const ownerId = overrides.userId ?? 'owner-id' + return { + id, + name: 'test-token', + status: overrides.status ?? 'active', + expirationDate: overrides.expirationDate ?? faker.date.future(), + lastUse: overrides.lastUse ?? null, + createdAt: faker.date.past(), + hash: 'hash', + userId: ownerId, + owner: { + id: ownerId, + adminRoleIds: overrides.adminRoleIds ?? [], + type: overrides.type ?? 'human', + }, + } +} + +export function makeAdminToken(overrides: { + id?: string + userId?: string + adminRoleIds?: string[] + type?: User['type'] + status?: AdminToken['status'] + permissions?: bigint + expirationDate?: AdminToken['expirationDate'] + lastUse?: AdminToken['lastUse'] +} = {}): AdminToken & { owner: { id: string, adminRoleIds: string[], type: string } } { + const id = overrides.id ?? 'admin-token-id' + const ownerId = overrides.userId ?? 'owner-id' + const type = overrides.type ?? 'human' + + return { + id, + name: 'admin-token', + status: overrides.status ?? 'active', + expirationDate: overrides.expirationDate ?? null, + permissions: overrides.permissions ?? 256n, + lastUse: overrides.lastUse ?? null, + createdAt: faker.date.past(), + hash: 'hash', + userId: ownerId, + owner: { + id: ownerId, + adminRoleIds: overrides.adminRoleIds ?? [], + type, + }, + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts new file mode 100644 index 0000000000..f018ac647e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts @@ -0,0 +1,21 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { User } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import { createParamDecorator } from '@nestjs/common' + +export interface UserContext { + userId: string + adminPermissions?: bigint + userType?: User['type'] +} + +export const AuthUser = createParamDecorator( + (_: unknown, ctx: ExecutionContext): UserContext => { + const request = ctx.switchToHttp().getRequest() + return { + userId: request.userId, + adminPermissions: request.adminPermissions, + userType: request.userType, + } + }, +) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts new file mode 100644 index 0000000000..23f7129d2e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../config/keycloak.config' +import { AuthService } from './auth.service' +import { DsoTokenModule } from './dso-token/dso-token.module' +import { KeycloakJwtModule } from './keycloak-jwt/keycloak-jwt.module' + +@Module({ + imports: [ConfigModule.forFeature(keycloakConfigFactory), DsoTokenModule, KeycloakJwtModule], + providers: [ + AuthService, + ], + exports: [ + AuthService, + KeycloakJwtModule, + ], +}) +export class AuthModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts new file mode 100644 index 0000000000..aa0fe99424 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts @@ -0,0 +1,59 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeAuthRequest } from './auth-testing.utils' +import { AuthService } from './auth.service' +import { DsoTokenService } from './dso-token/dso-token.service' +import { KeycloakJwtService } from './keycloak-jwt/keycloak-jwt.service' + +describe('authService', () => { + let module: TestingModule + let service: AuthService + let dsoTokenService: DeepMockProxy + let keycloakJwtService: DeepMockProxy + + beforeEach(async () => { + dsoTokenService = mockDeep() + keycloakJwtService = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: DsoTokenService, useValue: dsoTokenService }, + { provide: KeycloakJwtService, useValue: keycloakJwtService }, + ], + }).compile() + + service = module.get(AuthService) + }) + + it('should authenticate a Fastify request directly', async () => { + dsoTokenService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 0n, userType: 'human' }) + const request = makeAuthRequest({ 'x-dso-token': 'token' }) + + const result = await service.authenticate(request) + + expect(result).toEqual({ userId: 'u1', adminPermissions: 0n, userType: 'human' }) + expect(dsoTokenService.authenticate).toHaveBeenCalledWith(request, undefined) + }) + + it('should authenticate a Keycloak bearer token from the request header', async () => { + keycloakJwtService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 8n, userType: 'human' }) + + const request = makeAuthRequest({ authorization: 'Bearer jwt-token' }) + const result = await service.authenticate(request) + + expect(keycloakJwtService.authenticate).toHaveBeenCalledWith( + request, + undefined, + ) + expect(result).toEqual({ userId: 'u1', adminPermissions: 8n, userType: 'human' }) + }) + + it('should throw 401 when no supported auth header exists', async () => { + await expect(service.authenticate(makeAuthRequest({}))).rejects.toThrow(UnauthorizedException) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts new file mode 100644 index 0000000000..9ee491473c --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts @@ -0,0 +1,37 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from './auth-user.decorator' +import type { AuthRequirements } from './auth.utils' +import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common' +import { DsoTokenService } from './dso-token/dso-token.service' +import { KeycloakJwtService } from './keycloak-jwt/keycloak-jwt.service' + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name) + + constructor( + @Inject(DsoTokenService) private readonly dsoTokenService: DsoTokenService, + @Inject(KeycloakJwtService) private readonly keycloakJwtService: KeycloakJwtService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + const dsoTokenResult = await this.dsoTokenService.authenticate(request, requirements) + if (dsoTokenResult) { + this.logger.debug(`Auth authenticated request (requestId=${request.id}, authSource=dso-token, userId=${dsoTokenResult.userId}, adminPermissions=${dsoTokenResult.adminPermissions?.toString()}, userType=${dsoTokenResult.userType})`) + return dsoTokenResult + } + + const bearerTokenResult = await this.keycloakJwtService.authenticate(request, requirements) + if (bearerTokenResult) { + this.logger.debug(`Auth authenticated request (requestId=${request.id}, authSource=keycloak-jwt, userId=${bearerTokenResult.userId}, adminPermissions=${bearerTokenResult.adminPermissions?.toString()}, userType=${bearerTokenResult.userType})`) + return bearerTokenResult + } + + this.logger.warn(`Auth rejected request (requestId=${request.id}, hasDsoTokenHeader=${typeof request.headers['x-dso-token'] === 'string'}, hasAuthorizationHeader=${typeof request.headers.authorization === 'string'})`) + + throw new UnauthorizedException('Not authenticated') + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts new file mode 100644 index 0000000000..3b4d0c3ed9 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts @@ -0,0 +1,14 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from './auth-user.decorator' + +export interface AuthRequirements { + includeAdminRoleIds?: boolean + includeUserType?: boolean +} + +export interface AuthProvider { + authenticate: ( + request: FastifyRequest, + requirements?: AuthRequirements, + ) => Promise +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts new file mode 100644 index 0000000000..ed96461131 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../../database/database.module' +import { DsoTokenService } from './dso-token.service' + +@Module({ + imports: [DatabaseModule], + providers: [DsoTokenService], + exports: [DsoTokenService], +}) +export class DsoTokenModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts new file mode 100644 index 0000000000..f9ab618442 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts @@ -0,0 +1,118 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { createHash } from 'node:crypto' +import { faker } from '@faker-js/faker' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../../database/prisma.service' +import { makeAdminToken, makePersonalAccessToken } from '../auth-testing.utils' +import { DsoTokenService } from './dso-token.service' + +describe('dsoTokenService', () => { + let module: TestingModule + let service: DsoTokenService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep({ + adminRole: { + findMany: vi.fn().mockResolvedValue([]), + }, + }) + + module = await Test.createTestingModule({ + providers: [ + DsoTokenService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(DsoTokenService) + }) + + it('should validate a PersonalAccessToken and return raw token result', async () => { + const rawToken = faker.string.alphanumeric(32) + const patMock = makePersonalAccessToken({ adminRoleIds: [faker.string.uuid()] }) + prisma.personalAccessToken.findFirst.mockResolvedValue(patMock) + + const result = await service.validateToken(rawToken, { includeAdminRoleIds: true, includeUserType: true }) + + expect(prisma.personalAccessToken.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { hash: createHash('sha256').update(rawToken).digest('hex') } }), + ) + expect(result).toBeDefined() + if (result?.kind !== 'personal') throw new Error('Expected personal token result') + expect(result.ownerAdminRoleIds).toHaveLength(1) + expect(result.userType).toBe('human') + }) + + it('should validate an AdminToken and return raw token result', async () => { + const userId = faker.string.uuid() + prisma.personalAccessToken.findFirst.mockResolvedValue(null) + prisma.adminToken.findFirst.mockResolvedValue( + makeAdminToken({ + id: faker.string.uuid(), + userId, + permissions: 256n, + }), + ) + + const result = await service.validateToken(faker.string.alphanumeric(24), { includeAdminRoleIds: true, includeUserType: true }) + + expect(result).toBeDefined() + if (result?.kind !== 'admin') throw new Error('Expected admin token result') + expect(result.permissions).toBe(256n) + expect(result.userType).toBe('human') + }) + + it('should return undefined when no token found', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue(null) + prisma.adminToken.findFirst.mockResolvedValue(null) + + const result = await service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true }) + + expect(result).toBeUndefined() + }) + + it('should throw 401 when token is inactive', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue( + makePersonalAccessToken({ status: 'revoked', expirationDate: new Date(Date.now() + 86400000) }), + ) + + await expect(service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true })).rejects.toThrow(UnauthorizedException) + }) + + it('should throw 401 when token is expired', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue( + makePersonalAccessToken({ expirationDate: new Date(Date.now() - 1000) }), + ) + + await expect(service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true })).rejects.toThrow(UnauthorizedException) + }) + + it('should update lastUse and lastLogin', async () => { + const patMock = makePersonalAccessToken({}) + prisma.personalAccessToken.findFirst.mockResolvedValue(patMock) + + await service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true }) + + expect(prisma.personalAccessToken.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: patMock.id }, data: { lastUse: expect.any(String) } }), + ) + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: patMock.owner.id }, data: { lastLogin: expect.any(String) } }), + ) + }) + + it('should authenticate a DSO token from the request header', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue(makePersonalAccessToken({})) + + const result = await service.authenticateHeaders({ 'x-dso-token': 'token' }) + + expect(typeof result?.userId).toBe('string') + expect(result?.userType).toBe('human') + expect(result?.adminPermissions).toBe(0n) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts new file mode 100644 index 0000000000..1e27c96d63 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts @@ -0,0 +1,150 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../auth-user.decorator' +import type { AuthProvider, AuthRequirements } from '../auth.utils' +import type { AdminTokenWithOwner, AuthToken, PersonalAccessTokenWithOwner } from './dso-token.utils' +import { createHash } from 'node:crypto' +import { tokenHeaderName } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { PrismaService } from '../../database/prisma.service' +import { makeAdminTokenSelect, makePersonalAccessTokenSelect, validateToken } from './dso-token.utils' + +@Injectable() +export class DsoTokenService implements AuthProvider { + private readonly logger = new Logger(DsoTokenService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + return this.authenticateHeaders(request.headers, requirements) + } + + async authenticateHeaders( + headers: FastifyRequest['headers'], + requirements?: AuthRequirements, + ): Promise { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? true + const includeUserType = requirements?.includeUserType ?? true + + const tokenValue = headers[tokenHeaderName] + if (typeof tokenValue !== 'string') { + return undefined + } + + const tokenResult = await this.validateToken(tokenValue, { includeAdminRoleIds, includeUserType }) + if (!tokenResult) return undefined + + const adminPermissions = includeAdminRoleIds + ? await this.resolveAdminPermissions(tokenResult) + : undefined + + return { + userId: tokenResult.userId, + adminPermissions, + userType: tokenResult.userType, + } + } + + async validateToken( + rawToken: string, + requirements: Required, + ): Promise { + this.logger.debug(`validateToken started`) + const hash = createHash('sha256').update(rawToken).digest('hex') + const result = await this.findAndValidateToken(hash, requirements) + if (!result) { + this.logger.warn(`validateToken token not found`) + return undefined + } + this.logger.debug(`validateToken completed (userId=${result.userId})`) + return result + } + + private async findAndValidateToken(hash: string, requirements: Required): Promise { + const personalAccessTokenResult = await this.findAndValidatePersonalAccessToken(hash, requirements) + if (personalAccessTokenResult) { + return personalAccessTokenResult + } + + const adminTokenResult = await this.findAndValidateAdminToken(hash, requirements) + if (adminTokenResult) { + return adminTokenResult + } + + return undefined + } + + private async findAndValidatePersonalAccessToken(hash: string, requirements: Required): Promise { + const pat = await this.prisma.personalAccessToken.findFirst({ + select: makePersonalAccessTokenSelect(requirements), + where: { hash }, + }) satisfies PersonalAccessTokenWithOwner | null + if (pat) { + validateToken(pat) + await this.updateLastUse('personalAccessToken', pat.id, pat.owner.id) + return { + kind: 'personal' as const, + userId: pat.owner.id, + ownerAdminRoleIds: requirements.includeAdminRoleIds ? (pat.owner.adminRoleIds ?? []) : undefined, + userType: requirements.includeUserType ? (pat.owner.type ?? undefined) : undefined, + } + } + + return undefined + } + + private async findAndValidateAdminToken(hash: string, requirements: Required): Promise { + const adminToken = await this.prisma.adminToken.findFirst({ + select: makeAdminTokenSelect(requirements), + where: { hash }, + }) satisfies AdminTokenWithOwner | null + if (adminToken) { + validateToken(adminToken) + await this.updateLastUse('adminToken', adminToken.id, adminToken.owner.id) + return { + kind: 'admin' as const, + userId: adminToken.owner.id, + permissions: adminToken.permissions, + userType: adminToken.owner.type ?? undefined, + } + } + + return undefined + } + + private async updateLastUse(model: 'personalAccessToken' | 'adminToken', tokenId: string, userId: string) { + const now = new Date().toISOString() + if (model === 'personalAccessToken') { + await this.prisma.personalAccessToken.update({ where: { id: tokenId }, data: { lastUse: now } }) + } else { + await this.prisma.adminToken.update({ where: { id: tokenId }, data: { lastUse: now } }) + } + await this.prisma.user.update({ where: { id: userId }, data: { lastLogin: now } }) + } + + private async resolveAdminPermissions(result: AuthToken): Promise { + const globalRoles = await this.prisma.adminRole.findMany({ + where: { type: 'global' }, + select: { permissions: true }, + }) + const globalPerms = globalRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + + if (result.kind === 'admin') { + return globalPerms | result.permissions + } + const ownerAdminRoleIds = result.ownerAdminRoleIds ?? [] + if (!ownerAdminRoleIds.length) { + return globalPerms + } + const roles = await this.prisma.adminRole.findMany({ + select: { permissions: true }, + where: { id: { in: ownerAdminRoleIds } }, + }) + const tokenPerms = roles.reduce((acc, curr) => acc | curr.permissions, 0n) + return globalPerms | tokenPerms + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts new file mode 100644 index 0000000000..297533b572 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts @@ -0,0 +1,72 @@ +import type { AdminToken, Prisma, User } from '@prisma/client' +import type { AuthRequirements } from '../auth.utils' +import { UnauthorizedException } from '@nestjs/common' + +export type AuthToken + = | { kind: 'admin', userId: string, permissions: bigint, userType?: User['type'] } + | { kind: 'personal', userId: string, ownerAdminRoleIds?: string[], userType?: User['type'] } + +export function makeAdminTokenSelect(requirements?: AuthRequirements): Prisma.AdminTokenSelect { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? false + const includeUserType = requirements?.includeUserType ?? false + + return { + id: true, + name: true, + ...(includeAdminRoleIds ? { owner: { select: { adminRoleIds: true } } } : {}), + ...(includeUserType ? { owner: { select: { type: true } } } : {}), + ...(includeAdminRoleIds && includeUserType ? { owner: { select: { adminRoleIds: true, type: true } } } : {}), + } satisfies Prisma.AdminTokenSelect +} + +export function makePersonalAccessTokenSelect(requirements?: AuthRequirements): Prisma.PersonalAccessTokenSelect { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? false + const includeUserType = requirements?.includeUserType ?? false + + return { + id: true, + name: true, + owner: { + select: { + id: true, + ...(includeAdminRoleIds ? { adminRoleIds: true } : {}), + ...(includeUserType ? { type: true } : {}), + ...(includeAdminRoleIds && includeUserType ? { adminRoleIds: true, type: true } : {}), + }, + }, + } satisfies Prisma.PersonalAccessTokenSelect +} + +export interface PersonalAccessTokenWithOwner { + id: string + status: AdminToken['status'] + expirationDate: Date | null + owner: { + id: string + adminRoleIds?: string[] | null + type?: User['type'] | null + } +} + +export interface AdminTokenWithOwner { + id: string + status: AdminToken['status'] + expirationDate: Date | null + owner: { + id: string + adminRoleIds?: string[] | null + type?: User['type'] | null + } +} + +export type Token = PersonalAccessTokenWithOwner | AdminTokenWithOwner + +export function validateToken(token: Token): void { + if (token.expirationDate && token.expirationDate < new Date()) { + throw new UnauthorizedException(`Token expired: ${token.expirationDate}`) + } + + if (token.status === 'revoked') { + throw new UnauthorizedException('Token revoked') + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts new file mode 100644 index 0000000000..9b29a144f2 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts @@ -0,0 +1,45 @@ +import { faker } from '@faker-js/faker' + +export function makeMockUser(overrides: Partial<{ + id: string + firstName: string + lastName: string + email: string + createdAt: Date + updatedAt: Date + lastLogin: Date | null + adminRoleIds: string[] + type: 'human' | 'bot' | 'ghost' +}> = {}) { + return { + id: faker.string.uuid(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email().toLowerCase(), + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: null, + adminRoleIds: [], + type: 'human' as const, + ...overrides, + } +} + +export function makeMockAdminRole(overrides: Partial<{ + id: string + name: string + permissions: bigint + position: number + oidcGroup: string + type: string +}> = {}) { + return { + id: faker.string.uuid(), + name: faker.lorem.words(2), + permissions: 1n, + position: 0, + oidcGroup: '', + type: 'managed', + ...overrides, + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts new file mode 100644 index 0000000000..6c7851d92d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common' +import { JwtModule } from '@nestjs/jwt' +import { DatabaseModule } from '../../database/database.module' +import { KeycloakSecretProviderModule } from '../keycloak-secret-provider/keycloak-secret-provider.module' +import { KeycloakSecretProviderService } from '../keycloak-secret-provider/keycloak-secret-provider.service' +import { KeycloakJwtService } from './keycloak-jwt.service' + +@Module({ + imports: [ + DatabaseModule, + JwtModule.registerAsync({ + imports: [KeycloakSecretProviderModule], + inject: [KeycloakSecretProviderService], + useFactory: async (client: KeycloakSecretProviderService) => { + // The issuer is fetched from the openid-configuration endpoint + // rather than reconstructed from env vars, as the server may be + // behind a reverse proxy that differs from the public-facing domain. + const issuer = await client.fetchIssuer() + return { + secretOrKeyProvider: (requestType, tokenOrPayload) => client.getSecret(requestType, tokenOrPayload), + // Keycloak tokens currently do not include an audience claim for this app. + verifyOptions: { + algorithms: ['RS256'], + issuer, + }, + } + }, + }), + ], + providers: [KeycloakJwtService], + exports: [KeycloakJwtService, JwtModule], +}) +export class KeycloakJwtModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts new file mode 100644 index 0000000000..ed2c588caf --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts @@ -0,0 +1,170 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { UnauthorizedException } from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../../database/prisma.service' +import { makeAuthRequest } from '../auth-testing.utils' +import { makeMockAdminRole, makeMockUser } from './keycloak-jwt-testing.utils' +import { KeycloakJwtService } from './keycloak-jwt.service' + +describe('keycloakJwtService', () => { + let module: TestingModule + let service: KeycloakJwtService + let prisma: DeepMockProxy + let jwtService: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + jwtService = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + KeycloakJwtService, + { provide: PrismaService, useValue: prisma }, + { provide: JwtService, useValue: jwtService }, + ], + }).compile() + + service = module.get(KeycloakJwtService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('validatePayload', () => { + it('should recompute active admin roles from the current Keycloak group membership', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue( + makeMockUser({ + id: payload.sub, + adminRoleIds: ['stale-oidc-role', 'manual-role'], + }), + ) + prisma.adminRole.findMany.mockResolvedValue([ + makeMockAdminRole({ + id: 'stale-oidc-role', + oidcGroup: '/stale-group', + permissions: 8n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'manual-role', + oidcGroup: '', + permissions: 16n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'current-oidc-role', + oidcGroup: '/current-group', + permissions: 32n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'global-role', + oidcGroup: '', + permissions: 4n, + type: 'global', + }), + ]) + + const result = await service.validatePayload(payload) + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: payload.sub }, + data: { + adminRoleIds: ['manual-role', 'current-oidc-role', 'global-role'], + lastLogin: expect.any(String), + }, + }) + expect(result).toEqual({ + userId: payload.sub, + adminPermissions: 52n, + userType: 'human', + }) + }) + + it('should reject when the local user is missing', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue(null) + + await expect(service.validatePayload(payload)).rejects.toBeInstanceOf(UnauthorizedException) + expect(prisma.adminRole.findMany).not.toHaveBeenCalled() + expect(prisma.user.update).not.toHaveBeenCalled() + }) + + it('should skip admin role resolution when permissions are not required', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue( + makeMockUser({ + id: payload.sub, + type: 'human', + }), + ) + + const result = await service.validatePayload( + payload, + { includeAdminRoleIds: false, includeUserType: true }, + ) + + expect(prisma.adminRole.findMany).not.toHaveBeenCalled() + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: payload.sub }, + data: { + lastLogin: expect.any(String), + }, + }) + expect(result).toEqual({ + userId: payload.sub, + adminPermissions: undefined, + userType: 'human', + }) + }) + }) + + describe('authenticate', () => { + it('should authenticate a bearer token from the request', async () => { + jwtService.verifyAsync.mockResolvedValue({ + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: [], + }) + prisma.user.findUnique.mockResolvedValue(makeMockUser({})) + prisma.adminRole.findMany.mockResolvedValue([]) + + const result = await service.authenticate( + makeAuthRequest({ authorization: 'Bearer jwt-token' }), + ) + + expect(jwtService.verifyAsync).toHaveBeenCalledWith('jwt-token') + expect(result?.userId).toBeDefined() + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts new file mode 100644 index 0000000000..9e1eb00db7 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts @@ -0,0 +1,123 @@ +import type { Prisma } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { IncomingHttpHeaders } from 'node:http' +import type { UserContext } from '../auth-user.decorator' +import type { AuthProvider, AuthRequirements } from '../auth.utils' +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { z } from 'zod' +import { PrismaService } from '../../database/prisma.service' + +const KeycloakPayloadSchema = z.object({ + sub: z.string(), + email: z.string().optional().default(''), + given_name: z.string().optional().default(''), + family_name: z.string().optional().default(''), + groups: z.array(z.string()).optional().default([]), +}) + +type KeycloakPayload = z.infer + +@Injectable() +export class KeycloakJwtService implements AuthProvider { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(JwtService) private readonly jwtService: JwtService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + return this.authenticateHeaders(request.headers, requirements) + } + + async authenticateHeaders( + headers: IncomingHttpHeaders, + requirements?: AuthRequirements, + ): Promise { + const authHeader = headers.authorization + if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) { + return undefined + } + + try { + const jwt = authHeader.slice(7) + const payload = await this.jwtService.verifyAsync(jwt) + const parsedPayload = KeycloakPayloadSchema.parse(payload) + return await this.validatePayload(parsedPayload, requirements) + } catch (error) { + throw new UnauthorizedException( + error instanceof Error ? error.message : 'Authentication failed', + ) + } + } + + async validatePayload( + payload: KeycloakPayload, + requirements?: AuthRequirements, + ): Promise { + const authRequirements = normalizeRequirements(requirements) + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + select: makeUserSelect(authRequirements), + }) + if (!user) { + throw new UnauthorizedException('Not authenticated') + } + + let adminPermissions: bigint | undefined + let mergedRoleIds: string[] | undefined + + if (authRequirements.includeAdminRoleIds) { + const adminRoleIds = 'adminRoleIds' in user ? user.adminRoleIds : [] + const groups = payload.groups + + const matchingAdminRoles = await this.prisma.adminRole.findMany({ + where: { + OR: [ + { oidcGroup: { in: groups } }, + { id: { in: adminRoleIds } }, + { type: 'global' }, + ], + }, + }) + + const activeAdminRoles = matchingAdminRoles.filter(({ oidcGroup, type }) => + type === 'global' || !oidcGroup || groups.includes(oidcGroup), + ) + + mergedRoleIds = [...new Set(activeAdminRoles.map(({ id }) => id))] + adminPermissions = activeAdminRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + } + + await this.prisma.user.update({ + where: { id: payload.sub }, + data: { + ...(mergedRoleIds ? { adminRoleIds: mergedRoleIds } : {}), + lastLogin: new Date().toISOString(), + }, + }) + + return { + userId: payload.sub, + adminPermissions, + userType: 'type' in user ? user.type : undefined, + } + } +} + +function makeUserSelect(requirements: Required) { + return { + id: true, + ...(requirements.includeAdminRoleIds ? { adminRoleIds: true } : {}), + ...(requirements.includeUserType ? { type: true } : {}), + } satisfies Prisma.UserSelect +} + +function normalizeRequirements(requirements: AuthRequirements = {}): Required { + return { + includeAdminRoleIds: requirements.includeAdminRoleIds ?? true, + includeUserType: requirements.includeUserType ?? true, + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts new file mode 100644 index 0000000000..4c5ef71a1b --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts @@ -0,0 +1,22 @@ +import { generateKeyPairSync } from 'node:crypto' + +export function makeJwksResponse(kid: string): Response { + const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const jwk = publicKey.export({ format: 'jwk' }) + return new Response(JSON.stringify({ + keys: [ + { + kid, + kty: 'RSA', + use: 'sig', + n: jwk.n, + e: jwk.e, + }, + ], + }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts new file mode 100644 index 0000000000..42f606a58c --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts @@ -0,0 +1,2 @@ +export const KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX = 'jwks:' +export const KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY = `openid-configuration:` diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts new file mode 100644 index 0000000000..602419cdcf --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts @@ -0,0 +1,15 @@ +import { CacheModule } from '@nestjs/cache-manager' +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../../config/keycloak.config' +import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' + +@Module({ + imports: [ + CacheModule.register(), + ConfigModule.forFeature(keycloakConfigFactory), + ], + providers: [KeycloakSecretProviderService], + exports: [KeycloakSecretProviderService], +}) +export class KeycloakSecretProviderModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts new file mode 100644 index 0000000000..9d7e632122 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts @@ -0,0 +1,211 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { KeycloakConfig } from '../../../keycloak/keycloak.module-definition' +import { faker } from '@faker-js/faker' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { JwtSecretRequestType } from '@nestjs/jwt' +import { Test } from '@nestjs/testing' +import { createCache } from 'cache-manager' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KEYCLOAK_CONFIG } from '../../../keycloak/keycloak.module-definition' +import { makeJwksResponse } from './keycloak-secret-provider-testing.utils' +import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' +import { createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' + +describe('keycloakSecretProviderService', () => { + let module: TestingModule + let service: KeycloakSecretProviderService + let config: DeepMockProxy + let fetchMock: ReturnType + let cache: ReturnType + + beforeEach(async () => { + const keycloakDomain = faker.internet.domainName() + const keycloakRealm = faker.lorem.word() + const openidConfigurationUrl = `https://${keycloakDomain}/realms/${keycloakRealm}/.well-known/openid-configuration` + + config = mockDeep({ + protocol: 'https', + domain: keycloakDomain, + realm: keycloakRealm, + openidConfigurationUrl, + jwksTimeoutMs: 1_000, + jwksCacheTtlMs: 300_000, + openidConfigurationCacheTtlMs: 300_000, + }) + fetchMock = vi.fn() + cache = createCache() + + vi.clearAllMocks() + vi.unstubAllGlobals() + vi.stubGlobal('fetch', fetchMock) + + module = await Test.createTestingModule({ + providers: [ + KeycloakSecretProviderService, + { provide: KEYCLOAK_CONFIG, useValue: config }, + { provide: CACHE_MANAGER, useValue: cache }, + ], + }).compile() + + service = module.get(KeycloakSecretProviderService) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should fetch JWKS from Keycloak and parse the response', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + const internalJwksUri = `${config.protocol}://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-1')) + + const jwks = await service.fetchSigningKeys() + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0]?.[0]).toBe(config.openidConfigurationUrl) + expect(fetchMock.mock.calls[1]?.[0]).toBe(internalJwksUri) + expect(jwks).toEqual({ + keys: [ + { + kid: 'kid-1', + kty: 'RSA', + use: 'sig', + n: expect.any(String), + e: expect.any(String), + }, + ], + }) + }) + + it('should abort and return undefined when the JWKS request exceeds the timeout', async () => { + vi.useFakeTimers() + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockImplementationOnce((_url, init?: RequestInit) => new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + })) + + const promise = service.fetchSigningKeys() + await vi.advanceTimersByTimeAsync(1_000) + + await expect(promise).resolves.toBeUndefined() + }) + + it('should return undefined when Keycloak returns a non-OK response', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(new Response('', { status: 500, statusText: 'Internal Server Error' })) + + await expect(service.fetchSigningKeys()).resolves.toBeUndefined() + }) + + it('should resolve a PEM public key from the JWKS', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-2')) + + const publicKey = await service.fetchPublicKey('kid-2') + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(publicKey).toContain('BEGIN RSA PUBLIC KEY') + expect(await cache.get(createKeycloakSecretProviderPublicKeyCacheKey('kid-2'))).toBe(publicKey) + }) + + it('should reuse the cached PEM public key', async () => { + await cache.set( + createKeycloakSecretProviderPublicKeyCacheKey('cached-kid'), + '-----BEGIN RSA PUBLIC KEY-----\ncached\n-----END RSA PUBLIC KEY-----', + ) + + const publicKey = await service.fetchPublicKey('cached-kid') + + expect(fetchMock).not.toHaveBeenCalled() + expect(publicKey).toBe('-----BEGIN RSA PUBLIC KEY-----\ncached\n-----END RSA PUBLIC KEY-----') + }) + + it('should resolve the secret directly from the JWT token and request type', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-3')) + const header = Buffer.from(JSON.stringify({ kid: 'kid-3' })).toString('base64url') + + const secret = await service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(secret).toContain('BEGIN RSA PUBLIC KEY') + }) + + it('should reject signing requests', async () => { + await expect(service.getSecret(JwtSecretRequestType.SIGN, 'payload')).rejects.toThrow('Signing is not supported') + }) + + it('should reject non-string tokens', async () => { + await expect(service.getSecret(JwtSecretRequestType.VERIFY, Buffer.from('payload'))).rejects.toThrow( + 'Unsupported token type', + ) + }) + + it('should reject malformed JWTs', async () => { + await expect(service.getSecret(JwtSecretRequestType.VERIFY, 'only.two')).rejects.toThrow('Invalid JWT format') + }) + + it('should reject JWTs without a kid header', async () => { + const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url') + + await expect(service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`)).rejects.toThrow( + 'Missing kid', + ) + }) + + it('should reject JWTs when the key cannot be resolved', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ keys: [] }))) + const header = Buffer.from(JSON.stringify({ kid: 'missing-kid' })).toString('base64url') + + await expect(service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`)).rejects.toThrow( + 'Unknown signing key', + ) + }) + + it('should resolve the issuer from openid-configuration', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + + await expect(service.fetchIssuer()).resolves.toBe(issuer) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('should replace the discovered JWKS domain with the configured internal Keycloak domain', async () => { + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + issuer: `https://${config.domain}/realms/${config.realm}`, + jwks_uri: publicJwksUri, + }))) + + await expect(service.fetchJwksUri()).resolves.toBe( + `https://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs`, + ) + }) + + it('should keep the discovered JWKS URI unchanged when no internal Keycloak domain is configured', async () => { + config.domain = undefined + const publicJwksUri = `https://public.example.test/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + issuer: 'https://public.example.test/realms/test', + jwks_uri: publicJwksUri, + }))) + + await expect(service.fetchJwksUri()).resolves.toBe(publicJwksUri) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts new file mode 100644 index 0000000000..69a19bb5fa --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts @@ -0,0 +1,162 @@ +import type { Cache } from 'cache-manager' +import type { KeycloakConfig } from '../../../keycloak/keycloak.module-definition' +import { createPublicKey } from 'node:crypto' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { JwtSecretRequestType } from '@nestjs/jwt' +import { z } from 'zod' +import { KEYCLOAK_CONFIG } from '../../../keycloak/keycloak.module-definition' +import { createKeycloakSecretProviderOpenIdConfigurationCacheKey, createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' + +const OpenidConfigurationSchema = z.object({ + issuer: z.string().url(), + jwks_uri: z.string().url(), +}) + +type OpenidConfiguration = z.infer + +const JwksResponseSchema = z.object({ + keys: z.array(z.object({ + kid: z.string(), + kty: z.string(), + use: z.string(), + n: z.string(), + e: z.string(), + })), +}) + +type JwksResponse = z.infer + +const JwtHeaderSchema = z.object({ + kid: z.string(), +}) + +@Injectable() +export class KeycloakSecretProviderService { + private readonly logger = new Logger(KeycloakSecretProviderService.name) + + constructor( + @Inject(CACHE_MANAGER) private readonly cache: Cache, + @Inject(KEYCLOAK_CONFIG) private readonly keycloakConfig: KeycloakConfig, + ) {} + + async fetchOpenIdConfig(): Promise { + const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(this.keycloakConfig.openidConfigurationUrl) + const cached = await this.cache.get(cacheKey) + if (cached) return cached + + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) + if (!response.ok) { + this.logger.error(`Failed to fetch openid-configuration: ${response.status} ${response.statusText}`) + return undefined + } + + const raw = await response.json() + const config = OpenidConfigurationSchema.safeParse(raw) + if (!config.success) { + this.logger.error('openid-configuration response missing jwks_uri') + return undefined + } + + await this.cache.set(cacheKey, config.data, this.keycloakConfig.openidConfigurationCacheTtlMs) + return config.data + } + + async fetchIssuer(): Promise { + const config = await this.fetchOpenIdConfig() + return config?.issuer + } + + async fetchJwksUri(): Promise { + const config = await this.fetchOpenIdConfig() + return config ? this.replaceJwksUriDomainWithInternalDomain(config.jwks_uri) : undefined + } + + private replaceJwksUriDomainWithInternalDomain(jwksUri: string): string { + if (!this.keycloakConfig.domain) return jwksUri + const url = new URL(jwksUri) + url.protocol = this.keycloakConfig.protocol + url.host = this.keycloakConfig.domain + this.logger.log(`Replacing JWKS URI domain: ${jwksUri} -> ${url.toString()}`) + return url.toString() + } + + async fetchSigningKeys(): Promise { + const jwksUri = await this.fetchJwksUri() + if (!jwksUri) return undefined + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), this.keycloakConfig.jwksTimeoutMs) + + try { + const response = await fetch(jwksUri, { signal: controller.signal }) + if (!response.ok) { + this.logger.error(`Failed to fetch JWKS: ${response.status} ${response.statusText}`) + return undefined + } + + const raw = await response.json() + return JwksResponseSchema.parse(raw) + } catch (error) { + this.logger.error(`Failed to fetch JWKS: ${error instanceof Error ? error.message : String(error)}`) + return undefined + } finally { + clearTimeout(timeout) + } + } + + async fetchPublicKey(kid: string): Promise { + const cacheKey = createKeycloakSecretProviderPublicKeyCacheKey(kid) + const cached = await this.cache.get(cacheKey) + if (cached) return cached + + const jwks = await this.fetchSigningKeys() + if (!jwks) return undefined + + const key = jwks.keys.find(candidate => candidate.kid === kid && candidate.use === 'sig' && candidate.kty === 'RSA') + if (!key) return undefined + + const publicKey = createPublicKey({ + key: { kty: 'RSA', n: key.n, e: key.e }, + format: 'jwk', + }) + + const pem = publicKey.export({ format: 'pem', type: 'pkcs1' }) as string + await this.cache.set(cacheKey, pem, this.keycloakConfig.jwksCacheTtlMs) + return pem + } + + async getSecret( + requestType: JwtSecretRequestType, + tokenOrPayload: string | object | Buffer, + ): Promise { + if (requestType === JwtSecretRequestType.SIGN) { + throw new TypeError('Signing is not supported') + } + + if (typeof tokenOrPayload !== 'string') { + throw new TypeError('Unsupported token type') + } + + const parts = tokenOrPayload.split('.') + if (parts.length !== 3) { + throw new TypeError('Invalid JWT format') + } + + const rawHeader = JSON.parse( + Buffer.from(parts[0].replaceAll('-', '+').replaceAll('_', '/'), 'base64').toString(), + ) + const header = JwtHeaderSchema.safeParse(rawHeader) + if (!header.success) { + throw new TypeError('Missing kid') + } + const { kid } = header.data + + const publicKey = await this.fetchPublicKey(kid) + if (!publicKey) { + throw new Error('Unknown signing key') + } + + return publicKey + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts new file mode 100644 index 0000000000..b13beeed98 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts @@ -0,0 +1,9 @@ +import { KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY, KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX } from './keycloak-secret-provider.constants' + +export function createKeycloakSecretProviderPublicKeyCacheKey(kid: string): string { + return `${KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX}${kid}` +} + +export function createKeycloakSecretProviderOpenIdConfigurationCacheKey(url: string): string { + return `${KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY}${url}` +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts new file mode 100644 index 0000000000..d1853a3219 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts @@ -0,0 +1,27 @@ +export interface BaseConfig { + nodeEnv: 'development' | 'production' | 'test' + isTest: boolean + isDev: boolean + isCI: boolean + isProd: boolean + integration: boolean + ci: boolean + devSetup: boolean + docker: boolean + serverHost: string + serverPort: number + appVersion: string + dbUrl?: string + sessionSecret?: string + contactEmail: string + mockPlugins: boolean + projectsRootDir?: string + pluginsDir: string + httpProxy?: string + httpsProxy?: string +} + +// Runtime config (BASE_CONFIG token) is owned by the global config/base.config.ts +// (which calls registerAs('base', ...)); re-exported here so services keep a single +// stable import surface (the module) while config depends on the module. +export { BASE_CONFIG } from './base.module' diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts new file mode 100644 index 0000000000..5ee8150bd8 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts @@ -0,0 +1,13 @@ +import type { BaseConfig } from './base.config' +import { Global, Module } from '@nestjs/common' +import { createConfigurableModule } from '../../configurable-feature-module' + +export interface BaseModuleOptions extends BaseConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('base', true) + +@Global() +@Module({}) +export class BaseModule extends ConfigurableModuleClass {} + +export const BASE_CONFIG = MODULE_OPTIONS_TOKEN diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts new file mode 100644 index 0000000000..7c42430723 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts @@ -0,0 +1,52 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import { HealthIndicatorService } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { DatabaseHealthService } from './database-health.service' +import { PrismaService } from './prisma.service' + +describe('databaseHealthService', () => { + let service: DatabaseHealthService + let prisma: DeepMockProxy + let indicatorSession: { up: ReturnType, down: ReturnType } + + beforeEach(async () => { + prisma = mockDeep() + const healthIndicatorMock = mockDeep() + indicatorSession = { + up: vi.fn().mockReturnValue({ status: 'up' }), + down: vi.fn().mockReturnValue({ status: 'down' }), + } + vi.mocked(healthIndicatorMock.check).mockReturnValue(indicatorSession as any) + + const module = await Test.createTestingModule({ + providers: [ + DatabaseHealthService, + { provide: PrismaService, useValue: prisma }, + { provide: HealthIndicatorService, useValue: healthIndicatorMock }, + ], + }).compile() + + service = module.get(DatabaseHealthService) + }) + + it('reports up when the query succeeds', async () => { + prisma.$queryRaw.mockResolvedValueOnce(undefined as any) + + const result = await service.check('database') + + expect(prisma.$queryRaw).toHaveBeenCalled() + expect(indicatorSession.up).toHaveBeenCalled() + expect(result).toEqual({ status: 'up' }) + }) + + it('reports down when the query throws', async () => { + prisma.$queryRaw.mockRejectedValueOnce(new Error('boom')) + + const result = await service.check('database') + + expect(indicatorSession.down).toHaveBeenCalledWith({ message: 'boom' }) + expect(result).toEqual({ status: 'down' }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts new file mode 100644 index 0000000000..1e37899c9b --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts @@ -0,0 +1,23 @@ +import { Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { PrismaService } from './prisma.service' + +@Injectable() +export class DatabaseHealthService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + await this.prisma.$queryRaw`SELECT 1` + return indicator.up() + } catch (error) { + return indicator.down({ + message: error instanceof Error ? error.message : String(error), + }) + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts new file mode 100644 index 0000000000..7e909376ec --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseHealthService } from './database-health.service' +import { DatabaseService } from './database.service' +import { PrismaService } from './prisma.service' + +@Module({ + imports: [TerminusModule], + providers: [DatabaseHealthService, DatabaseService, PrismaService], + exports: [DatabaseHealthService, DatabaseService, PrismaService], +}) +export class DatabaseModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts new file mode 100644 index 0000000000..3f373ba1eb --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts @@ -0,0 +1,30 @@ +import type { ConfigType } from '@nestjs/config' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../config/base.config' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../../infrastructure/config/base.config' +import { DatabaseService } from './database.service' +import { PrismaService } from './prisma.service' + +describe('databaseService', () => { + let service: DatabaseService + + beforeEach(async () => { + const baseConfig = mockDeep() + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabaseService, + { provide: PrismaService, useValue: mockDeep() }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(DatabaseService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts new file mode 100644 index 0000000000..5d9d0c32f7 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts @@ -0,0 +1,73 @@ +import type { BaseConfig } from '../../infrastructure/config/base.config' +import { setTimeout } from 'node:timers/promises' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { BASE_CONFIG } from '../../infrastructure/config/base.config' +import { PrismaService } from './prisma.service' + +@Injectable() +export class DatabaseService { + private readonly loggerService = new Logger(DatabaseService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(BASE_CONFIG) private readonly configurationService: BaseConfig, + ) { + this.DELAY_BEFORE_RETRY + = this.configurationService.isTest || this.configurationService.isCI + ? 1000 + : 10000 + } + + DELAY_BEFORE_RETRY!: number + closingConnections = false + + async getConnection(triesLeft = 5): Promise { + if (this.closingConnections || triesLeft <= 0) { + throw new Error('Unable to connect to Postgres server') + } + triesLeft-- + + try { + if ( + this.configurationService.isDev + || this.configurationService.isTest + || this.configurationService.isCI + ) { + this.loggerService.log( + `Trying to connect to Postgres with: ${this.configurationService.dbUrl}`, + ) + } + await this.prisma.$connect() + + this.loggerService.log('Connected to Postgres!') + } catch (error) { + if (triesLeft > 0) { + this.loggerService.error(error) + this.loggerService.log( + `Could not connect to Postgres: ${error.message}`, + ) + this.loggerService.log(`Retrying (${triesLeft} tries left)`) + await setTimeout(this.DELAY_BEFORE_RETRY) + return this.getConnection(triesLeft) + } + + this.loggerService.log( + `Could not connect to Postgres: ${error.message}`, + ) + this.loggerService.log('Out of retries') + error.message = `Out of retries, last error: ${error.message}` + throw error + } + } + + async closeConnections() { + this.closingConnections = true + try { + await this.prisma.$disconnect() + } catch (error) { + this.loggerService.error(error) + } finally { + this.closingConnections = false + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts new file mode 100644 index 0000000000..f10a31e573 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts @@ -0,0 +1,14 @@ +import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common' +import { Injectable } from '@nestjs/common' +import { PrismaClient } from '@prisma/client' + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect() + } + + async onModuleDestroy() { + await this.$disconnect() + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts new file mode 100644 index 0000000000..8eb62bea67 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitterModule } from '@nestjs/event-emitter' +import { baseConfigFactory } from '../../../config/base.config' + +@Module({ + imports: [EventEmitterModule.forRoot(), ConfigModule.forFeature(baseConfigFactory)], + exports: [EventEmitterModule], +}) +export class EventsModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts new file mode 100644 index 0000000000..25b2a4e695 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from './auth/auth.module' +import { DatabaseModule } from './database/database.module' +import { EventsModule } from './events/events.module' +import { LoggerModule } from './logger/logger.module' +import { PermissionModule } from './permission/permission.module' + +@Module({ + providers: [], + imports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + exports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], +}) +export class InfrastructureModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts new file mode 100644 index 0000000000..257ef429f8 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts @@ -0,0 +1,37 @@ +import type { BaseConfig } from '../config/base.config' +import { getLoggerOptions } from '@cpn-console/logger' +import { Module } from '@nestjs/common' +import { LoggerModule as PinoLoggerModule } from 'nestjs-pino' +import { BASE_CONFIG } from '../config/base.config' + +@Module({ + imports: [ + PinoLoggerModule.forRootAsync({ + imports: [], + inject: [BASE_CONFIG], + useFactory: async (baseConfig: BaseConfig) => { + return { + pinoHttp: { + ...getLoggerOptions(baseConfig.isProd ? 'production' : 'development', baseConfig.isTest ? 'info' : 'debug'), + customLogLevel: (req, res, err) => { + if (err || res.statusCode >= 500) { + return 'error' + } + if (res.statusCode >= 400) { + return 'warn' + } + // kube liveness/readiness probes hit healthz constantly, only log it on failure + if (req.url?.split('?')[0] === '/api/v1/healthz') { + return 'silent' + } + return 'info' + }, + }, + } + }, + }), + ], + controllers: [], + providers: [], +}) +export class LoggerModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts new file mode 100644 index 0000000000..c2f926a351 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { ProjectPermissionModule } from './project/project.module' +import { UserPermissionModule } from './user/user.module' + +@Module({ + imports: [UserPermissionModule, ProjectPermissionModule], + exports: [UserPermissionModule, ProjectPermissionModule], +}) +export class PermissionModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts new file mode 100644 index 0000000000..7863f5ad4b --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_ACCESS_KEY = 'project-access' + +export function RequireProjectAccess() { + return SetMetadata(PROJECT_ACCESS_KEY, true) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts new file mode 100644 index 0000000000..ee11a3df91 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts @@ -0,0 +1,52 @@ +import type { FastifyRequest } from 'fastify' +import type { ProjectRequirements } from './project-loader.utils' +import type { ProjectContext } from './project.guard' +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../../database/prisma.service' +import { isUuid, makeProjectSelect, resolveProjectPermissions } from './project-loader.utils' + +type RequestWithProjectParams = FastifyRequest<{ Params?: ProjectParams }> + +interface ProjectParams { + projectId?: string + projectSlug?: string +} + +@Injectable() +export class ProjectPermissionLoaderService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async load(request: RequestWithProjectParams, userId: string, requirements?: ProjectRequirements): Promise { + let where: { id: string } | { slug: string } + if (request.params?.projectId) { + where = isUuid(request.params.projectId) ? { id: request.params.projectId } : { slug: request.params.projectId } + } else if (request.params?.projectSlug) { + where = { slug: request.params.projectSlug } + } else { + throw new BadRequestException('projectId or projectSlug is required') + } + + const raw = await this.prisma.project.findUnique({ + where, + select: makeProjectSelect(requirements), + }) + if (!raw) throw new NotFoundException('Projet introuvable') + + const project: ProjectContext = { + id: raw.id, + slug: raw.slug, + } + + if (requirements?.includeStatus) { + project.status = raw.status + } + if (requirements?.includeLocked) { + project.locked = raw.locked + } + if (requirements?.includePermissions) { + project.projectPermissions = resolveProjectPermissions(raw, userId) + } + + return project + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts new file mode 100644 index 0000000000..16e48b3fac --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts @@ -0,0 +1,53 @@ +import type { Prisma } from '@prisma/client' +import { PROJECT_PERMS } from '@cpn-console/shared' + +export interface ProjectRequirements { + includeStatus?: boolean + includeLocked?: boolean + includePermissions?: boolean +} + +export function makeProjectSelect(requirements?: ProjectRequirements): Prisma.ProjectSelect { + const includePermissions = requirements?.includePermissions ?? true + const includeStatus = requirements?.includeStatus ?? true + const includeLocked = requirements?.includeLocked ?? true + + return { + id: true, + slug: true, + ...(includeStatus ? { status: true } : {}), + ...(includeLocked ? { locked: true } : {}), + ...(includePermissions + ? { + ownerId: true, + everyonePerms: true, + roles: { select: { id: true, permissions: true } }, + members: { select: { userId: true, roleIds: true } }, + } + : {}), + } satisfies Prisma.ProjectSelect +} + +export function resolveProjectPermissions( + raw: { ownerId: string, everyonePerms: bigint, roles: Array<{ id: string, permissions: bigint }>, members: Array<{ userId: string, roleIds: string[] }> }, + userId: string, +): bigint { + if (raw.ownerId === userId) { + return PROJECT_PERMS.MANAGE + } + + const member = raw.members.find(m => m.userId === userId) + if (!member) { + return 0n + } + + const memberRoles = raw.roles.filter(role => member.roleIds.includes(role.id)) + return memberRoles.reduce( + (acc, curr) => acc | curr.permissions, + raw.everyonePerms | PROJECT_PERMS.GUEST, + ) +} + +export function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts new file mode 100644 index 0000000000..ac63012cc3 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_LOCKED_KEY = 'project-locked' + +export function RequireProjectLocked(locked: boolean) { + return SetMetadata(PROJECT_LOCKED_KEY, locked) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts new file mode 100644 index 0000000000..da064283ff --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts @@ -0,0 +1,8 @@ +import type { ProjectAuthorized } from '@cpn-console/shared' +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_PERMISSION_KEY = 'project-permission' + +export function RequireProjectPermission(...permissions: (keyof typeof ProjectAuthorized)[]) { + return SetMetadata(PROJECT_PERMISSION_KEY, permissions) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts new file mode 100644 index 0000000000..f1b2f05f48 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts @@ -0,0 +1,8 @@ +import type { Project } from '@prisma/client' +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_STATUS_KEY = 'project-status' + +export function RequireProjectStatus(...statuses: Project['status'][]) { + return SetMetadata(PROJECT_STATUS_KEY, statuses) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts new file mode 100644 index 0000000000..921cffd845 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts @@ -0,0 +1,14 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { FastifyRequest } from 'fastify' +import type { ProjectContext } from './project.guard' +import { createParamDecorator } from '@nestjs/common' + +export const Project = createParamDecorator( + (_: unknown, ctx: ExecutionContext): ProjectContext => { + const request = ctx.switchToHttp().getRequest() + if (!request.project) { + throw new Error('Project context is missing from the request') + } + return request.project + }, +) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts new file mode 100644 index 0000000000..8d4a5ba4c6 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts @@ -0,0 +1,63 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ProjectContext } from './project.guard' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AuthService } from '../../auth/auth.service' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectGuard } from './project.guard' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' +import { makeExecutionContext, makeProjectContext, makeProjectPolicy } from './project.testing.utils' + +describe('projectGuard', () => { + let module: TestingModule + let guard: ProjectGuard + let authService: DeepMockProxy + let projectService: DeepMockProxy + let projectPolicy: DeepMockProxy + let loader: DeepMockProxy + + beforeEach(async () => { + authService = mockDeep() + projectService = mockDeep() + projectPolicy = mockDeep() + loader = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectGuard, + { provide: AuthService, useValue: authService }, + { provide: ProjectPermissionService, useValue: projectService }, + { provide: ProjectPermissionPolicy, useValue: projectPolicy }, + { provide: ProjectPermissionLoaderService, useValue: loader }, + ], + }).compile() + + guard = module.get(ProjectGuard) + }) + + it('throws 401 when userId is not set on the request', async () => { + projectPolicy.build.mockReturnValue(makeProjectPolicy()) + authService.authenticate.mockRejectedValue(new UnauthorizedException()) + const ctx = makeExecutionContext({}) + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException) + }) + + it('loads project and validates', async () => { + const project: ProjectContext = makeProjectContext({}) + const policy = makeProjectPolicy({ projectPermissions: ['Manage'] }) + projectPolicy.build.mockReturnValue(policy) + authService.authenticate.mockResolvedValue({ userId: 'member1', adminPermissions: 0n }) + loader.load.mockResolvedValue(project) + const request = { userId: 'member1', adminPermissions: 0n, params: { projectId: 'p1' } } + const ctx = makeExecutionContext(request) + + const result = await guard.canActivate(ctx) + + expect(result).toBe(true) + expect(loader.load).toHaveBeenCalledWith(request, 'member1', expect.anything()) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts new file mode 100644 index 0000000000..b5ef931868 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts @@ -0,0 +1,94 @@ +import type { CanActivate, ExecutionContext } from '@nestjs/common' +import type { Project } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../../auth/auth-user.decorator' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { AuthService } from '../../auth/auth.service' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' + +export interface ProjectContext { + id: string + slug: string + locked?: boolean + status?: Project['status'] + projectPermissions?: bigint +} + +export interface RequestWithProjectContext extends FastifyRequest { + project?: ProjectContext +} + +type RequestWithUserContext = FastifyRequest & { + userId?: string + adminPermissions?: bigint + userType?: string +} + +interface ProjectParams { + projectId?: string + projectSlug?: string +} + +@Injectable() +export class ProjectGuard implements CanActivate { + private readonly logger = new Logger(ProjectGuard.name) + + constructor( + @Inject(AuthService) private readonly authService: AuthService, + @Inject(ProjectPermissionService) private readonly projectService: ProjectPermissionService, + @Inject(ProjectPermissionLoaderService) private readonly loader: ProjectPermissionLoaderService, + @Inject(ProjectPermissionPolicy) private readonly projectPolicy: ProjectPermissionPolicy, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const policy = this.projectPolicy.build(context) + const request = this.getRequest(context) + const user = await this.authenticate(request) + const project = await this.loadProject(request, user.userId) + + this.projectService.validate(policy, project, user) + + return true + } + + private getRequest(context: ExecutionContext): RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }> { + return context.switchToHttp().getRequest< + RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }> + >() + } + + private async authenticate( + request: RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }>, + ): Promise { + try { + const user = await this.authService.authenticate(request, { + includeAdminRoleIds: true, + includeUserType: true, + }) + + request.userId = user.userId + if (user.adminPermissions !== undefined) request.adminPermissions = user.adminPermissions + if (user.userType !== undefined) request.userType = user.userType + + return user + } catch (error) { + this.logger.warn(`Project access auth rejected (requestId=${request.id}, error=${error instanceof Error ? error.message : String(error)})`) + throw error + } + } + + private async loadProject( + request: RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }>, + userId: string, + ): Promise { + const project = await this.loader.load(request, userId, { + includeStatus: true, + includeLocked: true, + includePermissions: true, + }) + request.project = project + return project + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts new file mode 100644 index 0000000000..6561dd78dd --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../../auth/auth.module' +import { DatabaseModule } from '../../database/database.module' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectGuard } from './project.guard' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + ], + providers: [ + ProjectGuard, + ProjectPermissionLoaderService, + ProjectPermissionService, + ProjectPermissionPolicy, + ], + exports: [ + ProjectGuard, + ProjectPermissionLoaderService, + ProjectPermissionService, + ProjectPermissionPolicy, + ], +}) +export class ProjectPermissionModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts new file mode 100644 index 0000000000..9d4098d93e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts @@ -0,0 +1,37 @@ +import type { AdminAuthorized, ProjectAuthorized } from '@cpn-console/shared' +import type { ExecutionContext } from '@nestjs/common' +import type { Project, User } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { ADMIN_PERMISSIONS_KEY } from '../user/user-admin-permission.decorator' +import { USER_TYPES_KEY } from '../user/user-type.decorator' +import { PROJECT_ACCESS_KEY } from './project-access.decorator' +import { PROJECT_LOCKED_KEY } from './project-locked.decorator' +import { PROJECT_PERMISSION_KEY } from './project-permission.decorator' +import { PROJECT_STATUS_KEY } from './project-status.decorator' + +export interface ProjectPolicyConfig { + adminPermissions: (keyof typeof AdminAuthorized)[] + userTypes: User['type'][] + projectPermissions: (keyof typeof ProjectAuthorized)[] + projectStatuses: Project['status'][] + projectLocked?: boolean + projectAccess: boolean +} + +@Injectable() +export class ProjectPermissionPolicy { + constructor(@Inject(Reflector) private readonly reflector: Reflector) {} + + build(context: ExecutionContext): ProjectPolicyConfig { + const targets = [context.getHandler(), context.getClass()] + return { + adminPermissions: this.reflector.getAllAndOverride<(keyof typeof AdminAuthorized)[] | undefined>(ADMIN_PERMISSIONS_KEY, targets) ?? [], + userTypes: this.reflector.getAllAndOverride(USER_TYPES_KEY, targets) ?? [], + projectPermissions: this.reflector.getAllAndOverride<(keyof typeof ProjectAuthorized)[] | undefined>(PROJECT_PERMISSION_KEY, targets) ?? [], + projectStatuses: this.reflector.getAllAndOverride(PROJECT_STATUS_KEY, targets) ?? [], + projectLocked: this.reflector.getAllAndOverride(PROJECT_LOCKED_KEY, targets), + projectAccess: this.reflector.getAllAndOverride(PROJECT_ACCESS_KEY, targets) ?? false, + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts new file mode 100644 index 0000000000..f25771dd42 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts @@ -0,0 +1,72 @@ +import type { ProjectStatus, User } from '@prisma/client' +import type { UserContext } from '../../auth/auth-user.decorator' +import type { ProjectContext } from './project.guard' +import type { ProjectPolicyConfig } from './project.policy' +import { AdminAuthorized, ProjectAuthorized } from '@cpn-console/shared' +import { ForbiddenException, Injectable, Logger } from '@nestjs/common' + +@Injectable() +export class ProjectPermissionService { + private readonly logger = new Logger(ProjectPermissionService.name) + + validate(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + this.validateUserType(policy, user.userType) + this.validateProjectStatus(policy, project.status) + this.validateProjectLock(policy, project.locked) + this.validateProjectAccess(policy, project, user) + this.validateProjectPermissions(policy, project, user) + } + + validateProjectStatus(policy: ProjectPolicyConfig, projectStatus: ProjectStatus | undefined): void { + if (policy.projectStatuses.length > 0 && (!projectStatus || !policy.projectStatuses.includes(projectStatus))) { + this.logger.warn(`project auth denied: invalid project status (projectStatus=${projectStatus}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Statut de projet invalide') + } + } + + validateProjectLock(policy: ProjectPolicyConfig, projectLocked: boolean | undefined): void { + if (policy.projectLocked !== undefined && projectLocked !== policy.projectLocked) { + this.logger.warn(`project auth denied: invalid lock status (projectLocked=${projectLocked}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('État de verrouillage invalide') + } + } + + validateUserType(policy: ProjectPolicyConfig, userType: string | undefined): void { + if (!policy.userTypes.length) return + if (typeof userType !== 'string' || !policy.userTypes.includes(userType as User['type'])) { + this.logger.warn(`project auth denied: invalid user type (userType=${userType}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Cannot find requestor in request context') + } + } + + validateProjectAccess(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + if (!policy.projectAccess) return + + const adminPermissions = user.adminPermissions ?? 0n + const hasAccess = AdminAuthorized.Manage(adminPermissions) || Boolean(project.projectPermissions) + if (!hasAccess) { + this.logger.warn( + `project auth denied: missing project access (projectId=${project.id}, projectSlug=${project.slug}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, projectPermissions=${project.projectPermissions?.toString()}, policy=${JSON.stringify(policy)})`, + ) + throw new ForbiddenException('Permissions insuffisantes pour ce projet') + } + } + + validateProjectPermissions(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + if (!policy.projectPermissions.length) return + + const hasPermission = policy.projectPermissions.every( + permName => ProjectAuthorized[permName]({ + adminPermissions: user.adminPermissions, + projectPermissions: project.projectPermissions, + }), + ) + + if (!hasPermission) { + this.logger.warn( + `project auth denied: missing project permissions (projectId=${project.id}, projectSlug=${project.slug}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, projectPermissions=${project.projectPermissions?.toString()}, policy=${JSON.stringify(policy)})`, + ) + throw new ForbiddenException('Permissions insuffisantes pour ce projet') + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts new file mode 100644 index 0000000000..b2480f78cf --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts @@ -0,0 +1,37 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { HttpArgumentsHost } from '@nestjs/common/interfaces' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ProjectContext } from './project.guard' +import type { ProjectPolicyConfig } from './project.policy' +import { mockDeep } from 'vitest-mock-extended' + +export function makeExecutionContext(request: Record): DeepMockProxy { + const ctx = mockDeep() + const httpArgs = mockDeep() + httpArgs.getRequest.mockReturnValue(request) + ctx.switchToHttp.mockReturnValue(httpArgs) + return ctx +} + +export function makeProjectContext(overrides: Partial = {}): ProjectContext { + return { + id: 'p1', + slug: 'project', + locked: false, + status: 'created', + projectPermissions: 6n, + ...overrides, + } +} + +export function makeProjectPolicy(overrides: Partial = {}): ProjectPolicyConfig { + return { + adminPermissions: [], + userTypes: [], + projectPermissions: [], + projectStatuses: [], + projectLocked: undefined, + projectAccess: false, + ...overrides, + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts new file mode 100644 index 0000000000..ab70852172 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts @@ -0,0 +1,8 @@ +import type { AdminAuthorized } from '@cpn-console/shared' +import { SetMetadata } from '@nestjs/common' + +export const ADMIN_PERMISSIONS_KEY = 'admin-permissions' + +export function RequireAdminPermission(...permissions: (keyof typeof AdminAuthorized)[]) { + return SetMetadata(ADMIN_PERMISSIONS_KEY, permissions) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts new file mode 100644 index 0000000000..c2e004cc46 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts @@ -0,0 +1,25 @@ +import type { AdminAuthorized } from '@cpn-console/shared' +import type { ExecutionContext } from '@nestjs/common' +import type { User as PrismaUser } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { ADMIN_PERMISSIONS_KEY } from './user-admin-permission.decorator' +import { USER_TYPES_KEY } from './user-type.decorator' + +export interface UserPolicyConfig { + adminPermissions: (keyof typeof AdminAuthorized)[] + userTypes: PrismaUser['type'][] +} + +@Injectable() +export class UserPermissionPolicy { + constructor(@Inject(Reflector) private readonly reflector: Reflector) {} + + build(context: ExecutionContext): UserPolicyConfig { + const targets = [context.getHandler(), context.getClass()] + return { + adminPermissions: this.reflector.getAllAndOverride<(keyof typeof AdminAuthorized)[] | undefined>(ADMIN_PERMISSIONS_KEY, targets) ?? [], + userTypes: this.reflector.getAllAndOverride(USER_TYPES_KEY, targets) ?? [], + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts new file mode 100644 index 0000000000..dc6b09af03 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts @@ -0,0 +1,17 @@ +import type { UserContext } from '../../auth/auth-user.decorator' +import type { UserPolicyConfig } from './user-policy.service' + +export function makeUserPolicy(overrides: Partial = {}): UserPolicyConfig { + return { + adminPermissions: [], + userTypes: [], + ...overrides, + } +} + +export function makeUserContext(overrides: Partial = {}): UserContext { + return { + userId: 'test-user', + ...overrides, + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts new file mode 100644 index 0000000000..ee14dbbb38 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts @@ -0,0 +1,8 @@ +import type { User } from '@prisma/client' +import { SetMetadata } from '@nestjs/common' + +export const USER_TYPES_KEY = 'user-types' + +export function RequireUserType(...types: User['type'][]) { + return SetMetadata(USER_TYPES_KEY, types) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts new file mode 100644 index 0000000000..447b77477e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts @@ -0,0 +1,105 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserPolicyConfig } from './user-policy.service' +import { ForbiddenException, UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeExecutionContext } from '../../auth/auth-testing.utils' +import { AuthService } from '../../auth/auth.service' +import { UserPermissionPolicy } from './user-policy.service' +import { UserGuard } from './user.guard' +import { UserPermissionService } from './user.service' + +describe('userGuard', () => { + let module: TestingModule + let guard: UserGuard + let authService: DeepMockProxy + let userService: DeepMockProxy + let userPolicy: DeepMockProxy + + beforeEach(async () => { + authService = mockDeep() + userService = mockDeep() + userPolicy = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + UserGuard, + { provide: AuthService, useValue: authService }, + { provide: UserPermissionService, useValue: userService }, + { provide: UserPermissionPolicy, useValue: userPolicy }, + ], + }).compile() + + guard = module.get(UserGuard) + }) + + it('throws 401 when the auth service rejects the request', async () => { + authService.authenticate.mockRejectedValue(new UnauthorizedException()) + userPolicy.build.mockReturnValue({ adminPermissions: [], userTypes: [] }) + const ctx = makeExecutionContext({}) + + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException) + }) + + it('authenticates without loading optional auth data when no policy metadata is set', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1' }) + userPolicy.build.mockReturnValue({ adminPermissions: [], userTypes: [] }) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + const result = await guard.canActivate(ctx) + const request = ctx.switchToHttp().getRequest() + + expect(result).toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(request.userId).toBe('u1') + }) + + it('validates required admin permissions', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 32768n }) + const policy: UserPolicyConfig = { adminPermissions: ['ListSystem'], userTypes: [] } + userPolicy.build.mockReturnValue(policy) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + await expect(guard.canActivate(ctx)).resolves.toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(userService.validate).toHaveBeenCalled() + }) + + it('throws 403 when required admin permissions are missing', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 0n }) + const policy: UserPolicyConfig = { adminPermissions: ['ManageSystem'], userTypes: [] } + userPolicy.build.mockReturnValue(policy) + userService.validate.mockImplementation(() => { throw new ForbiddenException() }) + const ctx = makeExecutionContext({ 'x-dso-token': 'tok' }) + + await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException) + }) + + it('validates required user types', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', userType: 'human' }) + const policy: UserPolicyConfig = { adminPermissions: [], userTypes: ['human'] } + userPolicy.build.mockReturnValue(policy) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + await expect(guard.canActivate(ctx)).resolves.toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(userService.validate).toHaveBeenCalled() + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts new file mode 100644 index 0000000000..28c339accd --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts @@ -0,0 +1,60 @@ +import type { CanActivate, ExecutionContext } from '@nestjs/common' +import type { FastifyRequest } from 'fastify' +import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common' +import { AuthService } from '../../auth/auth.service' +import { UserPermissionPolicy } from './user-policy.service' +import { UserPermissionService } from './user.service' + +type RequestWithAuthContext = FastifyRequest & { + userId?: string + adminPermissions?: bigint + userType?: string +} + +@Injectable() +export class UserGuard implements CanActivate { + private readonly logger = new Logger(UserGuard.name) + + constructor( + @Inject(AuthService) private readonly authService: AuthService, + @Inject(UserPermissionService) private readonly userService: UserPermissionService, + @Inject(UserPermissionPolicy) private readonly userPolicy: UserPermissionPolicy, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const policy = this.userPolicy.build(context) + const request = this.getRequest(context) + const user = await this.authenticate(request) + + this.validate(policy, user) + this.logger.debug(`User auth granted (requestId=${request.id}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, userType=${user.userType})`) + + return true + } + + private getRequest(context: ExecutionContext): RequestWithAuthContext { + return context.switchToHttp().getRequest() + } + + private async authenticate(request: RequestWithAuthContext) { + try { + const user = await this.authService.authenticate(request, { + includeAdminRoleIds: true, + includeUserType: true, + }) + + request.userId = user.userId + if (user.adminPermissions !== undefined) request.adminPermissions = user.adminPermissions + if (user.userType !== undefined) request.userType = user.userType + + return user + } catch (error) { + this.logger.warn(`User auth ${error instanceof UnauthorizedException ? 'rejected' : 'denied'} (requestId=${request.id}, error=${error instanceof Error ? error.message : String(error)})`) + throw error + } + } + + private validate(policy: ReturnType, user: Awaited>) { + this.userService.validate(policy, user) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts new file mode 100644 index 0000000000..613821ed2a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../../auth/auth.module' +import { DatabaseModule } from '../../database/database.module' +import { UserPermissionPolicy } from './user-policy.service' +import { UserGuard } from './user.guard' +import { UserPermissionService } from './user.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + ], + providers: [ + UserGuard, + UserPermissionService, + UserPermissionPolicy, + ], + exports: [ + UserGuard, + UserPermissionService, + UserPermissionPolicy, + ], +}) +export class UserPermissionModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts new file mode 100644 index 0000000000..4ad5b84704 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts @@ -0,0 +1,73 @@ +import type { UserContext } from '../../auth/auth-user.decorator' +import { AdminAuthorized } from '@cpn-console/shared' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeUserContext, makeUserPolicy } from './user-testing.utils' +import { UserPermissionService } from './user.service' + +describe('userService', () => { + let service: UserPermissionService + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [UserPermissionService], + }).compile() + + service = module.get(UserPermissionService) + }) + + describe('validateAdminPermissions', () => { + it('does not throw when no admin permissions are required', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({}), 0n)).not.toThrow() + }) + + it('does not throw when the user has the required admin permission', () => { + const adminPerms = AdminAuthorized.ListSystem(32768n) ? 32768n : 0n + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ListSystem'] }), adminPerms)).not.toThrow() + }) + + it('throws ForbiddenException when the user lacks the required admin permission', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ManageSystem'] }), 0n)).toThrow(ForbiddenException) + }) + + it('validates multiple required admin permissions', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ManageSystem', 'ListSystem'] }), 0n)).toThrow(ForbiddenException) + }) + + it('treats undefined adminPermissions as 0n', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['Manage'] }), undefined)).toThrow(ForbiddenException) + }) + }) + + describe('validateUserType', () => { + it('does not throw when no user types are required', () => { + expect(() => service.validateUserType(makeUserPolicy({}), 'human')).not.toThrow() + }) + + it('does not throw when the user type is in the allowed list', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), 'human')).not.toThrow() + }) + + it('throws ForbiddenException when the user type is not in the allowed list', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), 'service')).toThrow(ForbiddenException) + }) + + it('throws ForbiddenException when userType is undefined', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), undefined)).toThrow(ForbiddenException) + }) + }) + + describe('validate', () => { + it('delegates to validateAdminPermissions and validateUserType', () => { + const spyPerms = vi.spyOn(service, 'validateAdminPermissions') + const spyType = vi.spyOn(service, 'validateUserType') + const user: UserContext = makeUserContext({ adminPermissions: 1n, userType: 'human' }) + + service.validate(makeUserPolicy({}), user) + + expect(spyPerms).toHaveBeenCalled() + expect(spyType).toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts new file mode 100644 index 0000000000..3784e2feb6 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts @@ -0,0 +1,36 @@ +import type { User as PrismaUser } from '@prisma/client' +import type { UserContext } from '../../auth/auth-user.decorator' +import type { UserPolicyConfig } from './user-policy.service' +import { AdminAuthorized } from '@cpn-console/shared' +import { ForbiddenException, Injectable, Logger } from '@nestjs/common' + +@Injectable() +export class UserPermissionService { + private readonly logger = new Logger(UserPermissionService.name) + + validate(policy: UserPolicyConfig, user: UserContext): void { + this.validateAdminPermissions(policy, user.adminPermissions) + this.validateUserType(policy, user.userType) + } + + validateAdminPermissions(policy: UserPolicyConfig, adminPermissions: bigint | undefined): void { + if (!policy.adminPermissions.length) return + + const hasPermission = policy.adminPermissions.every( + permName => AdminAuthorized[permName](adminPermissions ?? 0n), + ) + + if (!hasPermission) { + this.logger.warn(`User auth denied: missing admin permissions (adminPermissions=${adminPermissions?.toString()}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Permissions administrateur insuffisantes') + } + } + + validateUserType(policy: UserPolicyConfig, userType: string | undefined): void { + if (!policy.userTypes.length) return + if (typeof userType !== 'string' || !policy.userTypes.includes(userType as PrismaUser['type'])) { + this.logger.warn(`User auth denied: invalid user type (userType=${userType}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Cannot find requestor in request context') + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts new file mode 100644 index 0000000000..6fd1db8ee6 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts @@ -0,0 +1,100 @@ +import { BadRequestException } from '@nestjs/common' +import { beforeEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { ZodValidationPipe } from './zod-validation.pipe' + +describe('zodValidationPipe', () => { + const schema = z.object({ + name: z.string(), + age: z.number().int().positive(), + }) + + let pipe: ZodValidationPipe + + beforeEach(() => { + pipe = new ZodValidationPipe(schema) + }) + + describe('transform', () => { + it('should return validated data when input is valid', () => { + const validInput = { + name: 'Alice', + age: 30, + } + + const result = pipe.transform(validInput) + + expect(result).toEqual(validInput) + }) + + it('should strip unknown fields if schema does not allow them', () => { + const input = { + name: 'Alice', + age: 30, + extra: 'remove me', + } + + const result = pipe.transform(input) + + expect(result).toEqual({ + name: 'Alice', + age: 30, + }) + }) + + it('should throw BadRequestException when required fields are missing', () => { + const invalidInput = { + name: 'Alice', + } + + expect(() => pipe.transform(invalidInput)).toThrow( + BadRequestException, + ) + }) + + it('should throw BadRequestException when field types are invalid', () => { + const invalidInput = { + name: 'Alice', + age: '30', + } + + expect(() => pipe.transform(invalidInput)).toThrow( + BadRequestException, + ) + }) + + it('should include flattened Zod errors in the exception response', () => { + const invalidInput = { + name: 123, + age: -5, + } + + try { + pipe.transform(invalidInput) + throw new Error('Expected transform to throw') + } catch (error) { + expect(error).toBeInstanceOf(BadRequestException) + + if (error instanceof BadRequestException) { + const response = error.getResponse() as any + + expect(response).toHaveProperty('fieldErrors') + expect(response.fieldErrors).toHaveProperty('name') + expect(response.fieldErrors).toHaveProperty('age') + } + } + }) + + it('should throw when input is null', () => { + expect(() => pipe.transform(null)).toThrow( + BadRequestException, + ) + }) + + it('should throw when input is undefined', () => { + expect(() => pipe.transform(undefined)).toThrow( + BadRequestException, + ) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts new file mode 100644 index 0000000000..066fa255d4 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts @@ -0,0 +1,17 @@ +import type { PipeTransform } from '@nestjs/common' +import type { ZodSchema } from 'zod' +import { BadRequestException } from '@nestjs/common' + +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: ZodSchema) {} + + transform(value: unknown) { + const result = this.schema.safeParse(value) + + if (!result.success) { + throw new BadRequestException(result.error.flatten()) + } + + return result.data + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts new file mode 100644 index 0000000000..3056b5bcb1 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts @@ -0,0 +1,2 @@ +export const TRACER_NAME = '@cpn-console/server-nestjs' +export const SERVICE_NAME = 'console-pi-native-console' diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts new file mode 100644 index 0000000000..950f09d0cc --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts @@ -0,0 +1,74 @@ +import type { Span as OpenTelemetrySpan, SpanOptions } from '@opentelemetry/api' +import { SpanStatusCode, trace } from '@opentelemetry/api' +import { TRACER_NAME } from './telemetry.constants' + +export type TypedMethodDecorator = any>( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, +) => void + +export function StartActiveSpan(options?: SpanOptions): TypedMethodDecorator { + return any>( + _target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ): void => { + const original = descriptor.value + if (!original) return + + descriptor.value = function (this: ThisParameterType, ...args: Parameters): ReturnType { + const tracer = trace.getTracer(TRACER_NAME) + const className = this?.constructor?.name ?? 'Unknown' + const spanName = `${className}.${String(propertyKey)}` + + const runInActiveSpan = (span: OpenTelemetrySpan) => { + try { + const result = original.apply(this, args) + + if (isPromiseLike(result)) { + return handlePromiseResult(span, result) as ReturnType + } + + span.end() + return result + } catch (error) { + recordException(span, error) + span.end() + throw error + } + } + + if (options) { + return tracer.startActiveSpan(spanName, options, runInActiveSpan) as ReturnType + } + return tracer.startActiveSpan(spanName, runInActiveSpan) as ReturnType + } as T + } +} + +function isPromiseLike(value: unknown): value is Promise { + if (!value) return false + return typeof (value as Promise).then === 'function' +} + +async function handlePromiseResult(span: OpenTelemetrySpan, promise: Promise): Promise { + try { + return await promise + } catch (error) { + recordException(span, error) + throw error + } finally { + span.end() + } +} + +function recordException(span: OpenTelemetrySpan, error: unknown): void { + // If it's an actual Error object, OpenTelemetry captures the stack trace automatically + if (error instanceof Error) { + span.recordException(error) + } else { + span.recordException(String(error)) + } + span.setStatus({ code: SpanStatusCode.ERROR }) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts new file mode 100644 index 0000000000..f30d6c6af7 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts @@ -0,0 +1,251 @@ +import type { TestingModule } from '@nestjs/testing' +import type { KeycloakConfig } from './keycloak.module-definition' +import KcAdminClient from '@keycloak/keycloak-admin-client' +import { ScheduleModule } from '@nestjs/schedule' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' +import { ADMIN_TOKEN_REFRESH_INTERVAL_MS } from './keycloak.constants' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +const keycloakUrl = 'https://keycloak.internal' +const projectRealm = 'project-realm' +// The only handled token endpoint is the master realm's: a token request +// against any other realm is unhandled and fails the test, which enforces +// the realm pinning of authenticate() +const tokenUrl = `${keycloakUrl}/realms/master/protocol/openid-connect/token` +const childrenUrl = `${keycloakUrl}/admin/realms/${projectRealm}/groups/:parentId/children` + +const server = setupServer() + +function useTokenEndpoint({ rejectGrant = () => false }: { rejectGrant?: (grantType: string | null) => boolean } = {}) { + const tokenRequests: URLSearchParams[] = [] + let issued = 0 + server.use( + http.post(tokenUrl, async ({ request }) => { + const body = new URLSearchParams(await request.text()) + tokenRequests.push(body) + if (rejectGrant(body.get('grant_type'))) { + return HttpResponse.json({ error: 'invalid_grant' }, { status: 401 }) + } + issued++ + return HttpResponse.json({ access_token: `access-token-${issued}`, refresh_token: `refresh-token-${issued}`, expires_in: 60 }) + }), + ) + return tokenRequests +} + +function createKeycloakClientServiceTestingModule(config: Partial = {}) { + return Test.createTestingModule({ + imports: [ScheduleModule.forRoot()], + providers: [ + KeycloakClientService, + { provide: KEYCLOAK_ADMIN_CLIENT, useValue: new KcAdminClient({ baseUrl: keycloakUrl }) }, + { + provide: KEYCLOAK_CONFIG, + useValue: mockDeep({ + realm: projectRealm, + admin: 'admin', + adminPassword: 'admin-password', + adminClientId: 'admin-cli', + ...config, + }), + }, + ], + }) +} + +describe('keycloakClientService authentication lifecycle', () => { + let module: TestingModule + let client: KcAdminClient + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + vi.useFakeTimers() + module = await createKeycloakClientServiceTestingModule().compile() + client = module.get(KEYCLOAK_ADMIN_CLIENT) + }) + afterEach(async () => { + // close() re-runs init() on a module whose init() already failed; swallow + // that rethrow so the "initial authentication fails" test can clean up + await module.close().catch(() => {}) + server.resetHandlers() + vi.useRealTimers() + }) + afterAll(() => server.close()) + + it('should authenticate with the password grant then switch to the project realm', async () => { + const tokenRequests = useTokenEndpoint() + + await module.init() + + expect(tokenRequests).toHaveLength(1) + expect(Object.fromEntries(tokenRequests[0])).toMatchObject({ + grant_type: 'password', + client_id: 'admin-cli', + username: 'admin', + password: 'admin-password', + }) + expect(client.accessToken).toBe('access-token-1') + expect(client.realmName).toBe(projectRealm) + }) + + it('should refresh the token periodically with the rotated refresh token so it never expires', async () => { + const tokenRequests = useTokenEndpoint() + await module.init() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(2) + expect(Object.fromEntries(tokenRequests[1])).toMatchObject({ + grant_type: 'refresh_token', + client_id: 'admin-cli', + refresh_token: 'refresh-token-1', + }) + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(3) + expect(tokenRequests[2].get('refresh_token')).toBe('refresh-token-2') + expect(client.accessToken).toBe('access-token-3') + expect(client.realmName).toBe(projectRealm) + }) + + it('should fall back to a full re-authentication when the refresh grant fails', async () => { + const tokenRequests = useTokenEndpoint({ rejectGrant: grantType => grantType === 'refresh_token' }) + await module.init() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + + expect(tokenRequests.map(body => body.get('grant_type'))).toEqual(['password', 'refresh_token', 'password']) + expect(client.accessToken).toBe('access-token-2') + expect(client.realmName).toBe(projectRealm) + }) + + it('should keep refreshing and restore the project realm when both grants fail', async () => { + let keycloakIsDown = false + const tokenRequests = useTokenEndpoint({ rejectGrant: () => keycloakIsDown }) + await module.init() + + keycloakIsDown = true + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests.map(body => body.get('grant_type'))).toEqual(['password', 'refresh_token', 'password']) + expect(client.realmName).toBe(projectRealm) + + keycloakIsDown = false + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(4) + expect(tokenRequests[3].get('grant_type')).toBe('refresh_token') + expect(client.accessToken).toBe('access-token-2') + }) + + it('should stop refreshing the token on module destroy', async () => { + const tokenRequests = useTokenEndpoint() + await module.init() + expect(tokenRequests).toHaveLength(1) + + await module.close() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 3) + expect(tokenRequests).toHaveLength(1) + }) + + it('should rethrow when the initial authentication fails', async () => { + const tokenRequests = useTokenEndpoint({ rejectGrant: () => true }) + + await expect(module.init()).rejects.toThrow('invalid_grant') + expect(client.realmName).toBe('master') + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) + expect(tokenRequests).toHaveLength(1) + }) +}) + +describe('getOrCreateSubGroupByName', () => { + let module: TestingModule + let service: KeycloakClientService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + module = await createKeycloakClientServiceTestingModule().compile() + service = module.get(KeycloakClientService) + useTokenEndpoint() + await module.init() + }) + afterEach(async () => { + await module.close() + server.resetHandlers() + }) + afterAll(() => server.close()) + + it('should return the existing subgroup without creating it', async () => { + // No POST handler: a create attempt would fail the test + server.use( + http.get(childrenUrl, ({ params }) => { + expect(params.parentId).toBe('parent-id') + return HttpResponse.json([{ id: 'sub-id', name: 'sub' }]) + }), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toMatchObject({ id: 'sub-id', name: 'sub' }) + }) + + it('should create the subgroup when it does not exist', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([])), + http.post(childrenUrl, async ({ request, params }) => { + expect(params.parentId).toBe('parent-id') + expect(await request.json()).toEqual({ name: 'sub' }) + return new HttpResponse(null, { + status: 201, + headers: { location: `${keycloakUrl}/admin/realms/${projectRealm}/groups/created-id` }, + }) + }), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toEqual({ id: 'created-id', name: 'sub' }) + }) + + it('should re-fetch the subgroup when a concurrent creation causes a 409', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([]), { once: true }), + http.get(childrenUrl, () => HttpResponse.json([{ id: 'concurrent-id', name: 'sub' }])), + http.post(childrenUrl, () => + HttpResponse.json({ errorMessage: 'Sibling group named \'sub\' already exists.' }, { status: 409 })), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toMatchObject({ id: 'concurrent-id', name: 'sub' }) + }) + + it('should rethrow the 409 when the subgroup still cannot be found', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([])), + http.post(childrenUrl, () => + HttpResponse.json({ errorMessage: 'Sibling group named \'sub\' already exists.' }, { status: 409 })), + ) + + await expect(service.getOrCreateSubGroupByName('parent-id', 'sub')).rejects.toThrow('Sibling group named') + }) + + it('should rethrow non-409 errors without re-fetching', async () => { + let listCalls = 0 + server.use( + http.get(childrenUrl, () => { + listCalls++ + return HttpResponse.json([]) + }), + http.post(childrenUrl, () => + HttpResponse.json({ error: 'unauthorized' }, { status: 401 })), + ) + + await expect(service.getOrCreateSubGroupByName('parent-id', 'sub')).rejects.toThrow('unauthorized') + expect(listCalls).toBe(1) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts new file mode 100644 index 0000000000..bb106d64e2 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -0,0 +1,343 @@ +import type KcAdminClient from '@keycloak/keycloak-admin-client' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { Credentials } from '@keycloak/keycloak-admin-client/lib/utils/auth' +import type { OnModuleInit } from '@nestjs/common' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import type { KeycloakConfig } from './keycloak.module-definition' +import type { GroupRepresentationWith } from './keycloak.utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { Interval } from '@nestjs/schedule' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { getErrorResponseStatus } from '../../utils/http.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { ADMIN_AUTH_REALM, ADMIN_TOKEN_REFRESH_INTERVAL_MS, CONSOLE_GROUP_NAME, PASSWORD_GRANT_TYPE, REFRESH_TOKEN_GRANT_TYPE, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +export const KEYCLOAK_ADMIN_CLIENT = Symbol('KEYCLOAK_ADMIN_CLIENT') + +@Injectable() +export class KeycloakClientService implements OnModuleInit { + private readonly logger = new Logger(KeycloakClientService.name) + + private authenticated = false + + constructor( + @Inject(KEYCLOAK_CONFIG) private readonly keycloakConfig: KeycloakConfig, + @Inject(KEYCLOAK_ADMIN_CLIENT) private readonly client: KcAdminClient, + ) { + } + + async* getAllGroups() { + let first = 0 + while (true) { + const fetched = await this.client.groups.find({ first, max: SUBGROUPS_PAGINATE_QUERY_MAX, briefRepresentation: false }) + this.logger.verbose(`Loaded a Keycloak groups page (first=${first}, count=${fetched.length})`) + if (fetched.length === 0) break + for (const group of fetched) { + yield group + } + if (fetched.length < SUBGROUPS_PAGINATE_QUERY_MAX) break + first += SUBGROUPS_PAGINATE_QUERY_MAX + } + } + + @StartActiveSpan() + async getGroupByName(name: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + const groups = await this.client.groups.find({ search: name, briefRepresentation: false }) ?? [] + return groups.find(g => g.name === name) + } + + async getGroupByPath(path: string): Promise { + const parts = path.split('/').filter(Boolean) + this.logger.verbose(`Resolving Keycloak group path ${path} (depth=${parts.length})`) + let current: GroupRepresentationWith<'id'> | undefined + if (parts.length === 0) return undefined + + for (const name of parts) { + current = current + ? await this.getSubGroupByName(current.id, name) + : await this.getRootGroupByName(name) + + if (!current) { + this.logger.verbose(`Keycloak group path segment was not found (path=${path}, missing=${name})`) + return undefined + } + } + this.logger.verbose(`Keycloak group path resolved (path=${path}, groupId=${current?.id})`) + return current + } + + private async getSubGroupByName(parentId: string, name: string): Promise | undefined> { + for await (const subgroup of this.getSubGroups(parentId)) { + if (subgroup.name === name) { + const parsed = z.object({ id: z.string() }).and(z.record(z.string(), z.unknown())).safeParse(subgroup) + return parsed.success ? parsed.data : undefined + } + } + return undefined + } + + private async getRootGroupByName(name: string): Promise | undefined> { + const candidates = await this.client.groups.find({ search: name, briefRepresentation: false }) ?? [] + const match = candidates.find(g => g.path === `/${name}`) ?? candidates.find(g => g.name === name) + const parsed = z.object({ id: z.string() }).and(z.record(z.string(), z.unknown())).safeParse(match) + return parsed.success ? parsed.data : undefined + } + + async deleteGroup(id: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', id) + this.logger.log(`Deleting Keycloak group (groupId=${id})`) + await this.client.groups.del({ id }) + } + + async getGroupMembers(groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + const members = await this.client.groups.listMembers({ id: groupId }) + this.logger.verbose(`Loaded Keycloak group members (groupId=${groupId}, count=${members?.length ?? 0})`) + return members || [] + } + + async getUserByEmail(email: string): Promise { + const users = await this.client.users.find({ + email, + exact: true, + max: 1, + }) + return users[0] + } + + @StartActiveSpan() + async createGroup(name: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + this.logger.debug(`Creating Keycloak group ${name}`) + const result = await this.client.groups.create({ name }) + return { ...result, name } as GroupRepresentation + } + + async addUserToGroup(userId: string, groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + this.logger.verbose(`Adding user to Keycloak group (userId=${userId}, groupId=${groupId})`) + return this.client.users.addToGroup({ id: userId, groupId }) + } + + async removeUserFromGroup(userId: string, groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + this.logger.verbose(`Removing user from Keycloak group (userId=${userId}, groupId=${groupId})`) + return this.client.users.delFromGroup({ id: userId, groupId }) + } + + async* getSubGroups(parentId: string) { + let first = 0 + while (true) { + const page = await this.client.groups.listSubGroups({ + parentId, + briefRepresentation: false, + max: SUBGROUPS_PAGINATE_QUERY_MAX, + first, + }) + this.logger.verbose(`Loaded a Keycloak subgroups page (parentId=${parentId}, first=${first}, count=${page.length})`) + if (page.length === 0) break + for (const subgroup of page) { + yield subgroup + } + if (page.length < SUBGROUPS_PAGINATE_QUERY_MAX) break + first += SUBGROUPS_PAGINATE_QUERY_MAX + } + } + + async getOrCreateGroupByPath(path: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.path.depth', path.split('/').filter(Boolean).length) + this.logger.verbose(`Ensuring Keycloak group path exists: ${path}`) + const existingGroup = await this.getGroupByPath(path) + if (existingGroup) { + this.logger.verbose(`Keycloak group already exists at path ${path}`) + return existingGroup + } + + const parts = path.split('/').filter(Boolean) + let parentId: string | undefined + let current: GroupRepresentationWith<'id' | 'name'> | undefined + + for (const name of parts.values()) { + if (current) { + if (!parentId) parentId = current.id + const next = z.object({ id: z.string(), name: z.string() }).safeParse(await this.getOrCreateSubGroupByName(parentId, name)) + if (next.success) current = next.data + } else { + const next = z.object({ id: z.string(), name: z.string() }).safeParse(await this.getGroupByName(name) ?? await this.createGroup(name)) + if (next.success) current = next.data + } + parentId = current?.id + } + + if (current) { + this.logger.log(`Created Keycloak group path ${path} (groupId=${current.id})`) + } + return { ...current, path } as GroupRepresentation + } + + @StartActiveSpan() + async getOrCreateSubGroupByName(parentId: string, name: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + span?.setAttribute('parent.id', parentId) + const existing = await this.findSubGroupByName(parentId, name) + if (existing) return existing + + this.logger.debug(`Creating Keycloak subgroup ${name} under parentId=${parentId}`) + try { + const createdGroup = await this.client.groups.createChildGroup({ id: parentId }, { name }) + return { id: createdGroup.id, name } satisfies GroupRepresentation + } catch (err) { + // A concurrent reconciliation may have created the subgroup between the + // scan and the create; treat the 409 as "already exists" and re-fetch it + if (getErrorResponseStatus(err) !== 409) throw err + this.logger.verbose(`Keycloak subgroup ${name} was created concurrently under parentId=${parentId}, fetching it`) + const subgroup = await this.findSubGroupByName(parentId, name) + if (!subgroup) throw err + return subgroup + } + } + + private async findSubGroupByName(parentId: string, name: string): Promise { + for await (const subgroup of this.getSubGroups(parentId)) { + if (subgroup.name === name) { + return subgroup + } + } + return undefined + } + + async getOrCreateConsoleGroup(projectGroup: GroupRepresentationWith<'id'>) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', projectGroup.id) + this.logger.verbose(`Ensuring Keycloak console group exists (projectGroupId=${projectGroup.id})`) + return this.getOrCreateSubGroupByName(projectGroup.id, CONSOLE_GROUP_NAME) + } + + async getOrCreateRoleGroup( + consoleGroup: GroupRepresentationWith<'id' | 'name'>, + oidcGroup: string, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.group.id': consoleGroup.id, + 'role.oidc_group.present': !!oidcGroup, + 'role.oidc_group.depth': oidcGroup.split('/').filter(Boolean).length, + }) + const parts = oidcGroup.split('/').filter(Boolean) + if (parts.length === 0) { + throw new Error(`Invalid oidcGroup for project role: "${oidcGroup}"`) + } + + let current = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(consoleGroup.id, parts[0])) + + for (const name of parts.slice(1)) { + current = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(current.id, name)) + } + + return { ...current, path: `/${consoleGroup.name}/${parts.join('/')}` } satisfies GroupRepresentation + } + + async getOrCreateEnvironmentGroups(consoleGroup: GroupRepresentationWith<'id'>, environment: ProjectWithDetails['environments'][number]) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.group.id': consoleGroup.id, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const envGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(consoleGroup.id, environment.name)) + const [roGroup, rwGroup] = await Promise.all([ + this.getOrCreateSubGroupByName(envGroup.id, 'RO'), + this.getOrCreateSubGroupByName(envGroup.id, 'RW'), + ]) + this.logger.verbose(`Resolved Keycloak environment groups (consoleGroupId=${consoleGroup.id}, env=${environment.name}, envGroupId=${envGroup.id})`) + return { roGroup, rwGroup } + } + + async onModuleInit() { + try { + this.logger.log(`Authenticating Keycloak admin client (realm=${this.keycloakConfig.realm})`) + await this.authenticate(this.passwordCredentials()) + } catch (err) { + if (err instanceof Error) { + this.logger.error(`Keycloak Admin Client authentication failed: ${err.message}`, err.stack) + } else { + this.logger.error(`Keycloak Admin Client authentication failed: ${String(err)}`) + } + throw err + } + this.client.setConfig({ realmName: this.keycloakConfig.realm }) + this.authenticated = true + this.logger.log(`Keycloak Admin Client authenticated (realm=${this.keycloakConfig.realm})`) + } + + // The admin client never refreshes its token on its own; without this the + // access token expires (~60s) and every admin call fails with a 401 + @Interval(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + async refreshAdminToken() { + if (!this.authenticated) return + try { + await this.authenticate(this.refreshTokenCredentials()) + } catch (refreshErr) { + // The refresh token itself can expire or be revoked (e.g. Keycloak + // restart); fall back to a full re-authentication to recover + this.logger.warn(`Keycloak Admin Client token refresh failed, re-authenticating: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`) + try { + await this.authenticate(this.passwordCredentials()) + } catch (err) { + this.logger.error(`Keycloak Admin Client re-authentication failed: ${err instanceof Error ? err.message : String(err)}`) + } + } + } + + private passwordCredentials(): Credentials { + return { + clientId: this.keycloakConfig.adminClientId, + grantType: PASSWORD_GRANT_TYPE, + username: this.keycloakConfig.admin, + password: this.keycloakConfig.adminPassword, + } + } + + private refreshTokenCredentials(): Credentials { + return { + clientId: this.keycloakConfig.adminClientId, + grantType: REFRESH_TOKEN_GRANT_TYPE, + refreshToken: this.client.refreshToken, + } + } + + // auth() resolves the token endpoint from client.realmName, which onModuleInit + // switches to the project realm — the admin user lives in the master realm. + // Restore the realm even when auth fails: the still-valid previous token keeps + // serving admin calls, and those must target the project realm, not master + private async authenticate(credentials: Credentials) { + const realmName = this.client.realmName + this.client.setConfig({ realmName: ADMIN_AUTH_REALM }) + try { + await this.client.auth(credentials) + } finally { + this.client.setConfig({ realmName }) + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts new file mode 100644 index 0000000000..0c0a028836 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts @@ -0,0 +1,88 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + slug: true, + ownerId: true, + everyonePerms: true, + members: { + select: { + roleIds: true, + user: { + select: { + id: true, + email: true, + }, + }, + }, + }, + roles: { + select: { + id: true, + permissions: true, + oidcGroup: true, + type: true, + }, + }, + environments: { + select: { + id: true, + name: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export const adminRoleSelect = { + id: true, + oidcGroup: true, + type: true, +} satisfies Prisma.AdminRoleSelect + +export type AdminRoleWithDetails = Prisma.AdminRoleGetPayload<{ + select: typeof adminRoleSelect +}> + +export const userAdminRoleSelect = { + id: true, + adminRoleIds: true, +} satisfies Prisma.UserSelect + +export type UserWithAdminRoles = Prisma.UserGetPayload<{ + select: typeof userAdminRoleSelect +}> + +@Injectable() +export class KeycloakDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getAllAdminRoles(): Promise { + return this.prisma.adminRole.findMany({ + select: adminRoleSelect, + }) + } + + async getAllUsersWithAdminRoleIds(): Promise { + return this.prisma.user.findMany({ + select: userAdminRoleSelect, + }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts new file mode 100644 index 0000000000..6d78945601 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts @@ -0,0 +1,25 @@ +import type { KeycloakConfig } from './keycloak.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +@Injectable() +export class KeycloakHealthService { + constructor( + @Inject(KEYCLOAK_CONFIG) + private readonly keycloakConfig: KeycloakConfig, + @Inject(HealthIndicatorService) + private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts new file mode 100644 index 0000000000..a0b5e2dfac --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts @@ -0,0 +1,16 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Injectable } from '@nestjs/common' + +@Injectable() +export class KeycloakPluginService { + infos(): ServiceInfos { + return { + name: 'keycloak', + title: 'Keycloak', + config: { + global: [], + project: [], + }, + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts new file mode 100644 index 0000000000..17fb5c5f0d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts @@ -0,0 +1,87 @@ +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { ProjectWithDetails } from './keycloak-datastore.service' + +import { faker } from '@faker-js/faker' + +export function makeUserRepresentation( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + username: faker.internet.username(), + enabled: true, + ...overrides, + } satisfies UserRepresentation +} + +export function makeGroupRepresentation( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + path: `/${faker.word.noun()}`, + subGroups: [], + ...overrides, + } satisfies GroupRepresentation +} + +export function makeProjectUser( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + ...overrides, + } satisfies ProjectWithDetails['members'][number]['user'] +} + +export function makeProjectMember( + overrides: Partial = {}, +) { + return { + roleIds: [], + user: makeProjectUser(), + ...overrides, + } satisfies ProjectWithDetails['members'][number] +} + +export function makeProjectRole( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + permissions: 0n, + oidcGroup: '', + type: 'managed', + ...overrides, + } satisfies ProjectWithDetails['roles'][number] +} + +export function makeProjectEnvironment( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + ...overrides, + } satisfies ProjectWithDetails['environments'][number] +} + +export function makeProjectWithDetails( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(faker.word.words({ count: 2 })).toLowerCase(), + ownerId: faker.string.uuid(), + everyonePerms: 0n, + plugins: [], + members: [], + roles: [], + environments: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts new file mode 100644 index 0000000000..e964d9e74c --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts @@ -0,0 +1,20 @@ +// Keycloak plugin constants +export const PLUGIN_NAME = 'keycloak' + +// Name for the console group for admin and project roles +export const CONSOLE_GROUP_NAME = 'console' + +// Realm hosting the admin user used by the admin client (password grant on admin-cli) +export const ADMIN_AUTH_REALM = 'master' + +// OAuth2 grant types (RFC 6749) used to authenticate the admin client +export const PASSWORD_GRANT_TYPE = 'password' +export const REFRESH_TOKEN_GRANT_TYPE = 'refresh_token' + +// Keycloak's default access token lifespan is 60s; refresh well before it +// expires so a slow or delayed tick still lands within the lifespan +export const ADMIN_TOKEN_REFRESH_INTERVAL_MS = 45_000 + +// Maximum number of entities returned in a paginated query +export const GROUPS_PAGINATE_QUERY_MAX = 20 +export const SUBGROUPS_PAGINATE_QUERY_MAX = 20 diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts new file mode 100644 index 0000000000..8245a556b9 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts @@ -0,0 +1,29 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface KeycloakConfig { + protocol: string + domain?: string + publicProtocol: string + publicDomain?: string + realm?: string + clientId?: string + clientSecret?: string + admin?: string + adminPassword?: string + adminClientId: string + redirectUri?: string + jwksCacheTtlMs: number + jwksTimeoutMs: number + openidConfigurationCacheTtlMs: number + adminKcUserId: string[] + url?: string + realmUrl?: string + openidConfigurationUrl?: string +} + +export interface KeycloakModuleOptions extends KeycloakConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('keycloak') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as KEYCLOAK_CONFIG } diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts new file mode 100644 index 0000000000..7186de782a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts @@ -0,0 +1,31 @@ +import type { KeycloakConfig } from './keycloak.module-definition' +import KcAdminClient from '@keycloak/keycloak-admin-client' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { KeycloakHealthService } from './keycloak-health.service' +import { KeycloakPluginService } from './keycloak-plugin.service' +import { ConfigurableModuleClass, KEYCLOAK_CONFIG } from './keycloak.module-definition' +import { KeycloakService } from './keycloak.service' + +@Module({ + imports: [DatabaseModule, TerminusModule], + providers: [ + { + inject: [KEYCLOAK_CONFIG], + provide: KEYCLOAK_ADMIN_CLIENT, + useFactory: (config: KeycloakConfig) => new KcAdminClient({ + baseUrl: config.url, + }), + }, + KeycloakClientService, + KeycloakDatastoreService, + KeycloakHealthService, + KeycloakPluginService, + KeycloakService, + ], + exports: [KeycloakClientService, KeycloakHealthService, KeycloakPluginService, KeycloakService], +}) +export class KeycloakModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts new file mode 100644 index 0000000000..e5b116688a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -0,0 +1,450 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { AdminRoleWithDetails, ProjectWithDetails, UserWithAdminRoles } from './keycloak-datastore.service' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { + makeGroupRepresentation, + makeProjectEnvironment, + makeProjectMember, + makeProjectRole, + makeProjectUser, + makeProjectWithDetails, + makeUserRepresentation, +} from './keycloak-testing.utils' +import { KeycloakService } from './keycloak.service' + +describe('keycloakService', () => { + let service: KeycloakService + let keycloak: DeepMockProxy + let datastore: DeepMockProxy + + beforeEach(async () => { + keycloak = mockDeep({ + getOrCreateConsoleGroup: vi.fn().mockResolvedValue(makeGroupRepresentation({ id: 'console-group-id', name: 'console' })), + getOrCreateEnvironmentGroups: vi.fn().mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'rw-id', name: 'RW' }), + }), + getAllGroups: vi.fn().mockImplementation(async function* () { /* empty by default */ }), + deleteGroup: vi.fn().mockResolvedValue(undefined), + }) + datastore = mockDeep({ + getAllAdminRoles: vi.fn().mockResolvedValue([]), + getAllUsersWithAdminRoleIds: vi.fn().mockResolvedValue([]), + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + KeycloakService, + { provide: KeycloakClientService, useValue: keycloak }, + { provide: KeycloakDatastoreService, useValue: datastore }, + ], + }).compile() + + service = moduleRef.get(KeycloakService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('reconcile', () => { + const mockProject: ProjectWithDetails = makeProjectWithDetails({ + id: 'project-id', + slug: 'test-project', + ownerId: 'owner-id', + everyonePerms: 0n, + }) + + it('should not sync system external admin roles', async () => { + const adminRoles: AdminRoleWithDetails[] = [ + { id: 'admin-role-id', oidcGroup: '/console/system-external', type: 'system:external' }, + ] + const users: UserWithAdminRoles[] = [ + { id: 'user-1', adminRoleIds: ['admin-role-id'] }, + ] + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + + await service.handleCron() + + expect(keycloak.getOrCreateGroupByPath).not.toHaveBeenCalledWith('/console/system-external') + expect(keycloak.getGroupMembers).not.toHaveBeenCalled() + expect(keycloak.addUserToGroup).not.toHaveBeenCalled() + expect(keycloak.removeUserFromGroup).not.toHaveBeenCalled() + }) + + it('should sync admin role groups', async () => { + const adminRoles: AdminRoleWithDetails[] = [ + { id: 'admin-role-id', oidcGroup: '/console/admin', type: 'managed' }, + ] + const users: UserWithAdminRoles[] = [ + { id: 'user-1', adminRoleIds: ['admin-role-id'] }, + { id: 'user-2', adminRoleIds: [] }, + ] + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + + const adminGroup = makeGroupRepresentation({ id: 'kc-group-id', name: 'admin', path: '/console/admin' }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(adminGroup) + + keycloak.getGroupMembers.mockResolvedValue([ + makeUserRepresentation({ id: 'user-2' }), + ]) + + await service.handleCron() + + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/console/admin') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'kc-group-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'kc-group-id') + }) + + it('should purge orphans', async () => { + datastore.getAllProjects.mockResolvedValue([mockProject]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project', subGroups: [] }) + const orphanGroup = makeGroupRepresentation({ + id: 'orphan-id', + name: 'orphan-project', + subGroups: [makeGroupRepresentation({ name: 'console' })], + }) + + keycloak.getAllGroups.mockImplementation(async function* () { + yield projectGroup + yield orphanGroup + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(keycloak.getAllGroups).toHaveBeenCalled() + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/test-project') + expect(keycloak.deleteGroup).toHaveBeenCalledWith('orphan-id') + }) + + it('should sync project members', async () => { + const projectWithMembers = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: [], + }), + ], + }) + datastore.getAllProjects.mockResolvedValue([projectWithMembers]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + + // Current members: user-2 (extra), missing user-1 + keycloak.getGroupMembers.mockResolvedValue([ + makeUserRepresentation({ id: 'user-2', email: 'user2@example.com' }), + ]) + + keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should add missing member + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'group-id') + // Should add owner (missing in group members) + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('owner-id', 'group-id') + // Should remove extra member + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'group-id') + }) + + it('should sync OIDC role groups', async () => { + const roleWithOidc = makeProjectRole({ + id: 'role-oidc', + permissions: 0n, + oidcGroup: '/oidc-group', + type: 'managed', + }) + const projectWithRole = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-oidc'], + }), + ], + roles: [roleWithOidc], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRole]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const roleGroup = makeGroupRepresentation({ id: 'role-group-id', name: 'oidc-group', path: '/console/oidc-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockResolvedValue(roleGroup) + + // Project members: owner + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + // Role group members: user-2 (extra), missing user-1 + if (groupId === 'role-group-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2', email: 'user2@example.com' })]) + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should create/get role group + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/oidc-group') + // Should add user-1 to role group + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'role-group-id') + // Should remove user-2 from role group + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'role-group-id') + }) + + it('should sync environment groups', async () => { + const projectWithEnv = makeProjectWithDetails({ + ...mockProject, + environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], + }) + datastore.getAllProjects.mockResolvedValue([projectWithEnv]) + + const projectGroup = makeGroupRepresentation({ + id: 'group-id', + name: 'test-project', + subGroups: [makeGroupRepresentation({ name: 'console', id: 'console-id' })], + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + + // Mock console group retrieval + keycloak.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' }), + }) + keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + if (name === 'dev') return Promise.resolve(makeGroupRepresentation({ id: 'dev-id', name: 'dev' })) + if (name === 'RO') return Promise.resolve(makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' })) + if (name === 'RW') return Promise.resolve(makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' })) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name })) + }) + + // Mock existing environments: 'staging' (extra) + keycloak.getSubGroups.mockImplementation(async function* (parentId) { + if (parentId === 'console-id') { + yield makeGroupRepresentation({ id: 'staging-id', name: 'staging' }) + } + if (parentId === 'staging-id') { + yield makeGroupRepresentation({ name: 'RO' }) + yield makeGroupRepresentation({ name: 'RW' }) + } + }) + + await service.handleCron() + + // Should create dev group + expect(keycloak.getOrCreateConsoleGroup).toHaveBeenCalledWith({ id: 'group-id', name: 'test-project' }) + // Should create RO/RW groups + expect(keycloak.getOrCreateEnvironmentGroups).toHaveBeenCalledWith({ id: 'console-id', name: 'console' }, projectWithEnv.environments[0]) + // Should delete staging group + expect(keycloak.deleteGroup).toHaveBeenCalledWith('staging-id') + }) + + it('should sync environment permissions', async () => { + const userRo = makeUserRepresentation({ id: 'user-ro', email: 'ro@example.com' }) + const userRw = makeUserRepresentation({ id: 'user-rw', email: 'rw@example.com' }) + const userNone = makeUserRepresentation({ id: 'user-none', email: 'none@example.com' }) + + const projectWithEnvAndMembers = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: userRo.id, email: userRo.email }), + roleIds: ['role-ro'], + }), + makeProjectMember({ + user: makeProjectUser({ id: userRw.id, email: userRw.email }), + roleIds: ['role-rw'], + }), + makeProjectMember({ + user: makeProjectUser({ id: userNone.id, email: userNone.email }), + roleIds: [], + }), + ], + roles: [ + makeProjectRole({ id: 'role-ro', permissions: 256n, oidcGroup: '', type: 'managed' }), + makeProjectRole({ id: 'role-rw', permissions: 8n, oidcGroup: '', type: 'managed' }), + ], + environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], + }) + datastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) + + const projectGroup = makeGroupRepresentation({ + id: 'group-id', + name: 'test-project', + subGroups: [makeGroupRepresentation({ name: 'console', id: 'console-id' })], + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' }), + }) + + // Project group members (assume all are in project group for simplicity) + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([userRo, userRw, userNone]) + // RO group has userNone (extra), missing userRo + if (groupId === 'dev-ro-id') return Promise.resolve([userNone]) + // RW group has userNone (extra), missing userRw + if (groupId === 'dev-rw-id') return Promise.resolve([userNone]) + return Promise.resolve([]) + }) + + keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + if (name === 'dev') return Promise.resolve(makeGroupRepresentation({ id: 'dev-id', name: 'dev' })) + if (name === 'RO') return Promise.resolve(makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' })) + if (name === 'RW') return Promise.resolve(makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' })) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name })) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Sync RO + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-ro', 'dev-ro-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-ro-id') + // Sync RW + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-rw', 'dev-rw-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-rw-id') + }) + + it('should handle different role types (managed, external, global)', async () => { + const roleManaged = makeProjectRole({ id: 'role-managed', permissions: 0n, oidcGroup: '/managed-group', type: 'managed' }) + const roleExternal = makeProjectRole({ id: 'role-external', permissions: 0n, oidcGroup: '/external-group', type: 'external' }) + const roleGlobal = makeProjectRole({ id: 'role-global', permissions: 0n, oidcGroup: '/global-group', type: 'global' }) + + const projectWithRoles = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-managed', 'role-external', 'role-global'], + }), + ], + roles: [roleManaged, roleExternal, roleGlobal], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const managedGroup = makeGroupRepresentation({ id: 'managed-id', name: 'managed-group' }) + const externalGroup = makeGroupRepresentation({ id: 'external-id', name: 'external-group' }) + const globalGroup = makeGroupRepresentation({ id: 'global-id', name: 'global-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockImplementation((_consoleGroup, oidcGroup) => { + if (oidcGroup === '/managed-group') return Promise.resolve({ ...managedGroup, path: '/console/managed-group' }) + if (oidcGroup === '/external-group') return Promise.resolve({ ...externalGroup, path: '/console/external-group' }) + if (oidcGroup === '/global-group') return Promise.resolve({ ...globalGroup, path: '/console/global-group' }) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name: oidcGroup, path: `/console/${oidcGroup}` })) + }) + + // Group members + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + + // Managed: has extra user-2, missing user-1 + if (groupId === 'managed-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + // External: has extra user-2, missing user-1 + if (groupId === 'external-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + // Global: create group if it doesn't exist but no members + if (groupId === 'global-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Managed: should add user-1, remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/managed-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'managed-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'managed-id') + + // External: should add user-1, NOT remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/external-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'external-id') + expect(keycloak.removeUserFromGroup).not.toHaveBeenCalledWith('user-2', 'external-id') + + // Global: should sync group but no members + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/global-group') + }) + + it('should treat system-prefixed role types as their base type', async () => { + const roleSystemManaged = makeProjectRole({ id: 'role-system-managed', permissions: 0n, oidcGroup: '/system-managed-group', type: 'system:managed' }) + + const projectWithRoles = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-system-managed'], + }), + ], + roles: [roleSystemManaged], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const systemManagedGroup = makeGroupRepresentation({ id: 'system-managed-id', name: 'system-managed-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockResolvedValue({ ...systemManagedGroup, path: '/console/system-managed-group' }) + + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + + // has extra user-2, missing user-1 + if (groupId === 'system-managed-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // system:managed behaves like managed: add user-1, remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/system-managed-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'system-managed-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'system-managed-id') + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts new file mode 100644 index 0000000000..f737e77bcd --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -0,0 +1,670 @@ +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { AdminRoleWithDetails, ProjectWithDetails, UserWithAdminRoles } from './keycloak-datastore.service' +import type { GroupRepresentationWith } from './keycloak.utils' +import { getBaseRoleType, getPermsByUserRoles, isExternalRoleType, ProjectAuthorized, resourceListToDict } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { getErrorResponseStatus } from '../../utils/http.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { isMember, isNonEmptyGroupPath, isOwnedProjectGroup, normalizeGroupPath } from './keycloak.utils' + +@Injectable() +export class KeycloakService { + private readonly logger = new Logger(KeycloakService.name) + + constructor( + @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, + @Inject(KeycloakDatastoreService) private readonly datastore: KeycloakDatastoreService, + ) { + this.logger.log('KeycloakService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('keycloak', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroups([project]) + this.logger.log(`Keycloak sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('keycloak', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.purgeOrphanGroups([project]) + this.logger.log(`Keycloak cleanup completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting periodic Keycloak reconciliation') + const [projects, adminRoles, users] = await Promise.all([ + this.datastore.getAllProjects(), + this.datastore.getAllAdminRoles(), + this.datastore.getAllUsersWithAdminRoleIds(), + ]) + span?.setAttributes({ + 'keycloak.projects.count': projects.length, + 'keycloak.admin_roles.count': adminRoles.length, + 'keycloak.users.count': users.length, + }) + this.logger.debug(`Reconciling Keycloak state (projects=${projects.length}, adminRoles=${adminRoles.length}, users=${users.length})`) + await Promise.all([ + this.ensureAdminRoleGroups(adminRoles, users), + this.ensureProjectGroups(projects), + ]) + await this.purgeOrphanGroups(projects) + this.logger.log(`Keycloak reconciliation completed (projects=${projects.length}, adminRoles=${adminRoles.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.projects.count', projects.length) + this.logger.verbose(`Reconciling Keycloak project groups (${projects.length})`) + await Promise.all(projects.map(project => this.ensureProjectGroup(project))) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'project.members.count': project.members.length, + 'project.roles.count': project.roles.length, + 'project.environments.count': project.environments.length, + }) + this.logger.verbose(`Reconciling Keycloak project group (${project.slug}): members=${project.members.length} roles=${project.roles.length}`) + + const projectGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateGroupByPath(`/${project.slug}`)) + + span?.setAttribute('keycloak.project_group.id', projectGroup.id) + + await Promise.all([ + this.ensureProjectGroupMembers(project, projectGroup), + this.ensureConsoleGroup(project, projectGroup), + ]) + this.logger.verbose(`Keycloak project group reconciled (${project.slug}): groupId=${projectGroup.id}`) + } + + @StartActiveSpan() + private async ensureConsoleGroup(project: ProjectWithDetails, group: GroupRepresentationWith<'id'>) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.console_group.id', group.id) + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateConsoleGroup(group)) + this.logger.verbose(`Reconciling Keycloak console group (${project.slug}): projectGroupId=${group.id} consoleGroupId=${consoleGroup.id}`) + await Promise.all([ + this.ensureRoleGroups(project, consoleGroup), + this.ensureEnvironmentGroups(project, consoleGroup), + this.purgeOrphanEnvironmentGroups(project, consoleGroup), + ]) + } + + @StartActiveSpan() + private async ensureAdminRoleGroups( + adminRoles: AdminRoleWithDetails[], + users: UserWithAdminRoles[], + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.admin_roles.count': adminRoles.length, + 'keycloak.users.count': users.length, + }) + const rolesWithOidcGroup = adminRoles.filter(r => isNonEmptyGroupPath(r.oidcGroup) && !isExternalRoleType(r.type)) + span?.setAttribute('keycloak.admin_roles.oidc_group.count', rolesWithOidcGroup.length) + await Promise.all(rolesWithOidcGroup.map(role => this.ensureAdminRoleGroup(role, users))) + } + + @StartActiveSpan() + private async ensureAdminRoleGroup( + role: AdminRoleWithDetails, + users: UserWithAdminRoles[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('admin_role.id', role.id) + span?.setAttribute('admin_role.oidc_group.present', isNonEmptyGroupPath(role.oidcGroup)) + const roleGroupPath = normalizeGroupPath(role.oidcGroup) + if (!roleGroupPath) return + + span?.setAttribute('keycloak.group.path', roleGroupPath) + const roleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateGroupByPath(roleGroupPath)) + span?.setAttribute('keycloak.group.id', roleGroup.id) + + const groupMembers = await this.keycloak.getGroupMembers(roleGroup.id) + + const desiredUserIds = new Set( + users + .filter(u => u.adminRoleIds.includes(role.id)) + .map(u => u.id), + ) + span?.setAttribute('keycloak.group.members.desired', desiredUserIds.size) + + let addedCount = 0 + let removedCount = 0 + + await Promise.all([ + ...Array.from(desiredUserIds, async (userId) => { + if (!groupMembers.some(m => m.id === userId)) { + addedCount++ + await this.maybeAddUserToGroup(userId, roleGroup.id, roleGroup.name) + } + }), + ...groupMembers.map(async (member) => { + if (member.id && !desiredUserIds.has(member.id)) { + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, roleGroup.id, roleGroup.name) + } + }), + ]) + + span?.setAttribute('keycloak.group.members.added', addedCount) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + @StartActiveSpan() + private async purgeOrphanGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + const groups = map(this.keycloak.getAllGroups(), async (group) => { + return z.object({ + id: z.string(), + name: z.string(), + subGroups: z.array(z.object({ name: z.string() })), + }).parse(group) + }) + const projectSlugs = new Set(projects.map(p => p.slug)) + this.logger.verbose(`Scanning Keycloak groups for orphan cleanup (projects=${projects.length})`) + const promises: Promise[] = [] + let purgedCount = 0 + + for await (const group of groups) { + if (!projectSlugs.has(group.name)) { + if (isOwnedProjectGroup(group)) { + this.logger.log(`Deleting an orphan Keycloak group (groupId=${group.id}, groupName=${group.name})`) + purgedCount++ + promises.push(this.keycloak.deleteGroup(group.id)) + } + } + } + span?.setAttribute('purged.count', purgedCount) + await Promise.all(promises) + this.logger.log(`Orphan Keycloak group cleanup completed (purged=${purgedCount})`) + } + + private async maybeAddUserToGroup(userId: string, groupId: string, groupName: string) { + try { + await this.keycloak.addUserToGroup(userId, groupId) + this.logger.log(`Added user to Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } catch (e) { + const status = getErrorResponseStatus(e) + if (status === 404) { + this.logger.warn(`User not found in Keycloak, skipping addition: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else if (status === 409) { + this.logger.verbose(`User already a member of Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else { + throw e + } + } + } + + private async maybeRemoveUserFromGroup(userId: string, groupId: string, groupName: string) { + try { + await this.keycloak.removeUserFromGroup(userId, groupId) + this.logger.log(`Removed user from Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } catch (e) { + if (getErrorResponseStatus(e) === 404) { + this.logger.warn(`User not found in Keycloak, skipping removal: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else { + throw e + } + } + } + + @StartActiveSpan() + private async ensureProjectGroupMembers( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('keycloak.group.id', group.id) + + const groupMembers = await this.keycloak.getGroupMembers(group.id) + const desiredUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + + span?.setAttribute('keycloak.group.members.current', groupMembers.length) + span?.setAttribute('keycloak.group.members.desired', desiredUserIds.size) + + let addedCount = 0 + let removedCount = 0 + + await Promise.all([ + ...Array.from(desiredUserIds, async (userId) => { + if (!groupMembers.some(m => m.id === userId)) { + addedCount++ + await this.maybeAddUserToGroup(userId, group.id, group.name) + } + }), + ...groupMembers.map(async (member) => { + if (member.id && !desiredUserIds.has(member.id)) { + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, group.id, group.name) + } + }), + ]) + + span?.setAttribute('keycloak.group.members.added', addedCount) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + @StartActiveSpan() + private async ensureRoleGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'keycloak.group.id': group.id, + 'project.roles.count': project.roles.length, + }) + + const rolesWithOidcGroup = project.roles.filter(r => isNonEmptyGroupPath(r.oidcGroup)) + span?.setAttribute('project.roles.oidc_group.count', rolesWithOidcGroup.length) + + await Promise.all(rolesWithOidcGroup.map(role => this.ensureRoleGroup(project, role, group))) + } + + @StartActiveSpan() + private async ensureRoleGroup( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('role.oidc_group.present', isNonEmptyGroupPath(role.oidcGroup)) + if (!isNonEmptyGroupPath(role.oidcGroup)) return + + span?.setAttribute('role.oidc_group.depth', role.oidcGroup.split('/').filter(Boolean).length) + + const roleGroup = await this.keycloak.getOrCreateRoleGroup(group, role.oidcGroup) + span?.setAttribute('keycloak.group.id', roleGroup.id) + span?.setAttribute('keycloak.group.path', roleGroup.path) + + const groupMembers = await this.keycloak.getGroupMembers(roleGroup.id) + span?.setAttribute('keycloak.group.members.current', groupMembers.length) + + switch (getBaseRoleType(role.type)) { + case 'managed': + await Promise.all([ + this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers), + this.purgeOrphanRoleGroupMembers(project, role, roleGroup, groupMembers), + ]) + break + case 'external': + await this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers) + break + case 'global': + await this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers) + break + default: + throw new Error(`Unknown role type ${role.type}`) + } + } + + @StartActiveSpan() + private async ensureRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.members.current', members.length) + + const desiredMemberIds = project.members + .filter(m => m.roleIds.includes(role.id)) + .map(m => m.user.id) + + span?.setAttribute('keycloak.group.members.desired', desiredMemberIds.length) + + let addedCount = 0 + await Promise.all(project.members.map(async (member) => { + if (!members.some(m => m.id === member.user.id) && member.roleIds.includes(role.id)) { + addedCount++ + await this.maybeAddUserToGroup(member.user.id, group.id, group.name) + } + })) + + span?.setAttribute('keycloak.group.members.added', addedCount) + } + + @StartActiveSpan() + private async purgeOrphanRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.members.current', members.length) + + let removedCount = 0 + await Promise.all(members.map(async (member) => { + if (!isMember(project, member)) { + if (!member.id) { + throw new Error(`Failed to create or retrieve role group for ${role.oidcGroup}`) + } + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, group.id, group.name) + } + })) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + private async ensureEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id'>, + ) { + await Promise.all(project.environments.map(environment => + this.ensureEnvironmentGroup(project, environment, group))) + } + + @StartActiveSpan() + private async ensureEnvironmentGroup( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + group: GroupRepresentationWith<'id'>, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + 'project.roles.count': project.roles.length, + }) + + const { roGroup, rwGroup } = z.object({ + roGroup: z.object({ + id: z.string(), + name: z.string(), + }), + rwGroup: z.object({ + id: z.string(), + name: z.string(), + }), + }).parse(await this.keycloak.getOrCreateEnvironmentGroups(group, environment)) + + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + + const rolesById = resourceListToDict(project.roles) + + const [roMembers, rwMembers] = await Promise.all([ + this.keycloak.getGroupMembers(roGroup.id), + this.keycloak.getGroupMembers(rwGroup.id), + ]) + + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + + await Promise.all([ + this.ensureEnvironmentGroupMembers( + project, + environment, + rolesById, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + this.purgeOrphanEnvironmentGroupMembers( + project, + environment, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + ]) + } + + @StartActiveSpan() + private async ensureEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + rolesById: Record, + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('environment.id', environment.id) + span?.setAttribute('environment.name', environment.name) + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + this.logger.verbose(`Reconciling Keycloak environment group members: project=${project.slug} env=${environment.name} roMembers=${roMembers.length} rwMembers=${rwMembers.length}`) + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + span?.setAttribute('project.users.count', projectUserIds.size) + + let roAdded = 0 + let roRemoved = 0 + let rwAdded = 0 + let rwRemoved = 0 + + await Promise.all(Array.from(projectUserIds, async (userId) => { + const perms = this.getUserPermissions(project, rolesById, userId) + + const isInRo = roMembers.some(m => m.id === userId) + if (perms.ro && !isInRo) { + roAdded++ + await this.maybeAddUserToGroup(userId, roGroup.id, `RO group for ${environment.name}`) + } else if (!perms.ro && isInRo) { + roRemoved++ + await this.maybeRemoveUserFromGroup(userId, roGroup.id, `RO group for ${environment.name}`) + } + + const isInRw = rwMembers.some(m => m.id === userId) + if (perms.rw && !isInRw) { + rwAdded++ + await this.maybeAddUserToGroup(userId, rwGroup.id, `RW group for ${environment.name}`) + } else if (!perms.rw && isInRw) { + rwRemoved++ + await this.maybeRemoveUserFromGroup(userId, rwGroup.id, `RW group for ${environment.name}`) + } + })) + + span?.setAttribute('keycloak.env_group.ro.members.added', roAdded) + span?.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) + span?.setAttribute('keycloak.env_group.rw.members.added', rwAdded) + span?.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) + this.logger.verbose(`Keycloak environment group members reconciled: project=${project.slug} env=${environment.name} roAdded=${roAdded} roRemoved=${roRemoved} rwAdded=${rwAdded} rwRemoved=${rwRemoved}`) + } + + @StartActiveSpan() + private async purgeOrphanEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('environment.id', environment.id) + span?.setAttribute('environment.name', environment.name) + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + this.logger.verbose(`Purging orphan Keycloak environment group members: project=${project.slug} env=${environment.name} roMembers=${roMembers.length} rwMembers=${rwMembers.length}`) + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + span?.setAttribute('project.users.count', projectUserIds.size) + + let roRemoved = 0 + let rwRemoved = 0 + + await Promise.all([ + ...roMembers.map(async (member) => { + if (!member.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + if (!projectUserIds.has(member.id)) { + roRemoved++ + await this.maybeRemoveUserFromGroup(member.id, roGroup.id, `RO group for ${environment.name}`) + } + }), + ...rwMembers.map(async (member) => { + if (!member.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + if (!projectUserIds.has(member.id)) { + rwRemoved++ + await this.maybeRemoveUserFromGroup(member.id, rwGroup.id, `RW group for ${environment.name}`) + } + }), + ]) + + span?.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) + span?.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) + this.logger.log(`Orphan Keycloak environment group member cleanup completed: project=${project.slug} env=${environment.name} roRemoved=${roRemoved} rwRemoved=${rwRemoved}`) + } + + @StartActiveSpan() + private async purgeOrphanEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.name', group.name) + this.logger.verbose(`Scanning Keycloak environment groups for orphan cleanup: project=${project.slug} groupId=${group.id} groupName=${group.name}`) + + const envGroups = map(this.keycloak.getSubGroups(group.id), envGroup => z.object({ + id: z.string(), + name: z.string(), + }).parse(envGroup)) + + const promises: Promise[] = [] + let orphanCount = 0 + + for await (const envGroup of envGroups) { + span?.setAttribute('keycloak.env_group.id', envGroup.id) + span?.setAttribute('keycloak.env_group.name', envGroup.name) + + const subGroups = await getAll(map(this.keycloak.getSubGroups(envGroup.id), subgroup => z.object({ + name: z.string(), + }).parse(subgroup))) + + if (this.isEnvironmentGroup(subGroups) && !this.isOwnedEnvironmentGroup(project, envGroup)) { + orphanCount++ + this.logger.log(`Deleting orphan Keycloak environment group: project=${project.slug} envGroupId=${envGroup.id} envGroupName=${envGroup.name}`) + promises.push( + this.keycloak.deleteGroup(envGroup.id) + .catch(err => this.logger.warn(`Failed to delete orphan Keycloak environment group: project=${project.slug} envGroupId=${envGroup.id} envGroupName=${envGroup.name} err=${err instanceof Error ? err.message : String(err)}`)), + ) + } + } + + span?.setAttribute('keycloak.env_groups.orphan.count', orphanCount) + await Promise.all(promises) + this.logger.log(`Orphan Keycloak environment group cleanup completed: project=${project.slug} groupId=${group.id} orphanCount=${orphanCount}`) + } + + private isEnvironmentGroup( + subGroups: GroupRepresentationWith<'name'>[], + ) { + return subGroups.some(subgroup => subgroup.name === 'RO' || subgroup.name === 'RW') + } + + private isOwnedEnvironmentGroup( + project: ProjectWithDetails, + group: GroupRepresentationWith<'name'>, + ) { + return project.environments.some(e => e.name === group.name) + } + + private getUserPermissions( + project: ProjectWithDetails, + rolesById: Record, + userId: string, + ) { + if (userId === project.ownerId) return { ro: true, rw: true } + const member = project.members.find(m => m.user.id === userId) + if (!member) return { ro: false, rw: false } + + const projectPermissions = getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) + + return { + ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions }), + rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions }), + } + } +} + +async function* map( + iterable: AsyncIterable, + mapper: (value: T, index: number) => U | Promise, +): AsyncIterable { + let index = 0 + for await (const value of iterable) { + yield await mapper(value, index++) + } +} + +async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts new file mode 100644 index 0000000000..ac56609793 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts @@ -0,0 +1,25 @@ +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import { CONSOLE_GROUP_NAME } from './keycloak.constants' + +type With = T & Required> +export type GroupRepresentationWith = With + +export function isMember(project: ProjectWithDetails, member: UserRepresentation): boolean { + return project.members.some(m => m.user.id === member.id) || project.ownerId === member.id +} + +export function isNonEmptyGroupPath(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +export function normalizeGroupPath(value: unknown): string | undefined { + if (!isNonEmptyGroupPath(value)) return undefined + const trimmed = value.trim() + return trimmed.startsWith('/') ? trimmed : `/${trimmed}` +} + +export function isOwnedProjectGroup(group: GroupRepresentationWith<'subGroups'>): boolean { + return !!group.subGroups.some(sg => sg.name === CONSOLE_GROUP_NAME) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log-queries.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log-queries.utils.ts new file mode 100644 index 0000000000..7c8e6de59f --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log-queries.utils.ts @@ -0,0 +1,30 @@ +import type { Prisma } from '@prisma/client' + +const logSelect = { + id: true, + createdAt: true, + updatedAt: true, + action: true, + userId: true, + requestId: true, + projectId: true, + data: true, +} satisfies Prisma.LogSelect + +export type LogSelect = Prisma.LogGetPayload<{ + select: typeof logSelect +}> + +export function countLogs(tx: Prisma.TransactionClient, projectId: string | null | undefined) { + return tx.log.count({ where: { projectId } }) +} + +export function listLogs(tx: Prisma.TransactionClient, projectId: string | null | undefined, offset: number, limit: number) { + return tx.log.findMany({ + orderBy: { createdAt: 'desc' }, + skip: offset, + take: limit, + where: { projectId }, + select: logSelect, + }) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log-testing.utils.ts new file mode 100644 index 0000000000..1e90dffb17 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log-testing.utils.ts @@ -0,0 +1,16 @@ +import type { Log } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeLog(overrides: Partial = {}): Log { + return { + id: faker.string.uuid(), + data: {}, + action: faker.word.verb(), + userId: null, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + requestId: faker.string.uuid(), + projectId: faker.string.uuid(), + ...overrides, + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.controller.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.controller.spec.ts new file mode 100644 index 0000000000..fa9dfd0118 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.controller.spec.ts @@ -0,0 +1,79 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { ADMIN_PERMS } from '@cpn-console/shared' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectPermissionLoaderService } from '../infrastructure/permission/project/project-loader.service' +import { makeProjectContext } from '../infrastructure/permission/project/project.testing.utils' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { LogController } from './log.controller' +import { LogService } from './log.service' + +describe('logController', () => { + let module: TestingModule + let controller: LogController + let logs: DeepMockProxy + let projectLoader: DeepMockProxy + + beforeEach(async () => { + logs = mockDeep() + projectLoader = mockDeep() + + module = await Test.createTestingModule({ + controllers: [LogController], + providers: [ + { provide: LogService, useValue: logs }, + { provide: ProjectPermissionLoaderService, useValue: projectLoader }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(LogController) + }) + + it('returns logs for system admins without project lookup', async () => { + logs.getLogs.mockResolvedValueOnce({ total: 1, logs: [] }) + + const response = await controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: undefined }, + { userId: 'user-1', adminPermissions: ADMIN_PERMS.LIST_SYSTEM, userType: 'human' }, + ) + + expect(projectLoader.load).not.toHaveBeenCalled() + expect(logs.getLogs).toHaveBeenCalledWith({ limit: 10, offset: 0, clean: false, projectId: undefined }) + expect(response).toEqual({ total: 1, logs: [] }) + }) + + it('forces clean logs for project-scoped non-admin access', async () => { + projectLoader.load.mockResolvedValueOnce(makeProjectContext({ projectPermissions: 1n })) + logs.getLogs.mockResolvedValueOnce({ total: 1, logs: [] }) + + await controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: 'project-1' }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + ) + + expect(projectLoader.load).toHaveBeenCalledTimes(1) + expect(logs.getLogs).toHaveBeenCalledWith({ limit: 10, offset: 0, clean: true, projectId: 'project-1' }) + }) + + it('rejects non-admin access without projectId', async () => { + await expect(controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: undefined }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + )).rejects.toThrow(ForbiddenException) + }) + + it('rejects non-admin access when user has no project permissions', async () => { + projectLoader.load.mockResolvedValueOnce(makeProjectContext({ projectPermissions: 0n })) + + await expect(controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: 'project-1' }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + )).rejects.toThrow(ForbiddenException) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.controller.ts new file mode 100644 index 0000000000..951ee4f472 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.controller.ts @@ -0,0 +1,47 @@ +import type { AdminLogsQuery } from '@cpn-console/shared' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { AdminAuthorized, logContract } from '@cpn-console/shared' +import { Controller, ForbiddenException, Get, Inject, Query, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { ProjectPermissionLoaderService } from '../infrastructure/permission/project/project-loader.service' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { LogService } from './log.service' + +@Controller('api/v1/logs') +@UseGuards(UserGuard) +export class LogController { + constructor( + @Inject(LogService) private readonly logs: LogService, + @Inject(ProjectPermissionLoaderService) private readonly projectLoader: ProjectPermissionLoaderService, + ) {} + + @Get('') + async getLogs( + @Query(new ZodValidationPipe(logContract.getLogs.query)) query: AdminLogsQuery, + @AuthUser() user: UserContext, + ) { + const isSystemAdmin = AdminAuthorized.ListSystem(user.adminPermissions ?? 0n) + let effectiveQuery = query + + if (!isSystemAdmin) { + if (!query.projectId) { + throw new ForbiddenException() + } + + const project = await this.projectLoader.load( + { params: { projectId: query.projectId } } as Parameters[0], + user.userId, + { includePermissions: true, includeLocked: false, includeStatus: false }, + ) + + if (!project.projectPermissions) { + throw new ForbiddenException() + } + + effectiveQuery = { ...query, clean: true } + } + + return this.logs.getLogs(effectiveQuery) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.module.ts new file mode 100644 index 0000000000..f80194cb54 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { LogController } from './log.controller' +import { LogService } from './log.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, UserPermissionModule], + controllers: [LogController], + providers: [LogService], + exports: [LogService], +}) +export class LogModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.service.spec.ts new file mode 100644 index 0000000000..608bafbdbd --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.service.spec.ts @@ -0,0 +1,67 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeLog } from './log-testing.utils' +import { LogService } from './log.service' + +describe('logService', () => { + let module: TestingModule + let service: LogService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + LogService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(LogService) + }) + + it('should map clean logs', async () => { + const dbLogs = [makeLog({ + data: { args: {} }, + action: 'Action', + })] + + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + + const { logs } = await service.getLogs({ + limit: 10, + offset: 10, + clean: true, + projectId: undefined, + }) + + expect(logs[0]).not.toHaveProperty('requestId') + expect(logs[0].data).not.toHaveProperty('results') + expect(logs[0].data).not.toHaveProperty('args') + expect(logs[0].data).not.toHaveProperty('config') + }) + + it('should not filter admin logs', async () => { + const dbLogs = [makeLog({ + data: { args: {} }, + action: 'Action', + })] + + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + + const { logs } = await service.getLogs({ + limit: 10, + offset: 10, + clean: false, + projectId: undefined, + }) + + expect(logs[0].data).toHaveProperty('args') + expect(logs[0].data).not.toHaveProperty('config') + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.service.ts new file mode 100644 index 0000000000..8aa403a41c --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/log/log.service.ts @@ -0,0 +1,74 @@ +import type { AdminLogsQuery } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import { CleanLogSchema, exclude } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { countLogs, listLogs } from './log-queries.utils' + +/** + * Shape of the `data` JSON column, aligned with the LogSchema contract in + * `@cpn-console/shared` so the console can parse and display every entry. + */ +export interface LogData { + /** Payload the action/event was invoked with (e.g. the full project). */ + args?: unknown + /** Names of the services that reported a KO result. */ + failed?: boolean | string[] + /** Per-service outcome, keyed by service name. */ + results?: Record + totalExecutionTime?: number + messageResume?: string + /** Extra keys are persisted as-is, except the sensitive ones stripped by addLog. */ + [key: string]: unknown +} + +interface AddLogArgs { + action: string + data: LogData + userId?: string | null + requestId?: string | null + projectId?: string | null +} + +@Injectable() +export class LogService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + ) {} + + async getLogs(params: AdminLogsQuery) { + const [total, logs] = await this.getLogsWithCount(params.projectId, params.offset, params.limit) + + return { + total, + logs: params.clean + ? logs.map(log => CleanLogSchema.parse(log)) + : logs, + } + } + + async getLogsWithCount(projectId: string | null | undefined, offset: number, limit: number) { + return this.prisma.$transaction([ + countLogs(this.prisma, projectId), + listLogs(this.prisma, projectId, offset, limit), + ]) + } + + async addLog({ + action, + data, + userId = null, + requestId = null, + projectId = null, + }: AddLogArgs) { + return this.prisma.log.create({ + data: { + action, + userId, + requestId, + projectId, + data: exclude(data, ['cluster', 'user', 'newCreds', 'apis', 'config']) as Prisma.InputJsonValue, + }, + }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts new file mode 100644 index 0000000000..5ac2c471b3 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts @@ -0,0 +1,82 @@ +import type { ConfigType } from '@nestjs/config' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { nexusConfigFactory } from '../../config/nexus.config' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { NexusClientService } from './nexus-client.service' +import { NexusHttpClientService } from './nexus-http-client.service' +import { NEXUS_CONFIG } from './nexus.module-definition' + +const nexusUrl = 'https://nexus.internal' + +const server = setupServer() +const nexusAdminPassword = faker.internet.password() +const basicAuth = `Basic ${Buffer.from(`admin:${nexusAdminPassword}`, 'utf8').toString('base64')}` + +describe('nexusClientService', () => { + let service: NexusClientService + let config: DeepMockProxy> + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + beforeEach(async () => { + config = mockDeep>({ + internalUrl: nexusUrl, + admin: 'admin', + adminPassword: nexusAdminPassword, + internalOrPublicUrl: nexusUrl, + }) + + const module = await Test.createTestingModule({ + providers: [ + NexusClientService, + NexusHttpClientService, + { + provide: NEXUS_CONFIG, + useValue: config, + }, + ], + }).compile() + + service = module.get(NexusClientService) + }) + + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should return null on 404 (getRepositoriesMavenHosted)', async () => { + server.use( + http.get(`${nexusUrl}/service/rest/v1/repositories/maven/hosted/:name`, ({ request }) => { + expect(request.headers.get('authorization')).toBe(basicAuth) + return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.getRepositoriesMavenHosted('missing')).resolves.toBeNull() + }) + + it('should send basic auth and plain text body on change-password', async () => { + server.use( + http.put(`${nexusUrl}/service/rest/v1/security/users/:userId/change-password`, async ({ request, params }) => { + expect(request.method).toBe('PUT') + expect(request.url).toBe(`${nexusUrl}/service/rest/v1/security/users/u1/change-password`) + expect(params.userId).toBe('u1') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('content-type')).toContain('text/plain') + expect(await request.text()).toBe('pw123') + return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) + }), + ) + + await service.updateSecurityUsersChangePassword('u1', 'pw123') + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts new file mode 100644 index 0000000000..85f9e81d62 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts @@ -0,0 +1,297 @@ +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { NexusError, NexusHttpClientService } from './nexus-http-client.service' + +interface NexusRepositoryStorage { + blobStoreName: string + strictContentTypeValidation: boolean + writePolicy?: string +} + +interface NexusRepositoryCleanup { + policyNames: string[] +} + +interface NexusRepositoryComponent { + proprietaryComponents: boolean +} + +interface NexusRepositoryGroup { + memberNames: string[] +} + +export interface NexusMavenHostedRepository { + name: string + online: boolean + storage: NexusRepositoryStorage & { writePolicy: string } + cleanup?: NexusRepositoryCleanup + component: NexusRepositoryComponent + maven: { + versionPolicy: string + layoutPolicy: string + contentDisposition: string + } +} + +interface NexusMavenHostedRepositoryUpsertRequest extends NexusMavenHostedRepository {} + +export interface NexusMavenGroupRepository { + name: string + online: boolean + storage: Omit + group: NexusRepositoryGroup +} + +interface NexusMavenGroupRepositoryUpsertRequest extends NexusMavenGroupRepository {} + +export interface NexusNpmHostedRepository { + name: string + online: boolean + storage: NexusRepositoryStorage & { writePolicy: string } + cleanup?: NexusRepositoryCleanup + component: NexusRepositoryComponent +} + +interface NexusNpmHostedRepositoryUpsertRequest extends NexusNpmHostedRepository {} + +export interface NexusNpmGroupRepository { + name: string + online: boolean + storage: Omit + group: NexusRepositoryGroup +} + +interface NexusNpmGroupRepositoryUpsertRequest extends NexusNpmGroupRepository {} + +interface NexusRepositoryViewPrivilege { + name: string + description: string + actions: string[] + format: string + repository: string +} + +interface NexusRepositoryViewPrivilegeUpsertRequest extends NexusRepositoryViewPrivilege {} + +interface NexusRole { + id: string + name: string + privileges: string[] + source?: string + roles?: string[] + description?: string +} + +interface NexusRoleCreateRequest extends NexusRole { + description: string +} + +interface NexusRoleUpdateRequest extends NexusRole {} + +interface NexusUserCreateRequest { + userId: string + firstName: string + lastName: string + emailAddress: string + password: string + status: string + roles: string[] +} + +export interface NexusPrivilege extends NexusRepositoryViewPrivilege { + type: string +} + +@Injectable() +export class NexusClientService { + constructor( + @Inject(NexusHttpClientService) private readonly http: NexusHttpClientService, + ) {} + + @StartActiveSpan() + async getRepositoriesMavenHosted(name: string) { + try { + const res = await this.http.fetch(`repositories/maven/hosted/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createRepositoriesMavenHosted(body: NexusMavenHostedRepositoryUpsertRequest) { + await this.http.fetch('repositories/maven/hosted', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesMavenHosted(name: string, body: NexusMavenHostedRepositoryUpsertRequest) { + await this.http.fetch(`repositories/maven/hosted/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async createRepositoriesMavenGroup(body: NexusMavenGroupRepositoryUpsertRequest) { + await this.http.fetch('repositories/maven/group', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesMavenGroup(name: string, body: NexusMavenGroupRepositoryUpsertRequest) { + await this.http.fetch(`repositories/maven/group/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getRepositoriesMavenGroup(name: string) { + try { + const res = await this.http.fetch(`repositories/maven/group/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async getRepositoriesNpmHosted(name: string) { + try { + const res = await this.http.fetch(`repositories/npm/hosted/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createRepositoriesNpmHosted(body: NexusNpmHostedRepositoryUpsertRequest) { + await this.http.fetch('repositories/npm/hosted', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesNpmHosted(name: string, body: NexusNpmHostedRepositoryUpsertRequest) { + await this.http.fetch(`repositories/npm/hosted/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getRepositoriesNpmGroup(name: string): Promise { + try { + const res = await this.http.fetch(`repositories/npm/group/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async postRepositoriesNpmGroup(body: NexusNpmGroupRepositoryUpsertRequest) { + await this.http.fetch('repositories/npm/group', { method: 'POST', body }) + } + + @StartActiveSpan() + async putRepositoriesNpmGroup(name: string, body: NexusNpmGroupRepositoryUpsertRequest) { + await this.http.fetch(`repositories/npm/group/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getSecurityPrivileges(name: string): Promise { + try { + const res = await this.http.fetch(`security/privileges/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createSecurityPrivilegesRepositoryView(body: NexusRepositoryViewPrivilegeUpsertRequest) { + await this.http.fetch('security/privileges/repository-view', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateSecurityPrivilegesRepositoryView(name: string, body: NexusRepositoryViewPrivilegeUpsertRequest) { + await this.http.fetch(`security/privileges/repository-view/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async deleteSecurityPrivileges(name: string) { + try { + await this.http.fetch(`security/privileges/${name}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async getSecurityRoles(id: string): Promise { + try { + const res = await this.http.fetch(`security/roles/${id}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createSecurityRoles(body: NexusRoleCreateRequest) { + await this.http.fetch('security/roles', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateSecurityRoles(id: string, body: NexusRoleUpdateRequest) { + await this.http.fetch(`security/roles/${id}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async deleteSecurityRoles(id: string) { + try { + await this.http.fetch(`security/roles/${id}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async getSecurityUsers(userId: string): Promise<{ userId: string }[]> { + const query = new URLSearchParams({ userId }).toString() + const res = await this.http.fetch<{ userId: string }[]>(`security/users?${query}`) + return res.data ?? [] + } + + @StartActiveSpan() + async updateSecurityUsersChangePassword(userId: string, password: string) { + await this.http.fetch(`security/users/${userId}/change-password`, { + method: 'PUT', + body: password, + headers: { 'Content-Type': 'text/plain' }, + }) + } + + @StartActiveSpan() + async createSecurityUsers(body: NexusUserCreateRequest) { + await this.http.fetch('security/users', { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSecurityUsers(userId: string) { + try { + await this.http.fetch(`security/users/${userId}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async deleteRepositoriesByName(name: string) { + try { + await this.http.fetch(`repositories/${name}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts new file mode 100644 index 0000000000..ddbb28b966 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts @@ -0,0 +1,66 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './nexus.constants' + +export const projectSelect = { + slug: true, + owner: { + select: { + email: true, + firstName: true, + lastName: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class NexusDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts new file mode 100644 index 0000000000..5914403606 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts @@ -0,0 +1,27 @@ +import type { NexusConfig } from './nexus.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { NEXUS_CONFIG } from './nexus.module-definition' + +@Injectable() +export class NexusHealthService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const credentials = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` + const encoded = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${encoded}` + try { + const response = await fetch(this.nexusConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts new file mode 100644 index 0000000000..8575a4cd18 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts @@ -0,0 +1,129 @@ +import type { NexusConfig } from './nexus.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { NEXUS_CONFIG } from './nexus.module-definition' + +export interface NexusFetchOptions { + method?: string + body?: unknown + headers?: Record +} + +export interface NexusResponse { + status: HttpStatus + data: T | null +} + +export type NexusErrorKind + = | 'NotConfigured' + | 'HttpError' + | 'Unexpected' + +export class NexusError extends Error { + readonly kind: NexusErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + + constructor( + kind: NexusErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string } = {}, + ) { + super(message) + this.name = 'NexusError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + } +} + +@Injectable() +export class NexusHttpClientService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + ) {} + + @StartActiveSpan() + async fetch(path: string, options: NexusFetchOptions = {}): Promise> { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('nexus.method', method) + span?.setAttribute('nexus.path', path) + + const request = this.createRequest(path, method, options.body, options.headers) + const response = await fetch(request).catch((error) => { + throw new NexusError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('nexus.http.status', response.status) + const result = await handleResponse(response) + if (!response.ok) { + throw new NexusError('HttpError', `Request failed: ${method} ${path} responded ${result.status} ${response.statusText}`, { + status: result.status, + method, + path, + statusText: response.statusText, + }) + } + return result + } + + private get baseUrl() { + const url = this.nexusConfig.internalOrPublicUrl + if (!url) { + throw new NexusError('NotConfigured', 'NEXUS_INTERNAL_URL or NEXUS_URL is required') + } + return url + } + + private get apiBaseUrl() { + return new URL('service/rest/v1/', this.baseUrl).toString() + } + + private get basicAuth() { + if (!this.nexusConfig.admin) { + throw new NexusError('NotConfigured', 'NEXUS_ADMIN is required') + } + if (!this.nexusConfig.adminPassword) { + throw new NexusError('NotConfigured', 'NEXUS_ADMIN_PASSWORD is required') + } + const raw = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` + return Buffer.from(raw, 'utf8').toString('base64') + } + + private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record): Request { + const url = new URL(path, this.apiBaseUrl).toString() + const headers: Record = { + Authorization: `Basic ${this.basicAuth}`, + ...extraHeaders, + } + let requestBody: string | undefined + if (body !== undefined) { + if (typeof body === 'string') { + requestBody = body + headers['Content-Type'] = 'text/plain' + } else { + requestBody = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + } + return new Request(url, { method, headers, body: requestBody }) + } +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts new file mode 100644 index 0000000000..eab6e89046 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts @@ -0,0 +1,37 @@ +import type { ConfigType } from '@nestjs/config' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { nexusConfigFactory } from '../../config/nexus.config' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { NexusPluginService } from './nexus-plugin.service' +import { NEXUS_CONFIG } from './nexus.module-definition' + +describe('nexusPluginService', () => { + let service: NexusPluginService + let config: DeepMockProxy> + + beforeEach(async () => { + config = mockDeep>({ + url: 'https://nexus.public/', + internalUrl: 'https://nexus.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + NexusPluginService, + { provide: NEXUS_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(NexusPluginService) + }) + + it('returns the public Nexus url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://nexus.public/') + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts new file mode 100644 index 0000000000..3682cba90e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts @@ -0,0 +1,176 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { NexusConfig } from './nexus.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { NEXUS_CONFIG } from './nexus.module-definition' + +@Injectable() +export class NexusPluginService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'nexus', + to: () => this.nexusConfig.url, + title: 'Nexus', + imgSrc: '/img/nexus.png', + description: 'Nexus permet de gérer les binaires et artefacts de build à travers la chaîne logistique logicielle', + config: { + project: [ + { + key: 'activateNpmRepo', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt NPM', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'npmWritePolicy', + section: 'NPM', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture', + value: 'allow', + description: 'Politique d\'écriture des dépôts NPM, valeurs possibles: allow / allow_once, deny, replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'activateMavenRepo', + section: 'Maven', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt Maven', + value: DISABLED, + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'mavenSnapshotWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Snapshot', + value: 'allow', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'mavenReleaseWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Release', + value: 'allow_once', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + ], + global: [ + { + key: 'platformWriteGroupPaths', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemins des groupes OIDC plateforme en écriture', + value: '/console/admin', + description: 'Liste séparée par des virgules des chemins des groupes OIDC ayant accès en écriture aux dépôts Nexus de la plateforme', + placeholder: '/console/admin', + }, + { + key: 'platformReadGroupPaths', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemins des groupes OIDC plateforme en lecture', + value: '/console/readonly,/console/security', + description: 'Liste séparée par des virgules des chemins des groupes OIDC ayant accès en lecture aux dépôts Nexus de la plateforme', + placeholder: '/console/readonly,/console/security', + }, + { + key: 'projectWriteGroupPathSuffixes', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixes des groupes OIDC projet en écriture', + value: '/console/admin,/console/devops', + description: 'Liste séparée par des virgules des suffixes des chemins des groupes OIDC ayant accès en écriture aux dépôts Nexus du projet', + placeholder: '/console/admin,/console/devops', + }, + { + key: 'projectReadGroupPathSuffixes', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixes des groupes OIDC projet en lecture', + value: '/console/readonly,/console/security,/console/developer', + description: 'Liste séparée par des virgules des suffixes des chemins des groupes OIDC ayant accès en lecture aux dépôts Nexus du projet', + placeholder: '/console/readonly,/console/security,/console/developer', + }, + { + key: 'activateNpmRepoDefaultValue', + section: 'NPM', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Créer un dépôt NPM privé (comportement par défaut des projets)', + value: DISABLED, + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'activateMavenRepoDefaultValue', + section: 'Maven', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Créer un dépôt MAVEN privé (comportement par défaut des projets)', + value: DISABLED, + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'enablePlugin', + kind: 'switch', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Activer/Désactiver entièrement le plugin Nexus', + value: ENABLED, + description: 'Défaut: Activé', + }, + ], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts new file mode 100644 index 0000000000..4cdc734919 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts @@ -0,0 +1,15 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + slug: faker.internet.domainWord(), + owner: { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + }, + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.constants.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.constants.ts new file mode 100644 index 0000000000..151b2e56b1 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.constants.ts @@ -0,0 +1,32 @@ +// Name of the Nexus plugin used throughout the application +export const PLUGIN_NAME = 'nexus' + +// Configuration keys for enabling Maven and NPM repositories +export const NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO = 'activateMavenRepo' +export const NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO = 'activateNpmRepo' + +// Configuration keys for Maven snapshot, release, and NPM write policies +export const NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY = 'mavenSnapshotWritePolicy' +export const NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY = 'mavenReleaseWritePolicy' +export const NEXUS_CONFIG_KEY_NPM_WRITE_POLICY = 'npmWritePolicy' + +// Default write policy values for Maven snapshots, releases, and NPM packages +export const DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY = 'allow' +export const DEFAULT_MAVEN_RELEASE_WRITE_POLICY = 'allow_once' +export const DEFAULT_NPM_WRITE_POLICY = 'allow' + +// Default group paths granting write and read access at the platform level +export const DEFAULT_PLATFORM_WRITE_GROUP_PATHS = '/console/admin' +export const DEFAULT_PLATFORM_READ_GROUP_PATHS = '/console/readonly,/console/security' + +// Default group path suffixes granting write and read access at the project level +export const DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES = '/console/admin,/console/devops' +export const DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES = '/console/readonly,/console/security,/console/developer' + +// Plugin configuration keys for platform-level group paths +export const PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY = 'platformWriteGroupPaths' +export const PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY = 'platformReadGroupPaths' + +// Plugin configuration keys for project-level group path suffixes +export const PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectWriteGroupPathSuffixes' +export const PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectReadGroupPathSuffixes' diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts new file mode 100644 index 0000000000..998c0b3b7a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts @@ -0,0 +1,18 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface NexusConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + secretExposeInternalUrl: boolean + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface NexusModuleOptions extends NexusConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('nexus') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as NEXUS_CONFIG } diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.module.ts new file mode 100644 index 0000000000..25f0e992ce --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from '../../config/base.config' +import { nexusConfigFactory } from '../../config/nexus.config' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { NexusHealthService } from './nexus-health.service' +import { NexusHttpClientService } from './nexus-http-client.service' +import { NexusPluginService } from './nexus-plugin.service' +import { ConfigurableModuleClass } from './nexus.module-definition' +import { NexusService } from './nexus.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule, ConfigModule.forFeature(nexusConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + providers: [ + NexusHealthService, + NexusPluginService, + NexusService, + NexusDatastoreService, + NexusHttpClientService, + NexusClientService, + ], + exports: [NexusClientService, NexusHealthService, NexusPluginService, NexusService], +}) +export class NexusModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts new file mode 100644 index 0000000000..9963b5e4c5 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -0,0 +1,202 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { NexusConfig } from './nexus.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { makeProjectWithDetails } from './nexus-testing.utils' +import { + NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, + NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, + PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, + PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, +} from './nexus.constants' +import { NEXUS_CONFIG } from './nexus.module-definition' +import { NexusService } from './nexus.service' + +describe('nexusService', () => { + let service: NexusService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let config: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + getRepositoriesMavenHosted: vi.fn().mockResolvedValue(null), + getRepositoriesMavenGroup: vi.fn().mockResolvedValue(null), + getRepositoriesNpmHosted: vi.fn().mockResolvedValue(null), + getRepositoriesNpmGroup: vi.fn().mockResolvedValue(null), + getSecurityPrivileges: vi.fn().mockResolvedValue(null), + getSecurityRoles: vi.fn().mockResolvedValue(null), + getSecurityUsers: vi.fn().mockResolvedValue([]), + }) + datastore = mockDeep({ + getAllProjects: vi.fn().mockResolvedValue([]), + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + read: vi.fn().mockRejectedValue(new VaultError('NotFound', 'Not Found')), + }) + config = mockDeep({}) + baseConfig = mockDeep({ projectsRootDir: 'forge' }) + vaultConfig = mockDeep({}) + + const module = await Test.createTestingModule({ + providers: [ + NexusService, + { provide: NexusClientService, useValue: client }, + { provide: NexusDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: NEXUS_CONFIG, useValue: config }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + ], + }).compile() + + service = module.get(NexusService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('handleUpsert should reconcile based on computed flags', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: DISABLED }, + ], + }) + + await service.handleUpsert(project) + + expect(client.createRepositoriesMavenHosted).toHaveBeenCalled() + expect(client.deleteRepositoriesByName).toHaveBeenCalled() + expect(vault.write).toHaveBeenCalledWith( + expect.objectContaining({ + NEXUS_USERNAME: project.slug, + NEXUS_PASSWORD: expect.any(String), + }), + `forge/${project.slug}/tech/NEXUS`, + ) + }) + + it('handleDelete should delete project', async () => { + const project = makeProjectWithDetails() + await service.handleDelete(project) + expect(client.deleteSecurityRoles).toHaveBeenCalledWith(`${project.slug}-ID`) + expect(client.deleteSecurityUsers).toHaveBeenCalledWith(project.slug) + expect(vault.delete).toHaveBeenCalledWith(`forge/${project.slug}/tech/NEXUS`) + }) + + it('handleCron should reconcile all projects', async () => { + const projects = [ + makeProjectWithDetails({ plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }] }), + makeProjectWithDetails({ plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }] }), + ] + + datastore.getAllProjects.mockResolvedValue(projects) + + await service.handleCron() + + expect(client.createSecurityUsers).toHaveBeenCalledTimes(2) + }) + + it('reuses existing vault password and does not rotate Nexus user password', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + }) + + datastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { + if (key === PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) return ' ' + if (key === PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) return ' ' + return null + }) + + vault.read.mockResolvedValue(makeVaultSecret({ data: { NEXUS_PASSWORD: 'existing' } })) + client.getSecurityUsers.mockResolvedValue([{ userId: project.slug }]) + + await service.handleUpsert(project) + + expect(client.updateSecurityUsersChangePassword).not.toHaveBeenCalled() + expect(client.createSecurityUsers).not.toHaveBeenCalled() + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + NEXUS_USERNAME: project.slug, + NEXUS_PASSWORD: 'existing', + }), `forge/${project.slug}/tech/NEXUS`) + }) + + it('deletes group repos before their hosted members', async () => { + const project = makeProjectWithDetails() + + await service.handleDelete(project) + + const deletedRepos = client.deleteRepositoriesByName.mock.calls.map(([name]) => name) + expect(deletedRepos.indexOf(`${project.slug}-repository-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-repository-snapshot`)) + expect(deletedRepos.indexOf(`${project.slug}-repository-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-repository-release`)) + expect(deletedRepos.indexOf(`${project.slug}-npm-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-npm`)) + }) + + it('tolerates platform role failures when ensuring platform roles', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + }) + const staleProject = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }], + }) + + datastore.getAllProjects.mockResolvedValue([project, staleProject]) + client.createSecurityRoles.mockImplementation(async (body) => { + if (body.id.startsWith('console-')) throw new Error('Request failed: POST security/roles responded 400 Bad Request') + }) + + await expect(service.handleUpsert(project)).resolves.not.toThrow() + + expect(client.createSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({ id: 'console-admin' })) + }) + + it('dedupes project group roles by role id and keeps the highest privileges', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, + { pluginName: PLUGIN_NAME, key: PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, + ], + }) + + datastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { + if (key === PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) return ' ' + if (key === PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) return ' ' + return null + }) + + await service.handleUpsert(project) + + expect(client.createSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({ + id: `${project.slug}-console-devops`, + privileges: expect.arrayContaining([`${project.slug}-privilege-group`]), + })) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.service.ts new file mode 100644 index 0000000000..52061ace32 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -0,0 +1,696 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { NexusPrivilege } from './nexus-client.service' +import type { ProjectWithDetails } from './nexus-datastore.service' +import type { NexusConfig } from './nexus.module-definition' +import type { + MavenHostedRepoKind, +} from './nexus.utils' +import { specificallyEnabled } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { + DEFAULT_MAVEN_RELEASE_WRITE_POLICY, + DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY, + DEFAULT_NPM_WRITE_POLICY, + DEFAULT_PLATFORM_READ_GROUP_PATHS, + DEFAULT_PLATFORM_WRITE_GROUP_PATHS, + DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES, + NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, + NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, + NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY, + NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY, + NEXUS_CONFIG_KEY_NPM_WRITE_POLICY, + PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, + PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, +} from './nexus.constants' +import { NEXUS_CONFIG } from './nexus.module-definition' +import { + generateMavenHostedRepoName, + generateNpmHostedRepoName, + generateRandomPassword, + getPluginConfig, + getProjectVaultPath, +} from './nexus.utils' + +export interface EnsureMavenReposOptions { + snapshotWritePolicy: string + releaseWritePolicy: string +} + +@Injectable() +export class NexusService { + private readonly logger = new Logger(NexusService.name) + + constructor( + @Inject(NexusDatastoreService) private readonly datastore: NexusDatastoreService, + @Inject(NexusClientService) private readonly client: NexusClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) { + this.logger.log('NexusService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('nexus', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + await this.ensureProject(project) + const projects = await this.datastore.getAllProjects() + await this.ensurePlatformRoles(projects) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('nexus', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.deleteProject(project) + const projects = await this.datastore.getAllProjects() + await this.ensurePlatformRoles(projects) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Nexus reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('nexus.projects.count', projects.length) + await this.ensureProjects(projects) + await this.ensurePlatformRoles(projects) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('nexus.projects.count', projects.length) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const enableMaven = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO)) ?? false + const enableNpm = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO)) ?? false + + const mavenSnapshotWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY) ?? DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY + const mavenReleaseWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY) ?? DEFAULT_MAVEN_RELEASE_WRITE_POLICY + const npmWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_NPM_WRITE_POLICY) ?? DEFAULT_NPM_WRITE_POLICY + + await Promise.all([ + enableMaven + ? this.ensureMavenRepos(project, { snapshotWritePolicy: mavenSnapshotWritePolicy, releaseWritePolicy: mavenReleaseWritePolicy }) + : this.deleteMavenRepos(project), + enableNpm + ? this.ensureNpmRepos(project, npmWritePolicy) + : this.deleteNpmRepos(project), + ]) + + const privileges = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + ] + : []), + ] + const readOnlyPrivileges = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + : []), + ] + await this.ensureRole(project, privileges) + await this.ensureUser(project) + await this.ensureProjectGroupRoles(project, { readOnlyPrivileges, writePrivileges: privileges }) + } + + private async upsertPrivilege(body: NexusPrivilege) { + const existing = await this.client.getSecurityPrivileges(body.name) + if (!existing) { + await this.client.createSecurityPrivilegesRepositoryView(body) + return + } + await this.client.updateSecurityPrivilegesRepositoryView(body.name, body) + } + + private async ensureMavenHostedRepo(repoName: string, writePolicy: string) { + const existing = await this.client.getRepositoriesMavenHosted(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + } + if (!existing) { + await this.client.createRepositoriesMavenHosted(body) + return + } + await this.client.updateRepositoriesMavenHosted(repoName, body) + } + + private async ensureNpmHostedRepo(repoName: string, writePolicy: string) { + const existing = await this.client.getRepositoriesNpmHosted(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + } + if (!existing) { + await this.client.createRepositoriesNpmHosted(body) + return + } + await this.client.updateRepositoriesNpmHosted(repoName, body) + } + + private async ensureNpmGroupRepo(repoName: string, memberNames: string[]) { + const existing = await this.client.getRepositoriesNpmGroup(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames, + }, + } + if (!existing) { + await this.client.postRepositoriesNpmGroup(body) + return + } + await this.client.putRepositoriesNpmGroup(repoName, body) + } + + private async ensureMavenHostedRepos(args: { + releaseRepoName: string + snapshotRepoName: string + releaseWritePolicy: string + snapshotWritePolicy: string + }) { + await Promise.all([ + this.ensureMavenHostedRepo(args.snapshotRepoName, args.snapshotWritePolicy), + this.ensureMavenHostedRepo(args.releaseRepoName, args.releaseWritePolicy), + ]) + } + + private async ensureMavenRepos(project: ProjectWithDetails, options: EnsureMavenReposOptions) { + const releaseRepoName = generateMavenHostedRepoName(project, 'release') + const snapshotRepoName = generateMavenHostedRepoName(project, 'snapshot') + const groupRepoName = generateMavenGroupRepoName(project) + + const releasePrivilege = generateMavenHostedPrivilegeName(project, 'release') + const snapshotPrivilege = generateMavenHostedPrivilegeName(project, 'snapshot') + const groupPrivilege = generateMavenGroupPrivilegeName(project) + const releasePrivilegeReadonly = generateMavenHostedPrivilegeNameReadonly(project, 'release') + const snapshotPrivilegeReadonly = generateMavenHostedPrivilegeNameReadonly(project, 'snapshot') + const groupPrivilegeReadonly = generateMavenGroupPrivilegeNameReadonly(project) + + await this.ensureMavenHostedRepos({ + releaseRepoName, + snapshotRepoName, + releaseWritePolicy: options.releaseWritePolicy, + snapshotWritePolicy: options.snapshotWritePolicy, + }) + + await this.ensureMavenGroupRepo( + groupRepoName, + [releaseRepoName, snapshotRepoName, 'maven-public'], + ) + + const privilegesToEnsureWrite = [ + { repo: releaseRepoName, privilege: releasePrivilege }, + { repo: snapshotRepoName, privilege: snapshotPrivilege }, + { repo: groupRepoName, privilege: groupPrivilege }, + ] + const privilegesToEnsureReadonly = [ + { repo: releaseRepoName, privilege: releasePrivilegeReadonly }, + { repo: snapshotRepoName, privilege: snapshotPrivilegeReadonly }, + { repo: groupRepoName, privilege: groupPrivilegeReadonly }, + ] + await Promise.all([ + this.ensureRepositoryViewPrivileges({ + project, + type: 'maven', + format: 'maven2', + entries: privilegesToEnsureWrite, + actions: ['all'], + }), + this.ensureRepositoryViewPrivileges({ + project, + type: 'maven', + format: 'maven2', + entries: privilegesToEnsureReadonly, + actions: ['read', 'browse'], + }), + ]) + } + + private async ensureMavenGroupRepo(repoName: string, memberNames: string[]) { + const existing = await this.client.getRepositoriesMavenGroup(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames, + }, + } + if (!existing) { + await this.client.createRepositoriesMavenGroup(body) + return + } + await this.client.updateRepositoriesMavenGroup(repoName, body) + } + + private async ensureRepositoryViewPrivileges(args: { + project: ProjectWithDetails + type: string + format: string + entries: Array<{ repo: string, privilege: string }> + actions: string[] + }) { + for (const entry of args.entries) { + await this.upsertPrivilege({ + type: args.type, + name: entry.privilege, + description: `Privilege for organization ${args.project.slug} for repo ${entry.repo}`, + actions: args.actions, + format: args.format, + repository: entry.repo, + }) + } + } + + private async deleteMavenRepos(project: ProjectWithDetails) { + const hostedRepoNames = [ + generateMavenHostedRepoName(project, 'release'), + generateMavenHostedRepoName(project, 'snapshot'), + ] + const privileges = [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + await Promise.all(privileges.map(privilege => this.client.deleteSecurityPrivileges(privilege))) + // Nexus responds 500 when deleting a hosted repo still referenced by a group, so delete the group first + await this.client.deleteRepositoriesByName(generateMavenGroupRepoName(project)) + await Promise.all(hostedRepoNames.map(repo => this.client.deleteRepositoriesByName(repo))) + } + + private async ensureNpmRepos(project: ProjectWithDetails, writePolicy: string) { + const hostedRepoName = generateNpmHostedRepoName(project) + const groupRepoName = generateNpmGroupRepoName(project) + + const hostedPrivilege = generateNpmHostedPrivilegeName(project) + const groupPrivilege = generateNpmGroupPrivilegeName(project) + const hostedPrivilegeReadonly = generateNpmHostedPrivilegeNameReadonly(project) + const groupPrivilegeReadonly = generateNpmGroupPrivilegeNameReadonly(project) + + await this.ensureNpmHostedRepo(hostedRepoName, writePolicy) + await this.ensureNpmGroupRepo(groupRepoName, [hostedRepoName]) + + const privilegesToEnsureWrite = [ + { repo: hostedRepoName, privilege: hostedPrivilege }, + { repo: groupRepoName, privilege: groupPrivilege }, + ] + const privilegesToEnsureReadonly = [ + { repo: hostedRepoName, privilege: hostedPrivilegeReadonly }, + { repo: groupRepoName, privilege: groupPrivilegeReadonly }, + ] + await Promise.all([ + this.ensureRepositoryViewPrivileges({ + project, + type: 'npm', + format: 'npm', + entries: privilegesToEnsureWrite, + actions: ['all'], + }), + this.ensureRepositoryViewPrivileges({ + project, + type: 'npm', + format: 'npm', + entries: privilegesToEnsureReadonly, + actions: ['read', 'browse'], + }), + ]) + } + + private async deleteNpmRepos(project: ProjectWithDetails) { + const privileges = [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + await Promise.all(privileges.map(privilege => this.client.deleteSecurityPrivileges(privilege))) + // Nexus responds 500 when deleting a hosted repo still referenced by a group, so delete the group first + await this.client.deleteRepositoriesByName(generateNpmGroupRepoName(project)) + await this.client.deleteRepositoriesByName(generateNpmHostedRepoName(project)) + } + + private async ensureRole(project: ProjectWithDetails, privileges: string[]) { + const roleId = `${project.slug}-ID` + const role = await this.client.getSecurityRoles(roleId) + if (!role) { + await this.client.createSecurityRoles({ + id: roleId, + name: `${project.slug}-role`, + description: 'desc', + privileges, + }) + return + } + await this.client.updateSecurityRoles(roleId, { + id: roleId, + name: `${project.slug}-role`, + privileges, + }) + } + + private async ensureUser(project: ProjectWithDetails) { + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') + let existingPassword: string | undefined + try { + existingPassword = await this.vault.read(vaultPath).then(res => res.data?.NEXUS_PASSWORD) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') { + existingPassword = undefined + } else { + throw error + } + } + + const ensuredPassword = existingPassword ?? generateRandomPassword(30) + const users = await this.client.getSecurityUsers(project.slug) + const existing = users.find(u => u.userId === project.slug) + if (existing) { + if (!existingPassword || existingPassword !== ensuredPassword) { + await this.client.updateSecurityUsersChangePassword(project.slug, ensuredPassword) + } + } else { + await this.client.createSecurityUsers({ + userId: project.slug, + firstName: project.owner.firstName, + lastName: project.owner.lastName, + emailAddress: project.owner.email, + password: ensuredPassword, + status: 'active', + roles: [`${project.slug}-ID`], + }) + } + + await this.vault.write({ + NEXUS_PASSWORD: ensuredPassword, + NEXUS_USERNAME: project.slug, + }, vaultPath) + } + + private async ensureSecurityRole(id: string, privileges: string[]) { + const role = await this.client.getSecurityRoles(id) + if (!role) { + await this.client.createSecurityRoles({ + id, + name: id, + description: 'desc', + privileges, + }) + return + } + await this.client.updateSecurityRoles(id, { + id, + name: id, + privileges, + }) + } + + private async getOptionalConfigValue(project: ProjectWithDetails, key: string) { + const projectValue = getPluginConfig(project, key) + if (projectValue) return projectValue + return await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + } + + private async ensureProjectGroupRoles(project: ProjectWithDetails, args: { readOnlyPrivileges: string[], writePrivileges: string[] }) { + const rawWriteSuffixes = await this.getOptionalConfigValue(project, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES + + const rawReadSuffixes = await this.getOptionalConfigValue(project, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES + + const writeGroupPaths = generateProjectRoleGroupPath(project, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES) + const readGroupPaths = generateProjectRoleGroupPath(project, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES) + + const byId = generateRolePrivilegesMapping({ + readGroupPaths, + writeGroupPaths, + readOnlyPrivileges: args.readOnlyPrivileges, + writePrivileges: args.writePrivileges, + }) + + await Promise.all(Array.from(byId.entries(), ([id, privileges]) => this.ensureSecurityRole(id, privileges))) + } + + private async ensurePlatformRoles(projects: ProjectWithDetails[]) { + const rawWriteGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) + ?? DEFAULT_PLATFORM_WRITE_GROUP_PATHS + + const rawReadGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) + ?? DEFAULT_PLATFORM_READ_GROUP_PATHS + + const readonlyPrivileges = new Set() + const writePrivileges = new Set() + for (const project of projects) { + const computed = computeProjectPrivileges(project) + for (const privilege of computed.readOnly) readonlyPrivileges.add(privilege) + for (const privilege of computed.write) writePrivileges.add(privilege) + } + + const byId = generateRolePrivilegesMapping({ + readGroupPaths: parseOidcGroupPaths(rawReadGroupPaths || DEFAULT_PLATFORM_READ_GROUP_PATHS), + writeGroupPaths: parseOidcGroupPaths(rawWriteGroupPaths || DEFAULT_PLATFORM_WRITE_GROUP_PATHS), + readOnlyPrivileges: [...readonlyPrivileges], + writePrivileges: [...writePrivileges], + }) + + // Platform roles aggregate privileges of every Nexus-enabled project; a stale + // project (e.g. privileges missing on this instance) must not block the current one. + const entries = Array.from(byId.entries()) + const results = await Promise.allSettled(entries.map(([id, privileges]) => this.ensureSecurityRole(id, privileges))) + results.forEach((result, index) => { + if (result.status === 'rejected') { + this.logger.warn(`Failed to ensure platform role ${entries[index][0]}: ${result.reason instanceof Error ? result.reason.message : result.reason}`) + } + }) + } + + private async deleteProjectGroupRoles(project: ProjectWithDetails) { + const rawWriteSuffixes = await this.getOptionalConfigValue(project, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES + + const rawReadSuffixes = await this.getOptionalConfigValue(project, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES + + const groupPaths = [ + ...generateProjectRoleGroupPath(project, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES), + ...generateProjectRoleGroupPath(project, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES), + ] + + const ids = [...new Set(groupPaths.map(generateRoleId))] + await Promise.all(ids.map(id => this.client.deleteSecurityRoles(id))) + } + + @StartActiveSpan() + private async deleteProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + await Promise.all([ + this.deleteMavenRepos(project), + this.deleteNpmRepos(project), + ]) + + await Promise.all([ + this.deleteProjectGroupRoles(project), + this.client.deleteSecurityRoles(`${project.slug}-ID`), + this.client.deleteSecurityUsers(project.slug), + ]) + + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') + try { + await this.vault.delete(vaultPath) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + } +} + +function generateMavenHostedPrivilegeName(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${project.slug}-privilege-${kind}` +} + +function generateMavenGroupRepoName(project: ProjectWithDetails) { + return `${project.slug}-repository-group` +} + +function generateMavenGroupPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-privilege-group` +} + +function generateNpmHostedPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-npm-privilege` +} + +function generateNpmGroupRepoName(project: ProjectWithDetails) { + return `${project.slug}-npm-group` +} + +function generateNpmGroupPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-npm-group-privilege` +} + +function generateMavenHostedPrivilegeNameReadonly(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${generateMavenHostedPrivilegeName(project, kind)}-ro` +} + +function generateMavenGroupPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateMavenGroupPrivilegeName(project)}-ro` +} + +function generateNpmHostedPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateNpmHostedPrivilegeName(project)}-ro` +} + +function generateNpmGroupPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateNpmGroupPrivilegeName(project)}-ro` +} + +function generateProjectRoleGroupPath(project: ProjectWithDetails, rawGroupPathSuffixes: string) { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${project.slug}${path}`) +} + +function parseOidcGroupPaths(rawGroupPaths: string) { + return rawGroupPaths + .split(',') + .map(path => path.trim()) + .filter(Boolean) +} + +function generateRolePrivilegesMapping(args: { readGroupPaths: string[], writeGroupPaths: string[], readOnlyPrivileges: string[], writePrivileges: string[] }) { + const byId = new Map() + for (const groupPath of args.readGroupPaths) byId.set(generateRoleId(groupPath), args.readOnlyPrivileges) + for (const groupPath of args.writeGroupPaths) byId.set(generateRoleId(groupPath), args.writePrivileges) + return byId +} + +function generateRoleId(groupPath: string) { + const trimmed = groupPath.trim() + const withoutLeadingSlash = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed + return withoutLeadingSlash.replaceAll('/', '-') +} + +function computeProjectPrivileges(project: ProjectWithDetails) { + const enableMaven = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO)) ?? false + const enableNpm = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO)) ?? false + + const write = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + ] + : []), + ] + + const readOnly = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + : []), + ] + + return { readOnly, write } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.utils.ts new file mode 100644 index 0000000000..6e9e0e7645 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/nexus/nexus.utils.ts @@ -0,0 +1,26 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { randomBytes } from 'node:crypto' + +export function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +export function generateRandomPassword(length: number) { + const raw = randomBytes(Math.ceil(length * 0.75)).toString('base64url') + return raw.slice(0, length) +} + +export function getProjectVaultPath(projectRootDir: string, projectSlug: string, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return `${projectRootDir}/${projectSlug}/${normalized}` +} + +export type MavenHostedRepoKind = 'release' | 'snapshot' + +export function generateMavenHostedRepoName(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${project.slug}-repository-${kind}` +} + +export function generateNpmHostedRepoName(project: ProjectWithDetails) { + return `${project.slug}-npm` +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts new file mode 100644 index 0000000000..5f008fd21e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts @@ -0,0 +1,25 @@ +import type { OpenCdsConfig } from './opencds.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { OPENCDS_CONFIG } from './opencds.module-definition' + +@Injectable() +export class OpenCdsHealthService { + constructor( + @Inject(OPENCDS_CONFIG) private readonly opencdsConfig: OpenCdsConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const headers: Record = {} + headers.Authorization = `Bearer ${this.opencdsConfig.apiToken}` + const response = await fetch(this.opencdsConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts new file mode 100644 index 0000000000..5a31df9912 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts @@ -0,0 +1,16 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface OpenCdsConfig { + url: string + internalUrl: string + probeUrl?: string + apiToken: string + apiTlsRejectUnauthorized: boolean +} + +export interface OpenCdsModuleOptions extends OpenCdsConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('opencds') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as OPENCDS_CONFIG } diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/opencds/opencds.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/opencds/opencds.module.ts new file mode 100644 index 0000000000..edc85a987a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/opencds/opencds.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { OpenCdsHealthService } from './opencds-health.service' +import { ConfigurableModuleClass } from './opencds.module-definition' + +@Module({ + imports: [TerminusModule], + providers: [OpenCdsHealthService], + exports: [OpenCdsHealthService], +}) +export class OpenCdsModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts new file mode 100644 index 0000000000..1ef4cac975 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts @@ -0,0 +1,65 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginModule } from './plugin.module' +import { PluginService } from './plugin.service' + +describe('pluginModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits PluginService when USE_ARGOCD=false', async () => { + vi.stubEnv('USE_ARGOCD', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_ARGOCD')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_GITLAB=false', async () => { + vi.stubEnv('USE_GITLAB', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_GITLAB')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_REGISTRY=false', async () => { + vi.stubEnv('USE_REGISTRY', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_REGISTRY')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_KEYCLOAK=false', async () => { + vi.stubEnv('USE_KEYCLOAK', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_KEYCLOAK')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_NEXUS=false', async () => { + vi.stubEnv('USE_NEXUS', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_NEXUS')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_SONARQUBE=false', async () => { + vi.stubEnv('USE_SONARQUBE', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_SONARQUBE')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_VAULT')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.module.ts new file mode 100644 index 0000000000..d74a259e4e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { ArgoCDModule } from '../argocd/argocd.module' +import { GitlabModule } from '../gitlab/gitlab.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { NexusModule } from '../nexus/nexus.module' +import { RegistryModule } from '../registry/registry.module' +import { SonarqubeModule } from '../sonarqube/sonarqube.module' +import { VaultModule } from '../vault/vault.module' +import { PluginService } from './plugin.service' + +@Module({ + imports: [ + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + ConditionalModule.registerWhen(RegistryModule, 'USE_REGISTRY'), + KeycloakModule, + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(SonarqubeModule, 'USE_SONARQUBE'), + VaultModule, + ], + providers: [PluginService], + exports: [PluginService], +}) +export class PluginModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.service.ts new file mode 100644 index 0000000000..1b9303ee53 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.service.ts @@ -0,0 +1,46 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { ArgoCDPluginService } from '../argocd/argocd-plugin.service' +import { GitlabPluginService } from '../gitlab/gitlab-plugin.service' +import { KeycloakPluginService } from '../keycloak/keycloak-plugin.service' +import { NexusPluginService } from '../nexus/nexus-plugin.service' +import { RegistryPluginService } from '../registry/registry-plugin.service' +import { SonarqubePluginService } from '../sonarqube/sonarqube-plugin.service' +import { VaultPluginService } from '../vault/vault-plugin.service' + +@Injectable() +export class PluginService { + private readonly logger = new Logger(PluginService.name) + + constructor( + @Inject(ArgoCDPluginService) private readonly argoCDPlugin: ArgoCDPluginService, + @Inject(GitlabPluginService) private readonly gitlabPlugin: GitlabPluginService, + @Inject(RegistryPluginService) private readonly registryPlugin: RegistryPluginService, + @Inject(KeycloakPluginService) private readonly keycloakPlugin: KeycloakPluginService, + @Inject(NexusPluginService) private readonly nexusPlugin: NexusPluginService, + @Inject(SonarqubePluginService) private readonly sonarqubePlugin: SonarqubePluginService, + @Inject(VaultPluginService) private readonly vaultPlugin: VaultPluginService, + ) {} + + async infos(projectId: string): Promise { + const plugins = [ + ['argocd', () => this.argoCDPlugin.infos()], + ['gitlab', () => this.gitlabPlugin.infos()], + ['registry', () => this.registryPlugin.infos(projectId)], + ['keycloak', () => this.keycloakPlugin.infos()], + ['nexus', () => this.nexusPlugin.infos()], + ['sonarqube', () => this.sonarqubePlugin.infos()], + ['vault', () => this.vaultPlugin.infos()], + ] as const + + const settled = await Promise.allSettled(plugins.map(([, loadInfos]) => loadInfos())) + return settled.flatMap((result, index) => { + const [pluginName] = plugins[index] + if (result.status === 'fulfilled') { + return [result.value] + } + this.logger.warn(`Skipping project service plugin ${pluginName} because infos() failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`) + return [] + }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts new file mode 100644 index 0000000000..3a2ecd238e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { capturePluginResult, getFailedPlugins, mergePluginResults } from './plugin.utils' + +describe('capturePluginResult', () => { + it('should resolve a successful task into an OK result with the default message, keyed under the plugin name', async () => { + await expect(capturePluginResult('gitlab', async () => {})).resolves.toEqual({ + gitlab: { + status: 'OK', + message: 'Up to date', + executionTime: expect.any(Number), + }, + }) + }) + + it('should use the string returned by the task as message', async () => { + await expect(capturePluginResult('gitlab', async () => 'Everything synced')).resolves.toEqual({ + gitlab: expect.objectContaining({ status: 'OK', message: 'Everything synced' }), + }) + }) + + it('should resolve a throwing task into a KO result instead of rejecting', async () => { + const task = async () => { + throw new Error('GitLab unreachable') + } + await expect(capturePluginResult('gitlab', task)).resolves.toEqual({ + gitlab: { + status: 'KO', + message: 'GitLab unreachable', + executionTime: expect.any(Number), + error: new Error('GitLab unreachable'), + }, + }) + }) + + it('should report a fallback message for non-Error throws', async () => { + const task = async () => { + throw 'boom' // eslint-disable-line no-throw-literal + } + await expect(capturePluginResult('gitlab', task)).resolves.toEqual({ + gitlab: expect.objectContaining({ status: 'KO', message: 'Erreur inconnue', error: 'boom' }), + }) + }) + + it('should report under the given plugin name', async () => { + await expect(capturePluginResult('nexus', async () => 'custom message')).resolves.toEqual({ + nexus: expect.objectContaining({ status: 'OK', message: 'custom message' }), + }) + }) +}) + +describe('mergePluginResults', () => { + it('should return empty object when given empty array', () => { + expect(mergePluginResults([])).toEqual({}) + }) + + it('should return single result as-is', () => { + const result = [{ argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }] + expect(mergePluginResults(result)).toEqual(result[0]) + }) + + it('should merge multiple results into one', () => { + const results = [ + { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }, + { gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 } }, + ] + expect(mergePluginResults(results)).toEqual({ + argocd: { status: 'OK', message: 'Up to date', executionTime: 10 }, + gitlab: { status: 'OK', message: 'Synced', executionTime: 20 }, + }) + }) + + it('should have later entries overwrite earlier ones for the same plugin', () => { + const error = new Error('sync error') + const results = [ + { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }, + { argocd: { status: 'KO' as const, message: 'Failed', executionTime: 20, error } }, + ] + expect(mergePluginResults(results)).toEqual({ + argocd: { status: 'KO', message: 'Failed', executionTime: 20, error }, + }) + }) +}) + +describe('getFailedPlugins', () => { + it('should return empty array when all plugins are OK', () => { + const results = { + argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 }, + gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 }, + } + expect(getFailedPlugins(results)).toEqual([]) + }) + + it('should return names of KO plugins', () => { + const error = new Error('sync error') + const results = { + argocd: { status: 'KO' as const, message: 'Failed', executionTime: 10, error }, + gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 }, + } + expect(getFailedPlugins(results)).toEqual(['argocd']) + }) + + it('should return all plugins when all fail', () => { + const error = new Error('error') + const results = { + argocd: { status: 'KO' as const, message: 'Failed', executionTime: 10, error }, + gitlab: { status: 'KO' as const, message: 'Failed', executionTime: 20, error }, + } + const failed = getFailedPlugins(results) + expect(failed).toHaveLength(2) + expect(failed).toContain('argocd') + expect(failed).toContain('gitlab') + }) + + it('should return empty array for empty result', () => { + expect(getFailedPlugins({})).toEqual([]) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.utils.ts new file mode 100644 index 0000000000..a54447a6a0 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/plugin/plugin.utils.ts @@ -0,0 +1,107 @@ +import type { ToUrlFnParamaters } from '@cpn-console/hooks' +import { Logger } from '@nestjs/common' + +export function makeToUrlParams(overrides: Partial = {}): ToUrlFnParamaters { + return { + store: {}, + clusters: [], + zones: [], + environments: [], + project: { id: '', slug: 'dulei', name: '' }, + ...overrides, + } +} +export type PluginName = 'argocd' + | 'gitlab' + | 'nexus' + | 'vault' + | 'keycloak' + | 'harbor' + | 'sonarqube' + | 'observability' + +export type PluginResult = { + status: 'OK' + message: string + executionTime: number +} | { + status: 'KO' + message: string + executionTime: number + error: unknown +} + +export type PluginResults = Partial> + +export type RequiredPluginResult + = { [K in T]: PluginResult } & PluginResults + +const logger = new Logger('PluginResult') + +/** + * Runs a plugin task and always resolves with the task's outcome (status, + * message, execution time and error, if any) keyed under the given plugin + * name, instead of throwing, so the event emitter can merge and persist the + * results of every listener. + * + * The task keeps its natural shape: do the work, throw on failure, optionally + * return a message string to override the default 'Up to date'. + * + * The `@OnEvent` handler stays a thin, honestly-typed wrapper, while the + * traced work method keeps `@StartActiveSpan` so the span still records the + * exception on failure: + * + * ```ts + * @OnEvent('project.upsert') + * async handleUpsert(project: ProjectWithDetails): Promise> { + * return capturePluginResult('argocd', () => this.syncProject(project)) + * } + * + * @StartActiveSpan() + * private async syncProject(project: ProjectWithDetails) { ... } + * ``` + */ +export async function capturePluginResult

( + plugin: P, + task: () => Promise, +): Promise> { + const start = process.hrtime.bigint() + const elapsedMs = () => Number(process.hrtime.bigint() - start) / 1_000_000 + + try { + const message = await task() + return keyedBy(plugin, { + status: 'OK', + message: typeof message === 'string' ? message : 'Up to date', + executionTime: elapsedMs(), + }) + } catch (error: unknown) { + logger.error(`${plugin} handler failed`, error) + return keyedBy(plugin, { + status: 'KO', + message: error instanceof Error ? error.message : 'Erreur inconnue', + executionTime: elapsedMs(), + error, + }) + } +} + +// TypeScript widens a computed property with a generic key to an index +// signature instead of Record, so the assertion is confined here. +function keyedBy

(plugin: P, result: PluginResult): RequiredPluginResult

{ + return { [plugin]: result } as RequiredPluginResult

+} + +export function mergePluginResults(responses: PluginResults[]): PluginResults { + return responses.reduce((merged, currentResponse) => { + return { ...merged, ...currentResponse } + }, {} as PluginResults) +} + +export function getFailedPlugins(response: PluginResults): PluginName[] { + const entries = Object.entries(response) as [PluginName, PluginResult][] + + return entries + .filter(([_, result]) => result.status === 'KO') + .map(([pluginName]) => pluginName) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts new file mode 100644 index 0000000000..73858d492b --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts @@ -0,0 +1,16 @@ +import type { Prisma } from '@prisma/client' + +export const projectNotArchivedSelect = { + id: true, +} satisfies Prisma.ProjectSelect + +export type ProjectIdNotArchived = Prisma.ProjectGetPayload<{ + select: typeof projectNotArchivedSelect +}> + +export function listProjectIdsNotArchived(tx: Prisma.TransactionClient): Promise { + return tx.project.findMany({ + select: projectNotArchivedSelect, + where: { status: { not: 'archived' } }, + }) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts new file mode 100644 index 0000000000..82874da42d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts @@ -0,0 +1,36 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { projectContract } from '@cpn-console/shared' +import { Body, Controller, HttpCode, HttpStatus, Inject, Logger, Post, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectBulkService } from './project-bulk.service' + +@Controller('api/v1/projects-bulk') +export class ProjectBulkController { + private readonly logger = new Logger(ProjectBulkController.name) + + constructor( + @Inject(ProjectBulkService) private readonly projectBulk: ProjectBulkService, + ) {} + + @Post('') + @HttpCode(HttpStatus.ACCEPTED) + @UseGuards(UserGuard) + @RequireAdminPermission('Manage') + async bulkAction( + @Body(new ZodValidationPipe(projectContract.bulkActionProject.body)) body: typeof projectContract.bulkActionProject.body._type, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + const target = body.projectIds === 'all' + ? 'all' + : `count=${body.projectIds.length}` + + this.logger.log(`project.bulkAction requested (action=${body.action}, target=${target})`) + await this.projectBulk.bulkAction(body, user.userId, request.id) + this.logger.log(`project.bulkAction accepted (action=${body.action})`) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts new file mode 100644 index 0000000000..fbb1d9bf76 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { ProjectHooksModule } from '../project-hooks/project-hooks.module' +import { ProjectModule } from '../project/project.module' +import { ProjectBulkController } from './project-bulk.controller' +import { ProjectBulkService } from './project-bulk.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ProjectModule, + ProjectHooksModule, + ], + controllers: [ProjectBulkController], + providers: [ProjectBulkService], +}) +export class ProjectBulkModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts new file mode 100644 index 0000000000..37ec381d2e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts @@ -0,0 +1,85 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { ProjectHooksService } from '../project-hooks/project-hooks.service' +import { makeProject } from '../project/project-testing.utils' +import { ProjectService } from '../project/project.service' +import { ProjectBulkService } from './project-bulk.service' + +describe('projectBulkService', () => { + let module: TestingModule + let service: ProjectBulkService + let prisma: DeepMockProxy + let project: DeepMockProxy + let projectHooks: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + project = mockDeep() + projectHooks = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectBulkService, + { provide: PrismaService, useValue: prisma }, + { provide: ProjectService, useValue: project }, + { provide: ProjectHooksService, useValue: projectHooks }, + ], + }).compile() + + service = module.get(ProjectBulkService) + }) + + it('processes specific project ids', async () => { + const projectIds = [faker.string.uuid(), faker.string.uuid()] + + await service.bulkAction({ action: 'archive', projectIds }, 'user-id', 'request-id') + + expect(project.archive).toHaveBeenCalledTimes(2) + expect(project.archive).toHaveBeenCalledWith(projectIds[0], 'user-id', 'request-id') + expect(project.archive).toHaveBeenCalledWith(projectIds[1], 'user-id', 'request-id') + }) + + it('resolves "all" to all non-archived project ids', async () => { + const project1Id = faker.string.uuid() + const project2Id = faker.string.uuid() + + prisma.project.findMany.mockResolvedValue([makeProject({ id: project1Id }), makeProject({ id: project2Id })]) + + await service.bulkAction({ action: 'archive', projectIds: 'all' }, 'user-id', 'request-id') + + expect(prisma.project.findMany).toHaveBeenCalledWith({ + select: { id: true }, + where: { status: { not: 'archived' } }, + }) + expect(project.archive).toHaveBeenCalledTimes(2) + }) + + it('lock action updates locked to true via project hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'lock', projectIds: [projectId] }) + + expect(projectHooks.updateProjectLocked).toHaveBeenCalledWith(projectId, true) + }) + + it('unlock action updates locked to false via project hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'unlock', projectIds: [projectId] }) + + expect(projectHooks.updateProjectLocked).toHaveBeenCalledWith(projectId, false) + }) + + it('replay action triggers hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'replay', projectIds: [projectId] }, 'user-id', 'request-id') + + expect(projectHooks.replay).toHaveBeenCalledWith(projectId, 'user-id', 'request-id') + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts new file mode 100644 index 0000000000..5989764d17 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts @@ -0,0 +1,96 @@ +import type { projectContract } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { ProjectHooksService } from '../project-hooks/project-hooks.service' +import { ProjectService } from '../project/project.service' +import { listProjectIdsNotArchived } from './project-bulk-queries.utils' + +@Injectable() +export class ProjectBulkService { + private readonly logger = new Logger(ProjectBulkService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(ProjectService) private readonly project: ProjectService, + @Inject(ProjectHooksService) private readonly projectHooks: ProjectHooksService, + ) {} + + @StartActiveSpan() + async bulkAction( + data: typeof projectContract.bulkActionProject.body._type, + requestorUserId?: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.bulk.action', data.action) + const projectIdsLog = data.projectIds === 'all' ? 'all' : `count=${data.projectIds.length}` + this.logger.log(`project.bulkAction started (action=${data.action}, projectIds=${projectIdsLog})`) + + try { + const projectIds = await this.resolveProjectIds(data.projectIds) + span?.setAttribute('project.bulk.count', projectIds.length) + + const results = await this.runTasks(projectIds, data.action, requestorUserId, requestId) + const summary = this.summarizeTasks(results) + + span?.setAttributes({ + 'project.bulk.fulfilled': summary.fulfilled, + 'project.bulk.rejected': summary.rejected, + }) + this.logger.log(`project.bulkAction completed (action=${data.action}, projectCount=${projectIds.length}, fulfilled=${summary.fulfilled}, rejected=${summary.rejected})`) + } catch (error) { + const projectIdsLabel = data.projectIds === 'all' + ? 'all' + : `count=${data.projectIds.length}` + const errorMessage = error instanceof Error ? error.message : String(error) + + this.logger.error( + `project.bulkAction failed (action=${data.action}, projectIds=${projectIdsLabel}): ${errorMessage}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async resolveProjectIds(projectIds: string[] | 'all'): Promise { + if (projectIds === 'all') { + const result = await listProjectIdsNotArchived(this.prisma) + return result.map(p => p.id) + } + return projectIds + } + + private async runTasks( + projectIds: string[], + action: string, + requestorUserId?: string, + requestId?: string, + ): Promise[]> { + const tasks = projectIds.map((projectId) => { + if (action === 'archive') { + return this.project.archive(projectId, requestorUserId, requestId) + } + if (action === 'lock' || action === 'unlock') { + return this.projectHooks.updateProjectLocked(projectId, action === 'lock') + } + if (action === 'replay') { + return this.projectHooks.replay(projectId, requestorUserId, requestId) + } + return Promise.resolve() + }) + return Promise.allSettled(tasks) + } + + private summarizeTasks(results: PromiseSettledResult[]): { fulfilled: number, rejected: number } { + return results.reduce( + (acc, r) => { + if (r.status === 'fulfilled') acc.fulfilled += 1 + else acc.rejected += 1 + return acc + }, + { fulfilled: 0, rejected: 0 }, + ) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts new file mode 100644 index 0000000000..9dd8e67e34 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts @@ -0,0 +1,32 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Controller, HttpCode, HttpStatus, Inject, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ProjectHooksService } from './project-hooks.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectHooksController { + constructor( + @Inject(ProjectHooksService) private readonly projectHooks: ProjectHooksService, + ) {} + + @Put('/:projectId/hooks') + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ReplayHooks') + async replay( + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + await this.projectHooks.replay(project.id, user.userId, request.id) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts new file mode 100644 index 0000000000..ff83fb82fb --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { LogModule } from '../log/log.module' +import { ProjectHooksController } from './project-hooks.controller' +import { ProjectHooksService } from './project-hooks.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, EventsModule, LogModule, ProjectPermissionModule], + controllers: [ProjectHooksController], + providers: [ProjectHooksService], + exports: [ProjectHooksService], +}) +export class ProjectHooksModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts new file mode 100644 index 0000000000..92d9ddf77e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts @@ -0,0 +1,80 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { projectSelect } from '../project/project-queries.utils' +import { makeProject } from '../project/project-testing.utils' +import { ProjectHooksService } from './project-hooks.service' + +describe('projectHooksService', () => { + let module: TestingModule + let service: ProjectHooksService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectHooksService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(ProjectHooksService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('updateProjectLocked updates the project locked flag', async () => { + const project = makeProject({ locked: true }) + prisma.project.update.mockResolvedValue(project) + + await service.updateProjectLocked('project-id', true) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 'project-id' }, + data: { locked: true }, + select: projectSelect, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', project, { action: 'Update Project' }) + }) + + it('replayHooks loads the project and emits project.upsert', async () => { + const project = makeProject() + prisma.project.findFirst.mockResolvedValue(project) + + const requestId = 'request-id' + const userId = 'user-id' + + await service.replay('project-id', userId, requestId) + + expect(prisma.project.findFirst).toHaveBeenCalledWith({ + where: { id: 'project-id', status: { not: 'archived' } }, + select: projectSelect, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', project, { + action: 'Replay hooks for Project', + userId, + requestId, + }) + }) + + it('replayHooks rejects locked projects', async () => { + const project = makeProject({ locked: true }) + prisma.project.findFirst.mockResolvedValue(project) + + await expect(service.replay('project-id', 'user-id', 'request-id')).rejects.toThrow(ForbiddenException) + + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts new file mode 100644 index 0000000000..b51cd4df76 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts @@ -0,0 +1,46 @@ +import { ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { getProjectNotArchived, projectSelect } from '../project/project-queries.utils' + +@Injectable() +export class ProjectHooksService { + private readonly logger = new Logger(ProjectHooksService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async updateProjectLocked(projectId: string, locked: boolean): Promise { + const project = await this.prisma.project.update({ + where: { id: projectId }, + data: { locked }, + select: projectSelect, + }) + await this.appEvents.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + } + + async replay(projectId: string, requestorUserId?: string, requestId?: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.replayHooks started (projectId=${projectId})`) + const project = await getProjectNotArchived(this.prisma, projectId) + if (!project) { + this.logger.warn(`project.replayHooks notFound (projectId=${projectId})`) + return + } + if (project.locked) { + this.logger.warn(`project.replayHooks locked (projectId=${projectId})`) + throw new ForbiddenException('Veuillez déverrouiller le projet pour rejouer les webhooks') + } + span?.setAttribute('project.slug', project.slug) + await this.appEvents.emitProjectEvent('project.upsert', project, { + action: 'Replay hooks for Project', + userId: requestorUserId, + requestId, + }) + this.logger.log(`project.replayHooks completed (projectId=${projectId})`) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts new file mode 100644 index 0000000000..ad12c2486d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts @@ -0,0 +1,78 @@ +import type { Prisma } from '@prisma/client' + +export const projectMemberWithUser = { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, +} satisfies Prisma.ProjectMembersInclude + +export const projectOwnerIdSelect = { + ownerId: true, +} satisfies Prisma.ProjectSelect + +export type ProjectMemberWithUser = Prisma.ProjectMembersGetPayload<{ + include: typeof projectMemberWithUser +}> + +export type ProjectOwnerId = Prisma.ProjectGetPayload<{ + select: typeof projectOwnerIdSelect +}> + +export type AddMemberInput = { email: string } | { userId: string } +export interface PatchMemberInput { userId: string, roles: string[] } + +export function listProjectMembersWithUser(tx: Prisma.TransactionClient, projectId: string) { + return tx.projectMembers.findMany({ + where: { projectId }, + include: projectMemberWithUser, + }) +} + +export function upsertProjectMember( + tx: Prisma.TransactionClient, + projectId: string, + member: PatchMemberInput, +) { + const { userId, roles } = member + return tx.projectMembers.upsert({ + where: { projectId_userId: { projectId, userId } }, + create: { projectId, userId, roleIds: roles }, + update: { roleIds: roles }, + }) +} + +export function upsertProjectMemberIfMissing(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.upsert({ + where: { projectId_userId: { projectId, userId } }, + create: { projectId, userId, roleIds: [] }, + update: {}, + }) +} + +export function createProjectMember(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.create({ data: { projectId, userId } }) +} + +export function deleteProjectMember(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.delete({ where: { projectId_userId: { projectId, userId } } }) +} + +export function getHumanUser(tx: Prisma.TransactionClient, opts: { userId?: string, email?: string }) { + const { userId, email } = opts + return tx.user.findFirst({ + where: { + type: 'human', + ...(typeof userId === 'string' ? { id: userId } : {}), + ...(typeof email === 'string' ? { email } : {}), + }, + }) +} + +export function getProjectOwnerId(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectOwnerIdSelect }) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts new file mode 100644 index 0000000000..e01e7f37ea --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts @@ -0,0 +1,11 @@ +import type { ProjectMembers } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } satisfies ProjectMembers +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.controller.ts new file mode 100644 index 0000000000..1761096669 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.controller.ts @@ -0,0 +1,73 @@ +import type { Member } from '@cpn-console/shared' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { AddMemberInput, PatchMemberInput } from './project-members-queries.utils' +import { projectMemberContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Logger, Param, Patch, Post, UseGuards } from '@nestjs/common' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectMembersService } from './project-members.service' +import { generateProjectMember } from './project-members.utils' + +@Controller('api/v1/projects/:projectId/members') +@UseGuards(ProjectGuard) +export class ProjectMembersController { + private readonly logger = new Logger(ProjectMembersController.name) + + constructor( + @Inject(ProjectMembersService) private readonly projectMembers: ProjectMembersService, + ) {} + + @Get() + @RequireProjectPermission('ListMembers') + async list( + @Project() project: ProjectContext, + ): Promise { + return (await this.projectMembers.list(project.id)).map(generateProjectMember) + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async add( + @Body(new ZodValidationPipe(projectMemberContract.addMember.body)) body: AddMemberInput, + @Project() project: ProjectContext, + ): Promise { + const members = await this.projectMembers.add(project.id, body) + this.logger.log(`projectMembers.add completed (memberCount=${members.length})`) + return members.map(generateProjectMember) + } + + @Patch() + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async patch( + @Body(new ZodValidationPipe(projectMemberContract.patchMembers.body)) body: PatchMemberInput[], + @Project() project: ProjectContext, + ): Promise { + const members = await this.projectMembers.patch(project.id, body) + this.logger.log(`projectMembers.patchMembers completed (projectId=${project.id}, memberCount=${members.length})`) + return members.map(generateProjectMember) + } + + @Delete('/:userId') + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async remove( + @Project() project: ProjectContext, + @Param('userId') userId: string, + ): Promise { + const members = await this.projectMembers.remove(project.id, userId) + this.logger.log(`projectMembers.remove completed (projectId=${project.id}, userId=${userId}, memberCount=${members.length})`) + return members.map(generateProjectMember) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.module.ts new file mode 100644 index 0000000000..481aeddcef --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { keycloakConfigFactory } from '../../config/keycloak.config' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { ProjectMembersController } from './project-members.controller' +import { ProjectMembersService } from './project-members.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, KeycloakModule.forRoot(keycloakConfigFactory.asProvider()), ProjectPermissionModule], + controllers: [ProjectMembersController], + providers: [ProjectMembersService], +}) +export class ProjectMembersModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts new file mode 100644 index 0000000000..48ca9eeaaa --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts @@ -0,0 +1,268 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { KeycloakClientService } from '../keycloak/keycloak-client.service' +import { + makeProject, + makeProjectMembers, + makeProjectMemberWithUser, + makeUser, +} from '../project/project-testing.utils' +import { ProjectMembersService } from './project-members.service' + +describe('projectMembersService', () => { + let module: TestingModule + let service: ProjectMembersService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + let keycloak: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + keycloak = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectMembersService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + { provide: KeycloakClientService, useValue: keycloak }, + ], + }).compile() + + service = module.get(ProjectMembersService) + }) + + describe('list', () => { + it('returns members', async () => { + const projectId = faker.string.uuid() + const user1 = makeUser() + const user2 = makeUser() + prisma.projectMembers.findMany.mockResolvedValue([ + makeProjectMemberWithUser(user1, { roleIds: [faker.string.uuid()] }), + makeProjectMemberWithUser(user2), + ]) + + const result = await service.list(projectId) + + expect(result).toHaveLength(2) + expect(result[0]).toHaveProperty('roleIds') + expect(result[0]).toHaveProperty('user') + expect(result[0].user.id).toBe(user1.id) + }) + + it('returns empty array when no members', async () => { + const projectId = faker.string.uuid() + prisma.projectMembers.findMany.mockResolvedValue([]) + + const result = await service.list(projectId) + + expect(result).toEqual([]) + }) + }) + + describe('add', () => { + it('adds member by userId and returns updated member list', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const newUserId = faker.string.uuid() + const body = { userId: newUserId } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ id: newUserId })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId, userId: newUserId })) + const memberUser = makeUser() + tx.projectMembers.findMany.mockResolvedValue([makeProjectMemberWithUser(memberUser)]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.add(projectId, body) + + expect(tx.projectMembers.upsert).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId, userId: newUserId } }, + create: { projectId, userId: newUserId, roleIds: [] }, + update: {}, + }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: newUserId, + }, { action: 'Add Project Member' }) + expect(result).toBeDefined() + }) + + it('adds member by email and returns updated member list', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const email = faker.internet.email() + const body = { email } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ email })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId })) + tx.projectMembers.findMany.mockResolvedValue([]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.add(projectId, body) + + expect(tx.user.findFirst).toHaveBeenCalledWith({ + where: { email, type: 'human' }, + }) + expect(result).toBeDefined() + }) + + it('throws when adding owner as member', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const body = { userId: ownerId } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ id: ownerId })) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow('Le owner ne peut pas être ajouté à cette liste') + }) + + it('throws NotFoundException when user not found by userId', async () => { + const projectId = faker.string.uuid() + const body = { userId: faker.string.uuid() } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId: faker.string.uuid() })) + tx.user.findFirst.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow(NotFoundException) + }) + + it('falls back to Keycloak when user not found locally by email', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const email = faker.internet.email() + const keycloakUserId = faker.string.uuid() + const body = { email } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(null) + tx.user.upsert.mockResolvedValue(makeUser({ id: keycloakUserId, email })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId, userId: keycloakUserId })) + tx.projectMembers.findMany.mockResolvedValue([]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + keycloak.getUserByEmail.mockResolvedValue(makeUser({ + id: keycloakUserId, + email, + firstName: 'Keycloak', + lastName: 'User', + })) + + const result = await service.add(projectId, body) + + expect(keycloak.getUserByEmail).toHaveBeenCalledWith(email) + expect(tx.user.upsert).toHaveBeenCalledWith({ + where: { id: keycloakUserId }, + create: { + id: keycloakUserId, + email, + firstName: 'Keycloak', + lastName: 'User', + adminRoleIds: [], + type: 'human', + }, + update: { + email, + firstName: 'Keycloak', + lastName: 'User', + type: 'human', + }, + }) + expect(result).toBeDefined() + }) + + it('throws NotFoundException when user not found by email anywhere', async () => { + const projectId = faker.string.uuid() + const body = { email: faker.internet.email() } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId: faker.string.uuid() })) + tx.user.findFirst.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow(NotFoundException) + }) + + it('throws NotFoundException when project does not exist', async () => { + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.add(faker.string.uuid(), { userId: faker.string.uuid() }), + ).rejects.toThrow(NotFoundException) + }) + }) + + describe('patch', () => { + it('upserts multiple members and emits events', async () => { + const projectId = faker.string.uuid() + const members = [ + { userId: faker.string.uuid(), roles: [faker.string.uuid()] }, + { userId: faker.string.uuid(), roles: [] }, + ] + + const tx = mockDeep() + prisma.$transaction.mockImplementation(async cb => cb(tx)) + tx.projectMembers.findMany.mockResolvedValue([]) + + await service.patch(projectId, members) + + expect(tx.projectMembers.upsert).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: members[0].userId, + }, { action: 'Update Project Member' }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: members[1].userId, + }, { action: 'Update Project Member' }) + }) + }) + + describe('remove', () => { + it('deletes member, emits event, returns updated list', async () => { + const projectId = faker.string.uuid() + const userId = faker.string.uuid() + + const tx = mockDeep() + tx.projectMembers.delete.mockResolvedValue(makeProjectMembers({ projectId, userId })) + const memberUser = makeUser() + tx.projectMembers.findMany.mockResolvedValue([makeProjectMemberWithUser(memberUser)]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.remove(projectId, userId) + + expect(tx.projectMembers.delete).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId, userId } }, + }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.delete', { + projectId, + userId, + }, { action: 'Remove Project Member' }) + expect(result).toBeDefined() + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.service.ts new file mode 100644 index 0000000000..4daa282eaa --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.service.ts @@ -0,0 +1,164 @@ +import type { AddMemberInput, PatchMemberInput } from './project-members-queries.utils' +import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { KeycloakClientService } from '../keycloak/keycloak-client.service' +import { deleteProjectMember, getHumanUser, getProjectOwnerId, listProjectMembersWithUser, upsertProjectMember, upsertProjectMemberIfMissing } from './project-members-queries.utils' + +@Injectable() +export class ProjectMembersService { + private readonly logger = new Logger(ProjectMembersService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, + ) {} + + @StartActiveSpan() + async list(projectId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.debug(`projectMembers.listMembers started (projectId=${projectId})`) + const members = await listProjectMembersWithUser(this.prisma, projectId) + span?.setAttribute('project.members.count', members.length) + this.logger.debug(`projectMembers.listMembers completed (projectId=${projectId}, count=${members.length})`) + return members + } + + @StartActiveSpan() + async add( + projectId: string, + body: AddMemberInput, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + const lookupBy = 'userId' in body ? 'userId' : 'email' + span?.setAttribute('project.member.lookupBy', lookupBy) + const userIdCandidate = 'userId' in body ? body.userId : undefined + this.logger.log(`projectMembers.addMember started (projectId=${projectId}, lookupBy=${lookupBy}, userId=${userIdCandidate})`) + try { + const result = await this.prisma.$transaction(async (tx) => { + const project = await getProjectOwnerId(tx, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + + const userId = 'userId' in body ? body.userId : undefined + const email = 'email' in body ? body.email : undefined + + const userDb = await this.resolveHumanUser(tx, { userId, email }) + if (!userDb) throw new NotFoundException('Utilisateur introuvable') + + if (userDb.id === project.ownerId) { + throw new BadRequestException('Le owner ne peut pas être ajouté à cette liste') + } + + await upsertProjectMemberIfMissing(tx, projectId, userDb.id) + + const members = await listProjectMembersWithUser(tx, projectId) + return { userId: userDb.id, members } + }) + await this.appEvents.emitProjectMemberEvent('projectMember.upsert', { projectId, userId: result.userId }, { action: 'Add Project Member' }) + span?.setAttribute('project.member.userId', result.userId) + span?.setAttribute('project.members.count', result.members.length) + this.logger.log(`projectMembers.addMember completed (projectId=${projectId}, userId=${result.userId}, memberCount=${result.members.length})`) + return result.members + } catch (error) { + this.logger.error( + `projectMembers.addMember failed (projectId=${projectId}, lookupBy=${lookupBy}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async resolveHumanUser( + tx: Parameters[0], + opts: { userId?: string, email?: string }, + ) { + const userDb = await getHumanUser(tx, opts) + if (userDb || !opts.email) { + return userDb + } + + const keycloakUser = await this.keycloak.getUserByEmail(opts.email) + if (!keycloakUser) { + return null + } + + const keycloakUserId = keycloakUser.id ?? opts.email + return tx.user.upsert({ + where: { id: keycloakUserId }, + create: { + id: keycloakUserId, + email: keycloakUser.email ?? opts.email, + firstName: keycloakUser.firstName ?? keycloakUser.email ?? opts.email, + lastName: keycloakUser.lastName ?? '', + adminRoleIds: [], + type: 'human', + }, + update: { + email: keycloakUser.email ?? opts.email, + firstName: keycloakUser.firstName ?? keycloakUser.email ?? opts.email, + lastName: keycloakUser.lastName ?? '', + type: 'human', + }, + }) + } + + @StartActiveSpan() + async patch( + projectId: string, + body: PatchMemberInput[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('project.members.patch.count', body.length) + this.logger.log(`projectMembers.patchMembers started (projectId=${projectId}, patchCount=${body.length})`) + try { + const members = await this.prisma.$transaction(async (tx) => { + for (const member of body) { + await upsertProjectMember(tx, projectId, member) + } + return listProjectMembersWithUser(tx, projectId) + }) + await Promise.all( + body.map(member => this.appEvents.emitProjectMemberEvent('projectMember.upsert', { projectId, userId: member.userId }, { action: 'Update Project Member' })), + ) + span?.setAttribute('project.members.count', members.length) + this.logger.log(`projectMembers.patchMembers completed (projectId=${projectId}, memberCount=${members.length})`) + return members + } catch (error) { + this.logger.error( + `projectMembers.patchMembers failed (projectId=${projectId}, patchCount=${body.length}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async remove(projectId: string, userId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('project.member.userId', userId) + this.logger.log(`projectMembers.removeMember started (projectId=${projectId}, userId=${userId})`) + try { + const members = await this.prisma.$transaction(async (tx) => { + await deleteProjectMember(tx, projectId, userId) + return listProjectMembersWithUser(tx, projectId) + }) + await this.appEvents.emitProjectMemberEvent('projectMember.delete', { projectId, userId }, { action: 'Remove Project Member' }) + span?.setAttribute('project.members.count', members.length) + this.logger.log(`projectMembers.removeMember completed (projectId=${projectId}, userId=${userId}, memberCount=${members.length})`) + return members + } catch (error) { + this.logger.error( + `projectMembers.removeMember failed (projectId=${projectId}, userId=${userId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.utils.ts new file mode 100644 index 0000000000..4131cfbebd --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-members/project-members.utils.ts @@ -0,0 +1,13 @@ +import type { Member } from '@cpn-console/shared' +import type { ProjectMemberWithUser } from './project-members-queries.utils' + +export function generateProjectMember(member: ProjectMemberWithUser): Member { + const { roleIds, user } = member + return { + userId: user.id, + roleIds, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts new file mode 100644 index 0000000000..20a4355031 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts @@ -0,0 +1,47 @@ +import type { Prisma } from '@prisma/client' + +export const projectRoleWithProjectSelect = { + id: true, + name: true, + permissions: true, + position: true, + oidcGroup: true, + type: true, + projectId: true, + project: { + select: { + slug: true, + members: { + distinct: 'userId', + }, + }, + }, +} satisfies Prisma.ProjectRoleSelect + +export const projectRoleForDeleteSelect = { + type: true, + projectId: true, +} satisfies Prisma.ProjectRoleSelect + +export const projectForRoleContextSelect = { + id: true, + slug: true, + status: true, + locked: true, +} satisfies Prisma.ProjectSelect + +export type ProjectRoleWithProject = Prisma.ProjectRoleGetPayload<{ select: typeof projectRoleWithProjectSelect }> + +export type ProjectForRoleContext = Prisma.ProjectRoleGetPayload<{ select: typeof projectForRoleContextSelect }> + +export function getProjectBySlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: { slug: true } }) +} + +export function getProjectRoleForDelete(tx: Prisma.TransactionClient, roleId: string) { + return tx.projectRole.findUnique({ where: { id: roleId }, select: projectRoleForDeleteSelect }) +} + +export function getProjectForUpsert(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectForRoleContextSelect }) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts new file mode 100644 index 0000000000..c9b74e9e28 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts @@ -0,0 +1,71 @@ +import type { Project, ProjectMembers, ProjectRole } from '@prisma/client' +import { faker } from '@faker-js/faker' + +interface ProjectRoleWithMembers extends ProjectRole { + members?: Array +} + +interface ProjectRoleWithProject extends ProjectRole { + project: Pick +} + +export function makeProjectRole(overrides: Partial = {}): ProjectRole { + return { + id: faker.string.uuid(), + name: faker.person.jobTitle(), + permissions: faker.number.bigInt({ min: 0n, max: 64n }), + projectId: faker.string.uuid(), + position: faker.number.int({ min: 0, max: 100 }), + oidcGroup: '', + type: 'managed', + ...overrides, + } +} + +export function makeProjectRoleWithMembers(overrides: Partial = {}): ProjectRoleWithMembers { + return { + ...makeProjectRole(overrides), + members: overrides.members ?? [], + } +} + +export function makeProjectRoleWithProject(overrides: Partial = {}): ProjectRoleWithProject { + const role = makeProjectRole(overrides) + return { + ...role, + project: overrides.project ?? { slug: faker.helpers.slugify(faker.company.name()) }, + } +} + +export function makeProject(overrides: Partial = {}): Project { + return { + id: faker.string.uuid(), + name: faker.company.name(), + description: '', + status: 'initializing', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.helpers.slugify(faker.company.name()), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } +} + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts new file mode 100644 index 0000000000..c7fb6a706d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts @@ -0,0 +1,70 @@ +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { projectRoleContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Patch, Post, UseGuards } from '@nestjs/common' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectRolesService } from './project-roles.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectRolesController { + constructor( + @Inject(ProjectRolesService) private readonly projectRoles: ProjectRolesService, + ) {} + + @Get('/:projectId/roles') + @RequireProjectPermission('ListRoles') + async list( + @Project() project: ProjectContext, + ) { + return this.projectRoles.list(project.id) + } + + @Post('/:projectId/roles') + @HttpCode(HttpStatus.CREATED) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async create( + @Body(new ZodValidationPipe(projectRoleContract.createProjectRole.body)) body: typeof projectRoleContract.createProjectRole.body._type, + @Project() project: ProjectContext, + ) { + return this.projectRoles.create(project.id, body) + } + + @Patch('/:projectId/roles') + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async update( + @Body(new ZodValidationPipe(projectRoleContract.patchProjectRoles.body)) body: typeof projectRoleContract.patchProjectRoles.body._type, + @Project() project: ProjectContext, + ) { + return this.projectRoles.update(project.id, body) + } + + @Get('/:projectId/roles/member-counts') + @RequireProjectPermission('ListRoles') + async countMembers( + @Project() project: ProjectContext, + ) { + return this.projectRoles.countMembers(project.id) + } + + @Delete('/:projectId/roles/:roleId') + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async delete( + @Param('roleId') roleId: string, + @Project() _project: ProjectContext, + ): Promise { + return this.projectRoles.delete(roleId) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts new file mode 100644 index 0000000000..e6bf20ad5f --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { ProjectRolesController } from './project-roles.controller' +import { ProjectRolesService } from './project-roles.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, ProjectPermissionModule], + controllers: [ProjectRolesController], + providers: [ProjectRolesService], + exports: [ProjectRolesService], +}) +export class ProjectRolesModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts new file mode 100644 index 0000000000..ef4a761800 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts @@ -0,0 +1,181 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeProject, makeProjectMembers, makeProjectRole, makeProjectRoleWithProject } from './project-roles-testing.utils' +import { ProjectRolesService } from './project-roles.service' + +describe('projectRolesService', () => { + let module: TestingModule + let service: ProjectRolesService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectRolesService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(ProjectRolesService) + }) + + it('lists roles with permissions as strings and stripped oidcGroup', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + prisma.project.findUnique.mockResolvedValue(makeProject({ id: projectId, slug })) + prisma.projectRole.findMany.mockResolvedValue([makeProjectRoleWithProject({ + name: 'test', + permissions: 4n, + position: 0, + projectId, + oidcGroup: '/slug/console/admin', + project: { slug }, + })]) + + const roles = await service.list(projectId) + + expect(roles[0].permissions).toBe('4') + expect(roles[0].oidcGroup).toBe('/console/admin') + }) + + it('creates a role and emits project update', async () => { + const projectId = faker.string.uuid() + const slug = faker.string.alphanumeric(8).toLowerCase() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + prisma.projectRole.findFirst.mockResolvedValue(makeProjectRole({ position: 0 })) + prisma.projectRole.create.mockResolvedValue(makeProjectRole()) + prisma.projectRole.findMany.mockResolvedValue([]) + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + + await service.create(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + name: 'test', + permissions: 4n, + position: 1, + projectId, + }), + })) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', expect.anything(), { action: 'Upsert Project Role' }) + }) + + it('rejects system role creation', async () => { + const projectId = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug: 'slug' })) + + await expect(service.create(projectId, { name: 'test', permissions: '4', type: 'system:managed' })) + .rejects.toThrow(BadRequestException) + }) + + it('patches roles and validates positions', async () => { + const projectId = faker.string.uuid() + const slug = faker.string.alphanumeric(8).toLowerCase() + const roleId = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + prisma.projectRole.findMany.mockResolvedValue([makeProjectRoleWithProject({ + id: roleId, + name: 'test', + permissions: 4n, + position: 0, + projectId, + oidcGroup: '/slug/console/admin', + project: { slug }, + })]) + prisma.projectRole.update.mockResolvedValue(makeProjectRole({ id: roleId })) + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + + await service.update(projectId, [{ id: roleId, name: 'updated', position: 0 }]) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: roleId }, + data: expect.objectContaining({ + name: 'updated', + position: 0, + }), + })) + }) + + it('rejects incoherent positions', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + const roleId1 = faker.string.uuid() + const roleId2 = faker.string.uuid() + prisma.projectRole.findMany.mockResolvedValue([ + makeProjectRoleWithProject({ id: roleId1, name: 'a', permissions: 1n, position: 0, projectId, oidcGroup: '', project: { slug } }), + makeProjectRoleWithProject({ id: roleId2, name: 'b', permissions: 1n, position: 1, projectId, oidcGroup: '', project: { slug } }), + ]) + + await expect(service.update(projectId, [{ id: roleId1, position: 1 }])) + .rejects.toThrow(BadRequestException) + }) + + it('counts role members', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + const roleId1 = faker.string.uuid() + const roleId2 = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + prisma.projectRole.findMany.mockResolvedValue([ + makeProjectRoleWithProject({ id: roleId1, name: 'a', permissions: 1n, position: 0, projectId, oidcGroup: '', project: { slug } }), + makeProjectRoleWithProject({ id: roleId2, name: 'b', permissions: 1n, position: 1, projectId, oidcGroup: '', project: { slug } }), + ]) + prisma.projectMembers.findMany.mockResolvedValue([ + makeProjectMembers({ roleIds: [roleId1, roleId2] }), + makeProjectMembers({ roleIds: [roleId2] }), + ]) + + await expect(service.countMembers(projectId)).resolves.toEqual({ + [roleId1]: 1, + [roleId2]: 2, + }) + }) + + it('deletes role and cleans member role ids', async () => { + const projectId = faker.string.uuid() + const roleId = faker.string.uuid() + prisma.projectRole.findUnique.mockResolvedValue(makeProjectRole({ type: 'managed', projectId })) + prisma.project.findUnique.mockResolvedValue(makeProject({ id: projectId, slug: 'slug' })) + + const tx = mockDeep() + tx.projectRole.delete.mockResolvedValue(makeProjectRole()) + tx.projectMembers.findMany.mockResolvedValue([ + makeProjectMembers({ userId: faker.string.uuid(), roleIds: [roleId] }), + makeProjectMembers({ userId: faker.string.uuid(), roleIds: [roleId, faker.string.uuid()] }), + ]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.delete(roleId) + + expect(tx.projectMembers.update).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', expect.anything(), { action: 'Upsert Project Role' }) + }) + + it('rejects system role deletion', async () => { + prisma.projectRole.findUnique.mockResolvedValue(makeProjectRole({ type: 'system:managed', projectId: faker.string.uuid() })) + + await expect(service.delete(faker.string.uuid())) + .rejects.toThrow(BadRequestException) + }) + + it('rejects missing role deletion', async () => { + prisma.projectRole.findUnique.mockResolvedValue(null) + + await expect(service.delete(faker.string.uuid())) + .rejects.toThrow(NotFoundException) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts new file mode 100644 index 0000000000..a8c537b5fd --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts @@ -0,0 +1,130 @@ +import type { Project, ProjectRole } from '@prisma/client' +import type { + CreateProjectRoleInput, + PatchProjectRolesInput, +} from './project-roles.utils' +import { isSystemRoleType } from '@cpn-console/shared' +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { getProjectBySlug, getProjectRoleForDelete, projectRoleWithProjectSelect } from './project-roles-queries.utils' +import { + buildUpdatedProjectRoles, + toProjectRoleResponse, + validatePatchedProjectRolePositions, +} from './project-roles.utils' + +@Injectable() +export class ProjectRolesService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async list(projectId: Project['id']) { + const roles = await this.prisma.projectRole.findMany({ + where: { projectId }, + orderBy: { position: 'asc' }, + select: projectRoleWithProjectSelect, + }) + return roles.map(role => toProjectRoleResponse(role)) + } + + async update(projectId: Project['id'], roles: PatchProjectRolesInput) { + const project = await getProjectBySlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + + const dbRoles = await this.prisma.projectRole.findMany({ where: { projectId }, orderBy: { position: 'asc' } }) + const { requestedPositionsCount, updatedRoles } = buildUpdatedProjectRoles(project.slug, dbRoles, roles) + validatePatchedProjectRolePositions(dbRoles.length, requestedPositionsCount) + + for (const { id, ...role } of updatedRoles) { + await this.prisma.projectRole.update({ where: { id }, data: role }) + } + + await this.emitProjectUpsert(projectId) + return this.list(projectId) + } + + async create(projectId: Project['id'], role: CreateProjectRoleInput) { + const project = await getProjectBySlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + if (isSystemRoleType(role.type)) { + throw new BadRequestException('Impossible de créer un rôle système') + } + + const dbMaxPosRole = (await this.prisma.projectRole.findFirst({ + where: { projectId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + if (role.oidcGroup && !role.oidcGroup.startsWith('/')) { + throw new BadRequestException('oidcGroup doit commencer par /') + } + + await this.prisma.projectRole.create({ + data: { + ...role, + projectId, + position: dbMaxPosRole + 1, + permissions: BigInt(role.permissions), + oidcGroup: role.oidcGroup ? `/${project.slug}${role.oidcGroup}` : undefined, + }, + }) + + await this.emitProjectUpsert(projectId) + return this.list(projectId) + } + + async countMembers(projectId: Project['id']) { + const roles = await this.list(projectId) + const members = await this.prisma.projectMembers.findMany({ where: { projectId } }) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) + + for (const { roleIds } of members) { + for (const roleId of roleIds) { + rolesCounts[roleId]++ + } + } + + return rolesCounts + } + + async delete(roleId: ProjectRole['id']) { + const role = await getProjectRoleForDelete(this.prisma, roleId) + if (!role) throw new NotFoundException('Rôle introuvable') + if (isSystemRoleType(role.type)) { + throw new BadRequestException('Ce rôle système ne peut pas être supprimé') + } + + await this.prisma.$transaction(async (tx) => { + await tx.projectRole.delete({ where: { id: roleId } }) + const attachedMembers = await tx.projectMembers.findMany({ + where: { projectId: role.projectId, roleIds: { has: roleId } }, + }) + + for (const member of attachedMembers) { + await tx.projectMembers.update({ + where: { + projectId_userId: { + projectId: role.projectId, + userId: member.userId, + }, + }, + data: { + roleIds: { + set: member.roleIds.filter(existingRoleId => existingRoleId !== roleId), + }, + }, + }) + } + }) + + await this.emitProjectUpsert(role.projectId) + } + + private async emitProjectUpsert(projectId: string): Promise { + await this.appEvents.emitProjectEvent('project.upsert', projectId, { action: 'Upsert Project Role' }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts new file mode 100644 index 0000000000..c83bb2bb54 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts @@ -0,0 +1,108 @@ +import type { ProjectRole as SharedProjectRole } from '@cpn-console/shared' +import type { ProjectRole } from '@prisma/client' +import type { ProjectRoleWithProject } from './project-roles-queries.utils' +import { isSystemRoleType } from '@cpn-console/shared' +import { BadRequestException } from '@nestjs/common' + +export interface ProjectRoleResponse { + id: string + name: string + oidcGroup?: string | null + type?: string | null + permissions: string + userCount?: number +} + +export type CreateProjectRoleInput = Omit +export type PatchProjectRoleInput = Pick & Partial> +export type PatchProjectRolesInput = PatchProjectRoleInput[] + +export function toProjectRoleResponse( + role: ProjectRoleWithProject, +): ProjectRoleResponse { + return { + id: role.id, + name: role.name, + oidcGroup: stripProjectSlugFromOidcGroup(role.oidcGroup, role.project.slug), + type: role.type ?? 'managed', + permissions: role.permissions.toString(), + userCount: role.project.members?.length ?? 0, + } +} + +export function buildUpdatedProjectRoles( + projectSlug: string, + dbRoles: ProjectRole[], + roles: PatchProjectRolesInput, +): { requestedPositionsCount: number, updatedRoles: ProjectRole[] } { + const requestedPositions = new Set() + const updatedRoles: ProjectRole[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (!matchingRole) continue + + validateProjectRolePatch(dbRole, matchingRole) + + if (matchingRole.position !== undefined) { + requestedPositions.add(matchingRole.position) + } + + updatedRoles.push(mergeProjectRolePatch(projectSlug, dbRole, matchingRole)) + } + + return { requestedPositionsCount: requestedPositions.size, updatedRoles } +} + +export function validatePatchedProjectRolePositions(dbRolesCount: number, requestedPositionsCount: number): void { + if (requestedPositionsCount && requestedPositionsCount !== dbRolesCount) { + throw new BadRequestException('Les numéros de position des rôles sont incohérentes') + } +} + +function validateProjectRolePatch(dbRole: ProjectRole, matchingRole: PatchProjectRoleInput): void { + if (isSystemRoleType(dbRole.type)) { + throw new BadRequestException('Ce rôle système ne peut pas être modifié') + } + + if (isSystemRoleType(matchingRole.type)) { + throw new BadRequestException('Impossible de modifier un rôle en rôle système') + } + + if (matchingRole.oidcGroup && !matchingRole.oidcGroup.startsWith('/')) { + throw new BadRequestException('oidcGroup doit commencer par /') + } +} + +function mergeProjectRolePatch( + projectSlug: string, + dbRole: ProjectRole, + matchingRole: PatchProjectRoleInput, +): ProjectRole { + const permissions = typeof matchingRole.permissions === 'string' + ? BigInt(matchingRole.permissions) + : dbRole.permissions + const oidcGroup = typeof matchingRole.oidcGroup === 'string' + ? `/${projectSlug}${matchingRole.oidcGroup}` + : dbRole.oidcGroup + + return { + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup, + type: matchingRole.type ?? dbRole.type, + projectId: dbRole.projectId, + } +} + +const oidcRegexp = /^\/[^/]+/ + +function stripProjectSlugFromOidcGroup(oidcGroup: string, projectSlug: string): string | null { + if (!oidcGroup.startsWith(`/${projectSlug}`)) { + return oidcGroup.replace(oidcRegexp, '') + } + + return oidcGroup.slice(projectSlug.length + 1) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts new file mode 100644 index 0000000000..f6595cbc81 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts @@ -0,0 +1,13 @@ +import type { Prisma } from '@prisma/client' + +export const projectSlugSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export type ProjectSlug = Prisma.ProjectGetPayload<{ + select: typeof projectSlugSelect +}> + +export function getProjectSlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectSlugSelect }) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts new file mode 100644 index 0000000000..89d0174b4a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts @@ -0,0 +1,21 @@ +import type { VaultMetadata, VaultSecret } from '../vault/vault-client.service' +import { faker } from '@faker-js/faker' + +export function makeVaultSecret = Record>(overrides: Partial> = {}): VaultSecret { + return { + data: {} as T, + metadata: makeVaultMetadata(), + ...overrides, + } satisfies VaultSecret +} + +function makeVaultMetadata(overrides: Partial = {}): VaultMetadata { + return { + created_time: faker.date.past().toISOString(), + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + ...overrides, + } satisfies VaultMetadata +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts new file mode 100644 index 0000000000..ae99445888 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, Inject, UseGuards } from '@nestjs/common' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ProjectSecretsService } from './project-secrets.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectSecretsController { + constructor( + @Inject(ProjectSecretsService) private readonly projectSecrets: ProjectSecretsService, + ) {} + + @Get('/:projectId/secrets') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectPermission('SeeSecrets') + async get( + @Project() project: { id: string }, + ): Promise>> { + return this.projectSecrets.get(project.id) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts new file mode 100644 index 0000000000..1ecc4f73e6 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts @@ -0,0 +1,17 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProjectSecretsModule } from './project-secrets.module' +import { ProjectSecretsService } from './project-secrets.service' + +describe('projectSecretsModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ProjectSecretsService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ProjectSecretsModule, 'USE_VAULT')], + }).compile() + expect(() => module.get(ProjectSecretsService)).toThrow() + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts new file mode 100644 index 0000000000..16a6c23b13 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { VaultModule } from '../vault/vault.module' +import { ProjectSecretsController } from './project-secrets.controller' +import { ProjectSecretsService } from './project-secrets.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, VaultModule, ConfigModule.forFeature(baseConfigFactory)], + controllers: [ProjectSecretsController], + providers: [ProjectSecretsService], + exports: [ProjectSecretsService], +}) +export class ProjectSecretsModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts new file mode 100644 index 0000000000..62586bd954 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts @@ -0,0 +1,106 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeProject } from '../project/project-testing.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { VaultService } from '../vault/vault.service' +import { makeVaultSecret } from './project-secrets-testing.utils' +import { ProjectSecretsService } from './project-secrets.service' + +describe('projectSecretsService', () => { + let module: TestingModule + let service: ProjectSecretsService + let prisma: DeepMockProxy + let vault: DeepMockProxy + let vaultClient: DeepMockProxy + let config: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + vault = mockDeep() + vaultClient = mockDeep() + config = mockDeep({ projectsRootDir: '/vault' }) + vaultConfig = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectSecretsService, + { provide: PrismaService, useValue: prisma }, + { provide: BASE_CONFIG, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: VaultService, useValue: vault }, + { provide: VaultClientService, useValue: vaultClient }, + ], + }).compile() + + service = module.get(ProjectSecretsService) + }) + + it('returns parsed secrets from vault', async () => { + const projectId = 'project-id' + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproject' })) + vault.listProjectSecrets.mockResolvedValue(['group1/secret1']) + vaultClient.read.mockResolvedValue(makeVaultSecret({ data: { key1: 'value1', key2: 42, key3: true, key4: null } })) + + const result = await service.get(projectId) + + expect(prisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: projectId }, + select: { slug: true }, + }) + expect(vault.listProjectSecrets).toHaveBeenCalledWith('myproject') + expect(result).toHaveProperty('group1') + expect(result.group1).toHaveProperty('secret1.key1', 'value1') + expect(result.group1).toHaveProperty('secret1.key2', '42') + expect(result.group1).toHaveProperty('secret1.key3', 'true') + expect(result.group1).toHaveProperty('secret1.key4', '') + }) + + it('handles nested secret paths', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue(['group1/sub/path']) + vaultClient.read.mockResolvedValue(makeVaultSecret({ data: { nested: 'value' } })) + + const result = await service.get('project-id') + + expect(result.group1).toHaveProperty('sub/path.nested', 'value') + }) + + it('returns empty object when no secrets exist', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue([]) + + const result = await service.get('project-id') + + expect(result).toEqual({}) + }) + + it('returns empty object when secret listing fails', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockRejectedValue(new Error('vault unavailable')) + + const result = await service.get('project-id') + + expect(result).toEqual({}) + }) + + it('skips secrets that fail to read', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue(['group1/s1', 'group1/s2']) + vaultClient.read + .mockRejectedValueOnce(new Error('vault error')) + .mockResolvedValueOnce(makeVaultSecret({ data: { key: 'val' } })) + + const result = await service.get('project-id') + + expect(result.group1).toEqual({ 's2.key': 'val' }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts new file mode 100644 index 0000000000..2f9525b41f --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts @@ -0,0 +1,88 @@ +import type { ConfigType } from '@nestjs/config' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { z } from 'zod' +import { baseConfigFactory } from '../../config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultService } from '../vault/vault.service' +import { generateProjectPath } from '../vault/vault.utils' +import { getProjectSlug } from './project-secrets-queries.utils' + +const SecretValueSchema = z.union([ + z.string(), + z.undefined().transform(() => ''), + z.number().transform(String), + z.bigint().transform(String), + z.boolean().transform(String), + z.null().transform(() => ''), +]).catch('') + +export function parseSecretValue(value: string): string { + return SecretValueSchema.parse(value) +} + +@Injectable() +export class ProjectSecretsService { + private readonly logger = new Logger(ProjectSecretsService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, + @Inject(VaultService) private readonly vault: VaultService, + @Inject(VaultClientService) private readonly vaultClient: VaultClientService, + ) {} + + @StartActiveSpan() + async get(projectId: string): Promise>> { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.get started (projectId=${projectId})`) + try { + const project = await getProjectSlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + span?.setAttribute('project.slug', project.slug) + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, project.slug) + + const result: Record> = {} + const relativePaths = await this.vault.listProjectSecrets(project.slug).catch((error) => { + this.logger.warn( + `project.get secret listing failed (projectId=${projectId}, slug=${project.slug}): ${error instanceof Error ? error.message : String(error)}; returning an empty result`, + ) + return [] as string[] + }) + span?.setAttribute('vault.secretFiles.count', relativePaths.length) + this.logger.debug(`project.get listed (projectId=${projectId}, slug=${project.slug}, secretFiles=${relativePaths.length})`) + + for (const relativePath of relativePaths) { + const fullPath = `${projectPath}/${relativePath}` + const secret = await this.vaultClient.read>(fullPath).catch(() => null) + if (!secret?.data) continue + + const [group, ...rest] = relativePath.split('/').filter(Boolean) + if (!group) continue + const prefix = rest.length ? `${rest.join('/')}.` : '' + const groupObj = (result[group] ??= {}) + for (const [key, value] of Object.entries(secret.data)) { + groupObj[`${prefix}${key}`] = parseSecretValue(value) + } + } + + const groupCount = Object.keys(result).length + const keyCount = Object.values(result).reduce((acc, group) => acc + Object.keys(group).length, 0) + span?.setAttributes({ + 'vault.secretGroups.count': groupCount, + 'vault.secretKeys.count': keyCount, + }) + this.logger.log(`project.get completed (projectId=${projectId}, slug=${project.slug}, groupCount=${groupCount}, keyCount=${keyCount})`) + return result + } catch (error) { + this.logger.error( + `project.get failed (projectId=${projectId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts new file mode 100644 index 0000000000..ef539dae4a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts @@ -0,0 +1,117 @@ +import type { Prisma } from '@prisma/client' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + clusters: { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + infos: true, + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + createdAt: true, + updatedAt: true, + clusterId: true, + cluster: { + select: { + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectPluginSelect = { + key: true, + pluginName: true, + projectId: true, + value: true, +} satisfies Prisma.ProjectPluginSelect + +export const adminPluginSelect = { + key: true, + pluginName: true, + value: true, +} satisfies Prisma.AdminPluginSelect + +export const publicClusterSelect = { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + infos: true, + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, +} satisfies Prisma.ClusterFindManyArgs + +export type ProjectPlugin = Prisma.ProjectPluginGetPayload<{ + select: typeof projectPluginSelect +}> + +export type AdminPlugin = Prisma.AdminPluginGetPayload<{ + select: typeof adminPluginSelect +}> + +export type PublicCluster = Prisma.ClusterGetPayload<{ + select: typeof publicClusterSelect.select +}> + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export async function getServicesQueryData(tx: Prisma.TransactionClient, projectId: string) { + return Promise.all([ + tx.project.findUnique({ + where: { id: projectId }, + select: projectSelect, + }), + tx.projectPlugin.findMany({ + where: { projectId }, + select: projectPluginSelect, + }), + tx.adminPlugin.findMany({ + select: adminPluginSelect, + }), + tx.cluster.findMany({ + where: { privacy: 'public' }, + select: publicClusterSelect.select, + }), + ]) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts new file mode 100644 index 0000000000..6a52d2b057 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts @@ -0,0 +1,231 @@ +import type { Plugin } from '@cpn-console/hooks' +import type { PluginsUpdateBody } from '@cpn-console/shared' +import type { Cluster, Environment, ProjectPlugin as PrismaProjectPlugin, Project, Zone } from '@prisma/client' +import type { AdminPlugin, ProjectPlugin, ProjectWithDetails, PublicCluster } from './project-services-queries.utils' +import { DEFAULT } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +/** Narrow zone shape the service reads from `PublicCluster.zone` / environment clusters. */ +export type PublicZone = PublicCluster['zone'] + +export function makeServicesPluginName(): string { + return faker.helpers.slugify(faker.company.name()) +} + +export function makePlugin(overrides: Partial = {}): Plugin { + const pluginName = overrides.name ?? makeServicesPluginName() + + return { + infos: { + name: pluginName, + title: faker.commerce.productName(), + description: faker.lorem.sentence(), + imgSrc: `/${faker.helpers.slugify(faker.commerce.product())}.svg`, + config: { + global: [], + project: [ + { + kind: 'switch', + key: 'enabled', + title: 'Enabled', + value: DEFAULT, + initialValue: DEFAULT, + permissions: { + user: { read: true, write: true }, + admin: { read: true, write: true }, + }, + }, + ], + }, + to: ({ project }) => `/projects/${project.slug}`, + ...overrides, + }, + subscribedHooks: {}, + } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + name: faker.company.name(), + slug: faker.helpers.slugify(faker.company.name()), + description: faker.lorem.sentence(), + clusters: [makePublicCluster()], + environments: [], + ...overrides, + } +} + +export function makeProjectPlugin(overrides: Partial = {}): ProjectPlugin { + return { + pluginName: makeServicesPluginName(), + projectId: faker.string.uuid(), + key: 'enabled', + value: DEFAULT, + ...overrides, + } +} + +export function makeServicesAdminPlugin(overrides: Partial = {}): AdminPlugin { + const pluginName = overrides.pluginName ?? makeServicesPluginName() + const adminPlugin = { + pluginName, + key: 'enabled', + value: DEFAULT, + } + return { ...adminPlugin, ...overrides, pluginName } +} + +export function makePublicCluster(overrides: Partial = {}): PublicCluster { + const resolvedZone = overrides.zone ?? makeServicesZone() + return { + id: faker.string.uuid(), + label: faker.company.name(), + privacy: 'public', + clusterResources: false, + infos: null, + zone: resolvedZone, + ...overrides, + } +} + +export function makeServicesZone(overrides: Partial = {}): PublicZone { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`zone-${faker.string.alphanumeric(8)}`), + label: faker.location.city(), + argocdUrl: `https://${faker.helpers.slugify(faker.location.city())}.example.com`, + ...overrides, + } +} + +export function makeServicesEnvironment(overrides: Partial & { cluster?: PublicCluster } = {}): ProjectWithDetails['environments'][number] { + const { cluster: clusterOverride, ...restOverrides } = overrides + const cluster = clusterOverride ?? makePublicCluster() + return { + id: faker.string.uuid(), + name: faker.word.noun(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: cluster.id, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + cluster, + ...restOverrides, + } +} + +export function makeServicesUpdateBody(pluginName: string = makeServicesPluginName()): PluginsUpdateBody { + return { + [pluginName]: { + enabled: DEFAULT, + }, + } +} + +export type ClusterWithZone = Cluster & { zone: Zone } + +export function makeCluster(overrides: Partial = {}): Cluster { + return { + id: faker.string.uuid(), + label: faker.company.name(), + privacy: 'public', + secretName: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + infos: null, + memory: faker.number.float({ min: 0, max: 1024, fractionDigits: 2 }), + cpu: faker.number.float({ min: 0, max: 64, fractionDigits: 2 }), + gpu: faker.number.float({ min: 0, max: 8, fractionDigits: 2 }), + zoneId: faker.string.uuid(), + ...overrides, + } +} + +export function makeClusterWithZone(overrides: Partial = {}): ClusterWithZone { + const resolvedZone = overrides.zone ?? { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`zone-${faker.string.alphanumeric(8)}`), + label: faker.location.city(), + description: faker.lorem.sentence(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + argocdUrl: `https://${faker.helpers.slugify(faker.location.city())}.example.com`, + } + const { zone: _zone, ...clusterOverrides } = overrides + return { + ...makeCluster(clusterOverrides), + zoneId: clusterOverrides.zoneId ?? resolvedZone.id, + zone: resolvedZone, + } +} + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + projectId: faker.string.uuid(), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export type EnvironmentWithCluster = Environment & { cluster: ClusterWithZone } + +export function makeEnvironmentWithCluster(overrides: Partial = {}): EnvironmentWithCluster { + const resolvedCluster = overrides.cluster ?? makeClusterWithZone() + const { cluster: _cluster, ...envOverrides } = overrides + return { + ...makeEnvironment(envOverrides), + clusterId: envOverrides.clusterId ?? resolvedCluster.id, + cluster: resolvedCluster, + } +} + +export type ProjectWithRelations = Project & { + plugins: PrismaProjectPlugin[] + clusters: ClusterWithZone[] + environments: EnvironmentWithCluster[] +} + +export function makeProject(overrides: Partial = {}): ProjectWithRelations { + return { + id: faker.string.uuid(), + name: faker.company.name(), + description: faker.lorem.sentence(), + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.helpers.slugify(faker.company.name()), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + plugins: [], + clusters: [], + environments: [], + ...overrides, + } +} + +export function makeProjectWithRelations(overrides: Partial = {}): ProjectWithRelations { + return makeProject(overrides) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.controller.ts new file mode 100644 index 0000000000..00ff45d9f2 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.controller.ts @@ -0,0 +1,49 @@ +import type { PermissionTarget, PluginsUpdateBody } from '@cpn-console/shared' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { AdminAuthorized, projectServiceContract } from '@cpn-console/shared' +import { Body, Controller, ForbiddenException, Get, HttpCode, HttpStatus, Inject, Post, Query, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectServicesService } from './project-services.service' + +@Controller('api/v1/projects/:projectId/services') +@UseGuards(ProjectGuard) +export class ProjectServicesController { + constructor( + @Inject(ProjectServicesService) private readonly projectServices: ProjectServicesService, + ) {} + + @Get() + @RequireProjectPermission('ListEnvironments') + async get( + @Query(new ZodValidationPipe(projectServiceContract.getServices.query)) query: { permissionTarget: PermissionTarget }, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + ) { + if (query.permissionTarget === 'admin' && !AdminAuthorized.Manage(user.adminPermissions ?? 0n)) { + throw new ForbiddenException('Vous ne pouvez pas demander les paramètres admin') + } + + return this.projectServices.get(project.id, query.permissionTarget) + } + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + async update( + @Body(new ZodValidationPipe(projectServiceContract.updateProjectServices.body)) body: PluginsUpdateBody, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + ): Promise { + const allowedRoles: Array<'user' | 'admin'> = AdminAuthorized.Manage(user.adminPermissions ?? 0n) ? ['user', 'admin'] : ['user'] + await this.projectServices.update(project.id, body, allowedRoles) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts new file mode 100644 index 0000000000..375e371303 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts @@ -0,0 +1,17 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProjectServicesModule } from './project-services.module' +import { ProjectServicesService } from './project-services.service' + +describe('projectServicesModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ProjectServicesService when USE_PLUGINS=false', async () => { + vi.stubEnv('USE_PLUGINS', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ProjectServicesModule, 'USE_PLUGINS')], + }).compile() + expect(() => module.get(ProjectServicesService)).toThrow() + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.module.ts new file mode 100644 index 0000000000..599b8cae0a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { PluginModule } from '../plugin/plugin.module' +import { ProjectServicesController } from './project-services.controller' +import { ProjectServicesService } from './project-services.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, ConditionalModule.registerWhen(PluginModule, 'USE_PLUGINS')], + controllers: [ProjectServicesController], + providers: [ProjectServicesService], + exports: [ProjectServicesService], +}) +export class ProjectServicesModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts new file mode 100644 index 0000000000..f0702f9a97 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts @@ -0,0 +1,175 @@ +import type { Plugin, ServiceInfos } from '@cpn-console/hooks' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ClusterWithZone, ProjectWithRelations } from './project-services-testing.utils' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PluginService } from '../plugin/plugin.service' +import { + makeClusterWithZone, + makeEnvironmentWithCluster, + makePlugin, + makeProjectPlugin, + makeProjectWithRelations, + makeServicesUpdateBody, +} from './project-services-testing.utils' +import { ProjectServicesService } from './project-services.service' + +function makeEmptyServiceInfo(name: string): ServiceInfos { + return { + name, + title: name, + config: { + global: [], + project: [], + }, + } +} + +describe('servicesService', () => { + let module: TestingModule + let service: ProjectServicesService + let prisma: DeepMockProxy + let projectId: string + let pluginName: string + let projectSlug: string + let serviceTitle: string + let serviceDescription: string + let serviceImgSrc: string + let project: ProjectWithRelations + let cluster: ClusterWithZone + let plugin: Plugin + + beforeEach(async () => { + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(faker.company.name()) + serviceTitle = faker.commerce.productName() + serviceDescription = faker.lorem.sentence() + serviceImgSrc = `/${faker.helpers.slugify(faker.commerce.product())}.svg` + + plugin = makePlugin({ + title: serviceTitle, + description: serviceDescription, + imgSrc: serviceImgSrc, + to: ({ project }) => `/projects/${project.slug}`, + }) + pluginName = plugin.infos.name + project = makeProjectWithRelations({ id: projectId, slug: projectSlug }) + cluster = makeClusterWithZone() + + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectServicesService, + { provide: PrismaService, useValue: prisma }, + { provide: PluginService, useValue: { infos: async () => [plugin.infos, makeEmptyServiceInfo('gitlab'), makeEmptyServiceInfo('harbor'), makeEmptyServiceInfo('keycloak'), makeEmptyServiceInfo('nexus'), makeEmptyServiceInfo('sonarqube'), makeEmptyServiceInfo('vault')] } }, + ], + }).compile() + + service = module.get(ProjectServicesService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('get returns the service with actual hooks data', async () => { + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectPlugin.findMany.mockResolvedValue([ + makeProjectPlugin({ projectId, pluginName, value: ENABLED }), + ]) + prisma.adminPlugin.findMany.mockResolvedValue([]) + prisma.cluster.findMany.mockResolvedValue([cluster]) + + const result = await service.get(projectId, 'user') + const e2eService = result.find(entry => entry.name === pluginName) + + expect(e2eService).toMatchObject({ + name: pluginName, + title: serviceTitle, + imgSrc: serviceImgSrc, + urls: [{ to: `/projects/${projectSlug}`, name: '' }], + }) + expect(e2eService?.manifest.project?.[0]).toMatchObject({ + key: 'enabled', + value: ENABLED, + }) + }) + + it('get includes zones coming from project environments', async () => { + const baseZone = makeClusterWithZone().zone + const environmentZone = makeClusterWithZone({ + zone: { + ...baseZone, + argocdUrl: 'https://argocd.example.com', + }, + }) + const projectWithEnvironmentZones = makeProjectWithRelations({ + clusters: [], + environments: [ + makeEnvironmentWithCluster({ + cluster: environmentZone, + }), + ], + }) + const envZonePlugin = makePlugin({ + to: ({ zones }) => zones.map(zone => ({ + to: `${zone.argocdUrl}/applications?search=${zone.slug}`, + title: zone.label, + })), + }) + + prisma.project.findUnique.mockResolvedValue(projectWithEnvironmentZones) + prisma.projectPlugin.findMany.mockResolvedValue([]) + prisma.adminPlugin.findMany.mockResolvedValue([]) + prisma.cluster.findMany.mockResolvedValue([]) + + const envModule = await Test.createTestingModule({ + providers: [ + ProjectServicesService, + { provide: PrismaService, useValue: prisma }, + { provide: PluginService, useValue: { infos: async () => [envZonePlugin.infos] } }, + ], + }).compile() + const envService = envModule.get(ProjectServicesService) + + const result = await envService.get(projectId, 'user') + + expect(result.find(entry => entry.name === envZonePlugin.infos.name)).toMatchObject({ + urls: [{ to: `${environmentZone.zone.argocdUrl}/applications?search=${environmentZone.zone.slug}`, name: environmentZone.zone.label }], + }) + await envModule.close() + }) + + it('update stores project configuration through the real utility', async () => { + const data = makeServicesUpdateBody(pluginName) + + await service.update(projectId, data, ['user']) + + expect(prisma.projectPlugin.upsert).toHaveBeenCalledWith({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key: 'enabled', + }, + }, + create: { + projectId, + pluginName, + key: 'enabled', + value: data[pluginName].enabled, + }, + update: { + pluginName, + key: 'enabled', + value: data[pluginName].enabled, + }, + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.service.ts new file mode 100644 index 0000000000..572e947767 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.service.ts @@ -0,0 +1,105 @@ +import type { ZoneObject } from '@cpn-console/hooks' +import type { PermissionTarget, PluginsUpdateBody, ProjectService } from '@cpn-console/shared' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PluginService } from '../plugin/plugin.service' +import { getServicesQueryData } from './project-services-queries.utils' +import { + buildProjectEditStrippers, + generatePluginsUpdateBody, + normalizeServiceUrls, + parsePluginsUpdateBody, + populateServiceManifest, + saveProjectStore, +} from './project-services.utils' + +@Injectable() +export class ProjectServicesService { + private readonly logger = new Logger(ProjectServicesService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(PluginService) private readonly plugin: PluginService, + ) {} + + async get(projectId: string, permissionTarget: PermissionTarget): Promise { + const [project, projectStore, globalConfig, publicClusters] = await getServicesQueryData(this.prisma, projectId) + + if (!project) { + throw new NotFoundException('Projet introuvable') + } + + const store = generatePluginsUpdateBody([...projectStore, ...globalConfig]) + const clusters = [...project.clusters, ...publicClusters] + const zones = new Map() + for (const cluster of clusters) { + zones.set(cluster.zone.id, cluster.zone) + } + for (const environment of project.environments) { + const zone = environment.cluster?.zone + if (zone) zones.set(zone.id, zone) + } + const serviceInfos = await this.plugin.infos(projectId) + + const services = await Promise.all(serviceInfos.map(async (serviceInfo) => { + const { name, title, to, imgSrc, description } = serviceInfo + let toResponse: unknown = [] + if (to) { + try { + toResponse = to({ + clusters: clusters.map(c => ({ + id: c.id, + label: c.label, + privacy: c.privacy, + clusterResources: c.clusterResources, + infos: c.infos, + zone: c.zone, + })), + zones: [...zones.values()], + environments: project.environments, + project: { + id: project.id, + name: project.name, + slug: project.slug, + }, + store, + }) + } catch (error) { + this.logger.debug({ err: error, service: name }, 'Service URL generation failed, returning no URLs') + } + } + + const urls = normalizeServiceUrls(toResponse) + const manifest = populateServiceManifest({ + service: serviceInfo, + data: { + project: projectStore, + global: globalConfig, + }, + permissionTarget, + select: { + global: true, + project: true, + }, + }) + + return { imgSrc, title, name, urls, manifest, description } + })) + + return services.filter(service => service.urls.length || service.manifest.global?.length || service.manifest.project?.length) + } + + async update(projectId: string, data: PluginsUpdateBody, stripperRoles: Array<'user' | 'admin'>) { + this.logger.log(`Update project services started (projectId=${projectId}, pluginCount=${Object.keys(data).length}, stripperRoles=${stripperRoles.join(',')})`) + const editStrippers = buildProjectEditStrippers(await this.plugin.infos(projectId)) + + for (const role of stripperRoles) { + const parsedData = editStrippers.project[role].safeParse(data) + if (!parsedData.success) continue + + await saveProjectStore(parsePluginsUpdateBody(parsedData.data), projectId, this.prisma) + } + + this.logger.log(`Update project services completed (projectId=${projectId})`) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.utils.ts new file mode 100644 index 0000000000..c5961851e1 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project-services/project-services.utils.ts @@ -0,0 +1,125 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { PermissionTarget, PluginConfig, PluginsUpdateBody, ServiceUrl } from '@cpn-console/shared' +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { editStrippersGenerator } from '@cpn-console/hooks' +import { atomicValidators, DEFAULT } from '@cpn-console/shared' +import { z } from 'zod' + +export interface PluginRecord { + pluginName: string + key: string + value: string +} + +export function generatePluginsUpdateBody(records: PluginRecord[]): PluginsUpdateBody { + const obj: PluginsUpdateBody = {} + for (const record of records) { + if (!obj[record.pluginName]) obj[record.pluginName] = {} + obj[record.pluginName][record.key] = record.value + } + return obj +} + +export function parsePluginsUpdateBody(obj: PluginsUpdateBody): PluginRecord[] { + return Object.entries(obj) + .flatMap(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) +} + +export function buildProjectEditStrippers(services: ServiceInfos[]) { + const project = { + user: z.object({}), + admin: z.object({}), + } + let global = z.object({}) + + for (const service of services) { + if (!service.config) continue + const editZod = editStrippersGenerator.parse(service.config) + global = global.merge(z.object({ [service.name]: editZod.global.default({}) })) + project.admin = project.admin.merge(z.object({ [service.name]: editZod.project.admin.default({}) })) + project.user = project.user.merge(z.object({ [service.name]: editZod.project.user.default({}) })) + } + + return { project, global } +} + +interface ServiceManifestParams { + service: ServiceInfos + data: { project?: PluginRecord[], global?: PluginRecord[] } + permissionTarget: PermissionTarget + select: Partial> +} + +export function populateServiceManifest({ service, data, select, permissionTarget }: ServiceManifestParams): Partial { + const manifest = structuredClone(service.config) + + const selected: Partial = {} + for (const [scope] of Object.entries(select).filter(([_scope, bool]) => bool)) { + if (!manifest?.[scope]) continue + selected[scope] = manifest[scope].filter(item => item.permissions[permissionTarget].read || item.permissions[permissionTarget].write).map((item) => { + const row = data[scope]?.find(candidate => candidate.pluginName === service.name && candidate.key === item.key) + if (item.kind === 'switch') { + const value = atomicValidators.switch.safeParse(row?.value) + item.value = value.success ? value.data : DEFAULT + } else { + item.value = z.coerce.string().parse(row?.value ?? item.value ?? '') + } + return item + }) + } + return selected +} + +export function normalizeServiceUrls(toResponse: unknown): ServiceUrl[] { + if (Array.isArray(toResponse)) { + return toResponse.map(res => ({ name: res.title ?? '', description: res.description ?? '', to: res.to })) + } + + if (typeof toResponse === 'string') { + return [{ to: toResponse, name: '' }] + } + + if (toResponse) { + return [{ name: (toResponse as { title?: string }).title ?? '', to: (toResponse as { to: string }).to }] + } + + return [] +} + +export async function saveProjectStore(records: PluginRecord[], projectId: string, prisma: PrismaService) { + for (const { pluginName, key, value } of records) { + if (value === null) { + await prisma.projectPlugin.delete({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } else { + await prisma.projectPlugin.upsert({ + create: { + pluginName, + projectId, + key, + value: value.toString(), + }, + update: { + key, + value: value.toString(), + pluginName, + }, + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project-queries.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project-queries.utils.ts new file mode 100644 index 0000000000..8469ab9cee --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project-queries.utils.ts @@ -0,0 +1,311 @@ +import type { Prisma } from '@prisma/client' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + status: true, + locked: true, + limitless: true, + hprodCpu: true, + hprodGpu: true, + hprodMemory: true, + prodCpu: true, + prodGpu: true, + prodMemory: true, + everyonePerms: true, + ownerId: true, + createdAt: true, + updatedAt: true, + lastSuccessProvisionningVersion: true, + owner: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + type: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + }, + }, + members: { + select: { + roleIds: true, + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + type: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + }, + }, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + roles: { + select: { + id: true, + name: true, + permissions: true, + position: true, + oidcGroup: true, + type: true, + projectId: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + isPrivate: true, + externalRepoUrl: true, + externalUserName: true, + helmValuesFiles: true, + deployRevision: true, + deployPath: true, + createdAt: true, + updatedAt: true, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + clusterId: true, + stageId: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + deployments: { + select: { + id: true, + name: true, + autosync: true, + createdAt: true, + updatedAt: true, + environment: { + select: { + id: true, + name: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + deploymentSources: { + select: { + id: true, + type: true, + path: true, + targetRevision: true, + helmValuesFiles: true, + repository: { + select: { + id: true, + internalRepoName: true, + }, + }, + }, + }, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectForUpdateSelect = { + id: true, + ownerId: true, + status: true, + locked: true, + members: { + select: { + userId: true, + user: { + select: { + type: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectForDataSelect = { + name: true, + description: true, + createdAt: true, + updatedAt: true, + environments: { + select: { + name: true, + stage: true, + cluster: { + select: { label: true }, + }, + }, + }, + owner: true, +} satisfies Prisma.ProjectSelect + +export const projectIdSelect = { + id: true, +} satisfies Prisma.ProjectSelect + +export const projectSlugSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export const projectContextSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export const projectForUpsertSelect = { + id: true, + slug: true, + status: true, + locked: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export type ProjectUpdateContext = Prisma.ProjectGetPayload<{ + select: typeof projectForUpdateSelect +}> + +export type ProjectDataExport = Prisma.ProjectGetPayload<{ + select: typeof projectForDataSelect +}> + +export type ProjectContext = Prisma.ProjectGetPayload<{ + select: typeof projectContextSelect +}> + +export type ProjectForUpsert = Prisma.ProjectGetPayload<{ + select: typeof projectForUpsertSelect +}> + +export function getProject(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectSelect }) +} + +export function getProjectNotArchived(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findFirst({ where: { id: projectId, status: { not: 'archived' } }, select: projectSelect }) +} + +export function listProjects(tx: Prisma.TransactionClient, whereAnd: Prisma.ProjectWhereInput[]) { + return tx.project.findMany({ + where: { AND: whereAnd }, + select: projectSelect, + }) +} + +export function listProjectSlugsForPrefix(tx: Prisma.TransactionClient, prefix: string) { + return tx.project.findMany({ + where: { slug: { startsWith: prefix } }, + select: { slug: true }, + }) +} + +export function getProjectSlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: { slug: true } }) +} + +export function getProjectContext(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectContextSelect }) +} + +export function listProjectsForDataExport(tx: Prisma.TransactionClient) { + return tx.project.findMany({ + select: projectForDataSelect, + }) +} + +export function createProject(tx: Prisma.TransactionClient, data: Prisma.ProjectCreateInput) { + return tx.project.create({ + data, + select: projectIdSelect, + }) +} + +export function getNotArchivedProjectForUpdate(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findFirst({ + where: { id: projectId, status: { not: 'archived' } }, + select: projectForUpdateSelect, + }) +} + +export function getProjectForUpsert(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectForUpsertSelect }) +} + +export function updateProject(tx: Prisma.TransactionClient, projectId: string, data: Prisma.ProjectUpdateInput) { + return tx.project.update({ where: { id: projectId }, data }) +} + +export function deleteProjectDependencies(tx: Prisma.TransactionClient, projectId: string) { + return Promise.all([ + tx.repository.deleteMany({ where: { projectId } }), + tx.environment.deleteMany({ where: { projectId } }), + tx.deployment.deleteMany({ where: { projectId } }), + ]) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project-testing.utils.ts new file mode 100644 index 0000000000..4205b88470 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project-testing.utils.ts @@ -0,0 +1,161 @@ +import type { projectContract } from '@cpn-console/shared' +import type { Prisma, ProjectMembers, User } from '@prisma/client' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { projectSelect, ProjectWithDetails } from './project-queries.utils' +import { PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +export function makeUser(overrides: Partial = {}): User { + return { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastLogin: null, + ...overrides, + } +} + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } +} + +type ProjectMembersWithUser = ProjectMembers & { user: User } + +export function makeProjectMemberWithUser(user: User, overrides: Partial = {}): ProjectMembersWithUser { + return { + ...makeProjectMembers({ userId: user.id, ...overrides }), + user, + } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + const owner = overrides.owner ?? makeUser() + const id = overrides.id ?? faker.string.uuid() + return { + id, + name: faker.string.alphanumeric(8).toLowerCase(), + slug: faker.string.alphanumeric(8).toLowerCase(), + description: faker.lorem.sentence(), + status: 'created', + locked: false, + limitless: false, + hprodCpu: 1, + hprodGpu: 0, + hprodMemory: 2, + prodCpu: 1, + prodGpu: 0, + prodMemory: 2, + everyonePerms: PROJECT_PERMS.GUEST, + ownerId: owner.id, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastSuccessProvisionningVersion: null, + owner, + members: [], + plugins: [], + roles: [], + repositories: [], + environments: [], + deployments: [], + clusters: [], + ...overrides, + } +} + +export function makeProjectContext(overrides: Partial = {}): ProjectContext { + return { + id: faker.string.uuid(), + slug: faker.string.alphanumeric(8).toLowerCase(), + locked: false, + status: 'created', + ...overrides, + } +} + +type Project = Prisma.ProjectGetPayload<{ select: typeof projectSelect }> + +export function makeProject(overrides: Partial = {}): Project { + const id = overrides.id ?? faker.string.uuid() + return { + id, + name: faker.string.alphanumeric(8).toLowerCase(), + slug: faker.string.alphanumeric(8).toLowerCase(), + description: '', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 896n, + ownerId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastSuccessProvisionningVersion: null, + owner: { + id: faker.string.uuid(), + email: 'owner@example.com', + firstName: 'Owner', + lastName: 'Test', + adminRoleIds: [], + type: 'human', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastLogin: faker.date.past(), + }, + members: [], + plugins: [], + roles: [], + repositories: [], + environments: [], + deployments: [], + clusters: [], + ...overrides, + } +} + +export type ProjectWithMembers = Prisma.ProjectGetPayload<{ + include: { members: { include: { user: true } } } +}> + +export function makeProjectWithMembersResult( + project: ProjectWithDetails, + members: Array> = [], +): ProjectWithMembers { + return { ...project, members } as ProjectWithMembers +} + +export function makeCreateProjectBody(overrides: Partial = {}): typeof projectContract.createProject.body._type { + return { + name: faker.string.alphanumeric({ length: faker.number.int({ min: 2, max: 20 }) }).toLowerCase(), + description: faker.lorem.sentence(), + limitless: true, + hprodCpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + hprodGpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + hprodMemory: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodCpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodGpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodMemory: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + ...overrides, + } +} + +export function makeListProjectsQuery(overrides: Partial = {}): typeof projectContract.listProjects.query._type { + return { + filter: 'member', + ...overrides, + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.controller.ts new file mode 100644 index 0000000000..a10afe449f --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.controller.ts @@ -0,0 +1,91 @@ +import type { ProjectV2 } from '@cpn-console/shared' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { AdminAuthorized, projectContract } from '@cpn-console/shared' +import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, Inject, Post, Put, Query, Req, UseGuards } from '@nestjs/common' +import { json2csv } from 'json-2-csv' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectAccess } from '../infrastructure/permission/project/project-access.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectService } from './project.service' +import { generateProjectV2 } from './project.utils' + +@Controller('api/v1/projects') +export class ProjectController { + constructor( + @Inject(ProjectService) private readonly project: ProjectService, + ) {} + + @Get('/data') + @UseGuards(UserGuard) + @RequireAdminPermission('Manage') + async getProjectsData(): Promise { + return json2csv(await this.project.getProjectsData()) + } + + @Get('') + @UseGuards(UserGuard) + async list( + @Query(new ZodValidationPipe(projectContract.listProjects.query)) query: typeof projectContract.listProjects.query._type, + @AuthUser() user: UserContext, + ): Promise { + if (query.filter === 'all' && !AdminAuthorized.Manage(user.adminPermissions)) { + throw new ForbiddenException('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + return (await this.project.listProjects(query, user)).map(generateProjectV2) + } + + @Post('') + @HttpCode(HttpStatus.CREATED) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageProjects') + async create( + @Body(new ZodValidationPipe(projectContract.createProject.body)) body: typeof projectContract.createProject.body._type, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return generateProjectV2(await this.project.create(body, user.userId, request.id)) + } + + @Get('/:projectId') + @UseGuards(ProjectGuard) + @RequireProjectAccess() + async get( + @Project() project: ProjectContext, + ): Promise { + return generateProjectV2(await this.project.get(project.id)) + } + + @Put('/:projectId') + @HttpCode(HttpStatus.OK) + @UseGuards(ProjectGuard) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectPermission('Manage') + async update( + @Body(new ZodValidationPipe(projectContract.updateProject.body)) data: typeof projectContract.updateProject.body._type, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return generateProjectV2(await this.project.update(data, user, project.id, request.id)) + } + + @Delete('/:projectId') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(ProjectGuard) + @RequireProjectPermission('Manage') + async archive( + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.project.archive(project.id, user.userId, request.id) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.module.ts new file mode 100644 index 0000000000..4e50667c67 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { LogModule } from '../log/log.module' +import { VaultModule } from '../vault/vault.module' +import { ProjectController } from './project.controller' +import { ProjectService } from './project.service' + +@Module({ + imports: [ + AppEventsModule, + AuthModule, + DatabaseModule, + EventsModule, + ProjectPermissionModule, + UserPermissionModule, + LogModule, + VaultModule, + ConfigModule.forFeature(baseConfigFactory), + ], + controllers: [ProjectController], + providers: [ProjectService], + exports: [ProjectService], +}) +export class ProjectModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.service.spec.ts new file mode 100644 index 0000000000..76de94e2fa --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -0,0 +1,521 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import { faker } from '@faker-js/faker' +import { + ForbiddenException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { + makeCreateProjectBody, + makeListProjectsQuery, + makeProject, + makeProjectContext, + makeProjectMemberWithUser, + makeProjectWithDetails, + makeProjectWithMembersResult, + makeUser, +} from './project-testing.utils' +import { ProjectService } from './project.service' +import { generateSlug } from './project.utils' + +describe('projectService', () => { + let module: TestingModule + let service: ProjectService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + let config: DeepMockProxy + let vaultConfig: DeepMockProxy + let logs: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + config = mockDeep({ appVersion: 'dev' }) + vaultConfig = mockDeep() + logs = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + { provide: BASE_CONFIG, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: LogService, useValue: logs }, + ], + }).compile() + + service = module.get(ProjectService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('create', () => { + it('generates slug, creates project, emits event, returns ProjectV2', async () => { + const userId = faker.string.uuid() + const body = makeCreateProjectBody() + const existingSlugs = [body.name, `${body.name}-1`] + const expectedSlug = generateSlug(body.name, existingSlugs) + const tx = mockDeep() + + tx.project.findMany.mockResolvedValue(existingSlugs.map(slug => makeProject({ slug }))) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const pwd = makeProjectWithDetails({ slug: expectedSlug }) + pwd.roles = Array.from({ length: 4 }, (_, index) => ({ + id: faker.string.uuid(), + name: `role-${index}`, + permissions: 0n, + position: index, + oidcGroup: '', + type: 'managed', + projectId: pwd.id, + })) as never + tx.project.create.mockResolvedValue(makeProject({ id: pwd.id })) + tx.project.findUnique.mockResolvedValue(pwd) + + const requestId = faker.string.uuid() + const result = await service.create(body, userId, requestId) + + expect(tx.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { slug: { startsWith: body.name } } }), + ) + expect(tx.project.create).toHaveBeenCalled() + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', pwd.id, { + action: 'Create Project', + userId, + requestId, + }) + expect(logs.addLog).toHaveBeenCalledTimes(4) + expect(result).toBeDefined() + expect(result.slug).toBe(expectedSlug) + }) + + it('throws InternalServerErrorException when project cannot be loaded after creation', async () => { + const userId = faker.string.uuid() + const body = makeCreateProjectBody() + + const tx = mockDeep() + tx.project.findMany.mockResolvedValue([]) + tx.project.create.mockResolvedValue(makeProject({ id: 'test-id' })) + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.create(body, userId, faker.string.uuid())) + .rejects.toThrow(InternalServerErrorException) + }) + }) + + describe('list', () => { + it('returns projects filtered by member for non-admin', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + const projects = [makeProjectWithDetails(), makeProjectWithDetails()] + prisma.project.findMany.mockResolvedValue(projects) + + const result = await service.listProjects( + makeListProjectsQuery(), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalled() + expect(result).toHaveLength(2) + expect(result[0]).toHaveProperty('id') + expect(result[0]).toHaveProperty('slug') + }) + + it('allows admin-only "all" filter for admin users', async () => { + const userId = faker.string.uuid() + const adminPerms = BigInt(2) + const user = { userId, adminPermissions: adminPerms } satisfies UserContext + prisma.project.findMany.mockResolvedValue([makeProjectWithDetails()]) + + const result = await service.listProjects( + makeListProjectsQuery({ filter: 'all' }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalled() + expect(result).toHaveLength(1) + }) + + it('forbids "all" filter for non-admin users', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + + await expect( + service.listProjects(makeListProjectsQuery({ filter: 'all' }), user), + ).rejects.toThrow(ForbiddenException) + }) + + it('filters by status', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + prisma.project.findMany.mockResolvedValue([]) + + await service.listProjects( + makeListProjectsQuery({ status: 'created' }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ status: 'created' }), + ]), + }), + }), + ) + }) + + it('filters by search term', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + const search = 'myproject' + prisma.project.findMany.mockResolvedValue([]) + + await service.listProjects( + makeListProjectsQuery({ search }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ + OR: [ + { name: { contains: search } }, + { owner: { email: { contains: search } } }, + ], + }), + ]), + }), + }), + ) + }) + }) + + describe('get', () => { + it('returns ProjectV2 for non-archived project', async () => { + const ctx = makeProjectContext({ status: 'created' }) + const pwd = makeProjectWithDetails({ id: ctx.id, status: 'created' }) + prisma.project.findFirst.mockResolvedValue(pwd) + + const result = await service.get(ctx.id) + + expect(result).toBeDefined() + expect(result.id).toBe(ctx.id) + expect(result.status).toBe('created') + }) + + it('throws NotFoundException for archived project', async () => { + const ctx = makeProjectContext({ status: 'archived' }) + prisma.project.findFirst.mockResolvedValue(null) + + await expect(service.get(ctx.id)).rejects.toThrow(NotFoundException) + }) + + it('throws NotFoundException when project cannot be loaded', async () => { + const ctx = makeProjectContext({ status: 'created' }) + prisma.project.findFirst.mockResolvedValue(null) + + await expect(service.get(ctx.id)).rejects.toThrow(NotFoundException) + }) + }) + + describe('update', () => { + it('updates description and returns updated project', async () => { + const ctx = makeProjectContext({ status: 'created', locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id }) + const updatedPwd = makeProjectWithDetails({ id: ctx.id, description: 'Updated desc' }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(updatedPwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const requestId = faker.string.uuid() + const result = await service.update( + { description: 'Updated desc' }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + requestId, + ) + + expect(result.description).toBe('Updated desc') + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', ctx.id, { + action: 'Update Project', + userId: requestorId, + requestId, + }) + }) + + it('strips locked field for non-admin', async () => { + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: true }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: {}, + }), + ) + }) + + it('allows admin to update locked field', async () => { + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const adminPerms = BigInt(2) + const pwd = makeProjectWithDetails({ id: ctx.id }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: true }, + { userId: requestorId, adminPermissions: adminPerms }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: expect.objectContaining({ locked: true }), + }), + ) + }) + + it('strips ownerId for non-owner non-admin', async () => { + const ownerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { ownerId: faker.string.uuid() }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: {}, + }), + ) + }) + + it('throws ForbiddenException when project is locked and not unlocking', async () => { + const ctx = makeProjectContext({ locked: true }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id, locked: true }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { description: 'test' }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow(ForbiddenException) + }) + + it('allows admin to unlock a locked project', async () => { + const ctx = makeProjectContext({ locked: true }) + const requestorId = faker.string.uuid() + const adminPerms = BigInt(2) + const pwd = makeProjectWithDetails({ id: ctx.id, locked: true }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: false }, + { userId: requestorId, adminPermissions: adminPerms }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalled() + }) + + it('validates new owner is a member of the project', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow('Le nouveau propriétaire doit faire partie des membres actuels du projet') + }) + + it('validates new owner is a human account', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue( + makeProjectWithMembersResult(pwd, [makeProjectMemberWithUser(makeUser({ id: newOwnerId, type: 'bot' }))]), + ) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow('Seuls les comptes humains peuvent être propriétaire de projets') + }) + + it('transfers ownership correctly', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue( + makeProjectWithMembersResult(pwd, [makeProjectMemberWithUser( + makeUser({ id: newOwnerId, type: 'human' }), + { roleIds: [faker.string.uuid()] }, + )]), + ) + tx.project.findUnique.mockResolvedValue( + makeProjectWithDetails({ id: ctx.id, ownerId: newOwnerId }), + ) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.projectMembers.delete).toHaveBeenCalledWith({ + where: { projectId_userId: { userId: newOwnerId, projectId: ctx.id } }, + }) + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + owner: { connect: { id: newOwnerId } }, + }, + }), + ) + expect(result).toBeDefined() + }) + }) + + describe('archive', () => { + it('deletes related data, emits event, renames and archives project', async () => { + const projectId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: projectId, name: 'myproject', slug: 'myproject' }) + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(pwd) + tx.repository.deleteMany.mockResolvedValue({ count: 2 }) + tx.environment.deleteMany.mockResolvedValue({ count: 3 }) + tx.deployment.deleteMany.mockResolvedValue({ count: 1 }) + tx.project.update.mockResolvedValue(makeProject({ id: projectId })) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const requestId = faker.string.uuid() + const requestorId = faker.string.uuid() + + await service.archive(projectId, requestorId, requestId) + + expect(tx.repository.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.environment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.deployment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.delete', pwd, { + action: 'Delete all project resources', + userId: requestorId, + requestId, + }) + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: projectId }, + data: expect.objectContaining({ + status: 'archived', + locked: true, + clusters: { set: [] }, + }), + }), + ) + }) + + it('throws NotFoundException when project does not exist', async () => { + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.archive(faker.string.uuid())) + .rejects.toThrow(NotFoundException) + }) + }) + + describe('getData', () => { + it('returns CSV data array', async () => { + prisma.project.findMany.mockResolvedValue([makeProjectWithDetails()]) + + const result = await service.getProjectsData() + + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(1) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.service.ts new file mode 100644 index 0000000000..cbb6a8cf1c --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.service.ts @@ -0,0 +1,333 @@ +import type { projectContract } from '@cpn-console/shared' +import type { ConfigType } from '@nestjs/config' +import type { Prisma } from '@prisma/client' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectDataExport, ProjectUpdateContext, ProjectWithDetails } from './project-queries.utils' +import { AdminAuthorized } from '@cpn-console/shared' +import { BadRequestException, ForbiddenException, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { baseConfigFactory } from '../../config/base.config' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { LogService } from '../log/log.service' +import { createProjectMember, deleteProjectMember } from '../project-members/project-members-queries.utils' +import { + createProject, + deleteProjectDependencies, + getNotArchivedProjectForUpdate, + getProject, + listProjectSlugsForPrefix as listProjectSlugsForSlugPrefix, + projectForDataSelect, + projectSelect, + updateProject, +} from './project-queries.utils' +import { generateProjectCreateInput, generateProjectWhereInput, generateSlug, parseProjectUpdateInput } from './project.utils' + +@Injectable() +export class ProjectService { + private readonly logger = new Logger(ProjectService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, + @Inject(LogService) private readonly logs: LogService, + ) {} + + @StartActiveSpan() + async getProjectsData(): Promise { + const span = trace.getActiveSpan() + this.logger.log('project.getData requested') + const data = await this.listProjectsForDataExport() + span?.setAttribute('project.data.count', data.length) + this.logger.log(`project.getData completed (count=${data.length})`) + return data + } + + @StartActiveSpan() + async listProjects( + query: typeof projectContract.listProjects.query._type, + user: UserContext, + ): Promise { + const span = trace.getActiveSpan() + const { filter = 'member' } = query + span?.setAttribute('project.list.filter', filter) + span?.setAttribute('user.id', user.userId) + + if (filter === 'all' && !AdminAuthorized.Manage(user.adminPermissions)) { + this.logger.warn(`project.list forbidden (requestorUserId=${user.userId}, filter=${filter})`) + throw new ForbiddenException('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + + const whereAnd = generateProjectWhereInput({ + query, + requestorUserId: user.userId, + appVersion: this.baseConfig.appVersion, + }) + + this.logger.debug(`project.list started (requestorUserId=${user.userId}, filter=${filter})`) + const projects = await this.fetchProjects(whereAnd) + span?.setAttribute('project.list.count', projects.length) + this.logger.debug(`project.list completed (requestorUserId=${user.userId}, filter=${filter}, count=${projects.length})`) + + return projects + } + + @StartActiveSpan() + async get(projectId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.debug(`project.get started (projectId=${projectId})`) + const project = await this.getProjectNotArchived(projectId) + if (!project) { + this.logger.warn(`project.get notFound (projectId=${projectId})`) + throw new NotFoundException('Projet introuvable') + } + this.logger.debug(`project.get completed (projectId=${projectId})`) + return project + } + + @StartActiveSpan() + async create( + body: typeof projectContract.createProject.body._type, + requestorUserId: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('user.id', requestorUserId) + this.logger.log(`project.create started (requestorUserId=${requestorUserId}, projectName=${body.name})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const existingSlugs = await listProjectSlugsForSlugPrefix(tx, body.name) + const slug = generateSlug(body.name, existingSlugs.map(s => s.slug)) + + const created = await createProject(tx, generateProjectCreateInput(body, requestorUserId, slug)) + const loaded = await getProject(tx, created.id) + if (!loaded) throw new InternalServerErrorException('Project created but cannot be loaded') + return loaded + }) + await this.appEvents.emitProjectEvent('project.upsert', project.id, { + action: 'Create Project', + userId: requestorUserId, + requestId, + }) + await Promise.all(project.roles.map(async role => this.logProjectRoleAction( + 'Upsert Project Role', + project, + role.id, + `Rôle synchronisé: ${role.name}`, + requestorUserId, + requestId, + ))) + span?.setAttribute('project.id', project.id) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.create completed (requestorUserId=${requestorUserId}, projectId=${project.id}, slug=${project.slug})`) + return project + } catch (error) { + this.logger.error( + `project.create failed (requestorUserId=${requestorUserId}, projectName=${body.name}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + async update( + body: typeof projectContract.updateProject.body._type, + user: UserContext, + projectId: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('user.id', user.userId) + this.logger.log(`project.update started (projectId=${projectId}, requestorUserId=${user.userId})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const projectDb = await getNotArchivedProjectForUpdate(tx, projectId) + if (!projectDb) throw new NotFoundException('Projet introuvable') + + const { effectiveData, strippedKeys } = this.stripProjectUpdateBody(body, user, projectDb) + this.logProjectUpdateStrippedFields(span, projectId, user.userId, strippedKeys) + if (projectDb.locked && effectiveData.locked !== false) { + throw new ForbiddenException('Veuillez déverrouiller le projet pour le mettre à jour') + } + + await this.updateProjectOwnerIfNeeded(tx, projectDb, effectiveData, projectId, user.userId) + + const updateData = parseProjectUpdateInput(effectiveData) + const effectiveKeys = Object.keys(effectiveData) + span?.setAttribute('project.update.effectiveKeys.count', effectiveKeys.length) + await updateProject(tx, projectId, updateData) + + const updated = await getProject(tx, projectId) + if (!updated) throw new NotFoundException('Projet introuvable') + this.logger.log(`project.update dbUpdated (projectId=${projectId}, requestorUserId=${user.userId}, effectiveKeys=${effectiveKeys.join(',')})`) + return updated + }) + await this.appEvents.emitProjectEvent('project.upsert', project.id, { + action: 'Update Project', + userId: user.userId, + requestId, + }) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.update completed (projectId=${projectId}, requestorUserId=${user.userId})`) + return project + } catch (error) { + this.logger.error( + `project.update failed (projectId=${projectId}, requestorUserId=${user.userId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async archive(projectId: string, requestorUserId?: string, requestId?: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.archive started (projectId=${projectId})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const loaded = await getProject(tx, projectId) + if (!loaded) throw new NotFoundException('Projet introuvable') + + await deleteProjectDependencies(tx, projectId) + + const archivedSuffix = `${Date.now()}_archived` + await updateProject(tx, projectId, { + name: `${loaded.name}_${archivedSuffix}`, + slug: `${loaded.slug}_${archivedSuffix}`, + status: 'archived', + locked: true, + clusters: { set: [] }, + }) + + return loaded + }) + // pass the pre-archive snapshot: the row was renamed (slug suffixed) in the + // transaction above, listeners must clean up resources named after the old slug + await this.appEvents.emitProjectEvent('project.delete', project, { + action: 'Delete all project resources', + userId: requestorUserId, + requestId, + }) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.archive completed (projectId=${projectId}, slug=${project.slug})`) + } catch (error) { + this.logger.error( + `project.archive failed (projectId=${projectId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async logProjectRoleAction( + action: string, + project: ProjectWithDetails, + roleId: string, + messageResume: string, + userId: string | undefined, + requestId: string | undefined, + ): Promise { + await this.logs.addLog({ + action, + data: { + args: { projectId: project.id, roleId }, + messageResume, + results: { projectId: project.id, roleId }, + }, + userId, + requestId, + projectId: project.id, + }) + } + + private async listProjectsForDataExport(): Promise { + return this.prisma.project.findMany({ + select: projectForDataSelect, + }) + } + + private async fetchProjects(whereAnd: Prisma.ProjectWhereInput[]): Promise { + return this.prisma.project.findMany({ + where: { AND: whereAnd }, + select: projectSelect, + }) + } + + private async getProjectNotArchived(projectId: string): Promise { + return this.prisma.project.findFirst({ + where: { id: projectId, status: { not: 'archived' } }, + select: projectSelect, + }) + } + + private stripProjectUpdateBody( + body: typeof projectContract.updateProject.body._type, + user: UserContext, + project: ProjectUpdateContext, + ): { effectiveData: Record, strippedKeys: string[] } { + const isOwner = project.ownerId === user.userId + const isAdmin = AdminAuthorized.Manage(user.adminPermissions) + const effectiveData: Record = { ...body } + const strippedKeys: string[] = [] + + if (!isAdmin) { + if ('locked' in effectiveData) strippedKeys.push('locked') + delete effectiveData.locked + + if (!isOwner) { + if ('ownerId' in effectiveData) strippedKeys.push('ownerId') + delete effectiveData.ownerId + } + } + + return { effectiveData, strippedKeys } + } + + private logProjectUpdateStrippedFields( + span: ReturnType, + projectId: string, + requestorUserId: string, + strippedKeys: string[], + ): void { + if (!strippedKeys.length) return + + span?.setAttribute('project.update.strippedKeys.count', strippedKeys.length) + this.logger.debug(`project.update strippedFields (projectId=${projectId}, requestorUserId=${requestorUserId}, strippedKeys=${strippedKeys.join(',')})`) + } + + private async updateProjectOwnerIfNeeded( + tx: Prisma.TransactionClient, + project: ProjectUpdateContext, + effectiveData: Record, + projectId: string, + requestorUserId: string, + ): Promise { + const ownerIdCandidate = effectiveData.ownerId as string | undefined + if (!ownerIdCandidate || ownerIdCandidate === project.ownerId) return + + this.logger.log(`project.update ownerChange started (projectId=${projectId}, requestorUserId=${requestorUserId}, previousOwnerId=${project.ownerId}, nextOwnerId=${ownerIdCandidate})`) + + const memberCandidate = project.members.find(member => member.userId === ownerIdCandidate) + if (!memberCandidate) { + throw new BadRequestException('Le nouveau propriétaire doit faire partie des membres actuels du projet') + } + if (memberCandidate.user.type !== 'human') { + throw new BadRequestException('Seuls les comptes humains peuvent être propriétaire de projets') + } + + const oldOwnerIsMember = project.members.some(member => member.userId === project.ownerId) + if (!oldOwnerIsMember) { + await createProjectMember(tx, project.id, project.ownerId) + } + + await deleteProjectMember(tx, project.id, ownerIdCandidate) + await updateProject(tx, project.id, { owner: { connect: { id: ownerIdCandidate } } }) + + this.logger.log(`project.update ownerChange completed (projectId=${projectId}, requestorUserId=${requestorUserId}, previousOwnerId=${project.ownerId}, nextOwnerId=${ownerIdCandidate})`) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.utils.ts new file mode 100644 index 0000000000..bb50d21fdf --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/project/project.utils.ts @@ -0,0 +1,226 @@ +import type { CreateProjectBody, projectContract } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import type { ProjectWithDetails } from './project-queries.utils' +import { PROJECT_PERMS, ProjectSchemaV2, ProjectStatusSchema } from '@cpn-console/shared' +import { ProjectStatus } from '@prisma/client' +import { z } from 'zod' + +export function generateSlug(prefix: string, existingSlugs: string[] = []) { + if (!existingSlugs.includes(prefix)) return prefix + + let suffix = 1 + while (existingSlugs.includes(`${prefix}-${suffix}`)) { + suffix += 1 + } + return `${prefix}-${suffix}` +} + +export function generateProjectCreateInput( + data: CreateProjectBody, + ownerId: string, + slug: string, +): Prisma.ProjectCreateInput { + return { + name: data.name, + slug, + description: data.description ?? '', + status: ProjectStatus.created, + locked: false, + limitless: z.boolean().parse(data.limitless), + hprodCpu: data.hprodCpu, + hprodGpu: data.hprodGpu, + hprodMemory: data.hprodMemory, + prodCpu: data.prodCpu, + prodGpu: data.prodGpu, + prodMemory: data.prodMemory, + owner: { connect: { id: ownerId } }, + roles: { + create: [ + { + name: 'Administrateur', + permissions: PROJECT_PERMS.MANAGE, + position: 0, + oidcGroup: `/${slug}/console/admin`, + type: 'system:managed', + }, + { + name: 'DevOps', + permissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS + | PROJECT_PERMS.MANAGE_REPOSITORIES + | PROJECT_PERMS.REPLAY_HOOKS + | PROJECT_PERMS.SEE_SECRETS + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 1, + oidcGroup: `/${slug}/console/devops`, + type: 'system:managed', + }, + { + name: 'Développeur', + permissions: PROJECT_PERMS.MANAGE_REPOSITORIES + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 2, + oidcGroup: `/${slug}/console/developer`, + type: 'system:managed', + }, + { + name: 'Lecture seule', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 3, + oidcGroup: `/${slug}/console/readonly`, + type: 'system:managed', + }, + ], + }, + } +} + +export const ProjectV2ResponseSchema = ProjectSchemaV2.omit({ name: true }).extend({ + name: z.string(), +}) + +export function generateProjectV2(project: ProjectWithDetails) { + const payload = { + id: project.id, + name: project.name, + slug: project.slug, + description: project.description, + status: project.status, + locked: project.locked, + limitless: project.limitless, + hprodCpu: project.hprodCpu, + hprodGpu: project.hprodGpu, + hprodMemory: project.hprodMemory, + prodCpu: project.prodCpu, + prodGpu: project.prodGpu, + prodMemory: project.prodMemory, + everyonePerms: project.everyonePerms, + ownerId: project.ownerId, + owner: project.owner, + members: project.members.map(m => ({ + userId: m.user.id, + email: m.user.email, + firstName: m.user.firstName, + lastName: m.user.lastName, + roleIds: m.roleIds, + })), + roles: project.roles.map(role => ({ + id: role.id, + name: role.name, + permissions: role.permissions, + position: role.position, + projectId: role.projectId, + oidcGroup: role.oidcGroup ? role.oidcGroup.replace(`/${project.slug}`, '') : '', + type: role.type, + })), + clusterIds: project.clusters.map(c => c.id), + lastSuccessProvisionningVersion: project.lastSuccessProvisionningVersion, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + } + + return ProjectV2ResponseSchema.parse(payload) +} + +export function generateProjectWhereInput(opts: { + query: typeof projectContract.listProjects.query._type + requestorUserId: string + appVersion: string +}): Prisma.ProjectWhereInput[] { + const projectStatus = ProjectStatusSchema.options + const { status, statusIn, statusNotIn, filter = 'member', ...rest } = opts.query + + const whereAnd: Prisma.ProjectWhereInput[] = [] + if (rest.id) whereAnd.push({ id: rest.id }) + if (rest.locked !== undefined) whereAnd.push({ locked: rest.locked }) + if (rest.name) whereAnd.push({ name: rest.name }) + if (rest.description) whereAnd.push({ description: { contains: rest.description } }) + + const statusWhere = parseEnumWhereFilter({ + enumValues: projectStatus, + eqValue: status, + inValues: statusIn, + notInValues: statusNotIn, + }) + if (statusWhere) whereAnd.push({ status: statusWhere }) + + if (rest.lastSuccessProvisionningVersion) { + if (rest.lastSuccessProvisionningVersion === 'outdated') { + whereAnd.push({ lastSuccessProvisionningVersion: { not: opts.appVersion } }) + } else if (rest.lastSuccessProvisionningVersion === 'last') { + whereAnd.push({ lastSuccessProvisionningVersion: { equals: opts.appVersion } }) + } else { + whereAnd.push({ lastSuccessProvisionningVersion: rest.lastSuccessProvisionningVersion }) + } + } + + if (rest.search) { + whereAnd.push({ + OR: [ + { name: { contains: rest.search } }, + { owner: { email: { contains: rest.search } } }, + ], + }) + } + + if (filter === 'owned') { + whereAnd.push({ ownerId: opts.requestorUserId }) + } else if (filter === 'member') { + whereAnd.push({ + OR: [ + { members: { some: { userId: opts.requestorUserId } } }, + { ownerId: opts.requestorUserId }, + ], + }) + } + + return whereAnd +} + +export function parseEnumWhereFilter({ + enumValues, + eqValue, + inValues, + notInValues, +}: { + enumValues: T + eqValue: T[number] | undefined + inValues: string | undefined + notInValues: string | undefined +}): + | T[number] + | { in: T[number][] } + | { notIn: T[number][] } + | undefined { + if (eqValue) { + return eqValue + } + if (inValues) { + return { in: parseCsvEnumList(enumValues, inValues) } + } + if (notInValues) { + return { notIn: parseCsvEnumList(enumValues, notInValues) } + } +} + +const ProjectUpdateDataSchema = z.object({ + description: z.string().optional(), + locked: z.boolean().optional(), + limitless: z.boolean().optional(), + hprodCpu: z.number().optional(), + hprodGpu: z.number().optional(), + hprodMemory: z.number().optional(), + prodCpu: z.number().optional(), + prodGpu: z.number().optional(), + prodMemory: z.number().optional(), + everyonePerms: z.union([z.string(), z.number(), z.bigint()]).transform(BigInt).optional(), +}).passthrough() + +export function parseProjectUpdateInput(effectiveData: Record): Prisma.ProjectUpdateInput { + return ProjectUpdateDataSchema.parse(effectiveData) satisfies Prisma.ProjectUpdateInput +} + +function parseCsvEnumList(toMatch: T, inputs: string): T[number][] { + return inputs.split(',').filter(i => toMatch.includes(i)) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts new file mode 100644 index 0000000000..f9e676753d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts @@ -0,0 +1,22 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface HarborConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + ruleTemplate?: string + ruleCount?: string + retentionCron: string + robotRotationThresholdDays: number + projectSlugCacheTtlMs: number + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface HarborModuleOptions extends HarborConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('harbor') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as HARBOR_CONFIG } diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts new file mode 100644 index 0000000000..057bd83f04 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts @@ -0,0 +1,96 @@ +import type { HarborConfig } from './harbor.module-definition' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { VaultClientService } from '../vault/vault-client.service' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryHttpClientService } from './registry-http-client.service' + +const harborUrl = 'https://harbor.example' +const harborAdminPassword = faker.internet.password() +const basicAuth = `Basic ${Buffer.from(`admin:${harborAdminPassword}`, 'utf8').toString('base64')}` + +const server = setupServer() + +describe('registryService', () => { + let service: RegistryClientService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + beforeEach(async () => { + const harborConfig = mockDeep({ + url: harborUrl, + internalUrl: harborUrl, + internalOrPublicUrl: harborUrl, + admin: 'admin', + adminPassword: harborAdminPassword, + ruleTemplate: 'latestPushedK', + ruleCount: '10', + retentionCron: '0 22 2 * * *', + }) + + const module = await Test.createTestingModule({ + providers: [ + RegistryClientService, + RegistryHttpClientService, + { + provide: VaultClientService, + useValue: {}, + }, + { + provide: HARBOR_CONFIG, + useValue: harborConfig, + }, + ], + }).compile() + service = module.get(RegistryClientService) + }) + + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should send basic auth and JSON body on createProject', async () => { + server.use( + http.post(`${harborUrl}/api/v2.0/projects`, async ({ request }) => { + expect(request.method).toBe('POST') + expect(request.url).toBe(`${harborUrl}/api/v2.0/projects`) + expect(request.headers.get('accept')).toBe('application/json') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('content-type')).toContain('application/json') + expect(await request.json()).toEqual({ + project_name: 'myproj', + metadata: { auto_scan: 'true' }, + storage_limit: -1, + }) + return HttpResponse.json({}, { status: HttpStatus.CREATED }) + }), + ) + + await service.createProject('myproj', -1) + }) + + it('should send X-Is-Resource-Name on getProjectByName', async () => { + server.use( + http.get(`${harborUrl}/api/v2.0/projects/:projectName`, async ({ request, params }) => { + expect(request.method).toBe('GET') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('x-is-resource-name')).toBe('true') + expect(params.projectName).toBe('myproj') + return HttpResponse.json({ project_id: 123, metadata: {} }) + }), + ) + + const res = await service.getProjectByName('myproj') + + expect(res).toMatchObject({ status: HttpStatus.OK, data: { project_id: 123 } }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-client.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-client.service.ts new file mode 100644 index 0000000000..0e85eff9c9 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-client.service.ts @@ -0,0 +1,226 @@ +import type { RegistryQuery, RegistryResponse } from './registry-http-client.service' +import { Inject, Injectable } from '@nestjs/common' +import { RegistryHttpClientService } from './registry-http-client.service' +import { ROBOT_LIST_PAGE_SIZE } from './registry.constants' + +export const roAccess: HarborAccess[] = [ + { resource: 'repository', action: 'pull' }, + { resource: 'artifact', action: 'read' }, +] + +export const rwAccess: HarborAccess[] = [ + ...roAccess, + { resource: 'repository', action: 'list' }, + { resource: 'tag', action: 'list' }, + { resource: 'artifact', action: 'list' }, + { resource: 'scan', action: 'create' }, + { resource: 'scan', action: 'stop' }, + { resource: 'repository', action: 'push' }, + { resource: 'artifact-label', action: 'create' }, + { resource: 'artifact-label', action: 'delete' }, + { resource: 'tag', action: 'create' }, + { resource: 'tag', action: 'delete' }, +] + +export interface HarborAccess { + resource: string + action: string +} + +export interface HarborProject { + project_id?: number + metadata?: { + retention_id?: number | string + } +} + +export interface HarborRobot { + id?: number + name?: string +} + +export interface HarborRobotCreated { + id?: number + name: string + secret: string +} + +export interface HarborMember { + id?: number + entity_name?: string + entity_type?: string + role_id?: number +} + +export interface HarborGroupMemberRequest { + role_id: number + member_group: { + group_name: string + group_type: number + } +} + +export interface HarborProjectQuota { + ref?: { id?: number } + hard?: { storage?: number } +} + +export interface HarborRobotPermission { + namespace: string + kind: 'project' + access: HarborAccess[] +} + +export interface HarborRobotCreateRequest { + name: string + duration: number + description: string + disable: boolean + level: 'project' + permissions: HarborRobotPermission[] +} + +export interface HarborRetentionRule { + disabled: boolean + action: 'retain' + template: string + params: Record + tag_selectors: Array<{ kind: string, decoration: string, pattern: string }> + scope_selectors: { + repository: Array<{ kind: string, decoration: string, pattern: string }> + } +} + +export interface HarborRetentionPolicy { + algorithm: 'or' | 'and' + scope: { level: 'project', ref: number } + rules: HarborRetentionRule[] + trigger: { + kind: 'Schedule' + settings: { cron?: string } + references: unknown[] + } +} + +@Injectable() +export class RegistryClientService { + constructor( + @Inject(RegistryHttpClientService) private readonly http: RegistryHttpClientService, + ) {} + + async getProjectByName(projectName: string): Promise> { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async createProject(projectName: string, storageLimit: number) { + return this.http.fetch('projects', { + method: 'POST', + body: { + project_name: projectName, + metadata: { auto_scan: 'true' }, + storage_limit: storageLimit, + }, + }) + } + + async deleteProjectByName(projectName: string) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async listQuotas(projectId: number) { + return this.http.fetch(`quotas?reference_id=${encodeURIComponent(String(projectId))}`, { + method: 'GET', + }) + } + + async updateQuota(projectId: number, storageLimit: number) { + return this.http.fetch(`quotas/${encodeURIComponent(String(projectId))}`, { + method: 'PUT', + body: { + hard: { + storage: storageLimit, + }, + }, + }) + } + + async getGroupMembers(projectName: string) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async addGroupMember(projectName: string, body: HarborGroupMemberRequest) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members`, { + method: 'POST', + headers: { 'X-Is-Resource-Name': 'true' }, + body, + }) + } + + async removeGroupMember(projectName: string, memberId: number) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members/${encodeURIComponent(String(memberId))}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + getProjectRobots(projectId: number): AsyncGenerator { + return this.paginate('robots', { + q: `Level=project,ProjectID=${projectId}`, + }) + } + + async createRobot(body: HarborRobotCreateRequest) { + return this.http.fetch('robots', { + method: 'POST', + body, + }) + } + + async deleteRobot(robotId: number): Promise { + return this.http.fetch(`robots/${encodeURIComponent(String(robotId))}`, { + method: 'DELETE', + }) + } + + async getRetentionId(projectName: string): Promise { + const project = await this.getProjectByName(projectName) + if (project.status !== 200 || !project.data) return null + const retentionId = Number(project.data?.metadata?.retention_id) + return Number.isFinite(retentionId) ? retentionId : null + } + + async createRetention(body: HarborRetentionPolicy) { + return this.http.fetch('retentions', { + method: 'POST', + body, + }) + } + + async updateRetention(retentionId: number, body: HarborRetentionPolicy) { + return this.http.fetch(`retentions/${encodeURIComponent(String(retentionId))}`, { + method: 'PUT', + body, + }) + } + + private async* paginate(path: string, query: RegistryQuery): AsyncGenerator { + for (let page = 1; ; page++) { + const response = await this.http.fetch(path, { + method: 'GET', + query: { ...query, page, page_size: ROBOT_LIST_PAGE_SIZE }, + }) + if (response.status !== 200 || !response.data?.length) return + yield* response.data + if (response.data.length < ROBOT_LIST_PAGE_SIZE) return + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts new file mode 100644 index 0000000000..9d0a511ef6 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts @@ -0,0 +1,62 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './registry.constants' + +export const projectSelect = { + slug: true, + plugins: { + where: { + pluginName: PLUGIN_NAME, + }, + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class RegistryDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return await this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return await this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-health.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-health.service.ts new file mode 100644 index 0000000000..4ffab35aad --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -0,0 +1,27 @@ +import type { HarborConfig } from './harbor.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { HARBOR_CONFIG } from './harbor.module-definition' + +@Injectable() +export class RegistryHealthService { + constructor( + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const credentials = `${this.harborConfig.admin}:${this.harborConfig.adminPassword}` + const base64 = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${base64}` + try { + const response = await fetch(this.harborConfig.probeUrl, { method: 'GET', headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts new file mode 100644 index 0000000000..bc3848b0cd --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts @@ -0,0 +1,109 @@ +import type { HarborConfig } from './harbor.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { encodeBasicAuth } from './registry.utils' + +export type RegistryQuery = Record + +export interface RegistryFetchOptions { + method?: string + headers?: Record + body?: unknown + query?: RegistryQuery +} + +export interface RegistryResponse { + status: HttpStatus + data: T | null +} + +export type RegistryErrorKind + = | 'NotConfigured' + | 'Unexpected' + +export class RegistryError extends Error { + readonly kind: RegistryErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + + constructor( + kind: RegistryErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string } = {}, + ) { + super(message) + this.name = 'RegistryError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + } +} + +@Injectable() +export class RegistryHttpClientService { + constructor( + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + ) {} + + private get apiBaseUrl() { + return new URL('api/v2.0/', this.harborConfig.internalOrPublicUrl).toString() + } + + private get defaultHeaders() { + return { Accept: 'application/json', Authorization: `Basic ${encodeBasicAuth(this.harborConfig.admin, this.harborConfig.adminPassword)}` } + } + + async fetch( + path: string, + options: RegistryFetchOptions = {}, + ): Promise> { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('registry.method', method) + span?.setAttribute('registry.path', path) + + const request = this.createRequest(path, method, options.body, options.headers, options.query) + const response = await fetch(request).catch((error) => { + throw new RegistryError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('registry.http.status', response.status) + return await handleResponse(response) + } + + private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record, query?: RegistryQuery): Request { + const url = new URL(path, this.apiBaseUrl) + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, String(value)) + } + } + const headers: Record = { + ...this.defaultHeaders, + ...extraHeaders, + } + let requestBody: string | undefined + if (body !== undefined) { + requestBody = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + return new Request(url, { method, headers, body: requestBody }) + } +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts new file mode 100644 index 0000000000..b4d14b009f --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts @@ -0,0 +1,77 @@ +import type { Cache } from 'cache-manager' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { HarborConfig } from './harbor.module-definition' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { RegistryPluginService } from './registry-plugin.service' +import { makeProjectWithDetails } from './registry-testing.utils' + +describe('registryPluginService', () => { + let service: RegistryPluginService + let harborConfig: DeepMockProxy + let datastore: DeepMockProxy + let registryClient: DeepMockProxy + let cache: DeepMockProxy + + beforeEach(async () => { + harborConfig = mockDeep({ + url: 'https://harbor.example/', + projectSlugCacheTtlMs: 300000, + }) + datastore = mockDeep() + registryClient = mockDeep() + cache = mockDeep() + + const moduleRef = await Test.createTestingModule({ + providers: [ + RegistryPluginService, + { provide: HARBOR_CONFIG, useValue: harborConfig }, + { provide: RegistryDatastoreService, useValue: datastore }, + { provide: RegistryClientService, useValue: registryClient }, + { provide: CACHE_MANAGER, useValue: cache }, + ], + }).compile() + + service = moduleRef.get(RegistryPluginService) + }) + + it('uses the stored Harbor project id when available', async () => { + cache.get.mockResolvedValue('dulei') + registryClient.getProjectByName.mockResolvedValue({ + status: HttpStatus.OK, + data: { project_id: 144, metadata: {} }, + }) + const infos = await service.infos('dulei') + const url = infos.to?.(makeToUrlParams({ + project: { id: '', name: '', slug: 'dulei' }, + store: { registry: { projectId: '144' } }, + })) + + expect(url).toBe('https://harbor.example/harbor/projects/144/') + expect(datastore.getProject).not.toHaveBeenCalled() + expect(registryClient.getProjectByName).toHaveBeenCalledWith('dulei') + }) + + it('falls back to Harbor lookup when the store is empty', async () => { + cache.get.mockResolvedValue(undefined) + datastore.getProject.mockResolvedValue(makeProjectWithDetails({ slug: 'dulei' })) + registryClient.getProjectByName.mockResolvedValue({ + status: HttpStatus.OK, + data: { project_id: 144, metadata: {} }, + }) + + const infos = await service.infos('dulei') + const url = infos.to?.(makeToUrlParams()) + + expect(registryClient.getProjectByName).toHaveBeenCalledWith('dulei') + expect(url).toBe('https://harbor.example/harbor/projects/144/') + expect(cache.set).toHaveBeenCalledWith('registry:project-slug:dulei', 'dulei', 300000) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts new file mode 100644 index 0000000000..e502423c5f --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts @@ -0,0 +1,143 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { Cache } from 'cache-manager' +import type { HarborConfig } from './harbor.module-definition' +import { DISABLED } from '@cpn-console/shared' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { createProjectSlugCacheKey } from './registry.utils' + +@Injectable() +export class RegistryPluginService { + private readonly logger = new Logger(RegistryPluginService.name) + + constructor( + @Inject(HARBOR_CONFIG) + private readonly harborConfig: HarborConfig, + @Inject(RegistryDatastoreService) + private readonly datastore: RegistryDatastoreService, + @Inject(RegistryClientService) + private readonly registryClient: RegistryClientService, + @Inject(CACHE_MANAGER) + private readonly cache: Cache, + ) {} + + private async resolveProjectSlug(projectId: string): Promise { + const cacheKey = createProjectSlugCacheKey(projectId) + const cached = await this.cache.get(cacheKey) + if (cached !== undefined) return cached ?? undefined + + const project = await this.datastore.getProject(projectId) + const slug = project?.slug ?? null + await this.cache.set(cacheKey, slug, this.harborConfig.projectSlugCacheTtlMs) + return slug ?? undefined + } + + private async resolveHarborProjectId(projectSlug: string): Promise { + try { + const harborProject = await this.registryClient.getProjectByName(projectSlug) + const harborProjectId = Number(harborProject.data?.project_id) + if (harborProject.status !== 200 || !Number.isFinite(harborProjectId)) { + return undefined + } + this.logger.log(`Successfully resolve harbor project id for project slug ${projectSlug}: ${harborProjectId}`) + return harborProjectId + } catch (error) { + this.logger.error(`Failed to resolve harbor project id for project slug ${projectSlug}: ${error}`) + return undefined + } + } + + private resolveHarborProjectUrl(harborProjectId: number): string { + return new URL(`harbor/projects/${harborProjectId}/`, this.harborConfig.url).toString() + } + + private async resolveProjectUrl(projectId: string): Promise { + const projectSlug = await this.resolveProjectSlug(projectId) + if (!projectSlug) { + return undefined + } + + const harborProjectId = await this.resolveHarborProjectId(projectSlug) + if (harborProjectId === undefined) { + return undefined + } + + return this.resolveHarborProjectUrl(harborProjectId) + } + + async infos(projectId: string): Promise { + const quotaDescription = '-1 -> illimité, sinon 100MB / 1.2GB (unités : B, KB, MB, GB, TB)), max 1024TB' + const projectUrl = await this.resolveProjectUrl(projectId) + if (!projectUrl) { + throw new Error('Project not found') + } + + return { + name: 'registry', + to: () => projectUrl, + title: 'Harbor', + imgSrc: '/img/harbor.svg', + description: 'Harbor stocke, analyse et distribue vos images de conteneurs', + config: { + project: [{ + permissions: { + admin: { read: false, write: false }, + user: { read: false, write: false }, + }, + key: 'projectId', + kind: 'text', + title: 'Num du projet Harbor', + value: '', + }, { + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot projet', + description: 'Activer le robot de projet (read-only) et afficher ses identifiants aux utilisateurs', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota', + value: '', + description: `Stockage limite (vide utilisation du paramètre global, ${quotaDescription}`, + placeholder: '', + }], + global: [{ + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot RO aux projets', + description: 'Définit le comportement en l\'absence de ce paramétrage au niveau projet', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota par défaut', + value: '-1', + description: `Stockage limite par projet (${quotaDescription}`, + placeholder: '-1', + }], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts new file mode 100644 index 0000000000..c04eae0429 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts @@ -0,0 +1,24 @@ +import type { ProjectWithDetails } from './registry-datastore.service' +import type { RegistryResponse } from './registry-http-client.service' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' + +export function makeOkResponse(data: T): RegistryResponse { + return { status: HttpStatus.OK, data } +} + +export function makeCreatedResponse(data: T): RegistryResponse { + return { status: HttpStatus.CREATED, data } +} + +export function makeNoContent(): RegistryResponse { + return { status: HttpStatus.NO_CONTENT, data: null } +} + +export function makeProjectWithDetails(overrides: Partial = {}) { + return { + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.constants.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.constants.ts new file mode 100644 index 0000000000..e4c054daab --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.constants.ts @@ -0,0 +1,52 @@ +// Registry plugin identification +export const PLUGIN_NAME = 'harbor' + +// Registry configuration keys +export const REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT = 'quotaHardLimit' +export const REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT = 'publishProjectRobot' + +// Default platform-level group paths +export const DEFAULT_PLATFORM_ADMIN_GROUP_PATHS = '/console/admin' +export const DEFAULT_PLATFORM_GUEST_GROUP_PATHS = '/console/security,/console/readonly' + +// Default project-level group path suffixes +export const DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES = '/console/admin' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES = '/console/developer' +export const DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES = '/console/security,/console/readonly' + +// Platform group path plugin configuration keys +export const PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY = 'platformAdminGroupPath' +export const PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY = 'platformGuestGroupPaths' + +// Project group path suffixes plugin configuration keys +export const PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectAdminGroupPathSuffixes' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectMaintainerGroupPathSuffixes' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectDeveloperGroupPathSuffixes' +export const PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectGuestGroupPathSuffixes' + +// Registry robot name identifiers +export const ROBOT_NAME_RO = 'ro-robot' +export const ROBOT_NAME_RW = 'rw-robot' +export const ROBOT_NAME_PROJECT = 'project-robot' + +// Page size used when listing robots. A project only ever owns a handful of +// robots (ro, rw and optionally the project robot — 3 in practice), so a single +// page is plenty; this simply bounds each pagination request. +export const ROBOT_LIST_PAGE_SIZE = 100 + +// Allowed Harbor retention rule templates +export const ALLOWED_RETENTION_RULE_TEMPLATES = [ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +] as const + +// Harbor role identifiers +export const HARBOR_ROLE_PROJECT_ADMIN = 1 +export const HARBOR_ROLE_DEVELOPER = 2 +export const HARBOR_ROLE_GUEST = 3 +export const HARBOR_ROLE_MAINTAINER = 4 +export const HARBOR_ROLE_LIMITED_GUEST = 5 diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.module-definition.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.module-definition.ts new file mode 100644 index 0000000000..c994e3c6cc --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.module-definition.ts @@ -0,0 +1,21 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface RegistryConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + ruleTemplate: string + ruleCount?: number + retentionCron: string + robotRotationThresholdDays: number + projectSlugCacheTtlMs: number + internalOrPublicUrl?: string +} + +export interface RegistryModuleOptions extends RegistryConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('registry') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as REGISTRY_CONFIG } diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.module.ts new file mode 100644 index 0000000000..c93e994bce --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.module.ts @@ -0,0 +1,23 @@ +import { CacheModule } from '@nestjs/cache-manager' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { ConfigurableModuleClass as HarborConfigurableModuleClass } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { RegistryHealthService } from './registry-health.service' +import { RegistryHttpClientService } from './registry-http-client.service' +import { RegistryPluginService } from './registry-plugin.service' +import { ConfigurableModuleClass } from './registry.module-definition' +import { RegistryService } from './registry.service' + +@Module({}) +export class HarborModule extends HarborConfigurableModuleClass {} + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule, HarborModule, CacheModule.register()], + providers: [RegistryHealthService, RegistryPluginService, RegistryService, RegistryDatastoreService, RegistryHttpClientService, RegistryClientService], + exports: [RegistryHealthService, RegistryPluginService, RegistryService], +}) +export class RegistryModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.service.spec.ts new file mode 100644 index 0000000000..94cf3b9d05 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -0,0 +1,322 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { HarborConfig } from './harbor.module-definition' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from '../vault/vault-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { makeCreatedResponse, makeNoContent, makeOkResponse, makeProjectWithDetails } from './registry-testing.utils' +import { + PLUGIN_NAME, + REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, + REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, + ROBOT_NAME_PROJECT, +} from './registry.constants' +import { RegistryService } from './registry.service' + +describe('registryService', () => { + let service: RegistryService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let harborConfig: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + getProjectByName: vi.fn().mockResolvedValue(makeOkResponse({ project_id: 123, metadata: {} })), + listQuotas: vi.fn().mockResolvedValue(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])), + getRetentionId: vi.fn().mockResolvedValue(null), + createRetention: vi.fn().mockResolvedValue(makeCreatedResponse(null)), + getGroupMembers: vi.fn().mockResolvedValue(makeOkResponse([])), + getProjectRobots: vi.fn(async function* () {}), + addGroupMember: vi.fn().mockResolvedValue(makeCreatedResponse(null)), + removeGroupMember: vi.fn().mockResolvedValue(makeNoContent()), + deleteProjectByName: vi.fn().mockResolvedValue(makeNoContent()), + }) + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + read: vi.fn().mockResolvedValue(makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: 'robot$myproj+ro-robot', + TOKEN: 'secret', + }, + })), + write: vi.fn().mockResolvedValue(undefined), + }) + harborConfig = mockDeep({ + url: 'https://harbor.example', + internalUrl: 'https://harbor.example', + admin: 'admin', + adminPassword: faker.internet.password(), + ruleTemplate: 'latestPushedK', + ruleCount: '10', + retentionCron: '0 22 2 * * *', + robotRotationThresholdDays: 90, + }) + baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep({}) + + const module = await Test.createTestingModule({ + providers: [ + RegistryService, + { provide: RegistryClientService, useValue: client }, + { provide: RegistryDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: HARBOR_CONFIG, useValue: harborConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + ], + }).compile() + + service = module.get(RegistryService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('adds expected Harbor group memberships based on defaults', async () => { + const project = makeProjectWithDetails() + + await service.handleUpsert(project) + + const expected = [ + { groupName: `/${project.slug}`, roleId: 5 }, + { groupName: '/console/admin', roleId: 1 }, + { groupName: '/console/readonly', roleId: 3 }, + { groupName: '/console/security', roleId: 3 }, + { groupName: `/${project.slug}/console/readonly`, roleId: 3 }, + { groupName: `/${project.slug}/console/security`, roleId: 3 }, + { groupName: `/${project.slug}/console/developer`, roleId: 3 }, + { groupName: `/${project.slug}/console/devops`, roleId: 3 }, + { groupName: `/${project.slug}/console/admin`, roleId: 2 }, + ] + + expect(client.addGroupMember).toHaveBeenCalledTimes(expected.length) + for (const e of expected) { + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: e.roleId, + member_group: { + group_name: e.groupName, + group_type: 3, + }, + }) + } + }) + + it('reconciles an existing group membership when role differs', async () => { + const project = makeProjectWithDetails() + client.getGroupMembers.mockResolvedValueOnce(makeOkResponse([ + { id: 10, entity_name: `/${project.slug}/console/admin`, entity_type: 'g', role_id: 3 }, + ])) + + await service.handleUpsert(project) + + expect(client.removeGroupMember).toHaveBeenCalledWith(project.slug, 10) + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: 2, + member_group: { + group_name: `/${project.slug}/console/admin`, + group_type: 3, + }, + }) + }) + + it('returns a KO result when project admin membership creation fails', async () => { + const project = makeProjectWithDetails() + client.addGroupMember.mockImplementation(async (_projectName, body) => { + if (body.member_group.group_name === `/${project.slug}/console/admin` && body.role_id === 2) { + return { status: HttpStatus.BAD_REQUEST, data: null } + } + return { status: HttpStatus.CREATED, data: null } + }) + + await expect(service.handleUpsert(project)).resolves.toEqual({ + harbor: expect.objectContaining({ + status: 'KO', + message: expect.stringContaining('Harbor create member failed'), + }), + }) + + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: 2, + member_group: { + group_name: `/${project.slug}/console/admin`, + group_type: 3, + }, + }) + }) + + it('updates quota when it differs', async () => { + client.listQuotas.mockResolvedValueOnce(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])) + + await service.handleUpsert(makeProjectWithDetails({ + slug: 'myproj', + plugins: [ + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1024' }, + ], + })) + + expect(client.updateQuota).toHaveBeenCalledWith(123, 1024) + }) + + it('reuses robot secret when vault secret host matches', async () => { + const project = makeProjectWithDetails() + + await service.handleUpsert(project) + + expect(vault.read).toHaveBeenCalledTimes(2) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/ro-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/rw-robot`) + expect(client.getProjectRobots).not.toHaveBeenCalled() + expect(client.createRobot).not.toHaveBeenCalled() + expect(client.deleteRobot).not.toHaveBeenCalled() + expect(vault.write).not.toHaveBeenCalled() + }) + + it('rotates robot and writes secret when vault secret host differs', async () => { + const project = makeProjectWithDetails() + vault.read.mockImplementation(async (path: string) => { + if (path === `forge/${project.slug}/REGISTRY/ro-robot`) { + return makeVaultSecret({ + data: { + HOST: 'other.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'old', + }, + }) + } + return makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+rw-robot`, + TOKEN: 'secret', + }, + }) + }) + + client.getProjectRobots.mockImplementation(async function* () { + yield { id: 11, name: `robot$${project.slug}+ro-robot` } + }) + client.deleteRobot.mockResolvedValue(makeNoContent()) + client.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) + + await service.handleUpsert(project) + + expect(client.deleteRobot).toHaveBeenCalledWith(11) + expect(client.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + HOST: 'harbor.example', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'newsecret', + }), `forge/${project.slug}/REGISTRY/ro-robot`) + }) + + it('rotates robot and writes secret when vault secret is expiring', async () => { + const project = makeProjectWithDetails() + const old = makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'old', + }, + }) + old.metadata.created_time = new Date(Date.now() - 1000 * 60 * 60 * 24 * 120).toISOString() + + vault.read.mockImplementation(async (path: string) => { + if (path === `forge/${project.slug}/REGISTRY/ro-robot`) return old + return makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+rw-robot`, + TOKEN: 'secret', + }, + }) + }) + + client.getProjectRobots.mockImplementation(async function* () { + yield { id: 11, name: `robot$${project.slug}+ro-robot` } + }) + client.deleteRobot.mockResolvedValue(makeNoContent()) + client.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) + + await service.handleUpsert(project) + + expect(client.deleteRobot).toHaveBeenCalledWith(11) + expect(client.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + HOST: 'harbor.example', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'newsecret', + }), `forge/${project.slug}/REGISTRY/ro-robot`) + }) + + it('parses plugin config and enables project robot publishing', async () => { + const project = makeProjectWithDetails({ + plugins: [ + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1gb' }, + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, value: ENABLED }, + ], + }) + client.getProjectByName.mockResolvedValue(makeOkResponse({ project_id: 1, metadata: {} })) + + await service.handleUpsert(project) + + expect(client.updateQuota).toHaveBeenCalledWith(1, 1024 ** 3) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/ro-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/rw-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/${ROBOT_NAME_PROJECT}`) + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects', async () => { + datastore.getAllProjects.mockResolvedValue([ + makeProjectWithDetails({ slug: 'project-1' }), + makeProjectWithDetails({ slug: 'project-2' }), + ]) + + await service.handleCron() + + expect(client.getGroupMembers).toHaveBeenCalledWith('project-1') + expect(client.getGroupMembers).toHaveBeenCalledWith('project-2') + }) + }) + + describe('handleDelete', () => { + it('should delete project when it exists', async () => { + const project = makeProjectWithDetails() + await service.handleDelete(project) + expect(client.deleteProjectByName).toHaveBeenCalledWith(project.slug) + }) + + it('should not delete project when it does not exist', async () => { + client.getProjectByName.mockResolvedValueOnce({ status: HttpStatus.NOT_FOUND, data: null }) + await service.handleDelete(makeProjectWithDetails()) + expect(client.deleteProjectByName).not.toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.service.ts new file mode 100644 index 0000000000..43dc140455 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -0,0 +1,525 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultSecret } from '../vault/vault-client.service' +import type { HarborConfig } from './harbor.module-definition' +import type { + HarborAccess, + HarborGroupMemberRequest, + HarborMember, + HarborProjectQuota, + HarborRetentionPolicy, + HarborRobotCreateRequest, +} from './registry-client.service' +import type { ProjectWithDetails } from './registry-datastore.service' +import type { RuleTemplate, VaultRobotSecret } from './registry.utils' +import { specificallyEnabled } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { find } from '../../utils/iterable.utils' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService, roAccess, rwAccess } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { + ALLOWED_RETENTION_RULE_TEMPLATES, + DEFAULT_PLATFORM_ADMIN_GROUP_PATHS, + DEFAULT_PLATFORM_GUEST_GROUP_PATHS, + DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES, + HARBOR_ROLE_DEVELOPER, + HARBOR_ROLE_GUEST, + HARBOR_ROLE_LIMITED_GUEST, + HARBOR_ROLE_PROJECT_ADMIN, + PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, + REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, + ROBOT_NAME_PROJECT, + ROBOT_NAME_RO, + ROBOT_NAME_RW, +} from './registry.constants' +import { generateVaultRobotSecret, getHostFromUrl, getProjectVaultPath, parseBytes } from './registry.utils' + +@Injectable() +export class RegistryService { + private readonly logger = new Logger(RegistryService.name) + + constructor( + @Inject(RegistryClientService) private readonly client: RegistryClientService, + @Inject(RegistryDatastoreService) private readonly datastore: RegistryDatastoreService, + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('RegistryService initialized') + } + + private get host() { + return getHostFromUrl(this.harborConfig.url) + } + + private async getRobot(project: ProjectWithDetails, harborProjectId: number, robotName: string) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.robot.name': robotName, + }) + const fullName = generateRobotFullName(project, robotName) + return find(this.client.getProjectRobots(harborProjectId), r => r?.name === fullName) + } + + private async createProjectRobot(project: ProjectWithDetails, robotName: string, access: HarborAccess[]) { + const created = await this.client.createRobot( + generateRobotPermissions(project, robotName, access), + ) + if (created.status >= 300 || !created.data) { + throw new Error(`Harbor create robot failed (${created.status})`) + } + return created.data + } + + private async rotateRobot(project: ProjectWithDetails, harborProjectId: number, robotName: string, access: HarborAccess[]) { + const existing = await this.getRobot(project, harborProjectId, robotName) + if (existing?.id) { + await this.client.deleteRobot(existing.id) + } + return this.createProjectRobot(project, robotName, access) + } + + private async ensureRobotSecret(project: ProjectWithDetails, harborProjectId: number, robotName: string, access: HarborAccess[]) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.robot.name': robotName, + }) + const relativeVaultPath = `REGISTRY/${robotName}` + const vaultPath = getProjectVaultPath(project, this.baseConfig.projectsRootDir, relativeVaultPath) + const vaultRobotSecret = await this.vault.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + + const expiring = vaultRobotSecret + ? this.isRobotSecretExpiring(vaultRobotSecret) + : false + + span?.setAttributes({ + 'vault.secret.exists': !!vaultRobotSecret, + 'registry.robot.secret.expiring': expiring, + }) + + if (vaultRobotSecret?.data?.HOST === this.host && !expiring) { + span?.setAttribute('vault.secret.reused', true) + return vaultRobotSecret.data + } + + const existing = await this.getRobot(project, harborProjectId, robotName) + const created = existing + ? await this.rotateRobot(project, harborProjectId, robotName, access) + : await this.createProjectRobot(project, robotName, access) + const fullName = generateRobotFullName(project, robotName) + const secret = generateVaultRobotSecret(this.host, fullName, created.secret) + await this.vault.write(secret, vaultPath) + span?.setAttribute('vault.secret.written', true) + return secret + } + + private isRobotSecretExpiring(vaultSecret: VaultSecret): boolean { + const createdTimeRaw = vaultSecret?.metadata?.created_time + if (!createdTimeRaw) return false + const createdTime = new Date(createdTimeRaw) + return daysAgoFromNow(createdTime) > this.harborConfig.robotRotationThresholdDays + } + + private async ensureProjectGroupMember( + projectSlug: string, + groupName: string, + accessLevel: number, + membersByName: Map, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'registry.group.name': groupName, + 'registry.group.access_level': accessLevel, + }) + const existing = membersByName.get(groupName) + + if (existing?.id) { + if (existing.role_id !== accessLevel || existing.entity_type !== 'g') { + await this.client.removeGroupMember(projectSlug, Number(existing.id)) + membersByName.delete(groupName) + } else { + span?.setAttribute('registry.member.exists', true) + return + } + } + + const body: HarborGroupMemberRequest = { + role_id: accessLevel, + member_group: { + group_name: groupName, + group_type: 3, + }, + } + const created = await this.client.addGroupMember(projectSlug, body) + if (created.status >= 300) { + throw new Error(`Harbor create member failed (${created.status})`) + } + span?.setAttribute('registry.member.created', true) + } + + private async ensureProjectGroupMembers(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const members = await this.client.getGroupMembers(project.slug) + if (members.status !== 200 || !members.data) { + throw new Error(`Harbor list members failed (${members.status})`) + } + + const membersByName = new Map() + for (const member of members.data) { + const name = member?.entity_name + if (name) membersByName.set(name, member) + } + + const byGroupName = await this.generateAccessLevelMapping(project) + + await Promise.all( + Array.from(byGroupName.entries(), ([groupName, accessLevel]) => this.ensureProjectGroupMember( + project.slug, + groupName, + accessLevel, + membersByName, + )), + ) + } + + private async ensureProjectQuota(project: ProjectWithDetails, storageLimit: number) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.storage_limit.bytes': storageLimit, + }) + const existing = await this.client.getProjectByName(project.slug) + if (existing.status === 200 && existing.data) { + const projectId = Number(existing.data.project_id) + if (!Number.isFinite(projectId)) return existing.data + + const quotas = await this.client.listQuotas(projectId) + if (quotas.status === 200 && quotas.data) { + const hardQuota = quotas.data.find((q: HarborProjectQuota) => q?.ref?.id === projectId) + if (hardQuota?.hard?.storage !== storageLimit) { + await this.client.updateQuota(projectId, storageLimit) + span?.setAttribute('registry.quota.updated', true) + } + } + return existing.data + } + + const created = await this.client.createProject(project.slug, storageLimit) + if (created.status >= 300) { + throw new Error(`Harbor create project failed (${created.status})`) + } + span?.setAttribute('registry.project.created', true) + + const fetched = await this.client.getProjectByName(project.slug) + if (fetched.status !== 200 || !fetched.data) { + throw new Error(`Harbor get project failed (${fetched.status})`) + } + return fetched.data + } + + private async ensureRetentionPolicy(project: ProjectWithDetails, harborProjectId: number) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.project.id': harborProjectId, + }) + const policy = generateRetentionPolicy(harborProjectId, { + harborRuleTemplate: this.harborConfig.ruleTemplate, + harborRuleCount: this.harborConfig.ruleCount, + harborRetentionCron: this.harborConfig.retentionCron, + }) + const retentionId = await this.client.getRetentionId(project.slug) + span?.setAttribute('registry.retention.exists', !!retentionId) + const result = retentionId + ? await this.client.updateRetention(retentionId, policy) + : await this.client.createRetention(policy) + if (result.status >= 300) { + throw new Error(`Harbor retention policy failed (${result.status})`) + } + } + + @StartActiveSpan() + async ensureProject(project: ProjectWithDetails, options: { storageLimitBytes?: number, publishProjectRobot?: boolean } = {}) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.publish_project_robot': !!options.publishProjectRobot, + }) + const storageLimit = options.storageLimitBytes ?? -1 + const harborProject = await this.ensureProjectQuota(project, storageLimit) + const harborProjectId = Number(harborProject.project_id) + if (!Number.isFinite(harborProjectId)) + throw new Error('Unable to retrieve Harbor project_id') + + await Promise.all([ + this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_RO, roAccess), + this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_RW, rwAccess), + this.ensureProjectGroupMembers(project), + this.ensureRetentionPolicy(project, harborProjectId), + options.publishProjectRobot + ? this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_PROJECT, roAccess) + : Promise.resolve(), + ]) + + return { + projectId: harborProjectId, + basePath: `${this.host}/${project.slug}/`, + } + } + + @StartActiveSpan() + async deleteProject(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const existing = await this.client.getProjectByName(projectSlug) + if (existing.status === 404) { + span?.setAttribute('registry.project.exists', false) + return + } + const deleted = await this.client.deleteProjectByName(projectSlug) + if (deleted.status >= 300 && deleted.status !== 404) { + throw new Error(`Harbor delete project failed (${deleted.status})`) + } + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('harbor', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + const quotaConfigRaw = getPluginConfig(project, REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT) + const publishConfig = getPluginConfig(project, REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT) + const parsedQuota = quotaConfigRaw ? parseBytes(String(quotaConfigRaw)) : undefined + const storageLimitBytes = parsedQuota ?? -1 + const publishProjectRobot = specificallyEnabled(publishConfig) + await this.ensureProject(project, { storageLimitBytes, publishProjectRobot }) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('harbor', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.deleteProject(project.slug) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Registry reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('registry.projects.count', projects.length) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string) { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return getPluginConfig(project, key) + } + + private async getPlatformAdminGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_PLATFORM_ADMIN_GROUP_PATHS + return parseGroupPaths(raw) + } + + private async getPlatformGuestGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY) ?? DEFAULT_PLATFORM_GUEST_GROUP_PATHS + return parseGroupPaths(raw) + } + + private async getProjectAdminGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectMaintainerGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectGuestGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async generateAccessLevelMapping(project: ProjectWithDetails) { + const [ + platformAdminGroupPaths, + platformGuestGroupPaths, + projectAdminGroupPaths, + projectMaintainerGroupPaths, + projectDeveloperGroupPaths, + projectGuestGroupPaths, + ] = await Promise.all([ + this.getPlatformAdminGroupPaths(project), + this.getPlatformGuestGroupPaths(project), + this.getProjectAdminGroupPaths(project), + this.getProjectMaintainerGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectGuestGroupPaths(project), + ]) + + const roles = generateHarborAccessLevelMapping({ + guest: projectGuestGroupPaths, + developer: projectDeveloperGroupPaths, + maintainer: projectMaintainerGroupPaths, + admin: projectAdminGroupPaths, + platformAdmin: platformAdminGroupPaths, + platformGuest: platformGuestGroupPaths, + }) + return new Map([ + [`/${project.slug}`, HARBOR_ROLE_LIMITED_GUEST], + ...roles, + ]) + } +} + +function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +function parseGroupPaths(raw: string): string[] { + return raw + .split(',') + .map(path => path.trim()) + .filter(Boolean) +} + +function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string) { + return parseGroupPaths(rawGroupPathSuffixes).map(path => `/${projectSlug}${path}`) +} + +function generateHarborAccessLevelMapping(args: { guest: string[], developer: string[], maintainer: string[], admin: string[], platformAdmin: string[], platformGuest: string[] }) { + const byGroupName = new Map() + for (const groupName of args.guest) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.developer) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.maintainer) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.admin) byGroupName.set(groupName, HARBOR_ROLE_DEVELOPER) + for (const groupName of args.platformAdmin) byGroupName.set(groupName, HARBOR_ROLE_PROJECT_ADMIN) + for (const groupName of args.platformGuest) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + return byGroupName +} + +function daysAgoFromNow(date: Date) { + return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) +} + +function generateRobotFullName(project: ProjectWithDetails, robotName: string) { + return `robot$${project.slug}+${robotName}` +} + +function generateRobotPermissions(project: ProjectWithDetails, robotName: string, access: HarborAccess[]): HarborRobotCreateRequest { + return { + name: robotName, + duration: -1, + description: 'robot for ci builds', + disable: false, + level: 'project', + permissions: [{ + namespace: project.slug, + kind: 'project', + access, + }], + } +} + +function generateRetentionPolicy( + projectId: number, + options: { + harborRuleTemplate?: string + harborRuleCount?: string + harborRetentionCron?: string + }, +): HarborRetentionPolicy { + let template: RuleTemplate = 'latestPushedK' + if (isRuleTemplate(options.harborRuleTemplate)) { + template = options.harborRuleTemplate + } + + const rawCount = Number(options.harborRuleCount) + let count: number + if (Number.isFinite(rawCount) && rawCount > 0) { + count = rawCount + } else if (template === 'always') { + count = 1 + } else { + count = 10 + } + + return { + algorithm: 'or', + scope: { level: 'project', ref: projectId }, + rules: [ + { + disabled: false, + action: 'retain', + template, + params: { [template]: count }, + tag_selectors: [ + { kind: 'doublestar', decoration: 'matches', pattern: '**' }, + ], + scope_selectors: { + repository: [ + { kind: 'doublestar', decoration: 'repoMatches', pattern: '**' }, + ], + }, + }, + ], + trigger: { + kind: 'Schedule', + settings: { cron: options.harborRetentionCron }, + references: [], + }, + } +} + +function isRuleTemplate(value: unknown): value is RuleTemplate { + if (typeof value !== 'string') return false + for (const template of ALLOWED_RETENTION_RULE_TEMPLATES) { + if (template === value) return true + } + return false +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.utils.ts new file mode 100644 index 0000000000..2befbdbd12 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -0,0 +1,86 @@ +import type { ProjectWithDetails } from './registry-datastore.service' +import type { ALLOWED_RETENTION_RULE_TEMPLATES } from './registry.constants' +import { removeTrailingSlash } from '@cpn-console/shared' + +export type RuleTemplate = typeof ALLOWED_RETENTION_RULE_TEMPLATES[number] + +export function createProjectSlugCacheKey(projectId: string) { + return `registry:project-slug:${projectId}` +} + +const protocolPrefixRegex = /^https?:\/\//u +const parseBytesRegex = /^(\d+(?:\.\d+)?)(?:\s*(kb|mb|gb|tb|[kmgtb]))?$/u + +export function getHostFromUrl(url: string) { + return removeTrailingSlash(url).replace(protocolPrefixRegex, '').split('/')[0] +} + +export function encodeBasicAuth(username: string, password: string) { + return Buffer.from(`${username}:${password}`).toString('base64') +} + +export interface VaultRobotSecret { + DOCKER_CONFIG: string + HOST: string + TOKEN: string + USERNAME: string +} + +export function generateVaultRobotSecret(host: string, robotName: string, robotSecret: string): VaultRobotSecret { + const auth = `${robotName}:${robotSecret}` + const b64auth = Buffer.from(auth).toString('base64') + return { + DOCKER_CONFIG: JSON.stringify({ + auths: { + [host]: { + auth: b64auth, + email: '', + }, + }, + }), + HOST: host, + TOKEN: robotSecret, + USERNAME: robotName, + } +} + +export function getProjectVaultPath(project: ProjectWithDetails, projectRootDir: string | undefined, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return projectRootDir + ? `${projectRootDir}/${project.slug}/${normalized}` + : `${project.slug}/${normalized}` +} + +export function parseBytes(input: string | number | undefined) { + if (input === undefined || input === null) return undefined + if (typeof input === 'number' && Number.isFinite(input)) return input + const raw = String(input).trim().toLowerCase() + if (!raw) return undefined + const match = parseBytesRegex.exec(raw) + if (!match) { + return Number.isFinite(Number(raw)) ? Number(raw) : undefined + } + const value = Number(match[1]) + const unit = (match[2] ?? 'b').toLowerCase() + const pow = parseUnit(unit) + return Math.round(value * 1024 ** pow) +} + +function parseUnit(unit: string) { + switch (unit) { + case 'kb': + case 'k': + return 1 + case 'mb': + case 'm': + return 2 + case 'gb': + case 'g': + return 3 + case 'tb': + case 't': + return 4 + default: + return 0 + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts new file mode 100644 index 0000000000..51e59317ee --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts @@ -0,0 +1,199 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Dispatcher, RequestInit } from 'undici' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { OpenCdsConfig } from '../opencds/opencds.module-definition' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { Agent, fetch, Headers, ProxyAgent, Response } from 'undici' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { OPENCDS_CONFIG } from '../opencds/opencds.module-definition' +import { OpenCdsClientError, OpenCdsClientService } from './open-cds-client.service' + +vi.mock('undici', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + Agent: vi.fn(), + fetch: vi.fn(), + ProxyAgent: vi.fn(), + } +}) + +function mockFetchResponse(response: Response): void { + vi.mocked(fetch).mockResolvedValue(response) +} + +function getLastFetchCall(): [string, RequestInit] { + const [url, init] = vi.mocked(fetch).mock.lastCall as [string, RequestInit] + return [url, init] +} + +describe('openCdsClientService', () => { + let module: TestingModule + let service: OpenCdsClientService + let openCdsConfig: Partial + let baseConfig: DeepMockProxy + let tlsDispatcher: Pick + let proxyDispatcher: Pick + + beforeEach(async () => { + vi.clearAllMocks() + vi.unstubAllEnvs() + + tlsDispatcher = { dispatch: vi.fn() } + proxyDispatcher = { dispatch: vi.fn() } + + class MockAgent { + dispatch = tlsDispatcher + }; + + class ProxyMockAgent { + dispatch = proxyDispatcher + }; + + vi.mocked(Agent).mockImplementation(MockAgent as any) + vi.mocked(ProxyAgent).mockImplementation(ProxyMockAgent as any) + + openCdsConfig = { + url: 'https://opencds.example.com/root/api/', + apiToken: 'test-token', + apiTlsRejectUnauthorized: true, + } + baseConfig = mockDeep({ httpProxy: undefined }) + + module = await Test.createTestingModule({ + providers: [ + OpenCdsClientService, + { provide: OPENCDS_CONFIG, useValue: openCdsConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(OpenCdsClientService) + }) + + it('builds GET requests with an Axios-compatible URL, API key header and TLS-aware dispatcher', async () => { + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + const result = await service.get<{ ok: boolean }>('/requests') + + expect(Agent).toHaveBeenCalledWith({ + connect: { + rejectUnauthorized: true, + }, + }) + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests') + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('GET') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(result).toEqual({ ok: true }) + }) + + it('uses ProxyAgent when HTTP_PROXY is configured and preserves TLS settings for the upstream request', async () => { + baseConfig.httpProxy = 'http://proxy.internal:3128' + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + await service.get('/requests') + + expect(ProxyAgent).toHaveBeenCalledWith({ + requestTls: { + rejectUnauthorized: true, + }, + uri: 'http://proxy.internal:3128', + }) + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests') + expect(init.dispatcher?.dispatch).toBe(proxyDispatcher) + expect(init.method).toBe('GET') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + }) + + it('applies query parameters and omits undefined values on GET', async () => { + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + await service.get('/requests', { + query: { + page: 2, + active: true, + search: 'alpha', + ignored: undefined, + }, + }) + + const [url] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests?page=2&active=true&search=alpha') + }) + + it('sends POST without body and without forcing JSON content type', async () => { + mockFetchResponse(new Response(null, { status: HttpStatus.NO_CONTENT })) + + await service.post('/validate/id') + + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/validate/id') + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('POST') + expect(init.signal).toBeUndefined() + expect(init.body).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(new Headers(init.headers).has('Content-Type')).toBe(false) + }) + + it('serializes POST bodies as JSON and sets the content type', async () => { + mockFetchResponse(new Response(null, { status: HttpStatus.NO_CONTENT })) + + await service.post('/validate/id', { + requestId: '123', + enabled: true, + }) + + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/validate/id') + expect(init.body).toBe(JSON.stringify({ + requestId: '123', + enabled: true, + })) + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('POST') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(new Headers(init.headers).get('Content-Type')).toBe('application/json') + }) + + it('throws a dedicated error with HTTP status context for non-OK responses', async () => { + mockFetchResponse(new Response('upstream failure', { + status: HttpStatus.BAD_GATEWAY, + statusText: 'Bad Gateway', + })) + + await expect(service.get('/requests')).rejects.toMatchObject({ + body: 'upstream failure', + message: 'OpenCDS request failed with 502 Bad Gateway', + name: OpenCdsClientError.name, + status: HttpStatus.BAD_GATEWAY, + statusText: 'Bad Gateway', + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts new file mode 100644 index 0000000000..c2760046d2 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts @@ -0,0 +1,129 @@ +import type { HttpStatus } from '@nestjs/common' +import type { Dispatcher, HeadersInit } from 'undici' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { OpenCdsConfig } from '../opencds/opencds.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { Agent, fetch, Headers, ProxyAgent } from 'undici' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { OPENCDS_CONFIG } from '../opencds/opencds.module-definition' +import { throwIfNotOk } from './service-chain.utils' + +const URL_REGEX = /^https?:\/\// +const START_SLASHES_REGEX = /^\/+/ +const END_SLASHES_REGEX = /\/+$/ + +export interface OpenCdsRequestOptions { + headers?: HeadersInit + signal?: AbortSignal + query?: Record +} + +export class OpenCdsClientError extends Error { + constructor( + public readonly status: HttpStatus, + public readonly statusText: string, + public readonly body?: string, + ) { + super(`OpenCDS request failed with ${status} ${statusText}`) + } +} + +@Injectable() +export class OpenCdsClientService { + constructor( + @Inject(OPENCDS_CONFIG) private readonly opencdsConfig: OpenCdsConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + private readonly logger = new Logger(OpenCdsClientService.name) + + async get(path: string, options?: OpenCdsRequestOptions): Promise { + const url = this.buildUrl(path, options?.query) + this.logger.debug(`Retrieving data from URL: ${url}`) + + const headers = this.buildHeaders(options?.headers) + + const response = await fetch(url, { + dispatcher: this.buildDispatcher(), + headers, + method: 'GET', + signal: options?.signal, + }) + + await throwIfNotOk(response) + + return (await response.json()) as T + } + + async post( + path: string, + body?: TBody, + options?: OpenCdsRequestOptions, + ): Promise { + const hasBody = body !== undefined + + const response = await fetch(this.buildUrl(path, options?.query), { + body: hasBody ? JSON.stringify(body) : undefined, + dispatcher: this.buildDispatcher(), + headers: this.buildHeaders(options?.headers, hasBody), + method: 'POST', + signal: options?.signal, + }) + + await throwIfNotOk(response) + } + + private buildUrl( + path: string, + query?: OpenCdsRequestOptions['query'], + ): string { + if (!this.opencdsConfig.url) throw new Error('OpenCDS is disabled') + + const resolvedPath = URL_REGEX.test(path) + ? path + : `${this.opencdsConfig.url.replace(END_SLASHES_REGEX, '')}/${path.replace(START_SLASHES_REGEX, '')}` + + const url = new URL(resolvedPath) + + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined) { + url.searchParams.append(key, String(value)) + } + } + + return url.toString() + } + + private buildHeaders( + headers?: OpenCdsRequestOptions['headers'], + hasJsonBody = false, + ): Headers { + const mergedHeaders = new Headers(headers) + mergedHeaders.set('X-API-Key', this.opencdsConfig.apiToken ?? '') + + if (hasJsonBody) { + mergedHeaders.set('Content-Type', 'application/json') + } + + return mergedHeaders + } + + private buildDispatcher(): Dispatcher { + const httpProxy = this.baseConfig.httpProxy + + if (httpProxy) { + return new ProxyAgent({ + requestTls: { + rejectUnauthorized: this.opencdsConfig.apiTlsRejectUnauthorized, + }, + uri: httpProxy, + }) + } + + return new Agent({ + connect: { + rejectUnauthorized: this.opencdsConfig.apiTlsRejectUnauthorized, + }, + }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts new file mode 100644 index 0000000000..2318edb937 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts @@ -0,0 +1,126 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ServiceChainController } from './service-chain.controller' +import { ServiceChainService } from './service-chain.service' + +describe('serviceChainController', () => { + let module: TestingModule + let controller: ServiceChainController + let service: MockProxy + + const uuid = '550e8400-e29b-41d4-a716-446655440000' + const date = new Date('2026-01-01T00:00:00.000Z') + const serviceChain: ServiceChain = { + id: uuid, + state: 'opened', + commonName: 'test.example.com', + pai: 'test-pai', + network: 'RIE', + createdAt: date, + updatedAt: date, + } + const serviceChainDetails: ServiceChainDetails = { + ...serviceChain, + validationId: uuid, + validatedBy: null, + ref: null, + location: 'SIR', + targetAddress: '10.0.0.1', + projectId: uuid, + env: 'INT', + subjectAlternativeName: [], + redirect: false, + antivirus: null, + websocket: false, + ipWhiteList: [], + sslOutgoing: false, + } + const serviceChainFlows: ServiceChainFlows = { + reserve_ip: { state: 'success', input: {}, output: {}, updatedAt: date }, + create_cert: null, + call_exec: { state: 'success', input: {}, output: {}, updatedAt: date }, + activate_ip: { state: 'success', input: {}, output: {}, updatedAt: date }, + dns_request: { state: 'success', input: {}, output: {}, updatedAt: date }, + } + + beforeEach(async () => { + service = mock() + + module = await Test.createTestingModule({ + controllers: [ServiceChainController], + providers: [ + { provide: ServiceChainService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(ServiceChainController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call service.list()', async () => { + const mockResult = [serviceChain] + service.list.mockResolvedValue(mockResult) + + const result = await controller.list() + + expect(service.list).toHaveBeenCalled() + expect(result).toEqual(mockResult) + }) + }) + + describe('getDetails', () => { + it('should call service.getDetails() with id', async () => { + const mockResult = serviceChainDetails + service.getDetails.mockResolvedValue(mockResult) + + const result = await controller.getDetails(uuid) + + expect(service.getDetails).toHaveBeenCalledWith(uuid) + expect(result).toEqual(mockResult) + }) + }) + + describe('retry', () => { + it('should call service.retry() with id', async () => { + service.retry.mockResolvedValue() + + await controller.retry(uuid) + + expect(service.retry).toHaveBeenCalledWith(uuid) + }) + }) + + describe('validate', () => { + it('should call service.validate() with validationId', async () => { + service.validate.mockResolvedValue() + + await controller.validate(uuid) + + expect(service.validate).toHaveBeenCalledWith(uuid) + }) + }) + + describe('getFlows', () => { + it('should call service.getFlows() with id', async () => { + const mockResult = serviceChainFlows + service.getFlows.mockResolvedValue(mockResult) + + const result = await controller.getFlows(uuid) + + expect(service.getFlows).toHaveBeenCalledWith(uuid) + expect(result).toEqual(mockResult) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts new file mode 100644 index 0000000000..1caea91186 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Inject, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ServiceChainService } from './service-chain.service' + +@Controller('api/v1/service-chains') +export class ServiceChainController { + constructor( + @Inject(ServiceChainService) + private readonly serviceChainService: ServiceChainService, + ) {} + + @Post('validate/:validationId') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async validate(@Param('validationId', ParseUUIDPipe) validationId: string) { + await this.serviceChainService.validate(validationId) + } + + @Get() + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async list() { + return this.serviceChainService.list() + } + + @Get(':serviceChainId') + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async getDetails(@Param('serviceChainId', ParseUUIDPipe) id: string) { + return this.serviceChainService.getDetails(id) + } + + @Post(':serviceChainId/retry') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async retry(@Param('serviceChainId', ParseUUIDPipe) id: string) { + await this.serviceChainService.retry(id) + } + + @Get(':serviceChainId/flows') + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async getFlows(@Param('serviceChainId', ParseUUIDPipe) id: string) { + return this.serviceChainService.getFlows(id) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts new file mode 100644 index 0000000000..52964ab4b2 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { openCdsConfigFactory } from '../../config/opencds.config' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { OpenCdsClientService } from './open-cds-client.service' +import { ServiceChainController } from './service-chain.controller' +import { ServiceChainService } from './service-chain.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ConfigModule.forFeature(openCdsConfigFactory), + ConfigModule.forFeature(baseConfigFactory), + ], + controllers: [ServiceChainController], + providers: [OpenCdsClientService, ServiceChainService], + exports: [ServiceChainService], +}) +export class ServiceChainModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts new file mode 100644 index 0000000000..e92351a0b2 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts @@ -0,0 +1,124 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { OpenCdsClientService } from './open-cds-client.service' +import { ServiceChainService } from './service-chain.service' + +describe('serviceChainService', () => { + let module: TestingModule + let service: ServiceChainService + let openCdsClient: MockProxy + + beforeEach(async () => { + openCdsClient = mock() + + module = await Test.createTestingModule({ + providers: [ + ServiceChainService, + { provide: OpenCdsClientService, useValue: openCdsClient }, + ], + }).compile() + + service = module.get(ServiceChainService) + }) + + const uuid = '550e8400-e29b-41d4-a716-446655440000' + + const mockServiceChain: ServiceChain = { + id: uuid, + state: 'opened', + commonName: 'test.example.com', + pai: 'test-pai', + network: 'RIE', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const mockServiceChainDetails: ServiceChainDetails = { + ...mockServiceChain, + validationId: uuid, + validatedBy: null, + ref: null, + location: 'SIR', + targetAddress: '10.0.0.1', + projectId: uuid, + env: 'INT', + subjectAlternativeName: [], + redirect: false, + antivirus: null, + websocket: false, + ipWhiteList: [], + sslOutgoing: false, + } + const flowDetails: NonNullable = { + state: 'success', + input: {}, + output: {}, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const mockServiceChainFlows: ServiceChainFlows = { + reserve_ip: flowDetails, + create_cert: null, + call_exec: flowDetails, + activate_ip: flowDetails, + dns_request: flowDetails, + } + + describe('list', () => { + it('should call GET /requests and parse response', async () => { + openCdsClient.get.mockResolvedValue([mockServiceChain]) + + const result = await service.list() + + expect(openCdsClient.get).toHaveBeenCalledWith('/requests') + expect(result).toHaveLength(1) + expect(result[0].id).toBe(uuid) + }) + }) + + describe('getDetails', () => { + it('should call GET /requests/:id and parse response', async () => { + openCdsClient.get.mockResolvedValue(mockServiceChainDetails) + + const result = await service.getDetails(uuid) + + expect(openCdsClient.get).toHaveBeenCalledWith(`/requests/${uuid}`) + expect(result.id).toBe(uuid) + expect(result.location).toBe('SIR') + }) + }) + + describe('retry', () => { + it('should call POST /requests/:id/retry', async () => { + openCdsClient.post.mockResolvedValue() + + await service.retry(uuid) + + expect(openCdsClient.post).toHaveBeenCalledWith(`/requests/${uuid}/retry`) + }) + }) + + describe('validate', () => { + it('should call POST /validate/:validationId', async () => { + openCdsClient.post.mockResolvedValue() + + await service.validate(uuid) + + expect(openCdsClient.post).toHaveBeenCalledWith(`/validate/${uuid}`) + }) + }) + + describe('getFlows', () => { + it('should call GET /requests/:id/flows and parse response', async () => { + openCdsClient.get.mockResolvedValue(mockServiceChainFlows) + + const result = await service.getFlows(uuid) + + expect(openCdsClient.get).toHaveBeenCalledWith(`/requests/${uuid}/flows`) + expect(result.reserve_ip.state).toBe('success') + expect(result.create_cert).toBeNull() + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts new file mode 100644 index 0000000000..d40a031fdf --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts @@ -0,0 +1,45 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import { + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainListSchema, +} from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { OpenCdsClientService } from './open-cds-client.service' + +@Injectable() +export class ServiceChainService { + constructor(@Inject(OpenCdsClientService) private readonly openCdsClient: OpenCdsClientService) {} + + @StartActiveSpan() + async list(): Promise { + return ServiceChainListSchema.parse( + await this.openCdsClient.get('/requests'), + ) + } + + @StartActiveSpan() + async getDetails(id: string): Promise { + return ServiceChainDetailsSchema.parse( + await this.openCdsClient.get(`/requests/${id}`), + ) + } + + @StartActiveSpan() + async retry(id: string): Promise { + await this.openCdsClient.post(`/requests/${id}/retry`) + } + + @StartActiveSpan() + async validate(validationId: string): Promise { + await this.openCdsClient.post(`/validate/${validationId}`) + } + + @StartActiveSpan() + async getFlows(id: string): Promise { + return ServiceChainFlowsSchema.parse( + await this.openCdsClient.get(`/requests/${id}/flows`), + ) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts new file mode 100644 index 0000000000..3500ad7ec9 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts @@ -0,0 +1,20 @@ +import type { HttpStatus } from '@nestjs/common' +import type { Response } from 'undici' + +export class OpenCdsClientError extends Error { + constructor( + public readonly status: HttpStatus, + public readonly statusText: string, + public readonly body?: string, + ) { + super(`OpenCDS request failed with ${status} ${statusText}`) + this.name = 'OpenCdsClientError' + } +} + +export async function throwIfNotOk(response: T): Promise { + if (!response.ok) { + const body = await response.text() + throw new OpenCdsClientError(response.status, response.statusText, body) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts new file mode 100644 index 0000000000..26a5d114c4 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts @@ -0,0 +1,203 @@ +import type { AddPermissionGroupParams, CreateUserParams, DeactivateUserParams, RevokeUserTokenParams } from './sonarqube-client.service' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' +import { makeSonarqubeGeneratedToken, makeSonarqubeGroup, makeSonarqubePaging, makeSonarqubeProject, makeSonarqubeUser } from './sonarqube-testing.utils' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +const sonarUrl = 'https://sonarqube.internal' +const sonarToken = 'my-token' +const sonarAuthHeader = `Bearer ${sonarToken}` + +const server = setupServer() + +describe('sonarqubeClientService', () => { + let service: SonarqubeClientService + let config: ReturnType> + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + config = mockDeep({ + apiToken: sonarToken, + internalOrPublicUrl: sonarUrl, + }) + + const module = await Test.createTestingModule({ + providers: [ + SonarqubeClientService, + SonarqubeHttpClientService, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = module.get(SonarqubeClientService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('userGroupsSearch', () => { + it('should GET user_groups/search with auth', async () => { + const group = makeSonarqubeGroup({ name: 'my-group' }) + server.use( + http.get(`${sonarUrl}/api/user_groups/search`, ({ request }) => { + expect(request.headers.get('authorization')).toBe(sonarAuthHeader) + expect(new URL(request.url).searchParams.get('q')).toBe('my-group') + return HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), groups: [group] }) + }), + ) + const result = await service.searchUserGroup({ q: 'my-group' }) + expect(result.groups).toEqual([group]) + }) + }) + + describe('userGroupsCreate', () => { + it('should POST user_groups/create', async () => { + server.use( + http.post(`${sonarUrl}/api/user_groups/create`, ({ request }) => { + expect(new URL(request.url).searchParams.get('name')).toBe('new-group') + return HttpResponse.json({}) + }), + ) + await expect(service.createUserGroup({ name: 'new-group' })).resolves.not.toThrow() + }) + }) + + describe('usersSearch', () => { + it('should GET users/search', async () => { + const user = makeSonarqubeUser({ login: 'my-user' }) + server.use( + http.get(`${sonarUrl}/api/users/search`, () => + HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), users: [user] })), + ) + const result = await service.searchUsers({ q: 'my-user' }) + expect(result.users).toEqual([user]) + }) + }) + + describe('usersCreate', () => { + it('should POST users/create with all params as query string', async () => { + const user = { + email: faker.internet.email(), + local: 'true', + login: faker.internet.username(), + name: faker.internet.username(), + password: faker.internet.password(), + } satisfies CreateUserParams + server.use( + http.post(`${sonarUrl}/api/users/create`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('login')).toBe(user.login) + expect(params.get('email')).toBe(user.email) + expect(params.get('local')).toBe(user.local) + return HttpResponse.json({}) + }), + ) + await service.createUser(user) + }) + }) + + describe('usersDeactivate', () => { + it('should POST users/deactivate with anonymize param', async () => { + const user = { + login: faker.internet.username(), + anonymize: true, + } satisfies DeactivateUserParams + server.use( + http.post(`${sonarUrl}/api/users/deactivate`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('login')).toBe(user.login) + expect(params.get('anonymize')).toBe(String(user.anonymize)) + return HttpResponse.json({}) + }), + ) + await service.deactivateUser(user) + }) + }) + + describe('userTokensRevoke / userTokensGenerate', () => { + it('should POST user_tokens/revoke', async () => { + const token = makeSonarqubeGeneratedToken() + const revoke = { + login: token.login, + name: token.name, + } satisfies RevokeUserTokenParams + server.use( + http.post(`${sonarUrl}/api/user_tokens/revoke`, () => HttpResponse.json({})), + ) + await expect(service.revokeUserToken(revoke)).resolves.not.toThrow() + }) + + it('should POST user_tokens/generate and return the token', async () => { + const generated = makeSonarqubeGeneratedToken() + server.use( + http.post(`${sonarUrl}/api/user_tokens/generate`, () => HttpResponse.json(generated)), + ) + const result = await service.generateUserToken({ login: generated.login, name: generated.name }) + expect(result.token).toBe(generated.token) + }) + }) + + describe('projectsSearch', () => { + it('should GET projects/search', async () => { + const project = makeSonarqubeProject() + server.use( + http.get(`${sonarUrl}/api/projects/search`, () => + HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), components: [project] })), + ) + const result = await service.searchProject({ q: project.name }) + expect(result.components).toEqual([project]) + }) + }) + + describe('projectsDelete', () => { + it('should POST projects/delete with project key as query param', async () => { + const project = makeSonarqubeProject() + server.use( + http.post(`${sonarUrl}/api/projects/delete`, ({ request }) => { + expect(new URL(request.url).searchParams.get('project')).toBe(project.key) + return HttpResponse.json({}) + }), + ) + await service.deleteProject({ project: project.key }) + }) + }) + + describe('permissionsAddGroup', () => { + it('should POST permissions/add_group with global params', async () => { + const group = { + groupName: '/admin', + permission: 'admin', + } satisfies AddPermissionGroupParams + server.use( + http.post(`${sonarUrl}/api/permissions/add_group`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('groupName')).toBe(group.groupName) + expect(params.get('permission')).toBe(group.permission) + expect(params.has('projectKey')).toBe(false) + return HttpResponse.json({}) + }), + ) + await service.addPermissionGroup(group) + }) + + it('should POST permissions/add_group with projectKey for project-scoped call', async () => { + server.use( + http.post(`${sonarUrl}/api/permissions/add_group`, ({ request }) => { + expect(new URL(request.url).searchParams.get('projectKey')).toBe('proj-key') + return HttpResponse.json({}) + }), + ) + await service.addPermissionGroup({ groupName: '/proj', permission: 'scan', projectKey: 'proj-key' }) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts new file mode 100644 index 0000000000..7cc63c64a4 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts @@ -0,0 +1,287 @@ +import type { + SONARQUBE_PROJECT_QUALIFIER_APPLICATION, + SONARQUBE_PROJECT_QUALIFIER_BRANCH, + SONARQUBE_PROJECT_QUALIFIER_DIRECTORY, + SONARQUBE_PROJECT_QUALIFIER_FILE, + SONARQUBE_PROJECT_QUALIFIER_LIBRARY, + SONARQUBE_PROJECT_QUALIFIER_PROJECT, + SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW, + SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST, + SONARQUBE_PROJECT_QUALIFIER_VIEW, +} from './sonarqube.constants' +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' + +export interface SonarqubePaging { + pageIndex: number + pageSize: number + total: number +} + +export interface SonarqubeGroup { + id: string + name: string + description: string + membersCount: number + default: boolean +} + +export interface SonarqubeUser { + login: string + name: string + active: boolean + email: string + groups: string[] + tokensCount: number + local: boolean + externalIdentity: string + externalProvider: string + managed: boolean +} + +export type SonarqubeProjectQualifier + = | typeof SONARQUBE_PROJECT_QUALIFIER_APPLICATION + | typeof SONARQUBE_PROJECT_QUALIFIER_BRANCH + | typeof SONARQUBE_PROJECT_QUALIFIER_DIRECTORY + | typeof SONARQUBE_PROJECT_QUALIFIER_FILE + | typeof SONARQUBE_PROJECT_QUALIFIER_LIBRARY + | typeof SONARQUBE_PROJECT_QUALIFIER_PROJECT + | typeof SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST + | typeof SONARQUBE_PROJECT_QUALIFIER_VIEW + | typeof SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW + +export interface SonarqubeProject { + key: string + name: string + qualifier: SonarqubeProjectQualifier + visibility: 'private' | 'public' + lastAnalysisDate?: string + revision?: string +} + +export interface SonarqubeProjectResult { + projectSlug: string + repository: string + key: string +} + +export interface SonarqubeGeneratedToken { + token: string + login: string + name: string +} + +type BaseParams = Record + +export interface SearchUserGroupParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateUserGroupParams extends BaseParams { + name: string + description?: string +} + +export interface CreatePermissionTemplateParams extends BaseParams { + name: string + description?: string + projectKeyPattern?: string +} + +export interface SearchPermissionTemplatesParams extends BaseParams { + q?: string +} + +export interface SetPermissionDefaultTemplateParams extends BaseParams { + templateName: string + projectKeyPattern?: string +} + +export interface AddPermissionProjectCreatorToTemplateParams extends BaseParams { + templateName: string + permission: string +} + +export interface AddPermissionGroupToTemplateParams extends BaseParams { + groupName: string + templateName: string + permission: string +} + +export interface AddPermissionGroupParams extends BaseParams { + groupName: string + permission: string + projectKey?: string +} + +export interface AddPermissionUserParams extends BaseParams { + projectKey: string + permission: string + login: string +} + +export interface SearchUsersParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateUserParams extends BaseParams { + email: string + local: string + login: string + name: string + password: string +} + +export interface DeactivateUserParams extends BaseParams { + login: string + anonymize: boolean +} + +export interface RevokeUserTokenParams extends BaseParams { + login: string + name: string +} + +export interface GenerateUserTokenParams extends BaseParams { + login: string + name: string +} + +export interface SearchProjectParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateProjectParams extends BaseParams { + project: string + visibility: string + name: string + mainbranch: string +} + +export interface DeleteProjectParams extends BaseParams { + project: string +} + +export interface SearchUserGroupResponse { + paging: SonarqubePaging + groups: SonarqubeGroup[] +} + +export interface SonarqubePermissionTemplate { + id: string + name: string + description?: string +} + +export interface SearchPermissionTemplatesResponse { + permissionTemplates: SonarqubePermissionTemplate[] +} + +export interface SearchUsersResponse { + paging: SonarqubePaging + users: SonarqubeUser[] +} + +export interface SearchProjectResponse { + paging: SonarqubePaging + components: SonarqubeProject[] +} + +@Injectable() +export class SonarqubeClientService { + constructor( + @Inject(SonarqubeHttpClientService) private readonly http: SonarqubeHttpClientService, + ) {} + + @StartActiveSpan() + searchUserGroup(params: SearchUserGroupParams) { + return this.http.fetch('user_groups/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createUserGroup(params: CreateUserGroupParams) { + await this.http.fetch('user_groups/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async createPermissionTemplate(params: CreatePermissionTemplateParams) { + await this.http.fetch('permissions/create_template', { method: 'POST', params }) + } + + @StartActiveSpan() + searchPermissionTemplates(params: SearchPermissionTemplatesParams) { + return this.http.fetch('permissions/search_templates', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async setPermissionDefaultTemplate(params: SetPermissionDefaultTemplateParams) { + await this.http.fetch('permissions/set_default_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionProjectCreatorToTemplate(params: AddPermissionProjectCreatorToTemplateParams) { + await this.http.fetch('permissions/add_project_creator_to_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionGroupToTemplate(params: AddPermissionGroupToTemplateParams) { + await this.http.fetch('permissions/add_group_to_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionGroup(params: AddPermissionGroupParams) { + await this.http.fetch('permissions/add_group', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionUser(params: AddPermissionUserParams) { + await this.http.fetch('permissions/add_user', { method: 'POST', params }) + } + + @StartActiveSpan() + searchUsers(params: SearchUsersParams) { + return this.http.fetch('users/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createUser(params: CreateUserParams) { + await this.http.fetch('users/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async deactivateUser(params: DeactivateUserParams) { + await this.http.fetch('users/deactivate', { method: 'POST', params }) + } + + @StartActiveSpan() + async revokeUserToken(params: RevokeUserTokenParams) { + await this.http.fetch('user_tokens/revoke', { method: 'POST', params }) + } + + @StartActiveSpan() + generateUserToken(params: GenerateUserTokenParams) { + return this.http.fetch('user_tokens/generate', { method: 'POST', params }).then(res => res.data!) + } + + @StartActiveSpan() + searchProject(params: SearchProjectParams) { + return this.http.fetch('projects/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createProject(params: CreateProjectParams) { + await this.http.fetch('projects/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async deleteProject(params: DeleteProjectParams) { + await this.http.fetch('projects/delete', { method: 'POST', params }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts new file mode 100644 index 0000000000..bcb3143972 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts @@ -0,0 +1,68 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './sonarqube.constants' + +export const projectSelect = { + id: true, + slug: true, + repositories: { + select: { + internalRepoName: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class SonarqubeDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig( + pluginName: string, + key: string, + ): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts new file mode 100644 index 0000000000..8f77620a8c --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -0,0 +1,26 @@ +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +@Injectable() +export class SonarqubeHealthService { + constructor( + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const bearerToken = Buffer.from(`${this.sonarqubeConfig.apiToken}:`, 'utf-8').toString('base64') + headers.Authorization = `Bearer ${bearerToken}` + try { + const response = await fetch(this.sonarqubeConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts new file mode 100644 index 0000000000..6ee07f8585 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts @@ -0,0 +1,103 @@ +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +export interface SonarqubeFetchOptions { + method?: string + params?: Record +} + +export interface SonarqubeResponse { + status: HttpStatus + data: T | null +} + +export type SonarqubeErrorKind = 'NotConfigured' | 'ClientError' | 'ServerError' | 'Unexpected' + +export class SonarqubeError extends Error { + readonly kind: SonarqubeErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + + constructor( + kind: SonarqubeErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string } = {}, + ) { + super(message) + this.name = 'SonarqubeError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + } +} + +@Injectable() +export class SonarqubeHttpClientService { + constructor( + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + ) {} + + private get apiBaseUrl(): string { + return new URL('api/', this.sonarqubeConfig.internalOrPublicUrl).toString() + } + + private get defaultHeaders(): Record { + if (!this.sonarqubeConfig.apiToken) throw new SonarqubeError('NotConfigured', 'SONAR_API_TOKEN is required') + return { + Authorization: `Bearer ${this.sonarqubeConfig.apiToken}`, + } + } + + async fetch(path: string, options: SonarqubeFetchOptions = {}): Promise> { + const span = trace.getActiveSpan() + const method = (options.method ?? 'GET').toUpperCase() + span?.setAttribute('sonarqube.method', method) + span?.setAttribute('sonarqube.path', path) + + const request = this.createRequest(path, method, options.params) + const response = await fetch(request).catch((error) => { + throw new SonarqubeError('Unexpected', error instanceof Error ? error.message : String(error), { method, path }) + }) + + span?.setAttribute('sonarqube.http.status', response.status) + const result = await handleResponse(response) + if (response.status >= 400) { + const kind = response.status >= 500 ? 'ServerError' : 'ClientError' + throw new SonarqubeError(kind, formatErrorMessage(response.status, result.data), { status: response.status, method, path }) + } + return result + } + + private createRequest(path: string, method: string, params?: Record): Request { + const url = new URL(path, this.apiBaseUrl) + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) url.searchParams.append(key, String(value)) + } + } + return new Request(url.toString(), { method, headers: this.defaultHeaders }) + } +} + +function formatErrorMessage(status: number, data: unknown): string { + const errors = (data as { errors?: { msg?: unknown }[] } | null)?.errors + const details = Array.isArray(errors) + ? errors.map(e => e?.msg).filter((msg): msg is string => typeof msg === 'string').join('; ') + : '' + return details + ? `SonarQube API responded with status ${status}: ${details}` + : `SonarQube API responded with status ${status}` +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts new file mode 100644 index 0000000000..671a19562a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { SonarqubePluginService } from './sonarqube-plugin.service' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +describe('sonarqubePluginService', () => { + let service: SonarqubePluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://sonar.public/', + internalUrl: 'https://sonar.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + SonarqubePluginService, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(SonarqubePluginService) + }) + + it('returns the public SonarQube url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://sonar.public/projects') + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts new file mode 100644 index 0000000000..d1ffd9387e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts @@ -0,0 +1,22 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Inject, Injectable } from '@nestjs/common' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +@Injectable() +export class SonarqubePluginService { + constructor( + @Inject(SONARQUBE_CONFIG) + private readonly sonarqubeConfig: SonarqubeConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'sonarqube', + to: () => new URL('projects', this.sonarqubeConfig.url).toString(), + title: 'SonarQube', + imgSrc: '/img/sonarqube.svg', + description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts new file mode 100644 index 0000000000..0b7e8c0bbc --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts @@ -0,0 +1,90 @@ +import type { SonarqubeGeneratedToken, SonarqubeGroup, SonarqubePaging, SonarqubeProject, SonarqubeUser } from './sonarqube-client.service' +import type { ProjectWithDetails } from './sonarqube-datastore.service' +import { faker } from '@faker-js/faker' +import { SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' + +export function makeUserToken(overrides: Partial = {}) { + return { + token: faker.string.uuid(), + login: faker.internet.username(), + name: faker.person.fullName(), + ...overrides, + } satisfies SonarqubeGeneratedToken +} + +export function makeEmptyGroupsResponse() { + return { paging: makeSonarqubePaging(), groups: [] } +} + +export function makeEmptyUsersResponse() { + return { paging: makeSonarqubePaging(), users: [] } +} + +export function makeEmptyProjectsResponse() { + return { paging: makeSonarqubePaging(), components: [] } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.internet.domainWord(), + repositories: [], + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + +export function makeSonarqubeGroup(overrides: Partial = {}): SonarqubeGroup { + return { + id: faker.string.uuid(), + name: faker.internet.domainWord(), + description: '', + membersCount: 0, + default: false, + ...overrides, + } satisfies SonarqubeGroup +} + +export function makeSonarqubeUser(overrides: Partial = {}): SonarqubeUser { + return { + login: faker.internet.username(), + name: faker.person.fullName(), + active: true, + email: faker.internet.email(), + groups: [], + tokensCount: 0, + local: true, + externalIdentity: '', + externalProvider: '', + managed: false, + ...overrides, + } satisfies SonarqubeUser +} + +export function makeSonarqubeProject(overrides: Partial = {}): SonarqubeProject { + return { + key: faker.string.alphanumeric(20), + name: faker.internet.domainWord(), + qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, + visibility: 'private', + ...overrides, + } satisfies SonarqubeProject +} + +export function makeSonarqubePaging(overrides: Partial = {}): SonarqubePaging { + return { + pageIndex: 1, + pageSize: 100, + total: 0, + ...overrides, + } satisfies SonarqubePaging +} + +export function makeSonarqubeGeneratedToken(overrides: Partial = {}): SonarqubeGeneratedToken { + return { + token: faker.string.alphanumeric(40), + login: faker.internet.username(), + name: `Sonar Token for ${faker.internet.username()}`, + ...overrides, + } satisfies SonarqubeGeneratedToken +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts new file mode 100644 index 0000000000..9618e9341e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts @@ -0,0 +1,51 @@ +export const PLUGIN_NAME = 'sonarqube' +export const DEFAULT_PERMISSION_TEMPLATE_NAME = 'Forge Default' + +// SonarQube global permission names +export const GLOBAL_ADMIN_PERMISSIONS = ['admin', 'profileadmin', 'gateadmin', 'scan', 'provisioning'] as const + +// Permission template — grants to project creator and sonar-administrators on new projects +export const DEFAULT_TEMPLATE_PERMISSIONS = ['admin', 'codeviewer', 'issueadmin', 'securityhotspotadmin', 'scan', 'user'] as const + +// Project-level permission sets per role (SonarQube permission API names) +export const PROJECT_ADMIN_PERMISSIONS = ['admin', 'scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_DEVOPS_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_DEVELOPER_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_SECURITY_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_READONLY_PERMISSIONS = ['user', 'codeviewer'] as const + +// CI robot/service account — needs Execute Analysis + Browse + See Source Code +export const ROBOT_PROJECT_PERMISSIONS = ['scan', 'user', 'codeviewer'] as const + +// Default platform-wide Keycloak group paths +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_READONLY_GROUP_PATH = '/console/readonly' +export const DEFAULT_SECURITY_GROUP_PATH = '/console/security' + +// Default project role group path suffixes (appended to /{projectSlug}) +export const DEFAULT_PROJECT_ADMIN_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVOPS_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_SECURITY_SUFFIX = '/console/security' +export const DEFAULT_PROJECT_READONLY_SUFFIX = '/console/readonly' + +// Admin plugin config keys for overriding defaults +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const READONLY_GROUP_PATH_PLUGIN_KEY = 'readonlyGroupPath' +export const SECURITY_GROUP_PATH_PLUGIN_KEY = 'securityGroupPath' +export const PROJECT_ADMIN_SUFFIX_PLUGIN_KEY = 'projectAdminSuffix' +export const PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY = 'projectDevopsSuffix' +export const PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY = 'projectDeveloperSuffix' +export const PROJECT_SECURITY_SUFFIX_PLUGIN_KEY = 'projectSecuritySuffix' +export const PROJECT_READONLY_SUFFIX_PLUGIN_KEY = 'projectReadonlySuffix' + +// SonarQube project qualifier identifiers +export const SONARQUBE_PROJECT_QUALIFIER_APPLICATION = 'APP' +export const SONARQUBE_PROJECT_QUALIFIER_BRANCH = 'BRC' +export const SONARQUBE_PROJECT_QUALIFIER_DIRECTORY = 'DIR' +export const SONARQUBE_PROJECT_QUALIFIER_FILE = 'FIL' +export const SONARQUBE_PROJECT_QUALIFIER_LIBRARY = 'LIB' +export const SONARQUBE_PROJECT_QUALIFIER_PROJECT = 'TRK' +export const SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW = 'SVW' +export const SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST = 'UTS' +export const SONARQUBE_PROJECT_QUALIFIER_VIEW = 'VW' diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts new file mode 100644 index 0000000000..ef4dac617c --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts @@ -0,0 +1,16 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface SonarqubeConfig { + url: string + internalUrl: string + apiToken: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface SonarqubeModuleOptions extends SonarqubeConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('sonarqube') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as SONARQUBE_CONFIG } diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts new file mode 100644 index 0000000000..f054302ff4 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { SonarqubeHealthService } from './sonarqube-health.service' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' +import { SonarqubePluginService } from './sonarqube-plugin.service' +import { ConfigurableModuleClass } from './sonarqube.module-definition' +import { SonarqubeService } from './sonarqube.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule], + providers: [ + SonarqubeHealthService, + SonarqubeHttpClientService, + SonarqubeClientService, + SonarqubeDatastoreService, + SonarqubePluginService, + SonarqubeService, + ], + exports: [SonarqubeClientService, SonarqubeHealthService, SonarqubePluginService, SonarqubeService], +}) +export class SonarqubeModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts new file mode 100644 index 0000000000..e7d0ef90e1 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -0,0 +1,291 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { generateProjectKey } from '../../utils/crypto.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { + makeEmptyGroupsResponse, + makeEmptyProjectsResponse, + makeEmptyUsersResponse, + makeProjectWithDetails, + makeSonarqubePaging, + makeSonarqubeUser, + makeUserToken, +} from './sonarqube-testing.utils' +import { PLUGIN_NAME, SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' +import { SonarqubeService } from './sonarqube.service' + +describe('sonarqubeService', () => { + let service: SonarqubeService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let config: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + searchUserGroup: vi.fn().mockResolvedValue(makeEmptyGroupsResponse()), + createUserGroup: vi.fn().mockResolvedValue(undefined), + createPermissionTemplate: vi.fn().mockResolvedValue(undefined), + searchPermissionTemplates: vi.fn().mockResolvedValue({ permissionTemplates: [] }), + setPermissionDefaultTemplate: vi.fn().mockResolvedValue(undefined), + addPermissionGroupToTemplate: vi.fn().mockResolvedValue(undefined), + addPermissionGroup: vi.fn().mockResolvedValue(undefined), + addPermissionUser: vi.fn().mockResolvedValue(undefined), + searchUsers: vi.fn().mockResolvedValue(makeEmptyUsersResponse()), + createUser: vi.fn().mockResolvedValue(undefined), + deactivateUser: vi.fn().mockResolvedValue(undefined), + revokeUserToken: vi.fn().mockResolvedValue(undefined), + searchProject: vi.fn().mockResolvedValue(makeEmptyProjectsResponse()), + createProject: vi.fn().mockResolvedValue(undefined), + deleteProject: vi.fn().mockResolvedValue(undefined), + }) + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + readSonarqubeUser: vi.fn().mockResolvedValue(null), + writeSonarqubeUser: vi.fn().mockResolvedValue(undefined), + deleteSonarqubeUser: vi.fn().mockResolvedValue(undefined), + }) + config = mockDeep({ + internalOrPublicUrl: 'https://sonarqube.internal', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + SonarqubeService, + { provide: SonarqubeClientService, useValue: client }, + { provide: SonarqubeDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(SonarqubeService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('init', () => { + it('should set up the permission template', async () => { + await service.init() + expect(client.createPermissionTemplate).toHaveBeenCalledWith({ name: 'Forge Default' }) + expect(client.setPermissionDefaultTemplate).toHaveBeenCalledWith({ templateName: 'Forge Default' }) + }) + + it('should not recreate the permission template when it already exists', async () => { + client.searchPermissionTemplates.mockResolvedValue({ + permissionTemplates: [{ id: '1', name: 'Forge Default' }], + }) + await service.init() + expect(client.createPermissionTemplate).not.toHaveBeenCalled() + expect(client.setPermissionDefaultTemplate).toHaveBeenCalledWith({ templateName: 'Forge Default' }) + }) + + it('should create /console/admin group with global permissions when it does not exist', async () => { + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/admin' })) + }) + + it('should create /console/readonly and /console/security platform groups', async () => { + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/readonly' })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/security' })) + }) + + it('should not create groups that already exist', async () => { + client.searchUserGroup.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 1 }), + groups: [{ id: '1', name: '/console/admin', description: '', membersCount: 1, default: false }], + }) + await service.init() + expect(client.createUserGroup).not.toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) + }) + + it('should use custom group paths from admin plugin config', async () => { + datastore.getAdminPluginConfig.mockImplementation((_plugin, key) => { + if (key === 'adminGroupPath') return Promise.resolve('/custom/admin') + return Promise.resolve(null) + }) + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/custom/admin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/custom/admin' })) + }) + }) + + describe('handleUpsert', () => { + it('should create the 5 project role groups in SonarQube', async () => { + const project = makeProjectWithDetails() + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/admin` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/devops` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/developer` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/security` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/readonly` })) + }) + + it('should create a new user and write vault credentials', async () => { + const project = makeProjectWithDetails() + const userToken = makeUserToken({ login: project.slug }) + client.generateUserToken.mockResolvedValue(userToken) + + await service.handleUpsert(project) + + expect(client.createUser).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(client.generateUserToken).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(vault.writeSonarqubeUser).toHaveBeenCalledWith(project.slug, expect.objectContaining({ SONAR_USERNAME: project.slug, SONAR_TOKEN: userToken.token })) + }) + + it('should set role-based permissions on new repositories', async () => { + const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'repo' }] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.createProject).toHaveBeenCalledWith(expect.objectContaining({ visibility: 'private', name: `${project.slug}-repo` })) + expect(client.addPermissionUser).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/admin` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/devops` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/readonly' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/security' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer`, permission: 'issueadmin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer`, permission: 'securityhotspotadmin' })) + }) + + it('should not recreate user or write vault when both user and secret exist', async () => { + const project = makeProjectWithDetails({ slug: 'existing', repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.createUser).not.toHaveBeenCalled() + expect(client.generateUserToken).not.toHaveBeenCalled() + expect(vault.writeSonarqubeUser).not.toHaveBeenCalled() + }) + + it('should rotate token when user exists but vault secret is missing', async () => { + const project = makeProjectWithDetails({ repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + + await service.handleUpsert(project) + + expect(client.createUser).not.toHaveBeenCalled() + expect(client.generateUserToken).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(vault.writeSonarqubeUser).toHaveBeenCalledWith(project.slug, expect.objectContaining({ SONAR_PASSWORD: 'not initialized' })) + }) + + it('should delete sonarqube projects for removed repositories', async () => { + const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'kept' }] }) + const keptKey = generateProjectKey(project.slug, 'kept') + const removedKey = generateProjectKey(project.slug, 'removed') + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 2 }), + components: [ + { key: keptKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + { key: removedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + ], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.deleteProject).toHaveBeenCalledWith({ project: removedKey }) + expect(client.deleteProject).not.toHaveBeenCalledWith({ project: keptKey }) + }) + + it('should not delete sonarqube projects whose key was not generated by the console', async () => { + const project = makeProjectWithDetails({ slug: 'my', repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 2 }), + components: [ + // manually created project, hash suffix does not match generateProjectKey + { key: 'my-manual-project', name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + // belongs to project "my-app" (repo "x"), not to project "my" + { key: generateProjectKey('my-app', 'x'), name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + ], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.deleteProject).not.toHaveBeenCalled() + }) + + it('should use comma-separated group path suffixes from project plugin config', async () => { + const project = makeProjectWithDetails({ + repositories: [{ internalRepoName: 'repo' }], + plugins: [{ pluginName: PLUGIN_NAME, key: 'projectAdminSuffix', value: '/console/admin,/console/owner' }], + }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/admin` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/owner` })) + }) + }) + + describe('handleDelete', () => { + it('should delete sonarqube projects, anonymize user and remove vault entry', async () => { + const project = makeProjectWithDetails({ slug: 'doomed' }) + const doomedKey = generateProjectKey('doomed', 'repo') + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 1 }), + components: [{ key: doomedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: 'doomed' })] }) + + await service.handleDelete(project) + + expect(client.deleteProject).toHaveBeenCalledWith({ project: doomedKey }) + expect(client.deactivateUser).toHaveBeenCalledWith({ login: 'doomed', anonymize: true }) + expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('doomed') + }) + + it('should skip anonymization when the user does not exist', async () => { + const project = makeProjectWithDetails({ slug: 'no-user' }) + + await service.handleDelete(project) + + expect(client.deactivateUser).not.toHaveBeenCalled() + expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('no-user') + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects and run init', async () => { + const projects = [ + makeProjectWithDetails({ repositories: [] }), + makeProjectWithDetails({ repositories: [] }), + ] + datastore.getAllProjects.mockResolvedValue(projects) + client.generateUserToken.mockImplementation(({ login }) => Promise.resolve(makeUserToken({ login }))) + + await service.handleCron() + + expect(client.searchProject).toHaveBeenCalledTimes(2) + expect(client.createPermissionTemplate).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts new file mode 100644 index 0000000000..a4500dcca9 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -0,0 +1,456 @@ +import type { OnModuleInit } from '@nestjs/common' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { SonarqubeUserSecret } from '../vault/vault-client.service' +import type { SonarqubeProjectResult, SonarqubeUser } from './sonarqube-client.service' +import type { ProjectWithDetails } from './sonarqube-datastore.service' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { generateProjectKey, generateRandomPassword } from '../../utils/crypto.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_PERMISSION_TEMPLATE_NAME, + DEFAULT_PROJECT_ADMIN_SUFFIX, + DEFAULT_PROJECT_DEVELOPER_SUFFIX, + DEFAULT_PROJECT_DEVOPS_SUFFIX, + DEFAULT_PROJECT_READONLY_SUFFIX, + DEFAULT_PROJECT_SECURITY_SUFFIX, + DEFAULT_READONLY_GROUP_PATH, + DEFAULT_SECURITY_GROUP_PATH, + DEFAULT_TEMPLATE_PERMISSIONS, + GLOBAL_ADMIN_PERMISSIONS, + PLUGIN_NAME, + PROJECT_ADMIN_PERMISSIONS, + PROJECT_ADMIN_SUFFIX_PLUGIN_KEY, + PROJECT_DEVELOPER_PERMISSIONS, + PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_PERMISSIONS, + PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY, + PROJECT_READONLY_PERMISSIONS, + PROJECT_READONLY_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_PERMISSIONS, + PROJECT_SECURITY_SUFFIX_PLUGIN_KEY, + READONLY_GROUP_PATH_PLUGIN_KEY, + ROBOT_PROJECT_PERMISSIONS, + SECURITY_GROUP_PATH_PLUGIN_KEY, +} from './sonarqube.constants' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +interface SonarqubeRolePaths { + admin: string[] + devops: string[] + developer: string[] + security: string[] + readonly: string[] +} + +@Injectable() +export class SonarqubeService implements OnModuleInit { + private readonly logger = new Logger(SonarqubeService.name) + + constructor( + @Inject(SonarqubeDatastoreService) private readonly datastore: SonarqubeDatastoreService, + @Inject(SonarqubeClientService) private readonly client: SonarqubeClientService, + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('SonarqubeService initialized') + } + + async onModuleInit() { + await this.init().catch(error => this.logger.error({ error }, 'SonarQube initialization failed')) + } + + @StartActiveSpan() + async init(): Promise { + this.logger.log('Initializing SonarQube platform configuration') + const adminGroupPath = await this.getAdminGroupPath() + const [readonlyGroupPath, securityGroupPath] = await Promise.all([ + this.getReadonlyGroupPath(), + this.getSecurityGroupPath(), + ]) + await this.ensureDefaultPermissionTemplate() + await Promise.all([ + this.ensureGroupWithGlobalPermissions(adminGroupPath, GLOBAL_ADMIN_PERMISSIONS), + this.ensureGroup(readonlyGroupPath), + this.ensureGroup(securityGroupPath), + ]) + this.logger.log('SonarQube platform configuration initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('sonarqube', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`SonarQube sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('sonarqube', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.deleteProjectGroup(project) + this.logger.log(`SonarQube deletion completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting SonarQube reconciliation') + await this.init().catch(error => this.logger.error('SonarQube init during cron failed', error)) + const projects = await this.datastore.getAllProjects() + span?.setAttribute('sonarqube.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for SonarQube reconciliation`) + await this.ensureProjectGroups(projects) + this.logger.log(`SonarQube reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('sonarqube.projects.count', projects.length) + await Promise.all(projects.map(p => + this.ensureProjectGroup(p).catch(error => + this.logger.error(`Failed to reconcile SonarQube project (slug=${p.slug})`, error), + ), + )) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const rolePaths = await this.getProjectRoleGroupPaths(project) + await Promise.all([ + this.ensureUser(project.slug, project.slug), + this.ensureProjectSonarGroups(rolePaths), + this.ensureProjectRepositories(project, rolePaths), + ]) + } + + @StartActiveSpan() + private async deleteProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const sonarProjects = await this.findProjectsForSlug(project.slug) + span?.setAttribute('sonarqube.projects.count', sonarProjects.length) + this.logger.log(`Deleting ${sonarProjects.length} SonarQube repositories for project ${project.slug}`) + + await Promise.all(sonarProjects.map(async (sp) => { + await this.client.deleteProject({ project: sp.key }) + this.logger.verbose(`Deleted SonarQube repository (key=${sp.key})`) + })) + + const user = await this.findUser(project.slug) + if (user) { + await this.client.deactivateUser({ login: project.slug, anonymize: true }) + this.logger.log(`Anonymized SonarQube user (login=${project.slug})`) + } else { + this.logger.verbose(`SonarQube user not found, skipping anonymization (login=${project.slug})`) + } + + await this.vault.deleteSonarqubeUser(project.slug) + this.logger.verbose(`Deleted SonarQube vault credentials (slug=${project.slug})`) + } + + @StartActiveSpan() + private async ensureDefaultPermissionTemplate(): Promise { + this.logger.verbose(`Ensuring SonarQube permission template (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + const { permissionTemplates } = await this.client.searchPermissionTemplates({ q: DEFAULT_PERMISSION_TEMPLATE_NAME }) + + if (permissionTemplates.some(t => t.name.toLowerCase() === DEFAULT_PERMISSION_TEMPLATE_NAME.toLowerCase())) { + this.logger.verbose(`SonarQube permission template already exists (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } else { + await this.client.createPermissionTemplate({ name: DEFAULT_PERMISSION_TEMPLATE_NAME }) + this.logger.log(`Created SonarQube permission template (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } + await Promise.all(DEFAULT_TEMPLATE_PERMISSIONS.map(permission => + this.client.addPermissionProjectCreatorToTemplate({ templateName: DEFAULT_PERMISSION_TEMPLATE_NAME, permission }), + )) + await Promise.all(DEFAULT_TEMPLATE_PERMISSIONS.map(permission => + this.client.addPermissionGroupToTemplate({ groupName: 'sonar-administrators', templateName: DEFAULT_PERMISSION_TEMPLATE_NAME, permission }), + )) + await this.client.setPermissionDefaultTemplate({ templateName: DEFAULT_PERMISSION_TEMPLATE_NAME }) + this.logger.log(`SonarQube permission template ensured (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } + + @StartActiveSpan() + private async ensureUser(username: string, projectSlug: string): Promise { + const existingSecret = await this.vault.readSonarqubeUser(projectSlug) + const user = await this.findUser(username) + let newSecret: SonarqubeUserSecret | undefined + + if (!user) { + this.logger.log(`Creating SonarQube user (login=${username})`) + const password = generateRandomPassword(30) + await this.client.createUser({ email: `${projectSlug}@${projectSlug}`, local: 'true', login: username, name: username, password }) + const token = await this.rotateToken(username) + newSecret = { SONAR_USERNAME: username, SONAR_PASSWORD: password, SONAR_TOKEN: token } + } else if (existingSecret) { + this.logger.verbose(`SonarQube user already exists with vault credentials (login=${username})`) + } else { + this.logger.warn(`SonarQube user exists but vault secret is missing, rotating token (login=${username})`) + const token = await this.rotateToken(username) + newSecret = { SONAR_USERNAME: username, SONAR_PASSWORD: 'not initialized', SONAR_TOKEN: token } + } + + if (newSecret) { + await this.vault.writeSonarqubeUser(projectSlug, newSecret) + this.logger.log(`Stored SonarQube credentials in vault (slug=${projectSlug})`) + } + } + + private async ensureProjectSonarGroups(rolePaths: SonarqubeRolePaths): Promise { + const allGroups = [ + ...rolePaths.admin, + ...rolePaths.devops, + ...rolePaths.developer, + ...rolePaths.security, + ...rolePaths.readonly, + ] + await Promise.all(allGroups.map(group => this.ensureGroup(group))) + } + + @StartActiveSpan() + private async ensureProjectRepositories(project: ProjectWithDetails, rolePaths: SonarqubeRolePaths): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('repositories.count', project.repositories.length) + + const [readonlyGroupPath, securityGroupPath, existingSonarProjects] = await Promise.all([ + this.getReadonlyGroupPath(), + this.getSecurityGroupPath(), + this.findProjectsForSlug(project.slug), + ]) + + const orphans = existingSonarProjects.filter(sp => !project.repositories.some(r => r.internalRepoName === sp.repository)) + if (orphans.length) this.logger.log(`Removing ${orphans.length} orphan SonarQube repositories for project ${project.slug}`) + + await Promise.all([ + ...orphans.map(async (sp) => { + await this.client.deleteProject({ project: sp.key }) + this.logger.verbose(`Deleted orphan SonarQube repository (key=${sp.key})`) + }), + ...project.repositories.map(async (repository) => { + const projectKey = generateProjectKey(project.slug, repository.internalRepoName) + if (!existingSonarProjects.some(sp => sp.repository === repository.internalRepoName)) { + await this.client.createProject({ + project: projectKey, + visibility: 'private', + name: `${project.slug}-${repository.internalRepoName}`, + mainbranch: 'main', + }) + this.logger.log(`Created SonarQube repository (key=${projectKey})`) + } + await this.ensureProjectPermissions(projectKey, project.slug, rolePaths, readonlyGroupPath, securityGroupPath) + this.logger.verbose(`Ensured permissions on SonarQube repository (key=${projectKey})`) + }), + ]) + } + + private async ensureProjectPermissions( + projectKey: string, + login: string, + rolePaths: SonarqubeRolePaths, + readonlyGroupPath: string, + securityGroupPath: string, + ): Promise { + await Promise.all([ + ...ROBOT_PROJECT_PERMISSIONS.map(permission => + this.client.addPermissionUser({ projectKey, permission, login }), + ), + ...buildGroupPermissions(rolePaths, readonlyGroupPath, securityGroupPath).flatMap(({ groupName, permissions }) => + permissions.map(permission => this.client.addPermissionGroup({ projectKey, permission, groupName })), + ), + ]) + } + + private async ensureGroupWithGlobalPermissions(groupName: string, permissions: readonly string[]): Promise { + await this.ensureGroup(groupName) + await Promise.all(permissions.map(permission => + this.client.addPermissionGroup({ groupName, permission }), + )) + } + + private async ensureGroup(groupName: string): Promise { + const result = await this.client.searchUserGroup({ q: groupName }) + if (result.groups.some(g => g.name === groupName)) { + this.logger.verbose(`SonarQube group already exists (name=${groupName})`) + } else { + await this.client.createUserGroup({ name: groupName }) + this.logger.log(`Created SonarQube group (name=${groupName})`) + } + } + + private async rotateToken(login: string): Promise { + const name = `Sonar Token for ${login}` + await this.client.revokeUserToken({ login, name }).catch(() => {}) + const { token } = await this.client.generateUserToken({ login, name }) + this.logger.log(`Rotated SonarQube token (login=${login})`) + return token + } + + private async getAdminGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, ADMIN_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getReadonlyGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, READONLY_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_READONLY_GROUP_PATH + } + + private async getSecurityGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, SECURITY_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_SECURITY_GROUP_PATH + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return getProjectPluginConfig(project, key) ?? undefined + } + + private async getProjectRoleGroupPaths(project: ProjectWithDetails): Promise { + const [admin, devops, developer, security, readonly] = await Promise.all([ + this.getProjectAdminGroupPaths(project), + this.getProjectDevopsGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectSecurityGroupPaths(project), + this.getProjectReadonlyGroupPaths(project), + ]) + return { admin, devops, developer, security, readonly } + } + + private async getProjectAdminGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_ADMIN_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_ADMIN_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_ADMIN_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDevopsGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVOPS_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVELOPER_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectSecurityGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_SECURITY_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_SECURITY_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_SECURITY_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectReadonlyGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_READONLY_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_READONLY_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_READONLY_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async findUser(login: string): Promise { + let page = 1 + const pageSize = 100 + while (true) { + const response = await this.client.searchUsers({ q: login, ps: pageSize, p: page }) + const found = response.users.find(u => u.login === login) + if (found) return found + if (!response.users.length || response.paging.pageIndex * response.paging.pageSize >= response.paging.total) return undefined + page++ + } + } + + private async findProjectsForSlug(projectSlug: string): Promise { + let found: SonarqubeProjectResult[] = [] + let page = 0 + const pageSize = 100 + let total = 0 + do { + page++ + const result = await this.client.searchProject({ q: projectSlug, p: page, ps: pageSize }) + total = result.paging.total + found = [...found, ...filterProjectsOwningSlug(result.components, projectSlug)] + } while (page * pageSize < total) + return found + } +} + +function getProjectPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string): string[] { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(suffix => `/${projectSlug}${suffix}`) +} + +function buildGroupPermissions( + rolePaths: SonarqubeRolePaths, + readonlyGroupPath: string, + securityGroupPath: string, +): { groupName: string, permissions: readonly string[] }[] { + return [ + ...rolePaths.admin.map(groupName => ({ groupName, permissions: PROJECT_ADMIN_PERMISSIONS })), + ...rolePaths.devops.map(groupName => ({ groupName, permissions: PROJECT_DEVOPS_PERMISSIONS })), + ...rolePaths.developer.map(groupName => ({ groupName, permissions: PROJECT_DEVELOPER_PERMISSIONS })), + ...rolePaths.security.map(groupName => ({ groupName, permissions: PROJECT_SECURITY_PERMISSIONS })), + ...rolePaths.readonly.map(groupName => ({ groupName, permissions: PROJECT_READONLY_PERMISSIONS })), + { groupName: securityGroupPath, permissions: PROJECT_SECURITY_PERMISSIONS }, + { groupName: readonlyGroupPath, permissions: PROJECT_READONLY_PERMISSIONS }, + ] +} + +function filterProjectsOwningSlug( + components: { key: string }[], + projectSlug: string, +): SonarqubeProjectResult[] { + return components.reduce((acc, { key: sonarKey }) => { + const parts = sonarKey.split('-') + parts.pop() + for (let i = parts.length - 1; i > 0; i--) { + const project = parts.slice(0, i).join('-') + const repository = parts.slice(i).join('-') + // recompute the key (with its HMAC suffix) so keys not managed by the + // console, or owned by a project whose slug is a prefix, are never claimed + if (generateProjectKey(project, repository) === sonarKey && project === projectSlug) { + acc.push({ projectSlug, repository, key: sonarKey }) + break + } + } + return acc + }, []) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts new file mode 100644 index 0000000000..8b8fc8adff --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts @@ -0,0 +1,6 @@ +export function sonarProjectPropertiesFile(projectKey: string) { + return [ + `sonar.projectKey=${projectKey}`, + 'sonar.qualitygate.wait=true', + ] +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts new file mode 100644 index 0000000000..f4450b21d7 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts @@ -0,0 +1,11 @@ +import type { Prisma } from '@prisma/client' + +export const adminPluginSelect = { + pluginName: true, + key: true, + value: true, +} satisfies Prisma.AdminPluginSelect + +export type AdminPluginSelect = Prisma.AdminPluginGetPayload<{ + select: typeof adminPluginSelect +}> diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config.controller.ts new file mode 100644 index 0000000000..7f74d4d40d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config.controller.ts @@ -0,0 +1,27 @@ +import type { PluginsUpdateBody } from '@cpn-console/shared' +import { pluginUpdateBody } from '@cpn-console/shared' +import { Body, Controller, Get, HttpCode, HttpStatus, Inject, Post, UseGuards } from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { SystemConfigService } from './system-config.service' + +@Controller('api/v1/system/plugins') +export class SystemConfigController { + constructor(@Inject(SystemConfigService) private readonly service: SystemConfigService) {} + + @Get() + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async get() { + return this.service.list() + } + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async update(@Body(new ZodValidationPipe(pluginUpdateBody)) body: PluginsUpdateBody) { + await this.service.update(body) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config.module.ts new file mode 100644 index 0000000000..b0622abf84 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { SystemConfigController } from './system-config.controller' +import { SystemConfigService } from './system-config.service' + +@Module({ + imports: [DatabaseModule, UserPermissionModule], + controllers: [SystemConfigController], + providers: [SystemConfigService], +}) +export class SystemConfigModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts new file mode 100644 index 0000000000..0ba0742991 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts @@ -0,0 +1,41 @@ +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { SystemConfigService } from './system-config.service' + +describe('systemConfigService', () => { + let prisma: ReturnType> + let service: SystemConfigService + + beforeEach(() => { + prisma = mockDeep() + service = new SystemConfigService(prisma) + }) + + it('returns plugin config items', async () => { + prisma.adminPlugin.findMany.mockResolvedValue([ + { pluginName: 'argocd', key: 'url', value: 'https://argocd' }, + ]) + + const result = await service.list() + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + }) + + it('updates plugins config from body via transaction', async () => { + prisma.$transaction.mockImplementation(async (callback: any) => callback(prisma)) + prisma.adminPlugin.upsert.mockResolvedValue({ pluginName: 'argocd', key: 'url', value: 'https://argocd' }) + + await service.update({ argocd: { url: 'https://argocd' } }) + + expect(prisma.$transaction).toHaveBeenCalledTimes(1) + }) + + it('does not call upsert for empty body', async () => { + prisma.$transaction.mockImplementation(async (callback: any) => callback(prisma)) + + await service.update({}) + + expect(prisma.adminPlugin.upsert).toHaveBeenCalledTimes(0) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config.service.ts new file mode 100644 index 0000000000..100cee22bc --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-config/system-config.service.ts @@ -0,0 +1,46 @@ +import type { PluginsUpdateBody } from '@cpn-console/shared' +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { BadRequestException, Injectable } from '@nestjs/common' + +@Injectable() +export class SystemConfigService { + constructor(private readonly prisma: PrismaService) {} + + async list() { + const globalConfig = await this.prisma.adminPlugin.findMany({ + select: { key: true, value: true, pluginName: true }, + }) + + return Object.values(servicesInfos).map(({ name, title, imgSrc, description }) => { + const manifest = populatePluginManifests({ + data: { global: globalConfig }, + permissionTarget: 'admin', + pluginName: name, + select: { global: true, project: false }, + }) + return { imgSrc, title, name, manifest: manifest.global ?? [], description } + }).filter(plugin => plugin.manifest.length > 0) + } + + async update(body: PluginsUpdateBody) { + const parsedData = editStrippers.global.safeParse(body) + if (!parsedData.success) { + throw new BadRequestException(parsedData.error.message) + } + + const records = Object.entries(parsedData.data) + .flatMap(([pluginName, values]: [string, Record]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value: String(value) }))) + + await this.prisma.$transaction(async (tx) => { + for (const { pluginName, key, value } of records) { + await tx.adminPlugin.upsert({ + create: { pluginName, key, value }, + update: { key, value, pluginName }, + where: { pluginName_key: { pluginName, key } }, + }) + } + }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts new file mode 100644 index 0000000000..bdc5e91f25 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts @@ -0,0 +1,15 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +export function makeSystemSetting() { + return { + key: faker.string.alphanumeric(), + value: faker.string.alphanumeric(), + } satisfies SystemSetting +} + +export function makeSystemSettings() { + return faker.helpers.multiple(() => makeSystemSetting(), { + count: faker.number.int({ min: 1, max: 10 }), + }) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts new file mode 100644 index 0000000000..9644dc97f0 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts @@ -0,0 +1,24 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { SystemSettingSchema } from '@cpn-console/shared' +import { Body, Controller, Get, Inject, Put, Query } from '@nestjs/common' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { SystemSettingsService } from './system-settings.service' + +@Controller('api/v1/system/settings') +export class SystemSettingsController { + constructor(@Inject(SystemSettingsService) private readonly service: SystemSettingsService) {} + + @Get() + async list( + @Query() query: string, + ) { + return this.service.list(query) + } + + @Put(':key') + async upsert( + @Body(new ZodValidationPipe(SystemSettingSchema)) data: SystemSetting, + ) { + return this.service.upsert(data) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts new file mode 100644 index 0000000000..704b575e14 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { SystemSettingsController } from './system-settings.controller' +import { SystemSettingsService } from './system-settings.service' + +@Module({ + imports: [DatabaseModule], + controllers: [SystemSettingsController], + providers: [SystemSettingsService], + exports: [SystemSettingsService], +}) +export class SystemSettingsModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts new file mode 100644 index 0000000000..ae9ec6e161 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts @@ -0,0 +1,84 @@ +import type { TestingModule } from '@nestjs/testing' +import type { PrismaClient } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeSystemSetting, makeSystemSettings } from './system-settings-testing.utils' +import { SystemSettingsService } from './system-settings.service' + +describe('systemSettingsService', () => { + let module: TestingModule + let service: SystemSettingsService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + SystemSettingsService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(SystemSettingsService) + }) + + describe('list', () => { + it('should return all settings', async () => { + const systemSettings = makeSystemSettings() + + prisma.systemSetting.findMany.mockResolvedValue(systemSettings) + + const result = await service.list() + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: undefined } }) + expect(result).toEqual(systemSettings) + }) + + it('should return empty array if no settings are found', async () => { + prisma.systemSetting.findMany.mockResolvedValue([]) + + const result = await service.list() + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: undefined } }) + expect(result).toEqual([]) + }) + + it('should return setting by key', async () => { + const systemSettings = makeSystemSettings() + const systemSetting = faker.helpers.arrayElement(systemSettings) + prisma.systemSetting.findMany.mockResolvedValue([systemSetting]) + + const result = await service.list(systemSetting.key) + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: systemSetting.key } }) + expect(result).toEqual([systemSetting]) + }) + + it('should return empty array if key is not found', async () => { + const key = faker.string.alphanumeric(10) + prisma.systemSetting.findMany.mockResolvedValue([]) + + const result = await service.list(key) + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key } }) + expect(result).toEqual([]) + }) + }) + + describe('upsert', () => { + it('should update setting if it exists', async () => { + const systemSetting = makeSystemSetting() + prisma.systemSetting.upsert.mockResolvedValue(systemSetting) + + const result = await service.upsert(systemSetting) + + expect(result.key).toBe(systemSetting.key) + expect(result.value).toBe(systemSetting.value) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts new file mode 100644 index 0000000000..06c5e72603 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts @@ -0,0 +1,20 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +@Injectable() +export class SystemSettingsService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(key?: string): Promise { + return this.prisma.systemSetting.findMany({ where: { key } }) + } + + async upsert(data: SystemSetting): Promise { + return this.prisma.systemSetting.upsert({ + create: data, + update: data, + where: { key: data.key }, + }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts new file mode 100644 index 0000000000..d6cce4272f --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts @@ -0,0 +1,109 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from './vault-client.service' +import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { VAULT_CONFIG } from './vault.module-definition' + +const vaultUrl = 'https://vault.internal' + +const server = setupServer( + http.post(`${vaultUrl}/v1/auth/token/create`, () => { + return HttpResponse.json({ auth: { client_token: 'token' } }) + }), + http.get(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ data: { data: { secret: 'value' }, metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 } } }) + }), + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({}) + }), + http.delete(`${vaultUrl}/v1/kv/metadata/:path`, () => { + return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) + }), +) + +describe('vault', () => { + let service: VaultClientService + + beforeAll(() => server.listen()) + beforeEach(async () => { + const config = mockDeep({ + token: 'token', + url: vaultUrl, + internalUrl: vaultUrl, + kvName: 'kv', + internalOrPublicUrl: vaultUrl, + }) + const baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + + const module = await Test.createTestingModule({ + providers: [ + VaultClientService, + VaultHttpClientService, + { provide: VAULT_CONFIG, useValue: config }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(VaultClientService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + describe('read', () => { + it('should read secret', async () => { + const result = await service.read('path') + expect(result).toEqual({ + data: { secret: 'value' }, + metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 }, + }) + }) + + it('should throw if 404', async () => { + server.use( + http.get(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.read('path')).rejects.toBeInstanceOf(VaultError) + await expect(service.read('path')).rejects.toMatchObject({ kind: 'NotFound', status: HttpStatus.NOT_FOUND }) + }) + }) + + describe('write', () => { + it('should write secret', async () => { + await expect(service.write({ secret: 'value' }, 'path')).resolves.toBeUndefined() + }) + + it('should expose reasons on error', async () => { + server.use( + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ errors: ['No secret engine mount at test-project/'] }, { status: HttpStatus.BAD_REQUEST }) + }), + ) + + await expect(service.write({ secret: 'value' }, 'path')).rejects.toBeInstanceOf(VaultError) + await expect(service.write({ secret: 'value' }, 'path')).rejects.toMatchObject({ + kind: 'HttpError', + status: HttpStatus.BAD_REQUEST, + reasons: ['No secret engine mount at test-project/'], + }) + await expect(service.write({ secret: 'value' }, 'path')).rejects.toThrow('Request failed') + }) + }) + + describe('delete', () => { + it('should delete secret', async () => { + await expect(service.delete('path')).resolves.toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-client.service.ts new file mode 100644 index 0000000000..dd07abebbd --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -0,0 +1,410 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { VAULT_CONFIG } from './vault.module-definition' +import { generateGitlabMirrorCredPath, generateSonarqubeCredPath, generateTechReadOnlyCredPath } from './vault.utils' + +export interface VaultSysPoliciesAclUpsertRequest { + policy: string +} + +export interface VaultSysMountCreateRequest { + type: string + config: { + force_no_cache: boolean + } + options: { + version: number + } +} + +export interface VaultSysMountTuneRequest { + options: { + version: number + } +} + +export interface VaultAuthApproleRoleUpsertRequest { + secret_id_num_uses: string + secret_id_ttl: string + token_max_ttl: string + token_num_uses: string + token_ttl: string + token_type: string + token_policies: string[] +} + +export interface VaultIdentityGroupUpsertRequest { + name: string + type: string + policies: string[] +} + +export interface VaultIdentityGroupAliasCreateRequest { + name: string + mount_accessor: string + canonical_id: string +} + +export interface VaultAuthMethod { + accessor: string + type: string + description?: string +} + +export interface VaultSysAuthResponse { + data: Record +} + +export interface VaultIdentityGroupResponse { + data: { + id: string + name: string + alias?: { + id?: string + name?: string + } + } +} + +export interface SonarqubeUserSecret { + SONAR_USERNAME: string + SONAR_PASSWORD: string + SONAR_TOKEN: string +} + +export interface VaultMetadata { + created_time: string + custom_metadata: Record | null + deletion_time: string + destroyed: boolean + version: number +} + +export interface VaultSecret { + data: T + metadata: VaultMetadata +} + +export interface VaultResponse { + data: VaultSecret +} + +export interface VaultListResponse { + data: { + keys: string[] + } +} + +export interface VaultRoleIdResponse { + data: { + role_id: string + } +} + +export interface VaultSecretIdResponse { + data: { + secret_id: string + } +} + +@Injectable() +export class VaultClientService { + private readonly logger = new Logger(VaultClientService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultHttpClientService) private readonly http: VaultHttpClientService, + ) { + } + + @StartActiveSpan() + async getKvData(kvName: string, path: string): Promise> { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Reading Vault KV data (kvName=${kvName}, path=${path})`) + const response = await this.http.fetch>(`${kvName}/data/${path}`) + if (!response?.data) { + throw new VaultError('InvalidResponse', 'Missing "data" field', { method: 'GET', path: `${kvName}/data/${path}` }) + } + return response.data + } + + @StartActiveSpan() + async upsertKvData(kvName: string, path: string, body: { data: T }): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Writing Vault KV data (kvName=${kvName}, path=${path})`) + await this.http.fetch(`${kvName}/data/${path}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async read(path: string): Promise> { + this.logger.debug(`Reading Vault KV secret at ${path}`) + return await this.getKvData(this.vaultConfig.kvName, path) + } + + @StartActiveSpan() + async write(data: T, path: string): Promise { + this.logger.debug(`Writing Vault KV secret at ${path}`) + await this.upsertKvData(this.vaultConfig.kvName, path, { data }) + } + + @StartActiveSpan() + async delete(path: string): Promise { + this.logger.debug(`Deleting Vault KV secret at ${path}`) + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.path', path) + return await this.deleteKvMetadata(this.vaultConfig.kvName, path) + } + + @StartActiveSpan() + async readGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Reading Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + return await this.read(vaultCredsPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeGitlabMirrorCreds(projectSlug: string, repoName: string, data: Record): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Writing Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + await this.write(data, vaultCredsPath) + } + + @StartActiveSpan() + async deleteGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Deleting Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + await this.delete(vaultCredsPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + }) + } + + @StartActiveSpan() + async readTechnReadOnlyCreds(projectSlug: string): Promise { + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + return await this.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeTechReadOnlyCreds(projectSlug: string, creds: Record): Promise { + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + await this.write(creds, vaultPath) + } + + @StartActiveSpan() + async readSonarqubeUser(projectSlug: string): Promise | null> { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Reading Vault SonarQube user credentials (projectSlug=${projectSlug})`) + return await this.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeSonarqubeUser(projectSlug: string, secret: SonarqubeUserSecret): Promise { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Writing Vault SonarQube user credentials (projectSlug=${projectSlug})`) + await this.write(secret, vaultPath) + } + + @StartActiveSpan() + async deleteSonarqubeUser(projectSlug: string): Promise { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Deleting Vault SonarQube user credentials (projectSlug=${projectSlug})`) + await this.delete(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + }) + } + + @StartActiveSpan() + async writeMirrorTriggerToken(secret: Record): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.path', 'GITLAB') + await this.write(secret, 'GITLAB') + } + + @StartActiveSpan() + async deleteKvMetadata(kvName: string, path: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + try { + await this.http.fetch(`${kvName}/metadata/${path}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + } + + @StartActiveSpan() + async listKvMetadata(kvName: string, path: string): Promise { + try { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Listing Vault KV metadata (kvName=${kvName}, path=${path})`) + const response = await this.http.fetch(`${kvName}/metadata/${path}`, { method: 'LIST' }) + if (!response?.data?.keys) { + throw new VaultError('InvalidResponse', 'Missing "data.keys" field', { method: 'LIST', path: `${kvName}/metadata/${path}` }) + } + return response.data.keys + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return [] + throw error + } + } + + @StartActiveSpan() + async upsertSysPoliciesAcl(policyName: string, body: VaultSysPoliciesAclUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault ACL policy ${policyName}`) + await this.http.fetch(`sys/policies/acl/${policyName}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSysPoliciesAcl(policyName: string): Promise { + this.logger.verbose(`Deleting Vault ACL policy ${policyName}`) + await this.http.fetch(`sys/policies/acl/${policyName}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async createSysMount(name: string, body: VaultSysMountCreateRequest): Promise { + this.logger.verbose(`Creating Vault mount ${name} (version=${body.options.version})`) + await this.http.fetch(`sys/mounts/${name}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async tuneSysMount(name: string, body: VaultSysMountTuneRequest): Promise { + this.logger.verbose(`Tuning Vault mount ${name} (version=${body.options.version})`) + await this.http.fetch(`sys/mounts/${name}/tune`, { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSysMounts(name: string): Promise { + this.logger.verbose(`Deleting Vault mount ${name}`) + await this.http.fetch(`sys/mounts/${name}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async upsertAuthApproleRole(roleName: string, body: VaultAuthApproleRoleUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault AppRole ${roleName} (policies=${body.token_policies.length})`) + await this.http.fetch(`auth/approle/role/${roleName}`, { + method: 'POST', + body, + }) + } + + @StartActiveSpan() + async deleteAuthApproleRole(roleName: string): Promise { + this.logger.verbose(`Deleting Vault AppRole ${roleName}`) + await this.http.fetch(`auth/approle/role/${roleName}`, { method: 'DELETE' }) + } + + async getAuthApproleRoleRoleId(roleName: string) { + const path = `auth/approle/role/${roleName}/role-id` + this.logger.verbose(`Reading Vault AppRole role-id for ${roleName}`) + const response = await this.http.fetch(path) + const roleId = response?.data?.role_id + if (!roleId) { + throw new VaultError('InvalidResponse', `Vault role-id not found for role ${roleName}`, { method: 'GET', path }) + } + return roleId + } + + @StartActiveSpan() + async createAuthApproleRoleSecretId(roleName: string) { + const path = `auth/approle/role/${roleName}/secret-id` + this.logger.verbose(`Creating Vault AppRole secret-id for ${roleName}`) + const response = await this.http.fetch(path, { method: 'POST' }) + const secretId = response?.data?.secret_id + if (!secretId) { + throw new VaultError('InvalidResponse', `Vault secret-id not generated for role ${roleName}`, { method: 'POST', path }) + } + return secretId + } + + async getSysAuth(): Promise> { + this.logger.verbose('Listing Vault auth methods') + const response = await this.http.fetch('sys/auth') + return response?.data ?? {} + } + + @StartActiveSpan() + async upsertIdentityGroupName(groupName: string, body: VaultIdentityGroupUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault identity group ${groupName} (policies=${body.policies.length})`) + await this.http.fetch(`identity/group/name/${groupName}`, { + method: 'POST', + body, + }) + } + + @StartActiveSpan() + async getIdentityGroupName(groupName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.name', groupName) + const path = `identity/group/name/${groupName}` + const response = await this.http.fetch(path) + if (!response) throw new VaultError('InvalidResponse', 'Empty response', { method: 'GET', path }) + return response + } + + @StartActiveSpan() + async deleteIdentityGroupName(groupName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.name', groupName) + this.logger.verbose(`Deleting Vault identity group ${groupName}`) + await this.http.fetch(`identity/group/name/${groupName}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async createIdentityGroupAlias(body: VaultIdentityGroupAliasCreateRequest): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.alias', body.name) + this.logger.verbose(`Creating Vault identity group alias (aliasName=${body.name}, canonicalId=${body.canonical_id})`) + await this.http.fetch('identity/group-alias', { method: 'POST', body }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts new file mode 100644 index 0000000000..dfb48d9a71 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts @@ -0,0 +1,84 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + environments: { + select: { + id: true, + clusterId: true, + cpu: true, + memory: true, + autosync: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export const zoneSelect = { + id: true, + slug: true, + clusters: { + select: { + projects: { + select: { + id: true, + }, + }, + }, + }, +} satisfies Prisma.ZoneSelect + +export type ZoneWithDetails = Prisma.ZoneGetPayload<{ + select: typeof zoneSelect +}> + +@Injectable() +export class VaultDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ where: { id }, select: projectSelect }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } + + async getAllZones(): Promise { + return this.prisma.zone.findMany({ + select: zoneSelect, + }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-health.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-health.service.ts new file mode 100644 index 0000000000..daddabd5b4 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-health.service.ts @@ -0,0 +1,23 @@ +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { VAULT_CONFIG } from './vault.module-definition' + +@Injectable() +export class VaultHealthService { + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.vaultConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts new file mode 100644 index 0000000000..2a80532efe --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts @@ -0,0 +1,135 @@ +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus, Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VAULT_CONFIG } from './vault.module-definition' + +export interface VaultFetchOptions { + method?: string + body?: unknown +} + +export type VaultErrorKind + = | 'NotConfigured' + | 'NotFound' + | 'HttpError' + | 'InvalidResponse' + | 'ParseError' + | 'Unexpected' + +export class VaultError extends Error { + readonly kind: VaultErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + readonly reasons?: string[] + + constructor( + kind: VaultErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string, reasons?: string[] } = {}, + ) { + super(message) + this.name = 'VaultError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + this.reasons = details.reasons + } +} + +@Injectable() +export class VaultHttpClientService { + private readonly logger = new Logger(VaultHttpClientService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + ) {} + + @StartActiveSpan() + async fetch( + path: string, + options: VaultFetchOptions = {}, + ): Promise { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('vault.method', method) + span?.setAttribute('vault.path', path) + + this.logger.verbose(`Starting Vault request: ${method} ${path}`) + this.logger.debug(`Vault request: ${method} ${path}`) + const request = this.createRequest(path, method, options.body) + const response = await fetch(request).catch((error) => { + if (error instanceof Error) { + this.logger.error(`Vault request failed: ${method} ${path}: ${error.message}`, error.stack) + } else { + this.logger.error(`Vault request failed: ${method} ${path}: ${String(error)}`) + } + throw new VaultError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('vault.http.status', response.status) + this.logger.debug(`Vault response: ${method} ${path} status=${response.status}`) + + const parsed = await this.handleResponse(response, method, path) + this.logger.verbose(`Completed Vault request: ${method} ${path} status=${response.status} outcome=${parsed === null ? 'no-content' : 'ok'}`) + return parsed + } + + private get apiBaseUrl() { + return new URL('v1/', this.vaultConfig.internalOrPublicUrl).toString() + } + + private createRequest(path: string, method: string, body?: unknown): Request { + const url = new URL(path, this.apiBaseUrl).toString() + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Vault-Token': this.vaultConfig.token, + } + + return new Request(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }) + } + + private async handleResponse(response: Response, method: string, path: string): Promise { + if (response.status === HttpStatus.NO_CONTENT) return null + + if (!response.ok) { + await this.throwForStatus(response, method, path) + } + + return await response.json() + } + + private async throwForStatus(response: Response, method: string, path: string): Promise { + const responseBody = await response.json() + const vaultErrorBody = z.object({ errors: z.array(z.string()) }).safeParse(responseBody) + const reasons = vaultErrorBody.success ? vaultErrorBody.data.errors : undefined + const reasonsPart = reasons?.length ? ` reasons=${reasons.join('; ')}` : '' + this.logger.warn(`Vault request returned error: ${method} ${path} status=${response.status} statusText=${response.statusText}${reasonsPart}`) + + if (response.status === 404) { + throw new VaultError('NotFound', 'Not Found', { + status: 404, + method, + path, + statusText: response.statusText, + reasons, + }) + } + + throw new VaultError('HttpError', 'Request failed', { + status: response.status, + method, + path, + statusText: response.statusText, + reasons, + }) + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts new file mode 100644 index 0000000000..a1d5e06eea --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { VaultConfig } from './vault.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { VaultPluginService } from './vault-plugin.service' +import { VAULT_CONFIG } from './vault.module-definition' + +describe('vaultPluginService', () => { + let service: VaultPluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://vault.public/', + internalUrl: 'https://vault.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + VaultPluginService, + { provide: VAULT_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(VaultPluginService) + }) + + it('returns the public Vault url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://vault.public/ui/vault/secrets/dulei') + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts new file mode 100644 index 0000000000..0095ac34cf --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts @@ -0,0 +1,21 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable } from '@nestjs/common' +import { VAULT_CONFIG } from './vault.module-definition' + +@Injectable() +export class VaultPluginService { + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'vault', + to: ({ project }) => new URL(`ui/vault/secrets/${project.slug}`, this.vaultConfig.url).toString(), + title: 'Vault', + imgSrc: '/img/vault.svg', + description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', + } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts new file mode 100644 index 0000000000..2397b97bdc --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts @@ -0,0 +1,43 @@ +import type { VaultSecret } from './vault-client.service' +import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + name: faker.company.name(), + description: faker.company.buzzPhrase(), + environments: [], + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + +export function makeZoneWithDetails(overrides: Partial = {}): ZoneWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-zone-${faker.string.uuid()}`), + clusters: [], + ...overrides, + } satisfies ZoneWithDetails +} + +export function makeVaultSecret(overrides: Partial = {}): VaultSecret { + return { + data: {}, + metadata: makeVaultSecretMetadata(), + ...overrides, + } satisfies VaultSecret +} + +export function makeVaultSecretMetadata(overrides: Partial = {}): VaultSecret['metadata'] { + return { + created_time: faker.date.soon().toISOString(), + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + ...overrides, + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.constants.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.constants.ts new file mode 100644 index 0000000000..22adfa3275 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.constants.ts @@ -0,0 +1,34 @@ +// Plugin name used to identify the Vault integration +export const PLUGIN_NAME = 'vault' + +// Platform-level policy names for RBAC +export const PLATFORM_ADMIN_POLICY_NAME = 'platform--admin' +export const PLATFORM_READONLY_POLICY_NAME = 'platform--readonly' +export const PLATFORM_SECURITY_POLICY_NAME = 'platform--security' + +// Console group names for user role assignment +export const CONSOLE_ADMIN_GROUP_NAME = 'console-admin' +export const CONSOLE_READONLY_GROUP_NAME = 'console-readonly' +export const CONSOLE_SECURITY_GROUP_NAME = 'console-security' + +// Default LDAP/AD group paths for console access +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly' +export const DEFAULT_SECURITY_GROUP_PATH = '/console/security' + +// Default suffixes for project-scoped group paths +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly' +export const DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX = '/console/security' + +// Plugin configuration keys for custom group paths +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const AUDITOR_GROUP_PATH_PLUGIN_KEY = 'auditorGroupPath' +export const SECURITY_GROUP_PATH_PLUGIN_KEY = 'securityGroupPath' +export const PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReporterGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDeveloperGroupPathSuffix' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevopsGroupPathSuffix' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectMaintainerGroupPathSuffix' +export const PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectSecurityGroupPathSuffix' diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.module-definition.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.module-definition.ts new file mode 100644 index 0000000000..d0f1b425cf --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.module-definition.ts @@ -0,0 +1,17 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface VaultConfig { + token?: string + url: string + internalUrl: string + kvName: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface VaultModuleOptions extends VaultConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('vault') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as VAULT_CONFIG } diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.module.ts new file mode 100644 index 0000000000..367a1527a2 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { VaultHealthService } from './vault-health.service' +import { VaultHttpClientService } from './vault-http-client.service' +import { VaultPluginService } from './vault-plugin.service' +import { ConfigurableModuleClass } from './vault.module-definition' +import { VaultService } from './vault.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + providers: [ + VaultHealthService, + VaultHttpClientService, + VaultClientService, + VaultPluginService, + VaultService, + VaultDatastoreService, + ], + exports: [VaultClientService, VaultHealthService, VaultPluginService, VaultService], +}) +export class VaultModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.service.spec.ts new file mode 100644 index 0000000000..d46cb7b75c --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -0,0 +1,153 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { makeProjectWithDetails, makeVaultSecret, makeZoneWithDetails } from './vault-testing.utils' +import { VAULT_CONFIG } from './vault.module-definition' +import { VaultService } from './vault.service' + +const projectRoleGroupNameRegex = /^project-(.*)-(admin|devops|developer|readonly|security)$/ + +describe('vaultService', () => { + let service: VaultService + let datastore: DeepMockProxy + let client: DeepMockProxy + + beforeEach(async () => { + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + client = mockDeep({ + createSysMount: vi.fn().mockResolvedValue(undefined), + tuneSysMount: vi.fn().mockResolvedValue(undefined), + deleteSysMounts: vi.fn().mockResolvedValue(undefined), + upsertSysPoliciesAcl: vi.fn().mockResolvedValue(undefined), + deleteSysPoliciesAcl: vi.fn().mockResolvedValue(undefined), + upsertAuthApproleRole: vi.fn().mockResolvedValue(undefined), + deleteAuthApproleRole: vi.fn().mockResolvedValue(undefined), + getIdentityGroupName: vi.fn(async (groupName: string) => makeVaultSecret({ data: { id: 'gid', name: groupName } })), + deleteIdentityGroupName: vi.fn().mockResolvedValue(undefined), + getSysAuth: vi.fn().mockResolvedValue({ 'oidc/': { accessor: 'oidc-accessor', type: 'oidc' } }), + createIdentityGroupAlias: vi.fn().mockResolvedValue(undefined), + listKvMetadata: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + }) + + const vaultConfig = mockDeep({ kvName: 'kv' }) + const baseConfig = mockDeep({ projectsRootDir: 'forge' }) + + const module = await Test.createTestingModule({ + providers: [ + VaultService, + { provide: VaultClientService, useValue: client }, + { provide: VaultDatastoreService, useValue: datastore }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(VaultService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should reconcile on cron', async () => { + const projects = faker.helpers.multiple(() => makeProjectWithDetails()) + const zones = faker.helpers.multiple(() => makeZoneWithDetails()) + + datastore.getAllProjects.mockResolvedValue(projects) + datastore.getAllZones.mockResolvedValue(zones) + + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(datastore.getAllZones).toHaveBeenCalled() + expect(client.createSysMount).toHaveBeenCalledTimes(projects.length + zones.length) + projects.forEach((project) => { + expect(client.createSysMount).toHaveBeenCalledWith(project.slug, expect.any(Object)) + }) + zones.forEach((zone) => { + expect(client.createSysMount).toHaveBeenCalledWith(`zone-${zone.slug}`, expect.any(Object)) + }) + }) + + it('should upsert project on event', async () => { + const project = makeProjectWithDetails() + + client.getIdentityGroupName.mockImplementation(async (groupName: string) => { + const projectRoleMatch = groupName.match(projectRoleGroupNameRegex) + if (projectRoleMatch) { + const projectSlug = projectRoleMatch[1] + const role = projectRoleMatch[2] + return { data: { id: 'gid', name: groupName, alias: { name: `/${projectSlug}/console/${role}` } } } + } + + if (groupName === 'console-admin') return { data: { id: 'gid', name: groupName, alias: { name: '/console/admin' } } } + if (groupName === 'console-readonly') return { data: { id: 'gid', name: groupName, alias: { name: '/console/readonly' } } } + if (groupName === 'console-security') return { data: { id: 'gid', name: groupName, alias: { name: '/console/security' } } } + + return { data: { id: 'gid', name: groupName } } + }) + + await service.handleUpsert(project) + + expect(client.createSysMount).toHaveBeenCalledWith(project.slug, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`app--${project.slug}--admin`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`tech--${project.slug}--ro`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--devops`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--developer`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--readonly`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--security`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--admin', expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--readonly', expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--security', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-admin', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-readonly', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-security', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-admin`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-devops`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-developer`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`, expect.any(Object)) + expect(client.createIdentityGroupAlias).not.toHaveBeenCalled() + + const policyCalls = client.upsertSysPoliciesAcl.mock.calls as Array<[string, { policy: string }]> + expect(policyCalls.find(([name]) => name === `project--${project.slug}--developer`)?.[1].policy) + .toBe(`path "${project.slug}/data/*" { capabilities = ["list"] }`) + expect(policyCalls.find(([name]) => name === `project--${project.slug}--readonly`)?.[1].policy) + .toBe(`path "${project.slug}/data/*" { capabilities = ["list"] }`) + expect(policyCalls.find(([name]) => name === `project--${project.slug}--security`)?.[1].policy) + .toBe([ + `path "${project.slug}/metadata/*" { capabilities = ["list"] }`, + `path "transit/keys/${project.slug}/*" { capabilities = ["list"] }`, + ].join('\n')) + }) + + it('should delete project and destroy secrets on event', async () => { + const project = makeProjectWithDetails() + + await service.handleDelete(project) + + expect(client.deleteSysMounts).toHaveBeenCalledWith(project.slug) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`app--${project.slug}--admin`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`tech--${project.slug}--ro`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--devops`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--developer`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--readonly`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--security`) + expect(client.deleteAuthApproleRole).toHaveBeenCalledWith(project.slug) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-admin`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-devops`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-developer`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.service.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.service.ts new file mode 100644 index 0000000000..7128c24963 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -0,0 +1,594 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { VaultError } from './vault-http-client.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + AUDITOR_GROUP_PATH_PLUGIN_KEY, + CONSOLE_ADMIN_GROUP_NAME, + CONSOLE_READONLY_GROUP_NAME, + CONSOLE_SECURITY_GROUP_NAME, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX, + DEFAULT_SECURITY_GROUP_PATH, + PLATFORM_ADMIN_POLICY_NAME, + PLATFORM_READONLY_POLICY_NAME, + PLATFORM_SECURITY_POLICY_NAME, + PLUGIN_NAME, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + SECURITY_GROUP_PATH_PLUGIN_KEY, +} from './vault.constants' +import { VAULT_CONFIG } from './vault.module-definition' +import { generateProjectPath } from './vault.utils' + +type ProjectScope = 'admin' | 'devops' | 'developer' | 'readonly' | 'security' + +@Injectable() +export class VaultService { + private readonly logger = new Logger(VaultService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultDatastoreService) private readonly datastore: VaultDatastoreService, + @Inject(VaultClientService) private readonly client: VaultClientService, + ) { + this.logger.log('VaultService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('vault', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`Vault project sync completed for ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('vault', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await Promise.all([ + this.deleteProject(project), + this.deleteProjectSecrets(project.slug), + ]) + this.logger.log(`Vault project cleanup completed for ${project.slug}`) + } + + @OnEvent('zone.upsert') + async handleUpsertZone(zone: ZoneWithDetails): Promise> { + return capturePluginResult('vault', () => this.syncZone(zone)) + } + + @StartActiveSpan() + private async syncZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.log(`Handling a zone upsert event for ${zone.slug}`) + await this.ensureZone(zone) + this.logger.log(`Vault zone sync completed for ${zone.slug}`) + } + + @OnEvent('zone.delete') + async handleDeleteZone(zone: ZoneWithDetails): Promise> { + return capturePluginResult('vault', () => this.cleanupZone(zone)) + } + + @StartActiveSpan() + private async cleanupZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.log(`Handling a zone delete event for ${zone.slug}`) + await this.deleteZone(zone.slug) + this.logger.log(`Vault zone cleanup completed for ${zone.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Vault reconciliation') + const [projects, zones] = await Promise.all([ + this.datastore.getAllProjects(), + this.datastore.getAllZones(), + ]) + + span?.setAttributes({ + 'vault.projects.count': projects.length, + 'vault.zones.count': zones.length, + }) + this.logger.log(`Loaded state for Vault reconciliation (projects=${projects.length}, zones=${zones.length})`) + await Promise.all([ + this.ensureProjects(projects), + this.ensureZones(zones), + ]) + this.logger.log(`Vault reconciliation completed (projects=${projects.length} zones=${zones.length})`) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('vault.projects.count', projects.length) + this.logger.verbose(`Reconciling Vault projects (count=${projects.length})`) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling Vault project ${project.slug}`) + await this.upsertProject(project) + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return project.plugins?.find(p => p.pluginName === PLUGIN_NAME && p.key === key)?.value + } + + private async getAdminGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getAuditorGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, AUDITOR_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_AUDITOR_GROUP_PATH + } + + private async getSecurityGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, SECURITY_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_SECURITY_GROUP_PATH + } + + private async getProjectMaintainerGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX + } + + private async getProjectDevopsGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX + } + + private async getProjectDeveloperGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + } + + private async getProjectReporterGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX + } + + private async getProjectSecurityGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX + } + + @StartActiveSpan() + private async ensureZones(zones: ZoneWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('vault.zones.count', zones.length) + this.logger.verbose(`Reconciling Vault zones (count=${zones.length})`) + await Promise.all(zones.map(z => this.ensureZone(z))) + } + + @StartActiveSpan() + private async ensureZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.verbose(`Reconciling Vault zone ${zone.slug}`) + await this.upsertZone(zone.slug) + } + + private async upsertMount(kvName: string): Promise { + const createBody = { + type: 'kv', + config: { + force_no_cache: true, + }, + options: { + version: 2, + }, + } + const tuneBody = { + options: { + version: 2, + }, + } + try { + await this.client.createSysMount(kvName, createBody) + this.logger.log(`Created Vault mount ${kvName}`) + } catch (error) { + if (error instanceof VaultError && error.kind === 'HttpError' && error.status === 400) { + await this.client.tuneSysMount(kvName, tuneBody) + this.logger.log(`Vault mount ${kvName} already existed, so it was tuned to the expected settings`) + return + } + throw error + } + } + + private async deleteMount(kvName: string): Promise { + try { + await this.client.deleteSysMounts(kvName) + this.logger.log(`Deleted Vault mount ${kvName}`) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') { + this.logger.warn(`Vault mount ${kvName} was already missing`) + return + } + throw error + } + } + + @StartActiveSpan() + async upsertZone(zoneName: string): Promise { + const kvName = generateZoneName(zoneName) + const span = trace.getActiveSpan() + span?.setAttribute('zone.name', zoneName) + span?.setAttribute('vault.kv.name', kvName) + const policyName = generateZoneTechReadOnlyPolicyName(zoneName) + + await this.upsertMount(kvName) + await this.client.upsertSysPoliciesAcl(policyName, { + policy: `path "${kvName}/*" { capabilities = ["read"] }`, + }) + await this.client.upsertAuthApproleRole(kvName, generateApproleRoleBody([policyName])) + } + + @StartActiveSpan() + async deleteZone(zoneName: string): Promise { + const kvName = generateZoneName(zoneName) + const span = trace.getActiveSpan() + span?.setAttribute('zone.name', zoneName) + span?.setAttribute('vault.kv.name', kvName) + const policyName = generateZoneTechReadOnlyPolicyName(zoneName) + const roleName = kvName + + await this.deleteMount(kvName) + + const settled = await Promise.allSettled([ + this.client.deleteSysPoliciesAcl(policyName), + this.client.deleteAuthApproleRole(roleName), + ]) + + for (const result of settled) { + if (result.status !== 'rejected') continue + const error = result.reason + if (error instanceof VaultError && error.kind === 'NotFound') continue + throw error + } + } + + @StartActiveSpan() + async upsertProject(project: ProjectWithDetails): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('vault.kv.name', project.slug) + const appPolicyName = generateAppAdminPolicyName(project) + const techPolicyName = generateTechReadOnlyPolicyName(project) + const projectDevopsPolicyName = generateProjectPolicyName(project, 'devops') + const projectDeveloperPolicyName = generateProjectPolicyName(project, 'developer') + const projectReadOnlyPolicyName = generateProjectPolicyName(project, 'readonly') + const projectSecurityPolicyName = generateProjectPolicyName(project, 'security') + await this.upsertMount(project.slug) + + const [ + adminGroupPath, + auditorGroupPath, + securityGroupPath, + maintainerGroupPathSuffix, + devopsGroupPathSuffix, + developerGroupPathSuffix, + reporterGroupPathSuffix, + securityGroupPathSuffix, + ] = await Promise.all([ + this.getAdminGroupPath(project), + this.getAuditorGroupPath(project), + this.getSecurityGroupPath(project), + this.getProjectMaintainerGroupPathSuffix(project), + this.getProjectDevopsGroupPathSuffix(project), + this.getProjectDeveloperGroupPathSuffix(project), + this.getProjectReporterGroupPathSuffix(project), + this.getProjectSecurityGroupPathSuffix(project), + ]) + + const projectAdminGroupPaths = generateProjectRoleGroupPaths(project, maintainerGroupPathSuffix) + const projectDevopsGroupPaths = generateProjectRoleGroupPaths(project, devopsGroupPathSuffix) + const projectDeveloperGroupPaths = generateProjectRoleGroupPaths(project, developerGroupPathSuffix) + const projectReadOnlyGroupPaths = generateProjectRoleGroupPaths(project, reporterGroupPathSuffix) + const projectSecurityGroupPaths = generateProjectRoleGroupPaths(project, securityGroupPathSuffix) + + await Promise.all([ + this.ensureAppAdminPolicy(appPolicyName, project.slug), + this.ensureTechReadOnlyPolicy(techPolicyName, project.slug), + this.ensureProjectDevopsPolicy(projectDevopsPolicyName, project.slug), + this.ensureProjectReadOnlyPolicy(projectDeveloperPolicyName, project.slug), + this.ensureProjectReadOnlyPolicy(projectReadOnlyPolicyName, project.slug), + this.createProjectSecurityPolicy(projectSecurityPolicyName, project.slug), + this.ensurePlatformAdminPolicy(PLATFORM_ADMIN_POLICY_NAME), + this.ensurePlatformReadOnlyPolicy(PLATFORM_READONLY_POLICY_NAME), + this.ensurePlatformSecurityPolicy(PLATFORM_SECURITY_POLICY_NAME), + this.ensureIdentityGroup(CONSOLE_ADMIN_GROUP_NAME, [PLATFORM_ADMIN_POLICY_NAME], adminGroupPath), + this.ensureIdentityGroup(CONSOLE_READONLY_GROUP_NAME, [PLATFORM_READONLY_POLICY_NAME], auditorGroupPath), + this.ensureIdentityGroup(CONSOLE_SECURITY_GROUP_NAME, [PLATFORM_SECURITY_POLICY_NAME], securityGroupPath), + ...projectAdminGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'admin'), [appPolicyName], groupPath)), + ...projectDevopsGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'devops'), [projectDevopsPolicyName], groupPath)), + ...projectDeveloperGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'developer'), [projectDeveloperPolicyName], groupPath)), + ...projectReadOnlyGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'readonly'), [projectReadOnlyPolicyName], groupPath)), + ...projectSecurityGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'security'), [projectSecurityPolicyName], groupPath)), + this.client.upsertAuthApproleRole(project.slug, generateApproleRoleBody([techPolicyName, appPolicyName])), + ]) + } + + @StartActiveSpan() + async deleteProject(project: ProjectWithDetails): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('vault.kv.name', project.slug) + const appPolicyName = generateAppAdminPolicyName(project) + const techPolicyName = generateTechReadOnlyPolicyName(project) + const projectDevopsPolicyName = generateProjectPolicyName(project, 'devops') + const projectDeveloperPolicyName = generateProjectPolicyName(project, 'developer') + const projectReadOnlyPolicyName = generateProjectPolicyName(project, 'readonly') + const projectSecurityPolicyName = generateProjectPolicyName(project, 'security') + + await this.deleteMount(project.slug) + + const settled = await Promise.allSettled([ + this.client.deleteSysPoliciesAcl(appPolicyName), + this.client.deleteSysPoliciesAcl(techPolicyName), + this.client.deleteSysPoliciesAcl(projectDevopsPolicyName), + this.client.deleteSysPoliciesAcl(projectDeveloperPolicyName), + this.client.deleteSysPoliciesAcl(projectReadOnlyPolicyName), + this.client.deleteSysPoliciesAcl(projectSecurityPolicyName), + this.client.deleteAuthApproleRole(project.slug), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'admin')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'devops')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'developer')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'readonly')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'security')), + ]) + for (const result of settled) { + if (result.status !== 'rejected') continue + const error = result.reason + if (error instanceof VaultError && error.kind === 'NotFound') continue + throw error + } + } + + @StartActiveSpan() + private async ensureIdentityGroup(groupName: string, policies: string[], groupAliasName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'vault.group.name': groupName, + 'vault.policies.count': policies.length, + }) + await this.client.upsertIdentityGroupName(groupName, { + name: groupName, + type: 'external', + policies, + }) + + const groupResult = await this.client.getIdentityGroupName(groupName) + if (!groupResult.data?.id) { + throw new VaultError('InvalidResponse', `Vault group not found after upsert: ${groupName}`, { method: 'GET', path: `/v1/identity/group/name/${groupName}` }) + } + + const normalizedAliasName = groupAliasName.startsWith('/') ? groupAliasName : `/${groupAliasName}` + if (groupResult.data.alias?.name === normalizedAliasName) return + + const methods = await this.client.getSysAuth() + const oidc = methods['oidc/'] + if (!oidc?.accessor) { + throw new VaultError('InvalidResponse', 'Vault OIDC auth method not found (expected "oidc/")', { method: 'GET', path: '/v1/sys/auth' }) + } + try { + span?.setAttributes({ + 'vault.group.alias.name': normalizedAliasName, + 'vault.oidc.accessor': oidc.accessor, + }) + await this.client.createIdentityGroupAlias({ + name: normalizedAliasName, + mount_accessor: oidc.accessor, + canonical_id: groupResult.data.id, + }) + } catch (error) { + if (error instanceof VaultError && error.kind === 'HttpError' && error.status === 400) return + throw error + } + } + + async ensureAppAdminPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${projectSlug}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + }) + } + + async ensureProjectDevopsPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "${projectSlug}/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + `path "${projectSlug}/metadata/*" { capabilities = ["read", "list"] }`, + `path "${projectSlug}/delete/*" { capabilities = ["update"] }`, + `path "${projectSlug}/undelete/*" { capabilities = ["update"] }`, + `path "${projectSlug}/destroy/*" { capabilities = ["update"] }`, + ].join('\n'), + }) + } + + async ensureProjectReadOnlyPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${projectSlug}/data/*" { capabilities = ["list"] }`, + }) + } + + async createProjectSecurityPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "${projectSlug}/metadata/*" { capabilities = ["list"] }`, + `path "transit/keys/${projectSlug}/*" { capabilities = ["list"] }`, + ].join('\n'), + }) + } + + async ensurePlatformAdminPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "sys/*" { capabilities = ["create", "read", "update", "delete", "list", "sudo"] }`, + }) + } + + async ensurePlatformReadOnlyPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "sys/health" { capabilities = ["read"] }`, + `path "sys/mounts" { capabilities = ["read"] }`, + `path "sys/mounts/*" { capabilities = ["read"] }`, + `path "sys/auth" { capabilities = ["read"] }`, + `path "sys/auth/*" { capabilities = ["read"] }`, + `path "sys/policies/*" { capabilities = ["read", "list"] }`, + ].join('\n'), + }) + } + + async ensurePlatformSecurityPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "sys/audit" { capabilities = ["read", "list"] }`, + `path "sys/audit/*" { capabilities = ["read", "list"] }`, + `path "sys/policies/*" { capabilities = ["read", "list"] }`, + `path "sys/auth" { capabilities = ["read"] }`, + `path "sys/auth/*" { capabilities = ["read"] }`, + ].join('\n'), + }) + } + + async ensureTechReadOnlyPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${this.vaultConfig.kvName}/data/${projectSlug}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + }) + } + + async listProjectSecrets(projectSlug: string): Promise { + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) + return this.listRecursive(this.vaultConfig.kvName, projectPath, '') + } + + @StartActiveSpan() + async deleteProjectSecrets(projectSlug: string): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'vault.kv.name': this.vaultConfig.kvName, + }) + const secrets = await this.listProjectSecrets(projectSlug) + span?.setAttribute('vault.secrets.count', secrets.length) + + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) + await Promise.allSettled(secrets.map(async (relativePath) => { + const fullPath = `${projectPath}/${relativePath}` + try { + await this.client.delete(fullPath) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + })) + } + + private async listRecursive( + kvName: string, + basePath: string, + relativePath: string, + ): Promise { + const combined = relativePath.length === 0 ? basePath : `${basePath}/${relativePath}` + const keys = await this.client.listKvMetadata(kvName, combined) + if (keys.length === 0) return [] + + const results: string[] = [] + for (const key of keys) { + if (key.endsWith('/')) { + const nestedRel = relativePath.length === 0 ? key.slice(0, -1) : `${relativePath}/${key.slice(0, -1)}` + const nested = await this.listRecursive(kvName, basePath, nestedRel) + results.push(...nested) + } else { + results.push(relativePath.length === 0 ? key : `${relativePath}/${key}`) + } + } + return results + } +} + +function generateTechReadOnlyPolicyName(project: ProjectWithDetails) { + return `tech--${project.slug}--ro` +} + +function generateAppAdminPolicyName(project: ProjectWithDetails) { + return `app--${project.slug}--admin` +} + +function generateProjectPolicyName(project: ProjectWithDetails, scope: ProjectScope) { + return `project--${project.slug}--${scope}` +} + +function generateProjectGroupName(project: ProjectWithDetails, scope: ProjectScope) { + return `project-${project.slug}-${scope}` +} + +function generateProjectRoleGroupPaths(project: ProjectWithDetails, rawGroupPathSuffixes: string) { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${project.slug}${path}`) +} + +function generateZoneName(name: string) { + return `zone-${name}` +} + +function generateZoneTechReadOnlyPolicyName(zoneName: string) { + return `tech--${generateZoneName(zoneName)}--ro` +} + +function generateApproleRoleBody(policies: string[]) { + return { + secret_id_num_uses: '0', + secret_id_ttl: '0', + token_max_ttl: '0', + token_num_uses: '0', + token_ttl: '0', + token_type: 'batch', + token_policies: policies, + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.utils.ts new file mode 100644 index 0000000000..6e170bede2 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/vault/vault.utils.ts @@ -0,0 +1,15 @@ +export function generateProjectPath(projectRootDir: string, projectSlug: string) { + return `${projectRootDir}/${projectSlug}` +} + +export function generateGitlabMirrorCredPath(projectRootDir: string, projectSlug: string, repoName: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/${repoName}-mirror` +} + +export function generateTechReadOnlyCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/tech/GITLAB_MIRROR` +} + +export function generateSonarqubeCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/SONAR` +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/version/version.controller.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/version/version.controller.ts new file mode 100644 index 0000000000..7dc6a91019 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/version/version.controller.ts @@ -0,0 +1,15 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import { Controller, Get, Inject } from '@nestjs/common' +import { BASE_CONFIG } from '../infrastructure/config/base.config' + +@Controller('api/v1/version') +export class VersionController { + constructor( + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + @Get() + getVersion() { + return { version: this.baseConfig.appVersion } + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/modules/version/version.module.ts b/.jjconflict-base-0/apps/server-nestjs/src/modules/version/version.module.ts new file mode 100644 index 0000000000..c537037b5b --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/modules/version/version.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common' +import { VersionController } from './version.controller' + +@Module({ + controllers: [VersionController], +}) +export class VersionModule {} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql new file mode 100644 index 0000000000..f2f4e7b0b7 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql @@ -0,0 +1,151 @@ +-- CreateTable +CREATE TABLE "Environment" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Environment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Log" ( + "id" UUID NOT NULL, + "data" JSONB NOT NULL, + "action" TEXT NOT NULL DEFAULT '', + "userId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Log_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Organization" ( + "id" UUID NOT NULL, + "source" TEXT NOT NULL, + "name" TEXT NOT NULL, + "label" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Permission" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "environmentId" UUID NOT NULL, + "level" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Permission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "organizationId" UUID NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL, + "locked" BOOLEAN NOT NULL DEFAULT false, + "services" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Repository" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "internalRepoName" TEXT NOT NULL, + "externalRepoUrl" TEXT NOT NULL, + "externalUserName" TEXT, + "externalToken" TEXT, + "isInfra" BOOLEAN NOT NULL DEFAULT false, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Repository_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" UUID NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Role" ( + "userId" UUID NOT NULL, + "projectId" UUID NOT NULL, + "role" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Role_pkey" PRIMARY KEY ("userId","projectId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_id_key" ON "Organization"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_name_key" ON "Organization"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_label_key" ON "Organization"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_id_key" ON "Permission"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_userId_environmentId_key" ON "Permission"("userId", "environmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_id_key" ON "Project"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Role_userId_projectId_key" ON "Role"("userId", "projectId"); + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql new file mode 100644 index 0000000000..26e1ade3f2 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql @@ -0,0 +1,85 @@ +-- CreateEnum +CREATE TYPE "ClusterPrivacy" AS ENUM ('public', 'dedicated'); + +-- CreateTable +CREATE TABLE "Cluster" ( + "id" UUID NOT NULL, + "label" VARCHAR(50) NOT NULL, + "privacy" "ClusterPrivacy" NOT NULL DEFAULT 'dedicated', + "secretName" VARCHAR(50) NOT NULL, + "clusterResources" BOOLEAN NOT NULL DEFAULT false, + "kubeConfigId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cluster_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Kubeconfig" ( + "id" UUID NOT NULL, + "user" JSONB NOT NULL, + "cluster" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "parentClusterId" UUID, + + CONSTRAINT "Kubeconfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_ClusterToEnvironment" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateTable +CREATE TABLE "_ClusterToProject" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_id_key" ON "Cluster"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_label_key" ON "Cluster"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_secretName_key" ON "Cluster"("secretName"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_kubeConfigId_key" ON "Cluster"("kubeConfigId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_id_key" ON "Kubeconfig"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_parentClusterId_key" ON "Kubeconfig"("parentClusterId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToEnvironment_AB_unique" ON "_ClusterToEnvironment"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToEnvironment_B_index" ON "_ClusterToEnvironment"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToProject_AB_unique" ON "_ClusterToProject"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToProject_B_index" ON "_ClusterToProject"("B"); + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_kubeConfigId_fkey" FOREIGN KEY ("kubeConfigId") REFERENCES "Kubeconfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_B_fkey" FOREIGN KEY ("B") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_B_fkey" FOREIGN KEY ("B") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql new file mode 100644 index 0000000000..8f3fb5ff9f --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `parentClusterId` on the `Kubeconfig` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Kubeconfig_parentClusterId_key"; + +-- AlterTable +ALTER TABLE "Kubeconfig" DROP COLUMN "parentClusterId"; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql new file mode 100644 index 0000000000..4eb37edc53 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql @@ -0,0 +1,10 @@ +CREATE TYPE "ProjectStatus" AS ENUM ('initializing', 'created', 'failed', 'archived'); + +ALTER TABLE public."Project" ALTER COLUMN status TYPE "ProjectStatus" USING + case + when status = 'created' then 'created'::"ProjectStatus" + when status = 'failed' then 'failed'::"ProjectStatus" + when status = 'archived' then 'archived'::"ProjectStatus" + else 'initializing'::"ProjectStatus" + end; +ALTER TABLE public."Project" ALTER COLUMN status SET DEFAULT 'initializing'; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql new file mode 100644 index 0000000000..f402a0e3d4 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "infos" VARCHAR(200); diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql new file mode 100644 index 0000000000..f553e7880e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `externalToken` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "externalToken"; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql new file mode 100644 index 0000000000..394b650a51 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql @@ -0,0 +1,81 @@ +-- CreateEnum +CREATE TYPE "QuotaStageStatus" AS ENUM +('active', 'pendingDelete'); + +-- Create new tables +-- CreateTable +CREATE TABLE "Quota" +( + "id" UUID NOT NULL, + "memory" VARCHAR NOT NULL, + "cpu" REAL NOT NULL, + "name" VARCHAR NOT NULL, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "Quota_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Stage" +( + "id" UUID NOT NULL, + "name" VARCHAR NOT NULL, + + CONSTRAINT "Stage_pkey" PRIMARY KEY ("id") +); + +-- Associate Quotas and Stages +-- CreateTable +CREATE TABLE "QuotaStage" +( + "id" UUID NOT NULL, + "quotaId" UUID NOT NULL, + "stageId" UUID NOT NULL, + "status" "QuotaStageStatus" NOT NULL DEFAULT 'active', + + CONSTRAINT "QuotaStage_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "Quota_id_key" ON "Quota"("id"); +CREATE UNIQUE INDEX "Quota_name_key" ON "Quota"("name"); +CREATE UNIQUE INDEX "Stage_id_key" ON "Stage"("id"); +CREATE UNIQUE INDEX "Stage_name_key" ON "Stage"("name"); +CREATE UNIQUE INDEX "QuotaStage_id_key" ON "QuotaStage"("id"); +CREATE UNIQUE INDEX "QuotaStage_quotaId_stageId_key" ON "QuotaStage"("quotaId", "stageId"); +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Create default values for Quotas and Stages +-- Quota +INSERT INTO "Quota" + (id, cpu, memory, "name", "isPrivate") +VALUES + ('5a57b62f-2465-4fb6-a853-5a751d099199', 2, '4Gi', 'small', false), + ('08770663-3b76-4af6-8978-9f75eda4faa7', 4, '8Gi', 'medium', false), + ('b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 6, '12Gi', 'large', false), + ('97b851e8-9067-4a3d-a0e8-c3a6820c49be', 8, '16Gi', 'xlarge', false); + +-- Stage +INSERT INTO "Stage" + (id, "name") +VALUES + ('4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', 'dev'), + ('38fa869d-6267-441d-af7f-e0548fd06b7e', 'staging'), + ('d434310e-7850-4d59-b47f-0772edf50582', 'integration'), + ('9b3e9991-896d-4d90-bdc5-a34be8c06b8f', 'prod'); + +-- QuotaStage +INSERT INTO "QuotaStage" + (id, "quotaId", "stageId") +VALUES + ('0cb0c549-560e-4f26-8f4e-832dd722f68a', '5a57b62f-2465-4fb6-a853-5a751d099199', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('0530e9c9-b37d-4dec-93e6-1895f700e61c', '5a57b62f-2465-4fb6-a853-5a751d099199', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('8a99db49-b7b1-44bf-865d-5e709e8aa0fc', '5a57b62f-2465-4fb6-a853-5a751d099199', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('67561f00-d219-4ca6-b94a-3ee83f09d2d6', '5a57b62f-2465-4fb6-a853-5a751d099199', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('8b3c201e-7518-4254-a94a-16c404e46936', '08770663-3b76-4af6-8978-9f75eda4faa7', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('9157ae12-3e39-43f8-a24f-ae5d9c6b69b7', '08770663-3b76-4af6-8978-9f75eda4faa7', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('c733a1dd-c9fd-4def-b29e-df49ef7b6698', '08770663-3b76-4af6-8978-9f75eda4faa7', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('15a51f47-0ab2-4a94-a808-722639d8c092', '08770663-3b76-4af6-8978-9f75eda4faa7', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('cb66e80c-2304-472d-bc19-a411011674ca', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('59fb0e79-3a76-4b96-81d4-63f4caa98cfa', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('4174b22c-2bee-4f4a-9d85-da7b5463f214', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql new file mode 100644 index 0000000000..8c98a7f74e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql @@ -0,0 +1,35 @@ +-- Multiplication des environnements par clusteurs +ALTER TABLE "Environment" ADD COLUMN "clusterId" UUID; + +DO +$$ +DECLARE + perm record; + cte record; + env_uuid UUID; +BEGIN + FOR cte IN SELECT "B" AS environmentId, "A" AS "clusterId", "name", "projectId", status, "updatedAt", "createdAt" + FROM public."_ClusterToEnvironment", public."Environment" + WHERE public."Environment".id = "B" + LOOP + env_uuid := gen_random_uuid(); + INSERT INTO public."Environment" (id, "name", "projectId", "clusterId", status, "createdAt", "updatedAt") VALUES + (env_uuid, cte."name", cte."projectId", cte."clusterId", cte.status, cte."createdAt", cte."updatedAt"); + + FOR perm in SELECT * FROM public."Permission" WHERE "environmentId" = cte.environmentId + LOOP + INSERT INTO public."Permission" (id, "level", "createdAt", "updatedAt", "environmentId", "userId") VALUES + (gen_random_uuid(), perm."level", perm."createdAt", perm."updatedAt", env_uuid, perm."userId"); + END LOOP; + END LOOP; +END; +$$ +; +DELETE FROM public."Environment" WHERE "clusterId" is null; +ALTER TABLE public."Environment" ALTER COLUMN "clusterId" SET NOT NULL; +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_clusterId_fkey" FOREIGN KEY ("clusterId") REFERENCES "Cluster"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Delete old _ClusterToEnvironment +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_A_fkey"; +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_B_fkey"; +DROP TABLE "_ClusterToEnvironment"; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql new file mode 100644 index 0000000000..035cd1c855 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql @@ -0,0 +1,36 @@ +-- Associate cluster to Stages +-- CreateTable +CREATE TABLE "_ClusterToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToStage_AB_unique" ON "_ClusterToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToStage_B_index" ON "_ClusterToStage"("B"); + +DO +$$ +DECLARE + cluster record; + cte record; + env_uuid UUID; +BEGIN + FOR cluster IN SELECT id + FROM public."Cluster" + LOOP + INSERT INTO public."_ClusterToStage" ("A", "B") VALUES + (cluster.id, '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + (cluster.id, '38fa869d-6267-441d-af7f-e0548fd06b7e'), + (cluster.id, 'd434310e-7850-4d59-b47f-0772edf50582'), + (cluster.id, '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); + END LOOP; +END; +$$ diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql new file mode 100644 index 0000000000..43793bdb49 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaStageId" UUID; +UPDATE "Environment" SET "quotaStageId" = '8b3c201e-7518-4254-a94a-16c404e46936' WHERE "name" = 'dev'; +UPDATE "Environment" SET "quotaStageId" = '9157ae12-3e39-43f8-a24f-ae5d9c6b69b7' WHERE "name" = 'staging'; +UPDATE "Environment" SET "quotaStageId" = '4174b22c-2bee-4f4a-9d85-da7b5463f214' WHERE "name" = 'integration'; +UPDATE "Environment" SET "quotaStageId" = 'de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a' WHERE "name" = 'prod'; +ALTER TABLE "Environment" ALTER COLUMN "name" SET DATA TYPE VARCHAR(11); +ALTER TABLE "Environment" ALTER COLUMN "quotaStageId" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaStageId_fkey" FOREIGN KEY ("quotaStageId") REFERENCES "QuotaStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql new file mode 100644 index 0000000000..9af004b6a3 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- lock all projects +UPDATE public."Project" SET "locked"=true \ No newline at end of file diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql new file mode 100644 index 0000000000..d970d3965a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- set all projects to failed to avoid unlock them +UPDATE public."Project" SET "status" = 'failed' WHERE "status" != 'archived' \ No newline at end of file diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql new file mode 100644 index 0000000000..c387d9885c --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ADD COLUMN "requestId" VARCHAR(21); diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql new file mode 100644 index 0000000000..18e20262c8 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `status` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the column `status` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "status"; + +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "status"; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql new file mode 100644 index 0000000000..f784b21567 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql @@ -0,0 +1,46 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "zoneId" UUID; + +-- CreateTable +CREATE TABLE "Zone" +( + "id" UUID NOT NULL, + "slug" VARCHAR(10) NOT NULL, + "label" VARCHAR(50) NOT NULL, + "description" VARCHAR(200), + "createdAt" TIMESTAMP +(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP +(3) NOT NULL, + + CONSTRAINT "Zone_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_id_key" ON "Zone"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_slug_key" ON "Zone"("slug"); + +-- Create default zone +INSERT INTO "Zone" + (id, "slug", "label", "description", "updatedAt") +VALUES + ('a66c4230-eba6-41f1-aae5-bb1e4f90cce0', 'default', 'Zone Défaut', 'Zone par défaut, à changer', CURRENT_TIMESTAMP); + +-- Set default zoneId for current clusters +UPDATE "Cluster" +SET "zoneId" += 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0' +WHERE "zoneId" +IS NULL; + +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "zoneId" +SET +NOT NULL; + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") +ON DELETE RESTRICT ON +UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql new file mode 100644 index 0000000000..cb4f7ad968 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "ProjectPlugin" ( + "pluginName" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "AdminPlugin" ( + "pluginName" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectPlugin_projectId_pluginName_key_key" ON "ProjectPlugin"("projectId", "pluginName", "key"); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminPlugin_pluginName_key_key" ON "AdminPlugin"("pluginName", "key"); + +-- AddForeignKey +ALTER TABLE "ProjectPlugin" ADD CONSTRAINT "ProjectPlugin_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql new file mode 100644 index 0000000000..11672324f8 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql @@ -0,0 +1,19 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id, services FROM public."Project" LOOP + -- Extrait 'registry.id' de la colonne JSON 'services' + registry_id := (SELECT (project_row.services -> 'registry' ->> 'id')::TEXT); + -- Si 'registry.id' existe, insérer dans la table 'config' + IF registry_id IS NOT NULL THEN + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'registry', 'projectId', registry_id::TEXT); + END IF; + END LOOP; +END $$; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "services"; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql new file mode 100644 index 0000000000..9c7d5a8f8f --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "RoleList" AS ENUM ('owner', 'user'); + +-- AlterTable +ALTER TABLE public."Role" ALTER COLUMN "role" TYPE "RoleList" USING + case + when role = 'owner' then 'owner'::"RoleList" + else 'user'::"RoleList" + end; \ No newline at end of file diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql new file mode 100644 index 0000000000..45a4a5d1ea --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "ProjectClusterHistory" ( + "projectId" UUID NOT NULL, + "clusterId" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectClusterHistory_projectId_clusterId_key" ON "ProjectClusterHistory"("projectId", "clusterId"); diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql new file mode 100644 index 0000000000..2b1641a657 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql @@ -0,0 +1,11 @@ +DO $$ +DECLARE + env_row RECORD; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR env_row IN SELECT "projectId", "clusterId" FROM public."Environment" LOOP + INSERT INTO public."ProjectClusterHistory" ("projectId", "clusterId") + VALUES (env_row."projectId", env_row."clusterId") + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql new file mode 100644 index 0000000000..5e7ff03d45 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql @@ -0,0 +1,58 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaId" UUID, +ADD COLUMN "stageId" UUID; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "_QuotaToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_QuotaToStage_AB_unique" ON "_QuotaToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_QuotaToStage_B_index" ON "_QuotaToStage"("B"); + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +DO $$ +DECLARE + quota_stage_row RECORD; +BEGIN + FOR quota_stage_row IN SELECT * FROM public."QuotaStage" loop + UPDATE public."Environment" SET "stageId" = quota_stage_row."stageId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + UPDATE public."Environment" SET "quotaId" = quota_stage_row."quotaId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + insert into public."_QuotaToStage" values (quota_stage_row."quotaId", quota_stage_row."stageId"); + END LOOP; +END $$; + +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaStageId_fkey"; + +-- AlterTable +ALTER TABLE "Environment" ALTER COLUMN "quotaId" SET NOT NULL, +ALTER COLUMN "stageId" SET NOT NULL, +DROP COLUMN "quotaStageId"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_stageId_fkey"; + +-- DropTable +DROP TABLE "QuotaStage"; + +-- DropEnum +DROP TYPE "QuotaStageStatus"; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql new file mode 100644 index 0000000000..0036da8a12 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Made the column `description` on table `Project` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "description" SET NOT NULL, +ALTER COLUMN "description" SET DEFAULT ''; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql new file mode 100644 index 0000000000..ed6ae9b84e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql @@ -0,0 +1,198 @@ +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_environmentId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_environmentId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_userId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_userId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_projectId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_projectId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_userId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_userId_fkey"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectMembers') THEN + CREATE TABLE "ProjectMembers" ( + "projectId" UUID NOT NULL, + "userId" UUID NOT NULL, + "roleIds" TEXT[] + ); + END IF; +END $$; + +-- AlterTable +ALTER TABLE "Log" ADD COLUMN IF NOT EXISTS "projectId" UUID; + +INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt") +VALUES('04ac168a-2c4f-4816-9cce-af6c612e5912'::uuid, 'Anonymous', 'User', 'anon@user', '2023-07-03 14:46:56.770', '2023-07-03 14:46:56.770') +ON CONFLICT (id) DO NOTHING; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "everyonePerms" BIGINT NOT NULL DEFAULT 896, +ADD COLUMN IF NOT EXISTS "ownerId" UUID; + +DO $$ +DECLARE + role_row RECORD; +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR role_row IN SELECT "userId", "projectId", "role" FROM public."Role" LOOP + INSERT INTO public."ProjectMembers" ("userId", "projectId", "roleIds") VALUES (role_row."userId", role_row."projectId", '{}'); + IF role_row."role" = 'owner'::public."RoleList" THEN + UPDATE public."Project" + SET "ownerId"=role_row."userId" + WHERE id=role_row."projectId"::uuid; + END IF; + END LOOP; + END IF; +END $$; + +UPDATE public."Project" +SET "ownerId"='04ac168a-2c4f-4816-9cce-af6c612e5912' +WHERE "ownerId" IS NULL; + +ALTER TABLE public."Project" ALTER COLUMN "ownerId" SET NOT NULL; + +DELETE FROM public."ProjectMembers" pm +USING public."Project" p +WHERE pm."userId" = p."ownerId" +AND pm."projectId" = p."id"; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Permission') THEN + DROP TABLE "Permission"; + END IF; +END $$; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + DROP TABLE "Role"; + END IF; +END $$; + +-- DropEnum if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'RoleList') THEN + DROP TYPE "RoleList"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'AdminRole') THEN + CREATE TABLE "AdminRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "AdminRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectRole') THEN + CREATE TABLE "ProjectRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "projectId" UUID NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "ProjectRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_id_key') THEN + CREATE UNIQUE INDEX "AdminRole_id_key" ON "AdminRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_name_key') THEN + CREATE UNIQUE INDEX "AdminRole_name_key" ON "AdminRole"("name"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectMembers_projectId_userId_key') THEN + CREATE UNIQUE INDEX "ProjectMembers_projectId_userId_key" ON "ProjectMembers"("projectId", "userId"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectRole_id_key') THEN + CREATE UNIQUE INDEX "ProjectRole_id_key" ON "ProjectRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'Environment_projectId_name_key') THEN + CREATE UNIQUE INDEX "Environment_projectId_name_key" ON "Environment"("projectId", "name"); + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Log_projectId_fkey') THEN + ALTER TABLE "Log" ADD CONSTRAINT "Log_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Project_ownerId_fkey') THEN + ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_projectId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_userId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectRole_projectId_fkey') THEN + ALTER TABLE "ProjectRole" ADD CONSTRAINT "ProjectRole_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "adminRoleIds" TEXT[]; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql new file mode 100644 index 0000000000..c9b41827bf --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "AdminRole_name_key"; + +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "oidcGroup" TEXT; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql new file mode 100644 index 0000000000..265f262abb --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `oidcGroup` on table `AdminRole` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable + +UPDATE public."AdminRole" +SET "oidcGroup"='' +WHERE "oidcGroup" IS NULL; + +ALTER TABLE "AdminRole" ALTER COLUMN "oidcGroup" SET NOT NULL, +ALTER COLUMN "oidcGroup" SET DEFAULT ''; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql new file mode 100644 index 0000000000..4fc2768605 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "SystemSetting" +( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "SystemSetting_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemSetting_key_key" ON "SystemSetting"("key"); + +-- Create maintenance setting +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('maintenance', 'off'); diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql new file mode 100644 index 0000000000..95ab54869d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql @@ -0,0 +1,3 @@ +INSERT INTO public."AdminRole" +(id, "name", permissions, "position", "oidcGroup") +VALUES('76229c96-4716-45bc-99da-00498ec9018c'::uuid, 'Admin', 2, 0, '/admin'); \ No newline at end of file diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql new file mode 100644 index 0000000000..c116482186 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Made the column `externalUserName` on table `Repository` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +UPDATE "Repository" SET "externalUserName" = '' WHERE "externalUserName" IS NULL; + +ALTER TABLE "Repository" ALTER COLUMN "externalUserName" SET NOT NULL, +ALTER COLUMN "externalUserName" SET DEFAULT '', +ALTER COLUMN "externalRepoUrl" SET DEFAULT ''; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql new file mode 100644 index 0000000000..b0472cd806 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "TokenStatus" AS ENUM ('active', 'revoked'); + +-- CreateTable +CREATE TABLE "AdminToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "userId" UUID, + "expirationDate" TIMESTAMP(3), + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "AdminToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminToken_id_key" ON "AdminToken"("id"); + +-- AddForeignKey +ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql new file mode 100644 index 0000000000..47488b00c5 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "Log" DROP CONSTRAINT "Log_userId_fkey"; + +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "userId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql new file mode 100644 index 0000000000..18eca3eadf --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "requestId" SET DATA TYPE VARCHAR(36); diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql new file mode 100644 index 0000000000..74e0946f0a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Zone" ADD COLUMN "argocdUrl" TEXT NOT NULL DEFAULT 'https://example.com'; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql new file mode 100644 index 0000000000..41dac75359 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ProjectStatus" ADD VALUE 'warning'; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql new file mode 100644 index 0000000000..ef888d5e50 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql @@ -0,0 +1,12 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id FROM public."Project" WHERE status <> 'archived'::public."ProjectStatus" LOOP + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'nexus', 'activateMavenRepo', 'enabled') + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql new file mode 100644 index 0000000000..a57e5956c3 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql @@ -0,0 +1,12 @@ +-- CreateEnum +CREATE TYPE "UserType" AS ENUM ('human', 'bot', 'ghost'); + +-- AlterEnum +ALTER TYPE "TokenStatus" ADD VALUE 'inactive'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "type" "UserType" NOT NULL DEFAULT 'human'; +UPDATE "User" SET type = 'ghost' WHERE id = '04ac168a-2c4f-4816-9cce-af6c612e5912'; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "type" DROP DEFAULT; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql new file mode 100644 index 0000000000..71e15a312c --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql @@ -0,0 +1,84 @@ +-- CreateTable (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'PersonalAccessToken') THEN + CREATE TABLE "PersonalAccessToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "userId" UUID NOT NULL, + "expirationDate" TIMESTAMP(3) NOT NULL, + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'PersonalAccessToken_id_key') THEN + CREATE UNIQUE INDEX "PersonalAccessToken_id_key" ON "PersonalAccessToken"("id"); + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'PersonalAccessToken_userId_fkey') THEN + ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- Process AdminToken (idempotent) +DO $$ +DECLARE + admin_token record; + user_uuid UUID; +BEGIN + FOR admin_token IN SELECT "name", "id" + FROM public."AdminToken" + LOOP + -- Generate new UUID if user does not exist + user_uuid := COALESCE( + (SELECT id FROM public."User" WHERE email = concat(admin_token.name, '@bot.id')), + gen_random_uuid() + ); + + -- Insert user if not already exists + INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt", "type") + VALUES(user_uuid, 'Bot Admin', admin_token.name, concat(admin_token.name, '@bot.id'), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'bot') + ON CONFLICT (id) DO NOTHING; + + -- Update AdminToken with the new user ID + UPDATE public."AdminToken" SET "userId" = user_uuid WHERE id = admin_token.id; + END LOOP; +END $$; + +-- Alter AdminToken userId column to NOT NULL (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'AdminToken' AND column_name = 'userId' AND is_nullable = 'NO') THEN + ALTER TABLE public."AdminToken" ALTER COLUMN "userId" SET NOT NULL; + END IF; +END $$; + +-- DropForeignKey if exists (idempotent) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" DROP CONSTRAINT "AdminToken_userId_fkey"; + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql new file mode 100644 index 0000000000..521b2b10ac --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastLogin" TIMESTAMP(3); diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql new file mode 100644 index 0000000000..a7500833b9 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "slug" TEXT; + +UPDATE public."Project" p +SET slug = ( + SELECT concat(org.name, '-', subp.name) FROM public."Project" subp + LEFT JOIN public."Organization" org on org."id" = subp."organizationId" + WHERE subp.id = p.id +); + +ALTER TABLE public."Project" ALTER COLUMN "slug" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug"); diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql new file mode 100644 index 0000000000..b143cbeb9b --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "lastSuccessProvisionningVersion" TEXT; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql new file mode 100644 index 0000000000..7a88681901 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToProject_AB_unique"; + +-- AlterTable +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToStage_AB_unique"; + +-- AlterTable +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_QuotaToStage_AB_unique"; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql new file mode 100644 index 0000000000..21ce77b8d5 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "external" BOOLEAN NOT NULL DEFAULT false; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql new file mode 100644 index 0000000000..ac63cd639d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql @@ -0,0 +1,25 @@ +-- Vérifie les versions dans la table Project +DO $$ +DECLARE + project_id TEXT; + project_name TEXT; + last_version TEXT; +BEGIN + -- Boucle sur les projets non archivés + FOR project_id, project_name, last_version IN ( + SELECT id, name, "lastSuccessProvisionningVersion" + FROM "Project" + WHERE "status" != 'archived' + ) + LOOP + -- Vérifie si la version est NULL + IF last_version IS NULL THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version NULL.', project_name, project_id; + END IF; + + -- Vérifie si la version est inférieure à 8.23.0 selon SemVer + IF (string_to_array(last_version, '.')::int[] < ARRAY[8,23,0]) THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version (%), inférieure à 8.23.0.', project_name, project_id, last_version; + END IF; + END LOOP; +END $$; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql new file mode 100644 index 0000000000..54871c9017 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `organizationId` on the `Project` table. All the data in the column will be lost. + - You are about to drop the `Organization` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Project" DROP CONSTRAINT "Project_organizationId_fkey"; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "organizationId"; + +-- DropTable +DROP TABLE "Organization"; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql new file mode 100644 index 0000000000..68ca0df2fa --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "infos" SET DATA TYPE VARCHAR(1000); diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql new file mode 100644 index 0000000000..8364090d8d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql @@ -0,0 +1,44 @@ +-- AlterTable +ALTER TABLE "Environment" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; + +COMMENT ON COLUMN "Environment".cpu IS 'CPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".gpu IS 'GPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".memory IS 'Memory value as GigaBytes (1 and 0.01 are valid values)'; + +-- Use values from Quota. Memory is an extract of q.memory numeric value as it contains a unit (e.g. '2Gi'). +UPDATE "Environment" +SET cpu = q.cpu, memory = COALESCE(NULLIF(regexp_replace(q.memory, '\D', '','g'), ''), '0')::numeric +FROM "Quota" q +WHERE "quotaId" = q."id"; + +/* + Warnings: + + - You are about to drop the column `quotaId` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the `Quota` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_QuotaToStage` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_B_fkey"; + +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "quotaId", +ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- DropTable +DROP TABLE "Quota"; + +-- DropTable +DROP TABLE "_QuotaToStage"; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql new file mode 100644 index 0000000000..77f32b5ab5 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Cluster" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql new file mode 100644 index 0000000000..decca804aa --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Project" +ADD COLUMN "limitless" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "hprodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodMemory" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodMemory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql new file mode 100644 index 0000000000..95f3a689db --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql @@ -0,0 +1,4 @@ +-- Rename default zone +UPDATE "Zone" +SET ("label", "description") = ('DSO', 'Zone par défaut') +WHERE slug = 'default'; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql new file mode 100644 index 0000000000..aadb6cdba9 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Repository" ADD COLUMN "deployRevision" TEXT, +ADD COLUMN "deployPath" TEXT, +ADD COLUMN "helmValuesFiles" TEXT; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql new file mode 100644 index 0000000000..15cf56ab82 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql @@ -0,0 +1,12 @@ +-- Eliminate NULLs +UPDATE "Repository" SET "deployRevision" = '' WHERE "deployRevision" IS NULL; +UPDATE "Repository" SET "deployPath" = '' WHERE "deployPath" IS NULL; +UPDATE "Repository" SET "helmValuesFiles" = '' WHERE "helmValuesFiles" IS NULL; + +-- AlterTable +ALTER TABLE "Repository" ALTER COLUMN "deployRevision" SET NOT NULL, +ALTER COLUMN "deployRevision" SET DEFAULT '', +ALTER COLUMN "deployPath" SET NOT NULL, +ALTER COLUMN "deployPath" SET DEFAULT '', +ALTER COLUMN "helmValuesFiles" SET NOT NULL, +ALTER COLUMN "helmValuesFiles" SET DEFAULT ''; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql new file mode 100644 index 0000000000..1292495121 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "autosync" BOOLEAN NOT NULL DEFAULT true; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql new file mode 100644 index 0000000000..04c98df036 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "oidcGroup" TEXT NOT NULL DEFAULT ''; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql new file mode 100644 index 0000000000..40da41a75d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- Update AdminRole system roles +UPDATE "AdminRole" SET "type" = 'system' WHERE "name" IN ('Admin', 'Admin Locaux'); + +-- Update ProjectRole system roles +UPDATE "ProjectRole" SET "type" = 'system' WHERE "name" IN ('Administrateur', 'DevOps', 'Développeur', 'Lecture seule'); diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql new file mode 100644 index 0000000000..92c200d261 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql @@ -0,0 +1,26 @@ +-- Update existing Admin role to be system role 'Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + "oidcGroup" = '/admin', + "position" = 0 +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Lecture Seule Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '35848aa2-e881-4770-9844-0c5c3693e506'::uuid, + 'Lecture Seule Plateforme', + 1, -- Assuming 1n means bit 0 + 2, + '/readonly', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Lecture Seule Plateforme', + "type" = 'system', + "permissions" = 1, + "oidcGroup" = '/readonly'; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql new file mode 100644 index 0000000000..2a4afa8971 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql @@ -0,0 +1,28 @@ +-- Update existing Admin role to be system role 'Root Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Root Administrateur Plateforme' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Administrateur Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf'::uuid, + 'Administrateur Plateforme', + 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + 0, + '/console/admin', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, + "oidcGroup" = '/console/admin'; + +-- Update 'Lecture Seule Plateforme' system role +UPDATE "AdminRole" +SET + "oidcGroup" = '/console/readonly' +WHERE "id" = '35848aa2-e881-4770-9844-0c5c3693e506'::uuid; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql new file mode 100644 index 0000000000..f0cdb04873 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `external` on the `Cluster` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Cluster" DROP COLUMN "external"; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql new file mode 100644 index 0000000000..e07cad777b --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql @@ -0,0 +1,5 @@ +-- Enable legacy default permissions flag +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('refined-permissions', 'off'); diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql new file mode 100644 index 0000000000..efb6bc67d4 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql @@ -0,0 +1,3 @@ +-- Remove 'refined-permissions' system setting +DELETE FROM "SystemSetting" +WHERE "key" = 'refined-permissions'; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql new file mode 100644 index 0000000000..d019403c44 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql @@ -0,0 +1,15 @@ +-- Add global 'Tout le monde' role +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '034f589f-1750-4b15-bb34-4cd995e7fcaa'::uuid, + 'Tout le monde', + 25608, -- MANAGE_PROJECTS | LIST_PROJECTS | LIST_ZONES | LIST_STAGES + 0, + '', + 'global' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Tout le monde', + "type" = 'global', + "permissions" = 25608; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql new file mode 100644 index 0000000000..4fd0d80eb2 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql @@ -0,0 +1,25 @@ +-- Alter existing column to change the default value from 'custom' to 'managed' for AdminRole type +ALTER TABLE "AdminRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Alter existing column to change the default value from 'custom' to 'managed' for ProjectRole type +ALTER TABLE "ProjectRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Migrate system roles to managed roles +UPDATE "AdminRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate system roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate custom roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'custom'; + +-- Update existing Admin role to be an externally managed role +UPDATE "AdminRole" +SET "type" = 'external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql new file mode 100644 index 0000000000..d2691666bd --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql @@ -0,0 +1,15 @@ +-- Migrate well-known project roles to the new "system:"-prefixed type +UPDATE "ProjectRole" +SET "type" = 'system:managed' +WHERE + "type" = 'managed' + AND "oidcGroup" ~ '^/[^/]+/console/(admin|devops|developer|readonly)$'; + +-- Migrate well-known admin roles to the new "system:"-prefixed type +UPDATE "AdminRole" +SET "type" = 'system:managed' +WHERE id IN ( + '76229c96-4716-45bc-99da-00498ec9018c', -- Root Administrateur Plateforme + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', -- Administrateur Plateforme + '35848aa2-e881-4770-9844-0c5c3693e506' -- Lecture Seule Plateforme +); diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql new file mode 100644 index 0000000000..23593ba4f4 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "hprodCpu" DROP DEFAULT, +ALTER COLUMN "hprodGpu" DROP DEFAULT, +ALTER COLUMN "hprodMemory" DROP DEFAULT, +ALTER COLUMN "prodCpu" DROP DEFAULT, +ALTER COLUMN "prodGpu" DROP DEFAULT, +ALTER COLUMN "prodMemory" DROP DEFAULT; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql new file mode 100644 index 0000000000..7e3eefb25d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql @@ -0,0 +1,4 @@ +-- Set the Root Administrateur Plateforme admin role to external system-managed type +UPDATE "AdminRole" +SET "type" = 'system:external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql new file mode 100644 index 0000000000..ca81594339 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "DeploymentSourceType" AS ENUM ('git', 'oci'); + +-- CreateTable +CREATE TABLE "Deployment" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "name" VARCHAR(11) NOT NULL, + "autosync" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "environmentId" UUID NOT NULL, + + CONSTRAINT "Deployment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeploymentSource" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deploymentId" UUID NOT NULL, + "repositoryId" UUID NOT NULL, + "type" "DeploymentSourceType" NOT NULL, + "targetRevision" TEXT NOT NULL DEFAULT '', + "path" TEXT NOT NULL DEFAULT '', + "helmValuesFiles" TEXT NOT NULL DEFAULT '', + + CONSTRAINT "DeploymentSource_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Deployment_id_key" ON "Deployment"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentSource_id_key" ON "DeploymentSource"("id"); + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "Deployment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql new file mode 100644 index 0000000000..ad5a64d336 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_environmentId_fkey"; + +-- DropForeignKey +ALTER TABLE "DeploymentSource" DROP CONSTRAINT "DeploymentSource_repositoryId_fkey"; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/migration_lock.toml b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000..044d57cdb0 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/admin.prisma b/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/admin.prisma new file mode 100644 index 0000000000..c4ca38ccb1 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/admin.prisma @@ -0,0 +1,21 @@ +model AdminPlugin { + pluginName String + key String + value String + + @@unique([pluginName, key]) +} + +model AdminRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") +} + +model SystemSetting { + key String @id @unique + value String +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/project.prisma b/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/project.prisma new file mode 100644 index 0000000000..6bcc5ce949 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/project.prisma @@ -0,0 +1,144 @@ +model Deployment { + id String @id @unique @default(uuid()) @db.Uuid + projectId String @db.Uuid + name String @db.VarChar(11) + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + environmentId String @db.Uuid + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model DeploymentSource { + id String @id @unique @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deploymentId String @db.Uuid + repositoryId String @db.Uuid + type DeploymentSourceType + targetRevision String @default("") + path String @default("") + helmValuesFiles String @default("") + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) + deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) +} + +model Environment { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(11) + projectId String @db.Uuid + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clusterId String @db.Uuid + stageId String @db.Uuid + cluster Cluster @relation(fields: [clusterId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + stage Stage @relation(fields: [stageId], references: [id]) + deployments Deployment[] + + @@unique([projectId, name]) +} + +model Repository { + id String @id @default(uuid()) @db.Uuid + projectId String @db.Uuid + internalRepoName String + externalRepoUrl String @default("") + externalUserName String @default("") + isInfra Boolean @default(false) + isPrivate Boolean @default(false) + deployRevision String @default("") + deployPath String @default("") + helmValuesFiles String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model ProjectClusterHistory { + projectId String @db.Uuid + clusterId String @db.Uuid + + @@unique([projectId, clusterId]) +} + +model ProjectMembers { + projectId String @db.Uuid + userId String @db.Uuid + roleIds String[] + project Project @relation(fields: [projectId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@unique([projectId, userId]) +} + +model ProjectPlugin { + pluginName String + projectId String @db.Uuid + key String + value String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, pluginName, key]) +} + +model ProjectRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + projectId String @db.Uuid + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") + project Project @relation(fields: [projectId], references: [id]) +} + +model Project { + id String @id @unique @default(uuid()) @db.Uuid + name String + description String @default("") + status ProjectStatus @default(initializing) + locked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + everyonePerms BigInt @default(896) + ownerId String @db.Uuid + environments Environment[] + logs Log[] + owner User @relation(fields: [ownerId], references: [id]) + members ProjectMembers[] + plugins ProjectPlugin[] + roles ProjectRole[] + repositories Repository[] + clusters Cluster[] @relation("ClusterToProject") + slug String @unique + limitless Boolean @default(true) + hprodCpu Float @db.Real + hprodGpu Float @db.Real + hprodMemory Float @db.Real + prodCpu Float @db.Real + prodGpu Float @db.Real + prodMemory Float @db.Real + lastSuccessProvisionningVersion String? + deployments Deployment[] +} + +enum ProjectStatus { + initializing + created + failed + archived + warning +} + +enum DeploymentSourceType { + git + oci +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/schema.prisma b/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/schema.prisma new file mode 100644 index 0000000000..aadf7fea13 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/schema.prisma @@ -0,0 +1,21 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DB_URL") +} + +model Log { + id String @id @default(uuid()) @db.Uuid + data Json + action String @default("") + userId String? @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + requestId String? @db.VarChar(36) + projectId String? @db.Uuid + project Project? @relation(fields: [projectId], references: [id]) + user User? @relation(fields: [userId], references: [id]) +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/token.prisma b/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/token.prisma new file mode 100644 index 0000000000..c0c55751c5 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/token.prisma @@ -0,0 +1,30 @@ +model AdminToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + userId String @db.Uuid + expirationDate DateTime? + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +model PersonalAccessToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + userId String @db.Uuid + expirationDate DateTime + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +enum TokenStatus { + active + revoked + inactive +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/topography.prisma b/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/topography.prisma new file mode 100644 index 0000000000..63d90abb8a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/topography.prisma @@ -0,0 +1,52 @@ +model Cluster { + id String @id @unique @default(uuid()) @db.Uuid + label String @unique @db.VarChar(50) + privacy ClusterPrivacy @default(dedicated) + secretName String @unique @default(uuid()) @db.VarChar(50) + clusterResources Boolean @default(false) + kubeConfigId String @unique @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + infos String? @db.VarChar(1000) + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + zoneId String @db.Uuid + kubeconfig Kubeconfig @relation(fields: [kubeConfigId], references: [id], onDelete: Cascade) + zone Zone @relation(fields: [zoneId], references: [id]) + environments Environment[] + projects Project[] @relation("ClusterToProject") + stages Stage[] @relation("ClusterToStage") +} + +model Kubeconfig { + id String @id @unique @default(uuid()) @db.Uuid + user Json + cluster Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + parentCluster Cluster? +} + +model Stage { + id String @id @unique @default(uuid()) @db.Uuid + name String @unique @db.VarChar + environments Environment[] + clusters Cluster[] @relation("ClusterToStage") +} + +model Zone { + id String @id @unique @default(uuid()) @db.Uuid + slug String @unique @db.VarChar(10) + label String @db.VarChar(50) + description String? @db.VarChar(200) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + argocdUrl String @default("https://example.com") + clusters Cluster[] +} + +enum ClusterPrivacy { + public + dedicated +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/user.prisma b/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/user.prisma new file mode 100644 index 0000000000..e90fb69f83 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/prisma/schema/user.prisma @@ -0,0 +1,23 @@ +model User { + id String @id @db.Uuid + firstName String + lastName String + email String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? + adminRoleIds String[] + type UserType + + logs Log[] + projectsOwned Project[] + adminTokens AdminToken[] + projectMembers ProjectMembers[] + personalAccessTokens PersonalAccessToken[] +} + +enum UserType { + human + bot + ghost +} diff --git a/.jjconflict-base-0/apps/server-nestjs/src/utils/crypto.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/utils/crypto.spec.ts new file mode 100644 index 0000000000..090c58583a --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/utils/crypto.spec.ts @@ -0,0 +1,33 @@ +import { generateProjectKey as legacyGenerateProjectKey } from '@cpn-console/hooks' +import { describe, expect, it } from 'vitest' +import { generateProjectKey } from './crypto.utils' + +describe('generateProjectKey', () => { + it('matches the legacy @cpn-console/hooks implementation byte-for-byte', () => { + const cases: [string, string][] = [ + ['my-app', 'my-repo'], + ['my-app', 'front'], + ['my-app-2', 'front'], + ['slug-with-many-dashes', 'repo-with-dashes'], + ['a', 'b'], + ] + for (const [slug, repo] of cases) { + expect(generateProjectKey(slug, repo)).toBe(legacyGenerateProjectKey(slug, repo)) + } + }) + + it('produces stable known keys', () => { + // Golden values: existing SonarQube projects were created with these exact keys, + // and reconciliation recomputes them to decide ownership. Any change here means + // the console would stop recognizing (and could delete) existing projects. + expect(generateProjectKey('my-app', 'my-repo')).toBe('my-app-my-repo-923f') + expect(generateProjectKey('my-app', 'front')).toBe('my-app-front-1013') + expect(generateProjectKey('my-app', 'api')).toBe('my-app-api-a814') + }) + + it('disambiguates identical slug-repo concatenations via the hash suffix', () => { + // "my-app" + "front-api" and "my-app-front" + "api" share the prefix + // "my-app-front-api-"; only the repo hash tells them apart. + expect(generateProjectKey('my-app', 'front-api')).not.toBe(generateProjectKey('my-app-front', 'api')) + }) +}) diff --git a/apps/server-nestjs/src/utils/crypto.ts b/.jjconflict-base-0/apps/server-nestjs/src/utils/crypto.utils.ts similarity index 100% rename from apps/server-nestjs/src/utils/crypto.ts rename to .jjconflict-base-0/apps/server-nestjs/src/utils/crypto.utils.ts diff --git a/.jjconflict-base-0/apps/server-nestjs/src/utils/dotenv.utils.spec.ts b/.jjconflict-base-0/apps/server-nestjs/src/utils/dotenv.utils.spec.ts new file mode 100644 index 0000000000..b5c7014c46 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/utils/dotenv.utils.spec.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDotenvPaths, getExistingDotenvPaths } from './dotenv.utils' + +describe('dotenv.utils', () => { + afterEach(() => vi.unstubAllEnvs()) + + describe('getDotenvPaths', () => { + it('returns [".env"] when no env vars set', () => { + expect(getDotenvPaths()).toEqual(['.env']) + }) + + it('returns [".env", ".env.integ"] when INTEGRATION=true', () => { + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env', '.env.integ']) + }) + + it('returns [".env.docker"] when DOCKER=true', () => { + vi.stubEnv('DOCKER', 'true') + expect(getDotenvPaths()).toEqual(['.env.docker']) + }) + + it('returns [".env.integ", ".env.docker"] when DOCKER=true and INTEGRATION=true', () => { + vi.stubEnv('DOCKER', 'true') + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env.integ', '.env.docker']) + }) + }) + + describe('getExistingDotenvPaths', () => { + it('filters to only existing files', () => { + const paths = getExistingDotenvPaths() + expect(paths.every(p => typeof p === 'string')).toBe(true) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/src/utils/dotenv.utils.ts b/.jjconflict-base-0/apps/server-nestjs/src/utils/dotenv.utils.ts new file mode 100644 index 0000000000..7d3e0a0501 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/src/utils/dotenv.utils.ts @@ -0,0 +1,26 @@ +import fs from 'node:fs' + +export function getDotenvPaths(): string[] { + const paths: string[] = [] + + // Load .env unless DOCKER=true + if (process.env.DOCKER !== 'true') { + paths.push('.env') + } + + // Load .env.integ if INTEGRATION=true + if (process.env.INTEGRATION === 'true') { + paths.push('.env.integ') + } + + // Load .env.docker if DOCKER=true + if (process.env.DOCKER === 'true') { + paths.push('.env.docker') + } + + return paths +} + +export function getExistingDotenvPaths(): string[] { + return getDotenvPaths().filter(path => fs.existsSync(path)) +} diff --git a/apps/server-nestjs/src/utils/http-error.ts b/.jjconflict-base-0/apps/server-nestjs/src/utils/http.utils.ts similarity index 100% rename from apps/server-nestjs/src/utils/http-error.ts rename to .jjconflict-base-0/apps/server-nestjs/src/utils/http.utils.ts diff --git a/apps/server-nestjs/src/utils/iterable.ts b/.jjconflict-base-0/apps/server-nestjs/src/utils/iterable.utils.ts similarity index 100% rename from apps/server-nestjs/src/utils/iterable.ts rename to .jjconflict-base-0/apps/server-nestjs/src/utils/iterable.utils.ts diff --git a/.jjconflict-base-0/apps/server-nestjs/test/argocd.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/argocd.e2e-spec.ts new file mode 100644 index 0000000000..65e0ea7497 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/argocd.e2e-spec.ts @@ -0,0 +1,314 @@ +import type { CommitAction, Gitlab } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { baseConfigFactory } from '../src/config/base.config' +import { projectSelect } from '../src/modules/argocd/argocd-datastore.service' +import { ArgoCDModule } from '../src/modules/argocd/argocd.module' +import { ArgoCDService } from '../src/modules/argocd/argocd.service' +import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunArgoCDE2E + = Boolean(process.env.E2E) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + && Boolean(process.env.DB_URL) + +const describeWithArgoCD = describe.runIf(canRunArgoCDE2E) + +describeWithArgoCD('ArgoCDController (e2e)', {}, () => { + let moduleRef: TestingModule + let argocdController: ArgoCDService + let gitlab: GitlabClientService + let gitlabClient: Gitlab + let vault: VaultClientService + let prisma: PrismaService + let config: BaseConfig + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + let zoneId: string + let zoneSlug: string + let kubeconfigId: string + let clusterId: string + let clusterLabel: string + let stageId: string + let envDevId: string + let envProdId: string + let envDevName: string + let envProdName: string + + let infraRepoId: number | undefined + let infraRepoPath: string + let vaultProjectValuesPath: string | undefined + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ArgoCDModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + argocdController = moduleRef.get(ArgoCDService) + gitlab = moduleRef.get(GitlabClientService) + gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) + vault = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + config = moduleRef.get(baseConfigFactory) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + zoneId = faker.string.uuid() + zoneSlug = faker.string.alphanumeric({ length: 10 }).toLowerCase() + kubeconfigId = faker.string.uuid() + clusterId = faker.string.uuid() + clusterLabel = faker.helpers.slugify(`cluster-${faker.string.uuid()}`.slice(0, 40)) + stageId = faker.string.uuid() + envDevId = faker.string.uuid() + envProdId = faker.string.uuid() + envDevName = 'dev' + envProdName = 'prod' + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.zone.create({ + data: { + id: zoneId, + slug: zoneSlug, + label: `Zone ${zoneSlug}`, + argocdUrl: 'https://example.com', + }, + }) + + await prisma.kubeconfig.create({ + data: { + id: kubeconfigId, + user: { token: faker.string.alphanumeric({ length: 16 }) }, + cluster: { server: 'https://example.com' }, + }, + }) + + await prisma.cluster.create({ + data: { + id: clusterId, + label: clusterLabel, + secretName: faker.string.uuid(), + kubeConfigId: kubeconfigId, + infos: null, + memory: 100, + cpu: 100, + gpu: 0, + zoneId, + }, + }) + + await prisma.stage.create({ + data: { + id: stageId, + name: faker.helpers.slugify(`stage-${faker.string.uuid()}`), + }, + }) + + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + clusters: { + connect: { id: clusterId }, + }, + repositories: { + create: { + internalRepoName: zoneSlug, + isInfra: true, + deployRevision: 'HEAD', + deployPath: '.', + helmValuesFiles: '', + }, + }, + environments: { + create: [ + { + id: envDevId, + name: envDevName, + clusterId, + stageId, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + }, + { + id: envProdId, + name: envProdName, + clusterId, + stageId, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + }, + ], + }, + }, + }) + + infraRepoPath = `${config.projectsRootDir}/infra/${zoneSlug}` + try { + const existing = await gitlabClient.Projects.show(infraRepoPath) + if (existing.empty_repo || existing.default_branch !== 'main') { + await gitlabClient.Projects.remove(existing.id).catch(() => {}) + throw new Error('Recreate infra repo') + } + infraRepoId = existing.id + } catch (error: any) { + const description = error?.cause?.description ?? '' + if ( + !(typeof description === 'string' && description.includes('404')) + && !(error instanceof Error && error.message === 'Recreate infra repo') + ) { + throw error + } + + const infraGroup = await gitlab.getOrCreateProjectSubGroup('infra') + const created = await gitlabClient.Projects.create({ + name: zoneSlug, + path: zoneSlug, + namespaceId: infraGroup.id, + initializeWithReadme: true, + defaultBranch: 'main', + }) + infraRepoId = created.id + } + + vaultProjectValuesPath = `${config.projectsRootDir}/${testProjectId}` + await vault.write({ e2e: true }, vaultProjectValuesPath) + }) + + afterAll(async () => { + if (vaultProjectValuesPath) { + await vault.delete(vaultProjectValuesPath).catch(() => {}) + } + + if (infraRepoId) { + await gitlabClient.Projects.remove(infraRepoId).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.stage.deleteMany({ where: { id: stageId } }).catch(() => {}) + await prisma.cluster.deleteMany({ where: { id: clusterId } }).catch(() => {}) + await prisma.kubeconfig.deleteMany({ where: { id: kubeconfigId } }).catch(() => {}) + await prisma.zone.deleteMany({ where: { id: zoneId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should commit environment values and cleanup stale values in the zone infra repo', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + const infraProject = await gitlab.getOrCreateInfraGroupRepo(zoneSlug) + infraRepoId = infraProject.id + + const staleFilePath = `${project.name}/${clusterLabel}/stale/values.yaml` + if (!infraRepoId) throw new Error('Missing infra repo id') + const staleAction = await gitlab.generateCreateOrUpdateAction(infraProject, 'main', staleFilePath, 'stale: true\n') + await gitlab.maybeCreateCommit(infraProject, 'ci: :robot_face: Seed stale values', staleAction ? [staleAction] : []) + + await argocdController.handleUpsert(project) + + const expectedFilePath = `${project.name}/${clusterLabel}/${envDevName}/values.yaml` + const file = await gitlabClient.RepositoryFiles.show(infraRepoId, expectedFilePath, 'main') + const raw = Buffer.from(file.content, 'base64').toString('utf8') + const values = parse(raw) + + expect(values?.common?.['dso/project.slug']).toBe(testProjectSlug) + expect(values?.common?.['dso/environment']).toBe(envDevName) + expect(values?.environment?.valueFilePath).toBe(expectedFilePath) + expect(values?.application?.destination?.name).toBe(clusterLabel) + expect(values?.application?.autosync).toBe(true) + + const shouldBeDeleted = await gitlab.getFile(infraProject, staleFilePath, 'main') + expect(shouldBeDeleted).toBeUndefined() + }, 144000) + + it('should update existing values and delete values of a removed environment', async () => { + const before = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + const infraProject = await gitlab.getOrCreateInfraGroupRepo(zoneSlug) + infraRepoId = infraProject.id + + const devFilePath = `${before.name}/${clusterLabel}/${envDevName}/values.yaml` + const prodFilePath = `${before.name}/${clusterLabel}/${envProdName}/values.yaml` + + const seededActions = (await Promise.all([ + gitlab.generateCreateOrUpdateAction(infraProject, 'main', devFilePath, 'old: true\n'), + gitlab.generateCreateOrUpdateAction(infraProject, 'main', prodFilePath, 'old: true\n'), + ])).filter((action): action is NonNullable => action !== null) + await gitlab.maybeCreateCommit(infraProject, 'ci: :robot_face: Seed existing values', seededActions as CommitAction[]) + + await prisma.environment.deleteMany({ where: { id: envProdId } }) + + const after = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await argocdController.handleUpsert(after) + + const updatedDev = await gitlabClient.RepositoryFiles.show(infraRepoId, devFilePath, 'main') + const devRaw = Buffer.from(updatedDev.content, 'base64').toString('utf8') + const devValues = parse(devRaw) + expect(devValues?.common?.['dso/project.slug']).toBe(testProjectSlug) + expect(devValues?.common?.['dso/environment']).toBe(envDevName) + + const prodFile = await gitlab.getFile(infraProject, prodFilePath, 'main') + expect(prodFile).toBeUndefined() + }, 72000) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/test/gitlab.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/gitlab.e2e-spec.ts new file mode 100644 index 0000000000..e8d19c497b --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/gitlab.e2e-spec.ts @@ -0,0 +1,242 @@ +import type { ExpandedUserSchema, Gitlab } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' +import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' +import { projectSelect } from '../src/modules/gitlab/gitlab-datastore.service' +import { GitlabModule } from '../src/modules/gitlab/gitlab.module' +import { GitlabService } from '../src/modules/gitlab/gitlab.service' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunGitlabE2E + = Boolean(process.env.E2E) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + && Boolean(process.env.DB_URL) + +const describeWithGitLab = describe.runIf(canRunGitlabE2E) + +describeWithGitLab('GitlabController (e2e)', {}, () => { + let moduleRef: TestingModule + let gitlabController: GitlabService + let gitlabService: GitlabClientService + let gitlabClient: Gitlab + let vaultService: VaultClientService + let prisma: PrismaService + let config: BaseConfig + + let testProjectId: string + let testProjectSlug: string + let ownerId: string + let ownerUser: ExpandedUserSchema + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [GitlabModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + gitlabController = moduleRef.get(GitlabService) + gitlabService = moduleRef.get(GitlabClientService) + gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) + vaultService = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + config = moduleRef.get(baseConfigFactory) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + const ownerEmail = `test-owner-${ownerId}@example.com` + + // Create owner in GitLab + ownerUser = await gitlabClient.Users.create({ + name: 'Test Owner', + password: faker.internet.password({ length: 24 }), + username: `test-owner-${ownerId}`, + email: ownerEmail, + skipConfirmation: true, + }) + + // Create owner in DB + await prisma.user.create({ + data: { + id: ownerId, + email: ownerUser.email.toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + await prisma.repository.create({ + data: { + projectId: testProjectId, + internalRepoName: 'app', + externalRepoUrl: 'https://example.com/example.git', + isPrivate: false, + }, + }) + }) + + afterAll(async () => { + // Clean GitLab group + if (testProjectSlug && config.projectsRootDir) { + const fullPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = await gitlabService.getGroupByPath(fullPath) + if (group) { + await gitlabService.deleteGroup(group).catch(() => {}) + } + } + + // Clean Vault + if (testProjectSlug && config.projectsRootDir) { + const vaultPath = `${config.projectsRootDir}/${testProjectSlug}` + await vaultService.delete(`${vaultPath}/tech/GITLAB_MIRROR`).catch(() => {}) + await vaultService.delete(`${vaultPath}/app-mirror`).catch(() => {}) + } + + // Clean DB + if (prisma) { + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + if (ownerUser?.id) { + await gitlabClient.Users.remove(ownerUser.id).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should reconcile and create project group in GitLab and Vault secrets', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await gitlabController.handleUpsert(project) + + // Assert + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = z.object({ + id: z.number(), + name: z.string(), + full_path: z.string(), + web_url: z.string(), + }).parse(await gitlabService.getGroupByPath(groupPath)) + expect(group.full_path).toBe(groupPath) + + // Check membership + const members = await gitlabService.getGroupMembers(group) + const isMember = members.some(m => m.id === ownerUser.id) + expect(isMember).toBe(true) + + const repoVaultPath = `${config.projectsRootDir}/${testProjectSlug}/app-mirror` + const repoSecret = await vaultService.read(repoVaultPath) + expect(repoSecret?.data?.GIT_OUTPUT_USER).toBeTruthy() + expect(repoSecret?.data?.GIT_OUTPUT_PASSWORD).toBeTruthy() + }, 72000) + + describe('project members', () => { + let newUserId: string | undefined + let newUserGitlabId: number | undefined + + beforeAll(async () => { + newUserId = faker.string.uuid() + const newUser = await gitlabClient.Users.create({ + email: faker.internet.email().toLowerCase(), + username: faker.internet.username(), + name: `${faker.person.firstName()} ${faker.person.lastName()}`, + password: faker.internet.password({ length: 24 }), + skipConfirmation: true, + }) + newUserGitlabId = newUser.id + + await prisma.user.create({ + data: { + id: newUserId, + email: newUser.email, + firstName: 'Test', + lastName: 'User', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: newUserId, + roleIds: [], + }, + }) + }) + + afterAll(async () => { + if (newUserGitlabId) { + await gitlabClient.Users.remove(newUserGitlabId).catch(() => {}) + } + if (prisma && newUserId) { + await prisma.projectMembers.deleteMany({ where: { userId: newUserId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: newUserId } }).catch(() => {}) + } + }) + + it('should add member to GitLab group when added in DB', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await gitlabController.handleUpsert(project) + + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = z.object({ + id: z.number(), + name: z.string(), + web_url: z.string(), + }).parse(await gitlabService.getGroupByPath(groupPath)) + + const members = await gitlabService.getGroupMembers(group) + const isNewMemberPresent = members.some(m => m.id === newUserGitlabId) + expect(isNewMemberPresent).toBe(true) + }, 72000) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/test/keycloak.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/keycloak.e2e-spec.ts new file mode 100644 index 0000000000..9875253678 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -0,0 +1,537 @@ +import type KcAdminClient from '@keycloak/keycloak-admin-client' +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Logger } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { projectSelect } from '../src/modules/keycloak/keycloak-datastore.service' +import { KeycloakModule } from '../src/modules/keycloak/keycloak.module' +import { KeycloakService } from '../src/modules/keycloak/keycloak.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunKeycloakE2E + = Boolean(process.env.E2E) + && Boolean(process.env.KEYCLOAK_DOMAIN) + && Boolean(process.env.KEYCLOAK_REALM) + && Boolean(process.env.KEYCLOAK_PROTOCOL) + && Boolean(process.env.KEYCLOAK_ADMIN) + && Boolean(process.env.KEYCLOAK_ADMIN_PASSWORD) + +const describeWithKeycloak = describe.runIf(canRunKeycloakE2E) + +describeWithKeycloak('KeycloakController (e2e)', () => { + let moduleRef: TestingModule + let keycloakController: KeycloakService + let keycloak: KeycloakClientService + let keycloakAdminClient: KcAdminClient + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + let testRoleName: string + let testRoleId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [KeycloakModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + keycloakController = moduleRef.get(KeycloakService) + keycloak = moduleRef.get(KeycloakClientService) + keycloakAdminClient = moduleRef.get(KEYCLOAK_ADMIN_CLIENT) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + testRoleName = faker.helpers.slugify(`test-role-${faker.string.uuid()}`) + testRoleId = faker.string.uuid() + + const ownerEmail = faker.internet.email({ firstName: 'test-owner', provider: 'example.com' }) + + // Create owner in Keycloak + const createdUser = await keycloakAdminClient.users.create({ + id: ownerId, + username: `test-owner-${ownerId}`, + email: ownerEmail, + enabled: true, + firstName: 'Test', + lastName: 'Owner', + }) + if (createdUser.id) { + ownerId = createdUser.id + } + + // Create owner in DB + await prisma.user.create({ + data: { + id: ownerId, + email: ownerEmail, + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + try { + // Clean Keycloak + const group = await keycloak.getGroupByPath(`/${testProjectSlug}`) + if (group?.id) { + await keycloak.deleteGroup(group.id) + } + + // Clean owner user + if (ownerId) { + await keycloakAdminClient.users.del({ id: ownerId }).catch(() => {}) + if (prisma) { + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + } + + // Clean DB + if (prisma) { + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId } }) + // Prisma cascade delete should handle roles/envs if configured correctly, but explicit delete is safer + // We catch errors to avoid failing cleanup if tables/relations are different + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + } + } catch (e: any) { + Logger.warn(`Cleanup failed: ${e.message}`) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('should reconcile and create groups in Keycloak', async () => { + // Create Project in DB + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + roles: { + create: { + id: testRoleId, + name: testRoleName, + oidcGroup: `/${testRoleName}`, + permissions: BigInt(0), + position: 0, + }, + }, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await keycloakController.handleUpsert(project) + + // Assert + // Check main project group + const projectGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + expect(projectGroup.name).toBe(testProjectSlug) + + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console`)) + expect(consoleGroup.name).toBe('console') + + // Check role group + const roleGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(roleGroup.name).toBe(testRoleName) + + // Check membership (owner should be added) + const members = await keycloak.getGroupMembers(projectGroup.id) + const isMember = members.some(m => m.id === ownerId) + expect(isMember).toBe(true) + }, 60000) + + it('should add member to project group when added in DB', async () => { + // Create another user in Keycloak and DB + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-${newUserId}@example.com` + + // Create in Keycloak + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'User', + }) + + // Create in DB + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'User', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [testRoleId], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await keycloakController.handleUpsert(project) + + // Assert + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + const members = await keycloak.getGroupMembers(projectGroup.id) + const isMember = members.some(m => m.id === kcUser.id) + expect(isMember).toBe(true) + + // Check role group membership + const roleGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + const roleMembers = await keycloak.getGroupMembers(roleGroup.id) + const isRoleMember = roleMembers.some(m => m.id === kcUser.id) + expect(isRoleMember).toBe(true) + + // Cleanup user + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should remove member from project group when removed in DB', async () => { + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-remove-${newUserId}@example.com` + + // Create in Keycloak + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-remove-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserRemove', + }) + + // Create in DB + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserRemove', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [], // No roles + }, + }) + + let project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync add + await keycloakController.handleUpsert(project) + + // Verify added + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Remove from DB + await prisma.projectMembers.delete({ + where: { + projectId_userId: { + projectId: testProjectId, + userId: kcUser.id, + }, + }, + }) + + project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync remove + await keycloakController.handleUpsert(project) + + // Verify removed + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should handle non-existent users gracefully', async () => { + // Add a member in DB that does not exist in Keycloak + const fakeUserId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: fakeUserId, + email: `fake-${fakeUserId}@example.com`, + firstName: 'Fake', + lastName: 'User', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: fakeUserId, + roleIds: [], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act - should not throw + await expect(keycloakController.handleUpsert(project)).resolves.not.toThrow() + + // Cleanup + await prisma.projectMembers.deleteMany({ where: { userId: fakeUserId } }) + await prisma.user.delete({ where: { id: fakeUserId } }) + }, 60000) + + it('should add user back to Keycloak group if missing but present in DB', async () => { + // Create user and add to project in DB + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-sync-${newUserId}@example.com` + + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-sync-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserSync', + }) + + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserSync', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync to ensure they are added initially + await keycloakController.handleUpsert(project) + + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + + // Manually remove user from Keycloak group + await keycloak.removeUserFromGroup(kcUser.id, projectGroup.id) + + // Verify removal + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Sync again + await keycloakController.handleUpsert(project) + + // Verify added back + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should remove user from Keycloak group if present but missing in DB', async () => { + // Create user + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-orphan-${newUserId}@example.com` + + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-orphan-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserOrphan', + }) + + // We only need them in Keycloak for this test, but the controller checks if user is in DB to define "missing". + // Actually, `deleteExtraProjectMembers` iterates over Keycloak group members. + // So we don't strictly need the user in DB, but to be "clean" we should probably have them in DB but NOT in the project. + + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserOrphan', + type: 'human', + }, + }) + + // Get project from DB (user is NOT a member) + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync to create group + await keycloakController.handleUpsert(project) + + // Manually add user to Keycloak group + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + await keycloak.addUserToGroup(kcUser.id, projectGroup.id) + + // Verify added + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Sync again to remove user + await keycloakController.handleUpsert(project) + + // Verify removed + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should recreate project group if deleted in Keycloak', async () => { + // Ensure project exists and is synced + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await keycloakController.handleUpsert(project) + + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + + // Delete group in Keycloak + await keycloak.deleteGroup(projectGroup.id) + + // Verify deleted + const deletedProjectGroup = await keycloak.getGroupByPath(`/${testProjectSlug}`) + expect(deletedProjectGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedProjectGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + expect(recreatedProjectGroup?.name).toBe(testProjectSlug) + }, 60000) + + it('should recreate role group if deleted in Keycloak', async () => { + // Ensure project exists and is synced + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await keycloakController.handleUpsert(project) + + const roleGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + + // Delete role group in Keycloak + await keycloak.deleteGroup(roleGroup.id) + + // Verify deleted + const deletedRoleGroup = await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`) + expect(deletedRoleGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedRoleGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(recreatedRoleGroup?.name).toBe(testRoleName) + }, 60000) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/test/log.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/log.e2e-spec.ts new file mode 100644 index 0000000000..2b5f509b3f --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/log.e2e-spec.ts @@ -0,0 +1,188 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { LogModule } from '../src/modules/log/log.module' +import { LogService } from '../src/modules/log/log.service' + +const canRunLogE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + +const describeWithLog = describe.runIf(canRunLogE2E) + +describeWithLog('LogService (e2e)', () => { + let moduleRef: TestingModule + let logService: LogService + let prisma: PrismaService + + let ownerId: string + let projectId: string + let projectSlug: string + let projectLogId: string + let globalLogId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [LogModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + logService = moduleRef.get(LogService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + if (projectLogId) { + await prisma.log.deleteMany({ where: { id: projectLogId } }).catch(() => {}) + } + if (globalLogId) { + await prisma.log.deleteMany({ where: { id: globalLogId } }).catch(() => {}) + } + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should persist logs and return clean/project-filtered results', async () => { + const projectLog = await logService.addLog({ + action: 'project-upsert', + userId: ownerId, + projectId, + requestId: faker.string.uuid(), + data: { + args: { slug: projectSlug }, + results: { ok: true }, + config: { hidden: true }, + cluster: { id: 'cluster-1' }, + user: { id: ownerId }, + newCreds: { token: 'secret' }, + apis: ['v1'], + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }, + }) + projectLogId = projectLog.id + + const globalLog = await logService.addLog({ + action: 'global-upsert', + data: { + args: { enabled: true }, + results: { ok: true }, + config: { hidden: true }, + }, + }) + globalLogId = globalLog.id + + const storedLog = await prisma.log.findUniqueOrThrow({ + where: { id: projectLog.id }, + }) + expect(storedLog.data).toMatchObject({ + args: { slug: projectSlug }, + results: { ok: true }, + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }) + expect(storedLog.data).not.toHaveProperty('config') + expect(storedLog.data).not.toHaveProperty('cluster') + expect(storedLog.data).not.toHaveProperty('user') + expect(storedLog.data).not.toHaveProperty('newCreds') + expect(storedLog.data).not.toHaveProperty('apis') + + const { total: projectTotal, logs: projectLogs } = await logService.getLogs({ + offset: 0, + limit: 10, + projectId, + clean: false, + }) + expect(projectTotal).toBe(1) + expect(projectLogs).toHaveLength(1) + expect(projectLogs[0]).toMatchObject({ + id: projectLog.id, + action: 'project-upsert', + userId: ownerId, + projectId, + }) + expect(projectLogs[0].data).toMatchObject({ + args: { slug: projectSlug }, + results: { ok: true }, + warning: ['careful'], + }) + expect(projectLogs[0].data).not.toHaveProperty('config') + + const { total: allTotal, logs: allLogs } = await logService.getLogs({ + offset: 0, + limit: 10, + projectId: undefined, + clean: true, + }) + expect(allTotal).toBeGreaterThan(2) + expect(allLogs).toHaveLength(10) + expect(allLogs[0]).toMatchObject({ + id: globalLog.id, + action: 'global-upsert', + userId: null, + }) + expect(allLogs[0].data).not.toHaveProperty('args') + expect(allLogs[0].data).not.toHaveProperty('results') + expect(allLogs[0].data).not.toHaveProperty('config') + expect(allLogs[1]).toMatchObject({ + id: projectLog.id, + action: 'project-upsert', + userId: ownerId, + }) + expect(allLogs[1].data).toMatchObject({ + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }) + expect(allLogs[1].data).not.toHaveProperty('args') + expect(allLogs[1].data).not.toHaveProperty('results') + expect(allLogs[1].data).not.toHaveProperty('config') + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/test/nexus.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/nexus.e2e-spec.ts new file mode 100644 index 0000000000..0c5b45eb5d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/nexus.e2e-spec.ts @@ -0,0 +1,156 @@ +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { NexusClientService } from '../src/modules/nexus/nexus-client.service' +import { projectSelect } from '../src/modules/nexus/nexus-datastore.service' +import { makeProjectWithDetails } from '../src/modules/nexus/nexus-testing.utils' +import { NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, PLUGIN_NAME } from '../src/modules/nexus/nexus.constants' +import { NexusModule } from '../src/modules/nexus/nexus.module' +import { NexusService } from '../src/modules/nexus/nexus.service' +import { getProjectVaultPath } from '../src/modules/nexus/nexus.utils' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunNexusE2E + = Boolean(process.env.E2E) + && Boolean(process.env.NEXUS_URL) + && Boolean(process.env.NEXUS_ADMIN) + && Boolean(process.env.NEXUS_ADMIN_PASSWORD) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithNexus = describe.runIf(canRunNexusE2E) + +describeWithNexus('NexusController (e2e)', () => { + let moduleRef: TestingModule + let nexusController: NexusService + let nexusClient: NexusClientService + let vaultService: VaultClientService + let config: BaseConfig + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [NexusModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + nexusController = moduleRef.get(NexusService) + nexusClient = moduleRef.get(NexusClientService) + vaultService = moduleRef.get(VaultClientService) + config = moduleRef.get(baseConfigFactory) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (testProjectSlug) { + await nexusController.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should reconcile project in Nexus (repos, role, user, vault secret)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + plugins: { + create: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }, + ], + }, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await nexusController.handleUpsert(project) + + const mavenReleaseRepo = `${testProjectSlug}-repository-release` + const mavenSnapshotRepo = `${testProjectSlug}-repository-snapshot` + const mavenGroupRepo = `${testProjectSlug}-repository-group` + + const npmHostedRepo = `${testProjectSlug}-npm` + const npmGroupRepo = `${testProjectSlug}-npm-group` + + const [releaseRepo, snapshotRepo, groupRepo, npmRepo, npmGroup] = await Promise.all([ + nexusClient.getRepositoriesMavenHosted(mavenReleaseRepo), + nexusClient.getRepositoriesMavenHosted(mavenSnapshotRepo), + nexusClient.getRepositoriesMavenGroup(mavenGroupRepo), + nexusClient.getRepositoriesNpmHosted(npmHostedRepo), + nexusClient.getRepositoriesNpmGroup(npmGroupRepo), + ]) + + expect(releaseRepo).toBeTruthy() + expect(snapshotRepo).toBeTruthy() + expect(groupRepo).toBeTruthy() + expect(npmRepo).toBeTruthy() + expect(npmGroup).toBeTruthy() + + const roleId = `${testProjectSlug}-ID` + const role = await nexusClient.getSecurityRoles(roleId) + expect(role).toBeTruthy() + + const users = await nexusClient.getSecurityUsers(testProjectSlug) + expect(users.some(u => u.userId === testProjectSlug)).toBe(true) + + const vaultPath = getProjectVaultPath(config.projectsRootDir, testProjectSlug, 'tech/NEXUS') + const secret = await vaultService.read(vaultPath) + expect(secret.data?.NEXUS_USERNAME).toBe(testProjectSlug) + expect(secret.data?.NEXUS_PASSWORD).toBeTruthy() + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/test/project-bulk.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/project-bulk.e2e-spec.ts new file mode 100644 index 0000000000..3a1e80f39d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/project-bulk.e2e-spec.ts @@ -0,0 +1,109 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectBulkModule } from '../src/modules/project-bulk/project-bulk.module' +import { ProjectBulkService } from '../src/modules/project-bulk/project-bulk.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectBulkE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectBulk = describe.runIf(canRunProjectBulkE2E) + +describeWithProjectBulk('ProjectBulkService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectBulkService + let eventEmitter: EventEmitter2 + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectBulkModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectBulkService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('locks and unlocks projects', async () => { + await service.bulkAction({ action: 'lock', projectIds: [projectId] }) + const locked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } }) + expect(locked.locked).toBe(true) + + await service.bulkAction({ action: 'unlock', projectIds: [projectId] }) + const unlocked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } }) + expect(unlocked.locked).toBe(false) + }) + + it('replays hooks for projects', async () => { + await service.bulkAction({ action: 'replay', projectIds: [projectId] }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith( + 'project.upsert', + expect.objectContaining({ id: projectId, slug: projectSlug }), + ) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/test/project-hooks.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/project-hooks.e2e-spec.ts new file mode 100644 index 0000000000..ab6e75184e --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/project-hooks.e2e-spec.ts @@ -0,0 +1,119 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectHooksModule } from '../src/modules/project-hooks/project-hooks.module' +import { ProjectHooksService } from '../src/modules/project-hooks/project-hooks.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectHooksE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectHooks = describe.runIf(canRunProjectHooksE2E) + +describeWithProjectHooks('ProjectHooksService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectHooksService + let eventEmitter: DeepMockProxy + let vaultService: DeepMockProxy + let vaultClient: DeepMockProxy + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + vaultService = mockDeep() + vaultService.listProjectSecrets.mockResolvedValue([]) + vaultClient = mockDeep() + + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectHooksModule], + providers: [ + { provide: VaultService, useValue: vaultService }, + { provide: VaultClientService, useValue: vaultClient }, + ], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectHooksService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('replays hooks through the event system', async () => { + await service.replay(projectId) + + expect(eventEmitter.emitAsync).toHaveBeenCalledWith( + 'project.upsert', + expect.objectContaining({ id: projectId, slug: projectSlug }), + ) + }) + + it('rejects replayHooks when the project does not exist', async () => { + const result = await service.replay(faker.string.uuid()) + expect(result).toBeUndefined() + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/test/project-members.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/project-members.e2e-spec.ts new file mode 100644 index 0000000000..10dec97860 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/project-members.e2e-spec.ts @@ -0,0 +1,169 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { ProjectMembersModule } from '../src/modules/project-members/project-members.module' +import { ProjectMembersService } from '../src/modules/project-members/project-members.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectMembersE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectMembers = describe.runIf(canRunProjectMembersE2E) + +describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectMembersService + let eventEmitter: DeepMockProxy + let keycloakClient: DeepMockProxy + + let ownerId: string + let memberId: string + + beforeAll(async () => { + keycloakClient = mockDeep() + keycloakClient.getUserByEmail.mockResolvedValue(undefined) + + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectMembersModule], + providers: [ + { provide: KeycloakClientService, useValue: keycloakClient }, + ], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectMembersService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + memberId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.user.create({ + data: { + id: memberId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Member', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.user.deleteMany({ where: { id: memberId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('rejects addMember when project does not exist', async () => { + await expect(service.add(faker.string.uuid(), { userId: memberId })).rejects.toThrow(NotFoundException) + }) + + it('rejects listMembers when project does not exist', async () => { + await expect(service.list(faker.string.uuid())).resolves.toEqual([]) + }) + + describe('with project', () => { + let projectId: string + + beforeEach(async () => { + eventEmitter.emitAsync.mockClear() + + projectId = faker.string.uuid() + const projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + + eventEmitter.emitAsync.mockClear() + }) + + afterEach(async () => { + await prisma.projectMembers.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + }) + + it('list', async () => { + const members = await service.list(projectId) + expect(members).toHaveLength(0) + }) + + it('add', async () => { + const afterAdd = await service.add(projectId, { userId: memberId }) + expect(afterAdd.some(m => m.userId === memberId)).toBe(true) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.upsert', { projectId, userId: memberId }) + }) + + it('patch', async () => { + await service.add(projectId, { userId: memberId }) + const roleId = faker.string.uuid() + const afterPatch = await service.patch(projectId, [{ userId: memberId, roles: [roleId] }]) + expect(afterPatch.find(m => m.userId === memberId)?.roleIds).toContain(roleId) + }) + + it('remove', async () => { + await service.add(projectId, { userId: memberId }) + const afterRemove = await service.remove(projectId, memberId) + expect(afterRemove.some(m => m.userId === memberId)).toBe(false) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.delete', { projectId, userId: memberId }) + }) + + it('rejects addMember when adding owner', async () => { + await expect(service.add(projectId, { userId: ownerId })).rejects.toThrow(BadRequestException) + }) + + it('rejects addMember when user does not exist', async () => { + await expect(service.add(projectId, { userId: faker.string.uuid() })).rejects.toThrow(NotFoundException) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/test/project-roles.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/project-roles.e2e-spec.ts new file mode 100644 index 0000000000..0f530ebf53 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/project-roles.e2e-spec.ts @@ -0,0 +1,238 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectRolesModule } from '../src/modules/project-roles/project-roles.module' +import { ProjectRolesService } from '../src/modules/project-roles/project-roles.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectRolesE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectRoles = describe.runIf(canRunProjectRolesE2E) + +describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectRolesService + let eventEmitter: DeepMockProxy + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectRolesModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectRolesService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-roles-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E roles test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.projectRole.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('lists roles for a project', async () => { + const role = await prisma.projectRole.create({ + data: { + projectId, + name: 'admin', + permissions: 4n, + position: 0, + type: 'managed', + oidcGroup: `/${projectSlug}/admin`, + }, + }) + + const roles = await service.list(projectId) + + expect(roles).toHaveLength(1) + expect(roles[0].id).toBe(role.id) + expect(roles[0].permissions).toBe('4') + expect(roles[0].oidcGroup).toBe('/admin') + }) + + it('creates a role and emits project upsert', async () => { + const role = await service.create(projectId, { name: 'developer', permissions: '2' }) + + expect(role.length).toBeGreaterThanOrEqual(1) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', expect.objectContaining({ id: projectId })) + + const dbRole = await prisma.projectRole.findFirst({ + where: { projectId, name: 'developer' }, + }) + expect(dbRole).not.toBeNull() + expect(dbRole?.permissions).toBe(2n) + }) + + it('patches roles and updates position', async () => { + const existingRole = await prisma.projectRole.create({ + data: { + projectId, + name: 'tester', + permissions: 1n, + position: 5, + type: 'managed', + oidcGroup: '', + }, + }) + + await prisma.projectRole.create({ + data: { + projectId, + name: 'other', + permissions: 2n, + position: 6, + type: 'managed', + oidcGroup: '', + }, + }) + + const roles = await service.update(projectId, [{ + id: existingRole.id, + name: 'qa', + }]) + + const updated = roles.find(r => r.id === existingRole.id) + expect(updated?.name).toBe('qa') + }) + + it('counts role members', async () => { + const roleId = faker.string.uuid() + await prisma.projectRole.create({ + data: { + id: roleId, + projectId, + name: 'counted', + permissions: 1n, + position: 10, + type: 'managed', + oidcGroup: '', + }, + }) + + const userId = faker.string.uuid() + await prisma.user.create({ + data: { + id: userId, + email: faker.internet.email().toLowerCase(), + firstName: 'Member', + lastName: 'Test', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId, + userId, + roleIds: [roleId], + }, + }) + + const counts = await service.countMembers(projectId) + expect(counts[roleId]).toBe(1) + }) + + it('deletes a role and cleans member role ids', async () => { + const roleId = faker.string.uuid() + const userId = faker.string.uuid() + + await prisma.projectRole.create({ + data: { + id: roleId, + projectId, + name: 'to-delete', + permissions: 1n, + position: 20, + type: 'managed', + oidcGroup: '', + }, + }) + + await prisma.user.create({ + data: { + id: userId, + email: faker.internet.email().toLowerCase(), + firstName: 'Delete', + lastName: 'Test', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId, + userId, + roleIds: [roleId], + }, + }) + + await service.delete(roleId) + + const member = await prisma.projectMembers.findUnique({ + where: { projectId_userId: { projectId, userId } }, + }) + expect(member?.roleIds).not.toContain(roleId) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', expect.objectContaining({ id: projectId })) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/test/project-secrets.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/project-secrets.e2e-spec.ts new file mode 100644 index 0000000000..bb30aff5d7 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/project-secrets.e2e-spec.ts @@ -0,0 +1,145 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectSecretsModule } from '../src/modules/project-secrets/project-secrets.module' +import { ProjectSecretsService } from '../src/modules/project-secrets/project-secrets.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { generateProjectPath } from '../src/modules/vault/vault.utils' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectSecretsE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithProjectSecrets = describe.runIf(canRunProjectSecretsE2E) + +describeWithProjectSecrets('ProjectSecretsService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectSecretsService + let vaultClient: VaultClientService + + let ownerId: string + let projectId: string + let projectSlug: string + let projectPath: string + let secretAPath: string + let secretBPath: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectSecretsModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectSecretsService) + vaultClient = moduleRef.get(VaultClientService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + projectPath = generateProjectPath(process.env.PROJECTS_ROOT_DIR, projectSlug) + secretAPath = `${projectPath}/group1/secret1` + secretBPath = `${projectPath}/group2/secret2` + + await Promise.all([secretAPath, secretBPath].map(path => vaultClient.delete(path).catch(() => {}))) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + await Promise.all([secretAPath, secretBPath].map(path => vaultClient.delete(path).catch(() => {}))) + + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('returns an empty secret map when no Vault secrets exist', async () => { + const secrets = await service.get(projectId) + expect(secrets).toEqual({}) + }) + + describe('when Vault secrets exist', () => { + beforeAll(async () => { + await Promise.all([ + vaultClient.write({ + key1: 'value1', + key2: 42, + }, secretAPath), + vaultClient.write({ + key3: false, + key4: null, + }, secretBPath), + ]) + }) + + afterAll(async () => { + await Promise.all([ + vaultClient.delete(secretAPath).catch(() => {}), + vaultClient.delete(secretBPath).catch(() => {}), + ]) + }) + + it('parses real Vault values into grouped secrets', async () => { + const secrets = await service.get(projectId) + + expect(secrets.group1).toEqual({ + 'secret1.key1': 'value1', + 'secret1.key2': '42', + }) + expect(secrets.group2).toEqual({ + 'secret2.key3': 'false', + 'secret2.key4': '', + }) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/test/project-services.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/project-services.e2e-spec.ts new file mode 100644 index 0000000000..25612a1f99 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/project-services.e2e-spec.ts @@ -0,0 +1,144 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectServicesModule } from '../src/modules/project-services/project-services.module' +import { ProjectServicesService } from '../src/modules/project-services/project-services.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunServicesE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + && Boolean(process.env.ARGOCD_URL) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.HARBOR_URL) + && Boolean(process.env.HARBOR_ADMIN) + && Boolean(process.env.HARBOR_ADMIN_PASSWORD) + && Boolean(process.env.KEYCLOAK_DOMAIN) + && Boolean(process.env.KEYCLOAK_REALM) + && Boolean(process.env.KEYCLOAK_PROTOCOL) + && Boolean(process.env.NEXUS_URL) + && Boolean(process.env.NEXUS_ADMIN) + && Boolean(process.env.NEXUS_ADMIN_PASSWORD) + && Boolean(process.env.SONARQUBE_URL) + && Boolean(process.env.SONAR_API_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithServices = describe.runIf(canRunServicesE2E) + +const PLUGIN_NAME = 'gitlab' + +describeWithServices('ProjectServicesService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectServicesService + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectServicesModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectServicesService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.projectPlugin.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('get returns services for a project member', async () => { + const services = await service.get(projectId, 'user') + const gitlabService = services.find(entry => entry.name === PLUGIN_NAME) + + expect(gitlabService).toBeTruthy() + expect(gitlabService?.name).toBe(PLUGIN_NAME) + }) + + it('rejects get when project does not exist', async () => { + await expect(service.get(faker.string.uuid(), 'user')).rejects.toThrow(NotFoundException) + }) + + it('update stores project configuration', async () => { + await service.update(projectId, { + gitlab: { + enabled: 'enabled', + }, + }, ['user']) + + const stored = await prisma.projectPlugin.findUniqueOrThrow({ + where: { + projectId_pluginName_key: { + projectId, + pluginName: PLUGIN_NAME, + key: 'user.enabled', + }, + }, + select: { + value: true, + }, + }) + + expect(stored.value).toBe('enabled') + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/test/project.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/project.e2e-spec.ts new file mode 100644 index 0000000000..0aa6b9011d --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/project.e2e-spec.ts @@ -0,0 +1,153 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ForbiddenException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectPermissionModule } from '../src/modules/infrastructure/permission/project/project.module' +import { makeCreateProjectBody } from '../src/modules/project/project-testing.utils' +import { ProjectService } from '../src/modules/project/project.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProject = describe.runIf(canRunProjectE2E) + +describeWithProject('ProjectService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectService + let eventEmitter: DeepMockProxy + + let ownerId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectPermissionModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('ProjectService.create', async () => { + const createBody = makeCreateProjectBody({ + name: faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`), + description: 'Initial description', + }) + const created = await service.create(createBody, ownerId) + await prisma.project.deleteMany({ where: { id: created.id } }).catch(() => {}) + expect(created.id).toBeTruthy() + }) + + it('rejects ProjectService.list filter=all for non-admin', async () => { + await expect( + service.listProjects({ filter: 'all' }, { userId: ownerId, adminPermissions: 0n }), + ).rejects.toThrow(ForbiddenException) + }) + + it('rejects ProjectService.get when project does not exist', async () => { + await expect(service.get(faker.string.uuid())).rejects.toThrow(NotFoundException) + }) + + describe('with project', () => { + let projectId: string + let projectName: string + + beforeEach(async () => { + eventEmitter.emitAsync.mockClear() + + const createBody = makeCreateProjectBody({ + name: faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`), + description: 'Initial description', + }) + + const created = await service.create(createBody, ownerId) + projectId = created.id + projectName = created.name + + eventEmitter.emitAsync.mockClear() + }) + + afterEach(async () => { + await prisma.projectMembers.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + }) + + it('ProjectService.get', async () => { + const fetched = await service.get(projectId) + expect(fetched.id).toBe(projectId) + expect(fetched.ownerId).toBe(ownerId) + }) + + it('ProjectService.list', async () => { + const listResult = await service.listProjects({ filter: 'member' }, { userId: ownerId, adminPermissions: 0n }) + expect(listResult.some(p => p.id === projectId)).toBe(true) + }) + + it('ProjectService.getData', async () => { + const dataExport = await service.getProjectsData() + expect(Array.isArray(dataExport)).toBe(true) + expect(dataExport.some(p => p.name === projectName)).toBe(true) + }) + + it('ProjectService.update', async () => { + const updated = await service.update( + { description: 'Updated description' }, + { userId: ownerId, adminPermissions: 0n }, + projectId, + ) + expect(updated.description).toBe('Updated description') + }) + + it('ProjectService.archive', async () => { + await service.archive(projectId) + const archived = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { status: true, locked: true } }) + expect(archived.status).toBe('archived') + expect(archived.locked).toBe(true) + }) + + it('rejects ProjectService.update when project is locked', async () => { + await prisma.project.update({ where: { id: projectId }, data: { locked: true } }) + await expect( + service.update({ description: 'nope' }, { userId: ownerId, adminPermissions: 0n }, projectId), + ).rejects.toThrow(ForbiddenException) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/test/registry.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/registry.e2e-spec.ts new file mode 100644 index 0000000000..2fdf921814 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/registry.e2e-spec.ts @@ -0,0 +1,92 @@ +import type { ConfigType } from '@nestjs/config' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { harborConfigFactory } from '../src/config/harbor.config' +import { RegistryClientService } from '../src/modules/registry/registry-client.service' +import { makeProjectWithDetails } from '../src/modules/registry/registry-testing.utils' +import { ROBOT_NAME_PROJECT, ROBOT_NAME_RO, ROBOT_NAME_RW } from '../src/modules/registry/registry.constants' +import { RegistryModule } from '../src/modules/registry/registry.module' +import { RegistryService } from '../src/modules/registry/registry.service' +import { getHostFromUrl, getProjectVaultPath } from '../src/modules/registry/registry.utils' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' +import { getAll } from '../src/utils/iterable.utils' + +const canRunRegistryE2E + = Boolean(process.env.E2E) + && Boolean(process.env.HARBOR_URL) + && Boolean(process.env.HARBOR_ADMIN) + && Boolean(process.env.HARBOR_ADMIN_PASSWORD) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithRegistry = describe.runIf(canRunRegistryE2E) + +describeWithRegistry('RegistryService (e2e)', () => { + let moduleRef: TestingModule + let registry: RegistryService + let client: RegistryClientService + let vault: VaultClientService + let config: BaseConfig + let harborConfig: ConfigType + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory, harborConfigFactory] }), RegistryModule], + }) + .compile() + + await moduleRef.init() + + registry = moduleRef.get(RegistryService) + client = moduleRef.get(RegistryClientService) + vault = moduleRef.get(VaultClientService) + config = moduleRef.get(baseConfigFactory) + harborConfig = moduleRef.get(harborConfigFactory) + + projectSlug = faker.helpers.slugify(`test-project-${faker.string.alphanumeric({ length: 10 }).toLowerCase()}`).slice(0, 50) + }) + + afterAll(async () => { + if (vault && config && projectSlug) { + const paths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectsRootDir, `REGISTRY/${name}`)) + await Promise.all(paths.map(path => vault.delete(path).catch(() => {}))) + } + + if (registry && projectSlug) { + await registry.deleteProject(projectSlug).catch(() => {}) + } + + await moduleRef?.close() + }) + + it('should provision project in Harbor and write robot secrets to Vault', async () => { + const result = await registry.ensureProject({ slug: projectSlug, plugins: [] }, { publishProjectRobot: true }) + expect(result.basePath).toBe(`${getHostFromUrl(harborConfig.url)}/${projectSlug}/`) + + const project = await client.getProjectByName(projectSlug) + expect(project.status).toBe(200) + + const robots = await getAll(client.getProjectRobots(Number(project.data?.project_id))) + const robotNames = robots.flatMap(r => r.name ? [r.name] : []) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RO}`) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RW}`) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) + + const vaultPaths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectsRootDir, `REGISTRY/${name}`)) + const [roSecret, rwSecret, projectSecret] = await Promise.all(vaultPaths.map(path => vault.read(path))) + expect(roSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(rwSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(projectSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(roSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RO}`) + expect(rwSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RW}`) + expect(projectSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) + }) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/test/sonarqube.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/sonarqube.e2e-spec.ts new file mode 100644 index 0000000000..b8037965ef --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/sonarqube.e2e-spec.ts @@ -0,0 +1,183 @@ +import type { TestingModule } from '@nestjs/testing' +import { generateProjectKey } from '@cpn-console/hooks' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { SonarqubeClientService } from '../src/modules/sonarqube/sonarqube-client.service' +import { projectSelect } from '../src/modules/sonarqube/sonarqube-datastore.service' +import { makeProjectWithDetails } from '../src/modules/sonarqube/sonarqube-testing.utils' +import { SonarqubeModule } from '../src/modules/sonarqube/sonarqube.module' +import { SonarqubeService } from '../src/modules/sonarqube/sonarqube.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunSonarqubeE2E + = Boolean(process.env.E2E) + && Boolean(process.env.SONARQUBE_URL) + && Boolean(process.env.SONAR_API_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithSonarqube = describe.runIf(canRunSonarqubeE2E) + +describeWithSonarqube('SonarqubeService (e2e)', () => { + let moduleRef: TestingModule + let sonarqubeService: SonarqubeService + let sonarqubeClient: SonarqubeClientService + let vaultService: VaultClientService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + let testRepoName: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [SonarqubeModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + sonarqubeService = moduleRef.get(SonarqubeService) + sonarqubeClient = moduleRef.get(SonarqubeClientService) + vaultService = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-sonar-${faker.string.alphanumeric(8).toLowerCase()}`) + testRepoName = faker.helpers.slugify(`test-sonar-${faker.string.alphanumeric(8).toLowerCase()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (sonarqubeService && testProjectSlug) { + await sonarqubeService.handleDelete( + makeProjectWithDetails({ slug: testProjectSlug, repositories: [] }), + ).catch(() => {}) + } + + if (prisma) { + await prisma.repository.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should create platform groups during initialization', async () => { + // init() is triggered by moduleRef.init() via onModuleInit — groups must already exist + const [adminResult, readonlyResult, securityResult] = await Promise.all([ + sonarqubeClient.searchUserGroup({ q: '/console/admin' }), + sonarqubeClient.searchUserGroup({ q: '/console/readonly' }), + sonarqubeClient.searchUserGroup({ q: '/console/security' }), + ]) + + expect(adminResult.groups.some(g => g.name === '/console/admin')).toBe(true) + expect(readonlyResult.groups.some(g => g.name === '/console/readonly')).toBe(true) + expect(securityResult.groups.some(g => g.name === '/console/security')).toBe(true) + }) + + it('should reconcile project in SonarQube (groups, user, repository, vault secret)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E SonarQube Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + await prisma.repository.create({ + data: { + projectId: testProjectId, + internalRepoName: testRepoName, + isPrivate: false, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await sonarqubeService.handleUpsert(project) + + // All 5 project role groups should exist in SonarQube + const projectGroupNames = [ + `/${testProjectSlug}/console/admin`, + `/${testProjectSlug}/console/devops`, + `/${testProjectSlug}/console/developer`, + `/${testProjectSlug}/console/security`, + `/${testProjectSlug}/console/readonly`, + ] + for (const groupName of projectGroupNames) { + const result = await sonarqubeClient.searchUserGroup({ q: groupName }) + expect(result.groups.some(g => g.name === groupName), `group ${groupName} should exist`).toBe(true) + } + + // Robot/CI user should exist + const usersResult = await sonarqubeClient.searchUsers({ q: testProjectSlug }) + expect(usersResult.users.some(u => u.login === testProjectSlug)).toBe(true) + + // SonarQube analysis project for the repository should exist + const projectKey = generateProjectKey(testProjectSlug, testRepoName) + const projectsResult = await sonarqubeClient.searchProject({ q: testProjectSlug }) + expect(projectsResult.components.some(p => p.key === projectKey)).toBe(true) + + // Vault credentials should be written with correct username and token + const vaultSecret = await vaultService.readSonarqubeUser(testProjectSlug) + expect(vaultSecret?.data?.SONAR_USERNAME).toBe(testProjectSlug) + expect(vaultSecret?.data?.SONAR_TOKEN).toBeTruthy() + expect(vaultSecret?.data?.SONAR_PASSWORD).toBeTruthy() + }, 30000) + + it('should delete the project from SonarQube and remove vault credentials', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await sonarqubeService.handleDelete(project) + + // SonarQube analysis project should be removed + const projectKey = generateProjectKey(testProjectSlug, testRepoName) + const projectsResult = await sonarqubeClient.searchProject({ q: testProjectSlug }) + expect(projectsResult.components.some(p => p.key === projectKey)).toBe(false) + + // Vault credentials should be removed + const vaultSecret = await vaultService.readSonarqubeUser(testProjectSlug) + expect(vaultSecret).toBeNull() + }, 30000) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/test/vault.e2e-spec.ts b/.jjconflict-base-0/apps/server-nestjs/test/vault.e2e-spec.ts new file mode 100644 index 0000000000..6947cb9b53 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/test/vault.e2e-spec.ts @@ -0,0 +1,108 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { projectSelect } from '../src/modules/vault/vault-datastore.service' +import { makeProjectWithDetails } from '../src/modules/vault/vault-testing.utils' +import { VaultModule } from '../src/modules/vault/vault.module' +import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunVaultE2E + = Boolean(process.env.E2E) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithVault = describe.runIf(canRunVaultE2E) + +describeWithVault('VaultController (e2e)', () => { + let moduleRef: TestingModule + let vaultController: VaultService + let vaultClient: VaultClientService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + vaultController = moduleRef.get(VaultService) + vaultClient = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (testProjectSlug) { + await vaultController.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('should reconcile project in Vault (mount, group, role)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await vaultController.handleUpsert(project) + + const group = await vaultClient.getIdentityGroupName(testProjectSlug) + expect(group.data?.id).toBeTruthy() + expect(group.data?.name).toBe(testProjectSlug) + expect(group.data?.alias?.name).toBe(`/${testProjectSlug}`) + }, 180000) +}) diff --git a/.jjconflict-base-0/apps/server-nestjs/tsconfig.build.json b/.jjconflict-base-0/apps/server-nestjs/tsconfig.build.json new file mode 100644 index 0000000000..64f86c6bd2 --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/.jjconflict-base-0/apps/server-nestjs/tsconfig.json b/.jjconflict-base-0/apps/server-nestjs/tsconfig.json new file mode 100644 index 0000000000..48b6403d6f --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "incremental": true, + "target": "ES2023", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "baseUrl": "./", + "module": "nodenext", + "moduleResolution": "nodenext", + "resolvePackageJsonExports": true, + "strictBindCallApply": false, + "strictNullChecks": true, + "noFallthroughCasesInSwitch": false, + "noImplicitAny": false, + "declaration": true, + "outDir": "./dist", + "removeComments": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + } +} diff --git a/.jjconflict-base-0/apps/server-nestjs/vitest.config.ts b/.jjconflict-base-0/apps/server-nestjs/vitest.config.ts new file mode 100644 index 0000000000..b0337f6d0b --- /dev/null +++ b/.jjconflict-base-0/apps/server-nestjs/vitest.config.ts @@ -0,0 +1,22 @@ +import path from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: [ + { find: '@cpn-console/logger/hooks', replacement: path.resolve(__dirname, '../../packages/logger/src/hooks.ts') }, + { find: '@cpn-console/logger', replacement: path.resolve(__dirname, '../../packages/logger/src/index.ts') }, + { find: '@cpn-console/shared', replacement: path.resolve(__dirname, '../../packages/shared/src/index.ts') }, + { find: '@cpn-console/hooks', replacement: path.resolve(__dirname, '../../packages/hooks/src/index.ts') }, + ], + }, + test: { + globals: true, + environment: 'node', + include: ['src/**/*.spec.ts', 'test/**/*.e2e-spec.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}) diff --git a/.jjconflict-base-0/apps/server/.env-example b/.jjconflict-base-0/apps/server/.env-example new file mode 100644 index 0000000000..e26aaeea95 --- /dev/null +++ b/.jjconflict-base-0/apps/server/.env-example @@ -0,0 +1,46 @@ +# Niveau de log +LOG_LEVEL=debug +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 + +# Port d'écoute du serveur backend +SERVER_PORT=4001 +# URL de connexion à la base de données PostgreSQL (format Prisma) +DB_URL=postgresql://admin:admin@localhost:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +OPENCDS_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true + +# Dossier de stockage des plugins externes +EXTERNAL_PLUGINS_DIR_HOST_PATH=/home/stephane/projets/clients/cloud-pi/plugins +# URL du dashboard d'observabilité (géré par Grafana) +GRAFANA_URL= +# Version du chart "observability" à déployer pour les projets +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 +# Inutilisé ? +# SONARQUBE_INTERNAL_URL= diff --git a/.jjconflict-base-0/apps/server/.env.docker-example b/.jjconflict-base-0/apps/server/.env.docker-example new file mode 100644 index 0000000000..571be2ff45 --- /dev/null +++ b/.jjconflict-base-0/apps/server/.env.docker-example @@ -0,0 +1,31 @@ +# Niveau de log +LOG_LEVEL=debug +# Indique que l'application tourne dans un conteneur Docker +DOCKER=true +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine du Keycloak (nom du service Docker Compose + port interne) +KEYCLOAK_DOMAIN=keycloak:8080 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http en Docker local) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 + +# Port d'écoute du serveur dans le réseau Docker +SERVER_PORT=8080 +# URL de connexion PostgreSQL (utilise le nom de service Docker 'postgres') +DB_URL=postgresql://admin:admin@postgres:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-base-0/apps/server/.env.integ-example b/.jjconflict-base-0/apps/server/.env.integ-example new file mode 100644 index 0000000000..1bcb5f1565 --- /dev/null +++ b/.jjconflict-base-0/apps/server/.env.integ-example @@ -0,0 +1,108 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Désactive l'initialisation des données de démo (l'intégration a ses propres données) +DEV_SETUP="false" +# Active le mode intégration (charge ce fichier en surcharge) +INTEGRATION=true + +# --- Keycloak --- +# Protocole (généralement https en intégration) +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= +# Secret du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_SECRET= +# Domaine du Keycloak d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak d'intégration +KEYCLOAK_REALM= + +# --- ArgoCD --- +# Namespace Kubernetes dans lequel ArgoCD est déployé +ARGO_NAMESPACE= +# URL de l'API ArgoCD +ARGOCD_URL= +# URL interne de l'API ArgoCD +ARGOCD_INTERNAL_URL= + +# --- GitLab --- +# Token d'accès personnel GitLab (scope api) +GITLAB_TOKEN= +# URL de l'instance GitLab +GITLAB_URL= +# URL interne de l'API Gitlab +GITLAB_INTERNAL_URL= + +# --- Harbor --- +# Nom d'utilisateur admin Harbor +HARBOR_ADMIN= +# Mot de passe admin Harbor +HARBOR_ADMIN_PASSWORD= +# URL de l'instance Harbor +HARBOR_URL= +# URL interne de l'API Harbor +HARBOR_INTERNAL_URL= + +# --- Keycloak (admin) --- +# Utilisateur admin Keycloak pour la gestion des royaumes/clients +KEYCLOAK_ADMIN= +# Mot de passe admin Keycloak +KEYCLOAK_ADMIN_PASSWORD= +# URL complète de Keycloak (peut différer de KEYCLOAK_DOMAIN) +KEYCLOAK_URL= + +# --- Nexus --- +# Nom d'utilisateur admin Nexus +NEXUS_ADMIN= +# Mot de passe admin Nexus +NEXUS_ADMIN_PASSWORD= +# URL de l'instance Nexus +NEXUS_URL= +# URL interne de l'API Nexus +NEXUS_INTERNAL_URL= + +# --- Divers --- +# Répertoire racine des projets dans Gitlab +PROJECTS_ROOT_DIR= + +# --- SonarQube --- +# Token d'API SonarQube +SONAR_API_TOKEN= +# URL de l'instance SonarQube +SONARQUBE_URL= +# URL interne de l'API SonarQube +SONARQUBE_INTERNAL_URL= + +# --- Vault --- +# Token d'authentification Vault +VAULT_TOKEN= +# URL de l'instance Vault +VAULT_URL= +# URL interne de l'API Vault +VAULT_INTERNAL_URL= + +# --- Kubeconfig --- +# Chemin vers le kubeconfig sur la machine hôte (monté en volume dans Docker) +KUBECONFIG_HOST_PATH= +# Chemin vers le kubeconfig à l'intérieur du conteneur +KUBECONFIG_PATH=$HOME/.kube/config +# Contexte Kubernetes à utiliser +KUBECONFIG_CTX= + +# Répertoire contenant les plugins externes (monté en volume dans Docker) +EXTERNAL_PLUGINS_DIR_HOST_PATH=/path/to/plugins + +# Informations pour le plugin Observabilité +GRAFANA_URL= +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 + +# --- Configuration proxy (si nécessaire pour accéder aux services externes) --- +# HTTP_PROXY= +# HTTPS_PROXY= +# NO_PROXY= + +# Configuration OpenCDS +OPENCDS_URL= +OPENCDS_API_TOKEN=token +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false diff --git a/.jjconflict-base-0/apps/server/Dockerfile b/.jjconflict-base-0/apps/server/Dockerfile new file mode 100644 index 0000000000..47ca58e741 --- /dev/null +++ b/.jjconflict-base-0/apps/server/Dockerfile @@ -0,0 +1,66 @@ +# Base stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Install pnpm version defined in package.json "packageManager" property +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY --chown=node:root apps/server/package.json ./apps/server/package.json + +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +COPY --chown=node:root plugins/argocd/package.json ./plugins/argocd/package.json +COPY --chown=node:root plugins/gitlab/package.json ./plugins/gitlab/package.json +COPY --chown=node:root plugins/harbor/package.json ./plugins/harbor/package.json +COPY --chown=node:root plugins/keycloak/package.json ./plugins/keycloak/package.json +COPY --chown=node:root plugins/nexus/package.json ./plugins/nexus/package.json +COPY --chown=node:root plugins/sonarqube/package.json ./plugins/sonarqube/package.json +COPY --chown=node:root plugins/vault/package.json ./plugins/vault/package.json + +RUN pnpm install --frozen-lockfile +COPY --chown=node:root plugins/ ./plugins/ +COPY --chown=node:root packages/ ./packages/ + +COPY --chown=node:root apps/server/ ./apps/server/ +# Generate Prisma client +RUN pnpm --filter server run db:generate +ENTRYPOINT [ "pnpm", "--filter", "server", "run" ] +CMD [ "dev" ] + + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +# Build @cpn-console/server console-related dependencies +RUN pnpm run build +# Build @cpn-console/server +RUN pnpm --filter @cpn-console/server run build +# Export @cpn-console/server to target build directory +RUN pnpm --filter @cpn-console/server --prod deploy build + + +# Prod stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS prod + +ARG APP_VERSION +ENV APP_VERSION=$APP_VERSION + +VOLUME [ "/plugins" ] +WORKDIR /app +RUN mkdir -p /home/node/logs && chmod 770 -R /home/node/logs \ + && mkdir -p /home/node/.npm && chmod 770 -R /home/node/.npm \ + && chown node:root /app +COPY --chown=node:root --from=build /app/build . +RUN npm run db:generate +USER node +EXPOSE 8080 +ENTRYPOINT ["npm", "start"] diff --git a/.jjconflict-base-0/apps/server/README.md b/.jjconflict-base-0/apps/server/README.md new file mode 100644 index 0000000000..37b89ac99b --- /dev/null +++ b/.jjconflict-base-0/apps/server/README.md @@ -0,0 +1,39 @@ +# Console Cloud π Native - Serveur + +## Installation + +```sh +npm install +``` + +## Lancement de l'app + +```sh +npm run dev +``` + +## Lancement des tests unitaires + +```sh +npm run test +``` + +## Formattage du code + +```sh +# Lister les problèmes de formatage +npm run lint + +# Régler automatiquement les problèmes de formatage +npm run format +``` + +## Lancement de l'app en mode production + +```sh +npm start +``` + +## Activation OpenCDS + +Se référer à [la documentation dédiée](../../packages/opencds/README.adoc) diff --git a/.jjconflict-base-0/apps/server/eslint.config.js b/.jjconflict-base-0/apps/server/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-0/apps/server/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-0/apps/server/nodemon.json b/.jjconflict-base-0/apps/server/nodemon.json new file mode 100644 index 0000000000..a83d0540d3 --- /dev/null +++ b/.jjconflict-base-0/apps/server/nodemon.json @@ -0,0 +1,4 @@ +{ + "watch": ["server.ts", "src/"], + "ext": "js, ts" +} diff --git a/.jjconflict-base-0/apps/server/package.json b/.jjconflict-base-0/apps/server/package.json new file mode 100644 index 0000000000..3096b8ea59 --- /dev/null +++ b/.jjconflict-base-0/apps/server/package.json @@ -0,0 +1,109 @@ +{ + "name": "@cpn-console/server", + "type": "module", + "version": "9.22.1", + "description": "cpn-console server", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/bootstrap.js", + "module": "dist/bootstrap.js", + "types": "types/bootstrap.d.ts", + "files": [ + "dist", + "prisma.config.ts", + "src" + ], + "scripts": { + "prebuild": "pnpm i --frozen-lockfile", + "build": "tspc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "db:deploy": "prisma migrate deploy", + "db:diff": "prisma migrate diff", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev --name dso", + "db:reset": "prisma migrate reset", + "predev": "pnpm run db:deploy", + "dev": "nodemon --exec 'vite-node src/bootstrap.ts'", + "predebug": "pnpm run db:migrate", + "debug": "nodemon --exec 'NODE_OPTIONS=\"--inspect-brk=0.0.0.0\" vite-node src/bootstrap.ts'", + "format": "eslint ./ --fix", + "preinteg": "INTEGRATION=true pnpm run db:migrate", + "integ": "INTEGRATION=true nodemon --exec 'vite-node src/bootstrap.ts'", + "lint": "eslint ./", + "prepublishOnly": "pnpm run db:generate", + "prestart": "npm run db:deploy", + "start": "node --enable-source-maps dist/bootstrap.js", + "pretest": "pnpm run db:generate", + "test": "vitest run", + "pretest:cov": "pnpm run db:generate", + "test:cov": "vitest run --coverage", + "test:e2e": "pnpm run dev", + "test:e2e-ci": "pnpm run start" + }, + "dependencies": { + "@cpn-console/argocd-plugin": "workspace:^", + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/harbor-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/nexus-plugin": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/sonarqube-plugin": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@fastify/cookie": "catalog:runtime", + "@fastify/helmet": "catalog:runtime", + "@fastify/otel": "catalog:runtime", + "@fastify/session": "catalog:runtime", + "@fastify/swagger": "catalog:runtime", + "@fastify/swagger-ui": "catalog:runtime", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "@kubernetes-models/argo-cd": "catalog:runtime", + "@opentelemetry/auto-instrumentations-node": "catalog:otel", + "@opentelemetry/exporter-metrics-otlp-proto": "catalog:otel", + "@opentelemetry/exporter-trace-otlp-proto": "catalog:otel", + "@opentelemetry/instrumentation-nestjs-core": "catalog:otel", + "@opentelemetry/instrumentation-pino": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-node": "catalog:otel", + "@prisma/client": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@ts-rest/fastify": "catalog:runtime", + "@ts-rest/open-api": "catalog:runtime", + "axios": "catalog:runtime", + "date-fns": "catalog:runtime", + "fastify": "catalog:runtime", + "fastify-keycloak-adapter": "catalog:runtime", + "json-2-csv": "catalog:runtime", + "mustache": "catalog:runtime", + "prisma": "catalog:runtime", + "undici": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "fastify-plugin": "catalog:build", + "nodemon": "catalog:tools", + "pino-pretty": "catalog:tools", + "rimraf": "catalog:tools", + "ts-patch": "catalog:build", + "typescript": "catalog:build", + "typescript-transform-paths": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/apps/server/prisma.config.ts b/.jjconflict-base-0/apps/server/prisma.config.ts new file mode 100644 index 0000000000..ff5aaf9186 --- /dev/null +++ b/.jjconflict-base-0/apps/server/prisma.config.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' +import { defineConfig } from 'prisma/config' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +export default defineConfig({ + schema: path.join('src', 'prisma', 'schema'), + migrations: { + path: path.join('src', 'prisma', 'migrations'), + }, +}) diff --git a/.jjconflict-base-0/apps/server/src/__mocks__/prisma.ts b/.jjconflict-base-0/apps/server/src/__mocks__/prisma.ts new file mode 100644 index 0000000000..075578c96c --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/__mocks__/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma.js') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-base-0/apps/server/src/__mocks__/utils/hook-wrapper.ts b/.jjconflict-base-0/apps/server/src/__mocks__/utils/hook-wrapper.ts new file mode 100644 index 0000000000..f7fdd811a2 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/__mocks__/utils/hook-wrapper.ts @@ -0,0 +1,42 @@ +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../utils/hook-wrapper.ts') + +export const hook = { + adminRole: { + delete: vi.fn(), + upsert: vi.fn(), + }, + cluster: { + delete: vi.fn(), + upsert: vi.fn(), + }, + misc: { + checkServices: vi.fn(), + syncRepository: vi.fn(), + }, + project: { + upsert: vi.fn(), + delete: vi.fn(), + getSecrets: vi.fn(), + }, + projectRole: { + upsert: vi.fn(), + delete: vi.fn(), + }, + user: { + retrieveUserByEmail: vi.fn(), + }, + zone: { + delete: vi.fn(), + upsert: vi.fn(), + }, +} as const + +const hookMock = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(hookMock) +}) diff --git a/.jjconflict-base-0/apps/server/src/app.spec.ts b/.jjconflict-base-0/apps/server/src/app.spec.ts new file mode 100644 index 0000000000..e540174ed4 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/app.spec.ts @@ -0,0 +1,21 @@ +import { apiPrefix } from '@cpn-console/shared' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import app from './app.js' +import { getRandomRequestor, setRequestor } from './utils/mocks.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) + +describe('app', () => { + beforeEach(() => { + setRequestor(getRandomRequestor()) + }) + afterAll(async () => { + await app.close() + }) + + it('should respond 404 on unknown route', async () => { + const response = await app.inject() + .get(`${apiPrefix}/miss`) + expect(response.statusCode).toBe(404) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/app.ts b/.jjconflict-base-0/apps/server/src/app.ts new file mode 100644 index 0000000000..996db320fa --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/app.ts @@ -0,0 +1,63 @@ +import type { FastifyRequest } from 'fastify' +import { apiPrefix, getContract } from '@cpn-console/shared' +import fastifyCookie from '@fastify/cookie' +import helmet from '@fastify/helmet' +import fastifySession from '@fastify/session' +import fastifySwagger from '@fastify/swagger' +import fastifySwaggerUi from '@fastify/swagger-ui' +import { initServer } from '@ts-rest/fastify' +import { generateOpenApi } from '@ts-rest/open-api' +import fastify from 'fastify' +import keycloak from 'fastify-keycloak-adapter' +import { apiRouter } from './resources/index.js' +import { isDev, isInt, isTest } from './utils/env.js' +import { fastifyConf, swaggerConf, swaggerUiConf } from './utils/fastify.js' +import { keycloakConf, sessionConf } from './utils/keycloak.js' + +export const serverInstance: ReturnType = initServer() + +const openApiDocument = generateOpenApi(await getContract(), swaggerConf, { + setOperationId: true, +}) + +const app = fastify(fastifyConf) + .register(helmet, () => ({ + contentSecurityPolicy: !(isInt || isDev || isTest), + })) + .register(fastifyCookie) + .register(fastifySession, sessionConf) + // @ts-ignore + .register(keycloak, keycloakConf) + .register(fastifySwagger, { transformObject: () => openApiDocument }) + .register(fastifySwaggerUi, swaggerUiConf) + .register(apiRouter()) + .addHook('onRoute', (opts) => { + if (opts.path === `${apiPrefix}/healthz`) { + opts.logLevel = 'silent' + } + }) + .setErrorHandler((err: Error, req: FastifyRequest, reply) => { + const statusCode = 500 + // @ts-ignore vérifier l'objet + const message = err.description || err.message + reply + .status(statusCode) + .send({ status: statusCode, error: message, stack: err.stack }) + req.log.error({ err, reqId: req.id }, 'Unhandled request error') + }) + .addHook('onResponse', (req, res) => { + const user = req.session?.user as { + id: string + } + if (res.statusCode < 400) { + req.log.info({ status: res.statusCode, userId: user?.id }) + } else if (res.statusCode < 500) { + req.log.warn({ status: res.statusCode, userId: user?.id }) + } else { + req.log.error({ status: res.statusCode, userId: user?.id }) + } + }) + +await app.ready() + +export default app diff --git a/.jjconflict-base-0/apps/server/src/bootstrap.ts b/.jjconflict-base-0/apps/server/src/bootstrap.ts new file mode 100644 index 0000000000..83c833f007 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/bootstrap.ts @@ -0,0 +1,3 @@ +import './runtime.js' +import './telemetry.js' +import './server.js' diff --git a/.jjconflict-base-0/apps/server/src/connect.spec.ts b/.jjconflict-base-0/apps/server/src/connect.spec.ts new file mode 100644 index 0000000000..363c89d290 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/connect.spec.ts @@ -0,0 +1,86 @@ +import { logger } from '@cpn-console/logger' +import { PrismaClientInitializationError } from '@prisma/client/runtime/library.js' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from './__mocks__/prisma.js' +import app from './app.js' +import { getConnection } from './connect.js' + +vi.mock( + 'fastify-keycloak-adapter', + (await import('./utils/mocks.js')).mockSessionPlugin, +) +vi.mock('@/resources/queries-index.js') +vi.mock('./models/log.js', () => getModel('getLogModel')) +vi.mock('./models/repository.js', () => getModel('getRepositoryModel')) +vi.mock('./models/permission.js', () => getModel('getPermissionModel')) +vi.mock('./models/environment.js', () => getModel('getEnvironmentModel')) +vi.mock('./models/project.js', () => getModel('getProjectModel')) +vi.mock('./models/user.js', () => getModel('getUserModel')) +vi.mock('./models/users-projects.js', () => getModel('getRolesModel')) +vi.mock('./models/zone.js', () => getModel('getZoneModel')) +vi.mock('./prisma.js') +vi.mock('./utils/env.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isTest: true, + } +}) + +vi.spyOn(app, 'listen') +const infoSpy = vi.spyOn(logger, 'info') +const errorSpy = vi.spyOn(logger, 'error') +const debugSpy = vi.spyOn(logger, 'debug') + +function getModel(modelName: string) { + return { + [modelName]: vi.fn(() => ({ + sync: vi.fn(), + hasMany: vi.fn(), + belongsTo: vi.fn(), + belongsToMany: vi.fn(), + })), + } +} + +describe('connect', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should connect to postgres', async () => { + await getConnection() + + expect(debugSpy.mock.calls).toHaveLength(1) + expect(debugSpy.mock.calls[0]?.[1]).toBe('Connecting to Postgres') + + expect(infoSpy.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toContainEqual(['Connected to Postgres!']) + }) + + it( + 'should fail to connect once, then connect to postgres', + { timeout: 10_000 }, + async () => { + const errorToCatch = new PrismaClientInitializationError( + 'Failed to connect', + '2.19.0', + 'P1001', + ) + + prisma.$connect.mockRejectedValueOnce(errorToCatch) + await getConnection() + + expect(errorSpy.mock.calls).toHaveLength(1) + expect(errorSpy.mock.calls[0]?.[1]).toBe('Could not connect to Postgres') + + expect(infoSpy.mock.calls).toHaveLength(2) + expect(infoSpy.mock.calls[0]?.[1]).toBe('Retrying Postgres connection') + expect(infoSpy.mock.calls).toContainEqual(['Connected to Postgres!']) + + expect(debugSpy.mock.calls).toHaveLength(2) + expect(debugSpy.mock.calls[0]?.[1]).toBe('Connecting to Postgres') + expect(debugSpy.mock.calls[1]?.[1]).toBe('Connecting to Postgres') + }, + ) +}) diff --git a/.jjconflict-base-0/apps/server/src/connect.ts b/.jjconflict-base-0/apps/server/src/connect.ts new file mode 100644 index 0000000000..31d608a66e --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/connect.ts @@ -0,0 +1,63 @@ +import { setTimeout } from 'node:timers/promises' +import { logger } from '@cpn-console/logger' +import prisma from './prisma.js' +import { + dbUrl, + isCI, + isDev, + isTest, +} from './utils/env.js' + +const DELAY_BEFORE_RETRY = isTest ? 0 : isCI ? 1000 : 10000 +let closingConnections = false +const leadingSlashRegExp = /^\// + +function parseDbUrl(url: string | undefined) { + if (!url) return undefined + try { + const parsed = new URL(url) + return { + host: parsed.host, + database: parsed.pathname?.replace(leadingSlashRegExp, '') || undefined, + } + } catch { + return undefined + } +} + +export async function getConnection(triesLeft = 5): Promise { + if (closingConnections || triesLeft <= 0) { + throw new Error('Unable to connect to Postgres server') + } + triesLeft-- + + try { + if (isDev || isTest || isCI) + logger.debug({ db: parseDbUrl(dbUrl) }, 'Connecting to Postgres') + await prisma.$connect() + + logger.info('Connected to Postgres!') + } catch (err) { + if (triesLeft > 0) { + logger.error({ err }, 'Could not connect to Postgres') + logger.info({ triesLeft }, 'Retrying Postgres connection') + await setTimeout(DELAY_BEFORE_RETRY) + return getConnection(triesLeft) + } + + logger.error({ err }, 'Out of retries connecting to Postgres') + err.message = `Out of retries connecting to Postgres, last error: ${err.message}` + throw err + } +} + +export async function closeConnections() { + closingConnections = true + try { + await prisma.$disconnect() + } catch (error) { + logger.error({ err: error }, 'Could not disconnect from Postgres') + } finally { + closingConnections = false + } +} diff --git a/.jjconflict-base-0/apps/server/src/init/db/dump.ts b/.jjconflict-base-0/apps/server/src/init/db/dump.ts new file mode 100644 index 0000000000..6709946605 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/init/db/dump.ts @@ -0,0 +1,28 @@ +// @ts-nocheck + +/** + * How to use ? + * npx vite-node src/init/db/dump.ts + * format ./data.ts with linter + * cut/paste to packages/test-utils/src/imports/data.ts + */ + +import { writeFileSync } from 'node:fs' +import { Prisma } from '@prisma/client' +import prisma from '@/prisma.js' +import { associations, manyToManyRelation, modelKeys, models, resourceListToDict } from './utils.js' + +const Models = resourceListToDict(Prisma.dmmf.datamodel.models) + +for (const modelKey of modelKeys) { + const modelDatas = await prisma[modelKey].findMany() + models[modelKey] = modelDatas +} +for (const [model, targetModel, relationKey] of manyToManyRelation) { + const modelKey = model.slice(0, 1).toLocaleLowerCase() + model.slice(1) + const modelDatas = await prisma[modelKey].findMany({ select: { [Models[model].id]: true, [relationKey]: { select: { [Models[targetModel].id]: true } } } }) + associations.push([modelKey, modelDatas]) +} +const a = JSON.stringify({ ...models, associations }, null, 2) + +writeFileSync('./data.ts', `export const data = ${a}`) diff --git a/.jjconflict-base-0/apps/server/src/init/db/index.ts b/.jjconflict-base-0/apps/server/src/init/db/index.ts new file mode 100644 index 0000000000..fe37b33281 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/init/db/index.ts @@ -0,0 +1,51 @@ +import { logger } from '@cpn-console/logger' +import prisma from '@/prisma.js' +import { modelKeys } from './utils.js' + +type ExtractKeysWithFields = { + [K in keyof T]: T[K] extends { fields: any } ? K : never +}[keyof T] + +type Models = ExtractKeysWithFields + +type Imports = Partial> & { + associations: [Models, any[]] +} + +export async function initDb(data: Imports) { + const dataStringified = JSON.stringify(data) + const dataParsed = JSON.parse(dataStringified, (key, value) => { + try { + if (['permissions', 'everyonePerms'].includes(key)) { + return BigInt(value.slice(0, value.length - 1)) + } + } catch { + return value + } + return value + }) + logger.info('Drop tables') + for (const modelKey of modelKeys.toReversed()) { + // @ts-ignore + await prisma[modelKey].deleteMany() + } + logger.info('Import models') + for (const modelKey of modelKeys) { + // @ts-ignore + await prisma[modelKey].createMany({ data: dataParsed[modelKey] }) + } + logger.info('Import associations') + for (const [modelKey, rows] of dataParsed.associations) { + for (const row of rows) { + const idKey = 'id' + const connectKeys = Object.keys(row).filter(key => key !== idKey) + const dataConnects = connectKeys.reduce((acc, curr) => { + acc[curr] = { connect: row[curr] } + return acc + }, {} as Record) + // @ts-ignore + await prisma[modelKey].update({ where: { id: row.id }, data: dataConnects }) + } + } + logger.info('End import') +} diff --git a/.jjconflict-base-0/apps/server/src/init/db/utils.spec.ts b/.jjconflict-base-0/apps/server/src/init/db/utils.spec.ts new file mode 100644 index 0000000000..9b831ee089 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/init/db/utils.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { modelKeys, moveBefore, resourceListToDict } from './utils.js' + +vi.mock('fs', () => ({ writeFileSync: vi.fn() })) +for (const modelKey of modelKeys) { + prisma[modelKey].findMany.mockResolvedValue([]) +} + +describe('test moveBefore', () => { + it('should be moved', () => { + const arr = ['a', 'b', 'c'] + const arrSorted = moveBefore(arr, 'c', 'b') + expect(arrSorted).toEqual(['a', 'c', 'b']) + + const arrSorted2 = moveBefore(arr, 'c', 'a') + expect(arrSorted2).toEqual(['c', 'a', 'b']) + }) + it('should not be moved', () => { + const arr = ['a', 'b', 'c'] + const arrSorted = moveBefore(arr, 'b', 'c') + expect(arrSorted).toEqual(false) + + const arrSorted2 = moveBefore(arr, 'a', 'c') + expect(arrSorted2).toEqual(false) + + const arrSorted3 = moveBefore(arr, 'c', 'c') + expect(arrSorted3).toEqual(false) + }) +}) + +it('test resourceListToDict (by name)', () => { + const list = [ + { name: 'a', value: 1 }, + { name: 'b', value: 2 }, + { name: 'c', value: 3 }, + ] + const dict = resourceListToDict(list) + expect(dict).toEqual({ + a: { name: 'a', value: 1 }, + b: { name: 'b', value: 2 }, + c: { name: 'c', value: 3 }, + }) +}) + +it('stringify bigint', () => { + const list = { name: 'a', value: 1n } + + const dict = JSON.stringify(list) + + expect(dict).toEqual('{"name":"a","value":"1n"}') +}) diff --git a/.jjconflict-base-0/apps/server/src/init/db/utils.ts b/.jjconflict-base-0/apps/server/src/init/db/utils.ts new file mode 100644 index 0000000000..e4b941089f --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/init/db/utils.ts @@ -0,0 +1,85 @@ +// @ts-nocheck +import { Prisma } from '@prisma/client' + +// eslint-disable-next-line no-extend-native +BigInt.prototype.toJSON = function () { + return `${this.toString()}n` +} + +export type ResourceByName = Record +export function resourceListToDict(resList: Array): ResourceByName { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.name]: curr, + } + }, {} as ResourceByName) +} + +// @ts-ignore +const Models = resourceListToDict(Prisma.dmmf.datamodel.models) +let ModelsNames = Object.keys(Models) +let ModelsOrder = [...ModelsNames] + +export function moveBefore(arr: T, toMove: T[number], ref: T[number]): T | false { + const iref = arr.indexOf(ref) + const moveref = arr.indexOf(toMove) + if (moveref <= iref) return false + return [ + ...arr.slice(0, iref), + arr[moveref], + ...arr.slice(iref, moveref), + ...arr.slice(moveref + 1), + ] as T +} + +export const manyToManyRelation: [string, string, string][] = [] +function sort() { + let hasChanged = false + for (const model of ModelsNames) { + for (const field of Models[model].fields) { + if (field.isId) Models[model].id = field.name + if (field.type in Models) { + const relationField = Models[field.type].fields.find(({ type }) => type === model) + if (!relationField) throw new Error('unable to find matching model') + if ( + (relationField.isRequired && field.isRequired && !relationField.isList) + || (relationField.isRequired && !field.isRequired) + ) { + const moveRes = moveBefore(ModelsOrder, model, field.type) + if (moveRes) { + hasChanged = true + ModelsOrder = moveRes + } + } + if ( + field.isList && relationField.isList + && !manyToManyRelation.some(test => + (test[0] === model && test[1] === field.type) || (test[0] === field.type && test[1] === model)) + ) { + manyToManyRelation.push([model, field.type, field.name]) + } + } + } + } + ModelsNames = ModelsOrder + if (hasChanged) sort() +} + +sort() + +// special case to study +const logUserCase = moveBefore(ModelsOrder, 'User', 'Log') +if (logUserCase) { + ModelsOrder = logUserCase +} +const logProjectCase = moveBefore(ModelsOrder, 'Project', 'Log') +if (logProjectCase) { + ModelsOrder = logProjectCase +} + +export const models: Record = {} +export const associations: Record = [] +export const modelKeys = ModelsOrder.map(model => model.slice(0, 1).toLocaleLowerCase() + model.slice(1)) diff --git a/.jjconflict-base-0/apps/server/src/mocks/prisma.ts b/.jjconflict-base-0/apps/server/src/mocks/prisma.ts new file mode 100644 index 0000000000..075578c96c --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/mocks/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma.js') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-base-0/apps/server/src/mocks/utils.ts b/.jjconflict-base-0/apps/server/src/mocks/utils.ts new file mode 100644 index 0000000000..c57dd0f52b --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/mocks/utils.ts @@ -0,0 +1,24 @@ +import type { User } from '@cpn-console/test-utils' +import fp from 'fastify-plugin' + +let requestor: User + +export function setRequestor(user: User) { + requestor = user +} + +export function getRequestor() { + return requestor +} + +export async function mockSessionPlugin() { + const sessionPlugin = (app, opt, next) => { + app.addHook('onRequest', (req, res, next) => { + req.session = { user: getRequestor() } + next() + }) + next() + } + + return { default: fp(sessionPlugin) } +} diff --git a/.jjconflict-base-0/apps/server/src/plugins.ts b/.jjconflict-base-0/apps/server/src/plugins.ts new file mode 100644 index 0000000000..fcb54775f2 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/plugins.ts @@ -0,0 +1,47 @@ +import type { Plugin } from '@cpn-console/hooks' +import { readdirSync, statSync } from 'node:fs' +import { plugin as argo } from '@cpn-console/argocd-plugin' +import { plugin as gitlab } from '@cpn-console/gitlab-plugin' +import { plugin as harbor } from '@cpn-console/harbor-plugin' +import { pluginManager } from '@cpn-console/hooks' +import { plugin as keycloak } from '@cpn-console/keycloak-plugin' +import { logger as baseLogger } from '@cpn-console/logger' +import { plugin as nexus } from '@cpn-console/nexus-plugin' +import { plugin as sonarqube } from '@cpn-console/sonarqube-plugin' +import { plugin as vault } from '@cpn-console/vault-plugin' +import { pluginsDir } from './utils/env.js' +import { pluginManagerOptions } from './utils/plugins.js' + +const logger = baseLogger.child({ scope: 'plugin-manager' }) + +export async function initPm() { + const pm = pluginManager(pluginManagerOptions) + pm.register(argo) + pm.register(gitlab) + pm.register(harbor) + pm.register(keycloak) + pm.register(nexus) + pm.register(sonarqube) + pm.register(vault) + + if (!statSync(pluginsDir, { + throwIfNoEntry: false, + })) { + return pm + } + for (const dirName of readdirSync(pluginsDir)) { + const moduleAbsPath = `${pluginsDir}/${dirName}` + try { + statSync(`${moduleAbsPath}/package.json`) + const pkg = await import(`${moduleAbsPath}/package.json`, { with: { type: 'json' } }) + const entrypoint = pkg.default.module || pkg.default.main + if (!entrypoint) throw new Error(`No entrypoint found in package.json : ${pkg.default.name}`) + const { plugin } = await import(`${moduleAbsPath}/${entrypoint}`) as { plugin: Plugin } + pm.register(plugin) + } catch (error) { + logger.error({ err: error, moduleAbsPath }, 'Could not import external plugin module') + } + } + + return pm +} diff --git a/.jjconflict-base-0/apps/server/src/prepare-app.spec.ts b/.jjconflict-base-0/apps/server/src/prepare-app.spec.ts new file mode 100644 index 0000000000..5086faa776 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prepare-app.spec.ts @@ -0,0 +1,70 @@ +import { logger } from '@cpn-console/logger' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from './app.js' +import { getConnection } from './connect.js' +import { initDb } from './init/db/index.js' +import { getPreparedApp } from './prepare-app.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) +vi.mock('./connect.js') +vi.mock('./index.js') +vi.mock('./init/db/index.js', () => ({ initDb: vi.fn() })) + +vi.spyOn(app, 'listen') +vi.spyOn(logger, 'warn') +vi.spyOn(logger, 'error') +vi.spyOn(logger, 'debug') + +describe('server', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should getConnection', async () => { + await getPreparedApp() + + expect(getConnection).toHaveBeenCalledTimes(1) + expect(initDb.mock.calls).toHaveLength(1) + }) + + it('should throw an error on connection to DB', async () => { + const error = new Error('This is OK!') + getConnection.mockRejectedValueOnce(error) + + let response + await getPreparedApp() + .catch((err) => { response = err }) + + expect(getConnection.mock.calls).toHaveLength(1) + expect(app.listen.mock.calls).toHaveLength(0) + expect(response).toMatchObject(error) + }) + + it('should throw an error on initDb import if module is not found', async () => { + const infoSpy = vi.spyOn(logger, 'info') + const error = new Error('Failed to load') + initDb.mockRejectedValueOnce(error) + + await getPreparedApp() + + expect(initDb.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toHaveLength(3) + }) + + it('should throw an error on initDb import', async () => { + const infoSpy = vi.spyOn(logger, 'info') + const error = new Error('This is OK!') + initDb.mockRejectedValueOnce(error) + + let response + try { + await getPreparedApp() + } catch (err) { + response = err + } + + expect(initDb.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toHaveLength(2) + expect(response).toMatchObject(error) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/prepare-app.ts b/.jjconflict-base-0/apps/server/src/prepare-app.ts new file mode 100644 index 0000000000..000734714b --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prepare-app.ts @@ -0,0 +1,96 @@ +import { rm } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { logger } from '@cpn-console/logger' +import app from './app.js' +import { getConnection } from './connect.js' +import { initDb } from './init/db/index.js' +import { initPm } from './plugins.js' +import { isCI, isDev, isDevSetup, isInt, isProd, isTest, port } from './utils/env.js' + +async function initializeDB(path: string) { + logger.info('Starting init DB...') + const { data } = await import(path) + await initDb(data) + logger.info('initDb invoked successfully') +} + +export async function startServer(defaultPort: number = (port ? +port : 8080)) { + try { + await getConnection() + } catch (error) { + logger.error({ err: error }, 'Database connection failed') + if (!(error instanceof Error)) return + throw error + } + + initPm() + + logger.info('Reading init database file') + + try { + const dataPath = (isProd || isInt) + ? './init/db/imports/data.js' + : '@cpn-console/test-utils/src/imports/data.ts' + await initializeDB(dataPath) + if (isProd && !isDevSetup) { + logger.info('Cleaning up imported data file...') + const __filename = fileURLToPath(import.meta.url) + const __dirname = dirname(__filename) + await rm(resolve(__dirname, dataPath)) + logger.info(`Successfully deleted '${dataPath}'`) + } + } catch (err) { + if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.message?.includes('Failed to load') || err?.message?.includes('Cannot find module')) { + logger.info('No initDb file, skipping') + } else { + logger.warn({ err }, 'Init DB failed') + throw err + } + } + + try { + await app.listen({ host: '0.0.0.0', port: defaultPort ?? 8080 }) + } catch (error) { + logger.error({ err: error }, 'Failed to start HTTP server') + process.exit(1) + } + logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) +} + +export async function getPreparedApp() { + try { + await getConnection() + } catch (error) { + logger.error({ err: error }, 'Database connection failed') + throw error + } + + initPm() + + logger.info('Reading init database file') + + try { + const dataPath = (isProd || isInt) + ? './init/db/imports/data.js' + : '@cpn-console/test-utils/src/imports/data.ts' + await initializeDB(dataPath) + if (isProd && !isDevSetup) { + logger.info('Cleaning up imported data file...') + const __filename = fileURLToPath(import.meta.url) + const __dirname = dirname(__filename) + await rm(resolve(__dirname, dataPath)) + logger.info(`Successfully deleted '${dataPath}'`) + } + } catch (err) { + if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.message?.includes('Failed to load') || err?.message?.includes('Cannot find module')) { + logger.info('No initDb file, skipping') + } else { + logger.warn({ err }, 'Init DB failed') + throw err + } + } + + logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) + return app +} diff --git a/.jjconflict-base-0/apps/server/src/prisma.ts b/.jjconflict-base-0/apps/server/src/prisma.ts new file mode 100644 index 0000000000..4590932b68 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +export default prisma diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql new file mode 100644 index 0000000000..f2f4e7b0b7 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql @@ -0,0 +1,151 @@ +-- CreateTable +CREATE TABLE "Environment" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Environment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Log" ( + "id" UUID NOT NULL, + "data" JSONB NOT NULL, + "action" TEXT NOT NULL DEFAULT '', + "userId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Log_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Organization" ( + "id" UUID NOT NULL, + "source" TEXT NOT NULL, + "name" TEXT NOT NULL, + "label" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Permission" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "environmentId" UUID NOT NULL, + "level" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Permission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "organizationId" UUID NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL, + "locked" BOOLEAN NOT NULL DEFAULT false, + "services" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Repository" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "internalRepoName" TEXT NOT NULL, + "externalRepoUrl" TEXT NOT NULL, + "externalUserName" TEXT, + "externalToken" TEXT, + "isInfra" BOOLEAN NOT NULL DEFAULT false, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Repository_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" UUID NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Role" ( + "userId" UUID NOT NULL, + "projectId" UUID NOT NULL, + "role" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Role_pkey" PRIMARY KEY ("userId","projectId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_id_key" ON "Organization"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_name_key" ON "Organization"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_label_key" ON "Organization"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_id_key" ON "Permission"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_userId_environmentId_key" ON "Permission"("userId", "environmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_id_key" ON "Project"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Role_userId_projectId_key" ON "Role"("userId", "projectId"); + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql new file mode 100644 index 0000000000..26e1ade3f2 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql @@ -0,0 +1,85 @@ +-- CreateEnum +CREATE TYPE "ClusterPrivacy" AS ENUM ('public', 'dedicated'); + +-- CreateTable +CREATE TABLE "Cluster" ( + "id" UUID NOT NULL, + "label" VARCHAR(50) NOT NULL, + "privacy" "ClusterPrivacy" NOT NULL DEFAULT 'dedicated', + "secretName" VARCHAR(50) NOT NULL, + "clusterResources" BOOLEAN NOT NULL DEFAULT false, + "kubeConfigId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cluster_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Kubeconfig" ( + "id" UUID NOT NULL, + "user" JSONB NOT NULL, + "cluster" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "parentClusterId" UUID, + + CONSTRAINT "Kubeconfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_ClusterToEnvironment" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateTable +CREATE TABLE "_ClusterToProject" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_id_key" ON "Cluster"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_label_key" ON "Cluster"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_secretName_key" ON "Cluster"("secretName"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_kubeConfigId_key" ON "Cluster"("kubeConfigId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_id_key" ON "Kubeconfig"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_parentClusterId_key" ON "Kubeconfig"("parentClusterId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToEnvironment_AB_unique" ON "_ClusterToEnvironment"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToEnvironment_B_index" ON "_ClusterToEnvironment"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToProject_AB_unique" ON "_ClusterToProject"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToProject_B_index" ON "_ClusterToProject"("B"); + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_kubeConfigId_fkey" FOREIGN KEY ("kubeConfigId") REFERENCES "Kubeconfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_B_fkey" FOREIGN KEY ("B") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_B_fkey" FOREIGN KEY ("B") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql new file mode 100644 index 0000000000..8f3fb5ff9f --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `parentClusterId` on the `Kubeconfig` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Kubeconfig_parentClusterId_key"; + +-- AlterTable +ALTER TABLE "Kubeconfig" DROP COLUMN "parentClusterId"; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql new file mode 100644 index 0000000000..4eb37edc53 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql @@ -0,0 +1,10 @@ +CREATE TYPE "ProjectStatus" AS ENUM ('initializing', 'created', 'failed', 'archived'); + +ALTER TABLE public."Project" ALTER COLUMN status TYPE "ProjectStatus" USING + case + when status = 'created' then 'created'::"ProjectStatus" + when status = 'failed' then 'failed'::"ProjectStatus" + when status = 'archived' then 'archived'::"ProjectStatus" + else 'initializing'::"ProjectStatus" + end; +ALTER TABLE public."Project" ALTER COLUMN status SET DEFAULT 'initializing'; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql new file mode 100644 index 0000000000..f402a0e3d4 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "infos" VARCHAR(200); diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql new file mode 100644 index 0000000000..f553e7880e --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `externalToken` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "externalToken"; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql new file mode 100644 index 0000000000..394b650a51 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql @@ -0,0 +1,81 @@ +-- CreateEnum +CREATE TYPE "QuotaStageStatus" AS ENUM +('active', 'pendingDelete'); + +-- Create new tables +-- CreateTable +CREATE TABLE "Quota" +( + "id" UUID NOT NULL, + "memory" VARCHAR NOT NULL, + "cpu" REAL NOT NULL, + "name" VARCHAR NOT NULL, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "Quota_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Stage" +( + "id" UUID NOT NULL, + "name" VARCHAR NOT NULL, + + CONSTRAINT "Stage_pkey" PRIMARY KEY ("id") +); + +-- Associate Quotas and Stages +-- CreateTable +CREATE TABLE "QuotaStage" +( + "id" UUID NOT NULL, + "quotaId" UUID NOT NULL, + "stageId" UUID NOT NULL, + "status" "QuotaStageStatus" NOT NULL DEFAULT 'active', + + CONSTRAINT "QuotaStage_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "Quota_id_key" ON "Quota"("id"); +CREATE UNIQUE INDEX "Quota_name_key" ON "Quota"("name"); +CREATE UNIQUE INDEX "Stage_id_key" ON "Stage"("id"); +CREATE UNIQUE INDEX "Stage_name_key" ON "Stage"("name"); +CREATE UNIQUE INDEX "QuotaStage_id_key" ON "QuotaStage"("id"); +CREATE UNIQUE INDEX "QuotaStage_quotaId_stageId_key" ON "QuotaStage"("quotaId", "stageId"); +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Create default values for Quotas and Stages +-- Quota +INSERT INTO "Quota" + (id, cpu, memory, "name", "isPrivate") +VALUES + ('5a57b62f-2465-4fb6-a853-5a751d099199', 2, '4Gi', 'small', false), + ('08770663-3b76-4af6-8978-9f75eda4faa7', 4, '8Gi', 'medium', false), + ('b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 6, '12Gi', 'large', false), + ('97b851e8-9067-4a3d-a0e8-c3a6820c49be', 8, '16Gi', 'xlarge', false); + +-- Stage +INSERT INTO "Stage" + (id, "name") +VALUES + ('4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', 'dev'), + ('38fa869d-6267-441d-af7f-e0548fd06b7e', 'staging'), + ('d434310e-7850-4d59-b47f-0772edf50582', 'integration'), + ('9b3e9991-896d-4d90-bdc5-a34be8c06b8f', 'prod'); + +-- QuotaStage +INSERT INTO "QuotaStage" + (id, "quotaId", "stageId") +VALUES + ('0cb0c549-560e-4f26-8f4e-832dd722f68a', '5a57b62f-2465-4fb6-a853-5a751d099199', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('0530e9c9-b37d-4dec-93e6-1895f700e61c', '5a57b62f-2465-4fb6-a853-5a751d099199', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('8a99db49-b7b1-44bf-865d-5e709e8aa0fc', '5a57b62f-2465-4fb6-a853-5a751d099199', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('67561f00-d219-4ca6-b94a-3ee83f09d2d6', '5a57b62f-2465-4fb6-a853-5a751d099199', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('8b3c201e-7518-4254-a94a-16c404e46936', '08770663-3b76-4af6-8978-9f75eda4faa7', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('9157ae12-3e39-43f8-a24f-ae5d9c6b69b7', '08770663-3b76-4af6-8978-9f75eda4faa7', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('c733a1dd-c9fd-4def-b29e-df49ef7b6698', '08770663-3b76-4af6-8978-9f75eda4faa7', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('15a51f47-0ab2-4a94-a808-722639d8c092', '08770663-3b76-4af6-8978-9f75eda4faa7', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('cb66e80c-2304-472d-bc19-a411011674ca', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('59fb0e79-3a76-4b96-81d4-63f4caa98cfa', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('4174b22c-2bee-4f4a-9d85-da7b5463f214', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql new file mode 100644 index 0000000000..8c98a7f74e --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql @@ -0,0 +1,35 @@ +-- Multiplication des environnements par clusteurs +ALTER TABLE "Environment" ADD COLUMN "clusterId" UUID; + +DO +$$ +DECLARE + perm record; + cte record; + env_uuid UUID; +BEGIN + FOR cte IN SELECT "B" AS environmentId, "A" AS "clusterId", "name", "projectId", status, "updatedAt", "createdAt" + FROM public."_ClusterToEnvironment", public."Environment" + WHERE public."Environment".id = "B" + LOOP + env_uuid := gen_random_uuid(); + INSERT INTO public."Environment" (id, "name", "projectId", "clusterId", status, "createdAt", "updatedAt") VALUES + (env_uuid, cte."name", cte."projectId", cte."clusterId", cte.status, cte."createdAt", cte."updatedAt"); + + FOR perm in SELECT * FROM public."Permission" WHERE "environmentId" = cte.environmentId + LOOP + INSERT INTO public."Permission" (id, "level", "createdAt", "updatedAt", "environmentId", "userId") VALUES + (gen_random_uuid(), perm."level", perm."createdAt", perm."updatedAt", env_uuid, perm."userId"); + END LOOP; + END LOOP; +END; +$$ +; +DELETE FROM public."Environment" WHERE "clusterId" is null; +ALTER TABLE public."Environment" ALTER COLUMN "clusterId" SET NOT NULL; +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_clusterId_fkey" FOREIGN KEY ("clusterId") REFERENCES "Cluster"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Delete old _ClusterToEnvironment +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_A_fkey"; +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_B_fkey"; +DROP TABLE "_ClusterToEnvironment"; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql new file mode 100644 index 0000000000..035cd1c855 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql @@ -0,0 +1,36 @@ +-- Associate cluster to Stages +-- CreateTable +CREATE TABLE "_ClusterToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToStage_AB_unique" ON "_ClusterToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToStage_B_index" ON "_ClusterToStage"("B"); + +DO +$$ +DECLARE + cluster record; + cte record; + env_uuid UUID; +BEGIN + FOR cluster IN SELECT id + FROM public."Cluster" + LOOP + INSERT INTO public."_ClusterToStage" ("A", "B") VALUES + (cluster.id, '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + (cluster.id, '38fa869d-6267-441d-af7f-e0548fd06b7e'), + (cluster.id, 'd434310e-7850-4d59-b47f-0772edf50582'), + (cluster.id, '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); + END LOOP; +END; +$$ diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql new file mode 100644 index 0000000000..43793bdb49 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaStageId" UUID; +UPDATE "Environment" SET "quotaStageId" = '8b3c201e-7518-4254-a94a-16c404e46936' WHERE "name" = 'dev'; +UPDATE "Environment" SET "quotaStageId" = '9157ae12-3e39-43f8-a24f-ae5d9c6b69b7' WHERE "name" = 'staging'; +UPDATE "Environment" SET "quotaStageId" = '4174b22c-2bee-4f4a-9d85-da7b5463f214' WHERE "name" = 'integration'; +UPDATE "Environment" SET "quotaStageId" = 'de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a' WHERE "name" = 'prod'; +ALTER TABLE "Environment" ALTER COLUMN "name" SET DATA TYPE VARCHAR(11); +ALTER TABLE "Environment" ALTER COLUMN "quotaStageId" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaStageId_fkey" FOREIGN KEY ("quotaStageId") REFERENCES "QuotaStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql new file mode 100644 index 0000000000..9af004b6a3 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- lock all projects +UPDATE public."Project" SET "locked"=true \ No newline at end of file diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql new file mode 100644 index 0000000000..d970d3965a --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- set all projects to failed to avoid unlock them +UPDATE public."Project" SET "status" = 'failed' WHERE "status" != 'archived' \ No newline at end of file diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql new file mode 100644 index 0000000000..c387d9885c --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ADD COLUMN "requestId" VARCHAR(21); diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql new file mode 100644 index 0000000000..18e20262c8 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `status` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the column `status` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "status"; + +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "status"; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql new file mode 100644 index 0000000000..f784b21567 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql @@ -0,0 +1,46 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "zoneId" UUID; + +-- CreateTable +CREATE TABLE "Zone" +( + "id" UUID NOT NULL, + "slug" VARCHAR(10) NOT NULL, + "label" VARCHAR(50) NOT NULL, + "description" VARCHAR(200), + "createdAt" TIMESTAMP +(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP +(3) NOT NULL, + + CONSTRAINT "Zone_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_id_key" ON "Zone"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_slug_key" ON "Zone"("slug"); + +-- Create default zone +INSERT INTO "Zone" + (id, "slug", "label", "description", "updatedAt") +VALUES + ('a66c4230-eba6-41f1-aae5-bb1e4f90cce0', 'default', 'Zone Défaut', 'Zone par défaut, à changer', CURRENT_TIMESTAMP); + +-- Set default zoneId for current clusters +UPDATE "Cluster" +SET "zoneId" += 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0' +WHERE "zoneId" +IS NULL; + +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "zoneId" +SET +NOT NULL; + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") +ON DELETE RESTRICT ON +UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql new file mode 100644 index 0000000000..cb4f7ad968 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "ProjectPlugin" ( + "pluginName" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "AdminPlugin" ( + "pluginName" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectPlugin_projectId_pluginName_key_key" ON "ProjectPlugin"("projectId", "pluginName", "key"); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminPlugin_pluginName_key_key" ON "AdminPlugin"("pluginName", "key"); + +-- AddForeignKey +ALTER TABLE "ProjectPlugin" ADD CONSTRAINT "ProjectPlugin_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql new file mode 100644 index 0000000000..11672324f8 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql @@ -0,0 +1,19 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id, services FROM public."Project" LOOP + -- Extrait 'registry.id' de la colonne JSON 'services' + registry_id := (SELECT (project_row.services -> 'registry' ->> 'id')::TEXT); + -- Si 'registry.id' existe, insérer dans la table 'config' + IF registry_id IS NOT NULL THEN + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'registry', 'projectId', registry_id::TEXT); + END IF; + END LOOP; +END $$; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "services"; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql new file mode 100644 index 0000000000..9c7d5a8f8f --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "RoleList" AS ENUM ('owner', 'user'); + +-- AlterTable +ALTER TABLE public."Role" ALTER COLUMN "role" TYPE "RoleList" USING + case + when role = 'owner' then 'owner'::"RoleList" + else 'user'::"RoleList" + end; \ No newline at end of file diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql new file mode 100644 index 0000000000..45a4a5d1ea --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "ProjectClusterHistory" ( + "projectId" UUID NOT NULL, + "clusterId" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectClusterHistory_projectId_clusterId_key" ON "ProjectClusterHistory"("projectId", "clusterId"); diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql new file mode 100644 index 0000000000..2b1641a657 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql @@ -0,0 +1,11 @@ +DO $$ +DECLARE + env_row RECORD; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR env_row IN SELECT "projectId", "clusterId" FROM public."Environment" LOOP + INSERT INTO public."ProjectClusterHistory" ("projectId", "clusterId") + VALUES (env_row."projectId", env_row."clusterId") + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql new file mode 100644 index 0000000000..5e7ff03d45 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql @@ -0,0 +1,58 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaId" UUID, +ADD COLUMN "stageId" UUID; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "_QuotaToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_QuotaToStage_AB_unique" ON "_QuotaToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_QuotaToStage_B_index" ON "_QuotaToStage"("B"); + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +DO $$ +DECLARE + quota_stage_row RECORD; +BEGIN + FOR quota_stage_row IN SELECT * FROM public."QuotaStage" loop + UPDATE public."Environment" SET "stageId" = quota_stage_row."stageId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + UPDATE public."Environment" SET "quotaId" = quota_stage_row."quotaId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + insert into public."_QuotaToStage" values (quota_stage_row."quotaId", quota_stage_row."stageId"); + END LOOP; +END $$; + +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaStageId_fkey"; + +-- AlterTable +ALTER TABLE "Environment" ALTER COLUMN "quotaId" SET NOT NULL, +ALTER COLUMN "stageId" SET NOT NULL, +DROP COLUMN "quotaStageId"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_stageId_fkey"; + +-- DropTable +DROP TABLE "QuotaStage"; + +-- DropEnum +DROP TYPE "QuotaStageStatus"; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql new file mode 100644 index 0000000000..0036da8a12 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Made the column `description` on table `Project` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "description" SET NOT NULL, +ALTER COLUMN "description" SET DEFAULT ''; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql new file mode 100644 index 0000000000..ed6ae9b84e --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql @@ -0,0 +1,198 @@ +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_environmentId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_environmentId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_userId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_userId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_projectId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_projectId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_userId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_userId_fkey"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectMembers') THEN + CREATE TABLE "ProjectMembers" ( + "projectId" UUID NOT NULL, + "userId" UUID NOT NULL, + "roleIds" TEXT[] + ); + END IF; +END $$; + +-- AlterTable +ALTER TABLE "Log" ADD COLUMN IF NOT EXISTS "projectId" UUID; + +INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt") +VALUES('04ac168a-2c4f-4816-9cce-af6c612e5912'::uuid, 'Anonymous', 'User', 'anon@user', '2023-07-03 14:46:56.770', '2023-07-03 14:46:56.770') +ON CONFLICT (id) DO NOTHING; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "everyonePerms" BIGINT NOT NULL DEFAULT 896, +ADD COLUMN IF NOT EXISTS "ownerId" UUID; + +DO $$ +DECLARE + role_row RECORD; +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR role_row IN SELECT "userId", "projectId", "role" FROM public."Role" LOOP + INSERT INTO public."ProjectMembers" ("userId", "projectId", "roleIds") VALUES (role_row."userId", role_row."projectId", '{}'); + IF role_row."role" = 'owner'::public."RoleList" THEN + UPDATE public."Project" + SET "ownerId"=role_row."userId" + WHERE id=role_row."projectId"::uuid; + END IF; + END LOOP; + END IF; +END $$; + +UPDATE public."Project" +SET "ownerId"='04ac168a-2c4f-4816-9cce-af6c612e5912' +WHERE "ownerId" IS NULL; + +ALTER TABLE public."Project" ALTER COLUMN "ownerId" SET NOT NULL; + +DELETE FROM public."ProjectMembers" pm +USING public."Project" p +WHERE pm."userId" = p."ownerId" +AND pm."projectId" = p."id"; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Permission') THEN + DROP TABLE "Permission"; + END IF; +END $$; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + DROP TABLE "Role"; + END IF; +END $$; + +-- DropEnum if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'RoleList') THEN + DROP TYPE "RoleList"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'AdminRole') THEN + CREATE TABLE "AdminRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "AdminRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectRole') THEN + CREATE TABLE "ProjectRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "projectId" UUID NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "ProjectRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_id_key') THEN + CREATE UNIQUE INDEX "AdminRole_id_key" ON "AdminRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_name_key') THEN + CREATE UNIQUE INDEX "AdminRole_name_key" ON "AdminRole"("name"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectMembers_projectId_userId_key') THEN + CREATE UNIQUE INDEX "ProjectMembers_projectId_userId_key" ON "ProjectMembers"("projectId", "userId"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectRole_id_key') THEN + CREATE UNIQUE INDEX "ProjectRole_id_key" ON "ProjectRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'Environment_projectId_name_key') THEN + CREATE UNIQUE INDEX "Environment_projectId_name_key" ON "Environment"("projectId", "name"); + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Log_projectId_fkey') THEN + ALTER TABLE "Log" ADD CONSTRAINT "Log_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Project_ownerId_fkey') THEN + ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_projectId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_userId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectRole_projectId_fkey') THEN + ALTER TABLE "ProjectRole" ADD CONSTRAINT "ProjectRole_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "adminRoleIds" TEXT[]; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql new file mode 100644 index 0000000000..c9b41827bf --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "AdminRole_name_key"; + +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "oidcGroup" TEXT; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql new file mode 100644 index 0000000000..265f262abb --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `oidcGroup` on table `AdminRole` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable + +UPDATE public."AdminRole" +SET "oidcGroup"='' +WHERE "oidcGroup" IS NULL; + +ALTER TABLE "AdminRole" ALTER COLUMN "oidcGroup" SET NOT NULL, +ALTER COLUMN "oidcGroup" SET DEFAULT ''; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql new file mode 100644 index 0000000000..4fc2768605 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "SystemSetting" +( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "SystemSetting_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemSetting_key_key" ON "SystemSetting"("key"); + +-- Create maintenance setting +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('maintenance', 'off'); diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql new file mode 100644 index 0000000000..95ab54869d --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql @@ -0,0 +1,3 @@ +INSERT INTO public."AdminRole" +(id, "name", permissions, "position", "oidcGroup") +VALUES('76229c96-4716-45bc-99da-00498ec9018c'::uuid, 'Admin', 2, 0, '/admin'); \ No newline at end of file diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql new file mode 100644 index 0000000000..c116482186 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Made the column `externalUserName` on table `Repository` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +UPDATE "Repository" SET "externalUserName" = '' WHERE "externalUserName" IS NULL; + +ALTER TABLE "Repository" ALTER COLUMN "externalUserName" SET NOT NULL, +ALTER COLUMN "externalUserName" SET DEFAULT '', +ALTER COLUMN "externalRepoUrl" SET DEFAULT ''; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240916141253_token/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240916141253_token/migration.sql new file mode 100644 index 0000000000..b0472cd806 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240916141253_token/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "TokenStatus" AS ENUM ('active', 'revoked'); + +-- CreateTable +CREATE TABLE "AdminToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "userId" UUID, + "expirationDate" TIMESTAMP(3), + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "AdminToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminToken_id_key" ON "AdminToken"("id"); + +-- AddForeignKey +ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql new file mode 100644 index 0000000000..47488b00c5 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "Log" DROP CONSTRAINT "Log_userId_fkey"; + +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "userId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql new file mode 100644 index 0000000000..18eca3eadf --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "requestId" SET DATA TYPE VARCHAR(36); diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql new file mode 100644 index 0000000000..74e0946f0a --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Zone" ADD COLUMN "argocdUrl" TEXT NOT NULL DEFAULT 'https://example.com'; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql new file mode 100644 index 0000000000..41dac75359 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ProjectStatus" ADD VALUE 'warning'; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql new file mode 100644 index 0000000000..ef888d5e50 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql @@ -0,0 +1,12 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id FROM public."Project" WHERE status <> 'archived'::public."ProjectStatus" LOOP + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'nexus', 'activateMavenRepo', 'enabled') + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql new file mode 100644 index 0000000000..a57e5956c3 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql @@ -0,0 +1,12 @@ +-- CreateEnum +CREATE TYPE "UserType" AS ENUM ('human', 'bot', 'ghost'); + +-- AlterEnum +ALTER TYPE "TokenStatus" ADD VALUE 'inactive'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "type" "UserType" NOT NULL DEFAULT 'human'; +UPDATE "User" SET type = 'ghost' WHERE id = '04ac168a-2c4f-4816-9cce-af6c612e5912'; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "type" DROP DEFAULT; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql new file mode 100644 index 0000000000..71e15a312c --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql @@ -0,0 +1,84 @@ +-- CreateTable (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'PersonalAccessToken') THEN + CREATE TABLE "PersonalAccessToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "userId" UUID NOT NULL, + "expirationDate" TIMESTAMP(3) NOT NULL, + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'PersonalAccessToken_id_key') THEN + CREATE UNIQUE INDEX "PersonalAccessToken_id_key" ON "PersonalAccessToken"("id"); + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'PersonalAccessToken_userId_fkey') THEN + ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- Process AdminToken (idempotent) +DO $$ +DECLARE + admin_token record; + user_uuid UUID; +BEGIN + FOR admin_token IN SELECT "name", "id" + FROM public."AdminToken" + LOOP + -- Generate new UUID if user does not exist + user_uuid := COALESCE( + (SELECT id FROM public."User" WHERE email = concat(admin_token.name, '@bot.id')), + gen_random_uuid() + ); + + -- Insert user if not already exists + INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt", "type") + VALUES(user_uuid, 'Bot Admin', admin_token.name, concat(admin_token.name, '@bot.id'), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'bot') + ON CONFLICT (id) DO NOTHING; + + -- Update AdminToken with the new user ID + UPDATE public."AdminToken" SET "userId" = user_uuid WHERE id = admin_token.id; + END LOOP; +END $$; + +-- Alter AdminToken userId column to NOT NULL (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'AdminToken' AND column_name = 'userId' AND is_nullable = 'NO') THEN + ALTER TABLE public."AdminToken" ALTER COLUMN "userId" SET NOT NULL; + END IF; +END $$; + +-- DropForeignKey if exists (idempotent) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" DROP CONSTRAINT "AdminToken_userId_fkey"; + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql new file mode 100644 index 0000000000..521b2b10ac --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastLogin" TIMESTAMP(3); diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql new file mode 100644 index 0000000000..a7500833b9 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "slug" TEXT; + +UPDATE public."Project" p +SET slug = ( + SELECT concat(org.name, '-', subp.name) FROM public."Project" subp + LEFT JOIN public."Organization" org on org."id" = subp."organizationId" + WHERE subp.id = p.id +); + +ALTER TABLE public."Project" ALTER COLUMN "slug" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug"); diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql new file mode 100644 index 0000000000..b143cbeb9b --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "lastSuccessProvisionningVersion" TEXT; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql new file mode 100644 index 0000000000..7a88681901 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToProject_AB_unique"; + +-- AlterTable +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToStage_AB_unique"; + +-- AlterTable +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_QuotaToStage_AB_unique"; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql new file mode 100644 index 0000000000..21ce77b8d5 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "external" BOOLEAN NOT NULL DEFAULT false; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql new file mode 100644 index 0000000000..ac63cd639d --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql @@ -0,0 +1,25 @@ +-- Vérifie les versions dans la table Project +DO $$ +DECLARE + project_id TEXT; + project_name TEXT; + last_version TEXT; +BEGIN + -- Boucle sur les projets non archivés + FOR project_id, project_name, last_version IN ( + SELECT id, name, "lastSuccessProvisionningVersion" + FROM "Project" + WHERE "status" != 'archived' + ) + LOOP + -- Vérifie si la version est NULL + IF last_version IS NULL THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version NULL.', project_name, project_id; + END IF; + + -- Vérifie si la version est inférieure à 8.23.0 selon SemVer + IF (string_to_array(last_version, '.')::int[] < ARRAY[8,23,0]) THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version (%), inférieure à 8.23.0.', project_name, project_id, last_version; + END IF; + END LOOP; +END $$; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql new file mode 100644 index 0000000000..54871c9017 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `organizationId` on the `Project` table. All the data in the column will be lost. + - You are about to drop the `Organization` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Project" DROP CONSTRAINT "Project_organizationId_fkey"; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "organizationId"; + +-- DropTable +DROP TABLE "Organization"; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql new file mode 100644 index 0000000000..68ca0df2fa --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "infos" SET DATA TYPE VARCHAR(1000); diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql new file mode 100644 index 0000000000..8364090d8d --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql @@ -0,0 +1,44 @@ +-- AlterTable +ALTER TABLE "Environment" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; + +COMMENT ON COLUMN "Environment".cpu IS 'CPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".gpu IS 'GPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".memory IS 'Memory value as GigaBytes (1 and 0.01 are valid values)'; + +-- Use values from Quota. Memory is an extract of q.memory numeric value as it contains a unit (e.g. '2Gi'). +UPDATE "Environment" +SET cpu = q.cpu, memory = COALESCE(NULLIF(regexp_replace(q.memory, '\D', '','g'), ''), '0')::numeric +FROM "Quota" q +WHERE "quotaId" = q."id"; + +/* + Warnings: + + - You are about to drop the column `quotaId` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the `Quota` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_QuotaToStage` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_B_fkey"; + +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "quotaId", +ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- DropTable +DROP TABLE "Quota"; + +-- DropTable +DROP TABLE "_QuotaToStage"; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql new file mode 100644 index 0000000000..77f32b5ab5 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Cluster" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql new file mode 100644 index 0000000000..decca804aa --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Project" +ADD COLUMN "limitless" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "hprodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodMemory" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodMemory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql new file mode 100644 index 0000000000..95f3a689db --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql @@ -0,0 +1,4 @@ +-- Rename default zone +UPDATE "Zone" +SET ("label", "description") = ('DSO', 'Zone par défaut') +WHERE slug = 'default'; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql new file mode 100644 index 0000000000..aadb6cdba9 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Repository" ADD COLUMN "deployRevision" TEXT, +ADD COLUMN "deployPath" TEXT, +ADD COLUMN "helmValuesFiles" TEXT; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql new file mode 100644 index 0000000000..15cf56ab82 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql @@ -0,0 +1,12 @@ +-- Eliminate NULLs +UPDATE "Repository" SET "deployRevision" = '' WHERE "deployRevision" IS NULL; +UPDATE "Repository" SET "deployPath" = '' WHERE "deployPath" IS NULL; +UPDATE "Repository" SET "helmValuesFiles" = '' WHERE "helmValuesFiles" IS NULL; + +-- AlterTable +ALTER TABLE "Repository" ALTER COLUMN "deployRevision" SET NOT NULL, +ALTER COLUMN "deployRevision" SET DEFAULT '', +ALTER COLUMN "deployPath" SET NOT NULL, +ALTER COLUMN "deployPath" SET DEFAULT '', +ALTER COLUMN "helmValuesFiles" SET NOT NULL, +ALTER COLUMN "helmValuesFiles" SET DEFAULT ''; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql new file mode 100644 index 0000000000..1292495121 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "autosync" BOOLEAN NOT NULL DEFAULT true; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql new file mode 100644 index 0000000000..04c98df036 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "oidcGroup" TEXT NOT NULL DEFAULT ''; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql new file mode 100644 index 0000000000..40da41a75d --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- Update AdminRole system roles +UPDATE "AdminRole" SET "type" = 'system' WHERE "name" IN ('Admin', 'Admin Locaux'); + +-- Update ProjectRole system roles +UPDATE "ProjectRole" SET "type" = 'system' WHERE "name" IN ('Administrateur', 'DevOps', 'Développeur', 'Lecture seule'); diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql new file mode 100644 index 0000000000..92c200d261 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql @@ -0,0 +1,26 @@ +-- Update existing Admin role to be system role 'Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + "oidcGroup" = '/admin', + "position" = 0 +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Lecture Seule Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '35848aa2-e881-4770-9844-0c5c3693e506'::uuid, + 'Lecture Seule Plateforme', + 1, -- Assuming 1n means bit 0 + 2, + '/readonly', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Lecture Seule Plateforme', + "type" = 'system', + "permissions" = 1, + "oidcGroup" = '/readonly'; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql new file mode 100644 index 0000000000..2a4afa8971 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql @@ -0,0 +1,28 @@ +-- Update existing Admin role to be system role 'Root Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Root Administrateur Plateforme' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Administrateur Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf'::uuid, + 'Administrateur Plateforme', + 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + 0, + '/console/admin', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, + "oidcGroup" = '/console/admin'; + +-- Update 'Lecture Seule Plateforme' system role +UPDATE "AdminRole" +SET + "oidcGroup" = '/console/readonly' +WHERE "id" = '35848aa2-e881-4770-9844-0c5c3693e506'::uuid; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql new file mode 100644 index 0000000000..f0cdb04873 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `external` on the `Cluster` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Cluster" DROP COLUMN "external"; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql new file mode 100644 index 0000000000..e07cad777b --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql @@ -0,0 +1,5 @@ +-- Enable legacy default permissions flag +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('refined-permissions', 'off'); diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql new file mode 100644 index 0000000000..efb6bc67d4 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql @@ -0,0 +1,3 @@ +-- Remove 'refined-permissions' system setting +DELETE FROM "SystemSetting" +WHERE "key" = 'refined-permissions'; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql new file mode 100644 index 0000000000..d019403c44 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql @@ -0,0 +1,15 @@ +-- Add global 'Tout le monde' role +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '034f589f-1750-4b15-bb34-4cd995e7fcaa'::uuid, + 'Tout le monde', + 25608, -- MANAGE_PROJECTS | LIST_PROJECTS | LIST_ZONES | LIST_STAGES + 0, + '', + 'global' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Tout le monde', + "type" = 'global', + "permissions" = 25608; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql new file mode 100644 index 0000000000..4fd0d80eb2 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql @@ -0,0 +1,25 @@ +-- Alter existing column to change the default value from 'custom' to 'managed' for AdminRole type +ALTER TABLE "AdminRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Alter existing column to change the default value from 'custom' to 'managed' for ProjectRole type +ALTER TABLE "ProjectRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Migrate system roles to managed roles +UPDATE "AdminRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate system roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate custom roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'custom'; + +-- Update existing Admin role to be an externally managed role +UPDATE "AdminRole" +SET "type" = 'external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql new file mode 100644 index 0000000000..d2691666bd --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql @@ -0,0 +1,15 @@ +-- Migrate well-known project roles to the new "system:"-prefixed type +UPDATE "ProjectRole" +SET "type" = 'system:managed' +WHERE + "type" = 'managed' + AND "oidcGroup" ~ '^/[^/]+/console/(admin|devops|developer|readonly)$'; + +-- Migrate well-known admin roles to the new "system:"-prefixed type +UPDATE "AdminRole" +SET "type" = 'system:managed' +WHERE id IN ( + '76229c96-4716-45bc-99da-00498ec9018c', -- Root Administrateur Plateforme + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', -- Administrateur Plateforme + '35848aa2-e881-4770-9844-0c5c3693e506' -- Lecture Seule Plateforme +); diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql new file mode 100644 index 0000000000..23593ba4f4 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "hprodCpu" DROP DEFAULT, +ALTER COLUMN "hprodGpu" DROP DEFAULT, +ALTER COLUMN "hprodMemory" DROP DEFAULT, +ALTER COLUMN "prodCpu" DROP DEFAULT, +ALTER COLUMN "prodGpu" DROP DEFAULT, +ALTER COLUMN "prodMemory" DROP DEFAULT; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql new file mode 100644 index 0000000000..7e3eefb25d --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql @@ -0,0 +1,4 @@ +-- Set the Root Administrateur Plateforme admin role to external system-managed type +UPDATE "AdminRole" +SET "type" = 'system:external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql new file mode 100644 index 0000000000..ca81594339 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "DeploymentSourceType" AS ENUM ('git', 'oci'); + +-- CreateTable +CREATE TABLE "Deployment" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "name" VARCHAR(11) NOT NULL, + "autosync" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "environmentId" UUID NOT NULL, + + CONSTRAINT "Deployment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeploymentSource" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deploymentId" UUID NOT NULL, + "repositoryId" UUID NOT NULL, + "type" "DeploymentSourceType" NOT NULL, + "targetRevision" TEXT NOT NULL DEFAULT '', + "path" TEXT NOT NULL DEFAULT '', + "helmValuesFiles" TEXT NOT NULL DEFAULT '', + + CONSTRAINT "DeploymentSource_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Deployment_id_key" ON "Deployment"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentSource_id_key" ON "DeploymentSource"("id"); + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "Deployment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql new file mode 100644 index 0000000000..ad5a64d336 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_environmentId_fkey"; + +-- DropForeignKey +ALTER TABLE "DeploymentSource" DROP CONSTRAINT "DeploymentSource_repositoryId_fkey"; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-0/apps/server/src/prisma/migrations/migration_lock.toml b/.jjconflict-base-0/apps/server/src/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000..044d57cdb0 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/.jjconflict-base-0/apps/server/src/prisma/schema/admin.prisma b/.jjconflict-base-0/apps/server/src/prisma/schema/admin.prisma new file mode 100644 index 0000000000..c4ca38ccb1 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/schema/admin.prisma @@ -0,0 +1,21 @@ +model AdminPlugin { + pluginName String + key String + value String + + @@unique([pluginName, key]) +} + +model AdminRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") +} + +model SystemSetting { + key String @id @unique + value String +} diff --git a/.jjconflict-base-0/apps/server/src/prisma/schema/project.prisma b/.jjconflict-base-0/apps/server/src/prisma/schema/project.prisma new file mode 100644 index 0000000000..6bcc5ce949 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/schema/project.prisma @@ -0,0 +1,144 @@ +model Deployment { + id String @id @unique @default(uuid()) @db.Uuid + projectId String @db.Uuid + name String @db.VarChar(11) + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + environmentId String @db.Uuid + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model DeploymentSource { + id String @id @unique @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deploymentId String @db.Uuid + repositoryId String @db.Uuid + type DeploymentSourceType + targetRevision String @default("") + path String @default("") + helmValuesFiles String @default("") + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) + deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) +} + +model Environment { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(11) + projectId String @db.Uuid + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clusterId String @db.Uuid + stageId String @db.Uuid + cluster Cluster @relation(fields: [clusterId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + stage Stage @relation(fields: [stageId], references: [id]) + deployments Deployment[] + + @@unique([projectId, name]) +} + +model Repository { + id String @id @default(uuid()) @db.Uuid + projectId String @db.Uuid + internalRepoName String + externalRepoUrl String @default("") + externalUserName String @default("") + isInfra Boolean @default(false) + isPrivate Boolean @default(false) + deployRevision String @default("") + deployPath String @default("") + helmValuesFiles String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model ProjectClusterHistory { + projectId String @db.Uuid + clusterId String @db.Uuid + + @@unique([projectId, clusterId]) +} + +model ProjectMembers { + projectId String @db.Uuid + userId String @db.Uuid + roleIds String[] + project Project @relation(fields: [projectId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@unique([projectId, userId]) +} + +model ProjectPlugin { + pluginName String + projectId String @db.Uuid + key String + value String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, pluginName, key]) +} + +model ProjectRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + projectId String @db.Uuid + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") + project Project @relation(fields: [projectId], references: [id]) +} + +model Project { + id String @id @unique @default(uuid()) @db.Uuid + name String + description String @default("") + status ProjectStatus @default(initializing) + locked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + everyonePerms BigInt @default(896) + ownerId String @db.Uuid + environments Environment[] + logs Log[] + owner User @relation(fields: [ownerId], references: [id]) + members ProjectMembers[] + plugins ProjectPlugin[] + roles ProjectRole[] + repositories Repository[] + clusters Cluster[] @relation("ClusterToProject") + slug String @unique + limitless Boolean @default(true) + hprodCpu Float @db.Real + hprodGpu Float @db.Real + hprodMemory Float @db.Real + prodCpu Float @db.Real + prodGpu Float @db.Real + prodMemory Float @db.Real + lastSuccessProvisionningVersion String? + deployments Deployment[] +} + +enum ProjectStatus { + initializing + created + failed + archived + warning +} + +enum DeploymentSourceType { + git + oci +} diff --git a/.jjconflict-base-0/apps/server/src/prisma/schema/schema.prisma b/.jjconflict-base-0/apps/server/src/prisma/schema/schema.prisma new file mode 100644 index 0000000000..aadf7fea13 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/schema/schema.prisma @@ -0,0 +1,21 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DB_URL") +} + +model Log { + id String @id @default(uuid()) @db.Uuid + data Json + action String @default("") + userId String? @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + requestId String? @db.VarChar(36) + projectId String? @db.Uuid + project Project? @relation(fields: [projectId], references: [id]) + user User? @relation(fields: [userId], references: [id]) +} diff --git a/.jjconflict-base-0/apps/server/src/prisma/schema/token.prisma b/.jjconflict-base-0/apps/server/src/prisma/schema/token.prisma new file mode 100644 index 0000000000..c0c55751c5 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/schema/token.prisma @@ -0,0 +1,30 @@ +model AdminToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + userId String @db.Uuid + expirationDate DateTime? + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +model PersonalAccessToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + userId String @db.Uuid + expirationDate DateTime + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +enum TokenStatus { + active + revoked + inactive +} diff --git a/.jjconflict-base-0/apps/server/src/prisma/schema/topography.prisma b/.jjconflict-base-0/apps/server/src/prisma/schema/topography.prisma new file mode 100644 index 0000000000..63d90abb8a --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/schema/topography.prisma @@ -0,0 +1,52 @@ +model Cluster { + id String @id @unique @default(uuid()) @db.Uuid + label String @unique @db.VarChar(50) + privacy ClusterPrivacy @default(dedicated) + secretName String @unique @default(uuid()) @db.VarChar(50) + clusterResources Boolean @default(false) + kubeConfigId String @unique @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + infos String? @db.VarChar(1000) + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + zoneId String @db.Uuid + kubeconfig Kubeconfig @relation(fields: [kubeConfigId], references: [id], onDelete: Cascade) + zone Zone @relation(fields: [zoneId], references: [id]) + environments Environment[] + projects Project[] @relation("ClusterToProject") + stages Stage[] @relation("ClusterToStage") +} + +model Kubeconfig { + id String @id @unique @default(uuid()) @db.Uuid + user Json + cluster Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + parentCluster Cluster? +} + +model Stage { + id String @id @unique @default(uuid()) @db.Uuid + name String @unique @db.VarChar + environments Environment[] + clusters Cluster[] @relation("ClusterToStage") +} + +model Zone { + id String @id @unique @default(uuid()) @db.Uuid + slug String @unique @db.VarChar(10) + label String @db.VarChar(50) + description String? @db.VarChar(200) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + argocdUrl String @default("https://example.com") + clusters Cluster[] +} + +enum ClusterPrivacy { + public + dedicated +} diff --git a/.jjconflict-base-0/apps/server/src/prisma/schema/user.prisma b/.jjconflict-base-0/apps/server/src/prisma/schema/user.prisma new file mode 100644 index 0000000000..e90fb69f83 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/prisma/schema/user.prisma @@ -0,0 +1,23 @@ +model User { + id String @id @db.Uuid + firstName String + lastName String + email String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? + adminRoleIds String[] + type UserType + + logs Log[] + projectsOwned Project[] + adminTokens AdminToken[] + projectMembers ProjectMembers[] + personalAccessTokens PersonalAccessToken[] +} + +enum UserType { + human + bot + ghost +} diff --git a/.jjconflict-base-0/apps/server/src/resources/admin-role/business.spec.ts b/.jjconflict-base-0/apps/server/src/resources/admin-role/business.spec.ts new file mode 100644 index 0000000000..9f9a7051ea --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/admin-role/business.spec.ts @@ -0,0 +1,262 @@ +import type { AdminRole, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400 } from '../../utils/errors.ts' +import { countRolesMembers, createRole, deleteRole, listRoles, patchRoles } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +describe('test admin-role business', () => { + describe('listRoles', () => { + it('should stringify bigint', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + const response = await listRoles() + expect(response).toContainEqual(expect.objectContaining({ permissions: '4', type: 'managed' })) + }) + }) + + describe('createRole', () => { + it('should create role with incremented position when position 0 is the highest', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 1 } }) + }) + + it('should create role with incremented position with bigger position', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 51 } }) + }) + + it('should create role with incremented position with no role in db', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(null) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 0 } }) + }) + }) + describe('deleteRole', () => { + const roleId = faker.string.uuid() + it('should delete role and remove id from concerned users', async () => { + const users = [{ + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roleId], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }, { + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roleId, faker.string.uuid()], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }] as const satisfies User[] + + const dbRole: AdminRole = { + name: 'Admin', + id: roleId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.user.findMany.mockResolvedValue(users) + prisma.adminRole.findMany.mockResolvedValueOnce([]) + prisma.adminRole.findUnique.mockResolvedValueOnce(dbRole) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.delete.mockResolvedValue({ args: {}, failed: false, results: {} }) + await deleteRole(roleId, faker.string.uuid()) + + expect(prisma.user.findMany).toHaveBeenCalledTimes(2) + expect(prisma.user.update).toHaveBeenNthCalledWith(1, { where: { id: users[0].id }, data: { adminRoleIds: [] } }) + expect(prisma.user.update).toHaveBeenNthCalledWith(2, { where: { id: users[1].id }, data: { adminRoleIds: [users[1].adminRoleIds[1]] } }) + expect(prisma.adminRole.delete).toHaveBeenCalledWith({ where: { id: roleId } }) + }) + }) + + describe('countRolesMembers', () => { + it('should return aggregated role member counts', async () => { + const roles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + type: 'managed', + }] as const satisfies AdminRole[] + + const users = [{ + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roles[0].id, roles[1].id], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }, { + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roles[1].id], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }] as const satisfies User[] + prisma.adminRole.findMany.mockResolvedValue(roles) + prisma.user.findMany.mockResolvedValue(users) + + const response = await countRolesMembers() + + expect(response).toEqual({ [roles[0].id]: 1, [roles[1].id]: 2 }) + }) + }) + describe('patchRoles', () => { + const dbRoles: AdminRole[] = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + type: 'managed', + }] + + it('should do nothing', async () => { + prisma.adminRole.findMany.mockResolvedValue([]) + await patchRoles([], faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 1 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(updateRoles, faker.string.uuid()) + + expect(response).instanceOf(BadRequest400) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + it('should return 400 if incoherent positions (missing roles)', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[1].id, position: 1 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(updateRoles, faker.string.uuid()) + + expect(response).instanceOf(BadRequest400) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + it('should update positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 0 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await patchRoles(updateRoles, faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(2) + }) + it('should update permissions', async () => { + const updateRoles: (Pick & { permissions?: string })[] = [ + { id: dbRoles[1].id, permissions: '0' }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await patchRoles(updateRoles, faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(1) + expect(prisma.adminRole.update).toHaveBeenCalledWith({ + data: { + name: dbRoles[1].name, + oidcGroup: dbRoles[1].oidcGroup, + permissions: 0n, + position: 1, + type: 'managed', + }, + where: { + id: dbRoles[1].id, + }, + }) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/admin-role/business.ts b/.jjconflict-base-0/apps/server/src/resources/admin-role/business.ts new file mode 100644 index 0000000000..f81fd65b75 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/admin-role/business.ts @@ -0,0 +1,111 @@ +import type { AdminRole, adminRoleContract } from '@cpn-console/shared' +import type { Project, ProjectRole } from '@prisma/client' +import type { ErrorResType } from '@/utils/errors.js' +import prisma from '@/prisma.js' +import { addLogs, getAdminRoleById, listAdminRoles } from '@/resources/queries-index.js' +import { BadRequest400 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function listRoles() { + return listAdminRoles() + .then(roles => roles.map(role => ({ ...role, permissions: role.permissions.toString(), type: role.type ?? 'managed' }))) +} + +export async function patchRoles( + roles: typeof adminRoleContract.patchAdminRoles.body._type, + requestId: string, +): Promise { + const dbRoles = await prisma.adminRole.findMany() + const positionsAvailable: number[] = [] + const updatedRoles: (Omit & { permissions: bigint })[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (matchingRole) { + if (typeof matchingRole.position !== 'undefined' && !positionsAvailable.includes(matchingRole.position)) { + positionsAvailable.push(matchingRole.position) + } + updatedRoles.push({ + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions: matchingRole.permissions ? BigInt(matchingRole.permissions) : dbRole.permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup: matchingRole.oidcGroup ?? dbRole.oidcGroup, + type: matchingRole.type ?? dbRole.type, + }) + } + } + + if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes') + for (const { id, ...role } of updatedRoles) { + await prisma.adminRole.update({ where: { id }, data: role }) + const hookReply = await hook.adminRole.upsert(id) + await addLogs({ action: 'Update Admin Role', data: hookReply, requestId }) + } + + return listRoles() +} + +export async function createRole( + role: typeof adminRoleContract.createAdminRole.body._type, + requestId: string, +) { + const dbMaxPosRole = (await prisma.adminRole.findFirst({ + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + const createdRole = await prisma.adminRole.create({ + data: { + ...role, + position: dbMaxPosRole + 1, + permissions: 0n, + }, + }) + + const hookReply = await hook.adminRole.upsert(createdRole.id) + await addLogs({ action: 'Create Admin Role', data: hookReply, requestId }) + + return listRoles() +} + +export async function countRolesMembers() { + const roles = await prisma.adminRole.findMany({ where: { oidcGroup: { equals: '' } }, select: { id: true } }) + const roleIds = roles.map(role => role.id) + const users = await prisma.user.findMany({ + where: { adminRoleIds: { hasSome: roleIds } }, + select: { adminRoleIds: true }, + }) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) // {role uuid: 0} + for (const { adminRoleIds } of users) { + for (const roleId of adminRoleIds) { + rolesCounts[roleId]++ + } + } + return rolesCounts +} + +export async function deleteRole( + roleId: Project['id'], + requestId: string, +) { + const role = await getAdminRoleById(roleId) + if (role) { + const hookReply = await hook.adminRole.delete(role) + await addLogs({ action: 'Delete Admin Role', data: hookReply, requestId }) + } + + const allUsers = await prisma.user.findMany({ + where: { + adminRoleIds: { has: roleId }, + }, + }) + for (const user of allUsers) { + await prisma.user.update({ + where: { id: user.id }, + data: { adminRoleIds: user.adminRoleIds.filter(adminRoleId => adminRoleId !== roleId) }, + }) + } + await prisma.adminRole.delete({ where: { id: roleId } }) + return null +} diff --git a/.jjconflict-base-0/apps/server/src/resources/admin-role/queries.ts b/.jjconflict-base-0/apps/server/src/resources/admin-role/queries.ts new file mode 100644 index 0000000000..9c29eb0b36 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/admin-role/queries.ts @@ -0,0 +1,40 @@ +import type { + AdminRole, + Prisma, +} from '@prisma/client' +import prisma from '@/prisma.js' + +export const listAdminRoles = () => prisma.adminRole.findMany({ orderBy: { position: 'asc' } }) + +export function createAdminRole(data: Pick) { + return prisma.adminRole.create({ + data: { + name: data.name, + permissions: 0n, + position: data.position, + type: 'managed', + }, + }) +} + +export function updateAdminRole(id: AdminRole['id'], data: Pick) { + return prisma.adminRole.updateMany({ + where: { id }, + data, + }) +} + +export function deleteAdminRole(id: AdminRole['id']) { + return prisma.adminRole.delete({ + where: { + id, + }, + }) +} + +export async function getAdminRoleById(id: string) { + const role = await prisma.adminRole.findUnique({ where: { id } }) + if (!role) return null + const members = await prisma.user.findMany({ where: { adminRoleIds: { has: id } } }) + return { ...role, members } +} diff --git a/.jjconflict-base-0/apps/server/src/resources/admin-role/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/admin-role/router.spec.ts new file mode 100644 index 0000000000..dab0f79268 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/admin-role/router.spec.ts @@ -0,0 +1,184 @@ +import { ADMIN_PERMS, adminRoleContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListRolesMock = vi.spyOn(business, 'listRoles') +const businessCreateRoleMock = vi.spyOn(business, 'createRole') +const businessPatchRolesMock = vi.spyOn(business, 'patchRoles') +const businessCountRolesMembersMock = vi.spyOn(business, 'countRolesMembers') +const businessDeleteRoleMock = vi.spyOn(business, 'deleteRole') + +describe('test adminRoleContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listAdminRoles', () => { + it('should return list of admin roles', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const roles = [{ id: faker.string.uuid(), name: 'Role 1', oidcGroup: '', position: 0, permissions: '1', type: 'managed' }] + businessListRolesMock.mockResolvedValueOnce(roles) + + const response = await app.inject() + .get(adminRoleContract.listAdminRoles.path) + .end() + + expect(businessListRolesMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(roles) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('createAdminRole', () => { + it('should create a role for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + const newRole = { id: 'newRole', name: 'New Role' } + const roleData = { name: 'New Role' } + + authUserMock.mockResolvedValueOnce(user) + businessCreateRoleMock.mockResolvedValueOnce(newRole) + + const response = await app.inject() + .post(adminRoleContract.createAdminRole.path) + .body(roleData) + .end() + + expect(businessCreateRoleMock).toHaveBeenCalledWith(roleData, expect.any(String)) + expect(response.json()).toEqual(newRole) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(adminRoleContract.createAdminRole.path) + .body({ name: 'New Role' }) + .end() + + expect(businessCreateRoleMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('patchAdminRoles', () => { + const updatedRoles = [{ id: faker.string.uuid(), name: 'Role 1', oidcGroup: '', position: 0, permissions: '1', type: 'managed' }] + const rolesData = [{ id: updatedRoles[0].id, name: 'Updated Role', type: 'managed' }] + it('should update roles for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessPatchRolesMock.mockResolvedValueOnce(updatedRoles) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledWith(rolesData, expect.any(String)) + expect(response.json()).toEqual(updatedRoles) + expect(response.statusCode).toEqual(200) + }) + + it('should return error if business logic fails', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessPatchRolesMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledWith(rolesData, expect.any(String)) + expect(response.statusCode).toEqual(400) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('adminRoleMemberCounts', () => { + it('should return counts of role members for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + const counts = { role1: 5, role2: 3 } + + authUserMock.mockResolvedValueOnce(user) + businessCountRolesMembersMock.mockResolvedValueOnce(counts) + + const response = await app.inject() + .get(adminRoleContract.adminRoleMemberCounts.path) + .end() + + expect(businessCountRolesMembersMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(counts) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 if user is not admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(adminRoleContract.adminRoleMemberCounts.path) + .end() + + expect(businessCountRolesMembersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteAdminRole', () => { + const roleId = faker.string.uuid() + it('should delete a role for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessDeleteRoleMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .delete(adminRoleContract.deleteAdminRole.path.replace(':roleId', roleId)) + .end() + + expect(businessDeleteRoleMock).toHaveBeenCalledWith(roleId, expect.any(String)) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(adminRoleContract.deleteAdminRole.path.replace(':roleId', roleId)) + .end() + + expect(businessDeleteRoleMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/admin-role/router.ts b/.jjconflict-base-0/apps/server/src/resources/admin-role/router.ts new file mode 100644 index 0000000000..4141a2353e --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/admin-role/router.ts @@ -0,0 +1,78 @@ +import { AdminAuthorized, adminRoleContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + countRolesMembers, + createRole, + deleteRole, + listRoles, + patchRoles, +} from './business.js' + +export function adminRoleRouter() { + return serverInstance.router(adminRoleContract, { + // Récupérer des projets + listAdminRoles: async () => { + const body = await listRoles() + + return { + status: 200, + body, + } + }, + + createAdminRole: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await createRole(body, req.id) + + return { + status: 201, + body: resBody, + } + }, + + patchAdminRoles: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await patchRoles(body, req.id) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + adminRoleMemberCounts: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await countRolesMembers() + + return { + status: 200, + body: resBody, + } + }, + + deleteAdminRole: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await deleteRole(params.roleId, req.id) + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/admin-token/business.spec.ts b/.jjconflict-base-0/apps/server/src/resources/admin-token/business.spec.ts new file mode 100644 index 0000000000..2a698ac744 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/admin-token/business.spec.ts @@ -0,0 +1,73 @@ +import type { AdminToken } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { createToken, deleteToken, listTokens } from './business.ts' + +describe('test admin-token business', () => { + describe('listTokens', () => { + it('should stringify bigint', async () => { + const partialtoken: Partial = { + permissions: 4n, + } + + prisma.adminToken.findMany.mockResolvedValueOnce([partialtoken]) + const response = await listTokens({}) + expect(response).toEqual([{ permissions: '4' }]) + }) + it('should return revoked', async () => { + const partialtoken: Partial = { + permissions: 4n, + status: 'revoked', + } + + prisma.adminToken.findMany.mockResolvedValueOnce([partialtoken]) + const response = await listTokens({ withRevoked: true }) + expect(response).toEqual([{ ...partialtoken, permissions: '4' }]) + }) + }) + + describe('createToken', () => { + it('should create ', async () => { + const dbToken: Partial = undefined + const userId = faker.string.uuid() + const createdToken: AdminToken = { + expirationDate: null, + id: faker.string.uuid(), + name: 'test', + permissions: '2', + } + prisma.adminToken.findUnique.mockResolvedValueOnce(dbToken) + prisma.adminToken.create.mockResolvedValueOnce(createdToken) + await createToken({ name: 'test', permissions: '2', expirationDate: null }, userId, undefined) + + expect(prisma.adminToken.create).toHaveBeenCalledWith({ + data: { + name: 'test', + hash: expect.any(String), + permissions: 2n, + userId: expect.any(String), + expirationDate: undefined, + }, + omit: expect.any(Object), + include: { + owner: true, + }, + }) + }) + it('should not create cause expiration is too short', async () => { + const expirationDate = new Date() + await createToken({ name: 'test', permissions: '2', expirationDate: expirationDate.toISOString() }) + + expect(prisma.adminToken.create).toHaveBeenCalledTimes(0) + }) + }) + + describe('deleteToken', () => { + it('should delete token', async () => { + prisma.adminToken.delete.mockResolvedValueOnce(undefined) + await deleteToken(faker.string.uuid()) + expect(prisma.adminToken.updateMany).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/admin-token/business.ts b/.jjconflict-base-0/apps/server/src/resources/admin-token/business.ts new file mode 100644 index 0000000000..0702fde2b9 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/admin-token/business.ts @@ -0,0 +1,75 @@ +import type { adminTokenContract } from '@cpn-console/shared' +import type { $Enums, AdminToken, Prisma } from '@prisma/client' +import { createHash, randomUUID } from 'node:crypto' +import { logger as baseLogger } from '@cpn-console/logger' +import { generateRandomPassword, isAtLeastTomorrow } from '@cpn-console/shared' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../prisma.js' + +const logger = baseLogger.child({ scope: 'resource:admin-token' }) + +export async function listTokens(query: typeof adminTokenContract.listAdminTokens.query._type) { + const where = { + status: { + in: ['active'] as $Enums.TokenStatus[], + }, + } as const satisfies Prisma.AdminTokenWhereInput + + if (query?.withRevoked) where.status.in.push('revoked') + + return prisma.adminToken.findMany({ + omit: { hash: true }, + include: { owner: true }, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + where, + }).then(tokens => + tokens.map(({ permissions, ...token }) => ({ permissions: permissions.toString(), ...token })), + ) +} + +export async function createToken(data: typeof adminTokenContract.createAdminToken.body._type) { + if (data.expirationDate && !isAtLeastTomorrow(new Date(data.expirationDate))) { + return new BadRequest400('Date d\'expiration trop courte') + } + logger.info({ tokenName: data.name, expirationDate: data.expirationDate ?? null }, 'Create admin token started') + const password = generateRandomPassword(48, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-') + const hash = createHash('sha256').update(password).digest('hex') + const botUserId = randomUUID() + await prisma.user.create({ + data: { + firstName: 'Bot Admin', + lastName: data.name, + type: 'bot', + id: botUserId, + email: `${botUserId}@bot.io`, + }, + }) + const token = await prisma.adminToken.create({ + data: { + ...data, + hash, + permissions: BigInt(data.permissions), + expirationDate: data.expirationDate ? new Date(data.expirationDate) : undefined, + userId: botUserId, + }, + omit: { hash: true }, + include: { owner: true }, + }) + logger.info({ adminTokenId: token.id, botUserId, expirationDate: token.expirationDate ?? null, status: token.status }, 'Create admin token completed') + return { + ...token, + password, + permissions: token.permissions.toString(), + } +} + +export async function deleteToken(id: AdminToken['id']) { + logger.info({ adminTokenId: id }, 'Revoke admin token started') + return prisma.adminToken.updateMany({ + where: { id }, + data: { + status: 'revoked', + expirationDate: new Date(Date.now()), + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/admin-token/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/admin-token/router.spec.ts new file mode 100644 index 0000000000..61efec35fc --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/admin-token/router.spec.ts @@ -0,0 +1,161 @@ +import type { ExposedAdminToken } from '@cpn-console/shared' +import type { AdminToken } from '@prisma/client' +import { ADMIN_PERMS, adminTokenContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListTokensMock = vi.spyOn(business, 'listTokens') +const businessCreateTokenMock = vi.spyOn(business, 'createToken') +const businessDeleteTokenMock = vi.spyOn(business, 'deleteToken') + +describe('test adminTokenContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listAdminTokens', () => { + it('should return list of admin tokens', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_ADMIN_TOKEN) + authUserMock.mockResolvedValueOnce(user) + + const tokens: AdminToken[] = [{ + id: faker.string.uuid(), + name: 'token1', + permissions: '2', + lastUse: (new Date()).toISOString(), + expirationDate: null, + status: 'active', + createdAt: (new Date(Date.now())).toISOString(), + }] + businessListTokensMock.mockResolvedValueOnce(tokens) + + const response = await app.inject() + .get(adminTokenContract.listAdminTokens.path) + .end() + + expect(businessListTokensMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(tokens) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(adminTokenContract.listAdminTokens.path) + .end() + + expect(businessListTokensMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createAdminToken', () => { + it('should create a token for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + const newToken = { + id: faker.string.uuid(), + name: 'test', + lastUse: null, + expirationDate: null, + password: faker.string.alpha({ casing: 'lower', length: 10 }), + permissions: '2', + createdAt: (new Date(Date.now())).toISOString(), + status: 'active', + } + const tokenData: ExposedAdminToken = { + name: newToken.name, + permissions: newToken.permissions, + expirationDate: null, + } + + authUserMock.mockResolvedValueOnce(user) + businessCreateTokenMock.mockResolvedValueOnce(newToken) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body(tokenData) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledWith(tokenData) + expect(response.json()).toEqual(newToken) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body({ + name: 'new-token', + expirationDate: null, + permissions: '4', + }) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + authUserMock.mockResolvedValueOnce(user) + businessCreateTokenMock.mockResolvedValueOnce(new BadRequest400('Invalid date')) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body({ + name: 'new-token', + expirationDate: null, + permissions: '4', + }) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(400) + }) + }) + + describe('deleteAdminToken', () => { + const tokenId = faker.string.uuid() + it('should delete a token for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + authUserMock.mockResolvedValueOnce(user) + businessDeleteTokenMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .delete(adminTokenContract.deleteAdminToken.path.replace(':tokenId', tokenId)) + .end() + + expect(businessDeleteTokenMock).toHaveBeenCalledWith(tokenId) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(adminTokenContract.deleteAdminToken.path.replace(':tokenId', tokenId)) + .end() + + expect(businessDeleteTokenMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/admin-token/router.ts b/.jjconflict-base-0/apps/server/src/resources/admin-token/router.ts new file mode 100644 index 0000000000..305b6e527a --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/admin-token/router.ts @@ -0,0 +1,46 @@ +import { AdminAuthorized, adminTokenContract } from '@cpn-console/shared' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { serverInstance } from '../../app.js' +import { createToken, deleteToken, listTokens } from './business.js' + +export function adminTokenRouter() { + return serverInstance.router(adminTokenContract, { + listAdminTokens: async ({ request: req, query }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListAdminToken(perms.adminPermissions)) return new Forbidden403() + const body = await listTokens(query) + + return { + status: 200, + body, + } + }, + + createAdminToken: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageAdminToken(perms.adminPermissions)) return new Forbidden403() + const body = await createToken(data) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + deleteAdminToken: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageAdminToken(perms.adminPermissions)) return new Forbidden403() + await deleteToken(params.tokenId) + + return { + status: 204, + body: null, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/cluster/business.spec.ts b/.jjconflict-base-0/apps/server/src/resources/cluster/business.spec.ts new file mode 100644 index 0000000000..c003e66ae1 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/cluster/business.spec.ts @@ -0,0 +1,171 @@ +import type { Cluster, Environment } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400, ErrorResType, NotFound404, Unprocessable422 } from '../../utils/errors.ts' +import { createCluster, deleteCluster, getClusterAssociatedEnvironments, getClusterDetails, getClusterUsage, listClusters, updateCluster } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const userId = faker.string.uuid() +const requestId = faker.string.uuid() +const cluster: Cluster = { + id: faker.string.uuid(), + infos: faker.lorem.lines(2), + privacy: 'public', + createdAt: new Date(), + updatedAt: new Date(), + zoneId: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + label: faker.string.alpha(10), + secretName: faker.string.alpha(10), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), +} +describe('test Cluster business logic', () => { + describe('listClusters', () => { + it('should filter for user', async () => { + prisma.cluster.findMany.mockResolvedValue([]) + await listClusters(userId) + expect(prisma.cluster.findMany).toHaveBeenCalledTimes(1) + expect(prisma.cluster.findMany).toHaveBeenCalledWith({ select: expect.any(Object), where: { OR: [{ privacy: 'public' }, expect.any(Object), expect.any(Object), expect.any(Object)] } }) + }) + it('should not filter', async () => { + const dbStages = [{ id: faker.string.uuid() }] + prisma.cluster.findMany.mockResolvedValue([{ stages: dbStages }] as unknown as Cluster[]) + const response = await listClusters() + expect(prisma.cluster.findMany).toHaveBeenCalledTimes(1) + expect(prisma.cluster.findMany).toHaveBeenCalledWith({ select: expect.any(Object), where: {} }) + expect(response[0].stageIds).toStrictEqual([dbStages[0].id]) + }) + }) + + describe('getClusterAssociatedEnvironments', () => { + it('should list all environments attached to a cluster', async () => { + const envName = faker.string.alpha(8) + const projectName = faker.string.alpha(8) + const ownerEmail = faker.internet.email() + const cpu = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const gpu = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const memory = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const envs = [{ name: envName, cpu, gpu, memory, project: { name: projectName, owner: { email: ownerEmail } } }] as unknown as Environment[] + prisma.environment.findMany.mockResolvedValue(envs) + const response = await getClusterAssociatedEnvironments(cluster.id) + expect(response).toStrictEqual([{ + name: envName, + project: projectName, + owner: ownerEmail, + cpu, + gpu, + memory, + }]) + }) + }) + + describe('getClusterDetails', () => { + it('should return a cluster details', async () => { + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + await getClusterDetails(cluster.id) + }) + it('should return a cluster details, without infos in db', async () => { + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, infos: null, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + const response = await getClusterDetails(cluster.id) + expect(response.infos).toBe('') + }) + }) + + describe('getClusterUsage', () => { + it('should return a cluster usage', async () => { + prisma.environment.aggregate.mockResolvedValue({ _count: {}, _avg: {}, _min: {}, _max: {}, _sum: { + cpu: 10, + gpu: 5, + memory: 20, + } }) + const response = await getClusterUsage(cluster.id) + expect(response).toStrictEqual({ + cpu: 10, + gpu: 5, + memory: 20, + }) + }) + }) + + describe('createCluster', () => { + it('should create cluster', async () => { + hook.cluster.upsert.mockResolvedValue({ failed: false }) + prisma.cluster.findUnique.mockResolvedValue(null) + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + prisma.cluster.create.mockResolvedValue(cluster) + + const response = await createCluster({ + infos: faker.string.alpha(10), + zoneId: faker.string.uuid(), + privacy: 'public', + stageIds: [], + clusterResources: false, + kubeconfig: { cluster: { tlsServerName: faker.internet.domainName() }, user: {} }, + label: faker.string.alpha(10), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + }, userId, requestId) + + expect(response).not.instanceOf(ErrorResType) + expect(prisma.cluster.create).toHaveBeenCalled() + }) + }) + + describe('updateCluster', () => { + it('should update cluster', async () => { + hook.cluster.upsert.mockResolvedValue({ failed: false }) + prisma.cluster.findUnique.mockResolvedValue(cluster) + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + prisma.cluster.update.mockResolvedValue(cluster) + + const response = await updateCluster({ + infos: faker.string.alpha(10), + zoneId: faker.string.uuid(), + privacy: 'public', + stageIds: [], + }, cluster.id, userId, requestId) + + expect(response).not.instanceOf(ErrorResType) + expect(prisma.cluster.update).toHaveBeenCalled() + }) + it('should return 404', async () => { + prisma.cluster.findUnique.mockResolvedValue(null) + const response = await updateCluster({ infos: faker.string.alpha(10) }, cluster.id, userId, requestId) + expect(response).instanceOf(NotFound404) + }) + }) + + describe('deleteCluster', () => { + it('should delete cluster', async () => { + hook.cluster.delete.mockResolvedValue({}) + await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(prisma.cluster.delete).toHaveBeenCalledTimes(1) + }) + it('should return failed hook', async () => { + hook.cluster.delete.mockResolvedValue({ failed: true }) + const response = await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(response).instanceOf(Unprocessable422) + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + }) + it('should not delete cluster, env attached', async () => { + prisma.environment.findFirst.mockResolvedValue({ id: faker.string.uuid() } as Environment) + const response = await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) +}) + +// findUniqueOrThrow diff --git a/.jjconflict-base-0/apps/server/src/resources/cluster/business.ts b/.jjconflict-base-0/apps/server/src/resources/cluster/business.ts new file mode 100644 index 0000000000..ef542ce33a --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/cluster/business.ts @@ -0,0 +1,230 @@ +import type { Cluster, clusterContract, ClusterDetails, Kubeconfig } from '@cpn-console/shared' +import type { Prisma, Project, User } from '@prisma/client' +import type { Resources } from '@/types/index.js' +import { ClusterDetailsSchema, ClusterPrivacy } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + addLogs, + createCluster as createClusterQuery, + deleteCluster as deleteClusterQuery, + getClusterById, + getClusterByLabel, + getClusterDetails as getClusterDetailsQuery, + getClusterEnvironments, + getProjectsByClusterId, + linkClusterToProjects, + linkZoneToClusters, + listClusters as listClustersQuery, + listStagesByClusterId, + removeClusterFromProject, + removeClusterFromStage, + updateCluster as updateClusterQuery, +} from '@/resources/queries-index.js' +import { linkClusterToStages } from '@/resources/stage/business.js' +import { validateSchema } from '@/utils/business.js' +import { BadRequest400, ErrorResType, NotFound404, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function listClusters(userId?: User['id']) { + const where: Prisma.ClusterWhereInput = userId + ? { + OR: [ + // Sélectionne tous les clusters publics + { privacy: 'public' }, + // Sélectionne les clusters associés aux projets dont l'user est membre + { + projects: { some: { members: { some: { userId } } } }, + }, + // Sélectionne les clusters associés aux projets dont l'user est owner + { + projects: { some: { ownerId: userId } }, + }, + // Sélectionne les clusters associés aux environnments appartenant à des projets dont l'user est membre + { + environments: { some: { project: { members: { some: { userId } } } } }, + }, + ], + } + : {} + const clusters = await listClustersQuery(where) + return clusters.map(({ stages, ...cluster }) => ({ + ...cluster, + stageIds: stages.map(({ id }) => id), + })) +} + +export async function getClusterAssociatedEnvironments(clusterId: string) { + const clusterEnvironments = await getClusterEnvironments(clusterId) + + return clusterEnvironments.map((environment) => { + return ({ + project: environment.project?.name, + name: environment.name, + owner: environment.project.owner.email, + cpu: environment.cpu, + gpu: environment.gpu, + memory: environment.memory, + }) + }) +} + +export async function getClusterDetails(clusterId: string): Promise { + const { infos, projects, stages, kubeconfig, ...details } = await getClusterDetailsQuery(clusterId) + return { + ...details, + infos: infos ?? '', + projectIds: projects.map(project => project.id), + stageIds: stages.map(({ id }) => id), + kubeconfig: { + cluster: kubeconfig.cluster as unknown as Kubeconfig['cluster'], + user: kubeconfig.user as unknown as Kubeconfig['user'], + }, + } +} + +export async function getClusterUsage(clusterId: string): Promise { + const clusterUsage = await prisma.environment.aggregate({ + _sum: { + memory: true, + cpu: true, + gpu: true, + }, + where: { + clusterId, + }, + }) + return { + cpu: clusterUsage._sum.cpu ?? 0, + gpu: clusterUsage._sum.gpu ?? 0, + memory: clusterUsage._sum.memory ?? 0, + } +} + +export async function createCluster(data: typeof clusterContract.createCluster.body._type, userId: User['id'], requestId: string) { + const isLabelTaken = await getClusterByLabel(data.label) + if (isLabelTaken) return new BadRequest400('Ce label existe déjà pour un autre cluster') + + data.projectIds = data.privacy === ClusterPrivacy.PUBLIC + ? [] + : data.projectIds ?? [] + + const { + projectIds, + stageIds, + kubeconfig, + zoneId, + ...clusterData + } = data + + const clusterCreated = await createClusterQuery(clusterData, kubeconfig, zoneId) + + if (data.privacy === ClusterPrivacy.DEDICATED && projectIds.length) { + await linkClusterToProjects(clusterCreated.id, projectIds) + } + + if (stageIds?.length) { + await linkClusterToStages(clusterCreated.id, stageIds) + } + + const hookReply = await hook.cluster.upsert(clusterCreated.id, zoneId) + await addLogs({ action: 'Create Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la création du cluster') + } + + return getClusterDetails(clusterCreated.id) +} + +export async function updateCluster(data: typeof clusterContract.updateCluster.body._type, clusterId: Cluster['id'], userId: User['id'], requestId: string): Promise { + if (data?.privacy === ClusterPrivacy.PUBLIC) delete data.projectIds + + const schemaValidation = ClusterDetailsSchema.partial().safeParse({ ...data, id: clusterId }) + const validateResult = validateSchema(schemaValidation) + if (validateResult instanceof ErrorResType) return validateResult + + const dbCluster = await getClusterById(clusterId) + if (!dbCluster) return new NotFound404() + + const { + projectIds, + stageIds, + kubeconfig, + zoneId, + ...clusterData + } = data + + const clusterUpdated = await updateClusterQuery(clusterId, clusterData, + // @ts-ignore + kubeconfig) + + // zone + if (zoneId) { + await linkZoneToClusters(zoneId, [clusterId]) + } + + // projects + const dbProjects = await getProjectsByClusterId(clusterId) + + let projectsToRemove: Project['id'][] = [] + + if (projectIds && clusterUpdated.privacy === ClusterPrivacy.DEDICATED) { + await linkClusterToProjects(clusterId, projectIds) + projectsToRemove = dbProjects?.map(project => project.id)?.filter(dbProjectId => !projectIds.includes(dbProjectId)) ?? [] + } else if (clusterUpdated.privacy === ClusterPrivacy.PUBLIC) { + projectsToRemove = dbProjects?.map(project => project.id) ?? [] + } + + for (const projectId of projectsToRemove) { + await removeClusterFromProject(clusterUpdated.id, projectId) + } + + // stages + if (stageIds) { + await linkClusterToStages(clusterId, stageIds) + + const dbStages = await listStagesByClusterId(clusterId) + if (dbStages) { + for (const stage of dbStages) { + if (!stageIds.includes(stage.id)) { + await removeClusterFromStage(clusterUpdated.id, stage.id) + } + } + } + } + + const hookReply = await hook.cluster.upsert(clusterId, dbCluster.zoneId) + await addLogs({ action: 'Update Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la mise à jour du cluster') + } + + return getClusterDetails(clusterId) +} + +interface DeleteClusterArgs { + clusterId: Cluster['id'] + userId?: User['id'] + requestId: string + force?: boolean +} +export async function deleteCluster({ clusterId, requestId, force, userId }: DeleteClusterArgs) { + let message: string | null = null + if (force) { + const envs = await prisma.environment.deleteMany({ + where: { clusterId }, + }) + message = `${envs.count} environnements supprimés de force, n'oubliez pas de reprovisionner les projets concernés` + } else { + const environment = await prisma.environment.findFirst({ where: { clusterId } }) + if (environment) return new BadRequest400('Impossible de supprimer le cluster, des environnements en activité y sont déployés') + } + + const hookReply = await hook.cluster.delete(clusterId) + await addLogs({ action: 'Delete Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la suppression du cluster') + } + + await deleteClusterQuery(clusterId) + return message +} diff --git a/.jjconflict-base-0/apps/server/src/resources/cluster/queries.ts b/.jjconflict-base-0/apps/server/src/resources/cluster/queries.ts new file mode 100644 index 0000000000..f66e1fc0bc --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/cluster/queries.ts @@ -0,0 +1,309 @@ +import type { Cluster, Environment, Kubeconfig, Prisma, Project, Stage } from '@prisma/client' +import prisma from '@/prisma.js' + +export async function getClustersAssociatedWithProject(projectId: Project['id']) { + const [ + clusterIdsHistory, + clusterIdsEnv, + ] = await Promise.all([ + prisma.projectClusterHistory.findMany({ + select: { + clusterId: true, + }, + where: { + projectId, + }, + }).then(history => history.map(({ clusterId }) => clusterId)), + prisma.cluster.findMany({ + where: { environments: { some: { project: { id: projectId } } } }, + select: { id: true }, + }).then(cluster => cluster.map(({ id }) => id)), + ]) + const clusterIds = [ + ...clusterIdsHistory, + ...clusterIdsEnv.filter(id => !clusterIdsHistory.includes(id)), + ] + return prisma.cluster.findMany({ + where: { id: { in: clusterIds } }, + select: { + id: true, + infos: true, + label: true, + privacy: true, + secretName: true, + kubeconfig: true, + clusterResources: true, + cpu: true, + gpu: true, + memory: true, + zone: { + select: { + id: true, + slug: true, + argocdUrl: true, + label: true, + }, + }, + }, + }) +} + +export async function updateProjectClusterHistory(projectId: Project['id'], clusterIds: Cluster['id'][]) { + return prisma.$transaction([ + prisma.projectClusterHistory.deleteMany({ + where: { + AND: { + projectId, + clusterId: { notIn: clusterIds }, + }, + }, + }), + prisma.projectClusterHistory.createMany({ + data: clusterIds.map(clusterId => ({ clusterId, projectId })), + skipDuplicates: true, + }), + ]) +} + +export function getClusterById(id: Cluster['id']) { + return prisma.cluster.findUnique({ + where: { id }, + include: { kubeconfig: true }, + }) +} + +export function getClusterByIdOrThrow(id: Cluster['id']) { + return prisma.cluster.findUniqueOrThrow({ + where: { id }, + include: { kubeconfig: true, zone: true }, + }) +} + +export function getClusterEnvironments(clusterId: Cluster['id']) { + return prisma.environment.findMany({ + where: { clusterId }, + select: { + name: true, + cpu: true, + gpu: true, + memory: true, + project: { + select: { + slug: true, + name: true, + owner: true, + members: true, + }, + }, + }, + }) +} + +export function getClusterDetails(id: Cluster['id']) { + return prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { + createdAt: true, + projects: { + select: { + id: true, + }, + }, + id: true, + clusterResources: true, + infos: true, + label: true, + privacy: true, + kubeconfig: true, + stages: true, + updatedAt: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export function getClustersByIds(clusterIds: Cluster['id'][]) { + return prisma.cluster.findMany({ + where: { + id: { in: clusterIds }, + }, + include: { kubeconfig: true }, + }) +} + +export function getPublicClusters() { + return prisma.cluster.findMany({ + where: { privacy: 'public' }, + include: { zone: true }, + }) +} + +export async function getClusterNamesByZoneId(zoneId: string) { + const clusterNames = await prisma.cluster.findMany({ + where: { zoneId }, + select: { + label: true, + }, + }) + return clusterNames.map(({ label }) => label) +} + +export function getClusterByLabel(label: Cluster['label']) { + return prisma.cluster.findUnique({ where: { label } }) +} + +export function getClusterByEnvironmentId(id: Environment['id']) { + return prisma.cluster.findMany({ + where: { + environments: { + some: { id }, + }, + }, + include: { kubeconfig: true }, + }) +} + +export function getClustersWithProjectIdAndConfig() { + return prisma.cluster.findMany({ + select: { + id: true, + stages: true, + projects: { + where: { + status: { not: 'archived' }, + }, + select: { + id: true, + name: true, + slug: true, + status: true, + }, + }, + clusterResources: true, + label: true, + infos: true, + privacy: true, + secretName: true, + kubeconfig: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export function listClusters(where: Prisma.ClusterWhereInput) { + return prisma.cluster.findMany({ + where, + select: { + id: true, + label: true, + stages: true, + clusterResources: true, + privacy: true, + infos: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export async function getProjectsByClusterId(id: Cluster['id']) { + return (await prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { projects: true }, + }))?.projects +} + +export async function listStagesByClusterId(id: Cluster['id']) { + return (await prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { stages: true }, + }))?.stages +} + +export function createCluster(data: Omit, kubeconfig: Pick, zoneId: string) { + return prisma.cluster.create({ + data: { + ...data, + // @ts-ignore + kubeconfig: { create: kubeconfig }, + zone: { + connect: { id: zoneId }, + }, + }, + }) +} + +export function updateCluster(id: Cluster['id'], data: Partial>, kubeconfig: Pick) { + return prisma.cluster.update({ + where: { id }, + data: { + ...data, + kubeconfig: { + // @ts-ignore + update: kubeconfig, + }, + }, + }) +} + +export function linkClusterToProjects(id: Cluster['id'], projectIds: Project['id'][]) { + return prisma.cluster.update({ + where: { id }, + data: { + projects: { + connect: projectIds.map(projectId => ({ id: projectId })), + }, + }, + }) +} + +export function linkClusterToStages(id: Cluster['id'], stageIds: Stage['id'][]) { + return prisma.cluster.update({ + where: { id }, + data: { + stages: { + connect: stageIds.map(stageId => ({ id: stageId })), + }, + }, + }) +} + +export function removeClusterFromProject(id: Cluster['id'], projectId: Project['id']) { + return prisma.cluster.update({ + where: { id }, + data: { + projects: { + disconnect: { + id: projectId, + }, + }, + }, + }) +} + +export function removeClusterFromStage(id: Cluster['id'], stageId: Stage['id']) { + return prisma.cluster.update({ + where: { id }, + data: { + stages: { + disconnect: { + id: stageId, + }, + }, + }, + }) +} + +export function deleteCluster(id: Cluster['id']) { + return prisma.cluster.delete({ + where: { id }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/cluster/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/cluster/router.spec.ts new file mode 100644 index 0000000000..528858b052 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/cluster/router.spec.ts @@ -0,0 +1,308 @@ +import type { ClusterDetails, Environment } from '@cpn-console/shared' +import { ADMIN_PERMS, clusterContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listClusters') +const businessGetDetailsMock = vi.spyOn(business, 'getClusterDetails') +const businessGetUsageMock = vi.spyOn(business, 'getClusterUsage') +const businessGetEnvironmentsMock = vi.spyOn(business, 'getClusterAssociatedEnvironments') +const businessCreateMock = vi.spyOn(business, 'createCluster') +const businessUpdateMock = vi.spyOn(business, 'updateCluster') +const businessDeleteMock = vi.spyOn(business, 'deleteCluster') + +describe('test clusterContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listClusters', () => { + it('as non admin', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + businessListMock.mockResolvedValueOnce([]) + const response = await app.inject() + .get(clusterContract.listClusters.path) + .end() + + expect(businessListMock).toHaveBeenCalledWith(user.user.id) + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('as admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + + authUserMock.mockResolvedValueOnce(user) + + businessListMock.mockResolvedValueOnce([]) + const response = await app.inject() + .get(clusterContract.listClusters.path) + .end() + + expect(businessListMock).toHaveBeenCalledWith() + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getClusterDetails', () => { + it('should return cluster details', async () => { + const cluster: ClusterDetails = { + id: faker.string.uuid(), + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetDetailsMock.mockResolvedValueOnce(cluster) + const response = await app.inject() + .get(clusterContract.getClusterDetails.path.replace(':clusterId', cluster.id)) + .end() + + expect(businessGetDetailsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(cluster) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterDetails.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetDetailsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getClusterUsage', () => { + it('should return cluster usage', async () => { + const resources = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetUsageMock.mockResolvedValueOnce(resources) + const response = await app.inject() + .get(clusterContract.getClusterUsage.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetUsageMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(resources) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterUsage.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetUsageMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getClusterEnvironments', () => { + it('should return cluster environments', async () => { + const envs: Environment[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(envs) + const response = await app.inject() + .get(clusterContract.getClusterEnvironments.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterEnvironments.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createCluster', () => { + const cluster: ClusterDetails = { + id: faker.string.uuid(), + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + + it('should return created cluster', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(cluster) + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.json()).toEqual(cluster) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateCluster', () => { + const clusterId = faker.string.uuid() + const cluster: Omit = { + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + + it('should return created cluster', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: clusterId, ...cluster }) + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.json()).toEqual({ id: clusterId, ...cluster }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteCluster', () => { + it('should return empty when delete', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/cluster/router.ts b/.jjconflict-base-0/apps/server/src/resources/cluster/router.ts new file mode 100644 index 0000000000..3924bce6a9 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/cluster/router.ts @@ -0,0 +1,131 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, clusterContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + createCluster, + deleteCluster, + getClusterAssociatedEnvironments, + getClusterDetails as getClusterDetailsBusiness, + getClusterUsage, + listClusters, + updateCluster, +} from './business.js' +import '@/types/index.js' + +export function clusterRouter() { + return serverInstance.router(clusterContract, { + listClusters: async ({ request: req }) => { + const { adminPermissions, user } = await authUser(req) + + let body: AsyncReturnType = [] + if (AdminAuthorized.ListClusters(adminPermissions)) { + body = await listClusters() + } else if (user) { + body = await listClusters(user.id) + } + + return { + status: 200, + body, + } + }, + + getClusterDetails: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const cluster = await getClusterDetailsBusiness(clusterId) + + return { + status: 200, + body: cluster, + } + }, + + getClusterUsage: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const usage = await getClusterUsage(clusterId) + + return { + status: 200, + body: usage, + } + }, + + createCluster: async ({ request: req, body: data }) => { + const { adminPermissions, user } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + + if (!user) return new Unauthorized401('Require to be requested from user not api key') + const body = await createCluster(data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + getClusterEnvironments: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const environments = await getClusterAssociatedEnvironments(clusterId) + + return { + status: 200, + body: environments, + } + }, + + updateCluster: async ({ request: req, params, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const clusterId = params.clusterId + const body = await updateCluster(data, clusterId, user.id, req.id) + + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + deleteCluster: async ({ request: req, params, query: { force } }) => { + const { user, adminPermissions, tokenId } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + if (!user?.id && !tokenId) return new Unauthorized401('Your identity has not been found') + + const clusterId = params.clusterId + const body = await deleteCluster({ + clusterId, + userId: user?.id, + requestId: req.id, + force, + }) + + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/environment/business.spec.ts b/.jjconflict-base-0/apps/server/src/resources/environment/business.spec.ts new file mode 100644 index 0000000000..7bfebb5b2e --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/environment/business.spec.ts @@ -0,0 +1,353 @@ +import type { Cluster, Environment, Project, ProjectMembers, ProjectRole, Stage, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { Result } from '../../utils/business.js' +import { checkClusterResources, checkProjectResources, createEnvironment, deleteEnvironment, getProjectEnvironments, updateEnvironment } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const user: User = { + id: faker.string.uuid(), + createdAt: new Date(), + updatedAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + lastLogin: null, +} +const project: Project & { + clusters: Pick[] + members: ProjectMembers[] + roles: ProjectRole[] + owner: User +} = { + createdAt: new Date(), + updatedAt: new Date(), + description: '', + everyonePerms: 649n, + id: faker.string.uuid(), + locked: false, + name: faker.string.alphanumeric(8), + status: 'created', + ownerId: faker.string.uuid(), + owner: user, + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + clusters: [], + roles: [], + members: [], + slug: faker.string.alphanumeric(8), + lastSuccessProvisionningVersion: faker.string.numeric(), +} + +describe('test environment business', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getProjectEnvironments', () => { + it('should query environment for projectId', async () => { + prisma.environment.findMany.mockResolvedValue([]) + const projectId = faker.string.uuid() + await getProjectEnvironments(projectId) + + expect(prisma.environment.findMany).toHaveBeenCalledTimes(1) + }) + }) + + describe('createEnvironment', () => { + const clusterId = faker.string.uuid() + const stageId = faker.string.uuid() + const env = { name: 'new-env' } + it('should create environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const stageId = faker.string.uuid() + + prisma.environment.create.mockResolvedValue({ clusterId } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await createEnvironment({ + userId: user.id, + projectId: project.id, + name: env.name, + cpu: 0.1, + gpu: 0.5, + memory: 2.0, + clusterId, + stageId, + requestId, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should create environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + + prisma.environment.create.mockResolvedValue({ clusterId } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await createEnvironment({ + userId: user.id, + projectId: project.id, + name: env.name, + cpu: 0.1, + gpu: 0.5, + memory: 2.0, + clusterId, + stageId, + requestId, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('updateEnvironment', () => { + it('should update environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.update.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await updateEnvironment({ + user, + environmentId, + requestId, + cpu: 2.0, + gpu: 4.0, + memory: 12.5, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should update environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.update.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await updateEnvironment({ + user, + environmentId, + requestId, + cpu: 2.0, + gpu: 4.0, + memory: 12.5, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('deleteEnvironment', () => { + it('should delete environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.delete.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await deleteEnvironment({ environmentId, userId: user.id, projectId: project.id, requestId }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.delete).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should delete environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.delete.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await deleteEnvironment({ environmentId, userId: user.id, projectId: project.id, requestId }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.delete).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('checkClusterResources', () => { + it('should authorize cluster not yet configured', async () => { + const cluster: Cluster = { + cpu: 0, + gpu: 0, + memory: 0, + } as Cluster + const result = await checkClusterResources({ cpu: 1, gpu: 0, memory: 1 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should authorize cluster not yet used', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 0, + gpu: 0, + memory: 0, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 7 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should authorize cluster used but not full', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 2, + gpu: 0, + memory: 2, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 6 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should refuse cluster without enough space', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 5, + gpu: 0, + memory: 5, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 6 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le cluster ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + it('should refuse cluster without GPU', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 2, + gpu: 0, + memory: 2, + }, + } as any) + const result = await checkClusterResources({ cpu: 2, gpu: 1, memory: 2 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le cluster ne dispose pas de suffisamment de ressources : GPU.') + }) + }) + + describe('checkProjectResources', () => { + const prodStage: Stage = { + id: faker.string.uuid(), + name: 'prod', + } + const hprodStage: Stage = { + id: faker.string.uuid(), + name: 'hprod', + } + it('should authorize prod deployment for project with hprod resource but no prod resources', async () => { + const project: Project = { + hprodCpu: 10, + hprodGpu: 10, + hprodMemory: 10, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + } as Project + prisma.stage.findUnique.mockResolvedValue(prodStage) + prisma.stage.findMany.mockResolvedValue([prodStage]) + const result = await checkProjectResources({ cpu: 1, gpu: 0, memory: 1, stageId: prodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should refuse hprod deployment for project with hprod resource but no prod resources', async () => { + const project: Project = { + hprodCpu: 10, + hprodGpu: 10, + hprodMemory: 10, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + } as Project + prisma.stage.findUnique.mockResolvedValue(hprodStage) + prisma.stage.findMany.mockResolvedValue([prodStage] as Stage[]) + prisma.environment.aggregate.mockResolvedValue({ + _sum: { cpu: 0, gpu: 0, memory: 0 }, + } as any) + const result = await checkProjectResources({ cpu: 20, gpu: 20, memory: 20, stageId: hprodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le projet ne dispose pas de suffisamment de ressources : CPU, GPU, Mémoire.') + }) + it('should refuse overloading hprod deployment', async () => { + const project: Project = { + hprodCpu: 20, + hprodGpu: 20, + hprodMemory: 20, + prodCpu: 10, + prodGpu: 10, + prodMemory: 10, + } as Project + prisma.stage.findUnique.mockResolvedValue(hprodStage) + prisma.stage.findMany.mockResolvedValue([prodStage] as Stage[]) + prisma.environment.aggregate.mockResolvedValue({ + _sum: { cpu: 15, gpu: 15, memory: 15 }, + } as any) + const result = await checkProjectResources({ cpu: 5, gpu: 6, memory: 5, stageId: hprodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le projet ne dispose pas de suffisamment de ressources : GPU.') + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/environment/business.ts b/.jjconflict-base-0/apps/server/src/resources/environment/business.ts new file mode 100644 index 0000000000..2f068dedf8 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/environment/business.ts @@ -0,0 +1,306 @@ +import type { Cluster, Environment, Project, Stage, User } from '@prisma/client' +import type { Resources, UserDetails } from '@/types/index.js' +import prisma from '@/prisma.js' +import { + addLogs, + deleteEnvironment as deleteEnvironmentQuery, + getEnvironmentsByProjectId, + initializeEnvironment, + updateEnvironment as updateEnvironmentQuery, +} from '@/resources/queries-index.js' +import { Result } from '@/utils/business.js' +import { hook } from '@/utils/hook-wrapper.js' + +export function getProjectEnvironments(projectId: Project['id']) { + return getEnvironmentsByProjectId(projectId) +} + +// Routes logic +interface CreateEnvironmentParam { + userId: User['id'] + projectId: Project['id'] + name: Environment['name'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + clusterId: Environment['clusterId'] + stageId: Stage['id'] + requestId: string +} + +interface CreateEnvironmentResult { + id: Environment['id'] + createdAt: Date + updatedAt: Date + projectId: Project['id'] + name: Environment['name'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + clusterId: Environment['clusterId'] + stageId: Stage['id'] +} + +export async function createEnvironment({ + userId, + projectId, + name, + cpu, + gpu, + memory, + autosync, + clusterId, + stageId, + requestId, +}: CreateEnvironmentParam): Promise> { + const environment = await initializeEnvironment({ projectId, name, cpu, gpu, memory, autosync, clusterId, stageId }) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Create Environment', data: results, userId, requestId, projectId }) + if (results.failed) { + return Result.fail('Echec des services à la création de l\'environnement') + } + + return Result.succeed({ + ...environment, + stageId, + }) +} + +interface UpdateEnvironmentParam { + user: UserDetails + environmentId: Environment['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + requestId: string +} + +export async function updateEnvironment({ + user, + environmentId, + requestId, + cpu, + gpu, + memory, + autosync, +}: UpdateEnvironmentParam) { + const env = await updateEnvironmentQuery({ + id: environmentId, + cpu, + gpu, + memory, + autosync, + }) + const { results } = await hook.project.upsert(env.projectId) + await addLogs({ action: 'Update Environment', data: results, userId: user.id, requestId, projectId: env.projectId }) + if (results.failed) { + return Result.fail('Echec des services à la mise à jour de l\'environnement') + } + + return Result.succeed(env) +} + +interface DeleteEnvironmentParam { + userId?: User['id'] + environmentId: Environment['id'] + projectId: Project['id'] + requestId: string +} + +export async function deleteEnvironment({ + userId, + environmentId, + projectId, + requestId, +}: DeleteEnvironmentParam) { + const env = await deleteEnvironmentQuery(environmentId) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Delete Environment', data: results, userId, requestId, projectId: env.projectId }) + if (results.failed) { + return Result.fail('Echec des services à la suppression de l\'environnement') + } + return Result.succeed(null) +} + +export async function checkEnvironmentCreate(input: { + clusterId: Cluster['id'] + projectId: Project['id'] + name: Environment['name'] + stageId: Stage['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}): Promise> { + const errorMessages: string[] = [] + const [stage, sameNameEnvironment, cluster] = await Promise.all([ + input.stageId + ? prisma.stage.findUnique({ where: { id: input.stageId } }) + : undefined, + input.name + ? prisma.environment.findUnique({ where: { projectId_name: { projectId: input.projectId, name: input.name } } }) + : undefined, + input.clusterId + ? prisma.cluster.findFirst({ + where: { + OR: [{ // un cluster public + id: input.clusterId, + privacy: 'public', + }, { + id: input.clusterId, // un cluster dédié rattaché au projet + privacy: 'dedicated', + projects: { some: { id: input.projectId } }, + }], + }, + }) + : undefined, + ]) + if (sameNameEnvironment) errorMessages.push('Ce nom d\'environnement est déjà pris.') + if (!stage) errorMessages.push('Type d\'environnment invalide.') + if (!cluster) { + errorMessages.push('Cluster invalide.') + } else { + const resourceCheckResult = await checkClusterResources(input, cluster) + if (resourceCheckResult.isError) { + errorMessages.push(resourceCheckResult.error) + } + const project = await prisma.project.findUniqueOrThrow({ where: { id: input.projectId } }) + const projectCheckResult = await checkProjectResources(input, project) + if (projectCheckResult.isError) { + errorMessages.push(projectCheckResult.error) + } + } + if (errorMessages.length > 0) { + return Result.fail(errorMessages.join('\n')) + } + return Result.succeed(true) +} + +export async function checkClusterResources(input: { + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}, cluster: Cluster): Promise> { + if (cluster.cpu === 0 && cluster.memory === 0) { + // Unconfigured cluster + return Result.succeed(true) + } + const unsufficientResource = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: cluster.cpu, gpu: cluster.gpu, memory: cluster.memory }, + where: { + cluster: { + id: cluster.id, + }, + }, + }) + if (unsufficientResource.length > 0) { + return Result.fail(`Le cluster ne dispose pas de suffisamment de ressources : ${unsufficientResource.join(', ')}.`) + } + return Result.succeed(true) +} + +export async function checkProjectResources(input: { + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + stageId: Environment['stageId'] +}, project: Project): Promise> { + if (project.limitless) { + // No limits + return Result.succeed(true) + } + const stage = await prisma.stage.findUnique({ where: { id: input.stageId } }) + const prodStages = await prisma.stage.findMany({ select: { id: true }, where: { name: 'prod' } }) + let overflowResources: string[] + if (stage?.name === 'prod') { + overflowResources = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: project.prodCpu, gpu: project.prodGpu, memory: project.prodMemory }, + where: { + projectId: project.id, + stageId: { + in: prodStages.map(s => s.id), + }, + }, + }) + } else { // hprod + overflowResources = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: project.hprodCpu, gpu: project.hprodGpu, memory: project.hprodMemory }, + where: { + projectId: project.id, + stageId: { + notIn: prodStages.map(s => s.id), + }, + }, + }) + } + if (overflowResources.length > 0) { + return Result.fail(`Le projet ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.`) + } + return Result.succeed(true) +} + +export async function checkEnvironmentUpdate(input: { + environmentId: Environment['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}): Promise> { + const environment = await prisma.environment.findUniqueOrThrow({ + select: { cluster: true, projectId: true, stageId: true }, + where: { id: input.environmentId }, + }) + const cluster = await prisma.cluster.findUniqueOrThrow({ + where: { id: environment.cluster.id }, + }) + const errorMessages: string[] = [] + const resourceCheckResult = await checkClusterResources(input, cluster) + if (resourceCheckResult.isError) { + errorMessages.push(resourceCheckResult.error) + } + const project = await prisma.project.findUniqueOrThrow({ where: { id: environment.projectId } }) + const projectCheckResult = await checkProjectResources({ stageId: environment.stageId, ...input }, project) + if (projectCheckResult.isError) { + errorMessages.push(projectCheckResult.error) + } + if (errorMessages.length > 0) { + return Result.fail(errorMessages.join('\n')) + } + return Result.succeed(true) +} + +export async function getOverflowResources({ request, limit, where }: { + request: Resources + limit: Resources + where: any +}): Promise { + if (limit.cpu === 0 && limit.memory === 0) { + // Unconfigured project prod resources + return [] + } + const environmentResources = await prisma.environment.aggregate({ + _sum: { + memory: true, + cpu: true, + gpu: true, + }, + where, + }) + const unsufficientResource: string[] = [] + if ((environmentResources._sum.cpu ?? 0) + request.cpu > limit.cpu) { + unsufficientResource.push('CPU') + } + if ((environmentResources._sum.gpu ?? 0) + request.gpu > limit.gpu) { + unsufficientResource.push('GPU') + } + if ((environmentResources._sum.memory ?? 0) + request.memory > limit.memory) { + unsufficientResource.push('Mémoire') + } + return unsufficientResource +} diff --git a/.jjconflict-base-0/apps/server/src/resources/environment/queries.ts b/.jjconflict-base-0/apps/server/src/resources/environment/queries.ts new file mode 100644 index 0000000000..6b228e2687 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/environment/queries.ts @@ -0,0 +1,99 @@ +import type { Environment, Prisma, Project } from '@prisma/client' +import prisma from '@/prisma.js' + +// SELECT +export function getEnvironmentByIdOrThrow(id: Environment['id']) { + return prisma.environment.findUniqueOrThrow({ where: { id }, include: { stage: true } }) +} + +export function getEnvironmentInfos(id: Environment['id']) { + return prisma.environment.findUniqueOrThrow({ + where: { id }, + include: { + project: { + select: { + owner: true, + name: true, + id: true, + status: true, + repositories: { + where: { isInfra: true }, + }, + locked: true, + clusters: { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + }, + }, + }, + }, + stage: true, + }, + }) +} + +export async function getEnvironmentsByProjectId(projectId: Project['id']) { + return prisma.environment.findMany({ + where: { projectId }, + include: { + stage: true, + }, + }) +} + +export function getEnvironmentByIdWithCluster(id: Environment['id']) { + return prisma.environment.findUnique({ + where: { id }, + include: { + cluster: { + include: { kubeconfig: true }, + }, + }, + }) +} + +// INSERT +export function initializeEnvironment(data: Prisma.EnvironmentUncheckedCreateInput) { + return prisma.environment.create({ + data, + include: { + project: { + include: { + repositories: { + where: { isInfra: true }, + }, + }, + }, + }, + }) +} + +export function updateEnvironment({ id, cpu, gpu, memory, autosync }: { id: Environment['id'], cpu: Environment['cpu'], gpu: Environment['gpu'], memory: Environment['memory'], autosync: Environment['autosync'] }) { + return prisma.environment.update({ + where: { + id, + }, + data: { + cpu, + gpu, + memory, + autosync, + }, + }) +} + +// DELETE +export function deleteEnvironment(id: Environment['id']) { + return prisma.environment.delete({ + where: { id }, + }) +} + +export function deleteAllEnvironmentForProject(id: Project['id']) { + return prisma.environment.deleteMany({ + where: { projectId: id }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/environment/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/environment/router.spec.ts new file mode 100644 index 0000000000..2fbff26774 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/environment/router.spec.ts @@ -0,0 +1,340 @@ +import type { Environment } from '@cpn-console/shared' +import { environmentContract, PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { atDates, getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetProjectEnvironmentsMock = vi.spyOn(business, 'getProjectEnvironments') +const businessCreateEnvironmentMock = vi.spyOn(business, 'createEnvironment') +const businessUpdateEnvironmentMock = vi.spyOn(business, 'updateEnvironment') +const businessDeleteEnvironmentMock = vi.spyOn(business, 'deleteEnvironment') +const businessCheckEnvironmentCreateMock = vi.spyOn(business, 'checkEnvironmentCreate') +const businessCheckEnvironmentUpdateMock = vi.spyOn(business, 'checkEnvironmentUpdate') + +describe('environmentRouter tests', () => { + let projectId: string + let environmentId: string + let environmentData: Omit + + beforeEach(() => { + vi.resetAllMocks() + projectId = faker.string.uuid() + environmentId = faker.string.uuid() + environmentData = { + projectId, + name: 'envname', + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + } + }) + + describe('listEnvironments', () => { + it('should return environments for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetProjectEnvironmentsMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(environmentContract.listEnvironments.path) + .query({ projectId }) + .end() + + expect(businessGetProjectEnvironmentsMock).toHaveBeenCalledWith(projectId) + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for non member of projectId query ', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(environmentContract.listEnvironments.path) + .query({ projectId }) + .end() + + expect(businessGetProjectEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createEnvironment', () => { + it('should create environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ success: true }) + businessCreateEnvironmentMock.mockResolvedValueOnce({ + success: true, + data: { id: environmentId, ...environmentData, ...atDates }, + }) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.json()).toMatchObject({ id: environmentId, ...environmentData }) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ success: true, message: 'pas d erreur' }) + businessCreateEnvironmentMock.mockResolvedValueOnce({ isError: true, message: 'une erreur' }) + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(500) + }) + it('should pass invalid reason error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ isError: true, message: 'une erreur' }) + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateEnvironment', () => { + let updateData: { cpu: number, gpu: number, memory: number } + beforeEach(() => { + updateData = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + } + }) + + it('should update environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentUpdateMock.mockResolvedValueOnce({ success: true, value: true }) + businessUpdateEnvironmentMock.mockResolvedValueOnce({ success: true, data: { id: environmentId, ...environmentData, ...atDates } }) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.json()).toMatchObject({ id: environmentId, ...environmentData }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateEnvironmentMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(500) + }) + + it('should pass invalid reason error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentUpdateMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('deleteEnvironment', () => { + it('should delete environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteEnvironmentMock.mockResolvedValueOnce({ success: true }) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteEnvironmentMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(500) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/environment/router.ts b/.jjconflict-base-0/apps/server/src/resources/environment/router.ts new file mode 100644 index 0000000000..7ef91c05ff --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/environment/router.ts @@ -0,0 +1,107 @@ +import { environmentContract, ProjectAuthorized } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { BadRequest400, Forbidden403, Internal500, Unauthorized401 } from '@/utils/errors.js' +import { checkEnvironmentCreate, checkEnvironmentUpdate, createEnvironment, deleteEnvironment, getProjectEnvironments, updateEnvironment } from './business.js' + +export function environmentRouter() { + return serverInstance.router(environmentContract, { + listEnvironments: async ({ request: req, query }) => { + const projectId = query.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListEnvironments(perms)) return new Forbidden403() + const body = await getProjectEnvironments(projectId) + + return { + status: 200, + body, + } + }, + + createEnvironment: async ({ request: req, body: requestBody }) => { + const projectId = requestBody.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const checkCreateResult = await checkEnvironmentCreate({ ...requestBody }) + if (checkCreateResult.isError) return new BadRequest400(checkCreateResult.error) + + const result = await createEnvironment({ + userId: perms.user.id, + projectId, + name: requestBody.name, + clusterId: requestBody.clusterId, + cpu: requestBody.cpu, + gpu: requestBody.gpu, + memory: requestBody.memory, + autosync: requestBody.autosync, + stageId: requestBody.stageId, + requestId: req.id, + }) + if (result.isError) { + return new Internal500(result.error) + } + return { + status: 201, + body: result.data, + } + }, + + updateEnvironment: async ({ request: req, body: requestBody, params }) => { + const { environmentId } = params + const perms = await authUser(req, { environmentId }) + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const checkUpdateResult = await checkEnvironmentUpdate({ environmentId, ...requestBody }) + if (checkUpdateResult.isError) return new BadRequest400(checkUpdateResult.error) + + const result = await updateEnvironment({ + user: perms.user, + environmentId, + cpu: requestBody.cpu, + gpu: requestBody.gpu, + memory: requestBody.memory, + autosync: requestBody.autosync, + requestId: req.id, + }) + if (result.isError) { + return new Internal500(result.error) + } + return { + status: 200, + body: result.data, + } + }, + + deleteEnvironment: async ({ request: req, params }) => { + const { environmentId } = params + const perms = await authUser(req, { environmentId }) + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const result = await deleteEnvironment({ + userId: perms.user?.id, + environmentId, + requestId: req.id, + projectId: perms.projectId, + }) + if (result.isError) { + return new Internal500(result.error) + } + + return { + status: 204, + body: result.data, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/index.ts b/.jjconflict-base-0/apps/server/src/resources/index.ts new file mode 100644 index 0000000000..93a285f9ac --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/index.ts @@ -0,0 +1,49 @@ +import type { FastifyInstance } from 'fastify' +import { serverInstance } from '@/app.js' + +import { adminRoleRouter } from './admin-role/router.js' +import { adminTokenRouter } from './admin-token/router.js' +import { clusterRouter } from './cluster/router.js' +import { environmentRouter } from './environment/router.js' +import { logRouter } from './log/router.js' +import { projectMemberRouter } from './project-member/router.js' +import { projectRoleRouter } from './project-role/router.js' +import { projectServiceRouter } from './project-service/router.js' +import { projectRouter } from './project/router.js' +import { repositoryRouter } from './repository/router.js' +import { serviceChainRouter } from './service-chain/router.js' +import { serviceMonitorRouter } from './service-monitor/router.js' +import { stageRouter } from './stage/router.js' +import { pluginConfigRouter } from './system/config/router.js' +import { systemRouter } from './system/router.js' +import { systemSettingsRouter } from './system/settings/router.js' +import { userRouter } from './user/router.js' +import { personalAccessTokenRouter } from './user/tokens/router.js' +import { zoneRouter } from './zone/router.js' + +// relax validation schema if NO_VALIDATION env var is set to true. +// /!\ It can lead to security leaks !!!! +const validateTrue = { responseValidation: process.env.NO_VALIDATION !== 'true' } +export function apiRouter() { + return async (app: FastifyInstance) => { + await app.register(serverInstance.plugin(adminRoleRouter()), validateTrue) + await app.register(serverInstance.plugin(adminTokenRouter()), validateTrue) + await app.register(serverInstance.plugin(clusterRouter()), validateTrue) + await app.register(serverInstance.plugin(serviceChainRouter()), validateTrue) + await app.register(serverInstance.plugin(environmentRouter()), validateTrue) + await app.register(serverInstance.plugin(logRouter()), validateTrue) + await app.register(serverInstance.plugin(personalAccessTokenRouter()), validateTrue) + await app.register(serverInstance.plugin(projectRouter()), validateTrue) + await app.register(serverInstance.plugin(projectMemberRouter()), validateTrue) + await app.register(serverInstance.plugin(projectRoleRouter()), validateTrue) + await app.register(serverInstance.plugin(projectServiceRouter()), validateTrue) + await app.register(serverInstance.plugin(repositoryRouter()), validateTrue) + await app.register(serverInstance.plugin(serviceMonitorRouter()), validateTrue) + await app.register(serverInstance.plugin(pluginConfigRouter()), validateTrue) + await app.register(serverInstance.plugin(stageRouter()), validateTrue) + await app.register(serverInstance.plugin(systemRouter()), validateTrue) + await app.register(serverInstance.plugin(systemSettingsRouter()), validateTrue) + await app.register(serverInstance.plugin(userRouter()), validateTrue) + await app.register(serverInstance.plugin(zoneRouter()), validateTrue) + } +} diff --git a/.jjconflict-base-0/apps/server/src/resources/log/business.spec.ts b/.jjconflict-base-0/apps/server/src/resources/log/business.spec.ts new file mode 100644 index 0000000000..8a4d080e48 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/log/business.spec.ts @@ -0,0 +1,42 @@ +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { getLogs } from './business.ts' + +describe('test log business', () => { + it('should map filter (clean logs)', async () => { + const dbLogs = [{ + data: { args: {} }, + createdAt: new Date(), + updatedAt: new Date(), + userId: null, + action: 'Action', + id: faker.string.uuid(), + }] + const query = { limit: 10, offset: 10, clean: true, projectId: undefined } + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + const [_total, logs] = await getLogs(query) + + expect(logs[0]).not.haveOwnProperty('requestId') + expect(logs[0].data).not.haveOwnProperty('results') + expect(logs[0].data).not.haveOwnProperty('args') + expect(logs[0].data).not.haveOwnProperty('config') + }) + + it('should not filter (admin logs)', async () => { + const dbLogs = [{ + data: { args: {} }, + createdAt: new Date(), + updatedAt: new Date(), + userId: null, + action: 'Action', + id: faker.string.uuid(), + }] + const query = { limit: 10, offset: 10, clean: false, projectId: undefined } + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + const [_total, logs] = await getLogs(query) + + expect(logs[0].data).haveOwnProperty('args') + expect(logs[0].data).not.haveOwnProperty('config') + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/log/business.ts b/.jjconflict-base-0/apps/server/src/resources/log/business.ts new file mode 100644 index 0000000000..9a0182e7e1 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/log/business.ts @@ -0,0 +1,13 @@ +import type { logContract } from '@cpn-console/shared' +import { CleanLogSchema } from '@cpn-console/shared' +import { getAllLogs } from '@/resources/queries-index.js' + +export async function getLogs({ offset, limit, projectId, clean }: typeof logContract.getLogs.query._type) { + const [total, logs] = await getAllLogs({ skip: offset, take: limit, where: { projectId } }) + return [ + total, + clean + ? logs.map(log => CleanLogSchema.parse(log)) + : logs, + ] +} diff --git a/.jjconflict-base-0/apps/server/src/resources/log/queries.ts b/.jjconflict-base-0/apps/server/src/resources/log/queries.ts new file mode 100644 index 0000000000..3851a8f13f --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/log/queries.ts @@ -0,0 +1,57 @@ +import type { Log, Prisma, Project, User } from '@prisma/client' +import { exclude } from '@cpn-console/shared' +import prisma from '@/prisma.js' + +// SELECT +export function getAllLogsForUser(user: User, offset = 0) { + return prisma.log.findMany({ + where: { userId: user.id }, + take: 100, + skip: offset, + }) +} + +export function getAllLogs({ skip = 0, take = 5, where }: Prisma.LogFindManyArgs) { + return prisma.$transaction([ + prisma.log.count({ where }), + prisma.log.findMany({ + orderBy: { + createdAt: 'desc', + }, + skip, + take, + where, + }), + ]) +} + +// CREATE +interface AddLogsArgs { + action: Log['action'] + data: Record + userId?: User['id'] | null + requestId: string + projectId?: Project['id'] +} +export function addLogs({ action, data, requestId, userId = null, projectId }: AddLogsArgs) { + return prisma.log.create({ + data: { + action, + userId, + data: exclude(data, ['cluster', 'user', 'newCreds', 'apis']), + requestId, + projectId, + }, + }) +} + +// TECH +export function _createLog(data: Parameters[0]['create']) { + return prisma.log.upsert({ + where: { + id: data.id, + }, + create: data, + update: data, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/log/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/log/router.spec.ts new file mode 100644 index 0000000000..911f69305d --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/log/router.spec.ts @@ -0,0 +1,93 @@ +import { ADMIN_PERMS, logContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetLogsMock = vi.spyOn(business, 'getLogs') + +describe('test logContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getLogs', () => { + it('should return logs for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const logs = [] + const total = 1 + + authUserMock.mockResolvedValueOnce(user) + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0 }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ total, logs }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin, no projectId', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0 }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return logs for non-admin, with projectId', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 1n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + const projectId = faker.string.uuid() + + const logs = [] + const total = 1 + + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0, projectId, clean: false }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledWith({ clean: true, limit: 10, offset: 0, projectId }) + expect(response.statusCode).toEqual(200) + }) + + it('should not return logs for non-admin, with projectId', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + const projectId = faker.string.uuid() + + const logs = [] + const total = 1 + + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0, projectId, clean: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/log/router.ts b/.jjconflict-base-0/apps/server/src/resources/log/router.ts new file mode 100644 index 0000000000..72e8712f0b --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/log/router.ts @@ -0,0 +1,30 @@ +import type { CleanLog, Log, XOR } from '@cpn-console/shared' +import type { UserProfile, UserProjectProfile } from '@/utils/controller.js' +import { AdminAuthorized, logContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getLogs } from './business.js' + +export function logRouter() { + return serverInstance.router(logContract, { + // Récupérer des logs + getLogs: async ({ request: req, query }) => { + const perms: XOR = query.projectId + ? await authUser(req, { id: query.projectId }) + : await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) { + if (!perms.projectPermissions) return new Forbidden403() + query.clean = true + } + + const [total, logs] = await getLogs(query) as [number, unknown[]] as [number, Array] + + return { + status: 200, + body: { total, logs }, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/project-member/business.ts b/.jjconflict-base-0/apps/server/src/resources/project-member/business.ts new file mode 100644 index 0000000000..4144b81813 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project-member/business.ts @@ -0,0 +1,73 @@ +import type { projectMemberContract, XOR } from '@cpn-console/shared' +import type { Project, User } from '@prisma/client' +import { logger as baseLogger } from '@cpn-console/logger' +import { UserSchema } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + addLogs, + deleteMember, + listMembers as listMembersQuery, + upsertMember, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' +import { logViaSession } from '../user/business.js' + +const logger = baseLogger.child({ scope: 'resource:project-member' }) + +export const listMembers = async (projectId: Project['id']) => listMembersQuery(projectId) + +export async function addMember(projectId: Project['id'], user: XOR<{ userId: string }, { email: string }>, requestorId: User['id'], requestId: string, projectOwnerId: Project['ownerId']) { + let userInDb: User | undefined | null + + logger.info({ requestId, requestorId, projectId, identifierType: user.userId ? 'userId' : 'email' }, 'Add project member started') + if (user.userId) { + userInDb = await prisma.user.findUnique({ where: { id: user.userId, type: 'human' } }) + } else if (user.email) { + userInDb = await prisma.user.findUnique({ where: { email: user.email, type: 'human' } }) + } else { + return new BadRequest400('Veuillez spécifiez au moins un userId ou un email') + } + if (userInDb) { + if (userInDb.id === projectOwnerId) return new BadRequest400('Le owner ne peut pas être ajouté à cette liste') + } else if (user.email) { + const hookReply = await hook.user.retrieveUserByEmail(user.email) + await addLogs({ action: 'Retrieve User By Email', data: hookReply, userId: requestorId, requestId }) + if (hookReply.failed) { + logger.error({ requestId, requestorId, projectId }, 'Add project member failed during user lookup hooks') + throw new BadRequest400('Echec de la recherche auprès des services externes') + } + + const retrievedUser = hookReply.results.keycloak?.user + if (!retrievedUser) return new BadRequest400('Utilisateur introuvable') + const userValidated = UserSchema.pick({ email: true, firstName: true, lastName: true, id: true }).safeParse(retrievedUser) + if (!userValidated.success) return new BadRequest400('L\'utilisateur trouvé ne remplit pas les conditions de vérification') + const logResults = await logViaSession({ ...userValidated.data, groups: [] }) + userInDb = logResults.user + } else { + return new NotFound404() + } + + await upsertMember({ projectId, userId: userInDb.id, roleIds: [] }) + await hook.projectMember.upsert(projectId, userInDb.id) + logger.info({ requestId, requestorId, projectId, userId: userInDb.id }, 'Add project member completed') + return listMembers(projectId) +} + +export async function patchMembers(projectId: Project['id'], members: typeof projectMemberContract.patchMembers.body._type) { + logger.info({ projectId, membersCount: members.length }, 'Patch project members started') + for (const member of members) { + await upsertMember({ projectId, userId: member.userId, roleIds: member.roles }) + await hook.projectMember.upsert(projectId, member.userId) + } + logger.info({ projectId, membersCount: members.length }, 'Patch project members completed') + return listMembers(projectId) +} + +export async function removeMember(projectId: Project['id'], userId: User['id']) { + logger.info({ projectId, userId }, 'Remove project member started') + await hook.projectMember.delete(projectId, userId) + await deleteMember({ projectId, userId }) + logger.info({ projectId, userId }, 'Remove project member completed') + return listMembers(projectId) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/project-member/queries.ts b/.jjconflict-base-0/apps/server/src/resources/project-member/queries.ts new file mode 100644 index 0000000000..a4ceb00df1 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project-member/queries.ts @@ -0,0 +1,33 @@ +import type { + Prisma, + + Project, +} from '@prisma/client' + +import prisma from '@/prisma.js' + +export const listMembers = (projectId: Project['id']) => prisma.projectMembers.findMany({ where: { projectId }, include: { user: true } }) + +export function upsertMember(data: Prisma.ProjectMembersUncheckedCreateInput) { + return prisma.projectMembers.upsert({ + where: { + projectId_userId: { + userId: data.userId, + projectId: data.projectId, + }, + }, + create: data, + update: { + roleIds: data.roleIds, + }, + include: { user: true }, + }) +} + +export function deleteMember(data: Prisma.ProjectMembersWhereUniqueInput['projectId_userId']) { + return prisma.projectMembers.delete({ + where: { + projectId_userId: data, + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/project-member/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/project-member/router.spec.ts new file mode 100644 index 0000000000..6ebb1d50a2 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project-member/router.spec.ts @@ -0,0 +1,294 @@ +import type { Member } from '@cpn-console/shared' +import { PROJECT_PERMS, projectMemberContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMembersMock = vi.spyOn(business, 'listMembers') +const businessAddMemberMock = vi.spyOn(business, 'addMember') +const businessPatchMembersMock = vi.spyOn(business, 'patchMembers') +const businessRemoveMemberMock = vi.spyOn(business, 'removeMember') + +describe('projectMemberRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const userId = faker.string.uuid() + + describe('listMembers', () => { + it('should return members for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessListMembersMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectMemberContract.listMembers.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectMemberContract.listMembers.path.replace(':projectId', projectId)) + .end() + + expect(businessListMembersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('addMember', () => { + const memberData: Partial = { + userId: faker.string.uuid(), + } + + it('should add member for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const newMember = { + ...memberData, + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + roleIds: [], + } + + businessAddMemberMock.mockResolvedValueOnce([newMember]) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.json()).toEqual([newMember]) + expect(response.statusCode).toEqual(201) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + businessAddMemberMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('patchMembers', () => { + const patchData = [{ userId: faker.string.uuid(), roles: [] }] + + it('should patch members for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchMembersMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('removeMember', () => { + it('should remove member for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessRemoveMemberMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should be able leave a project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessRemoveMemberMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/project-member/router.ts b/.jjconflict-base-0/apps/server/src/resources/project-member/router.ts new file mode 100644 index 0000000000..e9755fab57 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project-member/router.ts @@ -0,0 +1,77 @@ +import { ProjectAuthorized, projectMemberContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + addMember, + listMembers, + patchMembers, + removeMember, +} from './business.js' + +export function projectMemberRouter() { + return serverInstance.router(projectMemberContract, { + listMembers: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListMembers(perms)) return new Forbidden403() + const body = await listMembers(projectId) + + return { + status: 200, + body, + } + }, + + addMember: async ({ request: req, params, body }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageMembers(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await addMember(projectId, body, perms.user.id, req.id, perms.projectOwnerId) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 201, + body: resBody, + } + }, + + patchMembers: async ({ request: req, params, body }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageMembers(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await patchMembers(projectId, body) + + return { + status: 200, + body: resBody, + } + }, + + removeMember: async ({ request: req, params }) => { + const { projectId, userId } = params + const perms = await authUser(req, { id: projectId }) + + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (!ProjectAuthorized.ManageMembers(perms) && userId !== perms.user?.id) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await removeMember(projectId, params.userId) + + return { + status: 200, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/project-role/business.spec.ts b/.jjconflict-base-0/apps/server/src/resources/project-role/business.spec.ts new file mode 100644 index 0000000000..89a70147fd --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project-role/business.spec.ts @@ -0,0 +1,357 @@ +import type { Project, ProjectMembers, ProjectRole } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../__mocks__/prisma.js' +import { countRolesMembers, createRole, deleteRole, listRoles, patchRoles } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', () => ({ + hook: { + project: { + upsert: vi.fn(), + delete: vi.fn(), + getSecrets: vi.fn(), + }, + projectRole: { + upsert: vi.fn(), + delete: vi.fn(), + }, + user: { + retrieveUserByEmail: vi.fn(), + }, + }, +})) + +describe('test project-role business', () => { + const project: Project = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + slug: faker.lorem.word({ length: { min: 2, max: 10 } }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + description: faker.lorem.sentence({ min: 2, max: 10 }), + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + everyonePerms: 0n, + ownerId: faker.string.uuid(), + lastSuccessProvisionningVersion: null, + } + const projectId = faker.string.uuid() + + describe('listRoles', () => { + it('should stringify bigint', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + const response = await listRoles(projectId) + expect(response).toContainEqual(expect.objectContaining({ permissions: '4' })) + }) + + it('should strip oidcGroup prefix', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: `/${project.slug}/console/admin`, + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValueOnce(project) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + + const response = await listRoles(projectId) + expect(response[0].oidcGroup).toBe('/console/admin') + }) + }) + + describe('createRole', () => { + it('should create role with incremented position when position 0 is the highest', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 1, projectId } }) + }) + + it('should create role with incremented position with bigger position', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 51, projectId } }) + }) + + it('should create role with incremented position with no role in db', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(null) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.projectRole.findFirst.mockResolvedValueOnce(null) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 0, projectId } }) + }) + + it('should create role with enforced oidcGroup prefix', async () => { + const dbRole: any = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: `/${project.slug}/console/admin`, + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValueOnce(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + + await createRole(projectId, { name: 'test', permissions: '4', oidcGroup: '/console/admin' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + oidcGroup: `/${project.slug}/console/admin`, + }), + })) + }) + }) + + describe('deleteRole', () => { + const roleId = faker.string.uuid() + it('should delete role and remove id from concerned users', async () => { + const dbRole: ProjectRole = { + id: roleId, + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + const members = [{ + userId: faker.string.uuid(), + projectId, + roleIds: [roleId], + }, { + userId: faker.string.uuid(), + projectId, + roleIds: [roleId, faker.string.uuid()], + }] as const satisfies Partial[] + + prisma.projectRole.findUnique.mockResolvedValue(dbRole) + prisma.projectMembers.findMany.mockResolvedValueOnce(members) + prisma.projectRole.findMany.mockResolvedValueOnce([]) + prisma.projectRole.delete.mockResolvedValue(dbRole) + await deleteRole(roleId) + + expect(prisma.projectMembers.update).toHaveBeenNthCalledWith(1, { where: expect.any(Object), data: { roleIds: { set: [] } } }) + expect(prisma.projectMembers.update).toHaveBeenNthCalledWith(2, { where: expect.any(Object), data: { roleIds: { set: [members[1].roleIds[1]] } } }) + expect(prisma.projectRole.delete).toHaveBeenCalledWith({ where: { id: roleId } }) + }) + }) + + describe.skip('countRolesMembers', () => { + it('should return aggregated role member counts', async () => { + const roles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + }] as const satisfies ProjectRole[] + + const members = [{ + userId: faker.string.uuid(), + projectId, + roleIds: [roles[0].id, roles[1].id], + }, { + userId: faker.string.uuid(), + projectId, + roleIds: [roles[1].id], + }] as const satisfies ProjectMembers[] + + prisma.projectRole.findMany.mockResolvedValue(roles) + prisma.projectMembers.findMany.mockResolvedValue(members) + + const response = await countRolesMembers(projectId) + + expect(response).toEqual({ [roles[0].id]: 1, [roles[1].id]: 2 }) + }) + }) + + describe('patchRoles', () => { + const dbRoles: ProjectRole[] = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + projectId, + oidcGroup: 'group1', + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + projectId, + oidcGroup: 'group2', + type: 'managed', + }] + + it('should do nothing', async () => { + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue([]) + await patchRoles(projectId, []) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 1 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(projectId, updateRoles) + + expect(response).instanceOf(BadRequest400) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions (missing)', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[1].id, position: 1 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(projectId, updateRoles) + + expect(response).instanceOf(BadRequest400) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should update positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 0 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledTimes(2) + }) + + it('should update permissions', async () => { + const updateRoles: (Pick & { permissions: string })[] = [ + { id: dbRoles[1].id, permissions: '0' }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + data: { + name: dbRoles[1].name, + permissions: 0n, + position: 1, + oidcGroup: dbRoles[1].oidcGroup, + projectId, + type: 'managed', + }, + where: { + id: dbRoles[1].id, + }, + })) + }) + + it('should update role with enforced oidcGroup prefix', async () => { + const updateRoles: any[] = [ + { id: dbRoles[1].id, oidcGroup: '/console/admin' }, + ] + + prisma.project.findUnique.mockResolvedValue(project) + + const dbRoleWithPrefix = { ...dbRoles[1], oidcGroup: `/${project.slug}/console/group2` } + prisma.projectRole.findMany.mockResolvedValue([dbRoleWithPrefix]) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + oidcGroup: `/${project.slug}/console/admin`, + }), + })) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/project-role/business.ts b/.jjconflict-base-0/apps/server/src/resources/project-role/business.ts new file mode 100644 index 0000000000..52bd8966b3 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project-role/business.ts @@ -0,0 +1,121 @@ +import type { projectRoleContract } from '@cpn-console/shared' +import type { Project, ProjectRole } from '@prisma/client' +import { isSystemRoleType } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + deleteRole as deleteRoleQuery, + listMembers, + listRoles as listRolesQuery, + updateRole, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +const oidcRegexp = /^\/[^/]+/ + +export async function listRoles(projectId: Project['id']) { + const roles = await listRolesQuery(projectId) + return roles.map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ? role.oidcGroup.replace(oidcRegexp, '') : role.oidcGroup, + })) +} + +export async function patchRoles(projectId: Project['id'], roles: typeof projectRoleContract.patchProjectRoles.body._type) { + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } }) + if (!project) throw new NotFound404() + const dbRoles = await listRolesQuery(projectId) + const positionsAvailable: number[] = [] + + const updatedRoles: (Omit & { permissions: bigint })[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (matchingRole) { + if (isSystemRoleType(dbRole.type)) { + return new BadRequest400('Ce rôle système ne peut pas être modifié') + } + if (isSystemRoleType(matchingRole.type)) { + return new BadRequest400('Impossible de modifier un rôle en rôle système') + } + if (typeof matchingRole?.position !== 'undefined' && !positionsAvailable.includes(matchingRole?.position)) { + positionsAvailable.push(matchingRole.position) + } + if (matchingRole.oidcGroup && !matchingRole.oidcGroup.startsWith('/')) { + return new BadRequest400('oidcGroup doit commencer par /') + } + updatedRoles.push({ + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions: matchingRole.permissions ? BigInt(matchingRole.permissions) : dbRole.permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup: matchingRole.oidcGroup ? `/${project.slug}${matchingRole.oidcGroup}` : dbRole.oidcGroup, + type: matchingRole.type ?? dbRole.type, + projectId: dbRole.projectId, + }) + } + } + + if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes') + for (const { id, ...role } of updatedRoles) { + await updateRole(id, role) + await hook.projectRole.upsert(id) + } + + return listRoles(projectId) +} + +export async function createRole(projectId: Project['id'], role: typeof projectRoleContract.createProjectRole.body._type) { + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } }) + if (!project) throw new NotFound404() + if (isSystemRoleType(role.type)) { + throw new BadRequest400('Impossible de créer un rôle système') + } + const dbMaxPosRole = (await prisma.projectRole.findFirst({ + where: { projectId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + if (role.oidcGroup && !role.oidcGroup.startsWith('/')) { + throw new BadRequest400('oidcGroup doit commencer par /') + } + + const createdRole = await prisma.projectRole.create({ + data: { + ...role, + projectId, + position: dbMaxPosRole + 1, + permissions: BigInt(role.permissions), + oidcGroup: role.oidcGroup ? `/${project.slug}${role.oidcGroup}` : undefined, + }, + }) + + await hook.projectRole.upsert(createdRole.id) + + return listRoles(projectId) +} + +export async function countRolesMembers(projectId: Project['id']) { + const roles = await listRoles(projectId) + const members = await listMembers(projectId) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) // {role uuid: 0} + for (const { roleIds } of members) { + for (const roleId of roleIds) { + rolesCounts[roleId]++ + } + } + return rolesCounts +} + +export async function deleteRole(roleId: Project['id']) { + const role = await prisma.projectRole.findUnique({ where: { id: roleId }, select: { type: true } }) + if (!role) throw new NotFound404() + if (isSystemRoleType(role.type)) { + throw new BadRequest400('Ce rôle système ne peut pas être supprimé') + } + await hook.projectRole.delete(roleId) + await deleteRoleQuery(roleId) + return null +} diff --git a/.jjconflict-base-0/apps/server/src/resources/project-role/queries.ts b/.jjconflict-base-0/apps/server/src/resources/project-role/queries.ts new file mode 100644 index 0000000000..3e2aa4a0fa --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project-role/queries.ts @@ -0,0 +1,59 @@ +import type { + Prisma, + Project, + ProjectRole, +} from '@prisma/client' + +import prisma from '@/prisma.js' + +export const getRole = (id: ProjectRole['id']) => prisma.projectRole.findUnique({ where: { id } }) + +export const listRoles = (projectId: Project['id']) => prisma.projectRole.findMany({ where: { projectId }, orderBy: { position: 'asc' } }) + +export function createRole(data: Pick) { + return prisma.projectRole.create({ + data: { + name: data.name, + permissions: 0n, + position: data.position, + projectId: data.projectId, + oidcGroup: data.oidcGroup, + type: 'managed', + }, + }) +} + +export function updateRole(id: ProjectRole['id'], data: Pick) { + return prisma.projectRole.update({ + where: { id }, + data, + }) +} + +export async function deleteRole(id: ProjectRole['id']) { + const role = await prisma.projectRole.delete({ + where: { + id, + }, + }) + const attachedMembers = await prisma.projectMembers.findMany({ + where: { projectId: role.projectId, roleIds: { has: id } }, + }) + for (const member of attachedMembers) { + await prisma.projectMembers.update({ + where: { + projectId_userId: { + projectId: role.projectId, + userId: member.userId, + }, + }, + data: { + roleIds: { + set: member.roleIds.filter(roleId => roleId !== id), + }, + }, + }) + } +} + +export const getProjectRoleById = (id: ProjectRole['id']) => prisma.projectRole.findUnique({ where: { id }, include: { project: true } }) diff --git a/.jjconflict-base-0/apps/server/src/resources/project-role/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/project-role/router.spec.ts new file mode 100644 index 0000000000..dd42fbc7ad --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project-role/router.spec.ts @@ -0,0 +1,299 @@ +import { PROJECT_PERMS, projectRoleContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('./business.js') +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) + +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessCreateRoleMock = vi.spyOn(business, 'createRole') +const businessDeleteRoleMock = vi.spyOn(business, 'deleteRole') +const businessListRolesMock = vi.spyOn(business, 'listRoles') +const businessPatchRolesMock = vi.spyOn(business, 'patchRoles') + +describe('tests projectRoleContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const roleId = faker.string.uuid() + + describe('listProjectRoles', () => { + it('should return 403 for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessListRolesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectRoleContract.listProjectRoles.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectRoleContract.listProjectRoles.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createProjectRole', () => { + it('should create role for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('patchProjectRoles', () => { + it('should patch roles for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchRolesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchRolesMock.mockResolvedValue(new BadRequest400('une erreur')) + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('projectRoleMemberCounts', () => { + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectRoleContract.projectRoleMemberCounts.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteProjectRole', () => { + it('should delete role for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteRoleMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 404 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/project-role/router.ts b/.jjconflict-base-0/apps/server/src/resources/project-role/router.ts new file mode 100644 index 0000000000..f7aa2ef902 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project-role/router.ts @@ -0,0 +1,88 @@ +import { ProjectAuthorized, projectRoleContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + countRolesMembers, + createRole, + deleteRole, + listRoles, + patchRoles, +} from './business.js' + +export function projectRoleRouter() { + return serverInstance.router(projectRoleContract, { + // Récupérer des projets + listProjectRoles: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ListRoles(perms)) return new Forbidden403() + + const body = await listRoles(projectId) + + return { + status: 200, + body, + } + }, + + createProjectRole: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await createRole(projectId, body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 201, + body: resBody, + } + }, + + patchProjectRoles: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await patchRoles(projectId, body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + projectRoleMemberCounts: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ListRoles(perms)) return new Forbidden403() + + const resBody = await countRolesMembers(projectId) + + return { + status: 200, + body: resBody, + } + }, + + deleteProjectRole: async ({ request: req, params: { projectId, roleId } }) => { + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await deleteRole(roleId) + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/project-service/business.ts b/.jjconflict-base-0/apps/server/src/resources/project-service/business.ts new file mode 100644 index 0000000000..bce70064ef --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project-service/business.ts @@ -0,0 +1,103 @@ +import type { ZoneObject } from '@cpn-console/hooks' +import type { + PermissionTarget, + PluginsUpdateBody, + ServiceUrl, +} from '@cpn-console/shared' +import type { Project, ProjectPlugin } from '@prisma/client' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { + getAdminPlugin, + getProjectInfosByIdOrThrow, + getProjectStore, + getPublicClusters, + saveProjectStore, +} from '@/resources/queries-index.js' + +const logger = baseLogger.child({ scope: 'resource:project-service' }) + +export type ConfigRecords = { + key: string + pluginName: string + value: string | number | null +}[] + +export function dbToObj(records: Omit[]): PluginsUpdateBody { + const obj: PluginsUpdateBody = {} + for (const record of records) { + if (!obj[record.pluginName]) obj[record.pluginName] = {} + obj[record.pluginName][record.key] = record.value + } + return obj +} + +export function objToDb(obj: PluginsUpdateBody): ConfigRecords { + return Object.entries(obj) + .map(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) + .flat() +} + +export async function getProjectServices(projectId: Project['id'], permissionTarget: PermissionTarget) { + // Pré-requis + const project = await getProjectInfosByIdOrThrow(projectId) + + const [projectStore, globalConfig] = await Promise.all([ + getProjectStore(projectId), + getAdminPlugin(), + ]) + const store = dbToObj([...projectStore, ...globalConfig]) + + const publicClusters = await getPublicClusters() + project.clusters = [...project.clusters, ...publicClusters] + const zones: Map = new Map() // Pour dédoublonnage des zones + project.clusters.map(c => zones.set(c.zone.id, c.zone)) + + return Object.values(servicesInfos).map(({ name, title, to, imgSrc, description }) => { + let urls: ServiceUrl[] = [] + const toResponse = to + ? to({ + clusters: project.clusters, + zones: [...zones.values()], + environments: project.environments, + project, + store, + }) + : [] + if (Array.isArray(toResponse)) { + urls = toResponse.map(res => ({ name: res.title ?? '', description: res.description ?? '', to: res.to })) + } else if (typeof toResponse === 'string') { + urls = [{ to: toResponse, name: '' }] + } else if (toResponse) { + urls = [{ name: toResponse.title ?? '', to: toResponse.to }] + } + const manifest = populatePluginManifests({ + data: { + project: projectStore, + global: globalConfig, + }, + permissionTarget, + pluginName: name, + select: { + global: true, + project: true, + }, + }) + return { imgSrc, title, name, urls, manifest, description } + }).filter(s => s.urls.length || s.manifest.global?.length || s.manifest.project?.length) +} + +export async function updateProjectServices(projectId: Project['id'], data: PluginsUpdateBody, stripperRoles: Array<'user' | 'admin'>) { + const pluginKeysCount = Object.fromEntries( + Object.entries(data).map(([pluginName, values]) => [pluginName, Object.keys(values).length]), + ) + logger.info({ projectId, pluginCount: Object.keys(data).length, pluginKeysCount, stripperRoles }, 'Update project services started') + for (const role of stripperRoles) { + const parsedData = editStrippers.project[role].safeParse(data) + if (!parsedData.success) continue + await saveProjectStore(objToDb(parsedData.data), projectId) + } + logger.info({ projectId }, 'Update project services completed') + return null +} diff --git a/.jjconflict-base-0/apps/server/src/resources/project-service/queries.ts b/.jjconflict-base-0/apps/server/src/resources/project-service/queries.ts new file mode 100644 index 0000000000..cf353614b2 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project-service/queries.ts @@ -0,0 +1,54 @@ +import type { Project } from '@prisma/client' +import type { ConfigRecords } from './business.js' +import prisma from '@/prisma.js' + +// CONFIG +export function getProjectStore(projectId: Project['id']) { + return prisma.projectPlugin.findMany({ + where: { projectId }, + select: { + key: true, + pluginName: true, + value: true, + }, + }) +} + +export const getAdminPlugin = prisma.adminPlugin.findMany + +export async function saveProjectStore(records: ConfigRecords, projectId: Project['id']) { + for (const { pluginName, key, value } of records) { + if (value === null) { + await prisma.projectPlugin.delete({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } else { + await prisma.projectPlugin.upsert({ + create: { + pluginName, + projectId, + key, + value: value.toString(), + }, + update: { + key, + value: value.toString(), + pluginName, + }, + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } + } +} diff --git a/.jjconflict-base-0/apps/server/src/resources/project-service/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/project-service/router.spec.ts new file mode 100644 index 0000000000..20398a0d08 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project-service/router.spec.ts @@ -0,0 +1,160 @@ +import { ADMIN_PERMS, PROJECT_PERMS, projectServiceContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetServicesMock = vi.spyOn(business, 'getProjectServices') +const businessUpdateServicesMock = vi.spyOn(business, 'updateProjectServices') + +describe('projectServiceRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + + describe('getServices', () => { + it('should return services for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .query({ permissionTarget: 'user' }) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledWith(projectId, 'user') + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should not return admin services for non admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .query({ permissionTarget: 'admin' }) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return services for admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledWith(projectId, 'user') + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateProjectServices', () => { + const updateData = { serviceA: { param1: 'value' } } + + it('should update services for project manager', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateServicesMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(businessUpdateServicesMock).toHaveBeenCalledWith(projectId, updateData, ['user']) + expect(response.statusCode).toEqual(204) + }) + + it('should update services for project admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateServicesMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(businessUpdateServicesMock).toHaveBeenCalledWith(projectId, updateData, ['user', 'admin']) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/project-service/router.ts b/.jjconflict-base-0/apps/server/src/resources/project-service/router.ts new file mode 100644 index 0000000000..e5a1f07159 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project-service/router.ts @@ -0,0 +1,38 @@ +import { AdminAuthorized, ProjectAuthorized, projectServiceContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getProjectServices, updateProjectServices } from './business.js' + +export function projectServiceRouter() { + return serverInstance.router(projectServiceContract, { + // Récupérer les services d'un projet + getServices: async ({ request: req, params: { projectId }, query }) => { + const perms = await authUser(req, { id: projectId }) + if (!perms.projectPermissions && !ProjectAuthorized.Manage(perms)) return new Forbidden403() + if (query.permissionTarget === 'admin' && !AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403('Vous ne pouvez pas demander les paramètres admin') + + const body = await getProjectServices(projectId, query.permissionTarget) + + return { + status: 200, + body, + } + }, + + updateProjectServices: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.Manage(perms) && !perms.projectPermissions) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + + const allowedRoles: Array<'user' | 'admin'> = AdminAuthorized.Manage(perms.adminPermissions) ? ['user', 'admin'] : ['user'] + + const resBody = await updateProjectServices(projectId, body, allowedRoles) + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/project/business.spec.ts b/.jjconflict-base-0/apps/server/src/resources/project/business.spec.ts new file mode 100644 index 0000000000..a9f6c4fa62 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project/business.spec.ts @@ -0,0 +1,365 @@ +import type { Cluster, Project, ProjectMembers, ProjectRole, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { + BadRequest400, + ErrorResType, + Unprocessable422, +} from '../../utils/errors.js' +import { dbToObj } from '../project-service/business.ts' +import * as userBusiness from '../user/business.js' +import { archiveProject, chunk, createProject, generateProjectsData, generateSlug, getProjectSecrets, listProjects, replayHooks, updateProject } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const logViaSessionMock = vi.spyOn(userBusiness, 'logViaSession') + +const projectId = faker.string.uuid() + +const user: User = { + id: faker.string.uuid(), + createdAt: new Date(), + updatedAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + lastLogin: null, +} +const project: Project & { + clusters: Pick[] + members: ProjectMembers[] + roles: ProjectRole[] + owner: User +} = { + createdAt: new Date(), + updatedAt: new Date(), + description: '', + everyonePerms: 649n, + id: faker.string.uuid(), + locked: false, + name: faker.string.alphanumeric(8), + status: 'created', + ownerId: faker.string.uuid(), + clusters: [], + roles: [], + members: [], +} +const reqId = faker.string.uuid() +describe('test project business utils', () => { + it('should transform arrow ', async () => { + const result = dbToObj([{ key: 'test', pluginName: 'test', value: 'test' }]) + expect(result).toEqual({ test: { test: 'test' } }) + }) +}) + +describe('test project business logic', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listProjects', () => { + it('should return stringified perms', async () => { + prisma.project.findMany.mockResolvedValue([{ everyonePerms: 5n, clusters: [], roles: [{ permissions: 28n }] }]) + const response = await listProjects({}, user.id) + expect(response[0].everyonePerms).toBe('5') + expect(response[0].roles[0].permissions).toBe('28') + }) + }) + describe('getProjectSecrets', () => { + const getResultsHook = { + failed: false, + args: {}, + results: { + registry: { + secrets: { + token: 'myToken', + }, + status: { + failed: false, + }, + }, + }, + } + it('should return transform secret', async () => { + hook.project.getSecrets.mockResolvedValue(getResultsHook) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + + // according to src/utils/mocks.ts + expect(JSON.stringify(response)).toContain('myToken') + }) + + it('should return projects secrets', async () => { + hook.project.getSecrets.mockResolvedValue(getResultsHook) + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + prisma.project.findMany.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + // according to src/utils/mocks.ts + expect(JSON.stringify(response)).toContain('myToken') + }) + + it('should return hook error', async () => { + hook.project.getSecrets.mockResolvedValue({ failed: true }) + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + prisma.project.findMany.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + // according to src/utils/mocks.ts + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('createProject', () => { + it('should create project', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.create.mockResolvedValue({ ...project, status: 'initializing' }) + prisma.project.findFirst.mockResolvedValue(undefined) + prisma.project.findMany.mockResolvedValue([]) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const projectRes = await createProject(project, user, reqId) + + expect(projectRes.name).toEqual(project.name) + expect(prisma.project.create).toHaveBeenCalledTimes(1) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should return plugins failed', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.create.mockResolvedValue({ ...project, status: 'initializing' }) + prisma.project.findFirst.mockResolvedValue(undefined) + prisma.project.findMany.mockResolvedValue([]) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const response = await createProject(project, user, reqId) + + expect(prisma.project.create).toHaveBeenCalledTimes(1) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + describe('updateProject', () => { + const updatedProjet = { + description: faker.lorem.lines(2), + everyonePerms: '5', + } + const reqId = faker.string.uuid() + const members: ProjectMembers[] = [{ userId: faker.string.uuid(), projectId: project.id, roleIds: [], user: { type: 'human' } }] + it('should update project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + await updateProject({ ...updatedProjet, ownerId: members[0].userId }, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(2) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should update nothing', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + await updateProject({ }, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(0) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should not update if project archived', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, status: 'archived' }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const response = await updateProject({ }, project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should not update project, cause missing member', async () => { + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members: [] }) + + const response = await updateProject({ ownerId: members[0].userId }, project.id, user, reqId) + + expect(prisma.project.findUniqueOrThrow).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(BadRequest400) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + expect(prisma.log.update).toHaveBeenCalledTimes(0) + }) + + it('should return plugins failed', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const response = await updateProject(updatedProjet, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + describe('replayHooks', () => { + const reqId = faker.string.uuid() + + it('should replay hooks', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + await replayHooks(project.id, user, reqId) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should not replay hooks on archived project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'archived' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should not replay hooks on locked project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: true, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should update nothing and return error', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: true } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('archiveProject', () => { + it('should archive project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeNull() + expect(prisma.project.update).toHaveBeenLastCalledWith({ + where: { id: project.id }, + data: { + clusters: { set: [] }, + }, + }) + }) + + it('should not archive a project already archived', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false, status: 'archived' }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + }) + + it('should not archive a project locked', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: true, status: 'created' }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + }) + + it('should return hook fail', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false }) + hook.project.delete.mockResolvedValue({ results: { failed: true }, project: Promise.resolve({ status: 'failed' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('generateProjectsData', () => { + it('shoud return string, very bad test ...', async () => { + prisma.project.findMany.mockResolvedValue([{ name: 'test' }]) + const response = await generateProjectsData() + expect(response).toBeTypeOf('string') + }) + }) +}) + +describe('chunk function', () => { + it('should return 5 elements', () => { + const letters = ['A', 'B', 'C', 'D', 'E'] + expect(chunk(letters, 5)).toEqual([letters]) + }) + it('should return 3,2 elements', () => { + const letters = ['A', 'B', 'C', 'D', 'E'] + expect(chunk(letters, 3)).toEqual([['A', 'B', 'C'], ['D', 'E']]) + }) + it('should return 4 elements', () => { + const letters = ['A', 'B', 'C', 'D'] + expect(chunk(letters, 5)).toEqual([letters]) + }) +}) + +const slugRegexp = /-[01]$/ +const slugFourRegexp = /-4$/ +const slugEmptySpaceRegexp = /-2$/ + +describe('generateSlug', () => { + it('should return prefix, no array', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix) + expect(generated).toEqual(prefix) + }) + it('should return prefix, empty array', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, []) + expect(generated).toEqual(prefix) + }) + it('should return prefix, no match', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [faker.string.alphanumeric(5), faker.string.alphanumeric(5)]) + expect(generated).toEqual(prefix) + }) + it('should return generated slug at 1 or 0, all matchs', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix]) + expect(generated).match(slugRegexp) + }) + it('should return generated slug at 4, all matchs', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix, `${prefix}-0`, `${prefix}-1`, `${prefix}-2`, `${prefix}-3`]) + expect(generated).match(slugFourRegexp) + }) + it('should fill empty space', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix, `${prefix}-0`, `${prefix}-1`, `${prefix}-3`]) + expect(generated).match(slugEmptySpaceRegexp) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/project/business.ts b/.jjconflict-base-0/apps/server/src/resources/project/business.ts new file mode 100644 index 0000000000..b27f16bf64 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project/business.ts @@ -0,0 +1,306 @@ +import type { projectContract } from '@cpn-console/shared' +import type { Project, User } from '@prisma/client' +import type { UserDetails } from '@/types/index.js' +import type { ErrorResType } from '@/utils/errors.js' +import { servicesInfos } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { ProjectStatusSchema } from '@cpn-console/shared' +import { json2csv } from 'json-2-csv' +import prisma from '@/prisma.js' +import { + addLogs, + deleteAllEnvironmentForProject, + deleteAllRepositoryForProject, + getAllProjectsDataForExport, + getProjectOrThrow, + getSlugs, + initializeProject, + listProjects as listProjectsQuery, + lockProject, + updateProject as updateProjectQuery, +} from '@/resources/queries-index.js' +import { whereBuilder } from '@/utils/controller.js' +import { parallelBulkLimit } from '@/utils/env.js' +import { BadRequest400, Forbidden403, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +const logger = baseLogger.child({ scope: 'resource:project' }) + +export function generateSlug(prefix: string, existingSlugs?: string[]) { + if (!existingSlugs?.includes(prefix)) { + return prefix + } + let idx = 1 + let generated = `${prefix}-${idx}` + while (existingSlugs.includes(generated)) { + idx++ + generated = `${prefix}-${idx}` + } + return generated +} + +const projectStatus = ProjectStatusSchema._def.values +export async function listProjects({ status, statusIn, statusNotIn, filter = 'member', ...query }: typeof projectContract.listProjects.query._type, userId: User['id'] | undefined) { + return listProjectsQuery({ + ...query, + status: whereBuilder({ enumValues: projectStatus, eqValue: status, inValues: statusIn, notInValues: statusNotIn }), + filter, + userId, + }).then(projects => projects.map(({ clusters, ...project }) => ({ + ...project, + clusterIds: clusters.map(({ id }) => id), + roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + everyonePerms: project.everyonePerms.toString(), + }))) +} + +export async function getProjectSecrets(projectId: string) { + const hookReply = await hook.project.getSecrets(projectId) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la récupération des secrets du projet') + } + + return Object.fromEntries( + Object.entries(hookReply.results) + // @ts-ignore + .filter(([_key, value]) => Object.keys(value.secrets).length) + // @ts-ignore + .map(([key, value]) => [servicesInfos[key]?.title, value.secrets]), + ) +} + +export async function createProject(dataDto: typeof projectContract.createProject.body._type, requestor: UserDetails, requestId: string) { + if (requestor.type !== 'human') return new BadRequest400('Seuls les comptes humains peuvent créer des projets') + + let slug = dataDto.name + logger.info({ requestId, userId: requestor.id, slugPrefix: slug }, 'Create project started') + const projectsWithSamePrefix = await getSlugs(slug) + slug = generateSlug(slug, projectsWithSamePrefix?.map(project => project.slug)) + + // Actions + const project = await initializeProject({ ...dataDto, slug, ownerId: requestor.id }) + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Project initialized') + + const { results, project: projectInfos } = await hook.project.upsert(project.id) + await addLogs({ action: 'Create Project', data: results, userId: requestor.id, requestId, projectId: project.id }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project failed during upsert hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project upsert hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services à la création du projet') + } + + for (const role of projectInfos.roles) { + const roleResult = await hook.projectRole.upsert(role.id) + await addLogs({ action: 'Upsert Project Role', data: roleResult.results, userId: requestor.id, requestId, projectId: project.id }) + } + + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project completed') + return { + ...projectInfos, + clusterIds: projectInfos.clusters.map(({ id }) => id), + everyonePerms: projectInfos.everyonePerms.toString(), + roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + } +} + +export async function getProject(projectId: Project['id']) { + return getProjectOrThrow(projectId).then(({ clusters, ...project }) => ({ + ...project, + clusterIds: clusters.map(({ id }) => id), + roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + everyonePerms: project.everyonePerms.toString(), + })) +} + +export async function updateProject( + { description, ownerId: ownerIdCandidate, everyonePerms, locked, ...data }: typeof projectContract.updateProject.body._type, + projectId: Project['id'], + requestor: UserDetails, + requestId: string, +) { + const changedFields = Object.entries({ + ...data, + description, + locked, + ownerId: ownerIdCandidate, + everyonePerms, + }).filter(([, value]) => typeof value !== 'undefined').map(([key]) => key) + logger.info({ requestId, userId: requestor.id, projectId, changedFields }, 'Update project started') + + // Actions + const projectDb = await prisma.project.findUniqueOrThrow({ + where: { id: projectId }, + include: { members: { include: { user: true } } }, + }) + + if (projectDb.status === 'archived') return new Forbidden403('Le projet est archivé') + + if (ownerIdCandidate && ownerIdCandidate !== projectDb.ownerId) { + const memberCandidate = projectDb.members.find(member => member.userId === ownerIdCandidate) + if (!memberCandidate) { + return new BadRequest400('Le nouveau propriétaire doit faire partie des membres actuels du projet') + } + if (memberCandidate.user.type !== 'human') return new BadRequest400('Seuls les comptes humains peuvent être propriétaire de projets') + if (!projectDb.members.some(member => member.userId === projectDb.ownerId)) { + await prisma.projectMembers.create({ + data: { userId: projectDb.ownerId, projectId }, + }) + } + await prisma.$transaction([ + prisma.projectMembers.delete({ + where: { projectId_userId: { userId: ownerIdCandidate, projectId } }, + }), + prisma.project.update({ where: { id: projectId }, data: { ownerId: ownerIdCandidate } }), + ]) + } + + if (typeof description !== 'undefined' || typeof everyonePerms !== 'undefined' || typeof locked !== 'undefined') { + await updateProjectQuery(projectId, { + description, + locked, + ...everyonePerms && { everyonePerms: BigInt(everyonePerms) }, + ...data, + }) + } + + const { results, project: projectInfos } = await hook.project.upsert(projectId) + await addLogs({ action: 'Update Project', data: results, userId: requestor.id, requestId, projectId: projectInfos.id }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId }, 'Update project failed during upsert hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId }, 'Update project upsert hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services à la mise à jour du projet') + } + + logger.info({ requestId, userId: requestor.id, projectId }, 'Update project completed') + return { + ...projectInfos, + clusterIds: projectInfos.clusters.map(({ id }) => id), + everyonePerms: projectInfos.everyonePerms.toString(), + roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${projectInfos.slug}`, '') : role.oidcGroup })), + } +} + +interface ReplayHooksArgs { + projectId: Project['id'] + userId?: User['id'] + requestId: string +} +export async function replayHooks({ projectId, userId, requestId }: ReplayHooksArgs): Promise { + logger.info({ requestId, userId, projectId }, 'Replay project hooks started') + const projectDb = await prisma.project.findUniqueOrThrow({ + where: { id: projectId }, + include: { members: { include: { user: true } } }, + }) + if (projectDb.locked) return new Forbidden403('Le projet est verrouillé') + if (projectDb.status === 'archived') return new Forbidden403('Le projet est archivé') + // Actions + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Replay hooks for Project', data: results, userId, requestId, projectId }) + if (results.failed) { + logger.error({ requestId, userId, projectId }, 'Replay project hooks failed') + } else { + logger.info({ requestId, userId, projectId }, 'Replay project hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services au reprovisionnement du projet') + } + return null +} + +export async function archiveProject(projectId: Project['id'], requestor: UserDetails, requestId: string): Promise { + logger.info({ requestId, userId: requestor.id, projectId }, 'Archive project started') + // Actions + // Fetch project state before hook so plugins see repos and environments + const projectDb = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, include: { members: { include: { user: true } } } }) + + if (projectDb.locked) return new Forbidden403('Le projet est verrouillé') + if (projectDb.status === 'archived') return new BadRequest400('Le projet est archivé') + if (projectDb.locked) { + await lockProject(projectId) + } + + // -- début - Suppression projet -- + const { results, project } = await hook.project.delete(projectId) + await addLogs({ action: 'Delete all project resources', data: results, userId: requestor.id, requestId, projectId }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId }, 'Archive project failed during delete hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId, status: project.status }, 'Archive project delete hooks completed') + } + if (project.status !== 'archived' && !projectDb.locked) { + await prisma.project.update({ where: { id: projectId }, data: { locked: false } }) + } + if (results.failed) { + return new Unprocessable422('Echec des services à la suppression du projet') + } + + // Empty the project after successful hooks so plugins still have access to repos and environments + await Promise.all([ + deleteAllRepositoryForProject(projectId), + deleteAllEnvironmentForProject(projectId), + ]) + + // Retrait clusters -- + await prisma.project.update({ + where: { id: projectId }, + data: { + clusters: { set: [] }, + }, + }) + + // -- fin - Suppression projet -- + logger.info({ requestId, userId: requestor.id, projectId }, 'Archive project completed') + return null +} + +export async function generateProjectsData() { + const projects = await getAllProjectsDataForExport() + + return json2csv(projects, { + emptyFieldValue: '', + }) +} + +export async function bulkActionProject(data: typeof projectContract.bulkActionProject.body._type, requestor: UserDetails, requestId: string) { + if (data.projectIds === 'all') { + data.projectIds = (await prisma.project.findMany({ + select: { id: true }, + where: { status: { not: 'archived' } }, + })).map(({ id }) => id) + } + bulkExector(data.projectIds + .map((projectId) => { + if (data.action === 'archive') { + return () => archiveProject(projectId, requestor, requestId) + } + if (data.action === 'lock') { + return () => updateProject({ locked: true }, projectId, requestor, requestId) + } + if (data.action === 'unlock') { + return () => updateProject({ locked: false }, projectId, requestor, requestId) + } + if (data.action === 'replay') { + return () => replayHooks({ projectId, userId: requestor.id, requestId }) + } + // should never been called + return async () => {} + })) +} + +export function chunk(arr: T[], size: number): T[][] { + return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => + arr.slice(i * size, i * size + size)) +} + +async function bulkExector(toExecute: Array<() => Promise>) { + const toExecuteChunked = chunk(toExecute, parallelBulkLimit) + for (const chunkToExecute of toExecuteChunked) { + await Promise.allSettled(chunkToExecute.map(fn => fn())) + } +} diff --git a/.jjconflict-base-0/apps/server/src/resources/project/queries.ts b/.jjconflict-base-0/apps/server/src/resources/project/queries.ts new file mode 100644 index 0000000000..5957b15478 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project/queries.ts @@ -0,0 +1,374 @@ +import type { projectContract, XOR } from '@cpn-console/shared' +import type { + Prisma, + Project, + User, +} from '@prisma/client' +import { PROJECT_PERMS } from '@cpn-console/shared' +import { + ProjectStatus, +} from '@prisma/client' +import prisma from '@/prisma.js' +import { appVersion } from '@/utils/env.js' +import { uuid } from '@/utils/queries-tools.js' + +type ProjectUpdate = Partial> +export function updateProject(id: Project['id'], data: ProjectUpdate) { + return prisma.project.update({ + where: { id }, + data, + include: { members: true }, + }) +} + +// SELECT +type FilterWhere = XOR<{ + userId?: User['id'] + filter: 'all' +}, { + userId: User['id'] | undefined + filter: 'owned' | 'member' +}> +type ListProjectWhere = Omit<(typeof projectContract.listProjects.query._type), 'status_in' | 'status_not_in' | 'status'> + & Pick + & FilterWhere +export async function listProjects({ + description, + locked, + name, + status, + id, + filter, + userId, + search, + lastSuccessProvisionningVersion, +}: ListProjectWhere) { + const whereAnd: Prisma.ProjectWhereInput[] = [] + if (id) whereAnd.push({ id }) + if (locked != null) whereAnd.push({ locked }) + if (name) whereAnd.push({ name }) + if (status) whereAnd.push({ status }) + if (description) whereAnd.push({ description: { contains: description } }) + if (lastSuccessProvisionningVersion) { + if (lastSuccessProvisionningVersion === 'outdated') whereAnd.push({ lastSuccessProvisionningVersion: { not: appVersion } }) + else if (lastSuccessProvisionningVersion === 'last') whereAnd.push({ lastSuccessProvisionningVersion: { equals: appVersion } }) + else whereAnd.push({ lastSuccessProvisionningVersion }) + } + if (search) { + whereAnd.push({ OR: [{ + name: { contains: search }, + }, { + owner: { email: { contains: search } }, + }] }) + } + + if (filter === 'owned') { + whereAnd.push({ ownerId: userId }) + } else if (filter === 'member') { + whereAnd.push({ OR: [{ + members: { some: { userId } }, + }, { + ownerId: userId, + }] }) + } + + return prisma.project.findMany({ + where: { AND: whereAnd }, + include: { + clusters: { select: { id: true } }, + members: { include: { user: true } }, + roles: true, + owner: true, + }, + }) +} + +export function getProjectOrThrow(id: Project['id'] | Project['slug']) { + return prisma.project.findFirstOrThrow({ + where: uuid.test(id) + ? { id } + : { slug: id }, + include: { + clusters: { select: { id: true } }, + members: { include: { user: true } }, + roles: true, + owner: true, + }, + }) +} + +export function getProjectInfosByIdOrThrow(projectId: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { + id: projectId, + }, + include: { + environments: true, + clusters: { include: { zone: true } }, + }, + }) +} + +export function getProjectMembers(projectId: Project['id']) { + return prisma.projectMembers.findMany({ + where: { + projectId, + }, + include: { user: true }, + }) +} + +export function getProjectById(id: Project['id']) { + return prisma.project.findUnique({ where: { id } }) +} + +export const baseProjectIncludes = { + members: { include: { user: true } }, + clusters: true, + roles: true, + owner: true, +} as const + +export function getProjectInfos(id: Project['id']) { + return prisma.project.findUnique({ + where: { id }, + include: baseProjectIncludes, + }) +} + +export function getProjectInfosOrThrow(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: baseProjectIncludes, + }) +} + +export function getProjectInfosAndRepos(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: { + ...baseProjectIncludes, + repositories: true, + }, + }) +} + +export function getSlugs(slugPrefix: string) { + return prisma.project.findMany({ + where: { + slug: { startsWith: slugPrefix }, + }, + }) +} + +export function getAllProjectsDataForExport() { + return prisma.project.findMany({ + select: { + name: true, + description: true, + createdAt: true, + updatedAt: true, + environments: { + select: { + name: true, + stage: true, + cluster: { + select: { label: true }, + }, + }, + }, + owner: true, + }, + }) +} + +export function getRolesByProjectId(projectId: Project['id']) { + return prisma.projectRole.findMany({ + where: { projectId }, + }) +} + +const clusterInfosSelect = { + id: true, + infos: true, + label: true, + privacy: true, + secretName: true, + kubeconfig: true, + clusterResources: true, + cpu: true, + gpu: true, + memory: true, + zone: { + select: { + id: true, + slug: true, + argocdUrl: true, + label: true, + }, + }, +} +export function getHookProjectInfos(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: { + members: { include: { user: true }, where: { user: { type: 'human' } } }, + clusters: { select: clusterInfosSelect }, + environments: { + include: { + stage: true, + cluster: { + select: clusterInfosSelect, + }, + }, + }, + repositories: true, + plugins: { + select: { + key: true, + pluginName: true, + value: true, + }, + }, + owner: true, + roles: true, + }, + }) +} + +// CREATE +interface CreateProjectParams { + name: Project['name'] + description?: Project['description'] + ownerId: User['id'] + slug: Project['slug'] + limitless: boolean + hprodCpu: number + hprodGpu: number + hprodMemory: number + prodCpu: number + prodGpu: number + prodMemory: number +} + +export function initializeProject(params: CreateProjectParams) { + return prisma.project.create({ + data: { + description: params.description ?? '', + status: ProjectStatus.created, + locked: false, + roles: { + create: [ + { + name: 'Administrateur', + permissions: PROJECT_PERMS.MANAGE, + position: 0, + oidcGroup: `/${params.slug}/console/admin`, + type: 'system:managed', + }, + { + name: 'DevOps', + permissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 1, + oidcGroup: `/${params.slug}/console/devops`, + type: 'system:managed', + }, + { + name: 'Développeur', + permissions: PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 2, + oidcGroup: `/${params.slug}/console/developer`, + type: 'system:managed', + }, + { + name: 'Sécurité', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 3, + oidcGroup: `/${params.slug}/console/security`, + type: 'system:managed', + }, + { + name: 'Lecture seule', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 4, + oidcGroup: `/${params.slug}/console/readonly`, + type: 'system:managed', + }, + ], + }, + ...params, + }, + }) +} + +// UPDATE +export function lockProject(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { locked: true }, + }) +} + +export function updateProjectCreated(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { + status: ProjectStatus.created, + lastSuccessProvisionningVersion: appVersion, + }, + include: baseProjectIncludes, + }) +} + +export function updateProjectFailed(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { status: ProjectStatus.failed }, + include: baseProjectIncludes, + }) +} + +export function updateProjectWarning(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { status: ProjectStatus.warning }, + include: baseProjectIncludes, + }) +} + +export function addUserToProject({ project, user }: { project: Project, user: User }) { + return prisma.projectMembers.create({ + data: { + userId: user.id, + projectId: project.id, + }, + }) +} + +export function removeUserFromProject({ projectId, userId }: { projectId: Project['id'], userId: User['id'] }) { + return prisma.projectMembers.delete({ + where: { + projectId_userId: { + projectId, + userId, + }, + }, + }) +} + +export async function archiveProject(id: Project['id']) { + const project = await prisma.project.findUnique({ + where: { id }, + select: { name: true, slug: true }, + }) + return prisma.project.update({ + where: { id }, + data: { + name: `${project?.name}_${Date.now()}_archived`, + slug: `${project?.slug}_${Date.now()}_archived`, + status: ProjectStatus.archived, + locked: true, + }, + include: baseProjectIncludes, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/project/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/project/router.spec.ts new file mode 100644 index 0000000000..8c5a2ed820 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project/router.spec.ts @@ -0,0 +1,473 @@ +import type { ProjectV2 } from '@cpn-console/shared' +import type { UserDetails } from '../../types/index.js' +import { ADMIN_PERMS, PROJECT_PERMS, projectContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getRandomRequestor, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listProjects') +const businessCreateMock = vi.spyOn(business, 'createProject') +const businessUpdateMock = vi.spyOn(business, 'updateProject') +const businessDeleteMock = vi.spyOn(business, 'archiveProject') +const businessSyncMock = vi.spyOn(business, 'replayHooks') +const bulkActionProjectMock = vi.spyOn(business, 'bulkActionProject') +const businessGetSecretsMock = vi.spyOn(business, 'getProjectSecrets') +const businessGenerateDataMock = vi.spyOn(business, 'generateProjectsData') + +describe('test projectContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + const projectOwner: ProjectV2['owner'] = { + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + id: faker.string.uuid(), + type: 'human', + } + const projectId = faker.string.uuid() + const project: Omit = { + name: faker.string.alpha({ length: 10, casing: 'lower' }), + slug: faker.string.alpha({ length: 5, casing: 'lower' }), + description: faker.string.alpha({ length: 5 }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + clusterIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + locked: false, + status: 'created', + everyonePerms: '0', + members: [], + owner: projectOwner, + ownerId: projectOwner.id, + roles: [], + lastSuccessProvisionningVersion: null, + } + describe('check unauthorized user on project behaviour', () => { + // UPDATE + it('on Update', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + it('on Update without enough perms', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // REPLAY + it('on replay', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(businessSyncMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // SECRETS + it('on see secret', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(businessGetSecretsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // ARCHIVE + it('on archive', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', projectId)) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + }) + describe('listProjects', () => { + it('should return list of projects', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + const projects: any[] = [] + businessListMock.mockResolvedValueOnce(projects) + const response = await app.inject() + .get(projectContract.listProjects.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(projects) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin with "all" filter', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + const projects: any[] = [] + businessListMock.mockResolvedValueOnce(projects) + + const response = await app.inject() + .get(projectContract.listProjects.path) + .query({ filter: 'all' }) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return list of projects for admin', async () => { + const projects: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(projects) + + const response = await app.inject() + .get(projectContract.listProjects.path) + .query({ filter: 'all' }) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createProject', () => { + it('should create and return project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce({ id: projectId, ...project }) + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: projectId, ...project }) + expect(response.statusCode).toEqual(201) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateProject', () => { + const projectUpdated: Partial = { description: faker.string.alpha({ length: 5 }) } + + it('should update and return project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should not update ownerId if not permitted', async () => { + const userDetails = getRandomRequestor() + const projectPerms = getProjectMockInfos({ projectOwnerId: faker.string.uuid(), projectPermissions: PROJECT_PERMS.MANAGE }) + const projectUpdated = { ownerId: faker.string.uuid(), description: faker.lorem.words() } + const user = getUserMockInfos(0n, userDetails as UserDetails, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith({ description: projectUpdated.description }, projectId, user.user, expect.any(String)) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should update ownerId and return project', async () => { + const requestor = getRandomRequestor() + const projectPerms = getProjectMockInfos({ projectOwnerId: requestor.id, projectPermissions: PROJECT_PERMS.MANAGE }) + const projectUpdated = { ownerId: faker.string.uuid(), description: faker.lorem.words() } + const user = getUserMockInfos(0n, requestor as UserDetails, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith(projectUpdated, projectId, user.user, expect.any(String)) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(400) + }) + }) + + describe('archiveProject', () => { + it('should archive project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return projects data for admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getProjectSecrets', () => { + it('should return project secrets for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const secrets = {} + businessGetSecretsMock.mockResolvedValueOnce(secrets) + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(businessGetSecretsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(secrets) + expect(response.statusCode).toEqual(200) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetSecretsMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized access to secrets', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('replayHooksForProject', () => { + it('should replay hooks for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(businessSyncMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized access to replay hooks', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getProjectsData', () => { + it('should return projects data for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + const data = '' + businessGenerateDataMock.mockResolvedValueOnce(data) + const response = await app.inject() + .get(projectContract.getProjectsData.path) + .end() + + expect(businessGenerateDataMock).toHaveBeenCalledTimes(1) + expect(response.body).toEqual(data) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin user', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectsData.path) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('bulkActionProject', () => { + it('should executebulk for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + const response = await app.inject() + .post(projectContract.bulkActionProject.path) + .body({ action: 'lock', projectIds: [projectId] }) + .end() + + expect(response.json()).toBeNull() + expect(bulkActionProjectMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(202) + }) + + it('should return 403 for unauthorized access to bulk update', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const response = await app.inject() + .post(projectContract.bulkActionProject.path) + .body({ action: 'lock', projectIds: [projectId] }) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/project/router.ts b/.jjconflict-base-0/apps/server/src/resources/project/router.ts new file mode 100644 index 0000000000..b969303ed2 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/project/router.ts @@ -0,0 +1,186 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, ProjectAuthorized, projectContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, NotFound404, Unauthorized401 } from '@/utils/errors.js' +import { + archiveProject, + bulkActionProject, + createProject, + generateProjectsData, + getProject, + getProjectSecrets, + listProjects, + replayHooks, + updateProject, +} from './business.js' + +export function projectRouter() { + return serverInstance.router(projectContract, { + + // Récupérer des projets + listProjects: async ({ request: req, query }) => { + const { adminPermissions, user } = await authUser(req) + let body: AsyncReturnType = [] + + if (adminPermissions && !user) { // c'est donc un compte de service + query.filter = 'all' + } + if (query.filter === 'all' && !AdminAuthorized.Manage(adminPermissions)) { + return new Forbidden403('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + + body = await listProjects( + query, + user?.id, + ) + + return { + status: 200, + body, + } + }, + + // Récupérer les secrets d'un projet + getProjectSecrets: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.SeeSecrets(perms)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await getProjectSecrets(projectId) + + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Créer un projet + createProject: async ({ request: req, body: data }) => { + const perms = await authUser(req) + if (perms.user?.type !== 'human') return new Unauthorized401('Cannot find requestor in database') + if (!AdminAuthorized.ManageProjects(perms.adminPermissions)) return new Forbidden403() + + const body = await createProject(data, perms.user, req.id) + + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Récuperer un seul projet + getProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.projectId) return new NotFound404() + if (!perms.projectPermissions && !AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new NotFound404() + + const body = await getProject(projectId) + + return { + status: 200, + body, + } + }, + + // Mettre à jour un projet + updateProject: async ({ request: req, params, body: data }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!ProjectAuthorized.Manage(perms)) return new Forbidden403() + + const isAdmin = AdminAuthorized.Manage(perms.adminPermissions) + const isOwner = perms.projectOwnerId === perms.user.id + if (!isAdmin) { + delete data.locked + if (!isOwner) delete data.ownerId // impossible de toucher à cette clé + } + if (perms.projectLocked) { + if (!isAdmin) return new Forbidden403('Le projet est verrouillé') + if (data.locked !== false) return new Forbidden403('Veuillez déverrouiler le projet pour le mettre à jour') + } + + const body = await updateProject(data, projectId, perms.user, req.id) + + if (body instanceof ErrorResType) return body + return { + status: 200, + body, + } + }, + + // Reprovisionner un projet + replayHooksForProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ReplayHooks(perms)) return new Forbidden403() + + const body = await replayHooks({ + projectId, + userId: perms.user?.id, + requestId: req.id, + }) + + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + + // Archiver un projet + archiveProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!ProjectAuthorized.Manage(perms)) return new Forbidden403() + + const body = await archiveProject(projectId, perms.user, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + // Récupérer les données de tous les projets pour export + getProjectsData: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + const body = await generateProjectsData() + + return { + status: 200, + body, + } + }, + + bulkActionProject: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + + await bulkActionProject(body, perms.user, req.id) + + return { + status: 202, + body: null, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/queries-index.ts b/.jjconflict-base-0/apps/server/src/resources/queries-index.ts new file mode 100644 index 0000000000..a2c9787d2a --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/queries-index.ts @@ -0,0 +1,14 @@ +export * from '@/resources/admin-role/queries.js' +export * from '@/resources/cluster/queries.js' +export * from '@/resources/environment/queries.js' +export * from '@/resources/log/queries.js' +export * from '@/resources/project-member/queries.js' +export * from '@/resources/project-role/queries.js' +export * from '@/resources/project-service/queries.js' +export * from '@/resources/project/queries.js' +export * from '@/resources/repository/queries.js' +export * from '@/resources/service-chain/queries.js' +export * from '@/resources/stage/queries.js' +export * from '@/resources/system/settings/queries.js' +export * from '@/resources/user/queries.js' +export * from '@/resources/zone/queries.js' diff --git a/.jjconflict-base-0/apps/server/src/resources/repository/business.ts b/.jjconflict-base-0/apps/server/src/resources/repository/business.ts new file mode 100644 index 0000000000..473c9c73a7 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/repository/business.ts @@ -0,0 +1,115 @@ +import type { CreateRepositoryBody, UpdateRepositoryBody } from '@cpn-console/shared' +import type { Project, Repository, User } from '@prisma/client' +import { addLogs, deleteRepository as deleteRepositoryQuery, getProjectInfosAndRepos, getProjectRepositories as getProjectRepositoriesQuery, initializeRepository, updateRepository as updateRepositoryQuery } from '@/resources/queries-index.js' +import { BadRequest400, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function getProjectRepositories(projectId: Project['id']) { + return getProjectRepositoriesQuery(projectId) +} + +export async function syncRepository({ + repositoryId, + userId, + syncAllBranches, + branchName, + requestId, +}: { + repositoryId: Repository['id'] + userId: User['id'] + syncAllBranches: boolean + branchName?: string + requestId: string +}) { + const hookReply = await hook.misc.syncRepository(repositoryId, { syncAllBranches, branchName }) + await addLogs({ action: 'Sync Repository', data: hookReply, userId, requestId, projectId: hookReply.args.id }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la synchronisation du dépôt') + } + return null +} + +export async function createRepository({ + data, + userId, + requestId, +}: { + data: CreateRepositoryBody + userId: User['id'] + requestId: string +}) { + const project = await getProjectInfosAndRepos(data.projectId) + + if (project.repositories?.find(repo => repo.internalRepoName === data.internalRepoName)) return new BadRequest400(`Le nom du dépôt interne ${data.internalRepoName} existe déjà en base pour ce projet`) + const dbData = { ...data, isInfra: !!data.isInfra, isPrivate: !!data.isPrivate } + delete dbData.externalToken + + const repo = await initializeRepository(dbData) + const { results } = await hook.project.upsert(project.id, data.isPrivate + ? { + [repo.internalRepoName]: { + token: data.externalToken ?? '', + username: data.externalUserName ?? '', + }, + } + : undefined) + await addLogs({ action: 'Create Repository', data: results, userId, requestId, projectId: repo.projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services lors de la création du dépôt') + } + + if (data.externalRepoUrl) { + await syncRepository({ repositoryId: repo.id, requestId, syncAllBranches: true, userId }) + } + return repo +} + +export async function updateRepository({ + repositoryId, + data, + userId, + requestId, +}: { + repositoryId: Repository['id'] + data: Partial + userId: User['id'] + requestId: string +}) { + const dbData = { ...data } + delete dbData.externalToken + const repo = await updateRepositoryQuery(repositoryId, dbData) + + const { results } = await hook.project.upsert(repo.projectId, { + [repo.internalRepoName]: { + username: repo.externalUserName ?? '', + token: data.externalToken ?? '', + }, + }) + await addLogs({ action: 'Update Repository', data: results, userId, requestId, projectId: repo.projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services à la mise à jour du dépôt') + } + + return repo +} + +export async function deleteRepository({ + repositoryId, + userId, + requestId, + projectId, +}: { + repositoryId: Repository['id'] + userId: User['id'] + requestId: string + projectId: Project['id'] +}) { + await deleteRepositoryQuery(repositoryId) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Delete Repository', data: results, userId, requestId, projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services à la suppression du dépôt') + } + return null +} diff --git a/.jjconflict-base-0/apps/server/src/resources/repository/queries.ts b/.jjconflict-base-0/apps/server/src/resources/repository/queries.ts new file mode 100644 index 0000000000..277e7af602 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/repository/queries.ts @@ -0,0 +1,62 @@ +import type { Project, Repository } from '@prisma/client' +import prisma from '@/prisma.js' + +// SELECT +export function getRepositoryById(id: Repository['id']) { + return prisma.repository.findUniqueOrThrow({ where: { id } }) +} + +export function getProjectRepositories(projectId: Project['id']) { + return prisma.repository.findMany({ where: { projectId } }) +} + +// CREATE +type RepositoryCreate = Pick + & Partial> + +export function initializeRepository({ projectId, internalRepoName, externalRepoUrl, isInfra, isPrivate, externalUserName, deployRevision, deployPath, helmValuesFiles }: RepositoryCreate) { + return prisma.repository.create({ + data: { + projectId, + internalRepoName, + externalRepoUrl, + externalUserName, + isInfra, + isPrivate, + deployRevision, + deployPath, + helmValuesFiles, + }, + }) +} + +export function getHookRepository(id: Repository['id']) { + return prisma.repository.findUniqueOrThrow({ + where: { + id, + }, + include: { + project: true, + }, + }) +} + +// UPDATE +export function updateRepository(id: Repository['id'], infos: Partial) { + return prisma.repository.update({ where: { id }, data: { ...infos } }) +} + +// DELETE +export async function deleteRepository(id: Repository['id']) { + const doesRepoExist = await getRepositoryById(id) + if (!doesRepoExist) throw new Error('Le dépôt interne demandé n\'existe pas en base pour ce projet') + return prisma.repository.delete({ where: { id } }) +} + +export function deleteAllRepositoryForProject(id: Project['id']) { + return prisma.repository.deleteMany({ where: { projectId: id } }) +} + +export function _createRepository(data: Parameters[0]['create']) { + return prisma.repository.upsert({ create: data, update: data, where: { id: data.id } }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/repository/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/repository/router.spec.ts new file mode 100644 index 0000000000..ae08505be0 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/repository/router.spec.ts @@ -0,0 +1,404 @@ +import { PROJECT_PERMS, repositoryContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { atDates, getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessCreateMock = vi.spyOn(business, 'createRepository') +const businessUpdateMock = vi.spyOn(business, 'updateRepository') +const businessDeleteMock = vi.spyOn(business, 'deleteRepository') +const businessSyncMock = vi.spyOn(business, 'syncRepository') +const businessGetProjectRepositoriesMock = vi.spyOn(business, 'getProjectRepositories') + +describe('repositoryRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const repositoryId = faker.string.uuid() + const repositoryData = { + projectId, + externalRepoUrl: `${faker.internet.url()}.git`, + isPrivate: true, + externalToken: faker.string.alpha(), + externalUserName: faker.internet.username(), + isInfra: false, + internalRepoName: faker.string.alpha({ length: 5, casing: 'lower' }), + } + + describe('listRepositories', () => { + it('should return repositories for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetProjectRepositoriesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(repositoryContract.listRepositories.path) + .query({ projectId }) + .end() + + expect(businessGetProjectRepositoriesMock).toHaveBeenCalledWith(projectId) + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.REPLAY_HOOKS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(repositoryContract.listRepositories.path) + .query({ projectId }) + .end() + + expect(businessGetProjectRepositoriesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('syncRepository', () => { + it('should synchronize repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(204) + expect(businessSyncMock).toHaveBeenCalledWith({ repositoryId, userId: user.user.id, branchName: 'main', requestId: expect.any(String), syncAllBranches: false }) + }) + + it('should return 403 for forbidden sync attempt', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('createRepository', () => { + it('should create repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...atDates }) + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(201) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should return 403 for insuficient permissions', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateRepository', () => { + const repoUpdateData = { isInfra: true } + it('should update repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...repoUpdateData, ...atDates }) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(200) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData, ...repoUpdateData }) + }) + + it('should update repository and drop creds if is not private', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const repoUpdateData = { isPrivate: false, externalUserName: 'test' } + businessUpdateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...repoUpdateData, ...atDates }) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith({ data: { isPrivate: false }, repositoryId, requestId: expect.any(String), userId: user.user.id }) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData, ...repoUpdateData }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not enough permissions', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(400) + }) + // TODO add tests about filtering + }) + + describe('deleteRepository', () => { + it('should delete repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not enough privilege', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/repository/router.ts b/.jjconflict-base-0/apps/server/src/resources/repository/router.ts new file mode 100644 index 0000000000..a9e93209ee --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/repository/router.ts @@ -0,0 +1,133 @@ +import { fakeToken, ProjectAuthorized, repositoryContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { filterObjectByKeys } from '@/utils/queries-tools.js' +import { + createRepository, + deleteRepository, + getProjectRepositories, + syncRepository, + updateRepository, +} from './business.js' + +export function repositoryRouter() { + return serverInstance.router(repositoryContract, { + // Récupérer tous les repositories d'un projet + listRepositories: async ({ request: req, query }) => { + const projectId = query.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListRepositories(perms)) return new Forbidden403() + const body = await getProjectRepositories(projectId) + + return { + status: 200, + body, + } + }, + + // Synchroniser un repository + syncRepository: async ({ request: req, params, body }) => { + const { repositoryId } = params + const perms = await authUser(req, { repositoryId }) + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const { syncAllBranches, branchName } = body + + const resBody = await syncRepository({ repositoryId, userId: perms.user.id, branchName, requestId: req.id, syncAllBranches }) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 204, + body: resBody, + } + }, + + // Créer un repository + createRepository: async ({ request: req, body: data }) => { + const projectId = data.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await createRepository({ data, userId: perms.user.id, requestId: req.id }) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Mettre à jour un repository + updateRepository: async ({ request: req, params, body }) => { + const repositoryId = params.repositoryId + const perms = await authUser(req, { repositoryId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const keysAllowedForUpdate = [ + 'externalRepoUrl', + 'isPrivate', + 'externalToken', + 'externalUserName', + 'isInfra', + 'deployRevision', + 'deployPath', + 'helmValuesFiles', + ] + const data = filterObjectByKeys(body, keysAllowedForUpdate) + + if (data.externalToken === fakeToken) { + delete data.externalToken + } + + if (data.isPrivate === false) { + delete data.externalToken + delete data.externalUserName + } + + const resBody = await updateRepository({ repositoryId, data, userId: perms.user.id, requestId: req.id }) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + // Supprimer un repository + deleteRepository: async ({ request: req, params }) => { + const repositoryId = params.repositoryId + const perms = await authUser(req, { repositoryId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await deleteRepository({ + repositoryId, + userId: perms.user.id, + requestId: req.id, + projectId: perms.projectId, + }) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/service-chain/business.spec.ts b/.jjconflict-base-0/apps/server/src/resources/service-chain/business.spec.ts new file mode 100644 index 0000000000..a3903e6eff --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/service-chain/business.spec.ts @@ -0,0 +1,171 @@ +import type { + ServiceChain, + ServiceChainDetails, + ServiceChainFlows, +} from '@cpn-console/shared' +import type { Mock } from 'vitest' +import { + serviceChainEnvironmentEnum, + serviceChainFlowStateEnum, + serviceChainLocationEnum, + serviceChainNetworkEnum, + serviceChainStateEnum, +} from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import axios from 'axios' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + getServiceChainDetails, + getServiceChainFlows, + listServiceChains, + retryServiceChain, + validateServiceChain, +} from './business.ts' + +vi.mock('axios') + +let serviceChain: ServiceChain +let serviceChainDetails: ServiceChainDetails +let serviceChainFlows: ServiceChainFlows + +describe('test ServiceChain business logic', () => { + beforeEach(() => { + serviceChain = { + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + } + + serviceChainDetails = { + ...serviceChain, + validationId: faker.string.uuid(), + validatedBy: faker.helpers.maybe(() => faker.string.uuid()) || null, + ref: faker.string.uuid(), + location: faker.helpers.arrayElement(serviceChainLocationEnum), + targetAddress: faker.internet.ipv4(), + projectId: faker.string.uuid(), + env: faker.helpers.arrayElement(serviceChainEnvironmentEnum), + subjectAlternativeName: faker.helpers.uniqueArray( + faker.internet.domainName, + 3, + ), + redirect: faker.datatype.boolean(), + antivirus: + faker.helpers.maybe(() => ({ + maxFileSize: faker.number.int(), + })) || null, // undefined is not wanted here + websocket: faker.datatype.boolean(), + ipWhiteList: faker.helpers + .uniqueArray(faker.internet.ipv4, 5) + .map(e => `${e}/32`), // We want a CIDR here + sslOutgoing: faker.datatype.boolean(), + } + + serviceChainFlows = { + reserve_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + create_cert: faker.helpers.maybe(() => ({ + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + })) || null, + call_exec: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + activate_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + dns_request: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + } + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('listServiceChains', () => { + it('should return a list of service chains', async () => { + const input = [serviceChain]; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await listServiceChains() + + expect(result).toStrictEqual(input) + }) + }) + + describe('getServiceChainDetails', () => { + it('should return a service chain details', async () => { + const input = serviceChainDetails; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await getServiceChainDetails(faker.string.uuid()) + + expect(result).toStrictEqual(input) + }) + }) + + describe('retryServiceChain', () => { + it('should trigger a service chain retry attempt', async () => { + const input = {}; + (axios.create as Mock).mockReturnValue({ + post: () => ({ data: input }), + }) + + const result = await retryServiceChain(faker.string.uuid()) + + expect(result.data).toStrictEqual(input) + }) + }) + + describe('validateServiceChain', () => { + it('should trigger a service chain validate attempt', async () => { + const input = {}; + (axios.create as Mock).mockReturnValue({ + post: () => ({ data: input }), + }) + + const result = await validateServiceChain(faker.string.uuid()) + + expect(result.data).toStrictEqual(input) + }) + }) + + describe('getServiceChainFlows', () => { + it('should return a service chain flows', async () => { + const input = serviceChainFlows; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await getServiceChainFlows(faker.string.uuid()) + + expect(result).toStrictEqual(input) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/service-chain/business.ts b/.jjconflict-base-0/apps/server/src/resources/service-chain/business.ts new file mode 100644 index 0000000000..43ffb986e6 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/service-chain/business.ts @@ -0,0 +1,27 @@ +import { + getServiceChainDetails as getServiceChainDetailsQuery, + getServiceChainFlows as getServiceChainFlowsQuery, + listServiceChains as listServiceChainsQuery, + retryServiceChain as retryServiceChainQuery, + validateServiceChain as validateServiceChainQuery, +} from '@/resources/queries-index.js' + +export async function listServiceChains() { + return listServiceChainsQuery() +} + +export async function getServiceChainDetails(serviceChainId: string) { + return getServiceChainDetailsQuery(serviceChainId) +} + +export async function retryServiceChain(serviceChainId: string) { + return retryServiceChainQuery(serviceChainId) +} + +export async function validateServiceChain(validationId: string) { + return validateServiceChainQuery(validationId) +} + +export async function getServiceChainFlows(serviceChainId: string) { + return getServiceChainFlowsQuery(serviceChainId) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/service-chain/queries.ts b/.jjconflict-base-0/apps/server/src/resources/service-chain/queries.ts new file mode 100644 index 0000000000..0207266c8a --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/service-chain/queries.ts @@ -0,0 +1,59 @@ +import type { ServiceChain } from '@cpn-console/shared' +import https from 'node:https' +import { + + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainListSchema, +} from '@cpn-console/shared' +import axios from 'axios' + +const openCDSEnvVar = 'OPENCDS_URL' +const openCDSTargetURL = process.env[openCDSEnvVar] +const openCDSDisabledErrorMessage = `OpenCDS is disabled, please set ${openCDSEnvVar} in your relevant .env file. See .env-example` + +function getClient() { + if (!openCDSTargetURL) { + throw new Error(openCDSDisabledErrorMessage) + } + return axios.create({ + baseURL: openCDSTargetURL, + httpsAgent: new https.Agent({ + rejectUnauthorized: + // We want it to be `false` only if it has explicitly + // been stated as "false" in the env vars + process.env.OPENCDS_API_TLS_REJECT_UNAUTHORIZED !== 'false', + }), + headers: { + 'X-API-Key': process.env.OPENCDS_API_TOKEN, + }, + }) +} + +export async function listServiceChains() { + return ServiceChainListSchema.parse( + (await getClient().get(`/requests`)).data, + ) +} + +export async function getServiceChainDetails( + serviceChainId: ServiceChain['id'], +) { + return ServiceChainDetailsSchema.parse( + (await getClient().get(`/requests/${serviceChainId}`)).data, + ) +} + +export async function retryServiceChain(serviceChainId: ServiceChain['id']) { + return await getClient().post(`/requests/${serviceChainId}/retry`) +} + +export async function validateServiceChain(validationId: string) { + return await getClient().post(`/validate/${validationId}`) +} + +export async function getServiceChainFlows(serviceChainId: ServiceChain['id']) { + return ServiceChainFlowsSchema.parse( + (await getClient().get(`/requests/${serviceChainId}/flows`)).data, + ) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/service-chain/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/service-chain/router.spec.ts new file mode 100644 index 0000000000..c69c14edff --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/service-chain/router.spec.ts @@ -0,0 +1,307 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import { + ADMIN_PERMS, + serviceChainContract, + ServiceChainDetailsSchema, + serviceChainEnvironmentEnum, + ServiceChainFlowsSchema, + serviceChainFlowStateEnum, + ServiceChainListSchema, + serviceChainLocationEnum, + serviceChainNetworkEnum, + serviceChainStateEnum, +} from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock( + 'fastify-keycloak-adapter', + (await import('../../utils/mocks.js')).mockSessionPlugin, +) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListServiceChainsMock = vi.spyOn(business, 'listServiceChains') +const businessGetServiceChainDetailsMock = vi.spyOn(business, 'getServiceChainDetails') +const businessRetryServiceChainMock = vi.spyOn(business, 'retryServiceChain') +const businessValidateServiceChainMock = vi.spyOn(business, 'validateServiceChain') +const businessGetServiceChainsFlowsMock = vi.spyOn(business, 'getServiceChainFlows') + +describe('test ServiceChainContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listServiceChains', () => { + it('as non admin', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + businessListServiceChainsMock.mockResolvedValueOnce([]) + const response = await app + .inject() + .get(serviceChainContract.listServiceChains.path) + .end() + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('as admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const serviceChainList = faker.helpers.multiple(() => ({ + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + })) + + authUserMock.mockResolvedValueOnce(user) + + businessListServiceChainsMock.mockResolvedValueOnce(serviceChainList) + const response = await app + .inject() + .get(serviceChainContract.listServiceChains.path) + .end() + + expect(businessListServiceChainsMock).toHaveBeenCalledWith() + + expect(ServiceChainListSchema.parse(response.json())).toStrictEqual( + serviceChainList, + ) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getServiceChainDetails', () => { + it('should return serviceChain details', async () => { + const serviceChainDetails: ServiceChainDetails = { + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + validationId: faker.string.uuid(), + validatedBy: faker.string.uuid(), + ref: faker.string.uuid(), + location: faker.helpers.arrayElement(serviceChainLocationEnum), + targetAddress: faker.internet.ipv4(), + projectId: faker.string.uuid(), + env: faker.helpers.arrayElement(serviceChainEnvironmentEnum), + subjectAlternativeName: faker.helpers.uniqueArray( + faker.internet.domainName, + 3, + ), + redirect: faker.datatype.boolean(), + antivirus: + faker.helpers.maybe(() => ({ + maxFileSize: faker.number.int(), + })) || null, // undefined is not wanted here + websocket: faker.datatype.boolean(), + ipWhiteList: faker.helpers + .uniqueArray(faker.internet.ipv4, 5) + .map(e => `${e}/32`), // We want a CIDR here + sslOutgoing: faker.datatype.boolean(), + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessGetServiceChainDetailsMock.mockResolvedValueOnce(serviceChainDetails) + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainDetails.path.replace( + ':serviceChainId', + serviceChainDetails.id, + ), + ) + .end() + + expect(ServiceChainDetailsSchema.parse(response.json())).toEqual( + serviceChainDetails, + ) + expect(response.statusCode).toEqual(200) + expect(businessGetServiceChainDetailsMock).toHaveBeenCalledTimes(1) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainDetails.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessGetServiceChainDetailsMock).toHaveBeenCalledTimes(0) + }) + }) + + describe('retryServiceChain', () => { + it('should return 204', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessRetryServiceChainMock.mockResolvedValueOnce({ + status: 204, + body: undefined, + }) + const response = await app + .inject() + .post( + serviceChainContract.retryServiceChain.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.body).toEqual('') + expect(businessRetryServiceChainMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(204) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .post( + serviceChainContract.retryServiceChain.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessRetryServiceChainMock).toHaveBeenCalledTimes(0) + }) + }) + + describe('validateServiceChain', () => { + it('should return 204', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessValidateServiceChainMock.mockResolvedValueOnce({ + status: 204, + body: undefined, + }) + const response = await app + .inject() + .post( + serviceChainContract.validateServiceChain.path.replace( + ':validationId', + faker.string.uuid(), + ), + ) + .end() + + expect(businessValidateServiceChainMock).toHaveBeenCalledTimes(1) + expect(response.body).toEqual('') + expect(response.statusCode).toEqual(204) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .post( + serviceChainContract.validateServiceChain.path.replace( + ':validationId', + faker.string.uuid(), + ), + ) + .end() + + expect(businessValidateServiceChainMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getServiceChainFlows', () => { + it('should return serviceChain flows', async () => { + const serviceChainFlows: ServiceChainFlows = { + reserve_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + create_cert: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + call_exec: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + activate_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + dns_request: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessGetServiceChainsFlowsMock.mockResolvedValueOnce(serviceChainFlows) + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainFlows.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(ServiceChainFlowsSchema.parse(response.json())).toEqual( + serviceChainFlows, + ) + expect(response.statusCode).toEqual(200) + expect(businessGetServiceChainsFlowsMock).toHaveBeenCalledTimes(1) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainFlows.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessGetServiceChainsFlowsMock).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/service-chain/router.ts b/.jjconflict-base-0/apps/server/src/resources/service-chain/router.ts new file mode 100644 index 0000000000..5888418cfe --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/service-chain/router.ts @@ -0,0 +1,94 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, serviceChainContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { + getServiceChainDetails as getServiceChainDetailsBusiness, + getServiceChainFlows as getServiceChainFlowsBusiness, + listServiceChains as listServiceChainsBusiness, + retryServiceChain as retryServiceChainBusiness, + validateServiceChain as validateServiceChainBusiness, +} from './business.js' +import '@/types/index.js' + +export function serviceChainRouter() { + return serverInstance.router(serviceChainContract, { + listServiceChains: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + let body: AsyncReturnType = [] + if (AdminAuthorized.ListSystem(adminPermissions)) { + body = await listServiceChainsBusiness() + } + + return { + status: 200, + body, + } + }, + + getServiceChainDetails: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + const serviceChainDetails + = await getServiceChainDetailsBusiness(serviceChainId) + + return { + status: 200, + body: serviceChainDetails, + } + }, + + retryServiceChain: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + await retryServiceChainBusiness(serviceChainId) + + return { + status: 204, + body: null, + } + }, + + validateServiceChain: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.validationId + await validateServiceChainBusiness(serviceChainId) + + return { + status: 204, + body: null, + } + }, + + getServiceChainFlows: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + const serviceChainFlows + = await getServiceChainFlowsBusiness(serviceChainId) + + return { + status: 200, + body: serviceChainFlows, + } + }, + + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/service-monitor/business.ts b/.jjconflict-base-0/apps/server/src/resources/service-monitor/business.ts new file mode 100644 index 0000000000..fa61d5a6de --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/service-monitor/business.ts @@ -0,0 +1,9 @@ +import { services } from '@cpn-console/hooks' + +export function checkServicesHealth() { + return services.getStatus() +} + +export async function refreshServicesHealth() { + return Promise.all(services.refreshStatus()) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/service-monitor/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/service-monitor/router.spec.ts new file mode 100644 index 0000000000..898ed61e5a --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/service-monitor/router.spec.ts @@ -0,0 +1,78 @@ +import type { ServiceStatus } from '@cpn-console/hooks' +import { ADMIN_PERMS, MonitorStatus, serviceContract } from '@cpn-console/shared' +import { describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +const authUserMock = vi.spyOn(utilsController, 'authUser') + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const businessCheckMock = vi.spyOn(business, 'checkServicesHealth') +const businessRefreshMock = vi.spyOn(business, 'refreshServicesHealth') + +describe('test serviceContract', () => { + const services: ServiceStatus[] = [{ interval: 1, lastUpdateTimestamp: 1, message: 'OK', name: 'A service', status: MonitorStatus.OK }] + const servicesComplete: ServiceStatus[] = [{ cause: 'error', interval: 1, lastUpdateTimestamp: 1, message: 'OK', name: 'A service', status: MonitorStatus.OK }] + + it('should return complete services, with cause', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(servicesComplete) + expect(response.statusCode).toEqual(200) + }) + + it('should not return complete services, forbidden', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return services', async () => { + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(services) + expect(response.statusCode).toEqual(200) + }) + + it('should refresh services', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessRefreshMock.mockResolvedValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(servicesComplete) + expect(response.statusCode).toEqual(200) + }) + + it('should refresh services, cause forbidden', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + businessRefreshMock.mockResolvedValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.statusCode).toEqual(403) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/service-monitor/router.ts b/.jjconflict-base-0/apps/server/src/resources/service-monitor/router.ts new file mode 100644 index 0000000000..ace5831c3e --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/service-monitor/router.ts @@ -0,0 +1,44 @@ +import { AdminAuthorized, serviceContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { checkServicesHealth, refreshServicesHealth } from './business.js' + +export function serviceMonitorRouter() { + return serverInstance.router(serviceContract, { + getServiceHealth: async () => { + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + + getCompleteServiceHealth: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ListSystem(adminPermissions)) return new Forbidden403() + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + + refreshServiceHealth: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageSystem(adminPermissions)) return new Forbidden403() + + await refreshServicesHealth() + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/stage/business.spec.ts b/.jjconflict-base-0/apps/server/src/resources/stage/business.spec.ts new file mode 100644 index 0000000000..ba43c2cb75 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/stage/business.spec.ts @@ -0,0 +1,113 @@ +import type { Environment, Stage } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { BadRequest400, NotFound404 } from '../../utils/errors.ts' +import { createStage, deleteStage, getStageAssociatedEnvironments, listStages, updateStage } from './business.ts' + +describe('test stage busines logic', () => { + let stage: Stage + beforeEach(() => { + vi.resetAllMocks() + stage = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + } + }) + describe('createStage', () => { + it('should create a stage', async () => { + prisma.stage.findUnique.mockResolvedValue(null) + prisma.stage.create.mockResolvedValue({ id: stage.id } as Stage) + await createStage({ name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(1) + }) + it('should not create a stage, name conflict', async () => { + prisma.stage.findUnique.mockResolvedValue({ id: stage.id } as Stage) + const response = await createStage({ name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) + + describe('updateStage', () => { + it('should update a stage', async () => { + const dbClusters = [{ id: faker.string.uuid() }] + const newClusters = [faker.string.uuid()] + prisma.stage.findUnique.mockResolvedValue({ ...stage, clusters: dbClusters } as Stage) + prisma.stage.update.mockResolvedValue({ id: stage.id } as Stage) + const response = await updateStage(stage.id, { name: stage.name, clusterIds: newClusters }) + expect(prisma.cluster.update).toHaveBeenCalledTimes(1) + expect(prisma.cluster.update).toHaveBeenCalledWith({ where: { id: dbClusters[0].id }, data: { + stages: { + disconnect: { + id: stage.id, + }, + }, + } }) + expect(prisma.stage.update).toHaveBeenCalledTimes(1) + expect(prisma.stage.update).toHaveBeenCalledWith({ where: { id: stage.id }, data: { + clusters: { + connect: [{ + id: newClusters[0], + }], + }, + } }) + expect(response.clusterIds).toBe(newClusters) + }) + it('should do nothing', async () => { + prisma.stage.findUnique.mockResolvedValue({ ...stage, clusters: [] } as Stage) + await updateStage(stage.id, { clusterIds: [], name: stage.name }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + }) + it('should return not found', async () => { + prisma.stage.findUnique.mockResolvedValue(null) + const response = await updateStage(stage.id, { name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + expect(response).instanceOf(NotFound404) + }) + }) + + describe('deleteStage', () => { + it('should delete a stage', async () => { + prisma.environment.findFirst.mockResolvedValue(null) + prisma.stage.delete.mockResolvedValue({ id: stage.id } as Stage) + await deleteStage(stage.id) + expect(prisma.stage.delete).toHaveBeenCalledTimes(1) + }) + it('should not delete a stage, environment attached', async () => { + prisma.environment.findFirst.mockResolvedValue({ id: faker.string.uuid() } as Environment) + const response = await deleteStage(stage.id) + expect(prisma.stage.delete).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) + + describe('listStages', () => { + const clusterAssociated = [{ id: faker.string.uuid() }] + it('should list all stages (admin, no userId provided)', async () => { + prisma.stage.findMany.mockResolvedValue([{ clusters: clusterAssociated }] as unknown as Stage[]) + const response = await listStages() + expect(response[0].clusterIds).toStrictEqual([clusterAssociated[0].id]) + expect(prisma.stage.findMany).toHaveBeenCalledTimes(1) + expect(prisma.stage.findMany).toHaveBeenCalledWith({ include: { clusters: true } }) + }) + }) + + describe('getStageAssociatedEnvironments', () => { + it('should list all environments attached to a stage stages', async () => { + const envName = faker.string.alpha(8) + const projectSlug = faker.string.alpha(8) + const clusterLabel = faker.string.alpha(8) + const ownerEmail = faker.internet.email() + const envs = [{ name: envName, project: { slug: projectSlug, owner: { email: ownerEmail } }, cluster: { label: clusterLabel } }] + prisma.environment.findMany.mockResolvedValue(envs as unknown as Environment[]) + const response = await getStageAssociatedEnvironments(stage.id) + expect(response).toStrictEqual([{ + name: envName, + project: projectSlug, + owner: ownerEmail, + cluster: clusterLabel, + }]) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/stage/business.ts b/.jjconflict-base-0/apps/server/src/resources/stage/business.ts new file mode 100644 index 0000000000..db6faf686e --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/stage/business.ts @@ -0,0 +1,97 @@ +import type { CreateStageBody, UpdateStageBody } from '@cpn-console/shared' +import type { Cluster, Stage } from '@prisma/client' +import prisma from '@/prisma.js' +import { + createStage as createStageQuery, + deleteStage as deleteStageQuery, + getAllStageIds, + getStageAssociatedEnvironmentById, + getStageById, + getStageByName, + linkClusterToStages as linkClusterToStagesQuery, + linkStageToClusters, + listStages as listStagesQuery, + removeClusterFromStage, + updateStageName, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' + +export async function getStageAssociatedEnvironments(stageId: Stage['id']) { + const environments = await getStageAssociatedEnvironmentById(stageId) + return environments.map(env => ({ + project: env.project.slug, + name: env.name, + cluster: env.cluster.label, + owner: env.project.owner.email, + })) +} + +export async function createStage({ clusterIds = [], name }: CreateStageBody) { + const isNameTaken = await getStageByName(name) + if (isNameTaken) return new BadRequest400('Un type d\'environnement portant ce nom existe déjà') + + const stage = await createStageQuery({ name }) + + if (clusterIds.length) { + await linkStageToClusters(stage.id, clusterIds) + } + + return { + id: stage.id, + name: stage.name, + clusterIds, + } +} + +export async function updateStage(stageId: Stage['id'], { clusterIds, name }: UpdateStageBody) { + const dbStage = await getStageById(stageId) + if (!dbStage) return new NotFound404() + if (name !== dbStage.name) { + await updateStageName(stageId, name) + } + // Remove clusters + const dbClusters = dbStage.clusters + if (dbClusters?.length) { + const clustersToRemove = dbClusters.filter(dbCluster => !clusterIds.includes(dbCluster.id)) + for (const clusterToRemove of clustersToRemove) { + await removeClusterFromStage(clusterToRemove.id, stageId) + } + } + // Add clusters + if (clusterIds.length) { + await linkStageToClusters(stageId, clusterIds) + } + + return { + id: stageId, + name: name ?? dbStage.name, + clusterIds: clusterIds ?? dbStage.clusters.map(({ id }) => id), + } +} + +export async function deleteStage(stageId: Stage['id']) { + const attachedEnvironment = await prisma.environment.findFirst({ where: { stageId }, select: { id: true } }) + if (attachedEnvironment) return new BadRequest400('Impossible de supprimer le stage, des environnements en activité y ont souscrit') + + await deleteStageQuery(stageId) + return null +} + +export async function listStages() { + const stages = await listStagesQuery() + + return stages.map((stage) => { + return { + id: stage.id, + name: stage.name, + clusterIds: stage.clusters.map(({ id }) => id), + } + }) +} + +export async function linkClusterToStages(clusterId: Cluster['id'], stageIds: Stage['id'][], linkToAll: boolean = false) { + if (linkToAll === true) { + stageIds = await getAllStageIds() + } + await linkClusterToStagesQuery(clusterId, stageIds) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/stage/queries.ts b/.jjconflict-base-0/apps/server/src/resources/stage/queries.ts new file mode 100644 index 0000000000..98d526600a --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/stage/queries.ts @@ -0,0 +1,111 @@ +import type { Cluster, Stage } from '@prisma/client' +import prisma from '@/prisma.js' + +export function listStages() { + return prisma.stage.findMany({ + include: { + clusters: true, + }, + }) +} + +export async function getAllStageIds() { + return (await prisma.stage.findMany({ + select: { + id: true, + }, + })).map(({ id }) => id) +} + +export function getStageById(id: Stage['id']) { + return prisma.stage.findUnique({ + where: { id }, + include: { + clusters: true, + }, + }) +} + +export function getStageByIdOrThrow(id: Stage['id']) { + return prisma.stage.findUniqueOrThrow({ + where: { id }, + include: { + clusters: true, + }, + }) +} + +export function getStageAssociatedEnvironmentById(id: Stage['id']) { + return prisma.environment.findMany({ + where: { + stageId: id, + }, + select: { + name: true, + cluster: { + select: { + label: true, + }, + }, + project: { + select: { + name: true, + owner: true, + slug: true, + }, + }, + }, + }) +} + +export function getStageAssociatedEnvironmentLengthById(id: Stage['id']) { + return prisma.environment.count({ + where: { + stageId: id, + }, + }) +} + +export function getStageByName(name: Stage['name']) { + return prisma.stage.findUnique({ + where: { name }, + }) +} + +export function linkStageToClusters(id: Stage['id'], clusterIds: Cluster['id'][]) { + return prisma.stage.update({ + where: { + id, + }, + data: { + clusters: { + connect: clusterIds.map(clusterId => ({ id: clusterId })), + }, + }, + }) +} + +export function createStage({ name }: { name: Stage['name'] }) { + return prisma.stage.create({ + data: { + name, + }, + }) +} + +export function updateStageName(id: Stage['id'], name: Stage['name']) { + return prisma.stage.update({ + where: { + id, + }, + data: { + name, + }, + }) +} + +export function deleteStage(id: Stage['id']) { + return prisma.stage.delete({ + where: { id }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/stage/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/stage/router.spec.ts new file mode 100644 index 0000000000..3e62a482fc --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/stage/router.spec.ts @@ -0,0 +1,204 @@ +import type { Stage } from '@cpn-console/shared' +import { ADMIN_PERMS, stageContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listStages') +const businessGetEnvironmentsMock = vi.spyOn(business, 'getStageAssociatedEnvironments') +const businessCreateMock = vi.spyOn(business, 'createStage') +const businessUpdateMock = vi.spyOn(business, 'updateStage') +const businessDeleteMock = vi.spyOn(business, 'deleteStage') + +describe('test stageContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listStages', () => { + it('should return list of stages', async () => { + const stages: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_STAGES) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(stages) + + const response = await app.inject() + .get(stageContract.listStages.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(stages) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getStageEnvironments', () => { + it('should return stage environments for admin', async () => { + const environments: any = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(environments) + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(environments) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createStage', () => { + const stage: Stage = { id: faker.string.uuid(), name: faker.string.alpha({ length: 5 }), clusterIds: [] } + + it('should create and return stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(stage) + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(stage) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateStage', () => { + const stageId = faker.string.uuid() + const stage = { name: faker.string.alpha({ length: 5 }), clusterIds: [] } + + it('should update and return stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: stageId, ...stage }) + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: stageId, ...stage }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteStage', () => { + it('should delete stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/stage/router.ts b/.jjconflict-base-0/apps/server/src/resources/stage/router.ts new file mode 100644 index 0000000000..73202d4549 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/stage/router.ts @@ -0,0 +1,92 @@ +import { AdminAuthorized, stageContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + createStage, + deleteStage, + getStageAssociatedEnvironments, + listStages, + updateStage, +} from './business.js' + +export function stageRouter() { + return serverInstance.router(stageContract, { + + // Récupérer les types d'environnement disponibles + listStages: async () => { + const body = await listStages() + + return { + status: 200, + body, + } + }, + + // Récupérer les environnements associés au stage + getStageEnvironments: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + const body = await getStageAssociatedEnvironments(stageId) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Créer un stage + createStage: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const body = await createStage(data) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Modifier une association stage / clusters + updateStage: async ({ request: req, params, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + + const body = await updateStage(stageId, data) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Supprimer un stage + deleteStage: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + + const body = await deleteStage(stageId) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/system/config/business.spec.ts b/.jjconflict-base-0/apps/server/src/resources/system/config/business.spec.ts new file mode 100644 index 0000000000..35d4844070 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/system/config/business.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import prisma from '../../../__mocks__/prisma.js' +import { objToDb, updatePluginConfig } from './business.ts' + +describe('test system/config business', () => { + const config = { test: { key1: 'value1' } } + it('should transform object to db row', () => { + const response = objToDb({ test: { key1: 'value1' } }) + expect(response).toEqual([{ pluginName: 'test', key: 'key1', value: 'value1' }]) + }) + describe('updatePluginConfig', () => { + it('should update', async () => { + prisma.adminPlugin.upsert.mockResolvedValue(null) + await updatePluginConfig(config) + }) + it('should update 0 items cause missing manifest', async () => { + // @ts-ignore + await updatePluginConfig({ test: { key: 1 } }) + expect(prisma.adminPlugin.upsert).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/system/config/business.ts b/.jjconflict-base-0/apps/server/src/resources/system/config/business.ts new file mode 100644 index 0000000000..111cb4cd1b --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/system/config/business.ts @@ -0,0 +1,50 @@ +import type { + PluginsUpdateBody, +} from '@cpn-console/shared' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { BadRequest400 } from '@/utils/errors.js' +import { + getAdminPlugin, + savePluginsConfig, +} from './queries.js' + +export type ConfigRecords = { + key: string + pluginName: string + value: string +}[] + +export function objToDb(obj: PluginsUpdateBody): ConfigRecords { + return Object.entries(obj) + .map(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) + .flat() +} + +export async function getPluginsConfig() { + const globalConfig = await getAdminPlugin() + + return Object.values(servicesInfos).map(({ name, title, imgSrc, description }) => { + const manifest = populatePluginManifests({ + data: { + global: globalConfig, + }, + permissionTarget: 'admin', + pluginName: name, + select: { + global: true, + project: false, + }, + }) + return { imgSrc, title, name, manifest: manifest.global ?? [], description } + }).filter(plugin => plugin.manifest.length > 0) +} + +export async function updatePluginConfig(data: PluginsUpdateBody) { + const parsedData = editStrippers.global.safeParse(data) + if (!parsedData.success) return new BadRequest400(parsedData.error.message) + const records = objToDb(parsedData.data) + + await savePluginsConfig(records) + return null +} diff --git a/.jjconflict-base-0/apps/server/src/resources/system/config/queries.ts b/.jjconflict-base-0/apps/server/src/resources/system/config/queries.ts new file mode 100644 index 0000000000..69808e506a --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/system/config/queries.ts @@ -0,0 +1,28 @@ +import type { ConfigRecords } from './business.js' +import prisma from '@/prisma.js' + +// CONFIG +export const getAdminPlugin = prisma.adminPlugin.findMany + +export async function savePluginsConfig(records: ConfigRecords) { + for (const { pluginName, key, value } of records) { + await prisma.adminPlugin.upsert({ + create: { + pluginName, + key, + value: String(value), + }, + update: { + key, + value: String(value), + pluginName, + }, + where: { + pluginName_key: { + pluginName, + key, + }, + }, + }) + } +} diff --git a/.jjconflict-base-0/apps/server/src/resources/system/config/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/system/config/router.spec.ts new file mode 100644 index 0000000000..423a1b7b38 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/system/config/router.spec.ts @@ -0,0 +1,96 @@ +import { ADMIN_PERMS, systemPluginContract } from '@cpn-console/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../../app.js' +import * as utilsController from '../../../utils/controller.js' +import { BadRequest400 } from '../../../utils/errors.js' +import { getUserMockInfos } from '../../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetPluginsConfigMock = vi.spyOn(business, 'getPluginsConfig') +const businessUpdatePluginConfigMock = vi.spyOn(business, 'updatePluginConfig') + +describe('test systemPluginContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getPluginsConfig', () => { + it('should return plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const pluginsConfig: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetPluginsConfigMock.mockResolvedValueOnce(pluginsConfig) + + const response = await app.inject() + .get(systemPluginContract.getPluginsConfig.path) + .end() + + expect(businessGetPluginsConfigMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(pluginsConfig) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(systemPluginContract.getPluginsConfig.path) + .end() + + expect(businessGetPluginsConfigMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updatePluginsConfig', () => { + const newConfig = { plugin1: { keyId: 'value' } } + it('should update plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpdatePluginConfigMock.mockResolvedValueOnce(newConfig) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return error if business logic fails', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpdatePluginConfigMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(400) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/system/config/router.ts b/.jjconflict-base-0/apps/server/src/resources/system/config/router.ts new file mode 100644 index 0000000000..d541dc1778 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/system/config/router.ts @@ -0,0 +1,38 @@ +import { AdminAuthorized, systemPluginContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { getPluginsConfig, updatePluginConfig } from './business.js' + +export function pluginConfigRouter() { + return serverInstance.router(systemPluginContract, { + // Récupérer les configurations plugins + getPluginsConfig: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) return new Forbidden403() + + const services = await getPluginsConfig() + + return { + status: 200, + body: services, + + } + }, + // Mettre à jour les configurations plugins + updatePluginsConfig: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) return new Forbidden403() + + const resBody = await updatePluginConfig(body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/system/index.ts b/.jjconflict-base-0/apps/server/src/resources/system/index.ts new file mode 100644 index 0000000000..a45d7accc7 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/system/index.ts @@ -0,0 +1 @@ +export * from './router.js' diff --git a/.jjconflict-base-0/apps/server/src/resources/system/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/system/router.spec.ts new file mode 100644 index 0000000000..62ad30d7f8 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/system/router.spec.ts @@ -0,0 +1,25 @@ +import { systemContract } from '@cpn-console/shared' +import { describe, expect, it, vi } from 'vitest' +import app from '../../app.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) + +describe('system - router', () => { + it('should send application version', async () => { + const response = await app.inject() + .get(systemContract.getVersion.path) + .end() + + expect(response.statusCode).toBe(200) + expect(response.json()).toStrictEqual({ version: process.env.APP_VERSION || 'dev' }) + }) + + it('should send application health with status OK', async () => { + const response = await app.inject() + .get(systemContract.getHealth.path) + .end() + + expect(response.statusCode).toBe(200) + expect(response.json()).toStrictEqual({ status: 'OK' }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/system/router.ts b/.jjconflict-base-0/apps/server/src/resources/system/router.ts new file mode 100644 index 0000000000..9976884963 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/system/router.ts @@ -0,0 +1,21 @@ +import { systemContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { appVersion } from '@/utils/env.js' + +export function systemRouter() { + return serverInstance.router(systemContract, { + getVersion: async () => ({ + status: 200, + body: { + version: appVersion, + }, + }), + + getHealth: async () => ({ + status: 200, + body: { + status: 'OK', + }, + }), + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/system/settings/business.ts b/.jjconflict-base-0/apps/server/src/resources/system/settings/business.ts new file mode 100644 index 0000000000..5e562353b0 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/system/settings/business.ts @@ -0,0 +1,9 @@ +import type { UpsertSystemSettingBody } from '@cpn-console/shared' +import { + getSystemSettings as getSystemSettingsQuery, + upsertSystemSetting as upsertSystemSettingQuery, +} from './queries.js' + +export const getSystemSettings = (key?: string) => getSystemSettingsQuery({ key }) + +export const upsertSystemSetting = (newSystemSetting: UpsertSystemSettingBody) => upsertSystemSettingQuery(newSystemSetting) diff --git a/.jjconflict-base-0/apps/server/src/resources/system/settings/queries.ts b/.jjconflict-base-0/apps/server/src/resources/system/settings/queries.ts new file mode 100644 index 0000000000..c64cb3b74c --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/system/settings/queries.ts @@ -0,0 +1,18 @@ +import type { Prisma, SystemSetting } from '@prisma/client' +import prisma from '@/prisma.js' + +export function upsertSystemSetting(newSystemSetting: SystemSetting) { + return prisma.systemSetting.upsert({ + create: { + ...newSystemSetting, + }, + update: { + value: newSystemSetting.value, + }, + where: { + key: newSystemSetting.key, + }, + }) +} + +export const getSystemSettings = (where?: Prisma.SystemSettingWhereInput) => prisma.systemSetting.findMany({ where }) diff --git a/.jjconflict-base-0/apps/server/src/resources/system/settings/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/system/settings/router.spec.ts new file mode 100644 index 0000000000..469ee45538 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/system/settings/router.spec.ts @@ -0,0 +1,82 @@ +import { ADMIN_PERMS, systemSettingsContract } from '@cpn-console/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../../app.js' +import * as utilsController from '../../../utils/controller.js' +import { getUserMockInfos } from '../../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetSystemSettingsMock = vi.spyOn(business, 'getSystemSettings') +const businessUpsertSystemSettingMock = vi.spyOn(business, 'upsertSystemSetting') + +describe('test systemSettingsContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listSystemSettings', () => { + it('should return plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const systemSettings: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetSystemSettingsMock.mockResolvedValueOnce(systemSettings) + + const response = await app.inject() + .get(systemSettingsContract.listSystemSettings.path) + .end() + + expect(businessGetSystemSettingsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(systemSettings) + expect(response.statusCode).toEqual(200) + }) + + it('should return 200 for anybody', async () => { + const user = getUserMockInfos(0n) + const systemSettings: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetSystemSettingsMock.mockResolvedValueOnce(systemSettings) + + const response = await app.inject() + .get(systemSettingsContract.listSystemSettings.path) + .end() + + expect(businessGetSystemSettingsMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('upsertSystemSetting', () => { + const newConfig = { key: 'key1', value: 'value1' } + it('should update system setting, authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpsertSystemSettingMock.mockResolvedValueOnce(newConfig) + + const response = await app.inject() + .post(systemSettingsContract.upsertSystemSetting.path) + .body(newConfig) + .end() + + expect(businessUpsertSystemSettingMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(systemSettingsContract.upsertSystemSetting.path) + .body(newConfig) + .end() + + expect(businessUpsertSystemSettingMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/system/settings/router.ts b/.jjconflict-base-0/apps/server/src/resources/system/settings/router.ts new file mode 100644 index 0000000000..98bce89e2d --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/system/settings/router.ts @@ -0,0 +1,31 @@ +import { AdminAuthorized, systemSettingsContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getSystemSettings, upsertSystemSetting } from './business.js' + +export function systemSettingsRouter() { + return serverInstance.router(systemSettingsContract, { + listSystemSettings: async ({ query }) => { + const systemSettings = await getSystemSettings(query.key) + + return { + status: 200, + body: systemSettings, + } + }, + + upsertSystemSetting: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) return new Forbidden403() + + const systemSetting = await upsertSystemSetting(data) + + return { + status: 201, + body: systemSetting, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/user/business.spec.ts b/.jjconflict-base-0/apps/server/src/resources/user/business.spec.ts new file mode 100644 index 0000000000..a86c0535ae --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/user/business.spec.ts @@ -0,0 +1,227 @@ +import type { UserDetails } from '../../types/index.ts' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { getMatchingUsers, getUsers, logViaSession, logViaToken, patchUsers, TokenInvalidReason } from './business.ts' +import * as queries from './queries.js' + +const getUsersQueryMock = vi.spyOn(queries, 'getUsers') +const getMatchingUsersQueryMock = vi.spyOn(queries, 'getMatchingUsers') + +describe('test users business', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const user = { + adminRoleIds: [], + createdAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + id: faker.string.uuid(), + lastName: faker.person.lastName(), + updatedAt: new Date(), + } + const projectId = faker.string.uuid() + const adminRoleId = faker.string.uuid() + describe('patchUsers', () => { + it('should do nothing', async () => { + prisma.user.update.mockResolvedValue(null) + + await patchUsers([]) + + expect(prisma.user.update).toHaveBeenCalledTimes(0) + }) + + it('should update a user adminRoleIds', async () => { + const userUpdated = { id: user.id, adminRoleIds: user.adminRoleIds } + + prisma.user.update.mockResolvedValue(user) + + prisma.user.findMany.mockResolvedValue([]) + + await patchUsers([userUpdated]) + expect(prisma.user.update).toHaveBeenCalledTimes(1) + expect(prisma.user.findMany).toHaveBeenCalledTimes(2) + + await patchUsers([userUpdated, userUpdated]) + expect(prisma.user.update).toHaveBeenCalledTimes(3) + }) + }) + describe('getUsers', () => { + it('should query without where', async () => { + prisma.user.update.mockResolvedValue(null) + + await getUsers({}) + + expect(getUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getUsersQueryMock).toHaveBeenCalledWith({ AND: [] }) + }) + it('should query with filter adminRoleIds', async () => { + prisma.user.update.mockResolvedValue(null) + + await getUsers({ adminRoleIds: [adminRoleId] }) + + expect(getUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getUsersQueryMock).toHaveBeenCalledWith({ AND: [{ adminRoleIds: { hasEvery: [adminRoleId] } }] }) + }) + }) + + describe('getMatchingUsers', () => { + const AND = [ + { + OR: [ + { + email: { + contains: 'abc', + mode: 'insensitive', + }, + }, + { + firstName: { + contains: 'abc', + mode: 'insensitive', + }, + }, + { + lastName: { + contains: 'abc', + mode: 'insensitive', + }, + }, + ], + }, + { + type: 'human', + }, + ] + it('should query only with letters ', async () => { + prisma.user.update.mockResolvedValue(null) + + await getMatchingUsers({ letters: 'abc' }) + + expect(getMatchingUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getMatchingUsersQueryMock).toHaveBeenCalledWith({ AND }) + }) + it('should query with letters and projectId', async () => { + prisma.user.update.mockResolvedValue(null) + + await getMatchingUsers({ letters: 'abc', notInProjectId: projectId }) + + expect(getMatchingUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getMatchingUsersQueryMock).toHaveBeenCalledWith({ AND: [...[{ + projectMembers: { + none: { + projectId, + }, + }, + }, { + projectsOwned: { + none: { + id: projectId, + }, + }, + }], ...AND] }) + }) + }) + describe('logViaSession', () => { + // ça ne teste pas tout mais c'est déjà bien hein + const adminRoles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: 0n, + position: 0, + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '/admin', + permissions: 0n, + position: 0, + }] + const userToLog: UserDetails = { + id: faker.string.uuid(), + email: user.email, + firstName: user.firstName, + groups: [], + lastName: user.lastName, + } + it('should create user and return adminPerms', async () => { + prisma.adminRole.findMany.mockResolvedValue(adminRoles) + prisma.user.findUnique.mockResolvedValue(undefined) + prisma.user.create.mockResolvedValue(user) + prisma.user.update.mockResolvedValue(user) + const response = await logViaSession(userToLog) + expect(response.adminPerms).toBe(0n) + expect(prisma.user.create).toHaveBeenCalledTimes(1) + }) + it('should update user and return adminPerms', async () => { + prisma.adminRole.findMany.mockResolvedValue(adminRoles) + prisma.user.findUnique.mockResolvedValue(user) + prisma.user.update.mockResolvedValue(user) + const response = await logViaSession(userToLog) + expect(response.adminPerms).toEqual(0n) + expect(prisma.user.create).toHaveBeenCalledTimes(0) + }) + }) +}) + +describe('logViaToken', () => { + const nextYear = new Date() + const lastYear = new Date() + nextYear.setFullYear((new Date()).getFullYear() + 1) + lastYear.setFullYear((new Date()).getFullYear() - 1) + const baseToken = { + createdAt: new Date(), + hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + id: faker.string.uuid(), + lastUse: null, + permissions: 2n, + userId: null, + status: 'active', + } as const + + beforeEach(() => { + // Mock the global roles lookup + prisma.adminRole.findMany.mockResolvedValue([]) + }) + + it('should return identity', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken }) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(2n) + }) + + it('should return identity based on pat', async () => { + const pat = structuredClone(baseToken) + delete pat.permissions + pat.owner = { adminRoleIds: null } + prisma.personalAccessToken.findFirst.mockResolvedValueOnce(pat) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(0n) + }) + + it('should return identity, with expirationDate', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, expirationDate: nextYear }) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(2n) + }) + + it('should return cause revoked', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, status: 'revoked' }) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.INACTIVE) + }) + + it('should return cause expired', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, expirationDate: lastYear }) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.EXPIRED) + }) + + it('should return cause not found', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce(undefined) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.NOT_FOUND) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/user/business.ts b/.jjconflict-base-0/apps/server/src/resources/user/business.ts new file mode 100644 index 0000000000..acbf67bb8e --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/user/business.ts @@ -0,0 +1,221 @@ +import type { userContract, XOR } from '@cpn-console/shared' +import type { AdminRole, AdminToken, PersonalAccessToken, Prisma, User } from '@prisma/client' +import type { UserDetails } from '@/types/index.js' +import { createHash } from 'node:crypto' +import prisma from '@/prisma.js' +import { getMatchingUsers as getMatchingUsersQuery, getUsers as getUsersQuery } from '@/resources/queries-index.js' +import { BadRequest400 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function getUsers(query: typeof userContract.getAllUsers.query._type, relationType: 'OR' | 'AND' = 'AND') { + const whereInputs: Prisma.UserWhereInput[] = [] + if (query.adminRoleIds?.length) { + whereInputs.push({ adminRoleIds: { hasEvery: query.adminRoleIds } }) + } + if (query.adminRoles?.length) { + const roles = query.adminRoles + ? await prisma.adminRole.findMany({ where: { name: { in: query.adminRoles } } }) + : [] + + const adminRoleNameNotFound = query.adminRoles?.find(nameQueried => !roles.some(({ name }) => name === nameQueried)) + if (adminRoleNameNotFound) { + return new BadRequest400(`Unable to find adminRole ${adminRoleNameNotFound}`) + } + whereInputs.push({ adminRoleIds: { hasEvery: roles.map(({ id }) => id) } }) + } + if (query.memberOfIds) { + whereInputs.push({ + AND: query.memberOfIds.map(id => ({ + OR: [ + { projectsOwned: { some: { id } } }, + { ProjectMembers: { some: { project: { id } } } }, + ], + })), + }) + } + + return getUsersQuery({ [relationType]: whereInputs }) +} + +export async function getMatchingUsers(query: typeof userContract.getMatchingUsers.query._type) { + const AND: Prisma.UserWhereInput[] = [] + if (query.notInProjectId) { + AND.push({ projectMembers: { none: { projectId: query.notInProjectId } } }) + AND.push({ projectsOwned: { none: { id: query.notInProjectId } } }) + } + const filter = { contains: query.letters, mode: 'insensitive' } as const // Default value: default + if (query.letters) { + AND.push({ + OR: [{ + email: filter, + }, { + firstName: filter, + }, { + lastName: filter, + }], + }) + AND.push({ type: 'human' }) + } + + return getMatchingUsersQuery({ + AND, + }) +} + +export async function patchUsers(users: typeof userContract.patchUsers.body._type) { + const userIds = users.map(u => u.id) + const usersBefore = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, adminRoleIds: true }, + }) + + for (const user of users) { + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + adminRoleIds: user.adminRoleIds, + }, + }) + } + + const impactedRoleIds = new Set() + for (const user of users) { + const before = usersBefore.find(u => u.id === user.id) + if (before) { + before.adminRoleIds.forEach(id => impactedRoleIds.add(id)) + } + user.adminRoleIds?.forEach(id => impactedRoleIds.add(id)) + } + + for (const roleId of impactedRoleIds) { + await hook.adminRole.upsert(roleId) + } + + return prisma.user.findMany({ + where: { + id: { in: users.map(({ id }) => id) }, + }, + }) +} + +export enum TokenInvalidReason { + INACTIVE = 'Not active', + EXPIRED = 'Expired', + NOT_FOUND = 'Not authenticated', +} + +export type UserTrial = Omit +export async function logViaSession({ id, email, groups, ...user }: UserTrial): Promise<{ user: User, adminPerms: bigint }> { + let userDb = await prisma.user.findUnique({ + where: { id }, + }) + + if (!userDb) { + userDb = await prisma.user.create({ data: { email, id, ...user, adminRoleIds: [], type: 'human' } }) + } + + const matchingAdminRoles = await prisma.adminRole.findMany({ + where: { OR: [{ oidcGroup: { in: groups } }, { id: { in: userDb.adminRoleIds } }, { type: 'global' }] }, + }) + + const oidcRoleIds = matchingAdminRoles + .filter(({ oidcGroup }) => oidcGroup && groups.includes(oidcGroup)) + .map(({ id }) => id) + + // On enregistre en bdd uniquement les roles de l'utilisateur + // qui ne viennent pas de keycloak + const updatedUser = await prisma.user.update({ where: { id }, data: { ...user, lastLogin: (new Date()).toISOString() } }) + .then(user => ({ ...user, adminRoleIds: [...new Set([...user.adminRoleIds, ...oidcRoleIds])] })) + return { + user: updatedUser, + adminPerms: sumAdminPerms(matchingAdminRoles), + } +} + +type UserWithTokenId = Omit & { tokenId: string } +export async function logViaToken(pass: string): Promise<({ user: UserWithTokenId, adminPerms: bigint }) | TokenInvalidReason> { + const passHash = createHash('sha256').update(pass).digest('hex') + + let token: (XOR & { owner: User }) | TokenInvalidReason | undefined + const tokenLoginMethods = [findPersonalAccessToken, findAdminToken] + for (const tokenLoginMethod of tokenLoginMethods) { + token = await tokenLoginMethod(passHash) + if (token) { + break + } + } + + if (typeof token === 'string') { + return token + } + if (!token) { + return TokenInvalidReason.NOT_FOUND + } + + const globalRoles = await prisma.adminRole.findMany({ where: { type: 'global' }, select: { permissions: true } }) + const globalPerms = globalRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + const adminPerms = token?.permissions ?? await getAdminRolesAndSum(token.owner.adminRoleIds) + + return { + user: { + ...token.owner, + tokenId: token.id, + }, + adminPerms: globalPerms | adminPerms, + } +} + +function isTokenInvalid(token: AdminToken | PersonalAccessToken): TokenInvalidReason | undefined { + if (token.status !== 'active') { + return TokenInvalidReason.INACTIVE + } + const currentDate = new Date() + if (token.expirationDate && currentDate.getTime() > token.expirationDate?.getTime()) { + return TokenInvalidReason.EXPIRED + } +} + +function sumAdminPerms(roles: AdminRole[]): bigint { + if (!roles.length) { + return 0n + } + return roles.reduce((acc, curr) => acc | curr.permissions, 0n) +} + +async function getAdminRolesAndSum(roles: AdminRole['id'][] | null): Promise { + if (!roles?.length) { + return 0n + } + return sumAdminPerms(await prisma.adminRole.findMany({ + where: { id: { in: roles } }, + })) +} + +// List all token tpe authentication +async function findPersonalAccessToken(digest: string): Promise<(PersonalAccessToken & { owner: User }) | undefined | TokenInvalidReason> { + const token = await prisma.personalAccessToken.findFirst({ where: { hash: digest }, include: { owner: true } }) + if (!token) + return undefined + const invalidReason = isTokenInvalid(token) + if (invalidReason) { + return invalidReason + } + await prisma.personalAccessToken.update({ where: { id: token.id }, data: { lastUse: (new Date()).toISOString() } }) + await prisma.user.update({ where: { id: token.owner.id }, data: { lastLogin: (new Date()).toISOString() } }) + return token +} + +async function findAdminToken(digest: string): Promise<(AdminToken & { owner: User }) | undefined | TokenInvalidReason> { + const token = await prisma.adminToken.findFirst({ where: { hash: digest }, include: { owner: true } }) + if (!token) + return undefined + const invalidReason = isTokenInvalid(token) + if (invalidReason) { + return invalidReason + } + await prisma.adminToken.update({ where: { id: token.id }, data: { lastUse: (new Date()).toISOString() } }) + await prisma.user.update({ where: { id: token.userId }, data: { lastLogin: (new Date()).toISOString() } }) + return token +} diff --git a/.jjconflict-base-0/apps/server/src/resources/user/queries.ts b/.jjconflict-base-0/apps/server/src/resources/user/queries.ts new file mode 100644 index 0000000000..1e33a8128b --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/user/queries.ts @@ -0,0 +1,60 @@ +import type { Prisma, User } from '@prisma/client' +import prisma from '@/prisma.js' + +type UserCreate = Omit + +// SELECT +export const getUsers = (where?: Prisma.UserWhereInput) => prisma.user.findMany({ where }) + +export async function getUserInfos(id: User['id']) { + return prisma.user.findMany({ + where: { id }, + include: { + logs: true, + }, + }) +} + +export function getMatchingUsers(where: Prisma.UserWhereInput) { + return prisma.user.findMany({ + where, + take: 5, + }) +} + +export function getUserById(id: User['id']) { + return prisma.user.findUnique({ where: { id } }) +} + +export function getUserOrThrow(id: User['id']) { + return prisma.user.findUniqueOrThrow({ + where: { id }, + }) +} + +export function getUserByEmail(email: User['email']) { + return prisma.user.findUnique({ where: { email } }) +} + +// CREATE +export async function createUser({ id, email, firstName, lastName, type }: UserCreate) { + const user = await getUserByEmail(email) + if (user) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') + return prisma.user.create({ data: { id, email, firstName, lastName, type } }) +} + +// UPDATE +export async function updateUserById({ id, email, firstName, lastName }: UserCreate) { + const user = await getUserById(id) + const isEmailAlreadyTaken = await getUserByEmail(email) + if (!user) throw new Error('L\'utilisateur demandé n\'existe pas') + if (isEmailAlreadyTaken) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') + if (user && !isEmailAlreadyTaken) { + return prisma.user.update({ where: { id }, data: { email, firstName, lastName } }) + } +} + +// TECH +export function _createUser(data: Prisma.UserCreateInput) { + return prisma.user.upsert({ where: { id: data.id }, create: data, update: data }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/user/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/user/router.spec.ts new file mode 100644 index 0000000000..f47c9aa311 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/user/router.spec.ts @@ -0,0 +1,142 @@ +import { ADMIN_PERMS, userContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos, setRequestor } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetMatchingMock = vi.spyOn(business, 'getMatchingUsers') +const businessLogViaSessionMock = vi.spyOn(business, 'logViaSession') +const businessGetUsersMock = vi.spyOn(business, 'getUsers') +const businessPatchMock = vi.spyOn(business, 'patchUsers') + +describe('test userContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getMatchingUsers', () => { + it('should return matching users', async () => { + const usersMatching: any[] = [] + businessGetMatchingMock.mockResolvedValueOnce(usersMatching) + + const response = await app.inject() + .get(userContract.getMatchingUsers.path) + .query({ letters: faker.person.fullName() }) + .end() + + expect(businessGetMatchingMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(usersMatching) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('auth', () => { + it('should return logged user', async () => { + const user = { + id: faker.string.uuid(), + adminRoleIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + type: 'human' as const, + lastName: faker.person.lastName(), + lastLogin: (new Date()).toISOString(), + } + // @ts-ignore + setRequestor(user) + // @ts-ignore + businessLogViaSessionMock.mockResolvedValueOnce({ user, adminPerms: 0n }) + + const response = await app.inject() + .get(userContract.auth.path) + .end() + + expect(businessLogViaSessionMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(user) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getAllUsers', () => { + it('should return all users for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + const users: any[] = [] + authUserMock.mockResolvedValueOnce(user) + businessGetUsersMock.mockResolvedValueOnce(users) + + const response = await app.inject() + .get(userContract.getAllUsers.path) + .query({ role: 'admin' }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetUsersMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(users) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(userContract.getAllUsers.path) + .query({ role: 'admin' }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetUsersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('patchUsers', () => { + const usersPatchData = [{ + id: faker.string.uuid(), + adminRoleIds: [], + }] + const usersReturn = [{ + id: faker.string.uuid(), + adminRoleIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + type: 'human', + }] + + it('should patch and return users for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + authUserMock.mockResolvedValueOnce(user) + + businessPatchMock.mockResolvedValueOnce(usersReturn) + const response = await app.inject() + .patch(userContract.patchUsers.path) + .body(usersPatchData) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessPatchMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(usersReturn) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(userContract.patchUsers.path) + .body(usersPatchData) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessPatchMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/user/router.ts b/.jjconflict-base-0/apps/server/src/resources/user/router.ts new file mode 100644 index 0000000000..e745dbec43 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/user/router.ts @@ -0,0 +1,72 @@ +import type { + UserTrial, +} from './business.js' +import { AdminAuthorized, userContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + getMatchingUsers, + getUsers, + logViaSession, + patchUsers, +} from './business.js' +import '@/types/index.js' + +export function userRouter() { + return serverInstance.router(userContract, { + getMatchingUsers: async ({ query }) => { + const usersMatching = await getMatchingUsers(query) + + return { + status: 200, + body: usersMatching, + } + }, + + auth: async ({ request: req }) => { + const user = req.session.user + + if (!user) return new Unauthorized401() + + const { user: body } = await logViaSession(user as unknown as UserTrial) + + return { + status: 200, + body, + } + }, + + getAllUsers: async ({ + request: req, + query: { relationType, ...query }, + }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageUsers(perms.adminPermissions)) + return new Forbidden403() + + const body = await getUsers(query, relationType) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + patchUsers: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageUsers(perms.adminPermissions)) + return new Forbidden403() + + const users = await patchUsers(body) + + return { + status: 200, + body: users, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/user/tokens/business.ts b/.jjconflict-base-0/apps/server/src/resources/user/tokens/business.ts new file mode 100644 index 0000000000..e378d3b79b --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/user/tokens/business.ts @@ -0,0 +1,51 @@ +import type { personalAccessTokenContract } from '@cpn-console/shared' +import type { AdminToken, User } from '@prisma/client' +import { createHash } from 'node:crypto' +import { generateRandomPassword, isAtLeastTomorrow } from '@cpn-console/shared' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../../prisma.js' + +export async function listTokens(userId: User['id']) { + return prisma.personalAccessToken.findMany({ + omit: { hash: true }, + include: { owner: true }, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + where: { userId }, + }) +} + +export async function createToken(data: typeof personalAccessTokenContract.createPersonalAccessToken.body._type, userId: User['id']) { + if (data.expirationDate && !isAtLeastTomorrow(new Date(data.expirationDate))) { + return new BadRequest400('Date d\'expiration trop courte') + } + const password = generateRandomPassword(48, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-') + const hash = createHash('sha256').update(password).digest('hex') + const token = await prisma.personalAccessToken.create({ + data: { + ...data, + hash, + expirationDate: new Date(data.expirationDate), + userId, + }, + omit: { hash: true }, + include: { owner: true }, + }) + return { + ...token, + password, + } +} + +export async function deleteToken(id: AdminToken['id'], userId: User['id']) { + const token = await prisma.personalAccessToken.findUnique({ + where: { + id, + userId, + }, + }) + if (token) { + return prisma.personalAccessToken.delete({ + where: { id }, + }) + } +} diff --git a/.jjconflict-base-0/apps/server/src/resources/user/tokens/router.ts b/.jjconflict-base-0/apps/server/src/resources/user/tokens/router.ts new file mode 100644 index 0000000000..369ded2b1a --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/user/tokens/router.ts @@ -0,0 +1,48 @@ +import { personalAccessTokenContract } from '@cpn-console/shared' + +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { createToken, deleteToken, listTokens } from './business.js' +import '@/types/index.js' + +export function personalAccessTokenRouter() { + return serverInstance.router(personalAccessTokenContract, { + listPersonalAccessTokens: async ({ request: req }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + const body = await listTokens(perms.user.id) + + return { + status: 200, + body, + } + }, + + createPersonalAccessToken: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + const body = await createToken(data, perms.user.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + deletePersonalAccessToken: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + await deleteToken(params.tokenId, perms.user.id) + + return { + status: 204, + body: null, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/zone/business.spec.ts b/.jjconflict-base-0/apps/server/src/resources/zone/business.spec.ts new file mode 100644 index 0000000000..2b42464e26 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/zone/business.spec.ts @@ -0,0 +1,133 @@ +import type { Cluster, Zone } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400 } from '../../utils/errors.ts' +import { createZone, deleteZone, listZones, updateZone } from './business.ts' +import * as queries from './queries.js' + +const userId = faker.string.uuid() +const reqId = faker.string.uuid() +const linkZoneToClustersMock = vi.spyOn(queries, 'linkZoneToClusters') +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +describe('test zone business', () => { + const zones: Zone[] = [{ + id: faker.string.uuid(), + label: faker.string.alphanumeric(), + argocdUrl: faker.internet.url(), + createdAt: new Date(), + updatedAt: new Date(), + description: faker.lorem.lines(1), + slug: faker.string.alphanumeric(5), + }, { + id: faker.string.uuid(), + label: faker.string.alphanumeric(), + argocdUrl: faker.internet.url(), + createdAt: new Date(), + updatedAt: new Date(), + description: faker.lorem.lines(1), + slug: faker.string.alphanumeric(6), + }] + + const clusters: Pick[] = [ + { id: faker.string.uuid() }, + { id: faker.string.uuid() }, + ] + + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listZones', () => { + it('should return zones', async () => { + prisma.zone.findMany.mockResolvedValueOnce(zones) + + const response = await listZones() + expect(response).toEqual(zones) + }) + }) + describe('createZone', () => { + it('should create zone without description and clusterIds', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl } + + hook.zone.upsert.mockResolvedValue({}) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).toEqual(zones[0]) + expect(prisma.zone.create).toHaveBeenCalledWith({ + data: { + slug: newZone.slug, + label: newZone.label, + argocdUrl: newZone.argocdUrl, + description: undefined, + }, + }) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(0) + }) + it('should create zone with description and clusterIds', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl, clusterIds: clusters.map(({ id }) => id), description: faker.lorem.lines(2) } + + hook.zone.upsert.mockResolvedValue({}) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).toEqual(zones[0]) + expect(prisma.zone.create).toHaveBeenCalledWith({ + data: { + description: newZone.description, + label: newZone.label, + argocdUrl: newZone.argocdUrl, + slug: newZone.slug, + }, + }) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(1) + }) + it('should not create zone, conflict label', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl } + + prisma.zone.findUnique.mockResolvedValueOnce(zones[0]) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).instanceOf(BadRequest400) + expect(prisma.zone.create).toHaveBeenCalledTimes(0) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(0) + }) + }) + describe('updateZone', () => { + it('should filter keys and update zone', async () => { + prisma.zone.update.mockResolvedValueOnce(zones[0]) + hook.zone.upsert.mockResolvedValue({}) + await updateZone(zones[0].id, { + description: '', + label: zones[0].label, + argocdUrl: zones[0].argocdUrl, + extraKey: 1, + }, userId, reqId) + expect(prisma.zone.update).toHaveBeenCalledWith({ where: { id: zones[0].id }, data: { + description: '', + label: zones[0].label, + argocdUrl: zones[0].argocdUrl, + } }) + }) + }) + describe('deleteZone', () => { + it('should not delete zone, cluster attached', async () => { + prisma.cluster.findFirst.mockResolvedValueOnce(clusters[0]) + const response = await deleteZone(zones[0].id, userId, reqId) + expect(response).instanceOf(BadRequest400) + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + }) + it('should delete zone', async () => { + prisma.cluster.findFirst.mockResolvedValueOnce(undefined) + hook.zone.delete.mockResolvedValue({}) + const response = await deleteZone(zones[0].id, userId, reqId) + expect(response).toEqual(null) + expect(prisma.zone.delete).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/zone/business.ts b/.jjconflict-base-0/apps/server/src/resources/zone/business.ts new file mode 100644 index 0000000000..eb3155ce60 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/zone/business.ts @@ -0,0 +1,78 @@ +import type { User, Zone } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { BadRequest400, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' +import { addLogs } from '../queries-index.js' +import { linkZoneToClusters } from './queries.js' + +export const listZones = prisma.zone.findMany + +export async function createZone( + data: { slug: string, label: string, argocdUrl: string, description?: string | null, clusterIds?: string[] }, + userId: User['id'], + requestId: string, +) { + const { slug, label, argocdUrl, description, clusterIds } = data + + const existingZone = await prisma.zone.findUnique({ + where: { slug }, + }) + + if (existingZone) return new BadRequest400(`Une zone portant le nom ${slug} existe déjà.`) + const zone = await prisma.zone.create({ + data: { + slug, + label, + argocdUrl, + description, + }, + }) + if (clusterIds) { + await linkZoneToClusters(zone.id, clusterIds) + } + const hookReply = await hook.zone.upsert(zone.id) + await addLogs({ action: 'Create zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la création de la zone') + } + return zone +} + +export async function updateZone( + zoneId: Zone['id'], + data: Pick, + userId: User['id'], + requestId: string, +) { + const { label, argocdUrl, description } = data + + const updatedZone = await prisma.zone.update({ + where: { + id: zoneId, + }, + data: { + label, + argocdUrl, + description, + }, + }) + const hookReply = await hook.zone.upsert(updatedZone.id) + await addLogs({ action: 'Update zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la mise à jour de la zone') + } + return updatedZone +} + +export async function deleteZone(zoneId: Zone['id'], userId: User['id'], requestId: string) { + const attachedCluster = await prisma.cluster.findFirst({ where: { zoneId }, select: { id: true } }) + if (attachedCluster) return new BadRequest400('Vous ne pouvez supprimer cette zone, car des clusters y sont associés.') + + const hookReply = await hook.zone.delete(zoneId) + await addLogs({ action: 'Delete zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la suppression de la zone') + } + await prisma.zone.delete({ where: { id: zoneId } }) + return null +} diff --git a/.jjconflict-base-0/apps/server/src/resources/zone/queries.ts b/.jjconflict-base-0/apps/server/src/resources/zone/queries.ts new file mode 100644 index 0000000000..1390bb153a --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/zone/queries.ts @@ -0,0 +1,21 @@ +import type { Cluster, Zone } from '@prisma/client' +import prisma from '@/prisma.js' + +export function getZoneByIdOrThrow(id: Zone['id']) { + return prisma.zone.findUniqueOrThrow({ + where: { id }, + }) +} + +export function linkZoneToClusters(zoneId: Zone['id'], clusterIds: Cluster['id'][]) { + return prisma.zone.update({ + where: { + id: zoneId, + }, + data: { + clusters: { + connect: clusterIds.map(clusterId => ({ id: clusterId })), + }, + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/resources/zone/router.spec.ts b/.jjconflict-base-0/apps/server/src/resources/zone/router.spec.ts new file mode 100644 index 0000000000..0ee9c7f394 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/zone/router.spec.ts @@ -0,0 +1,164 @@ +import type { Zone } from '@cpn-console/shared' +import { ADMIN_PERMS, zoneContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listZones') +const businessCreateMock = vi.spyOn(business, 'createZone') +const businessUpdateMock = vi.spyOn(business, 'updateZone') +const businessDeleteMock = vi.spyOn(business, 'deleteZone') + +describe('test zoneContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listZones', () => { + it('should return list of zones', async () => { + const zones: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_ZONES) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(zones) + + const response = await app.inject() + .get(zoneContract.listZones.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(zones) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('createZone', () => { + const zone = { id: faker.string.uuid(), label: faker.string.alpha({ length: 5 }), argocdUrl: faker.internet.url(), slug: faker.string.alpha({ length: 5, casing: 'lower' }), description: '' } + + it('should create and return zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(zone) + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(zone) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateZone', () => { + const zoneId = faker.string.uuid() + const zone: Omit = { label: faker.string.alpha({ length: 5 }), slug: faker.string.alpha({ length: 5, casing: 'lower' }), argocdUrl: faker.internet.url(), description: '' } + + it('should update and return zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: zoneId, ...zone }) + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: zoneId, ...zone }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteZone', () => { + it('should delete zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/resources/zone/router.ts b/.jjconflict-base-0/apps/server/src/resources/zone/router.ts new file mode 100644 index 0000000000..c682826c3e --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/resources/zone/router.ts @@ -0,0 +1,67 @@ +import { AdminAuthorized, zoneContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { createZone, deleteZone, listZones, updateZone } from './business.js' + +export function zoneRouter() { + return serverInstance.router(zoneContract, { + listZones: async () => { + const zones = await listZones() + + return { + status: 200, + body: zones, + } + }, + + createZone: async ({ request: req, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const body = await createZone(data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + updateZone: async ({ request: req, params, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const zoneId = params.zoneId + + const body = await updateZone(zoneId, data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + deleteZone: async ({ request: req, params }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + const zoneId = params.zoneId + + const body = await deleteZone(zoneId, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-base-0/apps/server/src/runtime.ts b/.jjconflict-base-0/apps/server/src/runtime.ts new file mode 100644 index 0000000000..d5b8822533 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/runtime.ts @@ -0,0 +1,22 @@ +import { Agent, cacheStores, interceptors, ProxyAgent, setGlobalDispatcher } from 'undici' + +const base = process.env.HTTP_PROXY ? new ProxyAgent(process.env.HTTP_PROXY) : new Agent() + +// Undici’s cache interceptor follows RFC 7234: +// 1. Only GET/HEAD are cached (configurable via `methods`). +// 2. Cache-Control directives are obeyed: +// - `no-store` → never cache +// - `max-age`, `s-maxage`, `expires` → freshness lifetime +// - `private` → skip if shared store +// - `no-cache` → revalidate with ETag/Last-Modified +// 3. Heuristic caching (status 200 without explicit freshness) +// uses 10 % of time since Last-Modified if present. +// 4. Stale responses are served while revalidating in background +// when `stale-while-revalidate` is present. +const client = base.compose( + interceptors.cache({ + store: new cacheStores.SqliteCacheStore(), + methods: ['GET', 'HEAD'], + }), +) +setGlobalDispatcher(client) diff --git a/.jjconflict-base-0/apps/server/src/server.spec.ts b/.jjconflict-base-0/apps/server/src/server.spec.ts new file mode 100644 index 0000000000..5dc093888a --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/server.spec.ts @@ -0,0 +1,57 @@ +import { logger } from '@cpn-console/logger' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { closeConnections } from './connect.js' +import { exitGracefully, handleExit } from './server.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) +vi.mock('./init/db/index.js', () => ({ initDb: vi.fn() })) +vi.mock('./connect.js') + +vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + +vi.mock('./prepare-app.js', () => { + const app = { + listen: vi.fn(), + close: vi.fn(async () => {}), + } + return { + getPreparedApp: () => Promise.resolve(app), + } +}) +vi.spyOn(logger, 'warn') +vi.spyOn(logger, 'debug') +const infoSpy = vi.spyOn(logger, 'info') +const errorSpy = vi.spyOn(logger, 'error') +const fatalSpy = vi.spyOn(logger, 'fatal') + +describe('server', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should call closeConnections without parameter', async () => { + await exitGracefully() + + expect(closeConnections).toHaveBeenCalledTimes(1) + expect(vi.mocked(closeConnections).mock.calls[0]).toHaveLength(0) + expect(errorSpy).toHaveBeenCalledTimes(0) + }) + + it('should log an error', async () => { + await exitGracefully(new Error('error')) + + expect(closeConnections).toHaveBeenCalledTimes(1) + expect(vi.mocked(closeConnections).mock.calls[0]).toHaveLength(0) + expect(fatalSpy).toHaveBeenCalledTimes(1) + expect(fatalSpy.mock.calls[0]?.[0]).toBeInstanceOf(Error) + expect(infoSpy).toHaveBeenCalledTimes(2) + }) + + it('should call process.on 4 times', () => { + const processOn = vi.spyOn(process, 'on') + + handleExit() + + expect(processOn).toHaveBeenCalledTimes(5) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/server.ts b/.jjconflict-base-0/apps/server/src/server.ts new file mode 100644 index 0000000000..93425dae79 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/server.ts @@ -0,0 +1,44 @@ +import { logger } from '@cpn-console/logger' +import { closeConnections } from './connect.js' +import { getPreparedApp } from './prepare-app.js' +import { isCI, isDev, isDevSetup, isProd, isTest, port } from './utils/env.js' + +const app = await getPreparedApp() + +try { + await app.listen({ host: '0.0.0.0', port: +(port ?? 8080) }) +} catch (error) { + logger.error({ err: error }, 'Failed to start server') + process.exit(1) +} + +logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) + +export async function exitGracefully(error?: Error) { + if (error instanceof Error) { + logger.fatal(error) + } + await app.close() + logger.info('Closing connections...') + await closeConnections() + logger.info('Exiting...') + process.exit(error instanceof Error ? 1 : 0) +} + +function logExitCode(code: number) { + logger.warn(`received signal: ${code}`) +} + +function logUnhandledRejection(reason: unknown, promise: Promise) { + logger.error({ promise, err: reason }, 'Unhandled Rejection') +} + +export function handleExit() { + process.on('exit', logExitCode) + process.on('SIGINT', exitGracefully) + process.on('SIGTERM', exitGracefully) + process.on('uncaughtException', exitGracefully) + process.on('unhandledRejection', logUnhandledRejection) +} + +handleExit() diff --git a/.jjconflict-base-0/apps/server/src/telemetry.ts b/.jjconflict-base-0/apps/server/src/telemetry.ts new file mode 100644 index 0000000000..65b59dddab --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/telemetry.ts @@ -0,0 +1,26 @@ +import { FastifyOtelInstrumentation } from '@fastify/otel' +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' +import { NodeSDK } from '@opentelemetry/sdk-node' + +const sdk = new NodeSDK({ + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({}), + }), + serviceName: 'console-pi-native-console', + traceExporter: new OTLPTraceExporter({}), + instrumentations: [ + getNodeAutoInstrumentations(), + new FastifyOtelInstrumentation({ + registerOnInitialization: true, + }), + ], +}) + +sdk.start() + +process.once('beforeExit', () => { + sdk.shutdown() +}) diff --git a/.jjconflict-base-0/apps/server/src/types/index.ts b/.jjconflict-base-0/apps/server/src/types/index.ts new file mode 100644 index 0000000000..ed39a8f94b --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/types/index.ts @@ -0,0 +1,22 @@ +import { User } from '@cpn-console/shared' + +export type UserDetails = { + id: string + firstName: string + lastName: string + email: string + groups: string[] + type: User['type'], +} + +export interface Resources { + cpu: number + gpu: number + memory: number +} + +declare module 'fastify' { + interface Session { + user?: UserDetails + } +} diff --git a/.jjconflict-base-0/apps/server/src/utils/business.ts b/.jjconflict-base-0/apps/server/src/utils/business.ts new file mode 100644 index 0000000000..10fe3ced2f --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/business.ts @@ -0,0 +1,42 @@ +import type { SharedSafeParseReturnType } from '@cpn-console/shared' +import { parseZodError } from '@cpn-console/shared' +import { BadRequest400 } from './errors.js' + +export type Success = Result +export type Failure = Result +export class Result { + protected constructor( + readonly success: boolean, + readonly value: T | string, + ) {} + + static succeed(value: T): Success { + return new Result(true, value) as Success + } + + static fail(message: string): Failure { + return new Result(false, message) as Failure + } + + get isSuccess(): boolean { + return this.success + } + + get isError(): boolean { + return !this.success + } + + get data(): T { + if (this.success) return this.value as T + throw new Error('Cannot get data from a Failure') + } + + get error(): string { + if (!this.success) return this.value as string + throw new Error('Cannot get error from a Success') + } +} + +export function validateSchema(schemaValidation: SharedSafeParseReturnType) { + if (!schemaValidation.success) return new BadRequest400(parseZodError(schemaValidation.error)) +} diff --git a/.jjconflict-base-0/apps/server/src/utils/controller.ts b/.jjconflict-base-0/apps/server/src/utils/controller.ts new file mode 100644 index 0000000000..57ab7865af --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/controller.ts @@ -0,0 +1,171 @@ +import type { XOR } from '@cpn-console/shared' +import type { Cluster, Prisma, Project, ProjectMembers, ProjectRole } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserTrial } from '@/resources/user/business.js' +import type { UserDetails } from '@/types/index.js' +import { PROJECT_PERMS as PP, PROJECT_PERMS, projectIsLockedInfo, tokenHeaderName } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { logViaSession, logViaToken } from '@/resources/user/business.js' +import { Unauthorized401 } from './errors.js' +import { uuid } from './queries-tools.js' + +export type RequireOnlyOne + = Pick> + & { + [K in Keys]-?: + Required> + & Partial, undefined>> + }[Keys] + +type ErrorMessagePredicate = () => string | undefined +export function getErrorMessage(...fns: ErrorMessagePredicate[]) { + for (const f of fns) { + const error = f() + if (error) { + return error + } + } +} + +/** + * Renvoie une erreur si le projet est verrouillé + */ +export function checkProjectLocked(project: { locked: boolean }): string { + return project.locked + ? projectIsLockedInfo + : '' +} + +export function checkLocked(project: { locked: Project['locked'] }): string { + return checkProjectLocked(project) +} + +export function checkClusterUnavailable(clusterId: Cluster['id'], authorizedClusterIds: Cluster['id'][]): string { + return authorizedClusterIds.includes(clusterId) + ? '' + : 'Ce cluster n\'est pas disponible pour cette combinaison projet et stage' +} + +export const splitStringsFilterArray = >(toMatch: T, inputs: string): T => inputs.split(',').filter(i => toMatch.includes(i)) as unknown as T + +type StringArray = string[] +interface WhereBuilderParams { + enumValues: T + eqValue: T[number] | undefined + inValues: string | undefined + notInValues: string | undefined +} + +export function whereBuilder({ enumValues, eqValue, inValues, notInValues }: WhereBuilderParams) { + if (eqValue) { + return eqValue + } else if (inValues) { + return { in: splitStringsFilterArray(enumValues, inValues) } + } else if (notInValues) { + return { notIn: splitStringsFilterArray(enumValues, notInValues) } + } +} + +type ProjectMinimalPerms = Pick & { roles: ProjectRole[], members: ProjectMembers[] } +export interface UserProfile { user?: UserDetails, adminPermissions: bigint, tokenId?: string } +export interface ProjectPermState { projectPermissions?: bigint, projectId: Project['id'], projectLocked: boolean, projectStatus: Project['status'], projectOwnerId: Project['ownerId'] } +export type UserProjectProfile = UserProfile & ProjectPermState + +type ProjectUniqueFinder = XOR< + { slug: string }, + XOR<{ environmentId: string }, XOR<{ repositoryId: string }, { id: string }>> +> + +const projectPermsSelect = { roles: true, members: true, everyonePerms: true, ownerId: true, id: true, locked: true, status: true } as const satisfies Prisma.ProjectSelect + +export async function authUser(req: FastifyRequest): Promise +export async function authUser(req: FastifyRequest, projectUnique: ProjectUniqueFinder): Promise +export async function authUser(req: FastifyRequest, projectUnique?: ProjectUniqueFinder): Promise { + let adminPermissions: bigint = 0n + let tokenId: string | undefined + const reqUser: UserTrial = req.session.user as unknown as UserTrial + let user: UserDetails | undefined + + if (req.session.user) { + const loginResult = await logViaSession(reqUser) + user = { + ...loginResult.user, + groups: reqUser.groups, + } + adminPermissions = loginResult.adminPerms + } else { + const tokenHeader = req.headers[tokenHeaderName] + if (typeof tokenHeader === 'string') { + const resultToken = await logViaToken(tokenHeader) + if (typeof resultToken === 'string') { + throw new Unauthorized401(resultToken) + } + adminPermissions = resultToken.adminPerms ?? 0n + tokenId = resultToken.user.tokenId + if (!user && resultToken.user) { + user = { ...resultToken.user, groups: [] } + } + } + } + + const baseReturnInfos = { + user, + adminPermissions, + tokenId, + } + if (!projectUnique || !user) { + return baseReturnInfos + } + let project: ProjectMinimalPerms | null | undefined + + if (projectUnique.repositoryId) { + project = (await prisma.repository.findUnique({ + where: { id: projectUnique.repositoryId }, + select: { project: { select: projectPermsSelect } }, + }))?.project + } else if (projectUnique.environmentId) { + project = (await prisma.environment.findUnique({ + where: { id: projectUnique.environmentId }, + select: { project: { select: projectPermsSelect } }, + }))?.project + } else if (projectUnique.id) { + project = uuid.test(projectUnique.id) + ? await prisma.project.findUnique({ + where: { id: projectUnique.id }, + select: projectPermsSelect, + }) + : await prisma.project.findUnique({ + where: { slug: projectUnique.id }, + select: projectPermsSelect, + }) + } else if (projectUnique.slug) { + project = await prisma.project.findFirstOrThrow({ + where: { slug: projectUnique.slug }, + select: projectPermsSelect, + }) + } + if (!project) { + return baseReturnInfos + } + + const projectPermissions = getProjectPermissions(project, user) + + return { + user, + adminPermissions, + projectPermissions, + projectId: project.id, + projectLocked: project.locked, + projectStatus: project.status, + projectOwnerId: project.ownerId, + } +} + +function getProjectPermissions(project: ProjectMinimalPerms, user: UserDetails): bigint | undefined { + if (project.ownerId === user.id) return PP.MANAGE + const member = project.members.find(member => member.userId === user.id) + if (!member) return + + const memberRoles = project.roles.filter(role => member.roleIds.includes(role.id)) + return memberRoles.reduce((acc, curr) => acc | curr.permissions, project.everyonePerms | PROJECT_PERMS.GUEST) +} diff --git a/.jjconflict-base-0/apps/server/src/utils/date.spec.ts b/.jjconflict-base-0/apps/server/src/utils/date.spec.ts new file mode 100644 index 0000000000..7abcaa1aaa --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/date.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { getJSDateFromUtcIso } from './date.js' + +describe('date-util', () => { + it('should return a native Date object', () => { + const date = '2022-10-11' + + const received = getJSDateFromUtcIso(date) + + expect(received.getMonth()).toBe(9) + expect(received.getFullYear()).toBe(2022) + expect(received.getDate()).toBeGreaterThan(10) + expect(received.getDate()).toBeLessThan(12) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/utils/date.ts b/.jjconflict-base-0/apps/server/src/utils/date.ts new file mode 100644 index 0000000000..87473d2625 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/date.ts @@ -0,0 +1,5 @@ +import { parseISO } from 'date-fns' + +export function getJSDateFromUtcIso(dateUtcIso: string) { + return parseISO(dateUtcIso) +} diff --git a/.jjconflict-base-0/apps/server/src/utils/env.ts b/.jjconflict-base-0/apps/server/src/utils/env.ts new file mode 100644 index 0000000000..2ca618fd19 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/env.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +// application mode +export const isDev = process.env.NODE_ENV === 'development' +export const isTest = process.env.NODE_ENV === 'test' +export const isProd = process.env.NODE_ENV === 'production' +export const isInt = process.env.INTEGRATION === 'true' +export const isCI = process.env.CI === 'true' +export const isDevSetup = process.env.DEV_SETUP === 'true' + +// app +export const port = process.env.SERVER_PORT +export const appVersion = isProd + ? (process.env.APP_VERSION ?? 'unknown') + : 'dev' + +// db +export const dbUrl = process.env.DB_URL + +// keycloak +export const sessionSecret = process.env.SESSION_SECRET +export const keycloakProtocol = process.env.KEYCLOAK_PROTOCOL +export const keycloakDomain = process.env.KEYCLOAK_DOMAIN +export const keycloakRealm = process.env.KEYCLOAK_REALM +export const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID +export const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET +export const keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI +export const adminsUserId = process.env.ADMIN_KC_USER_ID + ? process.env.ADMIN_KC_USER_ID.split(',') + : [] + +export const contactEmail = process.env.CONTACT_EMAIL ?? 'cloudpinative-relations@interieur.gouv.fr' + +// plugins +export const mockPlugins = process.env.MOCK_PLUGINS === 'true' +export const projectRootDir = process.env.PROJECTS_ROOT_DIR +export const pluginsDir = process.env.PLUGINS_DIR ?? '/plugins' +export const NODE_ENV = process.env.NODE_ENV === 'test' + ? 'test' + : process.env.NODE_ENV === 'development' + ? 'development' + : 'production' + +// server tuning +export const parallelBulkLimit = process.env.PARALLEL_BULK_LIMIT ? Number(process.env.PARALLEL_BULK_LIMIT) : 5 diff --git a/.jjconflict-base-0/apps/server/src/utils/errors.ts b/.jjconflict-base-0/apps/server/src/utils/errors.ts new file mode 100644 index 0000000000..0f1dd07fbe --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/errors.ts @@ -0,0 +1,48 @@ +export class ErrorResType { + readonly status: 400 | 401 | 403 | 404 | 422 | 500 + body: { message: string } = { message: '' } + constructor(code: 400 | 401 | 403 | 404 | 422 | 500) { + this.status = code + } +} +export class BadRequest400 extends ErrorResType { + constructor(message: string) { + super(400) + this.body.message = message ?? 'Bad Request' + } +} + +export class Unauthorized401 extends ErrorResType { + constructor(message?: string) { + super(401) + this.body.message = message ?? 'Unauthorized' + } +} + +export class Forbidden403 extends ErrorResType { + constructor(message?: string) { + super(403) + this.body.message = message ?? 'Forbidden' + } +} + +export class NotFound404 extends ErrorResType { + constructor() { + super(404) + this.body.message = 'Not Found' + } +} + +export class Unprocessable422 extends ErrorResType { + constructor(message?: string) { + super(422) + this.body.message = message ?? 'Unprocessable Entity' + } +} + +export class Internal500 extends ErrorResType { + constructor(message: string) { + super(500) + this.body.message = message + } +} diff --git a/.jjconflict-base-0/apps/server/src/utils/fastify.ts b/.jjconflict-base-0/apps/server/src/utils/fastify.ts new file mode 100644 index 0000000000..c51281e3e9 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/fastify.ts @@ -0,0 +1,54 @@ +import type { FastifySwaggerUiOptions } from '@fastify/swagger-ui' +import type { generateOpenApi } from '@ts-rest/open-api' +import type { FastifyServerOptions } from 'fastify' +import { randomUUID } from 'node:crypto' +import { getLoggerOptionsFromEnv } from '@cpn-console/logger' +import { swaggerUiPath } from '@cpn-console/shared' +import { + appVersion, + keycloakClientId, + keycloakClientSecret, + keycloakRealm, + keycloakRedirectUri, +} from './env.js' + +export const fastifyConf: FastifyServerOptions = { + maxParamLength: 5000, + logger: getLoggerOptionsFromEnv(), + genReqId: () => randomUUID(), +} + +const externalDocs = { + description: 'External documentation.', + url: 'https://cloud-pi-native.fr', +} + +export const swaggerConf: Parameters[1] = { + info: { + title: 'Console Cloud Pi Native', + description: 'API de gestion des ressources Cloud Pi Native.', + version: appVersion, + }, + + externalDocs, + servers: [ + { + url: keycloakRedirectUri, + }, + ], +} + +export const swaggerUiConf: FastifySwaggerUiOptions = { + routePrefix: `${swaggerUiPath}-server`, + uiConfig: { + docExpansion: 'list', + deepLinking: false, + }, + initOAuth: { + clientId: keycloakClientId, + clientSecret: keycloakClientSecret, + realm: keycloakRealm, + appName: 'Cloud Pi Native', + scopes: 'openid generic', + }, +} diff --git a/.jjconflict-base-0/apps/server/src/utils/hook-wrapper.spec.ts b/.jjconflict-base-0/apps/server/src/utils/hook-wrapper.spec.ts new file mode 100644 index 0000000000..c2f4e3ebcc --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/hook-wrapper.spec.ts @@ -0,0 +1,239 @@ +import type { KubeCluster, KubeUser, Project as ProjectPayload, Store } from '@cpn-console/hooks' +import type { ProjectInfos, ReposCreds } from './hook-wrapper.ts' +import { describe, expect, it } from 'vitest' +import { transformToHookProject } from './hook-wrapper.ts' + +const associatedCluster = { + id: 'f0e39981-0b6d-4c16-aa96-225062b75767', + infos: '', + label: 'carno', + privacy: 'dedicated', + secretName: '4a38422c-29e1-4b61-b533-edaa1b8a9b60', + kubeconfig: { + id: 'c8ba6db2-9a1d-4d6b-8b5e-2902cecd1437', + user: { + keyData: 'REDACTED', + certData: 'REDACTED', + }, + cluster: { + caData: 'REDACTED', + server: 'https://api-server:6443', + skipTLSVerify: false, + tlsServerName: 'api-server', + }, + createdAt: '2024-05-02T09:17:27.882Z', + updatedAt: '2024-05-02T09:17:27.882Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0', + slug: 'default', + }, +} +const nonAssociatedCluster = { + id: 'f0e39981-0b6d-4c16-aa96-225062b75111', + infos: '', + label: 'carno2', + privacy: 'dedicated', + secretName: '4a38422c-29e1-4b61-b533-edaa1b8a9111', + kubeconfig: { + id: 'c8ba6db2-9a1d-4d6b-8b5e-2902cecd1111', + user: { + keyData: 'REDACTED', + certData: 'REDACTED', + }, + cluster: { + caData: 'REDACTED', + server: 'https://api-server:6443', + skipTLSVerify: false, + tlsServerName: 'api-server', + }, + createdAt: '2024-05-02T09:17:27.882Z', + updatedAt: '2024-05-02T09:17:27.882Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0', + slug: 'default', + }, +} +const project: ProjectInfos = { + id: '011e7860-04d7-461f-912d-334c622d38b3', + name: 'candilib', + description: 'Application de réservation de places à l\'examen du permis B.', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.778Z', + updatedAt: '2023-07-03T14:46:56.783Z', + everyonePerms: 896n, + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + members: [], + clusters: [associatedCluster, nonAssociatedCluster], + environments: [ + { + id: '1b9f1053-fcf5-4053-a7b2-ff8a2c0c1921', + name: 'dev', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.787Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + quota: { + id: '5a57b62f-2465-4fb6-a853-5a751d099199', + memory: '4Gi', + cpu: 2, + name: 'small', + isPrivate: false, + }, + stage: { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + name: 'dev', + }, + cluster: { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + infos: 'Floating IP : 0.0.0.0', + label: 'pas-top-cluster', + privacy: 'dedicated', + secretName: '94d52618-7869-4192-b33e-85dd0959e815', + kubeconfig: { + id: 'b5662039-a62b-483e-ba54-b12c6f966c96', + user: { + token: 'kirikou', + }, + cluster: { + server: 'https://pwned.cluster', + tlsServerName: 'pwned.cluster', + }, + createdAt: '2024-07-24T16:54:14.969Z', + updatedAt: '2024-07-24T16:54:14.969Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + }, + }, + }, + { + id: '1c654f00-4798-4a80-929f-960ddb37885a', + name: 'integration', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + quota: { + id: '5a57b62f-2465-4fb6-a853-5a751d099199', + memory: '4Gi', + cpu: 2, + name: 'small', + isPrivate: false, + }, + stage: { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + name: 'integration', + }, + cluster: { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + infos: null, + label: 'top-secret-cluster', + privacy: 'dedicated', + secretName: '59be2d50-58f9-42f3-95dc-b0c0518e3d8a', + kubeconfig: { + id: '0e88f000-07e6-4781-a69d-0963489387f7', + user: { + token: 'nyan cat', + }, + cluster: { + server: 'https://nothere.cluster', + skipTLSVerify: false, + tlsServerName: 'nothere.cluster', + }, + createdAt: '2024-07-24T16:54:14.966Z', + updatedAt: '2024-07-24T16:54:14.966Z', + }, + clusterResources: true, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + }, + }, + }, + ], + repositories: [ + { + id: '299216bb-2dcc-42b5-ac71-6aa001d2dccf', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/dnum-mi/candilib.git', + externalUserName: 'this-is-a-test', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.802Z', + }, + ], + plugins: [], + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', + email: 'test@test.com', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + }, + roles: [], +} + +describe('transformToHookProject', () => { + // Mock data + const mockStore: Store = {} + const mockReposCreds: ReposCreds = { + console: { + token: 'test', + username: 'test', + }, + } + + it('transforme correctement le projet en objet Payload', () => { + const result: ProjectPayload = transformToHookProject(project, mockStore, mockReposCreds) + + // Asserts pour vérifier la transformation + + // Assert sur la transformation des utilisateurs + expect(result.users).toEqual([project.owner]) + + // Assert sur la transformation des rôles + expect(result.roles).toEqual([{ + name: 'owner', + position: 0, + users: [project.owner], + }]) + + // Assert sur la transformation des clusters + expect(result.clusters).toEqual([associatedCluster, nonAssociatedCluster].map(({ kubeconfig, ...cluster }) => ({ + user: kubeconfig.user as unknown as KubeUser, + cluster: kubeconfig.cluster as unknown as KubeCluster, + ...cluster, + privacy: cluster.privacy, + }))) + + // Assert sur la transformation des environnements + expect(result.environments).toEqual(project.environments.map(({ permissions: _, stage, quota, ...environment }) => ({ + quota, + stage: stage.name, + permissions: [{ permissions: { rw: true, ro: true }, userId: project.ownerId }], + ...environment, + apis: {}, + }))) + + // Assert sur la transformation des repositories + expect(result.repositories).toEqual(project.repositories.map(repo => ({ ...repo, newCreds: mockReposCreds[repo.internalRepoName] }))) + + // Assert sur le store + expect(result.store).toEqual(mockStore) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/utils/hook-wrapper.ts b/.jjconflict-base-0/apps/server/src/utils/hook-wrapper.ts new file mode 100644 index 0000000000..0df3d89a4b --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/hook-wrapper.ts @@ -0,0 +1,419 @@ +import type { ClusterObject, HookResult, KubeCluster, KubeUser, Project as ProjectPayload, RepoCreds, Repository, Store, ZoneObject } from '@cpn-console/hooks' +import type { AsyncReturnType } from '@cpn-console/shared' +import type { Cluster, Kubeconfig, Project, ProjectMembers, ProjectRole, Zone } from '@prisma/client' +import type { ConfigRecords } from '@/resources/project-service/business.js' +import { hooks } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { getPermsByUserRoles, ProjectAuthorized, resourceListToDict } from '@cpn-console/shared' +import { dbToObj } from '@/resources/project-service/business.js' +import { archiveProject, getAdminPlugin, getAdminRoleById, getClusterByIdOrThrow, getClusterNamesByZoneId, getClustersAssociatedWithProject, getHookProjectInfos, getHookRepository, getProjectStore, getRole, getZoneByIdOrThrow, saveProjectStore, updateProjectClusterHistory, updateProjectCreated, updateProjectFailed, updateProjectWarning } from '@/resources/queries-index.js' +import { genericProxy } from './proxy.js' + +export type ReposCreds = Record +export type ProjectInfos = AsyncReturnType + +const logger = baseLogger.child({ scope: 'utils:hook-wrapper' }) + +function summarizeHookResultForLogs(hookResult: HookResult) { + const nonOkResults = Object.fromEntries( + Object.entries(hookResult.results ?? {}) + .filter(([_pluginName, result]) => result?.status?.result !== 'OK') + .map(([pluginName, result]) => [ + pluginName, + { + result: result.status.result, + message: result.status.result === 'OK' ? undefined : result.status.message, + }, + ]), + ) + + return { + failed: hookResult.failed, + warning: hookResult.warning, + messageResume: hookResult.messageResume, + totalExecutionTime: hookResult.totalExecutionTime, + nonOkResults, + } +} + +async function getProjectPayload(projectId: Project['id'], reposCreds?: ReposCreds) { + const [ + project, + store, + clusters, + ] = await Promise.all([ + getHookProjectInfos(projectId), + getProjectStore(projectId), + getClustersAssociatedWithProject(projectId), + ]) + + return transformToHookProject({ + ...project, + clusters, + }, dbToObj(store), reposCreds) +} + +async function upsertProject(projectId: Project['id'], reposCreds?: ReposCreds) { + const [payload, config] = await Promise.all([ + getProjectPayload(projectId, reposCreds), + getAdminPlugin(), + ]) + + const results = await hooks.upsertProject.execute(payload, dbToObj(config)) + + const records: ConfigRecords = Object.entries(results.results).reduce((acc, [pluginName, result]) => { + if (result.store) { + return [...acc, ...Object.entries(result.store).map(([key, value]) => ({ pluginName, key, value: String(value) }))] + } + return acc + }, [] as ConfigRecords) + + await saveProjectStore(records, projectId) + const project = await manageProjectStatus(projectId, results, 'upsert', payload.environments.map(env => env.clusterId)) + return { + results, + project, + } +} +const project = { + upsert: async (projectId: Project['id'], reposCreds?: ReposCreds) => { + const first = await upsertProject(projectId, reposCreds) + if (!first.results.failed) { + return first + } + + logger.warn({ + action: 'upsertProject', + projectId, + attempt: 1, + maxAttempts: 2, + ...summarizeHookResultForLogs(first.results), + }, 'Hook upsertProject failed, retrying once') + + const second = await upsertProject(projectId, reposCreds) + const logPayload = { + action: 'upsertProject', + projectId, + attempt: 2, + maxAttempts: 2, + ...summarizeHookResultForLogs(second.results), + } + if (second.results.failed) { + logger.error(logPayload, 'Hook upsertProject retry failed') + } else { + logger.info(logPayload, 'Hook upsertProject retry succeeded') + } + + return second + }, + delete: async (projectId: Project['id']) => { + const [payload, config] = await Promise.all([ + getProjectPayload(projectId), + getAdminPlugin(), + ]) + const results = await hooks.deleteProject.execute(payload, dbToObj(config)) + return { + results, + project: await manageProjectStatus(projectId, results, 'delete', []), + } + }, + getSecrets: async (projectId: Project['id']) => { + const project = await getHookProjectInfos(projectId) + const store = dbToObj(await getProjectStore(project.id)) + const config = dbToObj(await getAdminPlugin()) + + return hooks.getProjectSecrets.execute({ ...project, store }, config) + }, +} as const + +type ProjectAction = keyof typeof project +async function manageProjectStatus(projectId: Project['id'], hookReply: HookResult, action: ProjectAction, envClusterIds: Cluster['id'][]): Promise> { + if (!hookReply.failed && hookReply.results?.kubernetes) { + await updateProjectClusterHistory(projectId, envClusterIds) + } + if (hookReply.failed) { + return updateProjectFailed(projectId) + } else if (hookReply.warning.length) { + return updateProjectWarning(projectId) + } else if (action === 'upsert') { + return updateProjectCreated(projectId) + } else if (action === 'delete') { + return archiveProject(projectId) + } + throw new Error('unknown action') +} + +const cluster = { + upsert: async (clusterId: Cluster['id'], previousZoneId: Cluster['zoneId']) => { + const cluster = await getClusterByIdOrThrow(clusterId) + const clusterObject = cluster as unknown as ClusterObject + const store = dbToObj(await getAdminPlugin()) + if (cluster.zoneId !== previousZoneId) { + // Upsert on the old zone to remove cluster + const previousClusterObject = { + ...cluster, + } as unknown as ClusterObject + previousClusterObject.zone = await getZoneByIdOrThrow(previousZoneId) + previousClusterObject.zone.clusterNames = await getClusterNamesByZoneId(previousZoneId) + const hookResult = await hooks.upsertCluster.execute({ + ...cluster.kubeconfig as unknown as Pick, + ...previousClusterObject, + }, store) + if (hookResult.failed) { + return hookResult + } + } + clusterObject.zone.clusterNames = await getClusterNamesByZoneId(cluster.zoneId) + return hooks.upsertCluster.execute({ + ...cluster.kubeconfig as unknown as Pick, + ...clusterObject, + }, store) + }, + delete: async (clusterId: Cluster['id']) => { + const cluster = await getClusterByIdOrThrow(clusterId) + const clusterObject = cluster as unknown as ClusterObject + const clusterNames = await getClusterNamesByZoneId(cluster.zoneId) + clusterObject.zone.clusterNames = clusterNames.filter(c => c !== cluster.label) + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteCluster.execute({ + ...cluster.kubeconfig as unknown as ClusterObject, + ...clusterObject, + }, store) + }, +} as const + +const user = { + retrieveUserByEmail: async (email: string) => { + const config = dbToObj(await getAdminPlugin()) + return hooks.retrieveUserByEmail.execute({ email }, config) + }, +} as const + +const projectMember = { + upsert: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { + const project = await getHookProjectInfos(projectId) + const projectStore = dbToObj(await getProjectStore(project.id)) + const hookProject = transformToHookProject(project, projectStore) + const store = dbToObj(await getAdminPlugin()) + + const member = project.members.find(m => m.userId === userId) + if (!member) throw new Error('Member not found') + + const memberRoles = project.roles + .filter(role => member.roleIds.includes(role.id)) + .map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ?? undefined, + project: hookProject, + })) + + const payload = { + userId: member.userId, + roleIds: member.roleIds, + firstName: member.user.firstName, + lastName: member.user.lastName, + email: member.user.email, + type: member.user.type as 'human' | 'bot' | 'ghost', + createdAt: member.user.createdAt.toISOString(), + updatedAt: member.user.updatedAt.toISOString(), + lastLogin: member.user.lastLogin?.toISOString(), + projectId: project.id, + roles: memberRoles, + project: hookProject, + } + + return hooks.upsertProjectMember.execute(payload, store) + }, + delete: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { + const project = await getHookProjectInfos(projectId) + const projectStore = dbToObj(await getProjectStore(project.id)) + const hookProject = transformToHookProject(project, projectStore) + const store = dbToObj(await getAdminPlugin()) + + const member = project.members.find(m => m.userId === userId) + if (!member) throw new Error('Member not found') + + const memberRoles = project.roles + .filter(role => member.roleIds.includes(role.id)) + .map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ?? undefined, + project: hookProject, + })) + + const payload = { + userId: member.userId, + roleIds: member.roleIds, + firstName: member.user.firstName, + lastName: member.user.lastName, + email: member.user.email, + type: member.user.type as 'human' | 'bot' | 'ghost', + createdAt: member.user.createdAt.toISOString(), + updatedAt: member.user.updatedAt.toISOString(), + lastLogin: member.user.lastLogin?.toISOString(), + projectId: project.id, + roles: memberRoles, + project: hookProject, + } + + return hooks.deleteProjectMember.execute(payload, store) + }, +} as const + +const projectRole = { + upsert: async (roleId: ProjectRole['id']) => { + const role = await getRole(roleId) + if (!role) throw new Error('Role not found') + + const project = await getHookProjectInfos(role.projectId) + const projectStore = dbToObj(await getProjectStore(role.projectId)) + const hookProject = transformToHookProject(project, projectStore) + + const rolePayload = { + ...role, + permissions: role.permissions.toString(), + project: hookProject, + } + const store = dbToObj(await getAdminPlugin()) + return hooks.upsertProjectRole.execute(rolePayload, store) + }, + delete: async (roleId: ProjectRole['id']) => { + const role = await getRole(roleId) + if (!role) throw new Error('Role not found') + + const project = await getHookProjectInfos(role.projectId) + const projectStore = dbToObj(await getProjectStore(role.projectId)) + const hookProject = transformToHookProject(project, projectStore) + + const rolePayload = { + ...role, + permissions: role.permissions.toString(), + project: hookProject, + } + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteProjectRole.execute(rolePayload, store) + }, +} as const + +const zone = { + upsert: async (zoneId: Zone['id']) => { + const zone: ZoneObject = await getZoneByIdOrThrow(zoneId) + zone.clusterNames = await getClusterNamesByZoneId(zoneId) + const store = dbToObj(await getAdminPlugin()) + return hooks.upsertZone.execute(zone, store) + }, + delete: async (zoneId: Zone['id']) => { + const zone = await getZoneByIdOrThrow(zoneId) + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteZone.execute(zone, store) + }, +} as const + +const misc = { + checkServices: async () => { + const config = dbToObj(await getAdminPlugin()) + return hooks.checkServices.execute({}, config) + }, + syncRepository: async (repoId: string, { syncAllBranches, branchName }: { syncAllBranches: boolean, branchName?: string }) => { + const { project, ...repoInfos } = await getHookRepository(repoId) + const store = dbToObj(await getProjectStore(project.id)) + const payload = { + repo: { ...repoInfos, syncAllBranches, branchName }, + ...project, + store, + } + const config = dbToObj(await getAdminPlugin()) + return hooks.syncRepository.execute(payload, config) + }, +} as const + +const adminRole = { + upsert: async (roleId: string) => { + const role = await getAdminRoleById(roleId) + if (!role) throw new Error('Role not found') + const config = dbToObj(await getAdminPlugin()) + return hooks.upsertAdminRole.execute(role, config) + }, + delete: async (role: AsyncReturnType) => { + if (!role) throw new Error('Role is required') + const config = dbToObj(await getAdminPlugin()) + return hooks.deleteAdminRole.execute(role, config) + }, +} as const + +export const hook = { + // @ts-ignore TODO voir comment opti la signature de la fonction + misc: genericProxy(misc), + // @ts-ignore TODO voir comment opti la signature de la fonction + project: genericProxy(project, { upsert: ['delete'], delete: ['upsert', 'delete'], getSecrets: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + projectRole: genericProxy(projectRole, { delete: ['upsert', 'delete'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + projectMember: genericProxy(projectMember, { delete: ['upsert'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + cluster: genericProxy(cluster, { delete: ['upsert', 'delete'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + zone: genericProxy(zone, { delete: ['upsert'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + user: genericProxy(user, {}), + // @ts-ignore TODO voir comment opti la signature de la fonction + adminRole: genericProxy(adminRole, { delete: ['upsert'], upsert: ['delete'] }), +} + +function formatClusterInfos({ kubeconfig, ...cluster }: Omit + & { kubeconfig: Kubeconfig, zone: Pick }) { + return { + user: kubeconfig.user as unknown as KubeUser, + cluster: kubeconfig.cluster as unknown as KubeCluster, + ...cluster, + privacy: cluster.privacy, + } +} +export type RolesById = Record + +export function transformToHookProject(project: ProjectInfos, store: Store, reposCreds: ReposCreds = {}): ProjectPayload { + const clusters = project.clusters.map(cluster => formatClusterInfos(cluster)) + const rolesById = resourceListToDict(project.roles) + + return ({ + ...project, + clusters, + environments: project.environments.map(({ stage, ...environment }) => ({ + stage: stage.name, + permissions: [ + { permissions: { rw: true, ro: true }, userId: project.ownerId }, + ...project.members.map(member => ({ + userId: member.userId, + permissions: { + ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions: getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) }), + rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions: getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) }), + }, + })), + ], + ...environment, + apis: {}, + })), + repositories: project.repositories.map(repo => ({ ...repo, newCreds: reposCreds[repo.internalRepoName] })), + store, + users: [project.owner, ...project.members.map(({ user }) => user)], + roles: [ + { + name: 'owner', + position: 0, + users: [project.owner], + }, + ...project.roles.map(role => ({ + name: role.name, + permissions: role.permissions.toString(), + position: role.position, + type: role.type, + oidcGroup: role.oidcGroup, + users: project.members + .filter(member => member.roleIds.includes(role.id)) + .map(member => member.user), + })), + ], + }) +} diff --git a/.jjconflict-base-0/apps/server/src/utils/keycloak-utils.spec.ts b/.jjconflict-base-0/apps/server/src/utils/keycloak-utils.spec.ts new file mode 100644 index 0000000000..42c9cfae04 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/keycloak-utils.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { userPayloadMapper } from './keycloak-utils.js' + +describe('keycloak', () => { + it('should map keycloak user object to DSO user object without groups', () => { + const payload = { + sub: 'thisIsAnId', + email: 'test@test.com', + given_name: 'Jean', + family_name: 'DUPOND', + } + const desired = { + id: 'thisIsAnId', + email: 'test@test.com', + firstName: 'Jean', + lastName: 'DUPOND', + groups: [], + } + + const transformed = userPayloadMapper(payload) + + expect(transformed).toMatchObject(desired) + }) + + it('should map keycloak user object to DSO user object with groups', () => { + const payload = { + sub: 'thisIsAnId', + email: 'test@test.com', + given_name: 'Jean', + family_name: 'DUPOND', + groups: ['group1'], + } + const desired = { + id: 'thisIsAnId', + email: 'test@test.com', + firstName: 'Jean', + lastName: 'DUPOND', + groups: ['group1'], + } + + const transformed = userPayloadMapper(payload) + + expect(transformed).toMatchObject(desired) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/utils/keycloak-utils.ts b/.jjconflict-base-0/apps/server/src/utils/keycloak-utils.ts new file mode 100644 index 0000000000..a86a5f13c3 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/keycloak-utils.ts @@ -0,0 +1,27 @@ +import type { FastifyRequest } from 'fastify' +import { tokenHeaderName } from '@cpn-console/shared' + +interface KeycloakPayload { + sub: string + email: string + given_name: string + family_name: string + groups: string[] +} + +export function userPayloadMapper(userPayload: KeycloakPayload) { + return { + id: userPayload.sub, + email: userPayload.email, + firstName: userPayload.given_name, + lastName: userPayload.family_name, + groups: userPayload.groups || [], + } +} + +export function bypassFn(request: FastifyRequest) { + try { + return !!request.headers[tokenHeaderName] + } catch {} + return false +} diff --git a/.jjconflict-base-0/apps/server/src/utils/keycloak.ts b/.jjconflict-base-0/apps/server/src/utils/keycloak.ts new file mode 100644 index 0000000000..73137efc3b --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/keycloak.ts @@ -0,0 +1,42 @@ +import type { KeycloakOptions } from 'fastify-keycloak-adapter' +import { serviceContract, swaggerUiPath, systemContract } from '@cpn-console/shared' +import { + keycloakClientId, + keycloakClientSecret, + keycloakDomain, + keycloakProtocol, + keycloakRealm, + keycloakRedirectUri, + sessionSecret, +} from './env.js' +import { bypassFn, userPayloadMapper } from './keycloak-utils.js' + +export const keycloakConf = { + appOrigin: keycloakRedirectUri ?? 'http://localhost:8080', + keycloakSubdomain: `${keycloakDomain}/realms/${keycloakRealm}`, + clientId: keycloakClientId ?? '', + clientSecret: keycloakClientSecret ?? '', + useHttps: keycloakProtocol === 'https', + disableCookiePlugin: true, + disableSessionPlugin: true, + // @ts-ignore + userPayloadMapper, + retries: 5, + excludedPatterns: [ + systemContract.getVersion.path, + systemContract.getHealth.path, + serviceContract.getServiceHealth.path, + `${swaggerUiPath}-server/**`, + ], + bypassFn, +} as const satisfies KeycloakOptions + +export const sessionConf = { + cookieName: 'sessionId', + secret: sessionSecret || 'a-very-strong-secret-with-more-than-32-char', + cookie: { + httpOnly: true, + secure: true, + }, + expires: 1_800_000, +} diff --git a/.jjconflict-base-0/apps/server/src/utils/mocks.ts b/.jjconflict-base-0/apps/server/src/utils/mocks.ts new file mode 100644 index 0000000000..16169f4d31 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/mocks.ts @@ -0,0 +1,152 @@ +import type { PluginsManifests, RepoCreds, ServiceInfos } from '@cpn-console/hooks' +import type { Repository } from '@prisma/client' +import type { UserDetails } from '../types/index.js' +import type * as utilsController from '../utils/controller.js' +import { editStrippers, populatePluginManifests } from '@cpn-console/hooks' +import { DEFAULT, DISABLED, PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import fp from 'fastify-plugin' + +let requestor: Requestor + +export function setRequestor(user: Requestor = getRandomRequestor()) { + requestor = user +} + +export function getRequestor() { + return requestor +} + +export async function mockSessionPlugin() { + const sessionPlugin = (app: any, opt: any, next: any) => { + app.addHook('onRequest', (req: any, res: any, next: any) => { + req.session = { user: getRequestor() } + next() + }) + next() + } + + return { default: fp(sessionPlugin) } +} + +export async function mockHooksPackage() { + const hookTemplate = { + execute: () => ({ + args: {}, + failed: false, + }), + validate: () => ({ + failed: false, + }), + } + + return { + editStrippers, + populatePluginManifests, + services: { + getStatus: () => [], + refreshStatus: async () => [], + }, + PluginApi: class { }, + servicesInfos: { + registry: { title: 'Harbor', name: 'registry', to: () => 'test' }, + plugin2: { title: 'Plugin2', name: 'plugin2', to: () => ({ to: 'test', title: 'Test' }) }, + plugin3: { title: 'Plugin3', name: 'plugin3', to: () => [{ to: 'test', title: 'Test' }] }, + plugin4: { title: 'Plugin4', name: 'plugin4', to: () => [{ to: 'test' }] }, + plugin5: { title: 'Plugin5', name: 'plugin5' }, + } as Record, + pluginsManifests: { + registry: { + title: 'Harbor', + global: [{ + kind: 'switch', + initialValue: DEFAULT, + key: 'test2', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Test2', + value: DEFAULT, + description: 'description', + }], + project: [{ + kind: 'switch', + key: 'test2', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Test', + value: DEFAULT, + initialValue: DISABLED, + }], + }, + } as PluginsManifests, + hooks: { + // projects + getProjectSecrets: { + execute: () => ({ + failed: false, + args: {}, + results: { + registry: { + secrets: { + token: 'myToken', + }, + status: { + failed: false, + }, + }, + }, + }), + }, + upsertProject: hookTemplate, + deleteProject: hookTemplate, + // clusters + upsertCluster: hookTemplate, + deleteCluster: hookTemplate, + // user + retrieveUserByEmail: hookTemplate, + }, + } +} + +export type ReposCreds = Record + +type Requestor = Partial +export function getRandomRequestor(user?: Requestor): Partial { + return { + id: user?.id ?? faker.string.uuid(), + email: user?.email ?? faker.internet.email(), + firstName: user?.firstName ?? faker.person.firstName(), + lastName: user?.lastName ?? faker.person.lastName(), + type: 'human', + ...user?.groups !== null && { groups: user?.groups ?? [] }, + } +} + +export function getUserMockInfos(adminPermissions?: bigint, user?: UserDetails): utilsController.UserProfile & utilsController.ProjectPermState +export function getUserMockInfos(adminPermissions?: bigint, user?: UserDetails, project?: utilsController.ProjectPermState): utilsController.UserProjectProfile & utilsController.ProjectPermState +export function getUserMockInfos(adminPermissions: bigint = 0n, user = getRandomRequestor(), project?: utilsController.ProjectPermState): utilsController.UserProfile | utilsController.UserProjectProfile { + return { + adminPermissions, + user, + ...project, + } +} + +export function getProjectMockInfos({ projectId, projectLocked, projectOwnerId, projectPermissions, projectStatus }: Partial): utilsController.ProjectPermState { + return { + projectId: projectId ?? faker.string.uuid(), + projectLocked: projectLocked ?? false, + projectOwnerId: projectOwnerId ?? faker.string.uuid(), + projectStatus: projectStatus ?? 'created', + projectPermissions: projectPermissions ?? PROJECT_PERMS.MANAGE, + } +} + +export const atDates = { + createdAt: new Date(), + updatedAt: new Date(), +} diff --git a/.jjconflict-base-0/apps/server/src/utils/plugins.ts b/.jjconflict-base-0/apps/server/src/utils/plugins.ts new file mode 100644 index 0000000000..9876677766 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/plugins.ts @@ -0,0 +1,9 @@ +import type { PluginManagerOptions } from '@cpn-console/hooks' +import { isCI, isInt, isProd } from './env.js' + +export const pluginManagerOptions: PluginManagerOptions = { + mockHooks: isCI || (!isProd && !isInt), + mockMonitoring: isCI || (!isProd && !isInt), + mockExternalServices: isCI || (!isProd && !isInt), + startPlugins: (!isCI && isProd) || isInt, +} diff --git a/.jjconflict-base-0/apps/server/src/utils/proxy.spec.ts b/.jjconflict-base-0/apps/server/src/utils/proxy.spec.ts new file mode 100644 index 0000000000..7fdc203833 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/proxy.spec.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { genericProxy } from './proxy.js' + +// Création d'une cible de test +const target = { + async fetchData(id: string) { + return { id, data: 'Mocked data' } + }, + async otherMethod(id: string) { + return { id, data: 'Mocked data' } + }, +} + +describe('test calls without ID passed', () => { + // Test d'appel de méthode sans ID + it('calling method without ID', async () => { + const proxied = genericProxy(target) + const result = await proxied.fetchData() + expect(result).toEqual({ id: undefined, data: 'Mocked data' }) + }) + + // Fonction de test asynchrone pour tester le cas où aucune ID n'est fournie + it('test when no ID is provided', async () => { + // Création d'une cible de test + const target = { + async fetchData() { + return 'No ID provided' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Appel à la méthode fetchData sans ID + const result = await proxied.fetchData() + + // Vérification que le résultat est correct + expect(result).toBe('No ID provided') + }) + + // Fonction de test asynchrone pour tester le cas où aucune ID n'est fournie avec une promesse en cours + it('test when no ID is provided with pending promise', async () => { + // Création d'une cible de test + const target = { + async fetchData() { + return new Promise(resolve => setTimeout(resolve, 100, 'Pending result')) + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Appel à la méthode fetchData sans ID + const promise1 = proxied.fetchData() + const promise2 = proxied.fetchData() // Deuxième appel avant la résolution du premier + + // Attendre que la première promesse se résolve + const result1 = await promise1 + + // Vérification que le résultat de la première promesse est correct + expect(result1).toBe('Pending result') + + // Attendre que la deuxième promesse se résolve + const result2 = await promise2 + + // Vérification que le résultat de la deuxième promesse est correct + expect(result2).toBe('Pending result') + }) + // Test pour vérifier que l'erreur est levée lorsque args est fourni sans ID + it('test error when args provided without ID', async () => { + // Création d'une cible de test + const target = { + async fetchData(_id: string, _args: any) { + return 'No ID provided' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + const args = { key: 'value' } + + // Appel de la fonction fetchData avec des arguments mais sans ID + await expect(proxied.fetchData(undefined, args)).rejects.toThrow('ID is required when args are provided') + }) +}) + +describe('test calls with ID passed', () => { + // Test d'appel de méthode avec ID + it('calling method with ID', async () => { + const proxied = genericProxy(target) + const result = await proxied.fetchData('123') + expect(result).toEqual({ id: '123', data: 'Mocked data' }) + }) + + // Test d'appel de méthode avec exclusion en cours + it('calling method with exclusion in progress', async () => { + const proxied = genericProxy(target, { fetchData: ['otherMethod'] }) + // Simuler une exécution en cours pour la méthode exclue + proxied.otherMethod('456') + + // Maintenant, tenter d'appeler fetchData pour le même ID devrait échouer + await expect(proxied.fetchData('456')).rejects.toThrow( + 'otherMethod in progress on 456, can\'t fetchData', + ) + }) + + // Fonction de test asynchrone pour tester le mélange des nextArgs + it('test mixing nextArgs from concurrent promises', async () => { + // Création d'une cible de test + const target = { + async fetchData(id: string, args?: object) { + return { id, args } + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + const promise1 = proxied.fetchData('123', { key1: 'value1' }) + // Appels successifs à fetchData avec différents arguments + const promise2 = proxied.fetchData('123', { key2: 'value2' }) + + // Promesse concurrente avec des nextArgs différents + const promise3 = proxied.fetchData('123', { key3: 'value3' }) + + // Attendre que les promesses se résolvent + const result1 = await promise1 + const result2 = await promise2 + const result3 = await promise3 + + // Vérification que les nextArgs de promise2 et promise3 ont été correctement mélangés + expect(result1.args).toEqual({ key1: 'value1' }) + expect(result2.args).toEqual({ key2: 'value2', key3: 'value3' }) + expect(result3.args).toEqual({ key2: 'value2', key3: 'value3' }) + }) + + it('test rejection of set attempt', () => { + // Création d'une cible de test + const target = { + async fetchData() { + return 'Mocked data' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Tentative de définir une nouvelle propriété sur le proxy + const setAttempt = () => { + proxied.fetchData = () => new Promise(resolve => resolve('illegal')) + } + + // Vérification que la tentative de set est rejetée + expect(setAttempt).toThrow(TypeError) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/utils/proxy.ts b/.jjconflict-base-0/apps/server/src/utils/proxy.ts new file mode 100644 index 0000000000..ef915a7d15 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/proxy.ts @@ -0,0 +1,78 @@ +// @ts-nocheck un enfer à typer, pour plus tard +type Tracker> = Record + nextArgs?: [string] +}>> | Promise + +type Target = Record Promise> +type Excludes = Partial>> | undefined +const toTarget = (target: T) => ({ tracker: {} as Tracker, methods: target }) + +// @ts-ignore +export function genericProxy(proxied: T, excludes: Excludes = {}): T { + return new Proxy(toTarget(proxied), { + get({ methods, tracker }, property: string) { + if (!(property in methods)) return + return async (...args) => { + const id = args[0] as string + + if (!id && args.length > 0) { + throw new Error('ID is required when args are provided') + } + + if (!id) { + if (tracker[property] instanceof Promise) { + return tracker[property] + } + const p = methods[property]() + if (p instanceof Promise) { + tracker[property] = p + p.then(() => { + delete tracker[property] + }) + } + return p + } + if (!tracker[property]) { + tracker[property] = {} + } + + for (const testExclude of excludes[property] ?? []) { + // @ts-ignore + if (tracker?.[testExclude]?.[id]?.currentExec) { + throw new Error(`${String(testExclude)} in progress on ${id}, can't ${String(property)}`) + } + } + + if (id in tracker[property]) { + if (args[1]) { + tracker[property][id].nextArgs = { + ...(tracker[property][id].nextArgs ?? {}), + ...args[1], + } + } + if (tracker[property][id].currentExec) { + return new Promise((resolve) => { + tracker[property][id].currentExec.then(() => { + resolve(tracker[property][id].currentExec ?? methods[property](id, tracker[property][id].nextArgs)) + }) + }) + } + } + const p = methods[property](...args) + tracker[property][id] = { + currentExec: p, + nextArgs: undefined, + } + tracker[property][id].currentExec = p + p.then(() => { + tracker[property][id].currentExec = undefined + }) + return p + } + }, + set() { + return false + }, + }) as T +} diff --git a/.jjconflict-base-0/apps/server/src/utils/queries-tools.spec.ts b/.jjconflict-base-0/apps/server/src/utils/queries-tools.spec.ts new file mode 100644 index 0000000000..e37ed94c25 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/queries-tools.spec.ts @@ -0,0 +1,47 @@ +import { exclude } from '@cpn-console/shared' +import { describe, expect, it } from 'vitest' +import { filterObjectByKeys } from './queries-tools.js' + +describe('queries-tools', () => { + it('should return a filtered object (filterObjectByKeys)', () => { + const initial = { + id: 'thisIsAnId', + name: 'alsoKeepThisKey', + description: 'keepThisKey', + } + const desired = { + name: 'alsoKeepThisKey', + description: 'keepThisKey', + } + + const transformed = filterObjectByKeys(initial, ['name', 'description']) + + expect(transformed).toMatchObject(desired) + }) + + it('should return a filtered object (exclude)', () => { + const initial = { + id: 'thisIsAnId', + name: 'myProjectName', + environment: { + permissions: { + password: 'secret', + id: 'notSecret', + }, + }, + } + const desired = { + id: 'thisIsAnId', + name: 'myProjectName', + environment: { + permissions: { + id: 'notSecret', + }, + }, + } + + const transformed = exclude(initial, ['password']) + + expect(transformed).toMatchObject(desired) + }) +}) diff --git a/.jjconflict-base-0/apps/server/src/utils/queries-tools.ts b/.jjconflict-base-0/apps/server/src/utils/queries-tools.ts new file mode 100644 index 0000000000..856ca277f1 --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/queries-tools.ts @@ -0,0 +1,11 @@ +export const dbKeysExcluded = ['updatedAt', 'createdAt'] + +// TODO +// @ts-ignore supprimer cette fonction et utiliser des schémas zod où elle est utilisé +export function filterObjectByKeys(obj, keys) { + return Object.fromEntries( + Object.entries(obj)?.filter(([key, _value]) => keys.includes(key)), + ) +} + +export const uuid: RegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i diff --git a/.jjconflict-base-0/apps/server/src/utils/random.spec.ts b/.jjconflict-base-0/apps/server/src/utils/random.spec.ts new file mode 100644 index 0000000000..3c33d6a4ee --- /dev/null +++ b/.jjconflict-base-0/apps/server/src/utils/random.spec.ts @@ -0,0 +1,148 @@ +import { createRandomDbSetup } from '@cpn-console/test-utils' +import { describe, expect, it } from 'vitest' + +describe('random utils', () => { + // TODO + it.skip('should create a random db for tests', () => { + const db = createRandomDbSetup({ nbUsers: 3, nbRepo: 1, envs: ['dev', 'prod'] }) + expect(db).toEqual( + expect.objectContaining({ + stages: expect.arrayContaining([ + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + ]), + quotas: expect.arrayContaining([ + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + ]), + project: expect.objectContaining({ + id: expect.any(String), + name: expect.any(String), + clusters: expect.arrayContaining([{ + caData: expect.any(String), + server: expect.any(String), + tlsServername: expect.any(String), + }]), + status: expect.any(String), + locked: expect.any(Boolean), + roles: expect.arrayContaining([ + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + ]), + repositories: expect.any(Array), + environments: expect.arrayContaining([ + { + id: expect.any(String), + stageId: expect.any(String), + projectId: expect.any(String), + quotaId: expect.any(String), + status: expect.any(String), + permissions: expect.any(Array), + clusters: expect.any(Array), + }, + { + id: expect.any(String), + stageId: expect.any(String), + projectId: expect.any(String), + quotaId: expect.any(String), + status: expect.any(String), + permissions: expect.any(Array), + clusters: expect.any(Array), + }, + ]), + }), + users: expect.arrayContaining([ + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + ]), + }), + ) + }) +}) diff --git a/.jjconflict-base-0/apps/server/tsconfig.json b/.jjconflict-base-0/apps/server/tsconfig.json new file mode 100644 index 0000000000..a3397898ca --- /dev/null +++ b/.jjconflict-base-0/apps/server/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "useUnknownInCatchVariables": false, + "declarationDir": "./types", + "outDir": "./dist", + "plugins": [{ "transform": "typescript-transform-paths" }] + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__", + "./src/mocks/utils.ts", + "./src/utils/mocks.ts" + ] +} diff --git a/.jjconflict-base-0/apps/server/vite.config.ts b/.jjconflict-base-0/apps/server/vite.config.ts new file mode 100644 index 0000000000..f9a3e50fae --- /dev/null +++ b/.jjconflict-base-0/apps/server/vite.config.ts @@ -0,0 +1,18 @@ +/// +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + poolMatchGlobs: [ + ['**/resources/**/*.spec.ts', 'forks'], + ], + }, +}) diff --git a/.jjconflict-base-0/apps/server/vitest-init.ts b/.jjconflict-base-0/apps/server/vitest-init.ts new file mode 100644 index 0000000000..596420f7de --- /dev/null +++ b/.jjconflict-base-0/apps/server/vitest-init.ts @@ -0,0 +1,11 @@ +process.env.ARGOCD_URL = 'https://argo-cd.readthedocs.io' +process.env.GITLAB_URL = 'https://gitlab.com' +process.env.HARBOR_URL = 'https://goharbor.io' +process.env.NEXUS_URL = 'https://sonatype.com/products/nexus-repository' +process.env.SONARQUBE_URL = 'https://www.sonarqube.org' +process.env.VAULT_URL = 'https://www.vaultproject.io' +process.env.PROJECTS_ROOT_DIR = 'forge-mi/projects' +process.env.KEYCLOAK_REDIRECT_URI = 'http://console.dso.local' +process.env.CONTACT_EMAIL = 'cloudpinative-relations@interieur.gouv.fr' +process.env.OPENCDS_URL = 'https://opencds.gouv.fr' +process.env.OPENCDS_API_TOKEN = 'test_token' diff --git a/.jjconflict-base-0/apps/server/vitest.config.ts b/.jjconflict-base-0/apps/server/vitest.config.ts new file mode 100644 index 0000000000..9dc913021e --- /dev/null +++ b/.jjconflict-base-0/apps/server/vitest.config.ts @@ -0,0 +1,35 @@ +import { fileURLToPath } from 'node:url' +import { mergeConfig } from 'vite' +import { configDefaults, defineConfig } from 'vitest/config' +import viteConfig from './vite.config' + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + reporters: ['default', 'hanging-process'], + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: [ + '**/types', + '**/mocks', + '**/*.spec.ts', + '**/*.d.ts', + '**/*.vue', + '**/queries.ts', + '**/mocks.ts', + '**/*.sql', + ], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + setupFiles: ['./vitest-init.ts'], + root: fileURLToPath(new URL('./', import.meta.url)), + pool: 'forks', + }, + }), +) diff --git a/.jjconflict-base-0/ci/matrix-docker.json b/.jjconflict-base-0/ci/matrix-docker.json new file mode 100644 index 0000000000..3550832dba --- /dev/null +++ b/.jjconflict-base-0/ci/matrix-docker.json @@ -0,0 +1,42 @@ +[ + { + "name": "server", + "build": { + "context": "./", + "dockerfile": "./apps/server/Dockerfile", + "target": "prod" + } + }, + { + "name": "nginx-strangler", + "build": { + "context": "./", + "dockerfile": "./apps/nginx-strangler/Dockerfile", + "target": "prod" + } + }, + { + "name": "server-nestjs", + "build": { + "context": "./", + "dockerfile": "./apps/server-nestjs/Dockerfile", + "target": "prod" + } + }, + { + "name": "client", + "build": { + "context": "./", + "dockerfile": "./apps/client/Dockerfile", + "target": "prod" + } + }, + { + "name": "opencds-mockoon", + "build": { + "context": "./", + "dockerfile": "./packages/opencds/Dockerfile", + "target": "prod" + } + } +] diff --git a/.jjconflict-base-0/ci/matrix-npm.json b/.jjconflict-base-0/ci/matrix-npm.json new file mode 100644 index 0000000000..b4805a8be6 --- /dev/null +++ b/.jjconflict-base-0/ci/matrix-npm.json @@ -0,0 +1,72 @@ +{ + "apps": [ + { + "name": "@cpn-console/server", + "path": "./apps/server" + }, + { + "name": "@cpn-console/server-nestjs", + "path": "./apps/server-nestjs" + }, + { + "name": "@cpn-console/client", + "path": "./apps/client" + } + ], + "packages": [ + { + "name": "@cpn-console/shared", + "path": "./packages/shared" + }, + { + "name": "@cpn-console/hooks", + "path": "./packages/hooks" + }, + { + "name": "@cpn-console/logger", + "path": "./packages/logger" + }, + { + "name": "@cpn-console/test-utils", + "path": "./packages/test-utils" + }, + { + "name": "@cpn-console/eslint-config", + "path": "./packages/eslintconfig" + }, + { + "name": "@cpn-console/ts-config", + "path": "./packages/tsconfig" + } + ], + "plugins": [ + { + "name": "@cpn-console/argocd-plugin", + "path": "./plugins/argocd" + }, + { + "name": "@cpn-console/gitlab-plugin", + "path": "./plugins/gitlab" + }, + { + "name": "@cpn-console/harbor-plugin", + "path": "./plugins/harbor" + }, + { + "name": "@cpn-console/keycloak-plugin", + "path": "./plugins/keycloak" + }, + { + "name": "@cpn-console/nexus-plugin", + "path": "./plugins/nexus" + }, + { + "name": "@cpn-console/sonarqube-plugin", + "path": "./plugins/sonarqube" + }, + { + "name": "@cpn-console/vault-plugin", + "path": "./plugins/vault" + } + ] +} diff --git a/.jjconflict-base-0/ci/scripts/build-matrix.sh b/.jjconflict-base-0/ci/scripts/build-matrix.sh new file mode 100755 index 0000000000..301a203ea5 --- /dev/null +++ b/.jjconflict-base-0/ci/scripts/build-matrix.sh @@ -0,0 +1,205 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Get versions +DOCKER_VERSION="$(docker --version)" + + +# Default +# REGISTRY="docker.io" +TAGS="latest" +COMMIT_SHA="$(git rev-parse --short HEAD)" +PLATFORMS="linux/amd64" +CSV=false +RECURSIVE=false + +unset MAJOR_VERSION +unset MINOR_VERSION +unset PATCH_VERSION + +# Declare script helper +TEXT_HELPER="\nThis script aims to build matrix for Github CI/CD. It will parse the given docker-compose file and return a json object with images infos (name, tag, context, dockerfile and if it need to be build) +Following flags are available: + + -a Create recursive tags, if it match 'x.x.x' it will create 'x.x' and 'x'. + + -c Use csv list formated output for tags instead of json array. + + -f Docker-compose file used to build matrix. + + -n Namespace used to tag images. e.g 'username/reponame'. + + -p Target platforms used to build matrix (List/CSV format. ex: 'linux/amd64,linux/arm64'). + Default is '$PLATFORMS'. + + -r Registry host used to build matrix. + Default is '$REGISTRY'. + + -t Docker tag used to build matrix. + Default is '$TAGS'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hacf:n:p:r:t: flag; do + case "${flag}" in + a) + RECURSIVE=true;; + c) + CSV=true;; + f) + COMPOSE_FILE=${OPTARG};; + n) + NAMESPACE=${OPTARG};; + p) + PLATFORMS=${OPTARG};; + r) + REGISTRY=${OPTARG};; + t) + TAGS=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ ! -f "$(readlink -f $COMPOSE_FILE)" ]; then + echo "\nDocker compose file $COMPOSE_FILE does not exist." + print_help + exit 1 +fi + + +if [ "$REGISTRY" ] && [[ "$REGISTRY" != */ ]]; then + REGISTRY="$REGISTRY/" +fi + +if [ "$NAMESPACE" ] && [[ "$NAMESPACE" != */ ]]; then + NAMESPACE="$NAMESPACE/" +fi + + +# Build core matrix +MATRIX=$(cat "$COMPOSE_FILE" \ + | docker run -i --rm mikefarah/yq -o=json \ + | jq \ + --arg d "$(dirname $(readlink -f $COMPOSE_FILE))" \ + --arg p "$PLATFORMS" \ + --arg r "$REGISTRY" \ + --arg t "$TAGS" \ + '.services | to_entries | map({ + image: (.value.image), + name: (.value.image | split(":")[0] | split("/")[-1]), + build: ( + if .value.build then { + context: ($d + "/" + .value.build.context), + dockerfile: ($d + "/" + .value.build.context + "/" + .value.build.dockerfile), + target: (.value.build.target), + platforms: [], + tags: [] + } + else + false + end) + })') + +# Add tags in matrix +for t in $(echo $TAGS | tr "," "\n"); do + if [[ "$t" == *"."*"."* ]] && [[ "$RECURSIVE" == "true" ]]; then + MAJOR_VERSION="$(echo $t | cut -d "." -f 1)" + MINOR_VERSION="$(echo $t | cut -d "." -f 2)" + PATCH_VERSION="$(echo $t | cut -d "." -f 3)" + + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg r "$REGISTRY" \ + --arg n "$NAMESPACE" \ + --arg major "$MAJOR_VERSION" \ + --arg minor "$MINOR_VERSION" \ + 'map(. | + if .build != false then + .build.tags += [ + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $major), + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $major + "." + $minor) + ] + else + . + end + )') + fi + + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg t "$t" \ + --arg r "$REGISTRY" \ + --arg n "$NAMESPACE" \ + 'map(. | + if .build != false then + .build.tags += [ + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $t) + ] + else + . + end + )') +done + +# Add platforms in matrix +for p in $(echo $PLATFORMS | tr "," "\n"); do + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg p "$p" \ + 'map(. | + if .build != false then + .build.platforms += [ + ($p) + ] + else + . + end + )') +done + +# Update image key with first tag +MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg t "$TAGS" \ + 'map(. | + if .build != false then + .image = (.image | split(":")[0] | split("/")[-1] + ":" + ($t | split(",")[0])) + else + . + end + )') + +# Convert tags & platforms from json array to csv list (use join instead of @csv to don't get unwanted quotes) +if [ "$CSV" == "true" ]; then + MATRIX=$(echo "$MATRIX" \ + | jq -r \ + 'map(. | + if .build != false then + .build.tags = (.build.tags | join(",")) | + .build.platforms = (.build.platforms | join(",")) + else + . + end + )') +fi + +echo "$MATRIX" | jq . \ No newline at end of file diff --git a/.jjconflict-base-0/ci/scripts/build-tags.sh b/.jjconflict-base-0/ci/scripts/build-tags.sh new file mode 100755 index 0000000000..63f795c710 --- /dev/null +++ b/.jjconflict-base-0/ci/scripts/build-tags.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +set -e + +unset MAJOR_VERSION +unset MINOR_VERSION +unset PATCH_VERSION + +# Declare script helper +TEXT_HELPER="\nThis script aims to build docker tags based on image and set of tags. +Following flags are available: + + -i Image used to create tags. + + -t Docker tag used to build matrix. + Default is '$TAGS'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hi:t: flag; do + case "${flag}" in + i) + IMAGE=${OPTARG};; + t) + TAGS=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ -z "$IMAGE" ] || [ -z "$TAGS" ]; then + echo "\nArguments missing." + print_help + exit 1 +fi + + +for TAG in $(echo $TAGS | tr "," "\n"); do + if [[ "$TAG" == *"."*"."* ]]; then + MAJOR_VERSION="$(echo $TAG | cut -d "." -f 1)" + MINOR_VERSION="$(echo $TAG | cut -d "." -f 1-2)" + if [ -z "$DOCKER_TAGS" ]; then + DOCKER_TAGS="$IMAGE:$MAJOR_VERSION,$IMAGE:$MINOR_VERSION,$IMAGE:$TAG" + else + DOCKER_TAGS="$DOCKER_TAGS,$IMAGE:$TAG,$IMAGE:$MAJOR_VERSION,$IMAGE:$MINOR_VERSION,$IMAGE:$TAG" + fi + else + if [ -z "$DOCKER_TAGS" ]; then + DOCKER_TAGS="$IMAGE:$TAG" + else + DOCKER_TAGS="$DOCKER_TAGS,$IMAGE:$TAG" + fi + fi +done + +echo "$DOCKER_TAGS" diff --git a/.jjconflict-base-0/ci/scripts/cleanup.sh b/.jjconflict-base-0/ci/scripts/cleanup.sh new file mode 100755 index 0000000000..acef1973a0 --- /dev/null +++ b/.jjconflict-base-0/ci/scripts/cleanup.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +cd "$PROJECT_DIR" + + +printf "\n${red}${i}.${no_color} Remove build\n" +i=$(($i + 1)) + +pnpm run build:clean + + +printf "\n${red}${i}.${no_color} Remove node_modules\n" +i=$(($i + 1)) + +while true; do + read -p "Do you want to remove node_modules ('rm -rf $PROJECT_DIR/**/node_modules')? " yn + case $yn in + [Yy]*) + for n in $(find $PROJECT_DIR -depth 3 -type d -name node_modules); do + echo "Delete '$n'" + rm -rf "$n" + done + # find $PROJECT_DIR -type d -name node_modules -delete + break;; + [Nn]*) + echo "\nKeeping node_modules\n" + break;; + *) + echo "\nPlease answer y or n.\n";; + esac +done + + +printf "\n${red}${i}.${no_color} Remove docker stuff\n" +i=$(($i + 1)) + +if docker image inspect dso-console/client:dev > /dev/null 2>&1; then + docker image rm dso-console/client:dev +fi +if docker image inspect dso-console/client:prod > /dev/null 2>&1; then + docker image rm dso-console/client:prod +fi +if docker image inspect dso-console/server:dev > /dev/null 2>&1; then + docker image rm dso-console/server:dev +fi +if docker image inspect dso-console/server:prod > /dev/null 2>&1; then + docker image rm dso-console/server:prod +fi + +if docker volume inspect docker_dso-postgres-data-dev > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-dev +fi +if docker volume inspect docker_dso-postgres-data-integ > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-integ +fi +if docker volume inspect docker_dso-postgres-data-local > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-local +fi diff --git a/.jjconflict-base-0/ci/scripts/delete-image.sh b/.jjconflict-base-0/ci/scripts/delete-image.sh new file mode 100755 index 0000000000..d1e0e70e59 --- /dev/null +++ b/.jjconflict-base-0/ci/scripts/delete-image.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' + +# Get versions +DOCKER_VERSION="$(docker --version)" +DOCKER_BUILDX_VERSION="$(docker buildx version)" + +# Declare script helper +TEXT_HELPER="\nThis script aims to delete an image with all its subsequent images in ghcr. +Following flags are available: + + -g Github token to perform api calls. + + -i Image name used for api calls. + + -o Github organization used for api calls. + + -t Image tage to delete. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hg:i:o:t: flag; do + case "${flag}" in + g) + GITHUB_TOKEN=${OPTARG};; + i) + IMAGE_NAME=${OPTARG};; + o) + ORG=${OPTARG};; + t) + TAG=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +checkDockerRunning () { + if [ ! -x "$(command -v docker)" ]; then + printf "\nThis script uses docker, and it isn't running - please start docker and try again!\n" + exit 1 + fi +} + +checkBuildxPlugin () { + if [ ! "$DOCKER_BUILDX_VERSION" ]; then + printf "\nThis script uses docker buildx plugin, and it isn't installed - please install docker buildx plugin and try again!\n" + exit 1 + fi +} + +# Settings +printf "\nScript settings: + -> docker version: ${DOCKER_VERSION} + -> docker buildx version: ${DOCKER_BUILDX_VERSION}\n" + + +if [ -z "$GITHUB_TOKEN" ] || [ -z "$ORG" ] || [ -z "$IMAGE_NAME" ] || [ -z "$TAG" ]; then + echo "\nYMissing arguments ...\n" + print_help + exit 1 +fi + +IMAGE_NAME_URL_ENCODED="$(jq -rn --arg x ${IMAGE_NAME} '$x | @uri')" +IMAGES=$(curl -s \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions?per_page=100") + +# Delete subsequent images +while read -r SHA; do + IMAGE_ID=$(echo "$IMAGES" | jq -r --arg s "$SHA" '.[] | select(.name==$s) | .id') + + printf "\n${red}[Delete ghcr image].${no_color} Deleting subsequent image '$ORG/$IMAGE_NAME@$SHA'\n" + + curl -s \ + -X DELETE \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions/${IMAGE_ID}" +done <<< "$(docker buildx imagetools inspect ghcr.io/${ORG}/${IMAGE_NAME}:${TAG} --raw | jq -r '.manifests[] | .digest')" + +# Delete main images +while read -r IMAGE_ID; do + + printf "\n${red}[Delete ghcr image].${no_color} Deleting GH package version: ${IMAGE_ID}'\n" + + curl -s \ + -X DELETE \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions/${IMAGE_ID}" +done <<< "$(echo "$IMAGES" | jq -r --arg t "$TAG" '.[] | select(.metadata.container.tags[] | contains($t)) | .id')" diff --git a/.jjconflict-base-0/ci/scripts/init-env.sh b/.jjconflict-base-0/ci/scripts/init-env.sh new file mode 100755 index 0000000000..0c6c5d3feb --- /dev/null +++ b/.jjconflict-base-0/ci/scripts/init-env.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Declare script helper +TEXT_HELPER="\nThis script aims to copy .env-example files into .env files at project initialization. +Following flags are available: + + -h Print script help\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts h flag +do + case "${flag}" in + h | *) + print_help + exit 0;; + esac +done + + +find $PROJECT_DIR -type f -name ".env*-example" -or -name "*-example.yaml" | while read f; do + if [ ! -f "${f/-example/}" ]; then + printf "\n${red}Copy${no_color}: '$f' + ${red}to${no_color}: '${f/-example/}'\n" + cp "$f" "${f/-example/}" + else + printf "\nFile '${f/-example/}' already exists\n" + fi +done diff --git a/.jjconflict-base-0/ci/scripts/run-tests.sh b/.jjconflict-base-0/ci/scripts/run-tests.sh new file mode 100755 index 0000000000..cf18c018fe --- /dev/null +++ b/.jjconflict-base-0/ci/scripts/run-tests.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Get versions +NODE_VERSION="$(node --version)" +NPM_VERSION="$(npm --version)" +DOCKER_VERSION="$(docker --version)" +DOCKER_BUILDX_VERSION="$(docker buildx version)" + +# Default +RUN_LINT="false" +RUN_UNIT_TESTS="false" +RUN_E2E_TESTS="false" +RUN_STATUS_CHECK="false" + +# Declare script helper +TEXT_HELPER="\nThis script aims to run application tests. +Following flags are available: + + -e Run e2e tests + + -l Run lint + + -s Run deployement status check + + -t (Optional) Tag used for docker images in e2e tests + + -u Run unit tests + + -h Print script help\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts heklst:u flag +do + case "${flag}" in + e) + RUN_E2E_TESTS=true;; + l) + RUN_LINT=true;; + s) + RUN_STATUS_CHECK=true;; + t) + TAG=${OPTARG};; + u) + RUN_UNIT_TESTS=true;; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ "$RUN_LINT" == "false" ] && [ "$RUN_UNIT_TESTS" == "false" ] && [ "$RUN_E2E_TESTS" == "false" ] && [ "$RUN_STATUS_CHECK" == "false" ]; then + printf "\nArgument(s) missing, you don't specify any kind of test to run.\n" + print_help + exit 1 +fi + +if [ "$RUN_E2E_TESTS" == "true" ] && [ -z "$TAG" ]; then + printf "\nArgument(s) missing, you don't specify the TAG used to pull docker images for e2e tests.\n" + print_help + exit 1 +fi + +checkDockerRunning () { + if [ ! -x "$(command -v docker)" ]; then + printf "\nThis script uses docker, and it isn't running - please start docker and try again!\n" + exit 1 + fi +} + +checkBuildxPlugin () { + if [ ! "$DOCKER_BUILDX_VERSION" ]; then + printf "\nThis script uses docker buildx plugin, and it isn't installed - please install docker buildx plugin and try again!\n" + exit 1 + fi +} + + +# Settings +printf "\nScript settings: + -> node version: ${NODE_VERSION} + -> npm version: ${NPM_VERSION} + -> docker version: ${DOCKER_VERSION} + -> docker buildx version: ${DOCKER_BUILDX_VERSION} + -> run unit tests: ${RUN_UNIT_TESTS} + -> run e2e tests: ${RUN_E2E_TESTS} + -> run deploy status check: ${RUN_STATUS_CHECK}\n" + + +cd "$PROJECT_DIR" + +# Run lint +if [ "$RUN_LINT" == "true" ]; then + npm run lint +fi + + +# Run unit tests +if [ "$RUN_UNIT_TESTS" == "true" ]; then + npm run test:cov +fi + +# Run e2e tests +if [ "$RUN_E2E_TESTS" == "true" ]; then + checkDockerRunning + + printf "\n${red}${i}.${no_color} Launch e2e tests\n" + i=$(($i + 1)) + + npm --prefix $PROJECT_DIR/packages/shared run build + npm --prefix $PROJECT_DIR/packages/test-utils run build + + if [[ -n "$TAG" ]]; then + docker pull ghcr.io/cloud-pi-native/console/server:$TAG && docker tag ghcr.io/cloud-pi-native/console/server:$TAG dso-console/server:ci + docker pull ghcr.io/cloud-pi-native/console/server-nestjs:$TAG && docker tag ghcr.io/cloud-pi-native/console/server-nestjs:$TAG dso-console/server-nestjs:ci + docker pull ghcr.io/cloud-pi-native/console/client:$TAG && docker tag ghcr.io/cloud-pi-native/console/client:$TAG dso-console/client:ci + docker pull ghcr.io/cloud-pi-native/console/nginx-strangler:$TAG && docker tag ghcr.io/cloud-pi-native/console/client:$TAG dso-console/nginx-strangler:ci + fi + + npm run docker:e2e-ci + + printf "\n${red}${i}.${no_color} Remove resources\n" + i=$(($i + 1)) + + npm run docker:e2e-ci:delete +fi diff --git a/.jjconflict-base-0/ci/scripts/setup.sh b/.jjconflict-base-0/ci/scripts/setup.sh new file mode 100755 index 0000000000..4917b17cf8 --- /dev/null +++ b/.jjconflict-base-0/ci/scripts/setup.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +cd "$PROJECT_DIR" + + +printf "\n${red}${i}.${no_color} Install dependencies\n" +i=$(($i + 1)) + +pnpm install + + +printf "\n${red}${i}.${no_color} Generate Prisma client\n" +i=$(($i + 1)) + +pnpm --filter=server run db:generate + + +printf "\n${red}${i}.${no_color} Build packges\n" +i=$(($i + 1)) + +pnpm run build --force + + +printf "\n${red}${i}.${no_color} Build docker dev images\n" +i=$(($i + 1)) + +pnpm run docker:dev:build + + +printf "\n${red}${i}.${no_color} Build docker prod images\n" +i=$(($i + 1)) + +pnpm run docker:prod:build diff --git a/.jjconflict-base-0/ci/scripts/vuln-report.sh b/.jjconflict-base-0/ci/scripts/vuln-report.sh new file mode 100755 index 0000000000..9bf3f6c169 --- /dev/null +++ b/.jjconflict-base-0/ci/scripts/vuln-report.sh @@ -0,0 +1,244 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + + +# Declare script helper +TEXT_HELPER="\nThis script aims to build a markdown style vulnerability report for the trivy json scan results. It will read the files and then build a markdown summary (compatible with Github issue) summarising the vulnerabilities for each image and configuration file for the given input folder. + +Following flags are available: + + -i Input folder that contain trivy json scan result files into subfolders 'images/' and 'configs/'. + Please provide the path without last slash (i.e not like this 'path/to/folder/'). + + -o Output file for the generated markdown report. + Please provide the path with the markdown file extension (i.e '.md'). + + -p Github project (format as '/'). + It can be set in GitHub Actions as '\${{ github.repository }}'. + + -r Github actions run ID. + It can be set in GitHub Actions as '\${{ github.run_id }}'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hi:o:p:r: flag +do + case "${flag}" in + i) + INPUT="${OPTARG}";; + o) + OUTPUT="${OPTARG}";; + p) + REPO="${OPTARG}";; + r) + RUN_ID="${OPTARG}";; + h | *) + print_help + exit 0;; + esac +done + + +NOW="$(date +'%Y-%m-%d at %H:%M')" +ARTIFACT_DOWNLOAD_URL="https://github.com/${REPO}/actions/runs/${RUN_ID}" + + +if [ -z "$INPUT" ] || [ -z "$OUTPUT" ] || [ -z "$REPO" ] || [ -z "$RUN_ID" ]; then + echo "Argument(s) missing, you should set all paramters." + print_help + exit 0 +fi + + +_jq() { + echo ${1} | base64 --decode | jq -r ${2} +} + + +printf "\n${red}${i}.${no_color} Build vulnerability report\n" +i=$(($i + 1)) + +VULNERABILITY_REPORT_BODY="# Security report + +:robot: This report was generated automatically the $NOW. + +It summarizes the result of a vulnerability scan of Trivy for each docker image and configuration file. +You should take a look at it and possibly modify some dependencies to fix vulnerabilities or update your configuration files." + +#--------------------# +# Images scan report # +#--------------------# +if [[ -n $(find $INPUT/images/*) ]]; then + FORMATED_REPORT=$(jq -s '.' $INPUT/images/* | jq '[ .[] | { + name: .ArtifactName, + type: .ArtifactType, + results: [ + .Results[] | select(.Vulnerabilities != null) | { + class: .Class, + target: .Target, + type: .Type, + length: .Vulnerabilities | (if length == 0 then "-" else length end), + vulnerabilityType: { + critical: [.Vulnerabilities[] | select(.Severity == "CRITICAL")] | (if length == 0 then "-" else length end), + high: [.Vulnerabilities[] | select(.Severity == "HIGH")] | (if length == 0 then "-" else length end), + medium: [.Vulnerabilities[] | select(.Severity == "MEDIUM")] | (if length == 0 then "-" else length end), + low: [.Vulnerabilities[] | select(.Severity == "LOW")] | (if length == 0 then "-" else length end), + unknown: [.Vulnerabilities[] | select(.Severity == "UNKNOWN")] | (if length == 0 then "-" else length end) + } + } + ] + } ]') + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +## Images" + + for row in $(echo "${FORMATED_REPORT}" | jq -r '.[] | @base64'); do + IMAGE_NAME=$(echo "$(_jq "${row}" '.')" | jq -r '.name') + OS_TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .length ] | add') + OS_CRITICAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.critical ] | add') + OS_HIGH_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.high ] | add') + OS_MEDIUM_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.medium ] | add') + OS_LOW_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.low ] | add') + OS_UNKNOWN_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.unknown ] | add') + + if [[ $OS_TOTAL_COUNT = 'null' ]]; then + IMAGE_SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + IMAGE_SCAN_SUMMARY=" +| Critical | High | Medium | Low | Unknown | Total | +|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:| +| $OS_CRITICAL_COUNT | $OS_HIGH_COUNT | $OS_MEDIUM_COUNT | $OS_LOW_COUNT | $OS_UNKNOWN_COUNT | $OS_TOTAL_COUNT| +" + fi + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +### \`$IMAGE_NAME\` + +**OS Packages** + +$IMAGE_SCAN_SUMMARY" + + DEP_TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "lang-pkgs") | .length ] | add') + if [[ $DEP_TOTAL_COUNT = 'null' ]]; then + SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + SCAN_SUMMARY=" +| Target | Critical | High | Medium | Low | Unknown | Total | +|:-----------|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:|" + + for row2 in $(echo "$(_jq ${row} '.')" | jq -r '.results[] | select(.class == "lang-pkgs") | @base64'); do + FULL_NAME=$(echo "$(_jq ${row2} '.')" | jq -r '.target') + TOTAL_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '.length') + CRITICAL_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.critical ] | add') + HIGH_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.high ] | add') + MEDIUM_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.medium ] | add') + LOW_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.low ] | add') + UNKNOWN_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.unknown ] | add') + + SCAN_SUMMARY="$SCAN_SUMMARY +| $FULL_NAME | $CRITICAL_COUNT | $HIGH_COUNT | $MEDIUM_COUNT | $LOW_COUNT | $UNKNOWN_COUNT | $TOTAL_COUNT |" + + done + fi + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY +**Language-specific packages** + +$SCAN_SUMMARY" + done + + SCAN_IMAGES=true +fi + +#--------------------# +# Config scan report # +#--------------------# +if [[ -n $(find $INPUT/configs/*) ]]; then + FORMATED_REPORT=$(jq -s '.' $INPUT/configs/* | jq '.[] | { + name: .ArtifactName, + type: .ArtifactType, + results: [ + .Results[] | select(.Misconfigurations != null) | { + class: .Class, + target: .Target, + type: .Type, + length: .Misconfigurations | (if length == 0 then "-" else length end), + vulnerabilityType: { + critical: [.Misconfigurations[] | select(.Severity == "CRITICAL")] | (if length == 0 then "-" else length end), + high: [.Misconfigurations[] | select(.Severity == "HIGH")] | (if length == 0 then "-" else length end), + medium: [.Misconfigurations[] | select(.Severity == "MEDIUM")] | (if length == 0 then "-" else length end), + low: [.Misconfigurations[] | select(.Severity == "LOW")] | (if length == 0 then "-" else length end), + unknown: [.Misconfigurations[] | select(.Severity == "UNKNOWN")] | (if length == 0 then "-" else length end) + } + } + ] + }') + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +## Configs" + + TOTAL_CONFIG=$(echo $FORMATED_REPORT | jq -r '[ .results[].length ] | add') + if [[ $TOTAL_CONFIG = 'null' ]]; then + SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + SCAN_SUMMARY=" +| Target | Critical | High | Medium | Low | Unknown | Total | +|:-----------|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:|" + fi + + for row in $(echo "${FORMATED_REPORT}" | jq -r '.results[] | @base64'); do + FULL_NAME=$(echo "$(_jq "${row}" '.')" | jq -r '.target') + TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '.length') + CRITICAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.critical ] | add') + HIGH_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.high ] | add') + MEDIUM_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.medium ] | add') + LOW_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.low ] | add') + UNKNOWN_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.unknown ] | add') + + SCAN_SUMMARY="$SCAN_SUMMARY +| $FULL_NAME | $CRITICAL_COUNT | $HIGH_COUNT | $MEDIUM_COUNT | $LOW_COUNT | $UNKNOWN_COUNT | $TOTAL_COUNT|" + + done + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY +$SCAN_SUMMARY" + + SCAN_CONFIG=true +fi + +if [ "$SCAN_DEPENDENCIES" = true ] || [ "$SCAN_CONFIG" = true ] || [ "$SCAN_IMAGES" = true ]; then + printf "\n${red}${i}.${no_color} Add artifacts download url to the vulnerability report body\n" + i=$(($i + 1)) + + VULNERABILITY_REPORT_BODY=$(cat < "$OUTPUT" +fi diff --git a/.jjconflict-base-0/commitlint.config.cjs b/.jjconflict-base-0/commitlint.config.cjs new file mode 100644 index 0000000000..070c7b1c34 --- /dev/null +++ b/.jjconflict-base-0/commitlint.config.cjs @@ -0,0 +1,9 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'body-leading-blank': [ + 2, // error + 'always', + ], + }, +}; diff --git a/.jjconflict-base-0/docker/docker-compose.ci.yml b/.jjconflict-base-0/docker/docker-compose.ci.yml new file mode 100644 index 0000000000..b8358950ea --- /dev/null +++ b/.jjconflict-base-0/docker/docker-compose.ci.yml @@ -0,0 +1,151 @@ +services: + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + ports: + - 8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - /opt/bitnami/keycloak/data/h2 + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + ports: + - 8081:80 + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: prod + image: dso-console/server:ci + container_name: dso-console_server + user: root + depends_on: + postgres: + condition: service_started + keycloak: + condition: service_healthy + volumes: + - ../packages/test-utils/src/imports/data.ts:/app/dist/init/db/imports/data.js + env_file: + - ../apps/server/.env.docker + environment: + NODE_ENV: production + CI: "true" + DEV_SETUP: "true" + networks: + - dso-network + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: prod + image: dso-console/server-nestjs:ci + container_name: dso-console_server-nestjs + depends_on: + postgres: + condition: service_started + keycloak: + condition: service_healthy + opencds-mockoon: + condition: service_healthy + env_file: + - ../apps/server-nestjs/.env.docker + environment: + NODE_ENV: "production" + CI: "true" + DEV_SETUP: "true" + OPENCDS_URL: "http://dso-opencds_mockoon:3100/api/v1" + OPENCDS_API_TOKEN: "token" + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: "false" + networks: + - dso-network + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:ci + container_name: dso-console_nginx-strangler + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + + client: + build: + context: .. + dockerfile: apps/client/Dockerfile + target: prod + image: dso-console/client:ci + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + user: root + volumes: + - ../apps/client/nginx/default.docker.conf:/etc/nginx/conf.d/default.conf + env_file: + - ../apps/client/.env.docker + environment: + OPENCDS_ENABLED: "true" + networks: + - dso-network + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:ci + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + name: dso-network + +volumes: + dso-keycloak-dsfr-theme: diff --git a/.jjconflict-base-0/docker/docker-compose.dev.yml b/.jjconflict-base-0/docker/docker-compose.dev.yml new file mode 100644 index 0000000000..98f2a31933 --- /dev/null +++ b/.jjconflict-base-0/docker/docker-compose.dev.yml @@ -0,0 +1,240 @@ +services: + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + ports: + - 8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - ../keycloak/data/dev:/opt/bitnami/keycloak/data/h2 + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-dev:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + - postgres + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-dev:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: dev + image: dso-console/server:dev + container_name: dso-console_server + command: [dev] + depends_on: + keycloak: + condition: service_healthy + postgres: + condition: service_started + ports: + - 4001:8080 + - 9229:9229 + env_file: + - ../apps/server/.env.docker + develop: + watch: + - action: sync + path: ../apps/server + target: /app/apps/server + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server/package.json + networks: + - dso-network + attach: true + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: dev + image: dso-console/server-nestjs:dev + container_name: dso-console_server-nestjs + command: [start:dev] + ports: + - 3001:3001 + depends_on: + keycloak: + condition: service_healthy + postgres: + condition: service_started + opencds-mockoon: + condition: service_healthy + env_file: + - ../apps/server-nestjs/.env.docker + develop: + watch: + - action: sync + path: ../apps/server-nestjs + target: /app/apps/server-nestjs + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - dist/ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server-nestjs/package.json + networks: + - dso-network + attach: true + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:dev + container_name: dso-console_nginx-strangler + ports: + - 4000:8080 + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + attach: false + + client: + restart: unless-stopped + build: + context: .. + dockerfile: apps/client/Dockerfile + target: dev + image: dso-console/client:dev + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + env_file: + - ../apps/client/.env.docker + develop: + watch: + - action: sync + path: ../apps/client + target: /app/apps/client + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: rebuild + path: ../apps/client/package.json + networks: + - dso-network + attach: true + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:dev + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + driver: bridge + +volumes: + dso-keycloak-dsfr-theme: + dso-pgadmin-data-dev: + dso-postgres-data-dev: diff --git a/.jjconflict-base-0/docker/docker-compose.integ.yml b/.jjconflict-base-0/docker/docker-compose.integ.yml new file mode 100644 index 0000000000..385f5e85e7 --- /dev/null +++ b/.jjconflict-base-0/docker/docker-compose.integ.yml @@ -0,0 +1,220 @@ +services: + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-integ:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + postgres: + condition: service_started + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-integ:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: dev + image: dso-console/server:integ + container_name: dso-console_server + command: [dev] + depends_on: + postgres: + condition: service_started + ports: + - 4001:8080 + - 9229:9229 + volumes: + - ${KUBECONFIG_HOST_PATH}:${KUBECONFIG_PATH:-/home/node/.kube/config} + - ${EXTERNAL_PLUGINS_DIR_HOST_PATH}:/plugins + env_file: + - ../apps/server/.env.docker + - ../apps/server/.env.integ + develop: + watch: + - action: sync + path: ../apps/server + target: /app/apps/server + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server/package.json + networks: + - dso-network + attach: true + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: dev + image: dso-console/server-nestjs:integ + container_name: dso-console_server-nestjs + command: [start:dev] + depends_on: + postgres: + condition: service_started + opencds-mockoon: + condition: service_healthy + ports: + - 3001:3001 + volumes: + - ${KUBECONFIG_HOST_PATH}:${KUBECONFIG_PATH:-/home/node/.kube/config} + - ${EXTERNAL_PLUGINS_DIR_HOST_PATH}:/plugins + env_file: + - ../apps/server-nestjs/.env.docker + - ../apps/server-nestjs/.env.integ + develop: + watch: + - action: sync + path: ../apps/server-nestjs + target: /app/apps/server-nestjs + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - dist/ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server-nestjs/package.json + networks: + - dso-network + attach: true + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:integ + container_name: dso-console_nginx-strangler + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + ports: + - 4000:8080 + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + attach: false + + client: + restart: unless-stopped + build: + context: .. + dockerfile: apps/client/Dockerfile + target: dev + image: dso-console/client:integ + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + env_file: + - ../apps/client/.env.docker + - ../apps/client/.env.integ + develop: + watch: + - action: sync + path: ../apps/client + target: /app/apps/client + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: rebuild + path: ../apps/client/package.json + networks: + - dso-network + attach: true + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:integ + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + driver: bridge + +volumes: + dso-pgadmin-data-integ: + dso-postgres-data-integ: diff --git a/.jjconflict-base-0/docker/docker-compose.local.yml b/.jjconflict-base-0/docker/docker-compose.local.yml new file mode 100644 index 0000000000..80473f1823 --- /dev/null +++ b/.jjconflict-base-0/docker/docker-compose.local.yml @@ -0,0 +1,146 @@ +services: + keycloak-fetch-theme: + restart: no + image: docker.io/curlimages/curl:8.8.0 + container_name: dso-console_keycloak-fetch + volumes: + - dso-keycloak-dsfr-theme:/tmp:rw + command: -LfS -o /tmp/keycloak-theme-dsfr.jar https://github.com/codegouvfr/keycloak-theme-dsfr/releases/download/v2.3.3/keycloak-theme-for-kc-26.0-to-26.1.jar + networks: + - dso-network + attach: false + + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + depends_on: + keycloak-fetch-theme: + condition: service_completed_successfully + ports: + - 127.0.0.1:8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - ../keycloak/data/dev:/opt/bitnami/keycloak/data/h2 + - type: volume + source: dso-keycloak-dsfr-theme + target: /opt/bitnami/keycloak/providers/keycloak-theme-dsfr.jar + volume: + subpath: keycloak-theme-dsfr.jar + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + DSFR_THEME_HOME_URL: http://localhost:8080 + DSFR_THEME_SERVICE_TITLE: Console Cloud π Native + DSFR_THEME_BRAND_TOP: Ministère
de l'Intérieur
et des Outre-Mer + DSFR_THEME_TOS_URL: + DSFR_THEME_CONTACT_EMAIL: cloudpinative-relations@interieur.gouv.fr + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-local:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:local + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + postgres: + condition: service_started + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-local:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + nginx-strangler: + # En mode local (pnpm dev), server et server-nestjs tournent nativement sur la machine. + # Le nginx-strangler reste en Docker et atteint les processes natifs via host.docker.internal. + # (voir .env-example de chaque app). + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:local + container_name: dso-console_nginx-strangler + extra_hosts: + - host.docker.internal:${HOST_GATEWAY_IP:-host-gateway} + ports: + - 4000:8080 + environment: + LEGACY_UPSTREAM: "host.docker.internal:4001" + NESTJS_UPSTREAM: "host.docker.internal:3001" + networks: + - dso-network + attach: false + + jaeger: + restart: unless-stopped + image: jaegertracing/all-in-one:1.76.0 + container_name: dso-console_jaeger + environment: + COLLECTOR_ZIPKIN_HOST_PORT: :9411 + ports: + - 16686:16686 + - 4317:4317 + - 4318:4318 + - 14250:14250 + - 14268:14268 + - 14269:14269 + - 9411:9411 + networks: + - dso-network + attach: false + +networks: + dso-network: + driver: bridge + +volumes: + dso-keycloak-dsfr-theme: + dso-keycloak-data-local: + dso-pgadmin-data-local: + dso-postgres-data-local: diff --git a/.jjconflict-base-0/eslint.config.js b/.jjconflict-base-0/eslint.config.js new file mode 100644 index 0000000000..50992e5703 --- /dev/null +++ b/.jjconflict-base-0/eslint.config.js @@ -0,0 +1,9 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase.append({ + ignores: [ + 'apps/', + 'packages/', + 'plugins/', + ], +}) diff --git a/.jjconflict-base-0/keycloak/mock-sso.md b/.jjconflict-base-0/keycloak/mock-sso.md new file mode 100644 index 0000000000..cd9b5c2982 --- /dev/null +++ b/.jjconflict-base-0/keycloak/mock-sso.md @@ -0,0 +1,34 @@ +# Keycloak + +## Development + +### Start only keycloak + +Go at the root level of the git project : + +```sh +# Go at the root level of the git project +cd `git rev-parse --show-toplevel` + +# Start keycloak +docker compose -f ./docker/docker-compose.dev.yml up keycloak +``` + +Admin page url: + +__*Login:*__ `admin` +__*Password:*__ `admin` + +## Docs + +### Get Started + + + +### Other doc + +* [Keycloak](https://github.com/keycloak/keycloak) - Keycloak Server and Java adapters +* [Keycloak Documentation](https://github.com/keycloak/keycloak-documentation) - Documentation for Keycloak +* [Keycloak QuickStarts](https://github.com/keycloak/keycloak-quickstarts) - QuickStarts for getting started with Keycloak +* [Keycloak Node.js Connect](https://github.com/keycloak/keycloak-nodejs-connect) - Node.js adapter for Keycloak +* [Keycloak Node.js Admin Client](https://github.com/keycloak/keycloak-nodejs-admin-client) - Node.js library for Keycloak Admin REST API diff --git a/.jjconflict-base-0/keycloak/realms/realm-dev.json b/.jjconflict-base-0/keycloak/realms/realm-dev.json new file mode 100644 index 0000000000..f7e932d8c0 --- /dev/null +++ b/.jjconflict-base-0/keycloak/realms/realm-dev.json @@ -0,0 +1,577 @@ +{ + "realm": "dso", + "enabled": true, + "accessTokenLifespan": 300, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "ssoSessionIdleTimeout": 600, + "ssoSessionMaxLifespan": 36000, + "sslRequired": "none", + "registrationAllowed": false, + "privateKey": "MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=", + "publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "requiredCredentials": ["password"], + "attributes": { + "frontendUrl": "http://localhost:8090" + }, + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "groups": [ + { + "id": "07169ee1-d967-48da-a784-1b1ccbdaea7a", + "name": "admin", + "path": "/admin", + "attributes": {}, + "realmRoles": [], + "clientRoles": {}, + "subGroups": [] + } + ], + "users": [ + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6565", + "username": "test", + "enabled": true, + "email": "test@test.com", + "emailVerified": true, + "firstName": "Jean", + "lastName": "DUPOND", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6566", + "username": "tcolin", + "enabled": true, + "email": "thibault.colin@test.com", + "emailVerified": true, + "firstName": "Thibault", + "lastName": "COLIN", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + }, + "groups": ["admin"] + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6567", + "username": "cnollet", + "enabled": true, + "email": "claire.nollet@test.com", + "emailVerified": true, + "firstName": "Claire", + "lastName": "NOLLET", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "58e13168-d533-446b-b6a1-936da0a9d7b7", + "username": "lpoumailloux", + "enabled": true, + "email": "leo.poumailloux@test.com", + "emailVerified": true, + "firstName": "Léo", + "lastName": "POUMAILLOUX", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6568", + "username": "btran", + "enabled": true, + "email": "baudoin.tran@test.com", + "emailVerified": true, + "firstName": "Baudoin", + "lastName": "TRAN", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6569", + "username": "atardif", + "enabled": true, + "email": "arnaud.tardif@test.com", + "emailVerified": true, + "firstName": "Arnaud", + "lastName": "TARDIF", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "387216f1-3b87-4211-9cac-4371125e1175", + "username": "admin", + "enabled": true, + "email": "admin@test.com", + "emailVerified": true, + "firstName": "Admin", + "lastName": "ADMIN", + "credentials": [ + { + "type": "password", + "value": "admin" + } + ], + "realmRoles": ["user", "admin"], + "clientRoles": { + "realm-management": ["realm-admin"], + "account": ["manage-account"] + }, + "groups": ["admin"] + } + ], + "roles": { + "realm": [ + { + "name": "user", + "description": "User privileges" + }, + { + "name": "admin", + "description": "Administrator privileges" + } + ] + }, + "clients": [ + { + "id": "e7d105eb-28d4-4557-a274-eb354a60a33b", + "clientId": "dso-console-frontend", + "name": "dso-console-frontend", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["*"], + "webOrigins": ["*"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "id.token.as.detached.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "require.pushed.authorization.requests": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": ["generic", "basic"], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "access": { + "view": true, + "configure": true, + "manage": true + } + }, + { + "id": "e7d105eb-28d4-4557-a274-eb354a60a33c", + "clientId": "dso-console-backend", + "name": "dso-console-backend", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "client-secret-backend", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "id.token.as.detached.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "require.pushed.authorization.requests": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": ["generic"], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "access": { + "view": true, + "configure": true, + "manage": true + } + } + ], + "clientScopes": [ + { + "id": "41b313bd-c5dc-448f-8056-007d25e42027", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "a067866c-7812-47c6-aa28-9e3421fd673b", + "name": "basic", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "910359ad-0368-44dd-b10c-d1cae6c6dd17", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "1611458d-38ab-4a9f-859e-1812d251c1ac", + "name": "generic", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "e0491f43-3e4c-4649-b16e-3c0e47bbc2c0", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "000f3a6c-bec3-40dd-8397-0c45019df7e0", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "2acd698c-03d2-4d0b-99a4-db00ed08f25d", + "name": "name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "13e8d53a-415f-4f98-a133-8795e0173efd", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "5a8652b6-9a42-48e5-819f-59259c349883", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "9d090281-1d17-4283-8fb7-183e1b536328", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "79bccac1-d0f1-4c28-a040-e0e640889e52", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "5ca2ebe3-ef32-4f62-bcad-e764c70d20e6", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "7d976f67-441d-4491-a1fe-f17343a03120", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "bb8a7b6e-2f1a-46b2-91e1-8d027dabe93d", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "84629e2c-c129-4d49-bb04-a03a5124ed47", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "f5c4b940-47ad-4734-8712-4f53357cff1c", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "e1e82435-1b52-4b41-aa0e-14a3e9779670", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "userinfo.token.claim": "true" + } + }, + { + "id": "c3803e56-b542-45e3-a780-0fb70841dd5c", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + } + ], + "loginWithEmailAllowed": true, + "registrationEmailAsUsername": false, + "loginTheme": "DSFR", + "accountTheme": "DSFR", + "adminTheme": "DSFR", + "emailTheme": "DSFR", + "internationalizationEnabled": true, + "supportedLocales": ["en", "fr"], + "defaultLocale": "fr" +} diff --git a/.jjconflict-base-0/migrations/v9/Dockerfile b/.jjconflict-base-0/migrations/v9/Dockerfile new file mode 100644 index 0000000000..5365c15bc1 --- /dev/null +++ b/.jjconflict-base-0/migrations/v9/Dockerfile @@ -0,0 +1,7 @@ +FROM docker.io/node:22.14.0-bullseye-slim +WORKDIR /app +COPY ./package.json package.json +RUN npm i +COPY ./migrate.js migrate.js +CMD [ "npm", "start" ] +USER node diff --git a/.jjconflict-base-0/migrations/v9/Migrate v9-FR.md b/.jjconflict-base-0/migrations/v9/Migrate v9-FR.md new file mode 100644 index 0000000000..f27cb4619f --- /dev/null +++ b/.jjconflict-base-0/migrations/v9/Migrate v9-FR.md @@ -0,0 +1,42 @@ +# Migration vers la v9 +## Qu'est-ce qui change ? +Nous avons pris la décision d'abandonner la fonctionnalité d'organisation, dès lors le projet ne sera plus rattaché à rien. Cela entraîne quelques problèmes de suivi des ressources, mais si vous suivez les instructions ci-dessous, tout ira bien. + +Les organisations reviendront à l'avenir, mais sous une forme complètement différente. + +## IMPORTANT +Sauvegardez votre instance gitlab et le kv Vault !!! + +## Préparation des ressources +Depuis la version 8.22.0, le serveur api stocke son numéro de version pour chaque projet provisionné avec succès. + +Avant de passer à la version 9, vous DEVEZ provisionner tous les projets actifs au moins à la version 8.23.0. Si vous ne le faites pas, le script de migration SQL lèvera une exception. + +Un bon suivi des ressources est essentiel. Si un projet ne peut pas être provisionné avec succès, vous pouvez toujours changer sa valeur dans la base de données directement, mais assurez-vous que les balises suivantes sont appliquées sur les ressources kubernetes correspondantes : +- dso/project.id : (namespace, applications, appprojects) +- dso/project.slug : (namespace, applications, appprojects) +- dso/environment.id : (namespace, applications, appprojects) +- dso/repository.id : (namespace, applications) + +## Migration des ressources utilisateurs +Vous pouvez lancer cette image (une seule fois) avant ou après la mise à jour vers la v9. + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: migrate-v9 +spec: + containers: + - image: ghcr.io/cloud-pi-native/migration:v9 + name: migrate-v9 + resources: {} + envFrom: + - secretRef: + name: dso-config + restartPolicy: Never +``` +kubectl run --image docker ghcr.io/cloud-pi-native/migration:v9 + +### À propos de Gitlab +Parce que gitlab effectue de nombreuses tâches de manière asynchrone, nous ne pouvons pas supprimer les anciens groupes d'organisation après avoir transféré les groupes de projet. Vous devrez vous connecter manuellement pour les rechercher et les supprimer. Si vous ne le faites pas, ce n'est pas grave... diff --git a/.jjconflict-base-0/migrations/v9/Migrate v9.md b/.jjconflict-base-0/migrations/v9/Migrate v9.md new file mode 100644 index 0000000000..9b7e94bacd --- /dev/null +++ b/.jjconflict-base-0/migrations/v9/Migrate v9.md @@ -0,0 +1,42 @@ +# Migrating to v9 +## What's changing? +We've taken the decision to abandon the organization feature, so the project won't be attached to anything. This is causing some resource tracking problems, but if you follow the instructions below, you'll be fine. + +Organizations will return in future, but in a completely different form. + +## IMPORTANT +Backup your gitlab instance and the vault KV !!! + +## Resource preparation +Since version 8.22.0, the api server stores its version number for each successfully provisioned project. + +Before upgrading to version 9, you MUST provision all active projects to at least version 8.23.0. If you fail to do so, the SQL migration script will throw an exception. + +Good resource tracking is essential. If a project cannot be provisioned successfully, you can always change its value in the database directly, but make sure the following tags are applied on the corresponding kubernetes resources: +- dso/project.id : (namespace, applications, appprojects) +- dso/project.slug : (namespace, applications, appprojects) +- dso/environment.id : (namespace, applications, appprojects) +- dso/repository.id : (namespace, applications) + +## Migrate user resources +You can run this image (once only) before or after upgrading to v9 + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: migrate-v9 +spec: + containers: + - image: ghcr.io/cloud-pi-native/migration:v9 + name: migrate-v9 + resources: {} + envFrom: + - secretRef: + name: dso-config + restartPolicy: Never +``` +kubectl run --image docker ghcr.io/cloud-pi-native/migration:v9 + +### About Gitlab +Because gitlab performs many tasks asynchronously, we can't delete the old organization groups after transferring the project groups. You'll have to log in manually to search for and delete them. If you don't, it's not a big deal... diff --git a/.jjconflict-base-0/migrations/v9/migrate.js b/.jjconflict-base-0/migrations/v9/migrate.js new file mode 100644 index 0000000000..ed8d048084 --- /dev/null +++ b/.jjconflict-base-0/migrations/v9/migrate.js @@ -0,0 +1,142 @@ +import { logger as baseLogger } from '@cpn-console/logger/hooks' +import { Gitlab } from '@gitbeaker/rest' +import axios from 'axios' + +const logger = baseLogger.child({ scope: 'migration:v9' }) + +export function removeTrailingSlash(url) { + return url?.endsWith('/') + ? url.slice(0, -1) + : url +} + +export function requiredEnv(envName) { + const envValue = process.env[envName] + if (envValue) return envValue + + throw new Error(`env: ${envName} is not defined !`) +} + +const gitlabToken = requiredEnv('GITLAB_TOKEN') +const gitlabPublicUrl = removeTrailingSlash(requiredEnv('GITLAB_URL')) +const projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') +const gitlabInternalUrl = process.env.GITLAB_INTERNAL_URL + ? removeTrailingSlash(process.env.GITLAB_INTERNAL_URL) + : gitlabPublicUrl + +const vaultPublicUrl = removeTrailingSlash(requiredEnv('VAULT_URL')) + +const axiosInstance = axios.create({ + baseURL: vaultPublicUrl, + headers: { + 'X-Vault-Token': requiredEnv('VAULT_TOKEN'), + }, +}) + +const api = new Gitlab({ token: gitlabToken, host: gitlabInternalUrl }) + +const groupRootSearch = await api.Groups.search(projectsRootDir) +const groupRootId = (groupRootSearch.find(grp => grp.full_path === projectsRootDir))?.id + +const organizationGroups = await api.Groups.allDescendantGroups(groupRootId, { perPage: 300 }) + +logger.info({ organizationGroupsCount: organizationGroups.length }, 'Loaded organization groups') +if (organizationGroups.length > 300) { + throw new Error('increase perPage, you could miss some results') +} + +for (const organizationGroup of organizationGroups) { + if (organizationGroup.name === 'Infra') continue + logger.info({ organizationGroupId: organizationGroup.id, organizationGroupName: organizationGroup.name }, 'Processing organization group') + const projectGroups = await api.Groups.allDescendantGroups(organizationGroup.id, { perPage: 300 }) + if (projectGroups.length > 300) { + throw new Error('increase perPage, you could miss some projects group results') + } + + for (const projectGroup of projectGroups) { + const newName = `${organizationGroup.name}-${projectGroup.name}` + logger.info({ projectGroupId: projectGroup.id, newName }, 'Renaming and transferring project group') + + try { + const renamedGroup = await api.Groups.edit(projectGroup.id, { name: newName, path: newName }) + await api.Groups.transfer(renamedGroup.id, { groupId: groupRootId }) + } catch (error) { + logger.warn({ err: error, projectGroupId: projectGroup.id, organizationGroupId: organizationGroup.id }, 'Could not rename/transfer project group') + } + } +} + +const coreKvName = 'forge-dso' + +const vaultToken = (await axiosInstance.post('/v1/auth/token/create')) + .data.auth.client_token + +function transformPath(path) { + if (!path.startsWith('/')) { + path = `/${path}` + } + const parts = path.split('/') + return `/${parts[1]}-${parts[2]}/${parts.slice(3).join('/')}` +} +const secretsMapper = {} + +async function list(path = '/') { + if (!path.startsWith('/')) + path = `/${path}` + const response = await axiosInstance({ + url: `/v1/${coreKvName}/metadata/${projectsRootDir}${path}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + method: 'list', + validateStatus: code => [200, 404].includes(code), + }) + + if (response.status === 404) return [] + for (const key of response.data.data.keys) { + if (key.endsWith('/')) { + await list(`${path}${key}`) + } else { + secretsMapper[`${path}${key}`] = transformPath(`${path}${key}`) + } + } +} + +try { + await list() + logger.info({ secretsCount: Object.keys(secretsMapper).length }, 'Collected secrets mapping') +} catch (error) { + logger.error({ err: error }, 'Failed while collecting secrets mapping') +} + +for (const [source, destination] of Object.entries(secretsMapper)) { + const secretContent = await axiosInstance({ + url: `/v1/${coreKvName}/data/${projectsRootDir}${source}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + method: 'get', + validateStatus: code => [200, 404].includes(code), + }) + const data = secretContent.data.data.data + + try { + await axiosInstance({ + method: 'POST', + url: `/v1/${coreKvName}/data/${projectsRootDir}${destination}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + data: { data }, + }) + await axiosInstance({ + method: 'delete', + url: `/v1/${coreKvName}/metadata/${projectsRootDir}${source}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + }) + } catch (error) { + logger.error({ err: error, source, destination }, 'Failed while moving secret') + } +} diff --git a/.jjconflict-base-0/migrations/v9/package.json b/.jjconflict-base-0/migrations/v9/package.json new file mode 100644 index 0000000000..c49c17280c --- /dev/null +++ b/.jjconflict-base-0/migrations/v9/package.json @@ -0,0 +1,15 @@ +{ + "name": "dso-v9-migration", + "version": "1.0.0", + "description": "", + "author": "Cloud Pi Native", + "license": "ISC", + "main": "index.js", + "scripts": { + "start": "node migrate.js" + }, + "dependencies": { + "@gitbeaker/rest": "catalog:runtime", + "axios": "catalog:runtime" + } +} \ No newline at end of file diff --git a/.jjconflict-base-0/misc/business-rules.md b/.jjconflict-base-0/misc/business-rules.md new file mode 100644 index 0000000000..04f1a4fee9 --- /dev/null +++ b/.jjconflict-base-0/misc/business-rules.md @@ -0,0 +1,63 @@ +# Console Cloud π Native + +## Règles métier + +### Formulaire de commande d'un espace projet + +| Champ | Type | Description | +| --------------------- | ------ | ------------------------------------- | +| Nom de l'organisation | string | obligatoire | +| Nom du projet | string | obligatoire, `regex([a-z0-9-]{2,53})` | + +### Formulaire de synchronisation d'un dépôt + +| Champ | Type | Description | +| ----------------------------------------------- | ------- | ----------------------------------------- | +| Nom du dépôt Git interne | string | obligatoire, `regex([a-z0-9-]{2,53})` | +| Url du dépôt Git externe | string | obligatoire, `regex(^https://)` | +| Dépôt externe privé | boolean | obligatoire | +| Nom d'utilisateur lié au token du dépôt externe | string | obligatoire si le dépôt externe est privé | +| Token d'accès au dépôt externe | string | obligatoire si le dépôt externe est privé | +| Dépôt d'infrastructure | boolean | obligatoire | + +### Status et verrouillage + +#### Schema + +- un `project` peut avoir pour `status: Enum('initializing', 'created', 'failed', 'archived')` +- un `repository` et un `environnement` peuvent avoir pour `status: Enum('initializing', 'created', 'failed', 'deleting')` +- un `project` peut être `locked (boolean)` + +#### Principe + +- `initializing` : la ressource est en cours de création, des opérations sont en cours côté `plugins` - le `project` est `locked: true` +- `created` : la ressource est créée, les opérations des `plugins` se sont bien déroulées - le `project` est `locked: false` +- `failed` : la ressource est créée, les opérations des `plugins` ont échoué - le `project` est `locked: true` +- `deleting` : la ressource est en cours de suppression, des opérations sont en cours côté `plugins` - le `project` est `locked: true` +- `archived` : les opérations des `plugins` se sont bien déroulées, le projet et son équipe sont toujours présents en base de données pour archive, mais invisibles des utilisateurs hors `admin` - le `project` est `locked: true` +- `project.locked: true` : toute opération de Create / Update sur le projet et ses ressource est interdite. + +### Permissions d'environnement + +Trois niveaux de permissions différents, +| Valeur en base | Description dans le front | Niveaux | +| -------------- | ------------------------- | ------------- | +| 0 | r | Lecture | +| 1 | rw | + Ecriture | +| 2 | rwd | + Suppression | + +## Points d'API + +voir swagger : + +--- + +## TODO: Idées + +### Admin + +- Lister chacune des tables +- Mise en place d'une table d'historisation des status des projets +- Mettre en place un point d'api pour gérer l'équipe d'admin (création/suppression) +- Possibilité de réexecuter dans keycloak les droits présents dans la DB (synchro DB - keycloak) +- Route qui récapitule l'ensemble des ressources argo et supprime toutes les autres diff --git a/.jjconflict-base-0/misc/controller-plugins-communication.png b/.jjconflict-base-0/misc/controller-plugins-communication.png new file mode 100644 index 0000000000..b446155dc5 Binary files /dev/null and b/.jjconflict-base-0/misc/controller-plugins-communication.png differ diff --git a/.jjconflict-base-0/misc/controllers.md b/.jjconflict-base-0/misc/controllers.md new file mode 100644 index 0000000000..d194fec2fd --- /dev/null +++ b/.jjconflict-base-0/misc/controllers.md @@ -0,0 +1,29 @@ +# Logique controllers + +```mermaid +flowchart TD + A[(db: find prérequis)] + A -->|KO| B>send code 400, 401, 403, 404] + A -->|try| D(db: update projectLocked = true + status = initializing + upsert/delete + hook: plugins) + D -->|catch| E[(db: update status = failed)] + E --> B + D -->|OK| G[(db: update projectLocked = false + status = created)] + G -->|OK| H>send code 200, 201] + G -->|catch| E +``` + +## Principe + +### Controller + +- reçoit la requête +- appelle la couche business +- renvoie la réponse + +### Business + +- est appelé par le controller +- appelle l'ORM pour les opérations en bdd +- appelle éventuellement les plugins +- gère les erreurs (try catch) +- renvoie un résultat ou une erreur au controller diff --git a/.jjconflict-base-0/misc/images/environments.png b/.jjconflict-base-0/misc/images/environments.png new file mode 100644 index 0000000000..2c9f47a130 Binary files /dev/null and b/.jjconflict-base-0/misc/images/environments.png differ diff --git a/.jjconflict-base-0/misc/images/members.png b/.jjconflict-base-0/misc/images/members.png new file mode 100644 index 0000000000..bcd9955ba6 Binary files /dev/null and b/.jjconflict-base-0/misc/images/members.png differ diff --git a/.jjconflict-base-0/misc/images/projects.png b/.jjconflict-base-0/misc/images/projects.png new file mode 100644 index 0000000000..de48c1d125 Binary files /dev/null and b/.jjconflict-base-0/misc/images/projects.png differ diff --git a/.jjconflict-base-0/misc/images/repositories.png b/.jjconflict-base-0/misc/images/repositories.png new file mode 100644 index 0000000000..b1029c947d Binary files /dev/null and b/.jjconflict-base-0/misc/images/repositories.png differ diff --git a/.jjconflict-base-0/misc/images/services.png b/.jjconflict-base-0/misc/images/services.png new file mode 100644 index 0000000000..4b346ebf31 Binary files /dev/null and b/.jjconflict-base-0/misc/images/services.png differ diff --git a/.jjconflict-base-0/misc/images/status.png b/.jjconflict-base-0/misc/images/status.png new file mode 100644 index 0000000000..472e57d330 Binary files /dev/null and b/.jjconflict-base-0/misc/images/status.png differ diff --git a/.jjconflict-base-0/misc/plugins.md b/.jjconflict-base-0/misc/plugins.md new file mode 100644 index 0000000000..b062c3285d --- /dev/null +++ b/.jjconflict-base-0/misc/plugins.md @@ -0,0 +1,67 @@ +# Plugins + +## Lexique + +Un plugin `core` est un service de base associé à la chaîne DSO. +Un plugin `external` est un service supplémentaire facultatif. +Un `hook` est un point de déclenchement dont le cycle de vie comprend plusieurs étapes (`step`) : `pre`, `main`, `post`, etc. +Tout `plugin` peut s'enregistrer sur un `hook`, à une étape donnée. + +## Principe + +Exemple : + +1. Le plugin `gitlab` s'enregistre sur le hook `createProject`, à l'étape `main`. +2. Le controller `createProjectController` déclenche le hook `createProject` en lui associant un `payload`. +3. Le plugin `gitlab` réagit au déclenchement du hook `createProject` et reçoit le payload associé du controller. +4. Le plugin `gitlab` effectue ses opérations (ex: création d'un groupe GitLab pour le projet). +5. Le plugin `gitlab` renvoie un objet `result` au controller `createProjectController`, contenant un `status` indiquant si tout s'est déroulé sans erreur ou non. +6. Le controller `createProjectController` enregistre ce `result` en base de donnée et s'arrête si le `status` est en erreur. + +## Modèles + +### Entries + +```js +{ + // args come from controllers + args: { + projectName: 'toto', + }, + failed: true || undefined + /* + [pluginName]: + [pluginName]: + ... + */ +} +``` + +### Result + +Note: Si un module réutilise son propre payload, il doit penser à renvoyer le précédent s'il ne veut pas l'écraser. Charge aussi au plugin de gérer les erreurs des étapes précédentes. + +```js +{ + status: { + result: string('KO' || 'OK'), + message: string(), + }, + vault: [ + { + name: string(), + data: { + // secret data + } + } + ] || undefined + // otherKey: {}, + // anotherKey: {}, +} +``` + +## Notes + +La communication des services associés à la chaîne DSO se fait au maximum en direct par les services Kubernetes via les url internes. On note toutefois les exceptions suivantes : +- Argocd communique avec l'url publique de Gitlab peu importe s'ils sont sur le même cluster. +- L'url publique de Harbor est utilisée dans les jobs de CI : il faudrait sinon une `imagePullSecret` avec url interne pour les jobs de CI et une avec url publique pour les pods des clusters applicatifs. diff --git a/.jjconflict-base-0/package.json b/.jjconflict-base-0/package.json new file mode 100644 index 0000000000..70b33dd885 --- /dev/null +++ b/.jjconflict-base-0/package.json @@ -0,0 +1,66 @@ +{ + "name": "@cpn-console/root", + "type": "module", + "version": "9.22.2", + "private": true, + "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "engines": { + "node": ">=24.0.0 <25.0.0", + "npm": ">=11.0.0" + }, + "scripts": { + "build": "pnpm -r run build", + "build:clean": "pnpm -r run build:clean", + "db:generate": "pnpm -r run db:generate", + "dev": "docker compose -f ./docker/docker-compose.local.yml up -d --remove-orphans && echo '\nStart applications with commands:\n\n pnpm --filter @cpn-console/server run dev\n pnpm --filter @cpn-console/client run dev\n pnpm --filter @cpn-console/server-nestjs run dev\n\nMake sure you provide env variables in `.env` files.'", + "dev:clean": "docker compose -f ./docker/docker-compose.local.yml down --remove-orphans", + "dev:delete": "docker compose -f ./docker/docker-compose.local.yml down -v --remove-orphans", + "docker:dev": "docker compose -f ./docker/docker-compose.dev.yml up --menu", + "docker:dev:build": "export COMPOSE_FILE=./docker/docker-compose.dev.yml && ./scripts/run-build.sh $COMPOSE_FILE", + "docker:dev:clean": "docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans", + "docker:dev:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:dev' -q) ; true", + "docker:e2e": "docker compose -f ./docker/docker-compose.dev.yml up ${TAG:+--no-build} -d --remove-orphans; pnpm playwright:test; docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans", + "docker:e2e-ci": "docker compose -f ./docker/docker-compose.ci.yml up ${TAG:+--no-build} -d --remove-orphans; pnpm playwright:test", + "docker:e2e-ci:delete": "docker compose -f ./docker/docker-compose.ci.yml down -v --remove-orphans", + "docker:integ": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.integ.yml up --menu", + "docker:integ:build": "export $(cat apps/server/.env.integ | grep -v '#' | xargs) && export COMPOSE_FILE=./docker/docker-compose.integ.yml && cd $(dirname $COMPOSE_FILE) && docker buildx bake --allow=fs.read=.. --file $(basename $COMPOSE_FILE) --load && cd - > /dev/null", + "docker:integ:clean": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.integ.yml down --remove-orphans", + "docker:integ:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:integ' -q) ; true", + "docker:ci": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.ci.yml up --menu", + "docker:ci:build": "export $(cat apps/server/.env.integ | grep -v '#' | xargs) && export COMPOSE_FILE=./docker/docker-compose.ci.yml && cd $(dirname $COMPOSE_FILE) && docker buildx bake --allow=fs.read=.. --file $(basename $COMPOSE_FILE) --load && cd - > /dev/null", + "docker:ci:clean": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.ci.yml down --remove-orphans", + "docker:ci:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:ci' -q) ; true", + "format": "pnpm -r run format", + "format:root": "eslint . --fix", + "format:style": "pnpm -r run format:style", + "fullclean": "sh ./ci/scripts/cleanup.sh", + "fullsetup": "sh ./ci/scripts/setup.sh", + "integ": "docker compose -f ./docker/docker-compose.local.yml up -d --remove-orphans postgres pgadmin && echo '\nStart server and client with commands:\n\n pnpm --filter @cpn-console/server run integ\n pnpm --filter @cpn-console/client run integ\n pnpm --filter @cpn-console/server-nestjs run integ\n\nMake sure you provide env variables in `.env.integ` files.'", + "integ:clean": "docker compose -f ./docker/docker-compose.local.yml down --remove-orphans", + "integ:delete": "docker compose -f ./docker/docker-compose.local.yml down -v --remove-orphans", + "lint": "pnpm -r run lint", + "lint:root": "eslint .", + "lint:style": "pnpm -r run lint:style", + "lint:ts": "pnpm -r run lint:ts", + "playwright:test": "pnpm --dir playwright run playwright:test", + "playwright:test:ui": "pnpm --dir playwright run playwright:test:ui", + "playwright:test:integration": "pnpm --dir playwright run playwright:test:integration", + "prepare": "husky", + "test": "pnpm -r --no-bail run test", + "test:cov": "pnpm -r --no-bail run test:cov", + "test:e2e": "pnpm kube:e2e", + "test:e2e-ci": "pnpm kube:prod; pnpm kube:e2e-ci" + }, + "devDependencies": { + "@commitlint/cli": "catalog:tools", + "@commitlint/config-conventional": "catalog:tools", + "@cpn-console/eslint-config": "workspace:^", + "eslint": "catalog:tools", + "husky": "catalog:tools", + "lint-staged": "catalog:tools" + } +} diff --git a/.jjconflict-base-0/packages/eslintconfig/eslint.config.js b/.jjconflict-base-0/packages/eslintconfig/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-0/packages/eslintconfig/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-0/packages/eslintconfig/package.json b/.jjconflict-base-0/packages/eslintconfig/package.json new file mode 100644 index 0000000000..e81e294cbe --- /dev/null +++ b/.jjconflict-base-0/packages/eslintconfig/package.json @@ -0,0 +1,21 @@ +{ + "name": "@cpn-console/eslint-config", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "type": "module", + "version": "1.0.2", + "license": "ISC", + "main": "src/index.js", + "types": "types/index.d.ts", + "devDependencies": { + "@antfu/eslint-config": "catalog:tools", + "eslint": "catalog:tools" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/packages/eslintconfig/src/index.js b/.jjconflict-base-0/packages/eslintconfig/src/index.js new file mode 100644 index 0000000000..79867556e6 --- /dev/null +++ b/.jjconflict-base-0/packages/eslintconfig/src/index.js @@ -0,0 +1,41 @@ +import antfu from '@antfu/eslint-config' + +export default antfu( + { + stylistic: { + overrides: { + 'antfu/consistent-chaining': 'off', + 'antfu/if-newline': 'off', + 'antfu/no-top-level-await': 'off', + 'node/prefer-global/process': ['error', 'always'], + 'node/prefer-global/buffer': ['error', 'always'], + 'style/comma-dangle': ['error', 'always-multiline'], + 'style/quote-props': ['error', 'as-needed', { keywords: false, unnecessary: true }], + 'style/brace-style': ['error', '1tbs', { allowSingleLine: true }], + 'style/max-statements-per-line': ['error', { max: 2 }], + 'ts/ban-ts-comment': 'off', + }, + }, + typescript: true, + vue: true, + yaml: { + overrides: { + 'yaml/quotes': ['error', { prefer: 'double' }], + 'yaml/indent': ['error', 2, { indentBlockSequences: true, indicatorValueIndent: 2 }], + }, + }, + ignores: [ + '**/node_modules', + '**/prisma/migrations', + '**/pnpm-lock.yaml', + '**/dist/', + '**/types/', + '**/coverage/', + '**/templates/*.{yaml,yml}', + '**/Chart.yaml', + '**/*.d.ts', + '**/*.md/*.js', + '**/*.md/*.ts', + ], + }, +) diff --git a/.jjconflict-base-0/packages/hooks/.gitignore b/.jjconflict-base-0/packages/hooks/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-0/packages/hooks/README.md b/.jjconflict-base-0/packages/hooks/README.md new file mode 100644 index 0000000000..e0149e15c8 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/README.md @@ -0,0 +1,245 @@ +# Plugin manager + +## Présentation + +Le plugin manager ou communément appelés "hooks" est une interface permettant au serveur d'api (`apps/server`) de déclencher des hooks "déclencheurs / événements" pour que les plugins puissent piloter et configurer des outils. + +Les plugins obligatoires de l'application sont souvent appelés `corePlugins`. + +L'objectif est que le server soit totalement agnostique des actions des plugins et même de leur existence. De plus le serveur d'API essayera au démarrage de charger des plugins dans un dossier spécifique, on les distingue en les appelant `externalPlugins`. + +## Développement de plugin + +Cette section est dédiée à tous ceux voulant améliorer les corePlugins ou en créer de nouveaux. + +Pour commencer je vous conseille d'aller regarder le [plugin vault](../../plugins/vault) + +### Interface + +Tout paquet existant devrait avoir un export nommé `plugin` et de type `Plugin`. + +Ce typage vous assurera de bien fournir toutes les clés nécessaires au bon fonctionnement du plugin : + +```ts +// index.ts +import type { Plugin } from '@cpn-console/hooks' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: {}, + monitor, +} +``` + +### Infos + +Ce sont les infos de bases de votre application : + +```ts +// infos.ts +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos: ServiceInfos = { + name: 'my_plugin', // il serait bien que ça ne change jamais, imaginez que c'est un identifiant unique. + to: ({ projectSlug }) => `${process.env.MON_URL}/${projectSlug}`, + title: 'Mon super plugin', + imgSrc: 'https://un_lien_vers/image_externe.svg/', // préférez le svg + description: 'La description générale de mon plugin', +} + +export default infos +``` + +La fonction `to` peux renvoyer une `String` ou un objet contenant une clé `to` et potentiellement d'autres informations ou encore un tableau de cet objet : + +```ts +// Valid +const to1 = () => 'une url' +const to2 = () => ({ to: 'url', title: 'Un titre', description: 'description', imgSrc: 'url' }) +function to3() { + return [ + { to: 'url générale', title: 'Service générale', description: 'description générale' }, + { to: 'url1', title: 'Service 1', description: 'description 1' }, + { to: 'url2', title: 'Service 2', description: 'description 2' }, + ] +} +// Invalid +const to3 = () => ['url', 'url1', 'url2'] +``` + +### Monitoring + +Pour offrir un (et un seul) service de monitoring sommaire de l'outil que le plugin manipule, vous pouvez créer une instance de la classe `Monitor` et l'initialiser avec une fonction dont vous aurez seul la maîtrise. +Cette fonction sera éxécutée par un `setInterval` toutes les 5 min ou selon le temps en miliseconds que vous aurez fourni : + +```ts +// monitor.ts +import { Monitor, type MonitorInfos, MonitorStatus } from '@cpn-console/shared' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = (new Date()).getTime() + // Votre fonction ne devrait jamais lever d'exception + try { + // faites des trucs + // mettez à jour les clés en fonction de votre résultat: + // instance.lastStatus.message = 'Tout va bien' + // instance.lastStatus.status = MonitorStatus.OK + } catch (error) { + instance.lastStatus.message = 'Error lors la requete' + instance.lastStatus.status = MonitorStatus.UNKNOW + // la clé cause n'est pour l'instant jamais retourné à l'utilisateur ni stocké, ça sera pour une prochaine PR + instance.lastStatus.cause = error + } + // c'est bien de le retourner mais on s'en fiche un peu + return instance.lastStatus +} + +export default new Monitor(monitor) +``` + +### SubscribedHooks + +Pour informer le plugin manager sur quels hooks vous voulez exécuter une fonction, vous devez fournir un objet qui aura cette structure : + +```ts + subscribedHooks: { + createProject: { + steps: { + pre: createDsoProjectFirst, + post: createDsoProjectLast, + }, + }, + archiveProject: { + steps: { post: archiveDsoProject }, + }, + }, +``` + +### Fonctions (StepCalls) + +Pensez à typer vos fonctions comme suit pour que TS soit capable de vous notifier si vous avez associé un mauvais payload à un hook. + +**Vos fonctions ne doivent pas non plus lever d'exceptions non gérées.** +**Et elles doivent toutes retourner un statut !** + +Vous pouvez aussi retourner des clés supplémentaires. Ces clés sont accessibles par tous les plugins dans `payload.results[nom_du_plugin]`. + +Attention, chaque fonction écrase le résultat de la step d'avant : + +```ts +export const createDsoProjectFirst: StepCall = async (payload) => { + try { + // fais des trucs + // oh j'ai un payload typé ! + return { + status: { + result: 'OK', + message: 'ça s\'est bien passé' // optionnel si OK + }, + foo: { + bar: 'complétement facultatif' + } + } + } catch (error) { + return { + status: { + result: 'KO', + message: 'Ouille !' // Obligatoire pour explique ce qui n'a pas réussi' + }, + } + } +} + +export const createDsoProjectLast: StepCall = async (payload) => { + try { + // fais des trucs + // oh j'ai un payload typé ! + return { + status: { + result: 'OK', + message: `${payload.results.my_plugin.foo.bar} a vraiment bien été crée` // optionnel si OK + }, + une_clé: { + newProjectName: 'complétement facultatif' + } + } + } catch (error) { + return { + status: { + result: 'KO', + message: 'Ouille !' // Obligatoire pour explique ce qui n'a pas réussi' + }, + } + } +} +``` + +### Apis + +Il n'existe pour l'instant qu'un seul plugin qui exporte une API pour les autres plugins, c'est Vault ! +D'autres viendront aider à la séparation des responsabiltés plus tard, stay tuned ! + +Vous pouvez, comme lui, déclarer des apis sur des hooks. Pour l'uniformité, déclarez que c'est un `extends` de `PluginApi` (qui est vide) : + +```ts +// api.ts +import { PluginApi } from '@cpn-console/hooks' + +export class ClusterApi extends PluginApi {} +``` + +```ts +// index.ts + subscribedHooks: { + createCluster: { + api: (args) => new ClusterApi(args.label, args.privacy), + steps: { post: createCluster }, + }, + deleteCluster: { + api: (args) => new ClusterApi(args.label), + }, + }, +``` + +### Dépendances entre les plugins + +La section précédente est bien sympathique mais en l'état, le plugin n'a aucune conscience des apis des autres plugins. + +> Disclaimer: Les plugins peuvent se parler entre eux mais attention ils ne s'importent jamais les uns les autres. C'est le Plugin Manager qui est en charge de fournir tous les objets nécessaires et de faire passe plat. + +Pour y arriver il va falloir deux étapes + +1) Le plugin qui expose l'api doit faire un `declare module` : + +```ts +// index.ts +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + vault: Args extends CreateClusterExecArgs | DeleteClusterExecArgs + ? ClusterApi + : undefined + } +} +``` + +1) Le module l'utilisant doit importer les types : + +```json +// package.json +{ + "peerDependencies": { + "my_plugin": "1.2.3" + } +} +``` + +```ts +// src/env.d.ts +/// +``` + +## Conclusion + +N'hésitez pas à ouvrir des issues si ce n'est pas clair et bon développement ! diff --git a/.jjconflict-base-0/packages/hooks/eslint.config.js b/.jjconflict-base-0/packages/hooks/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-0/packages/hooks/package.json b/.jjconflict-base-0/packages/hooks/package.json new file mode 100644 index 0000000000..e13bdf29ad --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/package.json @@ -0,0 +1,57 @@ +{ + "name": "@cpn-console/hooks", + "type": "module", + "version": "4.1.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'vite-node src/config.ts'", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "test:e2e-ci": "echo 'check cache'", + "test:watch": "vitest" + }, + "dependencies": { + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "json-schema": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/json-schema": "catalog:types", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "undici-types": "catalog:types", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/packages/hooks/src/config.ts b/.jjconflict-base-0/packages/hooks/src/config.ts new file mode 100644 index 0000000000..ab1a702bf9 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/config.ts @@ -0,0 +1,76 @@ +import type { PermissionTarget, PluginConfig } from '@cpn-console/shared' +import { atomicValidators, DEFAULT, pluginConfig } from '@cpn-console/shared' +import { z } from 'zod' +import { objectEntries } from './utils/utils.js' + +export type PluginsManifests = Record +export const pluginsManifests: PluginsManifests = {} + +export const editStrippersGenerator = pluginConfig.transform((arg) => { + const project = { + user: z.object({}), + admin: z.object({}), + } + let global = z.object({}) + + for (const item of arg.project) { + const zAny = atomicValidators[item.kind].optional() + if (item.permissions.user.write) { + project.user = project.user.merge(z.object({ [item.key]: zAny })) + } + if (item.permissions.admin.write) { + project.admin = project.admin.merge(z.object({ [item.key]: zAny })) + } + } + + for (const item of arg.global || []) { + if (item.permissions.admin.write) { + const zAny = atomicValidators[item.kind].optional() + global = global.merge(z.object({ [item.key]: zAny })) + } + } + return { project, global } +}) + +export const editStrippers = { + project: { + admin: z.object({}), + user: z.object({}), + }, + global: z.object({}), +} + +export function addPlugin(pluginName: string, config: PluginConfig, editStrippersObject: typeof editStrippers) { + const editZod = editStrippersGenerator.parse(config) + pluginsManifests[pluginName] = config + editStrippersObject.global = editStrippersObject.global.merge(z.object({ [pluginName]: editZod.global.default({}) })) + editStrippersObject.project.admin = editStrippersObject.project.admin.merge(z.object({ [pluginName]: editZod.project.admin.default({}) })) + editStrippersObject.project.user = editStrippersObject.project.user.merge(z.object({ [pluginName]: editZod.project.user.default({}) })) +} + +interface Row { pluginName: string, key: string, value: string } +interface PopulateManifestsParams { + data: { project?: Row[], global?: Row[] } + permissionTarget: PermissionTarget + select: Partial> + pluginName: string +} +export function populatePluginManifests({ data, select, permissionTarget, pluginName }: PopulateManifestsParams): Partial { + const manifest = structuredClone(pluginsManifests[pluginName]) + + const selected: Partial = {} + for (const [scope] of objectEntries(select).filter(([_scope, bool]) => bool)) { + if (!manifest?.[scope]) continue + selected[scope] = manifest[scope].filter(item => item.permissions[permissionTarget].read || item.permissions[permissionTarget].write).map((item) => { + const row = data[scope]?.find(candidate => candidate.pluginName === pluginName && candidate.key === item.key) + if (item.kind === 'switch') { + const value = atomicValidators.switch.safeParse(row?.value) + item.value = value.success ? value.data : DEFAULT + } else { + item.value = z.coerce.string().parse(row?.value ?? item.value ?? '') + } + return item + }) + } + return selected +} diff --git a/.jjconflict-base-0/packages/hooks/src/hooks/hook-admin-role.ts b/.jjconflict-base-0/packages/hooks/src/hooks/hook-admin-role.ts new file mode 100644 index 0000000000..ff9b55292d --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/hooks/hook-admin-role.ts @@ -0,0 +1,16 @@ +import type { Hook } from './hook.js' +import type { UserObject } from './index.js' +import { createHook } from './hook.js' + +export interface AdminRole { + id: string + name: string + permissions: bigint + position: number + oidcGroup: string + type?: string + members: UserObject[] +} + +export const upsertAdminRole: Hook = createHook() +export const deleteAdminRole: Hook = createHook() diff --git a/.jjconflict-base-0/packages/hooks/src/hooks/hook-cluster.ts b/.jjconflict-base-0/packages/hooks/src/hooks/hook-cluster.ts new file mode 100644 index 0000000000..faeef0d8b7 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/hooks/hook-cluster.ts @@ -0,0 +1,6 @@ +import type { Hook } from './hook.js' +import type { ClusterObject } from './index.js' +import { createHook } from './hook.js' + +export const upsertCluster: Hook = createHook() +export const deleteCluster: Hook = createHook() diff --git a/.jjconflict-base-0/packages/hooks/src/hooks/hook-misc.ts b/.jjconflict-base-0/packages/hooks/src/hooks/hook-misc.ts new file mode 100644 index 0000000000..faf6705c12 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/hooks/hook-misc.ts @@ -0,0 +1,16 @@ +import type { Project, Repository } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +// misc hooks +export type EmptyPayload = Record + +export const checkServices: Hook = createHook() + +// misc project related hooks +export type ProjectLite = Pick + +export const getProjectSecrets: Hook = createHook() + +export type UniqueRepo = ProjectLite & { repo: Omit & { syncAllBranches: boolean, branchName?: string } } +export const syncRepository: Hook = createHook() diff --git a/.jjconflict-base-0/packages/hooks/src/hooks/hook-project-member.ts b/.jjconflict-base-0/packages/hooks/src/hooks/hook-project-member.ts new file mode 100644 index 0000000000..c1d726f599 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/hooks/hook-project-member.ts @@ -0,0 +1,16 @@ +import type { ProjectRole } from './hook-project-role.js' +import type { Project } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +export interface ProjectMember { + userId: string + email: string + firstName: string + lastName: string + roles: ProjectRole[] + project: Project +} + +export const upsertProjectMember: Hook = createHook() +export const deleteProjectMember: Hook = createHook() diff --git a/.jjconflict-base-0/packages/hooks/src/hooks/hook-project-role.ts b/.jjconflict-base-0/packages/hooks/src/hooks/hook-project-role.ts new file mode 100644 index 0000000000..8d047f4d79 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/hooks/hook-project-role.ts @@ -0,0 +1,17 @@ +import type { Project } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +export interface ProjectRole { + id: string + name: string + permissions: string + projectId: string + position: number + type?: string + oidcGroup?: string + project: Project +} + +export const upsertProjectRole: Hook = createHook() +export const deleteProjectRole: Hook = createHook() diff --git a/.jjconflict-base-0/packages/hooks/src/hooks/hook-project.ts b/.jjconflict-base-0/packages/hooks/src/hooks/hook-project.ts new file mode 100644 index 0000000000..86d65eff97 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/hooks/hook-project.ts @@ -0,0 +1,74 @@ +import type { PluginApi } from '../utils/utils.js' +import type { Hook } from './hook.js' +import type { ClusterObject, ExternalRepoUrl, InternalRepoName, IsInfra, IsPrivate, UserObject } from './index.js' +import { createHook } from './hook.js' + +export interface RepoCreds { + username: string + token: string +} + +export interface Role { + name: string + permissions?: string + position: number + type?: string + oidcGroup?: string + users: UserObject[] +} + +export interface EnvironmentApis { + [x: string]: PluginApi +} +export interface Environment { + id: string + name: string + clusterId: ClusterObject['id'] + cpu: number + gpu: number + memory: number + stage: string + autosync: boolean + permissions: { + userId: UserObject['id'] + permissions: { + ro: boolean + rw: boolean + } + }[] + apis: EnvironmentApis +} + +export interface Repository { + id: string + internalRepoName: InternalRepoName + newCreds?: RepoCreds + externalRepoUrl: ExternalRepoUrl + isPrivate: IsPrivate + isInfra: IsInfra + deployRevision: string | null + deployPath: string | null + helmValuesFiles: string | null +} + +export interface ProjectStore { + [x: string]: { [x: string]: string } +} + +export interface Project { + id: string + description: string | null + name: string + status: string + clusters: ClusterObject[] + slug: string + environments: Environment[] + repositories: Repository[] + users: UserObject[] + roles: Role[] + store: ProjectStore + owner: UserObject +} + +export const upsertProject: Hook = createHook() +export const deleteProject: Hook = createHook() diff --git a/.jjconflict-base-0/packages/hooks/src/hooks/hook-user.ts b/.jjconflict-base-0/packages/hooks/src/hooks/hook-user.ts new file mode 100644 index 0000000000..ffb9a9cb97 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/hooks/hook-user.ts @@ -0,0 +1,7 @@ +import type { Hook } from './hook.js' +import type { UserObject } from './index.js' +import { createHook } from './hook.js' + +export type UserEmail = Pick +export type UserAdmin = Pick & { isAdmin: boolean } +export const retrieveUserByEmail: Hook = createHook() diff --git a/.jjconflict-base-0/packages/hooks/src/hooks/hook-zone.ts b/.jjconflict-base-0/packages/hooks/src/hooks/hook-zone.ts new file mode 100644 index 0000000000..dd2ffc7710 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/hooks/hook-zone.ts @@ -0,0 +1,6 @@ +import type { Hook } from './hook.js' +import type { ZoneObject } from './index.js' +import { createHook } from './hook.js' + +export const upsertZone: Hook = createHook() +export const deleteZone: Hook = createHook() diff --git a/.jjconflict-base-0/packages/hooks/src/hooks/hook.spec.ts b/.jjconflict-base-0/packages/hooks/src/hooks/hook.spec.ts new file mode 100644 index 0000000000..3dcfe36fd9 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/hooks/hook.spec.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest' +import { okStatus, PluginApi } from '../utils/utils.ts' +import { createHook, executeStep } from './hook.ts' + +const koStatus = { status: { result: 'KO', message: 'Failed' } } as const +const warningStatus = { status: { result: 'WARNING', message: 'Failed' } } as const + +async function simpleOkHookCall() { + return okStatus +} +async function simpleFailedHookCall() { + return koStatus +} +async function simpleWarningHookCall() { + return warningStatus +} + +describe('test executeStep mechanism', () => { + it('test payload results, everything ok', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleOkHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.failed).toBe(false) + expect(results.warning).toEqual([]) + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...okStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, everything ko', async () => { + const results = await executeStep({ + plugin1: simpleFailedHookCall, + plugin2: simpleFailedHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual([]) + expect(results.failed).contain('plugin1') + expect(results.failed).contain('plugin2') + expect(results.results).toEqual({ + plugin1: { ...koStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...koStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, partial ko', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleFailedHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual([]) + expect(results.failed).not.contain('plugin1') + expect(results.failed).contain('plugin2') + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...koStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, partial warning', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleWarningHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual(['plugin2']) + expect(results.failed).toEqual(false) + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...warningStatus, executionTime: { main: expect.any(Number) } }, + }) + }) +}) + +describe('createHook', () => { + it('test empty hookStructure', async () => { + const hook = createHook(false) + expect(hook).toEqual({ + apis: {}, + steps: { + check: {}, + pre: {}, + main: {}, + post: {}, + revert: {}, + }, + execute: expect.any(Function), + }) + + const hookUnique = createHook(true) + expect(hookUnique.uniquePlugin).toBe('') + }) + + it('test hook execution, simple ok', async () => { + const hook = createHook(false) + hook.steps.main.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, multistep ok', async () => { + const hook = createHook(false) + hook.steps.pre.plugin1 = simpleOkHookCall + hook.steps.main.plugin1 = simpleOkHookCall + hook.steps.post.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.warning).toEqual([]) + expect(hookResult.results).toEqual({ plugin1: { ...okStatus, executionTime: { + pre: expect.any(Number), + main: expect.any(Number), + post: expect.any(Number), + } } }) + }) + + it('test payload results, multistep with warning', async () => { + const hook = createHook(false) + hook.steps.pre.plugin1 = simpleWarningHookCall + hook.steps.main.plugin2 = simpleOkHookCall + hook.steps.post.plugin2 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.warning).toEqual(['plugin1']) + expect(hookResult.results).toEqual({ + plugin1: { + ...warningStatus, + executionTime: { + pre: expect.any(Number), + }, + }, + plugin2: { + ...okStatus, + executionTime: { + main: expect.any(Number), + post: expect.any(Number), + }, + }, + }) + }) + + it('test payload results, main fails', async () => { + const hook = createHook(false) + hook.apis.plugin1 = () => new PluginApi() // à tester ailleurs + hook.steps.pre.plugin1 = simpleOkHookCall + hook.steps.main.plugin1 = simpleFailedHookCall + hook.steps.post.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(['plugin1']) + expect(hookResult.results).toEqual({ + plugin1: { + ...koStatus, + executionTime: { + pre: expect.any(Number), + main: expect.any(Number), + }, + }, + }) + }) +}) diff --git a/.jjconflict-base-0/packages/hooks/src/hooks/hook.ts b/.jjconflict-base-0/packages/hooks/src/hooks/hook.ts new file mode 100644 index 0000000000..1377d9abcc --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/hooks/hook.ts @@ -0,0 +1,192 @@ +import type { PluginApi } from '../utils/utils.js' +import type * as hooks from './index.js' +import { logger } from '@cpn-console/logger/hooks' + +export type DefaultArgs = Record +export type PluginResultStoreValue = string | number | null +export type PluginResultStore = Record +export interface PluginResult { + status: { result: 'OK', message?: string } | { result: 'KO' | 'WARNING', message: string } + store?: PluginResultStore + [key: string]: any +} + +export interface HookPayloadResults { + [x: string]: PluginResult +} +// @ts-ignore +// eslint-disable-next-line unused-imports/no-unused-vars +export interface HookPayloadApis { + [x: string]: PluginApi +} +export type Store = Record> // TO DEPRECIATE USE ONFIG + +export interface Config { + [x: string]: { [x: string]: string } +} + +export interface HookPayload { + args: Args + failed: boolean | string[] + warning: string[] + results: HookPayloadResults + apis: HookPayloadApis + config: Config +} + +export type HookResult = Omit, 'apis'> & { totalExecutionTime: number, messageResume?: string } + +export type StepCall = (payload: HookPayload) => Promise +type HookStep = Record> +export type HookStepsNames = 'check' | 'pre' | 'main' | 'post' | 'revert' +export interface Hook { + uniquePlugin?: string // if plugin register on it no other one can register on it + execute: (args: E, store: Config) => Promise> + apis: Record PluginApi> + steps: Record +} +export type HookList = Record> + +function generateMessageResume(payload: HookPayload): string | undefined { + let messageResume = '' + if (Array.isArray(payload.failed)) { + for (const pluginName of payload.failed) { + messageResume += 'Errors:' + messageResume += `\n${pluginName}: ${payload.results[pluginName].status.message};` + } + } + if (payload.warning.length) { + for (const pluginName of payload.warning) { + messageResume += 'Warnings:' + messageResume += `\n${pluginName}: ${payload.results[pluginName].status.message};` + } + } + return messageResume || undefined +} + +function handleRejectedStepResult( + reason: any, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + logger.error({ plugin: name, step: stepName, err: reason }, 'Hook step failed') + const result: PluginResult = { + status: { + result: 'KO', + message: reason instanceof Error ? reason.message : String(reason), + }, + } + payload.failed = Array.isArray(payload.failed) + ? [...payload.failed, name] + : [name] + return result +} + +function handleFulfilledStepResult( + value: PluginResult, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + if (value.status.result === 'KO') { + payload.failed = Array.isArray(payload.failed) + ? [...payload.failed, name] + : [name] + logger.error({ plugin: name, step: stepName, err: value.status.message, status: value.status.result, message: value.status.message }, 'Hook step failed') + } else if (value.status.result === 'WARNING' && !payload.warning.includes(name)) { + payload.warning.push(name) + logger.warn({ plugin: name, step: stepName, err: value.status.message, status: value.status.result, message: value.status.message }, 'Hook step warning') + } else { + logger.trace({ plugin: name, step: stepName, status: value.status.result }, 'Hook step fulfilled') + } + return value +} + +function handleStepResult( + settled: PromiseSettledResult, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + const result = settled.status === 'fulfilled' + ? handleFulfilledStepResult(settled.value, name, stepName, payload) + : handleRejectedStepResult(settled.reason, name, stepName, payload) + return { ...result, executionTime: payload.results[name].executionTime } +} + +export async function executeStep(step: HookStep, payload: HookPayload, stepName: string) { + const names = Object.keys(step) + const fns = names.map(async (name) => { + if (payload.results[name]?.executionTime) { + payload.results[name].executionTime[stepName] = Date.now() + } else { + payload.results[name] = { + status: { result: 'OK' }, + executionTime: { [stepName]: Date.now() }, + } + } + const fnResult = await step[name](payload) + payload.results[name].executionTime[stepName] = Date.now() - payload.results[name].executionTime[stepName] + return fnResult + }) + const results = await Promise.allSettled(fns) + names.forEach((name, index) => { + payload.results[name] = handleStepResult(results[index], name, stepName, payload) + }) + return payload +} +export function createHook(unique = false) { + const steps: Record = { + check: {}, + pre: {}, + main: {}, + post: {}, + revert: {}, + } + const apis: Record PluginApi> = { + } + const execute = async (args: E, config: Config): Promise> => { + const startTime = Date.now() + const payloadApis: HookPayloadApis = {} + Object.entries(apis).forEach(([pluginName, apiFn]) => { + payloadApis[pluginName] = apiFn(args) + }) + let payload: HookPayload = { + failed: false, + args, + results: {}, + apis: payloadApis, + config, + warning: [], + } + + const executeSteps = ['pre', 'main', 'post'] as const + for (const step of executeSteps) { + payload = await executeStep(steps[step], payload, step) + if (payload.failed) { + payload = await executeStep(steps.revert, payload, 'revert') + break + } + } + return { + args: payload.args, + results: payload.results, + failed: payload.failed, + warning: payload.warning, + totalExecutionTime: Date.now() - startTime, + config, + messageResume: generateMessageResume(payload), + } + } + + const hook: Hook = { + apis, + steps, + execute, + } + if (unique) { + hook.uniquePlugin = '' + } + return hook +} diff --git a/.jjconflict-base-0/packages/hooks/src/hooks/index.ts b/.jjconflict-base-0/packages/hooks/src/hooks/index.ts new file mode 100644 index 0000000000..a783454745 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/hooks/index.ts @@ -0,0 +1,68 @@ +export * from './hook-admin-role.js' +export * from './hook-cluster.js' +export * from './hook-misc.js' +export * from './hook-project-member.js' +export * from './hook-project-role.js' +export * from './hook-project.js' +export * from './hook-user.js' +export * from './hook-zone.js' + +export interface PartialEnvironment { environment: string, stage: string, clusterLabel: string } +export type InternalRepoName = string +export type ExternalRepoUrl = string +export type ExternalUserName = string +export type ExternalToken = string +export type IsPrivate = boolean +export type IsInfra = boolean +export type InternalUrl = string +export interface UserObject { + firstName: string + lastName: string + id: string + email: string +} +export interface EnvironmentObject { + id: string + name: string + createdAt: Date + updatedAt: Date + clusterId: string +} + +export interface KubeUser { + readonly certData?: string + readonly keyData?: string + readonly token?: string + readonly username?: string + readonly password?: string +} + +export interface KubeCluster { + readonly caData?: string + readonly server: string + readonly skipTLSVerify?: boolean + readonly tlsServerName?: string +} + +export interface ZoneObject { + id: string + slug: string + label: string + argocdUrl: string + clusterNames?: string[] +} + +export interface ClusterObject { + id: string + label: string + privacy: 'public' | 'dedicated' + /** + * @deprecated Le nom aléatoire stocké ici n'est plus utilisé, au profit d'un nom caculé et déterministe. + */ + secretName: string + clusterResources: boolean + infos: string | null + cluster: KubeCluster + user: KubeUser + zone: ZoneObject +} diff --git a/.jjconflict-base-0/packages/hooks/src/index.ts b/.jjconflict-base-0/packages/hooks/src/index.ts new file mode 100644 index 0000000000..ad4af3bca8 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/index.ts @@ -0,0 +1,135 @@ +import type { Monitor } from '@cpn-console/shared' +import type { HookStepsNames, StepCall } from './hooks/hook.js' +import type { ServiceInfos } from './services.js' +import type { PluginApi } from './utils/utils.js' +import { logger } from '@cpn-console/logger/hooks' +import { addPlugin, editStrippers } from './config.js' +import * as hooks from './hooks/index.js' +import { servicesInfos } from './services.js' +import { objectEntries } from './utils/utils.js' + +export * from './config.js' + +export type HookChoice = keyof typeof hooks + +type ExecArgs = Parameters[0] + +export type PluginsFunctions = Partial<{ + [C in HookChoice]: { + steps?: Partial<{ + [S in HookStepsNames]: StepCall> + }> + api?: (args: ExecArgs) => PluginApi + } +}> + +export interface Plugin { + infos: ServiceInfos + subscribedHooks: PluginsFunctions + monitor?: Monitor + start?: (options: unknown) => void +} + +export type RegisterFn = (plugin: Plugin) => void +export type UnregisterFn = (name: Plugin['infos']['name']) => void +export interface PluginManager { + servicesInfos: Record + register: RegisterFn + unregister: UnregisterFn +} + +export interface PluginManagerOptions { + startPlugins?: boolean + mockMonitoring?: boolean + mockHooks?: boolean + mockExternalServices?: boolean + externalDir?: string +} + +let config: PluginManagerOptions +function pluginManager(options: PluginManagerOptions): PluginManager { + config = options + const register: RegisterFn = (plugin: Plugin) => { + if (plugin.infos.config) { + addPlugin(plugin.infos.name, plugin.infos.config, editStrippers) + } + + if (plugin.infos.to && config.mockExternalServices) + plugin.infos.to = () => [{ name: 'Lien', to: 'https://theuselessweb.com/' }] + if (plugin.start && options.startPlugins) + plugin.start({}) + const message: string[] = [] + if (plugin.monitor && config.mockMonitoring) { + plugin.monitor.monitorFn = async (instance: Monitor) => instance.lastStatus + } + if (plugin.monitor) + plugin.monitor.refresh() + servicesInfos[plugin.infos.name] = { + ...plugin.infos, + monitor: plugin.monitor, + } + const subscribedHooks = plugin.subscribedHooks + const name = plugin.infos.name + + if (!config.mockHooks) { + for (const [hook, functions] of objectEntries(subscribedHooks)) { + if (!(hook in hooks)) { + logger.warn({ plugin: name, hook }, 'Tried to register on an unknown hook') + continue + } + if (functions?.api) { + hooks[hook].apis[name] = functions.api + } + for (const [step, fn] of objectEntries(functions?.steps ?? {})) { + if (fn === undefined) + continue + if (hook === 'checkServices' && step !== 'check') { + logger.warn({ plugin: name, hook, step }, 'Tried to register an invalid step for checkServices hook') + continue + } + + if ('uniquePlugin' in hooks[hook] && hooks[hook]?.uniquePlugin !== '' && hooks[hook]?.uniquePlugin !== name) { + logger.warn({ plugin: name, hook, registeredBy: hooks[hook].uniquePlugin }, 'Hook is already registered by another plugin') + continue + } + // @ts-ignore + hooks[hook].steps[step][name] = fn + message.push(`${hook}:${step}`) + } + } + } + if (process.env.NODE_ENV !== 'test') { + logger.info({ plugin: name, registrations: message }, 'Plugin registered') + } + } + + const unregister: UnregisterFn = (name) => { + delete servicesInfos[name] + + Object.values(hooks).forEach((hook) => { + delete hook.steps.pre[name] + delete hook.steps.main[name] + delete hook.steps.post[name] + delete hook.steps.revert[name] + delete hook.apis[name] + }) + } + + return { + servicesInfos, + register, + unregister, + } +} + +export { + hooks, + pluginManager, +} + +export * from './hooks/hook.js' +export * from './hooks/index.js' +export * from './services.js' +export * from './utils/crypto.js' +export * from './utils/plugin-result-handler.js' +export * from './utils/utils.js' diff --git a/.jjconflict-base-0/packages/hooks/src/services.ts b/.jjconflict-base-0/packages/hooks/src/services.ts new file mode 100644 index 0000000000..dce8693564 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/services.ts @@ -0,0 +1,40 @@ +import type { Monitor, MonitorInfos, PluginConfig, PluginsUpdateBody } from '@cpn-console/shared' +import type { ClusterObject, EnvironmentObject, ProjectLite, ZoneObject } from './hooks/index.js' + +interface ToUrlObject { to: string, title?: string, description?: string, imgSrc?: string } +export interface ToUrlFnParamaters { + store: PluginsUpdateBody + clusters: Omit[] + zones: ZoneObject[] + environments: EnvironmentObject[] + project: Omit +} +type ToUrlFnResponse = ToUrlObject | ToUrlObject[] | string | void + +export interface ServiceInfos { + name: string + to?: ({ store, clusters, zones, environments, project }: ToUrlFnParamaters) => ToUrlFnResponse + monitor?: Monitor + title: string + imgSrc?: string + description?: string + config?: PluginConfig +} + +export const servicesInfos: Record = {} + +export type ServiceStatus = MonitorInfos & { name: string } +export const services = { + getStatus: (): Array => { + return Object.values(servicesInfos).reduce((acc, serviceInfos) => { + if (!serviceInfos?.monitor?.lastStatus) return acc + return [...acc, { ...serviceInfos.monitor.lastStatus, name: serviceInfos.title }] + }, [] as Array) + }, + refreshStatus: (): Array> => { + // @ts-ignore obligé d'ignore TS ne comprend pas l'interet du filter + return Object.values(servicesInfos) + .filter(servicesInfos => servicesInfos.monitor) + .map(serviceInfos => serviceInfos.monitor?.refresh()) + }, +} diff --git a/.jjconflict-base-0/packages/hooks/src/utils/crypto.ts b/.jjconflict-base-0/packages/hooks/src/utils/crypto.ts new file mode 100644 index 0000000000..5517e2b906 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/utils/crypto.ts @@ -0,0 +1,16 @@ +import type { Project } from '../hooks/index.js' +import crypto, { createHmac } from 'node:crypto' + +export function generateRandomPassword(length = 24) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*' + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +export function generateProjectKey(projectSlug: Project['slug'], repo: Project['repositories'][0]['internalRepoName']) { + const repoHash = createHmac('sha256', '') + .update(repo) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${repo}-${repoHash}` +} diff --git a/.jjconflict-base-0/packages/hooks/src/utils/index.ts b/.jjconflict-base-0/packages/hooks/src/utils/index.ts new file mode 100644 index 0000000000..ffb3bb4ec5 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './crypto.js' +export * from './utils.js' diff --git a/.jjconflict-base-0/packages/hooks/src/utils/plugin-result-handler.ts b/.jjconflict-base-0/packages/hooks/src/utils/plugin-result-handler.ts new file mode 100644 index 0000000000..a90261aede --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/utils/plugin-result-handler.ts @@ -0,0 +1,92 @@ +import type { PluginResult, PluginResultStore, PluginResultStoreValue } from '../hooks/hook.js' + +export class PluginResultBuilder { + private okMessages = [] as string[] + private warnMessages = [] as string[] + private koMessages = [] as string[] + private extras: Record = {} + public store: PluginResultStore = {} + + constructor(okMessage: string | undefined) { + if (okMessage) { + this.okMessages.push(okMessage) + } + } + + addExtra(key: string, value: any) { + this.extras[key] = value + return this + } + + addOkMessage(...messages: string[]) { + this.okMessages.push(...messages) + return this + } + + addKoMessage(...messages: string[]) { + this.koMessages.push(...messages) + return this + } + + addWarnMessage(...messages: string[]) { + this.warnMessages.push(...messages) + return this + } + + setOkMessage(...messages: string[]) { + this.okMessages = messages + return this + } + + setKoMessage(...messages: string[]) { + this.koMessages = messages + return this + } + + setWarnMessage(...messages: string[]) { + this.warnMessages = messages + return this + } + + setToStore(key: string, value: PluginResultStoreValue) { + this.store[key] = value + } + + deleteFromStore(key: string) { + delete this.store[key] + } + + returnUnexpectedError(error: unknown): PluginResult { + this.addKoMessage('UnexpectedError') + this.addExtra('error', error) + return this.getResultObject() + } + + getResultObject(): PluginResult { + const result: PluginResult = { + status: { + result: 'OK', + message: this.okMessages.join('\n') || undefined, + }, + ...this.extras, + } + + if (this.koMessages.length) { + result.status = { + result: 'KO', + message: this.koMessages.join('\n'), + } + } else if (this.warnMessages.length) { + result.status = { + result: 'WARNING', + message: this.warnMessages.join('\n'), + } + } + + if (Object.keys(this.store).length) { + result.store = this.store + } + + return result + } +} diff --git a/.jjconflict-base-0/packages/hooks/src/utils/utils.spec.ts b/.jjconflict-base-0/packages/hooks/src/utils/utils.spec.ts new file mode 100644 index 0000000000..a463bc5fb2 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/utils/utils.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { defaultOrNullish, disabledOrDefaultOrNullish, enabledOrDefaultOrNullish, objectEntries, objectKeys, objectValues, specificallyDisabled, specificallyEnabled } from './utils.ts' + +const object = { test1: 1, test2: 2, 3: 'test3' } + +it('should return object keys', () => { + const keys = objectKeys(object) + + // cannot gaurantee order in keys + expect(keys[0]).contain('3') + expect(keys[1]).contain('test1') + expect(keys[2]).contain('test2') +}) + +it('should return object entries', () => { + const keys = objectEntries(object) + + expect(keys[0]).toEqual(['3', 'test3']) + expect(keys[1]).toEqual(['test1', 1]) + expect(keys[2]).toEqual(['test2', 2]) +}) + +it('should return object values', () => { + const keys = objectValues(object) + + // cannot gaurantee order in values + expect(keys).contain('test3') + expect(keys).contain(1) + expect(keys).contain(2) +}) + +const values = [ + '', + 'nimp', + 'enabled', + 'default', + 'disabled', +] as const +describe('test config parsing', () => { + it('enabledOrDefaultOrNullish', () => { + expect(enabledOrDefaultOrNullish(values[0])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[1])).toBeFalsy() + expect(enabledOrDefaultOrNullish(values[2])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[3])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[4])).toBeFalsy() + }) + it('specificallyDisabled', () => { + expect(specificallyDisabled(values[0])).toBeFalsy() + expect(specificallyDisabled(values[1])).toBeFalsy() + expect(specificallyDisabled(values[2])).toBeFalsy() + expect(specificallyDisabled(values[3])).toBeFalsy() + expect(specificallyDisabled(values[4])).toBeTruthy() + }) + it('specificallyEnabled', () => { + expect(specificallyEnabled(values[0])).toBeFalsy() + expect(specificallyEnabled(values[1])).toBeFalsy() + expect(specificallyEnabled(values[2])).toBeTruthy() + expect(specificallyEnabled(values[3])).toBeFalsy() + expect(specificallyEnabled(values[4])).toBeFalsy() + }) + it('defaultOrNullish', () => { + expect(defaultOrNullish(values[0])).toBeTruthy() + expect(defaultOrNullish(values[1])).toBeFalsy() + expect(defaultOrNullish(values[2])).toBeFalsy() + expect(defaultOrNullish(values[3])).toBeTruthy() + expect(defaultOrNullish(values[4])).toBeFalsy() + }) + it('disabledOrDefault', () => { + expect(disabledOrDefaultOrNullish(values[0])).toBeTruthy() + expect(disabledOrDefaultOrNullish(values[1])).toBeFalsy() + expect(disabledOrDefaultOrNullish(values[2])).toBeFalsy() + expect(disabledOrDefaultOrNullish(values[3])).toBeTruthy() + expect(disabledOrDefaultOrNullish(values[4])).toBeTruthy() + }) +}) diff --git a/.jjconflict-base-0/packages/hooks/src/utils/utils.ts b/.jjconflict-base-0/packages/hooks/src/utils/utils.ts new file mode 100644 index 0000000000..65d03954c2 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/src/utils/utils.ts @@ -0,0 +1,88 @@ +import type { ServiceInfos } from '../services.js' +import { DEFAULT, DISABLED, ENABLED } from '@cpn-console/shared' + +export class PluginApi { } + +export function objectEntries>(obj: Obj): ([keyof Obj, Obj[keyof Obj]])[] { + return Object.entries(obj) as ([keyof Obj, Obj[keyof Obj]])[] +} +export function objectKeys>(obj: Obj): (keyof Obj)[] { + return Object.keys(obj) as (keyof Obj)[] +} +export function objectValues>(obj: Obj): (Obj[keyof Obj])[] { + return Object.values(obj) as (Obj[keyof Obj])[] +} + +type ConfigScope = keyof Required['config'] + +export type DeclareModuleGenerator, 'config'>, Scope extends ConfigScope> = { + [K in Infos['name']]?: { + [P in Infos['config'][Scope][number]['key']]?: string + } +} + +export const enabledOrDefaultOrNullish = (value?: string): boolean | undefined => value ? [ENABLED, DEFAULT].includes(value) : true +export const disabledOrDefaultOrNullish = (value?: string): boolean | undefined => value ? [DISABLED, DEFAULT].includes(value) : true +export const specificallyDisabled = (value?: string): boolean | undefined => value ? value === DISABLED : undefined +export const specificallyEnabled = (value?: string): boolean | undefined => value ? value === ENABLED : undefined +export const defaultOrNullish = (value?: string): boolean | undefined => value ? DEFAULT === value : true + +export const okStatus = { status: { result: 'OK' } } as const + +/** + * Take a list of list of kubernetes resources and ditch duplicates by name + * + * @remarks Kind is ignored. You can pass as many arguments as you want + * + * @example + * ```ts + * const foo = [{ metadata: { name: 'One' }}, { metadata: { name: 'Two' }}] + * const bar = [{ metadata: { name: 'One' }}, { metadata: { name: 'Three' }}] + * const xyz = [{ metadata: { name: 'Four' }}, { metadata: { name: 'Two' }}] + * uniqueResource(foo, bar, xyz) + * // [ + * // { metadata: { name: 'One' }}, + * // { metadata: { name: 'Two' }}, + * // { metadata: { name: 'Three' }}, + * // { metadata: { name: 'Four' }} + * // ] + * ``` + * + */ +export function uniqueResource(...lists: T[][]): T[] { + return lists + .flat() + .reduce((acc, cur) => (acc.some(item => item.metadata?.name === cur.metadata?.name) + ? acc + : [...acc, cur] + ), [] as T[]) +} + +export interface BaseResources { + kind?: string + apiVersion?: string + metadata: { + name?: string + namespace?: string + labels: { + [x: string]: string + } + [x: string]: any + } + [x: string]: any +} + +export interface BareMinimumResource { + metadata: { + name: string + labels: { + [x: string]: string + } + } +} + +export interface ListMinimumResources { + body: { + items: BareMinimumResource[] + } +} diff --git a/.jjconflict-base-0/packages/hooks/tsconfig.eslint.json b/.jjconflict-base-0/packages/hooks/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-0/packages/hooks/tsconfig.json b/.jjconflict-base-0/packages/hooks/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-0/packages/hooks/vitest.config.ts b/.jjconflict-base-0/packages/hooks/vitest.config.ts new file mode 100644 index 0000000000..58225abfa0 --- /dev/null +++ b/.jjconflict-base-0/packages/hooks/vitest.config.ts @@ -0,0 +1,16 @@ +import { configDefaults, defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: ['**/types', '**/mocks', '**/*.spec.ts', '**/*.d.ts'], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude], + }, +}) diff --git a/.jjconflict-base-0/packages/logger/eslint.config.js b/.jjconflict-base-0/packages/logger/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-0/packages/logger/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-0/packages/logger/package.json b/.jjconflict-base-0/packages/logger/package.json new file mode 100644 index 0000000000..2b14869190 --- /dev/null +++ b/.jjconflict-base-0/packages/logger/package.json @@ -0,0 +1,53 @@ +{ + "name": "@cpn-console/logger", + "type": "module", + "version": "1.0.1", + "private": false, + "description": "Cloud PI Native internal logger for legacy code", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "browser": "./dist/browser.js", + "default": "./dist/index.js" + }, + "./browser": { + "types": "./types/browser.d.ts", + "default": "./dist/browser.js" + }, + "./hooks": { + "types": "./types/hooks.d.ts", + "default": "./dist/hooks.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "pino": "catalog:tools", + "pino-pretty": "catalog:tools", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "rimraf": "catalog:tools", + "typescript": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/packages/logger/src/browser.ts b/.jjconflict-base-0/packages/logger/src/browser.ts new file mode 100644 index 0000000000..f1ed417404 --- /dev/null +++ b/.jjconflict-base-0/packages/logger/src/browser.ts @@ -0,0 +1,16 @@ +import { pino, stdSerializers } from 'pino' + +export const logger = pino({ + customLevels: { + audit: 25, + }, + level: 'info', + browser: { + asObject: true, + }, + serializers: { + err: stdSerializers.errWithCause, + }, +}) + +export type Logger = typeof logger diff --git a/.jjconflict-base-0/packages/logger/src/hooks.ts b/.jjconflict-base-0/packages/logger/src/hooks.ts new file mode 100644 index 0000000000..2ee8ad0f1a --- /dev/null +++ b/.jjconflict-base-0/packages/logger/src/hooks.ts @@ -0,0 +1,22 @@ +import type { Logger } from './index.js' +import { logger as baseLogger } from './index.js' + +export const logger: Logger = baseLogger.child( + { scope: 'hooks' }, + { + redact: { + paths: [ + 'err.config.auth.username', + 'err.config.auth.password', + 'err.config.headers', + 'err.config.data', + 'err.config.params.client_secret', + 'err.config.params.clientSecret', + 'err.request.headers', + 'err.response.data', + 'err.response.headers', + ], + remove: true, + }, + }, +) diff --git a/.jjconflict-base-0/packages/logger/src/index.ts b/.jjconflict-base-0/packages/logger/src/index.ts new file mode 100644 index 0000000000..cf2a6e2397 --- /dev/null +++ b/.jjconflict-base-0/packages/logger/src/index.ts @@ -0,0 +1,87 @@ +import type { LoggerOptions } from 'pino' +import { pino, stdSerializers } from 'pino' +import z from 'zod' + +const envSchema = z.object({ + LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error', 'audit']).default('debug'), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), +}) + +export type Env = z.infer['NODE_ENV'] +export type LogLevel = z.infer['LOG_LEVEL'] +const customLevels = { + audit: 25, +} + +const redact: LoggerOptions['redact'] = { + paths: [ + 'password', + '*.password', + 'passwd', + '*.passwd', + 'secret', + '*.secret', + 'token', + '*.token', + 'access_token', + '*.access_token', + 'refresh_token', + '*.refresh_token', + 'client_secret', + '*.client_secret', + 'authorization', + '*.authorization', + 'headers.authorization', + 'headers.cookie', + 'req.headers.authorization', + 'req.headers.cookie', + 'request.headers.authorization', + 'request.headers.cookie', + ], + remove: true, +} + +export function getLoggerOptions(env: Env, level: LogLevel): LoggerOptions { + switch (env) { + case 'development': + return { + transport: { + target: 'pino-pretty', + options: { + translateTime: 'dd/mm/yyyy - HH:MM:ss Z', + ignore: 'pid,hostname', + colorize: true, + singleLine: true, + }, + }, + customLevels, + level, + redact, + serializers: { + err: stdSerializers.errWithCause, + }, + } + case 'production': + return { + customLevels, + level, + redact, + serializers: { + err: stdSerializers.errWithCause, + }, + } + default: + return { + level: 'silent', + } + } +} + +export function getLoggerOptionsFromEnv(): LoggerOptions { + const env = envSchema.parse(process.env) + return getLoggerOptions(env.NODE_ENV, env.LOG_LEVEL) +} + +export const logger = pino(getLoggerOptionsFromEnv()) + +export type Logger = typeof logger diff --git a/.jjconflict-base-0/packages/logger/tsconfig.eslint.json b/.jjconflict-base-0/packages/logger/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-0/packages/logger/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-0/packages/logger/tsconfig.json b/.jjconflict-base-0/packages/logger/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-0/packages/logger/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-0/packages/opencds/Dockerfile b/.jjconflict-base-0/packages/opencds/Dockerfile new file mode 100644 index 0000000000..734496c4fb --- /dev/null +++ b/.jjconflict-base-0/packages/opencds/Dockerfile @@ -0,0 +1,21 @@ +FROM node:24-alpine AS prod + +RUN npm install -g @mockoon/cli@9.3.0 + +# Install curl for healthcheck and tzdata for timezone support. +RUN apk --no-cache add curl tzdata + +# Do not run as root. +RUN adduser --shell /bin/sh --disabled-password --gecos "" mockoon + +COPY packages/opencds/opencds_api.json ./opencds_api.json +RUN chown -R mockoon ./opencds_api.json + +USER mockoon + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3100/api/v1/health || exit 1 + +EXPOSE 3100 + +ENTRYPOINT ["mockoon-cli","start","--disable-log-to-file","--data","./opencds_api.json","--port","3100"] diff --git a/.jjconflict-base-0/packages/opencds/README.adoc b/.jjconflict-base-0/packages/opencds/README.adoc new file mode 100644 index 0000000000..bc9a736541 --- /dev/null +++ b/.jjconflict-base-0/packages/opencds/README.adoc @@ -0,0 +1,58 @@ += À propos + +Ce document va servir de fil conducteur pour toutes les idées autour de l'implémentation de la Console OpenCDS. Il permettra à l'équipe Socle d'échanger (au travers, par exemple, des commentaires de MR) sur les différentes problématiques entourant les travaux. C'est un document vivant, dynamique, qui sera remplacé par un "vrai" document d'architecture par la suite. + +== Cadrage effectué + +- SSO à prévoir avec Keycloak (déjà intégré dans la Console, d'où l'intérêt de l'utiliser pour intégrer le front OpenCDS) +- Rôles à prédéfinir dans *Keycloak* : + - `admin` (a accès à toute l'application) + - `cds` (n'a accès qu'à la partie qui concerne ses projets) + +Flux GUI définis : +- Pour tous les utilisateurs : + - Login/Logout à travers le SSO Keycloak comme la Console +- Utilisateurs `admin`: + - Liste de toutes les CDS + - Relancer une CDS en particulier + - Afficher les "flows" (étapes) d'un CDS +- Utilisateurs `cds`: + - Pouvoir lister les CDS de leur équipe + - Afficher les "flows" (étapes) d'une CDS qu'ils ont le droit de voir + - Avoir un écran de confirmation (on parlera plutôt de "validation" étant donné que la route côté OpenCDS s'appelle `/validate`) de création d'une CDS. Actuellement un mail leur est envoyé qui contient un lien pour validation. Ce lien valide la création de la CDS. Il faudra donc crééer un point d'entrée dans l'application pour plutôt ouvrir un écran de confirmation de la création. + +Lotissement des évolutions: +- [x] Création en tant que fonctionnalité "feature togglée" de Console (on verra plus tard si on en fait quelque chose à part) +- [x] Admin - Liste des CDS +- [x] Admin - Liste des Flow +- [x] Admin - Relancer un CDS en statut "échoué" (`/retry`) +- [ ] CDS - Liste des CDS/Flow de leur périmètre +- [ ] CDS - Écran de confirmation de la création d'un CDS (`/validate`) + += Architecture + +L'architecture retenue est celle consistant à utiliser le serveur ("Back for Front") de la console comme d'un Proxy pour OpenCDS. Ceci permet de réutiliser toute la mécanique d'authentification en SSO par Keycloak, la sérialisation/désérialisation entre `client` et `server-nestjs` (le Frontend et son BFF), ainsi que la génération des types TypeScript (à travers l'utilisation de schémas et de contrats `zod`). De cette manière, l'implémentation des ressources OpenCDS (`service-chains` et `flows`) peut se faire de la même manière que les autres ressources existantes (`cluster`, `projet`, etc.), avec le même niveau de qualité (tests unitaires avec vitest, et E2E avec Playwright). + += Contraintes + +- Console étant un projet OpenSource, et OpenCDS étant un sujet exclusivement "MI", le couplage entre les deux doit être le plus faible possible. Idéalement OpenCDS devrait être une "extension" de la Console de manière à ne jamais être directement référencé dans l'application. +=> Dans un premier temps on va fonctionner avec un "Feature Toggling" rudimentaire (à l'aide de variables d'environnement), pour voir où on va, et ensuite on verra comment éventuellement établir une séparation claire entre la console et sa partie OpenCDS, par exemple dans un projet Git totalement à part. + += Jeu de données + +Afin de pouvoir avancer sans forcément dépendre du backend OpenCDS, un https://mockoon.com/[Mockoon] sera utilisé (./opencds_api.json) pour simuler cette partie. Lors de la convergence entre les développements de la console et ceux côté OpenCDS, une version d'"intégration" de celle-ci sera déployée sur nos environnements PAX (`cpin-hp`, `formation`, et `pax-prod`) afin d'effectuer une recette dédiée. + += Mise en place + +Utilisation de varables d'environnements qui vont nous servir de moyen d'activer la fonctionnalité: + - Côté `apps/client`: + - `OPENCDS_ENABLED`: active ou non la fonctionnalité côté Front (cette activation est indépendante de `apps/server-nestjs`, donc il faut que cette variable d'environnement soit activée pour que la console affiche les écrans concernés par OpenCDS) + - Côté `apps/server-nestjs`: + - `OPENCDS_URL`: Chemin complet (URL + path) du service OpenCDS distant + - `OPENCDS_API_TOKEN`: Définit le token d'authentification au service OpenCDS qui sera utilisé pour chaque requête effectuée + - `OPENCDS_API_TLS_REJECT_UNAUTHORIZED`: Indique si on accepte les certificats non-validés (par exemple les certificats auto-signés, ou signés par une Autorité de Certification inconnue). Cette fonctionnalié permet d'intéragir facilement avec des services OpenCDS de "test" + += Notes + +- Sachant qu'il n'est pas trivial de lancer des services docker-compose de manière conditionnelle, le service `opencds-mockoon` est systématiquement lancé dans les docker-compose concernés (développement local, tests en CI, etc.) +- Les documentations de link:../../apps/client/README.md[la console] et link:../../apps/server-nestjs/README.md[son backend] ont été amendées pour référencer cette documentation, de manière à centraliser ici tous les aspects concernant OpenCDS. diff --git a/.jjconflict-base-0/packages/opencds/opencds_api.json b/.jjconflict-base-0/packages/opencds/opencds_api.json new file mode 100644 index 0000000000..6c92672f03 --- /dev/null +++ b/.jjconflict-base-0/packages/opencds/opencds_api.json @@ -0,0 +1,546 @@ +{ + "uuid": "b263d1c1-3103-4a17-940c-1c92215bb3b8", + "lastMigration": 33, + "name": "Opencds api", + "endpointPrefix": "api/v1/", + "latency": 0, + "port": 3003, + "hostname": "", + "folders": [], + "routes": [ + { + "uuid": "210c5fa6-22dc-4c79-a89e-4289fd2b84bb", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests", + "responses": [ + { + "uuid": "28bd3efb-d903-44c3-b728-66db3af526bc", + "body": "{{setVar 'networks' (array 'RIE' 'INTERNET')}}\n{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n[\n {{#repeat 50 100 comma=true}}\n {{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n {{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n {\n \"id\": \"{{faker 'string.uuid'}}\",\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"commonName\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\",\n \"pai\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"upper\\\" }'}}\",\n \"network\": \"{{oneOf (getVar 'networks')}}\",\n \"createdAt\": \"{{getVar 'createdAt'}}\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n }\n {{/repeat}}\n]", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "0d39381e-e815-40a9-9d47-cc739a8b582c", + "body": "", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "8f00d8d7-7df4-4401-b751-6440ee0e1658", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests/:request_id", + "responses": [ + { + "uuid": "410bd286-4377-47e5-ae3f-e4131fcb0b68", + "body": "{{setVar 'networks' (array 'RIE' 'INTERNET')}}\n{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n{{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'location' (array 'SIR' 'SIL')}}\n{{setVar 'environment' (array 'INT' 'PROD' 'BAS')}}\n{\n \"id\": \"{{urlParam 'request_id'}}\",\n \n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"commonName\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\",\n \"pai\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"upper\\\" }'}}\",\n \"network\": \"{{oneOf (getVar 'networks')}}\",\n \"createdAt\": \"{{getVar 'createdAt'}}\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\",\n\n \"validationId\": \"{{faker 'string.uuid'}}\",\n \"validatedBy\": \"{{faker 'string.uuid'}}\",\n \"ref\": \"{{faker 'string.uuid'}}\",\n \"location\": \"{{oneOf (getVar 'location')}}\",\n \"targetAddress\": \"{{faker 'internet.ipv4'}}\",\n \"projectId\": \"{{faker 'string.uuid'}}\",\n \"env\": \"{{oneOf (getVar 'environment')}}\",\n \"subjectAlternativeName\": [{{#repeat 0 50 comma=true}}\"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\"{{/repeat}}],\n \"redirect\": {{boolean}},\n \"antivirus\": {{#if (eq (boolean) true)}}{\n \"maxFileSize\": {{int 1 100}}\n }{{else}}null{{/if}},\n \"websocket\": {{boolean}},\n \"ipWhiteList\": [{{#repeat 0 10 comma=true}}\"{{faker 'internet.ipv4'}}/{{int 8 32}}\"{{/repeat}}],\n \"sslOutgoing\": {{boolean}}\n}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "41f0a982-3b94-44d7-8177-e120d4741dee", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "904ddbb0-480b-442d-b251-d008d10fa832", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "8ff2f39f-a82f-4364-90ed-135b5ec8f19b", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests/:request_id/flows", + "responses": [ + { + "uuid": "b24f3f54-b45c-4749-a134-dc1fa003a417", + "body": "{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n{{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'json' '' }}\n{\n \"reserve_ip\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"create_cert\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"call_exec\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"activate_ip\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"dns_request\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "e392c78f-a345-4b96-bf0d-74c8094c4097", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "c926d20b-b30f-4293-b583-3eb169943967", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "9cc0eae4-eef1-44ec-92cd-47819501695f", + "type": "http", + "documentation": "", + "method": "post", + "endpoint": "validate/:validation_id", + "responses": [ + { + "uuid": "69bc6592-1208-40a9-a3c0-f8b445fc9f4c", + "body": "", + "latency": 0, + "statusCode": 204, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "179726cf-4c32-4d07-aabf-ead6d4646e17", + "body": "", + "latency": 0, + "statusCode": 429, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "fce2e6b6-0894-4f05-873d-77d8965c4c67", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "79f5ce5f-bf70-4995-bad4-6e82fc72f55f", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "bb61c12f-8185-43fe-9498-22b6661e998c", + "type": "http", + "documentation": "", + "method": "post", + "endpoint": "requests/:request_id/retry", + "responses": [ + { + "uuid": "b703c7f4-7325-448a-ab57-5b122896c3cc", + "body": "", + "latency": 0, + "statusCode": 204, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "e6abdf74-3191-4140-b6ba-3922c52e4c15", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "7b5d5713-0b5c-49d3-b3a2-af6cd366654d", + "body": "", + "latency": 0, + "statusCode": 409, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "5d168446-71d6-456b-9de8-6b6100bd2f8a", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "1055fe07-2f29-4dec-8412-2066fb330b3c", + "type": "http", + "documentation": "OpenCDS Mockoon healthcheck", + "method": "get", + "endpoint": "health", + "responses": [ + { + "uuid": "0d7f2acf-8006-4995-a5e5-39d8ba1858ef", + "body": "{\"status\":\"OK\"}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + } + ], + "rootChildren": [ + { + "type": "route", + "uuid": "210c5fa6-22dc-4c79-a89e-4289fd2b84bb" + }, + { + "type": "route", + "uuid": "8f00d8d7-7df4-4401-b751-6440ee0e1658" + }, + { + "type": "route", + "uuid": "8ff2f39f-a82f-4364-90ed-135b5ec8f19b" + }, + { + "type": "route", + "uuid": "9cc0eae4-eef1-44ec-92cd-47819501695f" + }, + { + "type": "route", + "uuid": "bb61c12f-8185-43fe-9498-22b6661e998c" + }, + { + "type": "route", + "uuid": "1055fe07-2f29-4dec-8412-2066fb330b3c" + } + ], + "proxyMode": false, + "proxyHost": "", + "proxyRemovePrefix": false, + "tlsOptions": { + "enabled": false, + "type": "CERT", + "pfxPath": "", + "certPath": "", + "keyPath": "", + "caPath": "", + "passphrase": "" + }, + "cors": true, + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "proxyReqHeaders": [ + { + "key": "", + "value": "" + } + ], + "proxyResHeaders": [ + { + "key": "", + "value": "" + } + ], + "data": [], + "callbacks": [] +} \ No newline at end of file diff --git a/.jjconflict-base-0/packages/shared/.gitignore b/.jjconflict-base-0/packages/shared/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-0/packages/shared/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-0/packages/shared/eslint.config.js b/.jjconflict-base-0/packages/shared/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-0/packages/shared/package.json b/.jjconflict-base-0/packages/shared/package.json new file mode 100644 index 0000000000..5d3700491b --- /dev/null +++ b/.jjconflict-base-0/packages/shared/package.json @@ -0,0 +1,58 @@ +{ + "name": "@cpn-console/shared", + "type": "module", + "version": "1.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "test:e2e-ci": "echo 'check cache'", + "test:watch": "vitest" + }, + "dependencies": { + "@cpn-console/logger": "workspace:*", + "@ts-rest/core": "catalog:runtime", + "short-uuid": "catalog:runtime", + "zod": "catalog:runtime", + "zod-validation-error": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/packages/shared/src/api-client.ts b/.jjconflict-base-0/packages/shared/src/api-client.ts new file mode 100644 index 0000000000..c842cb9e99 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/api-client.ts @@ -0,0 +1,50 @@ +import type { ApiFetcher } from '@ts-rest/core' +import { initClient, initContract } from '@ts-rest/core' + +export const apiPrefix: string = '/api/v1' +export const apiPrefixV2: string = '/api/v2' + +export const contractInstance: ReturnType = initContract() + +export async function getContract() { + return contractInstance.router({ + AdminTokens: (await import('./contracts/index.js')).adminTokenContract, + AdminRoles: (await import('./contracts/index.js')).adminRoleContract, + Clusters: (await import('./contracts/index.js')).clusterContract, + ServiceChains: (await import('./contracts/index.js')).serviceChainContract, + Deployments: (await import('./contracts/index.js')).deploymentContract, + Environments: (await import('./contracts/index.js')).environmentContract, + EnvironmentsV2: (await import('./contracts/index.js')).environmentContractV2, + Logs: (await import('./contracts/index.js')).logContract, + PersonalAccessTokens: (await import('./contracts/index.js')) + .personalAccessTokenContract, + Projects: (await import('./contracts/index.js')).projectContract, + ProjectsMembers: (await import('./contracts/index.js')) + .projectMemberContract, + ProjectsRoles: (await import('./contracts/index.js')).projectRoleContract, + ProjectServices: (await import('./contracts/index.js')) + .projectServiceContract, + Repositories: (await import('./contracts/index.js')).repositoryContract, + Stages: (await import('./contracts/index.js')).stageContract, + Services: (await import('./contracts/index.js')).serviceContract, + Users: (await import('./contracts/index.js')).userContract, + Zones: (await import('./contracts/index.js')).zoneContract, + System: (await import('./contracts/index.js')).systemContract, + SystemPlugin: (await import('./contracts/index.js')).systemPluginContract, + SystemSettings: (await import('./contracts/index.js')) + .systemSettingsContract, + }) +} + +export async function getApiClient( + baseUrl: string, + baseHeaders: Record, + api: ApiFetcher | undefined, +): Promise { + return initClient(await getContract(), { + baseUrl, + baseHeaders, + api, + validateResponse: false, + }) +} diff --git a/.jjconflict-base-0/packages/shared/src/contracts/_utils.ts b/.jjconflict-base-0/packages/shared/src/contracts/_utils.ts new file mode 100644 index 0000000000..ea2f4ee5ff --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/_utils.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' +import { tokenHeaderName } from '../utils/const.js' + +export const EmptySchema = z.object({}) + +export const ErrorSchema = z.lazy(() => z.object({ + message: z.string() + .optional(), + error: z.unknown().optional(), + stack: z.unknown().optional(), +})) + +export const baseHeaders = z.object({ + [tokenHeaderName]: z.string().min(1).optional(), +}) diff --git a/.jjconflict-base-0/packages/shared/src/contracts/admin-role.ts b/.jjconflict-base-0/packages/shared/src/contracts/admin-role.ts new file mode 100644 index 0000000000..dc6696bee3 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/admin-role.ts @@ -0,0 +1,76 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { AdminRoleSchema } from '../schemas/role.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminRoleContract = contractInstance.router({ + listAdminRoles: { + method: 'GET', + path: '', + responses: { + 200: AdminRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createAdminRole: { + method: 'POST', + path: '', + body: AdminRoleSchema.pick({ name: true }), + responses: { + 200: AdminRoleSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + patchAdminRoles: { + method: 'PATCH', + path: '', + body: AdminRoleSchema.partial({ name: true, permissions: true, position: true, oidcGroup: true, type: true }).array(), + responses: { + 200: AdminRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + adminRoleMemberCounts: { + method: 'GET', + path: `/member-counts`, + responses: { + 200: z.record(z.number().min(0)), // Record + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deleteAdminRole: { + method: 'DELETE', + path: `/:roleId`, + pathParams: z.object({ + roleId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/admin/roles`, +}) diff --git a/.jjconflict-base-0/packages/shared/src/contracts/admin-token.ts b/.jjconflict-base-0/packages/shared/src/contracts/admin-token.ts new file mode 100644 index 0000000000..8df06a7db4 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/admin-token.ts @@ -0,0 +1,54 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { AdminTokenSchema, ExposedAdminTokenSchema } from '../schemas/token.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminTokenContract = contractInstance.router({ + listAdminTokens: { + method: 'GET', + path: '', + query: z.object({ + withRevoked: CoerceBooleanSchema + .optional(), + }), + responses: { + 200: AdminTokenSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createAdminToken: { + method: 'POST', + path: '', + body: AdminTokenSchema.pick({ name: true, permissions: true, expirationDate: true }).required(), + responses: { + 201: ExposedAdminTokenSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deleteAdminToken: { + method: 'DELETE', + path: `/:tokenId`, + pathParams: z.object({ tokenId: z.string().uuid() }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/admin/tokens`, +}) diff --git a/.jjconflict-base-0/packages/shared/src/contracts/cluster.ts b/.jjconflict-base-0/packages/shared/src/contracts/cluster.ts new file mode 100644 index 0000000000..96ada83d77 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/cluster.ts @@ -0,0 +1,138 @@ +import type { ClientInferResponseBody } from '@ts-rest/core' +import type Zod from 'zod' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { + CleanedClusterSchema, + ClusterDetailsSchema, + ClusterUsageSchema, +} from '../schemas/cluster.js' +import { EnvironmentSchema } from '../schemas/environment.js' +import { UserSchema } from '../schemas/user.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const ClusterParams = z.object({ + clusterId: CleanedClusterSchema.shape.id, +}) + +export const clusterContract = contractInstance.router({ + listClusters: { + method: 'GET', + path: '', + summary: 'Get clusters', + description: 'Retrieve clusters authorized for user', + responses: { + 200: z.array(CleanedClusterSchema), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + createCluster: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create cluster', + description: 'Create new cluster.', + body: ClusterDetailsSchema.omit({ id: true }), + responses: { + 201: ClusterDetailsSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterDetails: { + method: 'GET', + path: `/:clusterId`, + summary: 'Get cluster details', + description: 'Retrieved details of a cluster.', + pathParams: ClusterParams, + responses: { + 200: ClusterDetailsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterUsage: { + method: 'GET', + path: `/usage/:clusterId`, + summary: 'Get cluster usage', + description: 'Retrieved resources usage of a cluster.', + pathParams: ClusterParams, + responses: { + 200: ClusterUsageSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterEnvironments: { + method: 'GET', + path: `/:clusterId/environments`, + summary: 'Get cluster envs', + description: 'Retrieved environments linked to a cluster.', + pathParams: ClusterParams, + responses: { + 200: z.array(z.object({ + // TODO: Remettre `ProjectSchemaV2.shape.name` mais attention aux projets non compatibles + project: z.string(), + name: EnvironmentSchema.shape.name, + owner: UserSchema.shape.email.optional(), + cpu: EnvironmentSchema.shape.cpu, + gpu: EnvironmentSchema.shape.gpu, + memory: EnvironmentSchema.shape.memory, + })), + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateCluster: { + method: 'PUT', + path: `/:clusterId`, + summary: 'Update cluster', + description: 'Update a cluster by its ID.', + pathParams: ClusterParams, + body: ClusterDetailsSchema.omit({ id: true }).partial(), + responses: { + 200: ClusterDetailsSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteCluster: { + method: 'DELETE', + path: `/:clusterId`, + summary: 'Delete cluster', + description: 'Delete a cluster by its ID.', + query: z.object({ force: CoerceBooleanSchema.optional() }), + pathParams: ClusterParams, + body: ContractNoBody, + responses: { + 204: z.string() + .nullable(), + 400: ErrorSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/clusters`, +}) + +export type ClusterAssociatedEnvironments = ClientInferResponseBody +export type CreateClusterBody = Zod.infer +export type UpdateClusterBody = Zod.infer diff --git a/.jjconflict-base-0/packages/shared/src/contracts/deployment.ts b/.jjconflict-base-0/packages/shared/src/contracts/deployment.ts new file mode 100644 index 0000000000..968e65e940 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/deployment.ts @@ -0,0 +1,91 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + CreateDeploymentSchema, + DeploymentSchema, + UpdateDeploymentSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const deploymentContract = contractInstance.router({ + createDeployment: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create deployment', + description: 'Create new deployment.', + body: CreateDeploymentSchema, + responses: { + 201: DeploymentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listDeployments: { + method: 'GET', + path: '', + summary: 'Get deployments', + description: 'Retrieved project deployments.', + responses: { + 200: DeploymentSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateDeployment: { + method: 'PUT', + path: `/:deploymentId`, + summary: 'Update deployment', + description: 'Update a deployment by its ID.', + pathParams: z.object({ + deploymentId: z.string() + .uuid(), + }), + body: UpdateDeploymentSchema, + responses: { + 200: DeploymentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteDeployment: { + method: 'DELETE', + path: `/:deploymentId`, + summary: 'Delete deployment', + description: 'Delete a deployment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + deploymentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/deployments`, + pathParams: z.object({ + projectId: z.string().uuid(), + }), +}) + +export type CreateDeploymentBody = ClientInferRequest['body'] +export type UpdateDeploymentBody = ClientInferRequest['body'] diff --git a/.jjconflict-base-0/packages/shared/src/contracts/environment.ts b/.jjconflict-base-0/packages/shared/src/contracts/environment.ts new file mode 100644 index 0000000000..8d9320733e --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/environment.ts @@ -0,0 +1,175 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, apiPrefixV2, contractInstance } from '../api-client.js' +import { + CreateEnvironmentSchema, + EnvironmentSchema, + UpdateEnvironmentSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const environmentContract = contractInstance.router({ + createEnvironment: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create environment', + description: 'Create new environment.', + body: EnvironmentSchema.omit({ id: true, createdAt: true, updatedAt: true }), + responses: { + 201: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listEnvironments: { + method: 'GET', + path: '', + summary: 'Get environments', + description: 'Retrieved project environments.', + query: z.object({ + projectId: z.string() + .uuid(), + }), + responses: { + 200: EnvironmentSchema.omit({ name: true }) + .extend({ name: z.string() }).array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateEnvironment: { + method: 'PUT', + path: `/:environmentId`, + summary: 'Update environment', + description: 'Update a environment by its ID.', + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + body: EnvironmentSchema.pick({ cpu: true, gpu: true, memory: true, autosync: true }), + responses: { + 200: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteEnvironment: { + method: 'DELETE', + path: `/:environmentId`, + summary: 'Delete environment', + description: 'Delete a environment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/environments`, +}) + +export type CreateEnvironmentBody = ClientInferRequest['body'] +export type UpdateEnvironmentBody = ClientInferRequest['body'] + +// NB: les clés de routes servent d'operationId OpenAPI (setOperationId: true) et +// doivent être uniques sur l'ensemble du contrat, d'où le suffixe V2. +export const environmentContractV2 = contractInstance.router({ + createEnvironmentV2: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create environment', + description: 'Create new environment.', + body: CreateEnvironmentSchema, + responses: { + 201: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listEnvironmentsV2: { + method: 'GET', + path: '', + summary: 'Get environments', + description: 'Retrieved project environments.', + responses: { + 200: EnvironmentSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateEnvironmentV2: { + method: 'PUT', + path: '/:environmentId', + summary: 'Update environment', + description: 'Update an environment by its ID.', + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + body: UpdateEnvironmentSchema, + responses: { + 200: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteEnvironmentV2: { + method: 'DELETE', + path: '/:environmentId', + summary: 'Delete environment', + description: 'Delete an environment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefixV2}/projects/:projectId/environments`, + pathParams: z.object({ + projectId: z.string().uuid(), + }), +}) diff --git a/.jjconflict-base-0/packages/shared/src/contracts/index.ts b/.jjconflict-base-0/packages/shared/src/contracts/index.ts new file mode 100644 index 0000000000..c5c88fab16 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/index.ts @@ -0,0 +1,18 @@ +export * from './admin-role.js' +export * from './admin-token.js' +export * from './cluster.js' +export * from './deployment.js' +export * from './environment.js' +export * from './log.js' +export * from './personal-access-token.js' +export * from './project-member.js' +export * from './project-role.js' +export * from './project-service.js' +export * from './project.js' +export * from './repository.js' +export * from './service-chain.js' +export * from './service-monitor.js' +export * from './stage.js' +export * from './system.js' +export * from './user.js' +export * from './zone.js' diff --git a/.jjconflict-base-0/packages/shared/src/contracts/log.ts b/.jjconflict-base-0/packages/shared/src/contracts/log.ts new file mode 100644 index 0000000000..062f2b3adc --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/log.ts @@ -0,0 +1,43 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import type Zod from 'zod' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { CleanLogSchema, LogSchema } from '../schemas/log.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminLogsQuery = z.object({ + offset: z.coerce.number(), + limit: z.coerce.number(), + projectId: z.string().optional(), + clean: CoerceBooleanSchema.default(true), +}) +export type AdminLogsQuery = Zod.infer + +export const logContract = contractInstance.router({ + getLogs: { + method: 'GET', + path: '', + query: adminLogsQuery, + summary: 'Get logs', + description: 'Retrieved all logs.', + responses: { + 200: z.object({ + total: z.number(), + logs: LogSchema.array().or(CleanLogSchema.array()), + }), + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/logs`, +}) + +export type GetLogsQuery = ClientInferRequest['query'] + +export type Log = Zod.infer +export type CleanLog = Zod.infer diff --git a/.jjconflict-base-0/packages/shared/src/contracts/personal-access-token.ts b/.jjconflict-base-0/packages/shared/src/contracts/personal-access-token.ts new file mode 100644 index 0000000000..72e9012596 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/personal-access-token.ts @@ -0,0 +1,49 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ExposedPersonalAccessTokenSchema, PersonalAccessTokenSchema } from '../schemas/token.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const personalAccessTokenContract = contractInstance.router({ + listPersonalAccessTokens: { + method: 'GET', + path: '', + responses: { + 200: PersonalAccessTokenSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createPersonalAccessToken: { + method: 'POST', + path: '', + body: PersonalAccessTokenSchema.pick({ name: true, expirationDate: true }).required(), + responses: { + 201: ExposedPersonalAccessTokenSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deletePersonalAccessToken: { + method: 'DELETE', + path: `/:tokenId`, + pathParams: z.object({ tokenId: z.string().uuid() }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/user/tokens`, +}) diff --git a/.jjconflict-base-0/packages/shared/src/contracts/project-member.ts b/.jjconflict-base-0/packages/shared/src/contracts/project-member.ts new file mode 100644 index 0000000000..ac9797495f --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/project-member.ts @@ -0,0 +1,68 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { MemberSchema } from '../schemas/user.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const projectMemberContract = contractInstance.router({ + listMembers: { + method: 'GET', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + addMember: { + method: 'POST', + path: '', + body: z.object({ email: z.string() }).or(z.object({ userId: z.string() })), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 201: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + patchMembers: { + method: 'PATCH', + path: '', + body: z.object({ + userId: z.string().uuid(), + roles: z.string().uuid().array(), + }).array(), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + removeMember: { + method: 'DELETE', + path: `/:userId`, + pathParams: z.object({ + projectId: z.string().uuid(), + userId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/members`, +}) diff --git a/.jjconflict-base-0/packages/shared/src/contracts/project-role.ts b/.jjconflict-base-0/packages/shared/src/contracts/project-role.ts new file mode 100644 index 0000000000..b909a994db --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/project-role.ts @@ -0,0 +1,80 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ProjectRoleSchema } from '../schemas/role.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const projectRoleContract = contractInstance.router({ + listProjectRoles: { + method: 'GET', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + createProjectRole: { + method: 'POST', + path: '', + body: ProjectRoleSchema.omit({ position: true, id: true, projectId: true }), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + // 200: z.any(), + 201: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + patchProjectRoles: { + method: 'PATCH', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + // body: z.any(), + body: ProjectRoleSchema.pick({ id: true }).merge(ProjectRoleSchema.omit({ id: true, projectId: true }).partial()).array(), + responses: { + // 200: z.any(), + 200: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + projectRoleMemberCounts: { + method: 'GET', + path: `/member-counts`, + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: z.record(z.number().min(0)), // Record + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + deleteProjectRole: { + method: 'DELETE', + path: `/:roleId`, + pathParams: z.object({ + projectId: z.string().uuid(), + roleId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/roles`, +}) diff --git a/.jjconflict-base-0/packages/shared/src/contracts/project-service.ts b/.jjconflict-base-0/packages/shared/src/contracts/project-service.ts new file mode 100644 index 0000000000..a0545d06b2 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/project-service.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { pluginUpdateBody } from '../schemas/config.js' +import { permissionTarget, ServiceSchema } from '../schemas/services.js' +import { baseHeaders, ErrorSchema } from './_utils.js' +import { ProjectParams } from './project.js' + +export const projectServiceContract = contractInstance.router({ + getServices: { + method: 'GET', + path: '', + summary: 'Get Project\'s services', + description: 'Get all informations about services related to a project.', + query: z.object({ permissionTarget }), + pathParams: ProjectParams, + responses: { + 200: ServiceSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateProjectServices: { + method: 'POST', + path: '', + summary: 'Update project service configuration', + description: 'Update project service configuration', + pathParams: ProjectParams, + body: pluginUpdateBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/services`, +}) diff --git a/.jjconflict-base-0/packages/shared/src/contracts/project.ts b/.jjconflict-base-0/packages/shared/src/contracts/project.ts new file mode 100644 index 0000000000..8532849b4e --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/project.ts @@ -0,0 +1,184 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ProjectSchemaV2 } from '../schemas/project.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const ProjectParams = z.object({ + projectId: z.string().regex(/[a-z0-9-]*/), // uuid or slug like +}) + +export const projectContract = contractInstance.router({ + createProject: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create project', + description: 'Create a new project.', + body: ProjectSchemaV2.pick({ + name: true, + description: true, + limitless: true, + hprodCpu: true, + hprodGpu: true, + hprodMemory: true, + prodCpu: true, + prodGpu: true, + prodMemory: true, + }), + responses: { + 201: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + bulkActionProject: { + method: 'POST', + path: '-bulk', + contentType: 'application/json', + summary: 'Perform bulk action on projects', + description: 'Perform bulk action on projects.', + body: z.object({ + action: z.enum(['archive', 'lock', 'unlock', 'replay']), + projectIds: z.string().uuid().array().or(z.literal('all')), + }), + responses: { + 202: null, + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getProject: { + method: 'GET', + path: '/:projectId', + pathParams: ProjectParams, + summary: 'Get a project', + description: 'Get a project', + responses: { + 200: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listProjects: { + method: 'GET', + path: '', + query: ProjectSchemaV2 + .pick({ + id: true, + name: true, + status: true, + locked: true, + description: true, + lastSuccessProvisionningVersion: true, + }) + .extend({ + statusIn: z.string(), + statusNotIn: z.string(), + filter: z.enum(['owned', 'member', 'all']), + search: z.string(), + }) + .partial(), + summary: 'Get projects', + description: 'Get projects with filters', + responses: { + 200: ProjectSchemaV2.omit({ + name: true, + }).extend({ name: z.string() }).array(), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getProjectSecrets: { + method: 'GET', + path: `/:projectId/secrets`, + summary: 'Get project secrets', + description: 'Retrieved a project secrets.', + pathParams: ProjectParams, + responses: { + 200: z.record(z.record(z.string())), + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateProject: { + method: 'PUT', + path: `/:projectId`, + summary: 'Update project', + description: 'Update a project.', + pathParams: ProjectParams, + body: ProjectSchemaV2 + .pick({ + description: true, + everyonePerms: true, + locked: true, + ownerId: true, + limitless: true, + hprodMemory: true, + hprodCpu: true, + hprodGpu: true, + prodMemory: true, + prodCpu: true, + prodGpu: true, + }) + .partial(), + responses: { + 200: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 500: ErrorSchema, + }, + }, + + replayHooksForProject: { + method: 'PUT', + path: `/:projectId/hooks`, + summary: 'Replay hooks for project', + description: 'Replay hooks for a project.', + body: ContractNoBody, + pathParams: ProjectParams, + responses: { + 204: null, + 500: ErrorSchema, + }, + }, + + archiveProject: { + method: 'DELETE', + path: `/:projectId`, + summary: 'Delete project', + description: 'Delete a project.', + pathParams: ProjectParams, + body: ContractNoBody, + responses: { + 204: null, + 500: ErrorSchema, + }, + }, + + getProjectsData: { + method: 'GET', + path: `/data`, + summary: 'Download projects csv report', + description: 'Retrieve all projects data for download as CSV file.', + responses: { + 200: z.string(), + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects`, +}) + +export type CreateProjectBody = ClientInferRequest['body'] diff --git a/.jjconflict-base-0/packages/shared/src/contracts/repository.ts b/.jjconflict-base-0/packages/shared/src/contracts/repository.ts new file mode 100644 index 0000000000..131deee00e --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/repository.ts @@ -0,0 +1,107 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { RepoSchema } from '../schemas/repository.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const repositoryContract = contractInstance.router({ + createRepository: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create repo', + description: 'Create new repo.', + body: RepoSchema.omit({ id: true, createdAt: true, updatedAt: true }), + responses: { + 201: RepoSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listRepositories: { + method: 'GET', + path: '', + summary: 'Get repos', + description: 'Retrieved all repos.', + query: z.object({ + projectId: z.string() + .uuid(), + }), + responses: { + 200: z.array(RepoSchema + .omit({ internalRepoName: true }) + .extend({ internalRepoName: z.string() }), + ), + 500: ErrorSchema, + }, + }, + + syncRepository: { + method: 'POST', + path: `/:repositoryId/sync`, + summary: 'application/json', + description: 'Trigger a gitlab synchronization for a repository', + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + body: z.object({ + syncAllBranches: z.boolean(), + branchName: z.string().optional(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateRepository: { + method: 'PUT', + path: `/:repositoryId`, + summary: 'Update repo', + description: 'Update a repo by its ID.', + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + body: RepoSchema.omit({ createdAt: true, updatedAt: true }).partial(), + responses: { + 200: RepoSchema, + 500: ErrorSchema, + }, + }, + + deleteRepository: { + method: 'DELETE', + path: `/:repositoryId`, + summary: 'Delete repo', + description: 'Delete a repo by its ID.', + body: ContractNoBody, + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/repositories`, +}) + +export type CreateRepositoryBody = ClientInferRequest['body'] + +export type UpdateRepositoryBody = ClientInferRequest['body'] + +export type SyncRepositoryParams = ClientInferRequest['params'] + +export type RepositoryParams = ClientInferRequest['params'] diff --git a/.jjconflict-base-0/packages/shared/src/contracts/service-chain.ts b/.jjconflict-base-0/packages/shared/src/contracts/service-chain.ts new file mode 100644 index 0000000000..e98f864db8 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/service-chain.ts @@ -0,0 +1,99 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainSchema, +} from '../schemas/index.js' +import { baseHeaders, EmptySchema, ErrorSchema } from './_utils.js' + +export const ServiceChainParams = z.object({ + serviceChainId: ServiceChainSchema.shape.id, +}) + +export const ServiceChainValidationParams = z.object({ + validationId: ServiceChainDetailsSchema.shape.validationId, +}) + +export const serviceChainContract = contractInstance.router( + { + listServiceChains: { + method: 'GET', + path: '', + summary: 'Get Service Chains', + description: 'Retrieve Service Chains authorized for user', + responses: { + 200: z.array(ServiceChainSchema), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getServiceChainDetails: { + method: 'GET', + path: `/:serviceChainId`, + summary: 'Get Service Chain details', + description: 'Retrieved details of a Service Chain.', + pathParams: ServiceChainParams, + responses: { + 200: ServiceChainDetailsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + retryServiceChain: { + method: 'POST', + path: `/:serviceChainId/retry`, + summary: 'Retry Service Chain creation', + description: + 'Retry the whole service chain creation from the point it failed.', + pathParams: ServiceChainParams, + body: ContractNoBody, + responses: { + 204: null, + 401: ErrorSchema, + 404: ErrorSchema, + 429: EmptySchema, // Already successful, no retry needed + 500: ErrorSchema, + }, + }, + + validateServiceChain: { + method: 'POST', + path: `/validate/:validationId`, + summary: 'Validate Service Chain creation', + description: 'Trigger the whole service chain creation.', + pathParams: ServiceChainValidationParams, + body: ContractNoBody, + responses: { + 204: null, + 401: ErrorSchema, + 404: ErrorSchema, + 429: EmptySchema, // Validation already done + 500: ErrorSchema, + }, + }, + + getServiceChainFlows: { + method: 'GET', + path: `/:serviceChainId/flows`, + summary: 'Get Service Chain flow details', + description: 'Retrieved flow details of a Service Chain.', + pathParams: ServiceChainParams, + responses: { + 200: ServiceChainFlowsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + }, + { + baseHeaders, + pathPrefix: `${apiPrefix}/service-chains`, + }, +) diff --git a/.jjconflict-base-0/packages/shared/src/contracts/service-monitor.ts b/.jjconflict-base-0/packages/shared/src/contracts/service-monitor.ts new file mode 100644 index 0000000000..c2b02e304b --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/service-monitor.ts @@ -0,0 +1,51 @@ +import type { ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ServiceHealthSchema } from '../schemas/services.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const serviceContract = contractInstance.router({ + getServiceHealth: { + method: 'GET', + path: '/health-services', + summary: 'Get services health', + description: 'Retrieve services health.', + responses: { + 200: ServiceHealthSchema.array(), + 500: ErrorSchema, + }, + }, + + getCompleteServiceHealth: { + method: 'GET', + path: '/complete-services', + summary: 'Get services health with cause', + description: 'Retrieve services health with cause.', + responses: { + 200: ServiceHealthSchema.extend({ + cause: z.any().optional(), + }).array(), + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + refreshServiceHealth: { + method: 'GET', + path: '/refresh-services', + summary: 'Force services health', + description: 'Retrieved services health.', + responses: { + 200: ServiceHealthSchema.array(), + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}`, +}) + +export type ServiceBody = ClientInferResponseBody diff --git a/.jjconflict-base-0/packages/shared/src/contracts/stage.ts b/.jjconflict-base-0/packages/shared/src/contracts/stage.ts new file mode 100644 index 0000000000..a8f20f62cf --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/stage.ts @@ -0,0 +1,95 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { StageSchema } from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const stageContract = contractInstance.router({ + listStages: { + method: 'GET', + path: '', + summary: 'Get stages', + description: 'Retrieved all stages.', + responses: { + 200: StageSchema.array(), + 500: ErrorSchema, + }, + }, + + createStage: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create stage', + description: 'Create new stage.', + body: StageSchema.omit({ id: true }).partial({ clusterIds: true }), + responses: { + 201: StageSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getStageEnvironments: { + method: 'GET', + path: `/:stageId/environments`, + summary: 'Get stages', + description: 'Retrieved all stages.', + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + responses: { + 200: z.array(z.object({ + project: z.string(), + name: z.string(), + cluster: z.string(), + owner: z.string().optional(), + })), + 500: ErrorSchema, + }, + }, + + updateStage: { + method: 'PUT', + path: `/:stageId`, + summary: 'Update stage', + description: 'Update a stage by its ID.', + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + body: StageSchema.pick({ clusterIds: true, name: true }), + responses: { + 200: StageSchema, + 500: ErrorSchema, + }, + }, + + deleteStage: { + method: 'DELETE', + path: `/:stageId`, + summary: 'Delete stage', + description: 'Delete a stage by its ID.', + body: ContractNoBody, + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/stages`, +}) + +export type CreateStageBody = ClientInferRequest['body'] + +export type UpdateStageBody = ClientInferRequest['body'] + +export type StageAssociatedEnvironments = ClientInferResponseBody diff --git a/.jjconflict-base-0/packages/shared/src/contracts/system.ts b/.jjconflict-base-0/packages/shared/src/contracts/system.ts new file mode 100644 index 0000000000..b5c77864fe --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/system.ts @@ -0,0 +1,111 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + pluginSchema, + pluginUpdateBody, + SystemSettingSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const systemContract = contractInstance.router({ + getVersion: { + method: 'GET', + path: `/version`, + summary: 'Get version', + description: 'Retrieve api version.', + responses: { + 200: z.object({ + version: z.string(), + }), + 500: ErrorSchema, + }, + }, + + getHealth: { + method: 'GET', + path: `/healthz`, + summary: 'Get health', + description: 'Retrieve api health infos.', + responses: { + 200: z.object({ + status: z.enum(['OK', 'KO']), + }), + 500: ErrorSchema, + }, + }, +}, { + pathPrefix: `${apiPrefix}`, +}) + +export const systemPluginContract = contractInstance.router({ + getPluginsConfig: { + method: 'GET', + path: '', + summary: 'Get plugins configuration', + description: 'Get plugins configuration', + responses: { + 200: pluginSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updatePluginsConfig: { + method: 'POST', + path: '', + summary: 'Update project service configuration', + description: 'Update project service configuration', + body: pluginUpdateBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/system/plugins`, +}) + +export const systemSettingsContract = contractInstance.router({ + listSystemSettings: { + method: 'GET', + path: '', + summary: 'Get System Settings state', + description: 'Get System Settings state', + query: SystemSettingSchema.pick({ key: true }) + .partial(), + responses: { + 200: SystemSettingSchema.array(), + 500: ErrorSchema, + }, + }, + + upsertSystemSetting: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Update System Settings state', + description: 'Update System Settings state', + body: SystemSettingSchema, + responses: { + 201: SystemSettingSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/system/settings`, +}) + +export type SystemSettings = ClientInferResponseBody + +export type SystemSetting = SystemSettings[number] + +export type UpsertSystemSettingBody = ClientInferRequest['body'] diff --git a/.jjconflict-base-0/packages/shared/src/contracts/user.ts b/.jjconflict-base-0/packages/shared/src/contracts/user.ts new file mode 100644 index 0000000000..508ef91171 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/user.ts @@ -0,0 +1,83 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { UuidOrCsvUuidSchema } from '../schemas/_utils.js' +import { + RoleNameCsvSchema, + UserSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const userContract = contractInstance.router({ + getMatchingUsers: { + method: 'GET', + path: `${apiPrefix}/users/matching`, + query: z.object({ + letters: z.string(), + notInProjectId: z.string().uuid().optional(), + }), + summary: 'Get users by letters matching', + description: 'Retrieved users by letters matching.', + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + auth: { + method: 'GET', + path: `${apiPrefix}/auth`, + summary: 'Login', + description: 'OIDC callback to signin or signup', + responses: { + 200: UserSchema, + 307: null, + 500: ErrorSchema, + }, + }, + + getAllUsers: { + method: 'GET', + path: `${apiPrefix}/users`, + summary: 'Get all users', + description: 'Get all users.', + query: z.object({ + adminRoles: RoleNameCsvSchema + .optional(), + adminRoleIds: UuidOrCsvUuidSchema + .optional(), + memberOfIds: UuidOrCsvUuidSchema + .optional(), + relationType: z.enum(['OR', 'AND']) + .optional(), + }), + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + patchUsers: { + method: 'PATCH', + path: `${apiPrefix}/users`, + summary: 'Patch users', + body: UserSchema.pick({ adminRoleIds: true, id: true }).array(), + description: 'Update user admin role.', + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, +}) + +export type LettersQuery = ClientInferRequest['query'] + +export type AllUsers = ClientInferResponseBody diff --git a/.jjconflict-base-0/packages/shared/src/contracts/zone.ts b/.jjconflict-base-0/packages/shared/src/contracts/zone.ts new file mode 100644 index 0000000000..90a300ba5f --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/contracts/zone.ts @@ -0,0 +1,83 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + ZoneSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const zoneContract = contractInstance.router({ + listZones: { + method: 'GET', + path: '', + summary: 'Get zones', + description: 'Get all zones.', + responses: { + 200: ZoneSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + createZone: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create zone', + description: 'Create new zone.', + body: ZoneSchema + .omit({ id: true }) + .extend({ clusterIds: z.string().uuid().array().optional() }), + responses: { + 201: ZoneSchema, + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateZone: { + method: 'PUT', + path: `/:zoneId`, + summary: 'Update zone', + description: 'Update a zone by its ID.', + pathParams: z.object({ + zoneId: z.string() + .uuid(), + }), + body: ZoneSchema.omit({ id: true, slug: true }), + responses: { + 201: ZoneSchema, + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteZone: { + method: 'DELETE', + path: `/:zoneId`, + summary: 'Delete zone', + description: 'Delete a zone by its ID.', + body: ContractNoBody, + pathParams: z.object({ + zoneId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/zones`, +}) + +export type CreateZoneBody = ClientInferRequest['body'] +export type UpdateZoneBody = ClientInferRequest['body'] diff --git a/.jjconflict-base-0/packages/shared/src/index.ts b/.jjconflict-base-0/packages/shared/src/index.ts new file mode 100644 index 0000000000..8cc07ea318 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/index.ts @@ -0,0 +1,4 @@ +export * from './api-client.js' +export * from './contracts/index.js' +export * from './schemas/index.js' +export * from './utils/index.js' diff --git a/.jjconflict-base-0/packages/shared/src/schemas/_utils.ts b/.jjconflict-base-0/packages/shared/src/schemas/_utils.ts new file mode 100644 index 0000000000..91cea1b16a --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/_utils.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +export const CoerceBooleanSchema = z.boolean() + .or(z.enum(['true', 'false']) + .transform(value => value === 'true')) + +export const dateToString = z.string().or(z.date().transform(date => date.toISOString())) + +export const AtDatesToStringExtend = { + updatedAt: dateToString, + createdAt: dateToString, +} +export const permissionLevelSchema = z.coerce.string() + +export const UuidOrCsvUuidSchema = z.string() + .refine((value) => { + return !value + .split(',') + .some(uuid => !z.string().uuid().safeParse(uuid).success) + }) + .transform(value => value.split(',')) diff --git a/.jjconflict-base-0/packages/shared/src/schemas/cluster.ts b/.jjconflict-base-0/packages/shared/src/schemas/cluster.ts new file mode 100644 index 0000000000..bac22d02c0 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/cluster.ts @@ -0,0 +1,71 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const ClusterPrivacySchema = z.enum(['public', 'dedicated']) + +export const CleanedClusterSchema = z.object({ + id: z.string() + .uuid(), + label: z.string() + .regex(/^[a-z0-9-]+$/i) + .max(50), + infos: z.string() + .max(1000) + .optional() + .nullable() + .transform(value => value ?? ''), + clusterResources: z.boolean(), + privacy: ClusterPrivacySchema, + zoneId: z.string() + .uuid(), + stageIds: z.string() + .uuid() + .array(), + cpu: z.coerce.number().gte(0), + gpu: z.coerce.number().gte(0), + memory: z.coerce.number().gte(0), +}) + +export const KubeconfigSchema = z.object({ + user: z.object({ + username: z.string() + .optional(), + password: z.string() + .optional(), + keyData: z.string() + .optional(), + certData: z.string() + .optional(), + token: z.string() + .optional(), + }), + cluster: z.object({ + server: z.string() + .optional(), + tlsServerName: z.string(), + skipTLSVerify: z.boolean() + .optional(), + caData: z.string() + .optional(), + }), +}) + +export const ClusterDetailsSchema = CleanedClusterSchema.merge(z.object({ + projectIds: z.string() + .uuid() + .array() + .optional(), + kubeconfig: KubeconfigSchema, +})) + +export const ClusterUsageSchema = z.object({ + cpu: z.number(), + gpu: z.number(), + memory: z.number(), +}) + +export type Cluster = Zod.infer +export type ClusterDetails = Zod.infer +export type Kubeconfig = Zod.infer + +export type CleanedCluster = Zod.infer diff --git a/.jjconflict-base-0/packages/shared/src/schemas/config.ts b/.jjconflict-base-0/packages/shared/src/schemas/config.ts new file mode 100644 index 0000000000..cca95debd8 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/config.ts @@ -0,0 +1,84 @@ +import type Zod from 'zod' +import { z } from 'zod' + +const configItemBase = z.object({ + key: z.string().min(2).regex(/[a-z-]/), + description: z.string().optional(), + title: z.string(), + section: z.string().optional(), +}) + +const projectVisibilty = z.object({ + permissions: z.object({ + user: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + admin: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + }), +}) + +const globalVisibilty = z.object({ + permissions: z.object({ + user: z.object({ + read: z.boolean(), + write: z.boolean().refine(value => !value, { message: 'global params can\'t be user writable' }), + }), + admin: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + }), +}) + +export const ENABLED = 'enabled' +export const DISABLED = 'disabled' +export const DEFAULT = 'default' + +export const atomicValidators = { + switch: z.enum([DISABLED, DEFAULT, ENABLED]), + // eslint-disable-next-line regexp/prefer-w, regexp/use-ignore-case + text: z.string().trim().regex(/[a-zA-Z-_0-9 ]*/), +} + +export type SwitchParam = Zod.infer + +const configItemSwitch = z.object({ + kind: z.literal('switch'), + value: atomicValidators.switch, + initialValue: atomicValidators.switch, +}).merge(configItemBase) + +const configItemInput = z.object({ + kind: z.literal('text'), + value: atomicValidators.text, + placeholder: z.string().optional(), +}).merge(configItemBase) + +const configGlobalItemDeclaration = z.discriminatedUnion('kind', [ + configItemSwitch.merge(globalVisibilty), + configItemInput.merge(globalVisibilty), +]) + +export type PluginConfigItem = Zod.infer + +export const configProjectItemDeclaration = z.discriminatedUnion('kind', [ + configItemSwitch.merge(projectVisibilty), + configItemInput.merge(projectVisibilty), +]) + +export const pluginConfig = z.object({ + global: configGlobalItemDeclaration.array(), + project: configProjectItemDeclaration.array(), +}) + +export type PluginConfig = Zod.infer + +export const pluginsPopulatedManifests = pluginConfig.partial() +export type PluginsPopulatedManifests = Zod.infer + +export const pluginUpdateBody = z.record(z.record(z.string())) +export type PluginsUpdateBody = Zod.infer diff --git a/.jjconflict-base-0/packages/shared/src/schemas/deployment.ts b/.jjconflict-base-0/packages/shared/src/schemas/deployment.ts new file mode 100644 index 0000000000..b1e0f8ee47 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/deployment.ts @@ -0,0 +1,70 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestDeploymentName } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' +import { EnvironmentSchema } from './environment.js' +import { RepoSchema } from './repository.js' + +const DeploymentSourceType = z.enum(['git', 'oci']) + +export const DeploymentSourceSchema = z.object({ + id: z.string() + .uuid(), + deploymentId: z.string() + .uuid(), + repositoryId: z.string() + .uuid(), + type: DeploymentSourceType, + repository: RepoSchema, + // Optional deployment settings + targetRevision: z.string().optional(), + path: z.string().optional(), + helmValuesFiles: z.string().optional(), +}).extend(AtDatesToStringExtend) + +export const DeploymentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestDeploymentName), + projectId: z.string() + .uuid(), + environmentId: z.string() + .uuid(), + autosync: z.boolean(), + environment: EnvironmentSchema, + deploymentSources: DeploymentSourceSchema.array(), +}).extend(AtDatesToStringExtend) + +export const CreateDeploymentSchema = DeploymentSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + environment: true, +}).extend({ + deploymentSources: DeploymentSourceSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deploymentId: true, + repository: true, + }).array().min(1, 'Au moins une source de déploiement est requise'), +}) + +export const UpdateDeploymentSchema = CreateDeploymentSchema.extend({ + deploymentSources: DeploymentSourceSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deploymentId: true, + repository: true, + }) + .extend({ id: z.string().uuid().optional() }) + .array().min(1, 'Au moins une source de déploiement est requise'), +}) + +export type Deployment = Zod.infer +export type CreateDeployment = Zod.infer +export type UpdateDeployment = Zod.infer diff --git a/.jjconflict-base-0/packages/shared/src/schemas/environment.ts b/.jjconflict-base-0/packages/shared/src/schemas/environment.ts new file mode 100644 index 0000000000..f3c9bd2d8c --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/environment.ts @@ -0,0 +1,40 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestEnvironmentName } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' + +export const EnvironmentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestEnvironmentName), + projectId: z.string() + .uuid(), + stageId: z.string().uuid(), + clusterId: z.string() + .uuid(), + cpu: z.coerce.number().gte(0), + gpu: z.coerce.number().gte(0), + memory: z.coerce.number().gte(0), + autosync: z.boolean(), +}).extend(AtDatesToStringExtend) + +export const CreateEnvironmentSchema = EnvironmentSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, +}) + +export const UpdateEnvironmentSchema = EnvironmentSchema.pick({ + cpu: true, + gpu: true, + memory: true, + autosync: true, +}) + +export type Environment = Zod.infer +export type CreateEnvironment = Zod.infer +export type UpdateEnvironment = Zod.infer diff --git a/.jjconflict-base-0/packages/shared/src/schemas/index.ts b/.jjconflict-base-0/packages/shared/src/schemas/index.ts new file mode 100644 index 0000000000..236c4124b2 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/index.ts @@ -0,0 +1,15 @@ +export * from './cluster.js' +export * from './config.js' +export * from './deployment.js' +export * from './environment.js' +export * from './log.js' +export * from './project.js' +export * from './repository.js' +export * from './role.js' +export * from './service-chain.js' +export * from './services.js' +export * from './stage.js' +export * from './system.js' +export * from './token.js' +export * from './user.js' +export * from './zone.js' diff --git a/.jjconflict-base-0/packages/shared/src/schemas/log.ts b/.jjconflict-base-0/packages/shared/src/schemas/log.ts new file mode 100644 index 0000000000..a58f2777f2 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/log.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import { AtDatesToStringExtend } from './_utils.js' + +export const CleanLogSchema = z.object({ + id: z.string(), + data: z.object({ + failed: z.boolean().or(z.array(z.string())).optional(), + warning: z.string().array().optional(), + totalExecutionTime: z.number().optional(), + messageResume: z.string().optional(), + }).strip(), + action: z.string(), + userId: z.string().nullable(), +}) + .extend(AtDatesToStringExtend) + +export const LogSchema = z.object({ + id: z.string(), + data: z.object({ + args: z.any(), + failed: z.boolean().or(z.array(z.string())).optional(), + warning: z.string().array().optional(), + results: z.any(), + totalExecutionTime: z.number().optional(), + messageResume: z.string().optional(), + }) + .passthrough() + .transform((data) => { + delete data.config + return data + }), + action: z.string(), + userId: z.string().nullable(), + requestId: z.string().nullable(), + projectId: z.string().nullable(), +}) + .extend(AtDatesToStringExtend) diff --git a/.jjconflict-base-0/packages/shared/src/schemas/project.ts b/.jjconflict-base-0/packages/shared/src/schemas/project.ts new file mode 100644 index 0000000000..6f4e549f78 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/project.ts @@ -0,0 +1,87 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestEnvironmentName, projectStatus } from '../utils/const.js' +import { AtDatesToStringExtend, CoerceBooleanSchema, permissionLevelSchema } from './_utils.js' +import { RepoSchema } from './repository.js' +import { ProjectRoleSchema } from './role.js' +import { MemberSchema, UserSchema } from './user.js' + +export const descriptionMaxLength = 280 +export const projectNameMaxLength = 20 +export const ProjectStatusSchema = z.enum(projectStatus) + +const ProjectEnvironmentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestEnvironmentName), + slug: z.string() + .regex(/^[a-z0-9]+$/), + projectId: z.string() + .uuid(), + stageId: z.string().uuid(), + clusterId: z.string() + .uuid(), + permissions: z.array(z.object({ + id: z.string() + .uuid(), + userId: z.string() + .uuid(), + environmentId: z.string() + .uuid(), + level: z.union([ + z.string(), + z.number() + .int() + .nonnegative() + .max(2), + ]), + })), +}) +export const ProjectSchema = z.object({ + // ProjectInfos + repositories: RepoSchema.array(), + environments: ProjectEnvironmentSchema.array(), +}) + +export type Project = Zod.infer + +export const ProjectSchemaV2 = z.object({ + clusterIds: z.string().uuid().array(), + name: z.string() + .min(2) + .max(projectNameMaxLength) + .regex(/^[a-z0-9]+$/), + slug: z.string(), + description: z.string() + .max(descriptionMaxLength) + .optional(), + status: ProjectStatusSchema, + locked: CoerceBooleanSchema, + limitless: CoerceBooleanSchema, + hprodCpu: z.coerce.number().gte(0), + hprodGpu: z.coerce.number().gte(0), + hprodMemory: z.coerce.number().gte(0), + prodCpu: z.coerce.number().gte(0), + prodGpu: z.coerce.number().gte(0), + prodMemory: z.coerce.number().gte(0), + + id: z.string() + .uuid(), + members: MemberSchema + .array(), + ownerId: z.string() + .uuid(), + owner: UserSchema + .omit({ adminRoleIds: true }), + roles: ProjectRoleSchema + .array(), + everyonePerms: permissionLevelSchema, + lastSuccessProvisionningVersion: z.string() + .nullable(), +}) + .extend(AtDatesToStringExtend) + +export type ProjectV2 = Zod.infer diff --git a/.jjconflict-base-0/packages/shared/src/schemas/repository.ts b/.jjconflict-base-0/packages/shared/src/schemas/repository.ts new file mode 100644 index 0000000000..85675b9123 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/repository.ts @@ -0,0 +1,74 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { forbiddenRepoNames, invalidGitUrl, invalidInternalRepoName, missingCredentials } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' + +export const RepoSchema = z.object({ + id: z.string() + .uuid(), + internalRepoName: z.string() + .min(2, { message: 'Longueur minimum 2 caractères' }) + .max(20, { message: 'Longueur maximum 20 caractères' }) + .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, { message: invalidInternalRepoName }) + .refine(name => !forbiddenRepoNames.includes(name.toLowerCase()), { + message: `Le nom du dépôt choisi est inclus dans la liste d'exclusion: ${forbiddenRepoNames.join(', ')}`, + }), + externalRepoUrl: z.string() + .regex(/^https:\/\/.*\.git$/, { message: invalidGitUrl }) + .url({ message: 'Url invalide' }) + .or(z.literal('')) + .optional(), + isPrivate: z.boolean(), + isInfra: z.boolean(), + externalUserName: z.string() + .optional(), + externalToken: z.string() + .optional(), + projectId: z.string() + .uuid(), + // Optional deployment settings for infra repositories + deployRevision: z.string().optional(), + deployPath: z.string().optional(), + helmValuesFiles: z.string().optional(), +}).extend(AtDatesToStringExtend) + +// To only use in frontend form +export const RepoFormSchema = RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .extend({ isStandalone: z.boolean() }) + +export const UpdateRepoFormSchema = RepoFormSchema + .refine( + ({ isPrivate, externalToken, externalUserName }) => { + if (isPrivate) { + if (!externalToken && !externalUserName) return false + return true + } + return true + }, + { message: missingCredentials, path: ['credentials'] }, + ) + .refine(({ isStandalone, externalRepoUrl }) => { + if (!isStandalone && !externalRepoUrl) { + return false + } + return true + }, { message: 'Veuillez renseignez l\'url du dépôt externe', path: ['externalRepoUrl'] }) + +export const CreateRepoFormSchema = RepoFormSchema + .omit({ id: true, projectId: true }) + .refine(({ isPrivate, externalToken, externalUserName }) => { + if (isPrivate) { + if (!externalToken && !externalUserName) return false + return true + } + return true + }, { message: missingCredentials, path: ['credentials'] }) + .refine(({ isStandalone, externalRepoUrl }) => { + if (!isStandalone && !externalRepoUrl) { + return false + } + return true + }, { message: 'Veuillez renseignez l\'url du dépôt externe', path: ['externalRepoUrl'] }) + +export type Repo = Zod.infer diff --git a/.jjconflict-base-0/packages/shared/src/schemas/role.ts b/.jjconflict-base-0/packages/shared/src/schemas/role.ts new file mode 100644 index 0000000000..0e0a1d226a --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/role.ts @@ -0,0 +1,35 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { permissionLevelSchema } from './_utils.js' + +export const RoleNameSchema = z.string().max(30) + +export const RoleSchema = z.object({ + id: z.string().uuid(), + name: RoleNameSchema, + permissions: permissionLevelSchema, + position: z.number().min(0), + type: z.string().optional(), +}) + +export const ProjectRoleSchema = RoleSchema.extend({ + projectId: z.string().uuid(), + oidcGroup: z.string().optional(), +}) + +export const AdminRoleSchema = RoleSchema.extend({ + oidcGroup: z.string(), + type: z.string().optional().default('managed'), +}) + +export const RoleNameCsvSchema = z.string() + .refine((value) => { + return !value.split(',').some(name => !RoleNameSchema.safeParse(name).success) + }) + .transform(value => value.split(',')) + +export type Role = Zod.infer +export type RoleBigint = Omit, 'permissions'> & { permissions: bigint } +export type AdminRole = Zod.infer +export type ProjectRole = Zod.infer +export type ProjectRoleBigint = Omit, 'permissions'> & { permissions: bigint } diff --git a/.jjconflict-base-0/packages/shared/src/schemas/service-chain.ts b/.jjconflict-base-0/packages/shared/src/schemas/service-chain.ts new file mode 100644 index 0000000000..c364fcb8d0 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/service-chain.ts @@ -0,0 +1,103 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const serviceChainStateEnum = ['opened', 'pending', 'success', 'failed'] as const +export const ServiceChainStateZodEnum = z.enum(serviceChainStateEnum) +export type ServiceChainState = Zod.infer + +export const serviceChainNetworkEnum = ['RIE', 'INTERNET'] as const +export const ServiceChainNetworkZodEnum = z.enum(serviceChainNetworkEnum) +export type ServiceChainNetwork = Zod.infer + +export const serviceChainLocationEnum = ['SIR', 'SIL'] as const +export const ServiceChainLocationZodEnum = z.enum(serviceChainLocationEnum) +export type ServiceChainLocation = Zod.infer + +export const serviceChainEnvironmentEnum = ['INT', 'PROD', 'BAS'] as const +export const ServiceChainEnvironmentZodEnum = z.enum(serviceChainEnvironmentEnum) +export type ServiceChainEnvironment = Zod.infer< + typeof ServiceChainEnvironmentZodEnum +> + +export const ServiceChainSchema = z.object({ + id: z.string().uuid(), + state: ServiceChainStateZodEnum, + commonName: + // @TODO: Replace with z.hostname when upgraded to Zod v4 + z.string(), + pai: z.string(), + network: ServiceChainNetworkZodEnum, + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}) +export type ServiceChain = Zod.infer + +export const ServiceChainListSchema = z.array(ServiceChainSchema) +export type ServiceChainList = Zod.infer + +export const ServiceChainDetailsSchema = ServiceChainSchema.extend({ + validationId: z.string().uuid(), + validatedBy: z.nullable(z.string().uuid()), + ref: z.nullable(z.string().uuid()), + location: ServiceChainLocationZodEnum, + targetAddress: z.string().ip(), + projectId: z.string().uuid(), + env: ServiceChainEnvironmentZodEnum, + subjectAlternativeName: z.array( + // @TODO: Replace with z.hostname when upgraded to Zod v4 + z.string(), + ), + redirect: z.boolean(), + antivirus: z.nullable(z.object({ + maxFileSize: z.number(), + })), + websocket: z.boolean(), + ipWhiteList: z.array(z.string().cidr()), + sslOutgoing: z.boolean(), +}) +export type ServiceChainDetails = Zod.infer + +// JSON as String validation through Zod. Instead of adding yet-another-dependency, +// I merely copied the code as-is. +// Many thanks to JacobWeisenburger/zod_utilz ! +// +// Usage: use stringToJSON() as you would use z.string() to validate strings that must +// contain JSON stringified content +const literalSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), +]) +type Literal = z.infer +type Json = Literal | { [ key: string ]: Json } | Json[] +const jsonSchema: z.ZodType = z.lazy(() => + z.union([ + literalSchema, + z.array(jsonSchema), + z.record(jsonSchema), + ]), +) +export const json = () => jsonSchema + +export const serviceChainFlowStateEnum = ['opened', 'pending', 'success', 'failed'] as const +export const ServiceChainFlowStateZodEnum = z.enum(serviceChainFlowStateEnum) +export type ServiceChainFlowState = Zod.infer + +export const ServiceChainFlowDetailsSchema = z.object({ + state: ServiceChainFlowStateZodEnum, + input: json(), + output: json(), + updatedAt: z.coerce.date(), +}) +export type ServiceChainFlowDetails = Zod.infer + +export const ServiceChainFlowsSchema = z.object({ + reserve_ip: ServiceChainFlowDetailsSchema, + create_cert: z.nullable(ServiceChainFlowDetailsSchema), + call_exec: ServiceChainFlowDetailsSchema, + activate_ip: ServiceChainFlowDetailsSchema, + dns_request: ServiceChainFlowDetailsSchema, +}) +// Flows is on object, so always used in plural sense +export type ServiceChainFlows = Zod.infer diff --git a/.jjconflict-base-0/packages/shared/src/schemas/services.ts b/.jjconflict-base-0/packages/shared/src/schemas/services.ts new file mode 100644 index 0000000000..22c8299f1f --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/services.ts @@ -0,0 +1,36 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { pluginsPopulatedManifests } from './config.js' + +export const serviceUrl = z.object({ + to: z.string(), + name: z.string(), + description: z.string() + .optional(), +}) + +export type ServiceUrl = Zod.infer + +export const ServiceSchema = z.object({ + description: z.string() + .optional(), + title: z.string(), + name: z.string(), + imgSrc: z.string() + .optional(), + urls: serviceUrl.array(), + manifest: pluginsPopulatedManifests, +}) + +export const permissionTarget = z.enum(['user', 'admin']).default('user') + +export type PermissionTarget = Zod.infer +export type ProjectService = Zod.infer + +export const ServiceHealthSchema = z.object({ + name: z.string(), + status: z.enum(['OK', 'Dégradé', 'En échec', 'Inconnu']), + interval: z.number(), + lastUpdateTimestamp: z.number(), + message: z.string(), +}) diff --git a/.jjconflict-base-0/packages/shared/src/schemas/stage.ts b/.jjconflict-base-0/packages/shared/src/schemas/stage.ts new file mode 100644 index 0000000000..51cb72d120 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/stage.ts @@ -0,0 +1,14 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const StageSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/i) + .min(2, { message: 'must be at least 2 character long' }) + .max(20, { message: 'must not exceed 20 characters' }), + clusterIds: z.string().uuid().array(), +}) + +export type Stage = Zod.infer diff --git a/.jjconflict-base-0/packages/shared/src/schemas/system.ts b/.jjconflict-base-0/packages/shared/src/schemas/system.ts new file mode 100644 index 0000000000..d941a5b9d2 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/system.ts @@ -0,0 +1,20 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { configProjectItemDeclaration } from './config.js' + +export const pluginSchema = z.object({ + description: z.string() + .optional(), + title: z.string(), + name: z.string(), + imgSrc: z.string() + .optional(), + manifest: configProjectItemDeclaration.array(), +}) + +export type PluginSchema = Zod.infer + +export const SystemSettingSchema = z.object({ + key: z.string(), + value: z.string(), +}) diff --git a/.jjconflict-base-0/packages/shared/src/schemas/token.ts b/.jjconflict-base-0/packages/shared/src/schemas/token.ts new file mode 100644 index 0000000000..36cd518ac5 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/token.ts @@ -0,0 +1,44 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { dateToString, permissionLevelSchema } from './_utils.js' +import { UserSchema } from './user.js' + +export const TokenSchema = z.object({ + id: z.string().uuid(), + name: z.string() + .max(32, { message: 'Ne peut dépasser 32 caractères' }) + .min(2, { message: 'Ne peut faire moins de 2 caractères' }) + .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, { message: 'Le nom ne peut être constitué que de caractères minuscules, de chiffres et de tirets (-)' }), + lastUse: dateToString.nullable(), + createdAt: dateToString, + owner: UserSchema + .pick({ email: true, firstName: true, lastName: true, id: true, type: true }) + .optional() + .nullable(), + status: z.enum(['active', 'revoked', 'inactive']), +}) + +// Admin Token section +export const AdminTokenSchema = TokenSchema + .extend({ + expirationDate: dateToString.nullable(), + permissions: permissionLevelSchema, + }) + +export const ExposedAdminTokenSchema = AdminTokenSchema.extend({ + password: z.string(), +}) + +export type AdminToken = Zod.infer +export type ExposedAdminToken = Zod.infer + +// PAT section +export const PersonalAccessTokenSchema = TokenSchema.extend({ + expirationDate: dateToString, +}) +export const ExposedPersonalAccessTokenSchema = TokenSchema.extend({ + password: z.string(), +}) + +export type PersonalAccessToken = Zod.infer +export type ExposedPersonalAccessToken = Zod.infer diff --git a/.jjconflict-base-0/packages/shared/src/schemas/user.ts b/.jjconflict-base-0/packages/shared/src/schemas/user.ts new file mode 100644 index 0000000000..2fd89bc285 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/user.ts @@ -0,0 +1,39 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { AtDatesToStringExtend, dateToString } from './_utils.js' + +export const UserSchema = z.object({ + id: z.string() + .uuid(), + firstName: z.string(), + lastName: z.string(), + email: z.string(), + adminRoleIds: z.string().uuid().array(), + type: z.enum(['human', 'ghost', 'bot']), + lastLogin: dateToString.optional().nullable(), +}) + .extend(AtDatesToStringExtend) + +export const MemberSchema = z.object({ + userId: z.string() + .uuid(), + firstName: z.string(), + lastName: z.string(), + email: z.string() + .email(), + roleIds: z.string().uuid().array(), +}) + .or( + z.object({ + user: UserSchema, + roleIds: z.string().uuid().array(), + }).transform(({ user: { adminRoleIds: _, id: userId, ...user }, roleIds }) => ({ userId, roleIds, ...user })), + ) + +export const ProjectMemberSchema = MemberSchema.and(z.object({ + projectId: z.string().uuid(), +})) + +export type User = Zod.infer +export type Member = Zod.infer +export type ProjectMember = Zod.infer diff --git a/.jjconflict-base-0/packages/shared/src/schemas/zone.ts b/.jjconflict-base-0/packages/shared/src/schemas/zone.ts new file mode 100644 index 0000000000..e402a73a6c --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/schemas/zone.ts @@ -0,0 +1,24 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const ZoneSchema = z.object({ + id: z.string() + .uuid(), + slug: z.string() + .min(1) + .max(10) + .regex(/^[a-z0-9-]+$/), + label: z.string() + .min(1) + .max(50), + argocdUrl: z.string() + .min(5, { message: 'Longueur minimum 5 caractères' }) + .url({ message: 'Url invalide' }), + description: z.string() + .max(200) + .optional() + .nullable() + .transform(value => value ?? ''), +}) + +export type Zone = Zod.infer diff --git a/.jjconflict-base-0/packages/shared/src/utils/const.ts b/.jjconflict-base-0/packages/shared/src/utils/const.ts new file mode 100644 index 0000000000..9c80f33998 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/utils/const.ts @@ -0,0 +1,147 @@ +export const adminGroupPath = '/admin' +export const deleteValidationInput = 'DELETE' +export const forbiddenRepoNames = ['mirror', 'infra-apps', 'infra-observability'] + +export const inClusterLabel = 'in-cluster' as const +export const projectIsLockedInfo = 'Le projet est verrouillé, pas d\'action possible' +export const missingCredentials = 'Si le dépôt est privé, vous devez renseigner au moins le nom d\'utilisateur ou le token' +export const invalidGitUrl = 'L\'adresse doit commencer par https et se terminer par .git' +export const invalidInternalRepoName = 'Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique' + +export const fakeToken = 'fakeToken' +export const tokenHeaderName = 'x-dso-token' +export const swaggerUiPath = '/swagger-ui' + +export const levels = [ + 'r', + 'rw', + 'rwd', +] as const +export type PermissionLevels = typeof levels[number] + +export const projectRoles = [ + 'owner', + 'user', +] as const +export type ProjectRoles = typeof projectRoles[number] + +export const longestEnvironmentName = 11 as const +export const longestDeploymentName = 11 as const + +export const allStatus = [ + 'initializing', + 'created', + 'failed', + 'deleting', +] as const + +export const projectStatus = [ + 'initializing', + 'created', + 'failed', + 'archived', + 'warning', +] as const + +export const achievedStatus = [ + 'created', + 'failed', +] as const + +export type AchievedStatus = typeof achievedStatus[number] + +export enum ClusterPrivacy { + PUBLIC = 'public', + DEDICATED = 'dedicated', +} + +export const privacyWording: Record = { + dedicated: { text: 'dédié', icon: 'ri:shield-keyhole-line' }, + public: { text: 'public', icon: 'ri:door-open-line' }, +} + +export enum AllStatus { + CREATED = 'created', + FAILED = 'failed', + DELETING = 'deleting', +} + +export const logActions = [ + 'Create Project', + 'Create Repository', + 'Create Environment', + 'Delete Project', + 'Delete Repository', + 'Delete Environment', +] + +export const statusDict = { + locked: { + false: { + testId: 'unlocked-badge', + type: 'success', + icon: 'ri:lock-unlock-line', + wording: 'déverrouillé', + animation: undefined, + color: 'var(--success-425-625)', + }, + true: { + testId: 'locked-badge', + type: 'warning', + icon: 'ri:lock-line', + wording: 'verrouillé', + animation: undefined, + color: 'var(--warning-425-625)', + }, + }, + status: { + created: { + testId: 'created-badge', + type: 'success', + icon: 'ri:check-line', + wording: 'succès', + animation: undefined, + color: 'var(--success-425-625)', + }, + failed: { + testId: 'failed-badge', + type: 'error', + icon: 'ri:close-line', + wording: 'opérations échouées', + animation: undefined, + color: 'var(--error-425-625)', + }, + initializing: { + testId: 'initializing-badge', + type: 'info', + icon: 'ri:loader-4-line', + animation: 'spin', + wording: 'opérations en cours', + color: 'var(--info-425-625)', + }, + archived: { + testId: 'archived-badge', + type: 'info', + icon: 'ri:archive-line', + wording: 'archivé', + animation: undefined, + color: 'var(--text-mention-grey)', + }, + warning: { + testId: 'warning-badge', + type: 'warning', + icon: 'ri:alert-line', + wording: 'partiellement dégradé', + animation: undefined, + color: 'var(--warning-425-625)', + }, + }, +} as const + +export const servicePluginOrder = [ + 'argocd', + 'gitlab', + 'registry', + 'sonarqube', + 'vault', +] diff --git a/.jjconflict-base-0/packages/shared/src/utils/date.ts b/.jjconflict-base-0/packages/shared/src/utils/date.ts new file mode 100644 index 0000000000..1bc7428288 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/utils/date.ts @@ -0,0 +1,9 @@ +export function formatDate(dateString: string) { + const date = new Date(dateString) + return new Intl.DateTimeFormat('default', { dateStyle: 'long' }).format(date) +} + +export function formatDateTime(dateTimeString: string) { + const date = new Date(dateTimeString) + return new Intl.DateTimeFormat('default', { dateStyle: 'short', timeStyle: 'short' }).format(date) +} diff --git a/.jjconflict-base-0/packages/shared/src/utils/functions.spec.ts b/.jjconflict-base-0/packages/shared/src/utils/functions.spec.ts new file mode 100644 index 0000000000..deecd24656 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/utils/functions.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { calcProjectNameMaxLength, exclude, identity, removeTrailingSlash, shallowEqual, shallowMatch } from './functions.js' + +describe('function utils: identity', () => { + it('should return identity', () => { + expect(identity('Test')).toStrictEqual('Test') + }) +}) + +describe('function utils: exclude', () => { + it('should exclude keys', () => { + const emptyInput = {} + const simpleInput = { + hello: 'world', + foo: 'bar', + } + const simpleExpectedInput = { + foo: 'bar', + } + const complexInput = { + empty: emptyInput, + simple: simpleInput, + array: [emptyInput, simpleInput, simpleInput, emptyInput], + number: 1, + } + const complexExpectedOutput = { + empty: emptyInput, + simple: simpleExpectedInput, + array: [emptyInput, simpleExpectedInput, simpleExpectedInput, emptyInput], + number: 1, + } + + expect(exclude(emptyInput, ['absentKey'])).toStrictEqual({}) + expect(exclude(simpleInput, ['hello'])).toStrictEqual(simpleExpectedInput) + expect(exclude(complexInput, ['hello'])).toStrictEqual(complexExpectedOutput) + }) + + it('should not mutate input', () => { + const simpleInput = { + hello: 'world', + foo: 'bar', + } + exclude(simpleInput, ['hello']) + expect(simpleInput).toStrictEqual(simpleInput) + }) + + it('should stringify Error and not traverse non-serializable objects', () => { + class NotSerializable { + public value = 'secret' + } + + const input = { + err: new Error('boom'), + big: 896n, + date: new Date('2026-04-08T14:32:26.773Z'), + instance: new NotSerializable(), + } + + const transformed = exclude(input, []) + expect(transformed).toMatchObject({ + big: '896', + date: '2026-04-08T14:32:26.773Z', + instance: '[NotSerializable]', + }) + expect(typeof (transformed as any).err).toBe('string') + expect((transformed as any).err).toContain('boom') + }) +}) + +describe('function utils: calcProjectNameMaxLength', () => { + it('should return max length', () => { + expect(calcProjectNameMaxLength()).toStrictEqual(50) + }) +}) + +describe('function utils: removeTrailingSlash', () => { + it('should return string without ending slash', () => { + expect(removeTrailingSlash('mtest')).toStrictEqual('mtest') + expect(removeTrailingSlash('mtest/')).toStrictEqual('mtest') + }) +}) + +describe('function utils: shallowEqual', () => { + it('should return false if value differs', () => { + expect(shallowEqual({ a: 1 }, { a: 2 })).toEqual(false) + }) + it('should return false if missing key', () => { + expect(shallowEqual({ a: 1, b: 2 }, { a: 1 })).toEqual(false) + }) + it('should return true if equal', () => { + expect(shallowEqual({ a: 1 }, { a: 1 })).toEqual(true) + }) + it('should ignore undefined values in object1', () => { + expect(shallowEqual({ a: 1, b: undefined }, { a: 1 })).toEqual(true) + }) + it('should ignore undefined values in object2', () => { + expect(shallowEqual({ a: 1 }, { a: 1, b: undefined })).toEqual(true) + }) + it('should return true even if keys are in different order', () => { + expect(shallowEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toEqual(true) + }) + it('should treat empty objects as equal', () => { + expect(shallowEqual({}, {})).toEqual(true) + }) + it('should treat objects with only undefined keys as equal', () => { + expect(shallowEqual({ a: undefined }, { b: undefined })).toEqual(true) + }) +}) + +describe('function utils: shallowMatch', () => { + it('should return false if key is not equal', () => { + expect(shallowMatch({ a: 1 }, { a: 2 })).toEqual(false) + }) + it('should return false if missing key', () => { + expect(shallowMatch({ a: 1, b: 2 }, { a: 1 })).toEqual(false) + }) + it('should return true if is equal', () => { + expect(shallowMatch({ a: 1 }, { a: 1 })).toEqual(true) + }) +}) diff --git a/.jjconflict-base-0/packages/shared/src/utils/functions.ts b/.jjconflict-base-0/packages/shared/src/utils/functions.ts new file mode 100644 index 0000000000..f38778f36d --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/utils/functions.ts @@ -0,0 +1,290 @@ +import type { ResourceById, ResourceByKey } from './types.js' +import shortUUID from 'short-uuid' +import { longestEnvironmentName } from './const.js' + +/** + * @param {*} value Value wanted to be return as is + * @returns {*} Value returned as is + */ +export const identity = (value: unknown) => value + +export const getLongestStringOfArray = (array: Array) => array.reduce((acc, curr) => acc.length < curr.length ? curr : acc, '') + +export function calcProjectNameMaxLength() { + return 61 - longestEnvironmentName +} + +export const getUniqueListBy = (arr: Array>, key: string) => [...new Map(arr.map(item => [item[key], item])).values()] + +export const isString = (value: any): value is string => typeof value === 'string' + +type ObjToSort = Record + +export function sortArrByObjKeyAsc(arr: T, key: string): T { + return arr.toSorted((a: ObjToSort, b: ObjToSort) => { + const aValue = a[key] + const bValue = b[key] + if (isString(aValue) && isString(bValue)) { + return aValue.localeCompare(bValue.toString(), 'fr', { sensitivity: 'base' }) + } + if (typeof aValue === 'number' && typeof bValue === 'number') { + return aValue - bValue + } + if (typeof aValue === 'boolean' && typeof bValue === 'boolean') { + return Number(bValue) - Number(aValue) + } + return -1 + }) as T +} + +export function removeTrailingSlash(url: string) { + return url?.endsWith('/') + ? url.slice(0, -1) + : url +} + +function excludeCircular(value: unknown, keys: string[], inPath: WeakSet, inArray: boolean): unknown { + if (value === null) return null + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value + if (typeof value === 'bigint') return value.toString() + if (typeof value === 'undefined') return inArray ? null : undefined + if (typeof value === 'function') return inArray ? null : undefined + if (typeof value === 'symbol') return value.toString() + if (typeof value === 'object') { + if (inPath.has(value)) return '[object Circular]' + inPath.add(value) + + if (Array.isArray(value)) return value.map(v => excludeCircular(v, keys, inPath, true)) + + if (value instanceof Date) { + inPath.delete(value) + return value.toISOString() + } + + if (value instanceof Error) { + const payload = { + name: value.name, + message: value.message, + stack: value.stack, + } + inPath.delete(value) + try { + return JSON.stringify(payload) + } catch { + return `${value.name}: ${value.message}` + } + } + + if (value instanceof Map) { + const obj: Record = {} + for (const [k, v] of value.entries()) { + const key = typeof k === 'string' ? k : String(k) + obj[key] = excludeCircular(v, keys, inPath, false) + } + inPath.delete(value) + return obj + } + + if (value instanceof Set) { + const out = Array.from(value.values(), v => excludeCircular(v, keys, inPath, true)) + inPath.delete(value) + return out + } + + if (value instanceof RegExp) { + inPath.delete(value) + return value.toString() + } + + if (value instanceof URL) { + inPath.delete(value) + return value.toString() + } + + if (value instanceof URLSearchParams) { + inPath.delete(value) + return value.toString() + } + + if ('toJSON' in value && typeof value.toJSON === 'function') { + try { + const serialized = value.toJSON() + const out = excludeCircular(serialized, keys, inPath, inArray) + inPath.delete(value) + return out + } catch { + inPath.delete(value) + return '[object Unserializable]' + } + } + + const proto = Object.getPrototypeOf(value) + if (proto !== Object.prototype && proto !== null) { + const ctorName = (value as any)?.constructor?.name + const tag = typeof ctorName === 'string' && ctorName.length ? ctorName : 'Object' + try { + const s = String(value) + const out = s === '[object Object]' ? `[${tag}]` : s + inPath.delete(value) + return out + } catch { + inPath.delete(value) + return `[object ${tag}]` + } + } + + const obj = value as Record + const newObj: Record = {} + for (const [k, v] of Object.entries(obj)) { + if (keys.includes(k)) continue + const next = excludeCircular(v, keys, inPath, false) + if (typeof next === 'undefined') continue + newObj[k] = next + } + inPath.delete(value) + return newObj + } + return String(value) +} + +// Exclude keys from an object +export function exclude(result: T, keys: string[]): T { + const inPath = new WeakSet() + return excludeCircular(result, keys, inPath, false) as T +} + +export type AsyncReturnType Promise> + = T extends (...args: any) => Promise ? R : any + +export function objectEntries>(obj: Obj): ([keyof Obj, Obj[keyof Obj]])[] { + return Object.entries(obj) as ([keyof Obj, Obj[keyof Obj]])[] +} +export function objectKeys>(obj: Obj): (keyof Obj)[] { + return Object.keys(obj) as (keyof Obj)[] +} +export function objectValues>(obj: Obj): (Obj[keyof Obj])[] { + return Object.values(obj) as (Obj[keyof Obj])[] +} + +export function requiredEnv(envName: string): string { + const envValue = process.env[envName] + if (envValue) return envValue + + throw new Error(`env: ${envName} is not defined !`) +} + +export function resourceListToDict(resList: Array): ResourceById { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.id]: curr, + } + }, {} as ResourceById) +} + +export function resourceListToDictByKey(resList: Array): ResourceByKey { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.key]: curr, + } + }, {} as ResourceByKey) +} + +export function shallowEqual(object1: Record, object2: Record) { + const definedObject1 = Object.fromEntries( + Object.entries(object1).filter(([_, v]) => v !== undefined), + ) + const definedObject2 = Object.fromEntries( + Object.entries(object2).filter(([_, v]) => v !== undefined), + ) + const keys1 = Object.keys(definedObject1) + const keys2 = Object.keys(definedObject2) + + if (keys1.length !== keys2.length) { + return false + } + + for (const key of keys1) { + if (definedObject1[key] !== definedObject2[key]) { + return false + } + } + + return true +} + +/** + * Check if all the keys / values in first parameter is present in the the second parameter, the second one can have additional property + * + * @example + * ```ts + * shallowMatch({ a: 'a' }, { a: 'c' }) // false, invalid value + * shallowMatch({ a: 'a' }, { }) // fals, missing property + * shallowMatch({ a: 'a' }, { a: 'a', b: 'b' }) // true, everything is retrieved and equivalent in the second argument + * ``` + * + */ +export function shallowMatch(objectController: Record | undefined, objectToCheck: Record | undefined) { + if (!objectController || !objectToCheck) { + return false + } + + const keys1 = Object.keys(objectController) + const keys2 = Object.keys(objectToCheck) + + if (keys1.length > keys2.length) { + return false + } + + for (const key of keys1) { + if (objectController[key] !== objectToCheck[key]) { + return false + } + } + + return true +} + +export function generateRandomPassword(length = 24, chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*') { + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +export function isAtLeastTomorrow(actualTime: Date) { + const tomorrow = new Date(Date.now()) + tomorrow.setUTCHours(23, 59, 59, 999) + + return actualTime.getTime() > tomorrow.getTime() +} + +export function insert(pseudoArray: T[] | undefined, element: T): T[] { + if (!pseudoArray) { + return [element] + } else if (Array.isArray(pseudoArray)) { + return [...pseudoArray, element] + } else { + throw new TypeError('item is not an ArrayLike') + } +} + +export type ArrayElement + = ArrayType extends readonly (infer ElementType)[] ? ElementType : never + +export const bts = (v: boolean) => v ? 'true' : 'false' +export function stb(v?: string | undefined) { + return v === 'true' + ? true + : v === 'false' ? false : undefined +} + +const uuidTranslator = shortUUID(shortUUID.constants.uuid25Base36, { + consistentLength: false, +}) + +export const compressUUID = uuidTranslator.fromUUID +export const expandUUID = uuidTranslator.toUUID + +export function generateNamespaceName(projectId: string, envId: string) { + return `${compressUUID(envId)}--${compressUUID(projectId)}` +} diff --git a/.jjconflict-base-0/packages/shared/src/utils/index.ts b/.jjconflict-base-0/packages/shared/src/utils/index.ts new file mode 100644 index 0000000000..0600385dce --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/utils/index.ts @@ -0,0 +1,8 @@ +export * from './const.js' +export * from './date.js' +export * from './functions.js' +export * from './permissions.js' +export * from './plugins.js' +export * from './roles.js' +export * from './schemas.js' +export * from './types.js' diff --git a/.jjconflict-base-0/packages/shared/src/utils/permissions.ts b/.jjconflict-base-0/packages/shared/src/utils/permissions.ts new file mode 100644 index 0000000000..040fbf55d1 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/utils/permissions.ts @@ -0,0 +1,345 @@ +/** + * [FR] ATTENTION ! Ce fichier est la base du système de permissions de l'application. + * Ces permissions sont basés sur le Bitwise Permissions System. Les modifier à posteriori pourrait être catastrophique niveau sécurité. + * Veuillez bien étudier le système et lire la documentation. + * + * [EN] This file is the basis of the application's permissions system. + * These permissions are based on the Bitwise Permissions System. Modifying them after the fact could be catastrophic in terms of security. + * Please study the system carefully and read the documentation. + * https://en.wikipedia.org/wiki/Bitwise_operation + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_AND + * https://www.alexhyett.com/bitwise-operators/ + * + * Voici des sources d'inspirations + * https://discordapi.com/permissions.html#32 + * https://discord.com/developers/docs/topics/permissions#permissions + */ +import type { ResourceById } from './types.js' +import { logger as baseLogger } from '@cpn-console/logger' + +const logger = baseLogger.child({ scope: 'utils:permissions' }) + +export function getPermsByUserRoles(userRoles: string[] | undefined, rolesById: ResourceById<{ id: string, permissions: bigint | string }>, basePerms?: bigint | string) { + if (!userRoles) { + return basePerms ? BigInt(basePerms) : 0n + } + return userRoles.reduce((acc, curr) => { + if (!rolesById[curr]) { + logger.warn(`Role ${curr} not found in rolesById`) + return acc + } + return acc | BigInt(rolesById[curr].permissions) + }, basePerms ? BigInt(basePerms) : 0n) +} + +function permissionsParser(a: Record) { + const valuesRegistered = [] as bigint[] + for (const [k, v] of Object.entries(a)) { + if (typeof v !== 'bigint') + throw new Error(`${k} has a invalid value: ${v}, which is not a bigint`) + if (valuesRegistered.includes(v)) + throw new Error(`${k} has a duplicated value: ${v}`) + valuesRegistered.push(v) + } +} + +const bit = (position: bigint) => 1n << position + +// Be very careful and think to apply corresponding updates in database if you modify these values, You'll have to do binary updates in SQL, good luck ! +export const PROJECT_PERMS = { // project permissions + GUEST: bit(0n), + MANAGE: bit(1n), + MANAGE_MEMBERS: bit(2n), + MANAGE_ENVIRONMENTS: bit(3n), + MANAGE_REPOSITORIES: bit(4n), + MANAGE_ROLES: bit(5n), + SEE_SECRETS: bit(6n), + REPLAY_HOOKS: bit(7n), + LIST_ENVIRONMENTS: bit(8n), + LIST_REPOSITORIES: bit(9n), + LIST_MEMBERS: bit(10n), + LIST_ROLES: bit(11n), + MANAGE_DEPLOYMENTS: bit(12n), + LIST_DEPLOYMENTS: bit(13n), +} + +// Be very careful and think to apply corresponding updates in database if you modify these values, You'll have to do binary updates in SQL, good luck ! +export const ADMIN_PERMS = { // admin permissions + LIST: bit(0n), + MANAGE: bit(1n), + MANAGE_USERS: bit(2n), + MANAGE_PROJECTS: bit(3n), + MANAGE_ROLES: bit(4n), + MANAGE_CLUSTERS: bit(5n), + MANAGE_ZONES: bit(6n), + MANAGE_STAGES: bit(7n), + MANAGE_SYSTEM: bit(8n), + LIST_USERS: bit(9n), + LIST_PROJECTS: bit(10n), + LIST_ROLES: bit(11n), + LIST_CLUSTERS: bit(12n), + LIST_ZONES: bit(13n), + LIST_STAGES: bit(14n), + LIST_SYSTEM: bit(15n), + MANAGE_ADMIN_TOKEN: bit(16n), + LIST_ADMIN_TOKEN: bit(17n), +} + +export type ProjectPermsKeys = keyof typeof PROJECT_PERMS +export type AdminPermsKeys = keyof typeof ADMIN_PERMS + +permissionsParser(ADMIN_PERMS) +permissionsParser(PROJECT_PERMS) + +interface ProjectAuthorizedParams { adminPermissions?: bigint | string | null, projectPermissions?: bigint | string } + +export const toBigInt = (value?: bigint | number | string | undefined | null) => value ? BigInt(value) : 0n + +export const AdminAuthorized = { + Manage: (perms?: bigint | string | null) => !!(toBigInt(perms) & ADMIN_PERMS.MANAGE), + ManageUsers: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_USERS | ADMIN_PERMS.MANAGE)), + ManageProjects: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_PROJECTS | ADMIN_PERMS.MANAGE)), + ManageRoles: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ROLES | ADMIN_PERMS.MANAGE)), + ManageClusters: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_CLUSTERS | ADMIN_PERMS.MANAGE)), + ManageZones: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ZONES | ADMIN_PERMS.MANAGE)), + ManageStages: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_STAGES | ADMIN_PERMS.MANAGE)), + ManageSystem: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_SYSTEM | ADMIN_PERMS.MANAGE)), + ManageAdminToken: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ADMIN_TOKEN | ADMIN_PERMS.MANAGE)), + ListUsers: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_USERS | ADMIN_PERMS.MANAGE_USERS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListProjects: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_PROJECTS | ADMIN_PERMS.MANAGE_PROJECTS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListRoles: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ROLES | ADMIN_PERMS.MANAGE_ROLES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListClusters: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_CLUSTERS | ADMIN_PERMS.MANAGE_CLUSTERS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListZones: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ZONES | ADMIN_PERMS.MANAGE_ZONES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListStages: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_STAGES | ADMIN_PERMS.MANAGE_STAGES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListSystem: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_SYSTEM | ADMIN_PERMS.MANAGE_SYSTEM | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListAdminToken: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ADMIN_TOKEN | ADMIN_PERMS.MANAGE_ADMIN_TOKEN | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), +} as const + +export const ProjectAuthorized = { + Manage: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & PROJECT_PERMS.MANAGE), + + ListEnvironments: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE)), + ListRepositories: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_REPOSITORIES | PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.MANAGE)), + ListMembers: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_MEMBERS | PROJECT_PERMS.MANAGE_MEMBERS | PROJECT_PERMS.MANAGE)), + ListRoles: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_ROLES | PROJECT_PERMS.MANAGE_ROLES | PROJECT_PERMS.MANAGE)), + + ManageEnvironments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE)), + ManageRepositories: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.MANAGE)), + + ManageMembers: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_MEMBERS | PROJECT_PERMS.MANAGE)), + + ManageRoles: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_ROLES | PROJECT_PERMS.MANAGE)), + + ReplayHooks: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.MANAGE)), + + SeeSecrets: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.MANAGE)), + ManageDeployments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_DEPLOYMENTS | PROJECT_PERMS.MANAGE)), + ListDeployments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_DEPLOYMENTS | PROJECT_PERMS.MANAGE)), +} as const + +interface ScopePerm { + name: string + perms: Array<{ key: T, label: string, hint?: string }> +} +type PermDetails = Array> + +export const projectPermsDetails: PermDetails = [{ + name: 'Projet', + perms: [{ + key: 'MANAGE', + label: 'Gérer le projet', + hint: 'Permet de gérer tout le projet et ses ressources associées', + }, { + key: 'MANAGE_ROLES', + label: 'Gérer les rôles du projet', + hint: 'ATTENTION : Ce rôle inclut une élévation de privilège ! Permet de gérer les rôles du projet et les membres associés', + }, { + key: 'MANAGE_MEMBERS', + label: 'Gérer les membres du projet', + hint: 'Permet d\'inviter des utilisateurs et de les retirer', + }, { + key: 'LIST_ROLES', + label: 'Voir les rôles du projet', + hint: 'Permet de visualiser les rôles du projet', + }, { + key: 'LIST_MEMBERS', + label: 'Voir les membres du projet', + hint: 'Permet de visualiser les membres du projet', + }, { + key: 'SEE_SECRETS', + label: 'Afficher les secrets', + hint: 'Permet d\'afficher les secrets générés par les services', + }, { + key: 'REPLAY_HOOKS', + label: 'Reprovisionner le projet', + hint: 'Permet de lancer un reprovisionnage du projet', + }], +}, { + name: 'Environnement', + perms: [ + { + key: 'MANAGE_ENVIRONMENTS', + label: 'Gérer les environnements', + hint: 'Permet de créer, éditer, supprimer des environnements', + }, + { + key: 'LIST_ENVIRONMENTS', + label: 'Voir les environnements', + hint: 'Permet de visualiser tous les environnements et leurs configurations', + }, + ], +}, { + name: 'Dépôt', + perms: [ + { + key: 'MANAGE_REPOSITORIES', + label: 'Gérer les dépots', + hint: 'Permet de créer, éditer, supprimer des dépôts', + }, + { + key: 'LIST_REPOSITORIES', + label: 'Voir les dépôts', + hint: 'Permet de visualiser tous les dépôts et leurs configurations', + }, + ], +}, { + name: 'Déploiements', + perms: [ + { + key: 'MANAGE_DEPLOYMENTS', + label: 'Gérer les déploiements', + hint: 'Permet de créer, éditer, supprimer des déploiements', + }, + { + key: 'LIST_DEPLOYMENTS', + label: 'Voir les déploiements', + hint: 'Permet de visualiser tous les déploiements et leurs configurations', + }, + ], +}] as const + +export const adminPermsDetails: PermDetails = [{ + name: 'Global', + perms: [{ + key: 'MANAGE', + label: 'Administration globale', + hint: 'Administration globale de toute la console et de ses ressources', + }, { + key: 'LIST', + label: 'Lecture seule globale', + hint: 'Accès en lecture seule à toute la console et ses ressources', + }], +}, { + name: 'Gestion des utilisateurs', + perms: [{ + key: 'MANAGE_USERS', + label: 'Gérer les utilisateurs', + hint: 'Permet de gérer les utilisateurs de la console', + }, { + key: 'LIST_USERS', + label: 'Voir les utilisateurs', + hint: 'Permet de voir les utilisateurs de la console', + }], +}, { + name: 'Gestion des projets', + perms: [{ + key: 'MANAGE_PROJECTS', + label: 'Gérer les projets', + hint: 'Permet de gérer les projets de la console', + }, { + key: 'LIST_PROJECTS', + label: 'Voir les projets', + hint: 'Permet de voir les projets de la console', + }], +}, { + name: 'Gestion des rôles', + perms: [{ + key: 'MANAGE_ROLES', + label: 'Gérer les rôles', + hint: 'Permet de gérer les rôles de la console', + }, { + key: 'LIST_ROLES', + label: 'Voir les rôles', + hint: 'Permet de voir les rôles de la console', + }], +}, { + name: 'Infrastructure', + perms: [{ + key: 'MANAGE_CLUSTERS', + label: 'Gérer les clusters', + hint: 'Permet de gérer les clusters de la console', + }, { + key: 'LIST_CLUSTERS', + label: 'Voir les clusters', + hint: 'Permet de voir les clusters de la console', + }, { + key: 'MANAGE_ZONES', + label: 'Gérer les zones', + hint: 'Permet de gérer les zones de la console', + }, { + key: 'LIST_ZONES', + label: 'Voir les zones', + hint: 'Permet de voir les zones de la console', + }, { + key: 'MANAGE_STAGES', + label: 'Gérer les types d\'environnement', + hint: 'Permet de gérer les types d\'environnement de la console', + }, { + key: 'LIST_STAGES', + label: 'Voir les types d\'environnement', + hint: 'Permet de voir les types d\'environnement de la console', + }], +}, { + name: 'Système', + perms: [{ + key: 'MANAGE_SYSTEM', + label: 'Gérer le système', + hint: 'Permet de gérer les configurations et logs du système', + }, { + key: 'LIST_SYSTEM', + label: 'Voir le système', + hint: 'Permet de voir les configurations et logs du système', + }], +}, { + name: 'Jetons d’API', + perms: [{ + key: 'MANAGE_ADMIN_TOKEN', + label: 'Gérer les jetons d’API', + hint: 'Permet de créer et révoquer des jetons d’API admin', + }, { + key: 'LIST_ADMIN_TOKEN', + label: 'Voir les jetons d’API', + hint: 'Permet de lister les jetons d’API admin', + }], +}] as const + +export function getAdminPermLabelsByValue(value: bigint | string) { + value = typeof value === 'bigint' ? value : BigInt(value) + return adminPermsDetails + .map(section => section.perms) + .flat() + .filter(permDetail => ADMIN_PERMS[permDetail.key] & value) + .map(permDetail => permDetail.label) +} + +export function getProjectPermLabelsByValue(value: bigint | string) { + value = typeof value === 'bigint' ? value : BigInt(value) + return projectPermsDetails + .map(section => section.perms) + .flat() + .filter(permDetail => PROJECT_PERMS[permDetail.key] & value) + .map(permDetail => permDetail.label) +} diff --git a/.jjconflict-base-0/packages/shared/src/utils/plugins.ts b/.jjconflict-base-0/packages/shared/src/utils/plugins.ts new file mode 100644 index 0000000000..fd907ad899 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/utils/plugins.ts @@ -0,0 +1,42 @@ +export enum MonitorStatus { + OK = 'OK', + WARNING = 'Dégradé', + ERROR = 'En échec', + UNKNOW = 'Inconnu', +} + +export interface MonitorInfos { + lastUpdateTimestamp: number + interval: number + message: string + status: MonitorStatus + cause?: any // should be deserved to admins only +} +export class Monitor { + private intervalTime: number + public monitorFn: (instance: any) => Promise + private intervalID: NodeJS.Timeout | undefined + public lastStatus: MonitorInfos + + constructor(callback: (instance: Monitor) => Promise, interval: number = 5 * 60 * 1000) { + this.intervalTime = interval + this.monitorFn = () => { + this.lastStatus.cause = undefined + return callback(this) + } + this.lastStatus = { + interval: this.intervalTime, + lastUpdateTimestamp: Date.now(), + message: 'En attente d\'une première vérification', + status: MonitorStatus.UNKNOW, + cause: 'App just started', + } + } + + async refresh() { + if (this.intervalID) + clearInterval(this.intervalID) + this.intervalID = setInterval(() => this.monitorFn(this), this.intervalTime) + return this.monitorFn(this) + } +} diff --git a/.jjconflict-base-0/packages/shared/src/utils/roles.spec.ts b/.jjconflict-base-0/packages/shared/src/utils/roles.spec.ts new file mode 100644 index 0000000000..1124f52e69 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/utils/roles.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { + generateSystemRoleType, + getBaseRoleType, + isExternalRoleType, + isGlobalRoleType, + isManagedRoleType, + isSystemRoleType, +} from './roles.js' + +describe('roles', () => { + describe('generateSystemRoleType', () => { + it('prefixes a base type with system:', () => { + expect(generateSystemRoleType('external')).toBe('system:external') + }) + + it('returns system:undefined for undefined input', () => { + expect(generateSystemRoleType(undefined)).toBe('system:undefined') + }) + + it('returns system:null for null input', () => { + expect(generateSystemRoleType(null)).toBe('system:null') + }) + }) + + describe('isSystemRoleType', () => { + it('returns true for system: prefixed values', () => { + expect(isSystemRoleType('system:external')).toBe(true) + expect(isSystemRoleType('system:managed')).toBe(true) + }) + + it('returns false for non-system values', () => { + expect(isSystemRoleType('external')).toBe(false) + expect(isSystemRoleType('managed')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isSystemRoleType(null)).toBe(false) + expect(isSystemRoleType(undefined)).toBe(false) + }) + }) + + describe('getBaseRoleType', () => { + it('strips system: prefix', () => { + expect(getBaseRoleType('system:external')).toBe('external') + expect(getBaseRoleType('system:managed')).toBe('managed') + }) + + it('returns non-system values as-is', () => { + expect(getBaseRoleType('external')).toBe('external') + expect(getBaseRoleType('managed')).toBe('managed') + }) + + it('returns undefined for null or undefined', () => { + expect(getBaseRoleType(null)).toBeUndefined() + expect(getBaseRoleType(undefined)).toBeUndefined() + }) + }) + + describe('isExternalRoleType', () => { + it('returns true for system:external', () => { + expect(isExternalRoleType('system:external')).toBe(true) + }) + + it('returns true for bare external', () => { + expect(isExternalRoleType('external')).toBe(true) + }) + + it('returns false for other system types', () => { + expect(isExternalRoleType('system:managed')).toBe(false) + expect(isExternalRoleType('system:global')).toBe(false) + }) + + it('returns false for non-external types', () => { + expect(isExternalRoleType('managed')).toBe(false) + expect(isExternalRoleType('global')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isExternalRoleType(null)).toBe(false) + expect(isExternalRoleType(undefined)).toBe(false) + }) + }) + + describe('isManagedRoleType', () => { + it('returns true for system:managed', () => { + expect(isManagedRoleType('system:managed')).toBe(true) + }) + + it('returns true for bare managed', () => { + expect(isManagedRoleType('managed')).toBe(true) + }) + + it('returns false for other types', () => { + expect(isManagedRoleType('external')).toBe(false) + expect(isManagedRoleType('global')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isManagedRoleType(null)).toBe(false) + expect(isManagedRoleType(undefined)).toBe(false) + }) + }) + + describe('isGlobalRoleType', () => { + it('returns true for system:global', () => { + expect(isGlobalRoleType('system:global')).toBe(true) + }) + + it('returns true for bare global', () => { + expect(isGlobalRoleType('global')).toBe(true) + }) + + it('returns false for other types', () => { + expect(isGlobalRoleType('external')).toBe(false) + expect(isGlobalRoleType('managed')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isGlobalRoleType(null)).toBe(false) + expect(isGlobalRoleType(undefined)).toBe(false) + }) + }) +}) diff --git a/.jjconflict-base-0/packages/shared/src/utils/roles.ts b/.jjconflict-base-0/packages/shared/src/utils/roles.ts new file mode 100644 index 0000000000..3512475e8e --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/utils/roles.ts @@ -0,0 +1,26 @@ +export const SYSTEM_ROLE_TYPE = 'system' as const + +export function generateSystemRoleType(type: string | null | undefined) { + return `${SYSTEM_ROLE_TYPE}:${type}` +} + +export function isSystemRoleType(type: string | null | undefined) { + return !!type?.startsWith(`${SYSTEM_ROLE_TYPE}:`) +} + +export function getBaseRoleType(type: string | null | undefined) { + if (!type) return undefined + return isSystemRoleType(type) ? type.slice(`${SYSTEM_ROLE_TYPE}:`.length) : type +} + +export function isManagedRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'managed' +} + +export function isGlobalRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'global' +} + +export function isExternalRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'external' +} diff --git a/.jjconflict-base-0/packages/shared/src/utils/schemas.spec.ts b/.jjconflict-base-0/packages/shared/src/utils/schemas.spec.ts new file mode 100644 index 0000000000..5f0c422a13 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/utils/schemas.spec.ts @@ -0,0 +1,349 @@ +import type { Log, ProjectV2 } from '../index.js' +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { ZodError } from 'zod' +import { ClusterDetailsSchema, ClusterPrivacy, ClusterUsageSchema, descriptionMaxLength, EnvironmentSchema, instanciateSchema, LogSchema, parseZodError, ProjectSchemaV2, RepoSchema, StageSchema, UserSchema } from '../index.js' + +describe('schemas utils', () => { + it('should delete config in log', () => { + const toParse: Log = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + action: 'Create a log', + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + data: { + args: {}, + failed: false, + config: {}, + }, + requestId: faker.string.uuid(), + userId: faker.string.uuid(), + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + // @ts-ignore + delete parsed.data.config + expect(LogSchema + .safeParse(toParse)) + .toStrictEqual({ data: parsed, success: true }) + }) + + it('should not validate an undefined object', () => { + // @ts-ignore + expect(RepoSchema.safeParse(undefined).error).toBeInstanceOf(ZodError) + }) + + it('should validate a correct repository schema', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + externalToken: 'eddddsqsq-_', + isPrivate: true, + isInfra: false, + externalUserName: 'clai+re-nlet_', + } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a correct environment schema', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + projectId: faker.string.uuid(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + } + + expect(EnvironmentSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a correct project schema', () => { + const toParse: ProjectV2 = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + slug: faker.lorem.word({ length: { min: 2, max: 10 } }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + description: '', + status: 'created', + locked: false, + clusterIds: [], + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + members: [], + owner: { + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + email: 'invalid-email@mais-pas-grave', + firstName: faker.person.firstName(), + id: faker.string.uuid(), + lastName: faker.person.lastName(), + type: 'human', + }, + everyonePerms: '1', + ownerId: faker.string.uuid(), + roles: [], + lastSuccessProvisionningVersion: null, + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + // @ts-ignore + parsed.owner.updatedAt = parsed.owner.updatedAt.toISOString() + // @ts-ignore + parsed.owner.createdAt = parsed.owner.createdAt.toISOString() + expect(ProjectSchemaV2.safeParse(toParse)).toStrictEqual({ data: parsed, success: true }) + }) + + it('should validate a correct user schema', () => { + const toParse = { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + updatedAt: new Date(), + createdAt: new Date(), + type: 'human', + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + expect(UserSchema.safeParse(toParse)).toStrictEqual({ data: parsed, success: true }) + }) + + it('should validate a correct stage schema', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + clusterIds: [], + } + + expect(StageSchema.safeParse(toParse)).toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a repo business schema', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + externalUserName: 'clairenlet', + externalToken: 'myToken', + isPrivate: true, + isInfra: false, + } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a public cluster details schema', () => { + const toParse = { + id: faker.string.uuid(), + label: 'cluster', + clusterResources: true, + privacy: ClusterPrivacy.PUBLIC, + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + zoneId: faker.string.uuid(), + stageIds: [faker.string.uuid(), faker.string.uuid()], + kubeconfig: { + user: {}, + cluster: { + tlsServerName: 'blabla', + }, + }, + } + + expect(ClusterDetailsSchema + .safeParse(toParse)) + .toStrictEqual({ data: { ...toParse, infos: '' }, success: true }) + }) + + it('should validate a cluster usage schema', () => { + const toParse = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + } + + expect(ClusterUsageSchema + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a repository schema with wrong internal repo name', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: '-candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + isPrivate: false, + isInfra: false, + } + + // @ts-ignore + expect(parseZodError(RepoSchema + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candilib-' + + // @ts-ignore + expect(parseZodError(RepoSchema + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candiLib' + + // @ts-ignore + expect(parseZodError(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candi-lib' + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a too short project name', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.string.alpha({ casing: 'lower' }), + status: 'created', + locked: false, + description: '', + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + // @ts-ignore + expect(parseZodError(ProjectSchemaV2 + .safeParse(toParse) + .error)) + .toMatch('Validation error: String must contain at least 2 character(s) at "name"') + }) + + it('should not validate a too long project name', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.string.alpha({ length: 24, casing: 'lower' }), + status: 'created', + locked: false, + description: '', + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + // @ts-ignore + expect(parseZodError(ProjectSchemaV2 + .safeParse(toParse) + .error)) + .toMatch('Validation error: String must contain at most 20 character(s) at "name"') + }) + + it('should not validate a too long project description', () => { + const toParse = { + id: faker.string.uuid(), + name: 'candilib', + description: faker.string.alpha(descriptionMaxLength + 1), + status: 'created', + locked: false, + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + expect(ProjectSchemaV2 + .safeParse(toParse) + .error).toBeInstanceOf(ZodError) + }) + + it('should validate a single key with given schema', () => { + const toParse = { internalRepoName: 'candilib' } + + expect(RepoSchema + .pick({ internalRepoName: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a single key with given schema', () => { + const toParse = { internalRepoName: 'candi lib' } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .pick({ internalRepoName: true }) + .safeParse(toParse) + // @ts-ignore + .error).toBeInstanceOf(ZodError) + }) + + it('should return truthy schema', () => { + expect(instanciateSchema(RepoSchema.omit({ id: true }), true)).toMatchObject({ + internalRepoName: true, + externalRepoUrl: true, + externalToken: true, + isPrivate: true, + isInfra: true, + externalUserName: true, + projectId: true, + }) + }) + + it('should return true schema', () => { + expect(instanciateSchema(RepoSchema.omit({ id: true }), true)).toMatchObject({ + internalRepoName: true, + externalRepoUrl: true, + externalToken: true, + isPrivate: true, + isInfra: true, + externalUserName: true, + projectId: true, + }) + }) +}) diff --git a/.jjconflict-base-0/packages/shared/src/utils/schemas.ts b/.jjconflict-base-0/packages/shared/src/utils/schemas.ts new file mode 100644 index 0000000000..7330fa943e --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/utils/schemas.ts @@ -0,0 +1,20 @@ +// import Joi from 'joi' +import type { SafeParseReturnType, ZodError, ZodObject } from 'zod' +import { fromZodError } from 'zod-validation-error' + +export type SharedZodError = ZodError +export type SharedSafeParseReturnType = SafeParseReturnType +export const parseZodError = (zodError: ZodError) => fromZodError(zodError).toString() + +export function instanciateSchema, V extends boolean>(schema: T, value: V): Record { + const keys = schema.keyof()._def.values + // @ts-ignore + if (keys.length) { + // @ts-ignore + const entries = schema.keyof()._def.values?.map(key => [key, value]) + // @ts-ignore + return Object.fromEntries(entries) + } + // @ts-ignore + return {} +} diff --git a/.jjconflict-base-0/packages/shared/src/utils/types.ts b/.jjconflict-base-0/packages/shared/src/utils/types.ts new file mode 100644 index 0000000000..babed97bf2 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/src/utils/types.ts @@ -0,0 +1,21 @@ +export type ErrorTypes = 'info' | 'warning' | 'error' | 'success' + +export interface UserProfile { + email: string + id: string + firstName: string + lastName: string + groups: string[] +} + +export type ResourceById = Record + +export type ResourceByKey = Record + +export type Without = { [P in Exclude]?: never } + +export type XOR + = T extends object + ? U extends object + ? (Without & U) | (Without & T) + : U : T diff --git a/.jjconflict-base-0/packages/shared/tsconfig.eslint.json b/.jjconflict-base-0/packages/shared/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-0/packages/shared/tsconfig.json b/.jjconflict-base-0/packages/shared/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-0/packages/shared/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-0/packages/shared/vitest.config.ts b/.jjconflict-base-0/packages/shared/vitest.config.ts new file mode 100644 index 0000000000..fda276425b --- /dev/null +++ b/.jjconflict-base-0/packages/shared/vitest.config.ts @@ -0,0 +1,16 @@ +import { configDefaults, defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: ['**/types', '**/mocks', '**/*.spec.ts', '**/*.d.ts'], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + }, +}) diff --git a/.jjconflict-base-0/packages/test-utils/.gitignore b/.jjconflict-base-0/packages/test-utils/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-0/packages/test-utils/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-0/packages/test-utils/eslint.config.js b/.jjconflict-base-0/packages/test-utils/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-0/packages/test-utils/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-0/packages/test-utils/package.json b/.jjconflict-base-0/packages/test-utils/package.json new file mode 100644 index 0000000000..10d292744f --- /dev/null +++ b/.jjconflict-base-0/packages/test-utils/package.json @@ -0,0 +1,42 @@ +{ + "name": "@cpn-console/test-utils", + "type": "module", + "version": "1.0.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test:e2e-ci": "echo 'check cache'" + }, + "dependencies": { + "@cpn-console/shared": "workspace:^", + "@faker-js/faker": "catalog:test" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "rimraf": "catalog:tools", + "typescript": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/packages/test-utils/src/imports/data.ts b/.jjconflict-base-0/packages/test-utils/src/imports/data.ts new file mode 100644 index 0000000000..e7bc172877 --- /dev/null +++ b/.jjconflict-base-0/packages/test-utils/src/imports/data.ts @@ -0,0 +1,2305 @@ +export const data = { + personalAccessToken: [], + adminToken: [ + { + id: 'e66a5ce2-440d-4a60-ac0d-d439bbaba040', + name: 'test', + hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', // test + permissions: '2n', + userId: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + }, + { + id: 'e66a5ce2-440d-4a60-ac0d-d439bbaba041', + name: 'test', + hash: '4bb47f186df233e48b09d241ee4defb821add0c35ac8311469fe1522c6813dd5', // revoked + permissions: '2n', + userId: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + status: 'revoked', + }, + ], + adminPlugin: [], + adminRole: [ + { + id: '76229c96-4716-45bc-99da-00498ec9018c', + permissions: '3n', + position: 0, + oidcGroup: '/admin', + name: 'Root Administrateur Plateforme', + type: 'system:external', + }, + { + id: '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', + permissions: '3n', + position: 0, + oidcGroup: '/console/admin', + name: 'Administrateur Plateforme', + type: 'system:managed', + }, + { + id: 'eadf604f-5f54-4744-bdfb-4793d2271e9b', + permissions: '2n', + position: 1, + oidcGroup: '', + name: 'Admin Locaux', + type: 'external', + }, + { + id: '35848aa2-e881-4770-9844-0c5c3693e506', + permissions: '1n', + position: 2, + oidcGroup: '/console/readonly', + name: 'Lecture Seule Plateforme', + type: 'system:managed', + }, + { + id: '034f589f-1750-4b15-bb34-4cd995e7fcaa', + permissions: '25608n', + position: 0, + oidcGroup: '', + name: 'Tout le monde', + type: 'global', + }, + ], + kubeconfig: [ + { + id: '2a88634a-0a60-459c-bf68-c4ffb12430a2', + user: { + keyData: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=', + certData: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + }, + cluster: { + server: 'https://public.server:6443', + skipTLSVerify: true, + tlsServerName: 'public.server', + }, + createdAt: '2024-07-24T16:54:14.961Z', + updatedAt: '2024-07-24T16:54:14.961Z', + }, + { + id: '2a88634a-0a60-459c-bf68-c4ffb12430a3', + user: { + keyData: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=', + certData: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + }, + cluster: { + server: 'https://nowhere.server:6443', + skipTLSVerify: true, + tlsServerName: 'nowhere.server', + }, + createdAt: '2024-07-24T16:54:14.964Z', + updatedAt: '2024-07-24T16:54:14.964Z', + }, + { + id: '0e88f000-07e6-4781-a69d-0963489387f7', + user: { + token: 'nyan cat', + }, + cluster: { + server: 'https://nothere.cluster', + skipTLSVerify: false, + tlsServerName: 'nothere.cluster', + }, + createdAt: '2024-07-24T16:54:14.966Z', + updatedAt: '2024-07-24T16:54:14.966Z', + }, + { + id: 'b5662039-a62b-483e-ba54-b12c6f966c96', + user: { + token: 'kirikou', + }, + cluster: { + server: 'https://pwned.cluster', + tlsServerName: 'pwned.cluster', + }, + createdAt: '2024-07-24T16:54:14.969Z', + updatedAt: '2024-07-24T16:54:14.969Z', + }, + ], + zone: [ + { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce1', + slug: 'pr', + label: 'Zone privée', + argocdUrl: 'https://argocd.private-zone.fr', + description: 'Cette zone de déploiement est privée.', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + }, + { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + label: 'publique', + argocdUrl: 'https://argocd.public-zone.fr', + description: 'Zone de diffusion ouverte.', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + }, + ], + cluster: [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + label: 'unused-cluster', + privacy: 'public', + secretName: '3972ac09-6abc-4e49-83b6-d046da5260ed', + clusterResources: false, + memory: 8, + cpu: 10, + gpu: 0, + kubeConfigId: '2a88634a-0a60-459c-bf68-c4ffb12430a3', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + infos: 'Cluster public non utilisé', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + label: 'top-secret-cluster', + privacy: 'dedicated', + secretName: '59be2d50-58f9-42f3-95dc-b0c0518e3d8a', + clusterResources: true, + memory: 8, + cpu: 10, + gpu: 0, + kubeConfigId: '0e88f000-07e6-4781-a69d-0963489387f7', + createdAt: '2023-07-10T19:49:31.691Z', + updatedAt: '2024-07-24T16:54:15.234Z', + infos: null, + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + label: 'pas-top-cluster', + privacy: 'dedicated', + secretName: '94d52618-7869-4192-b33e-85dd0959e815', + clusterResources: false, + memory: 30, + cpu: 20, + gpu: 0, + kubeConfigId: 'b5662039-a62b-483e-ba54-b12c6f966c96', + createdAt: '2023-07-10T19:49:31.697Z', + updatedAt: '2024-07-24T16:54:15.249Z', + infos: 'Floating IP : 0.0.0.0', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + label: 'public1', + privacy: 'public', + secretName: '3972ac09-6abc-4e49-83b6-d046da5260ec', + clusterResources: false, + memory: 100, + cpu: 100, + gpu: 100, + kubeConfigId: '2a88634a-0a60-459c-bf68-c4ffb12430a2', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2024-07-24T16:54:15.261Z', + infos: 'Cluster public proposé par DSO', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + ], + user: [ + { + id: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + firstName: 'Bot Admin', + lastName: 'test', + email: 'c7712841-c0fd-40ff-abbb-9d914bcc907d@bot.io', + createdAt: '2023-11-16T15:30:01.140Z', + updatedAt: '2023-11-16T15:30:01.140Z', + adminRoleIds: [], + type: 'bot', + lastLogin: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + { + id: '387216f1-3b87-4211-9cac-4371125e1175', + firstName: 'Admin', + lastName: 'ADMIN', + email: 'admin@test.com', + createdAt: '2023-07-03T18:01:52.884Z', + updatedAt: '2023-07-06T12:53:39.183Z', + adminRoleIds: [ + '76229c96-4716-45bc-99da-00498ec9018c', + ], + type: 'human', + }, + { + id: '04ac168a-2c4f-4816-9cce-af6c612e5912', + firstName: 'Anonymous', + lastName: 'User', + email: 'anon@user', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + type: 'ghost', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', + firstName: 'Arnaud', + lastName: 'TARDIF', + email: 'arnaud.tardif@test.com', + createdAt: '2023-07-03T14:46:56.773Z', + updatedAt: '2023-07-03T14:46:56.773Z', + adminRoleIds: [], + type: 'human', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', + email: 'test@test.com', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + type: 'human', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + firstName: 'Thibault', + lastName: 'COLIN', + email: 'thibault.colin@test.com', + createdAt: '2023-07-03T14:46:56.772Z', + updatedAt: '2024-07-25T16:18:11.372Z', + type: 'human', + adminRoleIds: [ + 'eadf604f-5f54-4744-bdfb-4793d2271e9b', + ], + }, + ], + log: [ + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1220', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + environment: 'staging', + registryHost: 'blabla.com', + repositories: [], + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'Create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.809Z', + requestId: null, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1221', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + environment: 'staging', + registryHost: 'blabla.com', + repositories: [], + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.819Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1222', + data: { + argo: { + status: { + result: 'OK', + message: 'Not an infra repository', + }, + }, + args: { + id: 'bd934af0-6de2-41b2-a111-6b0c45b82384', + status: 'initializing', + isInfra: false, + project: 'int-2', + createdAt: '2023-06-08T15:15:56.692Z', + isPrivate: false, + projectId: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + updatedAt: '2023-06-08T15:15:56.692Z', + environment: [], + internalUrl: 'https://blabla.com/bla/projects/mi/int-2/repo.git', + externalRepoUrl: 'https://github.com/dnum-mi/dso-console.git', + externalUserName: '', + internalRepoName: 'repo', + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 1, + }, + gitlab: { + vault: [ + { + data: { + GIT_INPUT_URL: 'github.com/dnum-mi/dso-console.git', + GIT_INPUT_USER: '', + GIT_OUTPUT_URL: 'blabla.com/bla/projects/mi/int-2/repo.git', + GIT_OUTPUT_USER: 'root', + GIT_INPUT_PASSWORD: '', + GIT_OUTPUT_PASSWORD: 'password', + }, + name: 'repo-mirror', + }, + ], + status: { + result: 'OK', + message: 'Created', + }, + }, + }, + action: 'Create Repository', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.819Z', + requestId: null, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1223', + data: { + argo: { + status: { + result: 'OK', + message: 'Not an infra repository', + }, + }, + args: { + id: 'bd934af0-6de2-41b2-a111-6b0c45b82384', + status: 'initializing', + isInfra: false, + project: 'int-2', + createdAt: '2023-06-08T15:15:56.692Z', + isPrivate: false, + projectId: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + updatedAt: '2023-06-08T15:15:56.692Z', + environment: [], + internalUrl: 'https://blabla.com/bla/projects/mi/int-2/repo.git', + externalRepoUrl: 'https://github.com/dnum-mi/dso-console.git', + externalUserName: '', + internalRepoName: 'repo', + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 1, + }, + gitlab: { + vault: [ + { + data: { + GIT_INPUT_URL: 'github.com/dnum-mi/dso-console.git', + GIT_INPUT_USER: '', + GIT_OUTPUT_URL: 'blabla.com/bla/projects/mi/int-2/repo.git', + GIT_OUTPUT_USER: 'root', + GIT_INPUT_PASSWORD: '', + GIT_OUTPUT_PASSWORD: 'password', + }, + name: 'repo-mirror', + }, + ], + status: { + result: 'OK', + message: 'Created', + }, + }, + }, + action: 'Create Repository', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1224', + data: { + args: { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + locked: true, + status: 'initializing', + project: 'int-2', + createdAt: '2023-06-08T15:14:53.517Z', + updatedAt: '2023-06-08T15:14:53.517Z', + description: '', + }, + nexus: { + user: { + roles: [ + 'mi-int-2-ID', + ], + source: 'default', + status: 'active', + userId: 'mi-int-2', + lastName: 'Luffy', + readOnly: false, + firstName: 'Monkey D.', + emailAddress: 'claire.nollet@interieur.gouv.fr', + externalRoles: [], + }, + vault: [ + { + data: { + NEXUS_PASSWORD: 'password', + NEXUS_USERNAME: 'mi-int-2', + }, + name: 'NEXUS', + }, + ], + status: { + result: 'OK', + message: 'User Created', + }, + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 4, + }, + keycloak: { + group: { + id: '4a767b5c-1bf4-43b9-8164-5da76ded49a0', + }, + status: { + result: 'OK', + }, + }, + registry: { + vault: [ + { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + name: 'REGISTRY', + }, + ], + result: { + robot: { + id: 70, + name: 'robot$mi-int-2+ci', + secret: 'token', + expires_at: -1, + creation_time: '2023-06-08T15:14:55.813Z', + }, + project: { + name: 'mi-int-2', + metadata: { + public: 'false', + }, + owner_id: 1, + owner_name: 'admin', + project_id: 63, + repo_count: 0, + update_time: '2023-06-08T15:14:55.363Z', + creation_time: '2023-06-08T15:14:55.363Z', + cve_allowlist: { + id: 65, + items: [], + project_id: 63, + update_time: '0001-01-01T00:00:00.000Z', + creation_time: '0001-01-01T00:00:00.000Z', + }, + current_user_role_id: 1, + current_user_role_ids: [ + 1, + ], + }, + }, + status: { + result: 'OK', + message: 'Created', + }, + }, + sonarqube: { + user: { + user: { + name: 'mi-int-2', + email: 'claire.nollet@interieur.gouv.fr', + local: true, + login: 'mi-int-2', + active: true, + scmAccounts: [], + }, + }, + vault: [ + { + data: { + SONAR_TOKEN: 'token', + SONAR_PASSWORD: 'password', + SONAR_USERNAME: 'mi-int-2', + }, + name: 'SONAR', + }, + ], + result: {}, + status: { + result: 'OK', + message: 'User Created', + }, + }, + }, + action: 'Create Environment', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1225', + data: { + args: { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + locked: false, + status: 'created', + project: 'int-2', + createdAt: '2023-06-08T15:14:53.517Z', + updatedAt: '2023-06-08T15:16:49.011Z', + description: '', + }, + nexus: { + status: { + result: 'OK', + message: 'User deleted', + }, + }, + vault: { + status: { + result: 'OK', + }, + secretsDestroyed: 5, + }, + gitlab: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + keycloak: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + registry: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + sonarqube: { + status: { + result: 'OK', + message: 'User anonymized', + }, + }, + }, + action: 'Delete Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1226', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1227', + data: { + argo: { + status: { + result: 'KO', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1228', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1229', + data: { + argo: { + status: { + result: 'KO', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1230', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1231', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1232', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1233', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1234', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1235', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1236', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1237', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1238', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1239', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1240', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1241', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + ], + project: [ + { + id: '22e7044f-8414-435d-9c4a-2df42a65034b', + name: 'betaapp', + slug: 'betaapp', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.814Z', + updatedAt: '2023-07-03T14:46:56.817Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '9dabf3f9-6c86-4358-8598-65007d78df65', + name: 'projecttoarchive', + slug: 'projecttoarchive', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.824Z', + updatedAt: '2023-07-03T14:46:56.830Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + name: 'candilib', + slug: 'candilib', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application de réservation de places à l\'examen du permis B.', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.778Z', + updatedAt: '2023-07-03T14:46:56.783Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '011e7860-04d7-461f-912d-334c622d38c5', + name: 'basegun', + slug: 'basegun', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application d\'aide à la catégorisation d\'armes à feu.', + status: 'created', + locked: false, + createdAt: '2023-07-10T14:46:56.778Z', + updatedAt: '2023-07-10T14:46:56.783Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + name: 'psijfailed', + slug: 'psijfailed', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application de transmission d\'informations entre agents de la PS et de l\'IJ.', + status: 'failed', + locked: true, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '8bd21636-e8ce-4098-8ed8-16cbd60af340', + name: 'projet dégradé', + slug: 'projet dégradé', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Projet testé en état dégradé', + status: 'warning', + locked: true, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '554d9150-9a07-42c1-8207-1163f2f0addd', + name: 'pissenlit', + slug: 'pissenlit', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + name: 'rolistes', + slug: 'rolistes', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '0n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + ], + stage: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + name: 'dev', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + name: 'staging', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + name: 'integration', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + name: 'prod', + }, + ], + environment: [ + { + id: 'bc06ace5-ddf6-4f00-97fa-872922baf078', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.826Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '95ef0d9b-945e-4af6-851c-4c6685ceff20', + name: 'staging', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.829Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: '8d4503eb-64c7-407e-89db-6ab80865071f', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.855Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '3b0cf6c1-251b-4ec6-926f-b54ce1f82560', + name: 'staging', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.859Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: '1b9f1053-fcf5-4053-a7b2-ff8a2c0c1921', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.787Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '1c654f00-4798-4a80-929f-960ddb37885a', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '1c654f00-4798-4a80-929f-960ddb36774b', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38c5', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '2805a1f5-0ca4-46a4-b3d7-5b649aee4a91', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + createdAt: '2023-07-03T14:46:56.808Z', + updatedAt: '2023-07-03T14:46:56.815Z', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '2805a1f5-0ca4-46a4-b3d7-5b649aee4a92', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + createdAt: '2023-07-03T15:56:56.808Z', + updatedAt: '2023-07-03T15:56:56.815Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + ], + projectClusterHistory: [ + { + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38c5', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + }, + { + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + }, + ], + projectMembers: [ + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f9599', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + roleIds: [], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f95b5', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f9595', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + roleIds: [], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + roleIds: [], + }, + ], + projectPlugin: [], + projectRole: [ + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f95b5', + name: 'bg75', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + permissions: '2n', + position: 0, + }, + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f9595', + name: 'bg78', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + permissions: '1n', + position: 0, + }, + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f9599', + name: 'level up', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + permissions: '0n', + position: 0, + }, + ], + repository: [ + { + id: '53891549-e628-4893-8bd3-92abcb71068a', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + internalRepoName: 'beta-front', + externalRepoUrl: 'https://github.com/dnum-mi/beta-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.825Z', + }, + { + id: '26ad0fee-c4bd-462c-a4f7-0f7a713b56f7', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + internalRepoName: 'beta-back', + externalRepoUrl: 'https://github.com/dnum-mi/beta-back.git', + externalUserName: '', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.825Z', + }, + { + id: '83aa3c2a-cdae-4638-b95f-a02b3f31eace', + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + internalRepoName: 'archive-front', + externalRepoUrl: 'https://github.com/dnum-mi/archive-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.857Z', + }, + { + id: 'ad77aa10-a708-4fce-8d0c-15c1b4704309', + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + internalRepoName: 'archive-back', + externalRepoUrl: 'https://github.com/dnum-mi/archive-back.git', + externalUserName: '', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.854Z', + }, + { + id: '299216bb-2dcc-42b5-ac71-6aa001d2dccf', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/dnum-mi/candilib.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.802Z', + }, + { + id: 'c26df1a3-2e03-420d-9894-cd9ce4f98b60', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-back', + externalRepoUrl: 'https://github.com/dnum-mi/psij-back.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.813Z', + }, + { + id: '78f2b122-13cc-406f-bac8-9f0947a23172', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-front', + externalRepoUrl: 'https://github.com/dnum-mi/psij-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '78f2b122-13cc-406f-bac8-9f0947b23172', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-infra', + externalRepoUrl: 'https://github.com/dnum-mi/psij-infra.git', + externalUserName: 'this-is-a-test', + deployRevision: 'main', + deployPath: 'helm/', + helmValuesFiles: 'values.yaml,values.prod.yaml', + isInfra: true, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '9d3e7791-ee67-4282-be95-613c491c2e75', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + internalRepoName: 'pissenlit', + externalRepoUrl: 'https://github.com/dnum-mi/pissenlit.git', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '9d3e7791-ee67-4282-be95-613c491c2e76', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + internalRepoName: 'warhammer', + externalRepoUrl: 'https://github.com/dnum-mi/warhammer.git', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T16:46:56.809Z', + updatedAt: '2023-07-03T16:46:56.816Z', + }, + ], + systemSetting: [ + { + key: 'maintenance', + value: 'off', + }, + + ], + associations: [ + [ + 'cluster', + [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + projects: [], + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + projects: [ + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + }, + ], + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + projects: [ + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + }, + { + id: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: '9dabf3f9-6c86-4358-8598-65007d78df65', + }, + ], + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + projects: [], + }, + ], + ], + [ + 'cluster', + [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + stages: [], + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + ], + ], + ], + deployment: [], + deploymentSource: [], +} diff --git a/.jjconflict-base-0/packages/test-utils/src/index.ts b/.jjconflict-base-0/packages/test-utils/src/index.ts new file mode 100644 index 0000000000..51d39c09b7 --- /dev/null +++ b/.jjconflict-base-0/packages/test-utils/src/index.ts @@ -0,0 +1,5 @@ +export * from './imports/data.js' +export * from './utils/func-utils.js' +export * from './utils/random-project.js' +export * from './utils/random-utils.js' +export * from './utils/types.js' diff --git a/.jjconflict-base-0/packages/test-utils/src/utils/func-utils.ts b/.jjconflict-base-0/packages/test-utils/src/utils/func-utils.ts new file mode 100644 index 0000000000..da5c99a8b4 --- /dev/null +++ b/.jjconflict-base-0/packages/test-utils/src/utils/func-utils.ts @@ -0,0 +1,6 @@ +import type { User } from '@cpn-console/shared' + +export const repeatFn = (nb: number) => (fn: (optionalParam?: any) => any, optionalParam?: any) => Array.from({ length: nb }).map(() => fn(optionalParam)) + +// transform an array of UserModel in object-like usersStore +export const toUsersStore = (users: Array>): Record> => users.reduce((store, user) => ({ ...store, [user.id]: user }), {}) diff --git a/.jjconflict-base-0/packages/test-utils/src/utils/random-project.ts b/.jjconflict-base-0/packages/test-utils/src/utils/random-project.ts new file mode 100644 index 0000000000..73dcdf40bb --- /dev/null +++ b/.jjconflict-base-0/packages/test-utils/src/utils/random-project.ts @@ -0,0 +1,77 @@ +import type { User } from './types.js' +import { projectRoles } from '@cpn-console/shared' +import { repeatFn } from './func-utils.js' +import { + getRandomCluster, + getRandomEnv, + getRandomPerm, + getRandomProject, + getRandomRepo, + getRandomRole, + getRandomStage, + getRandomUser, + getRandomZone, +} from './random-utils.js' + +const basicStages = ['dev', 'staging', 'integration', 'prod'] + +export function createRandomDbSetup({ nbUsers = 1, nbRepo = 3, envs = basicStages }) { + // Create users + const users: User[] = repeatFn(nbUsers)(getRandomUser) + + // Create project + const project = getRandomProject() + + // Create Roles association table + project.roles = users.map(user => ({ + ...getRandomRole(user.id, project.id), + user, + })) + project.roles[0].role = projectRoles[0] + const ownerId = project.roles.find(role => role.role === 'owner')?.userId + + // @ts-ignore + project.members = project.roles.map(({ userId, user: { id: _, ...user }, role }) => { + return { + userId, + role, + ...user, + } + }) + + // Create zone + const zones = [getRandomZone()] + + // Create cluster + const clusters = [getRandomCluster({ projectIds: [project.id], zoneId: zones[0].id })] + // @ts-ignore + project.clusters = clusters + + // Create stages + const stages = basicStages.map(baseEnvironment => getRandomStage(baseEnvironment)) + stages.forEach((stage) => { + // @ts-ignore + stage.clusters = project.clusters + }) + + // Create repositories + project.repositories = repeatFn(nbRepo)(getRandomRepo, project.id) + + // Create environment + project.environments = envs + .map(env => getRandomEnv(env, project.id, stages[0].id, clusters[0].id)) + + // Create permissions + project.environments.forEach((env) => { + env.permissions = users.map(user => + user.id === ownerId ? getRandomPerm(env.id, user, 2) : getRandomPerm(env.id, user), + ) + }) + + return { + users, + stages, + zones, + project, + } +} diff --git a/.jjconflict-base-0/packages/test-utils/src/utils/random-utils.ts b/.jjconflict-base-0/packages/test-utils/src/utils/random-utils.ts new file mode 100644 index 0000000000..3f78ccf346 --- /dev/null +++ b/.jjconflict-base-0/packages/test-utils/src/utils/random-utils.ts @@ -0,0 +1,158 @@ +import type { AchievedStatus, ClusterDetails, ProjectRoles, Stage, Zone } from '@cpn-console/shared' +import type { Cluster, Environment, Log, Member, Permission, Project, Repository, Role, User } from './types.js' +import { achievedStatus, ClusterPrivacy, logActions, projectRoles } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { repeatFn } from './func-utils.js' + +export function getRandomProjectName() { + return faker.lorem.word() +} + +export function getRandomGitUrl() { + const url = `${faker.internet.url().split('.')[0]}.git` + return url.startsWith('https://') ? url : `https://${url.split('://')[1]}` +} + +export function getRandomProject(): Project & { status: AchievedStatus, members?: Member[] } { + const name = getRandomProjectName() + return { + id: faker.string.uuid(), + name, + slug: `${name}-1`, + description: faker.lorem.sentence(8), + status: faker.helpers.arrayElement(achievedStatus), + locked: false, + updatedAt: (new Date()).toISOString(), + createdAt: (new Date()).toISOString(), + } +} + +export function getRandomZone(): Zone { + return { + id: faker.string.uuid(), + slug: faker.lorem.word({ length: { min: 1, max: 10 } }), + label: faker.lorem.word({ length: { min: 1, max: 50 } }), + argocdUrl: faker.internet.url(), + description: faker.lorem.sentence(8), + } +} + +export function getRandomCluster({ projectIds = repeatFn(2)(faker.string.uuid), stageIds = repeatFn(2)(faker.string.uuid), privacy = faker.helpers.arrayElement(Object.values(ClusterPrivacy)), zoneId = faker.string.uuid() }: +{ projectIds?: string[], stageIds?: string[], privacy?: ClusterPrivacy, zoneId?: string }): ClusterDetails { + return { + id: faker.string.uuid(), + label: faker.lorem.word(), + infos: faker.lorem.sentence(8), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + zoneId, + projectIds: privacy === ClusterPrivacy.DEDICATED ? projectIds : [], + stageIds, + kubeconfig: { + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + }, + privacy, + clusterResources: faker.datatype.boolean(), + } +} + +export function getRandomUser(id = faker.string.uuid()): User { + return { + id, + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + } +} + +export function getRandomRole(userId = faker.string.uuid(), projectId = faker.string.uuid(), role: ProjectRoles = projectRoles[1]): Role { + return { + userId, + role, + projectId, + } +} + +export function getRandomMember(userId = faker.string.uuid(), role: ProjectRoles = projectRoles[1]): Member { + return { + userId, + role, + } +} + +export function getRandomRepo(projectId = faker.string.uuid()): Repository { + const repo: Repository = { + id: faker.string.uuid(), + projectId, + internalRepoName: faker.lorem.word(), + externalRepoUrl: getRandomGitUrl(), + isPrivate: faker.datatype.boolean(), + isInfra: faker.datatype.boolean(), + } + if (repo.isPrivate) { + repo.externalUserName = faker.person.firstName() + repo.externalToken = faker.internet.password({ length: 25 }) + } + + return repo +} + +export function getRandomStage(name: string = faker.lorem.word({ length: { min: 2, max: 20 } }), links?: { + clusterIds?: string[] + clusters?: Cluster[] +}): Stage { + return { + id: faker.string.uuid(), + name, + clusterIds: links?.clusterIds ?? links?.clusters?.map(({ id }) => id) ?? [] as string[], + } +} + +export function getRandomEnv(name = faker.lorem.slug(1), projectId = faker.string.uuid(), stageId = faker.string.uuid(), clusterId = faker.string.uuid()): Environment { + return { + id: faker.string.uuid(), + name, + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + projectId, + stageId, + clusterId, + } +} + +export function getRandomPerm(environmentId = faker.string.uuid(), user = getRandomUser(), level = faker.number.int({ min: 0, max: 2 })): Permission { + return { + id: faker.string.uuid(), + environmentId, + userId: user.id, + level, + user, + } +} + +export function getRandomLog(action = faker.helpers.arrayElement(logActions), userId = faker.string.uuid()): Log { + return { + id: faker.string.uuid(), + action, + userId, + updatedAt: (new Date()).toISOString(), + createdAt: (new Date()).toISOString(), + data: { + args: {}, + failed: faker.datatype.boolean(), + results: {}, + totalExecutionTime: 1, + }, + requestId: null, + } +} diff --git a/.jjconflict-base-0/packages/test-utils/src/utils/types.ts b/.jjconflict-base-0/packages/test-utils/src/utils/types.ts new file mode 100644 index 0000000000..14eaf73bb8 --- /dev/null +++ b/.jjconflict-base-0/packages/test-utils/src/utils/types.ts @@ -0,0 +1,112 @@ +export interface Repository { + id: string + projectId: string + internalRepoName: string + externalRepoUrl: string + isPrivate: boolean + isInfra: boolean + createdAt?: Date + updatedAt?: Date + externalUserName?: string + externalToken?: string +} + +export interface User { + id: string + email: string + firstName: string + lastName: string + updatedAt?: Date + createdAt?: Date +} + +export interface Role { + userId: string + projectId: string + role: string + updatedAt?: Date + createdAt?: Date + user?: User +} + +export interface Member { + userId: User['id'] + role: string +} + +export interface Permission { + id: string + environmentId: string + userId: string + level: number + user: User + updatedAt?: Date + createdAt?: Date +} + +export interface Cluster { + id: string + label: string + infos?: string + projectIds?: string[] + stageIds?: string[] + zoneId?: string + user: { + certData: string + keyData: string + } + cluster: { + caData: string + server: string + tlsServerName: string + } + privacy: string + clusterResources: boolean + updatedAt?: Date + createdAt?: Date +} + +export interface Environment { + id: string + name: string + cpu: number + gpu: number + memory: number + projectId: string + clusterId: string + stageId: string + permissions?: Permission[] + cluster?: Cluster + updatedAt?: Date + createdAt?: Date +} + +export interface Log { + id: string + action: string + userId: string + createdAt: Date | string + updatedAt: Date | string + data: { + args: any + failed: boolean + results: any + totalExecutionTime: number + } + requestId: null +} + +export interface Project { + id: string + name: string + slug: string + description: string + status: string + locked: boolean + roles?: Role[] + clusters?: Cluster[] + repositories?: Repository[] + environments?: Environment[] + updatedAt?: Date | string + createdAt?: Date | string +} diff --git a/.jjconflict-base-0/packages/test-utils/tsconfig.eslint.json b/.jjconflict-base-0/packages/test-utils/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-0/packages/test-utils/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-0/packages/test-utils/tsconfig.json b/.jjconflict-base-0/packages/test-utils/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-0/packages/test-utils/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-0/packages/tsconfig/package.json b/.jjconflict-base-0/packages/tsconfig/package.json new file mode 100644 index 0000000000..f90b7b4aee --- /dev/null +++ b/.jjconflict-base-0/packages/tsconfig/package.json @@ -0,0 +1,16 @@ +{ + "name": "@cpn-console/ts-config", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "version": "1.1.0", + "private": false, + "description": "", + "main": "index.js", + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} \ No newline at end of file diff --git a/.jjconflict-base-0/packages/tsconfig/tsconfig.base.json b/.jjconflict-base-0/packages/tsconfig/tsconfig.base.json new file mode 100644 index 0000000000..6a08a00241 --- /dev/null +++ b/.jjconflict-base-0/packages/tsconfig/tsconfig.base.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + "jsx": "preserve", + "lib": ["ESNext"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + + // Base + "paths": { + "@/*": [ + "src/*" + ] + }, + "resolveJsonModule": true, + + "allowImportingTsExtensions": false, + "allowJs": true, + // Best practices + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + // Some stricter flags + "useUnknownInCatchVariables": false, + "declaration": true, + "emitDeclarationOnly": false, + // Output + "noEmit": false, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + } +} diff --git a/.jjconflict-base-0/pgadmin/servers-docker.json b/.jjconflict-base-0/pgadmin/servers-docker.json new file mode 100644 index 0000000000..d1710b3648 --- /dev/null +++ b/.jjconflict-base-0/pgadmin/servers-docker.json @@ -0,0 +1,22 @@ +{ + "Servers": { + "1": { + "Name": "dso-console-db", + "Group": "Servers", + "Host": "postgres", + "Port": 5432, + "MaintenanceDB": "dso-console-db", + "Username": "admin", + "SSLMode": "prefer", + "SSLCert": "/.postgresql/postgresql.crt", + "SSLKey": "/.postgresql/postgresql.key", + "SSLCompression": 0, + "BGColor": "#EEEDE7", + "FGColor": "#05445E", + "Timeout": 10, + "UseSSHTunnel": 0, + "TunnelPort": "22", + "TunnelAuthentication": 0 + } + } +} diff --git a/.jjconflict-base-0/pgadmin/servers-kube.json b/.jjconflict-base-0/pgadmin/servers-kube.json new file mode 100644 index 0000000000..4f16a15fae --- /dev/null +++ b/.jjconflict-base-0/pgadmin/servers-kube.json @@ -0,0 +1,22 @@ +{ + "Servers": { + "1": { + "Name": "dso-console-db", + "Group": "Servers", + "Host": "dso-cpn-console-db-hl", + "Port": 5432, + "MaintenanceDB": "dso-console-db", + "Username": "admin", + "SSLMode": "prefer", + "SSLCert": "/.postgresql/postgresql.crt", + "SSLKey": "/.postgresql/postgresql.key", + "SSLCompression": 0, + "BGColor": "#EEEDE7", + "FGColor": "#05445E", + "Timeout": 10, + "UseSSHTunnel": 0, + "TunnelPort": "22", + "TunnelAuthentication": 0 + } + } +} diff --git a/.jjconflict-base-0/playwright/README.md b/.jjconflict-base-0/playwright/README.md new file mode 100644 index 0000000000..d47e6602c9 --- /dev/null +++ b/.jjconflict-base-0/playwright/README.md @@ -0,0 +1,78 @@ +# À propos + +Ce dossier contient tout ce qui nous est nécessaire pour lancer les tests automatisés avec Playwright. + +Il peut s'agir de tests E2E, d'une brique en particulier, bref tout ce qui est actionnable à partir de l'interface utilisateur. + +## Installer Playwright + +Tout est déjà prévu dans le `pnpm install` que vous avez normalement déjà exécuté à la racine du MonoRepo. Si ce n'est pas le cas vous pouvez relancer la commande dans ce dossier. + +## Installer les dépendances (navigateurs, etc.) + +Afin de pouvoir lancer les tests sur différents navigateurs, Playwright a besoin d'un certain nombre de binaires. +Pour les installer localement il faut lancer la commande suivante : + +```shell +$ pnpm exec playwright install --with-deps +``` + +Note: Il est possible qu'il soit nécessaire que vous fassiez un `sudo` + +## Lancer les tests + +Il faut d'abord lancer une stack, par ex, celle de Dev: + +```shell +$ pnpm run docker:dev +``` +On peut lancer ensuite les tests : + +```shell +$ pnpm run playwright:test --grep @e2e +``` + +OU (autre possibilité), lancer la "console" Playwright pour parcourir/exécuter les tests manuellement : + +```shell +$ pnpm run playwright:test:ui --grep @e2e +``` + +Et pour les tests d'intégration, il faut au préalable exporter des variables d'environnement : + +```shell +$ export KEYCLOAK_DOMAIN=keycloak.example.com +$ export KEYCLOAK_REDIRECT_URI=https://console.example.com +$ export KEYCLOAK_PROTOCOL=https +$ export KEYCLOAK_ADMIN_USERNAME= +$ export KEYCLOAK_ADMIN_PASSWORD= +$ export KEYCLOAK_REALM=dso +$ export CONSOLE_ADMIN_USERNAME= +$ export CONSOLE_ADMIN_PASSWORD= +$ export CONSOLE_ADMIN_EMAIL= +$ export CONSOLE_TEST_USERNAME= +$ export CONSOLE_TEST_PASSWORD= +$ export CONSOLE_TEST_EMAIL= +$ export CONSOLE_SECOND_TEST_USERNAME= +$ export CONSOLE_SECOND_TEST_PASSWORD= +$ export CONSOLE_SECOND_TEST_EMAIL= +$ export CONSOLE_GLOBAL_TIMEOUT='900000' +$ export CONSOLE_EXPECT_TIMEOUT='900000' +$ export CONSOLE_DESTINATION_CLUSTER= +$ export CONSOLE_VALUES_FILE= +``` + +On peut lancer ensuite les tests : + +```shell +$ pnpm run playwright:test:integration --grep @integ +``` + +## Conventions de code + +Afin d'assurer la lisibilité et la maintenabilité des tests Playwright, quelques décisions ont été prises concernant l'écriture du code : +- Pas d'imbrication de if : les conditions sont autorisées dans les fonctions, mais ne doivent pas être imbriquées. +- Arguments explicites : certains arguments initialement optionnels doivent être rendus obligatoires afin de rendre le comportement de la fonction plus clair (par exemple pour les zones publiques, ou les contextes spécifiques). +- Visibilité des tests : il doit être évident, à la lecture du test, de voir ce qui est réellement vérifié. Par conséquent, évitez de masquer les expect à l'intérieur de fonctions utilitaires. Les assertions doivent apparaître directement dans le corps du test lorsqu'elles participent à la vérification d'un comportement. + +Ces conventions ont pour objectif de rendre les tests plus explicites, plus faciles à relire, et de réduire les effets de bord cachés. diff --git a/.jjconflict-base-0/playwright/config/console.ts b/.jjconflict-base-0/playwright/config/console.ts new file mode 100644 index 0000000000..35ea8a13c9 --- /dev/null +++ b/.jjconflict-base-0/playwright/config/console.ts @@ -0,0 +1,83 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +// Retrieve frontend URL from environment variables (see playwright.config.ts) +export const clientURL + = process.env.KEYCLOAK_REDIRECT_URI || 'http://change-me' + +export interface Credentials { + id: string + username: string + password: string + firstName: string + lastName: string + email: string +} + +// Users referenced in Keycloak dev realm (../keycloak/realms/realm-dev.json) +export const adminUser: Credentials = { + id: '387216f1-3b87-4211-9cac-4371125e1175', + username: process.env.CONSOLE_ADMIN_USERNAME?.trim() || 'admin', + password: process.env.CONSOLE_ADMIN_PASSWORD?.trim() || 'admin', + firstName: 'Admin', + lastName: 'ADMIN', + email: process.env.CONSOLE_ADMIN_EMAIL?.trim() || 'admin@test.com', +} +export const testUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + username: process.env.CONSOLE_TEST_USERNAME?.trim() || 'test', + password: process.env.CONSOLE_TEST_PASSWORD?.trim() || 'test', + firstName: 'Jean', + lastName: 'DUPOND', + email: process.env.CONSOLE_TEST_EMAIL?.trim() || 'test@test.com', +} +export const cnolletUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + username: 'cnollet', + password: 'test', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', +} +export const tcolinUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + username: 'tcolin', + password: 'test', + firstName: 'Thibault', + lastName: 'COLIN', + email: 'thibault.colin@test.com', +} + +// User for integration test +export const secondTestUser: Credentials = { + id: 'test', + username: process.env.CONSOLE_SECOND_TEST_USERNAME?.trim() || 'secondtestuser', + password: process.env.CONSOLE_SECOND_TEST_PASSWORD?.trim() || 'secondtestuser', + firstName: 'test', + lastName: 'test', + email: process.env.CONSOLE_SECOND_TEST_EMAIL?.trim() || 'secondtestuser@example.com', +} + +export const currentUser: Credentials = { + id: 'current', + username: process.env.CONSOLE_CURRENT_TEST_USERNAME?.trim() || 'currentuser', + password: process.env.CONSOLE_CURRENT_TEST_PASSWORD?.trim() || 'currentuser', + firstName: 'test', + lastName: 'test', + email: process.env.CONSOLE_CURRENT_TEST_EMAIL?.trim() || 'currentuser@example.com', +} + +export async function signInCloudPiNative({ + page, + credentials, +}: { + page: Page + credentials: Credentials +}) { + const { username, password } = credentials + await page.getByRole('link', { name: 'Se connecter' }).click() + await page.getByRole('textbox', { name: 'Username or email' }).fill(username) + await page.getByRole('textbox', { name: 'Password' }).fill(password) + await page.getByRole('button', { name: 'Sign In' }).click() + await expect(page.locator('#top')).toContainText('Cloud π Native') +} diff --git a/.jjconflict-base-0/playwright/config/keycloak.ts b/.jjconflict-base-0/playwright/config/keycloak.ts new file mode 100644 index 0000000000..e93138829a --- /dev/null +++ b/.jjconflict-base-0/playwright/config/keycloak.ts @@ -0,0 +1,38 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +export interface KeycloakConfig { + url: string + realm: string + adminUser: string + adminPass: string + clientFrontend: string + clientBackend: string +} + +export function loadKeycloakConfig(): KeycloakConfig { + const protocol = process.env.KEYCLOAK_PROTOCOL || 'http' + const domain = process.env.KEYCLOAK_DOMAIN || 'localhost' + const port = process.env.KEYCLOAK_PORT ? `:${process.env.KEYCLOAK_PORT}` : '' + const url = `${protocol}://${domain}${port}` + + return { + url, + realm: process.env.KEYCLOAK_REALM?.trim() || 'dso', + adminUser: process.env.KEYCLOAK_ADMIN_USERNAME?.trim() || 'admin', + adminPass: process.env.KEYCLOAK_ADMIN_PASSWORD?.trim() || 'admin', + clientFrontend: process.env.KEYCLOAK_CLIENT_FRONTEND || 'dso-console-frontend', + clientBackend: process.env.KEYCLOAK_CLIENT_BACKEND || 'dso-console-backend', + } +} + +export async function signInKeycloak( + page: Page, +) { + const keycloakConfig = loadKeycloakConfig() + await page.goto(keycloakConfig.url) + await page.locator('#username').fill(keycloakConfig.adminUser) + await page.locator('#password').fill(keycloakConfig.adminPass) + await page.locator('#kc-login').click() + await expect(page.getByRole('link', { name: 'Logo' })).toBeVisible() +} diff --git a/.jjconflict-base-0/playwright/e2e-tests/admin-logs.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/admin-logs.spec.ts new file mode 100644 index 0000000000..c368f1d939 --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/admin-logs.spec.ts @@ -0,0 +1,75 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +const adminLogsUrlRe = /\/admin\/logs/ +const logCountRe = /Total\s*:\s*(\d+)/ + +async function goToAdminLogs(page: Page) { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationLogs').click() + await expect(page).toHaveURL(adminLogsUrlRe) + await expect(page.getByTestId('logCountInfo')).toBeVisible() +} + +test.describe('Administration logs', { tag: '@e2e' }, () => { + test('Should display logs list, logged in as admin', async ({ page }) => { + await goToAdminLogs(page) + + const logCountText = await page.getByTestId('logCountInfo').textContent() + expect(logCountText).toBeTruthy() + const total = Number((logCountText || '').match(logCountRe)?.[1] || 0) + + const positionInfo = page.getByTestId('positionInfo').first() + const seePreviousPageBtn = page.getByTestId('seePreviousPageBtn').first() + const seeFirstPageBtn = page.getByTestId('seeFirstPageBtn').first() + const seeNextPageBtn = page.getByTestId('seeNextPageBtn').first() + const seeLastPageBtn = page.getByTestId('seeLastPageBtn').first() + + await expect(positionInfo).toContainText('sur') + await expect(seePreviousPageBtn).toBeDisabled() + await expect(seeFirstPageBtn).toBeDisabled() + + const logs = page.locator('[data-testid$="-json"]') + await expect(logs).toHaveCount(Math.min(10, total)) + + if (total > 10) { + await expect(seeNextPageBtn).toBeEnabled() + await expect(seeLastPageBtn).toBeEnabled() + + const firstLogText = await logs.first().textContent() + await seeNextPageBtn.click() + + await expect(seePreviousPageBtn).toBeEnabled() + await expect(seeFirstPageBtn).toBeEnabled() + await expect(positionInfo).toContainText('sur') + await expect(logs.first()).not.toHaveText(firstLogText || '') + + await seeFirstPageBtn.click() + await expect(seePreviousPageBtn).toBeDisabled() + await expect(seeFirstPageBtn).toBeDisabled() + } else { + await expect(seeNextPageBtn).toBeDisabled() + await expect(seeLastPageBtn).toBeDisabled() + } + }) + + test('Should toggle compact logs display, logged in as admin', async ({ page }) => { + await goToAdminLogs(page) + + await expect(page.locator('h1')).toContainText( + 'Journaux des services associés à la chaîne DSO', + ) + + const jsonBoxes = page.locator('.json-box') + await expect(jsonBoxes.first()).toBeVisible() + + await page.getByTestId('showLogsBtn').click() + await expect(jsonBoxes).toHaveCount(0) + + await page.getByTestId('showLogsBtn').click() + await expect(jsonBoxes.first()).toBeVisible() + }) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/admin-stages.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/admin-stages.spec.ts new file mode 100644 index 0000000000..0964f9f100 --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/admin-stages.spec.ts @@ -0,0 +1,131 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative, testUser } from '../config/console' + +import { createProject } from '../helpers/project' +import { createStage, deleteStage } from '../helpers/stage' + +test.describe('Stages administration page', { tag: '@e2e' }, () => { + test('should display default stages list', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + for (const stageName of ['dev', 'integration', 'prod', 'staging']) { + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + } + }) + + test('should create a custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'all' }) + // Validation de la création + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Check stage availability in environment form + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = faker.string.alpha(10).toLowerCase() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page.getByLabel('Choix de la zone cible').selectOption({ label: 'publique' }) + await page.getByLabel('Type d\'environnement').selectOption({ label: stageName }) + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page.getByLabel('Choix du cluster cible').selectOption({ label: 'public1' }) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + }) + + test('should update a custom stage', async ({ page }) => { + // Create stage (1 cluster) + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'first' }) + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + // Update stage (adding 1 extra cluster) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.locator('h1')).toContainText(`Informations du type d\'environnement ${stageName}`) + await expect(page.getByTestId('addStageBtn')).not.toBeVisible() + await expect(page.getByTestId('updateStageBtn')).toBeVisible() + await expect(page.getByTestId('updateStageBtn')).toBeEnabled() + await expect(page.getByTestId('nameInput')).toBeDisabled() + await expect(page.getByTestId('nameInput')).toHaveValue(stageName) + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(1) + await page.locator('.fr-tag').first().click() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(2) + await page.getByTestId('updateStageBtn').click() + // Check stage (2 clusters) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.locator('h1')).toContainText(`Informations du type d\'environnement ${stageName}`) + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(2) + }) + + test('should not be able to create a stage with an existing name', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + // Try to create a stage "dev" + await expect(page.getByTestId('addStageLink')).toBeVisible() + await page.getByTestId('addStageLink').click() + await page.getByTestId('nameInput').fill('dev') + await page.getByTestId('addStageBtn').click() + await expect(page.getByTestId('snackbar').getByText('Un type d\'environnement portant ce nom existe déjà')).toBeVisible() + }) + + test('should delete a custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'first' }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Delete custom stage + await deleteStage({ page, stageName }) + await expect(page.getByTestId(`stageTile-${stageName}`)).not.toBeVisible() + }) + + test('should not delete a used custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'all' }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Use custom stage in an environment + await createProject({ page }) + const envName = faker.string.alpha(10).toLowerCase() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page.getByLabel('Choix de la zone cible').selectOption({ label: 'publique' }) + await page.getByLabel('Type d\'environnement').selectOption({ label: stageName }) + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page.getByLabel('Choix du cluster cible').selectOption({ label: 'public1' }) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Check custom stage in admin view + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.getByTestId('deleteStageZone')).not.toBeVisible() + await expect(page.getByTestId('associatedEnvironmentsZone')).toBeVisible() + await expect(page.getByTestId('associatedEnvironmentsZone')).toContainText('Le type d\'environnement ne peut être supprimé') + await expect(page.getByTestId('associatedEnvironmentsTable')).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable').getByText(envName), + ).toBeVisible() + }) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/admin-users.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/admin-users.spec.ts new file mode 100644 index 0000000000..82e3a15cb4 --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/admin-users.spec.ts @@ -0,0 +1,71 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { setCheckbox, unsetCheckbox } from 'helpers/checkbox' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +interface ApiUser { + id: string + email: string + firstName: string + lastName: string + type?: string +} + +const adminUsersUrlRe = /\/admin\/users/ +const listUsersUrlRe = /\/api\/v1\/users(?:\?|$)/ + +async function goToAdminUsers(page: Page) { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationUsers').click() + await expect(page).toHaveURL(adminUsersUrlRe) + await expect(page.getByTestId('tableAdministrationUsers')).toBeVisible() +} + +test.describe('Administration users', { tag: '@e2e' }, () => { + test('Should display admin users, logged in as admin', async ({ page }) => { + const usersResponse = page.waitForResponse((res) => { + return ( + res.request().method() === 'GET' + && listUsersUrlRe.test(res.url()) + && res.status() >= 200 + && res.status() < 300 + ) + }) + + await goToAdminUsers(page) + + const users = (await (await usersResponse).json()) as ApiUser[] + expect(users.length).toBeGreaterThan(0) + + for (const user of users) { + const row = page.getByTestId(`user-${user.id}`) + await expect(row).toContainText(user.email) + await expect(row).toContainText(user.lastName) + await expect(row).toContainText(user.firstName) + await expect(row).toContainText('202') + } + + await setCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersDisplayId')) + for (const user of users) { + await expect(page.getByTestId(`user-${user.id}`)).toContainText(user.id) + } + + const botUser = users.find(u => u.email.includes('anon@user')) ?? users.find(u => u.type && u.type !== 'human') + if (botUser) { + await expect(page.getByTestId(`user-${botUser.id}`)).toBeVisible() + await setCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersHideBots')) + await expect(page.getByTestId(`user-${botUser.id}`)).toHaveCount(0) + await unsetCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersHideBots')) + } + + const rows = page.getByTestId('tableAdministrationUsers').locator('tbody tr') + expect(await rows.count()).toBeGreaterThanOrEqual(users.length) + + const search = page.getByTestId('tableAdministrationUsersSearch') + await expect(search).toBeVisible() + await search.fill(botUser?.email ?? users[0].email) + await expect(page.getByTestId('tableAdministrationUsers').locator('tbody tr')).toHaveCount(1) + }) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/clusters.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/clusters.spec.ts new file mode 100644 index 0000000000..e7917af0d2 --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/clusters.spec.ts @@ -0,0 +1,235 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' +import { createCluster, deleteCluster } from '../helpers/cluster' +import { createEnvironment } from '../helpers/environment' +import { openClustersAdministration } from '../helpers/navigation' +import { createProject } from '../helpers/project' + +test.describe('Clusters page', () => { + test('should create a public cluster', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'first', + }) + // Validate + await expect(page.getByTestId(`clusterZone-${clusterName}`)).toContainText( + 'publique', + ) + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Public', + ) + }) + + test('should update a public cluster', { tag: '@e2e' }, async ({ page }) => { + const clusterName2 = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'public', + }) + // Update + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('labelInput')).toHaveValue(clusterName) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await page.getByTestId('labelInput').fill(clusterName2) + await expect(page.getByTestId('updateClusterBtn')).toBeVisible() + await expect(page.locator('#projects-select')).not.toBeVisible() + await expect(page.locator('#privacy-select option[selected]')).toHaveText( + 'public', + ) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('1') + await page.getByTestId('updateClusterBtn').click() + // Validate + await expect(page.getByTestId(`clusterResources-${clusterName2}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + }) + + test( + 'should create a dedicated cluster', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Validate + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Dédié', + ) + }, + ) + + test( + 'should associate a project to a dedicated cluster', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a dedicated project + const { name: projectName } = await createProject({ page }) + // Create the cluster + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Validate + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('updateClusterBtn')).toBeVisible() + await expect(page.locator('#projects-select')).toBeVisible() + await expect(page.locator('#privacy-select option[selected]')).toHaveText( + 'dedicated', + ) + // Associating specifically created project + await page.locator('#projects-select').click() + await expect( + page.locator('#projects-select .fr-tag--dismiss'), + ).toHaveCount(0) + await page + .getByTestId('choice-selector-search-projects-select') + .fill(projectName) + await page.locator('#projects-select .fr-tag').click() + await expect( + page.locator('#projects-select .fr-tag--dismiss'), + ).toHaveCount(1) + await page.getByTestId('updateClusterBtn').click() + // Validate + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Dédié', + ) + }, + ) + + test('should create a cluster even if given informations is longer than 200 chars but shorter than 1001 chars', async ({ + page, + }) => { + const informations = faker.string.alpha(1000).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + informations, + }) + // Validate + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('infosInput')).toHaveValue(informations) + }) + + test('should NOT create a cluster when given informations is longer than 1000 chars', async ({ + page, + }) => { + const clusterName = faker.string.alpha(10).toLowerCase() + const informations = faker.string.alpha(1001).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationClusters').click() + await page.getByTestId('addClusterLink').click() + await page.getByTestId('labelInput').fill(clusterName) + await page.getByTestId('infosInput').fill(informations) + await expect(page.getByTestId('addClusterBtn')).toBeDisabled() + }) + + test('should delete a cluster', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Delete + await deleteCluster({ page, clusterName }) + // Validate + await page.getByTestId('projectsSearchInput').fill(clusterName) + await expect(page.getByTestId('noClusterMsg')).toBeVisible() + await expect(page.getByTestId('noClusterMsg')).toHaveText( + 'Aucun cluster trouvé', + ) + }) + + test( + 'should NOT delete a cluster with associated environment', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a public cluster + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'all', + }) + // Create a project and an environment using this cluster + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: clusterName, + cpuInput: '2', + gpuInput: '0', + }) + // Try to delete the cluster + await expect(page.getByText('Opération en cours...')).toHaveCount(0) + await openClustersAdministration({ page }) + await page.getByTestId('projectsSearchInput').fill(clusterName) + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('deleteClusterZone')).not.toBeVisible() + await expect( + page.getByText('Le cluster ne peut être supprimé'), + ).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable'), + ).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable').getByText(envName), + ).toBeVisible() + }, + ) + + test('should NOT update the cluster label if it has associated environment', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a public cluster + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'all', + }) + // Create a project and an environment using this cluster + await createProject({ page }) + await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: clusterName, + cpuInput: '2', + gpuInput: '0', + }) + // Verify that cluster label is disabled + await expect(page.getByText('Opération en cours...')).toHaveCount(0) + await openClustersAdministration({ page }) + await page.getByTestId('projectsSearchInput').fill(clusterName) + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('labelInput')).toHaveValue(clusterName) + await expect(page.getByTestId('labelInput')).toBeDisabled() + }) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/dashboard.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/dashboard.spec.ts new file mode 100644 index 0000000000..cb72d47d0d --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/dashboard.spec.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + signInCloudPiNative, + testUser, +} from '../config/console' +import { createProject, projectSlugTextRegexp } from '../helpers/project' + +test.describe('Dashboard page', () => { + test( + 'Should display a project statuses', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { + name: projectName, + id: projectId, + slug: projectSlug, + } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + expect(await page.getByTestId('project-id').getAttribute('title')).toBe( + projectId, + ) + await expect(page.locator('h1')).toContainText(projectName) + }, + ) + + test( + 'Should add, display and edit description', + { tag: '@e2e' }, + async ({ page, browserName }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const description1 = 'Application de prise de rendez-vous en préfécture.' + const description2 + = 'Application d\'organisation de tournois de pétanque interministériels.' + + // Act 1 + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('setDescriptionBtn').click() + // Yep, we need that for now... + // @TODO Ensure setDescriptionBtn is properly chained to descriptionInput + if (browserName === 'chromium') { + await page.waitForTimeout(1000) + } + await page.getByTestId('descriptionInput').fill(description1) + await page.getByTestId('saveDescriptionBtn').click() + // Assert 1 + await expect(page.getByTestId('descriptionP')).toHaveText(description1) + + // Act 2 + await page.getByTestId('setDescriptionBtn').click() + if (browserName === 'chromium') { + await page.waitForTimeout(1000) + } + await page.getByTestId('descriptionInput').fill(description2) + await page.getByTestId('saveDescriptionBtn').click() + // Assert 2 + await expect(page.getByTestId('descriptionP')).toHaveText(description2) + }, + ) + + test('Should show project secrets', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await expect(page.getByTestId('projectSecretsZone')).not.toBeVisible() + await page.getByTestId('showSecretsBtn').click() + + // Assert + await expect(page.getByTestId('projectSecretsZone')).toBeVisible() + await expect(page.getByTestId('noProjectSecretsP')).toHaveText( + 'Aucun secret à afficher', + ) + }) + + test('Should replay hooks for project', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('snackbar')).toContainText( + 'Le projet a été reprovisionné avec succès', + ) + }) + + test( + 'Should not be able to access project secrets if not owner', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName, slug: projectSlug } = await createProject({ + page, + }) + + // Act + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('projectSecretsZone')).not.toBeVisible() + await expect(page.getByTestId('showSecretsBtn')).not.toBeVisible() + }, + ) + + // @TODO: This test's expectation was reversed in Cypress E2E tests ("you should not be able + // to archive someone else's project"), but the reality is that you actually can do that, + // and Playwright detects it as well. So, for the time being, and in order to make the test + // pass, we reversed this test's expectation: You indeed can (and therefore should be able to) + // archive someone else's project ! + test( + 'Should be able to archive a project if not owner', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName, slug: projectSlug } = await createProject({ + page, + }) + + // Act + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('showArchiveProjectBtn')).toBeVisible() + }, + ) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/deployment.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/deployment.spec.ts new file mode 100644 index 0000000000..77df873337 --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/deployment.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, testUser } from 'config/console' +import { createDeployment } from 'helpers/deployment' +import { createEnvironment } from 'helpers/environment' +import { createProject, deleteProject } from 'helpers/project' +import { createRepository } from 'helpers/repository' + +test.describe('Déploiement', { tag: '@e2e' }, () => { + let projectName: string + + test.beforeEach(async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name } = await createProject({ page }) + projectName = name + }) + + // Not working on CI (need to mock plugins....) + test.skip('should not be able to create a deployment without an environment or a repository', async ({ page }) => { + await page.getByRole('button', { name: 'Ajouter un nouveau déploiement' }).click() + await expect(page.getByText('Pour créer un déploiement, vous devez d\'abord créer un environnement et un dépôt.')).toBeVisible() + }) + + // Not working on CI (need to mock plugins....) + test.skip('should create a deployment', async ({ page }) => { + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + cpuInput: '2', + gpuInput: '0', + }) + const repoName = await createRepository({ page }) + + const deploymentName = await createDeployment({ page, envName, repoName, customStageName: 'dev' }) + await expect(page.getByText(deploymentName)).toBeVisible() + }) + + test.afterEach(async ({ page }) => { + if (!projectName) return + await deleteProject({ page, projectName }) + }) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/environments.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/environments.spec.ts new file mode 100644 index 0000000000..5035f890d1 --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/environments.spec.ts @@ -0,0 +1,335 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + cnolletUser, + signInCloudPiNative, + testUser, +} from '../config/console' + +import { unsetCheckbox } from '../helpers/checkbox' +import { createEnvironment, deleteEnvironment } from '../helpers/environment' +import { createProject } from '../helpers/project' + +test.describe('Environments page', { tag: '@e2e' }, () => { + test('should add environments to an existing project', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + cpuInput: '2', + gpuInput: '0', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + }) + + test('should not add environments to a project without enough hprod GPU', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ + page, + hprodResources: { + cpu: 10, + memory: 10, + gpu: 1, + }, + }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + memoryInput: '2', + gpuInput: '2', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).not.toBeVisible() + await expect( + page + .getByTestId('snackbar') + .getByText( + 'Le projet ne dispose pas de suffisamment de ressources : GPU.', + ), + ).toBeVisible() + }) + + test('should not add incorrect environments', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + + // Incorrect input + await page.getByTestId('environmentNameInput').click() + await page.getByTestId('environmentNameInput').fill(`${envName}-yolo`) + await expect( + page.getByRole('alert').getByText('Le nom de l\'environnment ne doit pas'), + ).toBeVisible() + + // Valid input + await page.getByTestId('cancelEnvironmentBtn').click() + await createEnvironment({ + page, + envName, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + // Second try with same name (invalid) + await createEnvironment({ + page, + envName, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect( + page + .getByTestId('snackbar') + .getByText('Ce nom d\'environnement est déjà pris'), + ).toBeVisible() + }) + + test('should alert cluster unavailability', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'Zone privée' }) + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: 'dev' }) + await expect(page.getByTestId('noClusterOptionAlert')).toBeVisible() + await expect(page.getByLabel('Choix du cluster cible')).not.toBeVisible() + + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'publique' }) + await expect(page.getByTestId('noClusterOptionAlert')).not.toBeVisible() + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + }) + + test('should display zone infos and cluster infos', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'publique' }) + await expect(page.getByTestId('chosenZoneDescription')).toBeVisible() + + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: 'dev' }) + await page + .getByLabel('Choix du cluster cible') + .selectOption({ label: 'public1' }) + await expect(page.getByTestId('clusterInfos')).toBeVisible() + }) + + test('should update environment resources', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toBeVisible() + await expect(page.locator('#zone-select option[selected]')).toHaveText( + 'publique', + ) + await expect(page.locator('#stage-select option[selected]')).toHaveText( + 'dev', + ) + await expect(page.locator('#cluster-select option[selected]')).toHaveText( + 'public1', + ) + await expect(page.getByTestId('memoryInput')).toHaveValue('1') + await expect(page.getByTestId('cpuInput')).toHaveValue('1') + await expect(page.getByTestId('gpuInput')).toHaveValue('1') + await page.getByTestId('memoryInput').fill('1.5') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('10') + await page.getByTestId('putEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1.5GiB 2CPU 10GPU', + ) + }) + + test('should not update environment resources when cluster is too small', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toBeVisible() + await expect(page.locator('#zone-select option[selected]')).toHaveText( + 'publique', + ) + await expect(page.locator('#stage-select option[selected]')).toHaveText( + 'dev', + ) + await expect(page.locator('#cluster-select option[selected]')).toHaveText( + 'public1', + ) + await expect(page.getByTestId('memoryInput')).toHaveValue('1') + await expect(page.getByTestId('cpuInput')).toHaveValue('1') + await expect(page.getByTestId('gpuInput')).toHaveValue('1') + await page.getByTestId('memoryInput').fill('120') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('1') + await page.getByTestId('putEnvironmentBtn').click() + await expect( + page + .getByTestId('snackbar') + .getByText( + 'Le cluster ne dispose pas de suffisamment de ressources : Mémoire.', + ), + ).toBeVisible() + }) + + test('should delete an environment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + await deleteEnvironment({ page, projectName, envName }) + }) + + test('should not be able to delete an environment if not owner', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ + page, + members: [cnolletUser], + }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Sign off and login as another user (project member) + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: cnolletUser }) + // Select previously created project + await page.getByTestId('menuMyProjects').click() + await expect(page.getByTestId('createProjectLink')).toBeVisible() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + // Verify absence of delete button + await page.getByTestId(`environmentTr-${envName}`).click() + await expect( + page.getByTestId('showDeleteEnvironmentBtn'), + ).not.toBeVisible() + }) + + test('should not be able to delete an environment if project locked', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName } = await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Sign off and login as admin to lock the project + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await page.getByTestId('handleProjectLockingBtn').click() + + // Sign off and login back as user + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: testUser }) + // Verify absence of delete button + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId(`environmentTr-${envName}`).click() + await expect( + page.getByTestId('showDeleteEnvironmentBtn'), + ).not.toBeVisible() + }) + + test('should show a warning if autosync is deactivated', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Verify warning message + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('input-checkbox-autosyncCbx')).toBeVisible() + await expect(page.getByTestId('input-checkbox-autosyncCbx')).toBeChecked() + await expect(page.getByTestId('noAutosyncAlert')).not.toBeVisible() + + // Act - Uncheck auto-sync to trigger warning message + await unsetCheckbox(page.getByTestId('input-checkbox-autosyncCbx')) + await expect(page.getByTestId('noAutosyncAlert')).toBeVisible() + await expect(page.getByTestId('noAutosyncAlert')) + .toHaveText('La synchronisation automatique est désactivée. Les déploiements devront être synchronisés manuellement.') + }) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/keycloak.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/keycloak.spec.ts new file mode 100644 index 0000000000..17c87fb6d4 --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/keycloak.spec.ts @@ -0,0 +1,185 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { loadKeycloakConfig, signInKeycloak } from '../config/keycloak' + +const keycloakConfig = loadKeycloakConfig() +const usersToDelete: string[] = [] +const groupsToDelete: string[] = [] + +export async function createUser(page: Page, usersToDelete: string[]) { + await page.getByRole('link', { name: 'Users' }).click() + await page.getByTestId('add-user').click() + const username = faker.internet.username() + usersToDelete.push(username) + await page + .getByRole('textbox', { name: 'Username' }) + .fill(username) + await page.getByTestId('email').fill(faker.internet.email()) + await page.getByTestId('user-creation-save').click() + await expect(page.getByLabel('The user has been created')).toBeVisible() + return username +} + +export async function createGroup(page: Page, groupsToDelete: string[]) { + await page.getByRole('link', { name: 'Groups' }).click() + await page.getByRole('button', { name: 'Create group' }).click() + const groupName = faker.string.alpha(10) + groupsToDelete.push(groupName) + await page.getByTestId('name').fill(groupName) + await page.getByTestId('createGroup').click() + await expect(page.getByLabel('Group created')).toBeVisible() + return groupName +} + +test.describe('Keycloak', () => { + test.beforeEach(async ({ page }) => { + await signInKeycloak(page) + }) + + test('should sign in to master realm', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + // expect also exists in sign in fuction but we check it explicitely here + // for documentation purposes + await expect(page.getByRole('link', { name: 'Logo' })).toBeVisible() + await page.getByTestId('realmSelector').click() + await expect(page.getByRole('menuitem', { name: keycloakConfig.realm })).toBeVisible() + }) + + test('should have a CπN realm', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByTestId('realmSelector').click() + await expect( + page.getByRole('menuitem', { name: keycloakConfig.realm }), + ).toBeVisible() + }) + + test.describe('CπN realm', () => { + test.beforeEach(async ({ page }) => { + await page.getByTestId('realmSelector').click() + await page.getByRole('menuitem', { name: keycloakConfig.realm }).click() + }) + + test('should have required Clients', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByTestId('realmSelector').click() + await page.getByRole('menuitem', { name: keycloakConfig.realm }).click() + await page.getByRole('link', { name: 'Clients' }).click() + await expect(page.getByRole('link', { name: keycloakConfig.clientFrontend })).toBeVisible() + await expect(page.getByRole('link', { name: keycloakConfig.clientBackend })).toBeVisible() + await page.getByRole('link', { name: 'Client scopes' }).click() + await expect(page.getByRole('link', { name: 'generic' })).toBeVisible() + }) + + test('should have required Clients scopes', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByRole('link', { name: 'Client scopes' }).click() + await expect(page.getByRole('link', { name: 'generic' })).toBeVisible() + }) + + test('should create a user', { tag: '@e2e' }, async ({ page }) => { + await createUser(page, usersToDelete) + }) + + test('should create a group', { tag: '@e2e' }, async ({ page }) => { + await createGroup(page, groupsToDelete) + }) + + test('should link a user to a group', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + const username = await createUser(page, usersToDelete) + const groupName = await createGroup(page, groupsToDelete) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .fill(groupName) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page.getByRole('button', { name: groupName }).click() + await page.getByTestId('members').click() + await page.getByRole('button', { name: 'Add member' }).click() + await page + .getByTestId('titleUsersinput') + .getByRole('textbox', { name: 'Search' }) + .fill(username) + await page + .getByTestId('titleUsersinput') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page.getByRole('checkbox', { name: 'Select row 0' }).check() + await page.getByTestId('add').click() + await expect(page.getByText('user added to the group')).toBeVisible() + }) + + test('should delete a group', { tag: '@e2e' }, async ({ page }) => { + const groupName = await createGroup(page, groupsToDelete) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .fill(groupName) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page + .getByRole('treeitem', { name: `${groupName} Actions` }) + .getByLabel('Actions') + .click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByTestId('confirm').click() + }) + + test('should delete a user', { tag: '@e2e' }, async ({ page }) => { + await createUser(page, usersToDelete) + await page.getByTestId('action-dropdown').click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByTestId('confirm').click() + }) + + test('cleanup Keycloak test data', { tag: '@integ' }, async ({ request }) => { + const params = new URLSearchParams() + params.append('grant_type', 'password') + params.append('client_id', 'admin-cli') + params.append('username', keycloakConfig.adminUser) + params.append('password', keycloakConfig.adminPass) + + const tokenRes = await request.post( + `${keycloakConfig.url}/realms/master/protocol/openid-connect/token`, + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: params.toString(), + }, + ) + + if (!tokenRes.ok) { + throw new Error(`Failed to get token: ${tokenRes.statusText}`) + } + + const tokenData = await tokenRes.json() + const authHeader = { Authorization: `Bearer ${tokenData.access_token}` } + + for (const username of usersToDelete) { + const usersRes = await request.get( + `${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users?username=${username}`, + { headers: authHeader }, + ) + const users = await usersRes.json() + for (const user of users) { + await request.delete(`${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users/${user.id}`, { + headers: authHeader, + }) + } + } + + for (const groupName of groupsToDelete) { + const groupsRes = await request.get( + `${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/groups?search=${groupName}`, + { headers: authHeader }, + ) + const groups = await groupsRes.json() + for (const group of groups) { + await request.delete(`${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/groups/${group.id}`, { + headers: authHeader, + }) + } + } + }) + }) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/profile.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/profile.spec.ts new file mode 100644 index 0000000000..0794c9c6e5 --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/profile.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test' + +import { + clientURL, + cnolletUser, + signInCloudPiNative, + tcolinUser, +} from '../config/console' + +test.describe('Profile page', () => { + test('Should display name once logged', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + + await expect(page.getByTestId('menuUserList')).toContainText( + `${cnolletUser.firstName} ${cnolletUser.lastName}`, + ) + }) + + test('Should display profile infos', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.goto(`${clientURL}/profile/info`) + + const locator = expect(page.getByTestId('profileInfos')) + await locator.toContainText( + `${tcolinUser.lastName}, ${tcolinUser.firstName}`, + ) + await locator.toContainText(tcolinUser.id) + await locator.toContainText('Admin') + await locator.toContainText(tcolinUser.email) + }) + + test('Should create a PAT', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.goto(`${clientURL}/profile/tokens`) + + // Act + await page.getByTestId('showNewTokenFormBtn').click() + await page.getByTestId('newTokenName').fill('test2') + await page.getByTestId('expirationDateInput').fill('2100-11-22') + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('newTokenPassword')).toBeVisible() + await page.getByTestId('showNewTokenFormBtn').click() + + // Assert + expect(page.getByTestId('newTokenPassword')).toBeDefined() + const locator = page.locator('tr', { hasText: 'test2' }) + expect(locator).toContainText(new Date().getFullYear().toString()) + expect(locator).toContainText('2100') + expect(locator).toContainText('Jamais') + expect(locator).toContainText('Actif') + await locator.getByTitle('Supprimer').click() + await page.getByTestId('confirmDeletionBtn').click() + await expect(page.getByTestId('tokenTable')).toContainText( + 'Aucune clé d\'api existante', + ) + }) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/project-logs.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/project-logs.spec.ts new file mode 100644 index 0000000000..78c0798827 --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/project-logs.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test' +import { + clientURL, + signInCloudPiNative, + tcolinUser, + testUser, +} from '../config/console' +import { createProject } from '../helpers/project' + +test.describe('Project logs page', () => { + test( + 'Should display project logs as owner', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + // Act + await createProject({ page }) + + // Assert + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + }, + ) + + test( + 'Should display additional logs after reprovisionning a project', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + + // Act + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 7', + ) + }, + ) + + test( + 'Should display project logs as project manager as well as a project member', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + // Act + const { name: projectName } = await createProject({ + page, + members: [tcolinUser], + }) + + // Assert - as Project Owner + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + + // Assert - as Project Member + await page.getByRole('link', { name: 'Se Déconnecter' }).click() + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 7', + ) + }, + ) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/project-roles.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/project-roles.spec.ts new file mode 100644 index 0000000000..23e9cf19fc --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/project-roles.spec.ts @@ -0,0 +1,201 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { setCheckbox, unsetCheckbox } from 'helpers/checkbox' +import { clientURL, cnolletUser, signInCloudPiNative, testUser } from '../config/console' +import { createEnvironment } from '../helpers/environment' +import { createProject, deleteProject } from '../helpers/project' +import { createRepository } from '../helpers/repository' + +async function openProjectByName({ page, projectName }: { page: Page, projectName: string }) { + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() +} + +async function openProjectRoleByName({ page, roleName }: { page: Page, roleName: string }) { + await page.getByTestId('test-tab-roles').click() + await page + .locator('[data-testid$="-tab"]') + .filter({ hasText: roleName }) + .click() +} + +async function assignPerms({ + page, + roleName, + perms, +}: { + page: Page + roleName: string + perms: readonly string[] +}) { + await openProjectRoleByName({ page, roleName }) + const setPermChecked = async (key: string, checked: boolean) => { + const input = page.locator(`#${key}-cbx`) + await expect(input).toBeVisible() + if ((await input.isChecked()) === checked) + return + + const label = page.locator(`label[for="${key}-cbx"]`) + if (await label.count()) { + await label.click() + } else { + await setCheckbox(input) + } + + try { + await expect(input).toBeChecked({ checked }) + } catch { + await input.evaluate((el: HTMLInputElement, nextChecked: boolean) => { + el.checked = nextChecked + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, checked) + await expect(input).toBeChecked({ checked }) + } + } + + for (const key of perms) { + await setPermChecked(key, true) + } + await expect(page.getByTestId('saveBtn')).toBeEnabled() + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') +} + +test.describe.serial('Project roles', { tag: '@e2e' }, () => { + const newRoleName = `role-${faker.string.alpha(10).toLowerCase()}` + let projectName = '' + let repositoryName = '' + let environmentName = '' + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + const project = await createProject({ page, members: [cnolletUser] }) + projectName = project.name + + repositoryName = await createRepository({ page }) + environmentName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + await page.getByTestId('test-tab-roles').click() + await page.getByTestId('addRoleBtn').click() + await expect(page.getByTestId('snackbar')).toContainText('Rôle ajouté') + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRoleName) + await page.getByTestId('saveBtn').click() + + await page.getByRole('tab', { name: 'Membres' }).click() + await setCheckbox(page.getByTestId(`input-checkbox-${cnolletUser.id}-cbx`)) + await page.close() + }) + + test.afterAll(async ({ browser }) => { + if (!projectName) + return + const page = await browser.newPage() + try { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await deleteProject({ page, projectName }) + } finally { + await page.close() + } + }) + + test('Should not grant perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + await openProjectByName({ page, projectName }) + + await page.getByTestId('test-tab-team').click() + await expect(page.getByTestId('teamTable').getByText('Retirer du projet')).toBeVisible() + await expect(page.getByTestId('addUserSuggestionInput')).toHaveCount(0) + await expect(page.getByTestId('showTransferProjectBtn')).toHaveCount(0) + await expect(page.getByTestId('test-tab-roles')).toBeVisible() + }) + + test('System roles forbid edits', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + + await openProjectRoleByName({ page, roleName: 'DevOps' }) + + await expect(page.getByTestId('roleNameInput')).toBeDisabled() + await expect(page.locator('#LIST_ENVIRONMENTS-cbx')).toBeDisabled() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('deleteBtn')).toHaveCount(0) + }) + + test('System roles allow member assignment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + await openProjectRoleByName({ page, roleName: 'DevOps' }) + + const membersTab = page.getByRole('tab', { name: 'Membres' }) + await expect(membersTab).toBeVisible() + await membersTab.click() + + const memberCheckbox = page.getByTestId(`input-checkbox-${cnolletUser.id}-cbx`) + await expect(memberCheckbox).toBeVisible() + + await setCheckbox(memberCheckbox) + await expect(memberCheckbox).toBeChecked() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') + + await unsetCheckbox(memberCheckbox) + await expect(memberCheckbox).not.toBeChecked() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') + }) + + test('Should assign view perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + await assignPerms({ + page, + roleName: newRoleName, + perms: ['LIST_ENVIRONMENTS', 'LIST_REPOSITORIES'], + }) + }) + + test('Should grant view perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + await openProjectByName({ page, projectName }) + await page.getByTestId('test-tab-resources').click() + + await expect(page.getByTestId('noReposTr')).toHaveCount(0) + await expect(page.getByTestId('addRepoLink')).toBeHidden() + await page.getByTestId(`repoTr-${repositoryName}`).click() + await expect(page.getByTestId('syncRepoBtn')).toHaveCount(0) + await expect(page.getByTestId('updateRepoBtn')).toHaveCount(0) + await expect(page.getByTestId('showDeleteRepoBtn')).toHaveCount(0) + await page.locator('.fr-modal__header > button.fr-btn--close').click() + + await expect(page.getByTestId('noEnvsTr')).toHaveCount(0) + await expect(page.getByTestId('addEnvironmentLink')).toBeHidden() + await page.getByTestId(`environmentTr-${environmentName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toHaveCount(0) + await expect(page.getByTestId('showDeleteEnvironmentBtn')).toHaveCount(0) + await page.locator('.fr-modal__header > button.fr-btn--close').click() + + await page.getByTestId('test-tab-team').click() + await expect(page.getByTestId('teamTable').getByText('Retirer du projet')).toBeVisible() + await expect(page.getByTestId('addUserSuggestionInput')).toHaveCount(0) + await expect(page.getByTestId('showTransferProjectBtn')).toHaveCount(0) + + await page.getByTestId('test-tab-roles').click() + await expect(page.getByTestId('insuficientPermsRoles')).toBeVisible() + }) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/projects.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/projects.spec.ts new file mode 100644 index 0000000000..0cf0d62384 --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/projects.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + cnolletUser, + signInCloudPiNative, + testUser, +} from '../config/console' +import { deleteValidationInput } from '../helpers/constants' +import { createProject } from '../helpers/project' +import { createRepository, synchronizeBranchOnRepository } from '../helpers/repository' + +test.describe('Projects page', () => { + test( + 'Should display only projects that user is member of', + { tag: '@e2e' }, + async ({ page }) => { + // Create a project as one user + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ page }) + + // Sign off and login as another user + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: cnolletUser }) + + // Previously created project should not appear for this user + await page.getByTestId('menuMyProjects').click() + await expect(page.getByTestId('createProjectLink')).toBeVisible() + await expect( + page.getByRole('link', { name: projectName }), + ).not.toBeVisible() + }, + ) + + test( + 'Should not keep the same default branch name for all repositories of a projects', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + // Adding a project directly puts us on the newly created project page, + // so no need to add navigation steps to go there + const firstRepositoryName = await createRepository({ page }) + const branchName = await synchronizeBranchOnRepository({ + page, + repositoryName: firstRepositoryName, + }) + const secondRepositoryName = await createRepository({ page }) + await page.getByTestId(`repoTr-${secondRepositoryName}`).click() + await expect(page.getByTestId('resource-modal')).toBeVisible() + await expect(page.getByTestId('branchNameInput')).not.toHaveValue( + branchName, + ) + }, + ) + + // @TODO Archiving and Deleting a project is, basically, the same thing. It seems that + // initially we wanted to delete projects but we moved on to archiving, while not updating + // the frontend x) + test( + 'Should archive a project, when logged in as an admin', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const { id: projectId, name: projectName } = await createProject({ page }) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Tous') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('archiveProjectInput')).not.toBeVisible() + await page.getByTestId('showArchiveProjectBtn').click() + await expect(page.getByTestId('confirmDeletionBtn')).toBeDisabled() + await page.getByTestId('archiveProjectInput').fill(deleteValidationInput) + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Archivés') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + // Projects are renamed (suffixed with a timestamp and `_archived`) so @TODO: Do Better… + await expect(page.getByRole('table', { name: 'Liste des projets' })).toContainText(projectName) + }, + ) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/repos.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/repos.spec.ts new file mode 100644 index 0000000000..41362ea716 --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/repos.spec.ts @@ -0,0 +1,742 @@ +import { expect, test } from '@playwright/test' +import { + clientURL, + signInCloudPiNative, + tcolinUser, + testUser, +} from '../config/console' + +import { setCheckbox, unsetCheckbox } from '../helpers/checkbox' +import { + deleteValidationInput, + fakeToken, + invalidGitUrlErrorMessage, + invalidInternalRepoErrorMessage, +} from '../helpers/constants' +import { createProject, projectSlugTextRegexp } from '../helpers/project' + +test.describe('Repositories', () => { + // @TODO: Rework this Cypress-inherited test (use of following-sibling is a + // good hint that something is very very wrong) + test( + 'Should handle repository form validation', + { tag: ['@e2e', '@repos', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + internalRepoName: 'repo00', + externalRepoUrl: 'https://github.com/externalUser01/repo00.git', + externalUserName: 'user', + externalToken: 'videnden88EHEBdldd_T0k9n', + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + + await page.getByTestId('addRepoLink').click() + await page.locator('dialog').waitFor({ state: 'visible' }) + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await setCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await unsetCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('internalRepoNameInput').fill('$%_>') + await expect( + page + .getByTestId('internalRepoNameInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidInternalRepoErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await expect( + page + .getByTestId('internalRepoNameInput') + .locator('//following-sibling::*[1]'), + ).not.toBeVisible() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.slice(0, -4)) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.slice(8)) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.replace('https', 'http')) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).not.toBeVisible() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalUserNameInput') + .fill(repo.externalUserName) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalUserNameInput').clear() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalTokenInput').clear() + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await unsetCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + }, + ) + + test( + 'Should add a standalone public repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + internalRepoName: 'repo00', + isInfra: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await setCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + await page.getByTestId('addRepoBtn').click() + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.locator('h2')).not.toContainText( + 'Synchroniser le dépôt', + ) + }, + ) + + test( + 'Should add an external public repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser01/repo01.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo01', + isInfra: false, + isPrivate: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external private repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser02/repo02.git', + externalToken: 'hoqjC1vXtABzytBIWBXsdyzubmqMYkgA', + externalUserName: '', + internalRepoName: 'repo02', + isInfra: false, + isPrivate: true, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external public infra repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external private infra repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser04/repo04.git', + externalToken: '', + externalUserName: 'externalUser+04', + internalRepoName: 'repo04', + isInfra: true, + isPrivate: true, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page + .getByTestId('externalUserNameInput') + .fill(repo.externalUserName) + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should update a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Update repository + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('internalRepoNameInput')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill('https://github.com/externalUser04/new-repo.git') + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page.getByTestId('externalUserNameInput').fill('newUser') + await page.getByTestId('externalTokenInput').fill('newToken') + await expect( + page.getByTestId('input-checkbox-infraRepoCbx'), + ).toBeEnabled() + await page.getByTestId('updateRepoBtn').click() + await expect(page.getByTestId(`repoTr-repo03`)).toBeVisible() + await page.reload() + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('externalRepoUrlInput')).toHaveValue( + 'https://github.com/externalUser04/new-repo.git', + ) + await expect( + page.getByTestId('input-checkbox-privateRepoCbx'), + ).toBeChecked() + await expect(page.getByTestId('externalUserNameInput')).toHaveValue( + 'newUser', + ) + await expect(page.getByTestId('externalTokenInput')).toHaveValue( + fakeToken, + ) + }, + ) + + test( + 'Should synchronise a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Update repository + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('branchNameInput')).toHaveValue('main') + await expect(page.getByTestId('branchNameInput')).toBeEnabled() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page + .getByTestId('toggleSyncAllBranches') + .locator('input') + .check({ force: true }) + await expect(page.getByTestId('branchNameInput')).not.toBeVisible() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page + .getByTestId('toggleSyncAllBranches') + .locator('input') + .uncheck({ force: true }) + await expect(page.getByTestId('branchNameInput')).toHaveValue('main') + await expect(page.getByTestId('branchNameInput')).toBeEnabled() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page.getByTestId('syncRepoBtn').click() + await expect( + page.getByText( + 'Travail de synchronisation lancé pour le dépôt repo03Fermer le message', + ), + ).toBeVisible() + + await page.getByTestId('branchNameInput').clear() + await expect(page.getByTestId('syncRepoBtn')).toBeDisabled() + await page.getByTestId('branchNameInput').fill('develop') + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page.getByTestId('syncRepoBtn').click() + await expect( + page.getByText( + 'Travail de synchronisation lancé pour le dépôt repo03Fermer le message', + ), + ).toBeVisible() + }, + ) + + test( + 'Should delete a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Delete repository + // + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('deleteRepoInput')).not.toBeVisible() + await expect(page.getByTestId('deleteRepoZone')).toBeVisible() + await page.getByTestId('showDeleteRepoBtn').click() + await expect(page.getByTestId('deleteRepoBtn')).toBeDisabled() + await page.getByTestId('deleteRepoInput').fill(deleteValidationInput) + await page.getByTestId('deleteRepoBtn').click() + await expect(page.getByTestId('repo-form')).not.toBeVisible() + await page.reload() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).not.toBeVisible() + }, + ) + + test( + 'Should not be able to delete a repository if not owner', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { + id: projectId, + name: projectName, + slug: projectSlug, + } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + + // Assert - Attempt to delete repository + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('deleteRepoZone')).not.toBeVisible() + }, + ) + + test( + 'Should not be able to delete a repository if project locked', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ + page, + credentials: + // Only admin users can lock projects + tcolinUser, + }) + const { + id: projectId, + name: projectName, + slug: projectSlug, + } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Act - Lock the project (have to go through admin menu for that) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('handleProjectLockingBtn').click() + + // Assert - Attempt to delete repository + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('showDeleteRepoBtn')).not.toBeVisible() + }, + ) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/roles.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/roles.spec.ts new file mode 100644 index 0000000000..a81d940536 --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/roles.spec.ts @@ -0,0 +1,228 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from 'config/console' +import { setCheckbox } from 'helpers/checkbox' + +test.describe('Administration Roles', () => { + test( + 'Should list admin roles', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + // Assert + // Obviously, we should not use data-testid that are made of ids… + // @TODO: Do better. + await expect( + page.getByTestId('76229c96-4716-45bc-99da-00498ec9018c-tab'), + ).toBeVisible() + await expect( + page.getByTestId('eadf604f-5f54-4744-bdfb-4793d2271e9b-tab'), + ).toBeVisible() + }, + ) + + test('Should add a new OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newOidcRole = { + name: faker.string.alpha(10).toLowerCase(), + oidcGroupName: `/${faker.string.alpha(10).toLowerCase()}`, + user: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newOidcRole.name) + await page.getByTestId('oidcGroupInput').fill(newOidcRole.oidcGroupName) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).toContainText(newOidcRole.name) + await page.getByTestId('test-members').click() + await expect(page.getByTestId('addUserBtn')).toBeDisabled() + await page + .getByTestId('addUserSuggestionInput') + .locator('input') + .fill(`${newOidcRole.user.email}`) + await page.getByTestId('addUserBtn').click() + + // Assert + const membershipCheckbox = page.getByTestId( + `input-checkbox-${newOidcRole.user.id}-cbx`, + ) + await expect(membershipCheckbox).toBeVisible() + await expect(membershipCheckbox).toBeChecked() + }) + + test('Should add a new non-OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + }) + + test('Should add a user to a role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + user: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create non-OIDC role + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + // Add user to role + await page.getByTestId('test-members').click() + await expect(page.getByTestId('addUserBtn')).toBeDisabled() + await page + .getByTestId('addUserSuggestionInput') + .locator('input') + .fill(`${newRole.user.email}`) + await page.getByTestId('addUserBtn').click() + + // Assert + const membershipCheckbox = page.getByTestId( + `input-checkbox-${newRole.user.id}-cbx`, + ) + await expect(membershipCheckbox).toBeVisible() + await expect(membershipCheckbox).toBeChecked() + }) + + test('Should remove OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newOidcRole = { + name: faker.string.alpha(10).toLowerCase(), + oidcGroupName: `/${faker.string.alpha(10).toLowerCase()}`, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create OIDC role + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newOidcRole.name) + await page.getByTestId('oidcGroupInput').fill(newOidcRole.oidcGroupName) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newOidcRole.name) + // Delete role + await page.getByTestId('deleteBtn').click() + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + }) + + test('Should remove non-OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create OIDC role + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + // Delete role + await page.getByTestId('deleteBtn').click() + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + }) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/service-chains.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/service-chains.spec.ts new file mode 100644 index 0000000000..e1dc6def9d --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/service-chains.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' + +test.describe('Service Chains page', () => { + test.describe('Given an Admin-level user', () => { + test( + 'should list service chains', + { tag: ['@e2e', '@service-chains'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationServiceChains').click() + // We take the first service chain available + await expect( + page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1), + ).toBeVisible() + }, + ) + + test( + 'should show a service chain details', + { tag: ['@e2e', '@service-chains'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationServiceChains').click() + // We take the first service chain available + await page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1) + .click() + await expect( + page.getByRole('heading', { name: 'Chaîne de services' }), + ).toBeVisible() + }, + ) + + test( + 'should show a service chain flows', + { tag: ['@e2e', '@service-chains', '@service-chain-flows'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await expect(page.locator('.fr-collapsing')).toBeVisible() + await expect(page.locator('.fr-collapsing')).not.toBeVisible() + + await page.getByTestId('menuAdministrationServiceChains').click() + await expect( + page.getByTitle('Voir les détails de la chaîne de service').first(), + ).toBeVisible() + // We take the first service chain available + await page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1) + .click() + await expect(page.getByTestId('service-chain-flows')).toBeVisible() + }, + ) + }) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/tokens.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/tokens.spec.ts new file mode 100644 index 0000000000..f6aa4b34db --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/tokens.spec.ts @@ -0,0 +1,37 @@ +import { faker } from '@faker-js/faker' + +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +test.describe('Admin Tokens page', () => { + // @TODO The original test did actually test whether the generated token worked. + // Add a dedicated test to do exactly that. + test( + 'Should create tokens, when logged in as an admin', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const tokenName = faker.string.alpha(10).toLowerCase() + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationToken').click() + await page.getByTestId('showNewTokenFormBtn').click() + + await page.getByTestId('newTokenName').fill(tokenName) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('newTokenPassword')).toBeVisible() + // Reinit form should hide newly generated password + await page.getByTestId('showNewTokenFormBtn').click() + await expect(page.getByTestId('newTokenPassword')).not.toBeVisible() + + await expect(page.getByTestId('tokenTable')).toContainText(tokenName) + }, + ) + + // @TODO Add token deletion test (the original one used "nth" which is not reliable) +}) diff --git a/.jjconflict-base-0/playwright/e2e-tests/zones.spec.ts b/.jjconflict-base-0/playwright/e2e-tests/zones.spec.ts new file mode 100644 index 0000000000..04c61a951b --- /dev/null +++ b/.jjconflict-base-0/playwright/e2e-tests/zones.spec.ts @@ -0,0 +1,193 @@ +import { faker } from '@faker-js/faker' + +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' +import { deleteValidationInput } from '../helpers/constants' + +test.describe('Zone page', () => { + // @TODO: Add clusters to this test to ensure this part of the feature + test('Should create a zone', { tag: ['@e2e', '@need-rework'] }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + expect(expect(page.locator('#clusters-select')).not.toBeVisible()) + await page.getByTestId('addZoneBtn').click() + + await page.reload() + + // Assert + await page.locator(`#zoneTile-${zone.slug}`).click() + await expect(page.getByTestId('slugInput')).toHaveValue(zone.slug) + await expect(page.getByTestId('slugInput')).toBeDisabled() + await expect(page.getByTestId('labelInput')).toHaveValue(zone.label) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await expect(page.getByTestId('argocdUrlInput')).toHaveValue( + zone.argocdUrl, + ) + await expect(page.getByTestId('argocdUrlInput')).toBeEnabled() + await expect(page.getByTestId('descriptionInput')).toHaveValue( + zone.description, + ) + await expect(page.getByTestId('descriptionInput')).toBeEnabled() + await expect(page.locator('#clusters-select')).toBeVisible() + await expect(page.locator('#clusters-select')).toHaveAttribute( + 'disabled', + 'true', + ) + await expect(page.getByTestId('updateZoneBtn')).toBeEnabled() + }) + + test('Should update a zone', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + const updatedZone = { + label: 'Zone Mise à Jour', + argocdUrl: 'https://vousnetesplusici.fr', + description: 'Cette zone a été mise à jour.', + } + // Zone creation (for later update) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + + // Act + await page.locator(`#zoneTile-${zone.slug}`).click() + await page.getByTestId('labelInput').fill(updatedZone.label) + await page.getByTestId('argocdUrlInput').fill(updatedZone.argocdUrl) + await page.getByTestId('descriptionInput').fill(updatedZone.description) + await page.getByTestId('updateZoneBtn').click() + + // Assert + await page.locator(`#zoneTile-${zone.slug}`).click() + await expect(page.getByTestId('slugInput')).toHaveValue(zone.slug) + await expect(page.getByTestId('slugInput')).toBeDisabled() + await expect(page.getByTestId('labelInput')).toHaveValue(updatedZone.label) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await expect(page.getByTestId('argocdUrlInput')).toHaveValue( + updatedZone.argocdUrl, + ) + await expect(page.getByTestId('argocdUrlInput')).toBeEnabled() + await expect(page.getByTestId('descriptionInput')).toHaveValue( + updatedZone.description, + ) + await expect(page.getByTestId('descriptionInput')).toBeEnabled() + await expect(page.locator('#clusters-select')).toBeVisible() + await expect(page.locator('#clusters-select')).toHaveAttribute( + 'disabled', + 'true', + ) + await expect(page.getByTestId('updateZoneBtn')).toBeEnabled() + }) + + test('Should not create a zone if slug is already taken', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + // Create the zone + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + + // Act - attempt to create a zone with the same slug + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(`${zone.label}bis`) + await page.getByTestId('argocdUrlInput').fill(`${zone.argocdUrl}bis`) + await page.getByTestId('descriptionInput').fill(`${zone.description}bis`) + await page.getByTestId('addZoneBtn').click() + + // Assert + const snackbar = page + .getByTestId('snackbar') + .filter({ hasText: `Une zone portant le nom ${zone.slug} existe déjà.` }) + await expect(snackbar).toBeVisible() + }) + + test('Should not delete a zone if associated clusters', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + // No data to arrange, a public zone is present by default (I guess ?) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + + // Assert + await page.getByTestId(`zoneTile-publique`).click() + await expect(page.getByTestId('showDeleteZoneBtn')).not.toBeVisible() + await expect(page.getByTestId('associatedClustersAlert')).toBeVisible() + }) + + test('Should delete a zone', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + // Create the zone + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + // @TODO: Add clusters to this test to ensure we can't delete a zone that has clusters + + // Act + await page.getByTestId('menuAdministrationZones').click() + await page.locator(`#zoneTile-${zone.slug}`).click() + await page.getByTestId('showDeleteZoneBtn').click() + await page.getByTestId('deleteZoneInput').fill(deleteValidationInput) + await page.getByTestId('deleteZoneBtn').click() + + // Assert + await page.getByTestId('menuAdministrationZones').click() + await expect(page.locator(`#zoneTile-${zone.slug}`)).not.toBeVisible() + }) +}) diff --git a/.jjconflict-base-0/playwright/eslint.config.mjs b/.jjconflict-base-0/playwright/eslint.config.mjs new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-0/playwright/eslint.config.mjs @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-0/playwright/helpers/checkbox.ts b/.jjconflict-base-0/playwright/helpers/checkbox.ts new file mode 100644 index 0000000000..b304a5c380 --- /dev/null +++ b/.jjconflict-base-0/playwright/helpers/checkbox.ts @@ -0,0 +1,20 @@ +import type { Locator } from '@playwright/test' +import { expect } from '@playwright/test' + +async function setCheckboxValue(locator: Locator, checked: boolean) { + await expect(locator).toBeVisible() + await locator.evaluate((el: HTMLInputElement, nextChecked: boolean) => { + el.checked = nextChecked + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, checked) + await expect(locator).toBeChecked({ checked }) +} + +export async function setCheckbox(locator: Locator) { + await setCheckboxValue(locator, true) +} + +export async function unsetCheckbox(locator: Locator) { + await setCheckboxValue(locator, false) +} diff --git a/.jjconflict-base-0/playwright/helpers/cluster.ts b/.jjconflict-base-0/playwright/helpers/cluster.ts new file mode 100644 index 0000000000..2239dd8904 --- /dev/null +++ b/.jjconflict-base-0/playwright/helpers/cluster.ts @@ -0,0 +1,124 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openClustersAdministration } from './navigation' + +type ClusterZone = string +type ClusterConfidentiality = 'public' | 'dedicated' +type ClusterAssociateStage = 'first' | 'all' | 'none' + +async function selectClusterZone(page: Page, zone: ClusterZone) { + await page.getByLabel('Zone associée').selectOption({ label: zone }) +} + +async function selectClusterConfidentiality(page: Page, confidentiality: ClusterConfidentiality) { + await page + .getByLabel('Confidentialité du cluster') + .selectOption({ label: confidentiality }) + + const projectsSelect = page.locator('#projects-select') + switch (confidentiality) { + case 'public': + await expect(projectsSelect).not.toBeVisible() + break + case 'dedicated': + await expect(projectsSelect).toBeVisible() + break + } +} + +async function addStageToClusterAssociationByName(page: Page, stageName: string) { + await page + .getByTestId('choice-selector-search-stages-select') + .fill(stageName) + await page.locator('#stages-select .fr-tag').first().click() +} + +async function addStagesToClusterAssociationByNames(page: Page, stageNames: string[]) { + for (const stageName of stageNames) + await addStageToClusterAssociationByName(page, stageName) +} + +async function addFirstStageToClusterAssociation(page: Page) { + await page.locator('#stages-select .fr-tag').first().click() +} + +async function addAllStagesToClusterAssociation(page: Page) { + const numberOfStages = (await page.locator('#stages-select .fr-tag').all()).length + for (let i = 0; i < numberOfStages; i++) + await page.locator('#stages-select .fr-tag').first().click() +} + +async function fillClusterInformations(page: Page, informations: string) { + await page.getByTestId('infosInput').fill(informations) +} + +async function searchCluster(page: Page, clusterName: string) { + await page.getByTestId('projectsSearchInput').fill(clusterName) +} + +async function openCluster(page: Page, clusterName: string) { + await page.getByTestId(`clusterLink-${clusterName}`).click() +} + +async function confirmClusterDeletion(page: Page) { + await expect(page.getByTestId('deleteClusterZone')).toBeVisible() + await page.getByTestId('showDeleteClusterBtn').click() + await expect(page.getByTestId('deleteClusterBtn')).toBeVisible() + await expect(page.getByTestId('deleteClusterBtn')).toBeDisabled() + await page.getByTestId('deleteClusterInput').fill(deleteValidationInput) + await expect(page.getByTestId('deleteClusterBtn')).toBeEnabled() + await page.getByTestId('deleteClusterBtn').click() +} + +export async function createCluster({ + page, + zone, + confidentiality, + associateStage, + associateStageNames, + informations, +}: { + page: Page + zone?: ClusterZone + confidentiality: ClusterConfidentiality + associateStage?: ClusterAssociateStage + associateStageNames?: string[] + informations?: string +}): Promise { + const clusterName = faker.string.alpha(10).toLowerCase() + await openClustersAdministration({ page }) + await page.getByTestId('addClusterLink').click() + await page.getByTestId('labelInput').fill(clusterName) + if (zone) + await selectClusterZone(page, zone) + await selectClusterConfidentiality(page, confidentiality) + if (associateStageNames?.length) + await addStagesToClusterAssociationByNames(page, associateStageNames) + + switch (associateStage ?? 'none') { + case 'first': + await addFirstStageToClusterAssociation(page) + break + case 'all': + await addAllStagesToClusterAssociation(page) + break + case 'none': + break + } + + if (informations) + await fillClusterInformations(page, informations) + await page.getByTestId('addClusterBtn').click() + await searchCluster(page, clusterName) + return clusterName +} + +export async function deleteCluster({ page, clusterName }: { page: Page, clusterName: string }) { + await openClustersAdministration({ page }) + await searchCluster(page, clusterName) + await expect(page.getByTestId(`clusterLink-${clusterName}`)).toBeVisible() + await openCluster(page, clusterName) + await confirmClusterDeletion(page) +} diff --git a/.jjconflict-base-0/playwright/helpers/constants.ts b/.jjconflict-base-0/playwright/helpers/constants.ts new file mode 100644 index 0000000000..df68f8179a --- /dev/null +++ b/.jjconflict-base-0/playwright/helpers/constants.ts @@ -0,0 +1,9 @@ +export const invalidGitUrlErrorMessage = 'L\'adresse doit commencer par https et se terminer par .git' +export const invalidInternalRepoErrorMessage = 'Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique' +export const fakeToken = 'fakeToken' +export const deleteValidationInput = 'DELETE' + +export const snackbarTimeoutMs = 20_000 +export const repoSyncTimeoutMs = 60_000 +export const projectReadinessTimeoutMs = 60_000 +export const pollingFastIntervalMs = 2_000 diff --git a/.jjconflict-base-0/playwright/helpers/deployment.ts b/.jjconflict-base-0/playwright/helpers/deployment.ts new file mode 100644 index 0000000000..7a2850f3fc --- /dev/null +++ b/.jjconflict-base-0/playwright/helpers/deployment.ts @@ -0,0 +1,44 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' + +export async function createDeployment({ + page, + deploymentName, + envName, + repoName, + customStageName, +}: { + page: Page + deploymentName?: string + envName: string + repoName: string + customStageName: string +}): Promise { + deploymentName = deploymentName ?? faker.string.alpha(10).toLocaleLowerCase() + await openDeploymentCreateForm(page) + await fillDeploymentName(page, deploymentName) + await selectDeploymentEnvironment(page, envName, customStageName) + await selectDeploymentRepository(page, repoName) + await submitDeploymentForm(page) + return deploymentName +} + +async function openDeploymentCreateForm(page: Page) { + await page.getByRole('button', { name: 'Ajouter un nouveau déploiement' }).click() +} + +async function fillDeploymentName(page: Page, deploymentName: string) { + await page.getByRole('textbox', { name: 'Nom du déploiement * Ne doit' }).fill(deploymentName) +} + +async function selectDeploymentEnvironment(page: Page, envName: string, customStageName: string) { + await page.getByText(`${envName} ${customStageName}`).click() +} + +async function selectDeploymentRepository(page: Page, repoName: string) { + await page.getByLabel('Dépôt *').selectOption({ label: repoName }) +} + +async function submitDeploymentForm(page: Page) { + await page.getByRole('button', { name: 'Enregistrer' }).click() +} diff --git a/.jjconflict-base-0/playwright/helpers/environment.ts b/.jjconflict-base-0/playwright/helpers/environment.ts new file mode 100644 index 0000000000..2eccb82509 --- /dev/null +++ b/.jjconflict-base-0/playwright/helpers/environment.ts @@ -0,0 +1,89 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openMyProjects } from './navigation' + +type EnvironmentZone = 'publique' | 'Zone privée' + +async function openEnvironmentCreateForm(page: Page) { + await page.getByTestId('addEnvironmentLink').click() +} + +async function fillEnvironmentName(page: Page, envName: string) { + await page.getByTestId('environmentNameInput').fill(envName) +} + +async function selectEnvironmentZone(page: Page, zone: EnvironmentZone) { + await page.getByLabel('Choix de la zone cible').selectOption({ label: zone }) +} + +async function selectEnvironmentStage(page: Page, stageName: string) { + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: stageName }) +} + +async function selectEnvironmentCluster(page: Page, clusterName: string) { + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page + .getByLabel('Choix du cluster cible') + .selectOption({ label: clusterName }) +} + +async function fillEnvironmentResources( + page: Page, + { memoryInput, cpuInput, gpuInput }: { memoryInput: string, cpuInput: string, gpuInput: string }, +) { + await page.getByTestId('memoryInput').fill(memoryInput) + await page.getByTestId('cpuInput').fill(cpuInput) + await page.getByTestId('gpuInput').fill(gpuInput) +} + +async function submitEnvironmentCreation(page: Page) { + await page.getByTestId('addEnvironmentBtn').click() +} + +export async function createEnvironment({ + page, + envName, + zone, + customStageName, + customClusterName, + memoryInput = '1', + cpuInput = '1', + gpuInput = '1', + envsToDelete, +}: { + page: Page + envName?: string + zone: EnvironmentZone + customStageName: string + customClusterName: string + memoryInput?: string + cpuInput?: string + gpuInput?: string + envsToDelete?: string[] +}): Promise { + envName = envName ?? faker.string.alpha(10).toLowerCase() + envsToDelete?.push(envName) + await openEnvironmentCreateForm(page) + await fillEnvironmentName(page, envName) + await selectEnvironmentZone(page, zone) + await selectEnvironmentStage(page, customStageName) + await selectEnvironmentCluster(page, customClusterName) + await fillEnvironmentResources(page, { memoryInput, cpuInput, gpuInput }) + await submitEnvironmentCreation(page) + return envName +} + +export async function deleteEnvironment({ page, projectName, envName }: { page: Page, projectName: string, envName: string }) { + await openMyProjects({ page }) + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId(`environmentTr-${envName}`).click() + await page.getByTestId('showDeleteEnvironmentBtn').click() + await expect(page.getByTestId('deleteEnvironmentZone')).toBeVisible() + await page.getByTestId('deleteEnvironmentInput').fill(deleteValidationInput) + await page.getByTestId('deleteEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).not.toBeVisible() +} diff --git a/.jjconflict-base-0/playwright/helpers/global.d.ts b/.jjconflict-base-0/playwright/helpers/global.d.ts new file mode 100644 index 0000000000..20981cf311 --- /dev/null +++ b/.jjconflict-base-0/playwright/helpers/global.d.ts @@ -0,0 +1,5 @@ +declare global { + interface RegExpConstructor { + escape(string: string): string + } +} diff --git a/.jjconflict-base-0/playwright/helpers/navigation.ts b/.jjconflict-base-0/playwright/helpers/navigation.ts new file mode 100644 index 0000000000..3d4dc22a7d --- /dev/null +++ b/.jjconflict-base-0/playwright/helpers/navigation.ts @@ -0,0 +1,26 @@ +import type { Page } from '@playwright/test' + +export async function openMyProjects({ page }: { page: Page }) { + await page.getByTestId('menuMyProjects').click() +} + +async function ensureAdminMenuVisible(page: Page, menuTestId: string) { + const menuItem = page.getByTestId(menuTestId) + if (!(await menuItem.isVisible())) + await page.getByTestId('menuAdministrationBtn').click() +} + +export async function openClustersAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationClusters') + await page.getByTestId('menuAdministrationClusters').click() +} + +export async function openStagesAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationStages') + await page.getByTestId('menuAdministrationStages').click() +} + +export async function openZonesAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationZones') + await page.getByTestId('menuAdministrationZones').click() +} diff --git a/.jjconflict-base-0/playwright/helpers/project.ts b/.jjconflict-base-0/playwright/helpers/project.ts new file mode 100644 index 0000000000..0d5a8d65d1 --- /dev/null +++ b/.jjconflict-base-0/playwright/helpers/project.ts @@ -0,0 +1,124 @@ +import type { Page } from '@playwright/test' +import type { Credentials } from '../config/console' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput, projectReadinessTimeoutMs } from './constants' +import { openMyProjects } from './navigation' + +const projectUrlRegexp = /\/projects\/[^/]+$/ +const projectSlugPrefixRegexp = /^slug\s*:\s*/i + +interface Resources { + cpu: number + gpu: number + memory: number +} + +async function openProjectCreateForm(page: Page) { + await openMyProjects({ page }) + await page.getByTestId('createProjectLink').click() +} + +async function fillProjectName(page: Page, name: string) { + await page.getByTestId('nameInput').click() + await page.getByTestId('nameInput').fill(name) +} + +async function fillProjectResources({ + page, + hprodResources, + prodResources, +}: { + page: Page + hprodResources?: Resources + prodResources?: Resources +}) { + const fillHprod = async (resources: Resources) => { + await page.getByTestId('cpuHprodInput').fill(resources.cpu.toString()) + await page.getByTestId('gpuHprodInput').fill(resources.gpu.toString()) + await page + .getByTestId('memoryHprodInput') + .fill(resources.memory.toString()) + } + const fillProd = async (resources: Resources) => { + await page.getByTestId('cpuProdInput').fill(resources.cpu.toString()) + await page.getByTestId('gpuProdInput').fill(resources.gpu.toString()) + await page + .getByTestId('memoryProdInput') + .fill(resources.memory.toString()) + } + + if (hprodResources) + await fillHprod(hprodResources) + if (prodResources) + await fillProd(prodResources) +} + +async function enableProjectLimitless(page: Page) { + await page.getByTestId('limitlessProjectSwitch').locator('label').click() +} + +async function addMembersToProject(page: Page, members: Credentials[]) { + await page.getByTestId('test-tab-team').click() + for (const member of members) { + await page + .getByRole('combobox', { name: 'Ajouter un utilisateur' }) + .fill(member.email) + await page.getByTestId('addUserBtn').click() + await expect( + page.getByTestId('teamTable').getByText(member.email), + ).toBeVisible() + } + await page.getByTestId('test-tab-resources').click() +} + +async function getProjectSlugAndId(page: Page) { + const rawSlugText = (await page.getByTestId('project-slug').textContent()) ?? 'no-slug' + const slug = rawSlugText?.replace(projectSlugPrefixRegexp, '').trim() + const id = (await page.getByTestId('project-id').getAttribute('title')) ?? 'no-id' + return { slug, id } +} + +export function projectSlugTextRegexp(projectSlug: string): RegExp { + const escapedSlug = RegExp.escape(projectSlug) + return new RegExp(`^slug:\\s*${escapedSlug}$`) +} + +export async function createProject({ + page, + projectName: name, + members, + hprodResources, + prodResources, +}: { + page: Page + projectName?: string + members?: Credentials[] + hprodResources?: Resources + prodResources?: Resources +}): Promise<{ id: string, slug: string, name: string }> { + name = name ?? faker.string.alpha(10).toLowerCase() + await openProjectCreateForm(page) + await fillProjectName(page, name) + await fillProjectResources({ page, hprodResources, prodResources }) + if (!hprodResources && !prodResources) await enableProjectLimitless(page) + + await page.getByTestId('createProjectBtn').click() + await expect(page).toHaveURL(projectUrlRegexp, { timeout: projectReadinessTimeoutMs }) + await expect(page.getByTestId('project-slug')).toBeVisible({ timeout: projectReadinessTimeoutMs }) + if (members?.length) + await addMembersToProject(page, members) + + const { slug, id } = await getProjectSlugAndId(page) + return { name, slug, id } +} + +export async function deleteProject({ page, projectName }: { page: Page, projectName: string }) { + await openMyProjects({ page }) + await page.getByRole('link', { name: projectName }).click() + await page.getByRole('button', { name: 'Supprimer le projet' }).click() + await page.getByTestId('archiveProjectInput').fill(deleteValidationInput) + await page.getByTestId('confirmDeletionBtn').click() + await openMyProjects({ page }) + await expect(page.getByRole('link', { name: projectName })).not.toBeVisible() +} diff --git a/.jjconflict-base-0/playwright/helpers/repository.ts b/.jjconflict-base-0/playwright/helpers/repository.ts new file mode 100644 index 0000000000..82678de00d --- /dev/null +++ b/.jjconflict-base-0/playwright/helpers/repository.ts @@ -0,0 +1,82 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { repoSyncTimeoutMs } from './constants' +import { waitForSnackbar } from './snackbar' + +const repoSyncApiRegex = /\/api\/.*\/repositories\/.*\/sync$/ + +export function waitForRepoSync({ + page, + timeoutMs = repoSyncTimeoutMs, +}: { + page: Page + timeoutMs?: number +}) { + return page.waitForResponse((response) => { + const request = response.request() + const success = response.status() >= 200 && response.status() < 300 + return request.method() === 'POST' && success && repoSyncApiRegex.test(response.url()) + }, { timeout: timeoutMs }) +} + +async function openAddRepositoryForm(page: Page) { + await page.getByTestId('addRepoLink').click() +} + +async function fillRepositoryUrl(page: Page, externalRepoUrlInput?: string) { + const url = externalRepoUrlInput ?? `${faker.internet.url({ appendSlash: true })}myrepository.git` + await page.getByTestId('externalRepoUrlInput').fill(url) +} + +async function closeResourceModal(page: Page) { + await page + .getByTestId('resource-modal') + .getByRole('button', { name: 'Fermer' }) + .click() +} + +export async function createRepository({ + page, + repositoryName, + externalRepoUrlInput, + infraRepo, +}: { + page: Page + repositoryName?: string + externalRepoUrlInput?: string + infraRepo?: boolean +}) { + repositoryName = repositoryName ?? faker.string.alpha(10).toLowerCase() + await openAddRepositoryForm(page) + await page.getByTestId('internalRepoNameInput').fill(repositoryName) + await fillRepositoryUrl(page, externalRepoUrlInput) + if (infraRepo) + await page.getByText('Dépôt contenant du code d\'').click() + await page.getByTestId('addRepoBtn').click() + await expect(page.getByTestId(`repoTr-${repositoryName}`)).toContainText( + repositoryName, + ) + return repositoryName +} + +export async function synchronizeBranchOnRepository({ + page, + repositoryName, + branchName, +}: { + page: Page + repositoryName: string + branchName?: string +}) { + branchName = branchName ?? faker.string.alpha(10).toLowerCase() + await page.getByTestId(`repoTr-${repositoryName}`).click() + await expect(page.getByTestId('resource-modal')).toBeVisible() + await page.getByTestId('branchNameInput').fill(branchName) + const syncRequest = waitForRepoSync({ page, timeoutMs: repoSyncTimeoutMs }) + await page.getByTestId('syncRepoBtn').click() + await syncRequest + await closeResourceModal(page) + await waitForSnackbar({ page, text: 'Travail de synchronisation lancé' }) + return branchName +} diff --git a/.jjconflict-base-0/playwright/helpers/snackbar.ts b/.jjconflict-base-0/playwright/helpers/snackbar.ts new file mode 100644 index 0000000000..7c7d57d685 --- /dev/null +++ b/.jjconflict-base-0/playwright/helpers/snackbar.ts @@ -0,0 +1,17 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' +import { snackbarTimeoutMs } from './constants' + +export async function waitForSnackbar({ + page, + text, + timeoutMs = snackbarTimeoutMs, +}: { + page: Page + text: string | RegExp + timeoutMs?: number +}) { + await expect(page.getByTestId('snackbar')).toContainText(text, { + timeout: timeoutMs, + }) +} diff --git a/.jjconflict-base-0/playwright/helpers/stage.ts b/.jjconflict-base-0/playwright/helpers/stage.ts new file mode 100644 index 0000000000..f99f261b34 --- /dev/null +++ b/.jjconflict-base-0/playwright/helpers/stage.ts @@ -0,0 +1,76 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openStagesAdministration } from './navigation' + +async function assertStageCreateForm(page: Page) { + await expect(page.locator('h1')).toContainText( + 'Informations du type d\'environnement', + ) + await expect(page.getByTestId('addStageBtn')).toBeVisible() + await expect(page.getByTestId('addStageBtn')).toBeDisabled() + await expect(page.getByTestId('updateStageBtn')).not.toBeVisible() +} + +async function addFirstClusterAssociation(page: Page) { + await page.locator('.fr-tag').first().click() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(1) +} + +async function addAllClusterAssociations(page: Page) { + await expect(page.locator('.fr-tag')).not.toHaveCount(0) + const numberOfAvalaibleClusters = (await page.locator('.fr-tag').all()).length + for (let i = 0; i < numberOfAvalaibleClusters; i++) + await page.locator('.fr-tag').first().click() + + await expect(page.locator('.fr-tag--dismiss')).toHaveCount( + numberOfAvalaibleClusters, + ) +} + +export async function createStage({ + page, + associateToCluster, +}: { + page: Page + associateToCluster: 'first' | 'all' | 'none' +}): Promise { + const stageName = faker.string.alpha(10).toLowerCase() + await openStagesAdministration({ page }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await page.getByTestId('addStageLink').click() + await assertStageCreateForm(page) + await page.getByTestId('nameInput').fill(stageName) + await expect(page.getByTestId('addStageBtn')).toBeEnabled() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(0) + + switch (associateToCluster) { + case 'first': + await addFirstClusterAssociation(page) + break + case 'all': + await addAllClusterAssociations(page) + break + case 'none': + break + } + + await page.getByTestId('addStageBtn').click() + return stageName +} + +export async function deleteStage({ page, stageName }: { page: Page, stageName: string }) { + await openStagesAdministration({ page }) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.getByTestId('deleteStageZone')).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsZone'), + ).not.toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable'), + ).not.toBeVisible() + await page.getByTestId('showDeleteStageBtn').click() + await page.getByTestId('deleteStageInput').fill(deleteValidationInput) + await page.getByTestId('deleteStageBtn').click() +} diff --git a/.jjconflict-base-0/playwright/helpers/zone.ts b/.jjconflict-base-0/playwright/helpers/zone.ts new file mode 100644 index 0000000000..c18973e9ef --- /dev/null +++ b/.jjconflict-base-0/playwright/helpers/zone.ts @@ -0,0 +1,34 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { deleteValidationInput } from './constants' +import { openZonesAdministration } from './navigation' + +async function openZoneCreateForm(page: Page) { + await page.getByTestId('createZoneLink').click() +} + +async function fillZoneCreateForm(page: Page, zoneName: string) { + await page.getByTestId('slugInput').fill(zoneName) + await page.getByTestId('labelInput').fill(zoneName) + await page.getByTestId('argocdUrlInput').fill(faker.internet.url()) + await page + .getByTestId('descriptionInput') + .fill(faker.string.alpha(100).toLowerCase()) +} + +export async function createZone({ page }: { page: Page }): Promise { + const zoneName = faker.string.alpha(10).toLowerCase() + await openZonesAdministration({ page }) + await openZoneCreateForm(page) + await fillZoneCreateForm(page, zoneName) + await page.getByTestId('addZoneBtn').click() + return zoneName +} + +export async function deleteZone({ page, zoneName }: { page: Page, zoneName: string }) { + await openZonesAdministration({ page }) + await page.getByRole('link', { name: zoneName }).click() + await page.getByTestId('showDeleteZoneBtn').click() + await page.getByTestId('deleteZoneInput').fill(deleteValidationInput) + await page.getByTestId('deleteZoneBtn').click() +} diff --git a/.jjconflict-base-0/playwright/integration-tests/admin-setup.spec.ts b/.jjconflict-base-0/playwright/integration-tests/admin-setup.spec.ts new file mode 100644 index 0000000000..da53fa6394 --- /dev/null +++ b/.jjconflict-base-0/playwright/integration-tests/admin-setup.spec.ts @@ -0,0 +1,54 @@ +import { test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' + +import { createCluster, deleteCluster } from '../helpers/cluster' +import { createStage, deleteStage } from '../helpers/stage' +import { createZone, deleteZone } from '../helpers/zone' + +const zonesToDelete: string[] = [] +const stagesToDelete: string[] = [] +const clustersToDelete: string[] = [] + +test.describe('Integration tests for admin', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Admin setup', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + const zoneName = await createZone({ page }) + zonesToDelete.push(zoneName) + // we need to attains 7 stages to be able to use associateStageNames argument in createCluster + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName1 = await createStage({ page, associateToCluster: 'none' }) + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName2 = await createStage({ page, associateToCluster: 'none' }) + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName3 = await createStage({ page, associateToCluster: 'none' }) + stagesToDelete.push(customStageName1) + stagesToDelete.push(customStageName2) + stagesToDelete.push(customStageName3) + const clusterName = await createCluster({ + page, + zone: zoneName, + confidentiality: 'public', + associateStageNames: [customStageName1, customStageName2, customStageName3], + }) + clustersToDelete.push(clusterName) + }) + + test('Cleanup admin test data', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + for (const stageName of stagesToDelete) { + await deleteStage({ page, stageName }) + } + for (const clusterName of clustersToDelete) { + await deleteCluster({ page, clusterName }) + } + for (const zoneName of zonesToDelete) { + await deleteZone({ page, zoneName }) + } + }) +}) diff --git a/.jjconflict-base-0/playwright/integration-tests/gitlab.spec.ts b/.jjconflict-base-0/playwright/integration-tests/gitlab.spec.ts new file mode 100644 index 0000000000..19d5424ddd --- /dev/null +++ b/.jjconflict-base-0/playwright/integration-tests/gitlab.spec.ts @@ -0,0 +1,125 @@ +import { expect, test } from '@playwright/test' +import { clientURL, currentUser, secondTestUser, signInCloudPiNative } from '../config/console' +import { projectReadinessTimeoutMs } from '../helpers/constants' +import { createProject, deleteProject } from '../helpers/project' + +const membersLinkRegexp = /^(members|membres)$/i +const manageLinkRegexp = /^(manage|gérer)$/i +const developerRoleRegexp = /developer/i + +test.describe('GitLab', () => { + test( + 'Should create project and have infra-apps + mirror repos in GitLab', + { tag: ['@integ', '@replayable'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: currentUser }) + + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + try { + await page.getByTestId('test-tab-services').click() + + const gitlabPopup = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const gitlabPage = await gitlabPopup + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + await expect( + gitlabPage.getByTestId('group-name').filter({ hasText: 'infra-apps' }), + ).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await expect( + gitlabPage.getByTestId('group-name').filter({ hasText: 'mirror' }), + ).toBeVisible({ timeout: projectReadinessTimeoutMs }) + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const manageLink = gitlabPage + .getByRole('button', { name: manageLinkRegexp }) + .or(gitlabPage.getByRole('link', { name: manageLinkRegexp })) + .first() + await manageLink.click({ timeout: projectReadinessTimeoutMs }) + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const membersLink = gitlabPage.getByRole('link', { name: membersLinkRegexp }).first() + await membersLink.click({ timeout: projectReadinessTimeoutMs }) + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const currentUserGitlabUsername = currentUser.email.replace('@', '.') + const userRow = gitlabPage.getByRole('row').filter({ hasText: `@${currentUserGitlabUsername}` }).first() + await userRow.scrollIntoViewIfNeeded() + await expect(userRow).toBeVisible({ + timeout: projectReadinessTimeoutMs, + }) + + const botRow = gitlabPage.getByRole('row').filter({ hasText: `${projectSlug}-bot` }).first() + await botRow.scrollIntoViewIfNeeded() + await expect(botRow).toBeVisible({ + timeout: projectReadinessTimeoutMs, + }) + } finally { + await deleteProject({ page, projectName }) + } + }, + ) + + test( + 'Should reflect console role assignment in GitLab builtin role', + { tag: ['@e2e', '@integ', '@replayable'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: currentUser }) + + const { name: projectName } = await createProject({ page }) + + try { + await page.getByTestId('test-tab-team').click() + await page.locator('input[data-testid="addUserSuggestionInput"]').fill(secondTestUser.email) + await page.getByTestId('addUserBtn').click() + const teamMemberRow = page.getByTestId('teamTable').getByRole('row').filter({ hasText: secondTestUser.email }).first() + await expect(teamMemberRow).toBeVisible({ timeout: projectReadinessTimeoutMs }) + const teamMemberUserId = ((await teamMemberRow.locator('code').first().textContent()) ?? '').trim() + expect(teamMemberUserId).not.toEqual('') + + await page.reload() + await expect(page.getByTestId('project-slug')).toBeVisible({ timeout: projectReadinessTimeoutMs }) + + await page.getByTestId('test-tab-roles').click() + await page + .locator('[data-testid$="-tab"]') + .filter({ hasText: 'DevOps' }) + .click() + await page.getByRole('tab', { name: 'Membres' }).click() + const roleMemberCheckbox = page.getByTestId(`input-checkbox-${teamMemberUserId}-cbx`) + await expect(roleMemberCheckbox).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await roleMemberCheckbox.check({ force: true }) + + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour', { timeout: projectReadinessTimeoutMs }) + + await page.getByTestId('test-tab-services').click() + + const gitlabPopup = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const gitlabPage = await gitlabPopup + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const manageLink = gitlabPage + .getByRole('button', { name: manageLinkRegexp }) + .or(gitlabPage.getByRole('link', { name: manageLinkRegexp })) + .first() + await manageLink.click({ timeout: projectReadinessTimeoutMs }) + + const membersLink = gitlabPage.getByRole('link', { name: membersLinkRegexp }).first() + await membersLink.click({ timeout: projectReadinessTimeoutMs }) + + const secondTestUserGitlabUsername = secondTestUser.email.replace('@', '.') + const memberRow = gitlabPage.getByRole('row').filter({ hasText: `@${secondTestUserGitlabUsername}` }).first() + await expect(memberRow).toContainText(developerRoleRegexp, { timeout: projectReadinessTimeoutMs }) + } finally { + await deleteProject({ page, projectName }) + } + }, + ) +}) diff --git a/.jjconflict-base-0/playwright/integration-tests/user-flow.spec.ts b/.jjconflict-base-0/playwright/integration-tests/user-flow.spec.ts new file mode 100644 index 0000000000..a27b10139b --- /dev/null +++ b/.jjconflict-base-0/playwright/integration-tests/user-flow.spec.ts @@ -0,0 +1,390 @@ +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, secondTestUser, signInCloudPiNative, testUser } from '../config/console' +import { pollingFastIntervalMs, projectReadinessTimeoutMs, repoSyncTimeoutMs } from '../helpers/constants' +import { createProject, deleteProject } from '../helpers/project' +import { createRepository } from '../helpers/repository' + +const projectsToDelete: string[] = [] +const projectName = 'socleprojecttest' +const repositoryName = 'socle-project-test' +const destinationCluster = process.env.CONSOLE_DESTINATION_CLUSTER || 'cpin-app-hp' +const helmValuesFiles = process.env.CONSOLE_VALUES_FILE || 'values-cpin-hp.yaml' +projectsToDelete.push(projectName) + +test.describe('Integration tests user flow: project creation', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Preliminary checks', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + // Check all services are healthy + await page.getByTestId('menuServicesHealth').click() + await expect(page.getByTestId('ArgoCD-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Gitlab-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Harbor-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Keycloak-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Nexus-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('SonarQube-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Vault-info').getByText('OK')).toBeVisible() + await expect(page.getByText('Tous les services')).toBeVisible() + // Check maintenance mode is disabled + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationSystemSettings').click() + await expect(page.getByText('Le mode Maintenance est')).not.toBeVisible() + await expect(page.getByText('Désactiver le mode maintenance')).not.toBeVisible() + await expect(page.getByText('Activer le mode maintenance')).toBeVisible() + }) + + test('Project creation and configuration', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page, projectName }) + // Enable Nexus Maven plugin + await page.getByTestId('test-tab-services').click() + await page.getByRole('button', { name: 'Nexus' }).click() + await page + .getByRole('group', { name: 'Activer le dépôt Maven' }) + .locator('input[value="enabled"] + label') + .click() + await page.getByTestId('saveBtn').click() + await expect(page.getByText('Paramètres sauvegardés')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Check if project reprovisioning is successful + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + await expect(page.getByText('Le projet a été reprovisionn')).toBeVisible() + // Add repository to project + await page.getByTestId('test-tab-resources').click() + await createRepository({ + page, + repositoryName, + externalRepoUrlInput: 'https://github.com/cloud-pi-native/socle-project-test.git', + infraRepo: true, + }) + await page.getByRole('cell', { name: repositoryName }).click() + await page.getByTestId('deployRevisionInput').fill('main') + await page.getByTestId('deployPathInput').fill('helm/') + await page.getByTestId('helmValuesFilesTextarea').fill(helmValuesFiles) + await page.getByTestId('updateRepoBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: first checks', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Check Vault kv', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Vault' }).click() + const page1 = await page1Promise + const page2Promise = page1.waitForEvent('popup') + await page1.getByRole('button', { name: 'Sign in with OIDC Provider' }).click() + await page2Promise + await expect(page1.getByRole('link', { name: 'Vault home' })).toBeVisible() + // Check that standard user has access to his project kv + await expect(page1.getByText('No secrets yet')).toBeVisible() + await expect(page1.getByRole('link', { name: 'Create secret' })).toBeVisible() + await page1.getByLabel('breadcrumbs').getByRole('link', { name: 'Secrets' }).click() + // Check that forge-dso kv is not accessible for standard user + await expect(page1.getByRole('link', { name: 'forge-dso' })).not.toBeVisible() + }) + + test('Project permissions', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Add user to project + await page.getByTestId('test-tab-team').click() + await page.getByTestId('addUserSuggestionInput').locator('input').fill(secondTestUser.email) + await page.getByTestId('addUserBtn').click() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Create read-only role + await page.getByTestId('test-tab-roles').click() + await page.getByTestId('addRoleBtn').click() + await page.getByTestId('roleNameInput').fill('readOnly') + await page.getByText('Afficher les secrets Permet d').click() + await page.getByText('Voir les environnements').click() + await page.getByText('Voir les dépôts Permet de').click() + await page.getByTestId('saveBtn').click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Add user to read-only role + await page.getByTestId('test-members').click() + await page.getByLabel('Rôles', { exact: true }).getByText(secondTestUser.email).click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Replay project hooks + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + await expect(page.getByText('Le projet a été reprovisionn')).toBeVisible() + }) + + test('Pipelines run', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if mirror pipeline is successful + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const page1 = await page1Promise + await page1.getByTestId('group-name').filter({ hasText: 'mirror' }).click() + await expect(page1.getByTestId('status_success_borderless-icon')).toBeVisible() + // Check if tests are successful + await page1.getByRole('link', { name: projectName }).click() + await page1.getByTestId('group-name').filter({ hasText: repositoryName }).click() + await page1.getByTestId('ci-icon').click() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-vault' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-nexus' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-harbor' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-sonar' }), + ).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: after pipelines checks', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Prepare ArgoCD deployment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Create environment for project to trigger ArgoCD deployment + await page.getByTestId('test-tab-resources').click() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').click() + await page.getByTestId('environmentNameInput').fill('integ') + await page.getByLabel('Zone *Choix de la zone cible').selectOption({ label: 'DSO' }) + await page.getByLabel('Type d\'environnement *Type d\'').selectOption({ label: 'dev' }) + await page.getByLabel('Cluster *Choix du cluster').selectOption({ label: destinationCluster }) + await page.getByTestId('memoryInput').fill('2') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + }) + + test('View Sonar scan report', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if sonar scan is available + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'SonarQube' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'OpenID Connect Log in with' }).click() + await page1.getByPlaceholder('Search for projects...').fill(projectName) + await page1.getByRole('link', { name: `${projectName}-${repositoryName}` }).click() + await expect( + page1.getByTestId('overview__quality-gate-panel').getByText('Passed', { exact: true }), + ).toBeVisible() + }) + + test('Check Harbor repository', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Harbor' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'LOGIN WITH keycloak' }).click() + await expect(page1.getByRole('button', { name: 'Administration' })).not.toBeVisible() + await expect(page1.getByText('Guest')).toBeVisible() + await expect(page1.getByRole('heading', { name: 'Private' })).toBeVisible() + await page1.getByRole('link', { name: `${projectName}/java-demo` }).click() + await expect(page1.getByRole('button', { name: 'main' })).toBeVisible() + await expect(page1.getByText('Policy')).not.toBeVisible() + // Check trivy scan result, hopefully will stay at C + await expect(page1.getByRole('button', { name: 'C', exact: true })).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: deployment and metrics', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('ArgoCD deployment', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if ArgoCD deployment is successful + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await page1.getByText('app.kubernetes.io/managed-by=dso-console, dso/environment=integ, dso/').click() + await expect(page1.getByText('Synced').nth(1)).toBeVisible() + await expect(page1.getByText('Sync OK')).toBeVisible() + await expect(page1.getByText('Healthy').nth(1)).toBeVisible() + await page1.getByRole('button', { name: ' Details' }).click() + const page2Promise = page1.waitForEvent('popup') + await page1.getByRole('link', { name: `http://${repositoryName}.` }).click() + const page2 = await page2Promise + await expect + .poll( + async () => { + await page2.reload({ waitUntil: 'domcontentloaded' }) + return page2.locator('body').textContent() + }, + { + timeout: repoSyncTimeoutMs, + intervals: [pollingFastIntervalMs], + }, + ) + .toContain('Application is running') + }) + + test('Check Grafana', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Grafana' }).click() + const page1 = await page1Promise + const signInLink = page1.getByRole('link', { name: 'Sign in with grafana-projects' }) + await expect(signInLink).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await signInLink.click({ timeout: projectReadinessTimeoutMs }) + await expect(page1.getByRole('link', { name: 'Grafana', exact: true })).toBeVisible() + await page1.getByTestId('data-testid Toggle menu').click() + await page1.getByRole('button', { name: 'Expand section Dashboards' }).click() + await page1.getByRole('link', { name: 'Dashboards', exact: true }).click() + const dsoDashboard = page1.getByRole('link', { name: 'dso-grafana' }) + await expect(dsoDashboard).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await dsoDashboard.click({ timeout: projectReadinessTimeoutMs }) + // Check if we can see some metrics + await page1.getByRole('link', { name: 'Kubernetes / Views /' }).click() + await expect(page1.getByText('0.100')).toBeVisible() // Cpu request + await expect(page1.getByText('0.500')).toBeVisible() // Cpu limit + await expect(page1.getByText('256')).toBeVisible() // Memory request + await expect(page1.getByText('512')).toBeVisible() // Memory limit + // Check if we can see some logs + await page1.getByTestId('data-testid dso-grafana breadcrumb').click() + await page1.getByRole('link', { name: 'Loki Kubernetes Logs' }).click() + await expect(page1.getByTestId('data-testid Panel status error').first()).not.toBeVisible() + await expect(page1.locator('.rc-drawer-mask')).not.toBeVisible() + await page1.getByTestId('data-testid TimePicker Open Button').click() + await page1.getByText('Last 1 hour').click() + await page1.locator('.css-13x53bc-Icon-topVerticalAlign').first().click() + await expect(page1.getByRole('cell', { name: 'app_kubernetes_io_name' }).nth(1)).toBeVisible() + await expect(page1.getByText('demo-java-helm').nth(1)).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: Cleanup', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Remove permissions and user', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Remove role membership + await page.getByTestId('test-tab-roles').click() + await page.getByRole('button', { name: 'readOnly' }).click() + await page.getByTestId('test-members').click() + await page.getByLabel('Rôles', { exact: true }).getByText(secondTestUser.email).click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Remove role + await page.getByTestId('test-general').click() + await page.getByTestId('deleteBtn').click() + await page.getByText('Rôle supprimé').click() + // Remove project membership + await page.getByTestId('test-tab-team').click() + await page.getByTitle(`Retirer ${secondTestUser.email} du`).click() + }) + + test('Remove stage', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByRole('cell', { name: 'integ' }).click() + await page.getByTestId('showDeleteEnvironmentBtn').click() + await page.getByTestId('deleteEnvironmentInput').fill('DELETE') + await page.getByTestId('deleteEnvironmentBtn').click() + await page.getByRole('heading', { name: 'Opération en cours...' }).click() + await expect( + page.getByRole('cell', { name: 'Aucun environnement existant' }), + ).toBeVisible() + // ArgoCD deployment will be deleted when stage is deleted + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await expect( + page1.getByRole('combobox', { name: 'Search applications...' }), + ).toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-integ-socle-` }), + ).not.toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-cpin-app-hp-integ-root` }), + ).not.toBeVisible() + }) + + // This check will forever fail until we fix + // https://github.com/cloud-pi-native/console/issues/1853 + test('Check Argocd root application deletion', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Tous') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await page.getByRole('cell', { name: projectName }).first().click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await expect( + page1.getByRole('combobox', { name: 'Search applications...' }), + ).toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-cpin-app-hp-integ-root` }), + ).not.toBeVisible() + }) + + test('Remove repository from project', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-resources').click() + await page.getByRole('cell', { name: repositoryName }).click() + await page.getByTestId('showDeleteRepoBtn').click() + await page.getByTestId('deleteRepoInput').fill('DELETE') + await page.getByTestId('deleteRepoBtn').click() + await page.getByRole('heading', { name: 'Opération en cours...' }).click() + await expect(page.getByRole('cell', { name: 'Aucun dépôt existant' })).toBeVisible() + for (const projectName of projectsToDelete) { + await deleteProject({ page, projectName }) + } + }) +}) diff --git a/.jjconflict-base-0/playwright/package.json b/.jjconflict-base-0/playwright/package.json new file mode 100644 index 0000000000..d4b0f175ee --- /dev/null +++ b/.jjconflict-base-0/playwright/package.json @@ -0,0 +1,27 @@ +{ + "name": "@cpn-console/playwright", + "version": "2.0.0", + "private": true, + "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26", + "description": "Our Playwright automated test suite", + "keywords": [ + "playwright", + "tests", + "e2e" + ], + "main": "playwright.config.ts", + "scripts": { + "playwright:test": "pnpm exec playwright test", + "playwright:test:ui": "pnpm exec playwright test --ui", + "integ:playwright:test": "INTEGRATION=true pnpm exec playwright test", + "integ:playwright:test:ui": "INTEGRATION=true pnpm exec playwright test --ui", + "format": "eslint ./ --fix" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@playwright/test": "catalog:test", + "@types/node": "catalog:types" + } +} diff --git a/.jjconflict-base-0/playwright/playwright.config.ts b/.jjconflict-base-0/playwright/playwright.config.ts new file mode 100644 index 0000000000..06a9545f6a --- /dev/null +++ b/.jjconflict-base-0/playwright/playwright.config.ts @@ -0,0 +1,58 @@ +import path from 'node:path' +import { defineConfig, devices } from '@playwright/test' + +process.loadEnvFile(path.resolve(__dirname, '..', 'apps/client', '.env.docker')) + +const isIntegration = process.env.INTEGRATION === 'true' + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: isIntegration ? './integration-tests' : './e2e-tests', + + /* Run tests in files in parallel */ + fullyParallel: true, + + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + + retries: 3, + + workers: process.env.CI ? 1 : undefined, // Default is 50% logical cores + + // The maximum number of test failures forthe whole test suite run. + // After reaching this number, testing will stop and exit with an error. + // Setting to zero (default) disables this behavior. + maxFailures: 1, + + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Retain trace on failed tries. See https://playwright.dev/docs/trace-viewer */ + trace: 'retain-on-failure', + }, + + /* All timeouts are in milliseconds */ + // Timeout for each and every `test` block + timeout: Number(process.env.CONSOLE_GLOBAL_TIMEOUT) || 30_000, + + // Timeout for each and every `expect` command + expect: { + timeout: Number(process.env.CONSOLE_EXPECT_TIMEOUT) || 10_000, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + ], +}) diff --git a/.jjconflict-base-0/playwright/tsconfig.json b/.jjconflict-base-0/playwright/tsconfig.json new file mode 100644 index 0000000000..f9908dab99 --- /dev/null +++ b/.jjconflict-base-0/playwright/tsconfig.json @@ -0,0 +1,41 @@ +{ + "extends": ["@cpn-console/ts-config/tsconfig.base.json"], + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + + "jsx": "preserve", + "lib": ["DOM", "ESNext"], + // Base + "baseUrl": "./", + "rootDir": "./", + "module": "Preserve", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "types": ["node"], + "allowImportingTsExtensions": false, + "allowJs": true, + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useUnknownInCatchVariables": true, + "declaration": true, + "declarationDir": "./types", + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "./dist", + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["./playwright.config.ts", "./config/**/*.ts", "./e2e-tests/**/*.ts", "./helpers/**/*.ts", "./integration-tests/**/*.ts"], + "exclude": [] +} diff --git a/.jjconflict-base-0/plugins/argocd/.gitignore b/.jjconflict-base-0/plugins/argocd/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-0/plugins/argocd/README.md b/.jjconflict-base-0/plugins/argocd/README.md new file mode 100644 index 0000000000..2fe1f154d8 --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/README.md @@ -0,0 +1,200 @@ +# Plugin ArgoCD + +Ce document décrit le fonctionnement du plugin ArgoCD. + +## Hooks et Steps supportés + +Le seul step supporté est pour l'instant le step `main`. Ceci est applicable à tous les hooks de ce plugin. + +## upsertProject hook + +Voici le diagramme de séquence du hook le plus important du plugin (`upsert`) : + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + end + + box Project Cluster + participant ArgoCD_EXT as ArgoCD + participant Kubernetes_EXT as Kubernetes + end + + Bob ->> Console: "Please upsert project" + + loop For every zone/environment + Console ->> Gitlab: Reconcile (create/update/delete) values.yaml files + ArgoCD_EXT ->> Gitlab: Detects new/updated values.yaml files + ArgoCD_EXT ->> Kubernetes_EXT: Apply changes + end +``` + +Les clusters sont dits "externes" car ils sont hors du périmètre de DSO, et gérés de manière autonome par les projets clients, ce qui est une contrainte majeur : on ne peut pas interagir avec l'API Kubernetes (le cluster est considéré comme "air gapped", c'est-à-dire injoignable de l'extérieur, *a fortiori* depuis la Console). C'est en particulier le cas pour les clusters qui se trouvent dans une Zone DR ("Diffusion Restreinte"). + +C'est pourquoi la Console fonctionne avec son paradigme dans lequel c'est le cluster externe qui **vient récupérer les informations pour se mettre à jour**. Et la manière la plus efficace de faire ça, c'est de fonctionner en mode "GitOps", un mode dans lequel la Console crée un fichier de configuration (typiquement un `values.yaml`) qui serait récupéré (d'où le nom de "pull", en opposition au "push" traditionnel dans lequel on va utiliser les API Kubernetes) par le cluster externe afin d'être traité par l'ArgoCD qui lui est rattaché. + +## deleteProject hook + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + participant ArgoCD_DSO as ArgoCD + participant Kubernetes_DSO as Kubernetes + end + + box Project Cluster + participant ArgoCD_EXT as ArgoCD + participant Kubernetes_EXT as Kubernetes + end + + Bob ->> Console: "Please delete project" + + loop For every zone/environment + Console ->> Gitlab: Delete values.yaml files + ArgoCD_EXT ->> Gitlab: Detects deleted values.yaml files + ArgoCD_EXT ->> Kubernetes_EXT: Apply relevant changes (delete resources) + end +``` + +## upsertCluster hook + +Ce hook sert à la création/modification d'un cluster Kubernetes. +Il est à noter la distinction des clusters étiquetés `in-cluster` pour lesquels la gestion des secrets est différente (c'est une exception à la règle). + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please upsert cluster" + + alt cluster has "in-cluster" label + Console ->> Kubernetes: Delete cluster Secret + else cluster DOES NOT HAVE "in-cluster" label + Console ->> Kubernetes: Create cluster Secret + end + + Console ->> Vault: Create entry for cluster data +``` + +## deleteCluster hook + +Ce hook sert à la "suppression" d'un cluster Kubernetes. +Il est à noter que le cluster n'est pas vraiment supprimé, mais "oublié" par la Console, qui n'en garde plus trace. + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please delete cluster" + + Console ->> Kubernetes: Delete cluster Secret + Console ->> Vault: Destroy entry for cluster data +``` + +## upsertZone hook + +Ce hook sert à la création/modification d'une Zone (qui contiendra donc un ou plusieurs clusters Kubernetes) + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please upsert zone" + + Console ->> Vault: Get credentials + Console ->> Gitlab: Create infrastructure project for the zone if needed + Console ->> Gitlab: Create argocd-values.yaml file +``` + +## Deployment as-code (declarative mode) + +```mermaid +--- +config: + layout: default + theme: base + flowchart: + curve: linear + defaultRenderer: "elk" +--- +flowchart TD + subgraph "Helm-charts" + H1@{ shape: doc, label: "Chart 'dso-argocd-zone'" } + H2@{ shape: doc, label: "Chart 'dso-env'" } + H3@{ shape: doc, label: "Chart 'dso-ns'" } + end + subgraph "Gitlab DSO" + subgraph "zone repository" + V1@{ shape: doc, label: "/argocd-values.yaml" } + V2@{ shape: docs, label: "**/values.yaml" } + end + subgraph "project repositories" + V3@{ shape: docs, label: "infra sources" } + end + end + subgraph "DSO Cluster" + A1[Application zone-#lt;name>-app] + A1 --Deploy--> A3[ApplicationSet dso-appset] + A1 --Deploy--> S1[Cluster secrets] + V2 --Generator--> A3 + A3 --Generates--> A4[Application *-root] + V2 --> A4 + H2 -.-> A4 + A4 --Deploys--> A7[Application #lt;project>-env] + A4 --Deploys--> A8@{ shape: procs, label: "Application #lt;project>-#lt;env>-#lt;random-id>" } + + end + S1 --> apps + V1 --> A1 + H1 -.-> A1 + V3 ==> A8 + H3 -.-> A7 + V2 --> A7 + A7 --Deploys--> namespace & R1 & R2 & R3 + A8 ==Deploys==> A10 + A8 --Deploys--> A11 + subgraph apps["Apps Clusters"] + subgraph namespace["Namespace"] + A10@{ shape: procs, label: "K8S resources" } + A11@{ shape: procs, label: "VaultStaticSecrets" } + R1[ResourceQuota] + R2[VaultConnection] + R3[VaultStaticSecret registry-pull-secret] + R3 --> R4[Secret registry-pull-secret] + A11 --> R5@{ shape: procs, label: "Secrets" } + end + end + subgraph "Vault DSO" + kv1["KV Infra"] + kv2@{ shape: procs, label: "KV Projets" } + end + kv1 --> R3 + kv2 --> A11 +``` diff --git a/.jjconflict-base-0/plugins/argocd/eslint.config.js b/.jjconflict-base-0/plugins/argocd/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-0/plugins/argocd/package.json b/.jjconflict-base-0/plugins/argocd/package.json new file mode 100644 index 0000000000..042fad6e4d --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/package.json @@ -0,0 +1,53 @@ +{ + "name": "@cpn-console/argocd-plugin", + "type": "module", + "version": "2.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@himenon/argocd-typescript-openapi": "catalog:runtime", + "axios": "catalog:runtime", + "yaml": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/plugins/argocd/src/cluster.ts b/.jjconflict-base-0/plugins/argocd/src/cluster.ts new file mode 100644 index 0000000000..efdb6de462 --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/src/cluster.ts @@ -0,0 +1,69 @@ +import type { ClusterObject, StepCall } from '@cpn-console/hooks' +import { updateZoneValues } from './utils.js' + +export const upsertCluster: StepCall = async (payload) => { + try { + const cluster = payload.args + const { vault } = payload.apis + const clusterData = { + name: cluster.label, + clusterResources: cluster.clusterResources.toString(), + server: cluster.cluster.server, + config: JSON.stringify(convertConfig(cluster)), + } + await vault.upsert() + await vault.write(clusterData, `clusters/cluster-${cluster.label}/argocd-cluster-secret`) + await updateZoneValues(cluster.zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Cluster secret created/updated', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed create/update cluster secret', + }, + } + } +} + +export const deleteCluster: StepCall = async (payload) => { + try { + await payload.apis.vault.destroy(`clusters/cluster-${payload.args.label}/argocd-cluster-secret`) + await updateZoneValues(payload.args.zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Cluster secret deleted', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed to delete cluster secret', + }, + } + } +} + +// ...désolé +function convertConfig(cluster: ClusterObject) { + return { + ...cluster.user?.username && { username: cluster.user?.username }, + ...cluster.user?.password && { password: cluster.user?.password }, + ...cluster.user?.token && { bearerToken: cluster.user?.token }, + tlsClientConfig: { + ...cluster.user?.keyData && { keyData: cluster.user?.keyData }, + ...cluster.user?.certData && { certData: cluster.user?.certData }, + ...(cluster.cluster?.caData && !cluster.cluster?.skipTLSVerify) && { caData: cluster.cluster?.caData }, + ...cluster.cluster?.skipTLSVerify && { insecure: cluster.cluster.skipTLSVerify }, + serverName: cluster.cluster.tlsServerName, + }, + } +} diff --git a/.jjconflict-base-0/plugins/argocd/src/env.d.ts b/.jjconflict-base-0/plugins/argocd/src/env.d.ts new file mode 100644 index 0000000000..58ef50fd4c --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/src/env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/.jjconflict-base-0/plugins/argocd/src/functions.spec.ts b/.jjconflict-base-0/plugins/argocd/src/functions.spec.ts new file mode 100644 index 0000000000..2a036bc0c4 --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/src/functions.spec.ts @@ -0,0 +1,212 @@ +import type { ClusterObject, Environment, Project, Repository } from '@cpn-console/hooks' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { stringify } from 'yaml' +import { deleteProject, upsertProject } from './functions.js' + +vi.mock('./utils.js', () => ({ + generateAppProjectName: vi.fn(() => 'app-project-name'), + getConfig: vi.fn(() => ({ namespace: 'argocd', url: 'https://argocd.example.com' })), +})) + +vi.mock('@cpn-console/shared', async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + generateNamespaceName: vi.fn(() => 'namespace-name'), + } +}) + +describe('argocd functions', () => { + const mockGitlabApi = { + // Used to get the public group URL for sourceRepositories + getPublicGroupUrl: vi.fn(), + // Used to get or create the infra project where values.yaml is stored + getOrCreateInfraProject: vi.fn(), + // Used to get the infra project details (repo URL) + getProjectById: vi.fn(), + // Used to resolve the public URL of a repository + getPublicRepoUrl: vi.fn(), + // Used to commit the values.yaml file + commitCreateOrUpdate: vi.fn(), + // Used to list files in the infra project for cleanup + listFiles: vi.fn(), + // Used to delete files from the infra project + commitDelete: vi.fn(), + } + // Used to get the RO and RW groups for the environment + const mockKeycloakApi = { + getEnvGroup: vi.fn(), + } + // Used to get user vault secrets + const mockVaultApi = { + Project: { + getValues: vi.fn(), + }, + } + + let mockCluster: ClusterObject + let mockEnvironment: Environment + let mockRepo: Repository + let mockProject: Project + + beforeEach(() => { + vi.clearAllMocks() + + mockCluster = { + id: faker.string.uuid(), + label: faker.string.alphanumeric(10), + zone: { slug: faker.string.alphanumeric(5) }, + privacy: 'public', + } as any + + mockEnvironment = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(5), + clusterId: mockCluster.id, + cpu: faker.number.int({ min: 1, max: 10 }), + memory: faker.number.int({ min: 1, max: 32 }), + gpu: 0, + autosync: true, + } as any + + mockRepo = { + id: faker.string.uuid(), + internalRepoName: faker.string.alphanumeric(10), + isInfra: true, + helmValuesFiles: null, + deployRevision: 'main', + deployPath: '/deploy', + } as any + + mockProject = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(10), + slug: faker.string.alphanumeric(10), + repositories: [mockRepo], + environments: [mockEnvironment], + clusters: [mockCluster], + store: {}, + } as any + }) + + it('upsertProject should succeed', async () => { + const infraProjectId = faker.number.int() + const infraProjectUrl = faker.internet.url() + const gitlabGroupUrl = faker.internet.url() + const gitlabRepoUrl = faker.internet.url() + + mockGitlabApi.getPublicGroupUrl.mockResolvedValue(gitlabGroupUrl) + mockGitlabApi.getOrCreateInfraProject.mockResolvedValue({ id: infraProjectId, http_url_to_repo: infraProjectUrl }) + mockKeycloakApi.getEnvGroup.mockResolvedValue({ subgroups: { RO: '/ro', RW: '/rw' } }) + mockVaultApi.Project.getValues.mockResolvedValue({ secret: 'value' }) + mockGitlabApi.getProjectById.mockResolvedValue({ http_url_to_repo: infraProjectUrl }) + mockGitlabApi.listFiles.mockResolvedValue([]) // No files to delete + mockGitlabApi.getPublicRepoUrl.mockResolvedValue(gitlabRepoUrl) + + const payload = { + args: mockProject, + apis: { + gitlab: mockGitlabApi, + keycloak: mockKeycloakApi, + vault: mockVaultApi, + }, + config: {}, + } as any + + const result = await upsertProject(payload) + + expect(result.status.result).toBe('OK') + + const expectedValues = { + common: { + 'dso/project': mockProject.name, + 'dso/project.id': mockProject.id, + 'dso/project.slug': mockProject.slug, + 'dso/environment': mockEnvironment.name, + 'dso/environment.id': mockEnvironment.id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'app-project-name', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: infraProjectUrl, + valueFileRevision: 'HEAD', + valueFilePath: `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, + roGroup: '/ro', + rwGroup: '/rw', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + projectAdminGroup: `/${mockProject.slug}/console/admin`, + projectDevopsGroup: `/${mockProject.slug}/console/devops`, + projectDevelopperGroup: `/${mockProject.slug}/console/developer`, + projectReadonlyGroup: `/${mockProject.slug}/console/readonly`, + }, + application: { + quota: { + cpu: mockEnvironment.cpu, + gpu: mockEnvironment.gpu, + memory: `${mockEnvironment.memory}Gi`, + }, + sourceRepositories: [ + `${gitlabGroupUrl}/**`, + ], + destination: { + namespace: 'namespace-name', + name: mockCluster.label, + }, + autosync: true, + vault: { secret: 'value' }, + repositories: [ + { + id: mockRepo.id, + name: mockRepo.internalRepoName, + repoURL: gitlabRepoUrl, + targetRevision: 'main', + path: '/deploy', + valueFiles: [], + }, + ], + }, + } + + expect(mockGitlabApi.commitCreateOrUpdate).toHaveBeenCalledWith( + infraProjectId, + stringify(expectedValues), + `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, + ) + expect(mockGitlabApi.commitDelete).toHaveBeenCalledWith(infraProjectId, []) + }) + + it('deleteProject should succeed', async () => { + const infraProjectId = faker.number.int() + mockGitlabApi.getOrCreateInfraProject.mockResolvedValue({ id: infraProjectId }) + mockGitlabApi.listFiles.mockResolvedValue([ + { name: 'values.yaml', path: `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, type: 'blob' }, + { name: 'values.yaml', path: `${mockProject.name}/${mockCluster.label}/old-env/values.yaml`, type: 'blob' }, + ]) + + const payload = { + args: mockProject, + apis: { + gitlab: mockGitlabApi, + }, + } as any + + const result = await deleteProject(payload) + + expect(result.status.result).toBe('OK') + expect(mockGitlabApi.listFiles).toHaveBeenCalledWith(infraProjectId, { + path: `${mockProject.name}/`, + recursive: true, + }) + // Only the stale env file should be deleted (not in project.environments) + expect(mockGitlabApi.commitDelete).toHaveBeenCalledWith(infraProjectId, [ + `${mockProject.name}/${mockCluster.label}/old-env/values.yaml`, + ]) + }) +}) diff --git a/.jjconflict-base-0/plugins/argocd/src/functions.ts b/.jjconflict-base-0/plugins/argocd/src/functions.ts new file mode 100644 index 0000000000..7c1ccbc139 --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/src/functions.ts @@ -0,0 +1,281 @@ +import type { GitlabProjectApi } from '@cpn-console/gitlab-plugin/types/class.js' +import type { + ClusterObject, + Config, + Environment, + Project, + Repository, + StepCall, +} from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' +import { stringify } from 'yaml' +import { + DEFAULT_DSO_ENV_CHART_VERSION, + DEFAULT_DSO_NS_CHART_VERSION, + DEFAULT_PLATFORM_ADMIN_GROUP_PATH, + DEFAULT_PLATFORM_READONLY_GROUP_PATH, + DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX, +} from './infos.js' +import { logger } from './logger.js' +import { generateAppProjectName, getConfig } from './utils.js' + +function splitExtraRepositories(repos?: string): string[] { + return repos ? repos.split(',').map(repo => repo.trim()) : [] +} + +function getValueFilePath(p: Project, c: ClusterObject, e: Environment): string { + return `${p.name}/${c.label}/${e.name}/values.yaml` +} + +export const upsertProject: StepCall = async (payload) => { + const project = payload.args + const gitlabApi = payload.apis.gitlab as unknown as GitlabProjectApi + const keycloakApi = payload.apis.keycloak as any + const vaultApi = payload.apis.vault as unknown as VaultProjectApi + + try { + const infraRepositories = project.repositories.filter( + repo => repo.isInfra, + ) + const sourceRepositories = [ + `${await gitlabApi.getPublicGroupUrl()}/**`, + ...splitExtraRepositories(payload.config.argocd?.extraRepositories), + ...splitExtraRepositories(project.store.argocd?.extraRepositories), + ] + + await Promise.all( + project.environments.map(async (environment) => { + const appNamespace = generateNamespaceName(project.id, environment.id) + const cluster = getCluster(project, environment) + const infraProject = await gitlabApi.getOrCreateInfraProject(cluster.zone.slug) + const appProjectName = generateAppProjectName(project.slug, environment.name) + const envGroup = await (keycloakApi as any).getEnvGroup(environment.name) + const roGroup = envGroup.subgroups.RO + const rwGroup = envGroup.subgroups.RW + + await ensureInfraEnvValues( + project, + environment, + appNamespace, + roGroup, + rwGroup, + appProjectName, + infraRepositories, + infraProject.id, + sourceRepositories, + gitlabApi, + vaultApi, + payload.config, + ) + }), + ) + + await removeInfraEnvValues(project, gitlabApi) + + logger.info({ action: 'upsertProject', projectSlug: project.slug }, 'Hook done') + + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +interface ArgoRepoSource { + repoURL: string + targetRevision: string + path: string + valueFiles: string[] +} + +async function ensureInfraEnvValues( + project: Project, + environment: Environment, + appNamespace: string, + roGroup: string, + rwGroup: string, + appProjectName: string, + infraRepositories: Repository[], + repoId: number, + sourceRepositories: string[], + gitlabApi: GitlabProjectApi, + vaultApi: VaultProjectApi, + config: Config, +) { + const platformAdminGroupPath = config.argocd?.platformAdminGroupPath ?? DEFAULT_PLATFORM_ADMIN_GROUP_PATH + const platformReadonlyGroupPath = config.argocd?.platformReadonlyGroupPath ?? DEFAULT_PLATFORM_READONLY_GROUP_PATH + const projectAdminGroupSuffix = config.argocd?.projectAdminGroupPathSuffix ?? DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX + const projectDevopsGroupSuffix = config.argocd?.projectDevopsGroupPathSuffix ?? DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX + const projectDevelopperGroupSuffix = config.argocd?.projectDevelopperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + const projectReadonlyGroupSuffix = config.argocd?.projectReadonlyGroupPathSuffix ?? DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX + const dsoEnvChartVersion = config.argocd?.dsoEnvChartVersion ?? DEFAULT_DSO_ENV_CHART_VERSION + const dsoNsChartVersion = config.argocd?.dsoNsChartVersion ?? DEFAULT_DSO_NS_CHART_VERSION + const cluster = getCluster(project, environment) + const infraProject = await gitlabApi.getProjectById(repoId) + const valueFilePath = getValueFilePath(project, cluster, environment) + const vaultValues = await vaultApi.Project.getValues() + const repositories: ArgoRepoSource[] = await Promise.all( + infraRepositories.map(async (repository) => { + const repoURL = await gitlabApi.getPublicRepoUrl( + repository.internalRepoName, + ) + const valueFiles = repository.helmValuesFiles + ? repository.helmValuesFiles + .replaceAll('', environment.name) + .split(',') + : [] + return { + id: repository.id, + name: repository.internalRepoName, + repoURL, + targetRevision: repository.deployRevision || 'HEAD', + path: repository.deployPath || '.', + valueFiles, + } + }), + ) + + const values = { + common: { + 'dso/project': project.name, + 'dso/project.id': project.id, + 'dso/project.slug': project.slug, + 'dso/environment': environment.name, + 'dso/environment.id': environment.id, + }, + argocd: { + cluster: inClusterLabel, + namespace: getConfig().namespace, + project: appProjectName, + envChartVersion: dsoEnvChartVersion, + nsChartVersion: dsoNsChartVersion, + }, + environment: { + valueFileRepository: infraProject.http_url_to_repo, + valueFileRevision: 'HEAD', + valueFilePath, + roGroup, + rwGroup, + platformAdminGroup: platformAdminGroupPath, + platformReadonlyGroup: platformReadonlyGroupPath, + projectAdminGroup: `/${project.slug}${projectAdminGroupSuffix}`, + projectDevopsGroup: `/${project.slug}${projectDevopsGroupSuffix}`, + projectDevelopperGroup: `/${project.slug}${projectDevelopperGroupSuffix}`, + projectReadonlyGroup: `/${project.slug}${projectReadonlyGroupSuffix}`, + }, + application: { + quota: { + cpu: environment.cpu, + gpu: environment.gpu, + memory: `${environment.memory}Gi`, + }, + sourceRepositories, + destination: { + namespace: appNamespace, + name: cluster.label, + }, + autosync: environment.autosync, + vault: vaultValues, + repositories, + }, + } + + await gitlabApi.commitCreateOrUpdate(repoId, stringify(values), valueFilePath) +} + +function getCluster(p: Project, e: Environment): ClusterObject { + const c = p.clusters.find(c => c.id === e.clusterId) + if (!c) throw new Error(`Unable to find cluster ${e.id} for env ${e.name}`) + return c +} + +async function removeInfraEnvValues( + project: Project, + gitlabApi: GitlabProjectApi, +) { + for (const z of getDistinctZones(project)) { + const infraProject = await gitlabApi.getOrCreateInfraProject(z) + const existingFiles = await gitlabApi.listFiles(infraProject.id, { + path: `${project.name}/`, + recursive: true, + }) + const neededFiles = project.environments.map(env => + getValueFilePath(project, getCluster(project, env), env), + ) + const filesToDelete: string[] = [] + for (const existingFile of existingFiles) { + if ( + existingFile.name === 'values.yaml' + && !neededFiles.includes(existingFile.path) + ) { + filesToDelete.push(existingFile.path) + } + } + await gitlabApi.commitDelete(infraProject.id, filesToDelete) + } +} + +function getDistinctZones(project: Project) { + const zones: Set = new Set() + project.clusters.forEach(c => zones.add(c.zone.slug)) + return zones +} + +async function cleanupProjectInfra( + project: Project, + gitlabApi: GitlabProjectApi, +) { + for (const z of getDistinctZones(project)) { + const infraProject = await gitlabApi.getOrCreateInfraProject(z) + const existingFiles = await gitlabApi.listFiles(infraProject.id, { + path: `${project.name}/`, + recursive: true, + }) + const filesToDelete = existingFiles + .filter(f => f.name === 'values.yaml') + .map(f => f.path) + await gitlabApi.commitDelete(infraProject.id, filesToDelete) + } +} + +export const deleteProject: StepCall = async (payload) => { + const project = payload.args + const gitlabApi = payload.apis.gitlab as unknown as GitlabProjectApi + + try { + await cleanupProjectInfra(project, gitlabApi) + + logger.info({ action: 'deleteProject', projectSlug: project.slug }, 'Hook done') + + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'deleteProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} diff --git a/.jjconflict-base-0/plugins/argocd/src/index.ts b/.jjconflict-base-0/plugins/argocd/src/index.ts new file mode 100644 index 0000000000..5d7acfdda0 --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/src/index.ts @@ -0,0 +1,43 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import { deleteCluster, upsertCluster } from './cluster.js' +import { deleteProject, upsertProject } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' +import { upsertZone } from './zone.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { + steps: { + main: upsertProject, + }, + }, + deleteProject: { + steps: { + main: deleteProject, + }, + }, + upsertCluster: { + steps: { + main: upsertCluster, + }, + }, + deleteCluster: { + steps: { + main: deleteCluster, + }, + }, + upsertZone: { + steps: { + main: upsertZone, + }, + }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-base-0/plugins/argocd/src/infos.ts b/.jjconflict-base-0/plugins/argocd/src/infos.ts new file mode 100644 index 0000000000..71d672c5e1 --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/src/infos.ts @@ -0,0 +1,133 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const extraRepositoriesDesc = 'appproject.spec.sourceRepos supplémentaires, séparés par des virgules (https://a.com/repo.git,https://b.com/' + +export const DEFAULT_PLATFORM_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_PLATFORM_READONLY_GROUP_PATH = '/console/readonly' +export const DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX = '/console/readonly' +export const DEFAULT_DSO_ENV_CHART_VERSION = 'dso-env-1.6.0' +export const DEFAULT_DSO_NS_CHART_VERSION = 'dso-ns-1.1.5' + +const infos = { + name: 'argocd', + to: ({ zones, project }) => zones.map(z => ({ + to: `${z.argocdUrl}/applications?showFavorites=false&proj=&sync=&health=&namespace=&cluster=&labels=&search=${project.slug}`, + title: `ArgoCD ${z.label}`, + })), + title: 'ArgoCD', + imgSrc: '/img/argocd.svg', + description: 'ArgoCD est un outil déclaratif de livraison continue GitOps pour Kubernetes', + config: { + global: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }, { + key: 'platformAdminGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Admin Group Path', + value: DEFAULT_PLATFORM_ADMIN_GROUP_PATH, + description: 'Chemin du groupe administrateur de plateforme', + }, { + key: 'platformReadonlyGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Readonly Group Path', + value: DEFAULT_PLATFORM_READONLY_GROUP_PATH, + description: 'Chemin du groupe lecture seule de plateforme', + }, { + key: 'projectAdminGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Admin Group Path Suffix', + value: DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe administrateur de projet', + }, { + key: 'projectDevopsGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project DevOps Group Path Suffix', + value: DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe devops de projet', + }, { + key: 'projectDevelopperGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Developer Group Path Suffix', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe développeur de projet', + }, { + key: 'projectReadonlyGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Readonly Group Path Suffix', + value: DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe lecture seule de projet', + }, { + key: 'dsoEnvChartVersion', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Env Chart Version', + value: DEFAULT_DSO_ENV_CHART_VERSION, + description: 'Version du chart Helm dso-env', + placeholder: DEFAULT_DSO_ENV_CHART_VERSION, + }, { + key: 'dsoNsChartVersion', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Namespace Chart Version', + value: DEFAULT_DSO_NS_CHART_VERSION, + description: 'Version du chart Helm dso-ns', + placeholder: DEFAULT_DSO_NS_CHART_VERSION, + }], + project: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-base-0/plugins/argocd/src/logger.ts b/.jjconflict-base-0/plugins/argocd/src/logger.ts new file mode 100644 index 0000000000..45416d7025 --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'argocd' }) diff --git a/.jjconflict-base-0/plugins/argocd/src/monitor.ts b/.jjconflict-base-0/plugins/argocd/src/monitor.ts new file mode 100644 index 0000000000..132a2a4441 --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/src/monitor.ts @@ -0,0 +1,27 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(requiredEnv('ARGOCD_INTERNAL_URL'), { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-base-0/plugins/argocd/src/utils.ts b/.jjconflict-base-0/plugins/argocd/src/utils.ts new file mode 100644 index 0000000000..151f2389ef --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/src/utils.ts @@ -0,0 +1,51 @@ +import type { GitlabZoneApi } from '@cpn-console/gitlab-plugin/types/class.js' +import type { ClusterObject, HookPayloadApis, ZoneObject } from '@cpn-console/hooks' +import { createHmac } from 'node:crypto' +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import { stringify } from 'yaml' + +export function generateAppProjectName(projectSlug: string, env: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${envHash}` +} + +export function generateApplicationName(projectSlug: string, env: string, repo: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${repo}-${envHash}` +} + +const config: { + namespace?: string + url?: string +} = {} + +export function getConfig(): Required { + config.namespace = config.namespace ?? requiredEnv('ARGO_NAMESPACE') + config.url = removeTrailingSlash(requiredEnv('ARGOCD_URL')) + + // @ts-ignore + return config +} + +export async function updateZoneValues(zone: ZoneObject, apis: HookPayloadApis | HookPayloadApis) { + const gitlab = apis.gitlab as unknown as GitlabZoneApi + const vault = apis.vault as any + const values = { + vault: await vault.getValues(), + clusters: zone.clusterNames, + } + const zoneRepo = await gitlab.getOrCreateInfraProject(zone.slug) + await gitlab.commitCreateOrUpdate(zoneRepo.id, stringify(values), 'argocd-values.yaml') + return { + status: { + result: 'OK', + message: 'Zone argocd configuration created/updated', + }, + } +} diff --git a/.jjconflict-base-0/plugins/argocd/src/zone.ts b/.jjconflict-base-0/plugins/argocd/src/zone.ts new file mode 100644 index 0000000000..61e74e765a --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/src/zone.ts @@ -0,0 +1,23 @@ +import type { StepCall, ZoneObject } from '@cpn-console/hooks' +import { updateZoneValues } from './utils.js' + +export const upsertZone: StepCall = async (payload) => { + try { + const zone = payload.args + await updateZoneValues(zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Zone argocd configuration created/updated', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed create/update zone argocd configuration', + }, + } + } +} diff --git a/.jjconflict-base-0/plugins/argocd/tsconfig.eslint.json b/.jjconflict-base-0/plugins/argocd/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-0/plugins/argocd/tsconfig.json b/.jjconflict-base-0/plugins/argocd/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-0/plugins/argocd/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-0/plugins/gitlab/.gitignore b/.jjconflict-base-0/plugins/gitlab/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-0/plugins/gitlab/eslint.config.js b/.jjconflict-base-0/plugins/gitlab/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-0/plugins/gitlab/files/.gitlab-ci.yml b/.jjconflict-base-0/plugins/gitlab/files/.gitlab-ci.yml new file mode 100644 index 0000000000..ca9be29847 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/files/.gitlab-ci.yml @@ -0,0 +1,22 @@ +variables: + PROJECT_NAME: + description: Nom du dépôt (dans ce Gitlab) à synchroniser. + GIT_BRANCH_DEPLOY: + description: Nom de la branche à synchroniser. + value: main + SYNC_ALL: + description: Synchroniser toutes les branches. + value: "false" + +include: + - project: $CATALOG_PATH + file: mirror.yml + ref: main + +repo_pull_sync: + extends: .repo_pull_sync + only: + - api + - triggers + - web + - schedules diff --git a/.jjconflict-base-0/plugins/gitlab/files/mirror.sh b/.jjconflict-base-0/plugins/gitlab/files/mirror.sh new file mode 100644 index 0000000000..8f5fbdfe37 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/files/mirror.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' + +# Console step increment +i=1 + +# Default values +BRANCH_TO_SYNC=main + +print_help() { + TEXT_HELPER="\nThis script aims to send a synchronization request to DSO.\nFollowing flags are available: + -a Api url to send the synchronization request + -b Branch which is wanted to be synchronize for the given repository (default '$BRANCH_TO_SYNC') + -g GitLab token to trigger the pipeline on the gitlab mirror project + -i Gitlab mirror project id + -r Gitlab repository name to mirror + -h Print script help\n" + printf "$TEXT_HELPER" +} + +print_args() { + printf "\nArguments received: + -a API_URL: $API_URL + -b BRANCH_TO_SYNC: $BRANCH_TO_SYNC + -g GITLAB_TRIGGER_TOKEN length: ${#GITLAB_TRIGGER_TOKEN} + -i GITLAB_MIRROR_PROJECT_ID: $GITLAB_MIRROR_PROJECT_ID + -r REPOSITORY_NAME: $REPOSITORY_NAME\n" +} + +# Parse options +while getopts :ha:b:g:i:r: flag +do + case "${flag}" in + a) + API_URL=${OPTARG};; + b) + BRANCH_TO_SYNC=${OPTARG};; + g) + GITLAB_TRIGGER_TOKEN=${OPTARG};; + i) + GITLAB_MIRROR_PROJECT_ID=${OPTARG};; + r) + REPOSITORY_NAME=${OPTARG};; + h) + printf "\nHelp requested.\n" + print_help + printf "\nExiting.\n" + exit 0;; + *) + printf "\nInvalid argument ${OPTARG} (${flag}).\n" + print_help + print_args + exit 1;; + esac +done + +# Test if arguments are missing +if [ -z ${API_URL} ] || [ -z ${BRANCH_TO_SYNC} ] || [ -z ${GITLAB_TRIGGER_TOKEN} ] || [ -z ${GITLAB_MIRROR_PROJECT_ID} ] || [ -z ${REPOSITORY_NAME} ]; then + printf "\nArgument(s) missing!\n" + print_help + print_args + exit 2 +fi + +# Print arguments +print_args + +# Send synchronization request +printf "\n${red}${i}.${no_color} Send request to DSO api.\n\n" + +curl \ + -X POST \ + --fail \ + -F token=${GITLAB_TRIGGER_TOKEN} \ + -F ref=main \ + -F variables[GIT_BRANCH_DEPLOY]=${BRANCH_TO_SYNC} \ + -F variables[PROJECT_NAME]=${REPOSITORY_NAME} \ + "${API_URL}/api/v4/projects/${GITLAB_MIRROR_PROJECT_ID}/trigger/pipeline" diff --git a/.jjconflict-base-0/plugins/gitlab/package.json b/.jjconflict-base-0/plugins/gitlab/package.json new file mode 100644 index 0000000000..5640a675c7 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/package.json @@ -0,0 +1,52 @@ +{ + "name": "@cpn-console/gitlab-plugin", + "type": "module", + "version": "3.3.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "files", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/requester-utils": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/plugins/gitlab/src/class.ts b/.jjconflict-base-0/plugins/gitlab/src/class.ts new file mode 100644 index 0000000000..2ddb702b02 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/class.ts @@ -0,0 +1,686 @@ +import type { Project, ProjectMember, UniqueRepo } from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { AccessTokenScopes, AllRepositoryTreesOptions, CommitAction, CondensedProjectSchema, Gitlab, GroupSchema, MemberSchema, ProjectSchema, ProjectVariableSchema, RepositoryFileExpandedSchema, VariableSchema } from '@gitbeaker/core' +import type { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { createHash } from 'node:crypto' +import { PluginApi } from '@cpn-console/hooks' +import { objectEntries } from '@cpn-console/shared' +import { AccessLevel } from '@gitbeaker/core' +import config from './config.js' +import { + customAttributesFilter, + infraGroupCustomAttributeKey, + managedByConsoleCustomAttributeKey, + projectGroupCustomAttributeKey, + upsertCustomAttribute, +} from './custom-attributes.js' +import { logger } from './logger.js' +import { + find, + getAll, + getApi, + getGroupRootId, + infraAppsRepoName, + internalMirrorRepoName, + MAX_PAGINATION_PER_PAGE, + offsetPaginate, +} from './utils.js' + +type setVariableResult = 'created' | 'updated' | 'already up-to-date' +type AccessLevelAllowed = AccessLevel.NO_ACCESS | AccessLevel.MINIMAL_ACCESS | AccessLevel.GUEST | AccessLevel.REPORTER | AccessLevel.DEVELOPER | AccessLevel.MAINTAINER | AccessLevel.OWNER +const infraGroupName = 'Infra' +const infraGroupPath = 'infra' +export const pluginManagedTopic = 'plugin-managed' + +interface GitlabMirrorSecret { + MIRROR_USER: string + MIRROR_TOKEN: string +} + +interface RepoSelect { + mirror?: CondensedProjectSchema + target?: CondensedProjectSchema +} +type PendingCommits = Record +}> + +interface CreateEmptyRepositoryArgs { + repoName: string + description?: string +} + +export class GitlabApi extends PluginApi { + protected api: Gitlab + private pendingCommits: PendingCommits = {} + + constructor() { + super() + this.api = getApi() + } + + public async createEmptyRepository({ createFirstCommit, groupId, repoName, description, ciConfigPath }: CreateEmptyRepositoryArgs & { + createFirstCommit: boolean + groupId: number + ciConfigPath?: string + }) { + logger.debug({ action: 'createEmptyRepository', repoName, groupId, createFirstCommit, ciConfigPath }, 'Create empty repository') + const project = await this.api.Projects.create({ + name: repoName, + path: repoName, + ciConfigPath, + namespaceId: groupId, + description, + }) + try { + await upsertCustomAttribute('projects', project.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createEmptyRepository', projectId: project.id, err }, 'Failed to upsert project custom attribute') + } + // Dépôt tout juste créé, zéro branche => pas d'erreur (filesTree undefined) + if (createFirstCommit) { + await this.api.Commits.create(project.id, 'main', 'ci: 🌱 First commit', []) + } + return project + } + + public async commitCreateOrUpdate( + repoId: number, + fileContent: string, + filePath: string, + branch: string = 'main', + comment: string = 'ci: :robot_face: Update file content', + ): Promise { + logger.debug({ action: 'commitCreateOrUpdate', repoId, filePath, branch }, 'Schedule commit create/update') + let action: CommitAction['action'] = 'create' + + const existingBranch = await find(offsetPaginate(opts => this.api.Branches.all(repoId, opts), { perPage: MAX_PAGINATION_PER_PAGE }), b => b.name === branch) + if (existingBranch) { + let actualFile: RepositoryFileExpandedSchema | undefined + try { + actualFile = await this.api.RepositoryFiles.show(repoId, filePath, branch) + } catch {} + if (actualFile) { + const newContentDigest = createHash('sha256').update(fileContent).digest('hex') + if (actualFile.content_sha256 === newContentDigest) { + // Already up-to-date + return false + } + // Update needed + action = 'update' + } + } + + const commitAction: CommitAction = { + action, + filePath, + content: fileContent, + } + this.addActions(repoId, branch, comment, [commitAction]) + + return true + } + + /** + * Fonction pour supprimer une liste de fichiers d'un repo + * @param repoId + * @param files + * @param branch + * @param comment + */ + public async commitDelete( + repoId: number, + files: string[], + branch: string = 'main', + comment: string = 'ci: :robot_face: Delete files', + ): Promise { + logger.debug({ action: 'commitDelete', repoId, branch, filesCount: files.length }, 'Schedule commit delete') + if (files.length) { + const commitActions: CommitAction[] = files.map((filePath) => { + return { + action: 'delete', + filePath, + } + }) + this.addActions(repoId, branch, comment, commitActions) + return true + } + return false + } + + private addActions(repoId: number, branch: string, comment: string, commitActions: CommitAction[]) { + if (!this.pendingCommits[repoId]) { + this.pendingCommits[repoId] = { branches: {} } + } + if (this.pendingCommits[repoId].branches[branch]) { + this.pendingCommits[repoId].branches[branch].actions.push(...commitActions) + this.pendingCommits[repoId].branches[branch].messages.push(comment) + } else { + this.pendingCommits[repoId].branches[branch] = { + actions: commitActions, + messages: [comment], + } + } + } + + public async commitFiles() { + let filesUpdated: number = 0 + for (const [id, repo] of objectEntries(this.pendingCommits)) { + for (const [branch, details] of objectEntries(repo.branches)) { + const filesNumber = details.actions.length + if (filesNumber) { + filesUpdated += filesNumber + const message = [`ci: :robot_face: Update ${filesNumber} file${filesNumber > 1 ? 's' : ''}`, ...details.messages.filter(m => m)].join('\n') + logger.debug({ action: 'commitFiles', repoId: id, branch, filesNumber }, 'Commit pending file changes') + await this.api.Commits.create(id, branch, message, details.actions) + } + } + } + return filesUpdated + } + + public async listFiles(repoId: number, options: AllRepositoryTreesOptions = {}) { + options.path = options?.path ?? '/' + options.ref = options?.ref ?? 'main' + options.recursive = options?.recursive ?? false + try { + const files = await this.api.Repositories.allRepositoryTrees(repoId, options) + // if (depth >= 0) { + // for (const file of files) { + // if (file.type !== 'tree') { + // return [] + // } + // const childrenFiles = await this.listFiles(repoId, { depth: depth - 1, ...options, path: file.path }) + // files.push(...childrenFiles) + // } + // } + return files + } catch (error) { + const { cause } = error as GitbeakerRequestError + if (cause?.description.includes('Not Found')) { + // Empty repository, with zero commit ==> Zero files + return [] + } else { + throw error + } + } + } + + public async deleteRepository(repoId: number, fullPath: string) { + logger.info({ action: 'deleteRepository', repoId, fullPath }, 'Delete repository') + await this.api.Projects.remove(repoId) // Marks for deletion + return this.api.Projects.remove(repoId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${repoId}` }) // Effective deletion + } +} + +export class GitlabZoneApi extends GitlabApi { + private infraProjectsByZoneSlug: Map + + constructor() { + super() + this.infraProjectsByZoneSlug = new Map() + } + + // Group Infra + public async getOrCreateInfraGroup(): Promise { + logger.debug({ action: 'getOrCreateInfraGroup', infraGroupName }, 'Get/create infra group') + const rootId = await getGroupRootId() + // Get or create projects_root_dir/infra group + const fast = await find( + offsetPaginate(opts => this.api.Groups.all({ + ...customAttributesFilter(infraGroupCustomAttributeKey, 'true'), + ...opts, + })), + group => group.parent_id === rootId, + ) + + const existingParentGroup = fast ?? await find( + offsetPaginate(opts => this.api.Groups.all({ + search: infraGroupName, + ...opts, + })), + group => group.parent_id === rootId && group.name === infraGroupName, + ) + + const group = existingParentGroup || await this.api.Groups.create(infraGroupName, infraGroupPath, { + parentId: rootId, + projectCreationLevel: 'maintainer', + subgroupCreationLevel: 'owner', + defaultBranchProtection: 0, + description: 'Group that hosts infrastructure-as-code repositories for all zones (ArgoCD pull targets).', + }) + try { + await upsertCustomAttribute('groups', group.id, infraGroupCustomAttributeKey, 'true') + await upsertCustomAttribute('groups', group.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getOrCreateInfraGroup', groupId: group.id, err }, 'Failed to upsert infra group custom attribute') + } + return group + } + + public async getOrCreateInfraProject(zone: string): Promise { + logger.debug({ action: 'getOrCreateInfraProject', zone }, 'Get/create infra project for zone') + if (this.infraProjectsByZoneSlug.has(zone)) { + return this.infraProjectsByZoneSlug.get(zone)! + } + const infraGroup = await this.getOrCreateInfraGroup() + // Get or create projects_root_dir/infra/zone + const fast = await find( + offsetPaginate(opts => this.api.Groups.allProjects(infraGroup.id, { + ...customAttributesFilter(managedByConsoleCustomAttributeKey, 'true'), + search: zone, + simple: true, + ...opts, + })), + repo => repo.name === zone, + ) + const project = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allProjects(infraGroup.id, { + search: zone, + simple: true, + ...opts, + })), + repo => repo.name === zone, + ) ?? await this.createEmptyRepository({ + repoName: zone, + groupId: infraGroup.id, + description: 'Repository hosting deployment files for this zone.', + createFirstCommit: true, + }) + this.infraProjectsByZoneSlug.set(zone, project) + return project + } +} + +export class GitlabProjectApi extends GitlabApi { + private project: Project | UniqueRepo | ProjectMember['project'] + private gitlabGroup: GroupSchema | undefined + private specialRepositories: string[] = [infraAppsRepoName, internalMirrorRepoName] + private zoneApi: GitlabZoneApi + + constructor(project: Project | UniqueRepo | ProjectMember['project']) { + super() + this.project = project + this.api = getApi() + this.zoneApi = new GitlabZoneApi() + } + + // Group Project + private async createProjectGroup(): Promise { + logger.info({ action: 'createProjectGroup', projectSlug: this.project.slug }, 'Create project group') + const parentId = await getGroupRootId() + const existingGroup = await find(offsetPaginate(opts => this.api.Groups.all({ + search: this.project.slug, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), group => group.parent_id === parentId && group.name === this.project.slug) + + if (existingGroup) return existingGroup + + const group = await this.api.Groups.create(this.project.slug, this.project.slug, { + parentId, + projectCreationLevel: 'maintainer', + subgroupCreationLevel: 'owner', + defaultBranchProtection: 0, + }) + try { + await upsertCustomAttribute('groups', group.id, projectGroupCustomAttributeKey, this.project.slug) + await upsertCustomAttribute('groups', group.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createProjectGroup', groupId: group.id, err }, 'Failed to upsert project group custom attribute') + } + return group + } + + public async getProjectGroup(): Promise { + logger.debug({ action: 'getProjectGroup', projectSlug: this.project.slug }, 'Get project group') + if (!this.gitlabGroup) { + logger.debug({ action: 'getProjectGroup', projectSlug: this.project.slug }, 'Search project group') + const parentId = await getGroupRootId() + const fast = await find( + offsetPaginate(opts => this.api.Groups.allSubgroups(parentId, { + ...customAttributesFilter(projectGroupCustomAttributeKey, this.project.slug), + ...opts, + })), + group => group.name === this.project.slug, + ) + + this.gitlabGroup = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allSubgroups(parentId, { + search: this.project.slug, + ...opts, + })), + group => group.name === this.project.slug, + ) + + if (this.gitlabGroup) { + try { + await upsertCustomAttribute('groups', this.gitlabGroup.id, projectGroupCustomAttributeKey, this.project.slug) + await upsertCustomAttribute('groups', this.gitlabGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getProjectGroup', groupId: this.gitlabGroup.id, err }, 'Failed to upsert project group custom attribute') + } + } + } + return this.gitlabGroup + } + + public async getOrCreateProjectGroup(): Promise { + logger.debug({ action: 'getOrCreateProjectGroup', projectSlug: this.project.slug }, 'Get/create project group') + const group = await this.getProjectGroup() + if (group) return group + return this.createProjectGroup() + } + + public async getPublicGroupUrl() { + return new URL(`/${config().projectsRootDir}/${this.project.slug}`, config().publicUrl) + } + + public async getInternalGroupUrl() { + return new URL(`/${config().projectsRootDir}/${this.project.slug}`, config().internalUrl) + } + + // Tokens + public async getProjectMirrorCreds(vaultApi: VaultProjectApi): Promise { + logger.debug({ action: 'getProjectMirrorCreds', projectSlug: this.project.slug }, 'Get/create project mirror credentials') + const tokenName = `${this.project.slug}-bot` + const currentToken = await this.getProjectToken(tokenName) + const creds: GitlabMirrorSecret = { + MIRROR_USER: '', + MIRROR_TOKEN: '', + } + if (currentToken) { + const vaultSecret = await vaultApi.read('tech/GITLAB_MIRROR', { throwIfNoEntry: false }) as { data: GitlabMirrorSecret } + if (vaultSecret) { + try { + const group = await this.getProjectGroup() + if (!group) throw new Error('Group not created yet') + + const res = await fetch(`${config().internalUrl}/api/v4/groups/${group.id}`, { + headers: { 'PRIVATE-TOKEN': vaultSecret.data.MIRROR_TOKEN }, + }) + + if (res.ok) { + return vaultSecret.data // valid token hence early exit + } + + throw new Error('Invalid token') + } catch (error) { + logger.warn({ action: 'getProjectMirrorCreds', err: error }, 'Mirror token invalid, revoking project token') + await this.revokeProjectToken(currentToken.id) + } + } + } + const newToken = await this.createProjectToken(tokenName, ['write_repository', 'read_repository', 'read_api']) + creds.MIRROR_TOKEN = newToken.token + creds.MIRROR_USER = newToken.name + await vaultApi.write(creds, 'tech/GITLAB_MIRROR') + return creds + } + + public async getProjectId(projectName: string) { + logger.debug({ action: 'getProjectId', projectName, projectSlug: this.project.slug }, 'Look up project id') + const projectGroup = await this.getProjectGroup() + if (!projectGroup) throw new Error(`Gitlab inaccessible, impossible de trouver le groupe ${this.project.slug}`) + + const fast = await find( + offsetPaginate(opts => this.api.Groups.allProjects(projectGroup.id, { + ...customAttributesFilter(managedByConsoleCustomAttributeKey, 'true'), + search: projectName, + simple: true, + ...opts, + })), + repo => repo.name === projectName, + ) + + const project = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allProjects(projectGroup.id, { + search: projectName, + simple: true, + ...opts, + })), + repo => repo.name === projectName, + ) + + return project?.id + } + + public async getProjectById(projectId: number) { + return this.api.Projects.show(projectId) + } + + public async getOrCreateInfraProject(zone: string) { + return await this.zoneApi.getOrCreateInfraProject(zone) + } + + public async getProjectToken(tokenName: string) { + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + return find(offsetPaginate(opts => this.api.GroupAccessTokens.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE }), token => token.name === tokenName) + } + + public async createProjectToken(tokenName: string, scopes: AccessTokenScopes[]) { + logger.info({ action: 'createProjectToken', tokenName, projectSlug: this.project.slug }, 'Create project access token') + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + const expiryDate = new Date() + expiryDate.setFullYear(expiryDate.getFullYear() + 1) + return this.api.GroupAccessTokens.create(group.id, tokenName, scopes, expiryDate.toLocaleDateString('en-CA')) + } + + public async revokeProjectToken(tokenId: number) { + logger.info({ action: 'revokeProjectToken', tokenId, projectSlug: this.project.slug }, 'Revoke project access token') + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + return this.api.GroupAccessTokens.revoke(group.id, tokenId) + } + + // Triggers + public async getMirrorProjectTriggerToken(vaultApi: VaultProjectApi) { + logger.debug({ action: 'getMirrorProjectTriggerToken', projectSlug: this.project.slug }, 'Get mirror project trigger token') + const tokenDescription = 'mirroring-from-external-repo' + const gitlabRepositories = await this.listRepositories() + const mirrorRepo = gitlabRepositories.find(repo => repo.name === internalMirrorRepoName) + if (!mirrorRepo) throw new Error('Don\'t know how mirror repo could not exist') + const currentTriggerToken = await find(offsetPaginate(opts => this.api.PipelineTriggerTokens.all(mirrorRepo.id, opts), { perPage: MAX_PAGINATION_PER_PAGE }), token => token.description === tokenDescription) + + const tokenVaultSecret = await vaultApi.read('GITLAB', { throwIfNoEntry: false }) + + if (currentTriggerToken && !tokenVaultSecret?.data?.GIT_MIRROR_TOKEN) { + await this.api.PipelineTriggerTokens.remove(mirrorRepo.id, currentTriggerToken.id) + } + const triggerToken = await this.api.PipelineTriggerTokens.create(mirrorRepo.id, tokenDescription) + return { token: triggerToken.token, repoId: mirrorRepo.id } + } + + // Repositories + public async getPublicRepoUrl(repoName: string) { + return `${await this.getPublicGroupUrl()}/${repoName}.git` + } + + public async getInternalRepoUrl(repoName: string) { + return `${await this.getInternalGroupUrl()}/${repoName}.git` + } + + public async listRepositories() { + const group = await this.getOrCreateProjectGroup() + const projects = await getAll(offsetPaginate(opts => this.api.Groups.allProjects(group.id, { simple: false, ...opts }), { perPage: MAX_PAGINATION_PER_PAGE })) // to refactor with https://github.com/jdalrymple/gitbeaker/pull/3624 + return Promise.all(projects.map(async (project) => { + if (this.specialRepositories.includes(project.name) && (!project.topics || !project.topics.includes(pluginManagedTopic))) { + return this.api.Projects.edit(project.id, { topics: project.topics ? [...project.topics, pluginManagedTopic] : [pluginManagedTopic] }) + } + return project + })) + } + + public async createEmptyProjectRepository({ repoName, description, clone }: CreateEmptyRepositoryArgs & { clone?: boolean }) { + logger.info({ action: 'createEmptyProjectRepository', repoName, projectSlug: this.project.slug, clone }, 'Create empty project repository') + const namespaceId = (await this.getOrCreateProjectGroup()).id + return this.createEmptyRepository({ + repoName, + groupId: namespaceId, + description, + ciConfigPath: clone ? '.gitlab-ci-dso.yml' : undefined, + createFirstCommit: !clone, + }) + } + + // Special Repositories + public async getSpecialRepositories(): Promise { + return this.specialRepositories + } + + public async addSpecialRepositories(name: string) { + logger.debug({ action: 'addSpecialRepositories', name, projectSlug: this.project.slug }, 'Register special repository') + if (!this.specialRepositories.includes(name)) { + this.specialRepositories.push(name) + } + } + + // Group members + public async getGroupMembers() { + const group = await this.getOrCreateProjectGroup() + return getAll(offsetPaginate(opts => this.api.GroupMembers.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async addGroupMember(userId: number, accessLevel: AccessLevelAllowed = AccessLevel.DEVELOPER): Promise { + logger.info({ action: 'addGroupMember', accessLevel, projectSlug: this.project.slug }, 'Add group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.add(group.id, userId, accessLevel) + } + + public async editGroupMember(userId: number, accessLevel: AccessLevelAllowed = AccessLevel.DEVELOPER): Promise { + logger.info({ action: 'editGroupMember', accessLevel, projectSlug: this.project.slug }, 'Edit group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.edit(group.id, userId, accessLevel) + } + + public async removeGroupMember(userId: number) { + logger.info({ action: 'removeGroupMember', projectSlug: this.project.slug }, 'Remove group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.remove(group.id, userId) + } + + // CI Variables + public async getGitlabGroupVariables(): Promise { + const group = await this.getOrCreateProjectGroup() + return await getAll(offsetPaginate(opts => this.api.GroupVariables.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async setGitlabGroupVariable(listVars: VariableSchema[], toSetVariable: VariableSchema): Promise { + const group = await this.getOrCreateProjectGroup() + const currentVariable = listVars.find(v => v.key === toSetVariable.key) + if (currentVariable) { + if ( + currentVariable.masked !== toSetVariable.masked + || currentVariable.value !== toSetVariable.value + || currentVariable.protected !== toSetVariable.protected + || currentVariable.variable_type !== toSetVariable.variable_type + ) { + await this.api.GroupVariables.edit( + group.id, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + filter: { environment_scope: '*' }, + }, + ) + return 'updated' + } + return 'already up-to-date' + } + await this.api.GroupVariables.create( + group.id, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + + }, + ) + return 'created' + } + + public async getGitlabRepoVariables(repoId: number): Promise { + return await getAll(offsetPaginate(opts => this.api.ProjectVariables.all(repoId, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async setGitlabRepoVariable(repoId: number, listVars: VariableSchema[], toSetVariable: ProjectVariableSchema): Promise { + const currentVariable = listVars.find(v => v.key === toSetVariable.key) + if (currentVariable) { + if ( + currentVariable.masked !== toSetVariable.masked + || currentVariable.value !== toSetVariable.value + || currentVariable.protected !== toSetVariable.protected + || currentVariable.variable_type !== toSetVariable.variable_type + ) { + await this.api.ProjectVariables.edit( + repoId, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + filter: { + environment_scope: toSetVariable.environment_scope, + }, + }, + ) + return 'updated' + } + return 'already up-to-date' + } + await this.api.ProjectVariables.create( + repoId, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + }, + ) + return 'created' + } + + // Mirror + public async triggerMirror(targetRepo: string, syncAllBranches: boolean, branchName?: string) { + logger.info({ action: 'triggerMirror', targetRepo, syncAllBranches, branchName, projectSlug: this.project.slug }, 'Trigger repository mirror') + if ((await this.getSpecialRepositories()).includes(targetRepo)) { + throw new Error('User requested for invalid mirroring') + } + const repos = await this.listRepositories() + const { mirror, target }: RepoSelect = repos.reduce((acc, repository) => { + if (repository.name === 'mirror') { + acc.mirror = repository + } + if (repository.name === targetRepo) { + acc.target = repository + } + return acc + }, {} as RepoSelect) + if (!mirror) throw new Error('Unable to find mirror repository') + if (!target) throw new Error('Unable to find target repository') + return this.api.Pipelines.create(mirror.id, 'main', { + variables: [ + { + key: 'SYNC_ALL', + value: syncAllBranches.toString(), + }, + { + key: 'GIT_BRANCH_DEPLOY', + value: branchName ?? '', + }, + { + key: 'PROJECT_NAME', + value: targetRepo, + }, + ], + }) + } +} diff --git a/.jjconflict-base-0/plugins/gitlab/src/config.ts b/.jjconflict-base-0/plugins/gitlab/src/config.ts new file mode 100644 index 0000000000..be14dda99c --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/config.ts @@ -0,0 +1,26 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + token: string + projectsRootDir: string + constructor() { + this.token = requiredEnv('GITLAB_TOKEN') + this.publicUrl = removeTrailingSlash(requiredEnv('GITLAB_URL')) + this.projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') + this.internalUrl = process.env.GITLAB_INTERNAL_URL + ? removeTrailingSlash(process.env.GITLAB_INTERNAL_URL) + : this.publicUrl + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-base-0/plugins/gitlab/src/custom-attributes.ts b/.jjconflict-base-0/plugins/gitlab/src/custom-attributes.ts new file mode 100644 index 0000000000..f9ba81bf56 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/custom-attributes.ts @@ -0,0 +1,31 @@ +import { logger } from './logger.js' +import { getApi } from './utils.js' + +export const groupRootCustomAttributeKey = 'cpn_projects_root_dir' +export const infraGroupCustomAttributeKey = 'cpn_infra_group' +export const projectGroupCustomAttributeKey = 'cpn_project_slug' +export const userIdCustomAttributeKey = 'cpn_user_id' +export const managedByConsoleCustomAttributeKey = 'cpn_managed_by_console' + +export function customAttributesFilter(key: string, value: string) { + return { [`custom_attributes[${key}]`]: value } as Record +} + +export async function upsertCustomAttribute(resource: 'groups' | 'projects' | 'users', id: number, key: string, value: string): Promise { + logger.info({ action: 'upsertCustomAttribute', resource, id, key }, 'Upsert custom attribute') + logger.debug({ action: 'upsertCustomAttribute', resource, id, key }, 'Upsert custom attribute details') + const api = getApi() + try { + if (resource === 'groups') { + await api.GroupCustomAttributes.set(id, key, value) + } else if (resource === 'projects') { + await api.ProjectCustomAttributes.set(id, key, value) + } else { + await api.UserCustomAttributes.set(id, key, value) + } + } catch (err) { + logger.warn({ action: 'upsertCustomAttribute', resource, id, key, err }, 'Failed to upsert custom attribute') + throw err + } + logger.info({ action: 'upsertCustomAttribute', resource, id, key }, 'Custom attribute upserted') +} diff --git a/.jjconflict-base-0/plugins/gitlab/src/env.d.ts b/.jjconflict-base-0/plugins/gitlab/src/env.d.ts new file mode 100644 index 0000000000..46f7f90d7c --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-base-0/plugins/gitlab/src/functions.ts b/.jjconflict-base-0/plugins/gitlab/src/functions.ts new file mode 100644 index 0000000000..392245d2da --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/functions.ts @@ -0,0 +1,374 @@ +import type { AdminRole, ClusterObject, PluginResult, Project, ProjectLite, ProjectMember, StepCall, UniqueRepo, ZoneObject } from '@cpn-console/hooks' +import type { GitlabProjectApi } from './class.js' +import type { VaultSecrets } from './utils.js' +import { okStatus, specificallyDisabled } from '@cpn-console/hooks' +import config from './config.js' +import { deleteGroup } from './group.js' +import { + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, +} from './infos.js' +import { ensureGroup } from './members.js' +import { ensureRepositories } from './repositories.js' +import { createUsername, getUser, upsertUser } from './user.js' +import { cleanGitlabError } from './utils.js' + +// Check +export const checkApi: StepCall = async (payload) => { + try { + const { users } = payload.args + for (const user of users) { + const userInfos = await getUser({ ...user, username: createUsername(user.email) }) + if (userInfos?.id === 1) { + return { + status: { + result: 'KO', + message: 'Gitlab notify: User 1 (root) should not use Console', + }, + } + } + } + + return { + status: { + result: 'OK', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const getDsoProjectSecrets: StepCall = async (payload) => { + try { + if (!specificallyDisabled(payload.config.gitlab?.displayTriggerHint)) { + // TODO déplacer les secrets dans un dossier pour tout lister plutôt que de sélectionner dans le code + const gitlab = (await payload.apis.vault.read('GITLAB')).data as VaultSecrets['GITLAB'] + /* eslint-disable no-template-curly-in-string */ + const curlCommand = [ + 'curl -X POST --fail', + '-F token=\${GIT_MIRROR_TOKEN}', + '-F ref=main', + '-F variables[GIT_BRANCH_DEPLOY]=\${BRANCH_TO_SYNC}', + '-F variables[PROJECT_NAME]=\${REPOSITORY_NAME}', + `"${config().publicUrl}/api/v4/projects/${gitlab.GIT_MIRROR_PROJECT_ID}/trigger/pipeline"`, + ] + /* eslint-enable */ + const secrets: Record = { + GIT_MIRROR_PROJECT_ID: String(gitlab.GIT_MIRROR_PROJECT_ID), + GIT_MIRROR_TOKEN: gitlab.GIT_MIRROR_TOKEN, + 'CURL COMMAND': curlCommand.join(' \\\n '), + } + + return { + status: { + result: 'OK', + message: 'secret retrieved', + }, + secrets, + } + } + return { + status: { + result: 'OK', + message: 'This feature is disabled', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'OK', + message: 'No secrets found for this project', + }, + } + } +} + +export const upsertDsoProject: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + }, + } + try { + const project = payload.args + const { gitlab: gitlabApi, vault: vaultApi } = payload.apis + + await gitlabApi.getOrCreateProjectGroup() + + await Promise.all(project.users.map(user => + ensureGroup(gitlabApi, project, user, payload.config), + )) + + const projectMirrorCreds = await gitlabApi.getProjectMirrorCreds(vaultApi) + await ensureRepositories(gitlabApi, project, vaultApi, { + botAccount: projectMirrorCreds.MIRROR_USER, + token: projectMirrorCreds.MIRROR_TOKEN, + }) + + const destroySecrets = (await vaultApi.list()) + .filter(path => path.endsWith('-mirror')) + .map(path => path.slice(1, path.length - 7)) + .filter(repoName => !project.repositories.some(projectRepo => projectRepo.internalRepoName === repoName)) + + await Promise.all(destroySecrets + .map(repoName => vaultApi.destroy(`${repoName}-mirror`)), + ) + + const mirrorTriggerToken = await gitlabApi.getMirrorProjectTriggerToken(vaultApi) + + const gitlabSecret: VaultSecrets['GITLAB'] = { + PROJECT_SLUG: project.slug, + GIT_MIRROR_PROJECT_ID: mirrorTriggerToken.repoId, + GIT_MIRROR_TOKEN: mirrorTriggerToken.token, + } + + await vaultApi.write(gitlabSecret, 'GITLAB') + + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const deleteDsoProject: StepCall = async (payload) => { + try { + const group = await payload.apis.gitlab.getProjectGroup() + if (group) await deleteGroup(group.id, group.full_path) + + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +export const syncRepository: StepCall = async (payload) => { + const targetRepo = payload.args.repo + const gitlabApi = payload.apis.gitlab + try { + await gitlabApi.triggerMirror(targetRepo.internalRepoName, targetRepo.syncAllBranches, targetRepo.branchName) + return { + status: { + result: 'OK', + message: 'Ci launched', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'Failed to trigger sync', + }, + } + } +} + +export const upsertZone: StepCall = async (payload) => { + const returnResult: PluginResult = okStatus + try { + const gitlabApi = payload.apis.gitlab + await gitlabApi.getOrCreateInfraProject(payload.args.slug) + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const deleteZone: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + message: 'Deleted', + }, + } + try { + const gitlabApi = payload.apis.gitlab + const zoneRepo = await gitlabApi.getOrCreateInfraProject(payload.args.slug) + await gitlabApi.deleteRepository(zoneRepo.id, zoneRepo.path_with_namespace) + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const commitFiles: StepCall = async (payload) => { + const returnResult = payload.results.gitlab + try { + const filesUpdated = await payload.apis.gitlab.commitFiles() + + returnResult.status.message = `${filesUpdated} file${filesUpdated > 1 ? 's' : ''} updated` + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Failed to commit files' + return returnResult + } +} + +export const upsertAdminRole: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH + const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? DEFAULT_AUDITOR_GROUP_PATH + + const isAdmin = role.oidcGroup === adminGroupPath ? true : undefined + const isAuditor = role.oidcGroup === auditorGroupPath ? true : undefined + + if (isAdmin === undefined && isAuditor === undefined) { + return { + status: { + result: 'OK', + message: 'Not a managed role for GitLab plugin', + }, + } + } + + for (const member of role.members) { + await upsertUser(member, isAdmin, isAuditor) + } + + return { + status: { + result: 'OK', + message: 'Members synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error occured while syncing admin role', + }, + } + } +} + +export const deleteAdminRole: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH + const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? DEFAULT_AUDITOR_GROUP_PATH + + const isAdmin = role.oidcGroup === adminGroupPath ? false : undefined + const isAuditor = role.oidcGroup === auditorGroupPath ? false : undefined + + if (isAdmin === undefined && isAuditor === undefined) { + return { + status: { + result: 'OK', + message: 'Not a managed role for GitLab plugin', + }, + } + } + + for (const member of role.members) { + await upsertUser(member, isAdmin, isAuditor) + } + + return { + status: { + result: 'OK', + message: 'Admin role deleted and members synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error occured while deleting admin role', + }, + } + } +} + +export const upsertProjectMember: StepCall = async (payload) => { + const member = payload.args + const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon + + try { + await Promise.all(member.project.users.map(user => + ensureGroup(gitlabApi, member.project, user, payload.config), + )) + + return { + status: { + result: 'OK', + message: 'Member synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error happened while syncing project member', + }, + } + } +} + +export const deleteProjectMember: StepCall = async (payload) => { + const member = payload.args + const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon + + try { + const userInfos = await getUser({ ...member, id: member.userId, username: createUsername(member.email) }) + if (!userInfos) { + return { + status: { + result: 'OK', + message: 'User not found in GitLab', + }, + } + } + + await gitlabApi.removeGroupMember(userInfos.id) + + return { + status: { + result: 'OK', + message: 'Member deleted', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error happened while deleting project member', + }, + } + } +} diff --git a/.jjconflict-base-0/plugins/gitlab/src/group.ts b/.jjconflict-base-0/plugins/gitlab/src/group.ts new file mode 100644 index 0000000000..eabf036561 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/group.ts @@ -0,0 +1,7 @@ +import { getApi } from './utils.js' + +export async function deleteGroup(groupId: number, fullPath: string) { + const api = getApi() + await api.Groups.remove(groupId) // Marks for deletion + return api.Groups.remove(groupId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${groupId}` }) // Effective deletion +} diff --git a/.jjconflict-base-0/plugins/gitlab/src/index.ts b/.jjconflict-base-0/plugins/gitlab/src/index.ts new file mode 100644 index 0000000000..1eb773c6c6 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/index.ts @@ -0,0 +1,116 @@ +import type { DeclareModuleGenerator, DefaultArgs, Plugin, Project, ProjectMember, UniqueRepo, ZoneObject } from '@cpn-console/hooks' +import { GitlabProjectApi, GitlabZoneApi } from './class.js' +import { + checkApi, + commitFiles, + deleteDsoProject, + deleteProjectMember, + deleteZone, + getDsoProjectSecrets, + syncRepository, + upsertAdminRole, + upsertDsoProject, + upsertProjectMember, + upsertZone, +} from './functions.js' +import infos from './infos.js' +import { logger } from './logger.js' +import monitor from './monitor.js' +import { getOrCreateGroupRoot } from './utils.js' + +const onlyApi = { api: (project: Project | UniqueRepo) => new GitlabProjectApi(project) } + +function start() { + getOrCreateGroupRoot().catch((error) => { + logger.error({ action: 'start', err: error }, 'Hook failed') + throw new Error('Error at gitlab plugin start') + }) +} + +export const plugin: Plugin = { + infos, + subscribedHooks: { + deleteProject: { + ...onlyApi, + steps: { + main: deleteDsoProject, + post: commitFiles, + }, + }, + upsertProject: { + ...onlyApi, + steps: { + check: checkApi, + main: upsertDsoProject, + post: commitFiles, + }, + }, + getProjectSecrets: { steps: { main: getDsoProjectSecrets } }, + syncRepository: { + ...onlyApi, + steps: { + main: syncRepository, + post: commitFiles, + }, + }, + upsertCluster: { + api: () => new GitlabZoneApi(), + steps: { + post: commitFiles, + }, + }, + deleteCluster: { + api: () => new GitlabZoneApi(), + steps: { + post: commitFiles, + }, + }, + upsertZone: { + api: () => new GitlabZoneApi(), + steps: { + pre: upsertZone, + post: commitFiles, + }, + }, + deleteZone: { + api: () => new GitlabZoneApi(), + steps: { + main: deleteZone, + }, + }, + upsertAdminRole: { + steps: { + main: upsertAdminRole, + }, + }, + upsertProjectMember: { + api: member => new GitlabProjectApi(member.project), + steps: { + main: upsertProjectMember, + }, + }, + deleteProjectMember: { + api: member => new GitlabProjectApi(member.project), + steps: { + post: deleteProjectMember, + }, + }, + }, + monitor, + start, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + gitlab: Args extends Project | UniqueRepo | ProjectMember['project'] + ? GitlabProjectApi + : Args extends ZoneObject + ? GitlabZoneApi + : never + } + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} + interface PluginResult { + warnReasons?: string[] + } +} diff --git a/.jjconflict-base-0/plugins/gitlab/src/infos.ts b/.jjconflict-base-0/plugins/gitlab/src/infos.ts new file mode 100644 index 0000000000..38f5d10eac --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/infos.ts @@ -0,0 +1,108 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import config from './config.js' + +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer,/console/devops' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly' + +const infos = { + name: 'gitlab', + to: ({ project }) => `${config().publicUrl}/${config().projectsRootDir}/${project.slug}`, + title: 'Gitlab', + imgSrc: '/img/gitlab.svg', + description: 'GitLab est un service d\'hébergement de code source et de pipeline CI/CD', + config: { + global: [ + { + kind: 'switch', + key: 'purge', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Purger les utilisateurs non synchronisés', + value: DISABLED, + description: 'Purger les utilisateurs non synchronisés de GitLab lors de la synchronisation', + }, + { + kind: 'switch', + key: 'displayTriggerHint', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Afficher l\'aide de déclenchement de pipeline', + value: ENABLED, + description: 'Afficher l\'aide de déclenchement de pipeline aux utilisateurs lorsqu\'ils souhaitent afficher les secrets du projet', + }, + { + kind: 'text', + key: 'adminGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Admin', + value: DEFAULT_ADMIN_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'administrateur GitLab', + placeholder: DEFAULT_ADMIN_GROUP_PATH, + }, + { + kind: 'text', + key: 'auditorGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Auditeur', + value: DEFAULT_AUDITOR_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'auditeur GitLab', + placeholder: DEFAULT_AUDITOR_GROUP_PATH, + }, + { + kind: 'text', + key: 'projectMaintainerGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Maintainer', + value: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Maintainer', + placeholder: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectDeveloperGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Developer', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Developer', + placeholder: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectReporterGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Reporter', + value: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Reporter', + placeholder: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + }, + ], + project: [], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-base-0/plugins/gitlab/src/logger.ts b/.jjconflict-base-0/plugins/gitlab/src/logger.ts new file mode 100644 index 0000000000..8d4c5caea7 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'gitlab' }) diff --git a/.jjconflict-base-0/plugins/gitlab/src/members.ts b/.jjconflict-base-0/plugins/gitlab/src/members.ts new file mode 100644 index 0000000000..2e558b18a5 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/members.ts @@ -0,0 +1,69 @@ +import type { Config, Project, Role, UserObject } from '@cpn-console/hooks' +import type { GitlabProjectApi } from './class.js' +import { AccessLevel } from '@gitbeaker/core' +import { + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, +} from './infos.js' +import { createUsername, upsertUser } from './user.js' +import { matchRole } from './utils.js' + +export function getGroupAccessLevelFromProjectRole(project: Project, user: UserObject, config: Config) { + const projectReporterGroupPathSuffixes = (config.gitlab?.projectReporterGroupPathSuffix ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX).split(',') + const projectDeveloperGroupPathSuffixes = (config.gitlab?.projectDeveloperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX).split(',') + const projectMaintainerGroupPathSuffixes = (config.gitlab?.projectMaintainerGroupPathSuffix ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX).split(',') + + const getAccessLevel = (role: Role): number | null => { + // TODO: update to AccessLevel.GUEST once we migrated pre-fine grained + // projects members to developer access level via database migrations + if (!role.oidcGroup) return AccessLevel.DEVELOPER + if (matchRole(project.slug, role.oidcGroup, projectReporterGroupPathSuffixes)) return AccessLevel.REPORTER + if (matchRole(project.slug, role.oidcGroup, projectDeveloperGroupPathSuffixes)) return AccessLevel.DEVELOPER + if (matchRole(project.slug, role.oidcGroup, projectMaintainerGroupPathSuffixes)) return AccessLevel.MAINTAINER + return null + } + + // TODO: update to AccessLevel.GUEST once we migrated pre-fine grained + // projects members to developer access level via database migrations + return project.roles.reduce((highestAccessLevel, role) => { + if (role.users.some(userRole => userRole.id === user.id)) { + const level = getAccessLevel(role) + if (level && level > (highestAccessLevel ?? 0)) return level + } + return highestAccessLevel + }, AccessLevel.DEVELOPER) +} + +export function getGroupAccessLevel(project: Project, user: UserObject, config: Config): number | null { + if (project.owner.id === user.id) return AccessLevel.OWNER + return getGroupAccessLevelFromProjectRole(project, user, config) +} + +export async function ensureGroup( + gitlabApi: GitlabProjectApi, + project: Project, + user: UserObject, + config: Config, +) { + const gitlabUser = await upsertUser({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }) + + const groupMembers = await gitlabApi.getGroupMembers() + const existingMember = groupMembers.find(m => m.username === createUsername(user.email)) + const maxAccessLevel = getGroupAccessLevel(project, user, config) + + if (existingMember && maxAccessLevel) { + if (existingMember.access_level !== maxAccessLevel) { + await gitlabApi.editGroupMember(gitlabUser.id, maxAccessLevel) + } + } else if (maxAccessLevel) { + await gitlabApi.addGroupMember(gitlabUser.id, maxAccessLevel) + } else if (existingMember) { + await gitlabApi.removeGroupMember(gitlabUser.id) + } +} diff --git a/.jjconflict-base-0/plugins/gitlab/src/monitor.ts b/.jjconflict-base-0/plugins/gitlab/src/monitor.ts new file mode 100644 index 0000000000..d190d6ed51 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/monitor.ts @@ -0,0 +1,52 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import config from './config.js' + +enum HealthStatus { + failed = 'failed', + ok = 'ok', +} +type GitlabRes = Record }> +const coreComponents = ['gitaly_check', 'master_check', 'db_check', 'sessions_check'] + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(`${config().internalUrl}/-/readiness?all=1`, { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + const data = res.data as GitlabRes + const failedComponents = Object.entries(data) + .reduce((acc, [name, value]) => { + if (value.status === HealthStatus.failed) return [...acc, name] + return acc + }, [] as string[]) + const failedCoreComponents = failedComponents.filter(name => coreComponents.includes(name)) + + if (failedCoreComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } + if (failedComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.WARNING + instance.lastStatus.message = 'Service dégradé' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Fatal Error' + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-base-0/plugins/gitlab/src/project.ts b/.jjconflict-base-0/plugins/gitlab/src/project.ts new file mode 100644 index 0000000000..df3dc8e67e --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/project.ts @@ -0,0 +1,52 @@ +import * as fs from 'node:fs/promises' +import path from 'node:path' + +import { getApi } from './utils.js' + +export async function provisionMirror(repoId: number) { + const baseDir = path.resolve(import.meta.url, '../../files/').split(':')[1] + + const gitlabCiYml = ( + await fs.readFile(`${baseDir}/.gitlab-ci.yml`) + ).toString() + const mirrorSh = (await fs.readFile(`${baseDir}/mirror.sh`)).toString() + + const mirrorFirstActions: CommitAction[] = [ + { + action: 'create', + filePath: '.gitlab-ci.yml', + content: gitlabCiYml, + execute_filemode: false, + }, + { + action: 'create', + filePath: 'mirror.sh', + content: mirrorSh, + execute_filemode: true, + }, + ] + const api = getApi() + await api.Commits.create( + repoId, + 'main', + 'ci: :construction_worker: first mirror', + mirrorFirstActions, + ) +} + +interface CommitAction { + /** The action to perform */ + action: 'create' | 'delete' | 'move' | 'update' | 'chmod' + /** Full path to the file. Ex. lib/class.rb */ + filePath: string + /** Original full path to the file being moved.Ex.lib / class1.rb */ + previousPath?: string + /** File content, required for all except delete. Optional for move */ + content?: string + /** text or base64. text is default. */ + encoding?: string + /** Last known file commit id. Will be only considered in update, move and delete actions. */ + lastCommitId?: string + /** When true/false enables/disables the execute flag on the file. Only considered for chmod action. */ + execute_filemode?: boolean +} diff --git a/.jjconflict-base-0/plugins/gitlab/src/repositories.ts b/.jjconflict-base-0/plugins/gitlab/src/repositories.ts new file mode 100644 index 0000000000..00797cf446 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/repositories.ts @@ -0,0 +1,93 @@ +import type { Project, Repository } from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { CondensedProjectSchema, ProjectSchema } from '@gitbeaker/rest' +import type { GitlabProjectApi } from './class.js' +import { shallowEqual } from '@cpn-console/shared' +import { pluginManagedTopic } from './class.js' +import { provisionMirror } from './project.js' +import { infraAppsRepoName, internalMirrorRepoName } from './utils.js' + +interface ProjectMirrorCreds { + botAccount: string + token: string +} + +export async function ensureRepositories(gitlabApi: GitlabProjectApi, project: Project, vaultApi: VaultProjectApi, projectMirrorCreds: ProjectMirrorCreds) { + const specialRepos = await gitlabApi.getSpecialRepositories() + const gitlabRepositories = await gitlabApi.listRepositories() + + const promises: Promise[] = [ + // delete excess repositories + ...gitlabRepositories + .filter(gitlabRepository => ( + !specialRepos.includes(gitlabRepository.name) + && !gitlabRepository.topics?.includes(pluginManagedTopic) + && !project.repositories.some(repo => repo.internalRepoName === gitlabRepository.name))) + .map(gitlabRepository => gitlabApi.deleteRepository(gitlabRepository.id, gitlabRepository.path_with_namespace)), + // create missing repositories + ...project.repositories.map(repo => ensureRepositoryExists(gitlabRepositories, repo, gitlabApi, projectMirrorCreds, vaultApi)), + ] + + if (!gitlabRepositories.some(repo => repo.name === infraAppsRepoName)) { + promises.push( + gitlabApi.createEmptyProjectRepository({ repoName: infraAppsRepoName, clone: false }), + ) + } + if (!gitlabRepositories.some(repo => repo.name === internalMirrorRepoName)) { + promises.push( + gitlabApi.createEmptyProjectRepository({ repoName: internalMirrorRepoName, clone: false }) + .then(mirrorRepo => provisionMirror(mirrorRepo.id)), + ) + } + + await Promise.all(promises) +} + +const urnRegexp = /:\/\/(.*)/s + +async function ensureRepositoryExists(gitlabRepositories: CondensedProjectSchema[], repository: Repository, gitlabApi: GitlabProjectApi, projectMirrorCreds: ProjectMirrorCreds, vaultApi: VaultProjectApi) { + const gitlabRepository: CondensedProjectSchema | ProjectSchema | void = gitlabRepositories.find(gitlabRepository => gitlabRepository.name === repository.internalRepoName) + const externalRepoUrn = repository.externalRepoUrl.split(urnRegexp)[1] + const vaultCredsPath = `${repository.internalRepoName}-mirror` + const currentVaultSecret = await vaultApi.read(vaultCredsPath, { throwIfNoEntry: false }) + + if (!gitlabRepository) { + await gitlabApi.createEmptyProjectRepository({ + repoName: repository.internalRepoName, + description: undefined, + clone: !!repository.externalRepoUrl, + }) + } + + if (!repository.externalRepoUrl) { + return currentVaultSecret && vaultApi.destroy(vaultCredsPath) + } + + let gitInputUser: string | undefined + let gitInputPassword: string | undefined + if (currentVaultSecret?.data) { + gitInputUser = currentVaultSecret.data.GIT_INPUT_USER + gitInputPassword = currentVaultSecret.data.GIT_INPUT_PASSWORD + } + + const internalRepoUrl = await gitlabApi.getInternalRepoUrl(repository.internalRepoName) + + const mirrorSecretData = { + GIT_INPUT_URL: externalRepoUrn, + GIT_INPUT_USER: repository.isPrivate + ? (repository.newCreds?.username || gitInputUser) + : undefined, + GIT_INPUT_PASSWORD: repository.isPrivate + ? (repository.newCreds?.token || gitInputPassword) + : undefined, + GIT_OUTPUT_URL: internalRepoUrl.split(urnRegexp)[1], + GIT_OUTPUT_USER: projectMirrorCreds.botAccount, + GIT_OUTPUT_PASSWORD: projectMirrorCreds.token, + } + if ( + !currentVaultSecret?.data + || !shallowEqual(mirrorSecretData, currentVaultSecret.data) + ) { + await vaultApi.write(mirrorSecretData, vaultCredsPath) + } +} diff --git a/.jjconflict-base-0/plugins/gitlab/src/user.ts b/.jjconflict-base-0/plugins/gitlab/src/user.ts new file mode 100644 index 0000000000..5f638e4bc1 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/user.ts @@ -0,0 +1,107 @@ +import type { UserObject } from '@cpn-console/hooks' +import type { CreateUserOptions, SimpleUserSchema } from '@gitbeaker/rest' +import { upsertCustomAttribute, userIdCustomAttributeKey } from './custom-attributes.js' +import { logger } from './logger.js' +import { find, getApi, offsetPaginate } from './utils.js' + +export function createUsername(email: string) { + const parts = email.split('@') + if (parts.length > 0) { + return parts[0] + } + return email +} + +export async function getUser(user: { email: string, username: string, id: string }): Promise { + const api = getApi() + + const isUser = (gitlabUser: SimpleUserSchema) => + gitlabUser?.externUid === user.id + || gitlabUser?.externUid === user.email + || gitlabUser.email === user.email + || gitlabUser.username === user.username + + const fast = await find( + offsetPaginate(opts => api.Users.all({ + externUid: user.email, + provider: 'openid_connect', + orderBy: 'username', + asAdmin: true, + ...opts, + })), + isUser, + ) + + if (!fast) { + logger.debug({ action: 'getUser', user }, 'User not found in fast search') + } + + return fast ?? await find( + offsetPaginate(opts => api.Users.all({ + search: user.username, + asAdmin: true, + ...opts, + })), + isUser, + ) +} + +export async function upsertUser(user: UserObject, isAdmin?: boolean, isAuditor?: boolean): Promise { + const api = getApi() + const username = createUsername(user.email) + const existingUser = await getUser({ ...user, username }) + + const userDefinitionBase: CreateUserOptions = { + // required options + name: `${user.firstName} ${user.lastName}`, + username, + email: user.email, + // sso options + externUid: user.email, + provider: 'openid_connect', + admin: isAdmin, + auditor: isAuditor, + } + + if (existingUser) { + const incorrectProps = Object.entries(userDefinitionBase).reduce((acc, [key, value]) => { + if (existingUser[key] !== value) { + acc.push({ + key, + curr: existingUser[key], + new: value, + }) + } + return acc + }, [] as { key: string, curr: any, new: any }[]) + + if (incorrectProps.length) { + logger.debug({ action: 'upsertUser', changes: incorrectProps }, 'User properties differ from expected') + try { + await api.Users.edit(existingUser.id, userDefinitionBase) + } catch (err) { + logger.error({ action: 'upsertUser', err }, 'Failed to update user') + } + } + try { + await upsertCustomAttribute('users', existingUser.id, userIdCustomAttributeKey, user.id) + } catch (err) { + logger.debug({ action: 'upsertUser', userId: existingUser.id, err }, 'Failed to upsert user custom attribute') + } + return existingUser + } + + const created = await api.Users.create({ + ...userDefinitionBase, + canCreateGroup: false, + forceRandomPassword: true, + projectsLimit: 0, + skipConfirmation: true, + }) + try { + await upsertCustomAttribute('users', created.id, userIdCustomAttributeKey, user.id) + } catch (err) { + logger.debug({ action: 'upsertUser', userId: created.id, err }, 'Failed to upsert user custom attribute') + } + return created +} diff --git a/.jjconflict-base-0/plugins/gitlab/src/utils.ts b/.jjconflict-base-0/plugins/gitlab/src/utils.ts new file mode 100644 index 0000000000..3dbe69b772 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/src/utils.ts @@ -0,0 +1,198 @@ +import type { BaseRequestOptions, Gitlab as IGitlab, OffsetPagination, PaginationRequestOptions } from '@gitbeaker/core' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { Gitlab } from '@gitbeaker/rest' +import config from './config.js' +import { customAttributesFilter, groupRootCustomAttributeKey, managedByConsoleCustomAttributeKey, upsertCustomAttribute } from './custom-attributes.js' +import { logger } from './logger.js' + +let api: IGitlab | undefined + +let groupRootId: number + +export const MAX_PAGINATION_PER_PAGE = 100 + +export async function getGroupRootId(throwIfNotFound?: true): Promise +export async function getGroupRootId(throwIfNotFound?: false): Promise +export async function getGroupRootId(throwIfNotFound?: boolean): Promise { + const gitlabApi = getApi() + const projectRootDir = config().projectsRootDir + logger.debug({ action: 'getGroupRootId', projectRootDir }, 'Resolve group root id') + if (groupRootId) return groupRootId + const fast = await find( + offsetPaginate(opts => gitlabApi.Groups.all({ + ...customAttributesFilter(groupRootCustomAttributeKey, projectRootDir), + ...opts, + })), + grp => grp.full_path === projectRootDir, + ) + const groupRoot = fast ?? await find( + offsetPaginate(opts => gitlabApi.Groups.all({ + search: projectRootDir, + ...opts, + })), + grp => grp.full_path === projectRootDir, + ) + logger.debug({ action: 'getGroupRootId', groupRootId: groupRoot?.id, groupRootPath: groupRoot?.full_path }, 'Resolved group root') + const searchId = groupRoot?.id + if (typeof searchId === 'undefined') { + if (throwIfNotFound) { + throw new Error(`Gitlab inaccessible, impossible de trouver le groupe RACINE ${projectRootDir}`) + } + return searchId + } + groupRootId = searchId + try { + await upsertCustomAttribute('groups', groupRootId, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', groupRootId, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getGroupRootId', groupRootId, err }, 'Failed to upsert group root custom attribute') + } + return groupRootId +} + +async function createGroupRoot(): Promise { + logger.info({ action: 'createGroupRoot', projectRootDir: config().projectsRootDir }, 'Create group root hierarchy') + const gitlabApi = getApi() + const projectRootDir = config().projectsRootDir + const projectRootDirArray = projectRootDir.split('/') + + const rootGroupPath = projectRootDirArray.shift() + if (!rootGroupPath) { + throw new Error('No projectRootDir available') + } + + let parentGroup = await find(offsetPaginate(opts => gitlabApi.Groups.all({ + search: rootGroupPath, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), grp => grp.full_path === rootGroupPath) ?? await gitlabApi.Groups.create(rootGroupPath, rootGroupPath) + + if (parentGroup.full_path === projectRootDir) { + try { + await upsertCustomAttribute('groups', parentGroup.id, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', parentGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createGroupRoot', groupRootId: parentGroup.id, err }, 'Failed to upsert group root custom attribute') + } + return parentGroup.id + } + + for (const path of projectRootDirArray) { + const futureFullPath = `${parentGroup.full_path}/${path}` + parentGroup = await find(offsetPaginate(opts => gitlabApi.Groups.all({ + search: futureFullPath, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), grp => grp.full_path === futureFullPath) ?? await gitlabApi.Groups.create(path, path, { parentId: parentGroup.id, visibility: 'internal' }) + + if (parentGroup.full_path === projectRootDir) { + try { + await upsertCustomAttribute('groups', parentGroup.id, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', parentGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createGroupRoot', groupRootId: parentGroup.id, err }, 'Failed to upsert group root custom attribute') + } + return parentGroup.id + } + } + throw new Error('No projectRootDir available or is malformed') +} + +export async function getOrCreateGroupRoot(): Promise { + return await getGroupRootId(false) ?? createGroupRoot() +} + +export function getApi(): IGitlab { + api ??= new Gitlab({ token: config().token, host: config().internalUrl }) + return api +} + +export const infraAppsRepoName = 'infra-apps' +export const internalMirrorRepoName = 'mirror' + +export interface VaultSecrets { + GITLAB: { + PROJECT_SLUG: string + GIT_MIRROR_PROJECT_ID: number + GIT_MIRROR_TOKEN: string + } +} + +// eslint-disable-next-line regexp/no-super-linear-backtracking +const keyValueRegExp = /\/\/(.*):(.*)@/g + +export function cleanGitlabError(error: T): T { + if (error instanceof GitbeakerRequestError && error.cause?.description) { + error.cause.description = String(error.cause.description).replaceAll(keyValueRegExp, '//MASKED:MASKED@') + } + return error +} + +export function matchRole(projectSlug: string, roleOidcGroup: string, configuredRolePath: string[]) { + return configuredRolePath.some(path => roleOidcGroup === `/${projectSlug}${path}`) +} + +export interface OffsetPaginateOptions { + startPage?: number + perPage?: number + maxPages?: number +} + +export async function* offsetPaginate( + request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, + options?: OffsetPaginateOptions, +): AsyncGenerator { + let page: number | null = options?.startPage ?? 1 + let pagesFetched = 0 + let total: number = 0 + logger.debug({ action: 'offsetPaginate', page }, 'Pagination start') + while (page !== null) { + if (options?.maxPages && pagesFetched >= options.maxPages) { + page = null + continue + } + try { + const { data, paginationInfo } = await request({ + page, + perPage: options?.perPage, + maxPages: options?.maxPages, + showExpanded: true, + pagination: 'offset', + }) + pagesFetched += 1 + total += data.length + logger.debug( + { action: 'offsetPaginate', page, nextPage: paginationInfo.next, items: data.length, total }, + 'Pagination page fetched', + ) + for (const item of data) { + yield item + } + page = paginationInfo.next + } catch (error) { + logger.error({ action: 'offsetPaginate', page, err: error }, 'Pagination request failed') + throw error + } + } + logger.debug({ action: 'offsetPaginate', total }, 'Pagination done') +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} + +export async function find( + iterable: AsyncIterable, + predicate: (item: T) => boolean, +): Promise { + for await (const item of iterable) { + if (predicate(item)) { + return item + } + } + return undefined +} diff --git a/.jjconflict-base-0/plugins/gitlab/tsconfig.eslint.json b/.jjconflict-base-0/plugins/gitlab/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-0/plugins/gitlab/tsconfig.json b/.jjconflict-base-0/plugins/gitlab/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-0/plugins/gitlab/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-0/plugins/harbor/.gitignore b/.jjconflict-base-0/plugins/harbor/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-0/plugins/harbor/eslint.config.js b/.jjconflict-base-0/plugins/harbor/eslint.config.js new file mode 100644 index 0000000000..3148148bf5 --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/eslint.config.js @@ -0,0 +1,4 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +// Generated by `pnpm run swagger:generate` — never linted or hand-edited. +export default eslintConfigBase.append({ ignores: ['src/api/Api.ts'] }) diff --git a/.jjconflict-base-0/plugins/harbor/package.json b/.jjconflict-base-0/plugins/harbor/package.json new file mode 100644 index 0000000000..ec8a51bb24 --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/package.json @@ -0,0 +1,55 @@ +{ + "name": "@cpn-console/harbor-plugin", + "type": "module", + "version": "2.3.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types", + "utils.d.ts" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./", + "swagger:generate": "node ./scripts/generate-api.mjs" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime", + "bytes": "catalog:runtime", + "cron-validator": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "js-yaml": "catalog:tools", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "swagger-typescript-api": "catalog:build", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/plugins/harbor/scripts/generate-api.mjs b/.jjconflict-base-0/plugins/harbor/scripts/generate-api.mjs new file mode 100644 index 0000000000..2633066ec9 --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/scripts/generate-api.mjs @@ -0,0 +1,37 @@ +/** + * Generates `src/api/Api.ts` from Harbor's OpenAPI/Swagger spec. + * + * The spec is fetched and parsed with `js-yaml` before being handed to + * `swagger-typescript-api`. This is deliberate: swagger-typescript-api v13 + * bundles a strict YAML parser that rejects Harbor's multi-line single-quoted + * `description` scalars, so we parse the YAML ourselves and pass the resulting + * JSON spec object programmatically. + * + * Bump HARBOR_TAG to regenerate against a newer Harbor release. + */ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { generateApi } from 'swagger-typescript-api' + +const HARBOR_TAG = process.env.HARBOR_TAG ?? 'v2.13.5' +const SPEC_URL = `https://raw.githubusercontent.com/goharbor/harbor/${HARBOR_TAG}/api/v2.0/swagger.yaml` + +const outputDir = resolve(dirname(fileURLToPath(import.meta.url)), '../src/api') + +console.log(`Fetching Harbor ${HARBOR_TAG} spec from ${SPEC_URL}`) +const res = await fetch(SPEC_URL) +if (!res.ok) { + throw new Error(`Failed to fetch spec (${res.status} ${res.statusText})`) +} + +const spec = yaml.load(await res.text()) + +await generateApi({ + spec, + output: outputDir, + fileName: 'Api.ts', + httpClientType: 'axios', +}) + +console.log(`Generated ${outputDir}/Api.ts`) diff --git a/.jjconflict-base-0/plugins/harbor/src/api/Api.ts b/.jjconflict-base-0/plugins/harbor/src/api/Api.ts new file mode 100644 index 0000000000..2616919eba --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/src/api/Api.ts @@ -0,0 +1,8030 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +/** The error array that describe the errors got during the handling of request */ +export interface Errors { + errors?: Error[]; +} + +/** a model for all the error response coming from harbor */ +export interface Error { + /** The error code */ + code?: string; + /** The error message */ + message?: string; +} + +export interface Search { + /** Search results of the projects that matched the filter keywords. */ + project?: Project[]; + /** Search results of the repositories that matched the filter keywords. */ + repository?: SearchRepository[]; +} + +export interface SearchRepository { + /** The ID of the project that the repository belongs to */ + project_id?: number; + /** The name of the project that the repository belongs to */ + project_name?: string; + /** The flag to indicate the publicity of the project that the repository belongs to (1 is public, 0 is not) */ + project_public?: boolean; + /** The name of the repository */ + repository_name?: string; + /** The count how many times the repository is pulled */ + pull_count?: number; + /** The count of artifacts in the repository */ + artifact_count?: number; +} + +export interface Repository { + /** + * The ID of the repository + * @format int64 + */ + id?: number; + /** + * The ID of the project that the repository belongs to + * @format int64 + */ + project_id?: number; + /** The name of the repository */ + name?: string; + /** The description of the repository */ + description?: string; + /** + * The count of the artifacts inside the repository + * @format int64 + */ + artifact_count: number; + /** + * The count that the artifact inside the repository pulled + * @format int64 + */ + pull_count: number; + /** + * The creation time of the repository + * @format date-time + */ + creation_time?: string | null; + /** + * The update time of the repository + * @format date-time + */ + update_time?: string; +} + +export interface Artifact { + /** + * The ID of the artifact + * @format int64 + */ + id?: number; + /** The type of the artifact, e.g. image, chart, etc */ + type?: string; + /** The media type of the artifact */ + media_type?: string; + /** The manifest media type of the artifact */ + manifest_media_type?: string; + /** The artifact_type in the manifest of the artifact */ + artifact_type?: string; + /** + * The ID of the project that the artifact belongs to + * @format int64 + */ + project_id?: number; + /** + * The ID of the repository that the artifact belongs to + * @format int64 + */ + repository_id?: number; + /** The name of the repository that the artifact belongs to */ + repository_name?: string; + /** The digest of the artifact */ + digest?: string; + /** + * The size of the artifact + * @format int64 + */ + size?: number; + /** The digest of the icon */ + icon?: string; + /** + * The push time of the artifact + * @format date-time + */ + push_time?: string; + /** + * The latest pull time of the artifact + * @format date-time + */ + pull_time?: string; + extra_attrs?: ExtraAttrs; + annotations?: Annotations; + references?: Reference[]; + tags?: Tag[]; + addition_links?: AdditionLinks; + labels?: Label[]; + /** The overview of the scan result. */ + scan_overview?: ScanOverview; + /** The overview of the generating SBOM progress */ + sbom_overview?: SBOMOverview; + accessories?: Accessory[]; +} + +export interface Tag { + /** + * The ID of the tag + * @format int64 + */ + id?: number; + /** + * The ID of the repository that the tag belongs to + * @format int64 + */ + repository_id?: number; + /** + * The ID of the artifact that the tag attached to + * @format int64 + */ + artifact_id?: number; + /** The name of the tag */ + name?: string; + /** + * The push time of the tag + * @format date-time + */ + push_time?: string; + /** + * The latest pull time of the tag + * @format date-time + */ + pull_time?: string; + /** The immutable status of the tag */ + immutable: boolean; +} + +export type ExtraAttrs = Record; + +export type Annotations = Record; + +export type AdditionLinks = Record; + +export interface AdditionLink { + /** The link of the addition */ + href?: string; + /** Determine whether the link is an absolute URL or not */ + absolute: boolean; +} + +export interface Reference { + /** + * The parent ID of the reference + * @format int64 + */ + parent_id?: number; + /** + * The child ID of the reference + * @format int64 + */ + child_id?: number; + /** The digest of the child artifact */ + child_digest?: string; + platform?: Platform; + annotations?: Annotations; + /** The download URLs */ + urls?: string[]; +} + +export interface Platform { + /** The architecture that the artifact applys to */ + architecture?: string; + /** The OS that the artifact applys to */ + os?: string; + /** The version of the OS that the artifact applys to */ + "'os.version'"?: string; + /** The features of the OS that the artifact applys to */ + "'os.features'"?: string[]; + /** The variant of the CPU */ + variant?: string; +} + +export interface Label { + /** + * The ID of the label + * @format int64 + */ + id?: number; + /** The name the label */ + name?: string; + /** The description the label */ + description?: string; + /** The color the label */ + color?: string; + /** The scope the label */ + scope?: string; + /** + * The ID of project that the label belongs to + * @format int64 + */ + project_id?: number; + /** + * The creation time the label + * @format date-time + */ + creation_time?: string; + /** + * The update time of the label + * @format date-time + */ + update_time?: string; +} + +export interface Scanner { + /** + * Name of the scanner + * @example "Trivy" + */ + name?: string; + /** + * Name of the scanner provider + * @example "Aqua Security" + */ + vendor?: string; + /** + * Version of the scanner adapter + * @example "v0.9.1" + */ + version?: string; +} + +/** The scan overview attached in the metadata of tag */ +export type ScanOverview = Record; + +/** The generate SBOM overview information */ +export interface SBOMOverview { + /** + * The start time of the generating sbom report task + * @format date-time + * @example "2006-01-02T14:04:05Z" + */ + start_time?: string; + /** + * The end time of the generating sbom report task + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + end_time?: string; + /** The status of the generating SBOM task */ + scan_status?: string; + /** The digest of the generated SBOM accessory */ + sbom_digest?: string; + /** + * id of the native scan report + * @example "5f62c830-f996-11e9-957f-0242c0a89008" + */ + report_id?: string; + /** + * Time in seconds required to create the report + * @format int64 + * @example 300 + */ + duration?: number; + scanner?: Scanner; +} + +/** The summary for the native report */ +export interface NativeReportSummary { + /** + * id of the native scan report + * @example "5f62c830-f996-11e9-957f-0242c0a89008" + */ + report_id?: string; + /** + * The status of the report generating process + * @example "Success" + */ + scan_status?: string; + /** + * The overall severity + * @example "High" + */ + severity?: string; + /** + * The seconds spent for generating the report + * @format int64 + * @example 300 + */ + duration?: number; + /** VulnerabilitySummary contains the total number of the foun d vulnerabilities number and numbers of each severity level. */ + summary?: VulnerabilitySummary; + /** + * The start time of the scan process that generating report + * @format date-time + * @example "2006-01-02T14:04:05Z" + */ + start_time?: string; + /** + * The end time of the scan process that generating report + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + end_time?: string; + /** + * The complete percent of the scanning which value is between 0 and 100 + * @example 100 + */ + complete_percent?: number; + scanner?: Scanner; +} + +/** VulnerabilitySummary contains the total number of the foun d vulnerabilities number and numbers of each severity level. */ +export interface VulnerabilitySummary { + /** + * The total number of the found vulnerabilities + * @format int + * @example 500 + */ + total: number; + /** + * The number of the fixable vulnerabilities + * @format int + * @example 100 + */ + fixable: number; + /** + * Numbers of the vulnerabilities with different severity + * @example {"Critical":5,"High":5} + */ + summary: Record; +} + +export interface AuditLog { + /** The ID of the audit log entry. */ + id?: number; + /** Username of the user in this log entry. */ + username?: string; + /** Name of the repository in this log entry. */ + resource?: string; + /** Tag of the repository in this log entry. */ + resource_type?: string; + /** The operation against the repository in this log entry. */ + operation?: string; + /** + * The time when this operation is triggered. + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + op_time?: string; +} + +export interface AuditLogExt { + /** The ID of the audit log entry. */ + id?: number; + /** The username of the operator in this log entry. */ + username?: string; + /** Name of the resource in this log entry. */ + resource?: string; + /** Type of the resource in this log entry. */ + resource_type?: string; + /** The operation against the resource in this log entry. */ + operation?: string; + /** The operation's detail description */ + operation_description?: string; + /** the operation's result, true for success, false for fail */ + operation_result: boolean; + /** + * The time when this operation is triggered. + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + op_time?: string; +} + +export interface AuditLogEventType { + /** + * the event type, such as create_user. + * @example "create_user" + */ + event_type?: string; +} + +export interface Metadata { + /** id */ + id?: string; + /** name */ + name?: string; + /** icon */ + icon?: string; + /** maintainers */ + maintainers?: string[]; + /** version */ + version?: string; + /** source */ + source?: string; +} + +export interface Instance { + /** Unique ID */ + id?: number; + /** Instance name */ + name?: string; + /** Description of instance */ + description?: string; + /** Based on which driver, identified by ID */ + vendor?: string; + /** The service endpoint of this instance */ + endpoint?: string; + /** The authentication way supported */ + auth_mode?: string; + /** The auth credential data if exists */ + auth_info?: Record; + /** The health status */ + status?: string; + /** Whether the instance is activated or not */ + enabled: boolean; + /** Whether the instance is default or not */ + default: boolean; + /** Whether the instance endpoint is insecure or not */ + insecure: boolean; + /** + * The timestamp of instance setting up + * @format int64 + */ + setup_timestamp?: number; +} + +export interface PreheatPolicy { + /** The ID of preheat policy */ + id?: number; + /** The Name of preheat policy */ + name?: string; + /** The Description of preheat policy */ + description?: string; + /** The ID of preheat policy project */ + project_id?: number; + /** The ID of preheat policy provider */ + provider_id?: number; + /** The Name of preheat policy provider */ + provider_name?: string; + /** The Filters of preheat policy */ + filters?: string; + /** The Trigger of preheat policy */ + trigger?: string; + /** Whether the preheat policy enabled */ + enabled: boolean; + /** The extra attributes of preheat policy */ + extra_attrs?: string; + /** + * The Create Time of preheat policy + * @format date-time + */ + creation_time?: string; + /** + * The Update Time of preheat policy + * @format date-time + */ + update_time?: string; +} + +export interface Metrics { + /** The count of task */ + task_count?: number; + /** The count of success task */ + success_task_count?: number; + /** The count of error task */ + error_task_count?: number; + /** The count of pending task */ + pending_task_count?: number; + /** The count of running task */ + running_task_count?: number; + /** The count of scheduled task */ + scheduled_task_count?: number; + /** The count of stopped task */ + stopped_task_count?: number; +} + +export interface Execution { + /** The ID of execution */ + id?: number; + /** The vendor type of execution */ + vendor_type?: string; + /** The vendor id of execution */ + vendor_id?: number; + /** The status of execution */ + status?: string; + /** The status message of execution */ + status_message?: string; + metrics?: Metrics; + /** The trigger of execution */ + trigger?: string; + extra_attrs?: ExtraAttrs; + /** The start time of execution */ + start_time?: string; + /** The end time of execution */ + end_time?: string; +} + +export interface Task { + /** The ID of task */ + id?: number; + /** The ID of task execution */ + execution_id?: number; + /** The status of task */ + status?: string; + /** The status message of task */ + status_message?: string; + /** + * The count of task run + * @format int32 + */ + run_count?: number; + extra_attrs?: ExtraAttrs; + /** The creation time of task */ + creation_time?: string; + /** The update time of task */ + update_time?: string; + /** The start time of task */ + start_time?: string; + /** The end time of task */ + end_time?: string; +} + +export interface ProviderUnderProject { + id?: number; + provider?: string; + enabled?: boolean; + default?: boolean; +} + +export interface Icon { + /** The content type of the icon */ + "content-type"?: string; + /** The base64 encoded content of the icon */ + content?: string; +} + +export interface ProjectReq { + /** + * The name of the project. + * @maxLength 255 + */ + project_name?: string; + /** deprecated, reserved for project creation in replication */ + public?: boolean | null; + /** The metadata of the project. */ + metadata?: ProjectMetadata; + /** The CVE allowlist of the project. */ + cve_allowlist?: CVEAllowlist; + /** + * The storage quota of the project. + * @format int64 + */ + storage_limit?: number | null; + /** + * The ID of referenced registry when creating the proxy cache project + * @format int64 + */ + registry_id?: number | null; +} + +export interface Project { + /** + * Project ID + * @format int32 + */ + project_id?: number; + /** + * The owner ID of the project always means the creator of the project. + * @format int32 + */ + owner_id?: number; + /** The name of the project. */ + name?: string; + /** + * The ID of referenced registry when the project is a proxy cache project. + * @format int64 + */ + registry_id?: number; + /** + * The creation time of the project. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the project. + * @format date-time + */ + update_time?: string; + /** A deletion mark of the project. */ + deleted?: boolean; + /** The owner name of the project. */ + owner_name?: string; + /** Correspond to the UI about whether the project's publicity is updatable (for UI) */ + togglable?: boolean; + /** The role ID with highest permission of the current user who triggered the API (for UI). This attribute is deprecated and will be removed in future versions. */ + current_user_role_id?: number; + /** The list of role ID of the current user who triggered the API (for UI) */ + current_user_role_ids?: number[]; + /** The number of the repositories under this project. */ + repo_count: number; + /** The metadata of the project. */ + metadata?: ProjectMetadata; + /** The CVE allowlist of this project. */ + cve_allowlist?: CVEAllowlist; +} + +export interface ProjectDeletable { + /** Whether the project can be deleted. */ + deletable?: boolean; + /** The detail message when the project can not be deleted. */ + message?: string; +} + +export interface ProjectMetadata { + /** The public status of the project. The valid values are "true", "false". */ + public?: string; + /** Whether content trust is enabled or not. If it is enabled, user can't pull unsigned images from this project. The valid values are "true", "false". */ + enable_content_trust?: string | null; + /** Whether cosign content trust is enabled or not. If it is enabled, user can't pull images without cosign signature from this project. The valid values are "true", "false". */ + enable_content_trust_cosign?: string | null; + /** Whether prevent the vulnerable images from running. The valid values are "true", "false". */ + prevent_vul?: string | null; + /** If the vulnerability is high than severity defined here, the images can't be pulled. The valid values are "none", "low", "medium", "high", "critical". */ + severity?: string | null; + /** Whether scan images automatically when pushing. The valid values are "true", "false". */ + auto_scan?: string | null; + /** Whether generating SBOM automatically when pushing a subject artifact. The valid values are "true", "false". */ + auto_sbom_generation?: string | null; + /** Whether this project reuse the system level CVE allowlist as the allowlist of its own. The valid values are "true", "false". If it is set to "true" the actual allowlist associate with this project, if any, will be ignored. */ + reuse_sys_cve_allowlist?: string | null; + /** The ID of the tag retention policy for the project */ + retention_id?: string | null; + /** The bandwidth limit of proxy cache, in Kbps (kilobits per second). It limits the communication between Harbor and the upstream registry, not the client and the Harbor. */ + proxy_speed_kb?: string | null; +} + +export interface ProjectSummary { + /** The number of the repositories under this project. */ + repo_count: number; + /** The total number of project admin members. */ + project_admin_count?: number; + /** The total number of maintainer members. */ + maintainer_count?: number; + /** The total number of developer members. */ + developer_count?: number; + /** The total number of guest members. */ + guest_count?: number; + /** The total number of limited guest members. */ + limited_guest_count?: number; + quota?: ProjectSummaryQuota; + registry?: Registry; +} + +export interface ProjectSummaryQuota { + /** The hard limits of the quota */ + hard?: ResourceList; + /** The used status of the quota */ + used?: ResourceList; +} + +export interface ProjectScanner { + /** The identifier of the scanner registration */ + uuid: string; +} + +/** The CVE Allowlist for system or project */ +export interface CVEAllowlist { + /** ID of the allowlist */ + id?: number; + /** ID of the project which the allowlist belongs to. For system level allowlist this attribute is zero. */ + project_id?: number; + /** the time for expiration of the allowlist, in the form of seconds since epoch. This is an optional attribute, if it's not set the CVE allowlist does not expire. */ + expires_at?: number | null; + items?: CVEAllowlistItem[]; + /** + * The creation time of the allowlist. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the allowlist. + * @format date-time + */ + update_time?: string; +} + +/** The item in CVE allowlist */ +export interface CVEAllowlistItem { + /** The ID of the CVE, such as "CVE-2019-10164" */ + cve_id?: string; +} + +export interface ReplicationPolicy { + /** + * The policy ID. + * @format int64 + */ + id?: number; + /** The policy name. */ + name?: string; + /** The description of the policy. */ + description?: string; + /** The source registry. */ + src_registry?: Registry; + /** The destination registry. */ + dest_registry?: Registry; + /** The destination namespace. */ + dest_namespace?: string; + /** + * Specify how many path components will be replaced by the provided destination namespace. + * The default value is -1 in which case the legacy mode will be applied. + * @format int8 + */ + dest_namespace_replace_count?: number; + trigger?: ReplicationTrigger; + /** The replication policy filter array. */ + filters?: ReplicationFilter[]; + /** Whether to replicate the deletion operation. */ + replicate_deletion?: boolean; + /** Deprecated, use "replicate_deletion" instead. Whether to replicate the deletion operation. */ + deletion?: boolean; + /** Whether to override the resources on the destination registry. */ + override?: boolean; + /** Whether the policy is enabled or not. */ + enabled?: boolean; + /** + * The create time of the policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the policy. + * @format date-time + */ + update_time?: string; + /** + * speed limit for each task + * @format int32 + */ + speed?: number; + /** Whether to enable copy by chunk. */ + copy_by_chunk?: boolean; +} + +export interface ReplicationTrigger { + /** The replication policy trigger type. The valid values are manual, event_based and scheduled. */ + type?: string; + trigger_settings?: ReplicationTriggerSettings; +} + +export interface ReplicationTriggerSettings { + /** The cron string for scheduled trigger */ + cron?: string; +} + +export interface ReplicationFilter { + /** The replication policy filter type. */ + type?: string; + /** The value of replication policy filter. */ + value?: object; + /** matches or excludes the result */ + decoration?: string; +} + +export interface RegistryCredential { + /** Credential type, such as 'basic', 'oauth'. */ + type?: string; + /** Access key, e.g. user name when credential type is 'basic'. */ + access_key?: string; + /** Access secret, e.g. password when credential type is 'basic'. */ + access_secret?: string; +} + +export interface Registry { + /** + * The registry ID. + * @format int64 + */ + id: number; + /** The registry URL string. */ + url?: string; + /** The registry name. */ + name?: string; + credential?: RegistryCredential; + /** Type of the registry, e.g. 'harbor'. */ + type?: string; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean; + /** Description of the registry. */ + description?: string; + /** Health status of the registry. */ + status?: string; + /** + * The create time of the policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the policy. + * @format date-time + */ + update_time?: string; +} + +export interface RegistryUpdate { + /** The registry name. */ + name?: string | null; + /** Description of the registry. */ + description?: string | null; + /** The registry URL. */ + url?: string | null; + /** Credential type of the registry, e.g. 'basic'. */ + credential_type?: string | null; + /** The registry access key. */ + access_key?: string | null; + /** The registry access secret. */ + access_secret?: string | null; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean | null; +} + +export interface RegistryPing { + /** + * The registry ID. + * @format int64 + */ + id?: number | null; + /** Type of the registry, e.g. 'harbor'. */ + type?: string | null; + /** The registry URL. */ + url?: string | null; + /** Credential type of the registry, e.g. 'basic'. */ + credential_type?: string | null; + /** The registry access key. */ + access_key?: string | null; + /** The registry access secret. */ + access_secret?: string | null; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean | null; +} + +/** The registry info contains the base info and capability declarations of the registry */ +export interface RegistryInfo { + /** The registry type */ + type?: string; + /** The description */ + description?: string; + /** The filters that the registry supports */ + supported_resource_filters?: FilterStyle[]; + /** The triggers that the registry supports */ + supported_triggers?: string[]; + /** The registry whether support copy by chunk. */ + supported_copy_by_chunk?: boolean; +} + +/** The registry provider info contains the base info and capability declarations of the registry provider */ +export interface RegistryProviderInfo { + /** The endpoint pattern */ + endpoint_pattern?: RegistryProviderEndpointPattern; + /** The credential pattern */ + credential_pattern?: RegistryProviderCredentialPattern; +} + +/** The registry endpoint pattern */ +export interface RegistryProviderEndpointPattern { + /** The endpoint type */ + endpoint_type?: string; + /** The endpoint list */ + endpoints?: RegistryEndpoint[]; +} + +/** The registry credential pattern */ +export interface RegistryProviderCredentialPattern { + /** The access key type */ + access_key_type?: string; + /** The access key data */ + access_key_data?: string; + /** The access secret type */ + access_secret_type?: string; + /** The access secret data */ + access_secret_data?: string; +} + +/** The style of the resource filter */ +export interface RegistryEndpoint { + /** The endpoint key */ + key?: string; + /** The endpoint value */ + value?: string; +} + +/** The style of the resource filter */ +export interface FilterStyle { + /** The filter type */ + type?: string; + /** The filter style */ + style?: string; + /** The filter values */ + values?: string[]; +} + +export type ResourceList = Record; + +/** The replication execution */ +export interface ReplicationExecution { + /** The ID of the execution */ + id?: number; + /** The ID if the policy that the execution belongs to */ + policy_id?: number; + /** The status of the execution */ + status?: string; + /** The trigger mode */ + trigger?: string; + /** + * The start time + * @format date-time + */ + start_time?: string; + /** + * The end time + * @format date-time + */ + end_time?: string; + /** The status text */ + status_text: string; + /** The total count of all executions */ + total: number; + /** The count of failed executions */ + failed: number; + /** The count of succeed executions */ + succeed: number; + /** The count of in_progress executions */ + in_progress: number; + /** The count of stopped executions */ + stopped: number; +} + +export interface StartReplicationExecution { + /** + * The ID of policy that the execution belongs to. + * @format int64 + */ + policy_id?: number; +} + +/** The replication task */ +export interface ReplicationTask { + /** The ID of the task */ + id?: number; + /** The ID of the execution that the task belongs to */ + execution_id?: number; + /** The status of the task */ + status?: string; + /** The ID of the underlying job that the task related to */ + job_id?: string; + /** The operation of the task */ + operation?: string; + /** The type of the resource that the task operates */ + resource_type?: string; + /** The source resource that the task operates */ + src_resource?: string; + /** The destination resource that the task operates */ + dst_resource?: string; + /** + * The start time of the task + * @format date-time + */ + start_time?: string; + /** + * The end time of the task + * @format date-time + */ + end_time?: string; +} + +export interface Robot { + /** + * The ID of the robot + * @format int64 + */ + id?: number; + /** The name of the robot */ + name?: string; + /** The description of the robot */ + description?: string; + /** The secret of the robot */ + secret?: string; + /** The level of the robot, project or system */ + level?: string; + /** + * The duration of the robot in days, duration must be either -1(Never) or a positive integer + * @format int64 + */ + duration?: number | null; + /** The editable status of the robot */ + editable: boolean; + /** The disable status of the robot */ + disable: boolean; + /** + * The expiration date of the robot + * @format int64 + */ + expires_at?: number; + permissions?: RobotPermission[]; + /** The type of the robot creator, like local(harbor_user) or robot. */ + creator_type?: string; + /** The reference of the robot creator, like the id of harbor user. */ + creator_ref?: number; + /** + * The creation time of the robot. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the robot. + * @format date-time + */ + update_time?: string; +} + +/** The request for robot account creation. */ +export interface RobotCreate { + /** The name of the robot */ + name?: string; + /** The description of the robot */ + description?: string; + /** The secret of the robot */ + secret?: string; + /** The level of the robot, project or system */ + level?: string; + /** The disable status of the robot */ + disable?: boolean; + /** + * The duration of the robot in days, duration must be either -1(Never) or a positive integer + * @format int64 + */ + duration?: number; + permissions?: RobotPermission[]; +} + +/** The response for robot account creation. */ +export interface RobotCreated { + /** + * The ID of the robot + * @format int64 + */ + id?: number; + /** The name of the robot */ + name?: string; + /** The secret of the robot */ + secret?: string; + /** + * The creation time of the robot. + * @format date-time + */ + creation_time?: string; + /** + * The expiration date of the robot + * @format int64 + */ + expires_at?: number; +} + +/** The response for refresh/update robot account secret. */ +export interface RobotSec { + /** The secret of the robot */ + secret?: string; +} + +export interface RobotPermission { + /** The kind of the permission */ + kind?: string; + /** The namespace of the permission */ + namespace?: string; + access?: Access[]; +} + +export interface Access { + /** The resource of the access. Possible resources are listed here for system and project level https://github.com/goharbor/harbor/blob/main/src/common/rbac/const.go */ + resource?: string; + /** The action of the access. Possible actions are *, pull, push, create, read, update, delete, list, operate, scanner-pull and stop. */ + action?: string; + /** The effect of the access */ + effect?: string; +} + +export interface RobotCreateV1 { + /** The name of robot account */ + name?: string; + /** The description of robot account */ + description?: string; + /** The expiration time on or after which the JWT MUST NOT be accepted for processing. */ + expires_at?: number; + /** The permission of robot account */ + access?: Access[]; +} + +export interface Storage { + /** + * Total volume size. + * @format uint64 + */ + total?: number; + /** + * Free volume size. + * @format uint64 + */ + free?: number; +} + +export interface GeneralInfo { + /** + * The banner message for the UI. It is the stringified result of the banner message object. + * @example "{"closable":true,"message":"your banner message content","type":"warning","fromDate":"06/19/2023","toDate":"06/21/2023"}" + */ + banner_message?: string | null; + /** + * The current time of the server. + * @format date-time + */ + current_time?: string | null; + /** The url of registry against which the docker command should be issued. */ + registry_url?: string | null; + /** The external URL of Harbor, with protocol. */ + external_url?: string | null; + /** The auth mode of current Harbor instance. */ + auth_mode?: string | null; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: boolean | null; + /** Indicate who can create projects, it could be 'adminonly' or 'everyone'. */ + project_creation_restriction?: string | null; + /** Indicate whether the Harbor instance enable user to register himself. */ + self_registration?: boolean | null; + /** Indicate whether there is a ca root cert file ready for download in the file system. */ + has_ca_root?: boolean | null; + /** The build version of Harbor. */ + harbor_version?: string | null; + /** The storage provider's name of Harbor registry */ + registry_storage_provider_name?: string | null; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: boolean | null; + /** The flag to indicate whether notification mechanism is enabled on Harbor instance. */ + notification_enable?: boolean | null; + /** The setting of auth proxy this is only available when Harbor relies on authproxy for authentication. */ + authproxy_settings?: AuthproxySetting | null; + /** The OIDC provider name, empty if current auth is not OIDC_auth or OIDC provider is not configured. */ + oidc_provider_name?: string | null; +} + +export interface AuthproxySetting { + /** The fully qualified URI of login endpoint of authproxy, such as 'https://192.168.1.2:8443/login' */ + endpoint?: string; + /** The fully qualified URI of token review endpoint of authproxy, such as 'https://192.168.1.2:8443/tokenreview' */ + tokenreivew_endpoint?: string; + /** The flag to determine whether Harbor can skip search the user/group when adding him as a member. */ + skip_search?: boolean; + /** The flag to determine whether Harbor should verify the certificate when connecting to the auth proxy. */ + verify_cert?: boolean; + /** The certificate to be pinned when connecting auth proxy. */ + server_certificate?: string; +} + +export interface SystemInfo { + /** The storage of system. */ + storage?: Storage[]; +} + +export interface GCHistory { + /** the id of gc job. */ + id?: number; + /** the job name of gc job. */ + job_name?: string; + /** the job kind of gc job. */ + job_kind?: string; + /** the job parameters of gc job. */ + job_parameters?: string; + schedule?: ScheduleObj; + /** the status of gc job. */ + job_status?: string; + /** if gc job was deleted. */ + deleted?: boolean; + /** + * the creation time of gc job. + * @format date-time + */ + creation_time?: string; + /** + * the update time of gc job. + * @format date-time + */ + update_time?: string; +} + +export interface ExecHistory { + /** the id of purge job. */ + id?: number; + /** the job name of purge job. */ + job_name?: string; + /** the job kind of purge job. */ + job_kind?: string; + /** the job parameters of purge job. */ + job_parameters?: string; + schedule?: ScheduleObj; + /** the status of purge job. */ + job_status?: string; + /** if purge job was deleted. */ + deleted?: boolean; + /** + * the creation time of purge job. + * @format date-time + */ + creation_time?: string; + /** + * the update time of purge job. + * @format date-time + */ + update_time?: string; +} + +export interface Schedule { + /** The id of the schedule. */ + id?: number; + /** The status of the schedule. */ + status?: string; + /** + * the creation time of the schedule. + * @format date-time + */ + creation_time?: string; + /** + * the update time of the schedule. + * @format date-time + */ + update_time?: string; + schedule?: ScheduleObj; + /** The parameters of schedule job */ + parameters?: Record; +} + +export interface ScheduleObj { + /** + * The schedule type. The valid values are 'Hourly', 'Daily', 'Weekly', 'Custom', 'Manual', 'None' and 'Schedule'. + * 'Manual' means to trigger it right away, 'Schedule' means to trigger it by a specified cron schedule and + * 'None' means to cancel the schedule. + */ + type?: + | "Hourly" + | "Daily" + | "Weekly" + | "Custom" + | "Manual" + | "None" + | "Schedule"; + /** A cron expression, a time-based job scheduler. */ + cron?: string; + /** + * The next time to schedule to run the job. + * @format date-time + */ + next_scheduled_time?: string; +} + +/** Stats provides the overall progress of the scan all process. */ +export interface Stats { + /** + * The total number of scan processes triggered by the scan all action + * @format int + * @example 100 + */ + total: number; + /** + * The number of the finished scan processes triggered by the scan all action + * @format int + * @example 90 + */ + completed: number; + /** + * The metrics data for the each status + * @example {"Success":5,"Error":2,"Running":3} + */ + metrics?: Record; + /** A flag indicating job status of scan all. */ + ongoing: boolean; + /** The trigger of the scan all job. */ + trigger?: "Manual" | "Schedule" | "Event"; +} + +/** the tag retention metadata */ +export interface RetentionMetadata { + /** templates */ + templates?: RetentionRuleMetadata[]; + /** supported scope selectors */ + scope_selectors?: RetentionSelectorMetadata[]; + /** supported tag selectors */ + tag_selectors?: RetentionSelectorMetadata[]; +} + +/** the tag retention rule metadata */ +export interface RetentionRuleMetadata { + /** rule id */ + rule_template?: string; + /** rule display text */ + display_text?: string; + /** rule action */ + action?: string; + /** rule params */ + params?: RetentionRuleParamMetadata[]; +} + +/** rule param */ +export interface RetentionRuleParamMetadata { + type?: string; + unit?: string; + required?: boolean; +} + +/** retention selector */ +export interface RetentionSelectorMetadata { + display_text?: string; + kind?: string; + decorations?: string[]; +} + +/** retention policy */ +export interface RetentionPolicy { + /** @format int64 */ + id?: number; + algorithm?: string; + rules?: RetentionRule[]; + trigger?: RetentionRuleTrigger; + scope?: RetentionPolicyScope; +} + +export interface RetentionRuleTrigger { + kind?: string; + settings?: object; + references?: object; +} + +export interface RetentionPolicyScope { + level?: string; + ref?: number; +} + +export interface RetentionRule { + id?: number; + priority?: number; + disabled?: boolean; + action?: string; + template?: string; + params?: Record; + tag_selectors?: RetentionSelector[]; + scope_selectors?: Record; +} + +export interface RetentionSelector { + kind?: string; + decoration?: string; + pattern?: string; + extras?: string; +} + +export interface RetentionExecution { + /** @format int64 */ + id?: number; + /** @format int64 */ + policy_id?: number; + start_time?: string; + end_time?: string; + status?: string; + trigger?: string; + dry_run?: boolean; +} + +export interface RetentionExecutionTask { + /** @format int64 */ + id?: number; + /** @format int64 */ + execution_id?: number; + repository?: string; + job_id?: string; + status?: string; + status_code: number; + /** @format int64 */ + status_revision?: number; + start_time?: string; + end_time?: string; + total: number; + retained: number; +} + +export interface QuotaUpdateReq { + /** The new hard limits for the quota */ + hard?: ResourceList; +} + +export type QuotaRefObject = Record; + +/** The quota object */ +export interface Quota { + /** ID of the quota */ + id?: number; + /** The reference object of the quota */ + ref?: QuotaRefObject; + /** The hard limits of the quota */ + hard: ResourceList; + /** The used status of the quota */ + used: ResourceList; + /** + * the creation time of the quota + * @format date-time + */ + creation_time?: string; + /** + * the update time of the quota + * @format date-time + */ + update_time?: string; +} + +/** Registration represents a named configuration for invoking a scanner via its adapter. */ +export interface ScannerRegistration { + /** The unique identifier of this registration. */ + uuid?: string; + /** + * The name of this registration. + * @example "Trivy" + */ + name?: string; + /** + * An optional description of this registration. + * @example "A free-to-use tool that scans container images for package vulnerabilities. + * " + */ + description: string; + /** + * A base URL of the scanner adapter + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url?: string; + /** + * Indicate whether the registration is enabled or not + * @default false + */ + disabled: boolean; + /** + * Indicate if the registration is set as the system default one + * @default false + */ + is_default: boolean; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @default "" + * @example "Bearer" + */ + auth: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential: string; + /** + * Indicate if skip the certificate verification when sending HTTP requests + * @default false + */ + skip_certVerify: boolean; + /** + * Indicate whether use internal registry addr for the scanner to pull content or not + * @default false + */ + use_internal_addr: boolean; + /** + * The creation time of this registration + * @format date-time + */ + create_time?: string; + /** + * The update time of this registration + * @format date-time + */ + update_time?: string; + /** + * Optional property to describe the name of the scanner registration + * @example "Trivy" + */ + adapter?: string; + /** + * Optional property to describe the vendor of the scanner registration + * @example "CentOS" + */ + vendor?: string; + /** + * Optional property to describe the version of the scanner registration + * @example "1.0.1" + */ + version?: string; + /** + * Indicate the healthy of the registration + * @default "" + * @example "healthy" + */ + health?: string; + /** + * Indicates the capabilities of the scanner, e.g. support_vulnerability or support_sbom. + * @example {"support_vulnerability":true,"support_sbom":true} + */ + capabilities?: Record; +} + +export interface ScannerRegistrationReq { + /** + * The name of this registration + * @example "Trivy" + */ + name: string; + /** + * An optional description of this registration. + * @example "A free-to-use tool that scans container images for package vulnerabilities. + * " + */ + description?: string; + /** + * A base URL of the scanner adapter. + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url: string; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @example "Bearer" + */ + auth?: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential?: string; + /** + * Indicate if skip the certificate verification when sending HTTP requests + * @default false + */ + skip_certVerify?: boolean; + /** + * Indicate whether use internal registry addr for the scanner to pull content or not + * @default false + */ + use_internal_addr?: boolean; + /** + * Indicate whether the registration is enabled or not + * @default false + */ + disabled?: boolean; +} + +export interface ScannerRegistrationSettings { + /** + * The name of this registration + * @example "Trivy" + */ + name: string; + /** + * A base URL of the scanner adapter. + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url: string; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @default "" + */ + auth?: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential?: string; +} + +export interface IsDefault { + /** A flag indicating whether a scanner registration is default. */ + is_default?: boolean; +} + +export interface ScannerCapability { + /** + * Specify the type of scanner capability, like vulnerability or sbom + * @example "sbom" + */ + type: string; + consumes_mime_types?: string[]; + produces_mime_types?: string[]; +} + +/** The metadata info of the scanner adapter */ +export interface ScannerAdapterMetadata { + scanner?: Scanner; + capabilities?: ScannerCapability[]; + /** @example {"harbor.scanner-adapter/registry-authorization-type":"Bearer"} */ + properties?: Record; +} + +export interface ImmutableRule { + id?: number; + priority?: number; + disabled?: boolean; + action?: string; + template?: string; + params?: Record; + tag_selectors?: ImmutableSelector[]; + scope_selectors?: Record; +} + +export interface ImmutableSelector { + kind?: string; + decoration?: string; + pattern?: string; + extras?: string; +} + +/** The ldap configure properties */ +export interface LdapConf { + /** The url of ldap service. */ + ldap_url?: string; + /** The search dn of ldap service. */ + ldap_search_dn?: string; + /** The search password of ldap service. */ + ldap_search_password?: string; + /** The base dn of ldap service. */ + ldap_base_dn?: string; + /** The serach filter of ldap service. */ + ldap_filter?: string; + /** The serach uid from ldap service attributes. */ + ldap_uid?: string; + /** + * The serach scope of ldap service. + * @format int64 + */ + ldap_scope?: number; + /** + * The connect timeout of ldap service(second). + * @format int64 + */ + ldap_connection_timeout?: number; + /** Verify Ldap server certificate. */ + ldap_verify_cert?: boolean; +} + +/** The ldap ping result */ +export interface LdapPingResult { + /** Test success */ + success?: boolean; + /** The ping operation output message. */ + message?: string; +} + +export interface LdapImportUsers { + /** selected uid list */ + ldap_uid_list?: string[]; +} + +export interface LdapFailedImportUser { + /** the uid can't add to system. */ + uid?: string; + /** fail reason. */ + error?: string; +} + +export interface LdapUser { + /** ldap username. */ + username?: string; + /** The user realname from "uid" or "cn" attribute. */ + realname?: string; + /** The user email address from "mail" or "email" attribute. */ + email?: string; +} + +export interface UserGroup { + /** The ID of the user group */ + id?: number; + /** The name of the user group */ + group_name?: string; + /** The group type, 1 for LDAP group, 2 for HTTP group, 3 for OIDC group. */ + group_type?: number; + /** The DN of the LDAP group if group type is 1 (LDAP group). */ + ldap_group_dn?: string; +} + +export interface UserGroupSearchItem { + /** The ID of the user group */ + id?: number; + /** The name of the user group */ + group_name?: string; + /** The group type, 1 for LDAP group, 2 for HTTP group, 3 for OIDC group. */ + group_type?: number; +} + +/** Supported webhook event types and notify types. */ +export interface SupportedWebhookEventTypes { + event_type?: EventType[]; + notify_type?: NotifyType[]; + payload_formats?: PayloadFormat[]; +} + +/** + * Webhook supported event type. + * @example "PULL_ARTIFACT" + */ +export type EventType = string; + +/** + * Webhook supported notify type. + * @example "http" + */ +export type NotifyType = string; + +/** + * The type of webhook paylod format. + * @example "CloudEvents" + */ +export type PayloadFormatType = string; + +/** Webhook supported payload format type collections. */ +export interface PayloadFormat { + /** Webhook supported notify type. */ + notify_type?: NotifyType; + /** The supported payload formats for this notify type. */ + formats?: PayloadFormatType[]; +} + +/** The webhook policy target object. */ +export interface WebhookTargetObject { + /** The webhook target notify type. */ + type?: string; + /** The webhook target address. */ + address?: string; + /** The webhook auth header. */ + auth_header?: string; + /** Whether or not to skip cert verify. */ + skip_cert_verify?: boolean; + /** The payload format of webhook, by default is Default for http type. */ + payload_format?: PayloadFormatType; +} + +/** The webhook policy object */ +export interface WebhookPolicy { + /** + * The webhook policy ID. + * @format int64 + */ + id?: number; + /** The name of webhook policy. */ + name?: string; + /** The description of webhook policy. */ + description?: string; + /** The project ID of webhook policy. */ + project_id?: number; + targets?: WebhookTargetObject[]; + event_types?: string[]; + /** The creator of the webhook policy. */ + creator?: string; + /** + * The create time of the webhook policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the webhook policy. + * @format date-time + */ + update_time?: string; + /** Whether the webhook policy is enabled or not. */ + enabled: boolean; +} + +/** The webhook policy and last trigger time group by event type. */ +export interface WebhookLastTrigger { + /** The webhook policy name. */ + policy_name?: string; + /** The webhook event type. */ + event_type?: string; + /** Whether or not the webhook policy enabled. */ + enabled?: boolean; + /** + * The creation time of webhook policy. + * @format date-time + */ + creation_time?: string; + /** + * The last trigger time of webhook policy. + * @format date-time + */ + last_trigger_time?: string; +} + +/** The webhook job. */ +export interface WebhookJob { + /** + * The webhook job ID. + * @format int64 + */ + id?: number; + /** + * The webhook policy ID. + * @format int64 + */ + policy_id?: number; + /** The webhook job event type. */ + event_type?: string; + /** The webhook job notify type. */ + notify_type?: string; + /** The webhook job status. */ + status?: string; + /** The webhook job notify detailed data. */ + job_detail?: string; + /** + * The webhook job creation time. + * @format date-time + */ + creation_time?: string; + /** + * The webhook job update time. + * @format date-time + */ + update_time?: string; +} + +export type InternalConfigurationsResponse = Record< + string, + InternalConfigurationValue +>; + +export interface InternalConfigurationValue { + /** The value of current config item */ + value?: object; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface ConfigurationsResponse { + /** The auth mode of current system, such as "db_auth", "ldap_auth", "oidc_auth" */ + auth_mode?: StringConfigItem; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: BoolConfigItem; + /** The Base DN for LDAP binding. */ + ldap_base_dn?: StringConfigItem; + /** The filter for LDAP search */ + ldap_filter?: StringConfigItem; + /** The base DN to search LDAP group. */ + ldap_group_base_dn?: StringConfigItem; + /** Specify the ldap group which have the same privilege with Harbor admin */ + ldap_group_admin_dn?: StringConfigItem; + /** The attribute which is used as identity of the LDAP group, default is cn.' */ + ldap_group_attribute_name?: StringConfigItem; + /** The filter to search the ldap group */ + ldap_group_search_filter?: StringConfigItem; + /** The scope to search ldap group. ''0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE'' */ + ldap_group_search_scope?: IntegerConfigItem; + /** Attach LDAP user group information in parallel. */ + ldap_group_attach_parallel?: BoolConfigItem; + /** The scope to search ldap users,'0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE' */ + ldap_scope?: IntegerConfigItem; + /** The DN of the user to do the search. */ + ldap_search_dn?: StringConfigItem; + /** Timeout in seconds for connection to LDAP server */ + ldap_timeout?: IntegerConfigItem; + /** The attribute which is used as identity for the LDAP binding, such as "CN" or "SAMAccountname" */ + ldap_uid?: StringConfigItem; + /** The URL of LDAP server */ + ldap_url?: StringConfigItem; + /** Whether verify your OIDC server certificate, disable it if your OIDC server is hosted via self-hosted certificate. */ + ldap_verify_cert?: BoolConfigItem; + /** The user attribute to identify the group membership */ + ldap_group_membership_attribute?: StringConfigItem; + /** Indicate who can create projects, it could be ''adminonly'' or ''everyone''. */ + project_creation_restriction?: StringConfigItem; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: BoolConfigItem; + /** Whether the Harbor instance supports self-registration. If it''s set to false, admin need to add user to the instance. */ + self_registration?: BoolConfigItem; + /** The expiration time of the token for internal Registry, in minutes. */ + token_expiration?: IntegerConfigItem; + /** The client id of UAA */ + uaa_client_id?: StringConfigItem; + /** The client secret of the UAA */ + uaa_client_secret?: StringConfigItem; + /** The endpoint of the UAA */ + uaa_endpoint?: StringConfigItem; + /** Verify the certificate in UAA server */ + uaa_verify_cert?: BoolConfigItem; + /** The endpoint of the HTTP auth */ + http_authproxy_endpoint?: StringConfigItem; + /** The token review endpoint */ + http_authproxy_tokenreview_endpoint?: StringConfigItem; + /** The group which has the harbor admin privileges */ + http_authproxy_admin_groups?: StringConfigItem; + /** The usernames which has the harbor admin privileges */ + http_authproxy_admin_usernames?: StringConfigItem; + /** Verify the HTTP auth provider's certificate */ + http_authproxy_verify_cert?: BoolConfigItem; + /** Search user before onboard */ + http_authproxy_skip_search?: BoolConfigItem; + /** The certificate of the HTTP auth provider */ + http_authproxy_server_certificate?: StringConfigItem; + /** The OIDC provider name */ + oidc_name?: StringConfigItem; + /** The endpoint of the OIDC provider */ + oidc_endpoint?: StringConfigItem; + /** The client ID of the OIDC provider */ + oidc_client_id?: StringConfigItem; + /** The attribute claims the group name */ + oidc_groups_claim?: StringConfigItem; + /** The OIDC group which has the harbor admin privileges */ + oidc_admin_group?: StringConfigItem; + /** The OIDC group filter which filters out the group doesn't match the regular expression */ + oidc_group_filter?: StringConfigItem; + /** The scope of the OIDC provider */ + oidc_scope?: StringConfigItem; + /** The attribute claims the username */ + oidc_user_claim?: StringConfigItem; + /** Verify the OIDC provider's certificate' */ + oidc_verify_cert?: BoolConfigItem; + /** Auto onboard the OIDC user */ + oidc_auto_onboard?: BoolConfigItem; + /** Extra parameters to add when redirect request to OIDC provider */ + oidc_extra_redirect_parms?: StringConfigItem; + /** Extra parameters to logout user session from the OIDC provider */ + oidc_logout?: BoolConfigItem; + /** The robot account token duration in days */ + robot_token_duration?: IntegerConfigItem; + /** The rebot account name prefix */ + robot_name_prefix?: StringConfigItem; + /** Enable notification */ + notification_enable?: BoolConfigItem; + /** Enable quota per project */ + quota_per_project_enable?: BoolConfigItem; + /** The storage quota per project */ + storage_per_project?: IntegerConfigItem; + /** The endpoint of the audit log forwarder */ + audit_log_forward_endpoint?: StringConfigItem; + /** Whether skip the audit log in database */ + skip_audit_log_database?: BoolConfigItem; + /** Whether or not to skip update the pull time for scanner */ + scanner_skip_update_pulltime?: BoolConfigItem; + scan_all_policy?: { + /** The type of scan all policy, currently the valid values are "none" and "daily" */ + type?: string; + /** The parameters of the policy, the values are dependent on the type of the policy. */ + parameter?: { + /** The offset in seconds of UTC 0 o'clock, only valid when the policy type is "daily" */ + daily_time?: number; + }; + }; + /** The session timeout in minutes */ + session_timeout?: IntegerConfigItem; + /** The banner message for the UI.It is the stringified result of the banner message object */ + banner_message?: StringConfigItem; + /** The audit log event types to skip to log in database */ + disabled_audit_log_event_types?: StringConfigItem; +} + +export interface Configurations { + /** The auth mode of current system, such as "db_auth", "ldap_auth", "oidc_auth" */ + auth_mode?: string; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: boolean | null; + /** The Base DN for LDAP binding. */ + ldap_base_dn?: string; + /** The filter for LDAP search */ + ldap_filter?: string; + /** The base DN to search LDAP group. */ + ldap_group_base_dn?: string; + /** Specify the ldap group which have the same privilege with Harbor admin */ + ldap_group_admin_dn?: string; + /** The attribute which is used as identity of the LDAP group, default is cn.' */ + ldap_group_attribute_name?: string; + /** The filter to search the ldap group */ + ldap_group_search_filter?: string; + /** The scope to search ldap group. ''0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE'' */ + ldap_group_search_scope?: number; + /** Attach LDAP user group information in parallel, the parallel worker count is 5 */ + ldap_group_attach_parallel?: boolean; + /** The scope to search ldap users,'0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE' */ + ldap_scope?: number; + /** The DN of the user to do the search. */ + ldap_search_dn?: string; + /** The password of the ldap search dn */ + ldap_search_password?: string; + /** Timeout in seconds for connection to LDAP server */ + ldap_timeout?: number; + /** The attribute which is used as identity for the LDAP binding, such as "CN" or "SAMAccountname" */ + ldap_uid?: string; + /** The URL of LDAP server */ + ldap_url?: string; + /** Whether verify your OIDC server certificate, disable it if your OIDC server is hosted via self-hosted certificate. */ + ldap_verify_cert?: boolean; + /** The user attribute to identify the group membership */ + ldap_group_membership_attribute?: string; + /** Indicate who can create projects, it could be ''adminonly'' or ''everyone''. */ + project_creation_restriction?: string; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: boolean; + /** Whether the Harbor instance supports self-registration. If it''s set to false, admin need to add user to the instance. */ + self_registration?: boolean; + /** The expiration time of the token for internal Registry, in minutes. */ + token_expiration?: number; + /** The client id of UAA */ + uaa_client_id?: string; + /** The client secret of the UAA */ + uaa_client_secret?: string; + /** The endpoint of the UAA */ + uaa_endpoint?: string; + /** Verify the certificate in UAA server */ + uaa_verify_cert?: boolean; + /** The endpoint of the HTTP auth */ + http_authproxy_endpoint?: string; + /** The token review endpoint */ + http_authproxy_tokenreview_endpoint?: string; + /** The group which has the harbor admin privileges */ + http_authproxy_admin_groups?: string; + /** The username which has the harbor admin privileges */ + http_authproxy_admin_usernames?: string; + /** Verify the HTTP auth provider's certificate */ + http_authproxy_verify_cert?: boolean; + /** Search user before onboard */ + http_authproxy_skip_search?: boolean; + /** The certificate of the HTTP auth provider */ + http_authproxy_server_certificate?: string; + /** The OIDC provider name */ + oidc_name?: string; + /** The endpoint of the OIDC provider */ + oidc_endpoint?: string; + /** The client ID of the OIDC provider */ + oidc_client_id?: string; + /** The OIDC provider secret */ + oidc_client_secret?: string; + /** The attribute claims the group name */ + oidc_groups_claim?: string; + /** The OIDC group which has the harbor admin privileges */ + oidc_admin_group?: string; + /** The OIDC group filter which filters out the group name doesn't match the regular expression */ + oidc_group_filter?: string; + /** The scope of the OIDC provider */ + oidc_scope?: string; + /** The attribute claims the username */ + oidc_user_claim?: string; + /** Verify the OIDC provider's certificate' */ + oidc_verify_cert?: boolean; + /** Auto onboard the OIDC user */ + oidc_auto_onboard?: boolean; + /** Extra parameters to add when redirect request to OIDC provider */ + oidc_extra_redirect_parms?: string; + /** Logout OIDC user session */ + oidc_logout?: boolean; + /** The robot account token duration in days */ + robot_token_duration?: number; + /** The rebot account name prefix */ + robot_name_prefix?: string; + /** Enable notification */ + notification_enable?: boolean; + /** Enable quota per project */ + quota_per_project_enable?: boolean; + /** The storage quota per project */ + storage_per_project?: number; + /** The audit log forward endpoint */ + audit_log_forward_endpoint?: string; + /** Skip audit log database */ + skip_audit_log_database?: boolean; + /** The session timeout for harbor, in minutes. */ + session_timeout?: number; + /** Whether or not to skip update pull time for scanner */ + scanner_skip_update_pulltime?: boolean; + /** The banner message for the UI.It is the stringified result of the banner message object */ + banner_message?: string; + /** the list to disable log audit event types. */ + disabled_audit_log_event_types?: string; +} + +export interface StringConfigItem { + /** The string value of current config item */ + value: string; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface BoolConfigItem { + /** The boolean value of current config item */ + value: boolean; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface IntegerConfigItem { + /** The integer value of current config item */ + value: number; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface ProjectMemberEntity { + /** the project member id */ + id?: number; + /** the project id */ + project_id?: number; + /** the name of the group member. */ + entity_name?: string; + /** the name of the role */ + role_name?: string; + /** the role id */ + role_id?: number; + /** the id of entity, if the member is a user, it is user_id in user table. if the member is a user group, it is the user group's ID in user_group table. */ + entity_id?: number; + /** the entity's type, u for user entity, g for group entity. */ + entity_type?: string; +} + +export interface ProjectMember { + /** The role id 1 for projectAdmin, 2 for developer, 3 for guest, 4 for maintainer */ + role_id?: number; + member_user?: UserEntity; + member_group?: UserGroup; +} + +export interface RoleRequest { + /** The role id 1 for projectAdmin, 2 for developer, 3 for guest, 4 for maintainer */ + role_id?: number; +} + +export interface UserEntity { + /** The ID of the user. */ + user_id?: number; + /** The name of the user. */ + username?: string; +} + +export interface UserProfile { + email?: string; + realname?: string; + comment?: string; +} + +export interface UserCreationReq { + /** @maxLength 255 */ + email?: string; + realname?: string; + comment?: string; + password?: string; + /** @maxLength 255 */ + username?: string; +} + +export interface OIDCUserInfo { + /** + * the ID of the OIDC info record + * @format int + */ + id?: number; + /** + * the ID of the user + * @format int + */ + user_id?: number; + /** the concatenation of sub and issuer in the ID token */ + subiss?: string; + /** the secret of the OIDC user that can be used for CLI to push/pull artifacts */ + secret?: string; + /** + * The creation time of the OIDC user info record. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the OIDC user info record. + * @format date-time + */ + update_time?: string; +} + +export interface UserResp { + email?: string; + realname?: string; + comment?: string; + /** @format int */ + user_id?: number; + username?: string; + sysadmin_flag: boolean; + /** indicate the admin privilege is grant by authenticator (LDAP), is always false unless it is the current login user */ + admin_role_in_auth: boolean; + oidc_user_meta?: OIDCUserInfo; + /** + * The creation time of the user. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the user. + * @format date-time + */ + update_time?: string; +} + +export interface UserSysAdminFlag { + /** true-admin, false-not admin. */ + sysadmin_flag?: boolean; +} + +export interface UserSearch { + /** + * The ID of the user. + * @format int + */ + user_id?: number; + username?: string; +} + +export interface PasswordReq { + /** The user's existing password. */ + old_password?: string; + /** New password for marking as to be updated. */ + new_password?: string; +} + +export interface UserSearchRespItem { + /** + * The ID of the user. + * @format int + */ + user_id?: number; + username?: string; +} + +export interface Permission { + /** The permission resoruce */ + resource?: string; + /** The permission action */ + action?: string; +} + +export interface Permissions { + /** The system level permissions */ + system?: Permission[]; + /** The project level permissions */ + project?: Permission[]; +} + +export interface OIDCCliSecretReq { + /** The new secret */ + secret?: string; +} + +/** The system health status */ +export interface OverallHealthStatus { + /** The overall health status. It is "healthy" only when all the components' status are "healthy" */ + status?: string; + components?: ComponentHealthStatus[]; +} + +/** The health status of component */ +export interface ComponentHealthStatus { + /** The component name */ + name?: string; + /** The health status of component. Is either "healthy" or "unhealthy". */ + status?: string; + /** (optional) The error message when the status is "unhealthy" */ + error?: string; +} + +export interface Statistic { + /** + * The count of the private projects + * @format int64 + */ + private_project_count: number; + /** + * The count of the private repositories + * @format int64 + */ + private_repo_count: number; + /** + * The count of the public projects + * @format int64 + */ + public_project_count: number; + /** + * The count of the public repositories + * @format int64 + */ + public_repo_count: number; + /** + * The count of the total projects, only be seen by the system admin + * @format int64 + */ + total_project_count: number; + /** + * The count of the total repositories, only be seen by the system admin + * @format int64 + */ + total_repo_count: number; + /** + * The total storage consumption of blobs, only be seen by the system admin + * @format int64 + */ + total_storage_consumption: number; +} + +/** The accessory of the artifact */ +export interface Accessory { + /** + * The ID of the accessory + * @format int64 + */ + id?: number; + /** + * The artifact id of the accessory + * @format int64 + */ + artifact_id: number; + /** + * Going to be deprecated, use repo and digest for insteand. The subject artifact id of the accessory. + * @format int64 + */ + subject_artifact_id?: number; + /** The subject artifact digest of the accessory */ + subject_artifact_digest: string; + /** The subject artifact repository name of the accessory */ + subject_artifact_repo: string; + /** + * The artifact size of the accessory + * @format int64 + */ + size: number; + /** The artifact digest of the accessory */ + digest: string; + /** The artifact size of the accessory */ + type: string; + /** The icon of the accessory */ + icon: string; + /** + * The creation time of the accessory + * @format date-time + */ + creation_time?: string; +} + +/** The criteria to select the scan data to export. */ +export interface ScanDataExportRequest { + /** Name of the scan data export job */ + job_name?: string; + /** A list of one or more projects for which to export the scan data, currently only one project is supported due to performance concerns, but define as array for extension in the future. */ + projects?: number[]; + /** A list of one or more labels for which to export the scan data, defaults to all if empty */ + labels?: number[]; + /** A list of repositories for which to export the scan data, defaults to all if empty */ + repositories?: string; + /** CVE-IDs for which to export data. Multiple CVE-IDs can be specified by separating using ',' and enclosed between '{}'. Defaults to all if empty */ + cveIds?: string; + /** A list of tags enclosed within '{}'. Defaults to all if empty */ + tags?: string; +} + +/** The metadata associated with the scan data export job */ +export interface ScanDataExportJob { + /** + * The id of the scan data export job + * @format int64 + */ + id?: number; +} + +/** The replication execution */ +export interface ScanDataExportExecution { + /** The ID of the execution */ + id?: number; + /** The ID if the user triggering the export job */ + user_id?: number; + /** The status of the execution */ + status?: string; + /** The trigger mode */ + trigger?: string; + /** + * The start time + * @format date-time + */ + start_time?: string; + /** + * The end time + * @format date-time + */ + end_time?: string; + /** The status text */ + status_text: string; + /** The name of the user triggering the job */ + user_name: string; + /** Indicates whether the export artifact is present in registry */ + file_present: boolean; +} + +/** The list of scan data export executions */ +export interface ScanDataExportExecutionList { + /** The list of scan data export executions */ + items?: ScanDataExportExecution[]; +} + +/** the worker pool of job service */ +export interface WorkerPool { + /** the process id of jobservice */ + pid?: number; + /** the id of the worker pool */ + worker_pool_id?: string; + /** + * The start time of the work pool + * @format date-time + */ + start_at?: string; + /** + * The heartbeat time of the work pool + * @format date-time + */ + heartbeat_at?: string; + /** The concurrency of the work pool */ + concurrency?: number; + /** The host of the work pool */ + host?: string; +} + +/** worker in the pool */ +export interface Worker { + /** the id of the worker */ + id?: string; + /** the id of the worker pool */ + pool_id?: string; + /** the name of the running job in the worker */ + job_name?: string; + /** the id of the running job in the worker */ + job_id?: string; + /** + * The start time of the worker + * @format date-time + */ + start_at?: string | null; + /** the checkin of the running job in the worker */ + check_in?: string; + /** + * The checkin time of the worker + * @format date-time + */ + checkin_at?: string | null; +} + +/** The request to stop, pause or resume */ +export interface ActionRequest { + /** The action of the request, should be stop, pause or resume */ + action?: "stop" | "pause" | "resume"; +} + +/** the job queue info */ +export interface JobQueue { + /** The type of the job queue */ + job_type?: string; + /** The count of jobs in the job queue */ + count?: number; + /** The latency the job queue (seconds) */ + latency?: number; + /** The paused status of the job queue */ + paused: boolean; +} + +/** the schedule task info */ +export interface ScheduleTask { + /** the id of the Schedule task */ + id?: number; + /** the vendor type of the current schedule task */ + vendor_type?: string; + /** the vendor id of the current task */ + vendor_id?: number; + /** the cron of the current schedule task */ + cron?: string; + /** + * the update time of the schedule task + * @format date-time + */ + update_time?: string; +} + +/** the scheduler status */ +export interface SchedulerStatus { + /** if the scheduler is paused */ + paused: boolean; +} + +/** the security summary */ +export interface SecuritySummary { + /** + * the count of critical vulnerabilities + * @format int64 + */ + critical_cnt: number; + /** + * the count of high vulnerabilities + * @format int64 + */ + high_cnt?: number; + /** + * the count of medium vulnerabilities + * @format int64 + */ + medium_cnt: number; + /** + * the count of low vulnerabilities + * @format int64 + */ + low_cnt: number; + /** + * the count of none vulnerabilities + * @format int64 + */ + none_cnt?: number; + /** + * the count of unknown vulnerabilities + * @format int64 + */ + unknown_cnt?: number; + /** + * the count of total vulnerabilities + * @format int64 + */ + total_vuls: number; + /** + * the count of scanned artifacts + * @format int64 + */ + scanned_cnt: number; + /** + * the total count of artifacts + * @format int64 + */ + total_artifact: number; + /** + * the count of fixable vulnerabilities + * @format int64 + */ + fixable_cnt: number; + /** the list of dangerous CVEs */ + dangerous_cves?: DangerousCVE[]; + /** the list of dangerous artifacts */ + dangerous_artifacts?: DangerousArtifact[]; +} + +/** the dangerous CVE information */ +export interface DangerousCVE { + /** the cve id */ + cve_id?: string; + /** the severity of the CVE */ + severity?: string; + /** + * the cvss score v3 + * @format float64 + */ + cvss_score_v3?: number; + /** the description of the CVE */ + desc?: string; + /** the package of the CVE */ + package?: string; + /** the version of the package */ + version?: string; +} + +/** the dangerous artifact information */ +export interface DangerousArtifact { + /** + * the project id of the artifact + * @format int64 + */ + project_id?: number; + /** the repository name of the artifact */ + repository_name?: string; + /** the digest of the artifact */ + digest?: string; + /** the count of critical vulnerabilities */ + critical_cnt: number; + /** + * the count of high vulnerabilities + * @format int64 + */ + high_cnt: number; + /** the count of medium vulnerabilities */ + medium_cnt: number; +} + +/** the vulnerability item info */ +export interface VulnerabilityItem { + /** + * the project ID of the artifact + * @format int64 + */ + project_id?: number; + /** the repository name of the artifact */ + repository_name?: string; + /** the digest of the artifact */ + digest?: string; + /** the tags of the artifact */ + tags?: string[]; + /** the CVE id of the vulnerability. */ + cve_id?: string; + /** the severity of the vulnerability */ + severity?: string; + /** + * the nvd cvss v3 score of the vulnerability + * @format float + */ + cvss_v3_score?: number; + /** the package of the vulnerability */ + package?: string; + /** the version of the package */ + version?: string; + /** the fixed version of the package */ + fixed_version?: string; + /** The description of the vulnerability */ + desc?: string; + /** Links of the vulnerability */ + links?: string[]; +} + +export interface ScanType { + /** The scan type for the scan request. Two options are currently supported, vulnerability and sbom */ + scan_type?: "vulnerability" | "sbom"; +} + +import type { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + HeadersDefaults, + ResponseType, +} from "axios"; +import axios from "axios"; + +export type QueryParamsType = Record; + +export interface FullRequestParams + extends Omit { + /** set parameter to `true` for call `securityWorker` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseType; + /** request body */ + body?: unknown; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig + extends Omit { + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | AxiosRequestConfig | void; + secure?: boolean; + format?: ResponseType; +} + +export enum ContentType { + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public instance: AxiosInstance; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private secure?: boolean; + private format?: ResponseType; + + constructor({ + securityWorker, + secure, + format, + ...axiosConfig + }: ApiConfig = {}) { + this.instance = axios.create({ + ...axiosConfig, + baseURL: axiosConfig.baseURL || "http://localhost/api/v2.0", + }); + this.secure = secure; + this.format = format; + this.securityWorker = securityWorker; + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected mergeRequestParams( + params1: AxiosRequestConfig, + params2?: AxiosRequestConfig, + ): AxiosRequestConfig { + const method = params1.method || (params2 && params2.method); + + return { + ...this.instance.defaults, + ...params1, + ...(params2 || {}), + headers: { + ...((method && + this.instance.defaults.headers[ + method.toLowerCase() as keyof HeadersDefaults + ]) || + {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected stringifyFormItem(formItem: unknown) { + if (typeof formItem === "object" && formItem !== null) { + return JSON.stringify(formItem); + } else { + return `${formItem}`; + } + } + + protected createFormData(input: Record): FormData { + if (input instanceof FormData) { + return input; + } + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + const isFileType = formItem instanceof Blob || formItem instanceof File; + formData.append( + key, + isFileType ? formItem : this.stringifyFormItem(formItem), + ); + } + + return formData; + }, new FormData()); + } + + public request = async ({ + secure, + path, + type, + query, + format, + body, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const responseFormat = format || this.format || undefined; + + if ( + type === ContentType.FormData && + body && + body !== null && + typeof body === "object" + ) { + body = this.createFormData(body as Record); + } + + if ( + type === ContentType.Text && + body && + body !== null && + typeof body !== "string" + ) { + body = JSON.stringify(body); + } + + return this.instance.request({ + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type ? { "Content-Type": type } : {}), + }, + params: query, + responseType: responseFormat, + data: body, + url: path, + }); + }; +} + +/** + * @title Harbor API + * @version 2.0 + * @baseUrl http://localhost/api/v2.0 + * + * These APIs provide services for manipulating Harbor project. + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + health = { + /** + * @description Check the status of Harbor components. This path does not require authentication. + * + * @tags health + * @name GetHealth + * @summary Check the status of Harbor components + * @request GET:/health + * @secure + */ + getHealth: (params: RequestParams = {}) => + this.request({ + path: `/health`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + search = { + /** + * @description The Search endpoint returns information about the projects and repositories offered at public status or related to the current logged in user. The response includes the project and repository list in a proper display order. + * + * @tags search + * @name Search + * @summary Search for projects and repositories + * @request GET:/search + * @secure + */ + search: ( + query: { + /** Search parameter for project and repository name. */ + q: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + statistics = { + /** + * @description Get the statistic information about the projects and repositories + * + * @tags statistic + * @name GetStatistic + * @summary Get the statistic information about the projects and repositories + * @request GET:/statistics + * @secure + */ + getStatistic: (params: RequestParams = {}) => + this.request({ + path: `/statistics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + ldap = { + /** + * @description This endpoint ping the available ldap service for test related configuration parameters. + * + * @tags Ldap + * @name PingLdap + * @summary Ping available ldap service. + * @request POST:/ldap/ping + * @secure + */ + pingLdap: (ldapconf: LdapConf, params: RequestParams = {}) => + this.request({ + path: `/ldap/ping`, + method: "POST", + body: ldapconf, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint searches the available ldap users based on related configuration parameters. Support searched by input ldap configuration, load configuration from the system and specific filter. + * + * @tags Ldap + * @name SearchLdapUser + * @summary Search available ldap users. + * @request GET:/ldap/users/search + * @secure + */ + searchLdapUser: ( + query?: { + /** Registered user ID */ + username?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/ldap/users/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint adds the selected available ldap users to harbor based on related configuration parameters from the system. System will try to guess the user email address and realname, add to harbor user information. If have errors when import user, will return the list of importing failed uid and the failed reason. + * + * @tags Ldap + * @name ImportLdapUser + * @summary Import selected available ldap users. + * @request POST:/ldap/users/import + * @secure + */ + importLdapUser: (uid_list: LdapImportUsers, params: RequestParams = {}) => + this.request({ + path: `/ldap/users/import`, + method: "POST", + body: uid_list, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint searches the available ldap groups based on related configuration parameters. support to search by groupname or groupdn. + * + * @tags Ldap + * @name SearchLdapGroup + * @summary Search available ldap groups. + * @request GET:/ldap/groups/search + * @secure + */ + searchLdapGroup: ( + query?: { + /** Ldap group name */ + groupname?: string; + /** The LDAP group DN */ + groupdn?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/ldap/groups/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + internalconfig = { + /** + * @description This endpoint is for retrieving system configurations that only provides for internal api call. + * + * @tags configure + * @name GetInternalconfig + * @summary Get internal configurations. + * @request GET:/internalconfig + * @secure + */ + getInternalconfig: (params: RequestParams = {}) => + this.request({ + path: `/internalconfig`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + configurations = { + /** + * @description This endpoint is for retrieving system configurations that only provides for admin user. + * + * @tags configure + * @name GetConfigurations + * @summary Get system configurations. + * @request GET:/configurations + * @secure + */ + getConfigurations: (params: RequestParams = {}) => + this.request({ + path: `/configurations`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for modifying system configurations that only provides for admin user. + * + * @tags configure + * @name UpdateConfigurations + * @summary Modify system configurations. + * @request PUT:/configurations + * @secure + */ + updateConfigurations: ( + configurations: Configurations, + params: RequestParams = {}, + ) => + this.request({ + path: `/configurations`, + method: "PUT", + body: configurations, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + projects = { + /** + * @description This endpoint returns projects created by Harbor. + * + * @tags project + * @name ListProjects + * @summary List projects + * @request GET:/projects + * @secure + */ + listProjects: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** The name of project. */ + name?: string; + /** The project is public or private. */ + public?: boolean; + /** The name of project owner. */ + owner?: string; + /** + * Bool value indicating whether return detailed information of the project + * @default true + */ + with_detail?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is used to check if the project name provided already exist. + * + * @tags project + * @name HeadProject + * @summary Check if the project name user provided already exists. + * @request HEAD:/projects + * @secure + */ + headProject: ( + query: { + /** Project name for checking exists. */ + project_name: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects`, + method: "HEAD", + query: query, + secure: true, + ...params, + }), + + /** + * @description This endpoint is for user to create a new project. + * + * @tags project + * @name CreateProject + * @summary Create a new project. + * @request POST:/projects + * @secure + */ + createProject: (project: ProjectReq, params: RequestParams = {}) => + this.request({ + path: `/projects`, + method: "POST", + body: project, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint returns specific project information by project ID. + * + * @tags project + * @name GetProject + * @summary Return specific project detail information + * @request GET:/projects/{project_name_or_id} + * @secure + */ + getProject: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is aimed to update the properties of a project. + * + * @tags project + * @name UpdateProject + * @summary Update properties for a selected project. + * @request PUT:/projects/{project_name_or_id} + * @secure + */ + updateProject: ( + projectNameOrId: string, + project: ProjectReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "PUT", + body: project, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is aimed to delete project by project ID. + * + * @tags project + * @name DeleteProject + * @summary Delete project by projectID + * @request DELETE:/projects/{project_name_or_id} + * @secure + */ + deleteProject: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Get the deletable status of the project + * + * @tags project + * @name GetProjectDeletable + * @summary Get the deletable status of the project + * @request GET:/projects/{project_name_or_id}/_deletable + * @secure + */ + getProjectDeletable: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/_deletable`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get summary of the project. + * + * @tags project + * @name GetProjectSummary + * @summary Get summary of the project. + * @request GET:/projects/{project_name_or_id}/summary + * @secure + */ + getProjectSummary: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}/summary`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get all project member information + * + * @tags member + * @name ListProjectMembers + * @summary Get all project member information + * @request GET:/projects/{project_name_or_id}/members + * @secure + */ + listProjectMembers: ( + projectNameOrId: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The entity name to search. */ + entityname?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create project member relationship, the member can be one of the user_member and group_member, The user_member need to specify user_id or username. If the user already exist in harbor DB, specify the user_id, If does not exist in harbor DB, it will SearchAndOnBoard the user. The group_member need to specify id or ldap_group_dn. If the group already exist in harbor DB. specify the user group's id, If does not exist, it will SearchAndOnBoard the group. + * + * @tags member + * @name CreateProjectMember + * @summary Create project member + * @request POST:/projects/{project_name_or_id}/members + * @secure + */ + createProjectMember: ( + projectNameOrId: string, + project_member: ProjectMember, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members`, + method: "POST", + body: project_member, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the project member information + * + * @tags member + * @name GetProjectMember + * @summary Get the project member information + * @request GET:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + getProjectMember: ( + projectNameOrId: string, + mid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update project member relationship + * + * @tags member + * @name UpdateProjectMember + * @summary Update project member + * @request PUT:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + updateProjectMember: ( + projectNameOrId: string, + mid: number, + role: RoleRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "PUT", + body: role, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags member + * @name DeleteProjectMember + * @summary Delete project member + * @request DELETE:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + deleteProjectMember: ( + projectNameOrId: string, + mid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Get the metadata of the specific project + * + * @tags projectMetadata + * @name ListProjectMetadatas + * @summary Get the metadata of the specific project + * @request GET:/projects/{project_name_or_id}/metadatas/ + * @secure + */ + listProjectMetadatas: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Add metadata for the specific project + * + * @tags projectMetadata + * @name AddProjectMetadatas + * @summary Add metadata for the specific project + * @request POST:/projects/{project_name_or_id}/metadatas/ + * @secure + */ + addProjectMetadatas: ( + projectNameOrId: string, + metadata: Annotations, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/`, + method: "POST", + body: metadata, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific metadata of the specific project + * + * @tags projectMetadata + * @name GetProjectMetadata + * @summary Get the specific metadata of the specific project + * @request GET:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + getProjectMetadata: ( + projectNameOrId: string, + metaName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update the specific metadata for the specific project + * + * @tags projectMetadata + * @name UpdateProjectMetadata + * @summary Update the specific metadata for the specific project + * @request PUT:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + updateProjectMetadata: ( + projectNameOrId: string, + metaName: string, + metadata: Annotations, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "PUT", + body: metadata, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the specific metadata for the specific project + * + * @tags projectMetadata + * @name DeleteProjectMetadata + * @summary Delete the specific metadata for the specific project + * @request DELETE:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + deleteProjectMetadata: ( + projectNameOrId: string, + metaName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List repositories of the specified project + * + * @tags repository + * @name ListRepositories + * @summary List repositories + * @request GET:/projects/{project_name}/repositories + * @secure + */ + listRepositories: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the repository specified by name + * + * @tags repository + * @name GetRepository + * @summary Get repository + * @request GET:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + getRepository: ( + projectName: string, + repositoryName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update the repository specified by name + * + * @tags repository + * @name UpdateRepository + * @summary Update repository + * @request PUT:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + updateRepository: ( + projectName: string, + repositoryName: string, + repository: Repository, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "PUT", + body: repository, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the repository specified by name + * + * @tags repository + * @name DeleteRepository + * @summary Delete repository + * @request DELETE:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + deleteRepository: ( + projectName: string, + repositoryName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List artifacts under the specific project and repository. Except the basic properties, the other supported queries in "q" includes "tags=*" to list only tagged artifacts, "tags=nil" to list only untagged artifacts, "tags=~v" to list artifacts whose tag fuzzy matches "v", "tags=v" to list artifact whose tag exactly matches "v", "labels=(id1, id2)" to list artifacts that both labels with id1 and id2 are added to + * + * @tags artifact + * @name ListArtifacts + * @summary List artifacts + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts + * @secure + */ + listArtifacts: ( + projectName: string, + repositoryName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are included inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are included inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is included inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifacts, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the immutable status is included inside the tags of the returning artifacts. Only works when setting "with_immutable_status=true" + * @default false + */ + with_immutable_status?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. Only works when setting "with_accessory=true" + * @default false + */ + with_accessory?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Copy the artifact specified in the "from" parameter to the repository. + * + * @tags artifact + * @name CopyArtifact + * @summary Copy artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts + * @secure + */ + copyArtifact: ( + projectName: string, + repositoryName: string, + query: { + /** The artifact from which the new artifact is copied from, the format should be "project/repository:tag" or "project/repository@digest". */ + from: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts`, + method: "POST", + query: query, + secure: true, + ...params, + }), + + /** + * @description Get the artifact specified by the reference under the project and repository. The reference can be digest or tag. + * + * @tags artifact + * @name GetArtifact + * @summary Get the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference} + * @secure + */ + getArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are inclued inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are inclued inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is inclued inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifact, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. + * @default false + */ + with_accessory?: boolean; + /** + * Specify whether the signature is inclued inside the returning artifacts + * @default false + */ + with_signature?: boolean; + /** + * Specify whether the immutable status is inclued inside the tags of the returning artifacts. + * @default false + */ + with_immutable_status?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the artifact specified by the reference under the project and repository. The reference can be digest or tag + * + * @tags artifact + * @name DeleteArtifact + * @summary Delete the specific artifact + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference} + * @secure + */ + deleteArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Scan the specified artifact + * + * @tags scan + * @name ScanArtifact + * @summary Scan the artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan + * @secure + */ + scanArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + scanType: ScanType, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan`, + method: "POST", + body: scanType, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Cancelling a scan job for a particular artifact + * + * @tags scan + * @name StopScanArtifact + * @summary Cancelling a scan job for a particular artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan/stop + * @secure + */ + stopScanArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + scanType: ScanType, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan/stop`, + method: "POST", + body: scanType, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the log of the scan report + * + * @tags scan + * @name GetReportLog + * @summary Get the log of the scan report + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan/{report_id}/log + * @secure + */ + getReportLog: ( + projectName: string, + repositoryName: string, + reference: string, + reportId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan/${reportId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description Create a tag for the specified artifact + * + * @tags artifact + * @name CreateTag + * @summary Create tag + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags + * @secure + */ + createTag: ( + projectName: string, + repositoryName: string, + reference: string, + tag: Tag, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags`, + method: "POST", + body: tag, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List tags of the specific artifact + * + * @tags artifact + * @name ListTags + * @summary List tags + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags + * @secure + */ + listTags: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the immutable status is included inside the returning tags + * @default false + */ + with_immutable_status?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the tag of the specified artifact + * + * @tags artifact + * @name DeleteTag + * @summary Delete tag + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags/{tag_name} + * @secure + */ + deleteTag: ( + projectName: string, + repositoryName: string, + reference: string, + tagName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags/${tagName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List accessories of the specific artifact + * + * @tags artifact + * @name ListAccessories + * @summary List accessories + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/accessories + * @secure + */ + listAccessories: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/accessories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the vulnerabilities addition of the artifact specified by the reference under the project and repository. + * + * @tags artifact + * @name GetVulnerabilitiesAddition + * @summary Get the vulnerabilities addition of the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/additions/vulnerabilities + * @secure + */ + getVulnerabilitiesAddition: ( + projectName: string, + repositoryName: string, + reference: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/additions/vulnerabilities`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the addition of the artifact specified by the reference under the project and repository. + * + * @tags artifact + * @name GetAddition + * @summary Get the addition of the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/additions/{addition} + * @secure + */ + getAddition: ( + projectName: string, + repositoryName: string, + reference: string, + addition: + | "build_history" + | "values.yaml" + | "readme.md" + | "dependencies" + | "sbom" + | "license" + | "files", + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/additions/${addition}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Add label to the specified artiact. + * + * @tags artifact + * @name AddLabel + * @summary Add label to artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/labels + * @secure + */ + addLabel: ( + projectName: string, + repositoryName: string, + reference: string, + label: Label, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/labels`, + method: "POST", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Remove the label from the specified artiact. + * + * @tags artifact + * @name RemoveLabel + * @summary Remove label from artifact + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/labels/{label_id} + * @secure + */ + removeLabel: ( + projectName: string, + repositoryName: string, + reference: string, + labelId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/labels/${labelId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List artifacts of the specified project + * + * @tags project + * @name ListArtifactsOfProject + * @summary List artifacts + * @request GET:/projects/{project_name_or_id}/artifacts + * @secure + */ + listArtifactsOfProject: ( + projectNameOrId: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are included inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are included inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is included inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifacts, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the immutable status is included inside the tags of the returning artifacts. Only works when setting "with_immutable_status=true" + * @default false + */ + with_immutable_status?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. Only works when setting "with_accessory=true" + * @default false + */ + with_accessory?: boolean; + /** + * Specify whether only the latest pushed artifact of each repository is included inside the returning artifacts. Only works when either artifact_type or media_type is included in the query. + * @default false + */ + latest_in_repository?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/artifacts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the scanner registration of the specified project. If no scanner registration is configured for the specified project, the system default scanner registration will be returned. + * + * @tags project + * @name GetScannerOfProject + * @summary Get project level scanner + * @request GET:/projects/{project_name_or_id}/scanner + * @secure + */ + getScannerOfProject: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Set one of the system configured scanner registration as the indepndent scanner of the specified project. + * + * @tags project + * @name SetScannerOfProject + * @summary Configure scanner for the specified project + * @request PUT:/projects/{project_name_or_id}/scanner + * @secure + */ + setScannerOfProject: ( + projectNameOrId: string, + payload: ProjectScanner, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner`, + method: "PUT", + body: payload, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Retrieve the system configured scanner registrations as candidates of setting project level scanner. + * + * @tags project + * @name ListScannerCandidatesOfProject + * @summary Get scanner registration candidates for configurating project level scanner + * @request GET:/projects/{project_name_or_id}/scanner/candidates + * @secure + */ + listScannerCandidatesOfProject: ( + projectNameOrId: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner/candidates`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get recent logs of the projects, it only query the previous version's audit log + * + * @tags project + * @name GetLogs + * @summary Get recent logs of the projects (deprecated) + * @request GET:/projects/{project_name}/logs + * @secure + */ + getLogs: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/logs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get recent logs of the projects + * + * @tags project + * @name GetLogExts + * @summary Get recent logs of the projects + * @request GET:/projects/{project_name}/auditlog-exts + * @secure + */ + getLogExts: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/auditlog-exts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a preheat policy under a project + * + * @tags preheat + * @name CreatePolicy + * @summary Create a preheat policy under a project + * @request POST:/projects/{project_name}/preheat/policies + * @secure + */ + createPolicy: ( + projectName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List preheat policies + * + * @tags preheat + * @name ListPolicies + * @summary List preheat policies + * @request GET:/projects/{project_name}/preheat/policies + * @secure + */ + listPolicies: ( + projectName: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a preheat policy + * + * @tags preheat + * @name GetPolicy + * @summary Get a preheat policy + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + getPolicy: ( + projectName: string, + preheatPolicyName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update preheat policy + * + * @tags preheat + * @name UpdatePolicy + * @summary Update preheat policy + * @request PUT:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + updatePolicy: ( + projectName: string, + preheatPolicyName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Manual preheat + * + * @tags preheat + * @name ManualPreheat + * @summary Manual preheat + * @request POST:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + manualPreheat: ( + projectName: string, + preheatPolicyName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete a preheat policy + * + * @tags preheat + * @name DeletePolicy + * @summary Delete a preheat policy + * @request DELETE:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + deletePolicy: ( + projectName: string, + preheatPolicyName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List executions for the given policy + * + * @tags preheat + * @name ListExecutions + * @summary List executions for the given policy + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions + * @secure + */ + listExecutions: ( + projectName: string, + preheatPolicyName: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a execution detail by id + * + * @tags preheat + * @name GetExecution + * @summary Get a execution detail by id + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id} + * @secure + */ + getExecution: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop a execution + * + * @tags preheat + * @name StopExecution + * @summary Stop a execution + * @request PATCH:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id} + * @secure + */ + stopExecution: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + execution: Execution, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}`, + method: "PATCH", + body: execution, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List all the related tasks for the given execution + * + * @tags preheat + * @name ListTasks + * @summary List all the related tasks for the given execution + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id}/tasks + * @secure + */ + listTasks: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the log text stream of the specified task for the given execution + * + * @tags preheat + * @name GetPreheatLog + * @summary Get the log text stream of the specified task for the given execution + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id}/tasks/{task_id}/logs + * @secure + */ + getPreheatLog: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}/tasks/${taskId}/logs`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description Get all providers at project level + * + * @tags preheat + * @name ListProvidersUnderProject + * @summary Get all providers at project level + * @request GET:/projects/{project_name}/preheat/providers + * @secure + */ + listProvidersUnderProject: ( + projectName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/providers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the immutable tag rules of a project + * + * @tags immutable + * @name ListImmuRules + * @summary List all immutable tag rules of current project + * @request GET:/projects/{project_name_or_id}/immutabletagrules + * @secure + */ + listImmuRules: ( + projectNameOrId: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint add an immutable tag rule to the project + * + * @tags immutable + * @name CreateImmuRule + * @summary Add an immutable tag rule to current project + * @request POST:/projects/{project_name_or_id}/immutabletagrules + * @secure + */ + createImmuRule: ( + projectNameOrId: string, + ImmutableRule: ImmutableRule, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules`, + method: "POST", + body: ImmutableRule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags immutable + * @name UpdateImmuRule + * @summary Update the immutable tag rule or enable or disable the rule + * @request PUT:/projects/{project_name_or_id}/immutabletagrules/{immutable_rule_id} + * @secure + */ + updateImmuRule: ( + projectNameOrId: string, + immutableRuleId: number, + ImmutableRule: ImmutableRule, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules/${immutableRuleId}`, + method: "PUT", + body: ImmutableRule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags immutable + * @name DeleteImmuRule + * @summary Delete the immutable tag rule. + * @request DELETE:/projects/{project_name_or_id}/immutabletagrules/{immutable_rule_id} + * @secure + */ + deleteImmuRule: ( + projectNameOrId: string, + immutableRuleId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules/${immutableRuleId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns webhook policies of a project. + * + * @tags webhook + * @name ListWebhookPoliciesOfProject + * @summary List project webhook policies. + * @request GET:/projects/{project_name_or_id}/webhook/policies + * @secure + */ + listWebhookPoliciesOfProject: ( + projectNameOrId: string, + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint create a webhook policy if the project does not have one. + * + * @tags webhook + * @name CreateWebhookPolicyOfProject + * @summary Create project webhook policy. + * @request POST:/projects/{project_name_or_id}/webhook/policies + * @secure + */ + createWebhookPolicyOfProject: ( + projectNameOrId: string, + policy: WebhookPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint returns specified webhook policy of a project. + * + * @tags webhook + * @name GetWebhookPolicyOfProject + * @summary Get project webhook policy + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + getWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is aimed to update the webhook policy of a project. + * + * @tags webhook + * @name UpdateWebhookPolicyOfProject + * @summary Update webhook policy of a project. + * @request PUT:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + updateWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + policy: WebhookPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is aimed to delete webhookpolicy of a project. + * + * @tags webhook + * @name DeleteWebhookPolicyOfProject + * @summary Delete webhook policy of a project + * @request DELETE:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + deleteWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns the executions of a specific webhook policy. + * + * @tags webhook + * @name ListExecutionsOfWebhookPolicy + * @summary List executions for a specific webhook policy + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions + * @secure + */ + listExecutionsOfWebhookPolicy: ( + projectNameOrId: string, + webhookPolicyId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the tasks of a specific webhook execution. + * + * @tags webhook + * @name ListTasksOfWebhookExecution + * @summary List tasks for a specific webhook execution + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions/{execution_id}/tasks + * @secure + */ + listTasksOfWebhookExecution: ( + projectNameOrId: string, + webhookPolicyId: number, + executionId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions/${executionId}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the logs of a specific webhook task. + * + * @tags webhook + * @name GetLogsOfWebhookTask + * @summary Get logs for a specific webhook task + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions/{execution_id}/tasks/{task_id}/log + * @secure + */ + getLogsOfWebhookTask: ( + projectNameOrId: string, + webhookPolicyId: number, + executionId: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions/${executionId}/tasks/${taskId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns last trigger information of project webhook policy. + * + * @tags webhook + * @name LastTrigger + * @summary Get project webhook policy last trigger info + * @request GET:/projects/{project_name_or_id}/webhook/lasttrigger + * @deprecated + * @secure + */ + lastTrigger: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/lasttrigger`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns webhook jobs of a project. + * + * @tags webhookjob + * @name ListWebhookJobs + * @summary List project webhook jobs + * @request GET:/projects/{project_name_or_id}/webhook/jobs + * @deprecated + * @secure + */ + listWebhookJobs: ( + projectNameOrId: string, + query: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * The policy ID. + * @format int64 + */ + policy_id: number; + /** The status of webhook job. */ + status?: string[]; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/jobs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get supported event types and notify types. + * + * @tags webhook + * @name GetSupportedEventTypes + * @summary Get supported event types and notify types. + * @request GET:/projects/{project_name_or_id}/webhook/events + * @secure + */ + getSupportedEventTypes: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/events`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + repositories = { + /** + * @description List all authorized repositories + * + * @tags repository + * @name ListAllRepositories + * @summary List all authorized repositories + * @request GET:/repositories + * @secure + */ + listAllRepositories: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/repositories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + auditLogs = { + /** + * @description This endpoint let the user see the recent operation logs of projects which the user is a member with project admin role,, or return all audit logs for system admin user, it only query the audit log in previous version. + * + * @tags auditlog + * @name ListAuditLogs + * @summary Get recent logs of projects which the user is a member with project admin role, or return all audit logs for system admin user (deprecated) + * @request GET:/audit-logs + * @secure + */ + listAuditLogs: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/audit-logs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + auditlogExts = { + /** + * @description This endpoint let user see the recent operation logs of the projects which he is member with project_admin role, or return all audit logs for system admin user. + * + * @tags auditlog + * @name ListAuditLogExts + * @summary Get recent logs of the projects which the user is a member with project_admin role, or return all audit logs for system admin user + * @request GET:/auditlog-exts + * @secure + */ + listAuditLogExts: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/auditlog-exts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get all event types of audit log + * + * @tags auditlog + * @name ListAuditLogEventTypes + * @summary Get all event types of audit log + * @request GET:/auditlog-exts/events + * @secure + */ + listAuditLogEventTypes: (params: RequestParams = {}) => + this.request({ + path: `/auditlog-exts/events`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + p2P = { + /** + * @description List P2P providers + * + * @tags preheat + * @name ListProviders + * @summary List P2P providers + * @request GET:/p2p/preheat/providers + * @secure + */ + listProviders: (params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/providers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint checks status of a instance, the instance can be given by ID or Endpoint URL (together with credential) + * + * @tags preheat + * @name PingInstances + * @summary Ping status of a instance. + * @request POST:/p2p/preheat/instances/ping + * @secure + */ + pingInstances: (instance: Instance, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/ping`, + method: "POST", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List P2P provider instances + * + * @tags preheat + * @name ListInstances + * @summary List P2P provider instances + * @request GET:/p2p/preheat/instances + * @secure + */ + listInstances: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/p2p/preheat/instances`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create p2p provider instances + * + * @tags preheat + * @name CreateInstance + * @summary Create p2p provider instances + * @request POST:/p2p/preheat/instances + * @secure + */ + createInstance: (instance: Instance, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances`, + method: "POST", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get a P2P provider instance + * + * @tags preheat + * @name GetInstance + * @summary Get a P2P provider instance + * @request GET:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + getInstance: (preheatInstanceName: string, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specified P2P provider instance + * + * @tags preheat + * @name DeleteInstance + * @summary Delete the specified P2P provider instance + * @request DELETE:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + deleteInstance: (preheatInstanceName: string, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the specified P2P provider instance + * + * @tags preheat + * @name UpdateInstance + * @summary Update the specified P2P provider instance + * @request PUT:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + updateInstance: ( + preheatInstanceName: string, + instance: Instance, + params: RequestParams = {}, + ) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "PUT", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + usergroups = { + /** + * @description Get all user groups information, it is open for system admin + * + * @tags usergroup + * @name ListUserGroups + * @summary Get all user groups information + * @request GET:/usergroups + * @secure + */ + listUserGroups: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** search with ldap group DN */ + ldap_group_dn?: string; + /** group name need to search, fuzzy matches */ + group_name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create user group information + * + * @tags usergroup + * @name CreateUserGroup + * @summary Create user group + * @request POST:/usergroups + * @secure + */ + createUserGroup: (usergroup: UserGroup, params: RequestParams = {}) => + this.request({ + path: `/usergroups`, + method: "POST", + body: usergroup, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is to search groups by group name. It's open for all authenticated requests. + * + * @tags usergroup + * @name SearchUserGroups + * @summary Search groups by groupname + * @request GET:/usergroups/search + * @secure + */ + searchUserGroups: ( + query: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Group name for filtering results. */ + groupname: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get user group information + * + * @tags usergroup + * @name GetUserGroup + * @summary Get user group information + * @request GET:/usergroups/{group_id} + * @secure + */ + getUserGroup: (groupId: number, params: RequestParams = {}) => + this.request({ + path: `/usergroups/${groupId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update user group information + * + * @tags usergroup + * @name UpdateUserGroup + * @summary Update group information + * @request PUT:/usergroups/{group_id} + * @secure + */ + updateUserGroup: ( + groupId: number, + usergroup: UserGroup, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups/${groupId}`, + method: "PUT", + body: usergroup, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete user group + * + * @tags usergroup + * @name DeleteUserGroup + * @summary Delete user group + * @request DELETE:/usergroups/{group_id} + * @secure + */ + deleteUserGroup: (groupId: number, params: RequestParams = {}) => + this.request({ + path: `/usergroups/${groupId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + icons = { + /** + * @description Get the artifact icon with the specified digest. As the original icon image is resized and encoded before returning, the parameter "digest" in the path doesn't match the hash of the returned content + * + * @tags icon + * @name GetIcon + * @summary Get artifact icon + * @request GET:/icons/{digest} + * @secure + */ + getIcon: (digest: string, params: RequestParams = {}) => + this.request({ + path: `/icons/${digest}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + robots = { + /** + * @description List the robot accounts with the specified level and project. + * + * @tags robot + * @name ListRobot + * @summary Get robot account + * @request GET:/robots + * @secure + */ + listRobot: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/robots`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a robot account + * + * @tags robot + * @name CreateRobot + * @summary Create a robot account + * @request POST:/robots + * @secure + */ + createRobot: (robot: RobotCreate, params: RequestParams = {}) => + this.request({ + path: `/robots`, + method: "POST", + body: robot, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns specific robot account information by robot ID. + * + * @tags robot + * @name GetRobotById + * @summary Get a robot account + * @request GET:/robots/{robot_id} + * @secure + */ + getRobotById: (robotId: number, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint updates specific robot account information by robot ID. + * + * @tags robot + * @name UpdateRobot + * @summary Update a robot account + * @request PUT:/robots/{robot_id} + * @secure + */ + updateRobot: (robotId: number, robot: Robot, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "PUT", + body: robot, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Refresh the robot secret + * + * @tags robot + * @name RefreshSec + * @summary Refresh the robot secret + * @request PATCH:/robots/{robot_id} + * @secure + */ + refreshSec: ( + robotId: number, + robotSec: RobotSec, + params: RequestParams = {}, + ) => + this.request({ + path: `/robots/${robotId}`, + method: "PATCH", + body: robotSec, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint deletes specific robot account information by robot ID. + * + * @tags robot + * @name DeleteRobot + * @summary Delete a robot account + * @request DELETE:/robots/{robot_id} + * @secure + */ + deleteRobot: (robotId: number, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + quotas = { + /** + * @description List quotas + * + * @tags quota + * @name ListQuotas + * @summary List quotas + * @request GET:/quotas + * @secure + */ + listQuotas: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The reference type of quota. */ + reference?: string; + /** The reference id of quota. */ + reference_id?: string; + /** + * Sort method, valid values include: + * 'hard.resource_name', '-hard.resource_name', 'used.resource_name', '-used.resource_name'. + * Here '-' stands for descending order, resource_name should be the real resource name of the quota. + */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/quotas`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the specified quota + * + * @tags quota + * @name GetQuota + * @summary Get the specified quota + * @request GET:/quotas/{id} + * @secure + */ + getQuota: (id: number, params: RequestParams = {}) => + this.request({ + path: `/quotas/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update hard limits of the specified quota + * + * @tags quota + * @name UpdateQuota + * @summary Update the specified quota + * @request PUT:/quotas/{id} + * @secure + */ + updateQuota: ( + id: number, + hard: QuotaUpdateReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/quotas/${id}`, + method: "PUT", + body: hard, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + replication = { + /** + * @description List replication policies + * + * @tags replication + * @name ListReplicationPolicies + * @summary List replication policies + * @request GET:/replication/policies + * @secure + */ + listReplicationPolicies: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Deprecated, use "query" instead. The policy name. */ + name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a replication policy + * + * @tags replication + * @name CreateReplicationPolicy + * @summary Create a replication policy + * @request POST:/replication/policies + * @secure + */ + createReplicationPolicy: ( + policy: ReplicationPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific replication policy + * + * @tags replication + * @name GetReplicationPolicy + * @summary Get the specific replication policy + * @request GET:/replication/policies/{id} + * @secure + */ + getReplicationPolicy: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/policies/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specific replication policy + * + * @tags replication + * @name DeleteReplicationPolicy + * @summary Delete the specific replication policy + * @request DELETE:/replication/policies/{id} + * @secure + */ + deleteReplicationPolicy: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/policies/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the replication policy + * + * @tags replication + * @name UpdateReplicationPolicy + * @summary Update the replication policy + * @request PUT:/replication/policies/{id} + * @secure + */ + updateReplicationPolicy: ( + id: number, + policy: ReplicationPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies/${id}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List replication executions + * + * @tags replication + * @name ListReplicationExecutions + * @summary List replication executions + * @request GET:/replication/executions + * @secure + */ + listReplicationExecutions: ( + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The ID of the policy that the executions belong to. */ + policy_id?: number; + /** The execution status. */ + status?: string; + /** The trigger mode. */ + trigger?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Start one replication execution according to the policy + * + * @tags replication + * @name StartReplication + * @summary Start one replication execution + * @request POST:/replication/executions + * @secure + */ + startReplication: ( + execution: StartReplicationExecution, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions`, + method: "POST", + body: execution, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the replication execution specified by ID + * + * @tags replication + * @name GetReplicationExecution + * @summary Get the specific replication execution + * @request GET:/replication/executions/{id} + * @secure + */ + getReplicationExecution: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/executions/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the replication execution specified by ID + * + * @tags replication + * @name StopReplication + * @summary Stop the specific replication execution + * @request PUT:/replication/executions/{id} + * @secure + */ + stopReplication: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/executions/${id}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description List replication tasks for a specific execution + * + * @tags replication + * @name ListReplicationTasks + * @summary List replication tasks for a specific execution + * @request GET:/replication/executions/{id}/tasks + * @secure + */ + listReplicationTasks: ( + id: number, + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The task status. */ + status?: string; + /** The resource type. */ + resource_type?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions/${id}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the log of the specific replication task + * + * @tags replication + * @name GetReplicationLog + * @summary Get the log of the specific replication task + * @request GET:/replication/executions/{id}/tasks/{task_id}/log + * @secure + */ + getReplicationLog: ( + id: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions/${id}/tasks/${taskId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description List registry adapters + * + * @tags registry + * @name ListRegistryProviderTypes + * @summary List registry adapters + * @request GET:/replication/adapters + * @secure + */ + listRegistryProviderTypes: (params: RequestParams = {}) => + this.request({ + path: `/replication/adapters`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description List all registered registry provider information + * + * @tags registry + * @name ListRegistryProviderInfos + * @summary List all registered registry provider information + * @request GET:/replication/adapterinfos + * @secure + */ + listRegistryProviderInfos: (params: RequestParams = {}) => + this.request, Errors>({ + path: `/replication/adapterinfos`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + registries = { + /** + * @description Create a registry + * + * @tags registry + * @name CreateRegistry + * @summary Create a registry + * @request POST:/registries + * @secure + */ + createRegistry: (registry: Registry, params: RequestParams = {}) => + this.request({ + path: `/registries`, + method: "POST", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List the registries + * + * @tags registry + * @name ListRegistries + * @summary List the registries + * @request GET:/registries + * @secure + */ + listRegistries: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Deprecated, use `q` instead. */ + name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/registries`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Check status of a registry + * + * @tags registry + * @name PingRegistry + * @summary Check status of a registry + * @request POST:/registries/ping + * @secure + */ + pingRegistry: (registry: RegistryPing, params: RequestParams = {}) => + this.request({ + path: `/registries/ping`, + method: "POST", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific registry + * + * @tags registry + * @name GetRegistry + * @summary Get the specific registry + * @request GET:/registries/{id} + * @secure + */ + getRegistry: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specific registry + * + * @tags registry + * @name DeleteRegistry + * @summary Delete the specific registry + * @request DELETE:/registries/{id} + * @secure + */ + deleteRegistry: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the registry + * + * @tags registry + * @name UpdateRegistry + * @summary Update the registry + * @request PUT:/registries/{id} + * @secure + */ + updateRegistry: ( + id: number, + registry: RegistryUpdate, + params: RequestParams = {}, + ) => + this.request({ + path: `/registries/${id}`, + method: "PUT", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the registry info + * + * @tags registry + * @name GetRegistryInfo + * @summary Get the registry info + * @request GET:/registries/{id}/info + * @secure + */ + getRegistryInfo: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}/info`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + scans = { + /** + * @description Get the metrics of the latest scan all process + * + * @tags scanAll + * @name GetLatestScanAllMetrics + * @summary Get the metrics of the latest scan all process + * @request GET:/scans/all/metrics + * @secure + */ + getLatestScanAllMetrics: (params: RequestParams = {}) => + this.request({ + path: `/scans/all/metrics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the metrics of the latest scheduled scan all process + * + * @tags scanAll + * @name GetLatestScheduledScanAllMetrics + * @summary Get the metrics of the latest scheduled scan all process + * @request GET:/scans/schedule/metrics + * @deprecated + * @secure + */ + getLatestScheduledScanAllMetrics: (params: RequestParams = {}) => + this.request({ + path: `/scans/schedule/metrics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + systeminfo = { + /** + * @description This API is for retrieving general system info, this can be called by anonymous request. Some attributes will be omitted in the response when this API is called by anonymous request. + * + * @tags systeminfo + * @name GetSystemInfo + * @summary Get general system info + * @request GET:/systeminfo + * @secure + */ + getSystemInfo: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for retrieving system volume info that only provides for admin user. Note that the response only reflects the storage status of local disk. + * + * @tags systeminfo + * @name GetVolumes + * @summary Get system volume info (total/free size). + * @request GET:/systeminfo/volumes + * @secure + */ + getVolumes: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo/volumes`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for downloading a default root certificate. + * + * @tags systeminfo + * @name GetCert + * @summary Get default root certificate. + * @request GET:/systeminfo/getcert + * @secure + */ + getCert: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo/getcert`, + method: "GET", + secure: true, + ...params, + }), + }; + system = { + /** + * @description Test the OIDC endpoint, the setting of the endpoint is provided in the request. This API can only be called by system admin. + * + * @tags oidc + * @name PingOidc + * @summary Test the OIDC endpoint. + * @request POST:/system/oidc/ping + * @secure + */ + pingOidc: ( + endpoint: { + /** The URL of OIDC endpoint to be tested. */ + url?: string; + /** Whether the certificate should be verified */ + verify_cert?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/oidc/ping`, + method: "POST", + body: endpoint, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let user get gc execution history. + * + * @tags gc + * @name GetGcHistory + * @summary Get gc results. + * @request GET:/system/gc + * @secure + */ + getGcHistory: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/gc`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user get gc status filtered by specific ID. + * + * @tags gc + * @name GetGc + * @summary Get gc status. + * @request GET:/system/gc/{gc_id} + * @secure + */ + getGc: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the GC execution specified by ID + * + * @tags gc + * @name StopGc + * @summary Stop the specific GC execution + * @request PUT:/system/gc/{gc_id} + * @secure + */ + stopGc: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description This endpoint let user get gc job logs filtered by specific ID. + * + * @tags gc + * @name GetGcLog + * @summary Get gc job log. + * @request GET:/system/gc/{gc_id}/log + * @secure + */ + getGcLog: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint is for get schedule of gc job. + * + * @tags gc + * @name GetGcSchedule + * @summary Get gc's schedule. + * @request GET:/system/gc/schedule + * @secure + */ + getGcSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for update gc schedule. + * + * @tags gc + * @name CreateGcSchedule + * @summary Create a gc schedule. + * @request POST:/system/gc/schedule + * @secure + */ + createGcSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for update gc schedule. + * + * @tags gc + * @name UpdateGcSchedule + * @summary Update gc's schedule. + * @request PUT:/system/gc/schedule + * @secure + */ + updateGcSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description get purge job execution history. + * + * @tags purge + * @name GetPurgeHistory + * @summary Get purge job results. + * @request GET:/system/purgeaudit + * @secure + */ + getPurgeHistory: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/purgeaudit`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user get purge job status filtered by specific ID. + * + * @tags purge + * @name GetPurgeJob + * @summary Get purge job status. + * @request GET:/system/purgeaudit/{purge_id} + * @secure + */ + getPurgeJob: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the purge audit log execution specified by ID + * + * @tags purge + * @name StopPurge + * @summary Stop the specific purge audit log execution + * @request PUT:/system/purgeaudit/{purge_id} + * @secure + */ + stopPurge: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description This endpoint let user get purge job logs filtered by specific ID. + * + * @tags purge + * @name GetPurgeJobLog + * @summary Get purge job log. + * @request GET:/system/purgeaudit/{purge_id}/log + * @secure + */ + getPurgeJobLog: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint is for get schedule of purge job. + * + * @tags purge + * @name GetPurgeSchedule + * @summary Get purge's schedule. + * @request GET:/system/purgeaudit/schedule + * @secure + */ + getPurgeSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for update purge job schedule. + * + * @tags purge + * @name CreatePurgeSchedule + * @summary Create a purge job schedule. + * @request POST:/system/purgeaudit/schedule + * @secure + */ + createPurgeSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for update purge job schedule. + * + * @tags purge + * @name UpdatePurgeSchedule + * @summary Update purge job's schedule. + * @request PUT:/system/purgeaudit/schedule + * @secure + */ + updatePurgeSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the system level allowlist of CVE. This API can be called by all authenticated users. + * + * @tags SystemCVEAllowlist + * @name GetSystemCveAllowlist + * @summary Get the system level allowlist of CVE. + * @request GET:/system/CVEAllowlist + * @secure + */ + getSystemCveAllowlist: (params: RequestParams = {}) => + this.request({ + path: `/system/CVEAllowlist`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This API overwrites the system level allowlist of CVE with the list in request body. Only system Admin has permission to call this API. + * + * @tags SystemCVEAllowlist + * @name PutSystemCveAllowlist + * @summary Update the system level allowlist of CVE. + * @request PUT:/system/CVEAllowlist + * @secure + */ + putSystemCveAllowlist: ( + allowlist: CVEAllowlist, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/CVEAllowlist`, + method: "PUT", + body: allowlist, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for getting a schedule for the scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name GetScanAllSchedule + * @summary Get scan all's schedule. + * @request GET:/system/scanAll/schedule + * @secure + */ + getScanAllSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for updating the schedule of scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name UpdateScanAllSchedule + * @summary Update scan all's schedule. + * @request PUT:/system/scanAll/schedule + * @secure + */ + updateScanAllSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for creating a schedule or a manual trigger for the scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name CreateScanAllSchedule + * @summary Create a schedule or a manual trigger for the scan all job. + * @request POST:/system/scanAll/schedule + * @secure + */ + createScanAllSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Stop scanAll job execution + * + * @tags scanAll + * @name StopScanAll + * @summary Stop scanAll job execution + * @request POST:/system/scanAll/stop + * @secure + */ + stopScanAll: (params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/stop`, + method: "POST", + secure: true, + ...params, + }), + }; + jobservice = { + /** + * @description Get worker pools + * + * @tags jobservice + * @name GetWorkerPools + * @summary Get worker pools + * @request GET:/jobservice/pools + * @secure + */ + getWorkerPools: (params: RequestParams = {}) => + this.request({ + path: `/jobservice/pools`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get workers in current pool + * + * @tags jobservice + * @name GetWorkers + * @summary Get workers + * @request GET:/jobservice/pools/{pool_id}/workers + * @secure + */ + getWorkers: (poolId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/pools/${poolId}/workers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop running job + * + * @tags jobservice + * @name StopRunningJob + * @summary Stop running job + * @request PUT:/jobservice/jobs/{job_id} + * @secure + */ + stopRunningJob: (jobId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/jobs/${jobId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description Get job log by job id, it is only used by administrator + * + * @tags jobservice + * @name ActionGetJobLog + * @summary Get job log by job id + * @request GET:/jobservice/jobs/{job_id}/log + * @secure + */ + actionGetJobLog: (jobId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/jobs/${jobId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description list job queue + * + * @tags jobservice + * @name ListJobQueues + * @summary list job queues + * @request GET:/jobservice/queues + * @secure + */ + listJobQueues: (params: RequestParams = {}) => + this.request({ + path: `/jobservice/queues`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description stop and clean, pause, resume pending jobs in the queue + * + * @tags jobservice + * @name ActionPendingJobs + * @summary stop and clean, pause, resume pending jobs in the queue + * @request PUT:/jobservice/queues/{job_type} + * @secure + */ + actionPendingJobs: ( + jobType: string, + action_request: ActionRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/jobservice/queues/${jobType}`, + method: "PUT", + body: action_request, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + schedules = { + /** + * @description List schedules + * + * @tags schedule + * @name ListSchedules + * @request GET:/schedules + * @secure + */ + listSchedules: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/schedules`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get scheduler paused status + * + * @tags schedule + * @name GetSchedulePaused + * @request GET:/schedules/{job_type}/paused + * @secure + */ + getSchedulePaused: (jobType: string, params: RequestParams = {}) => + this.request({ + path: `/schedules/${jobType}/paused`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + ping = { + /** + * @description This API simply replies a pong to indicate the process to handle API is up, disregarding the health status of dependent components. This path does not require any authentication. + * + * @tags ping + * @name GetPing + * @summary Ping Harbor to check if the API server is alive. + * @request GET:/ping + * @secure + */ + getPing: (params: RequestParams = {}) => + this.request({ + path: `/ping`, + method: "GET", + secure: true, + ...params, + }), + }; + retentions = { + /** + * @description Get Retention Metadatas. + * + * @tags Retention + * @name GetRentenitionMetadata + * @summary Get Retention Metadatas + * @request GET:/retentions/metadatas + * @secure + */ + getRentenitionMetadata: (params: RequestParams = {}) => + this.request({ + path: `/retentions/metadatas`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when no retention policy binded to project yet. + * + * @tags Retention + * @name CreateRetention + * @summary Create Retention Policy + * @request POST:/retentions + * @secure + */ + createRetention: (policy: RetentionPolicy, params: RequestParams = {}) => + this.request({ + path: `/retentions`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention Policy. + * + * @tags Retention + * @name GetRetention + * @summary Get Retention Policy + * @request GET:/retentions/{id} + * @secure + */ + getRetention: (id: number, params: RequestParams = {}) => + this.request({ + path: `/retentions/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when retention policy has already binded to project. + * + * @tags Retention + * @name UpdateRetention + * @summary Update Retention Policy + * @request PUT:/retentions/{id} + * @secure + */ + updateRetention: ( + id: number, + policy: RetentionPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when retention policy has already binded to project. + * + * @tags Retention + * @name DeleteRetention + * @summary Delete Retention Policy + * @request DELETE:/retentions/{id} + * @secure + */ + deleteRetention: (id: number, params: RequestParams = {}) => + this.request({ + path: `/retentions/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Trigger a Retention Execution, if dry_run is True, nothing would be deleted actually. + * + * @tags Retention + * @name TriggerRetentionExecution + * @summary Trigger a Retention Execution + * @request POST:/retentions/{id}/executions + * @secure + */ + triggerRetentionExecution: ( + id: number, + body: { + dry_run?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions`, + method: "POST", + body: body, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention executions, execution status may be delayed before job service schedule it up. + * + * @tags Retention + * @name ListRetentionExecutions + * @summary Get Retention executions + * @request GET:/retentions/{id}/executions + * @secure + */ + listRetentionExecutions: ( + id: number, + query?: { + /** + * The page number. + * @format int64 + */ + page?: number; + /** + * The size of per page. + * @format int64 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop a Retention execution, only support "stop" action now. + * + * @tags Retention + * @name OperateRetentionExecution + * @summary Stop a Retention execution + * @request PATCH:/retentions/{id}/executions/{eid} + * @secure + */ + operateRetentionExecution: ( + id: number, + eid: number, + body: { + action?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}`, + method: "PATCH", + body: body, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention tasks, each repository as a task. + * + * @tags Retention + * @name ListRetentionTasks + * @summary Get Retention tasks + * @request GET:/retentions/{id}/executions/{eid}/tasks + * @secure + */ + listRetentionTasks: ( + id: number, + eid: number, + query?: { + /** + * The page number. + * @format int64 + */ + page?: number; + /** + * The size of per page. + * @format int64 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get Retention job task log, tags ratain or deletion detail will be shown in a table. + * + * @tags Retention + * @name GetRetentionTaskLog + * @summary Get Retention job task log + * @request GET:/retentions/{id}/executions/{eid}/tasks/{tid} + * @secure + */ + getRetentionTaskLog: ( + id: number, + eid: number, + tid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}/tasks/${tid}`, + method: "GET", + secure: true, + ...params, + }), + }; + scanners = { + /** + * @description Returns a list of currently configured scanner registrations. + * + * @tags scanner + * @name ListScanners + * @summary List scanner registrations + * @request GET:/scanners + * @secure + */ + listScanners: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Creats a new scanner registration with the given data. + * + * @tags scanner + * @name CreateScanner + * @summary Create a scanner registration + * @request POST:/scanners + * @secure + */ + createScanner: ( + registration: ScannerRegistrationReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners`, + method: "POST", + body: registration, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Pings scanner adapter to test endpoint URL and authorization settings. + * + * @tags scanner + * @name PingScanner + * @summary Tests scanner registration settings + * @request POST:/scanners/ping + * @secure + */ + pingScanner: ( + settings: ScannerRegistrationSettings, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/ping`, + method: "POST", + body: settings, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Retruns the details of the specified scanner registration. + * + * @tags scanner + * @name GetScanner + * @summary Get a scanner registration details + * @request GET:/scanners/{registration_id} + * @secure + */ + getScanner: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Updates the specified scanner registration. + * + * @tags scanner + * @name UpdateScanner + * @summary Update a scanner registration + * @request PUT:/scanners/{registration_id} + * @secure + */ + updateScanner: ( + registrationId: string, + registration: ScannerRegistrationReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/${registrationId}`, + method: "PUT", + body: registration, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Deletes the specified scanner registration. + * + * @tags scanner + * @name DeleteScanner + * @summary Delete a scanner registration + * @request DELETE:/scanners/{registration_id} + * @secure + */ + deleteScanner: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}`, + method: "DELETE", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Set the specified scanner registration as the system default one. + * + * @tags scanner + * @name SetScannerAsDefault + * @summary Set system default scanner registration + * @request PATCH:/scanners/{registration_id} + * @secure + */ + setScannerAsDefault: ( + registrationId: string, + payload: IsDefault, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/${registrationId}`, + method: "PATCH", + body: payload, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the metadata of the specified scanner registration, including the capabilities and customized properties. + * + * @tags scanner + * @name GetScannerMetadata + * @summary Get the metadata of the specified scanner registration + * @request GET:/scanners/{registration_id}/metadata + * @secure + */ + getScannerMetadata: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}/metadata`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + users = { + /** + * No description + * + * @tags user + * @name ListUsers + * @summary List users + * @request GET:/users + * @secure + */ + listUsers: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This API can be used only when the authentication mode is for local DB. When self registration is disabled. + * + * @tags user + * @name CreateUser + * @summary Create a local user. + * @request POST:/users + * @secure + */ + createUser: (userReq: UserCreationReq, params: RequestParams = {}) => + this.request({ + path: `/users`, + method: "POST", + body: userReq, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetCurrentUserInfo + * @summary Get current user info. + * @request GET:/users/current + * @secure + */ + getCurrentUserInfo: (params: RequestParams = {}) => + this.request({ + path: `/users/current`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is to search the users by username. It's open for all authenticated requests. + * + * @tags user + * @name SearchUsers + * @summary Search users by username + * @request GET:/users/search + * @secure + */ + searchUsers: ( + query: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Username for filtering results. */ + username: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetUser + * @summary Get a user's profile. + * @request GET:/users/{user_id} + * @secure + */ + getUser: (userId: number, params: RequestParams = {}) => + this.request({ + path: `/users/${userId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags user + * @name UpdateUserProfile + * @summary Update user's profile. + * @request PUT:/users/{user_id} + * @secure + */ + updateUserProfile: ( + userId: number, + profile: UserProfile, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}`, + method: "PUT", + body: profile, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let administrator of Harbor mark a registered user as removed.It actually won't be deleted from DB. + * + * @tags user + * @name DeleteUser + * @summary Mark a registered user as be removed. + * @request DELETE:/users/{user_id} + * @secure + */ + deleteUser: (userId: number, params: RequestParams = {}) => + this.request({ + path: `/users/${userId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * No description + * + * @tags user + * @name SetUserSysAdmin + * @summary Update a registered user to change to be an administrator of Harbor. + * @request PUT:/users/{user_id}/sysadmin + * @secure + */ + setUserSysAdmin: ( + userId: number, + sysadmin_flag: UserSysAdminFlag, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/sysadmin`, + method: "PUT", + body: sysadmin_flag, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for user to update password. Users with the admin role can change any user's password. Regular users can change only their own password. + * + * @tags user + * @name UpdateUserPassword + * @summary Change the password on a user that already exists. + * @request PUT:/users/{user_id}/password + * @secure + */ + updateUserPassword: ( + userId: number, + password: PasswordReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/password`, + method: "PUT", + body: password, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetCurrentUserPermissions + * @summary Get current user permissions. + * @request GET:/users/current/permissions + * @secure + */ + getCurrentUserPermissions: ( + query?: { + /** The scope for the permission */ + scope?: string; + /** + * If true, the resources in the response are relative to the scope, + * eg for resource '/project/1/repository' if relative is 'true' then the resource in response will be 'repository'. + */ + relative?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/current/permissions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user generate a new CLI secret for himself. This API only works when auth mode is set to 'OIDC'. Once this API returns with successful status, the old secret will be invalid, as there will be only one CLI secret for a user. + * + * @tags user + * @name SetCliSecret + * @summary Set CLI secret for a user. + * @request PUT:/users/{user_id}/cli_secret + * @secure + */ + setCliSecret: ( + userId: number, + secret: OIDCCliSecretReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/cli_secret`, + method: "PUT", + body: secret, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + labels = { + /** + * @description This endpoint let user list labels by name, scope and project_id + * + * @tags label + * @name ListLabels + * @summary List labels according to the query strings. + * @request GET:/labels + * @secure + */ + listLabels: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The label name. */ + name?: string; + /** The label scope. Valid values are g and p. g for global labels and p for project labels. */ + scope?: string; + /** + * Relevant project ID, required when scope is p. + * @format int64 + */ + project_id?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/labels`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user creates a label. + * + * @tags label + * @name CreateLabel + * @summary Post creates a label + * @request POST:/labels + * @secure + */ + createLabel: (label: Label, params: RequestParams = {}) => + this.request({ + path: `/labels`, + method: "POST", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let user get the label by specific ID. + * + * @tags label + * @name GetLabelById + * @summary Get the label specified by ID. + * @request GET:/labels/{label_id} + * @secure + */ + getLabelById: (labelId: number, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user update label properties. + * + * @tags label + * @name UpdateLabel + * @summary Update the label properties. + * @request PUT:/labels/{label_id} + * @secure + */ + updateLabel: (labelId: number, label: Label, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "PUT", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the label specified by ID. + * + * @tags label + * @name DeleteLabel + * @summary Delete the label specified by ID. + * @request DELETE:/labels/{label_id} + * @secure + */ + deleteLabel: (labelId: number, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + export = { + /** + * @description Export scan data for selected projects + * + * @tags scan data export + * @name ExportScanData + * @summary Export scan data for selected projects + * @request POST:/export/cve + * @secure + */ + exportScanData: ( + criteria: ScanDataExportRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve`, + method: "POST", + body: criteria, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description Get the scan data export execution specified by ID + * + * @tags scan data export + * @name GetScanDataExportExecution + * @summary Get the specific scan data export execution + * @request GET:/export/cve/execution/{execution_id} + * @secure + */ + getScanDataExportExecution: ( + executionId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve/execution/${executionId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a list of specific scan data export execution jobs for a specified user + * + * @tags scan data export + * @name GetScanDataExportExecutionList + * @summary Get a list of specific scan data export execution jobs for a specified user + * @request GET:/export/cve/executions + * @secure + */ + getScanDataExportExecutionList: (params: RequestParams = {}) => + this.request({ + path: `/export/cve/executions`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Download the scan data report. Default format is CSV + * + * @tags scan data export + * @name DownloadScanData + * @summary Download the scan data export file + * @request GET:/export/cve/download/{execution_id} + * @secure + */ + downloadScanData: ( + executionId: number, + query?: { + /** The format of the data to be exported. e.g. CSV or PDF */ + format?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve/download/${executionId}`, + method: "GET", + query: query, + secure: true, + ...params, + }), + }; + security = { + /** + * @description Retrieve the vulnerability summary of the system + * + * @tags securityhub + * @name GetSecuritySummary + * @summary Get vulnerability system summary + * @request GET:/security/summary + * @secure + */ + getSecuritySummary: ( + query?: { + /** + * Specify whether the dangerous CVEs are included inside summary information + * @default false + */ + with_dangerous_cve?: boolean; + /** + * Specify whether the dangerous Artifact are included inside summary information + * @default false + */ + with_dangerous_artifact?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/security/summary`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the vulnerability list. use q to pass the query condition, supported conditions: cve_id(exact match) cvss_score_v3(range condition) severity(exact match) repository_name(exact match) project_id(exact match) package(exact match) tag(exact match) digest(exact match) + * + * @tags securityhub + * @name ListVulnerabilities + * @summary Get the vulnerability list. + * @request GET:/security/vul + * @secure + */ + listVulnerabilities: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Enable to ignore X-Total-Count when the total count > 1000, if the total count is less than 1000, the real total count is returned, else -1. + * @default false + */ + tune_count?: boolean; + /** + * Specify whether the tag information is included inside vulnerability information + * @default false + */ + with_tag?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/security/vul`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + permissions = { + /** + * @description This endpoint is for retrieving resource and action info that only provides for admin user(system admin and project admin). + * + * @tags permissions + * @name GetPermissions + * @summary Get system or project level permissions info. + * @request GET:/permissions + * @secure + */ + getPermissions: (params: RequestParams = {}) => + this.request({ + path: `/permissions`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; +} diff --git a/.jjconflict-base-0/plugins/harbor/src/env.d.ts b/.jjconflict-base-0/plugins/harbor/src/env.d.ts new file mode 100644 index 0000000000..44377abce9 --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-base-0/plugins/harbor/src/functions.ts b/.jjconflict-base-0/plugins/harbor/src/functions.ts new file mode 100644 index 0000000000..ea902dae27 --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/src/functions.ts @@ -0,0 +1,159 @@ +import type { PluginResult, Project, ProjectLite, StepCall } from '@cpn-console/hooks' +import type { VaultRobotSecret } from './robot.js' +import { + + specificallyDisabled, + specificallyEnabled, +} from '@cpn-console/hooks' +import { DEFAULT, ENABLED } from '@cpn-console/shared' +// @ts-ignore pas de typage disponible pour le paquet bytes +import bytes from 'bytes' +import { logger } from './logger.js' +import { addProjectGroupMember } from './permission.js' +import { addRetentionPolicy } from './policy.js' +import { createProject, deleteProject } from './project.js' +import { deleteRobot, ensureRobot, roAccess, rwAccess } from './robot.js' +import { + getApi, + getConfig, + projectRobotName, + roRobotName, + rwRobotName, +} from './utils.js' + +export const createDsoProject: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + }, + } + const warnReasons: string[] = [] + try { + const project = payload.args + const projectName = project.slug + const { vault: vaultApi, keycloak: keycloakApi } = payload.apis + + const publishRoRobotProject = project.store.registry?.publishProjectRobot + const publishRoRobotConfig = payload.config.registry?.publishProjectRobot + const createProjectRobot + = specificallyEnabled(publishRoRobotProject) + || (specificallyEnabled(publishRoRobotConfig) + && !specificallyDisabled(publishRoRobotProject)) + + const quotaHardLimit + = project.store.registry?.quotaHardLimit + || payload.config.registry?.quotaHardLimit + const quotaHardLimitBytes = quotaHardLimit + ? bytes.parse(quotaHardLimit) + : undefined + + const [projectCreated, oidcGroup] = await Promise.all([ + createProject( + projectName, + quotaHardLimitBytes === 1 ? undefined : quotaHardLimitBytes, + ), + keycloakApi.getProjectGroupPath(), + ]) + const api = getApi() + + if (!projectCreated.project_id) + throw new Error('Unable to retrieve project_id') + const projectId = projectCreated.project_id + + await Promise.all([ + ensureRobot(projectName, projectId, roRobotName, vaultApi, roAccess, api), // cette ligne en premier sinon ça foire au dessus + ensureRobot(projectName, projectId, rwRobotName, vaultApi, rwAccess, api), + addProjectGroupMember(projectName, oidcGroup), + addRetentionPolicy(projectName, projectId), + createProjectRobot + ? ensureRobot(projectName, projectId, projectRobotName, vaultApi, roAccess, api) + : deleteRobot(projectName, projectId, projectRobotName, vaultApi, api), + ]) + returnResult.status.message = `Created${createProjectRobot ? ' , with project robot' : ''}` + returnResult.store = { + projectId: projectCreated.project_id, + } + if (warnReasons.length) { + returnResult.status.result = 'WARNING' + returnResult.status.message = warnReasons.join(', ') + } + logger.info({ action: 'createDsoProject', projectSlug: projectName, result: returnResult.status.result, createProjectRobot }, 'Hook done') + return returnResult + } catch (error) { + logger.error({ action: 'createDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const deleteDsoProject: StepCall = async (payload) => { + try { + const project = payload.args + const projectName = project.slug + + await deleteProject(projectName) + + logger.info({ action: 'deleteDsoProject', projectSlug: projectName, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } catch (error) { + logger.error({ action: 'deleteDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const getProjectSecrets: StepCall = async ({ + args: project, + apis: { vault: vaultApi }, + config, +}) => { + logger.debug({ action: 'getProjectSecrets', projectSlug: project.slug }, 'Hook done') + const publishRoRobotProject = project.store.registry?.publishProjectRobot + const publishRoRobotConfig = config.registry?.publishProjectRobot + const projectRobotEnabled + = publishRoRobotProject === ENABLED + || (publishRoRobotConfig === ENABLED + && (!publishRoRobotProject || publishRoRobotProject === DEFAULT)) + + const VaultRobotSecret = projectRobotEnabled + ? ((await vaultApi.read(`REGISTRY/${projectRobotName}`, { + throwIfNoEntry: false, + })) as { data: VaultRobotSecret } | undefined) + : undefined + let secrets: { [x: string]: string } = { + 'Registry base path': `${getConfig().host}/${project.slug}/`, + } + + if (projectRobotEnabled) { + secrets = VaultRobotSecret?.data + ? { + ...secrets, + ...VaultRobotSecret.data, + } + : { + ...secrets, + '/!\\': 'Vous n\'avez pas de robot de lecture veuillez reprovisionner', + } + } + return { + status: { + result: 'OK', + }, + secrets, + } +} diff --git a/.jjconflict-base-0/plugins/harbor/src/index.ts b/.jjconflict-base-0/plugins/harbor/src/index.ts new file mode 100644 index 0000000000..c005ffd840 --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/src/index.ts @@ -0,0 +1,19 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import { createDsoProject, deleteDsoProject, getProjectSecrets } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { steps: { post: createDsoProject } }, + deleteProject: { steps: { main: deleteDsoProject } }, + getProjectSecrets: { steps: { main: getProjectSecrets } }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-base-0/plugins/harbor/src/infos.ts b/.jjconflict-base-0/plugins/harbor/src/infos.ts new file mode 100644 index 0000000000..97c3de42eb --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/src/infos.ts @@ -0,0 +1,71 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED } from '@cpn-console/shared' +import { getConfig } from './utils.js' + +const quotaDescription = '-1 -> illimité, sinon 100MB / 1.2GB (unités : B, KB, MB, GB, TB)), max 1024TB' +const infos = { + name: 'registry', + to: ({ store }) => store?.registry?.projectId ? `${getConfig().url}/harbor/projects/${store.registry.projectId}` : `${getConfig().url}/`, + title: 'Harbor', + imgSrc: '/img/harbor.svg', + description: 'Harbor stocke, analyse et distribue vos images de conteneurs', + config: { + project: [{ + permissions: { + admin: { read: false, write: false }, + user: { read: false, write: false }, + }, + key: 'projectId', + kind: 'text', + title: 'Num du projet Harbor', + value: '', + }, { + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot projet', + description: 'Activer le robot de projet (read-only) et afficher ses identifiants aux utilisateurs', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota', + value: '', + description: `Stockage limite (vide utilisation du paramètre global, ${quotaDescription}`, + placeholder: '', + }], + global: [{ + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot RO aux projets', + description: 'Définit le comportement en l\'absence de ce paramétrage au niveau projet', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota par défaut', + value: '-1', + description: `Stockage limite par projet (${quotaDescription}`, + placeholder: '-1', + }], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-base-0/plugins/harbor/src/kubeSecret.ts b/.jjconflict-base-0/plugins/harbor/src/kubeSecret.ts new file mode 100644 index 0000000000..acf1ec297b --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/src/kubeSecret.ts @@ -0,0 +1,14 @@ +// Utils +export function getSecretObject({ DOCKER_CONFIG }: { DOCKER_CONFIG: string }) { + const b64dockerConfig = Buffer.from(DOCKER_CONFIG).toString('base64') + return { + kind: 'Secret', + data: { + '.dockerconfigjson': b64dockerConfig, + }, + type: 'kubernetes.io/dockerconfigjson', + metadata: { + name: 'registry-pull-secret', + }, + } +} diff --git a/.jjconflict-base-0/plugins/harbor/src/logger.ts b/.jjconflict-base-0/plugins/harbor/src/logger.ts new file mode 100644 index 0000000000..82f73cecb6 --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'harbor' }) diff --git a/.jjconflict-base-0/plugins/harbor/src/monitor.ts b/.jjconflict-base-0/plugins/harbor/src/monitor.ts new file mode 100644 index 0000000000..eaa73c6ea1 --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/src/monitor.ts @@ -0,0 +1,52 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import { getApi } from './utils.js' + +enum HealthStatus { + healthy = 'healthy', + unhealthy = 'unhealthy', +} +const coreComponents = ['core', 'database', 'portal', 'registry', 'registryctl'] + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await getApi().health.getHealth({ + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + const data = res.data + if (data.status === HealthStatus.healthy) { + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + const failedCoreComponents = data.components + ? data.components + .filter(component => + component.status === HealthStatus.unhealthy + && component.name + && coreComponents.includes(component.name), + ) + : [] + + if (failedCoreComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.WARNING + instance.lastStatus.message = 'Service dégradé' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Fatal Error' + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-base-0/plugins/harbor/src/permission.ts b/.jjconflict-base-0/plugins/harbor/src/permission.ts new file mode 100644 index 0000000000..05e30d4d6c --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/src/permission.ts @@ -0,0 +1,26 @@ +import { getApi } from './utils.js' + +export async function addProjectGroupMember(projectName: string, groupName: string, accessLevel: number = 3): Promise { + const api = getApi() + const members = await api.projects.listProjectMembers(projectName) + const member = members.data.find(m => m.entity_name === groupName) + if (member?.id) { // member.id is always set pb comes from bad swagger + if (member.role_id !== accessLevel && member.entity_type !== 'g') { + // le membre semble être incorrectement paramétré, suppression pour recréation + await api.projects.deleteProjectMember(projectName, member.id) + } else { + // tout va bien + return + } + } + // Création du membre + await api.projects.createProjectMember(projectName, { + + role_id: accessLevel, + member_group: { + + group_name: groupName, + group_type: 3, + }, + }) +} diff --git a/.jjconflict-base-0/plugins/harbor/src/policy.ts b/.jjconflict-base-0/plugins/harbor/src/policy.ts new file mode 100644 index 0000000000..4945ad51cc --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/src/policy.ts @@ -0,0 +1,135 @@ +import { isValidCron } from 'cron-validator' +import { logger } from './logger.js' +import { getApi } from './utils.js' + +// https://github.com/goharbor/harbor/blob/main/src/server/v2.0/handler/retention.go +export type RuleTemplate + = | 'always' + | 'latestPulledK' + | 'latestPushedK' + | 'nDaysSinceLastPull' + | 'nDaysSinceLastPush' + +const allowed: RuleTemplate[] = [ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +] + +export const harborRuleTemplate: RuleTemplate = allowed.includes(process.env.HARBOR_RULE_TEMPLATE as RuleTemplate) + ? (process.env.HARBOR_RULE_TEMPLATE as RuleTemplate) + : 'latestPushedK' + +const countEnv = Number(process.env.HARBOR_RULE_COUNT) +export const harborRuleCount = !Number.isNaN(countEnv) && countEnv > 0 + ? countEnv + : harborRuleTemplate === 'always' + ? 1 + : 10 + +const defaultCron = '0 22 2 * * *' +const envCron = process.env.HARBOR_RETENTION_CRON?.trim() + +export const harborRetentionCron = envCron && isValidCron(envCron, { seconds: true }) + ? envCron + : defaultCron + +export interface TagSelector { + kind: 'doublestar' | 'label' + decoration: 'matches' | 'excludes' | 'repoMatches' | 'repoExcludes' + pattern: string +} + +export interface Rule { + disabled: boolean + action: 'retain' | 'delete' + template: RuleTemplate + params: Record + tag_selectors: TagSelector[] + scope_selectors: Record +} + +export interface Trigger { + kind: 'Schedule' + settings: { cron: string } + references: unknown[] +} + +export interface Policy { + algorithm: 'or' | 'and' + rules: Rule[] + trigger: Trigger + scope: { level: 'project', ref: number } +} + +export function makeDefaultPolicy(projectId: number): Policy { + return { + algorithm: 'or', + scope: { level: 'project', ref: projectId }, + rules: [ + { + disabled: false, + action: 'retain', + template: harborRuleTemplate, + params: { [harborRuleTemplate]: harborRuleCount }, + tag_selectors: [ + { kind: 'doublestar', decoration: 'matches', pattern: '**' }, + ], + scope_selectors: { + repository: [ + { kind: 'doublestar', decoration: 'repoMatches', pattern: '**' }, + ], + }, + }, + ], + trigger: { + kind: 'Schedule', + settings: { cron: harborRetentionCron }, + references: [], + }, + } +} + +export async function addRetentionPolicy( + projectName: string, + harborProjectId: number, +): Promise { + const api = getApi() + const ref = Number(harborProjectId) + if (Number.isNaN(ref)) throw new Error(`Invalid projectId: ${harborProjectId}`) + + const policy: Policy = makeDefaultPolicy(ref) + const project = await api.projects.getProject(projectName) + const retentionId = Number(project?.data?.metadata?.retention_id) + + try { + if (retentionId && !Number.isNaN(retentionId)) { + await api.retentions.updateRetention(retentionId, policy as unknown as any) + } else { + await api.retentions.createRetention(policy as unknown as any) + } + } catch (err: any) { + const payload = JSON.stringify(policy, null, 2) + const details = err?.response?.data ?? 'Unknown error' + logger.error( + { + action: 'addRetentionPolicy', + projectSlug: projectName, + projectName, + projectId: project?.data?.project_id, + retentionId, + payload, + details, + err, + }, + 'Failed to apply Harbor retention policy', + ) + throw new Error( + `Retention policy failed for project "${projectName}": ${ + typeof details === 'string' ? details : JSON.stringify(details) + }`, + ) + } +} diff --git a/.jjconflict-base-0/plugins/harbor/src/project.ts b/.jjconflict-base-0/plugins/harbor/src/project.ts new file mode 100644 index 0000000000..6b8a7a0e6e --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/src/project.ts @@ -0,0 +1,70 @@ +import type { Project as HarborProject, Quota } from './api/Api.js' +import { logger } from './logger.js' +import { getApi } from './utils.js' + +export async function createProject(projectName: string, storageLimit: number = -1): Promise { + logger.debug({ action: 'createProject', projectSlug: projectName, storageLimit }, 'Creating Harbor project if missing') + const api = getApi() + const existingProject = await api.projects.getProject(projectName, { + headers: { + 'X-Is-Resource-Name': true, + }, + validateStatus: () => true, + }) + + if (existingProject.status === 200) { + const projectId = existingProject.data.project_id as number + const refQuotas = await api.quotas.listQuotas({ + reference_id: String(projectId), + }) + const hardQuota = refQuotas.data.find(quota => quota.ref?.id === projectId) as Quota + + if (hardQuota.hard.storage !== storageLimit) { + await api.quotas.updateQuota(projectId, { + hard: { + storage: storageLimit, + }, + }) + } + return existingProject.data + } + + await api.projects.createProject({ + project_name: projectName, + metadata: { + auto_scan: 'true', + }, + storage_limit: storageLimit, + }, { + validateStatus: () => true, + }) + + const newProject = await api.projects.getProject(projectName) + return newProject.data +} + +async function removeRepositories(projectName: string) { + const api = getApi() + const repositories = await api.projects.listRepositories(projectName, { page_size: 100 }) + + for (const repo of repositories.data) { + // TODO mauvais swagger name existe forcément + const repoName = repo.name?.split('/').slice(1).join('/') as string + await api.projects.deleteRepository(projectName, repoName) + } + if (repositories.headers['x-total-count'] > repositories.data.length) { + await removeRepositories(projectName) + } +} + +export async function deleteProject(projectName: string) { + const api = getApi() + const project = await api.projects.getProject(projectName, { + validateStatus: status => [200, 404].includes(status), + }) + + if (project.status === 200) { + await removeRepositories(projectName) + await api.projects.deleteProject(projectName) + } +} diff --git a/.jjconflict-base-0/plugins/harbor/src/robot.ts b/.jjconflict-base-0/plugins/harbor/src/robot.ts new file mode 100644 index 0000000000..90c2ee23eb --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/src/robot.ts @@ -0,0 +1,133 @@ +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { Access, Robot, RobotCreated } from './api/Api.js' +import type { HarborApi } from './utils.js' +import { logger } from './logger.js' +import { getConfig, toVaultSecret } from './utils.js' + +// A project only ever owns a handful of robots (ro, rw and optionally the +// project robot — 3 in practice), so fetching a single page is enough; we never +// need to paginate the robot listing. +const ROBOT_LIST_PAGE_SIZE = 100 + +export interface VaultRobotSecret { + // {"auths":{"registry-host.com":{"auth":"","email":""}}}, + DOCKER_CONFIG: string + // registry-host.com, + HOST: string + TOKEN: string + // robot$+ + USERNAME: string +} + +export const getRobot = async (projectName: string, projectId: number, robotName: string, api: HarborApi): Promise => getRobotByName(projectId, `robot$${projectName}+${robotName}`, api) + +export async function ensureRobot(projectName: string, projectId: number, robotName: string, vaultApi: VaultProjectApi, access: Access[], api: HarborApi): Promise { + logger.debug({ action: 'ensureRobot', projectSlug: projectName, robotName }, 'Ensuring Harbor robot') + const vaultPath = `REGISTRY/${robotName}` + const robot = await getRobot(projectName, projectId, robotName, api) + const vaultRobotSecret = await vaultApi.read(vaultPath, { throwIfNoEntry: false }) as { data: VaultRobotSecret } | undefined + + let creds: VaultRobotSecret + // only regenerate robot if there is a change with the host + if (vaultRobotSecret?.data && vaultRobotSecret.data.HOST === getConfig().host) { + creds = vaultRobotSecret.data + } else if (robot) { + creds = toVaultSecret(await regenerateRobot(projectName, projectId, robotName, access, api) as Required) + await vaultApi.write(creds, vaultPath) + } else { + creds = toVaultSecret(await createRobot(projectName, robotName, access, api) as Required) + await vaultApi.write(creds, vaultPath) + } + return creds +} + +export async function deleteRobot(projectName: string, projectId: number, robotName: string, vaultApi: VaultProjectApi, api: HarborApi): Promise { + logger.info({ action: 'deleteRobot', projectSlug: projectName, robotName }, 'Deleting Harbor robot') + const vaultPath = `REGISTRY/${robotName}` + const robot = await getRobot(projectName, projectId, robotName, api) + if (robot?.id) { + api.robots.deleteRobot(robot.id) + } + const vaultRobotSecret = await vaultApi.read(vaultPath, { throwIfNoEntry: false }) as { data: VaultRobotSecret } | undefined + + if (vaultRobotSecret) { + await vaultApi.destroy(vaultPath) + } +} + +export async function createRobot(projectName: string, robotName: string, access: Access[], api: HarborApi) { + logger.info({ action: 'createRobot', projectSlug: projectName, robotName }, 'Creating Harbor robot') + return (await api.robots.createRobot(getRobotPermissions(projectName, robotName, access))).data +} + +export async function regenerateRobot(projectName: string, projectId: number, robotName: string, access: Access[], api: HarborApi): Promise { + logger.info({ action: 'regenerateRobot', projectSlug: projectName, robotName }, 'Regenerating Harbor robot') + const robot = await getRobot(projectName, projectId, robotName, api) + if (robot?.id) + await api.robots.deleteRobot(robot.id) + return createRobot(projectName, robotName, access, api) +} + +export async function getRobotByName(projectId: number, robotName: string, api: HarborApi): Promise { + logger.debug({ action: 'getRobotByName', projectId, robotName }, 'Listing Harbor robots to find matching name') + const listRobots = await api.robots.listRobot({ + q: `Level=project,ProjectID=${projectId}`, + page_size: ROBOT_LIST_PAGE_SIZE, + }) + return listRobots.data.find(({ name }) => name === robotName) +} + +function getRobotPermissions(projectName: string, robotName: string, access: Access[]) { + return { + name: robotName, + duration: -1, + description: 'robot for ci builds', + disable: false, + level: 'project', + permissions: [{ + namespace: projectName, + kind: 'project', + access, + }], + } +} + +export const roAccess: Access[] = [{ + resource: 'repository', + action: 'pull', +}, { + resource: 'artifact', + action: 'read', +}] + +export const rwAccess: Access[] = [...roAccess, { + resource: 'repository', + action: 'list', +}, { + resource: 'tag', + action: 'list', +}, { + resource: 'artifact', + action: 'list', +}, { + resource: 'scan', + action: 'create', +}, { + resource: 'scan', + action: 'stop', +}, { + resource: 'repository', + action: 'push', +}, { + resource: 'artifact-label', + action: 'create', +}, { + resource: 'artifact-label', + action: 'delete', +}, { + resource: 'tag', + action: 'create', +}, { + resource: 'tag', + action: 'delete', +}] diff --git a/.jjconflict-base-0/plugins/harbor/src/utils.ts b/.jjconflict-base-0/plugins/harbor/src/utils.ts new file mode 100644 index 0000000000..abb2b471ba --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/src/utils.ts @@ -0,0 +1,61 @@ +import type { RobotCreated } from './api/Api.js' +import type { VaultRobotSecret } from './robot.js' +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import { Api } from './api/Api.js' + +const config: { + url?: string + internalUrl?: string + host?: string +} = {} + +export function getConfig(): Required { + config.url = config.url ?? removeTrailingSlash(requiredEnv('HARBOR_URL')) + config.internalUrl = config.internalUrl ?? removeTrailingSlash(requiredEnv('HARBOR_INTERNAL_URL')) + config.host = config.host ?? config?.url?.split('://')[1] + // @ts-ignore + return config +} + +function getApiConfig() { + return { + auth: { + username: requiredEnv('HARBOR_ADMIN'), + password: requiredEnv('HARBOR_ADMIN_PASSWORD'), + }, + baseURL: `${getConfig().internalUrl}/api/v2.0/`, + } +} + +let api: Api> | undefined + +export type HarborApi = Api> +export function getApi(): HarborApi { + if (!api) { + api = new Api(getApiConfig()) + } + return api +} + +export function toVaultSecret(robot: Required): VaultRobotSecret { + const auth = `${robot.name}:${robot.secret}` + const buff = Buffer.from(auth) + const b64auth = buff.toString('base64') + return { + DOCKER_CONFIG: JSON.stringify({ + auths: { + [getConfig().host]: { + auth: b64auth, + email: '', + }, + }, + }), + HOST: getConfig().host, + TOKEN: robot.secret, + USERNAME: robot.name, + } +} + +export const rwRobotName = 'rw-robot' +export const roRobotName = 'ro-robot' +export const projectRobotName = 'project-robot' diff --git a/.jjconflict-base-0/plugins/harbor/tsconfig.eslint.json b/.jjconflict-base-0/plugins/harbor/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-0/plugins/harbor/tsconfig.json b/.jjconflict-base-0/plugins/harbor/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-0/plugins/harbor/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-0/plugins/keycloak/.gitignore b/.jjconflict-base-0/plugins/keycloak/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-0/plugins/keycloak/eslint.config.js b/.jjconflict-base-0/plugins/keycloak/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-0/plugins/keycloak/package.json b/.jjconflict-base-0/plugins/keycloak/package.json new file mode 100644 index 0000000000..d0cb9e3ece --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/package.json @@ -0,0 +1,49 @@ +{ + "name": "@cpn-console/keycloak-plugin", + "type": "module", + "version": "2.1.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@keycloak/keycloak-admin-client": "catalog:runtime", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/plugins/keycloak/src/class.ts b/.jjconflict-base-0/plugins/keycloak/src/class.ts new file mode 100644 index 0000000000..36f3dc045e --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/src/class.ts @@ -0,0 +1,33 @@ +import { PluginApi } from '@cpn-console/hooks' +import { consoleGroupName } from './group.js' + +interface KeycloakEnv { + path: string + subgroups: { // Key is the group name, value is the full path + RO: string + RW: string + } +} + +export class KeycloakProjectApi extends PluginApi { + private readonly projectSlug: string + + constructor(projectSlug: string) { + super() + this.projectSlug = projectSlug + } + + public async getProjectGroupPath(): Promise { + return `/${this.projectSlug}` + } + + public async getEnvGroup(environment: string): Promise { + return { + path: `/${this.projectSlug}/${consoleGroupName}/${environment}`, + subgroups: { + RO: `/${this.projectSlug}/${consoleGroupName}/${environment}/RO`, + RW: `/${this.projectSlug}/${consoleGroupName}/${environment}/RW`, + }, + } + } +} diff --git a/.jjconflict-base-0/plugins/keycloak/src/client.ts b/.jjconflict-base-0/plugins/keycloak/src/client.ts new file mode 100644 index 0000000000..484dc8789a --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/src/client.ts @@ -0,0 +1,28 @@ +import KcAdminClient from '@keycloak/keycloak-admin-client' +import getConfig from './config.js' +import { logger } from './logger.js' + +export async function getkcClient() { + const kcClient = new KcAdminClient({ + baseUrl: getConfig().url, + }) + + await kcClient.auth({ + clientId: 'admin-cli', + grantType: 'password', + username: process.env.KEYCLOAK_ADMIN, + password: process.env.KEYCLOAK_ADMIN_PASSWORD, + }) + kcClient.setConfig({ realmName: getConfig().realm }) + return kcClient +} + +export function start() { + getkcClient().catch((error) => { + logger.error({ err: error }, 'Failed to start Keycloak plugin') + if (process.env.IGNORE_PLUGINS_START_FAIL?.includes('keycloak')) { + return + } + throw new Error('failed to start keycloak plugin') + }) +} diff --git a/.jjconflict-base-0/plugins/keycloak/src/config.ts b/.jjconflict-base-0/plugins/keycloak/src/config.ts new file mode 100644 index 0000000000..d249e99b5b --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/src/config.ts @@ -0,0 +1,23 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + url: string + realm: string + + constructor() { + this.url = process.env.KEYCLOAK_INTERNAL_URL + ? removeTrailingSlash(process.env.KEYCLOAK_INTERNAL_URL) + : `${requiredEnv('KEYCLOAK_PROTOCOL')}://${requiredEnv('KEYCLOAK_DOMAIN')}` + this.realm = requiredEnv('KEYCLOAK_REALM') + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-base-0/plugins/keycloak/src/env.d.ts b/.jjconflict-base-0/plugins/keycloak/src/env.d.ts new file mode 100644 index 0000000000..81cd034697 --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/.jjconflict-base-0/plugins/keycloak/src/functions.ts b/.jjconflict-base-0/plugins/keycloak/src/functions.ts new file mode 100644 index 0000000000..9bf08d971d --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/src/functions.ts @@ -0,0 +1,478 @@ +import type { AdminRole, Project, ProjectMember, StepCall, UserEmail, ZoneObject } from '@cpn-console/hooks' +import type { ProjectRole } from '@cpn-console/shared' +import type ClientRepresentation from '@keycloak/keycloak-admin-client/lib/defs/clientRepresentation.js' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js' +import type { CustomGroup } from './group.js' +import { generateRandomPassword, PluginResultBuilder } from '@cpn-console/hooks' +import { isExternalRoleType } from '@cpn-console/shared' +import { getkcClient } from './client.js' +import { consoleGroupName, deleteGroup, getAllSubgroups, getGroupByName, getOrCreateChildGroup, getOrCreateGroupByPath, getOrCreateProjectGroup } from './group.js' +import { logger } from './logger.js' + +export const retrieveKeycloakUserByEmail: StepCall = async ({ args: { email } }) => { + const kcClient = await getkcClient() + try { + const user = (await kcClient.users.find({ email }))[0] + logger.debug({ action: 'retrieveKeycloakUserByEmail', found: Boolean(user) }, 'Hook done') + + return { + status: { result: 'OK' }, + user, + } + } catch (error) { + logger.error({ action: 'retrieveKeycloakUserByEmail', err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const deleteProject: StepCall = async ({ args: project }) => { + const projectSlug = project.slug + try { + const kcClient = await getkcClient() + const group = await getGroupByName(kcClient, projectSlug) + if (group?.id) { + await kcClient.groups.del({ id: group.id }) + logger.info({ action: 'deleteProject', projectSlug, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + logger.info({ action: 'deleteProject', projectSlug, outcome: 'already-missing' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already Missing', + }, + } + } catch (error) { + logger.error({ action: 'deleteProject', projectSlug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const upsertProject: StepCall = async ({ args: project }) => { + const projectSlug = project.slug + const pluginResult = new PluginResultBuilder('Up-to-date') + try { + const kcClient = await getkcClient() + const projectGroup = await getOrCreateProjectGroup(kcClient, projectSlug) + + const groupMembers = await kcClient.groups.listMembers({ id: projectGroup.id }) + + await Promise.all([ + ...groupMembers.map((member, index) => { + if (!project.users.some(({ id }) => id === member.id)) { + return kcClient.users.delFromGroup({ + // @ts-ignore id is present on user, bad typing in lib + id: member.id, + groupId: projectGroup.id, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak project group') + pluginResult.addExtra(`remove-${index}`, err) + }) + } + return undefined + }), + ...project.users.map((user, index) => { + if (!groupMembers.some(({ id }) => id === user.id)) { + return kcClient.users.addToGroup({ + id: user.id, + groupId: projectGroup.id, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t add user to keycloak project group') + pluginResult.addExtra(`add-${index}`, err) + }) + } + return undefined + }), + ]) + + // Ensure envs subgroups exists + const projectGroups = await getAllSubgroups(kcClient, projectGroup.id, 0) + + const consoleGroup: Required = projectGroups.find(({ name }) => name === consoleGroupName) as Required + ?? await getOrCreateChildGroup(kcClient, projectGroup.id, consoleGroupName) as Required + + const envGroups = await getAllSubgroups(kcClient, consoleGroup.id, 0) as CustomGroup[] + + const promises: Promise[] = [] + for (const environment of project.environments) { + const envGroup: Required = envGroups.find(group => group.name === environment.name) as Required + ?? await getOrCreateChildGroup(kcClient, consoleGroup.id, environment.name) + + const [roGroup, rwGroup] = await Promise.all([ + getOrCreateChildGroup(kcClient, envGroup.id, 'RO'), + getOrCreateChildGroup(kcClient, envGroup.id, 'RW'), + ]) + + // Ensure envs permissions membership exists + for (const permission of environment.permissions) { + if (permission.permissions.ro) { + promises.push(kcClient.users.addToGroup({ id: permission.userId, groupId: roGroup.id })) + } else { + promises.push(kcClient.users.delFromGroup({ id: permission.userId, groupId: roGroup.id })) + } + if (permission.permissions.rw) { + promises.push(kcClient.users.addToGroup({ id: permission.userId, groupId: rwGroup.id })) + } else { + promises.push(kcClient.users.delFromGroup({ id: permission.userId, groupId: rwGroup.id })) + } + } + } + + await Promise.all(promises) + + const envGroupIdsToDelete = envGroups + .filter(subGroup => !project.environments.some(({ name }) => name === subGroup.name)) + .map(subGroup => subGroup.id) + .filter((id): id is string => !!id) + if (envGroupIdsToDelete.length) { + await Promise.all(envGroupIdsToDelete.map(id => kcClient.groups.del({ id }))) + } + + logger.info({ action: 'upsertProject', projectSlug }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const upsertZone: StepCall = async ({ args: zone, apis }) => { + const zoneSlug = zone.slug + try { + const kcClient = await getkcClient() + const argocdUrl = zone.argocdUrl + const clientId = getClientZoneId(zone) + const client: ClientRepresentation = { + clientId, + clientAuthenticatorType: 'client-secret', + protocol: 'openid-connect', + publicClient: false, + defaultClientScopes: ['generic'], + redirectUris: [`${argocdUrl}/auth/callback`], + webOrigins: [argocdUrl], + rootUrl: argocdUrl, + adminUrl: argocdUrl, + baseUrl: '/applications', + } + const result = await kcClient.clients.find({ clientId, max: 1 }) + let outcome: 'updated' | 'created' + if (result.length > 0 && result[0].id) { + await kcClient.clients.update({ id: result[0].id }, client) + outcome = 'updated' + } else { + const password = generateRandomPassword(30) + await apis.vault.write({ clientSecret: password }, 'keycloak') + await kcClient.clients.create({ + secret: password, + ...client, + }) + outcome = 'created' + } + logger.info({ action: 'upsertZone', zoneSlug, clientId, outcome }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'upsertZone', zoneSlug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +export const deleteZone: StepCall = async ({ args: zone }) => { + const zoneSlug = zone.slug + try { + const kcClient = await getkcClient() + const clientId = getClientZoneId(zone) + const result = await kcClient.clients.find({ clientId, max: 1 }) + if (result.length > 0 && result[0].id) { + await kcClient.clients.del({ id: result[0].id }) + logger.info({ action: 'deleteZone', zoneSlug, clientId, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + logger.info({ action: 'deleteZone', zoneSlug, clientId, outcome: 'already-missing' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already Missing', + }, + } + } catch (error) { + logger.error({ action: 'deleteZone', zoneSlug, clientId: getClientZoneId(zone), err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const upsertAdminRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) return { status: { result: 'OK', message: 'No OIDC Group defined' } } + if (isExternalRoleType(role.type)) return { status: { result: 'OK', message: 'Skipped (external system role)' } } + const pluginResult = new PluginResultBuilder('Up-to-date') + try { + const kcClient = await getkcClient() + const group = await getOrCreateGroupByPath(kcClient, role.oidcGroup) + const groupMembers = await kcClient.groups.listMembers({ id: group.id }) + + await Promise.all([ + ...groupMembers.map((member, index) => { + if (member.id && !role.members.some(({ id }) => id === member.id)) { + return kcClient.users.delFromGroup({ + id: member.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak admin group') + pluginResult.addExtra(`remove-${index}`, err) + }) + } + return undefined + }), + ...role.members.map((user, index) => { + if (!groupMembers.some(({ id }) => id === user.id)) { + return kcClient.users.addToGroup({ + id: user.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t add user to keycloak admin group') + pluginResult.addExtra(`add-${index}`, err) + }) + } + return undefined + }), + ]) + + logger.info({ action: 'upsertAdminRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertAdminRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const deleteAdminRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) return { status: { result: 'OK', message: 'No OIDC Group defined' } } + if (isExternalRoleType(role.type)) return { status: { result: 'OK', message: 'Skipped (external system role)' } } + const pluginResult = new PluginResultBuilder('Deleted') + try { + const kcClient = await getkcClient() + let group: GroupRepresentation | undefined + if (role.oidcGroup.startsWith('/')) { + const name = role.oidcGroup.split('/').pop() || '' + const groups = await kcClient.groups.find({ search: name }) + group = groups.find(g => g.path === role.oidcGroup) + } else { + const groupOrVoid = await getGroupByName(kcClient, role.oidcGroup) + group = groupOrVoid || undefined + } + + if (group?.id) { + await Promise.all(role.members.map((user, index) => { + return kcClient.users.delFromGroup({ + id: user.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak admin group') + pluginResult.addExtra(`remove-${index}`, err) + }) + })) + } + logger.info({ action: 'deleteAdminRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'deleteAdminRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const upsertProjectRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) { + return { + status: { + result: 'OK', + message: 'No OIDC group defined', + }, + } + } + try { + const kcClient = await getkcClient() + await getOrCreateGroupByPath(kcClient, role.oidcGroup) + logger.info({ action: 'upsertProjectRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Synced', + }, + } + } catch (error) { + logger.error({ action: 'upsertProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed to sync role', + }, + } + } +} + +export const deleteProjectRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) { + return { + status: { + result: 'OK', + message: 'No OIDC group defined', + }, + } + } + try { + const kcClient = await getkcClient() + const [projectName, pluginName, roleName] = role.oidcGroup.split('/').slice(1) + if (!projectName || !pluginName || !roleName) throw new Error('Invalid OIDC group format') + const projectGroup = await getGroupByName(kcClient, projectName) + if (projectGroup?.id) { + const pluginGroups = await getAllSubgroups(kcClient, projectGroup.id, 0) + const pluginGroup = pluginGroups.find(({ name }) => name === pluginName) as Required | undefined + if (pluginGroup?.id) { + const roleGroups = await getAllSubgroups(kcClient, pluginGroup.id, 0) + const roleGroup = roleGroups.find(({ name }) => name === roleName) as Required | undefined + if (roleGroup?.id) { + await deleteGroup(kcClient, roleGroup.id) + logger.info({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + } + } + logger.info({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, outcome: 'already-deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already deleted', + }, + } + } catch (error) { + logger.error({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed to delete role', + }, + } + } +} + +export const upsertProjectMember: StepCall = async ({ args: member }) => { + const pluginResult = new PluginResultBuilder('Synced') + try { + const kcClient = await getkcClient() + + const projectGroup = await getOrCreateProjectGroup(kcClient, member.project.slug) + const consoleGroup = await getOrCreateChildGroup(kcClient, projectGroup.id, consoleGroupName) + const allRoleGroups = await getAllSubgroups(kcClient, consoleGroup.id, 0) + const userGroups = await kcClient.users.listGroups({ id: member.userId }) + + const userRolesOidcGroups = member.roles + .map(r => r.oidcGroup) + .filter((g): g is string => !!g) + + // Sync Roles + let groupMembershipChanges = 0 + for (const roleGroup of allRoleGroups) { + if (!roleGroup.id || !roleGroup.path) continue + const isMember = userGroups.some(ug => ug.id === roleGroup.id) + const shouldBeMember = userRolesOidcGroups.includes(roleGroup.path) + + if (shouldBeMember && !isMember) { + groupMembershipChanges += 1 + await kcClient.users.addToGroup({ id: member.userId, groupId: roleGroup.id }) + } else if (!shouldBeMember && isMember) { + groupMembershipChanges += 1 + await kcClient.users.delFromGroup({ id: member.userId, groupId: roleGroup.id }) + } + } + + logger.info({ action: 'upsertProjectMember', projectSlug: member.project.slug, groupMembershipChanges }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertProjectMember', projectSlug: member.project.slug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const deleteProjectMember: StepCall = async ({ args: member }) => { + const pluginResult = new PluginResultBuilder('Deleted') + try { + const kcClient = await getkcClient() + if (!member.userId) return pluginResult.getResultObject() + + const projectGroup = await getGroupByName(kcClient, member.project.slug) + if (!projectGroup?.id) return pluginResult.getResultObject() + + const userGroups = await kcClient.users.listGroups({ id: member.userId }) + const projectGroups = userGroups.filter(g => g.path?.startsWith(projectGroup.path!)) + + let removedCount = 0 + for (const group of projectGroups) { + if (group.id) { + await kcClient.users.delFromGroup({ id: member.userId, groupId: group.id }) + removedCount += 1 + } + } + + logger.info({ action: 'deleteProjectMember', projectSlug: member.project.slug, removedCount }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'deleteProjectMember', projectSlug: member.project.slug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +function getClientZoneId(zone: ZoneObject): string { + return `argocd-${zone.slug}-zone` +} diff --git a/.jjconflict-base-0/plugins/keycloak/src/group.ts b/.jjconflict-base-0/plugins/keycloak/src/group.ts new file mode 100644 index 0000000000..09c4cdc077 --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/src/group.ts @@ -0,0 +1,123 @@ +import type KeycloakAdminClient from '@keycloak/keycloak-admin-client' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js' + +export const consoleGroupName = 'console' + +export type CustomGroup = Required> +export async function getGroupByName(kcClient: KeycloakAdminClient, name: string): Promise { + const groupSearch = await kcClient.groups.find({ search: name }) + return groupSearch.find(grp => grp.name === name) +} + +export async function getAllSubgroups(kcClient: KeycloakAdminClient, parentId: string, first: number, subgroups: GroupRepresentation[] = []): Promise { + const newSubgroups = [ + ...subgroups, + ...await kcClient.groups.listSubGroups({ parentId, briefRepresentation: false, max: 10, first }), + ] + if (newSubgroups.length - subgroups.length === 10) { + return getAllSubgroups(kcClient, parentId, first + 10, newSubgroups) + } + return newSubgroups +} + +export async function getOrCreateChildGroup(kcClient: KeycloakAdminClient, parentId: string, name: string, subGroups: GroupRepresentation[] | undefined = []): Promise { + if (Array.isArray(subGroups) && subGroups.length > 0) { + const matchingGroup = subGroups.find(({ name: groupName }) => groupName === name) as Required | undefined + if (matchingGroup) { + return { + id: matchingGroup.id, + subGroups: matchingGroup.subGroups || [], + subGroupCount: matchingGroup.subGroups?.length || 0, + name, + } + } + } + subGroups = await getAllSubgroups(kcClient, parentId, 0) + + const matchingGroup = subGroups?.find(({ name: groupName }) => groupName === name) as Required | undefined + if (!matchingGroup) { + const newGroup = await kcClient.groups.createChildGroup({ id: parentId }, { name }) + return { + id: newGroup.id, + subGroups: [], + subGroupCount: 0, + name, + } + } + return { + id: matchingGroup.id, + subGroups: matchingGroup.subGroups || [], + subGroupCount: matchingGroup.subGroups?.length || 0, + name, + } +} + +export async function getOrCreateProjectGroup(kcClient: KeycloakAdminClient, name: string): Promise> & { subGroups: Required[] }> { + const existingGroup = await getGroupByName(kcClient, name) as Required + if (!existingGroup) { + const newGroup = await kcClient.groups.create({ name }) + return { + id: newGroup.id, + subGroups: [], + subGroupCount: 0, + name, + } + } + return { + id: existingGroup.id, + // @ts-ignore mauvais typage de librairie les subGroups ont forcément un id + subGroups: existingGroup.subGroups || [], + subGroupCount: existingGroup.subGroups?.length || 0, + name: existingGroup.name, + } +} + +export async function getOrCreateGroupByName(kcClient: KeycloakAdminClient, name: string): Promise> { + const group = await getGroupByName(kcClient, name) + if (group) return group as Required + const newGroup = await kcClient.groups.create({ name }) + const created = await kcClient.groups.findOne({ id: newGroup.id }) + if (!created) throw new Error(`Failed to retrieve created group: ${name}`) + return created as Required +} + +export async function getOrCreateGroupByPath(kcClient: KeycloakAdminClient, path: string): Promise> { + if (!path.startsWith('/')) { + return getOrCreateGroupByName(kcClient, path) + } + + const name = path.split('/').pop() || '' + const groups = await kcClient.groups.find({ search: name }) + const existingGroup = groups.find(g => g.path === path) + + if (existingGroup) return existingGroup as Required + + const groupNames = path.split('/').filter(Boolean) + let parentId: string | undefined + + for (const groupName of groupNames) { + const subGroups = parentId + ? await kcClient.groups.listSubGroups({ parentId }) + : await kcClient.groups.find({ search: groupName }) + const existingSubGroup = subGroups.find(g => g.name === groupName) + + if (existingSubGroup) { + parentId = existingSubGroup.id + } else { + const newGroup = parentId + ? await kcClient.groups.createChildGroup({ id: parentId }, { name: groupName }) + : await kcClient.groups.create({ name: groupName }) + parentId = newGroup.id + } + } + + if (parentId) { + const group = await kcClient.groups.findOne({ id: parentId }) + if (group) return group as Required + } + throw new Error(`Failed to create group path: ${path}`) +} + +export async function deleteGroup(kcClient: KeycloakAdminClient, groupId: string) { + await kcClient.groups.del({ id: groupId }) +} diff --git a/.jjconflict-base-0/plugins/keycloak/src/index.ts b/.jjconflict-base-0/plugins/keycloak/src/index.ts new file mode 100644 index 0000000000..a5fda76e1f --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/src/index.ts @@ -0,0 +1,67 @@ +import type { DefaultArgs, Plugin, Project, ProjectLite } from '@cpn-console/hooks' +import { KeycloakProjectApi } from './class.js' +import { start } from './client.js' +import { + deleteAdminRole, + deleteProject, + deleteProjectMember, + deleteProjectRole, + deleteZone, + retrieveKeycloakUserByEmail, + upsertAdminRole, + upsertProject, + upsertProjectMember, + upsertProjectRole, + upsertZone, +} from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + deleteProject: { + api: project => new KeycloakProjectApi(project.slug), + steps: { post: deleteProject }, + }, + upsertProject: { + api: project => new KeycloakProjectApi(project.slug), + steps: { main: upsertProject }, + }, + upsertProjectRole: { + steps: { main: upsertProjectRole }, + }, + upsertProjectMember: { + steps: { main: upsertProjectMember }, + }, + deleteProjectMember: { + steps: { post: deleteProjectMember }, + }, + upsertZone: { + steps: { main: upsertZone }, + }, + deleteZone: { + steps: { post: deleteZone }, + }, + deleteProjectRole: { + steps: { post: deleteProjectRole }, + }, + retrieveUserByEmail: { steps: { main: retrieveKeycloakUserByEmail } }, + upsertAdminRole: { + steps: { main: upsertAdminRole }, + }, + deleteAdminRole: { + steps: { post: deleteAdminRole }, + }, + }, + monitor, + start, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + keycloak: Args extends (ProjectLite | Project) + ? KeycloakProjectApi + : undefined + } +} diff --git a/.jjconflict-base-0/plugins/keycloak/src/infos.ts b/.jjconflict-base-0/plugins/keycloak/src/infos.ts new file mode 100644 index 0000000000..3b3980194f --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/src/infos.ts @@ -0,0 +1,12 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos: ServiceInfos = { + name: 'keycloak', + title: 'Keycloak', + config: { + global: [], + project: [], + }, +} + +export default infos diff --git a/.jjconflict-base-0/plugins/keycloak/src/logger.ts b/.jjconflict-base-0/plugins/keycloak/src/logger.ts new file mode 100644 index 0000000000..1578c26c49 --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'keycloak' }) diff --git a/.jjconflict-base-0/plugins/keycloak/src/monitor.ts b/.jjconflict-base-0/plugins/keycloak/src/monitor.ts new file mode 100644 index 0000000000..4f1df8d648 --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/src/monitor.ts @@ -0,0 +1,27 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(requiredEnv('KEYCLOAK_URL'), { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-base-0/plugins/keycloak/tsconfig.eslint.json b/.jjconflict-base-0/plugins/keycloak/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-0/plugins/keycloak/tsconfig.json b/.jjconflict-base-0/plugins/keycloak/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-0/plugins/keycloak/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-0/plugins/nexus/.gitignore b/.jjconflict-base-0/plugins/nexus/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-0/plugins/nexus/README.md b/.jjconflict-base-0/plugins/nexus/README.md new file mode 100644 index 0000000000..a8ee9f797a --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/README.md @@ -0,0 +1,13 @@ +# Introduction + +Ce plugin permet de déployer des dépôts à la demande sur nexus. Les seules technologies prisent en charge actuellement sont Maven et NPM. + +## Configuration + +| Env var | valeur possible | description | +| ---------------------- | --------------- | --------------------------------------------------------------------------------------------- | +| NEXUS_ADMIN | chaine | Nom d'utilisateur admin Nexus | +| NEXUS_ADMIN_PASSWORD | chaine | Mot de passe Nexus | +| NEXUS_URL | *url* | Url public d'accés au Nexus | +| NEXUS_INTERNAL_URL | *url* ou vide | url par laquelle la console interroge le service Nexus, si absent utilisation de l'url public | +| NEXUS__SECRET_EXPOSE_INTERNAL_URL | "true" ou vide | Exposition ou non de l'url interne dans la remontée des secrets du projet | diff --git a/.jjconflict-base-0/plugins/nexus/eslint.config.js b/.jjconflict-base-0/plugins/nexus/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-0/plugins/nexus/package.json b/.jjconflict-base-0/plugins/nexus/package.json new file mode 100644 index 0000000000..0a758fd7d9 --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/package.json @@ -0,0 +1,49 @@ +{ + "name": "@cpn-console/nexus-plugin", + "type": "module", + "version": "2.3.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/plugins/nexus/src/config.ts b/.jjconflict-base-0/plugins/nexus/src/config.ts new file mode 100644 index 0000000000..aecdc37956 --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/src/config.ts @@ -0,0 +1,29 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + secretExposedUrl: string + user: string + password: string + + constructor() { + this.password = requiredEnv('NEXUS_ADMIN_PASSWORD') + this.user = requiredEnv('NEXUS_ADMIN') + this.publicUrl = removeTrailingSlash(requiredEnv('NEXUS_URL')) + this.internalUrl = process.env.NEXUS_INTERNAL_URL + ? removeTrailingSlash(process.env.NEXUS_INTERNAL_URL) + : this.publicUrl + this.secretExposedUrl = process.env.NEXUS__SECRET_EXPOSE_INTERNAL_URL === 'true' ? this.internalUrl : this.publicUrl + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-base-0/plugins/nexus/src/env.d.ts b/.jjconflict-base-0/plugins/nexus/src/env.d.ts new file mode 100644 index 0000000000..b34e01d65a --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-base-0/plugins/nexus/src/functions.ts b/.jjconflict-base-0/plugins/nexus/src/functions.ts new file mode 100644 index 0000000000..5841f1c018 --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/src/functions.ts @@ -0,0 +1,28 @@ +import getConfig from './config.js' + +let axiosOptions: { + baseURL: string + auth: { + username: string + password: string + } + headers: { + Accept: string + } +} + +export function getAxiosOptions(): Required { + if (!axiosOptions) { + axiosOptions = { + baseURL: `${getConfig().internalUrl}/service/rest/v1/`, + auth: { + username: getConfig().user, + password: getConfig().password, + }, + headers: { + Accept: 'application/json', + }, + } + } + return axiosOptions +} diff --git a/.jjconflict-base-0/plugins/nexus/src/index.ts b/.jjconflict-base-0/plugins/nexus/src/index.ts new file mode 100644 index 0000000000..3457e0c021 --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/src/index.ts @@ -0,0 +1,19 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import infos from './infos.js' +import monitor from './monitor.js' +import { createNexusProject, deleteNexusProject, getSecrets } from './project.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { steps: { main: createNexusProject } }, + deleteProject: { steps: { main: deleteNexusProject } }, + getProjectSecrets: { steps: { main: getSecrets } }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-base-0/plugins/nexus/src/infos.ts b/.jjconflict-base-0/plugins/nexus/src/infos.ts new file mode 100644 index 0000000000..83abdb71aa --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/src/infos.ts @@ -0,0 +1,116 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos = { + name: 'nexus', + title: 'Nexus', + imgSrc: '/img/nexus.png', + description: 'Nexus permet de gérer les binaires et artefacts de build à travers la chaîne logistique logicielle', + config: { + project: [ + { + key: 'activateNpmRepo', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt NPM', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'npmWritePolicy', + section: 'NPM', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture', + value: 'allow', + description: 'Politique d\'écriture des dépôts NPM, valeurs possibles: allow / allow_once, deny, replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'activateMavenRepo', + section: 'Maven', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt Maven', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'mavenSnapshotWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Snapshot', + value: 'allow', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'mavenReleaseWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Release', + value: 'allow_once', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + ], + global: [ + { + key: 'activateNpmRepoDefaultValue', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Créer un dépôt NPM privé (comportement par défaut des projets)', + value: 'disabled', + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'activateMavenRepoDefaultValue', + section: 'Maven', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Créer un dépôt MAVEN privé (comportement par défaut des projets)', + value: 'disabled', + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'enablePlugin', + kind: 'switch', + initialValue: 'enabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Activer/Désactiver entièrement le plugin Nexus', + value: 'enabled', + description: 'Défaut: Activé', + }, + ], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-base-0/plugins/nexus/src/logger.ts b/.jjconflict-base-0/plugins/nexus/src/logger.ts new file mode 100644 index 0000000000..952b553155 --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'nexus' }) diff --git a/.jjconflict-base-0/plugins/nexus/src/maven.ts b/.jjconflict-base-0/plugins/nexus/src/maven.ts new file mode 100644 index 0000000000..4fea9e16cc --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/src/maven.ts @@ -0,0 +1,193 @@ +import type { AxiosInstance } from 'axios' +import type { WritePolicy } from './utils.js' +import getConfig from './config.js' +import { deleteIfExists } from './utils.js' + +// Retro-compatibilty, maven is a special case with bad name formats +function getRepoNames(projectName: string) { // Unique function per language cause names are unique per repo + return { + hosted: [ + { + repo: `${projectName}-repository-release`, + privilege: `${projectName}-privilege-release`, + }, + { + repo: `${projectName}-repository-snapshot`, + privilege: `${projectName}-privilege-snapshot`, + }, + ], + group: { + repo: `${projectName}-repository-group`, + privilege: `${projectName}-privilege-group`, + }, + } +} + +async function provisionMavenHosted(axiosInstance: AxiosInstance, repoName: string, writePolicy: WritePolicy) { + const repo = await axiosInstance({ + method: 'GET', + url: `/repositories/maven/hosted/${repoName}`, + validateStatus: code => [200, 404].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/maven/hosted', + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/maven/hosted/${repoName}`, + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [204].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/maven/hosted', + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/maven/hosted/${repoName}`, + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [204].includes(code), + }) + } + } +} +interface MavenOptions { + snapshotWritePolicy: WritePolicy + releaseWritePolicy: WritePolicy +} +export async function createMavenRepo(axiosInstance: AxiosInstance, projectName: string, options: MavenOptions) { + const names = getRepoNames(projectName) + + // create local repo maven + await Promise.all([ + provisionMavenHosted(axiosInstance, names.hosted[0].repo, options.releaseWritePolicy), + provisionMavenHosted(axiosInstance, names.hosted[1].repo, options.snapshotWritePolicy), + ]) + + // create maven group + await axiosInstance({ + method: 'post', + url: '/repositories/maven/group', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + 'maven-public', + ], + }, + }, + validateStatus: code => [201, 400].includes(code), + }) + + // create privileges + for (const name of [...names.hosted, names.group]) { + await axiosInstance({ + method: 'post', + url: '/security/privileges/repository-view', + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'maven2', + repository: name.repo, + }, + validateStatus: code => [201, 400].includes(code), + }) + } + return names +} + +export async function deleteMavenRepo(axiosInstance: AxiosInstance, projectName: string) { + const names = getRepoNames(projectName) + const repoPaths = [names.group, ...names.hosted] + const privileges = [...names.hosted, names.group] + const pathsToDelete = [ + // delete privileges + ...privileges.map(({ privilege }) => `/security/privileges/${privilege}`), + // delete local repo maven snapshot + ...repoPaths.map(repo => `/repositories/${repo.repo}`), + ] + for (const path of pathsToDelete) { + await deleteIfExists(path, axiosInstance) + } +} + +export function getMavenUrls(projectName: string) { + const nexusUrl = getConfig().secretExposedUrl + const names = getRepoNames(projectName) + return { + MAVEN_REPO_RELEASE: `${nexusUrl}/${names.hosted[0].repo}`, + MAVEN_REPO_SNAPSHOT: `${nexusUrl}/${names.hosted[1].repo}`, + } +} diff --git a/.jjconflict-base-0/plugins/nexus/src/monitor.ts b/.jjconflict-base-0/plugins/nexus/src/monitor.ts new file mode 100644 index 0000000000..a79babebeb --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/src/monitor.ts @@ -0,0 +1,79 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import { getAxiosOptions } from './functions.js' + +const coreComponents = [ + 'Blob Stores Ready', + 'File Blob Stores Path', +] +const auxComponents = [ + 'Available CPUs', + 'Blob Stores Quota', + 'Blob Stores Ready', + 'Default Admin Credentials', + 'DefaultRoleRealm', + 'File Blob Stores Path', + 'NuGet V2 repositories', + 'Scheduler', + 'com.codahale.metrics.health.jvm.ThreadDeadlockHealthCheck', + 'Node Health Check', + 'Re-encryption required', + 'Default Admin Credentials', + 'Recent version', +] + +type NexusRes = Record + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const status = await axios.get('/status', { + validateStatus: () => true, + ...getAxiosOptions(), + }) + const statusCheck = await axios.get('/status/check', { + validateStatus: res => [200, 503].includes(res), + ...getAxiosOptions(), + }) + const statusWritable = await axios.get('/status/writable', { + validateStatus: res => [200, 503].includes(res), + ...getAxiosOptions(), + }) + if (status.status === 503 || statusWritable.status === 503) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Nexus semble planté' + return instance.lastStatus + } + if (statusCheck.status === 200) { + const data = statusCheck.data as NexusRes + + const failedCoreComponents = coreComponents.filter(component => !data[component]?.healthy) + const failedAuxComponents = auxComponents.filter(component => !data[component]?.healthy) + if (failedAuxComponents.length || failedCoreComponents.length) { + instance.lastStatus.status = failedCoreComponents.length + ? MonitorStatus.ERROR + : MonitorStatus.WARNING + instance.lastStatus.message = failedCoreComponents.length + ? 'Des composants critiques sont en erreur' + : 'Le service est partiellement dégradé' + + instance.lastStatus.cause = `Les composants suivants sont en erreurs: ${[...failedCoreComponents, ...failedAuxComponents].join(', ')}` + return instance.lastStatus + } + } + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-base-0/plugins/nexus/src/npm.ts b/.jjconflict-base-0/plugins/nexus/src/npm.ts new file mode 100644 index 0000000000..02bc3573f0 --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/src/npm.ts @@ -0,0 +1,167 @@ +import type { AxiosInstance } from 'axios' +import type { WritePolicy } from './utils.js' +import getConfig from './config.js' +import { deleteIfExists } from './utils.js' + +function getRepoNames(projectName: string) { // Unique function per language cause names are unique per repo + return { + hosted: [ + { + repo: `${projectName}-npm`, + privilege: `${projectName}-npm-privilege`, + }, + ], + group: { + repo: `${projectName}-npm-group`, + privilege: `${projectName}-npm-group-privilege`, + }, + } +} +export async function createNpmRepo(axiosInstance: AxiosInstance, projectName: string, writePolicy: WritePolicy) { + const names = getRepoNames(projectName) + // create local repo maven + for (const repVersion of names.hosted) { + const repo = await axiosInstance({ + method: 'GET', + url: `/repositories/npm/hosted/${repVersion.repo}`, + validateStatus: code => [200, 404].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/npm/hosted', + data: { + name: repVersion.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/npm/hosted/${repVersion.repo}`, + data: { + name: repVersion.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + }, + validateStatus: code => [204].includes(code), + }) + } + } + // create maven group + const group = await axiosInstance({ + method: 'get', + url: `/repositories/npm/group/${names.group.repo}`, + validateStatus: code => [200, 404].includes(code), + }) + if (group.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/npm/group', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + ], + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + url: `/repositories/npm/group/${names.group.repo}`, + method: 'put', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + ], + }, + }, + validateStatus: code => [204].includes(code), + }) + } + + for (const name of [...names.hosted, names.group]) { + const privilege = await axiosInstance({ + method: 'get', + url: `/security/privileges/${name.privilege}`, + validateStatus: code => [200, 404].includes(code), + }) + if (privilege.status === 404) { + await axiosInstance({ + method: 'post', + url: '/security/privileges/repository-view', + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'npm', + repository: name.repo, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/security/privileges/repository-view/${name.privilege}`, + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'npm', + repository: name.repo, + }, + validateStatus: code => [204].includes(code), + }) + } + } + return names +} + +export async function deleteNpmRepo(axiosInstance: AxiosInstance, projectName: string) { + const names = getRepoNames(projectName) + const repoPaths = [names.group, ...names.hosted] + const privileges = [...names.hosted, names.group] + const pathsToDelete = [ + // delete privileges + ...privileges.map(({ privilege }) => `/security/privileges/${privilege}`), + // delete local repo maven snapshot + ...repoPaths.map(repo => `/repositories/${repo.repo}`), + ] + for (const path of pathsToDelete) { + await deleteIfExists(path, axiosInstance) + } +} + +export function getNpmUrls(projectName: string) { + const nexusUrl = getConfig().secretExposedUrl + const names = getRepoNames(projectName) + return { + NPM_REPO: `${nexusUrl}/${names.hosted[0].repo}`, + } +} diff --git a/.jjconflict-base-0/plugins/nexus/src/project.ts b/.jjconflict-base-0/plugins/nexus/src/project.ts new file mode 100644 index 0000000000..2363ca05a1 --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/src/project.ts @@ -0,0 +1,245 @@ +import type { Project, ProjectLite, StepCall } from '@cpn-console/hooks' +import type { WritePolicy } from './utils.js' +import { generateRandomPassword, specificallyDisabled } from '@cpn-console/hooks' +import axios from 'axios' +import { getAxiosOptions } from './functions.js' +import { logger } from './logger.js' +import { createMavenRepo, deleteMavenRepo, getMavenUrls } from './maven.js' +import { createNpmRepo, deleteNpmRepo, getNpmUrls } from './npm.js' +import { deleteIfExists, getTechUsed, parseProjectOptions, updateStore } from './utils.js' + +const getAxiosInstance = () => axios.create(getAxiosOptions()) + +export const deleteNexusProject: StepCall = async ({ args: project }) => { + const axiosInstance = getAxiosInstance() + const projectName = project.slug + try { + await deleteMavenRepo(axiosInstance, projectName) + await deleteNpmRepo(axiosInstance, projectName) + await Promise.all([ + // delete role + deleteIfExists(`/security/roles/${projectName}-ID`, axiosInstance), + // delete user + axiosInstance({ + method: 'delete', + url: `/security/users/${projectName}`, + validateStatus: code => code === 404 || code < 300, + }), + ]) + + logger.info({ action: 'deleteNexusProject', projectSlug: project.slug, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Project deleted from Nexus', + }, + store: { + activateNpmRepo: 'disabled', + activateMavenRepo: 'disabled', + }, + } + } catch (error) { + logger.error({ action: 'deleteNexusProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const createNexusProject: StepCall = async (payload) => { + try { + if (specificallyDisabled(payload.config.nexus?.enablePlugin)) { + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'disabled' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Nexus plugin is disabled', + }, + } + } + if (!payload.apis.vault) throw new Error('no Vault available') + + const axiosInstance = getAxiosInstance() + const projectName = payload.args.slug + const owner = payload.args.owner + const res: any = {} + const options = parseProjectOptions(payload.args.store.nexus) + const failedProvisionning: Partial> = {} + const techUsed = getTechUsed(payload) + + if (options.keysInError.length) { + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'invalid-config', invalidKeyCount: options.keysInError.length }, 'Hook done') + return { + status: { + result: 'WARNING', + message: `${options.keysInError.join(', ')} ${options.keysInError.length > 1 ? 'are' : 'is'} invalid, provisionning canceled, fix it and try again`, + }, + warnReasons: [`invalid key(s): ${options.keysInError.join(', ')}`], + } + } + const privilegesToAccess = [] as string[] + + try { + if (techUsed.maven) { + const names = await createMavenRepo(axiosInstance, projectName, { + releaseWritePolicy: options.mavenReleaseWritePolicy as WritePolicy, + snapshotWritePolicy: options.mavenSnapshotWritePolicy as WritePolicy, + }) + privilegesToAccess.push(names.group.privilege, ...names.hosted.map(({ privilege }) => privilege)) + } else { + await deleteMavenRepo(axiosInstance, projectName) + } + } catch (error) { + failedProvisionning.maven = { error, message: `Maven failed to ${techUsed.maven ? 'provision' : 'delete'} repositories please try again in few minutes` } + } + + try { + if (techUsed.npm) { + const names = await createNpmRepo(axiosInstance, projectName, options.npmWritePolicy as WritePolicy) + privilegesToAccess.push(names.group.privilege, ...names.hosted.map(({ privilege }) => privilege)) + } else { + await deleteNpmRepo(axiosInstance, projectName) + } + } catch (error) { + failedProvisionning.npm = { error, message: `Npm failed to ${techUsed.npm ? 'provision' : 'delete'} repositories please try again in few minutes` } + } + + const roleId = `${projectName}-ID` + // create role + const role = await axiosInstance({ + method: 'GET', + url: `security/roles/${roleId}`, + validateStatus: code => [200, 404].includes(code), + }) + if (role.status === 404) { + await axiosInstance({ + method: 'post', + url: '/security/roles', + data: { + id: `${projectName}-ID`, + name: `${projectName}-role`, + description: 'desc', + privileges: privilegesToAccess, + }, + validateStatus: code => [200].includes(code), + }) + } else if (role.status === 200) { + await axiosInstance({ + method: 'PUT', + url: `security/roles/${roleId}`, + data: { + id: `${projectName}-ID`, + name: `${projectName}-role`, + privileges: privilegesToAccess, + }, + validateStatus: code => [204].includes(code), + }) + } + + const vaultNexusSecret = await payload.apis.vault.read('NEXUS', { throwIfNoEntry: false }) + let currentPwd: string = vaultNexusSecret?.NEXUS_PASSWORD + + const newPwd = generateRandomPassword(30) + const getUser = await axiosInstance({ + url: `/security/users?userId=${projectName}`, + }) as { data: { userId: string }[] } + const user = getUser.data.find(user => user.userId === projectName) + if (user) { + res.user = getUser.data[0] + res.status = { result: 'OK', message: 'User already exists' } + if (!vaultNexusSecret) { + await axiosInstance({ + method: 'put', + url: `/security/users/${projectName}/change-password`, + data: newPwd, + headers: { + 'Content-Type': 'text/plain', + }, + }) + currentPwd = newPwd + } + } else { + // createUser + await axiosInstance({ + method: 'post', + url: '/security/users', + data: { + userId: projectName, + firstName: 'Monkey D.', + lastName: 'Luffy', + emailAddress: owner.email, + password: newPwd, + status: 'active', + roles: [`${projectName}-ID`], + }, + }) + currentPwd = newPwd + } + + if (!getUser.data.length || (getUser.data.length && !vaultNexusSecret)) { // conditions précédentes, si non existent ou si modp a dû être changé + await payload.apis.vault.write({ + NEXUS_PASSWORD: currentPwd, + NEXUS_USERNAME: projectName, + }, 'NEXUS') + } + + if (Object.keys(failedProvisionning).length) { + const failed = Object.values(failedProvisionning) + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'partial-failure', failedProvisionCount: failed.length }, 'Hook done') + return { + status: { + result: 'WARNING', + message: failed.map(({ message }) => message).join('; '), + }, + errors: failed.map(({ error }) => error), + } + } + + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'up-to-date' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + store: updateStore(payload.args.store.nexus), + } + } catch (error) { + logger.error({ action: 'createNexusProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Fail Create repositories', + }, + } + } +} + +export const getSecrets: StepCall = async (payload) => { + if (specificallyDisabled(payload.config.nexus?.enablePlugin)) { + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug, outcome: 'disabled' }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } + const projectName = payload.args.slug + const techUsed = getTechUsed(payload) + + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + secrets: { + ...techUsed.maven && getMavenUrls(projectName), + ...techUsed.npm && getNpmUrls(projectName), + }, + } +} diff --git a/.jjconflict-base-0/plugins/nexus/src/utils.ts b/.jjconflict-base-0/plugins/nexus/src/utils.ts new file mode 100644 index 0000000000..5c52c4435f --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/src/utils.ts @@ -0,0 +1,80 @@ +import type { HookPayload, Project, ProjectLite } from '@cpn-console/hooks' +import type { AxiosInstance } from 'axios' +import { defaultOrNullish, specificallyEnabled } from '@cpn-console/hooks' + +export async function deleteIfExists(url: string, axiosInstance: AxiosInstance) { + const res = await axiosInstance({ + method: 'get', + url, + validateStatus: code => code === 404 || code < 300, + }) + if (res.status !== 404) { + // delete maven group + return axiosInstance({ + method: 'delete', + url, + validateStatus: code => code === 404 || code < 300, + }) + } + return res +} + +export function getTechUsed(payload: HookPayload) { + const projectStore = payload.args.store.nexus + const globalConfig = payload.config.nexus + const techUsed = { + maven: false, + npm: false, + } + if (specificallyEnabled(projectStore?.activateMavenRepo)) { + techUsed.maven = true + } else if (defaultOrNullish(projectStore?.activateMavenRepo) && specificallyEnabled(globalConfig?.activateMavenRepoDefaultValue)) { + techUsed.maven = true + } + if (specificallyEnabled(projectStore?.activateNpmRepo)) { + techUsed.npm = true + } else if (defaultOrNullish(projectStore?.activateMavenRepo) && specificallyEnabled(globalConfig?.activateMavenRepoDefaultValue)) { + techUsed.npm = true + } + return techUsed +} + +export const writePolicyEnum = ['ALLOW', 'ALLOW_ONCE', 'DENY', 'REPLICATION_ONLY'] as const +export type WritePolicy = typeof writePolicyEnum[number] + +export function parseWritePolicy(inputTrial?: string): WritePolicy | 'ERROR' { + const input = inputTrial?.toUpperCase() ?? 'ALLOW' + return writePolicyEnum.find(policy => policy === input) ?? 'ERROR' +} + +export function parseProjectOptions(options: ProjectLite['store']['nexus']) { + // @ts-expect-error + const mavenReleaseWritePolicy = parseWritePolicy(options?.mavenReleaseWritePolicy ?? options?.mavenWritePolicy) + // @ts-expect-error + const mavenSnapshotWritePolicy = parseWritePolicy(options?.mavenSnapshotWritePolicy ?? options?.mavenWritePolicy) + const npmWritePolicy = parseWritePolicy(options?.npmWritePolicy) + const keysInError: (keyof NonNullable>)[] = [] + if (mavenReleaseWritePolicy === 'ERROR') keysInError.push('mavenReleaseWritePolicy') + if (mavenSnapshotWritePolicy === 'ERROR') keysInError.push('mavenSnapshotWritePolicy') + if (npmWritePolicy === 'ERROR') keysInError.push('npmWritePolicy') + return { + keysInError, + mavenReleaseWritePolicy, + mavenSnapshotWritePolicy, + npmWritePolicy, + } +} + +export function updateStore(store?: ProjectLite['store']['nexus']) { + if (!store) return {} + // @ts-expect-error + if (store.mavenWritePolicy) { + // @ts-expect-error + store.mavenReleaseWritePolicy = store.mavenReleaseWritePolicy ?? store.mavenWritePolicy + // @ts-expect-error + store.mavenSnapshotWritePolicy = store.mavenSnapshotWritePolicy ?? store.mavenWritePolicy + // @ts-expect-error + store.mavenWritePolicy = '' + } + return store +} diff --git a/.jjconflict-base-0/plugins/nexus/tsconfig.eslint.json b/.jjconflict-base-0/plugins/nexus/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-0/plugins/nexus/tsconfig.json b/.jjconflict-base-0/plugins/nexus/tsconfig.json new file mode 100644 index 0000000000..fb5ba5c23c --- /dev/null +++ b/.jjconflict-base-0/plugins/nexus/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "useUnknownInCatchVariables": true, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-0/plugins/sonarqube/.gitignore b/.jjconflict-base-0/plugins/sonarqube/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-0/plugins/sonarqube/eslint.config.js b/.jjconflict-base-0/plugins/sonarqube/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-0/plugins/sonarqube/package.json b/.jjconflict-base-0/plugins/sonarqube/package.json new file mode 100644 index 0000000000..703362ca0d --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/package.json @@ -0,0 +1,50 @@ +{ + "name": "@cpn-console/sonarqube-plugin", + "type": "module", + "version": "2.0.5", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/plugins/sonarqube/src/check.ts b/.jjconflict-base-0/plugins/sonarqube/src/check.ts new file mode 100644 index 0000000000..67a1e79cf2 --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/src/check.ts @@ -0,0 +1,45 @@ +import type { PluginResult } from '@cpn-console/hooks' +import { getAxiosInstance } from './tech.js' + +let status: PluginResult + +export async function getStatus() { + if (!status?.result) { + status = await check() + } + return status +} + +export async function check(): Promise { + const axiosInstance = getAxiosInstance() + + try { + const health = await axiosInstance({ + url: 'system/info', + }) + const res: PluginResult = { + status: { + result: 'OK', + }, + } + + if (health.data.Health === 'RED') { + res.status.result = 'KO' + } + if (health.data['Health Causes']) { + res.status.message = health.data['Health Causes'].join('\n') + } + + return res + } catch (error) { + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + updatedAt: Date.now(), + } + } +} diff --git a/.jjconflict-base-0/plugins/sonarqube/src/env.d.ts b/.jjconflict-base-0/plugins/sonarqube/src/env.d.ts new file mode 100644 index 0000000000..219601dae5 --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/src/env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/.jjconflict-base-0/plugins/sonarqube/src/functions.ts b/.jjconflict-base-0/plugins/sonarqube/src/functions.ts new file mode 100644 index 0000000000..2a72978cc7 --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/src/functions.ts @@ -0,0 +1,352 @@ +import type { Project, StepCall } from '@cpn-console/hooks' +import type { KeycloakProjectApi } from '@cpn-console/keycloak-plugin/types/class.js' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { SonarPaging } from './project.js' +import type { VaultSonarSecret } from './tech.js' +import type { SonarUser } from './user.js' +import { generateProjectKey } from '@cpn-console/hooks' +import { adminGroupPath } from '@cpn-console/shared' +import { ensureGroupExists, findGroupByName } from './group.js' +import { logger } from './logger.js' +import { + createDsoRepository, + deleteDsoRepository, + ensureRepositoryConfiguration, + files, + findSonarProjectsForDsoProjects, +} from './project.js' +import { getAxiosInstance } from './tech.js' +import { ensureUserExists } from './user.js' + +const globalPermissions = [ + 'admin', + 'profileadmin', + 'gateadmin', + 'scan', + 'provisioning', +] + +const projectPermissions = [ + 'admin', + 'codeviewer', + 'issueadmin', + 'securityhotspotadmin', + 'scan', + 'user', +] + +export async function initSonar() { + try { + await setTemplatePermisions() + await createAdminGroup() + await setAdminPermisions() + logger.info({ action: 'initSonar' }, 'Hook done') + } catch (error) { + logger.error({ action: 'initSonar', err: error }, 'Hook failed') + throw error + } +} + +async function createAdminGroup() { + const axiosInstance = getAxiosInstance() + const adminGroup = await findGroupByName(adminGroupPath) + if (!adminGroup) { + await axiosInstance({ + method: 'post', + params: { + name: adminGroupPath, + description: 'DSO platform admins', + }, + url: 'user_groups/create', + }) + } +} + +async function setAdminPermisions() { + const axiosInstance = getAxiosInstance() + for (const permission of globalPermissions) { + await axiosInstance({ + method: 'post', + params: { + groupName: adminGroupPath, + permission, + }, + url: 'permissions/add_group', + }) + } +} + +async function setTemplatePermisions() { + const axiosInstance = getAxiosInstance() + await axiosInstance({ + method: 'post', + params: { name: 'Forge Default' }, + url: 'permissions/create_template', + validateStatus: code => [200, 400].includes(code), + }) + for (const permission of projectPermissions) { + await axiosInstance({ + method: 'post', + params: { + templateName: 'Forge Default', + permission, + }, + url: 'permissions/add_project_creator_to_template', + }) + await axiosInstance({ + method: 'post', + params: { + groupName: 'sonar-administrators', + templateName: 'Forge Default', + permission, + }, + url: 'permissions/add_group_to_template', + }) + } + await axiosInstance({ + method: 'post', + params: { + templateName: 'Forge Default', + }, + url: 'permissions/set_default_template', + }) +} + +export const upsertProject: StepCall = async (payload) => { + try { + const project = payload.args + const { + vault: vaultApi, + keycloak: keycloakApi, + }: { + vault: VaultProjectApi + keycloak: KeycloakProjectApi + } = payload.apis + const { slug: projectSlug } = project + const username = project.slug + const keycloakGroupPath = await keycloakApi.getProjectGroupPath() + const sonarRepositories + = await findSonarProjectsForDsoProjects(projectSlug) + + await Promise.all([ + ensureUserAndVault(vaultApi, username, projectSlug), + ensureGroupExists(keycloakGroupPath), + + // Remove excess repositories + ...sonarRepositories + .filter( + sonarRepository => + !project.repositories.some( + repo => repo.internalRepoName === sonarRepository.repository, + ), + ) + .map(sonarRepository => deleteDsoRepository(sonarRepository.key)), + + // Create or configure needed repos + ...project.repositories.map(async (repository) => { + const projectKey = generateProjectKey( + projectSlug, + repository.internalRepoName, + ) + if ( + !sonarRepositories.some( + sonarRepository => + sonarRepository.repository === repository.internalRepoName, + ) + ) { + await createDsoRepository(projectSlug, repository.internalRepoName) + } + await ensureRepositoryConfiguration( + projectKey, + username, + keycloakGroupPath, + ) + }), + ]) + + logger.info({ action: 'upsertProject', projectSlug }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } catch (error) { + logger.error( + { action: 'upsertProject', projectSlug: payload.args.slug, err: error }, + 'Hook failed', + ) + return { + status: { + result: 'WARNING', + message: 'Failed to reconcile', + }, + errors: { + main: error, + }, + } + } +} + +export const setVariables: StepCall = async (payload) => { + const returnResponse = payload.results.sonarqube + try { + const project = payload.args + const { slug: projectSlug } = project + const { gitlab: gitlabApi } = payload.apis + + const sonarSecret = await payload.apis.vault.read('SONAR') + const listGroupVars = await gitlabApi.getGitlabGroupVariables() + await Promise.all([ + // Sonar vars saving in CI (repositories) + ...project.repositories.map(async (repo) => { + const projectKey = generateProjectKey( + projectSlug, + repo.internalRepoName, + ) + const repoId = await payload.apis.gitlab.getProjectId( + repo.internalRepoName, + ) + if (!repoId) return + const listVars = await gitlabApi.getGitlabRepoVariables(repoId) + return [ + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + key: 'PROJECT_KEY', + masked: false, + protected: false, + value: projectKey, + variable_type: 'env_var', + environment_scope: '*', + }), + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + key: 'PROJECT_NAME', + masked: false, + protected: false, + value: `${projectSlug}-${repo.internalRepoName}`, + variable_type: 'env_var', + environment_scope: '*', + }), + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + variable_type: 'file', + key: 'SONAR_PROJECT_PROPERTIES', + masked: false, + protected: false, + value: files['sonar-project.properties'](projectKey), + environment_scope: '*', + }), + ] + }), + // Sonar vars saving in CI (group) + gitlabApi.setGitlabGroupVariable(listGroupVars, { + key: 'SONAR_TOKEN', + masked: true, + protected: false, + value: sonarSecret.data.SONAR_TOKEN, + variable_type: 'env_var', + }), + ]) + + if (payload.results.sonarqube.status.result === 'WARNING') { + returnResponse.status.message = `main message: ${payload.results.sonarqube.status.message}, post message: OK` + } + logger.info({ action: 'setVariables', projectSlug }, 'Hook done') + return returnResponse + } catch (error) { + returnResponse.status.result = 'WARNING' + returnResponse.status.message = `main message: ${payload.results.sonarqube.status.message}, post message: Failed to reconcile` + returnResponse.errors = { + ...returnResponse.errors, + post: error, + } + logger.error( + { action: 'setVariables', projectSlug: payload.args.slug, err: error }, + 'Hook failed', + ) + return returnResponse + } +} + +export const deleteProject: StepCall = async (payload) => { + const axiosInstance = getAxiosInstance() + + const project = payload.args + const { slug: projectSlug } = project + const username = projectSlug + try { + const sonarRepositories + = await findSonarProjectsForDsoProjects(projectSlug) + await Promise.all(sonarRepositories.map(repo => deleteRepo(repo.key))) + const users: { paging: SonarPaging, users: SonarUser[] } = ( + await axiosInstance({ + url: 'users/search', + params: { + q: username, + }, + }) + )?.data + const user = users.users.find(u => u.login === username) + if (!user) { + return { + status: { + result: 'OK', + message: 'Already missing', + }, + } + } + await axiosInstance({ + url: 'users/deactivate', + params: { + login: username, + anonymize: true, + }, + method: 'post', + }) + logger.info( + { action: 'deleteProject', projectSlug, outcome: 'user-anonymized' }, + 'Hook done', + ) + return { + status: { + result: 'OK', + message: 'User anonymized', + }, + } + } catch (error) { + logger.error( + { action: 'deleteProject', projectSlug, err: error }, + 'Hook failed', + ) + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +async function deleteRepo(projectKey: string) { + return getAxiosInstance()({ + url: 'projects/delete', + params: { + project: projectKey, + }, + method: 'post', + }) +} + +async function ensureUserAndVault( + vaultApi: VaultProjectApi, + username: string, + projectSlug: string, +) { + const vaultUserSecret = (await vaultApi.read('SONAR', { + throwIfNoEntry: false, + })) as VaultSonarSecret | undefined + const newUserSecret = await ensureUserExists( + username, + projectSlug, + vaultUserSecret, + ) + if (newUserSecret) await vaultApi.write(newUserSecret, 'SONAR') +} diff --git a/.jjconflict-base-0/plugins/sonarqube/src/group.ts b/.jjconflict-base-0/plugins/sonarqube/src/group.ts new file mode 100644 index 0000000000..08ae823069 --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/src/group.ts @@ -0,0 +1,35 @@ +import type { SonarPaging } from './project.js' +import { getAxiosInstance } from './tech.js' + +export interface SonarGroup { + id: string + name: string + description: string + membersCount: number + default: boolean +} + +export async function findGroupByName(name: string): Promise { + const axiosInstance = getAxiosInstance() + const groupsSearch: { paging: SonarPaging, groups: SonarGroup[] } = (await axiosInstance({ + url: 'user_groups/search', + params: { + q: name, + }, + }))?.data + return groupsSearch.groups.find(g => g.name === name) +} + +export async function ensureGroupExists(groupName: string) { + const axiosInstance = getAxiosInstance() + const group = await findGroupByName(groupName) + if (!group) { + await axiosInstance({ + url: 'user_groups/create', + method: 'post', + params: { + name: groupName, + }, + }) + } +} diff --git a/.jjconflict-base-0/plugins/sonarqube/src/index.ts b/.jjconflict-base-0/plugins/sonarqube/src/index.ts new file mode 100644 index 0000000000..a05d49e210 --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/src/index.ts @@ -0,0 +1,37 @@ +import type { HookStepsNames, Plugin } from '@cpn-console/hooks' +import { getStatus } from './check.js' +import { deleteProject, initSonar, setVariables, upsertProject } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +function start(_options: unknown) { + try { + initSonar() + getStatus() + } catch {} +} + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { + steps: { + main: upsertProject, + post: setVariables, + }, + }, + deleteProject: { + steps: { + main: deleteProject, + }, + }, + }, + start, + monitor, +} + +declare module '@cpn-console/hooks' { + interface PluginResult { + errors?: Partial> + } +} diff --git a/.jjconflict-base-0/plugins/sonarqube/src/infos.ts b/.jjconflict-base-0/plugins/sonarqube/src/infos.ts new file mode 100644 index 0000000000..94ea6616cc --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/src/infos.ts @@ -0,0 +1,12 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { getConfig } from './tech.js' + +const infos: ServiceInfos = { + name: 'sonarqube', + to: () => `${getConfig().url}/projects`, + title: 'SonarQube', + imgSrc: '/img/sonarqube.svg', + description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', +} + +export default infos diff --git a/.jjconflict-base-0/plugins/sonarqube/src/logger.ts b/.jjconflict-base-0/plugins/sonarqube/src/logger.ts new file mode 100644 index 0000000000..7683fa7961 --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'sonarqube' }) diff --git a/.jjconflict-base-0/plugins/sonarqube/src/monitor.ts b/.jjconflict-base-0/plugins/sonarqube/src/monitor.ts new file mode 100644 index 0000000000..d4fe929477 --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/src/monitor.ts @@ -0,0 +1,41 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import { getAxiosOptions } from './tech.js' + +const statusMap = { + GREEN: MonitorStatus.OK, + YELLOW: MonitorStatus.WARNING, + RED: MonitorStatus.ERROR, +} +const messageMap = { + GREEN: MonitorStatus.OK, + YELLOW: 'Service dégradé', + RED: 'Service en panne', +} +interface SonarRes { + health: keyof typeof statusMap + causes: string[] +} + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get('/system/health', { + validateStatus: res => res === 200, + ...getAxiosOptions(), + }) + const data = res.data as SonarRes + + instance.lastStatus.status = statusMap[data.health] + instance.lastStatus.message = messageMap[data.health] + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-base-0/plugins/sonarqube/src/project.ts b/.jjconflict-base-0/plugins/sonarqube/src/project.ts new file mode 100644 index 0000000000..16082a8d5e --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/src/project.ts @@ -0,0 +1,160 @@ +import { generateProjectKey } from '@cpn-console/hooks' +import { logger } from './logger.js' +import { getAxiosInstance } from './tech.js' + +export interface SonarPaging { + pageIndex: number + pageSize: number + total: number +} + +export type Qualifiers + = 'BRC' // - Sub - projects + | 'DIR' // - Directories + | 'FIL' // - Files + | 'TRK' // - Projects + | 'UTS' // - Test Files + +export interface SonarProject { + key: string // unique key name + name: string + qualifier: Qualifiers + visibility: 'private' | 'public' + lastAnalysisDate?: string + revision?: string +} + +const robotPermissions = [ + 'user', + 'codeviewer', + 'admin', + 'scan', +] +const groupPermissions = [ + 'user', + 'codeviewer', + 'issueadmin', + 'securityhotspotadmin', + 'scan', +] + +export async function createDsoRepository(projectSlug: string, internalRepoName: string, sonarProjectKey?: string) { + const sonarProjectName = `${projectSlug}-${internalRepoName}` + if (!sonarProjectKey) + sonarProjectKey = generateProjectKey(projectSlug, internalRepoName) + return createProject(sonarProjectKey, sonarProjectName) +} + +export async function ensureRepositoryConfiguration(projectKey: string, login: string, groupName: string) { + const axiosInstance = getAxiosInstance() + + for (const permission of robotPermissions) { + await axiosInstance({ + url: 'permissions/add_user', + method: 'post', + params: { + projectKey, + permission, + login, + }, + }) + } + for (const permission of groupPermissions) { + await axiosInstance({ + url: 'permissions/add_group', + method: 'post', + params: { + projectKey, + permission, + groupName, + }, + }) + } +} + +export async function createProject(projectKey: string, projectName: string) { + return getAxiosInstance()({ + url: 'projects/create', + method: 'post', + params: { + project: projectKey, + visibility: 'private', + name: projectName, + mainbranch: 'main', + }, + }) +} + +export async function deleteDsoRepository(projectKey: string) { + const axiosInstance = getAxiosInstance() + + await axiosInstance({ + url: 'projects/delete', + method: 'post', + params: { + project: projectKey, + }, + }) +} + +interface SonarProjectResult { + projectSlug: string + repository: string + key: string +} + +function filterProjectsOwning(repos: { key: string }[], projectSlug: string): SonarProjectResult[] { + return repos.reduce((acc, repo) => { + let isOrphan = true + + const sonarKey = repo.key + const keyElements = sonarKey.split('-') + keyElements.pop() + for (let i = keyElements.length - 1; i > 0; i--) { + const project = keyElements.slice(0, i).join('-') + const repository = keyElements.slice(i).join('-') + const keyComputed = generateProjectKey(project, repository) + if (keyComputed === sonarKey) { + if (project === projectSlug) { + acc.push({ + projectSlug, + repository, + key: sonarKey, + }) + } + isOrphan = false + } + } + if (isOrphan) { + logger.warn({ sonarKey, projectSlug }, 'Orphan SonarQube project key does not match expected pattern') + } + return acc + }, [] as SonarProjectResult[]) +} + +export async function findSonarProjectsForDsoProjects(projectSlug: string) { + const axiosInstance = getAxiosInstance() + let foundProjectKeys: SonarProjectResult[] = [] + + let page = 0 + const pageSize = 100 + let total = 0 + do { + page++ + const similarProjects = await axiosInstance.get('projects/search', { + params: { + q: projectSlug, + p: page, + ps: pageSize, + }, + }) + total = similarProjects.data.paging.total + foundProjectKeys = [...foundProjectKeys, ...filterProjectsOwning(similarProjects.data.components, projectSlug)] + } while (page * pageSize < total) + + return foundProjectKeys +} + +export const files = { + 'sonar-project.properties': (key: string) => `sonar.projectKey=${key}\nsonar.qualitygate.wait=true\n`, +} diff --git a/.jjconflict-base-0/plugins/sonarqube/src/tech.ts b/.jjconflict-base-0/plugins/sonarqube/src/tech.ts new file mode 100644 index 0000000000..e820d8d381 --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/src/tech.ts @@ -0,0 +1,39 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +const config: { + url?: string + internalUrl?: string + user?: string + password?: string +} = {} + +export function getConfig(): Required { + config.url = config.url ?? removeTrailingSlash(requiredEnv('SONARQUBE_URL')) + config.internalUrl = config.internalUrl ?? removeTrailingSlash(requiredEnv('SONARQUBE_INTERNAL_URL')) + config.user = config.user ?? requiredEnv('SONAR_API_TOKEN') + // @ts-ignore + return config +} +export function getAxiosOptions() { + return { + baseURL: `${getConfig().internalUrl}/api/`, + auth: { + username: getConfig().user, + password: '', // Token is used, so password is useless + }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } +} + +export function getAxiosInstance() { + return axios.create(getAxiosOptions()) +} + +export interface VaultSonarSecret { + SONAR_USERNAME: string + SONAR_PASSWORD: string + SONAR_TOKEN: string +} diff --git a/.jjconflict-base-0/plugins/sonarqube/src/user.ts b/.jjconflict-base-0/plugins/sonarqube/src/user.ts new file mode 100644 index 0000000000..0dae43617d --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/src/user.ts @@ -0,0 +1,101 @@ +import type { SonarPaging } from './project.js' +import type { VaultSonarSecret } from './tech.js' +// import type { StepCall, ArchiveProjectExecArgs } from '@cpn-console/hooks' +import { generateRandomPassword } from '@cpn-console/hooks' +import { getAxiosInstance } from './tech.js' + +export interface SonarUser { + login: string + name: string + active: boolean + email: string + groups: string[] + tokensCount: number + local: boolean + externalIdentity: string + externalProvider: string + avatar: string + lastConnectionDate: Date + managed: boolean + sonarLintLastConnectionDate: Date +} + +export async function createUser(username: string, projectSlug: string) { + const axiosInstance = getAxiosInstance() + const fakeEmail = `${projectSlug}@${projectSlug}` + + const newPwd = generateRandomPassword(30) + await axiosInstance({ + url: 'users/create', + method: 'post', + params: { + email: fakeEmail, + local: 'true', + login: username, + name: username, + password: newPwd, + }, + }) + return newPwd +} + +export async function changeToken(username: string) { + const axiosInstance = getAxiosInstance() + await axiosInstance({ + url: 'user_tokens/revoke', + method: 'post', + params: { + login: username, + name: `Sonar Token for ${username}`, + }, + }) + const newToken = await axiosInstance({ + url: 'user_tokens/generate', + method: 'post', + params: { + login: username, + name: `Sonar Token for ${username}`, + }, + }) + return newToken.data.token +} + +export async function getUser(username: string): Promise { + const axiosInstance = getAxiosInstance() + let page = 1 + const pageSize = 100 + while (true) { + const response = await axiosInstance({ + url: 'users/search', + params: { + q: username, + ps: pageSize, + p: page, + }, + }) + const users: { paging: SonarPaging, users: SonarUser[] } = response.data + const found = users.users.find(user => user.login === username) + if (found) return found + if (!users.users.length || users.paging.pageIndex * users.paging.pageSize >= users.paging.total) { + break + } + page += 1 + } +} + +export async function ensureUserExists(username: string, projectSlug: string, vaultUserSecret: VaultSonarSecret | undefined): Promise { + const user = await getUser(username) + if (!user) { + return { + SONAR_PASSWORD: await createUser(username, projectSlug), + SONAR_TOKEN: await changeToken(username), + SONAR_USERNAME: username, + } + } else if (!vaultUserSecret) { + return { + SONAR_PASSWORD: 'not initialized', + SONAR_TOKEN: await changeToken(username), + SONAR_USERNAME: username, + } + } +} diff --git a/.jjconflict-base-0/plugins/sonarqube/tsconfig.eslint.json b/.jjconflict-base-0/plugins/sonarqube/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-0/plugins/sonarqube/tsconfig.json b/.jjconflict-base-0/plugins/sonarqube/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-0/plugins/sonarqube/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-0/plugins/vault/.gitignore b/.jjconflict-base-0/plugins/vault/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-0/plugins/vault/README.md b/.jjconflict-base-0/plugins/vault/README.md new file mode 100644 index 0000000000..bc7c4d1ec9 --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/README.md @@ -0,0 +1,14 @@ +# Introduction + +Plugin de gestion du plugin Vault + +## Configuration + +| Env var | valeur possible | description | +| --------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| VAULT_TOKEN | chaine | Root token ou token ayant les pleins droits sur le Vault | +| VAULT_URL | *url* | Url public d'accès au Vault | +| VAULT_INTERNAL_URL | *url* ou vide | Url par laquelle la console interroge le service Vault, si absent utilisation de l'url public | +| VAULT__HIDE_PROJECT_SERVICE | "true" ou vide | Décide si le plugin masque ou non le service dans l'interface du projet. Ne désactive pas la fonctionnalité du store projet seulement l'affichage. | +| VAULT__DISABLE_VAULT_SECRETS | "true" ou vide | Désactiver le déploiement des ressources nécessaires au Vault Secret Operator dans le namespace cible | +| VAULT__DEPLOY_VAULT_CONNECTION_IN_NS | "true" ou vide | Activer le déploiement du vaultconnections dans le namespace utilisateur, si vide ou faux, s'assure que la ressource n'existe pas | diff --git a/.jjconflict-base-0/plugins/vault/eslint.config.js b/.jjconflict-base-0/plugins/vault/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-0/plugins/vault/package.json b/.jjconflict-base-0/plugins/vault/package.json new file mode 100644 index 0000000000..b8fb478904 --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/package.json @@ -0,0 +1,47 @@ +{ + "name": "@cpn-console/vault-plugin", + "type": "module", + "version": "2.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-0/plugins/vault/src/config.ts b/.jjconflict-base-0/plugins/vault/src/config.ts new file mode 100644 index 0000000000..abce2040b0 --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/src/config.ts @@ -0,0 +1,32 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + token: string + projectsRootDir: string + hideProjectService: boolean + disableVaultSecrets: boolean + deployVaultConnectionInNs: boolean + constructor() { + this.token = requiredEnv('VAULT_TOKEN') + this.publicUrl = removeTrailingSlash(requiredEnv('VAULT_URL')) + this.projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') + this.internalUrl = process.env.VAULT_INTERNAL_URL + ? removeTrailingSlash(process.env.VAULT_INTERNAL_URL) + : this.publicUrl + this.hideProjectService = process.env.VAULT__HIDE_PROJECT_SERVICE === 'true' + this.disableVaultSecrets = process.env.VAULT__DISABLE_VAULT_SECRETS === 'true' + this.deployVaultConnectionInNs = process.env.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS === 'true' + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-base-0/plugins/vault/src/functions.ts b/.jjconflict-base-0/plugins/vault/src/functions.ts new file mode 100644 index 0000000000..2e3d98838b --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/src/functions.ts @@ -0,0 +1,112 @@ +import type { + Project, + ProjectLite, + StepCall, + ZoneObject, +} from '@cpn-console/hooks' +import { okStatus } from '@cpn-console/hooks' +import { logger } from './logger.js' + +export const upsertProject: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.Project.upsert() + logger.info({ action: 'upsertProject', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const archiveDsoProject: StepCall = async (payload) => { + try { + if (!payload.apis.vault) throw new Error('no Vault available') + await payload.apis.vault.Project.delete() + const allSecrets = await payload.apis.vault.list('/') + const promisesDestroy = allSecrets.map((path) => { + return payload.apis.vault.destroy(path) + }) + await Promise.all(promisesDestroy) + + logger.info({ action: 'archiveDsoProject', projectSlug: payload.args.slug, secretsDestroyed: allSecrets.length }, 'Hook done') + return { + status: { + result: 'OK', + }, + secretsDestroyed: allSecrets.length, + } + } catch (error) { + logger.error({ action: 'archiveDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const getSecrets: StepCall = async (payload) => { + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + secrets: { + '.spec.mount': `${payload.args.slug}`, + '.spec.vaultAuthRef': 'vault-auth', + }, + } +} +export const upsertZone: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.upsert() + logger.info({ action: 'upsertZone', zoneSlug: payload.args.slug }, 'Hook done') + return okStatus + } catch (error) { + logger.error({ action: 'upsertZone', zoneSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} +export const deleteZone: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.delete() + logger.info({ action: 'deleteZone', zoneSlug: payload.args.slug }, 'Hook done') + return okStatus + } catch (error) { + logger.error({ action: 'deleteZone', zoneSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} diff --git a/.jjconflict-base-0/plugins/vault/src/index.ts b/.jjconflict-base-0/plugins/vault/src/index.ts new file mode 100644 index 0000000000..e73ba4ab91 --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/src/index.ts @@ -0,0 +1,59 @@ +import type { ClusterObject, DefaultArgs, Plugin, Project, ProjectLite, ZoneObject } from '@cpn-console/hooks' +import { archiveDsoProject, deleteZone, getSecrets, upsertProject, upsertZone } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' +import { VaultProjectApi } from './vault-project-api.js' +import { VaultZoneApi } from './vault-zone-api.js' + +const onlyApi = { api: (project: ProjectLite) => new VaultProjectApi(project) } + +export const plugin: Plugin = { + infos, + subscribedHooks: { + getProjectSecrets: { + ...onlyApi, + steps: { + main: getSecrets, + }, + }, + upsertProject: { + ...onlyApi, + steps: { + main: upsertProject, + }, + }, + deleteProject: { + ...onlyApi, + steps: { post: archiveDsoProject }, // Destroy all secrets for project + }, + upsertZone: { + api: (zone: ZoneObject) => new VaultZoneApi(zone.slug), + steps: { + pre: upsertZone, + }, + }, + deleteZone: { + api: (zone: ZoneObject) => new VaultZoneApi(zone.slug), + steps: { + main: deleteZone, + }, + }, + upsertCluster: { + api: (c: ClusterObject) => new VaultZoneApi(c.zone.slug), + }, + deleteCluster: { + api: (c: ClusterObject) => new VaultZoneApi(c.zone.slug), + }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + vault: Args extends (ProjectLite | Project) + ? VaultProjectApi + : Args extends (ZoneObject | ClusterObject) + ? VaultZoneApi + : never + } +} diff --git a/.jjconflict-base-0/plugins/vault/src/infos.ts b/.jjconflict-base-0/plugins/vault/src/infos.ts new file mode 100644 index 0000000000..976f1f4218 --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/src/infos.ts @@ -0,0 +1,14 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import getConfig from './config.js' + +const infos: ServiceInfos = { + name: 'vault', + to: ({ project }) => getConfig().hideProjectService + ? undefined + : `${getConfig().publicUrl}/ui/vault/secrets/${project.slug}`, + title: 'Vault', + imgSrc: '/img/vault.svg', + description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', +} + +export default infos diff --git a/.jjconflict-base-0/plugins/vault/src/logger.ts b/.jjconflict-base-0/plugins/vault/src/logger.ts new file mode 100644 index 0000000000..f596e24a6f --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'vault' }) diff --git a/.jjconflict-base-0/plugins/vault/src/monitor.ts b/.jjconflict-base-0/plugins/vault/src/monitor.ts new file mode 100644 index 0000000000..091b850649 --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/src/monitor.ts @@ -0,0 +1,45 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import getConfig from './config.js' + +const vaultStatusCode = [200, 429, 472, 473, 501, 503] + +type VaultRes = { + initialized: boolean + sealed: boolean + standby: boolean + performance_standby: boolean + replication_performance_mode: string + replication_dr_mode: string + server_time_utc: number + version: string +} | undefined + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(`${getConfig().internalUrl}/v1/sys/health?standbyok=true`, { + headers: { + 'X-Vault-Token': getConfig().token, + }, + validateStatus: res => vaultStatusCode.includes(res), + }) + if (res.status === 200) { + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + } else { + instance.lastStatus.status = MonitorStatus.ERROR + const data = res.data as VaultRes + instance.lastStatus.message = data?.sealed ? 'Le Vault est scellé' : 'Vault en erreur' + } + } catch (error) { + instance.lastStatus.message = 'Error lors la requete' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + // test + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-base-0/plugins/vault/src/utils.ts b/.jjconflict-base-0/plugins/vault/src/utils.ts new file mode 100644 index 0000000000..d7036913f4 --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/src/utils.ts @@ -0,0 +1,21 @@ +import type { AxiosInstance } from 'axios' + +export async function getAuthMethod(axiosInstance: AxiosInstance, token: string) { + const response = await axiosInstance({ + method: 'get', + url: '/v1/sys/auth', + headers: { 'X-Vault-Token': token }, + }) + return response.data +} + +export const minimumConfig = { + type: 'kv', + options: { + version: 2, + }, +} +export function generateKVConfigUpdate(config: Record): typeof minimumConfig | void { + if (config?.type !== minimumConfig.type) return minimumConfig + if (config?.options?.version !== minimumConfig.options.version) return minimumConfig +} diff --git a/.jjconflict-base-0/plugins/vault/src/vault-api.ts b/.jjconflict-base-0/plugins/vault/src/vault-api.ts new file mode 100644 index 0000000000..a09cc7c338 --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/src/vault-api.ts @@ -0,0 +1,165 @@ +import type { AxiosInstance } from 'axios' +import { PluginApi } from '@cpn-console/hooks' +import axios from 'axios' +import getConfig from './config.js' +import { + generateKVConfigUpdate, +} from './utils.js' + +export interface AppRoleCredentials { + url: string + coreKvName: string + roleId: string + secretId: string +} + +export abstract class VaultApi extends PluginApi { + protected readonly axios: AxiosInstance + private token: string | undefined = undefined + + constructor() { + super() + this.axios = axios.create({ + baseURL: getConfig().internalUrl, + headers: { + 'X-Vault-Token': getConfig().token, + }, + }) + } + + protected async getToken() { + if (!this.token) { + this.token = (await this.axios.post('/v1/auth/token/create')).data.auth + .client_token as string + } + return this.token + } + + protected async destroy(path: string, kvName: string) { + if (path.startsWith('/')) path = path.slice(1) + return await this.axios({ + method: 'delete', + url: `/v1/${kvName}/metadata/${path}`, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + } + + Kv = { + upsert: async (kvName: string) => { + const token = await this.getToken() + const kvRes = await this.axios({ + method: 'get', + url: `/v1/sys/mounts/${kvName}/tune`, + headers: { 'X-Vault-Token': token }, + validateStatus: code => [400, 200].includes(code), + }) + if (kvRes.status === 400) { + await this.axios({ + method: 'post', + url: `/v1/sys/mounts/${kvName}`, + headers: { + 'X-Vault-Token': token, + }, + data: { + type: 'kv', + config: { + force_no_cache: true, + }, + options: { + version: 2, + }, + }, + }) + } else { + // means 200 status + const configUpdate = generateKVConfigUpdate(kvRes.data) + if (configUpdate) { + await this.axios({ + method: 'put', + url: `/v1/sys/mounts/${kvName}/tune`, + headers: { + 'X-Vault-Token': token, + }, + data: configUpdate, + }) + } + } + }, + delete: async (kvName: string) => { + const token = await this.getToken() + return await this.axios({ + method: 'delete', + url: `/v1/sys/mounts/${kvName}`, + headers: { 'X-Vault-Token': token }, + validateStatus: code => [400, 200, 204].includes(code), + }) + }, + } + + Policy = { + upsert: async (policyName: string, policy: string) => { + await this.axios({ + method: 'put', + url: `/v1/sys/policies/acl/${policyName}`, + data: { policy }, + headers: { + 'X-Vault-Token': await this.getToken(), + 'Content-Type': 'application/json', + }, + }) + }, + delete: async (policyName: string) => { + await this.axios({ + method: 'delete', + url: `/v1/sys/policies/acl/${policyName}`, + headers: { + 'X-Vault-Token': await this.getToken(), + 'Content-Type': 'application/json', + }, + }) + }, + } + + Role = { + upsert: async (roleName: string, policies: string[]) => { + await this.axios({ + method: 'post', + url: `/v1/auth/approle/role/${roleName}`, + data: { + secret_id_num_uses: '0', + secret_id_ttl: '0', + token_max_ttl: '0', + token_num_uses: '0', + token_ttl: '0', + token_type: 'batch', + token_policies: policies, + }, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + delete: async (roleName: string) => { + await this.axios.delete(`/v1/auth/approle/role/${roleName}`, { + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + getCredentials: async (roleName: string) => { + const { data: dataRole } = await this.axios.get( + `/v1/auth/approle/role/${roleName}/role-id`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + const { data: dataSecret } = await this.axios.put( + `/v1/auth/approle/role/${roleName}/secret-id`, + 'null', // Force empty data + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + return { + roleId: dataRole.data?.role_id, + secretId: dataSecret.data?.secret_id, + } + }, + } +} diff --git a/.jjconflict-base-0/plugins/vault/src/vault-project-api.ts b/.jjconflict-base-0/plugins/vault/src/vault-project-api.ts new file mode 100644 index 0000000000..45ec6280c9 --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/src/vault-project-api.ts @@ -0,0 +1,207 @@ +import type { ProjectLite } from '@cpn-console/hooks' +import getConfig from './config.js' +import { + getAuthMethod, +} from './utils.js' +import { VaultApi } from './vault-api.js' + +interface ReadOptions { + throwIfNoEntry: boolean +} +export interface AppRoleCredentials { + url: string + coreKvName: string + roleId: string + secretId: string +} + +interface VaultValuesWithoutCredentials { + /** Slash-separated directory (root node of all Gitlab projects) */ + projectsRootDir: string +} + +type VaultValues = AppRoleCredentials & VaultValuesWithoutCredentials + +export class VaultProjectApi extends VaultApi { + private readonly basePath: string + private readonly roleName: string + private readonly projectRootDir: string + private readonly defaultAppRoleCredentials: AppRoleCredentials + private readonly coreKvName: string = 'forge-dso' + private readonly projectKvName: string + private readonly groupName: string + private readonly policyName: { + techRO: string + appFull: string + } + + constructor(project: ProjectLite) { + super() + this.basePath = project.slug + this.roleName = project.slug + this.projectRootDir = getConfig().projectsRootDir + this.projectKvName = project.slug + this.groupName = project.slug + this.policyName = { + techRO: `tech--${project.slug}--ro`, + appFull: `app--${project.slug}--admin`, + } + this.defaultAppRoleCredentials = { + url: getConfig().deployVaultConnectionInNs ? getConfig().publicUrl : '', + coreKvName: this.coreKvName, + roleId: 'none', + secretId: 'none', + } + } + + public async list(path: string = '/'): Promise { + if (!path.startsWith('/')) path = `/${path}` + + const listSecretPath: string[] = [] + const response = await this.axios({ + url: `/v1/${this.coreKvName}/metadata/${this.projectRootDir}/${this.basePath}${path}/`, + headers: { + 'X-Vault-Token': await this.getToken(), + }, + method: 'list', + validateStatus: code => [200, 404].includes(code), + }) + + if (response.status === 404) return listSecretPath + for (const key of response.data.data.keys) { + if (key.endsWith('/')) { + const subSecrets = await this.list( + `${path.substring(this.basePath.length)}/${key}`, + ) + subSecrets.forEach((secret) => { + listSecretPath.push(`${key}${secret}`) + }) + } else { + listSecretPath.push(`/${key}`) + } + } + return listSecretPath.flat() + } + + public async read( + path: string = '/', + options: ReadOptions = { throwIfNoEntry: true }, + ) { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.get( + `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + validateStatus: status => + (options.throwIfNoEntry ? [200] : [200, 404]).includes(status), + }, + ) + return response.data.data + } + + public async write(body: object, path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.post( + `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + data: body, + }, + ) + return await response.data + } + + public async destroy(path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + return super.destroy( + `${this.projectRootDir}/${this.basePath}/${path}`, + this.coreKvName, + ) + } + + Project = { + upsert: async () => { + await this.Kv.upsert(this.projectKvName) + await this.Policy.upsert( + this.policyName.appFull, + `path "${this.projectKvName}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + ) + await this.Policy.upsert( + this.policyName.techRO, + `path "${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + ) + await this.Group.upsert() + await this.Role.upsert(this.roleName, [ + this.policyName.techRO, + this.policyName.appFull, + ]) + }, + delete: async () => { + await this.Kv.delete(this.projectKvName) + await this.Policy.delete(this.policyName.appFull) + await this.Policy.delete(this.policyName.techRO) + await this.Group.delete() + await this.Role.delete(this.roleName) + }, + getCredentials: async (): Promise => { + const creds = await this.Role.getCredentials(this.roleName) + return { + ...this.defaultAppRoleCredentials, + ...creds, + } + }, + getValues: async (): Promise => { + return { + projectsRootDir: getConfig().projectsRootDir, + ...(await this.Project.getCredentials()), + } + }, + } + + Group = { + upsert: async () => { + // TODO check api responses for POST and GET maybe duplicates + await this.axios({ + method: 'post', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + data: { + name: this.groupName, + type: 'external', + policies: [this.policyName.appFull], + }, + }) + + const response = await this.axios({ + method: 'get', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + validateStatus: code => [404, 200].includes(code), + }) + const group = response.data + const groupAliasName = `/${this.groupName}` + if (group.data.alias?.name === groupAliasName) { + return + } + const methods = await getAuthMethod(this.axios, await this.getToken()) + + await this.axios({ + url: `/v1/identity/group-alias`, + method: 'post', + headers: { 'X-Vault-Token': await this.getToken() }, + data: { + name: groupAliasName, + mount_accessor: methods['oidc/'].accessor, + canonical_id: group.data.id, + }, + }) + }, + delete: async () => { + await this.axios({ + method: 'delete', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + } +} diff --git a/.jjconflict-base-0/plugins/vault/src/vault-zone-api.ts b/.jjconflict-base-0/plugins/vault/src/vault-zone-api.ts new file mode 100644 index 0000000000..378d393083 --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/src/vault-zone-api.ts @@ -0,0 +1,63 @@ +import getConfig from './config.js' +import { VaultApi } from './vault-api.js' + +interface VaultCredentials { + url: string + kvName: string + roleId: string + secretId: string +} +type VaultValues = VaultCredentials + +export class VaultZoneApi extends VaultApi { + private readonly kvName: string + private readonly policyName: string + private readonly roleName: string + constructor(name: string) { + super() + this.kvName = `zone-${name}` + this.policyName = `tech--${this.kvName}--ro` + this.roleName = `zone-${name}` + } + + public async upsert() { + await this.Kv.upsert(this.kvName) + await this.Policy.upsert( + this.policyName, + `path "${this.kvName}/*" { capabilities = ["read"] }`, + ) + await this.Role.upsert(this.roleName, [this.policyName]) + } + + public async delete() { + await this.Kv.delete(this.kvName) + await this.Policy.delete(this.policyName) + await this.Role.delete(this.roleName) + } + + public async write(body: object, path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.post(`/v1/${this.kvName}/data/${path}`, { + headers: { 'X-Vault-Token': await this.getToken() }, + data: body, + }) + return await response.data + } + + public async destroy(path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + return super.destroy(path, this.kvName) + } + + public async getCredentials(): Promise { + return { + url: getConfig().publicUrl, + kvName: this.kvName, + ...(await this.Role.getCredentials(this.roleName)), + } as VaultCredentials + } + + public async getValues(): Promise { + return this.getCredentials() + } +} diff --git a/.jjconflict-base-0/plugins/vault/src/vso.ts b/.jjconflict-base-0/plugins/vault/src/vso.ts new file mode 100644 index 0000000000..579eb8d8ff --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/src/vso.ts @@ -0,0 +1,55 @@ +import type { AppRoleCredentials } from './vault-project-api.js' + +export function generateVsoVaultConnection(creds: AppRoleCredentials) { + return { + apiVersion: 'secrets.hashicorp.com/v1beta1', + kind: 'VaultConnection', + metadata: { + name: 'default', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + spec: { + address: creds.url, + }, + } +} + +export function generateVsoSecret(creds: AppRoleCredentials) { + return { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: 'vso-approle', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + stringData: { + id: creds.secretId, + }, + } +} +export function generateVaultAuth(creds: AppRoleCredentials, vaultConnectionRef: string | null = null) { + return { + apiVersion: 'secrets.hashicorp.com/v1beta1', + kind: 'VaultAuth', + metadata: { + name: 'vault-auth', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + spec: { + vaultConnectionRef, + method: 'appRole', + mount: 'approle', + appRole: { + roleId: creds.roleId, + secretRef: 'vso-approle', + }, + allowedNamespaces: null, + }, + } +} diff --git a/.jjconflict-base-0/plugins/vault/tsconfig.eslint.json b/.jjconflict-base-0/plugins/vault/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-0/plugins/vault/tsconfig.json b/.jjconflict-base-0/plugins/vault/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-0/plugins/vault/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-0/pnpm-lock.yaml b/.jjconflict-base-0/pnpm-lock.yaml new file mode 100644 index 0000000000..4265e63be7 --- /dev/null +++ b/.jjconflict-base-0/pnpm-lock.yaml @@ -0,0 +1,18500 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + injectWorkspacePackages: true + +catalogs: + build: + '@iconify/types': + specifier: ^2.0.0 + version: 2.0.0 + '@nestjs/cli': + specifier: ^11.0.16 + version: 11.0.16 + '@nestjs/schematics': + specifier: ^11.0.9 + version: 11.0.9 + '@nestjs/testing': + specifier: ^11.1.16 + version: 11.1.16 + '@unocss/transformer-directives': + specifier: ^0.65.4 + version: 0.65.4 + '@vitejs/plugin-vue': + specifier: ^6.0.4 + version: 6.0.4 + '@vue/tsconfig': + specifier: ^0.7.0 + version: 0.7.0 + fastify-plugin: + specifier: ^5.1.0 + version: 5.1.0 + swagger-typescript-api: + specifier: ^13.3.1 + version: 13.3.1 + ts-patch: + specifier: ^3.3.0 + version: 3.3.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-transform-paths: + specifier: ^3.5.6 + version: 3.5.6 + unocss: + specifier: ^66.6.6 + version: 66.6.6 + unplugin-auto-import: + specifier: ^0.18.6 + version: 0.18.6 + unplugin-vue-components: + specifier: ^0.27.5 + version: 0.27.5 + vite: + specifier: ^7.3.2 + version: 7.3.3 + vite-node: + specifier: ^6.0.0 + version: 6.0.0 + vite-plugin-pwa: + specifier: ^1.2.0 + version: 1.2.0 + vue-tsc: + specifier: ^2.2.12 + version: 2.2.12 + workbox-window: + specifier: ^7.4.0 + version: 7.4.0 + otel: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@opentelemetry/auto-instrumentations-node': + specifier: ^0.70.1 + version: 0.70.1 + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0 + '@opentelemetry/exporter-trace-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0 + '@opentelemetry/instrumentation-nestjs-core': + specifier: ^0.58.0 + version: 0.58.0 + '@opentelemetry/instrumentation-pino': + specifier: ^0.59.0 + version: 0.59.0 + '@opentelemetry/sdk-metrics': + specifier: ^2.5.1 + version: 2.6.0 + '@opentelemetry/sdk-node': + specifier: ^0.212.0 + version: 0.212.0 + runtime: + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 + '@fastify/helmet': + specifier: ^13.0.2 + version: 13.0.2 + '@fastify/otel': + specifier: ^0.18.1 + version: 0.18.1 + '@fastify/session': + specifier: ^11.1.1 + version: 11.1.1 + '@fastify/swagger': + specifier: ^9.7.0 + version: 9.7.0 + '@fastify/swagger-ui': + specifier: ^5.2.6 + version: 5.2.6 + '@gitbeaker/core': + specifier: ^40.6.0 + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: ^40.6.0 + version: 40.6.0 + '@gitbeaker/rest': + specifier: ^40.6.0 + version: 40.6.0 + '@gouvfr/dsfr': + specifier: ^1.14.4 + version: 1.14.4 + '@gouvminint/vue-dsfr': + specifier: ^8.15.0 + version: 8.15.0 + '@himenon/argocd-typescript-openapi': + specifier: ^1.2.2 + version: 1.2.2 + '@iconify-json/ri': + specifier: ^1.2.10 + version: 1.2.10 + '@iconify/vue': + specifier: ^4.3.0 + version: 4.3.0 + '@keycloak/keycloak-admin-client': + specifier: ^26.5.5 + version: 26.7.0 + '@kubernetes-models/argo-cd': + specifier: ^2.7.2 + version: 2.7.2 + '@nestjs/cache-manager': + specifier: ^3.1.2 + version: 3.1.3 + '@nestjs/common': + specifier: ^11.1.16 + version: 11.1.16 + '@nestjs/config': + specifier: ^4.0.3 + version: 4.0.3 + '@nestjs/core': + specifier: ^11.1.16 + version: 11.1.16 + '@nestjs/event-emitter': + specifier: ^3.0.1 + version: 3.0.1 + '@nestjs/jwt': + specifier: ^11.0.2 + version: 11.0.2 + '@nestjs/platform-fastify': + specifier: ^11.1.16 + version: 11.1.27 + '@nestjs/schedule': + specifier: ^6.1.1 + version: 6.1.1 + '@nestjs/swagger': + specifier: ^11.4.1 + version: 11.4.1 + '@nestjs/terminus': + specifier: ^11.1.1 + version: 11.1.1 + '@prisma/client': + specifier: ^6.19.2 + version: 6.19.2 + '@ts-rest/core': + specifier: ^3.52.1 + version: 3.52.1 + '@ts-rest/fastify': + specifier: ^3.52.1 + version: 3.52.1 + '@ts-rest/open-api': + specifier: ^3.52.1 + version: 3.52.1 + axios: + specifier: ^1.15.1 + version: 1.16.0 + bytes: + specifier: ^3.1.2 + version: 3.1.2 + cache-manager: + specifier: ^7.2.8 + version: 7.2.8 + cron-validator: + specifier: ^1.4.0 + version: 1.4.0 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + fastify: + specifier: ^5.8.5 + version: 5.8.5 + fastify-keycloak-adapter: + specifier: ^3.0.0 + version: 3.0.2 + javascript-time-ago: + specifier: ^2.6.4 + version: 2.6.4 + json-2-csv: + specifier: ^5.5.10 + version: 5.5.10 + json-schema: + specifier: ^0.4.0 + version: 0.4.0 + jszip: + specifier: ^3.10.1 + version: 3.10.1 + keycloak-js: + specifier: ^26.2.3 + version: 26.2.3 + mustache: + specifier: ^4.2.0 + version: 4.2.0 + nanoid: + specifier: 5.0.9 + version: 5.0.9 + nestjs-pino: + specifier: ^4.6.0 + version: 4.6.0 + p-debounce: + specifier: ^4.0.0 + version: 4.0.0 + pinia: + specifier: ^2.3.1 + version: 2.3.1 + prisma: + specifier: ^6.19.2 + version: 6.19.2 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + short-uuid: + specifier: ^5.2.0 + version: 5.2.0 + undici: + specifier: ^7.24.0 + version: 7.24.5 + vitest-mock-extended: + specifier: ^2.0.2 + version: 2.0.2 + vue: + specifier: ^3.5.30 + version: 3.5.30 + vue-router: + specifier: ^4.6.4 + version: 4.6.4 + vue3-json-viewer: + specifier: ^2.4.1 + version: 2.4.1 + xbytes: + specifier: ^1.9.1 + version: 1.9.1 + yaml: + specifier: ^2.7.1 + version: 2.9.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 + zod-validation-error: + specifier: ^3.5.4 + version: 3.5.4 + test: + '@faker-js/faker': + specifier: ^9.9.0 + version: 9.9.0 + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 + '@vitest/coverage-v8': + specifier: ^4.1.5 + version: 4.1.5 + globals: + specifier: ^16.5.0 + version: 16.5.0 + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + msw: + specifier: ^2.12.10 + version: 2.12.10 + vitest: + specifier: ^4.1.5 + version: 4.1.5 + tools: + '@antfu/eslint-config': + specifier: ^8.2.0 + version: 8.2.0 + '@commitlint/cli': + specifier: ^20.5.3 + version: 20.5.3 + '@commitlint/config-conventional': + specifier: ^20.5.3 + version: 20.5.3 + '@eslint/eslintrc': + specifier: ^3.3.5 + version: 3.3.5 + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 + '@vue/eslint-config-typescript': + specifier: ^14.7.0 + version: 14.7.0 + chalk: + specifier: ^5.6.2 + version: 5.6.2 + eslint: + specifier: ^10.5.0 + version: 10.5.0 + eslint-plugin-vue: + specifier: ^10.8.0 + version: 10.8.0 + husky: + specifier: ^9.1.7 + version: 9.1.7 + js-yaml: + specifier: ^4.1.0 + version: 4.2.0 + lint-staged: + specifier: ^16.4.0 + version: 16.4.0 + nodemon: + specifier: ^3.1.14 + version: 3.1.14 + pino: + specifier: ^9.14.0 + version: 9.14.0 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + rimraf: + specifier: ^6.1.3 + version: 6.1.3 + stylelint: + specifier: ^17.11.0 + version: 17.11.0 + stylelint-config-html: + specifier: ^1.1.0 + version: 1.1.0 + stylelint-config-recommended-vue: + specifier: ^1.6.1 + version: 1.6.1 + stylelint-config-standard: + specifier: ^40.0.0 + version: 40.0.0 + typescript-eslint: + specifier: ^8.57.0 + version: 8.57.0 + vue-eslint-parser: + specifier: ^10.4.0 + version: 10.4.0 + types: + '@types/jsdom': + specifier: ^21.1.7 + version: 21.1.7 + '@types/json-schema': + specifier: ^7.0.15 + version: 7.0.15 + '@types/node': + specifier: ^24.12.0 + version: 24.12.0 + undici-types: + specifier: ^7.16.0 + version: 7.16.0 + +importers: + + .: + devDependencies: + '@commitlint/cli': + specifier: catalog:tools + version: 20.5.3(@types/node@24.12.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3) + '@commitlint/config-conventional': + specifier: catalog:tools + version: 20.5.3 + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:packages/eslintconfig + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + husky: + specifier: catalog:tools + version: 9.1.7 + lint-staged: + specifier: catalog:tools + version: 16.4.0 + + apps/client: + dependencies: + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@gouvfr/dsfr': + specifier: catalog:runtime + version: 1.14.4 + '@gouvminint/vue-dsfr': + specifier: catalog:runtime + version: 8.15.0(@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3)))(vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)) + '@iconify-json/ri': + specifier: catalog:runtime + version: 1.2.10 + '@iconify/vue': + specifier: catalog:runtime + version: 4.3.0(vue@3.5.30(typescript@5.9.3)) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@vue/tsconfig': + specifier: catalog:build + version: 0.7.0(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + axios: + specifier: catalog:runtime + version: 1.16.0 + javascript-time-ago: + specifier: catalog:runtime + version: 2.6.4 + jszip: + specifier: catalog:runtime + version: 3.10.1 + keycloak-js: + specifier: catalog:runtime + version: 26.2.3 + nanoid: + specifier: catalog:runtime + version: 5.0.9 + p-debounce: + specifier: catalog:runtime + version: 4.0.0 + pinia: + specifier: catalog:runtime + version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + vue: + specifier: catalog:runtime + version: 3.5.30(typescript@5.9.3) + vue-router: + specifier: catalog:runtime + version: 4.6.4(vue@3.5.30(typescript@5.9.3)) + vue3-json-viewer: + specifier: catalog:runtime + version: 2.4.1(vue@3.5.30(typescript@5.9.3)) + xbytes: + specifier: catalog:runtime + version: 1.9.1 + yaml: + specifier: catalog:runtime + version: 2.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@iconify/types': + specifier: catalog:build + version: 2.0.0 + '@types/jsdom': + specifier: catalog:types + version: 21.1.7 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@unocss/transformer-directives': + specifier: catalog:build + version: 0.65.4 + '@vitejs/plugin-vue': + specifier: catalog:build + version: 6.0.4(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3)) + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + '@vue/eslint-config-typescript': + specifier: catalog:tools + version: 14.7.0(eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + chalk: + specifier: catalog:tools + version: 5.6.2 + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + eslint-plugin-vue: + specifier: catalog:tools + version: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + jsdom: + specifier: catalog:test + version: 25.0.1 + rimraf: + specifier: catalog:tools + version: 6.1.3 + stylelint: + specifier: catalog:tools + version: 17.11.0(typescript@5.9.3) + stylelint-config-html: + specifier: catalog:tools + version: 1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-recommended-vue: + specifier: catalog:tools + version: 1.6.1(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-standard: + specifier: catalog:tools + version: 40.0.0(stylelint@17.11.0(typescript@5.9.3)) + typescript: + specifier: catalog:build + version: 5.9.3 + unocss: + specifier: catalog:build + version: 66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + unplugin-auto-import: + specifier: catalog:build + version: 0.18.6(rollup@2.80.0) + unplugin-vue-components: + specifier: catalog:build + version: 0.27.5(@babel/parser@7.29.7)(rollup@2.80.0)(vue@3.5.30(typescript@5.9.3)) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vite-plugin-pwa: + specifier: catalog:build + version: 1.2.0(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + vue-eslint-parser: + specifier: catalog:tools + version: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + vue-tsc: + specifier: catalog:build + version: 2.2.12(typescript@5.9.3) + workbox-window: + specifier: catalog:build + version: 7.4.0 + + apps/server: + dependencies: + '@cpn-console/argocd-plugin': + specifier: workspace:^ + version: link:../../plugins/argocd + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../../plugins/gitlab + '@cpn-console/harbor-plugin': + specifier: workspace:^ + version: link:../../plugins/harbor + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../../plugins/keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/nexus-plugin': + specifier: workspace:^ + version: link:../../plugins/nexus + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/sonarqube-plugin': + specifier: workspace:^ + version: link:../../plugins/sonarqube + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../../plugins/vault + '@fastify/cookie': + specifier: catalog:runtime + version: 11.0.2 + '@fastify/helmet': + specifier: catalog:runtime + version: 13.0.2 + '@fastify/otel': + specifier: catalog:runtime + version: 0.18.1(@opentelemetry/api@1.9.1) + '@fastify/session': + specifier: catalog:runtime + version: 11.1.1 + '@fastify/swagger': + specifier: catalog:runtime + version: 9.7.0 + '@fastify/swagger-ui': + specifier: catalog:runtime + version: 5.2.6 + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + '@kubernetes-models/argo-cd': + specifier: catalog:runtime + version: 2.7.2 + '@opentelemetry/auto-instrumentations-node': + specifier: catalog:otel + version: 0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': + specifier: catalog:otel + version: 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': + specifier: catalog:otel + version: 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: catalog:otel + version: 0.212.0(@opentelemetry/api@1.9.1) + '@prisma/client': + specifier: catalog:runtime + version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@ts-rest/fastify': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76) + '@ts-rest/open-api': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76) + axios: + specifier: catalog:runtime + version: 1.16.0 + date-fns: + specifier: catalog:runtime + version: 4.1.0 + fastify: + specifier: catalog:runtime + version: 5.8.5 + fastify-keycloak-adapter: + specifier: catalog:runtime + version: 3.0.2 + json-2-csv: + specifier: catalog:runtime + version: 5.5.10 + mustache: + specifier: catalog:runtime + version: 4.2.0 + prisma: + specifier: catalog:runtime + version: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + undici: + specifier: catalog:runtime + version: 7.24.5 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + fastify-plugin: + specifier: catalog:build + version: 5.1.0 + nodemon: + specifier: catalog:tools + version: 3.1.14 + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + rimraf: + specifier: catalog:tools + version: 6.1.3 + ts-patch: + specifier: catalog:build + version: 3.3.0 + typescript: + specifier: catalog:build + version: 5.9.3 + typescript-transform-paths: + specifier: catalog:build + version: 3.5.6(typescript@5.9.3) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + apps/server-nestjs: + dependencies: + '@cpn-console/argocd-plugin': + specifier: workspace:^ + version: link:../../plugins/argocd + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../../plugins/gitlab + '@cpn-console/harbor-plugin': + specifier: workspace:^ + version: link:../../plugins/harbor + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../../plugins/keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/nexus-plugin': + specifier: workspace:^ + version: link:../../plugins/nexus + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/sonarqube-plugin': + specifier: workspace:^ + version: link:../../plugins/sonarqube + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../../plugins/vault + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + '@keycloak/keycloak-admin-client': + specifier: catalog:runtime + version: 26.7.0 + '@kubernetes-models/argo-cd': + specifier: catalog:runtime + version: 2.7.2 + '@nestjs/cache-manager': + specifier: catalog:runtime + version: 3.1.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2) + '@nestjs/common': + specifier: catalog:runtime + version: 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: catalog:runtime + version: 4.0.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: catalog:runtime + version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter': + specifier: catalog:runtime + version: 3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/jwt': + specifier: catalog:runtime + version: 11.0.2(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/platform-fastify': + specifier: catalog:runtime + version: 11.1.27(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/schedule': + specifier: catalog:runtime + version: 6.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/swagger': + specifier: catalog:runtime + version: 11.4.1(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2) + '@nestjs/terminus': + specifier: catalog:runtime + version: 11.1.1(@grpc/grpc-js@1.14.3)(@grpc/proto-loader@0.8.0)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@opentelemetry/api': + specifier: catalog:otel + version: 1.9.1 + '@opentelemetry/auto-instrumentations-node': + specifier: catalog:otel + version: 0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': + specifier: catalog:otel + version: 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': + specifier: catalog:otel + version: 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: catalog:otel + version: 0.212.0(@opentelemetry/api@1.9.1) + '@prisma/client': + specifier: catalog:runtime + version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@ts-rest/fastify': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76) + '@ts-rest/open-api': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76) + cache-manager: + specifier: catalog:runtime + version: 7.2.8 + date-fns: + specifier: catalog:runtime + version: 4.1.0 + fastify: + specifier: catalog:runtime + version: 5.8.5 + fastify-keycloak-adapter: + specifier: catalog:runtime + version: 3.0.2 + json-2-csv: + specifier: catalog:runtime + version: 5.5.10 + mustache: + specifier: catalog:runtime + version: 4.2.0 + nestjs-pino: + specifier: catalog:runtime + version: 4.6.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) + prisma: + specifier: catalog:runtime + version: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + reflect-metadata: + specifier: catalog:runtime + version: 0.2.2 + rxjs: + specifier: catalog:runtime + version: 7.8.2 + undici: + specifier: catalog:runtime + version: 7.24.5 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + yaml: + specifier: catalog:runtime + version: 2.9.0 + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@eslint/eslintrc': + specifier: catalog:tools + version: 3.3.5 + '@eslint/js': + specifier: catalog:tools + version: 9.39.4 + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@nestjs/cli': + specifier: catalog:build + version: 11.0.16(@types/node@24.12.0)(esbuild@0.27.3)(lightningcss@1.32.0) + '@nestjs/schematics': + specifier: catalog:build + version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) + '@nestjs/testing': + specifier: catalog:build + version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16) + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + globals: + specifier: catalog:test + version: 16.5.0 + msw: + specifier: catalog:test + version: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + rimraf: + specifier: catalog:tools + version: 6.1.3 + ts-patch: + specifier: catalog:build + version: 3.3.0 + typescript: + specifier: catalog:build + version: 5.9.3 + typescript-eslint: + specifier: catalog:tools + version: 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + packages/eslintconfig: + devDependencies: + '@antfu/eslint-config': + specifier: catalog:tools + version: 8.2.0(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5) + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + + packages/hooks: + dependencies: + '@cpn-console/logger': + specifier: workspace:^ + version: link:../logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../shared + json-schema: + specifier: catalog:runtime + version: 0.4.0 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/json-schema': + specifier: catalog:types + version: 7.0.15 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + undici-types: + specifier: catalog:types + version: 7.16.0 + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + + packages/logger: + dependencies: + pino: + specifier: catalog:tools + version: 9.14.0 + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + + packages/shared: + dependencies: + '@cpn-console/logger': + specifier: workspace:* + version: link:../logger + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + short-uuid: + specifier: catalog:runtime + version: 5.2.0 + zod: + specifier: catalog:runtime + version: 3.25.76 + zod-validation-error: + specifier: catalog:runtime + version: 3.5.4(zod@3.25.76) + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + packages/test-utils: + dependencies: + '@cpn-console/shared': + specifier: workspace:^ + version: link:../shared + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + + packages/tsconfig: {} + + playwright: + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@playwright/test': + specifier: catalog:test + version: 1.59.1 + '@types/node': + specifier: catalog:types + version: 24.12.0 + + plugins/argocd: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@himenon/argocd-typescript-openapi': + specifier: catalog:runtime + version: 1.2.2 + axios: + specifier: catalog:runtime + version: 1.16.0 + yaml: + specifier: catalog:runtime + version: 2.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/gitlab: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/harbor: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + bytes: + specifier: catalog:runtime + version: 3.1.2 + cron-validator: + specifier: catalog:runtime + version: 1.4.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + js-yaml: + specifier: catalog:tools + version: 4.2.0 + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + swagger-typescript-api: + specifier: catalog:build + version: 13.3.1(magicast@0.5.3) + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/keycloak: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@keycloak/keycloak-admin-client': + specifier: catalog:runtime + version: 26.7.0 + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/nexus: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/sonarqube: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/vault: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + +packages: + + '@anatine/zod-openapi@1.14.2': + resolution: {integrity: sha512-q0qHfnuNYVKu0Swrnnvfj9971AEyW7c8v9jCOZGCl5ZbyGMNG4RPyJkRcMi/JC8CRfdOe0IDfNm1nNsi2avprg==} + peerDependencies: + openapi3-ts: ^2.0.0 || ^3.0.0 + zod: ^3.20.0 + + '@angular-devkit/core@19.2.17': + resolution: {integrity: sha512-Ah008x2RJkd0F+NLKqIpA34/vUGwjlprRCkvddjDopAWRzYn6xCkz1Tqwuhn0nR1Dy47wTLKYD999TYl5ONOAQ==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/core@19.2.19': + resolution: {integrity: sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/schematics-cli@19.2.19': + resolution: {integrity: sha512-7q9UY6HK6sccL9F3cqGRUwKhM7b/XfD2YcVaZ2WD7VMaRlRm85v6mRjSrfKIAwxcQU0UK27kMc79NIIqaHjzxA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + hasBin: true + + '@angular-devkit/schematics@19.2.17': + resolution: {integrity: sha512-ADfbaBsrG8mBF6Mfs+crKA/2ykB8AJI50Cv9tKmZfwcUcyAdmTr+vVvhsBCfvUAEokigSsgqgpYxfkJVxhJYeg==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@angular-devkit/schematics@19.2.19': + resolution: {integrity: sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@antfu/eslint-config@8.2.0': + resolution: {integrity: sha512-spfwYXMNrlkl69riTSBnbC0C2K8EVfVMOK3ceP2EpAAioyfprIW1gTwyLRtd9jZSFeNdX4mFNAIG+o0sOneOfA==} + hasBin: true + peerDependencies: + '@angular-eslint/eslint-plugin': ^21.1.0 + '@angular-eslint/eslint-plugin-template': ^21.1.0 + '@angular-eslint/template-parser': ^21.1.0 + '@eslint-react/eslint-plugin': ^3.0.0 + '@next/eslint-plugin-next': '>=15.0.0' + '@prettier/plugin-xml': ^3.4.1 + '@unocss/eslint-plugin': '>=0.50.0' + astro-eslint-parser: ^1.0.2 + eslint: ^9.10.0 || ^10.0.0 + eslint-plugin-astro: ^1.2.0 + eslint-plugin-format: '>=0.1.0' + eslint-plugin-jsx-a11y: '>=6.10.2' + eslint-plugin-react-refresh: ^0.5.0 + eslint-plugin-solid: ^0.14.3 + eslint-plugin-svelte: '>=2.35.1' + eslint-plugin-vuejs-accessibility: ^2.4.1 + prettier-plugin-astro: ^0.14.0 + prettier-plugin-slidev: ^1.0.5 + svelte-eslint-parser: '>=0.37.0' + peerDependenciesMeta: + '@angular-eslint/eslint-plugin': + optional: true + '@angular-eslint/eslint-plugin-template': + optional: true + '@angular-eslint/template-parser': + optional: true + '@eslint-react/eslint-plugin': + optional: true + '@next/eslint-plugin-next': + optional: true + '@prettier/plugin-xml': + optional: true + '@unocss/eslint-plugin': + optional: true + astro-eslint-parser: + optional: true + eslint-plugin-astro: + optional: true + eslint-plugin-format: + optional: true + eslint-plugin-jsx-a11y: + optional: true + eslint-plugin-react-refresh: + optional: true + eslint-plugin-solid: + optional: true + eslint-plugin-svelte: + optional: true + eslint-plugin-vuejs-accessibility: + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-slidev: + optional: true + svelte-eslint-parser: + optional: true + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@antfu/utils@0.7.10': + resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + + '@apideck/better-ajv-errors@0.3.7': + resolution: {integrity: sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==} + engines: {node: '>=10'} + peerDependencies: + ajv: '>=8' + + '@apidevtools/json-schema-ref-parser@14.0.1': + resolution: {integrity: sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==} + engines: {node: '>= 16'} + + '@apidevtools/openapi-schemas@2.1.0': + resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} + engines: {node: '>=10'} + + '@apidevtools/swagger-methods@3.0.2': + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + + '@apidevtools/swagger-parser@12.1.0': + resolution: {integrity: sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==} + peerDependencies: + openapi-types: '>=7' + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.29.7': + resolution: {integrity: sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.29.7': + resolution: {integrity: sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.29.7': + resolution: {integrity: sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7': + resolution: {integrity: sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7': + resolution: {integrity: sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7': + resolution: {integrity: sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7': + resolution: {integrity: sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7': + resolution: {integrity: sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7': + resolution: {integrity: sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.29.7': + resolution: {integrity: sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-arrow-functions@7.29.7': + resolution: {integrity: sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.7': + resolution: {integrity: sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.29.7': + resolution: {integrity: sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.29.7': + resolution: {integrity: sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.29.7': + resolution: {integrity: sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.29.7': + resolution: {integrity: sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.29.7': + resolution: {integrity: sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.29.7': + resolution: {integrity: sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.29.7': + resolution: {integrity: sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.29.7': + resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.29.7': + resolution: {integrity: sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.29.7': + resolution: {integrity: sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-dynamic-import@7.29.7': + resolution: {integrity: sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.29.7': + resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.29.7': + resolution: {integrity: sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.29.7': + resolution: {integrity: sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.29.7': + resolution: {integrity: sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.29.7': + resolution: {integrity: sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.29.7': + resolution: {integrity: sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.29.7': + resolution: {integrity: sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7': + resolution: {integrity: sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.29.7': + resolution: {integrity: sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.29.7': + resolution: {integrity: sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@7.29.7': + resolution: {integrity: sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-umd@7.29.7': + resolution: {integrity: sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.29.7': + resolution: {integrity: sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7': + resolution: {integrity: sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.29.7': + resolution: {integrity: sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.29.7': + resolution: {integrity: sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.29.7': + resolution: {integrity: sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.29.7': + resolution: {integrity: sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.29.7': + resolution: {integrity: sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.29.7': + resolution: {integrity: sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.29.7': + resolution: {integrity: sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.29.7': + resolution: {integrity: sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.29.7': + resolution: {integrity: sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.7': + resolution: {integrity: sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regexp-modifiers@7.29.7': + resolution: {integrity: sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-reserved-words@7.29.7': + resolution: {integrity: sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.29.7': + resolution: {integrity: sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.29.7': + resolution: {integrity: sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.29.7': + resolution: {integrity: sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.29.7': + resolution: {integrity: sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.29.7': + resolution: {integrity: sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.29.7': + resolution: {integrity: sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.29.7': + resolution: {integrity: sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.29.7': + resolution: {integrity: sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7': + resolution: {integrity: sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.29.7': + resolution: {integrity: sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@biomejs/js-api@4.0.0': + resolution: {integrity: sha512-EOArR/6drRzM1/hwOIz1pZw90FL31Ud4Y7hEHGWVtMNmAwS9SrwZ8hMENGlLVXCeGW/kL46p8kX7eO6x9Nmezg==} + peerDependencies: + '@biomejs/wasm-bundler': ^2.3.0 + '@biomejs/wasm-nodejs': ^2.3.0 + '@biomejs/wasm-web': ^2.3.0 + peerDependenciesMeta: + '@biomejs/wasm-bundler': + optional: true + '@biomejs/wasm-nodejs': + optional: true + '@biomejs/wasm-web': + optional: true + + '@biomejs/wasm-nodejs@2.4.6': + resolution: {integrity: sha512-YRhedzOovXDMs+ZKzi/ZcDTefZIMcrp3z0Ruq+abrNb622aYUh/2m1Ooj8BcG4anGUGR6o+dTCbvAEx9vc74hw==} + + '@borewit/text-codec@0.2.1': + resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + + '@cacheable/memory@2.0.8': + resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} + + '@cacheable/utils@2.4.0': + resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==} + + '@clack/core@1.3.0': + resolution: {integrity: sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.3.0': + resolution: {integrity: sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw==} + engines: {node: '>= 20.12.0'} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@commitlint/cli@20.5.3': + resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@20.5.3': + resolution: {integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@20.5.0': + resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} + engines: {node: '>=v18'} + + '@commitlint/ensure@20.5.3': + resolution: {integrity: sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@20.0.0': + resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} + engines: {node: '>=v18'} + + '@commitlint/format@20.5.0': + resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@20.5.0': + resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} + engines: {node: '>=v18'} + + '@commitlint/lint@20.5.3': + resolution: {integrity: sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==} + engines: {node: '>=v18'} + + '@commitlint/load@20.5.3': + resolution: {integrity: sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==} + engines: {node: '>=v18'} + + '@commitlint/message@20.4.3': + resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} + engines: {node: '>=v18'} + + '@commitlint/parse@20.5.0': + resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} + engines: {node: '>=v18'} + + '@commitlint/read@20.5.0': + resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@20.5.3': + resolution: {integrity: sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==} + engines: {node: '>=v18'} + + '@commitlint/rules@20.5.3': + resolution: {integrity: sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@20.0.0': + resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} + engines: {node: '>=v18'} + + '@commitlint/top-level@20.4.3': + resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} + engines: {node: '>=v18'} + + '@commitlint/types@20.5.0': + resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} + engines: {node: '>=v18'} + + '@conventional-changelog/git-client@2.7.0': + resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} + engines: {node: '>=18'} + peerDependencies: + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.4.0 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@csstools/media-query-list-parser@5.0.0': + resolution: {integrity: sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/selector-resolve-nested@4.0.0': + resolution: {integrity: sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@csstools/selector-specificity@6.0.0': + resolution: {integrity: sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@e18e/eslint-plugin@0.3.0': + resolution: {integrity: sha512-hHgfpxsrZ2UYHcicA+tGZnmk19uJTaye9VH79O+XS8R4ona2Hx3xjhXghclNW58uXMk3xXlbYEOMr8thsoBmWg==} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + oxlint: ^1.55.0 + peerDependenciesMeta: + eslint: + optional: true + oxlint: + optional: true + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@es-joy/jsdoccomment@0.84.0': + resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@es-joy/jsdoccomment@0.86.0': + resolution: {integrity: sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-plugin-eslint-comments@4.7.1': + resolution: {integrity: sha512-Ql2nJFwA8wUGpILYGOQaT1glPsmvEwE0d+a+l7AALLzQvInqdbXJdx7aSu0DpUX9dB1wMVBMhm99/++S3MdEtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/compat@2.0.3': + resolution: {integrity: sha512-SjIJhGigp8hmd1YGIBwh7Ovri7Kisl42GYFjrOyHhtfYGGoLW6teYi/5p8W50KSsawUPpuLOSmsq1bD0NGQLBw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^8.40 || 9 || 10 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/markdown@8.0.1': + resolution: {integrity: sha512-WWKmld/EyNdEB8GMq7JMPX1SDWgyJAM1uhtCi5ySrqYQM4HQjmg11EX/q3ZpnpRXHfdccFtli3NBvvGaYjWyQw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exodus/schemasafe@1.3.0': + resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} + + '@faker-js/faker@9.9.0': + resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/formbody@8.0.2': + resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/helmet@13.0.2': + resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==} + + '@fastify/jwt@9.1.0': + resolution: {integrity: sha512-CiGHCnS5cPMdb004c70sUWhQTfzrJHAeTywt7nVw6dAiI0z1o4WRvU94xfijhkaId4bIxTCOjFgn4sU+Gvk43w==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/otel@0.18.1': + resolution: {integrity: sha512-7TYQXrOBRwCuTiwQm/2qCPO37af011934clxBj6F7KyF9a2a9MB+aSrWv8vMVp5N8rZC1AQ4pUp8uDs5Z40UPQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/session@11.1.1': + resolution: {integrity: sha512-nuKwTHxh3eJsI4NJeXoYVGzXUsg+kH1WfHgS7IofVyVhmjc+A6qGr+29WQy8hYZiNtmCjfG415COpf5xTBkW4Q==} + + '@fastify/static@9.1.3': + resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} + + '@fastify/swagger-ui@5.2.6': + resolution: {integrity: sha512-OMnms0O5s9wb6wis/K5nlrAMLsgUbr1GA8uphM41IasWe3AFdgxz6r/3bA9HTxlDNUYc2FGGKeqMp3ntxmSiNA==} + + '@fastify/swagger@9.7.0': + resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} + + '@gitbeaker/core@40.6.0': + resolution: {integrity: sha512-tVVm8ZPrS9YCHEcuPV8vD1IcEf9POpdygWo+kPvkK7LcC36EERVcXagb8snEaGgGLfUaVQh8qP4iDZgPnP3YBQ==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/requester-utils@40.6.0': + resolution: {integrity: sha512-DQu2l3iXtB+8e1Ye2ekeUHABt4mGMRTLtuVWtFqf74sqJnerHNOxVOjPn19qu/nKdvKR3ZLwSRTtPzEsxgcShg==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/rest@40.6.0': + resolution: {integrity: sha512-sAwYJclU3NlB/gdxqhH6Hnmy5LWzvW7D3W33eShQEnxMhM0VjnFHPHcgJLQCIux3hMiub1uGtTw1hBJTxDc2mQ==} + engines: {node: '>=18.20.0'} + + '@gouvfr/dsfr@1.14.4': + resolution: {integrity: sha512-Ca17apmTyAF2tMFwMcLwQN7fTlbfMFeyYSLUIUmjoSg1XVF4vDDkbvvKBpWJWaoTLDSFZBbvvHzcDyxw9wSkLA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@gouvminint/vue-dsfr@8.15.0': + resolution: {integrity: sha512-e9q7xJeWpGLLFmbe8YCeZ3bgsBM4LWMMiRVmde5DeAfILXewPh3uFKGMphWVKVKnz3tnxrgYM0GoGOn3I9FoYQ==} + engines: {node: '>=22.0.0', pnpm: '>=10.0.0'} + hasBin: true + peerDependencies: + '@iconify/vue': ^4.2.0 + vue: ^3.5.27 + vue-router: ^4.6.4 + + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + + '@himenon/argocd-typescript-openapi@1.2.2': + resolution: {integrity: sha512-xS8HAzhRSQXqMfD779EgbZmPcgkaNW8Hf2anrwvvPYb9Tt0vP9CtzOqj+8GYSOUQqgcRFejmCR9WoAgZJamB1g==} + engines: {pnpm: '>=8'} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@iconify-json/ri@1.2.10': + resolution: {integrity: sha512-WWMhoncVVM+Xmu9T5fgu2lhYRrKTEWhKk3Com0KiM111EeEsRLiASjpsFKnC/SrB6covhUp95r2mH8tGxhgd5Q==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + + '@iconify/vue@4.3.0': + resolution: {integrity: sha512-Xq0h6zMrHBbrW8jXJ9fISi+x8oDQllg5hTDkDuxnWiskJ63rpJu9CvJshj8VniHVTbsxCg9fVoPAaNp3RQI5OQ==} + peerDependencies: + vue: '>=3' + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.3.2': + resolution: {integrity: sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@keycloak/keycloak-admin-client@26.7.0': + resolution: {integrity: sha512-QEsX19sFCvknBzV2zelEkZ7waglCqzayY1zDtoRQinHuUiAe2f7q+xXT6R8XNukn+rShB41TOlG4QR2Bt6hxhw==} + engines: {node: '>=18'} + + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@kubernetes-models/apimachinery@2.2.0': + resolution: {integrity: sha512-7Yj3aMz/nCTdr221wHOMsw5vAMJnLqANMI3BTuCmYjbP+7/tGAaOsiFwT75F4TJH45Dk1P6wcAEqDeCD1ur82Q==} + engines: {node: '>=14'} + + '@kubernetes-models/argo-cd@2.7.2': + resolution: {integrity: sha512-Dge+7Uq54dt5c3pzusOw8i26FHWqJiyiFqHKQKHUNjOq2zb3e7LY2OYBrNP7hDSoM0Ap5K9cJTMgNzsaaKdjOQ==} + engines: {node: '>=14'} + + '@kubernetes-models/base@5.0.1': + resolution: {integrity: sha512-Yfzmh965y/hT8qs9eE+Ms9sO1iT31c/sPoJerBFRGS9KechXXfxHgeqtpOzq+eByQ1INblkcZ09rt2V0FxG0lA==} + engines: {node: '>=14'} + + '@kubernetes-models/validate@4.0.0': + resolution: {integrity: sha512-rsfF5t3sd5c+3ejUgcy8q0cVG2/BxT064L4Xz+CuKEe014u8T4MtFZiWjWZFpfMXSGKzFYEA6DJYm9CqjmIfZg==} + engines: {node: '>=14'} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@microsoft/kiota-abstractions@1.0.0-preview.103': + resolution: {integrity: sha512-om7nKH8nWyEAQ0lgbIdKdrkmznhkdDJdliCnAIo31X/hlUah1aBzvfUx+pbyp67Q040yLK+2J6P7AnvGSoN/qQ==} + + '@microsoft/kiota-http-fetchlibrary@1.0.0-preview.103': + resolution: {integrity: sha512-DbhGL+pdVjtv2AVvxB4fxvNC/GMzKbYyeQsknaXJXwqnPR/oIClWjaXlIxWL5SBK72DOmrZ18leaWMB2vfWvpw==} + + '@microsoft/kiota-serialization-form@1.0.0-preview.103': + resolution: {integrity: sha512-AD5Q59Qu61DfhaLLN7Mvgx4Zw0Zmftqx04XAbrRreC+r9hwT1BqTf0EReCraMzEoC4sdrp/8haI2Arm8dWUWuQ==} + + '@microsoft/kiota-serialization-json@1.0.0-preview.103': + resolution: {integrity: sha512-dTr8q6H42TSe+HaCPBXU4Pv6ypo7MjMGRo/r9GRbnCuy8VCWfe+Vh38rnAZJx4r8cmvOUD7Yw74IJ6q4Hjv3mg==} + + '@microsoft/kiota-serialization-multipart@1.0.0-preview.103': + resolution: {integrity: sha512-b3ePXYgFv/2TQtbzFQ8ZY+hzMtCRkz4Cx5y/Pf8vtoQu56KU0/9rYq5kA9wySDdCx/oZxfubQQRV/8QSTxmejw==} + + '@microsoft/kiota-serialization-text@1.0.0-preview.103': + resolution: {integrity: sha512-FkiboqpNtT2F40xUC9LETHK9ckHd+WuocLJE70ljI4EYvD59MQDsrTERYAwbmkfPIJ6JRE4OfLXFVh510LG1/Q==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} + engines: {node: '>=18'} + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nestjs/cache-manager@3.1.3': + resolution: {integrity: sha512-HMtiOfHz75NZX7mJn1VnZGLSachVI04TnUc5wvEogIaKwk5BDQHgtP5htxreizjv7oxKalJbuTyxtiF6bE+bgQ==} + peerDependencies: + '@nestjs/common': ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 || ^11.0.0 + cache-manager: '>=6' + keyv: '>=5' + rxjs: ^7.8.1 + + '@nestjs/cli@11.0.16': + resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==} + engines: {node: '>= 20.11'} + hasBin: true + peerDependencies: + '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 + '@swc/core': ^1.3.62 + peerDependenciesMeta: + '@swc/cli': + optional: true + '@swc/core': + optional: true + + '@nestjs/common@11.1.16': + resolution: {integrity: sha512-JSIeW+USuMJkkcNbiOdcPkVCeI3TSnXstIVEPpp3HiaKnPRuSbUUKm9TY9o/XpIcPHWUOQItAtC5BiAwFdVITQ==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/config@4.0.3': + resolution: {integrity: sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + '@nestjs/core@11.1.16': + resolution: {integrity: sha512-tXWXyCiqWthelJjrE0KLFjf0O98VEt+WPVx5CrqCf+059kIxJ8y1Vw7Cy7N4fwQafWNrmFL2AfN87DDMbVAY0w==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/event-emitter@3.0.1': + resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/jwt@11.0.2': + resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/platform-express@11.1.16': + resolution: {integrity: sha512-IOegr5+ZfUiMKgk+garsSU4MOkPRhm46e6w8Bp1GcO4vCdl9Piz6FlWAzKVfa/U3Hn/DdzSVJOW3TWcQQFdBDw==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + + '@nestjs/platform-fastify@11.1.27': + resolution: {integrity: sha512-rZMPS0RMP9P2O7Y/shAP0kKwrHElGO9cPTlgtdWpQ6JEX58uhYEJLGsU/X9a9l18DNSxxb3YjrEiVVuI2Iv6MA==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@fastify/view': ^10.0.0 || ^11.0.0 || ^12.0.0 + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + '@fastify/view': + optional: true + + '@nestjs/schedule@6.1.1': + resolution: {integrity: sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/schematics@11.0.9': + resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} + peerDependencies: + typescript: '>=4.8.2' + + '@nestjs/swagger@11.4.1': + resolution: {integrity: sha512-GuGzs8F1Cb3n+eEarmOqB4nt2ai+x4XGOYUXNYplOtDeB59DaFY5E16bsHsBWXiWgD1ywbyKQ5OVv02bQtB1Dw==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/terminus@11.1.1': + resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==} + peerDependencies: + '@grpc/grpc-js': '*' + '@grpc/proto-loader': '*' + '@mikro-orm/core': '*' + '@mikro-orm/nestjs': '*' + '@nestjs/axios': ^2.0.0 || ^3.0.0 || ^4.0.0 + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/microservices': ^10.0.0 || ^11.0.0 + '@nestjs/mongoose': ^11.0.0 + '@nestjs/sequelize': ^10.0.0 || ^11.0.0 + '@nestjs/typeorm': ^10.0.0 || ^11.0.0 + '@prisma/client': '*' + mongoose: '*' + reflect-metadata: 0.1.x || 0.2.x + rxjs: 7.x + sequelize: '*' + typeorm: '*' + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + '@grpc/proto-loader': + optional: true + '@mikro-orm/core': + optional: true + '@mikro-orm/nestjs': + optional: true + '@nestjs/axios': + optional: true + '@nestjs/microservices': + optional: true + '@nestjs/mongoose': + optional: true + '@nestjs/sequelize': + optional: true + '@nestjs/typeorm': + optional: true + '@prisma/client': + optional: true + mongoose: + optional: true + sequelize: + optional: true + typeorm: + optional: true + + '@nestjs/testing@11.1.16': + resolution: {integrity: sha512-E7/aUCxzeMSJV80L5GWGIuiMyR/1ncS7uOIetAImfbS4ATE1/h2GBafk0qpk+vjFtPIbtoh9BWDGICzUEU5jDA==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nuxt/opencollective@0.4.1': + resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} + engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} + hasBin: true + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@opentelemetry/api-logs@0.212.0': + resolution: {integrity: sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.213.0': + resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.214.0': + resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/auto-instrumentations-node@0.70.1': + resolution: {integrity: sha512-r8BKs0rHtBAzZViPIuzSD2eh65fOPau0NqVsca2sACuZ6LFGu6a+QMhqq7skXz+/OqKwFr/7/b6VsaNMS+zZpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.4.1 + '@opentelemetry/core': ^2.0.0 + + '@opentelemetry/configuration@0.212.0': + resolution: {integrity: sha512-D8sAY6RbqMa1W8lCeiaSL2eMCW2MF87QI3y+I6DQE1j+5GrDMwiKPLdzpa/2/+Zl9v1//74LmooCTCJBvWR8Iw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/context-async-hooks@2.5.1': + resolution: {integrity: sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.5.1': + resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.212.0': + resolution: {integrity: sha512-/0bk6fQG+eSFZ4L6NlckGTgUous/ib5+OVdg0x4OdwYeHzV3lTEo3it1HgnPY6UKpmX7ki+hJvxjsOql8rCeZA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.212.0': + resolution: {integrity: sha512-JidJasLwG/7M9RTxV/64xotDKmFAUSBc9SNlxI32QYuUMK5rVKhHNWMPDzC7E0pCAL3cu+FyiKvsTwLi2KqPYw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.212.0': + resolution: {integrity: sha512-RpKB5UVfxc7c6Ta1UaCrxXDTQ0OD7BCGT66a97Q5zR1x3+9fw4dSaiqMXT/6FAWj2HyFbem6Rcu1UzPZikGTWQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0': + resolution: {integrity: sha512-/6Gqf9wpBq22XsomR1i0iPGnbQtCq2Vwnrq5oiDPjYSqveBdK1jtQbhGfmpK2mLLxk4cPDtD1ZEYdIou5K8EaA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.212.0': + resolution: {integrity: sha512-8hgBw3aTTRpSTkU4b9MLf/2YVLnfWp+hfnLq/1Fa2cky+vx6HqTodo+Zv1GTIrAKMOOwgysOjufy0gTxngqeBg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0': + resolution: {integrity: sha512-yw3fTIw4KQIRXC/ZyYQq5gtA3Ogfdfz/g5HVgleobQAcjUUE8Nj3spGMx8iQPp+S+u6/js7BixufRkXhzLmpJA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.212.0': + resolution: {integrity: sha512-C7I4WN+ghn3g7SnxXm2RK3/sRD0k/BYcXaK6lGU3yPjiM7a1M25MLuM6zY3PeVPPzzTZPfuS7+wgn/tHk768Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0': + resolution: {integrity: sha512-geHF+zZaDb0/WRkJTxR8o8dG4fCWT/Wq7HBdNZCxwH5mxhwRi/5f37IDYH7nvU+dwU6IeY4Pg8TPI435JCiNkg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.212.0': + resolution: {integrity: sha512-hJFLhCJba5MW5QHexZMHZdMhBfNqNItxOsN0AZojwD1W2kU9xM+BEICowFGJFo/vNV+I2BJvTtmuKafeDSAo7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.212.0': + resolution: {integrity: sha512-9xTuYWp8ClBhljDGAoa0NSsJcsxJsC9zCFKMSZJp1Osb9pjXCMRdA6fwXtlubyqe7w8FH16EWtQNKx/FWi+Ghw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.212.0': + resolution: {integrity: sha512-v/0wMozNoiEPRolzC4YoPo4rAT0q8r7aqdnRw3Nu7IDN0CGFzNQazkfAlBJ6N5y0FYJkban7Aw5WnN73//6YlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.212.0': + resolution: {integrity: sha512-d1ivqPT0V+i0IVOOdzGaLqonjtlk5jYrW7ItutWzXL/Mk+PiYb59dymy/i2reot9dDnBFWfrsvxyqdutGF5Vig==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0': + resolution: {integrity: sha512-six3vPq3sL+ge1iZOfKEg+RHuFQhGb8ZTdlvD234w/0gi8ty/qKD46qoGpKvM3amy5yYunWBKiFBW47WaVS26w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.5.1': + resolution: {integrity: sha512-Me6JVO7WqXGXsgr4+7o+B7qwKJQbt0c8WamFnxpkR43avgG9k/niTntwCaXiXUTjonWy0+61ZuX6CGzj9nn8CQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-amqplib@0.59.0': + resolution: {integrity: sha512-xscSgOJA+GHphESDZxBHNk/zjNaEgoeufMwmiqYdL+qM27Xw3BbR9vN6Ucbq9dW6Y+oYUPgTTj17qf+Za4+uzg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-lambda@0.64.0': + resolution: {integrity: sha512-vYhM/a8fG34/Dl/Q9gfv5Ih3OFPgqeyn79S8FN+Xs/QZw6h6L8a1lDa3CyigyicOXLCmVIM7Fc9vFD4BGqgGLA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-sdk@0.67.0': + resolution: {integrity: sha512-btpwJnZ2RBXDh/pTpfVpInpBu9Pedi+lbLKbt3naB344SggbbYnIdT7u8EzmGIApWi9EV91vw7hm896I7nESQA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-bunyan@0.57.0': + resolution: {integrity: sha512-W4zLz1Y9ptCsdL+QMXR7xQaBHkJivLBmVlLCjUe23rX4V8E65fGAtlIJSKTKAfz4aEgtWgQAGMdkeqACwG0Caw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cassandra-driver@0.57.0': + resolution: {integrity: sha512-xLwrK+XnN32IB5i6t/a2j+SVdjlq/BIgjpVRkke4HAsKjoSMy1GeSI+ZOiJffRLFb4MojcvH4RG2+nEg1uC6Zg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.55.0': + resolution: {integrity: sha512-UfGw7ubKKZBoTRjxi5KlfeECEaXZinS20RdRNlZE5tVF+O17hJOnrcGwAoQAHp6eYmxI2jW9IQ4t6450gnNF9g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cucumber@0.28.0': + resolution: {integrity: sha512-kim+bRxu4LZqKEyF2SgO01tgG88W+/iYltyP1XjT31FIXzlBjzQpwtSLLM8byayO85mcZIBha54WSNFDLM/7qQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-dataloader@0.29.0': + resolution: {integrity: sha512-220WjRb1G1UiAKbVblSMxwxxFdpyB4wj1XYIO9BJs5r62Azj2dL5fyZiXK3/WO6wB3uLul9R946iKI1bpPxktQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dns@0.55.0': + resolution: {integrity: sha512-cfWLaFi22V+sQrKY7t6QroYzT3kO9m3PpkN1OXYmuCyfwxQaXOVlF8NSAHtua/RQYw0aQl+2fe6JOWyJdEZiwA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.60.0': + resolution: {integrity: sha512-KghHCDqKq0D7iuPIVCuPSXut5WVAI6uwKcPrhwTUJL5VE2LC18id2vKoiAm1V8XvVlgIGAiECtEvbrFwkTCj3g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fastify@0.56.0': + resolution: {integrity: sha512-zotOPoZsWtMF47BjottK23XaaBSmVuwG5D/R3FlGfAAwMNFoDR3IY1OGO9v9KfOU/1/xDVkxsQ22NFfu9lE8aA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.31.0': + resolution: {integrity: sha512-C7tdXGDnkMgLVlE79VSekB+Y+P345zKUigvFMs5M7U0GIYA8ERx3FS0aAcY/ICIq9YwRmH2uuMb++Br5M2vNUg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.55.0': + resolution: {integrity: sha512-7hWiyLbEX/dIS4LZy/h8VaAQPs8oBeEqsrysDWbos0b9PF414L6Rsbi2um/omtxIs+GTvsbuqDscWigeaxyWdA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.60.0': + resolution: {integrity: sha512-XPATrmxAd2tFCsYbJ3eVIXt+gyvMKjc36QQuQxjtssMnAbw006Le9b5lKs7WXik7ItOpM1exATi1aDdOcCjRRg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-grpc@0.212.0': + resolution: {integrity: sha512-r1t7LNKWVhSQMUrBdDJtooFmmLZ93kGuFixqeXPoUP8W+chJCxhey9l0c0+L3xriNdyB7TzvkKHhPXUDevgVEA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.58.0': + resolution: {integrity: sha512-reuRApR2KHm2VsfyDgsrLhNE+IOy4uIU6n3oMjUleReHacEEZmf4vXxdt4/qcmJ6GoUXnRN2AOu3s5N3pMrgYA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.212.0': + resolution: {integrity: sha512-t2nt16Uyv9irgR+tqnX96YeToOStc3X5js7Ljn3EKlI2b4Fe76VhMkTXtsTQ0aId6AsYgefrCRnXSCo/Fn/vww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.60.0': + resolution: {integrity: sha512-R+nnbPD9l2ruzu248qM3YDWzpdmWVaFFFv08lQqsc0EP4pT/B1GGUg06/tHOSo3L5njB2eejwyzpkvJkjaQEMA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.21.0': + resolution: {integrity: sha512-lkLrILnKGO7SHw1xPJnuGx2S4XwbKmQiJyzUGuEImRoU/6Gj0Nka0lkbeRd4ANN20dxr/mLdXIsUsk6DzTrX6A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.56.0': + resolution: {integrity: sha512-pKqtY5lbAQ70MC5K/BJeAA1t2gAUlRBZBAJ5ergRUNs5jw8zbdOXEZOLztiuNvQqD2z4a9N0Tkde9JMFm2pKMQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.60.0': + resolution: {integrity: sha512-UOmu2y2LHgPzKsm9xd0sCQJimr11YP4MKFc190Do1ufd8qds7Zd5BI3f6TudqYhH9dUIhojsQyUaS6K4nv5FsQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.56.0': + resolution: {integrity: sha512-vXtOValhKRgWA9tLAiTU3P37Q31OveRuM2N5iLSVHl4GzkMBQ5p50A9kSKvt5gReL6BzFDXPCM9ItJiAhSS2KQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-memcached@0.55.0': + resolution: {integrity: sha512-kdhW/j5X+vNCAvHVc50PZfvE7diUScg1ZkBaNFRygY3Z6IUjgPLR0luWQMDPSFun6AVo1HaMDPxbUqJrot6qrA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.65.0': + resolution: {integrity: sha512-hOAJRs5vrY7fZolSYUXmf29Y+HFDHWrek0DeLq82uwMPjPSda7h6oumQnqEX5olzw357q/QG39/uJdkclJ/JUg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.58.0': + resolution: {integrity: sha512-3L0Fqo1y2oreISFPWaqdt/bg3NhLgrkn5U/E/9RNG1QaM81drTMBCHseMY1q8SlejjE43ZWOy+0KbmRBlUPJ+g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.58.0': + resolution: {integrity: sha512-EubjV1XZb7XHrENqF7TW2lnah+KN0LddMneKNAB8PjGVKL5lJkVV/vhJ6EIcUNn9nCWmAwZ3GRcFVEDKCnyXfQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.58.0': + resolution: {integrity: sha512-wZDrBCL3WfJclV6KywWVV3/B2ZiUYmDQdgyu3pq4jK/5qSfoDmezHzT/Nayln5MVVWMAGXIMLrCj8BKa6jaKQQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-nestjs-core@0.58.0': + resolution: {integrity: sha512-0lE9oW8j6nmvBHJoOxIQgKzMQQYNfX1nhiWZdXD0sNAMFsWBtvECWS7NAPSroKrEP53I04TcHCyyhcK4I9voXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-net@0.56.0': + resolution: {integrity: sha512-h69x7U6f86mP3gGWWTaMkQZk0K3tBvpVMIU7E0q2kkVw6eZ5TqFm9rkaEy38moQmixiDFQ9j/2/cwxG9P7ZEeA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-openai@0.10.0': + resolution: {integrity: sha512-0lV2zxge2mMaruVCw/bmypWVu+aJ76rc0HBvAVFCPUI3zzJdgBZJZafGIHZ1IB2F6VvrDFL+JstEnle6V8brvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-oracledb@0.37.0': + resolution: {integrity: sha512-OzMghtAEAEkXlkUrZI4QcXSZq0MILeU6WC0/N5+1MSkuIkruIeaRw99/RtyS2of8vlPDa8XbbXl32Q1RM3wSyg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.64.0': + resolution: {integrity: sha512-NbfB/rlfsRI3zpTjnbvJv3qwuoGLsN8FxR/XoI+ZTn1Rs62x1IenO+TSSvk4NO+7FlXpd2MiOe8LT/oNbydHGA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.58.0': + resolution: {integrity: sha512-rgy+tA7cDjuSq6dXAO40OiYP25azIDHMBtxG3RzSmCBVEYdjggl6btyuLVasX6VkOOhP2gf6PBuLMNxVwaIqAw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.59.0': + resolution: {integrity: sha512-IgImVFtWjfMmqxc0NIe3iSjp+J3Asf9lLX8reouUFUk3Aa/qJQO5PEvOtO3sNQtJBkC9bAd1OQdFaFWSFQc03g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.60.0': + resolution: {integrity: sha512-Ea/GffmmzIVHc9geaMjT94IR7poVZzIv4Kk/Lw0tbxGD3cBYcMUsLFVajKxpZsE1NRCECFpidAWeifCIKD0inw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-restify@0.57.0': + resolution: {integrity: sha512-kO6MsZFU+RdXOKhsKw8SOSBYGYCdFSlza+mpBQRl1DQmveZcnidchv4V5JQPtNgHxCGH+1n3hDpLdxdGUbJPNA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-router@0.56.0': + resolution: {integrity: sha512-PHECDGQElLazI/QbHU16C5m9fDC7DGJk+jLIwO5ca6bcp7bXhUPPUTT78l7da2pDsrz4mhv5ytYNZmBbW/Q3rA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-runtime-node@0.25.0': + resolution: {integrity: sha512-XaCmwBSui5KeTn8M6OzaEn1rEsNWtUkjuc1ylg0tqQTLHibNQ0n7f8v4zdF6x/nBV1OnsiYlN8RLHauGemv/TA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-socket.io@0.59.0': + resolution: {integrity: sha512-71DnM/FEqH0PjvU2uZvzWJeaGyVIy3rJKk8rZrxg/aS2QT3qLGb+UPL/B+1vOw4pzDPn4papLTSMpLVF9G8uvw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.31.0': + resolution: {integrity: sha512-HoF2EtcyP3JR4R3jLPHohZ9lFcj1QLJyGmFfLKDTvUUjPiFuK4XZ6L1OV9HhaqvN0xY+tWKfNdCPS3r33rd0Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.22.0': + resolution: {integrity: sha512-yb6vEWUPOrD5i7yR1XceEEqiVHbMgr5YnUPnom5eQVCjvrTkEVswyrf9i+vvJR+28wrNqILIIphWgOOx6BjnTQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation-winston@0.56.0': + resolution: {integrity: sha512-ITIA0Qe61CQ6FQU/bN23pNBvJ+5U0ofoASMOOYrODtXyV9wI267AigNTTwDmv2Myt8dPEFvvVFJZKhiZLIpehA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.212.0': + resolution: {integrity: sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.213.0': + resolution: {integrity: sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.214.0': + resolution: {integrity: sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.212.0': + resolution: {integrity: sha512-HoMv5pQlzbuxiMS0hN7oiUtg8RsJR5T7EhZccumIWxYfNo/f4wFc7LPDfFK6oHdG2JF/+qTocfqIHoom+7kLpw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.213.0': + resolution: {integrity: sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.212.0': + resolution: {integrity: sha512-YidOSlzpsun9uw0iyIWrQp6HxpMtBlECE3tiHGAsnpEqJWbAUWcMnIffvIuvTtTQ1OyRtwwaE79dWSQ8+eiB7g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.212.0': + resolution: {integrity: sha512-bj7zYFOg6Db7NUwsRZQ/WoVXpAf41WY2gsd3kShSfdpZQDRKHWJiRZIg7A8HvWsf97wb05rMFzPbmSHyjEl9tw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.213.0': + resolution: {integrity: sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@2.5.1': + resolution: {integrity: sha512-AU6sZgunZrZv/LTeHP+9IQsSSH5p3PtOfDPe8VTdwYH69nZCfvvvXehhzu+9fMW2mgJMh5RVpiH8M9xuYOu5Dg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.5.1': + resolution: {integrity: sha512-8+SB94/aSIOVGDUPRFSBRHVUm2A8ye1vC6/qcf/D+TF4qat7PC6rbJhRxiUGDXZtMtKEPM/glgv5cBGSJQymSg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.38.2': + resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resource-detector-alibaba-cloud@0.33.3': + resolution: {integrity: sha512-Ep3LDWALU+wCgGzAa1rgFXh3TObahN7HaQZntAeVQnnNhZ3VSXnBniRGeSCTIRvRr7YdZvq+G+rstixtAN5Ugw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-aws@2.13.0': + resolution: {integrity: sha512-ZPCn7gZhGqUYUoD+RCHIlayoHBMaJaEjfqlgz2EPKoXJ4y7Ru7CUm+Tm3yJVMKF92cN9xUQR0j5KALyF0fg9aw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-azure@0.20.0': + resolution: {integrity: sha512-iRy+O2cB6DOlQ/OONaK+L8Cp8nLS89dZVRp6KgnFAfzykXuq9Ws/ygJKcU3CCmjkgY5j2Vk3uVTre/E35bWhYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-container@0.8.4': + resolution: {integrity: sha512-kIvGHkMSacp+kb7btTuXbOAIWLyOCO+P/h/8xxaeLcp5ptmHRZ67uEdLAQo61ApdayFB/uqjJ9gY4x2/i/KsoA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-gcp@0.47.0': + resolution: {integrity: sha512-57T/kRVdU0ch1P4KPEkmU2b5mWNlUs8hHgqrBYVF+fNZMc1jMdL1mANZhEzoLtWKIeoCEy+57Itt7RkXAYNJiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resources@2.5.1': + resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.212.0': + resolution: {integrity: sha512-qglb5cqTf0mOC1sDdZ7nfrPjgmAqs2OxkzOPIf2+Rqx8yKBK0pS7wRtB1xH30rqahBIut9QJDbDePyvtyqvH/Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.213.0': + resolution: {integrity: sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.5.1': + resolution: {integrity: sha512-RKMn3QKi8nE71ULUo0g/MBvq1N4icEBo7cQSKnL3URZT16/YH3nSVgWegOjwx7FRBTrjOIkMJkCUn/ZFIEfn4A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.6.0': + resolution: {integrity: sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.212.0': + resolution: {integrity: sha512-tJzVDk4Lo44MdgJLlP+gdYdMnjxSNsjC/IiTxj5CFSnsjzpHXwifgl3BpUX67Ty3KcdubNVfedeBc/TlqHXwwg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.5.1': + resolution: {integrity: sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.6.0': + resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.5.1': + resolution: {integrity: sha512-9lopQ6ZoElETOEN0csgmtEV5/9C7BMfA7VtF4Jape3i954b6sTY2k3Xw3CxUTKreDck/vpAuJM+EDo4zheUw+A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.41.2': + resolution: {integrity: sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + + '@ota-meshi/ast-token-store@0.3.0': + resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@oxc-parser/binding-android-arm-eabi@0.115.0': + resolution: {integrity: sha512-VoB2rhgoqgYf64d6Qs5emONQW8ASiTc0xp+aUE4JUhxjX+0pE3gblTYDO0upcN5vt9UlBNmUhAwfSifkfre7nw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.115.0': + resolution: {integrity: sha512-lWRX75u+gqfB4TF3pWCHuvhaeneAmRl2b2qNBcl4S6yJ0HtnT4VXOMEZrq747i4Zby1ZTxj6mtOe678Bg8gRLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.115.0': + resolution: {integrity: sha512-ii/oOZjfGY1aszXTy29Z5DRyCEnBOrAXDVCvfdfXFQsOZlbbOa7NMHD7D+06YFe5qdxfmbWAYv4yn6QJi/0d2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.115.0': + resolution: {integrity: sha512-R/sW/p8l77wglbjpMcF+h/3rWbp9zk1mRP3U14mxTYIC2k3m+aLBpXXgk2zksqf9qKk5mcc4GIYsuCn9l8TgDg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.115.0': + resolution: {integrity: sha512-CSJ5ldNm9wIGGkhaIJeGmxRMZbgxThRN+X1ufYQQUNi5jZDV/U3C2QDMywpP93fczNBj961hXtcUPO/oVGq4Pw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.115.0': + resolution: {integrity: sha512-uWFwssE5dHfQ8lH+ktrsD9JA49+Qa0gtxZHUs62z1e91NgGz6O7jefHGI6aygNyKNS45pnnBSDSP/zV977MsOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.115.0': + resolution: {integrity: sha512-fZbqt8y/sKQ+v6bBCuv/mYYFoC0+fZI3mGDDEemmDOhT78+aUs2+4ZMdbd2btlXmnLaScl37r8IRbhnok5Ka9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.115.0': + resolution: {integrity: sha512-1ej/MjuTY9tJEunU/hUPIFmgH5PqgMQoRjNOvOkibtJ3Zqlw/+Lc+HGHDNET8sjbgIkWzdhX+p4J96A5CPdbag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.115.0': + resolution: {integrity: sha512-HjsZbJPH9mMd4swJRywVMsDZsJX0hyKb1iNHo5ijRl5yhtbO3lj7ImSrrL1oZ1VEg0te4iKmDGGz/6YPLd1G8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.115.0': + resolution: {integrity: sha512-zhhePoBrd7kQx3oClX/W6NldsuCbuMqaN9rRsY+6/WoorAb4j490PG/FjqgAXscWp2uSW2WV9L+ksn0wHrvsrg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.115.0': + resolution: {integrity: sha512-t/IRojvUE9XrKu+/H1b8YINug+7Q6FLls5rsm2lxB5mnS8GN/eYAYrPgHkcg9/1SueRDSzGpDYu3lGWTObk1zw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.115.0': + resolution: {integrity: sha512-79jBHSSh/YpQRAmvYoaCfpyToRbJ/HBrdB7hxK2ku2JMehjopTVo+xMJss/RV7/ZYqeezgjvKDQzapJbgcjVZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.115.0': + resolution: {integrity: sha512-nA1TpxkhNTIOMMyiSSsa7XIVJVoOU/SsVrHIz3gHvWweB5PHCQfO7w+Lb2EP0lBWokv7HtA/KbF7aLDoXzmuMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.115.0': + resolution: {integrity: sha512-9iVX789DoC3SaOOG+X6NcF/tVChgLp2vcHffzOC2/Z1JTPlz6bMG2ogvcW6/9s0BG2qvhNQImd+gbWYeQbOwVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.115.0': + resolution: {integrity: sha512-RmQmk+mjCB0nMNfEYhaCxwofLo1Z95ebHw1AGvRiWGCd4zhCNOyskgCbMogIcQzSB3SuEKWgkssyaiQYVAA4hQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.115.0': + resolution: {integrity: sha512-viigraWWQhhDvX5aGq+wrQq58k00Xq3MHz/0R4AFMxGlZ8ogNonpEfNc73Q5Ly87Z6sU9BvxEdG0dnYTfVnmew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.115.0': + resolution: {integrity: sha512-IzGCrMwXhpb4kTXy/8lnqqqwjI7eOvy+r9AhVw+hsr8t1ecBBEHprcNy0aKatFHN6hsX7UMHHQmBAQjVvL/p1A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.115.0': + resolution: {integrity: sha512-/ym+Absk/TLFvbhh3se9XYuI1D7BrUVHw4RaG/2dmWKgBenrZHaJsgnRb7NJtaOyjEOLIPtULx1wDdVL0SX2eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.115.0': + resolution: {integrity: sha512-AQSZjIR+b+Te7uaO/hGTMjT8/oxlYrvKrOTi4KTHF/O6osjHEatUQ3y6ZW2+8+lJxy20zIcGz6iQFmFq/qDKkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.115.0': + resolution: {integrity: sha512-oxUl82N+fIO9jIaXPph8SPPHQXrA08BHokBBJW8ct9F/x6o6bZE6eUAhUtWajbtvFhL8UYcCWRMba+kww6MBlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.115.0': + resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + + '@oxc-project/types@0.128.0': + resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@prisma/client@6.19.2': + resolution: {integrity: sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.2': + resolution: {integrity: sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==} + + '@prisma/debug@6.19.2': + resolution: {integrity: sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines@6.19.2': + resolution: {integrity: sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==} + + '@prisma/fetch-engine@6.19.2': + resolution: {integrity: sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==} + + '@prisma/get-platform@6.19.2': + resolution: {integrity: sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.18': + resolution: {integrity: sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.18': + resolution: {integrity: sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': + resolution: {integrity: sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': + resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': + resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': + resolution: {integrity: sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': + resolution: {integrity: sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': + resolution: {integrity: sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.18': + resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==} + + '@rolldown/pluginutils@1.0.0-rc.2': + resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + + '@rollup/plugin-babel@5.3.1': + resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} + engines: {node: '>= 10.0.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@types/babel__core': ^7.1.9 + rollup: ^1.20.0||^2.0.0 + peerDependenciesMeta: + '@types/babel__core': + optional: true + + '@rollup/plugin-node-resolve@15.3.1': + resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-replace@2.4.2': + resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 + + '@rollup/plugin-terser@0.4.4': + resolution: {integrity: sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@3.1.0': + resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} + engines: {node: '>= 8.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0 + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@simple-libs/child-process-utils@1.0.2': + resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} + engines: {node: '>=18'} + + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} + + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@std-uritemplate/std-uritemplate@2.0.12': + resolution: {integrity: sha512-s/R828vYTrFPgAOFAUiHuP4i1eSZHCO9PKVfzVR+2DFERJ/G71BpZJVObM2yy3xeMjMW3wB61gvanb6/pV1lJw==} + + '@stylistic/eslint-plugin@5.10.0': + resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + '@surma/rollup-plugin-off-main-thread@2.2.3': + resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} + + '@swc/helpers@0.5.19': + resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@ts-rest/core@3.52.1': + resolution: {integrity: sha512-tAjz7Kxq/grJodcTA1Anop4AVRDlD40fkksEV5Mmal88VoZeRKAG8oMHsDwdwPZz+B/zgnz0q2sF+cm5M7Bc7g==} + peerDependencies: + '@types/node': ^18.18.7 || >=20.8.4 + zod: ^3.22.3 + peerDependenciesMeta: + '@types/node': + optional: true + zod: + optional: true + + '@ts-rest/fastify@3.52.1': + resolution: {integrity: sha512-y+w3ENayNI77T0l2gcr9mobF3Nfc4yIb4487mvNMsMUkGLMHmaWzhLy3Mo/mswuoA+tVdOkrD8tJD38bLY7iYQ==} + peerDependencies: + '@ts-rest/core': ~3.52.0 + fastify: ^4.0.0 + zod: ^3.22.3 + peerDependenciesMeta: + zod: + optional: true + + '@ts-rest/open-api@3.52.1': + resolution: {integrity: sha512-VY91g6HFzCe1/fNgX4Y3HzaJbIdztCGHTg34drkh7bKgtNOizLJLXV0wwIkMwLGvjhRHojJxnwYxHjGMIkScwg==} + peerDependencies: + '@ts-rest/core': ~3.52.0 + zod: ^3.22.3 + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/aws-lambda@8.10.161': + resolution: {integrity: sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/bunyan@1.8.11': + resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@0.0.39': + resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/memcached@2.2.10': + resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/mysql@2.15.27': + resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} + + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + + '@types/oracledb@6.5.2': + resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==} + + '@types/pg-pool@2.0.7': + resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@types/swagger-schema-official@2.0.25': + resolution: {integrity: sha512-T92Xav+Gf/Ik1uPW581nA+JftmjWPgskw/WBf4TJzxRG/SJ+DfNnNE+WuZ4mrXuzflQMqMkm1LSYjzYW7MB1Cg==} + + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@typescript-eslint/eslint-plugin@8.57.0': + resolution: {integrity: sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/eslint-plugin@8.59.2': + resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.57.0': + resolution: {integrity: sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.59.2': + resolution: {integrity: sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.57.0': + resolution: {integrity: sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.59.2': + resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/rule-tester@8.57.0': + resolution: {integrity: sha512-qs4OapXmAIj3so85/20lQG1WrBSSvDE/3b42Orl3lpZkaOlNXtbfKzL+9EPaY5wSEgdlhKEpympAMFHPG9i72Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + '@typescript-eslint/scope-manager@8.57.0': + resolution: {integrity: sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.59.2': + resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.57.0': + resolution: {integrity: sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/tsconfig-utils@8.59.2': + resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.57.0': + resolution: {integrity: sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.59.2': + resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.57.0': + resolution: {integrity: sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.59.2': + resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.57.0': + resolution: {integrity: sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/typescript-estree@8.59.2': + resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.57.0': + resolution: {integrity: sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.59.2': + resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.57.0': + resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.59.2': + resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unocss/cli@66.6.6': + resolution: {integrity: sha512-78SY8j4hAVelK+vP/adsDGaSjEITasYLFECJLHWxUJSzK+G9UIc5wtL/u4jA+zKvwVkHcDvbkcO5K6wwwpAixg==} + engines: {node: '>=14'} + hasBin: true + + '@unocss/config@66.6.6': + resolution: {integrity: sha512-menlnkqAFX/4wR2aandY8hSqrt01JE+rOzvtQxWaBt8kf1du62b0sS72FE5Z40n6HlEsEbF91N9FCfhnzG6i6g==} + engines: {node: '>=14'} + + '@unocss/core@0.65.4': + resolution: {integrity: sha512-a2JOoFutrhqd5RgPhIR5FIXrDoHDU3gwCbPrpT6KYTjsqlSc/fv02yZ+JGOZFN3MCFhCmaPTs+idDFtwb3xU8g==} + + '@unocss/core@66.6.6': + resolution: {integrity: sha512-Sbbx0ZQqmV8K2lg8E+z9MJzWb1MgRtJnvqzxDIrNuBjXasKhbcFt5wEMBtEZJOr63Z4ck0xThhZK53HmYT2jmg==} + + '@unocss/extractor-arbitrary-variants@66.6.6': + resolution: {integrity: sha512-uMzekF2miZRUwSZGvy3yYQiBAcSAs9LiXK8e3NjldxEw8xcRDWgTErxgStRoBeAD6UyzDcg/Cvwtf2guMbtR+g==} + + '@unocss/inspector@66.6.6': + resolution: {integrity: sha512-CpXIsqHwxCXJtUjUz6S29diHCIA+EJ1u5WML/6m2YPI4ObgWAVKrExy09inSg2icS52lFkWWdWQSeqc9kl5W6Q==} + + '@unocss/preset-attributify@66.6.6': + resolution: {integrity: sha512-3H12UI1rBt60PQy+S4IEeFYWu1/WQFuc2yhJ5mu/RCvX5/qwlIGanBpuh+xzTPXU1fWBlZN68yyO9uWOQgTqZQ==} + + '@unocss/preset-icons@66.6.6': + resolution: {integrity: sha512-HfIEEqf3jyKexOB2Sux556n0NkPoUftb2H4+Cf7prJvKHopMkZ/OUkXjwvUlxt1e5UpAEaIa0A2Ir7+ApxXoGA==} + + '@unocss/preset-mini@66.6.6': + resolution: {integrity: sha512-k+/95PKMPOK57cJcSmz34VkIFem8BlujRRx6/L0Yusw7vLJMh98k0rPhC5s+NomZ/d9ZPgbNylskLhItJlak3w==} + + '@unocss/preset-tagify@66.6.6': + resolution: {integrity: sha512-KgBXYPYS0g4TVC3NLiIB78YIqUlvDLanz1EHIDo34rOTUfMgY8Uf5VuDJAzMu4Sc0LiwwBJbk6nIG9/Zm7ufWg==} + + '@unocss/preset-typography@66.6.6': + resolution: {integrity: sha512-SM1km5nqt15z4sTabfOobSC633I5Ol5nnme6JFTra4wiyCUNs+Cg31nJ6jnopWDUT4SEAXqfUH7jKSSoCnI6ZA==} + + '@unocss/preset-uno@66.6.6': + resolution: {integrity: sha512-40PcBDtlhW7QP7e/WOxC684IhN5T1dXvj1dgx9ZzK+8lEDGjcX7bN2noW4aSenzSrHymeSsMrL/0ltL4ED/5Zw==} + + '@unocss/preset-web-fonts@66.6.6': + resolution: {integrity: sha512-5ikwgrJB8VPzKd0bqgGNgYUGix90KFnVtKJPjWTP5qsv3+ZtZnea1rRbAFl8i2t52hg35msNBsQo+40IC3xB6A==} + + '@unocss/preset-wind3@66.6.6': + resolution: {integrity: sha512-rk6gPPIQ7z2DVucOqp7XZ4vGpKAuzBV1vtUDvDh5WscxzO/QlqaeTfTALk5YgGpmLaF4+ns6FrTgLjV+wHgHuQ==} + + '@unocss/preset-wind4@66.6.6': + resolution: {integrity: sha512-caTDM9rZSlp4tyPWWAnwMvQr2PXq53LsEYwd3N8zj0ou2hcsqptJvF+mFvyhvGF66x26wWJr/FwuUEhh7qycaw==} + + '@unocss/preset-wind@66.6.6': + resolution: {integrity: sha512-TMy3lZ35FP/4QqDHOLWZmV+RoOGWUDqnDEOTjOKI1CQARGta0ppUmq+IZMuI1ZJLuOa4OZ9V6SfnwMXwRLgXmw==} + + '@unocss/rule-utils@0.65.4': + resolution: {integrity: sha512-+EzdJEWcqGcO6HwbBTe7vEdBRpuKkBiz4MycQeLD6GEio04T45y6VHHO7/WTqxltbO4YwwW9/s2TKRMxKtoG8g==} + engines: {node: '>=14'} + + '@unocss/rule-utils@66.6.6': + resolution: {integrity: sha512-krWtQKGshOaqQMuxeGq1NOA8NL35VdpYlmQEWOe39BY6TACT51bgQFu40MRfsAIMZZtoGS2YYTrnHojgR92omw==} + engines: {node: '>=14'} + + '@unocss/transformer-attributify-jsx@66.6.6': + resolution: {integrity: sha512-NnDchmN2EeFLy4lfVqDgNe9j1+w2RLL2L9zKECXs5g6rDVfeeEK6FNgxSq3XnPcKltjNCy1pF4MaDOROG7r8yA==} + + '@unocss/transformer-compile-class@66.6.6': + resolution: {integrity: sha512-KKssJxU8fZ9x84yznIirbtta2sB0LN/3lm0bp+Wl1298HITaNiVeG2n26iStQ3N7r240xRN2RarxncSVCMFwWw==} + + '@unocss/transformer-directives@0.65.4': + resolution: {integrity: sha512-zkoDEwzPkgXi6ohW7P11gbArwfTRMZ9knYSUYoPEltQz+UZYzeRQ85exiAmdz5MsbCAuhQEr577Kd/CWfhjEuA==} + + '@unocss/transformer-directives@66.6.6': + resolution: {integrity: sha512-CReFTcBfMtKkRvzIqxL20VptWt5C1Om27dwoKzyVFBXv0jzViWysbu0y0AQg3bsgD4cFqndFyAGyeL84j0nbKg==} + + '@unocss/transformer-variant-group@66.6.6': + resolution: {integrity: sha512-j4L/0Tw6AdMVB2dDnuBlDbevyL1/0CAk88a77VF/VjgEIBwB9VXsCCUsxz+2Dohcl7N2GMm7+kpaWA6qt2PSaA==} + + '@unocss/vite@66.6.6': + resolution: {integrity: sha512-DgG7KcUUMtoDhPOlFf2l4dR+66xZ23SdZvTYpikk5nZfLCzZd62vedutD7x0bTR6VpK2YRq39B+F+Z6TktNY/w==} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0 + + '@vitejs/plugin-vue@6.0.4': + resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vue: ^3.2.25 + + '@vitest/coverage-v8@4.1.5': + resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} + peerDependencies: + '@vitest/browser': 4.1.5 + vitest: 4.1.5 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/eslint-plugin@1.6.17': + resolution: {integrity: sha512-sIVY9ZeVcXyPxFCNRkIt8Yw4keKIcUyp9/8qnmuomPwE+ST1htw5sZsbqdUMTiah9SmCg1JYoK9RqdDtPeNYYg==} + engines: {node: '>=18'} + peerDependencies: + '@typescript-eslint/eslint-plugin': '*' + eslint: '>=8.57.0' + typescript: '>=5.0.0' + vitest: '*' + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + typescript: + optional: true + vitest: + optional: true + + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.30': + resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==} + + '@vue/compiler-dom@3.5.30': + resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==} + + '@vue/compiler-sfc@3.5.30': + resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==} + + '@vue/compiler-ssr@3.5.30': + resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/eslint-config-typescript@14.7.0': + resolution: {integrity: sha512-iegbMINVc+seZ/QxtzWiOBozctrHiF2WvGedruu2EbLujg9VuU0FQiNcN2z1ycuaoKKpF4m2qzB5HDEMKbxtIg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.10.0 || ^10.0.0 + eslint-plugin-vue: ^9.28.0 || ^10.0.0 + typescript: '>=4.8.4' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.30': + resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==} + + '@vue/runtime-core@3.5.30': + resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==} + + '@vue/runtime-dom@3.5.30': + resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==} + + '@vue/server-renderer@3.5.30': + resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==} + peerDependencies: + vue: 3.5.30 + + '@vue/shared@3.5.30': + resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==} + + '@vue/tsconfig@0.7.0': + resolution: {integrity: sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==} + peerDependencies: + typescript: 5.x + vue: ^3.4.0 + peerDependenciesMeta: + typescript: + optional: true + vue: + optional: true + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats-draft2019@1.6.1: + resolution: {integrity: sha512-JQPvavpkWDvIsBp2Z33UkYCtXCSpW4HD3tAZ+oL4iEFOk9obQZffx0yANwECt6vzr6ET+7HN5czRyqXbnq/u0Q==} + peerDependencies: + ajv: '*' + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + any-base@1.1.0: + resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + are-docs-informative@0.0.2: + resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + axios-retry@4.5.0: + resolution: {integrity: sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==} + peerDependencies: + axios: 0.x || 1.x + + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.14.2: + resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.38: + resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} + engines: {node: '>=6.0.0'} + hasBin: true + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bn.js@4.12.3: + resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + brorand@1.1.0: + resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builtin-modules@5.0.0: + resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} + engines: {node: '>=18.20'} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + + c12@3.3.3: + resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + cache-manager@7.2.8: + resolution: {integrity: sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==} + + cacheable@2.3.3: + resolution: {integrity: sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + camelize-ts@3.0.0: + resolution: {integrity: sha512-cgRwKKavoDKLTjO4FQTs3dRBePZp/2Y9Xpud0FhuCOTE86M2cniKN4CCXgRnsyXNMmQMifVHcv6SPaMtTx6ofQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + check-disk-space@3.4.0: + resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==} + engines: {node: '>=16'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cidr-regex@3.1.1: + resolution: {integrity: sha512-RBqYd32aDwbCMFJRL6wHOlDNYJsPNTt8vC82ErHF5vKt8QQzxm1FrkW8s/R5pVrXMf17sba09Uoy91PKiddAsw==} + engines: {node: '>=10'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.1: + resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clean-regexp@1.0.0: + resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} + engines: {node: '>=4'} + + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + clipboard@2.0.11: + resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + comment-json@4.4.1: + resolution: {integrity: sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==} + engines: {node: '>= 6'} + + comment-parser@1.4.5: + resolution: {integrity: sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==} + engines: {node: '>= 12.0.0'} + + comment-parser@1.4.6: + resolution: {integrity: sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==} + engines: {node: '>= 12.0.0'} + + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@9.3.1: + resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} + engines: {node: '>=18'} + + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} + hasBin: true + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cron-validator@1.4.0: + resolution: {integrity: sha512-wGcJ9FCy65iaU6egSH8b5dZYJF7GU/3Jh06wzaT9lsa5dbqExjljmu+0cJ8cpKn+vUyZa/EM4WAxeLR6SypJXw==} + + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto-random-string@2.0.0: + resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} + engines: {node: '>=8'} + + css-functions-list@3.3.3: + resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} + engines: {node: '>=12'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + deeks@3.1.0: + resolution: {integrity: sha512-e7oWH1LzIdv/prMQ7pmlDlaVoL64glqzvNgkgQNgyec9ORPHrT2jaOqMtRyqJuwWjtfb6v+2rk9pmaHj+F137A==} + engines: {node: '>= 16'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegate@3.2.0: + resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + + doc-path@4.1.1: + resolution: {integrity: sha512-h1ErTglQAVv2gCnOpD3sFS6uolDbOKHDU1BZq+Kl3npPqroU3dYL42lUgMfd5UimlwtRgp7C9dLGwqQ5D2HYgQ==} + engines: {node: '>=16'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + dompurify@3.3.2: + resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} + engines: {node: '>=20'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + effect@3.18.4: + resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-to-chromium@1.5.376: + resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + + elliptic@6.6.1: + resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.24.0: + resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.1: + resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} + engines: {node: '>= 0.4'} + + es-toolkit@1.48.1: + resolution: {integrity: sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==} + + es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-flat-gitignore@2.3.0: + resolution: {integrity: sha512-bg4ZLGgoARg1naWfsINUUb/52Ksw/K22K+T16D38Y8v+/sGwwIYrGvH/JBjOin+RQtxxC9tzNNiy4shnGtGyyQ==} + peerDependencies: + eslint: ^9.5.0 || ^10.0.0 + + eslint-flat-config-utils@3.2.0: + resolution: {integrity: sha512-PHgo1X5uqIorJONLVD9BIaOSdoYFD3z/AeJljdqDPlWVRpeCYkDbK9k0AXoYVqqNJr6FEYIEr5Rm2TSktLQcHw==} + + eslint-json-compat-utils@0.2.3: + resolution: {integrity: sha512-RbBmDFyu7FqnjE8F0ZxPNzx5UaptdeS9Uu50r7A+D7s/+FCX+ybiyViYEgFUaFIFqSWJgZRTpL5d8Kanxxl2lQ==} + engines: {node: '>=12'} + peerDependencies: + '@eslint/json': '*' + eslint: '*' + jsonc-eslint-parser: ^2.4.0 || ^3.0.0 + peerDependenciesMeta: + '@eslint/json': + optional: true + + eslint-merge-processors@2.0.0: + resolution: {integrity: sha512-sUuhSf3IrJdGooquEUB5TNpGNpBoQccbnaLHsb1XkBLUPPqCNivCpY05ZcpCOiV9uHwO2yxXEWVczVclzMxYlA==} + peerDependencies: + eslint: '*' + + eslint-plugin-antfu@3.2.2: + resolution: {integrity: sha512-Qzixht2Dmd/pMbb5EnKqw2V8TiWHbotPlsORO8a+IzCLFwE0RxK8a9k4DCTFPzBwyxJzH+0m2Mn8IUGeGQkyUw==} + peerDependencies: + eslint: '*' + + eslint-plugin-command@3.5.2: + resolution: {integrity: sha512-PA59QAkQDwvcCMEt5lYLJLI3zDGVKJeC4id/pcRY2XdRYhSGW7iyYT1VC1N3bmpuvu6Qb/9QptiS3GJMjeGTJg==} + peerDependencies: + '@typescript-eslint/rule-tester': '*' + '@typescript-eslint/typescript-estree': '*' + '@typescript-eslint/utils': '*' + eslint: '*' + + eslint-plugin-depend@1.5.0: + resolution: {integrity: sha512-i3UeLYmclf1Icp35+6W7CR4Bp2PIpDgBuf/mpmXK5UeLkZlvYJ21VuQKKHHAIBKRTPivPGX/gZl5JGno1o9Y0A==} + peerDependencies: + eslint: '>=8.40.0' + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-import-lite@0.6.0: + resolution: {integrity: sha512-80vevx2A7i3H7n1/6pqDO8cc5wRz6OwLDvIyVl9UflBV1N1f46e9Ihzi65IOLYoSxM6YykK2fTw1xm0Ixx6aTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + eslint-plugin-jsdoc@62.9.0: + resolution: {integrity: sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-jsonc@3.1.2: + resolution: {integrity: sha512-dopTxdB22iuOkgKyJCupEC5IYBItUT4J/teq1H5ddUObcaYhOURxtJElZczdcYnnKCghNU/vccuyPkliy2Wxsg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-n@17.24.0: + resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=8.23.0' + + eslint-plugin-no-only-tests@3.3.0: + resolution: {integrity: sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==} + engines: {node: '>=5.0.0'} + + eslint-plugin-perfectionist@5.9.0: + resolution: {integrity: sha512-8TWzg02zmnBdZwCkWLi8jhzqXI+fE7Z/RwV8SL6xD45tJ8Bp3wGuYL2XtQgfe/Wd0eBqOUX+s6ey73IyszvKTA==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-pnpm@1.6.0: + resolution: {integrity: sha512-dxmt9r3zvPaft6IugS4i0k16xag3fTbOvm/road5uV9Y8qUCQT0xzheSh3gMlYAlC6vXRpfArBDsTZ7H7JKCbg==} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + eslint-plugin-regexp@3.1.0: + resolution: {integrity: sha512-qGXIC3DIKZHcK1H9A9+Byz9gmndY6TTSRkSMTZpNXdyCw2ObSehRgccJv35n9AdUakEjQp5VFNLas6BMXizCZg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-toml@1.3.1: + resolution: {integrity: sha512-1l00fBP03HIt9IPV7ZxBi7x0y0NMdEZmakL1jBD6N/FoKBvfKxPw5S8XkmzBecOnFBTn5Z8sNJtL5vdf9cpRMQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-unicorn@64.0.0: + resolution: {integrity: sha512-rNZwalHh8i0UfPlhNwg5BTUO1CMdKNmjqe+TgzOTZnpKoi8VBgsW7u9qCHIdpxEzZ1uwrJrPF0uRb7l//K38gA==} + engines: {node: ^20.10.0 || >=21.0.0} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-unused-imports@4.4.1: + resolution: {integrity: sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==} + peerDependencies: + '@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0 + eslint: ^10.0.0 || ^9.0.0 || ^8.0.0 + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + + eslint-plugin-vue@10.8.0: + resolution: {integrity: sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-eslint-parser: ^10.0.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-plugin-yml@3.3.1: + resolution: {integrity: sha512-isntsZchaTqDMNNkD+CakrgA/pdUoJ45USWBKpuqfAW1MCuw731xX/vrXfoJFZU3tTFr24nCbDYmDfT2+g4QtQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + eslint: '>=9.38.0' + + eslint-processor-vue-blocks@2.0.0: + resolution: {integrity: sha512-u4W0CJwGoWY3bjXAuFpc/b6eK3NQEI8MoeW7ritKj3G3z/WtHrKjkqf+wk8mPEy5rlMGS+k6AZYOw2XBoN/02Q==} + peerDependencies: + '@vue/compiler-sfc': ^3.3.0 + eslint: '>=9.0.0' + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.5.0: + resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@1.0.1: + resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eta@3.5.0: + resolution: {integrity: sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==} + engines: {node: '>=6.0.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-copy@4.0.2: + resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + + fast-jwt@5.0.6: + resolution: {integrity: sha512-LPE7OCGUl11q3ZgW681cEU2d0d2JZ37hhJAmetCgNyW8waVaJVZXhyFF6U2so1Iim58Yc7pfxJe2P7MNetQH2g==} + engines: {node: '>=20'} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastfall@1.5.1: + resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==} + engines: {node: '>=0.10.0'} + + fastify-keycloak-adapter@3.0.2: + resolution: {integrity: sha512-Zm5PowNKxgHPztM1wC0hJmnFYPrUoduREEIzxwz2dnTpMm5qZkp7btweTuGW5AzDbjBu3EVM0fNyPxyR3OkwwA==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + + fastparallel@2.4.1: + resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fastseries@1.7.2: + resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==} + + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + file-entry-cache@11.1.2: + resolution: {integrity: sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-type@21.3.0: + resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} + engines: {node: '>=20'} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flat-cache@6.1.20: + resolution: {integrity: sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + focus-trap-vue@4.1.0: + resolution: {integrity: sha512-RvK28mED+4fqoqnJQyadfoiKZU2G/QEez+EnJtdsg83DjiuKetimvDGas9ot4p5bn1J09rDmKLtKFMBkwQc+JA==} + peerDependencies: + focus-trap: ^7.0.0 + vue: ^3.0.0 + + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fork-ts-checker-webpack-plugin@9.1.0: + resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} + engines: {node: '>=14.21.3'} + peerDependencies: + typescript: '>3.6.0' + webpack: ^5.11.0 + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fp-ts@2.16.11: + resolution: {integrity: sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-monkey@1.1.0: + resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-own-enumerable-property-symbols@3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + git-raw-commits@5.0.1: + resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} + engines: {node: '>=18'} + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. + hasBin: true + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + global-prefix@4.0.0: + resolution: {integrity: sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==} + engines: {node: '>=16'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@16.2.0: + resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} + engines: {node: '>=20'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + good-listener@1.2.2: + resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grant@5.4.24: + resolution: {integrity: sha512-PD5AvSI7wgCBDi2mEd6M/TIe+70c/fVc3Ik4B0s4mloWTy9J800eUEcxivOiyqSP9wvBy2QjWq1JR8gOfDMnEg==} + engines: {node: '>=12.0.0'} + + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + + hashery@1.5.0: + resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==} + engines: {node: '>=20'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + hmac-drbg@1.0.1: + resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-tags@5.1.0: + resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==} + engines: {node: '>=20.10'} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http2-client@1.3.5: + resolution: {integrity: sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-in-the-middle@2.0.6: + resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + + import-in-the-middle@3.0.0: + resolution: {integrity: sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==} + engines: {node: '>=18'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + io-ts@2.2.22: + resolution: {integrity: sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA==} + peerDependencies: + fp-ts: ^2.5.0 + + ip-regex@4.3.0: + resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} + engines: {node: '>=8'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-builtin-module@5.0.0: + resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} + engines: {node: '>=18.20'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-cidr@4.0.2: + resolution: {integrity: sha512-z4a1ENUajDbEl/Q6/pVBpTR1nBjjEE1X7qb7bmWYanNnPoKAvUCPFKeXV6Fe4mgTkWKBqiHIcwsI3SndiO5FeA==} + engines: {node: '>=10'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@1.0.1: + resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} + engines: {node: '>=0.10.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-regexp@1.0.0: + resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} + engines: {node: '>=0.10.0'} + + is-retry-allowed@2.2.0: + resolution: {integrity: sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==} + engines: {node: '>=10'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + javascript-time-ago@2.6.4: + resolution: {integrity: sha512-7K/Z37LuwVaxxjutUDd1pXpznufPcox0b1UYu00ksAMMlV6IsxIvduwL3kgfPxuBVF8jVj7nhrKMPDslMq94aQ==} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + jsdoc-type-pratt-parser@7.1.1: + resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} + engines: {node: '>=20.0.0'} + + jsdoc-type-pratt-parser@7.2.0: + resolution: {integrity: sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==} + engines: {node: '>=20.0.0'} + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-2-csv@5.5.10: + resolution: {integrity: sha512-Dep8wO3Fr5wNjQevO2Z8Y7yeee/nYSGRsi7q6zJDKEVHxXkXT+v21vxHmDX923UzmCXXkSo62HaTz6eTWzFLaw==} + engines: {node: '>= 16'} + deprecated: A security vulnerability has been reported with the preventCsvInjection option which has been fixed in version 5.5.11. Please upgrade as soon as possible. + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-eslint-parser@3.1.0: + resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jwk-to-pem@2.0.7: + resolution: {integrity: sha512-cSVphrmWr6reVchuKQZdfSs4U9c5Y4hwZggPoz6cbVnTpAVgGRpEuQng86IyqLeGZlhTh+c4MAreB6KbdQDKHQ==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + katex@0.16.45: + resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} + hasBin: true + + keycloak-js@26.2.3: + resolution: {integrity: sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + + loader-runner@4.3.2: + resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} + engines: {node: '>=6.11.5'} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + magic-regexp@0.10.0: + resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} + + magic-string@0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mathml-tag-names@4.0.0: + resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + memfs@3.5.3: + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} + engines: {node: '>= 4.0.0'} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} + engines: {node: '>=20'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimalistic-crypto-utils@1.0.1: + resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.1: + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} + + mnemonist@0.40.4: + resolution: {integrity: sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==} + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + module-replacements@2.11.0: + resolution: {integrity: sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA==} + + moo@0.5.3: + resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.12.10: + resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + multer@2.1.1: + resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} + engines: {node: '>= 10.16.0'} + + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@5.0.9: + resolution: {integrity: sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==} + engines: {node: ^18 || >=20} + hasBin: true + + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + natural-orderby@5.0.0: + resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} + engines: {node: '>=18'} + + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + nestjs-pino@4.6.0: + resolution: {integrity: sha512-MzSgnOu9MhRT/f7MsvoDnxat11D9JRJYwL1t+tI6J44UrNz9rUVDpceEh9VFsyfiiIJKUri5S+/snMOoaWh7YA==} + engines: {node: '>= 14'} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + pino: ^7.5.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-emoji@1.11.0: + resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + + node-fetch-h2@2.3.0: + resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==} + engines: {node: 4.x || >=6.0.0} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-readfiles@0.2.0: + resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==} + + node-releases@2.0.48: + resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} + engines: {node: '>=18'} + + nodemon@3.1.14: + resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + nypm@0.6.5: + resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} + engines: {node: '>=18'} + hasBin: true + + oas-kit-common@1.0.8: + resolution: {integrity: sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==} + + oas-linter@3.2.2: + resolution: {integrity: sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==} + + oas-resolver@2.5.6: + resolution: {integrity: sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==} + hasBin: true + + oas-schema-walker@1.1.5: + resolution: {integrity: sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==} + + oas-validator@5.0.8: + resolution: {integrity: sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==} + + oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-deep-merge@2.0.0: + resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + + openapi3-ts@2.0.2: + resolution: {integrity: sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + oxc-parser@0.115.0: + resolution: {integrity: sha512-2w7Xn3CbS/zwzSY82S5WLemrRu3CT57uF7Lx8llrE/2bul6iMTcJE4Rbls7GDNbLn3ttATI68PfOz2Pt3KZ2cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-walker@0.7.0: + resolution: {integrity: sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A==} + peerDependencies: + oxc-parser: '>=0.98.0' + + p-debounce@4.0.0: + resolution: {integrity: sha512-4Ispi9I9qYGO4lueiLDhe4q4iK5ERK8reLsuzH6BPaXn53EGaua8H66PXIFGrW897hwjXp+pVLrm/DLxN0RF0A==} + engines: {node: '>=12'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-gitignore@2.0.0: + resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==} + engines: {node: '>=14'} + + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch-browser@2.2.6: + resolution: {integrity: sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==} + engines: {node: '>=8.6'} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-http@11.0.0: + resolution: {integrity: sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + pnpm-workspace-yaml@1.6.0: + resolution: {integrity: sha512-uUy4dK3E11sp7nK+hnT7uAWfkBMe00KaUw8OG3NuNlYQoTk4sc9pcdIy1+XIP85v9Tvr02mK3JPaNNrP0QyRaw==} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-html@1.8.1: + resolution: {integrity: sha512-OLF6P7qctfAWayOhLpcVnTGqVeJzu2W3WpIYelfz2+JV5oGxfkcEvweN9U4XpeqE0P98dcD9ssusGwlF0TK0uQ==} + engines: {node: ^12 || >=14} + + postcss-safe-parser@6.0.0: + resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.3.3 + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + + pretty-bytes@6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} + + prisma@6.19.2: + resolution: {integrity: sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + protobufjs@7.6.4: + resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} + engines: {node: '>=12.0.0'} + + protobufjs@8.0.0: + resolution: {integrity: sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==} + engines: {node: '>=12.0.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qified@0.6.0: + resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} + engines: {node: '>=20'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + rate-limiter-flexible@4.0.1: + resolution: {integrity: sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ==} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + re2-wasm@1.0.2: + resolution: {integrity: sha512-VXUdgSiUrE/WZXn6gUIVVIsg0+Hp6VPZPOaHCay+OuFKy6u/8ktmeNEf+U5qSA8jzGGFsg8jrDNu1BeHpz2pJA==} + engines: {node: '>=10'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + refa@0.12.1: + resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + reftools@1.1.9: + resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regexp-ast-analysis@0.7.1: + resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + + relative-time-format@1.1.12: + resolution: {integrity: sha512-qaZBjmRIuXLfuLnzgqpFdBPa5W0euSX1tMnoMUHGPphLwJmrt8xbNiOIHrlvYOD6oNJ0M5owPCZyPibI8de5pQ==} + + request-compose@2.1.7: + resolution: {integrity: sha512-27amNkWTK4Qq25XEwdmrhb4VLMiQzRSKuDfsy1o1griykcyXk5MxMHmJG+OKTRdO9PgsO7Kkn7GrEkq0UAIIMQ==} + engines: {node: '>=12.0.0'} + + request-oauth@1.0.1: + resolution: {integrity: sha512-85THTg1RgOYtqQw42JON6AqvHLptlj1biw265Tsq4fD4cPdUvhDB2Qh9NTv17yCD322ROuO9aOmpc4GyayGVBA==} + engines: {node: '>=8.0.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + + rolldown@1.0.0-rc.18: + resolution: {integrity: sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@2.80.0: + resolution: {integrity: sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==} + engines: {node: '>=10.0.0'} + hasBin: true + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + schemes@1.4.0: + resolution: {integrity: sha512-ImFy9FbCsQlVgnE3TCWmLPCFnVzx0lHL/l+umHplDqAKd0dzFpnS6lFZIpagBlYhKwzVmlV36ec0Y1XTu8JBAQ==} + + scslre@0.3.0: + resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} + engines: {node: ^14.0.0 || >=16.0.0} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + select@1.1.2: + resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + short-uuid@5.2.0: + resolution: {integrity: sha512-296/Nzi4DmANh93iYBwT4NoYRJuHnKEzefrkSagQbTH/A6NTaB68hSPDjm5IlbI5dx9FXdmtqPcj6N5H+CPm6w==} + engines: {node: '>=14'} + + should-equal@2.0.0: + resolution: {integrity: sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==} + + should-format@3.0.3: + resolution: {integrity: sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==} + + should-type-adaptors@1.1.0: + resolution: {integrity: sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==} + + should-type@1.4.0: + resolution: {integrity: sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==} + + should-util@1.0.1: + resolution: {integrity: sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==} + + should@13.2.3: + resolution: {integrity: sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + + smob@1.6.2: + resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} + engines: {node: '>=20.0.0'} + + smtp-address-parser@1.1.0: + resolution: {integrity: sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==} + engines: {node: '>=0.10'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions + + sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@4.0.0: + resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + steed@1.1.3: + resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-object@3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-comments@2.0.1: + resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} + engines: {node: '>=10'} + + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + strtok3@10.3.4: + resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} + engines: {node: '>=18'} + + stylelint-config-html@1.1.0: + resolution: {integrity: sha512-IZv4IVESjKLumUGi+HWeb7skgO6/g4VMuAYrJdlqQFndgbj6WJAXPhaysvBiXefX79upBdQVumgYcdd17gCpjQ==} + engines: {node: ^12 || >=14} + peerDependencies: + postcss-html: ^1.0.0 + stylelint: '>=14.0.0' + + stylelint-config-recommended-vue@1.6.1: + resolution: {integrity: sha512-lLW7hTIMBiTfjenGuDq2kyHA6fBWd/+Df7MO4/AWOxiFeXP9clbpKgg27kHfwA3H7UNMGC7aeP3mNlZB5LMmEQ==} + engines: {node: ^12 || >=14} + peerDependencies: + postcss-html: ^1.0.0 + stylelint: '>=14.0.0' + + stylelint-config-recommended@18.0.0: + resolution: {integrity: sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint-config-standard@40.0.0: + resolution: {integrity: sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint@17.11.0: + resolution: {integrity: sha512-/3czzmbF9XdGWvReDF3Ex4R23Ajolo7j8RB2bFNEqk6Ht356nlpVV+G5bG2Qt8AW1ofJzXztBRDnAtd7cgowWA==} + engines: {node: '>=20.19.0'} + hasBin: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@4.4.0: + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} + engines: {node: '>=20'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + swagger-schema-official@2.0.0-bab6bed: + resolution: {integrity: sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA==} + + swagger-typescript-api@13.3.1: + resolution: {integrity: sha512-fSYf55kY0nV2TxS131h35sl+HaLNiZabF+A5ffFC7TCMlHTAVCSlmsh3MPXFaWYk8CcMLSFg4q4vDlWkhBDdAA==} + engines: {node: '>=20'} + hasBin: true + + swagger-ui-dist@5.32.4: + resolution: {integrity: sha512-0AADFFQNJzExEN49SrD/34Nn9cxNxVLiydYl2MBwSZFPVXNkVwC/EFAjoezGGqE8oDegiDC+p47t8lKObCinMQ==} + + swagger2openapi@7.0.8: + resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==} + hasBin: true + + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + temp-dir@2.0.0: + resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} + engines: {node: '>=8'} + + tempy@0.6.0: + resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==} + engines: {node: '>=10'} + + terser-webpack-plugin@5.6.1: + resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + engines: {node: '>=10'} + hasBin: true + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tiny-emitter@2.1.0: + resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyduration@3.4.1: + resolution: {integrity: sha512-NemFoamVYn7TmtwZKZ3OiliM9fZkr6EWiTM+wKknco6POSy2gS689xx/pXip0JYp40HXpUw6k65CUYHWYUXdaA==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts-core@7.4.3: + resolution: {integrity: sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tldts@7.4.3: + resolution: {integrity: sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + toml-eslint-parser@1.0.3: + resolution: {integrity: sha512-A5F0cM6+mDleacLIEUkmfpkBbnHJFV1d2rprHU2MXNk7mlxHq2zGojA+SRvQD1RoMo9gqjZPWEaKG4v1BQ48lw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-declaration-location@1.0.7: + resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} + peerDependencies: + typescript: '>=4.0.0' + + ts-deepmerge@6.2.1: + resolution: {integrity: sha512-8CYSLazCyj0DJDpPIxOFzJG46r93uh6EynYjuey+bxcLltBeqZL7DMfaE5ZPzZNFlav7wx+2TDa/mBl8gkTYzw==} + engines: {node: '>=14.13.1'} + + ts-essentials@10.1.1: + resolution: {integrity: sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==} + peerDependencies: + typescript: '>=4.5.0' + peerDependenciesMeta: + typescript: + optional: true + + ts-patch@3.3.0: + resolution: {integrity: sha512-zAOzDnd5qsfEnjd9IGy1IRuvA7ygyyxxdxesbhMdutt8AHFjD8Vw8hU2rMF89HX1BKRWFYqKHrO8Q6lw0NeUZg==} + hasBin: true + + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.16.0: + resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} + engines: {node: '>=10'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + engines: {node: '>=20'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + type-level-regexp@0.1.17: + resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript-eslint@8.57.0: + resolution: {integrity: sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript-transform-paths@3.5.6: + resolution: {integrity: sha512-3eQTG6Ogt+pgPEh45uX2s9OwcfAVjWnyNO+osjYcOqYaWDVMIFUkqW8e0O1cOaVwdMqQFQf6alDT+76xmeS2Ag==} + peerDependencies: + typescript: '>=3.6.5' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + unconfig@7.5.0: + resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@7.24.5: + resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} + engines: {node: '>=20.18.1'} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + + unimport@3.14.6: + resolution: {integrity: sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==} + + unique-string@2.0.0: + resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} + engines: {node: '>=8'} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unocss@66.6.6: + resolution: {integrity: sha512-PRKK945e2oZKHV664MA5Z9CDHbvY/V79IvTOUWKZ514jpl3UsJU3sS+skgxmKJSmwrWvXE5OVcmPthJrD/7vxg==} + engines: {node: '>=14'} + peerDependencies: + '@unocss/astro': 66.6.6 + '@unocss/postcss': 66.6.6 + '@unocss/webpack': 66.6.6 + peerDependenciesMeta: + '@unocss/astro': + optional: true + '@unocss/postcss': + optional: true + '@unocss/webpack': + optional: true + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unplugin-auto-import@0.18.6: + resolution: {integrity: sha512-LMFzX5DtkTj/3wZuyG5bgKBoJ7WSgzqSGJ8ppDRdlvPh45mx6t6w3OcbExQi53n3xF5MYkNGPNR/HYOL95KL2A==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': ^3.2.2 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + + unplugin-vue-components@0.27.5: + resolution: {integrity: sha512-m9j4goBeNwXyNN8oZHHxvIIYiG8FQ9UfmKWeNllpDvhU7btKNNELGPt+o3mckQKuPwrE7e0PvCsx+IWuDSD9Vg==} + engines: {node: '>=14'} + peerDependencies: + '@babel/parser': ^7.15.8 + '@nuxt/kit': ^3.2.2 + vue: 2 || 3 + peerDependenciesMeta: + '@babel/parser': + optional: true + '@nuxt/kit': + optional: true + + unplugin@1.16.1: + resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} + engines: {node: '>=14.0.0'} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + + upath@1.2.0: + resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} + engines: {node: '>=4'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-template@3.1.1: + resolution: {integrity: sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-node@6.0.0: + resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + vite-plugin-pwa@1.2.0: + resolution: {integrity: sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@vite-pwa/assets-generator': ^1.0.0 + vite: ^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + workbox-build: ^7.4.0 + workbox-window: ^7.4.0 + peerDependenciesMeta: + '@vite-pwa/assets-generator': + optional: true + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vite@8.0.11: + resolution: {integrity: sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest-mock-extended@2.0.2: + resolution: {integrity: sha512-n3MBqVITKyclZ0n0y66hkT4UiiEYFQn9tteAnIxT0MPz1Z8nFcPUG3Cf0cZOyoPOj/cq6Ab1XFw2lM/qM5EDWQ==} + peerDependencies: + typescript: 3.x || 4.x || 5.x + vitest: '>=2.0.0' + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-eslint-parser@10.4.0: + resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue3-json-viewer@2.4.1: + resolution: {integrity: sha512-Z1sunvS58lJ3ZcpNhl3jYQapBVw2wjnXbemigfMWm3QnjCeg3CPMq8R6pxHUYahxMfPKLvrbGve6mUXqhWyLaQ==} + peerDependencies: + vue: ^3.5.16 + + vue@3.5.30: + resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + watchpack@2.5.2: + resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + webpack-node-externals@3.0.0: + resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} + engines: {node: '>=6'} + + webpack-sources@3.5.0: + resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} + engines: {node: '>=10.13.0'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + webpack@5.104.1: + resolution: {integrity: sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + wildcard-match@5.1.4: + resolution: {integrity: sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g==} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + workbox-background-sync@7.4.0: + resolution: {integrity: sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==} + + workbox-broadcast-update@7.4.0: + resolution: {integrity: sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==} + + workbox-build@7.4.0: + resolution: {integrity: sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==} + engines: {node: '>=20.0.0'} + + workbox-cacheable-response@7.4.0: + resolution: {integrity: sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==} + + workbox-core@7.4.0: + resolution: {integrity: sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==} + + workbox-expiration@7.4.0: + resolution: {integrity: sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==} + + workbox-google-analytics@7.4.0: + resolution: {integrity: sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==} + + workbox-navigation-preload@7.4.0: + resolution: {integrity: sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==} + + workbox-precaching@7.4.0: + resolution: {integrity: sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==} + + workbox-range-requests@7.4.0: + resolution: {integrity: sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==} + + workbox-recipes@7.4.0: + resolution: {integrity: sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==} + + workbox-routing@7.4.0: + resolution: {integrity: sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==} + + workbox-strategies@7.4.0: + resolution: {integrity: sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==} + + workbox-streams@7.4.0: + resolution: {integrity: sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==} + + workbox-sw@7.4.0: + resolution: {integrity: sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==} + + workbox-window@7.4.0: + resolution: {integrity: sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xbytes@1.9.1: + resolution: {integrity: sha512-29E0ygMFWrM5JW2W1ypmezjyR2FOS5aCszdLe620ymSJBoZzL0/RCLJKCoUFu3DfQGhZ/FWykEBp5dfEy6+hjA==} + engines: {node: '>=1'} + + xcase@2.0.1: + resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml-eslint-parser@2.0.0: + resolution: {integrity: sha512-h0uDm97wvT2bokfwwTmY6kJ1hp6YDFL0nRHwNKz8s/VD1FH/vvZjAKoMUE+un0eaYBSG7/c6h+lJTP+31tjgTw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yummies@7.10.0: + resolution: {integrity: sha512-BXMmEYzFr/DtSvr1VMvctPVxQ/BZnx35oY2AGs6clkqgWnflsjqfuVle+8rH2uiMenmEf4I3OH9bRRW86yYyyg==} + peerDependencies: + mobx: ^6.12.4 + react: ^18 || ^19 + peerDependenciesMeta: + mobx: + optional: true + react: + optional: true + + zod-validation-error@3.5.4: + resolution: {integrity: sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.24.4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@anatine/zod-openapi@1.14.2(openapi3-ts@2.0.2)(zod@3.25.76)': + dependencies: + openapi3-ts: 2.0.2 + ts-deepmerge: 6.2.1 + zod: 3.25.76 + + '@angular-devkit/core@19.2.17(chokidar@4.0.3)': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + jsonc-parser: 3.3.1 + picomatch: 4.0.2 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/core@19.2.19(chokidar@4.0.3)': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + jsonc-parser: 3.3.1 + picomatch: 4.0.2 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/schematics-cli@19.2.19(@types/node@24.12.0)(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) + '@inquirer/prompts': 7.3.2(@types/node@24.12.0) + ansi-colors: 4.1.3 + symbol-observable: 4.0.0 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - '@types/node' + - chokidar + + '@angular-devkit/schematics@19.2.17(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.17(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@19.2.19(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@antfu/eslint-config@8.2.0(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5)': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@clack/prompts': 1.3.0 + '@e18e/eslint-plugin': 0.3.0(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint/markdown': 8.0.1 + '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@vitest/eslint-plugin': 1.6.17(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5) + ansis: 4.2.0 + cac: 7.0.0 + eslint: 10.5.0(jiti@2.6.1) + eslint-config-flat-gitignore: 2.3.0(eslint@10.5.0(jiti@2.6.1)) + eslint-flat-config-utils: 3.2.0 + eslint-merge-processors: 2.0.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-antfu: 3.2.2(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-import-lite: 0.6.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-jsdoc: 62.9.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-jsonc: 3.1.2(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-n: 17.24.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-no-only-tests: 3.3.0 + eslint-plugin-perfectionist: 5.9.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-pnpm: 1.6.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-regexp: 3.1.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-toml: 1.3.1(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-unicorn: 64.0.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + eslint-plugin-yml: 3.3.1(eslint@10.5.0(jiti@2.6.1)) + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1)) + globals: 17.6.0 + local-pkg: 1.1.2 + parse-gitignore: 2.0.0 + toml-eslint-parser: 1.0.3 + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + yaml-eslint-parser: 2.0.0 + transitivePeerDependencies: + - '@eslint/json' + - '@typescript-eslint/rule-tester' + - '@typescript-eslint/typescript-estree' + - '@typescript-eslint/utils' + - '@vue/compiler-sfc' + - oxlint + - supports-color + - typescript + - vitest + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.2.4 + + '@antfu/utils@0.7.10': {} + + '@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)': + dependencies: + ajv: 8.20.0 + jsonpointer: 5.0.1 + leven: 3.1.0 + + '@apidevtools/json-schema-ref-parser@14.0.1': + dependencies: + '@types/json-schema': 7.0.15 + js-yaml: 4.2.0 + + '@apidevtools/openapi-schemas@2.1.0': {} + + '@apidevtools/swagger-methods@3.0.2': {} + + '@apidevtools/swagger-parser@12.1.0(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 14.0.1 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@5.5.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + debug: 4.4.3(supports-color@5.5.0) + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-wrap-function': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helper-wrap-function@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + + '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/template': 7.29.7 + + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-dotall-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-keys@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-dynamic-import@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-exponentiation-operator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-member-expression-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-amd@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-systemjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regenerator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regexp-modifiers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-reserved-words@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-spread@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-typeof-symbol@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-property-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/preset-env@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7) + '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7) + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-systemjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.7) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + core-js-compat: 3.49.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/types': 7.29.7 + esutils: 2.0.3 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@biomejs/js-api@4.0.0(@biomejs/wasm-nodejs@2.4.6)': + optionalDependencies: + '@biomejs/wasm-nodejs': 2.4.6 + + '@biomejs/wasm-nodejs@2.4.6': {} + + '@borewit/text-codec@0.2.1': {} + + '@cacheable/memory@2.0.8': + dependencies: + '@cacheable/utils': 2.4.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.4.0': + dependencies: + hashery: 1.5.0 + keyv: 5.6.0 + + '@clack/core@1.3.0': + dependencies: + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@clack/prompts@1.3.0': + dependencies: + '@clack/core': 1.3.0 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@colors/colors@1.5.0': + optional: true + + '@commitlint/cli@20.5.3(@types/node@24.12.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': + dependencies: + '@commitlint/format': 20.5.0 + '@commitlint/lint': 20.5.3 + '@commitlint/load': 20.5.3(@types/node@24.12.0)(typescript@5.9.3) + '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) + '@commitlint/types': 20.5.0 + tinyexec: 1.2.4 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-conventionalcommits: 9.3.1 + + '@commitlint/config-validator@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + ajv: 8.20.0 + + '@commitlint/ensure@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + es-toolkit: 1.48.1 + + '@commitlint/execute-rule@20.0.0': {} + + '@commitlint/format@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + semver: 7.8.5 + + '@commitlint/lint@20.5.3': + dependencies: + '@commitlint/is-ignored': 20.5.0 + '@commitlint/parse': 20.5.0 + '@commitlint/rules': 20.5.3 + '@commitlint/types': 20.5.0 + + '@commitlint/load@20.5.3(@types/node@24.12.0)(typescript@5.9.3)': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/execute-rule': 20.0.0 + '@commitlint/resolve-extends': 20.5.3 + '@commitlint/types': 20.5.0 + cosmiconfig: 9.0.2(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@24.12.0)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3) + es-toolkit: 1.48.1 + is-plain-obj: 4.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@20.4.3': {} + + '@commitlint/parse@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 + + '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': + dependencies: + '@commitlint/top-level': 20.4.3 + '@commitlint/types': 20.5.0 + git-raw-commits: 5.0.1(conventional-commits-parser@6.4.0) + minimist: 1.2.8 + tinyexec: 1.2.4 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@20.5.3': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/types': 20.5.0 + es-toolkit: 1.48.1 + global-directory: 5.0.0 + import-meta-resolve: 4.2.0 + resolve-from: 5.0.0 + + '@commitlint/rules@20.5.3': + dependencies: + '@commitlint/ensure': 20.5.3 + '@commitlint/message': 20.4.3 + '@commitlint/to-lines': 20.0.0 + '@commitlint/types': 20.5.0 + + '@commitlint/to-lines@20.0.0': {} + + '@commitlint/top-level@20.4.3': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@20.5.0': + dependencies: + conventional-commits-parser: 6.4.0 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@2.7.0(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.8.5 + optionalDependencies: + conventional-commits-parser: 6.4.0 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/css-tokenizer@4.0.0': {} + + '@csstools/media-query-list-parser@5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/selector-resolve-nested@4.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@e18e/eslint-plugin@0.3.0(eslint@10.5.0(jiti@2.6.1))': + dependencies: + eslint-plugin-depend: 1.5.0(eslint@10.5.0(jiti@2.6.1)) + optionalDependencies: + eslint: 10.5.0(jiti@2.6.1) + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@es-joy/jsdoccomment@0.84.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.61.1 + comment-parser: 1.4.5 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.1.1 + + '@es-joy/jsdoccomment@0.86.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.61.1 + comment-parser: 1.4.6 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.2.0 + + '@es-joy/resolve.exports@1.2.0': {} + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@eslint-community/eslint-plugin-eslint-comments@4.7.1(eslint@10.5.0(jiti@2.6.1))': + dependencies: + escape-string-regexp: 4.0.0 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.6.1))': + dependencies: + eslint: 10.5.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/compat@2.0.3(eslint@10.5.0(jiti@2.6.1))': + dependencies: + '@eslint/core': 1.2.1 + optionalDependencies: + eslint: 10.5.0(jiti@2.6.1) + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3(supports-color@5.5.0) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/markdown@8.0.1': + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + github-slugger: 2.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-frontmatter: 2.0.1 + mdast-util-gfm: 3.1.0 + mdast-util-math: 3.0.0 + micromark-extension-frontmatter: 2.0.0 + micromark-extension-gfm: 3.0.0 + micromark-extension-math: 3.1.0 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@exodus/schemasafe@1.3.0': {} + + '@faker-js/faker@9.9.0': {} + + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.1.1 + fastify-plugin: 5.1.0 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/formbody@8.0.2': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/helmet@13.0.2': + dependencies: + fastify-plugin: 5.1.0 + helmet: 8.1.0 + + '@fastify/jwt@9.1.0': + dependencies: + '@fastify/error': 4.2.0 + '@lukeed/ms': 2.0.2 + fast-jwt: 5.0.6 + fastify-plugin: 5.1.0 + steed: 1.1.3 + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/otel@0.18.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/session@11.1.1': + dependencies: + fastify-plugin: 5.1.0 + safe-stable-stringify: 2.5.0 + + '@fastify/static@9.1.3': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@fastify/swagger-ui@5.2.6': + dependencies: + '@fastify/static': 9.1.3 + fastify-plugin: 5.1.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + + '@fastify/swagger@9.7.0': + dependencies: + fastify-plugin: 5.1.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + transitivePeerDependencies: + - supports-color + + '@gitbeaker/core@40.6.0': + dependencies: + '@gitbeaker/requester-utils': 40.6.0 + qs: 6.15.2 + xcase: 2.0.1 + + '@gitbeaker/requester-utils@40.6.0': + dependencies: + picomatch-browser: 2.2.6 + qs: 6.15.2 + rate-limiter-flexible: 4.0.1 + xcase: 2.0.1 + + '@gitbeaker/rest@40.6.0': + dependencies: + '@gitbeaker/core': 40.6.0 + '@gitbeaker/requester-utils': 40.6.0 + + '@gouvfr/dsfr@1.14.4': {} + + '@gouvminint/vue-dsfr@8.15.0(@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3)))(vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@gouvfr/dsfr': 1.14.4 + '@iconify/vue': 4.3.0(vue@3.5.30(typescript@5.9.3)) + focus-trap: 7.8.0 + focus-trap-vue: 4.1.0(focus-trap@7.8.0)(vue@3.5.30(typescript@5.9.3)) + vue: 3.5.30(typescript@5.9.3) + vue-router: 4.6.4(vue@3.5.30(typescript@5.9.3)) + + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.4 + yargs: 17.7.3 + + '@himenon/argocd-typescript-openapi@1.2.2': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@iconify-json/ri@1.2.10': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.1 + + '@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@iconify/types': 2.0.0 + vue: 3.5.30(typescript@5.9.3) + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/confirm@5.1.21(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/core@10.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/editor@4.2.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/external-editor': 1.0.3(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/expand@4.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/external-editor@1.0.3(@types/node@24.12.0)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/number@3.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/password@4.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/prompts@7.10.1(@types/node@24.12.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.12.0) + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@inquirer/editor': 4.2.23(@types/node@24.12.0) + '@inquirer/expand': 4.0.23(@types/node@24.12.0) + '@inquirer/input': 4.3.1(@types/node@24.12.0) + '@inquirer/number': 3.0.23(@types/node@24.12.0) + '@inquirer/password': 4.0.23(@types/node@24.12.0) + '@inquirer/rawlist': 4.1.11(@types/node@24.12.0) + '@inquirer/search': 3.2.2(@types/node@24.12.0) + '@inquirer/select': 4.4.2(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/prompts@7.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.12.0) + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@inquirer/editor': 4.2.23(@types/node@24.12.0) + '@inquirer/expand': 4.0.23(@types/node@24.12.0) + '@inquirer/input': 4.3.1(@types/node@24.12.0) + '@inquirer/number': 3.0.23(@types/node@24.12.0) + '@inquirer/password': 4.0.23(@types/node@24.12.0) + '@inquirer/rawlist': 4.1.11(@types/node@24.12.0) + '@inquirer/search': 3.2.2(@types/node@24.12.0) + '@inquirer/select': 4.4.2(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/rawlist@4.1.11(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/search@3.2.2(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/select@4.4.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/type@3.0.10(@types/node@24.12.0)': + optionalDependencies: + '@types/node': 24.12.0 + + '@isaacs/cliui@9.0.0': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@js-sdsl/ordered-map@4.4.2': {} + + '@keycloak/keycloak-admin-client@26.7.0': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + '@microsoft/kiota-http-fetchlibrary': 1.0.0-preview.103 + '@microsoft/kiota-serialization-form': 1.0.0-preview.103 + '@microsoft/kiota-serialization-json': 1.0.0-preview.103 + '@microsoft/kiota-serialization-multipart': 1.0.0-preview.103 + '@microsoft/kiota-serialization-text': 1.0.0-preview.103 + camelize-ts: 3.0.0 + url-template: 3.1.1 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@kubernetes-models/apimachinery@2.2.0': + dependencies: + '@kubernetes-models/base': 5.0.1 + '@kubernetes-models/validate': 4.0.0 + '@swc/helpers': 0.5.19 + + '@kubernetes-models/argo-cd@2.7.2': + dependencies: + '@kubernetes-models/apimachinery': 2.2.0 + '@kubernetes-models/base': 5.0.1 + '@kubernetes-models/validate': 4.0.0 + '@swc/helpers': 0.5.19 + + '@kubernetes-models/base@5.0.1': + dependencies: + '@kubernetes-models/validate': 4.0.0 + is-plain-object: 5.0.0 + tslib: 2.8.1 + + '@kubernetes-models/validate@4.0.0': + dependencies: + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-formats-draft2019: 1.6.1(ajv@8.20.0) + ajv-i18n: 4.2.0(ajv@8.20.0) + is-cidr: 4.0.2 + tslib: 2.8.1 + optionalDependencies: + re2-wasm: 1.0.2 + + '@lukeed/csprng@1.1.0': {} + + '@lukeed/ms@2.0.2': {} + + '@microsoft/kiota-abstractions@1.0.0-preview.103': + dependencies: + '@opentelemetry/api': 1.9.1 + '@std-uritemplate/std-uritemplate': 2.0.12 + tinyduration: 3.4.1 + tslib: 2.8.1 + + '@microsoft/kiota-http-fetchlibrary@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + '@opentelemetry/api': 1.9.1 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-form@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-json@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-multipart@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-text@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/tsdoc@0.16.0': {} + + '@mswjs/interceptors@0.41.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@nestjs/cache-manager@3.1.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cache-manager: 7.2.8 + keyv: 5.6.0 + rxjs: 7.8.2 + + '@nestjs/cli@11.0.16(@types/node@24.12.0)(esbuild@0.27.3)(lightningcss@1.32.0)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics-cli': 19.2.19(@types/node@24.12.0)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@24.12.0) + '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.9.3) + ansis: 4.2.0 + chokidar: 4.0.3 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)) + glob: 13.0.0 + node-emoji: 1.11.0 + ora: 5.4.1 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.9.3 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/html' + - '@types/node' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + - webpack-cli + + '@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.0 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/config@4.0.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.2.3 + dotenv-expand: 12.0.3 + lodash: 4.17.23 + rxjs: 7.8.2 + + '@nestjs/core@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxt/opencollective': 0.4.1 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.3.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + + '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + eventemitter2: 6.4.9 + + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.6 + express: 5.2.1 + multer: 2.1.1 + path-to-regexp: 8.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@nestjs/platform-fastify@11.1.27(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@fastify/cors': 11.2.0 + '@fastify/formbody': 8.0.2 + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-querystring: 1.1.2 + fastify: 5.8.5 + fastify-plugin: 6.0.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + path-to-regexp: 8.4.2 + reusify: 1.1.0 + tslib: 2.8.1 + optionalDependencies: + '@fastify/static': 9.1.3 + + '@nestjs/schedule@6.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 + + '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': + dependencies: + '@angular-devkit/core': 19.2.17(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.17(chokidar@4.0.3) + comment-json: 4.4.1 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.9.3 + transitivePeerDependencies: + - chokidar + + '@nestjs/swagger@11.4.1(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.4 + optionalDependencies: + '@fastify/static': 9.1.3 + + '@nestjs/terminus@11.1.1(@grpc/grpc-js@1.14.3)(@grpc/proto-loader@0.8.0)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + boxen: 5.1.2 + check-disk-space: 3.4.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + optionalDependencies: + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.8.0 + '@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + + '@nestjs/testing@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nuxt/opencollective@0.4.1': + dependencies: + consola: 3.4.2 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@opentelemetry/api-logs@0.212.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.213.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.214.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/auto-instrumentations-node@0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-amqplib': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-lambda': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-sdk': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-bunyan': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cassandra-driver': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-connect': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cucumber': 0.28.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dataloader': 0.29.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dns': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-express': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fastify': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fs': 0.31.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-generic-pool': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-graphql': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-hapi': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-kafkajs': 0.21.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-knex': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-koa': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-lru-memoizer': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-memcached': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongodb': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongoose': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql2': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-net': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-openai': 0.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-oracledb': 0.37.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-redis': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-restify': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-router': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-runtime-node': 0.25.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-socket.io': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-tedious': 0.31.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': 0.22.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-winston': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-alibaba-cloud': 0.33.3(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-aws': 2.13.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-azure': 0.20.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-container': 0.8.4(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-gcp': 0.47.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/configuration@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + yaml: 2.9.0 + + '@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/exporter-logs-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/exporter-trace-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/instrumentation-amqplib@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-lambda@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/aws-lambda': 8.10.161 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-sdk@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-bunyan@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@types/bunyan': 1.8.11 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cassandra-driver@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cucumber@0.28.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.29.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dns@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-express@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fastify@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.31.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.21.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-memcached@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/memcached': 2.2.10 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/mysql': 2.15.27 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-nestjs-core@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-net@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-openai@0.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-oracledb@0.37.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/oracledb': 6.5.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-restify@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-router@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-runtime-node@0.25.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-socket.io@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-tedious@0.31.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.22.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-winston@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + import-in-the-middle: 3.0.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + import-in-the-middle: 3.0.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + protobufjs: 8.0.0 + + '@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + protobufjs: 7.6.4 + + '@opentelemetry/propagator-b3@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/redis-common@0.38.2': {} + + '@opentelemetry/resource-detector-alibaba-cloud@0.33.3(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resource-detector-aws@2.13.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resource-detector-azure@0.20.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resource-detector-container@0.8.4(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resource-detector-gcp@0.47.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + gcp-metadata: 8.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-logs@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-metrics@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/configuration': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-trace-node@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/semantic-conventions@1.41.1': {} + + '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + + '@ota-meshi/ast-token-store@0.3.0': {} + + '@oxc-parser/binding-android-arm-eabi@0.115.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.115.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.115.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.115.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.115.0': + optional: true + + '@oxc-project/types@0.115.0': {} + + '@oxc-project/types@0.128.0': {} + + '@pinojs/redact@0.4.0': {} + + '@pkgr/core@0.2.9': {} + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + '@polka/url@1.0.0-next.29': {} + + '@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@6.19.2(magicast@0.3.5)': + dependencies: + c12: 3.1.0(magicast@0.3.5) + deepmerge-ts: 7.1.5 + effect: 3.18.4 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.2': {} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.2 + '@prisma/get-platform': 6.19.2 + + '@prisma/fetch-engine@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.2 + + '@prisma/get-platform@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.18': {} + + '@rolldown/pluginutils@1.0.0-rc.2': {} + + '@rollup/plugin-babel@5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.80.0)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@rollup/pluginutils': 3.1.0(rollup@2.80.0) + rollup: 2.80.0 + optionalDependencies: + '@types/babel__core': 7.20.5 + transitivePeerDependencies: + - supports-color + + '@rollup/plugin-node-resolve@15.3.1(rollup@2.80.0)': + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.12 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/plugin-replace@2.4.2(rollup@2.80.0)': + dependencies: + '@rollup/pluginutils': 3.1.0(rollup@2.80.0) + magic-string: 0.25.9 + rollup: 2.80.0 + + '@rollup/plugin-terser@0.4.4(rollup@2.80.0)': + dependencies: + serialize-javascript: 6.0.2 + smob: 1.6.2 + terser: 5.48.0 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/pluginutils@3.1.0(rollup@2.80.0)': + dependencies: + '@types/estree': 0.0.39 + estree-walker: 1.0.1 + picomatch: 2.3.2 + rollup: 2.80.0 + + '@rollup/pluginutils@5.4.0(rollup@2.80.0)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@scarf/scarf@1.4.0': {} + + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 + + '@simple-libs/stream-utils@1.2.0': {} + + '@sindresorhus/base62@1.0.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@std-uritemplate/std-uritemplate@2.0.12': {} + + '@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/types': 8.61.1 + eslint: 10.5.0(jiti@2.6.1) + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + estraverse: 5.3.0 + picomatch: 4.0.4 + + '@surma/rollup-plugin-off-main-thread@2.2.3': + dependencies: + ejs: 3.1.10 + json5: 2.2.3 + magic-string: 0.25.9 + string.prototype.matchall: 4.0.12 + + '@swc/helpers@0.5.19': + dependencies: + tslib: 2.8.1 + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3(supports-color@5.5.0) + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76)': + optionalDependencies: + '@types/node': 24.12.0 + zod: 3.25.76 + + '@ts-rest/fastify@3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76)': + dependencies: + '@ts-rest/core': 3.52.1(@types/node@24.12.0)(zod@3.25.76) + fastify: 5.8.5 + optionalDependencies: + zod: 3.25.76 + + '@ts-rest/open-api@3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76)': + dependencies: + '@anatine/zod-openapi': 1.14.2(openapi3-ts@2.0.2)(zod@3.25.76) + '@ts-rest/core': 3.52.1(@types/node@24.12.0)(zod@3.25.76) + openapi3-ts: 2.0.2 + zod: 3.25.76 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aws-lambda@8.10.161': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + optional: true + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + optional: true + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + optional: true + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + optional: true + + '@types/bunyan@1.8.11': + dependencies: + '@types/node': 24.12.0 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.12.0 + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.9 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + + '@types/esrecurse@4.3.1': {} + + '@types/estree@0.0.39': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/jsdom@21.1.7': + dependencies: + '@types/node': 24.12.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.12.0 + + '@types/katex@0.16.8': {} + + '@types/luxon@3.7.1': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/memcached@2.2.10': + dependencies: + '@types/node': 24.12.0 + + '@types/ms@2.1.0': {} + + '@types/mysql@2.15.27': + dependencies: + '@types/node': 24.12.0 + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + '@types/oracledb@6.5.2': + dependencies: + '@types/node': 24.12.0 + + '@types/pg-pool@2.0.7': + dependencies: + '@types/pg': 8.15.6 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 24.12.0 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + + '@types/resolve@1.20.2': {} + + '@types/statuses@2.0.6': {} + + '@types/swagger-schema-official@2.0.25': {} + + '@types/tedious@4.0.14': + dependencies: + '@types/node': 24.12.0 + + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': {} + + '@types/unist@3.0.3': {} + + '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/type-utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.0 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/type-utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.57.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3(supports-color@5.5.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3(supports-color@5.5.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + ajv: 6.15.0 + eslint: 10.5.0(jiti@2.6.1) + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/scope-manager@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + + '@typescript-eslint/scope-manager@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + + '@typescript-eslint/tsconfig-utils@8.57.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.57.0': {} + + '@typescript-eslint/types@8.59.2': {} + + '@typescript-eslint/types@8.61.1': {} + + '@typescript-eslint/typescript-estree@8.57.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.57.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + eslint-visitor-keys: 5.0.1 + + '@typescript-eslint/visitor-keys@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + eslint-visitor-keys: 5.0.1 + + '@unocss/cli@66.6.6': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + '@unocss/preset-wind4': 66.6.6 + '@unocss/transformer-directives': 66.6.6 + cac: 6.7.14 + chokidar: 5.0.0 + colorette: 2.0.20 + consola: 3.4.2 + magic-string: 0.30.21 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + tinyglobby: 0.2.17 + unplugin-utils: 0.3.1 + + '@unocss/config@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + colorette: 2.0.20 + consola: 3.4.2 + unconfig: 7.5.0 + + '@unocss/core@0.65.4': {} + + '@unocss/core@66.6.6': {} + + '@unocss/extractor-arbitrary-variants@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/inspector@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + colorette: 2.0.20 + gzip-size: 6.0.0 + sirv: 3.0.2 + + '@unocss/preset-attributify@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/preset-icons@66.6.6': + dependencies: + '@iconify/utils': 3.1.0 + '@unocss/core': 66.6.6 + ofetch: 1.5.1 + + '@unocss/preset-mini@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/extractor-arbitrary-variants': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-tagify@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/preset-typography@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-uno@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + + '@unocss/preset-web-fonts@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + ofetch: 1.5.1 + + '@unocss/preset-wind3@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-mini': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-wind4@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/extractor-arbitrary-variants': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-wind@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + + '@unocss/rule-utils@0.65.4': + dependencies: + '@unocss/core': 0.65.4 + magic-string: 0.30.21 + + '@unocss/rule-utils@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + magic-string: 0.30.21 + + '@unocss/transformer-attributify-jsx@66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@unocss/core': 66.6.6 + oxc-parser: 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + oxc-walker: 0.7.0(oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@unocss/transformer-compile-class@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/transformer-directives@0.65.4': + dependencies: + '@unocss/core': 0.65.4 + '@unocss/rule-utils': 0.65.4 + css-tree: 3.2.1 + + '@unocss/transformer-directives@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + css-tree: 3.2.1 + + '@unocss/transformer-variant-group@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/vite@66.6.6(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/inspector': 66.6.6 + chokidar: 5.0.0 + magic-string: 0.30.21 + pathe: 2.0.3 + tinyglobby: 0.2.17 + unplugin-utils: 0.3.1 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + + '@vitejs/plugin-vue@6.0.4(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vue: 3.5.30(typescript@5.9.3) + + '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.5 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + '@vitest/eslint-plugin@1.6.17(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + typescript: 5.9.3 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + + '@vitest/mocker@4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.30': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.30 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.30': + dependencies: + '@vue/compiler-core': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-sfc@3.5.30': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.30 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.30': + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + fast-glob: 3.3.3 + typescript-eslint: 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.30 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.30': + dependencies: + '@vue/shared': 3.5.30 + + '@vue/runtime-core@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/runtime-dom@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/runtime-core': 3.5.30 + '@vue/shared': 3.5.30 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + vue: 3.5.30(typescript@5.9.3) + + '@vue/shared@3.5.30': {} + + '@vue/tsconfig@0.7.0(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))': + optionalDependencies: + typescript: 5.9.3 + vue: 3.5.30(typescript@5.9.3) + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + abstract-logging@2.0.1: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + optional: true + + acorn-import-attributes@1.9.5(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-import-phases@1.0.4(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + agent-base@7.1.4: {} + + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats-draft2019@1.6.1(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + punycode: 2.3.1 + schemes: 1.4.0 + smtp-address-parser: 1.1.0 + uri-js: 4.4.1 + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-i18n@4.2.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv-keywords@3.5.2(ajv@6.15.0): + dependencies: + ajv: 6.15.0 + + ajv-keywords@5.1.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + fast-deep-equal: 3.1.3 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alien-signals@1.0.13: {} + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-colors@4.1.3: {} + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + ansis@4.2.0: {} + + any-base@1.1.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + append-field@1.0.0: + optional: true + + are-docs-informative@0.0.2: {} + + argparse@2.0.1: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-ify@1.0.0: {} + + array-timsort@1.0.3: {} + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + astral-regex@2.0.0: {} + + async-function@1.0.0: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + atomic-sleep@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + axios-retry@4.5.0(axios@1.16.0): + dependencies: + axios: 1.16.0 + is-retry-allowed: 2.2.0 + + axios@1.16.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.38: {} + + bignumber.js@9.3.1: {} + + binary-extensions@2.3.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bn.js@4.12.3: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + optional: true + + boolbase@1.0.0: {} + + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + brorand@1.1.0: + optional: true + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.38 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.376 + node-releases: 2.0.48 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builtin-modules@5.0.0: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + optional: true + + bytes@3.1.2: {} + + c12@3.1.0(magicast@0.3.5): + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.3.5 + + c12@3.3.3(magicast@0.5.3): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 17.3.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.5.3 + + cac@6.7.14: {} + + cac@7.0.0: {} + + cache-manager@7.2.8: + dependencies: + '@cacheable/utils': 2.4.0 + keyv: 5.6.0 + + cacheable@2.3.3: + dependencies: + '@cacheable/memory': 2.0.8 + '@cacheable/utils': 2.4.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.6.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + call-me-maybe@1.0.2: {} + + callsites@3.1.0: {} + + camelcase@6.3.0: {} + + camelize-ts@3.0.0: {} + + caniuse-lite@1.0.30001799: {} + + ccount@2.0.1: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + change-case@5.4.4: {} + + character-entities@2.0.2: {} + + chardet@2.1.1: {} + + check-disk-space@3.4.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chrome-trace-event@1.0.4: {} + + ci-info@4.4.0: {} + + cidr-regex@3.1.1: + dependencies: + ip-regex: 4.3.0 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.1: {} + + cjs-module-lexer@2.2.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clean-regexp@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + + cli-boxes@2.2.1: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.1 + + cli-width@4.1.0: {} + + clipboard@2.0.11: + dependencies: + good-listener: 1.2.2 + select: 1.1.2 + tiny-emitter: 2.1.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@14.0.3: {} + + commander@2.20.3: {} + + commander@4.1.1: {} + + commander@8.3.0: {} + + comment-json@4.4.1: + dependencies: + array-timsort: 1.0.3 + core-util-is: 1.0.3 + esprima: 4.0.1 + + comment-parser@1.4.5: {} + + comment-parser@1.4.6: {} + + common-tags@1.8.2: {} + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + optional: true + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + consola@3.4.2: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: + optional: true + + content-type@2.0.0: + optional: true + + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: + optional: true + + cookie@0.7.2: + optional: true + + cookie@1.1.1: {} + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.2 + + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + optional: true + + cosmiconfig-typescript-loader@6.3.0(@types/node@24.12.0)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3): + dependencies: + '@types/node': 24.12.0 + cosmiconfig: 9.0.2(typescript@5.9.3) + jiti: 2.6.1 + typescript: 5.9.3 + + cosmiconfig@8.3.6(typescript@5.9.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.2.0 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.3 + + cosmiconfig@9.0.2(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + cron-validator@1.4.0: {} + + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypto-random-string@2.0.0: {} + + css-functions-list@3.3.3: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-uri-to-buffer@4.0.1: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + date-fns@4.1.0: {} + + dateformat@4.6.3: {} + + dayjs@1.11.19: {} + + de-indent@1.0.2: {} + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + decimal.js@10.6.0: {} + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + deeks@3.1.0: {} + + deep-is@0.1.4: {} + + deepmerge-ts@7.1.5: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + defu@6.1.4: {} + + delayed-stream@1.0.0: {} + + delegate@3.2.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff-sequences@29.6.3: {} + + discontinuous-range@1.0.0: {} + + doc-path@4.1.1: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + dompurify@3.3.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.2.3: {} + + dotenv@17.3.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer@0.1.2: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: + optional: true + + effect@3.18.4: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-to-chromium@1.5.376: {} + + elliptic@6.6.1: + dependencies: + bn.js: 4.12.3 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + optional: true + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + empathic@2.0.0: {} + + encodeurl@2.0.0: + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.24.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + env-paths@2.2.1: {} + + environment@1.1.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.1 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.22 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.1.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-to-primitive@1.3.1: + dependencies: + es-abstract-get: 1.0.0 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es-toolkit@1.48.1: {} + + es6-promise@3.3.1: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-compat-utils@0.5.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + semver: 7.8.5 + + eslint-config-flat-gitignore@2.3.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/compat': 2.0.3(eslint@10.5.0(jiti@2.6.1)) + eslint: 10.5.0(jiti@2.6.1) + + eslint-flat-config-utils@3.2.0: + dependencies: + '@eslint/config-helpers': 0.5.5 + pathe: 2.0.3 + + eslint-json-compat-utils@0.2.3(eslint@10.5.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + esquery: 1.7.0 + jsonc-eslint-parser: 3.1.0 + + eslint-merge-processors@2.0.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-antfu@3.2.2(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-command@3.5.2(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@es-joy/jsdoccomment': 0.84.0 + '@typescript-eslint/rule-tester': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-depend@1.5.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + empathic: 2.0.0 + eslint: 10.5.0(jiti@2.6.1) + module-replacements: 2.11.0 + semver: 7.8.5 + + eslint-plugin-es-x@7.8.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + eslint: 10.5.0(jiti@2.6.1) + eslint-compat-utils: 0.5.1(eslint@10.5.0(jiti@2.6.1)) + + eslint-plugin-import-lite@0.6.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-jsdoc@62.9.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@es-joy/jsdoccomment': 0.86.0 + '@es-joy/resolve.exports': 1.2.0 + are-docs-informative: 0.0.2 + comment-parser: 1.4.6 + debug: 4.4.3(supports-color@5.5.0) + escape-string-regexp: 4.0.0 + eslint: 10.5.0(jiti@2.6.1) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.0 + parse-imports-exports: 0.2.4 + semver: 7.8.5 + spdx-expression-parse: 4.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color + + eslint-plugin-jsonc@3.1.2(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + diff-sequences: 29.6.3 + eslint: 10.5.0(jiti@2.6.1) + eslint-json-compat-utils: 0.2.3(eslint@10.5.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0) + jsonc-eslint-parser: 3.1.0 + natural-compare: 1.4.0 + synckit: 0.11.12 + transitivePeerDependencies: + - '@eslint/json' + + eslint-plugin-n@17.24.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + enhanced-resolve: 5.24.0 + eslint: 10.5.0(jiti@2.6.1) + eslint-plugin-es-x: 7.8.0(eslint@10.5.0(jiti@2.6.1)) + get-tsconfig: 4.13.6 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.8.5 + ts-declaration-location: 1.0.7(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + eslint-plugin-no-only-tests@3.3.0: {} + + eslint-plugin-perfectionist@5.9.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + natural-orderby: 5.0.0 + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-pnpm@1.6.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + empathic: 2.0.0 + eslint: 10.5.0(jiti@2.6.1) + jsonc-eslint-parser: 3.1.0 + pathe: 2.0.3 + pnpm-workspace-yaml: 1.6.0 + tinyglobby: 0.2.17 + yaml: 2.9.0 + yaml-eslint-parser: 2.0.0 + + eslint-plugin-regexp@3.1.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + comment-parser: 1.4.6 + eslint: 10.5.0(jiti@2.6.1) + jsdoc-type-pratt-parser: 7.2.0 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + scslre: 0.3.0 + + eslint-plugin-toml@1.3.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + toml-eslint-parser: 1.0.3 + transitivePeerDependencies: + - supports-color + + eslint-plugin-unicorn@64.0.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + change-case: 5.4.4 + ci-info: 4.4.0 + clean-regexp: 1.0.0 + core-js-compat: 3.49.0 + eslint: 10.5.0(jiti@2.6.1) + find-up-simple: 1.0.1 + globals: 17.6.0 + indent-string: 5.0.0 + is-builtin-module: 5.0.0 + jsesc: 3.1.0 + pluralize: 8.0.0 + regexp-tree: 0.1.27 + regjsparser: 0.13.2 + semver: 7.8.5 + strip-indent: 4.1.1 + + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + + eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + eslint: 10.5.0(jiti@2.6.1) + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.1 + semver: 7.8.5 + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + xml-name-validator: 4.0.0 + optionalDependencies: + '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + + eslint-plugin-yml@3.3.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + debug: 4.4.3(supports-color@5.5.0) + diff-sequences: 29.6.3 + escape-string-regexp: 5.0.0 + eslint: 10.5.0(jiti@2.6.1) + natural-compare: 1.4.0 + yaml-eslint-parser: 2.0.0 + transitivePeerDependencies: + - supports-color + + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@vue/compiler-sfc': 3.5.30 + eslint: 10.5.0(jiti@2.6.1) + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.5.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@5.5.0) + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-walker@1.0.1: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + eta@3.5.0: {} + + etag@1.8.1: + optional: true + + eventemitter2@6.4.9: {} + + eventemitter3@5.0.4: {} + + events@3.3.0: {} + + expect-type@1.3.0: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + optional: true + + exsolve@1.0.8: {} + + extend@3.0.2: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-copy@4.0.2: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-jwt@5.0.6: + dependencies: + '@lukeed/ms': 2.0.2 + asn1.js: 5.4.1 + ecdsa-sig-formatter: 1.0.11 + mnemonist: 0.40.4 + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.2: {} + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + + fastest-levenshtein@1.0.16: {} + + fastfall@1.5.1: + dependencies: + reusify: 1.1.0 + + fastify-keycloak-adapter@3.0.2: + dependencies: + '@fastify/cookie': 11.0.2 + '@fastify/jwt': 9.1.0 + '@fastify/session': 11.1.1 + axios: 1.16.0 + axios-retry: 4.5.0(axios@1.16.0) + fastify-plugin: 5.1.0 + fp-ts: 2.16.11 + grant: 5.4.24 + io-ts: 2.2.22(fp-ts@2.16.11) + qs: 6.15.2 + wildcard-match: 5.1.4 + transitivePeerDependencies: + - debug + + fastify-plugin@5.1.0: {} + + fastify-plugin@6.0.0: {} + + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 9.14.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.0 + + fastparallel@2.4.1: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fastseries@1.7.2: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + + fault@2.0.1: + dependencies: + format: 0.2.2 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + file-entry-cache@11.1.2: + dependencies: + flat-cache: 6.1.20 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-type@21.3.0: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.4 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + optional: true + + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + find-up-simple@1.0.1: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flat-cache@6.1.20: + dependencies: + cacheable: 2.3.3 + flatted: 3.4.2 + hookified: 1.15.1 + + flatted@3.4.2: {} + + focus-trap-vue@4.1.0(focus-trap@7.8.0)(vue@3.5.30(typescript@5.9.3)): + dependencies: + focus-trap: 7.8.0 + vue: 3.5.30(typescript@5.9.3) + + focus-trap@7.8.0: + dependencies: + tabbable: 6.4.0 + + follow-redirects@1.16.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)): + dependencies: + '@babel/code-frame': 7.29.7 + chalk: 4.1.2 + chokidar: 4.0.3 + cosmiconfig: 8.3.6(typescript@5.9.3) + deepmerge: 4.3.1 + fs-extra: 10.1.0 + memfs: 3.5.3 + minimatch: 3.1.5 + node-abort-controller: 3.1.1 + schema-utils: 3.3.0 + semver: 7.8.5 + tapable: 2.3.3 + typescript: 5.9.3 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + format@0.2.2: {} + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded-parse@2.1.2: {} + + forwarded@0.2.0: + optional: true + + fp-ts@2.16.11: {} + + fresh@2.0.0: + optional: true + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-monkey@1.1.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-own-enumerable-property-symbols@3.0.2: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.5 + pathe: 2.0.3 + + git-raw-commits@5.0.1(conventional-commits-parser@6.4.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + github-slugger@2.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + glob@13.0.0: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + global-directory@5.0.0: + dependencies: + ini: 6.0.0 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + global-prefix@4.0.0: + dependencies: + ini: 4.1.3 + kind-of: 6.0.3 + which: 4.0.0 + + globals@14.0.0: {} + + globals@15.15.0: {} + + globals@16.5.0: {} + + globals@17.6.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@16.2.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + + globjoin@0.1.4: {} + + globrex@0.1.2: {} + + good-listener@1.2.2: + dependencies: + delegate: 3.2.0 + + google-logging-utils@1.1.3: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + grant@5.4.24: + dependencies: + qs: 6.15.2 + request-compose: 2.1.7 + request-oauth: 1.0.1 + optionalDependencies: + cookie: 0.7.2 + cookie-signature: 1.2.2 + jwk-to-pem: 2.0.7 + jws: 4.0.1 + + graphql@16.14.2: {} + + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + + has-bigints@1.1.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-flag@5.0.1: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + optional: true + + hashery@1.5.0: + dependencies: + hookified: 1.15.1 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + headers-polyfill@4.0.3: {} + + helmet@8.1.0: {} + + help-me@5.0.0: {} + + hmac-drbg@1.0.1: + dependencies: + hash.js: 1.1.7 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + optional: true + + hookified@1.15.1: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + html-entities@2.6.0: {} + + html-escaper@2.0.2: {} + + html-tags@5.1.0: {} + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + http2-client@1.3.5: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + husky@9.1.7: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + idb@7.1.1: {} + + ieee754@1.2.1: {} + + ignore-by-default@1.0.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immediate@3.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-in-the-middle@2.0.6: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + import-in-the-middle@3.0.0: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + import-meta-resolve@4.2.0: {} + + imurmurhash@0.1.4: {} + + indent-string@5.0.0: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ini@4.1.3: {} + + ini@6.0.0: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + + io-ts@2.2.22(fp-ts@2.16.11): + dependencies: + fp-ts: 2.16.11 + + ip-regex@4.3.0: {} + + ipaddr.js@1.9.1: + optional: true + + ipaddr.js@2.4.0: {} + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-builtin-module@5.0.0: + dependencies: + builtin-modules: 5.0.0 + + is-callable@1.2.7: {} + + is-cidr@4.0.2: + dependencies: + cidr-regex: 3.1.1 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-map@2.0.3: {} + + is-module@1.0.0: {} + + is-negative-zero@2.0.3: {} + + is-node-process@1.2.0: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-obj@1.0.1: {} + + is-obj@2.0.0: {} + + is-path-inside@4.0.0: {} + + is-plain-obj@4.1.0: {} + + is-plain-object@5.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: + optional: true + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-regexp@1.0.0: {} + + is-retry-allowed@2.2.0: {} + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-unicode-supported@0.1.0: {} + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterare@1.2.1: {} + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + javascript-time-ago@2.6.4: + dependencies: + relative-time-format: 1.1.12 + + jest-worker@27.5.1: + dependencies: + '@types/node': 24.12.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jiti@2.6.1: {} + + joycon@3.1.1: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + jsdoc-type-pratt-parser@7.1.1: {} + + jsdoc-type-pratt-parser@7.2.0: {} + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-2-csv@5.5.10: + dependencies: + deeks: 3.1.0 + doc-path: 4.1.1 + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + fast-uri: 3.1.2 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema@0.4.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-eslint-parser@3.1.0: + dependencies: + acorn: 8.17.0 + eslint-visitor-keys: 5.0.1 + semver: 7.8.5 + + jsonc-parser@3.3.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonpointer@5.0.1: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwk-to-pem@2.0.7: + dependencies: + asn1.js: 5.4.1 + elliptic: 6.6.1 + safe-buffer: 5.2.1 + optional: true + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + katex@0.16.45: + dependencies: + commander: 8.3.0 + + keycloak-js@26.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.2.4 + yaml: 2.9.0 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + load-esm@1.0.3: {} + + loader-runner@4.3.2: {} + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.1 + pkg-types: 1.3.1 + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.1 + pkg-types: 2.3.0 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.debounce@4.0.8: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash.sortby@4.7.0: {} + + lodash.truncate@4.4.2: {} + + lodash@4.17.23: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + long@5.3.2: {} + + longest-streak@3.1.0: {} + + lru-cache@10.4.3: {} + + lru-cache@11.5.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + luxon@3.7.2: {} + + magic-regexp@0.10.0: + dependencies: + estree-walker: 3.0.3 + magic-string: 0.30.21 + mlly: 1.8.1 + regexp-tree: 0.1.27 + type-level-regexp: 0.1.17 + ufo: 1.6.3 + unplugin: 2.3.11 + + magic-string@0.25.9: + dependencies: + sourcemap-codec: 1.4.8 + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + optional: true + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + markdown-table@3.0.4: {} + + math-intrinsics@1.1.0: {} + + mathml-tag-names@4.0.0: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.27.1: {} + + media-typer@0.3.0: + optional: true + + media-typer@1.1.0: + optional: true + + memfs@3.5.3: + dependencies: + fs-monkey: 1.1.0 + + meow@13.2.0: {} + + meow@14.1.0: {} + + merge-descriptors@2.0.0: + optional: true + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.45 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3(supports-color@5.5.0) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: + optional: true + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + optional: true + + mime@3.0.0: {} + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimalistic-assert@1.0.1: {} + + minimalistic-crypto-utils@1.0.1: + optional: true + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.1 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mlly@1.8.1: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + mnemonist@0.40.4: + dependencies: + obliterator: 2.0.5 + + module-details-from-path@1.0.4: {} + + module-replacements@2.11.0: {} + + moo@0.5.3: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.7.0 + until-async: 3.0.2 + yargs: 17.7.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + muggle-string@0.4.1: {} + + multer@2.1.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + optional: true + + mustache@4.2.0: {} + + mute-stream@2.0.0: {} + + nanoid@3.3.15: {} + + nanoid@5.0.9: {} + + nanoid@5.1.6: {} + + natural-compare@1.4.0: {} + + natural-orderby@5.0.0: {} + + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.3 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + + negotiator@1.0.0: + optional: true + + neo-async@2.6.2: {} + + nestjs-pino@4.6.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2): + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + pino: 10.3.1 + pino-http: 11.0.0 + rxjs: 7.8.2 + + node-abort-controller@3.1.1: {} + + node-domexception@1.0.0: {} + + node-emoji@1.11.0: + dependencies: + lodash: 4.18.1 + + node-fetch-h2@2.3.0: + dependencies: + http2-client: 1.3.5 + + node-fetch-native@1.6.7: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-readfiles@0.2.0: + dependencies: + es6-promise: 3.3.1 + + node-releases@2.0.48: {} + + nodemon@3.1.14: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 10.2.5 + pstree.remy: 1.1.8 + semver: 7.8.5 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nwsapi@2.2.24: {} + + nypm@0.6.5: + dependencies: + citty: 0.2.1 + pathe: 2.0.3 + tinyexec: 1.2.4 + + oas-kit-common@1.0.8: + dependencies: + fast-safe-stringify: 2.1.1 + + oas-linter@3.2.2: + dependencies: + '@exodus/schemasafe': 1.3.0 + should: 13.2.3 + yaml: 1.10.3 + + oas-resolver@2.5.6: + dependencies: + node-fetch-h2: 2.3.0 + oas-kit-common: 1.0.8 + reftools: 1.1.9 + yaml: 1.10.3 + yargs: 17.7.3 + + oas-schema-walker@1.1.5: {} + + oas-validator@5.0.8: + dependencies: + call-me-maybe: 1.0.2 + oas-kit-common: 1.0.8 + oas-linter: 3.2.2 + oas-resolver: 2.5.6 + oas-schema-walker: 1.1.5 + reftools: 1.1.9 + should: 13.2.3 + yaml: 1.10.3 + + oauth-sign@0.9.0: {} + + object-assign@4.1.1: + optional: true + + object-deep-merge@2.0.0: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + obliterator@2.0.5: {} + + obug@2.1.3: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.3 + + ohash@2.0.11: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + optional: true + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + openapi-types@12.1.3: {} + + openapi3-ts@2.0.2: + dependencies: + yaml: 1.10.3 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + outvariant@1.4.3: {} + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + dependencies: + '@oxc-project/types': 0.115.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.115.0 + '@oxc-parser/binding-android-arm64': 0.115.0 + '@oxc-parser/binding-darwin-arm64': 0.115.0 + '@oxc-parser/binding-darwin-x64': 0.115.0 + '@oxc-parser/binding-freebsd-x64': 0.115.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.115.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.115.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.115.0 + '@oxc-parser/binding-linux-arm64-musl': 0.115.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.115.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.115.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.115.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.115.0 + '@oxc-parser/binding-linux-x64-gnu': 0.115.0 + '@oxc-parser/binding-linux-x64-musl': 0.115.0 + '@oxc-parser/binding-openharmony-arm64': 0.115.0 + '@oxc-parser/binding-wasm32-wasi': 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@oxc-parser/binding-win32-arm64-msvc': 0.115.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.115.0 + '@oxc-parser/binding-win32-x64-msvc': 0.115.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + oxc-walker@0.7.0(oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)): + dependencies: + magic-regexp: 0.10.0 + oxc-parser: 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + + p-debounce@4.0.0: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + package-manager-detector@1.6.0: {} + + pako@1.0.11: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-gitignore@2.0.0: {} + + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-statements@1.0.11: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: + optional: true + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + + path-to-regexp@6.3.0: {} + + path-to-regexp@8.3.0: {} + + path-to-regexp@8.4.2: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + pg-int8@1.0.1: {} + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + picocolors@1.1.1: {} + + picomatch-browser@2.2.6: {} + + picomatch@2.3.2: {} + + picomatch@4.0.2: {} + + picomatch@4.0.4: {} + + pinia@2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.30(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@vue/composition-api' + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-http@11.0.0: + dependencies: + get-caller-file: 2.0.5 + pino: 10.3.1 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.1.0 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.1 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + + pluralize@8.0.0: {} + + pnpm-workspace-yaml@1.6.0: + dependencies: + yaml: 2.9.0 + + possible-typed-array-names@1.1.0: {} + + postcss-html@1.8.1: + dependencies: + htmlparser2: 8.0.2 + js-tokens: 9.0.1 + postcss: 8.5.15 + postcss-safe-parser: 6.0.0(postcss@8.5.15) + + postcss-safe-parser@6.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-safe-parser@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prelude-ls@1.2.1: {} + + pretty-bytes@5.6.0: {} + + pretty-bytes@6.1.1: {} + + prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.2(magicast@0.3.5) + '@prisma/engines': 6.19.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + process-nextick-args@2.0.1: {} + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + protobufjs@7.6.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 24.12.0 + long: 5.3.2 + + protobufjs@8.0.0: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 24.12.0 + long: 5.3.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + optional: true + + proxy-from-env@2.1.0: {} + + pstree.remy@1.1.8: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + qified@0.6.0: + dependencies: + hookified: 1.15.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + + quansync@0.2.11: {} + + quansync@1.0.0: {} + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + range-parser@1.2.1: + optional: true + + rate-limiter-flexible@4.0.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + optional: true + + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + + re2-wasm@1.0.2: + optional: true + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + readdirp@4.1.2: {} + + readdirp@5.0.0: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + refa@0.12.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + + reflect-metadata@0.2.2: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + reftools@1.1.9: {} + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regexp-ast-analysis@0.7.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + + regexp-tree@0.1.27: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.2 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 + + relative-time-format@1.1.12: {} + + request-compose@2.1.7: {} + + request-oauth@1.0.1: + dependencies: + oauth-sign: 0.9.0 + qs: 6.15.2 + uuid: 8.3.2 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + + reserved-identifiers@1.2.0: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + ret@0.1.15: {} + + ret@0.5.0: {} + + rettime@0.10.1: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + + rolldown@1.0.0-rc.18: + dependencies: + '@oxc-project/types': 0.128.0 + '@rolldown/pluginutils': 1.0.0-rc.18 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.18 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.18 + '@rolldown/binding-darwin-x64': 1.0.0-rc.18 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.18 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.18 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.18 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.18 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.18 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.18 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.18 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.18 + + rollup@2.80.0: + optionalDependencies: + fsevents: 2.3.3 + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + optional: true + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.15.0 + ajv-keywords: 3.5.2(ajv@6.15.0) + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-keywords: 5.1.0(ajv@8.20.0) + + schemes@1.4.0: + dependencies: + extend: 3.0.2 + + scslre@0.3.0: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + + scule@1.3.0: {} + + secure-json-parse@4.1.0: {} + + select@1.1.2: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + optional: true + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + optional: true + + set-cookie-parser@2.7.2: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + short-uuid@5.2.0: + dependencies: + any-base: 1.1.0 + uuid: 9.0.1 + + should-equal@2.0.0: + dependencies: + should-type: 1.4.0 + + should-format@3.0.3: + dependencies: + should-type: 1.4.0 + should-type-adaptors: 1.1.0 + + should-type-adaptors@1.1.0: + dependencies: + should-type: 1.4.0 + should-util: 1.0.1 + + should-type@1.4.0: {} + + should-util@1.0.1: {} + + should@13.2.3: + dependencies: + should-equal: 2.0.0 + should-format: 3.0.3 + should-type: 1.4.0 + should-type-adaptors: 1.1.0 + should-util: 1.0.1 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.5 + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sisteransi@1.0.5: {} + + slash@5.1.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + smob@1.6.2: {} + + smtp-address-parser@1.1.0: + dependencies: + nearley: 2.20.1 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.4: {} + + source-map@0.8.0-beta.0: + dependencies: + whatwg-url: 7.1.0 + + sourcemap-codec@1.4.8: {} + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@4.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.1.0: {} + + steed@1.1.3: + dependencies: + fastfall: 1.5.1 + fastparallel: 2.4.1 + fastq: 1.20.1 + fastseries: 1.7.2 + reusify: 1.1.0 + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + streamsearch@1.1.0: + optional: true + + strict-event-emitter@0.5.1: {} + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-object@3.3.0: + dependencies: + get-own-enumerable-property-symbols: 3.0.2 + is-obj: 1.0.1 + is-regexp: 1.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-comments@2.0.1: {} + + strip-indent@4.1.1: {} + + strip-json-comments@3.1.1: {} + + strip-json-comments@5.0.3: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + strtok3@10.3.4: + dependencies: + '@tokenizer/token': 0.3.0 + + stylelint-config-html@1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + postcss-html: 1.8.1 + stylelint: 17.11.0(typescript@5.9.3) + + stylelint-config-recommended-vue@1.6.1(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + postcss-html: 1.8.1 + semver: 7.8.5 + stylelint: 17.11.0(typescript@5.9.3) + stylelint-config-html: 1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-recommended: 18.0.0(stylelint@17.11.0(typescript@5.9.3)) + + stylelint-config-recommended@18.0.0(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + stylelint: 17.11.0(typescript@5.9.3) + + stylelint-config-standard@40.0.0(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + stylelint: 17.11.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.11.0(typescript@5.9.3)) + + stylelint@17.11.0(typescript@5.9.3): + dependencies: + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) + colord: 2.9.3 + cosmiconfig: 9.0.2(typescript@5.9.3) + css-functions-list: 3.3.3 + css-tree: 3.2.1 + debug: 4.4.3(supports-color@5.5.0) + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.2 + global-modules: 2.0.0 + globby: 16.2.0 + globjoin: 0.1.4 + html-tags: 5.1.0 + ignore: 7.0.5 + import-meta-resolve: 4.2.0 + is-plain-object: 5.0.0 + mathml-tag-names: 4.0.0 + meow: 14.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-safe-parser: 7.0.1(postcss@8.5.15) + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + string-width: 8.2.1 + supports-hyperlinks: 4.4.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 7.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@10.2.2: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@4.4.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-tags@1.0.0: {} + + swagger-schema-official@2.0.0-bab6bed: {} + + swagger-typescript-api@13.3.1(magicast@0.5.3): + dependencies: + '@apidevtools/swagger-parser': 12.1.0(openapi-types@12.1.3) + '@biomejs/js-api': 4.0.0(@biomejs/wasm-nodejs@2.4.6) + '@biomejs/wasm-nodejs': 2.4.6 + '@types/swagger-schema-official': 2.0.25 + c12: 3.3.3(magicast@0.5.3) + citty: 0.2.1 + consola: 3.4.2 + es-toolkit: 1.48.1 + eta: 3.5.0 + nanoid: 5.1.6 + openapi-types: 12.1.3 + swagger-schema-official: 2.0.0-bab6bed + swagger2openapi: 7.0.8 + type-fest: 5.7.0 + typescript: 5.9.3 + yaml: 2.9.0 + yummies: 7.10.0 + transitivePeerDependencies: + - '@biomejs/wasm-bundler' + - '@biomejs/wasm-web' + - encoding + - magicast + - mobx + - react + + swagger-ui-dist@5.32.4: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger2openapi@7.0.8: + dependencies: + call-me-maybe: 1.0.2 + node-fetch: 2.7.0 + node-fetch-h2: 2.3.0 + node-readfiles: 0.2.0 + oas-kit-common: 1.0.8 + oas-resolver: 2.5.6 + oas-schema-walker: 1.1.5 + oas-validator: 5.0.8 + reftools: 1.1.9 + yaml: 1.10.3 + yargs: 17.7.3 + transitivePeerDependencies: + - encoding + + symbol-observable@4.0.0: {} + + symbol-tree@3.2.4: {} + + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + + tabbable@6.4.0: {} + + table@6.9.0: + dependencies: + ajv: 8.20.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tagged-tag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tapable@2.3.3: {} + + temp-dir@2.0.0: {} + + tempy@0.6.0: + dependencies: + is-stream: 2.0.1 + temp-dir: 2.0.0 + type-fest: 0.16.0 + unique-string: 2.0.0 + + terser-webpack-plugin@5.6.1(esbuild@0.27.3)(lightningcss@1.32.0)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.48.0 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + optionalDependencies: + esbuild: 0.27.3 + lightningcss: 1.32.0 + + terser@5.48.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.17.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tiny-emitter@2.1.0: {} + + tinybench@2.9.0: {} + + tinyduration@3.4.1: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tldts-core@6.1.86: {} + + tldts-core@7.4.3: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tldts@7.4.3: + dependencies: + tldts-core: 7.4.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + + toad-cache@3.7.0: {} + + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.1 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + toml-eslint-parser@1.0.3: + dependencies: + eslint-visitor-keys: 5.0.1 + + totalist@3.0.1: {} + + touch@3.1.1: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.3 + + tr46@0.0.3: {} + + tr46@1.0.1: + dependencies: + punycode: 2.3.1 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-declaration-location@1.0.7(typescript@5.9.3): + dependencies: + picomatch: 4.0.4 + typescript: 5.9.3 + + ts-deepmerge@6.2.1: {} + + ts-essentials@10.1.1(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + ts-patch@3.3.0: + dependencies: + chalk: 4.1.2 + global-prefix: 4.0.0 + minimist: 1.2.8 + resolve: 1.22.12 + semver: 7.8.5 + strip-ansi: 6.0.1 + + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.24.0 + tapable: 2.3.3 + tsconfig-paths: 4.2.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.16.0: {} + + type-fest@0.20.2: {} + + type-fest@5.7.0: + dependencies: + tagged-tag: 1.0.0 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + optional: true + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + optional: true + + type-level-regexp@0.1.17: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typedarray@0.0.6: + optional: true + + typescript-eslint@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript-transform-paths@3.5.6(typescript@5.9.3): + dependencies: + minimatch: 9.0.9 + typescript: 5.9.3 + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + unconfig@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + defu: 6.1.4 + jiti: 2.6.1 + quansync: 1.0.0 + unconfig-core: 7.5.0 + + undefsafe@2.0.5: {} + + undici-types@7.16.0: {} + + undici@7.24.5: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + unicorn-magic@0.4.0: {} + + unimport@3.14.6(rollup@2.80.0): + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + acorn: 8.17.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + fast-glob: 3.3.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.1 + pathe: 2.0.3 + picomatch: 4.0.4 + pkg-types: 1.3.1 + scule: 1.3.0 + strip-literal: 2.1.1 + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + + unique-string@2.0.0: + dependencies: + crypto-random-string: 2.0.0 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universalify@2.0.1: {} + + unocss@66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@unocss/cli': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/preset-attributify': 66.6.6 + '@unocss/preset-icons': 66.6.6 + '@unocss/preset-mini': 66.6.6 + '@unocss/preset-tagify': 66.6.6 + '@unocss/preset-typography': 66.6.6 + '@unocss/preset-uno': 66.6.6 + '@unocss/preset-web-fonts': 66.6.6 + '@unocss/preset-wind': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + '@unocss/preset-wind4': 66.6.6 + '@unocss/transformer-attributify-jsx': 66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@unocss/transformer-compile-class': 66.6.6 + '@unocss/transformer-directives': 66.6.6 + '@unocss/transformer-variant-group': 66.6.6 + '@unocss/vite': 66.6.6(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - vite + + unpipe@1.0.0: + optional: true + + unplugin-auto-import@0.18.6(rollup@2.80.0): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + fast-glob: 3.3.3 + local-pkg: 0.5.1 + magic-string: 0.30.21 + minimatch: 9.0.9 + unimport: 3.14.6(rollup@2.80.0) + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + + unplugin-vue-components@0.27.5(@babel/parser@7.29.7)(rollup@2.80.0)(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + fast-glob: 3.3.3 + local-pkg: 0.5.1 + magic-string: 0.30.21 + minimatch: 9.0.9 + mlly: 1.8.1 + unplugin: 1.16.1 + vue: 3.5.30(typescript@5.9.3) + optionalDependencies: + '@babel/parser': 7.29.7 + transitivePeerDependencies: + - rollup + - supports-color + + unplugin@1.16.1: + dependencies: + acorn: 8.17.0 + webpack-virtual-modules: 0.6.2 + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.17.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + until-async@3.0.2: {} + + upath@1.2.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-template@3.1.1: {} + + util-deprecate@1.0.2: {} + + uuid@8.3.2: {} + + uuid@9.0.1: {} + + vary@1.1.2: + optional: true + + vite-node@6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0): + dependencies: + cac: 7.0.0 + es-module-lexer: 2.1.0 + obug: 2.1.3 + pathe: 2.0.3 + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + vite-plugin-pwa@1.2.0(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): + dependencies: + debug: 4.4.3(supports-color@5.5.0) + pretty-bytes: 6.1.1 + tinyglobby: 0.2.17 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + workbox-build: 7.4.0(@types/babel__core@7.20.5) + workbox-window: 7.4.0 + transitivePeerDependencies: + - supports-color + + vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.59.0 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + terser: 5.48.0 + yaml: 2.9.0 + + vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.0-rc.18 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.0 + esbuild: 0.27.3 + fsevents: 2.3.3 + jiti: 2.6.1 + terser: 5.48.0 + yaml: 2.9.0 + + vitest-mock-extended@2.0.2(typescript@5.9.3)(vitest@4.1.5): + dependencies: + ts-essentials: 10.1.1(typescript@5.9.3) + typescript: 5.9.3 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.0 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + jsdom: 25.0.1 + transitivePeerDependencies: + - msw + + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.0 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + jsdom: 25.0.1 + transitivePeerDependencies: + - msw + + vscode-uri@3.1.0: {} + + vue-demi@0.14.10(vue@3.5.30(typescript@5.9.3)): + dependencies: + vue: 3.5.30(typescript@5.9.3) + + vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.30(typescript@5.9.3) + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue3-json-viewer@2.4.1(vue@3.5.30(typescript@5.9.3)): + dependencies: + clipboard: 2.0.11 + vue: 3.5.30(typescript@5.9.3) + + vue@3.5.30(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-sfc': 3.5.30 + '@vue/runtime-dom': 3.5.30 + '@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3)) + '@vue/shared': 3.5.30 + optionalDependencies: + typescript: 5.9.3 + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + watchpack@2.5.2: + dependencies: + graceful-fs: 4.2.11 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@4.0.2: {} + + webidl-conversions@7.0.0: {} + + webpack-node-externals@3.0.0: {} + + webpack-sources@3.5.0: {} + + webpack-virtual-modules@0.6.2: {} + + webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.17.0 + acorn-import-phases: 1.0.4(acorn@8.17.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.24.0 + es-module-lexer: 2.1.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.2 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.1(esbuild@0.27.3)(lightningcss@1.32.0)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)) + watchpack: 2.5.2 + webpack-sources: 3.5.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + whatwg-url@7.1.0: + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.22 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + wildcard-match@5.1.4: {} + + word-wrap@1.2.5: {} + + workbox-background-sync@7.4.0: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.0 + + workbox-broadcast-update@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-build@7.4.0(@types/babel__core@7.20.5): + dependencies: + '@apideck/better-ajv-errors': 0.3.7(ajv@8.20.0) + '@babel/core': 7.29.7 + '@babel/preset-env': 7.29.7(@babel/core@7.29.7) + '@babel/runtime': 7.29.7 + '@rollup/plugin-babel': 5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.80.0) + '@rollup/plugin-node-resolve': 15.3.1(rollup@2.80.0) + '@rollup/plugin-replace': 2.4.2(rollup@2.80.0) + '@rollup/plugin-terser': 0.4.4(rollup@2.80.0) + '@surma/rollup-plugin-off-main-thread': 2.2.3 + ajv: 8.20.0 + common-tags: 1.8.2 + fast-json-stable-stringify: 2.1.0 + fs-extra: 9.1.0 + glob: 11.1.0 + lodash: 4.18.1 + pretty-bytes: 5.6.0 + rollup: 2.80.0 + source-map: 0.8.0-beta.0 + stringify-object: 3.3.0 + strip-comments: 2.0.1 + tempy: 0.6.0 + upath: 1.2.0 + workbox-background-sync: 7.4.0 + workbox-broadcast-update: 7.4.0 + workbox-cacheable-response: 7.4.0 + workbox-core: 7.4.0 + workbox-expiration: 7.4.0 + workbox-google-analytics: 7.4.0 + workbox-navigation-preload: 7.4.0 + workbox-precaching: 7.4.0 + workbox-range-requests: 7.4.0 + workbox-recipes: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + workbox-streams: 7.4.0 + workbox-sw: 7.4.0 + workbox-window: 7.4.0 + transitivePeerDependencies: + - '@types/babel__core' + - supports-color + + workbox-cacheable-response@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-core@7.4.0: {} + + workbox-expiration@7.4.0: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.0 + + workbox-google-analytics@7.4.0: + dependencies: + workbox-background-sync: 7.4.0 + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-navigation-preload@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-precaching@7.4.0: + dependencies: + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-range-requests@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-recipes@7.4.0: + dependencies: + workbox-cacheable-response: 7.4.0 + workbox-core: 7.4.0 + workbox-expiration: 7.4.0 + workbox-precaching: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-routing@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-strategies@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-streams@7.4.0: + dependencies: + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + + workbox-sw@7.4.0: {} + + workbox-window@7.4.0: + dependencies: + '@types/trusted-types': 2.0.7 + workbox-core: 7.4.0 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@7.0.1: + dependencies: + signal-exit: 4.1.0 + + ws@8.21.0: {} + + xbytes@1.9.1: {} + + xcase@2.0.1: {} + + xml-name-validator@4.0.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml-eslint-parser@2.0.0: + dependencies: + eslint-visitor-keys: 5.0.1 + yaml: 2.9.0 + + yaml@1.10.3: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + yummies@7.10.0: + dependencies: + class-variance-authority: 0.7.1 + clsx: 2.1.1 + dayjs: 1.11.19 + dompurify: 3.3.2 + nanoid: 5.1.6 + tailwind-merge: 3.5.0 + + zod-validation-error@3.5.4(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + + zwitch@2.0.4: {} diff --git a/.jjconflict-base-0/pnpm-workspace.yaml b/.jjconflict-base-0/pnpm-workspace.yaml new file mode 100644 index 0000000000..a812ffa983 --- /dev/null +++ b/.jjconflict-base-0/pnpm-workspace.yaml @@ -0,0 +1,162 @@ +packages: + - packages/** + - plugins/** + - apps/** + - "!apps/server/src/plugins/external/**" + - playwright/** +allowBuilds: + "@nestjs/core": true + "@prisma/client": true + "@prisma/engines": true + "@scarf/scarf": true + esbuild: true + msw: true + prisma: true + protobufjs: true + vue-demi: true +allowUnusedPatches: true +autoInstallPeers: true +injectWorkspacePackages: true +# minimum number of minutes that must pass after a version is published before +# pnpm will install it. This applies to all dependencies, including transitive ones. +minimumReleaseAge: 1440 # 1 day +catalogMode: strict +cleanupUnusedCatalogs: true +shellEmulator: true +trustPolicy: no-downgrade +# Check these after each update. This list should be empty ! +trustPolicyExclude: + - rollup@2.80.0 + - semver@6.3.1 + - chokidar@4.0.3 + - pino@9.14.0 + - typescript-transform-paths@3.5.6 + - undici-types@6.21.0 +catalogs: + runtime: + "@gitbeaker/core": ^40.6.0 + "@gitbeaker/requester-utils": ^40.6.0 + "@gitbeaker/rest": ^40.6.0 + "@gouvfr/dsfr": ^1.14.4 + "@gouvminint/vue-dsfr": ^8.15.0 + "@himenon/argocd-typescript-openapi": ^1.2.2 + "@iconify-json/ri": ^1.2.10 + "@iconify/vue": ^4.3.0 + "@keycloak/keycloak-admin-client": ^26.5.5 + "@kubernetes-models/argo-cd": ^2.7.2 + "@nestjs/cache-manager": ^3.1.2 + "@nestjs/common": ^11.1.16 + "@nestjs/config": ^4.0.3 + "@nestjs/core": ^11.1.16 + "@nestjs/event-emitter": ^3.0.1 + "@nestjs/jwt": ^11.0.2 + "@nestjs/platform-fastify": ^11.1.16 + "@nestjs/schedule": ^6.1.1 + "@nestjs/swagger": ^11.4.1 + "@nestjs/terminus": ^11.1.1 + "@prisma/client": ^6.19.2 + "@ts-rest/core": ^3.52.1 + "@ts-rest/fastify": ^3.52.1 + "@ts-rest/open-api": ^3.52.1 + axios: ^1.15.1 + cache-manager: ^7.2.8 + date-fns: ^4.1.0 + fastify: ^5.8.5 + fastify-keycloak-adapter: ^3.0.0 + javascript-time-ago: ^2.6.4 + json-2-csv: ^5.5.10 + json-schema: ^0.4.0 + jszip: ^3.10.1 + keycloak-js: ^26.2.3 + mustache: ^4.2.0 + nanoid: 5.0.9 + nestjs-pino: ^4.6.0 + p-debounce: ^4.0.0 + pinia: ^2.3.1 + prisma: ^6.19.2 + reflect-metadata: ^0.2.2 + rxjs: ^7.8.2 + short-uuid: ^5.2.0 + undici: ^7.24.0 + vitest-mock-extended: ^2.0.2 + vue: ^3.5.30 + vue-router: ^4.6.4 + vue3-json-viewer: ^2.4.1 + xbytes: ^1.9.1 + yaml: ^2.7.1 + zod: ^3.25.76 + zod-validation-error: ^3.5.4 + "@fastify/cookie": ^11.0.2 + "@fastify/helmet": ^13.0.2 + "@fastify/otel": ^0.18.1 + "@fastify/session": ^11.1.1 + "@fastify/swagger": ^9.7.0 + "@fastify/swagger-ui": ^5.2.6 + bytes: ^3.1.2 + cron-validator: ^1.4.0 + build: + "@iconify/types": ^2.0.0 + "@nestjs/cli": ^11.0.16 + "@nestjs/schematics": ^11.0.9 + "@nestjs/testing": ^11.1.16 + "@unocss/transformer-directives": ^0.65.4 + "@vitejs/plugin-vue": ^6.0.4 + "@vue/tsconfig": ^0.7.0 + fastify-plugin: ^5.1.0 + swagger-typescript-api: ^13.3.1 + ts-patch: ^3.3.0 + typescript: ^5.9.3 + typescript-transform-paths: ^3.5.6 + unocss: ^66.6.6 + unplugin-auto-import: ^0.18.6 + unplugin-vue-components: ^0.27.5 + vite: ^7.3.2 + vite-node: ^6.0.0 + vite-plugin-pwa: ^1.2.0 + vue-tsc: ^2.2.12 + workbox-window: ^7.4.0 + test: + "@playwright/test": ^1.59.1 + "@faker-js/faker": ^9.9.0 + "@vitest/coverage-v8": ^4.1.5 + globals: ^16.5.0 + jsdom: ^25.0.1 + msw: ^2.12.10 + vitest: ^4.1.5 + types: + "@types/jsdom": ^21.1.7 + "@types/json-schema": ^7.0.15 + "@types/node": ^24.12.0 + undici-types: ^7.16.0 + otel: + "@opentelemetry/api": ^1.9.0 + "@opentelemetry/auto-instrumentations-node": ^0.70.1 + "@opentelemetry/exporter-metrics-otlp-proto": ^0.213.0 + "@opentelemetry/exporter-trace-otlp-proto": ^0.213.0 + "@opentelemetry/instrumentation-nestjs-core": ^0.58.0 + "@opentelemetry/instrumentation-pino": ^0.59.0 + "@opentelemetry/sdk-metrics": ^2.5.1 + "@opentelemetry/sdk-node": ^0.212.0 + tools: + "@antfu/eslint-config": ^8.2.0 + "@commitlint/cli": ^20.5.3 + "@commitlint/config-conventional": ^20.5.3 + "@eslint/eslintrc": ^3.3.5 + "@eslint/js": ^9.39.4 + "@vue/eslint-config-typescript": ^14.7.0 + chalk: ^5.6.2 + eslint: ^10.5.0 + eslint-plugin-vue: ^10.8.0 + husky: ^9.1.7 + js-yaml: ^4.1.0 + lint-staged: ^16.4.0 + nodemon: ^3.1.14 + pino: ^9.14.0 + pino-pretty: ^13.1.3 + rimraf: ^6.1.3 + stylelint: ^17.11.0 + stylelint-config-html: ^1.1.0 + stylelint-config-recommended-vue: ^1.6.1 + stylelint-config-standard: ^40.0.0 + typescript-eslint: ^8.57.0 + vue-eslint-parser: ^10.4.0 diff --git a/.jjconflict-base-0/release-please-config.json b/.jjconflict-base-0/release-please-config.json new file mode 100644 index 0000000000..473308bd27 --- /dev/null +++ b/.jjconflict-base-0/release-please-config.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "versioning": "always-bump-minor", + "pull-request-title-pattern": "chore${scope}: Release${component} v${version}", + "release-type": "node", + "changelog-type": "default", + "changelog-sections": [ + { + "type": "feat", + "hidden": false, + "section": "Features" + }, + { + "type": "feature", + "hidden": false, + "section": "Features" + }, + { + "type": "fix", + "hidden": false, + "section": "Bug Fixes" + }, + { + "type": "chore", + "hidden": false, + "section": "Miscellaneous Chores" + }, + { + "type": "build", + "hidden": false, + "section": "Miscellaneous Chores" + }, + { + "type": "docs", + "hidden": false, + "section": "Docs" + }, + { + "type": "refactor", + "hidden": false, + "section": "Refactoring" + }, + { + "type": "revert", + "hidden": false, + "section": "Reverted commits" + } + ], + "packages": { + ".": { + "package-name": "console", + "include-component-in-tag": false + } + } +} diff --git a/.jjconflict-base-0/scripts/run-build.sh b/.jjconflict-base-0/scripts/run-build.sh new file mode 100755 index 0000000000..9e53b07cf8 --- /dev/null +++ b/.jjconflict-base-0/scripts/run-build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd $(dirname "$COMPOSE_FILE") \ + && docker buildx bake --file $(basename "$COMPOSE_FILE") --load --allow=fs.read=.. \ + && cd - > /dev/null diff --git a/.jjconflict-base-0/scripts/update-hosts.sh b/.jjconflict-base-0/scripts/update-hosts.sh new file mode 100755 index 0000000000..0de643ec93 --- /dev/null +++ b/.jjconflict-base-0/scripts/update-hosts.sh @@ -0,0 +1,130 @@ +#!/bin/bash + +set -xe +set -o pipefail + +# Colorize terminal +export red='\e[0;31m' +export no_color='\033[0m' + +# Get versions +export DOCKER_VERSION="$(docker --version)" + +# Default +export SCRIPTPATH="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" +export HELM_RELEASE_NAME="dso" +export INTEGRATION_ARG="" +export INTEGRATION_ARGS_UTILS="" +export CI_ARGS="" + + +# Declare script helper +export TEXT_HELPER="\nThis script aims to manage a local kubernetes cluster using Kind also known as Kubernetes in Docker. +Following flags are available: + + -c Command tu run. Multiple commands can be provided as a comma separated list. + Available commands are : + create - Create kind cluster. + clean - Delete images in kind cluster (keep only infra resources and ingress controller). + delete - Delete kind cluster. + build - Build, push and load docker images from compose file into cluster nodes. + load - Load docker images from compose file into cluster nodes. + dev - Run application in development mode. + prod - Run application in production mode. + integ - Run application in integration mode (need to combine with 'dev' or 'prod'). + + -d Domains to add in /etc/hosts for local services resolution. Comma separated list. This will require sudo. + + -f Path to the docker-compose file that will be used with Kind. + + -i Install kind. + + -k Path to the kubeconfig to use. + + -t Tag used to deploy application images. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hc:d:f:ik:t: flag; do + case "${flag}" in + c) + export COMMAND=${OPTARG};; + d) + export DOMAINS=${OPTARG};; + f) + export COMPOSE_FILE=${OPTARG};; + i) + export INSTALL_KIND=true;; + k) + export KUBECONFIG_HOST_PATH=${OPTARG};; + t) + export TAG=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Utils +install_kind() { + printf "\n\n${red}[kind wrapper].${no_color} Install kind...\n\n" + if [ "$(uname)" = "Linux" ]; then + export OS=linux + elif [ "$(uname)" = "Darwin" ]; then + export OS=darwin + else + printf "\n\nNo installation available for your system, plese refer to the installation guide\n\n" + exit 0 + fi + + if [ "$(uname -m)" = "x86_64" ]; then + export ARCH=amd64 + elif [ "$(uname -m)" = "arm64" ] || [ "$(uname -m)" = "aarch64" ]; then + export ARCH=arm64 + fi + + curl -Lo ./kind "https://kind.sigs.k8s.io/dl/v0.20.0/kind-$OS-$ARCH" + chmod +x ./kind + mv ./kind /usr/local/bin/kind + + printf "\n\n$(kind --version) installed\n\n" +} + +if [ "$INSTALL_KIND" = "true" ] && [ -z "$(kind --version)" ]; then + install_kind +fi + + +# Script condition +if [ -z "$(kind --version)" ]; then + echo "\nYou need to install kind to run this script.\n" + print_help + exit 1 +fi + +if [[ "$COMMAND" =~ "build" ]] && [ ! -f "$(readlink -f $COMPOSE_FILE)" ]; then + echo "\nDocker compose file $COMPOSE_FILE does not exist.\n" + print_help + exit 1 +fi + + +# Add local services to /etc/hosts +if [ ! -z "$DOMAINS" ]; then + printf "\n\n${red}[kind wrapper].${no_color} Add services local domains to /etc/hosts\n\n" + + export FORMATED_DOMAINS="$(echo "$DOMAINS" | sed 's/,/\ /g')" + if [ "$(grep -c "$FORMATED_DOMAINS" /etc/hosts)" -ge 1 ]; then + printf "\n\n${red}[kind wrapper].${no_color} Services local domains already added to /etc/hosts\n\n" + else + sudo sh -c "echo $'\n\n# Kind\n127.0.0.1 $FORMATED_DOMAINS' >> /etc/hosts" + + printf "\n\n${red}[kind wrapper].${no_color} Services local domains successfully added to /etc/hosts\n\n" + fi +fi diff --git a/.jjconflict-base-1/.dockerignore b/.jjconflict-base-1/.dockerignore new file mode 100644 index 0000000000..2edecc5c93 --- /dev/null +++ b/.jjconflict-base-1/.dockerignore @@ -0,0 +1,26 @@ +# Npm +node_modules +npm-debug.log + +# Git +.gitkeep + +# Lint +.eslintrc.js +.eslintrc.cjs +.eslintrc.json +.eslintignore +.stylelintrc.js +.stylelintrc.cjs + +# Tests +.turbo/ +coverage/ + +# Docker +**/Dockerfile +.dockerignore +docker/ + +# Env +.env* \ No newline at end of file diff --git a/.jjconflict-base-1/.gitattributes b/.jjconflict-base-1/.gitattributes new file mode 100644 index 0000000000..2e7bbd226d --- /dev/null +++ b/.jjconflict-base-1/.gitattributes @@ -0,0 +1,2 @@ +# Generated API clients — collapse in diffs and exclude from language stats. +plugins/harbor/src/api/Api.ts linguist-generated=true diff --git a/.jjconflict-base-1/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.jjconflict-base-1/.github/ISSUE_TEMPLATE/BUG-REPORT.yml new file mode 100644 index 0000000000..f87368e72d --- /dev/null +++ b/.jjconflict-base-1/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -0,0 +1,98 @@ +name: 🐛 Rapport de bug +description: Créer un ticket pour un bug. +title: "🐛 [BUG] - " +labels: [bug] +body: + - type: textarea + id: description + attributes: + label: Description + description: Veuillez saisir une description explicite de votre problème. + placeholder: Description courte et explicite de votre incident... + validations: + required: true + + - type: textarea + id: reprod + attributes: + label: Etapes de reproduction + description: Veuillez saisir une description explicite pour reproduire votre problème. + value: | + 1. Aller à '...' + 2. Cliquer sur '....' + 3. Scroller jusqu'à '....' + 4. Voir l'erreur + render: bash + validations: + required: true + + - type: textarea + id: screenshot + attributes: + label: "Captures d'écran" + description: Si nécessaire, ajoutez des captures d'écran pour expliquer votre problème. + value: | + ![DESCRIPTION](LINK.png) + render: bash + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Logs + description: Veuillez copier/coller tous les logs pertinents. Ces données seront automatiquement formatées en code. + validations: + required: false + + - type: dropdown + id: browsers + attributes: + label: Navigateurs + description: Quels sont les navigateurs sur lesquels vous constatez le problème ? + multiple: true + options: + - Firefox + - Chrome + - Chromium + - Brave + - Safari + - Microsoft Edge + - Opera + validations: + required: false + + - type: dropdown + id: os + attributes: + label: OS + description: Quels sont les systèmes d'exploitation sur lesquels vous constatez le problème ? + multiple: true + options: + - Windows + - Linux + - Mac + validations: + required: false + + - type: input + id: affected-versions + attributes: + label: Version de la console impactée + description: Quelle version de la console est impactée par le bug ? + placeholder: v1.2.3 + validations: + required: false + + - type: checkboxes + id: dod + attributes: + label: Définition du fini + description: Cochez les différents élément de la Definition Of Done. + options: + - label: Le correctif est terminé + required: false + - label: Les tests liés à ce correctif ont été ajoutés + required: false + - label: La communication avec les autres équipes impliquées par ce correctif a été faite + required: false diff --git a/.jjconflict-base-1/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.jjconflict-base-1/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml new file mode 100644 index 0000000000..2d55aae3ad --- /dev/null +++ b/.jjconflict-base-1/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml @@ -0,0 +1,64 @@ +name: 💡 Demande de fonctionnalité +description: Créer un ticket pour demander une nouvelle fonctionnalité. +title: "💡 [REQUEST] - <title>" +labels: [enhancement] +body: + - type: textarea + id: summary + attributes: + label: Description + description: Veuillez saisir une brève explication de la fonctionnalité. + placeholder: Décrivez en quelques lignes la fonctionnalité désirée. + validations: + required: true + + - type: textarea + id: implementation_pr + attributes: + label: PRs liées + description: Pull request qui répond à la fonctionnalité. + placeholder: "#Pull Request ID" + validations: + required: false + + - type: textarea + id: reference_issues + attributes: + label: Issues liées + description: Issues associée à cette demande de fonctionnalité. + placeholder: "#Issues IDs" + validations: + required: false + + - type: textarea + id: basic_example + attributes: + label: Exemples simples + description: Indiquez ici quelques exemples de base de votre fonctionnalité. + placeholder: Quelques mots sur votre demande de fonctionnalité. + validations: + required: false + + - type: textarea + id: tech + attributes: + label: Spécifications techniques + description: Spcifiez les détails techniques liés à votre demande. + placeholder: Une description technique de la fonctionnalité. + validations: + required: false + + - type: checkboxes + id: dod + attributes: + label: Définition du fini + description: Cochez les différents élément de la Definition Of Done. + options: + - label: La fonctionnalité est terminée + required: false + - label: Les tests liés à cette fonctionnalité ont été ajoutés + required: false + - label: La documentation liée à cette fonctionnalité a été ajoutée (cf. https://github.com/cloud-pi-native/documentation) + required: false + - label: La communication avec les autres équipes impliquées par cette fonctionnalité a été faite + required: false diff --git a/.jjconflict-base-1/.github/ISSUE_TEMPLATE/config.yml b/.jjconflict-base-1/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..b565340e24 --- /dev/null +++ b/.jjconflict-base-1/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: 📚 Documentation + url: https://cloud-pi-native.fr + about: Cet outil de suivi des problèmes n'est pas destiné aux problèmes de documentation. Veuillez ouvrir un ticket sur le dépôt de documentation. + - name: 🚀 Déploiement + url: https://github.com/cloud-pi-native/socle + about: Cet outil de suivi des problèmes n'est pas destiné aux problèmes de déploiement de la plateforme. Veuillez ouvrir un ticket sur le dépôt de socle. diff --git a/.jjconflict-base-1/.github/PULL_REQUEST_TEMPLATE.md b/.jjconflict-base-1/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..1730631ac9 --- /dev/null +++ b/.jjconflict-base-1/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +## Issues liées + +Issues numéro: + +--------- + +<!-- Ne soumettez pas de mises à jour des dépendances à moins qu'elles ne corrigent un problème. --> + +<!-- Veuillez essayer de limiter votre Pull Request à un seul type (correction de bogue, fonctionnalité, etc.). Soumettez plusieurs PRs si nécessaire. --> + +## Quel est le comportement actuel ? +<!-- Veuillez décrire le comportement actuel que vous modifiez. --> + +## Quel est le nouveau comportement ? +<!-- Veuillez décrire le comportement ou les changements apportés par cette PR. --> + +## Cette PR introduit-elle un breaking change ? +<!-- Si un breaking change est introduit, veuillez décrire ci-dessous l'impact et la procédure de migration pour les applications existantes. --> + +## Autres informations +<!-- Toute autre information importante pour la PR, telle que des captures d'écran montrant l'aspect du composant avant et après la modification. --> diff --git a/.jjconflict-base-1/.github/SECURITY.md b/.jjconflict-base-1/.github/SECURITY.md new file mode 100644 index 0000000000..9aaaf8698f --- /dev/null +++ b/.jjconflict-base-1/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +Contact: cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-base-1/.github/labeler/build.yml b/.jjconflict-base-1/.github/labeler/build.yml new file mode 100644 index 0000000000..68a15e21bc --- /dev/null +++ b/.jjconflict-base-1/.github/labeler/build.yml @@ -0,0 +1,3 @@ +built: + - changed-files: + - any-glob-to-any-file: "**" diff --git a/.jjconflict-base-1/.github/workflows/job-build.yml b/.jjconflict-base-1/.github/workflows/job-build.yml new file mode 100644 index 0000000000..f1a1f96e51 --- /dev/null +++ b/.jjconflict-base-1/.github/workflows/job-build.yml @@ -0,0 +1,256 @@ +name: Build + +permissions: + contents: read + packages: write + +on: + workflow_call: + inputs: + REGISTRY: + required: true + type: string + NAMESPACE: + required: true + type: string + BUILD_AMD64: + required: true + type: boolean + BUILD_ARM64: + required: true + type: boolean + TAG: + required: true + type: string + MAJOR_TAG: + required: false + type: string + MINOR_TAG: + required: false + type: string + PATCH_TAG: + required: false + type: string + USE_QEMU: + required: true + type: boolean + PR_NUMBER: + required: false + type: string + secrets: + ARGOCD_TOKEN: + required: false + workflow_dispatch: + inputs: + REGISTRY: + description: Target registry to push images + required: true + type: string + default: ghcr.io + NAMESPACE: + description: Target namespace to the given registry + required: true + type: string + default: cloud-pi-native/console + BUILD_AMD64: + description: Build for amd64 + required: true + type: boolean + default: true + BUILD_ARM64: + description: Build for arm64 + required: true + type: boolean + default: true + USE_QEMU: + description: Use QEMU emulator for arm64 + required: true + type: boolean + default: false + PR_NUMBER: + description: ID number of the pull request to use for deploy preview app + required: false + type: string + +jobs: + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + lower-branch: ${{ steps.lower-branch.outputs.LOWER_BRANCH }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + - name: Get lowercase branch name + id: lower-branch + run: | + echo "LOWER_BRANCH=$(echo '${{ github.head_ref || github.ref_name }}' | sed 's/\//-/g' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + + build: + name: Build application + runs-on: ${{ matrix.runners }} + needs: + - matrix + strategy: + fail-fast: false + matrix: + runners: ${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && !inputs.USE_QEMU && fromJson('["ubuntu-latest", "ARM64"]')) || (inputs.BUILD_ARM64 && !inputs.USE_QEMU && fromJson('["ARM64"]')) || fromJson('["ubuntu-latest"]') }} + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Get short SHA + id: short-sha + run: | + echo "SHORT_SHA=sha-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Cache Docker layers + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: /tmp/.buildx-cache + key: ${{ matrix.images.name == 'server' && format('buildx-{0}-{1}-{2}-{3}', runner.os, runner.arch, matrix.images.name, hashFiles(format('apps/{0}/**', matrix.images.name),'packages/shared/**','plugins/**')) || format('buildx-{0}-{1}-{2}-{3}', runner.os, runner.arch, matrix.images.name, hashFiles(format('apps/{0}/**', matrix.images.name),'packages/shared/**')) }} + restore-keys: | + buildx-${{ runner.os }}-${{ runner.arch }}-${{ matrix.images.name }}- + + - name: Set up QEMU (for multi platform build) + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + if: ${{ inputs.USE_QEMU }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ inputs.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + logout: true + + - name: Build and push docker image + id: build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: ${{ matrix.images.build.context }} + file: ${{ matrix.images.build.dockerfile }} + provenance: false + platforms: ${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && inputs.USE_QEMU && 'linux/amd64,linux/arm64') || (inputs.BUILD_ARM64 && 'linux/arm64') || (inputs.BUILD_AMD64 && 'linux/amd64') }} + outputs: type=image,name=${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }},push-by-digest=true,name-canonical=true,push=true + build-args: | + APP_VERSION=${{ inputs.TAG || steps.short-sha.outputs.SHORT_SHA }} + # cache-from: type=gha + # cache-to: type=gha,mode=max + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new + + # Necessary to avoid forever growing cache + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + + - name: Export digest + run: | + mkdir -p /tmp/digests/${{ matrix.images.name }} + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${{ matrix.images.name }}/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: digests-${{ matrix.images.name }}.${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && inputs.USE_QEMU && 'multiarch') || (inputs.BUILD_ARM64 && 'arm64') || (inputs.BUILD_AMD64 && 'amd64') }}.digest + path: /tmp/digests/${{ matrix.images.name }}/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge digest + runs-on: ubuntu-latest + needs: + - matrix + - build + strategy: + fail-fast: false + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Download artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: digests-${{ matrix.images.name }}.*.digest + path: /tmp/digests/${{ matrix.images.name }} + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Docker meta + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + with: + images: ${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }} + tags: | + type=raw,value=${{ needs.matrix.outputs.lower-branch }},enable=${{ github.head_ref != 'main' }} + type=raw,value=${{ inputs.TAG }},enable=${{ inputs.TAG != '' }} + type=raw,value=${{ inputs.MAJOR_TAG }}.${{ inputs.MINOR_TAG }},enable=${{ inputs.MAJOR_TAG != '' && inputs.MINOR_TAG != '' }} + type=raw,value=${{ inputs.MAJOR_TAG }},enable=${{ inputs.MAJOR_TAG != '' }} + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ inputs.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + logout: true + + - name: Create manifest list and push + working-directory: /tmp/digests/${{ matrix.images.name }} + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ steps.meta.outputs.version }} + + preview: + name: Redeploy preview app + if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'preview') && inputs.PR_NUMBER != '' }} + runs-on: ubuntu-latest + needs: + - merge + steps: + - name: Replace template fields + id: feed-template + run: | + echo "ARGOCD_APP_NAME=$(echo '${{ vars.ARGOCD_APP_NAME_TEMPLATE }}' | sed 's|<pr_number>|${{ inputs.PR_NUMBER }}|g')" >> $GITHUB_OUTPUT + echo "ARGOCD_SYNC_PAYLOAD=$(echo '${{ vars.ARGOCD_SYNC_PAYLOAD_TEMPLATE }}' | sed 's|<pr_number>|${{ inputs.PR_NUMBER }}|g')" >> $GITHUB_OUTPUT + + - name: Deploy new image + run: | + set -e + + RESPONSE_CODE=$(curl -s -w "%{http_code}" -o >(RESPONSE_BODY=$(cat)) -X POST ${{ vars.ARGOCD_URL }}/api/v1/applications/${{ steps.feed-template.outputs.ARGOCD_APP_NAME }}/sync \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${{ secrets.ARGOCD_TOKEN }}" \ + -d '${{ steps.feed-template.outputs.ARGOCD_SYNC_PAYLOAD }}') + + echo "HTTP response code: $RESPONSE_CODE" + + if [ "$RESPONSE_CODE" -ne 200 ]; then + echo "Error: Non-200 HTTP response detected." + echo "Response body: $RESPONSE_BODY" + exit 1 + fi + continue-on-error: true diff --git a/.jjconflict-base-1/.github/workflows/job-label.yml b/.jjconflict-base-1/.github/workflows/job-label.yml new file mode 100644 index 0000000000..1fd944015f --- /dev/null +++ b/.jjconflict-base-1/.github/workflows/job-label.yml @@ -0,0 +1,25 @@ +name: Add labels on PR + +permissions: + contents: read + pull-requests: write + +on: + workflow_call: + inputs: + CONF_PATH: + required: true + type: string + +jobs: + label: + name: Add labels on PR + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Add pull request label + uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + with: + configuration-path: ${{ inputs.CONF_PATH }} diff --git a/.jjconflict-base-1/.github/workflows/job-lint.yml b/.jjconflict-base-1/.github/workflows/job-lint.yml new file mode 100644 index 0000000000..bbbae077d6 --- /dev/null +++ b/.jjconflict-base-1/.github/workflows/job-lint.yml @@ -0,0 +1,79 @@ +name: Lint + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: false + type: string + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + +jobs: + lint: + name: Lint codebase + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check lint error + run: | + ./ci/scripts/run-tests.sh -l + + - name: Validate nginx-strangler config + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends nginx gettext-base + # Préparer un répertoire de test isolé avec la config substituée + mkdir -p /tmp/nginx-test/conf.d /tmp/nginx-test/logs + envsubst '${LEGACY_UPSTREAM} ${NESTJS_UPSTREAM}' \ + < apps/nginx-strangler/conf.d/routing.conf \ + > /tmp/nginx-test/conf.d/routing.conf + # Adapter nginx.conf pour l'environnement CI (user www-data, paths accessibles) + sed \ + -e 's|^user .*|user www-data;|' \ + -e 's|pid .*|pid /tmp/nginx-test/nginx.pid;|' \ + -e 's|error_log .*|error_log /tmp/nginx-test/logs/error.log notice;|' \ + -e 's|access_log .*|access_log /tmp/nginx-test/logs/access.log main;|' \ + -e 's|include /etc/nginx/conf\.d/\*\.conf|include /tmp/nginx-test/conf.d/*.conf|' \ + apps/nginx-strangler/nginx.conf > /tmp/nginx-test/nginx.conf + nginx -t -c /tmp/nginx-test/nginx.conf + env: + LEGACY_UPSTREAM: "127.0.0.1:8080" + NESTJS_UPSTREAM: "127.0.0.1:3001" diff --git a/.jjconflict-base-1/.github/workflows/job-npm.yml b/.jjconflict-base-1/.github/workflows/job-npm.yml new file mode 100644 index 0000000000..51e4f3eedc --- /dev/null +++ b/.jjconflict-base-1/.github/workflows/job-npm.yml @@ -0,0 +1,247 @@ +name: Publish npm + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + PUBLISH_APPS: + required: true + type: boolean + PUBLISH_PACKAGES: + required: true + type: boolean + PUBLISH_PLUGINS: + required: true + type: boolean + CHECK_CHANGES: + required: true + type: boolean + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used + required: true + type: string + PUBLISH_APPS: + description: Publish apps modules + required: true + type: boolean + default: false + PUBLISH_PACKAGES: + description: Publish packages modules + required: true + type: boolean + default: false + PUBLISH_PLUGINS: + description: Publish plugins modules + required: true + type: boolean + default: true + CHECK_CHANGES: + required: true + type: boolean + default: true + +jobs: + path-filter: + if: ${{ inputs.CHECK_CHANGES }} + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.filter.outputs.changes }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + eslint-config: + - 'packages/eslintconfig/src/**' + shared: + - 'packages/shared/src/**' + hooks: + - 'packages/hooks/src/**' + ts-config: + - 'packages/tsconfig/**' + logger: + - 'packages/logger/**' + argocd: + - 'plugins/argocd/src/**' + gitlab: + - 'plugins/gitlab/src/**' + harbor: + - 'plugins/harbor/src/**' + keycloak: + - 'plugins/keycloak/src/**' + nexus: + - 'plugins/nexus/src/**' + sonarqube: + - 'plugins/sonarqube/src/**' + vault: + - 'plugins/vault/src/**' + + check-update: + name: Check for package version update + if: ${{ fromJson(needs.path-filter.outputs.packages).include[0] }} # skip if the matrix is empty! + runs-on: ubuntu-latest + needs: + - path-filter + strategy: + matrix: + packages: ${{ fromJSON(needs.path-filter.outputs.packages) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Check changes + id: check + run: | + PACKAGE_NAME=$(jq -cr --arg p "${{ matrix.packages }}" '[.packages[], .plugins[]][] | select(.name | test($p)?) | .name' < ./ci/matrix-npm.json) + PACKAGE_PATH=$(jq -cr --arg p "${{ matrix.packages }}" '[.packages[], .plugins[]][] | select(.name | test($p)?) | .path' < ./ci/matrix-npm.json) + PACKAGE_VERSION=$(jq -cr '.version' < ${PACKAGE_PATH}/package.json) + if [ "$(curl -s -o /dev/null -I -w %{http_code} https://registry.npmjs.org/${PACKAGE_NAME})" = 404 ]; then + REMOTE=false + else + REMOTE=$(pnpm view ${PACKAGE_NAME} --json | jq --arg v "$PACKAGE_VERSION" 'any(.versions[] == $v; .)') + fi + if [ "$REMOTE" = "true" ]; then + echo "WARNING=true" >> $GITHUB_OUTPUT + echo "PACKAGE_NAME=$PACKAGE_NAME" >> $GITHUB_OUTPUT + echo "PACKAGE_PATH=$PACKAGE_PATH" >> $GITHUB_OUTPUT + echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_OUTPUT + else + echo "WARNING=false" >> $GITHUB_OUTPUT + fi + + matrix: + name: Generate publish matrix + needs: + - path-filter + if: ${{ needs.path-filter.outputs.packages != '' && (inputs.PUBLISH_APPS || inputs.PUBLISH_PACKAGES || inputs.PUBLISH_PLUGINS) }} + runs-on: ubuntu-latest + outputs: + publish-matrix: ${{ steps.packages-matrix.outputs.PUBLISH_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: packages-matrix + run: | + PUBLISH_MATRIX="[]" + APPS_MATRIX="$(jq -c '.apps' < ./ci/matrix-npm.json)" + PACKAGES_MATRIX="$(jq -c '.packages' < ./ci/matrix-npm.json)" + PLUGINS_MATRIX="$(jq -c '.plugins' < ./ci/matrix-npm.json)" + if [ "${{ inputs.PUBLISH_APPS }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$APPS_MATRIX" '$acc + $cur')" + fi + if [ "${{ inputs.PUBLISH_PACKAGES }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$PACKAGES_MATRIX" '$acc + $cur')" + fi + if [ "${{ inputs.PUBLISH_PLUGINS }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$PLUGINS_MATRIX" '$acc + $cur')" + fi + echo "PUBLISH_MATRIX=$PUBLISH_MATRIX" >> $GITHUB_OUTPUT + + publish-npm: + name: Publish on npm + if: ${{ needs.path-filter.outputs.packages != '' && (inputs.PUBLISH_APPS || inputs.PUBLISH_PACKAGES || inputs.PUBLISH_PLUGINS) }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + needs: + - path-filter + - matrix + strategy: + # Allow jobs to be processed independently of their siblings status + fail-fast: false + matrix: + modules: ${{ fromJSON(needs.matrix.outputs.publish-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.ref }} + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Check for new package version + id: check-version + run: | + PACKAGE_VERSION=$(jq -cr '.version' < ${{ matrix.modules.path }}/package.json) + if [ "$(curl -s -o /dev/null -I -w %{http_code} https://registry.npmjs.org/${{ matrix.modules.name }})" = 404 ]; then + REMOTE=false + else + REMOTE=$(pnpm view ${{ matrix.modules.name }} --json | jq --arg v "$PACKAGE_VERSION" 'any(.versions[] == $v; .)') + fi + if [ "$REMOTE" = "true" ]; then + echo "Package ${{ matrix.modules.name }} - version $PACKAGE_VERSION already exists" + else + echo "Package ${{ matrix.modules.name }} - version $PACKAGE_VERSION does not exist" + fi + echo "REMOTE=$REMOTE" >> $GITHUB_OUTPUT + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + with: + node-version: "${{ inputs.NODE_VERSION }}" + registry-url: "https://registry.npmjs.org" + + - name: Get pnpm store directory + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install base dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: pnpm install --frozen-lockfile + + - name: Build internal dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter "./packages/**" run build && pnpm --filter "./plugins/**" run build + + - name: Update PNPM internal links with built internal dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm install --frozen-lockfile + + - name: Build package + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter ${{ matrix.modules.name }} run build + + - name: Publish package + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter ${{ matrix.modules.name }} publish --provenance --no-git-checks --report-summary diff --git a/.jjconflict-base-1/.github/workflows/job-playwright.yml b/.jjconflict-base-1/.github/workflows/job-playwright.yml new file mode 100644 index 0000000000..ec0a3fe695 --- /dev/null +++ b/.jjconflict-base-1/.github/workflows/job-playwright.yml @@ -0,0 +1,114 @@ +name: Playwright Tests + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + TAG: + required: true + type: string + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + TAG: + description: Container images version to use for console client and server/server-nestjs + required: true + type: string + default: latest +jobs: + test: + timeout-minutes: 60 + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache Node.js files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install Node.js dependencies + run: pnpm install --frozen-lockfile + + - name: Build internal dependencies + run: pnpm --filter "./packages/**" build + + - name: Get used Playwright version + id: playwright-version + run: echo "PLAYWRIGHT_VERSION=$(pnpm --dir playwright exec playwright -V | awk '{ print $2 }')" >> $GITHUB_ENV + + - name: Restore cache - Playwright browser binaries + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + id: playwright-cache + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} + + - name: Install Playwright system dependencies + run: pnpm --dir playwright exec playwright install --with-deps + + - name: Save cache - Playwright browser binaries + if: always() && steps.playwright-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} + + - name: Initialize application environment for tests + run: | + ./ci/scripts/init-env.sh + docker pull ghcr.io/cloud-pi-native/console/server:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/server:${{ inputs.TAG }} dso-console/server:ci + docker pull ghcr.io/cloud-pi-native/console/nginx-strangler:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/nginx-strangler:${{ inputs.TAG }} dso-console/nginx-strangler:ci + docker pull ghcr.io/cloud-pi-native/console/server-nestjs:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/server-nestjs:${{ inputs.TAG }} dso-console/server-nestjs:ci + docker pull ghcr.io/cloud-pi-native/console/client:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/client:${{ inputs.TAG }} dso-console/client:ci + docker pull ghcr.io/cloud-pi-native/console/opencds-mockoon:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/opencds-mockoon:${{ inputs.TAG }} dso-console/opencds-mockoon:ci + docker compose -f ./docker/docker-compose.ci.yml up --no-build -d --remove-orphans + + - name: Run Playwright tests + run: pnpm --dir playwright exec playwright test --grep @e2e + + - name: Clean up docker resources (containers, volumes) + run: docker compose -f ./docker/docker-compose.ci.yml down -v --remove-orphans + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright/playwright-report/ + retention-days: 30 diff --git a/.jjconflict-base-1/.github/workflows/job-release-please.yml b/.jjconflict-base-1/.github/workflows/job-release-please.yml new file mode 100644 index 0000000000..b4789c2cbb --- /dev/null +++ b/.jjconflict-base-1/.github/workflows/job-release-please.yml @@ -0,0 +1,84 @@ +name: Release + +permissions: + contents: write + pull-requests: write + +on: + workflow_call: + outputs: + release-created: + description: Has the releease been created + value: ${{ jobs.release.outputs.release-created }} + major-tag: + description: Major version tag + value: ${{ jobs.release.outputs.major-tag }} + minor-tag: + description: Minor version tag + value: ${{ jobs.release.outputs.minor-tag }} + patch-tag: + description: Patch version tag + value: ${{ jobs.release.outputs.patch-tag }} + +jobs: + release: + name: Create new release + runs-on: ubuntu-latest + outputs: + release-created: ${{ steps.release.outputs.release_created }} + major-tag: ${{ steps.release.outputs.major }} + minor-tag: ${{ steps.release.outputs.minor }} + patch-tag: ${{ steps.release.outputs.patch }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Update release-please config for hotfix branches + if: startsWith(github.ref_name, 'hotfix/') + run: | + # Update versioning to always-bump-patch for hotfix branches + jq '.versioning = "always-bump-patch"' release-please-config.json > tmp.json && mv tmp.json release-please-config.json + echo "Updated release-please-config.json versioning to always-bump-patch" + shell: bash + + - name: Create merge request for new release + uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 + id: release + with: + config-file: release-please-config.json + target-branch: ${{ github.ref_name }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Edit packages versions + if: ${{ ! steps.release.outputs.release_created && steps.release.outputs.pr }} + run: | + export BRANCH=release-please--branches--main--components--console + git fetch + git checkout "$BRANCH" + VERSION=$(jq -r .version package.json) + for pkg in $(jq -r '.apps | map(.path + "/package.json") | .[]' < ./ci/matrix-npm.json); do + yq e -i ".version |= \"$VERSION\"" $pkg + yq e -i ".publishConfig.tag |= \"latest\"" $pkg + done + git add . + # Emulate the author of the existing commit (the bot). + git config user.email "$(git log -n 1 --format='%ae')" + git config user.name "$(git log -n 1 --format='%an')" + # Update the PR. + git commit --amend --no-edit + git push -f + + - name: Tag major and minor versions + if: ${{ steps.release.outputs.release_created }} + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git remote add gh-token "https://${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" + git tag -d v${{ steps.release.outputs.major }} || true + git tag -d v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} || true + git push origin :v${{ steps.release.outputs.major }} || true + git push origin :v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} || true + git tag -a v${{ steps.release.outputs.major }} -m "Release v${{ steps.release.outputs.major }}" + git tag -a v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} -m "Release v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}" + git push origin v${{ steps.release.outputs.major }} + git push origin v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} diff --git a/.jjconflict-base-1/.github/workflows/job-scan.yml b/.jjconflict-base-1/.github/workflows/job-scan.yml new file mode 100644 index 0000000000..3efab69fc5 --- /dev/null +++ b/.jjconflict-base-1/.github/workflows/job-scan.yml @@ -0,0 +1,148 @@ +name: Vulnerability scan + +permissions: + contents: read + pull-requests: write + +on: + workflow_call: + inputs: + REGISTRY: + required: true + type: string + NAMESPACE: + required: true + type: string + TAG: + required: true + type: string + workflow_dispatch: + inputs: + REGISTRY: + description: Target registry to push images + required: true + type: string + default: ghcr.io + NAMESPACE: + description: Target namespace to the given registry + required: true + type: string + default: cloud-pi-native/console + TAG: + description: Tag used to scan images + required: true + type: string + default: latest + +jobs: + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + images-scan: + name: Scan images vulnerabilities + runs-on: ubuntu-latest + needs: + - matrix + strategy: + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run Trivy vulnerability scanner on images + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: "${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ inputs.TAG }}" + format: template + template: "@/contrib/sarif.tpl" + vuln-type: "os,library" + ignore-unfixed: true + output: trivy-results.sarif + github-pat: ${{ secrets.GITHUB_TOKEN }} + cache: "false" + continue-on-error: true + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: trivy-results.sarif + continue-on-error: true + + - name: Warn about error + if: failure() + run: | + echo ":warning: The Trivy scan report for image `${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ inputs.TAG }}` was not successfully completed and could not be uploaded to the Github Security Panel." >> $GITHUB_STEP_SUMMARY + + config-scan: + name: Scan config files vulnerabilities + runs-on: ubuntu-latest + needs: + - matrix + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run Trivy vulnerability scanner on config files + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + scan-ref: . + scan-type: config + format: template + template: "@/contrib/sarif.tpl" + skip-dirs: "**/node_modules,ci" + ignore-unfixed: true + output: trivy-results.sarif + github-pat: ${{ secrets.GITHUB_TOKEN }} + cache: "false" + continue-on-error: true + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: trivy-results.sarif + continue-on-error: true + + - name: Warn about error + if: failure() + run: | + echo ":warning: The Trivy scan report for configuration was not successfully completed and could not be uploaded to the Github Security Panel." >> $GITHUB_STEP_SUMMARY + + scan-notif: + name: Notify users + runs-on: ubuntu-latest + needs: + - matrix + - images-scan + - config-scan + if: ${{ github.event_name == 'pull_request' }} + env: + MERGE_REQUEST_ID: ${{ github.event.pull_request.number }} + COMMENT: | + 🤖 Hey ! + + The security scan report for the current pull request is available [here](https://github.com/${{ github.repository }}/security/code-scanning?query=is%3Aopen+branch%3Amain+pr%3A${{ github.event.pull_request.number || github.event.number }}). + steps: + - name: Add comment on Merge Request + id: add-comment-on-merge-request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.MERGE_REQUEST_ID, + body: process.env.COMMENT, + }); diff --git a/.jjconflict-base-1/.github/workflows/job-tests-unit.yml b/.jjconflict-base-1/.github/workflows/job-tests-unit.yml new file mode 100644 index 0000000000..993ff302cf --- /dev/null +++ b/.jjconflict-base-1/.github/workflows/job-tests-unit.yml @@ -0,0 +1,177 @@ +name: Tests - unit + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + secrets: + SONAR_HOST_URL: + required: false + SONAR_TOKEN: + required: false + SONAR_PROJECT_KEY: + required: false + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + +jobs: + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm -r install --frozen-lockfile + + - name: Build internal dependencies (packages) + run: pnpm --filter "./packages/**" build + + - name: Build internal plugins + run: pnpm --filter "./plugins/**" build + + - name: Run unit tests + run: | + ./ci/scripts/init-env.sh + ./ci/scripts/run-tests.sh -u + + - name: Upload vitest coverage artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: unit-tests-coverage + path: | + ./apps/**/coverage/lcov.info + ./packages/**/coverage/lcov.info + retention-days: 1 + + check-secrets: + name: Check if Sonar secrets are set + runs-on: ubuntu-latest + needs: + - unit-tests + outputs: + run-scan: ${{ steps.check-secrets.outputs.run-scan }} + steps: + - name: Check for sonar secrets + id: check-secrets + run: | + if [ "${{ secrets.SONAR_HOST_URL }}" != "" ] && [ "${{ secrets.SONAR_TOKEN }}" != "" ] && [ "${{ secrets.SONAR_PROJECT_KEY }}" != "" ]; then + echo "run-scan=true" >> $GITHUB_OUTPUT + else + echo "run-scan=false" >> $GITHUB_OUTPUT + fi + + code-scan: + name: Run code quality analysis + runs-on: ubuntu-latest + needs: + - unit-tests + - check-secrets + if: ${{ needs.check-secrets.outputs.run-scan == 'true' }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Download artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unit-tests-coverage + path: ./coverage + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set sonarqube args + id: sonar-args + run: | + echo "SONAR_ARGS_PR=-Dsonar.pullrequest.provider=github -Dsonar.pullrequest.key=${{ github.event.number }} -Dsonar.pullrequest.branch=${{ github.head_ref }} -Dsonar.pullrequest.base=${{ github.base_ref }} -Dsonar.pullrequest.github.repository=${{ github.repository }}" >> $GITHUB_OUTPUT + echo "SONAR_ARGS_BRANCH=-Dsonar.branch.name=${{ github.ref_name }}" >> $GITHUB_OUTPUT + + - name: SonarQube Scan + uses: sonarsource/sonarqube-scan-action@299e4b793aaa83bf2aba7c9c14bedbb485688ec4 # v7.1.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + args: > + -Dsonar.projectKey=${{ secrets.SONAR_PROJECT_KEY }} + -Dsonar.sources=apps,packages + -Dsonar.javascript.node.maxspace=4096 + -Dsonar.javascript.lcov.reportPaths=coverage/apps/server/coverage/lcov.info,coverage/apps/client/coverage/lcov.info,coverage/packages/shared/coverage/lcov.info + -Dsonar.coverage.exclusions=**/*.spec.js,**/*.spec.ts,**/*.vue,**/assets/**,**/packages/test-utils/**,apps/server/src/plugins/** + -Dsonar.cpd.exclusions=**/*.spec.js,**/*.spec.ts + -Dsonar.scm.provider=git + ${{ github.event_name == 'pull_request' && steps.sonar-args.outputs.SONAR_ARGS_PR || steps.sonar-args.outputs.SONAR_ARGS_BRANCH }} + continue-on-error: true + + - name: SonarQube Quality Gate check + id: sonarqube-quality-gate-check + uses: sonarsource/sonarqube-quality-gate-action@cf038b0e0cdecfa9e56c198bbb7d21d751d62c3b # v1.2.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + timeout-minutes: 5 + continue-on-error: true diff --git a/.jjconflict-base-1/.github/workflows/workflow-clean-cache.yml b/.jjconflict-base-1/.github/workflows/workflow-clean-cache.yml new file mode 100644 index 0000000000..d1e5b60e95 --- /dev/null +++ b/.jjconflict-base-1/.github/workflows/workflow-clean-cache.yml @@ -0,0 +1,94 @@ +name: Clean cache + +permissions: + contents: read + # For cache cleaning + actions: write + # For artifacts deletion + packages: write + +on: + pull_request: + types: + - closed + workflow_dispatch: + inputs: + PR_NUMBER: + description: Related pull request Id + required: false + type: number + BRANCH_NAME: + description: Related branch name + required: false + type: string + +jobs: + cleanup-cache: + name: Delete github cache + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Clean cache for closed branch + run: | + REPO=${{ github.repository }} + if [ -n "${{ inputs.BRANCH_NAME }}" ]; then + BRANCH="${{ inputs.BRANCH_NAME }}" + else + BRANCH="refs/pull/${{ github.event.pull_request.number || inputs.PR_NUMBER }}/merge" + fi + + echo "Fetching list of cache key" + cacheKeysForPR=$(gh cache list -R $REPO --ref $BRANCH -L 100 | cut -f 1 ) + + ## Setting this to not fail the workflow while deleting cache keys. + set +e + echo "Deleting caches..." + for cacheKey in $cacheKeysForPR; do + gh cache delete $cacheKey -R $REPO + done + echo "Done" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + cleanup-image: + name: Delete image from ghcr.io + runs-on: ubuntu-latest + needs: + - matrix + strategy: + fail-fast: false + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Get repository owner and name + id: image-infos + run: | + echo "ORG_NAME=$(echo '${{ github.repository }}' | cut -d "/" -f 1)" >> $GITHUB_OUTPUT + echo "REPO_NAME=$(echo '${{ github.repository }}' | cut -d "/" -f 2)" >> $GITHUB_OUTPUT + + - name: Delete ${{ matrix.images.name }} image + run: | + ./ci/scripts/delete-image.sh \ + -o "${{ steps.image-infos.outputs.ORG_NAME }}" \ + -i "${{ steps.image-infos.outputs.REPO_NAME }}/${{ matrix.images.name }}" \ + -t "pr-${{ github.event.pull_request.number || github.event.number }}" \ + -g "${{ secrets.GITHUB_TOKEN }}" diff --git a/.jjconflict-base-1/.github/workflows/workflow-continuous-integration.yml b/.jjconflict-base-1/.github/workflows/workflow-continuous-integration.yml new file mode 100644 index 0000000000..1553fddc93 --- /dev/null +++ b/.jjconflict-base-1/.github/workflows/workflow-continuous-integration.yml @@ -0,0 +1,150 @@ +name: Continuous Integration + +permissions: + contents: read + # Needed for build job + packages: write + # Needed for build-label and scan-vuln jobs + pull-requests: write + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + branches: + - "**" + workflow_dispatch: + +env: + NODE_VERSION: 24.13.1 + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: false + USE_QEMU: false + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ steps.filter.outputs.apps }} + packages: ${{ steps.filter.outputs.packages }} + e2e: ${{ steps.filter.outputs.e2e }} + ci: ${{ steps.filter.outputs.ci }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + ci: + - '.github/workflows/**' + e2e: + - 'playwright/**' + + expose-vars: + runs-on: ubuntu-latest + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + lint: + uses: ./.github/workflows/job-lint.yml + needs: + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + + unit-tests: + uses: ./.github/workflows/job-tests-unit.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' }} + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + secrets: + SONAR_HOST_URL: "${{ secrets.SONAR_HOST_URL }}" + SONAR_TOKEN: "${{ secrets.SONAR_TOKEN }}" + SONAR_PROJECT_KEY: "${{ secrets.SONAR_PROJECT_KEY }}" + + build: + uses: ./.github/workflows/job-build.yml + if: ${{ github.event.pull_request.number && (needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true') }} + needs: + - path-filter + - expose-vars + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.pull_request.number || github.event.number }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.number }} + secrets: + ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }} + + build-label: + uses: ./.github/workflows/job-label.yml + needs: + - expose-vars + - build + with: + CONF_PATH: ./.github/labeler/build.yml + + scan-vuln: + uses: ./.github/workflows/job-scan.yml + needs: + - expose-vars + - build + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.pull_request.number || github.event.number }} + + # Workaround for required status check in protection branches (see. https://github.com/orgs/community/discussions/13690) + all-jobs-passed: + name: Check jobs status + runs-on: ubuntu-latest + if: ${{ always() }} + needs: + - path-filter + - expose-vars + - lint + - unit-tests + - build + - scan-vuln + steps: + - name: Check status of all required jobs + run: |- + NEEDS_CONTEXT='${{ toJson(needs) }}' + JOB_IDS=$(echo "$NEEDS_CONTEXT" | jq -r 'keys[]') + for JOB_ID in $JOB_IDS; do + RESULT=$(echo "$NEEDS_CONTEXT" | jq -r ".[\"$JOB_ID\"].result") + echo "$JOB_ID job result: $RESULT" + if [[ $RESULT != "success" && $RESULT != "skipped" ]]; then + echo "***" + echo "Error: The $JOB_ID job did not pass." + exit 1 + fi + done + echo "All jobs passed or were skipped." diff --git a/.jjconflict-base-1/.github/workflows/workflow-create-or-update-release.yml b/.jjconflict-base-1/.github/workflows/workflow-create-or-update-release.yml new file mode 100644 index 0000000000..70d77d68c7 --- /dev/null +++ b/.jjconflict-base-1/.github/workflows/workflow-create-or-update-release.yml @@ -0,0 +1,93 @@ +name: Create/update next release + +permissions: + contents: write + pull-requests: write + # Needed for build job + packages: write + +on: + push: + branches: + - main + - hotfix/* + +env: + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: true + USE_QEMU: false + NODE_VERSION: 24.13.1 + +jobs: + expose-vars: + runs-on: ubuntu-latest + outputs: + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + NODE_VERSION: ${{ env.NODE_VERSION }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars" + + release: + uses: ./.github/workflows/job-release-please.yml + + build-amd64: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.release.outputs.release-created == 'true' }} + needs: + - expose-vars + - release + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: ${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} + MAJOR_TAG: ${{ needs.release.outputs.major-tag }} + MINOR_TAG: ${{ needs.release.outputs.minor-tag }} + PATCH_TAG: ${{ needs.release.outputs.patch-tag }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: false + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + + build-arm64: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.release.outputs.release-created == 'true' }} + needs: + - expose-vars + - release + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: ${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} + MAJOR_TAG: ${{ needs.release.outputs.major-tag }} + MINOR_TAG: ${{ needs.release.outputs.minor-tag }} + PATCH_TAG: ${{ needs.release.outputs.patch-tag }} + BUILD_AMD64: false + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + + update-chart: + runs-on: ubuntu-latest + needs: + - expose-vars + - release + - build-amd64 + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: helm-charts + - name: Trigger helm-charts update + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + run: | + gh workflow --repo cloud-pi-native/helm-charts run update-chart.yaml -f APP_VERSION=${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} -f CHART_NAME=dso-console diff --git a/.jjconflict-base-1/.github/workflows/workflow-create-preview-comment.yml b/.jjconflict-base-1/.github/workflows/workflow-create-preview-comment.yml new file mode 100644 index 0000000000..e91b3731b1 --- /dev/null +++ b/.jjconflict-base-1/.github/workflows/workflow-create-preview-comment.yml @@ -0,0 +1,43 @@ +name: Add preview comment + +permissions: + pull-requests: write + +on: + pull_request: + types: + - labeled + branches: + - "**" + +jobs: + preview-comment: + name: Add comment with preview infos + if: contains(github.event.pull_request.labels.*.name, 'preview') + runs-on: ubuntu-latest + steps: + - name: Generate app url + id: generate-url + run: | + echo "CONSOLE_URL=$(echo "${{ vars.ARGOCD_CONSOLE_URL_TEMPLATE }}" | sed 's|<pr_number>|${{ github.event.number }}|g')" >> $GITHUB_OUTPUT + + - name: Add comment on Merge Request + id: add-comment-on-merge-request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MERGE_REQUEST_ID: ${{ github.event.pull_request.number }} + COMMENT: | + 🤖 Hey ! + + A preview of the application is available at : ${{ steps.generate-url.outputs.CONSOLE_URL }} + + *Please be patient, deployment may take a few minutes.* + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.MERGE_REQUEST_ID, + body: process.env.COMMENT, + }); diff --git a/.jjconflict-base-1/.github/workflows/workflow-merge-queue.yml b/.jjconflict-base-1/.github/workflows/workflow-merge-queue.yml new file mode 100644 index 0000000000..cca23047d1 --- /dev/null +++ b/.jjconflict-base-1/.github/workflows/workflow-merge-queue.yml @@ -0,0 +1,166 @@ +name: Merge Queue + +permissions: + contents: read + # Needed for build job + packages: write + # Needed for build-label job + pull-requests: write + +on: + workflow_dispatch: + inputs: + PR_NUMBER: + description: Pull request number to run checks for + required: true + type: string + merge_group: + types: [checks_requested] + +env: + NODE_VERSION: 24.13.1 + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: false + USE_QEMU: false + MERGE_GROUP_HEAD_SHA: "${{ github.event.merge_group.head_sha || github.sha }}" + MERGE_GROUP_HEAD_REF: "${{ github.event.merge_group.head_ref || github.ref_name }}" + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.apps }} + packages: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.packages }} + e2e: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.e2e }} + ci: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.ci }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + if: ${{ github.event_name != 'workflow_dispatch' }} + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + ci: + - '.github/workflows/**' + e2e: + - 'playwright/**' + + expose-vars: + runs-on: ubuntu-latest + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + MERGE_GROUP_HEAD_REF: ${{ env.MERGE_GROUP_HEAD_REF }} + MERGE_GROUP_HEAD_SHA: ${{ env.MERGE_GROUP_HEAD_SHA }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + lint: + uses: ./.github/workflows/job-lint.yml + needs: + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + + unit-tests: + uses: ./.github/workflows/job-tests-unit.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + secrets: + SONAR_HOST_URL: "${{ secrets.SONAR_HOST_URL }}" + SONAR_TOKEN: "${{ secrets.SONAR_TOKEN }}" + SONAR_PROJECT_KEY: "${{ secrets.SONAR_PROJECT_KEY }}" + + build: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + PR_NUMBER: ${{ github.event.inputs.PR_NUMBER || github.event.number || '' }} + secrets: + ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }} + + build-label: + uses: ./.github/workflows/job-label.yml + needs: + - expose-vars + - build + with: + CONF_PATH: ./.github/labeler/build.yml + + playwright-tests: + uses: ./.github/workflows/job-playwright.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + - build + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + + scan-vuln: + uses: ./.github/workflows/job-scan.yml + needs: + - expose-vars + - build + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + + # Workaround for required status check in protection branches (see. https://github.com/orgs/community/discussions/13690) + all-jobs-passed: + name: Check jobs status + runs-on: ubuntu-latest + if: ${{ always() }} + needs: + - path-filter + - expose-vars + - lint + - unit-tests + - build + - playwright-tests + - scan-vuln + steps: + - name: Check status of all required jobs + run: |- + NEEDS_CONTEXT='${{ toJson(needs) }}' + JOB_IDS=$(echo "$NEEDS_CONTEXT" | jq -r 'keys[]') + for JOB_ID in $JOB_IDS; do + RESULT=$(echo "$NEEDS_CONTEXT" | jq -r ".[\"$JOB_ID\"].result") + echo "$JOB_ID job result: $RESULT" + if [[ $RESULT != "success" && $RESULT != "skipped" ]]; then + echo "***" + echo "Error: The $JOB_ID job did not pass." + exit 1 + fi + done + echo "All jobs passed or were skipped." diff --git a/.jjconflict-base-1/.github/workflows/workflow-post-merge.yml b/.jjconflict-base-1/.github/workflows/workflow-post-merge.yml new file mode 100644 index 0000000000..da61baa341 --- /dev/null +++ b/.jjconflict-base-1/.github/workflows/workflow-post-merge.yml @@ -0,0 +1,56 @@ +name: Post-merge + +on: + push: + branches: + - main + +env: + NODE_VERSION: 24.13.1 + +permissions: + contents: read + id-token: write + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ steps.filter.outputs.apps }} + packages: ${{ steps.filter.outputs.packages }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + + expose-vars: + runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.draft }} + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + npm-publish: + uses: ./.github/workflows/job-npm.yml + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + PUBLISH_PACKAGES: true + PUBLISH_PLUGINS: true + CHECK_CHANGES: true + # Deprecated variable: we never publish the apps on NPM registry + PUBLISH_APPS: false diff --git a/.jjconflict-base-1/.gitignore b/.jjconflict-base-1/.gitignore new file mode 100644 index 0000000000..0d127c9b66 --- /dev/null +++ b/.jjconflict-base-1/.gitignore @@ -0,0 +1,77 @@ +# code +.vscode/* +!.vscode/vue-dsfr.code-snippets +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + + +# modules +node_modules +**/node_modules + +# env +.env +.env.ci +.env.test +.env.prod +.env.int +.env.integ +.env.codespace +.env.docker + +# init-db +apps/server/src/init/db/imports/data + +# act +**/act/artifacts +**/act/docker/registry/auth +**/act/docker/registry/data + +# vitest +coverage +apps/client/vite.config.*.timestamp-*.mjs +apps/server/vite.config.*.timestamp-*.mjs + +# sonarqube +.scannerwork + +# keycloak +keycloak/data + +# turbo +.turbo + +# kube config +.kubeconfig + +# dist +dist +build +components.d.ts +*.tsbuildinfo + +# types +types/ + +# plugins +apps/server/src/plugins/external + +# helm +charts/ + +# integration helm values +**/env/dso-values-integ.yaml +**/env/kind-values.yml + +# tmp dev files +ci/kind/configs/rendered + +external-plugins + +# Playwright +playwright/test-results/ +playwright/playwright-report/ +playwright/blob-report/ +playwright/playwright/.cache/ diff --git a/.jjconflict-base-1/.husky/commit-msg b/.jjconflict-base-1/.husky/commit-msg new file mode 100755 index 0000000000..8761c9bac9 --- /dev/null +++ b/.jjconflict-base-1/.husky/commit-msg @@ -0,0 +1 @@ +pnpx commitlint --edit ${1} diff --git a/.jjconflict-base-1/.lintstagedrc b/.jjconflict-base-1/.lintstagedrc new file mode 100644 index 0000000000..741d5e9ca3 --- /dev/null +++ b/.jjconflict-base-1/.lintstagedrc @@ -0,0 +1,8 @@ +{ + "**/apps/client/**/*.{vue,css}": "pnpm --filter=@cpn-console/client run lint:style --", + "**/apps/client/**/*": "pnpm --filter=@cpn-console/client run lint:ts --", + "**/apps/server/**/*": "pnpm --filter=@cpn-console/server run lint --", + "**/packages/**/*": "pnpm --filter=./packages/* run lint --", + "**/plugins/**/*": "pnpm --filter=./plugins/* run lint --", + "**/*": "pnpm run lint:root --" +} diff --git a/.jjconflict-base-1/.pinact.yaml b/.jjconflict-base-1/.pinact.yaml new file mode 100644 index 0000000000..3b20588ab3 --- /dev/null +++ b/.jjconflict-base-1/.pinact.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/pinact/refs/heads/main/json-schema/pinact.json +# pinact - https://github.com/suzuki-shunsuke/pinact +version: 3 +# files: +# - pattern: action.yaml +# - pattern: */action.yaml + +# separator: " # " + +ignore_actions: +# - name: slsa-framework/slsa-github-generator/\.github/workflows/generator_generic_slsa3\.yml +# ref: v\d+\.\d+\.\d+ +# - name: actions/.* +# ref: main +# - name: suzuki-shunsuke/.* +# ref: release-.* diff --git a/.jjconflict-base-1/.release-please-manifest.json b/.jjconflict-base-1/.release-please-manifest.json new file mode 100644 index 0000000000..18953d7442 --- /dev/null +++ b/.jjconflict-base-1/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "9.22.2" +} diff --git a/.jjconflict-base-1/.vscode/extensions.json b/.jjconflict-base-1/.vscode/extensions.json new file mode 100644 index 0000000000..3795e131e1 --- /dev/null +++ b/.jjconflict-base-1/.vscode/extensions.json @@ -0,0 +1,30 @@ +{ + "recommendations": [ + // Cross-editor config + "editorconfig.editorconfig", + // Git + "mhutchie.git-graph", + "eamodio.gitlens", + // Node / npm + "christian-kohler.npm-intellisense", + // JavaScript + "dbaeumer.vscode-eslint", + // Vue.js + "vue.volar", + "vue.vscode-typescript-vue-plugin", + // Vitejs + "antfu.vite", + "antfu.browse-lite", + // Firefox Debugger + "firefox-devtools.vscode-firefox-debug", + // CSS + "csstools.postcss", + "stylelint.vscode-stylelint", + // YAML + "redhat.vscode-yaml", + // Makdown + "yzhang.markdown-all-in-one", + // Kubernetes + "ms-kubernetes-tools.vscode-kubernetes-tools" + ] +} diff --git a/.jjconflict-base-1/.vscode/launch.json b/.jjconflict-base-1/.vscode/launch.json new file mode 100644 index 0000000000..fbfb2f1013 --- /dev/null +++ b/.jjconflict-base-1/.vscode/launch.json @@ -0,0 +1,326 @@ +{ + // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. + // Pointez pour afficher la description des attributs existants. + // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Server (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server", + "run", + "integ" + ], + "envFile": "${workspaceFolder}/apps/server/.env.integ", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server", + "run", + "dev" + ], + "envFile": "${workspaceFolder}/apps/server/.env", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Infra (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "run", + "integ" + ], + "console": "internalConsole", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Infra (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "run", + "dev" + ], + "console": "internalConsole", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server NestJS (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server-nestjs", + "run", + "start:dev" + ], + "envFile": "${workspaceFolder}/apps/server-nestjs/.env.integ", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server NestJS (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server-nestjs", + "run", + "start:dev" + ], + "envFile": "${workspaceFolder}/apps/server-nestjs/.env", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Attach Server", + "type": "node", + "request": "attach", + "port": 9229, + "address": "127.0.0.1", + "localRoot": "${workspaceFolder}/apps/server", + "remoteRoot": "/app/apps/server", + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ], + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Chrome) (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "integ", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env.integ", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Chrome)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Chrome) (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "dev", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Chrome)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Firefox) (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "integ", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env.integ", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Firefox)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Firefox) (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "dev", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Firefox)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Launch Client (Chrome)", + "type": "chrome", + "request": "launch", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}/apps/client", + "presentation": { + "hidden": true + } + }, + { + "name": "Launch Client (Firefox)", + "type": "firefox", + "request": "launch", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}/apps/client", + "presentation": { + "hidden": true + } + } + ], + "compounds": [ + { + "name": "Launch (Chrome) (Integ)", + "configurations": [ + "Launch Infra (Integ)", + "Launch Server (Integ)", + "Launch Server NestJS (Integ)", + "Serve Client (Chrome) (Integ)" + ] + }, + { + "name": "Launch (Chrome) (Dev)", + "configurations": [ + "Launch Infra (Dev)", + "Launch Server (Dev)", + "Launch Server NestJS (Dev)", + "Serve Client (Chrome) (Dev)" + ] + }, + { + "name": "Launch (Firefox) (Integ)", + "configurations": [ + "Launch Infra (Integ)", + "Launch Server (Integ)", + "Launch Server NestJS (Integ)", + "Serve Client (Firefox) (Integ)" + ] + }, + { + "name": "Launch (Firefox) (Dev)", + "configurations": [ + "Launch Infra (Dev)", + "Launch Server (Dev)", + "Launch Server NestJS (Dev)", + "Serve Client (Firefox) (Dev)" + ] + } + ] +} diff --git a/.jjconflict-base-1/.vscode/settings.json b/.jjconflict-base-1/.vscode/settings.json new file mode 100644 index 0000000000..1b93a34e04 --- /dev/null +++ b/.jjconflict-base-1/.vscode/settings.json @@ -0,0 +1,120 @@ +{ + // visuals + "window.autoDetectColorScheme": true, + "editor.fontLigatures": "'ss01', 'ss02', 'ss03', 'ss06', 'zero'", + "editor.cursorBlinking": "phase", + "editor.suggestSelection": "first", + "editor.wordWrap": "on", + "editor.bracketPairColorization.enabled": true, + "editor.tabSize": 2, + "search.exclude": { + "**/.git": true, + "**/.nuxt": true, + "**/.pnpm": true, + "**/.yarn": true, + "**/.turbo": true, + "**/dist/**": true, + "**/out/**": true, + "**/logs": true, + "**/node_modules": true, + "**/package-lock.json": true, + "**/pnpm-lock.yaml": true, + "**/yarn.lock": true + }, + // general + "editor.unicodeHighlight.invisibleCharacters": false, + "workbench.startupEditor": "none", + // git + "git.autofetch": true, + "git.untrackedChanges": "separate", + // github + "githubPullRequests.pullBranch": "never", + // eslint + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "vue", + "html", + "markdown", + "json", + "jsonc", + "yaml", + "github-actions-workflow", + "toml", + "xml", + "gql", + "graphql", + "astro", + "svelte", + "css", + "less", + "scss", + "pcss", + "postcss" + ], + // stylelint + "css.validate": true, + "less.validate": true, + "scss.validate": true, + "files.associations": { + "*.css": "css" + }, + // languages + "[javascript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "javascript.preferences.quoteStyle": "single", + "javascript.preferences.importModuleSpecifierEnding": "js", + "javascript.suggest.completeJSDocs": true, + "javascript.suggest.jsdoc.generateReturns": true, + "[typescript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "typescript.preferences.quoteStyle": "single", + "typescript.preferences.importModuleSpecifierEnding": "js", + "typescript.suggest.completeJSDocs": true, + "typescript.suggest.jsdoc.generateReturns": true, + "[vue]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[css]": { + "editor.defaultFormatter": "stylelint.vscode-stylelint" + }, + "[html]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[markdown]": { + "editor.quickSuggestions": { + "other": true, + "comments": true, + "strings": true + }, + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.defaultFormatter": "yzhang.markdown-all-in-one" + }, + // conventional commits + "conventionalCommits.showNewVersionNotes": false, + // kubernetes + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "vscode-kubernetes.log-viewer.follow": true, + "vscode-kubernetes.log-viewer.timestamp": false, + "vscode-kubernetes.log-viewer.since": -1, + "vscode-kubernetes.log-viewer.tail": -1, + "vscode-kubernetes.log-viewer.destination": "Webview", + "vscode-kubernetes.log-viewer.wrap": true, + // sonarqube + "sonarlint.connectedMode.project": { + "connectionId": "https-sonarqube-fabrique-numerique-fr", + "projectKey": "cloud-pi-native_console_AYsa46O31ebDtzs-pYyZ" + }, + "prisma-smart-formatter.typescript.defaultFormatter": "dbaeumer.vscode-eslint", + "prisma-smart-formatter.prisma.defaultFormatter": "Prisma.prisma" +} diff --git a/.jjconflict-base-1/.vscode/vue-dsfr.code-snippets b/.jjconflict-base-1/.vscode/vue-dsfr.code-snippets new file mode 100644 index 0000000000..2533f522d8 --- /dev/null +++ b/.jjconflict-base-1/.vscode/vue-dsfr.code-snippets @@ -0,0 +1,823 @@ +{ + "template": { + "scope": "vue", + "prefix": "template", + "body": [ + "<template>", + " $1", + "</template>" + ], + "description": "Create <template> block" + }, + "script": { + "scope": "vue", + "prefix": "script", + "body": [ + "<script>", + "import { defineComponent } from 'vue';", + "", + "export default defineComponent({", + " $2", + "});", + "</script>" + ], + "description": "Create <script> block" + }, + "script setup": { + "scope": "vue", + "prefix": "script setup", + "body": [ + "<script setup>", + "defineProps({", + " $1", + "});", + "</script>" + ], + "description": "Create <script setup> + <script> blocks" + }, + "style": { + "scope": "vue", + "prefix": "scoped-style", + "body": [ + "<style scoped>", + "$1", + "</style>" + ], + "description": "Create <style> block" + }, + "v-for": { + "scope": "html", + "prefix": "v-for", + "body": [ + "<template v-for=\"$1 in $2\">", + " $3", + "</template>" + ], + "description": "Create html tag with v-for" + }, + "v-if": { + "scope": "html", + "prefix": "v-if", + "body": [ + "<template v-if=\"$1\">", + " $2", + "</template>" + ], + "description": "Create html tag with v-if" + }, + "v-else-if": { + "scope": "html", + "prefix": "v-else-if", + "body": [ + "<template v-else-if=\"$1\">", + " $2", + "</template>" + ], + "description": "Create html tag with v-else-if" + }, + "v-else": { + "scope": "html", + "prefix": "v-else", + "body": [ + "<template v-else>", + " $1", + "</template>" + ], + "description": "Create html tag with v-else" + }, + "DsfrAccordion": { + "scope": "html", + "prefix": "DsfrAccordion", + "body": [ + "<DsfrAccordion", + " :expanded-id=\"${1:expandedId}\"", + " :title=\"${2:accordionTitle}\"", + ">", + " $3", + "</DsfrAccordion>", + ], + "description": "Create DsfrAccordion boilerplate" + }, + "DsfrAccordionsGroup": { + "scope": "html", + "prefix": "DsfrAccordionsGroup", + "body": [ + "<DsfrAccordionsGroup>", + " <li>", + " <DsfrAccordion", + " :expanded-id=\"${1:expandedId}\"", + " :title=\"${2:accordionTitle}\"", + " @expand=\"expandedId = $event\"", + " >", + " </li>", + "</DsfrAccordionsGroup>", + ], + "description": "Create DsfrAccordionsGroup boilerplate" + }, + "DsfrAlert": { + "scope": "html", + "prefix": "DsfrAlert", + "body": [ + "<DsfrAlert", + " :title=\"${1:alertTitle}\"", + " :description=\"${2:alertDescription}\"", + " :type=\"${3:alertType}\"", + " :closed=\"${4:!openAlert}\"", + " ${5:closeable}", + " @close=\"${6:closeAlert()}\"", + "/>", + ], + "description": "Create DsfrAlert boilerplate" + }, + "DsfrBadge": { + "scope": "html", + "prefix": "DsfrBadge", + "body": [ + "<DsfrBadge", + " :label=\"${1}\"", + " :type=\"${2:success}\"", + "/>", + ], + "description": "Create DsfrBadge boilerplate" + }, + "DsfrBreadcrumb": { + "scope": "html", + "prefix": "DsfrBreadcrumb", + "body": [ + "<DsfrBreadcrumb", + " :links=\"${1:links}\"", + "/>", + ], + "description": "Create DsfrBreadcrumb boilerplate" + }, + "DsfrButton": { + "scope": "html", + "prefix": "DsfrButton", + "body": [ + "<DsfrButton", + " type=\"${1:buttonType}\"", + " :label=\"${2:buttonLabel}\"", + " ${3:secondary}", + " @click=\"${4:onClick()}\"", + "/>", + ], + "description": "Create DsfrButton boilerplate" + }, + "DsfrButtonGroup": { + "scope": "html", + "prefix": "DsfrButtonGroup", + "body": [ + "<DsfrButtonGroup", + " :buttons=\"${1:buttons}\"", + " :label=\"${2:buttonLabel}\"", + " ${3:secondary}", + " @click=\"${4:onClick()}\"", + "/>", + ], + "description": "Create DsfrButtonGroup boilerplate" + }, + "DsfrCallout": { + "scope": "html", + "prefix": "DsfrCallout", + "body": [ + "<DsfrCallout", + " :title=\"${1:title}\"", + " :content=\"${2:content}\"", + " :button=\"${3:button}\"", + " :icon=\"${4:icon}\"", + "/>", + ], + "description": "Create DsfrCallout boilerplate" + }, + "DsfrCard": { + "scope": "html", + "prefix": "DsfrCard", + "body": [ + "<DsfrCard", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :detail=\"${3:detail}\"", + " :link=\"${4:link}\"", + " :alt-img=\"${5:altImg}\"", + " :img-src=\"${6:imgSrc}\"", + "/>", + ], + "description": "Create DsfrCard boilerplate" + }, + "DsfrCheckbox": { + "scope": "html", + "prefix": "DsfrCheckbox", + "body": [ + "<DsfrCheckbox", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + " :name=\"${3:name}\"", + " :modelValue=\"${4:checked}\"", + " @update:model-value=\"updateChecked($event)\"", + "/>", + ], + "description": "Create DsfrCheckbox boilerplate" + }, + "DsfrCheckboxSet": { + "scope": "html", + "prefix": "DsfrCheckboxSet", + "body": [ + "<DsfrCheckboxSet", + " v-model=\"${1:selectedCheckbox}\"", + " :legend=\"${2:legend}\"", + " :options=\"${3:options}\"", + "/>", + ], + "description": "Create DsfrCheckboxSet boilerplate" + }, + "DsfrConsent": { + "scope": "html", + "prefix": "DsfrConsent", + "body": [ + "<DsfrConsent", + " :url=\"${1:url}\"", + " @accept-all=\"${2:onAcceptAll()}\"", + " @refuse-all=\"${3:onRefuseAll()}\"", + " @customize=\"${3:onCustomize()}\"", + "/>", + ], + "description": "Create DsfrConsent boilerplate" + }, + "DsfrFieldset": { + "scope": "html", + "prefix": "DsfrFieldset", + "body": [ + "<DsfrFieldset", + " :legend=\"${1:legend}\"", + " :hint=\"${2:hint}\"", + " :legend-class=\"${1:legendClass}\"", + " :hint-class=\"${1:hintClass}\"", + " :legend-id=\"${1:legendId}\"", + "/>", + ], + "description": "Create DsfrFieldset boilerplate" + }, + "DsfrFileUpload": { + "scope": "html", + "prefix": "DsfrFileUpload", + "body": [ + "<DsfrFileUpload", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + "/>", + ], + "description": "Create DsfrFileUpload boilerplate" + }, + "DsfrFollow": { + "scope": "html", + "prefix": "DsfrFollow", + "body": [ + "<DsfrFollow", + " :networks=\"${1:networks}\"", + " :newsletter-data=\"${2:newsletterData}\"", + "/>", + ], + "description": "Create DsfrFollow boilerplate" + }, + "DsfrFranceConnect": { + "scope": "html", + "prefix": "DsfrFranceConnect", + "body": [ + "<DsfrFranceConnect ${1:secure} />", + ], + "description": "Create DsfrFranceConnect boilerplate" + }, + "DsfrNewsLetter": { + "scope": "html", + "prefix": "DsfrNewsLetter", + "body": [ + "<DsfrNewsLetter", + " v-model:email=\"${1:email}\"", + " :title=\"${2:title}\"", + " :description=\"${3:description}\"", + " :labelEmail=\"${4:labelEmail}\"", + " :inputTitle=\"${5:inputTitle}\"", + " :placeholder=\"${6:placeholder}\"", + " :hintText=\"${7:hintText}\"", + " :buttonText=\"${8:buttonText}\"", + "/>", + ], + "description": "Create DsfrNewsLetter boilerplate" + }, + "DsfrSocialNetworks": { + "scope": "html", + "prefix": "DsfrSocialNetworks", + "body": [ + "<DsfrSocialNetworks", + " :networks=\"${1:networks}\"", + "/>", + ], + "description": "Create DsfrSocialNetworks boilerplate" + }, + "DsfrFooter": { + "scope": "html", + "prefix": "DsfrFooter", + "body": [ + "<DsfrFooter", + " :a11y-compliance=\"${1:a11yCompliance}\"", + " :logo-text=\"${2:logoText}\"", + " :legal-link=\"${3:legalLink}\"", + " :personal-data-link=\"${4:personalDataLink}\"", + " :cookies-link=\"${5:cookiesLink}\"", + " :a11y-compliance-link=\"${6:a11yComplianceLink}\"", + " :desc-text=\"${7:descText}\"", + " :home-link=\"${8:homeLink}\"", + " :partners=\"${9:partners}\"", + " :ecosystem-links=\"${10:ecosystemLinks}\"", + "/>", + ], + "description": "Create DsfrFooter boilerplate" + }, + "DsfrHeader": { + "scope": "html", + "prefix": "DsfrHeader", + "body": [ + "<DsfrHeader", + " :service-title=\"${1:serviceTitle}\"", + " :service-description=\"${2:serviceDescription}\"", + " :logo-text=\"${3:logoText}\"", + " :quick-links=\"${4:quickLinks}\"", + " ${5:show-search}", + "/>", + ], + "description": "Create DsfrHeader boilerplate" + }, + "DsfrHighlight": { + "scope": "html", + "prefix": "DsfrHighlight", + "body": [ + "<DsfrHighlight", + " :text=\"${1:text}\"", + "/>", + ], + "description": "Create DsfrHighlight boilerplate" + }, + "DsfrInput": { + "scope": "html", + "prefix": "DsfrInput", + "body": [ + "<DsfrInput", + " v-model=\"${1:inputValue}\"", + " type=\"${2:inputType}\"", + " :label=\"${3:inputLabel}\"", + " ${4:label-visible}", + " :placeholder=\"${5:inputPlaceholder}\"", + "/>", + ], + "description": "Create DsfrInput boilerplate" + }, + "DsfrLogo": { + "scope": "html", + "prefix": "DsfrLogo", + "body": [ + "<DsfrLogo", + " logo-text=\"$1\"", + "/>", + ], + "description": "Create DsfrLogo boilerplate" + }, + "DsfrPicture": { + "scope": "html", + "prefix": "DsfrPicture", + "body": [ + "<DsfrPicture", + " :src=\"${1:src}\"", + " :alt=\"${2:alt}\"", + " :title=\"${3:title}\"", + " :legend=\"${4:legend}\"", + "/>", + ], + "description": "Create DsfrPicture boilerplate" + }, + "DsfrVideo": { + "scope": "html", + "prefix": "DsfrVideo", + "body": [ + "<DsfrVideo", + " :src=\"${1:src}\"", + " :legend=\"${2:legend}\"", + "/>", + ], + "description": "Create DsfrVideo boilerplate" + }, + "DsfrModal": { + "scope": "html", + "prefix": "DsfrModal", + "body": [ + "<DsfrButton", + " label=\"${1:Ouvre la modale}\"", + " ref=\"modalOrigin\"", + " @click=\"${2:open()}\"", + "/>", + "<DsfrModal", + " :opened=\"${3:opened}\"", + " :title=\"${4:title}\"", + " :origin=\"$refs.modalOrigin\"", + ">", + " $5", + "</DsfrModal>", + ], + "description": "Create DsfrModal boilerplate" + }, + "DsfrNavigation": { + "scope": "html", + "prefix": "DsfrNavigation", + "body": [ + "<DsfrNavigation", + " :nav-items=\"${1:navItems}\"", + "/>", + ], + "description": "Create DsfrNavigation boilerplate" + }, + "DsfrNavigationItem": { + "scope": "html", + "prefix": "DsfrNavigationItem", + "body": [ + "<DsfrNavigationItem :active=\"${1:active}\">", + " $2", + "</DsfrNavigationItem>", + ], + "description": "Create DsfrNavigationItem boilerplate" + }, + "DsfrNavigationMegaMenu": { + "scope": "html", + "prefix": "DsfrNavigationMegaMenu", + "body": [ + "<DsfrNavigationMegaMenu", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :link=\"${3:link}\"", + " :menus=\"${4:menus}\"", + " :expanded-id=\"${5:expandedId}\"", + " :@click=\"${6:toggle($event)}\"", + ">", + " $7", + "</DsfrNavigationMegaMenu>", + ], + "description": "Create DsfrNavigationMegaMenu boilerplate" + }, + "DsfrNavigationMegaMenuCategory": { + "scope": "html", + "prefix": "DsfrNavigationMegaMenuCategory", + "body": [ + "<DsfrNavigationMegaMenuCategory", + " :title=\"${1:title}\"", + " :links=\"${2:links}\"", + "/>", + ], + "description": "Create DsfrNavigationMegaMenuCategory boilerplate" + }, + "DsfrNavigationMenu": { + "scope": "html", + "prefix": "DsfrNavigationMenu", + "body": [ + "<DsfrNavigationMenu", + " :title=\"${1:title}\"", + " :links=\"${2:links}\"", + " :expanded-id=\"${3:expandedId}\"", + " :@click=\"${4:toggle($event)}\"", + "/>", + ], + "description": "Create DsfrNavigationMenu boilerplate" + }, + "DsfrNavigationMenuItem": { + "scope": "html", + "prefix": "DsfrNavigationMenuItem", + "body": [ + "<DsfrNavigationMenuItem", + " :active=\"${1:active}\"", + ">", + "</DsfrNavigationMenuItem>", + ], + "description": "Create DsfrNavigationMenuItem boilerplate" + }, + "DsfrNavigationMenuLink": { + "scope": "html", + "prefix": "DsfrNavigationMenuLink", + "body": [ + "<DsfrNavigationMenuLink", + " :to=\"${1:to}\"", + " :text=\"${2:text}\"", + "/>", + ], + "description": "Create DsfrNavigationMenuLink boilerplate" + }, + "DsfrPagination": { + "scope": "html", + "prefix": "DsfrPagination", + "body": [ + "<DsfrPagination", + " :pages=\"${1:pages}\"", + " v-model:current-page=\"${2:currentPage}\"", + "/>", + ], + "description": "Create DsfrPagination boilerplate" + }, + "DsfrQuote": { + "scope": "html", + "prefix": "DsfrQuote", + "body": [ + "<DsfrQuote", + " :quote=\"${1:quote}\"", + " :author=\"${2:author}\"", + " :details=\"${3:details}\"", + " :illustration=\"${4:illustration}\"", + " :source=\"${5:source}\"", + " :sourceUrl=\"${6:sourceUrl}\"", + " :quoteImage=\"${7:quoteImage}\"", + "/>", + ], + "description": "Create DsfrQuote boilerplate" + }, + "DsfrRadioButton": { + "scope": "html", + "prefix": "DsfrRadioButton", + "body": [ + "<DsfrRadioButton", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + " :name=\"${3:name}\"", + " :value=\"${4:value}\"", + " :model-value=\"${5:modelValue}\"", + " @update:model-value=\"${6:updateCheckedValue($event)}\"", + "/>", + ], + "description": "Create DsfrRadioButton boilerplate" + }, + "DsfrRadioButtonSet": { + "scope": "html", + "prefix": "DsfrRadioButtonSet", + "body": [ + "<DsfrRadioButtonSet", + " v-model=\"${1:modelValue}\"", + " :legend=\"${2:legend}\"", + " name=\"${2:radio-set}\"", + " :options=\"${4:options}\"", + " @update:model-value=\"${6:updateCheckedValue($event)}\"", + "/>", + ], + "description": "Create DsfrRadioButtonSet boilerplate" + }, + "DsfrSearchBar": { + "scope": "html", + "prefix": "DsfrSearchBar", + "body": [ + "<DsfrSearchBar", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :placeholder=\"${3:placeholder}\"", + " ${4:label-visible}", + " :button-text=\"${5:buttonText}\"", + "/>", + ], + "description": "Create DsfrSearchBar boilerplate" + }, + "DsfrSelect": { + "scope": "html", + "prefix": "DsfrSelect", + "body": [ + "<DsfrSelect", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :options=\"${3:options}\"", + "/>", + ], + "description": "Create DsfrSelect boilerplate" + }, + "DsfrShare": { + "scope": "html", + "prefix": "DsfrShare", + "body": [ + "<DsfrShare", + " :title=\"${1:title}\"", + " :networks=\"${2:networks}\"", + " :mail=\"${3:mail}\"", + " :copy-label=\"${4:copyLabel}\"", + "/>", + ], + "description": "Create DsfrShare boilerplate" + }, + "DsfrSideMenu": { + "scope": "html", + "prefix": "DsfrSideMenu", + "body": [ + "<DsfrSideMenu", + " :heading-title=\"${1:headingTitle}\"", + " :button-label=\"${2:buttonLabel}\"", + " :menu-items=\"${3:menuItems}\"", + " @toggle-expand=\"${4:toggleExpand($event)}\"", + "/>", + ], + "description": "Create DsfrSideMenu boilerplate" + }, + "DsfrSideMenuButton": { + "scope": "html", + "prefix": "DsfrSideMenuButton", + "body": [ + "<DsfrSideMenuButton", + " :expanded=\"${1:expanded}\"", + " :button-label=\"${2:buttonLabel}\"", + " :control-id=\"${3:controlId}\"", + " @toggle-expand=\"${4:toggleExpand($event)}\"", + "/>", + ], + "description": "Create DsfrSideMenuButton boilerplate" + }, + "DsfrSideMenuLink": { + "scope": "html", + "prefix": "DsfrSideMenuLink", + "body": [ + "<DsfrSideMenuLink", + " :active=\"${1:active}\"", + " :to=\"${2:to}\"", + "/>", + ], + "description": "Create DsfrSideMenuLink boilerplate" + }, + "DsfrSideMenuList": { + "scope": "html", + "prefix": "DsfrSideMenuList", + "body": [ + "<DsfrSideMenuList", + " :menu-items=\"${1:menuItems}\"", + " @toggle-expand=\"${2:toggleExpand}\"", + "/>", + ], + "description": "Create DsfrSideMenuList boilerplate" + }, + "DsfrSideMenuListItem": { + "scope": "html", + "prefix": "DsfrSideMenuListItem", + "body": [ + "<DsfrSideMenuListItem", + " :active=\"${1:active}\"", + ">", + " $2", + "</DsfrSideMenuListItem>", + ], + "description": "Create DsfrSideMenuListItem boilerplate" + }, + "DsfrSkipLinks": { + "scope": "html", + "prefix": "DsfrSkipLinks", + "body": [ + "<DsfrSkipLinks", + " :links=\"${1:links}\"", + "/>", + ], + "description": "Create DsfrSkipLinks boilerplate" + }, + "DsfrTable": { + "scope": "html", + "prefix": "DsfrTable", + "body": [ + "<DsfrTable", + " :title=\"${1:title}\"", + " :headers=\"${2:headers}\"", + " :rows=\"${3:rows}\"", + "/>", + ], + "description": "Create DsfrTable boilerplate" + }, + "DsfrTableCell": { + "scope": "html", + "prefix": "DsfrTableCell", + "body": [ + "<DsfrTableCell", + " :field=\"${1:field}\"", + "/>", + ], + "description": "Create DsfrTableCell boilerplate" + }, + "DsfrTableHeader": { + "scope": "html", + "prefix": "DsfrTableHeader", + "body": [ + "<DsfrTableHeader", + " :header=\"${1:header}\"", + " :header-attrs=\"${2:headerAttrs}\"", + "/>", + ], + "description": "Create DsfrTableHeader boilerplate" + }, + "DsfrTableHeaders": { + "scope": "html", + "prefix": "DsfrTableHeaders", + "body": [ + "<DsfrTableHeaders", + " :headers=\"${1:headers}\"", + "/>", + ], + "description": "Create DsfrTableHeaders boilerplate" + }, + "DsfrTableRow": { + "scope": "html", + "prefix": "DsfrTableRow", + "body": [ + "<DsfrTableRow", + " :row-data=\"${1:rowData}\"", + "/>", + ], + "description": "Create DsfrTableRow boilerplate" + }, + "DsfrTabContent": { + "scope": "html", + "prefix": "DsfrTabContent", + "body": [ + "<DsfrTabContent", + " :panel-id=\"${1:panelId}\"", + " :tab-id=\"${2:tabId}\"", + " :selected=\"${3:selected}\"", + " :asc=\"${4:asc}\"", + ">", + " $5", + "</DsfrTabContent>", + ], + "description": "Create DsfrTabContent boilerplate" + }, + "DsfrTabItem": { + "scope": "html", + "prefix": "DsfrTabItem", + "body": [ + "<DsfrTabItem", + " :tab-id=\"${2:tabId}\"", + " :panel-id=\"${1:panelId}\"", + " :selected=\"${3:selected}\"", + " :icon=\"${4:icon}\"", + ">", + " $5", + "</DsfrTabItem>", + ], + "description": "Create DsfrTabItem boilerplate" + }, + "DsfrTabs": { + "scope": "html", + "prefix": "DsfrTabs", + "body": [ + "<DsfrTabs", + " :tab-list-name=\"${1:tabListName}\"", + " :tab-titles=\"${2:tabTitles}\"", + " :tab-contents=\"${3:tabContents}\"", + "/>", + ], + "description": "Create DsfrTabs boilerplate" + }, + "DsfrTag": { + "scope": "html", + "prefix": "DsfrTag", + "body": [ + "<DsfrTag", + " :label=\"${1:label}\"", + "/>", + ], + "description": "Create DsfrTag boilerplate" + }, + "DsfrTags": { + "scope": "html", + "prefix": "DsfrTags", + "body": [ + "<DsfrTags", + " :tags=\"${1:tags}\"", + "/>", + ], + "description": "Create DsfrTags boilerplate" + }, + "DsfrTile": { + "scope": "html", + "prefix": "DsfrTile", + "body": [ + "<DsfrTile", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :to=\"${3:to}\"", + " :rows=\"${4:rows}\"", + "/>", + ], + "description": "Create DsfrTile boilerplate" + }, + "DsfrTiles": { + "scope": "html", + "prefix": "DsfrTiles", + "body": [ + "<DsfrTiles :tiles=\"${1:tiles}\" />", + ], + "description": "Create DsfrTiles boilerplate" + }, + "DsfrToggleSwitch": { + "scope": "html", + "prefix": "DsfrToggleSwitch", + "body": [ + "<DsfrToggleSwitch", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :hint=\"${2:hint}\"", + "/>", + ], + "description": "Create DsfrToggleSwitch boilerplate" + }, +} \ No newline at end of file diff --git a/.jjconflict-base-1/AGENTS.md b/.jjconflict-base-1/AGENTS.md new file mode 100644 index 0000000000..77866c7703 --- /dev/null +++ b/.jjconflict-base-1/AGENTS.md @@ -0,0 +1,85 @@ +# Console Cloud Pi Native + +pnpm monorepo. Node >= 24, pnpm v11.8 + +## Structure + +- `apps/client` : Vue 3 + Vite + vue-dsfr (French gov design system), Pinia, UnoCSS +- `apps/server` : Fastify 4 + Prisma 6 (PostgreSQL), contract-first API via @ts-rest +- `apps/server-nestjs` : NestJS rewrite (in progress) +- `plugins/*` : argocd, gitlab, harbor, keycloak, kubernetes, nexus, sonarqube, vault +- `packages/shared` : API contracts (@ts-rest), types, permissions (BigInt bitmasks) +- `packages/hooks` : plugin hook system (core of plugin architecture) +- `packages/test-utils`, `packages/eslintconfig`, `packages/tsconfig` +- `playwright/` : E2E tests (dedicated workspace) +- `docker/` : docker-compose files (local, dev, integ, prod, ci) +- `ci/scripts/` : init-env.sh, setup.sh, cleanup.sh, run-tests.sh +- `keycloak/` : DSFR theme and dev realm data + +## Server architecture (Fastify) + +Resource-based organization in `apps/server/src/resources/`. Each resource follows a 3-file pattern: +- `router.ts` : route handlers (auth, permissions, delegates to business) +- `business.ts` : business logic, orchestrates queries + hook calls +- `queries.ts` : Prisma database queries + +API contracts defined in `@cpn-console/shared`, shared with client via @ts-rest. +Auth: Keycloak + Fastify session. Permissions: BigInt bitmasks (`ProjectAuthorized`, `AdminAuthorized`). + +## Plugin / Hook system + +Hook lifecycle: `pre` -> `main` -> `post` (sequential steps, parallel plugin execution). On failure: `revert`. +Plugins are statically imported in `apps/server/src/plugins.ts`, then external plugins dynamically loaded from `/plugins`. +Each plugin: `index.ts` (Plugin interface), `infos.ts` (metadata/config), `functions.ts` (hook handlers). +Plugins use TS module augmentation to extend `ProjectStore` and `Config` interfaces. + +## Database (Prisma) + +Multi-file schema in `apps/server/src/prisma/schema/*.prisma` (project, user, token, admin, topography). +Migrations: standard Prisma Migrate. Major version data migrations in `migrations/v9/`. + +## Environment config + +- Files: `.env`, `.env.docker`, `.env.integ` in `apps/client/`, `apps/server/`, `apps/server-nestjs/` +- Templates: `*-example` suffix (git-tracked), active files gitignored +- Override chain (weakest to strongest): `.env` < `.env.docker` (if DOCKER=true) < `.env.integ` (if INTEGRATION=true) < explicit env vars +- Server loading: `apps/server/src/utils/env.ts` | Client: `apps/client/vite.config.ts` + +## Testing + +- **Vitest**: unit tests everywhere (server, client, packages, plugins) — colocated `*.spec.ts` files +- **Playwright**: E2E in `playwright/` (Chromium + Firefox, parallel) +- Commands: `pnpm test` (all unit), `pnpm playwright:test` + +## Code quality + +- ESLint 9 flat config based on `@antfu/eslint-config` (no Prettier, except server-nestjs) +- Stylelint for CSS/Vue in client +- Husky hooks: pre-commit (lint-staged), commit-msg (commitlint), pre-push (unit tests) +- Conventional commits enforced: `feat`, `fix`, `chore`, `docs`, `refactor`, `revert`, `build` + +## TypeScript + +- Shared base: `packages/tsconfig/tsconfig.base.json` — ESNext, NodeNext, strict, `@/* -> src/*` alias +- Server: extends shared base, uses `ts-patch`/`tspc` for path transform in emitted JS +- Client: does NOT extend shared base, uses `Bundler` module resolution +- server-nestjs: standalone config with `emitDecoratorMetadata` + `experimentalDecorators` + +## Main commands + +- `pnpm dev` : local Docker infra (keycloak, postgres, pgadmin) + server/client +- `pnpm docker:dev` : fully containerized with Docker Compose Watch +- `pnpm docker:integ` / `pnpm integ` : integration mode (remote env) +- `pnpm fullsetup` : full install (deps, prisma generate, build, Docker images) +- `pnpm fullclean` : full cleanup (builds, node_modules, Docker images) + +## Git & Release + +- Branches: `main` (protected) + `hotfix/*` +- Release Please for automated versioning, changelogs, npm publish, Docker images, Helm chart updates +- PR template: `.github/PULL_REQUEST_TEMPLATE.md` + +## Conventions + +- Template env files use `-example` suffix (not `.example`) +- `ci/scripts/init-env.sh` copies `*-example` to active equivalents (non-destructive) diff --git a/.jjconflict-base-1/CHANGELOG.md b/.jjconflict-base-1/CHANGELOG.md new file mode 100644 index 0000000000..642437ef86 --- /dev/null +++ b/.jjconflict-base-1/CHANGELOG.md @@ -0,0 +1,3507 @@ +# Changelog + +## [9.22.2](https://github.com/cloud-pi-native/console/compare/v9.22.1...v9.22.2) (2026-07-24) + + +### Bug Fixes + +* ArgoCD internal url for health check ([9429667](https://github.com/cloud-pi-native/console/commit/942966785e23fca986246948033afe60d40d7232)) + +## [9.22.1](https://github.com/cloud-pi-native/console/compare/v9.22.0...v9.22.1) (2026-07-21) + + +### Bug Fixes + +* **plugins/nexus:** remove invalid cleanup policy from repo provisioning to fix Nexus 3.85+ ([8b99cb6](https://github.com/cloud-pi-native/console/commit/8b99cb6a7807dac1f6ba525a6ee3164f3fe13141)) + +## [9.22.0](https://github.com/cloud-pi-native/console/compare/v9.21.0...v9.22.0) (2026-07-21) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** restrict deployment visibility to console admins ([def61e7](https://github.com/cloud-pi-native/console/commit/def61e76b3a867445c5ddb1c08e0f3af59b188ae)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([52c8173](https://github.com/cloud-pi-native/console/commit/52c8173f5610e83836b661d7472c0fe3ff22f70f)) +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* :construction: make all new cluster external by default ([dcd203f](https://github.com/cloud-pi-native/console/commit/dcd203f3569d93a74e0d627f6cf3eb898d6b338d)) +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* add old-server new Nest.js services to CpinModule ([a17c109](https://github.com/cloud-pi-native/console/commit/a17c109616270115af701366ae8014e05cb41e7c)) +* add server-nestjs README to explain what we are doing ([57a4ca5](https://github.com/cloud-pi-native/console/commit/57a4ca51086b7c7af44a1df83cd272b82eb9a9f8)) +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* apply prettier on old-server ([6a781ac](https://github.com/cloud-pi-native/console/commit/6a781ac80c756924c53e28815064197b63508ff4)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* bump turbo to v2.7.1 ([d955e52](https://github.com/cloud-pi-native/console/commit/d955e52e65ed829053fdb120b217cb2744bd02c2)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** trigger Playwright E2E tests before Cypress E2E tests ([2262f37](https://github.com/cloud-pi-native/console/commit/2262f3717101833217d5963580e07fb4f69e9847)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* **client:** commit auto-imports.d.ts ([4252266](https://github.com/cloud-pi-native/console/commit/42522663edf5151ce7e38c36d4eb5d2d13b24a48)) +* **client:** do not install pnpm twice ([43ff3a3](https://github.com/cloud-pi-native/console/commit/43ff3a3e6690c901d4905236b627016ee8b4f043)) +* **client:** ensure build fails if typing system is not sound ([97297b5](https://github.com/cloud-pi-native/console/commit/97297b58a80bee7167171d39b16404f077fef03b)) +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* **client:** fix types ([131b2b6](https://github.com/cloud-pi-native/console/commit/131b2b64926493d94ba1e07c3501e331d0fcac8c)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* convert cypress dashboard e2e tests to playwright ([2badd91](https://github.com/cloud-pi-native/console/commit/2badd91181d4960d40898cfa01fbcfd0b13667aa)) +* convert plugin manager to NestJS service ([2ae46e3](https://github.com/cloud-pi-native/console/commit/2ae46e38787b3521911786898bee881c23fdb08b)) +* disable tests from nest.js for now ([5137c10](https://github.com/cloud-pi-native/console/commit/5137c10ed9296c761c3d31877bf54a411bbee140)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **e2e:** avoid launching Playwright and Cypress E2E tests at the same time ([1f203a8](https://github.com/cloud-pi-native/console/commit/1f203a8d35d6b1e4e69076f289256c7192a356da)) +* **e2e:** migrate Admin Role tests from Cypress to Playwright ([2112398](https://github.com/cloud-pi-native/console/commit/2112398f85b1ab7a5e332b798f68d4f04894d51d)) +* **e2e:** migrate Duplicate Zone test from Cypress to Playwright ([748ec80](https://github.com/cloud-pi-native/console/commit/748ec80917af7ef34b1f7922a13703706e5e41cc)) +* **e2e:** migrate last Role tests from Cypress to Playwright ([176ea34](https://github.com/cloud-pi-native/console/commit/176ea3481fc5b282497ea82a7562d735b11b325f)) +* **e2e:** migrate last Zone tests from Cypress to Playwright ([dcff338](https://github.com/cloud-pi-native/console/commit/dcff3384e695e95c9d2ae2b08486ce4ca47fa769)) +* **e2e:** migrate Project archive test from Cypress to Playwright ([0567931](https://github.com/cloud-pi-native/console/commit/0567931723cccf2fc54cb5560c6dd00043211f58)) +* **e2e:** migrate repository tests from Cypress to Playwright ([8370b8b](https://github.com/cloud-pi-native/console/commit/8370b8b4adbe5c1b65bc5f45bd50c860beeba96c)) +* **e2e:** migrate some Zone tests from Cypress to Playwright ([425e8c9](https://github.com/cloud-pi-native/console/commit/425e8c9672b276507983b6b0685bcf475bdb7c6c)) +* **e2e:** migrate Token tests from Cypress to Playwright ([86cb815](https://github.com/cloud-pi-native/console/commit/86cb8158285be552a1d0e94ebd30600858b9193a)) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* fix all typescript import errors by massively creating NestJs services ([68b7cb7](https://github.com/cloud-pi-native/console/commit/68b7cb75407dbb8d630dd4a50e4832e97796008a)) +* fix any type in AppService ([627e1e9](https://github.com/cloud-pi-native/console/commit/627e1e9f0cac82bcaa9b3b3a6337c3f2b447e1e5)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) +* **gitlab-plugin:** interalize awaited import to avoid top-level-await errors ([6472c74](https://github.com/cloud-pi-native/console/commit/6472c742c9ec74f834da994db697fcfb7d385c7e)) +* **gitlab:** purge useless file ([2637156](https://github.com/cloud-pi-native/console/commit/263715641844bb7d766730a3de9240531c9f26e3)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **hotfix/revert-server-nestjs-offload:** Release v9.20.1 ([8ebeae7](https://github.com/cloud-pi-native/console/commit/8ebeae7717d0aeac93ce14102a137f4aac62c409)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* initialize nestjs dedicated app ([a533963](https://github.com/cloud-pi-native/console/commit/a53396329455c9c856274f3c419e2ff377e12dba)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) +* **main:** release 9.10.0 ([c87ad31](https://github.com/cloud-pi-native/console/commit/c87ad3114149c4af995be42e130d03efd2e301d9)) +* **main:** release 9.10.1 ([c4b4649](https://github.com/cloud-pi-native/console/commit/c4b464985cfafc7d60246621e9beb82760b73200)) +* **main:** release 9.10.2 ([20a233a](https://github.com/cloud-pi-native/console/commit/20a233af09a3ef918a7b1de36057374dd4817726)) +* **main:** release 9.11.0 ([c03e2ef](https://github.com/cloud-pi-native/console/commit/c03e2ef8c4e448784528ae7dee68edff36e48930)) +* **main:** release 9.11.1 ([7580384](https://github.com/cloud-pi-native/console/commit/7580384525c7cdddbd51e7b0b9b4154ca624b058)) +* **main:** release 9.11.2 ([c7de3a1](https://github.com/cloud-pi-native/console/commit/c7de3a15bd54730655e3a75523c29e9f313a18d9)) +* **main:** release 9.11.3 ([9d2df48](https://github.com/cloud-pi-native/console/commit/9d2df48e3588a819fe5a63467dee773c5e1963c3)) +* **main:** release 9.11.4 ([9c873e1](https://github.com/cloud-pi-native/console/commit/9c873e1b5d1ec3d59b86b6ae19b0fe055f3ed669)) +* **main:** release 9.11.5 ([5fc68de](https://github.com/cloud-pi-native/console/commit/5fc68de5cf2a22f33171f03132798df20701df1b)) +* **main:** release 9.8.1 ([bb94301](https://github.com/cloud-pi-native/console/commit/bb9430196e042847931719ec518d5b9685a67585)) +* **main:** release 9.9.0 ([aa755da](https://github.com/cloud-pi-native/console/commit/aa755dac97ffd563bb0684fe669268b95c6d1362)) +* **main:** Release v9.12.0 ([3a14b03](https://github.com/cloud-pi-native/console/commit/3a14b03a7284ee8ee8a5e9d54d7ba842832c4017)) +* **main:** Release v9.12.0 ([4f6e1a8](https://github.com/cloud-pi-native/console/commit/4f6e1a8d8b630335c89a12b4daf4941e1dcdc32a)) +* **main:** Release v9.13.0 ([4daaa1d](https://github.com/cloud-pi-native/console/commit/4daaa1dce01276f83c6870ffe1c13af096547621)) +* **main:** Release v9.13.0 ([613ba30](https://github.com/cloud-pi-native/console/commit/613ba30b9f7f41e6db64fa8957709bca96e53847)) +* **main:** Release v9.13.1 ([0b39ff1](https://github.com/cloud-pi-native/console/commit/0b39ff1747d65fae9d4d4c6409421a30b1c57fc4)) +* **main:** Release v9.13.1 ([96ed6a3](https://github.com/cloud-pi-native/console/commit/96ed6a318a74978a9c2b8713d65100f1c0ca2f3d)) +* **main:** Release v9.13.2 ([400e9a7](https://github.com/cloud-pi-native/console/commit/400e9a71b28879c2bcd382ed4a43f55757d7aa2e)) +* **main:** Release v9.13.2 ([e2d09e3](https://github.com/cloud-pi-native/console/commit/e2d09e3f476fc958967089bc0ac9c5054b7eb5ac)) +* **main:** Release v9.14.0 ([5c62e19](https://github.com/cloud-pi-native/console/commit/5c62e191e0ab426dff4b5c390817a15345e3bdb0)) +* **main:** Release v9.14.1 ([8bccc81](https://github.com/cloud-pi-native/console/commit/8bccc81517f8cf39ddc5e1be972d4efa701b5e41)) +* **main:** Release v9.15.0 ([7b50776](https://github.com/cloud-pi-native/console/commit/7b50776d35fa73d1a28f6fb21010ffad85bafaf1)) +* **main:** Release v9.16.0 ([68abf2e](https://github.com/cloud-pi-native/console/commit/68abf2e5afd1fac52fad95c1319a4b50e8ac6275)) +* **main:** Release v9.16.1 ([3ba4c0b](https://github.com/cloud-pi-native/console/commit/3ba4c0bf08d899b614875cac99af7345a61998c8)) +* **main:** Release v9.17.0 ([a4061a6](https://github.com/cloud-pi-native/console/commit/a4061a61137c40c5b496e6649645b0b017723407)) +* **main:** Release v9.17.1 ([f8f7952](https://github.com/cloud-pi-native/console/commit/f8f795208ce32b5539e4d06c84f8579d1ae7ad2d)) +* **main:** Release v9.18.0 ([775bd1e](https://github.com/cloud-pi-native/console/commit/775bd1e82a598278ae2317bb775054b9e0627a36)) +* **main:** Release v9.18.1 ([261d9a3](https://github.com/cloud-pi-native/console/commit/261d9a367b71e8598c7956e4b76b612550244ffd)) +* **main:** Release v9.18.2 ([0a75369](https://github.com/cloud-pi-native/console/commit/0a753695320428332119401d6de53dc576538d72)) +* **main:** Release v9.18.3 ([f8cadc4](https://github.com/cloud-pi-native/console/commit/f8cadc47d40393c8fd040b0740957cff39ae6cb4)) +* **main:** Release v9.18.4 ([71dc32b](https://github.com/cloud-pi-native/console/commit/71dc32bf1f3d16a87a39f5d6378f3b7876bf2e42)) +* **main:** Release v9.19.0 ([c7d0f41](https://github.com/cloud-pi-native/console/commit/c7d0f41d7ecfd5d4645872de743d83d90b97bdab)) +* **main:** Release v9.20.0 ([9ee41b3](https://github.com/cloud-pi-native/console/commit/9ee41b36597cdd79c632ae3b2d3eb5b032781819)) +* **main:** Release v9.20.2 ([4075691](https://github.com/cloud-pi-native/console/commit/4075691de972a9d181dbed02a9aa2d801dc026f2)) +* **main:** Release v9.21.0 ([27e7f3a](https://github.com/cloud-pi-native/console/commit/27e7f3acff20927d8753d6bc67cebc4ed686f5ab)) +* move pnpm config to pnpm-workspace.yaml to avoid future issues with .npmrc ([00974c9](https://github.com/cloud-pi-native/console/commit/00974c9fcb98d223923c5062ea854ffd5ddeeb95)) +* move the entirety of apps/server in server-nestjs ([8437837](https://github.com/cloud-pi-native/console/commit/8437837fadca7dd79ec76cd3f004a83881891c29)) +* **nest-js:** add basic config and logger modules based on existing implementation ([429e4ba](https://github.com/cloud-pi-native/console/commit/429e4ba046f076e65320c3b4714aea878c99b754)) +* **nest-js:** initialize a bunch of module/services to move old-server code ([935f1d7](https://github.com/cloud-pi-native/console/commit/935f1d7fdef9ea68a404f01be07fa1b62b670dfd)) +* **nest-js:** update old-server code from main ([09f3729](https://github.com/cloud-pi-native/console/commit/09f3729078482abce704a2e885f6230134071f57)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** don't bother with webkit for now ([7d80b33](https://github.com/cloud-pi-native/console/commit/7d80b33f6ed5d364b526475e312679330825bd3c)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **playwright:** migrate E2E Project logs tests from Cypress ([089f0bc](https://github.com/cloud-pi-native/console/commit/089f0bcb6f443bc43c2936d039088b76bab10a01)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* **plugins:** update formatting ([86f8bad](https://github.com/cloud-pi-native/console/commit/86f8badfc617809d6f10e9c869b9fbce201de953)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* remove .js extensions from imports ([c8774f3](https://github.com/cloud-pi-native/console/commit/c8774f323c45ac85269d5c47eecf0813c632f879)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) +* remove configuration for tools that are not explicitely supported on the project ([e1be3d5](https://github.com/cloud-pi-native/console/commit/e1be3d5903a8a85d651785b6ef747040b9f29a41)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* rename nest.js base AppModule to MainModule for clarification with our own AppService ([8a20228](https://github.com/cloud-pi-native/console/commit/8a2022815fe363aa5626b0b4fcc8ab832dca12de)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) +* **server-nestjs:** add code to DatabaseService and thus finalize ApplicationInitializationService ([b12c207](https://github.com/cloud-pi-native/console/commit/b12c2079aaa944282ff5365504baf0095bd14e84)) +* **server-nestjs:** add code to PluginManagementService ([2d3133d](https://github.com/cloud-pi-native/console/commit/2d3133d5203fc94e71d3e4a2dfb5d4fc4e6bd784)) +* **server-nestjs:** add database initialization code in DatabaseInitializationService ([73c27d9](https://github.com/cloud-pi-native/console/commit/73c27d93ab779155a7e2c1c353909f3e5338b818)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) +* **server-nestjs:** convert all router services to NestJS classes ([2dd2c3e](https://github.com/cloud-pi-native/console/commit/2dd2c3ea697c6b508e89e107a689f79a8b39a6a1)) +* **server-nestjs:** delete unneeded code ([bbe07b2](https://github.com/cloud-pi-native/console/commit/bbe07b27bfd3d6c198e17a642cc441c919392d85)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** fix a fastify type issue ([868f1bc](https://github.com/cloud-pi-native/console/commit/868f1bc813f880963d13bccffd7814a54166f17a)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* **server-nestjs:** fix pnpm format command ([133254c](https://github.com/cloud-pi-native/console/commit/133254c1d285f45bb0385fda11d2f5b700f4ab20)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* **server-nestjs:** initialize core module and its AppService ([91d9596](https://github.com/cloud-pi-native/console/commit/91d9596765ced2a941f8e31c514e2b6c3969ef95)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove now useless old-server directory ([47d7536](https://github.com/cloud-pi-native/console/commit/47d7536a66e152b64fd69b0c57f72ab23a27d0e0)) +* **server-nestjs:** remove Router module ([8104360](https://github.com/cloud-pi-native/console/commit/8104360de9236f0f2c61dba2273b6d8cb368a356)) +* **server-nestjs:** remove test scripts (we'll migrate to vitest soon) ([afc4e91](https://github.com/cloud-pi-native/console/commit/afc4e912e88084a073b00e5213b2eed182b6c78b)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rename vite file to avoid compilation issues for now ([bd15699](https://github.com/cloud-pi-native/console/commit/bd15699e39d11ddbb6304366a41d67b8564c425a)) +* **server-nestjs:** retrieve prisma directory from old-server as-is ([a89fd36](https://github.com/cloud-pi-native/console/commit/a89fd368a1bc1c4d3eb1e535b0d4d776322e622f)) +* **server-nestjs:** rework configuration service to properly instanciate logger service ([64a113d](https://github.com/cloud-pi-native/console/commit/64a113d6591739557d459562acc57456fc3f74b9)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **server-nestjs:** update README with a plan for the future ([fc336d4](https://github.com/cloud-pi-native/console/commit/fc336d4b6793898a9190f585b39a9ba63c5e528b)) +* **server-nestjs:** update README with the modularization target ([d98bdfa](https://github.com/cloud-pi-native/console/commit/d98bdfa428290e7a5358a7caf33137a13815239c)) +* **server-nestjs:** use old-server eslint config ([d0f80ee](https://github.com/cloud-pi-native/console/commit/d0f80ee7bf88b7d26407b78a9792af699fb463e8)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* start integration old-server into nestjs ([d83b1cd](https://github.com/cloud-pi-native/console/commit/d83b1cdf47ba140a50a83da02c1c6a8fb024c9b9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) +* update browserlist/caniuse-lite ([23617f9](https://github.com/cloud-pi-native/console/commit/23617f9e051caf7e600360a92bae76ee6c8af575)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* update old-server import aliases ([d89735e](https://github.com/cloud-pi-native/console/commit/d89735e299b541efa4967a835c3cd0ece7d1ffbb)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) +* **vault:** explicit that VaultApi base class is abstract and must not be exported ([3673c4c](https://github.com/cloud-pi-native/console/commit/3673c4cf96bfc40bfac114fe7b67dee425b372da)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Docs + +* :memo: add notes regarding services communication ([ca4a057](https://github.com/cloud-pi-native/console/commit/ca4a0578d3e815f93aa1e57cbb880601354d600d)) +* :memo: Ajout du lien vers le MODOP de gestion des versions de dépendances ([7151293](https://github.com/cloud-pi-native/console/commit/715129312a6150f887298d1dc493ef7876c51e28)) +* :memo: update release documentation ([cea92a9](https://github.com/cloud-pi-native/console/commit/cea92a9aa651af68499ae318e4a7c27e037c706c)) +* :memo: use Nexus internal url for CI jobs ([2193ec3](https://github.com/cloud-pi-native/console/commit/2193ec3041fb0d78cc7a1fbe841fd451d2a7b095)) +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + + +### Refactoring + +* :bookmark: add tracing information on Argo apps ([340299b](https://github.com/cloud-pi-native/console/commit/340299b58e702a4258c5d767e3fdf4fc692f7d73)) +* :fire: remove AppRole enabled check to simplify the code ([95c7159](https://github.com/cloud-pi-native/console/commit/95c71595d7aac32fa211d18880fd5e7ed3bf4c53)) +* add PluginResultBuilder for improved result management in project upsert ([80d5bc4](https://github.com/cloud-pi-native/console/commit/80d5bc498422c1eb79134c54c9facc9c7a15e279)) +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) +* split Gitlab classes in separate files ([0d673a5](https://github.com/cloud-pi-native/console/commit/0d673a5d282e9dc82a816d16ac18c8452f740c1f)) +* split Vault classes in separate files ([15bc0ce](https://github.com/cloud-pi-native/console/commit/15bc0ce32dc74e8719c7a202a2e3e406aee50762)) + + +### Reverted commits + +* "feat(server-nestjs): add autoSync and suspended flags to plugin services" ([2116663](https://github.com/cloud-pi-native/console/commit/2116663621cf1d522ca73a0e76ea6b9ed4f9ff6d)) +* "fix: playwright test expect mock" ([d2a746c](https://github.com/cloud-pi-native/console/commit/d2a746c237af4d1cbfe38e8d090599bf26176525)) +* "refactor(server-nestjs): offload project route to new implementation" ([8a964bc](https://github.com/cloud-pi-native/console/commit/8a964bc5d2d223e9c2f2d975cc703a18be81fc45)) +* **client:** switch environments back from API v2 ([933977c](https://github.com/cloud-pi-native/console/commit/933977c8b3434d3db97c0f4300bb2d4bcc393588)) + +## [9.21.0](https://github.com/cloud-pi-native/console/compare/v9.20.2...v9.21.0) (2026-07-21) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** restrict deployment visibility to console admins ([def61e7](https://github.com/cloud-pi-native/console/commit/def61e76b3a867445c5ddb1c08e0f3af59b188ae)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([52c8173](https://github.com/cloud-pi-native/console/commit/52c8173f5610e83836b661d7472c0fe3ff22f70f)) +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* :construction: make all new cluster external by default ([dcd203f](https://github.com/cloud-pi-native/console/commit/dcd203f3569d93a74e0d627f6cf3eb898d6b338d)) +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* add old-server new Nest.js services to CpinModule ([a17c109](https://github.com/cloud-pi-native/console/commit/a17c109616270115af701366ae8014e05cb41e7c)) +* add server-nestjs README to explain what we are doing ([57a4ca5](https://github.com/cloud-pi-native/console/commit/57a4ca51086b7c7af44a1df83cd272b82eb9a9f8)) +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* apply prettier on old-server ([6a781ac](https://github.com/cloud-pi-native/console/commit/6a781ac80c756924c53e28815064197b63508ff4)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* bump turbo to v2.7.1 ([d955e52](https://github.com/cloud-pi-native/console/commit/d955e52e65ed829053fdb120b217cb2744bd02c2)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** trigger Playwright E2E tests before Cypress E2E tests ([2262f37](https://github.com/cloud-pi-native/console/commit/2262f3717101833217d5963580e07fb4f69e9847)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* **client:** commit auto-imports.d.ts ([4252266](https://github.com/cloud-pi-native/console/commit/42522663edf5151ce7e38c36d4eb5d2d13b24a48)) +* **client:** do not install pnpm twice ([43ff3a3](https://github.com/cloud-pi-native/console/commit/43ff3a3e6690c901d4905236b627016ee8b4f043)) +* **client:** ensure build fails if typing system is not sound ([97297b5](https://github.com/cloud-pi-native/console/commit/97297b58a80bee7167171d39b16404f077fef03b)) +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* **client:** fix types ([131b2b6](https://github.com/cloud-pi-native/console/commit/131b2b64926493d94ba1e07c3501e331d0fcac8c)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* convert cypress dashboard e2e tests to playwright ([2badd91](https://github.com/cloud-pi-native/console/commit/2badd91181d4960d40898cfa01fbcfd0b13667aa)) +* convert plugin manager to NestJS service ([2ae46e3](https://github.com/cloud-pi-native/console/commit/2ae46e38787b3521911786898bee881c23fdb08b)) +* disable tests from nest.js for now ([5137c10](https://github.com/cloud-pi-native/console/commit/5137c10ed9296c761c3d31877bf54a411bbee140)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **e2e:** avoid launching Playwright and Cypress E2E tests at the same time ([1f203a8](https://github.com/cloud-pi-native/console/commit/1f203a8d35d6b1e4e69076f289256c7192a356da)) +* **e2e:** migrate Admin Role tests from Cypress to Playwright ([2112398](https://github.com/cloud-pi-native/console/commit/2112398f85b1ab7a5e332b798f68d4f04894d51d)) +* **e2e:** migrate Duplicate Zone test from Cypress to Playwright ([748ec80](https://github.com/cloud-pi-native/console/commit/748ec80917af7ef34b1f7922a13703706e5e41cc)) +* **e2e:** migrate last Role tests from Cypress to Playwright ([176ea34](https://github.com/cloud-pi-native/console/commit/176ea3481fc5b282497ea82a7562d735b11b325f)) +* **e2e:** migrate last Zone tests from Cypress to Playwright ([dcff338](https://github.com/cloud-pi-native/console/commit/dcff3384e695e95c9d2ae2b08486ce4ca47fa769)) +* **e2e:** migrate profile tests from Cypress to Playwright ([d7201b6](https://github.com/cloud-pi-native/console/commit/d7201b6d253d342f6a1656d179d0a9d322745c33)) +* **e2e:** migrate Project archive test from Cypress to Playwright ([0567931](https://github.com/cloud-pi-native/console/commit/0567931723cccf2fc54cb5560c6dd00043211f58)) +* **e2e:** migrate repository tests from Cypress to Playwright ([8370b8b](https://github.com/cloud-pi-native/console/commit/8370b8b4adbe5c1b65bc5f45bd50c860beeba96c)) +* **e2e:** migrate some Zone tests from Cypress to Playwright ([425e8c9](https://github.com/cloud-pi-native/console/commit/425e8c9672b276507983b6b0685bcf475bdb7c6c)) +* **e2e:** migrate Token tests from Cypress to Playwright ([86cb815](https://github.com/cloud-pi-native/console/commit/86cb8158285be552a1d0e94ebd30600858b9193a)) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* fix all typescript import errors by massively creating NestJs services ([68b7cb7](https://github.com/cloud-pi-native/console/commit/68b7cb75407dbb8d630dd4a50e4832e97796008a)) +* fix any type in AppService ([627e1e9](https://github.com/cloud-pi-native/console/commit/627e1e9f0cac82bcaa9b3b3a6337c3f2b447e1e5)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) +* **gitlab-plugin:** interalize awaited import to avoid top-level-await errors ([6472c74](https://github.com/cloud-pi-native/console/commit/6472c742c9ec74f834da994db697fcfb7d385c7e)) +* **gitlab:** purge useless file ([2637156](https://github.com/cloud-pi-native/console/commit/263715641844bb7d766730a3de9240531c9f26e3)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **hotfix/revert-server-nestjs-offload:** Release v9.20.1 ([8ebeae7](https://github.com/cloud-pi-native/console/commit/8ebeae7717d0aeac93ce14102a137f4aac62c409)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* initialize nestjs dedicated app ([a533963](https://github.com/cloud-pi-native/console/commit/a53396329455c9c856274f3c419e2ff377e12dba)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) +* **main:** release 9.10.0 ([c87ad31](https://github.com/cloud-pi-native/console/commit/c87ad3114149c4af995be42e130d03efd2e301d9)) +* **main:** release 9.10.1 ([c4b4649](https://github.com/cloud-pi-native/console/commit/c4b464985cfafc7d60246621e9beb82760b73200)) +* **main:** release 9.10.2 ([20a233a](https://github.com/cloud-pi-native/console/commit/20a233af09a3ef918a7b1de36057374dd4817726)) +* **main:** release 9.11.0 ([c03e2ef](https://github.com/cloud-pi-native/console/commit/c03e2ef8c4e448784528ae7dee68edff36e48930)) +* **main:** release 9.11.1 ([7580384](https://github.com/cloud-pi-native/console/commit/7580384525c7cdddbd51e7b0b9b4154ca624b058)) +* **main:** release 9.11.2 ([c7de3a1](https://github.com/cloud-pi-native/console/commit/c7de3a15bd54730655e3a75523c29e9f313a18d9)) +* **main:** release 9.11.3 ([9d2df48](https://github.com/cloud-pi-native/console/commit/9d2df48e3588a819fe5a63467dee773c5e1963c3)) +* **main:** release 9.11.4 ([9c873e1](https://github.com/cloud-pi-native/console/commit/9c873e1b5d1ec3d59b86b6ae19b0fe055f3ed669)) +* **main:** release 9.11.5 ([5fc68de](https://github.com/cloud-pi-native/console/commit/5fc68de5cf2a22f33171f03132798df20701df1b)) +* **main:** release 9.8.1 ([bb94301](https://github.com/cloud-pi-native/console/commit/bb9430196e042847931719ec518d5b9685a67585)) +* **main:** release 9.9.0 ([aa755da](https://github.com/cloud-pi-native/console/commit/aa755dac97ffd563bb0684fe669268b95c6d1362)) +* **main:** Release v9.12.0 ([3a14b03](https://github.com/cloud-pi-native/console/commit/3a14b03a7284ee8ee8a5e9d54d7ba842832c4017)) +* **main:** Release v9.12.0 ([4f6e1a8](https://github.com/cloud-pi-native/console/commit/4f6e1a8d8b630335c89a12b4daf4941e1dcdc32a)) +* **main:** Release v9.13.0 ([4daaa1d](https://github.com/cloud-pi-native/console/commit/4daaa1dce01276f83c6870ffe1c13af096547621)) +* **main:** Release v9.13.0 ([613ba30](https://github.com/cloud-pi-native/console/commit/613ba30b9f7f41e6db64fa8957709bca96e53847)) +* **main:** Release v9.13.1 ([0b39ff1](https://github.com/cloud-pi-native/console/commit/0b39ff1747d65fae9d4d4c6409421a30b1c57fc4)) +* **main:** Release v9.13.1 ([96ed6a3](https://github.com/cloud-pi-native/console/commit/96ed6a318a74978a9c2b8713d65100f1c0ca2f3d)) +* **main:** Release v9.13.2 ([400e9a7](https://github.com/cloud-pi-native/console/commit/400e9a71b28879c2bcd382ed4a43f55757d7aa2e)) +* **main:** Release v9.13.2 ([e2d09e3](https://github.com/cloud-pi-native/console/commit/e2d09e3f476fc958967089bc0ac9c5054b7eb5ac)) +* **main:** Release v9.14.0 ([5c62e19](https://github.com/cloud-pi-native/console/commit/5c62e191e0ab426dff4b5c390817a15345e3bdb0)) +* **main:** Release v9.14.1 ([8bccc81](https://github.com/cloud-pi-native/console/commit/8bccc81517f8cf39ddc5e1be972d4efa701b5e41)) +* **main:** Release v9.15.0 ([7b50776](https://github.com/cloud-pi-native/console/commit/7b50776d35fa73d1a28f6fb21010ffad85bafaf1)) +* **main:** Release v9.16.0 ([68abf2e](https://github.com/cloud-pi-native/console/commit/68abf2e5afd1fac52fad95c1319a4b50e8ac6275)) +* **main:** Release v9.16.1 ([3ba4c0b](https://github.com/cloud-pi-native/console/commit/3ba4c0bf08d899b614875cac99af7345a61998c8)) +* **main:** Release v9.17.0 ([a4061a6](https://github.com/cloud-pi-native/console/commit/a4061a61137c40c5b496e6649645b0b017723407)) +* **main:** Release v9.17.1 ([f8f7952](https://github.com/cloud-pi-native/console/commit/f8f795208ce32b5539e4d06c84f8579d1ae7ad2d)) +* **main:** Release v9.18.0 ([775bd1e](https://github.com/cloud-pi-native/console/commit/775bd1e82a598278ae2317bb775054b9e0627a36)) +* **main:** Release v9.18.1 ([261d9a3](https://github.com/cloud-pi-native/console/commit/261d9a367b71e8598c7956e4b76b612550244ffd)) +* **main:** Release v9.18.2 ([0a75369](https://github.com/cloud-pi-native/console/commit/0a753695320428332119401d6de53dc576538d72)) +* **main:** Release v9.18.3 ([f8cadc4](https://github.com/cloud-pi-native/console/commit/f8cadc47d40393c8fd040b0740957cff39ae6cb4)) +* **main:** Release v9.18.4 ([71dc32b](https://github.com/cloud-pi-native/console/commit/71dc32bf1f3d16a87a39f5d6378f3b7876bf2e42)) +* **main:** Release v9.19.0 ([c7d0f41](https://github.com/cloud-pi-native/console/commit/c7d0f41d7ecfd5d4645872de743d83d90b97bdab)) +* **main:** Release v9.20.0 ([9ee41b3](https://github.com/cloud-pi-native/console/commit/9ee41b36597cdd79c632ae3b2d3eb5b032781819)) +* **main:** Release v9.20.2 ([4075691](https://github.com/cloud-pi-native/console/commit/4075691de972a9d181dbed02a9aa2d801dc026f2)) +* move pnpm config to pnpm-workspace.yaml to avoid future issues with .npmrc ([00974c9](https://github.com/cloud-pi-native/console/commit/00974c9fcb98d223923c5062ea854ffd5ddeeb95)) +* move the entirety of apps/server in server-nestjs ([8437837](https://github.com/cloud-pi-native/console/commit/8437837fadca7dd79ec76cd3f004a83881891c29)) +* **nest-js:** add basic config and logger modules based on existing implementation ([429e4ba](https://github.com/cloud-pi-native/console/commit/429e4ba046f076e65320c3b4714aea878c99b754)) +* **nest-js:** initialize a bunch of module/services to move old-server code ([935f1d7](https://github.com/cloud-pi-native/console/commit/935f1d7fdef9ea68a404f01be07fa1b62b670dfd)) +* **nest-js:** update old-server code from main ([09f3729](https://github.com/cloud-pi-native/console/commit/09f3729078482abce704a2e885f6230134071f57)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** don't bother with webkit for now ([7d80b33](https://github.com/cloud-pi-native/console/commit/7d80b33f6ed5d364b526475e312679330825bd3c)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **playwright:** migrate E2E Project logs tests from Cypress ([089f0bc](https://github.com/cloud-pi-native/console/commit/089f0bcb6f443bc43c2936d039088b76bab10a01)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* **plugins:** update formatting ([86f8bad](https://github.com/cloud-pi-native/console/commit/86f8badfc617809d6f10e9c869b9fbce201de953)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* remove .js extensions from imports ([c8774f3](https://github.com/cloud-pi-native/console/commit/c8774f323c45ac85269d5c47eecf0813c632f879)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) +* remove configuration for tools that are not explicitely supported on the project ([e1be3d5](https://github.com/cloud-pi-native/console/commit/e1be3d5903a8a85d651785b6ef747040b9f29a41)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* rename nest.js base AppModule to MainModule for clarification with our own AppService ([8a20228](https://github.com/cloud-pi-native/console/commit/8a2022815fe363aa5626b0b4fcc8ab832dca12de)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) +* **server-nestjs:** add code to DatabaseService and thus finalize ApplicationInitializationService ([b12c207](https://github.com/cloud-pi-native/console/commit/b12c2079aaa944282ff5365504baf0095bd14e84)) +* **server-nestjs:** add code to PluginManagementService ([2d3133d](https://github.com/cloud-pi-native/console/commit/2d3133d5203fc94e71d3e4a2dfb5d4fc4e6bd784)) +* **server-nestjs:** add database initialization code in DatabaseInitializationService ([73c27d9](https://github.com/cloud-pi-native/console/commit/73c27d93ab779155a7e2c1c353909f3e5338b818)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) +* **server-nestjs:** convert all router services to NestJS classes ([2dd2c3e](https://github.com/cloud-pi-native/console/commit/2dd2c3ea697c6b508e89e107a689f79a8b39a6a1)) +* **server-nestjs:** delete unneeded code ([bbe07b2](https://github.com/cloud-pi-native/console/commit/bbe07b27bfd3d6c198e17a642cc441c919392d85)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** fix a fastify type issue ([868f1bc](https://github.com/cloud-pi-native/console/commit/868f1bc813f880963d13bccffd7814a54166f17a)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* **server-nestjs:** fix pnpm format command ([133254c](https://github.com/cloud-pi-native/console/commit/133254c1d285f45bb0385fda11d2f5b700f4ab20)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* **server-nestjs:** initialize core module and its AppService ([91d9596](https://github.com/cloud-pi-native/console/commit/91d9596765ced2a941f8e31c514e2b6c3969ef95)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove now useless old-server directory ([47d7536](https://github.com/cloud-pi-native/console/commit/47d7536a66e152b64fd69b0c57f72ab23a27d0e0)) +* **server-nestjs:** remove Router module ([8104360](https://github.com/cloud-pi-native/console/commit/8104360de9236f0f2c61dba2273b6d8cb368a356)) +* **server-nestjs:** remove test scripts (we'll migrate to vitest soon) ([afc4e91](https://github.com/cloud-pi-native/console/commit/afc4e912e88084a073b00e5213b2eed182b6c78b)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rename vite file to avoid compilation issues for now ([bd15699](https://github.com/cloud-pi-native/console/commit/bd15699e39d11ddbb6304366a41d67b8564c425a)) +* **server-nestjs:** retrieve prisma directory from old-server as-is ([a89fd36](https://github.com/cloud-pi-native/console/commit/a89fd368a1bc1c4d3eb1e535b0d4d776322e622f)) +* **server-nestjs:** rework configuration service to properly instanciate logger service ([64a113d](https://github.com/cloud-pi-native/console/commit/64a113d6591739557d459562acc57456fc3f74b9)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **server-nestjs:** update README with a plan for the future ([fc336d4](https://github.com/cloud-pi-native/console/commit/fc336d4b6793898a9190f585b39a9ba63c5e528b)) +* **server-nestjs:** update README with the modularization target ([d98bdfa](https://github.com/cloud-pi-native/console/commit/d98bdfa428290e7a5358a7caf33137a13815239c)) +* **server-nestjs:** use old-server eslint config ([d0f80ee](https://github.com/cloud-pi-native/console/commit/d0f80ee7bf88b7d26407b78a9792af699fb463e8)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* start integration old-server into nestjs ([d83b1cd](https://github.com/cloud-pi-native/console/commit/d83b1cdf47ba140a50a83da02c1c6a8fb024c9b9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) +* update browserlist/caniuse-lite ([23617f9](https://github.com/cloud-pi-native/console/commit/23617f9e051caf7e600360a92bae76ee6c8af575)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* update old-server import aliases ([d89735e](https://github.com/cloud-pi-native/console/commit/d89735e299b541efa4967a835c3cd0ece7d1ffbb)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) +* **vault:** explicit that VaultApi base class is abstract and must not be exported ([3673c4c](https://github.com/cloud-pi-native/console/commit/3673c4cf96bfc40bfac114fe7b67dee425b372da)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Docs + +* :memo: add notes regarding services communication ([ca4a057](https://github.com/cloud-pi-native/console/commit/ca4a0578d3e815f93aa1e57cbb880601354d600d)) +* :memo: Ajout du lien vers le MODOP de gestion des versions de dépendances ([7151293](https://github.com/cloud-pi-native/console/commit/715129312a6150f887298d1dc493ef7876c51e28)) +* :memo: update release documentation ([cea92a9](https://github.com/cloud-pi-native/console/commit/cea92a9aa651af68499ae318e4a7c27e037c706c)) +* :memo: use Nexus internal url for CI jobs ([2193ec3](https://github.com/cloud-pi-native/console/commit/2193ec3041fb0d78cc7a1fbe841fd451d2a7b095)) +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + + +### Refactoring + +* :bookmark: add tracing information on Argo apps ([340299b](https://github.com/cloud-pi-native/console/commit/340299b58e702a4258c5d767e3fdf4fc692f7d73)) +* :fire: remove AppRole enabled check to simplify the code ([95c7159](https://github.com/cloud-pi-native/console/commit/95c71595d7aac32fa211d18880fd5e7ed3bf4c53)) +* add PluginResultBuilder for improved result management in project upsert ([80d5bc4](https://github.com/cloud-pi-native/console/commit/80d5bc498422c1eb79134c54c9facc9c7a15e279)) +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) +* split Gitlab classes in separate files ([0d673a5](https://github.com/cloud-pi-native/console/commit/0d673a5d282e9dc82a816d16ac18c8452f740c1f)) +* split Vault classes in separate files ([15bc0ce](https://github.com/cloud-pi-native/console/commit/15bc0ce32dc74e8719c7a202a2e3e406aee50762)) + + +### Reverted commits + +* "feat(server-nestjs): add autoSync and suspended flags to plugin services" ([2116663](https://github.com/cloud-pi-native/console/commit/2116663621cf1d522ca73a0e76ea6b9ed4f9ff6d)) +* "fix: playwright test expect mock" ([d2a746c](https://github.com/cloud-pi-native/console/commit/d2a746c237af4d1cbfe38e8d090599bf26176525)) +* "refactor(server-nestjs): offload project route to new implementation" ([8a964bc](https://github.com/cloud-pi-native/console/commit/8a964bc5d2d223e9c2f2d975cc703a18be81fc45)) +* **client:** switch environments back from API v2 ([933977c](https://github.com/cloud-pi-native/console/commit/933977c8b3434d3db97c0f4300bb2d4bcc393588)) + +## [9.20.2](https://github.com/cloud-pi-native/console/compare/v9.20.1...v9.20.2) (2026-07-21) + + +### Bug Fixes + +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) + +## [9.20.1](https://github.com/cloud-pi-native/console/compare/v9.20.0...v9.20.1) (2026-07-17) + + +### Bug Fixes + +* **client:** restrict deployment visibility to console admins ([d836948](https://github.com/cloud-pi-native/console/commit/d836948e2c6689445d473c562a2be189873c75af)) + + +### Reverted commits + +* **client:** switch environments back from API v2 ([9c1245a](https://github.com/cloud-pi-native/console/commit/9c1245a56f650d4819f27f926457810f33b67753)) +* feat(server-nestjs): add autoSync and suspended flags to plugin services ([a48697d](https://github.com/cloud-pi-native/console/commit/a48697d46fad4fbbf54567988d0e99433cb5c4cc)) +* fix: playwright test expect mock ([828db42](https://github.com/cloud-pi-native/console/commit/828db421f6ce4f7eda293a744fda9f22882f7a8c)) +* refactor(server-nestjs): offload project route to new implementation ([99a7f5d](https://github.com/cloud-pi-native/console/commit/99a7f5d29becefcdad24331004263756a6950774)) + +## [9.20.0](https://github.com/cloud-pi-native/console/compare/v9.19.0...v9.20.0) (2026-07-17) + +### Features + +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) + + +### Bug Fixes + +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) + + +### Docs + +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) + + +### Refactoring + +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) + +## [9.19.0](https://github.com/cloud-pi-native/console/compare/v9.18.4...v9.19.0) (2026-06-19) + + +### Features + +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) + + +### Bug Fixes + +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) + + +### Miscellaneous Chores + +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) + + +### Refactoring + +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) + +## [9.18.4](https://github.com/cloud-pi-native/console/compare/v9.18.3...v9.18.4) (2026-06-10) + + +### Bug Fixes + +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) + +## [9.18.3](https://github.com/cloud-pi-native/console/compare/v9.18.2...v9.18.3) (2026-06-01) + + +### Bug Fixes + +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) + + +### Docs + +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) + +## [9.18.2](https://github.com/cloud-pi-native/console/compare/v9.18.1...v9.18.2) (2026-05-22) + + +### Bug Fixes + +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) + + +### Miscellaneous Chores + +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) + + +### Docs + +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) + + +### Refactoring + +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) + +## [9.18.1](https://github.com/cloud-pi-native/console/compare/v9.18.0...v9.18.1) (2026-05-20) + + +### Bug Fixes + +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) + +## [9.18.0](https://github.com/cloud-pi-native/console/compare/v9.17.1...v9.18.0) (2026-05-18) + + +### Features + +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) + + +### Bug Fixes + +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) + + +### Miscellaneous Chores + +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) + +## [9.17.1](https://github.com/cloud-pi-native/console/compare/v9.17.0...v9.17.1) (2026-05-05) + + +### Bug Fixes + +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) + +## [9.17.0](https://github.com/cloud-pi-native/console/compare/v9.16.1...v9.17.0) (2026-04-30) + + +### Features + +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) + + +### Bug Fixes + +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) + + +### Miscellaneous Chores + +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) + + +### Docs + +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) + + +### Refactoring + +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) + +## [9.16.1](https://github.com/cloud-pi-native/console/compare/v9.16.0...v9.16.1) (2026-04-13) + + +### Bug Fixes + +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) + + +### Miscellaneous Chores + +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) + +## [9.16.0](https://github.com/cloud-pi-native/console/compare/v9.15.0...v9.16.0) (2026-04-10) + + +### Features + +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) + + +### Bug Fixes + +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) + + +### Miscellaneous Chores + +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) + +## [9.15.0](https://github.com/cloud-pi-native/console/compare/v9.14.1...v9.15.0) (2026-04-09) + + +### Features + +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) + + +### Bug Fixes + +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) + + +### Miscellaneous Chores + +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Refactoring + +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) + +## [9.14.1](https://github.com/cloud-pi-native/console/compare/v9.14.0...v9.14.1) (2026-03-25) + + +### Bug Fixes + +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) + + +### Miscellaneous Chores + +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) + + +### Docs + +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + +## [9.14.0](https://github.com/cloud-pi-native/console/compare/v9.13.2...v9.14.0) (2026-03-23) + + +### Features + +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) + + +### Miscellaneous Chores + +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) + + +### Docs + +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) + +## [9.13.2](https://github.com/cloud-pi-native/console/compare/v9.13.1...v9.13.2) (2026-02-24) + + +### Bug Fixes + +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) + + +### Miscellaneous Chores + +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) + + +### Docs + +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) + +## [9.13.1](https://github.com/cloud-pi-native/console/compare/v9.13.0...v9.13.1) (2026-02-17) + + +### Miscellaneous Chores + +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) + +## [9.13.0](https://github.com/cloud-pi-native/console/compare/v9.12.0...v9.13.0) (2026-02-16) + + +### Features + +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) + + +### Bug Fixes + +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) + + +### Miscellaneous Chores + +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) + + +### Docs + +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) + +## [9.12.0](https://github.com/cloud-pi-native/console/compare/v9.11.5...v9.12.0) (2026-02-03) + + +### Features + +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) + + +### Miscellaneous Chores + +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) + + +### Docs + +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) + +## [9.11.5](https://github.com/cloud-pi-native/console/compare/v9.11.4...v9.11.5) (2026-01-26) + + +### Bug Fixes + +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) + +## [9.11.4](https://github.com/cloud-pi-native/console/compare/v9.11.3...v9.11.4) (2026-01-20) + + +### Bug Fixes + +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) + +## [9.11.3](https://github.com/cloud-pi-native/console/compare/v9.11.2...v9.11.3) (2026-01-13) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) + +## [9.11.2](https://github.com/cloud-pi-native/console/compare/v9.11.1...v9.11.2) (2026-01-08) + + +### Bug Fixes + +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) + +## [9.11.1](https://github.com/cloud-pi-native/console/compare/v9.11.0...v9.11.1) (2026-01-06) + + +### Bug Fixes + +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) + +## [9.11.0](https://github.com/cloud-pi-native/console/compare/v9.10.2...v9.11.0) (2025-12-22) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) + +## [9.10.2](https://github.com/cloud-pi-native/console/compare/v9.10.1...v9.10.2) (2025-12-18) + + +### Bug Fixes + +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) + +## [9.10.1](https://github.com/cloud-pi-native/console/compare/v9.10.0...v9.10.1) (2025-12-18) + + +### Bug Fixes + +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) + +## [9.10.0](https://github.com/cloud-pi-native/console/compare/v9.9.0...v9.10.0) (2025-12-12) + + +### Features + +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) + + +### Bug Fixes + +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) + +## [9.9.0](https://github.com/cloud-pi-native/console/compare/v9.8.1...v9.9.0) (2025-11-27) + + +### Features + +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) + +## [9.8.1](https://github.com/cloud-pi-native/console/compare/v9.8.0...v9.8.1) (2025-11-21) + + +### Bug Fixes + +* :bug: display associated project on cluster form ([488ff24](https://github.com/cloud-pi-native/console/commit/488ff24dab0d90a6af83e96bdbe815a0f955220e)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* **ci:** remove PNPM_VERSION from Github Actions ([bd1f4af](https://github.com/cloud-pi-native/console/commit/bd1f4af2592658f7edcbcada13f1ea6cafcab3d4)) +* rely on packageManager property to retrieve the right version of pnpm ([7b20b83](https://github.com/cloud-pi-native/console/commit/7b20b832b6f83d367c33573d87c444a8da315703)) +* **shared:** explicitely import Zod type ([2a8a4d8](https://github.com/cloud-pi-native/console/commit/2a8a4d8074e45742769fb2f451672777c4f4afa5)) +* **tests:** ensure Cypress tests are green again ([5b7042b](https://github.com/cloud-pi-native/console/commit/5b7042b0be48697ce9dfa4a69377a04ceae8fb5c)) + +## [9.8.0](https://github.com/cloud-pi-native/console/compare/v9.7.2...v9.8.0) (2025-11-17) + + +### Features + +* :fire: de-duplicates Argo CD links for default zone ([2db26cd](https://github.com/cloud-pi-native/console/commit/2db26cdfce71ffbf330bdbe07a48f1276bad7cd9)) +* :poop: change the naming convention of cluster secret ([a471766](https://github.com/cloud-pi-native/console/commit/a47176643cb9a97f41c1345477da60bce5935f85)) + + +### Bug Fixes + +* :bug: improve number inputs for decimal values ([d69a8ba](https://github.com/cloud-pi-native/console/commit/d69a8ba547174be521ac8cab0e47db2448415a61)) +* **#1640:** :bug: only regenerate robot secret if host changes ([e5d3ba8](https://github.com/cloud-pi-native/console/commit/e5d3ba87f895451795be1d7fa42a389e63521a68)) + + +### Performance Improvements + +* :zap: direct service communication for Gitlab pipelines ([4991451](https://github.com/cloud-pi-native/console/commit/4991451f3d0c7dbac32cf5fed1fc880b29a37a1c)) + +## [9.7.2](https://github.com/cloud-pi-native/console/compare/v9.7.1...v9.7.2) (2025-10-27) + + +### Bug Fixes + +* release please condition ([c293866](https://github.com/cloud-pi-native/console/commit/c29386619ff3624d22a2a72f9753bc443045b5c0)) + +## [9.7.1](https://github.com/cloud-pi-native/console/compare/v9.7.0...v9.7.1) (2025-10-27) + + +### Bug Fixes + +* :bug: use the renamed path of repository/group being deleted ([38b8cff](https://github.com/cloud-pi-native/console/commit/38b8cffaff3484257104d325dd9e034bfa2611fa)) + +## [9.7.0](https://github.com/cloud-pi-native/console/compare/v9.6.0...v9.7.0) (2025-10-27) + + +### Features + +* :sparkles: add support for SonarQube and Harbor internal url ([43ce886](https://github.com/cloud-pi-native/console/commit/43ce88690883d84214c29a4cb910f81609e8cb4d)) + +## [9.6.0](https://github.com/cloud-pi-native/console/compare/v9.5.0...v9.6.0) (2025-10-14) + + +### Features + +* :sparkles: allow cluster name update ([161086d](https://github.com/cloud-pi-native/console/commit/161086dfd7c226eac65b3f5e3c835b7f923cf511)) +* :sparkles: allow cluster name update ([0cbc53e](https://github.com/cloud-pi-native/console/commit/0cbc53eb7f3b0e41f128e1066f584aadb55b9766)), closes [#1582](https://github.com/cloud-pi-native/console/issues/1582) + +## [9.5.0](https://github.com/cloud-pi-native/console/compare/v9.4.0...v9.5.0) (2025-10-10) + + +### Features + +* :sparkles: add project usage on resources tab ([1ee66ce](https://github.com/cloud-pi-native/console/commit/1ee66ce8211c2017b328887b7800a2e1ab537300)) +* :sparkles: add resources occupancy on clusters ([75ce5e9](https://github.com/cloud-pi-native/console/commit/75ce5e9450ca68d45d2635b29e0ff8bd8d8614d7)) +* :sparkles: add tags retention policy rules ([a0e3f98](https://github.com/cloud-pi-native/console/commit/a0e3f9839d18c9459264e452b14cc87359a3f6c8)) +* :sparkles: check cluster resources when adding an environment ([826f1e9](https://github.com/cloud-pi-native/console/commit/826f1e9d8f46bf2aed419c7c395ef336131849dc)) +* :sparkles: save resources constraints for the project ([58e6d7a](https://github.com/cloud-pi-native/console/commit/58e6d7ad2d06fbb9e766fa755ccd7ab44556409e)) +* **client:** add service chain details form ([d56e68c](https://github.com/cloud-pi-native/console/commit/d56e68c626ee854fd12e60694acbc87d178ee270)) +* move environments and stages scenario from cypress to playwright ([c4e703f](https://github.com/cloud-pi-native/console/commit/c4e703f92f964e5850ca16ae7d066dc6e7d692cf)) +* **opencds:** add 'list all service chains' section in admin side menu ([f58de6e](https://github.com/cloud-pi-native/console/commit/f58de6e55ad23ed8f8e81aa769f76f9adc62b7b1)) +* **opencds:** add client-side service chain details page ([478bc35](https://github.com/cloud-pi-native/console/commit/478bc35408c43da95da875b105f1cd8222dfa217)) +* **opencds:** add feature toggling default value ([5dc6071](https://github.com/cloud-pi-native/console/commit/5dc607178179c5ab76dedf0d84ab22d3e08cabf5)) +* **opencds:** add flows retrieval and display as a stepper ([7194218](https://github.com/cloud-pi-native/console/commit/71942182464d6c42da0ab818f28ee60024c2e056)) +* **opencds:** add mockoon docker image for local dev and ci considerations ([0cf8de5](https://github.com/cloud-pi-native/console/commit/0cf8de5cd04860cdcfe2259640abffd48881ac5b)) +* **opencds:** adjust service chain list view ([fa0f5c3](https://github.com/cloud-pi-native/console/commit/fa0f5c3b91d940547ef132ad443f3b0a187238aa)) +* **opencds:** clean up mockoon json ([8bd1f74](https://github.com/cloud-pi-native/console/commit/8bd1f7483d5b772705c317510cded0552cad40c6)) +* **opencds:** handle OpenCDS API token-based security ([409ae6e](https://github.com/cloud-pi-native/console/commit/409ae6e40a2e42b54fd95a05904c35e1efd18371)) +* **opencds:** use a badge for state on service-chain list ([e3c1340](https://github.com/cloud-pi-native/console/commit/e3c134095e0dc7fb8009bfdb8fc14e6e8d62959b)) +* remove quota table ([1561d8d](https://github.com/cloud-pi-native/console/commit/1561d8da82ab1413047a3573269ddc4a133360ca)) +* remove Quota table references to use Environment attributes instead ([03297ec](https://github.com/cloud-pi-native/console/commit/03297ec88fb69834c6d76f61b25309ce1a363f4f)) +* update Cluster model to store resources capability ([6167743](https://github.com/cloud-pi-native/console/commit/6167743743c91f5e109da7297de275ea9d4d23d3)) +* update Environment form to use new resources attributes ([ff58b26](https://github.com/cloud-pi-native/console/commit/ff58b26662db51f385ce49ac14b4073bd5cf71cf)) + + +### Bug Fixes + +* :bug: update zone values when modifying a cluster ([253acb9](https://github.com/cloud-pi-native/console/commit/253acb9d6106727fc11b22c8268cfcd07320001a)), closes [#1642](https://github.com/cloud-pi-native/console/issues/1642) +* :lipstick: show resources in clusters and environment tables ([8494e2a](https://github.com/cloud-pi-native/console/commit/8494e2ad19afc7e0c8afb65ccc2ee216d7399615)) +* config and locator due to keycloak upgrade ([516d9d5](https://github.com/cloud-pi-native/console/commit/516d9d5bc34b5261490170f2c356c050168f54e5)) +* **opencds:** fix validatedBy pseudo-null value conflicting with OpenCDS contract in server ([cde51a2](https://github.com/cloud-pi-native/console/commit/cde51a2f08cfd64e57714f6096a6ec2d6bb8962f)) +* **playwright:** bump playwright to 1.54.2 ([44ea5e3](https://github.com/cloud-pi-native/console/commit/44ea5e3f5093a45cf95266c0691dad60ca33de26)) +* **playwright:** fix flaky test on cluster informations by adding a loader ([ffc7b43](https://github.com/cloud-pi-native/console/commit/ffc7b436efa7af16a1a7d166c52d02317c26ab74)) + +## [9.4.0](https://github.com/cloud-pi-native/console/compare/v9.3.3...v9.4.0) (2025-08-25) + + +### Features + +* :rocket: empty Vault url to use the default VaultConnection ([e2d3b28](https://github.com/cloud-pi-native/console/commit/e2d3b287964952b0f6dd0a00ea97499827cb7198)) +* :sparkles: create contact email environment variable ([779cb4a](https://github.com/cloud-pi-native/console/commit/779cb4a83492d4159a7efa868822adc5640dd6f8)) +* ✨ Add the repo UID display on console ([2ed10e4](https://github.com/cloud-pi-native/console/commit/2ed10e4b612687b9a18441458749d2821bcff43e)) +* ✨ Variabilize clipboard timeout ([2ed10e4](https://github.com/cloud-pi-native/console/commit/2ed10e4b612687b9a18441458749d2821bcff43e)) +* add Playwright E2E tests for Keycloak ([1524bba](https://github.com/cloud-pi-native/console/commit/1524bba8d6adcc8b360d740e85b51fcf8db71964)) +* add README for ArgoCD plugin ([5d66223](https://github.com/cloud-pi-native/console/commit/5d66223c5e6af45fdc02d3f1e062a6424b0e6cdc)) +* allow cluster informations property to be up to 1000 characters long ([9fe3182](https://github.com/cloud-pi-native/console/commit/9fe3182a5c771552f23f1e147475acd606efe67a)) +* configure Playwright Github workflow ([4ac7f55](https://github.com/cloud-pi-native/console/commit/4ac7f55f6fc6d3d747bafad20efa7184e608b146)) +* **playwright:** add Project page tests ([419ea3c](https://github.com/cloud-pi-native/console/commit/419ea3c735dd3ece8e02ce2d77beaa815170f7cd)) + + +### Bug Fixes + +* :bug: adapt status analysis to Nexus 3.83.1 response ([4efa720](https://github.com/cloud-pi-native/console/commit/4efa720c50e766068d3c8773df83e3754d29c9f1)) +* **#1681:** :bug: missing kv creation if not exists ([e862c5c](https://github.com/cloud-pi-native/console/commit/e862c5ca51dee5a0216f2c66040f90d510579df4)) +* **1638:** add validation for repo names in Zod schema ([48621bd](https://github.com/cloud-pi-native/console/commit/48621bdc63446be7cd5e38ca13b98762af322c16)) +* **client:** ensure branch name is reset to default value after synchronization on a repository ([af0ea44](https://github.com/cloud-pi-native/console/commit/af0ea44186dd8c22c370c5c7edd77b4807fb9e80)) +* **client:** fix Teams E2E cypress being broken after keycloak realm update ([9c95fdf](https://github.com/cloud-pi-native/console/commit/9c95fdffbb208cae9b930fc0d2ca9516bb2cbe9b)) +* fix docker build permissions to allow local path import ([c0aaa2f](https://github.com/cloud-pi-native/console/commit/c0aaa2f29017845575c38ef83d18850f6ae741cb)) +* fix docker build permissions to allow local path import ([6b9461d](https://github.com/cloud-pi-native/console/commit/6b9461dd5e481486a5d1e479f4364348e1cf4fda)) +* fix formatting issue ([662a834](https://github.com/cloud-pi-native/console/commit/662a8342bd83242a61eceae900eb328b1eab5cc5)) +* fix typo ([c20bd60](https://github.com/cloud-pi-native/console/commit/c20bd60e462f59222b1d873d65dcb2ec980b6c33)) +* **playwright:** ensure keycloak tests work for all languages ([44e27a3](https://github.com/cloud-pi-native/console/commit/44e27a3a8d666949ad29d26b8cbd77da50b067bb)) +* **playwright:** fix format command not working ([578e4b3](https://github.com/cloud-pi-native/console/commit/578e4b3e5a0538ac41e9410d09b47f45b98f30bb)) +* remove cluster resources to avoid confusion on its usage ([2ecf9d7](https://github.com/cloud-pi-native/console/commit/2ecf9d70330581b9cb757fa5822a85c6464e0bec)) +* **tests:** fix admin id in pgsql import data not being consistent with keycloak ([3ee2583](https://github.com/cloud-pi-native/console/commit/3ee2583c6f1f7761b5586047b6a93d40c3fb5d58)) + +## [9.3.3](https://github.com/cloud-pi-native/console/compare/v9.3.2...v9.3.3) (2025-06-12) + + +### Bug Fixes + +* :bug: fix gitlab deletion API request ([7acd71b](https://github.com/cloud-pi-native/console/commit/7acd71bc9aed370882b41f31c37dcbb8122bdd3d)) + +## [9.3.2](https://github.com/cloud-pi-native/console/compare/v9.3.1...v9.3.2) (2025-06-12) + + +### Bug Fixes + +* :bug: add missing custom source repositories in argocd values (pulling mode) ([5330f7a](https://github.com/cloud-pi-native/console/commit/5330f7ae16565d7f6e0840fab7bb86691c6aeb4a)) +* fix admin roles not being retrieved when a new user logs in for the first time ([e40c00b](https://github.com/cloud-pi-native/console/commit/e40c00b1b25ecb4026612018737a6a0a1abd5aec)) +* fix hiding tile header for all tiles instead of just the projects ones ([156e75a](https://github.com/cloud-pi-native/console/commit/156e75a215bd18a90b9abe6adee5a300ea180bbd)) + +## [9.3.1](https://github.com/cloud-pi-native/console/compare/v9.3.0...v9.3.1) (2025-05-23) + + +### Bug Fixes + +* :bug: adapt status analysis to Nexus 3.78 response ([95001c2](https://github.com/cloud-pi-native/console/commit/95001c2d96afa101e9867da177d5eed8dcc6b40d)) + +## [9.3.0](https://github.com/cloud-pi-native/console/compare/v9.2.1...v9.3.0) (2025-05-19) + + +### Features + +* :zap: optimization of gitlab API calls ([d9b236e](https://github.com/cloud-pi-native/console/commit/d9b236ebe032d4976094fed757467165ac40df25)) + + +### Bug Fixes + +* :bug: add missing scope when creating keycloak client ([ec24df1](https://github.com/cloud-pi-native/console/commit/ec24df116469237ad85e67d183560976283fc1ec)) +* :wrench: duplicate changelog entries ([#1587](https://github.com/cloud-pi-native/console/issues/1587)) ([ea9fdec](https://github.com/cloud-pi-native/console/commit/ea9fdec8e15132d537f888aec0369ccb655a3667)) +* allowed sources repositories ([a5ce7ad](https://github.com/cloud-pi-native/console/commit/a5ce7ad7a6a3cc17fa8726a6be42cdf53828034b)) + +## [9.2.1](https://github.com/cloud-pi-native/console/compare/v9.2.0...v9.2.1) (2025-03-25) + + +### Bug Fixes + +* :bug: update condition to allow private quota modification ([f4c34bb](https://github.com/cloud-pi-native/console/commit/f4c34bb1e462a9d393c4af231a0f524eb20fcad1)) + +## [9.2.0](https://github.com/cloud-pi-native/console/compare/v9.1.0...v9.2.0) (2025-03-21) + + +### Features + +* :zap: limit gitlab api calls to list cicd variables in sonar plugin ([c1b3299](https://github.com/cloud-pi-native/console/commit/c1b32998f4f340c945c657d2dbbb81160fc054f8)) + +## [9.1.0](https://github.com/cloud-pi-native/console/compare/v9.0.0...v9.1.0) (2025-02-19) + + +### Features + +* :sparkles: display last refresh monitor and display cause to admins ([054ea37](https://github.com/cloud-pi-native/console/commit/054ea37d556b1c6e0fabf0e52f4611395aec18b4)) + + +### Bug Fixes + +* :alien: fix nexus monitoring ([8bb7448](https://github.com/cloud-pi-native/console/commit/8bb74480deaa2b88628d2a715496e93aa4d27629)) +* :bug: missing icons ([d2b98c2](https://github.com/cloud-pi-native/console/commit/d2b98c201fb9f4ce3181c2d2b55fdac2a85f11f5)) + +## [9.0.0](https://github.com/cloud-pi-native/console/compare/v8.24.2...v9.0.0) (2025-02-14) + + +### ⚠ BREAKING CHANGES + +* :triangular_flag_on_post: remove organizations final part + +### Features + +* :sparkles: auto create projectRootDir in Gitlab ([b4f1212](https://github.com/cloud-pi-native/console/commit/b4f12120e81d8d689b1d65de12ed06c65a95279b)) +* :triangular_flag_on_post: remove organizations final part ([73ce9a9](https://github.com/cloud-pi-native/console/commit/73ce9a9497f44bb4d831aed8c25f9ee2e8a337f5)) +* search cluster, table display ([d21be53](https://github.com/cloud-pi-native/console/commit/d21be53e168a820f500334d97faa73ae30aeaf6e)) +* unique dashboard ([d21be53](https://github.com/cloud-pi-native/console/commit/d21be53e168a820f500334d97faa73ae30aeaf6e)) + + +### Bug Fixes + +* :ambulance: bad migration ([baa0712](https://github.com/cloud-pi-native/console/commit/baa071203693db68154ffda61f99a66a72a2c1c8)) +* :ambulance: forgot to use slug in gitlab group ([aa22f19](https://github.com/cloud-pi-native/console/commit/aa22f1963d0d87d28d1a9d74fbf13ac207545382)) +* :bug: auto re login when session expiry ([6c16e88](https://github.com/cloud-pi-native/console/commit/6c16e884d505372a8d2142d5870aed635ac4afbd)) + +## [8.24.2](https://github.com/cloud-pi-native/console/compare/v8.24.1...v8.24.2) (2025-02-06) + + +### Bug Fixes + +* :recycle: add projectId to url params ([3986376](https://github.com/cloud-pi-native/console/commit/3986376d418c017f8edc802ad237df4e3a29df25)) + +## [8.24.1](https://github.com/cloud-pi-native/console/compare/v8.24.0...v8.24.1) (2025-02-04) + + +### Bug Fixes + +* :ambulance: owner of a project cannot see attached private quota ([297a4ac](https://github.com/cloud-pi-native/console/commit/297a4ac410c739142d3ef88f1da58492881ddb6f)) + +## [8.24.0](https://github.com/cloud-pi-native/console/compare/v8.23.4...v8.24.0) (2025-01-28) + + +### Features + +* :sparkles: add an 'external' boolean properties on clusters ([4a64abc](https://github.com/cloud-pi-native/console/commit/4a64abccea40d2675f435b6cec21bb4f9aa3aa43)) +* :sparkles: vault plugin, switch to deploy or not vaultconnections ([cc9c4e6](https://github.com/cloud-pi-native/console/commit/cc9c4e6ad40412921a8d7afc888f014d92ef46c8)) + + +### Bug Fixes + +* :alien: gitlab response changed on upgrade ([d595f3a](https://github.com/cloud-pi-native/console/commit/d595f3ac80e84070e27ed257d4b8879ee2611d46)) +* :bug: names displayed twice at project creation ([079a20a](https://github.com/cloud-pi-native/console/commit/079a20a4e5d910e64e43a6df2925acee5ea7368c)) + +## [8.23.4](https://github.com/cloud-pi-native/console/compare/v8.23.3...v8.23.4) (2025-01-24) + + +### Bug Fixes + +* :bug: duplicates name should not be already allowed ([eb2a4db](https://github.com/cloud-pi-native/console/commit/eb2a4db44596ad2f9f6affbbdb2dea84cb28067c)) +* fix gitlab, no first commit on cloned repository ([2d7038f](https://github.com/cloud-pi-native/console/commit/2d7038fd1f5aefb67259666086048d11a6d5537f)) + +## [8.23.3](https://github.com/cloud-pi-native/console/compare/v8.23.2...v8.23.3) (2025-01-21) + + +### Bug Fixes + +* :ambulance: fix gitlab ci config file ([2c2a287](https://github.com/cloud-pi-native/console/commit/2c2a287a76723dfffecd9ae892a8897f03aef161)) + +## [8.23.2](https://github.com/cloud-pi-native/console/compare/v8.23.1...v8.23.2) (2025-01-21) + + +### Bug Fixes + +* :bug: add a better patch capability for kubernetes ([0050394](https://github.com/cloud-pi-native/console/commit/005039450701f9d77a4479487ba872af602ec689)) +* :bug: can't retrieved enc ns name for gitops cluster ([dcf3c1a](https://github.com/cloud-pi-native/console/commit/dcf3c1a147755ae698ba09f7bb7d1d79d13cc1d1)) +* :triangular_flag_on_post: no more cloned repository at gitlab createProject ([211d070](https://github.com/cloud-pi-native/console/commit/211d070d77802d894c70c584fa2c41e86a297295)) + +## [8.23.1](https://github.com/cloud-pi-native/console/compare/v8.23.0...v8.23.1) (2025-01-20) + + +### Bug Fixes + +* :ambulance: bad gitlab url fn ([2ec5332](https://github.com/cloud-pi-native/console/commit/2ec5332ce320e40da131ff537974c169716e3ac5)) + +## [8.23.0](https://github.com/cloud-pi-native/console/compare/v8.22.1...v8.23.0) (2025-01-20) + + +### Features + +* :fire: remove organization feature ([3b331f6](https://github.com/cloud-pi-native/console/commit/3b331f680716880e7ae4e33b6e441452e2816f3c)) +* :sparkles: store cluster secret in Vault ([cea79ef](https://github.com/cloud-pi-native/console/commit/cea79efc6ecb4c33d868d7f5044ddc7825c69ed5)) + + +### Bug Fixes + +* :bug: test if description is string before using replaceAll function ([d5b1234](https://github.com/cloud-pi-native/console/commit/d5b12342c236dca1231d05528f6ccd7746f62309)) + +## [8.22.1](https://github.com/cloud-pi-native/console/compare/v8.22.0...v8.22.1) (2025-01-09) + + +### Bug Fixes + +* :zap: package icons in client ([73fa90d](https://github.com/cloud-pi-native/console/commit/73fa90dd8aa0fe9c7fa8075ada31eea39af808e5)) +* patch vue-dsfr ([02079aa](https://github.com/cloud-pi-native/console/commit/02079aaf569b80f1a6a245504757524826a7ab3c)) + +## [8.22.0](https://github.com/cloud-pi-native/console/compare/v8.21.0...v8.22.0) (2025-01-08) + + +### Features + +* :sparkles: store cluster secret in Vault ([3935ca6](https://github.com/cloud-pi-native/console/commit/3935ca68cb0927bf150e15c3b205baebafa5cb98)) +* track project version provisioning ([227baf6](https://github.com/cloud-pi-native/console/commit/227baf6802687c9555d3931071a7e049d2756db1)) + + +### Bug Fixes + +* :bug: downgrade gitlab lib to avoid upsert user warning ([90faeff](https://github.com/cloud-pi-native/console/commit/90faeff2978e0ee2db7624b895550db278aa9c17)) +* :bug: nexus auto disable repo config on delete ([1295ece](https://github.com/cloud-pi-native/console/commit/1295ece0312a6c7b319c379e88be1ee570d75499)) + +## [8.21.0](https://github.com/cloud-pi-native/console/compare/v8.20.0...v8.21.0) (2024-12-16) + + +### Features + +* :construction: zone kv store management ([628a6e5](https://github.com/cloud-pi-native/console/commit/628a6e57cb439c8b899acdb8bf1995eedbe0a184)) + + +### Bug Fixes + +* :bug: fix not displaying swagger ([6f708a1](https://github.com/cloud-pi-native/console/commit/6f708a150402835c4ef044bdb8e69f20dfe3ee32)) +* :bug: retry delete project is failed ([c5856fa](https://github.com/cloud-pi-native/console/commit/c5856fadc49acd238ee61d289cf8441a5b3a2e18)) + +## [8.20.0](https://github.com/cloud-pi-native/console/compare/v8.19.1...v8.20.0) (2024-11-25) + + +### Features + +* :sparkles: bulk actions on projects ([951fc15](https://github.com/cloud-pi-native/console/commit/951fc152016e00e3bac6cb1d9920998e3cfb4a80)) + +## [8.19.1](https://github.com/cloud-pi-native/console/compare/v8.19.0...v8.19.1) (2024-11-23) + + +### Bug Fixes + +* :ambulance: broken admin token ([a1eff7f](https://github.com/cloud-pi-native/console/commit/a1eff7f96c6793338019eea0676b8f6dea4c0056)) + +## [8.19.0](https://github.com/cloud-pi-native/console/compare/v8.18.2...v8.19.0) (2024-11-22) + + +### Features + +* :art: use upsert zone hook to manage infra repo ([6fe47d1](https://github.com/cloud-pi-native/console/commit/6fe47d1922359c4934b5364fa3d87dd86cec129b)) +* :sparkles: easy in-cluster config ([01435e2](https://github.com/cloud-pi-native/console/commit/01435e2595383b4be00bdafb45970161765da6b9)) +* :sparkles: enable pwa on frontend ([c6c03f3](https://github.com/cloud-pi-native/console/commit/c6c03f3dadf9e954353ad11c29c19d50538d1e2a)) + + +### Bug Fixes + +* :bug: vault forgot to set kv to v2 ([90dca64](https://github.com/cloud-pi-native/console/commit/90dca64aca9d64b8491b332d0151f1be68dcbccc)) + +## [8.18.2](https://github.com/cloud-pi-native/console/compare/v8.18.1...v8.18.2) (2024-11-20) + + +### Bug Fixes + +* :bug: fix vault detect api hashicorp ([86959f5](https://github.com/cloud-pi-native/console/commit/86959f5874f60e898442ba4f734ff1a5581998a6)) + +## [8.18.1](https://github.com/cloud-pi-native/console/compare/v8.18.0...v8.18.1) (2024-11-20) + + +### Bug Fixes + +* :ambulance: fix pat migration ([df3f699](https://github.com/cloud-pi-native/console/commit/df3f699ddc972a2b2001a20d5e31dd5ba9ad7482)) + +## [8.18.0](https://github.com/cloud-pi-native/console/compare/v8.17.2...v8.18.0) (2024-11-20) + + +### Features + +* :sparkles: build project kv with config for projects ([f1c687f](https://github.com/cloud-pi-native/console/commit/f1c687f55ee4c8b8c2f342e431fb3f0982ffd72c)) +* ✨ add PAT ([2c35cd7](https://github.com/cloud-pi-native/console/commit/2c35cd72a1d0fb14f8c1a633b86e7ef00da80bab)) + + +### Bug Fixes + +* :bug: fix weird front behviours ([e06cf11](https://github.com/cloud-pi-native/console/commit/e06cf117c0fef4f49749d80a7ac5b8de813c6ed7)) + +## [8.17.2](https://github.com/cloud-pi-native/console/compare/v8.17.1...v8.17.2) (2024-11-14) + + +### Bug Fixes + +* :zap: disallow recursive list to avoid big latency ([1fed78c](https://github.com/cloud-pi-native/console/commit/1fed78c1742b70918c9bab1ff616f0cf681d66fa)) + +## [8.17.1](https://github.com/cloud-pi-native/console/compare/v8.17.0...v8.17.1) (2024-11-08) + + +### Bug Fixes + +* :ambulance: fix not displayed repositories in admin view ([48c0a92](https://github.com/cloud-pi-native/console/commit/48c0a92339db999c598fe24cdc9302b28cffa14d)) +* :ambulance: internal gitlab url ([16f195e](https://github.com/cloud-pi-native/console/commit/16f195e55222de023046aaabefa9b6d7f79062ee)) + +## [8.17.0](https://github.com/cloud-pi-native/console/compare/v8.16.0...v8.17.0) (2024-11-07) + + +### Features + +* :sparkles: config items section ([7c41eea](https://github.com/cloud-pi-native/console/commit/7c41eeaec2855355c0fa683d4b7ec81210eae6af)) +* :sparkles: hooks returned store accept null ([1255a32](https://github.com/cloud-pi-native/console/commit/1255a32237a6775e465fc0efe4dfe10bc9463816)) +* :sparkles: nexus fine write policy ([14897e6](https://github.com/cloud-pi-native/console/commit/14897e685ff6665da35464d93571a972e2c8dae9)) +* :sparkles: record and display last time a user logged ([1e8323b](https://github.com/cloud-pi-native/console/commit/1e8323bbfcd611891fe741434d4cb5f780f45dec)) +* gitlab optional internal url ([c1643a1](https://github.com/cloud-pi-native/console/commit/c1643a1eaae350220c4ecf114c7fa16bfb1983a1)) + +## [8.16.0](https://github.com/cloud-pi-native/console/compare/v8.15.1...v8.16.0) (2024-11-06) + + +### Features + +* :lipstick: disable cluster selection when creating a zone ([a8bb19c](https://github.com/cloud-pi-native/console/commit/a8bb19cb4ff50b69b892547e05b1541e322b0ecd)) +* :lipstick: user list rework ([5c13b9d](https://github.com/cloud-pi-native/console/commit/5c13b9d756c549a2f12d99e7f7384690f3166553)) +* :sparkles: nexus customizable write policy ([78a004b](https://github.com/cloud-pi-native/console/commit/78a004bc72e97b3a98c963560e8c11db45b34b67)) + + +### Bug Fixes + +* :bug: minors plugins fix and front ([22937c8](https://github.com/cloud-pi-native/console/commit/22937c875480dc0b6bf67f0784311f98300df04d)) + +## [8.15.1](https://github.com/cloud-pi-native/console/compare/v8.15.0...v8.15.1) (2024-10-29) + + +### Bug Fixes + +* :bug: update depracated import assertion type syntax ([d8f2072](https://github.com/cloud-pi-native/console/commit/d8f20728a0d96c6799e5bba7e821ed12356d6393)) + +## [8.15.0](https://github.com/cloud-pi-native/console/compare/v8.14.0...v8.15.0) (2024-10-29) + + +### Features + +* :lipstick: adding the project id in namespace labels ([eace611](https://github.com/cloud-pi-native/console/commit/eace611838cd2a5ce0e457971914c9d3bf29e6d0)) +* 🚸 rework project selection ([1641fc9](https://github.com/cloud-pi-native/console/commit/1641fc974f455734bedeb0cf2fdaf7356c9b850b)) + +## [8.14.0](https://github.com/cloud-pi-native/console/compare/v8.13.2...v8.14.0) (2024-10-17) + + +### Features + +* :lipstick: add argocd urls for each zone ([cf467ae](https://github.com/cloud-pi-native/console/commit/cf467aeaf82c44099f8b964dd4ef2e00a5323a90)) +* :sparkles: add npm support in npm ([60b7752](https://github.com/cloud-pi-native/console/commit/60b7752fd31ca36860e64df9b320d8468163d0d4)) +* :sparkles: introduce warn status on project ([9d69357](https://github.com/cloud-pi-native/console/commit/9d69357fde2014c1fe134803f8361680bac1d3fc)) +* :sparkles: keycloak client management for configuring sso oidc of each argocd zone ([44b3b06](https://github.com/cloud-pi-native/console/commit/44b3b06e3882fe2a60691a8bd29572e023b32146)) +* :sparkles: return warns and errors message from plugins to user ([be4443f](https://github.com/cloud-pi-native/console/commit/be4443fc10db5669578279fbcbac2b4d67adcb52)) +* :sparkles: show short logs about project to users ([4888e64](https://github.com/cloud-pi-native/console/commit/4888e64527d2627fc2e320300a39745e2571b73c)) + +## [8.13.2](https://github.com/cloud-pi-native/console/compare/v8.13.1...v8.13.2) (2024-10-02) + + +### Bug Fixes + +* :ambulance: no check on api response ([cfc56db](https://github.com/cloud-pi-native/console/commit/cfc56db5b4cdab03b3515d2cae1477e7546dfef8)) + +## [8.13.1](https://github.com/cloud-pi-native/console/compare/v8.13.0...v8.13.1) (2024-09-27) + + +### Bug Fixes + +* :loud_sound: change log logic in sonarqube plugin ([5006562](https://github.com/cloud-pi-native/console/commit/500656244f12d0d1edeb995e30be8cfa95ca8a4f)) + +## [8.13.0](https://github.com/cloud-pi-native/console/compare/v8.12.3...v8.13.0) (2024-09-27) + + +### Features + +* :loud_sound: add logger object ([02c6715](https://github.com/cloud-pi-native/console/commit/02c67150d738ae200f8937adb88bf32001ff2b8c)) +* :sparkles: plugins can return warning to not interupt hook ([3e132c3](https://github.com/cloud-pi-native/console/commit/3e132c3238cc98d1d75ec956ce55efebe2c5304a)) + + +### Bug Fixes + +* :bug: change imports exports to not use z.lazy, fix swagger-ui ([64a60fe](https://github.com/cloud-pi-native/console/commit/64a60fe6bc84ea9de51519049252957726963afa)) +* :bug: dsfr tabs require rewrite DsoHome.vue ([ea03623](https://github.com/cloud-pi-native/console/commit/ea0362303757ac239e5b753af6777514cb714b03)) + +## [8.12.3](https://github.com/cloud-pi-native/console/compare/v8.12.2...v8.12.3) (2024-09-19) + + +### Bug Fixes + +* :passport_control: allow more actions to admin token ([81fa606](https://github.com/cloud-pi-native/console/commit/81fa60653e2bfbcb37251bda448ea3d2f9805eb3)) + +## [8.12.2](https://github.com/cloud-pi-native/console/compare/v8.12.1...v8.12.2) (2024-09-19) + + +### Bug Fixes + +* :ambulance: cbx not checked cause vuedsfr breaking change ([c7e607f](https://github.com/cloud-pi-native/console/commit/c7e607fa6a373866489d7e72e7234107d76a3b7e)) + +## [8.12.1](https://github.com/cloud-pi-native/console/compare/v8.12.0...v8.12.1) (2024-09-17) + + +### Bug Fixes + +* :bug: fix behaviour of complex querying on user list ([b63bd9a](https://github.com/cloud-pi-native/console/commit/b63bd9a56c118fe52ab38589a28c90777c5a557e)) + +## [8.12.0](https://github.com/cloud-pi-native/console/compare/v8.11.1...v8.12.0) (2024-09-17) + + +### Features + +* :sparkles: add admins api tokens ([e141642](https://github.com/cloud-pi-native/console/commit/e141642eb2b717c29cf91f482e91761e7ce4bfa8)) +* :sparkles: add users search filter (admin) ([cc1ab56](https://github.com/cloud-pi-native/console/commit/cc1ab56b2ac496ffd01196ee4d97362ab7a08e03)) + +## [8.11.1](https://github.com/cloud-pi-native/console/compare/v8.11.0...v8.11.1) (2024-09-02) + + +### Bug Fixes + +* :lipstick: better repo ui ([084fc91](https://github.com/cloud-pi-native/console/commit/084fc91332fbdd6052cadbde204df77cd772a07d)) + +## [8.11.0](https://github.com/cloud-pi-native/console/compare/v8.10.1...v8.11.0) (2024-08-29) + + +### Features + +* :sparkles: standalone repo ([ea9a2ba](https://github.com/cloud-pi-native/console/commit/ea9a2bac3fc9696fde6f1ae56b916e180ae19039)) + + +### Bug Fixes + +* :ambulance: edit previous migrations ([2a87288](https://github.com/cloud-pi-native/console/commit/2a87288ea99c19df706fe2f68173ba4bea919a17)) + + +### Performance Improvements + +* :zap: avoid unuseful keycloak call and auto-refresh ([8dcb1c2](https://github.com/cloud-pi-native/console/commit/8dcb1c24e9cce760e278222dcb84c62dff4118a1)) + +## [8.10.1](https://github.com/cloud-pi-native/console/compare/v8.10.0...v8.10.1) (2024-08-27) + + +### Bug Fixes + +* :ambulance: missed admin authorized to remove a member ([15eafa9](https://github.com/cloud-pi-native/console/commit/15eafa9996d7e1f827f5eb1cf6aef425f4620a2b)) + +## [8.10.0](https://github.com/cloud-pi-native/console/compare/v8.9.4...v8.10.0) (2024-08-26) + + +### Features + +* :sparkles: add project dedicated approle and policy in vault, to be used by argocd ([e354978](https://github.com/cloud-pi-native/console/commit/e354978392b8c5d7a4c3591388f0f0a9aa3d7865)) +* :sparkles: introduce fine grained perms and and roles ([ab0a7a5](https://github.com/cloud-pi-native/console/commit/ab0a7a55e42ef94a18e529b168d5f299687ca526)) +* :sparkles: maintenance mode ([fdbd5f0](https://github.com/cloud-pi-native/console/commit/fdbd5f0356a16c24ef677f8a06309aac89af3a4c)) +* :sparkles: option to mirror all branches ([655b032](https://github.com/cloud-pi-native/console/commit/655b0329599fe7e0857e487e3b0fd3ecb49b94ac)) + +## [8.9.4](https://github.com/cloud-pi-native/console/compare/v8.9.3...v8.9.4) (2024-08-09) + + +### Bug Fixes + +* :ambulance: handle missing commit in gitlab repo ([2ef9930](https://github.com/cloud-pi-native/console/commit/2ef993001770e76be5bdf2795a0cbe8e302cf71a)) + +## [8.9.3](https://github.com/cloud-pi-native/console/compare/v8.9.2...v8.9.3) (2024-08-08) + + +### Bug Fixes + +* :ambulance: fix error on getArgoRepo when the infra repository is empty (zero commit) ([bf3f3f1](https://github.com/cloud-pi-native/console/commit/bf3f3f10c0e50eaf2f2b788b587c04e27f1eff00)) + +## [8.9.2](https://github.com/cloud-pi-native/console/compare/v8.9.1...v8.9.2) (2024-07-19) + + +### Bug Fixes + +* :ambulance: clone private repositories in gitlab plugin ([d42bbf8](https://github.com/cloud-pi-native/console/commit/d42bbf8a02950a6f9d0d3218bd7bc40cec203843)) + +## [8.9.1](https://github.com/cloud-pi-native/console/compare/v8.9.0...v8.9.1) (2024-07-18) + + +### Bug Fixes + +* :bug: search on HEAD ref instead of main ([ac13b5f](https://github.com/cloud-pi-native/console/commit/ac13b5fca8c952841e162ed300fdce099c74ac9b)) + +## [8.9.0](https://github.com/cloud-pi-native/console/compare/v8.8.2...v8.9.0) (2024-07-17) + + +### Features + +* :rocket: add helm detection and list values files ([006ccba](https://github.com/cloud-pi-native/console/commit/006ccbaa6d4710af8f1495ae8dadccf729b7dcc6)) + +## [8.8.2](https://github.com/cloud-pi-native/console/compare/v8.8.1...v8.8.2) (2024-07-15) + + +### Bug Fixes + +* :art: harmonize hook calling in business ([4effaab](https://github.com/cloud-pi-native/console/commit/4effaab7f9c80561d0fd52e365822d806a5426f5)) +* :bug: don't ignore user token when testing cluster deployment mode ([cc488c3](https://github.com/cloud-pi-native/console/commit/cc488c3053fadffba040f8396eebfa27bf6c1f79)) +* :bug: env name error not displayed ([d2f84e8](https://github.com/cloud-pi-native/console/commit/d2f84e822b94074615df3385f0c088582ceaf3cf)) +* :bug: require tls serverName ([9d4dea2](https://github.com/cloud-pi-native/console/commit/9d4dea22e68aab7ef6ee6b74a16e92a397002709)) + +## [8.8.1](https://github.com/cloud-pi-native/console/compare/v8.8.0...v8.8.1) (2024-07-09) + + +### Bug Fixes + +* :ambulance: not ready for project reponse validation ([c77dd68](https://github.com/cloud-pi-native/console/commit/c77dd6889886b3c867294437e5377573771a7ff0)) +* :ambulance: second try to fix ([1ef6244](https://github.com/cloud-pi-native/console/commit/1ef6244d7536de5704ec6fc9a83b0cfc5d806147)) + +## [8.8.1](https://github.com/cloud-pi-native/console/compare/v8.8.0...v8.8.1) (2024-07-09) + + +### Bug Fixes + +* :ambulance: not ready for project reponse validation ([c77dd68](https://github.com/cloud-pi-native/console/commit/c77dd6889886b3c867294437e5377573771a7ff0)) + +## [8.8.0](https://github.com/cloud-pi-native/console/compare/v8.7.1...v8.8.0) (2024-07-09) + + +### Features + +* :sparkles: query filter admin projects ([feb701d](https://github.com/cloud-pi-native/console/commit/feb701d9d36db0120032f0c1f8888a893c799495)) + +## [8.7.1](https://github.com/cloud-pi-native/console/compare/v8.7.0...v8.7.1) (2024-07-05) + + +### Bug Fixes + +* :bug: insert user when first accessing admin pages ([095b9de](https://github.com/cloud-pi-native/console/commit/095b9de4e282712919e932bf67b15fdaeec96932)) +* :lipstick: repo should be able to change infra, not infra ([1069728](https://github.com/cloud-pi-native/console/commit/10697280499b44b1a1eef28a71d510bfe8916cf3)) + +## [8.7.0](https://github.com/cloud-pi-native/console/compare/v8.6.0...v8.7.0) (2024-07-03) + + +### Features + +* :necktie: hide private quotas for all on environment form ([ef08f29](https://github.com/cloud-pi-native/console/commit/ef08f29080d2be32236839be1e402fe76f44f465)) + + +### Bug Fixes + +* :lock: serialize sevice monitor response ([26ee98f](https://github.com/cloud-pi-native/console/commit/26ee98fa21984e57eaae8251dc14394f0e94c4b9)) + +## [8.6.0](https://github.com/cloud-pi-native/console/compare/v8.5.0...v8.6.0) (2024-06-28) + + +### Features + +* :art: change subpath of deployments ([d7b57ca](https://github.com/cloud-pi-native/console/commit/d7b57ca095bcb1ae8fcde5ead7a533b0f0ce81e5)) +* :sparkles: add user leo poumailloux ([0147e07](https://github.com/cloud-pi-native/console/commit/0147e071bbdfbb8c666d20247bd95938f645f152)) +* :sparkles: give project ownership ([dc6fa79](https://github.com/cloud-pi-native/console/commit/dc6fa798fe2fe37395c521482bbc857eb74b50d1)) +* :sparkles: truncate description ([8191a41](https://github.com/cloud-pi-native/console/commit/8191a41ae808980d400f1e65499e012d0d192c28)) + + +### Bug Fixes + +* :bug: handle vault ha for status monitor ([3692e01](https://github.com/cloud-pi-native/console/commit/3692e010d9d4c0c03e8403b4d5df3591f0312209)) +* :bug: interface suppression & add explication ([4476c9b](https://github.com/cloud-pi-native/console/commit/4476c9bacdcc748ca0d2d1029f8980392a2ea068)) +* :bug: isolation des groupes d'envs dans keycloak ([91b5d55](https://github.com/cloud-pi-native/console/commit/91b5d551a88f5059868a80271be5b37c451839d0)) +* :bug: prevent env deletion if project is locked ([d75a5cf](https://github.com/cloud-pi-native/console/commit/d75a5cfc4e86741a91ba31d0c2d2fc2b21fa5226)) +* :bug: prevent env deletion if project is locked ([a273869](https://github.com/cloud-pi-native/console/commit/a273869ba421ee37fc342c585c38c978aab5d411)) +* :bug: support slashes in syncRepository branchName ([d304427](https://github.com/cloud-pi-native/console/commit/d304427c8bd0af8e7080ec9480143d0abab3bb76)) + +## [8.5.0](https://github.com/cloud-pi-native/console/compare/v8.4.1...v8.5.0) (2024-06-10) + + +### Features + +* :sparkles: add text filter for admin user list ([d44f142](https://github.com/cloud-pi-native/console/commit/d44f142e9f559f7714e3eb71650c1dd3c8a325cd)) +* :sparkles: allow harbor quota limit ([9644d03](https://github.com/cloud-pi-native/console/commit/9644d037ea2642ba9e456e362c18a1eed520b66b)) +* :sparkles: argocd extra repositories ([303f4a2](https://github.com/cloud-pi-native/console/commit/303f4a2fac89ce511aeac9cf87dbf6221908e373)) + + +### Bug Fixes + +* :bug: keycloak crashed when more than 10 environments ([f3d4f5d](https://github.com/cloud-pi-native/console/commit/f3d4f5d3cc5b49946cc4e1ab969895229524f335)) +* :bug: manage if no users in project ([cd23a59](https://github.com/cloud-pi-native/console/commit/cd23a5940eddf1d8509bf7594842db5bcc5fa1ae)) +* :bug: why is it useful ? ([ee2bf5c](https://github.com/cloud-pi-native/console/commit/ee2bf5ce588a92b303b3af8db2bf24ed999822eb)) + +## [8.4.1](https://github.com/cloud-pi-native/console/compare/v8.4.0...v8.4.1) (2024-05-31) + + +### Bug Fixes + +* :bug: add try catch to parsing plugin error ([b958529](https://github.com/cloud-pi-native/console/commit/b95852942863f75eda7458e7593e09579c7811d5)) + +## [8.4.0](https://github.com/cloud-pi-native/console/compare/v8.3.0...v8.4.0) (2024-05-29) + + +### Features + +* :sparkles: create a user usable robot for harbor ([a71dfc9](https://github.com/cloud-pi-native/console/commit/a71dfc992b62a6c3c69dcc6d7de98b8d2d72a79c)) +* :sparkles: customize keycloak theme ([7c21375](https://github.com/cloud-pi-native/console/commit/7c21375d1ad56a03f84c25d04cbe0ada7eac8d5c)) +* :sparkles: option for gitlab display secret ([28d3ebc](https://github.com/cloud-pi-native/console/commit/28d3ebce4d6912904e1f21cd3c126ca342fc3cfb)) + + +### Bug Fixes + +* :art: squash const ([cd1be8c](https://github.com/cloud-pi-native/console/commit/cd1be8c8f282be045ffce473387b21ed95355f5e)) +* :bug: fallback if no owners are found ([629ef40](https://github.com/cloud-pi-native/console/commit/629ef40afb3c6ff0a22b0f17c2c86a343463a8ba)) +* :bug: missing await and ambiguous message ([0530b45](https://github.com/cloud-pi-native/console/commit/0530b4520a5bc05d6a45f9f76029c898f960ee34)) +* :bug: update cluster logic ([65ba7e1](https://github.com/cloud-pi-native/console/commit/65ba7e10f0d7dd9e1675da633c922fff8499ee95)) + +## [8.3.0](https://github.com/cloud-pi-native/console/compare/v8.2.2...v8.3.0) (2024-05-16) + + +### Features + +* :sparkles: handle keycloak admin group membership from console ([4741878](https://github.com/cloud-pi-native/console/commit/4741878fdfa4d784ed2e7695407ebce6b00b4850)) + + +### Bug Fixes + +* :ambulance: possible missed clusters in payload ([47efcc8](https://github.com/cloud-pi-native/console/commit/47efcc893f272823b5d7d7f4b78b9c29ddad9095)) +* :ambulance: unhandled promise gitlab ([592035c](https://github.com/cloud-pi-native/console/commit/592035c95605f004e62cba71faff36ea189d6173)) + +## [8.2.2](https://github.com/cloud-pi-native/console/compare/v8.2.1...v8.2.2) (2024-05-13) + + +### Bug Fixes + +* :bug: fix update cluster ([f02958c](https://github.com/cloud-pi-native/console/commit/f02958ca3888fdb452a545fd94b8d20f9ebb93ed)) +* :bug: stores should have unique names ([6e8032a](https://github.com/cloud-pi-native/console/commit/6e8032a68b71d7ea86b8153d0984013e5bd4c8df)) + +## [8.2.1](https://github.com/cloud-pi-native/console/compare/v8.2.0...v8.2.1) (2024-05-06) + + +### Bug Fixes + +* :fire: remove unjustified refine ([9b1d41e](https://github.com/cloud-pi-native/console/commit/9b1d41ec402446678518bb39c0f3cff543afceb3)) +* :mute: remove problematic log ([639e982](https://github.com/cloud-pi-native/console/commit/639e982df49ccb5888adb26f3a0b1ca62fe53973)) + +## [8.2.0](https://github.com/cloud-pi-native/console/compare/v8.1.1...v8.2.0) (2024-05-03) + + +### Features + +* :sparkles: adding the value file commit ([6ea872b](https://github.com/cloud-pi-native/console/commit/6ea872b498e658113983cdc2b80d9d2ca3cf770d)) +* :sparkles: can configure plugins globally or by project ([15b66c1](https://github.com/cloud-pi-native/console/commit/15b66c1be31311e3b6062c3d771fb3ecc3dcbfa8)) + + +### Bug Fixes + +* :bug: override plugins unzip if already exists ([ff65133](https://github.com/cloud-pi-native/console/commit/ff651332fcee6ae752bdf4533ebbb88f216bfe95)) + +## [8.1.1](https://github.com/cloud-pi-native/console/compare/v8.1.0...v8.1.1) (2024-04-24) + + +### Bug Fixes + +* :ambulance: correct sql query for zoneId ([c47dae4](https://github.com/cloud-pi-native/console/commit/c47dae4a4f74cd51e6c9fe19856d4b8eea08cbb8)) + +## [8.1.0](https://github.com/cloud-pi-native/console/compare/v8.0.3...v8.1.0) (2024-04-24) + + +### Features + +* :safety_vest: allow unlogged users to access services health ([e2fc942](https://github.com/cloud-pi-native/console/commit/e2fc9423b107f1afd9fdbe1b017b7dd1f3d2eab1)) +* :safety_vest: check existing zone slug before creating one ([072fffb](https://github.com/cloud-pi-native/console/commit/072fffbd1d4788329fb76b927242854feb25d31b)) +* :sparkles: add zones ([53d155f](https://github.com/cloud-pi-native/console/commit/53d155f244a846e0cf23d3cf75528e6d937fe0ce)) +* :sparkles: allow repo sync from console ui ([1cd1c93](https://github.com/cloud-pi-native/console/commit/1cd1c93a9e1d95663e51f4de24a5b518fa2bee1a)) +* :sparkles: display project services in admin view ([a5d53fd](https://github.com/cloud-pi-native/console/commit/a5d53fd586dd912b13b72196ed99c2354c022448)) + + +### Bug Fixes + +* :bug: app version in client footer ([ac2f26e](https://github.com/cloud-pi-native/console/commit/ac2f26e88ca3fa593668d4f9641465b09855c663)) +* :bug: get only client app version from env on production build ([3bb3017](https://github.com/cloud-pi-native/console/commit/3bb3017f968e8e93b955b33cb494d51ecce5521a)) +* :bug: handle stage and zone changes in environment form ([5aa0a58](https://github.com/cloud-pi-native/console/commit/5aa0a585e9903e0a29f7bec29e4a08090109542b)) + +## [8.0.3](https://github.com/cloud-pi-native/console/compare/v8.0.2...v8.0.3) (2024-04-16) + + +### Bug Fixes + +* :ambulance: gitlab secret ([0c03a95](https://github.com/cloud-pi-native/console/commit/0c03a9551377705fced2dedf1b9e8063df9d2748)) + +## [8.0.2](https://github.com/cloud-pi-native/console/compare/v8.0.1...v8.0.2) (2024-04-11) + + +### Bug Fixes + +* :lock: remove old robot permission that harbor does not support anymore ([1d988ce](https://github.com/cloud-pi-native/console/commit/1d988ce86b6b338a576d517816f013295b2f18d3)) + +## [8.0.1](https://github.com/cloud-pi-native/console/compare/v8.0.0...v8.0.1) (2024-04-04) + + +### Bug Fixes + +* :ambulance: change kubernetes logic ([3679c22](https://github.com/cloud-pi-native/console/commit/3679c2221c961b4887e1191229edaacd6e34649b)) + +## [8.0.0](https://github.com/cloud-pi-native/console/compare/v7.0.1...v8.0.0) (2024-04-02) + + +### ⚠ BREAKING CHANGES + +* :sparkles: simplify hooks for idempotency + +### Features + +* :safety_vest: allow + sign for repo externalUserName ([4a695ef](https://github.com/cloud-pi-native/console/commit/4a695eff71ea6a03f839d19d2e2526180ea5161b)) +* :sparkles: add replay hooks button for project ([9147231](https://github.com/cloud-pi-native/console/commit/91472317ebd112b4f360d859d25d8793c89eb331)) +* :sparkles: add sonar properties var file to gitlab ([0462a36](https://github.com/cloud-pi-native/console/commit/0462a364b54e66639ade6997f6f4c48e9fe305c6)) +* :sparkles: simplify hooks for idempotency ([aac0a2d](https://github.com/cloud-pi-native/console/commit/aac0a2d331acd4e86531591de99cc944584f7684)) +* 🚧 création du repo infra-apps à la création du projet ([17007f7](https://github.com/cloud-pi-native/console/commit/17007f7eb0bb8c9ba612cf70536236b428b801da)) + + +### Bug Fixes + +* :ambulance: gitlab plugin deleted public repo secret ([46002b0](https://github.com/cloud-pi-native/console/commit/46002b007e9dfd69ce5daeee33cfd61164e0ed8e)) +* :ambulance: gitlab plugin deleted public repo secret ([353f43e](https://github.com/cloud-pi-native/console/commit/353f43eba74d5d0b0a95ace2ee65d9c51c0685b7)) +* :bug: reload user projects after hooks replay ([ae57ed0](https://github.com/cloud-pi-native/console/commit/ae57ed017dac4e5601cdf80a02598967bf75364b)) +* :lipstick: improve logs ui ([ec6ecb2](https://github.com/cloud-pi-native/console/commit/ec6ecb283f0c7c37589c02e194f1b101e9dc10c2)) +* :zap: manage hook concurrency execution ([6af135f](https://github.com/cloud-pi-native/console/commit/6af135f0548d232ef5bd190375057da0968d8f94)) + +## [7.0.1](https://github.com/cloud-pi-native/console/compare/v7.0.0...v7.0.1) (2024-03-18) + + +### Bug Fixes + +* :bug: add a common error parser for logs ([2391439](https://github.com/cloud-pi-native/console/commit/2391439ef6147119863278bd69ac01797b368f76)) +* :bug: add a common error parser for logs ([b6f28d7](https://github.com/cloud-pi-native/console/commit/b6f28d738c03c3c023d16cf658de8cdc384aa6df)) +* :bug: correctly handle external plugins init ([91e5d7f](https://github.com/cloud-pi-native/console/commit/91e5d7f27f90c1e249ed9e295b05400f06565c2d)) +* :green_heart: fix ci-cd build and release ([a036562](https://github.com/cloud-pi-native/console/commit/a036562f005ba0550c4bf25248227aee02357371)) + +## [7.0.0](https://github.com/cloud-pi-native/console/compare/console-v6.5.1...console-v7.0.0) (2024-03-13) + + +### ⚠ BREAKING CHANGES + +* :art: split plugins +* :boom: environment management + +### Features + +* :alien: synch controllers with plugin calls ([d0a271d](https://github.com/cloud-pi-native/console/commit/d0a271d188840d8682b6abe412ee8da4db7992b1)) +* :boom: environment management ([b4f3793](https://github.com/cloud-pi-native/console/commit/b4f379315803aee1264192c16851af049fa661ee)) +* :children_crossing: add confirm box for updating an organization ([5c3cd20](https://github.com/cloud-pi-native/console/commit/5c3cd207221a22ed5d93bdf8638b23c9849897f6)) +* :children_crossing: add default branch to sync in mirror pipeline ([45dbc01](https://github.com/cloud-pi-native/console/commit/45dbc01c4a95d04a201b2c5e198e3b192e1e5fe2)) +* :children_crossing: add filtering on admin projects view ([dc5412c](https://github.com/cloud-pi-native/console/commit/dc5412c1a80ce84cd563376a0b7f72da7926a858)) +* :children_crossing: better ux for forms in tiles ([da68243](https://github.com/cloud-pi-native/console/commit/da68243a91865732db46b9ae610a82eeec5c9bf2)) +* :children_crossing: improve home page and update doc link ([038d982](https://github.com/cloud-pi-native/console/commit/038d982ead7bbfa70debdf902d5c46e8518c1e60)) +* :children_crossing: inform users that clipboard works only with https ([909c3b6](https://github.com/cloud-pi-native/console/commit/909c3b672bcae1401d2133c8fff94cdca91f61bc)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :iphone: more responsive sideMenu and permissionForm ([c840306](https://github.com/cloud-pi-native/console/commit/c8403067224b5ad90e34b8233a1830ceeec8b715)) +* :loud_sound: add payload and full hook response in validation hooks ([adabb02](https://github.com/cloud-pi-native/console/commit/adabb02fd0b8028b1fcdf1113744234a925d72ce)) +* :loud_sound: add reqId in db logs ([2b7f22b](https://github.com/cloud-pi-native/console/commit/2b7f22b7e24748dc37c8623bbda3e914ed7cae00)) +* :necktie: do not fail and lock project for membership reasons ([91038d5](https://github.com/cloud-pi-native/console/commit/91038d5ed3533e4d4eacb8eac3539f29a60ab091)) +* :passport_control: update harbor project member role ([4419dd5](https://github.com/cloud-pi-native/console/commit/4419dd5f395f79a9eb99e8c0f305a7433ad84f7e)) +* :safety_vest: update password character support ([c82f35f](https://github.com/cloud-pi-native/console/commit/c82f35fb7f20ad06c0a63eb518407d17ddb88b51)) +* :sparkles: add backoffice for admins ([fadfdfc](https://github.com/cloud-pi-native/console/commit/fadfdfc43c427261adc3e31dbb1907d6dcefc5cb)) +* :sparkles: add infos key for cluster ([25ab8b8](https://github.com/cloud-pi-native/console/commit/25ab8b8d821c37f4491bd8fea075bdc7fbfcfc93)) +* :sparkles: add or remove a team member via keycloak ([0563d20](https://github.com/cloud-pi-native/console/commit/0563d20ca93a25decc7dbb5d945e2b738b0d575a)) +* :sparkles: add project environments to hook payload ([7aa8117](https://github.com/cloud-pi-native/console/commit/7aa81179ac95afdddf711e1a0a4b3c3a45a35c5a)) +* :sparkles: add quota and stage management for admin ([80ce642](https://github.com/cloud-pi-native/console/commit/80ce642b69a5eaaf1b53c7d884b70889c4e71d9d)) +* :sparkles: add quotas selection for environment ([bb97f5f](https://github.com/cloud-pi-native/console/commit/bb97f5f17c5e7b497eccb655a6eb5da692f7df73)) +* :sparkles: add stage in setPermission payload ([e920805](https://github.com/cloud-pi-native/console/commit/e920805a3181ae98bf98b774d19e02d1e8a2139c)) +* :sparkles: add stage management for admin ([e133853](https://github.com/cloud-pi-native/console/commit/e13385347610014a4edcc0b2ff12252386328cb8)) +* :sparkles: add tlsServerName input in cluster form ([4bdc01e](https://github.com/cloud-pi-native/console/commit/4bdc01ef9dd5b2041a34491e46b9759e5c6652f0)) +* :sparkles: better integration of sonarqube ([c0a0517](https://github.com/cloud-pi-native/console/commit/c0a05173a158b74d752aafaccdff58fc3b29c382)) +* :sparkles: delete cluster if no environment suscribed ([8edc50a](https://github.com/cloud-pi-native/console/commit/8edc50a85f266f1135b340d6df39a48ea7ac746b)) +* :sparkles: disallow hyphen on project, environment and stage ([f9b05ee](https://github.com/cloud-pi-native/console/commit/f9b05eefee41f7d514d749a0ac3fa1c1d063f051)) +* :sparkles: display cluster infos to users on environment form ([4afdf34](https://github.com/cloud-pi-native/console/commit/4afdf34fc40df7789a00fe6d159ee73370b94cb9)) +* :sparkles: download projects data button for admins ([cb38e75](https://github.com/cloud-pi-native/console/commit/cb38e7573314ba0980c4161d1a77a9e83fddb236)) +* :sparkles: enable admin cluster update ([f81008e](https://github.com/cloud-pi-native/console/commit/f81008e97c25bc73466b6257bd4cb2dc0bde9163)) +* :sparkles: handle gitlab membership ([7ff68bd](https://github.com/cloud-pi-native/console/commit/7ff68bdf8f9a16562cfc00fbdb0c322b0f09943d)) +* :sparkles: lock and unlock projects according to their org status ([3b9444a](https://github.com/cloud-pi-native/console/commit/3b9444aeaa2fab76461946e9a4d00e8afe4a62f1)) +* :sparkles: retrieve user from kc if does not exist in db ([3d4461a](https://github.com/cloud-pi-native/console/commit/3d4461ae7d82e6f64a1644921893d89f41e9a2d9)) +* :sparkles: services secrets improvement ([40e3ddd](https://github.com/cloud-pi-native/console/commit/40e3ddd2acd7e000b03f5bfa5663533449d315bf)) +* :sparkles: to fn, more infos, multi tiles ([8c4de83](https://github.com/cloud-pi-native/console/commit/8c4de83cf3b4bc0e335292d437ddf7d22305ff93)) +* :speech_balloon: translate stage in front for better understanding ([5a66479](https://github.com/cloud-pi-native/console/commit/5a6647980319524098a72fcbd2be6affbda3f3d0)) +* :technologist: add images auto-scan to harbor settings ([507c6e6](https://github.com/cloud-pi-native/console/commit/507c6e6a541b19502084276db30cf834e586d955)) +* :technologist: add swagger for server api ([abe15d2](https://github.com/cloud-pi-native/console/commit/abe15d2ceb93039ecd795389156f8730e5327841)) + + +### Bug Fixes + +* :alien: add owner in archiveProject hookpayload ([5c99183](https://github.com/cloud-pi-native/console/commit/5c99183f03afe0c20980c9649ce37788b833a351)) +* :alien: change logo for svg ([aa8315b](https://github.com/cloud-pi-native/console/commit/aa8315b63617c8413a448499e2ab0e68c216faef)) +* :alien: fetch does not support proxy natively ([20d531d](https://github.com/cloud-pi-native/console/commit/20d531d622e6fe9e2869e13db0d205b44ad602ce)) +* :alien: sync controllers require bigger axios timeout ([5116bbd](https://github.com/cloud-pi-native/console/commit/5116bbdb8f49b73c920ac04421ca05ccabef902c)) +* :ambulance: bad logic register plugins ([049040e](https://github.com/cloud-pi-native/console/commit/049040eafda26214afbc2118cb56542b133feca5)) +* :ambulance: preserve status colum in project ([febfaff](https://github.com/cloud-pi-native/console/commit/febfaff702d6a55210723ce213d603ad059ceaed)) +* :ambulance: register cluster destination via kubeconfig ([0e8d310](https://github.com/cloud-pi-native/console/commit/0e8d3102267268c595d172808e30183dbe4b3aad)) +* :ambulance: send internalRepoName to plugins on updateRepository hook ([2bde004](https://github.com/cloud-pi-native/console/commit/2bde0045592f2898975633c0c6f959311234f7f3)) +* :art: can disble plugins by name, and some fixes ([f2bd397](https://github.com/cloud-pi-native/console/commit/f2bd397e0526d76d439ef8f38b5dcebdfbaa8ede)) +* :art: refactor of admin project types and refs ([be0684f](https://github.com/cloud-pi-native/console/commit/be0684f378696a24304d7abecd6b90c0738e1785)) +* :bento: update MultiSelector component ([9a44bb3](https://github.com/cloud-pi-native/console/commit/9a44bb35d8aa0da1aaf8f96a745c28f97f6ea53c)) +* :bug: add conditional chaining to avoid error in dsobadge ([56ad040](https://github.com/cloud-pi-native/console/commit/56ad0403242222f50ef8abe898b46c241d40b66d)) +* :bug: add missing await in gitlab createProjectMirror ([f1bf907](https://github.com/cloud-pi-native/console/commit/f1bf9072b88abec3f08654720f64571aa421db34)) +* :bug: change appproject policy ([e20b6c4](https://github.com/cloud-pi-native/console/commit/e20b6c431348b08bed7ea215a967c51d0e6187f0)) +* :bug: clusters related bugs ([c2ba63e](https://github.com/cloud-pi-native/console/commit/c2ba63e94d87bdf1741e9a51e4522c3f77b354de)) +* :bug: create appropriate folders for server image ([3027fef](https://github.com/cloud-pi-native/console/commit/3027fef08ebc8a58e2e705645febaf40af7314f5)) +* :bug: filter archived project from cluster form ([50913f5](https://github.com/cloud-pi-native/console/commit/50913f57def7d8e3e36f63a5876e476e64884828)) +* :bug: fix endless redirect on login if authenticated ([461c6bd](https://github.com/cloud-pi-native/console/commit/461c6bd385f44a551fe9af1027c6beca1e7fa9ec)) +* :bug: fix gitlab group deletion ([12a35d0](https://github.com/cloud-pi-native/console/commit/12a35d08619fbeb231d4e7931fb7fdb51ef63fde)) +* :bug: fix missing extensions ([4cd4b21](https://github.com/cloud-pi-native/console/commit/4cd4b212e88d30b447d5674cf157ce71862fe14a)) +* :bug: fix not updated argocd cluster and not using insecure in config ([69e7a9a](https://github.com/cloud-pi-native/console/commit/69e7a9ac7a584d76ce047b8330356ce2387218d7)) +* :bug: fix types, add void and handle it ([110cfed](https://github.com/cloud-pi-native/console/commit/110cfedfcb2a58757b5ad47f96f6323cbe034550)) +* :bug: import new shared package ([ff0b0db](https://github.com/cloud-pi-native/console/commit/ff0b0db9dcc96c10b409b51bd4180c03b5b01478)) +* :bug: launch e2e test in open mode without turbo ([d3497d1](https://github.com/cloud-pi-native/console/commit/d3497d13e3862d7701cbbd2da65cb7a571032eb2)) +* :bug: manage cancel emit on update repo form ([16df7b4](https://github.com/cloud-pi-native/console/commit/16df7b43d584fb1edfa5a01938356b04b785429b)) +* :bug: missed limits ([f2ba02d](https://github.com/cloud-pi-native/console/commit/f2ba02d41e923b0add154fc7cb5ae7d8a2c89370)) +* :bug: missing check in search group ([7fb65c1](https://github.com/cloud-pi-native/console/commit/7fb65c12754eea955c7ba942329cf5bfa612f8b7)) +* :bug: missing check nexus find user ([993dd2a](https://github.com/cloud-pi-native/console/commit/993dd2ab30687d0ea23000596f909b77b3be70e5)) +* :bug: missing check nexus find user ([e0f1674](https://github.com/cloud-pi-native/console/commit/e0f167424df8e0c01619a0da38bf3e5723014ee1)) +* :bug: now the admin can chosse skip tls api kube ([e09aa2a](https://github.com/cloud-pi-native/console/commit/e09aa2ac659e1439f59050c56256f530b9f8cae6)) +* :bug: optional paramater to call repeatFn ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: pin pnpm version in dockerfiles ([b494a2f](https://github.com/cloud-pi-native/console/commit/b494a2f0d190e3da81f9765dded76187333e61af)) +* :bug: pnpm lock ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: retrieve kc user bug fix ([aaf61c5](https://github.com/cloud-pi-native/console/commit/aaf61c51a288cd7998a5d3883cf467471b42c3cb)) +* :bug: run.sh js to ts reference ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: send cluster kubeconfig in hook paylaod ([e21c692](https://github.com/cloud-pi-native/console/commit/e21c6923d016bb59a69be457d5d4efc08243a771)) +* :bug: share loadingct display knowledge ([8520a5c](https://github.com/cloud-pi-native/console/commit/8520a5c827d05316e9dedc59dd90a92fb3f513d4)) +* :bug: should not set caData if skipTLSVerify is true ([ccd66f8](https://github.com/cloud-pi-native/console/commit/ccd66f83be75850ffe47e87ff405fcf9cf136e3c)) +* :bug: update compile cmd to build in plugins pkgs ([b9e4424](https://github.com/cloud-pi-native/console/commit/b9e44246c22238c3cdb383515816951ace4f65c2)) +* :bug: use argoCD regex for internalRepoName ([6c2ed3f](https://github.com/cloud-pi-native/console/commit/6c2ed3f682514b947e66547ce136d148b9e2d686)) +* :bug: various fixes around plugins and controllers ([6271c67](https://github.com/cloud-pi-native/console/commit/6271c67f3d8471ee6afc3409c5d753f7ade950f0)) +* :card_file_box: set all project status to failed for v6 release ([b689c35](https://github.com/cloud-pi-native/console/commit/b689c356f27e769b18aabab2f2510dd24354d95f)) +* :construction: fix configs ([ff77d7a](https://github.com/cloud-pi-native/console/commit/ff77d7ab10aecdf92643f9bcece9ef3360bc4c46)) +* :goal_net: add try catch in checkApi func ([baf38af](https://github.com/cloud-pi-native/console/commit/baf38af3f96fe227a934228d7875181e1407c8e9)) +* :goal_net: allow deleting environment if not found ([a2dc7da](https://github.com/cloud-pi-native/console/commit/a2dc7daf96942121e9291063569ff9c6612bd345)) +* :goal_net: throw error if catched in business ([1790ec2](https://github.com/cloud-pi-native/console/commit/1790ec274e965d5d62a0693937eec5ee765602e2)) +* :goal_net: validate schema before creating repo or env ([23535c3](https://github.com/cloud-pi-native/console/commit/23535c3e0c9923df0cc8adb5fb646dc2dd21fb7c)) +* :heavy_plus_sign: fix pnpm-lock file ([93aa64e](https://github.com/cloud-pi-native/console/commit/93aa64e146e11f30c8305f85d7ff03b696b0297b)) +* :label: add missing fastify keycloak adapter patch to improve types ([2d4ca1f](https://github.com/cloud-pi-native/console/commit/2d4ca1fc64c374cd06cef5b27842502db59a0c1c)) +* :lipstick: add icons ([ed1887b](https://github.com/cloud-pi-native/console/commit/ed1887baf6a65d0d58c6e399991a1419351c3883)) +* :lipstick: fix padding ([907d424](https://github.com/cloud-pi-native/console/commit/907d424a94f2dfc7e7110882438130bdb5dc4cd8)) +* :lipstick: restyle logs interface ([fc362cf](https://github.com/cloud-pi-native/console/commit/fc362cf461c65f6e4e8e7b6b2d92c83a41844bdf)) +* :lock: root token is not used in new repo credential when synced ([c92ea8b](https://github.com/cloud-pi-native/console/commit/c92ea8b5ef95097b07c2bbda435ce433f5bba53d)) +* :pushpin: pin axios version to avoid vite error ([88c6d30](https://github.com/cloud-pi-native/console/commit/88c6d30b5ab14cd9ca899fdf7cfcd17faa067903)) +* :rewind: fix bugs introduced during my vacations ([9ee467c](https://github.com/cloud-pi-native/console/commit/9ee467c69b590a4630dae19f42f2fc5c9321953e)) +* :safety_vest: validate user schema before trying to insert it in db ([ab8e6ef](https://github.com/cloud-pi-native/console/commit/ab8e6efdb00ec29c6a9a1e0f89c58ffbc999c966)) +* :technologist: change domains for dev and int ([0de00ab](https://github.com/cloud-pi-native/console/commit/0de00abf78543bc769f80be2f0e59795cfefefa5)) +* :technologist: fix docker volumes for integ mode ([80af072](https://github.com/cloud-pi-native/console/commit/80af072bfc0affa8ab0b415a3f6d63d17ceaf2c2)) +* :technologist: fix import kubeconfig in integration ([51af3dd](https://github.com/cloud-pi-native/console/commit/51af3dd16128a6d4a1e676921937005e295acfc9)) +* :test_tube: trying to fix ct test ([c67366e](https://github.com/cloud-pi-native/console/commit/c67366e0b319de7edd681b9ba9265091be5c9e32)) +* :test_tube: trying to optimize dependancies for failing modules ([ec97602](https://github.com/cloud-pi-native/console/commit/ec9760236a380efdcabc5bc0f0cb7e9b23c07adb)) +* :wrench: handle no values for disabledPlugins ([d0d46a3](https://github.com/cloud-pi-native/console/commit/d0d46a38570db6f626f5879da180829216826fa8)) + + +### Performance Improvements + +* :construction: skip failing test for merge ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) + + +### Reverts + +* :card_file_box: add missing default for projectStatus ([2263988](https://github.com/cloud-pi-native/console/commit/22639888442da48b097f936d0afb62652d262b02)) +* :card_file_box: add missing default for projectStatus ([5653165](https://github.com/cloud-pi-native/console/commit/565316504a1baa90d2c7c999c0ba6810151324f5)) +* :poop: comment harbor projet member role update ([63926ae](https://github.com/cloud-pi-native/console/commit/63926ae9c5a5fe6b9e6e8a2d0007f55d186b0f36)) + + +### Build System + +* :art: split plugins ([f4ac305](https://github.com/cloud-pi-native/console/commit/f4ac30587917b78d6d0f3e58cdda1b8a08123b48)) + +## [6.5.2](https://github.com/cloud-pi-native/console/compare/v6.5.1...v6.5.2) (2024-03-13) + + +### Bug Fixes + +* :bug: missing check in search group ([7fb65c1](https://github.com/cloud-pi-native/console/commit/7fb65c12754eea955c7ba942329cf5bfa612f8b7)) +* :bug: missing check nexus find user ([e0f1674](https://github.com/cloud-pi-native/console/commit/e0f167424df8e0c01619a0da38bf3e5723014ee1)) + +## [6.5.1](https://github.com/cloud-pi-native/console/compare/v6.5.0...v6.5.1) (2024-01-25) + + +### Bug Fixes + +* :bug: share loadingct display knowledge ([8520a5c](https://github.com/cloud-pi-native/console/commit/8520a5c827d05316e9dedc59dd90a92fb3f513d4)) + +## [6.5.0](https://github.com/cloud-pi-native/console/compare/v6.4.0...v6.5.0) (2024-01-25) + + +### Features + +* :children_crossing: better ux for forms in tiles ([da68243](https://github.com/cloud-pi-native/console/commit/da68243a91865732db46b9ae610a82eeec5c9bf2)) +* :loud_sound: add payload and full hook response in validation hooks ([adabb02](https://github.com/cloud-pi-native/console/commit/adabb02fd0b8028b1fcdf1113744234a925d72ce)) +* :loud_sound: add reqId in db logs ([2b7f22b](https://github.com/cloud-pi-native/console/commit/2b7f22b7e24748dc37c8623bbda3e914ed7cae00)) +* :sparkles: add project environments to hook payload ([7aa8117](https://github.com/cloud-pi-native/console/commit/7aa81179ac95afdddf711e1a0a4b3c3a45a35c5a)) +* :sparkles: disallow hyphen on project, environment and stage ([f9b05ee](https://github.com/cloud-pi-native/console/commit/f9b05eefee41f7d514d749a0ac3fa1c1d063f051)) +* :sparkles: download projects data button for admins ([cb38e75](https://github.com/cloud-pi-native/console/commit/cb38e7573314ba0980c4161d1a77a9e83fddb236)) +* :sparkles: to fn, more infos, multi tiles ([8c4de83](https://github.com/cloud-pi-native/console/commit/8c4de83cf3b4bc0e335292d437ddf7d22305ff93)) + + +### Bug Fixes + +* :goal_net: validate schema before creating repo or env ([23535c3](https://github.com/cloud-pi-native/console/commit/23535c3e0c9923df0cc8adb5fb646dc2dd21fb7c)) +* :pushpin: pin axios version to avoid vite error ([88c6d30](https://github.com/cloud-pi-native/console/commit/88c6d30b5ab14cd9ca899fdf7cfcd17faa067903)) + +## [6.4.0](https://github.com/cloud-pi-native/console/compare/v6.3.1...v6.4.0) (2023-12-20) + + +### Features + +* :children_crossing: add filtering on admin projects view ([dc5412c](https://github.com/cloud-pi-native/console/commit/dc5412c1a80ce84cd563376a0b7f72da7926a858)) +* :sparkles: display cluster infos to users on environment form ([4afdf34](https://github.com/cloud-pi-native/console/commit/4afdf34fc40df7789a00fe6d159ee73370b94cb9)) +* :speech_balloon: translate stage in front for better understanding ([5a66479](https://github.com/cloud-pi-native/console/commit/5a6647980319524098a72fcbd2be6affbda3f3d0)) + + +### Bug Fixes + +* :art: refactor of admin project types and refs ([be0684f](https://github.com/cloud-pi-native/console/commit/be0684f378696a24304d7abecd6b90c0738e1785)) + +## [6.3.1](https://github.com/cloud-pi-native/console/compare/v6.3.0...v6.3.1) (2023-12-12) + + +### Bug Fixes + +* :bug: missed limits ([f2ba02d](https://github.com/cloud-pi-native/console/commit/f2ba02d41e923b0add154fc7cb5ae7d8a2c89370)) + +## [6.3.0](https://github.com/cloud-pi-native/console/compare/v6.2.0...v6.3.0) (2023-12-11) + + +### Features + +* :sparkles: add backoffice for admins ([fadfdfc](https://github.com/cloud-pi-native/console/commit/fadfdfc43c427261adc3e31dbb1907d6dcefc5cb)) + + +### Bug Fixes + +* :bug: fix gitlab group deletion ([12a35d0](https://github.com/cloud-pi-native/console/commit/12a35d08619fbeb231d4e7931fb7fdb51ef63fde)) + +## [6.2.0](https://github.com/cloud-pi-native/console/compare/v6.1.0...v6.2.0) (2023-11-20) + + +### Features + +* :sparkles: delete cluster if no environment suscribed ([8edc50a](https://github.com/cloud-pi-native/console/commit/8edc50a85f266f1135b340d6df39a48ea7ac746b)) + + +### Bug Fixes + +* :alien: add owner in archiveProject hookpayload ([5c99183](https://github.com/cloud-pi-native/console/commit/5c99183f03afe0c20980c9649ce37788b833a351)) +* :bug: use argoCD regex for internalRepoName ([6c2ed3f](https://github.com/cloud-pi-native/console/commit/6c2ed3f682514b947e66547ce136d148b9e2d686)) + +## [6.1.0](https://github.com/cloud-pi-native/console/compare/v6.0.0...v6.1.0) (2023-11-13) + + +### Features + +* :sparkles: add quota and stage management for admin ([80ce642](https://github.com/cloud-pi-native/console/commit/80ce642b69a5eaaf1b53c7d884b70889c4e71d9d)) +* :sparkles: add stage management for admin ([e133853](https://github.com/cloud-pi-native/console/commit/e13385347610014a4edcc0b2ff12252386328cb8)) + + +### Bug Fixes + +* :bug: add missing await in gitlab createProjectMirror ([f1bf907](https://github.com/cloud-pi-native/console/commit/f1bf9072b88abec3f08654720f64571aa421db34)) +* :bug: fix endless redirect on login if authenticated ([461c6bd](https://github.com/cloud-pi-native/console/commit/461c6bd385f44a551fe9af1027c6beca1e7fa9ec)) +* :bug: should not set caData if skipTLSVerify is true ([ccd66f8](https://github.com/cloud-pi-native/console/commit/ccd66f83be75850ffe47e87ff405fcf9cf136e3c)) +* :lock: root token is not used in new repo credential when synced ([c92ea8b](https://github.com/cloud-pi-native/console/commit/c92ea8b5ef95097b07c2bbda435ce433f5bba53d)) + +## [6.0.0](https://github.com/cloud-pi-native/console/compare/v5.11.0...v6.0.0) (2023-10-27) + + +### ⚠ BREAKING CHANGES + +* :boom: environment management + +### Features + +* :boom: environment management ([b4f3793](https://github.com/cloud-pi-native/console/commit/b4f379315803aee1264192c16851af049fa661ee)) +* :sparkles: services secrets improvement ([40e3ddd](https://github.com/cloud-pi-native/console/commit/40e3ddd2acd7e000b03f5bfa5663533449d315bf)) + + +### Bug Fixes + +* :alien: change logo for svg ([aa8315b](https://github.com/cloud-pi-native/console/commit/aa8315b63617c8413a448499e2ab0e68c216faef)) +* :bug: add conditional chaining to avoid error in dsobadge ([56ad040](https://github.com/cloud-pi-native/console/commit/56ad0403242222f50ef8abe898b46c241d40b66d)) +* :bug: launch e2e test in open mode without turbo ([d3497d1](https://github.com/cloud-pi-native/console/commit/d3497d13e3862d7701cbbd2da65cb7a571032eb2)) +* :card_file_box: set all project status to failed for v6 release ([b689c35](https://github.com/cloud-pi-native/console/commit/b689c356f27e769b18aabab2f2510dd24354d95f)) + +## [5.11.0](https://github.com/cloud-pi-native/console/compare/v5.10.1...v5.11.0) (2023-10-13) + + +### Features + +* :children_crossing: add default branch to sync in mirror pipeline ([45dbc01](https://github.com/cloud-pi-native/console/commit/45dbc01c4a95d04a201b2c5e198e3b192e1e5fe2)) + + +### Bug Fixes + +* :goal_net: add try catch in checkApi func ([baf38af](https://github.com/cloud-pi-native/console/commit/baf38af3f96fe227a934228d7875181e1407c8e9)) + +## [5.10.1](https://github.com/cloud-pi-native/console/compare/v5.10.0...v5.10.1) (2023-10-10) + +### Bug Fixes + +* :ambulance: send internalRepoName to plugins on updateRepository hook ([2bde004](https://github.com/cloud-pi-native/console/commit/2bde0045592f2898975633c0c6f959311234f7f3)) + +## [5.10.0](https://github.com/cloud-pi-native/console/compare/v5.9.1...v5.10.0) (2023-10-03) + +### Features + +* :iphone: more responsive sideMenu and permissionForm ([c840306](https://github.com/cloud-pi-native/console/commit/c8403067224b5ad90e34b8233a1830ceeec8b715)) +* :sparkles: enable admin cluster update ([f81008e](https://github.com/cloud-pi-native/console/commit/f81008e97c25bc73466b6257bd4cb2dc0bde9163)) + +## [5.9.1](https://github.com/cloud-pi-native/console/compare/v5.9.0...v5.9.1) (2023-09-28) + +### Bug Fixes + +* :bug: fix not updated argocd cluster and not using insecure in config ([69e7a9a](https://github.com/cloud-pi-native/console/commit/69e7a9ac7a584d76ce047b8330356ce2387218d7)) + +## [5.9.0](https://github.com/cloud-pi-native/console/compare/v5.8.2...v5.9.0) (2023-09-25) + +### Features + +* :technologist: add swagger for server api ([abe15d2](https://github.com/cloud-pi-native/console/commit/abe15d2ceb93039ecd795389156f8730e5327841)) + +### Bug Fixes + +* :ambulance: register cluster destination via kubeconfig ([0e8d310](https://github.com/cloud-pi-native/console/commit/0e8d3102267268c595d172808e30183dbe4b3aad)) +* :safety_vest: validate user schema before trying to insert it in db ([ab8e6ef](https://github.com/cloud-pi-native/console/commit/ab8e6efdb00ec29c6a9a1e0f89c58ffbc999c966)) + +## [5.8.2](https://github.com/cloud-pi-native/console/compare/v5.8.1...v5.8.2) (2023-09-20) + +### Bug Fixes + +* :wrench: handle no values for disabledPlugins ([d0d46a3](https://github.com/cloud-pi-native/console/commit/d0d46a38570db6f626f5879da180829216826fa8)) + +## [5.8.1](https://github.com/cloud-pi-native/console/compare/v5.8.0...v5.8.1) (2023-09-18) + +### Bug Fixes + +* :ambulance: bad logic register plugins ([049040e](https://github.com/cloud-pi-native/console/commit/049040eafda26214afbc2118cb56542b133feca5)) + +## [5.8.0](https://github.com/cloud-pi-native/console/compare/v5.7.0...v5.8.0) (2023-09-18) + +### Features + +* :passport_control: update harbor project member role ([4419dd5](https://github.com/cloud-pi-native/console/commit/4419dd5f395f79a9eb99e8c0f305a7433ad84f7e)) +* :sparkles: add infos key for cluster ([25ab8b8](https://github.com/cloud-pi-native/console/commit/25ab8b8d821c37f4491bd8fea075bdc7fbfcfc93)) +* :sparkles: add tlsServerName input in cluster form ([4bdc01e](https://github.com/cloud-pi-native/console/commit/4bdc01ef9dd5b2041a34491e46b9759e5c6652f0)) +* :technologist: add images auto-scan to harbor settings ([507c6e6](https://github.com/cloud-pi-native/console/commit/507c6e6a541b19502084276db30cf834e586d955)) + +### Bug Fixes + +* :alien: fetch does not support proxy natively ([20d531d](https://github.com/cloud-pi-native/console/commit/20d531d622e6fe9e2869e13db0d205b44ad602ce)) +* :art: can disble plugins by name, and some fixes ([f2bd397](https://github.com/cloud-pi-native/console/commit/f2bd397e0526d76d439ef8f38b5dcebdfbaa8ede)) +* :bug: clusters related bugs ([c2ba63e](https://github.com/cloud-pi-native/console/commit/c2ba63e94d87bdf1741e9a51e4522c3f77b354de)) +* :bug: now the admin can chosse skip tls api kube ([e09aa2a](https://github.com/cloud-pi-native/console/commit/e09aa2ac659e1439f59050c56256f530b9f8cae6)) +* :goal_net: throw error if catched in business ([1790ec2](https://github.com/cloud-pi-native/console/commit/1790ec274e965d5d62a0693937eec5ee765602e2)) +* :heavy_plus_sign: fix pnpm-lock file ([93aa64e](https://github.com/cloud-pi-native/console/commit/93aa64e146e11f30c8305f85d7ff03b696b0297b)) + +### Reverts + +* :poop: comment harbor projet member role update ([63926ae](https://github.com/cloud-pi-native/console/commit/63926ae9c5a5fe6b9e6e8a2d0007f55d186b0f36)) + +## [5.7.0](https://github.com/cloud-pi-native/console/compare/v5.6.0...v5.7.0) (2023-09-07) + +### Features + +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :safety_vest: update password character support ([c82f35f](https://github.com/cloud-pi-native/console/commit/c82f35fb7f20ad06c0a63eb518407d17ddb88b51)) +* :sparkles: handle gitlab membership ([7ff68bd](https://github.com/cloud-pi-native/console/commit/7ff68bdf8f9a16562cfc00fbdb0c322b0f09943d)) + +### Bug Fixes + +* :bug: change appproject policy ([e20b6c4](https://github.com/cloud-pi-native/console/commit/e20b6c431348b08bed7ea215a967c51d0e6187f0)) +* :bug: import new shared package ([ff0b0db](https://github.com/cloud-pi-native/console/commit/ff0b0db9dcc96c10b409b51bd4180c03b5b01478)) + +### Reverts + +* :card_file_box: add missing default for projectStatus ([2263988](https://github.com/cloud-pi-native/console/commit/22639888442da48b097f936d0afb62652d262b02)) +* :card_file_box: add missing default for projectStatus ([5653165](https://github.com/cloud-pi-native/console/commit/565316504a1baa90d2c7c999c0ba6810151324f5)) + +## [5.6.0](https://github.com/cloud-pi-native/console/compare/v5.5.0...v5.6.0) (2023-08-28) + +### Features + +* :sparkles: better integration of sonarqube ([c0a0517](https://github.com/cloud-pi-native/console/commit/c0a05173a158b74d752aafaccdff58fc3b29c382)) + +### Bug Fixes + +* :ambulance: preserve status colum in project ([febfaff](https://github.com/cloud-pi-native/console/commit/febfaff702d6a55210723ce213d603ad059ceaed)) +* :bug: optional paramater to call repeatFn ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: pnpm lock ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: run.sh js to ts reference ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :lipstick: fix padding ([907d424](https://github.com/cloud-pi-native/console/commit/907d424a94f2dfc7e7110882438130bdb5dc4cd8)) +* :lipstick: restyle logs interface ([fc362cf](https://github.com/cloud-pi-native/console/commit/fc362cf461c65f6e4e8e7b6b2d92c83a41844bdf)) + +### Performance Improvements + +* :construction: skip failing test for merge ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) + +## [5.5.0](https://github.com/cloud-pi-native/console/compare/v5.4.0...v5.5.0) (2023-08-22) + +### Features + +* :children_crossing: inform users that clipboard works only with https ([909c3b6](https://github.com/cloud-pi-native/console/commit/909c3b672bcae1401d2133c8fff94cdca91f61bc)) +* :sparkles: retrieve user from kc if does not exist in db ([3d4461a](https://github.com/cloud-pi-native/console/commit/3d4461ae7d82e6f64a1644921893d89f41e9a2d9)) + +### Bug Fixes + +* :alien: sync controllers require bigger axios timeout ([5116bbd](https://github.com/cloud-pi-native/console/commit/5116bbdb8f49b73c920ac04421ca05ccabef902c)) +* :bento: update MultiSelector component ([9a44bb3](https://github.com/cloud-pi-native/console/commit/9a44bb35d8aa0da1aaf8f96a745c28f97f6ea53c)) +* :bug: filter archived project from cluster form ([50913f5](https://github.com/cloud-pi-native/console/commit/50913f57def7d8e3e36f63a5876e476e64884828)) +* :bug: fix types, add void and handle it ([110cfed](https://github.com/cloud-pi-native/console/commit/110cfedfcb2a58757b5ad47f96f6323cbe034550)) +* :bug: manage cancel emit on update repo form ([16df7b4](https://github.com/cloud-pi-native/console/commit/16df7b43d584fb1edfa5a01938356b04b785429b)) +* :bug: retrieve kc user bug fix ([aaf61c5](https://github.com/cloud-pi-native/console/commit/aaf61c51a288cd7998a5d3883cf467471b42c3cb)) +* :bug: various fixes around plugins and controllers ([6271c67](https://github.com/cloud-pi-native/console/commit/6271c67f3d8471ee6afc3409c5d753f7ade950f0)) +* :goal_net: allow deleting environment if not found ([a2dc7da](https://github.com/cloud-pi-native/console/commit/a2dc7daf96942121e9291063569ff9c6612bd345)) +* :lipstick: add icons ([ed1887b](https://github.com/cloud-pi-native/console/commit/ed1887baf6a65d0d58c6e399991a1419351c3883)) +* :rewind: fix bugs introduced during my vacations ([9ee467c](https://github.com/cloud-pi-native/console/commit/9ee467c69b590a4630dae19f42f2fc5c9321953e)) +* :technologist: fix import kubeconfig in integration ([51af3dd](https://github.com/cloud-pi-native/console/commit/51af3dd16128a6d4a1e676921937005e295acfc9)) + +## [5.4.0](https://github.com/cloud-pi-native/console/compare/v5.3.0...v5.4.0) (2023-08-02) + +### Features + +* :children_crossing: improve home page and update doc link ([038d982](https://github.com/cloud-pi-native/console/commit/038d982ead7bbfa70debdf902d5c46e8518c1e60)) +* :sparkles: add or remove a team member via keycloak ([0563d20](https://github.com/cloud-pi-native/console/commit/0563d20ca93a25decc7dbb5d945e2b738b0d575a)) + +### Bug Fixes + +* :technologist: change domains for dev and int ([0de00ab](https://github.com/cloud-pi-native/console/commit/0de00abf78543bc769f80be2f0e59795cfefefa5)) + +## [5.3.0](https://github.com/cloud-pi-native/console/compare/v5.2.1...v5.3.0) (2023-08-01) + +### Features + +* :alien: synch controllers with plugin calls ([d0a271d](https://github.com/cloud-pi-native/console/commit/d0a271d188840d8682b6abe412ee8da4db7992b1)) +* :children_crossing: add confirm box for updating an organization ([5c3cd20](https://github.com/cloud-pi-native/console/commit/5c3cd207221a22ed5d93bdf8638b23c9849897f6)) +* :sparkles: lock and unlock projects according to their org status ([3b9444a](https://github.com/cloud-pi-native/console/commit/3b9444aeaa2fab76461946e9a4d00e8afe4a62f1)) + +### Bug Fixes + +* :bug: create appropriate folders for server image ([3027fef](https://github.com/cloud-pi-native/console/commit/3027fef08ebc8a58e2e705645febaf40af7314f5)) +* :bug: fix missing extensions ([4cd4b21](https://github.com/cloud-pi-native/console/commit/4cd4b212e88d30b447d5674cf157ce71862fe14a)) +* :bug: pin pnpm version in dockerfiles ([b494a2f](https://github.com/cloud-pi-native/console/commit/b494a2f0d190e3da81f9765dded76187333e61af)) + +## [5.2.1](https://github.com/cloud-pi-native/console/compare/v5.2.0...v5.2.1) (2023-07-21) + +### Bug Fixes + +* :adhesive_bandage: update kaniko stage name in gitlab ci ([7a71712](https://github.com/cloud-pi-native/console/commit/7a71712075d7aed5aeeb00adb1d2ec51b6d717ab)) + +## [5.2.0](https://github.com/cloud-pi-native/console/compare/v5.1.0...v5.2.0) (2023-07-20) + +### Features + +* :children_crossing: improve gitlab mirroring by using a single mirror repo ([914bbe4](https://github.com/cloud-pi-native/console/commit/914bbe4546a3e876a6cd8847523f18e95d688d44)) +* :sparkles: add id in admin user table ([d1ebcea](https://github.com/cloud-pi-native/console/commit/d1ebcea1768993b6ab31e5f45a6642ac3577cfc5)) + +### Bug Fixes + +* :bug: add missing data to add argo cluster ([fc53f54](https://github.com/cloud-pi-native/console/commit/fc53f54d8f2d211857aa82c43589b9ee26e214b8)) +* :bug: delete kube resources when deleting a project ([5e8cd51](https://github.com/cloud-pi-native/console/commit/5e8cd514edc8a0eb4f5d49208cb17b6da82f4fd7)) +* :bug: fix argo plugin ([b1dc1fb](https://github.com/cloud-pi-native/console/commit/b1dc1fb60ba89b534c3e3cd357b89a4e08b74838)) + +## [5.1.0](https://github.com/cloud-pi-native/console/compare/v5.0.0...v5.1.0) (2023-07-18) + +### Features + +* :alien: add project infos for canel plugin ([a4516a1](https://github.com/cloud-pi-native/console/commit/a4516a14b2439dd3a74540168aee6daa586c7308)) +* :alien: add updateProject hook for description update for example ([7e97505](https://github.com/cloud-pi-native/console/commit/7e97505b5a75c3f6c680d5b1bb6f18a9e45b87b3)) + +### Bug Fixes + +* :bug: fix projectLimit for gitlab plugin ([13fca89](https://github.com/cloud-pi-native/console/commit/13fca89f37a16b980dce7f16e87151510d6f0b63)) +* :bug: missing await before plugins hook and logs recording on archiveProject ([e221341](https://github.com/cloud-pi-native/console/commit/e221341a7db27d67bfa40e4231d47de1e5e8648f)) +* :bug: missing await before plugins hook and logs recording on archiveProject ([2515c62](https://github.com/cloud-pi-native/console/commit/2515c624afd1d89718a98085c5e41c1c996157b8)) + +## [5.0.0](https://github.com/cloud-pi-native/console/compare/v4.1.0...v5.0.0) (2023-07-13) + +### ⚠ BREAKING CHANGES + +* :boom: migrate from sequelize to prisma + +### Features + +* :children_crossing: add organization to differenciate project ([5814d7e](https://github.com/cloud-pi-native/console/commit/5814d7e00b5a6a335a16059b62f7af456d3b4a58)) +* :sparkles: allow updating repo ([27b59cf](https://github.com/cloud-pi-native/console/commit/27b59cfc5cacdf0e96b0fd22b090ce4ab038792d)) +* :sparkles: always show ci form ([753d64b](https://github.com/cloud-pi-native/console/commit/753d64bc011701dcc9297097b1a0aec87cef6c03)) +* :tada: introduce multi-cluster deployment ([0e6c065](https://github.com/cloud-pi-native/console/commit/0e6c065d421c117050e00944ca68114414a05dc7)) +* :technologist: add config for codespace ([1ff7996](https://github.com/cloud-pi-native/console/commit/1ff79966454be7961ba80049be65815cfce1d4cf)) + +### Bug Fixes + +* :alien: change gitlab api calls ([f04dc87](https://github.com/cloud-pi-native/console/commit/f04dc87208ca19ee2b06ff2c35535e51a72f84cd)) +* :art: finish pr ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :art: quote fixes ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :bug: add module on shared ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :bug: fix imports, change dependencies ([41f427f](https://github.com/cloud-pi-native/console/commit/41f427fe17daef7ef1b3c330ec5635338edfda59)) +* :bug: fix logs pagination bug ([7f3a990](https://github.com/cloud-pi-native/console/commit/7f3a99085f701d02367fab04f194dc0c0e85356d)) +* :bug: forgot await in argo plugin ([f4504d4](https://github.com/cloud-pi-native/console/commit/f4504d4b1ccc999274c3267508c943f23fd8690b)) +* :bug: retrieve also inactive orgs on admin project list page ([4b8977e](https://github.com/cloud-pi-native/console/commit/4b8977e01214295f3090ab16a92bde4b7d7a64f9)) +* :construction_worker: fix CI: tsconfig is needed ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :construction: in progress ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :lipstick: more contrasted icon color for archived project ([790d276](https://github.com/cloud-pi-native/console/commit/790d2761d3f77756144172e3ccd80bcb0df4572d)) + +### Code Refactoring + +* :boom: migrate from sequelize to prisma ([37b065d](https://github.com/cloud-pi-native/console/commit/37b065d6cff844dfd1b9d2276831eddefa64a71e)) + +## [4.1.0](https://github.com/cloud-pi-native/console/compare/v4.0.0...v4.1.0) (2023-06-12) + +### Features + +* :sparkles: add admin log page ([41058ee](https://github.com/cloud-pi-native/console/commit/41058ee51899d4ec351ae7adad10eb3fefa99f8b)) + +### Bug Fixes + +* :bug: harbor, fix remove repos from unknown project ([8a18381](https://github.com/cloud-pi-native/console/commit/8a18381c2197634d90649a8520dd068a59e5369c)) +* :bug: test if external dir exists ([3276400](https://github.com/cloud-pi-native/console/commit/3276400a00b3435eb87e225e0e8deadd5a311b0d)) +* :zap: gitlab, stabilize user search and lifecycle ([179c912](https://github.com/cloud-pi-native/console/commit/179c91269ecf04ff6a489a5d92f490b940446e70)) + +## [4.0.0](https://github.com/dnum-mi/dso-console/compare/v3.4.1...v4.0.0) (2023-06-08) + +### ⚠ BREAKING CHANGES + +* :boom: clarify business logic on status and locking + +### Features + +* :alien: add hook to synchronize organizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :alien: add hook to synchronize organizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :alien: add keys to source organizations ([626c031](https://github.com/dnum-mi/dso-console/commit/626c03135571199c3a1f1e72e19132bc651288a3)) +* :boom: if status: failed (aka plugins failed), project remains locked ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) +* :children_crossing: improve user experience on dso-console ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :children_crossing: improve ux for danger zone ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :children_crossing: improve UX on permission form ([fb72076](https://github.com/dnum-mi/dso-console/commit/fb72076f16f79df456392fc7f9a7129ab1655832)) +* :children_crossing: redirect to dashboard when selecting a project ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :necktie: allow only one plugin to register on hook fetchOrganizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :sparkles: add project admin view ([b49ff96](https://github.com/dnum-mi/dso-console/commit/b49ff967853e31ca6cbcd8cd2ff78b299023788e)) + +### Bug Fixes + +* :art: temp fix labels, add check, refacto get ns ([a43f50c](https://github.com/dnum-mi/dso-console/commit/a43f50cb93b2453f025b9d4284f2b65b6134161a)) +* :bug: fix payload, fix check step ([ff76b27](https://github.com/dnum-mi/dso-console/commit/ff76b2750352cbe7b2d49ff5fb6e4d3e424b328f)) +* :bug: remove harbor repos before projects ([7239329](https://github.com/dnum-mi/dso-console/commit/72393297dcfaf44938e2814cccfd8366613ba75c)) +* :necktie: clarify business logic on status and locking ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) + +### Code Refactoring + +* :boom: clarify business logic on status and locking ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) + +## [3.4.1](https://github.com/dnum-mi/dso-console/compare/v3.4.0...v3.4.1) (2023-05-22) + +### Bug Fixes + +* :art: change ns labels ([ae14d67](https://github.com/dnum-mi/dso-console/commit/ae14d6736a3b3276b3406562709cf77e3aab0668)) +* :construction: hooks payload send full owner object, bug getSingleOwnerByProjectId ([0e74895](https://github.com/dnum-mi/dso-console/commit/0e74895695b42d4e13a645ef4005afbffec59b64)) + +## [3.4.0](https://github.com/dnum-mi/dso-console/compare/v3.3.1...v3.4.0) (2023-05-15) + +### Features + +* :alien: owner name is required to create Canel user ([e4aa5e9](https://github.com/dnum-mi/dso-console/commit/e4aa5e9662fb0494ef43b7f52140c9c11702613f)) + +## [3.3.1](https://github.com/dnum-mi/dso-console/compare/v3.3.0...v3.3.1) (2023-05-11) + +### Bug Fixes + +* :ambulance: keycloak now sends group path ([913f402](https://github.com/dnum-mi/dso-console/commit/913f40295f7f0eab17964e72e38e61200523456e)) + +## [3.3.0](https://github.com/dnum-mi/dso-console/compare/v3.2.3...v3.3.0) (2023-05-11) + +### Features + +* :sparkles: add organization page for admins ([5e6b7fa](https://github.com/dnum-mi/dso-console/commit/5e6b7fa252285b432ef5869aedf8881b642eff53)) + +### Bug Fixes + +* :ambulance: handle unocss migration for media queries ([8fdf445](https://github.com/dnum-mi/dso-console/commit/8fdf4455a34a274c3723685928549bb30bb04762)) +* :sparkles: set ingress secretName ([8be3479](https://github.com/dnum-mi/dso-console/commit/8be34796b15219a6b4f77585662b21d8d04c76c8)) + +## [3.2.3](https://github.com/dnum-mi/dso-console/compare/v3.2.2...v3.2.3) (2023-05-09) + +### Bug Fixes + +* :ambulance: keycloak group id, typo ([1aedf77](https://github.com/dnum-mi/dso-console/commit/1aedf77b4a74698bb1d52cbfe6863bd249a7eadb)) + +## [3.2.2](https://github.com/dnum-mi/dso-console/compare/v3.2.1...v3.2.2) (2023-05-09) + +### Bug Fixes + +* :ambulance: missing ownerid at env init ([14696ff](https://github.com/dnum-mi/dso-console/commit/14696ffc24a2bfe5345b135e99f48502ba0d2fae)) + +## [3.2.1](https://github.com/dnum-mi/dso-console/compare/v3.2.0...v3.2.1) (2023-05-09) + +### Bug Fixes + +* :ambulance: habor missing slash on oidc group ([e85b903](https://github.com/dnum-mi/dso-console/commit/e85b9031f77c7feba05f04b9444c5d6862342246)) +* :ambulance: keycloak do not add owner in group env ([f20b3da](https://github.com/dnum-mi/dso-console/commit/f20b3da4a8666d3b2912c3b7147819818a96c8ed)) + +## [3.2.0](https://github.com/dnum-mi/dso-console/compare/v3.1.0...v3.2.0) (2023-05-05) + +### Features + +* :lipstick: better ui for permission form ([56e0739](https://github.com/dnum-mi/dso-console/commit/56e0739d935d96c4eedd55807151f72f8fafcb6b)) +* :sparkles: add description field for a project ([e9d65ed](https://github.com/dnum-mi/dso-console/commit/e9d65eda3084ac46b48f8e7a4a7e78324cf8d351)) +* :sparkles: add download link for includes.zip ([0e067d1](https://github.com/dnum-mi/dso-console/commit/0e067d1965e39084d3f21191e27aa3b82948df17)) +* :sparkles: introduce users admin view ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) + +### Bug Fixes + +* :alien: refacto plugins to check services before save in database ([c58f3da](https://github.com/dnum-mi/dso-console/commit/c58f3dab057fd39f62e20ca3951278a87f6cfad0)) +* :bug: forget default value for log table ([ade449b](https://github.com/dnum-mi/dso-console/commit/ade449b0a834f850d4f64c94cfb489174b946463)) +* :construction: fix e2e test admin (temporary) ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) +* :construction: use ingress ([621d5ee](https://github.com/dnum-mi/dso-console/commit/621d5eee80adfb5f14459b93c7f51b431dd61c31)) +* :fire: remove routes ([65dabcb](https://github.com/dnum-mi/dso-console/commit/65dabcb28f8ede298975a2cec3e7ff5623ee4d8c)) +* :loud_sound: log database sync error ([3e73dac](https://github.com/dnum-mi/dso-console/commit/3e73dac0fc470051c5aaf410ebbbf28cafc9ee0e)) +* :wrench: update helm values ([16d3223](https://github.com/dnum-mi/dso-console/commit/16d32233c84001b8aa206a76b8ac3b772875cb64)) + +### Performance Improvements + +* :art: admin users no custom exception ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) + +## [3.1.0](https://github.com/dnum-mi/dso-console/compare/v3.0.0...v3.1.0) (2023-04-18) + +### Features + +* :children_crossing: add deployment infos below environment tiles ([992b34e](https://github.com/dnum-mi/dso-console/commit/992b34e070401ae330cab239d73bf27b56ba516c)) +* :sparkles: add custom ca to server ([8583577](https://github.com/dnum-mi/dso-console/commit/85835775d1bf21dfadffe5094dc1a513d6782525)) +* :sparkles: introduce environment management page ([3e77a67](https://github.com/dnum-mi/dso-console/commit/3e77a679446853b981d9bf98f1bd62795ce5cf1d)) + +### Bug Fixes + +* :bug: fix git output url ([e3402b4](https://github.com/dnum-mi/dso-console/commit/e3402b44dddbd71805293305df181321af04dafe)) +* :bug: forget delete mirror repository secret ([f00aac7](https://github.com/dnum-mi/dso-console/commit/f00aac7f6315a1f27ee05ebaa6863feb466ac654)) + +## [3.0.0](https://github.com/dnum-mi/dso-console/compare/v2.3.0...v3.0.0) (2023-04-14) + +### ⚠ BREAKING CHANGES + +* **api:** :building_construction: replace ansible with plugins + +### Features + +* :art: load kubeconfig only if if a path is set ([a9d9338](https://github.com/dnum-mi/dso-console/commit/a9d93387cc7ff407ea71edb848e664fb651fc09a)) +* :bento: add Marianne police ([c5af057](https://github.com/dnum-mi/dso-console/commit/c5af05774332d97fc5b669aeaa60f9fca6075998)) +* :boom: change helm deployment ([c1368c6](https://github.com/dnum-mi/dso-console/commit/c1368c69de93357c66709110f3c57d22a62af8ad)) +* :lipstick: add dark theme ([cfc16d8](https://github.com/dnum-mi/dso-console/commit/cfc16d81391c28447d02297a9ebdf22c1f142c6f)) +* :lipstick: add dark theme ([cfc16d8](https://github.com/dnum-mi/dso-console/commit/cfc16d81391c28447d02297a9ebdf22c1f142c6f)) +* :loud_sound: add a short description for database logging ([4146f5a](https://github.com/dnum-mi/dso-console/commit/4146f5ae7048fa280a654b5471a32968af159b2f)) +* :technologist: do not drop database in integration dev ([521d503](https://github.com/dnum-mi/dso-console/commit/521d503cdf5e2c24f9e148bc6435db485e1252e7)) + +### Bug Fixes + +* :ambulance: add kaniko proxy ([1564b6f](https://github.com/dnum-mi/dso-console/commit/1564b6f7a696c13846799b38557b1003d2cb87c8)) +* :ambulance: stabilize gitlab search and vault secret name ([044da15](https://github.com/dnum-mi/dso-console/commit/044da1511ff8a5f16136807c70194d662c08db26)) +* :bug: display hyphen only if service message exists ([ba5ed65](https://github.com/dnum-mi/dso-console/commit/ba5ed659954235f8b00e78df24029f1186d23b7a)) +* :bug: fix controllers logic ([251fba4](https://github.com/dnum-mi/dso-console/commit/251fba44ebd93e645487da63710add4d9382521d)) +* :bug: use proxy on services healthcheck ([85eef14](https://github.com/dnum-mi/dso-console/commit/85eef144f8c87760c61203de7944229af2828047)) +* :lipstick: make snackbar bakground -color adaptive to theme variations ([851a004](https://github.com/dnum-mi/dso-console/commit/851a004837eb901d02bbf65e20697e457ecde6e2)) +* :wrench: rename kubeconfig var ([61fdfbf](https://github.com/dnum-mi/dso-console/commit/61fdfbf3bb47b51225d2cf3c00b167c46ff1bedf)) + +### Code Refactoring + +* **api:** :building_construction: replace ansible with plugins ([59cd5fe](https://github.com/dnum-mi/dso-console/commit/59cd5fef0799ff3dd5df7ec42ef879647a6de78a)) + +## [2.3.0](https://github.com/dnum-mi/dso-console/compare/v2.2.1...v2.3.0) (2023-03-22) + +### Features + +* :alien: fetch services to get their statuses ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) +* :art: review tobi ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) +* :monocle_face: sort projects by ASC name ([ccb7fbb](https://github.com/dnum-mi/dso-console/commit/ccb7fbb5066da6ebc1776a893c86d8d80a292856)) +* :sparkles: build project services url in api ([2173f8a](https://github.com/dnum-mi/dso-console/commit/2173f8abade9848c83cc68e4a68da02982d4a887)) +* :sparkles: new page dedicated to services ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) + +### Bug Fixes + +* :bug: fix container env ([f9fd457](https://github.com/dnum-mi/dso-console/commit/f9fd45715d94789e02d8c88991b02fe9831e4460)) + +## [2.2.1](https://github.com/dnum-mi/dso-console/compare/v2.2.0...v2.2.1) (2023-03-14) + +### Bug Fixes + +* :bug: token should authorize "-" ([277f292](https://github.com/dnum-mi/dso-console/commit/277f29232fd5713c0a6e5c42f29ba257b71cdb75)) + +### Performance Improvements + +* :zap: no need to call /account endpoint ([3b278f7](https://github.com/dnum-mi/dso-console/commit/3b278f7e8a2fc22cf3a9aec8b1319c83a934a7f6)) + +## [2.2.0](https://github.com/dnum-mi/dso-console/compare/v2.1.1...v2.2.0) (2023-03-13) + +### Features + +* :sparkles: display project and repositories statuses in dashboard and repo pages ([ba27167](https://github.com/dnum-mi/dso-console/commit/ba2716798e8d39e5470b6fe0e97acc3a54480612)) + +### Bug Fixes + +* :bug: add joi schema validation in repo form ([8524e50](https://github.com/dnum-mi/dso-console/commit/8524e50cca3a97965b6cc2eb104620aec2d3e5dd)) +* :bug: do nothing when tile is disabled ([e09043c](https://github.com/dnum-mi/dso-console/commit/e09043c34a21315abd8c0c3d1570488057a3d1a6)) +* :bug: wrong key for project archived ([8e6b27b](https://github.com/dnum-mi/dso-console/commit/8e6b27b576e7ad28bde1b8a00b84acf366531993)) + +### Performance Improvements + +* :fire: no external token in database, no crypto needed ([0a3fb8c](https://github.com/dnum-mi/dso-console/commit/0a3fb8c5403215e1ae3d24fb8ea7cfcf43a8060f)) + +## [2.1.1](https://github.com/dnum-mi/dso-console/compare/v2.1.0...v2.1.1) (2023-02-27) + +### Bug Fixes + +* :globe_with_meridians: translate error messages in french ([a2827c3](https://github.com/dnum-mi/dso-console/commit/a2827c3746aad94293de183725bd2e77ac34f525)) + +## [2.1.0](https://github.com/dnum-mi/dso-console/compare/v2.0.0...v2.1.0) (2023-02-27) + +### Features + +* :children_crossing: redirect to services when selecting a project ([3849dad](https://github.com/dnum-mi/dso-console/commit/3849dad734e07ae0250962c8a71c1eb3470614c2)) +* :lipstick: whoami in sidemenu rather than snackbar ([70fcbf5](https://github.com/dnum-mi/dso-console/commit/70fcbf537fa32a04538d10673a1d0b4246fddd65)) +* :memo: ajout d'un schéma d'architecture ([72f61fb](https://github.com/dnum-mi/dso-console/commit/72f61fbf6060455f56d578003e927196c0292a50)) +* :necktie: add constraint for project name length ([f2d3975](https://github.com/dnum-mi/dso-console/commit/f2d3975f8cf5124142edc38a64f219ac9a223985)) +* :sparkles: add snackbar to handle errors ([0b5fa71](https://github.com/dnum-mi/dso-console/commit/0b5fa71b5d993fb7529f4fa2b74d78dacb0b33d3)) +* add active column on organization table, and client get only active: true ([de7b9d0](https://github.com/dnum-mi/dso-console/commit/de7b9d01bf878ee1cf298736e83410905390a5a3)) + +### Bug Fixes + +* :ambulance: bad owner w/ new queries ([5153b62](https://github.com/dnum-mi/dso-console/commit/5153b621c0707a438f191dd2b06d8c801804f014)) +* :ambulance: log more of ansible output ([e2b10d6](https://github.com/dnum-mi/dso-console/commit/e2b10d6875605c836606cb5ba79c963787d4c177)) +* :bug: error in ansible route ([a470677](https://github.com/dnum-mi/dso-console/commit/a470677a5d5c3b2b262434adc3f399f948caccd2)) +* :bug: error in ansible route ([1565411](https://github.com/dnum-mi/dso-console/commit/1565411524a2c17d0e7de79e5965562053a2024b)) +* :goal_net: catch fetch errors and send it to front ([fcca40b](https://github.com/dnum-mi/dso-console/commit/fcca40b2efbc5d38ae8fe0001043671756ebda34)) +* :goal_net: optional chaining to avoid errors on selected project ([6750077](https://github.com/dnum-mi/dso-console/commit/67500771939efb2e2329d8ab94407588aa76fa5b)) +* :goal_net: return if user does not have project ([f2a3d74](https://github.com/dnum-mi/dso-console/commit/f2a3d7449abc729b5506aaaf43a163e600c51bcb)) +* :lipstick: enforce ui on disabled dsfrtiles ([4de5d25](https://github.com/dnum-mi/dso-console/commit/4de5d257d847c4ecff137face3cbb276d543b779)) +* :truck: rename generated gitlab ci file ([d3e637e](https://github.com/dnum-mi/dso-console/commit/d3e637e04ef008c5316b7e720a9fc905c0ab0000)) +* :wrench: update postgres image ([16e5d55](https://github.com/dnum-mi/dso-console/commit/16e5d557c52e149def7f7c216371ba23fc721bbf)) + +### Reverts + +* :rewind: rebase error ([728a704](https://github.com/dnum-mi/dso-console/commit/728a704522a3f5cfcc2dc5879671a83a051a82c6)) + +## [2.0.0](https://github.com/dnum-mi/dso-console/compare/v1.0.0...v2.0.0) (2023-02-22) + +### ⚠ BREAKING CHANGES + +* **database:** :card_file_box: new database model, queries, + +### Features + +* :art: easier generation of files object ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :art: specific routes, add validation schema ([69645b9](https://github.com/dnum-mi/dso-console/commit/69645b9b392f8f56b8d3606780dfa63098fa5240)) +* :art: use only one get route ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: mob review ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :card_file_box: add an association table for users and projects ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: add destroy functions for db ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: wip : working with @ArnaudTa on liveshare ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: add delete repo button ([ad64daf](https://github.com/dnum-mi/dso-console/commit/ad64dafce222ea572e6169c502aaed08562a70e7)) +* :construction: Add download and copy, missing format ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: add status and locked for project ([1bb55b8](https://github.com/dnum-mi/dso-console/commit/1bb55b8758eecced9204aa7b77f404ac6a3d45b0)) +* :construction: adding api implementation ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :construction: all controllers writtend ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: applying review requested changes ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: commit à ecraser - wip fs file generator ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: feature to achieve later, keep asking for project status ([57846f9](https://github.com/dnum-mi/dso-console/commit/57846f923f15771186b8a7816324df42d55d86cb)) +* :construction: first database model ([69f51f2](https://github.com/dnum-mi/dso-console/commit/69f51f2eb16b052d4fddbb909ac7a5f0913b2c94)) +* :construction: new project controller ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: remove done todos ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: uncomment path for delete repo and projects ([8fb7cd9](https://github.com/dnum-mi/dso-console/commit/8fb7cd9b5c67857862cebfde091a3b5bbba54c93)) +* :construction: wip - ci files generated by server ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: wip downloading file ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: wip fix unit tests ([31c3a86](https://github.com/dnum-mi/dso-console/commit/31c3a863104f553adcefb5e78d64613e32f65704)) +* :lipstick: fix snackbar on bottom left ([7823c99](https://github.com/dnum-mi/dso-console/commit/7823c991b4adf62e380193d7f1d9fc33108dc368)) +* :lock: add crypto module to encrypt externalToken or other data if needed ([c5806b3](https://github.com/dnum-mi/dso-console/commit/c5806b392ec2b203f0d54a62527c9d96570f2918)) +* :necktie: cannot update nor delete permission if not permitted - controller side ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :necktie: disable delete permission for owner ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :necktie: permissions should be enabled only for permitted users ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :passport_control: organization controller ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :sparkles: add archiveProject btn ([e412223](https://github.com/dnum-mi/dso-console/commit/e41222346a4e3d9d60100d59b4edf160a2319c08)) +* :sparkles: add backend function to generate random passwords ([5244597](https://github.com/dnum-mi/dso-console/commit/52445972b7be13862d395059f82fae81b7efa495)) +* :sparkles: add delete playbook call in deleteRepo controller ([8abe695](https://github.com/dnum-mi/dso-console/commit/8abe6950e8f26aa01b7b1a95e4fbc4627f26d925)) +* :sparkles: add delete trigger for environment ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :sparkles: add environment and permissions management ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :sparkles: add environment page ([a5a5129](https://github.com/dnum-mi/dso-console/commit/a5a5129e89cb9594204b08c9cc76c3a30bb04eb7)) +* :sparkles: Add GitLab ci generation in repoForm ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: Add gitlabCi form for repo ([839f1e1](https://github.com/dnum-mi/dso-console/commit/839f1e18bf99f1bef53ed3ab39a64ab097c6a0d8)) +* :sparkles: add GitlabCI generated file ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: add helm charts ([e540325](https://github.com/dnum-mi/dso-console/commit/e5403252cd7d888c839be63758c15c1fdce76c71)) +* :sparkles: add log table in model ([642b245](https://github.com/dnum-mi/dso-console/commit/642b245a4a0ffa8877c900fa786345fd484f60f8)) +* :sparkles: add misc routes, healthz/version ([520976d](https://github.com/dnum-mi/dso-console/commit/520976ddfebba2d05385ba66d27af809e8736287)) +* :sparkles: ajout d'un système de role propre à la console avec authentification admin ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :sparkles: ansible-api respond playbook rc, server stores it in db ([7774045](https://github.com/dnum-mi/dso-console/commit/7774045ae6404e1f0de810bb09a7832c0292baba)) +* :sparkles: call delete repo and archive project playbooks ([97a7b13](https://github.com/dnum-mi/dso-console/commit/97a7b1300847fafa5053d58fb05079ef52a9d041)) +* :sparkles: ciForm ok ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: display current user name in closeable alert ([349ade9](https://github.com/dnum-mi/dso-console/commit/349ade9f5b8017bf206aff2c894a56cc675d6873)) +* :sparkles: get user's group from keycloak ([8311622](https://github.com/dnum-mi/dso-console/commit/83116226e7a3e172a94b4f988d037ec1dcc8aaf4)) +* :sparkles: record ansible responses in database ([5753883](https://github.com/dnum-mi/dso-console/commit/5753883ae32f36e28a128f396df3f9669e5a854b)) +* :sparkles: wip components for environment managing ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :test_tube: ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: rewrite random utils for tests ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* **Helm:** :art: add env per container ([48b41cf](https://github.com/dnum-mi/dso-console/commit/48b41cfea037ce90812197990c54d2726fb75ac7)) +* **Helm:** :art: variabilize server imports ([0973646](https://github.com/dnum-mi/dso-console/commit/09736463d3011f96348f94cf3ad8cc69c9c0f07e)) + +### Bug Fixes + +* :alembic: changement du keycloak flow ([1af0d85](https://github.com/dnum-mi/dso-console/commit/1af0d8504766f3f0ebab92feff8f4795e4e1c67e)) +* :art: variabilize pvc name ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: add await on start server functions and fix tests ([c8e66f8](https://github.com/dnum-mi/dso-console/commit/c8e66f87f4ccd4be1775b32729f12d9c0e919f35)) +* :bug: add refresh keycloak token ([b758e44](https://github.com/dnum-mi/dso-console/commit/b758e44241c868a1e8a1546271f1806069370964)) +* :bug: bind do not always works ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: change create project and change message logs ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: controller response before ansible fetch ([8983c45](https://github.com/dnum-mi/dso-console/commit/8983c457cd8de96d117d51881668b37c3b4f9407)) +* :bug: delete default argo repo at deleting ([5fafe99](https://github.com/dnum-mi/dso-console/commit/5fafe9919b7d08447e356eb260795a6cb91f3ba3)) +* :bug: fix projectAddUserController ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: flatMap didn't work as expected ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: forgot to load repositories data ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: import env ([98c1632](https://github.com/dnum-mi/dso-console/commit/98c16329e8fbd3b0bcabf2ac132d53931ef7f9bf)) +* :bug: init db doit être séquentiel pour ne pas violer l'intégrité des tables ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: isInfra & isPrivate false if undefined ([2fa987d](https://github.com/dnum-mi/dso-console/commit/2fa987d0872f6884627d6060c64d27d932a2db5c)) +* :bug: isInfra not send to ansible ([b049746](https://github.com/dnum-mi/dso-console/commit/b049746b97e5529d6f5368e045eb96a266bb432b)) +* :bug: mode dégradé si payload n'est pas bon ([14309cf](https://github.com/dnum-mi/dso-console/commit/14309cf41835f59168c72b6a4840fcf1ae8264d2)) +* :bug: repositories may be empty ([584798f](https://github.com/dnum-mi/dso-console/commit/584798f1ebba5894b2f9181cbe0f9bdbd49cdd6c)) +* :bug: review changes ([5e4bdb7](https://github.com/dnum-mi/dso-console/commit/5e4bdb73414102728e68d56fe30d65a7f718a0f7)) +* :bug: send proper data to ansible ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: send200 if code is OK ([08ca7eb](https://github.com/dnum-mi/dso-console/commit/08ca7ebdf0b24dabc60baa631c4887854780465c)) +* :bug: show permissionForm only when environment is created ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: stabilize playbookDir in different execution execution ([69645b9](https://github.com/dnum-mi/dso-console/commit/69645b9b392f8f56b8d3606780dfa63098fa5240)) +* :bug: update ansible calls ([04ee9ff](https://github.com/dnum-mi/dso-console/commit/04ee9fff7e78bbe4305ad7a1923df842a80a49ad)) +* :bug: update orgName generation to new db schema ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: use flatMap to iterate over two arrays ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: wip bug ansible repositoryInitializing ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: wip working on todo's ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: working on todo's ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: organization should be a FK in projects ([d014387](https://github.com/dnum-mi/dso-console/commit/d014387c8d09182675ddf98d381e27b78bc28545)) +* :card_file_box: turn envList into array of objects ([41f0278](https://github.com/dnum-mi/dso-console/commit/41f027858f108fb056958c118bf664a141e7623c)) +* :construction_worker: Copy test-utils in client docker ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: trying to log details for ansible api call ([ae944ae](https://github.com/dnum-mi/dso-console/commit/ae944aea77d0f13798c63e9bf9690363f75e9bc9)) +* :construction: wip ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :fire: remove bad log and comms ([748c43d](https://github.com/dnum-mi/dso-console/commit/748c43d93212a79b2dd04e527f97da5dd5d585b5)) +* :goal_net: add front error management for createProject ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :goal_net: catch errors in front when calling back ([5331773](https://github.com/dnum-mi/dso-console/commit/53317734961895bf348c758774d23c2505f812a8)) +* :lipstick: update UI for new DSFR version ([62750c4](https://github.com/dnum-mi/dso-console/commit/62750c478ecac453542e5933d1464af2a752e2d4)) +* :lock: filter users sensitive infos ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :lock: improve security using node utility function to compare hashes ([ea0e730](https://github.com/dnum-mi/dso-console/commit/ea0e73084a04c85d69e8d556e73241982df5d40f)) +* :loud_sound: mute logs for health route ([b1eaad5](https://github.com/dnum-mi/dso-console/commit/b1eaad5701ac73b131b0cb8692111ae5fd0a8d84)) +* :mute: remove sequelize logs to preserve data ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :necktie: permission level goes from 0 to 3, 0 by default, 3 for owner ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :pencil2: typo on var name ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :recycle: change where directive to through associations ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :recycle: fix ([8d2c911](https://github.com/dnum-mi/dso-console/commit/8d2c911b855f19b0a6568e2857129c9c62fcfcc9)) +* :recycle: use sequelize querytypes ([d45472a](https://github.com/dnum-mi/dso-console/commit/d45472a89a5be2a74c1af2da14d41ceb5d118284)) +* :rotating_light: Sonar warnings ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :rotating_light: SonarQube warning ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: fix TU ([26bd4f1](https://github.com/dnum-mi/dso-console/commit/26bd4f1477bd3664472ca4d8989630f09c2a7e0b)) +* :test_tube: use dedicated en var for ct tests ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: wip fix repo ansible fetch failed ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :truck: fix generateCIFiles feature ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :truck: rename folder for generating files ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) + +### Performance Improvements + +* :art: use map instead of forEach for array generation ([03c78fa](https://github.com/dnum-mi/dso-console/commit/03c78fa5f28bb86fad7e3e1b142a1c71d1639054)) +* :stethoscope: add health probes in templates ([d645f2e](https://github.com/dnum-mi/dso-console/commit/d645f2e35460ba92a67aeb0ea65ac9356ed36381)) + +### Reverts + +* :egg: hide environments and permissions feature waiting for playbooks ([edebee9](https://github.com/dnum-mi/dso-console/commit/edebee944905f5723518aaca0c37a51deeac80a6)) +* :rewind: hide snackbar for now ([6afe9e8](https://github.com/dnum-mi/dso-console/commit/6afe9e81bc5b32cb544456d7e697be66ea0349be)) + +### Code Refactoring + +* **database:** :card_file_box: new database model, queries, ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) + +## 1.0.0 (2022-12-16) + +### Features + +* :alembic: clone in entrypoint ([f97edb0](https://github.com/dnum-mi/dso-console/commit/f97edb07c31036cb1fc1882610e2f78185106028)) +* :art: baudoin's review ([656aac6](https://github.com/dnum-mi/dso-console/commit/656aac693a4c21a64402dd9bcd8b978f2fb63b55)) +* :art: introduce CLONE_DIR, where to clone ([263ea79](https://github.com/dnum-mi/dso-console/commit/263ea79deef5d9f58e2fc10ffcf98e31a4b41bc2)) +* :art: spread instead of push ([530b366](https://github.com/dnum-mi/dso-console/commit/530b366368b8eb1a2f2597362ecc2c5aa2bc1145)) +* :bento: Add images for services logos in public folder ([6d8e91c](https://github.com/dnum-mi/dso-console/commit/6d8e91ca9837dd60f603d5ed364e11fb3485a5b5)) +* :bento: Definitive logos for services ([e2e6270](https://github.com/dnum-mi/dso-console/commit/e2e627081224433e0d190876a8791aa1d2ea8d6b)) +* :boom: first query successfull ([61a7cd5](https://github.com/dnum-mi/dso-console/commit/61a7cd5857a6e6c648219d20399c19e8672a37db)) +* :boom: first query successfull ([36c973e](https://github.com/dnum-mi/dso-console/commit/36c973ebaa9f3a52669d0f921eedfb8aee6c4a9f)) +* :bricks: change routes ([af30bd8](https://github.com/dnum-mi/dso-console/commit/af30bd895bef8203b3e06facfa04d93be83b86f9)) +* :card_file_box: Add api getProjectById ([98cd231](https://github.com/dnum-mi/dso-console/commit/98cd23165e72add49762bc31b81c54526f42b768)) +* :card_file_box: Add api getProjectById ([98976d0](https://github.com/dnum-mi/dso-console/commit/98976d0a33cf65ee3be4baad629dae107410c6e3)) +* :card_file_box: Add createProject query ([9bcfb05](https://github.com/dnum-mi/dso-console/commit/9bcfb059d2c3c51c64ee223b777da5d999339e44)) +* :card_file_box: Add owner from keycloak in project ([5aa9aad](https://github.com/dnum-mi/dso-console/commit/5aa9aad9f12b5c5060139baebd5b2869ea91b1f4)) +* :card_file_box: Add owner from keycloak in project ([cadb474](https://github.com/dnum-mi/dso-console/commit/cadb4745648ecdfc0ffbbc045ca4a054ee062615)) +* :card_file_box: Add postgres and pgadmin ([76374e6](https://github.com/dnum-mi/dso-console/commit/76374e6acd72ba89ab15bdf4c7e1ff82f00e110d)) +* :card_file_box: Add user id in project queries ([48949df](https://github.com/dnum-mi/dso-console/commit/48949df562179493e2b524b541129ff520913d86)) +* :card_file_box: Create database & table on server start if not exists ([14b9f6b](https://github.com/dnum-mi/dso-console/commit/14b9f6b5359e33bd345eccb1be081698fbda8b59)) +* :card_file_box: Wip add createProject api ([1414bc0](https://github.com/dnum-mi/dso-console/commit/1414bc0ed59f63fd41f21dcc76dcebebd1a5c4cf)) +* :card_file_box: Wip app and server js ([2d4efdf](https://github.com/dnum-mi/dso-console/commit/2d4efdf91f98a82fcd18c3887c1943a283c653c8)) +* :construction: (wip) add project mongoose model ([76e8d77](https://github.com/dnum-mi/dso-console/commit/76e8d77a3342ff58707b47977a8cdd14a721339d)) +* :construction: (wip) trying to connect to keycloak with server ([2401fda](https://github.com/dnum-mi/dso-console/commit/2401fdaa383a38632e95d1097e4a3766ef7f6563)) +* :construction: Add check in addRepo and addUser queries ([c2bad66](https://github.com/dnum-mi/dso-console/commit/c2bad663b9aba71dffd9e023cbd098a38353ee0d)) +* :construction: Add DsfrTable for users ([8b63e84](https://github.com/dnum-mi/dso-console/commit/8b63e84bd6923f5128efbd427e659418794f7eaf)) +* :construction: Add DsoTeam table ([ca3e2c3](https://github.com/dnum-mi/dso-console/commit/ca3e2c3462d9e17d90e10afed4432b24735ded24)) +* :construction: Add final todos ([c224592](https://github.com/dnum-mi/dso-console/commit/c2245927e6a2b61cd3bf4fbef4fddf5708ccd20f)) +* :construction: Add OrderProject ([f4d2962](https://github.com/dnum-mi/dso-console/commit/f4d2962ec7b76a13c7f9849302f3a0279e79a442)) +* :construction: Add postgres and pgadmin ([a7ce8c5](https://github.com/dnum-mi/dso-console/commit/a7ce8c5ff9aee0003f7d2066a6f2e172b9d5bd08)) +* :construction: Add postgres and pgadmin ([6fdf560](https://github.com/dnum-mi/dso-console/commit/6fdf56045839636324015739852ee85d5685d584)) +* :construction: Add removeUserFromProject function ([9430fc6](https://github.com/dnum-mi/dso-console/commit/9430fc697e9761c3e79f0d37bef16dcf52f1e889)) +* :construction: Add routes for projects ([cd14601](https://github.com/dnum-mi/dso-console/commit/cd146017e1a6646777451e0ed711b9d6556e5393)) +* :construction: Add SideMenu to app ([3373f8c](https://github.com/dnum-mi/dso-console/commit/3373f8cf18607293f1081172ed93e8dabd69fd28)) +* :construction: Configure keycloak for frontend ([d8074ac](https://github.com/dnum-mi/dso-console/commit/d8074ac468a562322a8d44ed5d08b26be56cc7e8)) +* :construction: Failing specs projects ([a56cb8c](https://github.com/dnum-mi/dso-console/commit/a56cb8cb0685e3173b867519d09f4ca41b217959)) +* :construction: Func removeUserFromProject ([95fdc7f](https://github.com/dnum-mi/dso-console/commit/95fdc7f8cd8285bf18257364f833b9eb84588cf1)) +* :construction: Handle menu collapse in sm mode ([5237e89](https://github.com/dnum-mi/dso-console/commit/5237e892f86f5c4abd170562867a4745e2121bb9)) +* :construction: trying to access server /api/v-4/version ([a27483c](https://github.com/dnum-mi/dso-console/commit/a27483c6525091eca564b9ad615636edf79c5283)) +* :construction: trying to access server /api/v1/version ([2e43c8f](https://github.com/dnum-mi/dso-console/commit/2e43c8fd0c13f59bd40567912815a605c43bf0dc)) +* :construction: wip add node-postgres ([50ef526](https://github.com/dnum-mi/dso-console/commit/50ef526f2e647d5a16254468e9e83996e71baab1)) +* :construction: wip business rules orderProject ([24f7daa](https://github.com/dnum-mi/dso-console/commit/24f7daaae5cba5d7fdb6ee1ebc700c9aba1a0830)) +* :construction: wip pg ([391bccb](https://github.com/dnum-mi/dso-console/commit/391bccbbf34c9a365eed847824edb718643a8d73)) +* :construction: working on api x keycloak ([183f4d8](https://github.com/dnum-mi/dso-console/commit/183f4d8c9e49407f500444210f2f143dcf1433c3)) +* :hammer: Add turbo ([a3fdc96](https://github.com/dnum-mi/dso-console/commit/a3fdc96e23fbd3c66dea95ce89829dbe873b835a)) +* :hammer: Update docker compose scripts for pnpm ([537ba52](https://github.com/dnum-mi/dso-console/commit/537ba52f8c75659a8204beb5e48fa2ff11572be2)) +* :heavy_plus_sign: Add axios for api management ([591b012](https://github.com/dnum-mi/dso-console/commit/591b012df344e0842ce3848eaae4aa0c3beceb73)) +* :heavy_plus_sign: Add fastify keycloak adapter ([575e8d7](https://github.com/dnum-mi/dso-console/commit/575e8d71aa5d4fe9a0f2da94c484de2867ca5c9a)) +* :lipstick: Resize sidemenu and content ([61670d8](https://github.com/dnum-mi/dso-console/commit/61670d816aa3b70aa164e3d8531b12b3f835fcc6)) +* :lipstick: thiner projects subheader ([4427945](https://github.com/dnum-mi/dso-console/commit/442794546e8977dc3298a5ec92e68a30ef3775eb)) +* :lipstick: Update navigation in frontend ([5751ed3](https://github.com/dnum-mi/dso-console/commit/5751ed36dbdbeceb832c9bcc0c9972fca2abd26c)) +* :lock: Add keycloak token check on ansible api ([afe18f5](https://github.com/dnum-mi/dso-console/commit/afe18f58dc7c041adb40b1dee7617cb6b38ef9f5)) +* :loud_sound: forward request id to ansible api ([b6107bb](https://github.com/dnum-mi/dso-console/commit/b6107bbc322d6740bfbe7345e5865af6df4cb219)) +* :memo: Add documentation inside application ([0d70a0e](https://github.com/dnum-mi/dso-console/commit/0d70a0e8c6b4248e427e63d987dce7498f0c64ee)) +* :necktie: Add removeuser func ([3da7701](https://github.com/dnum-mi/dso-console/commit/3da77019f350b4dee5031ffbf5a97735299b0df9)) +* :necktie: users[].email must be unique ([d74b99d](https://github.com/dnum-mi/dso-console/commit/d74b99d642cdb3dea3fcd18116f644aa34542dbf)) +* :poop: dinindex turbo ([d7e78cb](https://github.com/dnum-mi/dso-console/commit/d7e78cb1e15c7008f761af42961bdc2754b117d0)) +* :poop: Pass current location to redirectUri ([ee65e42](https://github.com/dnum-mi/dso-console/commit/ee65e42daaff2fb20c4a9142a5ba87357e415088)) +* :poop: test 2 ([5c3b1da](https://github.com/dnum-mi/dso-console/commit/5c3b1da4c4d95ce3a9294c175f4826e174ae6436)) +* :poop: try to remove .tubro/ ([01ba3ef](https://github.com/dnum-mi/dso-console/commit/01ba3efaa8efacbb97638c5e89aebd3255149d2b)) +* :poop: turbo unindexation test ([2e7ba79](https://github.com/dnum-mi/dso-console/commit/2e7ba79848aae2fc174e364de867ff59159ed39c)) +* :recycle: Baudoin's review, userProfile in store ([c1a34b4](https://github.com/dnum-mi/dso-console/commit/c1a34b4c2c48455874829966d220b0b3137a010c)) +* :recycle: Harmonize api's names ([71ed577](https://github.com/dnum-mi/dso-console/commit/71ed577474dbe431cc1ebdb83336510ac93c58c8)) +* :recycle: Merge conf and init in a single keycloak file ([e777b02](https://github.com/dnum-mi/dso-console/commit/e777b028ce94df3288ab03bc6bdeea2ba2408fde)) +* :recycle: service url construction ([6b25ab3](https://github.com/dnum-mi/dso-console/commit/6b25ab3d40449da2b50ecd12604c6030aa0b287d)) +* :see_no_evil: Ignore .tubro ([d927f05](https://github.com/dnum-mi/dso-console/commit/d927f0559ecff8d3805709d9eca0487786295b52)) +* :sparkles: Add allTiles for all services, adapt projectTiles to selectedProject ([d207065](https://github.com/dnum-mi/dso-console/commit/d207065a628e26293f441a38ae9dd17950188d7c)) +* :sparkles: Add DsoService content ([024cbd0](https://github.com/dnum-mi/dso-console/commit/024cbd0c50475413a2a41097e301c88c87885363)) +* :sparkles: Add envList to project ([02e5215](https://github.com/dnum-mi/dso-console/commit/02e5215ec25a9dc7decd6c7d4d26c68a3a1b5fa5)) +* :sparkles: Add infra repo checkbox ([b0c0602](https://github.com/dnum-mi/dso-console/commit/b0c06020ea9f2aa1580514aa9498aec38cb3824b)) +* :sparkles: Add sidemenu and tmp routes ([be02a0c](https://github.com/dnum-mi/dso-console/commit/be02a0c2184b8ff92b13745efdfdda5626d91ec6)) +* :sparkles: Add specific endpoints to console api ([a35b9cc](https://github.com/dnum-mi/dso-console/commit/a35b9ccf452db0db438990633eded6b8d5ae0be7)) +* :sparkles: Add user to project ([85d5a1c](https://github.com/dnum-mi/dso-console/commit/85d5a1c98f9374ba9c7fb9b4f179bb8f40a6b0f3)) +* :sparkles: Add users[i].id in query ([35a36b4](https://github.com/dnum-mi/dso-console/commit/35a36b437d224b3d4b490a7c0ff2fdfe42f80410)) +* :sparkles: Add vault url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :sparkles: Add windicss to project ([8db02f5](https://github.com/dnum-mi/dso-console/commit/8db02f531f6f4dbc768ae726597b957cb8cb8e3a)) +* :sparkles: Create projects view ([aa0d833](https://github.com/dnum-mi/dso-console/commit/aa0d83356ab679261893cb563237062cdbbc04e6)) +* :sparkles: Finish v1 orderProject form ([590eb74](https://github.com/dnum-mi/dso-console/commit/590eb744f9daab7b9e34e6e2e9a376257f8290da)) +* :sparkles: Prepare ansible call for project provisioning ([a3d0918](https://github.com/dnum-mi/dso-console/commit/a3d09185837675f7954dad3185651ca300373bb1)) +* :sparkles: Separate ansible wrapper in a new api called ansible-api ([bcd64db](https://github.com/dnum-mi/dso-console/commit/bcd64dbdfa3c043bcf7c77f783ce1d36e43e4d05)) +* :speech_balloon: Correct services url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :speech_balloon: Update app's title ([869e2a3](https://github.com/dnum-mi/dso-console/commit/869e2a34455f1adb80d52e7fc5c4bc4c884368dd)) +* :speech_balloon: Update services url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :technologist: Add husky unit tests for server ([75e3a23](https://github.com/dnum-mi/dso-console/commit/75e3a23199ba15de9229809558a2b2569e38bdd4)) +* :technologist: Add watch src for hot reload ([e4e129d](https://github.com/dnum-mi/dso-console/commit/e4e129db4803f3bc5ed5a1ee3fc759ebcef38d89)) +* :truck: Better images for logos ([8ce9112](https://github.com/dnum-mi/dso-console/commit/8ce911280348e49d9f197dc9947d0d7d5bf5cee4)) +* :truck: Create folder projects in views, create store project ([a1df0f6](https://github.com/dnum-mi/dso-console/commit/a1df0f6bfcdbdba96d9b8c1c77e8500074f59512)) +* login on keycloack TEST ([20ab492](https://github.com/dnum-mi/dso-console/commit/20ab492a78f5e3e81a3295a3416a4422422d4244)) +* page login ([0c3946b](https://github.com/dnum-mi/dso-console/commit/0c3946b0ff04889b8b0fcf7b5160fea4409b3e8d)) +* **projectInit:** :building_construction: Add client folder ([9f3e90d](https://github.com/dnum-mi/dso-console/commit/9f3e90d305aebc6d9508d4570ffc0d1fac9502f2)) +* **projectInit:** :heavy_plus_sign: Add Stylelint ([21f33e0](https://github.com/dnum-mi/dso-console/commit/21f33e0554b04932a35ca723934c24bd93122513)) +* **projectInit:** :tada: Vite vue3 basic installation ([41aeb82](https://github.com/dnum-mi/dso-console/commit/41aeb824d02ba387bc8c842b07351fd55b221782)) + +### Bug Fixes + +* :alembic: edit code to run with new ansible directories ([8822d95](https://github.com/dnum-mi/dso-console/commit/8822d953e93db6f4ddc00383e7ad4a8c01e8cbaf)) +* :alembic: edit server image, kubeconfig fix for ansible ([5e02c5e](https://github.com/dnum-mi/dso-console/commit/5e02c5ef97a5fc67cfd30d296b36bf339fff8251)) +* :alembic: Try to fix keycloak redirect ([954bc51](https://github.com/dnum-mi/dso-console/commit/954bc513be0ff9a1e46b140af334273f710b9ec2)) +* :ambulance: fix a commit written too late in the night ([ee0cc99](https://github.com/dnum-mi/dso-console/commit/ee0cc99fab9a243ffddcc5ed874cbcd449d319f4)) +* :art: add env for keycloak in FRONT ([dea4e38](https://github.com/dnum-mi/dso-console/commit/dea4e38b36d9bb672f4f9e1079e9540d0e93d5af)) +* :art: rename file user in stores ([86be41e](https://github.com/dnum-mi/dso-console/commit/86be41e2ee7d6c8d0a0baad9507fe46fb8a527b4)) +* :bug: Add case sensitivity for init deb script ([699dedd](https://github.com/dnum-mi/dso-console/commit/699dedd5c8b149d8de83b632184fdb28b15784fc)) +* :bug: Add tagName (type) so it's closeable ([8aa65d5](https://github.com/dnum-mi/dso-console/commit/8aa65d53d03641ae30b0f989874f4acb85889b3f)) +* :bug: duplicated line ([2dad819](https://github.com/dnum-mi/dso-console/commit/2dad819540e76852251ef3ac8b7c3618abec0c71)) +* :bug: Finish removeUser feature ([25a4f36](https://github.com/dnum-mi/dso-console/commit/25a4f363ca99b3dd5b9a9adf6cf940021c509a5e)) +* :bug: Fix after dsfr's upgrade ([9273618](https://github.com/dnum-mi/dso-console/commit/92736182d04f6a965b15d0514a8c18e329ebec7a)) +* :bug: Fix bugs revealed by e2e tests ([f9ff1fc](https://github.com/dnum-mi/dso-console/commit/f9ff1fc8c633b894864489cb7b8c51eaf733e012)) +* :bug: Fix db init if not exists ([d3a7b77](https://github.com/dnum-mi/dso-console/commit/d3a7b779fb00399a94e6db570583bed77e4fc355)) +* :bug: Fix endless redirection ([7762a83](https://github.com/dnum-mi/dso-console/commit/7762a8358b6a76879e41ac6ea5a29765ec36a2e1)) +* :bug: Fix init db error ([9b50685](https://github.com/dnum-mi/dso-console/commit/9b5068519d7d26126c9730d32b375cb9e9243e92)) +* :bug: Fix multiples bugs ([fc94d99](https://github.com/dnum-mi/dso-console/commit/fc94d992ee0495113f7e5f4cd165f90b2460dd9d)) +* :bug: Fix potentital bugs ([ec04eee](https://github.com/dnum-mi/dso-console/commit/ec04eeeeb745be5e4a4ab5ad8a1c60e08cb74168)) +* :bug: Handle cases where no users ([47befb9](https://github.com/dnum-mi/dso-console/commit/47befb9e0dcfa07e733596aa5ccd9323e7447ad6)) +* :bug: Handle cases where users key does not exist in project ([98381dc](https://github.com/dnum-mi/dso-console/commit/98381dc3a746993c563b0b817cd6e985c40d4cc6)) +* :bug: Improve backend & fix in adequation of front changes ([a4df195](https://github.com/dnum-mi/dso-console/commit/a4df19533cd2e634149c549e5a717bf7b5bd1280)) +* :bug: kc logout ([c3621b9](https://github.com/dnum-mi/dso-console/commit/c3621b9f4db92b1a73a21b53d3894d79f2bdeb1d)) +* :bug: Last reviews ([4f39f8a](https://github.com/dnum-mi/dso-console/commit/4f39f8aea216aee29ff1128a7c5b60174e98b489)) +* :bug: multiple fixes, increase some log verbosity ([39e543b](https://github.com/dnum-mi/dso-console/commit/39e543bc8071d8323af96e85a6f6c8b7beffce19)) +* :bug: Reinit projects in store before pushing query result ([53d6c3f](https://github.com/dnum-mi/dso-console/commit/53d6c3f94e1211a69034c580abc6bd2731d7e717)) +* :bug: remove extra properties, it failed tests ([98abead](https://github.com/dnum-mi/dso-console/commit/98abeadf4ad5aad0eaab3f706500e27c98444ce3)) +* :bug: Remove unintentionaly added import ([8c6cab6](https://github.com/dnum-mi/dso-console/commit/8c6cab6fa05b57639003ea1e514da973d79fad3b)) +* :bug: Review tobi, an array even empty is always true ([9fe7068](https://github.com/dnum-mi/dso-console/commit/9fe706894c930fa5f2d187c0ce1500d75a797e29)) +* :bug: update for production ready ([4e80d61](https://github.com/dnum-mi/dso-console/commit/4e80d61fd3f9ec0526d99b3f3fec65a4ddfa78b0)) +* :coffin: Remove test class ([38a39ed](https://github.com/dnum-mi/dso-console/commit/38a39ed849fdcc7f62bdb24223f5cd74407e5aa9)) +* :coffin: Remove unused function ([b557bdc](https://github.com/dnum-mi/dso-console/commit/b557bdcb5b8fcb927ffc30cf9e4c0830d35b5363)) +* :construction_worker: Remove prod flag for dev stage in server dockerfile ([3e28a00](https://github.com/dnum-mi/dso-console/commit/3e28a00d1a652d46fcb0072471090ee2ad0fb899)) +* :construction: broken install in test yml ([1121d8b](https://github.com/dnum-mi/dso-console/commit/1121d8b7f90fc301a303c8705a039e1175d55eae)) +* :construction: Fix init keycloak in frontend ([0b41546](https://github.com/dnum-mi/dso-console/commit/0b41546b355e3583dd02fc1793c24c30af860030)) +* :construction: Wip reinit newUser object ([699a0bb](https://github.com/dnum-mi/dso-console/commit/699a0bbf8600efed4d1bf62a07d02f78c0e1c38c)) +* :construction: working on keyclock + pinia ([95d5822](https://github.com/dnum-mi/dso-console/commit/95d5822b23277b24a87f6f6d6c8ca77fc6a49926)) +* :fire: Remove useless silentchecksso & move cypress/vue to optional dep ([3b85f7a](https://github.com/dnum-mi/dso-console/commit/3b85f7a7f792d6fcb0b7f9bf8b94fe82b02ed577)) +* :globe_with_meridians: All english for dso-console ([6d45cba](https://github.com/dnum-mi/dso-console/commit/6d45cba12a6540dd13a933f6940b91701dbdab34)) +* :green_heart: Fix e2e tests in ci & improve code structure ([0857f70](https://github.com/dnum-mi/dso-console/commit/0857f703f0445cd49940d3337c4d94c9af4c70ea)) +* :lock: add binding in sql request to avoid sql injection ([46f5d04](https://github.com/dnum-mi/dso-console/commit/46f5d048e9df384c8f116bfdd8c9b7005160b5bf)) +* :loud_sound: Add logs for investigation ([b8f4867](https://github.com/dnum-mi/dso-console/commit/b8f48679f350ef34e856925fc9ff42ecfa1f281a)) +* :memo: Last review ([f37ad55](https://github.com/dnum-mi/dso-console/commit/f37ad5549d4e863cc78ed970c0525b4ed421b7f1)) +* :package: add jmespath lib ([5fcdd41](https://github.com/dnum-mi/dso-console/commit/5fcdd41c74ca678ce8cdfa79e5626d4eaeaba14c)) +* :pencil2: Typo in js ([ef99847](https://github.com/dnum-mi/dso-console/commit/ef99847908ce21cd28bddc8787101d9448c1976d)) +* :recycle: Refacto brainstorm thibault ([4e24417](https://github.com/dnum-mi/dso-console/commit/4e24417f7ac7a09dd57aa93f19d174ff679dcf74)) +* :rewind: Readd vitest config in shared ([2ec8b14](https://github.com/dnum-mi/dso-console/commit/2ec8b149a8d47136ac8305359688679e1eac1316)) +* :rewind: Rebase bug fix ([dbfaa2b](https://github.com/dnum-mi/dso-console/commit/dbfaa2b4732f51d6e1de0f731c55fa25518cc91c)) +* :rotating_light: Warn in console for missing required id ([db26c58](https://github.com/dnum-mi/dso-console/commit/db26c587106f1be4bf4765a0ea207adde71bb0ff)) +* :technologist: Add env exemple ([64fec78](https://github.com/dnum-mi/dso-console/commit/64fec783624dd4e3a625dbdd16f0bf9736adcc0d)) +* :test_tube: (to remove) failing tests skipped ([aeaded1](https://github.com/dnum-mi/dso-console/commit/aeaded1671f5de18d9e613fa32fda162a6147a27)) +* :white_check_mark: Correct api call in repo e2e test ([64d5196](https://github.com/dnum-mi/dso-console/commit/64d51968bad97f50f36a1619916b7c2b82378078)) +* :white_check_mark: Fix e2e tests on repo ([de0c0a5](https://github.com/dnum-mi/dso-console/commit/de0c0a59e02b639a67066c28b9532a5d38e12e98)) +* :zap: remove ansible legacy from server ([68c9dc9](https://github.com/dnum-mi/dso-console/commit/68c9dc9100aff3e71ccdab18608f61f9420e720f)) +* conf keycloak for cors from localhost:8080 ([1e06ff0](https://github.com/dnum-mi/dso-console/commit/1e06ff033094024d4e1a4f66bab5443b4505247c)) +* login on keycloack TEST ([c983eed](https://github.com/dnum-mi/dso-console/commit/c983eedccdba49d024ba6e5726f69afd33e577a1)) +* **projectInit:** :bricks: Add ports in vite config ([5de6e86](https://github.com/dnum-mi/dso-console/commit/5de6e86e02343ff910023681960e3bf7a788becd)) +* **projectInit:** :bug: Add context in docker-compose ([3ac3a72](https://github.com/dnum-mi/dso-console/commit/3ac3a7297c1b81fdb93ed099605ceeb2044ad617)) +* **projectInit:** :hammer: Fix run test script for client ([272f007](https://github.com/dnum-mi/dso-console/commit/272f007803d35e2113646d57b715e84249fa65bf)) +* remove double code in dockerfile ([2951672](https://github.com/dnum-mi/dso-console/commit/29516725acf793168854296f6f35088ef5b3447c)) +* rename and add volumes for keycloak ([3d0d999](https://github.com/dnum-mi/dso-console/commit/3d0d999630906223e8cb89fa51b31022e04e1659)) +* run cypress in container ([4589169](https://github.com/dnum-mi/dso-console/commit/4589169d0bb0fd1c368c08869e80251d0d5f691a)) +* run with delay 10 second to wait keycloak initialize ([b736991](https://github.com/dnum-mi/dso-console/commit/b736991208b9a5fc0880fba294fa96dc073f4c8a)) +* update gitignore for all node_modules ([3988743](https://github.com/dnum-mi/dso-console/commit/39887438eceefe4b08b80ad056f2992baaf78494)) +* update implicit flow in config keycloak ([06d2f77](https://github.com/dnum-mi/dso-console/commit/06d2f77fcc07db2de780867b0bda1f49294b349e)) +* update pnpm lock ([344261d](https://github.com/dnum-mi/dso-console/commit/344261d2e1b37e8df9eed0cca90ed8e21697d17a)) + +### Reverts + +* :recycle: Move code to another git branch ([1c632c7](https://github.com/dnum-mi/dso-console/commit/1c632c715ffc01100d75c46c1e84a565195a10b3)) diff --git a/.jjconflict-base-1/CONTRIBUTING.md b/.jjconflict-base-1/CONTRIBUTING.md new file mode 100644 index 0000000000..50318b28ee --- /dev/null +++ b/.jjconflict-base-1/CONTRIBUTING.md @@ -0,0 +1,115 @@ +# Contribuer à Console Cloud Pi Native + +Merci de contribuer à la Console Cloud Pi Native. Ce guide s'applique aux +contributions internes et externes, qu'elles concernent du code, de la +configuration, des dépendances ou de la documentation. + +## Principes + +La Console fournit une interface unifiée pour piloter les services de l'offre +Cloud Pi Native, automatiser le cycle de vie des projets et appliquer les +exigences DevSecOps de sécurité, de qualité, de maintenabilité et +d'interopérabilité. + +Toute évolution doit démontrer son alignement avec cette vision avant toute +implémentation. Une contribution qui ne sert pas l'offre, son architecture ou +sa feuille de route ne peut pas être acceptée. + +## Qualifier le sujet avant de développer + +Toute contribution de code doit être adossée à un ticket. Ce ticket décrit : + +- le besoin traité et son alignement avec la vision produit ou la feuille de route ; +- le périmètre fonctionnel et technique de la contribution ; +- les critères d'acceptation ; +- les impacts connus sur la sécurité, les contrats API, les données, l'exploitation et la documentation. + +Pour une correction de bug, une procédure de reproduction, un test automatisé +de non-régression et, pour une interface, des captures d'écran sont fortement +recommandés lorsqu'ils sont pertinents. Leur absence ne bloque pas la +contribution lorsqu'ils ne peuvent pas s'appliquer. Pour une fonctionnalité, +joindre des maquettes ou croquis, même rudimentaires, est encouragé. + +Les corrections strictement documentaires ou typographiques, sans impact +fonctionnel, sont les seules contributions qui ne nécessitent pas de ticket. +Elles restent soumises au contrôle de périmètre et aux exigences de qualité +adaptées. + +## Respecter le périmètre + +Une pull request ou merge request ne contient que les changements nécessaires +au sujet décrit par son ticket. Les commits, fichiers ou changements sans +rapport direct avec ce sujet sont rejetés et doivent être proposés séparément, +avec leur propre ticket. + +Utilisez le [template de pull request](.github/PULL_REQUEST_TEMPLATE.md) pour +présenter le comportement modifié et les informations utiles à la revue. Son +évolution est traitée séparément de ce guide. + +## Obtenir les validations requises + +Une approbation explicite de l'équipe Socle est obligatoire pour toute +contribution de code qui n'est ni une correction de bug ni un changement +trivial. + +L'approbation explicite de l'équipe Socle est obligatoire dans tous les cas +pour : + +- l'ajout, la suppression ou la mise à niveau d'une dépendance Node.js ; +- l'ajout ou la mise à niveau d'un outil installé dans une image Docker ; +- tout autre changement de dépendance ayant un impact sur la chaîne d'approvisionnement, la sécurité ou la maintenance. + +Les corrections de bugs et les changements triviaux ne dispensent ni du +contrôle de périmètre, ni de la revue de code, ni des contrôles qualité. Une +dépendance ajoutée pour corriger un bug doit donc recevoir l'approbation +explicite de l'équipe Socle. + +## Cibler le backend approuvé + +`apps/server` est le backend historique et ne reçoit plus de contribution. +Toute pull request ou merge request qui modifie ce dossier est rejetée, y +compris pour une correction de bug. + +Les évolutions backend ciblent exclusivement `apps/server-nestjs`. Elles +suivent le [plan de modularisation backend vers NestJS](apps/server-nestjs/documentation/Modularisation-de-console-server/README.md), l'[état de migration](apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md), les conventions NestJS et la stratégie de tests associée. Toute dérogation est une décision explicite de l'équipe Socle, hors du flux normal de contribution. + +## Respecter l'architecture et la qualité + +Respectez les frontières et conventions de la zone modifiée : + +- le frontend Vue 3 et le système de design existant ; +- les modules NestJS et Prisma dans `apps/server-nestjs` pour le backend ; +- les contrats partagés, permissions et schémas de données ; +- les plugins et leurs mécanismes de cycle de vie lorsqu'ils sont concernés. + +Ajoutez ou adaptez les tests proportionnés au risque et au périmètre : tests +unitaires ou d'intégration pour la logique et les contrats, et tests Playwright +pour les parcours utilisateurs affectés. Mettez à jour la documentation lorsqu'un +comportement, une API, une configuration, une opération ou une migration est +affectée. + +Avant soumission, exécutez les contrôles adaptés à votre modification : + +```bash +pnpm lint +pnpm test +pnpm playwright:test +``` + +Le test Playwright est requis lorsque la contribution affecte un parcours +utilisateur. Respectez aussi TypeScript strict, ESLint, Stylelint pour le +client, les hooks Husky et la CI. Les messages de commit suivent Conventional +Commits et sont rédigés en anglais. Rebasez votre branche sur `origin/main` +avant la demande de fusion. + +## Documents de référence + +Consultez et citez les références appropriées dans le ticket : + +- la [documentation publique Cloud Pi Native](https://github.com/cloud-pi-native/documentation), notamment l'[introduction de la plateforme](https://github.com/cloud-pi-native/documentation/blob/main/docs/platform/introduction.md) et la [feuille de route](https://github.com/cloud-pi-native/documentation/blob/main/docs/platform/roadmap.md) ; +- l'[architecture et les conventions du dépôt](README.md) ; +- le [plan de modularisation NestJS](apps/server-nestjs/documentation/Modularisation-de-console-server/README.md) ; + +La feuille de route publique est actuellement en cours de rédaction. Son +indisponibilité ne bloque pas la qualification : le ticket doit alors exposer +une justification concise au regard de la vision produit. diff --git a/.jjconflict-base-1/ENVIRONMENTS.md b/.jjconflict-base-1/ENVIRONMENTS.md new file mode 100644 index 0000000000..011939c593 --- /dev/null +++ b/.jjconflict-base-1/ENVIRONMENTS.md @@ -0,0 +1,244 @@ +# Fichiers de configuration d'environnements + +Cette documentation a pour but de détailler tout ce qui concerne la gestion des configuration d'environnements (appelé couramment "fichiers .env"). + +Comme vous aurez pu le constater, il y a beaucoup de choses à configurer pour un projet d'ampleur comme l'est la Console de CPiN, et il y a des cas d'usages très spécifiques qui seront décrits ici. Vous avez également la possibilité de composer votre propre manière de gérer vous variables d'environments en vous basant sur ce qui a été fait pour nous. + +## Contexte : migration progressive vers NestJS (Strangler Fig) + +Le backend de la console est en cours de migration de `apps/server` (Fastify/legacy) vers `apps/server-nestjs` (NestJS). Cette migration est progressive, route par route, selon le [Strangler Fig Pattern](https://martinfowler.com/bliki/StranglerFigApplication.html). + +Pour orchestrer cette migration, un **nginx-strangler** a été introduit (ticket [#1885](https://github.com/cloud-pi-native/console/issues/1885)). Il s'intercale entre le `client` et les backends, et route chaque requête API vers le bon service : + +``` +[client :8080] + │ proxy /api → nginx-strangler:8080 + ▼ +[nginx-strangler :8080] + ├── /api/[routes migrées] ──→ server-nestjs:3001 (NestJS) + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +**Conséquences pratiques pour les développeurs :** + +- `server-nestjs` est désormais présent dans tous les `docker-compose` aux côtés de `server`. +- Le `nginx-strangler` est le nouveau point d'entrée unique pour les appels API — le `client` ne pointe plus directement vers `server`. +- Consultez [`MODULARISATION-STATUT.md`](apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md) avant de développer sur un module backend, pour savoir s'il est en cours de migration (zones en feature freeze). +- Si vous travaillez sur un module déjà migré vers `server-nestjs`, développez dans `apps/server-nestjs`, pas dans `apps/server`. + +Pour plus de détails sur la stratégie de migration : [`apps/nginx-strangler/README.md`](apps/nginx-strangler/README.md). + +--- + +## Cas d'usage supportés + +Avant de décrire dans le détail comment configurer les différents environments, il est important de rappeler les Cas d'Usage qui sont supportés par les scripts de CPiN: + +- **Développement totalement en local** : vous déployez l'entièreté de l'écosystème de `console`, sans considération d'un branchement à l'extérieur, et vous développez directement en mode "serve" sur `client`, `server`, `server-nestjs`, un `plugin` en particulier, ou peut-être même une combinaison des trois. +- **Développement conteneurisé en local** : vous déployez l'entièreté de l'écosystème de `console`, sans considération d'un branchement à l'extérieur, et même les composants fondamentaux de console (comme `client`, `server` et `server-nestjs`) dans des conteneurs à l'aide d'images construites précédemment (en local, ou alors tirée depuis le registre de l'organisation CPiN) +- **Développement conteneurisé hybride** : vous déployez localement seulement une partie de l'écosystème de `console` (`client` et/ou `server`/`server-nestjs`, de manière à utiliser votre code local) et pour le reste (Base de données de `console`, `keycloak`, `gitlab`, les clusters applicatifs, etc.), vous vous branchez à un environnement existant (appelé `integ` pour `INTEGRATION`). Ce cas d'usage est très pratique pour tester votre code avec de "vraies données" d'un environnement fonctionnel (comme notre environnement interne `cpin-hp`) +- Les cas de déploiements finaux du système complet, qui sont eux adressé par un chart Helm stocké dans [le dépôt `helm-charts`](https://github.com/cloud-pi-native/helm-charts) + +Maintenant que ces définitions sont établies, passons à la configuration pour chacun des cas d'usage + +## Considérations communes à tous les cas + +Le mode de fonctionnement de la configuration des environnements est assez classique : les applications `client`, `server` et `server-nestjs` ont besoin d'avoir certaines variables d'environnements définies. + +Le mécanisme de surcharge des différentes configurations fonctionne de cette manière : + +``` +->: «surcharge» + +var d'env settée explicitement -> fichier .env.docker (si contexte docker) -> fichier .env.integ (si INTEGRATION=true) -> fichier .env +``` + +## Prégénération des fichiers .env, .env.docker, et .env.integ + +Un script permet de copier facilement les fichiers `.env*-example` en leur équivalent `.env*`: [`./ci/scripts/init-env.sh`](./ci/scripts/init-env.sh). + +> Il faut ensuite remplir ces fichiers, car ils ne sont là que simplement copiés avec les valeurs par défaut + +## Configuration pour le développement entièrement en local + +Le développement entièrement en local suit cette organisation des composants: + +```mermaid +--- +title: Architecture mode entièrement local +--- +graph LR + + subgraph DEV_LOCAL["DEV LOCAL"] + direction LR + NAV_L["Navigateur"] + NGINX_L(("nginx (client)\n:8080\nLOCAL")) + STRANGLER_L(("nginx-strangler\n:8080\n🐳 exposé :4000")) + SERVER_L(("server\n:4001\nLOCAL")) + NESTJS_L(("server-nestjs\n:3001\nLOCAL")) + + NAV_L --> NGINX_L + NGINX_L -- "/api" --> STRANGLER_L + STRANGLER_L -- "défaut\n(pas migré)" --> SERVER_L + STRANGLER_L -- "migré" --> NESTJS_L + end + + style DEV_LOCAL fill:#fff,stroke:#333,stroke-width:2px + style NAV_L fill:#fff,stroke:#333 +``` + +Docker Compose utilisé : [`docker/docker-compose.local.yml`](docker/docker-compose.local.yml) (infrastructure uniquement : Keycloak, PostgreSQL, pgAdmin, OpenCDS mock, **nginx-strangler, et Jaeger**) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/server/.env` +- `apps/server-nestjs/.env` *(si vous travaillez sur server-nestjs)* + +Les valeurs par défaut, disponibles dans les fichiers `.env-example`, sont suffisantes dans 99% des cas. Cela dit, c'est un cas d'usage assez restreint car la console se reposant sur quelques composants externes comme une base de données PostgreSQL ou un serveur IAM comme `keycloak`, il faut configurer manuellement les `.env` pour pointer sur les bonnes URLs, etc. Pas infaisable, mais pas très pratique au quotidien, hors des cas simple de build des applications. + +**Commandes de lancement :** + +```bash +# Lance l'infrastructure (Keycloak, PostgreSQL, pgAdmin, OpenCDS mock, nginx-strangler, Jaeger) +pnpm dev + +# Puis dans d'autres terminaux, lancer les serveurs et le client manuellement : +pnpm --filter server run dev +pnpm --filter server-nestjs run start:dev # nouveau backend NestJS +pnpm --filter client run dev +``` + +### Faire transiter les appels API par le nginx-strangler en dev local + +Par défaut, le proxy Vite du `client` pointe directement vers `server` (port `4000`). Pour faire transiter les appels via le `nginx-strangler` (utile pour tester le routage de migration ou travailler sur `server-nestjs`), une seule variable suffit dans `apps/client/.env` : + +```bash +# apps/client/.env +SERVER_PORT=8082 # port du nginx-strangler exposé par docker-compose.local.yml + # (au lieu de 4000 qui pointe directement vers server) +``` + +Le `nginx-strangler` est automatiquement lancé par `pnpm dev` via `docker-compose.local.yml`. Il écoute sur `localhost:8082` et redirige vers les deux backends natifs (`server:4000` et `server-nestjs:3001`) selon la configuration de [`apps/nginx-strangler/conf.d/routing.conf`](apps/nginx-strangler/conf.d/routing.conf). + +> **Note :** si vous ne travaillez pas sur la migration NestJS, vous n'avez pas besoin de changer `SERVER_PORT` — le comportement par défaut (proxy direct vers `server:4000`) reste identique. + +### Observabilité : Jaeger + OpenTelemetry (traces) + +Le fichier [`docker/docker-compose.local.yml`](docker/docker-compose.local.yml) démarre un service `jaeger` (image `jaegertracing/all-in-one`) pour collecter et visualiser les traces. + +- UI Jaeger : http://localhost:16686 +- Endpoints de collecte exposés sur la machine hôte : + - OTLP gRPC : `localhost:4317` + - OTLP HTTP (protobuf) : `http://localhost:4318` + +Dans `apps/server-nestjs`, l'instrumentation OpenTelemetry est initialisée au démarrage via [`src/instrumentation.ts`](apps/server-nestjs/src/instrumentation.ts) (appelée depuis `main.ts`) et exporte via OTLP. + +Pour vérifier rapidement : + +1. Démarrer l'infra : `pnpm dev` (Jaeger inclus). +2. Démarrer `server-nestjs` : `pnpm --filter server-nestjs run start:dev`. +3. Exécuter une requête sur une route backend (depuis le client ou un `curl`). +4. Ouvrir http://localhost:16686 et chercher le service `cloud-pi-native-console`. + +## Configuration pour le développement conteneurisé en local + +Docker Compose utilisé : [`docker/docker-compose.dev.yml`](docker/docker-compose.dev.yml) (tout conteneurisé avec Docker Compose Watch) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/client/.env.docker` +- `apps/server/.env` +- `apps/server/.env.docker` +- `apps/server-nestjs/.env` +- `apps/server-nestjs/.env.docker` + +Cette configuration est déjà plus intéressante, car elle s'appuie sur les conteneurs définis dans [ce docker-compose](docker/docker-compose.dev.yml), qui lance notamment une base de données PostreSQL (ainsi qu'un `pgadmin`), et un serveur Keycloak préchargé avec un royaume qui contient un jeu de données. Le docker-compose contient des instructions `develop` qui permettent soit de synchroniser certains fichiers, soit de carrément reconstruire l'image et de relancer le service concerné. De cette manière vous pouvez développer en laissant les conteneurs tourner. C'est un peu moins performant qu'un travail totalement en local, mais ça a le mérite d'être plus proche du déploiement cible. + +Le `nginx-strangler` et `server-nestjs` sont inclus dans ce docker-compose et démarrent automatiquement. Le `client` pointe vers `nginx-strangler` — le routage API est donc toujours actif, même si aucune route n'est encore basculée vers NestJS (tout passe par `server` en fallback). + +**Commande de lancement :** + +```bash +# Lance l'ensemble des conteneurs (client, server, server-nestjs, nginx-strangler, +# keycloak, postgres, pgadmin, opencds mock) avec Docker Compose Watch +pnpm docker:dev +``` + +## Configuration pour le développement hybride avec un environnement d'intégration existant + +Le développement hybride branché sur l'environnement d'intégration suit cette organisation des composants: + +```mermaid +--- +title: Architecture mode hybride branché sur l'environment d'intégration +--- +graph LR + + subgraph DEV_INTEG["DEV INTEG"] + direction LR + NAV_I["Navigateur"] + CURL_I["curl directs\n(tests)"] + INTEG["INTEG\n(keycloak, gitlab, etc.)"] + + subgraph DOCKER_NET["🐳 Réseau Docker"] + direction LR + NGINX_I(("nginx (client)\n:8080\nexposé :8080")) + STRANGLER_I(("nginx-strangler\n:8080\nexposé :4000")) + SERVER_I(("server\n:8080\nexposé :4001")) + NESTJS_I(("server-nestjs\n:3001\nexposé :3001")) + + NGINX_I -- "/api" --> STRANGLER_I + STRANGLER_I -- "pas migré" --> SERVER_I + STRANGLER_I -- "migré" --> NESTJS_I + end + + NAV_I --> NGINX_I + SERVER_I -. "accès externe" .-> INTEG + NESTJS_I -. "accès externe" .-> INTEG + + CURL_I -- ":8080" --> NGINX_I + CURL_I -- ":4000" --> STRANGLER_I + CURL_I -- ":4001" --> SERVER_I + CURL_I -- ":3001" --> NESTJS_I + end + + style DEV_INTEG fill:#fff,stroke:#333,stroke-width:2px + style DOCKER_NET fill:#e6f3ff,stroke:#2196F3,stroke-width:2px + style NAV_I fill:#fff,stroke:#333 + style CURL_I fill:#fff,stroke:#333 + style INTEG fill:#fff,stroke:#333,stroke-dasharray: 5 5 +``` + +Docker Compose utilisé : [`docker/docker-compose.integ.yml`](docker/docker-compose.integ.yml) (sans Keycloak, branché sur l'environnement d'intégration distant) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/client/.env.docker` +- `apps/client/.env.integ` +- `apps/server/.env` +- `apps/server/.env.docker` +- `apps/server/.env.integ` +- `apps/server-nestjs/.env` +- `apps/server-nestjs/.env.docker` +- `apps/server-nestjs/.env.integ` *(si vous travaillez sur server-nestjs en mode integ)* + +Cette configuration est une itération de la précédente. Dans ce cas d'usage le Keycloak n'est pas créé en tant que conteneur, car on est supposé se brancher sur l'environnement d'intégration défini dans les fichiers `.env.integ`. Le contenu de ces fichiers (en particulier celui de `apps/server`) est donc clé. + +Le `nginx-strangler` et `server-nestjs` sont également inclus dans ce docker-compose. + +**Commandes de lancement :** + +```bash +# Option 1 : Tout conteneurisé, branché sur l'environnement d'intégration +pnpm docker:integ + +# Option 2 : Seulement l'infra en Docker (postgres, pgadmin), server et client en local avec mode integ +pnpm integ +# Puis dans d'autres terminaux : +pnpm --filter server run dev +pnpm --filter server-nestjs run start:dev +pnpm --filter client run dev +``` diff --git a/.jjconflict-base-1/LICENSE b/.jjconflict-base-1/LICENSE new file mode 100644 index 0000000000..4b139aa8bc --- /dev/null +++ b/.jjconflict-base-1/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Ministère de l’Intérieur et des Outre-Mer + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/.jjconflict-base-1/README.md b/.jjconflict-base-1/README.md new file mode 100644 index 0000000000..3a89503a2b --- /dev/null +++ b/.jjconflict-base-1/README.md @@ -0,0 +1,409 @@ +# Console Cloud π Native + +La console Cloud π Native est une application web ayant pour but de piloter des services dans un cluster Kubernetes afin de fournir une plateforme cloud qui accompagne les produits numériques lors de toutes les phases de leur cycle de vie. + +Cette console offre une interface unifiée vers un ensemble de services tout en garantissant une cohérence globale du système avec la création automatique d'un certain nombre de ressources comme les comptes d'accès, les robots ou encore des ressources Kubernetes. +En addition du provisionnement automatique, elle garantit aussi le contrôle d'accès aux ressources du projet à l'aide d'une gestion d'équipe, de permissions, de quotas, etc. + +<img src="./misc/images/projects.png" width="30%"></img> <img src="./misc/images/repositories.png" width="30%"></img> <img src="./misc/images/environments.png" width="30%"></img> <img src="./misc/images/services.png" width="30%"></img> <img src="./misc/images/members.png" width="30%"></img> <img src="./misc/images/status.png" width="30%"></img> + +## Démarrage Rapide (OnBoarding) + +Afin de démarrer rapidement l'application pour la découvrir il vous faudra d'abord cloner ce dépôt, puis satisfaire à un certain nombre de prérequis. + +### Cloner le projet + +```bash +git clone https://github.com/cloud-pi-native/console.git + +# Se rendre dans le dossier du projet +cd console +``` + +### Prérequis + +#### Prérequis techniques du projet + +- [Docker >= v27](https://docs.docker.com/get-docker/) Orchestrateur de conteneurs + - [Plugin compose >= v2.35](https://github.com/docker/compose) (attention à ne pas avoir une vieille version qui traînerait dans `~/.docker/cli-plugins/` !). Permet de composer plusieurs conteneurs Docker + - [Plugin buildx](https://github.com/docker/buildx) Permet d'étendre les capacités de Docker à l'aide de BuildKit +- [Node.js >= v24](https://nodejs.org/en/download/) Environnement d'exécution JavaScript +- [PnPM >= v10](https://pnpm.io/installation) Gestionnaire de paquets pour JavaScript + +#### Prérequis de configuration du projet + +Afin de pouvoir démarrer l'application, il vous faudra également récupérer (ou bien créer vous-même) les fichiers : + +- `apps/client/.env.docker` +- `apps/client/.env.integ` +- `apps/server/.env.docker` +- `apps/server/.env.integ` + +Si vous ne les avez pas déjà, référez-vous à [./ENVIRONMENTS.md](./ENVIRONMENTS.md) qui est la documentation consacrée au sujet. + +### Lancement rapide utilisant l'application en local et les services sur un environnement distant + +Maintenant que vous avez les prérequis projets (prérequis techniques et de configuration), vous pouvez construire et lancer l'application localement, en la couplant avec les services (par exemple Keycloak) d'un environnement d'intégration : + +```bash +# Installer toutes les dépendances +$ pnpm install + +# Initialiser la base de données PostgreSQL lancée localement +$ pnpm db:generate + +# Construire les applications client et server +$ pnpm build + +# Construire les images dockers client et server, puis lancer la stack docker compose d'intégration +$ export KUBECONFIG_HOST_PATH="$HOME/.kube/config" && pnpm run docker:integ +``` + +Cette série de commandes va installer les diverses dépendances, construire l'intégralité de l'application Console (client et serveur) dans des images docker locales, et finalement lancer l'application en la connectant dans votre infrastructure d'Intégration existante. + +Si vous voulez une vue plus approfondie du lancement de l'application, vous pouvez vous référer à la [section détaillée à ce sujet](#développement). + +## Architecture + +Ce projet est construit avec [NodeJS](https://nodejs.org/), [VueJS](https://vuejs.org/), [Postgres](https://www.postgresql.org/) et [Keycloak](https://www.keycloak.org/). +Le serveur et le client sont livrés sous forme d'images [Docker](https://www.docker.com/) et sont déployées à l'aide de [Helm](https://helm.sh/) dans [Kubernetes](https://kubernetes.io/). + +### API + +Le serveur est construit selon une architecture __core / plugins__ pour favoriser l'évolutivité et l'ajout de nouvelles fonctionnalités / la gestion de nouveaux services. Pour ce faire, les plugins s'enregistrent auprès de différents `hooks` (qui suivent le cycle de vie d'un projet au sein de l'application), ces derniers seront déclenchés par les contrôleurs de l'application. + +Plusieurs plugins sont nativement enregistrés auprès du serveur pour assurer le bon fonctionnement de la plateforme, à savoir : + +- [Argocd](https://argo-cd.readthedocs.io/en/stable/) +- [Gitlab](https://about.gitlab.com/) +- [Harbor](https://goharbor.io/) +- [Keycloak](https://www.keycloak.org/) +- [Kubernetes](https://kubernetes.io/) +- [Nexus](https://www.sonatype.com/products/sonatype-nexus-repository) +- [Sonarqube](https://www.sonarsource.com/products/sonarqube/) +- [Vault](https://www.vaultproject.io/) + +> Pour plus d'informations sur le développement d'un plugin, voir : +> - [documentation des plugins](./misc/plugins.md). +> - [documentation du module hooks](./packages/hooks/README.md). +> - [exemple de plugin](https://github.com/cloud-pi-native/console-plugin-helloworld). + +### Gestion des rôles + +La console gère le contrôle d'accès via des rôles (administration de la console et rôles au niveau projet) composés d'un nom, d'un ensemble de permissions (bitmask) et d'un type (`type`). + +Chaque rôle est associé à un groupe OIDC/Keycloak (`oidcGroup`) mais la source de vérité peut varier selon le type : + +- `managed` : la console est source de vérité pour les membres et les permissions, puis synchronise l'appartenance avec le groupe `oidcGroup`. +- `external` : le groupe OIDC est la source de vérité pour l'appartenance ; la console s'aligne dessus. +- `global` : rôle d'administration destiné à porter des permissions transverses telle que le role "Tout le monde". + +Le préfixe `system:` est un qualifiant (pas un type à part entière) utilisé pour marquer les rôles fournis/maintenus par la plateforme, par exemple `system:managed`, `system:external`, `system:global`. + +Dans l'interface, un rôle dont le type commence par `system:` est considéré comme non modifiable : ses champs (nom/type/groupe/permissions) sont en lecture seule et la suppression est désactivée. Selon l'écran, la gestion des membres peut rester disponible. + +## Développement + +Le développement s'effectue à l'aide de Docker *(le client et le serveur peuvent tourner en local ou dans Docker)*. + +### Construire l'application + +Une fois le projet cloné, lancez les commandes suivantes dans votre terminal pour installer le projet et construire l'ensemble de l'application `console` : + +```shell +# Installer les dépendances du projet +pnpm install + +# Créer les fichiers d'environnement exemples +./ci/scripts/init-env.sh + +# Générer le client Prisma côté serveur +pnpm --filter @cpn-console/server run db:generate + +# Construire tous les paquets applicatifs +pnpm build +``` + +#### Lancer l'application + +L'application peut se lancer de plusieurs manières, à savoir : + +__Déploiement local du client et du serveur de la console, le reste dans des conteneurs :__ + +```shell +# Lancer keycloak, postgres et pgadmin dans des conteneurs +pnpm run dev + +# Lancer le serveur dans un shell (la commande va afficher les logs) +pnpm --filter @cpn-console/server run dev + +# Lancer le client dans un shell (la commande va afficher les logs) +pnpm --filter @cpn-console/client run dev + +# …une fois que vous terminé d'utiliser l'application: + +# Supprimer les conteneurs keycloak, postgres et pgadmin +pnpm run dev:clean + +# Supprimer les conteneurs keycloak, postgres et pgadmin (supprime les volumes docker) +pnpm run dev:delete +``` + +__Tout dans des conteneurs :__ + +```shell +# Lancer l'application +pnpm run docker:dev + +# Supprimer les conteneurs +pnpm run docker:dev:clean + +# Supprimer les conteneurs (supprime également les volumes !) +pnpm run docker:dev:delete +``` + +> Pour lancer le debugger Node.js sur le serveur, dans les fichiers `docker-compose` remplacer la directive `command: ["dev"]` par `command: ["debug"]`. + +#### Intégration + +L'application peut se lancer de plusieurs manières, à savoir : + +__Déploiement local du client et du serveur de la console, le reste dans des conteneurs :__ + +```shell +# Lancer postgres et pgadmin dans des conteneurs +# (le keycloak utilisé devra être configuré dans les .env.integ) +pnpm run integ + +# Lancer le serveur +pnpm --filter @cpn-console/server run integ + +# Lancer le client +pnpm --filter @cpn-console/client run integ + +# Supprimer les conteneurs postgres et pgadmin +pnpm run integ:clean + +# Supprimer les conteneurs postgres et pgadmin (supprime les volumes docker) +pnpm run integ:delete +``` + +__Tout dans des conteneurs :__ + +```shell +# Lancer l'application +pnpm run docker:integ + +# Supprimer les conteneurs +pnpm run docker:integ:clean + +# Supprimer les conteneurs (supprime les volumes docker) +pnpm run docker:integ:delete +``` + +#### Utilitaires + +Les commandes utilitaires de l'application : + +```shell +# Formattage du code +pnpm run format + +# Lancer la vérification syntaxique +pnpm run lint + +# Lancer les tests unitaires +pnpm run test + +# Lancer les tests de composants +pnpm run test:ct +``` + +L'intégralité des commandes est disponibles dans le fichier [package.json](./package.json) à la racine du projet, vous pouvez lancer ces dernières à l'aide de la commande `pnpm run <le_nom_du_script>`. + +### Tests Playwright + +Se référer à la [documentation concernée](./playwright/README.md). + +### Accès aux services + +Les services sont disponibles via les ports suivants : + +| Service | Url (local/docker) | +| ---------------------------------------------- | ----------------------- | +| Interface graphique *- (client)* | <http://localhost:8080> | +| Serveur *- (api)* | <http://localhost:4000> | +| Interface d'administration de base de données | <http://localhost:8081> | +| Interface d'administration du serveur keycloak | <http://localhost:8090> | + +*__Notes:__ ⚠ Il est possible que le navigateur utilisé (particulièrement Brave ou Firefox) bloque les cookies utilisés entre le frontend et keycloak, il est nécessaire de désactiver les protections de ce type dans votre navigateur (ex: Brave Shield).* + +### Informations de connexion + +Les comptes utilisés pendant le développement sont les suivants : + +| Service | Nom d'utilisateur | Mot de passe | +| ------------------ | ----------------- | ------------ | +| Keycloak *(admin)* | `admin` | `admin` | +| Keycloak *(user)* | `test` | `test` | +| PgAdmin | `admin@dso.fr` | `admin` | +| Postgres | `admin@dso.fr` | `admin` | + +> La liste complète des comptes Keycloak pré-créés est disponible [ici](./keycloak/realms/realm-dev.json). + +> Le nom de la base de données est : `dso-console-db`. + +### Variables d'environnements + +__Local / Docker:__ + +Les variables d'environnements sont gérées localement via des fichiers `.env` (local) / `.env.docker` (docker) dans les dossiers `./apps/server` et `./apps/client`, aux précédents fichiers s'ajoute un fichier `.env.integ` utilisé pour le mode intégration (local et docker). + +### Base de données + +Pour faciliter les opérations de migrations de base de données via [Prisma](https://www.prisma.io/), un script est disponible : + +```shell +# Lancer le script +pnpm --filter @cpn-console/server run db:wrapper + +# Voir l'aide du script +pnpm --filter @cpn-console/server run db:wrapper -h +``` + +### Débogage avec VS Code + +Le projet est configuré pour être débogué directement dans VS Code via le fichier `.vscode/launch.json`. +Rendez-vous dans l'onglet "Run and Debug" (ou `Ctrl+Shift+D`) pour voir les configurations disponibles. + +#### Configurations disponibles + +- **Launch Server** : Lance le serveur en local (hors Docker) et attache le débogueur. + - *Note : Assurez-vous que les bases de données (Postgres) et Keycloak sont accessibles (ex: lancés via `pnpm run dev` dans un autre terminal pour les dépendances).* +- **Attach Server** : S'attache au processus Node.js du serveur. + - Utile lorsque le serveur tourne dans un conteneur Docker. + - Pour que cela fonctionne, le serveur doit être lancé avec le mode debug activé. +- **Serve Client (Chrome/Firefox)** : Lance l'application client en local et ouvre une instance de navigateur dédiée au débogage. +- **Launch (Chrome/Firefox)** : Lance simultanément le serveur et le client pour une session de débogage complète (Compound task). + +#### Déboguer avec d'autres outils (Chrome DevTools, WebStorm, etc.) + +Le serveur Node.js expose le port de débogage `9229` lorsqu'il est lancé en mode debug (ou avec l'option `--inspect`). Vous pouvez utiliser n'importe quel client de débogage compatible avec le protocole Inspector de Node.js. + +**Exemple avec Chrome DevTools :** + +1. Lancez le serveur en mode debug (voir section précédente pour Docker, ou `pnpm run debug` en local). +2. Ouvrez Chrome et naviguez vers `chrome://inspect`. +3. Cliquez sur "Open dedicated DevTools for Node" ou attendez que votre target apparaisse sous "Remote Target". +4. Vous pouvez maintenant mettre des points d'arrêt, inspecter les variables, etc. + +### Construction des images + +Ce dépôt utilise des fichiers docker-compose pour construire les images docker: + +- [docker-compose.local.yml](./docker/docker-compose.local.yml) pour le développement local (`client`/`server`/`server-nestjs` lancés localement, et une "glue" docker pour les joindre) +- [docker-compose.dev.yml](./docker/docker-compose.dev.yml) pour la construction des images docker du mode développement. +- [docker-compose.ci.yml](./docker/docker-compose.ci.yml) pour la construction des images docker utilisées dans nos pipelines d'Intégration Continue. + +## Configuration du Keycloak + +Pour pouvoir gérer les droits utilisateurs des services le pod `server` doit accéder aux groupes des users. Cela signifie modifier le clientScope `profile`: + +- Onglet `Mappers` +- `Add Mappers > By configuration > Group Membership` + - Name: `groups` + - Token Claim Name: `groups` + - Full group path: `off` + - Add to ID token: `on` + - Add to access token: `on` + - Add to userinfo: `off` + +> En environnement de dev l'import par défaut prévoit déjà cette modification. + +Les utilisateurs faisant parti du group `admin` ont également accès à l'interface administrateur de la console une fois connectés via un onglet supplémentaire `Administration` dans le menu latéral de l'application. + +## Tableau des ressources, terminologie + +| Console Cloud Pi | Projet | Environnement | Dépots | Utilisateur / membre | +| ---------------- | ---------------------------- | ------------- | --------------------------------------- | -------------------- | +| __Openshift__ | | Namespace | | | +| __ArgoCD__ | | | (infra) Secret, AppProject, Application | | +| __Gitlab__ | Group | | Repository (Dépôt) | User | +| __Harbor__ | Project | | Repository [1] | | +| __Keycloak__ | | Group | | User / member | +| __Sonar__ | User | | | | +| __Nexus__ | Repositories, role, user ... | | | | + +[1] N'est pas crée par la console mais par le produit de la CI + +## Architecture du dépôt + +La gestion des dépendances est effectuée à l'aide de [pnpm](https://pnpm.io/) selon la structure de dossiers suivante : + +- Les différentes briques applicatives se trouvent dans le dossier `apps/`. +- Les bibliothèques additionnelles se trouvent dans le dossier `packages/`. +- Les plugins core se trouvent dans le dossier `plugins/`. + +*Schema de l'architecture du monorepo :* + +```shell +./ +├── apps +│ ├── client +│ └── server +├── packages +│ ├── eslintconfig +│ ├── hooks +│ ├── shared +│ ├── test-utils +│ └── tsconfig +├── plugins +│ ├── argocd +│ ├── gitlab +│ ├── harbor +│ ├── keycloak +│ ├── kubernetes +│ ├── nexus +│ ├── sonarqube +│ └── vault +├── package.json +├── pnpm-lock.yaml +├── pnpm-workspace.yaml +└── README.md +``` + +## Organisation avec Git + +Une requête de fusion ("merge request") doit être faite avec la branche `main` comme destination. + +La branche de base **doit** être à jour avec `origin/main` (utiliser `git pull origin/main && git rebase origin/main` ou l'option de rebasage dans l'interface Web si besoin) avant la demande de fusion (MR). + +## Conventions de nommage + +Cf. [Conventions - MIOM Fabrique Numérique](https://docs.fabrique-numerique.fr/conventions/nommage.html). + +Les commits doivent suivre la spécification des [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). Cette norme est utilisée pour construire la nouvelle version, voir ci-après. + +Il est possible d'ajouter l'[extension VSCode](https://github.com/vivaxy/vscode-conventional-commits) pour faciliter la création des commits. + +> Les messages de commits sont rédigés en anglais. + +## Gestion des versions + +Se référer à [./RELEASE.md](./RELEASE.md). + +## Gestion des dépendances + +### Mise à jour des dépendances + +À propos du cycle de vie des dépendances, et notamment leur mise à jour, un document spécifique est disponible [ici](https://github.com/cloud-pi-native/documentation-interne-socle/blob/main/Divers/Mode-op%C3%A9ratoire-mont%C3%A9es-version-librairies.md). Ce document détaille la stratégie choisie en interne de mise à jour des dépendances de la Console CPiN. + +Concernant la mise à jour de `browserlist` et de `caniuse-lite`, utiliser la commande suivante sachant qu'on utilise `pnpm` : + +```shell +$ pnpm update caniuse-lite -r +``` + +### Dépendances Helm + +La console embarque une dépendance avec le Helm Chart utilisé par ArgoCD pour consommer le fichier values qu'elle construit. Afin d'assurer la compatibilité, il faut toujours que la version du Helm chart associé ait été publiée au préalable (voir [la documentation dédiée](https://github.com/cloud-pi-native/helm-charts?tab=readme-ov-file#contribution)). diff --git a/.jjconflict-base-1/RELEASE.md b/.jjconflict-base-1/RELEASE.md new file mode 100644 index 0000000000..300f31fd01 --- /dev/null +++ b/.jjconflict-base-1/RELEASE.md @@ -0,0 +1,110 @@ +# Gestion des versions + +Ce document décrit comment est géré le versionnement de `console`, c'est-à-dire : + +- Préparer la création d'une nouvelle version de `console` au fur et à mesure des ajouts +- Créer effectivement la nouvelle version +- Mettre à jour le chart Helm concerné +- Publier les nouvelles versions de modules NPM concernés + +## Incrémentation des versions + +Afin d'éviter une confusion entre les hotfixes, qui sont des versions `PATCH` qui ensuite rétroportées sur la branche principale (`main`), et les versions régulières qui n'ont que des commits `fix:` (et donc produisent par défaut, elles aussi, des versions `PATCH`), nous avons décidé d'adopter le protocole de versionnement suivant : + +- Les versions régulières sur `main` sont par défaut des `MINOR` (et très rarement des `MAJOR`) +- Les versions de hotfix d'une version en particulier sont **forcément** des `PATCH` (ceci est forcé dans le [flux de travail GitHub `Create new release`](https://github.com/cloud-pi-native/console/tree/main/.github/workflows/job-release-please.yml#L39)) + +La structure de versionnement de `console` est donc **`MAJOR`.`MINOR`.`HOTFIX`** + +## Schéma récapitulatif + +Les sections suivantes vont expliciter ce schéma: + +```mermaid +gitGraph + commit id: "…previous commits" + commit id: "Add basic features" + + branch release-please-main + checkout release-please-main + commit id:"bump to v1.2.0" tag: "v1.2.0" + + checkout main + merge release-please-main + checkout release-please-main + branch "hotfix/urgent-fix-for-v1.2.0" + commit id: "Fix stuff" + branch release-please-hotfix_v1.2.1 + commit id:"bump to v1.2.1" tag: "v1.2.1" + checkout "hotfix/urgent-fix-for-v1.2.0" + merge release-please-hotfix_v1.2.1 + + checkout main + commit id: "Add features" + checkout release-please-main + commit id:"bump to v1.3.0" tag: "v1.3.0" + merge main + commit id:"bump to v1.3.0" tag: "v1.3.0" + checkout main + commit id: "More features" + checkout release-please-main + merge main + commit id:"bump to v1.3.0 (recreated)" tag: "v1.3.0 (recreated)" + checkout main + merge release-please-main + + checkout main + merge "hotfix/urgent-fix-for-v1.2.0" + commit id:"backport v1.2.1 fix onto main" + checkout release-please-main + commit id:"bump to v1.4.0" tag: "v1.4.0" + merge main + commit id:"bump to v1.4.0" tag: "v1.4.0" + checkout main + merge release-please-main +``` + +## Versionnement de Console + +Le flux de travail qui créé les nouvelles versions s'intitule [`create-or-update-release`](https://github.com/cloud-pi-native/console/blob/main/.github/workflows/workflow-create-or-update-release.yml) et est déclenché à chaque nouveau commit sur `main` (soit lorsqu'on fusionne une requête de fusion, soit un commit poussé en outrepassant l'interdiction de pousser sur `main`) ou sur une branche `hotfix/*`. + +Le flux de travail utilise [release-please-action](https://github.com/googleapis/release-please-action) pour automatiquement générer les tags Git ainsi que les nouvelles versions sur GitHub. À chaque fois que du code est poussé dans la branche `main` ou un branche `hotfix/*`, une requête de fusion de version est créée en analysant les messages de commits pour déterminer le numéro de version à créer (`PATCH`, `MINOR`, ou `MAJOR`). Si une requête de fusion de nouvelle version existe déjà, elle est mise à jour afin de refléter les nouveaux changements ajoutés à la future nouvelle version. + +Les différent types de commits (`chore:`, `feat:`, `fix:` etc.) vont alimenter différentes sections de la `CHANGELOG`. Ces sections sont décrites dans la configuration de release-please, [`./release-please-config.json`](./release-please-config.json) + +Lorsqu'une requête de fusion de version (sur `main` ou `hotfix/*`) est fusionnée, les images de conteneur des applications (`client`, `server`, etc.) sont alors créées et hébergées dans la [registry Github associée au dépôt](https://github.com/orgs/cloud-pi-native/packages?repo_name=console) avec les tags appropriés (qui reflètent les tags git concernés). + +> Seuls les tags "complets" (`vX.Y.Z`) sont immutables, les tags "partiels" (`vX` et `vX.Y`) sont recréés pour relier la dernière version concernée. C'est pour ça que lorsque vous tirez les changements de `main` il est recommandé de faire un `git pull --tags --force` afin de forcer la recréation de vos tags locaux pour ces tags partiels. + +## Versionnement du chart Helm `dso-console` + +Le déploiement de `console` se fait préférablement à l'aide de son chart Helm, nommé [`dso-console`](https://github.com/cloud-pi-native/helm-charts/tree/main/charts/dso-console). + +La dernière étape du flux de travail de création de nouvelles versions (cf. section ci-dessus) est la création automatique d'une requête de fusion dans le dépôt `helm-charts` pour la mise à jour du chart Helm `dso-console`. Une fusion manuelle sur ce dépôt est alors nécessaire pour déclencher la publication de la nouvelle version du chart Helm (embarquant donc la nouvelle version de `console`). Exemple d'une telle requête de fusion de nouvelle version du chart Helm : https://github.com/cloud-pi-native/helm-charts/pull/204. + +> Les versions "régulières" (`MAJOR` ou `MINOR` depuis `main`) et les versions "hotfixes" (`PATCH` depuis `hotfix/*`) produisent le même type de requête de fusion côté `helm-charts`, car du point de vue de ce dépôt toute mise à jour de l'application est un `PATCH` bump côté chart. + +## Versionnement des modules NPM + +La publication des nouvelles versions de modules npm du dépôt est automatique et est inclus dans le flux de travail de création d'une nouvelle version. Il analyse les numéros de version présents dans les différents fichiers `package.json` pour déterminer si une nouvelle version du module doit être créée et publiée. + +> Il est possible de créer une version de pré-release d'un module npm en modifiant la clé `publishConfig.tag` dans le `package.json` avec par exemple `beta` pour générer une version beta. + +## Hotfixes + +Autant que faire se peut il vaut mieux privilégier le "Fix Forward" avec de nouvelles versions, afin d'éviter la charger de générer/rétroporter un hotfix. + +Ceci étant dit, il arrivera, hélas, qu'un hotfix soit nécessaire sur une version livrée. + +Voici donc le processus compatible avec l'utilisation de `release-please`: + +- Se placer localement sur le tag de la version concernée: `$ git checkout v1.2.0` (`v1.2.0` est ici la version à hotfixer) +- En tirer une branche dédiée au hotfix: `$ git checkout -b hotfix/my-urgent-hotfix-for-v1.2.0` (Note: Il n'est pas nécessaire de spécifier la version dans le nom de la branche, mais ça peut aider à la lecture et ainsi confirmer la version concernée) +- Faire les modifications nécessaires, committer, etc. +- Pousser la nouvelle branche sur le dépôt Github +- ⚠ Si vous voulez faire une "preview" de cette branche il faudra très probablement créer une **autre branche** qui cible `main` et y résoudre les conflits avant de faire une `preview`. En effet il est fortement probable que si vous faites un hotfix, la branche `main` aura déjà une nouvelle version (sinon vous ne feriez pas un hotfix, vous feriez simplement une nouvelle version 😁). Or il faut savoir que [Github ne permet pas l'exécution des workflows en cas de conflits](https://github.com/orgs/community/discussions/26304). Donc si vous voulez une `preview` du hotfix il faudra une MR dédiée, et dont vous aurez résolu les hotfixes (ça devrait être normalement limité aux fichiers contenant la version comme `CHANGELOG.md`). C'est pénible, mais c'est comme ça. +- Une fois la nouvelle branche poussée, `release-please` va être déclenché par le flux de travail Github `create-or-update-release` afin de créer une requête de fusion pour la nouvelle version hotfixée (avec comme cible la branche de hotfix). Il est d'ailleurs à noter que dans le cas d'un hotfix **on ne fait qu'une montée du "PATCH"** (ici on obtiendra donc la version `v1.2.1`, qui est alors le premier hotfix de la version `v1.2.0`) quelque soit les commits (donc même un `feat!` ne fera pas de montée majeure) +- Valider la MR de version hotfixée (créée donc par `release-please`) à l'aide du flux de travail Github `Continuous Integration` +- Une fois la MR de version hotfixée validée et fusionnée, la nouvelle version est créée et, comme pour les versions traditionnelles, une requête de fusion est crée dans le dépôt `helm-charts` pour avoir là aussi une version hotfixée (mais, pour le chart Helm, c'est considéré comme une version classique) +- Il faudra ensuite faire des picorages (`git cherry-pick`) ou une MR de la branche de hotfix vers `main` afin d'intégrer le ou les commits de hotfix dans la prochaine version officielle +- ⚠ Il faut retagger le commit de hotfix quand on a backporté le fix + le commit de bump de la release hotfix sur `main`, car sinon la Release côté GitHub pointe vers un tag dont le commit n'est pas sur `main` (le SHA a logiquement changé lorsqu'il a été backporté). Refaire donc localement un `git tag vX.Y.Z commit-de-release-backporté-sur-main` et pusher avec `git push --tags --force` pour forcer la mise à jour du tag. SI VOUS NE FAITES PAS ÇA LES CHANGELOG VONT TOTALEMENT PARTIR EN VRILLE ⚠ diff --git a/.jjconflict-base-1/apps/client/.env-example b/.jjconflict-base-1/apps/client/.env-example new file mode 100644 index 0000000000..2f23f06f97 --- /dev/null +++ b/.jjconflict-base-1/apps/client/.env-example @@ -0,0 +1,24 @@ +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Hôte du serveur backend vers lequel le client proxifie les appels API +SERVER_HOST=localhost +# Port d'écoute du serveur backend +# En développement local avec le nginx-strangler (pnpm dev +# + docker-compose.local.yml), il vaut mieux mettre SERVER_PORT=4000 pour que +# le proxy Vite passe par nginx-strangler plutôt que directement par server (4001) +# ou server-nestjs (3001). +SERVER_PORT=4000 +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté frontend +KEYCLOAK_CLIENT_ID=dso-console-frontend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# Active/désactive le module OpenCDS (true | false) +OPENCDS_ENABLED=false +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-base-1/apps/client/.env.docker-example b/.jjconflict-base-1/apps/client/.env.docker-example new file mode 100644 index 0000000000..1f05a462c6 --- /dev/null +++ b/.jjconflict-base-1/apps/client/.env.docker-example @@ -0,0 +1,21 @@ +# Spécifique à ce ficher. Permet de dire que la stack est conteneurisée +DOCKER=true +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Hôte du nginx vers lequel le client proxifie les appels API +SERVER_HOST=nginx-strangler +SERVER_PORT=8080 +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté frontend +KEYCLOAK_CLIENT_ID=dso-console-frontend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# Active/désactive le module OpenCDS (true | false) +OPENCDS_ENABLED=false +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-base-1/apps/client/.env.integ-example b/.jjconflict-base-1/apps/client/.env.integ-example new file mode 100644 index 0000000000..4f870dfd2f --- /dev/null +++ b/.jjconflict-base-1/apps/client/.env.integ-example @@ -0,0 +1,11 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Domaine du Keycloak de l'environnement d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak de l'environnement d'intégration +KEYCLOAK_REALM= +# En intégration, Keycloak est généralement exposé en HTTPS +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak frontend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= diff --git a/.jjconflict-base-1/apps/client/.eslintrc-auto-import.json b/.jjconflict-base-1/apps/client/.eslintrc-auto-import.json new file mode 100644 index 0000000000..253b4b4683 --- /dev/null +++ b/.jjconflict-base-1/apps/client/.eslintrc-auto-import.json @@ -0,0 +1,93 @@ +{ + "globals": { + "Component": true, + "ComponentPublicInstance": true, + "ComputedRef": true, + "DirectiveBinding": true, + "EffectScope": true, + "ExtractDefaultPropTypes": true, + "ExtractPropTypes": true, + "ExtractPublicPropTypes": true, + "InjectionKey": true, + "MaybeRef": true, + "MaybeRefOrGetter": true, + "PropType": true, + "Ref": true, + "VNode": true, + "WritableComputedRef": true, + "acceptHMRUpdate": true, + "computed": true, + "createApp": true, + "createPinia": true, + "customRef": true, + "defineAsyncComponent": true, + "defineComponent": true, + "defineStore": true, + "effectScope": true, + "getActivePinia": true, + "getCurrentInstance": true, + "getCurrentScope": true, + "h": true, + "inject": true, + "isProxy": true, + "isReactive": true, + "isReadonly": true, + "isRef": true, + "mapActions": true, + "mapGetters": true, + "mapState": true, + "mapStores": true, + "mapWritableState": true, + "markRaw": true, + "nextTick": true, + "onActivated": true, + "onBeforeMount": true, + "onBeforeRouteLeave": true, + "onBeforeRouteUpdate": true, + "onBeforeUnmount": true, + "onBeforeUpdate": true, + "onDeactivated": true, + "onErrorCaptured": true, + "onMounted": true, + "onRenderTracked": true, + "onRenderTriggered": true, + "onScopeDispose": true, + "onServerPrefetch": true, + "onUnmounted": true, + "onUpdated": true, + "onWatcherCleanup": true, + "provide": true, + "reactive": true, + "readonly": true, + "ref": true, + "resolveComponent": true, + "setActivePinia": true, + "setMapStoreSuffix": true, + "shallowReactive": true, + "shallowReadonly": true, + "shallowRef": true, + "storeToRefs": true, + "toRaw": true, + "toRef": true, + "toRefs": true, + "toValue": true, + "triggerRef": true, + "unref": true, + "useAttrs": true, + "useCssModule": true, + "useCssVars": true, + "useId": true, + "useLink": true, + "useModel": true, + "useRoute": true, + "useRouter": true, + "useScheme": true, + "useSlots": true, + "useTabs": true, + "useTemplateRef": true, + "watch": true, + "watchEffect": true, + "watchPostEffect": true, + "watchSyncEffect": true + } +} diff --git a/.jjconflict-base-1/apps/client/.stylelintrc.js b/.jjconflict-base-1/apps/client/.stylelintrc.js new file mode 100644 index 0000000000..86614a8803 --- /dev/null +++ b/.jjconflict-base-1/apps/client/.stylelintrc.js @@ -0,0 +1,40 @@ +export default { + root: true, + extends: [ + 'stylelint-config-standard', + 'stylelint-config-html/vue', + 'stylelint-config-html', + 'stylelint-config-recommended-vue', + ], + ignoreDisables: false, + rules: { + 'at-rule-no-deprecated': [ + true, + { + ignoreAtRules: [ + 'windi', + 'apply', + 'include', + 'variants', + 'responsive', + 'screen', + ], + }, + ], + 'at-rule-no-unknown': [ + true, + { + ignoreAtRules: [ + 'windi', + 'apply', + 'include', + 'variants', + 'responsive', + 'screen', + ], + }, + ], + 'no-descending-specificity': null, + 'selector-class-pattern': '^((sm|md|lg|xl|2xl):)?[a-z][-_/a-z0-9]*$', + }, +} diff --git a/.jjconflict-base-1/apps/client/Dockerfile b/.jjconflict-base-1/apps/client/Dockerfile new file mode 100644 index 0000000000..b6784f3a82 --- /dev/null +++ b/.jjconflict-base-1/apps/client/Dockerfile @@ -0,0 +1,48 @@ +# Dev stage ------------------------------------------------------------------------ +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Install pnpm version defined in package.json "packageManager" property +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY --chown=node:root apps/client/package.json ./apps/client/package.json +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +RUN pnpm install --ignore-scripts # --no-optional + +COPY --chown=node:root packages/ ./packages/ +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +COPY --chown=node:root apps/client/ ./apps/client/ +RUN pnpm --filter client run icons + +ENTRYPOINT [ "pnpm", "--filter", "client", "run", "dev" ] + + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +ARG APP_VERSION +RUN pnpm --filter @cpn-console/client run build + + +# Prod stage ----------------------------------------------------------------------- +FROM nginxinc/nginx-unprivileged:1.31-alpine AS prod + +COPY --chown=nginx:root --from=build /app/apps/client/dist /etc/nginx/html/ +COPY --chown=nginx:root --chmod=660 ./apps/client/nginx/default.conf /etc/nginx/conf.d/default.conf +COPY --chown=nginx:root --chmod=750 ./apps/client/nginx/entrypoint.sh /docker-entrypoint.d/99-envsubst-js-assets.sh + +RUN chmod -R g=u /etc/nginx/html + +EXPOSE 8080 diff --git a/.jjconflict-base-1/apps/client/README.md b/.jjconflict-base-1/apps/client/README.md new file mode 100644 index 0000000000..00810a3778 --- /dev/null +++ b/.jjconflict-base-1/apps/client/README.md @@ -0,0 +1,56 @@ +# Console Cloud π Native - Client + +## Installation + +```sh +npm install +``` + +## Lancement de l'app + +```sh +npm run dev +``` + +## Lancement des tests unitaires + +```sh +npm run test +``` + +## Formattage du code + +```sh +# Lister les problèmes de formatage +npm run lint + +# Régler automatiquement les problèmes de formatage +npm run format +``` + +## Build de l'app + +```sh +npm run build +``` + +## Prévisualisation du code de production + +```sh +npm run preview +``` + +## Récupérer toutes les icones du projets +`grep -r -oh "ri:[a-z0-9-]*" . | sort | uniq | cut -d ':' -f 2 | awk NF | awk '{print " \047" $1 "\047,"}'` + +## Activation OpenCDS + +Se référer à [la documentation dédiée](../../packages/opencds/README.adoc) + +## Crédits + +- [vue](https://github.com/vuejs/) +- [vue-dsfr](https://github.com/dnum-mi/vue-dsfr) +- [vite](https://github.com/vitejs/vite) +- [stylelint](https://github.com/stylelint/stylelint) +- [eslint](https://github.com/eslint/eslint) diff --git a/.jjconflict-base-1/apps/client/eslint.config.js b/.jjconflict-base-1/apps/client/eslint.config.js new file mode 100644 index 0000000000..88c06179d3 --- /dev/null +++ b/.jjconflict-base-1/apps/client/eslint.config.js @@ -0,0 +1,6 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase.overrideRules({ + 'e18e/prefer-static-regex': 'off', + 'vue/no-required-prop-with-default': 'off', +}) diff --git a/.jjconflict-base-1/apps/client/index.html b/.jjconflict-base-1/apps/client/index.html new file mode 100644 index 0000000000..b57fbfc4ab --- /dev/null +++ b/.jjconflict-base-1/apps/client/index.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html lang="fr"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" href="/favicon.ico" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Console Cloud π Native + + +
+ + + diff --git a/.jjconflict-base-1/apps/client/jsconfig.json b/.jjconflict-base-1/apps/client/jsconfig.json new file mode 100644 index 0000000000..a5cee14f16 --- /dev/null +++ b/.jjconflict-base-1/apps/client/jsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "jsx": "preserve", + "baseUrl": ".", + "module": "ESNext", + "moduleResolution": "node", + "paths": { + "@/*": [ + "./src/*" + ] + }, + "sourceMap": true + }, + "exclude": [ + "node_modules" + ] +} diff --git a/.jjconflict-base-1/apps/client/nginx/README.md b/.jjconflict-base-1/apps/client/nginx/README.md new file mode 100644 index 0000000000..b2e55f8ea1 --- /dev/null +++ b/.jjconflict-base-1/apps/client/nginx/README.md @@ -0,0 +1,5 @@ +# À propos + +Ce nginx est celui utilisé à fin d'exposer les fichiers de l'application "SPA" (Single Page Application) de la Console. + +**À ne pas confondre avec [`nginx-strangler`](../../nginx-strangler) qui est le composant permettant le transfert progressif des routes de `server` vers `server-nestjs`** diff --git a/.jjconflict-base-1/apps/client/nginx/default.conf b/.jjconflict-base-1/apps/client/nginx/default.conf new file mode 100644 index 0000000000..60cfae693b --- /dev/null +++ b/.jjconflict-base-1/apps/client/nginx/default.conf @@ -0,0 +1,16 @@ +server { + listen 8080; + server_name localhost; + root /etc/nginx/html; + index index.html; + + large_client_header_buffers 4 32k; + + location / { + try_files $uri $uri/ @rewrites; + } + + location @rewrites { + rewrite "^(.+)$" /index.html last; + } +} diff --git a/.jjconflict-base-1/apps/client/nginx/default.docker.conf b/.jjconflict-base-1/apps/client/nginx/default.docker.conf new file mode 100644 index 0000000000..74bca2057d --- /dev/null +++ b/.jjconflict-base-1/apps/client/nginx/default.docker.conf @@ -0,0 +1,35 @@ +upstream api { + # Toutes les requêtes API transitent par nginx-strangler qui orchestre + # la bascule progressive vers server-nestjs (Strangler Fig Pattern, ticket #1885) + server nginx-strangler:8080; +} + +server { + listen 8080; + server_name localhost; + root /etc/nginx/html; + index index.html; + + large_client_header_buffers 4 32k; + + location / { + try_files $uri $uri/ @rewrites; + } + + location @rewrites { + rewrite "^(.+)$" /index.html last; + } + + location ~ ^/(api|swagger-ui) { + rewrite "^(.*)$" $1 break; + add_header Access-Control-Allow-Origin '$http_origin'; + proxy_pass http://api; + proxy_redirect off; + proxy_buffering off; + proxy_http_version 1.1; + + # For WebSockets + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/.jjconflict-base-1/apps/client/nginx/entrypoint.sh b/.jjconflict-base-1/apps/client/nginx/entrypoint.sh new file mode 100755 index 0000000000..98dfe3bcd9 --- /dev/null +++ b/.jjconflict-base-1/apps/client/nginx/entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +ASSETS_DIR=/etc/nginx/html/assets + +make_envsubst_vars() { + fmt= + for name do + fmt="${fmt}${fmt:+ }\${$name}" + done + + printf '%s\n' "$fmt" +} + +ENV_VARS=$(make_envsubst_vars \ + SERVER_HOST \ + SERVER_PORT \ + OPENCDS_ENABLED \ + KEYCLOAK_PROTOCOL \ + KEYCLOAK_DOMAIN \ + KEYCLOAK_REALM \ + KEYCLOAK_CLIENT_ID \ + KEYCLOAK_REDIRECT_URI \ + CONTACT_EMAIL \ +) + +echo "Replacing env variables in JavaScript asset files..." +for file in $ASSETS_DIR/*.js; do + echo "Processing $file ..."; + envsubst "${ENV_VARS}" \ + < $file \ + > $file-out && \ + mv $file-out $file +done +echo "Done !" diff --git a/.jjconflict-base-1/apps/client/package.json b/.jjconflict-base-1/apps/client/package.json new file mode 100644 index 0000000000..a897dccf6f --- /dev/null +++ b/.jjconflict-base-1/apps/client/package.json @@ -0,0 +1,88 @@ +{ + "name": "@cpn-console/client", + "type": "module", + "version": "9.22.1", + "private": false, + "description": "cpn-console client", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "scripts": { + "build": "pnpm run icons && pnpm run type-check && pnpm run build-only", + "build-only": "vite --mode=production build", + "build:clean": "rimraf ./dist ./tsconfig.tsbuildinfo", + "dev": "vite --mode=development", + "format": "NODE_OPTIONS='--no-warnings=ExperimentalWarning' eslint ./ --fix", + "format:style": "stylelint ./src/**/*.{css,vue} --fix", + "icons": "vue-dsfr-icons -s scripts/icons.js -t src/icon-collections.ts", + "integ": "vite --mode=integration", + "lint": "pnpm run --parallel \"/^lint:.*/\"", + "lint:style": "stylelint ./src/**/*.{css,vue}", + "lint:ts": "NODE_OPTIONS='--no-warnings=ExperimentalWarning' eslint ./", + "preview": "vite preview --port 8080", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "type-check": "vue-tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@gouvfr/dsfr": "catalog:runtime", + "@gouvminint/vue-dsfr": "catalog:runtime", + "@iconify-json/ri": "catalog:runtime", + "@iconify/vue": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@vue/tsconfig": "catalog:build", + "axios": "catalog:runtime", + "javascript-time-ago": "catalog:runtime", + "jszip": "catalog:runtime", + "keycloak-js": "catalog:runtime", + "nanoid": "catalog:runtime", + "p-debounce": "catalog:runtime", + "pinia": "catalog:runtime", + "vue": "catalog:runtime", + "vue-router": "catalog:runtime", + "vue3-json-viewer": "catalog:runtime", + "xbytes": "catalog:runtime", + "yaml": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@iconify/types": "catalog:build", + "@types/jsdom": "catalog:types", + "@types/node": "catalog:types", + "@unocss/transformer-directives": "catalog:build", + "@vitejs/plugin-vue": "catalog:build", + "@vitest/coverage-v8": "catalog:test", + "@vue/eslint-config-typescript": "catalog:tools", + "chalk": "catalog:tools", + "eslint": "catalog:tools", + "eslint-plugin-vue": "catalog:tools", + "jsdom": "catalog:test", + "rimraf": "catalog:tools", + "stylelint": "catalog:tools", + "stylelint-config-html": "catalog:tools", + "stylelint-config-recommended-vue": "catalog:tools", + "stylelint-config-standard": "catalog:tools", + "typescript": "catalog:build", + "unocss": "catalog:build", + "unplugin-auto-import": "catalog:build", + "unplugin-vue-components": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vite-plugin-pwa": "catalog:build", + "vitest": "catalog:test", + "vue-eslint-parser": "catalog:tools", + "vue-tsc": "catalog:build", + "workbox-window": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/apps/client/public/browserconfig.xml b/.jjconflict-base-1/apps/client/public/browserconfig.xml new file mode 100644 index 0000000000..4826e1357a --- /dev/null +++ b/.jjconflict-base-1/apps/client/public/browserconfig.xml @@ -0,0 +1,2 @@ + +#ffffff diff --git a/.jjconflict-base-1/apps/client/public/favicon.ico b/.jjconflict-base-1/apps/client/public/favicon.ico new file mode 100644 index 0000000000..d5b9b0fcb9 Binary files /dev/null and b/.jjconflict-base-1/apps/client/public/favicon.ico differ diff --git a/.jjconflict-base-1/apps/client/public/img/architecture.png b/.jjconflict-base-1/apps/client/public/img/architecture.png new file mode 100644 index 0000000000..46a52e9c98 Binary files /dev/null and b/.jjconflict-base-1/apps/client/public/img/architecture.png differ diff --git a/.jjconflict-base-1/apps/client/public/img/argocd.svg b/.jjconflict-base-1/apps/client/public/img/argocd.svg new file mode 100644 index 0000000000..fb7bc6fe84 --- /dev/null +++ b/.jjconflict-base-1/apps/client/public/img/argocd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.jjconflict-base-1/apps/client/public/img/gitlab.svg b/.jjconflict-base-1/apps/client/public/img/gitlab.svg new file mode 100644 index 0000000000..602ffcc063 --- /dev/null +++ b/.jjconflict-base-1/apps/client/public/img/gitlab.svg @@ -0,0 +1 @@ +GitLab home page \ No newline at end of file diff --git a/.jjconflict-base-1/apps/client/public/img/harbor.svg b/.jjconflict-base-1/apps/client/public/img/harbor.svg new file mode 100644 index 0000000000..c5d042f6b6 --- /dev/null +++ b/.jjconflict-base-1/apps/client/public/img/harbor.svg @@ -0,0 +1,482 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.jjconflict-base-1/apps/client/public/img/logo-marianne.png b/.jjconflict-base-1/apps/client/public/img/logo-marianne.png new file mode 100644 index 0000000000..786e3d61f4 Binary files /dev/null and b/.jjconflict-base-1/apps/client/public/img/logo-marianne.png differ diff --git a/.jjconflict-base-1/apps/client/public/img/nexus.png b/.jjconflict-base-1/apps/client/public/img/nexus.png new file mode 100644 index 0000000000..809e5fbe1b Binary files /dev/null and b/.jjconflict-base-1/apps/client/public/img/nexus.png differ diff --git a/.jjconflict-base-1/apps/client/public/img/sonarqube.svg b/.jjconflict-base-1/apps/client/public/img/sonarqube.svg new file mode 100644 index 0000000000..c6bb30720f --- /dev/null +++ b/.jjconflict-base-1/apps/client/public/img/sonarqube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.jjconflict-base-1/apps/client/public/img/vault.svg b/.jjconflict-base-1/apps/client/public/img/vault.svg new file mode 100644 index 0000000000..d5be8c74d2 --- /dev/null +++ b/.jjconflict-base-1/apps/client/public/img/vault.svg @@ -0,0 +1 @@ +Vault \ No newline at end of file diff --git a/.jjconflict-base-1/apps/client/public/manifest.json b/.jjconflict-base-1/apps/client/public/manifest.json new file mode 100644 index 0000000000..a99d9409c7 --- /dev/null +++ b/.jjconflict-base-1/apps/client/public/manifest.json @@ -0,0 +1,41 @@ +{ + "name": "VueDsfr App", + "icons": [ + { + "src": "/icons/android-icon-36x36.png", + "sizes": "36x36", + "type": "image/png", + "density": "0.75" + }, + { + "src": "/icons/android-icon-48x48.png", + "sizes": "48x48", + "type": "image/png", + "density": "1.0" + }, + { + "src": "/icons/android-icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "density": "1.5" + }, + { + "src": "/icons/android-icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "density": "2.0" + }, + { + "src": "/icons/android-icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "density": "3.0" + }, + { + "src": "/icons/android-icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "density": "4.0" + } + ] +} diff --git a/.jjconflict-base-1/apps/client/scripts/create-collections.js b/.jjconflict-base-1/apps/client/scripts/create-collections.js new file mode 100644 index 0000000000..da1b4a6ef3 --- /dev/null +++ b/.jjconflict-base-1/apps/client/scripts/create-collections.js @@ -0,0 +1,18 @@ +#!env node +import path from 'node:path' + +import { createCustomCollectionFile } from '@gouvminint/vue-dsfr/meta' +import { Command } from 'commander' + +const program = new Command() + +program + .option('-s, --source ', 'Chemin vers le fichier de tuples [IconifyJSON, string[]]') + .option('-t, --target ', 'Chemin vers le fichier destination (src/icons.ts par défaut)') + .parse(process.argv) + +const options = program.opts() + +if (options.source && options.target) { + createCustomCollectionFile(path.resolve(process.cwd(), options.source), path.resolve(process.cwd(), options.target)) +} diff --git a/.jjconflict-base-1/apps/client/scripts/icons.js b/.jjconflict-base-1/apps/client/scripts/icons.js new file mode 100644 index 0000000000..c275232c2a --- /dev/null +++ b/.jjconflict-base-1/apps/client/scripts/icons.js @@ -0,0 +1,75 @@ +// @ts-check +import { icons as riCollection } from '@iconify-json/ri' + +/** + * @type {string[]} + */ +const riIconNames = [ + 'account-circle-line', + 'add-line', + 'admin-line', + 'arrow-drop-left-line', + 'arrow-drop-right-line', + 'arrow-go-back-line', + 'arrow-left-double-line', + 'arrow-right-double-line', + 'arrow-right-line', + 'arrow-right-s-line', + 'award-line', + 'building-line', + 'checkbox-blank-circle-fill', + 'checkbox-circle-line', + 'clipboard-line', + 'close-line', + 'code-s-slash-line', + 'dashboard-line', + 'delete-bin-7-line', + 'exchange-line', + 'eye-line', + 'eye-off-line', + 'file-download-line', + 'file-info-line', + 'filter-line', + 'filter-off-line', + 'flow-chart', + 'focus-3-line', + 'folder-line', + 'folder-shield-2-line', + 'folders-line', + 'folder-user-line', + 'github-line', + 'git-merge-line', + 'git-repository-private-line', + 'global-line', + 'key-2-line', + 'loader-4-line', + 'lock-line', + 'lock-unlock-line', + 'mail-line', + 'moon-clear-line', + 'newspaper-line', + 'pencil-line', + 'question-line', + 'refresh-line', + 'restart-line', + 'send-plane-line', + 'server-line', + 'settings-3-line', + 'shapes-line', + 'shield-check-line', + 'sound-module-line', + 'stock-line', + 'sun-line', + 'team-line', + 'tools-line', + 'upload-cloud-line', + 'user-add-line', + 'user-shared-2-line', +] + +/** + * @type {[import('@iconify/vue').IconifyJSON, string[]][]} + */ +export const collectionsToFilter = [ + [riCollection, riIconNames], +] diff --git a/.jjconflict-base-1/apps/client/src/App.vue b/.jjconflict-base-1/apps/client/src/App.vue new file mode 100644 index 0000000000..91e0e48742 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/App.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/api/xhr-client.ts b/.jjconflict-base-1/apps/client/src/api/xhr-client.ts new file mode 100644 index 0000000000..427c6b36ba --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/api/xhr-client.ts @@ -0,0 +1,57 @@ +import type { ApiFetcherArgs } from '@ts-rest/core' +import { logger } from '@cpn-console/logger/browser' +import { apiPrefix, getApiClient } from '@cpn-console/shared' +import { tsRestFetchApi } from '@ts-rest/core' +import { getKeycloak } from '@/utils/keycloak/keycloak.js' + +export const apiClient = await getApiClient( + '', + {}, + async (args: ApiFetcherArgs): Promise<{ status: number, body: { status: number, error: string } | unknown, headers: Headers }> => { + // Paths that do not require token + const validPaths = [`${apiPrefix}/version`, '/login', `${apiPrefix}/services`] + if (validPaths.some(validPath => args.path?.startsWith(validPath))) { + return tsRestFetchApi(args) + } + + // Envs that do not require token + if (process.env.NODE_ENV === 'test' && process.env.CT === 'true') { + return tsRestFetchApi(args) + } + + // Generate token + const keycloak = getKeycloak() + const token = keycloak.token + if (token) { + args.headers.Authorization = `Bearer ${token}` + } + + return tsRestFetchApi(args) + }, +) + +export function extractData(response: T, expectedStatus: S): Extract['body'] { + if (response.status === 401) { + const keycloak = getKeycloak() + if (!keycloak.authenticated) { + keycloak.login() + } + } + if (response.status >= 400) { + // `message` d'abord : les erreurs gérées du legacy (ErrorResType) et celles de NestJS + // (HttpException) portent le vrai message dans `message` — chez NestJS, `error` ne contient + // que la raison HTTP générique ("Forbidden", "Bad Request"). `error` reste en repli pour + // les erreurs non gérées du legacy (setErrorHandler), qui n'ont pas de champ `message`. + // @ts-ignore + throw new Error(response.body?.message ?? response.body?.error ?? 'Erreur inconnue') + } + if (response.status === expectedStatus) return response.body + try { + throw new Error(`Erreur lors de la requete, reçu ${response.status}, attendu ${expectedStatus}`) + } catch (error: unknown) { + if (error instanceof Error) { + logger.error({ err: error }, 'Request failed') + } + throw error + } +} diff --git a/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Bold.otf b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Bold.otf new file mode 100755 index 0000000000..4a051bcd7a Binary files /dev/null and b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Bold.otf differ diff --git a/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-BoldItalic.otf b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-BoldItalic.otf new file mode 100755 index 0000000000..d24f18d012 Binary files /dev/null and b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-BoldItalic.otf differ diff --git a/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-ExtraBold.otf b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-ExtraBold.otf new file mode 100755 index 0000000000..77b5d8ad1d Binary files /dev/null and b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-ExtraBold.otf differ diff --git a/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf new file mode 100755 index 0000000000..b5a48d1946 Binary files /dev/null and b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf differ diff --git a/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Light.otf b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Light.otf new file mode 100755 index 0000000000..5a7b8aad90 Binary files /dev/null and b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Light.otf differ diff --git a/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-LightItalic.otf b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-LightItalic.otf new file mode 100755 index 0000000000..15747f0a79 Binary files /dev/null and b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-LightItalic.otf differ diff --git a/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Medium.otf b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Medium.otf new file mode 100755 index 0000000000..cd15454d1b Binary files /dev/null and b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Medium.otf differ diff --git a/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-MediumItalic.otf b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-MediumItalic.otf new file mode 100755 index 0000000000..d7069ddb95 Binary files /dev/null and b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-MediumItalic.otf differ diff --git a/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Regular.otf b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Regular.otf new file mode 100755 index 0000000000..b8aa87d247 Binary files /dev/null and b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Regular.otf differ diff --git a/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-RegularItalic.otf b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-RegularItalic.otf new file mode 100755 index 0000000000..e0b98c8956 Binary files /dev/null and b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-RegularItalic.otf differ diff --git a/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Thin.otf b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Thin.otf new file mode 100755 index 0000000000..055d889cab Binary files /dev/null and b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-Thin.otf differ diff --git a/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-ThinItalic.otf b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-ThinItalic.otf new file mode 100755 index 0000000000..23fe8e8a97 Binary files /dev/null and b/.jjconflict-base-1/apps/client/src/assets/fonts/Marianne-ThinItalic.otf differ diff --git a/.jjconflict-base-1/apps/client/src/auto-imports.d.ts b/.jjconflict-base-1/apps/client/src/auto-imports.d.ts new file mode 100644 index 0000000000..4372345fd5 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/auto-imports.d.ts @@ -0,0 +1,173 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + const EffectScope: typeof import('vue')['EffectScope'] + const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] + const computed: typeof import('vue')['computed'] + const createApp: typeof import('vue')['createApp'] + const createPinia: typeof import('pinia')['createPinia'] + const customRef: typeof import('vue')['customRef'] + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] + const defineComponent: typeof import('vue')['defineComponent'] + const defineStore: typeof import('pinia')['defineStore'] + const effectScope: typeof import('vue')['effectScope'] + const getActivePinia: typeof import('pinia')['getActivePinia'] + const getCurrentInstance: typeof import('vue')['getCurrentInstance'] + const getCurrentScope: typeof import('vue')['getCurrentScope'] + const h: typeof import('vue')['h'] + const inject: typeof import('vue')['inject'] + const isProxy: typeof import('vue')['isProxy'] + const isReactive: typeof import('vue')['isReactive'] + const isReadonly: typeof import('vue')['isReadonly'] + const isRef: typeof import('vue')['isRef'] + const mapActions: typeof import('pinia')['mapActions'] + const mapGetters: typeof import('pinia')['mapGetters'] + const mapState: typeof import('pinia')['mapState'] + const mapStores: typeof import('pinia')['mapStores'] + const mapWritableState: typeof import('pinia')['mapWritableState'] + const markRaw: typeof import('vue')['markRaw'] + const nextTick: typeof import('vue')['nextTick'] + const onActivated: typeof import('vue')['onActivated'] + const onBeforeMount: typeof import('vue')['onBeforeMount'] + const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave'] + const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate'] + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] + const onDeactivated: typeof import('vue')['onDeactivated'] + const onErrorCaptured: typeof import('vue')['onErrorCaptured'] + const onMounted: typeof import('vue')['onMounted'] + const onRenderTracked: typeof import('vue')['onRenderTracked'] + const onRenderTriggered: typeof import('vue')['onRenderTriggered'] + const onScopeDispose: typeof import('vue')['onScopeDispose'] + const onServerPrefetch: typeof import('vue')['onServerPrefetch'] + const onUnmounted: typeof import('vue')['onUnmounted'] + const onUpdated: typeof import('vue')['onUpdated'] + const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] + const provide: typeof import('vue')['provide'] + const reactive: typeof import('vue')['reactive'] + const readonly: typeof import('vue')['readonly'] + const ref: typeof import('vue')['ref'] + const resolveComponent: typeof import('vue')['resolveComponent'] + const setActivePinia: typeof import('pinia')['setActivePinia'] + const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] + const shallowReactive: typeof import('vue')['shallowReactive'] + const shallowReadonly: typeof import('vue')['shallowReadonly'] + const shallowRef: typeof import('vue')['shallowRef'] + const storeToRefs: typeof import('pinia')['storeToRefs'] + const toRaw: typeof import('vue')['toRaw'] + const toRef: typeof import('vue')['toRef'] + const toRefs: typeof import('vue')['toRefs'] + const toValue: typeof import('vue')['toValue'] + const triggerRef: typeof import('vue')['triggerRef'] + const unref: typeof import('vue')['unref'] + const useAttrs: typeof import('vue')['useAttrs'] + const useCssModule: typeof import('vue')['useCssModule'] + const useCssVars: typeof import('vue')['useCssVars'] + const useId: typeof import('vue')['useId'] + const useLink: typeof import('vue-router')['useLink'] + const useModel: typeof import('vue')['useModel'] + const useRoute: typeof import('vue-router')['useRoute'] + const useRouter: typeof import('vue-router')['useRouter'] + const useScheme: typeof import('@gouvminint/vue-dsfr')['useScheme'] + const useSlots: typeof import('vue')['useSlots'] + const useTabs: typeof import('@gouvminint/vue-dsfr')['useTabs'] + const useTemplateRef: typeof import('vue')['useTemplateRef'] + const watch: typeof import('vue')['watch'] + const watchEffect: typeof import('vue')['watchEffect'] + const watchPostEffect: typeof import('vue')['watchPostEffect'] + const watchSyncEffect: typeof import('vue')['watchSyncEffect'] +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') +} + +// for vue template auto import +import { UnwrapRef } from 'vue' +declare module 'vue' { + interface GlobalComponents {} + interface ComponentCustomProperties { + readonly EffectScope: UnwrapRef + readonly acceptHMRUpdate: UnwrapRef + readonly computed: UnwrapRef + readonly createApp: UnwrapRef + readonly createPinia: UnwrapRef + readonly customRef: UnwrapRef + readonly defineAsyncComponent: UnwrapRef + readonly defineComponent: UnwrapRef + readonly defineStore: UnwrapRef + readonly effectScope: UnwrapRef + readonly getActivePinia: UnwrapRef + readonly getCurrentInstance: UnwrapRef + readonly getCurrentScope: UnwrapRef + readonly h: UnwrapRef + readonly inject: UnwrapRef + readonly isProxy: UnwrapRef + readonly isReactive: UnwrapRef + readonly isReadonly: UnwrapRef + readonly isRef: UnwrapRef + readonly mapActions: UnwrapRef + readonly mapGetters: UnwrapRef + readonly mapState: UnwrapRef + readonly mapStores: UnwrapRef + readonly mapWritableState: UnwrapRef + readonly markRaw: UnwrapRef + readonly nextTick: UnwrapRef + readonly onActivated: UnwrapRef + readonly onBeforeMount: UnwrapRef + readonly onBeforeRouteLeave: UnwrapRef + readonly onBeforeRouteUpdate: UnwrapRef + readonly onBeforeUnmount: UnwrapRef + readonly onBeforeUpdate: UnwrapRef + readonly onDeactivated: UnwrapRef + readonly onErrorCaptured: UnwrapRef + readonly onMounted: UnwrapRef + readonly onRenderTracked: UnwrapRef + readonly onRenderTriggered: UnwrapRef + readonly onScopeDispose: UnwrapRef + readonly onServerPrefetch: UnwrapRef + readonly onUnmounted: UnwrapRef + readonly onUpdated: UnwrapRef + readonly onWatcherCleanup: UnwrapRef + readonly provide: UnwrapRef + readonly reactive: UnwrapRef + readonly readonly: UnwrapRef + readonly ref: UnwrapRef + readonly resolveComponent: UnwrapRef + readonly setActivePinia: UnwrapRef + readonly setMapStoreSuffix: UnwrapRef + readonly shallowReactive: UnwrapRef + readonly shallowReadonly: UnwrapRef + readonly shallowRef: UnwrapRef + readonly storeToRefs: UnwrapRef + readonly toRaw: UnwrapRef + readonly toRef: UnwrapRef + readonly toRefs: UnwrapRef + readonly toValue: UnwrapRef + readonly triggerRef: UnwrapRef + readonly unref: UnwrapRef + readonly useAttrs: UnwrapRef + readonly useCssModule: UnwrapRef + readonly useCssVars: UnwrapRef + readonly useId: UnwrapRef + readonly useLink: UnwrapRef + readonly useModel: UnwrapRef + readonly useRoute: UnwrapRef + readonly useRouter: UnwrapRef + readonly useScheme: UnwrapRef + readonly useSlots: UnwrapRef + readonly useTabs: UnwrapRef + readonly useTemplateRef: UnwrapRef + readonly watch: UnwrapRef + readonly watchEffect: UnwrapRef + readonly watchPostEffect: UnwrapRef + readonly watchSyncEffect: UnwrapRef + } +} \ No newline at end of file diff --git a/.jjconflict-base-1/apps/client/src/components/AdminRoleForm.vue b/.jjconflict-base-1/apps/client/src/components/AdminRoleForm.vue new file mode 100644 index 0000000000..bf129c8ad7 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/AdminRoleForm.vue @@ -0,0 +1,304 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/Badge.vue b/.jjconflict-base-1/apps/client/src/components/Badge.vue new file mode 100644 index 0000000000..1dd98c5cda --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/Badge.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ChoiceSelector.vue b/.jjconflict-base-1/apps/client/src/components/ChoiceSelector.vue new file mode 100644 index 0000000000..b615ab6750 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ChoiceSelector.vue @@ -0,0 +1,272 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ClusterForm.vue b/.jjconflict-base-1/apps/client/src/components/ClusterForm.vue new file mode 100644 index 0000000000..81f51ebcaf --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ClusterForm.vue @@ -0,0 +1,532 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ConfigParam.vue b/.jjconflict-base-1/apps/client/src/components/ConfigParam.vue new file mode 100644 index 0000000000..b36d3820c7 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ConfigParam.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/components/DeleteForm.vue b/.jjconflict-base-1/apps/client/src/components/DeleteForm.vue new file mode 100644 index 0000000000..3716065a14 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/DeleteForm.vue @@ -0,0 +1,73 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/DeploymentCard.vue b/.jjconflict-base-1/apps/client/src/components/DeploymentCard.vue new file mode 100644 index 0000000000..cceb650e6f --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/DeploymentCard.vue @@ -0,0 +1,46 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/DeploymentModal.vue b/.jjconflict-base-1/apps/client/src/components/DeploymentModal.vue new file mode 100644 index 0000000000..2323b7cea9 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/DeploymentModal.vue @@ -0,0 +1,182 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/DeploymentRepoOption.vue b/.jjconflict-base-1/apps/client/src/components/DeploymentRepoOption.vue new file mode 100644 index 0000000000..0aba4f3fc3 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/DeploymentRepoOption.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/components/DeploymentRepoSelect.vue b/.jjconflict-base-1/apps/client/src/components/DeploymentRepoSelect.vue new file mode 100644 index 0000000000..9af01fb8c6 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/DeploymentRepoSelect.vue @@ -0,0 +1,65 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/DeploymentResources.vue b/.jjconflict-base-1/apps/client/src/components/DeploymentResources.vue new file mode 100644 index 0000000000..948c34c059 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/DeploymentResources.vue @@ -0,0 +1,96 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/DsoBadge.vue b/.jjconflict-base-1/apps/client/src/components/DsoBadge.vue new file mode 100644 index 0000000000..0f33785b50 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/DsoBadge.vue @@ -0,0 +1,52 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/DsoSnackbar.vue b/.jjconflict-base-1/apps/client/src/components/DsoSnackbar.vue new file mode 100644 index 0000000000..a91ff46ee8 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/DsoSnackbar.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/components/EnvironmentForm.vue b/.jjconflict-base-1/apps/client/src/components/EnvironmentForm.vue new file mode 100644 index 0000000000..1035945aca --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/EnvironmentForm.vue @@ -0,0 +1,396 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ErrorGoBackToProjects.vue b/.jjconflict-base-1/apps/client/src/components/ErrorGoBackToProjects.vue new file mode 100644 index 0000000000..3dc6c155f1 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ErrorGoBackToProjects.vue @@ -0,0 +1,25 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/Loader.vue b/.jjconflict-base-1/apps/client/src/components/Loader.vue new file mode 100644 index 0000000000..5ba76adde9 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/Loader.vue @@ -0,0 +1,21 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/LoadingCt.vue b/.jjconflict-base-1/apps/client/src/components/LoadingCt.vue new file mode 100644 index 0000000000..a36af47cc3 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/LoadingCt.vue @@ -0,0 +1,29 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/LogsViewer.vue b/.jjconflict-base-1/apps/client/src/components/LogsViewer.vue new file mode 100644 index 0000000000..2ce24862f4 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/LogsViewer.vue @@ -0,0 +1,218 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/OperationPanel.vue b/.jjconflict-base-1/apps/client/src/components/OperationPanel.vue new file mode 100644 index 0000000000..e308f41fb7 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/OperationPanel.vue @@ -0,0 +1,36 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/PaginationCt.vue b/.jjconflict-base-1/apps/client/src/components/PaginationCt.vue new file mode 100644 index 0000000000..ef0e1e2c73 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/PaginationCt.vue @@ -0,0 +1,74 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ProjectAction.vue b/.jjconflict-base-1/apps/client/src/components/ProjectAction.vue new file mode 100644 index 0000000000..e05dc0bbd2 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ProjectAction.vue @@ -0,0 +1,51 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ProjectArchiveButton.vue b/.jjconflict-base-1/apps/client/src/components/ProjectArchiveButton.vue new file mode 100644 index 0000000000..1d9fe1b0f5 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ProjectArchiveButton.vue @@ -0,0 +1,74 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ProjectBanner.vue b/.jjconflict-base-1/apps/client/src/components/ProjectBanner.vue new file mode 100644 index 0000000000..05ab11ea11 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ProjectBanner.vue @@ -0,0 +1,185 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ProjectClustersInfos.vue b/.jjconflict-base-1/apps/client/src/components/ProjectClustersInfos.vue new file mode 100644 index 0000000000..d7af0874ed --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ProjectClustersInfos.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ProjectLockButton.vue b/.jjconflict-base-1/apps/client/src/components/ProjectLockButton.vue new file mode 100644 index 0000000000..ac700f5cc4 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ProjectLockButton.vue @@ -0,0 +1,32 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ProjectLogsViewer.vue b/.jjconflict-base-1/apps/client/src/components/ProjectLogsViewer.vue new file mode 100644 index 0000000000..fba2129414 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ProjectLogsViewer.vue @@ -0,0 +1,47 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ProjectReplayButton.vue b/.jjconflict-base-1/apps/client/src/components/ProjectReplayButton.vue new file mode 100644 index 0000000000..efb2a90886 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ProjectReplayButton.vue @@ -0,0 +1,62 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ProjectResources.vue b/.jjconflict-base-1/apps/client/src/components/ProjectResources.vue new file mode 100644 index 0000000000..0c0d25e2a5 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ProjectResources.vue @@ -0,0 +1,478 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ProjectRoleForm.vue b/.jjconflict-base-1/apps/client/src/components/ProjectRoleForm.vue new file mode 100644 index 0000000000..d20f31c5d9 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ProjectRoleForm.vue @@ -0,0 +1,201 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ProjectRoles.vue b/.jjconflict-base-1/apps/client/src/components/ProjectRoles.vue new file mode 100644 index 0000000000..5760069994 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ProjectRoles.vue @@ -0,0 +1,162 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ProjectSecretsButton.vue b/.jjconflict-base-1/apps/client/src/components/ProjectSecretsButton.vue new file mode 100644 index 0000000000..05f1f423e4 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ProjectSecretsButton.vue @@ -0,0 +1,93 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ProjectSettings.vue b/.jjconflict-base-1/apps/client/src/components/ProjectSettings.vue new file mode 100644 index 0000000000..4bcd1dc66a --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ProjectSettings.vue @@ -0,0 +1,164 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ReloadPrompt.vue b/.jjconflict-base-1/apps/client/src/components/ReloadPrompt.vue new file mode 100644 index 0000000000..aa914e8ecb --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ReloadPrompt.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/components/RepoForm.vue b/.jjconflict-base-1/apps/client/src/components/RepoForm.vue new file mode 100644 index 0000000000..0a38b379cd --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/RepoForm.vue @@ -0,0 +1,397 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/SelectProject.vue b/.jjconflict-base-1/apps/client/src/components/SelectProject.vue new file mode 100644 index 0000000000..5c98b13e21 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/SelectProject.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ServiceChainForm.vue b/.jjconflict-base-1/apps/client/src/components/ServiceChainForm.vue new file mode 100644 index 0000000000..eec5f94223 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ServiceChainForm.vue @@ -0,0 +1,423 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ServicesConfig.vue b/.jjconflict-base-1/apps/client/src/components/ServicesConfig.vue new file mode 100644 index 0000000000..1cd4a36f9b --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ServicesConfig.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/components/SideMenu.vue b/.jjconflict-base-1/apps/client/src/components/SideMenu.vue new file mode 100644 index 0000000000..ecde0c9aef --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/SideMenu.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/components/StageForm.vue b/.jjconflict-base-1/apps/client/src/components/StageForm.vue new file mode 100644 index 0000000000..afca19b513 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/StageForm.vue @@ -0,0 +1,212 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/SuggestionInput.vue b/.jjconflict-base-1/apps/client/src/components/SuggestionInput.vue new file mode 100644 index 0000000000..fb704e409a --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/SuggestionInput.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/components/TeamCt.vue b/.jjconflict-base-1/apps/client/src/components/TeamCt.vue new file mode 100644 index 0000000000..d850569e77 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/TeamCt.vue @@ -0,0 +1,259 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/TokenForm.vue b/.jjconflict-base-1/apps/client/src/components/TokenForm.vue new file mode 100644 index 0000000000..2d743348ec --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/TokenForm.vue @@ -0,0 +1,159 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/components/ZoneForm.vue b/.jjconflict-base-1/apps/client/src/components/ZoneForm.vue new file mode 100644 index 0000000000..57c0b8db5d --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/components/ZoneForm.vue @@ -0,0 +1,225 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/env.d.ts b/.jjconflict-base-1/apps/client/src/env.d.ts new file mode 100644 index 0000000000..c725a2cc66 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/env.d.ts @@ -0,0 +1,37 @@ +/// +/// + +declare module 'virtual:pwa-register/vue' { + import type { Ref } from 'vue' + import type { RegisterSWOptions } from 'vite-plugin-pwa/types' + + export type { RegisterSWOptions } + + export function useRegisterSW(options?: RegisterSWOptions): { + needRefresh: Ref + offlineReady: Ref + updateServiceWorker: (reloadPage?: boolean) => Promise + } +} + +declare module 'vue3-json-viewer' { + import { AllowedComponentProps, App, Component, ComponentCustomProps, VNodeProps } from 'vue'; + + interface JsonViewerProps { + value: Record | Array | string | number | boolean; + expanded: boolean; + expandDepth: number; + copyable: boolean | object; + sort: boolean; + boxed: boolean; + theme: string; //"dark" | "light" + previewMode: boolean; + timeformat: (value: any) => string; + } + + type JsonViewerType = JsonViewerProps & VNodeProps & AllowedComponentProps & ComponentCustomProps; + const JsonViewer: Component; + export { JsonViewer }; + const def: { install: (app: App) => void }; + export default def; +} diff --git a/.jjconflict-base-1/apps/client/src/icon-collections.ts b/.jjconflict-base-1/apps/client/src/icon-collections.ts new file mode 100644 index 0000000000..de8e82fe94 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/icon-collections.ts @@ -0,0 +1,5 @@ +import type { IconifyJSON } from '@iconify/vue' + +const collections: IconifyJSON[] = [{ prefix: 'ri', icons: { 'account-circle-line': { body: '' }, 'add-line': { body: '' }, 'admin-line': { body: '' }, 'arrow-drop-left-line': { body: '' }, 'arrow-drop-right-line': { body: '' }, 'arrow-go-back-line': { body: '' }, 'arrow-left-double-line': { body: '' }, 'arrow-right-double-line': { body: '' }, 'arrow-right-line': { body: '' }, 'arrow-right-s-line': { body: '' }, 'award-line': { body: '' }, 'building-line': { body: '' }, 'checkbox-blank-circle-fill': { body: '' }, 'checkbox-circle-line': { body: '' }, 'clipboard-line': { body: '' }, 'close-line': { body: '' }, 'code-s-slash-line': { body: '' }, 'dashboard-line': { body: '' }, 'delete-bin-7-line': { body: '' }, 'exchange-line': { body: '' }, 'eye-line': { body: '' }, 'eye-off-line': { body: '' }, 'file-download-line': { body: '' }, 'file-info-line': { body: '' }, 'filter-line': { body: '' }, 'filter-off-line': { body: '' }, 'flow-chart': { body: '' }, 'focus-3-line': { body: '' }, 'folder-line': { body: '' }, 'folder-shield-2-line': { body: '' }, 'folder-user-line': { body: '' }, 'folders-line': { body: '' }, 'git-merge-line': { body: '' }, 'git-repository-private-line': { body: '' }, 'github-line': { body: '' }, 'global-line': { body: '' }, 'key-2-line': { body: '' }, 'loader-4-line': { body: '' }, 'lock-line': { body: '' }, 'lock-unlock-line': { body: '' }, 'mail-line': { body: '' }, 'moon-clear-line': { body: '' }, 'newspaper-line': { body: '' }, 'pencil-line': { body: '' }, 'question-line': { body: '' }, 'refresh-line': { body: '' }, 'restart-line': { body: '' }, 'send-plane-line': { body: '' }, 'server-line': { body: '' }, 'settings-3-line': { body: '' }, 'shapes-line': { body: '' }, 'shield-check-line': { body: '' }, 'sound-module-line': { body: '' }, 'stock-line': { body: '' }, 'sun-line': { body: '' }, 'team-line': { body: '' }, 'tools-line': { body: '' }, 'upload-cloud-line': { body: '' }, 'user-add-line': { body: '' }, 'user-shared-2-line': { body: '' } }, width: 24, height: 24 }] +export const ri = { accountCircleLine: 'ri:account-circle-line', addLine: 'ri:add-line', adminLine: 'ri:admin-line', arrowDropLeftLine: 'ri:arrow-drop-left-line', arrowDropRightLine: 'ri:arrow-drop-right-line', arrowGoBackLine: 'ri:arrow-go-back-line', arrowLeftDoubleLine: 'ri:arrow-left-double-line', arrowRightDoubleLine: 'ri:arrow-right-double-line', arrowRightLine: 'ri:arrow-right-line', arrowRightSLine: 'ri:arrow-right-s-line', awardLine: 'ri:award-line', buildingLine: 'ri:building-line', checkboxBlankCircleFill: 'ri:checkbox-blank-circle-fill', checkboxCircleLine: 'ri:checkbox-circle-line', clipboardLine: 'ri:clipboard-line', closeLine: 'ri:close-line', codeSSlashLine: 'ri:code-s-slash-line', dashboardLine: 'ri:dashboard-line', deleteBin7Line: 'ri:delete-bin-7-line', exchangeLine: 'ri:exchange-line', eyeLine: 'ri:eye-line', eyeOffLine: 'ri:eye-off-line', fileDownloadLine: 'ri:file-download-line', fileInfoLine: 'ri:file-info-line', filterLine: 'ri:filter-line', filterOffLine: 'ri:filter-off-line', flowChart: 'ri:flow-chart', focus3Line: 'ri:focus-3-line', folderLine: 'ri:folder-line', folderShield2Line: 'ri:folder-shield-2-line', folderUserLine: 'ri:folder-user-line', foldersLine: 'ri:folders-line', gitMergeLine: 'ri:git-merge-line', gitRepositoryPrivateLine: 'ri:git-repository-private-line', githubLine: 'ri:github-line', globalLine: 'ri:global-line', key2Line: 'ri:key-2-line', loader4Line: 'ri:loader-4-line', lockLine: 'ri:lock-line', lockUnlockLine: 'ri:lock-unlock-line', mailLine: 'ri:mail-line', moonClearLine: 'ri:moon-clear-line', newspaperLine: 'ri:newspaper-line', pencilLine: 'ri:pencil-line', questionLine: 'ri:question-line', refreshLine: 'ri:refresh-line', restartLine: 'ri:restart-line', sendPlaneLine: 'ri:send-plane-line', serverLine: 'ri:server-line', settings3Line: 'ri:settings-3-line', shapesLine: 'ri:shapes-line', shieldCheckLine: 'ri:shield-check-line', soundModuleLine: 'ri:sound-module-line', stockLine: 'ri:stock-line', sunLine: 'ri:sun-line', teamLine: 'ri:team-line', toolsLine: 'ri:tools-line', uploadCloudLine: 'ri:upload-cloud-line', userAddLine: 'ri:user-add-line', userShared2Line: 'ri:user-shared-2-line' } as const +export default collections diff --git a/.jjconflict-base-1/apps/client/src/main.css b/.jjconflict-base-1/apps/client/src/main.css new file mode 100644 index 0000000000..58f1adca7f --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/main.css @@ -0,0 +1,194 @@ +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-Regular.otf'); +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-RegularItalic.otf'); + font-style: italic; +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-Bold.otf'); + font-weight: 700; +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-BoldItalic.otf'); + font-weight: 700; + font-style: italic; +} + + +html, +body, +#app { + font-family: Marianne, sans-serif; + height: 100%; +} + +.h-full { + height: 100%; +} + +.disabled { + color: var(--text-disabled-grey); + cursor: not-allowed; +} + +.fr-fieldset { + margin: 1rem 0 0; + align-items: flex-start; + min-width: 0; +} + +.fr-checkbox-group { + margin-bottom: 1rem; +} + +.dso-footer.fr-footer { + box-shadow: inset 0 2px 0 0 var(--border-plain-blue-france); +} + +.dso-footer .fr-footer__bottom { + display: none; +} + +.dso-footer .fr-footer__content-desc { + text-align: right; + margin-right: 25px; +} + +.danger-zone { + border-bottom: solid var(--border-plain-warning); + margin-top: 2rem; + padding: 2rem; + background-color: var(--background-alt-grey); +} + +.fr-alert { + background-color: var(--background-alt-grey); +} + +.fr-alert pre { + white-space: normal; +} + +.log-panel, .log-btn { + background-color: var(--background-default-grey); +} + +.log-panel { + box-shadow: inset 0 0 0 1px var(--border-action-high-blue-france); +} + +.log-box--success{ + border-color: var(--background-contrast-success); +} + +.log-box--warning{ + border-color: var(--background-contrast-warning); +} + +.log-box--error{ + border-color: var(--background-contrast-error); +} + +.danger-zone-btns { + @apply flex justify-between items-center gap-4 { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + describe('detect project slug helper', () => { + let projectStore: ReturnType + let slug: string + let uuid: string + + beforeEach(() => { + projectStore = useProjectStore() + slug = faker.lorem.slug() + uuid = faker.string.uuid() + const recent = faker.date.recent() + const ownerId = faker.string.uuid() + const project: ProjectV2 = { + id: uuid, + clusterIds: [], + description: '', + everyonePerms: '0', + name: slug, + slug, + locked: false, + owner: { + type: 'human', + id: ownerId, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + createdAt: recent.toString(), + updatedAt: recent.toString(), + lastLogin: recent.toString(), + }, + ownerId, + roles: [], + members: [], + createdAt: recent.toString(), + updatedAt: recent.toString(), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + status: 'created', + lastSuccessProvisionningVersion: null, + } + projectStore.updateStore([project]) + }) + + it('it should return project\'slug with uuid passed', () => { + const slugFound = detectProjectslug({ + params: { + slug: uuid, + }, + }) + expect(slugFound).toEqual(slug) + }) + + it('it should return project\'slug with slug passed', () => { + const slugFound = detectProjectslug({ + params: { + slug, + }, + }) + expect(slugFound).toEqual(slug) + }) + }) + + describe('navigation with real router instance', () => { + it('renders home and navigates to projects', async () => { + const router = createAppRouter('') + const userStore = useUserStore() + const systemStore = useSystemSettingsStore() + + // Ensure global guard does not redirect to /login + // by simulating an authenticated user. + userStore.isLoggedIn = true + userStore.setIsLoggedIn = async () => {} + systemStore.listSystemSettings = async () => {} + router.push('/') + await router.isReady() + + expect(router.currentRoute.value.name).toEqual('Home') + + await router.push('/projects') + await router.isReady() + + expect(router.currentRoute.value.matched.some(r => r.name === 'Projects')).toBe(true) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/client/src/router/index.ts b/.jjconflict-base-1/apps/client/src/router/index.ts new file mode 100644 index 0000000000..002e8421a5 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/router/index.ts @@ -0,0 +1,326 @@ +import type { + RouteLocationNormalizedGeneric, + RouteRecordRaw, +} from 'vue-router' +import { logger } from '@cpn-console/logger/browser' +import { AdminAuthorized, swaggerUiPath } from '@cpn-console/shared' +import { + createRouter, + createWebHistory, +} from 'vue-router' +import { useProjectStore } from '@/stores/project.js' +import { useSystemSettingsStore } from '@/stores/system-settings.js' + +import { useUserStore } from '@/stores/user.js' +import { uuid } from '@/utils/regex.js' +import DsoHome from '@/views/DsoHome.vue' +import NotFound from '@/views/NotFound.vue' + +const AdminCluster = () => import('@/views/admin/AdminCluster.vue') +const AdminServiceChain = () => import('@/views/admin/AdminServiceChain.vue') +const ServicesHealth = () => import('@/views/ServicesHealth.vue') +const CreateProject = () => import('@/views/CreateProject.vue') +const ProfileWrapper = () => import('@/views/profile/ProfileWrapper.vue') +const UserInfo = () => import('@/views/profile/UserInfo.vue') +const PersonalAccessTokens = () => import('@/views/profile/PersonalAccessTokens.vue') +const DsoProjects = () => import('@/views/projects/DsoProjects.vue') +const DsoAdmin = () => import('@/views/admin/DsoAdmin.vue') +const ListUser = () => import('@/views/admin/ListUser.vue') +const ProjectDashboard = () => import('@/views/ProjectDashboard.vue') +const ListProjects = () => import('@/views/admin/ListProjects.vue') +const ListLogs = () => import('@/views/admin/ListLogs.vue') +const AdminRoles = () => import('@/views/admin/AdminRoles.vue') +const ListClusters = () => import('@/views/admin/ListClusters.vue') +const ListServiceChains = () => import('@/views/admin/ListServiceChains.vue') +const ListStages = () => import('@/views/admin/ListStages.vue') +const ListZones = () => import('@/views/admin/ListZones.vue') +const ListPlugins = () => import('@/views/admin/ListPlugins.vue') +const SystemSettings = () => import('@/views/admin/SystemSettings.vue') +const Maintenance = () => import('@/views/DsoMaintenance.vue') +const AdminTokens = () => import('@/views/admin/AdminTokens.vue') + +const MAIN_TITLE = 'Console Cloud π Native' + +export function detectProjectslug(to: Pick) { + const slugParam = to.params.slug as string + return uuid.test(slugParam) + ? useProjectStore().projects.find(project => project.id === slugParam)?.slug + : slugParam +} + +export const routes: Readonly = [ + { + path: '/login', + name: 'Login', + beforeEnter: async (_to, _from) => { + const userStore = useUserStore() + await userStore.login() + }, + component: DsoProjects, + }, + { + path: '/logout', + name: 'Logout', + beforeEnter: async (_to, _from) => { + const userStore = useUserStore() + await userStore.logout() + }, + component: DsoHome, + }, + { + path: '/', + name: 'Home', + component: DsoHome, + }, + { + path: '/profile', + name: 'Profile', + component: ProfileWrapper, + children: [ + { + path: 'info', + name: 'UserInfo', + component: UserInfo, + }, + { + path: 'tokens', + name: 'PersonalAccessTokens', + component: PersonalAccessTokens, + }, + ], + }, + { + path: '/maintenance', + name: 'Maintenance', + component: Maintenance, + }, + { + path: '/services-health', + name: 'ServicesHealth', + component: ServicesHealth, + }, + + { + path: '/projects', + children: [ + { + path: '', + name: 'Projects', + component: DsoProjects, + }, + { + path: ':slug', + name: 'Project', + component: ProjectDashboard, + async beforeEnter(to, _from, next) { + if (typeof to.params.slug !== 'string') { + return next({ name: 'Projects' }) + } + if (!(to.params.slug in useProjectStore().projectsBySlug)) { + await useProjectStore().getProject(to.params.slug).catch(async () => { + logger.warn({ slug: to.params.slug }, 'Unable to find project information, redirecting to /projects') + return next({ name: 'Projects' }) + }) + } + useProjectStore().lastSelectedProjectSlug = to.params.slug + return next() + }, + props(to) { + return { + projectSlug: detectProjectslug(to), + parentRoute: 'Projects', + asProfile: 'user', + tab: to.query.tab, + } + }, + }, + ], + }, + { + path: '/create-project', + name: 'CreateProject', + component: CreateProject, + }, + { + name: 'ParentAdmin', + path: '/admin', + component: DsoAdmin, + children: [ + { + path: 'users', + name: 'ListUser', + component: ListUser, + }, + { + path: 'projects', + children: [ + { + path: '', + name: 'ListProjects', + component: ListProjects, + }, + { + path: ':slug', + name: 'AdminProject', + component: ProjectDashboard, + async beforeEnter(to, _from, next) { + if (typeof to.params.slug !== 'string') { + return next('/admin/projects') + } + await useProjectStore().getProject(to.params.slug) + return next() + }, + props(to) { + return { + projectSlug: detectProjectslug(to), + parentRoute: 'ListProjects', + asProfile: 'admin', + } + }, + }, + ], + }, + { + path: 'service-chains', + children: [ + { + path: '', + name: 'ListServiceChains', + component: ListServiceChains, + }, + { + path: ':id', + name: 'AdminServiceChain', + component: AdminServiceChain, + props(to) { + return { + id: to.params.id, + } + }, + }, + ], + }, + { + path: 'logs', + name: 'ListLogs', + component: ListLogs, + }, + { + path: 'clusters', + children: [ + { + path: '', + name: 'ListClusters', + component: ListClusters, + }, + { + path: ':id', + name: 'AdminCluster', + component: AdminCluster, + props(to) { + return { + id: to.params.id, + } + }, + }, + ], + }, + { + path: 'stages', + name: 'ListStages', + component: ListStages, + }, + { + path: 'zones', + name: 'ListZones', + component: ListZones, + }, + { + path: 'plugins', + name: 'ListPlugins', + component: ListPlugins, + }, + { + path: 'system-settings', + name: 'SystemSettings', + component: SystemSettings, + }, + { + path: 'roles', + name: 'AdminRoles', + component: AdminRoles, + }, + { + path: 'tokens', + name: 'AdminTokens', + component: AdminTokens, + }, + ], + }, + { + path: `${swaggerUiPath}-server`, + name: 'Swagger-server', + // no component, swagger plugin + components: {}, + strict: false, + }, + { + path: `${swaggerUiPath}-server-nestjs`, + name: 'Swagger-server-nestjs', + // no component, swagger plugin + components: {}, + strict: false, + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: NotFound, + }, +] + +export function createAppRouter(base?: string) { + const router = createRouter({ + history: createWebHistory(base ?? (import.meta.env?.BASE_URL || '')), + scrollBehavior: (to) => { if (to.hash && !to.hash.startsWith('#state=')) return ({ el: to.hash }) }, + routes, + }) + router.beforeEach((to) => { + const specificTitle = to.meta.title ? `${to.meta.title} - ` : '' + document.title = `${specificTitle}${MAIN_TITLE}` + }) + router.beforeEach(async (to, _from, next) => { + const validPath = new Set(['Login', 'Home', 'Doc', 'NotFound', 'ServicesHealth', 'Maintenance', 'Logout', 'Swagger-server', 'Swagger-server-nestjs']) + const userStore = useUserStore() + const systemStore = useSystemSettingsStore() + await userStore.setIsLoggedIn() + if ( + !validPath.has(to.name?.toString() ?? '') + && !userStore.isLoggedIn + ) { + return next('/login') + } + if (to.name === 'Login' && userStore.isLoggedIn) { + return next('/') + } + if ( + !validPath.has(to.name?.toString() ?? '') + && userStore.isLoggedIn + ) { + await systemStore.listSystemSettings() + if (systemStore.systemSettingsByKey.maintenance?.value === 'on' && !AdminAuthorized.Manage(userStore.adminPerms)) return next('/maintenance') + } + next() + }) + return router +} + +const router = createAppRouter() + +export const isInProject = computed(() => router.currentRoute.value.matched.some(route => route.name === 'Project')) +export const selectedProjectSlug = computed(() => { + if (router.currentRoute.value.matched.some(route => route.name === 'Project')) { + return router.currentRoute.value.params.slug as string + } + return undefined +}) +export default router diff --git a/.jjconflict-base-1/apps/client/src/stores/admin-role.ts b/.jjconflict-base-1/apps/client/src/stores/admin-role.ts new file mode 100644 index 0000000000..fa70004e0f --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/admin-role.ts @@ -0,0 +1,61 @@ +import type { AdminRole, adminRoleContract } from '@cpn-console/shared' +import { AdminAuthorized } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '../api/xhr-client.js' +import { useUserStore } from './user.js' + +export const useAdminRoleStore = defineStore('adminRole', () => { + const userStore = useUserStore() + const roles = ref([]) + const memberCounts = ref>({}) + + const countMembersRoles = async () => { + memberCounts.value + = await apiClient.AdminRoles.adminRoleMemberCounts().then((res: any) => + extractData(res, 200), + ) + return memberCounts.value + } + + const listRoles = async () => { + roles.value = await apiClient.AdminRoles.listAdminRoles().then((response: any) => + extractData(response, 200), + ) + if (AdminAuthorized.ListRoles(userStore.adminPerms)) { + await countMembersRoles() + } + return roles.value + } + + const createRole = async () => { + roles.value = await apiClient.AdminRoles.createAdminRole({ + body: { name: 'Nouveau rôle' }, + }).then((res: any) => extractData(res, 201)) + } + + const deleteRole = async (roleId: AdminRole['id']) => { + await apiClient.AdminRoles.deleteAdminRole({ params: { roleId } }).then( + (res: any) => extractData(res, 204), + ) + await listRoles() + } + + const patchRoles = async ( + body: typeof adminRoleContract.patchAdminRoles.body._type, + ) => { + roles.value = await apiClient.AdminRoles.patchAdminRoles({ + body, + }).then((res: any) => extractData(res, 200)) + } + + return { + roles, + memberCounts, + countMembersRoles, + listRoles, + createRole, + deleteRole, + patchRoles, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/admin-token.spec.ts b/.jjconflict-base-1/apps/client/src/stores/admin-token.spec.ts new file mode 100644 index 0000000000..96a91a1b94 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/admin-token.spec.ts @@ -0,0 +1,57 @@ +import type { ExposedAdminToken } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useAdminTokenStore } from './admin-token.js' + +const apiClientGet = vi.spyOn(apiClient.AdminTokens, 'listAdminTokens') +const apiClientPost = vi.spyOn(apiClient.AdminTokens, 'createAdminToken') +const apiClientDelete = vi.spyOn(apiClient.AdminTokens, 'deleteAdminToken') + +describe('cluster Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get tokens list by api call', async () => { + const data = [] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminTokenStore = useAdminTokenStore() + + await adminTokenStore.listTokens() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should add token by api call', async () => { + const data: ExposedAdminToken = { + name: 'test2', + expirationDate: null, + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + lastUse: null, + password: 'password', + permissions: '2', + } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: data })) + const adminTokenStore = useAdminTokenStore() + + const res = await adminTokenStore.createToken({ name: data.name, permissions: data.permissions }) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should delete token by api call', async () => { + const tokenId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204 })) + const adminTokenStore = useAdminTokenStore() + + await adminTokenStore.deleteToken(tokenId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/admin-token.ts b/.jjconflict-base-1/apps/client/src/stores/admin-token.ts new file mode 100644 index 0000000000..3b76dc2fe8 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/admin-token.ts @@ -0,0 +1,33 @@ +import type { AdminToken, adminTokenContract } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '../api/xhr-client.js' + +export const useAdminTokenStore = defineStore('adminToken', () => { + const listTokens = async ( + query: typeof adminTokenContract.listAdminTokens.query._type = {}, + ) => { + return apiClient.AdminTokens.listAdminTokens({ query }).then( + (response: any) => extractData(response, 200), + ) + } + + const createToken = async ( + body: typeof adminTokenContract.createAdminToken.body._type, + ) => { + return apiClient.AdminTokens.createAdminToken({ body }).then((res: any) => + extractData(res, 201), + ) + } + + const deleteToken = async (tokenId: AdminToken['id']) => { + await apiClient.AdminTokens.deleteAdminToken({ params: { tokenId } }).then( + (res: any) => extractData(res, 204), + ) + } + + return { + listTokens, + createToken, + deleteToken, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/cluster.spec.ts b/.jjconflict-base-1/apps/client/src/stores/cluster.spec.ts new file mode 100644 index 0000000000..903c45450e --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/cluster.spec.ts @@ -0,0 +1,128 @@ +import { ClusterPrivacy } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useClusterStore } from './cluster.js' + +const apiClientGet = vi.spyOn(apiClient.Clusters, 'getClusterDetails') +const apiClientGetClusterEnvironments = vi.spyOn(apiClient.Clusters, 'getClusterEnvironments') +const apiClientPost = vi.spyOn(apiClient.Clusters, 'createCluster') +const apiClientPut = vi.spyOn(apiClient.Clusters, 'updateCluster') +const apiClientDelete = vi.spyOn(apiClient.Clusters, 'deleteCluster') + +describe('cluster Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get clusters list by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b7', + label: 'cluster2', + infos: 'infos', + kubeconfig: { + cluster: { + tlsServerName: '8790044f-8414-569f-9c4a-2df42a879879', + }, + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + }, + clusterResources: true, + privacy: 'public', + } + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + await adminClusterStore.getClusterDetails(data.id) + + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should get cluster\'s associated environments by api call', async () => { + const clusterId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + const data = { + environments: [], + } + + apiClientGetClusterEnvironments.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.getClusterAssociatedEnvironments(clusterId) + + expect(res).toBe(data) + expect(apiClientGetClusterEnvironments).toHaveBeenCalledTimes(1) + }) + + it('should add cluster by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + label: 'cluster1', + projectIds: [ + '22e7044f-8414-435d-9c4a-2df42a65034b', + ], + kubeconfig: { + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + }, + clusterResources: true, + privacy: ClusterPrivacy.DEDICATED, + } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.addCluster(data) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update cluster by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + projectIds: [ + '22e7044f-8414-435d-9c4a-2df42a65034b', + ], + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + clusterResources: true, + privacy: ClusterPrivacy.DEDICATED, + } + apiClientPut.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.updateCluster(data) + + expect(res).toBe(data) + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete cluster by api call', async () => { + const clusterId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204 })) + const adminClusterStore = useClusterStore() + + await adminClusterStore.deleteCluster(clusterId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/cluster.ts b/.jjconflict-base-1/apps/client/src/stores/cluster.ts new file mode 100644 index 0000000000..3f94777f2b --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/cluster.ts @@ -0,0 +1,65 @@ +import type { CleanedCluster, Cluster, CreateClusterBody, UpdateClusterBody } from '@cpn-console/shared' +import { + resourceListToDict, + +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useClusterStore = defineStore('cluster', () => { + const clusters = ref>([]) + const clustersById = computed(() => resourceListToDict(clusters.value)) + + const getClusters = async () => { + clusters.value = await apiClient.Clusters.listClusters().then( + (response: any) => extractData(response, 200), + ) + return clusters.value + } + + const getClusterDetails = async (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterDetails({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const getClusterUsage = async (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterUsage({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const getClusterAssociatedEnvironments = (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterEnvironments({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const addCluster = (cluster: CreateClusterBody) => + apiClient.Clusters.createCluster({ body: cluster }).then((response: any) => + extractData(response, 201), + ) + + const updateCluster = ({ + id, + ...body + }: UpdateClusterBody & { id: Cluster['id'] }) => + apiClient.Clusters.updateCluster({ body, params: { clusterId: id } }).then( + (response: any) => extractData(response, 200), + ) + + const deleteCluster = (clusterId: Cluster['id']) => + apiClient.Clusters.deleteCluster({ params: { clusterId } }).then( + (response: any) => extractData(response, 204), + ) + + return { + clusters, + clustersById, + getClusterAssociatedEnvironments, + addCluster, + updateCluster, + deleteCluster, + getClusterDetails, + getClusterUsage, + getClusters, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/log.spec.ts b/.jjconflict-base-1/apps/client/src/stores/log.spec.ts new file mode 100644 index 0000000000..e9b9d0b796 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/log.spec.ts @@ -0,0 +1,32 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useLogStore } from './log.js' + +const apiClientGet = vi.spyOn(apiClient.Logs, 'getLogs') + +describe('log Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get logs list and total by api call', async () => { + const data = { + total: 1, + logs: [ + { id: 'thisIsAnId', data: {}, action: 'Create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565' }, + ], + } + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminLogStore = useLogStore() + + await adminLogStore.getAllLogs({ offset: 5, limit: 10 }) + + expect(adminLogStore.count).toEqual(data.total) + expect(adminLogStore.logs).toStrictEqual(data.logs) + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/log.ts b/.jjconflict-base-1/apps/client/src/stores/log.ts new file mode 100644 index 0000000000..3b1266cc48 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/log.ts @@ -0,0 +1,30 @@ +import type { GetLogsQuery, Log } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useLogStore = defineStore('log', () => { + const logs = ref([]) + const count = ref(undefined) + const needRefresh = ref(false) + + const getAllLogs = async ({ offset, limit }: GetLogsQuery = { offset: 0, limit: 100 }) => { + const res = await apiClient.Logs.getLogs({ query: { offset, limit, clean: false } }) + .then((response: any) => extractData(response, 200)) + count.value = res.total + logs.value = res.logs as Log[] + } + + const listLogs = async ({ offset, limit, clean, projectId }: GetLogsQuery = { offset: 0, limit: 10 }) => { + return apiClient.Logs.getLogs({ query: { offset, limit, clean, projectId } }) + .then((response: any) => extractData(response, 200)) + } + + return { + logs, + count, + needRefresh, + getAllLogs, + listLogs, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/plugins.ts b/.jjconflict-base-1/apps/client/src/stores/plugins.ts new file mode 100644 index 0000000000..99dd9f7abd --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/plugins.ts @@ -0,0 +1,19 @@ +import type { PluginsUpdateBody, ProjectService } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const usePluginsConfigStore = defineStore('plugins', () => { + const services: ProjectService[] = [] + + const getPluginsConfig = () => apiClient.SystemPlugin.getPluginsConfig() + .then((response: any) => extractData(response, 200)) + + const updatePluginsConfig = (body: PluginsUpdateBody) => apiClient.SystemPlugin.updatePluginsConfig({ body }) + .then((response: any) => extractData(response, 204)) + + return { + services, + getPluginsConfig, + updatePluginsConfig, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/project.spec.ts b/.jjconflict-base-1/apps/client/src/stores/project.spec.ts new file mode 100644 index 0000000000..6e57c0e9c4 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/project.spec.ts @@ -0,0 +1,94 @@ +import { createRandomDbSetup, getRandomMember, getRandomProject } from '@cpn-console/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client' +import { useProjectStore } from './project' + +const getProject = vi.spyOn(apiClient.Projects, 'getProject') +const listProjects = vi.spyOn(apiClient.Projects, 'listProjects') +const listEnvironments = vi.spyOn(apiClient.Environments, 'listEnvironments') +const listRepositories = vi.spyOn(apiClient.Repositories, 'listRepositories') +const apiClientPost = vi.spyOn(apiClient.Projects, 'createProject') + +describe('project Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should retrieve projects by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.environments + delete randomDbSetup.project.repositories + delete randomDbSetup.project.clusters + const projects = [randomDbSetup.project] + + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: projects, headers: {} })) + + await projectStore.listProjects() + + expect(listProjects).toHaveBeenCalledTimes(1) + expect(projectStore.projects).toMatchObject(projects) + }) + + it('should retrieve user\'s projects by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.clusters + const projects = [randomDbSetup.project] + + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: projects, headers: {} })) + listEnvironments.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.environments, headers: {} })) + listRepositories.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.repositories, headers: {} })) + + await projectStore.listMyProjects() + + expect(listProjects).toHaveBeenCalledTimes(1) + }) + + it('should retrieve one project by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.clusters + const project = randomDbSetup.project + + getProject.mockReturnValueOnce(Promise.resolve({ status: 200, body: project, headers: {} })) + listEnvironments.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.environments, headers: {} })) + listRepositories.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.repositories, headers: {} })) + + await projectStore.getProject('foo') + + expect(getProject).toHaveBeenCalledTimes(1) + }) + + it('should create a project by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + const projects = [randomDbSetup.project] + const newProject = getRandomProject() + newProject.members = [getRandomMember(randomDbSetup.users[0].id, 'owner')] + + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: newProject, headers: {} })) + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: [...projects, newProject], headers: {} })) + + await projectStore.createProject(newProject) + + expect(apiClientPost).toHaveBeenCalledTimes(1) + expect(projectStore.myProjects).toHaveLength(1) + }) +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/project.ts b/.jjconflict-base-1/apps/client/src/stores/project.ts new file mode 100644 index 0000000000..44e59d56a8 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/project.ts @@ -0,0 +1,119 @@ +import type { + CreateProjectBody, + projectContract, + ProjectV2, +} from '@cpn-console/shared' +import pDebounce from 'p-debounce' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' +import { Project } from '@/utils/project-utils.js' +import { useUserStore } from './user.js' + +export const useProjectStore = defineStore('project', () => { + const userStore = useUserStore() + + const amIPartOf = (project: ProjectV2) => + project.status !== 'archived' + && (project.ownerId === userStore.userProfile?.id + || project.members.some( + member => member.userId === userStore.userProfile?.id, + )) + + // mostly for admin views + const projectsBySlug = ref>({}) + + const projects = computed(() => + Object.values(projectsBySlug.value).sort((p1, p2) => + p1.slug.localeCompare(p2.slug), + ), + ) + + const myProjects = computed(() => + Object.values(projectsBySlug.value).filter( + project => project.status !== 'archived' && amIPartOf(project), + ), + ) + + const updateStore = async (projectsRecieved: ProjectV2[]) => { + return projectsRecieved.map((project) => { + if (project.slug in projectsBySlug.value) { + return projectsBySlug.value[project.slug].Commands.updateData(project) + } + const newProject = new Project(project) + projectsBySlug.value[project.slug] = newProject + return newProject + }) + } + + const selectFromStore = (slugs: ProjectV2['slug'][]) => { + return slugs + .filter(slug => slug in projectsBySlug.value) + .map(slug => projectsBySlug.value[slug]) + } + + const getProject = async (projectId: ProjectV2['id']) => { + const res = await apiClient.Projects.getProject({ + params: { projectId }, + }).then((response: any) => extractData(response, 200)) + return (await updateStore([res]))[0] + } + + const listProjects = async ( + query: typeof projectContract.listProjects.query._type = { + filter: 'member', + statusNotIn: 'archived', + }, + ) => { + const res = await apiClient.Projects.listProjects({ query }).then( + (response: any) => extractData(response, 200), + ) + await updateStore(res) + return selectFromStore(res.map((project: any) => project.slug)) + } + + const listMyProjects = pDebounce(async () => { + const res = await apiClient.Projects.listProjects({ + query: { filter: 'member', statusNotIn: 'archived' }, + }).then((response: any) => extractData(response, 200)) + for (const storedProject of myProjects.value) { + if ( + !res.some((responseProject: any) => responseProject.id === storedProject.id) + ) { + delete projectsBySlug.value[storedProject.slug] + } + } + await updateStore(res) + return selectFromStore(res.map((project: any) => project.slug)) + }, 200) + + const createProject = async (body: CreateProjectBody) => { + const project = await apiClient.Projects.createProject({ body }).then( + (response: any) => extractData(response, 201), + ) + projectsBySlug.value[project.slug] = new Project(project) + return project + } + + const generateProjectsData = () => + apiClient.Projects.getProjectsData().then((response: any) => + extractData(response, 200), + ) + + // Should only be used for components and vue outside of Project route and its children, consider using the props instead + // Should only be update by beforeEnter() of Project route + const lastSelectedProjectSlug = ref() + + return { + projects, + projectsBySlug, + myProjects, + lastSelectedProjectSlug, + getProject, + listProjects, + listMyProjects, + createProject, + generateProjectsData, + updateStore, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/service-chain.ts b/.jjconflict-base-1/apps/client/src/stores/service-chain.ts new file mode 100644 index 0000000000..802a594e0d --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/service-chain.ts @@ -0,0 +1,53 @@ +import type { ServiceChain, ServiceChainList } from '@cpn-console/shared' +import { + resourceListToDict, + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useServiceChainStore = defineStore('serviceChain', () => { + const serviceChains = ref([]) + const serviceChainsById = computed(() => + resourceListToDict(serviceChains.value), + ) + + const getServiceChainsList = async () => { + serviceChains.value + = await apiClient.ServiceChains.listServiceChains().then((response: any) => + extractData(response, 200), + ) + return serviceChains.value + } + + const getServiceChainDetails = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.getServiceChainDetails({ + params: { serviceChainId }, + }).then((response: any) => + ServiceChainDetailsSchema.parse(extractData(response, 200)), + ) + + const getServiceChainFlows = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.getServiceChainFlows({ + params: { serviceChainId }, + }).then((response: any) => + ServiceChainFlowsSchema.parse(extractData(response, 200)), + ) + + const retryServiceChain = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.retryServiceChain({ + params: { serviceChainId }, + }) + + return { + serviceChains, + serviceChainsById, + getServiceChainDetails, + getServiceChainFlows, + getServiceChainsList, + retryServiceChain, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/services-monitor.spec.ts b/.jjconflict-base-1/apps/client/src/stores/services-monitor.spec.ts new file mode 100644 index 0000000000..a2d72c8417 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/services-monitor.spec.ts @@ -0,0 +1,60 @@ +import type { ServiceBody } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useServiceStore } from './services-monitor.js' + +const apiClientGet = vi.spyOn(apiClient.Services, 'getServiceHealth') + +describe('service Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get services health by api call (healthy)', async () => { + const data: ServiceBody = [ + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'OK', name: 'Keycloak', status: 'OK' }, + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'Service perdu', name: 'Gitlab', status: 'OK' }, + ] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const serviceStore = useServiceStore() + + expect(serviceStore.servicesHealth).toMatchObject({}) + expect(serviceStore.services).toMatchObject([]) + + await serviceStore.checkServicesHealth() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(serviceStore.servicesHealth).toMatchObject({ + message: 'Tous les services fonctionnent normalement', + serviceStatus: 'OK', + status: 'success', + }) + expect(serviceStore.services).toMatchObject(data) + }) + + it('should get services health by api call (unhealthy)', async () => { + const data = [ + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'OK', name: 'Keycloak', status: 'OK' }, + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'Service perdu', name: 'Gitlab', status: 'Inconnu' }, + ] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const serviceStore = useServiceStore() + + expect(serviceStore.servicesHealth).toMatchObject({}) + expect(serviceStore.services).toMatchObject([]) + + await serviceStore.checkServicesHealth() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(serviceStore.servicesHealth).toMatchObject({ + message: 'Échec lors de la dernière vérification', + serviceStatus: 'Inconnu', + status: undefined, + }) + expect(serviceStore.services).toMatchObject(data) + }) +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/services-monitor.ts b/.jjconflict-base-1/apps/client/src/stores/services-monitor.ts new file mode 100644 index 0000000000..d810ef328b --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/services-monitor.ts @@ -0,0 +1,125 @@ +import type { ServiceBody } from '@cpn-console/shared' +// @ts-ignore '@gouvminint/vue-dsfr' missing types +import type { DsfrAlertType } from '@gouvminint/vue-dsfr' +import { MonitorStatus } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export type ServicesHealth = { + message: string + status: DsfrAlertType | undefined + dotColor: 'gray' | 'red' | 'green' | 'orange' | 'blue' +} | Record + +interface ServiceHealthOption { message: string, status: DsfrAlertType | undefined, serviceStatus: MonitorStatus, dotColor: ServicesHealth['dotColor'] } + +export const alertTypeMapper: Record = { + [MonitorStatus.OK]: 'success', + [MonitorStatus.WARNING]: 'warning', + [MonitorStatus.ERROR]: 'error', + [MonitorStatus.UNKNOW]: undefined, +} + +const serviceHealthOptions = { + fetching: { + message: 'Vérification de l\'état des services...', + status: 'info', + serviceStatus: MonitorStatus.UNKNOW, + dotColor: 'blue', + }, + fetchError: { + message: 'Échec lors de la dernière vérification', + status: alertTypeMapper[MonitorStatus.UNKNOW], + serviceStatus: MonitorStatus.UNKNOW, + dotColor: 'gray', + }, + error: { + message: 'Un ou plusieurs services dysfonctionnent', + status: alertTypeMapper[MonitorStatus.ERROR], + serviceStatus: MonitorStatus.ERROR, + dotColor: 'red', + }, + warn: { + message: 'Un ou plusieurs services sont partiellement dégradés', + status: alertTypeMapper[MonitorStatus.WARNING], + serviceStatus: MonitorStatus.WARNING, + dotColor: 'orange', + }, + ok: { + message: 'Tous les services fonctionnent normalement', + status: alertTypeMapper[MonitorStatus.OK], + serviceStatus: MonitorStatus.OK, + dotColor: 'green', + }, +} as const satisfies Record + +export const useServiceStore = defineStore('serviceMonitor', () => { + const callStastus = ref<'ok' | 'fetching' | 'error'>('fetching') + + const displayCause = ref(false) + const services = ref([]) + + const serviceHealthIndex = computed(() => { + if (callStastus.value === 'fetching') { + return 'fetching' + } + if (services.value.some(({ status }) => status === 'Dégradé')) { + return 'warn' + } + if (services.value.some(({ status }) => status === 'En échec')) { + return 'error' + } + if (services.value.some(({ status }) => status === 'Inconnu')) { + return 'fetchError' + } + return 'ok' + }) + + const servicesHealth = computed(() => serviceHealthOptions[serviceHealthIndex.value]) + let interval: NodeJS.Timeout + + const clear = () => interval && clearInterval(interval) + + const checkServicesHealth = async () => { + callStastus.value = 'fetching' + try { + services.value = await (displayCause.value + ? apiClient.Services.getCompleteServiceHealth() + : apiClient.Services.getServiceHealth()) + .then((res: any) => extractData(res, 200)) + callStastus.value = 'ok' + } catch { + callStastus.value = 'error' + } + } + + const refreshServicesHealth = async () => { + await apiClient.Services.refreshServiceHealth() + .then((res: any) => extractData(res, 200)) + return checkServicesHealth() + } + + const startHealthPolling = async () => { + if (!interval) return + + clear() + await checkServicesHealth() + interval = setInterval(checkServicesHealth, 300_000_000) + } + + async function toggleDisplayCause() { + displayCause.value = !displayCause.value + await checkServicesHealth() + } + + return { + displayCause, + servicesHealth, + services, + checkServicesHealth, + refreshServicesHealth, + toggleDisplayCause, + startHealthPolling, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/snackbar.spec.ts b/.jjconflict-base-1/apps/client/src/stores/snackbar.spec.ts new file mode 100644 index 0000000000..4fe623ecb3 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/snackbar.spec.ts @@ -0,0 +1,155 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSnackbarStore } from './snackbar.js' + +describe('snackbar Store', () => { + beforeEach(() => { + vi.clearAllTimers() + vi.useFakeTimers() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should display message in snackbar with default values', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(6000) + + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display message in snackbar with custom timer', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message, 'info', 10000) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(6000) + + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(4000) + + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display message in snackbar with custom type', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + const type = 'warning' + snackbarStore.setMessage(message, type) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + }) + + it('should display message in snackbar and don\'t hide it if type error', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + const type = 'error' + snackbarStore.setMessage(message, type) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type) + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display last message in snackbar', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message1 = 'this is a message' + const type1 = 'warning' + snackbarStore.setMessage(message1, type1) + + expect(snackbarStore.message).toEqual(message1) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type1) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + const message2 = 'this is another message' + const type2 = 'info' + snackbarStore.setMessage(message2, type2) + + expect(snackbarStore.message).toEqual(message2) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type2) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + }) + + it('should hide message in snackbar', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + snackbarStore.hideMessage() + + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/snackbar.ts b/.jjconflict-base-1/apps/client/src/stores/snackbar.ts new file mode 100644 index 0000000000..badfb2e072 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/snackbar.ts @@ -0,0 +1,42 @@ +import type { ErrorTypes } from '@cpn-console/shared' +import type { Ref } from 'vue' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useSnackbarStore = defineStore('snackbar', () => { + const defaultTimeout: number = 6000 + const message: Ref = ref(undefined) + const isOpen: Ref = ref(false) + const type: Ref = ref('info') + const timeoutId: Ref | undefined> = ref(undefined) + const isWaitingForResponse = ref(false) + + const hideMessage = () => { + isOpen.value = false + clearTimeout(timeoutId.value) + timeoutId.value = undefined + } + + const setMessage = (errorMessage: string, errorType: ErrorTypes = 'info', timeout: number = defaultTimeout) => { + if (timeoutId.value) { + clearTimeout(timeoutId.value) + timeoutId.value = undefined + } + if (errorType !== 'error') { + timeoutId.value = setTimeout(hideMessage, timeout) + } + message.value = errorMessage + isOpen.value = true + type.value = errorType + } + + return { + message, + isOpen, + type, + timeoutId, + isWaitingForResponse, + setMessage, + hideMessage, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/stage.spec.ts b/.jjconflict-base-1/apps/client/src/stores/stage.spec.ts new file mode 100644 index 0000000000..542c5c4c1c --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/stage.spec.ts @@ -0,0 +1,96 @@ +import type { Stage } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useStageStore } from './stage.js' + +const apiClientListStages = vi.spyOn(apiClient.Stages, 'listStages') +const apiClientGet = vi.spyOn(apiClient.Stages, 'getStageEnvironments') +const apiClientPost = vi.spyOn(apiClient.Stages, 'createStage') +const apiClientPut = vi.spyOn(apiClient.Stages, 'updateStage') +const apiClientDelete = vi.spyOn(apiClient.Stages, 'deleteStage') + +describe('stage Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get stages list by api call', async () => { + const data = [ + { id: 'id1', name: 'dev' }, + { id: 'id2', name: 'int' }, + { id: 'id3', name: 'prod' }, + ] as Stage[] + apiClientListStages.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + await stageStore.getAllStages() + + expect(stageStore.stages).toEqual(data) + expect(apiClientListStages).toHaveBeenCalledTimes(1) + }) + + it('should get a stage\'s associated environments by api call', async () => { + const stageId = 'stageId' + const data = [ + { name: 'env1' }, + { name: 'env2' }, + { name: 'env3' }, + ] as { name: string, project: string, cluster: string, owner: string }[] + apiClientGet.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.getStageAssociatedEnvironments(stageId) + + expect(res).toBe(data) + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should create a stage by api call', async () => { + const data = { + name: 'int', + } as Stage + + apiClientPost.mockResolvedValueOnce({ status: 201, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.addStage(data) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update a stage\'s quotas and clusters associated by api call', async () => { + const stageId = 'stageId' + const quotaIds = ['stage1'] + const clusterIds = ['cluster1'] + const data = { + name: 'stageA', + quotaIds, + clusterIds, + id: stageId, + } + + apiClientPut.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.updateStage(stageId, { clusterIds, name: 'stageA' }) + + expect(res).toBe(data) + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete a stage by api call', async () => { + const stageId = 'stageId' + + apiClientDelete.mockResolvedValueOnce({ status: 204, body: null, headers: null }) + const stageStore = useStageStore() + + await stageStore.deleteStage(stageId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/stage.ts b/.jjconflict-base-1/apps/client/src/stores/stage.ts new file mode 100644 index 0000000000..de6d3824bf --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/stage.ts @@ -0,0 +1,45 @@ +import type { CreateStageBody, Stage, UpdateStageBody } from '@cpn-console/shared' +import { + + resourceListToDict, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useStageStore = defineStore('stage', () => { + const stages = ref([]) + const stagesById = computed(() => resourceListToDict(stages.value)) + + const getAllStages = async () => { + stages.value = await apiClient.Stages.listStages() + .then((response: any) => extractData(response, 200)) + return stages.value + } + + const getStageAssociatedEnvironments = (stageId: string) => + apiClient.Stages.getStageEnvironments({ params: { stageId } }) + .then((response: any) => extractData(response, 200)) + + const addStage = (body: CreateStageBody) => + apiClient.Stages.createStage({ body }) + .then((response: any) => extractData(response, 201)) + + const updateStage = (stageId: string, body: UpdateStageBody) => + apiClient.Stages.updateStage({ params: { stageId }, body }) + .then((response: any) => extractData(response, 200)) + + const deleteStage = (stageId: string) => + apiClient.Stages.deleteStage({ params: { stageId } }) + .then((response: any) => extractData(response, 204)) + + return { + stages, + stagesById, + getAllStages, + getStageAssociatedEnvironments, + addStage, + updateStage, + deleteStage, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/system-settings.spec.ts b/.jjconflict-base-1/apps/client/src/stores/system-settings.spec.ts new file mode 100644 index 0000000000..0cf6eca2eb --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/system-settings.spec.ts @@ -0,0 +1,55 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useSystemSettingsStore } from './system-settings.js' + +const listSystemSettings = vi.spyOn(apiClient.SystemSettings, 'listSystemSettings') +const upsertSystemSetting = vi.spyOn(apiClient.SystemSettings, 'upsertSystemSetting') + +describe('system Settings Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get system settings list by api call', async () => { + const data = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'miom' }, + ] + listSystemSettings.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const systemSettingsStore = useSystemSettingsStore() + + await systemSettingsStore.listSystemSettings() + + expect(systemSettingsStore.systemSettings).toEqual(data) + expect(listSystemSettings).toHaveBeenCalledTimes(1) + }) + + it('should upsert a system setting by api call', async () => { + const data = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'miom' }, + ] + const newSystemSetting = { key: 'organisation', value: 'mj' } + const newData = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'mj' }, + ] + + upsertSystemSetting.mockReturnValueOnce(Promise.resolve({ status: 201, body: newSystemSetting })) + const systemSettingsStore = useSystemSettingsStore() + systemSettingsStore.systemSettings = data + + const res = await systemSettingsStore.upsertSystemSetting(data) + + expect(res).toEqual(newSystemSetting) + expect(systemSettingsStore.systemSettings).toEqual(newData) + expect(upsertSystemSetting).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/system-settings.ts b/.jjconflict-base-1/apps/client/src/stores/system-settings.ts new file mode 100644 index 0000000000..8eebac262e --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/system-settings.ts @@ -0,0 +1,36 @@ +import type { + SystemSettings, + systemSettingsContract, + UpsertSystemSettingBody, +} from '@cpn-console/shared' +import { + resourceListToDictByKey, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useSystemSettingsStore = defineStore('systemSettings', () => { + const systemSettings = ref([]) + const systemSettingsByKey = computed(() => resourceListToDictByKey(systemSettings.value)) + + const listSystemSettings = async (query: typeof systemSettingsContract.listSystemSettings.query._type = {}) => { + systemSettings.value = await apiClient.SystemSettings.listSystemSettings(query) + .then((response: any) => extractData(response, 200)) + } + + const upsertSystemSetting = async (newSystemSetting: UpsertSystemSettingBody) => { + const res = await apiClient.SystemSettings.upsertSystemSetting({ body: newSystemSetting }) + .then((response: any) => extractData(response, 201)) + systemSettings.value = systemSettings.value + .toSpliced(systemSettings.value + .findIndex(systemSetting => systemSetting.key === res.key), 1, res) + return res + } + + return { + systemSettings, + systemSettingsByKey, + listSystemSettings, + upsertSystemSetting, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/token.ts b/.jjconflict-base-1/apps/client/src/stores/token.ts new file mode 100644 index 0000000000..d19b832333 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/token.ts @@ -0,0 +1,36 @@ +import type { + PersonalAccessToken, + personalAccessTokenContract, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '../api/xhr-client.js' + +export const useTokenStore = defineStore('token', () => { + const listPersonalAccessTokens = async () => { + return apiClient.PersonalAccessTokens.listPersonalAccessTokens().then( + (response: any) => extractData(response, 200), + ) + } + + const createPersonalAccessToken = async ( + body: typeof personalAccessTokenContract.createPersonalAccessToken.body._type, + ) => { + return apiClient.PersonalAccessTokens.createPersonalAccessToken({ + body, + }).then((res: any) => extractData(res, 201)) + } + + const deletePersonalAccessToken = async ( + tokenId: PersonalAccessToken['id'], + ) => { + await apiClient.PersonalAccessTokens.deletePersonalAccessToken({ + params: { tokenId }, + }).then((res: any) => extractData(res, 204)) + } + + return { + listPersonalAccessTokens, + createPersonalAccessToken, + deletePersonalAccessToken, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/user.spec.ts b/.jjconflict-base-1/apps/client/src/stores/user.spec.ts new file mode 100644 index 0000000000..8ca9e7a521 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/user.spec.ts @@ -0,0 +1,80 @@ +import Keycloak from 'keycloak-js' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useUserStore } from './user.js' + +const authMock = vi.spyOn(apiClient.Users, 'auth') +const listAdminRoles = vi.spyOn(apiClient.AdminRoles, 'listAdminRoles') + +authMock.mockResolvedValue(Promise.resolve({ status: 200, body: {} })) +listAdminRoles.mockResolvedValue(Promise.resolve({ status: 200, body: [] })) + +vi.mock('keycloak-js', () => { + const Keycloak = vi.fn() + Keycloak.prototype.authenticated = true + Keycloak.prototype.idTokenParsed = { + email: 'michel.michel@test.com', + sub: 'userId', + given_name: 'Michel', + family_name: 'MICHEL', + groups: ['/admin'], + } + Keycloak.prototype.login = vi.fn() + Keycloak.prototype.logout = vi.fn() + + return { default: Keycloak } +}) +const keycloak = new Keycloak() + +describe('user Store', () => { + beforeEach(() => { + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + afterEach(() => { + vi.clearAllMocks() + }) + + it('should retrieve isLoggedIn from Keycloak (true)', async () => { + const userStore = useUserStore() + + expect(userStore.isLoggedIn).toBeUndefined() + + await userStore.setIsLoggedIn() + + expect(userStore.isLoggedIn).toEqual(true) + }) + + it('should retrieve userProfile from Keycloak', async () => { + const userStore = useUserStore() + + expect(userStore.userProfile).toMatchObject({}) + + userStore.setUserProfile() + + expect(userStore.userProfile).toMatchObject({ + email: 'michel.michel@test.com', + id: 'userId', + firstName: 'Michel', + lastName: 'MICHEL', + }) + }) + + it('should call Keycloak login function', async () => { + const userStore = useUserStore() + + await userStore.login() + + expect(keycloak.login).toBeCalledTimes(1) + }) + + it('should call Keycloak logout function', async () => { + const userStore = useUserStore() + + await userStore.logout() + + expect(keycloak.logout).toBeCalledTimes(1) + }) +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/user.ts b/.jjconflict-base-1/apps/client/src/stores/user.ts new file mode 100644 index 0000000000..f860e6d037 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/user.ts @@ -0,0 +1,58 @@ +import type { AdminRole, User, UserProfile } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' +import { getKeycloak, getUserProfile, keycloakLogin, keycloakLogout } from '@/utils/keycloak/keycloak.js' +import { useAdminRoleStore } from './admin-role.js' +import { useSystemSettingsStore } from './system-settings.js' + +export const useUserStore = defineStore('user', () => { + const adminRoleStore = useAdminRoleStore() + const systemSettingsStore = useSystemSettingsStore() + const isLoggedIn = ref() + const userProfile = ref() + const apiAuthInfos = ref() + + const myAdminRoles = computed(() => adminRoleStore.roles?.filter(adminRole => apiAuthInfos.value?.adminRoleIds.includes(adminRole.id)) ?? []) + + const adminPerms = computed(() => { + if (!apiAuthInfos.value) return null + const globalRoles = adminRoleStore.roles?.filter(role => role.type === 'global') ?? [] + const globalPerms = globalRoles.reduce((acc, role) => acc | BigInt(role.permissions), 0n) + const adminPerms = myAdminRoles.value.reduce((acc, role) => acc | BigInt(role.permissions), 0n) + return globalPerms | adminPerms + }) + + const setUserProfile = async () => { + userProfile.value = getUserProfile() + await systemSettingsStore.listSystemSettings().catch(() => undefined) + await apiClient.Users.auth() + .then((res: any) => apiAuthInfos.value = extractData(res, 200)) + } + + const setIsLoggedIn = async () => { + const keycloak = getKeycloak() + if (keycloak.authenticated !== isLoggedIn.value) { + isLoggedIn.value = keycloak.authenticated + if (isLoggedIn.value) { + await setUserProfile() + } + } + } + + const login = () => keycloakLogin() + + const logout = () => keycloakLogout() + + return { + isLoggedIn, + setIsLoggedIn, + userProfile, + apiAuthInfos, + myAdminRoles, + adminPerms, + setUserProfile, + login, + logout, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/users.ts b/.jjconflict-base-1/apps/client/src/stores/users.ts new file mode 100644 index 0000000000..4bacc644da --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/users.ts @@ -0,0 +1,23 @@ +import type { userContract } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useUsersStore = defineStore('users', () => { + const listUsers = async (query: typeof userContract.getAllUsers.query._input) => + apiClient.Users.getAllUsers({ query }) + .then((res: any) => extractData(res, 200)) + + const listMatchingUsers = async (query: typeof userContract.getMatchingUsers.query._type) => + apiClient.Users.getMatchingUsers({ query }) + .then((res: any) => extractData(res, 200)) + + const patchUsers = async (body: typeof userContract.patchUsers.body._type) => + apiClient.Users.patchUsers({ body }) + .then((res: any) => extractData(res, 200)) + + return { + listUsers, + listMatchingUsers, + patchUsers, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/zone.spec.ts b/.jjconflict-base-1/apps/client/src/stores/zone.spec.ts new file mode 100644 index 0000000000..087ee45034 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/zone.spec.ts @@ -0,0 +1,67 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useZoneStore } from './zone.js' + +const apiClientGet = vi.spyOn(apiClient.Zones, 'listZones') +const apiClientPost = vi.spyOn(apiClient.Zones, 'createZone') +const apiClientPut = vi.spyOn(apiClient.Zones, 'updateZone') +const apiClientDelete = vi.spyOn(apiClient.Zones, 'deleteZone') + +describe('zone Store', () => { + beforeEach(() => { + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + afterEach(() => { + vi.clearAllMocks() + }) + + it('should retrieve all zones', async () => { + const zoneStore = useZoneStore() + + expect(zoneStore.zones).toEqual([]) + + const zones = [{ id: 'zoneId' }, { id: 'anotherZoneId' }] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: zones })) + + await zoneStore.getAllZones() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(zoneStore.zones).toMatchObject(zones) + }) + + it('should create a new zone', async () => { + const adminZoneStore = useZoneStore() + + const zone = { label: 'Zone à défendre', slug: 'zad' } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, data: zone })) + + await adminZoneStore.createZone(zone) + + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update a zone', async () => { + const adminZoneStore = useZoneStore() + + const zone = { id: 'zoneId', label: 'Zod à update' } + apiClientPut.mockReturnValueOnce(Promise.resolve({ status: 200, data: zone })) + + await adminZoneStore.updateZone(zone.id, zone) + + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete a zone', async () => { + const adminZoneStore = useZoneStore() + + const zoneId = 'zoneId' + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204, data: null })) + + await adminZoneStore.deleteZone(zoneId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-base-1/apps/client/src/stores/zone.ts b/.jjconflict-base-1/apps/client/src/stores/zone.ts new file mode 100644 index 0000000000..0c81a3e0f1 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/stores/zone.ts @@ -0,0 +1,40 @@ +import type { CreateZoneBody, UpdateZoneBody, Zone } from '@cpn-console/shared' +import { + + resourceListToDict, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useZoneStore = defineStore('zone', () => { + const zones = ref([]) + const zonesById = computed(() => resourceListToDict(zones.value)) + + const getAllZones = async () => { + zones.value = await apiClient.Zones.listZones() + .then((response: any) => extractData(response, 200)) + return zones.value + } + + const createZone = (body: CreateZoneBody) => + apiClient.Zones.createZone({ body }) + .then((response: any) => extractData(response, 201)) + + const updateZone = (zoneId: Zone['id'], data: UpdateZoneBody) => + apiClient.Zones.updateZone({ body: data, params: { zoneId } }) + .then((response: any) => extractData(response, 200)) + + const deleteZone = (zoneId: Zone['id']) => + apiClient.Zones.deleteZone({ params: { zoneId } }) + .then((response: any) => extractData(response, 204)) + + return { + zones, + zonesById, + getAllZones, + createZone, + updateZone, + deleteZone, + } +}) diff --git a/.jjconflict-base-1/apps/client/src/utils/env.ts b/.jjconflict-base-1/apps/client/src/utils/env.ts new file mode 100644 index 0000000000..189eb7143e --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/utils/env.ts @@ -0,0 +1,20 @@ +/* eslint-disable no-template-curly-in-string */ +export const serverHost: string = process.env.SERVER_HOST ?? '${SERVER_HOST}' + +export const serverPort: string = process.env.SERVER_PORT ?? '${SERVER_PORT}' + +export const clientPort: string = process.env.CLIENT_PORT ?? '${CLIENT_PORT}' + +export const keycloakProtocol: string = process.env.KEYCLOAK_PROTOCOL ?? '${KEYCLOAK_PROTOCOL}' + +export const keycloakDomain: string = process.env.KEYCLOAK_DOMAIN ?? '${KEYCLOAK_DOMAIN}' + +export const keycloakRealm: string = process.env.KEYCLOAK_REALM ?? '${KEYCLOAK_REALM}' + +export const keycloakClientId: string = process.env.KEYCLOAK_CLIENT_ID ?? '${KEYCLOAK_CLIENT_ID}' + +export const keycloakRedirectUri: string = process.env.KEYCLOAK_REDIRECT_URI ?? '${KEYCLOAK_REDIRECT_URI}' + +export const contactEmail: string = process.env.CONTACT_EMAIL ?? '${CONTACT_EMAIL}' + +export const openCDSEnabled: string = process.env.OPENCDS_ENABLED ?? '${OPENCDS_ENABLED}' diff --git a/.jjconflict-base-1/apps/client/src/utils/func.spec.ts b/.jjconflict-base-1/apps/client/src/utils/func.spec.ts new file mode 100644 index 0000000000..75ef96cead --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/utils/func.spec.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('localeParseFloat EN tests', () => { + let localeParseFloatEN: (s: string) => number + beforeEach(async () => { + vi.resetModules() + vi.resetAllMocks() + Object.defineProperty(navigator, 'language', { + value: 'en-EN', + configurable: true, + }); + ({ localeParseFloat: localeParseFloatEN } = await import('./func.js')) + }) + it('should parse invalid float FR in locale EN', async () => { + const result = localeParseFloatEN('4,25') + expect(result).toBe(425) + }) + + it('should parse valid float EN in locale EN', async () => { + const result = localeParseFloatEN('4.25') + expect(result).toBe(4.25) + }) +}) + +describe('localeParseFloat FR tests', () => { + let localeParseFloatFR: (s: string) => number + beforeEach(async () => { + vi.resetModules() + vi.resetAllMocks() + Object.defineProperty(navigator, 'language', { + value: 'fr-FR', + configurable: true, + }); + ({ localeParseFloat: localeParseFloatFR } = await import('./func.js')) + }) + it('should parse valid float FR in locale FR', async () => { + const result = localeParseFloatFR('4,25') + expect(result).toBe(4.25) + }) + it('should parse valid float EN in locale FR', async () => { + const result = localeParseFloatFR('4.25') + expect(result).toBe(4.25) + }) +}) diff --git a/.jjconflict-base-1/apps/client/src/utils/func.ts b/.jjconflict-base-1/apps/client/src/utils/func.ts new file mode 100644 index 0000000000..71f1a61b5e --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/utils/func.ts @@ -0,0 +1,103 @@ +import type { Ref } from 'vue' +import { useSnackbarStore } from '@/stores/snackbar.js' + +const LOCALE = navigator.language.slice(0, 2) +// Get the thousands and decimal separator characters used in the locale. +const [,THOUSANDS_SEPARATOR,,,,DECIMAL_SEPARATOR] = 1111.1.toLocaleString(LOCALE) +// 0.1 number as local string to use in labels and placeholders +export const ONE_TENTH_STR = 0.1.toLocaleString() + +export async function copyContent(content: string): Promise { + const snackbarStore = useSnackbarStore() + try { + await navigator.clipboard.writeText(content) + snackbarStore.setMessage('Donnée copiée', 'success') + } catch (error: any) { + snackbarStore.setMessage(error?.message, 'error') + } +} + +export function toCodeComponent(value: string) { + return { + component: 'code', + text: value, + title: 'Copier la valeur', + class: 'fr-text-default--info text-xs cursor-pointer', + onClick: () => copyContent(value), + } +} + +const maxDescriptionLength = 60 +export function truncateDescription(description: string) { + let innerHTML: string + + if (description.length <= maxDescriptionLength) { + innerHTML = description + } else { + const lastSpaceIndex = description.slice(0, maxDescriptionLength).lastIndexOf(' ') + innerHTML = `${description.slice(0, lastSpaceIndex > 0 ? lastSpaceIndex : maxDescriptionLength)} ...` + } + + return { + id: 'description', + 'data-testid': 'description', + component: 'span', + open: false, + title: description, + innerHTML, + } +} + +export function clickInDialog(e?: MouseEvent | TouchEvent, fn?: () => void) { + // @ts-ignore + if (e && e.target?.tagName !== 'DIALOG') { + return + } + fn?.() +} + +function randomId() { + String.fromCharCode(97) + return (Array.from({ length: 6 })).map(() => String.fromCharCode(97 + Math.floor(Math.random() * 26))) +} +export function getRandomId(suffix?: string, prefix?: string) { + return (prefix ? (`${prefix}-`) : '') + randomId() + (suffix ? (`-${suffix}`) : '') +} + +export function toKebabCase(value: string) { + return value + .trim() + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +/** + * Replace current locale separators occurences before parsing a floating Number. + * See https://stackoverflow.com/a/59679285 + * @param s String to parse + * @returns Number + */ +export function localeParseFloat(s: string): number { + // Remove thousand separators, and put a point where the decimal separator occurs + const delocalizedInput = s.replaceAll(THOUSANDS_SEPARATOR, '').replaceAll(DECIMAL_SEPARATOR, '.') + // Now it can be parsed + return Number.parseFloat(delocalizedInput) +} + +export async function scrollToFirstError(container: Ref) { + await nextTick() + + if (!container.value) return + + const firstErrorElement = container.value.querySelector('.fr-error-text, .fr-input-group--error') + + if (firstErrorElement) { + firstErrorElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }) + } +} diff --git a/.jjconflict-base-1/apps/client/src/utils/keycloak/keycloak.spec.ts b/.jjconflict-base-1/apps/client/src/utils/keycloak/keycloak.spec.ts new file mode 100644 index 0000000000..63760697cf --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/utils/keycloak/keycloak.spec.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getKeycloak, getUserProfile, keycloakInit, keycloakLogin, keycloakLogout } from './keycloak.js' + +const userToken = { + email: 'test@test.com', + sub: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + given_name: 'Jean', + family_name: 'DUPOND', +} +const userStored = { + email: 'test@test.com', + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', +} +const keycloak = getKeycloak() + +vi.mock('keycloak-js', () => { + class Keycloak { + idTokenParsed: Record + init: () => void + login: () => void + logout: () => void + + constructor() { + this.idTokenParsed = userToken + this.init = vi.fn() + this.login = vi.fn() + this.logout = vi.fn() + } + } + return { default: Keycloak } +}) + +const mockedKeycloakInit = vi.spyOn(keycloak, 'init') +const mockedKeycloakLogin = vi.spyOn(keycloak, 'login') +const mockedKeycloakLogout = vi.spyOn(keycloak, 'logout') + +describe('keycloak-init', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return Keycloak instance', () => { + const keycloak = getKeycloak() + + expect(keycloak).toBeInstanceOf(Object) + }) + + it('should return keycloak user profile', () => { + const keycloakUser = getUserProfile() + + expect(keycloakUser).toBeInstanceOf(Object) + expect(keycloakUser).toMatchObject(userStored) + }) + + it('should init keycloak', async () => { + await keycloakInit() + + expect(mockedKeycloakInit.mock.calls).toHaveLength(1) + }) + + it('should throw an error if keycloak can\'t be initialize', async () => { + const error = new Error('Failed to init keycloak') + try { + mockedKeycloakInit.mockReturnValueOnce(Promise.reject(error)) + await keycloakInit() + } catch (e) { + expect(mockedKeycloakInit.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) + + it('should start login process to keycloak', async () => { + await keycloakLogin() + + expect(mockedKeycloakLogin.mock.calls).toHaveLength(1) + }) + + it('should throw an error if login process to keycloak failed', async () => { + const error = new Error('Failed to start login process keycloak') + try { + mockedKeycloakLogin.mockReturnValueOnce(Promise.reject(error)) + await keycloakLogin() + } catch (e) { + expect(mockedKeycloakLogin.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) + + it('should start logout process to keycloak', async () => { + await keycloakLogout() + + expect(mockedKeycloakLogout.mock.calls).toHaveLength(1) + }) + + it('should throw an error if logout process to keycloak failed', async () => { + const error = new Error('Failed to start logout process keycloak') + try { + mockedKeycloakLogout.mockReturnValueOnce(Promise.reject(error)) + await keycloakLogout() + } catch (e) { + expect(mockedKeycloakLogout.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) +}) diff --git a/.jjconflict-base-1/apps/client/src/utils/keycloak/keycloak.ts b/.jjconflict-base-1/apps/client/src/utils/keycloak/keycloak.ts new file mode 100644 index 0000000000..2d36a097c5 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/utils/keycloak/keycloak.ts @@ -0,0 +1,101 @@ +import type { UserProfile } from '@cpn-console/shared' +import type { KeycloakConfig, KeycloakInitOptions } from 'keycloak-js' +import { logger } from '@cpn-console/logger/browser' +import Keycloak from 'keycloak-js' +import { + keycloakClientId, + keycloakDomain, + keycloakProtocol, + keycloakRealm, + keycloakRedirectUri, +} from '../env.js' + +export const keycloakInitOptions: KeycloakInitOptions = { + onLoad: 'check-sso', + flow: 'standard', + redirectUri: keycloakRedirectUri, +} + +export const keycloakConfig: KeycloakConfig = { + url: `${keycloakProtocol}://${keycloakDomain}`, + realm: keycloakRealm, + clientId: keycloakClientId, +} + +let keycloak: Keycloak + +export function getKeycloak() { + if (!keycloak) { + keycloak = new Keycloak(keycloakConfig) + keycloak.onAuthSuccess = () => { + if (!(keycloak.refreshTokenParsed?.exp && keycloak.tokenParsed?.exp && keycloak.refreshTokenParsed.exp > keycloak.tokenParsed.exp)) { + return + } + logger.warn('Keycloak misconfiguration: refreshToken should not expire before token') + const refreshTokenDelay = (keycloak.tokenParsed.exp * 1000 - Date.now()) / 2 + setTimeout(() => { + keycloak.updateToken() + }, refreshTokenDelay) + } + keycloak.onTokenExpired = () => { + keycloak.updateToken(30) + } + } + return keycloak +} + +export function getUserProfile(): UserProfile { + try { + const keycloak = getKeycloak() + const { email, sub: id, given_name: firstName, family_name: lastName, groups } = keycloak.idTokenParsed as { email: string, sub: string, given_name: string, firstName: string, family_name: string, lastName: string, groups: string[] } + return { + email, + id, + firstName, + lastName, + groups, + } + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de récupération du profil keycloak de l\'utilisateur') + } +} + +export async function keycloakInit() { + const currentUrl = new URL(window.location.href) + const redirectUri = `${window.location.origin}${currentUrl.pathname}${currentUrl.search}` + try { + const { onLoad, flow } = keycloakInitOptions + const keycloak = getKeycloak() + await keycloak.init({ + onLoad, + flow, + redirectUri, + }) + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec d\'initialisation du keycloak') + } +} + +export async function keycloakLogin() { + try { + const keycloak = getKeycloak() + const currentUrl = new URL(window.location.href) + const redirectUri = `${window.location.origin}${currentUrl.pathname}${currentUrl.search}` + await keycloak.login({ redirectUri }) + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de connexion au keycloak') + } +} + +export async function keycloakLogout() { + try { + const keycloak = getKeycloak() + await keycloak.logout() + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de déconnexion du keycloak') + } +} diff --git a/.jjconflict-base-1/apps/client/src/utils/project-utils.ts b/.jjconflict-base-1/apps/client/src/utils/project-utils.ts new file mode 100644 index 0000000000..e45746f660 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/utils/project-utils.ts @@ -0,0 +1,414 @@ +import type { + CreateDeploymentBody, + CreateEnvironmentBody, + CreateRepositoryBody, + Deployment, + Environment, + GetLogsQuery, + PermissionTarget, + PluginsUpdateBody, + projectContract, + projectMemberContract, + projectRoleContract, + ProjectService, + ProjectV2, + Repo, + RepositoryParams, + Role, + UpdateDeploymentBody, + UpdateEnvironmentBody, + UpdateRepositoryBody, + User, +} from '@cpn-console/shared' +import type { Ref } from 'vue' +import { + getPermsByUserRoles, + objectKeys, + PROJECT_PERMS, + resourceListToDict, +} from '@cpn-console/shared' + +import { + apiClient, + extractData, +} from '@/api/xhr-client.js' +import { useLogStore } from '@/stores/log.js' +import { useUserStore } from '@/stores/user.js' +import { getRandomId } from './func.js' + +export type ProjectOperations = 'create' + | 'delete' + | 'deploymentManagement' + | 'envManagement' + | 'repoManagement' + | 'teamManagement' + | 'searchSecret' + | 'replay' + | 'update' + | 'lockHandling' + | 'saveServices' + | 'roleManagement' + +function calculateProjectPerms(project: ProjectV2 | undefined, userId: string | undefined) { + if (!project || !userId) return 0n + if (userId === project?.ownerId) return PROJECT_PERMS.MANAGE + const selfMember = project.members.find(member => member.userId === userId) + if (!selfMember) return 0n + + return getPermsByUserRoles(selfMember.roleIds, resourceListToDict(project.roles), project.everyonePerms) +} + +export class Project implements ProjectV2 { + id: string + clusterIds: string[] + description: string | undefined + everyonePerms: string + name: string + slug: string + locked: boolean + owner: Omit + ownerId: string + roles: { id: string, name: string, permissions: string, position: number, projectId: string, oidcGroup?: string, type?: string }[] + members: ({ userId: string, firstName: string, lastName: string, email: string, roleIds: string[] } | { updatedAt: string, createdAt: string, firstName: string, lastName: string, email: string, userId: string, roleIds: string[] })[] + createdAt: string + updatedAt: string + limitless: boolean + hprodMemory: number + hprodCpu: number + hprodGpu: number + prodMemory: number + prodCpu: number + prodGpu: number + status: ProjectV2['status'] + operationsInProgress: Ref + myPerms: bigint + repositories: Ref + environments: Ref + deployments: Ref + services: ProjectService[] = [] + lastSuccessProvisionningVersion: string | null + needReplay: Ref + + constructor(project: ProjectV2) { + this.id = project.id + this.clusterIds = project.clusterIds + this.description = project.description + this.everyonePerms = project.everyonePerms + this.name = project.name + this.slug = project.slug + this.locked = project.locked + this.owner = project.owner + this.ownerId = project.ownerId + this.roles = project.roles + this.members = project.members + this.createdAt = project.createdAt + this.updatedAt = project.updatedAt + this.limitless = project.limitless + this.hprodMemory = project.hprodMemory + this.hprodCpu = project.hprodCpu + this.hprodGpu = project.hprodGpu + this.prodMemory = project.prodMemory + this.prodCpu = project.prodCpu + this.prodGpu = project.prodGpu + this.lastSuccessProvisionningVersion = project.lastSuccessProvisionningVersion + this.status = project.status + this.myPerms = calculateProjectPerms(this, useUserStore().userProfile?.id) + this.operationsInProgress = ref([]) + this.environments = ref([]) + this.repositories = ref([]) + this.needReplay = ref(false) + this.deployments = ref([]) + } + + private removeOperation(operationName: ProjectOperations) { + setTimeout(() => { + useLogStore().needRefresh = true + }, 100) + this.operationsInProgress.value = this.operationsInProgress.value.filter(op => op !== operationName) + return this.operationsInProgress.value + } + + private addOperation(operationName: ProjectOperations): () => void { + this.needReplay.value = ['teamManagement', 'saveServices', 'roleManagement'].includes(operationName) + if (this.operationsInProgress.value.includes(operationName)) { + operationName += getRandomId() + } + if (this.operationsInProgress.value.length <= 1) { + this.operationsInProgress.value.push(operationName) + } else { + return () => {} + } + return () => { this.removeOperation(operationName) } + } + + private computePerms() { + this.myPerms = calculateProjectPerms(this, useUserStore().userProfile?.id) + } + + Commands = { + update: async (data: typeof projectContract.updateProject.body._type) => { + const callback = this.addOperation('update') + try { + const project = await apiClient.Projects.updateProject({ body: data, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + .finally(() => callback()) + return this.Commands.updateData(project) + } finally { + callback() + } + }, + updateData: (project: Partial) => { + for (const key of objectKeys(project)) { + // @ts-ignore + this[key] = project[key] + } + this.computePerms() + return undefined + }, + refresh: async () => { + const project = await apiClient.Projects.getProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.Commands.updateData(project) + await Promise.all([ + this.Repositories.list(), + this.Environments.list(), + ]) + return undefined + }, + replay: async () => { + const callback = this.addOperation('update') + try { + await apiClient.Projects.replayHooksForProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 204)) + return this.Commands.refresh() + } finally { + callback() + } + }, + delete: async () => { + const callback = this.addOperation('delete') + try { + await apiClient.Projects.archiveProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 204)) + this.status = 'archived' + } catch { + await this.Commands.refresh() + } finally { + callback() + } + }, + } + + Members = { + list: async () => { + this.members = await apiClient.ProjectsMembers.listMembers({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.members + }, + create: async (email: string) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.addMember({ params: { projectId: this.id }, body: { email } }) + .then((response: any) => extractData(response, 201)) + return this.Members.list() + } finally { callback() } + }, + delete: async (userId: User['id']) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.removeMember({ params: { projectId: this.id, userId } }) + .then((response: any) => extractData(response, 200)) + return this.Members.list() + } finally { callback() } + }, + patch: async (body: typeof projectMemberContract.patchMembers.body._type) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.patchMembers({ params: { projectId: this.id }, body }) + .then((response: any) => extractData(response, 200)) + return this.Members.list() + } finally { callback() } + }, + getCandidateUsers: async (letters: string) => { + return apiClient.Users.getMatchingUsers({ query: { letters, notInProjectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + } + + Deployments = { + list: async () => { + this.deployments.value = await apiClient.Deployments.listDeployments({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.deployments.value + }, + create: async (deploymentData: Omit) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.createDeployment({ + params: { projectId: this.id }, + body: { ...deploymentData, projectId: this.id }, + }) + .then((response: any) => extractData(response, 201)) + } finally { callback() } + }, + update: async (id: Deployment['id'], deployment: UpdateDeploymentBody) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.updateDeployment({ + params: { projectId: this.id, deploymentId: id }, + body: deployment, + }) + .then((response: any) => extractData(response, 200)) + } finally { callback() } + }, + delete: async (deploymentId: Deployment['id']) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.deleteDeployment({ + params: { projectId: this.id, deploymentId }, + }) + .then((response: any) => extractData(response, 204)) + } finally { callback() } + }, + } + + Environments = { + list: async () => { + this.environments.value = await apiClient.Environments.listEnvironments({ query: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.environments.value + }, + create: async (envData: Omit) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.createEnvironment({ body: { ...envData, projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + return this.Environments.list() + } finally { callback() } + }, + update: async (id: Environment['id'], environment: UpdateEnvironmentBody) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.updateEnvironment({ body: environment, params: { environmentId: id } }) + .then((response: any) => extractData(response, 200)) + await this.Environments.list() + return this.environments + } finally { callback() } + }, + delete: async (environmentId: Environment['id']) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.deleteEnvironment({ params: { environmentId } }) + .then((response: any) => extractData(response, 204)) + await this.Environments.list() + return this.environments + } finally { callback() } + }, + } + + Repositories = { + list: async () => { + this.repositories.value = await apiClient.Repositories.listRepositories({ query: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.repositories.value + }, + sync: async (repositoryId: Repo['id'], { branchName, syncAllBranches = false }: { branchName?: string, syncAllBranches?: boolean }) => { + const callback = this.addOperation('repoManagement') + try { + return apiClient.Repositories.syncRepository({ + params: { repositoryId }, + body: { branchName, syncAllBranches }, + }) + .then((response: any) => extractData(response, 204)) + } finally { callback() } + }, + create: async (repoData: Omit) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.createRepository({ body: { ...repoData, projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + return this.Repositories.list() + } finally { callback() } + }, + update: async (id: RepositoryParams['repositoryId'], repoData: Omit) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.updateRepository({ body: { ...repoData, projectId: this.id }, params: { repositoryId: id } }) + .then((response: any) => extractData(response, 200)) + return this.Repositories.list() + } finally { callback() } + }, + delete: async (repositoryId: Repo['id']) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.deleteRepository({ params: { repositoryId } }) + .then((response: any) => extractData(response, 204)) + return this.Repositories.list() + } finally { callback() } + }, + } + + Roles = { + countMembers: async () => { + return apiClient.ProjectsRoles.projectRoleMemberCounts({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + list: async () => { + this.roles = await apiClient.ProjectsRoles.listProjectRoles({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.computePerms() + return this.roles + }, + patch: async (body: typeof projectRoleContract.patchProjectRoles.body._type) => { + const callback = this.addOperation('roleManagement') + try { + this.roles = await apiClient.ProjectsRoles.patchProjectRoles({ body, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.computePerms() + return this.roles + } finally { callback() } + }, + create: async (body: typeof projectRoleContract.createProjectRole.body._type) => { + this.roles = await apiClient.ProjectsRoles.createProjectRole({ body, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + this.computePerms() + return this.roles + }, + delete: async (roleId: Role['id']) => { + const callback = this.addOperation('roleManagement') + try { + await apiClient.ProjectsRoles.deleteProjectRole({ params: { projectId: this.id, roleId } }) + .then((response: any) => extractData(response, 204)) + this.computePerms() + return this.Roles.list() + } finally { callback() } + }, + } + + Services = { + getSecrets: async () => { + return apiClient.Projects.getProjectSecrets({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + list: async (permissionTarget: PermissionTarget = 'user') => { + this.services = await apiClient.ProjectServices.getServices({ params: { projectId: this.id }, query: { permissionTarget } }) + .then((response: any) => extractData(response, 200)) + return this.services + }, + update: async (body: PluginsUpdateBody) => { + const callback = this.addOperation('saveServices') + try { + await apiClient.ProjectServices.updateProjectServices({ params: { projectId: this.id }, body }) + .then((response: any) => extractData(response, 204)) + return this.Services.list() + } finally { callback() } + }, + } + + Logs = { + list: async ({ offset, limit, clean }: GetLogsQuery = { offset: 0, limit: 10 }) => { + return apiClient.Logs.getLogs({ query: { offset, limit, clean, projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + } +} diff --git a/.jjconflict-base-1/apps/client/src/utils/regex.spec.ts b/.jjconflict-base-1/apps/client/src/utils/regex.spec.ts new file mode 100644 index 0000000000..353338ee8c --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/utils/regex.spec.ts @@ -0,0 +1,25 @@ +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { email, noSpace, uuid } from '@/utils/regex.js' + +describe('regex', () => { + it('should return false (noSpace)', () => { + expect(noSpace.test('te st')).toStrictEqual(false) + }) + it('should return true (noSpace)', () => { + expect(noSpace.test('test')).toStrictEqual(true) + }) + + it('should return false (email)', () => { + expect(email.test('prenom.nom')).toStrictEqual(false) + }) + it('should return true (email)', () => { + expect(email.test('prenom.nom@interieur.gouv.fr')).toStrictEqual(true) + }) + it('should return true (uuid)', () => { + expect(uuid.test(faker.string.uuid())).toStrictEqual(true) + }) + it('should return false (uuid)', () => { + expect(uuid.test('abc')).toStrictEqual(false) + }) +}) diff --git a/.jjconflict-base-1/apps/client/src/utils/regex.ts b/.jjconflict-base-1/apps/client/src/utils/regex.ts new file mode 100644 index 0000000000..38ded36071 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/utils/regex.ts @@ -0,0 +1,5 @@ +export const noSpace: RegExp = /^\S*$/ + +export const email: RegExp = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/ + +export const uuid: RegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i diff --git a/.jjconflict-base-1/apps/client/src/views/CreateProject.vue b/.jjconflict-base-1/apps/client/src/views/CreateProject.vue new file mode 100644 index 0000000000..797df6602d --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/CreateProject.vue @@ -0,0 +1,148 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/DsoHome.vue b/.jjconflict-base-1/apps/client/src/views/DsoHome.vue new file mode 100644 index 0000000000..6e141e518b --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/DsoHome.vue @@ -0,0 +1,183 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/DsoMaintenance.vue b/.jjconflict-base-1/apps/client/src/views/DsoMaintenance.vue new file mode 100644 index 0000000000..e220e5965c --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/DsoMaintenance.vue @@ -0,0 +1,36 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/NotFound.vue b/.jjconflict-base-1/apps/client/src/views/NotFound.vue new file mode 100644 index 0000000000..a0e59fb9cf --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/NotFound.vue @@ -0,0 +1,22 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/ProjectDashboard.vue b/.jjconflict-base-1/apps/client/src/views/ProjectDashboard.vue new file mode 100644 index 0000000000..d411ed7e78 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/ProjectDashboard.vue @@ -0,0 +1,344 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/ServicesHealth.vue b/.jjconflict-base-1/apps/client/src/views/ServicesHealth.vue new file mode 100644 index 0000000000..ca9f021061 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/ServicesHealth.vue @@ -0,0 +1,107 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/admin/AdminCluster.vue b/.jjconflict-base-1/apps/client/src/views/admin/AdminCluster.vue new file mode 100644 index 0000000000..fe87036105 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/admin/AdminCluster.vue @@ -0,0 +1,101 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/admin/AdminRoles.vue b/.jjconflict-base-1/apps/client/src/views/admin/AdminRoles.vue new file mode 100644 index 0000000000..3c4357c340 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/admin/AdminRoles.vue @@ -0,0 +1,127 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/admin/AdminServiceChain.vue b/.jjconflict-base-1/apps/client/src/views/admin/AdminServiceChain.vue new file mode 100644 index 0000000000..cee1e4baa2 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/admin/AdminServiceChain.vue @@ -0,0 +1,42 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/admin/AdminTokens.vue b/.jjconflict-base-1/apps/client/src/views/admin/AdminTokens.vue new file mode 100644 index 0000000000..e4cb9e9e6c --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/admin/AdminTokens.vue @@ -0,0 +1,147 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/admin/DsoAdmin.vue b/.jjconflict-base-1/apps/client/src/views/admin/DsoAdmin.vue new file mode 100644 index 0000000000..415c8d7276 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/admin/DsoAdmin.vue @@ -0,0 +1,14 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/admin/ListClusters.vue b/.jjconflict-base-1/apps/client/src/views/admin/ListClusters.vue new file mode 100644 index 0000000000..7074ceee8a --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/admin/ListClusters.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/views/admin/ListLogs.vue b/.jjconflict-base-1/apps/client/src/views/admin/ListLogs.vue new file mode 100644 index 0000000000..2fab771995 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/admin/ListLogs.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/views/admin/ListPlugins.vue b/.jjconflict-base-1/apps/client/src/views/admin/ListPlugins.vue new file mode 100644 index 0000000000..250e3938ec --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/admin/ListPlugins.vue @@ -0,0 +1,140 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/admin/ListProjects.vue b/.jjconflict-base-1/apps/client/src/views/admin/ListProjects.vue new file mode 100644 index 0000000000..171f4ea557 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/admin/ListProjects.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/views/admin/ListServiceChains.vue b/.jjconflict-base-1/apps/client/src/views/admin/ListServiceChains.vue new file mode 100644 index 0000000000..2894d83443 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/admin/ListServiceChains.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/src/views/admin/ListStages.vue b/.jjconflict-base-1/apps/client/src/views/admin/ListStages.vue new file mode 100644 index 0000000000..060aec61fb --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/admin/ListStages.vue @@ -0,0 +1,169 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/admin/ListUser.vue b/.jjconflict-base-1/apps/client/src/views/admin/ListUser.vue new file mode 100644 index 0000000000..6fd88db2d3 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/admin/ListUser.vue @@ -0,0 +1,261 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/admin/ListZones.vue b/.jjconflict-base-1/apps/client/src/views/admin/ListZones.vue new file mode 100644 index 0000000000..18698cadfa --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/admin/ListZones.vue @@ -0,0 +1,173 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/admin/SystemSettings.vue b/.jjconflict-base-1/apps/client/src/views/admin/SystemSettings.vue new file mode 100644 index 0000000000..8054ef29f5 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/admin/SystemSettings.vue @@ -0,0 +1,29 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/profile/PersonalAccessTokens.vue b/.jjconflict-base-1/apps/client/src/views/profile/PersonalAccessTokens.vue new file mode 100644 index 0000000000..f97b54cb59 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/profile/PersonalAccessTokens.vue @@ -0,0 +1,128 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/profile/ProfileWrapper.vue b/.jjconflict-base-1/apps/client/src/views/profile/ProfileWrapper.vue new file mode 100644 index 0000000000..d31d761dfd --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/profile/ProfileWrapper.vue @@ -0,0 +1,7 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/profile/UserInfo.vue b/.jjconflict-base-1/apps/client/src/views/profile/UserInfo.vue new file mode 100644 index 0000000000..c3b9e4a1b5 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/profile/UserInfo.vue @@ -0,0 +1,66 @@ + + + diff --git a/.jjconflict-base-1/apps/client/src/views/projects/DsoProjects.vue b/.jjconflict-base-1/apps/client/src/views/projects/DsoProjects.vue new file mode 100644 index 0000000000..d4174d1337 --- /dev/null +++ b/.jjconflict-base-1/apps/client/src/views/projects/DsoProjects.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/.jjconflict-base-1/apps/client/tsconfig.eslint.json b/.jjconflict-base-1/apps/client/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-1/apps/client/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-1/apps/client/tsconfig.json b/.jjconflict-base-1/apps/client/tsconfig.json new file mode 100644 index 0000000000..7f8a3442bf --- /dev/null +++ b/.jjconflict-base-1/apps/client/tsconfig.json @@ -0,0 +1,62 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + + "jsx": "preserve", + "lib": ["DOM", "ESNext"], + // Base + "baseUrl": "./", + "rootDir": "./src", + "module": "Preserve", + "moduleResolution": "Bundler", + "paths": { + "@/*": [ + "src/*" + ] + }, + "resolveJsonModule": true, + "types": [ + "vitest", + "vite/client", + "vite-plugin-pwa/vue", + "vite-plugin-pwa/client" + ], + "allowImportingTsExtensions": false, + "allowJs": true, + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useUnknownInCatchVariables": true, + "declaration": true, + "declarationDir": "./types", + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "./dist", + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "vueCompilerOptions": {}, + "include": [ + "./src/**/*.ts", + "./src/**/*.js", + "./src/**/*.vue", + "./src/**/*.d.ts" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-1/apps/client/uno.config.ts b/.jjconflict-base-1/apps/client/uno.config.ts new file mode 100644 index 0000000000..6035f5b009 --- /dev/null +++ b/.jjconflict-base-1/apps/client/uno.config.ts @@ -0,0 +1,23 @@ +import { + defineConfig, + presetIcons, + presetUno, + transformerDirectives, + transformerVariantGroup, +} from 'unocss' + +export default defineConfig({ + presets: [ + presetUno(), + presetIcons({ + scale: 1.2, + extraProperties: { + display: 'inline-block', + }, + }), + ], + transformers: [ + transformerDirectives(), + transformerVariantGroup(), + ], +}) diff --git a/.jjconflict-base-1/apps/client/vite.config.ts b/.jjconflict-base-1/apps/client/vite.config.ts new file mode 100644 index 0000000000..817f1306c6 --- /dev/null +++ b/.jjconflict-base-1/apps/client/vite.config.ts @@ -0,0 +1,145 @@ +import fs from 'node:fs' +import { fileURLToPath, URL } from 'node:url' +import { + vueDsfrAutoimportPreset, + vueDsfrComponentResolver, +} from '@gouvminint/vue-dsfr/meta' +import vue from '@vitejs/plugin-vue' +import UnoCSS from 'unocss/vite' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { defineConfig } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) + process.loadEnvFile('.env.integ') + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) + process.loadEnvFile('.env') + +const serverHost = process.env.SERVER_HOST ?? 'localhost' +const serverPort = process.env.SERVER_PORT ?? 4000 +const clientPort = process.env.CLIENT_PORT ?? 8080 + +const define = process.env.NODE_ENV === 'production' + ? { 'process.env': { APP_VERSION: process.env.APP_VERSION } } + : { 'process.env': process.env } + +// https://vitejs.dev/config/ +export default defineConfig({ + server: { + host: '0.0.0.0', + port: Number(clientPort) || 8080, + proxy: { + '^/api': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + '^/swagger-ui-server': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + '^/swagger-ui-server-nestjs': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + }, + }, + define, + plugins: [ + vue(), + AutoImport({ + imports: [ + // @ts-ignore + 'vue', + // @ts-ignore + 'vue-router', + // @ts-ignore + 'pinia', + // @ts-ignore + vueDsfrAutoimportPreset, + ], + vueTemplate: true, + dts: './src/auto-imports.d.ts', + eslintrc: { + enabled: true, + filepath: './.eslintrc-auto-import.json', + globalsPropValue: true, + }, + }), + Components({ + extensions: ['vue'], + dirs: [ + './src/components', + './src/views', + ], + include: [/\.vue$/, /\.vue\?vue/], + dts: './src/components.d.ts', + resolvers: [ + vueDsfrComponentResolver, + ], + }), + UnoCSS({ + extendTheme: (theme) => { + return { + ...theme, + breakpoints: { + ...theme.breakpoints, + dsfrmenu: '992px', + }, + } + }, + }), + VitePWA({ + registerType: 'prompt', // autoUpdate + // disable: true, + // selfDestroying: true, + workbox: { + maximumFileSizeToCacheInBytes: 5_000_000, + cleanupOutdatedCaches: true, + navigateFallbackDenylist: [ + /^\/api/, + /^\/swagger-ui-server/, + /^\/swagger-ui-server-nestjs/, + ], + }, + devOptions: { + enabled: false, + }, + manifest: { + name: 'Console Cloud Pi Native', + short_name: 'CPiN', + description: 'Une console web pour les controler tous', + start_url: '/', + display: 'standalone', + background_color: '#ffffff', + theme_color: '#42b883', + icons: [ + { + src: '/favicon.ico', + sizes: '16x16', + type: 'image/png', + }, + ], + }, + }), + ], + base: '/', + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + dedupe: ['vue'], + }, + build: { + target: 'ESNext', + }, + optimizeDeps: { + include: [ + 'jszip', + ], + }, +}) diff --git a/.jjconflict-base-1/apps/client/vitest.config.ts b/.jjconflict-base-1/apps/client/vitest.config.ts new file mode 100644 index 0000000000..7aa0e9fda4 --- /dev/null +++ b/.jjconflict-base-1/apps/client/vitest.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import { mergeConfig } from 'vite' +import { configDefaults, defineConfig } from 'vitest/config' +import viteConfig from './vite.config' + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + reporters: ['default', 'hanging-process'], + environment: 'jsdom', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: [ + '**/types', + '**/mocks', + '**/*.spec.ts', + '**/*.d.ts', + '**/*.vue', + 'src/main.ts', + 'src/icons.ts', + 'src/router/index.ts', + ], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + root: fileURLToPath(new URL('./', import.meta.url)), + pool: 'forks', + }, + }), +) diff --git a/.jjconflict-base-1/apps/nginx-strangler/Dockerfile b/.jjconflict-base-1/apps/nginx-strangler/Dockerfile new file mode 100644 index 0000000000..ec2c6753fd --- /dev/null +++ b/.jjconflict-base-1/apps/nginx-strangler/Dockerfile @@ -0,0 +1,32 @@ +FROM nginxinc/nginx-unprivileged:1.31-alpine AS prod + +USER root + +# On supprime la config par défaut +RUN rm /etc/nginx/conf.d/default.conf + +# Config principale +COPY apps/nginx-strangler/nginx.conf /etc/nginx/nginx.conf + +# Template de routing (sera substitué au démarrage) +COPY apps/nginx-strangler/conf.d/routing.conf /etc/nginx/templates/routing.conf.template + +# Donner à l'utilisateur nginx et au groupe root les droits d'écriture sur conf.d/ (pour subst au démarrage) +# et sur les répertoires de logs/pid nécessaires en mode non-root +# OpenShift exécute les conteneurs avec un UID arbitraire appartenant au groupe root. +RUN chown -R nginx:root \ + /etc/nginx/nginx.conf \ + /etc/nginx/conf.d \ + /etc/nginx/templates \ + /etc/nginx/mime.types \ + /var/cache/nginx \ + /var/log/nginx \ + && touch /var/run/nginx.pid \ + && chown nginx:root /var/run/nginx.pid && chmod g+w /var/run/nginx.pid + +USER nginx + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/health || exit 1 + +EXPOSE 8080 diff --git a/.jjconflict-base-1/apps/nginx-strangler/README.md b/.jjconflict-base-1/apps/nginx-strangler/README.md new file mode 100644 index 0000000000..f7bd5af63e --- /dev/null +++ b/.jjconflict-base-1/apps/nginx-strangler/README.md @@ -0,0 +1,120 @@ +# nginx-strangler + +Reverse proxy dédié à la migration progressive du backend `server` +(Fastify/legacy) vers `server-nestjs` (NestJS), selon le [Strangler FigPattern](https://martinfowler.com/bliki/StranglerFigApplication.html). + +Ticket de référence : [#1885](https://github.com/cloud-pi-native/console/issues/1885) + +--- + +## Architecture + +``` +[Client nginx :8080] + │ proxy_pass → nginx-strangler:8080 + ▼ +[nginx-strangler :8080] + ├── /api/[routes migrées] ──→ server-nestjs:3001 + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +--- + +## Variables d'environnement + +| Variable | Description | Exemple Docker | Exemple local | +|---|---|---|---| +| `LEGACY_UPSTREAM` | Adresse du backend Fastify legacy | `server:8080` | `127.0.0.1:4000` | +| `NESTJS_UPSTREAM` | Adresse du backend NestJS | `server-nestjs:3001` | `127.0.0.1:3001` | + +Ces variables sont substituées dans `conf.d/routing.conf` via `envsubst` au démarrage du conteneur. + +--- + +## Basculer une route vers NestJS + +Éditer `conf.d/routing.conf` et ajouter un bloc `location` **avant** le bloc fallback `/api/`, en respectant le format suivant : + +```nginx +# [Vague/Itération X - nom-du-module] AAAA-MM-JJ +location = /api/v1/ma-route { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Puis recharger nginx sans downtime : + +```bash +docker compose exec nginx-strangler nginx -t # valider la config +docker compose exec nginx-strangler nginx -s reload +``` + +### Modificateurs de location + +| Modificateur | Usage | +|---|---| +| `=` | Route exacte (ex: `GET /api/v1/system/health`) | +| `^~` | Préfixe prioritaire (ex: tout un module `/api/v1/users/`) | +| *(aucun)* | Préfixe standard | + +--- + +## Rollback d'une route + +En cas de problème sur une route basculée : + +```bash +# 1. Dans conf.d/routing.conf, commenter le bloc concerné : +# # [Vague X - module] AAAA-MM-JJ ← ROLLBACK AAAA-MM-JJ +# # location = /api/v1/xxx { ... } + +# 2. Recharger nginx sans downtime +docker compose exec nginx-strangler nginx -s reload + +# 3. Vérifier les logs +docker compose logs -f nginx-strangler | grep "/api/v1/xxx" +``` + +--- + +## Lancer en dev local (pnpm dev) + +Quand `server` et `server-nestjs` tournent nativement (hors Docker), le +`nginx-strangler` reste lui en Docker avec des upstreams pointant vers les +ports "locaux" des deux services: + +```bash +# Dans docker/docker-compose.local.yml, le service nginx-strangler utilise : +# LEGACY_UPSTREAM=127.0.0.1:4000 +# NESTJS_UPSTREAM=127.0.0.1:3001 + +docker compose -f docker/docker-compose.local.yml up nginx-strangler +``` + +--- + +## Vérification de la configuration + +```bash +# Tester la syntaxe (depuis l'hôte) +docker compose exec nginx-strangler nginx -t + +# Voir la config résolue (après substitution envsubst) +docker compose exec nginx-strangler cat /etc/nginx/conf.d/routing.conf + +# Logs en temps réel +docker compose logs -f nginx-strangler +``` + +--- + +## Liens + +- Plan de mise en place : [`apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md`](../server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md) +- Stratégie de modularisation : [`apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md`](../server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md) +- Cartographie des modules : [`apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md`](../server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) diff --git a/.jjconflict-base-1/apps/nginx-strangler/conf.d/routing.conf b/.jjconflict-base-1/apps/nginx-strangler/conf.d/routing.conf new file mode 100644 index 0000000000..25bf6f7192 --- /dev/null +++ b/.jjconflict-base-1/apps/nginx-strangler/conf.d/routing.conf @@ -0,0 +1,106 @@ +# ============================================================================= +# nginx-strangler — Configuration de routage "as-code" +# +# Ce fichier est le cœur du pattern Strangler Fig. +# Il route progressivement les routes de server (legacy) vers server-nestjs. +# +# Variables d'environnement substituées au démarrage (via envsubst) : +# LEGACY_UPSTREAM — adresse du backend Fastify legacy (ex: server:8080) +# NESTJS_UPSTREAM — adresse du backend NestJS (ex: server-nestjs:3001) +# +# Format d'une bascule : +# # [Vague X - module] AAAA-MM-JJ +# location [= | ^~ | ~] /api/v1/xxx { +# proxy_pass http://server-nestjs; +# include /etc/nginx/conf.d/_proxy_headers.conf; +# } +# +# Procédure de rollback d'une route : +# 1. Commenter le bloc location concerné (ajouter "# ROLLBACK AAAA-MM-JJ") +# 2. docker compose exec nginx-strangler nginx -s reload +# ============================================================================= + +upstream server-legacy { + server ${LEGACY_UPSTREAM}; +} + +upstream server-nestjs { + server ${NESTJS_UPSTREAM}; +} + +server { + listen 8080; + + # Taille des headers (nécessaire pour les tokens Keycloak) + large_client_header_buffers 4 32k; + + # Healthcheck de nginx-strangler lui-même + location = /health { + access_log off; + add_header 'Content-Type' 'application/json'; + return 200 '{"status":"UP"}'; + } + + # ── Swagger UI ──────────────────────────────────────────────────────────────── + location /swagger-ui-server-nestjs { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /swagger-ui-server { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── Routes migrées vers NestJS ──────────────────────────────────────────────── + location /api/v1/service-chains { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ~ ^/api/v1/projects/[^/]+/deployments(.*)$ { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Les routes /api/v2 n'existent que sur server-nestjs + location /api/v2/ { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── Routes par défaut (pour l'instant dans le legacy) ───────────────────────── + location /api/ { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_redirect off; + } + +} diff --git a/.jjconflict-base-1/apps/nginx-strangler/conf.d/routing.local.conf.example b/.jjconflict-base-1/apps/nginx-strangler/conf.d/routing.local.conf.example new file mode 100644 index 0000000000..b8f62e2e8b --- /dev/null +++ b/.jjconflict-base-1/apps/nginx-strangler/conf.d/routing.local.conf.example @@ -0,0 +1,64 @@ +# ============================================================================= +# Exemple de configuration nginx-strangler pour le développement local +# (pnpm dev — server et server-nestjs tournent nativement, hors Docker) +# +# Ce fichier est fourni à titre documentaire. +# En pratique, le docker-compose.local.yml injecte directement les variables +# d'environnement LEGACY_UPSTREAM et NESTJS_UPSTREAM dans le conteneur +# nginx-strangler via envsubst, sans qu'il soit nécessaire de modifier ce fichier. +# +# Pour lancer le nginx-strangler en mode local : +# +# docker compose -f docker/docker-compose.local.yml up nginx-strangler +# +# Les valeurs par défaut dans docker-compose.local.yml : +# LEGACY_UPSTREAM=host.docker.internal:4000 (server Fastify natif) +# NESTJS_UPSTREAM=host.docker.internal:3001 (server-nestjs natif) +# +# Si vos servers écoutent sur des ports différents, surcharger les variables : +# +# LEGACY_UPSTREAM=host.docker.internal:4000 \ +# NESTJS_UPSTREAM=host.docker.internal:3001 \ +# docker compose -f docker/docker-compose.local.yml up nginx-strangler +# +# Le nginx-strangler est alors accessible sur http://localhost:8082 +# (le client Vue.js en dev natif doit pointer son proxy /api vers localhost:8082) +# ============================================================================= + +upstream server-legacy { + server host.docker.internal:4000; +} + +upstream server-nestjs { + server host.docker.internal:3001; +} + +server { + listen 8080; + + # ── Routes migrées vers NestJS ──────────────────────────────────────────── + # (vide au démarrage — toutes les routes sont en fallback sur server-legacy) + + # ── Fallback : tout le reste vers le server legacy ──────────────────────── + location /api/ { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_redirect off; + } + + location /swagger-ui { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/.jjconflict-base-1/apps/nginx-strangler/nginx.conf b/.jjconflict-base-1/apps/nginx-strangler/nginx.conf new file mode 100644 index 0000000000..657e2100dd --- /dev/null +++ b/.jjconflict-base-1/apps/nginx-strangler/nginx.conf @@ -0,0 +1,28 @@ +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + 'upstream=$upstream_addr rt=$request_time'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + + # Taille des headers (nécessaire pour les tokens Keycloak) + large_client_header_buffers 4 32k; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/.jjconflict-base-1/apps/server-nestjs/.env-example b/.jjconflict-base-1/apps/server-nestjs/.env-example new file mode 100644 index 0000000000..1da8adba5c --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/.env-example @@ -0,0 +1,61 @@ +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Activation des plugins externes (true | false). Actif par défaut en développement. +USE_PLUGINS=true +# HOME=/home/node +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# Identifiant de l'administrateur Keycloak sur le royaume KEYCLOAK_REALM +KEYCLOAK_ADMIN=admin +# Mot de passe de l'administrateur Keycloak sur le royaume KEYCLOAK_REALM +KEYCLOAK_ADMIN_PASSWORD=admin +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 +# Interface d'écoute du serveur backend +SERVER_HOST=localhost +# Port d'écoute du serveur backend +SERVER_PORT=3001 +# URL de connexion à la base de données PostgreSQL (format Prisma) +DB_URL=postgresql://admin:admin@localhost:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +OPENCDS_URL= +# URL interne de l'API OpenCDS +OPENCDS_INTERNAL_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_GITLAB=false +USE_NEXUS=false +USE_SONARQUBE=false +USE_REGISTRY=false +USE_HARBOR=false +USE_OPENCDS=false +USE_ARGOCD=false diff --git a/.jjconflict-base-1/apps/server-nestjs/.env.docker-example b/.jjconflict-base-1/apps/server-nestjs/.env.docker-example new file mode 100644 index 0000000000..d0cf596b3a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/.env.docker-example @@ -0,0 +1,64 @@ +# Indique que l'application tourne dans un conteneur Docker +DOCKER=true +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Activation des plugins externes (true | false). +USE_PLUGINS=fasle +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine du Keycloak (nom du service Docker Compose + port interne) +KEYCLOAK_DOMAIN=keycloak:8080 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http en Docker local) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# Identifiant de l'administrateur Keycloak +KEYCLOAK_ADMIN=admin +# Mot de passe de l'administrateur Keycloak +KEYCLOAK_ADMIN_PASSWORD=admin +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 +# Interface d'écoute du serveur backend +SERVER_HOST=0.0.0.0 +# Port d'écoute du serveur dans le réseau Docker +SERVER_PORT=3001 +# URL de connexion PostgreSQL (utilise le nom de service Docker 'postgres') +DB_URL=postgresql://admin:admin@postgres:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +# Par défaut configuré sur le conteneur Mockoon (à changer sur envs non-PAX) +OPENCDS_URL= +# URL interne de l'API OpenCDS +OPENCDS_INTERNAL_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_GITLAB=false +USE_VAULT=false +USE_NEXUS=false +USE_SONARQUBE=false +USE_REGISTRY=false +USE_HARBOR=false +USE_OPENCDS=false +USE_ARGOCD=false diff --git a/.jjconflict-base-1/apps/server-nestjs/.env.integ-example b/.jjconflict-base-1/apps/server-nestjs/.env.integ-example new file mode 100644 index 0000000000..f0bf743326 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/.env.integ-example @@ -0,0 +1,130 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Désactive l'initialisation des données de démo (l'intégration a ses propres données) +DEV_SETUP="false" +# Active le mode intégration (charge ce fichier en surcharge) +INTEGRATION=true +# Activation des plugins externes (true | false). Désactivé en mode intégration. +USE_PLUGINS=false + +# --- Keycloak --- +# Protocole (généralement https en intégration) +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= +# Secret du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_SECRET= +# Domaine du Keycloak d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak d'intégration +KEYCLOAK_REALM= + +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 + +# --- ArgoCD --- +# Namespace Kubernetes dans lequel ArgoCD est déployé +ARGO_NAMESPACE= +# URL de l'API ArgoCD +ARGOCD_URL= +# URL interne de l'API ArgoCD +ARGOCD_INTERNAL_URL= + +# --- GitLab --- +# Token d'accès personnel GitLab (scope api) +GITLAB_TOKEN= +# URL de l'instance GitLab +GITLAB_URL= +# URL interne de l'API Gitlab +GITLAB_INTERNAL_URL= + +# --- Harbor --- +# Nom d'utilisateur admin Harbor +HARBOR_ADMIN= +# Mot de passe admin Harbor +HARBOR_ADMIN_PASSWORD= +# URL de l'instance Harbor +HARBOR_URL= +# URL interne de l'API Harbor +HARBOR_INTERNAL_URL= + +# --- Keycloak (admin) --- +# Utilisateur admin Keycloak pour la gestion des royaumes/clients +KEYCLOAK_ADMIN= +# Mot de passe admin Keycloak +KEYCLOAK_ADMIN_PASSWORD= +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL complète de Keycloak (peut différer de KEYCLOAK_DOMAIN) +KEYCLOAK_URL= + +# --- Nexus --- +# Nom d'utilisateur admin Nexus +NEXUS_ADMIN= +# Mot de passe admin Nexus +NEXUS_ADMIN_PASSWORD= +# URL de l'instance Nexus +NEXUS_URL= +# URL interne de l'API Nexus +NEXUS_INTERNAL_URL= + +# --- Divers --- +# Répertoire racine des projets dans Gitlab +PROJECTS_ROOT_DIR= + +# --- SonarQube --- +# Token d'API SonarQube +SONAR_API_TOKEN= +# URL de l'instance SonarQube +SONARQUBE_URL= +# URL interne de l'API SonarQube +SONARQUBE_INTERNAL_URL= + +# --- Vault --- +# Token d'authentification Vault +VAULT_TOKEN= +# URL de l'instance Vault +VAULT_URL= +# URL interne de l'API Vault +VAULT_INTERNAL_URL= + +# --- Kubeconfig --- +# Chemin vers le kubeconfig sur la machine hôte (monté en volume dans Docker) +KUBECONFIG_HOST_PATH= +# Chemin vers le kubeconfig à l'intérieur du conteneur +KUBECONFIG_PATH=$HOME/.kube/config +# Contexte Kubernetes à utiliser +KUBECONFIG_CTX= + +# Répertoire contenant les plugins externes (monté en volume dans Docker) +EXTERNAL_PLUGINS_DIR_HOST_PATH=/path/to/plugins + +# Informations pour le plugin Observabilité +GRAFANA_URL= +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 + +# --- Configuration proxy (si nécessaire pour accéder aux services externes) --- +# HTTP_PROXY= +# HTTPS_PROXY= +# NO_PROXY= + +# Configuration OpenCDS +OPENCDS_URL= +OPENCDS_INTERNAL_URL= +OPENCDS_API_TOKEN=token +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false + +# --- Activation des plugins externes --- +# Désactivés par défaut en intégration : renseignez les variables et passez à "true" pour activer. +USE_KEYCLOAK=true +USE_GITLAB=true +USE_VAULT=true +USE_NEXUS=true +USE_SONARQUBE=true +USE_REGISTRY=true +USE_HARBOR=true +USE_OPENCDS=true +USE_ARGOCD=true diff --git a/.jjconflict-base-1/apps/server-nestjs/.gitignore b/.jjconflict-base-1/apps/server-nestjs/.gitignore new file mode 100644 index 0000000000..4b56acfbeb --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/.gitignore @@ -0,0 +1,56 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/.jjconflict-base-1/apps/server-nestjs/.prettierrc b/.jjconflict-base-1/apps/server-nestjs/.prettierrc new file mode 100644 index 0000000000..8deec5d940 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/.prettierrc @@ -0,0 +1,12 @@ +{ + "plugins": ["@trivago/prettier-plugin-sort-imports"], + "importOrderParserPlugins": ["typescript", "decorators"], + "importOrder": ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true, + "printWidth": 80, + "semi": true, + "singleQuote": true, + "tabWidth": 4, + "trailingComma": "all" +} diff --git a/.jjconflict-base-1/apps/server-nestjs/Dockerfile b/.jjconflict-base-1/apps/server-nestjs/Dockerfile new file mode 100644 index 0000000000..6eb7f5f180 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/Dockerfile @@ -0,0 +1,106 @@ +# Base / dev stage ---------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Activer pnpm via corepack (version définie dans package.json "packageManager") +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +# Copier les manifestes workspace (nécessaires pour que pnpm résolve les dépendances) +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ + +# Manifestes des packages workspace dont server-nestjs dépend +COPY --chown=node:root apps/server-nestjs/package.json ./apps/server-nestjs/package.json + +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +COPY --chown=node:root plugins/argocd/package.json ./plugins/argocd/package.json +COPY --chown=node:root plugins/gitlab/package.json ./plugins/gitlab/package.json +COPY --chown=node:root plugins/harbor/package.json ./plugins/harbor/package.json +COPY --chown=node:root plugins/keycloak/package.json ./plugins/keycloak/package.json +COPY --chown=node:root plugins/nexus/package.json ./plugins/nexus/package.json +COPY --chown=node:root plugins/sonarqube/package.json ./plugins/sonarqube/package.json +COPY --chown=node:root plugins/vault/package.json ./plugins/vault/package.json + +# Installer toutes les dépendances afin de permettre le build des libs partagées +RUN pnpm install --frozen-lockfile + +# Copier les sources des packages workspace +COPY --chown=node:root plugins/ ./plugins/ +COPY --chown=node:root packages/ ./packages/ +COPY --chown=node:root apps/server-nestjs/ ./apps/server-nestjs/ + +# Build des libs partagées (génère dist/ et types/) +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +# Build hooks (génère dist/ et types/ nécessaires aux imports) +RUN pnpm --filter @cpn-console/hooks run build + +# Réinjecter les libs buildées dans node_modules (injectWorkspacePackages copie au moment de l'install) +RUN pnpm --filter @cpn-console/server-nestjs install --frozen-lockfile + +# Générer le client Prisma (schéma multi-fichiers : pointer sur le dossier) +RUN pnpm --filter @cpn-console/server-nestjs exec prisma generate \ + --schema=src/prisma/schema + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/api/v1/healthz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" + +ENTRYPOINT ["pnpm", "--filter", "server-nestjs", "run"] +CMD ["start:dev"] + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +# Build shared (génère dist/ et types/) +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +# Build hooks (génère dist/ et types/ nécessaires aux imports) +RUN pnpm --filter @cpn-console/hooks run build + +# Réinjecter shared buildé dans node_modules (injectWorkspacePackages copie au moment de l'install) +RUN pnpm --filter @cpn-console/server-nestjs install --frozen-lockfile + +# Build server-nestjs +RUN pnpm --filter @cpn-console/server-nestjs run build + +# Export @cpn-console/server-nestjs to target build directory +RUN pnpm --filter @cpn-console/server-nestjs --prod deploy build + +# Prod stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS prod + +ARG APP_VERSION +ENV APP_VERSION=$APP_VERSION + +VOLUME ["/plugins"] +WORKDIR /app + +RUN mkdir -p /home/node/logs && chmod 770 -R /home/node/logs \ + && mkdir -p /home/node/.npm && chmod 770 -R /home/node/.npm \ + && chown node:root /app + +# Copier l'artefact de build et les node_modules de production +COPY --chown=node:root --from=build /app/build . +COPY --chown=node:root --from=build /app/apps/server-nestjs/dist ./dist +# COPY --chown=node:root --from=build /app/build/node_modules ./node_modules + +# Regénérer le client Prisma dans l'image de production +RUN npm run db:generate + +USER node +EXPOSE 3001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/api/v1/healthz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" + +ENTRYPOINT ["node", "dist/src/main"] diff --git a/.jjconflict-base-1/apps/server-nestjs/README.md b/.jjconflict-base-1/apps/server-nestjs/README.md new file mode 100644 index 0000000000..62673164cc --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/README.md @@ -0,0 +1,543 @@ +# À propos + +Ce dossier contient une nouvelle version de `server`, basée sur NestJS. + +## Objectifs + +Rappel : L'objectif principal de cette démarche est de préparer l'extraction de +chaque "plugin" en tant que module NestJS incluant à la fois la partie Front et +la partie Backend pour une meilleure composition de la Console. + +On va profiter de cette vision pour passer, côté `server` d'un "Back For +Front", à un backend qui va gérer à la fois le Front et le Back de différentes +fonctionnalités (appelée "Plugins"), et ce parfois de manière dynamique (mais +déjà en statique, ce sera pas mal 😅). + +## Conséquences pour apps/server + +On va donc passer de ça : + +```mermaid +flowchart TD + + %% --- Top-level --- + NestJS["Nest.js"] + FutursModules["Futurs modules
Nest.js correctement
découpés..."] + MainModule["MainModule"] + + NestJS --> MainModule + MainModule --> CPinModule + MainModule -.-> FutursModules + + CPinModule["CPinModule
(Contient TOUT le code actuel de 'server')"] + + %% --- Core services --- + ConnectionService["ConnectionService"] + LoggerService["LoggerService"] + FastifyService["FastifyService"] + ServerService["ServerService"] + AppService["AppService"] + ResourceService["ResourceService"] + PrepareAppService["PrepareAppService"] + InitDbService["InitDbService"] + PluginService["PluginService"] + + %% --- Router services --- + AdminRoleRouterService["AdminRoleRouterService"] + AdminTokenRouterService["AdminTokenRouterService"] + ClusterRouterService["ClusterRouterService"] + OtherRouterService["...RouterService"] + + %% --- External services --- + Gitlab["Gitlab"] + ArgoCD["ArgoCD"] + Dots["..."] + + %% --- CPinModule connections --- + CPinModule --> ConnectionService + CPinModule --> AppService + CPinModule --> LoggerService + CPinModule --> FastifyService + CPinModule --> ServerService + CPinModule --> ResourceService + CPinModule --> PrepareAppService + + %% --- AppService central connections --- + ConnectionService --> AppService + LoggerService --> AppService + FastifyService --> AppService + ServerService --> AppService + + AppService --> ResourceService + AppService --> PrepareAppService + AppService --> ServerService + AppService --> FastifyService + AppService --> LoggerService + AppService --> ConnectionService + + %% --- ResourceService to routers --- + ResourceService --> AdminRoleRouterService + ResourceService --> AdminTokenRouterService + ResourceService --> ClusterRouterService + ResourceService --> OtherRouterService + + %% --- PrepareAppService --- + PrepareAppService --> InitDbService + PrepareAppService --> PluginService + PrepareAppService --> ServerService + + %% --- PluginService external interactions --- + PluginService --> Gitlab + PluginService --> ArgoCD + PluginService --> Dots + + %% --- Bounding box (visual grouping only) --- + subgraph CPinBlock[" "] + CPinModule + ConnectionService + LoggerService + FastifyService + ServerService + AppService + ResourceService + PrepareAppService + InitDbService + PluginService + AdminRoleRouterService + AdminTokenRouterService + ClusterRouterService + OtherRouterService + end +``` + +à ça : + +```mermaid +flowchart TD + + %% --- Top-level Nest module --- + NestJS["Point d'entrée de NestJS"] + MainModule["MainModule"] + CPinModule["CPinModule"] + FutursModules["Futurs modules
NestJS correctement
découpés..."] + + NestJS --> MainModule + MainModule --> CPinModule + MainModule -.-> FutursModules + + %% --- Layering for clarity --- + subgraph LayerInit["Initialisation de l'application"] + ApplicationInitializationService["ApplicationInitializationService"] + DatabaseInitializationService["DatabaseInitializationService"] + PluginManagementService["PluginManagementService"] + end + + subgraph LayerCore["Coeur de l'application"] + AppService["AppService"] + RouterService["RouterService"] + ServerService["ServerService"] + end + + subgraph LayerInfra["Couche Infrastructure"] + LoggerService["LoggerService"] + ConfigurationService["ConfigurationService"] + DatabaseService["DatabaseService"] + FastifyService["FastifyService"] + HTTPClientService["HTTPClientService"] + end + + OtherAPIService["APIs externes
(par ex. OpenCDS)"] + HTTPClientService --> OtherAPIService + + subgraph LayerBusiness["Modules métiers"] + subgraph AdminRole["Admin Roles"] + AdminRoleRouterService["AdminRoleRouterService"] + AdminRoleBusinessService["AdminRoleBusinessService"] + AdminRoleDTOService["AdminRoleDTOService"] + AdminRoleRouterService --> AdminRoleBusinessService + AdminRoleRouterService --> LoggerService + AdminRoleBusinessService --> AdminRoleDTOService + AdminRoleBusinessService --> LoggerService + AdminRoleDTOService --> LoggerService + AdminRoleDTOService --> DatabaseService + end + subgraph AdminToken["Admin Tokens"] + AdminTokenRouterService["AdminTokenRouterService"] + AdminTokenBusinessService["AdminTokenBusinessService"] + AdminTokenDTOService["AdminTokenDTOService"] + AdminTokenRouterService --> AdminTokenBusinessService + AdminTokenRouterService --> LoggerService + AdminTokenBusinessService --> AdminTokenDTOService + AdminTokenBusinessService --> LoggerService + AdminTokenDTOService --> DatabaseService + AdminTokenDTOService --> LoggerService + end + subgraph ServiceChain["Service chains"] + ServiceChainRouterService["ServiceChainRouterService"] + ServiceChainBusinessService["ServiceChainBusinessService"] + ServiceChainRouterService --> ServiceChainBusinessService + ServiceChainRouterService --> LoggerService + ServiceChainBusinessService --> HTTPClientService + ServiceChainBusinessService --> LoggerService + + end + subgraph Cluster["Clusters"] + ClusterRouterService["ClusterRouterService"] + ClusterBusinessService["ClusterBusinessService"] + ClusterDTOService["ClusterDTOService"] + ClusterRouterService --> ClusterBusinessService + ClusterRouterService --> LoggerService + ClusterBusinessService --> ClusterDTOService + ClusterBusinessService --> LoggerService + ClusterDTOService --> DatabaseService + ClusterDTOService --> LoggerService + end + OtherBusinessModules["...Other Business Modules"] + end + + RouterService --> AdminRoleRouterService + RouterService --> AdminTokenRouterService + RouterService --> ClusterRouterService + RouterService --> ServiceChainRouterService + RouterService --> OtherBusinessModules + RouterService --> LoggerService + + subgraph LayerPlugins["Plugins compatibles CPiN"] + Gitlab["Gitlab"] + ArgoCD["ArgoCD"] + Kubernetes["Kubernetes"] + Dots["..."] + end + + %% --- Module wiring --- + CPinModule --> ApplicationInitializationService + + %% Application initialization + ApplicationInitializationService --> LoggerService + ApplicationInitializationService --> ConfigurationService + ApplicationInitializationService --> FastifyService + ApplicationInitializationService --> AppService + ApplicationInitializationService --> DatabaseInitializationService + DatabaseInitializationService --> DatabaseService + DatabaseInitializationService --> LoggerService + ApplicationInitializationService --> PluginManagementService + ApplicationInitializationService --> LoggerService + + %% App Core internal flow + AppService --> RouterService + AppService --> ServerService + AppService --> LoggerService + ServerService --> LoggerService + + %% Plugin Management + PluginManagementService --> Gitlab + PluginManagementService --> ArgoCD + PluginManagementService --> Kubernetes + PluginManagementService --> Dots + PluginManagementService --> LoggerService + Gitlab --> LoggerService + ArgoCD --> LoggerService + Kubernetes --> LoggerService + Dots --> LoggerService +``` + +et enfin à ça : + +```mermaid +flowchart TD + + %% --- Top-level Nest module --- + NestJS["Point d'entrée de NestJS"] + MainModule["MainModule"] + + NestJS --> MainModule + + subgraph CoreModule["CoreModule"] + ApplicationInitializationService["ApplicationInitializationService"] + end + + subgraph InfrastructureModule["InfrastructureModule"] + LoggerService["LoggerService"] + ConfigurationService["ConfigurationService"] + DatabaseService["DatabaseService"] + HTTPClientService["HTTPClientService"] + end + + OtherAPIService["APIs externes
(par ex. OpenCDS)"] + HTTPClientService --> OtherAPIService + + subgraph MandatoryModules["Modules obligatoires CPiN"] + subgraph GitlabModule["GitlabModule"] + GitlabController["GitlabController"] + GitlabBusinessService["GitlabBusinessService"] + GitlabDTOService["GitlabDTOService"] + GitlabController --> GitlabBusinessService + GitlabController --> LoggerService + GitlabBusinessService --> GitlabDTOService + GitlabBusinessService --> LoggerService + GitlabDTOService --> DatabaseService + GitlabDTOService --> LoggerService + end + + subgraph ArgoCDModule["ArgoCDModule"] + ArgoCDController["ArgoCDController"] + ArgoCDBusinessService["ArgoCDBusinessService"] + ArgoCDDTOService["ArgoCDDTOService"] + ArgoCDController --> ArgoCDBusinessService + ArgoCDController --> LoggerService + ArgoCDBusinessService --> ArgoCDDTOService + ArgoCDBusinessService --> LoggerService + ArgoCDDTOService --> DatabaseService + ArgoCDDTOService --> LoggerService + end + + subgraph KubernetesModule["KubernetesModule"] + KubernetesController["KubernetesController"] + KubernetesBusinessService["KubernetesBusinessService"] + KubernetesDTOService["KubernetesDTOService"] + KubernetesController --> KubernetesBusinessService + KubernetesController --> LoggerService + KubernetesBusinessService --> KubernetesDTOService + KubernetesBusinessService --> LoggerService + KubernetesDTOService --> DatabaseService + KubernetesDTOService --> LoggerService + end + + subgraph AdminRoleModule["AdminRoleModule"] + AdminRoleController["AdminRoleController"] + AdminRoleBusinessService["AdminRoleBusinessService"] + AdminRoleDTOService["AdminRoleDTOService"] + AdminRoleController --> AdminRoleBusinessService + AdminRoleController --> LoggerService + AdminRoleBusinessService --> AdminRoleDTOService + AdminRoleBusinessService --> LoggerService + AdminRoleDTOService --> LoggerService + AdminRoleDTOService --> DatabaseService + end + + subgraph AdminTokenModule["AdminTokenModule"] + AdminTokenController["AdminTokenController"] + AdminTokenBusinessService["AdminTokenBusinessService"] + AdminTokenDTOService["AdminTokenDTOService"] + AdminTokenController --> AdminTokenBusinessService + AdminTokenController --> LoggerService + AdminTokenBusinessService --> AdminTokenDTOService + AdminTokenBusinessService --> LoggerService + AdminTokenDTOService --> DatabaseService + AdminTokenDTOService --> LoggerService + end + + subgraph ClusterModule["ClusterModule"] + ClusterController["ClusterController"] + ClusterBusinessService["ClusterBusinessService"] + ClusterDTOService["ClusterDTOService"] + ClusterController --> ClusterBusinessService + ClusterController --> LoggerService + ClusterBusinessService --> ClusterDTOService + ClusterBusinessService --> LoggerService + ClusterDTOService --> DatabaseService + ClusterDTOService --> LoggerService + end + OtherBusinessModules["...Other Business Modules"] + end + + CoreModule --> GitlabModule + CoreModule --> ArgoCDModule + CoreModule --> KubernetesModule + CoreModule --> AdminRoleModule + CoreModule --> AdminTokenModule + CoreModule --> ClusterModule + + subgraph ThirdPartyModules["Modules optionnels de CPin"] + subgraph ServiceChainModule["ServiceChainModule"] + ServiceChainController["ServiceChainController"] + ServiceChainBusinessService["ServiceChainBusinessService"] + ServiceChainController --> ServiceChainBusinessService + ServiceChainController --> LoggerService + ServiceChainBusinessService --> HTTPClientService + ServiceChainBusinessService --> LoggerService + end + end + + CoreModule --> ServiceChainModule + + %% --- Module wiring --- + MainModule --> ApplicationInitializationService + + %% Application initialization + ApplicationInitializationService --> LoggerService + ApplicationInitializationService --> ConfigurationService + ApplicationInitializationService --> LoggerService +``` + +## Architecture du module ServiceChain (Vague 1) + +Le module ServiceChain est le **premier module métier migré** depuis le legacy +vers server-nestjs via le pattern Strangler Fig. Il sert de proxy HTTP vers +l'API externe OpenCDS (gestion des chaînes de service réseau). + +### Flux de proxying OpenCDS + +``` +┌──────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ Client │────▶│ nginx-strangler │────▶│ server-nestjs │────▶│ API OpenCDS │ +│ (browser/ │ │ │ │ │ │ (externe) │ +│ script) │ │ /api/v1/ │ │ ServiceChain │ │ │ +│ │◀────│ service-chains │◀────│ Controller │◀────│ /requests │ +│ │ │ ──▶ nestjs │ │ ──▶ Service │ │ /validate │ +└──────────┘ │ │ │ ──▶ axios │ │ /flows │ + │ /api/* (reste) │ └──────────────────┘ └──────────────┘ + │ ──▶ legacy │ + └─────────────────┘ +``` + +### Structure du module + +``` +src/cpin-module/ +├── infrastructure/ +│ └── auth/ # Auth transverse (réutilisable) +│ ├── auth.module.ts # Module NestJS +│ ├── auth.service.ts # Lookup token SHA256 → Prisma +│ ├── admin-permission.guard.ts # Guard : vérifie x-dso-token + permissions +│ ├── admin-permission.decorator.ts @RequireAdminPermission('ListSystem') +│ └── admin-permission.guard.spec.ts +│ +└── service-chain/ # Module métier + ├── service-chain.module.ts # Imports: ConfigurationModule, AuthModule + ├── service-chain.controller.ts # 5 endpoints sous /api/v1/service-chains + ├── service-chain.service.ts # Proxy axios → OpenCDS + validation Zod + ├── service-chain.controller.spec.ts + └── service-chain.service.spec.ts +``` + +### Authentification + +Seule l'**auth par token** (`x-dso-token`) est supportée pour l'instant. +L'auth par session Keycloak sera ajoutée lors de la migration globale du +mécanisme de session vers NestJS (les cookies de session Fastify ne sont pas +déchiffrables par Express). + +``` + Requête HTTP + │ + ▼ + ┌──────────────────────┐ + │ AdminPermissionGuard │ + │ │ + │ 1. Lire header │ + │ x-dso-token │──── absent ──▶ 401 Unauthorized + │ │ + │ 2. SHA256(token) │ + │ → Prisma lookup │──── invalide ──▶ 401 Unauthorized + │ (PersonalAccess │ (expiré, révoqué, introuvable) + │ Token ou Admin │ + │ Token) │ + │ │ + │ 3. Vérifier perms │ + │ bitwise via │──── insuffisant ──▶ 403 Forbidden + │ AdminAuthorized │ + │ │ + │ 4. OK → continuer │──────────────────▶ Controller + └──────────────────────┘ +``` + +### Endpoints + +| Méthode | Route | Permission | Description | +|---------|-------|------------|-------------| +| `GET` | `/api/v1/service-chains` | `ListSystem` | Liste toutes les chaînes | +| `GET` | `/api/v1/service-chains/:id` | `ListSystem` | Détails d'une chaîne | +| `GET` | `/api/v1/service-chains/:id/flows` | `ListSystem` | Flux d'une chaîne | +| `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | Relancer une chaîne | +| `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | Valider une chaîne | + +### Différences avec le legacy + +- **403 systématique** : le legacy retournait `[]` sur `GET /` sans permission ; + le NestJS retourne 403 pour tous les endpoints sans permission. +- **Pas d'auth session** : seul `x-dso-token` fonctionne (le legacy supportait + aussi les sessions Keycloak). +- **Validation UUID** : les paramètres d'URL sont validés via `ParseUUIDPipe` + (400 si format invalide). + +### Variables d'environnement + +| Variable | Description | Défaut | +|----------|-------------|--------| +| `OPENCDS_URL` | URL de base de l'API OpenCDS | _(vide = désactivé)_ | +| `OPENCDS_API_TOKEN` | Token d'API OpenCDS (header `X-API-Key`) | — | +| `OPENCDS_API_TLS_REJECT_UNAUTHORIZED` | Vérification TLS (`true`/`false`) | `true` | + +--- + +Pour mettre à jour `old-server` (après avoir rebasé sur `origin/master`, par exemple) : + +```bash +server-nestjs/$ rm -rf src/cpin-module/old-server +server-nestjs/$ cp -r ../server src/cpin-module/old-server +server-nestjs/$ find src/cpin-module/old-server -type f -iname "*.ts" -exec sed -i -e "s#@/#@old-server/#g" {} \; +server-nestjs/$ find src/cpin-module/old-server -type f -iname "*.ts" -exec sed -i -e "s#\.[jt]s'#'#g" {} \; +``` + +## Fichiers à supprimer dans le futur + +Certains fichiers de `old-server` servait de "framework" pour le backend, et +vont donc être réécrits en tant que modules/services NestJS. On va garder la +liste ici, ce qui permettra de ne pas être constamment en conflit sur le code +de `server`. En attendant de pouvoir s'en débarrasser, et afin de s'assurer que +leur code n'est pas utilisé dans d'autres parties du backend, on va commenter +l'intégralité de ces fichiers (comme ça pas d'erreur d'import quand on les +supprimera). + +Voilà donc la liste des fichiers "dépréciés" : + +``` +old-server/src/app.ts -> Réécrit en AppService +old-server/src/connect.ts -> Réécrit en DatabaseService +old-server/src/init/db/* (à part dump.ts) -> Réécrit en DatabaseInitializationService +old-server/src/resources/**/router.ts -> Réécrit en **RouterService +old-server/src/resources/index.ts -> Réécrit en RouterService +old-server/src/server.ts -> Intégré à ApplicationInitializationService +old-server/src/utils/env.ts -> Réécrit en ConfigurationModule +old-server/src/utils/fastify.ts -> Réécrit en FastifyService +old-server/src/utils/keycloak-utils.ts -> Intégré dans AppService +old-server/src/utils/keycloak.ts -> Intégré dans AppService +old-server/src/utils/logger.ts -> Réécrit en LoggerModule +old-server/src/utils/plugin.ts -> Réécrit en PluginManagementService +``` + +## Prochaines itérations sur le sujet + +Tâches à réaliser par la suite dans d'autres itérations/tickets/etc. : + +- Migrer une fonctionnalité "verticale" complète (Route, Contract, Controller, + Business, Queries, Prisma schema) dans son propre module NestJS qui sera + importé dans `MainModule` (et pas dans `CpinModule` qui devra disparaître + à terme). +- Définir la liste de ces fonctionnalités verticales, et planifier l'extraction + de certaines d'entre elles (OpenCDS, typiquement, qui n'a rien à faire dans + le code de base de la Console) +- Migrer la base actuelle de NestJS de Jest vers Vitest et s'assurer que les + tests unitaires passent à nouveaux (attention, certains d'entre eux devront + être adaptés vu qu'on a commencé à réécrire du code au standard NestJS) +- Intérgrer `server-nestjs` dans tous les `docker compose` et les différents + scripts PNPM/Bash qui font tout le sel de nos process de dev +- Revoir les imports de données (le fameux `dump.ts`). C'était déjà une + mauvaise idée à l'époque, ça l'est encore plus aujourd'hui. On ferait mieux + d'utiliser un side-container pour ça +- Être capable de déployer `server-nestjs` en parallèle de `server`, avec + probablement un reverse proxy dédié à la migration des routes de l'ancien + vers le nouveau (à rediscuter plus concrètement) + +Les étapes d'après-après (quand on sera sereins sur la migration de `server` +vers `server-nestjs`) : + +- Tester les capacités de NestJS SSR (Server Side Rendering), notamment + vis-à-vis de nos composants VueJS. Il y aura probablement des sujets autour + de l'isolation du code VueJS des différentes fonctionnalités afin de + faciliter leur extraction de `client` vers `server` +- Implémenter une fonctionnalité du Front en tant que module NestJS SSR + (OpenCDS est un très bon cas d'étude grâce à son côté très isolé dans le + code). Idéalement ce code "Front" ajouté "côté Backend" devra être colocalisé + avec le code "Backend" correspondant. diff --git a/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md b/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md new file mode 100644 index 0000000000..1973831edb --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md @@ -0,0 +1,214 @@ +# Modularisation Backend : Node.js Server → NestJS + +## 🎯 Objectifs de la modularisation + +### Principes directeurs + +- La Console a évolué très rapidement. Depuis sa création jusqu'à récemment, la + priorité a été donnée à l'ajout de fonctionnalités, sans forcément avoir le + temps de consolider le socle technique. La modularisation de sa partie + serveur, à l'aide d'un framework robuste comme NestJS, permettra une + évolution saine de la plate-forme. +- Il y a une volonté de permettre davantage de souplesse dans la configuration + de la console (utilisation d'un plugin en remplacement d'un autre), afin de + permettre aux projets qui voudraient avoir "leur" console de la configurer + comme ils veulent +- Le code du serveur de la console est assez daté, dans ses paradigmes et ses + dépendances. Un nettoyage de ce code par une remise à plat de la cartographie + des interdépendances entre les différents modules permettra d'améliorer les + choses module par module + +### Objectifs techniques +- **Modularisation** : Découpage en modules métier cohérents et maintenables +- **Architecture propre** : Injection de dépendances, séparation des responsabilités +- **Scalabilité** : Base solide pour les évolutions futures +- **Qualité code** : Standards NestJS, TypeScript strict + +### Objectifs organisationnels +- **Continuité produit** : Zéro impact sur les développements en cours +- **Montée en compétence** : Équipe formée progressivement à NestJS +- **Risque maîtrisé** : Modularisation progressive sans interruption de service + +## 📊 Contexte technique + +### Stack actuelle +- **Backend** : Node.js (avec ts-rest/fastify et injection de dépendance manuelle des plugins) +- **ORM** : Prisma avec PostgreSQL +- **Auth** : OIDC avec Keycloak +- **Tests** : Playwright (E2E), Vitest (unitaires) +- **Déploiement** : Docker Compose +- **CI/CD** : Pipeline existante + +### Stack cible +- **Backend** : NestJS (TypeScript) +- **ORM** : Prisma (conservé, partagé) +- **Auth** : OIDC Keycloak +- **Tests** : Playwright + Vitest (conservés) +- **Déploiement** : Docker Compose (phase transition) → NestJS seul +- **CI/CD** : Adaptée pour build et le déploiement des 2 images en parallèle + +### Métriques du projet +- **Routes** : ~100 +- **Équipe** : ~5 développeurs +- **Timeline** : 3 mois (12 sprints) +- **Couverture tests E2E** : 33% → objectif 50% + +## 🏗️ Stratégie : Strangler Fig Pattern + +### Principe +Le nouveau backend `server-nestjs` coexiste avec l'ancien et "étouffe" progressivement `server` en reprenant route par route ses fonctionnalités. + +### Pourquoi cette approche ? +✅ **Zero downtime** : Service continu pendant la modularisation +✅ **Rollback facile** : Retour arrière immédiat via configuration nginx +✅ **Équipe non bloquée** : Développements possibles en parallèle +✅ **Validation progressive** : Tests en prod sur petit périmètre +✅ **Risque maîtrisé** : Pas de big bang + +### Architecture de transition + +``` +┌─────────────┐ +│ Nginx │ ← Point d'entrée unique (port 80) +│ (Reverse │ +│ Proxy) │ +└──────┬──────┘ + │ + ├─────────────┐ + │ │ + Migré Pas encore + │ │ + │ │ +┌──────▼──────┐ ┌────▼───────┐ +│ NestJS API │ │ Server API │ +│ (3001) │ │ (3000) │ +└──────┬──────┘ └────┬───────┘ + │ │ + └─────────────│ + │ + ┌──────▼─────┐ + │ PostgreSQL │ + │ (Prisma) │ + └────────────┘ +``` + +## 📋 Documents de référence + +Cette modularisation est documentée dans plusieurs fichiers : + +1. **[00-OVERVIEW.md](00-OVERVIEW.md)** *(ce document)* - Vue d'ensemble +3. **[01-MODULARISATION-STRATEGY.md](01-MODULARISATION-STRATEGY.md)** - Stratégie et méthodologie +7. **[MODULARISATION_STATUS.md](MODULARISATION_STATUS.md)** - Suivi en temps réel (fichier vivant) +8. **[MODULARISATION_MAP.md](MODULARISATION_MAP.md)** - Cartographie des modules (fichier vivant) + +## 🚀 Démarrage rapide + +### Pour l'équipe de développement +1. Lire ce document (OVERVIEW) +2. Consulter [MODULARISATION\_STATUS.md](MODULARISATION_STATUS.md) pour connaître l'état actuel +3. Suivre le canal Mattermost `#backend-modularisation` +4. Respecter les zones en modularisation (🚧) avant de développer + +### Pour le lead technique de la modularisation +1. Lire tous les documents dans l'ordre +2. Personnaliser [MODULARISATION\_MAP.md](MODULARISATION_MAP.md) avec la cartographie réelle +3. Configurer l'infrastructure +4. Lancer la communication + +## 🎯 Principes directeurs + +### Technique +- **Parité fonctionnelle** : Chaque route migrée doit être strictement équivalent à son prédécesseur +- **Tests de contrat** : Validation automatique de la parité comportementale +- **Monitoring** : Surveillance continue des performances et erreurs +- **Documentation** : Chaque décision architecturale documentée (ADR) + +### Organisationnel +- **Communication proactive** : Annonce claire des zones en modularisation +- **Collaboration** : "Pair programming" encouragé pour montée en compétence +- **Pragmatisme** : Adapter le planning selon les contraintes business +- **Célébration** : Reconnaître les étapes franchies + +## ⚠️ Règles critiques + +### À FAIRE +✅ Annoncer début/fin de modularisation d'un module sur Mattermost +✅ Mettre à jour MODULARISATION\_STATUS.md à chaque changement +✅ Écrire des tests de contrat avant de migrer une route +✅ Déployer progressivement (intégration → staging → prod) +✅ Monitorer 24-48 h après chaque modularisation + +### À NE PAS FAIRE + +❌ Migrer un module pendant qu'une fonctionnalité y est développée +❌ Modifier le schéma Prisma pendant la modularisation (sauf critique) +❌ Déployer sans tests E2E passants +❌ Oublier de mettre à jour la config nginx pour indiquer la bascule d'une route +❌ Développer sur `server` (et pas sur `server-nestjs`) un module déjà migré + +## 📞 Contacts + +### Responsable modularisation +- **Lead technique** : @stephane.trebel +- **Canal Mattermost** : #backend-modularisation +- **Réunions** : Hebdo modularisation (les jeudis à 10h, 30 min) + +### Escalade +- **Problème technique bloquant** : Contacter @lead-dev immédiatement +- **Conflit de développement** : Contacter @lead-dev immédiatement +- **Incident de Production** : Retour en arrière Nginx sur la route concernée + +## 📈 Métriques de succès + +### Fin de modularisation (sprint 12) +- [ ] 100% des routes migrées sur NestJS +- [ ] code de `server` supprimé du dépôt Git de `console` +- [ ] Couverture des Tests E2E ≥ 50% des cas d'usage de `console` +- [ ] Documentation technique complète des modules (cartographie, dépendances inter-modules, etc.) +- [ ] 0 régression fonctionnelle +- [ ] Équipe autonome sur NestJS + +### Indicateurs intermédiaires +- **S4** : 20% migré (Modules transverses (logger, configuration, etc.)) +- **S6** : 40% migré (Modules "verticaux" comme Zone, Cluster, etc.) +- **S8** : 60% migré +- **S10** : 80% migré (Plugins, comme ArgoCD, Gitlab, etc.) +- **S12** : 100% migré + +### Migrations anticipées +- **ServiceChain (OpenCDS)** : Migré le 2026-04-09 (prévu V3/S8). Ce module + isolé (proxy HTTP vers API externe, aucune + dépendance entrante) a servi de premier test de bout en bout du pipeline de + migration : module NestJS + `InfrastructureModule` + `UserGuard` + + nginx-strangler + Docker. + +## 🗓️ Dates clés + +- **S1-S2** : Cartographie et setup +- **S3-S4** : Modularisation des modules transverses +- **S5-S6** : Modularisation des modules métiers "cœur" (Zone, Cluster, etc.) +- **S7-S10** : Modularisation modules plugins (ArgoCD, Gitlab, etc.) +- **S11-S12** : Finalisation et nettoyage du code (suppression reverse proxy, suppression de `server`, etc.) + +*Dates indicatives, ajustables selon les contraintes business* + +## 📚 Ressources + +### Documentation NestJS + +- [Documentation officielle NestJS](https://docs.nestjs.com/) +- [Prisma avec NestJS](https://docs.nestjs.com/recipes/prisma) +- [Authentification OIDC NestJS](https://docs.nestjs.com/recipes/passport) + +### Outils + +- [Madge](https://github.com/pahen/madge) - Analyse de dépendances +- [Compodoc](https://compodoc.app/) - Documentation auto +- [Playwright](https://playwright.dev/) - Tests E2E +- [Vitest](https://vitest.dev/) - Tests unitaires + +--- + +**Version** : 1.2 +**Dernière mise à jour** : 2026-06-16 +**Prochaine revue** : Fin S2 diff --git a/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md b/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md new file mode 100644 index 0000000000..10c05e2525 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md @@ -0,0 +1,313 @@ +# Stratégie de Modularisation - console/server → console/server-nestjs + +## 🎯 Approche globale + +Sachant que nous avons des fonctionalités assez bien identifiées (AdminTokens, Plugin Gitlab, etc.), nous allons utiliser une approche « Modularisation par verticaux ». + +### Principe + +Plutôt que de migrer par couches horizontales (tous les DTOs, puis tous les services, puis tous les controllers, puis tous les plugins), nous migrerons **module complet par module complet**. + +### Pourquoi cette approche ? + +✅ **Valeur métier immédiate** : Chaque itération livre un module fonctionnel +✅ **Tests E2E possibles** : Validation complète de la non-régression dès la fin du module +✅ **Risque isolé** : Un problème n'affecte qu'un seul domaine métier +✅ **Rollback granulaire** : Retour arrière possible module par module +✅ **Équipe non bloquée** : Autres modules restent disponibles pour développement +✅ **Reprise du typage** : Beaucoup de modules ont un typage Typescript lacunaire, voire inexistant (`any`). Migrer un module sera l'opportunité de reprendre ces aspects et définir clairement l'"API" du Module + +### Anatomie d'un vertical + +``` +ZoneModule (exemple) +├── Model (Prisma) ← Déjà existant, partagé +├── Repository/Service ← Modularisation du code métier +├── Controller/Routes ← Modularisation des endpoints +├── DTOs & Validation ← Ajout validation NestJS +├── Tests unitaires ← Nouveaux tests Vitest +└── Tests E2E ← Adaptation tests Playwright si besoin (normalement jamais) +``` + +## Phase 1: 📊 Cartographie et priorisation du code de console/server actuel + +### Objectifs de la cartographie + +1. **Inventaire exhaustif** : Lister tous les endpoints et leurs dépendances +2. **Graphe de dépendances** : Identifier les modules couplés entre eux (typiquement Vault et…tout le reste) +3. **Priorisation** : Définir l'ordre optimal de modularisation (de "bas en haut", donc de ceux qui ont le moins de dépendances à ceux qui dépendent de presque tout le monde) +4. **Estimation** : Évaluer grosse maille la charge de chaque module + +### Outils d'analyse automatique + +Une manière de faire qui combine une approche automatique (on parcourt tout ce qu'on peut) et une approche empirique (on regarde ce que ça donne) peut se faire de la manière suivante : + +```bash +# 1. Installer les outils +npm install -D madge dependency-cruiser + +# 2. Générer le graphe de dépendances +npx madge --image dependency-graph.png --extensions js,ts server/src + +# 3. Détecter les cycles de dépendances +npx madge --circular --extensions js,ts server/src + +# 4. Analyser les violations architecturales +npx depcruise src --output-type err +``` + +### Ordre de modularisation recommandé + +**Stratégie en 3 phases** : + +1. **Phase d'apprentissage (S3-S4)** : Modules simples mais critiques + - Auth : Critique mais bien délimité + - Objectif : Valider l'approche, former l'équipe + +2. **Phase de vélocité (S5-S8)** : Modules métier principaux + - Cluster, Zone, etc. + - Objectif : Migrer le gros du trafic + +3. **Phase de finalisation (S9-S12)** : Modules périphériques + - Plugins qui doivent exister impérativement (Kubernetes, Gitlab, etc. -LISTE À DEFINIR-) + - Objectif : Compléter la modularisation, suppression de `server` et renommage de `server-nestjs` en `server` + +## 🔄 Gestion du passage simple singleton node.js → Injection de dépendances dans des services singleton + +### Le problème des singletons purement "node.js" + +Les singletons actuellement utilisés dans le code sont ceux de Node.js. Ce sont +des instances certes uniques, mais qui reposent intégralement sur une logique +d'import des fichiers. Cette logique n'est pas mauvaise en soi (et fonctionne +encore maintenant), mais elle est très limitée, car elle ne nous permet pas de +configurer les "modules" importés (changer une dépendance par une autre, par +exemple). Par essence les modules node.js ne permettent pas d'injection de +dépendance, en particulier au runtime. + +```javascript +// ❌ Pattern singleton dans le code server +const prisma = require('../db').default; + +async findById(id) { + return prisma.user.findUnique({ where: { id } }); +} + +async create(data) { + return prisma.user.create({ data }); +} + +// Export d'une instance unique (singleton) +module.exports = { findById, create }; +``` + +**Problèmes** : +- État partagé mutable +- Difficile à tester (mocking complexe) +- Pas de contrôle du cycle de vie +- Couplage fort avec les dépendances +- Typage souvent lacunaire + +### Solution : Migrer vers un vrai service singleton de type NestJS + +```typescript +// nest-backend/src/users/users.service.ts +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { User } from '@prisma/client'; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id } }); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + return user; + } + + async create(createUserDto: CreateUserDto): Promise { + return this.prisma.user.create({ + data: createUserDto, + }); + } + + // Ajouter validation, transformation, logique métier +} +``` + +### Checklist de modularisation d'un singleton node.js -> singleton NestJS + +Pour chaque service singleton node.js à migrer : + +- [ ] **Identifier** : Lister tous les singletons node.js (grep `module.exports =` ou `export default`) +- [ ] **Analyser** : Documenter l'interface publique (méthodes exposées) +- [ ] **État mutable** : Identifier les propriétés d'instance partagées +- [ ] **Dépendances** : Lister ce dont le singleton dépend +- [ ] **Créer le service** : Reproduire l'interface du singleton en NestJS +- [ ] **Tests de contrat** : Valider la parité comportementale avant/après +- [ ] **Migrer progressivement** : Remplacer les usages un par un +- [ ] **Nettoyage** : Supprimer le singleton node.js une fois inutilisé + +## 📝 Méthodologie de modularisation d'un module + +### Processus en 7 étapes + +#### 1. Analyse et préparation (Jour 1) + +```bash +# Analyser les dépendances du module +npx madge --depends my-module old-server/src + +# Lister les endpoints concernés +grep -r "router.*mymodule" old-server/src/routes + +# Identifier les tests E2E existants +grep -r "my-module" playwright/tests/e2e +``` + +**Livrables** : +- Liste des routes/endpoints à migrer +- Graphe des dépendances +- Plan de tests + +#### 2. Setup du nouveau module NestJS (Jour 1) + +**Note**: Afin d'accélérer la création des entités NestJS (services, modules, +contrôleurs, etc.), on pourra utiliser [la CLI `nest`](https://docs.nestjs.com/cli/usages#nest-generate) +pour générer des modèles (*templates*). + +```bash +# Générer le module avec NestJS CLI (par ex pour un module UserModule) +cd nest-backend +nest g module users +nest g controller users +nest g service users +``` + +**Structure créée** : +``` +src/users/ +├── users.module.ts +├── users.controller.ts +├── users.service.ts +├── dto/ +│ ├── create-user.dto.ts +│ └── update-user.dto.ts +├── entities/ +│ └── user.entity.ts +└── users.controller.spec.ts +``` + +#### 3. Modularisation de la logique métier (Jours 2-3) + +**Ordre de modularisation** : +1. DTOs et contrat de validation, suivant les besoins (certains services ont + besoin d'un DTO puisqu'ils utilisent la BDD, d'autres non) +2. Service (logique métier du module) +3. Controller (routes du module) +4. Guards et interceptors pour les contrôles qui peuvent être factorisés + +#### 4. Écriture des tests unitaires pour le nouveau service (Jour 3) + +#### 5. Adaptation des tests E2E si nécessaire (Jour 4) + +#### 6. Configuration du nginx étrangleur et déploiement (Jour 5) + +**Mise à jour de `apps/nginx-strangler/routing.conf`** : + +```nginx +# [S5-S6] Users - 2026-02-23 +location /api/xxxxx { + proxy_pass http://server-nest; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location /api/yyyyy { + proxy_pass http://server-nest; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Note: Prévoir un "blue-green" afin de permettre un passage souple et serein +entre les anciens et nouveaux services. + +**Déploiement progressif** : + +```bash +# 1. Backup de la config actuelle +cp apps/nginx-strangler/routing.conf apps/nginx-strangler/routing.conf.backup + +# 2. Mise à jour de la config +# (éditer apps/nginx-strangler/routing.conf) + +# 3. Validation de la config nginx +docker-compose exec nginx nginx -t + +# 4. Rechargement sans downtime +docker-compose exec nginx nginx -s reload + +# 5. Monitoring (surveiller les logs pendant 5-10min) +docker-compose logs -f nginx | grep users +``` + +Note: Prévoir un retour arrière en cas de problème ⚠ + +#### 7. Monitoring et validation (Jours 5-7) + +Note: Faire un point sur la métrologie existante. Envisager si besoin de +fiabiliser la stack d'observabilité, implémenter Loki et remonter des métriques +si besoin). + +**Métriques à surveiller** : + +```bash +# Logs en temps réel +docker-compose logs -f nest-api | grep users + +# Taux d'erreur +docker-compose logs nest-api | grep ERROR | grep users | wc -l + +# Temps de réponse moyen (exemple avec awk) +docker-compose logs nginx | grep "/api/users" | \ + awk '{print $NF}' | awk -F'=' '{sum+=$2; count++} END {print sum/count}' +``` + +**Validation** : +- [ ] Aucune erreur 5xx pendant 24h +- [ ] Temps de réponse comparable au code `server` (±20%) +- [ ] Tests E2E passants +- [ ] Recette utilisateurs (si applicable) + +## 🚨 Gestion des situations complexes + +### Cas 1 : Module avec beaucoup de dépendances + +**Stratégie** : +1. Migrer d'abord les dépendances (Users, Products) +2. Créer des adaptateurs temporaires (des services "passe-plat") pour les modules qui ne sont pas encore migrés +3. Tester intensivement les interactions entre modules (typage fort, tests d'intégration, E2E, etc.) + +## ✅ Checklist de fin de module + +Avant de considérer un module comme "migré" : + +- [ ] Tous les endpoints du module redirigent vers `server-nestjs` +- [ ] Tests de contrat passants (parité avec le code `server`) +- [ ] Tests E2E passants (éventuellement adaptés) +- [ ] Configuration nginx mise à jour et déployée +- [ ] Monitoring actif +- [ ] Aucune régression détectée +- [ ] Documentation technique à jour +- [ ] MODULARISATION_STATUS.md mis à jour +- [ ] Annonce de fin sur Mattermost diff --git a/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md b/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md new file mode 100644 index 0000000000..257b260401 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md @@ -0,0 +1,120 @@ +# Architecture d’un module (pattern `apps/server-nestjs/src/modules/*`) + +Les modules NestJS métier vivent dans `src/modules//` et suivent un découpage “vertical slice” avec des responsabilités explicites : **client**, **service (API publique)**, **controller service (orchestration)**, **datastore**, **utils** et **tests**. + +Exemples concrets : + +- Module GitLab : `src/modules/gitlab/` +- Module Keycloak : `src/modules/keycloak/` + +## Structure type + +```txt +src/modules// +├── .module.ts +├── .constants.ts +├── -client.service.ts +├── .service.ts +├── -datastore.service.ts +├── .utils.ts +├── -testing.utils.ts +└── *.spec.ts +``` + +## Sens des dépendances (flow recommandé) + +Objectif : un flux de dépendances lisible et sans cycles. + +```txt +.service.ts + ↙ ↘ +-client.service.ts -datastore.service.ts +``` + +Règles pratiques : + +- Le `.service.ts` est un entrypoint interne (cron, events, reconcile) et orchestre en appelant directement le `client` (et le `datastore` si nécessaire), sans dépendre du `service`. +- Le `service` contient les règles métier (décisions, transformations, validations) et s’appuie sur le `client` (et le `datastore` quand la lecture/écriture DB fait partie du cas d’usage). +- Le `client` encapsule l’accès à une API externe (initialisation + appels + erreurs bas niveau). +- Le `datastore` encapsule l’accès DB (Prisma) et expose des méthodes de lecture/écriture typées. +- Les `utils` restent “purs” (pas d’IO, pas d’injection Nest). +- Les `testing utils` centralisent les factories/fixtures pour réduire la duplication dans les tests. + +## Composants + +### `.module.ts` + +Rôle : + +- Déclare les providers, imports, exports du module. +- Exporte le `service` du module (`.service.ts`) qui constitue l’API publique. + +### `-client.service.ts` + +Rôle : + +- Adapter vers le système externe (SDK HTTP, client Keycloak, client GitLab, etc.). +- Conserver un contrat stable pour le reste du module. +- Mapper/normaliser les erreurs externes si nécessaire. + +À éviter : + +- Décisions métier (permissions, synchronisation, règles de purge) : elles vont dans `.service.ts` ou le controller service. + +### `.service.ts` + +Rôle : + +- Orchestrateur de workflows : `@Cron`, `@OnEvent`, reconcile périodique, tâches “batch”. +- Coordination entre `client` et `datastore` (sans dépendre du `service`). +- Garde-fous “safety” avant opérations destructrices (ex: suppression de groupes orphelins). + +### `-datastore.service.ts` + +Rôle : + +- Accès DB via Prisma (select/include, transactions, pagination). +- Exposition de types agrégés utiles au domaine (ex: `ProjectWithDetails`). + +À éviter : + +- Appliquer des règles métier (ex: calcul de permissions) : on garde le datastore centré persistence. + +### `.utils.ts` + +Rôle : + +- Fonctions utilitaires pures : mapping, helpers de collections, types partagés. +- Aucune dépendance Nest, aucune lecture/écriture DB, aucun appel réseau. + +### `-testing.utils.ts` + +Rôle : + +- Factories typées pour les structures fréquemment utilisées en tests. +- Support d’`overrides` pour construire rapidement des variantes. +- Centralisation des erreurs/fake responses spécifiques au module (quand utile). + +## Tests (Vitest) + +### `.service.spec.ts` + +Cible : + +- Orchestration : séquences d’appels, side-effects attendus, reconcile. + +Approche : + +- Mock du `service`, du `datastore`, et des appels externes. +- Vérification des appels effectués et des paramètres attendus. + +### `-datastore.service.spec.ts` (si présent) + +Cible : + +- Forme des requêtes Prisma, mapping de résultat, typage de l’agrégat renvoyé. + +Approche : + +- Mock de Prisma/DatabaseService, pas de logique métier. + diff --git a/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md b/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md new file mode 100644 index 0000000000..a5e6b10c1d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md @@ -0,0 +1,1276 @@ +# Cartographie des modules - Modularisation Backend + +> Derniere mise a jour : **2026-06-16** (refonte Infrastructure/Auth/Permission) + +--- + +## Objectif de ce document + +Cartographier l'ensemble des modules de l'application backend actuelle pour : +1. Identifier les dependances entre modules +2. Prioriser l'ordre de modularisation +3. Estimer la charge de chaque module +4. Faciliter la communication sur les zones en modularisation + +--- + +## Decisions structurantes + +| Decision | Choix | +|----------|-------| +| Approche migration | Bottom-up (feuilles d'abord, puis remontée vers les modules couples) | +| Contrats API | Decorateurs NestJS natifs + class-validator (abandon de ts-rest) | +| queries-index.ts | Supprime des le depart : chaque module NestJS possede ses propres queries Prisma | +| Systeme d'evenements | `@nestjs/event-emitter` (remplacement progressif de `@cpn-console/hooks`) | +| Plugins | Deviennent des modules NestJS (encapsulation puis reecriture progressive) | +| Module project | Decoupe en 3 sous-modules : ProjectCore, ProjectSecrets, ProjectBulk | +| Equipe dediee | 2 developpeurs en parallele | + +--- + +## Vue synthetique + +**75 routes metier** reparties en **18 modules metier** (dont project eclate en 3), +plus **5 couches transverses** et **7 plugins** a encapsuler. + +### Legende des statuts + +- FAIT : Deja implemente dans `server-nestjs` +- A CREER : N'existe pas encore +- A MIGRER : Existe dans `server`, a migrer vers `server-nestjs` + +--- + +## Architecture lasagnes - Etat des lieux + +``` + Couche Etat actuel server-nestjs ++--------------------------------------------------------------+ +| Couche 6 : Modules metier (controllers, DTOs) | A MIGRER ++--------------------------------------------------------------+ +| Couche 5 : Plugins (modules NestJS injectables) | A CREER ++--------------------------------------------------------------+ +| Couche 4 : Evenements (EventEmitter, remplacement hooks) | A CREER ++--------------------------------------------------------------+ +| Couche 3 : Securite (AuthService, UserGuard, ProjectGuard, Filters) | PARTIEL ++--------------------------------------------------------------+ +| Couche 2 : Core (AppService, FastifyService) | FAIT ++--------------------------------------------------------------+ +| Couche 1 : Infrastructure (Config, Logger, DB, HTTP) | FAIT ++--------------------------------------------------------------+ +``` + +Les couches 1 et 2 sont en place. La couche 3 est **partiellement** en place : +l'auth par token (`x-dso-token`) et le bearer JWT Keycloak passent désormais +par `AuthService`. La couche de permissions est structurée autour de +`InfrastructureModule -> PermissionModule -> UserModule / ProjectModule` +avec `UserGuard`, `ProjectGuard`, `UserService` et `ProjectService`. +Il reste a homogénéiser les usages des decorateurs de contexte utilisateur +(`@AuthUser()`), et le `GlobalExceptionFilter`. +Les couches 4 et 5 restent a creer. + +--- + +## Couche 0 - Socle transverse (S3, priorite absolue) + +Ces elements ne sont pas dans la matrice de scoring car ce ne sont pas des +modules metier. Ils conditionnent cependant la migration de **tous** les modules +qui suivent. Les creer en premier permet que chaque migration de module soit un +exercice de "remplissage" des couches superieures, sans reinventer +l'infrastructure a chaque fois. + +### 0a. AuthService + decorateur @AuthUser() — PARTIEL + +**Sprint** : S3 (Dev A) +**Remplace** : `authUser()` dans `apps/server/src/utils/controller.ts` + +**Etat actuel (2026-04-09)** : La partie auth par token est implementee dans +`infrastructure/auth/` : +- ✅ `AuthService` : validation token SHA256, lookup Prisma + (PersonalAccessToken + AdminToken), verification status/expiration, calcul + permissions bitwise (y compris roles globaux) +- ✅ `AuthService` : route `x-dso-token` et bearer JWT Keycloak vers + `DsoTokenService` / `KeycloakJwtService` +- ✅ `UserGuard` : applique les requirements de permission et injecte le + contexte utilisateur dans la request +- ✅ `@AuthUser()` : decorateur de parametre pour le contexte utilisateur +- ✅ `@RequireAdminPermission()` / `@RequireProjectPermission()` : metadata + de permissions admin et projet + +**Reste a faire** : +- Harmonisation des controles d'accès entre modules restants +- Chargement optionnel du projet (par slug, id, environmentId, repositoryId) +- Formalisation des types de contexte utilisateur/projet partages + +### 0b. GlobalExceptionFilter + +**Sprint** : S3 (Dev B) +**Remplace** : Les classes de `apps/server/src/utils/errors.ts` + +Mapping des erreurs : + +| Classe actuelle (server) | Equivalent NestJS | +|--------------------------|-------------------| +| `BadRequest400` | `BadRequestException` | +| `Unauthorized401` | `UnauthorizedException` | +| `Forbidden403` | `ForbiddenException` | +| `NotFound404` | `NotFoundException` | +| `Unprocessable422` | `UnprocessableEntityException` | +| `Internal500` | `InternalServerErrorException` | + +Le filtre global intercepte les `HttpException` NestJS et les formate dans le +schema de reponse attendu par le client (`{ status, body: { message } }`). + +### 0c. PermissionModule + decorateurs de permissions + +**Sprint** : S3-S4 (Dev A) +**Remplace** : `getProjectPermissions`, `AdminAuthorized`, `ProjectAuthorized` dans `utils/controller.ts` + +Fonctionnalites : +- Decorateurs `@RequireAdminPermission()` et `@RequireProjectPermission()` sur les methodes de controller +- Guard `UserGuard` qui verifie les bitmasks de permissions admin et projet +- Support des deux niveaux : `AdminRole` (global) et `ProjectRole` (scope projet) + +### 0d. ValidationPipe global + +**Sprint** : S3 (Dev B) +**Remplace** : La validation Zod des contrats ts-rest + +Configuration : +- `class-validator` + `class-transformer` en pipe global +- `whitelist: true` (strip des proprietes non declarees) +- `forbidNonWhitelisted: true` (erreur si proprietes inconnues) +- `transform: true` (transformation automatique des types) + +### 0e. EventEmitterModule + +**Sprint** : S4-S5 (Dev A) +**Remplace** : `@cpn-console/hooks` (premiere iteration) + +Mise en place de `@nestjs/event-emitter` avec : +- Evenements types pour chaque hook actuel (ProjectUpserted, ClusterDeleted, etc.) +- Pattern `@OnEvent('project.upserted')` pour les abonnements +- Pas de steps pre/check/main/post dans cette v1 : orchestration lineaire + +Ce module sera utilise par les modules metier des la Vague 2 (quand les modules +avec hooks commencent a etre migres). + +--- + +## Matrice de priorisation + +### Criteres de scoring + +| Critere | Poids | Description | +|---------|-------|-------------| +| Dependances faibles | 40% | Moins de dependances sortantes = plus facile a extraire (bottom-up) | +| Criticite | 30% | Balance risque/valeur. Un module critique et bien delimite score haut | +| Trafic | 20% | Volume raisonnable pour validation de la migration | +| Difficulte (inversee) | 10% | Complexite technique inversee (facile = score eleve) | + +### Formule + +``` +Score = (Dependances x 0.4) + (Criticite x 0.3) + (Trafic x 0.2) + (Difficulte x 0.1) + +Echelle : 1-10 pour chaque critere. +Plus le score est eleve, plus le module est prioritaire. +``` + +### Resultats ordonnes par score + +| Rang | Module | Type | Score | Vague | Sprint | Statut | +|------|--------|------|-------|-------|--------|--------| +| 1 | vault (encapsulation) | Plugin | 8.5 | V3 | S7-S8 | ✅ MIGRE | +| 2 | system (health/version) | Metier | 7.8 | V1 | S3 | ✅ MIGRE | +| 3 | system/settings | Metier | 7.4 | V1 | S3 | ✅ MIGRE | +| 4 | system/config | Metier | 7.4 | V1 | S3-S4 | | +| 5 | keycloak (encapsulation) | Plugin | 7.4 | V3 | S8 | ✅ MIGRE | +| 6 | admin-token | Metier | 7.1 | V1 | S3-S4 | | +| 7 | user/tokens | Metier | 7.1 | V1 | S3-S4 | | +| 8 | gitlab (encapsulation) | Plugin | 6.7 | V4 | S9 | ✅ MIGRE | +| 9 | service-monitor | Metier | 6.6 | V2 | S5 | | +| 10 | user | Metier | 6.6 | V2 | S5 | | +| 11 | stage | Metier | 6.5 | V2 | S5-S6 | | +| 12 | log | Metier | 6.5 | V1 | S4 | | +| 13 | zone | Metier | 6.4 | V2 | S6 | | +| 14 | environment | Metier | 6.3 | V3 | S7 | | +| 15 | admin-role | Metier | 6.1 | V2 | S5 | | +| 16 | project-core | Metier | 5.8 | V4 | S9 | | +| 17 | service-chain | Metier | 5.9 | V3 | S8 | ✅ MIGRE | +| 18 | repository | Metier | 5.8 | V3 | S7-S8 | | +| 19 | cluster | Metier | 5.7 | V3 | S7 | | +| 20 | harbor (encapsulation) | Plugin | 5.6 | V4 | S9-S10 | ✅ MIGRE | +| 21 | project-service | Metier | 5.6 | V3 | S8 | | +| 22 | argocd (encapsulation) | Plugin | 5.3 | V5 | S10-S11 | ✅ MIGRE | +| 23 | project-role | Metier | 5.2 | V3 | S7-S8 | | +| 24 | nexus (encapsulation) | Plugin | 5.1 | V4 | S10 | ✅ MIGRE | +| 25 | project-member | Metier | 4.7 | V3 | S8 | | +| 26 | project-secrets | Metier | 4.6 | V4 | S9 | | +| 27 | project-bulk | Metier | 4.2 | V4 | S9-S10 | | +| 28 | sonarqube (encapsulation) | Plugin | 4.2 | V5 | S11 | | + +**Note** : Le score brut ne dicte pas directement l'ordre de migration. +L'ordre reel est contraint par le graphe de dependances (bottom-up), les +pre-requis transverses, et la parallelisation a 2 devs. C'est pourquoi `vault` +(score 8.5) est en Vague 3 et non en Vague 1 : son encapsulation NestJS +necessite l'EventEmitter et un pattern valide sur les modules metier simples. + +--- + +## Vague 1 - Quick wins et validation du pattern (S3-S4) + +**Objectif** : Valider le pattern NestJS complet (Controller, Service, Prisma, +AuthGuard, DTOs class-validator, ExceptionFilter). Former l'equipe. Deployer +les premiers modules via Nginx. + +**Livrable fin S4** : 13 routes migrees (~17%) + +### 1. system (health/version) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.8 | +| **Sprint** | S3 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/version` - Version de l'application +- `GET /api/v1/healthz` - Health check + +**Dependances sortantes** : Aucune (lecture de variable d'env) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Module le plus simple du codebase. Ideal pour valider le pipeline complet : + creation controller NestJS, deploiement Docker, bascule Nginx +- Pas d'authentification requise sur ces routes + +**Estimation** : 0.5 jour + +--- + +### 2. system/settings — ✅ MIGRE (2026-04-28) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.4 | +| **Sprint** | S3 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/system/settings` - Liste des parametres systeme +- `PUT /api/v1/system/settings` - Mise a jour d'un parametre + +**Dependances sortantes** : Aucune (queries propres, Prisma direct) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Premiere occasion de valider le pattern CRUD NestJS avec DTOs +- Auth requise (admin uniquement) + +**Estimation** : 1 jour + +--- + +### 3. system/config + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.4 | +| **Sprint** | S3-S4 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/plugins-config` - Configuration des plugins +- `PUT /api/v1/admin/plugins-config` - Mise a jour de la configuration + +**Dependances sortantes** : Aucune (queries propres) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth admin requise +- Gestion de la configuration JSON des plugins (AdminPlugin en BDD) + +**Estimation** : 1 jour + +--- + +### 4. admin-token + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 7.1 | +| **Sprint** | S3-S4 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/admin/tokens` - Liste des tokens de service admin +- `POST /api/v1/admin/tokens` - Creation d'un token +- `DELETE /api/v1/admin/tokens/:tokenId` - Suppression d'un token + +**Dependances sortantes** : Aucune (Prisma direct, pas de queries-index) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth admin requise +- Premier module avec CRUD complet et generation de token (hash bcrypt) +- Pas de hook-wrapper utilise + +**Estimation** : 1 jour + +--- + +### 5. user/tokens + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 7.1 | +| **Sprint** | S3-S4 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/users/me/tokens` - Liste des tokens personnels +- `POST /api/v1/users/me/tokens` - Creation d'un token personnel +- `DELETE /api/v1/users/me/tokens/:tokenId` - Suppression d'un token + +**Dependances sortantes** : Aucune (Prisma direct) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth utilisateur requise (scope personnel : l'utilisateur gere ses propres tokens) +- Similaire a admin-token, bon pour paralleliser +- Hash bcrypt des tokens + +**Estimation** : 1 jour + +--- + +### 6. log + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 1 | +| **Score** | 6.5 | +| **Sprint** | S4 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/logs` - Logs d'audit du projet + +**Dependances sortantes** : `queries-index` (lecture seule via `getAllLogs`) +**Dependances entrantes** : Aucune (beaucoup de modules *ecrivent* des logs +via `addLogs`, mais ce module ne fait que *lire*) + +**Points d'attention** : +- Premiere rencontre avec queries-index. La strategie est de creer les queries + Prisma directement dans le module NestJS (pas de barrel partage) +- Auth utilisateur requise, scope projet +- Read-only : pas de hooks + +**Estimation** : 0.5 jour + +--- + +## Vague 2 - Fondations metier et premiers hooks (S5-S6) + +**Objectif** : Migrer les modules qui sont des dependances pour d'autres. +Introduction de l'EventEmitter dans les modules avec hooks. `stage`, `zone` +et `user` sont des pre-requis pour la Vague 3 (cluster, project-member). + +**Livrable fin S6** : 34 routes migrees (~45%) + +### 7. service-monitor + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 6.6 | +| **Sprint** | S5 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/services` - Sante des services (plugins) +- `GET /api/v1/services/all` - Sante complete de tous les services +- `POST /api/v1/services/refresh` - Rafraichissement des statuts + +**Dependances sortantes** : `@cpn-console/hooks` (appel direct des moniteurs de plugins) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Premier module utilisant le systeme de hooks (via les moniteurs de plugins) +- Bon premier test de l'EventEmitter : les plugins exposent leur moniteur, + le service-monitor les interroge +- Pas de queries Prisma (pas d'interaction BDD) + +**Estimation** : 1 jour + +--- + +### 8. user + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.6 | +| **Sprint** | S5 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/users` - Recherche d'utilisateurs (matching) +- `POST /api/v1/auth` - Authentification / synchronisation session +- `GET /api/v1/admin/users` - Liste complete des utilisateurs (admin) +- `PATCH /api/v1/admin/users` - Modification utilisateurs (admin) + +**Dependances sortantes** : `queries-index` (getMatchingUsers, getUsers), hooks (adminRole.upsert) +**Dependances entrantes** : `project-member` (importe `logViaSession`), `utils/controller.ts` (importe `logViaSession`, `logViaToken`) + +**Points d'attention** : +- Module critique : `logViaSession` et `logViaToken` sont consommes par le + systeme d'auth. Ces fonctions auront ete portees dans l'AuthGuard (Couche 0), + donc la dependance entrante est deja resolue +- Hooks utilises (adminRole.upsert) : necessite l'EventEmitter +- Pre-requis pour `project-member` (Vague 3) + +**Estimation** : 2 jours + +--- + +### 9. admin-role + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 6.1 | +| **Sprint** | S5 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/roles` - Liste des roles admin +- `POST /api/v1/admin/roles` - Creation d'un role +- `PATCH /api/v1/admin/roles` - Modification des roles +- `GET /api/v1/admin/roles/member-counts` - Comptage des membres par role +- `DELETE /api/v1/admin/roles/:roleId` - Suppression d'un role + +**Dependances sortantes** : `queries-index` (getAdminRoleById, listAdminRoles), hooks (adminRole.upsert, adminRole.delete) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Valide le pattern hooks + EventEmitter dans un contexte admin +- Permissions admin (bitmask) : valide `UserGuard` +- Queries a internaliser dans le module + +**Estimation** : 1.5 jours + +--- + +### 10. stage + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 6.5 | +| **Sprint** | S5-S6 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/stages` - Liste des stages (dev, staging, prod, etc.) +- `GET /api/v1/stages/:stageId/environments` - Environnements d'un stage +- `POST /api/v1/stages` - Creation d'un stage +- `PUT /api/v1/stages/:stageId` - Modification d'un stage +- `DELETE /api/v1/stages/:stageId` - Suppression d'un stage + +**Dependances sortantes** : `queries-index` (nombreuses queries stage + cluster) +**Dependances entrantes** : `cluster/business.ts` (importe directement `linkClusterToStages`) + +**Points d'attention** : +- Pre-requis critique pour `cluster` (Vague 3). La fonction `linkClusterToStages` + exportee par stage est consommee directement par cluster. Dans l'architecture + NestJS, ce sera un service injectable du StageModule exporte et importe par + ClusterModule +- Pas de hooks directs +- Beaucoup de queries a internaliser (11 fonctions dans queries-index) + +**Estimation** : 2 jours + +--- + +### 11. zone + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.4 | +| **Sprint** | S6 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/zones` - Liste des zones +- `POST /api/v1/zones` - Creation d'une zone +- `PUT /api/v1/zones/:zoneId` - Modification d'une zone +- `DELETE /api/v1/zones/:zoneId` - Suppression d'une zone + +**Dependances sortantes** : `queries-index` (addLogs), `./queries` (linkZoneToClusters), hooks (zone.upsert, zone.delete) +**Dependances entrantes** : Aucune directe (mais les hooks zone declenchent des actions dans les plugins keycloak, vault, gitlab) + +**Points d'attention** : +- Hooks utilises : zone.upsert et zone.delete declenchent des operations dans + 5 plugins (vault, keycloak, gitlab, argocd). C'est le premier module ou + l'EventEmitter sera teste en profondeur +- Interaction avec clusters via `linkZoneToClusters` +- Gestion des logs d'audit (addLogs) + +**Estimation** : 2 jours + +--- + +## Vague 3 - Modules couples + debut encapsulation plugins (S7-S8) + +**Objectif** : Migrer les modules a dependances croisees directes. En parallele, +debut d'encapsulation des plugins feuilles (vault, keycloak) dans des modules +NestJS injectables. + +**Livrable fin S8** : 66 routes migrees (~88%) + plugins vault et keycloak encapsules + +### 12. cluster + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 7 | +| **Score** | 5.7 | +| **Sprint** | S7 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/clusters` - Liste des clusters +- `GET /api/v1/clusters/:clusterId` - Details d'un cluster +- `GET /api/v1/clusters/:clusterId/usage` - Utilisation d'un cluster +- `POST /api/v1/clusters` - Creation d'un cluster +- `GET /api/v1/clusters/:clusterId/environments` - Environnements d'un cluster +- `PUT /api/v1/clusters/:clusterId` - Modification d'un cluster +- `DELETE /api/v1/clusters/:clusterId` - Suppression d'un cluster + +**Dependances sortantes** : +- `queries-index` (15+ fonctions : addLogs, createCluster, deleteCluster, + getClusterById, getClusterByLabel, getClusterDetails, getClusterEnvironments, + getProjectsByClusterId, linkClusterToProjects, linkZoneToClusters, listClusters, + listStagesByClusterId, removeClusterFromProject, removeClusterFromStage, updateCluster) +- **`stage/business.ts`** (import direct : `linkClusterToStages`) +- hooks (cluster.upsert, cluster.delete) +- `utils/business.ts` (Result monad) + +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Module le plus complexe en termes de dependances sortantes +- La dependance directe vers `stage/business` sera resolue par un import du + StageModule dans ClusterModule (stage migre en Vague 2) +- 15+ queries a internaliser dans le module +- Hooks cluster declenchent des operations dans vault et argocd +- Gestion des kubeconfigs (donnees sensibles) + +**Estimation** : 3 jours + +--- + +### 13. environment + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.3 | +| **Sprint** | S7 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/environments` - Liste des environnements +- `POST /api/v1/projects/:projectId/environments` - Creation d'un environnement +- `PUT /api/v1/environments/:environmentId` - Modification d'un environnement +- `DELETE /api/v1/environments/:environmentId` - Suppression d'un environnement + +**Dependances sortantes** : `queries-index` (addLogs, deleteEnvironment, getEnvironmentsByProjectId, initializeEnvironment, updateEnvironment), hooks +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Utilise le pattern `Result` (monad Success/Failure) de `utils/business.ts`. + A decider si on conserve ce pattern dans NestJS ou si on passe aux exceptions +- Scope projet : necessite `ProjectGuard` avec `ProjectAuthorized` +- Hooks environnement declenchent des operations dans les plugins + +**Estimation** : 2 jours + +--- + +### 14. repository + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.8 | +| **Sprint** | S7-S8 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/repositories` - Liste des depots +- `POST /api/v1/projects/:projectId/repositories/sync` - Synchronisation d'un depot +- `POST /api/v1/projects/:projectId/repositories` - Creation d'un depot +- `PUT /api/v1/repositories/:repositoryId` - Modification d'un depot +- `DELETE /api/v1/repositories/:repositoryId` - Suppression d'un depot + +**Dependances sortantes** : `queries-index` (addLogs, deleteRepository, getProjectInfosAndRepos, getProjectRepositories, initializeRepository, updateRepository), hooks (project.upsert, misc.syncRepository) +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Le hook `misc.syncRepository` est specifique a ce module (declenchement de + pipeline miroir GitLab) +- Scope projet : `ProjectGuard` requis +- Gestion des credentials de miroir (donnees sensibles dans Vault) + +**Estimation** : 2 jours + +--- + +### 15. project-role + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.2 | +| **Sprint** | S7-S8 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects/:projectId/roles` - Liste des roles projet +- `POST /api/v1/projects/:projectId/roles` - Creation d'un role +- `PATCH /api/v1/projects/:projectId/roles` - Modification des roles +- `GET /api/v1/projects/:projectId/roles/member-counts` - Comptage des membres +- `DELETE /api/v1/projects/:projectId/roles/:roleId` - Suppression d'un role + +**Dependances sortantes** : `queries-index` (deleteRole, listMembers, listRoles, updateRole), hooks (projectRole.upsert, projectRole.delete) +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Pattern similaire a admin-role (migre en Vague 2) mais scope projet +- Bitmask de permissions projet +- Hooks projectRole declenchent des operations dans Keycloak + +**Estimation** : 1.5 jours + +--- + +### 16. project-member + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 4.7 | +| **Sprint** | S8 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects/:projectId/members` - Liste des membres +- `POST /api/v1/projects/:projectId/members` - Ajout d'un membre +- `PATCH /api/v1/projects/:projectId/members` - Modification des membres +- `DELETE /api/v1/projects/:projectId/members/:memberId` - Retrait d'un membre + +**Dependances sortantes** : +- **`user/business.ts`** (import direct : `logViaSession`) +- `queries-index` (addLogs, deleteMember, listMembers, upsertMember) +- hooks (user.retrieveUserByEmail, projectMember.upsert, projectMember.delete) + +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- La dependance vers `user/business.ts` (`logViaSession`) aura ete resolue + par l'AuthGuard (Couche 0). Pas de dependance residuelle vers UserModule +- Hooks projectMember declenchent des operations dans Keycloak +- Recherche d'utilisateur par email via Keycloak (hook user.retrieveUserByEmail) + +**Estimation** : 1.5 jours + +--- + +### 17. project-service + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 5.6 | +| **Sprint** | S8 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/services` - Liste des services/plugins du projet +- `PUT /api/v1/projects/:projectId/services` - Mise a jour des services + +**Dependances sortantes** : `queries-index` (getAdminPlugin, getProjectInfosByIdOrThrow, getProjectStore, getPublicClusters, saveProjectStore) +**Dependances entrantes** : `utils/hook-wrapper.ts` (importe `ConfigRecords` type et `dbToObj`) + +**Points d'attention** : +- Ce module exporte des types et fonctions utilitaires consommes par hook-wrapper. + Dans l'architecture NestJS, ces types seront definis dans un module partage + ou dans le ProjectServiceModule exporte +- Gestion de la configuration plugin par projet (JSON store) +- Pas de hooks directs + +**Estimation** : 1 jour + +--- + +### 18. service-chain — ✅ MIGRE (2026-04-09) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.9 | +| **Sprint prevu** | S8 | +| **Migration effective** | 2026-04-09 | +| **Dev** | @stephane.trebel | + +**Routes (implementation reelle)** : +- `GET /api/v1/service-chains` - Liste des chaines de service +- `GET /api/v1/service-chains/:id` - Details d'une chaine +- `GET /api/v1/service-chains/:id/flows` - Flux de la chaine +- `POST /api/v1/service-chains/:id/retry` - Relance d'une chaine +- `POST /api/v1/service-chains/validate/:id` - Validation + +**Dependances sortantes** : API externe OpenCDS (HTTP via axios) +**Dependances entrantes** : Aucune + +**Implementation** : +- Module place dans la catégorie `ThirdPartyModules` (futur module optionnel CPin) comme envisagé +- Auth par token et bearer JWT via `AuthService` et `UserGuard` +- Validation UUID sur les parametres d'URL (`ParseUUIDPipe`) +- Validation des reponses OpenCDS via schemas Zod de `@cpn-console/shared` +- Telemetrie via `@StartActiveSpan()` (OpenTelemetry) +- Utilisation d'un service dédié `OpenCdsClientService` afin de gérer les requêtes vers le service OpenCDS externe + +**Differences avec le legacy** : +- 403 systematique si permissions insuffisantes (le legacy renvoyait `[]` sur GET /) +- Pas d'auth session Keycloak (token uniquement) +- Validation UUID stricte (400 si format invalide) +- Client OpenCDS dédié dans server-nestjs + +**Fichiers** : +- `src/cpin-module/service-chain/service-chain.*.ts` +- `src/cpin-module/service-chain/open-cds-client.*.ts` +- `src/modules/infrastructure/auth/` (AuthModule partage) + +--- + +### Plugin : vault (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 7 | +| **Score** | 8.5 | +| **Sprint** | S7-S8 | +| **Dev** | A (en parallele de cluster) | + +**Encapsulation** : Wrapper les 3 classes existantes (VaultApi, VaultProjectApi, +VaultZoneApi) dans un `VaultModule` NestJS avec des services `@Injectable()`. +La logique interne reste intacte. Le module expose `VaultService`, +`VaultProjectService`, `VaultZoneService` via DI. + +**Dependances sortantes (plugins)** : Aucune (feuille du graphe de plugins) +**Dependances entrantes (plugins)** : gitlab, keycloak, harbor, nexus, sonarqube, argocd (tous) + +**Points d'attention** : +- Plugin fondation de toute la chaine. Doit etre encapsule en premier +- Les autres plugins consomment les API classes de vault via le systeme de hooks. + Dans NestJS, ils importeront VaultModule et injecteront les services +- Configuration par variables d'environnement (a integrer dans ConfigurationService) + +**Estimation encapsulation** : 2 jours +**Estimation reecriture complete** : 5 jours (ulterieur) + +--- + +### Plugin : keycloak (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 11 (le plus grand nombre) | +| **Score** | 7.4 | +| **Sprint** | S8 | +| **Dev** | B (en parallele de project-member/service-chain) | + +**Encapsulation** : Wrapper `KeycloakProjectApi` et les fonctions de `functions.ts` +dans un `KeycloakModule` NestJS. + +**Dependances sortantes (plugins)** : vault (runtime via `payload.apis.vault`) +**Dependances entrantes (plugins)** : argocd, harbor, sonarqube + +**Points d'attention** : +- 11 hooks souscrits couvrant projets, zones, roles, membres, utilisateurs +- Le plus grand nombre de fonctions exportees (12) +- Utilise `@keycloak/keycloak-admin-client` (dependance externe) +- Necessite vault encapsule avant (depend de VaultModule) + +**Estimation encapsulation** : 2 jours +**Estimation reecriture complete** : 5-7 jours (ulterieur) + +--- + +## Vague 4 - Module project (decoupe) + plugins intermediaires (S9-S10) + +**Objectif** : Migrer le coeur metier (project) en 3 sous-modules independants. +Encapsuler les plugins intermediaires. + +**Livrable fin S10** : 75 routes migrees (100% des routes metier) + plugins +vault, keycloak, gitlab, harbor, nexus encapsules + +### 19. project-core + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.8 | +| **Sprint** | S9 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects` - Liste des projets +- `GET /api/v1/projects/:projectId` - Details d'un projet +- `POST /api/v1/projects` - Creation d'un projet +- `PUT /api/v1/projects/:projectId` - Modification d'un projet +- `DELETE /api/v1/projects/:projectId` - Archivage d'un projet + +**Dependances sortantes** : `queries-index` (addLogs, getProjectOrThrow, getSlugs, initializeProject, listProjects, lockProject, updateProject), hooks (project.upsert, project.delete, projectRole.upsert) +**Dependances entrantes** : Presque tous les modules a scope projet + +**Points d'attention** : +- Coeur absolu du systeme. Toute erreur ici impacte tout +- Le CRUD projet declenche des hooks vers TOUS les plugins (upsertProject, + deleteProject). C'est le plus gros consommateur de l'EventEmitter +- Gestion du statut projet (created/failed/warning/archived) via state machine +- Verrouillage projet (`lockProject`) pendant les operations longues +- La creation declenche : Keycloak (groupes), Vault (KV store), GitLab (groupe + + repos), Harbor (registre), Nexus (repos), SonarQube (projets), ArgoCD (config) + +**Estimation** : 3 jours + +--- + +### 20. project-secrets + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 1 | +| **Score** | 4.6 | +| **Sprint** | S9 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/secrets` - Secrets du projet + +**Dependances sortantes** : hooks (project.getSecrets) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Appelle le hook `getProjectSecrets` qui interroge chaque plugin pour ses + secrets (Vault, GitLab mirror token, Harbor robot, Nexus credentials, etc.) +- Donnees sensibles : attention au logging et a la serialisation +- Necessite que les plugins soient encapsules pour fonctionner correctement + +**Estimation** : 1 jour + +--- + +### 21. project-bulk + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 4.2 | +| **Sprint** | S9-S10 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/projects/data` - Export bulk des donnees projets +- `POST /api/v1/admin/projects/bulk` - Action bulk sur les projets +- `POST /api/v1/projects/:projectId/replay-hooks` - Rejeu des hooks d'un projet + +**Dependances sortantes** : `queries-index` (getAllProjectsDataForExport, deleteAllEnvironmentForProject, deleteAllRepositoryForProject), hooks (project.upsert pour replay) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Routes admin uniquement +- Le bulk action itere sur les projets avec une limite de parallelisme + (`parallelBulkLimit` dans la config). Attention aux performances +- Le replay-hooks re-execute toute la chaine de hooks pour un projet +- L'export data peut generer de gros payloads + +**Estimation** : 2 jours + +--- + +### Plugin : gitlab (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 8 | +| **Score** | 6.7 | +| **Sprint** | S9 | +| **Dev** | B | + +**Encapsulation** : Wrapper les 3 classes (GitlabApi, GitlabZoneApi, +GitlabProjectApi) dans un `GitlabModule` NestJS. + +**Dependances sortantes (plugins)** : vault (VaultProjectApi pour secrets) +**Dependances entrantes (plugins)** : argocd, nexus, sonarqube + +**Points d'attention** : +- Plugin le plus complexe (13 fichiers source, classe de 570 lignes) +- Utilise `@gitbeaker/core` et `@gitbeaker/rest` (dependances externes) +- Systeme de "pending commits" (commits bufferises puis envoyes en batch) +- Necessite vault encapsule avant + +**Estimation encapsulation** : 2-3 jours +**Estimation reecriture complete** : 7-10 jours (ulterieur) + +--- + +### Plugin : harbor (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 3 | +| **Score** | 5.6 | +| **Sprint** | S9-S10 | +| **Dev** | A | + +**Encapsulation** : Wrapper dans un `HarborModule` NestJS. + +**Dependances sortantes (plugins)** : keycloak (getProjectGroupPath), vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- API generee (fichier `api/Api.ts`) +- Gestion des quotas (parsing de tailles humaines "100MB", "1.2GB") +- Necessite keycloak et vault encapsules avant + +**Estimation encapsulation** : 1.5 jours + +--- + +### Plugin : nexus (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 3 | +| **Score** | 5.1 | +| **Sprint** | S10 | +| **Dev** | B | + +**Encapsulation** : Wrapper dans un `NexusModule` NestJS. + +**Dependances sortantes (plugins)** : vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Gestion Maven + NPM (repos group/hosted/snapshot) +- Configuration riche (activation par techno) +- Necessite vault encapsule avant + +**Estimation encapsulation** : 1.5 jours + +--- + +## Vague 5 - Plugins terminaux + finalisation (S10-S12) + +**Objectif** : Encapsuler les derniers plugins. Demarrer le nettoyage. + +**Livrable fin S11** : Tous les plugins encapsules en modules NestJS + +### Plugin : argocd (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 5 | +| **Score** | 5.3 | +| **Sprint** | S10-S11 | +| **Dev** | A | + +**Encapsulation** : Wrapper dans un `ArgoCDModule` NestJS. + +**Dependances sortantes (plugins)** : gitlab (GitlabProjectApi), keycloak (KeycloakProjectApi), vault (VaultProjectApi, VaultZoneApi) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Le plugin le plus couple (depend de 3 autres plugins) +- Generation YAML complexe (value files pour Helm) +- Necessite gitlab, keycloak et vault encapsules avant +- Dernier plugin avec dependances, donc naturellement parmi les derniers + +**Estimation encapsulation** : 2 jours + +--- + +### Plugin : sonarqube (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 2 | +| **Score** | 4.2 | +| **Sprint** | S11 | +| **Dev** | B | + +**Encapsulation** : Wrapper dans un `SonarQubeModule` NestJS. + +**Dependances sortantes (plugins)** : gitlab (GitlabProjectApi pour CI vars), keycloak (getProjectGroupPath), vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Depend de 3 autres plugins (comme argocd) +- Interaction avec l'API SonarQube + ecriture de variables CI dans GitLab +- Necessite gitlab, keycloak et vault encapsules avant + +**Estimation encapsulation** : 1.5 jours + +--- + +## Vague 6 - Nettoyage (S11-S12) + +- Suppression du reverse proxy Nginx (tout pointe vers `server-nestjs`) +- Suppression du code `apps/server` +- Renommage `server-nestjs` en `server` +- Suppression de `@cpn-console/hooks` (remplace par EventEmitter NestJS) +- Suppression de `ts-rest` cote server (remplace par decorateurs NestJS) +- Mise a jour de tous les `docker-compose` et scripts PNPM/Bash +- Reecriture progressive des plugins (au-dela de l'encapsulation) -- planifiee + dans des iterations futures + +--- + +## Graphe de dependances + +### Graphe actuel (genere par Madge) + +![Graphe des dependances de `server`](./graph-dependances-server-avant.png) + +Commande : + +``` +npx madge --image dependency-graph.png --extensions js,ts apps/server/src +``` + +### Graphe cible des modules NestJS + +```mermaid +flowchart TD + subgraph Transverse["Couche 0 : Socle transverse"] + AuthGuard["AuthService + @AuthUser()"] + ExceptionFilter["GlobalExceptionFilter"] + PermissionsGuard["UserGuard + ProjectGuard"] + ValidationPipe["ValidationPipe global"] + EventEmitter["EventEmitterModule"] + end + + subgraph Infrastructure["Couche 1-2 : Infrastructure (FAIT)"] + ConfigModule["ConfigurationModule"] + LoggerModule["LoggerModule"] + DatabaseService["DatabaseService (Prisma)"] + AppService["AppService"] + FastifyService["FastifyService"] + end + + subgraph Feuilles["Vague 1 : Modules feuilles"] + SystemModule["SystemModule (health, version)"] + SettingsModule["SystemSettingsModule"] + ConfigPluginModule["SystemConfigModule"] + AdminTokenModule["AdminTokenModule"] + UserTokensModule["UserTokensModule"] + LogModule["LogModule"] + end + + subgraph Fondations["Vague 2 : Fondations metier"] + ServiceMonitorModule["ServiceMonitorModule"] + UserModule["UserModule"] + AdminRoleModule["AdminRoleModule"] + StageModule["StageModule"] + ZoneModule["ZoneModule"] + end + + subgraph Couples["Vague 3 : Modules couples"] + ClusterModule["ClusterModule"] + EnvironmentModule["EnvironmentModule"] + RepositoryModule["RepositoryModule"] + ProjectRoleModule["ProjectRoleModule"] + ProjectMemberModule["ProjectMemberModule"] + ProjectServiceModule["ProjectServiceModule"] + ServiceChainModule["ServiceChainModule"] + end + + subgraph ProjectGroup["Vague 4 : Project (decoupe)"] + ProjectCoreModule["ProjectCoreModule"] + ProjectSecretsModule["ProjectSecretsModule"] + ProjectBulkModule["ProjectBulkModule"] + end + + subgraph Plugins["Vague 3-5 : Plugins NestJS"] + VaultModule["VaultModule"] + KeycloakModule["KeycloakModule"] + GitlabModule["GitlabModule"] + HarborModule["HarborModule"] + NexusModule["NexusModule"] + ArgoCDModule["ArgoCDModule"] + SonarQubeModule["SonarQubeModule"] + end + + %% Dependances infrastructure + Feuilles --> AuthGuard + Feuilles --> ExceptionFilter + Feuilles --> ValidationPipe + Fondations --> EventEmitter + Fondations --> PermissionsGuard + + %% Dependances inter-modules metier + ClusterModule --> StageModule + ProjectMemberModule -.-> UserModule + + %% Dependances plugins + KeycloakModule --> VaultModule + GitlabModule --> VaultModule + HarborModule --> KeycloakModule + HarborModule --> VaultModule + NexusModule --> VaultModule + ArgoCDModule --> GitlabModule + ArgoCDModule --> KeycloakModule + ArgoCDModule --> VaultModule + SonarQubeModule --> GitlabModule + SonarQubeModule --> KeycloakModule + SonarQubeModule --> VaultModule + + %% Evenements + ProjectCoreModule -.->|evenements| Plugins + ZoneModule -.->|evenements| Plugins + ClusterModule -.->|evenements| Plugins +``` + +### Dependances directes inter-modules (code actuel) + +| Module source | Importe depuis | Fonction | Resolution NestJS | +|---------------|----------------|----------|-------------------| +| cluster | stage/business | `linkClusterToStages` | ClusterModule importe StageModule | +| project-member | user/business | `logViaSession` | Resolu par AuthService / AuthUser (Couche 0) | +| utils/controller | user/business | `logViaSession`, `logViaToken` | Resolu par AuthService / AuthUser (Couche 0) | +| utils/hook-wrapper | project-service/business | `ConfigRecords`, `dbToObj` | Types partages ou ProjectServiceModule exporte | + +### Dependances inter-plugins + +``` +vault (feuille -- aucune dependance plugin) + | + +--- keycloak (vault en runtime) + | | + | +--- harbor (keycloak + vault) + | +--- argocd (gitlab + keycloak + vault) + | +--- sonarqube (gitlab + keycloak + vault) + | + +--- gitlab (vault) + | + +--- nexus (vault, gitlab en devDependency) + +--- argocd (gitlab + keycloak + vault) + +--- sonarqube (gitlab + keycloak + vault) +``` + +--- + +## Modules transverses + +### Middlewares globaux + +| Middleware | Statut | Module NestJS | +|-----------|--------|---------------| +| CORS | A configurer | AppService (Fastify) | +| Helmet (securite) | FAIT | AppService | +| Cookie + Session | FAIT | AppService | +| Body parser | FAIT | Fastify (natif) | +| Compression | A evaluer | Fastify compression plugin | +| Logging (requetes) | FAIT | LoggerModule (nestjs-pino) | +| Configuration (.env) | FAIT | ConfigurationModule | +| Validation (DTOs) | A CREER | ValidationPipe global (Couche 0) | + +### Services partages + +| Service | Statut | Module NestJS | +|---------|--------|---------------| +| PrismaService (DB) | FAIT | DatabaseService | +| LoggerService | FAIT | LoggerModule | +| ConfigService (env) | FAIT | ConfigurationModule | +| HttpClientService | STUB (vide) | A implementer dans InfrastructureModule | +| ServerService (ts-rest) | STUB | A supprimer (abandon ts-rest) | + +--- + +## Checklist de cartographie + +### Sprint 1 + +- [x] Installer outils d'analyse (Madge) +- [x] Generer graphe de dependances automatique +- [x] Lister toutes les routes (75 routes identifiees) +- [x] Identifier les modules metier principaux (16 domaines + 7 plugins) +- [x] Premiere version de cette cartographie + +### Sprint 2 + +- [x] Affiner les dependances inter-modules +- [x] Calculer les scores de priorisation (28 unites scorees) +- [x] Definir l'ordre de modularisation (6 vagues, bottom-up) +- [x] Documenter les points d'attention par module +- [x] Finaliser les estimations de charge +- [x] Completer ce fichier +- [ ] Valider l'ordre de modularisation avec l'equipe + +--- + +## Estimations de charge par vague + +| Vague | Sprint | Routes | Modules | Charge estimee (j/p) | Objectif | +|-------|--------|--------|---------|----------------------|----------| +| V0 | S3 | 0 | 5 transverses | 5 j/p | Socle (Guards, Filters, Pipes, EventEmitter) | +| V1 | S3-S4 | 13 | 6 | 5 j/p | Quick wins, validation pattern | +| V2 | S5-S6 | 21 | 5 | 9 j/p | Fondations metier, premiers hooks | +| V3 | S7-S8 | 32 | 7 metier + 2 plugins | 17 j/p | Modules couples, vault + keycloak | +| V4 | S9-S10 | 9 | 3 metier + 3 plugins | 13 j/p | Project decoupe, gitlab + harbor + nexus | +| V5 | S10-S11 | 0 | 2 plugins | 3.5 j/p | argocd + sonarqube | +| V6 | S11-S12 | 0 | 0 | 5 j/p | Nettoyage, suppression server | +| **Total** | **S3-S12** | **75** | **28** | **~57.5 j/p** | | + +Avec 2 developpeurs dedies : **~29 jours calendaires** (soit ~6 sprints d'une +semaine), ce qui s'inscrit dans la fenetre S3-S12 (10 sprints disponibles). +La marge de 4 sprints absorbe les imprevisibles, la montee en competence +NestJS, et les eventuelles sessions de pair programming sur les modules +complexes. + +--- + +## Propagation de la modularisation + +Mettre a jour dans ce document : +- Le chemin parcouru par un module migre (quel environnement, a quel + moment/jalon, et avec quel resultat succes/echec) +- Tracer les manques de tests d'integration ou E2E sur un module suivant les + retours effectues +- Expliciter les regles de suivi dans la metrologie de la migration d'un module + (avant/apres, payload et requetes concernees, etc.) + +--- + +## Contact + +**Responsable cartographie** : @stephane.trebel +**Questions** : #backend-modularisation sur Mattermost + +--- + +**Version** : 2.0 +**Derniere mise a jour** : 2026-02-23 +**Prochaine revision majeure** : Fin S4 (premier bilan post-Vague 1) diff --git a/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md b/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md new file mode 100644 index 0000000000..fe899d0bdf --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md @@ -0,0 +1,214 @@ +# État de la modularisation Backend → NestJS + +> 📋 **Ce fichier est mis à jour en temps réel** +> Dernière mise à jour : **2026-07-23** + +--- + +## 🎯 Progression globale + +![Progress](https://progress-bar.dev/7/?title=modularisation&width=400) + +**~60%** complété (16/18 modules métier migrés, 45/75 routes) + +--- + +## 📊 Vue d'ensemble + +| Statut | Nombre de modules | % du total | +|--------|-------------------|------------| +| ✅ Migré | 16 | ~89% | +| 🚧 En cours | 0 | 0% | +| 📅 Planifié | 2 | ~11% | +| ⏳ En attente de cartographie | 0 | 0% | + +--- + +## ✅ Modules migrés + +### ServiceChain (OpenCDS) — migré le 2026-04-09 + +Module proxy HTTP vers l'API externe OpenCDS (gestion des chaînes de service +réseau). Migré en avance de phase par rapport au planning initial (prévu V3/S8), +profitant de son isolement complet vis-à-vis du reste du codebase. + +- **Routes** : 5 (`/api/v1/service-chains/...`) +- **Auth** : `AuthService` unifie `x-dso-token` et bearer JWT Keycloak +- **Nginx** : Bascule effectuée dans `nginx-strangler/conf.d/routing.conf` +- **Tests** : Controller + Service couverts (Vitest) +- **Différences avec le legacy** : + - 403 systématique si permissions insuffisantes (le legacy renvoyait `[]` sur GET /) + - Validation UUID sur les paramètres d'URL (400 si format invalide) + +| Méthode | Route | Permission | +|---------|-------|------------| +| `GET` | `/api/v1/service-chains` | `ListSystem` | +| `GET` | `/api/v1/service-chains/:id` | `ListSystem` | +| `GET` | `/api/v1/service-chains/:id/flows` | `ListSystem` | +| `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | +| `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | + +### Infrastructure transverse déployée + +En support de cette migration, les éléments d'infrastructure suivants ont été +créés : + +- **AuthModule** (`infrastructure/auth/`) : `AuthService` (validation token + SHA256 via Prisma + bearer JWT Keycloak), `DsoTokenModule`, + `KeycloakJwtModule` et décorateur `@AuthUser()` +- **PermissionModule** (`infrastructure/permission/`) : `UserModule` + (`UserGuard`, `UserService`, `UserPolicy`) + `ProjectModule` + (`ProjectGuard`, `ProjectLoaderService`, `ProjectService`, `ProjectPolicy`) +- **Nginx strangler** : Reverse proxy configuré pour router les routes migrées + vers server-nestjs, le reste vers le legacy +- **Docker** : Build order corrigé (shared avant server-nestjs) + +> **Limitation connue** : l'auth par session Keycloak et le flux bearer JWT ont +> désormais la même entrée publique (`AuthService.authenticate(request, ...)`), +> mais les usages côté contrôleurs restent encore à homogénéiser au fil des +> modules migrés. + +--- + +## 🚧 En cours de modularisation + +### Aucune modularisation en cours + +--- + +## 📅 Modules planifiés + +> Ces informations seront affinées après la cartographie (fin S2) + +### Sprint 3-4 (27 janvier - 9 février) +- **Module** : Auth +- **Responsable** : @stephane.trebel +- **Routes** : ~5 +- **Criticité** : 🔴 Haute +- **Statut** : 📅 Planifié + +### Sprint 5-6 (10-23 février) +- **Module** : Users +- **Responsable** : TBD +- **Routes** : ~10 +- **Criticité** : 🔴 Haute +- **Statut** : 📅 Planifié + +### Sprint 7-8 (24 février - 9 mars) +- **Module** : À définir (selon cartographie) +- **Responsable** : TBD +- **Routes** : TBD +- **Statut** : 📅 Planifié + +### Sprint 9-10 (10-23 mars) +- **Module** : À définir (selon cartographie) +- **Responsable** : TBD +- **Routes** : TBD +- **Statut** : 📅 Planifié + +### Sprint 11-12 (24 mars - 6 avril) +- **Modules** : Finalisation + cleanup +- **Responsable** : Équipe +- **Objectif** : Suppression du legacy + +--- + +## 🚫 Zones en feature freeze + +### Aucune zone gelée actuellement + +**Règle** : Quand un module passe en status 🚧 (En cours), il est automatiquement en feature freeze. + +**Que faire si vous devez travailler sur une zone gelée ?** +1. Vérifier l'urgence réelle (Critique / Importante / Normale) +2. Consulter la [matrice de décision](04-COMMUNICATION-PLAN.md#matrice-de-décision-pour-développeurs) +3. Coordonner avec le responsable de la modularisation +4. Annoncer sur #backend-modularisation + +--- + +## 📈 Métriques de qualité + +### Couverture de tests + +| Type | Initial | Actuel | Objectif | +|------|---------|--------|----------| +| E2E Playwright | 33% | 33% | 50% | +| Unitaires Vitest | ? | ? | 70% | +| Tests de contrat | 0% | 0% | 100% | + +### Routes par statut + +- **Total** : ~75 routes métier +- **Migrés** : 45 (~60%) +- **En cours** : 0 (0%) +- **Restants** : ~30 (~40%) + +--- + +## 🗓️ Dates clés + +| Date | Événement | +|------|-----------| +| 07/01/2026 | Début du projet (S1) | +| 26/01/2026 | Fin de la cartographie (S2) | +| 27/01/2026 | Début modularisation Auth (S3) | +| 09/02/2026 | Fin modularisation Auth (S4) - 20% complété | +| 09/03/2026 | Point mi-parcours - 60% complété | +| 26/03/2026 | Migration ServiceChain (OpenCDS) finalisée — 1er module métier migré | +| 06/04/2026 | Fin de modularisation - 100% complété | + +--- + +## 📞 Contacts & Communication + +### Canaux +- **Slack** : #backend-modularisation +- **Meeting hebdo** : Vendredi 16h (30min) +- **Lead technique** : @stephane.trebel + +### Besoin d'aide ? +- **Question technique** : Poser sur #backend-modularisation +- **Conflit de développement** : Contacter @stephane.trebel +- **Urgence production** : Suivre la procédure de rollback (voir [01-TECHNICAL-SETUP.md](01-TECHNICAL-SETUP.md)) + +--- + +## 📚 Documentation + +- [00-OVERVIEW.md](00-OVERVIEW.md) - Vue d'ensemble du projet +- [01-TECHNICAL-SETUP.md](01-TECHNICAL-SETUP.md) - Configuration technique +- [02-modularisation-STRATEGY.md](02-modularisation-STRATEGY.md) - Stratégie de modularisation +- [03-PLANNING.md](03-PLANNING.md) - Planning détaillé 12 sprints +- [04-COMMUNICATION-PLAN.md](04-COMMUNICATION-PLAN.md) - Plan de communication +- [05-TESTING-STRATEGY.md](05-TESTING-STRATEGY.md) - Stratégie de tests +- [modularisation_MAP.md](modularisation_MAP.md) - Cartographie des modules + +--- + +## 🔄 Historique des changements + +### 2026-04-09 +- ✅ Migration du module **ServiceChain (OpenCDS)** — 5 routes, proxy HTTP vers API externe +- ✅ Création de l'**AuthModule** (infrastructure/auth/) : auth par token `x-dso-token` +- ✅ Configuration **nginx-strangler** pour les routes service-chain +- ✅ Fix Docker : build order shared → server-nestjs +- ✅ Mise à jour de ce fichier de suivi + +### 2026-01-07 (S1) +- ✅ Création du fichier de suivi +- ✅ Initialisation de la documentation +- ✅ Kickoff du projet + +--- + +## 📝 Notes + +> Ce fichier sera mis à jour régulièrement (minimum une fois par sprint, idéalement en temps réel). +> Toute l'équipe peut consulter ce fichier pour connaître l'état actuel de la modularisation. +> Pour proposer des modifications ou signaler des incohérences, utiliser #backend-modularisation. + +--- + +**Version du fichier** : 1.1 +**Responsable de mise à jour** : Lead technique backend diff --git a/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md b/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md new file mode 100644 index 0000000000..811aa04f0c --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md @@ -0,0 +1,120 @@ +# Documentation de Modularisation du Backend de Console (apps/server) vers NestJS + +Ce dossier contient toute la documentation nécessaire pour mener à bien la modularisation du backend Node.js vers NestJS. + +## 📁 Structure de la documentation + +### Documents principaux (à lire dans l'ordre) + +1. **00-OVERVIEW.md** - Vue d'ensemble du projet de modularisation + - Objectifs et contexte + - Stratégie "Strangler Pattern" + - Principes directeurs + - Métriques de succès + +2. **01-TECHNICAL-SETUP.md** - Configuration technique détaillée + - Configuration Nginx (reverse proxy) qui va progressivement "étrangler" `server` au profit de `server-nestjs` + - Gestion Prisma/BDD partagée entre `server` et `server-nestjs` + - Déploiement `docker compose` + +3. **02-MODULARISATION-STRATEGY.md** - Stratégie et méthodologie + - Modularisation par slices verticales + - Cartographie et priorisation + - Gestion singletons → Injection de dépendances + - Méthodologie en 7 étapes par module + - Architecture Decision Records (ADR) + +4. **03-PLANNING.md** - Planning détaillé sur les prochains sprints + - Découpage sprint par sprint + - Sprints 1-2 : Fondations + - Sprints 3-4 : Modules transverses de `server` (logger, etc.) + - Sprints 5-6 : Modules applicatifs (Zones, Clusters, etc.) + - Sprints 7-10 : Modules métier (les fameux plugins) + - Sprints 11-12 : Finalisation + - Métriques et KPIs + +5. **04-COMMUNICATION-PLAN.md** - Plan de communication équipe + - Canaux de communication (Mattermost, fichiers, réunions) + - Gestion des zones en modularisation + - Modèles d'annonces concernant la modularisation + - Matrice de décision pour développeurs + - Procédure en cas de conflit + +6. **05-TESTING-STRATEGY.md** - Stratégie de tests + - Tests de contrat (nouveaux, critiques) + - Tests E2E Playwright + - Tests unitaires Vitest + - Tests d'intégration + - Workflow de tests pendant modularisation + - Intégration CI/CD + +### Fichiers vivants (mis à jour régulièrement) + +7. **MODULARISATION_STATUT.md** - Suivi en temps réel de l'avancement + - Progression globale + - Modules migrés + - Modules en cours + - Zones en "feature freeze" du fait de leur modularisation en cours 🚧 + - Dates clés + +8. **MODULARISATION_CARTOGRAPHIE.md** - Cartographie des modules + - Liste de tous les modules avec métadonnées + - Graphe de dépendances + - Scoring et priorisation des modules + - Estimation de charge + +## 🚀 Par où commencer ? + +### Si vous êtes le lead technique de la modularisation + +1. Lire **00-OVERVIEW.md** pour la vue d'ensemble +2. Consulter **01-TECHNICAL-SETUP.md** pour préparer l'infrastructure +3. Étudier **02-MODULARISATION-STRATEGY.md** pour la méthodologie +4. Planifier avec **03-PLANNING.md** +5. Organiser la communication avec **04-COMMUNICATION-PLAN.md** +6. Mettre en place les tests avec **05-TESTING-STRATEGY.md** +7. Compléter **MODULARISATION_MAP.md** lors de la cartographie (S1-S2) +8. Maintenir **MODULARISATION_STATUT.md** à jour tout au long de la modularisation + +### Si vous êtes développeur dans l'équipe + +1. Lire **00-OVERVIEW.md** pour comprendre le projet +2. Consulter **MODULARISATION_STATUT.md** AVANT de traiter un ticket, pour éviter de toucher à une zone en cours de modularisation +3. Suivre le canal Mattermost #backend-modularisation qui sera créé dans cet objectif +4. Respecter les zones en modularisation (🚧) +5. Consulter **04-COMMUNICATION-PLAN.md** en cas de conflit + +## 📊 Contexte du projet + +- **Durée** : 3 mois (12 semaines) +- **Équipe** : 5 développeurs +- **Endpoints** : ~100 +- **Approche** : Strangler Fig Pattern (modularisation progressive) +- **Stack cible** : NestJS + Prisma + Keycloak OIDC + +## 🎯 Objectifs + +- ✅ 100% des endpoints migrés sur NestJS +- ✅ 0 régression fonctionnelle +- ✅ Couverture tests E2E ≥ 50% +- ✅ Équipe autonome sur NestJS +- ✅ Documentation complète + +## 📞 Contact + +- **Lead technique** : @stephane.trebel +- **Canal Mattermost** : #backend-modularisation +- **Réunion hebdo** : Jeudi 10h (30min) + +## 📚 Ressources externes + +- [NestJS Documentation](https://docs.nestjs.com/) +- [Prisma Documentation](https://www.prisma.io/docs) +- [Playwright Documentation](https://playwright.dev/) +- [Vitest Documentation](https://vitest.dev/) + +--- + +**Bonne modularisation ! 🚀** + +*Version 1.0 - Dernière mise à jour : 2026-01-07* diff --git a/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png b/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png new file mode 100644 index 0000000000..569dd60e19 Binary files /dev/null and b/.jjconflict-base-1/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png differ diff --git a/.jjconflict-base-1/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md b/.jjconflict-base-1/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md new file mode 100644 index 0000000000..b7ba2c4258 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md @@ -0,0 +1,271 @@ +# Plan : Nginx "étrangleur" pour la transition `server` → `server-nestjs` + +> Ticket de référence : [#1885](https://github.com/cloud-pi-native/console/issues/1885) +> PR en cours : [#1951](https://github.com/cloud-pi-native/console/pull/1951) — branche `add-server-nextjs-to-local-dev` +> Dernière mise à jour : 2026-02-26 + +--- + +## Contexte et état des lieux + +| Élément | État | +|---|---| +| `server` (Fastify) | Port `8080` interne, exposé `4001` sur l'hôte | +| Nginx actuel | Embarqué dans l'image `client`, proxifie `/api` + `/swagger-ui` vers `server:8080` — upstream changé vers `nginx-strangler:8080` | +| `server-nestjs` | Dockerisé (port `3001`), ajouté dans tous les docker-compose | +| `nginx-strangler` | Service dédié créé dans `apps/nginx-strangler/`, fallback total sur `server` legacy, exposé sur le port `4000` pour se "substituer" à `server` | +| Envs couverts | Local dev, docker-compose (dev/ci/integ) | + +--- + +## Architecture cible + +``` +[Navigateur / API client] + │ + ▼ +┌────────────────────┐ +│ Client (8080) │ ← nginx embarqué dans l'image client +│ nginx "front" │ SPA + proxy /api → nginx-strangler +└────────┬───────────┘ + │ proxy_pass http://nginx-strangler:8080 + ▼ +┌────────────────────┐ +│ nginx-strangler │ ← service dédié (port 8080 interne, non-root) +│ (routing as-code) │ +└──────┬─────────────┘ + │ + ├── /api/[routes migrées] ──→ server-nestjs:3001 + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +**Principe clé** : le nginx client existant reste inchangé dans son rôle SPA, mais son upstream `api` est redirigé vers `nginx-strangler` plutôt que directement vers `server`. Le `nginx-strangler` devient l'unique point de routage des appels API. + +--- + +## Décisions structurantes + +| Décision | Choix | +|---|---| +| Emplacement du nginx-strangler | Répertoire `apps/nginx-strangler/` à la racine du dépôt | +| Port interne de `nginx-strangler` | `8080` (non-root, Trivy DS002) | +| Port interne de `server-nestjs` | `3001` (distinct du legacy sur `8080`) | +| Envs PAX / MinInt | Couverts via docker-compose uniquement dans ce ticket | +| Config nginx | `envsubst` pour substituer les upstreams selon l'environnement | +| Rollback | Commenter une `location` dans `routing.conf` + `nginx -s reload` | +| Sécurité image nginx | `USER nginx`, `HEALTHCHECK`, `chown` sur les répertoires système nginx | +| Sécurité image server-nestjs | `USER node`, `HEALTHCHECK` sur le stage `prod` (Trivy DS026) | + +--- + +## Découvertes en cours de réalisation + +- Le nginx existant dans l'image `client` proxifiait directement vers `server:8080` — upstream changé vers `nginx-strangler:8080` +- `server-nestjs/src/main.ts` utilisait `process.env.PORT ?? 0` au lieu de `ConfigurationService.port` — corrigé +- Le schéma Prisma de `server-nestjs` est multi-fichiers dans `src/prisma/schema/` — `prisma generate` doit pointer sur le dossier, pas sur `schema.prisma` +- En CI (`job-lint.yml`), `nginx -t` échoue si `apt-get update -qq` n'est pas exécuté avant l'install, et si les chemins `pid`/`error_log`/`access_log` ne sont pas patchés vers `/tmp/` (runner non-root) +- En CI (`job-playwright.yml`), les images `server-nestjs:ci` et `nginx-strangler:ci` doivent être buildées localement avant le `docker compose up --no-build` (même pattern que `opencds-mockoon`) + +--- + +## Phase 1 — Socle nginx-strangler ✅ + +### Tâche 1.1 : Créer `apps/nginx-strangler/` ✅ + +``` +apps/nginx-strangler/ +├── Dockerfile # image nginx:1.27-alpine, USER nginx, HEALTHCHECK, EXPOSE 8080, envsubst entrypoint +├── nginx.conf # directives globales (worker_processes, logs, include conf.d/) +├── conf.d/ +│ ├── routing.conf # fichier "vivant" des routes (commenté par vague de migration) +│ └── routing.local.conf.example # template documentaire pour le mode local natif +└── README.md # procédures opérationnelles (déploiement, rollback, ajout de route, dev local) +``` + +### Tâche 1.2 : Contenu initial de `routing.conf` ✅ + +Le fichier utilise des variables d'environnement substituées via `envsubst` pour permettre la réutilisation de la même image dans tous les contextes (Docker, local natif) : + +```nginx +upstream server-legacy { + server ${LEGACY_UPSTREAM}; +} + +upstream server-nestjs { + server ${NESTJS_UPSTREAM}; +} + +server { + listen 8080; + + # ── Routes migrées vers NestJS ────────────────────────────────────────── + # Format : # [Vague X - Module] AAAA-MM-JJ + # (vide au démarrage — toutes les routes sont en fallback sur server-legacy) + + # ── Fallback : tout le reste vers le server legacy ─────────────────────── + location /api/ { + proxy_pass http://server-legacy; + ... + } + + location /swagger-ui { + proxy_pass http://server-legacy; + ... + } +} +``` + +Exemple de bascule future (à ajouter au fil des vagues) : +```nginx +# [Vague 1 - system/health] 2026-03-XX +location = /api/v1/system/health { + proxy_pass http://server-nestjs; + ... +} +``` + +--- + +## Phase 2 — Dockerisation de `server-nestjs` ✅ + +### Tâche 2.1 : Fix du port dans `main.ts` ✅ + +`apps/server-nestjs/src/main.ts` utilisait `process.env.PORT ?? 0` — câblé sur `ConfigurationService.port`. + +### Tâche 2.2 : Créer `apps/server-nestjs/Dockerfile` ✅ + +Multi-stage : `base` → `deps` → `build` → `prod`. `prisma generate --schema=src/prisma/schema` (dossier multi-fichiers). `USER node`, `HEALTHCHECK`, `EXPOSE 3001`. + +### Tâche 2.3 : `apps/server-nestjs/.env.docker-example` ✅ + +`SERVER_PORT=3001` présent. + +--- + +## Phase 3 — Mise à jour des docker-compose ✅ + +### Tâche 3.1 : `docker/docker-compose.dev.yml` ✅ + +Services `server-nestjs` (avec Docker Compose Watch) et `nginx-strangler` ajoutés. `client` dépend de `nginx-strangler`. + +### Tâche 3.2 : `docker/docker-compose.prod.yml` ✅ + +Idem sans Watch. Bug `depends_on` vide sur `opencds-mockoon` corrigé. + +### Tâche 3.3 : `docker/docker-compose.ci.yml` ✅ + +Idem prod. + +### Tâche 3.4 : `docker/docker-compose.integ.yml` ✅ + +Idem dev (avec Watch) + volumes kubeconfig. + +### Tâche 3.5 : `docker/docker-compose.local.yml` ✅ + +`nginx-strangler` ajouté avec `host.docker.internal`, port `8082:8080`, `extra_hosts: host-gateway`. + +### Tâche 3.6 : `apps/client/nginx/default.docker.conf` ✅ + +Upstream `server:8080` → `nginx-strangler:8080`. + +--- + +## Phase 4 — Dev local (pnpm dev natif) ✅ + +### Tâche 4.1 : Template de config pour le mode local ✅ + +`apps/nginx-strangler/conf.d/routing.local.conf.example` créé avec `host.docker.internal` comme valeurs d'exemple. + +### Tâche 4.2 : Documentation ✅ + +- `ENVIRONMENTS.md` : refonte complète avec section Strangler Fig, `server-nestjs` mentionné partout, sous-section dédiée au geste manuel `SERVER_PORT=8082` +- `apps/client/.env-example` : note sur `SERVER_PORT=8082` pour passer par le nginx-strangler en local + +Variables par contexte : + +| Contexte | `LEGACY_UPSTREAM` | `NESTJS_UPSTREAM` | +|---|---|---| +| Docker (dev/prod/ci/integ) | `server:8080` | `server-nestjs:3001` | +| Local (pnpm dev) | `127.0.0.1:4001` | `127.0.0.1:3001` | +| PAX / MinInt | À adapter selon la topologie réseau | + +--- + +## Phase 5 — Validation et outillage + +### Tâche 5.1 : Validation `nginx -t` dans le CI ✅ + +Étape ajoutée dans `.github/workflows/job-lint.yml` : +- `apt-get update -qq` + install `nginx gettext-base` +- `envsubst` sur `routing.conf` +- `sed` pour patcher `user`, `pid`, `error_log`, `access_log` vers des chemins accessibles en non-root +- `nginx -t -c /tmp/nginx-test/nginx.conf` + +### Tâche 5.2 : Smoke test ❌ (à faire) + +Vérifier dans les tests Playwright que `/api/v1/system/health` répond correctement en passant par le nginx-strangler (et non plus directement par `server`). + +### Tâche 5.3 : Procédure de rollback documentée ✅ + +Documentée dans `apps/nginx-strangler/README.md` : + +```bash +# 1. Commenter la location concernée dans routing.conf +# 2. Recharger nginx sans downtime +docker compose exec nginx-strangler nginx -s reload +# 3. Vérifier les logs +docker compose logs -f nginx-strangler | grep /api/v1/xxx +``` + +### Tâche 5.4 (ajout) : Build des images CI dans job-playwright ✅ + +`docker buildx build -t dso-console/server-nestjs:ci` et `nginx-strangler:ci` ajoutés dans `.github/workflows/job-playwright.yml` avant le `docker compose up --no-build`. + +--- + +## Critères d'acceptation + +- [x] Le service `nginx-strangler` démarre correctement dans tous les docker-compose +- [x] Les appels `/api/*` continuent de fonctionner sans régression (fallback total sur `server`) +- [x] `nginx -t` passe en CI +- [x] La config est documentée et le format de bascule des routes est clairement défini +- [x] La procédure de rollback est documentée +- [x] Le dev local (`pnpm dev`) fonctionne avec le nginx-strangler en Docker +- [ ] Smoke test Playwright sur `/api/v1/system/health` via nginx-strangler + +--- + +## Ce qui reste à faire (post-merge PR #1951) + +1. **Tâche 5.2** : Smoke test Playwright (optionnel pour la PR courante, recommandé avant Vague 1) +2. **Vague 1** : Basculer les premiers modules dans `apps/nginx-strangler/conf.d/routing.conf` : + - `system` (health, config, settings) + - `admin-token` + - `user/tokens` + - `log` + - Référence : [`MODULARISATION-CARTOGRAPHIE.md`](../Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) + +--- + +## Séquençage initial et estimation de charge + +``` +Phase 1 : Créer apps/nginx-strangler/ (structure + routing.conf) Dev A ~0.5j ✅ +Phase 2 : Dockeriser server-nestjs (Dockerfile + port) Dev B ~1j ✅ + + Modifier upstream nginx client Dev A ~0.5h ✅ +Phase 3 : Mettre à jour tous les docker-compose Dev A+B ~1j ✅ +Phase 4 : Config locale (envsubst + doc) Dev A ~0.5j ✅ +Phase 5 : CI + smoke test + rollback doc Dev B ~0.5j 🔄 (5.1 ✅, 5.2 ❌, 5.3 ✅) +───────────────────────────────────────────────────────────────────── +Total estimé ~3.5 j/p +``` + +--- + +## Liens + +- Ticket GitHub : [#1885](https://github.com/cloud-pi-native/console/issues/1885) +- PR : [#1951](https://github.com/cloud-pi-native/console/pull/1951) +- Documentation Nginx strangler dans la stratégie de modularisation : [`01-MODULARISATION-STRATEGIE.md`](../Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md) +- Cartographie des modules (vagues de migration) : [`MODULARISATION-CARTOGRAPHIE.md`](../Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) +- Suivi de l'avancement : [`MODULARISATION-STATUT.md`](../Modularisation-de-console-server/MODULARISATION-STATUT.md) diff --git a/.jjconflict-base-1/apps/server-nestjs/eslint.config.mjs b/.jjconflict-base-1/apps/server-nestjs/eslint.config.mjs new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/eslint.config.mjs @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-1/apps/server-nestjs/nest-cli.json b/.jjconflict-base-1/apps/server-nestjs/nest-cli.json new file mode 100644 index 0000000000..f9aa683b1a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/package.json b/.jjconflict-base-1/apps/server-nestjs/package.json new file mode 100644 index 0000000000..79126d32e2 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/package.json @@ -0,0 +1,111 @@ +{ + "name": "@cpn-console/server-nestjs", + "version": "9.22.1", + "private": true, + "description": "", + "author": "", + "license": "UNLICENSED", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "scripts": { + "prebuild": "pnpm i --frozen-lockfile", + "build": "nest build", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev --name dso", + "db:reset": "prisma migrate reset", + "format": "eslint ./ --fix", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "start": "nest start", + "start:debug": "nest start --debug --watch", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "pretest": "pnpm run db:generate", + "test": "vitest run", + "test:watch": "vitest", + "pretest:cov": "pnpm run db:generate", + "test:cov": "vitest run --coverage", + "test:debug": "vitest --inspect" + }, + "dependencies": { + "@cpn-console/argocd-plugin": "workspace:^", + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/harbor-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/nexus-plugin": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/sonarqube-plugin": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/requester-utils": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "@keycloak/keycloak-admin-client": "catalog:runtime", + "@kubernetes-models/argo-cd": "catalog:runtime", + "@nestjs/cache-manager": "catalog:runtime", + "@nestjs/common": "catalog:runtime", + "@nestjs/config": "catalog:runtime", + "@nestjs/core": "catalog:runtime", + "@nestjs/event-emitter": "catalog:runtime", + "@nestjs/jwt": "catalog:runtime", + "@nestjs/platform-fastify": "catalog:runtime", + "@nestjs/schedule": "catalog:runtime", + "@nestjs/swagger": "catalog:runtime", + "@nestjs/terminus": "catalog:runtime", + "@opentelemetry/api": "catalog:otel", + "@opentelemetry/auto-instrumentations-node": "catalog:otel", + "@opentelemetry/exporter-metrics-otlp-proto": "catalog:otel", + "@opentelemetry/exporter-trace-otlp-proto": "catalog:otel", + "@opentelemetry/instrumentation-nestjs-core": "catalog:otel", + "@opentelemetry/instrumentation-pino": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-node": "catalog:otel", + "@prisma/client": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@ts-rest/fastify": "catalog:runtime", + "@ts-rest/open-api": "catalog:runtime", + "cache-manager": "catalog:runtime", + "date-fns": "catalog:runtime", + "fastify": "catalog:runtime", + "fastify-keycloak-adapter": "catalog:runtime", + "json-2-csv": "catalog:runtime", + "mustache": "catalog:runtime", + "nestjs-pino": "catalog:runtime", + "prisma": "catalog:runtime", + "reflect-metadata": "catalog:runtime", + "rxjs": "catalog:runtime", + "undici": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime", + "yaml": "catalog:runtime", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@eslint/eslintrc": "catalog:tools", + "@eslint/js": "catalog:tools", + "@faker-js/faker": "catalog:test", + "@nestjs/cli": "catalog:build", + "@nestjs/schematics": "catalog:build", + "@nestjs/testing": "catalog:build", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "eslint": "catalog:tools", + "globals": "catalog:test", + "msw": "catalog:test", + "pino-pretty": "catalog:tools", + "rimraf": "catalog:tools", + "ts-patch": "catalog:build", + "typescript": "catalog:build", + "typescript-eslint": "catalog:tools", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/prisma.config.ts b/.jjconflict-base-1/apps/server-nestjs/prisma.config.ts new file mode 100644 index 0000000000..ff5aaf9186 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/prisma.config.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' +import { defineConfig } from 'prisma/config' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +export default defineConfig({ + schema: path.join('src', 'prisma', 'schema'), + migrations: { + path: path.join('src', 'prisma', 'migrations'), + }, +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/__mocks__/prisma.ts b/.jjconflict-base-1/apps/server-nestjs/src/__mocks__/prisma.ts new file mode 100644 index 0000000000..2a871fd476 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/__mocks__/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-base-1/apps/server-nestjs/src/config/argocd.config.ts b/.jjconflict-base-1/apps/server-nestjs/src/config/argocd.config.ts new file mode 100644 index 0000000000..a7f1ecc094 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/config/argocd.config.ts @@ -0,0 +1,25 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const argocdFeatureSchema = z.object({ + ARGO_NAMESPACE: z.string().default('argocd'), + ARGOCD_URL: z.string().url(), + ARGOCD_INTERNAL_URL: z.string().url().optional(), + ARGOCD_EXTRA_REPOSITORIES: z.string().min(1), + DSO_ENV_CHART_VERSION: z.string().default('dso-env-1.6.0'), + DSO_NS_CHART_VERSION: z.string().default('dso-ns-1.1.5'), + VAULT__DEPLOY_VAULT_CONNECTION_IN_NS: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform(raw => ({ + namespace: raw.ARGO_NAMESPACE, + url: raw.ARGOCD_URL, + internalUrl: raw.ARGOCD_INTERNAL_URL, + extraRepositories: raw.ARGOCD_EXTRA_REPOSITORIES, + dsoEnvChartVersion: raw.DSO_ENV_CHART_VERSION, + dsoNsChartVersion: raw.DSO_NS_CHART_VERSION, + vaultDeployVaultConnectionInNs: raw.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS, + internalOrPublicUrl: raw.ARGOCD_INTERNAL_URL ?? raw.ARGOCD_URL, + probeUrl: new URL('/api/version', raw.ARGOCD_URL).toString(), +})) + +export const argocdConfigFactory = registerAs('argocd', () => argocdFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/config/base.config.ts b/.jjconflict-base-1/apps/server-nestjs/src/config/base.config.ts new file mode 100644 index 0000000000..33fa5e784f --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/config/base.config.ts @@ -0,0 +1,50 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, nonEmpty, truthySchema } from './config.utils' + +const baseFeatureSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).optional(), + INTEGRATION: flag(truthySchema.default('false')), + CI: flag(truthySchema.default('false')), + DEV_SETUP: flag(truthySchema.default('false')), + DOCKER: flag(truthySchema.default('false')), + SERVER_HOST: z.string().default('localhost'), + SERVER_PORT: z.string().transform(Number).default('0'), + APP_VERSION: z.string().optional().default('unknown'), + DB_URL: z.string().url().optional(), + SESSION_SECRET: z.string().min(32).optional(), + CONTACT_EMAIL: z.string().email().default('cloudpinative-relations@interieur.gouv.fr'), + MOCK_PLUGINS: flag(truthySchema.default('false')), + PROJECTS_ROOT_DIR: nonEmpty(z.string()), + PLUGINS_DIR: z.string().default('/plugins'), + HTTP_PROXY: nonEmpty(z.string().url()), + HTTPS_PROXY: nonEmpty(z.string().url()), +}).transform((raw) => { + const nodeEnv = raw.NODE_ENV ?? 'production' + return { + nodeEnv, + isTest: raw.NODE_ENV === 'test', + isDev: raw.NODE_ENV === 'development', + isCI: raw.CI, + isProd: nodeEnv === 'production', + integration: raw.INTEGRATION, + ci: raw.CI, + devSetup: raw.DEV_SETUP, + docker: raw.DOCKER, + serverHost: raw.SERVER_HOST, + serverPort: raw.SERVER_PORT, + appVersion: raw.NODE_ENV === 'production' ? raw.APP_VERSION : 'dev', + dbUrl: raw.DB_URL, + sessionSecret: raw.SESSION_SECRET, + contactEmail: raw.CONTACT_EMAIL, + mockPlugins: raw.MOCK_PLUGINS, + projectsRootDir: raw.PROJECTS_ROOT_DIR, + pluginsDir: raw.PLUGINS_DIR, + httpProxy: raw.HTTP_PROXY, + httpsProxy: raw.HTTPS_PROXY, + } +}) + +export type BaseConfig = z.infer + +export const baseConfigFactory = registerAs('base', () => baseFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/config/config.utils.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/config/config.utils.spec.ts new file mode 100644 index 0000000000..155c025349 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/config/config.utils.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import z from 'zod' +import { csv, flag, truthySchema } from './config.utils' + +describe('config.utils', () => { + describe('flag', () => { + it('coerces true/1 (case-insensitive) to true', () => { + const schema = flag(truthySchema.default('false')) + expect(schema.parse('true')).toBe(true) + expect(schema.parse('1')).toBe(true) + expect(schema.parse('TRUE')).toBe(true) + }) + + it('coerces false/0 to false', () => { + const schema = flag(truthySchema.default('true')) + expect(schema.parse('false')).toBe(false) + expect(schema.parse('0')).toBe(false) + }) + + it('falls back to the schema default when missing', () => { + expect(flag(truthySchema.default('false')).parse(undefined)).toBe(false) + expect(flag(truthySchema.default('true')).parse(undefined)).toBe(true) + }) + }) + + describe('csv', () => { + const schema = csv(z.string()) + + it('splits, trims and drops empty parts', () => { + expect(schema.parse('a, b ,,c')).toEqual(['a', 'b', 'c']) + }) + + it('maps missing/empty to an empty array', () => { + expect(schema.parse(undefined)).toEqual([]) + expect(schema.parse('')).toEqual([]) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/config/config.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/config/config.utils.ts new file mode 100644 index 0000000000..f06cde73f8 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/config/config.utils.ts @@ -0,0 +1,27 @@ +import z from 'zod' + +// Shared truthy enum for flag(): 'true'/'false'/'1'/'0'. +export const truthySchema = z.enum(['true', 'false', '1', '0']) + +// Boolean flag. Strictly takes a z string schema (e.g. truthySchema.default('true')). +// Lowercases, then coerces 'true'/'1' -> true, 'false'/'0' -> false. +// Missing value falls back to the schema's default. +export function flag(schema: z.ZodType) { + return z + .preprocess( + val => (typeof val === 'string' ? val.toLowerCase() : val), + schema, + ) + .transform(val => val === 'true' || val === '1') +} + +// Comma-separated string -> array of schema-validated, trimmed non-empty parts. +// Empty/missing -> []. Strictly takes a z string schema (applied per element). +export function csv>(schema: T) { + return z + .preprocess( + val => (typeof val === 'string' ? val : val ?? ''), + z.string().transform(value => value.split(',').map(part => part.trim()).filter(Boolean)), + ) + .pipe(z.array(schema)) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/config/gitlab.config.ts b/.jjconflict-base-1/apps/server-nestjs/src/config/gitlab.config.ts new file mode 100644 index 0000000000..d376279d4f --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/config/gitlab.config.ts @@ -0,0 +1,28 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const gitlabFeatureSchema = z.object({ + GITLAB_TOKEN: z.string().min(1), + GITLAB_URL: z.string().url(), + GITLAB_INTERNAL_URL: z.string().url().optional(), + GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS: z.coerce.number().int().positive().default(180), + GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + GITLAB__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), + PROJECTS_ROOT_DIR: z.string().min(1), +}).transform((raw) => { + const urlBase = raw.GITLAB_INTERNAL_URL ?? raw.GITLAB_URL + return { + token: raw.GITLAB_TOKEN, + url: raw.GITLAB_URL, + internalUrl: raw.GITLAB_INTERNAL_URL, + secretExposeInternalUrl: raw.GITLAB__SECRET_EXPOSE_INTERNAL_URL, + mirrorTokenExpirationDays: raw.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS, + mirrorTokenRotationThresholdDays: raw.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS, + projectRootDir: raw.PROJECTS_ROOT_DIR, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/-/health', urlBase).toString(), + } +}) + +export const gitlabConfigFactory = registerAs('gitlab', () => gitlabFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/config/harbor.config.ts b/.jjconflict-base-1/apps/server-nestjs/src/config/harbor.config.ts new file mode 100644 index 0000000000..3e4d093272 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/config/harbor.config.ts @@ -0,0 +1,34 @@ +import type { HarborConfig } from '../modules/registry/harbor.module-definition' +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const harborFeatureSchema = z.object({ + HARBOR_URL: z.string().url(), + HARBOR_INTERNAL_URL: z.string().url().optional(), + HARBOR_ADMIN: z.string().min(1), + HARBOR_ADMIN_PASSWORD: z.string().min(1), + HARBOR_RULE_TEMPLATE: z.string().optional(), + HARBOR_RULE_COUNT: z.string().optional(), + HARBOR_RETENTION_CRON: z.string().default('0 22 2 * * *'), + HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + HARBOR_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform((raw) => { + const urlBase = raw.HARBOR_INTERNAL_URL ?? raw.HARBOR_URL + return { + url: raw.HARBOR_URL, + internalUrl: raw.HARBOR_INTERNAL_URL, + admin: raw.HARBOR_ADMIN, + adminPassword: raw.HARBOR_ADMIN_PASSWORD, + ruleTemplate: raw.HARBOR_RULE_TEMPLATE, + ruleCount: raw.HARBOR_RULE_COUNT, + retentionCron: raw.HARBOR_RETENTION_CRON, + robotRotationThresholdDays: raw.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.HARBOR_PROJECT_SLUG_CACHE_TTL_MS, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/api/v2.0/ping', urlBase).toString(), + } +}) + +export type HarborAppConfig = HarborConfig + +export const harborConfigFactory = registerAs('harbor', () => harborFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/config/keycloak.config.ts b/.jjconflict-base-1/apps/server-nestjs/src/config/keycloak.config.ts new file mode 100644 index 0000000000..cdecb0cadf --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/config/keycloak.config.ts @@ -0,0 +1,53 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, nonEmpty, truthySchema } from './config.utils' + +const keycloakFeatureSchema = z.object({ + USE_KEYCLOAK: flag(truthySchema.default('true')), + KEYCLOAK_PROTOCOL: z.string().default('https'), + KEYCLOAK_DOMAIN: nonEmpty(z.string()), + KEYCLOAK_PUBLIC_PROTOCOL: z.string().default('https'), + KEYCLOAK_PUBLIC_DOMAIN: nonEmpty(z.string()), + KEYCLOAK_REALM: nonEmpty(z.string()), + KEYCLOAK_CLIENT_ID: nonEmpty(z.string()), + KEYCLOAK_CLIENT_SECRET: nonEmpty(z.string()), + KEYCLOAK_ADMIN: nonEmpty(z.string()), + KEYCLOAK_ADMIN_PASSWORD: nonEmpty(z.string()), + KEYCLOAK_ADMIN_CLIENT_ID: z.string().default('admin-cli'), + KEYCLOAK_REDIRECT_URI: nonEmpty(z.string().url()), + KEYCLOAK_JWKS_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + KEYCLOAK_JWKS_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), + KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + ADMIN_KC_USER_ID: z.preprocess( + value => (typeof value === 'string' ? value.split(',').map(part => part.trim()).filter(Boolean) : value), + z.array(z.string()).default([]), + ), +}).transform((raw) => { + const keycloakUrl = `${raw.KEYCLOAK_PROTOCOL}://${raw.KEYCLOAK_DOMAIN}` + const keycloakRealmUrl = `${keycloakUrl}/realms/${raw.KEYCLOAK_REALM}` + return { + enabled: raw.USE_KEYCLOAK, + protocol: raw.KEYCLOAK_PROTOCOL, + domain: raw.KEYCLOAK_DOMAIN, + publicProtocol: raw.KEYCLOAK_PUBLIC_PROTOCOL, + publicDomain: raw.KEYCLOAK_PUBLIC_DOMAIN, + realm: raw.KEYCLOAK_REALM, + clientId: raw.KEYCLOAK_CLIENT_ID, + clientSecret: raw.KEYCLOAK_CLIENT_SECRET, + admin: raw.KEYCLOAK_ADMIN, + adminPassword: raw.KEYCLOAK_ADMIN_PASSWORD, + adminClientId: raw.KEYCLOAK_ADMIN_CLIENT_ID, + redirectUri: raw.KEYCLOAK_REDIRECT_URI, + jwksCacheTtlMs: raw.KEYCLOAK_JWKS_CACHE_TTL_MS, + jwksTimeoutMs: raw.KEYCLOAK_JWKS_TIMEOUT_MS, + openidConfigurationCacheTtlMs: raw.KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS, + adminKcUserId: raw.ADMIN_KC_USER_ID, + url: keycloakUrl, + realmUrl: keycloakRealmUrl, + openidConfigurationUrl: `${keycloakRealmUrl}/.well-known/openid-configuration`, + } +}) + +export type KeycloakConfig = z.infer + +export const keycloakConfigFactory = registerAs('keycloak', () => keycloakFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/config/nexus.config.ts b/.jjconflict-base-1/apps/server-nestjs/src/config/nexus.config.ts new file mode 100644 index 0000000000..cfdc23000b --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/config/nexus.config.ts @@ -0,0 +1,24 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const nexusFeatureSchema = z.object({ + NEXUS_URL: z.string().url(), + NEXUS_INTERNAL_URL: z.string().url().optional(), + NEXUS_ADMIN: z.string().min(1), + NEXUS_ADMIN_PASSWORD: z.string().min(1), + NEXUS__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + const urlBase = raw.NEXUS_INTERNAL_URL ?? raw.NEXUS_URL + return { + url: raw.NEXUS_URL, + internalUrl: raw.NEXUS_INTERNAL_URL, + admin: raw.NEXUS_ADMIN, + adminPassword: raw.NEXUS_ADMIN_PASSWORD, + secretExposeInternalUrl: raw.NEXUS__SECRET_EXPOSE_INTERNAL_URL, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/service/rest/v1/status', urlBase).toString(), + } +}) + +export const nexusConfigFactory = registerAs('nexus', () => nexusFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/config/opencds.config.ts b/.jjconflict-base-1/apps/server-nestjs/src/config/opencds.config.ts new file mode 100644 index 0000000000..fb1da23b96 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/config/opencds.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const opencdsFeatureSchema = z.object({ + OPENCDS_URL: z.string().url(), + OPENCDS_INTERNAL_URL: z.string().url().optional(), + OPENCDS_API_TOKEN: z.string().min(1), + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + const probeBase = raw.OPENCDS_INTERNAL_URL ?? raw.OPENCDS_URL + return { + url: raw.OPENCDS_URL, + internalUrl: raw.OPENCDS_INTERNAL_URL, + probeUrl: new URL('/api/v1/health', probeBase).toString(), + apiToken: raw.OPENCDS_API_TOKEN, + apiTlsRejectUnauthorized: raw.OPENCDS_API_TLS_REJECT_UNAUTHORIZED, + } +}) + +export const openCdsConfigFactory = registerAs('opencds', () => opencdsFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/config/registry.config.ts b/.jjconflict-base-1/apps/server-nestjs/src/config/registry.config.ts new file mode 100644 index 0000000000..c30ef12c50 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/config/registry.config.ts @@ -0,0 +1,30 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const registryFeatureSchema = z.object({ + REGISTRY_URL: z.string().url(), + REGISTRY_INTERNAL_URL: z.string().url().optional(), + REGISTRY_ADMIN: z.string().min(1), + REGISTRY_ADMIN_PASSWORD: z.string().min(1), + REGISTRY_RULE_TEMPLATE: z.string().min(1), + REGISTRY_RULE_COUNT: z.coerce.number().int().nonnegative().optional(), + REGISTRY_RETENTION_CRON: z.string().default('0 22 2 * * *'), + REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + REGISTRY_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform((raw) => { + const urlBase = raw.REGISTRY_INTERNAL_URL ?? raw.REGISTRY_URL + return { + url: raw.REGISTRY_URL, + internalUrl: raw.REGISTRY_INTERNAL_URL, + admin: raw.REGISTRY_ADMIN, + adminPassword: raw.REGISTRY_ADMIN_PASSWORD, + ruleTemplate: raw.REGISTRY_RULE_TEMPLATE, + ruleCount: raw.REGISTRY_RULE_COUNT, + retentionCron: raw.REGISTRY_RETENTION_CRON, + robotRotationThresholdDays: raw.REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.REGISTRY_PROJECT_SLUG_CACHE_TTL_MS, + internalOrPublicUrl: urlBase, + } +}) + +export const registryConfigFactory = registerAs('registry', () => registryFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/config/sonarqube.config.ts b/.jjconflict-base-1/apps/server-nestjs/src/config/sonarqube.config.ts new file mode 100644 index 0000000000..78fdd00939 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/config/sonarqube.config.ts @@ -0,0 +1,19 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const sonarqubeFeatureSchema = z.object({ + SONARQUBE_URL: z.string().url(), + SONARQUBE_INTERNAL_URL: z.string().url().optional(), + SONAR_API_TOKEN: z.string().min(1), +}).transform((raw) => { + const urlBase = raw.SONARQUBE_INTERNAL_URL ?? raw.SONARQUBE_URL + return { + url: raw.SONARQUBE_URL, + internalUrl: raw.SONARQUBE_INTERNAL_URL, + apiToken: raw.SONAR_API_TOKEN, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/api/system/health', urlBase).toString(), + } +}) + +export const sonarqubeConfigFactory = registerAs('sonarqube', () => sonarqubeFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/config/vault.config.ts b/.jjconflict-base-1/apps/server-nestjs/src/config/vault.config.ts new file mode 100644 index 0000000000..83aca3cb62 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/config/vault.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const vaultFeatureSchema = z.object({ + VAULT_TOKEN: z.string().min(1), + VAULT_URL: z.string().url(), + VAULT_INTERNAL_URL: z.string().url().optional(), + VAULT_KV_NAME: z.string().default('forge-dso'), +}).transform((raw) => { + const urlBase = raw.VAULT_INTERNAL_URL ?? raw.VAULT_URL + return { + token: raw.VAULT_TOKEN, + url: raw.VAULT_URL, + internalUrl: raw.VAULT_INTERNAL_URL, + kvName: raw.VAULT_KV_NAME, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/v1/sys/health', urlBase).toString(), + } +}) + +export const vaultConfigFactory = registerAs('vault', () => vaultFeatureSchema.parse(process.env)) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/main.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/main.module.ts new file mode 100644 index 0000000000..fd244f65c0 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/main.module.ts @@ -0,0 +1,53 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { ScheduleModule } from '@nestjs/schedule' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from './config/base.config' +import { DeploymentModule } from './modules/deployment/deployment.module' +import { EnvironmentModule } from './modules/environment/environment.module' +import { HealthzModule } from './modules/healthz/healthz.module' +import { InfrastructureModule } from './modules/infrastructure/infrastructure.module' +import { KeycloakModule } from './modules/keycloak/keycloak.module' +import { LogModule } from './modules/log/log.module' +import { ProjectBulkModule } from './modules/project-bulk/project-bulk.module' +import { ProjectHooksModule } from './modules/project-hooks/project-hooks.module' +import { ProjectMembersModule } from './modules/project-members/project-members.module' +import { ProjectRolesModule } from './modules/project-roles/project-roles.module' +import { ProjectSecretsModule } from './modules/project-secrets/project-secrets.module' +import { ProjectServicesModule } from './modules/project-services/project-services.module' +import { ProjectModule } from './modules/project/project.module' +import { ServiceChainModule } from './modules/service-chain/service-chain.module' +import { SystemSettingsModule } from './modules/system-settings/system-settings.module' +import { VersionModule } from './modules/version/version.module' +import { getDotenvPaths } from './utils/dotenv.utils' + +@Module({ + imports: [ + ConfigModule.forRoot({ + envFilePath: getDotenvPaths(), + isGlobal: true, + load: [baseConfigFactory], + }), + TerminusModule.forRoot(), + InfrastructureModule, + HealthzModule, + ConditionalModule.registerWhen(KeycloakModule, 'USE_KEYCLOAK'), + ScheduleModule.forRoot(), + SystemSettingsModule, + ServiceChainModule, + ProjectModule, + ProjectHooksModule, + ProjectSecretsModule, + ProjectServicesModule, + ProjectBulkModule, + ProjectMembersModule, + ProjectRolesModule, + LogModule, + DeploymentModule, + EnvironmentModule, + VersionModule, + ], + controllers: [], + providers: [], +}) +export class MainModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/main.ts b/.jjconflict-base-1/apps/server-nestjs/src/main.ts new file mode 100644 index 0000000000..600f7232f7 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/main.ts @@ -0,0 +1,66 @@ +import type { NestFastifyApplication } from '@nestjs/platform-fastify' +import type { BaseConfig } from './modules/infrastructure/config/base.config' +import { NestFactory } from '@nestjs/core' +import { FastifyAdapter } from '@nestjs/platform-fastify' +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' +import { NodeSDK, resources } from '@opentelemetry/sdk-node' +import { Logger } from 'nestjs-pino' +import { MainModule } from './main.module' +import { BASE_CONFIG } from './modules/infrastructure/config/base.module' + +const SERVICE_NAME = 'console-pi-native-console' + +const telemetry = new NodeSDK({ + traceExporter: new OTLPTraceExporter({}), + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + }), + instrumentations: [ + getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-nestjs-core': { enabled: true }, + '@opentelemetry/instrumentation-pino': { enabled: true }, + }), + ], + resource: resources.resourceFromAttributes({ + 'service.name': SERVICE_NAME, + }), +}) + +async function bootstrap() { + telemetry.start() + + const app = await NestFactory.create(MainModule, new FastifyAdapter(), { + bufferLogs: true, + }) + + app.useLogger(app.get(Logger)) + app.flushLogs() + app.enableShutdownHooks() + app.getHttpAdapter().getInstance().addHook('onClose', async () => { + await telemetry.shutdown() + }) + + const config = app.get(BASE_CONFIG) + + // Setup swagger-ui route + const swaggerConfig = new DocumentBuilder() + .setTitle('Console Cloud π Native') + .setDescription('Description de l\' API Cloud π Native') + .setVersion('1.0') + .addTag('console') + .build() + const documentFactory = () => SwaggerModule.createDocument(app, swaggerConfig) + SwaggerModule.setup('swagger-ui-server-nestjs', app, documentFactory) + + await app.listen(config.serverPort, config.serverHost) + + const serverUrl = await app.getUrl() + const logger = app.get(Logger) + logger.log(`NestJS server running on: ${serverUrl}`) +} + +void bootstrap() diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts new file mode 100644 index 0000000000..d3103a2e31 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts @@ -0,0 +1,104 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + helmValuesFiles: true, + deployRevision: true, + deployPath: true, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + deployments: { + select: { + id: true, + name: true, + autosync: true, + environment: { + select: { + id: true, + name: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + deploymentSources: { + select: { + type: true, + path: true, + targetRevision: true, + helmValuesFiles: true, + repository: { + select: { + id: true, + internalRepoName: true, + }, + }, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class ArgoCDDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts new file mode 100644 index 0000000000..f79fc15424 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts @@ -0,0 +1,23 @@ +import type { ArgocdConfig } from './argocd.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ARGOCD_CONFIG } from './argocd.module-definition' + +@Injectable() +export class ArgoCDHealthService { + constructor( + @Inject(ARGOCD_CONFIG) private readonly argocdConfig: ArgocdConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.argocdConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts new file mode 100644 index 0000000000..7388fc6dd8 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts @@ -0,0 +1,169 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Injectable } from '@nestjs/common' +import { + DEFAULT_DSO_ENV_CHART_VERSION, + DEFAULT_DSO_NS_CHART_VERSION, + DSO_ENV_CHART_VERSION_PLUGIN_KEY, + DSO_NS_CHART_VERSION_PLUGIN_KEY, + EXTRA_REPOSITORIES_PLUGIN_KEY, + PLATFORM_ADMIN_GROUP_PATH, + PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + PLATFORM_READONLY_GROUP_PATH, + PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY, + PLATFORM_SECURITY_GROUP_PATH, + PROJECT_ADMIN_GROUP_PATH_SUFFIX, + PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_READONLY_GROUP_PATH_SUFFIX, + PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_GROUP_PATH_SUFFIX, +} from './argocd.constants' + +@Injectable() +export class ArgoCDPluginService { + infos(): ServiceInfos { + const extraRepositoriesDesc = 'appproject.spec.sourceRepos supplémentaires, séparés par des virgules (https://a.com/repo.git,https://b.com/' + + return { + name: 'argocd', + to: ({ zones, project }) => zones.map(z => ({ + to: new URL(`applications?showFavorites=false&proj=&sync=&health=&namespace=&cluster=&labels=&search=${project.slug}`, z.argocdUrl).toString(), + title: `ArgoCD ${z.label}`, + })), + title: 'ArgoCD', + imgSrc: '/img/argocd.svg', + description: 'ArgoCD est un outil déclaratif de livraison continue GitOps pour Kubernetes', + config: { + global: [{ + key: EXTRA_REPOSITORIES_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }, { + key: PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Admin Group Path', + value: PLATFORM_ADMIN_GROUP_PATH, + description: 'Chemin du groupe administrateur de plateforme', + }, { + key: PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Readonly Group Path', + value: PLATFORM_READONLY_GROUP_PATH, + description: 'Chemin du groupe lecture seule de plateforme', + }, { + key: 'platformSecurityGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Security Group Path', + value: PLATFORM_SECURITY_GROUP_PATH, + description: 'Chemin du groupe sécurité de plateforme', + }, { + key: PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Admin Group Path Suffix', + value: PROJECT_ADMIN_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe administrateur de projet', + }, { + key: PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project DevOps Group Path Suffix', + value: PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe devops de projet', + }, { + key: PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Developer Group Path Suffix', + value: PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe développeur de projet', + }, { + key: PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Readonly Group Path Suffix', + value: PROJECT_READONLY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe lecture seule de projet', + }, { + key: 'projectSecurityGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Security Group Path Suffix', + value: PROJECT_SECURITY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe sécurité de projet', + }, { + key: DSO_ENV_CHART_VERSION_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Env Chart Version', + value: DEFAULT_DSO_ENV_CHART_VERSION, + description: 'Version du chart Helm dso-env', + placeholder: DEFAULT_DSO_ENV_CHART_VERSION, + }, { + key: DSO_NS_CHART_VERSION_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Namespace Chart Version', + value: DEFAULT_DSO_NS_CHART_VERSION, + description: 'Version du chart Helm dso-ns', + placeholder: DEFAULT_DSO_NS_CHART_VERSION, + }], + project: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts new file mode 100644 index 0000000000..65d58f8955 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts @@ -0,0 +1,94 @@ +import type { ProjectWithDetails } from './argocd-datastore.service' + +import { faker } from '@faker-js/faker' + +export function makeProjectDeploymentSource( + overrides: Partial = {}, +): ProjectWithDetails['deployments'][number]['deploymentSources'][number] { + return { + type: 'git', + path: '.', + targetRevision: 'HEAD', + helmValuesFiles: '', + repository: makeProjectRepository(), + ...overrides, + } satisfies ProjectWithDetails['deployments'][number]['deploymentSources'][number] +} + +export function makeProjectDeployment( + overrides: Partial = {}, +): ProjectWithDetails['deployments'][number] { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + environment: makeProjectEnvironment(), + autosync: true, + deploymentSources: [ + { + type: 'git', + path: '.', + targetRevision: 'HEAD', + helmValuesFiles: '', + repository: makeProjectRepository(), + }, + ], + ...overrides, + } satisfies ProjectWithDetails['deployments'][number] +} + +export function makeProjectRepository( + overrides: Partial = {}, +): ProjectWithDetails['repositories'][number] { + return { + id: faker.string.uuid(), + internalRepoName: faker.word.noun(), + isInfra: false, + deployRevision: 'HEAD', + deployPath: '.', + helmValuesFiles: '', + ...overrides, + } satisfies ProjectWithDetails['repositories'][number] +} + +export function makeCluster( + overrides: Partial = {}, +): ProjectWithDetails['environments'][number]['cluster'] { + return { + id: faker.string.uuid(), + label: faker.word.noun(), + zone: { + slug: faker.word.noun(), + }, + ...overrides, + } satisfies ProjectWithDetails['environments'][number]['cluster'] +} + +export function makeProjectEnvironment( + overrides: Partial = {}, +): ProjectWithDetails['environments'][number] { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + cluster: makeCluster(), + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + ...overrides, + } satisfies ProjectWithDetails['environments'][number] +} + +export function makeProjectWithDetails( + overrides: Partial = {}, +): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(faker.word.words({ count: 2 })).toLowerCase(), + name: faker.word.noun(), + environments: [makeProjectEnvironment()], + repositories: [makeProjectRepository()], + plugins: [], + deployments: [makeProjectDeployment()], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.constants.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.constants.ts new file mode 100644 index 0000000000..d856cf98bf --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.constants.ts @@ -0,0 +1,30 @@ +// ArgoCD plugin constants +export const PLUGIN_NAME = 'argocd' + +// Platform-level group paths +export const CONSOLE_ADMIN_GROUP_PATH = '/console/admin' +export const PLATFORM_ADMIN_GROUP_PATH = '/console/admin' +export const PLATFORM_READONLY_GROUP_PATH = '/console/readonly' +export const PLATFORM_SECURITY_GROUP_PATH = '/console/security' + +// Suffix for project group path +export const PROJECT_ADMIN_GROUP_PATH_SUFFIX = '/console/admin' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const PROJECT_READONLY_GROUP_PATH_SUFFIX = '/console/readonly' +export const PROJECT_SECURITY_GROUP_PATH_SUFFIX = '/console/security' + +// Default chart versions +export const DEFAULT_DSO_ENV_CHART_VERSION = 'dso-env-1.6.0' +export const DEFAULT_DSO_NS_CHART_VERSION = 'dso-ns-1.1.5' + +// Plugin configuration keys +export const EXTRA_REPOSITORIES_PLUGIN_KEY = 'extraRepositories' +export const PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY = 'platformAdminGroupPath' +export const PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY = 'platformReadonlyGroupPath' +export const PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectAdminGroupPathSuffix' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevopsGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevelopperGroupPathSuffix' +export const PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReadonlyGroupPathSuffix' +export const DSO_ENV_CHART_VERSION_PLUGIN_KEY = 'dsoEnvChartVersion' +export const DSO_NS_CHART_VERSION_PLUGIN_KEY = 'dsoNsChartVersion' diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts new file mode 100644 index 0000000000..a134c72f4d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts @@ -0,0 +1,19 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface ArgocdConfig { + namespace: string + url: string + internalUrl: string + extraRepositories: string + dsoEnvChartVersion: string + dsoNsChartVersion: string + vaultDeployVaultConnectionInNs: boolean + internalOrPublicUrl?: string +} + +export interface ArgoCDModuleOptions extends ArgocdConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('argocd') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as ARGOCD_CONFIG } diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts new file mode 100644 index 0000000000..91447152ec --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts @@ -0,0 +1,26 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { argocdConfigFactory } from '../../config/argocd.config' +import { ArgoCDModule } from './argocd.module' +import { ArgoCDService } from './argocd.service' + +describe('argocdModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ArgoCDService when USE_GITLAB=false', async () => { + vi.stubEnv('USE_GITLAB', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule.forRoot(argocdConfigFactory.asProvider()), 'USE_GITLAB')], + }).compile() + expect(() => module.get(ArgoCDService)).toThrow() + }) + + it('omits ArgoCDService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule.forRoot(argocdConfigFactory.asProvider()), 'USE_VAULT')], + }).compile() + expect(() => module.get(ArgoCDService)).toThrow() + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.module.ts new file mode 100644 index 0000000000..42f04c0841 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { argocdConfigFactory } from '../../config/argocd.config' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' +import { GitlabModule } from '../gitlab/gitlab.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { ArgoCDHealthService } from './argocd-health.service' +import { ArgoCDPluginService } from './argocd-plugin.service' +import { ArgoCDService } from './argocd.service' + +@Module({ + imports: [DatabaseModule, ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), TerminusModule, ConditionalModule.registerWhen(VaultModule, 'USE_VAULT'), ConfigModule.forFeature(argocdConfigFactory), ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + providers: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService, ArgoCDDatastoreService], + exports: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService], +}) +export class ArgoCDModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts new file mode 100644 index 0000000000..2c0511c4bf --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -0,0 +1,541 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { ArgocdConfig } from './argocd.module-definition' +import { generateNamespaceName } from '@cpn-console/shared' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { stringify } from 'yaml' +import { GitlabClientService } from '../gitlab/gitlab-client.service' +import { makeCommitAction, makeProjectSchema, makeRepositoryTreeSchema } from '../gitlab/gitlab-testing.utils' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { makeProjectDeployment, makeProjectDeploymentSource, makeProjectEnvironment, makeProjectRepository, makeProjectWithDetails } from './argocd-testing.utils' +import { ARGOCD_CONFIG } from './argocd.module-definition' +import { ArgoCDService } from './argocd.service' + +describe('argoCDService', () => { + let service: ArgoCDService + let datastore: DeepMockProxy + let gitlab: DeepMockProxy + let vault: DeepMockProxy + let argocdConfig: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + datastore = mockDeep() + gitlab = mockDeep() + vault = mockDeep() + argocdConfig = mockDeep({ + namespace: 'argocd', + url: 'https://argocd.internal', + internalUrl: undefined, + extraRepositories: 'repo3', + dsoEnvChartVersion: 'dso-env-1.6.0', + dsoNsChartVersion: 'dso-ns-1.1.5', + vaultDeployVaultConnectionInNs: false, + internalOrPublicUrl: undefined, + }) + baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep({ + url: 'https://vault.internal', + kvName: 'kv', + internalUrl: undefined, + }) + + const module = await Test.createTestingModule({ + providers: [ + ArgoCDService, + { provide: ArgoCDDatastoreService, useValue: datastore }, + { provide: ARGOCD_CONFIG, useValue: argocdConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: GitlabClientService, useValue: gitlab }, + { provide: VaultClientService, useValue: vault }, + ], + }).compile() + + service = module.get(ArgoCDService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('should return OK result when ensureProject succeeds', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [], + repositories: [], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) + gitlab.maybeCreateCommit.mockResolvedValue(undefined) + + const result = await service.handleUpsert(mockProject) + + expect(result).toEqual({ + argocd: expect.objectContaining({ + status: 'OK', + message: 'Up to date', + executionTime: expect.any(Number), + }), + }) + }) + + it('should return KO result when ensureProject throws', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const error = new Error('GitLab unreachable') + gitlab.getOrCreateInfraGroupRepo.mockRejectedValue(error) + + const result = await service.handleUpsert(mockProject) + + expect(result).toEqual({ + argocd: expect.objectContaining({ + status: 'KO', + message: 'GitLab unreachable', + executionTime: expect.any(Number), + error, + }), + }) + }) + }) + + it('should sync project environments', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + makeProjectEnvironment({ name: 'prod', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + // Verify Gitlab calls + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'dev', + 'dso/environment.id': mockProject.environments[0].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-dev-6293', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/dev/values.yaml', + roGroup: '/project-1/console/dev/RO', + rwGroup: '/project-1/console/dev/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockProject.environments[0].id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + id: mockProject.repositories[0].id, + name: 'infra-repo', + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: 'HEAD', + path: '.', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/dev/values.yaml', + }, + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'prod', + 'dso/environment.id': mockProject.environments[1].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-prod-c626', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/prod/values.yaml', + roGroup: '/project-1/console/prod/RO', + rwGroup: '/project-1/console/prod/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockProject.environments[1].id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + id: mockProject.repositories[0].id, + name: 'infra-repo', + repoURL: 'https://gitlab.internal/group/project-1/infra-repo.git', + targetRevision: 'HEAD', + path: '.', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/prod/values.yaml', + }, + ]), + ) + + expect(gitlab.listFiles).toHaveBeenCalledWith(infraProject, { + path: 'Project 1/', + recursive: true, + }) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(2) + }) + + it('should delete values file when an environment is removed', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([ + makeRepositoryTreeSchema( + { name: 'values.yaml', path: 'Project 1/cluster-1/dev/values.yaml' }, + ), + makeRepositoryTreeSchema( + { name: 'values.yaml', path: 'Project 1/cluster-1/prod/values.yaml' }, + ), + ]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + expect.objectContaining({ + action: 'create', + filePath: 'Project 1/cluster-1/dev/values.yaml', + }), + { + action: 'delete', + filePath: 'Project 1/cluster-1/prod/values.yaml', + }, + ]), + ) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(1) + }) + + it('should not commit when there is no diff', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + + gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) + + await expect(service.handleCron()).resolves.not.toThrow() + + expect(gitlab.maybeCreateCommit).not.toHaveBeenCalled() + }) + + it('should sync project deployments', async () => { + const mockRepo = makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true }) + const mockDevEnv = makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }) + const mockProdEnv = makeProjectEnvironment({ name: 'prod', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }) + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + mockDevEnv, + mockProdEnv, + ], + repositories: [mockRepo], + plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], + deployments: [ + makeProjectDeployment({ + environment: mockDevEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: 'dev' })], + }), + makeProjectDeployment({ + environment: mockDevEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: '1.0.0', path: 'service-1' })], + }), + makeProjectDeployment({ + environment: mockProdEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: 'prod' })], + }), + ], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + // Verify Gitlab calls + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'dev', + 'dso/environment.id': mockProject.environments[0].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-dev-6293', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/dev/values.yaml', + roGroup: '/project-1/console/dev/RO', + rwGroup: '/project-1/console/dev/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockDevEnv.id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + name: 'infra-repo', + id: mockRepo.id, + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: 'dev', + path: '.', + valueFiles: [], + }, + { + name: 'infra-repo', + id: mockRepo.id, + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: '1.0.0', + path: 'service-1', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/dev/values.yaml', + }, + ]), + ) + + expect(gitlab.listFiles).toHaveBeenCalledWith(infraProject, { + path: 'Project 1/', + recursive: true, + }) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(4) // 2 environments + 2 deployments + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.service.ts new file mode 100644 index 0000000000..cbfa862247 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -0,0 +1,662 @@ +import type { CommitAction, CondensedProjectSchema, ProjectSchema, SimpleProjectSchema } from '@gitbeaker/core' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { ProjectWithDetails } from './argocd-datastore.service' +import type { ArgocdConfig } from './argocd.module-definition' +import { createHmac } from 'node:crypto' +import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { stringify } from 'yaml' +import { GitlabClientService } from '../gitlab/gitlab-client.service' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { + CONSOLE_ADMIN_GROUP_PATH, + PLATFORM_ADMIN_GROUP_PATH, + PLATFORM_READONLY_GROUP_PATH, + PLATFORM_SECURITY_GROUP_PATH, + PROJECT_ADMIN_GROUP_PATH_SUFFIX, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + PROJECT_READONLY_GROUP_PATH_SUFFIX, + PROJECT_SECURITY_GROUP_PATH_SUFFIX, +} from './argocd.constants' +import { ARGOCD_CONFIG } from './argocd.module-definition' + +@Injectable() +export class ArgoCDService { + private readonly logger = new Logger(ArgoCDService.name) + + constructor( + @Inject(ArgoCDDatastoreService) private readonly datastore: ArgoCDDatastoreService, + @Inject(ARGOCD_CONFIG) private readonly argocdConfig: ArgocdConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('ArgoCDService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('argocd', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`ArgoCD sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('argocd', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`ArgoCD sync completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + this.logger.log('Starting ArgoCD reconciliation') + const projects = await this.datastore.getAllProjects() + const span = trace.getActiveSpan() + span?.setAttribute('argocd.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for ArgoCD reconciliation`) + await this.ensureProjects(projects) + this.logger.log(`ArgoCD reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('argocd.projects.count', projects.length) + this.logger.verbose(`Reconciling ArgoCD projects (count=${projects.length})`) + await Promise.all(projects.map(project => this.ensureProject(project))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling ArgoCD project ${project.slug}`) + await this.ensureZones(project) + this.logger.verbose(`ArgoCD project reconciled (${project.slug})`) + } + + @StartActiveSpan() + private async ensureZones( + project: ProjectWithDetails, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const zones = getDistinctZones(project) + span?.setAttribute('argocd.zones.count', zones.length) + this.logger.verbose(`Reconciling ArgoCD zones for project ${project.slug} (count=${zones.length})`) + await Promise.all(zones.map(zoneSlug => this.ensureZone(project, zoneSlug))) + } + + @StartActiveSpan() + private async ensureZone( + project: ProjectWithDetails, + zoneSlug: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const infraProject = await this.gitlab.getOrCreateInfraGroupRepo(zoneSlug) + span?.setAttributes({ + 'argocd.repo.id': infraProject.id, + 'argocd.repo.path': infraProject.path_with_namespace, + 'zone.slug': zoneSlug, + }) + this.logger.verbose(`Reconciling ArgoCD zone for project ${project.slug} in zone ${zoneSlug} (repoId=${infraProject.id})`) + + const actions = await this.generateEnvironmentActions(project, infraProject, zoneSlug) + + span?.setAttribute('argocd.repo.actions.count', actions.length) + if (actions.length === 0) { + this.logger.verbose(`No ArgoCD changes need to be committed for project ${project.slug} in zone ${zoneSlug}`) + return + } + + this.logger.log(`Applying ArgoCD changes for project ${project.slug} in zone ${zoneSlug} (actions=${actions.length})`) + await this.gitlab.maybeCreateCommit(infraProject, `ci: :robot_face: Sync ${project.slug}`, actions) + } + + private async generateEnvironmentActions( + project: ProjectWithDetails, + infraProject: ProjectSchema, + zoneSlug: string, + ) { + const environmentActions = await this.generateEnvironmentsUpdateActions( + project, + project.environments, + infraProject, + zoneSlug, + ) + const deploymentActions = await this.generateDeploymentsUpdateActions( + project, + infraProject, + zoneSlug, + ) + const purgeEnvironmentActions = await this.generatePurgeEnvironmentActions( + project, + infraProject, + zoneSlug, + ) + return [ + ...(deploymentActions.length ? deploymentActions : environmentActions), + ...purgeEnvironmentActions, + ] satisfies CommitAction[] + } + + private async generatePurgeEnvironmentActions( + project: ProjectWithDetails, + infraProject: CondensedProjectSchema, + zoneSlug: string, + ): Promise { + const neededFiles = new Set() + const clusterLabelsInZone = new Set( + project.environments + .filter(e => e.cluster.zone.slug === zoneSlug) + .map(e => e.cluster.label), + ) + + project.environments.forEach((env) => { + if (env.cluster?.zone.slug !== zoneSlug) return + neededFiles.add(formatEnvironmentValuesFilePath(project, env.cluster, env)) + }) + + const existingFiles = await this.gitlab.listFiles(infraProject, { + path: `${project.name}/`, + recursive: true, + }) + + const projectPrefix = `${project.name}/` + const actions = existingFiles + .filter((existingFile) => { + if (existingFile.name !== 'values.yaml') return false + if (!existingFile.path.startsWith(projectPrefix)) return false + + const remaining = existingFile.path.slice(projectPrefix.length) + const clusterLabel = remaining.split('/')[0] + if (!clusterLabel || !clusterLabelsInZone.has(clusterLabel)) return false + + return !neededFiles.has(existingFile.path) + }) + .map(existingFile => ({ action: 'delete', filePath: existingFile.path } satisfies CommitAction)) + + this.logger.verbose(`Computed ArgoCD purge actions for project ${project.slug} in zone ${zoneSlug} (actions=${actions.length})`) + + return actions + } + + private async generateEnvironmentsUpdateActions( + project: ProjectWithDetails, + environments: ProjectWithDetails['environments'], + infraProject: SimpleProjectSchema, + zoneSlug: string, + ): Promise { + this.logger.verbose(`Computing ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug} (environments=${environments.length})`) + const actions = await Promise.all( + environments + .filter(env => env.cluster?.zone.slug === zoneSlug) + .map(env => this.generateEnvironmentUpdateAction(project, env, infraProject)), + ) + const filteredActions: CommitAction[] = actions.filter(a => !!a) + this.logger.verbose(`Computed ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug} (actions=${filteredActions.length})`) + return filteredActions + } + + private async generateEnvironmentUpdateAction( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + infraProject: SimpleProjectSchema, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const vaultValues = await this.generateVaultValues(project.slug) + const cluster = environment.cluster + if (!cluster) { + this.logger.warn(`Cluster not found for environment ${environment.id} in project ${project.slug}`) + throw new Error(`Cluster not found for environment ${environment.id}`) + } + span?.setAttribute('zone.slug', cluster.zone.slug) + + const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment) + + const gitlabPublicProjectUrl = `${(await this.gitlab.getOrCreateProjectGroupPublicUrl())}/${project.slug}` + + const values = formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories: this.argocdConfig.extraRepositories, + infraProject, + valueFilePath, + vaultValues, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, + }) + + return this.gitlab.generateCreateOrUpdateAction( + infraProject, + 'main', + valueFilePath, + stringify(values), + ) + } + + private async generateDeploymentsUpdateActions( + project: ProjectWithDetails, + infraProject: SimpleProjectSchema, + zoneSlug: string, + ): Promise { + this.logger.verbose(`Computing ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug} (environments=${project.deployments.length})`) + const environments = project.deployments.reduce< + Record + >((acc, deployment) => { + const environment = deployment.environment + if (acc[environment.id]) acc[environment.id].deployments.push(deployment) + else acc[environment.id] = { environment, deployments: [deployment] } + return acc + }, {}) + + const actions = await Promise.all( + Object.values(environments) + .filter(({ environment }) => environment.cluster?.zone.slug === zoneSlug) + .map(({ environment, deployments }) => this.generateEnvironmentWithDeploymentsUpdateAction(project, environment, deployments, infraProject)), + ) + + const filteredActions: CommitAction[] = actions.filter(a => !!a) + this.logger.verbose(`Computed ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug} (actions=${filteredActions.length})`) + return filteredActions + } + + private async generateEnvironmentWithDeploymentsUpdateAction( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + deployments: ProjectWithDetails['deployments'][number][], + infraProject: SimpleProjectSchema, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const vaultValues = await this.generateVaultValues(project.slug) + const cluster = environment.cluster + if (!cluster) { + this.logger.warn(`Cluster not found for environment ${environment.id} in project ${project.slug}`) + throw new Error(`Cluster not found for environment ${environment.id}`) + } + span?.setAttribute('zone.slug', cluster.zone.slug) + + const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment) + + const gitlabPublicProjectUrl = `${(await this.gitlab.getOrCreateProjectGroupPublicUrl())}/${project.slug}` + + const values = formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories: this.argocdConfig.extraRepositories, + infraProject, + valueFilePath, + vaultValues, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, + deployments, + }) + + return this.gitlab.generateCreateOrUpdateAction( + infraProject, + 'main', + valueFilePath, + stringify(values), + ) + } + + private async generateVaultValues(projectSlug: string): Promise> { + this.logger.verbose(`Generating Vault project values for projectSlug=${projectSlug}`) + const roleId = await this.vault.getAuthApproleRoleRoleId(projectSlug).catch(() => { + this.logger.warn(`Couldn't find app role (project=${projectSlug})`) + return undefined + }) + const secretId = await this.vault.createAuthApproleRoleSecretId(projectSlug).catch(() => { + this.logger.warn(`Couldn't find secret (project=${projectSlug})`) + return undefined + }) + return { + projectsRootDir: this.baseConfig.projectsRootDir, + url: this.argocdConfig.vaultDeployVaultConnectionInNs ? this.vaultConfig.url : '', + coreKvName: this.vaultConfig.kvName, + roleId: roleId ?? 'none', + secretId: secretId ?? 'none', + } + } +} + +interface ValuesSchema { + common: { + 'dso/project': string + 'dso/project.id': string + 'dso/project.slug': string + 'dso/environment': string + 'dso/environment.id': string + } + argocd: { + cluster: string + namespace: string + project: string + envChartVersion: string + nsChartVersion: string + } + environment: { + valueFileRepository: string + valueFileRevision: string + valueFilePath: string + roGroup: string + rwGroup: string + consoleAdminGroup: string + platformAdminGroup: string + platformReadonlyGroup: string + platformSecurityGroup: string + projectAdminGroup: string + projectDevopsGroup: string + projectDevelopperGroup: string + projectSecurityGroup: string + projectReadonlyGroup: string + } + application: { + quota: { + cpu: number + gpu: number + memory: string + } + sourceRepositories: string[] + destination: { + namespace: string + name: string + } + autosync: boolean + vault: Record + repositories: { + name: string + id: string + repoURL: string + targetRevision: string + path: string + valueFiles: string[] + }[] + } + features: { + fineGrainedRoles: { + enabled: boolean + } + } +} + +function formatReadOnlyGroupName(projectSlug: string, environmentName: string) { + return generateEnvironmentConsoleGroupPath(projectSlug, environmentName, 'RO') +} + +function formatReadWriteGroupName(projectSlug: string, environmentName: string) { + return generateEnvironmentConsoleGroupPath(projectSlug, environmentName, 'RW') +} + +function generateEnvironmentConsoleGroupPath(projectSlug: string, environmentName: string, access: 'RO' | 'RW') { + return `/${projectSlug}/console/${environmentName}/${access}` +} + +function generateProjectConsoleGroupPath(projectSlug: string, suffix: string) { + return `/${projectSlug}${suffix}` +} + +function formatAppProjectName(projectSlug: string, env: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${envHash}` +} + +function formatEnvironmentValuesFilePath(project: { name: string }, cluster: { label: string }, env: { name: string }): string { + return `${project.name}/${cluster.label}/${env.name}/values.yaml` +} + +function getDistinctZones(project: ProjectWithDetails) { + const zones = new Set() + project.environments.forEach(e => zones.add(e.cluster.zone.slug)) + return [...zones] +} + +function splitExtraRepositories(extraRepositories: string | undefined): string[] { + if (!extraRepositories) return [] + return extraRepositories.split(',').map(r => r.trim()).filter(r => r.length > 0) +} + +function formatRepositoriesValues( + repositories: ProjectWithDetails['repositories'], + gitlabPublicProjectUrl: string, + envName: string, +) { + return repositories + .filter(repo => repo.isInfra) + .map((repository) => { + const valueFiles = splitExtraRepositories(repository.helmValuesFiles?.replaceAll('', envName)) + return { + id: repository.id, + name: repository.internalRepoName, + repoURL: `${gitlabPublicProjectUrl}/${repository.internalRepoName}.git`, + targetRevision: repository.deployRevision || 'HEAD', + path: repository.deployPath || '.', + valueFiles, + } satisfies ValuesSchema['application']['repositories'][number] + }) +} + +function formatRepositoriesValuesFromDeployments( + deployments: ProjectWithDetails['deployments'][number][], + gitlabPublicProjectUrl: string, + envName: string, +) { + return deployments.flatMap(deployment => + deployment.deploymentSources + .map((source) => { + const valueFiles = splitExtraRepositories(source.helmValuesFiles?.replaceAll('', envName)) + return { + name: source.repository.internalRepoName, + id: source.repository.id, + repoURL: `${gitlabPublicProjectUrl}/${source.repository.internalRepoName}.git`, + targetRevision: source.targetRevision || 'HEAD', + path: source.path || '.', + valueFiles, + } satisfies ValuesSchema['application']['repositories'][number] + }), + ) +} + +function formatEnvironmentValues( + project: ProjectWithDetails, + infraProject: SimpleProjectSchema, + valueFilePath: string, + roGroup: string, + rwGroup: string, +) { + return { + valueFileRepository: infraProject.http_url_to_repo, + valueFileRevision: 'HEAD', + valueFilePath, + roGroup, + rwGroup, + consoleAdminGroup: CONSOLE_ADMIN_GROUP_PATH, + platformAdminGroup: PLATFORM_ADMIN_GROUP_PATH, + platformReadonlyGroup: PLATFORM_READONLY_GROUP_PATH, + platformSecurityGroup: PLATFORM_SECURITY_GROUP_PATH, + projectAdminGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_ADMIN_GROUP_PATH_SUFFIX), + projectDevopsGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_DEVOPS_GROUP_PATH_SUFFIX), + projectDevelopperGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX), + projectSecurityGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_SECURITY_GROUP_PATH_SUFFIX), + projectReadonlyGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_READONLY_GROUP_PATH_SUFFIX), + } satisfies ValuesSchema['environment'] +} + +interface FormatSourceRepositoriesValuesOptions { + gitlabPublicProjectUrl: string + argocdExtraRepositories?: string + projectPlugins?: ProjectWithDetails['plugins'] +} + +function formatSourceRepositoriesValues( + { gitlabPublicProjectUrl, argocdExtraRepositories, projectPlugins }: FormatSourceRepositoriesValuesOptions, +): string[] { + let projectExtraRepositories = '' + if (projectPlugins) { + const argocdPlugin = projectPlugins.find(p => p.pluginName === 'argocd' && p.key === 'extraRepositories') + if (argocdPlugin) projectExtraRepositories = argocdPlugin.value + } + + return [ + `${gitlabPublicProjectUrl}/**`, + ...splitExtraRepositories(argocdExtraRepositories), + ...splitExtraRepositories(projectExtraRepositories), + ] +} + +interface FormatCommonOptions { + project: ProjectWithDetails + environment: ProjectWithDetails['environments'][number] +} + +function formatCommon({ project, environment }: FormatCommonOptions) { + return { + 'dso/project': project.name, + 'dso/project.id': project.id, + 'dso/project.slug': project.slug, + 'dso/environment': environment.name, + 'dso/environment.id': environment.id, + } satisfies ValuesSchema['common'] +} + +interface FormatArgoCDValuesOptions { + namespace: string + project: string + envChartVersion: string + nsChartVersion: string +} + +function formatArgoCDValues(options: FormatArgoCDValuesOptions) { + const { namespace, project, envChartVersion, nsChartVersion } = options + return { + cluster: inClusterLabel, + namespace, + project, + envChartVersion, + nsChartVersion, + } satisfies ValuesSchema['argocd'] +} + +interface FormatValuesOptions { + project: ProjectWithDetails + environment: ProjectWithDetails['environments'][number] + cluster: ProjectWithDetails['environments'][number]['cluster'] + gitlabPublicProjectUrl: string + argocdExtraRepositories?: string + vaultValues: Record + infraProject: SimpleProjectSchema + valueFilePath: string + argoNamespace: string + envChartVersion: string + nsChartVersion: string + deployments?: ProjectWithDetails['deployments'][number][] +} + +function formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories, + vaultValues, + infraProject, + valueFilePath, + argoNamespace, + envChartVersion, + nsChartVersion, + deployments, +}: FormatValuesOptions) { + return { + common: formatCommon({ project, environment }), + argocd: formatArgoCDValues({ + namespace: argoNamespace, + project: formatAppProjectName(project.slug, environment.name), + envChartVersion, + nsChartVersion, + }), + environment: formatEnvironmentValues( + project, + infraProject, + valueFilePath, + formatReadOnlyGroupName(project.slug, environment.name), + formatReadWriteGroupName(project.slug, environment.name), + ), + application: { + quota: { + cpu: environment.cpu, + gpu: environment.gpu, + memory: `${environment.memory}Gi`, + }, + sourceRepositories: formatSourceRepositoriesValues({ + gitlabPublicProjectUrl, + argocdExtraRepositories, + projectPlugins: project.plugins, + }), + destination: { + namespace: generateNamespaceName(project.id, environment.id), + name: cluster.label, + }, + autosync: environment.autosync, + vault: vaultValues, + repositories: deployments + ? formatRepositoriesValuesFromDeployments(deployments, gitlabPublicProjectUrl, environment.name) + : formatRepositoriesValues( + project.repositories, + gitlabPublicProjectUrl, + environment.name, + ), + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + } satisfies ValuesSchema +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/configurable-feature-module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/configurable-feature-module.ts new file mode 100644 index 0000000000..85b2418316 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/configurable-feature-module.ts @@ -0,0 +1,78 @@ +import type { DynamicModule } from '@nestjs/common' +import { ConfigurableModuleBuilder } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' + +// Minimal shape of the generated configurable class, so consumers (e.g. `XModule.forRoot(...)`) +// see the `forRoot` static method. The arg is `TOptions` (plain object, for tests) OR a +// `ConfigFactory` (e.g. `registerAs('', ...)`), whose `.asProvider()` is wired by +// `forRoot` into this module's options token. +export interface ConfigurableCls { + new(...args: any[]): any + forRoot: (arg: TOptions | any) => DynamicModule +} + +// Builds a configurable module that OWNS its config contract (interface embedded in-module, +// no ../../config / registerAs dependency inside the module). The module is wired from the +// outside via `XModule.forRoot(xxxConfigFactory)`, where `xxxConfigFactory` is the +// `registerAs('', ...)` result from `config/*.config.ts`. `` MUST equal +// `token` so `ConfigModule.forFeature` registers it under `CONFIGURATION()`. +// `isGlobal` makes the module (and its config token) available app-wide via @Global(). +// ponytail: native forRoot only takes a plain options object; we wrap it so it also accepts +// a `ConfigFactory` and wires it (via ConfigModule.forFeature + a provider under the token). +function isConfigFactory(arg: unknown): boolean { + return typeof arg === 'function' && 'asProvider' in (arg as any) + && typeof (arg as any).asProvider === 'function' +} + +function isConfigProvider(arg: unknown): arg is { provide: unknown, useFactory?: unknown, useValue?: unknown } { + return Boolean(arg) && typeof arg === 'object' + && 'provide' in (arg as Record) + && ('useFactory' in (arg as Record) || 'useValue' in (arg as Record)) +} + +export function createConfigurableModule(token: string, isGlobal = false) { + const { ConfigurableModuleClass } = new ConfigurableModuleBuilder({ optionsInjectionToken: token }) + .setClassMethodName('forRoot') + .setFactoryMethodName('useFactory') + .setExtras( + { isGlobal }, + (definition: DynamicModule, extras) => ({ + ...definition, + exports: [...(definition.exports ?? []), token], + global: extras.isGlobal, + }), + ) + .build() + + class WrappedModuleClass extends (ConfigurableModuleClass as any) { + static forRoot(arg: any): DynamicModule { + const def = (super.forRoot as (o: unknown) => DynamicModule)({}) as DynamicModule + if (isConfigFactory(arg)) { + const provider = (arg as any).asProvider() + return { + ...def, + imports: [(ConfigModule as any).forFeature(arg), ...(def.imports ?? [])], + providers: [ + ...(def.providers ?? []), + { provide: token, useFactory: provider.useFactory, inject: provider.inject }, + ], + exports: [...(def.exports ?? []), token], + } + } + if (isConfigProvider(arg)) { + const provider = arg as any + return { + ...def, + providers: [...(def.providers ?? []), provider], + exports: [...(def.exports ?? []), provider.provide], + } + } + return (super.forRoot as (o: unknown) => DynamicModule)(arg) + } + } + + return { + ConfigurableModuleClass: WrappedModuleClass as unknown as ConfigurableCls, + MODULE_OPTIONS_TOKEN: token, + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts new file mode 100644 index 0000000000..01267db740 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts @@ -0,0 +1,155 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' + +const mockDeployment = { + name: 'dep1', + id: 'dep1', + projectId: 'id', + memory: 3, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: 'envId', +} + +describe('deploymentDatastoreService', () => { + let module: TestingModule + let service: DeploymentDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + DeploymentDatastoreService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(DeploymentDatastoreService) + }) + + describe('getDeploymentById', () => { + it('should return a deployment with sources and repository', async () => { + const deployment = { ...mockDeployment } + + prisma.deployment.findUnique.mockResolvedValue(deployment) + + const result = await service.getDeploymentById('dep1') + + expect(prisma.deployment.findUnique).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + }) + expect(result).toEqual(deployment) + }) + }) + + describe('getDeploymentsByProjectId', () => { + it('should return deployments for a project', async () => { + const deployments = [{ ...mockDeployment }] + + prisma.deployment.findMany.mockResolvedValue(deployments) + + const result = await service.getDeploymentsByProjectId('project1') + + expect(prisma.deployment.findMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }) + expect(result).toEqual(deployments) + }) + }) + + describe('createDeployment', () => { + it('should create a deployment', async () => { + const created = { + ...mockDeployment, + name: 'test', + } + + const data = { + ...created, + project: { connect: { id: 'id' } }, + environment: { connect: { id: 'envId' } }, + } + + prisma.deployment.create.mockResolvedValue(data) + + const result = await service.createDeployment({ + ...data, + }) + + expect(prisma.deployment.create).toHaveBeenCalledWith({ data }) + expect(result).toEqual(data) + }) + }) + + describe('updateDeployment', () => { + it('should update a deployment', async () => { + const updated = { ...mockDeployment, name: 'updated' } + + prisma.deployment.update.mockResolvedValue(updated) + + const result = await service.updateDeployment('dep1', { + ...mockDeployment, + name: 'updated', + }) + + expect(prisma.deployment.update).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + data: { ...mockDeployment, name: 'updated' }, + }) + expect(result).toEqual(updated) + }) + }) + + describe('deleteDeployment', () => { + it('should delete a deployment', async () => { + const deleted = { ...mockDeployment } + + prisma.deployment.delete.mockResolvedValue(deleted) + + const result = await service.deleteDeployment('dep1') + + expect(prisma.deployment.delete).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + }) + expect(result).toEqual(deleted) + }) + }) + + describe('deleteAllDeploymentsByProjectId', () => { + it('should delete all deployments for a project', async () => { + const response = { count: 3 } + + prisma.deployment.deleteMany.mockResolvedValue(response) + + const result = await service.deleteAllDeploymentsByProjectId('project1') + + expect(prisma.deployment.deleteMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + }) + expect(result).toEqual(response) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts new file mode 100644 index 0000000000..859a299494 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts @@ -0,0 +1,56 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +@Injectable() +export class DeploymentDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + getDeploymentById(deploymentId: string) { + return this.prisma.deployment.findUnique({ + where: { id: deploymentId }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + }) + } + + getDeploymentsByProjectId(projectId: string) { + return this.prisma.deployment.findMany({ + where: { projectId }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }) + } + + createDeployment(data: Prisma.DeploymentCreateInput) { + return this.prisma.deployment.create({ data }) + } + + updateDeployment(deploymentId: string, data: Prisma.DeploymentUpdateInput) { + return this.prisma.deployment.update({ + where: { id: deploymentId }, + data, + }) + } + + deleteDeployment(deploymentId: string) { + return this.prisma.deployment.delete({ + where: { id: deploymentId }, + }) + } + + deleteAllDeploymentsByProjectId(projectId: string) { + return this.prisma.deployment.deleteMany({ + where: { projectId }, + }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts new file mode 100644 index 0000000000..20f7e249a3 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts @@ -0,0 +1,85 @@ +import type { Deployment, DeploymentSource, Environment, Prisma, Repository } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeDeployment(overrides: Partial = {}): Deployment { + return { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + autosync: true, + environmentId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } +} + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + projectId: faker.string.uuid(), + memory: 2, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export function makeRepository(overrides: Partial = {}): Repository { + return { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: faker.string.alphanumeric(8).toLowerCase(), + externalRepoUrl: '', + externalUserName: '', + isInfra: false, + isPrivate: false, + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } +} + +type DeploymentSourceWithRepository = DeploymentSource & { repository: Repository } + +export function makeDeploymentSource(overrides: Partial = {}): DeploymentSourceWithRepository { + const repositoryId = overrides.repositoryId ?? faker.string.uuid() + return { + id: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + deploymentId: faker.string.uuid(), + repositoryId, + type: 'git', + targetRevision: 'main', + path: '/app', + helmValuesFiles: '', + repository: makeRepository({ id: repositoryId }), + ...overrides, + } +} + +export type DeploymentWithRelations = Prisma.DeploymentGetPayload<{ + include: { + environment: true + deploymentSources: { include: { repository: true } } + } +}> + +export function makeDeploymentWithRelations(overrides: Partial = {}): DeploymentWithRelations { + const base = makeDeployment(overrides) + return { + ...base, + environment: overrides.environment ?? makeEnvironment({ id: base.environmentId, projectId: base.projectId }), + deploymentSources: overrides.deploymentSources ?? [makeDeploymentSource({ deploymentId: base.id })], + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts new file mode 100644 index 0000000000..53f28c259f --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts @@ -0,0 +1,139 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { makeDeployment, makeDeploymentWithRelations } from './deployment-testing.utils' +import { DeploymentController } from './deployment.controller' +import { DeploymentService } from './deployment.service' + +describe('deploymentController', () => { + let module: TestingModule + let controller: DeploymentController + let service: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + + const project: ProjectContext = { id: projectId, slug: 'my-project' } + const user: UserContext = { userId } + const request = { id: requestId } as FastifyRequest + + const validCreateDeployment = { + name: 'dev', + projectId, + environmentId: '22222222-2222-2222-2222-222222222222', + autosync: true, + deploymentSources: [ + { + repositoryId: '33333333-3333-3333-3333-333333333333', + type: 'git', + targetRevision: 'main', + path: '/app', + }, + ], + } satisfies CreateDeployment + + const validUpdateDeployment = { + ...validCreateDeployment, + deploymentSources: [ + { + id: '44444444-4444-4444-4444-444444444444', + repositoryId: '33333333-3333-3333-3333-333333333333', + type: 'git', + targetRevision: 'develop', + path: '/updated-app', + }, + ], + } satisfies UpdateDeployment + + beforeEach(async () => { + service = mockDeep() + + module = await Test.createTestingModule({ + controllers: [DeploymentController], + providers: [ + { provide: DeploymentService, useValue: service }, + ], + }) + .overrideGuard(ProjectGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(DeploymentController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call deploymentService.listByProjectId with projectId', async () => { + const expectedResult = [makeDeploymentWithRelations({ projectId })] + + service.listByProjectId.mockResolvedValue(expectedResult) + + const result = await controller.list(project) + + expect(service.listByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('create', () => { + it('should validate body and call deploymentService.createDeployment', async () => { + const expectedResult = makeDeployment() + + service.createDeployment.mockResolvedValue(expectedResult) + + const result = await controller.create(validCreateDeployment, project, user, request) + + expect(service.createDeployment).toHaveBeenCalledWith( + projectId, + validCreateDeployment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('update', () => { + it('should validate body and call deploymentService.updateDeployment', async () => { + const deploymentId = '55555555-5555-5555-5555-555555555555' + const expectedResult = makeDeployment({ id: deploymentId }) + + service.updateDeployment.mockResolvedValue(expectedResult) + + const result = await controller.update(deploymentId, validUpdateDeployment, project, user, request) + + expect(service.updateDeployment).toHaveBeenCalledWith( + projectId, + deploymentId, + validUpdateDeployment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('delete', () => { + it('should call deploymentService.deleteDeployment with deploymentId', async () => { + const deploymentId = '55555555-5555-5555-5555-555555555555' + + service.deleteDeployment.mockResolvedValue(undefined) + + const result = await controller.delete(deploymentId, project, user, request) + + expect(service.deleteDeployment).toHaveBeenCalledWith(projectId, deploymentId, userId, requestId) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.controller.ts new file mode 100644 index 0000000000..d3a8706e01 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.controller.ts @@ -0,0 +1,63 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { CreateDeploymentSchema, UpdateDeploymentSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { DeploymentService } from './deployment.service' + +@Controller('api/v1/projects/:projectId/deployments') +@UseGuards(ProjectGuard) +export class DeploymentController { + constructor(@Inject(DeploymentService) private readonly deploymentService: DeploymentService) {} + + @Get('') + @RequireAdminPermission('ListProjects') + @RequireProjectPermission('ListDeployments') + list(@Project() project: ProjectContext) { + return this.deploymentService.listByProjectId(project.id) + } + + @Post('') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.CREATED) + create( + @Body(new ZodValidationPipe(CreateDeploymentSchema)) data: CreateDeployment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.createDeployment(project.id, data, user.userId, request.id) + } + + @Put(':deploymentId') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.OK) + update( + @Param('deploymentId') deploymentId: string, @Body(new ZodValidationPipe(UpdateDeploymentSchema)) + data: UpdateDeployment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.updateDeployment(project.id, deploymentId, data, user.userId, request.id) + } + + @Delete(':deploymentId') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.NO_CONTENT) + delete( + @Param('deploymentId') deploymentId: string, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.deleteDeployment(project.id, deploymentId, user.userId, request.id) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.module.ts new file mode 100644 index 0000000000..cc0f6e1a91 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { ProjectModule } from '../project/project.module' +import { DeploymentDatastoreService } from './deployment-datastore.service' +import { DeploymentController } from './deployment.controller' +import { DeploymentService } from './deployment.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, EventsModule, ProjectModule, ProjectPermissionModule], + controllers: [DeploymentController], + providers: [ + DeploymentDatastoreService, + DeploymentService, + ], +}) +export class DeploymentModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts new file mode 100644 index 0000000000..3b9e19c058 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts @@ -0,0 +1,222 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' +import { makeDeployment, makeDeploymentSource, makeDeploymentWithRelations } from './deployment-testing.utils' +import { DeploymentService } from './deployment.service' + +describe('deploymentService', () => { + let module: TestingModule + let service: DeploymentService + let datastore: DeepMockProxy + let appEvents: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + const deploymentId = '22222222-2222-2222-2222-222222222222' + + const okArgoCDResults = { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } } + + const validCreateDeployment = { + name: 'mydeployment', + projectId, + environmentId: '33333333-3333-3333-3333-333333333333', + autosync: true, + deploymentSources: [ + { + repositoryId: '44444444-4444-4444-4444-444444444444', + type: 'git', + targetRevision: 'main', + path: '/app', + helmValuesFiles: 'values.yaml', + }, + ], + } satisfies CreateDeployment + + const validUpdateDeployment = { + ...validCreateDeployment, + deploymentSources: [ + { + id: '55555555-5555-5555-5555-555555555555', + repositoryId: '44444444-4444-4444-4444-444444444444', + type: 'git', + targetRevision: 'develop', + path: '/updated-app', + helmValuesFiles: 'updated-values.yaml', + }, + ], + } satisfies UpdateDeployment + + beforeEach(async () => { + datastore = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + DeploymentService, + { provide: DeploymentDatastoreService, useValue: datastore }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(DeploymentService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('listByProjectId', () => { + it('should return deployments by projectId', async () => { + const deployments = [makeDeploymentWithRelations({ id: deploymentId, projectId })] + datastore.getDeploymentsByProjectId.mockResolvedValue(deployments) + + const result = await service.listByProjectId(projectId) + + expect(datastore.getDeploymentsByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(deployments) + }) + }) + + describe('createDeployment', () => { + it('should create deployment and trigger a project reconciliation', async () => { + const createdDeployment = makeDeployment({ id: deploymentId, projectId }) + + datastore.createDeployment.mockResolvedValue(createdDeployment) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + const result = await service.createDeployment(projectId, validCreateDeployment, userId, requestId) + + expect(datastore.createDeployment).toHaveBeenCalledWith({ + name: validCreateDeployment.name, + project: { connect: { id: projectId } }, + autosync: validCreateDeployment.autosync, + environment: { connect: { id: validCreateDeployment.environmentId } }, + deploymentSources: { + createMany: { + data: validCreateDeployment.deploymentSources.map(source => ({ + type: source.type, + repositoryId: source.repositoryId, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + })), + }, + }, + }) + + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Create Deployment', userId, requestId }) + expect(result).toEqual(createdDeployment) + }) + + it('should return the deployment even when the reconciliation fails', async () => { + const createdDeployment = makeDeployment({ id: deploymentId, projectId }) + datastore.createDeployment.mockResolvedValue(createdDeployment) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + const result = await service.createDeployment(projectId, validCreateDeployment, userId, requestId) + + expect(result).toEqual(createdDeployment) + }) + }) + + describe('updateDeployment', () => { + it('should update deployment and trigger a project reconciliation', async () => { + const existingDeployment = makeDeploymentWithRelations({ + id: deploymentId, + projectId, + deploymentSources: [ + makeDeploymentSource({ id: '55555555-5555-5555-5555-555555555555', deploymentId }), + makeDeploymentSource({ id: '66666666-6666-6666-6666-666666666666', deploymentId }), + ], + }) + + const updatedDeployment = makeDeployment({ id: deploymentId, projectId }) + + datastore.getDeploymentById.mockResolvedValue(existingDeployment) + datastore.updateDeployment.mockResolvedValue(updatedDeployment) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + const result = await service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId) + + expect(datastore.updateDeployment).toHaveBeenCalledWith( + deploymentId, + expect.objectContaining({ + name: validUpdateDeployment.name, + deploymentSources: { + deleteMany: { + id: { in: ['66666666-6666-6666-6666-666666666666'] }, + }, + upsert: expect.any(Array), + }, + }), + ) + + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Update Deployment', userId, requestId }) + expect(result).toEqual(updatedDeployment) + }) + + it('should throw if deployment does not exist', async () => { + datastore.getDeploymentById.mockResolvedValue(null) + + await expect( + service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId), + ).rejects.toThrow(`Deployment with id ${deploymentId} not found`) + + expect(datastore.updateDeployment).not.toHaveBeenCalled() + }) + + it('should return the deployment even when the reconciliation fails', async () => { + const existingDeployment = makeDeploymentWithRelations({ + id: deploymentId, + projectId, + deploymentSources: [ + makeDeploymentSource({ id: '55555555-5555-5555-5555-555555555555', deploymentId }), + ], + }) + const updatedDeployment = makeDeployment({ id: deploymentId, projectId }) + datastore.getDeploymentById.mockResolvedValue(existingDeployment) + datastore.updateDeployment.mockResolvedValue(updatedDeployment) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + const result = await service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId) + + expect(result).toEqual(updatedDeployment) + }) + }) + + describe('deleteDeployment', () => { + it('should delete deployment and trigger a project reconciliation', async () => { + datastore.deleteDeployment.mockResolvedValue(makeDeployment({ id: deploymentId, projectId })) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + await service.deleteDeployment(projectId, deploymentId, userId, requestId) + + expect(datastore.deleteDeployment).toHaveBeenCalledWith(deploymentId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Delete Deployment', userId, requestId }) + }) + + it('should resolve even when the reconciliation fails', async () => { + datastore.deleteDeployment.mockResolvedValue(makeDeployment({ id: deploymentId, projectId })) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + await expect(service.deleteDeployment(projectId, deploymentId, userId, requestId)).resolves.toBeUndefined() + }) + }) + + describe('deleteAllDeploymentsByProjectId', () => { + it('should delete all deployments and upsert project', async () => { + datastore.deleteAllDeploymentsByProjectId.mockResolvedValue({ count: 1 }) + + await service.deleteAllDeploymentsByProjectId(projectId) + + expect(datastore.deleteAllDeploymentsByProjectId).toHaveBeenCalledWith(projectId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Delete all project deployments' }) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.service.ts new file mode 100644 index 0000000000..beec47e562 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/deployment/deployment.service.ts @@ -0,0 +1,108 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { EventLogAction } from '../events/app-events.service' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' + +@Injectable() +export class DeploymentService { + private readonly logger = new Logger(DeploymentService.name) + + constructor( + @Inject(DeploymentDatastoreService) private readonly deploymentDatastoreService: DeploymentDatastoreService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async listByProjectId(projectId: string) { + return this.deploymentDatastoreService.getDeploymentsByProjectId(projectId) + } + + async createDeployment(projectId: string, deploymentToCreate: CreateDeployment, userId: string, requestId: string) { + const deployment = await this.deploymentDatastoreService.createDeployment({ + name: deploymentToCreate.name, + project: { connect: { id: projectId } }, + autosync: deploymentToCreate.autosync, + environment: { connect: { id: deploymentToCreate.environmentId } }, + deploymentSources: { + createMany: { + data: deploymentToCreate.deploymentSources.map(({ type, repositoryId, targetRevision, path, helmValuesFiles }) => ({ + type, + repositoryId, + targetRevision, + path, + helmValuesFiles, + })), + }, + }, + }) + + this.reconcileProject(projectId, 'Create Deployment', userId, requestId) + return deployment + } + + async updateDeployment(projectId: string, deploymentId: string, deploymentToUpdate: UpdateDeployment, userId: string, requestId: string) { + const existing = await this.deploymentDatastoreService.getDeploymentById(deploymentId) + if (!existing) throw new Error(`Deployment with id ${deploymentId} not found`) + + const incomingDeploymentSourceIds = new Set( + deploymentToUpdate.deploymentSources + .filter(s => s.id) + .map(s => s.id), + ) + + const deploymentSourcesToDelete = existing.deploymentSources.filter( + e => !incomingDeploymentSourceIds.has(e.id), + ) + + const deployment = await this.deploymentDatastoreService.updateDeployment(deploymentId, { + name: deploymentToUpdate.name, + autosync: deploymentToUpdate.autosync, + environment: { connect: { id: deploymentToUpdate.environmentId } }, + deploymentSources: { + deleteMany: { + id: { in: deploymentSourcesToDelete.map(s => s.id) }, + }, + upsert: deploymentToUpdate.deploymentSources.map(source => ({ + where: { id: source.id ?? crypto.randomUUID() }, + update: { + repository: { connect: { id: source.repositoryId } }, + type: source.type, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + }, + create: { + repository: { connect: { id: source.repositoryId } }, + type: source.type, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + }, + })), + }, + }) + this.reconcileProject(projectId, 'Update Deployment', userId, requestId) + return deployment + } + + async deleteDeployment(projectId: string, deploymentId: string, userId: string, requestId: string) { + await this.deploymentDatastoreService.deleteDeployment(deploymentId) + this.reconcileProject(projectId, 'Delete Deployment', userId, requestId) + } + + async deleteAllDeploymentsByProjectId(projectId: string) { + await this.deploymentDatastoreService.deleteAllDeploymentsByProjectId(projectId) + await this.appEvents.emitProjectEvent('project.upsert', projectId, { action: 'Delete all project deployments' }) + } + + /** + * Triggers the project reconciliation without blocking the response: listener + * outcomes (including failures) are persisted in the admin log by AppEventsService. + */ + private reconcileProject(projectId: string, action: EventLogAction, userId: string, requestId: string) { + this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) + .catch((error: unknown) => { + this.logger.error(`project.upsert reconciliation failed (projectId=${projectId})`, error instanceof Error ? error.stack : String(error)) + }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts new file mode 100644 index 0000000000..6189f20316 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts @@ -0,0 +1,181 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeCluster, makeEnvironment, makeStage } from './environment-testing.utils' + +describe('environmentDatastoreService', () => { + let module: TestingModule + let service: EnvironmentDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentDatastoreService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(EnvironmentDatastoreService) + }) + + describe('getProjectEnvironment', () => { + it('should return an environment with its cluster scoped to the project', async () => { + const environment = makeEnvironment({ id: 'env1', projectId: 'project1' }) + prisma.environment.findFirst.mockResolvedValue(environment) + + const result = await service.getProjectEnvironment('project1', 'env1') + + expect(prisma.environment.findFirst).toHaveBeenCalledWith({ + where: { id: 'env1', projectId: 'project1' }, + include: { cluster: true }, + }) + expect(result).toEqual(environment) + }) + }) + + describe('getEnvironmentsByProjectId', () => { + it('should return environments for a project with their stage', async () => { + const environments = [makeEnvironment({ projectId: 'project1' })] + prisma.environment.findMany.mockResolvedValue(environments) + + const result = await service.getEnvironmentsByProjectId('project1') + + expect(prisma.environment.findMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + include: { stage: true }, + }) + expect(result).toEqual(environments) + }) + }) + + describe('getEnvironmentByName', () => { + it('should look the environment up by its project unique name', async () => { + prisma.environment.findUnique.mockResolvedValue(null) + + const result = await service.getEnvironmentByName('project1', 'dev') + + expect(prisma.environment.findUnique).toHaveBeenCalledWith({ + where: { projectId_name: { projectId: 'project1', name: 'dev' } }, + }) + expect(result).toBeNull() + }) + }) + + describe('getStageById', () => { + it('should return the stage', async () => { + const stage = makeStage({ id: 'stage1' }) + prisma.stage.findUnique.mockResolvedValue(stage) + + const result = await service.getStageById('stage1') + + expect(prisma.stage.findUnique).toHaveBeenCalledWith({ where: { id: 'stage1' } }) + expect(result).toEqual(stage) + }) + }) + + describe('getProdStageIds', () => { + it('should return the ids of the prod stages', async () => { + prisma.stage.findMany.mockResolvedValue([makeStage({ id: 'stage1', name: 'prod' })]) + + await service.getProdStageIds() + + expect(prisma.stage.findMany).toHaveBeenCalledWith({ + select: { id: true }, + where: { name: 'prod' }, + }) + }) + }) + + describe('getAvailableCluster', () => { + it('should only match public clusters or dedicated clusters of the project', async () => { + const cluster = makeCluster({ id: 'cluster1' }) + prisma.cluster.findFirst.mockResolvedValue(cluster) + + const result = await service.getAvailableCluster('cluster1', 'project1') + + expect(prisma.cluster.findFirst).toHaveBeenCalledWith({ + where: { + OR: [{ + id: 'cluster1', + privacy: 'public', + }, { + id: 'cluster1', + privacy: 'dedicated', + projects: { some: { id: 'project1' } }, + }], + }, + }) + expect(result).toEqual(cluster) + }) + }) + + describe('sumEnvironmentResources', () => { + it('should aggregate cpu, gpu and memory', async () => { + await service.sumEnvironmentResources({ clusterId: 'cluster1' }) + + expect(prisma.environment.aggregate).toHaveBeenCalledWith({ + _sum: { + cpu: true, + gpu: true, + memory: true, + }, + where: { clusterId: 'cluster1' }, + }) + }) + }) + + describe('createEnvironment', () => { + it('should create an environment', async () => { + const environment = makeEnvironment() + prisma.environment.create.mockResolvedValue(environment) + + const result = await service.createEnvironment({ + projectId: environment.projectId, + name: environment.name, + clusterId: environment.clusterId, + stageId: environment.stageId, + cpu: environment.cpu, + gpu: environment.gpu, + memory: environment.memory, + autosync: environment.autosync, + }) + + expect(prisma.environment.create).toHaveBeenCalled() + expect(result).toEqual(environment) + }) + }) + + describe('updateEnvironment', () => { + it('should update the environment resources', async () => { + const environment = makeEnvironment({ id: 'env1' }) + prisma.environment.update.mockResolvedValue(environment) + + const result = await service.updateEnvironment('env1', { cpu: 2, gpu: 0, memory: 4, autosync: false }) + + expect(prisma.environment.update).toHaveBeenCalledWith({ + where: { id: 'env1' }, + data: { cpu: 2, gpu: 0, memory: 4, autosync: false }, + }) + expect(result).toEqual(environment) + }) + }) + + describe('deleteEnvironment', () => { + it('should delete the environment', async () => { + const environment = makeEnvironment({ id: 'env1' }) + prisma.environment.delete.mockResolvedValue(environment) + + const result = await service.deleteEnvironment('env1') + + expect(prisma.environment.delete).toHaveBeenCalledWith({ where: { id: 'env1' } }) + expect(result).toEqual(environment) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts new file mode 100644 index 0000000000..1dab19eb23 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts @@ -0,0 +1,99 @@ +import type { Cluster, Environment, Prisma, Project, Stage } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PROD_STAGE_NAME } from './environment.constants' + +export type EnvironmentWithCluster = Environment & { cluster: Cluster } +export type EnvironmentWithStage = Environment & { stage: Stage } + +export interface EnvironmentResourcesSum { + _sum: { + cpu: number | null + gpu: number | null + memory: number | null + } +} + +@Injectable() +export class EnvironmentDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + getProjectEnvironment(projectId: string, environmentId: string): Promise { + return this.prisma.environment.findFirst({ + where: { id: environmentId, projectId }, + include: { cluster: true }, + }) + } + + getEnvironmentsByProjectId(projectId: string): Promise { + return this.prisma.environment.findMany({ + where: { projectId }, + include: { stage: true }, + }) + } + + getEnvironmentByName(projectId: string, name: string): Promise { + return this.prisma.environment.findUnique({ + where: { projectId_name: { projectId, name } }, + }) + } + + getStageById(stageId: string): Promise { + return this.prisma.stage.findUnique({ where: { id: stageId } }) + } + + getProdStageIds(): Promise<{ id: string }[]> { + return this.prisma.stage.findMany({ + select: { id: true }, + where: { name: PROD_STAGE_NAME }, + }) + } + + /** Only public clusters or dedicated clusters attached to the project are usable. */ + getAvailableCluster(clusterId: string, projectId: string): Promise { + return this.prisma.cluster.findFirst({ + where: { + OR: [{ + id: clusterId, + privacy: 'public', + }, { + id: clusterId, + privacy: 'dedicated', + projects: { some: { id: projectId } }, + }], + }, + }) + } + + getProjectById(projectId: string): Promise { + return this.prisma.project.findUniqueOrThrow({ where: { id: projectId } }) + } + + sumEnvironmentResources(where: Prisma.EnvironmentWhereInput): Promise { + return this.prisma.environment.aggregate({ + _sum: { + cpu: true, + gpu: true, + memory: true, + }, + where, + }) + } + + createEnvironment(data: Prisma.EnvironmentUncheckedCreateInput): Promise { + return this.prisma.environment.create({ data }) + } + + updateEnvironment(environmentId: string, data: Pick): Promise { + return this.prisma.environment.update({ + where: { id: environmentId }, + data, + }) + } + + deleteEnvironment(environmentId: string): Promise { + return this.prisma.environment.delete({ + where: { id: environmentId }, + }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts new file mode 100644 index 0000000000..5f0d0ee58d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts @@ -0,0 +1,87 @@ +import type { Cluster, Environment, Project, Stage } from '@prisma/client' +import type { EnvironmentWithCluster, EnvironmentWithStage } from './environment-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + projectId: faker.string.uuid(), + memory: 2, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export function makeCluster(overrides: Partial = {}): Cluster { + return { + id: faker.string.uuid(), + label: faker.string.alphanumeric(8).toLowerCase(), + privacy: 'public', + secretName: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + infos: null, + memory: 0, + cpu: 0, + gpu: 0, + zoneId: faker.string.uuid(), + ...overrides, + } +} + +export function makeStage(overrides: Partial = {}): Stage { + return { + id: faker.string.uuid(), + name: 'dev', + ...overrides, + } +} + +export function makeProject(overrides: Partial = {}): Project { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + description: '', + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.string.alphanumeric(8).toLowerCase(), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } +} + +export function makeEnvironmentWithCluster(overrides: Partial = {}): EnvironmentWithCluster { + const base = makeEnvironment(overrides) + return { + ...base, + cluster: overrides.cluster ?? makeCluster({ id: base.clusterId }), + } +} + +export function makeEnvironmentWithStage(overrides: Partial = {}): EnvironmentWithStage { + const base = makeEnvironment(overrides) + return { + ...base, + stage: overrides.stage ?? makeStage({ id: base.stageId }), + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts new file mode 100644 index 0000000000..1933528eb8 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts @@ -0,0 +1,222 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { BadRequestException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeCluster, makeEnvironment, makeEnvironmentWithCluster, makeProject, makeStage } from './environment-testing.utils' +import { EnvironmentValidationService } from './environment-validation.service' + +describe('environmentValidationService', () => { + let module: TestingModule + let service: EnvironmentValidationService + let datastore: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const environmentId = '22222222-2222-2222-2222-222222222222' + const clusterId = '33333333-3333-3333-3333-333333333333' + const stageId = '44444444-4444-4444-4444-444444444444' + + const validCreateEnvironment = { + name: 'dev', + clusterId, + stageId, + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + datastore = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentValidationService, + { provide: EnvironmentDatastoreService, useValue: datastore }, + ], + }).compile() + + service = module.get(EnvironmentValidationService) + }) + + describe('validateCreate', () => { + it('should pass when the stage, name and cluster are valid and no limit applies', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + // cpu/memory at 0 means the cluster resources are not configured + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)).resolves.toBeUndefined() + }) + + it('should reject when the environment name is already taken', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(makeEnvironment({ projectId, name: validCreateEnvironment.name })) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow(BadRequestException) + }) + + it('should reject when the stage does not exist', async () => { + datastore.getStageById.mockResolvedValue(null) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Type d\'environnement invalide.') + }) + + it('should reject when the cluster is not available for the project', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(null) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Cluster invalide.') + }) + + it('should reject when the cluster does not have enough resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 4, gpu: 0, memory: 8 })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le cluster ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ clusterId }) + }) + + it('should reject when the project does not have enough prod resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'prod' })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + prodCpu: 2, + prodGpu: 0, + prodMemory: 4, + })) + datastore.getProdStageIds.mockResolvedValue([{ id: stageId }]) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 1, gpu: 0, memory: 2 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le projet ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + + it('should reject when the project does not have enough non-prod resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'dev' })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + hprodCpu: 2, + hprodGpu: 0, + hprodMemory: 4, + })) + datastore.getProdStageIds.mockResolvedValue([{ id: '55555555-5555-5555-5555-555555555555' }]) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 1, gpu: 0, memory: 2 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le projet ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + }) + + describe('validateUpdate', () => { + it('should pass when the project is limitless and the cluster limits are not configured', async () => { + const environment = makeEnvironmentWithCluster({ + projectId, + cluster: makeCluster({ id: clusterId, cpu: 0, memory: 0 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + }) + + it('should reject when the cluster does not have enough resources', async () => { + const environment = makeEnvironmentWithCluster({ + projectId, + cluster: makeCluster({ id: clusterId, cpu: 4, gpu: 0, memory: 8 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)) + .rejects.toThrow('Le cluster ne dispose pas de suffisamment de ressources : CPU, GPU, Mémoire.') + }) + + it('should not count the environment being updated in the cluster consumed resources', async () => { + const environment = makeEnvironmentWithCluster({ + id: environmentId, + projectId, + cluster: makeCluster({ id: clusterId, cpu: 8, gpu: 2, memory: 16 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + // the sum only covers the other environments of the cluster + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ + clusterId, + id: { not: environmentId }, + }) + }) + + it('should not count the environment being updated in the project consumed resources', async () => { + const environment = makeEnvironmentWithCluster({ + id: environmentId, + projectId, + stageId, + cluster: makeCluster({ id: clusterId, cpu: 0, memory: 0 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + prodCpu: 8, + prodGpu: 2, + prodMemory: 16, + })) + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'prod' })) + datastore.getProdStageIds.mockResolvedValue([{ id: stageId }]) + // the sum only covers the other prod environments of the project + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ + projectId, + stageId: { in: [stageId] }, + id: { not: environmentId }, + }) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-validation.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-validation.service.ts new file mode 100644 index 0000000000..f81672410a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment-validation.service.ts @@ -0,0 +1,152 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Cluster, Prisma, Project } from '@prisma/client' +import type { EnvironmentWithCluster } from './environment-datastore.service' +import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { PROD_STAGE_NAME } from './environment.constants' + +interface EnvironmentResources { + cpu: number + gpu: number + memory: number +} + +/** cpu and memory both at 0 mean the limits were never configured, so quota checks do not apply. */ +function areResourceLimitsConfigured(limit: EnvironmentResources): boolean { + return limit.cpu !== 0 || limit.memory !== 0 +} + +/** + * Business rules for environments: name unicity, stage and cluster validity, + * cluster and project resource quotas. Existence/ownership (404) concerns stay + * in EnvironmentService; this service only decides whether a request is allowed (400). + */ +@Injectable() +export class EnvironmentValidationService { + constructor( + @Inject(EnvironmentDatastoreService) private readonly environmentDatastoreService: EnvironmentDatastoreService, + ) {} + + async validateCreate(projectId: string, input: CreateEnvironment): Promise { + const errorMessages: string[] = [] + const [stage, sameNameEnvironment, cluster] = await Promise.all([ + this.environmentDatastoreService.getStageById(input.stageId), + this.environmentDatastoreService.getEnvironmentByName(projectId, input.name), + this.environmentDatastoreService.getAvailableCluster(input.clusterId, projectId), + ]) + + if (sameNameEnvironment) errorMessages.push('Ce nom d\'environnement est déjà pris.') + if (!stage) errorMessages.push('Type d\'environnement invalide.') + if (cluster) { + const clusterError = await this.checkClusterResources(input, cluster) + if (clusterError) errorMessages.push(clusterError) + + const project = await this.environmentDatastoreService.getProjectById(projectId) + const projectError = await this.checkProjectResources(input, input.stageId, project) + if (projectError) errorMessages.push(projectError) + } else { + errorMessages.push('Cluster invalide.') + } + + if (errorMessages.length > 0) { + throw new BadRequestException(errorMessages.join('\n')) + } + } + + async validateUpdate(environment: EnvironmentWithCluster, input: UpdateEnvironment): Promise { + const errorMessages: string[] = [] + const clusterError = await this.checkClusterResources(input, environment.cluster, environment.id) + if (clusterError) errorMessages.push(clusterError) + + const project = await this.environmentDatastoreService.getProjectById(environment.projectId) + const projectError = await this.checkProjectResources(input, environment.stageId, project, environment.id) + if (projectError) errorMessages.push(projectError) + + if (errorMessages.length > 0) { + throw new BadRequestException(errorMessages.join('\n')) + } + } + + private async checkClusterResources(request: EnvironmentResources, cluster: Cluster, excludedEnvironmentId?: string): Promise { + const overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: cluster.cpu, gpu: cluster.gpu, memory: cluster.memory }, + where: { clusterId: cluster.id }, + excludedEnvironmentId, + }) + if (overflowResources.length > 0) { + return `Le cluster ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.` + } + return undefined + } + + private async checkProjectResources(request: EnvironmentResources, stageId: string, project: Project, excludedEnvironmentId?: string): Promise { + if (project.limitless) { + // No limits + return undefined + } + const [stage, prodStages] = await Promise.all([ + this.environmentDatastoreService.getStageById(stageId), + this.environmentDatastoreService.getProdStageIds(), + ]) + const prodStageIds = prodStages.map(s => s.id) + + let overflowResources: string[] + if (stage?.name === PROD_STAGE_NAME) { + overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: project.prodCpu, gpu: project.prodGpu, memory: project.prodMemory }, + where: { + projectId: project.id, + stageId: { in: prodStageIds }, + }, + excludedEnvironmentId, + }) + } else { // hprod + overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: project.hprodCpu, gpu: project.hprodGpu, memory: project.hprodMemory }, + where: { + projectId: project.id, + stageId: { notIn: prodStageIds }, + }, + excludedEnvironmentId, + }) + } + if (overflowResources.length > 0) { + return `Le projet ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.` + } + return undefined + } + + /** + * On update, the environment being updated must be excluded from the consumed-resources + * sum: its requested resources replace its current ones. Counting both would reject + * legitimate updates within the quota (#1911). + */ + private async getOverflowResources({ request, limit, where, excludedEnvironmentId }: { + request: EnvironmentResources + limit: EnvironmentResources + where: Prisma.EnvironmentWhereInput + excludedEnvironmentId?: string + }): Promise { + if (!areResourceLimitsConfigured(limit)) { + return [] + } + const environmentResources = await this.environmentDatastoreService.sumEnvironmentResources( + excludedEnvironmentId === undefined ? where : { ...where, id: { not: excludedEnvironmentId } }, + ) + // A null sum means no environment matched: nothing is consumed yet. + const insufficientResources: string[] = [] + if ((environmentResources._sum.cpu ?? 0) + request.cpu > limit.cpu) { + insufficientResources.push('CPU') + } + if ((environmentResources._sum.gpu ?? 0) + request.gpu > limit.gpu) { + insufficientResources.push('GPU') + } + if ((environmentResources._sum.memory ?? 0) + request.memory > limit.memory) { + insufficientResources.push('Mémoire') + } + return insufficientResources + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.constants.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.constants.ts new file mode 100644 index 0000000000..b55c418847 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.constants.ts @@ -0,0 +1,2 @@ +/** Name of the stage whose environments consume the project's production resource quotas. */ +export const PROD_STAGE_NAME = 'prod' diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts new file mode 100644 index 0000000000..1449e91a3a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts @@ -0,0 +1,128 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { makeEnvironment, makeEnvironmentWithStage } from './environment-testing.utils' +import { EnvironmentController } from './environment.controller' +import { EnvironmentService } from './environment.service' + +describe('environmentController', () => { + let module: TestingModule + let controller: EnvironmentController + let service: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + + const project: ProjectContext = { id: projectId, slug: 'my-project' } + const user: UserContext = { userId } + const request = { id: requestId } as FastifyRequest + + const validCreateEnvironment = { + name: 'dev', + clusterId: '22222222-2222-2222-2222-222222222222', + stageId: '33333333-3333-3333-3333-333333333333', + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + service = mockDeep() + + module = await Test.createTestingModule({ + controllers: [EnvironmentController], + providers: [ + { provide: EnvironmentService, useValue: service }, + ], + }) + .overrideGuard(ProjectGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(EnvironmentController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call environmentService.listByProjectId with projectId', async () => { + const expectedResult = [makeEnvironmentWithStage({ projectId })] + + service.listByProjectId.mockResolvedValue(expectedResult) + + const result = await controller.list(project) + + expect(service.listByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('create', () => { + it('should validate body and call environmentService.createEnvironment', async () => { + const expectedResult = makeEnvironment({ projectId }) + + service.createEnvironment.mockResolvedValue(expectedResult) + + const result = await controller.create(validCreateEnvironment, project, user, request) + + expect(service.createEnvironment).toHaveBeenCalledWith( + projectId, + validCreateEnvironment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('update', () => { + it('should validate body and call environmentService.updateEnvironment', async () => { + const environmentId = '44444444-4444-4444-4444-444444444444' + const expectedResult = makeEnvironment({ id: environmentId, projectId }) + + service.updateEnvironment.mockResolvedValue(expectedResult) + + const result = await controller.update(environmentId, validUpdateEnvironment, project, user, request) + + expect(service.updateEnvironment).toHaveBeenCalledWith( + projectId, + environmentId, + validUpdateEnvironment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('delete', () => { + it('should call environmentService.deleteEnvironment with environmentId', async () => { + const environmentId = '44444444-4444-4444-4444-444444444444' + + service.deleteEnvironment.mockResolvedValue(undefined) + + const result = await controller.delete(environmentId, project, user, request) + + expect(service.deleteEnvironment).toHaveBeenCalledWith(projectId, environmentId, userId, requestId) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.controller.ts new file mode 100644 index 0000000000..7589f241f8 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.controller.ts @@ -0,0 +1,73 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Environment } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { EnvironmentWithStage } from './environment-datastore.service' +import { CreateEnvironmentSchema, UpdateEnvironmentSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { EnvironmentService } from './environment.service' + +@Controller('api/v2/projects/:projectId/environments') +@UseGuards(ProjectGuard) +export class EnvironmentController { + constructor(@Inject(EnvironmentService) private readonly environmentService: EnvironmentService) {} + + @Get('') + @RequireAdminPermission('ListProjects') + @RequireProjectPermission('ListEnvironments') + list(@Project() project: ProjectContext): Promise { + return this.environmentService.listByProjectId(project.id) + } + + @Post('') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.CREATED) + create( + @Body(new ZodValidationPipe(CreateEnvironmentSchema)) data: CreateEnvironment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.createEnvironment(project.id, data, user.userId, request.id) + } + + @Put(':environmentId') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.OK) + update( + @Param('environmentId', ParseUUIDPipe) environmentId: string, + @Body(new ZodValidationPipe(UpdateEnvironmentSchema)) data: UpdateEnvironment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.updateEnvironment(project.id, environmentId, data, user.userId, request.id) + } + + @Delete(':environmentId') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.NO_CONTENT) + delete( + @Param('environmentId', ParseUUIDPipe) environmentId: string, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.deleteEnvironment(project.id, environmentId, user.userId, request.id) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.module.ts new file mode 100644 index 0000000000..a9c4f597d4 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { EnvironmentValidationService } from './environment-validation.service' +import { EnvironmentController } from './environment.controller' +import { EnvironmentService } from './environment.service' + +@Module({ + imports: [AppEventsModule, InfrastructureModule], + controllers: [EnvironmentController], + providers: [ + EnvironmentDatastoreService, + EnvironmentValidationService, + EnvironmentService, + ], +}) +export class EnvironmentModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.service.spec.ts new file mode 100644 index 0000000000..d9baf3db40 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.service.spec.ts @@ -0,0 +1,183 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeEnvironment, makeEnvironmentWithCluster, makeEnvironmentWithStage } from './environment-testing.utils' +import { EnvironmentValidationService } from './environment-validation.service' +import { EnvironmentService } from './environment.service' + +describe('environmentService', () => { + let module: TestingModule + let service: EnvironmentService + let datastore: DeepMockProxy + let validation: DeepMockProxy + let appEvents: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + const environmentId = '22222222-2222-2222-2222-222222222222' + const clusterId = '33333333-3333-3333-3333-333333333333' + const stageId = '44444444-4444-4444-4444-444444444444' + + const validCreateEnvironment = { + name: 'dev', + clusterId, + stageId, + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + datastore = mockDeep() + validation = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentService, + { provide: EnvironmentDatastoreService, useValue: datastore }, + { provide: EnvironmentValidationService, useValue: validation }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(EnvironmentService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('listByProjectId', () => { + it('should return environments by projectId', async () => { + const environments = [makeEnvironmentWithStage({ id: environmentId, projectId })] + datastore.getEnvironmentsByProjectId.mockResolvedValue(environments) + + const result = await service.listByProjectId(projectId) + + expect(datastore.getEnvironmentsByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(environments) + }) + }) + + describe('createEnvironment', () => { + it('should validate, create the environment and trigger the project reconciliation', async () => { + const environment = makeEnvironment({ id: environmentId, projectId, clusterId, stageId }) + validation.validateCreate.mockResolvedValue(undefined) + datastore.createEnvironment.mockResolvedValue(environment) + appEvents.emitProjectEvent.mockResolvedValue({}) + + const result = await service.createEnvironment(projectId, validCreateEnvironment, userId, requestId) + + expect(validation.validateCreate).toHaveBeenCalledWith(projectId, validCreateEnvironment) + expect(datastore.createEnvironment).toHaveBeenCalledWith({ + projectId, + name: validCreateEnvironment.name, + clusterId, + stageId, + cpu: validCreateEnvironment.cpu, + gpu: validCreateEnvironment.gpu, + memory: validCreateEnvironment.memory, + autosync: validCreateEnvironment.autosync, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Create Environment', + userId, + requestId, + }) + expect(result).toEqual(environment) + }) + + it('should not create the environment when the validation fails', async () => { + validation.validateCreate.mockRejectedValue(new BadRequestException('Cluster invalide.')) + + await expect(service.createEnvironment(projectId, validCreateEnvironment, userId, requestId)) + .rejects.toThrow(BadRequestException) + expect(datastore.createEnvironment).not.toHaveBeenCalled() + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) + }) + + describe('updateEnvironment', () => { + it('should validate, update the environment and trigger the project reconciliation', async () => { + const existing = makeEnvironmentWithCluster({ id: environmentId, projectId }) + const updated = makeEnvironment({ id: environmentId, projectId, ...validUpdateEnvironment }) + datastore.getProjectEnvironment.mockResolvedValue(existing) + validation.validateUpdate.mockResolvedValue(undefined) + datastore.updateEnvironment.mockResolvedValue(updated) + appEvents.emitProjectEvent.mockResolvedValue({}) + + const result = await service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId) + + expect(validation.validateUpdate).toHaveBeenCalledWith(existing, validUpdateEnvironment) + expect(datastore.updateEnvironment).toHaveBeenCalledWith(environmentId, validUpdateEnvironment) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Update Environment', + userId, + requestId, + }) + expect(result).toEqual(updated) + }) + + it('should reject when the environment is not found in the project', async () => { + datastore.getProjectEnvironment.mockResolvedValue(null) + + await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) + .rejects.toThrow(NotFoundException) + expect(datastore.getProjectEnvironment).toHaveBeenCalledWith(projectId, environmentId) + expect(validation.validateUpdate).not.toHaveBeenCalled() + expect(datastore.updateEnvironment).not.toHaveBeenCalled() + }) + + it('should not update the environment when the validation fails', async () => { + datastore.getProjectEnvironment.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId, projectId })) + validation.validateUpdate.mockRejectedValue(new BadRequestException('Le cluster ne dispose pas de suffisamment de ressources : CPU.')) + + await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) + .rejects.toThrow(BadRequestException) + expect(datastore.updateEnvironment).not.toHaveBeenCalled() + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) + }) + + describe('deleteEnvironment', () => { + it('should delete the environment and trigger the project reconciliation', async () => { + datastore.getProjectEnvironment.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId, projectId })) + datastore.deleteEnvironment.mockResolvedValue(makeEnvironment({ id: environmentId, projectId })) + appEvents.emitProjectEvent.mockResolvedValue({}) + + await service.deleteEnvironment(projectId, environmentId, userId, requestId) + + expect(datastore.deleteEnvironment).toHaveBeenCalledWith(environmentId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Delete Environment', + userId, + requestId, + }) + }) + + it('should reject when the environment is not found in the project', async () => { + datastore.getProjectEnvironment.mockResolvedValue(null) + + await expect(service.deleteEnvironment(projectId, environmentId, userId, requestId)) + .rejects.toThrow(NotFoundException) + expect(datastore.getProjectEnvironment).toHaveBeenCalledWith(projectId, environmentId) + expect(datastore.deleteEnvironment).not.toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.service.ts new file mode 100644 index 0000000000..d181c84966 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/environment/environment.service.ts @@ -0,0 +1,81 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Environment } from '@prisma/client' +import type { EventLogAction } from '../events/app-events.service' +import type { EnvironmentWithCluster, EnvironmentWithStage } from './environment-datastore.service' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { EnvironmentValidationService } from './environment-validation.service' + +@Injectable() +export class EnvironmentService { + private readonly logger = new Logger(EnvironmentService.name) + + constructor( + @Inject(EnvironmentDatastoreService) private readonly environmentDatastoreService: EnvironmentDatastoreService, + @Inject(EnvironmentValidationService) private readonly environmentValidationService: EnvironmentValidationService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async listByProjectId(projectId: string): Promise { + return this.environmentDatastoreService.getEnvironmentsByProjectId(projectId) + } + + async createEnvironment(projectId: string, environmentToCreate: CreateEnvironment, userId: string, requestId: string): Promise { + await this.environmentValidationService.validateCreate(projectId, environmentToCreate) + + const environment = await this.environmentDatastoreService.createEnvironment({ + projectId, + name: environmentToCreate.name, + clusterId: environmentToCreate.clusterId, + stageId: environmentToCreate.stageId, + cpu: environmentToCreate.cpu, + gpu: environmentToCreate.gpu, + memory: environmentToCreate.memory, + autosync: environmentToCreate.autosync, + }) + + this.reconcileProject(projectId, 'Create Environment', userId, requestId) + return environment + } + + async updateEnvironment(projectId: string, environmentId: string, environmentToUpdate: UpdateEnvironment, userId: string, requestId: string): Promise { + const existingEnvironment = await this.getProjectEnvironmentOrThrow(projectId, environmentId) + await this.environmentValidationService.validateUpdate(existingEnvironment, environmentToUpdate) + + const environment = await this.environmentDatastoreService.updateEnvironment(environmentId, { + cpu: environmentToUpdate.cpu, + gpu: environmentToUpdate.gpu, + memory: environmentToUpdate.memory, + autosync: environmentToUpdate.autosync, + }) + + this.reconcileProject(projectId, 'Update Environment', userId, requestId) + return environment + } + + async deleteEnvironment(projectId: string, environmentId: string, userId: string, requestId: string): Promise { + await this.getProjectEnvironmentOrThrow(projectId, environmentId) + await this.environmentDatastoreService.deleteEnvironment(environmentId) + this.reconcileProject(projectId, 'Delete Environment', userId, requestId) + } + + private async getProjectEnvironmentOrThrow(projectId: string, environmentId: string): Promise { + const environment = await this.environmentDatastoreService.getProjectEnvironment(projectId, environmentId) + if (!environment) { + throw new NotFoundException('Environnement introuvable') + } + return environment + } + + /** + * Triggers the project reconciliation without blocking the response: listener + * outcomes (including failures) are persisted in the admin log by AppEventsService. + */ + private reconcileProject(projectId: string, action: EventLogAction, userId: string, requestId: string): void { + this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) + .catch((error: unknown) => { + this.logger.error(`project.upsert reconciliation failed (projectId=${projectId})`, error instanceof Error ? error.stack : String(error)) + }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.module.ts new file mode 100644 index 0000000000..08130cfc7b --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { LogModule } from '../log/log.module' +import { AppEventsService } from './app-events.service' + +@Module({ + imports: [InfrastructureModule, LogModule], + providers: [AppEventsService], + exports: [AppEventsService], +}) +export class AppEventsModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.service.spec.ts new file mode 100644 index 0000000000..7bd6fa2ea2 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.service.spec.ts @@ -0,0 +1,178 @@ +import type { EventEmitter2 as EventEmitter2Type } from '@nestjs/event-emitter' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { projectSelect } from '../project/project-queries.utils' +import { makeProject } from '../project/project-testing.utils' +import { AppEventsService } from './app-events.service' + +describe('appEventsService', () => { + let module: TestingModule + let service: AppEventsService + let prisma: DeepMockProxy + let eventEmitter: DeepMockProxy + let logs: DeepMockProxy + let config: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + eventEmitter = mockDeep({ emitAsync: vi.fn().mockResolvedValue([]) }) + logs = mockDeep() + config = mockDeep({ appVersion: 'test-version' }) + + module = await Test.createTestingModule({ + providers: [ + AppEventsService, + { provide: PrismaService, useValue: prisma }, + { provide: EventEmitter2, useValue: eventEmitter }, + { provide: LogService, useValue: logs }, + { provide: BASE_CONFIG, useValue: config }, + ], + }).compile() + + service = module.get(AppEventsService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('loads the project by id, emits and logs the merged listener results', async () => { + const project = makeProject() + prisma.project.findUnique.mockResolvedValue(project) + eventEmitter.emitAsync.mockResolvedValue([ + undefined, // listener not yet migrated to capturePluginResult + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + { argocd: { status: 'KO', message: 'Sync failed', executionTime: 20, error: new Error('Sync failed') } }, + ]) + + const results = await service.emitProjectEvent('project.upsert', project.id, { + action: 'Create Project', + userId: 'user-id', + requestId: 'request-id', + }) + + expect(prisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: project.id }, + select: projectSelect, + }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', project) + expect(results).toEqual({ + gitlab: expect.objectContaining({ status: 'OK' }), + argocd: expect.objectContaining({ status: 'KO' }), + }) + expect(logs.addLog).toHaveBeenCalledWith({ + action: 'Create Project', + userId: 'user-id', + requestId: 'request-id', + projectId: project.id, + data: { + args: project, + failed: ['argocd'], + results: { + gitlab: { + status: { result: 'OK', message: 'Up to date' }, + executionTime: { main: 10 }, + }, + argocd: { + status: { result: 'KO', message: 'Sync failed' }, + executionTime: { main: 20 }, + error: expect.stringContaining('"message":"Sync failed"'), + }, + }, + totalExecutionTime: expect.any(Number), + messageResume: 'Errors:\nargocd: Sync failed;', + }, + }) + }) + + it('marks the project as failed when a listener reports a KO result', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { argocd: { status: 'KO', message: 'Sync failed', executionTime: 20, error: new Error('Sync failed') } }, + ]) + + await service.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: project.id }, + data: { status: 'failed' }, + }) + }) + + it('marks the project as created with the provisioning version after a successful upsert', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + ]) + + await service.emitProjectEvent('project.upsert', project, { action: 'Create Project' }) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: project.id }, + data: { status: 'created', lastSuccessProvisionningVersion: 'test-version' }, + }) + }) + + it('does not touch the project status after a successful delete', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + ]) + + await service.emitProjectEvent('project.delete', project, { action: 'Delete all project resources' }) + + expect(prisma.project.update).not.toHaveBeenCalled() + }) + + it('uses a provided project snapshot without fetching it again', async () => { + const project = makeProject() + + await service.emitProjectEvent('project.delete', project, { action: 'Delete all project resources' }) + + expect(prisma.project.findUnique).not.toHaveBeenCalled() + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.delete', project) + }) + + it('defaults the log userId and requestId to null', async () => { + const project = makeProject() + + await service.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Update Project', + userId: null, + requestId: null, + })) + }) + + it('skips emitting and logging when the project cannot be found', async () => { + prisma.project.findUnique.mockResolvedValue(null) + + const results = await service.emitProjectEvent('project.upsert', 'missing-id', { action: 'Update Project' }) + + expect(results).toEqual({}) + expect(eventEmitter.emitAsync).not.toHaveBeenCalled() + expect(logs.addLog).not.toHaveBeenCalled() + }) + + it('emits and logs project member events with their payload as args', async () => { + const payload = { projectId: 'project-id', userId: 'user-id' } + + await service.emitProjectMemberEvent('projectMember.upsert', payload, { action: 'Add Project Member' }) + + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.upsert', payload) + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Add Project Member', + projectId: 'project-id', + data: expect.objectContaining({ args: payload }), + })) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.service.ts new file mode 100644 index 0000000000..7de8bcf562 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -0,0 +1,138 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { PluginResults } from '../plugin/plugin.utils' +import type { ProjectWithDetails } from '../project/project-queries.utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { getFailedPlugins, mergePluginResults } from '../plugin/plugin.utils' +import { getProject } from '../project/project-queries.utils' +import { formatEventLogData, isPluginResults } from './app-events.utils' + +export type ProjectEventName = 'project.upsert' | 'project.delete' +export type ProjectMemberEventName = 'projectMember.upsert' | 'projectMember.delete' + +export interface ProjectMemberEventPayload { + projectId: string + userId: string +} + +/** Admin-log action labels (legacy hooks wording). */ +export type EventLogAction + = | 'Create Project' | 'Update Project' | 'Delete all project resources' + | 'Replay hooks for Project' | 'Upsert Project Role' + | 'Create Deployment' | 'Update Deployment' | 'Delete Deployment' + | 'Delete all project deployments' + | 'Create Environment' | 'Update Environment' | 'Delete Environment' + | 'Add Project Member' | 'Update Project Member' | 'Remove Project Member' + +export interface EventContext { + /** Action label persisted in the admin log. */ + action: EventLogAction + userId?: string | null + requestId?: string | null +} + +/** + * Single entry point to emit domain events: emits through EventEmitter2, merges the + * `PluginResults` returned by every `capturePluginResult` listener and persists one admin + * log per event. Emitters only provide an id and the log context. + */ +@Injectable() +export class AppEventsService { + private readonly logger = new Logger(AppEventsService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, + @Inject(LogService) private readonly logs: LogService, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + /** + * Emits a project event and logs the merged listener results. + * + * Pass a project id in the general case: the project is loaded here, after the + * emitting transaction commits, so every listener sees the same committed state. + * Pass an already-loaded snapshot when the row no longer reflects what listeners + * must act on (e.g. archiving renames the slug before emitting `project.delete`). + */ + async emitProjectEvent( + event: ProjectEventName, + projectOrId: string | ProjectWithDetails, + context: EventContext, + ): Promise { + const project = typeof projectOrId === 'string' + ? await getProject(this.prisma, projectOrId) + : projectOrId + + if (!project) { + this.logger.warn(`${event} skipped: project not found (projectId=${String(projectOrId)})`) + return {} + } + + const results = await this.emitAndLog(event, project, project.id, context) + await this.updateProjectStatus(event, project.id, results) + return results + } + + async emitProjectMemberEvent( + event: ProjectMemberEventName, + payload: ProjectMemberEventPayload, + context: EventContext, + ): Promise { + return this.emitAndLog(event, payload, payload.projectId, context) + } + + private async emitAndLog( + event: string, + payload: unknown, + projectId: string, + context: EventContext, + ): Promise { + const start = process.hrtime.bigint() + const responses = await this.eventEmitter.emitAsync(event, payload) + const totalExecutionTime = Number(process.hrtime.bigint() - start) / 1_000_000 + + const results = mergePluginResults(responses.filter(isPluginResults)) + this.logger.log(`${event} completed (projectId=${projectId}, services=${Object.keys(results).join(',') || 'none'})`) + + await this.logs.addLog({ + action: context.action, + data: formatEventLogData(payload, results, totalExecutionTime), + userId: context.userId ?? null, + requestId: context.requestId ?? null, + projectId, + }) + + return results + } + + /** + * Reflects the listeners' outcome on the project row (legacy hooks behavior): + * any KO result marks the project `failed`; a fully successful upsert marks it + * `created` and records the provisioning version. A successful `project.delete` + * leaves the `archived` status set when the project was archived. + */ + private async updateProjectStatus( + event: ProjectEventName, + projectId: string, + results: PluginResults, + ): Promise { + const failed = getFailedPlugins(results) + + if (failed.length) { + this.logger.warn(`${event} marked project as failed (projectId=${projectId}, failed=${failed.join(',')})`) + await this.prisma.project.update({ where: { id: projectId }, data: { status: 'failed' } }) + return + } + + if (event === 'project.upsert') { + await this.prisma.project.update({ + where: { id: projectId }, + data: { status: 'created', lastSuccessProvisionningVersion: this.baseConfig.appVersion }, + }) + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts new file mode 100644 index 0000000000..3c1ba22a7a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts @@ -0,0 +1,96 @@ +import type { PluginResults } from '../plugin/plugin.utils' +import { NetworkError } from '@keycloak/keycloak-admin-client' +import { describe, expect, it } from 'vitest' +import { formatEventLogData, isPluginResults, serializeError } from './app-events.utils' + +describe('isPluginResults', () => { + it('should accept an object produced by a capturePluginResult handler', () => { + expect(isPluginResults({ gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } })).toBe(true) + expect(isPluginResults({ argocd: { status: 'KO', message: 'boom', executionTime: 10, error: new Error('boom') } })).toBe(true) + }) + + it('should reject non-result responses', () => { + expect(isPluginResults(undefined)).toBe(false) + expect(isPluginResults(null)).toBe(false) + expect(isPluginResults('ok')).toBe(false) + expect(isPluginResults({})).toBe(false) + expect(isPluginResults({ gitlab: { foo: 'bar' } })).toBe(false) + expect(isPluginResults({ gitlab: { status: 'MAYBE' } })).toBe(false) + }) +}) + +describe('serializeError', () => { + it('should serialize an Error with name, message and stack', () => { + const parsed = JSON.parse(serializeError(new Error('boom'))) + expect(parsed).toEqual({ name: 'Error', message: 'boom', stack: expect.any(String) }) + }) + + it('should include HTTP details from errors carrying a fetch Response', () => { + const error = new NetworkError('Network response was not OK.', { + response: new Response('conflict', { status: 409 }), + responseData: { errorMessage: 'Sibling group named \'admin\' already exists.' }, + }) + const parsed = JSON.parse(serializeError(error)) + expect(parsed).toEqual({ + name: 'Error', + message: 'Network response was not OK.', + status: 409, + url: expect.any(String), + responseData: { errorMessage: 'Sibling group named \'admin\' already exists.' }, + stack: expect.any(String), + }) + }) + + it('should serialize non-Error values', () => { + expect(serializeError({ code: 42 })).toBe('{"code":42}') + expect(serializeError('boom')).toBe('"boom"') + }) +}) + +describe('formatEventLogData', () => { + const args = { id: 'project-1', slug: 'project-1' } + + it('should format results in the LogSchema shape', () => { + const results: PluginResults = { + gitlab: { status: 'OK', message: 'Up to date', executionTime: 12.4 }, + argocd: { status: 'KO', message: 'Sync failed', executionTime: 100.9, error: new Error('Sync failed') }, + } + + expect(formatEventLogData(args, results, 120.6)).toEqual({ + args, + failed: ['argocd'], + results: { + gitlab: { + status: { result: 'OK', message: 'Up to date' }, + executionTime: { main: 12 }, + }, + argocd: { + status: { result: 'KO', message: 'Sync failed' }, + executionTime: { main: 101 }, + error: expect.stringContaining('"message":"Sync failed"'), + }, + }, + totalExecutionTime: 121, + messageResume: 'Errors:\nargocd: Sync failed;', + }) + }) + + it('should omit the failed list and produce a success resume when everything is OK', () => { + const results: PluginResults = { + gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 }, + } + + const data = formatEventLogData(args, results, 10) + expect(data).not.toHaveProperty('failed') + expect(data.messageResume).toBe('Success') + }) + + it('should handle events without any listener', () => { + expect(formatEventLogData(args, {}, 1)).toEqual({ + args, + results: {}, + totalExecutionTime: 1, + messageResume: 'Success', + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.utils.ts new file mode 100644 index 0000000000..a9cf62706b --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/events/app-events.utils.ts @@ -0,0 +1,77 @@ +import type { LogData } from '../log/log.service' +import type { PluginName, PluginResult, PluginResults } from '../plugin/plugin.utils' +import { getErrorHttpDetails } from '../../utils/http.utils' +import { getFailedPlugins } from '../plugin/plugin.utils' + +/** Per-service result as persisted in the admin logs (legacy hooks format, parsed by LogSchema). */ +export interface LoggablePluginResult { + status: { + result: 'OK' | 'KO' + message?: string + } + executionTime: { + main: number + } + error?: string +} + +function isPluginResult(value: unknown): value is PluginResult { + return typeof value === 'object' + && value !== null + && 'status' in value + && ((value as PluginResult).status === 'OK' || (value as PluginResult).status === 'KO') +} + +/** Narrows an `emitAsync` response to a `PluginResults` object produced by `capturePluginResult` handlers. */ +export function isPluginResults(response: unknown): response is PluginResults { + if (typeof response !== 'object' || response === null) return false + const values = Object.values(response) + return values.length > 0 && values.every(isPluginResult) +} + +export function serializeError(error: unknown): string { + if (error instanceof Error) { + return JSON.stringify({ + name: error.name, + message: error.message, + ...getErrorHttpDetails(error), + stack: error.stack, + }) + } + try { + return JSON.stringify(error) + } catch { + return String(error) + } +} + +function toLoggableResult(result: PluginResult): LoggablePluginResult { + return { + status: { + result: result.status, + ...(result.message ? { message: result.message } : {}), + }, + executionTime: { main: Math.round(result.executionTime) }, + ...(result.status === 'KO' ? { error: serializeError(result.error) } : {}), + } +} + +function buildMessageResume(results: PluginResults, failed: PluginName[]): string { + if (!failed.length) return 'Success' + const errorLines = failed.map(service => `${service}: ${results[service]?.message};`).join('\n') + return `Errors:\n${errorLines}` +} + +/** Builds the log `data` payload for an event: the emitted args plus every listener's result. */ +export function formatEventLogData(args: unknown, results: PluginResults, totalExecutionTime: number): LogData { + const failed = getFailedPlugins(results) + const entries = Object.entries(results) as [PluginName, PluginResult][] + + return { + args, + ...(failed.length ? { failed } : {}), + results: Object.fromEntries(entries.map(([service, result]) => [service, toLoggableResult(result)])), + totalExecutionTime: Math.round(totalExecutionTime), + messageResume: buildMessageResume(results, failed), + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts new file mode 100644 index 0000000000..0db65f4630 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts @@ -0,0 +1,689 @@ +import type { ExpandedGroupSchema, Gitlab as GitlabApi, ProjectSchema } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { MockedFunction } from 'vitest' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' +import { + makeAccessTokenExposedSchema, + makeAccessTokenSchema, + makeExpandedGroupSchema, + makeExpandedUserSchema, + makeGitbeakerRequestError, + makeGroupSchema, + makeMemberSchema, + makeOffsetPagination, + makePipelineTriggerToken, + makeProjectSchema, + makeRepositoryFileExpandedSchema, + makeRepositoryTreeSchema, +} from './gitlab-testing.utils' +import { + GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, + MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, + PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, + USER_ID_CUSTOM_ATTRIBUTE_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +describe('gitlab-client', () => { + let service: GitlabClientService + let gitlabApi: DeepMockProxy + + beforeEach(async () => { + gitlabApi = mockDeep() + const config = mockDeep({ + url: 'https://gitlab.internal', + token: 'token', + internalUrl: 'https://gitlab.internal', + projectRootDir: 'forge', + mirrorTokenExpirationDays: 30, + internalOrPublicUrl: 'https://gitlab.internal', + }) + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GitlabClientService, + { provide: GITLAB_REST_CLIENT, useValue: gitlabApi }, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + service = module.get(GitlabClientService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('getOrCreateInfraProject', () => { + it('should create infra project if not exists', async () => { + const zoneSlug = 'zone-1' + const rootId = 123 + const infraGroupId = 456 + const projectId = 789 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + + gitlabApi.Groups.show.mockResolvedValueOnce({ id: rootId, full_path: 'forge' } as ExpandedGroupSchema) + + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + gitlabApi.Groups.create.mockResolvedValue({ id: infraGroupId, full_path: 'forge/infra' } as ExpandedGroupSchema) + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.create.mockResolvedValue({ + id: projectId, + path_with_namespace: 'forge/infra/zone-1', + http_url_to_repo: 'https://gitlab.internal/infra/zone-1.git', + } as ProjectSchema) + + const result = await service.getOrCreateInfraGroupRepo(zoneSlug) + + expect(result).toEqual({ + id: projectId, + http_url_to_repo: 'https://gitlab.internal/infra/zone-1.git', + path_with_namespace: 'forge/infra/zone-1', + }) + expect(gitlabApi.Groups.create).toHaveBeenCalledWith('infra', 'infra', expect.any(Object)) + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(rootId, GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(infraGroupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(infraGroupId, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ + name: zoneSlug, + path: zoneSlug, + namespaceId: infraGroupId, + })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + }) + + describe('commitCreateOrUpdate', () => { + it('should create commit if file not exists', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'content' + const filePath = 'file.txt' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + const notFoundError = makeGitbeakerRequestError({ description: '404 File Not Found' }) + gitlabRepositoryFilesShowMock.mockRejectedValue(notFoundError) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + message, + [{ action: 'create', filePath, content }], + ) + }) + + it('should update commit if content differs', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'new content' + const filePath = 'file.txt' + const oldHash = 'oldhash' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + gitlabRepositoryFilesShowMock.mockResolvedValue(makeRepositoryFileExpandedSchema({ content_sha256: oldHash })) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + message, + [{ action: 'update', filePath, content }], + ) + }) + + it('should do nothing if content matches', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'content' + const filePath = 'file.txt' + const hash = 'ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + gitlabRepositoryFilesShowMock.mockResolvedValue(makeRepositoryFileExpandedSchema({ content_sha256: hash })) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).not.toHaveBeenCalled() + }) + }) + + describe('getOrCreateProjectGroup', () => { + it('should create project group if not exists', async () => { + const projectSlug = 'project-1' + const rootId = 123 + const groupId = 456 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce(makeExpandedGroupSchema({ id: rootId, full_path: 'forge' })) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.create.mockResolvedValue(makeExpandedGroupSchema({ id: groupId, name: projectSlug, path: projectSlug, full_path: `forge/${projectSlug}` })) + + const result = await service.getOrCreateProjectSubGroup(projectSlug) + + expect(result).toEqual(expect.objectContaining({ id: groupId, name: projectSlug })) + expect(gitlabApi.Groups.create).toHaveBeenCalledWith(projectSlug, projectSlug, expect.objectContaining({ + parentId: rootId, + })) + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(groupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(groupId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + }) + + it('should return existing group', async () => { + const projectSlug = 'project-1' + const rootId = 123 + const groupId = 456 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce(makeExpandedGroupSchema({ id: rootId, full_path: 'forge' })) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: rootId, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectSubGroup(projectSlug) + + expect(result).toEqual({ id: groupId, name: projectSlug, parent_id: rootId, full_path: 'forge/project-1' }) + expect(gitlabApi.Groups.create).not.toHaveBeenCalled() + }) + }) + + describe('repositories', () => { + it('should return internal repo url', async () => { + const projectSlug = 'project-1' + const repoName = 'repo-1' + const rootId = 123 + const groupId = 1 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectGroupInternalRepoUrl(projectSlug, repoName) + expect(result).toBe('https://gitlab.internal/forge/project-1/repo-1.git') + }) + + it('should upsert mirror repo', async () => { + const projectSlug = 'project-1' + const repoId = 1 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValue({ + data: [{ id: repoId, path_with_namespace: 'forge/project-1/mirror' }], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: 'mirror' } as ProjectSchema) + + const result = await service.upsertProjectMirrorRepo(projectSlug) + + expect(result).toEqual({ id: repoId, name: 'mirror' }) + expect(gitlabApi.Projects.edit).toHaveBeenCalledWith(repoId, expect.objectContaining({ + name: 'mirror', + path: 'mirror', + })) + }) + + it('should set managed custom attribute when upserting a project repo', async () => { + const projectSlug = 'project-1' + const repoName = 'repo-1' + const repoId = 101 + + gitlabApi.Projects.show.mockResolvedValue(makeProjectSchema({ id: repoId })) + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: repoName } as ProjectSchema) + + const result = await service.upsertProjectGroupRepo(projectSlug, repoName, 'desc') + + expect(result).toEqual({ id: repoId, name: repoName }) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(repoId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + + describe('upsertUser', () => { + it('should create user and set custom attribute if not exists', async () => { + const consoleUser = { id: 'u1', email: 'new@example.com', firstName: 'New', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'new', + name: 'New User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([]) + gitlabApi.Users.create.mockResolvedValue(makeExpandedUserSchema({ id: 999, email: consoleUser.email })) + + const result = await service.upsertUser(gitlabUser, { cpnUserId: consoleUser.id }) + + expect(result).toEqual(expect.objectContaining({ id: 999, email: consoleUser.email })) + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'new@example.com', + username: 'new', + name: 'New User', + externUid: 'new@example.com', + provider: 'openid_connect', + skipConfirmation: true, + })) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(999, USER_ID_CUSTOM_ATTRIBUTE_KEY, consoleUser.id) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(999, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + + it('should set custom attribute if user exists', async () => { + const consoleUser = { id: 'u1', email: 'existing@example.com', firstName: 'Existing', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'existing', + name: 'Existing User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([makeExpandedUserSchema({ id: 1000, email: consoleUser.email })]) + + const result = await service.upsertUser(gitlabUser, { cpnUserId: consoleUser.id }) + + expect(result).toEqual(expect.objectContaining({ id: 1000, email: consoleUser.email })) + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.objectContaining({ + email: 'existing@example.com', + username: 'existing', + name: 'Existing User', + externUid: 'existing@example.com', + provider: 'openid_connect', + })) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(1000, USER_ID_CUSTOM_ATTRIBUTE_KEY, consoleUser.id) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(1000, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.Users.create).not.toHaveBeenCalled() + }) + + it('should set admin flag when provided', async () => { + const consoleUser = { id: 'u1', email: 'admin@example.com', firstName: 'Admin', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'admin', + name: 'Admin User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([]) + gitlabApi.Users.create.mockResolvedValue(makeExpandedUserSchema({ id: 999, email: consoleUser.email })) + + await service.upsertUser({ ...gitlabUser, admin: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'admin@example.com', + username: 'admin', + name: 'Admin User', + externUid: 'admin@example.com', + provider: 'openid_connect', + admin: true, + skipConfirmation: true, + })) + }) + + it('should not disable existing admin flag when enabling auditor flag', async () => { + const consoleUser = { id: 'u1', email: 'admin-auditor@example.com', firstName: 'Admin', lastName: 'Auditor' } + const gitlabUser = { + email: consoleUser.email, + username: 'admin-auditor', + name: 'Admin Auditor', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([ + makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_admin: true }), + ]) + + await service.upsertUser({ ...gitlabUser, auditor: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.not.objectContaining({ + admin: true, + })) + }) + + it('should not disable existing auditor flag when enabling admin flag', async () => { + const consoleUser = { id: 'u1', email: 'auditor-admin@example.com', firstName: 'Auditor', lastName: 'Admin' } + const gitlabUser = { + email: consoleUser.email, + username: 'auditor-admin', + name: 'Auditor Admin', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([ + makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_auditor: true }), + ]) + + await service.upsertUser({ ...gitlabUser, admin: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.not.objectContaining({ + auditor: true, + })) + }) + }) + + it('should create pipeline trigger token if not exists', async () => { + const projectSlug = 'project-1' + const repoId = 1 + const tokenDescription = 'mirroring-from-external-repo' + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValue({ + data: [{ id: repoId, path_with_namespace: 'forge/project-1/mirror' }], + paginationInfo: { next: null }, + }) + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: 'mirror' } as ProjectSchema) + + const gitlabPipelineTriggerTokensAllMock = gitlabApi.PipelineTriggerTokens.all as MockedFunction + gitlabPipelineTriggerTokensAllMock.mockResolvedValue({ + data: [], + paginationInfo: makeOffsetPagination({ next: null }), + }) + + gitlabApi.PipelineTriggerTokens.create.mockResolvedValue(makePipelineTriggerToken({ id: 2, description: tokenDescription })) + + const result = await service.getOrCreateMirrorPipelineTriggerToken(projectSlug) + + expect(result).toEqual(expect.objectContaining({ id: 2, description: tokenDescription })) + expect(gitlabApi.PipelineTriggerTokens.create).toHaveBeenCalledWith(repoId, tokenDescription) + }) + }) + + describe('group Members', () => { + it('should get group members', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const members = [makeMemberSchema({ id: 1, name: 'user' })] + const gitlabGroupMembersAllMock = gitlabApi.GroupMembers.all as MockedFunction + gitlabGroupMembersAllMock.mockResolvedValue(members) + + const result = await service.getGroupMembers(group) + expect(result).toEqual(members) + expect(gitlabApi.GroupMembers.all).toHaveBeenCalledWith(groupId) + }) + + it('should add group member', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const userId = 2 + const accessLevel = 30 + gitlabApi.GroupMembers.add.mockResolvedValue(makeMemberSchema({ id: userId })) + + await service.addGroupMember(group, userId, accessLevel) + expect(gitlabApi.GroupMembers.add).toHaveBeenCalledWith(groupId, userId, accessLevel) + }) + + it('should remove group member', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const userId = 2 + gitlabApi.GroupMembers.remove.mockResolvedValue(undefined) + + await service.removeGroupMember(group, userId) + expect(gitlabApi.GroupMembers.remove).toHaveBeenCalledWith(groupId, userId) + }) + }) + + describe('createProjectMirrorAccessToken', () => { + it('should create project access token with correct scopes', async () => { + const projectSlug = 'project-1' + const groupId = 456 + const tokenName = `${projectSlug}-bot` + const token = makeAccessTokenExposedSchema({ id: 1, name: tokenName, token: 'secret-token' }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: 123, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce({ id: 123, full_path: 'forge' } as ExpandedGroupSchema) + + const gitlabGroupsAllSubgroupsMock = gitlabApi.Groups.allSubgroups as MockedFunction + gitlabGroupsAllSubgroupsMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: 123, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + gitlabApi.GroupAccessTokens.create.mockResolvedValue(token) + + const result = await service.createMirrorAccessToken(projectSlug) + + expect(result).toEqual(token) + expect(gitlabApi.GroupAccessTokens.create).toHaveBeenCalledWith( + groupId, + tokenName, + ['write_repository', 'read_repository', 'read_api'], + expect.any(String), + ) + }) + }) + + describe('getOrCreateProjectGroupRepo', () => { + it('should return existing repo', async () => { + const subGroupPath = 'project-1' + const repoName = 'repo-1' + const fullPath = `${subGroupPath}/${repoName}` + const projectId = 789 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [{ id: projectId, path_with_namespace: `forge/${fullPath}` }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectGroupRepo(subGroupPath, fullPath) + + expect(result).toEqual(expect.objectContaining({ id: projectId })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, 'project-1') + }) + + it('should create repo if not exists', async () => { + const subGroupPath = 'project-1' + const repoName = 'repo-1' + const fullPath = `${subGroupPath}/${repoName}` + const projectId = 789 + const groupId = 456 + const rootId = 123 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: subGroupPath, parent_id: rootId, full_path: `forge/${subGroupPath}` }], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.create.mockResolvedValue({ id: projectId, name: repoName } as ProjectSchema) + + const result = await service.getOrCreateProjectGroupRepo(subGroupPath, fullPath) + + expect(result).toEqual(expect.objectContaining({ id: projectId })) + expect(gitlabApi.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ + name: repoName, + path: repoName, + namespaceId: groupId, + })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, 'project-1') + }) + }) + + describe('getFile', () => { + it('should return file content', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const file = makeRepositoryFileExpandedSchema({ content: 'content' }) + + gitlabApi.RepositoryFiles.show.mockResolvedValue(file) + + const result = await service.getFile(repo, filePath, ref) + expect(result).toEqual(file) + }) + + it('should return undefined on 404', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const error = makeGitbeakerRequestError({ description: '404 File Not Found' }) + + gitlabApi.RepositoryFiles.show.mockRejectedValue(error) + + const result = await service.getFile(repo, filePath, ref) + expect(result).toBeUndefined() + }) + + it('should throw on other errors', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const error = new Error('Some other error') + + gitlabApi.RepositoryFiles.show.mockRejectedValue(error) + + await expect(service.getFile(repo, filePath, ref)).rejects.toThrow(error) + }) + }) + + describe('listFiles', () => { + it('should return files', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const files = [makeRepositoryTreeSchema({ path: 'file.txt' })] + + gitlabApi.Repositories.allRepositoryTrees.mockResolvedValue(files) + + const result = await service.listFiles(repo) + expect(result).toEqual(files) + }) + + it('should return empty array on 404', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const error = makeGitbeakerRequestError({ description: '404 Tree Not Found' }) + + gitlabApi.Repositories.allRepositoryTrees.mockRejectedValue(error) + + const result = await service.listFiles(repo) + expect(result).toEqual([]) + }) + }) + + describe('getProjectToken', () => { + it('should return specific token', async () => { + const projectSlug = 'project-1' + const groupId = 456 + const tokenName = `${projectSlug}-bot` + const token = makeAccessTokenSchema({ id: 1, name: tokenName }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: 123, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce({ id: 123, full_path: 'forge' } as ExpandedGroupSchema) + const gitlabGroupsAllSubgroupsMock = gitlabApi.Groups.allSubgroups as MockedFunction + gitlabGroupsAllSubgroupsMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: 123, full_path: `forge/${projectSlug}` }], + paginationInfo: { next: null }, + }) + + const gitlabGroupAccessTokensAllMock = gitlabApi.GroupAccessTokens.all as MockedFunction + gitlabGroupAccessTokensAllMock.mockResolvedValue({ + data: [token], + paginationInfo: makeOffsetPagination({ next: null }), + }) + + const result = await service.getProjectToken(projectSlug) + expect(result).toEqual(token) + }) + }) + + describe('createUser', () => { + it('should create user', async () => { + const email = 'user@example.com' + const username = 'user' + const name = 'User Name' + const user = makeExpandedUserSchema({ id: 1, username }) + + gitlabApi.Users.create.mockResolvedValue(user) + + const result = await service.createUser({ email, username, name }) + + expect(result).toEqual(user) + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email, + username, + name, + skipConfirmation: true, + })) + }) + }) + + describe('commitMirror', () => { + it('should create mirror commit', async () => { + const repoId = 1 + + await service.commitMirror(repoId) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + expect.any(String), + expect.arrayContaining([ + expect.objectContaining({ filePath: '.gitlab-ci.yml', action: 'create' }), + expect.objectContaining({ filePath: 'mirror.sh', action: 'create' }), + ]), + ) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts new file mode 100644 index 0000000000..d1a755fc9a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -0,0 +1,554 @@ +import type { + AccessLevel, + AccessTokenScopes, + BaseRequestOptions, + CommitAction, + CondensedGroupSchema, + CondensedProjectSchema, + EditUserOptions, + ExpandedUserSchema, + Gitlab, + GroupSchema, + OffsetPagination, + PaginationRequestOptions, + PipelineTriggerTokenSchema, + SimpleUserSchema, +} from '@gitbeaker/core' +import type { GitlabConfig } from './gitlab.module-definition' +import { join } from 'node:path' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { find } from '../../utils/iterable.utils' +import { + GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_PATH, + MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, + MIRROR_REPO_NAME, + PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, + TOKEN_DESCRIPTION, + TOPIC_PLUGIN_MANAGED, + USER_ID_CUSTOM_ATTRIBUTE_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { generateGitlabCIConfigContent, generateMirrorScriptContent, hasFileContentChanged } from './gitlab.utils' + +export const GITLAB_REST_CLIENT = Symbol('GITLAB_REST_CLIENT') + +type With = T & Required> +export type CondensedGroupSchemaWith = With +export type CondensedProjectSchemaWith = With +export type EditUserOptionsWith = With +type UserSchema = SimpleUserSchema | ExpandedUserSchema + +export interface OffsetPaginateOptions { + startPage?: number + perPage?: number + maxPages?: number +} + +@Injectable() +export class GitlabClientService { + private readonly logger = new Logger(GitlabClientService.name) + + constructor( + @Inject(GITLAB_CONFIG) readonly config: GitlabConfig, + @Inject(GITLAB_REST_CLIENT) private readonly client: Gitlab, + ) { + } + + async upsertGroupCustomAttribute(groupId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab group custom attribute (groupId=${groupId}, key=${key})`) + try { + await this.client.GroupCustomAttributes.set(groupId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab group custom attribute (groupId=${groupId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + async upsertProjectCustomAttribute(projectId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab project custom attribute (projectId=${projectId}, key=${key})`) + try { + await this.client.ProjectCustomAttributes.set(projectId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab project custom attribute (projectId=${projectId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + async upsertUserCustomAttribute(userId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab user custom attribute (userId=${userId}, key=${key})`) + try { + await this.client.UserCustomAttributes.set(userId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab user custom attribute (userId=${userId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + private async setManagedUserAttributes(userId: number, cpnUserId: string) { + await this.upsertUserCustomAttribute(userId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + await this.upsertUserCustomAttribute(userId, USER_ID_CUSTOM_ATTRIBUTE_KEY, cpnUserId) + } + + private async setManagedInfraProjectAttributes(projectId: number) { + await this.upsertProjectCustomAttribute(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedProjectAttributes(projectId: number, projectSlug: string) { + await this.upsertProjectCustomAttribute(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + await this.upsertProjectCustomAttribute(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + } + + private async setManagedGroupAttributes(groupId: number) { + await this.upsertGroupCustomAttribute(groupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedRootGroupAttributes(groupId: number) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedInfraGroupAttributes(groupId: number) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedProjectGroupAttributes(groupId: number, projectSlug: string) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + } + + async getGroupByPath(path: string) { + this.logger.verbose(`Looking up a GitLab group by path ${path}`) + return find( + this.offsetPaginate(opts => this.client.Groups.all({ search: path, orderBy: 'path', ...opts })), + g => g.full_path === path, + ) + } + + async createGroup(path: string) { + this.logger.log(`Creating a GitLab group at path ${path}`) + const created = await this.client.Groups.create(path, path) + if (this.config.projectRootDir && created.full_path === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(created.id) + } + if (this.config.projectRootDir && created.full_path === `${this.config.projectRootDir}/${INFRA_GROUP_PATH}`) { + await this.setManagedInfraGroupAttributes(created.id) + } + return created + } + + async createSubGroup(parentGroup: CondensedGroupSchemaWith<'id' | 'full_path'>, name: string, fullPath: string) { + this.logger.log(`Creating a GitLab subgroup ${fullPath} (parentId=${parentGroup.id})`) + const created = await this.client.Groups.create(name, name, { parentId: parentGroup.id }) + if (this.config.projectRootDir && fullPath === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(created.id) + } else if (this.config.projectRootDir && fullPath === `${this.config.projectRootDir}/${INFRA_GROUP_PATH}`) { + await this.setManagedInfraGroupAttributes(created.id) + } else if (this.config.projectRootDir && fullPath.startsWith(`${this.config.projectRootDir}/`) && !fullPath.slice(this.config.projectRootDir.length + 1).includes('/')) { + const projectSlug = fullPath.slice(this.config.projectRootDir.length + 1) + if (projectSlug && projectSlug !== INFRA_GROUP_PATH) { + await this.setManagedProjectGroupAttributes(created.id, projectSlug) + } + } + return created + } + + async getOrCreateGroupByPath(path: string) { + const parts = path.split('/') + const rootGroupPath = parts.shift() + if (!rootGroupPath) throw new Error('Invalid projects root dir') + + this.logger.verbose(`Resolving GitLab group path ${path} (depth=${1 + parts.length})`) + let parentGroup = await this.getGroupByPath(rootGroupPath) ?? await this.createGroup(rootGroupPath) + if (this.config.projectRootDir && parentGroup.full_path === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(parentGroup.id) + } + + let currentFullPath: string + for (const part of parts) { + currentFullPath = `${parentGroup.full_path}/${part}` + parentGroup = await this.getGroupByPath(currentFullPath) ?? await this.createSubGroup(parentGroup, part, currentFullPath) + } + + this.logger.verbose(`GitLab group path resolved (path=${path}, groupId=${parentGroup.id})`) + return parentGroup + } + + async getOrCreateProjectGroup() { + if (!this.config.projectRootDir) throw new Error('projectRootDir not configured') + return this.getOrCreateGroupByPath(this.config.projectRootDir) + } + + async getOrCreateProjectSubGroup(subGroupPath: string) { + const fullPath = this.config.projectRootDir + ? `${this.config.projectRootDir}/${subGroupPath}` + : subGroupPath + return this.getOrCreateGroupByPath(fullPath) + } + + async getOrCreateProjectGroupPublicUrl(): Promise { + const projectGroup = await this.getOrCreateProjectGroup() + return new URL(projectGroup.full_path, this.config.url).toString() + } + + async getOrCreateInfraGroupRepoPublicUrl(repoName: string): Promise { + const projectGroup = await this.getOrCreateProjectGroup() + return new URL(`${projectGroup.full_path}/${INFRA_GROUP_PATH}/${repoName}.git`, this.config.url).toString() + } + + async getOrCreateProjectGroupInternalRepoUrl(subGroupPath: string, repoName: string): Promise { + const projectGroup = await this.getOrCreateProjectSubGroup(subGroupPath) + const urlBase = this.config.internalOrPublicUrl + if (!urlBase) throw new Error('GITLAB_URL is required') + return `${urlBase}/${projectGroup.full_path}/${repoName}.git` + } + + private async getOrCreateRepo(subGroupPath: string) { + const fullPath = this.config.projectRootDir + ? `${this.config.projectRootDir}/${subGroupPath}` + : subGroupPath + this.logger.verbose(`Resolving a GitLab project repository by path ${fullPath}`) + try { + const existingRepo = await this.client.Projects.show(fullPath) + if (existingRepo) { + this.logger.verbose(`Found a GitLab project repository (path=${fullPath}, repoId=${existingRepo.id})`) + return existingRepo + } + } catch (error) { + if (!(error instanceof GitbeakerRequestError) || !error.cause?.description?.includes('404')) { + throw error + } + } + const repo = await find( + this.offsetPaginate(opts => this.client.Projects.all({ + search: fullPath, + orderBy: 'path', + ...opts, + })), + p => p.path_with_namespace === fullPath, + ) + if (repo) { + this.logger.verbose(`Found a GitLab project repository via search (path=${fullPath}, repoId=${repo.id})`) + return repo + } + const parts = subGroupPath.split('/') + const repoName = parts.pop() + if (!repoName) throw new Error('Invalid repo path') + const parentGroup = await this.getOrCreateProjectSubGroup(parts.join('/')) + try { + const created = await this.client.Projects.create({ + name: repoName, + path: repoName, + namespaceId: parentGroup.id, + defaultBranch: 'main', + }) + this.logger.log(`Created a GitLab project repository (path=${fullPath}, repoId=${created.id})`) + return created + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('has already been taken')) { + this.logger.warn(`GitLab project repository already exists (race); reloading ${fullPath}`) + const reloaded = await this.client.Projects.show(fullPath) + return reloaded + } + throw error + } + } + + async getOrCreateProjectGroupRepo(projectSlug: string, subGroupPath: string) { + const repo = await this.getOrCreateRepo(subGroupPath) + await this.setManagedProjectAttributes(repo.id, projectSlug) + return repo + } + + async getOrCreateInfraGroupRepo(path: string) { + const fullPath = join(INFRA_GROUP_PATH, path) + const repo = await this.getOrCreateRepo(fullPath) + await this.setManagedInfraProjectAttributes(repo.id) + return repo + } + + async getFile(repo: CondensedProjectSchemaWith<'id'>, filePath: string, ref: string = 'main') { + try { + return await this.client.RepositoryFiles.show(repo.id, filePath, ref) + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('Not Found')) { + this.logger.debug(`GitLab file not found (repoId=${repo.id}, ref=${ref}, filePath=${filePath})`) + return + } + throw error + } + } + + async maybeCreateCommit( + repo: CondensedProjectSchemaWith<'id'>, + message: string, + actions: CommitAction[], + ref: string = 'main', + ): Promise { + if (actions.length === 0) { + this.logger.debug(`No GitLab commit actions to create (repoId=${repo.id}, ref=${ref})`) + return + } + this.logger.log(`Creating a GitLab commit (repoId=${repo.id}, ref=${ref}, actions=${actions.length})`) + await this.client.Commits.create(repo.id, ref, message, actions) + this.logger.verbose(`GitLab commit created (repoId=${repo.id}, ref=${ref}, actions=${actions.length})`) + } + + async generateCreateOrUpdateAction(repo: CondensedProjectSchemaWith<'id'>, ref: string, filePath: string, content: string): Promise { + const file = await this.getFile(repo, filePath, ref) + if (file && !hasFileContentChanged(file, content)) { + this.logger.debug(`GitLab file is up to date; skipping commit action (repoId=${repo.id}, ref=${ref}, filePath=${filePath})`) + return null + } + this.logger.verbose(`Prepared a GitLab commit action (repoId=${repo.id}, ref=${ref}, filePath=${filePath}, action=${file ? 'update' : 'create'})`) + return { + action: file ? 'update' : 'create', + filePath, + content, + } + } + + async listFiles(repo: CondensedProjectSchemaWith<'id'>, options: { path?: string, recursive?: boolean, ref?: string } = {}) { + try { + const path = options.path ?? '/' + const recursive = options.recursive ?? false + const ref = options.ref ?? 'main' + this.logger.verbose(`Listing GitLab repository tree (repoId=${repo.id}, ref=${ref}, path=${path}, recursive=${recursive})`) + const files = await this.client.Repositories.allRepositoryTrees(repo.id, { + path: options.path ?? '/', + recursive: options.recursive ?? false, + ref: options.ref ?? 'main', + }) + this.logger.verbose(`Listed GitLab repository tree (repoId=${repo.id}, ref=${ref}, path=${path}, count=${files.length})`) + return files + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('Not Found')) { + return [] + } + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('404 Tree Not Found')) { + return [] + } + throw error + } + } + + async getProjectGroup(projectSlug: string): Promise { + const parentGroup = await this.getOrCreateProjectGroup() + return find( + this.offsetPaginate(opts => this.client.Groups.allSubgroups(parentGroup.id, opts)), + g => g.name === projectSlug, + ) + } + + async deleteGroup(group: CondensedGroupSchemaWith<'id' | 'full_path'>): Promise { + this.logger.verbose(`Deleting GitLab group ${group.full_path} (groupId=${group.id})`) + await this.client.Groups.remove(group.id) + } + + async getGroupMembers(group: CondensedGroupSchemaWith<'id'>) { + this.logger.verbose(`Loading GitLab group members (groupId=${group.id})`) + return this.client.GroupMembers.all(group.id) + } + + async addGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number, accessLevel: Exclude) { + this.logger.verbose(`Adding a GitLab group member (groupId=${group.id}, userId=${userId}, accessLevel=${accessLevel})`) + return this.client.GroupMembers.add(group.id, userId, accessLevel) + } + + async editGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number, accessLevel: Exclude) { + this.logger.verbose(`Editing a GitLab group member (groupId=${group.id}, userId=${userId}, accessLevel=${accessLevel})`) + return this.client.GroupMembers.edit(group.id, userId, accessLevel) + } + + async removeGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number) { + this.logger.verbose(`Removing a GitLab group member (groupId=${group.id}, userId=${userId})`) + return this.client.GroupMembers.remove(group.id, userId) + } + + async getUserByEmail(email: string) { + const users = await this.client.Users.all({ search: email, orderBy: 'username' }) + if (users.length === 0) return null + return users[0] as UserSchema + } + + async createUser(user: EditUserOptions) { + this.logger.log(`Creating a GitLab user (email=${user.email}, username=${user.username})`) + return await this.client.Users.create({ + ...user, + skipConfirmation: true, + }) as UserSchema + } + + async upsertUser( + user: Omit, 'externUid' | 'provider'>, + options: { cpnUserId: string }, + ) { + const existing = await this.getUserByEmail(user.email) + + const editOptions: EditUserOptions = { + ...user, + externUid: user.email, + provider: 'openid_connect', + } + const gitlabUser = existing ?? await this.createUser(editOptions) + + if (existing) { + const hasDiff = Object.entries(editOptions).some(([key, value]) => { + if (value === undefined) return false + return (existing as Record)[key] !== value + }) + if (hasDiff) { + await this.client.Users.edit(gitlabUser.id, editOptions) + } + } + await this.setManagedUserAttributes(gitlabUser.id, options.cpnUserId) + return gitlabUser + } + + async* getRepos(projectSlug: string) { + const group = await this.getOrCreateProjectSubGroup(projectSlug) + const repos = this.offsetPaginate(opts => this.client.Groups.allProjects(group.id, { simple: false, ...opts })) + for await (const repo of repos) { + yield repo + } + } + + async upsertProjectGroupRepo(projectSlug: string, repoName: string, description?: string) { + const fullPath = `${projectSlug}/${repoName}` + const repo = await this.getOrCreateProjectGroupRepo(projectSlug, fullPath) + const updated = await this.client.Projects.edit(repo.id, { + name: repoName, + path: repoName, + topics: [TOPIC_PLUGIN_MANAGED], + description, + }) + return updated + } + + async deleteProjectGroupRepo(projectSlug: string, repoName: string) { + const fullPath = `${projectSlug}/${repoName}` + const repo = await this.getOrCreateProjectGroupRepo(projectSlug, fullPath) + return this.client.Projects.remove(repo.id) + } + + async commitMirror(repoId: number) { + this.logger.log(`Creating a GitLab mirror bootstrap commit (repoId=${repoId})`) + const actions: CommitAction[] = [ + { + action: 'create', + filePath: '.gitlab-ci.yml', + content: generateGitlabCIConfigContent(), + execute_filemode: false, + }, + { + action: 'create', + filePath: 'mirror.sh', + content: generateMirrorScriptContent(), + execute_filemode: true, + }, + ] + + await this.client.Commits.create( + repoId, + 'main', + 'ci: :construction_worker: first mirror', + actions, + ) + this.logger.verbose(`GitLab mirror bootstrap commit created (repoId=${repoId}, actions=${actions.length})`) + } + + async upsertProjectMirrorRepo(projectSlug: string) { + return this.upsertProjectGroupRepo(projectSlug, MIRROR_REPO_NAME) + } + + async getProjectToken(projectSlug: string) { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + return find( + this.offsetPaginate<{ name: string }>( + opts => this.client.GroupAccessTokens.all(group.id, opts) as unknown as Promise<{ data: { name: string }[], paginationInfo: OffsetPagination }>, + ), + token => token.name === `${projectSlug}-bot`, + ) + } + + async createProjectToken(projectSlug: string, tokenName: string, scopes: AccessTokenScopes[]) { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + const expirationDays = Number(this.config.mirrorTokenExpirationDays) + const effectiveExpirationDays = Number.isFinite(expirationDays) && expirationDays > 0 ? expirationDays : 30 + const expiryDate = new Date(Date.now() + effectiveExpirationDays * 24 * 60 * 60 * 1000) + this.logger.log(`Creating a GitLab group access token (projectSlug=${projectSlug}, tokenName=${tokenName}, expiry=${expiryDate.toISOString().slice(0, 10)})`) + return this.client.GroupAccessTokens.create(group.id, tokenName, scopes, expiryDate.toISOString().slice(0, 10)) + } + + async createMirrorAccessToken(projectSlug: string) { + const tokenName = `${projectSlug}-bot` + return this.createProjectToken(projectSlug, tokenName, ['write_repository', 'read_repository', 'read_api']) + } + + async getOrCreateMirrorPipelineTriggerToken(projectSlug: string): Promise { + const mirrorRepo = await this.upsertProjectMirrorRepo(projectSlug) + this.logger.verbose(`Resolving a GitLab pipeline trigger token (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + const currentTriggerToken = await find( + this.offsetPaginate(opts => this.client.PipelineTriggerTokens.all(mirrorRepo.id, opts)), + token => token.description === TOKEN_DESCRIPTION, + ) + if (currentTriggerToken) { + this.logger.verbose(`GitLab pipeline trigger token found (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + return currentTriggerToken + } + const created = await this.client.PipelineTriggerTokens.create(mirrorRepo.id, TOKEN_DESCRIPTION) + this.logger.log(`GitLab pipeline trigger token created (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + return created + } + + private async* offsetPaginate( + request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, + options?: OffsetPaginateOptions, + ): AsyncGenerator { + let page: number | null = options?.startPage ?? 1 + let pagesFetched = 0 + let total = 0 + + this.logger.debug(`Pagination start (page=${page})`) + + while (page !== null) { + if (options?.maxPages && pagesFetched >= options.maxPages) { + page = null + continue + } + + try { + const { data, paginationInfo } = await request({ + page, + perPage: options?.perPage, + maxPages: options?.maxPages, + showExpanded: true, + pagination: 'offset', + }) + + pagesFetched += 1 + total += data.length + + const nextPage = paginationInfo.next ?? null + this.logger.debug(`Pagination page fetched (page=${page}, nextPage=${nextPage ?? 'null'}, items=${data.length}, total=${total})`) + + for (const item of data) { + yield item + } + + page = nextPage + } catch (error) { + if (error instanceof Error) { + this.logger.error(`Pagination request failed (page=${page}): ${error.message}`, error.stack) + } else { + this.logger.error(`Pagination request failed (page=${page}): ${String(error)}`) + } + throw error + } + } + + this.logger.debug(`Pagination done (total=${total})`) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts new file mode 100644 index 0000000000..5f72414b87 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts @@ -0,0 +1,133 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + owner: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + roles: { + select: { + id: true, + oidcGroup: true, + }, + }, + members: { + select: { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + }, + }, + roleIds: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + isPrivate: true, + externalRepoUrl: true, + externalUserName: true, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class GitlabDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: 'gitlab', + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } + + async getAdminRolesByOidcGroups(oidcGroups: string[]): Promise<{ id: string, oidcGroup: string }[]> { + return this.prisma.adminRole.findMany({ + where: { + oidcGroup: { + in: oidcGroups, + }, + }, + select: { + id: true, + oidcGroup: true, + }, + }) + } + + async getUser(id: string) { + return this.prisma.user.findUnique({ + where: { + id, + }, + }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts new file mode 100644 index 0000000000..0c4bdf8c41 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts @@ -0,0 +1,23 @@ +import type { GitlabConfig } from './gitlab.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +@Injectable() +export class GitlabHealthService { + constructor( + @Inject(GITLAB_CONFIG) private readonly gitlabConfig: GitlabConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.gitlabConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts new file mode 100644 index 0000000000..7f4d85f694 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { GitlabPluginService } from './gitlab-plugin.service' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +describe('gitlabPluginService', () => { + let service: GitlabPluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://gitlab.public', + projectRootDir: 'forge', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + GitlabPluginService, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(GitlabPluginService) + }) + + it('should expose the legacy project url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams({ project: { id: '', name: '', slug: 'dulei' } })) + + expect(url).toBe('https://gitlab.public/forge/dulei') + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts new file mode 100644 index 0000000000..3ce12a442c --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts @@ -0,0 +1,113 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { GitlabConfig } from './gitlab.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { DEFAULT_ADMIN_GROUP_PATH, DEFAULT_AUDITOR_GROUP_PATH, DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, PURGE_PLUGIN_KEY } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +@Injectable() +export class GitlabPluginService { + constructor( + @Inject(GITLAB_CONFIG) + private readonly gitlabConfig: GitlabConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'gitlab', + to: ({ project }) => new URL(`${this.gitlabConfig.projectRootDir}/${project.slug}`, this.gitlabConfig.url).toString(), + title: 'Gitlab', + imgSrc: '/img/gitlab.svg', + description: 'GitLab est un service d\'hébergement de code source et de pipeline CI/CD', + config: { + global: [ + { + kind: 'switch', + key: PURGE_PLUGIN_KEY, + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Purger les utilisateurs non synchronisés', + value: DISABLED, + description: 'Purger les utilisateurs non synchronisés de GitLab lors de la synchronisation', + }, + { + kind: 'switch', + key: 'displayTriggerHint', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Afficher l\'aide de déclenchement de pipeline', + value: ENABLED, + description: 'Afficher l\'aide de déclenchement de pipeline aux utilisateurs lorsqu\'ils souhaitent afficher les secrets du projet', + }, + { + kind: 'text', + key: 'adminGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Admin', + value: DEFAULT_ADMIN_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'administrateur GitLab', + placeholder: DEFAULT_ADMIN_GROUP_PATH, + }, + { + kind: 'text', + key: 'auditorGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Auditeur', + value: DEFAULT_AUDITOR_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'auditeur GitLab', + placeholder: DEFAULT_AUDITOR_GROUP_PATH, + }, + { + kind: 'text', + key: 'projectMaintainerGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Maintainer', + value: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Maintainer', + placeholder: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectDeveloperGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Developer', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Developer', + placeholder: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectReporterGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Reporter', + value: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Reporter', + placeholder: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + }, + ], + project: [], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts new file mode 100644 index 0000000000..678e3e1e36 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts @@ -0,0 +1,432 @@ +import type { + AccessTokenExposedSchema, + AccessTokenSchema, + CommitAction, + ExpandedGroupSchema, + ExpandedUserSchema, + GroupSchema, + MemberSchema, + OffsetPagination, + PipelineTriggerTokenSchema, + ProjectSchema, + RepositoryFileExpandedSchema, + RepositoryTreeSchema, +} from '@gitbeaker/core' +import type { AdminRole, Project, User } from '@prisma/client' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { faker } from '@faker-js/faker' +import { AccessLevel } from '@gitbeaker/core' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { ProjectStatus } from '@prisma/client' +import { PLUGIN_NAME } from './gitlab.constants' + +export function makeExpandedUserSchema(overrides: Partial = {}): ExpandedUserSchema { + const isoDate = faker.date.past().toISOString() + return { + id: 1, + name: 'User', + username: 'user', + state: 'active', + avatar_url: '', + web_url: 'https://gitlab.example/users/user', + created_at: isoDate, + locked: null, + bio: null, + bot: false, + location: null, + public_email: null, + skype: null, + linkedin: null, + twitter: null, + discord: null, + website_url: null, + pronouns: null, + organization: null, + job_title: null, + work_information: null, + followers: null, + following: null, + local_time: null, + is_followed: null, + is_admin: null, + last_sign_in_at: isoDate, + confirmed_at: isoDate, + last_activity_on: isoDate, + email: 'user@example.com', + theme_id: 1, + color_scheme_id: 1, + projects_limit: 0, + current_sign_in_at: null, + note: null, + identities: null, + can_create_group: false, + can_create_project: false, + two_factor_enabled: false, + external: false, + private_profile: null, + namespace_id: null, + created_by: null, + ...overrides, + } satisfies ExpandedUserSchema +} + +export function makeMemberSchema(overrides: Partial = {}) { + return { + id: 1, + username: 'user', + name: 'User', + state: 'active', + avatar_url: '', + web_url: 'https://gitlab.example/users/user', + expires_at: faker.date.future().toISOString(), + access_level: 30, + email: 'user@example.com', + group_saml_identity: { + extern_uid: '', + provider: '', + saml_provider_id: 1, + }, + ...overrides, + } satisfies MemberSchema +} + +export function makeGroupSchema(overrides: Partial = {}) { + return { + id: 123, + web_url: 'https://gitlab.example/groups/forge', + name: 'forge', + avatar_url: '', + full_name: 'forge', + full_path: 'forge', + path: 'forge', + description: '', + visibility: 'private', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: 'maintainer', + subgroup_creation_level: 'maintainer', + lfs_enabled: true, + default_branch_protection: 0, + request_access_enabled: false, + created_at: faker.date.past().toISOString(), + parent_id: 0, + ...overrides, + } satisfies GroupSchema +} + +export function makeExpandedGroupSchema(overrides: Partial = {}): ExpandedGroupSchema { + return { + id: 123, + web_url: 'https://gitlab.example/groups/forge', + name: 'forge', + avatar_url: '', + full_name: 'forge', + full_path: 'forge', + path: 'forge', + description: '', + visibility: 'private', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: 'maintainer', + subgroup_creation_level: 'maintainer', + lfs_enabled: true, + default_branch_protection: 0, + request_access_enabled: false, + created_at: faker.date.past().toISOString(), + parent_id: 0, + runners_token: 'runners-token', + file_template_project_id: 1, + shared_with_groups: null, + ...overrides, + } satisfies ExpandedGroupSchema +} + +export function makeProjectSchema(overrides: Partial = {}) { + return { + id: 1, + web_url: 'https://gitlab.example/projects/1', + name: 'repo', + path: 'repo', + description: '', + name_with_namespace: 'forge / repo', + path_with_namespace: 'forge/repo', + created_at: faker.date.past().toISOString(), + default_branch: 'main', + topics: [], + ssh_url_to_repo: 'ssh://gitlab.example/forge/repo.git', + http_url_to_repo: 'https://gitlab.example/forge/repo.git', + readme_url: '', + forks_count: 0, + avatar_url: null, + star_count: 0, + last_activity_at: faker.date.future().toISOString(), + namespace: { id: 1, name: 'forge', path: 'forge', kind: 'group', full_path: 'forge', avatar_url: '', web_url: 'https://gitlab.example/groups/forge' }, + description_html: '', + visibility: 'private', + empty_repo: false, + owner: { id: 1, name: 'Owner', created_at: faker.date.past().toISOString() }, + issues_enabled: true, + open_issues_count: 0, + merge_requests_enabled: true, + jobs_enabled: true, + wiki_enabled: true, + snippets_enabled: true, + can_create_merge_request_in: true, + resolve_outdated_diff_discussions: false, + container_registry_access_level: 'enabled', + security_and_compliance_access_level: 'enabled', + container_expiration_policy: { + cadence: '1d', + enabled: false, + keep_n: null, + older_than: null, + name_regex_delete: null, + name_regex_keep: null, + next_run_at: faker.date.future().toISOString(), + }, + updated_at: faker.date.past().toISOString(), + creator_id: 1, + import_url: null, + import_type: null, + import_status: 'none', + import_error: null, + permissions: { + project_access: { access_level: 0, notification_level: 0 }, + group_access: { access_level: 0, notification_level: 0 }, + }, + archived: false, + license_url: '', + license: { key: 'mit', name: 'MIT', nickname: 'MIT', html_url: '', source_url: '' }, + shared_runners_enabled: true, + group_runners_enabled: true, + runners_token: '', + ci_default_git_depth: 0, + ci_forward_deployment_enabled: false, + ci_forward_deployment_rollback_allowed: false, + ci_allow_fork_pipelines_to_run_in_parent_project: false, + ci_separated_caches: false, + ci_restrict_pipeline_cancellation_role: '', + public_jobs: false, + shared_with_groups: null, + repository_storage: '', + only_allow_merge_if_pipeline_succeeds: false, + allow_merge_on_skipped_pipeline: false, + restrict_user_defined_variables: false, + only_allow_merge_if_all_discussions_are_resolved: false, + remove_source_branch_after_merge: false, + printing_merge_requests_link_enabled: false, + request_access_enabled: false, + merge_method: '', + squash_option: '', + auto_devops_enabled: false, + auto_devops_deploy_strategy: '', + mirror: false, + mirror_user_id: 1, + mirror_trigger_builds: false, + only_mirror_protected_branches: false, + mirror_overwrites_diverged_branches: false, + external_authorization_classification_label: '', + packages_enabled: false, + service_desk_enabled: false, + service_desk_address: 'service-desk@example.com', + service_desk_reply_to: 'service-desk@example.com', + autoclose_referenced_issues: false, + suggestion_commit_message: 'Add suggestion commit message', + enforce_auth_checks_on_uploads: false, + merge_commit_template: 'Add suggestion commit message', + squash_commit_template: 'Add suggestion commit message', + issue_branch_template: 'Add suggestion commit message', + marked_for_deletion_on: faker.date.future().toISOString(), + compliance_frameworks: [], + warn_about_potentially_unwanted_characters: false, + container_registry_image_prefix: 'registry.gitlab.example/forge/repo', + _links: { + self: 'https://gitlab.example/projects/1', + issues: 'https://gitlab.example/projects/1/issues', + merge_requests: 'https://gitlab.example/projects/1/merge_requests', + repo_branches: 'https://gitlab.example/projects/1/repository/branches', + labels: 'https://gitlab.example/projects/1/labels', + events: 'https://gitlab.example/projects/1/events', + members: 'https://gitlab.example/projects/1/members', + cluster_agents: 'https://gitlab.example/projects/1/cluster_agents', + }, + ...overrides, + } satisfies ProjectSchema +} + +export function makeProject(overrides: Partial = {}) { + return { + id: faker.string.uuid(), + slug: faker.lorem.slug(), + name: faker.company.name(), + description: faker.lorem.sentence(), + status: faker.helpers.enumValue(ProjectStatus), + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + everyonePerms: 0n, + ownerId: faker.string.uuid(), + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } satisfies Project +} + +export function makeProjectWithDetails(overrides: Partial = {}) { + return { + id: faker.string.uuid(), + slug: faker.lorem.slug(), + name: faker.company.name(), + description: faker.lorem.sentence(), + owner: { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + }, + plugins: [], + roles: [], + members: [], + repositories: [], + clusters: [], + ...overrides, + } satisfies ProjectWithDetails +} +export function makePipelineTriggerToken(overrides: Partial = {}) { + return { + id: 1, + description: 'mirroring-from-external-repo', + created_at: faker.date.past().toISOString(), + last_used: null, + token: 'trigger-token', + updated_at: faker.date.past().toISOString(), + owner: null, + repoId: 1, + ...overrides, + } satisfies PipelineTriggerTokenSchema +} + +export function makeOffsetPagination(overrides: Partial = {}) { + return { + total: 1, + next: null, + current: 1, + previous: null, + perPage: 20, + totalPages: 1, + ...overrides, + } satisfies OffsetPagination +} + +export function makeAccessTokenSchema(overrides: Partial = {}) { + const isoDate = faker.date.past().toISOString() + return { + id: 1, + user_id: 1, + name: 'token', + expires_at: isoDate, + active: true, + created_at: isoDate, + revoked: false, + access_level: AccessLevel.DEVELOPER, + ...overrides, + } satisfies AccessTokenSchema +} + +export function makeAccessTokenExposedSchema(overrides: Partial = {}) { + return { + ...makeAccessTokenSchema(), + token: 'secret-token', + ...overrides, + } satisfies AccessTokenExposedSchema +} + +export function makeRepositoryFileExpandedSchema(overrides: Partial = {}) { + return { + file_name: 'file.txt', + file_path: 'file.txt', + size: 7, + encoding: 'base64', + content: 'content', + content_sha256: 'sha256', + ref: 'main', + blob_id: 'blob', + commit_id: 'commit', + last_commit_id: 'last-commit', + ...overrides, + } satisfies RepositoryFileExpandedSchema +} + +export function makeRepositoryTreeSchema(overrides: Partial = {}) { + return { + id: 'id', + name: 'file.txt', + type: 'blob', + path: 'file.txt', + mode: '100644', + ...overrides, + } satisfies RepositoryTreeSchema +} + +export function makeGitbeakerRequestError(params: { message?: string, status?: number, statusText?: string, description: string }) { + const request = new Request('https://gitlab.internal.example/api') + const response = new Response(null, { status: params.status ?? 404, statusText: params.statusText ?? 'Not Found' }) + return new GitbeakerRequestError(params.message ?? params.statusText ?? 'Error', { + cause: { + description: params.description, + request, + response, + }, + }) +} + +export function makeCommitAction(overrides: Partial = {}) { + return { + action: 'create', + filePath: 'file.txt', + content: 'content', + ...overrides, + } satisfies CommitAction +} + +export function makeAdminPlugin(overrides: { value?: string, pluginName?: string, key?: string } = {}) { + return { + pluginName: overrides.pluginName ?? PLUGIN_NAME, + key: overrides.key ?? 'token', + value: overrides.value ?? faker.string.alphanumeric(20), + } +} + +export function makeAdminRole(overrides: { id?: string, oidcGroup?: string } = {}) { + return { + id: overrides.id ?? faker.string.uuid(), + name: faker.word.words(), + permissions: 0n, + position: 0, + oidcGroup: overrides.oidcGroup ?? faker.word.words(), + type: 'managed', + } satisfies AdminRole +} + +export function makeUser(overrides: { id?: string, email?: string, firstName?: string, lastName?: string } = {}) { + return { + id: overrides.id ?? faker.string.uuid(), + email: overrides.email ?? faker.internet.email(), + firstName: overrides.firstName ?? faker.person.firstName(), + lastName: overrides.lastName ?? faker.person.lastName(), + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: null, + adminRoleIds: [], + type: 'human' as const, + } satisfies User +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts new file mode 100644 index 0000000000..d4d331cd2e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts @@ -0,0 +1,34 @@ +// GitLab plugin constants +export const PLUGIN_NAME = 'gitlab' + +// Infrastructure integration +export const INFRA_GROUP_NAME = 'Infra' +export const INFRA_GROUP_PATH = 'infra' +export const INFRA_APPS_REPO_NAME = 'infra-apps' +export const MIRROR_REPO_NAME = 'mirror' + +// Managed resources sentinel +export const TOPIC_PLUGIN_MANAGED = 'plugin-managed' +export const TOKEN_DESCRIPTION = 'mirroring-from-external-repo' + +// Default group paths for console roles +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly,/console/security' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin,/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly,/console/security' + +// Plugin configuration keys +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const AUDITOR_GROUP_PATH_PLUGIN_KEY = 'auditorGroupPath' +export const PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReporterGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDeveloperGroupPathSuffix' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectMaintainerGroupPathSuffix' +export const PURGE_PLUGIN_KEY = 'purge' + +// Custom attribute keys used in GitLab groups +export const GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY = 'cpn_projects_root_dir' +export const INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY = 'cpn_infra_group' +export const PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY = 'cpn_project_slug' +export const USER_ID_CUSTOM_ATTRIBUTE_KEY = 'cpn_user_id' +export const MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY = 'cpn_managed_by_console' diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts new file mode 100644 index 0000000000..3bb17e6b57 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts @@ -0,0 +1,19 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface GitlabConfig { + token?: string + url: string + internalUrl: string + mirrorTokenExpirationDays: number + mirrorTokenRotationThresholdDays: number + projectRootDir: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface GitlabModuleOptions extends GitlabConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('gitlab') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as GITLAB_CONFIG } diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts new file mode 100644 index 0000000000..f08dc6f312 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts @@ -0,0 +1,33 @@ +import type { GitlabConfig } from './gitlab.module-definition' +import { Gitlab } from '@gitbeaker/rest' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { GitlabHealthService } from './gitlab-health.service' +import { GitlabPluginService } from './gitlab-plugin.service' +import { ConfigurableModuleClass, GITLAB_CONFIG } from './gitlab.module-definition' +import { GitlabService } from './gitlab.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule], + providers: [ + { + provide: GITLAB_REST_CLIENT, + inject: [GITLAB_CONFIG], + useFactory: (config: GitlabConfig) => new Gitlab({ + token: config.token, + host: config.internalOrPublicUrl, + }), + }, + GitlabClientService, + GitlabDatastoreService, + GitlabHealthService, + GitlabPluginService, + GitlabService, + ], + exports: [GitlabClientService, GitlabHealthService, GitlabPluginService, GitlabService], +}) +export class GitlabModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts new file mode 100644 index 0000000000..da46c5fe4f --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -0,0 +1,492 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { AccessLevel } from '@gitbeaker/core' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { VaultClientService } from '../vault/vault-client.service' +import { GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { makeAccessTokenExposedSchema, makeExpandedUserSchema, makeGroupSchema, makeMemberSchema, makePipelineTriggerToken, makeProjectSchema, makeProjectWithDetails } from './gitlab-testing.utils' +import { PLUGIN_NAME, TOPIC_PLUGIN_MANAGED } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { GitlabService } from './gitlab.service' + +describe('gitlabService', () => { + let service: GitlabService + let gitlab: DeepMockProxy + let vault: DeepMockProxy + let datastore: DeepMockProxy + + beforeEach(async () => { + gitlab = mockDeep() + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + getAdminRolesByOidcGroups: vi.fn().mockResolvedValue([]), + }) + vault = mockDeep({ + writeGitlabMirrorCreds: vi.fn().mockResolvedValue(undefined), + deleteGitlabMirrorCreds: vi.fn().mockResolvedValue(undefined), + writeTechReadOnlyCreds: vi.fn().mockResolvedValue(undefined), + writeMirrorTriggerToken: vi.fn().mockResolvedValue(undefined), + readTechnReadOnlyCreds: vi.fn().mockResolvedValue(null), + readGitlabMirrorCreds: vi.fn().mockResolvedValue(null), + }) + const config = mockDeep({ projectRootDir: 'forge' }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + GitlabService, + { provide: GitlabClientService, useValue: gitlab }, + { provide: GitlabDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(GitlabService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('should reconcile project members and repositories', async () => { + const project = makeProjectWithDetails() + const group = makeGroupSchema({ + id: 123, + full_path: 'forge/console/project-1', + full_name: 'forge/console/project-1', + name: 'project-1', + path: 'project-1', + parent_id: 1, + }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectGroupRepo.mockResolvedValue(makeProjectSchema({ id: 1 })) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/repo') + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + gitlab.upsertUser.mockResolvedValue(makeExpandedUserSchema({ id: 123, username: 'user' })) + + await service.handleUpsert(project) + + expect(gitlab.getOrCreateProjectSubGroup).toHaveBeenCalledWith(project.slug) + expect(gitlab.getGroupMembers).toHaveBeenCalledWith(group) + expect(gitlab.getRepos).toHaveBeenCalledWith(project.slug) + }) + + it('should remove orphan member if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 999, username: 'orphan' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).toHaveBeenCalledWith(group, 999) + }) + + it('should not remove managed user (bot) even if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 888, username: 'group_123_bot' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).not.toHaveBeenCalled() + }) + + it('should not remove orphan member if purge disabled', async () => { + const project = makeProjectWithDetails() + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 999, username: 'orphan' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).not.toHaveBeenCalled() + }) + + it('should delete orphan repositories if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepo = makeProjectSchema({ name: 'orphan-repo', topics: [TOPIC_PLUGIN_MANAGED] }) + const unmanagedRepo = makeProjectSchema({ name: 'unmanaged-repo', topics: [] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepo + yield unmanagedRepo + })()) + gitlab.deleteProjectGroupRepo.mockResolvedValue(undefined) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).toHaveBeenCalledWith(project.slug, 'orphan-repo') + expect(gitlab.deleteProjectGroupRepo).toHaveBeenCalledTimes(1) + }) + + it('should not delete orphan repositories if purge disabled', async () => { + const project = makeProjectWithDetails({ + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepo = makeProjectSchema({ name: 'orphan-repo', topics: [TOPIC_PLUGIN_MANAGED] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepo + })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).not.toHaveBeenCalled() + }) + + it('should not delete orphan repositories without the correct topic even if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepoWithoutTopic = makeProjectSchema({ name: 'orphan-repo', topics: [] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepoWithoutTopic + })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).not.toHaveBeenCalled() + }) + + it('should create gitlab user if not exists', async () => { + const project = makeProjectWithDetails({ + owner: { id: 'o1', email: 'owner@example.com', firstName: 'Owner', lastName: 'User', adminRoleIds: [] }, + members: [{ user: { id: 'u1', email: 'new@example.com', firstName: 'New', lastName: 'User', adminRoleIds: [] }, roleIds: [] }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'new@example.com' ? 999 : 998, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'new@example.com' }), + expect.objectContaining({ cpnUserId: 'u1' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'owner@example.com' }), + expect.objectContaining({ cpnUserId: 'o1' }), + ) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 999, AccessLevel.GUEST) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 998, AccessLevel.OWNER) + }) + + it('should map roles to access levels and apply highest level', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-reporter', oidcGroup: '/project-1/console/readonly' }, + { id: 'r-developer', oidcGroup: '/project-1/console/developer' }, + { id: 'r-devops', oidcGroup: '/project-1/console/devops' }, + { id: 'r-maintainer', oidcGroup: '/project-1/console/admin' }, + { id: 'r-unknown', oidcGroup: '/other/group' }, + ], + members: [ + { user: { id: 'u1', email: 'reporter@example.com', firstName: 'Rep', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter'] }, + { user: { id: 'u2', email: 'developer@example.com', firstName: 'Dev', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-developer'] }, + { user: { id: 'u3', email: 'devops@example.com', firstName: 'Ops', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-devops'] }, + { user: { id: 'u4', email: 'maintainer@example.com', firstName: 'Main', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-maintainer'] }, + { user: { id: 'u5', email: 'mixed@example.com', firstName: 'Mixed', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter', 'r-developer'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + const idByEmail: Record = { + 'reporter@example.com': 101, + 'developer@example.com': 102, + 'devops@example.com': 103, + 'maintainer@example.com': 104, + 'mixed@example.com': 105, + 'owner@example.com': 100, + } + return makeExpandedUserSchema({ + id: idByEmail[user.email] ?? 999, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 101, AccessLevel.REPORTER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 102, AccessLevel.DEVELOPER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 103, AccessLevel.MAINTAINER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 104, AccessLevel.MAINTAINER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.DEVELOPER) + }) + + it('should prioritize higher access level when oidc group appears in multiple paths', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-devops', oidcGroup: '/project-1/console/devops' }, + ], + members: [ + { user: { id: 'u1', email: 'devops@example.com', firstName: 'Ops', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-devops'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'devops@example.com' ? 101 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 101, AccessLevel.MAINTAINER) + }) + + it('should map security project role to reporter access level', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-security', oidcGroup: '/project-1/console/security' }, + ], + members: [ + { user: { id: 'u1', email: 'security@example.com', firstName: 'Sec', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-security'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'security@example.com' ? 105 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.REPORTER) + }) + + it('should downgrade existing member to guest when no role maps to an access level', async () => { + const project = makeProjectWithDetails({ + roles: [{ id: 'r-unknown', oidcGroup: '/other/group' }], + members: [{ user: { id: 'u1', email: 'no-access@example.com', firstName: 'No', lastName: 'Access', adminRoleIds: [] }, roleIds: ['r-unknown'] }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 105, username: 'no-access', access_level: AccessLevel.REPORTER })]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'no-access@example.com' ? 105 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.editGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.GUEST) + expect(gitlab.removeGroupMember).not.toHaveBeenCalledWith(group, 105) + }) + + it('should bind builtin roles (admin/auditor) when role ids are resolved', async () => { + const project = makeProjectWithDetails({ + owner: { id: 'o1', email: 'owner@example.com', firstName: 'Owner', lastName: 'User', adminRoleIds: ['admin-role-id'] }, + members: [ + { user: { id: 'u1', email: 'admin@example.com', firstName: 'Admin', lastName: 'User', adminRoleIds: ['admin-role-id'] }, roleIds: [] }, + { user: { id: 'u2', email: 'auditor@example.com', firstName: 'Auditor', lastName: 'User', adminRoleIds: ['auditor-role-id'] }, roleIds: [] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + datastore.getAdminPluginConfig.mockImplementation(async (_pluginName: string, key: string) => { + if (key === 'adminGroupPath') return '/console/admin' + if (key === 'auditorGroupPath') return '/console/readonly' + return null + }) + datastore.getAdminRolesByOidcGroups.mockResolvedValue([ + { id: 'admin-role-id', oidcGroup: '/console/admin' }, + { id: 'auditor-role-id', oidcGroup: '/console/readonly' }, + ]) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: faker.number.int(), + email: user.email, + username: user.email.split('@')[0], + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'admin@example.com', admin: true, auditor: false }), + expect.objectContaining({ cpnUserId: 'u1' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'auditor@example.com', admin: false, auditor: true }), + expect.objectContaining({ cpnUserId: 'u2' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'owner@example.com', admin: true, auditor: false }), + expect.objectContaining({ cpnUserId: 'o1' }), + ) + }) + + it('should configure repository mirroring if external url is present', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + repositories: [{ + id: 'r1', + internalRepoName: 'repo-1', + externalRepoUrl: 'https://github.com/org/repo.git', + isPrivate: true, + externalUserName: 'user', + isInfra: false, + }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const gitlabRepo = makeProjectSchema({ id: 101, name: 'repo-1', path: 'repo-1', path_with_namespace: 'forge/console/project-1/repo-1' }) + const accessToken = makeAccessTokenExposedSchema({ + name: 'bot', + scopes: ['read_api'], + access_level: 40, + }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { yield gitlabRepo })()) + gitlab.getOrCreateProjectGroupInternalRepoUrl.mockResolvedValue('https://gitlab.internal/group/repo-1.git') + gitlab.createMirrorAccessToken.mockResolvedValue(accessToken) + vault.readTechnReadOnlyCreds.mockResolvedValue(null) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.createMirrorAccessToken).toHaveBeenCalledWith('project-1') + expect(gitlab.upsertProjectMirrorRepo).toHaveBeenCalledWith('project-1') + + expect(vault.writeGitlabMirrorCreds).toHaveBeenCalledWith( + 'project-1', + 'repo-1', + expect.objectContaining({ + GIT_INPUT_URL: 'github.com/org/repo.git', + GIT_OUTPUT_USER: 'bot', + GIT_OUTPUT_PASSWORD: accessToken.token, + }), + ) + expect(vault.writeTechReadOnlyCreds).toHaveBeenCalledWith('project-1', { + MIRROR_USER: 'bot', + MIRROR_TOKEN: accessToken.token, + }) + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects', async () => { + const projects = [makeProjectWithDetails({ id: 'p1', slug: 'project-1' })] + datastore.getAllProjects.mockResolvedValue(projects) + + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(gitlab.getOrCreateProjectSubGroup).toHaveBeenCalledWith('project-1') + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts new file mode 100644 index 0000000000..fd214154c1 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -0,0 +1,528 @@ +import type { CondensedGroupSchema, MemberSchema, ProjectSchema } from '@gitbeaker/core' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultSecret } from '../vault/vault-client.service' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import type { GitlabConfig } from './gitlab.module-definition' +import { specificallyEnabled } from '@cpn-console/hooks' +import { AccessLevel } from '@gitbeaker/core' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { getAll } from '../../utils/iterable.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + AUDITOR_GROUP_PATH_PLUGIN_KEY, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + INFRA_APPS_REPO_NAME, + PLUGIN_NAME, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PURGE_PLUGIN_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { + adminRoleFlag, + daysAgoFromNow, + generateAccessLevelMapping, + generateAdminRoleMapping, + generateName, + generateProjectRoleGroupPath, + generateUsername, + generateUsernameCandidates, + getProjectPluginConfig, + isOwnedRepo, + isOwnedUser, + isSystemRepo, +} from './gitlab.utils' + +type ProjectAccessLevel = Exclude + +@Injectable() +export class GitlabService { + private readonly logger = new Logger(GitlabService.name) + + constructor( + @Inject(GitlabDatastoreService) private readonly datastore: GitlabDatastoreService, + @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(GITLAB_CONFIG) private readonly gitlabConfig: GitlabConfig, + ) { + this.logger.log('GitLabService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('gitlab', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`GitLab sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('gitlab', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`GitLab sync completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + span?.setAttribute('gitlab.projects.count', 0) + this.logger.log('Starting GitLab reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('gitlab.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for GitLab reconciliation`) + await this.ensureProjectGroups(projects) + this.logger.log(`GitLab reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('gitlab.projects.count', projects.length) + this.logger.verbose(`Reconciling GitLab project groups (${projects.length})`) + await Promise.all(projects.map(p => this.ensureProjectGroup(p))) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling GitLab project group (${project.slug})`) + const group = await this.gitlab.getOrCreateProjectSubGroup(project.slug) + const members = await this.gitlab.getGroupMembers(group) + this.logger.verbose(`Loaded GitLab project group state (${project.slug}): groupId=${group.id} members=${members.length}`) + await this.ensureProjectGroupMembers(project, group, members) + await this.ensureProjectRepos(project) + await this.purgeOrphanRepos(project) + await this.ensureSystemRepos(project) + this.logger.verbose(`GitLab project group reconciled (${project.slug})`) + } + + @StartActiveSpan() + private async ensureProjectGroupMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling GitLab group members for project ${project.slug} (groupId=${group.id}, members=${members.length})`) + const { adminRoleId, auditorRoleId } = await this.getAdminRoleIds(project) + await this.addMissingMembers(project, group, members, adminRoleId, auditorRoleId) + await this.addMissingOwnerMember(project, group, members, adminRoleId, auditorRoleId) + await this.purgeOrphanMembers(project, group, members) + } + + private async addMissingMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + adminRoleId?: string, + auditorRoleId?: string, + ) { + const membersById = new Map(members.map(m => [m.id, m])) + const groupPaths = await this.getProjectRoleGroupPaths(project) + const accessLevelByUserId = generateAccessLevelMapping(project, groupPaths) + + await Promise.all(project.members.map(async ({ user }) => { + const gitlabUser = await this.gitlab.upsertUser({ + email: user.email, + username: generateUsername(user.email), + name: generateName(user.firstName, user.lastName), + admin: adminRoleFlag(user, adminRoleId), + auditor: adminRoleFlag(user, auditorRoleId), + }, { + cpnUserId: user.id, + }) + if (!gitlabUser) { + this.logger.warn(`Unable to resolve a GitLab user for a project member (project=${project.slug}, userId=${user.id}, email=${user.email})`) + return + } + const accessLevel = accessLevelByUserId.get(user.id) ?? AccessLevel.GUEST + await this.ensureGroupMemberAccessLevel(group, gitlabUser.id, accessLevel, membersById) + })) + } + + private async ensureGroupMemberAccessLevel( + group: CondensedGroupSchema, + gitlabUserId: number, + accessLevel: ProjectAccessLevel, + membersById: Map, + ) { + const existingMember = membersById.get(gitlabUserId) + + if (accessLevel === AccessLevel.NO_ACCESS) { + if (existingMember) { + await this.gitlab.removeGroupMember(group, gitlabUserId) + } + return + } + + if (!existingMember) { + await this.gitlab.addGroupMember(group, gitlabUserId, accessLevel) + return + } + + if (existingMember.access_level !== accessLevel) { + await this.gitlab.editGroupMember(group, gitlabUserId, accessLevel) + } + } + + private async addMissingOwnerMember( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + adminRoleId?: string, + auditorRoleId?: string, + ) { + const gitlabUser = await this.gitlab.upsertUser({ + email: project.owner.email, + username: generateUsername(project.owner.email), + name: generateName(project.owner.firstName, project.owner.lastName), + admin: adminRoleFlag(project.owner, adminRoleId), + auditor: adminRoleFlag(project.owner, auditorRoleId), + }, { + cpnUserId: project.owner.id, + }) + if (!gitlabUser) { + this.logger.warn(`Unable to resolve the GitLab owner account (project=${project.slug}, ownerId=${project.owner.id}, email=${project.owner.email})`) + return + } + const membersById = new Map(members.map(m => [m.id, m])) + await this.ensureGroupMemberAccessLevel(group, gitlabUser.id, AccessLevel.OWNER, membersById) + } + + private async getAdminRoleIds(project: ProjectWithDetails): Promise<{ adminRoleId?: string, auditorRoleId?: string }> { + const adminGroupPath = await this.getAdminGroupPath(project) + const auditorGroupPath = await this.getAuditorGroupPath(project) + const roles = await this.datastore.getAdminRolesByOidcGroups([adminGroupPath, auditorGroupPath]) + return generateAdminRoleMapping(roles, adminGroupPath, auditorGroupPath) + } + + private async getAdminGroupPath(project: ProjectWithDetails): Promise { + return await this.getAdminOrProjectPluginConfig(project, ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getAuditorGroupPath(project: ProjectWithDetails): Promise { + return await this.getAdminOrProjectPluginConfig(project, AUDITOR_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_AUDITOR_GROUP_PATH + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + if (!project) return undefined + return getProjectPluginConfig(project, key) ?? undefined + } + + private async getProjectRoleGroupPaths(project: ProjectWithDetails): Promise<{ reporter: string[], developer: string[], maintainer: string[] }> { + const [reporter, developer, maintainer] = await Promise.all([ + this.getProjectReporterGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectMaintainerGroupPaths(project), + ]) + + return { + reporter, + developer, + maintainer, + } + } + + private async getProjectReporterGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectMaintainerGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + @StartActiveSpan() + private async purgeOrphanMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'group.id': group.id, + 'members.total': members.length, + }) + const purgeConfig = getProjectPluginConfig(project, PURGE_PLUGIN_KEY) + const usernames = new Set([ + ...generateUsernameCandidates(project.owner.email), + ...project.members.flatMap(m => generateUsernameCandidates(m.user.email)), + ]) + const emails = new Set([ + project.owner.email.toLowerCase(), + ...project.members.map(m => m.user.email.toLowerCase()), + ]) + + const orphans = members.filter((member) => { + if (isOwnedUser(member)) return false + if (usernames.has(member.username)) return false + if (member.email && emails.has(member.email.toLowerCase())) return false + return true + }) + span?.setAttribute('orphans.count', orphans.length) + + if (specificallyEnabled(purgeConfig)) { + span?.setAttribute('purge.enabled', true) + let removedCount = 0 + await Promise.all(orphans.map(async (orphan) => { + await this.gitlab.removeGroupMember(group, orphan.id) + removedCount++ + this.logger.log(`Removed a user from the GitLab group (groupId=${group.id}, username=${orphan.username})`) + })) + span?.setAttribute('orphans.removed.count', removedCount) + } else { + span?.setAttribute('purge.enabled', false) + let warnedCount = 0 + for (const orphan of orphans) { + warnedCount++ + this.logger.warn(`User is in the GitLab group but not in the project (purge disabled, username=${orphan.username})`) + } + span?.setAttribute('orphans.warned.count', warnedCount) + } + } + + @StartActiveSpan() + private async ensureProjectRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'repositories.count': project.repositories.length, + }) + const gitlabRepositories = await getAll(this.gitlab.getRepos(project.slug)) + span?.setAttribute('gitlab.repositories.count', gitlabRepositories.length) + let mirroringEnabledCount = 0 + let mirroringDisabledCount = 0 + for (const repo of project.repositories) { + const externalHost = this.getExternalRepoHost(repo.externalRepoUrl) + span?.addEvent('gitlab.repo.reconcile', { + 'repository.name': repo.internalRepoName, + 'repository.isPrivate': repo.isPrivate, + ...(externalHost ? { 'repository.external.host': externalHost } : {}), + 'repository.external': !!repo.externalRepoUrl, + }) + await this.ensureRepository(project, repo, gitlabRepositories) + + if (repo.externalRepoUrl) { + span?.setAttribute('repository.mirroring', true) + mirroringEnabledCount++ + await this.configureRepositoryMirroring(project, repo) + } else { + span?.setAttribute('repository.mirroring', false) + mirroringDisabledCount++ + await this.vault.deleteGitlabMirrorCreds(project.slug, repo.internalRepoName) + } + } + span?.setAttribute('repositories.mirroring.enabled.count', mirroringEnabledCount) + span?.setAttribute('repositories.mirroring.disabled.count', mirroringDisabledCount) + } + + @StartActiveSpan() + private async purgeOrphanRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const gitlabRepositories = await getAll(this.gitlab.getRepos(project.slug)) + span?.setAttribute('gitlab.repositories.count', gitlabRepositories.length) + + const orphanRepos = gitlabRepositories.filter(r => isOwnedRepo(r) && !isSystemRepo(project, r)) + span?.setAttribute('orphan.repositories.count', orphanRepos.length) + + if (specificallyEnabled(getProjectPluginConfig(project, PURGE_PLUGIN_KEY))) { + span?.setAttribute('purge.enabled', true) + let removedCount = 0 + await Promise.all(orphanRepos.map(async (orphan) => { + await this.gitlab.deleteProjectGroupRepo(project.slug, orphan.name) + removedCount++ + this.logger.log(`Removed a repository from the GitLab project (project=${project.slug}, repoName=${orphan.name})`) + })) + + span?.setAttribute('orphan.repositories.removed.count', removedCount) + } else { + span?.setAttribute('purge.enabled', false) + let warnedCount = 0 + for (const orphan of orphanRepos) { + warnedCount++ + this.logger.warn(`Repository is in GitLab but not in the project definition (purge disabled, project=${project.slug}, repoName=${orphan.name})`) + } + span?.setAttribute('managed.repositories.warned.count', warnedCount) + } + } + + private async ensureRepository( + project: ProjectWithDetails, + repo: ProjectWithDetails['repositories'][number], + gitlabRepositories: ProjectSchema[], + ) { + return gitlabRepositories.find(r => r.name === repo.internalRepoName) + ?? await this.gitlab.upsertProjectGroupRepo( + project.slug, + repo.internalRepoName, + ) + } + + @StartActiveSpan() + private async configureRepositoryMirroring( + project: ProjectWithDetails, + repo: ProjectWithDetails['repositories'][number], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('repository.internalRepoName', repo.internalRepoName) + span?.setAttribute('repository.isPrivate', repo.isPrivate) + const externalHost = this.getExternalRepoHost(repo.externalRepoUrl) + if (externalHost) span?.setAttribute('repository.external.host', externalHost) + + const currentVaultSecret = await this.vault.readGitlabMirrorCreds(project.slug, repo.internalRepoName) + span?.setAttribute('vault.secret.exists', !!currentVaultSecret) + if (!currentVaultSecret) { + this.logger.warn(`No existing mirror credentials found in Vault; rotating new credentials (project=${project.slug}, repoName=${repo.internalRepoName})`) + } + + const internalRepoUrl = await this.gitlab.getOrCreateProjectGroupInternalRepoUrl(project.slug, repo.internalRepoName) + const externalRepoUrn = repo.externalRepoUrl.split('://')[1] + const internalRepoUrn = internalRepoUrl.split('://')[1] + span?.setAttribute('repository.externalRepoUrn', externalRepoUrn) + span?.setAttribute('repository.internalRepoUrn', internalRepoUrn) + + const projectMirrorCreds = await this.getOrRotateMirrorCreds(project.slug) + + const mirrorSecretData = { + GIT_INPUT_URL: externalRepoUrn, + GIT_INPUT_USER: repo.isPrivate ? repo.externalUserName : undefined, + GIT_INPUT_PASSWORD: currentVaultSecret?.data?.GIT_INPUT_PASSWORD, // Preserve existing password as it's not in DB + GIT_OUTPUT_URL: internalRepoUrn, + GIT_OUTPUT_USER: projectMirrorCreds.MIRROR_USER, + GIT_OUTPUT_PASSWORD: projectMirrorCreds.MIRROR_TOKEN, + } + + // Write to vault if changed + // Using simplified check + await this.vault.writeGitlabMirrorCreds(project.slug, repo.internalRepoName, mirrorSecretData) + span?.setAttribute('vault.secret.written', true) + } + + @StartActiveSpan() + private async ensureSystemRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + await Promise.all([ + this.ensureInfraAppsRepo(project.slug), + this.ensureMirrorRepo(project.slug), + ]) + } + + private async ensureInfraAppsRepo(projectSlug: string) { + await this.gitlab.upsertProjectGroupRepo(projectSlug, INFRA_APPS_REPO_NAME) + } + + private async ensureMirrorRepo(projectSlug: string) { + const mirrorRepo = await this.gitlab.upsertProjectMirrorRepo(projectSlug) + if (mirrorRepo.empty_repo) { + await this.gitlab.commitMirror(mirrorRepo.id) + } + await this.ensureMirrorRepoTriggerToken(projectSlug) + } + + @StartActiveSpan() + private async ensureMirrorRepoTriggerToken(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const triggerToken = await this.gitlab.getOrCreateMirrorPipelineTriggerToken(projectSlug) + const gitlabSecret = { + PROJECT_SLUG: projectSlug, + GIT_MIRROR_PROJECT_ID: triggerToken.repoId, + GIT_MIRROR_TOKEN: triggerToken.token, + } + await this.vault.writeMirrorTriggerToken(gitlabSecret) + span?.setAttribute('vault.secret.written', true) + } + + @StartActiveSpan() + private async getOrRotateMirrorCreds(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const vaultSecret = await this.vault.readTechnReadOnlyCreds(projectSlug) + if (!vaultSecret) return this.createMirrorAccessToken(projectSlug) + + const isExpiring = this.isMirrorCredsExpiring(vaultSecret) + span?.setAttribute('mirror.creds.expiring', isExpiring) + if (!isExpiring) { + span?.setAttribute('mirror.creds.rotated', false) + return vaultSecret.data as { MIRROR_USER: string, MIRROR_TOKEN: string } + } + return this.createMirrorAccessToken(projectSlug) + } + + @StartActiveSpan() + private async createMirrorAccessToken(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('mirror.creds.rotated', true) + const token = await this.gitlab.createMirrorAccessToken(projectSlug) + const creds = { + MIRROR_USER: token.name, + MIRROR_TOKEN: token.token, + } + await this.vault.writeTechReadOnlyCreds(projectSlug, creds) + span?.setAttribute('vault.secret.written', true) + return creds + } + + private isMirrorCredsExpiring(vaultSecret: VaultSecret): boolean { + if (!vaultSecret?.metadata?.created_time) return false + const createdTime = new Date(vaultSecret.metadata.created_time) + return daysAgoFromNow(createdTime) > this.gitlabConfig.mirrorTokenRotationThresholdDays + } + + private getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { + if (!externalRepoUrl) return undefined + try { + return new URL(externalRepoUrl).host + } catch { + return undefined + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts new file mode 100644 index 0000000000..63f574bd3c --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts @@ -0,0 +1,233 @@ +import type { MemberSchema, ProjectSchema } from '@gitbeaker/core' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { createHash } from 'node:crypto' +import { AccessLevel } from '@gitbeaker/core' +import { stringify } from 'yaml' +import { TOPIC_PLUGIN_MANAGED } from './gitlab.constants' + +export type ProjectAccessLevel = Exclude + +export function getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { + if (!externalRepoUrl) return undefined + try { + return new URL(externalRepoUrl).host + } catch { + return undefined + } +} + +export function hasFileContentChanged(file: { content_sha256?: string } | null | undefined, content: string): boolean { + return file?.content_sha256 !== digestContent(content) +} + +export function digestContent(content: string): string { + return createHash('sha256').update(content).digest('hex') +} + +export function generateUsername(email: string): string { + const localPart = email.split('@')[0] + return localPart.replaceAll(/[^\w-]/g, '') +} + +export function generateUsernameCandidates(email: string): string[] { + const username = generateUsername(email) + return [username, `${username}_1`, `${username}_2`, `${username}_3`] +} + +export function generateName(firstName: string | null, lastName: string | null): string { + const parts = [firstName, lastName].filter(Boolean) + return parts.length > 0 ? parts.join(' ') : 'User' +} + +export function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string): string[] { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${projectSlug}${path}`) +} + +export function generateAdminRoleMapping( + roles: ProjectWithDetails['roles'], + adminGroupPath: string, + auditorGroupPath: string, +): { adminRoleId?: string, auditorRoleId?: string } { + const roleIdByOidcGroup = new Map(roles.map(r => [r.oidcGroup, r.id] as [string | null, string])) + return { + adminRoleId: roleIdByOidcGroup.get(adminGroupPath), + auditorRoleId: roleIdByOidcGroup.get(auditorGroupPath), + } +} + +export function generateAccessLevelMapping( + project: ProjectWithDetails, + groupPaths: { reporter: string[], developer: string[], maintainer: string[] }, +): Map { + const getAccessLevelFromOidcGroup = (oidcGroup: string | null): ProjectAccessLevel | null => { + if (!oidcGroup) return null + if (groupPaths.maintainer.includes(oidcGroup)) return AccessLevel.MAINTAINER + if (groupPaths.developer.includes(oidcGroup)) return AccessLevel.DEVELOPER + if (groupPaths.reporter.includes(oidcGroup)) return AccessLevel.REPORTER + return null + } + + const roleAccessLevelById = new Map( + project.roles.map(role => [role.id, getAccessLevelFromOidcGroup(role.oidcGroup)]), + ) + + return project.members.reduce((acc, membership) => { + const highest = membership.roleIds.reduce((highest: ProjectAccessLevel | null, roleId) => { + const level = roleAccessLevelById.get(roleId) + if (level !== null && level !== undefined && (highest === null || level > highest)) return level + return highest + }, null) + acc.set(membership.user.id, highest ?? AccessLevel.GUEST) + return acc + }, new Map()) +} + +export function generateGitlabCIConfigContent() { + return stringify({ + variables: { + PROJECT_NAME: { + description: 'Nom du dépôt (dans ce Gitlab) à synchroniser.', + }, + GIT_BRANCH_DEPLOY: { + description: 'Nom de la branche à synchroniser.', + value: 'main', + }, + SYNC_ALL: { + description: 'Synchroniser toutes les branches.', + value: 'false', + }, + }, + include: [ + { + project: '$CATALOG_PATH', + file: 'mirror.yml', + ref: 'main', + }, + ], + repo_pull_sync: { + extends: '.repo_pull_sync', + only: [ + 'api', + 'triggers', + 'web', + 'schedules', + ], + }, + }) +} + +export function generateMirrorScriptContent() { + return `#!/bin/bash + +set -e + +# Colorize terminal +red='\\e[0;31m' +no_color='\\033[0m' + +# Console step increment +i=1 + +# Default values +BRANCH_TO_SYNC=main + +print_help() { + TEXT_HELPER="\\nThis script aims to send a synchronization request to DSO.\\nFollowing flags are available: + -a Api url to send the synchronization request + -b Branch which is wanted to be synchronize for the given repository (default '$BRANCH_TO_SYNC') + -g GitLab token to trigger the pipeline on the gitlab mirror project + -i Gitlab mirror project id + -r Gitlab repository name to mirror + -h Print script help\\n" + printf "$TEXT_HELPER" +} + +print_args() { + printf "\\nArguments received: + -a API_URL: $API_URL + -b BRANCH_TO_SYNC: $BRANCH_TO_SYNC + -g GITLAB_TRIGGER_TOKEN length: \${#GITLAB_TRIGGER_TOKEN} + -i GITLAB_MIRROR_PROJECT_ID: $GITLAB_MIRROR_PROJECT_ID + -r REPOSITORY_NAME: $REPOSITORY_NAME\\n" +} + +# Parse options +while getopts :ha:b:g:i:r: flag +do + case "\${flag}" in + a) + API_URL=\${OPTARG};; + b) + BRANCH_TO_SYNC=\${OPTARG};; + g) + GITLAB_TRIGGER_TOKEN=\${OPTARG};; + i) + GITLAB_MIRROR_PROJECT_ID=\${OPTARG};; + r) + REPOSITORY_NAME=\${OPTARG};; + h) + printf "\\nHelp requested.\\n" + print_help + printf "\\nExiting.\\n" + exit 0;; + *) + printf "\\nInvalid argument \${OPTARG} (\${flag}).\\n" + print_help + print_args + exit 1;; + esac +done + +# Test if arguments are missing +if [ -z \${API_URL} ] || [ -z \${BRANCH_TO_SYNC} ] || [ -z \${GITLAB_TRIGGER_TOKEN} ] || [ -z \${GITLAB_MIRROR_PROJECT_ID} ] || [ -z \${REPOSITORY_NAME} ]; then + printf "\\nArgument(s) missing!\\n" + print_help + print_args + exit 2 +fi + +# Print arguments +print_args + +# Send synchronization request +printf "\\n\${red}\${i}.\${no_color} Send request to DSO api.\\n\\n" + +curl \\ + -X POST \\ + --fail \\ + -F token=\${GITLAB_TRIGGER_TOKEN} \\ + -F ref=main \\ + -F variables[GIT_BRANCH_DEPLOY]=\${BRANCH_TO_SYNC} \\ + -F variables[PROJECT_NAME]=\${REPOSITORY_NAME} \\ + "\${API_URL}/api/v4/projects/\${GITLAB_MIRROR_PROJECT_ID}/trigger/pipeline" +` +} + +const ownedUserRegex = /group_\d+_bot/u + +export function isOwnedUser(member: MemberSchema) { + return ownedUserRegex.test(member.username) +} + +export function isOwnedRepo(repo: ProjectSchema) { + return repo.topics?.includes(TOPIC_PLUGIN_MANAGED) ?? false +} + +export function isSystemRepo(project: ProjectWithDetails, repo: ProjectSchema) { + return project.repositories.some(r => r.internalRepoName === repo.name) +} + +export function getProjectPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} +export function daysAgoFromNow(date: Date) { + return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) +} + +export function adminRoleFlag(user: ProjectWithDetails['members'][0]['user'], adminRoleId?: string) { + return adminRoleId ? user.adminRoleIds?.includes(adminRoleId) : undefined +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/healthz/healthz.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/healthz/healthz.controller.ts new file mode 100644 index 0000000000..b18ec7aadd --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/healthz/healthz.controller.ts @@ -0,0 +1,14 @@ +import type { HealthzService } from './healthz.service' +import { Controller, Get } from '@nestjs/common' +import { HealthCheck } from '@nestjs/terminus' + +@Controller('api/v1/healthz') +export class HealthzController { + constructor(private readonly healthz: HealthzService) {} + + @Get() + @HealthCheck() + check() { + return this.healthz.check() + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/healthz/healthz.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/healthz/healthz.module.ts new file mode 100644 index 0000000000..eba9c35084 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/healthz/healthz.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { ArgoCDModule } from '../argocd/argocd.module' +import { GitlabModule } from '../gitlab/gitlab.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { NexusModule } from '../nexus/nexus.module' +import { OpenCdsModule } from '../opencds/opencds.module' +import { RegistryModule } from '../registry/registry.module' +import { VaultModule } from '../vault/vault.module' +import { HealthzController } from './healthz.controller' +import { HealthzService } from './healthz.service' + +@Module({ + imports: [ + TerminusModule.forRoot(), + ConfigModule.forRoot(), + DatabaseModule, + ConditionalModule.registerWhen(KeycloakModule, 'USE_KEYCLOAK'), + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + ConditionalModule.registerWhen(VaultModule, 'USE_VAULT'), + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(RegistryModule, 'USE_REGISTRY'), + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(OpenCdsModule, 'USE_OPENCDS'), + ], + controllers: [HealthzController], + providers: [HealthzService], +}) +export class HealthzModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts new file mode 100644 index 0000000000..288e80d353 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts @@ -0,0 +1,106 @@ +import { HealthCheckService, TerminusModule } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { OpenCdsHealthService } from '../opencds/opencds-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { VaultHealthService } from '../vault/vault-health.service' +import { HealthzService } from './healthz.service' + +describe('healthzService', () => { + afterEach(() => vi.clearAllMocks()) + + describe('with all optional probes registered', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let keycloak: ReturnType> + let gitlab: ReturnType> + let vault: ReturnType> + let nexus: ReturnType> + let registry: ReturnType> + let argocd: ReturnType> + let opencds: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + keycloak = mockDeep() + gitlab = mockDeep() + vault = mockDeep() + nexus = mockDeep() + registry = mockDeep() + argocd = mockDeep() + opencds = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + { provide: KeycloakHealthService, useValue: keycloak }, + { provide: GitlabHealthService, useValue: gitlab }, + { provide: VaultHealthService, useValue: vault }, + { provide: NexusHealthService, useValue: nexus }, + { provide: RegistryHealthService, useValue: registry }, + { provide: ArgoCDHealthService, useValue: argocd }, + { provide: OpenCdsHealthService, useValue: opencds }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('checks every injected probe, including those wired via @Optional', async () => { + await service.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(keycloak.check).toHaveBeenCalledWith('keycloak') + expect(gitlab.check).toHaveBeenCalledWith('gitlab') + expect(vault.check).toHaveBeenCalledWith('vault') + expect(nexus.check).toHaveBeenCalledWith('nexus') + expect(registry.check).toHaveBeenCalledWith('registry') + expect(argocd.check).toHaveBeenCalledWith('argocd') + expect(opencds.check).toHaveBeenCalledWith('opencds') + }) + }) + + describe('without optional probes registered (@Optional -> undefined)', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('omits probes whose services are not registered', async () => { + await service.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(healthCheck.check).toHaveBeenCalledWith([expect.any(Function)]) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/healthz/healthz.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/healthz/healthz.service.ts new file mode 100644 index 0000000000..67619844e0 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/healthz/healthz.service.ts @@ -0,0 +1,41 @@ +import type { HealthIndicatorFunction } from '@nestjs/terminus' +import { Inject, Injectable, Optional } from '@nestjs/common' +import { HealthCheckService } from '@nestjs/terminus' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { OpenCdsHealthService } from '../opencds/opencds-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { VaultHealthService } from '../vault/vault-health.service' + +@Injectable() +export class HealthzService { + // Optional modules (gated by USE_*) resolve to undefined when not registered. + constructor( + @Inject(HealthCheckService) private readonly health: HealthCheckService, + @Inject(DatabaseHealthService) @Optional() private readonly database?: DatabaseHealthService, + @Inject(KeycloakHealthService) @Optional() private readonly keycloak?: KeycloakHealthService, + @Inject(GitlabHealthService) @Optional() private readonly gitlab?: GitlabHealthService, + @Inject(VaultHealthService) @Optional() private readonly vault?: VaultHealthService, + @Inject(NexusHealthService) @Optional() private readonly nexus?: NexusHealthService, + @Inject(RegistryHealthService) @Optional() private readonly registry?: RegistryHealthService, + @Inject(ArgoCDHealthService) @Optional() private readonly argocd?: ArgoCDHealthService, + @Inject(OpenCdsHealthService) @Optional() private readonly opencds?: OpenCdsHealthService, + ) {} + + check() { + const checks: HealthIndicatorFunction[] = [] + // Each health service's own check() reports 'Not configured' when its URL is absent. + if (this.database) checks.push(() => this.database!.check('database')) + if (this.keycloak) checks.push(() => this.keycloak!.check('keycloak')) + if (this.gitlab) checks.push(() => this.gitlab!.check('gitlab')) + if (this.vault) checks.push(() => this.vault!.check('vault')) + if (this.nexus) checks.push(() => this.nexus!.check('nexus')) + if (this.registry) checks.push(() => this.registry!.check('registry')) + if (this.argocd) checks.push(() => this.argocd!.check('argocd')) + if (this.opencds) checks.push(() => this.opencds!.check('opencds')) + return this.health.check(checks) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts new file mode 100644 index 0000000000..d170ff59ee --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts @@ -0,0 +1,86 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { HttpArgumentsHost } from '@nestjs/common/interfaces' +import type { AdminToken, PersonalAccessToken, User } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from './auth-user.decorator' +import { faker } from '@faker-js/faker' +import { mockDeep } from 'vitest-mock-extended' + +export function makeAuthRequest(headers: FastifyRequest['headers'] = {}): FastifyRequest { + const request = mockDeep() + request.headers = headers + return request +} + +export function makeExecutionContext(headers: FastifyRequest['headers'] = {}): DeepMockProxy { + const context = mockDeep() + const httpArgumentsHost = mockDeep() + const request = mockDeep() + + request.headers = headers + httpArgumentsHost.getRequest.mockReturnValue(request) + context.switchToHttp.mockReturnValue(httpArgumentsHost) + + return context +} + +export function makePersonalAccessToken(overrides: { + id?: string + userId?: string + adminRoleIds?: string[] + type?: User['type'] + status?: PersonalAccessToken['status'] + expirationDate?: PersonalAccessToken['expirationDate'] + lastUse?: PersonalAccessToken['lastUse'] +} = {}): PersonalAccessToken & { owner: { id: string, adminRoleIds: string[], type: string } } { + const id = overrides.id ?? 'pat-id' + const ownerId = overrides.userId ?? 'owner-id' + return { + id, + name: 'test-token', + status: overrides.status ?? 'active', + expirationDate: overrides.expirationDate ?? faker.date.future(), + lastUse: overrides.lastUse ?? null, + createdAt: faker.date.past(), + hash: 'hash', + userId: ownerId, + owner: { + id: ownerId, + adminRoleIds: overrides.adminRoleIds ?? [], + type: overrides.type ?? 'human', + }, + } +} + +export function makeAdminToken(overrides: { + id?: string + userId?: string + adminRoleIds?: string[] + type?: User['type'] + status?: AdminToken['status'] + permissions?: bigint + expirationDate?: AdminToken['expirationDate'] + lastUse?: AdminToken['lastUse'] +} = {}): AdminToken & { owner: { id: string, adminRoleIds: string[], type: string } } { + const id = overrides.id ?? 'admin-token-id' + const ownerId = overrides.userId ?? 'owner-id' + const type = overrides.type ?? 'human' + + return { + id, + name: 'admin-token', + status: overrides.status ?? 'active', + expirationDate: overrides.expirationDate ?? null, + permissions: overrides.permissions ?? 256n, + lastUse: overrides.lastUse ?? null, + createdAt: faker.date.past(), + hash: 'hash', + userId: ownerId, + owner: { + id: ownerId, + adminRoleIds: overrides.adminRoleIds ?? [], + type, + }, + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts new file mode 100644 index 0000000000..f018ac647e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts @@ -0,0 +1,21 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { User } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import { createParamDecorator } from '@nestjs/common' + +export interface UserContext { + userId: string + adminPermissions?: bigint + userType?: User['type'] +} + +export const AuthUser = createParamDecorator( + (_: unknown, ctx: ExecutionContext): UserContext => { + const request = ctx.switchToHttp().getRequest() + return { + userId: request.userId, + adminPermissions: request.adminPermissions, + userType: request.userType, + } + }, +) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts new file mode 100644 index 0000000000..23f7129d2e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../config/keycloak.config' +import { AuthService } from './auth.service' +import { DsoTokenModule } from './dso-token/dso-token.module' +import { KeycloakJwtModule } from './keycloak-jwt/keycloak-jwt.module' + +@Module({ + imports: [ConfigModule.forFeature(keycloakConfigFactory), DsoTokenModule, KeycloakJwtModule], + providers: [ + AuthService, + ], + exports: [ + AuthService, + KeycloakJwtModule, + ], +}) +export class AuthModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts new file mode 100644 index 0000000000..aa0fe99424 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts @@ -0,0 +1,59 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeAuthRequest } from './auth-testing.utils' +import { AuthService } from './auth.service' +import { DsoTokenService } from './dso-token/dso-token.service' +import { KeycloakJwtService } from './keycloak-jwt/keycloak-jwt.service' + +describe('authService', () => { + let module: TestingModule + let service: AuthService + let dsoTokenService: DeepMockProxy + let keycloakJwtService: DeepMockProxy + + beforeEach(async () => { + dsoTokenService = mockDeep() + keycloakJwtService = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: DsoTokenService, useValue: dsoTokenService }, + { provide: KeycloakJwtService, useValue: keycloakJwtService }, + ], + }).compile() + + service = module.get(AuthService) + }) + + it('should authenticate a Fastify request directly', async () => { + dsoTokenService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 0n, userType: 'human' }) + const request = makeAuthRequest({ 'x-dso-token': 'token' }) + + const result = await service.authenticate(request) + + expect(result).toEqual({ userId: 'u1', adminPermissions: 0n, userType: 'human' }) + expect(dsoTokenService.authenticate).toHaveBeenCalledWith(request, undefined) + }) + + it('should authenticate a Keycloak bearer token from the request header', async () => { + keycloakJwtService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 8n, userType: 'human' }) + + const request = makeAuthRequest({ authorization: 'Bearer jwt-token' }) + const result = await service.authenticate(request) + + expect(keycloakJwtService.authenticate).toHaveBeenCalledWith( + request, + undefined, + ) + expect(result).toEqual({ userId: 'u1', adminPermissions: 8n, userType: 'human' }) + }) + + it('should throw 401 when no supported auth header exists', async () => { + await expect(service.authenticate(makeAuthRequest({}))).rejects.toThrow(UnauthorizedException) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts new file mode 100644 index 0000000000..9ee491473c --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts @@ -0,0 +1,37 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from './auth-user.decorator' +import type { AuthRequirements } from './auth.utils' +import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common' +import { DsoTokenService } from './dso-token/dso-token.service' +import { KeycloakJwtService } from './keycloak-jwt/keycloak-jwt.service' + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name) + + constructor( + @Inject(DsoTokenService) private readonly dsoTokenService: DsoTokenService, + @Inject(KeycloakJwtService) private readonly keycloakJwtService: KeycloakJwtService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + const dsoTokenResult = await this.dsoTokenService.authenticate(request, requirements) + if (dsoTokenResult) { + this.logger.debug(`Auth authenticated request (requestId=${request.id}, authSource=dso-token, userId=${dsoTokenResult.userId}, adminPermissions=${dsoTokenResult.adminPermissions?.toString()}, userType=${dsoTokenResult.userType})`) + return dsoTokenResult + } + + const bearerTokenResult = await this.keycloakJwtService.authenticate(request, requirements) + if (bearerTokenResult) { + this.logger.debug(`Auth authenticated request (requestId=${request.id}, authSource=keycloak-jwt, userId=${bearerTokenResult.userId}, adminPermissions=${bearerTokenResult.adminPermissions?.toString()}, userType=${bearerTokenResult.userType})`) + return bearerTokenResult + } + + this.logger.warn(`Auth rejected request (requestId=${request.id}, hasDsoTokenHeader=${typeof request.headers['x-dso-token'] === 'string'}, hasAuthorizationHeader=${typeof request.headers.authorization === 'string'})`) + + throw new UnauthorizedException('Not authenticated') + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts new file mode 100644 index 0000000000..3b4d0c3ed9 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts @@ -0,0 +1,14 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from './auth-user.decorator' + +export interface AuthRequirements { + includeAdminRoleIds?: boolean + includeUserType?: boolean +} + +export interface AuthProvider { + authenticate: ( + request: FastifyRequest, + requirements?: AuthRequirements, + ) => Promise +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts new file mode 100644 index 0000000000..ed96461131 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../../database/database.module' +import { DsoTokenService } from './dso-token.service' + +@Module({ + imports: [DatabaseModule], + providers: [DsoTokenService], + exports: [DsoTokenService], +}) +export class DsoTokenModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts new file mode 100644 index 0000000000..f9ab618442 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts @@ -0,0 +1,118 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { createHash } from 'node:crypto' +import { faker } from '@faker-js/faker' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../../database/prisma.service' +import { makeAdminToken, makePersonalAccessToken } from '../auth-testing.utils' +import { DsoTokenService } from './dso-token.service' + +describe('dsoTokenService', () => { + let module: TestingModule + let service: DsoTokenService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep({ + adminRole: { + findMany: vi.fn().mockResolvedValue([]), + }, + }) + + module = await Test.createTestingModule({ + providers: [ + DsoTokenService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(DsoTokenService) + }) + + it('should validate a PersonalAccessToken and return raw token result', async () => { + const rawToken = faker.string.alphanumeric(32) + const patMock = makePersonalAccessToken({ adminRoleIds: [faker.string.uuid()] }) + prisma.personalAccessToken.findFirst.mockResolvedValue(patMock) + + const result = await service.validateToken(rawToken, { includeAdminRoleIds: true, includeUserType: true }) + + expect(prisma.personalAccessToken.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { hash: createHash('sha256').update(rawToken).digest('hex') } }), + ) + expect(result).toBeDefined() + if (result?.kind !== 'personal') throw new Error('Expected personal token result') + expect(result.ownerAdminRoleIds).toHaveLength(1) + expect(result.userType).toBe('human') + }) + + it('should validate an AdminToken and return raw token result', async () => { + const userId = faker.string.uuid() + prisma.personalAccessToken.findFirst.mockResolvedValue(null) + prisma.adminToken.findFirst.mockResolvedValue( + makeAdminToken({ + id: faker.string.uuid(), + userId, + permissions: 256n, + }), + ) + + const result = await service.validateToken(faker.string.alphanumeric(24), { includeAdminRoleIds: true, includeUserType: true }) + + expect(result).toBeDefined() + if (result?.kind !== 'admin') throw new Error('Expected admin token result') + expect(result.permissions).toBe(256n) + expect(result.userType).toBe('human') + }) + + it('should return undefined when no token found', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue(null) + prisma.adminToken.findFirst.mockResolvedValue(null) + + const result = await service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true }) + + expect(result).toBeUndefined() + }) + + it('should throw 401 when token is inactive', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue( + makePersonalAccessToken({ status: 'revoked', expirationDate: new Date(Date.now() + 86400000) }), + ) + + await expect(service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true })).rejects.toThrow(UnauthorizedException) + }) + + it('should throw 401 when token is expired', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue( + makePersonalAccessToken({ expirationDate: new Date(Date.now() - 1000) }), + ) + + await expect(service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true })).rejects.toThrow(UnauthorizedException) + }) + + it('should update lastUse and lastLogin', async () => { + const patMock = makePersonalAccessToken({}) + prisma.personalAccessToken.findFirst.mockResolvedValue(patMock) + + await service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true }) + + expect(prisma.personalAccessToken.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: patMock.id }, data: { lastUse: expect.any(String) } }), + ) + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: patMock.owner.id }, data: { lastLogin: expect.any(String) } }), + ) + }) + + it('should authenticate a DSO token from the request header', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue(makePersonalAccessToken({})) + + const result = await service.authenticateHeaders({ 'x-dso-token': 'token' }) + + expect(typeof result?.userId).toBe('string') + expect(result?.userType).toBe('human') + expect(result?.adminPermissions).toBe(0n) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts new file mode 100644 index 0000000000..1e27c96d63 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts @@ -0,0 +1,150 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../auth-user.decorator' +import type { AuthProvider, AuthRequirements } from '../auth.utils' +import type { AdminTokenWithOwner, AuthToken, PersonalAccessTokenWithOwner } from './dso-token.utils' +import { createHash } from 'node:crypto' +import { tokenHeaderName } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { PrismaService } from '../../database/prisma.service' +import { makeAdminTokenSelect, makePersonalAccessTokenSelect, validateToken } from './dso-token.utils' + +@Injectable() +export class DsoTokenService implements AuthProvider { + private readonly logger = new Logger(DsoTokenService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + return this.authenticateHeaders(request.headers, requirements) + } + + async authenticateHeaders( + headers: FastifyRequest['headers'], + requirements?: AuthRequirements, + ): Promise { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? true + const includeUserType = requirements?.includeUserType ?? true + + const tokenValue = headers[tokenHeaderName] + if (typeof tokenValue !== 'string') { + return undefined + } + + const tokenResult = await this.validateToken(tokenValue, { includeAdminRoleIds, includeUserType }) + if (!tokenResult) return undefined + + const adminPermissions = includeAdminRoleIds + ? await this.resolveAdminPermissions(tokenResult) + : undefined + + return { + userId: tokenResult.userId, + adminPermissions, + userType: tokenResult.userType, + } + } + + async validateToken( + rawToken: string, + requirements: Required, + ): Promise { + this.logger.debug(`validateToken started`) + const hash = createHash('sha256').update(rawToken).digest('hex') + const result = await this.findAndValidateToken(hash, requirements) + if (!result) { + this.logger.warn(`validateToken token not found`) + return undefined + } + this.logger.debug(`validateToken completed (userId=${result.userId})`) + return result + } + + private async findAndValidateToken(hash: string, requirements: Required): Promise { + const personalAccessTokenResult = await this.findAndValidatePersonalAccessToken(hash, requirements) + if (personalAccessTokenResult) { + return personalAccessTokenResult + } + + const adminTokenResult = await this.findAndValidateAdminToken(hash, requirements) + if (adminTokenResult) { + return adminTokenResult + } + + return undefined + } + + private async findAndValidatePersonalAccessToken(hash: string, requirements: Required): Promise { + const pat = await this.prisma.personalAccessToken.findFirst({ + select: makePersonalAccessTokenSelect(requirements), + where: { hash }, + }) satisfies PersonalAccessTokenWithOwner | null + if (pat) { + validateToken(pat) + await this.updateLastUse('personalAccessToken', pat.id, pat.owner.id) + return { + kind: 'personal' as const, + userId: pat.owner.id, + ownerAdminRoleIds: requirements.includeAdminRoleIds ? (pat.owner.adminRoleIds ?? []) : undefined, + userType: requirements.includeUserType ? (pat.owner.type ?? undefined) : undefined, + } + } + + return undefined + } + + private async findAndValidateAdminToken(hash: string, requirements: Required): Promise { + const adminToken = await this.prisma.adminToken.findFirst({ + select: makeAdminTokenSelect(requirements), + where: { hash }, + }) satisfies AdminTokenWithOwner | null + if (adminToken) { + validateToken(adminToken) + await this.updateLastUse('adminToken', adminToken.id, adminToken.owner.id) + return { + kind: 'admin' as const, + userId: adminToken.owner.id, + permissions: adminToken.permissions, + userType: adminToken.owner.type ?? undefined, + } + } + + return undefined + } + + private async updateLastUse(model: 'personalAccessToken' | 'adminToken', tokenId: string, userId: string) { + const now = new Date().toISOString() + if (model === 'personalAccessToken') { + await this.prisma.personalAccessToken.update({ where: { id: tokenId }, data: { lastUse: now } }) + } else { + await this.prisma.adminToken.update({ where: { id: tokenId }, data: { lastUse: now } }) + } + await this.prisma.user.update({ where: { id: userId }, data: { lastLogin: now } }) + } + + private async resolveAdminPermissions(result: AuthToken): Promise { + const globalRoles = await this.prisma.adminRole.findMany({ + where: { type: 'global' }, + select: { permissions: true }, + }) + const globalPerms = globalRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + + if (result.kind === 'admin') { + return globalPerms | result.permissions + } + const ownerAdminRoleIds = result.ownerAdminRoleIds ?? [] + if (!ownerAdminRoleIds.length) { + return globalPerms + } + const roles = await this.prisma.adminRole.findMany({ + select: { permissions: true }, + where: { id: { in: ownerAdminRoleIds } }, + }) + const tokenPerms = roles.reduce((acc, curr) => acc | curr.permissions, 0n) + return globalPerms | tokenPerms + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts new file mode 100644 index 0000000000..297533b572 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts @@ -0,0 +1,72 @@ +import type { AdminToken, Prisma, User } from '@prisma/client' +import type { AuthRequirements } from '../auth.utils' +import { UnauthorizedException } from '@nestjs/common' + +export type AuthToken + = | { kind: 'admin', userId: string, permissions: bigint, userType?: User['type'] } + | { kind: 'personal', userId: string, ownerAdminRoleIds?: string[], userType?: User['type'] } + +export function makeAdminTokenSelect(requirements?: AuthRequirements): Prisma.AdminTokenSelect { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? false + const includeUserType = requirements?.includeUserType ?? false + + return { + id: true, + name: true, + ...(includeAdminRoleIds ? { owner: { select: { adminRoleIds: true } } } : {}), + ...(includeUserType ? { owner: { select: { type: true } } } : {}), + ...(includeAdminRoleIds && includeUserType ? { owner: { select: { adminRoleIds: true, type: true } } } : {}), + } satisfies Prisma.AdminTokenSelect +} + +export function makePersonalAccessTokenSelect(requirements?: AuthRequirements): Prisma.PersonalAccessTokenSelect { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? false + const includeUserType = requirements?.includeUserType ?? false + + return { + id: true, + name: true, + owner: { + select: { + id: true, + ...(includeAdminRoleIds ? { adminRoleIds: true } : {}), + ...(includeUserType ? { type: true } : {}), + ...(includeAdminRoleIds && includeUserType ? { adminRoleIds: true, type: true } : {}), + }, + }, + } satisfies Prisma.PersonalAccessTokenSelect +} + +export interface PersonalAccessTokenWithOwner { + id: string + status: AdminToken['status'] + expirationDate: Date | null + owner: { + id: string + adminRoleIds?: string[] | null + type?: User['type'] | null + } +} + +export interface AdminTokenWithOwner { + id: string + status: AdminToken['status'] + expirationDate: Date | null + owner: { + id: string + adminRoleIds?: string[] | null + type?: User['type'] | null + } +} + +export type Token = PersonalAccessTokenWithOwner | AdminTokenWithOwner + +export function validateToken(token: Token): void { + if (token.expirationDate && token.expirationDate < new Date()) { + throw new UnauthorizedException(`Token expired: ${token.expirationDate}`) + } + + if (token.status === 'revoked') { + throw new UnauthorizedException('Token revoked') + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts new file mode 100644 index 0000000000..9b29a144f2 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts @@ -0,0 +1,45 @@ +import { faker } from '@faker-js/faker' + +export function makeMockUser(overrides: Partial<{ + id: string + firstName: string + lastName: string + email: string + createdAt: Date + updatedAt: Date + lastLogin: Date | null + adminRoleIds: string[] + type: 'human' | 'bot' | 'ghost' +}> = {}) { + return { + id: faker.string.uuid(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email().toLowerCase(), + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: null, + adminRoleIds: [], + type: 'human' as const, + ...overrides, + } +} + +export function makeMockAdminRole(overrides: Partial<{ + id: string + name: string + permissions: bigint + position: number + oidcGroup: string + type: string +}> = {}) { + return { + id: faker.string.uuid(), + name: faker.lorem.words(2), + permissions: 1n, + position: 0, + oidcGroup: '', + type: 'managed', + ...overrides, + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts new file mode 100644 index 0000000000..6c7851d92d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common' +import { JwtModule } from '@nestjs/jwt' +import { DatabaseModule } from '../../database/database.module' +import { KeycloakSecretProviderModule } from '../keycloak-secret-provider/keycloak-secret-provider.module' +import { KeycloakSecretProviderService } from '../keycloak-secret-provider/keycloak-secret-provider.service' +import { KeycloakJwtService } from './keycloak-jwt.service' + +@Module({ + imports: [ + DatabaseModule, + JwtModule.registerAsync({ + imports: [KeycloakSecretProviderModule], + inject: [KeycloakSecretProviderService], + useFactory: async (client: KeycloakSecretProviderService) => { + // The issuer is fetched from the openid-configuration endpoint + // rather than reconstructed from env vars, as the server may be + // behind a reverse proxy that differs from the public-facing domain. + const issuer = await client.fetchIssuer() + return { + secretOrKeyProvider: (requestType, tokenOrPayload) => client.getSecret(requestType, tokenOrPayload), + // Keycloak tokens currently do not include an audience claim for this app. + verifyOptions: { + algorithms: ['RS256'], + issuer, + }, + } + }, + }), + ], + providers: [KeycloakJwtService], + exports: [KeycloakJwtService, JwtModule], +}) +export class KeycloakJwtModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts new file mode 100644 index 0000000000..ed2c588caf --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts @@ -0,0 +1,170 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { UnauthorizedException } from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../../database/prisma.service' +import { makeAuthRequest } from '../auth-testing.utils' +import { makeMockAdminRole, makeMockUser } from './keycloak-jwt-testing.utils' +import { KeycloakJwtService } from './keycloak-jwt.service' + +describe('keycloakJwtService', () => { + let module: TestingModule + let service: KeycloakJwtService + let prisma: DeepMockProxy + let jwtService: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + jwtService = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + KeycloakJwtService, + { provide: PrismaService, useValue: prisma }, + { provide: JwtService, useValue: jwtService }, + ], + }).compile() + + service = module.get(KeycloakJwtService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('validatePayload', () => { + it('should recompute active admin roles from the current Keycloak group membership', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue( + makeMockUser({ + id: payload.sub, + adminRoleIds: ['stale-oidc-role', 'manual-role'], + }), + ) + prisma.adminRole.findMany.mockResolvedValue([ + makeMockAdminRole({ + id: 'stale-oidc-role', + oidcGroup: '/stale-group', + permissions: 8n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'manual-role', + oidcGroup: '', + permissions: 16n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'current-oidc-role', + oidcGroup: '/current-group', + permissions: 32n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'global-role', + oidcGroup: '', + permissions: 4n, + type: 'global', + }), + ]) + + const result = await service.validatePayload(payload) + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: payload.sub }, + data: { + adminRoleIds: ['manual-role', 'current-oidc-role', 'global-role'], + lastLogin: expect.any(String), + }, + }) + expect(result).toEqual({ + userId: payload.sub, + adminPermissions: 52n, + userType: 'human', + }) + }) + + it('should reject when the local user is missing', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue(null) + + await expect(service.validatePayload(payload)).rejects.toBeInstanceOf(UnauthorizedException) + expect(prisma.adminRole.findMany).not.toHaveBeenCalled() + expect(prisma.user.update).not.toHaveBeenCalled() + }) + + it('should skip admin role resolution when permissions are not required', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue( + makeMockUser({ + id: payload.sub, + type: 'human', + }), + ) + + const result = await service.validatePayload( + payload, + { includeAdminRoleIds: false, includeUserType: true }, + ) + + expect(prisma.adminRole.findMany).not.toHaveBeenCalled() + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: payload.sub }, + data: { + lastLogin: expect.any(String), + }, + }) + expect(result).toEqual({ + userId: payload.sub, + adminPermissions: undefined, + userType: 'human', + }) + }) + }) + + describe('authenticate', () => { + it('should authenticate a bearer token from the request', async () => { + jwtService.verifyAsync.mockResolvedValue({ + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: [], + }) + prisma.user.findUnique.mockResolvedValue(makeMockUser({})) + prisma.adminRole.findMany.mockResolvedValue([]) + + const result = await service.authenticate( + makeAuthRequest({ authorization: 'Bearer jwt-token' }), + ) + + expect(jwtService.verifyAsync).toHaveBeenCalledWith('jwt-token') + expect(result?.userId).toBeDefined() + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts new file mode 100644 index 0000000000..9e1eb00db7 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts @@ -0,0 +1,123 @@ +import type { Prisma } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { IncomingHttpHeaders } from 'node:http' +import type { UserContext } from '../auth-user.decorator' +import type { AuthProvider, AuthRequirements } from '../auth.utils' +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { z } from 'zod' +import { PrismaService } from '../../database/prisma.service' + +const KeycloakPayloadSchema = z.object({ + sub: z.string(), + email: z.string().optional().default(''), + given_name: z.string().optional().default(''), + family_name: z.string().optional().default(''), + groups: z.array(z.string()).optional().default([]), +}) + +type KeycloakPayload = z.infer + +@Injectable() +export class KeycloakJwtService implements AuthProvider { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(JwtService) private readonly jwtService: JwtService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + return this.authenticateHeaders(request.headers, requirements) + } + + async authenticateHeaders( + headers: IncomingHttpHeaders, + requirements?: AuthRequirements, + ): Promise { + const authHeader = headers.authorization + if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) { + return undefined + } + + try { + const jwt = authHeader.slice(7) + const payload = await this.jwtService.verifyAsync(jwt) + const parsedPayload = KeycloakPayloadSchema.parse(payload) + return await this.validatePayload(parsedPayload, requirements) + } catch (error) { + throw new UnauthorizedException( + error instanceof Error ? error.message : 'Authentication failed', + ) + } + } + + async validatePayload( + payload: KeycloakPayload, + requirements?: AuthRequirements, + ): Promise { + const authRequirements = normalizeRequirements(requirements) + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + select: makeUserSelect(authRequirements), + }) + if (!user) { + throw new UnauthorizedException('Not authenticated') + } + + let adminPermissions: bigint | undefined + let mergedRoleIds: string[] | undefined + + if (authRequirements.includeAdminRoleIds) { + const adminRoleIds = 'adminRoleIds' in user ? user.adminRoleIds : [] + const groups = payload.groups + + const matchingAdminRoles = await this.prisma.adminRole.findMany({ + where: { + OR: [ + { oidcGroup: { in: groups } }, + { id: { in: adminRoleIds } }, + { type: 'global' }, + ], + }, + }) + + const activeAdminRoles = matchingAdminRoles.filter(({ oidcGroup, type }) => + type === 'global' || !oidcGroup || groups.includes(oidcGroup), + ) + + mergedRoleIds = [...new Set(activeAdminRoles.map(({ id }) => id))] + adminPermissions = activeAdminRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + } + + await this.prisma.user.update({ + where: { id: payload.sub }, + data: { + ...(mergedRoleIds ? { adminRoleIds: mergedRoleIds } : {}), + lastLogin: new Date().toISOString(), + }, + }) + + return { + userId: payload.sub, + adminPermissions, + userType: 'type' in user ? user.type : undefined, + } + } +} + +function makeUserSelect(requirements: Required) { + return { + id: true, + ...(requirements.includeAdminRoleIds ? { adminRoleIds: true } : {}), + ...(requirements.includeUserType ? { type: true } : {}), + } satisfies Prisma.UserSelect +} + +function normalizeRequirements(requirements: AuthRequirements = {}): Required { + return { + includeAdminRoleIds: requirements.includeAdminRoleIds ?? true, + includeUserType: requirements.includeUserType ?? true, + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts new file mode 100644 index 0000000000..4c5ef71a1b --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts @@ -0,0 +1,22 @@ +import { generateKeyPairSync } from 'node:crypto' + +export function makeJwksResponse(kid: string): Response { + const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const jwk = publicKey.export({ format: 'jwk' }) + return new Response(JSON.stringify({ + keys: [ + { + kid, + kty: 'RSA', + use: 'sig', + n: jwk.n, + e: jwk.e, + }, + ], + }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts new file mode 100644 index 0000000000..42f606a58c --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts @@ -0,0 +1,2 @@ +export const KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX = 'jwks:' +export const KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY = `openid-configuration:` diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts new file mode 100644 index 0000000000..602419cdcf --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts @@ -0,0 +1,15 @@ +import { CacheModule } from '@nestjs/cache-manager' +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../../config/keycloak.config' +import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' + +@Module({ + imports: [ + CacheModule.register(), + ConfigModule.forFeature(keycloakConfigFactory), + ], + providers: [KeycloakSecretProviderService], + exports: [KeycloakSecretProviderService], +}) +export class KeycloakSecretProviderModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts new file mode 100644 index 0000000000..9d7e632122 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts @@ -0,0 +1,211 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { KeycloakConfig } from '../../../keycloak/keycloak.module-definition' +import { faker } from '@faker-js/faker' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { JwtSecretRequestType } from '@nestjs/jwt' +import { Test } from '@nestjs/testing' +import { createCache } from 'cache-manager' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KEYCLOAK_CONFIG } from '../../../keycloak/keycloak.module-definition' +import { makeJwksResponse } from './keycloak-secret-provider-testing.utils' +import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' +import { createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' + +describe('keycloakSecretProviderService', () => { + let module: TestingModule + let service: KeycloakSecretProviderService + let config: DeepMockProxy + let fetchMock: ReturnType + let cache: ReturnType + + beforeEach(async () => { + const keycloakDomain = faker.internet.domainName() + const keycloakRealm = faker.lorem.word() + const openidConfigurationUrl = `https://${keycloakDomain}/realms/${keycloakRealm}/.well-known/openid-configuration` + + config = mockDeep({ + protocol: 'https', + domain: keycloakDomain, + realm: keycloakRealm, + openidConfigurationUrl, + jwksTimeoutMs: 1_000, + jwksCacheTtlMs: 300_000, + openidConfigurationCacheTtlMs: 300_000, + }) + fetchMock = vi.fn() + cache = createCache() + + vi.clearAllMocks() + vi.unstubAllGlobals() + vi.stubGlobal('fetch', fetchMock) + + module = await Test.createTestingModule({ + providers: [ + KeycloakSecretProviderService, + { provide: KEYCLOAK_CONFIG, useValue: config }, + { provide: CACHE_MANAGER, useValue: cache }, + ], + }).compile() + + service = module.get(KeycloakSecretProviderService) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should fetch JWKS from Keycloak and parse the response', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + const internalJwksUri = `${config.protocol}://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-1')) + + const jwks = await service.fetchSigningKeys() + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0]?.[0]).toBe(config.openidConfigurationUrl) + expect(fetchMock.mock.calls[1]?.[0]).toBe(internalJwksUri) + expect(jwks).toEqual({ + keys: [ + { + kid: 'kid-1', + kty: 'RSA', + use: 'sig', + n: expect.any(String), + e: expect.any(String), + }, + ], + }) + }) + + it('should abort and return undefined when the JWKS request exceeds the timeout', async () => { + vi.useFakeTimers() + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockImplementationOnce((_url, init?: RequestInit) => new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + })) + + const promise = service.fetchSigningKeys() + await vi.advanceTimersByTimeAsync(1_000) + + await expect(promise).resolves.toBeUndefined() + }) + + it('should return undefined when Keycloak returns a non-OK response', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(new Response('', { status: 500, statusText: 'Internal Server Error' })) + + await expect(service.fetchSigningKeys()).resolves.toBeUndefined() + }) + + it('should resolve a PEM public key from the JWKS', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-2')) + + const publicKey = await service.fetchPublicKey('kid-2') + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(publicKey).toContain('BEGIN RSA PUBLIC KEY') + expect(await cache.get(createKeycloakSecretProviderPublicKeyCacheKey('kid-2'))).toBe(publicKey) + }) + + it('should reuse the cached PEM public key', async () => { + await cache.set( + createKeycloakSecretProviderPublicKeyCacheKey('cached-kid'), + '-----BEGIN RSA PUBLIC KEY-----\ncached\n-----END RSA PUBLIC KEY-----', + ) + + const publicKey = await service.fetchPublicKey('cached-kid') + + expect(fetchMock).not.toHaveBeenCalled() + expect(publicKey).toBe('-----BEGIN RSA PUBLIC KEY-----\ncached\n-----END RSA PUBLIC KEY-----') + }) + + it('should resolve the secret directly from the JWT token and request type', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-3')) + const header = Buffer.from(JSON.stringify({ kid: 'kid-3' })).toString('base64url') + + const secret = await service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(secret).toContain('BEGIN RSA PUBLIC KEY') + }) + + it('should reject signing requests', async () => { + await expect(service.getSecret(JwtSecretRequestType.SIGN, 'payload')).rejects.toThrow('Signing is not supported') + }) + + it('should reject non-string tokens', async () => { + await expect(service.getSecret(JwtSecretRequestType.VERIFY, Buffer.from('payload'))).rejects.toThrow( + 'Unsupported token type', + ) + }) + + it('should reject malformed JWTs', async () => { + await expect(service.getSecret(JwtSecretRequestType.VERIFY, 'only.two')).rejects.toThrow('Invalid JWT format') + }) + + it('should reject JWTs without a kid header', async () => { + const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url') + + await expect(service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`)).rejects.toThrow( + 'Missing kid', + ) + }) + + it('should reject JWTs when the key cannot be resolved', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ keys: [] }))) + const header = Buffer.from(JSON.stringify({ kid: 'missing-kid' })).toString('base64url') + + await expect(service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`)).rejects.toThrow( + 'Unknown signing key', + ) + }) + + it('should resolve the issuer from openid-configuration', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + + await expect(service.fetchIssuer()).resolves.toBe(issuer) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('should replace the discovered JWKS domain with the configured internal Keycloak domain', async () => { + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + issuer: `https://${config.domain}/realms/${config.realm}`, + jwks_uri: publicJwksUri, + }))) + + await expect(service.fetchJwksUri()).resolves.toBe( + `https://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs`, + ) + }) + + it('should keep the discovered JWKS URI unchanged when no internal Keycloak domain is configured', async () => { + config.domain = undefined + const publicJwksUri = `https://public.example.test/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + issuer: 'https://public.example.test/realms/test', + jwks_uri: publicJwksUri, + }))) + + await expect(service.fetchJwksUri()).resolves.toBe(publicJwksUri) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts new file mode 100644 index 0000000000..69a19bb5fa --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts @@ -0,0 +1,162 @@ +import type { Cache } from 'cache-manager' +import type { KeycloakConfig } from '../../../keycloak/keycloak.module-definition' +import { createPublicKey } from 'node:crypto' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { JwtSecretRequestType } from '@nestjs/jwt' +import { z } from 'zod' +import { KEYCLOAK_CONFIG } from '../../../keycloak/keycloak.module-definition' +import { createKeycloakSecretProviderOpenIdConfigurationCacheKey, createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' + +const OpenidConfigurationSchema = z.object({ + issuer: z.string().url(), + jwks_uri: z.string().url(), +}) + +type OpenidConfiguration = z.infer + +const JwksResponseSchema = z.object({ + keys: z.array(z.object({ + kid: z.string(), + kty: z.string(), + use: z.string(), + n: z.string(), + e: z.string(), + })), +}) + +type JwksResponse = z.infer + +const JwtHeaderSchema = z.object({ + kid: z.string(), +}) + +@Injectable() +export class KeycloakSecretProviderService { + private readonly logger = new Logger(KeycloakSecretProviderService.name) + + constructor( + @Inject(CACHE_MANAGER) private readonly cache: Cache, + @Inject(KEYCLOAK_CONFIG) private readonly keycloakConfig: KeycloakConfig, + ) {} + + async fetchOpenIdConfig(): Promise { + const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(this.keycloakConfig.openidConfigurationUrl) + const cached = await this.cache.get(cacheKey) + if (cached) return cached + + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) + if (!response.ok) { + this.logger.error(`Failed to fetch openid-configuration: ${response.status} ${response.statusText}`) + return undefined + } + + const raw = await response.json() + const config = OpenidConfigurationSchema.safeParse(raw) + if (!config.success) { + this.logger.error('openid-configuration response missing jwks_uri') + return undefined + } + + await this.cache.set(cacheKey, config.data, this.keycloakConfig.openidConfigurationCacheTtlMs) + return config.data + } + + async fetchIssuer(): Promise { + const config = await this.fetchOpenIdConfig() + return config?.issuer + } + + async fetchJwksUri(): Promise { + const config = await this.fetchOpenIdConfig() + return config ? this.replaceJwksUriDomainWithInternalDomain(config.jwks_uri) : undefined + } + + private replaceJwksUriDomainWithInternalDomain(jwksUri: string): string { + if (!this.keycloakConfig.domain) return jwksUri + const url = new URL(jwksUri) + url.protocol = this.keycloakConfig.protocol + url.host = this.keycloakConfig.domain + this.logger.log(`Replacing JWKS URI domain: ${jwksUri} -> ${url.toString()}`) + return url.toString() + } + + async fetchSigningKeys(): Promise { + const jwksUri = await this.fetchJwksUri() + if (!jwksUri) return undefined + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), this.keycloakConfig.jwksTimeoutMs) + + try { + const response = await fetch(jwksUri, { signal: controller.signal }) + if (!response.ok) { + this.logger.error(`Failed to fetch JWKS: ${response.status} ${response.statusText}`) + return undefined + } + + const raw = await response.json() + return JwksResponseSchema.parse(raw) + } catch (error) { + this.logger.error(`Failed to fetch JWKS: ${error instanceof Error ? error.message : String(error)}`) + return undefined + } finally { + clearTimeout(timeout) + } + } + + async fetchPublicKey(kid: string): Promise { + const cacheKey = createKeycloakSecretProviderPublicKeyCacheKey(kid) + const cached = await this.cache.get(cacheKey) + if (cached) return cached + + const jwks = await this.fetchSigningKeys() + if (!jwks) return undefined + + const key = jwks.keys.find(candidate => candidate.kid === kid && candidate.use === 'sig' && candidate.kty === 'RSA') + if (!key) return undefined + + const publicKey = createPublicKey({ + key: { kty: 'RSA', n: key.n, e: key.e }, + format: 'jwk', + }) + + const pem = publicKey.export({ format: 'pem', type: 'pkcs1' }) as string + await this.cache.set(cacheKey, pem, this.keycloakConfig.jwksCacheTtlMs) + return pem + } + + async getSecret( + requestType: JwtSecretRequestType, + tokenOrPayload: string | object | Buffer, + ): Promise { + if (requestType === JwtSecretRequestType.SIGN) { + throw new TypeError('Signing is not supported') + } + + if (typeof tokenOrPayload !== 'string') { + throw new TypeError('Unsupported token type') + } + + const parts = tokenOrPayload.split('.') + if (parts.length !== 3) { + throw new TypeError('Invalid JWT format') + } + + const rawHeader = JSON.parse( + Buffer.from(parts[0].replaceAll('-', '+').replaceAll('_', '/'), 'base64').toString(), + ) + const header = JwtHeaderSchema.safeParse(rawHeader) + if (!header.success) { + throw new TypeError('Missing kid') + } + const { kid } = header.data + + const publicKey = await this.fetchPublicKey(kid) + if (!publicKey) { + throw new Error('Unknown signing key') + } + + return publicKey + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts new file mode 100644 index 0000000000..b13beeed98 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts @@ -0,0 +1,9 @@ +import { KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY, KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX } from './keycloak-secret-provider.constants' + +export function createKeycloakSecretProviderPublicKeyCacheKey(kid: string): string { + return `${KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX}${kid}` +} + +export function createKeycloakSecretProviderOpenIdConfigurationCacheKey(url: string): string { + return `${KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY}${url}` +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts new file mode 100644 index 0000000000..d1853a3219 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts @@ -0,0 +1,27 @@ +export interface BaseConfig { + nodeEnv: 'development' | 'production' | 'test' + isTest: boolean + isDev: boolean + isCI: boolean + isProd: boolean + integration: boolean + ci: boolean + devSetup: boolean + docker: boolean + serverHost: string + serverPort: number + appVersion: string + dbUrl?: string + sessionSecret?: string + contactEmail: string + mockPlugins: boolean + projectsRootDir?: string + pluginsDir: string + httpProxy?: string + httpsProxy?: string +} + +// Runtime config (BASE_CONFIG token) is owned by the global config/base.config.ts +// (which calls registerAs('base', ...)); re-exported here so services keep a single +// stable import surface (the module) while config depends on the module. +export { BASE_CONFIG } from './base.module' diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts new file mode 100644 index 0000000000..5ee8150bd8 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts @@ -0,0 +1,13 @@ +import type { BaseConfig } from './base.config' +import { Global, Module } from '@nestjs/common' +import { createConfigurableModule } from '../../configurable-feature-module' + +export interface BaseModuleOptions extends BaseConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('base', true) + +@Global() +@Module({}) +export class BaseModule extends ConfigurableModuleClass {} + +export const BASE_CONFIG = MODULE_OPTIONS_TOKEN diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts new file mode 100644 index 0000000000..7c42430723 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts @@ -0,0 +1,52 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import { HealthIndicatorService } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { DatabaseHealthService } from './database-health.service' +import { PrismaService } from './prisma.service' + +describe('databaseHealthService', () => { + let service: DatabaseHealthService + let prisma: DeepMockProxy + let indicatorSession: { up: ReturnType, down: ReturnType } + + beforeEach(async () => { + prisma = mockDeep() + const healthIndicatorMock = mockDeep() + indicatorSession = { + up: vi.fn().mockReturnValue({ status: 'up' }), + down: vi.fn().mockReturnValue({ status: 'down' }), + } + vi.mocked(healthIndicatorMock.check).mockReturnValue(indicatorSession as any) + + const module = await Test.createTestingModule({ + providers: [ + DatabaseHealthService, + { provide: PrismaService, useValue: prisma }, + { provide: HealthIndicatorService, useValue: healthIndicatorMock }, + ], + }).compile() + + service = module.get(DatabaseHealthService) + }) + + it('reports up when the query succeeds', async () => { + prisma.$queryRaw.mockResolvedValueOnce(undefined as any) + + const result = await service.check('database') + + expect(prisma.$queryRaw).toHaveBeenCalled() + expect(indicatorSession.up).toHaveBeenCalled() + expect(result).toEqual({ status: 'up' }) + }) + + it('reports down when the query throws', async () => { + prisma.$queryRaw.mockRejectedValueOnce(new Error('boom')) + + const result = await service.check('database') + + expect(indicatorSession.down).toHaveBeenCalledWith({ message: 'boom' }) + expect(result).toEqual({ status: 'down' }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts new file mode 100644 index 0000000000..1e37899c9b --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts @@ -0,0 +1,23 @@ +import { Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { PrismaService } from './prisma.service' + +@Injectable() +export class DatabaseHealthService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + await this.prisma.$queryRaw`SELECT 1` + return indicator.up() + } catch (error) { + return indicator.down({ + message: error instanceof Error ? error.message : String(error), + }) + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts new file mode 100644 index 0000000000..7e909376ec --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseHealthService } from './database-health.service' +import { DatabaseService } from './database.service' +import { PrismaService } from './prisma.service' + +@Module({ + imports: [TerminusModule], + providers: [DatabaseHealthService, DatabaseService, PrismaService], + exports: [DatabaseHealthService, DatabaseService, PrismaService], +}) +export class DatabaseModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts new file mode 100644 index 0000000000..3f373ba1eb --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts @@ -0,0 +1,30 @@ +import type { ConfigType } from '@nestjs/config' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../config/base.config' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../../infrastructure/config/base.config' +import { DatabaseService } from './database.service' +import { PrismaService } from './prisma.service' + +describe('databaseService', () => { + let service: DatabaseService + + beforeEach(async () => { + const baseConfig = mockDeep() + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabaseService, + { provide: PrismaService, useValue: mockDeep() }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(DatabaseService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts new file mode 100644 index 0000000000..5d9d0c32f7 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts @@ -0,0 +1,73 @@ +import type { BaseConfig } from '../../infrastructure/config/base.config' +import { setTimeout } from 'node:timers/promises' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { BASE_CONFIG } from '../../infrastructure/config/base.config' +import { PrismaService } from './prisma.service' + +@Injectable() +export class DatabaseService { + private readonly loggerService = new Logger(DatabaseService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(BASE_CONFIG) private readonly configurationService: BaseConfig, + ) { + this.DELAY_BEFORE_RETRY + = this.configurationService.isTest || this.configurationService.isCI + ? 1000 + : 10000 + } + + DELAY_BEFORE_RETRY!: number + closingConnections = false + + async getConnection(triesLeft = 5): Promise { + if (this.closingConnections || triesLeft <= 0) { + throw new Error('Unable to connect to Postgres server') + } + triesLeft-- + + try { + if ( + this.configurationService.isDev + || this.configurationService.isTest + || this.configurationService.isCI + ) { + this.loggerService.log( + `Trying to connect to Postgres with: ${this.configurationService.dbUrl}`, + ) + } + await this.prisma.$connect() + + this.loggerService.log('Connected to Postgres!') + } catch (error) { + if (triesLeft > 0) { + this.loggerService.error(error) + this.loggerService.log( + `Could not connect to Postgres: ${error.message}`, + ) + this.loggerService.log(`Retrying (${triesLeft} tries left)`) + await setTimeout(this.DELAY_BEFORE_RETRY) + return this.getConnection(triesLeft) + } + + this.loggerService.log( + `Could not connect to Postgres: ${error.message}`, + ) + this.loggerService.log('Out of retries') + error.message = `Out of retries, last error: ${error.message}` + throw error + } + } + + async closeConnections() { + this.closingConnections = true + try { + await this.prisma.$disconnect() + } catch (error) { + this.loggerService.error(error) + } finally { + this.closingConnections = false + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts new file mode 100644 index 0000000000..f10a31e573 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts @@ -0,0 +1,14 @@ +import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common' +import { Injectable } from '@nestjs/common' +import { PrismaClient } from '@prisma/client' + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect() + } + + async onModuleDestroy() { + await this.$disconnect() + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts new file mode 100644 index 0000000000..8eb62bea67 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitterModule } from '@nestjs/event-emitter' +import { baseConfigFactory } from '../../../config/base.config' + +@Module({ + imports: [EventEmitterModule.forRoot(), ConfigModule.forFeature(baseConfigFactory)], + exports: [EventEmitterModule], +}) +export class EventsModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts new file mode 100644 index 0000000000..25b2a4e695 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from './auth/auth.module' +import { DatabaseModule } from './database/database.module' +import { EventsModule } from './events/events.module' +import { LoggerModule } from './logger/logger.module' +import { PermissionModule } from './permission/permission.module' + +@Module({ + providers: [], + imports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + exports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], +}) +export class InfrastructureModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts new file mode 100644 index 0000000000..257ef429f8 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts @@ -0,0 +1,37 @@ +import type { BaseConfig } from '../config/base.config' +import { getLoggerOptions } from '@cpn-console/logger' +import { Module } from '@nestjs/common' +import { LoggerModule as PinoLoggerModule } from 'nestjs-pino' +import { BASE_CONFIG } from '../config/base.config' + +@Module({ + imports: [ + PinoLoggerModule.forRootAsync({ + imports: [], + inject: [BASE_CONFIG], + useFactory: async (baseConfig: BaseConfig) => { + return { + pinoHttp: { + ...getLoggerOptions(baseConfig.isProd ? 'production' : 'development', baseConfig.isTest ? 'info' : 'debug'), + customLogLevel: (req, res, err) => { + if (err || res.statusCode >= 500) { + return 'error' + } + if (res.statusCode >= 400) { + return 'warn' + } + // kube liveness/readiness probes hit healthz constantly, only log it on failure + if (req.url?.split('?')[0] === '/api/v1/healthz') { + return 'silent' + } + return 'info' + }, + }, + } + }, + }), + ], + controllers: [], + providers: [], +}) +export class LoggerModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts new file mode 100644 index 0000000000..c2f926a351 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { ProjectPermissionModule } from './project/project.module' +import { UserPermissionModule } from './user/user.module' + +@Module({ + imports: [UserPermissionModule, ProjectPermissionModule], + exports: [UserPermissionModule, ProjectPermissionModule], +}) +export class PermissionModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts new file mode 100644 index 0000000000..7863f5ad4b --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_ACCESS_KEY = 'project-access' + +export function RequireProjectAccess() { + return SetMetadata(PROJECT_ACCESS_KEY, true) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts new file mode 100644 index 0000000000..ee11a3df91 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts @@ -0,0 +1,52 @@ +import type { FastifyRequest } from 'fastify' +import type { ProjectRequirements } from './project-loader.utils' +import type { ProjectContext } from './project.guard' +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../../database/prisma.service' +import { isUuid, makeProjectSelect, resolveProjectPermissions } from './project-loader.utils' + +type RequestWithProjectParams = FastifyRequest<{ Params?: ProjectParams }> + +interface ProjectParams { + projectId?: string + projectSlug?: string +} + +@Injectable() +export class ProjectPermissionLoaderService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async load(request: RequestWithProjectParams, userId: string, requirements?: ProjectRequirements): Promise { + let where: { id: string } | { slug: string } + if (request.params?.projectId) { + where = isUuid(request.params.projectId) ? { id: request.params.projectId } : { slug: request.params.projectId } + } else if (request.params?.projectSlug) { + where = { slug: request.params.projectSlug } + } else { + throw new BadRequestException('projectId or projectSlug is required') + } + + const raw = await this.prisma.project.findUnique({ + where, + select: makeProjectSelect(requirements), + }) + if (!raw) throw new NotFoundException('Projet introuvable') + + const project: ProjectContext = { + id: raw.id, + slug: raw.slug, + } + + if (requirements?.includeStatus) { + project.status = raw.status + } + if (requirements?.includeLocked) { + project.locked = raw.locked + } + if (requirements?.includePermissions) { + project.projectPermissions = resolveProjectPermissions(raw, userId) + } + + return project + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts new file mode 100644 index 0000000000..16e48b3fac --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts @@ -0,0 +1,53 @@ +import type { Prisma } from '@prisma/client' +import { PROJECT_PERMS } from '@cpn-console/shared' + +export interface ProjectRequirements { + includeStatus?: boolean + includeLocked?: boolean + includePermissions?: boolean +} + +export function makeProjectSelect(requirements?: ProjectRequirements): Prisma.ProjectSelect { + const includePermissions = requirements?.includePermissions ?? true + const includeStatus = requirements?.includeStatus ?? true + const includeLocked = requirements?.includeLocked ?? true + + return { + id: true, + slug: true, + ...(includeStatus ? { status: true } : {}), + ...(includeLocked ? { locked: true } : {}), + ...(includePermissions + ? { + ownerId: true, + everyonePerms: true, + roles: { select: { id: true, permissions: true } }, + members: { select: { userId: true, roleIds: true } }, + } + : {}), + } satisfies Prisma.ProjectSelect +} + +export function resolveProjectPermissions( + raw: { ownerId: string, everyonePerms: bigint, roles: Array<{ id: string, permissions: bigint }>, members: Array<{ userId: string, roleIds: string[] }> }, + userId: string, +): bigint { + if (raw.ownerId === userId) { + return PROJECT_PERMS.MANAGE + } + + const member = raw.members.find(m => m.userId === userId) + if (!member) { + return 0n + } + + const memberRoles = raw.roles.filter(role => member.roleIds.includes(role.id)) + return memberRoles.reduce( + (acc, curr) => acc | curr.permissions, + raw.everyonePerms | PROJECT_PERMS.GUEST, + ) +} + +export function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts new file mode 100644 index 0000000000..ac63012cc3 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_LOCKED_KEY = 'project-locked' + +export function RequireProjectLocked(locked: boolean) { + return SetMetadata(PROJECT_LOCKED_KEY, locked) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts new file mode 100644 index 0000000000..da064283ff --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts @@ -0,0 +1,8 @@ +import type { ProjectAuthorized } from '@cpn-console/shared' +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_PERMISSION_KEY = 'project-permission' + +export function RequireProjectPermission(...permissions: (keyof typeof ProjectAuthorized)[]) { + return SetMetadata(PROJECT_PERMISSION_KEY, permissions) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts new file mode 100644 index 0000000000..f1b2f05f48 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts @@ -0,0 +1,8 @@ +import type { Project } from '@prisma/client' +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_STATUS_KEY = 'project-status' + +export function RequireProjectStatus(...statuses: Project['status'][]) { + return SetMetadata(PROJECT_STATUS_KEY, statuses) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts new file mode 100644 index 0000000000..921cffd845 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts @@ -0,0 +1,14 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { FastifyRequest } from 'fastify' +import type { ProjectContext } from './project.guard' +import { createParamDecorator } from '@nestjs/common' + +export const Project = createParamDecorator( + (_: unknown, ctx: ExecutionContext): ProjectContext => { + const request = ctx.switchToHttp().getRequest() + if (!request.project) { + throw new Error('Project context is missing from the request') + } + return request.project + }, +) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts new file mode 100644 index 0000000000..8d4a5ba4c6 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts @@ -0,0 +1,63 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ProjectContext } from './project.guard' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AuthService } from '../../auth/auth.service' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectGuard } from './project.guard' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' +import { makeExecutionContext, makeProjectContext, makeProjectPolicy } from './project.testing.utils' + +describe('projectGuard', () => { + let module: TestingModule + let guard: ProjectGuard + let authService: DeepMockProxy + let projectService: DeepMockProxy + let projectPolicy: DeepMockProxy + let loader: DeepMockProxy + + beforeEach(async () => { + authService = mockDeep() + projectService = mockDeep() + projectPolicy = mockDeep() + loader = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectGuard, + { provide: AuthService, useValue: authService }, + { provide: ProjectPermissionService, useValue: projectService }, + { provide: ProjectPermissionPolicy, useValue: projectPolicy }, + { provide: ProjectPermissionLoaderService, useValue: loader }, + ], + }).compile() + + guard = module.get(ProjectGuard) + }) + + it('throws 401 when userId is not set on the request', async () => { + projectPolicy.build.mockReturnValue(makeProjectPolicy()) + authService.authenticate.mockRejectedValue(new UnauthorizedException()) + const ctx = makeExecutionContext({}) + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException) + }) + + it('loads project and validates', async () => { + const project: ProjectContext = makeProjectContext({}) + const policy = makeProjectPolicy({ projectPermissions: ['Manage'] }) + projectPolicy.build.mockReturnValue(policy) + authService.authenticate.mockResolvedValue({ userId: 'member1', adminPermissions: 0n }) + loader.load.mockResolvedValue(project) + const request = { userId: 'member1', adminPermissions: 0n, params: { projectId: 'p1' } } + const ctx = makeExecutionContext(request) + + const result = await guard.canActivate(ctx) + + expect(result).toBe(true) + expect(loader.load).toHaveBeenCalledWith(request, 'member1', expect.anything()) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts new file mode 100644 index 0000000000..b5ef931868 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts @@ -0,0 +1,94 @@ +import type { CanActivate, ExecutionContext } from '@nestjs/common' +import type { Project } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../../auth/auth-user.decorator' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { AuthService } from '../../auth/auth.service' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' + +export interface ProjectContext { + id: string + slug: string + locked?: boolean + status?: Project['status'] + projectPermissions?: bigint +} + +export interface RequestWithProjectContext extends FastifyRequest { + project?: ProjectContext +} + +type RequestWithUserContext = FastifyRequest & { + userId?: string + adminPermissions?: bigint + userType?: string +} + +interface ProjectParams { + projectId?: string + projectSlug?: string +} + +@Injectable() +export class ProjectGuard implements CanActivate { + private readonly logger = new Logger(ProjectGuard.name) + + constructor( + @Inject(AuthService) private readonly authService: AuthService, + @Inject(ProjectPermissionService) private readonly projectService: ProjectPermissionService, + @Inject(ProjectPermissionLoaderService) private readonly loader: ProjectPermissionLoaderService, + @Inject(ProjectPermissionPolicy) private readonly projectPolicy: ProjectPermissionPolicy, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const policy = this.projectPolicy.build(context) + const request = this.getRequest(context) + const user = await this.authenticate(request) + const project = await this.loadProject(request, user.userId) + + this.projectService.validate(policy, project, user) + + return true + } + + private getRequest(context: ExecutionContext): RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }> { + return context.switchToHttp().getRequest< + RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }> + >() + } + + private async authenticate( + request: RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }>, + ): Promise { + try { + const user = await this.authService.authenticate(request, { + includeAdminRoleIds: true, + includeUserType: true, + }) + + request.userId = user.userId + if (user.adminPermissions !== undefined) request.adminPermissions = user.adminPermissions + if (user.userType !== undefined) request.userType = user.userType + + return user + } catch (error) { + this.logger.warn(`Project access auth rejected (requestId=${request.id}, error=${error instanceof Error ? error.message : String(error)})`) + throw error + } + } + + private async loadProject( + request: RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }>, + userId: string, + ): Promise { + const project = await this.loader.load(request, userId, { + includeStatus: true, + includeLocked: true, + includePermissions: true, + }) + request.project = project + return project + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts new file mode 100644 index 0000000000..6561dd78dd --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../../auth/auth.module' +import { DatabaseModule } from '../../database/database.module' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectGuard } from './project.guard' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + ], + providers: [ + ProjectGuard, + ProjectPermissionLoaderService, + ProjectPermissionService, + ProjectPermissionPolicy, + ], + exports: [ + ProjectGuard, + ProjectPermissionLoaderService, + ProjectPermissionService, + ProjectPermissionPolicy, + ], +}) +export class ProjectPermissionModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts new file mode 100644 index 0000000000..9d4098d93e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts @@ -0,0 +1,37 @@ +import type { AdminAuthorized, ProjectAuthorized } from '@cpn-console/shared' +import type { ExecutionContext } from '@nestjs/common' +import type { Project, User } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { ADMIN_PERMISSIONS_KEY } from '../user/user-admin-permission.decorator' +import { USER_TYPES_KEY } from '../user/user-type.decorator' +import { PROJECT_ACCESS_KEY } from './project-access.decorator' +import { PROJECT_LOCKED_KEY } from './project-locked.decorator' +import { PROJECT_PERMISSION_KEY } from './project-permission.decorator' +import { PROJECT_STATUS_KEY } from './project-status.decorator' + +export interface ProjectPolicyConfig { + adminPermissions: (keyof typeof AdminAuthorized)[] + userTypes: User['type'][] + projectPermissions: (keyof typeof ProjectAuthorized)[] + projectStatuses: Project['status'][] + projectLocked?: boolean + projectAccess: boolean +} + +@Injectable() +export class ProjectPermissionPolicy { + constructor(@Inject(Reflector) private readonly reflector: Reflector) {} + + build(context: ExecutionContext): ProjectPolicyConfig { + const targets = [context.getHandler(), context.getClass()] + return { + adminPermissions: this.reflector.getAllAndOverride<(keyof typeof AdminAuthorized)[] | undefined>(ADMIN_PERMISSIONS_KEY, targets) ?? [], + userTypes: this.reflector.getAllAndOverride(USER_TYPES_KEY, targets) ?? [], + projectPermissions: this.reflector.getAllAndOverride<(keyof typeof ProjectAuthorized)[] | undefined>(PROJECT_PERMISSION_KEY, targets) ?? [], + projectStatuses: this.reflector.getAllAndOverride(PROJECT_STATUS_KEY, targets) ?? [], + projectLocked: this.reflector.getAllAndOverride(PROJECT_LOCKED_KEY, targets), + projectAccess: this.reflector.getAllAndOverride(PROJECT_ACCESS_KEY, targets) ?? false, + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts new file mode 100644 index 0000000000..f25771dd42 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts @@ -0,0 +1,72 @@ +import type { ProjectStatus, User } from '@prisma/client' +import type { UserContext } from '../../auth/auth-user.decorator' +import type { ProjectContext } from './project.guard' +import type { ProjectPolicyConfig } from './project.policy' +import { AdminAuthorized, ProjectAuthorized } from '@cpn-console/shared' +import { ForbiddenException, Injectable, Logger } from '@nestjs/common' + +@Injectable() +export class ProjectPermissionService { + private readonly logger = new Logger(ProjectPermissionService.name) + + validate(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + this.validateUserType(policy, user.userType) + this.validateProjectStatus(policy, project.status) + this.validateProjectLock(policy, project.locked) + this.validateProjectAccess(policy, project, user) + this.validateProjectPermissions(policy, project, user) + } + + validateProjectStatus(policy: ProjectPolicyConfig, projectStatus: ProjectStatus | undefined): void { + if (policy.projectStatuses.length > 0 && (!projectStatus || !policy.projectStatuses.includes(projectStatus))) { + this.logger.warn(`project auth denied: invalid project status (projectStatus=${projectStatus}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Statut de projet invalide') + } + } + + validateProjectLock(policy: ProjectPolicyConfig, projectLocked: boolean | undefined): void { + if (policy.projectLocked !== undefined && projectLocked !== policy.projectLocked) { + this.logger.warn(`project auth denied: invalid lock status (projectLocked=${projectLocked}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('État de verrouillage invalide') + } + } + + validateUserType(policy: ProjectPolicyConfig, userType: string | undefined): void { + if (!policy.userTypes.length) return + if (typeof userType !== 'string' || !policy.userTypes.includes(userType as User['type'])) { + this.logger.warn(`project auth denied: invalid user type (userType=${userType}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Cannot find requestor in request context') + } + } + + validateProjectAccess(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + if (!policy.projectAccess) return + + const adminPermissions = user.adminPermissions ?? 0n + const hasAccess = AdminAuthorized.Manage(adminPermissions) || Boolean(project.projectPermissions) + if (!hasAccess) { + this.logger.warn( + `project auth denied: missing project access (projectId=${project.id}, projectSlug=${project.slug}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, projectPermissions=${project.projectPermissions?.toString()}, policy=${JSON.stringify(policy)})`, + ) + throw new ForbiddenException('Permissions insuffisantes pour ce projet') + } + } + + validateProjectPermissions(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + if (!policy.projectPermissions.length) return + + const hasPermission = policy.projectPermissions.every( + permName => ProjectAuthorized[permName]({ + adminPermissions: user.adminPermissions, + projectPermissions: project.projectPermissions, + }), + ) + + if (!hasPermission) { + this.logger.warn( + `project auth denied: missing project permissions (projectId=${project.id}, projectSlug=${project.slug}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, projectPermissions=${project.projectPermissions?.toString()}, policy=${JSON.stringify(policy)})`, + ) + throw new ForbiddenException('Permissions insuffisantes pour ce projet') + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts new file mode 100644 index 0000000000..b2480f78cf --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts @@ -0,0 +1,37 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { HttpArgumentsHost } from '@nestjs/common/interfaces' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ProjectContext } from './project.guard' +import type { ProjectPolicyConfig } from './project.policy' +import { mockDeep } from 'vitest-mock-extended' + +export function makeExecutionContext(request: Record): DeepMockProxy { + const ctx = mockDeep() + const httpArgs = mockDeep() + httpArgs.getRequest.mockReturnValue(request) + ctx.switchToHttp.mockReturnValue(httpArgs) + return ctx +} + +export function makeProjectContext(overrides: Partial = {}): ProjectContext { + return { + id: 'p1', + slug: 'project', + locked: false, + status: 'created', + projectPermissions: 6n, + ...overrides, + } +} + +export function makeProjectPolicy(overrides: Partial = {}): ProjectPolicyConfig { + return { + adminPermissions: [], + userTypes: [], + projectPermissions: [], + projectStatuses: [], + projectLocked: undefined, + projectAccess: false, + ...overrides, + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts new file mode 100644 index 0000000000..ab70852172 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts @@ -0,0 +1,8 @@ +import type { AdminAuthorized } from '@cpn-console/shared' +import { SetMetadata } from '@nestjs/common' + +export const ADMIN_PERMISSIONS_KEY = 'admin-permissions' + +export function RequireAdminPermission(...permissions: (keyof typeof AdminAuthorized)[]) { + return SetMetadata(ADMIN_PERMISSIONS_KEY, permissions) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts new file mode 100644 index 0000000000..c2e004cc46 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts @@ -0,0 +1,25 @@ +import type { AdminAuthorized } from '@cpn-console/shared' +import type { ExecutionContext } from '@nestjs/common' +import type { User as PrismaUser } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { ADMIN_PERMISSIONS_KEY } from './user-admin-permission.decorator' +import { USER_TYPES_KEY } from './user-type.decorator' + +export interface UserPolicyConfig { + adminPermissions: (keyof typeof AdminAuthorized)[] + userTypes: PrismaUser['type'][] +} + +@Injectable() +export class UserPermissionPolicy { + constructor(@Inject(Reflector) private readonly reflector: Reflector) {} + + build(context: ExecutionContext): UserPolicyConfig { + const targets = [context.getHandler(), context.getClass()] + return { + adminPermissions: this.reflector.getAllAndOverride<(keyof typeof AdminAuthorized)[] | undefined>(ADMIN_PERMISSIONS_KEY, targets) ?? [], + userTypes: this.reflector.getAllAndOverride(USER_TYPES_KEY, targets) ?? [], + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts new file mode 100644 index 0000000000..dc6b09af03 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts @@ -0,0 +1,17 @@ +import type { UserContext } from '../../auth/auth-user.decorator' +import type { UserPolicyConfig } from './user-policy.service' + +export function makeUserPolicy(overrides: Partial = {}): UserPolicyConfig { + return { + adminPermissions: [], + userTypes: [], + ...overrides, + } +} + +export function makeUserContext(overrides: Partial = {}): UserContext { + return { + userId: 'test-user', + ...overrides, + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts new file mode 100644 index 0000000000..ee14dbbb38 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts @@ -0,0 +1,8 @@ +import type { User } from '@prisma/client' +import { SetMetadata } from '@nestjs/common' + +export const USER_TYPES_KEY = 'user-types' + +export function RequireUserType(...types: User['type'][]) { + return SetMetadata(USER_TYPES_KEY, types) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts new file mode 100644 index 0000000000..447b77477e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts @@ -0,0 +1,105 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserPolicyConfig } from './user-policy.service' +import { ForbiddenException, UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeExecutionContext } from '../../auth/auth-testing.utils' +import { AuthService } from '../../auth/auth.service' +import { UserPermissionPolicy } from './user-policy.service' +import { UserGuard } from './user.guard' +import { UserPermissionService } from './user.service' + +describe('userGuard', () => { + let module: TestingModule + let guard: UserGuard + let authService: DeepMockProxy + let userService: DeepMockProxy + let userPolicy: DeepMockProxy + + beforeEach(async () => { + authService = mockDeep() + userService = mockDeep() + userPolicy = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + UserGuard, + { provide: AuthService, useValue: authService }, + { provide: UserPermissionService, useValue: userService }, + { provide: UserPermissionPolicy, useValue: userPolicy }, + ], + }).compile() + + guard = module.get(UserGuard) + }) + + it('throws 401 when the auth service rejects the request', async () => { + authService.authenticate.mockRejectedValue(new UnauthorizedException()) + userPolicy.build.mockReturnValue({ adminPermissions: [], userTypes: [] }) + const ctx = makeExecutionContext({}) + + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException) + }) + + it('authenticates without loading optional auth data when no policy metadata is set', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1' }) + userPolicy.build.mockReturnValue({ adminPermissions: [], userTypes: [] }) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + const result = await guard.canActivate(ctx) + const request = ctx.switchToHttp().getRequest() + + expect(result).toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(request.userId).toBe('u1') + }) + + it('validates required admin permissions', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 32768n }) + const policy: UserPolicyConfig = { adminPermissions: ['ListSystem'], userTypes: [] } + userPolicy.build.mockReturnValue(policy) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + await expect(guard.canActivate(ctx)).resolves.toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(userService.validate).toHaveBeenCalled() + }) + + it('throws 403 when required admin permissions are missing', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 0n }) + const policy: UserPolicyConfig = { adminPermissions: ['ManageSystem'], userTypes: [] } + userPolicy.build.mockReturnValue(policy) + userService.validate.mockImplementation(() => { throw new ForbiddenException() }) + const ctx = makeExecutionContext({ 'x-dso-token': 'tok' }) + + await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException) + }) + + it('validates required user types', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', userType: 'human' }) + const policy: UserPolicyConfig = { adminPermissions: [], userTypes: ['human'] } + userPolicy.build.mockReturnValue(policy) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + await expect(guard.canActivate(ctx)).resolves.toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(userService.validate).toHaveBeenCalled() + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts new file mode 100644 index 0000000000..28c339accd --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts @@ -0,0 +1,60 @@ +import type { CanActivate, ExecutionContext } from '@nestjs/common' +import type { FastifyRequest } from 'fastify' +import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common' +import { AuthService } from '../../auth/auth.service' +import { UserPermissionPolicy } from './user-policy.service' +import { UserPermissionService } from './user.service' + +type RequestWithAuthContext = FastifyRequest & { + userId?: string + adminPermissions?: bigint + userType?: string +} + +@Injectable() +export class UserGuard implements CanActivate { + private readonly logger = new Logger(UserGuard.name) + + constructor( + @Inject(AuthService) private readonly authService: AuthService, + @Inject(UserPermissionService) private readonly userService: UserPermissionService, + @Inject(UserPermissionPolicy) private readonly userPolicy: UserPermissionPolicy, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const policy = this.userPolicy.build(context) + const request = this.getRequest(context) + const user = await this.authenticate(request) + + this.validate(policy, user) + this.logger.debug(`User auth granted (requestId=${request.id}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, userType=${user.userType})`) + + return true + } + + private getRequest(context: ExecutionContext): RequestWithAuthContext { + return context.switchToHttp().getRequest() + } + + private async authenticate(request: RequestWithAuthContext) { + try { + const user = await this.authService.authenticate(request, { + includeAdminRoleIds: true, + includeUserType: true, + }) + + request.userId = user.userId + if (user.adminPermissions !== undefined) request.adminPermissions = user.adminPermissions + if (user.userType !== undefined) request.userType = user.userType + + return user + } catch (error) { + this.logger.warn(`User auth ${error instanceof UnauthorizedException ? 'rejected' : 'denied'} (requestId=${request.id}, error=${error instanceof Error ? error.message : String(error)})`) + throw error + } + } + + private validate(policy: ReturnType, user: Awaited>) { + this.userService.validate(policy, user) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts new file mode 100644 index 0000000000..613821ed2a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../../auth/auth.module' +import { DatabaseModule } from '../../database/database.module' +import { UserPermissionPolicy } from './user-policy.service' +import { UserGuard } from './user.guard' +import { UserPermissionService } from './user.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + ], + providers: [ + UserGuard, + UserPermissionService, + UserPermissionPolicy, + ], + exports: [ + UserGuard, + UserPermissionService, + UserPermissionPolicy, + ], +}) +export class UserPermissionModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts new file mode 100644 index 0000000000..4ad5b84704 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts @@ -0,0 +1,73 @@ +import type { UserContext } from '../../auth/auth-user.decorator' +import { AdminAuthorized } from '@cpn-console/shared' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeUserContext, makeUserPolicy } from './user-testing.utils' +import { UserPermissionService } from './user.service' + +describe('userService', () => { + let service: UserPermissionService + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [UserPermissionService], + }).compile() + + service = module.get(UserPermissionService) + }) + + describe('validateAdminPermissions', () => { + it('does not throw when no admin permissions are required', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({}), 0n)).not.toThrow() + }) + + it('does not throw when the user has the required admin permission', () => { + const adminPerms = AdminAuthorized.ListSystem(32768n) ? 32768n : 0n + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ListSystem'] }), adminPerms)).not.toThrow() + }) + + it('throws ForbiddenException when the user lacks the required admin permission', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ManageSystem'] }), 0n)).toThrow(ForbiddenException) + }) + + it('validates multiple required admin permissions', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ManageSystem', 'ListSystem'] }), 0n)).toThrow(ForbiddenException) + }) + + it('treats undefined adminPermissions as 0n', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['Manage'] }), undefined)).toThrow(ForbiddenException) + }) + }) + + describe('validateUserType', () => { + it('does not throw when no user types are required', () => { + expect(() => service.validateUserType(makeUserPolicy({}), 'human')).not.toThrow() + }) + + it('does not throw when the user type is in the allowed list', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), 'human')).not.toThrow() + }) + + it('throws ForbiddenException when the user type is not in the allowed list', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), 'service')).toThrow(ForbiddenException) + }) + + it('throws ForbiddenException when userType is undefined', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), undefined)).toThrow(ForbiddenException) + }) + }) + + describe('validate', () => { + it('delegates to validateAdminPermissions and validateUserType', () => { + const spyPerms = vi.spyOn(service, 'validateAdminPermissions') + const spyType = vi.spyOn(service, 'validateUserType') + const user: UserContext = makeUserContext({ adminPermissions: 1n, userType: 'human' }) + + service.validate(makeUserPolicy({}), user) + + expect(spyPerms).toHaveBeenCalled() + expect(spyType).toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts new file mode 100644 index 0000000000..3784e2feb6 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts @@ -0,0 +1,36 @@ +import type { User as PrismaUser } from '@prisma/client' +import type { UserContext } from '../../auth/auth-user.decorator' +import type { UserPolicyConfig } from './user-policy.service' +import { AdminAuthorized } from '@cpn-console/shared' +import { ForbiddenException, Injectable, Logger } from '@nestjs/common' + +@Injectable() +export class UserPermissionService { + private readonly logger = new Logger(UserPermissionService.name) + + validate(policy: UserPolicyConfig, user: UserContext): void { + this.validateAdminPermissions(policy, user.adminPermissions) + this.validateUserType(policy, user.userType) + } + + validateAdminPermissions(policy: UserPolicyConfig, adminPermissions: bigint | undefined): void { + if (!policy.adminPermissions.length) return + + const hasPermission = policy.adminPermissions.every( + permName => AdminAuthorized[permName](adminPermissions ?? 0n), + ) + + if (!hasPermission) { + this.logger.warn(`User auth denied: missing admin permissions (adminPermissions=${adminPermissions?.toString()}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Permissions administrateur insuffisantes') + } + } + + validateUserType(policy: UserPolicyConfig, userType: string | undefined): void { + if (!policy.userTypes.length) return + if (typeof userType !== 'string' || !policy.userTypes.includes(userType as PrismaUser['type'])) { + this.logger.warn(`User auth denied: invalid user type (userType=${userType}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Cannot find requestor in request context') + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts new file mode 100644 index 0000000000..6fd1db8ee6 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts @@ -0,0 +1,100 @@ +import { BadRequestException } from '@nestjs/common' +import { beforeEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { ZodValidationPipe } from './zod-validation.pipe' + +describe('zodValidationPipe', () => { + const schema = z.object({ + name: z.string(), + age: z.number().int().positive(), + }) + + let pipe: ZodValidationPipe + + beforeEach(() => { + pipe = new ZodValidationPipe(schema) + }) + + describe('transform', () => { + it('should return validated data when input is valid', () => { + const validInput = { + name: 'Alice', + age: 30, + } + + const result = pipe.transform(validInput) + + expect(result).toEqual(validInput) + }) + + it('should strip unknown fields if schema does not allow them', () => { + const input = { + name: 'Alice', + age: 30, + extra: 'remove me', + } + + const result = pipe.transform(input) + + expect(result).toEqual({ + name: 'Alice', + age: 30, + }) + }) + + it('should throw BadRequestException when required fields are missing', () => { + const invalidInput = { + name: 'Alice', + } + + expect(() => pipe.transform(invalidInput)).toThrow( + BadRequestException, + ) + }) + + it('should throw BadRequestException when field types are invalid', () => { + const invalidInput = { + name: 'Alice', + age: '30', + } + + expect(() => pipe.transform(invalidInput)).toThrow( + BadRequestException, + ) + }) + + it('should include flattened Zod errors in the exception response', () => { + const invalidInput = { + name: 123, + age: -5, + } + + try { + pipe.transform(invalidInput) + throw new Error('Expected transform to throw') + } catch (error) { + expect(error).toBeInstanceOf(BadRequestException) + + if (error instanceof BadRequestException) { + const response = error.getResponse() as any + + expect(response).toHaveProperty('fieldErrors') + expect(response.fieldErrors).toHaveProperty('name') + expect(response.fieldErrors).toHaveProperty('age') + } + } + }) + + it('should throw when input is null', () => { + expect(() => pipe.transform(null)).toThrow( + BadRequestException, + ) + }) + + it('should throw when input is undefined', () => { + expect(() => pipe.transform(undefined)).toThrow( + BadRequestException, + ) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts new file mode 100644 index 0000000000..066fa255d4 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts @@ -0,0 +1,17 @@ +import type { PipeTransform } from '@nestjs/common' +import type { ZodSchema } from 'zod' +import { BadRequestException } from '@nestjs/common' + +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: ZodSchema) {} + + transform(value: unknown) { + const result = this.schema.safeParse(value) + + if (!result.success) { + throw new BadRequestException(result.error.flatten()) + } + + return result.data + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts new file mode 100644 index 0000000000..3056b5bcb1 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts @@ -0,0 +1,2 @@ +export const TRACER_NAME = '@cpn-console/server-nestjs' +export const SERVICE_NAME = 'console-pi-native-console' diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts new file mode 100644 index 0000000000..950f09d0cc --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts @@ -0,0 +1,74 @@ +import type { Span as OpenTelemetrySpan, SpanOptions } from '@opentelemetry/api' +import { SpanStatusCode, trace } from '@opentelemetry/api' +import { TRACER_NAME } from './telemetry.constants' + +export type TypedMethodDecorator = any>( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, +) => void + +export function StartActiveSpan(options?: SpanOptions): TypedMethodDecorator { + return any>( + _target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ): void => { + const original = descriptor.value + if (!original) return + + descriptor.value = function (this: ThisParameterType, ...args: Parameters): ReturnType { + const tracer = trace.getTracer(TRACER_NAME) + const className = this?.constructor?.name ?? 'Unknown' + const spanName = `${className}.${String(propertyKey)}` + + const runInActiveSpan = (span: OpenTelemetrySpan) => { + try { + const result = original.apply(this, args) + + if (isPromiseLike(result)) { + return handlePromiseResult(span, result) as ReturnType + } + + span.end() + return result + } catch (error) { + recordException(span, error) + span.end() + throw error + } + } + + if (options) { + return tracer.startActiveSpan(spanName, options, runInActiveSpan) as ReturnType + } + return tracer.startActiveSpan(spanName, runInActiveSpan) as ReturnType + } as T + } +} + +function isPromiseLike(value: unknown): value is Promise { + if (!value) return false + return typeof (value as Promise).then === 'function' +} + +async function handlePromiseResult(span: OpenTelemetrySpan, promise: Promise): Promise { + try { + return await promise + } catch (error) { + recordException(span, error) + throw error + } finally { + span.end() + } +} + +function recordException(span: OpenTelemetrySpan, error: unknown): void { + // If it's an actual Error object, OpenTelemetry captures the stack trace automatically + if (error instanceof Error) { + span.recordException(error) + } else { + span.recordException(String(error)) + } + span.setStatus({ code: SpanStatusCode.ERROR }) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts new file mode 100644 index 0000000000..f30d6c6af7 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts @@ -0,0 +1,251 @@ +import type { TestingModule } from '@nestjs/testing' +import type { KeycloakConfig } from './keycloak.module-definition' +import KcAdminClient from '@keycloak/keycloak-admin-client' +import { ScheduleModule } from '@nestjs/schedule' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' +import { ADMIN_TOKEN_REFRESH_INTERVAL_MS } from './keycloak.constants' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +const keycloakUrl = 'https://keycloak.internal' +const projectRealm = 'project-realm' +// The only handled token endpoint is the master realm's: a token request +// against any other realm is unhandled and fails the test, which enforces +// the realm pinning of authenticate() +const tokenUrl = `${keycloakUrl}/realms/master/protocol/openid-connect/token` +const childrenUrl = `${keycloakUrl}/admin/realms/${projectRealm}/groups/:parentId/children` + +const server = setupServer() + +function useTokenEndpoint({ rejectGrant = () => false }: { rejectGrant?: (grantType: string | null) => boolean } = {}) { + const tokenRequests: URLSearchParams[] = [] + let issued = 0 + server.use( + http.post(tokenUrl, async ({ request }) => { + const body = new URLSearchParams(await request.text()) + tokenRequests.push(body) + if (rejectGrant(body.get('grant_type'))) { + return HttpResponse.json({ error: 'invalid_grant' }, { status: 401 }) + } + issued++ + return HttpResponse.json({ access_token: `access-token-${issued}`, refresh_token: `refresh-token-${issued}`, expires_in: 60 }) + }), + ) + return tokenRequests +} + +function createKeycloakClientServiceTestingModule(config: Partial = {}) { + return Test.createTestingModule({ + imports: [ScheduleModule.forRoot()], + providers: [ + KeycloakClientService, + { provide: KEYCLOAK_ADMIN_CLIENT, useValue: new KcAdminClient({ baseUrl: keycloakUrl }) }, + { + provide: KEYCLOAK_CONFIG, + useValue: mockDeep({ + realm: projectRealm, + admin: 'admin', + adminPassword: 'admin-password', + adminClientId: 'admin-cli', + ...config, + }), + }, + ], + }) +} + +describe('keycloakClientService authentication lifecycle', () => { + let module: TestingModule + let client: KcAdminClient + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + vi.useFakeTimers() + module = await createKeycloakClientServiceTestingModule().compile() + client = module.get(KEYCLOAK_ADMIN_CLIENT) + }) + afterEach(async () => { + // close() re-runs init() on a module whose init() already failed; swallow + // that rethrow so the "initial authentication fails" test can clean up + await module.close().catch(() => {}) + server.resetHandlers() + vi.useRealTimers() + }) + afterAll(() => server.close()) + + it('should authenticate with the password grant then switch to the project realm', async () => { + const tokenRequests = useTokenEndpoint() + + await module.init() + + expect(tokenRequests).toHaveLength(1) + expect(Object.fromEntries(tokenRequests[0])).toMatchObject({ + grant_type: 'password', + client_id: 'admin-cli', + username: 'admin', + password: 'admin-password', + }) + expect(client.accessToken).toBe('access-token-1') + expect(client.realmName).toBe(projectRealm) + }) + + it('should refresh the token periodically with the rotated refresh token so it never expires', async () => { + const tokenRequests = useTokenEndpoint() + await module.init() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(2) + expect(Object.fromEntries(tokenRequests[1])).toMatchObject({ + grant_type: 'refresh_token', + client_id: 'admin-cli', + refresh_token: 'refresh-token-1', + }) + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(3) + expect(tokenRequests[2].get('refresh_token')).toBe('refresh-token-2') + expect(client.accessToken).toBe('access-token-3') + expect(client.realmName).toBe(projectRealm) + }) + + it('should fall back to a full re-authentication when the refresh grant fails', async () => { + const tokenRequests = useTokenEndpoint({ rejectGrant: grantType => grantType === 'refresh_token' }) + await module.init() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + + expect(tokenRequests.map(body => body.get('grant_type'))).toEqual(['password', 'refresh_token', 'password']) + expect(client.accessToken).toBe('access-token-2') + expect(client.realmName).toBe(projectRealm) + }) + + it('should keep refreshing and restore the project realm when both grants fail', async () => { + let keycloakIsDown = false + const tokenRequests = useTokenEndpoint({ rejectGrant: () => keycloakIsDown }) + await module.init() + + keycloakIsDown = true + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests.map(body => body.get('grant_type'))).toEqual(['password', 'refresh_token', 'password']) + expect(client.realmName).toBe(projectRealm) + + keycloakIsDown = false + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(4) + expect(tokenRequests[3].get('grant_type')).toBe('refresh_token') + expect(client.accessToken).toBe('access-token-2') + }) + + it('should stop refreshing the token on module destroy', async () => { + const tokenRequests = useTokenEndpoint() + await module.init() + expect(tokenRequests).toHaveLength(1) + + await module.close() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 3) + expect(tokenRequests).toHaveLength(1) + }) + + it('should rethrow when the initial authentication fails', async () => { + const tokenRequests = useTokenEndpoint({ rejectGrant: () => true }) + + await expect(module.init()).rejects.toThrow('invalid_grant') + expect(client.realmName).toBe('master') + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) + expect(tokenRequests).toHaveLength(1) + }) +}) + +describe('getOrCreateSubGroupByName', () => { + let module: TestingModule + let service: KeycloakClientService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + module = await createKeycloakClientServiceTestingModule().compile() + service = module.get(KeycloakClientService) + useTokenEndpoint() + await module.init() + }) + afterEach(async () => { + await module.close() + server.resetHandlers() + }) + afterAll(() => server.close()) + + it('should return the existing subgroup without creating it', async () => { + // No POST handler: a create attempt would fail the test + server.use( + http.get(childrenUrl, ({ params }) => { + expect(params.parentId).toBe('parent-id') + return HttpResponse.json([{ id: 'sub-id', name: 'sub' }]) + }), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toMatchObject({ id: 'sub-id', name: 'sub' }) + }) + + it('should create the subgroup when it does not exist', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([])), + http.post(childrenUrl, async ({ request, params }) => { + expect(params.parentId).toBe('parent-id') + expect(await request.json()).toEqual({ name: 'sub' }) + return new HttpResponse(null, { + status: 201, + headers: { location: `${keycloakUrl}/admin/realms/${projectRealm}/groups/created-id` }, + }) + }), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toEqual({ id: 'created-id', name: 'sub' }) + }) + + it('should re-fetch the subgroup when a concurrent creation causes a 409', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([]), { once: true }), + http.get(childrenUrl, () => HttpResponse.json([{ id: 'concurrent-id', name: 'sub' }])), + http.post(childrenUrl, () => + HttpResponse.json({ errorMessage: 'Sibling group named \'sub\' already exists.' }, { status: 409 })), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toMatchObject({ id: 'concurrent-id', name: 'sub' }) + }) + + it('should rethrow the 409 when the subgroup still cannot be found', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([])), + http.post(childrenUrl, () => + HttpResponse.json({ errorMessage: 'Sibling group named \'sub\' already exists.' }, { status: 409 })), + ) + + await expect(service.getOrCreateSubGroupByName('parent-id', 'sub')).rejects.toThrow('Sibling group named') + }) + + it('should rethrow non-409 errors without re-fetching', async () => { + let listCalls = 0 + server.use( + http.get(childrenUrl, () => { + listCalls++ + return HttpResponse.json([]) + }), + http.post(childrenUrl, () => + HttpResponse.json({ error: 'unauthorized' }, { status: 401 })), + ) + + await expect(service.getOrCreateSubGroupByName('parent-id', 'sub')).rejects.toThrow('unauthorized') + expect(listCalls).toBe(1) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts new file mode 100644 index 0000000000..bb106d64e2 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -0,0 +1,343 @@ +import type KcAdminClient from '@keycloak/keycloak-admin-client' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { Credentials } from '@keycloak/keycloak-admin-client/lib/utils/auth' +import type { OnModuleInit } from '@nestjs/common' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import type { KeycloakConfig } from './keycloak.module-definition' +import type { GroupRepresentationWith } from './keycloak.utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { Interval } from '@nestjs/schedule' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { getErrorResponseStatus } from '../../utils/http.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { ADMIN_AUTH_REALM, ADMIN_TOKEN_REFRESH_INTERVAL_MS, CONSOLE_GROUP_NAME, PASSWORD_GRANT_TYPE, REFRESH_TOKEN_GRANT_TYPE, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +export const KEYCLOAK_ADMIN_CLIENT = Symbol('KEYCLOAK_ADMIN_CLIENT') + +@Injectable() +export class KeycloakClientService implements OnModuleInit { + private readonly logger = new Logger(KeycloakClientService.name) + + private authenticated = false + + constructor( + @Inject(KEYCLOAK_CONFIG) private readonly keycloakConfig: KeycloakConfig, + @Inject(KEYCLOAK_ADMIN_CLIENT) private readonly client: KcAdminClient, + ) { + } + + async* getAllGroups() { + let first = 0 + while (true) { + const fetched = await this.client.groups.find({ first, max: SUBGROUPS_PAGINATE_QUERY_MAX, briefRepresentation: false }) + this.logger.verbose(`Loaded a Keycloak groups page (first=${first}, count=${fetched.length})`) + if (fetched.length === 0) break + for (const group of fetched) { + yield group + } + if (fetched.length < SUBGROUPS_PAGINATE_QUERY_MAX) break + first += SUBGROUPS_PAGINATE_QUERY_MAX + } + } + + @StartActiveSpan() + async getGroupByName(name: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + const groups = await this.client.groups.find({ search: name, briefRepresentation: false }) ?? [] + return groups.find(g => g.name === name) + } + + async getGroupByPath(path: string): Promise { + const parts = path.split('/').filter(Boolean) + this.logger.verbose(`Resolving Keycloak group path ${path} (depth=${parts.length})`) + let current: GroupRepresentationWith<'id'> | undefined + if (parts.length === 0) return undefined + + for (const name of parts) { + current = current + ? await this.getSubGroupByName(current.id, name) + : await this.getRootGroupByName(name) + + if (!current) { + this.logger.verbose(`Keycloak group path segment was not found (path=${path}, missing=${name})`) + return undefined + } + } + this.logger.verbose(`Keycloak group path resolved (path=${path}, groupId=${current?.id})`) + return current + } + + private async getSubGroupByName(parentId: string, name: string): Promise | undefined> { + for await (const subgroup of this.getSubGroups(parentId)) { + if (subgroup.name === name) { + const parsed = z.object({ id: z.string() }).and(z.record(z.string(), z.unknown())).safeParse(subgroup) + return parsed.success ? parsed.data : undefined + } + } + return undefined + } + + private async getRootGroupByName(name: string): Promise | undefined> { + const candidates = await this.client.groups.find({ search: name, briefRepresentation: false }) ?? [] + const match = candidates.find(g => g.path === `/${name}`) ?? candidates.find(g => g.name === name) + const parsed = z.object({ id: z.string() }).and(z.record(z.string(), z.unknown())).safeParse(match) + return parsed.success ? parsed.data : undefined + } + + async deleteGroup(id: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', id) + this.logger.log(`Deleting Keycloak group (groupId=${id})`) + await this.client.groups.del({ id }) + } + + async getGroupMembers(groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + const members = await this.client.groups.listMembers({ id: groupId }) + this.logger.verbose(`Loaded Keycloak group members (groupId=${groupId}, count=${members?.length ?? 0})`) + return members || [] + } + + async getUserByEmail(email: string): Promise { + const users = await this.client.users.find({ + email, + exact: true, + max: 1, + }) + return users[0] + } + + @StartActiveSpan() + async createGroup(name: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + this.logger.debug(`Creating Keycloak group ${name}`) + const result = await this.client.groups.create({ name }) + return { ...result, name } as GroupRepresentation + } + + async addUserToGroup(userId: string, groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + this.logger.verbose(`Adding user to Keycloak group (userId=${userId}, groupId=${groupId})`) + return this.client.users.addToGroup({ id: userId, groupId }) + } + + async removeUserFromGroup(userId: string, groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + this.logger.verbose(`Removing user from Keycloak group (userId=${userId}, groupId=${groupId})`) + return this.client.users.delFromGroup({ id: userId, groupId }) + } + + async* getSubGroups(parentId: string) { + let first = 0 + while (true) { + const page = await this.client.groups.listSubGroups({ + parentId, + briefRepresentation: false, + max: SUBGROUPS_PAGINATE_QUERY_MAX, + first, + }) + this.logger.verbose(`Loaded a Keycloak subgroups page (parentId=${parentId}, first=${first}, count=${page.length})`) + if (page.length === 0) break + for (const subgroup of page) { + yield subgroup + } + if (page.length < SUBGROUPS_PAGINATE_QUERY_MAX) break + first += SUBGROUPS_PAGINATE_QUERY_MAX + } + } + + async getOrCreateGroupByPath(path: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.path.depth', path.split('/').filter(Boolean).length) + this.logger.verbose(`Ensuring Keycloak group path exists: ${path}`) + const existingGroup = await this.getGroupByPath(path) + if (existingGroup) { + this.logger.verbose(`Keycloak group already exists at path ${path}`) + return existingGroup + } + + const parts = path.split('/').filter(Boolean) + let parentId: string | undefined + let current: GroupRepresentationWith<'id' | 'name'> | undefined + + for (const name of parts.values()) { + if (current) { + if (!parentId) parentId = current.id + const next = z.object({ id: z.string(), name: z.string() }).safeParse(await this.getOrCreateSubGroupByName(parentId, name)) + if (next.success) current = next.data + } else { + const next = z.object({ id: z.string(), name: z.string() }).safeParse(await this.getGroupByName(name) ?? await this.createGroup(name)) + if (next.success) current = next.data + } + parentId = current?.id + } + + if (current) { + this.logger.log(`Created Keycloak group path ${path} (groupId=${current.id})`) + } + return { ...current, path } as GroupRepresentation + } + + @StartActiveSpan() + async getOrCreateSubGroupByName(parentId: string, name: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + span?.setAttribute('parent.id', parentId) + const existing = await this.findSubGroupByName(parentId, name) + if (existing) return existing + + this.logger.debug(`Creating Keycloak subgroup ${name} under parentId=${parentId}`) + try { + const createdGroup = await this.client.groups.createChildGroup({ id: parentId }, { name }) + return { id: createdGroup.id, name } satisfies GroupRepresentation + } catch (err) { + // A concurrent reconciliation may have created the subgroup between the + // scan and the create; treat the 409 as "already exists" and re-fetch it + if (getErrorResponseStatus(err) !== 409) throw err + this.logger.verbose(`Keycloak subgroup ${name} was created concurrently under parentId=${parentId}, fetching it`) + const subgroup = await this.findSubGroupByName(parentId, name) + if (!subgroup) throw err + return subgroup + } + } + + private async findSubGroupByName(parentId: string, name: string): Promise { + for await (const subgroup of this.getSubGroups(parentId)) { + if (subgroup.name === name) { + return subgroup + } + } + return undefined + } + + async getOrCreateConsoleGroup(projectGroup: GroupRepresentationWith<'id'>) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', projectGroup.id) + this.logger.verbose(`Ensuring Keycloak console group exists (projectGroupId=${projectGroup.id})`) + return this.getOrCreateSubGroupByName(projectGroup.id, CONSOLE_GROUP_NAME) + } + + async getOrCreateRoleGroup( + consoleGroup: GroupRepresentationWith<'id' | 'name'>, + oidcGroup: string, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.group.id': consoleGroup.id, + 'role.oidc_group.present': !!oidcGroup, + 'role.oidc_group.depth': oidcGroup.split('/').filter(Boolean).length, + }) + const parts = oidcGroup.split('/').filter(Boolean) + if (parts.length === 0) { + throw new Error(`Invalid oidcGroup for project role: "${oidcGroup}"`) + } + + let current = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(consoleGroup.id, parts[0])) + + for (const name of parts.slice(1)) { + current = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(current.id, name)) + } + + return { ...current, path: `/${consoleGroup.name}/${parts.join('/')}` } satisfies GroupRepresentation + } + + async getOrCreateEnvironmentGroups(consoleGroup: GroupRepresentationWith<'id'>, environment: ProjectWithDetails['environments'][number]) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.group.id': consoleGroup.id, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const envGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(consoleGroup.id, environment.name)) + const [roGroup, rwGroup] = await Promise.all([ + this.getOrCreateSubGroupByName(envGroup.id, 'RO'), + this.getOrCreateSubGroupByName(envGroup.id, 'RW'), + ]) + this.logger.verbose(`Resolved Keycloak environment groups (consoleGroupId=${consoleGroup.id}, env=${environment.name}, envGroupId=${envGroup.id})`) + return { roGroup, rwGroup } + } + + async onModuleInit() { + try { + this.logger.log(`Authenticating Keycloak admin client (realm=${this.keycloakConfig.realm})`) + await this.authenticate(this.passwordCredentials()) + } catch (err) { + if (err instanceof Error) { + this.logger.error(`Keycloak Admin Client authentication failed: ${err.message}`, err.stack) + } else { + this.logger.error(`Keycloak Admin Client authentication failed: ${String(err)}`) + } + throw err + } + this.client.setConfig({ realmName: this.keycloakConfig.realm }) + this.authenticated = true + this.logger.log(`Keycloak Admin Client authenticated (realm=${this.keycloakConfig.realm})`) + } + + // The admin client never refreshes its token on its own; without this the + // access token expires (~60s) and every admin call fails with a 401 + @Interval(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + async refreshAdminToken() { + if (!this.authenticated) return + try { + await this.authenticate(this.refreshTokenCredentials()) + } catch (refreshErr) { + // The refresh token itself can expire or be revoked (e.g. Keycloak + // restart); fall back to a full re-authentication to recover + this.logger.warn(`Keycloak Admin Client token refresh failed, re-authenticating: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`) + try { + await this.authenticate(this.passwordCredentials()) + } catch (err) { + this.logger.error(`Keycloak Admin Client re-authentication failed: ${err instanceof Error ? err.message : String(err)}`) + } + } + } + + private passwordCredentials(): Credentials { + return { + clientId: this.keycloakConfig.adminClientId, + grantType: PASSWORD_GRANT_TYPE, + username: this.keycloakConfig.admin, + password: this.keycloakConfig.adminPassword, + } + } + + private refreshTokenCredentials(): Credentials { + return { + clientId: this.keycloakConfig.adminClientId, + grantType: REFRESH_TOKEN_GRANT_TYPE, + refreshToken: this.client.refreshToken, + } + } + + // auth() resolves the token endpoint from client.realmName, which onModuleInit + // switches to the project realm — the admin user lives in the master realm. + // Restore the realm even when auth fails: the still-valid previous token keeps + // serving admin calls, and those must target the project realm, not master + private async authenticate(credentials: Credentials) { + const realmName = this.client.realmName + this.client.setConfig({ realmName: ADMIN_AUTH_REALM }) + try { + await this.client.auth(credentials) + } finally { + this.client.setConfig({ realmName }) + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts new file mode 100644 index 0000000000..0c0a028836 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts @@ -0,0 +1,88 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + slug: true, + ownerId: true, + everyonePerms: true, + members: { + select: { + roleIds: true, + user: { + select: { + id: true, + email: true, + }, + }, + }, + }, + roles: { + select: { + id: true, + permissions: true, + oidcGroup: true, + type: true, + }, + }, + environments: { + select: { + id: true, + name: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export const adminRoleSelect = { + id: true, + oidcGroup: true, + type: true, +} satisfies Prisma.AdminRoleSelect + +export type AdminRoleWithDetails = Prisma.AdminRoleGetPayload<{ + select: typeof adminRoleSelect +}> + +export const userAdminRoleSelect = { + id: true, + adminRoleIds: true, +} satisfies Prisma.UserSelect + +export type UserWithAdminRoles = Prisma.UserGetPayload<{ + select: typeof userAdminRoleSelect +}> + +@Injectable() +export class KeycloakDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getAllAdminRoles(): Promise { + return this.prisma.adminRole.findMany({ + select: adminRoleSelect, + }) + } + + async getAllUsersWithAdminRoleIds(): Promise { + return this.prisma.user.findMany({ + select: userAdminRoleSelect, + }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts new file mode 100644 index 0000000000..6d78945601 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts @@ -0,0 +1,25 @@ +import type { KeycloakConfig } from './keycloak.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +@Injectable() +export class KeycloakHealthService { + constructor( + @Inject(KEYCLOAK_CONFIG) + private readonly keycloakConfig: KeycloakConfig, + @Inject(HealthIndicatorService) + private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts new file mode 100644 index 0000000000..a0b5e2dfac --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts @@ -0,0 +1,16 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Injectable } from '@nestjs/common' + +@Injectable() +export class KeycloakPluginService { + infos(): ServiceInfos { + return { + name: 'keycloak', + title: 'Keycloak', + config: { + global: [], + project: [], + }, + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts new file mode 100644 index 0000000000..17fb5c5f0d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts @@ -0,0 +1,87 @@ +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { ProjectWithDetails } from './keycloak-datastore.service' + +import { faker } from '@faker-js/faker' + +export function makeUserRepresentation( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + username: faker.internet.username(), + enabled: true, + ...overrides, + } satisfies UserRepresentation +} + +export function makeGroupRepresentation( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + path: `/${faker.word.noun()}`, + subGroups: [], + ...overrides, + } satisfies GroupRepresentation +} + +export function makeProjectUser( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + ...overrides, + } satisfies ProjectWithDetails['members'][number]['user'] +} + +export function makeProjectMember( + overrides: Partial = {}, +) { + return { + roleIds: [], + user: makeProjectUser(), + ...overrides, + } satisfies ProjectWithDetails['members'][number] +} + +export function makeProjectRole( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + permissions: 0n, + oidcGroup: '', + type: 'managed', + ...overrides, + } satisfies ProjectWithDetails['roles'][number] +} + +export function makeProjectEnvironment( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + ...overrides, + } satisfies ProjectWithDetails['environments'][number] +} + +export function makeProjectWithDetails( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(faker.word.words({ count: 2 })).toLowerCase(), + ownerId: faker.string.uuid(), + everyonePerms: 0n, + plugins: [], + members: [], + roles: [], + environments: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts new file mode 100644 index 0000000000..e964d9e74c --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts @@ -0,0 +1,20 @@ +// Keycloak plugin constants +export const PLUGIN_NAME = 'keycloak' + +// Name for the console group for admin and project roles +export const CONSOLE_GROUP_NAME = 'console' + +// Realm hosting the admin user used by the admin client (password grant on admin-cli) +export const ADMIN_AUTH_REALM = 'master' + +// OAuth2 grant types (RFC 6749) used to authenticate the admin client +export const PASSWORD_GRANT_TYPE = 'password' +export const REFRESH_TOKEN_GRANT_TYPE = 'refresh_token' + +// Keycloak's default access token lifespan is 60s; refresh well before it +// expires so a slow or delayed tick still lands within the lifespan +export const ADMIN_TOKEN_REFRESH_INTERVAL_MS = 45_000 + +// Maximum number of entities returned in a paginated query +export const GROUPS_PAGINATE_QUERY_MAX = 20 +export const SUBGROUPS_PAGINATE_QUERY_MAX = 20 diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts new file mode 100644 index 0000000000..8245a556b9 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts @@ -0,0 +1,29 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface KeycloakConfig { + protocol: string + domain?: string + publicProtocol: string + publicDomain?: string + realm?: string + clientId?: string + clientSecret?: string + admin?: string + adminPassword?: string + adminClientId: string + redirectUri?: string + jwksCacheTtlMs: number + jwksTimeoutMs: number + openidConfigurationCacheTtlMs: number + adminKcUserId: string[] + url?: string + realmUrl?: string + openidConfigurationUrl?: string +} + +export interface KeycloakModuleOptions extends KeycloakConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('keycloak') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as KEYCLOAK_CONFIG } diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts new file mode 100644 index 0000000000..7186de782a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts @@ -0,0 +1,31 @@ +import type { KeycloakConfig } from './keycloak.module-definition' +import KcAdminClient from '@keycloak/keycloak-admin-client' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { KeycloakHealthService } from './keycloak-health.service' +import { KeycloakPluginService } from './keycloak-plugin.service' +import { ConfigurableModuleClass, KEYCLOAK_CONFIG } from './keycloak.module-definition' +import { KeycloakService } from './keycloak.service' + +@Module({ + imports: [DatabaseModule, TerminusModule], + providers: [ + { + inject: [KEYCLOAK_CONFIG], + provide: KEYCLOAK_ADMIN_CLIENT, + useFactory: (config: KeycloakConfig) => new KcAdminClient({ + baseUrl: config.url, + }), + }, + KeycloakClientService, + KeycloakDatastoreService, + KeycloakHealthService, + KeycloakPluginService, + KeycloakService, + ], + exports: [KeycloakClientService, KeycloakHealthService, KeycloakPluginService, KeycloakService], +}) +export class KeycloakModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts new file mode 100644 index 0000000000..e5b116688a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -0,0 +1,450 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { AdminRoleWithDetails, ProjectWithDetails, UserWithAdminRoles } from './keycloak-datastore.service' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { + makeGroupRepresentation, + makeProjectEnvironment, + makeProjectMember, + makeProjectRole, + makeProjectUser, + makeProjectWithDetails, + makeUserRepresentation, +} from './keycloak-testing.utils' +import { KeycloakService } from './keycloak.service' + +describe('keycloakService', () => { + let service: KeycloakService + let keycloak: DeepMockProxy + let datastore: DeepMockProxy + + beforeEach(async () => { + keycloak = mockDeep({ + getOrCreateConsoleGroup: vi.fn().mockResolvedValue(makeGroupRepresentation({ id: 'console-group-id', name: 'console' })), + getOrCreateEnvironmentGroups: vi.fn().mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'rw-id', name: 'RW' }), + }), + getAllGroups: vi.fn().mockImplementation(async function* () { /* empty by default */ }), + deleteGroup: vi.fn().mockResolvedValue(undefined), + }) + datastore = mockDeep({ + getAllAdminRoles: vi.fn().mockResolvedValue([]), + getAllUsersWithAdminRoleIds: vi.fn().mockResolvedValue([]), + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + KeycloakService, + { provide: KeycloakClientService, useValue: keycloak }, + { provide: KeycloakDatastoreService, useValue: datastore }, + ], + }).compile() + + service = moduleRef.get(KeycloakService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('reconcile', () => { + const mockProject: ProjectWithDetails = makeProjectWithDetails({ + id: 'project-id', + slug: 'test-project', + ownerId: 'owner-id', + everyonePerms: 0n, + }) + + it('should not sync system external admin roles', async () => { + const adminRoles: AdminRoleWithDetails[] = [ + { id: 'admin-role-id', oidcGroup: '/console/system-external', type: 'system:external' }, + ] + const users: UserWithAdminRoles[] = [ + { id: 'user-1', adminRoleIds: ['admin-role-id'] }, + ] + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + + await service.handleCron() + + expect(keycloak.getOrCreateGroupByPath).not.toHaveBeenCalledWith('/console/system-external') + expect(keycloak.getGroupMembers).not.toHaveBeenCalled() + expect(keycloak.addUserToGroup).not.toHaveBeenCalled() + expect(keycloak.removeUserFromGroup).not.toHaveBeenCalled() + }) + + it('should sync admin role groups', async () => { + const adminRoles: AdminRoleWithDetails[] = [ + { id: 'admin-role-id', oidcGroup: '/console/admin', type: 'managed' }, + ] + const users: UserWithAdminRoles[] = [ + { id: 'user-1', adminRoleIds: ['admin-role-id'] }, + { id: 'user-2', adminRoleIds: [] }, + ] + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + + const adminGroup = makeGroupRepresentation({ id: 'kc-group-id', name: 'admin', path: '/console/admin' }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(adminGroup) + + keycloak.getGroupMembers.mockResolvedValue([ + makeUserRepresentation({ id: 'user-2' }), + ]) + + await service.handleCron() + + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/console/admin') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'kc-group-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'kc-group-id') + }) + + it('should purge orphans', async () => { + datastore.getAllProjects.mockResolvedValue([mockProject]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project', subGroups: [] }) + const orphanGroup = makeGroupRepresentation({ + id: 'orphan-id', + name: 'orphan-project', + subGroups: [makeGroupRepresentation({ name: 'console' })], + }) + + keycloak.getAllGroups.mockImplementation(async function* () { + yield projectGroup + yield orphanGroup + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(keycloak.getAllGroups).toHaveBeenCalled() + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/test-project') + expect(keycloak.deleteGroup).toHaveBeenCalledWith('orphan-id') + }) + + it('should sync project members', async () => { + const projectWithMembers = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: [], + }), + ], + }) + datastore.getAllProjects.mockResolvedValue([projectWithMembers]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + + // Current members: user-2 (extra), missing user-1 + keycloak.getGroupMembers.mockResolvedValue([ + makeUserRepresentation({ id: 'user-2', email: 'user2@example.com' }), + ]) + + keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should add missing member + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'group-id') + // Should add owner (missing in group members) + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('owner-id', 'group-id') + // Should remove extra member + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'group-id') + }) + + it('should sync OIDC role groups', async () => { + const roleWithOidc = makeProjectRole({ + id: 'role-oidc', + permissions: 0n, + oidcGroup: '/oidc-group', + type: 'managed', + }) + const projectWithRole = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-oidc'], + }), + ], + roles: [roleWithOidc], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRole]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const roleGroup = makeGroupRepresentation({ id: 'role-group-id', name: 'oidc-group', path: '/console/oidc-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockResolvedValue(roleGroup) + + // Project members: owner + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + // Role group members: user-2 (extra), missing user-1 + if (groupId === 'role-group-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2', email: 'user2@example.com' })]) + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should create/get role group + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/oidc-group') + // Should add user-1 to role group + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'role-group-id') + // Should remove user-2 from role group + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'role-group-id') + }) + + it('should sync environment groups', async () => { + const projectWithEnv = makeProjectWithDetails({ + ...mockProject, + environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], + }) + datastore.getAllProjects.mockResolvedValue([projectWithEnv]) + + const projectGroup = makeGroupRepresentation({ + id: 'group-id', + name: 'test-project', + subGroups: [makeGroupRepresentation({ name: 'console', id: 'console-id' })], + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + + // Mock console group retrieval + keycloak.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' }), + }) + keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + if (name === 'dev') return Promise.resolve(makeGroupRepresentation({ id: 'dev-id', name: 'dev' })) + if (name === 'RO') return Promise.resolve(makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' })) + if (name === 'RW') return Promise.resolve(makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' })) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name })) + }) + + // Mock existing environments: 'staging' (extra) + keycloak.getSubGroups.mockImplementation(async function* (parentId) { + if (parentId === 'console-id') { + yield makeGroupRepresentation({ id: 'staging-id', name: 'staging' }) + } + if (parentId === 'staging-id') { + yield makeGroupRepresentation({ name: 'RO' }) + yield makeGroupRepresentation({ name: 'RW' }) + } + }) + + await service.handleCron() + + // Should create dev group + expect(keycloak.getOrCreateConsoleGroup).toHaveBeenCalledWith({ id: 'group-id', name: 'test-project' }) + // Should create RO/RW groups + expect(keycloak.getOrCreateEnvironmentGroups).toHaveBeenCalledWith({ id: 'console-id', name: 'console' }, projectWithEnv.environments[0]) + // Should delete staging group + expect(keycloak.deleteGroup).toHaveBeenCalledWith('staging-id') + }) + + it('should sync environment permissions', async () => { + const userRo = makeUserRepresentation({ id: 'user-ro', email: 'ro@example.com' }) + const userRw = makeUserRepresentation({ id: 'user-rw', email: 'rw@example.com' }) + const userNone = makeUserRepresentation({ id: 'user-none', email: 'none@example.com' }) + + const projectWithEnvAndMembers = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: userRo.id, email: userRo.email }), + roleIds: ['role-ro'], + }), + makeProjectMember({ + user: makeProjectUser({ id: userRw.id, email: userRw.email }), + roleIds: ['role-rw'], + }), + makeProjectMember({ + user: makeProjectUser({ id: userNone.id, email: userNone.email }), + roleIds: [], + }), + ], + roles: [ + makeProjectRole({ id: 'role-ro', permissions: 256n, oidcGroup: '', type: 'managed' }), + makeProjectRole({ id: 'role-rw', permissions: 8n, oidcGroup: '', type: 'managed' }), + ], + environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], + }) + datastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) + + const projectGroup = makeGroupRepresentation({ + id: 'group-id', + name: 'test-project', + subGroups: [makeGroupRepresentation({ name: 'console', id: 'console-id' })], + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' }), + }) + + // Project group members (assume all are in project group for simplicity) + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([userRo, userRw, userNone]) + // RO group has userNone (extra), missing userRo + if (groupId === 'dev-ro-id') return Promise.resolve([userNone]) + // RW group has userNone (extra), missing userRw + if (groupId === 'dev-rw-id') return Promise.resolve([userNone]) + return Promise.resolve([]) + }) + + keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + if (name === 'dev') return Promise.resolve(makeGroupRepresentation({ id: 'dev-id', name: 'dev' })) + if (name === 'RO') return Promise.resolve(makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' })) + if (name === 'RW') return Promise.resolve(makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' })) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name })) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Sync RO + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-ro', 'dev-ro-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-ro-id') + // Sync RW + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-rw', 'dev-rw-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-rw-id') + }) + + it('should handle different role types (managed, external, global)', async () => { + const roleManaged = makeProjectRole({ id: 'role-managed', permissions: 0n, oidcGroup: '/managed-group', type: 'managed' }) + const roleExternal = makeProjectRole({ id: 'role-external', permissions: 0n, oidcGroup: '/external-group', type: 'external' }) + const roleGlobal = makeProjectRole({ id: 'role-global', permissions: 0n, oidcGroup: '/global-group', type: 'global' }) + + const projectWithRoles = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-managed', 'role-external', 'role-global'], + }), + ], + roles: [roleManaged, roleExternal, roleGlobal], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const managedGroup = makeGroupRepresentation({ id: 'managed-id', name: 'managed-group' }) + const externalGroup = makeGroupRepresentation({ id: 'external-id', name: 'external-group' }) + const globalGroup = makeGroupRepresentation({ id: 'global-id', name: 'global-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockImplementation((_consoleGroup, oidcGroup) => { + if (oidcGroup === '/managed-group') return Promise.resolve({ ...managedGroup, path: '/console/managed-group' }) + if (oidcGroup === '/external-group') return Promise.resolve({ ...externalGroup, path: '/console/external-group' }) + if (oidcGroup === '/global-group') return Promise.resolve({ ...globalGroup, path: '/console/global-group' }) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name: oidcGroup, path: `/console/${oidcGroup}` })) + }) + + // Group members + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + + // Managed: has extra user-2, missing user-1 + if (groupId === 'managed-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + // External: has extra user-2, missing user-1 + if (groupId === 'external-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + // Global: create group if it doesn't exist but no members + if (groupId === 'global-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Managed: should add user-1, remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/managed-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'managed-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'managed-id') + + // External: should add user-1, NOT remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/external-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'external-id') + expect(keycloak.removeUserFromGroup).not.toHaveBeenCalledWith('user-2', 'external-id') + + // Global: should sync group but no members + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/global-group') + }) + + it('should treat system-prefixed role types as their base type', async () => { + const roleSystemManaged = makeProjectRole({ id: 'role-system-managed', permissions: 0n, oidcGroup: '/system-managed-group', type: 'system:managed' }) + + const projectWithRoles = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-system-managed'], + }), + ], + roles: [roleSystemManaged], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const systemManagedGroup = makeGroupRepresentation({ id: 'system-managed-id', name: 'system-managed-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockResolvedValue({ ...systemManagedGroup, path: '/console/system-managed-group' }) + + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + + // has extra user-2, missing user-1 + if (groupId === 'system-managed-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // system:managed behaves like managed: add user-1, remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/system-managed-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'system-managed-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'system-managed-id') + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts new file mode 100644 index 0000000000..f737e77bcd --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -0,0 +1,670 @@ +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { AdminRoleWithDetails, ProjectWithDetails, UserWithAdminRoles } from './keycloak-datastore.service' +import type { GroupRepresentationWith } from './keycloak.utils' +import { getBaseRoleType, getPermsByUserRoles, isExternalRoleType, ProjectAuthorized, resourceListToDict } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { getErrorResponseStatus } from '../../utils/http.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { isMember, isNonEmptyGroupPath, isOwnedProjectGroup, normalizeGroupPath } from './keycloak.utils' + +@Injectable() +export class KeycloakService { + private readonly logger = new Logger(KeycloakService.name) + + constructor( + @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, + @Inject(KeycloakDatastoreService) private readonly datastore: KeycloakDatastoreService, + ) { + this.logger.log('KeycloakService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('keycloak', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroups([project]) + this.logger.log(`Keycloak sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('keycloak', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.purgeOrphanGroups([project]) + this.logger.log(`Keycloak cleanup completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting periodic Keycloak reconciliation') + const [projects, adminRoles, users] = await Promise.all([ + this.datastore.getAllProjects(), + this.datastore.getAllAdminRoles(), + this.datastore.getAllUsersWithAdminRoleIds(), + ]) + span?.setAttributes({ + 'keycloak.projects.count': projects.length, + 'keycloak.admin_roles.count': adminRoles.length, + 'keycloak.users.count': users.length, + }) + this.logger.debug(`Reconciling Keycloak state (projects=${projects.length}, adminRoles=${adminRoles.length}, users=${users.length})`) + await Promise.all([ + this.ensureAdminRoleGroups(adminRoles, users), + this.ensureProjectGroups(projects), + ]) + await this.purgeOrphanGroups(projects) + this.logger.log(`Keycloak reconciliation completed (projects=${projects.length}, adminRoles=${adminRoles.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.projects.count', projects.length) + this.logger.verbose(`Reconciling Keycloak project groups (${projects.length})`) + await Promise.all(projects.map(project => this.ensureProjectGroup(project))) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'project.members.count': project.members.length, + 'project.roles.count': project.roles.length, + 'project.environments.count': project.environments.length, + }) + this.logger.verbose(`Reconciling Keycloak project group (${project.slug}): members=${project.members.length} roles=${project.roles.length}`) + + const projectGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateGroupByPath(`/${project.slug}`)) + + span?.setAttribute('keycloak.project_group.id', projectGroup.id) + + await Promise.all([ + this.ensureProjectGroupMembers(project, projectGroup), + this.ensureConsoleGroup(project, projectGroup), + ]) + this.logger.verbose(`Keycloak project group reconciled (${project.slug}): groupId=${projectGroup.id}`) + } + + @StartActiveSpan() + private async ensureConsoleGroup(project: ProjectWithDetails, group: GroupRepresentationWith<'id'>) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.console_group.id', group.id) + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateConsoleGroup(group)) + this.logger.verbose(`Reconciling Keycloak console group (${project.slug}): projectGroupId=${group.id} consoleGroupId=${consoleGroup.id}`) + await Promise.all([ + this.ensureRoleGroups(project, consoleGroup), + this.ensureEnvironmentGroups(project, consoleGroup), + this.purgeOrphanEnvironmentGroups(project, consoleGroup), + ]) + } + + @StartActiveSpan() + private async ensureAdminRoleGroups( + adminRoles: AdminRoleWithDetails[], + users: UserWithAdminRoles[], + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.admin_roles.count': adminRoles.length, + 'keycloak.users.count': users.length, + }) + const rolesWithOidcGroup = adminRoles.filter(r => isNonEmptyGroupPath(r.oidcGroup) && !isExternalRoleType(r.type)) + span?.setAttribute('keycloak.admin_roles.oidc_group.count', rolesWithOidcGroup.length) + await Promise.all(rolesWithOidcGroup.map(role => this.ensureAdminRoleGroup(role, users))) + } + + @StartActiveSpan() + private async ensureAdminRoleGroup( + role: AdminRoleWithDetails, + users: UserWithAdminRoles[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('admin_role.id', role.id) + span?.setAttribute('admin_role.oidc_group.present', isNonEmptyGroupPath(role.oidcGroup)) + const roleGroupPath = normalizeGroupPath(role.oidcGroup) + if (!roleGroupPath) return + + span?.setAttribute('keycloak.group.path', roleGroupPath) + const roleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateGroupByPath(roleGroupPath)) + span?.setAttribute('keycloak.group.id', roleGroup.id) + + const groupMembers = await this.keycloak.getGroupMembers(roleGroup.id) + + const desiredUserIds = new Set( + users + .filter(u => u.adminRoleIds.includes(role.id)) + .map(u => u.id), + ) + span?.setAttribute('keycloak.group.members.desired', desiredUserIds.size) + + let addedCount = 0 + let removedCount = 0 + + await Promise.all([ + ...Array.from(desiredUserIds, async (userId) => { + if (!groupMembers.some(m => m.id === userId)) { + addedCount++ + await this.maybeAddUserToGroup(userId, roleGroup.id, roleGroup.name) + } + }), + ...groupMembers.map(async (member) => { + if (member.id && !desiredUserIds.has(member.id)) { + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, roleGroup.id, roleGroup.name) + } + }), + ]) + + span?.setAttribute('keycloak.group.members.added', addedCount) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + @StartActiveSpan() + private async purgeOrphanGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + const groups = map(this.keycloak.getAllGroups(), async (group) => { + return z.object({ + id: z.string(), + name: z.string(), + subGroups: z.array(z.object({ name: z.string() })), + }).parse(group) + }) + const projectSlugs = new Set(projects.map(p => p.slug)) + this.logger.verbose(`Scanning Keycloak groups for orphan cleanup (projects=${projects.length})`) + const promises: Promise[] = [] + let purgedCount = 0 + + for await (const group of groups) { + if (!projectSlugs.has(group.name)) { + if (isOwnedProjectGroup(group)) { + this.logger.log(`Deleting an orphan Keycloak group (groupId=${group.id}, groupName=${group.name})`) + purgedCount++ + promises.push(this.keycloak.deleteGroup(group.id)) + } + } + } + span?.setAttribute('purged.count', purgedCount) + await Promise.all(promises) + this.logger.log(`Orphan Keycloak group cleanup completed (purged=${purgedCount})`) + } + + private async maybeAddUserToGroup(userId: string, groupId: string, groupName: string) { + try { + await this.keycloak.addUserToGroup(userId, groupId) + this.logger.log(`Added user to Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } catch (e) { + const status = getErrorResponseStatus(e) + if (status === 404) { + this.logger.warn(`User not found in Keycloak, skipping addition: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else if (status === 409) { + this.logger.verbose(`User already a member of Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else { + throw e + } + } + } + + private async maybeRemoveUserFromGroup(userId: string, groupId: string, groupName: string) { + try { + await this.keycloak.removeUserFromGroup(userId, groupId) + this.logger.log(`Removed user from Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } catch (e) { + if (getErrorResponseStatus(e) === 404) { + this.logger.warn(`User not found in Keycloak, skipping removal: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else { + throw e + } + } + } + + @StartActiveSpan() + private async ensureProjectGroupMembers( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('keycloak.group.id', group.id) + + const groupMembers = await this.keycloak.getGroupMembers(group.id) + const desiredUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + + span?.setAttribute('keycloak.group.members.current', groupMembers.length) + span?.setAttribute('keycloak.group.members.desired', desiredUserIds.size) + + let addedCount = 0 + let removedCount = 0 + + await Promise.all([ + ...Array.from(desiredUserIds, async (userId) => { + if (!groupMembers.some(m => m.id === userId)) { + addedCount++ + await this.maybeAddUserToGroup(userId, group.id, group.name) + } + }), + ...groupMembers.map(async (member) => { + if (member.id && !desiredUserIds.has(member.id)) { + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, group.id, group.name) + } + }), + ]) + + span?.setAttribute('keycloak.group.members.added', addedCount) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + @StartActiveSpan() + private async ensureRoleGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'keycloak.group.id': group.id, + 'project.roles.count': project.roles.length, + }) + + const rolesWithOidcGroup = project.roles.filter(r => isNonEmptyGroupPath(r.oidcGroup)) + span?.setAttribute('project.roles.oidc_group.count', rolesWithOidcGroup.length) + + await Promise.all(rolesWithOidcGroup.map(role => this.ensureRoleGroup(project, role, group))) + } + + @StartActiveSpan() + private async ensureRoleGroup( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('role.oidc_group.present', isNonEmptyGroupPath(role.oidcGroup)) + if (!isNonEmptyGroupPath(role.oidcGroup)) return + + span?.setAttribute('role.oidc_group.depth', role.oidcGroup.split('/').filter(Boolean).length) + + const roleGroup = await this.keycloak.getOrCreateRoleGroup(group, role.oidcGroup) + span?.setAttribute('keycloak.group.id', roleGroup.id) + span?.setAttribute('keycloak.group.path', roleGroup.path) + + const groupMembers = await this.keycloak.getGroupMembers(roleGroup.id) + span?.setAttribute('keycloak.group.members.current', groupMembers.length) + + switch (getBaseRoleType(role.type)) { + case 'managed': + await Promise.all([ + this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers), + this.purgeOrphanRoleGroupMembers(project, role, roleGroup, groupMembers), + ]) + break + case 'external': + await this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers) + break + case 'global': + await this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers) + break + default: + throw new Error(`Unknown role type ${role.type}`) + } + } + + @StartActiveSpan() + private async ensureRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.members.current', members.length) + + const desiredMemberIds = project.members + .filter(m => m.roleIds.includes(role.id)) + .map(m => m.user.id) + + span?.setAttribute('keycloak.group.members.desired', desiredMemberIds.length) + + let addedCount = 0 + await Promise.all(project.members.map(async (member) => { + if (!members.some(m => m.id === member.user.id) && member.roleIds.includes(role.id)) { + addedCount++ + await this.maybeAddUserToGroup(member.user.id, group.id, group.name) + } + })) + + span?.setAttribute('keycloak.group.members.added', addedCount) + } + + @StartActiveSpan() + private async purgeOrphanRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.members.current', members.length) + + let removedCount = 0 + await Promise.all(members.map(async (member) => { + if (!isMember(project, member)) { + if (!member.id) { + throw new Error(`Failed to create or retrieve role group for ${role.oidcGroup}`) + } + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, group.id, group.name) + } + })) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + private async ensureEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id'>, + ) { + await Promise.all(project.environments.map(environment => + this.ensureEnvironmentGroup(project, environment, group))) + } + + @StartActiveSpan() + private async ensureEnvironmentGroup( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + group: GroupRepresentationWith<'id'>, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + 'project.roles.count': project.roles.length, + }) + + const { roGroup, rwGroup } = z.object({ + roGroup: z.object({ + id: z.string(), + name: z.string(), + }), + rwGroup: z.object({ + id: z.string(), + name: z.string(), + }), + }).parse(await this.keycloak.getOrCreateEnvironmentGroups(group, environment)) + + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + + const rolesById = resourceListToDict(project.roles) + + const [roMembers, rwMembers] = await Promise.all([ + this.keycloak.getGroupMembers(roGroup.id), + this.keycloak.getGroupMembers(rwGroup.id), + ]) + + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + + await Promise.all([ + this.ensureEnvironmentGroupMembers( + project, + environment, + rolesById, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + this.purgeOrphanEnvironmentGroupMembers( + project, + environment, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + ]) + } + + @StartActiveSpan() + private async ensureEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + rolesById: Record, + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('environment.id', environment.id) + span?.setAttribute('environment.name', environment.name) + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + this.logger.verbose(`Reconciling Keycloak environment group members: project=${project.slug} env=${environment.name} roMembers=${roMembers.length} rwMembers=${rwMembers.length}`) + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + span?.setAttribute('project.users.count', projectUserIds.size) + + let roAdded = 0 + let roRemoved = 0 + let rwAdded = 0 + let rwRemoved = 0 + + await Promise.all(Array.from(projectUserIds, async (userId) => { + const perms = this.getUserPermissions(project, rolesById, userId) + + const isInRo = roMembers.some(m => m.id === userId) + if (perms.ro && !isInRo) { + roAdded++ + await this.maybeAddUserToGroup(userId, roGroup.id, `RO group for ${environment.name}`) + } else if (!perms.ro && isInRo) { + roRemoved++ + await this.maybeRemoveUserFromGroup(userId, roGroup.id, `RO group for ${environment.name}`) + } + + const isInRw = rwMembers.some(m => m.id === userId) + if (perms.rw && !isInRw) { + rwAdded++ + await this.maybeAddUserToGroup(userId, rwGroup.id, `RW group for ${environment.name}`) + } else if (!perms.rw && isInRw) { + rwRemoved++ + await this.maybeRemoveUserFromGroup(userId, rwGroup.id, `RW group for ${environment.name}`) + } + })) + + span?.setAttribute('keycloak.env_group.ro.members.added', roAdded) + span?.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) + span?.setAttribute('keycloak.env_group.rw.members.added', rwAdded) + span?.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) + this.logger.verbose(`Keycloak environment group members reconciled: project=${project.slug} env=${environment.name} roAdded=${roAdded} roRemoved=${roRemoved} rwAdded=${rwAdded} rwRemoved=${rwRemoved}`) + } + + @StartActiveSpan() + private async purgeOrphanEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('environment.id', environment.id) + span?.setAttribute('environment.name', environment.name) + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + this.logger.verbose(`Purging orphan Keycloak environment group members: project=${project.slug} env=${environment.name} roMembers=${roMembers.length} rwMembers=${rwMembers.length}`) + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + span?.setAttribute('project.users.count', projectUserIds.size) + + let roRemoved = 0 + let rwRemoved = 0 + + await Promise.all([ + ...roMembers.map(async (member) => { + if (!member.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + if (!projectUserIds.has(member.id)) { + roRemoved++ + await this.maybeRemoveUserFromGroup(member.id, roGroup.id, `RO group for ${environment.name}`) + } + }), + ...rwMembers.map(async (member) => { + if (!member.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + if (!projectUserIds.has(member.id)) { + rwRemoved++ + await this.maybeRemoveUserFromGroup(member.id, rwGroup.id, `RW group for ${environment.name}`) + } + }), + ]) + + span?.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) + span?.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) + this.logger.log(`Orphan Keycloak environment group member cleanup completed: project=${project.slug} env=${environment.name} roRemoved=${roRemoved} rwRemoved=${rwRemoved}`) + } + + @StartActiveSpan() + private async purgeOrphanEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.name', group.name) + this.logger.verbose(`Scanning Keycloak environment groups for orphan cleanup: project=${project.slug} groupId=${group.id} groupName=${group.name}`) + + const envGroups = map(this.keycloak.getSubGroups(group.id), envGroup => z.object({ + id: z.string(), + name: z.string(), + }).parse(envGroup)) + + const promises: Promise[] = [] + let orphanCount = 0 + + for await (const envGroup of envGroups) { + span?.setAttribute('keycloak.env_group.id', envGroup.id) + span?.setAttribute('keycloak.env_group.name', envGroup.name) + + const subGroups = await getAll(map(this.keycloak.getSubGroups(envGroup.id), subgroup => z.object({ + name: z.string(), + }).parse(subgroup))) + + if (this.isEnvironmentGroup(subGroups) && !this.isOwnedEnvironmentGroup(project, envGroup)) { + orphanCount++ + this.logger.log(`Deleting orphan Keycloak environment group: project=${project.slug} envGroupId=${envGroup.id} envGroupName=${envGroup.name}`) + promises.push( + this.keycloak.deleteGroup(envGroup.id) + .catch(err => this.logger.warn(`Failed to delete orphan Keycloak environment group: project=${project.slug} envGroupId=${envGroup.id} envGroupName=${envGroup.name} err=${err instanceof Error ? err.message : String(err)}`)), + ) + } + } + + span?.setAttribute('keycloak.env_groups.orphan.count', orphanCount) + await Promise.all(promises) + this.logger.log(`Orphan Keycloak environment group cleanup completed: project=${project.slug} groupId=${group.id} orphanCount=${orphanCount}`) + } + + private isEnvironmentGroup( + subGroups: GroupRepresentationWith<'name'>[], + ) { + return subGroups.some(subgroup => subgroup.name === 'RO' || subgroup.name === 'RW') + } + + private isOwnedEnvironmentGroup( + project: ProjectWithDetails, + group: GroupRepresentationWith<'name'>, + ) { + return project.environments.some(e => e.name === group.name) + } + + private getUserPermissions( + project: ProjectWithDetails, + rolesById: Record, + userId: string, + ) { + if (userId === project.ownerId) return { ro: true, rw: true } + const member = project.members.find(m => m.user.id === userId) + if (!member) return { ro: false, rw: false } + + const projectPermissions = getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) + + return { + ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions }), + rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions }), + } + } +} + +async function* map( + iterable: AsyncIterable, + mapper: (value: T, index: number) => U | Promise, +): AsyncIterable { + let index = 0 + for await (const value of iterable) { + yield await mapper(value, index++) + } +} + +async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts new file mode 100644 index 0000000000..ac56609793 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts @@ -0,0 +1,25 @@ +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import { CONSOLE_GROUP_NAME } from './keycloak.constants' + +type With = T & Required> +export type GroupRepresentationWith = With + +export function isMember(project: ProjectWithDetails, member: UserRepresentation): boolean { + return project.members.some(m => m.user.id === member.id) || project.ownerId === member.id +} + +export function isNonEmptyGroupPath(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +export function normalizeGroupPath(value: unknown): string | undefined { + if (!isNonEmptyGroupPath(value)) return undefined + const trimmed = value.trim() + return trimmed.startsWith('/') ? trimmed : `/${trimmed}` +} + +export function isOwnedProjectGroup(group: GroupRepresentationWith<'subGroups'>): boolean { + return !!group.subGroups.some(sg => sg.name === CONSOLE_GROUP_NAME) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log-queries.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log-queries.utils.ts new file mode 100644 index 0000000000..7c8e6de59f --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log-queries.utils.ts @@ -0,0 +1,30 @@ +import type { Prisma } from '@prisma/client' + +const logSelect = { + id: true, + createdAt: true, + updatedAt: true, + action: true, + userId: true, + requestId: true, + projectId: true, + data: true, +} satisfies Prisma.LogSelect + +export type LogSelect = Prisma.LogGetPayload<{ + select: typeof logSelect +}> + +export function countLogs(tx: Prisma.TransactionClient, projectId: string | null | undefined) { + return tx.log.count({ where: { projectId } }) +} + +export function listLogs(tx: Prisma.TransactionClient, projectId: string | null | undefined, offset: number, limit: number) { + return tx.log.findMany({ + orderBy: { createdAt: 'desc' }, + skip: offset, + take: limit, + where: { projectId }, + select: logSelect, + }) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log-testing.utils.ts new file mode 100644 index 0000000000..1e90dffb17 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log-testing.utils.ts @@ -0,0 +1,16 @@ +import type { Log } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeLog(overrides: Partial = {}): Log { + return { + id: faker.string.uuid(), + data: {}, + action: faker.word.verb(), + userId: null, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + requestId: faker.string.uuid(), + projectId: faker.string.uuid(), + ...overrides, + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.controller.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.controller.spec.ts new file mode 100644 index 0000000000..fa9dfd0118 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.controller.spec.ts @@ -0,0 +1,79 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { ADMIN_PERMS } from '@cpn-console/shared' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectPermissionLoaderService } from '../infrastructure/permission/project/project-loader.service' +import { makeProjectContext } from '../infrastructure/permission/project/project.testing.utils' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { LogController } from './log.controller' +import { LogService } from './log.service' + +describe('logController', () => { + let module: TestingModule + let controller: LogController + let logs: DeepMockProxy + let projectLoader: DeepMockProxy + + beforeEach(async () => { + logs = mockDeep() + projectLoader = mockDeep() + + module = await Test.createTestingModule({ + controllers: [LogController], + providers: [ + { provide: LogService, useValue: logs }, + { provide: ProjectPermissionLoaderService, useValue: projectLoader }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(LogController) + }) + + it('returns logs for system admins without project lookup', async () => { + logs.getLogs.mockResolvedValueOnce({ total: 1, logs: [] }) + + const response = await controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: undefined }, + { userId: 'user-1', adminPermissions: ADMIN_PERMS.LIST_SYSTEM, userType: 'human' }, + ) + + expect(projectLoader.load).not.toHaveBeenCalled() + expect(logs.getLogs).toHaveBeenCalledWith({ limit: 10, offset: 0, clean: false, projectId: undefined }) + expect(response).toEqual({ total: 1, logs: [] }) + }) + + it('forces clean logs for project-scoped non-admin access', async () => { + projectLoader.load.mockResolvedValueOnce(makeProjectContext({ projectPermissions: 1n })) + logs.getLogs.mockResolvedValueOnce({ total: 1, logs: [] }) + + await controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: 'project-1' }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + ) + + expect(projectLoader.load).toHaveBeenCalledTimes(1) + expect(logs.getLogs).toHaveBeenCalledWith({ limit: 10, offset: 0, clean: true, projectId: 'project-1' }) + }) + + it('rejects non-admin access without projectId', async () => { + await expect(controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: undefined }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + )).rejects.toThrow(ForbiddenException) + }) + + it('rejects non-admin access when user has no project permissions', async () => { + projectLoader.load.mockResolvedValueOnce(makeProjectContext({ projectPermissions: 0n })) + + await expect(controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: 'project-1' }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + )).rejects.toThrow(ForbiddenException) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.controller.ts new file mode 100644 index 0000000000..951ee4f472 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.controller.ts @@ -0,0 +1,47 @@ +import type { AdminLogsQuery } from '@cpn-console/shared' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { AdminAuthorized, logContract } from '@cpn-console/shared' +import { Controller, ForbiddenException, Get, Inject, Query, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { ProjectPermissionLoaderService } from '../infrastructure/permission/project/project-loader.service' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { LogService } from './log.service' + +@Controller('api/v1/logs') +@UseGuards(UserGuard) +export class LogController { + constructor( + @Inject(LogService) private readonly logs: LogService, + @Inject(ProjectPermissionLoaderService) private readonly projectLoader: ProjectPermissionLoaderService, + ) {} + + @Get('') + async getLogs( + @Query(new ZodValidationPipe(logContract.getLogs.query)) query: AdminLogsQuery, + @AuthUser() user: UserContext, + ) { + const isSystemAdmin = AdminAuthorized.ListSystem(user.adminPermissions ?? 0n) + let effectiveQuery = query + + if (!isSystemAdmin) { + if (!query.projectId) { + throw new ForbiddenException() + } + + const project = await this.projectLoader.load( + { params: { projectId: query.projectId } } as Parameters[0], + user.userId, + { includePermissions: true, includeLocked: false, includeStatus: false }, + ) + + if (!project.projectPermissions) { + throw new ForbiddenException() + } + + effectiveQuery = { ...query, clean: true } + } + + return this.logs.getLogs(effectiveQuery) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.module.ts new file mode 100644 index 0000000000..f80194cb54 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { LogController } from './log.controller' +import { LogService } from './log.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, UserPermissionModule], + controllers: [LogController], + providers: [LogService], + exports: [LogService], +}) +export class LogModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.service.spec.ts new file mode 100644 index 0000000000..608bafbdbd --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.service.spec.ts @@ -0,0 +1,67 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeLog } from './log-testing.utils' +import { LogService } from './log.service' + +describe('logService', () => { + let module: TestingModule + let service: LogService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + LogService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(LogService) + }) + + it('should map clean logs', async () => { + const dbLogs = [makeLog({ + data: { args: {} }, + action: 'Action', + })] + + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + + const { logs } = await service.getLogs({ + limit: 10, + offset: 10, + clean: true, + projectId: undefined, + }) + + expect(logs[0]).not.toHaveProperty('requestId') + expect(logs[0].data).not.toHaveProperty('results') + expect(logs[0].data).not.toHaveProperty('args') + expect(logs[0].data).not.toHaveProperty('config') + }) + + it('should not filter admin logs', async () => { + const dbLogs = [makeLog({ + data: { args: {} }, + action: 'Action', + })] + + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + + const { logs } = await service.getLogs({ + limit: 10, + offset: 10, + clean: false, + projectId: undefined, + }) + + expect(logs[0].data).toHaveProperty('args') + expect(logs[0].data).not.toHaveProperty('config') + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.service.ts new file mode 100644 index 0000000000..8aa403a41c --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/log/log.service.ts @@ -0,0 +1,74 @@ +import type { AdminLogsQuery } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import { CleanLogSchema, exclude } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { countLogs, listLogs } from './log-queries.utils' + +/** + * Shape of the `data` JSON column, aligned with the LogSchema contract in + * `@cpn-console/shared` so the console can parse and display every entry. + */ +export interface LogData { + /** Payload the action/event was invoked with (e.g. the full project). */ + args?: unknown + /** Names of the services that reported a KO result. */ + failed?: boolean | string[] + /** Per-service outcome, keyed by service name. */ + results?: Record + totalExecutionTime?: number + messageResume?: string + /** Extra keys are persisted as-is, except the sensitive ones stripped by addLog. */ + [key: string]: unknown +} + +interface AddLogArgs { + action: string + data: LogData + userId?: string | null + requestId?: string | null + projectId?: string | null +} + +@Injectable() +export class LogService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + ) {} + + async getLogs(params: AdminLogsQuery) { + const [total, logs] = await this.getLogsWithCount(params.projectId, params.offset, params.limit) + + return { + total, + logs: params.clean + ? logs.map(log => CleanLogSchema.parse(log)) + : logs, + } + } + + async getLogsWithCount(projectId: string | null | undefined, offset: number, limit: number) { + return this.prisma.$transaction([ + countLogs(this.prisma, projectId), + listLogs(this.prisma, projectId, offset, limit), + ]) + } + + async addLog({ + action, + data, + userId = null, + requestId = null, + projectId = null, + }: AddLogArgs) { + return this.prisma.log.create({ + data: { + action, + userId, + requestId, + projectId, + data: exclude(data, ['cluster', 'user', 'newCreds', 'apis', 'config']) as Prisma.InputJsonValue, + }, + }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts new file mode 100644 index 0000000000..5ac2c471b3 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts @@ -0,0 +1,82 @@ +import type { ConfigType } from '@nestjs/config' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { nexusConfigFactory } from '../../config/nexus.config' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { NexusClientService } from './nexus-client.service' +import { NexusHttpClientService } from './nexus-http-client.service' +import { NEXUS_CONFIG } from './nexus.module-definition' + +const nexusUrl = 'https://nexus.internal' + +const server = setupServer() +const nexusAdminPassword = faker.internet.password() +const basicAuth = `Basic ${Buffer.from(`admin:${nexusAdminPassword}`, 'utf8').toString('base64')}` + +describe('nexusClientService', () => { + let service: NexusClientService + let config: DeepMockProxy> + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + beforeEach(async () => { + config = mockDeep>({ + internalUrl: nexusUrl, + admin: 'admin', + adminPassword: nexusAdminPassword, + internalOrPublicUrl: nexusUrl, + }) + + const module = await Test.createTestingModule({ + providers: [ + NexusClientService, + NexusHttpClientService, + { + provide: NEXUS_CONFIG, + useValue: config, + }, + ], + }).compile() + + service = module.get(NexusClientService) + }) + + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should return null on 404 (getRepositoriesMavenHosted)', async () => { + server.use( + http.get(`${nexusUrl}/service/rest/v1/repositories/maven/hosted/:name`, ({ request }) => { + expect(request.headers.get('authorization')).toBe(basicAuth) + return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.getRepositoriesMavenHosted('missing')).resolves.toBeNull() + }) + + it('should send basic auth and plain text body on change-password', async () => { + server.use( + http.put(`${nexusUrl}/service/rest/v1/security/users/:userId/change-password`, async ({ request, params }) => { + expect(request.method).toBe('PUT') + expect(request.url).toBe(`${nexusUrl}/service/rest/v1/security/users/u1/change-password`) + expect(params.userId).toBe('u1') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('content-type')).toContain('text/plain') + expect(await request.text()).toBe('pw123') + return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) + }), + ) + + await service.updateSecurityUsersChangePassword('u1', 'pw123') + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts new file mode 100644 index 0000000000..85f9e81d62 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts @@ -0,0 +1,297 @@ +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { NexusError, NexusHttpClientService } from './nexus-http-client.service' + +interface NexusRepositoryStorage { + blobStoreName: string + strictContentTypeValidation: boolean + writePolicy?: string +} + +interface NexusRepositoryCleanup { + policyNames: string[] +} + +interface NexusRepositoryComponent { + proprietaryComponents: boolean +} + +interface NexusRepositoryGroup { + memberNames: string[] +} + +export interface NexusMavenHostedRepository { + name: string + online: boolean + storage: NexusRepositoryStorage & { writePolicy: string } + cleanup?: NexusRepositoryCleanup + component: NexusRepositoryComponent + maven: { + versionPolicy: string + layoutPolicy: string + contentDisposition: string + } +} + +interface NexusMavenHostedRepositoryUpsertRequest extends NexusMavenHostedRepository {} + +export interface NexusMavenGroupRepository { + name: string + online: boolean + storage: Omit + group: NexusRepositoryGroup +} + +interface NexusMavenGroupRepositoryUpsertRequest extends NexusMavenGroupRepository {} + +export interface NexusNpmHostedRepository { + name: string + online: boolean + storage: NexusRepositoryStorage & { writePolicy: string } + cleanup?: NexusRepositoryCleanup + component: NexusRepositoryComponent +} + +interface NexusNpmHostedRepositoryUpsertRequest extends NexusNpmHostedRepository {} + +export interface NexusNpmGroupRepository { + name: string + online: boolean + storage: Omit + group: NexusRepositoryGroup +} + +interface NexusNpmGroupRepositoryUpsertRequest extends NexusNpmGroupRepository {} + +interface NexusRepositoryViewPrivilege { + name: string + description: string + actions: string[] + format: string + repository: string +} + +interface NexusRepositoryViewPrivilegeUpsertRequest extends NexusRepositoryViewPrivilege {} + +interface NexusRole { + id: string + name: string + privileges: string[] + source?: string + roles?: string[] + description?: string +} + +interface NexusRoleCreateRequest extends NexusRole { + description: string +} + +interface NexusRoleUpdateRequest extends NexusRole {} + +interface NexusUserCreateRequest { + userId: string + firstName: string + lastName: string + emailAddress: string + password: string + status: string + roles: string[] +} + +export interface NexusPrivilege extends NexusRepositoryViewPrivilege { + type: string +} + +@Injectable() +export class NexusClientService { + constructor( + @Inject(NexusHttpClientService) private readonly http: NexusHttpClientService, + ) {} + + @StartActiveSpan() + async getRepositoriesMavenHosted(name: string) { + try { + const res = await this.http.fetch(`repositories/maven/hosted/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createRepositoriesMavenHosted(body: NexusMavenHostedRepositoryUpsertRequest) { + await this.http.fetch('repositories/maven/hosted', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesMavenHosted(name: string, body: NexusMavenHostedRepositoryUpsertRequest) { + await this.http.fetch(`repositories/maven/hosted/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async createRepositoriesMavenGroup(body: NexusMavenGroupRepositoryUpsertRequest) { + await this.http.fetch('repositories/maven/group', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesMavenGroup(name: string, body: NexusMavenGroupRepositoryUpsertRequest) { + await this.http.fetch(`repositories/maven/group/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getRepositoriesMavenGroup(name: string) { + try { + const res = await this.http.fetch(`repositories/maven/group/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async getRepositoriesNpmHosted(name: string) { + try { + const res = await this.http.fetch(`repositories/npm/hosted/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createRepositoriesNpmHosted(body: NexusNpmHostedRepositoryUpsertRequest) { + await this.http.fetch('repositories/npm/hosted', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesNpmHosted(name: string, body: NexusNpmHostedRepositoryUpsertRequest) { + await this.http.fetch(`repositories/npm/hosted/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getRepositoriesNpmGroup(name: string): Promise { + try { + const res = await this.http.fetch(`repositories/npm/group/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async postRepositoriesNpmGroup(body: NexusNpmGroupRepositoryUpsertRequest) { + await this.http.fetch('repositories/npm/group', { method: 'POST', body }) + } + + @StartActiveSpan() + async putRepositoriesNpmGroup(name: string, body: NexusNpmGroupRepositoryUpsertRequest) { + await this.http.fetch(`repositories/npm/group/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getSecurityPrivileges(name: string): Promise { + try { + const res = await this.http.fetch(`security/privileges/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createSecurityPrivilegesRepositoryView(body: NexusRepositoryViewPrivilegeUpsertRequest) { + await this.http.fetch('security/privileges/repository-view', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateSecurityPrivilegesRepositoryView(name: string, body: NexusRepositoryViewPrivilegeUpsertRequest) { + await this.http.fetch(`security/privileges/repository-view/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async deleteSecurityPrivileges(name: string) { + try { + await this.http.fetch(`security/privileges/${name}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async getSecurityRoles(id: string): Promise { + try { + const res = await this.http.fetch(`security/roles/${id}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createSecurityRoles(body: NexusRoleCreateRequest) { + await this.http.fetch('security/roles', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateSecurityRoles(id: string, body: NexusRoleUpdateRequest) { + await this.http.fetch(`security/roles/${id}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async deleteSecurityRoles(id: string) { + try { + await this.http.fetch(`security/roles/${id}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async getSecurityUsers(userId: string): Promise<{ userId: string }[]> { + const query = new URLSearchParams({ userId }).toString() + const res = await this.http.fetch<{ userId: string }[]>(`security/users?${query}`) + return res.data ?? [] + } + + @StartActiveSpan() + async updateSecurityUsersChangePassword(userId: string, password: string) { + await this.http.fetch(`security/users/${userId}/change-password`, { + method: 'PUT', + body: password, + headers: { 'Content-Type': 'text/plain' }, + }) + } + + @StartActiveSpan() + async createSecurityUsers(body: NexusUserCreateRequest) { + await this.http.fetch('security/users', { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSecurityUsers(userId: string) { + try { + await this.http.fetch(`security/users/${userId}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async deleteRepositoriesByName(name: string) { + try { + await this.http.fetch(`repositories/${name}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts new file mode 100644 index 0000000000..ddbb28b966 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts @@ -0,0 +1,66 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './nexus.constants' + +export const projectSelect = { + slug: true, + owner: { + select: { + email: true, + firstName: true, + lastName: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class NexusDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts new file mode 100644 index 0000000000..5914403606 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts @@ -0,0 +1,27 @@ +import type { NexusConfig } from './nexus.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { NEXUS_CONFIG } from './nexus.module-definition' + +@Injectable() +export class NexusHealthService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const credentials = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` + const encoded = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${encoded}` + try { + const response = await fetch(this.nexusConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts new file mode 100644 index 0000000000..8575a4cd18 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts @@ -0,0 +1,129 @@ +import type { NexusConfig } from './nexus.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { NEXUS_CONFIG } from './nexus.module-definition' + +export interface NexusFetchOptions { + method?: string + body?: unknown + headers?: Record +} + +export interface NexusResponse { + status: HttpStatus + data: T | null +} + +export type NexusErrorKind + = | 'NotConfigured' + | 'HttpError' + | 'Unexpected' + +export class NexusError extends Error { + readonly kind: NexusErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + + constructor( + kind: NexusErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string } = {}, + ) { + super(message) + this.name = 'NexusError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + } +} + +@Injectable() +export class NexusHttpClientService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + ) {} + + @StartActiveSpan() + async fetch(path: string, options: NexusFetchOptions = {}): Promise> { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('nexus.method', method) + span?.setAttribute('nexus.path', path) + + const request = this.createRequest(path, method, options.body, options.headers) + const response = await fetch(request).catch((error) => { + throw new NexusError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('nexus.http.status', response.status) + const result = await handleResponse(response) + if (!response.ok) { + throw new NexusError('HttpError', `Request failed: ${method} ${path} responded ${result.status} ${response.statusText}`, { + status: result.status, + method, + path, + statusText: response.statusText, + }) + } + return result + } + + private get baseUrl() { + const url = this.nexusConfig.internalOrPublicUrl + if (!url) { + throw new NexusError('NotConfigured', 'NEXUS_INTERNAL_URL or NEXUS_URL is required') + } + return url + } + + private get apiBaseUrl() { + return new URL('service/rest/v1/', this.baseUrl).toString() + } + + private get basicAuth() { + if (!this.nexusConfig.admin) { + throw new NexusError('NotConfigured', 'NEXUS_ADMIN is required') + } + if (!this.nexusConfig.adminPassword) { + throw new NexusError('NotConfigured', 'NEXUS_ADMIN_PASSWORD is required') + } + const raw = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` + return Buffer.from(raw, 'utf8').toString('base64') + } + + private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record): Request { + const url = new URL(path, this.apiBaseUrl).toString() + const headers: Record = { + Authorization: `Basic ${this.basicAuth}`, + ...extraHeaders, + } + let requestBody: string | undefined + if (body !== undefined) { + if (typeof body === 'string') { + requestBody = body + headers['Content-Type'] = 'text/plain' + } else { + requestBody = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + } + return new Request(url, { method, headers, body: requestBody }) + } +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts new file mode 100644 index 0000000000..eab6e89046 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts @@ -0,0 +1,37 @@ +import type { ConfigType } from '@nestjs/config' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { nexusConfigFactory } from '../../config/nexus.config' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { NexusPluginService } from './nexus-plugin.service' +import { NEXUS_CONFIG } from './nexus.module-definition' + +describe('nexusPluginService', () => { + let service: NexusPluginService + let config: DeepMockProxy> + + beforeEach(async () => { + config = mockDeep>({ + url: 'https://nexus.public/', + internalUrl: 'https://nexus.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + NexusPluginService, + { provide: NEXUS_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(NexusPluginService) + }) + + it('returns the public Nexus url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://nexus.public/') + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts new file mode 100644 index 0000000000..3682cba90e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts @@ -0,0 +1,176 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { NexusConfig } from './nexus.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { NEXUS_CONFIG } from './nexus.module-definition' + +@Injectable() +export class NexusPluginService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'nexus', + to: () => this.nexusConfig.url, + title: 'Nexus', + imgSrc: '/img/nexus.png', + description: 'Nexus permet de gérer les binaires et artefacts de build à travers la chaîne logistique logicielle', + config: { + project: [ + { + key: 'activateNpmRepo', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt NPM', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'npmWritePolicy', + section: 'NPM', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture', + value: 'allow', + description: 'Politique d\'écriture des dépôts NPM, valeurs possibles: allow / allow_once, deny, replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'activateMavenRepo', + section: 'Maven', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt Maven', + value: DISABLED, + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'mavenSnapshotWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Snapshot', + value: 'allow', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'mavenReleaseWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Release', + value: 'allow_once', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + ], + global: [ + { + key: 'platformWriteGroupPaths', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemins des groupes OIDC plateforme en écriture', + value: '/console/admin', + description: 'Liste séparée par des virgules des chemins des groupes OIDC ayant accès en écriture aux dépôts Nexus de la plateforme', + placeholder: '/console/admin', + }, + { + key: 'platformReadGroupPaths', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemins des groupes OIDC plateforme en lecture', + value: '/console/readonly,/console/security', + description: 'Liste séparée par des virgules des chemins des groupes OIDC ayant accès en lecture aux dépôts Nexus de la plateforme', + placeholder: '/console/readonly,/console/security', + }, + { + key: 'projectWriteGroupPathSuffixes', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixes des groupes OIDC projet en écriture', + value: '/console/admin,/console/devops', + description: 'Liste séparée par des virgules des suffixes des chemins des groupes OIDC ayant accès en écriture aux dépôts Nexus du projet', + placeholder: '/console/admin,/console/devops', + }, + { + key: 'projectReadGroupPathSuffixes', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixes des groupes OIDC projet en lecture', + value: '/console/readonly,/console/security,/console/developer', + description: 'Liste séparée par des virgules des suffixes des chemins des groupes OIDC ayant accès en lecture aux dépôts Nexus du projet', + placeholder: '/console/readonly,/console/security,/console/developer', + }, + { + key: 'activateNpmRepoDefaultValue', + section: 'NPM', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Créer un dépôt NPM privé (comportement par défaut des projets)', + value: DISABLED, + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'activateMavenRepoDefaultValue', + section: 'Maven', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Créer un dépôt MAVEN privé (comportement par défaut des projets)', + value: DISABLED, + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'enablePlugin', + kind: 'switch', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Activer/Désactiver entièrement le plugin Nexus', + value: ENABLED, + description: 'Défaut: Activé', + }, + ], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts new file mode 100644 index 0000000000..4cdc734919 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts @@ -0,0 +1,15 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + slug: faker.internet.domainWord(), + owner: { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + }, + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.constants.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.constants.ts new file mode 100644 index 0000000000..151b2e56b1 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.constants.ts @@ -0,0 +1,32 @@ +// Name of the Nexus plugin used throughout the application +export const PLUGIN_NAME = 'nexus' + +// Configuration keys for enabling Maven and NPM repositories +export const NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO = 'activateMavenRepo' +export const NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO = 'activateNpmRepo' + +// Configuration keys for Maven snapshot, release, and NPM write policies +export const NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY = 'mavenSnapshotWritePolicy' +export const NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY = 'mavenReleaseWritePolicy' +export const NEXUS_CONFIG_KEY_NPM_WRITE_POLICY = 'npmWritePolicy' + +// Default write policy values for Maven snapshots, releases, and NPM packages +export const DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY = 'allow' +export const DEFAULT_MAVEN_RELEASE_WRITE_POLICY = 'allow_once' +export const DEFAULT_NPM_WRITE_POLICY = 'allow' + +// Default group paths granting write and read access at the platform level +export const DEFAULT_PLATFORM_WRITE_GROUP_PATHS = '/console/admin' +export const DEFAULT_PLATFORM_READ_GROUP_PATHS = '/console/readonly,/console/security' + +// Default group path suffixes granting write and read access at the project level +export const DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES = '/console/admin,/console/devops' +export const DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES = '/console/readonly,/console/security,/console/developer' + +// Plugin configuration keys for platform-level group paths +export const PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY = 'platformWriteGroupPaths' +export const PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY = 'platformReadGroupPaths' + +// Plugin configuration keys for project-level group path suffixes +export const PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectWriteGroupPathSuffixes' +export const PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectReadGroupPathSuffixes' diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts new file mode 100644 index 0000000000..998c0b3b7a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts @@ -0,0 +1,18 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface NexusConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + secretExposeInternalUrl: boolean + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface NexusModuleOptions extends NexusConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('nexus') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as NEXUS_CONFIG } diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.module.ts new file mode 100644 index 0000000000..25f0e992ce --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from '../../config/base.config' +import { nexusConfigFactory } from '../../config/nexus.config' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { NexusHealthService } from './nexus-health.service' +import { NexusHttpClientService } from './nexus-http-client.service' +import { NexusPluginService } from './nexus-plugin.service' +import { ConfigurableModuleClass } from './nexus.module-definition' +import { NexusService } from './nexus.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule, ConfigModule.forFeature(nexusConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + providers: [ + NexusHealthService, + NexusPluginService, + NexusService, + NexusDatastoreService, + NexusHttpClientService, + NexusClientService, + ], + exports: [NexusClientService, NexusHealthService, NexusPluginService, NexusService], +}) +export class NexusModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts new file mode 100644 index 0000000000..9963b5e4c5 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -0,0 +1,202 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { NexusConfig } from './nexus.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { makeProjectWithDetails } from './nexus-testing.utils' +import { + NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, + NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, + PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, + PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, +} from './nexus.constants' +import { NEXUS_CONFIG } from './nexus.module-definition' +import { NexusService } from './nexus.service' + +describe('nexusService', () => { + let service: NexusService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let config: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + getRepositoriesMavenHosted: vi.fn().mockResolvedValue(null), + getRepositoriesMavenGroup: vi.fn().mockResolvedValue(null), + getRepositoriesNpmHosted: vi.fn().mockResolvedValue(null), + getRepositoriesNpmGroup: vi.fn().mockResolvedValue(null), + getSecurityPrivileges: vi.fn().mockResolvedValue(null), + getSecurityRoles: vi.fn().mockResolvedValue(null), + getSecurityUsers: vi.fn().mockResolvedValue([]), + }) + datastore = mockDeep({ + getAllProjects: vi.fn().mockResolvedValue([]), + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + read: vi.fn().mockRejectedValue(new VaultError('NotFound', 'Not Found')), + }) + config = mockDeep({}) + baseConfig = mockDeep({ projectsRootDir: 'forge' }) + vaultConfig = mockDeep({}) + + const module = await Test.createTestingModule({ + providers: [ + NexusService, + { provide: NexusClientService, useValue: client }, + { provide: NexusDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: NEXUS_CONFIG, useValue: config }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + ], + }).compile() + + service = module.get(NexusService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('handleUpsert should reconcile based on computed flags', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: DISABLED }, + ], + }) + + await service.handleUpsert(project) + + expect(client.createRepositoriesMavenHosted).toHaveBeenCalled() + expect(client.deleteRepositoriesByName).toHaveBeenCalled() + expect(vault.write).toHaveBeenCalledWith( + expect.objectContaining({ + NEXUS_USERNAME: project.slug, + NEXUS_PASSWORD: expect.any(String), + }), + `forge/${project.slug}/tech/NEXUS`, + ) + }) + + it('handleDelete should delete project', async () => { + const project = makeProjectWithDetails() + await service.handleDelete(project) + expect(client.deleteSecurityRoles).toHaveBeenCalledWith(`${project.slug}-ID`) + expect(client.deleteSecurityUsers).toHaveBeenCalledWith(project.slug) + expect(vault.delete).toHaveBeenCalledWith(`forge/${project.slug}/tech/NEXUS`) + }) + + it('handleCron should reconcile all projects', async () => { + const projects = [ + makeProjectWithDetails({ plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }] }), + makeProjectWithDetails({ plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }] }), + ] + + datastore.getAllProjects.mockResolvedValue(projects) + + await service.handleCron() + + expect(client.createSecurityUsers).toHaveBeenCalledTimes(2) + }) + + it('reuses existing vault password and does not rotate Nexus user password', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + }) + + datastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { + if (key === PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) return ' ' + if (key === PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) return ' ' + return null + }) + + vault.read.mockResolvedValue(makeVaultSecret({ data: { NEXUS_PASSWORD: 'existing' } })) + client.getSecurityUsers.mockResolvedValue([{ userId: project.slug }]) + + await service.handleUpsert(project) + + expect(client.updateSecurityUsersChangePassword).not.toHaveBeenCalled() + expect(client.createSecurityUsers).not.toHaveBeenCalled() + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + NEXUS_USERNAME: project.slug, + NEXUS_PASSWORD: 'existing', + }), `forge/${project.slug}/tech/NEXUS`) + }) + + it('deletes group repos before their hosted members', async () => { + const project = makeProjectWithDetails() + + await service.handleDelete(project) + + const deletedRepos = client.deleteRepositoriesByName.mock.calls.map(([name]) => name) + expect(deletedRepos.indexOf(`${project.slug}-repository-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-repository-snapshot`)) + expect(deletedRepos.indexOf(`${project.slug}-repository-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-repository-release`)) + expect(deletedRepos.indexOf(`${project.slug}-npm-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-npm`)) + }) + + it('tolerates platform role failures when ensuring platform roles', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + }) + const staleProject = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }], + }) + + datastore.getAllProjects.mockResolvedValue([project, staleProject]) + client.createSecurityRoles.mockImplementation(async (body) => { + if (body.id.startsWith('console-')) throw new Error('Request failed: POST security/roles responded 400 Bad Request') + }) + + await expect(service.handleUpsert(project)).resolves.not.toThrow() + + expect(client.createSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({ id: 'console-admin' })) + }) + + it('dedupes project group roles by role id and keeps the highest privileges', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, + { pluginName: PLUGIN_NAME, key: PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, + ], + }) + + datastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { + if (key === PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) return ' ' + if (key === PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) return ' ' + return null + }) + + await service.handleUpsert(project) + + expect(client.createSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({ + id: `${project.slug}-console-devops`, + privileges: expect.arrayContaining([`${project.slug}-privilege-group`]), + })) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.service.ts new file mode 100644 index 0000000000..52061ace32 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -0,0 +1,696 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { NexusPrivilege } from './nexus-client.service' +import type { ProjectWithDetails } from './nexus-datastore.service' +import type { NexusConfig } from './nexus.module-definition' +import type { + MavenHostedRepoKind, +} from './nexus.utils' +import { specificallyEnabled } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { + DEFAULT_MAVEN_RELEASE_WRITE_POLICY, + DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY, + DEFAULT_NPM_WRITE_POLICY, + DEFAULT_PLATFORM_READ_GROUP_PATHS, + DEFAULT_PLATFORM_WRITE_GROUP_PATHS, + DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES, + NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, + NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, + NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY, + NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY, + NEXUS_CONFIG_KEY_NPM_WRITE_POLICY, + PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, + PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, +} from './nexus.constants' +import { NEXUS_CONFIG } from './nexus.module-definition' +import { + generateMavenHostedRepoName, + generateNpmHostedRepoName, + generateRandomPassword, + getPluginConfig, + getProjectVaultPath, +} from './nexus.utils' + +export interface EnsureMavenReposOptions { + snapshotWritePolicy: string + releaseWritePolicy: string +} + +@Injectable() +export class NexusService { + private readonly logger = new Logger(NexusService.name) + + constructor( + @Inject(NexusDatastoreService) private readonly datastore: NexusDatastoreService, + @Inject(NexusClientService) private readonly client: NexusClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) { + this.logger.log('NexusService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('nexus', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + await this.ensureProject(project) + const projects = await this.datastore.getAllProjects() + await this.ensurePlatformRoles(projects) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('nexus', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.deleteProject(project) + const projects = await this.datastore.getAllProjects() + await this.ensurePlatformRoles(projects) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Nexus reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('nexus.projects.count', projects.length) + await this.ensureProjects(projects) + await this.ensurePlatformRoles(projects) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('nexus.projects.count', projects.length) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const enableMaven = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO)) ?? false + const enableNpm = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO)) ?? false + + const mavenSnapshotWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY) ?? DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY + const mavenReleaseWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY) ?? DEFAULT_MAVEN_RELEASE_WRITE_POLICY + const npmWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_NPM_WRITE_POLICY) ?? DEFAULT_NPM_WRITE_POLICY + + await Promise.all([ + enableMaven + ? this.ensureMavenRepos(project, { snapshotWritePolicy: mavenSnapshotWritePolicy, releaseWritePolicy: mavenReleaseWritePolicy }) + : this.deleteMavenRepos(project), + enableNpm + ? this.ensureNpmRepos(project, npmWritePolicy) + : this.deleteNpmRepos(project), + ]) + + const privileges = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + ] + : []), + ] + const readOnlyPrivileges = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + : []), + ] + await this.ensureRole(project, privileges) + await this.ensureUser(project) + await this.ensureProjectGroupRoles(project, { readOnlyPrivileges, writePrivileges: privileges }) + } + + private async upsertPrivilege(body: NexusPrivilege) { + const existing = await this.client.getSecurityPrivileges(body.name) + if (!existing) { + await this.client.createSecurityPrivilegesRepositoryView(body) + return + } + await this.client.updateSecurityPrivilegesRepositoryView(body.name, body) + } + + private async ensureMavenHostedRepo(repoName: string, writePolicy: string) { + const existing = await this.client.getRepositoriesMavenHosted(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + } + if (!existing) { + await this.client.createRepositoriesMavenHosted(body) + return + } + await this.client.updateRepositoriesMavenHosted(repoName, body) + } + + private async ensureNpmHostedRepo(repoName: string, writePolicy: string) { + const existing = await this.client.getRepositoriesNpmHosted(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + } + if (!existing) { + await this.client.createRepositoriesNpmHosted(body) + return + } + await this.client.updateRepositoriesNpmHosted(repoName, body) + } + + private async ensureNpmGroupRepo(repoName: string, memberNames: string[]) { + const existing = await this.client.getRepositoriesNpmGroup(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames, + }, + } + if (!existing) { + await this.client.postRepositoriesNpmGroup(body) + return + } + await this.client.putRepositoriesNpmGroup(repoName, body) + } + + private async ensureMavenHostedRepos(args: { + releaseRepoName: string + snapshotRepoName: string + releaseWritePolicy: string + snapshotWritePolicy: string + }) { + await Promise.all([ + this.ensureMavenHostedRepo(args.snapshotRepoName, args.snapshotWritePolicy), + this.ensureMavenHostedRepo(args.releaseRepoName, args.releaseWritePolicy), + ]) + } + + private async ensureMavenRepos(project: ProjectWithDetails, options: EnsureMavenReposOptions) { + const releaseRepoName = generateMavenHostedRepoName(project, 'release') + const snapshotRepoName = generateMavenHostedRepoName(project, 'snapshot') + const groupRepoName = generateMavenGroupRepoName(project) + + const releasePrivilege = generateMavenHostedPrivilegeName(project, 'release') + const snapshotPrivilege = generateMavenHostedPrivilegeName(project, 'snapshot') + const groupPrivilege = generateMavenGroupPrivilegeName(project) + const releasePrivilegeReadonly = generateMavenHostedPrivilegeNameReadonly(project, 'release') + const snapshotPrivilegeReadonly = generateMavenHostedPrivilegeNameReadonly(project, 'snapshot') + const groupPrivilegeReadonly = generateMavenGroupPrivilegeNameReadonly(project) + + await this.ensureMavenHostedRepos({ + releaseRepoName, + snapshotRepoName, + releaseWritePolicy: options.releaseWritePolicy, + snapshotWritePolicy: options.snapshotWritePolicy, + }) + + await this.ensureMavenGroupRepo( + groupRepoName, + [releaseRepoName, snapshotRepoName, 'maven-public'], + ) + + const privilegesToEnsureWrite = [ + { repo: releaseRepoName, privilege: releasePrivilege }, + { repo: snapshotRepoName, privilege: snapshotPrivilege }, + { repo: groupRepoName, privilege: groupPrivilege }, + ] + const privilegesToEnsureReadonly = [ + { repo: releaseRepoName, privilege: releasePrivilegeReadonly }, + { repo: snapshotRepoName, privilege: snapshotPrivilegeReadonly }, + { repo: groupRepoName, privilege: groupPrivilegeReadonly }, + ] + await Promise.all([ + this.ensureRepositoryViewPrivileges({ + project, + type: 'maven', + format: 'maven2', + entries: privilegesToEnsureWrite, + actions: ['all'], + }), + this.ensureRepositoryViewPrivileges({ + project, + type: 'maven', + format: 'maven2', + entries: privilegesToEnsureReadonly, + actions: ['read', 'browse'], + }), + ]) + } + + private async ensureMavenGroupRepo(repoName: string, memberNames: string[]) { + const existing = await this.client.getRepositoriesMavenGroup(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames, + }, + } + if (!existing) { + await this.client.createRepositoriesMavenGroup(body) + return + } + await this.client.updateRepositoriesMavenGroup(repoName, body) + } + + private async ensureRepositoryViewPrivileges(args: { + project: ProjectWithDetails + type: string + format: string + entries: Array<{ repo: string, privilege: string }> + actions: string[] + }) { + for (const entry of args.entries) { + await this.upsertPrivilege({ + type: args.type, + name: entry.privilege, + description: `Privilege for organization ${args.project.slug} for repo ${entry.repo}`, + actions: args.actions, + format: args.format, + repository: entry.repo, + }) + } + } + + private async deleteMavenRepos(project: ProjectWithDetails) { + const hostedRepoNames = [ + generateMavenHostedRepoName(project, 'release'), + generateMavenHostedRepoName(project, 'snapshot'), + ] + const privileges = [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + await Promise.all(privileges.map(privilege => this.client.deleteSecurityPrivileges(privilege))) + // Nexus responds 500 when deleting a hosted repo still referenced by a group, so delete the group first + await this.client.deleteRepositoriesByName(generateMavenGroupRepoName(project)) + await Promise.all(hostedRepoNames.map(repo => this.client.deleteRepositoriesByName(repo))) + } + + private async ensureNpmRepos(project: ProjectWithDetails, writePolicy: string) { + const hostedRepoName = generateNpmHostedRepoName(project) + const groupRepoName = generateNpmGroupRepoName(project) + + const hostedPrivilege = generateNpmHostedPrivilegeName(project) + const groupPrivilege = generateNpmGroupPrivilegeName(project) + const hostedPrivilegeReadonly = generateNpmHostedPrivilegeNameReadonly(project) + const groupPrivilegeReadonly = generateNpmGroupPrivilegeNameReadonly(project) + + await this.ensureNpmHostedRepo(hostedRepoName, writePolicy) + await this.ensureNpmGroupRepo(groupRepoName, [hostedRepoName]) + + const privilegesToEnsureWrite = [ + { repo: hostedRepoName, privilege: hostedPrivilege }, + { repo: groupRepoName, privilege: groupPrivilege }, + ] + const privilegesToEnsureReadonly = [ + { repo: hostedRepoName, privilege: hostedPrivilegeReadonly }, + { repo: groupRepoName, privilege: groupPrivilegeReadonly }, + ] + await Promise.all([ + this.ensureRepositoryViewPrivileges({ + project, + type: 'npm', + format: 'npm', + entries: privilegesToEnsureWrite, + actions: ['all'], + }), + this.ensureRepositoryViewPrivileges({ + project, + type: 'npm', + format: 'npm', + entries: privilegesToEnsureReadonly, + actions: ['read', 'browse'], + }), + ]) + } + + private async deleteNpmRepos(project: ProjectWithDetails) { + const privileges = [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + await Promise.all(privileges.map(privilege => this.client.deleteSecurityPrivileges(privilege))) + // Nexus responds 500 when deleting a hosted repo still referenced by a group, so delete the group first + await this.client.deleteRepositoriesByName(generateNpmGroupRepoName(project)) + await this.client.deleteRepositoriesByName(generateNpmHostedRepoName(project)) + } + + private async ensureRole(project: ProjectWithDetails, privileges: string[]) { + const roleId = `${project.slug}-ID` + const role = await this.client.getSecurityRoles(roleId) + if (!role) { + await this.client.createSecurityRoles({ + id: roleId, + name: `${project.slug}-role`, + description: 'desc', + privileges, + }) + return + } + await this.client.updateSecurityRoles(roleId, { + id: roleId, + name: `${project.slug}-role`, + privileges, + }) + } + + private async ensureUser(project: ProjectWithDetails) { + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') + let existingPassword: string | undefined + try { + existingPassword = await this.vault.read(vaultPath).then(res => res.data?.NEXUS_PASSWORD) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') { + existingPassword = undefined + } else { + throw error + } + } + + const ensuredPassword = existingPassword ?? generateRandomPassword(30) + const users = await this.client.getSecurityUsers(project.slug) + const existing = users.find(u => u.userId === project.slug) + if (existing) { + if (!existingPassword || existingPassword !== ensuredPassword) { + await this.client.updateSecurityUsersChangePassword(project.slug, ensuredPassword) + } + } else { + await this.client.createSecurityUsers({ + userId: project.slug, + firstName: project.owner.firstName, + lastName: project.owner.lastName, + emailAddress: project.owner.email, + password: ensuredPassword, + status: 'active', + roles: [`${project.slug}-ID`], + }) + } + + await this.vault.write({ + NEXUS_PASSWORD: ensuredPassword, + NEXUS_USERNAME: project.slug, + }, vaultPath) + } + + private async ensureSecurityRole(id: string, privileges: string[]) { + const role = await this.client.getSecurityRoles(id) + if (!role) { + await this.client.createSecurityRoles({ + id, + name: id, + description: 'desc', + privileges, + }) + return + } + await this.client.updateSecurityRoles(id, { + id, + name: id, + privileges, + }) + } + + private async getOptionalConfigValue(project: ProjectWithDetails, key: string) { + const projectValue = getPluginConfig(project, key) + if (projectValue) return projectValue + return await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + } + + private async ensureProjectGroupRoles(project: ProjectWithDetails, args: { readOnlyPrivileges: string[], writePrivileges: string[] }) { + const rawWriteSuffixes = await this.getOptionalConfigValue(project, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES + + const rawReadSuffixes = await this.getOptionalConfigValue(project, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES + + const writeGroupPaths = generateProjectRoleGroupPath(project, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES) + const readGroupPaths = generateProjectRoleGroupPath(project, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES) + + const byId = generateRolePrivilegesMapping({ + readGroupPaths, + writeGroupPaths, + readOnlyPrivileges: args.readOnlyPrivileges, + writePrivileges: args.writePrivileges, + }) + + await Promise.all(Array.from(byId.entries(), ([id, privileges]) => this.ensureSecurityRole(id, privileges))) + } + + private async ensurePlatformRoles(projects: ProjectWithDetails[]) { + const rawWriteGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) + ?? DEFAULT_PLATFORM_WRITE_GROUP_PATHS + + const rawReadGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) + ?? DEFAULT_PLATFORM_READ_GROUP_PATHS + + const readonlyPrivileges = new Set() + const writePrivileges = new Set() + for (const project of projects) { + const computed = computeProjectPrivileges(project) + for (const privilege of computed.readOnly) readonlyPrivileges.add(privilege) + for (const privilege of computed.write) writePrivileges.add(privilege) + } + + const byId = generateRolePrivilegesMapping({ + readGroupPaths: parseOidcGroupPaths(rawReadGroupPaths || DEFAULT_PLATFORM_READ_GROUP_PATHS), + writeGroupPaths: parseOidcGroupPaths(rawWriteGroupPaths || DEFAULT_PLATFORM_WRITE_GROUP_PATHS), + readOnlyPrivileges: [...readonlyPrivileges], + writePrivileges: [...writePrivileges], + }) + + // Platform roles aggregate privileges of every Nexus-enabled project; a stale + // project (e.g. privileges missing on this instance) must not block the current one. + const entries = Array.from(byId.entries()) + const results = await Promise.allSettled(entries.map(([id, privileges]) => this.ensureSecurityRole(id, privileges))) + results.forEach((result, index) => { + if (result.status === 'rejected') { + this.logger.warn(`Failed to ensure platform role ${entries[index][0]}: ${result.reason instanceof Error ? result.reason.message : result.reason}`) + } + }) + } + + private async deleteProjectGroupRoles(project: ProjectWithDetails) { + const rawWriteSuffixes = await this.getOptionalConfigValue(project, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES + + const rawReadSuffixes = await this.getOptionalConfigValue(project, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES + + const groupPaths = [ + ...generateProjectRoleGroupPath(project, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES), + ...generateProjectRoleGroupPath(project, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES), + ] + + const ids = [...new Set(groupPaths.map(generateRoleId))] + await Promise.all(ids.map(id => this.client.deleteSecurityRoles(id))) + } + + @StartActiveSpan() + private async deleteProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + await Promise.all([ + this.deleteMavenRepos(project), + this.deleteNpmRepos(project), + ]) + + await Promise.all([ + this.deleteProjectGroupRoles(project), + this.client.deleteSecurityRoles(`${project.slug}-ID`), + this.client.deleteSecurityUsers(project.slug), + ]) + + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') + try { + await this.vault.delete(vaultPath) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + } +} + +function generateMavenHostedPrivilegeName(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${project.slug}-privilege-${kind}` +} + +function generateMavenGroupRepoName(project: ProjectWithDetails) { + return `${project.slug}-repository-group` +} + +function generateMavenGroupPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-privilege-group` +} + +function generateNpmHostedPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-npm-privilege` +} + +function generateNpmGroupRepoName(project: ProjectWithDetails) { + return `${project.slug}-npm-group` +} + +function generateNpmGroupPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-npm-group-privilege` +} + +function generateMavenHostedPrivilegeNameReadonly(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${generateMavenHostedPrivilegeName(project, kind)}-ro` +} + +function generateMavenGroupPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateMavenGroupPrivilegeName(project)}-ro` +} + +function generateNpmHostedPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateNpmHostedPrivilegeName(project)}-ro` +} + +function generateNpmGroupPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateNpmGroupPrivilegeName(project)}-ro` +} + +function generateProjectRoleGroupPath(project: ProjectWithDetails, rawGroupPathSuffixes: string) { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${project.slug}${path}`) +} + +function parseOidcGroupPaths(rawGroupPaths: string) { + return rawGroupPaths + .split(',') + .map(path => path.trim()) + .filter(Boolean) +} + +function generateRolePrivilegesMapping(args: { readGroupPaths: string[], writeGroupPaths: string[], readOnlyPrivileges: string[], writePrivileges: string[] }) { + const byId = new Map() + for (const groupPath of args.readGroupPaths) byId.set(generateRoleId(groupPath), args.readOnlyPrivileges) + for (const groupPath of args.writeGroupPaths) byId.set(generateRoleId(groupPath), args.writePrivileges) + return byId +} + +function generateRoleId(groupPath: string) { + const trimmed = groupPath.trim() + const withoutLeadingSlash = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed + return withoutLeadingSlash.replaceAll('/', '-') +} + +function computeProjectPrivileges(project: ProjectWithDetails) { + const enableMaven = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO)) ?? false + const enableNpm = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO)) ?? false + + const write = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + ] + : []), + ] + + const readOnly = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + : []), + ] + + return { readOnly, write } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.utils.ts new file mode 100644 index 0000000000..6e9e0e7645 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/nexus/nexus.utils.ts @@ -0,0 +1,26 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { randomBytes } from 'node:crypto' + +export function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +export function generateRandomPassword(length: number) { + const raw = randomBytes(Math.ceil(length * 0.75)).toString('base64url') + return raw.slice(0, length) +} + +export function getProjectVaultPath(projectRootDir: string, projectSlug: string, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return `${projectRootDir}/${projectSlug}/${normalized}` +} + +export type MavenHostedRepoKind = 'release' | 'snapshot' + +export function generateMavenHostedRepoName(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${project.slug}-repository-${kind}` +} + +export function generateNpmHostedRepoName(project: ProjectWithDetails) { + return `${project.slug}-npm` +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts new file mode 100644 index 0000000000..5f008fd21e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts @@ -0,0 +1,25 @@ +import type { OpenCdsConfig } from './opencds.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { OPENCDS_CONFIG } from './opencds.module-definition' + +@Injectable() +export class OpenCdsHealthService { + constructor( + @Inject(OPENCDS_CONFIG) private readonly opencdsConfig: OpenCdsConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const headers: Record = {} + headers.Authorization = `Bearer ${this.opencdsConfig.apiToken}` + const response = await fetch(this.opencdsConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts new file mode 100644 index 0000000000..5a31df9912 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts @@ -0,0 +1,16 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface OpenCdsConfig { + url: string + internalUrl: string + probeUrl?: string + apiToken: string + apiTlsRejectUnauthorized: boolean +} + +export interface OpenCdsModuleOptions extends OpenCdsConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('opencds') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as OPENCDS_CONFIG } diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/opencds/opencds.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/opencds/opencds.module.ts new file mode 100644 index 0000000000..edc85a987a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/opencds/opencds.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { OpenCdsHealthService } from './opencds-health.service' +import { ConfigurableModuleClass } from './opencds.module-definition' + +@Module({ + imports: [TerminusModule], + providers: [OpenCdsHealthService], + exports: [OpenCdsHealthService], +}) +export class OpenCdsModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts new file mode 100644 index 0000000000..1ef4cac975 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts @@ -0,0 +1,65 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginModule } from './plugin.module' +import { PluginService } from './plugin.service' + +describe('pluginModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits PluginService when USE_ARGOCD=false', async () => { + vi.stubEnv('USE_ARGOCD', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_ARGOCD')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_GITLAB=false', async () => { + vi.stubEnv('USE_GITLAB', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_GITLAB')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_REGISTRY=false', async () => { + vi.stubEnv('USE_REGISTRY', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_REGISTRY')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_KEYCLOAK=false', async () => { + vi.stubEnv('USE_KEYCLOAK', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_KEYCLOAK')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_NEXUS=false', async () => { + vi.stubEnv('USE_NEXUS', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_NEXUS')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_SONARQUBE=false', async () => { + vi.stubEnv('USE_SONARQUBE', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_SONARQUBE')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_VAULT')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.module.ts new file mode 100644 index 0000000000..386aed9e53 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { ArgoCDModule } from '../argocd/argocd.module' +import { GitlabModule } from '../gitlab/gitlab.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { NexusModule } from '../nexus/nexus.module' +import { RegistryModule } from '../registry/registry.module' +import { SonarqubeModule } from '../sonarqube/sonarqube.module' +import { VaultModule } from '../vault/vault.module' +import { PluginService } from './plugin.service' + +@Module({ + imports: [ + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + ConditionalModule.registerWhen(RegistryModule, 'USE_REGISTRY'), + ConditionalModule.registerWhen(KeycloakModule, 'USE_KEYCLOAK'), + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(SonarqubeModule, 'USE_SONARQUBE'), + ConditionalModule.registerWhen(VaultModule, 'USE_VAULT'), + ], + providers: [PluginService], + exports: [PluginService], +}) +export class PluginModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.service.ts new file mode 100644 index 0000000000..1b9303ee53 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.service.ts @@ -0,0 +1,46 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { ArgoCDPluginService } from '../argocd/argocd-plugin.service' +import { GitlabPluginService } from '../gitlab/gitlab-plugin.service' +import { KeycloakPluginService } from '../keycloak/keycloak-plugin.service' +import { NexusPluginService } from '../nexus/nexus-plugin.service' +import { RegistryPluginService } from '../registry/registry-plugin.service' +import { SonarqubePluginService } from '../sonarqube/sonarqube-plugin.service' +import { VaultPluginService } from '../vault/vault-plugin.service' + +@Injectable() +export class PluginService { + private readonly logger = new Logger(PluginService.name) + + constructor( + @Inject(ArgoCDPluginService) private readonly argoCDPlugin: ArgoCDPluginService, + @Inject(GitlabPluginService) private readonly gitlabPlugin: GitlabPluginService, + @Inject(RegistryPluginService) private readonly registryPlugin: RegistryPluginService, + @Inject(KeycloakPluginService) private readonly keycloakPlugin: KeycloakPluginService, + @Inject(NexusPluginService) private readonly nexusPlugin: NexusPluginService, + @Inject(SonarqubePluginService) private readonly sonarqubePlugin: SonarqubePluginService, + @Inject(VaultPluginService) private readonly vaultPlugin: VaultPluginService, + ) {} + + async infos(projectId: string): Promise { + const plugins = [ + ['argocd', () => this.argoCDPlugin.infos()], + ['gitlab', () => this.gitlabPlugin.infos()], + ['registry', () => this.registryPlugin.infos(projectId)], + ['keycloak', () => this.keycloakPlugin.infos()], + ['nexus', () => this.nexusPlugin.infos()], + ['sonarqube', () => this.sonarqubePlugin.infos()], + ['vault', () => this.vaultPlugin.infos()], + ] as const + + const settled = await Promise.allSettled(plugins.map(([, loadInfos]) => loadInfos())) + return settled.flatMap((result, index) => { + const [pluginName] = plugins[index] + if (result.status === 'fulfilled') { + return [result.value] + } + this.logger.warn(`Skipping project service plugin ${pluginName} because infos() failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`) + return [] + }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts new file mode 100644 index 0000000000..3a2ecd238e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { capturePluginResult, getFailedPlugins, mergePluginResults } from './plugin.utils' + +describe('capturePluginResult', () => { + it('should resolve a successful task into an OK result with the default message, keyed under the plugin name', async () => { + await expect(capturePluginResult('gitlab', async () => {})).resolves.toEqual({ + gitlab: { + status: 'OK', + message: 'Up to date', + executionTime: expect.any(Number), + }, + }) + }) + + it('should use the string returned by the task as message', async () => { + await expect(capturePluginResult('gitlab', async () => 'Everything synced')).resolves.toEqual({ + gitlab: expect.objectContaining({ status: 'OK', message: 'Everything synced' }), + }) + }) + + it('should resolve a throwing task into a KO result instead of rejecting', async () => { + const task = async () => { + throw new Error('GitLab unreachable') + } + await expect(capturePluginResult('gitlab', task)).resolves.toEqual({ + gitlab: { + status: 'KO', + message: 'GitLab unreachable', + executionTime: expect.any(Number), + error: new Error('GitLab unreachable'), + }, + }) + }) + + it('should report a fallback message for non-Error throws', async () => { + const task = async () => { + throw 'boom' // eslint-disable-line no-throw-literal + } + await expect(capturePluginResult('gitlab', task)).resolves.toEqual({ + gitlab: expect.objectContaining({ status: 'KO', message: 'Erreur inconnue', error: 'boom' }), + }) + }) + + it('should report under the given plugin name', async () => { + await expect(capturePluginResult('nexus', async () => 'custom message')).resolves.toEqual({ + nexus: expect.objectContaining({ status: 'OK', message: 'custom message' }), + }) + }) +}) + +describe('mergePluginResults', () => { + it('should return empty object when given empty array', () => { + expect(mergePluginResults([])).toEqual({}) + }) + + it('should return single result as-is', () => { + const result = [{ argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }] + expect(mergePluginResults(result)).toEqual(result[0]) + }) + + it('should merge multiple results into one', () => { + const results = [ + { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }, + { gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 } }, + ] + expect(mergePluginResults(results)).toEqual({ + argocd: { status: 'OK', message: 'Up to date', executionTime: 10 }, + gitlab: { status: 'OK', message: 'Synced', executionTime: 20 }, + }) + }) + + it('should have later entries overwrite earlier ones for the same plugin', () => { + const error = new Error('sync error') + const results = [ + { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }, + { argocd: { status: 'KO' as const, message: 'Failed', executionTime: 20, error } }, + ] + expect(mergePluginResults(results)).toEqual({ + argocd: { status: 'KO', message: 'Failed', executionTime: 20, error }, + }) + }) +}) + +describe('getFailedPlugins', () => { + it('should return empty array when all plugins are OK', () => { + const results = { + argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 }, + gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 }, + } + expect(getFailedPlugins(results)).toEqual([]) + }) + + it('should return names of KO plugins', () => { + const error = new Error('sync error') + const results = { + argocd: { status: 'KO' as const, message: 'Failed', executionTime: 10, error }, + gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 }, + } + expect(getFailedPlugins(results)).toEqual(['argocd']) + }) + + it('should return all plugins when all fail', () => { + const error = new Error('error') + const results = { + argocd: { status: 'KO' as const, message: 'Failed', executionTime: 10, error }, + gitlab: { status: 'KO' as const, message: 'Failed', executionTime: 20, error }, + } + const failed = getFailedPlugins(results) + expect(failed).toHaveLength(2) + expect(failed).toContain('argocd') + expect(failed).toContain('gitlab') + }) + + it('should return empty array for empty result', () => { + expect(getFailedPlugins({})).toEqual([]) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.utils.ts new file mode 100644 index 0000000000..a54447a6a0 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/plugin/plugin.utils.ts @@ -0,0 +1,107 @@ +import type { ToUrlFnParamaters } from '@cpn-console/hooks' +import { Logger } from '@nestjs/common' + +export function makeToUrlParams(overrides: Partial = {}): ToUrlFnParamaters { + return { + store: {}, + clusters: [], + zones: [], + environments: [], + project: { id: '', slug: 'dulei', name: '' }, + ...overrides, + } +} +export type PluginName = 'argocd' + | 'gitlab' + | 'nexus' + | 'vault' + | 'keycloak' + | 'harbor' + | 'sonarqube' + | 'observability' + +export type PluginResult = { + status: 'OK' + message: string + executionTime: number +} | { + status: 'KO' + message: string + executionTime: number + error: unknown +} + +export type PluginResults = Partial> + +export type RequiredPluginResult + = { [K in T]: PluginResult } & PluginResults + +const logger = new Logger('PluginResult') + +/** + * Runs a plugin task and always resolves with the task's outcome (status, + * message, execution time and error, if any) keyed under the given plugin + * name, instead of throwing, so the event emitter can merge and persist the + * results of every listener. + * + * The task keeps its natural shape: do the work, throw on failure, optionally + * return a message string to override the default 'Up to date'. + * + * The `@OnEvent` handler stays a thin, honestly-typed wrapper, while the + * traced work method keeps `@StartActiveSpan` so the span still records the + * exception on failure: + * + * ```ts + * @OnEvent('project.upsert') + * async handleUpsert(project: ProjectWithDetails): Promise> { + * return capturePluginResult('argocd', () => this.syncProject(project)) + * } + * + * @StartActiveSpan() + * private async syncProject(project: ProjectWithDetails) { ... } + * ``` + */ +export async function capturePluginResult

( + plugin: P, + task: () => Promise, +): Promise> { + const start = process.hrtime.bigint() + const elapsedMs = () => Number(process.hrtime.bigint() - start) / 1_000_000 + + try { + const message = await task() + return keyedBy(plugin, { + status: 'OK', + message: typeof message === 'string' ? message : 'Up to date', + executionTime: elapsedMs(), + }) + } catch (error: unknown) { + logger.error(`${plugin} handler failed`, error) + return keyedBy(plugin, { + status: 'KO', + message: error instanceof Error ? error.message : 'Erreur inconnue', + executionTime: elapsedMs(), + error, + }) + } +} + +// TypeScript widens a computed property with a generic key to an index +// signature instead of Record, so the assertion is confined here. +function keyedBy

(plugin: P, result: PluginResult): RequiredPluginResult

{ + return { [plugin]: result } as RequiredPluginResult

+} + +export function mergePluginResults(responses: PluginResults[]): PluginResults { + return responses.reduce((merged, currentResponse) => { + return { ...merged, ...currentResponse } + }, {} as PluginResults) +} + +export function getFailedPlugins(response: PluginResults): PluginName[] { + const entries = Object.entries(response) as [PluginName, PluginResult][] + + return entries + .filter(([_, result]) => result.status === 'KO') + .map(([pluginName]) => pluginName) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts new file mode 100644 index 0000000000..73858d492b --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts @@ -0,0 +1,16 @@ +import type { Prisma } from '@prisma/client' + +export const projectNotArchivedSelect = { + id: true, +} satisfies Prisma.ProjectSelect + +export type ProjectIdNotArchived = Prisma.ProjectGetPayload<{ + select: typeof projectNotArchivedSelect +}> + +export function listProjectIdsNotArchived(tx: Prisma.TransactionClient): Promise { + return tx.project.findMany({ + select: projectNotArchivedSelect, + where: { status: { not: 'archived' } }, + }) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts new file mode 100644 index 0000000000..82874da42d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts @@ -0,0 +1,36 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { projectContract } from '@cpn-console/shared' +import { Body, Controller, HttpCode, HttpStatus, Inject, Logger, Post, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectBulkService } from './project-bulk.service' + +@Controller('api/v1/projects-bulk') +export class ProjectBulkController { + private readonly logger = new Logger(ProjectBulkController.name) + + constructor( + @Inject(ProjectBulkService) private readonly projectBulk: ProjectBulkService, + ) {} + + @Post('') + @HttpCode(HttpStatus.ACCEPTED) + @UseGuards(UserGuard) + @RequireAdminPermission('Manage') + async bulkAction( + @Body(new ZodValidationPipe(projectContract.bulkActionProject.body)) body: typeof projectContract.bulkActionProject.body._type, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + const target = body.projectIds === 'all' + ? 'all' + : `count=${body.projectIds.length}` + + this.logger.log(`project.bulkAction requested (action=${body.action}, target=${target})`) + await this.projectBulk.bulkAction(body, user.userId, request.id) + this.logger.log(`project.bulkAction accepted (action=${body.action})`) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts new file mode 100644 index 0000000000..fbb1d9bf76 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { ProjectHooksModule } from '../project-hooks/project-hooks.module' +import { ProjectModule } from '../project/project.module' +import { ProjectBulkController } from './project-bulk.controller' +import { ProjectBulkService } from './project-bulk.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ProjectModule, + ProjectHooksModule, + ], + controllers: [ProjectBulkController], + providers: [ProjectBulkService], +}) +export class ProjectBulkModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts new file mode 100644 index 0000000000..37ec381d2e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts @@ -0,0 +1,85 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { ProjectHooksService } from '../project-hooks/project-hooks.service' +import { makeProject } from '../project/project-testing.utils' +import { ProjectService } from '../project/project.service' +import { ProjectBulkService } from './project-bulk.service' + +describe('projectBulkService', () => { + let module: TestingModule + let service: ProjectBulkService + let prisma: DeepMockProxy + let project: DeepMockProxy + let projectHooks: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + project = mockDeep() + projectHooks = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectBulkService, + { provide: PrismaService, useValue: prisma }, + { provide: ProjectService, useValue: project }, + { provide: ProjectHooksService, useValue: projectHooks }, + ], + }).compile() + + service = module.get(ProjectBulkService) + }) + + it('processes specific project ids', async () => { + const projectIds = [faker.string.uuid(), faker.string.uuid()] + + await service.bulkAction({ action: 'archive', projectIds }, 'user-id', 'request-id') + + expect(project.archive).toHaveBeenCalledTimes(2) + expect(project.archive).toHaveBeenCalledWith(projectIds[0], 'user-id', 'request-id') + expect(project.archive).toHaveBeenCalledWith(projectIds[1], 'user-id', 'request-id') + }) + + it('resolves "all" to all non-archived project ids', async () => { + const project1Id = faker.string.uuid() + const project2Id = faker.string.uuid() + + prisma.project.findMany.mockResolvedValue([makeProject({ id: project1Id }), makeProject({ id: project2Id })]) + + await service.bulkAction({ action: 'archive', projectIds: 'all' }, 'user-id', 'request-id') + + expect(prisma.project.findMany).toHaveBeenCalledWith({ + select: { id: true }, + where: { status: { not: 'archived' } }, + }) + expect(project.archive).toHaveBeenCalledTimes(2) + }) + + it('lock action updates locked to true via project hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'lock', projectIds: [projectId] }) + + expect(projectHooks.updateProjectLocked).toHaveBeenCalledWith(projectId, true) + }) + + it('unlock action updates locked to false via project hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'unlock', projectIds: [projectId] }) + + expect(projectHooks.updateProjectLocked).toHaveBeenCalledWith(projectId, false) + }) + + it('replay action triggers hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'replay', projectIds: [projectId] }, 'user-id', 'request-id') + + expect(projectHooks.replay).toHaveBeenCalledWith(projectId, 'user-id', 'request-id') + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts new file mode 100644 index 0000000000..5989764d17 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts @@ -0,0 +1,96 @@ +import type { projectContract } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { ProjectHooksService } from '../project-hooks/project-hooks.service' +import { ProjectService } from '../project/project.service' +import { listProjectIdsNotArchived } from './project-bulk-queries.utils' + +@Injectable() +export class ProjectBulkService { + private readonly logger = new Logger(ProjectBulkService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(ProjectService) private readonly project: ProjectService, + @Inject(ProjectHooksService) private readonly projectHooks: ProjectHooksService, + ) {} + + @StartActiveSpan() + async bulkAction( + data: typeof projectContract.bulkActionProject.body._type, + requestorUserId?: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.bulk.action', data.action) + const projectIdsLog = data.projectIds === 'all' ? 'all' : `count=${data.projectIds.length}` + this.logger.log(`project.bulkAction started (action=${data.action}, projectIds=${projectIdsLog})`) + + try { + const projectIds = await this.resolveProjectIds(data.projectIds) + span?.setAttribute('project.bulk.count', projectIds.length) + + const results = await this.runTasks(projectIds, data.action, requestorUserId, requestId) + const summary = this.summarizeTasks(results) + + span?.setAttributes({ + 'project.bulk.fulfilled': summary.fulfilled, + 'project.bulk.rejected': summary.rejected, + }) + this.logger.log(`project.bulkAction completed (action=${data.action}, projectCount=${projectIds.length}, fulfilled=${summary.fulfilled}, rejected=${summary.rejected})`) + } catch (error) { + const projectIdsLabel = data.projectIds === 'all' + ? 'all' + : `count=${data.projectIds.length}` + const errorMessage = error instanceof Error ? error.message : String(error) + + this.logger.error( + `project.bulkAction failed (action=${data.action}, projectIds=${projectIdsLabel}): ${errorMessage}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async resolveProjectIds(projectIds: string[] | 'all'): Promise { + if (projectIds === 'all') { + const result = await listProjectIdsNotArchived(this.prisma) + return result.map(p => p.id) + } + return projectIds + } + + private async runTasks( + projectIds: string[], + action: string, + requestorUserId?: string, + requestId?: string, + ): Promise[]> { + const tasks = projectIds.map((projectId) => { + if (action === 'archive') { + return this.project.archive(projectId, requestorUserId, requestId) + } + if (action === 'lock' || action === 'unlock') { + return this.projectHooks.updateProjectLocked(projectId, action === 'lock') + } + if (action === 'replay') { + return this.projectHooks.replay(projectId, requestorUserId, requestId) + } + return Promise.resolve() + }) + return Promise.allSettled(tasks) + } + + private summarizeTasks(results: PromiseSettledResult[]): { fulfilled: number, rejected: number } { + return results.reduce( + (acc, r) => { + if (r.status === 'fulfilled') acc.fulfilled += 1 + else acc.rejected += 1 + return acc + }, + { fulfilled: 0, rejected: 0 }, + ) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts new file mode 100644 index 0000000000..9dd8e67e34 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts @@ -0,0 +1,32 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Controller, HttpCode, HttpStatus, Inject, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ProjectHooksService } from './project-hooks.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectHooksController { + constructor( + @Inject(ProjectHooksService) private readonly projectHooks: ProjectHooksService, + ) {} + + @Put('/:projectId/hooks') + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ReplayHooks') + async replay( + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + await this.projectHooks.replay(project.id, user.userId, request.id) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts new file mode 100644 index 0000000000..ff83fb82fb --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { LogModule } from '../log/log.module' +import { ProjectHooksController } from './project-hooks.controller' +import { ProjectHooksService } from './project-hooks.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, EventsModule, LogModule, ProjectPermissionModule], + controllers: [ProjectHooksController], + providers: [ProjectHooksService], + exports: [ProjectHooksService], +}) +export class ProjectHooksModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts new file mode 100644 index 0000000000..92d9ddf77e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts @@ -0,0 +1,80 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { projectSelect } from '../project/project-queries.utils' +import { makeProject } from '../project/project-testing.utils' +import { ProjectHooksService } from './project-hooks.service' + +describe('projectHooksService', () => { + let module: TestingModule + let service: ProjectHooksService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectHooksService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(ProjectHooksService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('updateProjectLocked updates the project locked flag', async () => { + const project = makeProject({ locked: true }) + prisma.project.update.mockResolvedValue(project) + + await service.updateProjectLocked('project-id', true) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 'project-id' }, + data: { locked: true }, + select: projectSelect, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', project, { action: 'Update Project' }) + }) + + it('replayHooks loads the project and emits project.upsert', async () => { + const project = makeProject() + prisma.project.findFirst.mockResolvedValue(project) + + const requestId = 'request-id' + const userId = 'user-id' + + await service.replay('project-id', userId, requestId) + + expect(prisma.project.findFirst).toHaveBeenCalledWith({ + where: { id: 'project-id', status: { not: 'archived' } }, + select: projectSelect, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', project, { + action: 'Replay hooks for Project', + userId, + requestId, + }) + }) + + it('replayHooks rejects locked projects', async () => { + const project = makeProject({ locked: true }) + prisma.project.findFirst.mockResolvedValue(project) + + await expect(service.replay('project-id', 'user-id', 'request-id')).rejects.toThrow(ForbiddenException) + + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts new file mode 100644 index 0000000000..b51cd4df76 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts @@ -0,0 +1,46 @@ +import { ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { getProjectNotArchived, projectSelect } from '../project/project-queries.utils' + +@Injectable() +export class ProjectHooksService { + private readonly logger = new Logger(ProjectHooksService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async updateProjectLocked(projectId: string, locked: boolean): Promise { + const project = await this.prisma.project.update({ + where: { id: projectId }, + data: { locked }, + select: projectSelect, + }) + await this.appEvents.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + } + + async replay(projectId: string, requestorUserId?: string, requestId?: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.replayHooks started (projectId=${projectId})`) + const project = await getProjectNotArchived(this.prisma, projectId) + if (!project) { + this.logger.warn(`project.replayHooks notFound (projectId=${projectId})`) + return + } + if (project.locked) { + this.logger.warn(`project.replayHooks locked (projectId=${projectId})`) + throw new ForbiddenException('Veuillez déverrouiller le projet pour rejouer les webhooks') + } + span?.setAttribute('project.slug', project.slug) + await this.appEvents.emitProjectEvent('project.upsert', project, { + action: 'Replay hooks for Project', + userId: requestorUserId, + requestId, + }) + this.logger.log(`project.replayHooks completed (projectId=${projectId})`) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts new file mode 100644 index 0000000000..ad12c2486d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts @@ -0,0 +1,78 @@ +import type { Prisma } from '@prisma/client' + +export const projectMemberWithUser = { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, +} satisfies Prisma.ProjectMembersInclude + +export const projectOwnerIdSelect = { + ownerId: true, +} satisfies Prisma.ProjectSelect + +export type ProjectMemberWithUser = Prisma.ProjectMembersGetPayload<{ + include: typeof projectMemberWithUser +}> + +export type ProjectOwnerId = Prisma.ProjectGetPayload<{ + select: typeof projectOwnerIdSelect +}> + +export type AddMemberInput = { email: string } | { userId: string } +export interface PatchMemberInput { userId: string, roles: string[] } + +export function listProjectMembersWithUser(tx: Prisma.TransactionClient, projectId: string) { + return tx.projectMembers.findMany({ + where: { projectId }, + include: projectMemberWithUser, + }) +} + +export function upsertProjectMember( + tx: Prisma.TransactionClient, + projectId: string, + member: PatchMemberInput, +) { + const { userId, roles } = member + return tx.projectMembers.upsert({ + where: { projectId_userId: { projectId, userId } }, + create: { projectId, userId, roleIds: roles }, + update: { roleIds: roles }, + }) +} + +export function upsertProjectMemberIfMissing(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.upsert({ + where: { projectId_userId: { projectId, userId } }, + create: { projectId, userId, roleIds: [] }, + update: {}, + }) +} + +export function createProjectMember(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.create({ data: { projectId, userId } }) +} + +export function deleteProjectMember(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.delete({ where: { projectId_userId: { projectId, userId } } }) +} + +export function getHumanUser(tx: Prisma.TransactionClient, opts: { userId?: string, email?: string }) { + const { userId, email } = opts + return tx.user.findFirst({ + where: { + type: 'human', + ...(typeof userId === 'string' ? { id: userId } : {}), + ...(typeof email === 'string' ? { email } : {}), + }, + }) +} + +export function getProjectOwnerId(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectOwnerIdSelect }) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts new file mode 100644 index 0000000000..e01e7f37ea --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts @@ -0,0 +1,11 @@ +import type { ProjectMembers } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } satisfies ProjectMembers +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.controller.ts new file mode 100644 index 0000000000..1761096669 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.controller.ts @@ -0,0 +1,73 @@ +import type { Member } from '@cpn-console/shared' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { AddMemberInput, PatchMemberInput } from './project-members-queries.utils' +import { projectMemberContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Logger, Param, Patch, Post, UseGuards } from '@nestjs/common' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectMembersService } from './project-members.service' +import { generateProjectMember } from './project-members.utils' + +@Controller('api/v1/projects/:projectId/members') +@UseGuards(ProjectGuard) +export class ProjectMembersController { + private readonly logger = new Logger(ProjectMembersController.name) + + constructor( + @Inject(ProjectMembersService) private readonly projectMembers: ProjectMembersService, + ) {} + + @Get() + @RequireProjectPermission('ListMembers') + async list( + @Project() project: ProjectContext, + ): Promise { + return (await this.projectMembers.list(project.id)).map(generateProjectMember) + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async add( + @Body(new ZodValidationPipe(projectMemberContract.addMember.body)) body: AddMemberInput, + @Project() project: ProjectContext, + ): Promise { + const members = await this.projectMembers.add(project.id, body) + this.logger.log(`projectMembers.add completed (memberCount=${members.length})`) + return members.map(generateProjectMember) + } + + @Patch() + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async patch( + @Body(new ZodValidationPipe(projectMemberContract.patchMembers.body)) body: PatchMemberInput[], + @Project() project: ProjectContext, + ): Promise { + const members = await this.projectMembers.patch(project.id, body) + this.logger.log(`projectMembers.patchMembers completed (projectId=${project.id}, memberCount=${members.length})`) + return members.map(generateProjectMember) + } + + @Delete('/:userId') + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async remove( + @Project() project: ProjectContext, + @Param('userId') userId: string, + ): Promise { + const members = await this.projectMembers.remove(project.id, userId) + this.logger.log(`projectMembers.remove completed (projectId=${project.id}, userId=${userId}, memberCount=${members.length})`) + return members.map(generateProjectMember) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.module.ts new file mode 100644 index 0000000000..481aeddcef --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { keycloakConfigFactory } from '../../config/keycloak.config' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { ProjectMembersController } from './project-members.controller' +import { ProjectMembersService } from './project-members.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, KeycloakModule.forRoot(keycloakConfigFactory.asProvider()), ProjectPermissionModule], + controllers: [ProjectMembersController], + providers: [ProjectMembersService], +}) +export class ProjectMembersModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts new file mode 100644 index 0000000000..48ca9eeaaa --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts @@ -0,0 +1,268 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { KeycloakClientService } from '../keycloak/keycloak-client.service' +import { + makeProject, + makeProjectMembers, + makeProjectMemberWithUser, + makeUser, +} from '../project/project-testing.utils' +import { ProjectMembersService } from './project-members.service' + +describe('projectMembersService', () => { + let module: TestingModule + let service: ProjectMembersService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + let keycloak: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + keycloak = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectMembersService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + { provide: KeycloakClientService, useValue: keycloak }, + ], + }).compile() + + service = module.get(ProjectMembersService) + }) + + describe('list', () => { + it('returns members', async () => { + const projectId = faker.string.uuid() + const user1 = makeUser() + const user2 = makeUser() + prisma.projectMembers.findMany.mockResolvedValue([ + makeProjectMemberWithUser(user1, { roleIds: [faker.string.uuid()] }), + makeProjectMemberWithUser(user2), + ]) + + const result = await service.list(projectId) + + expect(result).toHaveLength(2) + expect(result[0]).toHaveProperty('roleIds') + expect(result[0]).toHaveProperty('user') + expect(result[0].user.id).toBe(user1.id) + }) + + it('returns empty array when no members', async () => { + const projectId = faker.string.uuid() + prisma.projectMembers.findMany.mockResolvedValue([]) + + const result = await service.list(projectId) + + expect(result).toEqual([]) + }) + }) + + describe('add', () => { + it('adds member by userId and returns updated member list', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const newUserId = faker.string.uuid() + const body = { userId: newUserId } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ id: newUserId })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId, userId: newUserId })) + const memberUser = makeUser() + tx.projectMembers.findMany.mockResolvedValue([makeProjectMemberWithUser(memberUser)]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.add(projectId, body) + + expect(tx.projectMembers.upsert).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId, userId: newUserId } }, + create: { projectId, userId: newUserId, roleIds: [] }, + update: {}, + }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: newUserId, + }, { action: 'Add Project Member' }) + expect(result).toBeDefined() + }) + + it('adds member by email and returns updated member list', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const email = faker.internet.email() + const body = { email } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ email })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId })) + tx.projectMembers.findMany.mockResolvedValue([]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.add(projectId, body) + + expect(tx.user.findFirst).toHaveBeenCalledWith({ + where: { email, type: 'human' }, + }) + expect(result).toBeDefined() + }) + + it('throws when adding owner as member', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const body = { userId: ownerId } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ id: ownerId })) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow('Le owner ne peut pas être ajouté à cette liste') + }) + + it('throws NotFoundException when user not found by userId', async () => { + const projectId = faker.string.uuid() + const body = { userId: faker.string.uuid() } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId: faker.string.uuid() })) + tx.user.findFirst.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow(NotFoundException) + }) + + it('falls back to Keycloak when user not found locally by email', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const email = faker.internet.email() + const keycloakUserId = faker.string.uuid() + const body = { email } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(null) + tx.user.upsert.mockResolvedValue(makeUser({ id: keycloakUserId, email })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId, userId: keycloakUserId })) + tx.projectMembers.findMany.mockResolvedValue([]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + keycloak.getUserByEmail.mockResolvedValue(makeUser({ + id: keycloakUserId, + email, + firstName: 'Keycloak', + lastName: 'User', + })) + + const result = await service.add(projectId, body) + + expect(keycloak.getUserByEmail).toHaveBeenCalledWith(email) + expect(tx.user.upsert).toHaveBeenCalledWith({ + where: { id: keycloakUserId }, + create: { + id: keycloakUserId, + email, + firstName: 'Keycloak', + lastName: 'User', + adminRoleIds: [], + type: 'human', + }, + update: { + email, + firstName: 'Keycloak', + lastName: 'User', + type: 'human', + }, + }) + expect(result).toBeDefined() + }) + + it('throws NotFoundException when user not found by email anywhere', async () => { + const projectId = faker.string.uuid() + const body = { email: faker.internet.email() } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId: faker.string.uuid() })) + tx.user.findFirst.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow(NotFoundException) + }) + + it('throws NotFoundException when project does not exist', async () => { + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.add(faker.string.uuid(), { userId: faker.string.uuid() }), + ).rejects.toThrow(NotFoundException) + }) + }) + + describe('patch', () => { + it('upserts multiple members and emits events', async () => { + const projectId = faker.string.uuid() + const members = [ + { userId: faker.string.uuid(), roles: [faker.string.uuid()] }, + { userId: faker.string.uuid(), roles: [] }, + ] + + const tx = mockDeep() + prisma.$transaction.mockImplementation(async cb => cb(tx)) + tx.projectMembers.findMany.mockResolvedValue([]) + + await service.patch(projectId, members) + + expect(tx.projectMembers.upsert).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: members[0].userId, + }, { action: 'Update Project Member' }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: members[1].userId, + }, { action: 'Update Project Member' }) + }) + }) + + describe('remove', () => { + it('deletes member, emits event, returns updated list', async () => { + const projectId = faker.string.uuid() + const userId = faker.string.uuid() + + const tx = mockDeep() + tx.projectMembers.delete.mockResolvedValue(makeProjectMembers({ projectId, userId })) + const memberUser = makeUser() + tx.projectMembers.findMany.mockResolvedValue([makeProjectMemberWithUser(memberUser)]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.remove(projectId, userId) + + expect(tx.projectMembers.delete).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId, userId } }, + }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.delete', { + projectId, + userId, + }, { action: 'Remove Project Member' }) + expect(result).toBeDefined() + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.service.ts new file mode 100644 index 0000000000..4daa282eaa --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.service.ts @@ -0,0 +1,164 @@ +import type { AddMemberInput, PatchMemberInput } from './project-members-queries.utils' +import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { KeycloakClientService } from '../keycloak/keycloak-client.service' +import { deleteProjectMember, getHumanUser, getProjectOwnerId, listProjectMembersWithUser, upsertProjectMember, upsertProjectMemberIfMissing } from './project-members-queries.utils' + +@Injectable() +export class ProjectMembersService { + private readonly logger = new Logger(ProjectMembersService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, + ) {} + + @StartActiveSpan() + async list(projectId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.debug(`projectMembers.listMembers started (projectId=${projectId})`) + const members = await listProjectMembersWithUser(this.prisma, projectId) + span?.setAttribute('project.members.count', members.length) + this.logger.debug(`projectMembers.listMembers completed (projectId=${projectId}, count=${members.length})`) + return members + } + + @StartActiveSpan() + async add( + projectId: string, + body: AddMemberInput, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + const lookupBy = 'userId' in body ? 'userId' : 'email' + span?.setAttribute('project.member.lookupBy', lookupBy) + const userIdCandidate = 'userId' in body ? body.userId : undefined + this.logger.log(`projectMembers.addMember started (projectId=${projectId}, lookupBy=${lookupBy}, userId=${userIdCandidate})`) + try { + const result = await this.prisma.$transaction(async (tx) => { + const project = await getProjectOwnerId(tx, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + + const userId = 'userId' in body ? body.userId : undefined + const email = 'email' in body ? body.email : undefined + + const userDb = await this.resolveHumanUser(tx, { userId, email }) + if (!userDb) throw new NotFoundException('Utilisateur introuvable') + + if (userDb.id === project.ownerId) { + throw new BadRequestException('Le owner ne peut pas être ajouté à cette liste') + } + + await upsertProjectMemberIfMissing(tx, projectId, userDb.id) + + const members = await listProjectMembersWithUser(tx, projectId) + return { userId: userDb.id, members } + }) + await this.appEvents.emitProjectMemberEvent('projectMember.upsert', { projectId, userId: result.userId }, { action: 'Add Project Member' }) + span?.setAttribute('project.member.userId', result.userId) + span?.setAttribute('project.members.count', result.members.length) + this.logger.log(`projectMembers.addMember completed (projectId=${projectId}, userId=${result.userId}, memberCount=${result.members.length})`) + return result.members + } catch (error) { + this.logger.error( + `projectMembers.addMember failed (projectId=${projectId}, lookupBy=${lookupBy}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async resolveHumanUser( + tx: Parameters[0], + opts: { userId?: string, email?: string }, + ) { + const userDb = await getHumanUser(tx, opts) + if (userDb || !opts.email) { + return userDb + } + + const keycloakUser = await this.keycloak.getUserByEmail(opts.email) + if (!keycloakUser) { + return null + } + + const keycloakUserId = keycloakUser.id ?? opts.email + return tx.user.upsert({ + where: { id: keycloakUserId }, + create: { + id: keycloakUserId, + email: keycloakUser.email ?? opts.email, + firstName: keycloakUser.firstName ?? keycloakUser.email ?? opts.email, + lastName: keycloakUser.lastName ?? '', + adminRoleIds: [], + type: 'human', + }, + update: { + email: keycloakUser.email ?? opts.email, + firstName: keycloakUser.firstName ?? keycloakUser.email ?? opts.email, + lastName: keycloakUser.lastName ?? '', + type: 'human', + }, + }) + } + + @StartActiveSpan() + async patch( + projectId: string, + body: PatchMemberInput[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('project.members.patch.count', body.length) + this.logger.log(`projectMembers.patchMembers started (projectId=${projectId}, patchCount=${body.length})`) + try { + const members = await this.prisma.$transaction(async (tx) => { + for (const member of body) { + await upsertProjectMember(tx, projectId, member) + } + return listProjectMembersWithUser(tx, projectId) + }) + await Promise.all( + body.map(member => this.appEvents.emitProjectMemberEvent('projectMember.upsert', { projectId, userId: member.userId }, { action: 'Update Project Member' })), + ) + span?.setAttribute('project.members.count', members.length) + this.logger.log(`projectMembers.patchMembers completed (projectId=${projectId}, memberCount=${members.length})`) + return members + } catch (error) { + this.logger.error( + `projectMembers.patchMembers failed (projectId=${projectId}, patchCount=${body.length}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async remove(projectId: string, userId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('project.member.userId', userId) + this.logger.log(`projectMembers.removeMember started (projectId=${projectId}, userId=${userId})`) + try { + const members = await this.prisma.$transaction(async (tx) => { + await deleteProjectMember(tx, projectId, userId) + return listProjectMembersWithUser(tx, projectId) + }) + await this.appEvents.emitProjectMemberEvent('projectMember.delete', { projectId, userId }, { action: 'Remove Project Member' }) + span?.setAttribute('project.members.count', members.length) + this.logger.log(`projectMembers.removeMember completed (projectId=${projectId}, userId=${userId}, memberCount=${members.length})`) + return members + } catch (error) { + this.logger.error( + `projectMembers.removeMember failed (projectId=${projectId}, userId=${userId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.utils.ts new file mode 100644 index 0000000000..4131cfbebd --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-members/project-members.utils.ts @@ -0,0 +1,13 @@ +import type { Member } from '@cpn-console/shared' +import type { ProjectMemberWithUser } from './project-members-queries.utils' + +export function generateProjectMember(member: ProjectMemberWithUser): Member { + const { roleIds, user } = member + return { + userId: user.id, + roleIds, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts new file mode 100644 index 0000000000..20a4355031 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts @@ -0,0 +1,47 @@ +import type { Prisma } from '@prisma/client' + +export const projectRoleWithProjectSelect = { + id: true, + name: true, + permissions: true, + position: true, + oidcGroup: true, + type: true, + projectId: true, + project: { + select: { + slug: true, + members: { + distinct: 'userId', + }, + }, + }, +} satisfies Prisma.ProjectRoleSelect + +export const projectRoleForDeleteSelect = { + type: true, + projectId: true, +} satisfies Prisma.ProjectRoleSelect + +export const projectForRoleContextSelect = { + id: true, + slug: true, + status: true, + locked: true, +} satisfies Prisma.ProjectSelect + +export type ProjectRoleWithProject = Prisma.ProjectRoleGetPayload<{ select: typeof projectRoleWithProjectSelect }> + +export type ProjectForRoleContext = Prisma.ProjectRoleGetPayload<{ select: typeof projectForRoleContextSelect }> + +export function getProjectBySlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: { slug: true } }) +} + +export function getProjectRoleForDelete(tx: Prisma.TransactionClient, roleId: string) { + return tx.projectRole.findUnique({ where: { id: roleId }, select: projectRoleForDeleteSelect }) +} + +export function getProjectForUpsert(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectForRoleContextSelect }) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts new file mode 100644 index 0000000000..c9b74e9e28 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts @@ -0,0 +1,71 @@ +import type { Project, ProjectMembers, ProjectRole } from '@prisma/client' +import { faker } from '@faker-js/faker' + +interface ProjectRoleWithMembers extends ProjectRole { + members?: Array +} + +interface ProjectRoleWithProject extends ProjectRole { + project: Pick +} + +export function makeProjectRole(overrides: Partial = {}): ProjectRole { + return { + id: faker.string.uuid(), + name: faker.person.jobTitle(), + permissions: faker.number.bigInt({ min: 0n, max: 64n }), + projectId: faker.string.uuid(), + position: faker.number.int({ min: 0, max: 100 }), + oidcGroup: '', + type: 'managed', + ...overrides, + } +} + +export function makeProjectRoleWithMembers(overrides: Partial = {}): ProjectRoleWithMembers { + return { + ...makeProjectRole(overrides), + members: overrides.members ?? [], + } +} + +export function makeProjectRoleWithProject(overrides: Partial = {}): ProjectRoleWithProject { + const role = makeProjectRole(overrides) + return { + ...role, + project: overrides.project ?? { slug: faker.helpers.slugify(faker.company.name()) }, + } +} + +export function makeProject(overrides: Partial = {}): Project { + return { + id: faker.string.uuid(), + name: faker.company.name(), + description: '', + status: 'initializing', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.helpers.slugify(faker.company.name()), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } +} + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts new file mode 100644 index 0000000000..c7fb6a706d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts @@ -0,0 +1,70 @@ +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { projectRoleContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Patch, Post, UseGuards } from '@nestjs/common' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectRolesService } from './project-roles.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectRolesController { + constructor( + @Inject(ProjectRolesService) private readonly projectRoles: ProjectRolesService, + ) {} + + @Get('/:projectId/roles') + @RequireProjectPermission('ListRoles') + async list( + @Project() project: ProjectContext, + ) { + return this.projectRoles.list(project.id) + } + + @Post('/:projectId/roles') + @HttpCode(HttpStatus.CREATED) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async create( + @Body(new ZodValidationPipe(projectRoleContract.createProjectRole.body)) body: typeof projectRoleContract.createProjectRole.body._type, + @Project() project: ProjectContext, + ) { + return this.projectRoles.create(project.id, body) + } + + @Patch('/:projectId/roles') + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async update( + @Body(new ZodValidationPipe(projectRoleContract.patchProjectRoles.body)) body: typeof projectRoleContract.patchProjectRoles.body._type, + @Project() project: ProjectContext, + ) { + return this.projectRoles.update(project.id, body) + } + + @Get('/:projectId/roles/member-counts') + @RequireProjectPermission('ListRoles') + async countMembers( + @Project() project: ProjectContext, + ) { + return this.projectRoles.countMembers(project.id) + } + + @Delete('/:projectId/roles/:roleId') + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async delete( + @Param('roleId') roleId: string, + @Project() _project: ProjectContext, + ): Promise { + return this.projectRoles.delete(roleId) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts new file mode 100644 index 0000000000..e6bf20ad5f --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { ProjectRolesController } from './project-roles.controller' +import { ProjectRolesService } from './project-roles.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, ProjectPermissionModule], + controllers: [ProjectRolesController], + providers: [ProjectRolesService], + exports: [ProjectRolesService], +}) +export class ProjectRolesModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts new file mode 100644 index 0000000000..ef4a761800 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts @@ -0,0 +1,181 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeProject, makeProjectMembers, makeProjectRole, makeProjectRoleWithProject } from './project-roles-testing.utils' +import { ProjectRolesService } from './project-roles.service' + +describe('projectRolesService', () => { + let module: TestingModule + let service: ProjectRolesService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectRolesService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(ProjectRolesService) + }) + + it('lists roles with permissions as strings and stripped oidcGroup', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + prisma.project.findUnique.mockResolvedValue(makeProject({ id: projectId, slug })) + prisma.projectRole.findMany.mockResolvedValue([makeProjectRoleWithProject({ + name: 'test', + permissions: 4n, + position: 0, + projectId, + oidcGroup: '/slug/console/admin', + project: { slug }, + })]) + + const roles = await service.list(projectId) + + expect(roles[0].permissions).toBe('4') + expect(roles[0].oidcGroup).toBe('/console/admin') + }) + + it('creates a role and emits project update', async () => { + const projectId = faker.string.uuid() + const slug = faker.string.alphanumeric(8).toLowerCase() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + prisma.projectRole.findFirst.mockResolvedValue(makeProjectRole({ position: 0 })) + prisma.projectRole.create.mockResolvedValue(makeProjectRole()) + prisma.projectRole.findMany.mockResolvedValue([]) + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + + await service.create(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + name: 'test', + permissions: 4n, + position: 1, + projectId, + }), + })) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', expect.anything(), { action: 'Upsert Project Role' }) + }) + + it('rejects system role creation', async () => { + const projectId = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug: 'slug' })) + + await expect(service.create(projectId, { name: 'test', permissions: '4', type: 'system:managed' })) + .rejects.toThrow(BadRequestException) + }) + + it('patches roles and validates positions', async () => { + const projectId = faker.string.uuid() + const slug = faker.string.alphanumeric(8).toLowerCase() + const roleId = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + prisma.projectRole.findMany.mockResolvedValue([makeProjectRoleWithProject({ + id: roleId, + name: 'test', + permissions: 4n, + position: 0, + projectId, + oidcGroup: '/slug/console/admin', + project: { slug }, + })]) + prisma.projectRole.update.mockResolvedValue(makeProjectRole({ id: roleId })) + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + + await service.update(projectId, [{ id: roleId, name: 'updated', position: 0 }]) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: roleId }, + data: expect.objectContaining({ + name: 'updated', + position: 0, + }), + })) + }) + + it('rejects incoherent positions', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + const roleId1 = faker.string.uuid() + const roleId2 = faker.string.uuid() + prisma.projectRole.findMany.mockResolvedValue([ + makeProjectRoleWithProject({ id: roleId1, name: 'a', permissions: 1n, position: 0, projectId, oidcGroup: '', project: { slug } }), + makeProjectRoleWithProject({ id: roleId2, name: 'b', permissions: 1n, position: 1, projectId, oidcGroup: '', project: { slug } }), + ]) + + await expect(service.update(projectId, [{ id: roleId1, position: 1 }])) + .rejects.toThrow(BadRequestException) + }) + + it('counts role members', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + const roleId1 = faker.string.uuid() + const roleId2 = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + prisma.projectRole.findMany.mockResolvedValue([ + makeProjectRoleWithProject({ id: roleId1, name: 'a', permissions: 1n, position: 0, projectId, oidcGroup: '', project: { slug } }), + makeProjectRoleWithProject({ id: roleId2, name: 'b', permissions: 1n, position: 1, projectId, oidcGroup: '', project: { slug } }), + ]) + prisma.projectMembers.findMany.mockResolvedValue([ + makeProjectMembers({ roleIds: [roleId1, roleId2] }), + makeProjectMembers({ roleIds: [roleId2] }), + ]) + + await expect(service.countMembers(projectId)).resolves.toEqual({ + [roleId1]: 1, + [roleId2]: 2, + }) + }) + + it('deletes role and cleans member role ids', async () => { + const projectId = faker.string.uuid() + const roleId = faker.string.uuid() + prisma.projectRole.findUnique.mockResolvedValue(makeProjectRole({ type: 'managed', projectId })) + prisma.project.findUnique.mockResolvedValue(makeProject({ id: projectId, slug: 'slug' })) + + const tx = mockDeep() + tx.projectRole.delete.mockResolvedValue(makeProjectRole()) + tx.projectMembers.findMany.mockResolvedValue([ + makeProjectMembers({ userId: faker.string.uuid(), roleIds: [roleId] }), + makeProjectMembers({ userId: faker.string.uuid(), roleIds: [roleId, faker.string.uuid()] }), + ]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.delete(roleId) + + expect(tx.projectMembers.update).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', expect.anything(), { action: 'Upsert Project Role' }) + }) + + it('rejects system role deletion', async () => { + prisma.projectRole.findUnique.mockResolvedValue(makeProjectRole({ type: 'system:managed', projectId: faker.string.uuid() })) + + await expect(service.delete(faker.string.uuid())) + .rejects.toThrow(BadRequestException) + }) + + it('rejects missing role deletion', async () => { + prisma.projectRole.findUnique.mockResolvedValue(null) + + await expect(service.delete(faker.string.uuid())) + .rejects.toThrow(NotFoundException) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts new file mode 100644 index 0000000000..a8c537b5fd --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts @@ -0,0 +1,130 @@ +import type { Project, ProjectRole } from '@prisma/client' +import type { + CreateProjectRoleInput, + PatchProjectRolesInput, +} from './project-roles.utils' +import { isSystemRoleType } from '@cpn-console/shared' +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { getProjectBySlug, getProjectRoleForDelete, projectRoleWithProjectSelect } from './project-roles-queries.utils' +import { + buildUpdatedProjectRoles, + toProjectRoleResponse, + validatePatchedProjectRolePositions, +} from './project-roles.utils' + +@Injectable() +export class ProjectRolesService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async list(projectId: Project['id']) { + const roles = await this.prisma.projectRole.findMany({ + where: { projectId }, + orderBy: { position: 'asc' }, + select: projectRoleWithProjectSelect, + }) + return roles.map(role => toProjectRoleResponse(role)) + } + + async update(projectId: Project['id'], roles: PatchProjectRolesInput) { + const project = await getProjectBySlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + + const dbRoles = await this.prisma.projectRole.findMany({ where: { projectId }, orderBy: { position: 'asc' } }) + const { requestedPositionsCount, updatedRoles } = buildUpdatedProjectRoles(project.slug, dbRoles, roles) + validatePatchedProjectRolePositions(dbRoles.length, requestedPositionsCount) + + for (const { id, ...role } of updatedRoles) { + await this.prisma.projectRole.update({ where: { id }, data: role }) + } + + await this.emitProjectUpsert(projectId) + return this.list(projectId) + } + + async create(projectId: Project['id'], role: CreateProjectRoleInput) { + const project = await getProjectBySlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + if (isSystemRoleType(role.type)) { + throw new BadRequestException('Impossible de créer un rôle système') + } + + const dbMaxPosRole = (await this.prisma.projectRole.findFirst({ + where: { projectId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + if (role.oidcGroup && !role.oidcGroup.startsWith('/')) { + throw new BadRequestException('oidcGroup doit commencer par /') + } + + await this.prisma.projectRole.create({ + data: { + ...role, + projectId, + position: dbMaxPosRole + 1, + permissions: BigInt(role.permissions), + oidcGroup: role.oidcGroup ? `/${project.slug}${role.oidcGroup}` : undefined, + }, + }) + + await this.emitProjectUpsert(projectId) + return this.list(projectId) + } + + async countMembers(projectId: Project['id']) { + const roles = await this.list(projectId) + const members = await this.prisma.projectMembers.findMany({ where: { projectId } }) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) + + for (const { roleIds } of members) { + for (const roleId of roleIds) { + rolesCounts[roleId]++ + } + } + + return rolesCounts + } + + async delete(roleId: ProjectRole['id']) { + const role = await getProjectRoleForDelete(this.prisma, roleId) + if (!role) throw new NotFoundException('Rôle introuvable') + if (isSystemRoleType(role.type)) { + throw new BadRequestException('Ce rôle système ne peut pas être supprimé') + } + + await this.prisma.$transaction(async (tx) => { + await tx.projectRole.delete({ where: { id: roleId } }) + const attachedMembers = await tx.projectMembers.findMany({ + where: { projectId: role.projectId, roleIds: { has: roleId } }, + }) + + for (const member of attachedMembers) { + await tx.projectMembers.update({ + where: { + projectId_userId: { + projectId: role.projectId, + userId: member.userId, + }, + }, + data: { + roleIds: { + set: member.roleIds.filter(existingRoleId => existingRoleId !== roleId), + }, + }, + }) + } + }) + + await this.emitProjectUpsert(role.projectId) + } + + private async emitProjectUpsert(projectId: string): Promise { + await this.appEvents.emitProjectEvent('project.upsert', projectId, { action: 'Upsert Project Role' }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts new file mode 100644 index 0000000000..c83bb2bb54 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts @@ -0,0 +1,108 @@ +import type { ProjectRole as SharedProjectRole } from '@cpn-console/shared' +import type { ProjectRole } from '@prisma/client' +import type { ProjectRoleWithProject } from './project-roles-queries.utils' +import { isSystemRoleType } from '@cpn-console/shared' +import { BadRequestException } from '@nestjs/common' + +export interface ProjectRoleResponse { + id: string + name: string + oidcGroup?: string | null + type?: string | null + permissions: string + userCount?: number +} + +export type CreateProjectRoleInput = Omit +export type PatchProjectRoleInput = Pick & Partial> +export type PatchProjectRolesInput = PatchProjectRoleInput[] + +export function toProjectRoleResponse( + role: ProjectRoleWithProject, +): ProjectRoleResponse { + return { + id: role.id, + name: role.name, + oidcGroup: stripProjectSlugFromOidcGroup(role.oidcGroup, role.project.slug), + type: role.type ?? 'managed', + permissions: role.permissions.toString(), + userCount: role.project.members?.length ?? 0, + } +} + +export function buildUpdatedProjectRoles( + projectSlug: string, + dbRoles: ProjectRole[], + roles: PatchProjectRolesInput, +): { requestedPositionsCount: number, updatedRoles: ProjectRole[] } { + const requestedPositions = new Set() + const updatedRoles: ProjectRole[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (!matchingRole) continue + + validateProjectRolePatch(dbRole, matchingRole) + + if (matchingRole.position !== undefined) { + requestedPositions.add(matchingRole.position) + } + + updatedRoles.push(mergeProjectRolePatch(projectSlug, dbRole, matchingRole)) + } + + return { requestedPositionsCount: requestedPositions.size, updatedRoles } +} + +export function validatePatchedProjectRolePositions(dbRolesCount: number, requestedPositionsCount: number): void { + if (requestedPositionsCount && requestedPositionsCount !== dbRolesCount) { + throw new BadRequestException('Les numéros de position des rôles sont incohérentes') + } +} + +function validateProjectRolePatch(dbRole: ProjectRole, matchingRole: PatchProjectRoleInput): void { + if (isSystemRoleType(dbRole.type)) { + throw new BadRequestException('Ce rôle système ne peut pas être modifié') + } + + if (isSystemRoleType(matchingRole.type)) { + throw new BadRequestException('Impossible de modifier un rôle en rôle système') + } + + if (matchingRole.oidcGroup && !matchingRole.oidcGroup.startsWith('/')) { + throw new BadRequestException('oidcGroup doit commencer par /') + } +} + +function mergeProjectRolePatch( + projectSlug: string, + dbRole: ProjectRole, + matchingRole: PatchProjectRoleInput, +): ProjectRole { + const permissions = typeof matchingRole.permissions === 'string' + ? BigInt(matchingRole.permissions) + : dbRole.permissions + const oidcGroup = typeof matchingRole.oidcGroup === 'string' + ? `/${projectSlug}${matchingRole.oidcGroup}` + : dbRole.oidcGroup + + return { + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup, + type: matchingRole.type ?? dbRole.type, + projectId: dbRole.projectId, + } +} + +const oidcRegexp = /^\/[^/]+/ + +function stripProjectSlugFromOidcGroup(oidcGroup: string, projectSlug: string): string | null { + if (!oidcGroup.startsWith(`/${projectSlug}`)) { + return oidcGroup.replace(oidcRegexp, '') + } + + return oidcGroup.slice(projectSlug.length + 1) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts new file mode 100644 index 0000000000..f6595cbc81 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts @@ -0,0 +1,13 @@ +import type { Prisma } from '@prisma/client' + +export const projectSlugSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export type ProjectSlug = Prisma.ProjectGetPayload<{ + select: typeof projectSlugSelect +}> + +export function getProjectSlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectSlugSelect }) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts new file mode 100644 index 0000000000..89d0174b4a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts @@ -0,0 +1,21 @@ +import type { VaultMetadata, VaultSecret } from '../vault/vault-client.service' +import { faker } from '@faker-js/faker' + +export function makeVaultSecret = Record>(overrides: Partial> = {}): VaultSecret { + return { + data: {} as T, + metadata: makeVaultMetadata(), + ...overrides, + } satisfies VaultSecret +} + +function makeVaultMetadata(overrides: Partial = {}): VaultMetadata { + return { + created_time: faker.date.past().toISOString(), + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + ...overrides, + } satisfies VaultMetadata +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts new file mode 100644 index 0000000000..ae99445888 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, Inject, UseGuards } from '@nestjs/common' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ProjectSecretsService } from './project-secrets.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectSecretsController { + constructor( + @Inject(ProjectSecretsService) private readonly projectSecrets: ProjectSecretsService, + ) {} + + @Get('/:projectId/secrets') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectPermission('SeeSecrets') + async get( + @Project() project: { id: string }, + ): Promise>> { + return this.projectSecrets.get(project.id) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts new file mode 100644 index 0000000000..1ecc4f73e6 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts @@ -0,0 +1,17 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProjectSecretsModule } from './project-secrets.module' +import { ProjectSecretsService } from './project-secrets.service' + +describe('projectSecretsModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ProjectSecretsService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ProjectSecretsModule, 'USE_VAULT')], + }).compile() + expect(() => module.get(ProjectSecretsService)).toThrow() + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts new file mode 100644 index 0000000000..f6e1648569 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { VaultModule } from '../vault/vault.module' +import { ProjectSecretsController } from './project-secrets.controller' +import { ProjectSecretsService } from './project-secrets.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, ConditionalModule.registerWhen(VaultModule, 'USE_VAULT'), ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + controllers: [ProjectSecretsController], + providers: [ProjectSecretsService], + exports: [ProjectSecretsService], +}) +export class ProjectSecretsModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts new file mode 100644 index 0000000000..62586bd954 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts @@ -0,0 +1,106 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeProject } from '../project/project-testing.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { VaultService } from '../vault/vault.service' +import { makeVaultSecret } from './project-secrets-testing.utils' +import { ProjectSecretsService } from './project-secrets.service' + +describe('projectSecretsService', () => { + let module: TestingModule + let service: ProjectSecretsService + let prisma: DeepMockProxy + let vault: DeepMockProxy + let vaultClient: DeepMockProxy + let config: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + vault = mockDeep() + vaultClient = mockDeep() + config = mockDeep({ projectsRootDir: '/vault' }) + vaultConfig = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectSecretsService, + { provide: PrismaService, useValue: prisma }, + { provide: BASE_CONFIG, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: VaultService, useValue: vault }, + { provide: VaultClientService, useValue: vaultClient }, + ], + }).compile() + + service = module.get(ProjectSecretsService) + }) + + it('returns parsed secrets from vault', async () => { + const projectId = 'project-id' + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproject' })) + vault.listProjectSecrets.mockResolvedValue(['group1/secret1']) + vaultClient.read.mockResolvedValue(makeVaultSecret({ data: { key1: 'value1', key2: 42, key3: true, key4: null } })) + + const result = await service.get(projectId) + + expect(prisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: projectId }, + select: { slug: true }, + }) + expect(vault.listProjectSecrets).toHaveBeenCalledWith('myproject') + expect(result).toHaveProperty('group1') + expect(result.group1).toHaveProperty('secret1.key1', 'value1') + expect(result.group1).toHaveProperty('secret1.key2', '42') + expect(result.group1).toHaveProperty('secret1.key3', 'true') + expect(result.group1).toHaveProperty('secret1.key4', '') + }) + + it('handles nested secret paths', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue(['group1/sub/path']) + vaultClient.read.mockResolvedValue(makeVaultSecret({ data: { nested: 'value' } })) + + const result = await service.get('project-id') + + expect(result.group1).toHaveProperty('sub/path.nested', 'value') + }) + + it('returns empty object when no secrets exist', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue([]) + + const result = await service.get('project-id') + + expect(result).toEqual({}) + }) + + it('returns empty object when secret listing fails', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockRejectedValue(new Error('vault unavailable')) + + const result = await service.get('project-id') + + expect(result).toEqual({}) + }) + + it('skips secrets that fail to read', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue(['group1/s1', 'group1/s2']) + vaultClient.read + .mockRejectedValueOnce(new Error('vault error')) + .mockResolvedValueOnce(makeVaultSecret({ data: { key: 'val' } })) + + const result = await service.get('project-id') + + expect(result.group1).toEqual({ 's2.key': 'val' }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts new file mode 100644 index 0000000000..fa1dd4952c --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts @@ -0,0 +1,90 @@ +import type { ConfigType } from '@nestjs/config' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { z } from 'zod' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultService } from '../vault/vault.service' +import { generateProjectPath } from '../vault/vault.utils' +import { getProjectSlug } from './project-secrets-queries.utils' + +const SecretValueSchema = z.union([ + z.string(), + z.undefined().transform(() => ''), + z.number().transform(String), + z.bigint().transform(String), + z.boolean().transform(String), + z.null().transform(() => ''), +]).catch('') + +export function parseSecretValue(value: string): string { + return SecretValueSchema.parse(value) +} + +@Injectable() +export class ProjectSecretsService { + private readonly logger = new Logger(ProjectSecretsService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: ConfigType, + @Inject(VaultService) private readonly vault: VaultService, + @Inject(VaultClientService) private readonly vaultClient: VaultClientService, + ) {} + + @StartActiveSpan() + async get(projectId: string): Promise>> { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.get started (projectId=${projectId})`) + try { + const project = await getProjectSlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + span?.setAttribute('project.slug', project.slug) + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, project.slug) + + const result: Record> = {} + const relativePaths = await this.vault.listProjectSecrets(project.slug).catch((error) => { + this.logger.warn( + `project.get secret listing failed (projectId=${projectId}, slug=${project.slug}): ${error instanceof Error ? error.message : String(error)}; returning an empty result`, + ) + return [] as string[] + }) + span?.setAttribute('vault.secretFiles.count', relativePaths.length) + this.logger.debug(`project.get listed (projectId=${projectId}, slug=${project.slug}, secretFiles=${relativePaths.length})`) + + for (const relativePath of relativePaths) { + const fullPath = `${projectPath}/${relativePath}` + const secret = await this.vaultClient.read>(fullPath).catch(() => null) + if (!secret?.data) continue + + const [group, ...rest] = relativePath.split('/').filter(Boolean) + if (!group) continue + const prefix = rest.length ? `${rest.join('/')}.` : '' + const groupObj = (result[group] ??= {}) + for (const [key, value] of Object.entries(secret.data)) { + groupObj[`${prefix}${key}`] = parseSecretValue(value) + } + } + + const groupCount = Object.keys(result).length + const keyCount = Object.values(result).reduce((acc, group) => acc + Object.keys(group).length, 0) + span?.setAttributes({ + 'vault.secretGroups.count': groupCount, + 'vault.secretKeys.count': keyCount, + }) + this.logger.log(`project.get completed (projectId=${projectId}, slug=${project.slug}, groupCount=${groupCount}, keyCount=${keyCount})`) + return result + } catch (error) { + this.logger.error( + `project.get failed (projectId=${projectId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts new file mode 100644 index 0000000000..ef539dae4a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts @@ -0,0 +1,117 @@ +import type { Prisma } from '@prisma/client' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + clusters: { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + infos: true, + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + createdAt: true, + updatedAt: true, + clusterId: true, + cluster: { + select: { + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectPluginSelect = { + key: true, + pluginName: true, + projectId: true, + value: true, +} satisfies Prisma.ProjectPluginSelect + +export const adminPluginSelect = { + key: true, + pluginName: true, + value: true, +} satisfies Prisma.AdminPluginSelect + +export const publicClusterSelect = { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + infos: true, + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, +} satisfies Prisma.ClusterFindManyArgs + +export type ProjectPlugin = Prisma.ProjectPluginGetPayload<{ + select: typeof projectPluginSelect +}> + +export type AdminPlugin = Prisma.AdminPluginGetPayload<{ + select: typeof adminPluginSelect +}> + +export type PublicCluster = Prisma.ClusterGetPayload<{ + select: typeof publicClusterSelect.select +}> + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export async function getServicesQueryData(tx: Prisma.TransactionClient, projectId: string) { + return Promise.all([ + tx.project.findUnique({ + where: { id: projectId }, + select: projectSelect, + }), + tx.projectPlugin.findMany({ + where: { projectId }, + select: projectPluginSelect, + }), + tx.adminPlugin.findMany({ + select: adminPluginSelect, + }), + tx.cluster.findMany({ + where: { privacy: 'public' }, + select: publicClusterSelect.select, + }), + ]) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts new file mode 100644 index 0000000000..6a52d2b057 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts @@ -0,0 +1,231 @@ +import type { Plugin } from '@cpn-console/hooks' +import type { PluginsUpdateBody } from '@cpn-console/shared' +import type { Cluster, Environment, ProjectPlugin as PrismaProjectPlugin, Project, Zone } from '@prisma/client' +import type { AdminPlugin, ProjectPlugin, ProjectWithDetails, PublicCluster } from './project-services-queries.utils' +import { DEFAULT } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +/** Narrow zone shape the service reads from `PublicCluster.zone` / environment clusters. */ +export type PublicZone = PublicCluster['zone'] + +export function makeServicesPluginName(): string { + return faker.helpers.slugify(faker.company.name()) +} + +export function makePlugin(overrides: Partial = {}): Plugin { + const pluginName = overrides.name ?? makeServicesPluginName() + + return { + infos: { + name: pluginName, + title: faker.commerce.productName(), + description: faker.lorem.sentence(), + imgSrc: `/${faker.helpers.slugify(faker.commerce.product())}.svg`, + config: { + global: [], + project: [ + { + kind: 'switch', + key: 'enabled', + title: 'Enabled', + value: DEFAULT, + initialValue: DEFAULT, + permissions: { + user: { read: true, write: true }, + admin: { read: true, write: true }, + }, + }, + ], + }, + to: ({ project }) => `/projects/${project.slug}`, + ...overrides, + }, + subscribedHooks: {}, + } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + name: faker.company.name(), + slug: faker.helpers.slugify(faker.company.name()), + description: faker.lorem.sentence(), + clusters: [makePublicCluster()], + environments: [], + ...overrides, + } +} + +export function makeProjectPlugin(overrides: Partial = {}): ProjectPlugin { + return { + pluginName: makeServicesPluginName(), + projectId: faker.string.uuid(), + key: 'enabled', + value: DEFAULT, + ...overrides, + } +} + +export function makeServicesAdminPlugin(overrides: Partial = {}): AdminPlugin { + const pluginName = overrides.pluginName ?? makeServicesPluginName() + const adminPlugin = { + pluginName, + key: 'enabled', + value: DEFAULT, + } + return { ...adminPlugin, ...overrides, pluginName } +} + +export function makePublicCluster(overrides: Partial = {}): PublicCluster { + const resolvedZone = overrides.zone ?? makeServicesZone() + return { + id: faker.string.uuid(), + label: faker.company.name(), + privacy: 'public', + clusterResources: false, + infos: null, + zone: resolvedZone, + ...overrides, + } +} + +export function makeServicesZone(overrides: Partial = {}): PublicZone { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`zone-${faker.string.alphanumeric(8)}`), + label: faker.location.city(), + argocdUrl: `https://${faker.helpers.slugify(faker.location.city())}.example.com`, + ...overrides, + } +} + +export function makeServicesEnvironment(overrides: Partial & { cluster?: PublicCluster } = {}): ProjectWithDetails['environments'][number] { + const { cluster: clusterOverride, ...restOverrides } = overrides + const cluster = clusterOverride ?? makePublicCluster() + return { + id: faker.string.uuid(), + name: faker.word.noun(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: cluster.id, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + cluster, + ...restOverrides, + } +} + +export function makeServicesUpdateBody(pluginName: string = makeServicesPluginName()): PluginsUpdateBody { + return { + [pluginName]: { + enabled: DEFAULT, + }, + } +} + +export type ClusterWithZone = Cluster & { zone: Zone } + +export function makeCluster(overrides: Partial = {}): Cluster { + return { + id: faker.string.uuid(), + label: faker.company.name(), + privacy: 'public', + secretName: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + infos: null, + memory: faker.number.float({ min: 0, max: 1024, fractionDigits: 2 }), + cpu: faker.number.float({ min: 0, max: 64, fractionDigits: 2 }), + gpu: faker.number.float({ min: 0, max: 8, fractionDigits: 2 }), + zoneId: faker.string.uuid(), + ...overrides, + } +} + +export function makeClusterWithZone(overrides: Partial = {}): ClusterWithZone { + const resolvedZone = overrides.zone ?? { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`zone-${faker.string.alphanumeric(8)}`), + label: faker.location.city(), + description: faker.lorem.sentence(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + argocdUrl: `https://${faker.helpers.slugify(faker.location.city())}.example.com`, + } + const { zone: _zone, ...clusterOverrides } = overrides + return { + ...makeCluster(clusterOverrides), + zoneId: clusterOverrides.zoneId ?? resolvedZone.id, + zone: resolvedZone, + } +} + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + projectId: faker.string.uuid(), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export type EnvironmentWithCluster = Environment & { cluster: ClusterWithZone } + +export function makeEnvironmentWithCluster(overrides: Partial = {}): EnvironmentWithCluster { + const resolvedCluster = overrides.cluster ?? makeClusterWithZone() + const { cluster: _cluster, ...envOverrides } = overrides + return { + ...makeEnvironment(envOverrides), + clusterId: envOverrides.clusterId ?? resolvedCluster.id, + cluster: resolvedCluster, + } +} + +export type ProjectWithRelations = Project & { + plugins: PrismaProjectPlugin[] + clusters: ClusterWithZone[] + environments: EnvironmentWithCluster[] +} + +export function makeProject(overrides: Partial = {}): ProjectWithRelations { + return { + id: faker.string.uuid(), + name: faker.company.name(), + description: faker.lorem.sentence(), + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.helpers.slugify(faker.company.name()), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + plugins: [], + clusters: [], + environments: [], + ...overrides, + } +} + +export function makeProjectWithRelations(overrides: Partial = {}): ProjectWithRelations { + return makeProject(overrides) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.controller.ts new file mode 100644 index 0000000000..00ff45d9f2 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.controller.ts @@ -0,0 +1,49 @@ +import type { PermissionTarget, PluginsUpdateBody } from '@cpn-console/shared' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { AdminAuthorized, projectServiceContract } from '@cpn-console/shared' +import { Body, Controller, ForbiddenException, Get, HttpCode, HttpStatus, Inject, Post, Query, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectServicesService } from './project-services.service' + +@Controller('api/v1/projects/:projectId/services') +@UseGuards(ProjectGuard) +export class ProjectServicesController { + constructor( + @Inject(ProjectServicesService) private readonly projectServices: ProjectServicesService, + ) {} + + @Get() + @RequireProjectPermission('ListEnvironments') + async get( + @Query(new ZodValidationPipe(projectServiceContract.getServices.query)) query: { permissionTarget: PermissionTarget }, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + ) { + if (query.permissionTarget === 'admin' && !AdminAuthorized.Manage(user.adminPermissions ?? 0n)) { + throw new ForbiddenException('Vous ne pouvez pas demander les paramètres admin') + } + + return this.projectServices.get(project.id, query.permissionTarget) + } + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + async update( + @Body(new ZodValidationPipe(projectServiceContract.updateProjectServices.body)) body: PluginsUpdateBody, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + ): Promise { + const allowedRoles: Array<'user' | 'admin'> = AdminAuthorized.Manage(user.adminPermissions ?? 0n) ? ['user', 'admin'] : ['user'] + await this.projectServices.update(project.id, body, allowedRoles) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts new file mode 100644 index 0000000000..375e371303 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts @@ -0,0 +1,17 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProjectServicesModule } from './project-services.module' +import { ProjectServicesService } from './project-services.service' + +describe('projectServicesModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ProjectServicesService when USE_PLUGINS=false', async () => { + vi.stubEnv('USE_PLUGINS', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ProjectServicesModule, 'USE_PLUGINS')], + }).compile() + expect(() => module.get(ProjectServicesService)).toThrow() + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.module.ts new file mode 100644 index 0000000000..599b8cae0a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { PluginModule } from '../plugin/plugin.module' +import { ProjectServicesController } from './project-services.controller' +import { ProjectServicesService } from './project-services.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, ConditionalModule.registerWhen(PluginModule, 'USE_PLUGINS')], + controllers: [ProjectServicesController], + providers: [ProjectServicesService], + exports: [ProjectServicesService], +}) +export class ProjectServicesModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts new file mode 100644 index 0000000000..f0702f9a97 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts @@ -0,0 +1,175 @@ +import type { Plugin, ServiceInfos } from '@cpn-console/hooks' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ClusterWithZone, ProjectWithRelations } from './project-services-testing.utils' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PluginService } from '../plugin/plugin.service' +import { + makeClusterWithZone, + makeEnvironmentWithCluster, + makePlugin, + makeProjectPlugin, + makeProjectWithRelations, + makeServicesUpdateBody, +} from './project-services-testing.utils' +import { ProjectServicesService } from './project-services.service' + +function makeEmptyServiceInfo(name: string): ServiceInfos { + return { + name, + title: name, + config: { + global: [], + project: [], + }, + } +} + +describe('servicesService', () => { + let module: TestingModule + let service: ProjectServicesService + let prisma: DeepMockProxy + let projectId: string + let pluginName: string + let projectSlug: string + let serviceTitle: string + let serviceDescription: string + let serviceImgSrc: string + let project: ProjectWithRelations + let cluster: ClusterWithZone + let plugin: Plugin + + beforeEach(async () => { + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(faker.company.name()) + serviceTitle = faker.commerce.productName() + serviceDescription = faker.lorem.sentence() + serviceImgSrc = `/${faker.helpers.slugify(faker.commerce.product())}.svg` + + plugin = makePlugin({ + title: serviceTitle, + description: serviceDescription, + imgSrc: serviceImgSrc, + to: ({ project }) => `/projects/${project.slug}`, + }) + pluginName = plugin.infos.name + project = makeProjectWithRelations({ id: projectId, slug: projectSlug }) + cluster = makeClusterWithZone() + + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectServicesService, + { provide: PrismaService, useValue: prisma }, + { provide: PluginService, useValue: { infos: async () => [plugin.infos, makeEmptyServiceInfo('gitlab'), makeEmptyServiceInfo('harbor'), makeEmptyServiceInfo('keycloak'), makeEmptyServiceInfo('nexus'), makeEmptyServiceInfo('sonarqube'), makeEmptyServiceInfo('vault')] } }, + ], + }).compile() + + service = module.get(ProjectServicesService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('get returns the service with actual hooks data', async () => { + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectPlugin.findMany.mockResolvedValue([ + makeProjectPlugin({ projectId, pluginName, value: ENABLED }), + ]) + prisma.adminPlugin.findMany.mockResolvedValue([]) + prisma.cluster.findMany.mockResolvedValue([cluster]) + + const result = await service.get(projectId, 'user') + const e2eService = result.find(entry => entry.name === pluginName) + + expect(e2eService).toMatchObject({ + name: pluginName, + title: serviceTitle, + imgSrc: serviceImgSrc, + urls: [{ to: `/projects/${projectSlug}`, name: '' }], + }) + expect(e2eService?.manifest.project?.[0]).toMatchObject({ + key: 'enabled', + value: ENABLED, + }) + }) + + it('get includes zones coming from project environments', async () => { + const baseZone = makeClusterWithZone().zone + const environmentZone = makeClusterWithZone({ + zone: { + ...baseZone, + argocdUrl: 'https://argocd.example.com', + }, + }) + const projectWithEnvironmentZones = makeProjectWithRelations({ + clusters: [], + environments: [ + makeEnvironmentWithCluster({ + cluster: environmentZone, + }), + ], + }) + const envZonePlugin = makePlugin({ + to: ({ zones }) => zones.map(zone => ({ + to: `${zone.argocdUrl}/applications?search=${zone.slug}`, + title: zone.label, + })), + }) + + prisma.project.findUnique.mockResolvedValue(projectWithEnvironmentZones) + prisma.projectPlugin.findMany.mockResolvedValue([]) + prisma.adminPlugin.findMany.mockResolvedValue([]) + prisma.cluster.findMany.mockResolvedValue([]) + + const envModule = await Test.createTestingModule({ + providers: [ + ProjectServicesService, + { provide: PrismaService, useValue: prisma }, + { provide: PluginService, useValue: { infos: async () => [envZonePlugin.infos] } }, + ], + }).compile() + const envService = envModule.get(ProjectServicesService) + + const result = await envService.get(projectId, 'user') + + expect(result.find(entry => entry.name === envZonePlugin.infos.name)).toMatchObject({ + urls: [{ to: `${environmentZone.zone.argocdUrl}/applications?search=${environmentZone.zone.slug}`, name: environmentZone.zone.label }], + }) + await envModule.close() + }) + + it('update stores project configuration through the real utility', async () => { + const data = makeServicesUpdateBody(pluginName) + + await service.update(projectId, data, ['user']) + + expect(prisma.projectPlugin.upsert).toHaveBeenCalledWith({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key: 'enabled', + }, + }, + create: { + projectId, + pluginName, + key: 'enabled', + value: data[pluginName].enabled, + }, + update: { + pluginName, + key: 'enabled', + value: data[pluginName].enabled, + }, + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.service.ts new file mode 100644 index 0000000000..572e947767 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.service.ts @@ -0,0 +1,105 @@ +import type { ZoneObject } from '@cpn-console/hooks' +import type { PermissionTarget, PluginsUpdateBody, ProjectService } from '@cpn-console/shared' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PluginService } from '../plugin/plugin.service' +import { getServicesQueryData } from './project-services-queries.utils' +import { + buildProjectEditStrippers, + generatePluginsUpdateBody, + normalizeServiceUrls, + parsePluginsUpdateBody, + populateServiceManifest, + saveProjectStore, +} from './project-services.utils' + +@Injectable() +export class ProjectServicesService { + private readonly logger = new Logger(ProjectServicesService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(PluginService) private readonly plugin: PluginService, + ) {} + + async get(projectId: string, permissionTarget: PermissionTarget): Promise { + const [project, projectStore, globalConfig, publicClusters] = await getServicesQueryData(this.prisma, projectId) + + if (!project) { + throw new NotFoundException('Projet introuvable') + } + + const store = generatePluginsUpdateBody([...projectStore, ...globalConfig]) + const clusters = [...project.clusters, ...publicClusters] + const zones = new Map() + for (const cluster of clusters) { + zones.set(cluster.zone.id, cluster.zone) + } + for (const environment of project.environments) { + const zone = environment.cluster?.zone + if (zone) zones.set(zone.id, zone) + } + const serviceInfos = await this.plugin.infos(projectId) + + const services = await Promise.all(serviceInfos.map(async (serviceInfo) => { + const { name, title, to, imgSrc, description } = serviceInfo + let toResponse: unknown = [] + if (to) { + try { + toResponse = to({ + clusters: clusters.map(c => ({ + id: c.id, + label: c.label, + privacy: c.privacy, + clusterResources: c.clusterResources, + infos: c.infos, + zone: c.zone, + })), + zones: [...zones.values()], + environments: project.environments, + project: { + id: project.id, + name: project.name, + slug: project.slug, + }, + store, + }) + } catch (error) { + this.logger.debug({ err: error, service: name }, 'Service URL generation failed, returning no URLs') + } + } + + const urls = normalizeServiceUrls(toResponse) + const manifest = populateServiceManifest({ + service: serviceInfo, + data: { + project: projectStore, + global: globalConfig, + }, + permissionTarget, + select: { + global: true, + project: true, + }, + }) + + return { imgSrc, title, name, urls, manifest, description } + })) + + return services.filter(service => service.urls.length || service.manifest.global?.length || service.manifest.project?.length) + } + + async update(projectId: string, data: PluginsUpdateBody, stripperRoles: Array<'user' | 'admin'>) { + this.logger.log(`Update project services started (projectId=${projectId}, pluginCount=${Object.keys(data).length}, stripperRoles=${stripperRoles.join(',')})`) + const editStrippers = buildProjectEditStrippers(await this.plugin.infos(projectId)) + + for (const role of stripperRoles) { + const parsedData = editStrippers.project[role].safeParse(data) + if (!parsedData.success) continue + + await saveProjectStore(parsePluginsUpdateBody(parsedData.data), projectId, this.prisma) + } + + this.logger.log(`Update project services completed (projectId=${projectId})`) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.utils.ts new file mode 100644 index 0000000000..c5961851e1 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project-services/project-services.utils.ts @@ -0,0 +1,125 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { PermissionTarget, PluginConfig, PluginsUpdateBody, ServiceUrl } from '@cpn-console/shared' +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { editStrippersGenerator } from '@cpn-console/hooks' +import { atomicValidators, DEFAULT } from '@cpn-console/shared' +import { z } from 'zod' + +export interface PluginRecord { + pluginName: string + key: string + value: string +} + +export function generatePluginsUpdateBody(records: PluginRecord[]): PluginsUpdateBody { + const obj: PluginsUpdateBody = {} + for (const record of records) { + if (!obj[record.pluginName]) obj[record.pluginName] = {} + obj[record.pluginName][record.key] = record.value + } + return obj +} + +export function parsePluginsUpdateBody(obj: PluginsUpdateBody): PluginRecord[] { + return Object.entries(obj) + .flatMap(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) +} + +export function buildProjectEditStrippers(services: ServiceInfos[]) { + const project = { + user: z.object({}), + admin: z.object({}), + } + let global = z.object({}) + + for (const service of services) { + if (!service.config) continue + const editZod = editStrippersGenerator.parse(service.config) + global = global.merge(z.object({ [service.name]: editZod.global.default({}) })) + project.admin = project.admin.merge(z.object({ [service.name]: editZod.project.admin.default({}) })) + project.user = project.user.merge(z.object({ [service.name]: editZod.project.user.default({}) })) + } + + return { project, global } +} + +interface ServiceManifestParams { + service: ServiceInfos + data: { project?: PluginRecord[], global?: PluginRecord[] } + permissionTarget: PermissionTarget + select: Partial> +} + +export function populateServiceManifest({ service, data, select, permissionTarget }: ServiceManifestParams): Partial { + const manifest = structuredClone(service.config) + + const selected: Partial = {} + for (const [scope] of Object.entries(select).filter(([_scope, bool]) => bool)) { + if (!manifest?.[scope]) continue + selected[scope] = manifest[scope].filter(item => item.permissions[permissionTarget].read || item.permissions[permissionTarget].write).map((item) => { + const row = data[scope]?.find(candidate => candidate.pluginName === service.name && candidate.key === item.key) + if (item.kind === 'switch') { + const value = atomicValidators.switch.safeParse(row?.value) + item.value = value.success ? value.data : DEFAULT + } else { + item.value = z.coerce.string().parse(row?.value ?? item.value ?? '') + } + return item + }) + } + return selected +} + +export function normalizeServiceUrls(toResponse: unknown): ServiceUrl[] { + if (Array.isArray(toResponse)) { + return toResponse.map(res => ({ name: res.title ?? '', description: res.description ?? '', to: res.to })) + } + + if (typeof toResponse === 'string') { + return [{ to: toResponse, name: '' }] + } + + if (toResponse) { + return [{ name: (toResponse as { title?: string }).title ?? '', to: (toResponse as { to: string }).to }] + } + + return [] +} + +export async function saveProjectStore(records: PluginRecord[], projectId: string, prisma: PrismaService) { + for (const { pluginName, key, value } of records) { + if (value === null) { + await prisma.projectPlugin.delete({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } else { + await prisma.projectPlugin.upsert({ + create: { + pluginName, + projectId, + key, + value: value.toString(), + }, + update: { + key, + value: value.toString(), + pluginName, + }, + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project-queries.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project-queries.utils.ts new file mode 100644 index 0000000000..8469ab9cee --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project-queries.utils.ts @@ -0,0 +1,311 @@ +import type { Prisma } from '@prisma/client' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + status: true, + locked: true, + limitless: true, + hprodCpu: true, + hprodGpu: true, + hprodMemory: true, + prodCpu: true, + prodGpu: true, + prodMemory: true, + everyonePerms: true, + ownerId: true, + createdAt: true, + updatedAt: true, + lastSuccessProvisionningVersion: true, + owner: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + type: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + }, + }, + members: { + select: { + roleIds: true, + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + type: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + }, + }, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + roles: { + select: { + id: true, + name: true, + permissions: true, + position: true, + oidcGroup: true, + type: true, + projectId: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + isPrivate: true, + externalRepoUrl: true, + externalUserName: true, + helmValuesFiles: true, + deployRevision: true, + deployPath: true, + createdAt: true, + updatedAt: true, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + clusterId: true, + stageId: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + deployments: { + select: { + id: true, + name: true, + autosync: true, + createdAt: true, + updatedAt: true, + environment: { + select: { + id: true, + name: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + deploymentSources: { + select: { + id: true, + type: true, + path: true, + targetRevision: true, + helmValuesFiles: true, + repository: { + select: { + id: true, + internalRepoName: true, + }, + }, + }, + }, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectForUpdateSelect = { + id: true, + ownerId: true, + status: true, + locked: true, + members: { + select: { + userId: true, + user: { + select: { + type: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectForDataSelect = { + name: true, + description: true, + createdAt: true, + updatedAt: true, + environments: { + select: { + name: true, + stage: true, + cluster: { + select: { label: true }, + }, + }, + }, + owner: true, +} satisfies Prisma.ProjectSelect + +export const projectIdSelect = { + id: true, +} satisfies Prisma.ProjectSelect + +export const projectSlugSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export const projectContextSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export const projectForUpsertSelect = { + id: true, + slug: true, + status: true, + locked: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export type ProjectUpdateContext = Prisma.ProjectGetPayload<{ + select: typeof projectForUpdateSelect +}> + +export type ProjectDataExport = Prisma.ProjectGetPayload<{ + select: typeof projectForDataSelect +}> + +export type ProjectContext = Prisma.ProjectGetPayload<{ + select: typeof projectContextSelect +}> + +export type ProjectForUpsert = Prisma.ProjectGetPayload<{ + select: typeof projectForUpsertSelect +}> + +export function getProject(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectSelect }) +} + +export function getProjectNotArchived(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findFirst({ where: { id: projectId, status: { not: 'archived' } }, select: projectSelect }) +} + +export function listProjects(tx: Prisma.TransactionClient, whereAnd: Prisma.ProjectWhereInput[]) { + return tx.project.findMany({ + where: { AND: whereAnd }, + select: projectSelect, + }) +} + +export function listProjectSlugsForPrefix(tx: Prisma.TransactionClient, prefix: string) { + return tx.project.findMany({ + where: { slug: { startsWith: prefix } }, + select: { slug: true }, + }) +} + +export function getProjectSlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: { slug: true } }) +} + +export function getProjectContext(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectContextSelect }) +} + +export function listProjectsForDataExport(tx: Prisma.TransactionClient) { + return tx.project.findMany({ + select: projectForDataSelect, + }) +} + +export function createProject(tx: Prisma.TransactionClient, data: Prisma.ProjectCreateInput) { + return tx.project.create({ + data, + select: projectIdSelect, + }) +} + +export function getNotArchivedProjectForUpdate(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findFirst({ + where: { id: projectId, status: { not: 'archived' } }, + select: projectForUpdateSelect, + }) +} + +export function getProjectForUpsert(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectForUpsertSelect }) +} + +export function updateProject(tx: Prisma.TransactionClient, projectId: string, data: Prisma.ProjectUpdateInput) { + return tx.project.update({ where: { id: projectId }, data }) +} + +export function deleteProjectDependencies(tx: Prisma.TransactionClient, projectId: string) { + return Promise.all([ + tx.repository.deleteMany({ where: { projectId } }), + tx.environment.deleteMany({ where: { projectId } }), + tx.deployment.deleteMany({ where: { projectId } }), + ]) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project-testing.utils.ts new file mode 100644 index 0000000000..4205b88470 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project-testing.utils.ts @@ -0,0 +1,161 @@ +import type { projectContract } from '@cpn-console/shared' +import type { Prisma, ProjectMembers, User } from '@prisma/client' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { projectSelect, ProjectWithDetails } from './project-queries.utils' +import { PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +export function makeUser(overrides: Partial = {}): User { + return { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastLogin: null, + ...overrides, + } +} + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } +} + +type ProjectMembersWithUser = ProjectMembers & { user: User } + +export function makeProjectMemberWithUser(user: User, overrides: Partial = {}): ProjectMembersWithUser { + return { + ...makeProjectMembers({ userId: user.id, ...overrides }), + user, + } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + const owner = overrides.owner ?? makeUser() + const id = overrides.id ?? faker.string.uuid() + return { + id, + name: faker.string.alphanumeric(8).toLowerCase(), + slug: faker.string.alphanumeric(8).toLowerCase(), + description: faker.lorem.sentence(), + status: 'created', + locked: false, + limitless: false, + hprodCpu: 1, + hprodGpu: 0, + hprodMemory: 2, + prodCpu: 1, + prodGpu: 0, + prodMemory: 2, + everyonePerms: PROJECT_PERMS.GUEST, + ownerId: owner.id, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastSuccessProvisionningVersion: null, + owner, + members: [], + plugins: [], + roles: [], + repositories: [], + environments: [], + deployments: [], + clusters: [], + ...overrides, + } +} + +export function makeProjectContext(overrides: Partial = {}): ProjectContext { + return { + id: faker.string.uuid(), + slug: faker.string.alphanumeric(8).toLowerCase(), + locked: false, + status: 'created', + ...overrides, + } +} + +type Project = Prisma.ProjectGetPayload<{ select: typeof projectSelect }> + +export function makeProject(overrides: Partial = {}): Project { + const id = overrides.id ?? faker.string.uuid() + return { + id, + name: faker.string.alphanumeric(8).toLowerCase(), + slug: faker.string.alphanumeric(8).toLowerCase(), + description: '', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 896n, + ownerId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastSuccessProvisionningVersion: null, + owner: { + id: faker.string.uuid(), + email: 'owner@example.com', + firstName: 'Owner', + lastName: 'Test', + adminRoleIds: [], + type: 'human', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastLogin: faker.date.past(), + }, + members: [], + plugins: [], + roles: [], + repositories: [], + environments: [], + deployments: [], + clusters: [], + ...overrides, + } +} + +export type ProjectWithMembers = Prisma.ProjectGetPayload<{ + include: { members: { include: { user: true } } } +}> + +export function makeProjectWithMembersResult( + project: ProjectWithDetails, + members: Array> = [], +): ProjectWithMembers { + return { ...project, members } as ProjectWithMembers +} + +export function makeCreateProjectBody(overrides: Partial = {}): typeof projectContract.createProject.body._type { + return { + name: faker.string.alphanumeric({ length: faker.number.int({ min: 2, max: 20 }) }).toLowerCase(), + description: faker.lorem.sentence(), + limitless: true, + hprodCpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + hprodGpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + hprodMemory: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodCpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodGpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodMemory: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + ...overrides, + } +} + +export function makeListProjectsQuery(overrides: Partial = {}): typeof projectContract.listProjects.query._type { + return { + filter: 'member', + ...overrides, + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.controller.ts new file mode 100644 index 0000000000..a10afe449f --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.controller.ts @@ -0,0 +1,91 @@ +import type { ProjectV2 } from '@cpn-console/shared' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { AdminAuthorized, projectContract } from '@cpn-console/shared' +import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, Inject, Post, Put, Query, Req, UseGuards } from '@nestjs/common' +import { json2csv } from 'json-2-csv' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectAccess } from '../infrastructure/permission/project/project-access.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectService } from './project.service' +import { generateProjectV2 } from './project.utils' + +@Controller('api/v1/projects') +export class ProjectController { + constructor( + @Inject(ProjectService) private readonly project: ProjectService, + ) {} + + @Get('/data') + @UseGuards(UserGuard) + @RequireAdminPermission('Manage') + async getProjectsData(): Promise { + return json2csv(await this.project.getProjectsData()) + } + + @Get('') + @UseGuards(UserGuard) + async list( + @Query(new ZodValidationPipe(projectContract.listProjects.query)) query: typeof projectContract.listProjects.query._type, + @AuthUser() user: UserContext, + ): Promise { + if (query.filter === 'all' && !AdminAuthorized.Manage(user.adminPermissions)) { + throw new ForbiddenException('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + return (await this.project.listProjects(query, user)).map(generateProjectV2) + } + + @Post('') + @HttpCode(HttpStatus.CREATED) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageProjects') + async create( + @Body(new ZodValidationPipe(projectContract.createProject.body)) body: typeof projectContract.createProject.body._type, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return generateProjectV2(await this.project.create(body, user.userId, request.id)) + } + + @Get('/:projectId') + @UseGuards(ProjectGuard) + @RequireProjectAccess() + async get( + @Project() project: ProjectContext, + ): Promise { + return generateProjectV2(await this.project.get(project.id)) + } + + @Put('/:projectId') + @HttpCode(HttpStatus.OK) + @UseGuards(ProjectGuard) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectPermission('Manage') + async update( + @Body(new ZodValidationPipe(projectContract.updateProject.body)) data: typeof projectContract.updateProject.body._type, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return generateProjectV2(await this.project.update(data, user, project.id, request.id)) + } + + @Delete('/:projectId') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(ProjectGuard) + @RequireProjectPermission('Manage') + async archive( + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.project.archive(project.id, user.userId, request.id) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.module.ts new file mode 100644 index 0000000000..4e50667c67 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { LogModule } from '../log/log.module' +import { VaultModule } from '../vault/vault.module' +import { ProjectController } from './project.controller' +import { ProjectService } from './project.service' + +@Module({ + imports: [ + AppEventsModule, + AuthModule, + DatabaseModule, + EventsModule, + ProjectPermissionModule, + UserPermissionModule, + LogModule, + VaultModule, + ConfigModule.forFeature(baseConfigFactory), + ], + controllers: [ProjectController], + providers: [ProjectService], + exports: [ProjectService], +}) +export class ProjectModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.service.spec.ts new file mode 100644 index 0000000000..76de94e2fa --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -0,0 +1,521 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import { faker } from '@faker-js/faker' +import { + ForbiddenException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { + makeCreateProjectBody, + makeListProjectsQuery, + makeProject, + makeProjectContext, + makeProjectMemberWithUser, + makeProjectWithDetails, + makeProjectWithMembersResult, + makeUser, +} from './project-testing.utils' +import { ProjectService } from './project.service' +import { generateSlug } from './project.utils' + +describe('projectService', () => { + let module: TestingModule + let service: ProjectService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + let config: DeepMockProxy + let vaultConfig: DeepMockProxy + let logs: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + config = mockDeep({ appVersion: 'dev' }) + vaultConfig = mockDeep() + logs = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + { provide: BASE_CONFIG, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: LogService, useValue: logs }, + ], + }).compile() + + service = module.get(ProjectService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('create', () => { + it('generates slug, creates project, emits event, returns ProjectV2', async () => { + const userId = faker.string.uuid() + const body = makeCreateProjectBody() + const existingSlugs = [body.name, `${body.name}-1`] + const expectedSlug = generateSlug(body.name, existingSlugs) + const tx = mockDeep() + + tx.project.findMany.mockResolvedValue(existingSlugs.map(slug => makeProject({ slug }))) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const pwd = makeProjectWithDetails({ slug: expectedSlug }) + pwd.roles = Array.from({ length: 4 }, (_, index) => ({ + id: faker.string.uuid(), + name: `role-${index}`, + permissions: 0n, + position: index, + oidcGroup: '', + type: 'managed', + projectId: pwd.id, + })) as never + tx.project.create.mockResolvedValue(makeProject({ id: pwd.id })) + tx.project.findUnique.mockResolvedValue(pwd) + + const requestId = faker.string.uuid() + const result = await service.create(body, userId, requestId) + + expect(tx.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { slug: { startsWith: body.name } } }), + ) + expect(tx.project.create).toHaveBeenCalled() + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', pwd.id, { + action: 'Create Project', + userId, + requestId, + }) + expect(logs.addLog).toHaveBeenCalledTimes(4) + expect(result).toBeDefined() + expect(result.slug).toBe(expectedSlug) + }) + + it('throws InternalServerErrorException when project cannot be loaded after creation', async () => { + const userId = faker.string.uuid() + const body = makeCreateProjectBody() + + const tx = mockDeep() + tx.project.findMany.mockResolvedValue([]) + tx.project.create.mockResolvedValue(makeProject({ id: 'test-id' })) + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.create(body, userId, faker.string.uuid())) + .rejects.toThrow(InternalServerErrorException) + }) + }) + + describe('list', () => { + it('returns projects filtered by member for non-admin', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + const projects = [makeProjectWithDetails(), makeProjectWithDetails()] + prisma.project.findMany.mockResolvedValue(projects) + + const result = await service.listProjects( + makeListProjectsQuery(), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalled() + expect(result).toHaveLength(2) + expect(result[0]).toHaveProperty('id') + expect(result[0]).toHaveProperty('slug') + }) + + it('allows admin-only "all" filter for admin users', async () => { + const userId = faker.string.uuid() + const adminPerms = BigInt(2) + const user = { userId, adminPermissions: adminPerms } satisfies UserContext + prisma.project.findMany.mockResolvedValue([makeProjectWithDetails()]) + + const result = await service.listProjects( + makeListProjectsQuery({ filter: 'all' }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalled() + expect(result).toHaveLength(1) + }) + + it('forbids "all" filter for non-admin users', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + + await expect( + service.listProjects(makeListProjectsQuery({ filter: 'all' }), user), + ).rejects.toThrow(ForbiddenException) + }) + + it('filters by status', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + prisma.project.findMany.mockResolvedValue([]) + + await service.listProjects( + makeListProjectsQuery({ status: 'created' }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ status: 'created' }), + ]), + }), + }), + ) + }) + + it('filters by search term', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + const search = 'myproject' + prisma.project.findMany.mockResolvedValue([]) + + await service.listProjects( + makeListProjectsQuery({ search }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ + OR: [ + { name: { contains: search } }, + { owner: { email: { contains: search } } }, + ], + }), + ]), + }), + }), + ) + }) + }) + + describe('get', () => { + it('returns ProjectV2 for non-archived project', async () => { + const ctx = makeProjectContext({ status: 'created' }) + const pwd = makeProjectWithDetails({ id: ctx.id, status: 'created' }) + prisma.project.findFirst.mockResolvedValue(pwd) + + const result = await service.get(ctx.id) + + expect(result).toBeDefined() + expect(result.id).toBe(ctx.id) + expect(result.status).toBe('created') + }) + + it('throws NotFoundException for archived project', async () => { + const ctx = makeProjectContext({ status: 'archived' }) + prisma.project.findFirst.mockResolvedValue(null) + + await expect(service.get(ctx.id)).rejects.toThrow(NotFoundException) + }) + + it('throws NotFoundException when project cannot be loaded', async () => { + const ctx = makeProjectContext({ status: 'created' }) + prisma.project.findFirst.mockResolvedValue(null) + + await expect(service.get(ctx.id)).rejects.toThrow(NotFoundException) + }) + }) + + describe('update', () => { + it('updates description and returns updated project', async () => { + const ctx = makeProjectContext({ status: 'created', locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id }) + const updatedPwd = makeProjectWithDetails({ id: ctx.id, description: 'Updated desc' }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(updatedPwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const requestId = faker.string.uuid() + const result = await service.update( + { description: 'Updated desc' }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + requestId, + ) + + expect(result.description).toBe('Updated desc') + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', ctx.id, { + action: 'Update Project', + userId: requestorId, + requestId, + }) + }) + + it('strips locked field for non-admin', async () => { + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: true }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: {}, + }), + ) + }) + + it('allows admin to update locked field', async () => { + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const adminPerms = BigInt(2) + const pwd = makeProjectWithDetails({ id: ctx.id }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: true }, + { userId: requestorId, adminPermissions: adminPerms }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: expect.objectContaining({ locked: true }), + }), + ) + }) + + it('strips ownerId for non-owner non-admin', async () => { + const ownerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { ownerId: faker.string.uuid() }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: {}, + }), + ) + }) + + it('throws ForbiddenException when project is locked and not unlocking', async () => { + const ctx = makeProjectContext({ locked: true }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id, locked: true }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { description: 'test' }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow(ForbiddenException) + }) + + it('allows admin to unlock a locked project', async () => { + const ctx = makeProjectContext({ locked: true }) + const requestorId = faker.string.uuid() + const adminPerms = BigInt(2) + const pwd = makeProjectWithDetails({ id: ctx.id, locked: true }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: false }, + { userId: requestorId, adminPermissions: adminPerms }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalled() + }) + + it('validates new owner is a member of the project', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow('Le nouveau propriétaire doit faire partie des membres actuels du projet') + }) + + it('validates new owner is a human account', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue( + makeProjectWithMembersResult(pwd, [makeProjectMemberWithUser(makeUser({ id: newOwnerId, type: 'bot' }))]), + ) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow('Seuls les comptes humains peuvent être propriétaire de projets') + }) + + it('transfers ownership correctly', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue( + makeProjectWithMembersResult(pwd, [makeProjectMemberWithUser( + makeUser({ id: newOwnerId, type: 'human' }), + { roleIds: [faker.string.uuid()] }, + )]), + ) + tx.project.findUnique.mockResolvedValue( + makeProjectWithDetails({ id: ctx.id, ownerId: newOwnerId }), + ) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.projectMembers.delete).toHaveBeenCalledWith({ + where: { projectId_userId: { userId: newOwnerId, projectId: ctx.id } }, + }) + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + owner: { connect: { id: newOwnerId } }, + }, + }), + ) + expect(result).toBeDefined() + }) + }) + + describe('archive', () => { + it('deletes related data, emits event, renames and archives project', async () => { + const projectId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: projectId, name: 'myproject', slug: 'myproject' }) + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(pwd) + tx.repository.deleteMany.mockResolvedValue({ count: 2 }) + tx.environment.deleteMany.mockResolvedValue({ count: 3 }) + tx.deployment.deleteMany.mockResolvedValue({ count: 1 }) + tx.project.update.mockResolvedValue(makeProject({ id: projectId })) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const requestId = faker.string.uuid() + const requestorId = faker.string.uuid() + + await service.archive(projectId, requestorId, requestId) + + expect(tx.repository.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.environment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.deployment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.delete', pwd, { + action: 'Delete all project resources', + userId: requestorId, + requestId, + }) + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: projectId }, + data: expect.objectContaining({ + status: 'archived', + locked: true, + clusters: { set: [] }, + }), + }), + ) + }) + + it('throws NotFoundException when project does not exist', async () => { + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.archive(faker.string.uuid())) + .rejects.toThrow(NotFoundException) + }) + }) + + describe('getData', () => { + it('returns CSV data array', async () => { + prisma.project.findMany.mockResolvedValue([makeProjectWithDetails()]) + + const result = await service.getProjectsData() + + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(1) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.service.ts new file mode 100644 index 0000000000..2f920de897 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.service.ts @@ -0,0 +1,335 @@ +import type { projectContract } from '@cpn-console/shared' +import type { ConfigType } from '@nestjs/config' +import type { Prisma } from '@prisma/client' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectDataExport, ProjectUpdateContext, ProjectWithDetails } from './project-queries.utils' +import { AdminAuthorized } from '@cpn-console/shared' +import { BadRequestException, ForbiddenException, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { LogService } from '../log/log.service' +import { createProjectMember, deleteProjectMember } from '../project-members/project-members-queries.utils' +import { + createProject, + deleteProjectDependencies, + getNotArchivedProjectForUpdate, + getProject, + listProjectSlugsForPrefix as listProjectSlugsForSlugPrefix, + projectForDataSelect, + projectSelect, + updateProject, +} from './project-queries.utils' +import { generateProjectCreateInput, generateProjectWhereInput, generateSlug, parseProjectUpdateInput } from './project.utils' + +@Injectable() +export class ProjectService { + private readonly logger = new Logger(ProjectService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: ConfigType, + @Inject(LogService) private readonly logs: LogService, + ) {} + + @StartActiveSpan() + async getProjectsData(): Promise { + const span = trace.getActiveSpan() + this.logger.log('project.getData requested') + const data = await this.listProjectsForDataExport() + span?.setAttribute('project.data.count', data.length) + this.logger.log(`project.getData completed (count=${data.length})`) + return data + } + + @StartActiveSpan() + async listProjects( + query: typeof projectContract.listProjects.query._type, + user: UserContext, + ): Promise { + const span = trace.getActiveSpan() + const { filter = 'member' } = query + span?.setAttribute('project.list.filter', filter) + span?.setAttribute('user.id', user.userId) + + if (filter === 'all' && !AdminAuthorized.Manage(user.adminPermissions)) { + this.logger.warn(`project.list forbidden (requestorUserId=${user.userId}, filter=${filter})`) + throw new ForbiddenException('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + + const whereAnd = generateProjectWhereInput({ + query, + requestorUserId: user.userId, + appVersion: this.baseConfig.appVersion, + }) + + this.logger.debug(`project.list started (requestorUserId=${user.userId}, filter=${filter})`) + const projects = await this.fetchProjects(whereAnd) + span?.setAttribute('project.list.count', projects.length) + this.logger.debug(`project.list completed (requestorUserId=${user.userId}, filter=${filter}, count=${projects.length})`) + + return projects + } + + @StartActiveSpan() + async get(projectId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.debug(`project.get started (projectId=${projectId})`) + const project = await this.getProjectNotArchived(projectId) + if (!project) { + this.logger.warn(`project.get notFound (projectId=${projectId})`) + throw new NotFoundException('Projet introuvable') + } + this.logger.debug(`project.get completed (projectId=${projectId})`) + return project + } + + @StartActiveSpan() + async create( + body: typeof projectContract.createProject.body._type, + requestorUserId: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('user.id', requestorUserId) + this.logger.log(`project.create started (requestorUserId=${requestorUserId}, projectName=${body.name})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const existingSlugs = await listProjectSlugsForSlugPrefix(tx, body.name) + const slug = generateSlug(body.name, existingSlugs.map(s => s.slug)) + + const created = await createProject(tx, generateProjectCreateInput(body, requestorUserId, slug)) + const loaded = await getProject(tx, created.id) + if (!loaded) throw new InternalServerErrorException('Project created but cannot be loaded') + return loaded + }) + await this.appEvents.emitProjectEvent('project.upsert', project.id, { + action: 'Create Project', + userId: requestorUserId, + requestId, + }) + await Promise.all(project.roles.map(async role => this.logProjectRoleAction( + 'Upsert Project Role', + project, + role.id, + `Rôle synchronisé: ${role.name}`, + requestorUserId, + requestId, + ))) + span?.setAttribute('project.id', project.id) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.create completed (requestorUserId=${requestorUserId}, projectId=${project.id}, slug=${project.slug})`) + return project + } catch (error) { + this.logger.error( + `project.create failed (requestorUserId=${requestorUserId}, projectName=${body.name}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + async update( + body: typeof projectContract.updateProject.body._type, + user: UserContext, + projectId: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('user.id', user.userId) + this.logger.log(`project.update started (projectId=${projectId}, requestorUserId=${user.userId})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const projectDb = await getNotArchivedProjectForUpdate(tx, projectId) + if (!projectDb) throw new NotFoundException('Projet introuvable') + + const { effectiveData, strippedKeys } = this.stripProjectUpdateBody(body, user, projectDb) + this.logProjectUpdateStrippedFields(span, projectId, user.userId, strippedKeys) + if (projectDb.locked && effectiveData.locked !== false) { + throw new ForbiddenException('Veuillez déverrouiller le projet pour le mettre à jour') + } + + await this.updateProjectOwnerIfNeeded(tx, projectDb, effectiveData, projectId, user.userId) + + const updateData = parseProjectUpdateInput(effectiveData) + const effectiveKeys = Object.keys(effectiveData) + span?.setAttribute('project.update.effectiveKeys.count', effectiveKeys.length) + await updateProject(tx, projectId, updateData) + + const updated = await getProject(tx, projectId) + if (!updated) throw new NotFoundException('Projet introuvable') + this.logger.log(`project.update dbUpdated (projectId=${projectId}, requestorUserId=${user.userId}, effectiveKeys=${effectiveKeys.join(',')})`) + return updated + }) + await this.appEvents.emitProjectEvent('project.upsert', project.id, { + action: 'Update Project', + userId: user.userId, + requestId, + }) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.update completed (projectId=${projectId}, requestorUserId=${user.userId})`) + return project + } catch (error) { + this.logger.error( + `project.update failed (projectId=${projectId}, requestorUserId=${user.userId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async archive(projectId: string, requestorUserId?: string, requestId?: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.archive started (projectId=${projectId})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const loaded = await getProject(tx, projectId) + if (!loaded) throw new NotFoundException('Projet introuvable') + + await deleteProjectDependencies(tx, projectId) + + const archivedSuffix = `${Date.now()}_archived` + await updateProject(tx, projectId, { + name: `${loaded.name}_${archivedSuffix}`, + slug: `${loaded.slug}_${archivedSuffix}`, + status: 'archived', + locked: true, + clusters: { set: [] }, + }) + + return loaded + }) + // pass the pre-archive snapshot: the row was renamed (slug suffixed) in the + // transaction above, listeners must clean up resources named after the old slug + await this.appEvents.emitProjectEvent('project.delete', project, { + action: 'Delete all project resources', + userId: requestorUserId, + requestId, + }) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.archive completed (projectId=${projectId}, slug=${project.slug})`) + } catch (error) { + this.logger.error( + `project.archive failed (projectId=${projectId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async logProjectRoleAction( + action: string, + project: ProjectWithDetails, + roleId: string, + messageResume: string, + userId: string | undefined, + requestId: string | undefined, + ): Promise { + await this.logs.addLog({ + action, + data: { + args: { projectId: project.id, roleId }, + messageResume, + results: { projectId: project.id, roleId }, + }, + userId, + requestId, + projectId: project.id, + }) + } + + private async listProjectsForDataExport(): Promise { + return this.prisma.project.findMany({ + select: projectForDataSelect, + }) + } + + private async fetchProjects(whereAnd: Prisma.ProjectWhereInput[]): Promise { + return this.prisma.project.findMany({ + where: { AND: whereAnd }, + select: projectSelect, + }) + } + + private async getProjectNotArchived(projectId: string): Promise { + return this.prisma.project.findFirst({ + where: { id: projectId, status: { not: 'archived' } }, + select: projectSelect, + }) + } + + private stripProjectUpdateBody( + body: typeof projectContract.updateProject.body._type, + user: UserContext, + project: ProjectUpdateContext, + ): { effectiveData: Record, strippedKeys: string[] } { + const isOwner = project.ownerId === user.userId + const isAdmin = AdminAuthorized.Manage(user.adminPermissions) + const effectiveData: Record = { ...body } + const strippedKeys: string[] = [] + + if (!isAdmin) { + if ('locked' in effectiveData) strippedKeys.push('locked') + delete effectiveData.locked + + if (!isOwner) { + if ('ownerId' in effectiveData) strippedKeys.push('ownerId') + delete effectiveData.ownerId + } + } + + return { effectiveData, strippedKeys } + } + + private logProjectUpdateStrippedFields( + span: ReturnType, + projectId: string, + requestorUserId: string, + strippedKeys: string[], + ): void { + if (!strippedKeys.length) return + + span?.setAttribute('project.update.strippedKeys.count', strippedKeys.length) + this.logger.debug(`project.update strippedFields (projectId=${projectId}, requestorUserId=${requestorUserId}, strippedKeys=${strippedKeys.join(',')})`) + } + + private async updateProjectOwnerIfNeeded( + tx: Prisma.TransactionClient, + project: ProjectUpdateContext, + effectiveData: Record, + projectId: string, + requestorUserId: string, + ): Promise { + const ownerIdCandidate = effectiveData.ownerId as string | undefined + if (!ownerIdCandidate || ownerIdCandidate === project.ownerId) return + + this.logger.log(`project.update ownerChange started (projectId=${projectId}, requestorUserId=${requestorUserId}, previousOwnerId=${project.ownerId}, nextOwnerId=${ownerIdCandidate})`) + + const memberCandidate = project.members.find(member => member.userId === ownerIdCandidate) + if (!memberCandidate) { + throw new BadRequestException('Le nouveau propriétaire doit faire partie des membres actuels du projet') + } + if (memberCandidate.user.type !== 'human') { + throw new BadRequestException('Seuls les comptes humains peuvent être propriétaire de projets') + } + + const oldOwnerIsMember = project.members.some(member => member.userId === project.ownerId) + if (!oldOwnerIsMember) { + await createProjectMember(tx, project.id, project.ownerId) + } + + await deleteProjectMember(tx, project.id, ownerIdCandidate) + await updateProject(tx, project.id, { owner: { connect: { id: ownerIdCandidate } } }) + + this.logger.log(`project.update ownerChange completed (projectId=${projectId}, requestorUserId=${requestorUserId}, previousOwnerId=${project.ownerId}, nextOwnerId=${ownerIdCandidate})`) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.utils.ts new file mode 100644 index 0000000000..bb50d21fdf --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/project/project.utils.ts @@ -0,0 +1,226 @@ +import type { CreateProjectBody, projectContract } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import type { ProjectWithDetails } from './project-queries.utils' +import { PROJECT_PERMS, ProjectSchemaV2, ProjectStatusSchema } from '@cpn-console/shared' +import { ProjectStatus } from '@prisma/client' +import { z } from 'zod' + +export function generateSlug(prefix: string, existingSlugs: string[] = []) { + if (!existingSlugs.includes(prefix)) return prefix + + let suffix = 1 + while (existingSlugs.includes(`${prefix}-${suffix}`)) { + suffix += 1 + } + return `${prefix}-${suffix}` +} + +export function generateProjectCreateInput( + data: CreateProjectBody, + ownerId: string, + slug: string, +): Prisma.ProjectCreateInput { + return { + name: data.name, + slug, + description: data.description ?? '', + status: ProjectStatus.created, + locked: false, + limitless: z.boolean().parse(data.limitless), + hprodCpu: data.hprodCpu, + hprodGpu: data.hprodGpu, + hprodMemory: data.hprodMemory, + prodCpu: data.prodCpu, + prodGpu: data.prodGpu, + prodMemory: data.prodMemory, + owner: { connect: { id: ownerId } }, + roles: { + create: [ + { + name: 'Administrateur', + permissions: PROJECT_PERMS.MANAGE, + position: 0, + oidcGroup: `/${slug}/console/admin`, + type: 'system:managed', + }, + { + name: 'DevOps', + permissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS + | PROJECT_PERMS.MANAGE_REPOSITORIES + | PROJECT_PERMS.REPLAY_HOOKS + | PROJECT_PERMS.SEE_SECRETS + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 1, + oidcGroup: `/${slug}/console/devops`, + type: 'system:managed', + }, + { + name: 'Développeur', + permissions: PROJECT_PERMS.MANAGE_REPOSITORIES + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 2, + oidcGroup: `/${slug}/console/developer`, + type: 'system:managed', + }, + { + name: 'Lecture seule', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 3, + oidcGroup: `/${slug}/console/readonly`, + type: 'system:managed', + }, + ], + }, + } +} + +export const ProjectV2ResponseSchema = ProjectSchemaV2.omit({ name: true }).extend({ + name: z.string(), +}) + +export function generateProjectV2(project: ProjectWithDetails) { + const payload = { + id: project.id, + name: project.name, + slug: project.slug, + description: project.description, + status: project.status, + locked: project.locked, + limitless: project.limitless, + hprodCpu: project.hprodCpu, + hprodGpu: project.hprodGpu, + hprodMemory: project.hprodMemory, + prodCpu: project.prodCpu, + prodGpu: project.prodGpu, + prodMemory: project.prodMemory, + everyonePerms: project.everyonePerms, + ownerId: project.ownerId, + owner: project.owner, + members: project.members.map(m => ({ + userId: m.user.id, + email: m.user.email, + firstName: m.user.firstName, + lastName: m.user.lastName, + roleIds: m.roleIds, + })), + roles: project.roles.map(role => ({ + id: role.id, + name: role.name, + permissions: role.permissions, + position: role.position, + projectId: role.projectId, + oidcGroup: role.oidcGroup ? role.oidcGroup.replace(`/${project.slug}`, '') : '', + type: role.type, + })), + clusterIds: project.clusters.map(c => c.id), + lastSuccessProvisionningVersion: project.lastSuccessProvisionningVersion, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + } + + return ProjectV2ResponseSchema.parse(payload) +} + +export function generateProjectWhereInput(opts: { + query: typeof projectContract.listProjects.query._type + requestorUserId: string + appVersion: string +}): Prisma.ProjectWhereInput[] { + const projectStatus = ProjectStatusSchema.options + const { status, statusIn, statusNotIn, filter = 'member', ...rest } = opts.query + + const whereAnd: Prisma.ProjectWhereInput[] = [] + if (rest.id) whereAnd.push({ id: rest.id }) + if (rest.locked !== undefined) whereAnd.push({ locked: rest.locked }) + if (rest.name) whereAnd.push({ name: rest.name }) + if (rest.description) whereAnd.push({ description: { contains: rest.description } }) + + const statusWhere = parseEnumWhereFilter({ + enumValues: projectStatus, + eqValue: status, + inValues: statusIn, + notInValues: statusNotIn, + }) + if (statusWhere) whereAnd.push({ status: statusWhere }) + + if (rest.lastSuccessProvisionningVersion) { + if (rest.lastSuccessProvisionningVersion === 'outdated') { + whereAnd.push({ lastSuccessProvisionningVersion: { not: opts.appVersion } }) + } else if (rest.lastSuccessProvisionningVersion === 'last') { + whereAnd.push({ lastSuccessProvisionningVersion: { equals: opts.appVersion } }) + } else { + whereAnd.push({ lastSuccessProvisionningVersion: rest.lastSuccessProvisionningVersion }) + } + } + + if (rest.search) { + whereAnd.push({ + OR: [ + { name: { contains: rest.search } }, + { owner: { email: { contains: rest.search } } }, + ], + }) + } + + if (filter === 'owned') { + whereAnd.push({ ownerId: opts.requestorUserId }) + } else if (filter === 'member') { + whereAnd.push({ + OR: [ + { members: { some: { userId: opts.requestorUserId } } }, + { ownerId: opts.requestorUserId }, + ], + }) + } + + return whereAnd +} + +export function parseEnumWhereFilter({ + enumValues, + eqValue, + inValues, + notInValues, +}: { + enumValues: T + eqValue: T[number] | undefined + inValues: string | undefined + notInValues: string | undefined +}): + | T[number] + | { in: T[number][] } + | { notIn: T[number][] } + | undefined { + if (eqValue) { + return eqValue + } + if (inValues) { + return { in: parseCsvEnumList(enumValues, inValues) } + } + if (notInValues) { + return { notIn: parseCsvEnumList(enumValues, notInValues) } + } +} + +const ProjectUpdateDataSchema = z.object({ + description: z.string().optional(), + locked: z.boolean().optional(), + limitless: z.boolean().optional(), + hprodCpu: z.number().optional(), + hprodGpu: z.number().optional(), + hprodMemory: z.number().optional(), + prodCpu: z.number().optional(), + prodGpu: z.number().optional(), + prodMemory: z.number().optional(), + everyonePerms: z.union([z.string(), z.number(), z.bigint()]).transform(BigInt).optional(), +}).passthrough() + +export function parseProjectUpdateInput(effectiveData: Record): Prisma.ProjectUpdateInput { + return ProjectUpdateDataSchema.parse(effectiveData) satisfies Prisma.ProjectUpdateInput +} + +function parseCsvEnumList(toMatch: T, inputs: string): T[number][] { + return inputs.split(',').filter(i => toMatch.includes(i)) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts new file mode 100644 index 0000000000..f9e676753d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts @@ -0,0 +1,22 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface HarborConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + ruleTemplate?: string + ruleCount?: string + retentionCron: string + robotRotationThresholdDays: number + projectSlugCacheTtlMs: number + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface HarborModuleOptions extends HarborConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('harbor') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as HARBOR_CONFIG } diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts new file mode 100644 index 0000000000..057bd83f04 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts @@ -0,0 +1,96 @@ +import type { HarborConfig } from './harbor.module-definition' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { VaultClientService } from '../vault/vault-client.service' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryHttpClientService } from './registry-http-client.service' + +const harborUrl = 'https://harbor.example' +const harborAdminPassword = faker.internet.password() +const basicAuth = `Basic ${Buffer.from(`admin:${harborAdminPassword}`, 'utf8').toString('base64')}` + +const server = setupServer() + +describe('registryService', () => { + let service: RegistryClientService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + beforeEach(async () => { + const harborConfig = mockDeep({ + url: harborUrl, + internalUrl: harborUrl, + internalOrPublicUrl: harborUrl, + admin: 'admin', + adminPassword: harborAdminPassword, + ruleTemplate: 'latestPushedK', + ruleCount: '10', + retentionCron: '0 22 2 * * *', + }) + + const module = await Test.createTestingModule({ + providers: [ + RegistryClientService, + RegistryHttpClientService, + { + provide: VaultClientService, + useValue: {}, + }, + { + provide: HARBOR_CONFIG, + useValue: harborConfig, + }, + ], + }).compile() + service = module.get(RegistryClientService) + }) + + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should send basic auth and JSON body on createProject', async () => { + server.use( + http.post(`${harborUrl}/api/v2.0/projects`, async ({ request }) => { + expect(request.method).toBe('POST') + expect(request.url).toBe(`${harborUrl}/api/v2.0/projects`) + expect(request.headers.get('accept')).toBe('application/json') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('content-type')).toContain('application/json') + expect(await request.json()).toEqual({ + project_name: 'myproj', + metadata: { auto_scan: 'true' }, + storage_limit: -1, + }) + return HttpResponse.json({}, { status: HttpStatus.CREATED }) + }), + ) + + await service.createProject('myproj', -1) + }) + + it('should send X-Is-Resource-Name on getProjectByName', async () => { + server.use( + http.get(`${harborUrl}/api/v2.0/projects/:projectName`, async ({ request, params }) => { + expect(request.method).toBe('GET') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('x-is-resource-name')).toBe('true') + expect(params.projectName).toBe('myproj') + return HttpResponse.json({ project_id: 123, metadata: {} }) + }), + ) + + const res = await service.getProjectByName('myproj') + + expect(res).toMatchObject({ status: HttpStatus.OK, data: { project_id: 123 } }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-client.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-client.service.ts new file mode 100644 index 0000000000..0e85eff9c9 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-client.service.ts @@ -0,0 +1,226 @@ +import type { RegistryQuery, RegistryResponse } from './registry-http-client.service' +import { Inject, Injectable } from '@nestjs/common' +import { RegistryHttpClientService } from './registry-http-client.service' +import { ROBOT_LIST_PAGE_SIZE } from './registry.constants' + +export const roAccess: HarborAccess[] = [ + { resource: 'repository', action: 'pull' }, + { resource: 'artifact', action: 'read' }, +] + +export const rwAccess: HarborAccess[] = [ + ...roAccess, + { resource: 'repository', action: 'list' }, + { resource: 'tag', action: 'list' }, + { resource: 'artifact', action: 'list' }, + { resource: 'scan', action: 'create' }, + { resource: 'scan', action: 'stop' }, + { resource: 'repository', action: 'push' }, + { resource: 'artifact-label', action: 'create' }, + { resource: 'artifact-label', action: 'delete' }, + { resource: 'tag', action: 'create' }, + { resource: 'tag', action: 'delete' }, +] + +export interface HarborAccess { + resource: string + action: string +} + +export interface HarborProject { + project_id?: number + metadata?: { + retention_id?: number | string + } +} + +export interface HarborRobot { + id?: number + name?: string +} + +export interface HarborRobotCreated { + id?: number + name: string + secret: string +} + +export interface HarborMember { + id?: number + entity_name?: string + entity_type?: string + role_id?: number +} + +export interface HarborGroupMemberRequest { + role_id: number + member_group: { + group_name: string + group_type: number + } +} + +export interface HarborProjectQuota { + ref?: { id?: number } + hard?: { storage?: number } +} + +export interface HarborRobotPermission { + namespace: string + kind: 'project' + access: HarborAccess[] +} + +export interface HarborRobotCreateRequest { + name: string + duration: number + description: string + disable: boolean + level: 'project' + permissions: HarborRobotPermission[] +} + +export interface HarborRetentionRule { + disabled: boolean + action: 'retain' + template: string + params: Record + tag_selectors: Array<{ kind: string, decoration: string, pattern: string }> + scope_selectors: { + repository: Array<{ kind: string, decoration: string, pattern: string }> + } +} + +export interface HarborRetentionPolicy { + algorithm: 'or' | 'and' + scope: { level: 'project', ref: number } + rules: HarborRetentionRule[] + trigger: { + kind: 'Schedule' + settings: { cron?: string } + references: unknown[] + } +} + +@Injectable() +export class RegistryClientService { + constructor( + @Inject(RegistryHttpClientService) private readonly http: RegistryHttpClientService, + ) {} + + async getProjectByName(projectName: string): Promise> { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async createProject(projectName: string, storageLimit: number) { + return this.http.fetch('projects', { + method: 'POST', + body: { + project_name: projectName, + metadata: { auto_scan: 'true' }, + storage_limit: storageLimit, + }, + }) + } + + async deleteProjectByName(projectName: string) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async listQuotas(projectId: number) { + return this.http.fetch(`quotas?reference_id=${encodeURIComponent(String(projectId))}`, { + method: 'GET', + }) + } + + async updateQuota(projectId: number, storageLimit: number) { + return this.http.fetch(`quotas/${encodeURIComponent(String(projectId))}`, { + method: 'PUT', + body: { + hard: { + storage: storageLimit, + }, + }, + }) + } + + async getGroupMembers(projectName: string) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async addGroupMember(projectName: string, body: HarborGroupMemberRequest) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members`, { + method: 'POST', + headers: { 'X-Is-Resource-Name': 'true' }, + body, + }) + } + + async removeGroupMember(projectName: string, memberId: number) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members/${encodeURIComponent(String(memberId))}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + getProjectRobots(projectId: number): AsyncGenerator { + return this.paginate('robots', { + q: `Level=project,ProjectID=${projectId}`, + }) + } + + async createRobot(body: HarborRobotCreateRequest) { + return this.http.fetch('robots', { + method: 'POST', + body, + }) + } + + async deleteRobot(robotId: number): Promise { + return this.http.fetch(`robots/${encodeURIComponent(String(robotId))}`, { + method: 'DELETE', + }) + } + + async getRetentionId(projectName: string): Promise { + const project = await this.getProjectByName(projectName) + if (project.status !== 200 || !project.data) return null + const retentionId = Number(project.data?.metadata?.retention_id) + return Number.isFinite(retentionId) ? retentionId : null + } + + async createRetention(body: HarborRetentionPolicy) { + return this.http.fetch('retentions', { + method: 'POST', + body, + }) + } + + async updateRetention(retentionId: number, body: HarborRetentionPolicy) { + return this.http.fetch(`retentions/${encodeURIComponent(String(retentionId))}`, { + method: 'PUT', + body, + }) + } + + private async* paginate(path: string, query: RegistryQuery): AsyncGenerator { + for (let page = 1; ; page++) { + const response = await this.http.fetch(path, { + method: 'GET', + query: { ...query, page, page_size: ROBOT_LIST_PAGE_SIZE }, + }) + if (response.status !== 200 || !response.data?.length) return + yield* response.data + if (response.data.length < ROBOT_LIST_PAGE_SIZE) return + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts new file mode 100644 index 0000000000..9d0a511ef6 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts @@ -0,0 +1,62 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './registry.constants' + +export const projectSelect = { + slug: true, + plugins: { + where: { + pluginName: PLUGIN_NAME, + }, + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class RegistryDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return await this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return await this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-health.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-health.service.ts new file mode 100644 index 0000000000..4ffab35aad --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -0,0 +1,27 @@ +import type { HarborConfig } from './harbor.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { HARBOR_CONFIG } from './harbor.module-definition' + +@Injectable() +export class RegistryHealthService { + constructor( + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const credentials = `${this.harborConfig.admin}:${this.harborConfig.adminPassword}` + const base64 = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${base64}` + try { + const response = await fetch(this.harborConfig.probeUrl, { method: 'GET', headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts new file mode 100644 index 0000000000..bc3848b0cd --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts @@ -0,0 +1,109 @@ +import type { HarborConfig } from './harbor.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { encodeBasicAuth } from './registry.utils' + +export type RegistryQuery = Record + +export interface RegistryFetchOptions { + method?: string + headers?: Record + body?: unknown + query?: RegistryQuery +} + +export interface RegistryResponse { + status: HttpStatus + data: T | null +} + +export type RegistryErrorKind + = | 'NotConfigured' + | 'Unexpected' + +export class RegistryError extends Error { + readonly kind: RegistryErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + + constructor( + kind: RegistryErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string } = {}, + ) { + super(message) + this.name = 'RegistryError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + } +} + +@Injectable() +export class RegistryHttpClientService { + constructor( + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + ) {} + + private get apiBaseUrl() { + return new URL('api/v2.0/', this.harborConfig.internalOrPublicUrl).toString() + } + + private get defaultHeaders() { + return { Accept: 'application/json', Authorization: `Basic ${encodeBasicAuth(this.harborConfig.admin, this.harborConfig.adminPassword)}` } + } + + async fetch( + path: string, + options: RegistryFetchOptions = {}, + ): Promise> { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('registry.method', method) + span?.setAttribute('registry.path', path) + + const request = this.createRequest(path, method, options.body, options.headers, options.query) + const response = await fetch(request).catch((error) => { + throw new RegistryError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('registry.http.status', response.status) + return await handleResponse(response) + } + + private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record, query?: RegistryQuery): Request { + const url = new URL(path, this.apiBaseUrl) + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, String(value)) + } + } + const headers: Record = { + ...this.defaultHeaders, + ...extraHeaders, + } + let requestBody: string | undefined + if (body !== undefined) { + requestBody = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + return new Request(url, { method, headers, body: requestBody }) + } +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts new file mode 100644 index 0000000000..b4d14b009f --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts @@ -0,0 +1,77 @@ +import type { Cache } from 'cache-manager' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { HarborConfig } from './harbor.module-definition' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { RegistryPluginService } from './registry-plugin.service' +import { makeProjectWithDetails } from './registry-testing.utils' + +describe('registryPluginService', () => { + let service: RegistryPluginService + let harborConfig: DeepMockProxy + let datastore: DeepMockProxy + let registryClient: DeepMockProxy + let cache: DeepMockProxy + + beforeEach(async () => { + harborConfig = mockDeep({ + url: 'https://harbor.example/', + projectSlugCacheTtlMs: 300000, + }) + datastore = mockDeep() + registryClient = mockDeep() + cache = mockDeep() + + const moduleRef = await Test.createTestingModule({ + providers: [ + RegistryPluginService, + { provide: HARBOR_CONFIG, useValue: harborConfig }, + { provide: RegistryDatastoreService, useValue: datastore }, + { provide: RegistryClientService, useValue: registryClient }, + { provide: CACHE_MANAGER, useValue: cache }, + ], + }).compile() + + service = moduleRef.get(RegistryPluginService) + }) + + it('uses the stored Harbor project id when available', async () => { + cache.get.mockResolvedValue('dulei') + registryClient.getProjectByName.mockResolvedValue({ + status: HttpStatus.OK, + data: { project_id: 144, metadata: {} }, + }) + const infos = await service.infos('dulei') + const url = infos.to?.(makeToUrlParams({ + project: { id: '', name: '', slug: 'dulei' }, + store: { registry: { projectId: '144' } }, + })) + + expect(url).toBe('https://harbor.example/harbor/projects/144/') + expect(datastore.getProject).not.toHaveBeenCalled() + expect(registryClient.getProjectByName).toHaveBeenCalledWith('dulei') + }) + + it('falls back to Harbor lookup when the store is empty', async () => { + cache.get.mockResolvedValue(undefined) + datastore.getProject.mockResolvedValue(makeProjectWithDetails({ slug: 'dulei' })) + registryClient.getProjectByName.mockResolvedValue({ + status: HttpStatus.OK, + data: { project_id: 144, metadata: {} }, + }) + + const infos = await service.infos('dulei') + const url = infos.to?.(makeToUrlParams()) + + expect(registryClient.getProjectByName).toHaveBeenCalledWith('dulei') + expect(url).toBe('https://harbor.example/harbor/projects/144/') + expect(cache.set).toHaveBeenCalledWith('registry:project-slug:dulei', 'dulei', 300000) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts new file mode 100644 index 0000000000..e502423c5f --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts @@ -0,0 +1,143 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { Cache } from 'cache-manager' +import type { HarborConfig } from './harbor.module-definition' +import { DISABLED } from '@cpn-console/shared' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { createProjectSlugCacheKey } from './registry.utils' + +@Injectable() +export class RegistryPluginService { + private readonly logger = new Logger(RegistryPluginService.name) + + constructor( + @Inject(HARBOR_CONFIG) + private readonly harborConfig: HarborConfig, + @Inject(RegistryDatastoreService) + private readonly datastore: RegistryDatastoreService, + @Inject(RegistryClientService) + private readonly registryClient: RegistryClientService, + @Inject(CACHE_MANAGER) + private readonly cache: Cache, + ) {} + + private async resolveProjectSlug(projectId: string): Promise { + const cacheKey = createProjectSlugCacheKey(projectId) + const cached = await this.cache.get(cacheKey) + if (cached !== undefined) return cached ?? undefined + + const project = await this.datastore.getProject(projectId) + const slug = project?.slug ?? null + await this.cache.set(cacheKey, slug, this.harborConfig.projectSlugCacheTtlMs) + return slug ?? undefined + } + + private async resolveHarborProjectId(projectSlug: string): Promise { + try { + const harborProject = await this.registryClient.getProjectByName(projectSlug) + const harborProjectId = Number(harborProject.data?.project_id) + if (harborProject.status !== 200 || !Number.isFinite(harborProjectId)) { + return undefined + } + this.logger.log(`Successfully resolve harbor project id for project slug ${projectSlug}: ${harborProjectId}`) + return harborProjectId + } catch (error) { + this.logger.error(`Failed to resolve harbor project id for project slug ${projectSlug}: ${error}`) + return undefined + } + } + + private resolveHarborProjectUrl(harborProjectId: number): string { + return new URL(`harbor/projects/${harborProjectId}/`, this.harborConfig.url).toString() + } + + private async resolveProjectUrl(projectId: string): Promise { + const projectSlug = await this.resolveProjectSlug(projectId) + if (!projectSlug) { + return undefined + } + + const harborProjectId = await this.resolveHarborProjectId(projectSlug) + if (harborProjectId === undefined) { + return undefined + } + + return this.resolveHarborProjectUrl(harborProjectId) + } + + async infos(projectId: string): Promise { + const quotaDescription = '-1 -> illimité, sinon 100MB / 1.2GB (unités : B, KB, MB, GB, TB)), max 1024TB' + const projectUrl = await this.resolveProjectUrl(projectId) + if (!projectUrl) { + throw new Error('Project not found') + } + + return { + name: 'registry', + to: () => projectUrl, + title: 'Harbor', + imgSrc: '/img/harbor.svg', + description: 'Harbor stocke, analyse et distribue vos images de conteneurs', + config: { + project: [{ + permissions: { + admin: { read: false, write: false }, + user: { read: false, write: false }, + }, + key: 'projectId', + kind: 'text', + title: 'Num du projet Harbor', + value: '', + }, { + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot projet', + description: 'Activer le robot de projet (read-only) et afficher ses identifiants aux utilisateurs', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota', + value: '', + description: `Stockage limite (vide utilisation du paramètre global, ${quotaDescription}`, + placeholder: '', + }], + global: [{ + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot RO aux projets', + description: 'Définit le comportement en l\'absence de ce paramétrage au niveau projet', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota par défaut', + value: '-1', + description: `Stockage limite par projet (${quotaDescription}`, + placeholder: '-1', + }], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts new file mode 100644 index 0000000000..c04eae0429 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts @@ -0,0 +1,24 @@ +import type { ProjectWithDetails } from './registry-datastore.service' +import type { RegistryResponse } from './registry-http-client.service' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' + +export function makeOkResponse(data: T): RegistryResponse { + return { status: HttpStatus.OK, data } +} + +export function makeCreatedResponse(data: T): RegistryResponse { + return { status: HttpStatus.CREATED, data } +} + +export function makeNoContent(): RegistryResponse { + return { status: HttpStatus.NO_CONTENT, data: null } +} + +export function makeProjectWithDetails(overrides: Partial = {}) { + return { + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.constants.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.constants.ts new file mode 100644 index 0000000000..e4c054daab --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.constants.ts @@ -0,0 +1,52 @@ +// Registry plugin identification +export const PLUGIN_NAME = 'harbor' + +// Registry configuration keys +export const REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT = 'quotaHardLimit' +export const REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT = 'publishProjectRobot' + +// Default platform-level group paths +export const DEFAULT_PLATFORM_ADMIN_GROUP_PATHS = '/console/admin' +export const DEFAULT_PLATFORM_GUEST_GROUP_PATHS = '/console/security,/console/readonly' + +// Default project-level group path suffixes +export const DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES = '/console/admin' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES = '/console/developer' +export const DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES = '/console/security,/console/readonly' + +// Platform group path plugin configuration keys +export const PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY = 'platformAdminGroupPath' +export const PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY = 'platformGuestGroupPaths' + +// Project group path suffixes plugin configuration keys +export const PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectAdminGroupPathSuffixes' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectMaintainerGroupPathSuffixes' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectDeveloperGroupPathSuffixes' +export const PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectGuestGroupPathSuffixes' + +// Registry robot name identifiers +export const ROBOT_NAME_RO = 'ro-robot' +export const ROBOT_NAME_RW = 'rw-robot' +export const ROBOT_NAME_PROJECT = 'project-robot' + +// Page size used when listing robots. A project only ever owns a handful of +// robots (ro, rw and optionally the project robot — 3 in practice), so a single +// page is plenty; this simply bounds each pagination request. +export const ROBOT_LIST_PAGE_SIZE = 100 + +// Allowed Harbor retention rule templates +export const ALLOWED_RETENTION_RULE_TEMPLATES = [ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +] as const + +// Harbor role identifiers +export const HARBOR_ROLE_PROJECT_ADMIN = 1 +export const HARBOR_ROLE_DEVELOPER = 2 +export const HARBOR_ROLE_GUEST = 3 +export const HARBOR_ROLE_MAINTAINER = 4 +export const HARBOR_ROLE_LIMITED_GUEST = 5 diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.module-definition.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.module-definition.ts new file mode 100644 index 0000000000..c994e3c6cc --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.module-definition.ts @@ -0,0 +1,21 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface RegistryConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + ruleTemplate: string + ruleCount?: number + retentionCron: string + robotRotationThresholdDays: number + projectSlugCacheTtlMs: number + internalOrPublicUrl?: string +} + +export interface RegistryModuleOptions extends RegistryConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('registry') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as REGISTRY_CONFIG } diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.module.ts new file mode 100644 index 0000000000..c93e994bce --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.module.ts @@ -0,0 +1,23 @@ +import { CacheModule } from '@nestjs/cache-manager' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { ConfigurableModuleClass as HarborConfigurableModuleClass } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { RegistryHealthService } from './registry-health.service' +import { RegistryHttpClientService } from './registry-http-client.service' +import { RegistryPluginService } from './registry-plugin.service' +import { ConfigurableModuleClass } from './registry.module-definition' +import { RegistryService } from './registry.service' + +@Module({}) +export class HarborModule extends HarborConfigurableModuleClass {} + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule, HarborModule, CacheModule.register()], + providers: [RegistryHealthService, RegistryPluginService, RegistryService, RegistryDatastoreService, RegistryHttpClientService, RegistryClientService], + exports: [RegistryHealthService, RegistryPluginService, RegistryService], +}) +export class RegistryModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.service.spec.ts new file mode 100644 index 0000000000..94cf3b9d05 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -0,0 +1,322 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { HarborConfig } from './harbor.module-definition' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from '../vault/vault-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { makeCreatedResponse, makeNoContent, makeOkResponse, makeProjectWithDetails } from './registry-testing.utils' +import { + PLUGIN_NAME, + REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, + REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, + ROBOT_NAME_PROJECT, +} from './registry.constants' +import { RegistryService } from './registry.service' + +describe('registryService', () => { + let service: RegistryService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let harborConfig: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + getProjectByName: vi.fn().mockResolvedValue(makeOkResponse({ project_id: 123, metadata: {} })), + listQuotas: vi.fn().mockResolvedValue(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])), + getRetentionId: vi.fn().mockResolvedValue(null), + createRetention: vi.fn().mockResolvedValue(makeCreatedResponse(null)), + getGroupMembers: vi.fn().mockResolvedValue(makeOkResponse([])), + getProjectRobots: vi.fn(async function* () {}), + addGroupMember: vi.fn().mockResolvedValue(makeCreatedResponse(null)), + removeGroupMember: vi.fn().mockResolvedValue(makeNoContent()), + deleteProjectByName: vi.fn().mockResolvedValue(makeNoContent()), + }) + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + read: vi.fn().mockResolvedValue(makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: 'robot$myproj+ro-robot', + TOKEN: 'secret', + }, + })), + write: vi.fn().mockResolvedValue(undefined), + }) + harborConfig = mockDeep({ + url: 'https://harbor.example', + internalUrl: 'https://harbor.example', + admin: 'admin', + adminPassword: faker.internet.password(), + ruleTemplate: 'latestPushedK', + ruleCount: '10', + retentionCron: '0 22 2 * * *', + robotRotationThresholdDays: 90, + }) + baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep({}) + + const module = await Test.createTestingModule({ + providers: [ + RegistryService, + { provide: RegistryClientService, useValue: client }, + { provide: RegistryDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: HARBOR_CONFIG, useValue: harborConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + ], + }).compile() + + service = module.get(RegistryService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('adds expected Harbor group memberships based on defaults', async () => { + const project = makeProjectWithDetails() + + await service.handleUpsert(project) + + const expected = [ + { groupName: `/${project.slug}`, roleId: 5 }, + { groupName: '/console/admin', roleId: 1 }, + { groupName: '/console/readonly', roleId: 3 }, + { groupName: '/console/security', roleId: 3 }, + { groupName: `/${project.slug}/console/readonly`, roleId: 3 }, + { groupName: `/${project.slug}/console/security`, roleId: 3 }, + { groupName: `/${project.slug}/console/developer`, roleId: 3 }, + { groupName: `/${project.slug}/console/devops`, roleId: 3 }, + { groupName: `/${project.slug}/console/admin`, roleId: 2 }, + ] + + expect(client.addGroupMember).toHaveBeenCalledTimes(expected.length) + for (const e of expected) { + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: e.roleId, + member_group: { + group_name: e.groupName, + group_type: 3, + }, + }) + } + }) + + it('reconciles an existing group membership when role differs', async () => { + const project = makeProjectWithDetails() + client.getGroupMembers.mockResolvedValueOnce(makeOkResponse([ + { id: 10, entity_name: `/${project.slug}/console/admin`, entity_type: 'g', role_id: 3 }, + ])) + + await service.handleUpsert(project) + + expect(client.removeGroupMember).toHaveBeenCalledWith(project.slug, 10) + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: 2, + member_group: { + group_name: `/${project.slug}/console/admin`, + group_type: 3, + }, + }) + }) + + it('returns a KO result when project admin membership creation fails', async () => { + const project = makeProjectWithDetails() + client.addGroupMember.mockImplementation(async (_projectName, body) => { + if (body.member_group.group_name === `/${project.slug}/console/admin` && body.role_id === 2) { + return { status: HttpStatus.BAD_REQUEST, data: null } + } + return { status: HttpStatus.CREATED, data: null } + }) + + await expect(service.handleUpsert(project)).resolves.toEqual({ + harbor: expect.objectContaining({ + status: 'KO', + message: expect.stringContaining('Harbor create member failed'), + }), + }) + + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: 2, + member_group: { + group_name: `/${project.slug}/console/admin`, + group_type: 3, + }, + }) + }) + + it('updates quota when it differs', async () => { + client.listQuotas.mockResolvedValueOnce(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])) + + await service.handleUpsert(makeProjectWithDetails({ + slug: 'myproj', + plugins: [ + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1024' }, + ], + })) + + expect(client.updateQuota).toHaveBeenCalledWith(123, 1024) + }) + + it('reuses robot secret when vault secret host matches', async () => { + const project = makeProjectWithDetails() + + await service.handleUpsert(project) + + expect(vault.read).toHaveBeenCalledTimes(2) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/ro-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/rw-robot`) + expect(client.getProjectRobots).not.toHaveBeenCalled() + expect(client.createRobot).not.toHaveBeenCalled() + expect(client.deleteRobot).not.toHaveBeenCalled() + expect(vault.write).not.toHaveBeenCalled() + }) + + it('rotates robot and writes secret when vault secret host differs', async () => { + const project = makeProjectWithDetails() + vault.read.mockImplementation(async (path: string) => { + if (path === `forge/${project.slug}/REGISTRY/ro-robot`) { + return makeVaultSecret({ + data: { + HOST: 'other.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'old', + }, + }) + } + return makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+rw-robot`, + TOKEN: 'secret', + }, + }) + }) + + client.getProjectRobots.mockImplementation(async function* () { + yield { id: 11, name: `robot$${project.slug}+ro-robot` } + }) + client.deleteRobot.mockResolvedValue(makeNoContent()) + client.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) + + await service.handleUpsert(project) + + expect(client.deleteRobot).toHaveBeenCalledWith(11) + expect(client.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + HOST: 'harbor.example', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'newsecret', + }), `forge/${project.slug}/REGISTRY/ro-robot`) + }) + + it('rotates robot and writes secret when vault secret is expiring', async () => { + const project = makeProjectWithDetails() + const old = makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'old', + }, + }) + old.metadata.created_time = new Date(Date.now() - 1000 * 60 * 60 * 24 * 120).toISOString() + + vault.read.mockImplementation(async (path: string) => { + if (path === `forge/${project.slug}/REGISTRY/ro-robot`) return old + return makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+rw-robot`, + TOKEN: 'secret', + }, + }) + }) + + client.getProjectRobots.mockImplementation(async function* () { + yield { id: 11, name: `robot$${project.slug}+ro-robot` } + }) + client.deleteRobot.mockResolvedValue(makeNoContent()) + client.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) + + await service.handleUpsert(project) + + expect(client.deleteRobot).toHaveBeenCalledWith(11) + expect(client.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + HOST: 'harbor.example', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'newsecret', + }), `forge/${project.slug}/REGISTRY/ro-robot`) + }) + + it('parses plugin config and enables project robot publishing', async () => { + const project = makeProjectWithDetails({ + plugins: [ + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1gb' }, + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, value: ENABLED }, + ], + }) + client.getProjectByName.mockResolvedValue(makeOkResponse({ project_id: 1, metadata: {} })) + + await service.handleUpsert(project) + + expect(client.updateQuota).toHaveBeenCalledWith(1, 1024 ** 3) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/ro-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/rw-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/${ROBOT_NAME_PROJECT}`) + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects', async () => { + datastore.getAllProjects.mockResolvedValue([ + makeProjectWithDetails({ slug: 'project-1' }), + makeProjectWithDetails({ slug: 'project-2' }), + ]) + + await service.handleCron() + + expect(client.getGroupMembers).toHaveBeenCalledWith('project-1') + expect(client.getGroupMembers).toHaveBeenCalledWith('project-2') + }) + }) + + describe('handleDelete', () => { + it('should delete project when it exists', async () => { + const project = makeProjectWithDetails() + await service.handleDelete(project) + expect(client.deleteProjectByName).toHaveBeenCalledWith(project.slug) + }) + + it('should not delete project when it does not exist', async () => { + client.getProjectByName.mockResolvedValueOnce({ status: HttpStatus.NOT_FOUND, data: null }) + await service.handleDelete(makeProjectWithDetails()) + expect(client.deleteProjectByName).not.toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.service.ts new file mode 100644 index 0000000000..43dc140455 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -0,0 +1,525 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultSecret } from '../vault/vault-client.service' +import type { HarborConfig } from './harbor.module-definition' +import type { + HarborAccess, + HarborGroupMemberRequest, + HarborMember, + HarborProjectQuota, + HarborRetentionPolicy, + HarborRobotCreateRequest, +} from './registry-client.service' +import type { ProjectWithDetails } from './registry-datastore.service' +import type { RuleTemplate, VaultRobotSecret } from './registry.utils' +import { specificallyEnabled } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { find } from '../../utils/iterable.utils' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService, roAccess, rwAccess } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { + ALLOWED_RETENTION_RULE_TEMPLATES, + DEFAULT_PLATFORM_ADMIN_GROUP_PATHS, + DEFAULT_PLATFORM_GUEST_GROUP_PATHS, + DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES, + HARBOR_ROLE_DEVELOPER, + HARBOR_ROLE_GUEST, + HARBOR_ROLE_LIMITED_GUEST, + HARBOR_ROLE_PROJECT_ADMIN, + PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, + REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, + ROBOT_NAME_PROJECT, + ROBOT_NAME_RO, + ROBOT_NAME_RW, +} from './registry.constants' +import { generateVaultRobotSecret, getHostFromUrl, getProjectVaultPath, parseBytes } from './registry.utils' + +@Injectable() +export class RegistryService { + private readonly logger = new Logger(RegistryService.name) + + constructor( + @Inject(RegistryClientService) private readonly client: RegistryClientService, + @Inject(RegistryDatastoreService) private readonly datastore: RegistryDatastoreService, + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('RegistryService initialized') + } + + private get host() { + return getHostFromUrl(this.harborConfig.url) + } + + private async getRobot(project: ProjectWithDetails, harborProjectId: number, robotName: string) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.robot.name': robotName, + }) + const fullName = generateRobotFullName(project, robotName) + return find(this.client.getProjectRobots(harborProjectId), r => r?.name === fullName) + } + + private async createProjectRobot(project: ProjectWithDetails, robotName: string, access: HarborAccess[]) { + const created = await this.client.createRobot( + generateRobotPermissions(project, robotName, access), + ) + if (created.status >= 300 || !created.data) { + throw new Error(`Harbor create robot failed (${created.status})`) + } + return created.data + } + + private async rotateRobot(project: ProjectWithDetails, harborProjectId: number, robotName: string, access: HarborAccess[]) { + const existing = await this.getRobot(project, harborProjectId, robotName) + if (existing?.id) { + await this.client.deleteRobot(existing.id) + } + return this.createProjectRobot(project, robotName, access) + } + + private async ensureRobotSecret(project: ProjectWithDetails, harborProjectId: number, robotName: string, access: HarborAccess[]) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.robot.name': robotName, + }) + const relativeVaultPath = `REGISTRY/${robotName}` + const vaultPath = getProjectVaultPath(project, this.baseConfig.projectsRootDir, relativeVaultPath) + const vaultRobotSecret = await this.vault.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + + const expiring = vaultRobotSecret + ? this.isRobotSecretExpiring(vaultRobotSecret) + : false + + span?.setAttributes({ + 'vault.secret.exists': !!vaultRobotSecret, + 'registry.robot.secret.expiring': expiring, + }) + + if (vaultRobotSecret?.data?.HOST === this.host && !expiring) { + span?.setAttribute('vault.secret.reused', true) + return vaultRobotSecret.data + } + + const existing = await this.getRobot(project, harborProjectId, robotName) + const created = existing + ? await this.rotateRobot(project, harborProjectId, robotName, access) + : await this.createProjectRobot(project, robotName, access) + const fullName = generateRobotFullName(project, robotName) + const secret = generateVaultRobotSecret(this.host, fullName, created.secret) + await this.vault.write(secret, vaultPath) + span?.setAttribute('vault.secret.written', true) + return secret + } + + private isRobotSecretExpiring(vaultSecret: VaultSecret): boolean { + const createdTimeRaw = vaultSecret?.metadata?.created_time + if (!createdTimeRaw) return false + const createdTime = new Date(createdTimeRaw) + return daysAgoFromNow(createdTime) > this.harborConfig.robotRotationThresholdDays + } + + private async ensureProjectGroupMember( + projectSlug: string, + groupName: string, + accessLevel: number, + membersByName: Map, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'registry.group.name': groupName, + 'registry.group.access_level': accessLevel, + }) + const existing = membersByName.get(groupName) + + if (existing?.id) { + if (existing.role_id !== accessLevel || existing.entity_type !== 'g') { + await this.client.removeGroupMember(projectSlug, Number(existing.id)) + membersByName.delete(groupName) + } else { + span?.setAttribute('registry.member.exists', true) + return + } + } + + const body: HarborGroupMemberRequest = { + role_id: accessLevel, + member_group: { + group_name: groupName, + group_type: 3, + }, + } + const created = await this.client.addGroupMember(projectSlug, body) + if (created.status >= 300) { + throw new Error(`Harbor create member failed (${created.status})`) + } + span?.setAttribute('registry.member.created', true) + } + + private async ensureProjectGroupMembers(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const members = await this.client.getGroupMembers(project.slug) + if (members.status !== 200 || !members.data) { + throw new Error(`Harbor list members failed (${members.status})`) + } + + const membersByName = new Map() + for (const member of members.data) { + const name = member?.entity_name + if (name) membersByName.set(name, member) + } + + const byGroupName = await this.generateAccessLevelMapping(project) + + await Promise.all( + Array.from(byGroupName.entries(), ([groupName, accessLevel]) => this.ensureProjectGroupMember( + project.slug, + groupName, + accessLevel, + membersByName, + )), + ) + } + + private async ensureProjectQuota(project: ProjectWithDetails, storageLimit: number) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.storage_limit.bytes': storageLimit, + }) + const existing = await this.client.getProjectByName(project.slug) + if (existing.status === 200 && existing.data) { + const projectId = Number(existing.data.project_id) + if (!Number.isFinite(projectId)) return existing.data + + const quotas = await this.client.listQuotas(projectId) + if (quotas.status === 200 && quotas.data) { + const hardQuota = quotas.data.find((q: HarborProjectQuota) => q?.ref?.id === projectId) + if (hardQuota?.hard?.storage !== storageLimit) { + await this.client.updateQuota(projectId, storageLimit) + span?.setAttribute('registry.quota.updated', true) + } + } + return existing.data + } + + const created = await this.client.createProject(project.slug, storageLimit) + if (created.status >= 300) { + throw new Error(`Harbor create project failed (${created.status})`) + } + span?.setAttribute('registry.project.created', true) + + const fetched = await this.client.getProjectByName(project.slug) + if (fetched.status !== 200 || !fetched.data) { + throw new Error(`Harbor get project failed (${fetched.status})`) + } + return fetched.data + } + + private async ensureRetentionPolicy(project: ProjectWithDetails, harborProjectId: number) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.project.id': harborProjectId, + }) + const policy = generateRetentionPolicy(harborProjectId, { + harborRuleTemplate: this.harborConfig.ruleTemplate, + harborRuleCount: this.harborConfig.ruleCount, + harborRetentionCron: this.harborConfig.retentionCron, + }) + const retentionId = await this.client.getRetentionId(project.slug) + span?.setAttribute('registry.retention.exists', !!retentionId) + const result = retentionId + ? await this.client.updateRetention(retentionId, policy) + : await this.client.createRetention(policy) + if (result.status >= 300) { + throw new Error(`Harbor retention policy failed (${result.status})`) + } + } + + @StartActiveSpan() + async ensureProject(project: ProjectWithDetails, options: { storageLimitBytes?: number, publishProjectRobot?: boolean } = {}) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.publish_project_robot': !!options.publishProjectRobot, + }) + const storageLimit = options.storageLimitBytes ?? -1 + const harborProject = await this.ensureProjectQuota(project, storageLimit) + const harborProjectId = Number(harborProject.project_id) + if (!Number.isFinite(harborProjectId)) + throw new Error('Unable to retrieve Harbor project_id') + + await Promise.all([ + this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_RO, roAccess), + this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_RW, rwAccess), + this.ensureProjectGroupMembers(project), + this.ensureRetentionPolicy(project, harborProjectId), + options.publishProjectRobot + ? this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_PROJECT, roAccess) + : Promise.resolve(), + ]) + + return { + projectId: harborProjectId, + basePath: `${this.host}/${project.slug}/`, + } + } + + @StartActiveSpan() + async deleteProject(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const existing = await this.client.getProjectByName(projectSlug) + if (existing.status === 404) { + span?.setAttribute('registry.project.exists', false) + return + } + const deleted = await this.client.deleteProjectByName(projectSlug) + if (deleted.status >= 300 && deleted.status !== 404) { + throw new Error(`Harbor delete project failed (${deleted.status})`) + } + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('harbor', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + const quotaConfigRaw = getPluginConfig(project, REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT) + const publishConfig = getPluginConfig(project, REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT) + const parsedQuota = quotaConfigRaw ? parseBytes(String(quotaConfigRaw)) : undefined + const storageLimitBytes = parsedQuota ?? -1 + const publishProjectRobot = specificallyEnabled(publishConfig) + await this.ensureProject(project, { storageLimitBytes, publishProjectRobot }) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('harbor', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.deleteProject(project.slug) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Registry reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('registry.projects.count', projects.length) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string) { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return getPluginConfig(project, key) + } + + private async getPlatformAdminGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_PLATFORM_ADMIN_GROUP_PATHS + return parseGroupPaths(raw) + } + + private async getPlatformGuestGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY) ?? DEFAULT_PLATFORM_GUEST_GROUP_PATHS + return parseGroupPaths(raw) + } + + private async getProjectAdminGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectMaintainerGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectGuestGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async generateAccessLevelMapping(project: ProjectWithDetails) { + const [ + platformAdminGroupPaths, + platformGuestGroupPaths, + projectAdminGroupPaths, + projectMaintainerGroupPaths, + projectDeveloperGroupPaths, + projectGuestGroupPaths, + ] = await Promise.all([ + this.getPlatformAdminGroupPaths(project), + this.getPlatformGuestGroupPaths(project), + this.getProjectAdminGroupPaths(project), + this.getProjectMaintainerGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectGuestGroupPaths(project), + ]) + + const roles = generateHarborAccessLevelMapping({ + guest: projectGuestGroupPaths, + developer: projectDeveloperGroupPaths, + maintainer: projectMaintainerGroupPaths, + admin: projectAdminGroupPaths, + platformAdmin: platformAdminGroupPaths, + platformGuest: platformGuestGroupPaths, + }) + return new Map([ + [`/${project.slug}`, HARBOR_ROLE_LIMITED_GUEST], + ...roles, + ]) + } +} + +function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +function parseGroupPaths(raw: string): string[] { + return raw + .split(',') + .map(path => path.trim()) + .filter(Boolean) +} + +function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string) { + return parseGroupPaths(rawGroupPathSuffixes).map(path => `/${projectSlug}${path}`) +} + +function generateHarborAccessLevelMapping(args: { guest: string[], developer: string[], maintainer: string[], admin: string[], platformAdmin: string[], platformGuest: string[] }) { + const byGroupName = new Map() + for (const groupName of args.guest) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.developer) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.maintainer) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.admin) byGroupName.set(groupName, HARBOR_ROLE_DEVELOPER) + for (const groupName of args.platformAdmin) byGroupName.set(groupName, HARBOR_ROLE_PROJECT_ADMIN) + for (const groupName of args.platformGuest) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + return byGroupName +} + +function daysAgoFromNow(date: Date) { + return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) +} + +function generateRobotFullName(project: ProjectWithDetails, robotName: string) { + return `robot$${project.slug}+${robotName}` +} + +function generateRobotPermissions(project: ProjectWithDetails, robotName: string, access: HarborAccess[]): HarborRobotCreateRequest { + return { + name: robotName, + duration: -1, + description: 'robot for ci builds', + disable: false, + level: 'project', + permissions: [{ + namespace: project.slug, + kind: 'project', + access, + }], + } +} + +function generateRetentionPolicy( + projectId: number, + options: { + harborRuleTemplate?: string + harborRuleCount?: string + harborRetentionCron?: string + }, +): HarborRetentionPolicy { + let template: RuleTemplate = 'latestPushedK' + if (isRuleTemplate(options.harborRuleTemplate)) { + template = options.harborRuleTemplate + } + + const rawCount = Number(options.harborRuleCount) + let count: number + if (Number.isFinite(rawCount) && rawCount > 0) { + count = rawCount + } else if (template === 'always') { + count = 1 + } else { + count = 10 + } + + return { + algorithm: 'or', + scope: { level: 'project', ref: projectId }, + rules: [ + { + disabled: false, + action: 'retain', + template, + params: { [template]: count }, + tag_selectors: [ + { kind: 'doublestar', decoration: 'matches', pattern: '**' }, + ], + scope_selectors: { + repository: [ + { kind: 'doublestar', decoration: 'repoMatches', pattern: '**' }, + ], + }, + }, + ], + trigger: { + kind: 'Schedule', + settings: { cron: options.harborRetentionCron }, + references: [], + }, + } +} + +function isRuleTemplate(value: unknown): value is RuleTemplate { + if (typeof value !== 'string') return false + for (const template of ALLOWED_RETENTION_RULE_TEMPLATES) { + if (template === value) return true + } + return false +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.utils.ts new file mode 100644 index 0000000000..2befbdbd12 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -0,0 +1,86 @@ +import type { ProjectWithDetails } from './registry-datastore.service' +import type { ALLOWED_RETENTION_RULE_TEMPLATES } from './registry.constants' +import { removeTrailingSlash } from '@cpn-console/shared' + +export type RuleTemplate = typeof ALLOWED_RETENTION_RULE_TEMPLATES[number] + +export function createProjectSlugCacheKey(projectId: string) { + return `registry:project-slug:${projectId}` +} + +const protocolPrefixRegex = /^https?:\/\//u +const parseBytesRegex = /^(\d+(?:\.\d+)?)(?:\s*(kb|mb|gb|tb|[kmgtb]))?$/u + +export function getHostFromUrl(url: string) { + return removeTrailingSlash(url).replace(protocolPrefixRegex, '').split('/')[0] +} + +export function encodeBasicAuth(username: string, password: string) { + return Buffer.from(`${username}:${password}`).toString('base64') +} + +export interface VaultRobotSecret { + DOCKER_CONFIG: string + HOST: string + TOKEN: string + USERNAME: string +} + +export function generateVaultRobotSecret(host: string, robotName: string, robotSecret: string): VaultRobotSecret { + const auth = `${robotName}:${robotSecret}` + const b64auth = Buffer.from(auth).toString('base64') + return { + DOCKER_CONFIG: JSON.stringify({ + auths: { + [host]: { + auth: b64auth, + email: '', + }, + }, + }), + HOST: host, + TOKEN: robotSecret, + USERNAME: robotName, + } +} + +export function getProjectVaultPath(project: ProjectWithDetails, projectRootDir: string | undefined, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return projectRootDir + ? `${projectRootDir}/${project.slug}/${normalized}` + : `${project.slug}/${normalized}` +} + +export function parseBytes(input: string | number | undefined) { + if (input === undefined || input === null) return undefined + if (typeof input === 'number' && Number.isFinite(input)) return input + const raw = String(input).trim().toLowerCase() + if (!raw) return undefined + const match = parseBytesRegex.exec(raw) + if (!match) { + return Number.isFinite(Number(raw)) ? Number(raw) : undefined + } + const value = Number(match[1]) + const unit = (match[2] ?? 'b').toLowerCase() + const pow = parseUnit(unit) + return Math.round(value * 1024 ** pow) +} + +function parseUnit(unit: string) { + switch (unit) { + case 'kb': + case 'k': + return 1 + case 'mb': + case 'm': + return 2 + case 'gb': + case 'g': + return 3 + case 'tb': + case 't': + return 4 + default: + return 0 + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts new file mode 100644 index 0000000000..51e59317ee --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts @@ -0,0 +1,199 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Dispatcher, RequestInit } from 'undici' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { OpenCdsConfig } from '../opencds/opencds.module-definition' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { Agent, fetch, Headers, ProxyAgent, Response } from 'undici' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { OPENCDS_CONFIG } from '../opencds/opencds.module-definition' +import { OpenCdsClientError, OpenCdsClientService } from './open-cds-client.service' + +vi.mock('undici', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + Agent: vi.fn(), + fetch: vi.fn(), + ProxyAgent: vi.fn(), + } +}) + +function mockFetchResponse(response: Response): void { + vi.mocked(fetch).mockResolvedValue(response) +} + +function getLastFetchCall(): [string, RequestInit] { + const [url, init] = vi.mocked(fetch).mock.lastCall as [string, RequestInit] + return [url, init] +} + +describe('openCdsClientService', () => { + let module: TestingModule + let service: OpenCdsClientService + let openCdsConfig: Partial + let baseConfig: DeepMockProxy + let tlsDispatcher: Pick + let proxyDispatcher: Pick + + beforeEach(async () => { + vi.clearAllMocks() + vi.unstubAllEnvs() + + tlsDispatcher = { dispatch: vi.fn() } + proxyDispatcher = { dispatch: vi.fn() } + + class MockAgent { + dispatch = tlsDispatcher + }; + + class ProxyMockAgent { + dispatch = proxyDispatcher + }; + + vi.mocked(Agent).mockImplementation(MockAgent as any) + vi.mocked(ProxyAgent).mockImplementation(ProxyMockAgent as any) + + openCdsConfig = { + url: 'https://opencds.example.com/root/api/', + apiToken: 'test-token', + apiTlsRejectUnauthorized: true, + } + baseConfig = mockDeep({ httpProxy: undefined }) + + module = await Test.createTestingModule({ + providers: [ + OpenCdsClientService, + { provide: OPENCDS_CONFIG, useValue: openCdsConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(OpenCdsClientService) + }) + + it('builds GET requests with an Axios-compatible URL, API key header and TLS-aware dispatcher', async () => { + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + const result = await service.get<{ ok: boolean }>('/requests') + + expect(Agent).toHaveBeenCalledWith({ + connect: { + rejectUnauthorized: true, + }, + }) + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests') + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('GET') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(result).toEqual({ ok: true }) + }) + + it('uses ProxyAgent when HTTP_PROXY is configured and preserves TLS settings for the upstream request', async () => { + baseConfig.httpProxy = 'http://proxy.internal:3128' + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + await service.get('/requests') + + expect(ProxyAgent).toHaveBeenCalledWith({ + requestTls: { + rejectUnauthorized: true, + }, + uri: 'http://proxy.internal:3128', + }) + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests') + expect(init.dispatcher?.dispatch).toBe(proxyDispatcher) + expect(init.method).toBe('GET') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + }) + + it('applies query parameters and omits undefined values on GET', async () => { + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + await service.get('/requests', { + query: { + page: 2, + active: true, + search: 'alpha', + ignored: undefined, + }, + }) + + const [url] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests?page=2&active=true&search=alpha') + }) + + it('sends POST without body and without forcing JSON content type', async () => { + mockFetchResponse(new Response(null, { status: HttpStatus.NO_CONTENT })) + + await service.post('/validate/id') + + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/validate/id') + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('POST') + expect(init.signal).toBeUndefined() + expect(init.body).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(new Headers(init.headers).has('Content-Type')).toBe(false) + }) + + it('serializes POST bodies as JSON and sets the content type', async () => { + mockFetchResponse(new Response(null, { status: HttpStatus.NO_CONTENT })) + + await service.post('/validate/id', { + requestId: '123', + enabled: true, + }) + + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/validate/id') + expect(init.body).toBe(JSON.stringify({ + requestId: '123', + enabled: true, + })) + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('POST') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(new Headers(init.headers).get('Content-Type')).toBe('application/json') + }) + + it('throws a dedicated error with HTTP status context for non-OK responses', async () => { + mockFetchResponse(new Response('upstream failure', { + status: HttpStatus.BAD_GATEWAY, + statusText: 'Bad Gateway', + })) + + await expect(service.get('/requests')).rejects.toMatchObject({ + body: 'upstream failure', + message: 'OpenCDS request failed with 502 Bad Gateway', + name: OpenCdsClientError.name, + status: HttpStatus.BAD_GATEWAY, + statusText: 'Bad Gateway', + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts new file mode 100644 index 0000000000..c2760046d2 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts @@ -0,0 +1,129 @@ +import type { HttpStatus } from '@nestjs/common' +import type { Dispatcher, HeadersInit } from 'undici' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { OpenCdsConfig } from '../opencds/opencds.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { Agent, fetch, Headers, ProxyAgent } from 'undici' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { OPENCDS_CONFIG } from '../opencds/opencds.module-definition' +import { throwIfNotOk } from './service-chain.utils' + +const URL_REGEX = /^https?:\/\// +const START_SLASHES_REGEX = /^\/+/ +const END_SLASHES_REGEX = /\/+$/ + +export interface OpenCdsRequestOptions { + headers?: HeadersInit + signal?: AbortSignal + query?: Record +} + +export class OpenCdsClientError extends Error { + constructor( + public readonly status: HttpStatus, + public readonly statusText: string, + public readonly body?: string, + ) { + super(`OpenCDS request failed with ${status} ${statusText}`) + } +} + +@Injectable() +export class OpenCdsClientService { + constructor( + @Inject(OPENCDS_CONFIG) private readonly opencdsConfig: OpenCdsConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + private readonly logger = new Logger(OpenCdsClientService.name) + + async get(path: string, options?: OpenCdsRequestOptions): Promise { + const url = this.buildUrl(path, options?.query) + this.logger.debug(`Retrieving data from URL: ${url}`) + + const headers = this.buildHeaders(options?.headers) + + const response = await fetch(url, { + dispatcher: this.buildDispatcher(), + headers, + method: 'GET', + signal: options?.signal, + }) + + await throwIfNotOk(response) + + return (await response.json()) as T + } + + async post( + path: string, + body?: TBody, + options?: OpenCdsRequestOptions, + ): Promise { + const hasBody = body !== undefined + + const response = await fetch(this.buildUrl(path, options?.query), { + body: hasBody ? JSON.stringify(body) : undefined, + dispatcher: this.buildDispatcher(), + headers: this.buildHeaders(options?.headers, hasBody), + method: 'POST', + signal: options?.signal, + }) + + await throwIfNotOk(response) + } + + private buildUrl( + path: string, + query?: OpenCdsRequestOptions['query'], + ): string { + if (!this.opencdsConfig.url) throw new Error('OpenCDS is disabled') + + const resolvedPath = URL_REGEX.test(path) + ? path + : `${this.opencdsConfig.url.replace(END_SLASHES_REGEX, '')}/${path.replace(START_SLASHES_REGEX, '')}` + + const url = new URL(resolvedPath) + + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined) { + url.searchParams.append(key, String(value)) + } + } + + return url.toString() + } + + private buildHeaders( + headers?: OpenCdsRequestOptions['headers'], + hasJsonBody = false, + ): Headers { + const mergedHeaders = new Headers(headers) + mergedHeaders.set('X-API-Key', this.opencdsConfig.apiToken ?? '') + + if (hasJsonBody) { + mergedHeaders.set('Content-Type', 'application/json') + } + + return mergedHeaders + } + + private buildDispatcher(): Dispatcher { + const httpProxy = this.baseConfig.httpProxy + + if (httpProxy) { + return new ProxyAgent({ + requestTls: { + rejectUnauthorized: this.opencdsConfig.apiTlsRejectUnauthorized, + }, + uri: httpProxy, + }) + } + + return new Agent({ + connect: { + rejectUnauthorized: this.opencdsConfig.apiTlsRejectUnauthorized, + }, + }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts new file mode 100644 index 0000000000..2318edb937 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts @@ -0,0 +1,126 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ServiceChainController } from './service-chain.controller' +import { ServiceChainService } from './service-chain.service' + +describe('serviceChainController', () => { + let module: TestingModule + let controller: ServiceChainController + let service: MockProxy + + const uuid = '550e8400-e29b-41d4-a716-446655440000' + const date = new Date('2026-01-01T00:00:00.000Z') + const serviceChain: ServiceChain = { + id: uuid, + state: 'opened', + commonName: 'test.example.com', + pai: 'test-pai', + network: 'RIE', + createdAt: date, + updatedAt: date, + } + const serviceChainDetails: ServiceChainDetails = { + ...serviceChain, + validationId: uuid, + validatedBy: null, + ref: null, + location: 'SIR', + targetAddress: '10.0.0.1', + projectId: uuid, + env: 'INT', + subjectAlternativeName: [], + redirect: false, + antivirus: null, + websocket: false, + ipWhiteList: [], + sslOutgoing: false, + } + const serviceChainFlows: ServiceChainFlows = { + reserve_ip: { state: 'success', input: {}, output: {}, updatedAt: date }, + create_cert: null, + call_exec: { state: 'success', input: {}, output: {}, updatedAt: date }, + activate_ip: { state: 'success', input: {}, output: {}, updatedAt: date }, + dns_request: { state: 'success', input: {}, output: {}, updatedAt: date }, + } + + beforeEach(async () => { + service = mock() + + module = await Test.createTestingModule({ + controllers: [ServiceChainController], + providers: [ + { provide: ServiceChainService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(ServiceChainController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call service.list()', async () => { + const mockResult = [serviceChain] + service.list.mockResolvedValue(mockResult) + + const result = await controller.list() + + expect(service.list).toHaveBeenCalled() + expect(result).toEqual(mockResult) + }) + }) + + describe('getDetails', () => { + it('should call service.getDetails() with id', async () => { + const mockResult = serviceChainDetails + service.getDetails.mockResolvedValue(mockResult) + + const result = await controller.getDetails(uuid) + + expect(service.getDetails).toHaveBeenCalledWith(uuid) + expect(result).toEqual(mockResult) + }) + }) + + describe('retry', () => { + it('should call service.retry() with id', async () => { + service.retry.mockResolvedValue() + + await controller.retry(uuid) + + expect(service.retry).toHaveBeenCalledWith(uuid) + }) + }) + + describe('validate', () => { + it('should call service.validate() with validationId', async () => { + service.validate.mockResolvedValue() + + await controller.validate(uuid) + + expect(service.validate).toHaveBeenCalledWith(uuid) + }) + }) + + describe('getFlows', () => { + it('should call service.getFlows() with id', async () => { + const mockResult = serviceChainFlows + service.getFlows.mockResolvedValue(mockResult) + + const result = await controller.getFlows(uuid) + + expect(service.getFlows).toHaveBeenCalledWith(uuid) + expect(result).toEqual(mockResult) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts new file mode 100644 index 0000000000..1caea91186 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Inject, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ServiceChainService } from './service-chain.service' + +@Controller('api/v1/service-chains') +export class ServiceChainController { + constructor( + @Inject(ServiceChainService) + private readonly serviceChainService: ServiceChainService, + ) {} + + @Post('validate/:validationId') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async validate(@Param('validationId', ParseUUIDPipe) validationId: string) { + await this.serviceChainService.validate(validationId) + } + + @Get() + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async list() { + return this.serviceChainService.list() + } + + @Get(':serviceChainId') + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async getDetails(@Param('serviceChainId', ParseUUIDPipe) id: string) { + return this.serviceChainService.getDetails(id) + } + + @Post(':serviceChainId/retry') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async retry(@Param('serviceChainId', ParseUUIDPipe) id: string) { + await this.serviceChainService.retry(id) + } + + @Get(':serviceChainId/flows') + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async getFlows(@Param('serviceChainId', ParseUUIDPipe) id: string) { + return this.serviceChainService.getFlows(id) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts new file mode 100644 index 0000000000..52964ab4b2 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { openCdsConfigFactory } from '../../config/opencds.config' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { OpenCdsClientService } from './open-cds-client.service' +import { ServiceChainController } from './service-chain.controller' +import { ServiceChainService } from './service-chain.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ConfigModule.forFeature(openCdsConfigFactory), + ConfigModule.forFeature(baseConfigFactory), + ], + controllers: [ServiceChainController], + providers: [OpenCdsClientService, ServiceChainService], + exports: [ServiceChainService], +}) +export class ServiceChainModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts new file mode 100644 index 0000000000..e92351a0b2 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts @@ -0,0 +1,124 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { OpenCdsClientService } from './open-cds-client.service' +import { ServiceChainService } from './service-chain.service' + +describe('serviceChainService', () => { + let module: TestingModule + let service: ServiceChainService + let openCdsClient: MockProxy + + beforeEach(async () => { + openCdsClient = mock() + + module = await Test.createTestingModule({ + providers: [ + ServiceChainService, + { provide: OpenCdsClientService, useValue: openCdsClient }, + ], + }).compile() + + service = module.get(ServiceChainService) + }) + + const uuid = '550e8400-e29b-41d4-a716-446655440000' + + const mockServiceChain: ServiceChain = { + id: uuid, + state: 'opened', + commonName: 'test.example.com', + pai: 'test-pai', + network: 'RIE', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const mockServiceChainDetails: ServiceChainDetails = { + ...mockServiceChain, + validationId: uuid, + validatedBy: null, + ref: null, + location: 'SIR', + targetAddress: '10.0.0.1', + projectId: uuid, + env: 'INT', + subjectAlternativeName: [], + redirect: false, + antivirus: null, + websocket: false, + ipWhiteList: [], + sslOutgoing: false, + } + const flowDetails: NonNullable = { + state: 'success', + input: {}, + output: {}, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const mockServiceChainFlows: ServiceChainFlows = { + reserve_ip: flowDetails, + create_cert: null, + call_exec: flowDetails, + activate_ip: flowDetails, + dns_request: flowDetails, + } + + describe('list', () => { + it('should call GET /requests and parse response', async () => { + openCdsClient.get.mockResolvedValue([mockServiceChain]) + + const result = await service.list() + + expect(openCdsClient.get).toHaveBeenCalledWith('/requests') + expect(result).toHaveLength(1) + expect(result[0].id).toBe(uuid) + }) + }) + + describe('getDetails', () => { + it('should call GET /requests/:id and parse response', async () => { + openCdsClient.get.mockResolvedValue(mockServiceChainDetails) + + const result = await service.getDetails(uuid) + + expect(openCdsClient.get).toHaveBeenCalledWith(`/requests/${uuid}`) + expect(result.id).toBe(uuid) + expect(result.location).toBe('SIR') + }) + }) + + describe('retry', () => { + it('should call POST /requests/:id/retry', async () => { + openCdsClient.post.mockResolvedValue() + + await service.retry(uuid) + + expect(openCdsClient.post).toHaveBeenCalledWith(`/requests/${uuid}/retry`) + }) + }) + + describe('validate', () => { + it('should call POST /validate/:validationId', async () => { + openCdsClient.post.mockResolvedValue() + + await service.validate(uuid) + + expect(openCdsClient.post).toHaveBeenCalledWith(`/validate/${uuid}`) + }) + }) + + describe('getFlows', () => { + it('should call GET /requests/:id/flows and parse response', async () => { + openCdsClient.get.mockResolvedValue(mockServiceChainFlows) + + const result = await service.getFlows(uuid) + + expect(openCdsClient.get).toHaveBeenCalledWith(`/requests/${uuid}/flows`) + expect(result.reserve_ip.state).toBe('success') + expect(result.create_cert).toBeNull() + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts new file mode 100644 index 0000000000..d40a031fdf --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts @@ -0,0 +1,45 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import { + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainListSchema, +} from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { OpenCdsClientService } from './open-cds-client.service' + +@Injectable() +export class ServiceChainService { + constructor(@Inject(OpenCdsClientService) private readonly openCdsClient: OpenCdsClientService) {} + + @StartActiveSpan() + async list(): Promise { + return ServiceChainListSchema.parse( + await this.openCdsClient.get('/requests'), + ) + } + + @StartActiveSpan() + async getDetails(id: string): Promise { + return ServiceChainDetailsSchema.parse( + await this.openCdsClient.get(`/requests/${id}`), + ) + } + + @StartActiveSpan() + async retry(id: string): Promise { + await this.openCdsClient.post(`/requests/${id}/retry`) + } + + @StartActiveSpan() + async validate(validationId: string): Promise { + await this.openCdsClient.post(`/validate/${validationId}`) + } + + @StartActiveSpan() + async getFlows(id: string): Promise { + return ServiceChainFlowsSchema.parse( + await this.openCdsClient.get(`/requests/${id}/flows`), + ) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts new file mode 100644 index 0000000000..3500ad7ec9 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts @@ -0,0 +1,20 @@ +import type { HttpStatus } from '@nestjs/common' +import type { Response } from 'undici' + +export class OpenCdsClientError extends Error { + constructor( + public readonly status: HttpStatus, + public readonly statusText: string, + public readonly body?: string, + ) { + super(`OpenCDS request failed with ${status} ${statusText}`) + this.name = 'OpenCdsClientError' + } +} + +export async function throwIfNotOk(response: T): Promise { + if (!response.ok) { + const body = await response.text() + throw new OpenCdsClientError(response.status, response.statusText, body) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts new file mode 100644 index 0000000000..26a5d114c4 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts @@ -0,0 +1,203 @@ +import type { AddPermissionGroupParams, CreateUserParams, DeactivateUserParams, RevokeUserTokenParams } from './sonarqube-client.service' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' +import { makeSonarqubeGeneratedToken, makeSonarqubeGroup, makeSonarqubePaging, makeSonarqubeProject, makeSonarqubeUser } from './sonarqube-testing.utils' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +const sonarUrl = 'https://sonarqube.internal' +const sonarToken = 'my-token' +const sonarAuthHeader = `Bearer ${sonarToken}` + +const server = setupServer() + +describe('sonarqubeClientService', () => { + let service: SonarqubeClientService + let config: ReturnType> + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + config = mockDeep({ + apiToken: sonarToken, + internalOrPublicUrl: sonarUrl, + }) + + const module = await Test.createTestingModule({ + providers: [ + SonarqubeClientService, + SonarqubeHttpClientService, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = module.get(SonarqubeClientService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('userGroupsSearch', () => { + it('should GET user_groups/search with auth', async () => { + const group = makeSonarqubeGroup({ name: 'my-group' }) + server.use( + http.get(`${sonarUrl}/api/user_groups/search`, ({ request }) => { + expect(request.headers.get('authorization')).toBe(sonarAuthHeader) + expect(new URL(request.url).searchParams.get('q')).toBe('my-group') + return HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), groups: [group] }) + }), + ) + const result = await service.searchUserGroup({ q: 'my-group' }) + expect(result.groups).toEqual([group]) + }) + }) + + describe('userGroupsCreate', () => { + it('should POST user_groups/create', async () => { + server.use( + http.post(`${sonarUrl}/api/user_groups/create`, ({ request }) => { + expect(new URL(request.url).searchParams.get('name')).toBe('new-group') + return HttpResponse.json({}) + }), + ) + await expect(service.createUserGroup({ name: 'new-group' })).resolves.not.toThrow() + }) + }) + + describe('usersSearch', () => { + it('should GET users/search', async () => { + const user = makeSonarqubeUser({ login: 'my-user' }) + server.use( + http.get(`${sonarUrl}/api/users/search`, () => + HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), users: [user] })), + ) + const result = await service.searchUsers({ q: 'my-user' }) + expect(result.users).toEqual([user]) + }) + }) + + describe('usersCreate', () => { + it('should POST users/create with all params as query string', async () => { + const user = { + email: faker.internet.email(), + local: 'true', + login: faker.internet.username(), + name: faker.internet.username(), + password: faker.internet.password(), + } satisfies CreateUserParams + server.use( + http.post(`${sonarUrl}/api/users/create`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('login')).toBe(user.login) + expect(params.get('email')).toBe(user.email) + expect(params.get('local')).toBe(user.local) + return HttpResponse.json({}) + }), + ) + await service.createUser(user) + }) + }) + + describe('usersDeactivate', () => { + it('should POST users/deactivate with anonymize param', async () => { + const user = { + login: faker.internet.username(), + anonymize: true, + } satisfies DeactivateUserParams + server.use( + http.post(`${sonarUrl}/api/users/deactivate`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('login')).toBe(user.login) + expect(params.get('anonymize')).toBe(String(user.anonymize)) + return HttpResponse.json({}) + }), + ) + await service.deactivateUser(user) + }) + }) + + describe('userTokensRevoke / userTokensGenerate', () => { + it('should POST user_tokens/revoke', async () => { + const token = makeSonarqubeGeneratedToken() + const revoke = { + login: token.login, + name: token.name, + } satisfies RevokeUserTokenParams + server.use( + http.post(`${sonarUrl}/api/user_tokens/revoke`, () => HttpResponse.json({})), + ) + await expect(service.revokeUserToken(revoke)).resolves.not.toThrow() + }) + + it('should POST user_tokens/generate and return the token', async () => { + const generated = makeSonarqubeGeneratedToken() + server.use( + http.post(`${sonarUrl}/api/user_tokens/generate`, () => HttpResponse.json(generated)), + ) + const result = await service.generateUserToken({ login: generated.login, name: generated.name }) + expect(result.token).toBe(generated.token) + }) + }) + + describe('projectsSearch', () => { + it('should GET projects/search', async () => { + const project = makeSonarqubeProject() + server.use( + http.get(`${sonarUrl}/api/projects/search`, () => + HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), components: [project] })), + ) + const result = await service.searchProject({ q: project.name }) + expect(result.components).toEqual([project]) + }) + }) + + describe('projectsDelete', () => { + it('should POST projects/delete with project key as query param', async () => { + const project = makeSonarqubeProject() + server.use( + http.post(`${sonarUrl}/api/projects/delete`, ({ request }) => { + expect(new URL(request.url).searchParams.get('project')).toBe(project.key) + return HttpResponse.json({}) + }), + ) + await service.deleteProject({ project: project.key }) + }) + }) + + describe('permissionsAddGroup', () => { + it('should POST permissions/add_group with global params', async () => { + const group = { + groupName: '/admin', + permission: 'admin', + } satisfies AddPermissionGroupParams + server.use( + http.post(`${sonarUrl}/api/permissions/add_group`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('groupName')).toBe(group.groupName) + expect(params.get('permission')).toBe(group.permission) + expect(params.has('projectKey')).toBe(false) + return HttpResponse.json({}) + }), + ) + await service.addPermissionGroup(group) + }) + + it('should POST permissions/add_group with projectKey for project-scoped call', async () => { + server.use( + http.post(`${sonarUrl}/api/permissions/add_group`, ({ request }) => { + expect(new URL(request.url).searchParams.get('projectKey')).toBe('proj-key') + return HttpResponse.json({}) + }), + ) + await service.addPermissionGroup({ groupName: '/proj', permission: 'scan', projectKey: 'proj-key' }) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts new file mode 100644 index 0000000000..7cc63c64a4 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts @@ -0,0 +1,287 @@ +import type { + SONARQUBE_PROJECT_QUALIFIER_APPLICATION, + SONARQUBE_PROJECT_QUALIFIER_BRANCH, + SONARQUBE_PROJECT_QUALIFIER_DIRECTORY, + SONARQUBE_PROJECT_QUALIFIER_FILE, + SONARQUBE_PROJECT_QUALIFIER_LIBRARY, + SONARQUBE_PROJECT_QUALIFIER_PROJECT, + SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW, + SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST, + SONARQUBE_PROJECT_QUALIFIER_VIEW, +} from './sonarqube.constants' +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' + +export interface SonarqubePaging { + pageIndex: number + pageSize: number + total: number +} + +export interface SonarqubeGroup { + id: string + name: string + description: string + membersCount: number + default: boolean +} + +export interface SonarqubeUser { + login: string + name: string + active: boolean + email: string + groups: string[] + tokensCount: number + local: boolean + externalIdentity: string + externalProvider: string + managed: boolean +} + +export type SonarqubeProjectQualifier + = | typeof SONARQUBE_PROJECT_QUALIFIER_APPLICATION + | typeof SONARQUBE_PROJECT_QUALIFIER_BRANCH + | typeof SONARQUBE_PROJECT_QUALIFIER_DIRECTORY + | typeof SONARQUBE_PROJECT_QUALIFIER_FILE + | typeof SONARQUBE_PROJECT_QUALIFIER_LIBRARY + | typeof SONARQUBE_PROJECT_QUALIFIER_PROJECT + | typeof SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST + | typeof SONARQUBE_PROJECT_QUALIFIER_VIEW + | typeof SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW + +export interface SonarqubeProject { + key: string + name: string + qualifier: SonarqubeProjectQualifier + visibility: 'private' | 'public' + lastAnalysisDate?: string + revision?: string +} + +export interface SonarqubeProjectResult { + projectSlug: string + repository: string + key: string +} + +export interface SonarqubeGeneratedToken { + token: string + login: string + name: string +} + +type BaseParams = Record + +export interface SearchUserGroupParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateUserGroupParams extends BaseParams { + name: string + description?: string +} + +export interface CreatePermissionTemplateParams extends BaseParams { + name: string + description?: string + projectKeyPattern?: string +} + +export interface SearchPermissionTemplatesParams extends BaseParams { + q?: string +} + +export interface SetPermissionDefaultTemplateParams extends BaseParams { + templateName: string + projectKeyPattern?: string +} + +export interface AddPermissionProjectCreatorToTemplateParams extends BaseParams { + templateName: string + permission: string +} + +export interface AddPermissionGroupToTemplateParams extends BaseParams { + groupName: string + templateName: string + permission: string +} + +export interface AddPermissionGroupParams extends BaseParams { + groupName: string + permission: string + projectKey?: string +} + +export interface AddPermissionUserParams extends BaseParams { + projectKey: string + permission: string + login: string +} + +export interface SearchUsersParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateUserParams extends BaseParams { + email: string + local: string + login: string + name: string + password: string +} + +export interface DeactivateUserParams extends BaseParams { + login: string + anonymize: boolean +} + +export interface RevokeUserTokenParams extends BaseParams { + login: string + name: string +} + +export interface GenerateUserTokenParams extends BaseParams { + login: string + name: string +} + +export interface SearchProjectParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateProjectParams extends BaseParams { + project: string + visibility: string + name: string + mainbranch: string +} + +export interface DeleteProjectParams extends BaseParams { + project: string +} + +export interface SearchUserGroupResponse { + paging: SonarqubePaging + groups: SonarqubeGroup[] +} + +export interface SonarqubePermissionTemplate { + id: string + name: string + description?: string +} + +export interface SearchPermissionTemplatesResponse { + permissionTemplates: SonarqubePermissionTemplate[] +} + +export interface SearchUsersResponse { + paging: SonarqubePaging + users: SonarqubeUser[] +} + +export interface SearchProjectResponse { + paging: SonarqubePaging + components: SonarqubeProject[] +} + +@Injectable() +export class SonarqubeClientService { + constructor( + @Inject(SonarqubeHttpClientService) private readonly http: SonarqubeHttpClientService, + ) {} + + @StartActiveSpan() + searchUserGroup(params: SearchUserGroupParams) { + return this.http.fetch('user_groups/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createUserGroup(params: CreateUserGroupParams) { + await this.http.fetch('user_groups/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async createPermissionTemplate(params: CreatePermissionTemplateParams) { + await this.http.fetch('permissions/create_template', { method: 'POST', params }) + } + + @StartActiveSpan() + searchPermissionTemplates(params: SearchPermissionTemplatesParams) { + return this.http.fetch('permissions/search_templates', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async setPermissionDefaultTemplate(params: SetPermissionDefaultTemplateParams) { + await this.http.fetch('permissions/set_default_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionProjectCreatorToTemplate(params: AddPermissionProjectCreatorToTemplateParams) { + await this.http.fetch('permissions/add_project_creator_to_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionGroupToTemplate(params: AddPermissionGroupToTemplateParams) { + await this.http.fetch('permissions/add_group_to_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionGroup(params: AddPermissionGroupParams) { + await this.http.fetch('permissions/add_group', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionUser(params: AddPermissionUserParams) { + await this.http.fetch('permissions/add_user', { method: 'POST', params }) + } + + @StartActiveSpan() + searchUsers(params: SearchUsersParams) { + return this.http.fetch('users/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createUser(params: CreateUserParams) { + await this.http.fetch('users/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async deactivateUser(params: DeactivateUserParams) { + await this.http.fetch('users/deactivate', { method: 'POST', params }) + } + + @StartActiveSpan() + async revokeUserToken(params: RevokeUserTokenParams) { + await this.http.fetch('user_tokens/revoke', { method: 'POST', params }) + } + + @StartActiveSpan() + generateUserToken(params: GenerateUserTokenParams) { + return this.http.fetch('user_tokens/generate', { method: 'POST', params }).then(res => res.data!) + } + + @StartActiveSpan() + searchProject(params: SearchProjectParams) { + return this.http.fetch('projects/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createProject(params: CreateProjectParams) { + await this.http.fetch('projects/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async deleteProject(params: DeleteProjectParams) { + await this.http.fetch('projects/delete', { method: 'POST', params }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts new file mode 100644 index 0000000000..bcb3143972 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts @@ -0,0 +1,68 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './sonarqube.constants' + +export const projectSelect = { + id: true, + slug: true, + repositories: { + select: { + internalRepoName: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class SonarqubeDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig( + pluginName: string, + key: string, + ): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts new file mode 100644 index 0000000000..8f77620a8c --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -0,0 +1,26 @@ +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +@Injectable() +export class SonarqubeHealthService { + constructor( + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const bearerToken = Buffer.from(`${this.sonarqubeConfig.apiToken}:`, 'utf-8').toString('base64') + headers.Authorization = `Bearer ${bearerToken}` + try { + const response = await fetch(this.sonarqubeConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts new file mode 100644 index 0000000000..6ee07f8585 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts @@ -0,0 +1,103 @@ +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +export interface SonarqubeFetchOptions { + method?: string + params?: Record +} + +export interface SonarqubeResponse { + status: HttpStatus + data: T | null +} + +export type SonarqubeErrorKind = 'NotConfigured' | 'ClientError' | 'ServerError' | 'Unexpected' + +export class SonarqubeError extends Error { + readonly kind: SonarqubeErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + + constructor( + kind: SonarqubeErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string } = {}, + ) { + super(message) + this.name = 'SonarqubeError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + } +} + +@Injectable() +export class SonarqubeHttpClientService { + constructor( + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + ) {} + + private get apiBaseUrl(): string { + return new URL('api/', this.sonarqubeConfig.internalOrPublicUrl).toString() + } + + private get defaultHeaders(): Record { + if (!this.sonarqubeConfig.apiToken) throw new SonarqubeError('NotConfigured', 'SONAR_API_TOKEN is required') + return { + Authorization: `Bearer ${this.sonarqubeConfig.apiToken}`, + } + } + + async fetch(path: string, options: SonarqubeFetchOptions = {}): Promise> { + const span = trace.getActiveSpan() + const method = (options.method ?? 'GET').toUpperCase() + span?.setAttribute('sonarqube.method', method) + span?.setAttribute('sonarqube.path', path) + + const request = this.createRequest(path, method, options.params) + const response = await fetch(request).catch((error) => { + throw new SonarqubeError('Unexpected', error instanceof Error ? error.message : String(error), { method, path }) + }) + + span?.setAttribute('sonarqube.http.status', response.status) + const result = await handleResponse(response) + if (response.status >= 400) { + const kind = response.status >= 500 ? 'ServerError' : 'ClientError' + throw new SonarqubeError(kind, formatErrorMessage(response.status, result.data), { status: response.status, method, path }) + } + return result + } + + private createRequest(path: string, method: string, params?: Record): Request { + const url = new URL(path, this.apiBaseUrl) + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) url.searchParams.append(key, String(value)) + } + } + return new Request(url.toString(), { method, headers: this.defaultHeaders }) + } +} + +function formatErrorMessage(status: number, data: unknown): string { + const errors = (data as { errors?: { msg?: unknown }[] } | null)?.errors + const details = Array.isArray(errors) + ? errors.map(e => e?.msg).filter((msg): msg is string => typeof msg === 'string').join('; ') + : '' + return details + ? `SonarQube API responded with status ${status}: ${details}` + : `SonarQube API responded with status ${status}` +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts new file mode 100644 index 0000000000..671a19562a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { SonarqubePluginService } from './sonarqube-plugin.service' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +describe('sonarqubePluginService', () => { + let service: SonarqubePluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://sonar.public/', + internalUrl: 'https://sonar.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + SonarqubePluginService, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(SonarqubePluginService) + }) + + it('returns the public SonarQube url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://sonar.public/projects') + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts new file mode 100644 index 0000000000..d1ffd9387e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts @@ -0,0 +1,22 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Inject, Injectable } from '@nestjs/common' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +@Injectable() +export class SonarqubePluginService { + constructor( + @Inject(SONARQUBE_CONFIG) + private readonly sonarqubeConfig: SonarqubeConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'sonarqube', + to: () => new URL('projects', this.sonarqubeConfig.url).toString(), + title: 'SonarQube', + imgSrc: '/img/sonarqube.svg', + description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts new file mode 100644 index 0000000000..0b7e8c0bbc --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts @@ -0,0 +1,90 @@ +import type { SonarqubeGeneratedToken, SonarqubeGroup, SonarqubePaging, SonarqubeProject, SonarqubeUser } from './sonarqube-client.service' +import type { ProjectWithDetails } from './sonarqube-datastore.service' +import { faker } from '@faker-js/faker' +import { SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' + +export function makeUserToken(overrides: Partial = {}) { + return { + token: faker.string.uuid(), + login: faker.internet.username(), + name: faker.person.fullName(), + ...overrides, + } satisfies SonarqubeGeneratedToken +} + +export function makeEmptyGroupsResponse() { + return { paging: makeSonarqubePaging(), groups: [] } +} + +export function makeEmptyUsersResponse() { + return { paging: makeSonarqubePaging(), users: [] } +} + +export function makeEmptyProjectsResponse() { + return { paging: makeSonarqubePaging(), components: [] } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.internet.domainWord(), + repositories: [], + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + +export function makeSonarqubeGroup(overrides: Partial = {}): SonarqubeGroup { + return { + id: faker.string.uuid(), + name: faker.internet.domainWord(), + description: '', + membersCount: 0, + default: false, + ...overrides, + } satisfies SonarqubeGroup +} + +export function makeSonarqubeUser(overrides: Partial = {}): SonarqubeUser { + return { + login: faker.internet.username(), + name: faker.person.fullName(), + active: true, + email: faker.internet.email(), + groups: [], + tokensCount: 0, + local: true, + externalIdentity: '', + externalProvider: '', + managed: false, + ...overrides, + } satisfies SonarqubeUser +} + +export function makeSonarqubeProject(overrides: Partial = {}): SonarqubeProject { + return { + key: faker.string.alphanumeric(20), + name: faker.internet.domainWord(), + qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, + visibility: 'private', + ...overrides, + } satisfies SonarqubeProject +} + +export function makeSonarqubePaging(overrides: Partial = {}): SonarqubePaging { + return { + pageIndex: 1, + pageSize: 100, + total: 0, + ...overrides, + } satisfies SonarqubePaging +} + +export function makeSonarqubeGeneratedToken(overrides: Partial = {}): SonarqubeGeneratedToken { + return { + token: faker.string.alphanumeric(40), + login: faker.internet.username(), + name: `Sonar Token for ${faker.internet.username()}`, + ...overrides, + } satisfies SonarqubeGeneratedToken +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts new file mode 100644 index 0000000000..9618e9341e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts @@ -0,0 +1,51 @@ +export const PLUGIN_NAME = 'sonarqube' +export const DEFAULT_PERMISSION_TEMPLATE_NAME = 'Forge Default' + +// SonarQube global permission names +export const GLOBAL_ADMIN_PERMISSIONS = ['admin', 'profileadmin', 'gateadmin', 'scan', 'provisioning'] as const + +// Permission template — grants to project creator and sonar-administrators on new projects +export const DEFAULT_TEMPLATE_PERMISSIONS = ['admin', 'codeviewer', 'issueadmin', 'securityhotspotadmin', 'scan', 'user'] as const + +// Project-level permission sets per role (SonarQube permission API names) +export const PROJECT_ADMIN_PERMISSIONS = ['admin', 'scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_DEVOPS_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_DEVELOPER_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_SECURITY_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_READONLY_PERMISSIONS = ['user', 'codeviewer'] as const + +// CI robot/service account — needs Execute Analysis + Browse + See Source Code +export const ROBOT_PROJECT_PERMISSIONS = ['scan', 'user', 'codeviewer'] as const + +// Default platform-wide Keycloak group paths +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_READONLY_GROUP_PATH = '/console/readonly' +export const DEFAULT_SECURITY_GROUP_PATH = '/console/security' + +// Default project role group path suffixes (appended to /{projectSlug}) +export const DEFAULT_PROJECT_ADMIN_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVOPS_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_SECURITY_SUFFIX = '/console/security' +export const DEFAULT_PROJECT_READONLY_SUFFIX = '/console/readonly' + +// Admin plugin config keys for overriding defaults +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const READONLY_GROUP_PATH_PLUGIN_KEY = 'readonlyGroupPath' +export const SECURITY_GROUP_PATH_PLUGIN_KEY = 'securityGroupPath' +export const PROJECT_ADMIN_SUFFIX_PLUGIN_KEY = 'projectAdminSuffix' +export const PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY = 'projectDevopsSuffix' +export const PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY = 'projectDeveloperSuffix' +export const PROJECT_SECURITY_SUFFIX_PLUGIN_KEY = 'projectSecuritySuffix' +export const PROJECT_READONLY_SUFFIX_PLUGIN_KEY = 'projectReadonlySuffix' + +// SonarQube project qualifier identifiers +export const SONARQUBE_PROJECT_QUALIFIER_APPLICATION = 'APP' +export const SONARQUBE_PROJECT_QUALIFIER_BRANCH = 'BRC' +export const SONARQUBE_PROJECT_QUALIFIER_DIRECTORY = 'DIR' +export const SONARQUBE_PROJECT_QUALIFIER_FILE = 'FIL' +export const SONARQUBE_PROJECT_QUALIFIER_LIBRARY = 'LIB' +export const SONARQUBE_PROJECT_QUALIFIER_PROJECT = 'TRK' +export const SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW = 'SVW' +export const SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST = 'UTS' +export const SONARQUBE_PROJECT_QUALIFIER_VIEW = 'VW' diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts new file mode 100644 index 0000000000..ef4dac617c --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts @@ -0,0 +1,16 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface SonarqubeConfig { + url: string + internalUrl: string + apiToken: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface SonarqubeModuleOptions extends SonarqubeConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('sonarqube') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as SONARQUBE_CONFIG } diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts new file mode 100644 index 0000000000..f054302ff4 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { SonarqubeHealthService } from './sonarqube-health.service' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' +import { SonarqubePluginService } from './sonarqube-plugin.service' +import { ConfigurableModuleClass } from './sonarqube.module-definition' +import { SonarqubeService } from './sonarqube.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule], + providers: [ + SonarqubeHealthService, + SonarqubeHttpClientService, + SonarqubeClientService, + SonarqubeDatastoreService, + SonarqubePluginService, + SonarqubeService, + ], + exports: [SonarqubeClientService, SonarqubeHealthService, SonarqubePluginService, SonarqubeService], +}) +export class SonarqubeModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts new file mode 100644 index 0000000000..e7d0ef90e1 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -0,0 +1,291 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { generateProjectKey } from '../../utils/crypto.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { + makeEmptyGroupsResponse, + makeEmptyProjectsResponse, + makeEmptyUsersResponse, + makeProjectWithDetails, + makeSonarqubePaging, + makeSonarqubeUser, + makeUserToken, +} from './sonarqube-testing.utils' +import { PLUGIN_NAME, SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' +import { SonarqubeService } from './sonarqube.service' + +describe('sonarqubeService', () => { + let service: SonarqubeService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let config: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + searchUserGroup: vi.fn().mockResolvedValue(makeEmptyGroupsResponse()), + createUserGroup: vi.fn().mockResolvedValue(undefined), + createPermissionTemplate: vi.fn().mockResolvedValue(undefined), + searchPermissionTemplates: vi.fn().mockResolvedValue({ permissionTemplates: [] }), + setPermissionDefaultTemplate: vi.fn().mockResolvedValue(undefined), + addPermissionGroupToTemplate: vi.fn().mockResolvedValue(undefined), + addPermissionGroup: vi.fn().mockResolvedValue(undefined), + addPermissionUser: vi.fn().mockResolvedValue(undefined), + searchUsers: vi.fn().mockResolvedValue(makeEmptyUsersResponse()), + createUser: vi.fn().mockResolvedValue(undefined), + deactivateUser: vi.fn().mockResolvedValue(undefined), + revokeUserToken: vi.fn().mockResolvedValue(undefined), + searchProject: vi.fn().mockResolvedValue(makeEmptyProjectsResponse()), + createProject: vi.fn().mockResolvedValue(undefined), + deleteProject: vi.fn().mockResolvedValue(undefined), + }) + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + readSonarqubeUser: vi.fn().mockResolvedValue(null), + writeSonarqubeUser: vi.fn().mockResolvedValue(undefined), + deleteSonarqubeUser: vi.fn().mockResolvedValue(undefined), + }) + config = mockDeep({ + internalOrPublicUrl: 'https://sonarqube.internal', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + SonarqubeService, + { provide: SonarqubeClientService, useValue: client }, + { provide: SonarqubeDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(SonarqubeService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('init', () => { + it('should set up the permission template', async () => { + await service.init() + expect(client.createPermissionTemplate).toHaveBeenCalledWith({ name: 'Forge Default' }) + expect(client.setPermissionDefaultTemplate).toHaveBeenCalledWith({ templateName: 'Forge Default' }) + }) + + it('should not recreate the permission template when it already exists', async () => { + client.searchPermissionTemplates.mockResolvedValue({ + permissionTemplates: [{ id: '1', name: 'Forge Default' }], + }) + await service.init() + expect(client.createPermissionTemplate).not.toHaveBeenCalled() + expect(client.setPermissionDefaultTemplate).toHaveBeenCalledWith({ templateName: 'Forge Default' }) + }) + + it('should create /console/admin group with global permissions when it does not exist', async () => { + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/admin' })) + }) + + it('should create /console/readonly and /console/security platform groups', async () => { + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/readonly' })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/security' })) + }) + + it('should not create groups that already exist', async () => { + client.searchUserGroup.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 1 }), + groups: [{ id: '1', name: '/console/admin', description: '', membersCount: 1, default: false }], + }) + await service.init() + expect(client.createUserGroup).not.toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) + }) + + it('should use custom group paths from admin plugin config', async () => { + datastore.getAdminPluginConfig.mockImplementation((_plugin, key) => { + if (key === 'adminGroupPath') return Promise.resolve('/custom/admin') + return Promise.resolve(null) + }) + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/custom/admin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/custom/admin' })) + }) + }) + + describe('handleUpsert', () => { + it('should create the 5 project role groups in SonarQube', async () => { + const project = makeProjectWithDetails() + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/admin` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/devops` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/developer` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/security` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/readonly` })) + }) + + it('should create a new user and write vault credentials', async () => { + const project = makeProjectWithDetails() + const userToken = makeUserToken({ login: project.slug }) + client.generateUserToken.mockResolvedValue(userToken) + + await service.handleUpsert(project) + + expect(client.createUser).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(client.generateUserToken).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(vault.writeSonarqubeUser).toHaveBeenCalledWith(project.slug, expect.objectContaining({ SONAR_USERNAME: project.slug, SONAR_TOKEN: userToken.token })) + }) + + it('should set role-based permissions on new repositories', async () => { + const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'repo' }] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.createProject).toHaveBeenCalledWith(expect.objectContaining({ visibility: 'private', name: `${project.slug}-repo` })) + expect(client.addPermissionUser).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/admin` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/devops` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/readonly' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/security' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer`, permission: 'issueadmin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer`, permission: 'securityhotspotadmin' })) + }) + + it('should not recreate user or write vault when both user and secret exist', async () => { + const project = makeProjectWithDetails({ slug: 'existing', repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.createUser).not.toHaveBeenCalled() + expect(client.generateUserToken).not.toHaveBeenCalled() + expect(vault.writeSonarqubeUser).not.toHaveBeenCalled() + }) + + it('should rotate token when user exists but vault secret is missing', async () => { + const project = makeProjectWithDetails({ repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + + await service.handleUpsert(project) + + expect(client.createUser).not.toHaveBeenCalled() + expect(client.generateUserToken).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(vault.writeSonarqubeUser).toHaveBeenCalledWith(project.slug, expect.objectContaining({ SONAR_PASSWORD: 'not initialized' })) + }) + + it('should delete sonarqube projects for removed repositories', async () => { + const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'kept' }] }) + const keptKey = generateProjectKey(project.slug, 'kept') + const removedKey = generateProjectKey(project.slug, 'removed') + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 2 }), + components: [ + { key: keptKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + { key: removedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + ], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.deleteProject).toHaveBeenCalledWith({ project: removedKey }) + expect(client.deleteProject).not.toHaveBeenCalledWith({ project: keptKey }) + }) + + it('should not delete sonarqube projects whose key was not generated by the console', async () => { + const project = makeProjectWithDetails({ slug: 'my', repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 2 }), + components: [ + // manually created project, hash suffix does not match generateProjectKey + { key: 'my-manual-project', name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + // belongs to project "my-app" (repo "x"), not to project "my" + { key: generateProjectKey('my-app', 'x'), name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + ], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.deleteProject).not.toHaveBeenCalled() + }) + + it('should use comma-separated group path suffixes from project plugin config', async () => { + const project = makeProjectWithDetails({ + repositories: [{ internalRepoName: 'repo' }], + plugins: [{ pluginName: PLUGIN_NAME, key: 'projectAdminSuffix', value: '/console/admin,/console/owner' }], + }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/admin` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/owner` })) + }) + }) + + describe('handleDelete', () => { + it('should delete sonarqube projects, anonymize user and remove vault entry', async () => { + const project = makeProjectWithDetails({ slug: 'doomed' }) + const doomedKey = generateProjectKey('doomed', 'repo') + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 1 }), + components: [{ key: doomedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: 'doomed' })] }) + + await service.handleDelete(project) + + expect(client.deleteProject).toHaveBeenCalledWith({ project: doomedKey }) + expect(client.deactivateUser).toHaveBeenCalledWith({ login: 'doomed', anonymize: true }) + expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('doomed') + }) + + it('should skip anonymization when the user does not exist', async () => { + const project = makeProjectWithDetails({ slug: 'no-user' }) + + await service.handleDelete(project) + + expect(client.deactivateUser).not.toHaveBeenCalled() + expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('no-user') + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects and run init', async () => { + const projects = [ + makeProjectWithDetails({ repositories: [] }), + makeProjectWithDetails({ repositories: [] }), + ] + datastore.getAllProjects.mockResolvedValue(projects) + client.generateUserToken.mockImplementation(({ login }) => Promise.resolve(makeUserToken({ login }))) + + await service.handleCron() + + expect(client.searchProject).toHaveBeenCalledTimes(2) + expect(client.createPermissionTemplate).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts new file mode 100644 index 0000000000..a4500dcca9 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -0,0 +1,456 @@ +import type { OnModuleInit } from '@nestjs/common' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { SonarqubeUserSecret } from '../vault/vault-client.service' +import type { SonarqubeProjectResult, SonarqubeUser } from './sonarqube-client.service' +import type { ProjectWithDetails } from './sonarqube-datastore.service' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { generateProjectKey, generateRandomPassword } from '../../utils/crypto.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_PERMISSION_TEMPLATE_NAME, + DEFAULT_PROJECT_ADMIN_SUFFIX, + DEFAULT_PROJECT_DEVELOPER_SUFFIX, + DEFAULT_PROJECT_DEVOPS_SUFFIX, + DEFAULT_PROJECT_READONLY_SUFFIX, + DEFAULT_PROJECT_SECURITY_SUFFIX, + DEFAULT_READONLY_GROUP_PATH, + DEFAULT_SECURITY_GROUP_PATH, + DEFAULT_TEMPLATE_PERMISSIONS, + GLOBAL_ADMIN_PERMISSIONS, + PLUGIN_NAME, + PROJECT_ADMIN_PERMISSIONS, + PROJECT_ADMIN_SUFFIX_PLUGIN_KEY, + PROJECT_DEVELOPER_PERMISSIONS, + PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_PERMISSIONS, + PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY, + PROJECT_READONLY_PERMISSIONS, + PROJECT_READONLY_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_PERMISSIONS, + PROJECT_SECURITY_SUFFIX_PLUGIN_KEY, + READONLY_GROUP_PATH_PLUGIN_KEY, + ROBOT_PROJECT_PERMISSIONS, + SECURITY_GROUP_PATH_PLUGIN_KEY, +} from './sonarqube.constants' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +interface SonarqubeRolePaths { + admin: string[] + devops: string[] + developer: string[] + security: string[] + readonly: string[] +} + +@Injectable() +export class SonarqubeService implements OnModuleInit { + private readonly logger = new Logger(SonarqubeService.name) + + constructor( + @Inject(SonarqubeDatastoreService) private readonly datastore: SonarqubeDatastoreService, + @Inject(SonarqubeClientService) private readonly client: SonarqubeClientService, + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('SonarqubeService initialized') + } + + async onModuleInit() { + await this.init().catch(error => this.logger.error({ error }, 'SonarQube initialization failed')) + } + + @StartActiveSpan() + async init(): Promise { + this.logger.log('Initializing SonarQube platform configuration') + const adminGroupPath = await this.getAdminGroupPath() + const [readonlyGroupPath, securityGroupPath] = await Promise.all([ + this.getReadonlyGroupPath(), + this.getSecurityGroupPath(), + ]) + await this.ensureDefaultPermissionTemplate() + await Promise.all([ + this.ensureGroupWithGlobalPermissions(adminGroupPath, GLOBAL_ADMIN_PERMISSIONS), + this.ensureGroup(readonlyGroupPath), + this.ensureGroup(securityGroupPath), + ]) + this.logger.log('SonarQube platform configuration initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('sonarqube', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`SonarQube sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('sonarqube', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.deleteProjectGroup(project) + this.logger.log(`SonarQube deletion completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting SonarQube reconciliation') + await this.init().catch(error => this.logger.error('SonarQube init during cron failed', error)) + const projects = await this.datastore.getAllProjects() + span?.setAttribute('sonarqube.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for SonarQube reconciliation`) + await this.ensureProjectGroups(projects) + this.logger.log(`SonarQube reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('sonarqube.projects.count', projects.length) + await Promise.all(projects.map(p => + this.ensureProjectGroup(p).catch(error => + this.logger.error(`Failed to reconcile SonarQube project (slug=${p.slug})`, error), + ), + )) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const rolePaths = await this.getProjectRoleGroupPaths(project) + await Promise.all([ + this.ensureUser(project.slug, project.slug), + this.ensureProjectSonarGroups(rolePaths), + this.ensureProjectRepositories(project, rolePaths), + ]) + } + + @StartActiveSpan() + private async deleteProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const sonarProjects = await this.findProjectsForSlug(project.slug) + span?.setAttribute('sonarqube.projects.count', sonarProjects.length) + this.logger.log(`Deleting ${sonarProjects.length} SonarQube repositories for project ${project.slug}`) + + await Promise.all(sonarProjects.map(async (sp) => { + await this.client.deleteProject({ project: sp.key }) + this.logger.verbose(`Deleted SonarQube repository (key=${sp.key})`) + })) + + const user = await this.findUser(project.slug) + if (user) { + await this.client.deactivateUser({ login: project.slug, anonymize: true }) + this.logger.log(`Anonymized SonarQube user (login=${project.slug})`) + } else { + this.logger.verbose(`SonarQube user not found, skipping anonymization (login=${project.slug})`) + } + + await this.vault.deleteSonarqubeUser(project.slug) + this.logger.verbose(`Deleted SonarQube vault credentials (slug=${project.slug})`) + } + + @StartActiveSpan() + private async ensureDefaultPermissionTemplate(): Promise { + this.logger.verbose(`Ensuring SonarQube permission template (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + const { permissionTemplates } = await this.client.searchPermissionTemplates({ q: DEFAULT_PERMISSION_TEMPLATE_NAME }) + + if (permissionTemplates.some(t => t.name.toLowerCase() === DEFAULT_PERMISSION_TEMPLATE_NAME.toLowerCase())) { + this.logger.verbose(`SonarQube permission template already exists (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } else { + await this.client.createPermissionTemplate({ name: DEFAULT_PERMISSION_TEMPLATE_NAME }) + this.logger.log(`Created SonarQube permission template (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } + await Promise.all(DEFAULT_TEMPLATE_PERMISSIONS.map(permission => + this.client.addPermissionProjectCreatorToTemplate({ templateName: DEFAULT_PERMISSION_TEMPLATE_NAME, permission }), + )) + await Promise.all(DEFAULT_TEMPLATE_PERMISSIONS.map(permission => + this.client.addPermissionGroupToTemplate({ groupName: 'sonar-administrators', templateName: DEFAULT_PERMISSION_TEMPLATE_NAME, permission }), + )) + await this.client.setPermissionDefaultTemplate({ templateName: DEFAULT_PERMISSION_TEMPLATE_NAME }) + this.logger.log(`SonarQube permission template ensured (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } + + @StartActiveSpan() + private async ensureUser(username: string, projectSlug: string): Promise { + const existingSecret = await this.vault.readSonarqubeUser(projectSlug) + const user = await this.findUser(username) + let newSecret: SonarqubeUserSecret | undefined + + if (!user) { + this.logger.log(`Creating SonarQube user (login=${username})`) + const password = generateRandomPassword(30) + await this.client.createUser({ email: `${projectSlug}@${projectSlug}`, local: 'true', login: username, name: username, password }) + const token = await this.rotateToken(username) + newSecret = { SONAR_USERNAME: username, SONAR_PASSWORD: password, SONAR_TOKEN: token } + } else if (existingSecret) { + this.logger.verbose(`SonarQube user already exists with vault credentials (login=${username})`) + } else { + this.logger.warn(`SonarQube user exists but vault secret is missing, rotating token (login=${username})`) + const token = await this.rotateToken(username) + newSecret = { SONAR_USERNAME: username, SONAR_PASSWORD: 'not initialized', SONAR_TOKEN: token } + } + + if (newSecret) { + await this.vault.writeSonarqubeUser(projectSlug, newSecret) + this.logger.log(`Stored SonarQube credentials in vault (slug=${projectSlug})`) + } + } + + private async ensureProjectSonarGroups(rolePaths: SonarqubeRolePaths): Promise { + const allGroups = [ + ...rolePaths.admin, + ...rolePaths.devops, + ...rolePaths.developer, + ...rolePaths.security, + ...rolePaths.readonly, + ] + await Promise.all(allGroups.map(group => this.ensureGroup(group))) + } + + @StartActiveSpan() + private async ensureProjectRepositories(project: ProjectWithDetails, rolePaths: SonarqubeRolePaths): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('repositories.count', project.repositories.length) + + const [readonlyGroupPath, securityGroupPath, existingSonarProjects] = await Promise.all([ + this.getReadonlyGroupPath(), + this.getSecurityGroupPath(), + this.findProjectsForSlug(project.slug), + ]) + + const orphans = existingSonarProjects.filter(sp => !project.repositories.some(r => r.internalRepoName === sp.repository)) + if (orphans.length) this.logger.log(`Removing ${orphans.length} orphan SonarQube repositories for project ${project.slug}`) + + await Promise.all([ + ...orphans.map(async (sp) => { + await this.client.deleteProject({ project: sp.key }) + this.logger.verbose(`Deleted orphan SonarQube repository (key=${sp.key})`) + }), + ...project.repositories.map(async (repository) => { + const projectKey = generateProjectKey(project.slug, repository.internalRepoName) + if (!existingSonarProjects.some(sp => sp.repository === repository.internalRepoName)) { + await this.client.createProject({ + project: projectKey, + visibility: 'private', + name: `${project.slug}-${repository.internalRepoName}`, + mainbranch: 'main', + }) + this.logger.log(`Created SonarQube repository (key=${projectKey})`) + } + await this.ensureProjectPermissions(projectKey, project.slug, rolePaths, readonlyGroupPath, securityGroupPath) + this.logger.verbose(`Ensured permissions on SonarQube repository (key=${projectKey})`) + }), + ]) + } + + private async ensureProjectPermissions( + projectKey: string, + login: string, + rolePaths: SonarqubeRolePaths, + readonlyGroupPath: string, + securityGroupPath: string, + ): Promise { + await Promise.all([ + ...ROBOT_PROJECT_PERMISSIONS.map(permission => + this.client.addPermissionUser({ projectKey, permission, login }), + ), + ...buildGroupPermissions(rolePaths, readonlyGroupPath, securityGroupPath).flatMap(({ groupName, permissions }) => + permissions.map(permission => this.client.addPermissionGroup({ projectKey, permission, groupName })), + ), + ]) + } + + private async ensureGroupWithGlobalPermissions(groupName: string, permissions: readonly string[]): Promise { + await this.ensureGroup(groupName) + await Promise.all(permissions.map(permission => + this.client.addPermissionGroup({ groupName, permission }), + )) + } + + private async ensureGroup(groupName: string): Promise { + const result = await this.client.searchUserGroup({ q: groupName }) + if (result.groups.some(g => g.name === groupName)) { + this.logger.verbose(`SonarQube group already exists (name=${groupName})`) + } else { + await this.client.createUserGroup({ name: groupName }) + this.logger.log(`Created SonarQube group (name=${groupName})`) + } + } + + private async rotateToken(login: string): Promise { + const name = `Sonar Token for ${login}` + await this.client.revokeUserToken({ login, name }).catch(() => {}) + const { token } = await this.client.generateUserToken({ login, name }) + this.logger.log(`Rotated SonarQube token (login=${login})`) + return token + } + + private async getAdminGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, ADMIN_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getReadonlyGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, READONLY_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_READONLY_GROUP_PATH + } + + private async getSecurityGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, SECURITY_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_SECURITY_GROUP_PATH + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return getProjectPluginConfig(project, key) ?? undefined + } + + private async getProjectRoleGroupPaths(project: ProjectWithDetails): Promise { + const [admin, devops, developer, security, readonly] = await Promise.all([ + this.getProjectAdminGroupPaths(project), + this.getProjectDevopsGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectSecurityGroupPaths(project), + this.getProjectReadonlyGroupPaths(project), + ]) + return { admin, devops, developer, security, readonly } + } + + private async getProjectAdminGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_ADMIN_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_ADMIN_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_ADMIN_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDevopsGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVOPS_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVELOPER_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectSecurityGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_SECURITY_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_SECURITY_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_SECURITY_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectReadonlyGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_READONLY_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_READONLY_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_READONLY_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async findUser(login: string): Promise { + let page = 1 + const pageSize = 100 + while (true) { + const response = await this.client.searchUsers({ q: login, ps: pageSize, p: page }) + const found = response.users.find(u => u.login === login) + if (found) return found + if (!response.users.length || response.paging.pageIndex * response.paging.pageSize >= response.paging.total) return undefined + page++ + } + } + + private async findProjectsForSlug(projectSlug: string): Promise { + let found: SonarqubeProjectResult[] = [] + let page = 0 + const pageSize = 100 + let total = 0 + do { + page++ + const result = await this.client.searchProject({ q: projectSlug, p: page, ps: pageSize }) + total = result.paging.total + found = [...found, ...filterProjectsOwningSlug(result.components, projectSlug)] + } while (page * pageSize < total) + return found + } +} + +function getProjectPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string): string[] { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(suffix => `/${projectSlug}${suffix}`) +} + +function buildGroupPermissions( + rolePaths: SonarqubeRolePaths, + readonlyGroupPath: string, + securityGroupPath: string, +): { groupName: string, permissions: readonly string[] }[] { + return [ + ...rolePaths.admin.map(groupName => ({ groupName, permissions: PROJECT_ADMIN_PERMISSIONS })), + ...rolePaths.devops.map(groupName => ({ groupName, permissions: PROJECT_DEVOPS_PERMISSIONS })), + ...rolePaths.developer.map(groupName => ({ groupName, permissions: PROJECT_DEVELOPER_PERMISSIONS })), + ...rolePaths.security.map(groupName => ({ groupName, permissions: PROJECT_SECURITY_PERMISSIONS })), + ...rolePaths.readonly.map(groupName => ({ groupName, permissions: PROJECT_READONLY_PERMISSIONS })), + { groupName: securityGroupPath, permissions: PROJECT_SECURITY_PERMISSIONS }, + { groupName: readonlyGroupPath, permissions: PROJECT_READONLY_PERMISSIONS }, + ] +} + +function filterProjectsOwningSlug( + components: { key: string }[], + projectSlug: string, +): SonarqubeProjectResult[] { + return components.reduce((acc, { key: sonarKey }) => { + const parts = sonarKey.split('-') + parts.pop() + for (let i = parts.length - 1; i > 0; i--) { + const project = parts.slice(0, i).join('-') + const repository = parts.slice(i).join('-') + // recompute the key (with its HMAC suffix) so keys not managed by the + // console, or owned by a project whose slug is a prefix, are never claimed + if (generateProjectKey(project, repository) === sonarKey && project === projectSlug) { + acc.push({ projectSlug, repository, key: sonarKey }) + break + } + } + return acc + }, []) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts new file mode 100644 index 0000000000..8b8fc8adff --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts @@ -0,0 +1,6 @@ +export function sonarProjectPropertiesFile(projectKey: string) { + return [ + `sonar.projectKey=${projectKey}`, + 'sonar.qualitygate.wait=true', + ] +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts new file mode 100644 index 0000000000..f4450b21d7 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts @@ -0,0 +1,11 @@ +import type { Prisma } from '@prisma/client' + +export const adminPluginSelect = { + pluginName: true, + key: true, + value: true, +} satisfies Prisma.AdminPluginSelect + +export type AdminPluginSelect = Prisma.AdminPluginGetPayload<{ + select: typeof adminPluginSelect +}> diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config.controller.ts new file mode 100644 index 0000000000..7f74d4d40d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config.controller.ts @@ -0,0 +1,27 @@ +import type { PluginsUpdateBody } from '@cpn-console/shared' +import { pluginUpdateBody } from '@cpn-console/shared' +import { Body, Controller, Get, HttpCode, HttpStatus, Inject, Post, UseGuards } from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { SystemConfigService } from './system-config.service' + +@Controller('api/v1/system/plugins') +export class SystemConfigController { + constructor(@Inject(SystemConfigService) private readonly service: SystemConfigService) {} + + @Get() + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async get() { + return this.service.list() + } + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async update(@Body(new ZodValidationPipe(pluginUpdateBody)) body: PluginsUpdateBody) { + await this.service.update(body) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config.module.ts new file mode 100644 index 0000000000..b0622abf84 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { SystemConfigController } from './system-config.controller' +import { SystemConfigService } from './system-config.service' + +@Module({ + imports: [DatabaseModule, UserPermissionModule], + controllers: [SystemConfigController], + providers: [SystemConfigService], +}) +export class SystemConfigModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts new file mode 100644 index 0000000000..0ba0742991 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts @@ -0,0 +1,41 @@ +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { SystemConfigService } from './system-config.service' + +describe('systemConfigService', () => { + let prisma: ReturnType> + let service: SystemConfigService + + beforeEach(() => { + prisma = mockDeep() + service = new SystemConfigService(prisma) + }) + + it('returns plugin config items', async () => { + prisma.adminPlugin.findMany.mockResolvedValue([ + { pluginName: 'argocd', key: 'url', value: 'https://argocd' }, + ]) + + const result = await service.list() + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + }) + + it('updates plugins config from body via transaction', async () => { + prisma.$transaction.mockImplementation(async (callback: any) => callback(prisma)) + prisma.adminPlugin.upsert.mockResolvedValue({ pluginName: 'argocd', key: 'url', value: 'https://argocd' }) + + await service.update({ argocd: { url: 'https://argocd' } }) + + expect(prisma.$transaction).toHaveBeenCalledTimes(1) + }) + + it('does not call upsert for empty body', async () => { + prisma.$transaction.mockImplementation(async (callback: any) => callback(prisma)) + + await service.update({}) + + expect(prisma.adminPlugin.upsert).toHaveBeenCalledTimes(0) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config.service.ts new file mode 100644 index 0000000000..100cee22bc --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-config/system-config.service.ts @@ -0,0 +1,46 @@ +import type { PluginsUpdateBody } from '@cpn-console/shared' +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { BadRequestException, Injectable } from '@nestjs/common' + +@Injectable() +export class SystemConfigService { + constructor(private readonly prisma: PrismaService) {} + + async list() { + const globalConfig = await this.prisma.adminPlugin.findMany({ + select: { key: true, value: true, pluginName: true }, + }) + + return Object.values(servicesInfos).map(({ name, title, imgSrc, description }) => { + const manifest = populatePluginManifests({ + data: { global: globalConfig }, + permissionTarget: 'admin', + pluginName: name, + select: { global: true, project: false }, + }) + return { imgSrc, title, name, manifest: manifest.global ?? [], description } + }).filter(plugin => plugin.manifest.length > 0) + } + + async update(body: PluginsUpdateBody) { + const parsedData = editStrippers.global.safeParse(body) + if (!parsedData.success) { + throw new BadRequestException(parsedData.error.message) + } + + const records = Object.entries(parsedData.data) + .flatMap(([pluginName, values]: [string, Record]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value: String(value) }))) + + await this.prisma.$transaction(async (tx) => { + for (const { pluginName, key, value } of records) { + await tx.adminPlugin.upsert({ + create: { pluginName, key, value }, + update: { key, value, pluginName }, + where: { pluginName_key: { pluginName, key } }, + }) + } + }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts new file mode 100644 index 0000000000..bdc5e91f25 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts @@ -0,0 +1,15 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +export function makeSystemSetting() { + return { + key: faker.string.alphanumeric(), + value: faker.string.alphanumeric(), + } satisfies SystemSetting +} + +export function makeSystemSettings() { + return faker.helpers.multiple(() => makeSystemSetting(), { + count: faker.number.int({ min: 1, max: 10 }), + }) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts new file mode 100644 index 0000000000..9644dc97f0 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts @@ -0,0 +1,24 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { SystemSettingSchema } from '@cpn-console/shared' +import { Body, Controller, Get, Inject, Put, Query } from '@nestjs/common' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { SystemSettingsService } from './system-settings.service' + +@Controller('api/v1/system/settings') +export class SystemSettingsController { + constructor(@Inject(SystemSettingsService) private readonly service: SystemSettingsService) {} + + @Get() + async list( + @Query() query: string, + ) { + return this.service.list(query) + } + + @Put(':key') + async upsert( + @Body(new ZodValidationPipe(SystemSettingSchema)) data: SystemSetting, + ) { + return this.service.upsert(data) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts new file mode 100644 index 0000000000..704b575e14 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { SystemSettingsController } from './system-settings.controller' +import { SystemSettingsService } from './system-settings.service' + +@Module({ + imports: [DatabaseModule], + controllers: [SystemSettingsController], + providers: [SystemSettingsService], + exports: [SystemSettingsService], +}) +export class SystemSettingsModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts new file mode 100644 index 0000000000..ae9ec6e161 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts @@ -0,0 +1,84 @@ +import type { TestingModule } from '@nestjs/testing' +import type { PrismaClient } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeSystemSetting, makeSystemSettings } from './system-settings-testing.utils' +import { SystemSettingsService } from './system-settings.service' + +describe('systemSettingsService', () => { + let module: TestingModule + let service: SystemSettingsService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + SystemSettingsService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(SystemSettingsService) + }) + + describe('list', () => { + it('should return all settings', async () => { + const systemSettings = makeSystemSettings() + + prisma.systemSetting.findMany.mockResolvedValue(systemSettings) + + const result = await service.list() + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: undefined } }) + expect(result).toEqual(systemSettings) + }) + + it('should return empty array if no settings are found', async () => { + prisma.systemSetting.findMany.mockResolvedValue([]) + + const result = await service.list() + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: undefined } }) + expect(result).toEqual([]) + }) + + it('should return setting by key', async () => { + const systemSettings = makeSystemSettings() + const systemSetting = faker.helpers.arrayElement(systemSettings) + prisma.systemSetting.findMany.mockResolvedValue([systemSetting]) + + const result = await service.list(systemSetting.key) + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: systemSetting.key } }) + expect(result).toEqual([systemSetting]) + }) + + it('should return empty array if key is not found', async () => { + const key = faker.string.alphanumeric(10) + prisma.systemSetting.findMany.mockResolvedValue([]) + + const result = await service.list(key) + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key } }) + expect(result).toEqual([]) + }) + }) + + describe('upsert', () => { + it('should update setting if it exists', async () => { + const systemSetting = makeSystemSetting() + prisma.systemSetting.upsert.mockResolvedValue(systemSetting) + + const result = await service.upsert(systemSetting) + + expect(result.key).toBe(systemSetting.key) + expect(result.value).toBe(systemSetting.value) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts new file mode 100644 index 0000000000..06c5e72603 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts @@ -0,0 +1,20 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +@Injectable() +export class SystemSettingsService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(key?: string): Promise { + return this.prisma.systemSetting.findMany({ where: { key } }) + } + + async upsert(data: SystemSetting): Promise { + return this.prisma.systemSetting.upsert({ + create: data, + update: data, + where: { key: data.key }, + }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts new file mode 100644 index 0000000000..d6cce4272f --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts @@ -0,0 +1,109 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from './vault-client.service' +import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { VAULT_CONFIG } from './vault.module-definition' + +const vaultUrl = 'https://vault.internal' + +const server = setupServer( + http.post(`${vaultUrl}/v1/auth/token/create`, () => { + return HttpResponse.json({ auth: { client_token: 'token' } }) + }), + http.get(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ data: { data: { secret: 'value' }, metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 } } }) + }), + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({}) + }), + http.delete(`${vaultUrl}/v1/kv/metadata/:path`, () => { + return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) + }), +) + +describe('vault', () => { + let service: VaultClientService + + beforeAll(() => server.listen()) + beforeEach(async () => { + const config = mockDeep({ + token: 'token', + url: vaultUrl, + internalUrl: vaultUrl, + kvName: 'kv', + internalOrPublicUrl: vaultUrl, + }) + const baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + + const module = await Test.createTestingModule({ + providers: [ + VaultClientService, + VaultHttpClientService, + { provide: VAULT_CONFIG, useValue: config }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(VaultClientService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + describe('read', () => { + it('should read secret', async () => { + const result = await service.read('path') + expect(result).toEqual({ + data: { secret: 'value' }, + metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 }, + }) + }) + + it('should throw if 404', async () => { + server.use( + http.get(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.read('path')).rejects.toBeInstanceOf(VaultError) + await expect(service.read('path')).rejects.toMatchObject({ kind: 'NotFound', status: HttpStatus.NOT_FOUND }) + }) + }) + + describe('write', () => { + it('should write secret', async () => { + await expect(service.write({ secret: 'value' }, 'path')).resolves.toBeUndefined() + }) + + it('should expose reasons on error', async () => { + server.use( + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ errors: ['No secret engine mount at test-project/'] }, { status: HttpStatus.BAD_REQUEST }) + }), + ) + + await expect(service.write({ secret: 'value' }, 'path')).rejects.toBeInstanceOf(VaultError) + await expect(service.write({ secret: 'value' }, 'path')).rejects.toMatchObject({ + kind: 'HttpError', + status: HttpStatus.BAD_REQUEST, + reasons: ['No secret engine mount at test-project/'], + }) + await expect(service.write({ secret: 'value' }, 'path')).rejects.toThrow('Request failed') + }) + }) + + describe('delete', () => { + it('should delete secret', async () => { + await expect(service.delete('path')).resolves.toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-client.service.ts new file mode 100644 index 0000000000..dd07abebbd --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -0,0 +1,410 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { VAULT_CONFIG } from './vault.module-definition' +import { generateGitlabMirrorCredPath, generateSonarqubeCredPath, generateTechReadOnlyCredPath } from './vault.utils' + +export interface VaultSysPoliciesAclUpsertRequest { + policy: string +} + +export interface VaultSysMountCreateRequest { + type: string + config: { + force_no_cache: boolean + } + options: { + version: number + } +} + +export interface VaultSysMountTuneRequest { + options: { + version: number + } +} + +export interface VaultAuthApproleRoleUpsertRequest { + secret_id_num_uses: string + secret_id_ttl: string + token_max_ttl: string + token_num_uses: string + token_ttl: string + token_type: string + token_policies: string[] +} + +export interface VaultIdentityGroupUpsertRequest { + name: string + type: string + policies: string[] +} + +export interface VaultIdentityGroupAliasCreateRequest { + name: string + mount_accessor: string + canonical_id: string +} + +export interface VaultAuthMethod { + accessor: string + type: string + description?: string +} + +export interface VaultSysAuthResponse { + data: Record +} + +export interface VaultIdentityGroupResponse { + data: { + id: string + name: string + alias?: { + id?: string + name?: string + } + } +} + +export interface SonarqubeUserSecret { + SONAR_USERNAME: string + SONAR_PASSWORD: string + SONAR_TOKEN: string +} + +export interface VaultMetadata { + created_time: string + custom_metadata: Record | null + deletion_time: string + destroyed: boolean + version: number +} + +export interface VaultSecret { + data: T + metadata: VaultMetadata +} + +export interface VaultResponse { + data: VaultSecret +} + +export interface VaultListResponse { + data: { + keys: string[] + } +} + +export interface VaultRoleIdResponse { + data: { + role_id: string + } +} + +export interface VaultSecretIdResponse { + data: { + secret_id: string + } +} + +@Injectable() +export class VaultClientService { + private readonly logger = new Logger(VaultClientService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultHttpClientService) private readonly http: VaultHttpClientService, + ) { + } + + @StartActiveSpan() + async getKvData(kvName: string, path: string): Promise> { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Reading Vault KV data (kvName=${kvName}, path=${path})`) + const response = await this.http.fetch>(`${kvName}/data/${path}`) + if (!response?.data) { + throw new VaultError('InvalidResponse', 'Missing "data" field', { method: 'GET', path: `${kvName}/data/${path}` }) + } + return response.data + } + + @StartActiveSpan() + async upsertKvData(kvName: string, path: string, body: { data: T }): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Writing Vault KV data (kvName=${kvName}, path=${path})`) + await this.http.fetch(`${kvName}/data/${path}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async read(path: string): Promise> { + this.logger.debug(`Reading Vault KV secret at ${path}`) + return await this.getKvData(this.vaultConfig.kvName, path) + } + + @StartActiveSpan() + async write(data: T, path: string): Promise { + this.logger.debug(`Writing Vault KV secret at ${path}`) + await this.upsertKvData(this.vaultConfig.kvName, path, { data }) + } + + @StartActiveSpan() + async delete(path: string): Promise { + this.logger.debug(`Deleting Vault KV secret at ${path}`) + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.path', path) + return await this.deleteKvMetadata(this.vaultConfig.kvName, path) + } + + @StartActiveSpan() + async readGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Reading Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + return await this.read(vaultCredsPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeGitlabMirrorCreds(projectSlug: string, repoName: string, data: Record): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Writing Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + await this.write(data, vaultCredsPath) + } + + @StartActiveSpan() + async deleteGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Deleting Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + await this.delete(vaultCredsPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + }) + } + + @StartActiveSpan() + async readTechnReadOnlyCreds(projectSlug: string): Promise { + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + return await this.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeTechReadOnlyCreds(projectSlug: string, creds: Record): Promise { + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + await this.write(creds, vaultPath) + } + + @StartActiveSpan() + async readSonarqubeUser(projectSlug: string): Promise | null> { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Reading Vault SonarQube user credentials (projectSlug=${projectSlug})`) + return await this.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeSonarqubeUser(projectSlug: string, secret: SonarqubeUserSecret): Promise { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Writing Vault SonarQube user credentials (projectSlug=${projectSlug})`) + await this.write(secret, vaultPath) + } + + @StartActiveSpan() + async deleteSonarqubeUser(projectSlug: string): Promise { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Deleting Vault SonarQube user credentials (projectSlug=${projectSlug})`) + await this.delete(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + }) + } + + @StartActiveSpan() + async writeMirrorTriggerToken(secret: Record): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.path', 'GITLAB') + await this.write(secret, 'GITLAB') + } + + @StartActiveSpan() + async deleteKvMetadata(kvName: string, path: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + try { + await this.http.fetch(`${kvName}/metadata/${path}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + } + + @StartActiveSpan() + async listKvMetadata(kvName: string, path: string): Promise { + try { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Listing Vault KV metadata (kvName=${kvName}, path=${path})`) + const response = await this.http.fetch(`${kvName}/metadata/${path}`, { method: 'LIST' }) + if (!response?.data?.keys) { + throw new VaultError('InvalidResponse', 'Missing "data.keys" field', { method: 'LIST', path: `${kvName}/metadata/${path}` }) + } + return response.data.keys + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return [] + throw error + } + } + + @StartActiveSpan() + async upsertSysPoliciesAcl(policyName: string, body: VaultSysPoliciesAclUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault ACL policy ${policyName}`) + await this.http.fetch(`sys/policies/acl/${policyName}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSysPoliciesAcl(policyName: string): Promise { + this.logger.verbose(`Deleting Vault ACL policy ${policyName}`) + await this.http.fetch(`sys/policies/acl/${policyName}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async createSysMount(name: string, body: VaultSysMountCreateRequest): Promise { + this.logger.verbose(`Creating Vault mount ${name} (version=${body.options.version})`) + await this.http.fetch(`sys/mounts/${name}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async tuneSysMount(name: string, body: VaultSysMountTuneRequest): Promise { + this.logger.verbose(`Tuning Vault mount ${name} (version=${body.options.version})`) + await this.http.fetch(`sys/mounts/${name}/tune`, { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSysMounts(name: string): Promise { + this.logger.verbose(`Deleting Vault mount ${name}`) + await this.http.fetch(`sys/mounts/${name}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async upsertAuthApproleRole(roleName: string, body: VaultAuthApproleRoleUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault AppRole ${roleName} (policies=${body.token_policies.length})`) + await this.http.fetch(`auth/approle/role/${roleName}`, { + method: 'POST', + body, + }) + } + + @StartActiveSpan() + async deleteAuthApproleRole(roleName: string): Promise { + this.logger.verbose(`Deleting Vault AppRole ${roleName}`) + await this.http.fetch(`auth/approle/role/${roleName}`, { method: 'DELETE' }) + } + + async getAuthApproleRoleRoleId(roleName: string) { + const path = `auth/approle/role/${roleName}/role-id` + this.logger.verbose(`Reading Vault AppRole role-id for ${roleName}`) + const response = await this.http.fetch(path) + const roleId = response?.data?.role_id + if (!roleId) { + throw new VaultError('InvalidResponse', `Vault role-id not found for role ${roleName}`, { method: 'GET', path }) + } + return roleId + } + + @StartActiveSpan() + async createAuthApproleRoleSecretId(roleName: string) { + const path = `auth/approle/role/${roleName}/secret-id` + this.logger.verbose(`Creating Vault AppRole secret-id for ${roleName}`) + const response = await this.http.fetch(path, { method: 'POST' }) + const secretId = response?.data?.secret_id + if (!secretId) { + throw new VaultError('InvalidResponse', `Vault secret-id not generated for role ${roleName}`, { method: 'POST', path }) + } + return secretId + } + + async getSysAuth(): Promise> { + this.logger.verbose('Listing Vault auth methods') + const response = await this.http.fetch('sys/auth') + return response?.data ?? {} + } + + @StartActiveSpan() + async upsertIdentityGroupName(groupName: string, body: VaultIdentityGroupUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault identity group ${groupName} (policies=${body.policies.length})`) + await this.http.fetch(`identity/group/name/${groupName}`, { + method: 'POST', + body, + }) + } + + @StartActiveSpan() + async getIdentityGroupName(groupName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.name', groupName) + const path = `identity/group/name/${groupName}` + const response = await this.http.fetch(path) + if (!response) throw new VaultError('InvalidResponse', 'Empty response', { method: 'GET', path }) + return response + } + + @StartActiveSpan() + async deleteIdentityGroupName(groupName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.name', groupName) + this.logger.verbose(`Deleting Vault identity group ${groupName}`) + await this.http.fetch(`identity/group/name/${groupName}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async createIdentityGroupAlias(body: VaultIdentityGroupAliasCreateRequest): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.alias', body.name) + this.logger.verbose(`Creating Vault identity group alias (aliasName=${body.name}, canonicalId=${body.canonical_id})`) + await this.http.fetch('identity/group-alias', { method: 'POST', body }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts new file mode 100644 index 0000000000..dfb48d9a71 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts @@ -0,0 +1,84 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + environments: { + select: { + id: true, + clusterId: true, + cpu: true, + memory: true, + autosync: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export const zoneSelect = { + id: true, + slug: true, + clusters: { + select: { + projects: { + select: { + id: true, + }, + }, + }, + }, +} satisfies Prisma.ZoneSelect + +export type ZoneWithDetails = Prisma.ZoneGetPayload<{ + select: typeof zoneSelect +}> + +@Injectable() +export class VaultDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ where: { id }, select: projectSelect }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } + + async getAllZones(): Promise { + return this.prisma.zone.findMany({ + select: zoneSelect, + }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-health.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-health.service.ts new file mode 100644 index 0000000000..daddabd5b4 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-health.service.ts @@ -0,0 +1,23 @@ +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { VAULT_CONFIG } from './vault.module-definition' + +@Injectable() +export class VaultHealthService { + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.vaultConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts new file mode 100644 index 0000000000..2a80532efe --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts @@ -0,0 +1,135 @@ +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus, Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VAULT_CONFIG } from './vault.module-definition' + +export interface VaultFetchOptions { + method?: string + body?: unknown +} + +export type VaultErrorKind + = | 'NotConfigured' + | 'NotFound' + | 'HttpError' + | 'InvalidResponse' + | 'ParseError' + | 'Unexpected' + +export class VaultError extends Error { + readonly kind: VaultErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + readonly reasons?: string[] + + constructor( + kind: VaultErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string, reasons?: string[] } = {}, + ) { + super(message) + this.name = 'VaultError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + this.reasons = details.reasons + } +} + +@Injectable() +export class VaultHttpClientService { + private readonly logger = new Logger(VaultHttpClientService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + ) {} + + @StartActiveSpan() + async fetch( + path: string, + options: VaultFetchOptions = {}, + ): Promise { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('vault.method', method) + span?.setAttribute('vault.path', path) + + this.logger.verbose(`Starting Vault request: ${method} ${path}`) + this.logger.debug(`Vault request: ${method} ${path}`) + const request = this.createRequest(path, method, options.body) + const response = await fetch(request).catch((error) => { + if (error instanceof Error) { + this.logger.error(`Vault request failed: ${method} ${path}: ${error.message}`, error.stack) + } else { + this.logger.error(`Vault request failed: ${method} ${path}: ${String(error)}`) + } + throw new VaultError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('vault.http.status', response.status) + this.logger.debug(`Vault response: ${method} ${path} status=${response.status}`) + + const parsed = await this.handleResponse(response, method, path) + this.logger.verbose(`Completed Vault request: ${method} ${path} status=${response.status} outcome=${parsed === null ? 'no-content' : 'ok'}`) + return parsed + } + + private get apiBaseUrl() { + return new URL('v1/', this.vaultConfig.internalOrPublicUrl).toString() + } + + private createRequest(path: string, method: string, body?: unknown): Request { + const url = new URL(path, this.apiBaseUrl).toString() + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Vault-Token': this.vaultConfig.token, + } + + return new Request(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }) + } + + private async handleResponse(response: Response, method: string, path: string): Promise { + if (response.status === HttpStatus.NO_CONTENT) return null + + if (!response.ok) { + await this.throwForStatus(response, method, path) + } + + return await response.json() + } + + private async throwForStatus(response: Response, method: string, path: string): Promise { + const responseBody = await response.json() + const vaultErrorBody = z.object({ errors: z.array(z.string()) }).safeParse(responseBody) + const reasons = vaultErrorBody.success ? vaultErrorBody.data.errors : undefined + const reasonsPart = reasons?.length ? ` reasons=${reasons.join('; ')}` : '' + this.logger.warn(`Vault request returned error: ${method} ${path} status=${response.status} statusText=${response.statusText}${reasonsPart}`) + + if (response.status === 404) { + throw new VaultError('NotFound', 'Not Found', { + status: 404, + method, + path, + statusText: response.statusText, + reasons, + }) + } + + throw new VaultError('HttpError', 'Request failed', { + status: response.status, + method, + path, + statusText: response.statusText, + reasons, + }) + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts new file mode 100644 index 0000000000..a1d5e06eea --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { VaultConfig } from './vault.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { VaultPluginService } from './vault-plugin.service' +import { VAULT_CONFIG } from './vault.module-definition' + +describe('vaultPluginService', () => { + let service: VaultPluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://vault.public/', + internalUrl: 'https://vault.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + VaultPluginService, + { provide: VAULT_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(VaultPluginService) + }) + + it('returns the public Vault url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://vault.public/ui/vault/secrets/dulei') + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts new file mode 100644 index 0000000000..0095ac34cf --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts @@ -0,0 +1,21 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable } from '@nestjs/common' +import { VAULT_CONFIG } from './vault.module-definition' + +@Injectable() +export class VaultPluginService { + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'vault', + to: ({ project }) => new URL(`ui/vault/secrets/${project.slug}`, this.vaultConfig.url).toString(), + title: 'Vault', + imgSrc: '/img/vault.svg', + description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', + } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts new file mode 100644 index 0000000000..2397b97bdc --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts @@ -0,0 +1,43 @@ +import type { VaultSecret } from './vault-client.service' +import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + name: faker.company.name(), + description: faker.company.buzzPhrase(), + environments: [], + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + +export function makeZoneWithDetails(overrides: Partial = {}): ZoneWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-zone-${faker.string.uuid()}`), + clusters: [], + ...overrides, + } satisfies ZoneWithDetails +} + +export function makeVaultSecret(overrides: Partial = {}): VaultSecret { + return { + data: {}, + metadata: makeVaultSecretMetadata(), + ...overrides, + } satisfies VaultSecret +} + +export function makeVaultSecretMetadata(overrides: Partial = {}): VaultSecret['metadata'] { + return { + created_time: faker.date.soon().toISOString(), + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + ...overrides, + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.constants.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.constants.ts new file mode 100644 index 0000000000..22adfa3275 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.constants.ts @@ -0,0 +1,34 @@ +// Plugin name used to identify the Vault integration +export const PLUGIN_NAME = 'vault' + +// Platform-level policy names for RBAC +export const PLATFORM_ADMIN_POLICY_NAME = 'platform--admin' +export const PLATFORM_READONLY_POLICY_NAME = 'platform--readonly' +export const PLATFORM_SECURITY_POLICY_NAME = 'platform--security' + +// Console group names for user role assignment +export const CONSOLE_ADMIN_GROUP_NAME = 'console-admin' +export const CONSOLE_READONLY_GROUP_NAME = 'console-readonly' +export const CONSOLE_SECURITY_GROUP_NAME = 'console-security' + +// Default LDAP/AD group paths for console access +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly' +export const DEFAULT_SECURITY_GROUP_PATH = '/console/security' + +// Default suffixes for project-scoped group paths +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly' +export const DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX = '/console/security' + +// Plugin configuration keys for custom group paths +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const AUDITOR_GROUP_PATH_PLUGIN_KEY = 'auditorGroupPath' +export const SECURITY_GROUP_PATH_PLUGIN_KEY = 'securityGroupPath' +export const PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReporterGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDeveloperGroupPathSuffix' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevopsGroupPathSuffix' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectMaintainerGroupPathSuffix' +export const PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectSecurityGroupPathSuffix' diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.module-definition.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.module-definition.ts new file mode 100644 index 0000000000..d0f1b425cf --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.module-definition.ts @@ -0,0 +1,17 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface VaultConfig { + token?: string + url: string + internalUrl: string + kvName: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface VaultModuleOptions extends VaultConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('vault') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as VAULT_CONFIG } diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.module.ts new file mode 100644 index 0000000000..367a1527a2 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { VaultHealthService } from './vault-health.service' +import { VaultHttpClientService } from './vault-http-client.service' +import { VaultPluginService } from './vault-plugin.service' +import { ConfigurableModuleClass } from './vault.module-definition' +import { VaultService } from './vault.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + providers: [ + VaultHealthService, + VaultHttpClientService, + VaultClientService, + VaultPluginService, + VaultService, + VaultDatastoreService, + ], + exports: [VaultClientService, VaultHealthService, VaultPluginService, VaultService], +}) +export class VaultModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.service.spec.ts new file mode 100644 index 0000000000..d46cb7b75c --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -0,0 +1,153 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { makeProjectWithDetails, makeVaultSecret, makeZoneWithDetails } from './vault-testing.utils' +import { VAULT_CONFIG } from './vault.module-definition' +import { VaultService } from './vault.service' + +const projectRoleGroupNameRegex = /^project-(.*)-(admin|devops|developer|readonly|security)$/ + +describe('vaultService', () => { + let service: VaultService + let datastore: DeepMockProxy + let client: DeepMockProxy + + beforeEach(async () => { + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + client = mockDeep({ + createSysMount: vi.fn().mockResolvedValue(undefined), + tuneSysMount: vi.fn().mockResolvedValue(undefined), + deleteSysMounts: vi.fn().mockResolvedValue(undefined), + upsertSysPoliciesAcl: vi.fn().mockResolvedValue(undefined), + deleteSysPoliciesAcl: vi.fn().mockResolvedValue(undefined), + upsertAuthApproleRole: vi.fn().mockResolvedValue(undefined), + deleteAuthApproleRole: vi.fn().mockResolvedValue(undefined), + getIdentityGroupName: vi.fn(async (groupName: string) => makeVaultSecret({ data: { id: 'gid', name: groupName } })), + deleteIdentityGroupName: vi.fn().mockResolvedValue(undefined), + getSysAuth: vi.fn().mockResolvedValue({ 'oidc/': { accessor: 'oidc-accessor', type: 'oidc' } }), + createIdentityGroupAlias: vi.fn().mockResolvedValue(undefined), + listKvMetadata: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + }) + + const vaultConfig = mockDeep({ kvName: 'kv' }) + const baseConfig = mockDeep({ projectsRootDir: 'forge' }) + + const module = await Test.createTestingModule({ + providers: [ + VaultService, + { provide: VaultClientService, useValue: client }, + { provide: VaultDatastoreService, useValue: datastore }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(VaultService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should reconcile on cron', async () => { + const projects = faker.helpers.multiple(() => makeProjectWithDetails()) + const zones = faker.helpers.multiple(() => makeZoneWithDetails()) + + datastore.getAllProjects.mockResolvedValue(projects) + datastore.getAllZones.mockResolvedValue(zones) + + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(datastore.getAllZones).toHaveBeenCalled() + expect(client.createSysMount).toHaveBeenCalledTimes(projects.length + zones.length) + projects.forEach((project) => { + expect(client.createSysMount).toHaveBeenCalledWith(project.slug, expect.any(Object)) + }) + zones.forEach((zone) => { + expect(client.createSysMount).toHaveBeenCalledWith(`zone-${zone.slug}`, expect.any(Object)) + }) + }) + + it('should upsert project on event', async () => { + const project = makeProjectWithDetails() + + client.getIdentityGroupName.mockImplementation(async (groupName: string) => { + const projectRoleMatch = groupName.match(projectRoleGroupNameRegex) + if (projectRoleMatch) { + const projectSlug = projectRoleMatch[1] + const role = projectRoleMatch[2] + return { data: { id: 'gid', name: groupName, alias: { name: `/${projectSlug}/console/${role}` } } } + } + + if (groupName === 'console-admin') return { data: { id: 'gid', name: groupName, alias: { name: '/console/admin' } } } + if (groupName === 'console-readonly') return { data: { id: 'gid', name: groupName, alias: { name: '/console/readonly' } } } + if (groupName === 'console-security') return { data: { id: 'gid', name: groupName, alias: { name: '/console/security' } } } + + return { data: { id: 'gid', name: groupName } } + }) + + await service.handleUpsert(project) + + expect(client.createSysMount).toHaveBeenCalledWith(project.slug, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`app--${project.slug}--admin`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`tech--${project.slug}--ro`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--devops`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--developer`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--readonly`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--security`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--admin', expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--readonly', expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--security', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-admin', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-readonly', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-security', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-admin`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-devops`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-developer`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`, expect.any(Object)) + expect(client.createIdentityGroupAlias).not.toHaveBeenCalled() + + const policyCalls = client.upsertSysPoliciesAcl.mock.calls as Array<[string, { policy: string }]> + expect(policyCalls.find(([name]) => name === `project--${project.slug}--developer`)?.[1].policy) + .toBe(`path "${project.slug}/data/*" { capabilities = ["list"] }`) + expect(policyCalls.find(([name]) => name === `project--${project.slug}--readonly`)?.[1].policy) + .toBe(`path "${project.slug}/data/*" { capabilities = ["list"] }`) + expect(policyCalls.find(([name]) => name === `project--${project.slug}--security`)?.[1].policy) + .toBe([ + `path "${project.slug}/metadata/*" { capabilities = ["list"] }`, + `path "transit/keys/${project.slug}/*" { capabilities = ["list"] }`, + ].join('\n')) + }) + + it('should delete project and destroy secrets on event', async () => { + const project = makeProjectWithDetails() + + await service.handleDelete(project) + + expect(client.deleteSysMounts).toHaveBeenCalledWith(project.slug) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`app--${project.slug}--admin`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`tech--${project.slug}--ro`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--devops`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--developer`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--readonly`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--security`) + expect(client.deleteAuthApproleRole).toHaveBeenCalledWith(project.slug) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-admin`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-devops`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-developer`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.service.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.service.ts new file mode 100644 index 0000000000..7128c24963 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -0,0 +1,594 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { VaultError } from './vault-http-client.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + AUDITOR_GROUP_PATH_PLUGIN_KEY, + CONSOLE_ADMIN_GROUP_NAME, + CONSOLE_READONLY_GROUP_NAME, + CONSOLE_SECURITY_GROUP_NAME, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX, + DEFAULT_SECURITY_GROUP_PATH, + PLATFORM_ADMIN_POLICY_NAME, + PLATFORM_READONLY_POLICY_NAME, + PLATFORM_SECURITY_POLICY_NAME, + PLUGIN_NAME, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + SECURITY_GROUP_PATH_PLUGIN_KEY, +} from './vault.constants' +import { VAULT_CONFIG } from './vault.module-definition' +import { generateProjectPath } from './vault.utils' + +type ProjectScope = 'admin' | 'devops' | 'developer' | 'readonly' | 'security' + +@Injectable() +export class VaultService { + private readonly logger = new Logger(VaultService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultDatastoreService) private readonly datastore: VaultDatastoreService, + @Inject(VaultClientService) private readonly client: VaultClientService, + ) { + this.logger.log('VaultService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('vault', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`Vault project sync completed for ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('vault', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await Promise.all([ + this.deleteProject(project), + this.deleteProjectSecrets(project.slug), + ]) + this.logger.log(`Vault project cleanup completed for ${project.slug}`) + } + + @OnEvent('zone.upsert') + async handleUpsertZone(zone: ZoneWithDetails): Promise> { + return capturePluginResult('vault', () => this.syncZone(zone)) + } + + @StartActiveSpan() + private async syncZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.log(`Handling a zone upsert event for ${zone.slug}`) + await this.ensureZone(zone) + this.logger.log(`Vault zone sync completed for ${zone.slug}`) + } + + @OnEvent('zone.delete') + async handleDeleteZone(zone: ZoneWithDetails): Promise> { + return capturePluginResult('vault', () => this.cleanupZone(zone)) + } + + @StartActiveSpan() + private async cleanupZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.log(`Handling a zone delete event for ${zone.slug}`) + await this.deleteZone(zone.slug) + this.logger.log(`Vault zone cleanup completed for ${zone.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Vault reconciliation') + const [projects, zones] = await Promise.all([ + this.datastore.getAllProjects(), + this.datastore.getAllZones(), + ]) + + span?.setAttributes({ + 'vault.projects.count': projects.length, + 'vault.zones.count': zones.length, + }) + this.logger.log(`Loaded state for Vault reconciliation (projects=${projects.length}, zones=${zones.length})`) + await Promise.all([ + this.ensureProjects(projects), + this.ensureZones(zones), + ]) + this.logger.log(`Vault reconciliation completed (projects=${projects.length} zones=${zones.length})`) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('vault.projects.count', projects.length) + this.logger.verbose(`Reconciling Vault projects (count=${projects.length})`) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling Vault project ${project.slug}`) + await this.upsertProject(project) + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return project.plugins?.find(p => p.pluginName === PLUGIN_NAME && p.key === key)?.value + } + + private async getAdminGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getAuditorGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, AUDITOR_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_AUDITOR_GROUP_PATH + } + + private async getSecurityGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, SECURITY_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_SECURITY_GROUP_PATH + } + + private async getProjectMaintainerGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX + } + + private async getProjectDevopsGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX + } + + private async getProjectDeveloperGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + } + + private async getProjectReporterGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX + } + + private async getProjectSecurityGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX + } + + @StartActiveSpan() + private async ensureZones(zones: ZoneWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('vault.zones.count', zones.length) + this.logger.verbose(`Reconciling Vault zones (count=${zones.length})`) + await Promise.all(zones.map(z => this.ensureZone(z))) + } + + @StartActiveSpan() + private async ensureZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.verbose(`Reconciling Vault zone ${zone.slug}`) + await this.upsertZone(zone.slug) + } + + private async upsertMount(kvName: string): Promise { + const createBody = { + type: 'kv', + config: { + force_no_cache: true, + }, + options: { + version: 2, + }, + } + const tuneBody = { + options: { + version: 2, + }, + } + try { + await this.client.createSysMount(kvName, createBody) + this.logger.log(`Created Vault mount ${kvName}`) + } catch (error) { + if (error instanceof VaultError && error.kind === 'HttpError' && error.status === 400) { + await this.client.tuneSysMount(kvName, tuneBody) + this.logger.log(`Vault mount ${kvName} already existed, so it was tuned to the expected settings`) + return + } + throw error + } + } + + private async deleteMount(kvName: string): Promise { + try { + await this.client.deleteSysMounts(kvName) + this.logger.log(`Deleted Vault mount ${kvName}`) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') { + this.logger.warn(`Vault mount ${kvName} was already missing`) + return + } + throw error + } + } + + @StartActiveSpan() + async upsertZone(zoneName: string): Promise { + const kvName = generateZoneName(zoneName) + const span = trace.getActiveSpan() + span?.setAttribute('zone.name', zoneName) + span?.setAttribute('vault.kv.name', kvName) + const policyName = generateZoneTechReadOnlyPolicyName(zoneName) + + await this.upsertMount(kvName) + await this.client.upsertSysPoliciesAcl(policyName, { + policy: `path "${kvName}/*" { capabilities = ["read"] }`, + }) + await this.client.upsertAuthApproleRole(kvName, generateApproleRoleBody([policyName])) + } + + @StartActiveSpan() + async deleteZone(zoneName: string): Promise { + const kvName = generateZoneName(zoneName) + const span = trace.getActiveSpan() + span?.setAttribute('zone.name', zoneName) + span?.setAttribute('vault.kv.name', kvName) + const policyName = generateZoneTechReadOnlyPolicyName(zoneName) + const roleName = kvName + + await this.deleteMount(kvName) + + const settled = await Promise.allSettled([ + this.client.deleteSysPoliciesAcl(policyName), + this.client.deleteAuthApproleRole(roleName), + ]) + + for (const result of settled) { + if (result.status !== 'rejected') continue + const error = result.reason + if (error instanceof VaultError && error.kind === 'NotFound') continue + throw error + } + } + + @StartActiveSpan() + async upsertProject(project: ProjectWithDetails): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('vault.kv.name', project.slug) + const appPolicyName = generateAppAdminPolicyName(project) + const techPolicyName = generateTechReadOnlyPolicyName(project) + const projectDevopsPolicyName = generateProjectPolicyName(project, 'devops') + const projectDeveloperPolicyName = generateProjectPolicyName(project, 'developer') + const projectReadOnlyPolicyName = generateProjectPolicyName(project, 'readonly') + const projectSecurityPolicyName = generateProjectPolicyName(project, 'security') + await this.upsertMount(project.slug) + + const [ + adminGroupPath, + auditorGroupPath, + securityGroupPath, + maintainerGroupPathSuffix, + devopsGroupPathSuffix, + developerGroupPathSuffix, + reporterGroupPathSuffix, + securityGroupPathSuffix, + ] = await Promise.all([ + this.getAdminGroupPath(project), + this.getAuditorGroupPath(project), + this.getSecurityGroupPath(project), + this.getProjectMaintainerGroupPathSuffix(project), + this.getProjectDevopsGroupPathSuffix(project), + this.getProjectDeveloperGroupPathSuffix(project), + this.getProjectReporterGroupPathSuffix(project), + this.getProjectSecurityGroupPathSuffix(project), + ]) + + const projectAdminGroupPaths = generateProjectRoleGroupPaths(project, maintainerGroupPathSuffix) + const projectDevopsGroupPaths = generateProjectRoleGroupPaths(project, devopsGroupPathSuffix) + const projectDeveloperGroupPaths = generateProjectRoleGroupPaths(project, developerGroupPathSuffix) + const projectReadOnlyGroupPaths = generateProjectRoleGroupPaths(project, reporterGroupPathSuffix) + const projectSecurityGroupPaths = generateProjectRoleGroupPaths(project, securityGroupPathSuffix) + + await Promise.all([ + this.ensureAppAdminPolicy(appPolicyName, project.slug), + this.ensureTechReadOnlyPolicy(techPolicyName, project.slug), + this.ensureProjectDevopsPolicy(projectDevopsPolicyName, project.slug), + this.ensureProjectReadOnlyPolicy(projectDeveloperPolicyName, project.slug), + this.ensureProjectReadOnlyPolicy(projectReadOnlyPolicyName, project.slug), + this.createProjectSecurityPolicy(projectSecurityPolicyName, project.slug), + this.ensurePlatformAdminPolicy(PLATFORM_ADMIN_POLICY_NAME), + this.ensurePlatformReadOnlyPolicy(PLATFORM_READONLY_POLICY_NAME), + this.ensurePlatformSecurityPolicy(PLATFORM_SECURITY_POLICY_NAME), + this.ensureIdentityGroup(CONSOLE_ADMIN_GROUP_NAME, [PLATFORM_ADMIN_POLICY_NAME], adminGroupPath), + this.ensureIdentityGroup(CONSOLE_READONLY_GROUP_NAME, [PLATFORM_READONLY_POLICY_NAME], auditorGroupPath), + this.ensureIdentityGroup(CONSOLE_SECURITY_GROUP_NAME, [PLATFORM_SECURITY_POLICY_NAME], securityGroupPath), + ...projectAdminGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'admin'), [appPolicyName], groupPath)), + ...projectDevopsGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'devops'), [projectDevopsPolicyName], groupPath)), + ...projectDeveloperGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'developer'), [projectDeveloperPolicyName], groupPath)), + ...projectReadOnlyGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'readonly'), [projectReadOnlyPolicyName], groupPath)), + ...projectSecurityGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'security'), [projectSecurityPolicyName], groupPath)), + this.client.upsertAuthApproleRole(project.slug, generateApproleRoleBody([techPolicyName, appPolicyName])), + ]) + } + + @StartActiveSpan() + async deleteProject(project: ProjectWithDetails): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('vault.kv.name', project.slug) + const appPolicyName = generateAppAdminPolicyName(project) + const techPolicyName = generateTechReadOnlyPolicyName(project) + const projectDevopsPolicyName = generateProjectPolicyName(project, 'devops') + const projectDeveloperPolicyName = generateProjectPolicyName(project, 'developer') + const projectReadOnlyPolicyName = generateProjectPolicyName(project, 'readonly') + const projectSecurityPolicyName = generateProjectPolicyName(project, 'security') + + await this.deleteMount(project.slug) + + const settled = await Promise.allSettled([ + this.client.deleteSysPoliciesAcl(appPolicyName), + this.client.deleteSysPoliciesAcl(techPolicyName), + this.client.deleteSysPoliciesAcl(projectDevopsPolicyName), + this.client.deleteSysPoliciesAcl(projectDeveloperPolicyName), + this.client.deleteSysPoliciesAcl(projectReadOnlyPolicyName), + this.client.deleteSysPoliciesAcl(projectSecurityPolicyName), + this.client.deleteAuthApproleRole(project.slug), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'admin')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'devops')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'developer')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'readonly')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'security')), + ]) + for (const result of settled) { + if (result.status !== 'rejected') continue + const error = result.reason + if (error instanceof VaultError && error.kind === 'NotFound') continue + throw error + } + } + + @StartActiveSpan() + private async ensureIdentityGroup(groupName: string, policies: string[], groupAliasName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'vault.group.name': groupName, + 'vault.policies.count': policies.length, + }) + await this.client.upsertIdentityGroupName(groupName, { + name: groupName, + type: 'external', + policies, + }) + + const groupResult = await this.client.getIdentityGroupName(groupName) + if (!groupResult.data?.id) { + throw new VaultError('InvalidResponse', `Vault group not found after upsert: ${groupName}`, { method: 'GET', path: `/v1/identity/group/name/${groupName}` }) + } + + const normalizedAliasName = groupAliasName.startsWith('/') ? groupAliasName : `/${groupAliasName}` + if (groupResult.data.alias?.name === normalizedAliasName) return + + const methods = await this.client.getSysAuth() + const oidc = methods['oidc/'] + if (!oidc?.accessor) { + throw new VaultError('InvalidResponse', 'Vault OIDC auth method not found (expected "oidc/")', { method: 'GET', path: '/v1/sys/auth' }) + } + try { + span?.setAttributes({ + 'vault.group.alias.name': normalizedAliasName, + 'vault.oidc.accessor': oidc.accessor, + }) + await this.client.createIdentityGroupAlias({ + name: normalizedAliasName, + mount_accessor: oidc.accessor, + canonical_id: groupResult.data.id, + }) + } catch (error) { + if (error instanceof VaultError && error.kind === 'HttpError' && error.status === 400) return + throw error + } + } + + async ensureAppAdminPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${projectSlug}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + }) + } + + async ensureProjectDevopsPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "${projectSlug}/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + `path "${projectSlug}/metadata/*" { capabilities = ["read", "list"] }`, + `path "${projectSlug}/delete/*" { capabilities = ["update"] }`, + `path "${projectSlug}/undelete/*" { capabilities = ["update"] }`, + `path "${projectSlug}/destroy/*" { capabilities = ["update"] }`, + ].join('\n'), + }) + } + + async ensureProjectReadOnlyPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${projectSlug}/data/*" { capabilities = ["list"] }`, + }) + } + + async createProjectSecurityPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "${projectSlug}/metadata/*" { capabilities = ["list"] }`, + `path "transit/keys/${projectSlug}/*" { capabilities = ["list"] }`, + ].join('\n'), + }) + } + + async ensurePlatformAdminPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "sys/*" { capabilities = ["create", "read", "update", "delete", "list", "sudo"] }`, + }) + } + + async ensurePlatformReadOnlyPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "sys/health" { capabilities = ["read"] }`, + `path "sys/mounts" { capabilities = ["read"] }`, + `path "sys/mounts/*" { capabilities = ["read"] }`, + `path "sys/auth" { capabilities = ["read"] }`, + `path "sys/auth/*" { capabilities = ["read"] }`, + `path "sys/policies/*" { capabilities = ["read", "list"] }`, + ].join('\n'), + }) + } + + async ensurePlatformSecurityPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "sys/audit" { capabilities = ["read", "list"] }`, + `path "sys/audit/*" { capabilities = ["read", "list"] }`, + `path "sys/policies/*" { capabilities = ["read", "list"] }`, + `path "sys/auth" { capabilities = ["read"] }`, + `path "sys/auth/*" { capabilities = ["read"] }`, + ].join('\n'), + }) + } + + async ensureTechReadOnlyPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${this.vaultConfig.kvName}/data/${projectSlug}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + }) + } + + async listProjectSecrets(projectSlug: string): Promise { + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) + return this.listRecursive(this.vaultConfig.kvName, projectPath, '') + } + + @StartActiveSpan() + async deleteProjectSecrets(projectSlug: string): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'vault.kv.name': this.vaultConfig.kvName, + }) + const secrets = await this.listProjectSecrets(projectSlug) + span?.setAttribute('vault.secrets.count', secrets.length) + + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) + await Promise.allSettled(secrets.map(async (relativePath) => { + const fullPath = `${projectPath}/${relativePath}` + try { + await this.client.delete(fullPath) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + })) + } + + private async listRecursive( + kvName: string, + basePath: string, + relativePath: string, + ): Promise { + const combined = relativePath.length === 0 ? basePath : `${basePath}/${relativePath}` + const keys = await this.client.listKvMetadata(kvName, combined) + if (keys.length === 0) return [] + + const results: string[] = [] + for (const key of keys) { + if (key.endsWith('/')) { + const nestedRel = relativePath.length === 0 ? key.slice(0, -1) : `${relativePath}/${key.slice(0, -1)}` + const nested = await this.listRecursive(kvName, basePath, nestedRel) + results.push(...nested) + } else { + results.push(relativePath.length === 0 ? key : `${relativePath}/${key}`) + } + } + return results + } +} + +function generateTechReadOnlyPolicyName(project: ProjectWithDetails) { + return `tech--${project.slug}--ro` +} + +function generateAppAdminPolicyName(project: ProjectWithDetails) { + return `app--${project.slug}--admin` +} + +function generateProjectPolicyName(project: ProjectWithDetails, scope: ProjectScope) { + return `project--${project.slug}--${scope}` +} + +function generateProjectGroupName(project: ProjectWithDetails, scope: ProjectScope) { + return `project-${project.slug}-${scope}` +} + +function generateProjectRoleGroupPaths(project: ProjectWithDetails, rawGroupPathSuffixes: string) { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${project.slug}${path}`) +} + +function generateZoneName(name: string) { + return `zone-${name}` +} + +function generateZoneTechReadOnlyPolicyName(zoneName: string) { + return `tech--${generateZoneName(zoneName)}--ro` +} + +function generateApproleRoleBody(policies: string[]) { + return { + secret_id_num_uses: '0', + secret_id_ttl: '0', + token_max_ttl: '0', + token_num_uses: '0', + token_ttl: '0', + token_type: 'batch', + token_policies: policies, + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.utils.ts new file mode 100644 index 0000000000..6e170bede2 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/vault/vault.utils.ts @@ -0,0 +1,15 @@ +export function generateProjectPath(projectRootDir: string, projectSlug: string) { + return `${projectRootDir}/${projectSlug}` +} + +export function generateGitlabMirrorCredPath(projectRootDir: string, projectSlug: string, repoName: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/${repoName}-mirror` +} + +export function generateTechReadOnlyCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/tech/GITLAB_MIRROR` +} + +export function generateSonarqubeCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/SONAR` +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/version/version.controller.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/version/version.controller.ts new file mode 100644 index 0000000000..7dc6a91019 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/version/version.controller.ts @@ -0,0 +1,15 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import { Controller, Get, Inject } from '@nestjs/common' +import { BASE_CONFIG } from '../infrastructure/config/base.config' + +@Controller('api/v1/version') +export class VersionController { + constructor( + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + @Get() + getVersion() { + return { version: this.baseConfig.appVersion } + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/modules/version/version.module.ts b/.jjconflict-base-1/apps/server-nestjs/src/modules/version/version.module.ts new file mode 100644 index 0000000000..c537037b5b --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/modules/version/version.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common' +import { VersionController } from './version.controller' + +@Module({ + controllers: [VersionController], +}) +export class VersionModule {} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql new file mode 100644 index 0000000000..f2f4e7b0b7 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql @@ -0,0 +1,151 @@ +-- CreateTable +CREATE TABLE "Environment" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Environment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Log" ( + "id" UUID NOT NULL, + "data" JSONB NOT NULL, + "action" TEXT NOT NULL DEFAULT '', + "userId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Log_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Organization" ( + "id" UUID NOT NULL, + "source" TEXT NOT NULL, + "name" TEXT NOT NULL, + "label" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Permission" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "environmentId" UUID NOT NULL, + "level" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Permission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "organizationId" UUID NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL, + "locked" BOOLEAN NOT NULL DEFAULT false, + "services" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Repository" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "internalRepoName" TEXT NOT NULL, + "externalRepoUrl" TEXT NOT NULL, + "externalUserName" TEXT, + "externalToken" TEXT, + "isInfra" BOOLEAN NOT NULL DEFAULT false, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Repository_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" UUID NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Role" ( + "userId" UUID NOT NULL, + "projectId" UUID NOT NULL, + "role" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Role_pkey" PRIMARY KEY ("userId","projectId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_id_key" ON "Organization"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_name_key" ON "Organization"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_label_key" ON "Organization"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_id_key" ON "Permission"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_userId_environmentId_key" ON "Permission"("userId", "environmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_id_key" ON "Project"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Role_userId_projectId_key" ON "Role"("userId", "projectId"); + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql new file mode 100644 index 0000000000..26e1ade3f2 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql @@ -0,0 +1,85 @@ +-- CreateEnum +CREATE TYPE "ClusterPrivacy" AS ENUM ('public', 'dedicated'); + +-- CreateTable +CREATE TABLE "Cluster" ( + "id" UUID NOT NULL, + "label" VARCHAR(50) NOT NULL, + "privacy" "ClusterPrivacy" NOT NULL DEFAULT 'dedicated', + "secretName" VARCHAR(50) NOT NULL, + "clusterResources" BOOLEAN NOT NULL DEFAULT false, + "kubeConfigId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cluster_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Kubeconfig" ( + "id" UUID NOT NULL, + "user" JSONB NOT NULL, + "cluster" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "parentClusterId" UUID, + + CONSTRAINT "Kubeconfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_ClusterToEnvironment" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateTable +CREATE TABLE "_ClusterToProject" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_id_key" ON "Cluster"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_label_key" ON "Cluster"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_secretName_key" ON "Cluster"("secretName"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_kubeConfigId_key" ON "Cluster"("kubeConfigId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_id_key" ON "Kubeconfig"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_parentClusterId_key" ON "Kubeconfig"("parentClusterId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToEnvironment_AB_unique" ON "_ClusterToEnvironment"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToEnvironment_B_index" ON "_ClusterToEnvironment"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToProject_AB_unique" ON "_ClusterToProject"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToProject_B_index" ON "_ClusterToProject"("B"); + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_kubeConfigId_fkey" FOREIGN KEY ("kubeConfigId") REFERENCES "Kubeconfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_B_fkey" FOREIGN KEY ("B") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_B_fkey" FOREIGN KEY ("B") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql new file mode 100644 index 0000000000..8f3fb5ff9f --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `parentClusterId` on the `Kubeconfig` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Kubeconfig_parentClusterId_key"; + +-- AlterTable +ALTER TABLE "Kubeconfig" DROP COLUMN "parentClusterId"; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql new file mode 100644 index 0000000000..4eb37edc53 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql @@ -0,0 +1,10 @@ +CREATE TYPE "ProjectStatus" AS ENUM ('initializing', 'created', 'failed', 'archived'); + +ALTER TABLE public."Project" ALTER COLUMN status TYPE "ProjectStatus" USING + case + when status = 'created' then 'created'::"ProjectStatus" + when status = 'failed' then 'failed'::"ProjectStatus" + when status = 'archived' then 'archived'::"ProjectStatus" + else 'initializing'::"ProjectStatus" + end; +ALTER TABLE public."Project" ALTER COLUMN status SET DEFAULT 'initializing'; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql new file mode 100644 index 0000000000..f402a0e3d4 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "infos" VARCHAR(200); diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql new file mode 100644 index 0000000000..f553e7880e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `externalToken` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "externalToken"; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql new file mode 100644 index 0000000000..394b650a51 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql @@ -0,0 +1,81 @@ +-- CreateEnum +CREATE TYPE "QuotaStageStatus" AS ENUM +('active', 'pendingDelete'); + +-- Create new tables +-- CreateTable +CREATE TABLE "Quota" +( + "id" UUID NOT NULL, + "memory" VARCHAR NOT NULL, + "cpu" REAL NOT NULL, + "name" VARCHAR NOT NULL, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "Quota_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Stage" +( + "id" UUID NOT NULL, + "name" VARCHAR NOT NULL, + + CONSTRAINT "Stage_pkey" PRIMARY KEY ("id") +); + +-- Associate Quotas and Stages +-- CreateTable +CREATE TABLE "QuotaStage" +( + "id" UUID NOT NULL, + "quotaId" UUID NOT NULL, + "stageId" UUID NOT NULL, + "status" "QuotaStageStatus" NOT NULL DEFAULT 'active', + + CONSTRAINT "QuotaStage_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "Quota_id_key" ON "Quota"("id"); +CREATE UNIQUE INDEX "Quota_name_key" ON "Quota"("name"); +CREATE UNIQUE INDEX "Stage_id_key" ON "Stage"("id"); +CREATE UNIQUE INDEX "Stage_name_key" ON "Stage"("name"); +CREATE UNIQUE INDEX "QuotaStage_id_key" ON "QuotaStage"("id"); +CREATE UNIQUE INDEX "QuotaStage_quotaId_stageId_key" ON "QuotaStage"("quotaId", "stageId"); +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Create default values for Quotas and Stages +-- Quota +INSERT INTO "Quota" + (id, cpu, memory, "name", "isPrivate") +VALUES + ('5a57b62f-2465-4fb6-a853-5a751d099199', 2, '4Gi', 'small', false), + ('08770663-3b76-4af6-8978-9f75eda4faa7', 4, '8Gi', 'medium', false), + ('b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 6, '12Gi', 'large', false), + ('97b851e8-9067-4a3d-a0e8-c3a6820c49be', 8, '16Gi', 'xlarge', false); + +-- Stage +INSERT INTO "Stage" + (id, "name") +VALUES + ('4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', 'dev'), + ('38fa869d-6267-441d-af7f-e0548fd06b7e', 'staging'), + ('d434310e-7850-4d59-b47f-0772edf50582', 'integration'), + ('9b3e9991-896d-4d90-bdc5-a34be8c06b8f', 'prod'); + +-- QuotaStage +INSERT INTO "QuotaStage" + (id, "quotaId", "stageId") +VALUES + ('0cb0c549-560e-4f26-8f4e-832dd722f68a', '5a57b62f-2465-4fb6-a853-5a751d099199', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('0530e9c9-b37d-4dec-93e6-1895f700e61c', '5a57b62f-2465-4fb6-a853-5a751d099199', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('8a99db49-b7b1-44bf-865d-5e709e8aa0fc', '5a57b62f-2465-4fb6-a853-5a751d099199', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('67561f00-d219-4ca6-b94a-3ee83f09d2d6', '5a57b62f-2465-4fb6-a853-5a751d099199', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('8b3c201e-7518-4254-a94a-16c404e46936', '08770663-3b76-4af6-8978-9f75eda4faa7', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('9157ae12-3e39-43f8-a24f-ae5d9c6b69b7', '08770663-3b76-4af6-8978-9f75eda4faa7', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('c733a1dd-c9fd-4def-b29e-df49ef7b6698', '08770663-3b76-4af6-8978-9f75eda4faa7', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('15a51f47-0ab2-4a94-a808-722639d8c092', '08770663-3b76-4af6-8978-9f75eda4faa7', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('cb66e80c-2304-472d-bc19-a411011674ca', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('59fb0e79-3a76-4b96-81d4-63f4caa98cfa', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('4174b22c-2bee-4f4a-9d85-da7b5463f214', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql new file mode 100644 index 0000000000..8c98a7f74e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql @@ -0,0 +1,35 @@ +-- Multiplication des environnements par clusteurs +ALTER TABLE "Environment" ADD COLUMN "clusterId" UUID; + +DO +$$ +DECLARE + perm record; + cte record; + env_uuid UUID; +BEGIN + FOR cte IN SELECT "B" AS environmentId, "A" AS "clusterId", "name", "projectId", status, "updatedAt", "createdAt" + FROM public."_ClusterToEnvironment", public."Environment" + WHERE public."Environment".id = "B" + LOOP + env_uuid := gen_random_uuid(); + INSERT INTO public."Environment" (id, "name", "projectId", "clusterId", status, "createdAt", "updatedAt") VALUES + (env_uuid, cte."name", cte."projectId", cte."clusterId", cte.status, cte."createdAt", cte."updatedAt"); + + FOR perm in SELECT * FROM public."Permission" WHERE "environmentId" = cte.environmentId + LOOP + INSERT INTO public."Permission" (id, "level", "createdAt", "updatedAt", "environmentId", "userId") VALUES + (gen_random_uuid(), perm."level", perm."createdAt", perm."updatedAt", env_uuid, perm."userId"); + END LOOP; + END LOOP; +END; +$$ +; +DELETE FROM public."Environment" WHERE "clusterId" is null; +ALTER TABLE public."Environment" ALTER COLUMN "clusterId" SET NOT NULL; +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_clusterId_fkey" FOREIGN KEY ("clusterId") REFERENCES "Cluster"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Delete old _ClusterToEnvironment +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_A_fkey"; +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_B_fkey"; +DROP TABLE "_ClusterToEnvironment"; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql new file mode 100644 index 0000000000..035cd1c855 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql @@ -0,0 +1,36 @@ +-- Associate cluster to Stages +-- CreateTable +CREATE TABLE "_ClusterToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToStage_AB_unique" ON "_ClusterToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToStage_B_index" ON "_ClusterToStage"("B"); + +DO +$$ +DECLARE + cluster record; + cte record; + env_uuid UUID; +BEGIN + FOR cluster IN SELECT id + FROM public."Cluster" + LOOP + INSERT INTO public."_ClusterToStage" ("A", "B") VALUES + (cluster.id, '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + (cluster.id, '38fa869d-6267-441d-af7f-e0548fd06b7e'), + (cluster.id, 'd434310e-7850-4d59-b47f-0772edf50582'), + (cluster.id, '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); + END LOOP; +END; +$$ diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql new file mode 100644 index 0000000000..43793bdb49 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaStageId" UUID; +UPDATE "Environment" SET "quotaStageId" = '8b3c201e-7518-4254-a94a-16c404e46936' WHERE "name" = 'dev'; +UPDATE "Environment" SET "quotaStageId" = '9157ae12-3e39-43f8-a24f-ae5d9c6b69b7' WHERE "name" = 'staging'; +UPDATE "Environment" SET "quotaStageId" = '4174b22c-2bee-4f4a-9d85-da7b5463f214' WHERE "name" = 'integration'; +UPDATE "Environment" SET "quotaStageId" = 'de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a' WHERE "name" = 'prod'; +ALTER TABLE "Environment" ALTER COLUMN "name" SET DATA TYPE VARCHAR(11); +ALTER TABLE "Environment" ALTER COLUMN "quotaStageId" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaStageId_fkey" FOREIGN KEY ("quotaStageId") REFERENCES "QuotaStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql new file mode 100644 index 0000000000..9af004b6a3 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- lock all projects +UPDATE public."Project" SET "locked"=true \ No newline at end of file diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql new file mode 100644 index 0000000000..d970d3965a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- set all projects to failed to avoid unlock them +UPDATE public."Project" SET "status" = 'failed' WHERE "status" != 'archived' \ No newline at end of file diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql new file mode 100644 index 0000000000..c387d9885c --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ADD COLUMN "requestId" VARCHAR(21); diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql new file mode 100644 index 0000000000..18e20262c8 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `status` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the column `status` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "status"; + +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "status"; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql new file mode 100644 index 0000000000..f784b21567 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql @@ -0,0 +1,46 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "zoneId" UUID; + +-- CreateTable +CREATE TABLE "Zone" +( + "id" UUID NOT NULL, + "slug" VARCHAR(10) NOT NULL, + "label" VARCHAR(50) NOT NULL, + "description" VARCHAR(200), + "createdAt" TIMESTAMP +(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP +(3) NOT NULL, + + CONSTRAINT "Zone_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_id_key" ON "Zone"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_slug_key" ON "Zone"("slug"); + +-- Create default zone +INSERT INTO "Zone" + (id, "slug", "label", "description", "updatedAt") +VALUES + ('a66c4230-eba6-41f1-aae5-bb1e4f90cce0', 'default', 'Zone Défaut', 'Zone par défaut, à changer', CURRENT_TIMESTAMP); + +-- Set default zoneId for current clusters +UPDATE "Cluster" +SET "zoneId" += 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0' +WHERE "zoneId" +IS NULL; + +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "zoneId" +SET +NOT NULL; + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") +ON DELETE RESTRICT ON +UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql new file mode 100644 index 0000000000..cb4f7ad968 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "ProjectPlugin" ( + "pluginName" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "AdminPlugin" ( + "pluginName" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectPlugin_projectId_pluginName_key_key" ON "ProjectPlugin"("projectId", "pluginName", "key"); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminPlugin_pluginName_key_key" ON "AdminPlugin"("pluginName", "key"); + +-- AddForeignKey +ALTER TABLE "ProjectPlugin" ADD CONSTRAINT "ProjectPlugin_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql new file mode 100644 index 0000000000..11672324f8 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql @@ -0,0 +1,19 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id, services FROM public."Project" LOOP + -- Extrait 'registry.id' de la colonne JSON 'services' + registry_id := (SELECT (project_row.services -> 'registry' ->> 'id')::TEXT); + -- Si 'registry.id' existe, insérer dans la table 'config' + IF registry_id IS NOT NULL THEN + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'registry', 'projectId', registry_id::TEXT); + END IF; + END LOOP; +END $$; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "services"; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql new file mode 100644 index 0000000000..9c7d5a8f8f --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "RoleList" AS ENUM ('owner', 'user'); + +-- AlterTable +ALTER TABLE public."Role" ALTER COLUMN "role" TYPE "RoleList" USING + case + when role = 'owner' then 'owner'::"RoleList" + else 'user'::"RoleList" + end; \ No newline at end of file diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql new file mode 100644 index 0000000000..45a4a5d1ea --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "ProjectClusterHistory" ( + "projectId" UUID NOT NULL, + "clusterId" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectClusterHistory_projectId_clusterId_key" ON "ProjectClusterHistory"("projectId", "clusterId"); diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql new file mode 100644 index 0000000000..2b1641a657 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql @@ -0,0 +1,11 @@ +DO $$ +DECLARE + env_row RECORD; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR env_row IN SELECT "projectId", "clusterId" FROM public."Environment" LOOP + INSERT INTO public."ProjectClusterHistory" ("projectId", "clusterId") + VALUES (env_row."projectId", env_row."clusterId") + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql new file mode 100644 index 0000000000..5e7ff03d45 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql @@ -0,0 +1,58 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaId" UUID, +ADD COLUMN "stageId" UUID; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "_QuotaToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_QuotaToStage_AB_unique" ON "_QuotaToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_QuotaToStage_B_index" ON "_QuotaToStage"("B"); + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +DO $$ +DECLARE + quota_stage_row RECORD; +BEGIN + FOR quota_stage_row IN SELECT * FROM public."QuotaStage" loop + UPDATE public."Environment" SET "stageId" = quota_stage_row."stageId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + UPDATE public."Environment" SET "quotaId" = quota_stage_row."quotaId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + insert into public."_QuotaToStage" values (quota_stage_row."quotaId", quota_stage_row."stageId"); + END LOOP; +END $$; + +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaStageId_fkey"; + +-- AlterTable +ALTER TABLE "Environment" ALTER COLUMN "quotaId" SET NOT NULL, +ALTER COLUMN "stageId" SET NOT NULL, +DROP COLUMN "quotaStageId"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_stageId_fkey"; + +-- DropTable +DROP TABLE "QuotaStage"; + +-- DropEnum +DROP TYPE "QuotaStageStatus"; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql new file mode 100644 index 0000000000..0036da8a12 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Made the column `description` on table `Project` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "description" SET NOT NULL, +ALTER COLUMN "description" SET DEFAULT ''; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql new file mode 100644 index 0000000000..ed6ae9b84e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql @@ -0,0 +1,198 @@ +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_environmentId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_environmentId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_userId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_userId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_projectId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_projectId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_userId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_userId_fkey"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectMembers') THEN + CREATE TABLE "ProjectMembers" ( + "projectId" UUID NOT NULL, + "userId" UUID NOT NULL, + "roleIds" TEXT[] + ); + END IF; +END $$; + +-- AlterTable +ALTER TABLE "Log" ADD COLUMN IF NOT EXISTS "projectId" UUID; + +INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt") +VALUES('04ac168a-2c4f-4816-9cce-af6c612e5912'::uuid, 'Anonymous', 'User', 'anon@user', '2023-07-03 14:46:56.770', '2023-07-03 14:46:56.770') +ON CONFLICT (id) DO NOTHING; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "everyonePerms" BIGINT NOT NULL DEFAULT 896, +ADD COLUMN IF NOT EXISTS "ownerId" UUID; + +DO $$ +DECLARE + role_row RECORD; +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR role_row IN SELECT "userId", "projectId", "role" FROM public."Role" LOOP + INSERT INTO public."ProjectMembers" ("userId", "projectId", "roleIds") VALUES (role_row."userId", role_row."projectId", '{}'); + IF role_row."role" = 'owner'::public."RoleList" THEN + UPDATE public."Project" + SET "ownerId"=role_row."userId" + WHERE id=role_row."projectId"::uuid; + END IF; + END LOOP; + END IF; +END $$; + +UPDATE public."Project" +SET "ownerId"='04ac168a-2c4f-4816-9cce-af6c612e5912' +WHERE "ownerId" IS NULL; + +ALTER TABLE public."Project" ALTER COLUMN "ownerId" SET NOT NULL; + +DELETE FROM public."ProjectMembers" pm +USING public."Project" p +WHERE pm."userId" = p."ownerId" +AND pm."projectId" = p."id"; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Permission') THEN + DROP TABLE "Permission"; + END IF; +END $$; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + DROP TABLE "Role"; + END IF; +END $$; + +-- DropEnum if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'RoleList') THEN + DROP TYPE "RoleList"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'AdminRole') THEN + CREATE TABLE "AdminRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "AdminRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectRole') THEN + CREATE TABLE "ProjectRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "projectId" UUID NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "ProjectRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_id_key') THEN + CREATE UNIQUE INDEX "AdminRole_id_key" ON "AdminRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_name_key') THEN + CREATE UNIQUE INDEX "AdminRole_name_key" ON "AdminRole"("name"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectMembers_projectId_userId_key') THEN + CREATE UNIQUE INDEX "ProjectMembers_projectId_userId_key" ON "ProjectMembers"("projectId", "userId"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectRole_id_key') THEN + CREATE UNIQUE INDEX "ProjectRole_id_key" ON "ProjectRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'Environment_projectId_name_key') THEN + CREATE UNIQUE INDEX "Environment_projectId_name_key" ON "Environment"("projectId", "name"); + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Log_projectId_fkey') THEN + ALTER TABLE "Log" ADD CONSTRAINT "Log_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Project_ownerId_fkey') THEN + ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_projectId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_userId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectRole_projectId_fkey') THEN + ALTER TABLE "ProjectRole" ADD CONSTRAINT "ProjectRole_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "adminRoleIds" TEXT[]; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql new file mode 100644 index 0000000000..c9b41827bf --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "AdminRole_name_key"; + +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "oidcGroup" TEXT; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql new file mode 100644 index 0000000000..265f262abb --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `oidcGroup` on table `AdminRole` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable + +UPDATE public."AdminRole" +SET "oidcGroup"='' +WHERE "oidcGroup" IS NULL; + +ALTER TABLE "AdminRole" ALTER COLUMN "oidcGroup" SET NOT NULL, +ALTER COLUMN "oidcGroup" SET DEFAULT ''; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql new file mode 100644 index 0000000000..4fc2768605 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "SystemSetting" +( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "SystemSetting_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemSetting_key_key" ON "SystemSetting"("key"); + +-- Create maintenance setting +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('maintenance', 'off'); diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql new file mode 100644 index 0000000000..95ab54869d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql @@ -0,0 +1,3 @@ +INSERT INTO public."AdminRole" +(id, "name", permissions, "position", "oidcGroup") +VALUES('76229c96-4716-45bc-99da-00498ec9018c'::uuid, 'Admin', 2, 0, '/admin'); \ No newline at end of file diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql new file mode 100644 index 0000000000..c116482186 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Made the column `externalUserName` on table `Repository` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +UPDATE "Repository" SET "externalUserName" = '' WHERE "externalUserName" IS NULL; + +ALTER TABLE "Repository" ALTER COLUMN "externalUserName" SET NOT NULL, +ALTER COLUMN "externalUserName" SET DEFAULT '', +ALTER COLUMN "externalRepoUrl" SET DEFAULT ''; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql new file mode 100644 index 0000000000..b0472cd806 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "TokenStatus" AS ENUM ('active', 'revoked'); + +-- CreateTable +CREATE TABLE "AdminToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "userId" UUID, + "expirationDate" TIMESTAMP(3), + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "AdminToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminToken_id_key" ON "AdminToken"("id"); + +-- AddForeignKey +ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql new file mode 100644 index 0000000000..47488b00c5 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "Log" DROP CONSTRAINT "Log_userId_fkey"; + +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "userId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql new file mode 100644 index 0000000000..18eca3eadf --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "requestId" SET DATA TYPE VARCHAR(36); diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql new file mode 100644 index 0000000000..74e0946f0a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Zone" ADD COLUMN "argocdUrl" TEXT NOT NULL DEFAULT 'https://example.com'; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql new file mode 100644 index 0000000000..41dac75359 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ProjectStatus" ADD VALUE 'warning'; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql new file mode 100644 index 0000000000..ef888d5e50 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql @@ -0,0 +1,12 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id FROM public."Project" WHERE status <> 'archived'::public."ProjectStatus" LOOP + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'nexus', 'activateMavenRepo', 'enabled') + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql new file mode 100644 index 0000000000..a57e5956c3 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql @@ -0,0 +1,12 @@ +-- CreateEnum +CREATE TYPE "UserType" AS ENUM ('human', 'bot', 'ghost'); + +-- AlterEnum +ALTER TYPE "TokenStatus" ADD VALUE 'inactive'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "type" "UserType" NOT NULL DEFAULT 'human'; +UPDATE "User" SET type = 'ghost' WHERE id = '04ac168a-2c4f-4816-9cce-af6c612e5912'; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "type" DROP DEFAULT; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql new file mode 100644 index 0000000000..71e15a312c --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql @@ -0,0 +1,84 @@ +-- CreateTable (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'PersonalAccessToken') THEN + CREATE TABLE "PersonalAccessToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "userId" UUID NOT NULL, + "expirationDate" TIMESTAMP(3) NOT NULL, + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'PersonalAccessToken_id_key') THEN + CREATE UNIQUE INDEX "PersonalAccessToken_id_key" ON "PersonalAccessToken"("id"); + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'PersonalAccessToken_userId_fkey') THEN + ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- Process AdminToken (idempotent) +DO $$ +DECLARE + admin_token record; + user_uuid UUID; +BEGIN + FOR admin_token IN SELECT "name", "id" + FROM public."AdminToken" + LOOP + -- Generate new UUID if user does not exist + user_uuid := COALESCE( + (SELECT id FROM public."User" WHERE email = concat(admin_token.name, '@bot.id')), + gen_random_uuid() + ); + + -- Insert user if not already exists + INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt", "type") + VALUES(user_uuid, 'Bot Admin', admin_token.name, concat(admin_token.name, '@bot.id'), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'bot') + ON CONFLICT (id) DO NOTHING; + + -- Update AdminToken with the new user ID + UPDATE public."AdminToken" SET "userId" = user_uuid WHERE id = admin_token.id; + END LOOP; +END $$; + +-- Alter AdminToken userId column to NOT NULL (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'AdminToken' AND column_name = 'userId' AND is_nullable = 'NO') THEN + ALTER TABLE public."AdminToken" ALTER COLUMN "userId" SET NOT NULL; + END IF; +END $$; + +-- DropForeignKey if exists (idempotent) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" DROP CONSTRAINT "AdminToken_userId_fkey"; + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql new file mode 100644 index 0000000000..521b2b10ac --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastLogin" TIMESTAMP(3); diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql new file mode 100644 index 0000000000..a7500833b9 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "slug" TEXT; + +UPDATE public."Project" p +SET slug = ( + SELECT concat(org.name, '-', subp.name) FROM public."Project" subp + LEFT JOIN public."Organization" org on org."id" = subp."organizationId" + WHERE subp.id = p.id +); + +ALTER TABLE public."Project" ALTER COLUMN "slug" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug"); diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql new file mode 100644 index 0000000000..b143cbeb9b --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "lastSuccessProvisionningVersion" TEXT; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql new file mode 100644 index 0000000000..7a88681901 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToProject_AB_unique"; + +-- AlterTable +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToStage_AB_unique"; + +-- AlterTable +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_QuotaToStage_AB_unique"; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql new file mode 100644 index 0000000000..21ce77b8d5 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "external" BOOLEAN NOT NULL DEFAULT false; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql new file mode 100644 index 0000000000..ac63cd639d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql @@ -0,0 +1,25 @@ +-- Vérifie les versions dans la table Project +DO $$ +DECLARE + project_id TEXT; + project_name TEXT; + last_version TEXT; +BEGIN + -- Boucle sur les projets non archivés + FOR project_id, project_name, last_version IN ( + SELECT id, name, "lastSuccessProvisionningVersion" + FROM "Project" + WHERE "status" != 'archived' + ) + LOOP + -- Vérifie si la version est NULL + IF last_version IS NULL THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version NULL.', project_name, project_id; + END IF; + + -- Vérifie si la version est inférieure à 8.23.0 selon SemVer + IF (string_to_array(last_version, '.')::int[] < ARRAY[8,23,0]) THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version (%), inférieure à 8.23.0.', project_name, project_id, last_version; + END IF; + END LOOP; +END $$; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql new file mode 100644 index 0000000000..54871c9017 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `organizationId` on the `Project` table. All the data in the column will be lost. + - You are about to drop the `Organization` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Project" DROP CONSTRAINT "Project_organizationId_fkey"; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "organizationId"; + +-- DropTable +DROP TABLE "Organization"; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql new file mode 100644 index 0000000000..68ca0df2fa --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "infos" SET DATA TYPE VARCHAR(1000); diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql new file mode 100644 index 0000000000..8364090d8d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql @@ -0,0 +1,44 @@ +-- AlterTable +ALTER TABLE "Environment" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; + +COMMENT ON COLUMN "Environment".cpu IS 'CPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".gpu IS 'GPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".memory IS 'Memory value as GigaBytes (1 and 0.01 are valid values)'; + +-- Use values from Quota. Memory is an extract of q.memory numeric value as it contains a unit (e.g. '2Gi'). +UPDATE "Environment" +SET cpu = q.cpu, memory = COALESCE(NULLIF(regexp_replace(q.memory, '\D', '','g'), ''), '0')::numeric +FROM "Quota" q +WHERE "quotaId" = q."id"; + +/* + Warnings: + + - You are about to drop the column `quotaId` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the `Quota` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_QuotaToStage` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_B_fkey"; + +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "quotaId", +ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- DropTable +DROP TABLE "Quota"; + +-- DropTable +DROP TABLE "_QuotaToStage"; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql new file mode 100644 index 0000000000..77f32b5ab5 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Cluster" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql new file mode 100644 index 0000000000..decca804aa --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Project" +ADD COLUMN "limitless" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "hprodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodMemory" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodMemory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql new file mode 100644 index 0000000000..95f3a689db --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql @@ -0,0 +1,4 @@ +-- Rename default zone +UPDATE "Zone" +SET ("label", "description") = ('DSO', 'Zone par défaut') +WHERE slug = 'default'; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql new file mode 100644 index 0000000000..aadb6cdba9 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Repository" ADD COLUMN "deployRevision" TEXT, +ADD COLUMN "deployPath" TEXT, +ADD COLUMN "helmValuesFiles" TEXT; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql new file mode 100644 index 0000000000..15cf56ab82 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql @@ -0,0 +1,12 @@ +-- Eliminate NULLs +UPDATE "Repository" SET "deployRevision" = '' WHERE "deployRevision" IS NULL; +UPDATE "Repository" SET "deployPath" = '' WHERE "deployPath" IS NULL; +UPDATE "Repository" SET "helmValuesFiles" = '' WHERE "helmValuesFiles" IS NULL; + +-- AlterTable +ALTER TABLE "Repository" ALTER COLUMN "deployRevision" SET NOT NULL, +ALTER COLUMN "deployRevision" SET DEFAULT '', +ALTER COLUMN "deployPath" SET NOT NULL, +ALTER COLUMN "deployPath" SET DEFAULT '', +ALTER COLUMN "helmValuesFiles" SET NOT NULL, +ALTER COLUMN "helmValuesFiles" SET DEFAULT ''; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql new file mode 100644 index 0000000000..1292495121 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "autosync" BOOLEAN NOT NULL DEFAULT true; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql new file mode 100644 index 0000000000..04c98df036 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "oidcGroup" TEXT NOT NULL DEFAULT ''; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql new file mode 100644 index 0000000000..40da41a75d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- Update AdminRole system roles +UPDATE "AdminRole" SET "type" = 'system' WHERE "name" IN ('Admin', 'Admin Locaux'); + +-- Update ProjectRole system roles +UPDATE "ProjectRole" SET "type" = 'system' WHERE "name" IN ('Administrateur', 'DevOps', 'Développeur', 'Lecture seule'); diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql new file mode 100644 index 0000000000..92c200d261 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql @@ -0,0 +1,26 @@ +-- Update existing Admin role to be system role 'Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + "oidcGroup" = '/admin', + "position" = 0 +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Lecture Seule Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '35848aa2-e881-4770-9844-0c5c3693e506'::uuid, + 'Lecture Seule Plateforme', + 1, -- Assuming 1n means bit 0 + 2, + '/readonly', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Lecture Seule Plateforme', + "type" = 'system', + "permissions" = 1, + "oidcGroup" = '/readonly'; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql new file mode 100644 index 0000000000..2a4afa8971 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql @@ -0,0 +1,28 @@ +-- Update existing Admin role to be system role 'Root Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Root Administrateur Plateforme' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Administrateur Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf'::uuid, + 'Administrateur Plateforme', + 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + 0, + '/console/admin', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, + "oidcGroup" = '/console/admin'; + +-- Update 'Lecture Seule Plateforme' system role +UPDATE "AdminRole" +SET + "oidcGroup" = '/console/readonly' +WHERE "id" = '35848aa2-e881-4770-9844-0c5c3693e506'::uuid; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql new file mode 100644 index 0000000000..f0cdb04873 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `external` on the `Cluster` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Cluster" DROP COLUMN "external"; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql new file mode 100644 index 0000000000..e07cad777b --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql @@ -0,0 +1,5 @@ +-- Enable legacy default permissions flag +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('refined-permissions', 'off'); diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql new file mode 100644 index 0000000000..efb6bc67d4 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql @@ -0,0 +1,3 @@ +-- Remove 'refined-permissions' system setting +DELETE FROM "SystemSetting" +WHERE "key" = 'refined-permissions'; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql new file mode 100644 index 0000000000..d019403c44 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql @@ -0,0 +1,15 @@ +-- Add global 'Tout le monde' role +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '034f589f-1750-4b15-bb34-4cd995e7fcaa'::uuid, + 'Tout le monde', + 25608, -- MANAGE_PROJECTS | LIST_PROJECTS | LIST_ZONES | LIST_STAGES + 0, + '', + 'global' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Tout le monde', + "type" = 'global', + "permissions" = 25608; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql new file mode 100644 index 0000000000..4fd0d80eb2 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql @@ -0,0 +1,25 @@ +-- Alter existing column to change the default value from 'custom' to 'managed' for AdminRole type +ALTER TABLE "AdminRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Alter existing column to change the default value from 'custom' to 'managed' for ProjectRole type +ALTER TABLE "ProjectRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Migrate system roles to managed roles +UPDATE "AdminRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate system roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate custom roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'custom'; + +-- Update existing Admin role to be an externally managed role +UPDATE "AdminRole" +SET "type" = 'external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql new file mode 100644 index 0000000000..d2691666bd --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql @@ -0,0 +1,15 @@ +-- Migrate well-known project roles to the new "system:"-prefixed type +UPDATE "ProjectRole" +SET "type" = 'system:managed' +WHERE + "type" = 'managed' + AND "oidcGroup" ~ '^/[^/]+/console/(admin|devops|developer|readonly)$'; + +-- Migrate well-known admin roles to the new "system:"-prefixed type +UPDATE "AdminRole" +SET "type" = 'system:managed' +WHERE id IN ( + '76229c96-4716-45bc-99da-00498ec9018c', -- Root Administrateur Plateforme + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', -- Administrateur Plateforme + '35848aa2-e881-4770-9844-0c5c3693e506' -- Lecture Seule Plateforme +); diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql new file mode 100644 index 0000000000..23593ba4f4 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "hprodCpu" DROP DEFAULT, +ALTER COLUMN "hprodGpu" DROP DEFAULT, +ALTER COLUMN "hprodMemory" DROP DEFAULT, +ALTER COLUMN "prodCpu" DROP DEFAULT, +ALTER COLUMN "prodGpu" DROP DEFAULT, +ALTER COLUMN "prodMemory" DROP DEFAULT; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql new file mode 100644 index 0000000000..7e3eefb25d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql @@ -0,0 +1,4 @@ +-- Set the Root Administrateur Plateforme admin role to external system-managed type +UPDATE "AdminRole" +SET "type" = 'system:external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql new file mode 100644 index 0000000000..ca81594339 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "DeploymentSourceType" AS ENUM ('git', 'oci'); + +-- CreateTable +CREATE TABLE "Deployment" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "name" VARCHAR(11) NOT NULL, + "autosync" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "environmentId" UUID NOT NULL, + + CONSTRAINT "Deployment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeploymentSource" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deploymentId" UUID NOT NULL, + "repositoryId" UUID NOT NULL, + "type" "DeploymentSourceType" NOT NULL, + "targetRevision" TEXT NOT NULL DEFAULT '', + "path" TEXT NOT NULL DEFAULT '', + "helmValuesFiles" TEXT NOT NULL DEFAULT '', + + CONSTRAINT "DeploymentSource_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Deployment_id_key" ON "Deployment"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentSource_id_key" ON "DeploymentSource"("id"); + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "Deployment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql new file mode 100644 index 0000000000..ad5a64d336 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_environmentId_fkey"; + +-- DropForeignKey +ALTER TABLE "DeploymentSource" DROP CONSTRAINT "DeploymentSource_repositoryId_fkey"; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/migration_lock.toml b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000..044d57cdb0 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/admin.prisma b/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/admin.prisma new file mode 100644 index 0000000000..c4ca38ccb1 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/admin.prisma @@ -0,0 +1,21 @@ +model AdminPlugin { + pluginName String + key String + value String + + @@unique([pluginName, key]) +} + +model AdminRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") +} + +model SystemSetting { + key String @id @unique + value String +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/project.prisma b/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/project.prisma new file mode 100644 index 0000000000..6bcc5ce949 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/project.prisma @@ -0,0 +1,144 @@ +model Deployment { + id String @id @unique @default(uuid()) @db.Uuid + projectId String @db.Uuid + name String @db.VarChar(11) + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + environmentId String @db.Uuid + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model DeploymentSource { + id String @id @unique @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deploymentId String @db.Uuid + repositoryId String @db.Uuid + type DeploymentSourceType + targetRevision String @default("") + path String @default("") + helmValuesFiles String @default("") + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) + deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) +} + +model Environment { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(11) + projectId String @db.Uuid + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clusterId String @db.Uuid + stageId String @db.Uuid + cluster Cluster @relation(fields: [clusterId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + stage Stage @relation(fields: [stageId], references: [id]) + deployments Deployment[] + + @@unique([projectId, name]) +} + +model Repository { + id String @id @default(uuid()) @db.Uuid + projectId String @db.Uuid + internalRepoName String + externalRepoUrl String @default("") + externalUserName String @default("") + isInfra Boolean @default(false) + isPrivate Boolean @default(false) + deployRevision String @default("") + deployPath String @default("") + helmValuesFiles String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model ProjectClusterHistory { + projectId String @db.Uuid + clusterId String @db.Uuid + + @@unique([projectId, clusterId]) +} + +model ProjectMembers { + projectId String @db.Uuid + userId String @db.Uuid + roleIds String[] + project Project @relation(fields: [projectId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@unique([projectId, userId]) +} + +model ProjectPlugin { + pluginName String + projectId String @db.Uuid + key String + value String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, pluginName, key]) +} + +model ProjectRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + projectId String @db.Uuid + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") + project Project @relation(fields: [projectId], references: [id]) +} + +model Project { + id String @id @unique @default(uuid()) @db.Uuid + name String + description String @default("") + status ProjectStatus @default(initializing) + locked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + everyonePerms BigInt @default(896) + ownerId String @db.Uuid + environments Environment[] + logs Log[] + owner User @relation(fields: [ownerId], references: [id]) + members ProjectMembers[] + plugins ProjectPlugin[] + roles ProjectRole[] + repositories Repository[] + clusters Cluster[] @relation("ClusterToProject") + slug String @unique + limitless Boolean @default(true) + hprodCpu Float @db.Real + hprodGpu Float @db.Real + hprodMemory Float @db.Real + prodCpu Float @db.Real + prodGpu Float @db.Real + prodMemory Float @db.Real + lastSuccessProvisionningVersion String? + deployments Deployment[] +} + +enum ProjectStatus { + initializing + created + failed + archived + warning +} + +enum DeploymentSourceType { + git + oci +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/schema.prisma b/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/schema.prisma new file mode 100644 index 0000000000..aadf7fea13 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/schema.prisma @@ -0,0 +1,21 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DB_URL") +} + +model Log { + id String @id @default(uuid()) @db.Uuid + data Json + action String @default("") + userId String? @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + requestId String? @db.VarChar(36) + projectId String? @db.Uuid + project Project? @relation(fields: [projectId], references: [id]) + user User? @relation(fields: [userId], references: [id]) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/token.prisma b/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/token.prisma new file mode 100644 index 0000000000..c0c55751c5 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/token.prisma @@ -0,0 +1,30 @@ +model AdminToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + userId String @db.Uuid + expirationDate DateTime? + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +model PersonalAccessToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + userId String @db.Uuid + expirationDate DateTime + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +enum TokenStatus { + active + revoked + inactive +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/topography.prisma b/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/topography.prisma new file mode 100644 index 0000000000..63d90abb8a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/topography.prisma @@ -0,0 +1,52 @@ +model Cluster { + id String @id @unique @default(uuid()) @db.Uuid + label String @unique @db.VarChar(50) + privacy ClusterPrivacy @default(dedicated) + secretName String @unique @default(uuid()) @db.VarChar(50) + clusterResources Boolean @default(false) + kubeConfigId String @unique @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + infos String? @db.VarChar(1000) + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + zoneId String @db.Uuid + kubeconfig Kubeconfig @relation(fields: [kubeConfigId], references: [id], onDelete: Cascade) + zone Zone @relation(fields: [zoneId], references: [id]) + environments Environment[] + projects Project[] @relation("ClusterToProject") + stages Stage[] @relation("ClusterToStage") +} + +model Kubeconfig { + id String @id @unique @default(uuid()) @db.Uuid + user Json + cluster Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + parentCluster Cluster? +} + +model Stage { + id String @id @unique @default(uuid()) @db.Uuid + name String @unique @db.VarChar + environments Environment[] + clusters Cluster[] @relation("ClusterToStage") +} + +model Zone { + id String @id @unique @default(uuid()) @db.Uuid + slug String @unique @db.VarChar(10) + label String @db.VarChar(50) + description String? @db.VarChar(200) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + argocdUrl String @default("https://example.com") + clusters Cluster[] +} + +enum ClusterPrivacy { + public + dedicated +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/user.prisma b/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/user.prisma new file mode 100644 index 0000000000..e90fb69f83 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/prisma/schema/user.prisma @@ -0,0 +1,23 @@ +model User { + id String @id @db.Uuid + firstName String + lastName String + email String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? + adminRoleIds String[] + type UserType + + logs Log[] + projectsOwned Project[] + adminTokens AdminToken[] + projectMembers ProjectMembers[] + personalAccessTokens PersonalAccessToken[] +} + +enum UserType { + human + bot + ghost +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/utils/crypto.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/utils/crypto.spec.ts new file mode 100644 index 0000000000..090c58583a --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/utils/crypto.spec.ts @@ -0,0 +1,33 @@ +import { generateProjectKey as legacyGenerateProjectKey } from '@cpn-console/hooks' +import { describe, expect, it } from 'vitest' +import { generateProjectKey } from './crypto.utils' + +describe('generateProjectKey', () => { + it('matches the legacy @cpn-console/hooks implementation byte-for-byte', () => { + const cases: [string, string][] = [ + ['my-app', 'my-repo'], + ['my-app', 'front'], + ['my-app-2', 'front'], + ['slug-with-many-dashes', 'repo-with-dashes'], + ['a', 'b'], + ] + for (const [slug, repo] of cases) { + expect(generateProjectKey(slug, repo)).toBe(legacyGenerateProjectKey(slug, repo)) + } + }) + + it('produces stable known keys', () => { + // Golden values: existing SonarQube projects were created with these exact keys, + // and reconciliation recomputes them to decide ownership. Any change here means + // the console would stop recognizing (and could delete) existing projects. + expect(generateProjectKey('my-app', 'my-repo')).toBe('my-app-my-repo-923f') + expect(generateProjectKey('my-app', 'front')).toBe('my-app-front-1013') + expect(generateProjectKey('my-app', 'api')).toBe('my-app-api-a814') + }) + + it('disambiguates identical slug-repo concatenations via the hash suffix', () => { + // "my-app" + "front-api" and "my-app-front" + "api" share the prefix + // "my-app-front-api-"; only the repo hash tells them apart. + expect(generateProjectKey('my-app', 'front-api')).not.toBe(generateProjectKey('my-app-front', 'api')) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/utils/crypto.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/utils/crypto.utils.ts new file mode 100644 index 0000000000..f12bebcd13 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/utils/crypto.utils.ts @@ -0,0 +1,18 @@ +import crypto, { createHmac } from 'node:crypto' + +export function generateRandomPassword(length = 24) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*' + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +// Must stay byte-for-byte identical to the legacy @cpn-console/hooks implementation: +// existing SonarQube project keys were generated with it, and ownership matching +// recomputes keys to decide which projects to reconcile or delete. +export function generateProjectKey(projectSlug: string, internalRepoName: string) { + const repoHash = createHmac('sha256', '') + .update(internalRepoName) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${internalRepoName}-${repoHash}` +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/utils/dotenv.utils.spec.ts b/.jjconflict-base-1/apps/server-nestjs/src/utils/dotenv.utils.spec.ts new file mode 100644 index 0000000000..b5c7014c46 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/utils/dotenv.utils.spec.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDotenvPaths, getExistingDotenvPaths } from './dotenv.utils' + +describe('dotenv.utils', () => { + afterEach(() => vi.unstubAllEnvs()) + + describe('getDotenvPaths', () => { + it('returns [".env"] when no env vars set', () => { + expect(getDotenvPaths()).toEqual(['.env']) + }) + + it('returns [".env", ".env.integ"] when INTEGRATION=true', () => { + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env', '.env.integ']) + }) + + it('returns [".env.docker"] when DOCKER=true', () => { + vi.stubEnv('DOCKER', 'true') + expect(getDotenvPaths()).toEqual(['.env.docker']) + }) + + it('returns [".env.integ", ".env.docker"] when DOCKER=true and INTEGRATION=true', () => { + vi.stubEnv('DOCKER', 'true') + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env.integ', '.env.docker']) + }) + }) + + describe('getExistingDotenvPaths', () => { + it('filters to only existing files', () => { + const paths = getExistingDotenvPaths() + expect(paths.every(p => typeof p === 'string')).toBe(true) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/src/utils/dotenv.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/utils/dotenv.utils.ts new file mode 100644 index 0000000000..7d3e0a0501 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/utils/dotenv.utils.ts @@ -0,0 +1,26 @@ +import fs from 'node:fs' + +export function getDotenvPaths(): string[] { + const paths: string[] = [] + + // Load .env unless DOCKER=true + if (process.env.DOCKER !== 'true') { + paths.push('.env') + } + + // Load .env.integ if INTEGRATION=true + if (process.env.INTEGRATION === 'true') { + paths.push('.env.integ') + } + + // Load .env.docker if DOCKER=true + if (process.env.DOCKER === 'true') { + paths.push('.env.docker') + } + + return paths +} + +export function getExistingDotenvPaths(): string[] { + return getDotenvPaths().filter(path => fs.existsSync(path)) +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/utils/http.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/utils/http.utils.ts new file mode 100644 index 0000000000..b85cc2f78b --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/utils/http.utils.ts @@ -0,0 +1,20 @@ +/** HTTP status carried by errors such as keycloak-admin-client's NetworkError. */ +export function getErrorResponseStatus(error: unknown): number | undefined { + if (error instanceof Error && 'response' in error && error.response instanceof Response) { + return error.response.status + } + return undefined +} + +/** HTTP details (status, url, response body) attached to errors carrying a fetch Response. */ +export function getErrorHttpDetails(error: Error): Record { + const details: Record = {} + if ('response' in error && error.response instanceof Response) { + details.status = error.response.status + details.url = error.response.url + } + if ('responseData' in error && error.responseData !== undefined && error.responseData !== '') { + details.responseData = error.responseData + } + return details +} diff --git a/.jjconflict-base-1/apps/server-nestjs/src/utils/iterable.utils.ts b/.jjconflict-base-1/apps/server-nestjs/src/utils/iterable.utils.ts new file mode 100644 index 0000000000..a46ef80632 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/src/utils/iterable.utils.ts @@ -0,0 +1,26 @@ +export async function* map( + iterable: AsyncIterable, + mapper: (value: T, index: number) => U | Promise, +): AsyncIterable { + let index = 0 + for await (const value of iterable) { + yield await mapper(value, index++) + } +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} + +export async function find(generator: AsyncGenerator, predicate: (item: T) => boolean): Promise { + for await (const item of generator) { + if (predicate(item)) return item + } + return undefined +} diff --git a/.jjconflict-base-1/apps/server-nestjs/test/argocd.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/argocd.e2e-spec.ts new file mode 100644 index 0000000000..65e0ea7497 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/argocd.e2e-spec.ts @@ -0,0 +1,314 @@ +import type { CommitAction, Gitlab } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { baseConfigFactory } from '../src/config/base.config' +import { projectSelect } from '../src/modules/argocd/argocd-datastore.service' +import { ArgoCDModule } from '../src/modules/argocd/argocd.module' +import { ArgoCDService } from '../src/modules/argocd/argocd.service' +import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunArgoCDE2E + = Boolean(process.env.E2E) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + && Boolean(process.env.DB_URL) + +const describeWithArgoCD = describe.runIf(canRunArgoCDE2E) + +describeWithArgoCD('ArgoCDController (e2e)', {}, () => { + let moduleRef: TestingModule + let argocdController: ArgoCDService + let gitlab: GitlabClientService + let gitlabClient: Gitlab + let vault: VaultClientService + let prisma: PrismaService + let config: BaseConfig + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + let zoneId: string + let zoneSlug: string + let kubeconfigId: string + let clusterId: string + let clusterLabel: string + let stageId: string + let envDevId: string + let envProdId: string + let envDevName: string + let envProdName: string + + let infraRepoId: number | undefined + let infraRepoPath: string + let vaultProjectValuesPath: string | undefined + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ArgoCDModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + argocdController = moduleRef.get(ArgoCDService) + gitlab = moduleRef.get(GitlabClientService) + gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) + vault = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + config = moduleRef.get(baseConfigFactory) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + zoneId = faker.string.uuid() + zoneSlug = faker.string.alphanumeric({ length: 10 }).toLowerCase() + kubeconfigId = faker.string.uuid() + clusterId = faker.string.uuid() + clusterLabel = faker.helpers.slugify(`cluster-${faker.string.uuid()}`.slice(0, 40)) + stageId = faker.string.uuid() + envDevId = faker.string.uuid() + envProdId = faker.string.uuid() + envDevName = 'dev' + envProdName = 'prod' + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.zone.create({ + data: { + id: zoneId, + slug: zoneSlug, + label: `Zone ${zoneSlug}`, + argocdUrl: 'https://example.com', + }, + }) + + await prisma.kubeconfig.create({ + data: { + id: kubeconfigId, + user: { token: faker.string.alphanumeric({ length: 16 }) }, + cluster: { server: 'https://example.com' }, + }, + }) + + await prisma.cluster.create({ + data: { + id: clusterId, + label: clusterLabel, + secretName: faker.string.uuid(), + kubeConfigId: kubeconfigId, + infos: null, + memory: 100, + cpu: 100, + gpu: 0, + zoneId, + }, + }) + + await prisma.stage.create({ + data: { + id: stageId, + name: faker.helpers.slugify(`stage-${faker.string.uuid()}`), + }, + }) + + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + clusters: { + connect: { id: clusterId }, + }, + repositories: { + create: { + internalRepoName: zoneSlug, + isInfra: true, + deployRevision: 'HEAD', + deployPath: '.', + helmValuesFiles: '', + }, + }, + environments: { + create: [ + { + id: envDevId, + name: envDevName, + clusterId, + stageId, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + }, + { + id: envProdId, + name: envProdName, + clusterId, + stageId, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + }, + ], + }, + }, + }) + + infraRepoPath = `${config.projectsRootDir}/infra/${zoneSlug}` + try { + const existing = await gitlabClient.Projects.show(infraRepoPath) + if (existing.empty_repo || existing.default_branch !== 'main') { + await gitlabClient.Projects.remove(existing.id).catch(() => {}) + throw new Error('Recreate infra repo') + } + infraRepoId = existing.id + } catch (error: any) { + const description = error?.cause?.description ?? '' + if ( + !(typeof description === 'string' && description.includes('404')) + && !(error instanceof Error && error.message === 'Recreate infra repo') + ) { + throw error + } + + const infraGroup = await gitlab.getOrCreateProjectSubGroup('infra') + const created = await gitlabClient.Projects.create({ + name: zoneSlug, + path: zoneSlug, + namespaceId: infraGroup.id, + initializeWithReadme: true, + defaultBranch: 'main', + }) + infraRepoId = created.id + } + + vaultProjectValuesPath = `${config.projectsRootDir}/${testProjectId}` + await vault.write({ e2e: true }, vaultProjectValuesPath) + }) + + afterAll(async () => { + if (vaultProjectValuesPath) { + await vault.delete(vaultProjectValuesPath).catch(() => {}) + } + + if (infraRepoId) { + await gitlabClient.Projects.remove(infraRepoId).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.stage.deleteMany({ where: { id: stageId } }).catch(() => {}) + await prisma.cluster.deleteMany({ where: { id: clusterId } }).catch(() => {}) + await prisma.kubeconfig.deleteMany({ where: { id: kubeconfigId } }).catch(() => {}) + await prisma.zone.deleteMany({ where: { id: zoneId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should commit environment values and cleanup stale values in the zone infra repo', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + const infraProject = await gitlab.getOrCreateInfraGroupRepo(zoneSlug) + infraRepoId = infraProject.id + + const staleFilePath = `${project.name}/${clusterLabel}/stale/values.yaml` + if (!infraRepoId) throw new Error('Missing infra repo id') + const staleAction = await gitlab.generateCreateOrUpdateAction(infraProject, 'main', staleFilePath, 'stale: true\n') + await gitlab.maybeCreateCommit(infraProject, 'ci: :robot_face: Seed stale values', staleAction ? [staleAction] : []) + + await argocdController.handleUpsert(project) + + const expectedFilePath = `${project.name}/${clusterLabel}/${envDevName}/values.yaml` + const file = await gitlabClient.RepositoryFiles.show(infraRepoId, expectedFilePath, 'main') + const raw = Buffer.from(file.content, 'base64').toString('utf8') + const values = parse(raw) + + expect(values?.common?.['dso/project.slug']).toBe(testProjectSlug) + expect(values?.common?.['dso/environment']).toBe(envDevName) + expect(values?.environment?.valueFilePath).toBe(expectedFilePath) + expect(values?.application?.destination?.name).toBe(clusterLabel) + expect(values?.application?.autosync).toBe(true) + + const shouldBeDeleted = await gitlab.getFile(infraProject, staleFilePath, 'main') + expect(shouldBeDeleted).toBeUndefined() + }, 144000) + + it('should update existing values and delete values of a removed environment', async () => { + const before = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + const infraProject = await gitlab.getOrCreateInfraGroupRepo(zoneSlug) + infraRepoId = infraProject.id + + const devFilePath = `${before.name}/${clusterLabel}/${envDevName}/values.yaml` + const prodFilePath = `${before.name}/${clusterLabel}/${envProdName}/values.yaml` + + const seededActions = (await Promise.all([ + gitlab.generateCreateOrUpdateAction(infraProject, 'main', devFilePath, 'old: true\n'), + gitlab.generateCreateOrUpdateAction(infraProject, 'main', prodFilePath, 'old: true\n'), + ])).filter((action): action is NonNullable => action !== null) + await gitlab.maybeCreateCommit(infraProject, 'ci: :robot_face: Seed existing values', seededActions as CommitAction[]) + + await prisma.environment.deleteMany({ where: { id: envProdId } }) + + const after = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await argocdController.handleUpsert(after) + + const updatedDev = await gitlabClient.RepositoryFiles.show(infraRepoId, devFilePath, 'main') + const devRaw = Buffer.from(updatedDev.content, 'base64').toString('utf8') + const devValues = parse(devRaw) + expect(devValues?.common?.['dso/project.slug']).toBe(testProjectSlug) + expect(devValues?.common?.['dso/environment']).toBe(envDevName) + + const prodFile = await gitlab.getFile(infraProject, prodFilePath, 'main') + expect(prodFile).toBeUndefined() + }, 72000) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/test/gitlab.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/gitlab.e2e-spec.ts new file mode 100644 index 0000000000..e8d19c497b --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/gitlab.e2e-spec.ts @@ -0,0 +1,242 @@ +import type { ExpandedUserSchema, Gitlab } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' +import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' +import { projectSelect } from '../src/modules/gitlab/gitlab-datastore.service' +import { GitlabModule } from '../src/modules/gitlab/gitlab.module' +import { GitlabService } from '../src/modules/gitlab/gitlab.service' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunGitlabE2E + = Boolean(process.env.E2E) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + && Boolean(process.env.DB_URL) + +const describeWithGitLab = describe.runIf(canRunGitlabE2E) + +describeWithGitLab('GitlabController (e2e)', {}, () => { + let moduleRef: TestingModule + let gitlabController: GitlabService + let gitlabService: GitlabClientService + let gitlabClient: Gitlab + let vaultService: VaultClientService + let prisma: PrismaService + let config: BaseConfig + + let testProjectId: string + let testProjectSlug: string + let ownerId: string + let ownerUser: ExpandedUserSchema + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [GitlabModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + gitlabController = moduleRef.get(GitlabService) + gitlabService = moduleRef.get(GitlabClientService) + gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) + vaultService = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + config = moduleRef.get(baseConfigFactory) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + const ownerEmail = `test-owner-${ownerId}@example.com` + + // Create owner in GitLab + ownerUser = await gitlabClient.Users.create({ + name: 'Test Owner', + password: faker.internet.password({ length: 24 }), + username: `test-owner-${ownerId}`, + email: ownerEmail, + skipConfirmation: true, + }) + + // Create owner in DB + await prisma.user.create({ + data: { + id: ownerId, + email: ownerUser.email.toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + await prisma.repository.create({ + data: { + projectId: testProjectId, + internalRepoName: 'app', + externalRepoUrl: 'https://example.com/example.git', + isPrivate: false, + }, + }) + }) + + afterAll(async () => { + // Clean GitLab group + if (testProjectSlug && config.projectsRootDir) { + const fullPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = await gitlabService.getGroupByPath(fullPath) + if (group) { + await gitlabService.deleteGroup(group).catch(() => {}) + } + } + + // Clean Vault + if (testProjectSlug && config.projectsRootDir) { + const vaultPath = `${config.projectsRootDir}/${testProjectSlug}` + await vaultService.delete(`${vaultPath}/tech/GITLAB_MIRROR`).catch(() => {}) + await vaultService.delete(`${vaultPath}/app-mirror`).catch(() => {}) + } + + // Clean DB + if (prisma) { + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + if (ownerUser?.id) { + await gitlabClient.Users.remove(ownerUser.id).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should reconcile and create project group in GitLab and Vault secrets', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await gitlabController.handleUpsert(project) + + // Assert + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = z.object({ + id: z.number(), + name: z.string(), + full_path: z.string(), + web_url: z.string(), + }).parse(await gitlabService.getGroupByPath(groupPath)) + expect(group.full_path).toBe(groupPath) + + // Check membership + const members = await gitlabService.getGroupMembers(group) + const isMember = members.some(m => m.id === ownerUser.id) + expect(isMember).toBe(true) + + const repoVaultPath = `${config.projectsRootDir}/${testProjectSlug}/app-mirror` + const repoSecret = await vaultService.read(repoVaultPath) + expect(repoSecret?.data?.GIT_OUTPUT_USER).toBeTruthy() + expect(repoSecret?.data?.GIT_OUTPUT_PASSWORD).toBeTruthy() + }, 72000) + + describe('project members', () => { + let newUserId: string | undefined + let newUserGitlabId: number | undefined + + beforeAll(async () => { + newUserId = faker.string.uuid() + const newUser = await gitlabClient.Users.create({ + email: faker.internet.email().toLowerCase(), + username: faker.internet.username(), + name: `${faker.person.firstName()} ${faker.person.lastName()}`, + password: faker.internet.password({ length: 24 }), + skipConfirmation: true, + }) + newUserGitlabId = newUser.id + + await prisma.user.create({ + data: { + id: newUserId, + email: newUser.email, + firstName: 'Test', + lastName: 'User', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: newUserId, + roleIds: [], + }, + }) + }) + + afterAll(async () => { + if (newUserGitlabId) { + await gitlabClient.Users.remove(newUserGitlabId).catch(() => {}) + } + if (prisma && newUserId) { + await prisma.projectMembers.deleteMany({ where: { userId: newUserId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: newUserId } }).catch(() => {}) + } + }) + + it('should add member to GitLab group when added in DB', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await gitlabController.handleUpsert(project) + + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = z.object({ + id: z.number(), + name: z.string(), + web_url: z.string(), + }).parse(await gitlabService.getGroupByPath(groupPath)) + + const members = await gitlabService.getGroupMembers(group) + const isNewMemberPresent = members.some(m => m.id === newUserGitlabId) + expect(isNewMemberPresent).toBe(true) + }, 72000) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/test/keycloak.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/keycloak.e2e-spec.ts new file mode 100644 index 0000000000..9875253678 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -0,0 +1,537 @@ +import type KcAdminClient from '@keycloak/keycloak-admin-client' +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Logger } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { projectSelect } from '../src/modules/keycloak/keycloak-datastore.service' +import { KeycloakModule } from '../src/modules/keycloak/keycloak.module' +import { KeycloakService } from '../src/modules/keycloak/keycloak.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunKeycloakE2E + = Boolean(process.env.E2E) + && Boolean(process.env.KEYCLOAK_DOMAIN) + && Boolean(process.env.KEYCLOAK_REALM) + && Boolean(process.env.KEYCLOAK_PROTOCOL) + && Boolean(process.env.KEYCLOAK_ADMIN) + && Boolean(process.env.KEYCLOAK_ADMIN_PASSWORD) + +const describeWithKeycloak = describe.runIf(canRunKeycloakE2E) + +describeWithKeycloak('KeycloakController (e2e)', () => { + let moduleRef: TestingModule + let keycloakController: KeycloakService + let keycloak: KeycloakClientService + let keycloakAdminClient: KcAdminClient + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + let testRoleName: string + let testRoleId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [KeycloakModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + keycloakController = moduleRef.get(KeycloakService) + keycloak = moduleRef.get(KeycloakClientService) + keycloakAdminClient = moduleRef.get(KEYCLOAK_ADMIN_CLIENT) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + testRoleName = faker.helpers.slugify(`test-role-${faker.string.uuid()}`) + testRoleId = faker.string.uuid() + + const ownerEmail = faker.internet.email({ firstName: 'test-owner', provider: 'example.com' }) + + // Create owner in Keycloak + const createdUser = await keycloakAdminClient.users.create({ + id: ownerId, + username: `test-owner-${ownerId}`, + email: ownerEmail, + enabled: true, + firstName: 'Test', + lastName: 'Owner', + }) + if (createdUser.id) { + ownerId = createdUser.id + } + + // Create owner in DB + await prisma.user.create({ + data: { + id: ownerId, + email: ownerEmail, + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + try { + // Clean Keycloak + const group = await keycloak.getGroupByPath(`/${testProjectSlug}`) + if (group?.id) { + await keycloak.deleteGroup(group.id) + } + + // Clean owner user + if (ownerId) { + await keycloakAdminClient.users.del({ id: ownerId }).catch(() => {}) + if (prisma) { + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + } + + // Clean DB + if (prisma) { + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId } }) + // Prisma cascade delete should handle roles/envs if configured correctly, but explicit delete is safer + // We catch errors to avoid failing cleanup if tables/relations are different + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + } + } catch (e: any) { + Logger.warn(`Cleanup failed: ${e.message}`) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('should reconcile and create groups in Keycloak', async () => { + // Create Project in DB + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + roles: { + create: { + id: testRoleId, + name: testRoleName, + oidcGroup: `/${testRoleName}`, + permissions: BigInt(0), + position: 0, + }, + }, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await keycloakController.handleUpsert(project) + + // Assert + // Check main project group + const projectGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + expect(projectGroup.name).toBe(testProjectSlug) + + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console`)) + expect(consoleGroup.name).toBe('console') + + // Check role group + const roleGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(roleGroup.name).toBe(testRoleName) + + // Check membership (owner should be added) + const members = await keycloak.getGroupMembers(projectGroup.id) + const isMember = members.some(m => m.id === ownerId) + expect(isMember).toBe(true) + }, 60000) + + it('should add member to project group when added in DB', async () => { + // Create another user in Keycloak and DB + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-${newUserId}@example.com` + + // Create in Keycloak + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'User', + }) + + // Create in DB + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'User', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [testRoleId], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await keycloakController.handleUpsert(project) + + // Assert + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + const members = await keycloak.getGroupMembers(projectGroup.id) + const isMember = members.some(m => m.id === kcUser.id) + expect(isMember).toBe(true) + + // Check role group membership + const roleGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + const roleMembers = await keycloak.getGroupMembers(roleGroup.id) + const isRoleMember = roleMembers.some(m => m.id === kcUser.id) + expect(isRoleMember).toBe(true) + + // Cleanup user + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should remove member from project group when removed in DB', async () => { + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-remove-${newUserId}@example.com` + + // Create in Keycloak + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-remove-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserRemove', + }) + + // Create in DB + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserRemove', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [], // No roles + }, + }) + + let project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync add + await keycloakController.handleUpsert(project) + + // Verify added + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Remove from DB + await prisma.projectMembers.delete({ + where: { + projectId_userId: { + projectId: testProjectId, + userId: kcUser.id, + }, + }, + }) + + project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync remove + await keycloakController.handleUpsert(project) + + // Verify removed + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should handle non-existent users gracefully', async () => { + // Add a member in DB that does not exist in Keycloak + const fakeUserId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: fakeUserId, + email: `fake-${fakeUserId}@example.com`, + firstName: 'Fake', + lastName: 'User', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: fakeUserId, + roleIds: [], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act - should not throw + await expect(keycloakController.handleUpsert(project)).resolves.not.toThrow() + + // Cleanup + await prisma.projectMembers.deleteMany({ where: { userId: fakeUserId } }) + await prisma.user.delete({ where: { id: fakeUserId } }) + }, 60000) + + it('should add user back to Keycloak group if missing but present in DB', async () => { + // Create user and add to project in DB + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-sync-${newUserId}@example.com` + + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-sync-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserSync', + }) + + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserSync', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync to ensure they are added initially + await keycloakController.handleUpsert(project) + + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + + // Manually remove user from Keycloak group + await keycloak.removeUserFromGroup(kcUser.id, projectGroup.id) + + // Verify removal + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Sync again + await keycloakController.handleUpsert(project) + + // Verify added back + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should remove user from Keycloak group if present but missing in DB', async () => { + // Create user + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-orphan-${newUserId}@example.com` + + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-orphan-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserOrphan', + }) + + // We only need them in Keycloak for this test, but the controller checks if user is in DB to define "missing". + // Actually, `deleteExtraProjectMembers` iterates over Keycloak group members. + // So we don't strictly need the user in DB, but to be "clean" we should probably have them in DB but NOT in the project. + + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserOrphan', + type: 'human', + }, + }) + + // Get project from DB (user is NOT a member) + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync to create group + await keycloakController.handleUpsert(project) + + // Manually add user to Keycloak group + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + await keycloak.addUserToGroup(kcUser.id, projectGroup.id) + + // Verify added + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Sync again to remove user + await keycloakController.handleUpsert(project) + + // Verify removed + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should recreate project group if deleted in Keycloak', async () => { + // Ensure project exists and is synced + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await keycloakController.handleUpsert(project) + + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + + // Delete group in Keycloak + await keycloak.deleteGroup(projectGroup.id) + + // Verify deleted + const deletedProjectGroup = await keycloak.getGroupByPath(`/${testProjectSlug}`) + expect(deletedProjectGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedProjectGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + expect(recreatedProjectGroup?.name).toBe(testProjectSlug) + }, 60000) + + it('should recreate role group if deleted in Keycloak', async () => { + // Ensure project exists and is synced + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await keycloakController.handleUpsert(project) + + const roleGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + + // Delete role group in Keycloak + await keycloak.deleteGroup(roleGroup.id) + + // Verify deleted + const deletedRoleGroup = await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`) + expect(deletedRoleGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedRoleGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(recreatedRoleGroup?.name).toBe(testRoleName) + }, 60000) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/test/log.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/log.e2e-spec.ts new file mode 100644 index 0000000000..2b5f509b3f --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/log.e2e-spec.ts @@ -0,0 +1,188 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { LogModule } from '../src/modules/log/log.module' +import { LogService } from '../src/modules/log/log.service' + +const canRunLogE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + +const describeWithLog = describe.runIf(canRunLogE2E) + +describeWithLog('LogService (e2e)', () => { + let moduleRef: TestingModule + let logService: LogService + let prisma: PrismaService + + let ownerId: string + let projectId: string + let projectSlug: string + let projectLogId: string + let globalLogId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [LogModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + logService = moduleRef.get(LogService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + if (projectLogId) { + await prisma.log.deleteMany({ where: { id: projectLogId } }).catch(() => {}) + } + if (globalLogId) { + await prisma.log.deleteMany({ where: { id: globalLogId } }).catch(() => {}) + } + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should persist logs and return clean/project-filtered results', async () => { + const projectLog = await logService.addLog({ + action: 'project-upsert', + userId: ownerId, + projectId, + requestId: faker.string.uuid(), + data: { + args: { slug: projectSlug }, + results: { ok: true }, + config: { hidden: true }, + cluster: { id: 'cluster-1' }, + user: { id: ownerId }, + newCreds: { token: 'secret' }, + apis: ['v1'], + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }, + }) + projectLogId = projectLog.id + + const globalLog = await logService.addLog({ + action: 'global-upsert', + data: { + args: { enabled: true }, + results: { ok: true }, + config: { hidden: true }, + }, + }) + globalLogId = globalLog.id + + const storedLog = await prisma.log.findUniqueOrThrow({ + where: { id: projectLog.id }, + }) + expect(storedLog.data).toMatchObject({ + args: { slug: projectSlug }, + results: { ok: true }, + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }) + expect(storedLog.data).not.toHaveProperty('config') + expect(storedLog.data).not.toHaveProperty('cluster') + expect(storedLog.data).not.toHaveProperty('user') + expect(storedLog.data).not.toHaveProperty('newCreds') + expect(storedLog.data).not.toHaveProperty('apis') + + const { total: projectTotal, logs: projectLogs } = await logService.getLogs({ + offset: 0, + limit: 10, + projectId, + clean: false, + }) + expect(projectTotal).toBe(1) + expect(projectLogs).toHaveLength(1) + expect(projectLogs[0]).toMatchObject({ + id: projectLog.id, + action: 'project-upsert', + userId: ownerId, + projectId, + }) + expect(projectLogs[0].data).toMatchObject({ + args: { slug: projectSlug }, + results: { ok: true }, + warning: ['careful'], + }) + expect(projectLogs[0].data).not.toHaveProperty('config') + + const { total: allTotal, logs: allLogs } = await logService.getLogs({ + offset: 0, + limit: 10, + projectId: undefined, + clean: true, + }) + expect(allTotal).toBeGreaterThan(2) + expect(allLogs).toHaveLength(10) + expect(allLogs[0]).toMatchObject({ + id: globalLog.id, + action: 'global-upsert', + userId: null, + }) + expect(allLogs[0].data).not.toHaveProperty('args') + expect(allLogs[0].data).not.toHaveProperty('results') + expect(allLogs[0].data).not.toHaveProperty('config') + expect(allLogs[1]).toMatchObject({ + id: projectLog.id, + action: 'project-upsert', + userId: ownerId, + }) + expect(allLogs[1].data).toMatchObject({ + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }) + expect(allLogs[1].data).not.toHaveProperty('args') + expect(allLogs[1].data).not.toHaveProperty('results') + expect(allLogs[1].data).not.toHaveProperty('config') + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/test/nexus.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/nexus.e2e-spec.ts new file mode 100644 index 0000000000..0c5b45eb5d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/nexus.e2e-spec.ts @@ -0,0 +1,156 @@ +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { NexusClientService } from '../src/modules/nexus/nexus-client.service' +import { projectSelect } from '../src/modules/nexus/nexus-datastore.service' +import { makeProjectWithDetails } from '../src/modules/nexus/nexus-testing.utils' +import { NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, PLUGIN_NAME } from '../src/modules/nexus/nexus.constants' +import { NexusModule } from '../src/modules/nexus/nexus.module' +import { NexusService } from '../src/modules/nexus/nexus.service' +import { getProjectVaultPath } from '../src/modules/nexus/nexus.utils' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunNexusE2E + = Boolean(process.env.E2E) + && Boolean(process.env.NEXUS_URL) + && Boolean(process.env.NEXUS_ADMIN) + && Boolean(process.env.NEXUS_ADMIN_PASSWORD) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithNexus = describe.runIf(canRunNexusE2E) + +describeWithNexus('NexusController (e2e)', () => { + let moduleRef: TestingModule + let nexusController: NexusService + let nexusClient: NexusClientService + let vaultService: VaultClientService + let config: BaseConfig + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [NexusModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + nexusController = moduleRef.get(NexusService) + nexusClient = moduleRef.get(NexusClientService) + vaultService = moduleRef.get(VaultClientService) + config = moduleRef.get(baseConfigFactory) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (testProjectSlug) { + await nexusController.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should reconcile project in Nexus (repos, role, user, vault secret)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + plugins: { + create: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }, + ], + }, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await nexusController.handleUpsert(project) + + const mavenReleaseRepo = `${testProjectSlug}-repository-release` + const mavenSnapshotRepo = `${testProjectSlug}-repository-snapshot` + const mavenGroupRepo = `${testProjectSlug}-repository-group` + + const npmHostedRepo = `${testProjectSlug}-npm` + const npmGroupRepo = `${testProjectSlug}-npm-group` + + const [releaseRepo, snapshotRepo, groupRepo, npmRepo, npmGroup] = await Promise.all([ + nexusClient.getRepositoriesMavenHosted(mavenReleaseRepo), + nexusClient.getRepositoriesMavenHosted(mavenSnapshotRepo), + nexusClient.getRepositoriesMavenGroup(mavenGroupRepo), + nexusClient.getRepositoriesNpmHosted(npmHostedRepo), + nexusClient.getRepositoriesNpmGroup(npmGroupRepo), + ]) + + expect(releaseRepo).toBeTruthy() + expect(snapshotRepo).toBeTruthy() + expect(groupRepo).toBeTruthy() + expect(npmRepo).toBeTruthy() + expect(npmGroup).toBeTruthy() + + const roleId = `${testProjectSlug}-ID` + const role = await nexusClient.getSecurityRoles(roleId) + expect(role).toBeTruthy() + + const users = await nexusClient.getSecurityUsers(testProjectSlug) + expect(users.some(u => u.userId === testProjectSlug)).toBe(true) + + const vaultPath = getProjectVaultPath(config.projectsRootDir, testProjectSlug, 'tech/NEXUS') + const secret = await vaultService.read(vaultPath) + expect(secret.data?.NEXUS_USERNAME).toBe(testProjectSlug) + expect(secret.data?.NEXUS_PASSWORD).toBeTruthy() + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/test/project-bulk.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/project-bulk.e2e-spec.ts new file mode 100644 index 0000000000..3a1e80f39d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/project-bulk.e2e-spec.ts @@ -0,0 +1,109 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectBulkModule } from '../src/modules/project-bulk/project-bulk.module' +import { ProjectBulkService } from '../src/modules/project-bulk/project-bulk.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectBulkE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectBulk = describe.runIf(canRunProjectBulkE2E) + +describeWithProjectBulk('ProjectBulkService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectBulkService + let eventEmitter: EventEmitter2 + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectBulkModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectBulkService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('locks and unlocks projects', async () => { + await service.bulkAction({ action: 'lock', projectIds: [projectId] }) + const locked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } }) + expect(locked.locked).toBe(true) + + await service.bulkAction({ action: 'unlock', projectIds: [projectId] }) + const unlocked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } }) + expect(unlocked.locked).toBe(false) + }) + + it('replays hooks for projects', async () => { + await service.bulkAction({ action: 'replay', projectIds: [projectId] }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith( + 'project.upsert', + expect.objectContaining({ id: projectId, slug: projectSlug }), + ) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/test/project-hooks.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/project-hooks.e2e-spec.ts new file mode 100644 index 0000000000..ab6e75184e --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/project-hooks.e2e-spec.ts @@ -0,0 +1,119 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectHooksModule } from '../src/modules/project-hooks/project-hooks.module' +import { ProjectHooksService } from '../src/modules/project-hooks/project-hooks.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectHooksE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectHooks = describe.runIf(canRunProjectHooksE2E) + +describeWithProjectHooks('ProjectHooksService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectHooksService + let eventEmitter: DeepMockProxy + let vaultService: DeepMockProxy + let vaultClient: DeepMockProxy + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + vaultService = mockDeep() + vaultService.listProjectSecrets.mockResolvedValue([]) + vaultClient = mockDeep() + + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectHooksModule], + providers: [ + { provide: VaultService, useValue: vaultService }, + { provide: VaultClientService, useValue: vaultClient }, + ], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectHooksService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('replays hooks through the event system', async () => { + await service.replay(projectId) + + expect(eventEmitter.emitAsync).toHaveBeenCalledWith( + 'project.upsert', + expect.objectContaining({ id: projectId, slug: projectSlug }), + ) + }) + + it('rejects replayHooks when the project does not exist', async () => { + const result = await service.replay(faker.string.uuid()) + expect(result).toBeUndefined() + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/test/project-members.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/project-members.e2e-spec.ts new file mode 100644 index 0000000000..10dec97860 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/project-members.e2e-spec.ts @@ -0,0 +1,169 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { ProjectMembersModule } from '../src/modules/project-members/project-members.module' +import { ProjectMembersService } from '../src/modules/project-members/project-members.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectMembersE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectMembers = describe.runIf(canRunProjectMembersE2E) + +describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectMembersService + let eventEmitter: DeepMockProxy + let keycloakClient: DeepMockProxy + + let ownerId: string + let memberId: string + + beforeAll(async () => { + keycloakClient = mockDeep() + keycloakClient.getUserByEmail.mockResolvedValue(undefined) + + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectMembersModule], + providers: [ + { provide: KeycloakClientService, useValue: keycloakClient }, + ], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectMembersService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + memberId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.user.create({ + data: { + id: memberId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Member', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.user.deleteMany({ where: { id: memberId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('rejects addMember when project does not exist', async () => { + await expect(service.add(faker.string.uuid(), { userId: memberId })).rejects.toThrow(NotFoundException) + }) + + it('rejects listMembers when project does not exist', async () => { + await expect(service.list(faker.string.uuid())).resolves.toEqual([]) + }) + + describe('with project', () => { + let projectId: string + + beforeEach(async () => { + eventEmitter.emitAsync.mockClear() + + projectId = faker.string.uuid() + const projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + + eventEmitter.emitAsync.mockClear() + }) + + afterEach(async () => { + await prisma.projectMembers.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + }) + + it('list', async () => { + const members = await service.list(projectId) + expect(members).toHaveLength(0) + }) + + it('add', async () => { + const afterAdd = await service.add(projectId, { userId: memberId }) + expect(afterAdd.some(m => m.userId === memberId)).toBe(true) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.upsert', { projectId, userId: memberId }) + }) + + it('patch', async () => { + await service.add(projectId, { userId: memberId }) + const roleId = faker.string.uuid() + const afterPatch = await service.patch(projectId, [{ userId: memberId, roles: [roleId] }]) + expect(afterPatch.find(m => m.userId === memberId)?.roleIds).toContain(roleId) + }) + + it('remove', async () => { + await service.add(projectId, { userId: memberId }) + const afterRemove = await service.remove(projectId, memberId) + expect(afterRemove.some(m => m.userId === memberId)).toBe(false) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.delete', { projectId, userId: memberId }) + }) + + it('rejects addMember when adding owner', async () => { + await expect(service.add(projectId, { userId: ownerId })).rejects.toThrow(BadRequestException) + }) + + it('rejects addMember when user does not exist', async () => { + await expect(service.add(projectId, { userId: faker.string.uuid() })).rejects.toThrow(NotFoundException) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/test/project-roles.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/project-roles.e2e-spec.ts new file mode 100644 index 0000000000..0f530ebf53 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/project-roles.e2e-spec.ts @@ -0,0 +1,238 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectRolesModule } from '../src/modules/project-roles/project-roles.module' +import { ProjectRolesService } from '../src/modules/project-roles/project-roles.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectRolesE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectRoles = describe.runIf(canRunProjectRolesE2E) + +describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectRolesService + let eventEmitter: DeepMockProxy + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectRolesModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectRolesService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-roles-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E roles test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.projectRole.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('lists roles for a project', async () => { + const role = await prisma.projectRole.create({ + data: { + projectId, + name: 'admin', + permissions: 4n, + position: 0, + type: 'managed', + oidcGroup: `/${projectSlug}/admin`, + }, + }) + + const roles = await service.list(projectId) + + expect(roles).toHaveLength(1) + expect(roles[0].id).toBe(role.id) + expect(roles[0].permissions).toBe('4') + expect(roles[0].oidcGroup).toBe('/admin') + }) + + it('creates a role and emits project upsert', async () => { + const role = await service.create(projectId, { name: 'developer', permissions: '2' }) + + expect(role.length).toBeGreaterThanOrEqual(1) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', expect.objectContaining({ id: projectId })) + + const dbRole = await prisma.projectRole.findFirst({ + where: { projectId, name: 'developer' }, + }) + expect(dbRole).not.toBeNull() + expect(dbRole?.permissions).toBe(2n) + }) + + it('patches roles and updates position', async () => { + const existingRole = await prisma.projectRole.create({ + data: { + projectId, + name: 'tester', + permissions: 1n, + position: 5, + type: 'managed', + oidcGroup: '', + }, + }) + + await prisma.projectRole.create({ + data: { + projectId, + name: 'other', + permissions: 2n, + position: 6, + type: 'managed', + oidcGroup: '', + }, + }) + + const roles = await service.update(projectId, [{ + id: existingRole.id, + name: 'qa', + }]) + + const updated = roles.find(r => r.id === existingRole.id) + expect(updated?.name).toBe('qa') + }) + + it('counts role members', async () => { + const roleId = faker.string.uuid() + await prisma.projectRole.create({ + data: { + id: roleId, + projectId, + name: 'counted', + permissions: 1n, + position: 10, + type: 'managed', + oidcGroup: '', + }, + }) + + const userId = faker.string.uuid() + await prisma.user.create({ + data: { + id: userId, + email: faker.internet.email().toLowerCase(), + firstName: 'Member', + lastName: 'Test', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId, + userId, + roleIds: [roleId], + }, + }) + + const counts = await service.countMembers(projectId) + expect(counts[roleId]).toBe(1) + }) + + it('deletes a role and cleans member role ids', async () => { + const roleId = faker.string.uuid() + const userId = faker.string.uuid() + + await prisma.projectRole.create({ + data: { + id: roleId, + projectId, + name: 'to-delete', + permissions: 1n, + position: 20, + type: 'managed', + oidcGroup: '', + }, + }) + + await prisma.user.create({ + data: { + id: userId, + email: faker.internet.email().toLowerCase(), + firstName: 'Delete', + lastName: 'Test', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId, + userId, + roleIds: [roleId], + }, + }) + + await service.delete(roleId) + + const member = await prisma.projectMembers.findUnique({ + where: { projectId_userId: { projectId, userId } }, + }) + expect(member?.roleIds).not.toContain(roleId) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', expect.objectContaining({ id: projectId })) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/test/project-secrets.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/project-secrets.e2e-spec.ts new file mode 100644 index 0000000000..bb30aff5d7 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/project-secrets.e2e-spec.ts @@ -0,0 +1,145 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectSecretsModule } from '../src/modules/project-secrets/project-secrets.module' +import { ProjectSecretsService } from '../src/modules/project-secrets/project-secrets.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { generateProjectPath } from '../src/modules/vault/vault.utils' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectSecretsE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithProjectSecrets = describe.runIf(canRunProjectSecretsE2E) + +describeWithProjectSecrets('ProjectSecretsService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectSecretsService + let vaultClient: VaultClientService + + let ownerId: string + let projectId: string + let projectSlug: string + let projectPath: string + let secretAPath: string + let secretBPath: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectSecretsModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectSecretsService) + vaultClient = moduleRef.get(VaultClientService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + projectPath = generateProjectPath(process.env.PROJECTS_ROOT_DIR, projectSlug) + secretAPath = `${projectPath}/group1/secret1` + secretBPath = `${projectPath}/group2/secret2` + + await Promise.all([secretAPath, secretBPath].map(path => vaultClient.delete(path).catch(() => {}))) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + await Promise.all([secretAPath, secretBPath].map(path => vaultClient.delete(path).catch(() => {}))) + + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('returns an empty secret map when no Vault secrets exist', async () => { + const secrets = await service.get(projectId) + expect(secrets).toEqual({}) + }) + + describe('when Vault secrets exist', () => { + beforeAll(async () => { + await Promise.all([ + vaultClient.write({ + key1: 'value1', + key2: 42, + }, secretAPath), + vaultClient.write({ + key3: false, + key4: null, + }, secretBPath), + ]) + }) + + afterAll(async () => { + await Promise.all([ + vaultClient.delete(secretAPath).catch(() => {}), + vaultClient.delete(secretBPath).catch(() => {}), + ]) + }) + + it('parses real Vault values into grouped secrets', async () => { + const secrets = await service.get(projectId) + + expect(secrets.group1).toEqual({ + 'secret1.key1': 'value1', + 'secret1.key2': '42', + }) + expect(secrets.group2).toEqual({ + 'secret2.key3': 'false', + 'secret2.key4': '', + }) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/test/project-services.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/project-services.e2e-spec.ts new file mode 100644 index 0000000000..25612a1f99 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/project-services.e2e-spec.ts @@ -0,0 +1,144 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectServicesModule } from '../src/modules/project-services/project-services.module' +import { ProjectServicesService } from '../src/modules/project-services/project-services.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunServicesE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + && Boolean(process.env.ARGOCD_URL) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.HARBOR_URL) + && Boolean(process.env.HARBOR_ADMIN) + && Boolean(process.env.HARBOR_ADMIN_PASSWORD) + && Boolean(process.env.KEYCLOAK_DOMAIN) + && Boolean(process.env.KEYCLOAK_REALM) + && Boolean(process.env.KEYCLOAK_PROTOCOL) + && Boolean(process.env.NEXUS_URL) + && Boolean(process.env.NEXUS_ADMIN) + && Boolean(process.env.NEXUS_ADMIN_PASSWORD) + && Boolean(process.env.SONARQUBE_URL) + && Boolean(process.env.SONAR_API_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithServices = describe.runIf(canRunServicesE2E) + +const PLUGIN_NAME = 'gitlab' + +describeWithServices('ProjectServicesService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectServicesService + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectServicesModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectServicesService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.projectPlugin.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('get returns services for a project member', async () => { + const services = await service.get(projectId, 'user') + const gitlabService = services.find(entry => entry.name === PLUGIN_NAME) + + expect(gitlabService).toBeTruthy() + expect(gitlabService?.name).toBe(PLUGIN_NAME) + }) + + it('rejects get when project does not exist', async () => { + await expect(service.get(faker.string.uuid(), 'user')).rejects.toThrow(NotFoundException) + }) + + it('update stores project configuration', async () => { + await service.update(projectId, { + gitlab: { + enabled: 'enabled', + }, + }, ['user']) + + const stored = await prisma.projectPlugin.findUniqueOrThrow({ + where: { + projectId_pluginName_key: { + projectId, + pluginName: PLUGIN_NAME, + key: 'user.enabled', + }, + }, + select: { + value: true, + }, + }) + + expect(stored.value).toBe('enabled') + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/test/project.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/project.e2e-spec.ts new file mode 100644 index 0000000000..0aa6b9011d --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/project.e2e-spec.ts @@ -0,0 +1,153 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ForbiddenException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectPermissionModule } from '../src/modules/infrastructure/permission/project/project.module' +import { makeCreateProjectBody } from '../src/modules/project/project-testing.utils' +import { ProjectService } from '../src/modules/project/project.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProject = describe.runIf(canRunProjectE2E) + +describeWithProject('ProjectService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectService + let eventEmitter: DeepMockProxy + + let ownerId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectPermissionModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('ProjectService.create', async () => { + const createBody = makeCreateProjectBody({ + name: faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`), + description: 'Initial description', + }) + const created = await service.create(createBody, ownerId) + await prisma.project.deleteMany({ where: { id: created.id } }).catch(() => {}) + expect(created.id).toBeTruthy() + }) + + it('rejects ProjectService.list filter=all for non-admin', async () => { + await expect( + service.listProjects({ filter: 'all' }, { userId: ownerId, adminPermissions: 0n }), + ).rejects.toThrow(ForbiddenException) + }) + + it('rejects ProjectService.get when project does not exist', async () => { + await expect(service.get(faker.string.uuid())).rejects.toThrow(NotFoundException) + }) + + describe('with project', () => { + let projectId: string + let projectName: string + + beforeEach(async () => { + eventEmitter.emitAsync.mockClear() + + const createBody = makeCreateProjectBody({ + name: faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`), + description: 'Initial description', + }) + + const created = await service.create(createBody, ownerId) + projectId = created.id + projectName = created.name + + eventEmitter.emitAsync.mockClear() + }) + + afterEach(async () => { + await prisma.projectMembers.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + }) + + it('ProjectService.get', async () => { + const fetched = await service.get(projectId) + expect(fetched.id).toBe(projectId) + expect(fetched.ownerId).toBe(ownerId) + }) + + it('ProjectService.list', async () => { + const listResult = await service.listProjects({ filter: 'member' }, { userId: ownerId, adminPermissions: 0n }) + expect(listResult.some(p => p.id === projectId)).toBe(true) + }) + + it('ProjectService.getData', async () => { + const dataExport = await service.getProjectsData() + expect(Array.isArray(dataExport)).toBe(true) + expect(dataExport.some(p => p.name === projectName)).toBe(true) + }) + + it('ProjectService.update', async () => { + const updated = await service.update( + { description: 'Updated description' }, + { userId: ownerId, adminPermissions: 0n }, + projectId, + ) + expect(updated.description).toBe('Updated description') + }) + + it('ProjectService.archive', async () => { + await service.archive(projectId) + const archived = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { status: true, locked: true } }) + expect(archived.status).toBe('archived') + expect(archived.locked).toBe(true) + }) + + it('rejects ProjectService.update when project is locked', async () => { + await prisma.project.update({ where: { id: projectId }, data: { locked: true } }) + await expect( + service.update({ description: 'nope' }, { userId: ownerId, adminPermissions: 0n }, projectId), + ).rejects.toThrow(ForbiddenException) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/test/registry.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/registry.e2e-spec.ts new file mode 100644 index 0000000000..2fdf921814 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/registry.e2e-spec.ts @@ -0,0 +1,92 @@ +import type { ConfigType } from '@nestjs/config' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { harborConfigFactory } from '../src/config/harbor.config' +import { RegistryClientService } from '../src/modules/registry/registry-client.service' +import { makeProjectWithDetails } from '../src/modules/registry/registry-testing.utils' +import { ROBOT_NAME_PROJECT, ROBOT_NAME_RO, ROBOT_NAME_RW } from '../src/modules/registry/registry.constants' +import { RegistryModule } from '../src/modules/registry/registry.module' +import { RegistryService } from '../src/modules/registry/registry.service' +import { getHostFromUrl, getProjectVaultPath } from '../src/modules/registry/registry.utils' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' +import { getAll } from '../src/utils/iterable.utils' + +const canRunRegistryE2E + = Boolean(process.env.E2E) + && Boolean(process.env.HARBOR_URL) + && Boolean(process.env.HARBOR_ADMIN) + && Boolean(process.env.HARBOR_ADMIN_PASSWORD) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithRegistry = describe.runIf(canRunRegistryE2E) + +describeWithRegistry('RegistryService (e2e)', () => { + let moduleRef: TestingModule + let registry: RegistryService + let client: RegistryClientService + let vault: VaultClientService + let config: BaseConfig + let harborConfig: ConfigType + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory, harborConfigFactory] }), RegistryModule], + }) + .compile() + + await moduleRef.init() + + registry = moduleRef.get(RegistryService) + client = moduleRef.get(RegistryClientService) + vault = moduleRef.get(VaultClientService) + config = moduleRef.get(baseConfigFactory) + harborConfig = moduleRef.get(harborConfigFactory) + + projectSlug = faker.helpers.slugify(`test-project-${faker.string.alphanumeric({ length: 10 }).toLowerCase()}`).slice(0, 50) + }) + + afterAll(async () => { + if (vault && config && projectSlug) { + const paths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectsRootDir, `REGISTRY/${name}`)) + await Promise.all(paths.map(path => vault.delete(path).catch(() => {}))) + } + + if (registry && projectSlug) { + await registry.deleteProject(projectSlug).catch(() => {}) + } + + await moduleRef?.close() + }) + + it('should provision project in Harbor and write robot secrets to Vault', async () => { + const result = await registry.ensureProject({ slug: projectSlug, plugins: [] }, { publishProjectRobot: true }) + expect(result.basePath).toBe(`${getHostFromUrl(harborConfig.url)}/${projectSlug}/`) + + const project = await client.getProjectByName(projectSlug) + expect(project.status).toBe(200) + + const robots = await getAll(client.getProjectRobots(Number(project.data?.project_id))) + const robotNames = robots.flatMap(r => r.name ? [r.name] : []) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RO}`) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RW}`) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) + + const vaultPaths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectsRootDir, `REGISTRY/${name}`)) + const [roSecret, rwSecret, projectSecret] = await Promise.all(vaultPaths.map(path => vault.read(path))) + expect(roSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(rwSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(projectSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(roSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RO}`) + expect(rwSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RW}`) + expect(projectSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) + }) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/test/sonarqube.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/sonarqube.e2e-spec.ts new file mode 100644 index 0000000000..b8037965ef --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/sonarqube.e2e-spec.ts @@ -0,0 +1,183 @@ +import type { TestingModule } from '@nestjs/testing' +import { generateProjectKey } from '@cpn-console/hooks' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { SonarqubeClientService } from '../src/modules/sonarqube/sonarqube-client.service' +import { projectSelect } from '../src/modules/sonarqube/sonarqube-datastore.service' +import { makeProjectWithDetails } from '../src/modules/sonarqube/sonarqube-testing.utils' +import { SonarqubeModule } from '../src/modules/sonarqube/sonarqube.module' +import { SonarqubeService } from '../src/modules/sonarqube/sonarqube.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunSonarqubeE2E + = Boolean(process.env.E2E) + && Boolean(process.env.SONARQUBE_URL) + && Boolean(process.env.SONAR_API_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithSonarqube = describe.runIf(canRunSonarqubeE2E) + +describeWithSonarqube('SonarqubeService (e2e)', () => { + let moduleRef: TestingModule + let sonarqubeService: SonarqubeService + let sonarqubeClient: SonarqubeClientService + let vaultService: VaultClientService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + let testRepoName: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [SonarqubeModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + sonarqubeService = moduleRef.get(SonarqubeService) + sonarqubeClient = moduleRef.get(SonarqubeClientService) + vaultService = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-sonar-${faker.string.alphanumeric(8).toLowerCase()}`) + testRepoName = faker.helpers.slugify(`test-sonar-${faker.string.alphanumeric(8).toLowerCase()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (sonarqubeService && testProjectSlug) { + await sonarqubeService.handleDelete( + makeProjectWithDetails({ slug: testProjectSlug, repositories: [] }), + ).catch(() => {}) + } + + if (prisma) { + await prisma.repository.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should create platform groups during initialization', async () => { + // init() is triggered by moduleRef.init() via onModuleInit — groups must already exist + const [adminResult, readonlyResult, securityResult] = await Promise.all([ + sonarqubeClient.searchUserGroup({ q: '/console/admin' }), + sonarqubeClient.searchUserGroup({ q: '/console/readonly' }), + sonarqubeClient.searchUserGroup({ q: '/console/security' }), + ]) + + expect(adminResult.groups.some(g => g.name === '/console/admin')).toBe(true) + expect(readonlyResult.groups.some(g => g.name === '/console/readonly')).toBe(true) + expect(securityResult.groups.some(g => g.name === '/console/security')).toBe(true) + }) + + it('should reconcile project in SonarQube (groups, user, repository, vault secret)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E SonarQube Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + await prisma.repository.create({ + data: { + projectId: testProjectId, + internalRepoName: testRepoName, + isPrivate: false, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await sonarqubeService.handleUpsert(project) + + // All 5 project role groups should exist in SonarQube + const projectGroupNames = [ + `/${testProjectSlug}/console/admin`, + `/${testProjectSlug}/console/devops`, + `/${testProjectSlug}/console/developer`, + `/${testProjectSlug}/console/security`, + `/${testProjectSlug}/console/readonly`, + ] + for (const groupName of projectGroupNames) { + const result = await sonarqubeClient.searchUserGroup({ q: groupName }) + expect(result.groups.some(g => g.name === groupName), `group ${groupName} should exist`).toBe(true) + } + + // Robot/CI user should exist + const usersResult = await sonarqubeClient.searchUsers({ q: testProjectSlug }) + expect(usersResult.users.some(u => u.login === testProjectSlug)).toBe(true) + + // SonarQube analysis project for the repository should exist + const projectKey = generateProjectKey(testProjectSlug, testRepoName) + const projectsResult = await sonarqubeClient.searchProject({ q: testProjectSlug }) + expect(projectsResult.components.some(p => p.key === projectKey)).toBe(true) + + // Vault credentials should be written with correct username and token + const vaultSecret = await vaultService.readSonarqubeUser(testProjectSlug) + expect(vaultSecret?.data?.SONAR_USERNAME).toBe(testProjectSlug) + expect(vaultSecret?.data?.SONAR_TOKEN).toBeTruthy() + expect(vaultSecret?.data?.SONAR_PASSWORD).toBeTruthy() + }, 30000) + + it('should delete the project from SonarQube and remove vault credentials', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await sonarqubeService.handleDelete(project) + + // SonarQube analysis project should be removed + const projectKey = generateProjectKey(testProjectSlug, testRepoName) + const projectsResult = await sonarqubeClient.searchProject({ q: testProjectSlug }) + expect(projectsResult.components.some(p => p.key === projectKey)).toBe(false) + + // Vault credentials should be removed + const vaultSecret = await vaultService.readSonarqubeUser(testProjectSlug) + expect(vaultSecret).toBeNull() + }, 30000) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/test/vault.e2e-spec.ts b/.jjconflict-base-1/apps/server-nestjs/test/vault.e2e-spec.ts new file mode 100644 index 0000000000..6947cb9b53 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/test/vault.e2e-spec.ts @@ -0,0 +1,108 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { projectSelect } from '../src/modules/vault/vault-datastore.service' +import { makeProjectWithDetails } from '../src/modules/vault/vault-testing.utils' +import { VaultModule } from '../src/modules/vault/vault.module' +import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunVaultE2E + = Boolean(process.env.E2E) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithVault = describe.runIf(canRunVaultE2E) + +describeWithVault('VaultController (e2e)', () => { + let moduleRef: TestingModule + let vaultController: VaultService + let vaultClient: VaultClientService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + vaultController = moduleRef.get(VaultService) + vaultClient = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (testProjectSlug) { + await vaultController.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('should reconcile project in Vault (mount, group, role)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await vaultController.handleUpsert(project) + + const group = await vaultClient.getIdentityGroupName(testProjectSlug) + expect(group.data?.id).toBeTruthy() + expect(group.data?.name).toBe(testProjectSlug) + expect(group.data?.alias?.name).toBe(`/${testProjectSlug}`) + }, 180000) +}) diff --git a/.jjconflict-base-1/apps/server-nestjs/tsconfig.build.json b/.jjconflict-base-1/apps/server-nestjs/tsconfig.build.json new file mode 100644 index 0000000000..64f86c6bd2 --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/.jjconflict-base-1/apps/server-nestjs/tsconfig.json b/.jjconflict-base-1/apps/server-nestjs/tsconfig.json new file mode 100644 index 0000000000..48b6403d6f --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "incremental": true, + "target": "ES2023", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "baseUrl": "./", + "module": "nodenext", + "moduleResolution": "nodenext", + "resolvePackageJsonExports": true, + "strictBindCallApply": false, + "strictNullChecks": true, + "noFallthroughCasesInSwitch": false, + "noImplicitAny": false, + "declaration": true, + "outDir": "./dist", + "removeComments": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + } +} diff --git a/.jjconflict-base-1/apps/server-nestjs/vitest.config.ts b/.jjconflict-base-1/apps/server-nestjs/vitest.config.ts new file mode 100644 index 0000000000..b0337f6d0b --- /dev/null +++ b/.jjconflict-base-1/apps/server-nestjs/vitest.config.ts @@ -0,0 +1,22 @@ +import path from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: [ + { find: '@cpn-console/logger/hooks', replacement: path.resolve(__dirname, '../../packages/logger/src/hooks.ts') }, + { find: '@cpn-console/logger', replacement: path.resolve(__dirname, '../../packages/logger/src/index.ts') }, + { find: '@cpn-console/shared', replacement: path.resolve(__dirname, '../../packages/shared/src/index.ts') }, + { find: '@cpn-console/hooks', replacement: path.resolve(__dirname, '../../packages/hooks/src/index.ts') }, + ], + }, + test: { + globals: true, + environment: 'node', + include: ['src/**/*.spec.ts', 'test/**/*.e2e-spec.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}) diff --git a/.jjconflict-base-1/apps/server/.env-example b/.jjconflict-base-1/apps/server/.env-example new file mode 100644 index 0000000000..e26aaeea95 --- /dev/null +++ b/.jjconflict-base-1/apps/server/.env-example @@ -0,0 +1,46 @@ +# Niveau de log +LOG_LEVEL=debug +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 + +# Port d'écoute du serveur backend +SERVER_PORT=4001 +# URL de connexion à la base de données PostgreSQL (format Prisma) +DB_URL=postgresql://admin:admin@localhost:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +OPENCDS_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true + +# Dossier de stockage des plugins externes +EXTERNAL_PLUGINS_DIR_HOST_PATH=/home/stephane/projets/clients/cloud-pi/plugins +# URL du dashboard d'observabilité (géré par Grafana) +GRAFANA_URL= +# Version du chart "observability" à déployer pour les projets +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 +# Inutilisé ? +# SONARQUBE_INTERNAL_URL= diff --git a/.jjconflict-base-1/apps/server/.env.docker-example b/.jjconflict-base-1/apps/server/.env.docker-example new file mode 100644 index 0000000000..571be2ff45 --- /dev/null +++ b/.jjconflict-base-1/apps/server/.env.docker-example @@ -0,0 +1,31 @@ +# Niveau de log +LOG_LEVEL=debug +# Indique que l'application tourne dans un conteneur Docker +DOCKER=true +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine du Keycloak (nom du service Docker Compose + port interne) +KEYCLOAK_DOMAIN=keycloak:8080 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http en Docker local) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 + +# Port d'écoute du serveur dans le réseau Docker +SERVER_PORT=8080 +# URL de connexion PostgreSQL (utilise le nom de service Docker 'postgres') +DB_URL=postgresql://admin:admin@postgres:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-base-1/apps/server/.env.integ-example b/.jjconflict-base-1/apps/server/.env.integ-example new file mode 100644 index 0000000000..1bcb5f1565 --- /dev/null +++ b/.jjconflict-base-1/apps/server/.env.integ-example @@ -0,0 +1,108 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Désactive l'initialisation des données de démo (l'intégration a ses propres données) +DEV_SETUP="false" +# Active le mode intégration (charge ce fichier en surcharge) +INTEGRATION=true + +# --- Keycloak --- +# Protocole (généralement https en intégration) +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= +# Secret du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_SECRET= +# Domaine du Keycloak d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak d'intégration +KEYCLOAK_REALM= + +# --- ArgoCD --- +# Namespace Kubernetes dans lequel ArgoCD est déployé +ARGO_NAMESPACE= +# URL de l'API ArgoCD +ARGOCD_URL= +# URL interne de l'API ArgoCD +ARGOCD_INTERNAL_URL= + +# --- GitLab --- +# Token d'accès personnel GitLab (scope api) +GITLAB_TOKEN= +# URL de l'instance GitLab +GITLAB_URL= +# URL interne de l'API Gitlab +GITLAB_INTERNAL_URL= + +# --- Harbor --- +# Nom d'utilisateur admin Harbor +HARBOR_ADMIN= +# Mot de passe admin Harbor +HARBOR_ADMIN_PASSWORD= +# URL de l'instance Harbor +HARBOR_URL= +# URL interne de l'API Harbor +HARBOR_INTERNAL_URL= + +# --- Keycloak (admin) --- +# Utilisateur admin Keycloak pour la gestion des royaumes/clients +KEYCLOAK_ADMIN= +# Mot de passe admin Keycloak +KEYCLOAK_ADMIN_PASSWORD= +# URL complète de Keycloak (peut différer de KEYCLOAK_DOMAIN) +KEYCLOAK_URL= + +# --- Nexus --- +# Nom d'utilisateur admin Nexus +NEXUS_ADMIN= +# Mot de passe admin Nexus +NEXUS_ADMIN_PASSWORD= +# URL de l'instance Nexus +NEXUS_URL= +# URL interne de l'API Nexus +NEXUS_INTERNAL_URL= + +# --- Divers --- +# Répertoire racine des projets dans Gitlab +PROJECTS_ROOT_DIR= + +# --- SonarQube --- +# Token d'API SonarQube +SONAR_API_TOKEN= +# URL de l'instance SonarQube +SONARQUBE_URL= +# URL interne de l'API SonarQube +SONARQUBE_INTERNAL_URL= + +# --- Vault --- +# Token d'authentification Vault +VAULT_TOKEN= +# URL de l'instance Vault +VAULT_URL= +# URL interne de l'API Vault +VAULT_INTERNAL_URL= + +# --- Kubeconfig --- +# Chemin vers le kubeconfig sur la machine hôte (monté en volume dans Docker) +KUBECONFIG_HOST_PATH= +# Chemin vers le kubeconfig à l'intérieur du conteneur +KUBECONFIG_PATH=$HOME/.kube/config +# Contexte Kubernetes à utiliser +KUBECONFIG_CTX= + +# Répertoire contenant les plugins externes (monté en volume dans Docker) +EXTERNAL_PLUGINS_DIR_HOST_PATH=/path/to/plugins + +# Informations pour le plugin Observabilité +GRAFANA_URL= +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 + +# --- Configuration proxy (si nécessaire pour accéder aux services externes) --- +# HTTP_PROXY= +# HTTPS_PROXY= +# NO_PROXY= + +# Configuration OpenCDS +OPENCDS_URL= +OPENCDS_API_TOKEN=token +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false diff --git a/.jjconflict-base-1/apps/server/Dockerfile b/.jjconflict-base-1/apps/server/Dockerfile new file mode 100644 index 0000000000..47ca58e741 --- /dev/null +++ b/.jjconflict-base-1/apps/server/Dockerfile @@ -0,0 +1,66 @@ +# Base stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Install pnpm version defined in package.json "packageManager" property +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY --chown=node:root apps/server/package.json ./apps/server/package.json + +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +COPY --chown=node:root plugins/argocd/package.json ./plugins/argocd/package.json +COPY --chown=node:root plugins/gitlab/package.json ./plugins/gitlab/package.json +COPY --chown=node:root plugins/harbor/package.json ./plugins/harbor/package.json +COPY --chown=node:root plugins/keycloak/package.json ./plugins/keycloak/package.json +COPY --chown=node:root plugins/nexus/package.json ./plugins/nexus/package.json +COPY --chown=node:root plugins/sonarqube/package.json ./plugins/sonarqube/package.json +COPY --chown=node:root plugins/vault/package.json ./plugins/vault/package.json + +RUN pnpm install --frozen-lockfile +COPY --chown=node:root plugins/ ./plugins/ +COPY --chown=node:root packages/ ./packages/ + +COPY --chown=node:root apps/server/ ./apps/server/ +# Generate Prisma client +RUN pnpm --filter server run db:generate +ENTRYPOINT [ "pnpm", "--filter", "server", "run" ] +CMD [ "dev" ] + + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +# Build @cpn-console/server console-related dependencies +RUN pnpm run build +# Build @cpn-console/server +RUN pnpm --filter @cpn-console/server run build +# Export @cpn-console/server to target build directory +RUN pnpm --filter @cpn-console/server --prod deploy build + + +# Prod stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS prod + +ARG APP_VERSION +ENV APP_VERSION=$APP_VERSION + +VOLUME [ "/plugins" ] +WORKDIR /app +RUN mkdir -p /home/node/logs && chmod 770 -R /home/node/logs \ + && mkdir -p /home/node/.npm && chmod 770 -R /home/node/.npm \ + && chown node:root /app +COPY --chown=node:root --from=build /app/build . +RUN npm run db:generate +USER node +EXPOSE 8080 +ENTRYPOINT ["npm", "start"] diff --git a/.jjconflict-base-1/apps/server/README.md b/.jjconflict-base-1/apps/server/README.md new file mode 100644 index 0000000000..37b89ac99b --- /dev/null +++ b/.jjconflict-base-1/apps/server/README.md @@ -0,0 +1,39 @@ +# Console Cloud π Native - Serveur + +## Installation + +```sh +npm install +``` + +## Lancement de l'app + +```sh +npm run dev +``` + +## Lancement des tests unitaires + +```sh +npm run test +``` + +## Formattage du code + +```sh +# Lister les problèmes de formatage +npm run lint + +# Régler automatiquement les problèmes de formatage +npm run format +``` + +## Lancement de l'app en mode production + +```sh +npm start +``` + +## Activation OpenCDS + +Se référer à [la documentation dédiée](../../packages/opencds/README.adoc) diff --git a/.jjconflict-base-1/apps/server/eslint.config.js b/.jjconflict-base-1/apps/server/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-1/apps/server/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-1/apps/server/nodemon.json b/.jjconflict-base-1/apps/server/nodemon.json new file mode 100644 index 0000000000..a83d0540d3 --- /dev/null +++ b/.jjconflict-base-1/apps/server/nodemon.json @@ -0,0 +1,4 @@ +{ + "watch": ["server.ts", "src/"], + "ext": "js, ts" +} diff --git a/.jjconflict-base-1/apps/server/package.json b/.jjconflict-base-1/apps/server/package.json new file mode 100644 index 0000000000..3096b8ea59 --- /dev/null +++ b/.jjconflict-base-1/apps/server/package.json @@ -0,0 +1,109 @@ +{ + "name": "@cpn-console/server", + "type": "module", + "version": "9.22.1", + "description": "cpn-console server", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/bootstrap.js", + "module": "dist/bootstrap.js", + "types": "types/bootstrap.d.ts", + "files": [ + "dist", + "prisma.config.ts", + "src" + ], + "scripts": { + "prebuild": "pnpm i --frozen-lockfile", + "build": "tspc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "db:deploy": "prisma migrate deploy", + "db:diff": "prisma migrate diff", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev --name dso", + "db:reset": "prisma migrate reset", + "predev": "pnpm run db:deploy", + "dev": "nodemon --exec 'vite-node src/bootstrap.ts'", + "predebug": "pnpm run db:migrate", + "debug": "nodemon --exec 'NODE_OPTIONS=\"--inspect-brk=0.0.0.0\" vite-node src/bootstrap.ts'", + "format": "eslint ./ --fix", + "preinteg": "INTEGRATION=true pnpm run db:migrate", + "integ": "INTEGRATION=true nodemon --exec 'vite-node src/bootstrap.ts'", + "lint": "eslint ./", + "prepublishOnly": "pnpm run db:generate", + "prestart": "npm run db:deploy", + "start": "node --enable-source-maps dist/bootstrap.js", + "pretest": "pnpm run db:generate", + "test": "vitest run", + "pretest:cov": "pnpm run db:generate", + "test:cov": "vitest run --coverage", + "test:e2e": "pnpm run dev", + "test:e2e-ci": "pnpm run start" + }, + "dependencies": { + "@cpn-console/argocd-plugin": "workspace:^", + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/harbor-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/nexus-plugin": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/sonarqube-plugin": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@fastify/cookie": "catalog:runtime", + "@fastify/helmet": "catalog:runtime", + "@fastify/otel": "catalog:runtime", + "@fastify/session": "catalog:runtime", + "@fastify/swagger": "catalog:runtime", + "@fastify/swagger-ui": "catalog:runtime", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "@kubernetes-models/argo-cd": "catalog:runtime", + "@opentelemetry/auto-instrumentations-node": "catalog:otel", + "@opentelemetry/exporter-metrics-otlp-proto": "catalog:otel", + "@opentelemetry/exporter-trace-otlp-proto": "catalog:otel", + "@opentelemetry/instrumentation-nestjs-core": "catalog:otel", + "@opentelemetry/instrumentation-pino": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-node": "catalog:otel", + "@prisma/client": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@ts-rest/fastify": "catalog:runtime", + "@ts-rest/open-api": "catalog:runtime", + "axios": "catalog:runtime", + "date-fns": "catalog:runtime", + "fastify": "catalog:runtime", + "fastify-keycloak-adapter": "catalog:runtime", + "json-2-csv": "catalog:runtime", + "mustache": "catalog:runtime", + "prisma": "catalog:runtime", + "undici": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "fastify-plugin": "catalog:build", + "nodemon": "catalog:tools", + "pino-pretty": "catalog:tools", + "rimraf": "catalog:tools", + "ts-patch": "catalog:build", + "typescript": "catalog:build", + "typescript-transform-paths": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/apps/server/prisma.config.ts b/.jjconflict-base-1/apps/server/prisma.config.ts new file mode 100644 index 0000000000..ff5aaf9186 --- /dev/null +++ b/.jjconflict-base-1/apps/server/prisma.config.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' +import { defineConfig } from 'prisma/config' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +export default defineConfig({ + schema: path.join('src', 'prisma', 'schema'), + migrations: { + path: path.join('src', 'prisma', 'migrations'), + }, +}) diff --git a/.jjconflict-base-1/apps/server/src/__mocks__/prisma.ts b/.jjconflict-base-1/apps/server/src/__mocks__/prisma.ts new file mode 100644 index 0000000000..075578c96c --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/__mocks__/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma.js') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-base-1/apps/server/src/__mocks__/utils/hook-wrapper.ts b/.jjconflict-base-1/apps/server/src/__mocks__/utils/hook-wrapper.ts new file mode 100644 index 0000000000..f7fdd811a2 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/__mocks__/utils/hook-wrapper.ts @@ -0,0 +1,42 @@ +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../utils/hook-wrapper.ts') + +export const hook = { + adminRole: { + delete: vi.fn(), + upsert: vi.fn(), + }, + cluster: { + delete: vi.fn(), + upsert: vi.fn(), + }, + misc: { + checkServices: vi.fn(), + syncRepository: vi.fn(), + }, + project: { + upsert: vi.fn(), + delete: vi.fn(), + getSecrets: vi.fn(), + }, + projectRole: { + upsert: vi.fn(), + delete: vi.fn(), + }, + user: { + retrieveUserByEmail: vi.fn(), + }, + zone: { + delete: vi.fn(), + upsert: vi.fn(), + }, +} as const + +const hookMock = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(hookMock) +}) diff --git a/.jjconflict-base-1/apps/server/src/app.spec.ts b/.jjconflict-base-1/apps/server/src/app.spec.ts new file mode 100644 index 0000000000..e540174ed4 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/app.spec.ts @@ -0,0 +1,21 @@ +import { apiPrefix } from '@cpn-console/shared' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import app from './app.js' +import { getRandomRequestor, setRequestor } from './utils/mocks.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) + +describe('app', () => { + beforeEach(() => { + setRequestor(getRandomRequestor()) + }) + afterAll(async () => { + await app.close() + }) + + it('should respond 404 on unknown route', async () => { + const response = await app.inject() + .get(`${apiPrefix}/miss`) + expect(response.statusCode).toBe(404) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/app.ts b/.jjconflict-base-1/apps/server/src/app.ts new file mode 100644 index 0000000000..996db320fa --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/app.ts @@ -0,0 +1,63 @@ +import type { FastifyRequest } from 'fastify' +import { apiPrefix, getContract } from '@cpn-console/shared' +import fastifyCookie from '@fastify/cookie' +import helmet from '@fastify/helmet' +import fastifySession from '@fastify/session' +import fastifySwagger from '@fastify/swagger' +import fastifySwaggerUi from '@fastify/swagger-ui' +import { initServer } from '@ts-rest/fastify' +import { generateOpenApi } from '@ts-rest/open-api' +import fastify from 'fastify' +import keycloak from 'fastify-keycloak-adapter' +import { apiRouter } from './resources/index.js' +import { isDev, isInt, isTest } from './utils/env.js' +import { fastifyConf, swaggerConf, swaggerUiConf } from './utils/fastify.js' +import { keycloakConf, sessionConf } from './utils/keycloak.js' + +export const serverInstance: ReturnType = initServer() + +const openApiDocument = generateOpenApi(await getContract(), swaggerConf, { + setOperationId: true, +}) + +const app = fastify(fastifyConf) + .register(helmet, () => ({ + contentSecurityPolicy: !(isInt || isDev || isTest), + })) + .register(fastifyCookie) + .register(fastifySession, sessionConf) + // @ts-ignore + .register(keycloak, keycloakConf) + .register(fastifySwagger, { transformObject: () => openApiDocument }) + .register(fastifySwaggerUi, swaggerUiConf) + .register(apiRouter()) + .addHook('onRoute', (opts) => { + if (opts.path === `${apiPrefix}/healthz`) { + opts.logLevel = 'silent' + } + }) + .setErrorHandler((err: Error, req: FastifyRequest, reply) => { + const statusCode = 500 + // @ts-ignore vérifier l'objet + const message = err.description || err.message + reply + .status(statusCode) + .send({ status: statusCode, error: message, stack: err.stack }) + req.log.error({ err, reqId: req.id }, 'Unhandled request error') + }) + .addHook('onResponse', (req, res) => { + const user = req.session?.user as { + id: string + } + if (res.statusCode < 400) { + req.log.info({ status: res.statusCode, userId: user?.id }) + } else if (res.statusCode < 500) { + req.log.warn({ status: res.statusCode, userId: user?.id }) + } else { + req.log.error({ status: res.statusCode, userId: user?.id }) + } + }) + +await app.ready() + +export default app diff --git a/.jjconflict-base-1/apps/server/src/bootstrap.ts b/.jjconflict-base-1/apps/server/src/bootstrap.ts new file mode 100644 index 0000000000..83c833f007 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/bootstrap.ts @@ -0,0 +1,3 @@ +import './runtime.js' +import './telemetry.js' +import './server.js' diff --git a/.jjconflict-base-1/apps/server/src/connect.spec.ts b/.jjconflict-base-1/apps/server/src/connect.spec.ts new file mode 100644 index 0000000000..363c89d290 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/connect.spec.ts @@ -0,0 +1,86 @@ +import { logger } from '@cpn-console/logger' +import { PrismaClientInitializationError } from '@prisma/client/runtime/library.js' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from './__mocks__/prisma.js' +import app from './app.js' +import { getConnection } from './connect.js' + +vi.mock( + 'fastify-keycloak-adapter', + (await import('./utils/mocks.js')).mockSessionPlugin, +) +vi.mock('@/resources/queries-index.js') +vi.mock('./models/log.js', () => getModel('getLogModel')) +vi.mock('./models/repository.js', () => getModel('getRepositoryModel')) +vi.mock('./models/permission.js', () => getModel('getPermissionModel')) +vi.mock('./models/environment.js', () => getModel('getEnvironmentModel')) +vi.mock('./models/project.js', () => getModel('getProjectModel')) +vi.mock('./models/user.js', () => getModel('getUserModel')) +vi.mock('./models/users-projects.js', () => getModel('getRolesModel')) +vi.mock('./models/zone.js', () => getModel('getZoneModel')) +vi.mock('./prisma.js') +vi.mock('./utils/env.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isTest: true, + } +}) + +vi.spyOn(app, 'listen') +const infoSpy = vi.spyOn(logger, 'info') +const errorSpy = vi.spyOn(logger, 'error') +const debugSpy = vi.spyOn(logger, 'debug') + +function getModel(modelName: string) { + return { + [modelName]: vi.fn(() => ({ + sync: vi.fn(), + hasMany: vi.fn(), + belongsTo: vi.fn(), + belongsToMany: vi.fn(), + })), + } +} + +describe('connect', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should connect to postgres', async () => { + await getConnection() + + expect(debugSpy.mock.calls).toHaveLength(1) + expect(debugSpy.mock.calls[0]?.[1]).toBe('Connecting to Postgres') + + expect(infoSpy.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toContainEqual(['Connected to Postgres!']) + }) + + it( + 'should fail to connect once, then connect to postgres', + { timeout: 10_000 }, + async () => { + const errorToCatch = new PrismaClientInitializationError( + 'Failed to connect', + '2.19.0', + 'P1001', + ) + + prisma.$connect.mockRejectedValueOnce(errorToCatch) + await getConnection() + + expect(errorSpy.mock.calls).toHaveLength(1) + expect(errorSpy.mock.calls[0]?.[1]).toBe('Could not connect to Postgres') + + expect(infoSpy.mock.calls).toHaveLength(2) + expect(infoSpy.mock.calls[0]?.[1]).toBe('Retrying Postgres connection') + expect(infoSpy.mock.calls).toContainEqual(['Connected to Postgres!']) + + expect(debugSpy.mock.calls).toHaveLength(2) + expect(debugSpy.mock.calls[0]?.[1]).toBe('Connecting to Postgres') + expect(debugSpy.mock.calls[1]?.[1]).toBe('Connecting to Postgres') + }, + ) +}) diff --git a/.jjconflict-base-1/apps/server/src/connect.ts b/.jjconflict-base-1/apps/server/src/connect.ts new file mode 100644 index 0000000000..31d608a66e --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/connect.ts @@ -0,0 +1,63 @@ +import { setTimeout } from 'node:timers/promises' +import { logger } from '@cpn-console/logger' +import prisma from './prisma.js' +import { + dbUrl, + isCI, + isDev, + isTest, +} from './utils/env.js' + +const DELAY_BEFORE_RETRY = isTest ? 0 : isCI ? 1000 : 10000 +let closingConnections = false +const leadingSlashRegExp = /^\// + +function parseDbUrl(url: string | undefined) { + if (!url) return undefined + try { + const parsed = new URL(url) + return { + host: parsed.host, + database: parsed.pathname?.replace(leadingSlashRegExp, '') || undefined, + } + } catch { + return undefined + } +} + +export async function getConnection(triesLeft = 5): Promise { + if (closingConnections || triesLeft <= 0) { + throw new Error('Unable to connect to Postgres server') + } + triesLeft-- + + try { + if (isDev || isTest || isCI) + logger.debug({ db: parseDbUrl(dbUrl) }, 'Connecting to Postgres') + await prisma.$connect() + + logger.info('Connected to Postgres!') + } catch (err) { + if (triesLeft > 0) { + logger.error({ err }, 'Could not connect to Postgres') + logger.info({ triesLeft }, 'Retrying Postgres connection') + await setTimeout(DELAY_BEFORE_RETRY) + return getConnection(triesLeft) + } + + logger.error({ err }, 'Out of retries connecting to Postgres') + err.message = `Out of retries connecting to Postgres, last error: ${err.message}` + throw err + } +} + +export async function closeConnections() { + closingConnections = true + try { + await prisma.$disconnect() + } catch (error) { + logger.error({ err: error }, 'Could not disconnect from Postgres') + } finally { + closingConnections = false + } +} diff --git a/.jjconflict-base-1/apps/server/src/init/db/dump.ts b/.jjconflict-base-1/apps/server/src/init/db/dump.ts new file mode 100644 index 0000000000..6709946605 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/init/db/dump.ts @@ -0,0 +1,28 @@ +// @ts-nocheck + +/** + * How to use ? + * npx vite-node src/init/db/dump.ts + * format ./data.ts with linter + * cut/paste to packages/test-utils/src/imports/data.ts + */ + +import { writeFileSync } from 'node:fs' +import { Prisma } from '@prisma/client' +import prisma from '@/prisma.js' +import { associations, manyToManyRelation, modelKeys, models, resourceListToDict } from './utils.js' + +const Models = resourceListToDict(Prisma.dmmf.datamodel.models) + +for (const modelKey of modelKeys) { + const modelDatas = await prisma[modelKey].findMany() + models[modelKey] = modelDatas +} +for (const [model, targetModel, relationKey] of manyToManyRelation) { + const modelKey = model.slice(0, 1).toLocaleLowerCase() + model.slice(1) + const modelDatas = await prisma[modelKey].findMany({ select: { [Models[model].id]: true, [relationKey]: { select: { [Models[targetModel].id]: true } } } }) + associations.push([modelKey, modelDatas]) +} +const a = JSON.stringify({ ...models, associations }, null, 2) + +writeFileSync('./data.ts', `export const data = ${a}`) diff --git a/.jjconflict-base-1/apps/server/src/init/db/index.ts b/.jjconflict-base-1/apps/server/src/init/db/index.ts new file mode 100644 index 0000000000..fe37b33281 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/init/db/index.ts @@ -0,0 +1,51 @@ +import { logger } from '@cpn-console/logger' +import prisma from '@/prisma.js' +import { modelKeys } from './utils.js' + +type ExtractKeysWithFields = { + [K in keyof T]: T[K] extends { fields: any } ? K : never +}[keyof T] + +type Models = ExtractKeysWithFields + +type Imports = Partial> & { + associations: [Models, any[]] +} + +export async function initDb(data: Imports) { + const dataStringified = JSON.stringify(data) + const dataParsed = JSON.parse(dataStringified, (key, value) => { + try { + if (['permissions', 'everyonePerms'].includes(key)) { + return BigInt(value.slice(0, value.length - 1)) + } + } catch { + return value + } + return value + }) + logger.info('Drop tables') + for (const modelKey of modelKeys.toReversed()) { + // @ts-ignore + await prisma[modelKey].deleteMany() + } + logger.info('Import models') + for (const modelKey of modelKeys) { + // @ts-ignore + await prisma[modelKey].createMany({ data: dataParsed[modelKey] }) + } + logger.info('Import associations') + for (const [modelKey, rows] of dataParsed.associations) { + for (const row of rows) { + const idKey = 'id' + const connectKeys = Object.keys(row).filter(key => key !== idKey) + const dataConnects = connectKeys.reduce((acc, curr) => { + acc[curr] = { connect: row[curr] } + return acc + }, {} as Record) + // @ts-ignore + await prisma[modelKey].update({ where: { id: row.id }, data: dataConnects }) + } + } + logger.info('End import') +} diff --git a/.jjconflict-base-1/apps/server/src/init/db/utils.spec.ts b/.jjconflict-base-1/apps/server/src/init/db/utils.spec.ts new file mode 100644 index 0000000000..9b831ee089 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/init/db/utils.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { modelKeys, moveBefore, resourceListToDict } from './utils.js' + +vi.mock('fs', () => ({ writeFileSync: vi.fn() })) +for (const modelKey of modelKeys) { + prisma[modelKey].findMany.mockResolvedValue([]) +} + +describe('test moveBefore', () => { + it('should be moved', () => { + const arr = ['a', 'b', 'c'] + const arrSorted = moveBefore(arr, 'c', 'b') + expect(arrSorted).toEqual(['a', 'c', 'b']) + + const arrSorted2 = moveBefore(arr, 'c', 'a') + expect(arrSorted2).toEqual(['c', 'a', 'b']) + }) + it('should not be moved', () => { + const arr = ['a', 'b', 'c'] + const arrSorted = moveBefore(arr, 'b', 'c') + expect(arrSorted).toEqual(false) + + const arrSorted2 = moveBefore(arr, 'a', 'c') + expect(arrSorted2).toEqual(false) + + const arrSorted3 = moveBefore(arr, 'c', 'c') + expect(arrSorted3).toEqual(false) + }) +}) + +it('test resourceListToDict (by name)', () => { + const list = [ + { name: 'a', value: 1 }, + { name: 'b', value: 2 }, + { name: 'c', value: 3 }, + ] + const dict = resourceListToDict(list) + expect(dict).toEqual({ + a: { name: 'a', value: 1 }, + b: { name: 'b', value: 2 }, + c: { name: 'c', value: 3 }, + }) +}) + +it('stringify bigint', () => { + const list = { name: 'a', value: 1n } + + const dict = JSON.stringify(list) + + expect(dict).toEqual('{"name":"a","value":"1n"}') +}) diff --git a/.jjconflict-base-1/apps/server/src/init/db/utils.ts b/.jjconflict-base-1/apps/server/src/init/db/utils.ts new file mode 100644 index 0000000000..e4b941089f --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/init/db/utils.ts @@ -0,0 +1,85 @@ +// @ts-nocheck +import { Prisma } from '@prisma/client' + +// eslint-disable-next-line no-extend-native +BigInt.prototype.toJSON = function () { + return `${this.toString()}n` +} + +export type ResourceByName = Record +export function resourceListToDict(resList: Array): ResourceByName { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.name]: curr, + } + }, {} as ResourceByName) +} + +// @ts-ignore +const Models = resourceListToDict(Prisma.dmmf.datamodel.models) +let ModelsNames = Object.keys(Models) +let ModelsOrder = [...ModelsNames] + +export function moveBefore(arr: T, toMove: T[number], ref: T[number]): T | false { + const iref = arr.indexOf(ref) + const moveref = arr.indexOf(toMove) + if (moveref <= iref) return false + return [ + ...arr.slice(0, iref), + arr[moveref], + ...arr.slice(iref, moveref), + ...arr.slice(moveref + 1), + ] as T +} + +export const manyToManyRelation: [string, string, string][] = [] +function sort() { + let hasChanged = false + for (const model of ModelsNames) { + for (const field of Models[model].fields) { + if (field.isId) Models[model].id = field.name + if (field.type in Models) { + const relationField = Models[field.type].fields.find(({ type }) => type === model) + if (!relationField) throw new Error('unable to find matching model') + if ( + (relationField.isRequired && field.isRequired && !relationField.isList) + || (relationField.isRequired && !field.isRequired) + ) { + const moveRes = moveBefore(ModelsOrder, model, field.type) + if (moveRes) { + hasChanged = true + ModelsOrder = moveRes + } + } + if ( + field.isList && relationField.isList + && !manyToManyRelation.some(test => + (test[0] === model && test[1] === field.type) || (test[0] === field.type && test[1] === model)) + ) { + manyToManyRelation.push([model, field.type, field.name]) + } + } + } + } + ModelsNames = ModelsOrder + if (hasChanged) sort() +} + +sort() + +// special case to study +const logUserCase = moveBefore(ModelsOrder, 'User', 'Log') +if (logUserCase) { + ModelsOrder = logUserCase +} +const logProjectCase = moveBefore(ModelsOrder, 'Project', 'Log') +if (logProjectCase) { + ModelsOrder = logProjectCase +} + +export const models: Record = {} +export const associations: Record = [] +export const modelKeys = ModelsOrder.map(model => model.slice(0, 1).toLocaleLowerCase() + model.slice(1)) diff --git a/.jjconflict-base-1/apps/server/src/mocks/prisma.ts b/.jjconflict-base-1/apps/server/src/mocks/prisma.ts new file mode 100644 index 0000000000..075578c96c --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/mocks/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma.js') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-base-1/apps/server/src/mocks/utils.ts b/.jjconflict-base-1/apps/server/src/mocks/utils.ts new file mode 100644 index 0000000000..c57dd0f52b --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/mocks/utils.ts @@ -0,0 +1,24 @@ +import type { User } from '@cpn-console/test-utils' +import fp from 'fastify-plugin' + +let requestor: User + +export function setRequestor(user: User) { + requestor = user +} + +export function getRequestor() { + return requestor +} + +export async function mockSessionPlugin() { + const sessionPlugin = (app, opt, next) => { + app.addHook('onRequest', (req, res, next) => { + req.session = { user: getRequestor() } + next() + }) + next() + } + + return { default: fp(sessionPlugin) } +} diff --git a/.jjconflict-base-1/apps/server/src/plugins.ts b/.jjconflict-base-1/apps/server/src/plugins.ts new file mode 100644 index 0000000000..fcb54775f2 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/plugins.ts @@ -0,0 +1,47 @@ +import type { Plugin } from '@cpn-console/hooks' +import { readdirSync, statSync } from 'node:fs' +import { plugin as argo } from '@cpn-console/argocd-plugin' +import { plugin as gitlab } from '@cpn-console/gitlab-plugin' +import { plugin as harbor } from '@cpn-console/harbor-plugin' +import { pluginManager } from '@cpn-console/hooks' +import { plugin as keycloak } from '@cpn-console/keycloak-plugin' +import { logger as baseLogger } from '@cpn-console/logger' +import { plugin as nexus } from '@cpn-console/nexus-plugin' +import { plugin as sonarqube } from '@cpn-console/sonarqube-plugin' +import { plugin as vault } from '@cpn-console/vault-plugin' +import { pluginsDir } from './utils/env.js' +import { pluginManagerOptions } from './utils/plugins.js' + +const logger = baseLogger.child({ scope: 'plugin-manager' }) + +export async function initPm() { + const pm = pluginManager(pluginManagerOptions) + pm.register(argo) + pm.register(gitlab) + pm.register(harbor) + pm.register(keycloak) + pm.register(nexus) + pm.register(sonarqube) + pm.register(vault) + + if (!statSync(pluginsDir, { + throwIfNoEntry: false, + })) { + return pm + } + for (const dirName of readdirSync(pluginsDir)) { + const moduleAbsPath = `${pluginsDir}/${dirName}` + try { + statSync(`${moduleAbsPath}/package.json`) + const pkg = await import(`${moduleAbsPath}/package.json`, { with: { type: 'json' } }) + const entrypoint = pkg.default.module || pkg.default.main + if (!entrypoint) throw new Error(`No entrypoint found in package.json : ${pkg.default.name}`) + const { plugin } = await import(`${moduleAbsPath}/${entrypoint}`) as { plugin: Plugin } + pm.register(plugin) + } catch (error) { + logger.error({ err: error, moduleAbsPath }, 'Could not import external plugin module') + } + } + + return pm +} diff --git a/.jjconflict-base-1/apps/server/src/prepare-app.spec.ts b/.jjconflict-base-1/apps/server/src/prepare-app.spec.ts new file mode 100644 index 0000000000..5086faa776 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prepare-app.spec.ts @@ -0,0 +1,70 @@ +import { logger } from '@cpn-console/logger' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from './app.js' +import { getConnection } from './connect.js' +import { initDb } from './init/db/index.js' +import { getPreparedApp } from './prepare-app.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) +vi.mock('./connect.js') +vi.mock('./index.js') +vi.mock('./init/db/index.js', () => ({ initDb: vi.fn() })) + +vi.spyOn(app, 'listen') +vi.spyOn(logger, 'warn') +vi.spyOn(logger, 'error') +vi.spyOn(logger, 'debug') + +describe('server', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should getConnection', async () => { + await getPreparedApp() + + expect(getConnection).toHaveBeenCalledTimes(1) + expect(initDb.mock.calls).toHaveLength(1) + }) + + it('should throw an error on connection to DB', async () => { + const error = new Error('This is OK!') + getConnection.mockRejectedValueOnce(error) + + let response + await getPreparedApp() + .catch((err) => { response = err }) + + expect(getConnection.mock.calls).toHaveLength(1) + expect(app.listen.mock.calls).toHaveLength(0) + expect(response).toMatchObject(error) + }) + + it('should throw an error on initDb import if module is not found', async () => { + const infoSpy = vi.spyOn(logger, 'info') + const error = new Error('Failed to load') + initDb.mockRejectedValueOnce(error) + + await getPreparedApp() + + expect(initDb.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toHaveLength(3) + }) + + it('should throw an error on initDb import', async () => { + const infoSpy = vi.spyOn(logger, 'info') + const error = new Error('This is OK!') + initDb.mockRejectedValueOnce(error) + + let response + try { + await getPreparedApp() + } catch (err) { + response = err + } + + expect(initDb.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toHaveLength(2) + expect(response).toMatchObject(error) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/prepare-app.ts b/.jjconflict-base-1/apps/server/src/prepare-app.ts new file mode 100644 index 0000000000..000734714b --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prepare-app.ts @@ -0,0 +1,96 @@ +import { rm } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { logger } from '@cpn-console/logger' +import app from './app.js' +import { getConnection } from './connect.js' +import { initDb } from './init/db/index.js' +import { initPm } from './plugins.js' +import { isCI, isDev, isDevSetup, isInt, isProd, isTest, port } from './utils/env.js' + +async function initializeDB(path: string) { + logger.info('Starting init DB...') + const { data } = await import(path) + await initDb(data) + logger.info('initDb invoked successfully') +} + +export async function startServer(defaultPort: number = (port ? +port : 8080)) { + try { + await getConnection() + } catch (error) { + logger.error({ err: error }, 'Database connection failed') + if (!(error instanceof Error)) return + throw error + } + + initPm() + + logger.info('Reading init database file') + + try { + const dataPath = (isProd || isInt) + ? './init/db/imports/data.js' + : '@cpn-console/test-utils/src/imports/data.ts' + await initializeDB(dataPath) + if (isProd && !isDevSetup) { + logger.info('Cleaning up imported data file...') + const __filename = fileURLToPath(import.meta.url) + const __dirname = dirname(__filename) + await rm(resolve(__dirname, dataPath)) + logger.info(`Successfully deleted '${dataPath}'`) + } + } catch (err) { + if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.message?.includes('Failed to load') || err?.message?.includes('Cannot find module')) { + logger.info('No initDb file, skipping') + } else { + logger.warn({ err }, 'Init DB failed') + throw err + } + } + + try { + await app.listen({ host: '0.0.0.0', port: defaultPort ?? 8080 }) + } catch (error) { + logger.error({ err: error }, 'Failed to start HTTP server') + process.exit(1) + } + logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) +} + +export async function getPreparedApp() { + try { + await getConnection() + } catch (error) { + logger.error({ err: error }, 'Database connection failed') + throw error + } + + initPm() + + logger.info('Reading init database file') + + try { + const dataPath = (isProd || isInt) + ? './init/db/imports/data.js' + : '@cpn-console/test-utils/src/imports/data.ts' + await initializeDB(dataPath) + if (isProd && !isDevSetup) { + logger.info('Cleaning up imported data file...') + const __filename = fileURLToPath(import.meta.url) + const __dirname = dirname(__filename) + await rm(resolve(__dirname, dataPath)) + logger.info(`Successfully deleted '${dataPath}'`) + } + } catch (err) { + if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.message?.includes('Failed to load') || err?.message?.includes('Cannot find module')) { + logger.info('No initDb file, skipping') + } else { + logger.warn({ err }, 'Init DB failed') + throw err + } + } + + logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) + return app +} diff --git a/.jjconflict-base-1/apps/server/src/prisma.ts b/.jjconflict-base-1/apps/server/src/prisma.ts new file mode 100644 index 0000000000..4590932b68 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +export default prisma diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql new file mode 100644 index 0000000000..f2f4e7b0b7 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql @@ -0,0 +1,151 @@ +-- CreateTable +CREATE TABLE "Environment" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Environment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Log" ( + "id" UUID NOT NULL, + "data" JSONB NOT NULL, + "action" TEXT NOT NULL DEFAULT '', + "userId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Log_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Organization" ( + "id" UUID NOT NULL, + "source" TEXT NOT NULL, + "name" TEXT NOT NULL, + "label" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Permission" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "environmentId" UUID NOT NULL, + "level" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Permission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "organizationId" UUID NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL, + "locked" BOOLEAN NOT NULL DEFAULT false, + "services" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Repository" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "internalRepoName" TEXT NOT NULL, + "externalRepoUrl" TEXT NOT NULL, + "externalUserName" TEXT, + "externalToken" TEXT, + "isInfra" BOOLEAN NOT NULL DEFAULT false, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Repository_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" UUID NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Role" ( + "userId" UUID NOT NULL, + "projectId" UUID NOT NULL, + "role" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Role_pkey" PRIMARY KEY ("userId","projectId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_id_key" ON "Organization"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_name_key" ON "Organization"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_label_key" ON "Organization"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_id_key" ON "Permission"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_userId_environmentId_key" ON "Permission"("userId", "environmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_id_key" ON "Project"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Role_userId_projectId_key" ON "Role"("userId", "projectId"); + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql new file mode 100644 index 0000000000..26e1ade3f2 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql @@ -0,0 +1,85 @@ +-- CreateEnum +CREATE TYPE "ClusterPrivacy" AS ENUM ('public', 'dedicated'); + +-- CreateTable +CREATE TABLE "Cluster" ( + "id" UUID NOT NULL, + "label" VARCHAR(50) NOT NULL, + "privacy" "ClusterPrivacy" NOT NULL DEFAULT 'dedicated', + "secretName" VARCHAR(50) NOT NULL, + "clusterResources" BOOLEAN NOT NULL DEFAULT false, + "kubeConfigId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cluster_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Kubeconfig" ( + "id" UUID NOT NULL, + "user" JSONB NOT NULL, + "cluster" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "parentClusterId" UUID, + + CONSTRAINT "Kubeconfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_ClusterToEnvironment" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateTable +CREATE TABLE "_ClusterToProject" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_id_key" ON "Cluster"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_label_key" ON "Cluster"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_secretName_key" ON "Cluster"("secretName"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_kubeConfigId_key" ON "Cluster"("kubeConfigId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_id_key" ON "Kubeconfig"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_parentClusterId_key" ON "Kubeconfig"("parentClusterId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToEnvironment_AB_unique" ON "_ClusterToEnvironment"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToEnvironment_B_index" ON "_ClusterToEnvironment"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToProject_AB_unique" ON "_ClusterToProject"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToProject_B_index" ON "_ClusterToProject"("B"); + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_kubeConfigId_fkey" FOREIGN KEY ("kubeConfigId") REFERENCES "Kubeconfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_B_fkey" FOREIGN KEY ("B") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_B_fkey" FOREIGN KEY ("B") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql new file mode 100644 index 0000000000..8f3fb5ff9f --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `parentClusterId` on the `Kubeconfig` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Kubeconfig_parentClusterId_key"; + +-- AlterTable +ALTER TABLE "Kubeconfig" DROP COLUMN "parentClusterId"; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql new file mode 100644 index 0000000000..4eb37edc53 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql @@ -0,0 +1,10 @@ +CREATE TYPE "ProjectStatus" AS ENUM ('initializing', 'created', 'failed', 'archived'); + +ALTER TABLE public."Project" ALTER COLUMN status TYPE "ProjectStatus" USING + case + when status = 'created' then 'created'::"ProjectStatus" + when status = 'failed' then 'failed'::"ProjectStatus" + when status = 'archived' then 'archived'::"ProjectStatus" + else 'initializing'::"ProjectStatus" + end; +ALTER TABLE public."Project" ALTER COLUMN status SET DEFAULT 'initializing'; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql new file mode 100644 index 0000000000..f402a0e3d4 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "infos" VARCHAR(200); diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql new file mode 100644 index 0000000000..f553e7880e --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `externalToken` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "externalToken"; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql new file mode 100644 index 0000000000..394b650a51 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql @@ -0,0 +1,81 @@ +-- CreateEnum +CREATE TYPE "QuotaStageStatus" AS ENUM +('active', 'pendingDelete'); + +-- Create new tables +-- CreateTable +CREATE TABLE "Quota" +( + "id" UUID NOT NULL, + "memory" VARCHAR NOT NULL, + "cpu" REAL NOT NULL, + "name" VARCHAR NOT NULL, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "Quota_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Stage" +( + "id" UUID NOT NULL, + "name" VARCHAR NOT NULL, + + CONSTRAINT "Stage_pkey" PRIMARY KEY ("id") +); + +-- Associate Quotas and Stages +-- CreateTable +CREATE TABLE "QuotaStage" +( + "id" UUID NOT NULL, + "quotaId" UUID NOT NULL, + "stageId" UUID NOT NULL, + "status" "QuotaStageStatus" NOT NULL DEFAULT 'active', + + CONSTRAINT "QuotaStage_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "Quota_id_key" ON "Quota"("id"); +CREATE UNIQUE INDEX "Quota_name_key" ON "Quota"("name"); +CREATE UNIQUE INDEX "Stage_id_key" ON "Stage"("id"); +CREATE UNIQUE INDEX "Stage_name_key" ON "Stage"("name"); +CREATE UNIQUE INDEX "QuotaStage_id_key" ON "QuotaStage"("id"); +CREATE UNIQUE INDEX "QuotaStage_quotaId_stageId_key" ON "QuotaStage"("quotaId", "stageId"); +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Create default values for Quotas and Stages +-- Quota +INSERT INTO "Quota" + (id, cpu, memory, "name", "isPrivate") +VALUES + ('5a57b62f-2465-4fb6-a853-5a751d099199', 2, '4Gi', 'small', false), + ('08770663-3b76-4af6-8978-9f75eda4faa7', 4, '8Gi', 'medium', false), + ('b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 6, '12Gi', 'large', false), + ('97b851e8-9067-4a3d-a0e8-c3a6820c49be', 8, '16Gi', 'xlarge', false); + +-- Stage +INSERT INTO "Stage" + (id, "name") +VALUES + ('4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', 'dev'), + ('38fa869d-6267-441d-af7f-e0548fd06b7e', 'staging'), + ('d434310e-7850-4d59-b47f-0772edf50582', 'integration'), + ('9b3e9991-896d-4d90-bdc5-a34be8c06b8f', 'prod'); + +-- QuotaStage +INSERT INTO "QuotaStage" + (id, "quotaId", "stageId") +VALUES + ('0cb0c549-560e-4f26-8f4e-832dd722f68a', '5a57b62f-2465-4fb6-a853-5a751d099199', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('0530e9c9-b37d-4dec-93e6-1895f700e61c', '5a57b62f-2465-4fb6-a853-5a751d099199', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('8a99db49-b7b1-44bf-865d-5e709e8aa0fc', '5a57b62f-2465-4fb6-a853-5a751d099199', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('67561f00-d219-4ca6-b94a-3ee83f09d2d6', '5a57b62f-2465-4fb6-a853-5a751d099199', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('8b3c201e-7518-4254-a94a-16c404e46936', '08770663-3b76-4af6-8978-9f75eda4faa7', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('9157ae12-3e39-43f8-a24f-ae5d9c6b69b7', '08770663-3b76-4af6-8978-9f75eda4faa7', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('c733a1dd-c9fd-4def-b29e-df49ef7b6698', '08770663-3b76-4af6-8978-9f75eda4faa7', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('15a51f47-0ab2-4a94-a808-722639d8c092', '08770663-3b76-4af6-8978-9f75eda4faa7', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('cb66e80c-2304-472d-bc19-a411011674ca', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('59fb0e79-3a76-4b96-81d4-63f4caa98cfa', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('4174b22c-2bee-4f4a-9d85-da7b5463f214', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql new file mode 100644 index 0000000000..8c98a7f74e --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql @@ -0,0 +1,35 @@ +-- Multiplication des environnements par clusteurs +ALTER TABLE "Environment" ADD COLUMN "clusterId" UUID; + +DO +$$ +DECLARE + perm record; + cte record; + env_uuid UUID; +BEGIN + FOR cte IN SELECT "B" AS environmentId, "A" AS "clusterId", "name", "projectId", status, "updatedAt", "createdAt" + FROM public."_ClusterToEnvironment", public."Environment" + WHERE public."Environment".id = "B" + LOOP + env_uuid := gen_random_uuid(); + INSERT INTO public."Environment" (id, "name", "projectId", "clusterId", status, "createdAt", "updatedAt") VALUES + (env_uuid, cte."name", cte."projectId", cte."clusterId", cte.status, cte."createdAt", cte."updatedAt"); + + FOR perm in SELECT * FROM public."Permission" WHERE "environmentId" = cte.environmentId + LOOP + INSERT INTO public."Permission" (id, "level", "createdAt", "updatedAt", "environmentId", "userId") VALUES + (gen_random_uuid(), perm."level", perm."createdAt", perm."updatedAt", env_uuid, perm."userId"); + END LOOP; + END LOOP; +END; +$$ +; +DELETE FROM public."Environment" WHERE "clusterId" is null; +ALTER TABLE public."Environment" ALTER COLUMN "clusterId" SET NOT NULL; +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_clusterId_fkey" FOREIGN KEY ("clusterId") REFERENCES "Cluster"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Delete old _ClusterToEnvironment +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_A_fkey"; +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_B_fkey"; +DROP TABLE "_ClusterToEnvironment"; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql new file mode 100644 index 0000000000..035cd1c855 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql @@ -0,0 +1,36 @@ +-- Associate cluster to Stages +-- CreateTable +CREATE TABLE "_ClusterToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToStage_AB_unique" ON "_ClusterToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToStage_B_index" ON "_ClusterToStage"("B"); + +DO +$$ +DECLARE + cluster record; + cte record; + env_uuid UUID; +BEGIN + FOR cluster IN SELECT id + FROM public."Cluster" + LOOP + INSERT INTO public."_ClusterToStage" ("A", "B") VALUES + (cluster.id, '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + (cluster.id, '38fa869d-6267-441d-af7f-e0548fd06b7e'), + (cluster.id, 'd434310e-7850-4d59-b47f-0772edf50582'), + (cluster.id, '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); + END LOOP; +END; +$$ diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql new file mode 100644 index 0000000000..43793bdb49 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaStageId" UUID; +UPDATE "Environment" SET "quotaStageId" = '8b3c201e-7518-4254-a94a-16c404e46936' WHERE "name" = 'dev'; +UPDATE "Environment" SET "quotaStageId" = '9157ae12-3e39-43f8-a24f-ae5d9c6b69b7' WHERE "name" = 'staging'; +UPDATE "Environment" SET "quotaStageId" = '4174b22c-2bee-4f4a-9d85-da7b5463f214' WHERE "name" = 'integration'; +UPDATE "Environment" SET "quotaStageId" = 'de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a' WHERE "name" = 'prod'; +ALTER TABLE "Environment" ALTER COLUMN "name" SET DATA TYPE VARCHAR(11); +ALTER TABLE "Environment" ALTER COLUMN "quotaStageId" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaStageId_fkey" FOREIGN KEY ("quotaStageId") REFERENCES "QuotaStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql new file mode 100644 index 0000000000..9af004b6a3 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- lock all projects +UPDATE public."Project" SET "locked"=true \ No newline at end of file diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql new file mode 100644 index 0000000000..d970d3965a --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- set all projects to failed to avoid unlock them +UPDATE public."Project" SET "status" = 'failed' WHERE "status" != 'archived' \ No newline at end of file diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql new file mode 100644 index 0000000000..c387d9885c --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ADD COLUMN "requestId" VARCHAR(21); diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql new file mode 100644 index 0000000000..18e20262c8 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `status` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the column `status` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "status"; + +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "status"; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql new file mode 100644 index 0000000000..f784b21567 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql @@ -0,0 +1,46 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "zoneId" UUID; + +-- CreateTable +CREATE TABLE "Zone" +( + "id" UUID NOT NULL, + "slug" VARCHAR(10) NOT NULL, + "label" VARCHAR(50) NOT NULL, + "description" VARCHAR(200), + "createdAt" TIMESTAMP +(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP +(3) NOT NULL, + + CONSTRAINT "Zone_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_id_key" ON "Zone"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_slug_key" ON "Zone"("slug"); + +-- Create default zone +INSERT INTO "Zone" + (id, "slug", "label", "description", "updatedAt") +VALUES + ('a66c4230-eba6-41f1-aae5-bb1e4f90cce0', 'default', 'Zone Défaut', 'Zone par défaut, à changer', CURRENT_TIMESTAMP); + +-- Set default zoneId for current clusters +UPDATE "Cluster" +SET "zoneId" += 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0' +WHERE "zoneId" +IS NULL; + +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "zoneId" +SET +NOT NULL; + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") +ON DELETE RESTRICT ON +UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql new file mode 100644 index 0000000000..cb4f7ad968 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "ProjectPlugin" ( + "pluginName" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "AdminPlugin" ( + "pluginName" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectPlugin_projectId_pluginName_key_key" ON "ProjectPlugin"("projectId", "pluginName", "key"); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminPlugin_pluginName_key_key" ON "AdminPlugin"("pluginName", "key"); + +-- AddForeignKey +ALTER TABLE "ProjectPlugin" ADD CONSTRAINT "ProjectPlugin_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql new file mode 100644 index 0000000000..11672324f8 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql @@ -0,0 +1,19 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id, services FROM public."Project" LOOP + -- Extrait 'registry.id' de la colonne JSON 'services' + registry_id := (SELECT (project_row.services -> 'registry' ->> 'id')::TEXT); + -- Si 'registry.id' existe, insérer dans la table 'config' + IF registry_id IS NOT NULL THEN + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'registry', 'projectId', registry_id::TEXT); + END IF; + END LOOP; +END $$; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "services"; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql new file mode 100644 index 0000000000..9c7d5a8f8f --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "RoleList" AS ENUM ('owner', 'user'); + +-- AlterTable +ALTER TABLE public."Role" ALTER COLUMN "role" TYPE "RoleList" USING + case + when role = 'owner' then 'owner'::"RoleList" + else 'user'::"RoleList" + end; \ No newline at end of file diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql new file mode 100644 index 0000000000..45a4a5d1ea --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "ProjectClusterHistory" ( + "projectId" UUID NOT NULL, + "clusterId" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectClusterHistory_projectId_clusterId_key" ON "ProjectClusterHistory"("projectId", "clusterId"); diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql new file mode 100644 index 0000000000..2b1641a657 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql @@ -0,0 +1,11 @@ +DO $$ +DECLARE + env_row RECORD; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR env_row IN SELECT "projectId", "clusterId" FROM public."Environment" LOOP + INSERT INTO public."ProjectClusterHistory" ("projectId", "clusterId") + VALUES (env_row."projectId", env_row."clusterId") + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql new file mode 100644 index 0000000000..5e7ff03d45 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql @@ -0,0 +1,58 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaId" UUID, +ADD COLUMN "stageId" UUID; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "_QuotaToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_QuotaToStage_AB_unique" ON "_QuotaToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_QuotaToStage_B_index" ON "_QuotaToStage"("B"); + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +DO $$ +DECLARE + quota_stage_row RECORD; +BEGIN + FOR quota_stage_row IN SELECT * FROM public."QuotaStage" loop + UPDATE public."Environment" SET "stageId" = quota_stage_row."stageId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + UPDATE public."Environment" SET "quotaId" = quota_stage_row."quotaId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + insert into public."_QuotaToStage" values (quota_stage_row."quotaId", quota_stage_row."stageId"); + END LOOP; +END $$; + +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaStageId_fkey"; + +-- AlterTable +ALTER TABLE "Environment" ALTER COLUMN "quotaId" SET NOT NULL, +ALTER COLUMN "stageId" SET NOT NULL, +DROP COLUMN "quotaStageId"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_stageId_fkey"; + +-- DropTable +DROP TABLE "QuotaStage"; + +-- DropEnum +DROP TYPE "QuotaStageStatus"; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql new file mode 100644 index 0000000000..0036da8a12 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Made the column `description` on table `Project` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "description" SET NOT NULL, +ALTER COLUMN "description" SET DEFAULT ''; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql new file mode 100644 index 0000000000..ed6ae9b84e --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql @@ -0,0 +1,198 @@ +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_environmentId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_environmentId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_userId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_userId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_projectId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_projectId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_userId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_userId_fkey"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectMembers') THEN + CREATE TABLE "ProjectMembers" ( + "projectId" UUID NOT NULL, + "userId" UUID NOT NULL, + "roleIds" TEXT[] + ); + END IF; +END $$; + +-- AlterTable +ALTER TABLE "Log" ADD COLUMN IF NOT EXISTS "projectId" UUID; + +INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt") +VALUES('04ac168a-2c4f-4816-9cce-af6c612e5912'::uuid, 'Anonymous', 'User', 'anon@user', '2023-07-03 14:46:56.770', '2023-07-03 14:46:56.770') +ON CONFLICT (id) DO NOTHING; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "everyonePerms" BIGINT NOT NULL DEFAULT 896, +ADD COLUMN IF NOT EXISTS "ownerId" UUID; + +DO $$ +DECLARE + role_row RECORD; +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR role_row IN SELECT "userId", "projectId", "role" FROM public."Role" LOOP + INSERT INTO public."ProjectMembers" ("userId", "projectId", "roleIds") VALUES (role_row."userId", role_row."projectId", '{}'); + IF role_row."role" = 'owner'::public."RoleList" THEN + UPDATE public."Project" + SET "ownerId"=role_row."userId" + WHERE id=role_row."projectId"::uuid; + END IF; + END LOOP; + END IF; +END $$; + +UPDATE public."Project" +SET "ownerId"='04ac168a-2c4f-4816-9cce-af6c612e5912' +WHERE "ownerId" IS NULL; + +ALTER TABLE public."Project" ALTER COLUMN "ownerId" SET NOT NULL; + +DELETE FROM public."ProjectMembers" pm +USING public."Project" p +WHERE pm."userId" = p."ownerId" +AND pm."projectId" = p."id"; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Permission') THEN + DROP TABLE "Permission"; + END IF; +END $$; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + DROP TABLE "Role"; + END IF; +END $$; + +-- DropEnum if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'RoleList') THEN + DROP TYPE "RoleList"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'AdminRole') THEN + CREATE TABLE "AdminRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "AdminRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectRole') THEN + CREATE TABLE "ProjectRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "projectId" UUID NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "ProjectRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_id_key') THEN + CREATE UNIQUE INDEX "AdminRole_id_key" ON "AdminRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_name_key') THEN + CREATE UNIQUE INDEX "AdminRole_name_key" ON "AdminRole"("name"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectMembers_projectId_userId_key') THEN + CREATE UNIQUE INDEX "ProjectMembers_projectId_userId_key" ON "ProjectMembers"("projectId", "userId"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectRole_id_key') THEN + CREATE UNIQUE INDEX "ProjectRole_id_key" ON "ProjectRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'Environment_projectId_name_key') THEN + CREATE UNIQUE INDEX "Environment_projectId_name_key" ON "Environment"("projectId", "name"); + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Log_projectId_fkey') THEN + ALTER TABLE "Log" ADD CONSTRAINT "Log_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Project_ownerId_fkey') THEN + ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_projectId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_userId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectRole_projectId_fkey') THEN + ALTER TABLE "ProjectRole" ADD CONSTRAINT "ProjectRole_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "adminRoleIds" TEXT[]; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql new file mode 100644 index 0000000000..c9b41827bf --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "AdminRole_name_key"; + +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "oidcGroup" TEXT; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql new file mode 100644 index 0000000000..265f262abb --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `oidcGroup` on table `AdminRole` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable + +UPDATE public."AdminRole" +SET "oidcGroup"='' +WHERE "oidcGroup" IS NULL; + +ALTER TABLE "AdminRole" ALTER COLUMN "oidcGroup" SET NOT NULL, +ALTER COLUMN "oidcGroup" SET DEFAULT ''; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql new file mode 100644 index 0000000000..4fc2768605 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "SystemSetting" +( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "SystemSetting_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemSetting_key_key" ON "SystemSetting"("key"); + +-- Create maintenance setting +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('maintenance', 'off'); diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql new file mode 100644 index 0000000000..95ab54869d --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql @@ -0,0 +1,3 @@ +INSERT INTO public."AdminRole" +(id, "name", permissions, "position", "oidcGroup") +VALUES('76229c96-4716-45bc-99da-00498ec9018c'::uuid, 'Admin', 2, 0, '/admin'); \ No newline at end of file diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql new file mode 100644 index 0000000000..c116482186 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Made the column `externalUserName` on table `Repository` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +UPDATE "Repository" SET "externalUserName" = '' WHERE "externalUserName" IS NULL; + +ALTER TABLE "Repository" ALTER COLUMN "externalUserName" SET NOT NULL, +ALTER COLUMN "externalUserName" SET DEFAULT '', +ALTER COLUMN "externalRepoUrl" SET DEFAULT ''; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240916141253_token/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240916141253_token/migration.sql new file mode 100644 index 0000000000..b0472cd806 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240916141253_token/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "TokenStatus" AS ENUM ('active', 'revoked'); + +-- CreateTable +CREATE TABLE "AdminToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "userId" UUID, + "expirationDate" TIMESTAMP(3), + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "AdminToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminToken_id_key" ON "AdminToken"("id"); + +-- AddForeignKey +ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql new file mode 100644 index 0000000000..47488b00c5 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "Log" DROP CONSTRAINT "Log_userId_fkey"; + +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "userId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql new file mode 100644 index 0000000000..18eca3eadf --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "requestId" SET DATA TYPE VARCHAR(36); diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql new file mode 100644 index 0000000000..74e0946f0a --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Zone" ADD COLUMN "argocdUrl" TEXT NOT NULL DEFAULT 'https://example.com'; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql new file mode 100644 index 0000000000..41dac75359 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ProjectStatus" ADD VALUE 'warning'; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql new file mode 100644 index 0000000000..ef888d5e50 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql @@ -0,0 +1,12 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id FROM public."Project" WHERE status <> 'archived'::public."ProjectStatus" LOOP + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'nexus', 'activateMavenRepo', 'enabled') + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql new file mode 100644 index 0000000000..a57e5956c3 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql @@ -0,0 +1,12 @@ +-- CreateEnum +CREATE TYPE "UserType" AS ENUM ('human', 'bot', 'ghost'); + +-- AlterEnum +ALTER TYPE "TokenStatus" ADD VALUE 'inactive'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "type" "UserType" NOT NULL DEFAULT 'human'; +UPDATE "User" SET type = 'ghost' WHERE id = '04ac168a-2c4f-4816-9cce-af6c612e5912'; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "type" DROP DEFAULT; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql new file mode 100644 index 0000000000..71e15a312c --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql @@ -0,0 +1,84 @@ +-- CreateTable (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'PersonalAccessToken') THEN + CREATE TABLE "PersonalAccessToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "userId" UUID NOT NULL, + "expirationDate" TIMESTAMP(3) NOT NULL, + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'PersonalAccessToken_id_key') THEN + CREATE UNIQUE INDEX "PersonalAccessToken_id_key" ON "PersonalAccessToken"("id"); + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'PersonalAccessToken_userId_fkey') THEN + ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- Process AdminToken (idempotent) +DO $$ +DECLARE + admin_token record; + user_uuid UUID; +BEGIN + FOR admin_token IN SELECT "name", "id" + FROM public."AdminToken" + LOOP + -- Generate new UUID if user does not exist + user_uuid := COALESCE( + (SELECT id FROM public."User" WHERE email = concat(admin_token.name, '@bot.id')), + gen_random_uuid() + ); + + -- Insert user if not already exists + INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt", "type") + VALUES(user_uuid, 'Bot Admin', admin_token.name, concat(admin_token.name, '@bot.id'), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'bot') + ON CONFLICT (id) DO NOTHING; + + -- Update AdminToken with the new user ID + UPDATE public."AdminToken" SET "userId" = user_uuid WHERE id = admin_token.id; + END LOOP; +END $$; + +-- Alter AdminToken userId column to NOT NULL (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'AdminToken' AND column_name = 'userId' AND is_nullable = 'NO') THEN + ALTER TABLE public."AdminToken" ALTER COLUMN "userId" SET NOT NULL; + END IF; +END $$; + +-- DropForeignKey if exists (idempotent) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" DROP CONSTRAINT "AdminToken_userId_fkey"; + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql new file mode 100644 index 0000000000..521b2b10ac --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastLogin" TIMESTAMP(3); diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql new file mode 100644 index 0000000000..a7500833b9 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "slug" TEXT; + +UPDATE public."Project" p +SET slug = ( + SELECT concat(org.name, '-', subp.name) FROM public."Project" subp + LEFT JOIN public."Organization" org on org."id" = subp."organizationId" + WHERE subp.id = p.id +); + +ALTER TABLE public."Project" ALTER COLUMN "slug" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug"); diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql new file mode 100644 index 0000000000..b143cbeb9b --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "lastSuccessProvisionningVersion" TEXT; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql new file mode 100644 index 0000000000..7a88681901 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToProject_AB_unique"; + +-- AlterTable +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToStage_AB_unique"; + +-- AlterTable +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_QuotaToStage_AB_unique"; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql new file mode 100644 index 0000000000..21ce77b8d5 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "external" BOOLEAN NOT NULL DEFAULT false; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql new file mode 100644 index 0000000000..ac63cd639d --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql @@ -0,0 +1,25 @@ +-- Vérifie les versions dans la table Project +DO $$ +DECLARE + project_id TEXT; + project_name TEXT; + last_version TEXT; +BEGIN + -- Boucle sur les projets non archivés + FOR project_id, project_name, last_version IN ( + SELECT id, name, "lastSuccessProvisionningVersion" + FROM "Project" + WHERE "status" != 'archived' + ) + LOOP + -- Vérifie si la version est NULL + IF last_version IS NULL THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version NULL.', project_name, project_id; + END IF; + + -- Vérifie si la version est inférieure à 8.23.0 selon SemVer + IF (string_to_array(last_version, '.')::int[] < ARRAY[8,23,0]) THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version (%), inférieure à 8.23.0.', project_name, project_id, last_version; + END IF; + END LOOP; +END $$; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql new file mode 100644 index 0000000000..54871c9017 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `organizationId` on the `Project` table. All the data in the column will be lost. + - You are about to drop the `Organization` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Project" DROP CONSTRAINT "Project_organizationId_fkey"; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "organizationId"; + +-- DropTable +DROP TABLE "Organization"; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql new file mode 100644 index 0000000000..68ca0df2fa --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "infos" SET DATA TYPE VARCHAR(1000); diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql new file mode 100644 index 0000000000..8364090d8d --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql @@ -0,0 +1,44 @@ +-- AlterTable +ALTER TABLE "Environment" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; + +COMMENT ON COLUMN "Environment".cpu IS 'CPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".gpu IS 'GPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".memory IS 'Memory value as GigaBytes (1 and 0.01 are valid values)'; + +-- Use values from Quota. Memory is an extract of q.memory numeric value as it contains a unit (e.g. '2Gi'). +UPDATE "Environment" +SET cpu = q.cpu, memory = COALESCE(NULLIF(regexp_replace(q.memory, '\D', '','g'), ''), '0')::numeric +FROM "Quota" q +WHERE "quotaId" = q."id"; + +/* + Warnings: + + - You are about to drop the column `quotaId` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the `Quota` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_QuotaToStage` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_B_fkey"; + +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "quotaId", +ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- DropTable +DROP TABLE "Quota"; + +-- DropTable +DROP TABLE "_QuotaToStage"; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql new file mode 100644 index 0000000000..77f32b5ab5 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Cluster" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql new file mode 100644 index 0000000000..decca804aa --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Project" +ADD COLUMN "limitless" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "hprodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodMemory" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodMemory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql new file mode 100644 index 0000000000..95f3a689db --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql @@ -0,0 +1,4 @@ +-- Rename default zone +UPDATE "Zone" +SET ("label", "description") = ('DSO', 'Zone par défaut') +WHERE slug = 'default'; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql new file mode 100644 index 0000000000..aadb6cdba9 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Repository" ADD COLUMN "deployRevision" TEXT, +ADD COLUMN "deployPath" TEXT, +ADD COLUMN "helmValuesFiles" TEXT; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql new file mode 100644 index 0000000000..15cf56ab82 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql @@ -0,0 +1,12 @@ +-- Eliminate NULLs +UPDATE "Repository" SET "deployRevision" = '' WHERE "deployRevision" IS NULL; +UPDATE "Repository" SET "deployPath" = '' WHERE "deployPath" IS NULL; +UPDATE "Repository" SET "helmValuesFiles" = '' WHERE "helmValuesFiles" IS NULL; + +-- AlterTable +ALTER TABLE "Repository" ALTER COLUMN "deployRevision" SET NOT NULL, +ALTER COLUMN "deployRevision" SET DEFAULT '', +ALTER COLUMN "deployPath" SET NOT NULL, +ALTER COLUMN "deployPath" SET DEFAULT '', +ALTER COLUMN "helmValuesFiles" SET NOT NULL, +ALTER COLUMN "helmValuesFiles" SET DEFAULT ''; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql new file mode 100644 index 0000000000..1292495121 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "autosync" BOOLEAN NOT NULL DEFAULT true; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql new file mode 100644 index 0000000000..04c98df036 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "oidcGroup" TEXT NOT NULL DEFAULT ''; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql new file mode 100644 index 0000000000..40da41a75d --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- Update AdminRole system roles +UPDATE "AdminRole" SET "type" = 'system' WHERE "name" IN ('Admin', 'Admin Locaux'); + +-- Update ProjectRole system roles +UPDATE "ProjectRole" SET "type" = 'system' WHERE "name" IN ('Administrateur', 'DevOps', 'Développeur', 'Lecture seule'); diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql new file mode 100644 index 0000000000..92c200d261 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql @@ -0,0 +1,26 @@ +-- Update existing Admin role to be system role 'Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + "oidcGroup" = '/admin', + "position" = 0 +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Lecture Seule Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '35848aa2-e881-4770-9844-0c5c3693e506'::uuid, + 'Lecture Seule Plateforme', + 1, -- Assuming 1n means bit 0 + 2, + '/readonly', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Lecture Seule Plateforme', + "type" = 'system', + "permissions" = 1, + "oidcGroup" = '/readonly'; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql new file mode 100644 index 0000000000..2a4afa8971 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql @@ -0,0 +1,28 @@ +-- Update existing Admin role to be system role 'Root Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Root Administrateur Plateforme' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Administrateur Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf'::uuid, + 'Administrateur Plateforme', + 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + 0, + '/console/admin', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, + "oidcGroup" = '/console/admin'; + +-- Update 'Lecture Seule Plateforme' system role +UPDATE "AdminRole" +SET + "oidcGroup" = '/console/readonly' +WHERE "id" = '35848aa2-e881-4770-9844-0c5c3693e506'::uuid; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql new file mode 100644 index 0000000000..f0cdb04873 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `external` on the `Cluster` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Cluster" DROP COLUMN "external"; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql new file mode 100644 index 0000000000..e07cad777b --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql @@ -0,0 +1,5 @@ +-- Enable legacy default permissions flag +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('refined-permissions', 'off'); diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql new file mode 100644 index 0000000000..efb6bc67d4 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql @@ -0,0 +1,3 @@ +-- Remove 'refined-permissions' system setting +DELETE FROM "SystemSetting" +WHERE "key" = 'refined-permissions'; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql new file mode 100644 index 0000000000..d019403c44 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql @@ -0,0 +1,15 @@ +-- Add global 'Tout le monde' role +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '034f589f-1750-4b15-bb34-4cd995e7fcaa'::uuid, + 'Tout le monde', + 25608, -- MANAGE_PROJECTS | LIST_PROJECTS | LIST_ZONES | LIST_STAGES + 0, + '', + 'global' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Tout le monde', + "type" = 'global', + "permissions" = 25608; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql new file mode 100644 index 0000000000..4fd0d80eb2 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql @@ -0,0 +1,25 @@ +-- Alter existing column to change the default value from 'custom' to 'managed' for AdminRole type +ALTER TABLE "AdminRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Alter existing column to change the default value from 'custom' to 'managed' for ProjectRole type +ALTER TABLE "ProjectRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Migrate system roles to managed roles +UPDATE "AdminRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate system roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate custom roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'custom'; + +-- Update existing Admin role to be an externally managed role +UPDATE "AdminRole" +SET "type" = 'external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql new file mode 100644 index 0000000000..d2691666bd --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql @@ -0,0 +1,15 @@ +-- Migrate well-known project roles to the new "system:"-prefixed type +UPDATE "ProjectRole" +SET "type" = 'system:managed' +WHERE + "type" = 'managed' + AND "oidcGroup" ~ '^/[^/]+/console/(admin|devops|developer|readonly)$'; + +-- Migrate well-known admin roles to the new "system:"-prefixed type +UPDATE "AdminRole" +SET "type" = 'system:managed' +WHERE id IN ( + '76229c96-4716-45bc-99da-00498ec9018c', -- Root Administrateur Plateforme + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', -- Administrateur Plateforme + '35848aa2-e881-4770-9844-0c5c3693e506' -- Lecture Seule Plateforme +); diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql new file mode 100644 index 0000000000..23593ba4f4 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "hprodCpu" DROP DEFAULT, +ALTER COLUMN "hprodGpu" DROP DEFAULT, +ALTER COLUMN "hprodMemory" DROP DEFAULT, +ALTER COLUMN "prodCpu" DROP DEFAULT, +ALTER COLUMN "prodGpu" DROP DEFAULT, +ALTER COLUMN "prodMemory" DROP DEFAULT; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql new file mode 100644 index 0000000000..7e3eefb25d --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql @@ -0,0 +1,4 @@ +-- Set the Root Administrateur Plateforme admin role to external system-managed type +UPDATE "AdminRole" +SET "type" = 'system:external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql new file mode 100644 index 0000000000..ca81594339 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "DeploymentSourceType" AS ENUM ('git', 'oci'); + +-- CreateTable +CREATE TABLE "Deployment" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "name" VARCHAR(11) NOT NULL, + "autosync" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "environmentId" UUID NOT NULL, + + CONSTRAINT "Deployment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeploymentSource" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deploymentId" UUID NOT NULL, + "repositoryId" UUID NOT NULL, + "type" "DeploymentSourceType" NOT NULL, + "targetRevision" TEXT NOT NULL DEFAULT '', + "path" TEXT NOT NULL DEFAULT '', + "helmValuesFiles" TEXT NOT NULL DEFAULT '', + + CONSTRAINT "DeploymentSource_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Deployment_id_key" ON "Deployment"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentSource_id_key" ON "DeploymentSource"("id"); + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "Deployment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql new file mode 100644 index 0000000000..ad5a64d336 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_environmentId_fkey"; + +-- DropForeignKey +ALTER TABLE "DeploymentSource" DROP CONSTRAINT "DeploymentSource_repositoryId_fkey"; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-base-1/apps/server/src/prisma/migrations/migration_lock.toml b/.jjconflict-base-1/apps/server/src/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000..044d57cdb0 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/.jjconflict-base-1/apps/server/src/prisma/schema/admin.prisma b/.jjconflict-base-1/apps/server/src/prisma/schema/admin.prisma new file mode 100644 index 0000000000..c4ca38ccb1 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/schema/admin.prisma @@ -0,0 +1,21 @@ +model AdminPlugin { + pluginName String + key String + value String + + @@unique([pluginName, key]) +} + +model AdminRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") +} + +model SystemSetting { + key String @id @unique + value String +} diff --git a/.jjconflict-base-1/apps/server/src/prisma/schema/project.prisma b/.jjconflict-base-1/apps/server/src/prisma/schema/project.prisma new file mode 100644 index 0000000000..6bcc5ce949 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/schema/project.prisma @@ -0,0 +1,144 @@ +model Deployment { + id String @id @unique @default(uuid()) @db.Uuid + projectId String @db.Uuid + name String @db.VarChar(11) + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + environmentId String @db.Uuid + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model DeploymentSource { + id String @id @unique @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deploymentId String @db.Uuid + repositoryId String @db.Uuid + type DeploymentSourceType + targetRevision String @default("") + path String @default("") + helmValuesFiles String @default("") + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) + deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) +} + +model Environment { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(11) + projectId String @db.Uuid + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clusterId String @db.Uuid + stageId String @db.Uuid + cluster Cluster @relation(fields: [clusterId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + stage Stage @relation(fields: [stageId], references: [id]) + deployments Deployment[] + + @@unique([projectId, name]) +} + +model Repository { + id String @id @default(uuid()) @db.Uuid + projectId String @db.Uuid + internalRepoName String + externalRepoUrl String @default("") + externalUserName String @default("") + isInfra Boolean @default(false) + isPrivate Boolean @default(false) + deployRevision String @default("") + deployPath String @default("") + helmValuesFiles String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model ProjectClusterHistory { + projectId String @db.Uuid + clusterId String @db.Uuid + + @@unique([projectId, clusterId]) +} + +model ProjectMembers { + projectId String @db.Uuid + userId String @db.Uuid + roleIds String[] + project Project @relation(fields: [projectId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@unique([projectId, userId]) +} + +model ProjectPlugin { + pluginName String + projectId String @db.Uuid + key String + value String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, pluginName, key]) +} + +model ProjectRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + projectId String @db.Uuid + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") + project Project @relation(fields: [projectId], references: [id]) +} + +model Project { + id String @id @unique @default(uuid()) @db.Uuid + name String + description String @default("") + status ProjectStatus @default(initializing) + locked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + everyonePerms BigInt @default(896) + ownerId String @db.Uuid + environments Environment[] + logs Log[] + owner User @relation(fields: [ownerId], references: [id]) + members ProjectMembers[] + plugins ProjectPlugin[] + roles ProjectRole[] + repositories Repository[] + clusters Cluster[] @relation("ClusterToProject") + slug String @unique + limitless Boolean @default(true) + hprodCpu Float @db.Real + hprodGpu Float @db.Real + hprodMemory Float @db.Real + prodCpu Float @db.Real + prodGpu Float @db.Real + prodMemory Float @db.Real + lastSuccessProvisionningVersion String? + deployments Deployment[] +} + +enum ProjectStatus { + initializing + created + failed + archived + warning +} + +enum DeploymentSourceType { + git + oci +} diff --git a/.jjconflict-base-1/apps/server/src/prisma/schema/schema.prisma b/.jjconflict-base-1/apps/server/src/prisma/schema/schema.prisma new file mode 100644 index 0000000000..aadf7fea13 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/schema/schema.prisma @@ -0,0 +1,21 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DB_URL") +} + +model Log { + id String @id @default(uuid()) @db.Uuid + data Json + action String @default("") + userId String? @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + requestId String? @db.VarChar(36) + projectId String? @db.Uuid + project Project? @relation(fields: [projectId], references: [id]) + user User? @relation(fields: [userId], references: [id]) +} diff --git a/.jjconflict-base-1/apps/server/src/prisma/schema/token.prisma b/.jjconflict-base-1/apps/server/src/prisma/schema/token.prisma new file mode 100644 index 0000000000..c0c55751c5 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/schema/token.prisma @@ -0,0 +1,30 @@ +model AdminToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + userId String @db.Uuid + expirationDate DateTime? + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +model PersonalAccessToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + userId String @db.Uuid + expirationDate DateTime + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +enum TokenStatus { + active + revoked + inactive +} diff --git a/.jjconflict-base-1/apps/server/src/prisma/schema/topography.prisma b/.jjconflict-base-1/apps/server/src/prisma/schema/topography.prisma new file mode 100644 index 0000000000..63d90abb8a --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/schema/topography.prisma @@ -0,0 +1,52 @@ +model Cluster { + id String @id @unique @default(uuid()) @db.Uuid + label String @unique @db.VarChar(50) + privacy ClusterPrivacy @default(dedicated) + secretName String @unique @default(uuid()) @db.VarChar(50) + clusterResources Boolean @default(false) + kubeConfigId String @unique @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + infos String? @db.VarChar(1000) + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + zoneId String @db.Uuid + kubeconfig Kubeconfig @relation(fields: [kubeConfigId], references: [id], onDelete: Cascade) + zone Zone @relation(fields: [zoneId], references: [id]) + environments Environment[] + projects Project[] @relation("ClusterToProject") + stages Stage[] @relation("ClusterToStage") +} + +model Kubeconfig { + id String @id @unique @default(uuid()) @db.Uuid + user Json + cluster Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + parentCluster Cluster? +} + +model Stage { + id String @id @unique @default(uuid()) @db.Uuid + name String @unique @db.VarChar + environments Environment[] + clusters Cluster[] @relation("ClusterToStage") +} + +model Zone { + id String @id @unique @default(uuid()) @db.Uuid + slug String @unique @db.VarChar(10) + label String @db.VarChar(50) + description String? @db.VarChar(200) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + argocdUrl String @default("https://example.com") + clusters Cluster[] +} + +enum ClusterPrivacy { + public + dedicated +} diff --git a/.jjconflict-base-1/apps/server/src/prisma/schema/user.prisma b/.jjconflict-base-1/apps/server/src/prisma/schema/user.prisma new file mode 100644 index 0000000000..e90fb69f83 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/prisma/schema/user.prisma @@ -0,0 +1,23 @@ +model User { + id String @id @db.Uuid + firstName String + lastName String + email String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? + adminRoleIds String[] + type UserType + + logs Log[] + projectsOwned Project[] + adminTokens AdminToken[] + projectMembers ProjectMembers[] + personalAccessTokens PersonalAccessToken[] +} + +enum UserType { + human + bot + ghost +} diff --git a/.jjconflict-base-1/apps/server/src/resources/admin-role/business.spec.ts b/.jjconflict-base-1/apps/server/src/resources/admin-role/business.spec.ts new file mode 100644 index 0000000000..9f9a7051ea --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/admin-role/business.spec.ts @@ -0,0 +1,262 @@ +import type { AdminRole, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400 } from '../../utils/errors.ts' +import { countRolesMembers, createRole, deleteRole, listRoles, patchRoles } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +describe('test admin-role business', () => { + describe('listRoles', () => { + it('should stringify bigint', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + const response = await listRoles() + expect(response).toContainEqual(expect.objectContaining({ permissions: '4', type: 'managed' })) + }) + }) + + describe('createRole', () => { + it('should create role with incremented position when position 0 is the highest', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 1 } }) + }) + + it('should create role with incremented position with bigger position', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 51 } }) + }) + + it('should create role with incremented position with no role in db', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(null) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 0 } }) + }) + }) + describe('deleteRole', () => { + const roleId = faker.string.uuid() + it('should delete role and remove id from concerned users', async () => { + const users = [{ + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roleId], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }, { + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roleId, faker.string.uuid()], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }] as const satisfies User[] + + const dbRole: AdminRole = { + name: 'Admin', + id: roleId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.user.findMany.mockResolvedValue(users) + prisma.adminRole.findMany.mockResolvedValueOnce([]) + prisma.adminRole.findUnique.mockResolvedValueOnce(dbRole) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.delete.mockResolvedValue({ args: {}, failed: false, results: {} }) + await deleteRole(roleId, faker.string.uuid()) + + expect(prisma.user.findMany).toHaveBeenCalledTimes(2) + expect(prisma.user.update).toHaveBeenNthCalledWith(1, { where: { id: users[0].id }, data: { adminRoleIds: [] } }) + expect(prisma.user.update).toHaveBeenNthCalledWith(2, { where: { id: users[1].id }, data: { adminRoleIds: [users[1].adminRoleIds[1]] } }) + expect(prisma.adminRole.delete).toHaveBeenCalledWith({ where: { id: roleId } }) + }) + }) + + describe('countRolesMembers', () => { + it('should return aggregated role member counts', async () => { + const roles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + type: 'managed', + }] as const satisfies AdminRole[] + + const users = [{ + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roles[0].id, roles[1].id], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }, { + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roles[1].id], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }] as const satisfies User[] + prisma.adminRole.findMany.mockResolvedValue(roles) + prisma.user.findMany.mockResolvedValue(users) + + const response = await countRolesMembers() + + expect(response).toEqual({ [roles[0].id]: 1, [roles[1].id]: 2 }) + }) + }) + describe('patchRoles', () => { + const dbRoles: AdminRole[] = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + type: 'managed', + }] + + it('should do nothing', async () => { + prisma.adminRole.findMany.mockResolvedValue([]) + await patchRoles([], faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 1 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(updateRoles, faker.string.uuid()) + + expect(response).instanceOf(BadRequest400) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + it('should return 400 if incoherent positions (missing roles)', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[1].id, position: 1 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(updateRoles, faker.string.uuid()) + + expect(response).instanceOf(BadRequest400) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + it('should update positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 0 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await patchRoles(updateRoles, faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(2) + }) + it('should update permissions', async () => { + const updateRoles: (Pick & { permissions?: string })[] = [ + { id: dbRoles[1].id, permissions: '0' }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await patchRoles(updateRoles, faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(1) + expect(prisma.adminRole.update).toHaveBeenCalledWith({ + data: { + name: dbRoles[1].name, + oidcGroup: dbRoles[1].oidcGroup, + permissions: 0n, + position: 1, + type: 'managed', + }, + where: { + id: dbRoles[1].id, + }, + }) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/admin-role/business.ts b/.jjconflict-base-1/apps/server/src/resources/admin-role/business.ts new file mode 100644 index 0000000000..f81fd65b75 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/admin-role/business.ts @@ -0,0 +1,111 @@ +import type { AdminRole, adminRoleContract } from '@cpn-console/shared' +import type { Project, ProjectRole } from '@prisma/client' +import type { ErrorResType } from '@/utils/errors.js' +import prisma from '@/prisma.js' +import { addLogs, getAdminRoleById, listAdminRoles } from '@/resources/queries-index.js' +import { BadRequest400 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function listRoles() { + return listAdminRoles() + .then(roles => roles.map(role => ({ ...role, permissions: role.permissions.toString(), type: role.type ?? 'managed' }))) +} + +export async function patchRoles( + roles: typeof adminRoleContract.patchAdminRoles.body._type, + requestId: string, +): Promise { + const dbRoles = await prisma.adminRole.findMany() + const positionsAvailable: number[] = [] + const updatedRoles: (Omit & { permissions: bigint })[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (matchingRole) { + if (typeof matchingRole.position !== 'undefined' && !positionsAvailable.includes(matchingRole.position)) { + positionsAvailable.push(matchingRole.position) + } + updatedRoles.push({ + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions: matchingRole.permissions ? BigInt(matchingRole.permissions) : dbRole.permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup: matchingRole.oidcGroup ?? dbRole.oidcGroup, + type: matchingRole.type ?? dbRole.type, + }) + } + } + + if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes') + for (const { id, ...role } of updatedRoles) { + await prisma.adminRole.update({ where: { id }, data: role }) + const hookReply = await hook.adminRole.upsert(id) + await addLogs({ action: 'Update Admin Role', data: hookReply, requestId }) + } + + return listRoles() +} + +export async function createRole( + role: typeof adminRoleContract.createAdminRole.body._type, + requestId: string, +) { + const dbMaxPosRole = (await prisma.adminRole.findFirst({ + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + const createdRole = await prisma.adminRole.create({ + data: { + ...role, + position: dbMaxPosRole + 1, + permissions: 0n, + }, + }) + + const hookReply = await hook.adminRole.upsert(createdRole.id) + await addLogs({ action: 'Create Admin Role', data: hookReply, requestId }) + + return listRoles() +} + +export async function countRolesMembers() { + const roles = await prisma.adminRole.findMany({ where: { oidcGroup: { equals: '' } }, select: { id: true } }) + const roleIds = roles.map(role => role.id) + const users = await prisma.user.findMany({ + where: { adminRoleIds: { hasSome: roleIds } }, + select: { adminRoleIds: true }, + }) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) // {role uuid: 0} + for (const { adminRoleIds } of users) { + for (const roleId of adminRoleIds) { + rolesCounts[roleId]++ + } + } + return rolesCounts +} + +export async function deleteRole( + roleId: Project['id'], + requestId: string, +) { + const role = await getAdminRoleById(roleId) + if (role) { + const hookReply = await hook.adminRole.delete(role) + await addLogs({ action: 'Delete Admin Role', data: hookReply, requestId }) + } + + const allUsers = await prisma.user.findMany({ + where: { + adminRoleIds: { has: roleId }, + }, + }) + for (const user of allUsers) { + await prisma.user.update({ + where: { id: user.id }, + data: { adminRoleIds: user.adminRoleIds.filter(adminRoleId => adminRoleId !== roleId) }, + }) + } + await prisma.adminRole.delete({ where: { id: roleId } }) + return null +} diff --git a/.jjconflict-base-1/apps/server/src/resources/admin-role/queries.ts b/.jjconflict-base-1/apps/server/src/resources/admin-role/queries.ts new file mode 100644 index 0000000000..9c29eb0b36 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/admin-role/queries.ts @@ -0,0 +1,40 @@ +import type { + AdminRole, + Prisma, +} from '@prisma/client' +import prisma from '@/prisma.js' + +export const listAdminRoles = () => prisma.adminRole.findMany({ orderBy: { position: 'asc' } }) + +export function createAdminRole(data: Pick) { + return prisma.adminRole.create({ + data: { + name: data.name, + permissions: 0n, + position: data.position, + type: 'managed', + }, + }) +} + +export function updateAdminRole(id: AdminRole['id'], data: Pick) { + return prisma.adminRole.updateMany({ + where: { id }, + data, + }) +} + +export function deleteAdminRole(id: AdminRole['id']) { + return prisma.adminRole.delete({ + where: { + id, + }, + }) +} + +export async function getAdminRoleById(id: string) { + const role = await prisma.adminRole.findUnique({ where: { id } }) + if (!role) return null + const members = await prisma.user.findMany({ where: { adminRoleIds: { has: id } } }) + return { ...role, members } +} diff --git a/.jjconflict-base-1/apps/server/src/resources/admin-role/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/admin-role/router.spec.ts new file mode 100644 index 0000000000..dab0f79268 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/admin-role/router.spec.ts @@ -0,0 +1,184 @@ +import { ADMIN_PERMS, adminRoleContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListRolesMock = vi.spyOn(business, 'listRoles') +const businessCreateRoleMock = vi.spyOn(business, 'createRole') +const businessPatchRolesMock = vi.spyOn(business, 'patchRoles') +const businessCountRolesMembersMock = vi.spyOn(business, 'countRolesMembers') +const businessDeleteRoleMock = vi.spyOn(business, 'deleteRole') + +describe('test adminRoleContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listAdminRoles', () => { + it('should return list of admin roles', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const roles = [{ id: faker.string.uuid(), name: 'Role 1', oidcGroup: '', position: 0, permissions: '1', type: 'managed' }] + businessListRolesMock.mockResolvedValueOnce(roles) + + const response = await app.inject() + .get(adminRoleContract.listAdminRoles.path) + .end() + + expect(businessListRolesMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(roles) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('createAdminRole', () => { + it('should create a role for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + const newRole = { id: 'newRole', name: 'New Role' } + const roleData = { name: 'New Role' } + + authUserMock.mockResolvedValueOnce(user) + businessCreateRoleMock.mockResolvedValueOnce(newRole) + + const response = await app.inject() + .post(adminRoleContract.createAdminRole.path) + .body(roleData) + .end() + + expect(businessCreateRoleMock).toHaveBeenCalledWith(roleData, expect.any(String)) + expect(response.json()).toEqual(newRole) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(adminRoleContract.createAdminRole.path) + .body({ name: 'New Role' }) + .end() + + expect(businessCreateRoleMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('patchAdminRoles', () => { + const updatedRoles = [{ id: faker.string.uuid(), name: 'Role 1', oidcGroup: '', position: 0, permissions: '1', type: 'managed' }] + const rolesData = [{ id: updatedRoles[0].id, name: 'Updated Role', type: 'managed' }] + it('should update roles for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessPatchRolesMock.mockResolvedValueOnce(updatedRoles) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledWith(rolesData, expect.any(String)) + expect(response.json()).toEqual(updatedRoles) + expect(response.statusCode).toEqual(200) + }) + + it('should return error if business logic fails', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessPatchRolesMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledWith(rolesData, expect.any(String)) + expect(response.statusCode).toEqual(400) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('adminRoleMemberCounts', () => { + it('should return counts of role members for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + const counts = { role1: 5, role2: 3 } + + authUserMock.mockResolvedValueOnce(user) + businessCountRolesMembersMock.mockResolvedValueOnce(counts) + + const response = await app.inject() + .get(adminRoleContract.adminRoleMemberCounts.path) + .end() + + expect(businessCountRolesMembersMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(counts) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 if user is not admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(adminRoleContract.adminRoleMemberCounts.path) + .end() + + expect(businessCountRolesMembersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteAdminRole', () => { + const roleId = faker.string.uuid() + it('should delete a role for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessDeleteRoleMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .delete(adminRoleContract.deleteAdminRole.path.replace(':roleId', roleId)) + .end() + + expect(businessDeleteRoleMock).toHaveBeenCalledWith(roleId, expect.any(String)) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(adminRoleContract.deleteAdminRole.path.replace(':roleId', roleId)) + .end() + + expect(businessDeleteRoleMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/admin-role/router.ts b/.jjconflict-base-1/apps/server/src/resources/admin-role/router.ts new file mode 100644 index 0000000000..4141a2353e --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/admin-role/router.ts @@ -0,0 +1,78 @@ +import { AdminAuthorized, adminRoleContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + countRolesMembers, + createRole, + deleteRole, + listRoles, + patchRoles, +} from './business.js' + +export function adminRoleRouter() { + return serverInstance.router(adminRoleContract, { + // Récupérer des projets + listAdminRoles: async () => { + const body = await listRoles() + + return { + status: 200, + body, + } + }, + + createAdminRole: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await createRole(body, req.id) + + return { + status: 201, + body: resBody, + } + }, + + patchAdminRoles: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await patchRoles(body, req.id) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + adminRoleMemberCounts: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await countRolesMembers() + + return { + status: 200, + body: resBody, + } + }, + + deleteAdminRole: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await deleteRole(params.roleId, req.id) + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/admin-token/business.spec.ts b/.jjconflict-base-1/apps/server/src/resources/admin-token/business.spec.ts new file mode 100644 index 0000000000..2a698ac744 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/admin-token/business.spec.ts @@ -0,0 +1,73 @@ +import type { AdminToken } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { createToken, deleteToken, listTokens } from './business.ts' + +describe('test admin-token business', () => { + describe('listTokens', () => { + it('should stringify bigint', async () => { + const partialtoken: Partial = { + permissions: 4n, + } + + prisma.adminToken.findMany.mockResolvedValueOnce([partialtoken]) + const response = await listTokens({}) + expect(response).toEqual([{ permissions: '4' }]) + }) + it('should return revoked', async () => { + const partialtoken: Partial = { + permissions: 4n, + status: 'revoked', + } + + prisma.adminToken.findMany.mockResolvedValueOnce([partialtoken]) + const response = await listTokens({ withRevoked: true }) + expect(response).toEqual([{ ...partialtoken, permissions: '4' }]) + }) + }) + + describe('createToken', () => { + it('should create ', async () => { + const dbToken: Partial = undefined + const userId = faker.string.uuid() + const createdToken: AdminToken = { + expirationDate: null, + id: faker.string.uuid(), + name: 'test', + permissions: '2', + } + prisma.adminToken.findUnique.mockResolvedValueOnce(dbToken) + prisma.adminToken.create.mockResolvedValueOnce(createdToken) + await createToken({ name: 'test', permissions: '2', expirationDate: null }, userId, undefined) + + expect(prisma.adminToken.create).toHaveBeenCalledWith({ + data: { + name: 'test', + hash: expect.any(String), + permissions: 2n, + userId: expect.any(String), + expirationDate: undefined, + }, + omit: expect.any(Object), + include: { + owner: true, + }, + }) + }) + it('should not create cause expiration is too short', async () => { + const expirationDate = new Date() + await createToken({ name: 'test', permissions: '2', expirationDate: expirationDate.toISOString() }) + + expect(prisma.adminToken.create).toHaveBeenCalledTimes(0) + }) + }) + + describe('deleteToken', () => { + it('should delete token', async () => { + prisma.adminToken.delete.mockResolvedValueOnce(undefined) + await deleteToken(faker.string.uuid()) + expect(prisma.adminToken.updateMany).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/admin-token/business.ts b/.jjconflict-base-1/apps/server/src/resources/admin-token/business.ts new file mode 100644 index 0000000000..0702fde2b9 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/admin-token/business.ts @@ -0,0 +1,75 @@ +import type { adminTokenContract } from '@cpn-console/shared' +import type { $Enums, AdminToken, Prisma } from '@prisma/client' +import { createHash, randomUUID } from 'node:crypto' +import { logger as baseLogger } from '@cpn-console/logger' +import { generateRandomPassword, isAtLeastTomorrow } from '@cpn-console/shared' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../prisma.js' + +const logger = baseLogger.child({ scope: 'resource:admin-token' }) + +export async function listTokens(query: typeof adminTokenContract.listAdminTokens.query._type) { + const where = { + status: { + in: ['active'] as $Enums.TokenStatus[], + }, + } as const satisfies Prisma.AdminTokenWhereInput + + if (query?.withRevoked) where.status.in.push('revoked') + + return prisma.adminToken.findMany({ + omit: { hash: true }, + include: { owner: true }, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + where, + }).then(tokens => + tokens.map(({ permissions, ...token }) => ({ permissions: permissions.toString(), ...token })), + ) +} + +export async function createToken(data: typeof adminTokenContract.createAdminToken.body._type) { + if (data.expirationDate && !isAtLeastTomorrow(new Date(data.expirationDate))) { + return new BadRequest400('Date d\'expiration trop courte') + } + logger.info({ tokenName: data.name, expirationDate: data.expirationDate ?? null }, 'Create admin token started') + const password = generateRandomPassword(48, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-') + const hash = createHash('sha256').update(password).digest('hex') + const botUserId = randomUUID() + await prisma.user.create({ + data: { + firstName: 'Bot Admin', + lastName: data.name, + type: 'bot', + id: botUserId, + email: `${botUserId}@bot.io`, + }, + }) + const token = await prisma.adminToken.create({ + data: { + ...data, + hash, + permissions: BigInt(data.permissions), + expirationDate: data.expirationDate ? new Date(data.expirationDate) : undefined, + userId: botUserId, + }, + omit: { hash: true }, + include: { owner: true }, + }) + logger.info({ adminTokenId: token.id, botUserId, expirationDate: token.expirationDate ?? null, status: token.status }, 'Create admin token completed') + return { + ...token, + password, + permissions: token.permissions.toString(), + } +} + +export async function deleteToken(id: AdminToken['id']) { + logger.info({ adminTokenId: id }, 'Revoke admin token started') + return prisma.adminToken.updateMany({ + where: { id }, + data: { + status: 'revoked', + expirationDate: new Date(Date.now()), + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/admin-token/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/admin-token/router.spec.ts new file mode 100644 index 0000000000..61efec35fc --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/admin-token/router.spec.ts @@ -0,0 +1,161 @@ +import type { ExposedAdminToken } from '@cpn-console/shared' +import type { AdminToken } from '@prisma/client' +import { ADMIN_PERMS, adminTokenContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListTokensMock = vi.spyOn(business, 'listTokens') +const businessCreateTokenMock = vi.spyOn(business, 'createToken') +const businessDeleteTokenMock = vi.spyOn(business, 'deleteToken') + +describe('test adminTokenContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listAdminTokens', () => { + it('should return list of admin tokens', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_ADMIN_TOKEN) + authUserMock.mockResolvedValueOnce(user) + + const tokens: AdminToken[] = [{ + id: faker.string.uuid(), + name: 'token1', + permissions: '2', + lastUse: (new Date()).toISOString(), + expirationDate: null, + status: 'active', + createdAt: (new Date(Date.now())).toISOString(), + }] + businessListTokensMock.mockResolvedValueOnce(tokens) + + const response = await app.inject() + .get(adminTokenContract.listAdminTokens.path) + .end() + + expect(businessListTokensMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(tokens) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(adminTokenContract.listAdminTokens.path) + .end() + + expect(businessListTokensMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createAdminToken', () => { + it('should create a token for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + const newToken = { + id: faker.string.uuid(), + name: 'test', + lastUse: null, + expirationDate: null, + password: faker.string.alpha({ casing: 'lower', length: 10 }), + permissions: '2', + createdAt: (new Date(Date.now())).toISOString(), + status: 'active', + } + const tokenData: ExposedAdminToken = { + name: newToken.name, + permissions: newToken.permissions, + expirationDate: null, + } + + authUserMock.mockResolvedValueOnce(user) + businessCreateTokenMock.mockResolvedValueOnce(newToken) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body(tokenData) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledWith(tokenData) + expect(response.json()).toEqual(newToken) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body({ + name: 'new-token', + expirationDate: null, + permissions: '4', + }) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + authUserMock.mockResolvedValueOnce(user) + businessCreateTokenMock.mockResolvedValueOnce(new BadRequest400('Invalid date')) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body({ + name: 'new-token', + expirationDate: null, + permissions: '4', + }) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(400) + }) + }) + + describe('deleteAdminToken', () => { + const tokenId = faker.string.uuid() + it('should delete a token for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + authUserMock.mockResolvedValueOnce(user) + businessDeleteTokenMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .delete(adminTokenContract.deleteAdminToken.path.replace(':tokenId', tokenId)) + .end() + + expect(businessDeleteTokenMock).toHaveBeenCalledWith(tokenId) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(adminTokenContract.deleteAdminToken.path.replace(':tokenId', tokenId)) + .end() + + expect(businessDeleteTokenMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/admin-token/router.ts b/.jjconflict-base-1/apps/server/src/resources/admin-token/router.ts new file mode 100644 index 0000000000..305b6e527a --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/admin-token/router.ts @@ -0,0 +1,46 @@ +import { AdminAuthorized, adminTokenContract } from '@cpn-console/shared' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { serverInstance } from '../../app.js' +import { createToken, deleteToken, listTokens } from './business.js' + +export function adminTokenRouter() { + return serverInstance.router(adminTokenContract, { + listAdminTokens: async ({ request: req, query }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListAdminToken(perms.adminPermissions)) return new Forbidden403() + const body = await listTokens(query) + + return { + status: 200, + body, + } + }, + + createAdminToken: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageAdminToken(perms.adminPermissions)) return new Forbidden403() + const body = await createToken(data) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + deleteAdminToken: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageAdminToken(perms.adminPermissions)) return new Forbidden403() + await deleteToken(params.tokenId) + + return { + status: 204, + body: null, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/cluster/business.spec.ts b/.jjconflict-base-1/apps/server/src/resources/cluster/business.spec.ts new file mode 100644 index 0000000000..c003e66ae1 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/cluster/business.spec.ts @@ -0,0 +1,171 @@ +import type { Cluster, Environment } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400, ErrorResType, NotFound404, Unprocessable422 } from '../../utils/errors.ts' +import { createCluster, deleteCluster, getClusterAssociatedEnvironments, getClusterDetails, getClusterUsage, listClusters, updateCluster } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const userId = faker.string.uuid() +const requestId = faker.string.uuid() +const cluster: Cluster = { + id: faker.string.uuid(), + infos: faker.lorem.lines(2), + privacy: 'public', + createdAt: new Date(), + updatedAt: new Date(), + zoneId: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + label: faker.string.alpha(10), + secretName: faker.string.alpha(10), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), +} +describe('test Cluster business logic', () => { + describe('listClusters', () => { + it('should filter for user', async () => { + prisma.cluster.findMany.mockResolvedValue([]) + await listClusters(userId) + expect(prisma.cluster.findMany).toHaveBeenCalledTimes(1) + expect(prisma.cluster.findMany).toHaveBeenCalledWith({ select: expect.any(Object), where: { OR: [{ privacy: 'public' }, expect.any(Object), expect.any(Object), expect.any(Object)] } }) + }) + it('should not filter', async () => { + const dbStages = [{ id: faker.string.uuid() }] + prisma.cluster.findMany.mockResolvedValue([{ stages: dbStages }] as unknown as Cluster[]) + const response = await listClusters() + expect(prisma.cluster.findMany).toHaveBeenCalledTimes(1) + expect(prisma.cluster.findMany).toHaveBeenCalledWith({ select: expect.any(Object), where: {} }) + expect(response[0].stageIds).toStrictEqual([dbStages[0].id]) + }) + }) + + describe('getClusterAssociatedEnvironments', () => { + it('should list all environments attached to a cluster', async () => { + const envName = faker.string.alpha(8) + const projectName = faker.string.alpha(8) + const ownerEmail = faker.internet.email() + const cpu = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const gpu = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const memory = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const envs = [{ name: envName, cpu, gpu, memory, project: { name: projectName, owner: { email: ownerEmail } } }] as unknown as Environment[] + prisma.environment.findMany.mockResolvedValue(envs) + const response = await getClusterAssociatedEnvironments(cluster.id) + expect(response).toStrictEqual([{ + name: envName, + project: projectName, + owner: ownerEmail, + cpu, + gpu, + memory, + }]) + }) + }) + + describe('getClusterDetails', () => { + it('should return a cluster details', async () => { + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + await getClusterDetails(cluster.id) + }) + it('should return a cluster details, without infos in db', async () => { + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, infos: null, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + const response = await getClusterDetails(cluster.id) + expect(response.infos).toBe('') + }) + }) + + describe('getClusterUsage', () => { + it('should return a cluster usage', async () => { + prisma.environment.aggregate.mockResolvedValue({ _count: {}, _avg: {}, _min: {}, _max: {}, _sum: { + cpu: 10, + gpu: 5, + memory: 20, + } }) + const response = await getClusterUsage(cluster.id) + expect(response).toStrictEqual({ + cpu: 10, + gpu: 5, + memory: 20, + }) + }) + }) + + describe('createCluster', () => { + it('should create cluster', async () => { + hook.cluster.upsert.mockResolvedValue({ failed: false }) + prisma.cluster.findUnique.mockResolvedValue(null) + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + prisma.cluster.create.mockResolvedValue(cluster) + + const response = await createCluster({ + infos: faker.string.alpha(10), + zoneId: faker.string.uuid(), + privacy: 'public', + stageIds: [], + clusterResources: false, + kubeconfig: { cluster: { tlsServerName: faker.internet.domainName() }, user: {} }, + label: faker.string.alpha(10), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + }, userId, requestId) + + expect(response).not.instanceOf(ErrorResType) + expect(prisma.cluster.create).toHaveBeenCalled() + }) + }) + + describe('updateCluster', () => { + it('should update cluster', async () => { + hook.cluster.upsert.mockResolvedValue({ failed: false }) + prisma.cluster.findUnique.mockResolvedValue(cluster) + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + prisma.cluster.update.mockResolvedValue(cluster) + + const response = await updateCluster({ + infos: faker.string.alpha(10), + zoneId: faker.string.uuid(), + privacy: 'public', + stageIds: [], + }, cluster.id, userId, requestId) + + expect(response).not.instanceOf(ErrorResType) + expect(prisma.cluster.update).toHaveBeenCalled() + }) + it('should return 404', async () => { + prisma.cluster.findUnique.mockResolvedValue(null) + const response = await updateCluster({ infos: faker.string.alpha(10) }, cluster.id, userId, requestId) + expect(response).instanceOf(NotFound404) + }) + }) + + describe('deleteCluster', () => { + it('should delete cluster', async () => { + hook.cluster.delete.mockResolvedValue({}) + await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(prisma.cluster.delete).toHaveBeenCalledTimes(1) + }) + it('should return failed hook', async () => { + hook.cluster.delete.mockResolvedValue({ failed: true }) + const response = await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(response).instanceOf(Unprocessable422) + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + }) + it('should not delete cluster, env attached', async () => { + prisma.environment.findFirst.mockResolvedValue({ id: faker.string.uuid() } as Environment) + const response = await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) +}) + +// findUniqueOrThrow diff --git a/.jjconflict-base-1/apps/server/src/resources/cluster/business.ts b/.jjconflict-base-1/apps/server/src/resources/cluster/business.ts new file mode 100644 index 0000000000..ef542ce33a --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/cluster/business.ts @@ -0,0 +1,230 @@ +import type { Cluster, clusterContract, ClusterDetails, Kubeconfig } from '@cpn-console/shared' +import type { Prisma, Project, User } from '@prisma/client' +import type { Resources } from '@/types/index.js' +import { ClusterDetailsSchema, ClusterPrivacy } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + addLogs, + createCluster as createClusterQuery, + deleteCluster as deleteClusterQuery, + getClusterById, + getClusterByLabel, + getClusterDetails as getClusterDetailsQuery, + getClusterEnvironments, + getProjectsByClusterId, + linkClusterToProjects, + linkZoneToClusters, + listClusters as listClustersQuery, + listStagesByClusterId, + removeClusterFromProject, + removeClusterFromStage, + updateCluster as updateClusterQuery, +} from '@/resources/queries-index.js' +import { linkClusterToStages } from '@/resources/stage/business.js' +import { validateSchema } from '@/utils/business.js' +import { BadRequest400, ErrorResType, NotFound404, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function listClusters(userId?: User['id']) { + const where: Prisma.ClusterWhereInput = userId + ? { + OR: [ + // Sélectionne tous les clusters publics + { privacy: 'public' }, + // Sélectionne les clusters associés aux projets dont l'user est membre + { + projects: { some: { members: { some: { userId } } } }, + }, + // Sélectionne les clusters associés aux projets dont l'user est owner + { + projects: { some: { ownerId: userId } }, + }, + // Sélectionne les clusters associés aux environnments appartenant à des projets dont l'user est membre + { + environments: { some: { project: { members: { some: { userId } } } } }, + }, + ], + } + : {} + const clusters = await listClustersQuery(where) + return clusters.map(({ stages, ...cluster }) => ({ + ...cluster, + stageIds: stages.map(({ id }) => id), + })) +} + +export async function getClusterAssociatedEnvironments(clusterId: string) { + const clusterEnvironments = await getClusterEnvironments(clusterId) + + return clusterEnvironments.map((environment) => { + return ({ + project: environment.project?.name, + name: environment.name, + owner: environment.project.owner.email, + cpu: environment.cpu, + gpu: environment.gpu, + memory: environment.memory, + }) + }) +} + +export async function getClusterDetails(clusterId: string): Promise { + const { infos, projects, stages, kubeconfig, ...details } = await getClusterDetailsQuery(clusterId) + return { + ...details, + infos: infos ?? '', + projectIds: projects.map(project => project.id), + stageIds: stages.map(({ id }) => id), + kubeconfig: { + cluster: kubeconfig.cluster as unknown as Kubeconfig['cluster'], + user: kubeconfig.user as unknown as Kubeconfig['user'], + }, + } +} + +export async function getClusterUsage(clusterId: string): Promise { + const clusterUsage = await prisma.environment.aggregate({ + _sum: { + memory: true, + cpu: true, + gpu: true, + }, + where: { + clusterId, + }, + }) + return { + cpu: clusterUsage._sum.cpu ?? 0, + gpu: clusterUsage._sum.gpu ?? 0, + memory: clusterUsage._sum.memory ?? 0, + } +} + +export async function createCluster(data: typeof clusterContract.createCluster.body._type, userId: User['id'], requestId: string) { + const isLabelTaken = await getClusterByLabel(data.label) + if (isLabelTaken) return new BadRequest400('Ce label existe déjà pour un autre cluster') + + data.projectIds = data.privacy === ClusterPrivacy.PUBLIC + ? [] + : data.projectIds ?? [] + + const { + projectIds, + stageIds, + kubeconfig, + zoneId, + ...clusterData + } = data + + const clusterCreated = await createClusterQuery(clusterData, kubeconfig, zoneId) + + if (data.privacy === ClusterPrivacy.DEDICATED && projectIds.length) { + await linkClusterToProjects(clusterCreated.id, projectIds) + } + + if (stageIds?.length) { + await linkClusterToStages(clusterCreated.id, stageIds) + } + + const hookReply = await hook.cluster.upsert(clusterCreated.id, zoneId) + await addLogs({ action: 'Create Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la création du cluster') + } + + return getClusterDetails(clusterCreated.id) +} + +export async function updateCluster(data: typeof clusterContract.updateCluster.body._type, clusterId: Cluster['id'], userId: User['id'], requestId: string): Promise { + if (data?.privacy === ClusterPrivacy.PUBLIC) delete data.projectIds + + const schemaValidation = ClusterDetailsSchema.partial().safeParse({ ...data, id: clusterId }) + const validateResult = validateSchema(schemaValidation) + if (validateResult instanceof ErrorResType) return validateResult + + const dbCluster = await getClusterById(clusterId) + if (!dbCluster) return new NotFound404() + + const { + projectIds, + stageIds, + kubeconfig, + zoneId, + ...clusterData + } = data + + const clusterUpdated = await updateClusterQuery(clusterId, clusterData, + // @ts-ignore + kubeconfig) + + // zone + if (zoneId) { + await linkZoneToClusters(zoneId, [clusterId]) + } + + // projects + const dbProjects = await getProjectsByClusterId(clusterId) + + let projectsToRemove: Project['id'][] = [] + + if (projectIds && clusterUpdated.privacy === ClusterPrivacy.DEDICATED) { + await linkClusterToProjects(clusterId, projectIds) + projectsToRemove = dbProjects?.map(project => project.id)?.filter(dbProjectId => !projectIds.includes(dbProjectId)) ?? [] + } else if (clusterUpdated.privacy === ClusterPrivacy.PUBLIC) { + projectsToRemove = dbProjects?.map(project => project.id) ?? [] + } + + for (const projectId of projectsToRemove) { + await removeClusterFromProject(clusterUpdated.id, projectId) + } + + // stages + if (stageIds) { + await linkClusterToStages(clusterId, stageIds) + + const dbStages = await listStagesByClusterId(clusterId) + if (dbStages) { + for (const stage of dbStages) { + if (!stageIds.includes(stage.id)) { + await removeClusterFromStage(clusterUpdated.id, stage.id) + } + } + } + } + + const hookReply = await hook.cluster.upsert(clusterId, dbCluster.zoneId) + await addLogs({ action: 'Update Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la mise à jour du cluster') + } + + return getClusterDetails(clusterId) +} + +interface DeleteClusterArgs { + clusterId: Cluster['id'] + userId?: User['id'] + requestId: string + force?: boolean +} +export async function deleteCluster({ clusterId, requestId, force, userId }: DeleteClusterArgs) { + let message: string | null = null + if (force) { + const envs = await prisma.environment.deleteMany({ + where: { clusterId }, + }) + message = `${envs.count} environnements supprimés de force, n'oubliez pas de reprovisionner les projets concernés` + } else { + const environment = await prisma.environment.findFirst({ where: { clusterId } }) + if (environment) return new BadRequest400('Impossible de supprimer le cluster, des environnements en activité y sont déployés') + } + + const hookReply = await hook.cluster.delete(clusterId) + await addLogs({ action: 'Delete Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la suppression du cluster') + } + + await deleteClusterQuery(clusterId) + return message +} diff --git a/.jjconflict-base-1/apps/server/src/resources/cluster/queries.ts b/.jjconflict-base-1/apps/server/src/resources/cluster/queries.ts new file mode 100644 index 0000000000..f66e1fc0bc --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/cluster/queries.ts @@ -0,0 +1,309 @@ +import type { Cluster, Environment, Kubeconfig, Prisma, Project, Stage } from '@prisma/client' +import prisma from '@/prisma.js' + +export async function getClustersAssociatedWithProject(projectId: Project['id']) { + const [ + clusterIdsHistory, + clusterIdsEnv, + ] = await Promise.all([ + prisma.projectClusterHistory.findMany({ + select: { + clusterId: true, + }, + where: { + projectId, + }, + }).then(history => history.map(({ clusterId }) => clusterId)), + prisma.cluster.findMany({ + where: { environments: { some: { project: { id: projectId } } } }, + select: { id: true }, + }).then(cluster => cluster.map(({ id }) => id)), + ]) + const clusterIds = [ + ...clusterIdsHistory, + ...clusterIdsEnv.filter(id => !clusterIdsHistory.includes(id)), + ] + return prisma.cluster.findMany({ + where: { id: { in: clusterIds } }, + select: { + id: true, + infos: true, + label: true, + privacy: true, + secretName: true, + kubeconfig: true, + clusterResources: true, + cpu: true, + gpu: true, + memory: true, + zone: { + select: { + id: true, + slug: true, + argocdUrl: true, + label: true, + }, + }, + }, + }) +} + +export async function updateProjectClusterHistory(projectId: Project['id'], clusterIds: Cluster['id'][]) { + return prisma.$transaction([ + prisma.projectClusterHistory.deleteMany({ + where: { + AND: { + projectId, + clusterId: { notIn: clusterIds }, + }, + }, + }), + prisma.projectClusterHistory.createMany({ + data: clusterIds.map(clusterId => ({ clusterId, projectId })), + skipDuplicates: true, + }), + ]) +} + +export function getClusterById(id: Cluster['id']) { + return prisma.cluster.findUnique({ + where: { id }, + include: { kubeconfig: true }, + }) +} + +export function getClusterByIdOrThrow(id: Cluster['id']) { + return prisma.cluster.findUniqueOrThrow({ + where: { id }, + include: { kubeconfig: true, zone: true }, + }) +} + +export function getClusterEnvironments(clusterId: Cluster['id']) { + return prisma.environment.findMany({ + where: { clusterId }, + select: { + name: true, + cpu: true, + gpu: true, + memory: true, + project: { + select: { + slug: true, + name: true, + owner: true, + members: true, + }, + }, + }, + }) +} + +export function getClusterDetails(id: Cluster['id']) { + return prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { + createdAt: true, + projects: { + select: { + id: true, + }, + }, + id: true, + clusterResources: true, + infos: true, + label: true, + privacy: true, + kubeconfig: true, + stages: true, + updatedAt: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export function getClustersByIds(clusterIds: Cluster['id'][]) { + return prisma.cluster.findMany({ + where: { + id: { in: clusterIds }, + }, + include: { kubeconfig: true }, + }) +} + +export function getPublicClusters() { + return prisma.cluster.findMany({ + where: { privacy: 'public' }, + include: { zone: true }, + }) +} + +export async function getClusterNamesByZoneId(zoneId: string) { + const clusterNames = await prisma.cluster.findMany({ + where: { zoneId }, + select: { + label: true, + }, + }) + return clusterNames.map(({ label }) => label) +} + +export function getClusterByLabel(label: Cluster['label']) { + return prisma.cluster.findUnique({ where: { label } }) +} + +export function getClusterByEnvironmentId(id: Environment['id']) { + return prisma.cluster.findMany({ + where: { + environments: { + some: { id }, + }, + }, + include: { kubeconfig: true }, + }) +} + +export function getClustersWithProjectIdAndConfig() { + return prisma.cluster.findMany({ + select: { + id: true, + stages: true, + projects: { + where: { + status: { not: 'archived' }, + }, + select: { + id: true, + name: true, + slug: true, + status: true, + }, + }, + clusterResources: true, + label: true, + infos: true, + privacy: true, + secretName: true, + kubeconfig: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export function listClusters(where: Prisma.ClusterWhereInput) { + return prisma.cluster.findMany({ + where, + select: { + id: true, + label: true, + stages: true, + clusterResources: true, + privacy: true, + infos: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export async function getProjectsByClusterId(id: Cluster['id']) { + return (await prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { projects: true }, + }))?.projects +} + +export async function listStagesByClusterId(id: Cluster['id']) { + return (await prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { stages: true }, + }))?.stages +} + +export function createCluster(data: Omit, kubeconfig: Pick, zoneId: string) { + return prisma.cluster.create({ + data: { + ...data, + // @ts-ignore + kubeconfig: { create: kubeconfig }, + zone: { + connect: { id: zoneId }, + }, + }, + }) +} + +export function updateCluster(id: Cluster['id'], data: Partial>, kubeconfig: Pick) { + return prisma.cluster.update({ + where: { id }, + data: { + ...data, + kubeconfig: { + // @ts-ignore + update: kubeconfig, + }, + }, + }) +} + +export function linkClusterToProjects(id: Cluster['id'], projectIds: Project['id'][]) { + return prisma.cluster.update({ + where: { id }, + data: { + projects: { + connect: projectIds.map(projectId => ({ id: projectId })), + }, + }, + }) +} + +export function linkClusterToStages(id: Cluster['id'], stageIds: Stage['id'][]) { + return prisma.cluster.update({ + where: { id }, + data: { + stages: { + connect: stageIds.map(stageId => ({ id: stageId })), + }, + }, + }) +} + +export function removeClusterFromProject(id: Cluster['id'], projectId: Project['id']) { + return prisma.cluster.update({ + where: { id }, + data: { + projects: { + disconnect: { + id: projectId, + }, + }, + }, + }) +} + +export function removeClusterFromStage(id: Cluster['id'], stageId: Stage['id']) { + return prisma.cluster.update({ + where: { id }, + data: { + stages: { + disconnect: { + id: stageId, + }, + }, + }, + }) +} + +export function deleteCluster(id: Cluster['id']) { + return prisma.cluster.delete({ + where: { id }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/cluster/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/cluster/router.spec.ts new file mode 100644 index 0000000000..528858b052 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/cluster/router.spec.ts @@ -0,0 +1,308 @@ +import type { ClusterDetails, Environment } from '@cpn-console/shared' +import { ADMIN_PERMS, clusterContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listClusters') +const businessGetDetailsMock = vi.spyOn(business, 'getClusterDetails') +const businessGetUsageMock = vi.spyOn(business, 'getClusterUsage') +const businessGetEnvironmentsMock = vi.spyOn(business, 'getClusterAssociatedEnvironments') +const businessCreateMock = vi.spyOn(business, 'createCluster') +const businessUpdateMock = vi.spyOn(business, 'updateCluster') +const businessDeleteMock = vi.spyOn(business, 'deleteCluster') + +describe('test clusterContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listClusters', () => { + it('as non admin', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + businessListMock.mockResolvedValueOnce([]) + const response = await app.inject() + .get(clusterContract.listClusters.path) + .end() + + expect(businessListMock).toHaveBeenCalledWith(user.user.id) + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('as admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + + authUserMock.mockResolvedValueOnce(user) + + businessListMock.mockResolvedValueOnce([]) + const response = await app.inject() + .get(clusterContract.listClusters.path) + .end() + + expect(businessListMock).toHaveBeenCalledWith() + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getClusterDetails', () => { + it('should return cluster details', async () => { + const cluster: ClusterDetails = { + id: faker.string.uuid(), + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetDetailsMock.mockResolvedValueOnce(cluster) + const response = await app.inject() + .get(clusterContract.getClusterDetails.path.replace(':clusterId', cluster.id)) + .end() + + expect(businessGetDetailsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(cluster) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterDetails.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetDetailsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getClusterUsage', () => { + it('should return cluster usage', async () => { + const resources = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetUsageMock.mockResolvedValueOnce(resources) + const response = await app.inject() + .get(clusterContract.getClusterUsage.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetUsageMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(resources) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterUsage.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetUsageMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getClusterEnvironments', () => { + it('should return cluster environments', async () => { + const envs: Environment[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(envs) + const response = await app.inject() + .get(clusterContract.getClusterEnvironments.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterEnvironments.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createCluster', () => { + const cluster: ClusterDetails = { + id: faker.string.uuid(), + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + + it('should return created cluster', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(cluster) + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.json()).toEqual(cluster) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateCluster', () => { + const clusterId = faker.string.uuid() + const cluster: Omit = { + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + + it('should return created cluster', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: clusterId, ...cluster }) + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.json()).toEqual({ id: clusterId, ...cluster }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteCluster', () => { + it('should return empty when delete', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/cluster/router.ts b/.jjconflict-base-1/apps/server/src/resources/cluster/router.ts new file mode 100644 index 0000000000..3924bce6a9 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/cluster/router.ts @@ -0,0 +1,131 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, clusterContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + createCluster, + deleteCluster, + getClusterAssociatedEnvironments, + getClusterDetails as getClusterDetailsBusiness, + getClusterUsage, + listClusters, + updateCluster, +} from './business.js' +import '@/types/index.js' + +export function clusterRouter() { + return serverInstance.router(clusterContract, { + listClusters: async ({ request: req }) => { + const { adminPermissions, user } = await authUser(req) + + let body: AsyncReturnType = [] + if (AdminAuthorized.ListClusters(adminPermissions)) { + body = await listClusters() + } else if (user) { + body = await listClusters(user.id) + } + + return { + status: 200, + body, + } + }, + + getClusterDetails: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const cluster = await getClusterDetailsBusiness(clusterId) + + return { + status: 200, + body: cluster, + } + }, + + getClusterUsage: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const usage = await getClusterUsage(clusterId) + + return { + status: 200, + body: usage, + } + }, + + createCluster: async ({ request: req, body: data }) => { + const { adminPermissions, user } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + + if (!user) return new Unauthorized401('Require to be requested from user not api key') + const body = await createCluster(data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + getClusterEnvironments: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const environments = await getClusterAssociatedEnvironments(clusterId) + + return { + status: 200, + body: environments, + } + }, + + updateCluster: async ({ request: req, params, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const clusterId = params.clusterId + const body = await updateCluster(data, clusterId, user.id, req.id) + + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + deleteCluster: async ({ request: req, params, query: { force } }) => { + const { user, adminPermissions, tokenId } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + if (!user?.id && !tokenId) return new Unauthorized401('Your identity has not been found') + + const clusterId = params.clusterId + const body = await deleteCluster({ + clusterId, + userId: user?.id, + requestId: req.id, + force, + }) + + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/environment/business.spec.ts b/.jjconflict-base-1/apps/server/src/resources/environment/business.spec.ts new file mode 100644 index 0000000000..7bfebb5b2e --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/environment/business.spec.ts @@ -0,0 +1,353 @@ +import type { Cluster, Environment, Project, ProjectMembers, ProjectRole, Stage, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { Result } from '../../utils/business.js' +import { checkClusterResources, checkProjectResources, createEnvironment, deleteEnvironment, getProjectEnvironments, updateEnvironment } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const user: User = { + id: faker.string.uuid(), + createdAt: new Date(), + updatedAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + lastLogin: null, +} +const project: Project & { + clusters: Pick[] + members: ProjectMembers[] + roles: ProjectRole[] + owner: User +} = { + createdAt: new Date(), + updatedAt: new Date(), + description: '', + everyonePerms: 649n, + id: faker.string.uuid(), + locked: false, + name: faker.string.alphanumeric(8), + status: 'created', + ownerId: faker.string.uuid(), + owner: user, + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + clusters: [], + roles: [], + members: [], + slug: faker.string.alphanumeric(8), + lastSuccessProvisionningVersion: faker.string.numeric(), +} + +describe('test environment business', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getProjectEnvironments', () => { + it('should query environment for projectId', async () => { + prisma.environment.findMany.mockResolvedValue([]) + const projectId = faker.string.uuid() + await getProjectEnvironments(projectId) + + expect(prisma.environment.findMany).toHaveBeenCalledTimes(1) + }) + }) + + describe('createEnvironment', () => { + const clusterId = faker.string.uuid() + const stageId = faker.string.uuid() + const env = { name: 'new-env' } + it('should create environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const stageId = faker.string.uuid() + + prisma.environment.create.mockResolvedValue({ clusterId } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await createEnvironment({ + userId: user.id, + projectId: project.id, + name: env.name, + cpu: 0.1, + gpu: 0.5, + memory: 2.0, + clusterId, + stageId, + requestId, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should create environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + + prisma.environment.create.mockResolvedValue({ clusterId } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await createEnvironment({ + userId: user.id, + projectId: project.id, + name: env.name, + cpu: 0.1, + gpu: 0.5, + memory: 2.0, + clusterId, + stageId, + requestId, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('updateEnvironment', () => { + it('should update environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.update.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await updateEnvironment({ + user, + environmentId, + requestId, + cpu: 2.0, + gpu: 4.0, + memory: 12.5, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should update environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.update.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await updateEnvironment({ + user, + environmentId, + requestId, + cpu: 2.0, + gpu: 4.0, + memory: 12.5, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('deleteEnvironment', () => { + it('should delete environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.delete.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await deleteEnvironment({ environmentId, userId: user.id, projectId: project.id, requestId }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.delete).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should delete environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.delete.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await deleteEnvironment({ environmentId, userId: user.id, projectId: project.id, requestId }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.delete).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('checkClusterResources', () => { + it('should authorize cluster not yet configured', async () => { + const cluster: Cluster = { + cpu: 0, + gpu: 0, + memory: 0, + } as Cluster + const result = await checkClusterResources({ cpu: 1, gpu: 0, memory: 1 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should authorize cluster not yet used', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 0, + gpu: 0, + memory: 0, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 7 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should authorize cluster used but not full', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 2, + gpu: 0, + memory: 2, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 6 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should refuse cluster without enough space', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 5, + gpu: 0, + memory: 5, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 6 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le cluster ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + it('should refuse cluster without GPU', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 2, + gpu: 0, + memory: 2, + }, + } as any) + const result = await checkClusterResources({ cpu: 2, gpu: 1, memory: 2 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le cluster ne dispose pas de suffisamment de ressources : GPU.') + }) + }) + + describe('checkProjectResources', () => { + const prodStage: Stage = { + id: faker.string.uuid(), + name: 'prod', + } + const hprodStage: Stage = { + id: faker.string.uuid(), + name: 'hprod', + } + it('should authorize prod deployment for project with hprod resource but no prod resources', async () => { + const project: Project = { + hprodCpu: 10, + hprodGpu: 10, + hprodMemory: 10, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + } as Project + prisma.stage.findUnique.mockResolvedValue(prodStage) + prisma.stage.findMany.mockResolvedValue([prodStage]) + const result = await checkProjectResources({ cpu: 1, gpu: 0, memory: 1, stageId: prodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should refuse hprod deployment for project with hprod resource but no prod resources', async () => { + const project: Project = { + hprodCpu: 10, + hprodGpu: 10, + hprodMemory: 10, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + } as Project + prisma.stage.findUnique.mockResolvedValue(hprodStage) + prisma.stage.findMany.mockResolvedValue([prodStage] as Stage[]) + prisma.environment.aggregate.mockResolvedValue({ + _sum: { cpu: 0, gpu: 0, memory: 0 }, + } as any) + const result = await checkProjectResources({ cpu: 20, gpu: 20, memory: 20, stageId: hprodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le projet ne dispose pas de suffisamment de ressources : CPU, GPU, Mémoire.') + }) + it('should refuse overloading hprod deployment', async () => { + const project: Project = { + hprodCpu: 20, + hprodGpu: 20, + hprodMemory: 20, + prodCpu: 10, + prodGpu: 10, + prodMemory: 10, + } as Project + prisma.stage.findUnique.mockResolvedValue(hprodStage) + prisma.stage.findMany.mockResolvedValue([prodStage] as Stage[]) + prisma.environment.aggregate.mockResolvedValue({ + _sum: { cpu: 15, gpu: 15, memory: 15 }, + } as any) + const result = await checkProjectResources({ cpu: 5, gpu: 6, memory: 5, stageId: hprodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le projet ne dispose pas de suffisamment de ressources : GPU.') + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/environment/business.ts b/.jjconflict-base-1/apps/server/src/resources/environment/business.ts new file mode 100644 index 0000000000..2f068dedf8 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/environment/business.ts @@ -0,0 +1,306 @@ +import type { Cluster, Environment, Project, Stage, User } from '@prisma/client' +import type { Resources, UserDetails } from '@/types/index.js' +import prisma from '@/prisma.js' +import { + addLogs, + deleteEnvironment as deleteEnvironmentQuery, + getEnvironmentsByProjectId, + initializeEnvironment, + updateEnvironment as updateEnvironmentQuery, +} from '@/resources/queries-index.js' +import { Result } from '@/utils/business.js' +import { hook } from '@/utils/hook-wrapper.js' + +export function getProjectEnvironments(projectId: Project['id']) { + return getEnvironmentsByProjectId(projectId) +} + +// Routes logic +interface CreateEnvironmentParam { + userId: User['id'] + projectId: Project['id'] + name: Environment['name'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + clusterId: Environment['clusterId'] + stageId: Stage['id'] + requestId: string +} + +interface CreateEnvironmentResult { + id: Environment['id'] + createdAt: Date + updatedAt: Date + projectId: Project['id'] + name: Environment['name'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + clusterId: Environment['clusterId'] + stageId: Stage['id'] +} + +export async function createEnvironment({ + userId, + projectId, + name, + cpu, + gpu, + memory, + autosync, + clusterId, + stageId, + requestId, +}: CreateEnvironmentParam): Promise> { + const environment = await initializeEnvironment({ projectId, name, cpu, gpu, memory, autosync, clusterId, stageId }) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Create Environment', data: results, userId, requestId, projectId }) + if (results.failed) { + return Result.fail('Echec des services à la création de l\'environnement') + } + + return Result.succeed({ + ...environment, + stageId, + }) +} + +interface UpdateEnvironmentParam { + user: UserDetails + environmentId: Environment['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + requestId: string +} + +export async function updateEnvironment({ + user, + environmentId, + requestId, + cpu, + gpu, + memory, + autosync, +}: UpdateEnvironmentParam) { + const env = await updateEnvironmentQuery({ + id: environmentId, + cpu, + gpu, + memory, + autosync, + }) + const { results } = await hook.project.upsert(env.projectId) + await addLogs({ action: 'Update Environment', data: results, userId: user.id, requestId, projectId: env.projectId }) + if (results.failed) { + return Result.fail('Echec des services à la mise à jour de l\'environnement') + } + + return Result.succeed(env) +} + +interface DeleteEnvironmentParam { + userId?: User['id'] + environmentId: Environment['id'] + projectId: Project['id'] + requestId: string +} + +export async function deleteEnvironment({ + userId, + environmentId, + projectId, + requestId, +}: DeleteEnvironmentParam) { + const env = await deleteEnvironmentQuery(environmentId) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Delete Environment', data: results, userId, requestId, projectId: env.projectId }) + if (results.failed) { + return Result.fail('Echec des services à la suppression de l\'environnement') + } + return Result.succeed(null) +} + +export async function checkEnvironmentCreate(input: { + clusterId: Cluster['id'] + projectId: Project['id'] + name: Environment['name'] + stageId: Stage['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}): Promise> { + const errorMessages: string[] = [] + const [stage, sameNameEnvironment, cluster] = await Promise.all([ + input.stageId + ? prisma.stage.findUnique({ where: { id: input.stageId } }) + : undefined, + input.name + ? prisma.environment.findUnique({ where: { projectId_name: { projectId: input.projectId, name: input.name } } }) + : undefined, + input.clusterId + ? prisma.cluster.findFirst({ + where: { + OR: [{ // un cluster public + id: input.clusterId, + privacy: 'public', + }, { + id: input.clusterId, // un cluster dédié rattaché au projet + privacy: 'dedicated', + projects: { some: { id: input.projectId } }, + }], + }, + }) + : undefined, + ]) + if (sameNameEnvironment) errorMessages.push('Ce nom d\'environnement est déjà pris.') + if (!stage) errorMessages.push('Type d\'environnment invalide.') + if (!cluster) { + errorMessages.push('Cluster invalide.') + } else { + const resourceCheckResult = await checkClusterResources(input, cluster) + if (resourceCheckResult.isError) { + errorMessages.push(resourceCheckResult.error) + } + const project = await prisma.project.findUniqueOrThrow({ where: { id: input.projectId } }) + const projectCheckResult = await checkProjectResources(input, project) + if (projectCheckResult.isError) { + errorMessages.push(projectCheckResult.error) + } + } + if (errorMessages.length > 0) { + return Result.fail(errorMessages.join('\n')) + } + return Result.succeed(true) +} + +export async function checkClusterResources(input: { + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}, cluster: Cluster): Promise> { + if (cluster.cpu === 0 && cluster.memory === 0) { + // Unconfigured cluster + return Result.succeed(true) + } + const unsufficientResource = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: cluster.cpu, gpu: cluster.gpu, memory: cluster.memory }, + where: { + cluster: { + id: cluster.id, + }, + }, + }) + if (unsufficientResource.length > 0) { + return Result.fail(`Le cluster ne dispose pas de suffisamment de ressources : ${unsufficientResource.join(', ')}.`) + } + return Result.succeed(true) +} + +export async function checkProjectResources(input: { + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + stageId: Environment['stageId'] +}, project: Project): Promise> { + if (project.limitless) { + // No limits + return Result.succeed(true) + } + const stage = await prisma.stage.findUnique({ where: { id: input.stageId } }) + const prodStages = await prisma.stage.findMany({ select: { id: true }, where: { name: 'prod' } }) + let overflowResources: string[] + if (stage?.name === 'prod') { + overflowResources = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: project.prodCpu, gpu: project.prodGpu, memory: project.prodMemory }, + where: { + projectId: project.id, + stageId: { + in: prodStages.map(s => s.id), + }, + }, + }) + } else { // hprod + overflowResources = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: project.hprodCpu, gpu: project.hprodGpu, memory: project.hprodMemory }, + where: { + projectId: project.id, + stageId: { + notIn: prodStages.map(s => s.id), + }, + }, + }) + } + if (overflowResources.length > 0) { + return Result.fail(`Le projet ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.`) + } + return Result.succeed(true) +} + +export async function checkEnvironmentUpdate(input: { + environmentId: Environment['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}): Promise> { + const environment = await prisma.environment.findUniqueOrThrow({ + select: { cluster: true, projectId: true, stageId: true }, + where: { id: input.environmentId }, + }) + const cluster = await prisma.cluster.findUniqueOrThrow({ + where: { id: environment.cluster.id }, + }) + const errorMessages: string[] = [] + const resourceCheckResult = await checkClusterResources(input, cluster) + if (resourceCheckResult.isError) { + errorMessages.push(resourceCheckResult.error) + } + const project = await prisma.project.findUniqueOrThrow({ where: { id: environment.projectId } }) + const projectCheckResult = await checkProjectResources({ stageId: environment.stageId, ...input }, project) + if (projectCheckResult.isError) { + errorMessages.push(projectCheckResult.error) + } + if (errorMessages.length > 0) { + return Result.fail(errorMessages.join('\n')) + } + return Result.succeed(true) +} + +export async function getOverflowResources({ request, limit, where }: { + request: Resources + limit: Resources + where: any +}): Promise { + if (limit.cpu === 0 && limit.memory === 0) { + // Unconfigured project prod resources + return [] + } + const environmentResources = await prisma.environment.aggregate({ + _sum: { + memory: true, + cpu: true, + gpu: true, + }, + where, + }) + const unsufficientResource: string[] = [] + if ((environmentResources._sum.cpu ?? 0) + request.cpu > limit.cpu) { + unsufficientResource.push('CPU') + } + if ((environmentResources._sum.gpu ?? 0) + request.gpu > limit.gpu) { + unsufficientResource.push('GPU') + } + if ((environmentResources._sum.memory ?? 0) + request.memory > limit.memory) { + unsufficientResource.push('Mémoire') + } + return unsufficientResource +} diff --git a/.jjconflict-base-1/apps/server/src/resources/environment/queries.ts b/.jjconflict-base-1/apps/server/src/resources/environment/queries.ts new file mode 100644 index 0000000000..6b228e2687 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/environment/queries.ts @@ -0,0 +1,99 @@ +import type { Environment, Prisma, Project } from '@prisma/client' +import prisma from '@/prisma.js' + +// SELECT +export function getEnvironmentByIdOrThrow(id: Environment['id']) { + return prisma.environment.findUniqueOrThrow({ where: { id }, include: { stage: true } }) +} + +export function getEnvironmentInfos(id: Environment['id']) { + return prisma.environment.findUniqueOrThrow({ + where: { id }, + include: { + project: { + select: { + owner: true, + name: true, + id: true, + status: true, + repositories: { + where: { isInfra: true }, + }, + locked: true, + clusters: { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + }, + }, + }, + }, + stage: true, + }, + }) +} + +export async function getEnvironmentsByProjectId(projectId: Project['id']) { + return prisma.environment.findMany({ + where: { projectId }, + include: { + stage: true, + }, + }) +} + +export function getEnvironmentByIdWithCluster(id: Environment['id']) { + return prisma.environment.findUnique({ + where: { id }, + include: { + cluster: { + include: { kubeconfig: true }, + }, + }, + }) +} + +// INSERT +export function initializeEnvironment(data: Prisma.EnvironmentUncheckedCreateInput) { + return prisma.environment.create({ + data, + include: { + project: { + include: { + repositories: { + where: { isInfra: true }, + }, + }, + }, + }, + }) +} + +export function updateEnvironment({ id, cpu, gpu, memory, autosync }: { id: Environment['id'], cpu: Environment['cpu'], gpu: Environment['gpu'], memory: Environment['memory'], autosync: Environment['autosync'] }) { + return prisma.environment.update({ + where: { + id, + }, + data: { + cpu, + gpu, + memory, + autosync, + }, + }) +} + +// DELETE +export function deleteEnvironment(id: Environment['id']) { + return prisma.environment.delete({ + where: { id }, + }) +} + +export function deleteAllEnvironmentForProject(id: Project['id']) { + return prisma.environment.deleteMany({ + where: { projectId: id }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/environment/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/environment/router.spec.ts new file mode 100644 index 0000000000..2fbff26774 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/environment/router.spec.ts @@ -0,0 +1,340 @@ +import type { Environment } from '@cpn-console/shared' +import { environmentContract, PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { atDates, getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetProjectEnvironmentsMock = vi.spyOn(business, 'getProjectEnvironments') +const businessCreateEnvironmentMock = vi.spyOn(business, 'createEnvironment') +const businessUpdateEnvironmentMock = vi.spyOn(business, 'updateEnvironment') +const businessDeleteEnvironmentMock = vi.spyOn(business, 'deleteEnvironment') +const businessCheckEnvironmentCreateMock = vi.spyOn(business, 'checkEnvironmentCreate') +const businessCheckEnvironmentUpdateMock = vi.spyOn(business, 'checkEnvironmentUpdate') + +describe('environmentRouter tests', () => { + let projectId: string + let environmentId: string + let environmentData: Omit + + beforeEach(() => { + vi.resetAllMocks() + projectId = faker.string.uuid() + environmentId = faker.string.uuid() + environmentData = { + projectId, + name: 'envname', + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + } + }) + + describe('listEnvironments', () => { + it('should return environments for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetProjectEnvironmentsMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(environmentContract.listEnvironments.path) + .query({ projectId }) + .end() + + expect(businessGetProjectEnvironmentsMock).toHaveBeenCalledWith(projectId) + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for non member of projectId query ', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(environmentContract.listEnvironments.path) + .query({ projectId }) + .end() + + expect(businessGetProjectEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createEnvironment', () => { + it('should create environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ success: true }) + businessCreateEnvironmentMock.mockResolvedValueOnce({ + success: true, + data: { id: environmentId, ...environmentData, ...atDates }, + }) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.json()).toMatchObject({ id: environmentId, ...environmentData }) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ success: true, message: 'pas d erreur' }) + businessCreateEnvironmentMock.mockResolvedValueOnce({ isError: true, message: 'une erreur' }) + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(500) + }) + it('should pass invalid reason error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ isError: true, message: 'une erreur' }) + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateEnvironment', () => { + let updateData: { cpu: number, gpu: number, memory: number } + beforeEach(() => { + updateData = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + } + }) + + it('should update environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentUpdateMock.mockResolvedValueOnce({ success: true, value: true }) + businessUpdateEnvironmentMock.mockResolvedValueOnce({ success: true, data: { id: environmentId, ...environmentData, ...atDates } }) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.json()).toMatchObject({ id: environmentId, ...environmentData }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateEnvironmentMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(500) + }) + + it('should pass invalid reason error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentUpdateMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('deleteEnvironment', () => { + it('should delete environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteEnvironmentMock.mockResolvedValueOnce({ success: true }) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteEnvironmentMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(500) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/environment/router.ts b/.jjconflict-base-1/apps/server/src/resources/environment/router.ts new file mode 100644 index 0000000000..7ef91c05ff --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/environment/router.ts @@ -0,0 +1,107 @@ +import { environmentContract, ProjectAuthorized } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { BadRequest400, Forbidden403, Internal500, Unauthorized401 } from '@/utils/errors.js' +import { checkEnvironmentCreate, checkEnvironmentUpdate, createEnvironment, deleteEnvironment, getProjectEnvironments, updateEnvironment } from './business.js' + +export function environmentRouter() { + return serverInstance.router(environmentContract, { + listEnvironments: async ({ request: req, query }) => { + const projectId = query.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListEnvironments(perms)) return new Forbidden403() + const body = await getProjectEnvironments(projectId) + + return { + status: 200, + body, + } + }, + + createEnvironment: async ({ request: req, body: requestBody }) => { + const projectId = requestBody.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const checkCreateResult = await checkEnvironmentCreate({ ...requestBody }) + if (checkCreateResult.isError) return new BadRequest400(checkCreateResult.error) + + const result = await createEnvironment({ + userId: perms.user.id, + projectId, + name: requestBody.name, + clusterId: requestBody.clusterId, + cpu: requestBody.cpu, + gpu: requestBody.gpu, + memory: requestBody.memory, + autosync: requestBody.autosync, + stageId: requestBody.stageId, + requestId: req.id, + }) + if (result.isError) { + return new Internal500(result.error) + } + return { + status: 201, + body: result.data, + } + }, + + updateEnvironment: async ({ request: req, body: requestBody, params }) => { + const { environmentId } = params + const perms = await authUser(req, { environmentId }) + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const checkUpdateResult = await checkEnvironmentUpdate({ environmentId, ...requestBody }) + if (checkUpdateResult.isError) return new BadRequest400(checkUpdateResult.error) + + const result = await updateEnvironment({ + user: perms.user, + environmentId, + cpu: requestBody.cpu, + gpu: requestBody.gpu, + memory: requestBody.memory, + autosync: requestBody.autosync, + requestId: req.id, + }) + if (result.isError) { + return new Internal500(result.error) + } + return { + status: 200, + body: result.data, + } + }, + + deleteEnvironment: async ({ request: req, params }) => { + const { environmentId } = params + const perms = await authUser(req, { environmentId }) + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const result = await deleteEnvironment({ + userId: perms.user?.id, + environmentId, + requestId: req.id, + projectId: perms.projectId, + }) + if (result.isError) { + return new Internal500(result.error) + } + + return { + status: 204, + body: result.data, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/index.ts b/.jjconflict-base-1/apps/server/src/resources/index.ts new file mode 100644 index 0000000000..93a285f9ac --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/index.ts @@ -0,0 +1,49 @@ +import type { FastifyInstance } from 'fastify' +import { serverInstance } from '@/app.js' + +import { adminRoleRouter } from './admin-role/router.js' +import { adminTokenRouter } from './admin-token/router.js' +import { clusterRouter } from './cluster/router.js' +import { environmentRouter } from './environment/router.js' +import { logRouter } from './log/router.js' +import { projectMemberRouter } from './project-member/router.js' +import { projectRoleRouter } from './project-role/router.js' +import { projectServiceRouter } from './project-service/router.js' +import { projectRouter } from './project/router.js' +import { repositoryRouter } from './repository/router.js' +import { serviceChainRouter } from './service-chain/router.js' +import { serviceMonitorRouter } from './service-monitor/router.js' +import { stageRouter } from './stage/router.js' +import { pluginConfigRouter } from './system/config/router.js' +import { systemRouter } from './system/router.js' +import { systemSettingsRouter } from './system/settings/router.js' +import { userRouter } from './user/router.js' +import { personalAccessTokenRouter } from './user/tokens/router.js' +import { zoneRouter } from './zone/router.js' + +// relax validation schema if NO_VALIDATION env var is set to true. +// /!\ It can lead to security leaks !!!! +const validateTrue = { responseValidation: process.env.NO_VALIDATION !== 'true' } +export function apiRouter() { + return async (app: FastifyInstance) => { + await app.register(serverInstance.plugin(adminRoleRouter()), validateTrue) + await app.register(serverInstance.plugin(adminTokenRouter()), validateTrue) + await app.register(serverInstance.plugin(clusterRouter()), validateTrue) + await app.register(serverInstance.plugin(serviceChainRouter()), validateTrue) + await app.register(serverInstance.plugin(environmentRouter()), validateTrue) + await app.register(serverInstance.plugin(logRouter()), validateTrue) + await app.register(serverInstance.plugin(personalAccessTokenRouter()), validateTrue) + await app.register(serverInstance.plugin(projectRouter()), validateTrue) + await app.register(serverInstance.plugin(projectMemberRouter()), validateTrue) + await app.register(serverInstance.plugin(projectRoleRouter()), validateTrue) + await app.register(serverInstance.plugin(projectServiceRouter()), validateTrue) + await app.register(serverInstance.plugin(repositoryRouter()), validateTrue) + await app.register(serverInstance.plugin(serviceMonitorRouter()), validateTrue) + await app.register(serverInstance.plugin(pluginConfigRouter()), validateTrue) + await app.register(serverInstance.plugin(stageRouter()), validateTrue) + await app.register(serverInstance.plugin(systemRouter()), validateTrue) + await app.register(serverInstance.plugin(systemSettingsRouter()), validateTrue) + await app.register(serverInstance.plugin(userRouter()), validateTrue) + await app.register(serverInstance.plugin(zoneRouter()), validateTrue) + } +} diff --git a/.jjconflict-base-1/apps/server/src/resources/log/business.spec.ts b/.jjconflict-base-1/apps/server/src/resources/log/business.spec.ts new file mode 100644 index 0000000000..8a4d080e48 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/log/business.spec.ts @@ -0,0 +1,42 @@ +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { getLogs } from './business.ts' + +describe('test log business', () => { + it('should map filter (clean logs)', async () => { + const dbLogs = [{ + data: { args: {} }, + createdAt: new Date(), + updatedAt: new Date(), + userId: null, + action: 'Action', + id: faker.string.uuid(), + }] + const query = { limit: 10, offset: 10, clean: true, projectId: undefined } + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + const [_total, logs] = await getLogs(query) + + expect(logs[0]).not.haveOwnProperty('requestId') + expect(logs[0].data).not.haveOwnProperty('results') + expect(logs[0].data).not.haveOwnProperty('args') + expect(logs[0].data).not.haveOwnProperty('config') + }) + + it('should not filter (admin logs)', async () => { + const dbLogs = [{ + data: { args: {} }, + createdAt: new Date(), + updatedAt: new Date(), + userId: null, + action: 'Action', + id: faker.string.uuid(), + }] + const query = { limit: 10, offset: 10, clean: false, projectId: undefined } + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + const [_total, logs] = await getLogs(query) + + expect(logs[0].data).haveOwnProperty('args') + expect(logs[0].data).not.haveOwnProperty('config') + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/log/business.ts b/.jjconflict-base-1/apps/server/src/resources/log/business.ts new file mode 100644 index 0000000000..9a0182e7e1 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/log/business.ts @@ -0,0 +1,13 @@ +import type { logContract } from '@cpn-console/shared' +import { CleanLogSchema } from '@cpn-console/shared' +import { getAllLogs } from '@/resources/queries-index.js' + +export async function getLogs({ offset, limit, projectId, clean }: typeof logContract.getLogs.query._type) { + const [total, logs] = await getAllLogs({ skip: offset, take: limit, where: { projectId } }) + return [ + total, + clean + ? logs.map(log => CleanLogSchema.parse(log)) + : logs, + ] +} diff --git a/.jjconflict-base-1/apps/server/src/resources/log/queries.ts b/.jjconflict-base-1/apps/server/src/resources/log/queries.ts new file mode 100644 index 0000000000..3851a8f13f --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/log/queries.ts @@ -0,0 +1,57 @@ +import type { Log, Prisma, Project, User } from '@prisma/client' +import { exclude } from '@cpn-console/shared' +import prisma from '@/prisma.js' + +// SELECT +export function getAllLogsForUser(user: User, offset = 0) { + return prisma.log.findMany({ + where: { userId: user.id }, + take: 100, + skip: offset, + }) +} + +export function getAllLogs({ skip = 0, take = 5, where }: Prisma.LogFindManyArgs) { + return prisma.$transaction([ + prisma.log.count({ where }), + prisma.log.findMany({ + orderBy: { + createdAt: 'desc', + }, + skip, + take, + where, + }), + ]) +} + +// CREATE +interface AddLogsArgs { + action: Log['action'] + data: Record + userId?: User['id'] | null + requestId: string + projectId?: Project['id'] +} +export function addLogs({ action, data, requestId, userId = null, projectId }: AddLogsArgs) { + return prisma.log.create({ + data: { + action, + userId, + data: exclude(data, ['cluster', 'user', 'newCreds', 'apis']), + requestId, + projectId, + }, + }) +} + +// TECH +export function _createLog(data: Parameters[0]['create']) { + return prisma.log.upsert({ + where: { + id: data.id, + }, + create: data, + update: data, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/log/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/log/router.spec.ts new file mode 100644 index 0000000000..911f69305d --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/log/router.spec.ts @@ -0,0 +1,93 @@ +import { ADMIN_PERMS, logContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetLogsMock = vi.spyOn(business, 'getLogs') + +describe('test logContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getLogs', () => { + it('should return logs for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const logs = [] + const total = 1 + + authUserMock.mockResolvedValueOnce(user) + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0 }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ total, logs }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin, no projectId', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0 }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return logs for non-admin, with projectId', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 1n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + const projectId = faker.string.uuid() + + const logs = [] + const total = 1 + + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0, projectId, clean: false }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledWith({ clean: true, limit: 10, offset: 0, projectId }) + expect(response.statusCode).toEqual(200) + }) + + it('should not return logs for non-admin, with projectId', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + const projectId = faker.string.uuid() + + const logs = [] + const total = 1 + + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0, projectId, clean: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/log/router.ts b/.jjconflict-base-1/apps/server/src/resources/log/router.ts new file mode 100644 index 0000000000..72e8712f0b --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/log/router.ts @@ -0,0 +1,30 @@ +import type { CleanLog, Log, XOR } from '@cpn-console/shared' +import type { UserProfile, UserProjectProfile } from '@/utils/controller.js' +import { AdminAuthorized, logContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getLogs } from './business.js' + +export function logRouter() { + return serverInstance.router(logContract, { + // Récupérer des logs + getLogs: async ({ request: req, query }) => { + const perms: XOR = query.projectId + ? await authUser(req, { id: query.projectId }) + : await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) { + if (!perms.projectPermissions) return new Forbidden403() + query.clean = true + } + + const [total, logs] = await getLogs(query) as [number, unknown[]] as [number, Array] + + return { + status: 200, + body: { total, logs }, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/project-member/business.ts b/.jjconflict-base-1/apps/server/src/resources/project-member/business.ts new file mode 100644 index 0000000000..4144b81813 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project-member/business.ts @@ -0,0 +1,73 @@ +import type { projectMemberContract, XOR } from '@cpn-console/shared' +import type { Project, User } from '@prisma/client' +import { logger as baseLogger } from '@cpn-console/logger' +import { UserSchema } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + addLogs, + deleteMember, + listMembers as listMembersQuery, + upsertMember, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' +import { logViaSession } from '../user/business.js' + +const logger = baseLogger.child({ scope: 'resource:project-member' }) + +export const listMembers = async (projectId: Project['id']) => listMembersQuery(projectId) + +export async function addMember(projectId: Project['id'], user: XOR<{ userId: string }, { email: string }>, requestorId: User['id'], requestId: string, projectOwnerId: Project['ownerId']) { + let userInDb: User | undefined | null + + logger.info({ requestId, requestorId, projectId, identifierType: user.userId ? 'userId' : 'email' }, 'Add project member started') + if (user.userId) { + userInDb = await prisma.user.findUnique({ where: { id: user.userId, type: 'human' } }) + } else if (user.email) { + userInDb = await prisma.user.findUnique({ where: { email: user.email, type: 'human' } }) + } else { + return new BadRequest400('Veuillez spécifiez au moins un userId ou un email') + } + if (userInDb) { + if (userInDb.id === projectOwnerId) return new BadRequest400('Le owner ne peut pas être ajouté à cette liste') + } else if (user.email) { + const hookReply = await hook.user.retrieveUserByEmail(user.email) + await addLogs({ action: 'Retrieve User By Email', data: hookReply, userId: requestorId, requestId }) + if (hookReply.failed) { + logger.error({ requestId, requestorId, projectId }, 'Add project member failed during user lookup hooks') + throw new BadRequest400('Echec de la recherche auprès des services externes') + } + + const retrievedUser = hookReply.results.keycloak?.user + if (!retrievedUser) return new BadRequest400('Utilisateur introuvable') + const userValidated = UserSchema.pick({ email: true, firstName: true, lastName: true, id: true }).safeParse(retrievedUser) + if (!userValidated.success) return new BadRequest400('L\'utilisateur trouvé ne remplit pas les conditions de vérification') + const logResults = await logViaSession({ ...userValidated.data, groups: [] }) + userInDb = logResults.user + } else { + return new NotFound404() + } + + await upsertMember({ projectId, userId: userInDb.id, roleIds: [] }) + await hook.projectMember.upsert(projectId, userInDb.id) + logger.info({ requestId, requestorId, projectId, userId: userInDb.id }, 'Add project member completed') + return listMembers(projectId) +} + +export async function patchMembers(projectId: Project['id'], members: typeof projectMemberContract.patchMembers.body._type) { + logger.info({ projectId, membersCount: members.length }, 'Patch project members started') + for (const member of members) { + await upsertMember({ projectId, userId: member.userId, roleIds: member.roles }) + await hook.projectMember.upsert(projectId, member.userId) + } + logger.info({ projectId, membersCount: members.length }, 'Patch project members completed') + return listMembers(projectId) +} + +export async function removeMember(projectId: Project['id'], userId: User['id']) { + logger.info({ projectId, userId }, 'Remove project member started') + await hook.projectMember.delete(projectId, userId) + await deleteMember({ projectId, userId }) + logger.info({ projectId, userId }, 'Remove project member completed') + return listMembers(projectId) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/project-member/queries.ts b/.jjconflict-base-1/apps/server/src/resources/project-member/queries.ts new file mode 100644 index 0000000000..a4ceb00df1 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project-member/queries.ts @@ -0,0 +1,33 @@ +import type { + Prisma, + + Project, +} from '@prisma/client' + +import prisma from '@/prisma.js' + +export const listMembers = (projectId: Project['id']) => prisma.projectMembers.findMany({ where: { projectId }, include: { user: true } }) + +export function upsertMember(data: Prisma.ProjectMembersUncheckedCreateInput) { + return prisma.projectMembers.upsert({ + where: { + projectId_userId: { + userId: data.userId, + projectId: data.projectId, + }, + }, + create: data, + update: { + roleIds: data.roleIds, + }, + include: { user: true }, + }) +} + +export function deleteMember(data: Prisma.ProjectMembersWhereUniqueInput['projectId_userId']) { + return prisma.projectMembers.delete({ + where: { + projectId_userId: data, + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/project-member/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/project-member/router.spec.ts new file mode 100644 index 0000000000..6ebb1d50a2 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project-member/router.spec.ts @@ -0,0 +1,294 @@ +import type { Member } from '@cpn-console/shared' +import { PROJECT_PERMS, projectMemberContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMembersMock = vi.spyOn(business, 'listMembers') +const businessAddMemberMock = vi.spyOn(business, 'addMember') +const businessPatchMembersMock = vi.spyOn(business, 'patchMembers') +const businessRemoveMemberMock = vi.spyOn(business, 'removeMember') + +describe('projectMemberRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const userId = faker.string.uuid() + + describe('listMembers', () => { + it('should return members for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessListMembersMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectMemberContract.listMembers.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectMemberContract.listMembers.path.replace(':projectId', projectId)) + .end() + + expect(businessListMembersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('addMember', () => { + const memberData: Partial = { + userId: faker.string.uuid(), + } + + it('should add member for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const newMember = { + ...memberData, + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + roleIds: [], + } + + businessAddMemberMock.mockResolvedValueOnce([newMember]) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.json()).toEqual([newMember]) + expect(response.statusCode).toEqual(201) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + businessAddMemberMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('patchMembers', () => { + const patchData = [{ userId: faker.string.uuid(), roles: [] }] + + it('should patch members for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchMembersMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('removeMember', () => { + it('should remove member for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessRemoveMemberMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should be able leave a project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessRemoveMemberMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/project-member/router.ts b/.jjconflict-base-1/apps/server/src/resources/project-member/router.ts new file mode 100644 index 0000000000..e9755fab57 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project-member/router.ts @@ -0,0 +1,77 @@ +import { ProjectAuthorized, projectMemberContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + addMember, + listMembers, + patchMembers, + removeMember, +} from './business.js' + +export function projectMemberRouter() { + return serverInstance.router(projectMemberContract, { + listMembers: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListMembers(perms)) return new Forbidden403() + const body = await listMembers(projectId) + + return { + status: 200, + body, + } + }, + + addMember: async ({ request: req, params, body }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageMembers(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await addMember(projectId, body, perms.user.id, req.id, perms.projectOwnerId) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 201, + body: resBody, + } + }, + + patchMembers: async ({ request: req, params, body }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageMembers(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await patchMembers(projectId, body) + + return { + status: 200, + body: resBody, + } + }, + + removeMember: async ({ request: req, params }) => { + const { projectId, userId } = params + const perms = await authUser(req, { id: projectId }) + + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (!ProjectAuthorized.ManageMembers(perms) && userId !== perms.user?.id) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await removeMember(projectId, params.userId) + + return { + status: 200, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/project-role/business.spec.ts b/.jjconflict-base-1/apps/server/src/resources/project-role/business.spec.ts new file mode 100644 index 0000000000..89a70147fd --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project-role/business.spec.ts @@ -0,0 +1,357 @@ +import type { Project, ProjectMembers, ProjectRole } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../__mocks__/prisma.js' +import { countRolesMembers, createRole, deleteRole, listRoles, patchRoles } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', () => ({ + hook: { + project: { + upsert: vi.fn(), + delete: vi.fn(), + getSecrets: vi.fn(), + }, + projectRole: { + upsert: vi.fn(), + delete: vi.fn(), + }, + user: { + retrieveUserByEmail: vi.fn(), + }, + }, +})) + +describe('test project-role business', () => { + const project: Project = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + slug: faker.lorem.word({ length: { min: 2, max: 10 } }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + description: faker.lorem.sentence({ min: 2, max: 10 }), + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + everyonePerms: 0n, + ownerId: faker.string.uuid(), + lastSuccessProvisionningVersion: null, + } + const projectId = faker.string.uuid() + + describe('listRoles', () => { + it('should stringify bigint', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + const response = await listRoles(projectId) + expect(response).toContainEqual(expect.objectContaining({ permissions: '4' })) + }) + + it('should strip oidcGroup prefix', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: `/${project.slug}/console/admin`, + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValueOnce(project) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + + const response = await listRoles(projectId) + expect(response[0].oidcGroup).toBe('/console/admin') + }) + }) + + describe('createRole', () => { + it('should create role with incremented position when position 0 is the highest', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 1, projectId } }) + }) + + it('should create role with incremented position with bigger position', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 51, projectId } }) + }) + + it('should create role with incremented position with no role in db', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(null) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.projectRole.findFirst.mockResolvedValueOnce(null) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 0, projectId } }) + }) + + it('should create role with enforced oidcGroup prefix', async () => { + const dbRole: any = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: `/${project.slug}/console/admin`, + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValueOnce(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + + await createRole(projectId, { name: 'test', permissions: '4', oidcGroup: '/console/admin' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + oidcGroup: `/${project.slug}/console/admin`, + }), + })) + }) + }) + + describe('deleteRole', () => { + const roleId = faker.string.uuid() + it('should delete role and remove id from concerned users', async () => { + const dbRole: ProjectRole = { + id: roleId, + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + const members = [{ + userId: faker.string.uuid(), + projectId, + roleIds: [roleId], + }, { + userId: faker.string.uuid(), + projectId, + roleIds: [roleId, faker.string.uuid()], + }] as const satisfies Partial[] + + prisma.projectRole.findUnique.mockResolvedValue(dbRole) + prisma.projectMembers.findMany.mockResolvedValueOnce(members) + prisma.projectRole.findMany.mockResolvedValueOnce([]) + prisma.projectRole.delete.mockResolvedValue(dbRole) + await deleteRole(roleId) + + expect(prisma.projectMembers.update).toHaveBeenNthCalledWith(1, { where: expect.any(Object), data: { roleIds: { set: [] } } }) + expect(prisma.projectMembers.update).toHaveBeenNthCalledWith(2, { where: expect.any(Object), data: { roleIds: { set: [members[1].roleIds[1]] } } }) + expect(prisma.projectRole.delete).toHaveBeenCalledWith({ where: { id: roleId } }) + }) + }) + + describe.skip('countRolesMembers', () => { + it('should return aggregated role member counts', async () => { + const roles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + }] as const satisfies ProjectRole[] + + const members = [{ + userId: faker.string.uuid(), + projectId, + roleIds: [roles[0].id, roles[1].id], + }, { + userId: faker.string.uuid(), + projectId, + roleIds: [roles[1].id], + }] as const satisfies ProjectMembers[] + + prisma.projectRole.findMany.mockResolvedValue(roles) + prisma.projectMembers.findMany.mockResolvedValue(members) + + const response = await countRolesMembers(projectId) + + expect(response).toEqual({ [roles[0].id]: 1, [roles[1].id]: 2 }) + }) + }) + + describe('patchRoles', () => { + const dbRoles: ProjectRole[] = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + projectId, + oidcGroup: 'group1', + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + projectId, + oidcGroup: 'group2', + type: 'managed', + }] + + it('should do nothing', async () => { + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue([]) + await patchRoles(projectId, []) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 1 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(projectId, updateRoles) + + expect(response).instanceOf(BadRequest400) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions (missing)', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[1].id, position: 1 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(projectId, updateRoles) + + expect(response).instanceOf(BadRequest400) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should update positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 0 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledTimes(2) + }) + + it('should update permissions', async () => { + const updateRoles: (Pick & { permissions: string })[] = [ + { id: dbRoles[1].id, permissions: '0' }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + data: { + name: dbRoles[1].name, + permissions: 0n, + position: 1, + oidcGroup: dbRoles[1].oidcGroup, + projectId, + type: 'managed', + }, + where: { + id: dbRoles[1].id, + }, + })) + }) + + it('should update role with enforced oidcGroup prefix', async () => { + const updateRoles: any[] = [ + { id: dbRoles[1].id, oidcGroup: '/console/admin' }, + ] + + prisma.project.findUnique.mockResolvedValue(project) + + const dbRoleWithPrefix = { ...dbRoles[1], oidcGroup: `/${project.slug}/console/group2` } + prisma.projectRole.findMany.mockResolvedValue([dbRoleWithPrefix]) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + oidcGroup: `/${project.slug}/console/admin`, + }), + })) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/project-role/business.ts b/.jjconflict-base-1/apps/server/src/resources/project-role/business.ts new file mode 100644 index 0000000000..52bd8966b3 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project-role/business.ts @@ -0,0 +1,121 @@ +import type { projectRoleContract } from '@cpn-console/shared' +import type { Project, ProjectRole } from '@prisma/client' +import { isSystemRoleType } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + deleteRole as deleteRoleQuery, + listMembers, + listRoles as listRolesQuery, + updateRole, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +const oidcRegexp = /^\/[^/]+/ + +export async function listRoles(projectId: Project['id']) { + const roles = await listRolesQuery(projectId) + return roles.map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ? role.oidcGroup.replace(oidcRegexp, '') : role.oidcGroup, + })) +} + +export async function patchRoles(projectId: Project['id'], roles: typeof projectRoleContract.patchProjectRoles.body._type) { + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } }) + if (!project) throw new NotFound404() + const dbRoles = await listRolesQuery(projectId) + const positionsAvailable: number[] = [] + + const updatedRoles: (Omit & { permissions: bigint })[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (matchingRole) { + if (isSystemRoleType(dbRole.type)) { + return new BadRequest400('Ce rôle système ne peut pas être modifié') + } + if (isSystemRoleType(matchingRole.type)) { + return new BadRequest400('Impossible de modifier un rôle en rôle système') + } + if (typeof matchingRole?.position !== 'undefined' && !positionsAvailable.includes(matchingRole?.position)) { + positionsAvailable.push(matchingRole.position) + } + if (matchingRole.oidcGroup && !matchingRole.oidcGroup.startsWith('/')) { + return new BadRequest400('oidcGroup doit commencer par /') + } + updatedRoles.push({ + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions: matchingRole.permissions ? BigInt(matchingRole.permissions) : dbRole.permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup: matchingRole.oidcGroup ? `/${project.slug}${matchingRole.oidcGroup}` : dbRole.oidcGroup, + type: matchingRole.type ?? dbRole.type, + projectId: dbRole.projectId, + }) + } + } + + if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes') + for (const { id, ...role } of updatedRoles) { + await updateRole(id, role) + await hook.projectRole.upsert(id) + } + + return listRoles(projectId) +} + +export async function createRole(projectId: Project['id'], role: typeof projectRoleContract.createProjectRole.body._type) { + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } }) + if (!project) throw new NotFound404() + if (isSystemRoleType(role.type)) { + throw new BadRequest400('Impossible de créer un rôle système') + } + const dbMaxPosRole = (await prisma.projectRole.findFirst({ + where: { projectId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + if (role.oidcGroup && !role.oidcGroup.startsWith('/')) { + throw new BadRequest400('oidcGroup doit commencer par /') + } + + const createdRole = await prisma.projectRole.create({ + data: { + ...role, + projectId, + position: dbMaxPosRole + 1, + permissions: BigInt(role.permissions), + oidcGroup: role.oidcGroup ? `/${project.slug}${role.oidcGroup}` : undefined, + }, + }) + + await hook.projectRole.upsert(createdRole.id) + + return listRoles(projectId) +} + +export async function countRolesMembers(projectId: Project['id']) { + const roles = await listRoles(projectId) + const members = await listMembers(projectId) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) // {role uuid: 0} + for (const { roleIds } of members) { + for (const roleId of roleIds) { + rolesCounts[roleId]++ + } + } + return rolesCounts +} + +export async function deleteRole(roleId: Project['id']) { + const role = await prisma.projectRole.findUnique({ where: { id: roleId }, select: { type: true } }) + if (!role) throw new NotFound404() + if (isSystemRoleType(role.type)) { + throw new BadRequest400('Ce rôle système ne peut pas être supprimé') + } + await hook.projectRole.delete(roleId) + await deleteRoleQuery(roleId) + return null +} diff --git a/.jjconflict-base-1/apps/server/src/resources/project-role/queries.ts b/.jjconflict-base-1/apps/server/src/resources/project-role/queries.ts new file mode 100644 index 0000000000..3e2aa4a0fa --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project-role/queries.ts @@ -0,0 +1,59 @@ +import type { + Prisma, + Project, + ProjectRole, +} from '@prisma/client' + +import prisma from '@/prisma.js' + +export const getRole = (id: ProjectRole['id']) => prisma.projectRole.findUnique({ where: { id } }) + +export const listRoles = (projectId: Project['id']) => prisma.projectRole.findMany({ where: { projectId }, orderBy: { position: 'asc' } }) + +export function createRole(data: Pick) { + return prisma.projectRole.create({ + data: { + name: data.name, + permissions: 0n, + position: data.position, + projectId: data.projectId, + oidcGroup: data.oidcGroup, + type: 'managed', + }, + }) +} + +export function updateRole(id: ProjectRole['id'], data: Pick) { + return prisma.projectRole.update({ + where: { id }, + data, + }) +} + +export async function deleteRole(id: ProjectRole['id']) { + const role = await prisma.projectRole.delete({ + where: { + id, + }, + }) + const attachedMembers = await prisma.projectMembers.findMany({ + where: { projectId: role.projectId, roleIds: { has: id } }, + }) + for (const member of attachedMembers) { + await prisma.projectMembers.update({ + where: { + projectId_userId: { + projectId: role.projectId, + userId: member.userId, + }, + }, + data: { + roleIds: { + set: member.roleIds.filter(roleId => roleId !== id), + }, + }, + }) + } +} + +export const getProjectRoleById = (id: ProjectRole['id']) => prisma.projectRole.findUnique({ where: { id }, include: { project: true } }) diff --git a/.jjconflict-base-1/apps/server/src/resources/project-role/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/project-role/router.spec.ts new file mode 100644 index 0000000000..dd42fbc7ad --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project-role/router.spec.ts @@ -0,0 +1,299 @@ +import { PROJECT_PERMS, projectRoleContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('./business.js') +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) + +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessCreateRoleMock = vi.spyOn(business, 'createRole') +const businessDeleteRoleMock = vi.spyOn(business, 'deleteRole') +const businessListRolesMock = vi.spyOn(business, 'listRoles') +const businessPatchRolesMock = vi.spyOn(business, 'patchRoles') + +describe('tests projectRoleContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const roleId = faker.string.uuid() + + describe('listProjectRoles', () => { + it('should return 403 for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessListRolesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectRoleContract.listProjectRoles.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectRoleContract.listProjectRoles.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createProjectRole', () => { + it('should create role for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('patchProjectRoles', () => { + it('should patch roles for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchRolesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchRolesMock.mockResolvedValue(new BadRequest400('une erreur')) + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('projectRoleMemberCounts', () => { + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectRoleContract.projectRoleMemberCounts.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteProjectRole', () => { + it('should delete role for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteRoleMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 404 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/project-role/router.ts b/.jjconflict-base-1/apps/server/src/resources/project-role/router.ts new file mode 100644 index 0000000000..f7aa2ef902 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project-role/router.ts @@ -0,0 +1,88 @@ +import { ProjectAuthorized, projectRoleContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + countRolesMembers, + createRole, + deleteRole, + listRoles, + patchRoles, +} from './business.js' + +export function projectRoleRouter() { + return serverInstance.router(projectRoleContract, { + // Récupérer des projets + listProjectRoles: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ListRoles(perms)) return new Forbidden403() + + const body = await listRoles(projectId) + + return { + status: 200, + body, + } + }, + + createProjectRole: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await createRole(projectId, body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 201, + body: resBody, + } + }, + + patchProjectRoles: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await patchRoles(projectId, body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + projectRoleMemberCounts: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ListRoles(perms)) return new Forbidden403() + + const resBody = await countRolesMembers(projectId) + + return { + status: 200, + body: resBody, + } + }, + + deleteProjectRole: async ({ request: req, params: { projectId, roleId } }) => { + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await deleteRole(roleId) + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/project-service/business.ts b/.jjconflict-base-1/apps/server/src/resources/project-service/business.ts new file mode 100644 index 0000000000..bce70064ef --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project-service/business.ts @@ -0,0 +1,103 @@ +import type { ZoneObject } from '@cpn-console/hooks' +import type { + PermissionTarget, + PluginsUpdateBody, + ServiceUrl, +} from '@cpn-console/shared' +import type { Project, ProjectPlugin } from '@prisma/client' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { + getAdminPlugin, + getProjectInfosByIdOrThrow, + getProjectStore, + getPublicClusters, + saveProjectStore, +} from '@/resources/queries-index.js' + +const logger = baseLogger.child({ scope: 'resource:project-service' }) + +export type ConfigRecords = { + key: string + pluginName: string + value: string | number | null +}[] + +export function dbToObj(records: Omit[]): PluginsUpdateBody { + const obj: PluginsUpdateBody = {} + for (const record of records) { + if (!obj[record.pluginName]) obj[record.pluginName] = {} + obj[record.pluginName][record.key] = record.value + } + return obj +} + +export function objToDb(obj: PluginsUpdateBody): ConfigRecords { + return Object.entries(obj) + .map(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) + .flat() +} + +export async function getProjectServices(projectId: Project['id'], permissionTarget: PermissionTarget) { + // Pré-requis + const project = await getProjectInfosByIdOrThrow(projectId) + + const [projectStore, globalConfig] = await Promise.all([ + getProjectStore(projectId), + getAdminPlugin(), + ]) + const store = dbToObj([...projectStore, ...globalConfig]) + + const publicClusters = await getPublicClusters() + project.clusters = [...project.clusters, ...publicClusters] + const zones: Map = new Map() // Pour dédoublonnage des zones + project.clusters.map(c => zones.set(c.zone.id, c.zone)) + + return Object.values(servicesInfos).map(({ name, title, to, imgSrc, description }) => { + let urls: ServiceUrl[] = [] + const toResponse = to + ? to({ + clusters: project.clusters, + zones: [...zones.values()], + environments: project.environments, + project, + store, + }) + : [] + if (Array.isArray(toResponse)) { + urls = toResponse.map(res => ({ name: res.title ?? '', description: res.description ?? '', to: res.to })) + } else if (typeof toResponse === 'string') { + urls = [{ to: toResponse, name: '' }] + } else if (toResponse) { + urls = [{ name: toResponse.title ?? '', to: toResponse.to }] + } + const manifest = populatePluginManifests({ + data: { + project: projectStore, + global: globalConfig, + }, + permissionTarget, + pluginName: name, + select: { + global: true, + project: true, + }, + }) + return { imgSrc, title, name, urls, manifest, description } + }).filter(s => s.urls.length || s.manifest.global?.length || s.manifest.project?.length) +} + +export async function updateProjectServices(projectId: Project['id'], data: PluginsUpdateBody, stripperRoles: Array<'user' | 'admin'>) { + const pluginKeysCount = Object.fromEntries( + Object.entries(data).map(([pluginName, values]) => [pluginName, Object.keys(values).length]), + ) + logger.info({ projectId, pluginCount: Object.keys(data).length, pluginKeysCount, stripperRoles }, 'Update project services started') + for (const role of stripperRoles) { + const parsedData = editStrippers.project[role].safeParse(data) + if (!parsedData.success) continue + await saveProjectStore(objToDb(parsedData.data), projectId) + } + logger.info({ projectId }, 'Update project services completed') + return null +} diff --git a/.jjconflict-base-1/apps/server/src/resources/project-service/queries.ts b/.jjconflict-base-1/apps/server/src/resources/project-service/queries.ts new file mode 100644 index 0000000000..cf353614b2 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project-service/queries.ts @@ -0,0 +1,54 @@ +import type { Project } from '@prisma/client' +import type { ConfigRecords } from './business.js' +import prisma from '@/prisma.js' + +// CONFIG +export function getProjectStore(projectId: Project['id']) { + return prisma.projectPlugin.findMany({ + where: { projectId }, + select: { + key: true, + pluginName: true, + value: true, + }, + }) +} + +export const getAdminPlugin = prisma.adminPlugin.findMany + +export async function saveProjectStore(records: ConfigRecords, projectId: Project['id']) { + for (const { pluginName, key, value } of records) { + if (value === null) { + await prisma.projectPlugin.delete({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } else { + await prisma.projectPlugin.upsert({ + create: { + pluginName, + projectId, + key, + value: value.toString(), + }, + update: { + key, + value: value.toString(), + pluginName, + }, + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } + } +} diff --git a/.jjconflict-base-1/apps/server/src/resources/project-service/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/project-service/router.spec.ts new file mode 100644 index 0000000000..20398a0d08 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project-service/router.spec.ts @@ -0,0 +1,160 @@ +import { ADMIN_PERMS, PROJECT_PERMS, projectServiceContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetServicesMock = vi.spyOn(business, 'getProjectServices') +const businessUpdateServicesMock = vi.spyOn(business, 'updateProjectServices') + +describe('projectServiceRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + + describe('getServices', () => { + it('should return services for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .query({ permissionTarget: 'user' }) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledWith(projectId, 'user') + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should not return admin services for non admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .query({ permissionTarget: 'admin' }) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return services for admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledWith(projectId, 'user') + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateProjectServices', () => { + const updateData = { serviceA: { param1: 'value' } } + + it('should update services for project manager', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateServicesMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(businessUpdateServicesMock).toHaveBeenCalledWith(projectId, updateData, ['user']) + expect(response.statusCode).toEqual(204) + }) + + it('should update services for project admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateServicesMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(businessUpdateServicesMock).toHaveBeenCalledWith(projectId, updateData, ['user', 'admin']) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/project-service/router.ts b/.jjconflict-base-1/apps/server/src/resources/project-service/router.ts new file mode 100644 index 0000000000..e5a1f07159 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project-service/router.ts @@ -0,0 +1,38 @@ +import { AdminAuthorized, ProjectAuthorized, projectServiceContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getProjectServices, updateProjectServices } from './business.js' + +export function projectServiceRouter() { + return serverInstance.router(projectServiceContract, { + // Récupérer les services d'un projet + getServices: async ({ request: req, params: { projectId }, query }) => { + const perms = await authUser(req, { id: projectId }) + if (!perms.projectPermissions && !ProjectAuthorized.Manage(perms)) return new Forbidden403() + if (query.permissionTarget === 'admin' && !AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403('Vous ne pouvez pas demander les paramètres admin') + + const body = await getProjectServices(projectId, query.permissionTarget) + + return { + status: 200, + body, + } + }, + + updateProjectServices: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.Manage(perms) && !perms.projectPermissions) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + + const allowedRoles: Array<'user' | 'admin'> = AdminAuthorized.Manage(perms.adminPermissions) ? ['user', 'admin'] : ['user'] + + const resBody = await updateProjectServices(projectId, body, allowedRoles) + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/project/business.spec.ts b/.jjconflict-base-1/apps/server/src/resources/project/business.spec.ts new file mode 100644 index 0000000000..a9f6c4fa62 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project/business.spec.ts @@ -0,0 +1,365 @@ +import type { Cluster, Project, ProjectMembers, ProjectRole, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { + BadRequest400, + ErrorResType, + Unprocessable422, +} from '../../utils/errors.js' +import { dbToObj } from '../project-service/business.ts' +import * as userBusiness from '../user/business.js' +import { archiveProject, chunk, createProject, generateProjectsData, generateSlug, getProjectSecrets, listProjects, replayHooks, updateProject } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const logViaSessionMock = vi.spyOn(userBusiness, 'logViaSession') + +const projectId = faker.string.uuid() + +const user: User = { + id: faker.string.uuid(), + createdAt: new Date(), + updatedAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + lastLogin: null, +} +const project: Project & { + clusters: Pick[] + members: ProjectMembers[] + roles: ProjectRole[] + owner: User +} = { + createdAt: new Date(), + updatedAt: new Date(), + description: '', + everyonePerms: 649n, + id: faker.string.uuid(), + locked: false, + name: faker.string.alphanumeric(8), + status: 'created', + ownerId: faker.string.uuid(), + clusters: [], + roles: [], + members: [], +} +const reqId = faker.string.uuid() +describe('test project business utils', () => { + it('should transform arrow ', async () => { + const result = dbToObj([{ key: 'test', pluginName: 'test', value: 'test' }]) + expect(result).toEqual({ test: { test: 'test' } }) + }) +}) + +describe('test project business logic', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listProjects', () => { + it('should return stringified perms', async () => { + prisma.project.findMany.mockResolvedValue([{ everyonePerms: 5n, clusters: [], roles: [{ permissions: 28n }] }]) + const response = await listProjects({}, user.id) + expect(response[0].everyonePerms).toBe('5') + expect(response[0].roles[0].permissions).toBe('28') + }) + }) + describe('getProjectSecrets', () => { + const getResultsHook = { + failed: false, + args: {}, + results: { + registry: { + secrets: { + token: 'myToken', + }, + status: { + failed: false, + }, + }, + }, + } + it('should return transform secret', async () => { + hook.project.getSecrets.mockResolvedValue(getResultsHook) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + + // according to src/utils/mocks.ts + expect(JSON.stringify(response)).toContain('myToken') + }) + + it('should return projects secrets', async () => { + hook.project.getSecrets.mockResolvedValue(getResultsHook) + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + prisma.project.findMany.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + // according to src/utils/mocks.ts + expect(JSON.stringify(response)).toContain('myToken') + }) + + it('should return hook error', async () => { + hook.project.getSecrets.mockResolvedValue({ failed: true }) + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + prisma.project.findMany.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + // according to src/utils/mocks.ts + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('createProject', () => { + it('should create project', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.create.mockResolvedValue({ ...project, status: 'initializing' }) + prisma.project.findFirst.mockResolvedValue(undefined) + prisma.project.findMany.mockResolvedValue([]) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const projectRes = await createProject(project, user, reqId) + + expect(projectRes.name).toEqual(project.name) + expect(prisma.project.create).toHaveBeenCalledTimes(1) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should return plugins failed', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.create.mockResolvedValue({ ...project, status: 'initializing' }) + prisma.project.findFirst.mockResolvedValue(undefined) + prisma.project.findMany.mockResolvedValue([]) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const response = await createProject(project, user, reqId) + + expect(prisma.project.create).toHaveBeenCalledTimes(1) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + describe('updateProject', () => { + const updatedProjet = { + description: faker.lorem.lines(2), + everyonePerms: '5', + } + const reqId = faker.string.uuid() + const members: ProjectMembers[] = [{ userId: faker.string.uuid(), projectId: project.id, roleIds: [], user: { type: 'human' } }] + it('should update project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + await updateProject({ ...updatedProjet, ownerId: members[0].userId }, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(2) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should update nothing', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + await updateProject({ }, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(0) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should not update if project archived', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, status: 'archived' }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const response = await updateProject({ }, project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should not update project, cause missing member', async () => { + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members: [] }) + + const response = await updateProject({ ownerId: members[0].userId }, project.id, user, reqId) + + expect(prisma.project.findUniqueOrThrow).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(BadRequest400) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + expect(prisma.log.update).toHaveBeenCalledTimes(0) + }) + + it('should return plugins failed', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const response = await updateProject(updatedProjet, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + describe('replayHooks', () => { + const reqId = faker.string.uuid() + + it('should replay hooks', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + await replayHooks(project.id, user, reqId) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should not replay hooks on archived project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'archived' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should not replay hooks on locked project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: true, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should update nothing and return error', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: true } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('archiveProject', () => { + it('should archive project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeNull() + expect(prisma.project.update).toHaveBeenLastCalledWith({ + where: { id: project.id }, + data: { + clusters: { set: [] }, + }, + }) + }) + + it('should not archive a project already archived', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false, status: 'archived' }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + }) + + it('should not archive a project locked', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: true, status: 'created' }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + }) + + it('should return hook fail', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false }) + hook.project.delete.mockResolvedValue({ results: { failed: true }, project: Promise.resolve({ status: 'failed' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('generateProjectsData', () => { + it('shoud return string, very bad test ...', async () => { + prisma.project.findMany.mockResolvedValue([{ name: 'test' }]) + const response = await generateProjectsData() + expect(response).toBeTypeOf('string') + }) + }) +}) + +describe('chunk function', () => { + it('should return 5 elements', () => { + const letters = ['A', 'B', 'C', 'D', 'E'] + expect(chunk(letters, 5)).toEqual([letters]) + }) + it('should return 3,2 elements', () => { + const letters = ['A', 'B', 'C', 'D', 'E'] + expect(chunk(letters, 3)).toEqual([['A', 'B', 'C'], ['D', 'E']]) + }) + it('should return 4 elements', () => { + const letters = ['A', 'B', 'C', 'D'] + expect(chunk(letters, 5)).toEqual([letters]) + }) +}) + +const slugRegexp = /-[01]$/ +const slugFourRegexp = /-4$/ +const slugEmptySpaceRegexp = /-2$/ + +describe('generateSlug', () => { + it('should return prefix, no array', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix) + expect(generated).toEqual(prefix) + }) + it('should return prefix, empty array', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, []) + expect(generated).toEqual(prefix) + }) + it('should return prefix, no match', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [faker.string.alphanumeric(5), faker.string.alphanumeric(5)]) + expect(generated).toEqual(prefix) + }) + it('should return generated slug at 1 or 0, all matchs', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix]) + expect(generated).match(slugRegexp) + }) + it('should return generated slug at 4, all matchs', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix, `${prefix}-0`, `${prefix}-1`, `${prefix}-2`, `${prefix}-3`]) + expect(generated).match(slugFourRegexp) + }) + it('should fill empty space', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix, `${prefix}-0`, `${prefix}-1`, `${prefix}-3`]) + expect(generated).match(slugEmptySpaceRegexp) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/project/business.ts b/.jjconflict-base-1/apps/server/src/resources/project/business.ts new file mode 100644 index 0000000000..b27f16bf64 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project/business.ts @@ -0,0 +1,306 @@ +import type { projectContract } from '@cpn-console/shared' +import type { Project, User } from '@prisma/client' +import type { UserDetails } from '@/types/index.js' +import type { ErrorResType } from '@/utils/errors.js' +import { servicesInfos } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { ProjectStatusSchema } from '@cpn-console/shared' +import { json2csv } from 'json-2-csv' +import prisma from '@/prisma.js' +import { + addLogs, + deleteAllEnvironmentForProject, + deleteAllRepositoryForProject, + getAllProjectsDataForExport, + getProjectOrThrow, + getSlugs, + initializeProject, + listProjects as listProjectsQuery, + lockProject, + updateProject as updateProjectQuery, +} from '@/resources/queries-index.js' +import { whereBuilder } from '@/utils/controller.js' +import { parallelBulkLimit } from '@/utils/env.js' +import { BadRequest400, Forbidden403, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +const logger = baseLogger.child({ scope: 'resource:project' }) + +export function generateSlug(prefix: string, existingSlugs?: string[]) { + if (!existingSlugs?.includes(prefix)) { + return prefix + } + let idx = 1 + let generated = `${prefix}-${idx}` + while (existingSlugs.includes(generated)) { + idx++ + generated = `${prefix}-${idx}` + } + return generated +} + +const projectStatus = ProjectStatusSchema._def.values +export async function listProjects({ status, statusIn, statusNotIn, filter = 'member', ...query }: typeof projectContract.listProjects.query._type, userId: User['id'] | undefined) { + return listProjectsQuery({ + ...query, + status: whereBuilder({ enumValues: projectStatus, eqValue: status, inValues: statusIn, notInValues: statusNotIn }), + filter, + userId, + }).then(projects => projects.map(({ clusters, ...project }) => ({ + ...project, + clusterIds: clusters.map(({ id }) => id), + roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + everyonePerms: project.everyonePerms.toString(), + }))) +} + +export async function getProjectSecrets(projectId: string) { + const hookReply = await hook.project.getSecrets(projectId) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la récupération des secrets du projet') + } + + return Object.fromEntries( + Object.entries(hookReply.results) + // @ts-ignore + .filter(([_key, value]) => Object.keys(value.secrets).length) + // @ts-ignore + .map(([key, value]) => [servicesInfos[key]?.title, value.secrets]), + ) +} + +export async function createProject(dataDto: typeof projectContract.createProject.body._type, requestor: UserDetails, requestId: string) { + if (requestor.type !== 'human') return new BadRequest400('Seuls les comptes humains peuvent créer des projets') + + let slug = dataDto.name + logger.info({ requestId, userId: requestor.id, slugPrefix: slug }, 'Create project started') + const projectsWithSamePrefix = await getSlugs(slug) + slug = generateSlug(slug, projectsWithSamePrefix?.map(project => project.slug)) + + // Actions + const project = await initializeProject({ ...dataDto, slug, ownerId: requestor.id }) + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Project initialized') + + const { results, project: projectInfos } = await hook.project.upsert(project.id) + await addLogs({ action: 'Create Project', data: results, userId: requestor.id, requestId, projectId: project.id }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project failed during upsert hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project upsert hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services à la création du projet') + } + + for (const role of projectInfos.roles) { + const roleResult = await hook.projectRole.upsert(role.id) + await addLogs({ action: 'Upsert Project Role', data: roleResult.results, userId: requestor.id, requestId, projectId: project.id }) + } + + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project completed') + return { + ...projectInfos, + clusterIds: projectInfos.clusters.map(({ id }) => id), + everyonePerms: projectInfos.everyonePerms.toString(), + roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + } +} + +export async function getProject(projectId: Project['id']) { + return getProjectOrThrow(projectId).then(({ clusters, ...project }) => ({ + ...project, + clusterIds: clusters.map(({ id }) => id), + roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + everyonePerms: project.everyonePerms.toString(), + })) +} + +export async function updateProject( + { description, ownerId: ownerIdCandidate, everyonePerms, locked, ...data }: typeof projectContract.updateProject.body._type, + projectId: Project['id'], + requestor: UserDetails, + requestId: string, +) { + const changedFields = Object.entries({ + ...data, + description, + locked, + ownerId: ownerIdCandidate, + everyonePerms, + }).filter(([, value]) => typeof value !== 'undefined').map(([key]) => key) + logger.info({ requestId, userId: requestor.id, projectId, changedFields }, 'Update project started') + + // Actions + const projectDb = await prisma.project.findUniqueOrThrow({ + where: { id: projectId }, + include: { members: { include: { user: true } } }, + }) + + if (projectDb.status === 'archived') return new Forbidden403('Le projet est archivé') + + if (ownerIdCandidate && ownerIdCandidate !== projectDb.ownerId) { + const memberCandidate = projectDb.members.find(member => member.userId === ownerIdCandidate) + if (!memberCandidate) { + return new BadRequest400('Le nouveau propriétaire doit faire partie des membres actuels du projet') + } + if (memberCandidate.user.type !== 'human') return new BadRequest400('Seuls les comptes humains peuvent être propriétaire de projets') + if (!projectDb.members.some(member => member.userId === projectDb.ownerId)) { + await prisma.projectMembers.create({ + data: { userId: projectDb.ownerId, projectId }, + }) + } + await prisma.$transaction([ + prisma.projectMembers.delete({ + where: { projectId_userId: { userId: ownerIdCandidate, projectId } }, + }), + prisma.project.update({ where: { id: projectId }, data: { ownerId: ownerIdCandidate } }), + ]) + } + + if (typeof description !== 'undefined' || typeof everyonePerms !== 'undefined' || typeof locked !== 'undefined') { + await updateProjectQuery(projectId, { + description, + locked, + ...everyonePerms && { everyonePerms: BigInt(everyonePerms) }, + ...data, + }) + } + + const { results, project: projectInfos } = await hook.project.upsert(projectId) + await addLogs({ action: 'Update Project', data: results, userId: requestor.id, requestId, projectId: projectInfos.id }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId }, 'Update project failed during upsert hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId }, 'Update project upsert hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services à la mise à jour du projet') + } + + logger.info({ requestId, userId: requestor.id, projectId }, 'Update project completed') + return { + ...projectInfos, + clusterIds: projectInfos.clusters.map(({ id }) => id), + everyonePerms: projectInfos.everyonePerms.toString(), + roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${projectInfos.slug}`, '') : role.oidcGroup })), + } +} + +interface ReplayHooksArgs { + projectId: Project['id'] + userId?: User['id'] + requestId: string +} +export async function replayHooks({ projectId, userId, requestId }: ReplayHooksArgs): Promise { + logger.info({ requestId, userId, projectId }, 'Replay project hooks started') + const projectDb = await prisma.project.findUniqueOrThrow({ + where: { id: projectId }, + include: { members: { include: { user: true } } }, + }) + if (projectDb.locked) return new Forbidden403('Le projet est verrouillé') + if (projectDb.status === 'archived') return new Forbidden403('Le projet est archivé') + // Actions + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Replay hooks for Project', data: results, userId, requestId, projectId }) + if (results.failed) { + logger.error({ requestId, userId, projectId }, 'Replay project hooks failed') + } else { + logger.info({ requestId, userId, projectId }, 'Replay project hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services au reprovisionnement du projet') + } + return null +} + +export async function archiveProject(projectId: Project['id'], requestor: UserDetails, requestId: string): Promise { + logger.info({ requestId, userId: requestor.id, projectId }, 'Archive project started') + // Actions + // Fetch project state before hook so plugins see repos and environments + const projectDb = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, include: { members: { include: { user: true } } } }) + + if (projectDb.locked) return new Forbidden403('Le projet est verrouillé') + if (projectDb.status === 'archived') return new BadRequest400('Le projet est archivé') + if (projectDb.locked) { + await lockProject(projectId) + } + + // -- début - Suppression projet -- + const { results, project } = await hook.project.delete(projectId) + await addLogs({ action: 'Delete all project resources', data: results, userId: requestor.id, requestId, projectId }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId }, 'Archive project failed during delete hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId, status: project.status }, 'Archive project delete hooks completed') + } + if (project.status !== 'archived' && !projectDb.locked) { + await prisma.project.update({ where: { id: projectId }, data: { locked: false } }) + } + if (results.failed) { + return new Unprocessable422('Echec des services à la suppression du projet') + } + + // Empty the project after successful hooks so plugins still have access to repos and environments + await Promise.all([ + deleteAllRepositoryForProject(projectId), + deleteAllEnvironmentForProject(projectId), + ]) + + // Retrait clusters -- + await prisma.project.update({ + where: { id: projectId }, + data: { + clusters: { set: [] }, + }, + }) + + // -- fin - Suppression projet -- + logger.info({ requestId, userId: requestor.id, projectId }, 'Archive project completed') + return null +} + +export async function generateProjectsData() { + const projects = await getAllProjectsDataForExport() + + return json2csv(projects, { + emptyFieldValue: '', + }) +} + +export async function bulkActionProject(data: typeof projectContract.bulkActionProject.body._type, requestor: UserDetails, requestId: string) { + if (data.projectIds === 'all') { + data.projectIds = (await prisma.project.findMany({ + select: { id: true }, + where: { status: { not: 'archived' } }, + })).map(({ id }) => id) + } + bulkExector(data.projectIds + .map((projectId) => { + if (data.action === 'archive') { + return () => archiveProject(projectId, requestor, requestId) + } + if (data.action === 'lock') { + return () => updateProject({ locked: true }, projectId, requestor, requestId) + } + if (data.action === 'unlock') { + return () => updateProject({ locked: false }, projectId, requestor, requestId) + } + if (data.action === 'replay') { + return () => replayHooks({ projectId, userId: requestor.id, requestId }) + } + // should never been called + return async () => {} + })) +} + +export function chunk(arr: T[], size: number): T[][] { + return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => + arr.slice(i * size, i * size + size)) +} + +async function bulkExector(toExecute: Array<() => Promise>) { + const toExecuteChunked = chunk(toExecute, parallelBulkLimit) + for (const chunkToExecute of toExecuteChunked) { + await Promise.allSettled(chunkToExecute.map(fn => fn())) + } +} diff --git a/.jjconflict-base-1/apps/server/src/resources/project/queries.ts b/.jjconflict-base-1/apps/server/src/resources/project/queries.ts new file mode 100644 index 0000000000..5957b15478 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project/queries.ts @@ -0,0 +1,374 @@ +import type { projectContract, XOR } from '@cpn-console/shared' +import type { + Prisma, + Project, + User, +} from '@prisma/client' +import { PROJECT_PERMS } from '@cpn-console/shared' +import { + ProjectStatus, +} from '@prisma/client' +import prisma from '@/prisma.js' +import { appVersion } from '@/utils/env.js' +import { uuid } from '@/utils/queries-tools.js' + +type ProjectUpdate = Partial> +export function updateProject(id: Project['id'], data: ProjectUpdate) { + return prisma.project.update({ + where: { id }, + data, + include: { members: true }, + }) +} + +// SELECT +type FilterWhere = XOR<{ + userId?: User['id'] + filter: 'all' +}, { + userId: User['id'] | undefined + filter: 'owned' | 'member' +}> +type ListProjectWhere = Omit<(typeof projectContract.listProjects.query._type), 'status_in' | 'status_not_in' | 'status'> + & Pick + & FilterWhere +export async function listProjects({ + description, + locked, + name, + status, + id, + filter, + userId, + search, + lastSuccessProvisionningVersion, +}: ListProjectWhere) { + const whereAnd: Prisma.ProjectWhereInput[] = [] + if (id) whereAnd.push({ id }) + if (locked != null) whereAnd.push({ locked }) + if (name) whereAnd.push({ name }) + if (status) whereAnd.push({ status }) + if (description) whereAnd.push({ description: { contains: description } }) + if (lastSuccessProvisionningVersion) { + if (lastSuccessProvisionningVersion === 'outdated') whereAnd.push({ lastSuccessProvisionningVersion: { not: appVersion } }) + else if (lastSuccessProvisionningVersion === 'last') whereAnd.push({ lastSuccessProvisionningVersion: { equals: appVersion } }) + else whereAnd.push({ lastSuccessProvisionningVersion }) + } + if (search) { + whereAnd.push({ OR: [{ + name: { contains: search }, + }, { + owner: { email: { contains: search } }, + }] }) + } + + if (filter === 'owned') { + whereAnd.push({ ownerId: userId }) + } else if (filter === 'member') { + whereAnd.push({ OR: [{ + members: { some: { userId } }, + }, { + ownerId: userId, + }] }) + } + + return prisma.project.findMany({ + where: { AND: whereAnd }, + include: { + clusters: { select: { id: true } }, + members: { include: { user: true } }, + roles: true, + owner: true, + }, + }) +} + +export function getProjectOrThrow(id: Project['id'] | Project['slug']) { + return prisma.project.findFirstOrThrow({ + where: uuid.test(id) + ? { id } + : { slug: id }, + include: { + clusters: { select: { id: true } }, + members: { include: { user: true } }, + roles: true, + owner: true, + }, + }) +} + +export function getProjectInfosByIdOrThrow(projectId: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { + id: projectId, + }, + include: { + environments: true, + clusters: { include: { zone: true } }, + }, + }) +} + +export function getProjectMembers(projectId: Project['id']) { + return prisma.projectMembers.findMany({ + where: { + projectId, + }, + include: { user: true }, + }) +} + +export function getProjectById(id: Project['id']) { + return prisma.project.findUnique({ where: { id } }) +} + +export const baseProjectIncludes = { + members: { include: { user: true } }, + clusters: true, + roles: true, + owner: true, +} as const + +export function getProjectInfos(id: Project['id']) { + return prisma.project.findUnique({ + where: { id }, + include: baseProjectIncludes, + }) +} + +export function getProjectInfosOrThrow(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: baseProjectIncludes, + }) +} + +export function getProjectInfosAndRepos(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: { + ...baseProjectIncludes, + repositories: true, + }, + }) +} + +export function getSlugs(slugPrefix: string) { + return prisma.project.findMany({ + where: { + slug: { startsWith: slugPrefix }, + }, + }) +} + +export function getAllProjectsDataForExport() { + return prisma.project.findMany({ + select: { + name: true, + description: true, + createdAt: true, + updatedAt: true, + environments: { + select: { + name: true, + stage: true, + cluster: { + select: { label: true }, + }, + }, + }, + owner: true, + }, + }) +} + +export function getRolesByProjectId(projectId: Project['id']) { + return prisma.projectRole.findMany({ + where: { projectId }, + }) +} + +const clusterInfosSelect = { + id: true, + infos: true, + label: true, + privacy: true, + secretName: true, + kubeconfig: true, + clusterResources: true, + cpu: true, + gpu: true, + memory: true, + zone: { + select: { + id: true, + slug: true, + argocdUrl: true, + label: true, + }, + }, +} +export function getHookProjectInfos(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: { + members: { include: { user: true }, where: { user: { type: 'human' } } }, + clusters: { select: clusterInfosSelect }, + environments: { + include: { + stage: true, + cluster: { + select: clusterInfosSelect, + }, + }, + }, + repositories: true, + plugins: { + select: { + key: true, + pluginName: true, + value: true, + }, + }, + owner: true, + roles: true, + }, + }) +} + +// CREATE +interface CreateProjectParams { + name: Project['name'] + description?: Project['description'] + ownerId: User['id'] + slug: Project['slug'] + limitless: boolean + hprodCpu: number + hprodGpu: number + hprodMemory: number + prodCpu: number + prodGpu: number + prodMemory: number +} + +export function initializeProject(params: CreateProjectParams) { + return prisma.project.create({ + data: { + description: params.description ?? '', + status: ProjectStatus.created, + locked: false, + roles: { + create: [ + { + name: 'Administrateur', + permissions: PROJECT_PERMS.MANAGE, + position: 0, + oidcGroup: `/${params.slug}/console/admin`, + type: 'system:managed', + }, + { + name: 'DevOps', + permissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 1, + oidcGroup: `/${params.slug}/console/devops`, + type: 'system:managed', + }, + { + name: 'Développeur', + permissions: PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 2, + oidcGroup: `/${params.slug}/console/developer`, + type: 'system:managed', + }, + { + name: 'Sécurité', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 3, + oidcGroup: `/${params.slug}/console/security`, + type: 'system:managed', + }, + { + name: 'Lecture seule', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 4, + oidcGroup: `/${params.slug}/console/readonly`, + type: 'system:managed', + }, + ], + }, + ...params, + }, + }) +} + +// UPDATE +export function lockProject(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { locked: true }, + }) +} + +export function updateProjectCreated(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { + status: ProjectStatus.created, + lastSuccessProvisionningVersion: appVersion, + }, + include: baseProjectIncludes, + }) +} + +export function updateProjectFailed(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { status: ProjectStatus.failed }, + include: baseProjectIncludes, + }) +} + +export function updateProjectWarning(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { status: ProjectStatus.warning }, + include: baseProjectIncludes, + }) +} + +export function addUserToProject({ project, user }: { project: Project, user: User }) { + return prisma.projectMembers.create({ + data: { + userId: user.id, + projectId: project.id, + }, + }) +} + +export function removeUserFromProject({ projectId, userId }: { projectId: Project['id'], userId: User['id'] }) { + return prisma.projectMembers.delete({ + where: { + projectId_userId: { + projectId, + userId, + }, + }, + }) +} + +export async function archiveProject(id: Project['id']) { + const project = await prisma.project.findUnique({ + where: { id }, + select: { name: true, slug: true }, + }) + return prisma.project.update({ + where: { id }, + data: { + name: `${project?.name}_${Date.now()}_archived`, + slug: `${project?.slug}_${Date.now()}_archived`, + status: ProjectStatus.archived, + locked: true, + }, + include: baseProjectIncludes, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/project/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/project/router.spec.ts new file mode 100644 index 0000000000..8c5a2ed820 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project/router.spec.ts @@ -0,0 +1,473 @@ +import type { ProjectV2 } from '@cpn-console/shared' +import type { UserDetails } from '../../types/index.js' +import { ADMIN_PERMS, PROJECT_PERMS, projectContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getRandomRequestor, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listProjects') +const businessCreateMock = vi.spyOn(business, 'createProject') +const businessUpdateMock = vi.spyOn(business, 'updateProject') +const businessDeleteMock = vi.spyOn(business, 'archiveProject') +const businessSyncMock = vi.spyOn(business, 'replayHooks') +const bulkActionProjectMock = vi.spyOn(business, 'bulkActionProject') +const businessGetSecretsMock = vi.spyOn(business, 'getProjectSecrets') +const businessGenerateDataMock = vi.spyOn(business, 'generateProjectsData') + +describe('test projectContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + const projectOwner: ProjectV2['owner'] = { + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + id: faker.string.uuid(), + type: 'human', + } + const projectId = faker.string.uuid() + const project: Omit = { + name: faker.string.alpha({ length: 10, casing: 'lower' }), + slug: faker.string.alpha({ length: 5, casing: 'lower' }), + description: faker.string.alpha({ length: 5 }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + clusterIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + locked: false, + status: 'created', + everyonePerms: '0', + members: [], + owner: projectOwner, + ownerId: projectOwner.id, + roles: [], + lastSuccessProvisionningVersion: null, + } + describe('check unauthorized user on project behaviour', () => { + // UPDATE + it('on Update', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + it('on Update without enough perms', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // REPLAY + it('on replay', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(businessSyncMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // SECRETS + it('on see secret', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(businessGetSecretsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // ARCHIVE + it('on archive', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', projectId)) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + }) + describe('listProjects', () => { + it('should return list of projects', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + const projects: any[] = [] + businessListMock.mockResolvedValueOnce(projects) + const response = await app.inject() + .get(projectContract.listProjects.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(projects) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin with "all" filter', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + const projects: any[] = [] + businessListMock.mockResolvedValueOnce(projects) + + const response = await app.inject() + .get(projectContract.listProjects.path) + .query({ filter: 'all' }) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return list of projects for admin', async () => { + const projects: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(projects) + + const response = await app.inject() + .get(projectContract.listProjects.path) + .query({ filter: 'all' }) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createProject', () => { + it('should create and return project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce({ id: projectId, ...project }) + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: projectId, ...project }) + expect(response.statusCode).toEqual(201) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateProject', () => { + const projectUpdated: Partial = { description: faker.string.alpha({ length: 5 }) } + + it('should update and return project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should not update ownerId if not permitted', async () => { + const userDetails = getRandomRequestor() + const projectPerms = getProjectMockInfos({ projectOwnerId: faker.string.uuid(), projectPermissions: PROJECT_PERMS.MANAGE }) + const projectUpdated = { ownerId: faker.string.uuid(), description: faker.lorem.words() } + const user = getUserMockInfos(0n, userDetails as UserDetails, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith({ description: projectUpdated.description }, projectId, user.user, expect.any(String)) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should update ownerId and return project', async () => { + const requestor = getRandomRequestor() + const projectPerms = getProjectMockInfos({ projectOwnerId: requestor.id, projectPermissions: PROJECT_PERMS.MANAGE }) + const projectUpdated = { ownerId: faker.string.uuid(), description: faker.lorem.words() } + const user = getUserMockInfos(0n, requestor as UserDetails, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith(projectUpdated, projectId, user.user, expect.any(String)) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(400) + }) + }) + + describe('archiveProject', () => { + it('should archive project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return projects data for admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getProjectSecrets', () => { + it('should return project secrets for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const secrets = {} + businessGetSecretsMock.mockResolvedValueOnce(secrets) + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(businessGetSecretsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(secrets) + expect(response.statusCode).toEqual(200) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetSecretsMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized access to secrets', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('replayHooksForProject', () => { + it('should replay hooks for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(businessSyncMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized access to replay hooks', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getProjectsData', () => { + it('should return projects data for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + const data = '' + businessGenerateDataMock.mockResolvedValueOnce(data) + const response = await app.inject() + .get(projectContract.getProjectsData.path) + .end() + + expect(businessGenerateDataMock).toHaveBeenCalledTimes(1) + expect(response.body).toEqual(data) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin user', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectsData.path) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('bulkActionProject', () => { + it('should executebulk for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + const response = await app.inject() + .post(projectContract.bulkActionProject.path) + .body({ action: 'lock', projectIds: [projectId] }) + .end() + + expect(response.json()).toBeNull() + expect(bulkActionProjectMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(202) + }) + + it('should return 403 for unauthorized access to bulk update', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const response = await app.inject() + .post(projectContract.bulkActionProject.path) + .body({ action: 'lock', projectIds: [projectId] }) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/project/router.ts b/.jjconflict-base-1/apps/server/src/resources/project/router.ts new file mode 100644 index 0000000000..b969303ed2 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/project/router.ts @@ -0,0 +1,186 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, ProjectAuthorized, projectContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, NotFound404, Unauthorized401 } from '@/utils/errors.js' +import { + archiveProject, + bulkActionProject, + createProject, + generateProjectsData, + getProject, + getProjectSecrets, + listProjects, + replayHooks, + updateProject, +} from './business.js' + +export function projectRouter() { + return serverInstance.router(projectContract, { + + // Récupérer des projets + listProjects: async ({ request: req, query }) => { + const { adminPermissions, user } = await authUser(req) + let body: AsyncReturnType = [] + + if (adminPermissions && !user) { // c'est donc un compte de service + query.filter = 'all' + } + if (query.filter === 'all' && !AdminAuthorized.Manage(adminPermissions)) { + return new Forbidden403('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + + body = await listProjects( + query, + user?.id, + ) + + return { + status: 200, + body, + } + }, + + // Récupérer les secrets d'un projet + getProjectSecrets: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.SeeSecrets(perms)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await getProjectSecrets(projectId) + + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Créer un projet + createProject: async ({ request: req, body: data }) => { + const perms = await authUser(req) + if (perms.user?.type !== 'human') return new Unauthorized401('Cannot find requestor in database') + if (!AdminAuthorized.ManageProjects(perms.adminPermissions)) return new Forbidden403() + + const body = await createProject(data, perms.user, req.id) + + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Récuperer un seul projet + getProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.projectId) return new NotFound404() + if (!perms.projectPermissions && !AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new NotFound404() + + const body = await getProject(projectId) + + return { + status: 200, + body, + } + }, + + // Mettre à jour un projet + updateProject: async ({ request: req, params, body: data }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!ProjectAuthorized.Manage(perms)) return new Forbidden403() + + const isAdmin = AdminAuthorized.Manage(perms.adminPermissions) + const isOwner = perms.projectOwnerId === perms.user.id + if (!isAdmin) { + delete data.locked + if (!isOwner) delete data.ownerId // impossible de toucher à cette clé + } + if (perms.projectLocked) { + if (!isAdmin) return new Forbidden403('Le projet est verrouillé') + if (data.locked !== false) return new Forbidden403('Veuillez déverrouiler le projet pour le mettre à jour') + } + + const body = await updateProject(data, projectId, perms.user, req.id) + + if (body instanceof ErrorResType) return body + return { + status: 200, + body, + } + }, + + // Reprovisionner un projet + replayHooksForProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ReplayHooks(perms)) return new Forbidden403() + + const body = await replayHooks({ + projectId, + userId: perms.user?.id, + requestId: req.id, + }) + + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + + // Archiver un projet + archiveProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!ProjectAuthorized.Manage(perms)) return new Forbidden403() + + const body = await archiveProject(projectId, perms.user, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + // Récupérer les données de tous les projets pour export + getProjectsData: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + const body = await generateProjectsData() + + return { + status: 200, + body, + } + }, + + bulkActionProject: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + + await bulkActionProject(body, perms.user, req.id) + + return { + status: 202, + body: null, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/queries-index.ts b/.jjconflict-base-1/apps/server/src/resources/queries-index.ts new file mode 100644 index 0000000000..a2c9787d2a --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/queries-index.ts @@ -0,0 +1,14 @@ +export * from '@/resources/admin-role/queries.js' +export * from '@/resources/cluster/queries.js' +export * from '@/resources/environment/queries.js' +export * from '@/resources/log/queries.js' +export * from '@/resources/project-member/queries.js' +export * from '@/resources/project-role/queries.js' +export * from '@/resources/project-service/queries.js' +export * from '@/resources/project/queries.js' +export * from '@/resources/repository/queries.js' +export * from '@/resources/service-chain/queries.js' +export * from '@/resources/stage/queries.js' +export * from '@/resources/system/settings/queries.js' +export * from '@/resources/user/queries.js' +export * from '@/resources/zone/queries.js' diff --git a/.jjconflict-base-1/apps/server/src/resources/repository/business.ts b/.jjconflict-base-1/apps/server/src/resources/repository/business.ts new file mode 100644 index 0000000000..473c9c73a7 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/repository/business.ts @@ -0,0 +1,115 @@ +import type { CreateRepositoryBody, UpdateRepositoryBody } from '@cpn-console/shared' +import type { Project, Repository, User } from '@prisma/client' +import { addLogs, deleteRepository as deleteRepositoryQuery, getProjectInfosAndRepos, getProjectRepositories as getProjectRepositoriesQuery, initializeRepository, updateRepository as updateRepositoryQuery } from '@/resources/queries-index.js' +import { BadRequest400, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function getProjectRepositories(projectId: Project['id']) { + return getProjectRepositoriesQuery(projectId) +} + +export async function syncRepository({ + repositoryId, + userId, + syncAllBranches, + branchName, + requestId, +}: { + repositoryId: Repository['id'] + userId: User['id'] + syncAllBranches: boolean + branchName?: string + requestId: string +}) { + const hookReply = await hook.misc.syncRepository(repositoryId, { syncAllBranches, branchName }) + await addLogs({ action: 'Sync Repository', data: hookReply, userId, requestId, projectId: hookReply.args.id }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la synchronisation du dépôt') + } + return null +} + +export async function createRepository({ + data, + userId, + requestId, +}: { + data: CreateRepositoryBody + userId: User['id'] + requestId: string +}) { + const project = await getProjectInfosAndRepos(data.projectId) + + if (project.repositories?.find(repo => repo.internalRepoName === data.internalRepoName)) return new BadRequest400(`Le nom du dépôt interne ${data.internalRepoName} existe déjà en base pour ce projet`) + const dbData = { ...data, isInfra: !!data.isInfra, isPrivate: !!data.isPrivate } + delete dbData.externalToken + + const repo = await initializeRepository(dbData) + const { results } = await hook.project.upsert(project.id, data.isPrivate + ? { + [repo.internalRepoName]: { + token: data.externalToken ?? '', + username: data.externalUserName ?? '', + }, + } + : undefined) + await addLogs({ action: 'Create Repository', data: results, userId, requestId, projectId: repo.projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services lors de la création du dépôt') + } + + if (data.externalRepoUrl) { + await syncRepository({ repositoryId: repo.id, requestId, syncAllBranches: true, userId }) + } + return repo +} + +export async function updateRepository({ + repositoryId, + data, + userId, + requestId, +}: { + repositoryId: Repository['id'] + data: Partial + userId: User['id'] + requestId: string +}) { + const dbData = { ...data } + delete dbData.externalToken + const repo = await updateRepositoryQuery(repositoryId, dbData) + + const { results } = await hook.project.upsert(repo.projectId, { + [repo.internalRepoName]: { + username: repo.externalUserName ?? '', + token: data.externalToken ?? '', + }, + }) + await addLogs({ action: 'Update Repository', data: results, userId, requestId, projectId: repo.projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services à la mise à jour du dépôt') + } + + return repo +} + +export async function deleteRepository({ + repositoryId, + userId, + requestId, + projectId, +}: { + repositoryId: Repository['id'] + userId: User['id'] + requestId: string + projectId: Project['id'] +}) { + await deleteRepositoryQuery(repositoryId) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Delete Repository', data: results, userId, requestId, projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services à la suppression du dépôt') + } + return null +} diff --git a/.jjconflict-base-1/apps/server/src/resources/repository/queries.ts b/.jjconflict-base-1/apps/server/src/resources/repository/queries.ts new file mode 100644 index 0000000000..277e7af602 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/repository/queries.ts @@ -0,0 +1,62 @@ +import type { Project, Repository } from '@prisma/client' +import prisma from '@/prisma.js' + +// SELECT +export function getRepositoryById(id: Repository['id']) { + return prisma.repository.findUniqueOrThrow({ where: { id } }) +} + +export function getProjectRepositories(projectId: Project['id']) { + return prisma.repository.findMany({ where: { projectId } }) +} + +// CREATE +type RepositoryCreate = Pick + & Partial> + +export function initializeRepository({ projectId, internalRepoName, externalRepoUrl, isInfra, isPrivate, externalUserName, deployRevision, deployPath, helmValuesFiles }: RepositoryCreate) { + return prisma.repository.create({ + data: { + projectId, + internalRepoName, + externalRepoUrl, + externalUserName, + isInfra, + isPrivate, + deployRevision, + deployPath, + helmValuesFiles, + }, + }) +} + +export function getHookRepository(id: Repository['id']) { + return prisma.repository.findUniqueOrThrow({ + where: { + id, + }, + include: { + project: true, + }, + }) +} + +// UPDATE +export function updateRepository(id: Repository['id'], infos: Partial) { + return prisma.repository.update({ where: { id }, data: { ...infos } }) +} + +// DELETE +export async function deleteRepository(id: Repository['id']) { + const doesRepoExist = await getRepositoryById(id) + if (!doesRepoExist) throw new Error('Le dépôt interne demandé n\'existe pas en base pour ce projet') + return prisma.repository.delete({ where: { id } }) +} + +export function deleteAllRepositoryForProject(id: Project['id']) { + return prisma.repository.deleteMany({ where: { projectId: id } }) +} + +export function _createRepository(data: Parameters[0]['create']) { + return prisma.repository.upsert({ create: data, update: data, where: { id: data.id } }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/repository/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/repository/router.spec.ts new file mode 100644 index 0000000000..ae08505be0 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/repository/router.spec.ts @@ -0,0 +1,404 @@ +import { PROJECT_PERMS, repositoryContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { atDates, getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessCreateMock = vi.spyOn(business, 'createRepository') +const businessUpdateMock = vi.spyOn(business, 'updateRepository') +const businessDeleteMock = vi.spyOn(business, 'deleteRepository') +const businessSyncMock = vi.spyOn(business, 'syncRepository') +const businessGetProjectRepositoriesMock = vi.spyOn(business, 'getProjectRepositories') + +describe('repositoryRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const repositoryId = faker.string.uuid() + const repositoryData = { + projectId, + externalRepoUrl: `${faker.internet.url()}.git`, + isPrivate: true, + externalToken: faker.string.alpha(), + externalUserName: faker.internet.username(), + isInfra: false, + internalRepoName: faker.string.alpha({ length: 5, casing: 'lower' }), + } + + describe('listRepositories', () => { + it('should return repositories for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetProjectRepositoriesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(repositoryContract.listRepositories.path) + .query({ projectId }) + .end() + + expect(businessGetProjectRepositoriesMock).toHaveBeenCalledWith(projectId) + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.REPLAY_HOOKS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(repositoryContract.listRepositories.path) + .query({ projectId }) + .end() + + expect(businessGetProjectRepositoriesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('syncRepository', () => { + it('should synchronize repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(204) + expect(businessSyncMock).toHaveBeenCalledWith({ repositoryId, userId: user.user.id, branchName: 'main', requestId: expect.any(String), syncAllBranches: false }) + }) + + it('should return 403 for forbidden sync attempt', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('createRepository', () => { + it('should create repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...atDates }) + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(201) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should return 403 for insuficient permissions', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateRepository', () => { + const repoUpdateData = { isInfra: true } + it('should update repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...repoUpdateData, ...atDates }) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(200) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData, ...repoUpdateData }) + }) + + it('should update repository and drop creds if is not private', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const repoUpdateData = { isPrivate: false, externalUserName: 'test' } + businessUpdateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...repoUpdateData, ...atDates }) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith({ data: { isPrivate: false }, repositoryId, requestId: expect.any(String), userId: user.user.id }) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData, ...repoUpdateData }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not enough permissions', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(400) + }) + // TODO add tests about filtering + }) + + describe('deleteRepository', () => { + it('should delete repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not enough privilege', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/repository/router.ts b/.jjconflict-base-1/apps/server/src/resources/repository/router.ts new file mode 100644 index 0000000000..a9e93209ee --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/repository/router.ts @@ -0,0 +1,133 @@ +import { fakeToken, ProjectAuthorized, repositoryContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { filterObjectByKeys } from '@/utils/queries-tools.js' +import { + createRepository, + deleteRepository, + getProjectRepositories, + syncRepository, + updateRepository, +} from './business.js' + +export function repositoryRouter() { + return serverInstance.router(repositoryContract, { + // Récupérer tous les repositories d'un projet + listRepositories: async ({ request: req, query }) => { + const projectId = query.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListRepositories(perms)) return new Forbidden403() + const body = await getProjectRepositories(projectId) + + return { + status: 200, + body, + } + }, + + // Synchroniser un repository + syncRepository: async ({ request: req, params, body }) => { + const { repositoryId } = params + const perms = await authUser(req, { repositoryId }) + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const { syncAllBranches, branchName } = body + + const resBody = await syncRepository({ repositoryId, userId: perms.user.id, branchName, requestId: req.id, syncAllBranches }) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 204, + body: resBody, + } + }, + + // Créer un repository + createRepository: async ({ request: req, body: data }) => { + const projectId = data.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await createRepository({ data, userId: perms.user.id, requestId: req.id }) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Mettre à jour un repository + updateRepository: async ({ request: req, params, body }) => { + const repositoryId = params.repositoryId + const perms = await authUser(req, { repositoryId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const keysAllowedForUpdate = [ + 'externalRepoUrl', + 'isPrivate', + 'externalToken', + 'externalUserName', + 'isInfra', + 'deployRevision', + 'deployPath', + 'helmValuesFiles', + ] + const data = filterObjectByKeys(body, keysAllowedForUpdate) + + if (data.externalToken === fakeToken) { + delete data.externalToken + } + + if (data.isPrivate === false) { + delete data.externalToken + delete data.externalUserName + } + + const resBody = await updateRepository({ repositoryId, data, userId: perms.user.id, requestId: req.id }) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + // Supprimer un repository + deleteRepository: async ({ request: req, params }) => { + const repositoryId = params.repositoryId + const perms = await authUser(req, { repositoryId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await deleteRepository({ + repositoryId, + userId: perms.user.id, + requestId: req.id, + projectId: perms.projectId, + }) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/service-chain/business.spec.ts b/.jjconflict-base-1/apps/server/src/resources/service-chain/business.spec.ts new file mode 100644 index 0000000000..a3903e6eff --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/service-chain/business.spec.ts @@ -0,0 +1,171 @@ +import type { + ServiceChain, + ServiceChainDetails, + ServiceChainFlows, +} from '@cpn-console/shared' +import type { Mock } from 'vitest' +import { + serviceChainEnvironmentEnum, + serviceChainFlowStateEnum, + serviceChainLocationEnum, + serviceChainNetworkEnum, + serviceChainStateEnum, +} from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import axios from 'axios' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + getServiceChainDetails, + getServiceChainFlows, + listServiceChains, + retryServiceChain, + validateServiceChain, +} from './business.ts' + +vi.mock('axios') + +let serviceChain: ServiceChain +let serviceChainDetails: ServiceChainDetails +let serviceChainFlows: ServiceChainFlows + +describe('test ServiceChain business logic', () => { + beforeEach(() => { + serviceChain = { + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + } + + serviceChainDetails = { + ...serviceChain, + validationId: faker.string.uuid(), + validatedBy: faker.helpers.maybe(() => faker.string.uuid()) || null, + ref: faker.string.uuid(), + location: faker.helpers.arrayElement(serviceChainLocationEnum), + targetAddress: faker.internet.ipv4(), + projectId: faker.string.uuid(), + env: faker.helpers.arrayElement(serviceChainEnvironmentEnum), + subjectAlternativeName: faker.helpers.uniqueArray( + faker.internet.domainName, + 3, + ), + redirect: faker.datatype.boolean(), + antivirus: + faker.helpers.maybe(() => ({ + maxFileSize: faker.number.int(), + })) || null, // undefined is not wanted here + websocket: faker.datatype.boolean(), + ipWhiteList: faker.helpers + .uniqueArray(faker.internet.ipv4, 5) + .map(e => `${e}/32`), // We want a CIDR here + sslOutgoing: faker.datatype.boolean(), + } + + serviceChainFlows = { + reserve_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + create_cert: faker.helpers.maybe(() => ({ + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + })) || null, + call_exec: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + activate_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + dns_request: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + } + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('listServiceChains', () => { + it('should return a list of service chains', async () => { + const input = [serviceChain]; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await listServiceChains() + + expect(result).toStrictEqual(input) + }) + }) + + describe('getServiceChainDetails', () => { + it('should return a service chain details', async () => { + const input = serviceChainDetails; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await getServiceChainDetails(faker.string.uuid()) + + expect(result).toStrictEqual(input) + }) + }) + + describe('retryServiceChain', () => { + it('should trigger a service chain retry attempt', async () => { + const input = {}; + (axios.create as Mock).mockReturnValue({ + post: () => ({ data: input }), + }) + + const result = await retryServiceChain(faker.string.uuid()) + + expect(result.data).toStrictEqual(input) + }) + }) + + describe('validateServiceChain', () => { + it('should trigger a service chain validate attempt', async () => { + const input = {}; + (axios.create as Mock).mockReturnValue({ + post: () => ({ data: input }), + }) + + const result = await validateServiceChain(faker.string.uuid()) + + expect(result.data).toStrictEqual(input) + }) + }) + + describe('getServiceChainFlows', () => { + it('should return a service chain flows', async () => { + const input = serviceChainFlows; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await getServiceChainFlows(faker.string.uuid()) + + expect(result).toStrictEqual(input) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/service-chain/business.ts b/.jjconflict-base-1/apps/server/src/resources/service-chain/business.ts new file mode 100644 index 0000000000..43ffb986e6 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/service-chain/business.ts @@ -0,0 +1,27 @@ +import { + getServiceChainDetails as getServiceChainDetailsQuery, + getServiceChainFlows as getServiceChainFlowsQuery, + listServiceChains as listServiceChainsQuery, + retryServiceChain as retryServiceChainQuery, + validateServiceChain as validateServiceChainQuery, +} from '@/resources/queries-index.js' + +export async function listServiceChains() { + return listServiceChainsQuery() +} + +export async function getServiceChainDetails(serviceChainId: string) { + return getServiceChainDetailsQuery(serviceChainId) +} + +export async function retryServiceChain(serviceChainId: string) { + return retryServiceChainQuery(serviceChainId) +} + +export async function validateServiceChain(validationId: string) { + return validateServiceChainQuery(validationId) +} + +export async function getServiceChainFlows(serviceChainId: string) { + return getServiceChainFlowsQuery(serviceChainId) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/service-chain/queries.ts b/.jjconflict-base-1/apps/server/src/resources/service-chain/queries.ts new file mode 100644 index 0000000000..0207266c8a --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/service-chain/queries.ts @@ -0,0 +1,59 @@ +import type { ServiceChain } from '@cpn-console/shared' +import https from 'node:https' +import { + + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainListSchema, +} from '@cpn-console/shared' +import axios from 'axios' + +const openCDSEnvVar = 'OPENCDS_URL' +const openCDSTargetURL = process.env[openCDSEnvVar] +const openCDSDisabledErrorMessage = `OpenCDS is disabled, please set ${openCDSEnvVar} in your relevant .env file. See .env-example` + +function getClient() { + if (!openCDSTargetURL) { + throw new Error(openCDSDisabledErrorMessage) + } + return axios.create({ + baseURL: openCDSTargetURL, + httpsAgent: new https.Agent({ + rejectUnauthorized: + // We want it to be `false` only if it has explicitly + // been stated as "false" in the env vars + process.env.OPENCDS_API_TLS_REJECT_UNAUTHORIZED !== 'false', + }), + headers: { + 'X-API-Key': process.env.OPENCDS_API_TOKEN, + }, + }) +} + +export async function listServiceChains() { + return ServiceChainListSchema.parse( + (await getClient().get(`/requests`)).data, + ) +} + +export async function getServiceChainDetails( + serviceChainId: ServiceChain['id'], +) { + return ServiceChainDetailsSchema.parse( + (await getClient().get(`/requests/${serviceChainId}`)).data, + ) +} + +export async function retryServiceChain(serviceChainId: ServiceChain['id']) { + return await getClient().post(`/requests/${serviceChainId}/retry`) +} + +export async function validateServiceChain(validationId: string) { + return await getClient().post(`/validate/${validationId}`) +} + +export async function getServiceChainFlows(serviceChainId: ServiceChain['id']) { + return ServiceChainFlowsSchema.parse( + (await getClient().get(`/requests/${serviceChainId}/flows`)).data, + ) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/service-chain/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/service-chain/router.spec.ts new file mode 100644 index 0000000000..c69c14edff --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/service-chain/router.spec.ts @@ -0,0 +1,307 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import { + ADMIN_PERMS, + serviceChainContract, + ServiceChainDetailsSchema, + serviceChainEnvironmentEnum, + ServiceChainFlowsSchema, + serviceChainFlowStateEnum, + ServiceChainListSchema, + serviceChainLocationEnum, + serviceChainNetworkEnum, + serviceChainStateEnum, +} from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock( + 'fastify-keycloak-adapter', + (await import('../../utils/mocks.js')).mockSessionPlugin, +) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListServiceChainsMock = vi.spyOn(business, 'listServiceChains') +const businessGetServiceChainDetailsMock = vi.spyOn(business, 'getServiceChainDetails') +const businessRetryServiceChainMock = vi.spyOn(business, 'retryServiceChain') +const businessValidateServiceChainMock = vi.spyOn(business, 'validateServiceChain') +const businessGetServiceChainsFlowsMock = vi.spyOn(business, 'getServiceChainFlows') + +describe('test ServiceChainContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listServiceChains', () => { + it('as non admin', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + businessListServiceChainsMock.mockResolvedValueOnce([]) + const response = await app + .inject() + .get(serviceChainContract.listServiceChains.path) + .end() + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('as admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const serviceChainList = faker.helpers.multiple(() => ({ + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + })) + + authUserMock.mockResolvedValueOnce(user) + + businessListServiceChainsMock.mockResolvedValueOnce(serviceChainList) + const response = await app + .inject() + .get(serviceChainContract.listServiceChains.path) + .end() + + expect(businessListServiceChainsMock).toHaveBeenCalledWith() + + expect(ServiceChainListSchema.parse(response.json())).toStrictEqual( + serviceChainList, + ) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getServiceChainDetails', () => { + it('should return serviceChain details', async () => { + const serviceChainDetails: ServiceChainDetails = { + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + validationId: faker.string.uuid(), + validatedBy: faker.string.uuid(), + ref: faker.string.uuid(), + location: faker.helpers.arrayElement(serviceChainLocationEnum), + targetAddress: faker.internet.ipv4(), + projectId: faker.string.uuid(), + env: faker.helpers.arrayElement(serviceChainEnvironmentEnum), + subjectAlternativeName: faker.helpers.uniqueArray( + faker.internet.domainName, + 3, + ), + redirect: faker.datatype.boolean(), + antivirus: + faker.helpers.maybe(() => ({ + maxFileSize: faker.number.int(), + })) || null, // undefined is not wanted here + websocket: faker.datatype.boolean(), + ipWhiteList: faker.helpers + .uniqueArray(faker.internet.ipv4, 5) + .map(e => `${e}/32`), // We want a CIDR here + sslOutgoing: faker.datatype.boolean(), + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessGetServiceChainDetailsMock.mockResolvedValueOnce(serviceChainDetails) + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainDetails.path.replace( + ':serviceChainId', + serviceChainDetails.id, + ), + ) + .end() + + expect(ServiceChainDetailsSchema.parse(response.json())).toEqual( + serviceChainDetails, + ) + expect(response.statusCode).toEqual(200) + expect(businessGetServiceChainDetailsMock).toHaveBeenCalledTimes(1) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainDetails.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessGetServiceChainDetailsMock).toHaveBeenCalledTimes(0) + }) + }) + + describe('retryServiceChain', () => { + it('should return 204', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessRetryServiceChainMock.mockResolvedValueOnce({ + status: 204, + body: undefined, + }) + const response = await app + .inject() + .post( + serviceChainContract.retryServiceChain.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.body).toEqual('') + expect(businessRetryServiceChainMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(204) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .post( + serviceChainContract.retryServiceChain.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessRetryServiceChainMock).toHaveBeenCalledTimes(0) + }) + }) + + describe('validateServiceChain', () => { + it('should return 204', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessValidateServiceChainMock.mockResolvedValueOnce({ + status: 204, + body: undefined, + }) + const response = await app + .inject() + .post( + serviceChainContract.validateServiceChain.path.replace( + ':validationId', + faker.string.uuid(), + ), + ) + .end() + + expect(businessValidateServiceChainMock).toHaveBeenCalledTimes(1) + expect(response.body).toEqual('') + expect(response.statusCode).toEqual(204) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .post( + serviceChainContract.validateServiceChain.path.replace( + ':validationId', + faker.string.uuid(), + ), + ) + .end() + + expect(businessValidateServiceChainMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getServiceChainFlows', () => { + it('should return serviceChain flows', async () => { + const serviceChainFlows: ServiceChainFlows = { + reserve_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + create_cert: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + call_exec: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + activate_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + dns_request: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessGetServiceChainsFlowsMock.mockResolvedValueOnce(serviceChainFlows) + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainFlows.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(ServiceChainFlowsSchema.parse(response.json())).toEqual( + serviceChainFlows, + ) + expect(response.statusCode).toEqual(200) + expect(businessGetServiceChainsFlowsMock).toHaveBeenCalledTimes(1) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainFlows.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessGetServiceChainsFlowsMock).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/service-chain/router.ts b/.jjconflict-base-1/apps/server/src/resources/service-chain/router.ts new file mode 100644 index 0000000000..5888418cfe --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/service-chain/router.ts @@ -0,0 +1,94 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, serviceChainContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { + getServiceChainDetails as getServiceChainDetailsBusiness, + getServiceChainFlows as getServiceChainFlowsBusiness, + listServiceChains as listServiceChainsBusiness, + retryServiceChain as retryServiceChainBusiness, + validateServiceChain as validateServiceChainBusiness, +} from './business.js' +import '@/types/index.js' + +export function serviceChainRouter() { + return serverInstance.router(serviceChainContract, { + listServiceChains: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + let body: AsyncReturnType = [] + if (AdminAuthorized.ListSystem(adminPermissions)) { + body = await listServiceChainsBusiness() + } + + return { + status: 200, + body, + } + }, + + getServiceChainDetails: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + const serviceChainDetails + = await getServiceChainDetailsBusiness(serviceChainId) + + return { + status: 200, + body: serviceChainDetails, + } + }, + + retryServiceChain: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + await retryServiceChainBusiness(serviceChainId) + + return { + status: 204, + body: null, + } + }, + + validateServiceChain: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.validationId + await validateServiceChainBusiness(serviceChainId) + + return { + status: 204, + body: null, + } + }, + + getServiceChainFlows: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + const serviceChainFlows + = await getServiceChainFlowsBusiness(serviceChainId) + + return { + status: 200, + body: serviceChainFlows, + } + }, + + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/service-monitor/business.ts b/.jjconflict-base-1/apps/server/src/resources/service-monitor/business.ts new file mode 100644 index 0000000000..fa61d5a6de --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/service-monitor/business.ts @@ -0,0 +1,9 @@ +import { services } from '@cpn-console/hooks' + +export function checkServicesHealth() { + return services.getStatus() +} + +export async function refreshServicesHealth() { + return Promise.all(services.refreshStatus()) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/service-monitor/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/service-monitor/router.spec.ts new file mode 100644 index 0000000000..898ed61e5a --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/service-monitor/router.spec.ts @@ -0,0 +1,78 @@ +import type { ServiceStatus } from '@cpn-console/hooks' +import { ADMIN_PERMS, MonitorStatus, serviceContract } from '@cpn-console/shared' +import { describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +const authUserMock = vi.spyOn(utilsController, 'authUser') + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const businessCheckMock = vi.spyOn(business, 'checkServicesHealth') +const businessRefreshMock = vi.spyOn(business, 'refreshServicesHealth') + +describe('test serviceContract', () => { + const services: ServiceStatus[] = [{ interval: 1, lastUpdateTimestamp: 1, message: 'OK', name: 'A service', status: MonitorStatus.OK }] + const servicesComplete: ServiceStatus[] = [{ cause: 'error', interval: 1, lastUpdateTimestamp: 1, message: 'OK', name: 'A service', status: MonitorStatus.OK }] + + it('should return complete services, with cause', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(servicesComplete) + expect(response.statusCode).toEqual(200) + }) + + it('should not return complete services, forbidden', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return services', async () => { + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(services) + expect(response.statusCode).toEqual(200) + }) + + it('should refresh services', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessRefreshMock.mockResolvedValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(servicesComplete) + expect(response.statusCode).toEqual(200) + }) + + it('should refresh services, cause forbidden', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + businessRefreshMock.mockResolvedValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.statusCode).toEqual(403) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/service-monitor/router.ts b/.jjconflict-base-1/apps/server/src/resources/service-monitor/router.ts new file mode 100644 index 0000000000..ace5831c3e --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/service-monitor/router.ts @@ -0,0 +1,44 @@ +import { AdminAuthorized, serviceContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { checkServicesHealth, refreshServicesHealth } from './business.js' + +export function serviceMonitorRouter() { + return serverInstance.router(serviceContract, { + getServiceHealth: async () => { + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + + getCompleteServiceHealth: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ListSystem(adminPermissions)) return new Forbidden403() + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + + refreshServiceHealth: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageSystem(adminPermissions)) return new Forbidden403() + + await refreshServicesHealth() + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/stage/business.spec.ts b/.jjconflict-base-1/apps/server/src/resources/stage/business.spec.ts new file mode 100644 index 0000000000..ba43c2cb75 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/stage/business.spec.ts @@ -0,0 +1,113 @@ +import type { Environment, Stage } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { BadRequest400, NotFound404 } from '../../utils/errors.ts' +import { createStage, deleteStage, getStageAssociatedEnvironments, listStages, updateStage } from './business.ts' + +describe('test stage busines logic', () => { + let stage: Stage + beforeEach(() => { + vi.resetAllMocks() + stage = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + } + }) + describe('createStage', () => { + it('should create a stage', async () => { + prisma.stage.findUnique.mockResolvedValue(null) + prisma.stage.create.mockResolvedValue({ id: stage.id } as Stage) + await createStage({ name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(1) + }) + it('should not create a stage, name conflict', async () => { + prisma.stage.findUnique.mockResolvedValue({ id: stage.id } as Stage) + const response = await createStage({ name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) + + describe('updateStage', () => { + it('should update a stage', async () => { + const dbClusters = [{ id: faker.string.uuid() }] + const newClusters = [faker.string.uuid()] + prisma.stage.findUnique.mockResolvedValue({ ...stage, clusters: dbClusters } as Stage) + prisma.stage.update.mockResolvedValue({ id: stage.id } as Stage) + const response = await updateStage(stage.id, { name: stage.name, clusterIds: newClusters }) + expect(prisma.cluster.update).toHaveBeenCalledTimes(1) + expect(prisma.cluster.update).toHaveBeenCalledWith({ where: { id: dbClusters[0].id }, data: { + stages: { + disconnect: { + id: stage.id, + }, + }, + } }) + expect(prisma.stage.update).toHaveBeenCalledTimes(1) + expect(prisma.stage.update).toHaveBeenCalledWith({ where: { id: stage.id }, data: { + clusters: { + connect: [{ + id: newClusters[0], + }], + }, + } }) + expect(response.clusterIds).toBe(newClusters) + }) + it('should do nothing', async () => { + prisma.stage.findUnique.mockResolvedValue({ ...stage, clusters: [] } as Stage) + await updateStage(stage.id, { clusterIds: [], name: stage.name }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + }) + it('should return not found', async () => { + prisma.stage.findUnique.mockResolvedValue(null) + const response = await updateStage(stage.id, { name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + expect(response).instanceOf(NotFound404) + }) + }) + + describe('deleteStage', () => { + it('should delete a stage', async () => { + prisma.environment.findFirst.mockResolvedValue(null) + prisma.stage.delete.mockResolvedValue({ id: stage.id } as Stage) + await deleteStage(stage.id) + expect(prisma.stage.delete).toHaveBeenCalledTimes(1) + }) + it('should not delete a stage, environment attached', async () => { + prisma.environment.findFirst.mockResolvedValue({ id: faker.string.uuid() } as Environment) + const response = await deleteStage(stage.id) + expect(prisma.stage.delete).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) + + describe('listStages', () => { + const clusterAssociated = [{ id: faker.string.uuid() }] + it('should list all stages (admin, no userId provided)', async () => { + prisma.stage.findMany.mockResolvedValue([{ clusters: clusterAssociated }] as unknown as Stage[]) + const response = await listStages() + expect(response[0].clusterIds).toStrictEqual([clusterAssociated[0].id]) + expect(prisma.stage.findMany).toHaveBeenCalledTimes(1) + expect(prisma.stage.findMany).toHaveBeenCalledWith({ include: { clusters: true } }) + }) + }) + + describe('getStageAssociatedEnvironments', () => { + it('should list all environments attached to a stage stages', async () => { + const envName = faker.string.alpha(8) + const projectSlug = faker.string.alpha(8) + const clusterLabel = faker.string.alpha(8) + const ownerEmail = faker.internet.email() + const envs = [{ name: envName, project: { slug: projectSlug, owner: { email: ownerEmail } }, cluster: { label: clusterLabel } }] + prisma.environment.findMany.mockResolvedValue(envs as unknown as Environment[]) + const response = await getStageAssociatedEnvironments(stage.id) + expect(response).toStrictEqual([{ + name: envName, + project: projectSlug, + owner: ownerEmail, + cluster: clusterLabel, + }]) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/stage/business.ts b/.jjconflict-base-1/apps/server/src/resources/stage/business.ts new file mode 100644 index 0000000000..db6faf686e --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/stage/business.ts @@ -0,0 +1,97 @@ +import type { CreateStageBody, UpdateStageBody } from '@cpn-console/shared' +import type { Cluster, Stage } from '@prisma/client' +import prisma from '@/prisma.js' +import { + createStage as createStageQuery, + deleteStage as deleteStageQuery, + getAllStageIds, + getStageAssociatedEnvironmentById, + getStageById, + getStageByName, + linkClusterToStages as linkClusterToStagesQuery, + linkStageToClusters, + listStages as listStagesQuery, + removeClusterFromStage, + updateStageName, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' + +export async function getStageAssociatedEnvironments(stageId: Stage['id']) { + const environments = await getStageAssociatedEnvironmentById(stageId) + return environments.map(env => ({ + project: env.project.slug, + name: env.name, + cluster: env.cluster.label, + owner: env.project.owner.email, + })) +} + +export async function createStage({ clusterIds = [], name }: CreateStageBody) { + const isNameTaken = await getStageByName(name) + if (isNameTaken) return new BadRequest400('Un type d\'environnement portant ce nom existe déjà') + + const stage = await createStageQuery({ name }) + + if (clusterIds.length) { + await linkStageToClusters(stage.id, clusterIds) + } + + return { + id: stage.id, + name: stage.name, + clusterIds, + } +} + +export async function updateStage(stageId: Stage['id'], { clusterIds, name }: UpdateStageBody) { + const dbStage = await getStageById(stageId) + if (!dbStage) return new NotFound404() + if (name !== dbStage.name) { + await updateStageName(stageId, name) + } + // Remove clusters + const dbClusters = dbStage.clusters + if (dbClusters?.length) { + const clustersToRemove = dbClusters.filter(dbCluster => !clusterIds.includes(dbCluster.id)) + for (const clusterToRemove of clustersToRemove) { + await removeClusterFromStage(clusterToRemove.id, stageId) + } + } + // Add clusters + if (clusterIds.length) { + await linkStageToClusters(stageId, clusterIds) + } + + return { + id: stageId, + name: name ?? dbStage.name, + clusterIds: clusterIds ?? dbStage.clusters.map(({ id }) => id), + } +} + +export async function deleteStage(stageId: Stage['id']) { + const attachedEnvironment = await prisma.environment.findFirst({ where: { stageId }, select: { id: true } }) + if (attachedEnvironment) return new BadRequest400('Impossible de supprimer le stage, des environnements en activité y ont souscrit') + + await deleteStageQuery(stageId) + return null +} + +export async function listStages() { + const stages = await listStagesQuery() + + return stages.map((stage) => { + return { + id: stage.id, + name: stage.name, + clusterIds: stage.clusters.map(({ id }) => id), + } + }) +} + +export async function linkClusterToStages(clusterId: Cluster['id'], stageIds: Stage['id'][], linkToAll: boolean = false) { + if (linkToAll === true) { + stageIds = await getAllStageIds() + } + await linkClusterToStagesQuery(clusterId, stageIds) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/stage/queries.ts b/.jjconflict-base-1/apps/server/src/resources/stage/queries.ts new file mode 100644 index 0000000000..98d526600a --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/stage/queries.ts @@ -0,0 +1,111 @@ +import type { Cluster, Stage } from '@prisma/client' +import prisma from '@/prisma.js' + +export function listStages() { + return prisma.stage.findMany({ + include: { + clusters: true, + }, + }) +} + +export async function getAllStageIds() { + return (await prisma.stage.findMany({ + select: { + id: true, + }, + })).map(({ id }) => id) +} + +export function getStageById(id: Stage['id']) { + return prisma.stage.findUnique({ + where: { id }, + include: { + clusters: true, + }, + }) +} + +export function getStageByIdOrThrow(id: Stage['id']) { + return prisma.stage.findUniqueOrThrow({ + where: { id }, + include: { + clusters: true, + }, + }) +} + +export function getStageAssociatedEnvironmentById(id: Stage['id']) { + return prisma.environment.findMany({ + where: { + stageId: id, + }, + select: { + name: true, + cluster: { + select: { + label: true, + }, + }, + project: { + select: { + name: true, + owner: true, + slug: true, + }, + }, + }, + }) +} + +export function getStageAssociatedEnvironmentLengthById(id: Stage['id']) { + return prisma.environment.count({ + where: { + stageId: id, + }, + }) +} + +export function getStageByName(name: Stage['name']) { + return prisma.stage.findUnique({ + where: { name }, + }) +} + +export function linkStageToClusters(id: Stage['id'], clusterIds: Cluster['id'][]) { + return prisma.stage.update({ + where: { + id, + }, + data: { + clusters: { + connect: clusterIds.map(clusterId => ({ id: clusterId })), + }, + }, + }) +} + +export function createStage({ name }: { name: Stage['name'] }) { + return prisma.stage.create({ + data: { + name, + }, + }) +} + +export function updateStageName(id: Stage['id'], name: Stage['name']) { + return prisma.stage.update({ + where: { + id, + }, + data: { + name, + }, + }) +} + +export function deleteStage(id: Stage['id']) { + return prisma.stage.delete({ + where: { id }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/stage/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/stage/router.spec.ts new file mode 100644 index 0000000000..3e62a482fc --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/stage/router.spec.ts @@ -0,0 +1,204 @@ +import type { Stage } from '@cpn-console/shared' +import { ADMIN_PERMS, stageContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listStages') +const businessGetEnvironmentsMock = vi.spyOn(business, 'getStageAssociatedEnvironments') +const businessCreateMock = vi.spyOn(business, 'createStage') +const businessUpdateMock = vi.spyOn(business, 'updateStage') +const businessDeleteMock = vi.spyOn(business, 'deleteStage') + +describe('test stageContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listStages', () => { + it('should return list of stages', async () => { + const stages: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_STAGES) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(stages) + + const response = await app.inject() + .get(stageContract.listStages.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(stages) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getStageEnvironments', () => { + it('should return stage environments for admin', async () => { + const environments: any = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(environments) + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(environments) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createStage', () => { + const stage: Stage = { id: faker.string.uuid(), name: faker.string.alpha({ length: 5 }), clusterIds: [] } + + it('should create and return stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(stage) + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(stage) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateStage', () => { + const stageId = faker.string.uuid() + const stage = { name: faker.string.alpha({ length: 5 }), clusterIds: [] } + + it('should update and return stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: stageId, ...stage }) + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: stageId, ...stage }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteStage', () => { + it('should delete stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/stage/router.ts b/.jjconflict-base-1/apps/server/src/resources/stage/router.ts new file mode 100644 index 0000000000..73202d4549 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/stage/router.ts @@ -0,0 +1,92 @@ +import { AdminAuthorized, stageContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + createStage, + deleteStage, + getStageAssociatedEnvironments, + listStages, + updateStage, +} from './business.js' + +export function stageRouter() { + return serverInstance.router(stageContract, { + + // Récupérer les types d'environnement disponibles + listStages: async () => { + const body = await listStages() + + return { + status: 200, + body, + } + }, + + // Récupérer les environnements associés au stage + getStageEnvironments: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + const body = await getStageAssociatedEnvironments(stageId) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Créer un stage + createStage: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const body = await createStage(data) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Modifier une association stage / clusters + updateStage: async ({ request: req, params, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + + const body = await updateStage(stageId, data) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Supprimer un stage + deleteStage: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + + const body = await deleteStage(stageId) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/system/config/business.spec.ts b/.jjconflict-base-1/apps/server/src/resources/system/config/business.spec.ts new file mode 100644 index 0000000000..35d4844070 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/system/config/business.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import prisma from '../../../__mocks__/prisma.js' +import { objToDb, updatePluginConfig } from './business.ts' + +describe('test system/config business', () => { + const config = { test: { key1: 'value1' } } + it('should transform object to db row', () => { + const response = objToDb({ test: { key1: 'value1' } }) + expect(response).toEqual([{ pluginName: 'test', key: 'key1', value: 'value1' }]) + }) + describe('updatePluginConfig', () => { + it('should update', async () => { + prisma.adminPlugin.upsert.mockResolvedValue(null) + await updatePluginConfig(config) + }) + it('should update 0 items cause missing manifest', async () => { + // @ts-ignore + await updatePluginConfig({ test: { key: 1 } }) + expect(prisma.adminPlugin.upsert).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/system/config/business.ts b/.jjconflict-base-1/apps/server/src/resources/system/config/business.ts new file mode 100644 index 0000000000..111cb4cd1b --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/system/config/business.ts @@ -0,0 +1,50 @@ +import type { + PluginsUpdateBody, +} from '@cpn-console/shared' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { BadRequest400 } from '@/utils/errors.js' +import { + getAdminPlugin, + savePluginsConfig, +} from './queries.js' + +export type ConfigRecords = { + key: string + pluginName: string + value: string +}[] + +export function objToDb(obj: PluginsUpdateBody): ConfigRecords { + return Object.entries(obj) + .map(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) + .flat() +} + +export async function getPluginsConfig() { + const globalConfig = await getAdminPlugin() + + return Object.values(servicesInfos).map(({ name, title, imgSrc, description }) => { + const manifest = populatePluginManifests({ + data: { + global: globalConfig, + }, + permissionTarget: 'admin', + pluginName: name, + select: { + global: true, + project: false, + }, + }) + return { imgSrc, title, name, manifest: manifest.global ?? [], description } + }).filter(plugin => plugin.manifest.length > 0) +} + +export async function updatePluginConfig(data: PluginsUpdateBody) { + const parsedData = editStrippers.global.safeParse(data) + if (!parsedData.success) return new BadRequest400(parsedData.error.message) + const records = objToDb(parsedData.data) + + await savePluginsConfig(records) + return null +} diff --git a/.jjconflict-base-1/apps/server/src/resources/system/config/queries.ts b/.jjconflict-base-1/apps/server/src/resources/system/config/queries.ts new file mode 100644 index 0000000000..69808e506a --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/system/config/queries.ts @@ -0,0 +1,28 @@ +import type { ConfigRecords } from './business.js' +import prisma from '@/prisma.js' + +// CONFIG +export const getAdminPlugin = prisma.adminPlugin.findMany + +export async function savePluginsConfig(records: ConfigRecords) { + for (const { pluginName, key, value } of records) { + await prisma.adminPlugin.upsert({ + create: { + pluginName, + key, + value: String(value), + }, + update: { + key, + value: String(value), + pluginName, + }, + where: { + pluginName_key: { + pluginName, + key, + }, + }, + }) + } +} diff --git a/.jjconflict-base-1/apps/server/src/resources/system/config/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/system/config/router.spec.ts new file mode 100644 index 0000000000..423a1b7b38 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/system/config/router.spec.ts @@ -0,0 +1,96 @@ +import { ADMIN_PERMS, systemPluginContract } from '@cpn-console/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../../app.js' +import * as utilsController from '../../../utils/controller.js' +import { BadRequest400 } from '../../../utils/errors.js' +import { getUserMockInfos } from '../../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetPluginsConfigMock = vi.spyOn(business, 'getPluginsConfig') +const businessUpdatePluginConfigMock = vi.spyOn(business, 'updatePluginConfig') + +describe('test systemPluginContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getPluginsConfig', () => { + it('should return plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const pluginsConfig: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetPluginsConfigMock.mockResolvedValueOnce(pluginsConfig) + + const response = await app.inject() + .get(systemPluginContract.getPluginsConfig.path) + .end() + + expect(businessGetPluginsConfigMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(pluginsConfig) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(systemPluginContract.getPluginsConfig.path) + .end() + + expect(businessGetPluginsConfigMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updatePluginsConfig', () => { + const newConfig = { plugin1: { keyId: 'value' } } + it('should update plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpdatePluginConfigMock.mockResolvedValueOnce(newConfig) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return error if business logic fails', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpdatePluginConfigMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(400) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/system/config/router.ts b/.jjconflict-base-1/apps/server/src/resources/system/config/router.ts new file mode 100644 index 0000000000..d541dc1778 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/system/config/router.ts @@ -0,0 +1,38 @@ +import { AdminAuthorized, systemPluginContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { getPluginsConfig, updatePluginConfig } from './business.js' + +export function pluginConfigRouter() { + return serverInstance.router(systemPluginContract, { + // Récupérer les configurations plugins + getPluginsConfig: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) return new Forbidden403() + + const services = await getPluginsConfig() + + return { + status: 200, + body: services, + + } + }, + // Mettre à jour les configurations plugins + updatePluginsConfig: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) return new Forbidden403() + + const resBody = await updatePluginConfig(body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/system/index.ts b/.jjconflict-base-1/apps/server/src/resources/system/index.ts new file mode 100644 index 0000000000..a45d7accc7 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/system/index.ts @@ -0,0 +1 @@ +export * from './router.js' diff --git a/.jjconflict-base-1/apps/server/src/resources/system/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/system/router.spec.ts new file mode 100644 index 0000000000..62ad30d7f8 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/system/router.spec.ts @@ -0,0 +1,25 @@ +import { systemContract } from '@cpn-console/shared' +import { describe, expect, it, vi } from 'vitest' +import app from '../../app.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) + +describe('system - router', () => { + it('should send application version', async () => { + const response = await app.inject() + .get(systemContract.getVersion.path) + .end() + + expect(response.statusCode).toBe(200) + expect(response.json()).toStrictEqual({ version: process.env.APP_VERSION || 'dev' }) + }) + + it('should send application health with status OK', async () => { + const response = await app.inject() + .get(systemContract.getHealth.path) + .end() + + expect(response.statusCode).toBe(200) + expect(response.json()).toStrictEqual({ status: 'OK' }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/system/router.ts b/.jjconflict-base-1/apps/server/src/resources/system/router.ts new file mode 100644 index 0000000000..9976884963 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/system/router.ts @@ -0,0 +1,21 @@ +import { systemContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { appVersion } from '@/utils/env.js' + +export function systemRouter() { + return serverInstance.router(systemContract, { + getVersion: async () => ({ + status: 200, + body: { + version: appVersion, + }, + }), + + getHealth: async () => ({ + status: 200, + body: { + status: 'OK', + }, + }), + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/system/settings/business.ts b/.jjconflict-base-1/apps/server/src/resources/system/settings/business.ts new file mode 100644 index 0000000000..5e562353b0 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/system/settings/business.ts @@ -0,0 +1,9 @@ +import type { UpsertSystemSettingBody } from '@cpn-console/shared' +import { + getSystemSettings as getSystemSettingsQuery, + upsertSystemSetting as upsertSystemSettingQuery, +} from './queries.js' + +export const getSystemSettings = (key?: string) => getSystemSettingsQuery({ key }) + +export const upsertSystemSetting = (newSystemSetting: UpsertSystemSettingBody) => upsertSystemSettingQuery(newSystemSetting) diff --git a/.jjconflict-base-1/apps/server/src/resources/system/settings/queries.ts b/.jjconflict-base-1/apps/server/src/resources/system/settings/queries.ts new file mode 100644 index 0000000000..c64cb3b74c --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/system/settings/queries.ts @@ -0,0 +1,18 @@ +import type { Prisma, SystemSetting } from '@prisma/client' +import prisma from '@/prisma.js' + +export function upsertSystemSetting(newSystemSetting: SystemSetting) { + return prisma.systemSetting.upsert({ + create: { + ...newSystemSetting, + }, + update: { + value: newSystemSetting.value, + }, + where: { + key: newSystemSetting.key, + }, + }) +} + +export const getSystemSettings = (where?: Prisma.SystemSettingWhereInput) => prisma.systemSetting.findMany({ where }) diff --git a/.jjconflict-base-1/apps/server/src/resources/system/settings/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/system/settings/router.spec.ts new file mode 100644 index 0000000000..469ee45538 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/system/settings/router.spec.ts @@ -0,0 +1,82 @@ +import { ADMIN_PERMS, systemSettingsContract } from '@cpn-console/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../../app.js' +import * as utilsController from '../../../utils/controller.js' +import { getUserMockInfos } from '../../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetSystemSettingsMock = vi.spyOn(business, 'getSystemSettings') +const businessUpsertSystemSettingMock = vi.spyOn(business, 'upsertSystemSetting') + +describe('test systemSettingsContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listSystemSettings', () => { + it('should return plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const systemSettings: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetSystemSettingsMock.mockResolvedValueOnce(systemSettings) + + const response = await app.inject() + .get(systemSettingsContract.listSystemSettings.path) + .end() + + expect(businessGetSystemSettingsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(systemSettings) + expect(response.statusCode).toEqual(200) + }) + + it('should return 200 for anybody', async () => { + const user = getUserMockInfos(0n) + const systemSettings: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetSystemSettingsMock.mockResolvedValueOnce(systemSettings) + + const response = await app.inject() + .get(systemSettingsContract.listSystemSettings.path) + .end() + + expect(businessGetSystemSettingsMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('upsertSystemSetting', () => { + const newConfig = { key: 'key1', value: 'value1' } + it('should update system setting, authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpsertSystemSettingMock.mockResolvedValueOnce(newConfig) + + const response = await app.inject() + .post(systemSettingsContract.upsertSystemSetting.path) + .body(newConfig) + .end() + + expect(businessUpsertSystemSettingMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(systemSettingsContract.upsertSystemSetting.path) + .body(newConfig) + .end() + + expect(businessUpsertSystemSettingMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/system/settings/router.ts b/.jjconflict-base-1/apps/server/src/resources/system/settings/router.ts new file mode 100644 index 0000000000..98bce89e2d --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/system/settings/router.ts @@ -0,0 +1,31 @@ +import { AdminAuthorized, systemSettingsContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getSystemSettings, upsertSystemSetting } from './business.js' + +export function systemSettingsRouter() { + return serverInstance.router(systemSettingsContract, { + listSystemSettings: async ({ query }) => { + const systemSettings = await getSystemSettings(query.key) + + return { + status: 200, + body: systemSettings, + } + }, + + upsertSystemSetting: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) return new Forbidden403() + + const systemSetting = await upsertSystemSetting(data) + + return { + status: 201, + body: systemSetting, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/user/business.spec.ts b/.jjconflict-base-1/apps/server/src/resources/user/business.spec.ts new file mode 100644 index 0000000000..a86c0535ae --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/user/business.spec.ts @@ -0,0 +1,227 @@ +import type { UserDetails } from '../../types/index.ts' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { getMatchingUsers, getUsers, logViaSession, logViaToken, patchUsers, TokenInvalidReason } from './business.ts' +import * as queries from './queries.js' + +const getUsersQueryMock = vi.spyOn(queries, 'getUsers') +const getMatchingUsersQueryMock = vi.spyOn(queries, 'getMatchingUsers') + +describe('test users business', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const user = { + adminRoleIds: [], + createdAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + id: faker.string.uuid(), + lastName: faker.person.lastName(), + updatedAt: new Date(), + } + const projectId = faker.string.uuid() + const adminRoleId = faker.string.uuid() + describe('patchUsers', () => { + it('should do nothing', async () => { + prisma.user.update.mockResolvedValue(null) + + await patchUsers([]) + + expect(prisma.user.update).toHaveBeenCalledTimes(0) + }) + + it('should update a user adminRoleIds', async () => { + const userUpdated = { id: user.id, adminRoleIds: user.adminRoleIds } + + prisma.user.update.mockResolvedValue(user) + + prisma.user.findMany.mockResolvedValue([]) + + await patchUsers([userUpdated]) + expect(prisma.user.update).toHaveBeenCalledTimes(1) + expect(prisma.user.findMany).toHaveBeenCalledTimes(2) + + await patchUsers([userUpdated, userUpdated]) + expect(prisma.user.update).toHaveBeenCalledTimes(3) + }) + }) + describe('getUsers', () => { + it('should query without where', async () => { + prisma.user.update.mockResolvedValue(null) + + await getUsers({}) + + expect(getUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getUsersQueryMock).toHaveBeenCalledWith({ AND: [] }) + }) + it('should query with filter adminRoleIds', async () => { + prisma.user.update.mockResolvedValue(null) + + await getUsers({ adminRoleIds: [adminRoleId] }) + + expect(getUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getUsersQueryMock).toHaveBeenCalledWith({ AND: [{ adminRoleIds: { hasEvery: [adminRoleId] } }] }) + }) + }) + + describe('getMatchingUsers', () => { + const AND = [ + { + OR: [ + { + email: { + contains: 'abc', + mode: 'insensitive', + }, + }, + { + firstName: { + contains: 'abc', + mode: 'insensitive', + }, + }, + { + lastName: { + contains: 'abc', + mode: 'insensitive', + }, + }, + ], + }, + { + type: 'human', + }, + ] + it('should query only with letters ', async () => { + prisma.user.update.mockResolvedValue(null) + + await getMatchingUsers({ letters: 'abc' }) + + expect(getMatchingUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getMatchingUsersQueryMock).toHaveBeenCalledWith({ AND }) + }) + it('should query with letters and projectId', async () => { + prisma.user.update.mockResolvedValue(null) + + await getMatchingUsers({ letters: 'abc', notInProjectId: projectId }) + + expect(getMatchingUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getMatchingUsersQueryMock).toHaveBeenCalledWith({ AND: [...[{ + projectMembers: { + none: { + projectId, + }, + }, + }, { + projectsOwned: { + none: { + id: projectId, + }, + }, + }], ...AND] }) + }) + }) + describe('logViaSession', () => { + // ça ne teste pas tout mais c'est déjà bien hein + const adminRoles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: 0n, + position: 0, + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '/admin', + permissions: 0n, + position: 0, + }] + const userToLog: UserDetails = { + id: faker.string.uuid(), + email: user.email, + firstName: user.firstName, + groups: [], + lastName: user.lastName, + } + it('should create user and return adminPerms', async () => { + prisma.adminRole.findMany.mockResolvedValue(adminRoles) + prisma.user.findUnique.mockResolvedValue(undefined) + prisma.user.create.mockResolvedValue(user) + prisma.user.update.mockResolvedValue(user) + const response = await logViaSession(userToLog) + expect(response.adminPerms).toBe(0n) + expect(prisma.user.create).toHaveBeenCalledTimes(1) + }) + it('should update user and return adminPerms', async () => { + prisma.adminRole.findMany.mockResolvedValue(adminRoles) + prisma.user.findUnique.mockResolvedValue(user) + prisma.user.update.mockResolvedValue(user) + const response = await logViaSession(userToLog) + expect(response.adminPerms).toEqual(0n) + expect(prisma.user.create).toHaveBeenCalledTimes(0) + }) + }) +}) + +describe('logViaToken', () => { + const nextYear = new Date() + const lastYear = new Date() + nextYear.setFullYear((new Date()).getFullYear() + 1) + lastYear.setFullYear((new Date()).getFullYear() - 1) + const baseToken = { + createdAt: new Date(), + hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + id: faker.string.uuid(), + lastUse: null, + permissions: 2n, + userId: null, + status: 'active', + } as const + + beforeEach(() => { + // Mock the global roles lookup + prisma.adminRole.findMany.mockResolvedValue([]) + }) + + it('should return identity', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken }) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(2n) + }) + + it('should return identity based on pat', async () => { + const pat = structuredClone(baseToken) + delete pat.permissions + pat.owner = { adminRoleIds: null } + prisma.personalAccessToken.findFirst.mockResolvedValueOnce(pat) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(0n) + }) + + it('should return identity, with expirationDate', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, expirationDate: nextYear }) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(2n) + }) + + it('should return cause revoked', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, status: 'revoked' }) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.INACTIVE) + }) + + it('should return cause expired', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, expirationDate: lastYear }) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.EXPIRED) + }) + + it('should return cause not found', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce(undefined) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.NOT_FOUND) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/user/business.ts b/.jjconflict-base-1/apps/server/src/resources/user/business.ts new file mode 100644 index 0000000000..acbf67bb8e --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/user/business.ts @@ -0,0 +1,221 @@ +import type { userContract, XOR } from '@cpn-console/shared' +import type { AdminRole, AdminToken, PersonalAccessToken, Prisma, User } from '@prisma/client' +import type { UserDetails } from '@/types/index.js' +import { createHash } from 'node:crypto' +import prisma from '@/prisma.js' +import { getMatchingUsers as getMatchingUsersQuery, getUsers as getUsersQuery } from '@/resources/queries-index.js' +import { BadRequest400 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function getUsers(query: typeof userContract.getAllUsers.query._type, relationType: 'OR' | 'AND' = 'AND') { + const whereInputs: Prisma.UserWhereInput[] = [] + if (query.adminRoleIds?.length) { + whereInputs.push({ adminRoleIds: { hasEvery: query.adminRoleIds } }) + } + if (query.adminRoles?.length) { + const roles = query.adminRoles + ? await prisma.adminRole.findMany({ where: { name: { in: query.adminRoles } } }) + : [] + + const adminRoleNameNotFound = query.adminRoles?.find(nameQueried => !roles.some(({ name }) => name === nameQueried)) + if (adminRoleNameNotFound) { + return new BadRequest400(`Unable to find adminRole ${adminRoleNameNotFound}`) + } + whereInputs.push({ adminRoleIds: { hasEvery: roles.map(({ id }) => id) } }) + } + if (query.memberOfIds) { + whereInputs.push({ + AND: query.memberOfIds.map(id => ({ + OR: [ + { projectsOwned: { some: { id } } }, + { ProjectMembers: { some: { project: { id } } } }, + ], + })), + }) + } + + return getUsersQuery({ [relationType]: whereInputs }) +} + +export async function getMatchingUsers(query: typeof userContract.getMatchingUsers.query._type) { + const AND: Prisma.UserWhereInput[] = [] + if (query.notInProjectId) { + AND.push({ projectMembers: { none: { projectId: query.notInProjectId } } }) + AND.push({ projectsOwned: { none: { id: query.notInProjectId } } }) + } + const filter = { contains: query.letters, mode: 'insensitive' } as const // Default value: default + if (query.letters) { + AND.push({ + OR: [{ + email: filter, + }, { + firstName: filter, + }, { + lastName: filter, + }], + }) + AND.push({ type: 'human' }) + } + + return getMatchingUsersQuery({ + AND, + }) +} + +export async function patchUsers(users: typeof userContract.patchUsers.body._type) { + const userIds = users.map(u => u.id) + const usersBefore = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, adminRoleIds: true }, + }) + + for (const user of users) { + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + adminRoleIds: user.adminRoleIds, + }, + }) + } + + const impactedRoleIds = new Set() + for (const user of users) { + const before = usersBefore.find(u => u.id === user.id) + if (before) { + before.adminRoleIds.forEach(id => impactedRoleIds.add(id)) + } + user.adminRoleIds?.forEach(id => impactedRoleIds.add(id)) + } + + for (const roleId of impactedRoleIds) { + await hook.adminRole.upsert(roleId) + } + + return prisma.user.findMany({ + where: { + id: { in: users.map(({ id }) => id) }, + }, + }) +} + +export enum TokenInvalidReason { + INACTIVE = 'Not active', + EXPIRED = 'Expired', + NOT_FOUND = 'Not authenticated', +} + +export type UserTrial = Omit +export async function logViaSession({ id, email, groups, ...user }: UserTrial): Promise<{ user: User, adminPerms: bigint }> { + let userDb = await prisma.user.findUnique({ + where: { id }, + }) + + if (!userDb) { + userDb = await prisma.user.create({ data: { email, id, ...user, adminRoleIds: [], type: 'human' } }) + } + + const matchingAdminRoles = await prisma.adminRole.findMany({ + where: { OR: [{ oidcGroup: { in: groups } }, { id: { in: userDb.adminRoleIds } }, { type: 'global' }] }, + }) + + const oidcRoleIds = matchingAdminRoles + .filter(({ oidcGroup }) => oidcGroup && groups.includes(oidcGroup)) + .map(({ id }) => id) + + // On enregistre en bdd uniquement les roles de l'utilisateur + // qui ne viennent pas de keycloak + const updatedUser = await prisma.user.update({ where: { id }, data: { ...user, lastLogin: (new Date()).toISOString() } }) + .then(user => ({ ...user, adminRoleIds: [...new Set([...user.adminRoleIds, ...oidcRoleIds])] })) + return { + user: updatedUser, + adminPerms: sumAdminPerms(matchingAdminRoles), + } +} + +type UserWithTokenId = Omit & { tokenId: string } +export async function logViaToken(pass: string): Promise<({ user: UserWithTokenId, adminPerms: bigint }) | TokenInvalidReason> { + const passHash = createHash('sha256').update(pass).digest('hex') + + let token: (XOR & { owner: User }) | TokenInvalidReason | undefined + const tokenLoginMethods = [findPersonalAccessToken, findAdminToken] + for (const tokenLoginMethod of tokenLoginMethods) { + token = await tokenLoginMethod(passHash) + if (token) { + break + } + } + + if (typeof token === 'string') { + return token + } + if (!token) { + return TokenInvalidReason.NOT_FOUND + } + + const globalRoles = await prisma.adminRole.findMany({ where: { type: 'global' }, select: { permissions: true } }) + const globalPerms = globalRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + const adminPerms = token?.permissions ?? await getAdminRolesAndSum(token.owner.adminRoleIds) + + return { + user: { + ...token.owner, + tokenId: token.id, + }, + adminPerms: globalPerms | adminPerms, + } +} + +function isTokenInvalid(token: AdminToken | PersonalAccessToken): TokenInvalidReason | undefined { + if (token.status !== 'active') { + return TokenInvalidReason.INACTIVE + } + const currentDate = new Date() + if (token.expirationDate && currentDate.getTime() > token.expirationDate?.getTime()) { + return TokenInvalidReason.EXPIRED + } +} + +function sumAdminPerms(roles: AdminRole[]): bigint { + if (!roles.length) { + return 0n + } + return roles.reduce((acc, curr) => acc | curr.permissions, 0n) +} + +async function getAdminRolesAndSum(roles: AdminRole['id'][] | null): Promise { + if (!roles?.length) { + return 0n + } + return sumAdminPerms(await prisma.adminRole.findMany({ + where: { id: { in: roles } }, + })) +} + +// List all token tpe authentication +async function findPersonalAccessToken(digest: string): Promise<(PersonalAccessToken & { owner: User }) | undefined | TokenInvalidReason> { + const token = await prisma.personalAccessToken.findFirst({ where: { hash: digest }, include: { owner: true } }) + if (!token) + return undefined + const invalidReason = isTokenInvalid(token) + if (invalidReason) { + return invalidReason + } + await prisma.personalAccessToken.update({ where: { id: token.id }, data: { lastUse: (new Date()).toISOString() } }) + await prisma.user.update({ where: { id: token.owner.id }, data: { lastLogin: (new Date()).toISOString() } }) + return token +} + +async function findAdminToken(digest: string): Promise<(AdminToken & { owner: User }) | undefined | TokenInvalidReason> { + const token = await prisma.adminToken.findFirst({ where: { hash: digest }, include: { owner: true } }) + if (!token) + return undefined + const invalidReason = isTokenInvalid(token) + if (invalidReason) { + return invalidReason + } + await prisma.adminToken.update({ where: { id: token.id }, data: { lastUse: (new Date()).toISOString() } }) + await prisma.user.update({ where: { id: token.userId }, data: { lastLogin: (new Date()).toISOString() } }) + return token +} diff --git a/.jjconflict-base-1/apps/server/src/resources/user/queries.ts b/.jjconflict-base-1/apps/server/src/resources/user/queries.ts new file mode 100644 index 0000000000..1e33a8128b --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/user/queries.ts @@ -0,0 +1,60 @@ +import type { Prisma, User } from '@prisma/client' +import prisma from '@/prisma.js' + +type UserCreate = Omit + +// SELECT +export const getUsers = (where?: Prisma.UserWhereInput) => prisma.user.findMany({ where }) + +export async function getUserInfos(id: User['id']) { + return prisma.user.findMany({ + where: { id }, + include: { + logs: true, + }, + }) +} + +export function getMatchingUsers(where: Prisma.UserWhereInput) { + return prisma.user.findMany({ + where, + take: 5, + }) +} + +export function getUserById(id: User['id']) { + return prisma.user.findUnique({ where: { id } }) +} + +export function getUserOrThrow(id: User['id']) { + return prisma.user.findUniqueOrThrow({ + where: { id }, + }) +} + +export function getUserByEmail(email: User['email']) { + return prisma.user.findUnique({ where: { email } }) +} + +// CREATE +export async function createUser({ id, email, firstName, lastName, type }: UserCreate) { + const user = await getUserByEmail(email) + if (user) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') + return prisma.user.create({ data: { id, email, firstName, lastName, type } }) +} + +// UPDATE +export async function updateUserById({ id, email, firstName, lastName }: UserCreate) { + const user = await getUserById(id) + const isEmailAlreadyTaken = await getUserByEmail(email) + if (!user) throw new Error('L\'utilisateur demandé n\'existe pas') + if (isEmailAlreadyTaken) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') + if (user && !isEmailAlreadyTaken) { + return prisma.user.update({ where: { id }, data: { email, firstName, lastName } }) + } +} + +// TECH +export function _createUser(data: Prisma.UserCreateInput) { + return prisma.user.upsert({ where: { id: data.id }, create: data, update: data }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/user/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/user/router.spec.ts new file mode 100644 index 0000000000..f47c9aa311 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/user/router.spec.ts @@ -0,0 +1,142 @@ +import { ADMIN_PERMS, userContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos, setRequestor } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetMatchingMock = vi.spyOn(business, 'getMatchingUsers') +const businessLogViaSessionMock = vi.spyOn(business, 'logViaSession') +const businessGetUsersMock = vi.spyOn(business, 'getUsers') +const businessPatchMock = vi.spyOn(business, 'patchUsers') + +describe('test userContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getMatchingUsers', () => { + it('should return matching users', async () => { + const usersMatching: any[] = [] + businessGetMatchingMock.mockResolvedValueOnce(usersMatching) + + const response = await app.inject() + .get(userContract.getMatchingUsers.path) + .query({ letters: faker.person.fullName() }) + .end() + + expect(businessGetMatchingMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(usersMatching) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('auth', () => { + it('should return logged user', async () => { + const user = { + id: faker.string.uuid(), + adminRoleIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + type: 'human' as const, + lastName: faker.person.lastName(), + lastLogin: (new Date()).toISOString(), + } + // @ts-ignore + setRequestor(user) + // @ts-ignore + businessLogViaSessionMock.mockResolvedValueOnce({ user, adminPerms: 0n }) + + const response = await app.inject() + .get(userContract.auth.path) + .end() + + expect(businessLogViaSessionMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(user) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getAllUsers', () => { + it('should return all users for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + const users: any[] = [] + authUserMock.mockResolvedValueOnce(user) + businessGetUsersMock.mockResolvedValueOnce(users) + + const response = await app.inject() + .get(userContract.getAllUsers.path) + .query({ role: 'admin' }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetUsersMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(users) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(userContract.getAllUsers.path) + .query({ role: 'admin' }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetUsersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('patchUsers', () => { + const usersPatchData = [{ + id: faker.string.uuid(), + adminRoleIds: [], + }] + const usersReturn = [{ + id: faker.string.uuid(), + adminRoleIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + type: 'human', + }] + + it('should patch and return users for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + authUserMock.mockResolvedValueOnce(user) + + businessPatchMock.mockResolvedValueOnce(usersReturn) + const response = await app.inject() + .patch(userContract.patchUsers.path) + .body(usersPatchData) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessPatchMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(usersReturn) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(userContract.patchUsers.path) + .body(usersPatchData) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessPatchMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/user/router.ts b/.jjconflict-base-1/apps/server/src/resources/user/router.ts new file mode 100644 index 0000000000..e745dbec43 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/user/router.ts @@ -0,0 +1,72 @@ +import type { + UserTrial, +} from './business.js' +import { AdminAuthorized, userContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + getMatchingUsers, + getUsers, + logViaSession, + patchUsers, +} from './business.js' +import '@/types/index.js' + +export function userRouter() { + return serverInstance.router(userContract, { + getMatchingUsers: async ({ query }) => { + const usersMatching = await getMatchingUsers(query) + + return { + status: 200, + body: usersMatching, + } + }, + + auth: async ({ request: req }) => { + const user = req.session.user + + if (!user) return new Unauthorized401() + + const { user: body } = await logViaSession(user as unknown as UserTrial) + + return { + status: 200, + body, + } + }, + + getAllUsers: async ({ + request: req, + query: { relationType, ...query }, + }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageUsers(perms.adminPermissions)) + return new Forbidden403() + + const body = await getUsers(query, relationType) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + patchUsers: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageUsers(perms.adminPermissions)) + return new Forbidden403() + + const users = await patchUsers(body) + + return { + status: 200, + body: users, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/user/tokens/business.ts b/.jjconflict-base-1/apps/server/src/resources/user/tokens/business.ts new file mode 100644 index 0000000000..e378d3b79b --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/user/tokens/business.ts @@ -0,0 +1,51 @@ +import type { personalAccessTokenContract } from '@cpn-console/shared' +import type { AdminToken, User } from '@prisma/client' +import { createHash } from 'node:crypto' +import { generateRandomPassword, isAtLeastTomorrow } from '@cpn-console/shared' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../../prisma.js' + +export async function listTokens(userId: User['id']) { + return prisma.personalAccessToken.findMany({ + omit: { hash: true }, + include: { owner: true }, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + where: { userId }, + }) +} + +export async function createToken(data: typeof personalAccessTokenContract.createPersonalAccessToken.body._type, userId: User['id']) { + if (data.expirationDate && !isAtLeastTomorrow(new Date(data.expirationDate))) { + return new BadRequest400('Date d\'expiration trop courte') + } + const password = generateRandomPassword(48, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-') + const hash = createHash('sha256').update(password).digest('hex') + const token = await prisma.personalAccessToken.create({ + data: { + ...data, + hash, + expirationDate: new Date(data.expirationDate), + userId, + }, + omit: { hash: true }, + include: { owner: true }, + }) + return { + ...token, + password, + } +} + +export async function deleteToken(id: AdminToken['id'], userId: User['id']) { + const token = await prisma.personalAccessToken.findUnique({ + where: { + id, + userId, + }, + }) + if (token) { + return prisma.personalAccessToken.delete({ + where: { id }, + }) + } +} diff --git a/.jjconflict-base-1/apps/server/src/resources/user/tokens/router.ts b/.jjconflict-base-1/apps/server/src/resources/user/tokens/router.ts new file mode 100644 index 0000000000..369ded2b1a --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/user/tokens/router.ts @@ -0,0 +1,48 @@ +import { personalAccessTokenContract } from '@cpn-console/shared' + +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { createToken, deleteToken, listTokens } from './business.js' +import '@/types/index.js' + +export function personalAccessTokenRouter() { + return serverInstance.router(personalAccessTokenContract, { + listPersonalAccessTokens: async ({ request: req }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + const body = await listTokens(perms.user.id) + + return { + status: 200, + body, + } + }, + + createPersonalAccessToken: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + const body = await createToken(data, perms.user.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + deletePersonalAccessToken: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + await deleteToken(params.tokenId, perms.user.id) + + return { + status: 204, + body: null, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/zone/business.spec.ts b/.jjconflict-base-1/apps/server/src/resources/zone/business.spec.ts new file mode 100644 index 0000000000..2b42464e26 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/zone/business.spec.ts @@ -0,0 +1,133 @@ +import type { Cluster, Zone } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400 } from '../../utils/errors.ts' +import { createZone, deleteZone, listZones, updateZone } from './business.ts' +import * as queries from './queries.js' + +const userId = faker.string.uuid() +const reqId = faker.string.uuid() +const linkZoneToClustersMock = vi.spyOn(queries, 'linkZoneToClusters') +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +describe('test zone business', () => { + const zones: Zone[] = [{ + id: faker.string.uuid(), + label: faker.string.alphanumeric(), + argocdUrl: faker.internet.url(), + createdAt: new Date(), + updatedAt: new Date(), + description: faker.lorem.lines(1), + slug: faker.string.alphanumeric(5), + }, { + id: faker.string.uuid(), + label: faker.string.alphanumeric(), + argocdUrl: faker.internet.url(), + createdAt: new Date(), + updatedAt: new Date(), + description: faker.lorem.lines(1), + slug: faker.string.alphanumeric(6), + }] + + const clusters: Pick[] = [ + { id: faker.string.uuid() }, + { id: faker.string.uuid() }, + ] + + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listZones', () => { + it('should return zones', async () => { + prisma.zone.findMany.mockResolvedValueOnce(zones) + + const response = await listZones() + expect(response).toEqual(zones) + }) + }) + describe('createZone', () => { + it('should create zone without description and clusterIds', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl } + + hook.zone.upsert.mockResolvedValue({}) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).toEqual(zones[0]) + expect(prisma.zone.create).toHaveBeenCalledWith({ + data: { + slug: newZone.slug, + label: newZone.label, + argocdUrl: newZone.argocdUrl, + description: undefined, + }, + }) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(0) + }) + it('should create zone with description and clusterIds', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl, clusterIds: clusters.map(({ id }) => id), description: faker.lorem.lines(2) } + + hook.zone.upsert.mockResolvedValue({}) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).toEqual(zones[0]) + expect(prisma.zone.create).toHaveBeenCalledWith({ + data: { + description: newZone.description, + label: newZone.label, + argocdUrl: newZone.argocdUrl, + slug: newZone.slug, + }, + }) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(1) + }) + it('should not create zone, conflict label', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl } + + prisma.zone.findUnique.mockResolvedValueOnce(zones[0]) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).instanceOf(BadRequest400) + expect(prisma.zone.create).toHaveBeenCalledTimes(0) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(0) + }) + }) + describe('updateZone', () => { + it('should filter keys and update zone', async () => { + prisma.zone.update.mockResolvedValueOnce(zones[0]) + hook.zone.upsert.mockResolvedValue({}) + await updateZone(zones[0].id, { + description: '', + label: zones[0].label, + argocdUrl: zones[0].argocdUrl, + extraKey: 1, + }, userId, reqId) + expect(prisma.zone.update).toHaveBeenCalledWith({ where: { id: zones[0].id }, data: { + description: '', + label: zones[0].label, + argocdUrl: zones[0].argocdUrl, + } }) + }) + }) + describe('deleteZone', () => { + it('should not delete zone, cluster attached', async () => { + prisma.cluster.findFirst.mockResolvedValueOnce(clusters[0]) + const response = await deleteZone(zones[0].id, userId, reqId) + expect(response).instanceOf(BadRequest400) + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + }) + it('should delete zone', async () => { + prisma.cluster.findFirst.mockResolvedValueOnce(undefined) + hook.zone.delete.mockResolvedValue({}) + const response = await deleteZone(zones[0].id, userId, reqId) + expect(response).toEqual(null) + expect(prisma.zone.delete).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/zone/business.ts b/.jjconflict-base-1/apps/server/src/resources/zone/business.ts new file mode 100644 index 0000000000..eb3155ce60 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/zone/business.ts @@ -0,0 +1,78 @@ +import type { User, Zone } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { BadRequest400, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' +import { addLogs } from '../queries-index.js' +import { linkZoneToClusters } from './queries.js' + +export const listZones = prisma.zone.findMany + +export async function createZone( + data: { slug: string, label: string, argocdUrl: string, description?: string | null, clusterIds?: string[] }, + userId: User['id'], + requestId: string, +) { + const { slug, label, argocdUrl, description, clusterIds } = data + + const existingZone = await prisma.zone.findUnique({ + where: { slug }, + }) + + if (existingZone) return new BadRequest400(`Une zone portant le nom ${slug} existe déjà.`) + const zone = await prisma.zone.create({ + data: { + slug, + label, + argocdUrl, + description, + }, + }) + if (clusterIds) { + await linkZoneToClusters(zone.id, clusterIds) + } + const hookReply = await hook.zone.upsert(zone.id) + await addLogs({ action: 'Create zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la création de la zone') + } + return zone +} + +export async function updateZone( + zoneId: Zone['id'], + data: Pick, + userId: User['id'], + requestId: string, +) { + const { label, argocdUrl, description } = data + + const updatedZone = await prisma.zone.update({ + where: { + id: zoneId, + }, + data: { + label, + argocdUrl, + description, + }, + }) + const hookReply = await hook.zone.upsert(updatedZone.id) + await addLogs({ action: 'Update zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la mise à jour de la zone') + } + return updatedZone +} + +export async function deleteZone(zoneId: Zone['id'], userId: User['id'], requestId: string) { + const attachedCluster = await prisma.cluster.findFirst({ where: { zoneId }, select: { id: true } }) + if (attachedCluster) return new BadRequest400('Vous ne pouvez supprimer cette zone, car des clusters y sont associés.') + + const hookReply = await hook.zone.delete(zoneId) + await addLogs({ action: 'Delete zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la suppression de la zone') + } + await prisma.zone.delete({ where: { id: zoneId } }) + return null +} diff --git a/.jjconflict-base-1/apps/server/src/resources/zone/queries.ts b/.jjconflict-base-1/apps/server/src/resources/zone/queries.ts new file mode 100644 index 0000000000..1390bb153a --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/zone/queries.ts @@ -0,0 +1,21 @@ +import type { Cluster, Zone } from '@prisma/client' +import prisma from '@/prisma.js' + +export function getZoneByIdOrThrow(id: Zone['id']) { + return prisma.zone.findUniqueOrThrow({ + where: { id }, + }) +} + +export function linkZoneToClusters(zoneId: Zone['id'], clusterIds: Cluster['id'][]) { + return prisma.zone.update({ + where: { + id: zoneId, + }, + data: { + clusters: { + connect: clusterIds.map(clusterId => ({ id: clusterId })), + }, + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/resources/zone/router.spec.ts b/.jjconflict-base-1/apps/server/src/resources/zone/router.spec.ts new file mode 100644 index 0000000000..0ee9c7f394 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/zone/router.spec.ts @@ -0,0 +1,164 @@ +import type { Zone } from '@cpn-console/shared' +import { ADMIN_PERMS, zoneContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listZones') +const businessCreateMock = vi.spyOn(business, 'createZone') +const businessUpdateMock = vi.spyOn(business, 'updateZone') +const businessDeleteMock = vi.spyOn(business, 'deleteZone') + +describe('test zoneContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listZones', () => { + it('should return list of zones', async () => { + const zones: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_ZONES) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(zones) + + const response = await app.inject() + .get(zoneContract.listZones.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(zones) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('createZone', () => { + const zone = { id: faker.string.uuid(), label: faker.string.alpha({ length: 5 }), argocdUrl: faker.internet.url(), slug: faker.string.alpha({ length: 5, casing: 'lower' }), description: '' } + + it('should create and return zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(zone) + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(zone) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateZone', () => { + const zoneId = faker.string.uuid() + const zone: Omit = { label: faker.string.alpha({ length: 5 }), slug: faker.string.alpha({ length: 5, casing: 'lower' }), argocdUrl: faker.internet.url(), description: '' } + + it('should update and return zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: zoneId, ...zone }) + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: zoneId, ...zone }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteZone', () => { + it('should delete zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/resources/zone/router.ts b/.jjconflict-base-1/apps/server/src/resources/zone/router.ts new file mode 100644 index 0000000000..c682826c3e --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/resources/zone/router.ts @@ -0,0 +1,67 @@ +import { AdminAuthorized, zoneContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { createZone, deleteZone, listZones, updateZone } from './business.js' + +export function zoneRouter() { + return serverInstance.router(zoneContract, { + listZones: async () => { + const zones = await listZones() + + return { + status: 200, + body: zones, + } + }, + + createZone: async ({ request: req, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const body = await createZone(data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + updateZone: async ({ request: req, params, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const zoneId = params.zoneId + + const body = await updateZone(zoneId, data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + deleteZone: async ({ request: req, params }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + const zoneId = params.zoneId + + const body = await deleteZone(zoneId, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-base-1/apps/server/src/runtime.ts b/.jjconflict-base-1/apps/server/src/runtime.ts new file mode 100644 index 0000000000..d5b8822533 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/runtime.ts @@ -0,0 +1,22 @@ +import { Agent, cacheStores, interceptors, ProxyAgent, setGlobalDispatcher } from 'undici' + +const base = process.env.HTTP_PROXY ? new ProxyAgent(process.env.HTTP_PROXY) : new Agent() + +// Undici’s cache interceptor follows RFC 7234: +// 1. Only GET/HEAD are cached (configurable via `methods`). +// 2. Cache-Control directives are obeyed: +// - `no-store` → never cache +// - `max-age`, `s-maxage`, `expires` → freshness lifetime +// - `private` → skip if shared store +// - `no-cache` → revalidate with ETag/Last-Modified +// 3. Heuristic caching (status 200 without explicit freshness) +// uses 10 % of time since Last-Modified if present. +// 4. Stale responses are served while revalidating in background +// when `stale-while-revalidate` is present. +const client = base.compose( + interceptors.cache({ + store: new cacheStores.SqliteCacheStore(), + methods: ['GET', 'HEAD'], + }), +) +setGlobalDispatcher(client) diff --git a/.jjconflict-base-1/apps/server/src/server.spec.ts b/.jjconflict-base-1/apps/server/src/server.spec.ts new file mode 100644 index 0000000000..5dc093888a --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/server.spec.ts @@ -0,0 +1,57 @@ +import { logger } from '@cpn-console/logger' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { closeConnections } from './connect.js' +import { exitGracefully, handleExit } from './server.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) +vi.mock('./init/db/index.js', () => ({ initDb: vi.fn() })) +vi.mock('./connect.js') + +vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + +vi.mock('./prepare-app.js', () => { + const app = { + listen: vi.fn(), + close: vi.fn(async () => {}), + } + return { + getPreparedApp: () => Promise.resolve(app), + } +}) +vi.spyOn(logger, 'warn') +vi.spyOn(logger, 'debug') +const infoSpy = vi.spyOn(logger, 'info') +const errorSpy = vi.spyOn(logger, 'error') +const fatalSpy = vi.spyOn(logger, 'fatal') + +describe('server', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should call closeConnections without parameter', async () => { + await exitGracefully() + + expect(closeConnections).toHaveBeenCalledTimes(1) + expect(vi.mocked(closeConnections).mock.calls[0]).toHaveLength(0) + expect(errorSpy).toHaveBeenCalledTimes(0) + }) + + it('should log an error', async () => { + await exitGracefully(new Error('error')) + + expect(closeConnections).toHaveBeenCalledTimes(1) + expect(vi.mocked(closeConnections).mock.calls[0]).toHaveLength(0) + expect(fatalSpy).toHaveBeenCalledTimes(1) + expect(fatalSpy.mock.calls[0]?.[0]).toBeInstanceOf(Error) + expect(infoSpy).toHaveBeenCalledTimes(2) + }) + + it('should call process.on 4 times', () => { + const processOn = vi.spyOn(process, 'on') + + handleExit() + + expect(processOn).toHaveBeenCalledTimes(5) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/server.ts b/.jjconflict-base-1/apps/server/src/server.ts new file mode 100644 index 0000000000..93425dae79 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/server.ts @@ -0,0 +1,44 @@ +import { logger } from '@cpn-console/logger' +import { closeConnections } from './connect.js' +import { getPreparedApp } from './prepare-app.js' +import { isCI, isDev, isDevSetup, isProd, isTest, port } from './utils/env.js' + +const app = await getPreparedApp() + +try { + await app.listen({ host: '0.0.0.0', port: +(port ?? 8080) }) +} catch (error) { + logger.error({ err: error }, 'Failed to start server') + process.exit(1) +} + +logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) + +export async function exitGracefully(error?: Error) { + if (error instanceof Error) { + logger.fatal(error) + } + await app.close() + logger.info('Closing connections...') + await closeConnections() + logger.info('Exiting...') + process.exit(error instanceof Error ? 1 : 0) +} + +function logExitCode(code: number) { + logger.warn(`received signal: ${code}`) +} + +function logUnhandledRejection(reason: unknown, promise: Promise) { + logger.error({ promise, err: reason }, 'Unhandled Rejection') +} + +export function handleExit() { + process.on('exit', logExitCode) + process.on('SIGINT', exitGracefully) + process.on('SIGTERM', exitGracefully) + process.on('uncaughtException', exitGracefully) + process.on('unhandledRejection', logUnhandledRejection) +} + +handleExit() diff --git a/.jjconflict-base-1/apps/server/src/telemetry.ts b/.jjconflict-base-1/apps/server/src/telemetry.ts new file mode 100644 index 0000000000..65b59dddab --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/telemetry.ts @@ -0,0 +1,26 @@ +import { FastifyOtelInstrumentation } from '@fastify/otel' +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' +import { NodeSDK } from '@opentelemetry/sdk-node' + +const sdk = new NodeSDK({ + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({}), + }), + serviceName: 'console-pi-native-console', + traceExporter: new OTLPTraceExporter({}), + instrumentations: [ + getNodeAutoInstrumentations(), + new FastifyOtelInstrumentation({ + registerOnInitialization: true, + }), + ], +}) + +sdk.start() + +process.once('beforeExit', () => { + sdk.shutdown() +}) diff --git a/.jjconflict-base-1/apps/server/src/types/index.ts b/.jjconflict-base-1/apps/server/src/types/index.ts new file mode 100644 index 0000000000..ed39a8f94b --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/types/index.ts @@ -0,0 +1,22 @@ +import { User } from '@cpn-console/shared' + +export type UserDetails = { + id: string + firstName: string + lastName: string + email: string + groups: string[] + type: User['type'], +} + +export interface Resources { + cpu: number + gpu: number + memory: number +} + +declare module 'fastify' { + interface Session { + user?: UserDetails + } +} diff --git a/.jjconflict-base-1/apps/server/src/utils/business.ts b/.jjconflict-base-1/apps/server/src/utils/business.ts new file mode 100644 index 0000000000..10fe3ced2f --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/business.ts @@ -0,0 +1,42 @@ +import type { SharedSafeParseReturnType } from '@cpn-console/shared' +import { parseZodError } from '@cpn-console/shared' +import { BadRequest400 } from './errors.js' + +export type Success = Result +export type Failure = Result +export class Result { + protected constructor( + readonly success: boolean, + readonly value: T | string, + ) {} + + static succeed(value: T): Success { + return new Result(true, value) as Success + } + + static fail(message: string): Failure { + return new Result(false, message) as Failure + } + + get isSuccess(): boolean { + return this.success + } + + get isError(): boolean { + return !this.success + } + + get data(): T { + if (this.success) return this.value as T + throw new Error('Cannot get data from a Failure') + } + + get error(): string { + if (!this.success) return this.value as string + throw new Error('Cannot get error from a Success') + } +} + +export function validateSchema(schemaValidation: SharedSafeParseReturnType) { + if (!schemaValidation.success) return new BadRequest400(parseZodError(schemaValidation.error)) +} diff --git a/.jjconflict-base-1/apps/server/src/utils/controller.ts b/.jjconflict-base-1/apps/server/src/utils/controller.ts new file mode 100644 index 0000000000..57ab7865af --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/controller.ts @@ -0,0 +1,171 @@ +import type { XOR } from '@cpn-console/shared' +import type { Cluster, Prisma, Project, ProjectMembers, ProjectRole } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserTrial } from '@/resources/user/business.js' +import type { UserDetails } from '@/types/index.js' +import { PROJECT_PERMS as PP, PROJECT_PERMS, projectIsLockedInfo, tokenHeaderName } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { logViaSession, logViaToken } from '@/resources/user/business.js' +import { Unauthorized401 } from './errors.js' +import { uuid } from './queries-tools.js' + +export type RequireOnlyOne + = Pick> + & { + [K in Keys]-?: + Required> + & Partial, undefined>> + }[Keys] + +type ErrorMessagePredicate = () => string | undefined +export function getErrorMessage(...fns: ErrorMessagePredicate[]) { + for (const f of fns) { + const error = f() + if (error) { + return error + } + } +} + +/** + * Renvoie une erreur si le projet est verrouillé + */ +export function checkProjectLocked(project: { locked: boolean }): string { + return project.locked + ? projectIsLockedInfo + : '' +} + +export function checkLocked(project: { locked: Project['locked'] }): string { + return checkProjectLocked(project) +} + +export function checkClusterUnavailable(clusterId: Cluster['id'], authorizedClusterIds: Cluster['id'][]): string { + return authorizedClusterIds.includes(clusterId) + ? '' + : 'Ce cluster n\'est pas disponible pour cette combinaison projet et stage' +} + +export const splitStringsFilterArray = >(toMatch: T, inputs: string): T => inputs.split(',').filter(i => toMatch.includes(i)) as unknown as T + +type StringArray = string[] +interface WhereBuilderParams { + enumValues: T + eqValue: T[number] | undefined + inValues: string | undefined + notInValues: string | undefined +} + +export function whereBuilder({ enumValues, eqValue, inValues, notInValues }: WhereBuilderParams) { + if (eqValue) { + return eqValue + } else if (inValues) { + return { in: splitStringsFilterArray(enumValues, inValues) } + } else if (notInValues) { + return { notIn: splitStringsFilterArray(enumValues, notInValues) } + } +} + +type ProjectMinimalPerms = Pick & { roles: ProjectRole[], members: ProjectMembers[] } +export interface UserProfile { user?: UserDetails, adminPermissions: bigint, tokenId?: string } +export interface ProjectPermState { projectPermissions?: bigint, projectId: Project['id'], projectLocked: boolean, projectStatus: Project['status'], projectOwnerId: Project['ownerId'] } +export type UserProjectProfile = UserProfile & ProjectPermState + +type ProjectUniqueFinder = XOR< + { slug: string }, + XOR<{ environmentId: string }, XOR<{ repositoryId: string }, { id: string }>> +> + +const projectPermsSelect = { roles: true, members: true, everyonePerms: true, ownerId: true, id: true, locked: true, status: true } as const satisfies Prisma.ProjectSelect + +export async function authUser(req: FastifyRequest): Promise +export async function authUser(req: FastifyRequest, projectUnique: ProjectUniqueFinder): Promise +export async function authUser(req: FastifyRequest, projectUnique?: ProjectUniqueFinder): Promise { + let adminPermissions: bigint = 0n + let tokenId: string | undefined + const reqUser: UserTrial = req.session.user as unknown as UserTrial + let user: UserDetails | undefined + + if (req.session.user) { + const loginResult = await logViaSession(reqUser) + user = { + ...loginResult.user, + groups: reqUser.groups, + } + adminPermissions = loginResult.adminPerms + } else { + const tokenHeader = req.headers[tokenHeaderName] + if (typeof tokenHeader === 'string') { + const resultToken = await logViaToken(tokenHeader) + if (typeof resultToken === 'string') { + throw new Unauthorized401(resultToken) + } + adminPermissions = resultToken.adminPerms ?? 0n + tokenId = resultToken.user.tokenId + if (!user && resultToken.user) { + user = { ...resultToken.user, groups: [] } + } + } + } + + const baseReturnInfos = { + user, + adminPermissions, + tokenId, + } + if (!projectUnique || !user) { + return baseReturnInfos + } + let project: ProjectMinimalPerms | null | undefined + + if (projectUnique.repositoryId) { + project = (await prisma.repository.findUnique({ + where: { id: projectUnique.repositoryId }, + select: { project: { select: projectPermsSelect } }, + }))?.project + } else if (projectUnique.environmentId) { + project = (await prisma.environment.findUnique({ + where: { id: projectUnique.environmentId }, + select: { project: { select: projectPermsSelect } }, + }))?.project + } else if (projectUnique.id) { + project = uuid.test(projectUnique.id) + ? await prisma.project.findUnique({ + where: { id: projectUnique.id }, + select: projectPermsSelect, + }) + : await prisma.project.findUnique({ + where: { slug: projectUnique.id }, + select: projectPermsSelect, + }) + } else if (projectUnique.slug) { + project = await prisma.project.findFirstOrThrow({ + where: { slug: projectUnique.slug }, + select: projectPermsSelect, + }) + } + if (!project) { + return baseReturnInfos + } + + const projectPermissions = getProjectPermissions(project, user) + + return { + user, + adminPermissions, + projectPermissions, + projectId: project.id, + projectLocked: project.locked, + projectStatus: project.status, + projectOwnerId: project.ownerId, + } +} + +function getProjectPermissions(project: ProjectMinimalPerms, user: UserDetails): bigint | undefined { + if (project.ownerId === user.id) return PP.MANAGE + const member = project.members.find(member => member.userId === user.id) + if (!member) return + + const memberRoles = project.roles.filter(role => member.roleIds.includes(role.id)) + return memberRoles.reduce((acc, curr) => acc | curr.permissions, project.everyonePerms | PROJECT_PERMS.GUEST) +} diff --git a/.jjconflict-base-1/apps/server/src/utils/date.spec.ts b/.jjconflict-base-1/apps/server/src/utils/date.spec.ts new file mode 100644 index 0000000000..7abcaa1aaa --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/date.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { getJSDateFromUtcIso } from './date.js' + +describe('date-util', () => { + it('should return a native Date object', () => { + const date = '2022-10-11' + + const received = getJSDateFromUtcIso(date) + + expect(received.getMonth()).toBe(9) + expect(received.getFullYear()).toBe(2022) + expect(received.getDate()).toBeGreaterThan(10) + expect(received.getDate()).toBeLessThan(12) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/utils/date.ts b/.jjconflict-base-1/apps/server/src/utils/date.ts new file mode 100644 index 0000000000..87473d2625 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/date.ts @@ -0,0 +1,5 @@ +import { parseISO } from 'date-fns' + +export function getJSDateFromUtcIso(dateUtcIso: string) { + return parseISO(dateUtcIso) +} diff --git a/.jjconflict-base-1/apps/server/src/utils/env.ts b/.jjconflict-base-1/apps/server/src/utils/env.ts new file mode 100644 index 0000000000..2ca618fd19 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/env.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +// application mode +export const isDev = process.env.NODE_ENV === 'development' +export const isTest = process.env.NODE_ENV === 'test' +export const isProd = process.env.NODE_ENV === 'production' +export const isInt = process.env.INTEGRATION === 'true' +export const isCI = process.env.CI === 'true' +export const isDevSetup = process.env.DEV_SETUP === 'true' + +// app +export const port = process.env.SERVER_PORT +export const appVersion = isProd + ? (process.env.APP_VERSION ?? 'unknown') + : 'dev' + +// db +export const dbUrl = process.env.DB_URL + +// keycloak +export const sessionSecret = process.env.SESSION_SECRET +export const keycloakProtocol = process.env.KEYCLOAK_PROTOCOL +export const keycloakDomain = process.env.KEYCLOAK_DOMAIN +export const keycloakRealm = process.env.KEYCLOAK_REALM +export const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID +export const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET +export const keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI +export const adminsUserId = process.env.ADMIN_KC_USER_ID + ? process.env.ADMIN_KC_USER_ID.split(',') + : [] + +export const contactEmail = process.env.CONTACT_EMAIL ?? 'cloudpinative-relations@interieur.gouv.fr' + +// plugins +export const mockPlugins = process.env.MOCK_PLUGINS === 'true' +export const projectRootDir = process.env.PROJECTS_ROOT_DIR +export const pluginsDir = process.env.PLUGINS_DIR ?? '/plugins' +export const NODE_ENV = process.env.NODE_ENV === 'test' + ? 'test' + : process.env.NODE_ENV === 'development' + ? 'development' + : 'production' + +// server tuning +export const parallelBulkLimit = process.env.PARALLEL_BULK_LIMIT ? Number(process.env.PARALLEL_BULK_LIMIT) : 5 diff --git a/.jjconflict-base-1/apps/server/src/utils/errors.ts b/.jjconflict-base-1/apps/server/src/utils/errors.ts new file mode 100644 index 0000000000..0f1dd07fbe --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/errors.ts @@ -0,0 +1,48 @@ +export class ErrorResType { + readonly status: 400 | 401 | 403 | 404 | 422 | 500 + body: { message: string } = { message: '' } + constructor(code: 400 | 401 | 403 | 404 | 422 | 500) { + this.status = code + } +} +export class BadRequest400 extends ErrorResType { + constructor(message: string) { + super(400) + this.body.message = message ?? 'Bad Request' + } +} + +export class Unauthorized401 extends ErrorResType { + constructor(message?: string) { + super(401) + this.body.message = message ?? 'Unauthorized' + } +} + +export class Forbidden403 extends ErrorResType { + constructor(message?: string) { + super(403) + this.body.message = message ?? 'Forbidden' + } +} + +export class NotFound404 extends ErrorResType { + constructor() { + super(404) + this.body.message = 'Not Found' + } +} + +export class Unprocessable422 extends ErrorResType { + constructor(message?: string) { + super(422) + this.body.message = message ?? 'Unprocessable Entity' + } +} + +export class Internal500 extends ErrorResType { + constructor(message: string) { + super(500) + this.body.message = message + } +} diff --git a/.jjconflict-base-1/apps/server/src/utils/fastify.ts b/.jjconflict-base-1/apps/server/src/utils/fastify.ts new file mode 100644 index 0000000000..c51281e3e9 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/fastify.ts @@ -0,0 +1,54 @@ +import type { FastifySwaggerUiOptions } from '@fastify/swagger-ui' +import type { generateOpenApi } from '@ts-rest/open-api' +import type { FastifyServerOptions } from 'fastify' +import { randomUUID } from 'node:crypto' +import { getLoggerOptionsFromEnv } from '@cpn-console/logger' +import { swaggerUiPath } from '@cpn-console/shared' +import { + appVersion, + keycloakClientId, + keycloakClientSecret, + keycloakRealm, + keycloakRedirectUri, +} from './env.js' + +export const fastifyConf: FastifyServerOptions = { + maxParamLength: 5000, + logger: getLoggerOptionsFromEnv(), + genReqId: () => randomUUID(), +} + +const externalDocs = { + description: 'External documentation.', + url: 'https://cloud-pi-native.fr', +} + +export const swaggerConf: Parameters[1] = { + info: { + title: 'Console Cloud Pi Native', + description: 'API de gestion des ressources Cloud Pi Native.', + version: appVersion, + }, + + externalDocs, + servers: [ + { + url: keycloakRedirectUri, + }, + ], +} + +export const swaggerUiConf: FastifySwaggerUiOptions = { + routePrefix: `${swaggerUiPath}-server`, + uiConfig: { + docExpansion: 'list', + deepLinking: false, + }, + initOAuth: { + clientId: keycloakClientId, + clientSecret: keycloakClientSecret, + realm: keycloakRealm, + appName: 'Cloud Pi Native', + scopes: 'openid generic', + }, +} diff --git a/.jjconflict-base-1/apps/server/src/utils/hook-wrapper.spec.ts b/.jjconflict-base-1/apps/server/src/utils/hook-wrapper.spec.ts new file mode 100644 index 0000000000..c2f4e3ebcc --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/hook-wrapper.spec.ts @@ -0,0 +1,239 @@ +import type { KubeCluster, KubeUser, Project as ProjectPayload, Store } from '@cpn-console/hooks' +import type { ProjectInfos, ReposCreds } from './hook-wrapper.ts' +import { describe, expect, it } from 'vitest' +import { transformToHookProject } from './hook-wrapper.ts' + +const associatedCluster = { + id: 'f0e39981-0b6d-4c16-aa96-225062b75767', + infos: '', + label: 'carno', + privacy: 'dedicated', + secretName: '4a38422c-29e1-4b61-b533-edaa1b8a9b60', + kubeconfig: { + id: 'c8ba6db2-9a1d-4d6b-8b5e-2902cecd1437', + user: { + keyData: 'REDACTED', + certData: 'REDACTED', + }, + cluster: { + caData: 'REDACTED', + server: 'https://api-server:6443', + skipTLSVerify: false, + tlsServerName: 'api-server', + }, + createdAt: '2024-05-02T09:17:27.882Z', + updatedAt: '2024-05-02T09:17:27.882Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0', + slug: 'default', + }, +} +const nonAssociatedCluster = { + id: 'f0e39981-0b6d-4c16-aa96-225062b75111', + infos: '', + label: 'carno2', + privacy: 'dedicated', + secretName: '4a38422c-29e1-4b61-b533-edaa1b8a9111', + kubeconfig: { + id: 'c8ba6db2-9a1d-4d6b-8b5e-2902cecd1111', + user: { + keyData: 'REDACTED', + certData: 'REDACTED', + }, + cluster: { + caData: 'REDACTED', + server: 'https://api-server:6443', + skipTLSVerify: false, + tlsServerName: 'api-server', + }, + createdAt: '2024-05-02T09:17:27.882Z', + updatedAt: '2024-05-02T09:17:27.882Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0', + slug: 'default', + }, +} +const project: ProjectInfos = { + id: '011e7860-04d7-461f-912d-334c622d38b3', + name: 'candilib', + description: 'Application de réservation de places à l\'examen du permis B.', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.778Z', + updatedAt: '2023-07-03T14:46:56.783Z', + everyonePerms: 896n, + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + members: [], + clusters: [associatedCluster, nonAssociatedCluster], + environments: [ + { + id: '1b9f1053-fcf5-4053-a7b2-ff8a2c0c1921', + name: 'dev', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.787Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + quota: { + id: '5a57b62f-2465-4fb6-a853-5a751d099199', + memory: '4Gi', + cpu: 2, + name: 'small', + isPrivate: false, + }, + stage: { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + name: 'dev', + }, + cluster: { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + infos: 'Floating IP : 0.0.0.0', + label: 'pas-top-cluster', + privacy: 'dedicated', + secretName: '94d52618-7869-4192-b33e-85dd0959e815', + kubeconfig: { + id: 'b5662039-a62b-483e-ba54-b12c6f966c96', + user: { + token: 'kirikou', + }, + cluster: { + server: 'https://pwned.cluster', + tlsServerName: 'pwned.cluster', + }, + createdAt: '2024-07-24T16:54:14.969Z', + updatedAt: '2024-07-24T16:54:14.969Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + }, + }, + }, + { + id: '1c654f00-4798-4a80-929f-960ddb37885a', + name: 'integration', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + quota: { + id: '5a57b62f-2465-4fb6-a853-5a751d099199', + memory: '4Gi', + cpu: 2, + name: 'small', + isPrivate: false, + }, + stage: { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + name: 'integration', + }, + cluster: { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + infos: null, + label: 'top-secret-cluster', + privacy: 'dedicated', + secretName: '59be2d50-58f9-42f3-95dc-b0c0518e3d8a', + kubeconfig: { + id: '0e88f000-07e6-4781-a69d-0963489387f7', + user: { + token: 'nyan cat', + }, + cluster: { + server: 'https://nothere.cluster', + skipTLSVerify: false, + tlsServerName: 'nothere.cluster', + }, + createdAt: '2024-07-24T16:54:14.966Z', + updatedAt: '2024-07-24T16:54:14.966Z', + }, + clusterResources: true, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + }, + }, + }, + ], + repositories: [ + { + id: '299216bb-2dcc-42b5-ac71-6aa001d2dccf', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/dnum-mi/candilib.git', + externalUserName: 'this-is-a-test', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.802Z', + }, + ], + plugins: [], + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', + email: 'test@test.com', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + }, + roles: [], +} + +describe('transformToHookProject', () => { + // Mock data + const mockStore: Store = {} + const mockReposCreds: ReposCreds = { + console: { + token: 'test', + username: 'test', + }, + } + + it('transforme correctement le projet en objet Payload', () => { + const result: ProjectPayload = transformToHookProject(project, mockStore, mockReposCreds) + + // Asserts pour vérifier la transformation + + // Assert sur la transformation des utilisateurs + expect(result.users).toEqual([project.owner]) + + // Assert sur la transformation des rôles + expect(result.roles).toEqual([{ + name: 'owner', + position: 0, + users: [project.owner], + }]) + + // Assert sur la transformation des clusters + expect(result.clusters).toEqual([associatedCluster, nonAssociatedCluster].map(({ kubeconfig, ...cluster }) => ({ + user: kubeconfig.user as unknown as KubeUser, + cluster: kubeconfig.cluster as unknown as KubeCluster, + ...cluster, + privacy: cluster.privacy, + }))) + + // Assert sur la transformation des environnements + expect(result.environments).toEqual(project.environments.map(({ permissions: _, stage, quota, ...environment }) => ({ + quota, + stage: stage.name, + permissions: [{ permissions: { rw: true, ro: true }, userId: project.ownerId }], + ...environment, + apis: {}, + }))) + + // Assert sur la transformation des repositories + expect(result.repositories).toEqual(project.repositories.map(repo => ({ ...repo, newCreds: mockReposCreds[repo.internalRepoName] }))) + + // Assert sur le store + expect(result.store).toEqual(mockStore) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/utils/hook-wrapper.ts b/.jjconflict-base-1/apps/server/src/utils/hook-wrapper.ts new file mode 100644 index 0000000000..0df3d89a4b --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/hook-wrapper.ts @@ -0,0 +1,419 @@ +import type { ClusterObject, HookResult, KubeCluster, KubeUser, Project as ProjectPayload, RepoCreds, Repository, Store, ZoneObject } from '@cpn-console/hooks' +import type { AsyncReturnType } from '@cpn-console/shared' +import type { Cluster, Kubeconfig, Project, ProjectMembers, ProjectRole, Zone } from '@prisma/client' +import type { ConfigRecords } from '@/resources/project-service/business.js' +import { hooks } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { getPermsByUserRoles, ProjectAuthorized, resourceListToDict } from '@cpn-console/shared' +import { dbToObj } from '@/resources/project-service/business.js' +import { archiveProject, getAdminPlugin, getAdminRoleById, getClusterByIdOrThrow, getClusterNamesByZoneId, getClustersAssociatedWithProject, getHookProjectInfos, getHookRepository, getProjectStore, getRole, getZoneByIdOrThrow, saveProjectStore, updateProjectClusterHistory, updateProjectCreated, updateProjectFailed, updateProjectWarning } from '@/resources/queries-index.js' +import { genericProxy } from './proxy.js' + +export type ReposCreds = Record +export type ProjectInfos = AsyncReturnType + +const logger = baseLogger.child({ scope: 'utils:hook-wrapper' }) + +function summarizeHookResultForLogs(hookResult: HookResult) { + const nonOkResults = Object.fromEntries( + Object.entries(hookResult.results ?? {}) + .filter(([_pluginName, result]) => result?.status?.result !== 'OK') + .map(([pluginName, result]) => [ + pluginName, + { + result: result.status.result, + message: result.status.result === 'OK' ? undefined : result.status.message, + }, + ]), + ) + + return { + failed: hookResult.failed, + warning: hookResult.warning, + messageResume: hookResult.messageResume, + totalExecutionTime: hookResult.totalExecutionTime, + nonOkResults, + } +} + +async function getProjectPayload(projectId: Project['id'], reposCreds?: ReposCreds) { + const [ + project, + store, + clusters, + ] = await Promise.all([ + getHookProjectInfos(projectId), + getProjectStore(projectId), + getClustersAssociatedWithProject(projectId), + ]) + + return transformToHookProject({ + ...project, + clusters, + }, dbToObj(store), reposCreds) +} + +async function upsertProject(projectId: Project['id'], reposCreds?: ReposCreds) { + const [payload, config] = await Promise.all([ + getProjectPayload(projectId, reposCreds), + getAdminPlugin(), + ]) + + const results = await hooks.upsertProject.execute(payload, dbToObj(config)) + + const records: ConfigRecords = Object.entries(results.results).reduce((acc, [pluginName, result]) => { + if (result.store) { + return [...acc, ...Object.entries(result.store).map(([key, value]) => ({ pluginName, key, value: String(value) }))] + } + return acc + }, [] as ConfigRecords) + + await saveProjectStore(records, projectId) + const project = await manageProjectStatus(projectId, results, 'upsert', payload.environments.map(env => env.clusterId)) + return { + results, + project, + } +} +const project = { + upsert: async (projectId: Project['id'], reposCreds?: ReposCreds) => { + const first = await upsertProject(projectId, reposCreds) + if (!first.results.failed) { + return first + } + + logger.warn({ + action: 'upsertProject', + projectId, + attempt: 1, + maxAttempts: 2, + ...summarizeHookResultForLogs(first.results), + }, 'Hook upsertProject failed, retrying once') + + const second = await upsertProject(projectId, reposCreds) + const logPayload = { + action: 'upsertProject', + projectId, + attempt: 2, + maxAttempts: 2, + ...summarizeHookResultForLogs(second.results), + } + if (second.results.failed) { + logger.error(logPayload, 'Hook upsertProject retry failed') + } else { + logger.info(logPayload, 'Hook upsertProject retry succeeded') + } + + return second + }, + delete: async (projectId: Project['id']) => { + const [payload, config] = await Promise.all([ + getProjectPayload(projectId), + getAdminPlugin(), + ]) + const results = await hooks.deleteProject.execute(payload, dbToObj(config)) + return { + results, + project: await manageProjectStatus(projectId, results, 'delete', []), + } + }, + getSecrets: async (projectId: Project['id']) => { + const project = await getHookProjectInfos(projectId) + const store = dbToObj(await getProjectStore(project.id)) + const config = dbToObj(await getAdminPlugin()) + + return hooks.getProjectSecrets.execute({ ...project, store }, config) + }, +} as const + +type ProjectAction = keyof typeof project +async function manageProjectStatus(projectId: Project['id'], hookReply: HookResult, action: ProjectAction, envClusterIds: Cluster['id'][]): Promise> { + if (!hookReply.failed && hookReply.results?.kubernetes) { + await updateProjectClusterHistory(projectId, envClusterIds) + } + if (hookReply.failed) { + return updateProjectFailed(projectId) + } else if (hookReply.warning.length) { + return updateProjectWarning(projectId) + } else if (action === 'upsert') { + return updateProjectCreated(projectId) + } else if (action === 'delete') { + return archiveProject(projectId) + } + throw new Error('unknown action') +} + +const cluster = { + upsert: async (clusterId: Cluster['id'], previousZoneId: Cluster['zoneId']) => { + const cluster = await getClusterByIdOrThrow(clusterId) + const clusterObject = cluster as unknown as ClusterObject + const store = dbToObj(await getAdminPlugin()) + if (cluster.zoneId !== previousZoneId) { + // Upsert on the old zone to remove cluster + const previousClusterObject = { + ...cluster, + } as unknown as ClusterObject + previousClusterObject.zone = await getZoneByIdOrThrow(previousZoneId) + previousClusterObject.zone.clusterNames = await getClusterNamesByZoneId(previousZoneId) + const hookResult = await hooks.upsertCluster.execute({ + ...cluster.kubeconfig as unknown as Pick, + ...previousClusterObject, + }, store) + if (hookResult.failed) { + return hookResult + } + } + clusterObject.zone.clusterNames = await getClusterNamesByZoneId(cluster.zoneId) + return hooks.upsertCluster.execute({ + ...cluster.kubeconfig as unknown as Pick, + ...clusterObject, + }, store) + }, + delete: async (clusterId: Cluster['id']) => { + const cluster = await getClusterByIdOrThrow(clusterId) + const clusterObject = cluster as unknown as ClusterObject + const clusterNames = await getClusterNamesByZoneId(cluster.zoneId) + clusterObject.zone.clusterNames = clusterNames.filter(c => c !== cluster.label) + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteCluster.execute({ + ...cluster.kubeconfig as unknown as ClusterObject, + ...clusterObject, + }, store) + }, +} as const + +const user = { + retrieveUserByEmail: async (email: string) => { + const config = dbToObj(await getAdminPlugin()) + return hooks.retrieveUserByEmail.execute({ email }, config) + }, +} as const + +const projectMember = { + upsert: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { + const project = await getHookProjectInfos(projectId) + const projectStore = dbToObj(await getProjectStore(project.id)) + const hookProject = transformToHookProject(project, projectStore) + const store = dbToObj(await getAdminPlugin()) + + const member = project.members.find(m => m.userId === userId) + if (!member) throw new Error('Member not found') + + const memberRoles = project.roles + .filter(role => member.roleIds.includes(role.id)) + .map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ?? undefined, + project: hookProject, + })) + + const payload = { + userId: member.userId, + roleIds: member.roleIds, + firstName: member.user.firstName, + lastName: member.user.lastName, + email: member.user.email, + type: member.user.type as 'human' | 'bot' | 'ghost', + createdAt: member.user.createdAt.toISOString(), + updatedAt: member.user.updatedAt.toISOString(), + lastLogin: member.user.lastLogin?.toISOString(), + projectId: project.id, + roles: memberRoles, + project: hookProject, + } + + return hooks.upsertProjectMember.execute(payload, store) + }, + delete: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { + const project = await getHookProjectInfos(projectId) + const projectStore = dbToObj(await getProjectStore(project.id)) + const hookProject = transformToHookProject(project, projectStore) + const store = dbToObj(await getAdminPlugin()) + + const member = project.members.find(m => m.userId === userId) + if (!member) throw new Error('Member not found') + + const memberRoles = project.roles + .filter(role => member.roleIds.includes(role.id)) + .map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ?? undefined, + project: hookProject, + })) + + const payload = { + userId: member.userId, + roleIds: member.roleIds, + firstName: member.user.firstName, + lastName: member.user.lastName, + email: member.user.email, + type: member.user.type as 'human' | 'bot' | 'ghost', + createdAt: member.user.createdAt.toISOString(), + updatedAt: member.user.updatedAt.toISOString(), + lastLogin: member.user.lastLogin?.toISOString(), + projectId: project.id, + roles: memberRoles, + project: hookProject, + } + + return hooks.deleteProjectMember.execute(payload, store) + }, +} as const + +const projectRole = { + upsert: async (roleId: ProjectRole['id']) => { + const role = await getRole(roleId) + if (!role) throw new Error('Role not found') + + const project = await getHookProjectInfos(role.projectId) + const projectStore = dbToObj(await getProjectStore(role.projectId)) + const hookProject = transformToHookProject(project, projectStore) + + const rolePayload = { + ...role, + permissions: role.permissions.toString(), + project: hookProject, + } + const store = dbToObj(await getAdminPlugin()) + return hooks.upsertProjectRole.execute(rolePayload, store) + }, + delete: async (roleId: ProjectRole['id']) => { + const role = await getRole(roleId) + if (!role) throw new Error('Role not found') + + const project = await getHookProjectInfos(role.projectId) + const projectStore = dbToObj(await getProjectStore(role.projectId)) + const hookProject = transformToHookProject(project, projectStore) + + const rolePayload = { + ...role, + permissions: role.permissions.toString(), + project: hookProject, + } + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteProjectRole.execute(rolePayload, store) + }, +} as const + +const zone = { + upsert: async (zoneId: Zone['id']) => { + const zone: ZoneObject = await getZoneByIdOrThrow(zoneId) + zone.clusterNames = await getClusterNamesByZoneId(zoneId) + const store = dbToObj(await getAdminPlugin()) + return hooks.upsertZone.execute(zone, store) + }, + delete: async (zoneId: Zone['id']) => { + const zone = await getZoneByIdOrThrow(zoneId) + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteZone.execute(zone, store) + }, +} as const + +const misc = { + checkServices: async () => { + const config = dbToObj(await getAdminPlugin()) + return hooks.checkServices.execute({}, config) + }, + syncRepository: async (repoId: string, { syncAllBranches, branchName }: { syncAllBranches: boolean, branchName?: string }) => { + const { project, ...repoInfos } = await getHookRepository(repoId) + const store = dbToObj(await getProjectStore(project.id)) + const payload = { + repo: { ...repoInfos, syncAllBranches, branchName }, + ...project, + store, + } + const config = dbToObj(await getAdminPlugin()) + return hooks.syncRepository.execute(payload, config) + }, +} as const + +const adminRole = { + upsert: async (roleId: string) => { + const role = await getAdminRoleById(roleId) + if (!role) throw new Error('Role not found') + const config = dbToObj(await getAdminPlugin()) + return hooks.upsertAdminRole.execute(role, config) + }, + delete: async (role: AsyncReturnType) => { + if (!role) throw new Error('Role is required') + const config = dbToObj(await getAdminPlugin()) + return hooks.deleteAdminRole.execute(role, config) + }, +} as const + +export const hook = { + // @ts-ignore TODO voir comment opti la signature de la fonction + misc: genericProxy(misc), + // @ts-ignore TODO voir comment opti la signature de la fonction + project: genericProxy(project, { upsert: ['delete'], delete: ['upsert', 'delete'], getSecrets: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + projectRole: genericProxy(projectRole, { delete: ['upsert', 'delete'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + projectMember: genericProxy(projectMember, { delete: ['upsert'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + cluster: genericProxy(cluster, { delete: ['upsert', 'delete'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + zone: genericProxy(zone, { delete: ['upsert'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + user: genericProxy(user, {}), + // @ts-ignore TODO voir comment opti la signature de la fonction + adminRole: genericProxy(adminRole, { delete: ['upsert'], upsert: ['delete'] }), +} + +function formatClusterInfos({ kubeconfig, ...cluster }: Omit + & { kubeconfig: Kubeconfig, zone: Pick }) { + return { + user: kubeconfig.user as unknown as KubeUser, + cluster: kubeconfig.cluster as unknown as KubeCluster, + ...cluster, + privacy: cluster.privacy, + } +} +export type RolesById = Record + +export function transformToHookProject(project: ProjectInfos, store: Store, reposCreds: ReposCreds = {}): ProjectPayload { + const clusters = project.clusters.map(cluster => formatClusterInfos(cluster)) + const rolesById = resourceListToDict(project.roles) + + return ({ + ...project, + clusters, + environments: project.environments.map(({ stage, ...environment }) => ({ + stage: stage.name, + permissions: [ + { permissions: { rw: true, ro: true }, userId: project.ownerId }, + ...project.members.map(member => ({ + userId: member.userId, + permissions: { + ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions: getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) }), + rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions: getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) }), + }, + })), + ], + ...environment, + apis: {}, + })), + repositories: project.repositories.map(repo => ({ ...repo, newCreds: reposCreds[repo.internalRepoName] })), + store, + users: [project.owner, ...project.members.map(({ user }) => user)], + roles: [ + { + name: 'owner', + position: 0, + users: [project.owner], + }, + ...project.roles.map(role => ({ + name: role.name, + permissions: role.permissions.toString(), + position: role.position, + type: role.type, + oidcGroup: role.oidcGroup, + users: project.members + .filter(member => member.roleIds.includes(role.id)) + .map(member => member.user), + })), + ], + }) +} diff --git a/.jjconflict-base-1/apps/server/src/utils/keycloak-utils.spec.ts b/.jjconflict-base-1/apps/server/src/utils/keycloak-utils.spec.ts new file mode 100644 index 0000000000..42c9cfae04 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/keycloak-utils.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { userPayloadMapper } from './keycloak-utils.js' + +describe('keycloak', () => { + it('should map keycloak user object to DSO user object without groups', () => { + const payload = { + sub: 'thisIsAnId', + email: 'test@test.com', + given_name: 'Jean', + family_name: 'DUPOND', + } + const desired = { + id: 'thisIsAnId', + email: 'test@test.com', + firstName: 'Jean', + lastName: 'DUPOND', + groups: [], + } + + const transformed = userPayloadMapper(payload) + + expect(transformed).toMatchObject(desired) + }) + + it('should map keycloak user object to DSO user object with groups', () => { + const payload = { + sub: 'thisIsAnId', + email: 'test@test.com', + given_name: 'Jean', + family_name: 'DUPOND', + groups: ['group1'], + } + const desired = { + id: 'thisIsAnId', + email: 'test@test.com', + firstName: 'Jean', + lastName: 'DUPOND', + groups: ['group1'], + } + + const transformed = userPayloadMapper(payload) + + expect(transformed).toMatchObject(desired) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/utils/keycloak-utils.ts b/.jjconflict-base-1/apps/server/src/utils/keycloak-utils.ts new file mode 100644 index 0000000000..a86a5f13c3 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/keycloak-utils.ts @@ -0,0 +1,27 @@ +import type { FastifyRequest } from 'fastify' +import { tokenHeaderName } from '@cpn-console/shared' + +interface KeycloakPayload { + sub: string + email: string + given_name: string + family_name: string + groups: string[] +} + +export function userPayloadMapper(userPayload: KeycloakPayload) { + return { + id: userPayload.sub, + email: userPayload.email, + firstName: userPayload.given_name, + lastName: userPayload.family_name, + groups: userPayload.groups || [], + } +} + +export function bypassFn(request: FastifyRequest) { + try { + return !!request.headers[tokenHeaderName] + } catch {} + return false +} diff --git a/.jjconflict-base-1/apps/server/src/utils/keycloak.ts b/.jjconflict-base-1/apps/server/src/utils/keycloak.ts new file mode 100644 index 0000000000..73137efc3b --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/keycloak.ts @@ -0,0 +1,42 @@ +import type { KeycloakOptions } from 'fastify-keycloak-adapter' +import { serviceContract, swaggerUiPath, systemContract } from '@cpn-console/shared' +import { + keycloakClientId, + keycloakClientSecret, + keycloakDomain, + keycloakProtocol, + keycloakRealm, + keycloakRedirectUri, + sessionSecret, +} from './env.js' +import { bypassFn, userPayloadMapper } from './keycloak-utils.js' + +export const keycloakConf = { + appOrigin: keycloakRedirectUri ?? 'http://localhost:8080', + keycloakSubdomain: `${keycloakDomain}/realms/${keycloakRealm}`, + clientId: keycloakClientId ?? '', + clientSecret: keycloakClientSecret ?? '', + useHttps: keycloakProtocol === 'https', + disableCookiePlugin: true, + disableSessionPlugin: true, + // @ts-ignore + userPayloadMapper, + retries: 5, + excludedPatterns: [ + systemContract.getVersion.path, + systemContract.getHealth.path, + serviceContract.getServiceHealth.path, + `${swaggerUiPath}-server/**`, + ], + bypassFn, +} as const satisfies KeycloakOptions + +export const sessionConf = { + cookieName: 'sessionId', + secret: sessionSecret || 'a-very-strong-secret-with-more-than-32-char', + cookie: { + httpOnly: true, + secure: true, + }, + expires: 1_800_000, +} diff --git a/.jjconflict-base-1/apps/server/src/utils/mocks.ts b/.jjconflict-base-1/apps/server/src/utils/mocks.ts new file mode 100644 index 0000000000..16169f4d31 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/mocks.ts @@ -0,0 +1,152 @@ +import type { PluginsManifests, RepoCreds, ServiceInfos } from '@cpn-console/hooks' +import type { Repository } from '@prisma/client' +import type { UserDetails } from '../types/index.js' +import type * as utilsController from '../utils/controller.js' +import { editStrippers, populatePluginManifests } from '@cpn-console/hooks' +import { DEFAULT, DISABLED, PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import fp from 'fastify-plugin' + +let requestor: Requestor + +export function setRequestor(user: Requestor = getRandomRequestor()) { + requestor = user +} + +export function getRequestor() { + return requestor +} + +export async function mockSessionPlugin() { + const sessionPlugin = (app: any, opt: any, next: any) => { + app.addHook('onRequest', (req: any, res: any, next: any) => { + req.session = { user: getRequestor() } + next() + }) + next() + } + + return { default: fp(sessionPlugin) } +} + +export async function mockHooksPackage() { + const hookTemplate = { + execute: () => ({ + args: {}, + failed: false, + }), + validate: () => ({ + failed: false, + }), + } + + return { + editStrippers, + populatePluginManifests, + services: { + getStatus: () => [], + refreshStatus: async () => [], + }, + PluginApi: class { }, + servicesInfos: { + registry: { title: 'Harbor', name: 'registry', to: () => 'test' }, + plugin2: { title: 'Plugin2', name: 'plugin2', to: () => ({ to: 'test', title: 'Test' }) }, + plugin3: { title: 'Plugin3', name: 'plugin3', to: () => [{ to: 'test', title: 'Test' }] }, + plugin4: { title: 'Plugin4', name: 'plugin4', to: () => [{ to: 'test' }] }, + plugin5: { title: 'Plugin5', name: 'plugin5' }, + } as Record, + pluginsManifests: { + registry: { + title: 'Harbor', + global: [{ + kind: 'switch', + initialValue: DEFAULT, + key: 'test2', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Test2', + value: DEFAULT, + description: 'description', + }], + project: [{ + kind: 'switch', + key: 'test2', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Test', + value: DEFAULT, + initialValue: DISABLED, + }], + }, + } as PluginsManifests, + hooks: { + // projects + getProjectSecrets: { + execute: () => ({ + failed: false, + args: {}, + results: { + registry: { + secrets: { + token: 'myToken', + }, + status: { + failed: false, + }, + }, + }, + }), + }, + upsertProject: hookTemplate, + deleteProject: hookTemplate, + // clusters + upsertCluster: hookTemplate, + deleteCluster: hookTemplate, + // user + retrieveUserByEmail: hookTemplate, + }, + } +} + +export type ReposCreds = Record + +type Requestor = Partial +export function getRandomRequestor(user?: Requestor): Partial { + return { + id: user?.id ?? faker.string.uuid(), + email: user?.email ?? faker.internet.email(), + firstName: user?.firstName ?? faker.person.firstName(), + lastName: user?.lastName ?? faker.person.lastName(), + type: 'human', + ...user?.groups !== null && { groups: user?.groups ?? [] }, + } +} + +export function getUserMockInfos(adminPermissions?: bigint, user?: UserDetails): utilsController.UserProfile & utilsController.ProjectPermState +export function getUserMockInfos(adminPermissions?: bigint, user?: UserDetails, project?: utilsController.ProjectPermState): utilsController.UserProjectProfile & utilsController.ProjectPermState +export function getUserMockInfos(adminPermissions: bigint = 0n, user = getRandomRequestor(), project?: utilsController.ProjectPermState): utilsController.UserProfile | utilsController.UserProjectProfile { + return { + adminPermissions, + user, + ...project, + } +} + +export function getProjectMockInfos({ projectId, projectLocked, projectOwnerId, projectPermissions, projectStatus }: Partial): utilsController.ProjectPermState { + return { + projectId: projectId ?? faker.string.uuid(), + projectLocked: projectLocked ?? false, + projectOwnerId: projectOwnerId ?? faker.string.uuid(), + projectStatus: projectStatus ?? 'created', + projectPermissions: projectPermissions ?? PROJECT_PERMS.MANAGE, + } +} + +export const atDates = { + createdAt: new Date(), + updatedAt: new Date(), +} diff --git a/.jjconflict-base-1/apps/server/src/utils/plugins.ts b/.jjconflict-base-1/apps/server/src/utils/plugins.ts new file mode 100644 index 0000000000..9876677766 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/plugins.ts @@ -0,0 +1,9 @@ +import type { PluginManagerOptions } from '@cpn-console/hooks' +import { isCI, isInt, isProd } from './env.js' + +export const pluginManagerOptions: PluginManagerOptions = { + mockHooks: isCI || (!isProd && !isInt), + mockMonitoring: isCI || (!isProd && !isInt), + mockExternalServices: isCI || (!isProd && !isInt), + startPlugins: (!isCI && isProd) || isInt, +} diff --git a/.jjconflict-base-1/apps/server/src/utils/proxy.spec.ts b/.jjconflict-base-1/apps/server/src/utils/proxy.spec.ts new file mode 100644 index 0000000000..7fdc203833 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/proxy.spec.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { genericProxy } from './proxy.js' + +// Création d'une cible de test +const target = { + async fetchData(id: string) { + return { id, data: 'Mocked data' } + }, + async otherMethod(id: string) { + return { id, data: 'Mocked data' } + }, +} + +describe('test calls without ID passed', () => { + // Test d'appel de méthode sans ID + it('calling method without ID', async () => { + const proxied = genericProxy(target) + const result = await proxied.fetchData() + expect(result).toEqual({ id: undefined, data: 'Mocked data' }) + }) + + // Fonction de test asynchrone pour tester le cas où aucune ID n'est fournie + it('test when no ID is provided', async () => { + // Création d'une cible de test + const target = { + async fetchData() { + return 'No ID provided' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Appel à la méthode fetchData sans ID + const result = await proxied.fetchData() + + // Vérification que le résultat est correct + expect(result).toBe('No ID provided') + }) + + // Fonction de test asynchrone pour tester le cas où aucune ID n'est fournie avec une promesse en cours + it('test when no ID is provided with pending promise', async () => { + // Création d'une cible de test + const target = { + async fetchData() { + return new Promise(resolve => setTimeout(resolve, 100, 'Pending result')) + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Appel à la méthode fetchData sans ID + const promise1 = proxied.fetchData() + const promise2 = proxied.fetchData() // Deuxième appel avant la résolution du premier + + // Attendre que la première promesse se résolve + const result1 = await promise1 + + // Vérification que le résultat de la première promesse est correct + expect(result1).toBe('Pending result') + + // Attendre que la deuxième promesse se résolve + const result2 = await promise2 + + // Vérification que le résultat de la deuxième promesse est correct + expect(result2).toBe('Pending result') + }) + // Test pour vérifier que l'erreur est levée lorsque args est fourni sans ID + it('test error when args provided without ID', async () => { + // Création d'une cible de test + const target = { + async fetchData(_id: string, _args: any) { + return 'No ID provided' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + const args = { key: 'value' } + + // Appel de la fonction fetchData avec des arguments mais sans ID + await expect(proxied.fetchData(undefined, args)).rejects.toThrow('ID is required when args are provided') + }) +}) + +describe('test calls with ID passed', () => { + // Test d'appel de méthode avec ID + it('calling method with ID', async () => { + const proxied = genericProxy(target) + const result = await proxied.fetchData('123') + expect(result).toEqual({ id: '123', data: 'Mocked data' }) + }) + + // Test d'appel de méthode avec exclusion en cours + it('calling method with exclusion in progress', async () => { + const proxied = genericProxy(target, { fetchData: ['otherMethod'] }) + // Simuler une exécution en cours pour la méthode exclue + proxied.otherMethod('456') + + // Maintenant, tenter d'appeler fetchData pour le même ID devrait échouer + await expect(proxied.fetchData('456')).rejects.toThrow( + 'otherMethod in progress on 456, can\'t fetchData', + ) + }) + + // Fonction de test asynchrone pour tester le mélange des nextArgs + it('test mixing nextArgs from concurrent promises', async () => { + // Création d'une cible de test + const target = { + async fetchData(id: string, args?: object) { + return { id, args } + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + const promise1 = proxied.fetchData('123', { key1: 'value1' }) + // Appels successifs à fetchData avec différents arguments + const promise2 = proxied.fetchData('123', { key2: 'value2' }) + + // Promesse concurrente avec des nextArgs différents + const promise3 = proxied.fetchData('123', { key3: 'value3' }) + + // Attendre que les promesses se résolvent + const result1 = await promise1 + const result2 = await promise2 + const result3 = await promise3 + + // Vérification que les nextArgs de promise2 et promise3 ont été correctement mélangés + expect(result1.args).toEqual({ key1: 'value1' }) + expect(result2.args).toEqual({ key2: 'value2', key3: 'value3' }) + expect(result3.args).toEqual({ key2: 'value2', key3: 'value3' }) + }) + + it('test rejection of set attempt', () => { + // Création d'une cible de test + const target = { + async fetchData() { + return 'Mocked data' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Tentative de définir une nouvelle propriété sur le proxy + const setAttempt = () => { + proxied.fetchData = () => new Promise(resolve => resolve('illegal')) + } + + // Vérification que la tentative de set est rejetée + expect(setAttempt).toThrow(TypeError) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/utils/proxy.ts b/.jjconflict-base-1/apps/server/src/utils/proxy.ts new file mode 100644 index 0000000000..ef915a7d15 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/proxy.ts @@ -0,0 +1,78 @@ +// @ts-nocheck un enfer à typer, pour plus tard +type Tracker> = Record + nextArgs?: [string] +}>> | Promise + +type Target = Record Promise> +type Excludes = Partial>> | undefined +const toTarget = (target: T) => ({ tracker: {} as Tracker, methods: target }) + +// @ts-ignore +export function genericProxy(proxied: T, excludes: Excludes = {}): T { + return new Proxy(toTarget(proxied), { + get({ methods, tracker }, property: string) { + if (!(property in methods)) return + return async (...args) => { + const id = args[0] as string + + if (!id && args.length > 0) { + throw new Error('ID is required when args are provided') + } + + if (!id) { + if (tracker[property] instanceof Promise) { + return tracker[property] + } + const p = methods[property]() + if (p instanceof Promise) { + tracker[property] = p + p.then(() => { + delete tracker[property] + }) + } + return p + } + if (!tracker[property]) { + tracker[property] = {} + } + + for (const testExclude of excludes[property] ?? []) { + // @ts-ignore + if (tracker?.[testExclude]?.[id]?.currentExec) { + throw new Error(`${String(testExclude)} in progress on ${id}, can't ${String(property)}`) + } + } + + if (id in tracker[property]) { + if (args[1]) { + tracker[property][id].nextArgs = { + ...(tracker[property][id].nextArgs ?? {}), + ...args[1], + } + } + if (tracker[property][id].currentExec) { + return new Promise((resolve) => { + tracker[property][id].currentExec.then(() => { + resolve(tracker[property][id].currentExec ?? methods[property](id, tracker[property][id].nextArgs)) + }) + }) + } + } + const p = methods[property](...args) + tracker[property][id] = { + currentExec: p, + nextArgs: undefined, + } + tracker[property][id].currentExec = p + p.then(() => { + tracker[property][id].currentExec = undefined + }) + return p + } + }, + set() { + return false + }, + }) as T +} diff --git a/.jjconflict-base-1/apps/server/src/utils/queries-tools.spec.ts b/.jjconflict-base-1/apps/server/src/utils/queries-tools.spec.ts new file mode 100644 index 0000000000..e37ed94c25 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/queries-tools.spec.ts @@ -0,0 +1,47 @@ +import { exclude } from '@cpn-console/shared' +import { describe, expect, it } from 'vitest' +import { filterObjectByKeys } from './queries-tools.js' + +describe('queries-tools', () => { + it('should return a filtered object (filterObjectByKeys)', () => { + const initial = { + id: 'thisIsAnId', + name: 'alsoKeepThisKey', + description: 'keepThisKey', + } + const desired = { + name: 'alsoKeepThisKey', + description: 'keepThisKey', + } + + const transformed = filterObjectByKeys(initial, ['name', 'description']) + + expect(transformed).toMatchObject(desired) + }) + + it('should return a filtered object (exclude)', () => { + const initial = { + id: 'thisIsAnId', + name: 'myProjectName', + environment: { + permissions: { + password: 'secret', + id: 'notSecret', + }, + }, + } + const desired = { + id: 'thisIsAnId', + name: 'myProjectName', + environment: { + permissions: { + id: 'notSecret', + }, + }, + } + + const transformed = exclude(initial, ['password']) + + expect(transformed).toMatchObject(desired) + }) +}) diff --git a/.jjconflict-base-1/apps/server/src/utils/queries-tools.ts b/.jjconflict-base-1/apps/server/src/utils/queries-tools.ts new file mode 100644 index 0000000000..856ca277f1 --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/queries-tools.ts @@ -0,0 +1,11 @@ +export const dbKeysExcluded = ['updatedAt', 'createdAt'] + +// TODO +// @ts-ignore supprimer cette fonction et utiliser des schémas zod où elle est utilisé +export function filterObjectByKeys(obj, keys) { + return Object.fromEntries( + Object.entries(obj)?.filter(([key, _value]) => keys.includes(key)), + ) +} + +export const uuid: RegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i diff --git a/.jjconflict-base-1/apps/server/src/utils/random.spec.ts b/.jjconflict-base-1/apps/server/src/utils/random.spec.ts new file mode 100644 index 0000000000..3c33d6a4ee --- /dev/null +++ b/.jjconflict-base-1/apps/server/src/utils/random.spec.ts @@ -0,0 +1,148 @@ +import { createRandomDbSetup } from '@cpn-console/test-utils' +import { describe, expect, it } from 'vitest' + +describe('random utils', () => { + // TODO + it.skip('should create a random db for tests', () => { + const db = createRandomDbSetup({ nbUsers: 3, nbRepo: 1, envs: ['dev', 'prod'] }) + expect(db).toEqual( + expect.objectContaining({ + stages: expect.arrayContaining([ + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + ]), + quotas: expect.arrayContaining([ + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + ]), + project: expect.objectContaining({ + id: expect.any(String), + name: expect.any(String), + clusters: expect.arrayContaining([{ + caData: expect.any(String), + server: expect.any(String), + tlsServername: expect.any(String), + }]), + status: expect.any(String), + locked: expect.any(Boolean), + roles: expect.arrayContaining([ + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + ]), + repositories: expect.any(Array), + environments: expect.arrayContaining([ + { + id: expect.any(String), + stageId: expect.any(String), + projectId: expect.any(String), + quotaId: expect.any(String), + status: expect.any(String), + permissions: expect.any(Array), + clusters: expect.any(Array), + }, + { + id: expect.any(String), + stageId: expect.any(String), + projectId: expect.any(String), + quotaId: expect.any(String), + status: expect.any(String), + permissions: expect.any(Array), + clusters: expect.any(Array), + }, + ]), + }), + users: expect.arrayContaining([ + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + ]), + }), + ) + }) +}) diff --git a/.jjconflict-base-1/apps/server/tsconfig.json b/.jjconflict-base-1/apps/server/tsconfig.json new file mode 100644 index 0000000000..a3397898ca --- /dev/null +++ b/.jjconflict-base-1/apps/server/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "useUnknownInCatchVariables": false, + "declarationDir": "./types", + "outDir": "./dist", + "plugins": [{ "transform": "typescript-transform-paths" }] + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__", + "./src/mocks/utils.ts", + "./src/utils/mocks.ts" + ] +} diff --git a/.jjconflict-base-1/apps/server/vite.config.ts b/.jjconflict-base-1/apps/server/vite.config.ts new file mode 100644 index 0000000000..f9a3e50fae --- /dev/null +++ b/.jjconflict-base-1/apps/server/vite.config.ts @@ -0,0 +1,18 @@ +/// +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + poolMatchGlobs: [ + ['**/resources/**/*.spec.ts', 'forks'], + ], + }, +}) diff --git a/.jjconflict-base-1/apps/server/vitest-init.ts b/.jjconflict-base-1/apps/server/vitest-init.ts new file mode 100644 index 0000000000..596420f7de --- /dev/null +++ b/.jjconflict-base-1/apps/server/vitest-init.ts @@ -0,0 +1,11 @@ +process.env.ARGOCD_URL = 'https://argo-cd.readthedocs.io' +process.env.GITLAB_URL = 'https://gitlab.com' +process.env.HARBOR_URL = 'https://goharbor.io' +process.env.NEXUS_URL = 'https://sonatype.com/products/nexus-repository' +process.env.SONARQUBE_URL = 'https://www.sonarqube.org' +process.env.VAULT_URL = 'https://www.vaultproject.io' +process.env.PROJECTS_ROOT_DIR = 'forge-mi/projects' +process.env.KEYCLOAK_REDIRECT_URI = 'http://console.dso.local' +process.env.CONTACT_EMAIL = 'cloudpinative-relations@interieur.gouv.fr' +process.env.OPENCDS_URL = 'https://opencds.gouv.fr' +process.env.OPENCDS_API_TOKEN = 'test_token' diff --git a/.jjconflict-base-1/apps/server/vitest.config.ts b/.jjconflict-base-1/apps/server/vitest.config.ts new file mode 100644 index 0000000000..9dc913021e --- /dev/null +++ b/.jjconflict-base-1/apps/server/vitest.config.ts @@ -0,0 +1,35 @@ +import { fileURLToPath } from 'node:url' +import { mergeConfig } from 'vite' +import { configDefaults, defineConfig } from 'vitest/config' +import viteConfig from './vite.config' + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + reporters: ['default', 'hanging-process'], + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: [ + '**/types', + '**/mocks', + '**/*.spec.ts', + '**/*.d.ts', + '**/*.vue', + '**/queries.ts', + '**/mocks.ts', + '**/*.sql', + ], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + setupFiles: ['./vitest-init.ts'], + root: fileURLToPath(new URL('./', import.meta.url)), + pool: 'forks', + }, + }), +) diff --git a/.jjconflict-base-1/ci/matrix-docker.json b/.jjconflict-base-1/ci/matrix-docker.json new file mode 100644 index 0000000000..3550832dba --- /dev/null +++ b/.jjconflict-base-1/ci/matrix-docker.json @@ -0,0 +1,42 @@ +[ + { + "name": "server", + "build": { + "context": "./", + "dockerfile": "./apps/server/Dockerfile", + "target": "prod" + } + }, + { + "name": "nginx-strangler", + "build": { + "context": "./", + "dockerfile": "./apps/nginx-strangler/Dockerfile", + "target": "prod" + } + }, + { + "name": "server-nestjs", + "build": { + "context": "./", + "dockerfile": "./apps/server-nestjs/Dockerfile", + "target": "prod" + } + }, + { + "name": "client", + "build": { + "context": "./", + "dockerfile": "./apps/client/Dockerfile", + "target": "prod" + } + }, + { + "name": "opencds-mockoon", + "build": { + "context": "./", + "dockerfile": "./packages/opencds/Dockerfile", + "target": "prod" + } + } +] diff --git a/.jjconflict-base-1/ci/matrix-npm.json b/.jjconflict-base-1/ci/matrix-npm.json new file mode 100644 index 0000000000..b4805a8be6 --- /dev/null +++ b/.jjconflict-base-1/ci/matrix-npm.json @@ -0,0 +1,72 @@ +{ + "apps": [ + { + "name": "@cpn-console/server", + "path": "./apps/server" + }, + { + "name": "@cpn-console/server-nestjs", + "path": "./apps/server-nestjs" + }, + { + "name": "@cpn-console/client", + "path": "./apps/client" + } + ], + "packages": [ + { + "name": "@cpn-console/shared", + "path": "./packages/shared" + }, + { + "name": "@cpn-console/hooks", + "path": "./packages/hooks" + }, + { + "name": "@cpn-console/logger", + "path": "./packages/logger" + }, + { + "name": "@cpn-console/test-utils", + "path": "./packages/test-utils" + }, + { + "name": "@cpn-console/eslint-config", + "path": "./packages/eslintconfig" + }, + { + "name": "@cpn-console/ts-config", + "path": "./packages/tsconfig" + } + ], + "plugins": [ + { + "name": "@cpn-console/argocd-plugin", + "path": "./plugins/argocd" + }, + { + "name": "@cpn-console/gitlab-plugin", + "path": "./plugins/gitlab" + }, + { + "name": "@cpn-console/harbor-plugin", + "path": "./plugins/harbor" + }, + { + "name": "@cpn-console/keycloak-plugin", + "path": "./plugins/keycloak" + }, + { + "name": "@cpn-console/nexus-plugin", + "path": "./plugins/nexus" + }, + { + "name": "@cpn-console/sonarqube-plugin", + "path": "./plugins/sonarqube" + }, + { + "name": "@cpn-console/vault-plugin", + "path": "./plugins/vault" + } + ] +} diff --git a/.jjconflict-base-1/ci/scripts/build-matrix.sh b/.jjconflict-base-1/ci/scripts/build-matrix.sh new file mode 100755 index 0000000000..301a203ea5 --- /dev/null +++ b/.jjconflict-base-1/ci/scripts/build-matrix.sh @@ -0,0 +1,205 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Get versions +DOCKER_VERSION="$(docker --version)" + + +# Default +# REGISTRY="docker.io" +TAGS="latest" +COMMIT_SHA="$(git rev-parse --short HEAD)" +PLATFORMS="linux/amd64" +CSV=false +RECURSIVE=false + +unset MAJOR_VERSION +unset MINOR_VERSION +unset PATCH_VERSION + +# Declare script helper +TEXT_HELPER="\nThis script aims to build matrix for Github CI/CD. It will parse the given docker-compose file and return a json object with images infos (name, tag, context, dockerfile and if it need to be build) +Following flags are available: + + -a Create recursive tags, if it match 'x.x.x' it will create 'x.x' and 'x'. + + -c Use csv list formated output for tags instead of json array. + + -f Docker-compose file used to build matrix. + + -n Namespace used to tag images. e.g 'username/reponame'. + + -p Target platforms used to build matrix (List/CSV format. ex: 'linux/amd64,linux/arm64'). + Default is '$PLATFORMS'. + + -r Registry host used to build matrix. + Default is '$REGISTRY'. + + -t Docker tag used to build matrix. + Default is '$TAGS'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hacf:n:p:r:t: flag; do + case "${flag}" in + a) + RECURSIVE=true;; + c) + CSV=true;; + f) + COMPOSE_FILE=${OPTARG};; + n) + NAMESPACE=${OPTARG};; + p) + PLATFORMS=${OPTARG};; + r) + REGISTRY=${OPTARG};; + t) + TAGS=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ ! -f "$(readlink -f $COMPOSE_FILE)" ]; then + echo "\nDocker compose file $COMPOSE_FILE does not exist." + print_help + exit 1 +fi + + +if [ "$REGISTRY" ] && [[ "$REGISTRY" != */ ]]; then + REGISTRY="$REGISTRY/" +fi + +if [ "$NAMESPACE" ] && [[ "$NAMESPACE" != */ ]]; then + NAMESPACE="$NAMESPACE/" +fi + + +# Build core matrix +MATRIX=$(cat "$COMPOSE_FILE" \ + | docker run -i --rm mikefarah/yq -o=json \ + | jq \ + --arg d "$(dirname $(readlink -f $COMPOSE_FILE))" \ + --arg p "$PLATFORMS" \ + --arg r "$REGISTRY" \ + --arg t "$TAGS" \ + '.services | to_entries | map({ + image: (.value.image), + name: (.value.image | split(":")[0] | split("/")[-1]), + build: ( + if .value.build then { + context: ($d + "/" + .value.build.context), + dockerfile: ($d + "/" + .value.build.context + "/" + .value.build.dockerfile), + target: (.value.build.target), + platforms: [], + tags: [] + } + else + false + end) + })') + +# Add tags in matrix +for t in $(echo $TAGS | tr "," "\n"); do + if [[ "$t" == *"."*"."* ]] && [[ "$RECURSIVE" == "true" ]]; then + MAJOR_VERSION="$(echo $t | cut -d "." -f 1)" + MINOR_VERSION="$(echo $t | cut -d "." -f 2)" + PATCH_VERSION="$(echo $t | cut -d "." -f 3)" + + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg r "$REGISTRY" \ + --arg n "$NAMESPACE" \ + --arg major "$MAJOR_VERSION" \ + --arg minor "$MINOR_VERSION" \ + 'map(. | + if .build != false then + .build.tags += [ + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $major), + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $major + "." + $minor) + ] + else + . + end + )') + fi + + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg t "$t" \ + --arg r "$REGISTRY" \ + --arg n "$NAMESPACE" \ + 'map(. | + if .build != false then + .build.tags += [ + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $t) + ] + else + . + end + )') +done + +# Add platforms in matrix +for p in $(echo $PLATFORMS | tr "," "\n"); do + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg p "$p" \ + 'map(. | + if .build != false then + .build.platforms += [ + ($p) + ] + else + . + end + )') +done + +# Update image key with first tag +MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg t "$TAGS" \ + 'map(. | + if .build != false then + .image = (.image | split(":")[0] | split("/")[-1] + ":" + ($t | split(",")[0])) + else + . + end + )') + +# Convert tags & platforms from json array to csv list (use join instead of @csv to don't get unwanted quotes) +if [ "$CSV" == "true" ]; then + MATRIX=$(echo "$MATRIX" \ + | jq -r \ + 'map(. | + if .build != false then + .build.tags = (.build.tags | join(",")) | + .build.platforms = (.build.platforms | join(",")) + else + . + end + )') +fi + +echo "$MATRIX" | jq . \ No newline at end of file diff --git a/.jjconflict-base-1/ci/scripts/build-tags.sh b/.jjconflict-base-1/ci/scripts/build-tags.sh new file mode 100755 index 0000000000..63f795c710 --- /dev/null +++ b/.jjconflict-base-1/ci/scripts/build-tags.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +set -e + +unset MAJOR_VERSION +unset MINOR_VERSION +unset PATCH_VERSION + +# Declare script helper +TEXT_HELPER="\nThis script aims to build docker tags based on image and set of tags. +Following flags are available: + + -i Image used to create tags. + + -t Docker tag used to build matrix. + Default is '$TAGS'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hi:t: flag; do + case "${flag}" in + i) + IMAGE=${OPTARG};; + t) + TAGS=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ -z "$IMAGE" ] || [ -z "$TAGS" ]; then + echo "\nArguments missing." + print_help + exit 1 +fi + + +for TAG in $(echo $TAGS | tr "," "\n"); do + if [[ "$TAG" == *"."*"."* ]]; then + MAJOR_VERSION="$(echo $TAG | cut -d "." -f 1)" + MINOR_VERSION="$(echo $TAG | cut -d "." -f 1-2)" + if [ -z "$DOCKER_TAGS" ]; then + DOCKER_TAGS="$IMAGE:$MAJOR_VERSION,$IMAGE:$MINOR_VERSION,$IMAGE:$TAG" + else + DOCKER_TAGS="$DOCKER_TAGS,$IMAGE:$TAG,$IMAGE:$MAJOR_VERSION,$IMAGE:$MINOR_VERSION,$IMAGE:$TAG" + fi + else + if [ -z "$DOCKER_TAGS" ]; then + DOCKER_TAGS="$IMAGE:$TAG" + else + DOCKER_TAGS="$DOCKER_TAGS,$IMAGE:$TAG" + fi + fi +done + +echo "$DOCKER_TAGS" diff --git a/.jjconflict-base-1/ci/scripts/cleanup.sh b/.jjconflict-base-1/ci/scripts/cleanup.sh new file mode 100755 index 0000000000..acef1973a0 --- /dev/null +++ b/.jjconflict-base-1/ci/scripts/cleanup.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +cd "$PROJECT_DIR" + + +printf "\n${red}${i}.${no_color} Remove build\n" +i=$(($i + 1)) + +pnpm run build:clean + + +printf "\n${red}${i}.${no_color} Remove node_modules\n" +i=$(($i + 1)) + +while true; do + read -p "Do you want to remove node_modules ('rm -rf $PROJECT_DIR/**/node_modules')? " yn + case $yn in + [Yy]*) + for n in $(find $PROJECT_DIR -depth 3 -type d -name node_modules); do + echo "Delete '$n'" + rm -rf "$n" + done + # find $PROJECT_DIR -type d -name node_modules -delete + break;; + [Nn]*) + echo "\nKeeping node_modules\n" + break;; + *) + echo "\nPlease answer y or n.\n";; + esac +done + + +printf "\n${red}${i}.${no_color} Remove docker stuff\n" +i=$(($i + 1)) + +if docker image inspect dso-console/client:dev > /dev/null 2>&1; then + docker image rm dso-console/client:dev +fi +if docker image inspect dso-console/client:prod > /dev/null 2>&1; then + docker image rm dso-console/client:prod +fi +if docker image inspect dso-console/server:dev > /dev/null 2>&1; then + docker image rm dso-console/server:dev +fi +if docker image inspect dso-console/server:prod > /dev/null 2>&1; then + docker image rm dso-console/server:prod +fi + +if docker volume inspect docker_dso-postgres-data-dev > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-dev +fi +if docker volume inspect docker_dso-postgres-data-integ > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-integ +fi +if docker volume inspect docker_dso-postgres-data-local > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-local +fi diff --git a/.jjconflict-base-1/ci/scripts/delete-image.sh b/.jjconflict-base-1/ci/scripts/delete-image.sh new file mode 100755 index 0000000000..d1e0e70e59 --- /dev/null +++ b/.jjconflict-base-1/ci/scripts/delete-image.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' + +# Get versions +DOCKER_VERSION="$(docker --version)" +DOCKER_BUILDX_VERSION="$(docker buildx version)" + +# Declare script helper +TEXT_HELPER="\nThis script aims to delete an image with all its subsequent images in ghcr. +Following flags are available: + + -g Github token to perform api calls. + + -i Image name used for api calls. + + -o Github organization used for api calls. + + -t Image tage to delete. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hg:i:o:t: flag; do + case "${flag}" in + g) + GITHUB_TOKEN=${OPTARG};; + i) + IMAGE_NAME=${OPTARG};; + o) + ORG=${OPTARG};; + t) + TAG=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +checkDockerRunning () { + if [ ! -x "$(command -v docker)" ]; then + printf "\nThis script uses docker, and it isn't running - please start docker and try again!\n" + exit 1 + fi +} + +checkBuildxPlugin () { + if [ ! "$DOCKER_BUILDX_VERSION" ]; then + printf "\nThis script uses docker buildx plugin, and it isn't installed - please install docker buildx plugin and try again!\n" + exit 1 + fi +} + +# Settings +printf "\nScript settings: + -> docker version: ${DOCKER_VERSION} + -> docker buildx version: ${DOCKER_BUILDX_VERSION}\n" + + +if [ -z "$GITHUB_TOKEN" ] || [ -z "$ORG" ] || [ -z "$IMAGE_NAME" ] || [ -z "$TAG" ]; then + echo "\nYMissing arguments ...\n" + print_help + exit 1 +fi + +IMAGE_NAME_URL_ENCODED="$(jq -rn --arg x ${IMAGE_NAME} '$x | @uri')" +IMAGES=$(curl -s \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions?per_page=100") + +# Delete subsequent images +while read -r SHA; do + IMAGE_ID=$(echo "$IMAGES" | jq -r --arg s "$SHA" '.[] | select(.name==$s) | .id') + + printf "\n${red}[Delete ghcr image].${no_color} Deleting subsequent image '$ORG/$IMAGE_NAME@$SHA'\n" + + curl -s \ + -X DELETE \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions/${IMAGE_ID}" +done <<< "$(docker buildx imagetools inspect ghcr.io/${ORG}/${IMAGE_NAME}:${TAG} --raw | jq -r '.manifests[] | .digest')" + +# Delete main images +while read -r IMAGE_ID; do + + printf "\n${red}[Delete ghcr image].${no_color} Deleting GH package version: ${IMAGE_ID}'\n" + + curl -s \ + -X DELETE \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions/${IMAGE_ID}" +done <<< "$(echo "$IMAGES" | jq -r --arg t "$TAG" '.[] | select(.metadata.container.tags[] | contains($t)) | .id')" diff --git a/.jjconflict-base-1/ci/scripts/init-env.sh b/.jjconflict-base-1/ci/scripts/init-env.sh new file mode 100755 index 0000000000..0c6c5d3feb --- /dev/null +++ b/.jjconflict-base-1/ci/scripts/init-env.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Declare script helper +TEXT_HELPER="\nThis script aims to copy .env-example files into .env files at project initialization. +Following flags are available: + + -h Print script help\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts h flag +do + case "${flag}" in + h | *) + print_help + exit 0;; + esac +done + + +find $PROJECT_DIR -type f -name ".env*-example" -or -name "*-example.yaml" | while read f; do + if [ ! -f "${f/-example/}" ]; then + printf "\n${red}Copy${no_color}: '$f' + ${red}to${no_color}: '${f/-example/}'\n" + cp "$f" "${f/-example/}" + else + printf "\nFile '${f/-example/}' already exists\n" + fi +done diff --git a/.jjconflict-base-1/ci/scripts/run-tests.sh b/.jjconflict-base-1/ci/scripts/run-tests.sh new file mode 100755 index 0000000000..cf18c018fe --- /dev/null +++ b/.jjconflict-base-1/ci/scripts/run-tests.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Get versions +NODE_VERSION="$(node --version)" +NPM_VERSION="$(npm --version)" +DOCKER_VERSION="$(docker --version)" +DOCKER_BUILDX_VERSION="$(docker buildx version)" + +# Default +RUN_LINT="false" +RUN_UNIT_TESTS="false" +RUN_E2E_TESTS="false" +RUN_STATUS_CHECK="false" + +# Declare script helper +TEXT_HELPER="\nThis script aims to run application tests. +Following flags are available: + + -e Run e2e tests + + -l Run lint + + -s Run deployement status check + + -t (Optional) Tag used for docker images in e2e tests + + -u Run unit tests + + -h Print script help\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts heklst:u flag +do + case "${flag}" in + e) + RUN_E2E_TESTS=true;; + l) + RUN_LINT=true;; + s) + RUN_STATUS_CHECK=true;; + t) + TAG=${OPTARG};; + u) + RUN_UNIT_TESTS=true;; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ "$RUN_LINT" == "false" ] && [ "$RUN_UNIT_TESTS" == "false" ] && [ "$RUN_E2E_TESTS" == "false" ] && [ "$RUN_STATUS_CHECK" == "false" ]; then + printf "\nArgument(s) missing, you don't specify any kind of test to run.\n" + print_help + exit 1 +fi + +if [ "$RUN_E2E_TESTS" == "true" ] && [ -z "$TAG" ]; then + printf "\nArgument(s) missing, you don't specify the TAG used to pull docker images for e2e tests.\n" + print_help + exit 1 +fi + +checkDockerRunning () { + if [ ! -x "$(command -v docker)" ]; then + printf "\nThis script uses docker, and it isn't running - please start docker and try again!\n" + exit 1 + fi +} + +checkBuildxPlugin () { + if [ ! "$DOCKER_BUILDX_VERSION" ]; then + printf "\nThis script uses docker buildx plugin, and it isn't installed - please install docker buildx plugin and try again!\n" + exit 1 + fi +} + + +# Settings +printf "\nScript settings: + -> node version: ${NODE_VERSION} + -> npm version: ${NPM_VERSION} + -> docker version: ${DOCKER_VERSION} + -> docker buildx version: ${DOCKER_BUILDX_VERSION} + -> run unit tests: ${RUN_UNIT_TESTS} + -> run e2e tests: ${RUN_E2E_TESTS} + -> run deploy status check: ${RUN_STATUS_CHECK}\n" + + +cd "$PROJECT_DIR" + +# Run lint +if [ "$RUN_LINT" == "true" ]; then + npm run lint +fi + + +# Run unit tests +if [ "$RUN_UNIT_TESTS" == "true" ]; then + npm run test:cov +fi + +# Run e2e tests +if [ "$RUN_E2E_TESTS" == "true" ]; then + checkDockerRunning + + printf "\n${red}${i}.${no_color} Launch e2e tests\n" + i=$(($i + 1)) + + npm --prefix $PROJECT_DIR/packages/shared run build + npm --prefix $PROJECT_DIR/packages/test-utils run build + + if [[ -n "$TAG" ]]; then + docker pull ghcr.io/cloud-pi-native/console/server:$TAG && docker tag ghcr.io/cloud-pi-native/console/server:$TAG dso-console/server:ci + docker pull ghcr.io/cloud-pi-native/console/server-nestjs:$TAG && docker tag ghcr.io/cloud-pi-native/console/server-nestjs:$TAG dso-console/server-nestjs:ci + docker pull ghcr.io/cloud-pi-native/console/client:$TAG && docker tag ghcr.io/cloud-pi-native/console/client:$TAG dso-console/client:ci + docker pull ghcr.io/cloud-pi-native/console/nginx-strangler:$TAG && docker tag ghcr.io/cloud-pi-native/console/client:$TAG dso-console/nginx-strangler:ci + fi + + npm run docker:e2e-ci + + printf "\n${red}${i}.${no_color} Remove resources\n" + i=$(($i + 1)) + + npm run docker:e2e-ci:delete +fi diff --git a/.jjconflict-base-1/ci/scripts/setup.sh b/.jjconflict-base-1/ci/scripts/setup.sh new file mode 100755 index 0000000000..4917b17cf8 --- /dev/null +++ b/.jjconflict-base-1/ci/scripts/setup.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +cd "$PROJECT_DIR" + + +printf "\n${red}${i}.${no_color} Install dependencies\n" +i=$(($i + 1)) + +pnpm install + + +printf "\n${red}${i}.${no_color} Generate Prisma client\n" +i=$(($i + 1)) + +pnpm --filter=server run db:generate + + +printf "\n${red}${i}.${no_color} Build packges\n" +i=$(($i + 1)) + +pnpm run build --force + + +printf "\n${red}${i}.${no_color} Build docker dev images\n" +i=$(($i + 1)) + +pnpm run docker:dev:build + + +printf "\n${red}${i}.${no_color} Build docker prod images\n" +i=$(($i + 1)) + +pnpm run docker:prod:build diff --git a/.jjconflict-base-1/ci/scripts/vuln-report.sh b/.jjconflict-base-1/ci/scripts/vuln-report.sh new file mode 100755 index 0000000000..9bf3f6c169 --- /dev/null +++ b/.jjconflict-base-1/ci/scripts/vuln-report.sh @@ -0,0 +1,244 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + + +# Declare script helper +TEXT_HELPER="\nThis script aims to build a markdown style vulnerability report for the trivy json scan results. It will read the files and then build a markdown summary (compatible with Github issue) summarising the vulnerabilities for each image and configuration file for the given input folder. + +Following flags are available: + + -i Input folder that contain trivy json scan result files into subfolders 'images/' and 'configs/'. + Please provide the path without last slash (i.e not like this 'path/to/folder/'). + + -o Output file for the generated markdown report. + Please provide the path with the markdown file extension (i.e '.md'). + + -p Github project (format as '/'). + It can be set in GitHub Actions as '\${{ github.repository }}'. + + -r Github actions run ID. + It can be set in GitHub Actions as '\${{ github.run_id }}'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hi:o:p:r: flag +do + case "${flag}" in + i) + INPUT="${OPTARG}";; + o) + OUTPUT="${OPTARG}";; + p) + REPO="${OPTARG}";; + r) + RUN_ID="${OPTARG}";; + h | *) + print_help + exit 0;; + esac +done + + +NOW="$(date +'%Y-%m-%d at %H:%M')" +ARTIFACT_DOWNLOAD_URL="https://github.com/${REPO}/actions/runs/${RUN_ID}" + + +if [ -z "$INPUT" ] || [ -z "$OUTPUT" ] || [ -z "$REPO" ] || [ -z "$RUN_ID" ]; then + echo "Argument(s) missing, you should set all paramters." + print_help + exit 0 +fi + + +_jq() { + echo ${1} | base64 --decode | jq -r ${2} +} + + +printf "\n${red}${i}.${no_color} Build vulnerability report\n" +i=$(($i + 1)) + +VULNERABILITY_REPORT_BODY="# Security report + +:robot: This report was generated automatically the $NOW. + +It summarizes the result of a vulnerability scan of Trivy for each docker image and configuration file. +You should take a look at it and possibly modify some dependencies to fix vulnerabilities or update your configuration files." + +#--------------------# +# Images scan report # +#--------------------# +if [[ -n $(find $INPUT/images/*) ]]; then + FORMATED_REPORT=$(jq -s '.' $INPUT/images/* | jq '[ .[] | { + name: .ArtifactName, + type: .ArtifactType, + results: [ + .Results[] | select(.Vulnerabilities != null) | { + class: .Class, + target: .Target, + type: .Type, + length: .Vulnerabilities | (if length == 0 then "-" else length end), + vulnerabilityType: { + critical: [.Vulnerabilities[] | select(.Severity == "CRITICAL")] | (if length == 0 then "-" else length end), + high: [.Vulnerabilities[] | select(.Severity == "HIGH")] | (if length == 0 then "-" else length end), + medium: [.Vulnerabilities[] | select(.Severity == "MEDIUM")] | (if length == 0 then "-" else length end), + low: [.Vulnerabilities[] | select(.Severity == "LOW")] | (if length == 0 then "-" else length end), + unknown: [.Vulnerabilities[] | select(.Severity == "UNKNOWN")] | (if length == 0 then "-" else length end) + } + } + ] + } ]') + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +## Images" + + for row in $(echo "${FORMATED_REPORT}" | jq -r '.[] | @base64'); do + IMAGE_NAME=$(echo "$(_jq "${row}" '.')" | jq -r '.name') + OS_TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .length ] | add') + OS_CRITICAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.critical ] | add') + OS_HIGH_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.high ] | add') + OS_MEDIUM_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.medium ] | add') + OS_LOW_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.low ] | add') + OS_UNKNOWN_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.unknown ] | add') + + if [[ $OS_TOTAL_COUNT = 'null' ]]; then + IMAGE_SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + IMAGE_SCAN_SUMMARY=" +| Critical | High | Medium | Low | Unknown | Total | +|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:| +| $OS_CRITICAL_COUNT | $OS_HIGH_COUNT | $OS_MEDIUM_COUNT | $OS_LOW_COUNT | $OS_UNKNOWN_COUNT | $OS_TOTAL_COUNT| +" + fi + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +### \`$IMAGE_NAME\` + +**OS Packages** + +$IMAGE_SCAN_SUMMARY" + + DEP_TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "lang-pkgs") | .length ] | add') + if [[ $DEP_TOTAL_COUNT = 'null' ]]; then + SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + SCAN_SUMMARY=" +| Target | Critical | High | Medium | Low | Unknown | Total | +|:-----------|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:|" + + for row2 in $(echo "$(_jq ${row} '.')" | jq -r '.results[] | select(.class == "lang-pkgs") | @base64'); do + FULL_NAME=$(echo "$(_jq ${row2} '.')" | jq -r '.target') + TOTAL_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '.length') + CRITICAL_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.critical ] | add') + HIGH_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.high ] | add') + MEDIUM_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.medium ] | add') + LOW_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.low ] | add') + UNKNOWN_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.unknown ] | add') + + SCAN_SUMMARY="$SCAN_SUMMARY +| $FULL_NAME | $CRITICAL_COUNT | $HIGH_COUNT | $MEDIUM_COUNT | $LOW_COUNT | $UNKNOWN_COUNT | $TOTAL_COUNT |" + + done + fi + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY +**Language-specific packages** + +$SCAN_SUMMARY" + done + + SCAN_IMAGES=true +fi + +#--------------------# +# Config scan report # +#--------------------# +if [[ -n $(find $INPUT/configs/*) ]]; then + FORMATED_REPORT=$(jq -s '.' $INPUT/configs/* | jq '.[] | { + name: .ArtifactName, + type: .ArtifactType, + results: [ + .Results[] | select(.Misconfigurations != null) | { + class: .Class, + target: .Target, + type: .Type, + length: .Misconfigurations | (if length == 0 then "-" else length end), + vulnerabilityType: { + critical: [.Misconfigurations[] | select(.Severity == "CRITICAL")] | (if length == 0 then "-" else length end), + high: [.Misconfigurations[] | select(.Severity == "HIGH")] | (if length == 0 then "-" else length end), + medium: [.Misconfigurations[] | select(.Severity == "MEDIUM")] | (if length == 0 then "-" else length end), + low: [.Misconfigurations[] | select(.Severity == "LOW")] | (if length == 0 then "-" else length end), + unknown: [.Misconfigurations[] | select(.Severity == "UNKNOWN")] | (if length == 0 then "-" else length end) + } + } + ] + }') + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +## Configs" + + TOTAL_CONFIG=$(echo $FORMATED_REPORT | jq -r '[ .results[].length ] | add') + if [[ $TOTAL_CONFIG = 'null' ]]; then + SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + SCAN_SUMMARY=" +| Target | Critical | High | Medium | Low | Unknown | Total | +|:-----------|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:|" + fi + + for row in $(echo "${FORMATED_REPORT}" | jq -r '.results[] | @base64'); do + FULL_NAME=$(echo "$(_jq "${row}" '.')" | jq -r '.target') + TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '.length') + CRITICAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.critical ] | add') + HIGH_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.high ] | add') + MEDIUM_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.medium ] | add') + LOW_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.low ] | add') + UNKNOWN_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.unknown ] | add') + + SCAN_SUMMARY="$SCAN_SUMMARY +| $FULL_NAME | $CRITICAL_COUNT | $HIGH_COUNT | $MEDIUM_COUNT | $LOW_COUNT | $UNKNOWN_COUNT | $TOTAL_COUNT|" + + done + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY +$SCAN_SUMMARY" + + SCAN_CONFIG=true +fi + +if [ "$SCAN_DEPENDENCIES" = true ] || [ "$SCAN_CONFIG" = true ] || [ "$SCAN_IMAGES" = true ]; then + printf "\n${red}${i}.${no_color} Add artifacts download url to the vulnerability report body\n" + i=$(($i + 1)) + + VULNERABILITY_REPORT_BODY=$(cat < "$OUTPUT" +fi diff --git a/.jjconflict-base-1/commitlint.config.cjs b/.jjconflict-base-1/commitlint.config.cjs new file mode 100644 index 0000000000..070c7b1c34 --- /dev/null +++ b/.jjconflict-base-1/commitlint.config.cjs @@ -0,0 +1,9 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'body-leading-blank': [ + 2, // error + 'always', + ], + }, +}; diff --git a/.jjconflict-base-1/docker/docker-compose.ci.yml b/.jjconflict-base-1/docker/docker-compose.ci.yml new file mode 100644 index 0000000000..b8358950ea --- /dev/null +++ b/.jjconflict-base-1/docker/docker-compose.ci.yml @@ -0,0 +1,151 @@ +services: + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + ports: + - 8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - /opt/bitnami/keycloak/data/h2 + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + ports: + - 8081:80 + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: prod + image: dso-console/server:ci + container_name: dso-console_server + user: root + depends_on: + postgres: + condition: service_started + keycloak: + condition: service_healthy + volumes: + - ../packages/test-utils/src/imports/data.ts:/app/dist/init/db/imports/data.js + env_file: + - ../apps/server/.env.docker + environment: + NODE_ENV: production + CI: "true" + DEV_SETUP: "true" + networks: + - dso-network + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: prod + image: dso-console/server-nestjs:ci + container_name: dso-console_server-nestjs + depends_on: + postgres: + condition: service_started + keycloak: + condition: service_healthy + opencds-mockoon: + condition: service_healthy + env_file: + - ../apps/server-nestjs/.env.docker + environment: + NODE_ENV: "production" + CI: "true" + DEV_SETUP: "true" + OPENCDS_URL: "http://dso-opencds_mockoon:3100/api/v1" + OPENCDS_API_TOKEN: "token" + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: "false" + networks: + - dso-network + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:ci + container_name: dso-console_nginx-strangler + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + + client: + build: + context: .. + dockerfile: apps/client/Dockerfile + target: prod + image: dso-console/client:ci + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + user: root + volumes: + - ../apps/client/nginx/default.docker.conf:/etc/nginx/conf.d/default.conf + env_file: + - ../apps/client/.env.docker + environment: + OPENCDS_ENABLED: "true" + networks: + - dso-network + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:ci + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + name: dso-network + +volumes: + dso-keycloak-dsfr-theme: diff --git a/.jjconflict-base-1/docker/docker-compose.dev.yml b/.jjconflict-base-1/docker/docker-compose.dev.yml new file mode 100644 index 0000000000..98f2a31933 --- /dev/null +++ b/.jjconflict-base-1/docker/docker-compose.dev.yml @@ -0,0 +1,240 @@ +services: + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + ports: + - 8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - ../keycloak/data/dev:/opt/bitnami/keycloak/data/h2 + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-dev:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + - postgres + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-dev:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: dev + image: dso-console/server:dev + container_name: dso-console_server + command: [dev] + depends_on: + keycloak: + condition: service_healthy + postgres: + condition: service_started + ports: + - 4001:8080 + - 9229:9229 + env_file: + - ../apps/server/.env.docker + develop: + watch: + - action: sync + path: ../apps/server + target: /app/apps/server + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server/package.json + networks: + - dso-network + attach: true + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: dev + image: dso-console/server-nestjs:dev + container_name: dso-console_server-nestjs + command: [start:dev] + ports: + - 3001:3001 + depends_on: + keycloak: + condition: service_healthy + postgres: + condition: service_started + opencds-mockoon: + condition: service_healthy + env_file: + - ../apps/server-nestjs/.env.docker + develop: + watch: + - action: sync + path: ../apps/server-nestjs + target: /app/apps/server-nestjs + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - dist/ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server-nestjs/package.json + networks: + - dso-network + attach: true + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:dev + container_name: dso-console_nginx-strangler + ports: + - 4000:8080 + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + attach: false + + client: + restart: unless-stopped + build: + context: .. + dockerfile: apps/client/Dockerfile + target: dev + image: dso-console/client:dev + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + env_file: + - ../apps/client/.env.docker + develop: + watch: + - action: sync + path: ../apps/client + target: /app/apps/client + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: rebuild + path: ../apps/client/package.json + networks: + - dso-network + attach: true + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:dev + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + driver: bridge + +volumes: + dso-keycloak-dsfr-theme: + dso-pgadmin-data-dev: + dso-postgres-data-dev: diff --git a/.jjconflict-base-1/docker/docker-compose.integ.yml b/.jjconflict-base-1/docker/docker-compose.integ.yml new file mode 100644 index 0000000000..385f5e85e7 --- /dev/null +++ b/.jjconflict-base-1/docker/docker-compose.integ.yml @@ -0,0 +1,220 @@ +services: + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-integ:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + postgres: + condition: service_started + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-integ:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: dev + image: dso-console/server:integ + container_name: dso-console_server + command: [dev] + depends_on: + postgres: + condition: service_started + ports: + - 4001:8080 + - 9229:9229 + volumes: + - ${KUBECONFIG_HOST_PATH}:${KUBECONFIG_PATH:-/home/node/.kube/config} + - ${EXTERNAL_PLUGINS_DIR_HOST_PATH}:/plugins + env_file: + - ../apps/server/.env.docker + - ../apps/server/.env.integ + develop: + watch: + - action: sync + path: ../apps/server + target: /app/apps/server + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server/package.json + networks: + - dso-network + attach: true + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: dev + image: dso-console/server-nestjs:integ + container_name: dso-console_server-nestjs + command: [start:dev] + depends_on: + postgres: + condition: service_started + opencds-mockoon: + condition: service_healthy + ports: + - 3001:3001 + volumes: + - ${KUBECONFIG_HOST_PATH}:${KUBECONFIG_PATH:-/home/node/.kube/config} + - ${EXTERNAL_PLUGINS_DIR_HOST_PATH}:/plugins + env_file: + - ../apps/server-nestjs/.env.docker + - ../apps/server-nestjs/.env.integ + develop: + watch: + - action: sync + path: ../apps/server-nestjs + target: /app/apps/server-nestjs + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - dist/ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server-nestjs/package.json + networks: + - dso-network + attach: true + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:integ + container_name: dso-console_nginx-strangler + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + ports: + - 4000:8080 + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + attach: false + + client: + restart: unless-stopped + build: + context: .. + dockerfile: apps/client/Dockerfile + target: dev + image: dso-console/client:integ + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + env_file: + - ../apps/client/.env.docker + - ../apps/client/.env.integ + develop: + watch: + - action: sync + path: ../apps/client + target: /app/apps/client + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: rebuild + path: ../apps/client/package.json + networks: + - dso-network + attach: true + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:integ + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + driver: bridge + +volumes: + dso-pgadmin-data-integ: + dso-postgres-data-integ: diff --git a/.jjconflict-base-1/docker/docker-compose.local.yml b/.jjconflict-base-1/docker/docker-compose.local.yml new file mode 100644 index 0000000000..80473f1823 --- /dev/null +++ b/.jjconflict-base-1/docker/docker-compose.local.yml @@ -0,0 +1,146 @@ +services: + keycloak-fetch-theme: + restart: no + image: docker.io/curlimages/curl:8.8.0 + container_name: dso-console_keycloak-fetch + volumes: + - dso-keycloak-dsfr-theme:/tmp:rw + command: -LfS -o /tmp/keycloak-theme-dsfr.jar https://github.com/codegouvfr/keycloak-theme-dsfr/releases/download/v2.3.3/keycloak-theme-for-kc-26.0-to-26.1.jar + networks: + - dso-network + attach: false + + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + depends_on: + keycloak-fetch-theme: + condition: service_completed_successfully + ports: + - 127.0.0.1:8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - ../keycloak/data/dev:/opt/bitnami/keycloak/data/h2 + - type: volume + source: dso-keycloak-dsfr-theme + target: /opt/bitnami/keycloak/providers/keycloak-theme-dsfr.jar + volume: + subpath: keycloak-theme-dsfr.jar + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + DSFR_THEME_HOME_URL: http://localhost:8080 + DSFR_THEME_SERVICE_TITLE: Console Cloud π Native + DSFR_THEME_BRAND_TOP: Ministère
de l'Intérieur
et des Outre-Mer + DSFR_THEME_TOS_URL: + DSFR_THEME_CONTACT_EMAIL: cloudpinative-relations@interieur.gouv.fr + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-local:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:local + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + postgres: + condition: service_started + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-local:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + nginx-strangler: + # En mode local (pnpm dev), server et server-nestjs tournent nativement sur la machine. + # Le nginx-strangler reste en Docker et atteint les processes natifs via host.docker.internal. + # (voir .env-example de chaque app). + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:local + container_name: dso-console_nginx-strangler + extra_hosts: + - host.docker.internal:${HOST_GATEWAY_IP:-host-gateway} + ports: + - 4000:8080 + environment: + LEGACY_UPSTREAM: "host.docker.internal:4001" + NESTJS_UPSTREAM: "host.docker.internal:3001" + networks: + - dso-network + attach: false + + jaeger: + restart: unless-stopped + image: jaegertracing/all-in-one:1.76.0 + container_name: dso-console_jaeger + environment: + COLLECTOR_ZIPKIN_HOST_PORT: :9411 + ports: + - 16686:16686 + - 4317:4317 + - 4318:4318 + - 14250:14250 + - 14268:14268 + - 14269:14269 + - 9411:9411 + networks: + - dso-network + attach: false + +networks: + dso-network: + driver: bridge + +volumes: + dso-keycloak-dsfr-theme: + dso-keycloak-data-local: + dso-pgadmin-data-local: + dso-postgres-data-local: diff --git a/.jjconflict-base-1/eslint.config.js b/.jjconflict-base-1/eslint.config.js new file mode 100644 index 0000000000..50992e5703 --- /dev/null +++ b/.jjconflict-base-1/eslint.config.js @@ -0,0 +1,9 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase.append({ + ignores: [ + 'apps/', + 'packages/', + 'plugins/', + ], +}) diff --git a/.jjconflict-base-1/keycloak/mock-sso.md b/.jjconflict-base-1/keycloak/mock-sso.md new file mode 100644 index 0000000000..cd9b5c2982 --- /dev/null +++ b/.jjconflict-base-1/keycloak/mock-sso.md @@ -0,0 +1,34 @@ +# Keycloak + +## Development + +### Start only keycloak + +Go at the root level of the git project : + +```sh +# Go at the root level of the git project +cd `git rev-parse --show-toplevel` + +# Start keycloak +docker compose -f ./docker/docker-compose.dev.yml up keycloak +``` + +Admin page url: + +__*Login:*__ `admin` +__*Password:*__ `admin` + +## Docs + +### Get Started + + + +### Other doc + +* [Keycloak](https://github.com/keycloak/keycloak) - Keycloak Server and Java adapters +* [Keycloak Documentation](https://github.com/keycloak/keycloak-documentation) - Documentation for Keycloak +* [Keycloak QuickStarts](https://github.com/keycloak/keycloak-quickstarts) - QuickStarts for getting started with Keycloak +* [Keycloak Node.js Connect](https://github.com/keycloak/keycloak-nodejs-connect) - Node.js adapter for Keycloak +* [Keycloak Node.js Admin Client](https://github.com/keycloak/keycloak-nodejs-admin-client) - Node.js library for Keycloak Admin REST API diff --git a/.jjconflict-base-1/keycloak/realms/realm-dev.json b/.jjconflict-base-1/keycloak/realms/realm-dev.json new file mode 100644 index 0000000000..f7e932d8c0 --- /dev/null +++ b/.jjconflict-base-1/keycloak/realms/realm-dev.json @@ -0,0 +1,577 @@ +{ + "realm": "dso", + "enabled": true, + "accessTokenLifespan": 300, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "ssoSessionIdleTimeout": 600, + "ssoSessionMaxLifespan": 36000, + "sslRequired": "none", + "registrationAllowed": false, + "privateKey": "MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=", + "publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "requiredCredentials": ["password"], + "attributes": { + "frontendUrl": "http://localhost:8090" + }, + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "groups": [ + { + "id": "07169ee1-d967-48da-a784-1b1ccbdaea7a", + "name": "admin", + "path": "/admin", + "attributes": {}, + "realmRoles": [], + "clientRoles": {}, + "subGroups": [] + } + ], + "users": [ + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6565", + "username": "test", + "enabled": true, + "email": "test@test.com", + "emailVerified": true, + "firstName": "Jean", + "lastName": "DUPOND", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6566", + "username": "tcolin", + "enabled": true, + "email": "thibault.colin@test.com", + "emailVerified": true, + "firstName": "Thibault", + "lastName": "COLIN", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + }, + "groups": ["admin"] + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6567", + "username": "cnollet", + "enabled": true, + "email": "claire.nollet@test.com", + "emailVerified": true, + "firstName": "Claire", + "lastName": "NOLLET", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "58e13168-d533-446b-b6a1-936da0a9d7b7", + "username": "lpoumailloux", + "enabled": true, + "email": "leo.poumailloux@test.com", + "emailVerified": true, + "firstName": "Léo", + "lastName": "POUMAILLOUX", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6568", + "username": "btran", + "enabled": true, + "email": "baudoin.tran@test.com", + "emailVerified": true, + "firstName": "Baudoin", + "lastName": "TRAN", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6569", + "username": "atardif", + "enabled": true, + "email": "arnaud.tardif@test.com", + "emailVerified": true, + "firstName": "Arnaud", + "lastName": "TARDIF", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "387216f1-3b87-4211-9cac-4371125e1175", + "username": "admin", + "enabled": true, + "email": "admin@test.com", + "emailVerified": true, + "firstName": "Admin", + "lastName": "ADMIN", + "credentials": [ + { + "type": "password", + "value": "admin" + } + ], + "realmRoles": ["user", "admin"], + "clientRoles": { + "realm-management": ["realm-admin"], + "account": ["manage-account"] + }, + "groups": ["admin"] + } + ], + "roles": { + "realm": [ + { + "name": "user", + "description": "User privileges" + }, + { + "name": "admin", + "description": "Administrator privileges" + } + ] + }, + "clients": [ + { + "id": "e7d105eb-28d4-4557-a274-eb354a60a33b", + "clientId": "dso-console-frontend", + "name": "dso-console-frontend", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["*"], + "webOrigins": ["*"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "id.token.as.detached.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "require.pushed.authorization.requests": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": ["generic", "basic"], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "access": { + "view": true, + "configure": true, + "manage": true + } + }, + { + "id": "e7d105eb-28d4-4557-a274-eb354a60a33c", + "clientId": "dso-console-backend", + "name": "dso-console-backend", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "client-secret-backend", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "id.token.as.detached.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "require.pushed.authorization.requests": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": ["generic"], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "access": { + "view": true, + "configure": true, + "manage": true + } + } + ], + "clientScopes": [ + { + "id": "41b313bd-c5dc-448f-8056-007d25e42027", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "a067866c-7812-47c6-aa28-9e3421fd673b", + "name": "basic", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "910359ad-0368-44dd-b10c-d1cae6c6dd17", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "1611458d-38ab-4a9f-859e-1812d251c1ac", + "name": "generic", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "e0491f43-3e4c-4649-b16e-3c0e47bbc2c0", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "000f3a6c-bec3-40dd-8397-0c45019df7e0", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "2acd698c-03d2-4d0b-99a4-db00ed08f25d", + "name": "name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "13e8d53a-415f-4f98-a133-8795e0173efd", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "5a8652b6-9a42-48e5-819f-59259c349883", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "9d090281-1d17-4283-8fb7-183e1b536328", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "79bccac1-d0f1-4c28-a040-e0e640889e52", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "5ca2ebe3-ef32-4f62-bcad-e764c70d20e6", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "7d976f67-441d-4491-a1fe-f17343a03120", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "bb8a7b6e-2f1a-46b2-91e1-8d027dabe93d", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "84629e2c-c129-4d49-bb04-a03a5124ed47", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "f5c4b940-47ad-4734-8712-4f53357cff1c", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "e1e82435-1b52-4b41-aa0e-14a3e9779670", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "userinfo.token.claim": "true" + } + }, + { + "id": "c3803e56-b542-45e3-a780-0fb70841dd5c", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + } + ], + "loginWithEmailAllowed": true, + "registrationEmailAsUsername": false, + "loginTheme": "DSFR", + "accountTheme": "DSFR", + "adminTheme": "DSFR", + "emailTheme": "DSFR", + "internationalizationEnabled": true, + "supportedLocales": ["en", "fr"], + "defaultLocale": "fr" +} diff --git a/.jjconflict-base-1/migrations/v9/Dockerfile b/.jjconflict-base-1/migrations/v9/Dockerfile new file mode 100644 index 0000000000..5365c15bc1 --- /dev/null +++ b/.jjconflict-base-1/migrations/v9/Dockerfile @@ -0,0 +1,7 @@ +FROM docker.io/node:22.14.0-bullseye-slim +WORKDIR /app +COPY ./package.json package.json +RUN npm i +COPY ./migrate.js migrate.js +CMD [ "npm", "start" ] +USER node diff --git a/.jjconflict-base-1/migrations/v9/Migrate v9-FR.md b/.jjconflict-base-1/migrations/v9/Migrate v9-FR.md new file mode 100644 index 0000000000..f27cb4619f --- /dev/null +++ b/.jjconflict-base-1/migrations/v9/Migrate v9-FR.md @@ -0,0 +1,42 @@ +# Migration vers la v9 +## Qu'est-ce qui change ? +Nous avons pris la décision d'abandonner la fonctionnalité d'organisation, dès lors le projet ne sera plus rattaché à rien. Cela entraîne quelques problèmes de suivi des ressources, mais si vous suivez les instructions ci-dessous, tout ira bien. + +Les organisations reviendront à l'avenir, mais sous une forme complètement différente. + +## IMPORTANT +Sauvegardez votre instance gitlab et le kv Vault !!! + +## Préparation des ressources +Depuis la version 8.22.0, le serveur api stocke son numéro de version pour chaque projet provisionné avec succès. + +Avant de passer à la version 9, vous DEVEZ provisionner tous les projets actifs au moins à la version 8.23.0. Si vous ne le faites pas, le script de migration SQL lèvera une exception. + +Un bon suivi des ressources est essentiel. Si un projet ne peut pas être provisionné avec succès, vous pouvez toujours changer sa valeur dans la base de données directement, mais assurez-vous que les balises suivantes sont appliquées sur les ressources kubernetes correspondantes : +- dso/project.id : (namespace, applications, appprojects) +- dso/project.slug : (namespace, applications, appprojects) +- dso/environment.id : (namespace, applications, appprojects) +- dso/repository.id : (namespace, applications) + +## Migration des ressources utilisateurs +Vous pouvez lancer cette image (une seule fois) avant ou après la mise à jour vers la v9. + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: migrate-v9 +spec: + containers: + - image: ghcr.io/cloud-pi-native/migration:v9 + name: migrate-v9 + resources: {} + envFrom: + - secretRef: + name: dso-config + restartPolicy: Never +``` +kubectl run --image docker ghcr.io/cloud-pi-native/migration:v9 + +### À propos de Gitlab +Parce que gitlab effectue de nombreuses tâches de manière asynchrone, nous ne pouvons pas supprimer les anciens groupes d'organisation après avoir transféré les groupes de projet. Vous devrez vous connecter manuellement pour les rechercher et les supprimer. Si vous ne le faites pas, ce n'est pas grave... diff --git a/.jjconflict-base-1/migrations/v9/Migrate v9.md b/.jjconflict-base-1/migrations/v9/Migrate v9.md new file mode 100644 index 0000000000..9b7e94bacd --- /dev/null +++ b/.jjconflict-base-1/migrations/v9/Migrate v9.md @@ -0,0 +1,42 @@ +# Migrating to v9 +## What's changing? +We've taken the decision to abandon the organization feature, so the project won't be attached to anything. This is causing some resource tracking problems, but if you follow the instructions below, you'll be fine. + +Organizations will return in future, but in a completely different form. + +## IMPORTANT +Backup your gitlab instance and the vault KV !!! + +## Resource preparation +Since version 8.22.0, the api server stores its version number for each successfully provisioned project. + +Before upgrading to version 9, you MUST provision all active projects to at least version 8.23.0. If you fail to do so, the SQL migration script will throw an exception. + +Good resource tracking is essential. If a project cannot be provisioned successfully, you can always change its value in the database directly, but make sure the following tags are applied on the corresponding kubernetes resources: +- dso/project.id : (namespace, applications, appprojects) +- dso/project.slug : (namespace, applications, appprojects) +- dso/environment.id : (namespace, applications, appprojects) +- dso/repository.id : (namespace, applications) + +## Migrate user resources +You can run this image (once only) before or after upgrading to v9 + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: migrate-v9 +spec: + containers: + - image: ghcr.io/cloud-pi-native/migration:v9 + name: migrate-v9 + resources: {} + envFrom: + - secretRef: + name: dso-config + restartPolicy: Never +``` +kubectl run --image docker ghcr.io/cloud-pi-native/migration:v9 + +### About Gitlab +Because gitlab performs many tasks asynchronously, we can't delete the old organization groups after transferring the project groups. You'll have to log in manually to search for and delete them. If you don't, it's not a big deal... diff --git a/.jjconflict-base-1/migrations/v9/migrate.js b/.jjconflict-base-1/migrations/v9/migrate.js new file mode 100644 index 0000000000..ed8d048084 --- /dev/null +++ b/.jjconflict-base-1/migrations/v9/migrate.js @@ -0,0 +1,142 @@ +import { logger as baseLogger } from '@cpn-console/logger/hooks' +import { Gitlab } from '@gitbeaker/rest' +import axios from 'axios' + +const logger = baseLogger.child({ scope: 'migration:v9' }) + +export function removeTrailingSlash(url) { + return url?.endsWith('/') + ? url.slice(0, -1) + : url +} + +export function requiredEnv(envName) { + const envValue = process.env[envName] + if (envValue) return envValue + + throw new Error(`env: ${envName} is not defined !`) +} + +const gitlabToken = requiredEnv('GITLAB_TOKEN') +const gitlabPublicUrl = removeTrailingSlash(requiredEnv('GITLAB_URL')) +const projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') +const gitlabInternalUrl = process.env.GITLAB_INTERNAL_URL + ? removeTrailingSlash(process.env.GITLAB_INTERNAL_URL) + : gitlabPublicUrl + +const vaultPublicUrl = removeTrailingSlash(requiredEnv('VAULT_URL')) + +const axiosInstance = axios.create({ + baseURL: vaultPublicUrl, + headers: { + 'X-Vault-Token': requiredEnv('VAULT_TOKEN'), + }, +}) + +const api = new Gitlab({ token: gitlabToken, host: gitlabInternalUrl }) + +const groupRootSearch = await api.Groups.search(projectsRootDir) +const groupRootId = (groupRootSearch.find(grp => grp.full_path === projectsRootDir))?.id + +const organizationGroups = await api.Groups.allDescendantGroups(groupRootId, { perPage: 300 }) + +logger.info({ organizationGroupsCount: organizationGroups.length }, 'Loaded organization groups') +if (organizationGroups.length > 300) { + throw new Error('increase perPage, you could miss some results') +} + +for (const organizationGroup of organizationGroups) { + if (organizationGroup.name === 'Infra') continue + logger.info({ organizationGroupId: organizationGroup.id, organizationGroupName: organizationGroup.name }, 'Processing organization group') + const projectGroups = await api.Groups.allDescendantGroups(organizationGroup.id, { perPage: 300 }) + if (projectGroups.length > 300) { + throw new Error('increase perPage, you could miss some projects group results') + } + + for (const projectGroup of projectGroups) { + const newName = `${organizationGroup.name}-${projectGroup.name}` + logger.info({ projectGroupId: projectGroup.id, newName }, 'Renaming and transferring project group') + + try { + const renamedGroup = await api.Groups.edit(projectGroup.id, { name: newName, path: newName }) + await api.Groups.transfer(renamedGroup.id, { groupId: groupRootId }) + } catch (error) { + logger.warn({ err: error, projectGroupId: projectGroup.id, organizationGroupId: organizationGroup.id }, 'Could not rename/transfer project group') + } + } +} + +const coreKvName = 'forge-dso' + +const vaultToken = (await axiosInstance.post('/v1/auth/token/create')) + .data.auth.client_token + +function transformPath(path) { + if (!path.startsWith('/')) { + path = `/${path}` + } + const parts = path.split('/') + return `/${parts[1]}-${parts[2]}/${parts.slice(3).join('/')}` +} +const secretsMapper = {} + +async function list(path = '/') { + if (!path.startsWith('/')) + path = `/${path}` + const response = await axiosInstance({ + url: `/v1/${coreKvName}/metadata/${projectsRootDir}${path}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + method: 'list', + validateStatus: code => [200, 404].includes(code), + }) + + if (response.status === 404) return [] + for (const key of response.data.data.keys) { + if (key.endsWith('/')) { + await list(`${path}${key}`) + } else { + secretsMapper[`${path}${key}`] = transformPath(`${path}${key}`) + } + } +} + +try { + await list() + logger.info({ secretsCount: Object.keys(secretsMapper).length }, 'Collected secrets mapping') +} catch (error) { + logger.error({ err: error }, 'Failed while collecting secrets mapping') +} + +for (const [source, destination] of Object.entries(secretsMapper)) { + const secretContent = await axiosInstance({ + url: `/v1/${coreKvName}/data/${projectsRootDir}${source}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + method: 'get', + validateStatus: code => [200, 404].includes(code), + }) + const data = secretContent.data.data.data + + try { + await axiosInstance({ + method: 'POST', + url: `/v1/${coreKvName}/data/${projectsRootDir}${destination}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + data: { data }, + }) + await axiosInstance({ + method: 'delete', + url: `/v1/${coreKvName}/metadata/${projectsRootDir}${source}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + }) + } catch (error) { + logger.error({ err: error, source, destination }, 'Failed while moving secret') + } +} diff --git a/.jjconflict-base-1/migrations/v9/package.json b/.jjconflict-base-1/migrations/v9/package.json new file mode 100644 index 0000000000..c49c17280c --- /dev/null +++ b/.jjconflict-base-1/migrations/v9/package.json @@ -0,0 +1,15 @@ +{ + "name": "dso-v9-migration", + "version": "1.0.0", + "description": "", + "author": "Cloud Pi Native", + "license": "ISC", + "main": "index.js", + "scripts": { + "start": "node migrate.js" + }, + "dependencies": { + "@gitbeaker/rest": "catalog:runtime", + "axios": "catalog:runtime" + } +} \ No newline at end of file diff --git a/.jjconflict-base-1/misc/business-rules.md b/.jjconflict-base-1/misc/business-rules.md new file mode 100644 index 0000000000..04f1a4fee9 --- /dev/null +++ b/.jjconflict-base-1/misc/business-rules.md @@ -0,0 +1,63 @@ +# Console Cloud π Native + +## Règles métier + +### Formulaire de commande d'un espace projet + +| Champ | Type | Description | +| --------------------- | ------ | ------------------------------------- | +| Nom de l'organisation | string | obligatoire | +| Nom du projet | string | obligatoire, `regex([a-z0-9-]{2,53})` | + +### Formulaire de synchronisation d'un dépôt + +| Champ | Type | Description | +| ----------------------------------------------- | ------- | ----------------------------------------- | +| Nom du dépôt Git interne | string | obligatoire, `regex([a-z0-9-]{2,53})` | +| Url du dépôt Git externe | string | obligatoire, `regex(^https://)` | +| Dépôt externe privé | boolean | obligatoire | +| Nom d'utilisateur lié au token du dépôt externe | string | obligatoire si le dépôt externe est privé | +| Token d'accès au dépôt externe | string | obligatoire si le dépôt externe est privé | +| Dépôt d'infrastructure | boolean | obligatoire | + +### Status et verrouillage + +#### Schema + +- un `project` peut avoir pour `status: Enum('initializing', 'created', 'failed', 'archived')` +- un `repository` et un `environnement` peuvent avoir pour `status: Enum('initializing', 'created', 'failed', 'deleting')` +- un `project` peut être `locked (boolean)` + +#### Principe + +- `initializing` : la ressource est en cours de création, des opérations sont en cours côté `plugins` - le `project` est `locked: true` +- `created` : la ressource est créée, les opérations des `plugins` se sont bien déroulées - le `project` est `locked: false` +- `failed` : la ressource est créée, les opérations des `plugins` ont échoué - le `project` est `locked: true` +- `deleting` : la ressource est en cours de suppression, des opérations sont en cours côté `plugins` - le `project` est `locked: true` +- `archived` : les opérations des `plugins` se sont bien déroulées, le projet et son équipe sont toujours présents en base de données pour archive, mais invisibles des utilisateurs hors `admin` - le `project` est `locked: true` +- `project.locked: true` : toute opération de Create / Update sur le projet et ses ressource est interdite. + +### Permissions d'environnement + +Trois niveaux de permissions différents, +| Valeur en base | Description dans le front | Niveaux | +| -------------- | ------------------------- | ------------- | +| 0 | r | Lecture | +| 1 | rw | + Ecriture | +| 2 | rwd | + Suppression | + +## Points d'API + +voir swagger : + +--- + +## TODO: Idées + +### Admin + +- Lister chacune des tables +- Mise en place d'une table d'historisation des status des projets +- Mettre en place un point d'api pour gérer l'équipe d'admin (création/suppression) +- Possibilité de réexecuter dans keycloak les droits présents dans la DB (synchro DB - keycloak) +- Route qui récapitule l'ensemble des ressources argo et supprime toutes les autres diff --git a/.jjconflict-base-1/misc/controller-plugins-communication.png b/.jjconflict-base-1/misc/controller-plugins-communication.png new file mode 100644 index 0000000000..b446155dc5 Binary files /dev/null and b/.jjconflict-base-1/misc/controller-plugins-communication.png differ diff --git a/.jjconflict-base-1/misc/controllers.md b/.jjconflict-base-1/misc/controllers.md new file mode 100644 index 0000000000..d194fec2fd --- /dev/null +++ b/.jjconflict-base-1/misc/controllers.md @@ -0,0 +1,29 @@ +# Logique controllers + +```mermaid +flowchart TD + A[(db: find prérequis)] + A -->|KO| B>send code 400, 401, 403, 404] + A -->|try| D(db: update projectLocked = true + status = initializing + upsert/delete + hook: plugins) + D -->|catch| E[(db: update status = failed)] + E --> B + D -->|OK| G[(db: update projectLocked = false + status = created)] + G -->|OK| H>send code 200, 201] + G -->|catch| E +``` + +## Principe + +### Controller + +- reçoit la requête +- appelle la couche business +- renvoie la réponse + +### Business + +- est appelé par le controller +- appelle l'ORM pour les opérations en bdd +- appelle éventuellement les plugins +- gère les erreurs (try catch) +- renvoie un résultat ou une erreur au controller diff --git a/.jjconflict-base-1/misc/images/environments.png b/.jjconflict-base-1/misc/images/environments.png new file mode 100644 index 0000000000..2c9f47a130 Binary files /dev/null and b/.jjconflict-base-1/misc/images/environments.png differ diff --git a/.jjconflict-base-1/misc/images/members.png b/.jjconflict-base-1/misc/images/members.png new file mode 100644 index 0000000000..bcd9955ba6 Binary files /dev/null and b/.jjconflict-base-1/misc/images/members.png differ diff --git a/.jjconflict-base-1/misc/images/projects.png b/.jjconflict-base-1/misc/images/projects.png new file mode 100644 index 0000000000..de48c1d125 Binary files /dev/null and b/.jjconflict-base-1/misc/images/projects.png differ diff --git a/.jjconflict-base-1/misc/images/repositories.png b/.jjconflict-base-1/misc/images/repositories.png new file mode 100644 index 0000000000..b1029c947d Binary files /dev/null and b/.jjconflict-base-1/misc/images/repositories.png differ diff --git a/.jjconflict-base-1/misc/images/services.png b/.jjconflict-base-1/misc/images/services.png new file mode 100644 index 0000000000..4b346ebf31 Binary files /dev/null and b/.jjconflict-base-1/misc/images/services.png differ diff --git a/.jjconflict-base-1/misc/images/status.png b/.jjconflict-base-1/misc/images/status.png new file mode 100644 index 0000000000..472e57d330 Binary files /dev/null and b/.jjconflict-base-1/misc/images/status.png differ diff --git a/.jjconflict-base-1/misc/plugins.md b/.jjconflict-base-1/misc/plugins.md new file mode 100644 index 0000000000..b062c3285d --- /dev/null +++ b/.jjconflict-base-1/misc/plugins.md @@ -0,0 +1,67 @@ +# Plugins + +## Lexique + +Un plugin `core` est un service de base associé à la chaîne DSO. +Un plugin `external` est un service supplémentaire facultatif. +Un `hook` est un point de déclenchement dont le cycle de vie comprend plusieurs étapes (`step`) : `pre`, `main`, `post`, etc. +Tout `plugin` peut s'enregistrer sur un `hook`, à une étape donnée. + +## Principe + +Exemple : + +1. Le plugin `gitlab` s'enregistre sur le hook `createProject`, à l'étape `main`. +2. Le controller `createProjectController` déclenche le hook `createProject` en lui associant un `payload`. +3. Le plugin `gitlab` réagit au déclenchement du hook `createProject` et reçoit le payload associé du controller. +4. Le plugin `gitlab` effectue ses opérations (ex: création d'un groupe GitLab pour le projet). +5. Le plugin `gitlab` renvoie un objet `result` au controller `createProjectController`, contenant un `status` indiquant si tout s'est déroulé sans erreur ou non. +6. Le controller `createProjectController` enregistre ce `result` en base de donnée et s'arrête si le `status` est en erreur. + +## Modèles + +### Entries + +```js +{ + // args come from controllers + args: { + projectName: 'toto', + }, + failed: true || undefined + /* + [pluginName]: + [pluginName]: + ... + */ +} +``` + +### Result + +Note: Si un module réutilise son propre payload, il doit penser à renvoyer le précédent s'il ne veut pas l'écraser. Charge aussi au plugin de gérer les erreurs des étapes précédentes. + +```js +{ + status: { + result: string('KO' || 'OK'), + message: string(), + }, + vault: [ + { + name: string(), + data: { + // secret data + } + } + ] || undefined + // otherKey: {}, + // anotherKey: {}, +} +``` + +## Notes + +La communication des services associés à la chaîne DSO se fait au maximum en direct par les services Kubernetes via les url internes. On note toutefois les exceptions suivantes : +- Argocd communique avec l'url publique de Gitlab peu importe s'ils sont sur le même cluster. +- L'url publique de Harbor est utilisée dans les jobs de CI : il faudrait sinon une `imagePullSecret` avec url interne pour les jobs de CI et une avec url publique pour les pods des clusters applicatifs. diff --git a/.jjconflict-base-1/package.json b/.jjconflict-base-1/package.json new file mode 100644 index 0000000000..70b33dd885 --- /dev/null +++ b/.jjconflict-base-1/package.json @@ -0,0 +1,66 @@ +{ + "name": "@cpn-console/root", + "type": "module", + "version": "9.22.2", + "private": true, + "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "engines": { + "node": ">=24.0.0 <25.0.0", + "npm": ">=11.0.0" + }, + "scripts": { + "build": "pnpm -r run build", + "build:clean": "pnpm -r run build:clean", + "db:generate": "pnpm -r run db:generate", + "dev": "docker compose -f ./docker/docker-compose.local.yml up -d --remove-orphans && echo '\nStart applications with commands:\n\n pnpm --filter @cpn-console/server run dev\n pnpm --filter @cpn-console/client run dev\n pnpm --filter @cpn-console/server-nestjs run dev\n\nMake sure you provide env variables in `.env` files.'", + "dev:clean": "docker compose -f ./docker/docker-compose.local.yml down --remove-orphans", + "dev:delete": "docker compose -f ./docker/docker-compose.local.yml down -v --remove-orphans", + "docker:dev": "docker compose -f ./docker/docker-compose.dev.yml up --menu", + "docker:dev:build": "export COMPOSE_FILE=./docker/docker-compose.dev.yml && ./scripts/run-build.sh $COMPOSE_FILE", + "docker:dev:clean": "docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans", + "docker:dev:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:dev' -q) ; true", + "docker:e2e": "docker compose -f ./docker/docker-compose.dev.yml up ${TAG:+--no-build} -d --remove-orphans; pnpm playwright:test; docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans", + "docker:e2e-ci": "docker compose -f ./docker/docker-compose.ci.yml up ${TAG:+--no-build} -d --remove-orphans; pnpm playwright:test", + "docker:e2e-ci:delete": "docker compose -f ./docker/docker-compose.ci.yml down -v --remove-orphans", + "docker:integ": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.integ.yml up --menu", + "docker:integ:build": "export $(cat apps/server/.env.integ | grep -v '#' | xargs) && export COMPOSE_FILE=./docker/docker-compose.integ.yml && cd $(dirname $COMPOSE_FILE) && docker buildx bake --allow=fs.read=.. --file $(basename $COMPOSE_FILE) --load && cd - > /dev/null", + "docker:integ:clean": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.integ.yml down --remove-orphans", + "docker:integ:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:integ' -q) ; true", + "docker:ci": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.ci.yml up --menu", + "docker:ci:build": "export $(cat apps/server/.env.integ | grep -v '#' | xargs) && export COMPOSE_FILE=./docker/docker-compose.ci.yml && cd $(dirname $COMPOSE_FILE) && docker buildx bake --allow=fs.read=.. --file $(basename $COMPOSE_FILE) --load && cd - > /dev/null", + "docker:ci:clean": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.ci.yml down --remove-orphans", + "docker:ci:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:ci' -q) ; true", + "format": "pnpm -r run format", + "format:root": "eslint . --fix", + "format:style": "pnpm -r run format:style", + "fullclean": "sh ./ci/scripts/cleanup.sh", + "fullsetup": "sh ./ci/scripts/setup.sh", + "integ": "docker compose -f ./docker/docker-compose.local.yml up -d --remove-orphans postgres pgadmin && echo '\nStart server and client with commands:\n\n pnpm --filter @cpn-console/server run integ\n pnpm --filter @cpn-console/client run integ\n pnpm --filter @cpn-console/server-nestjs run integ\n\nMake sure you provide env variables in `.env.integ` files.'", + "integ:clean": "docker compose -f ./docker/docker-compose.local.yml down --remove-orphans", + "integ:delete": "docker compose -f ./docker/docker-compose.local.yml down -v --remove-orphans", + "lint": "pnpm -r run lint", + "lint:root": "eslint .", + "lint:style": "pnpm -r run lint:style", + "lint:ts": "pnpm -r run lint:ts", + "playwright:test": "pnpm --dir playwright run playwright:test", + "playwright:test:ui": "pnpm --dir playwright run playwright:test:ui", + "playwright:test:integration": "pnpm --dir playwright run playwright:test:integration", + "prepare": "husky", + "test": "pnpm -r --no-bail run test", + "test:cov": "pnpm -r --no-bail run test:cov", + "test:e2e": "pnpm kube:e2e", + "test:e2e-ci": "pnpm kube:prod; pnpm kube:e2e-ci" + }, + "devDependencies": { + "@commitlint/cli": "catalog:tools", + "@commitlint/config-conventional": "catalog:tools", + "@cpn-console/eslint-config": "workspace:^", + "eslint": "catalog:tools", + "husky": "catalog:tools", + "lint-staged": "catalog:tools" + } +} diff --git a/.jjconflict-base-1/packages/eslintconfig/eslint.config.js b/.jjconflict-base-1/packages/eslintconfig/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-1/packages/eslintconfig/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-1/packages/eslintconfig/package.json b/.jjconflict-base-1/packages/eslintconfig/package.json new file mode 100644 index 0000000000..e81e294cbe --- /dev/null +++ b/.jjconflict-base-1/packages/eslintconfig/package.json @@ -0,0 +1,21 @@ +{ + "name": "@cpn-console/eslint-config", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "type": "module", + "version": "1.0.2", + "license": "ISC", + "main": "src/index.js", + "types": "types/index.d.ts", + "devDependencies": { + "@antfu/eslint-config": "catalog:tools", + "eslint": "catalog:tools" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/packages/eslintconfig/src/index.js b/.jjconflict-base-1/packages/eslintconfig/src/index.js new file mode 100644 index 0000000000..79867556e6 --- /dev/null +++ b/.jjconflict-base-1/packages/eslintconfig/src/index.js @@ -0,0 +1,41 @@ +import antfu from '@antfu/eslint-config' + +export default antfu( + { + stylistic: { + overrides: { + 'antfu/consistent-chaining': 'off', + 'antfu/if-newline': 'off', + 'antfu/no-top-level-await': 'off', + 'node/prefer-global/process': ['error', 'always'], + 'node/prefer-global/buffer': ['error', 'always'], + 'style/comma-dangle': ['error', 'always-multiline'], + 'style/quote-props': ['error', 'as-needed', { keywords: false, unnecessary: true }], + 'style/brace-style': ['error', '1tbs', { allowSingleLine: true }], + 'style/max-statements-per-line': ['error', { max: 2 }], + 'ts/ban-ts-comment': 'off', + }, + }, + typescript: true, + vue: true, + yaml: { + overrides: { + 'yaml/quotes': ['error', { prefer: 'double' }], + 'yaml/indent': ['error', 2, { indentBlockSequences: true, indicatorValueIndent: 2 }], + }, + }, + ignores: [ + '**/node_modules', + '**/prisma/migrations', + '**/pnpm-lock.yaml', + '**/dist/', + '**/types/', + '**/coverage/', + '**/templates/*.{yaml,yml}', + '**/Chart.yaml', + '**/*.d.ts', + '**/*.md/*.js', + '**/*.md/*.ts', + ], + }, +) diff --git a/.jjconflict-base-1/packages/hooks/.gitignore b/.jjconflict-base-1/packages/hooks/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-1/packages/hooks/README.md b/.jjconflict-base-1/packages/hooks/README.md new file mode 100644 index 0000000000..e0149e15c8 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/README.md @@ -0,0 +1,245 @@ +# Plugin manager + +## Présentation + +Le plugin manager ou communément appelés "hooks" est une interface permettant au serveur d'api (`apps/server`) de déclencher des hooks "déclencheurs / événements" pour que les plugins puissent piloter et configurer des outils. + +Les plugins obligatoires de l'application sont souvent appelés `corePlugins`. + +L'objectif est que le server soit totalement agnostique des actions des plugins et même de leur existence. De plus le serveur d'API essayera au démarrage de charger des plugins dans un dossier spécifique, on les distingue en les appelant `externalPlugins`. + +## Développement de plugin + +Cette section est dédiée à tous ceux voulant améliorer les corePlugins ou en créer de nouveaux. + +Pour commencer je vous conseille d'aller regarder le [plugin vault](../../plugins/vault) + +### Interface + +Tout paquet existant devrait avoir un export nommé `plugin` et de type `Plugin`. + +Ce typage vous assurera de bien fournir toutes les clés nécessaires au bon fonctionnement du plugin : + +```ts +// index.ts +import type { Plugin } from '@cpn-console/hooks' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: {}, + monitor, +} +``` + +### Infos + +Ce sont les infos de bases de votre application : + +```ts +// infos.ts +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos: ServiceInfos = { + name: 'my_plugin', // il serait bien que ça ne change jamais, imaginez que c'est un identifiant unique. + to: ({ projectSlug }) => `${process.env.MON_URL}/${projectSlug}`, + title: 'Mon super plugin', + imgSrc: 'https://un_lien_vers/image_externe.svg/', // préférez le svg + description: 'La description générale de mon plugin', +} + +export default infos +``` + +La fonction `to` peux renvoyer une `String` ou un objet contenant une clé `to` et potentiellement d'autres informations ou encore un tableau de cet objet : + +```ts +// Valid +const to1 = () => 'une url' +const to2 = () => ({ to: 'url', title: 'Un titre', description: 'description', imgSrc: 'url' }) +function to3() { + return [ + { to: 'url générale', title: 'Service générale', description: 'description générale' }, + { to: 'url1', title: 'Service 1', description: 'description 1' }, + { to: 'url2', title: 'Service 2', description: 'description 2' }, + ] +} +// Invalid +const to3 = () => ['url', 'url1', 'url2'] +``` + +### Monitoring + +Pour offrir un (et un seul) service de monitoring sommaire de l'outil que le plugin manipule, vous pouvez créer une instance de la classe `Monitor` et l'initialiser avec une fonction dont vous aurez seul la maîtrise. +Cette fonction sera éxécutée par un `setInterval` toutes les 5 min ou selon le temps en miliseconds que vous aurez fourni : + +```ts +// monitor.ts +import { Monitor, type MonitorInfos, MonitorStatus } from '@cpn-console/shared' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = (new Date()).getTime() + // Votre fonction ne devrait jamais lever d'exception + try { + // faites des trucs + // mettez à jour les clés en fonction de votre résultat: + // instance.lastStatus.message = 'Tout va bien' + // instance.lastStatus.status = MonitorStatus.OK + } catch (error) { + instance.lastStatus.message = 'Error lors la requete' + instance.lastStatus.status = MonitorStatus.UNKNOW + // la clé cause n'est pour l'instant jamais retourné à l'utilisateur ni stocké, ça sera pour une prochaine PR + instance.lastStatus.cause = error + } + // c'est bien de le retourner mais on s'en fiche un peu + return instance.lastStatus +} + +export default new Monitor(monitor) +``` + +### SubscribedHooks + +Pour informer le plugin manager sur quels hooks vous voulez exécuter une fonction, vous devez fournir un objet qui aura cette structure : + +```ts + subscribedHooks: { + createProject: { + steps: { + pre: createDsoProjectFirst, + post: createDsoProjectLast, + }, + }, + archiveProject: { + steps: { post: archiveDsoProject }, + }, + }, +``` + +### Fonctions (StepCalls) + +Pensez à typer vos fonctions comme suit pour que TS soit capable de vous notifier si vous avez associé un mauvais payload à un hook. + +**Vos fonctions ne doivent pas non plus lever d'exceptions non gérées.** +**Et elles doivent toutes retourner un statut !** + +Vous pouvez aussi retourner des clés supplémentaires. Ces clés sont accessibles par tous les plugins dans `payload.results[nom_du_plugin]`. + +Attention, chaque fonction écrase le résultat de la step d'avant : + +```ts +export const createDsoProjectFirst: StepCall = async (payload) => { + try { + // fais des trucs + // oh j'ai un payload typé ! + return { + status: { + result: 'OK', + message: 'ça s\'est bien passé' // optionnel si OK + }, + foo: { + bar: 'complétement facultatif' + } + } + } catch (error) { + return { + status: { + result: 'KO', + message: 'Ouille !' // Obligatoire pour explique ce qui n'a pas réussi' + }, + } + } +} + +export const createDsoProjectLast: StepCall = async (payload) => { + try { + // fais des trucs + // oh j'ai un payload typé ! + return { + status: { + result: 'OK', + message: `${payload.results.my_plugin.foo.bar} a vraiment bien été crée` // optionnel si OK + }, + une_clé: { + newProjectName: 'complétement facultatif' + } + } + } catch (error) { + return { + status: { + result: 'KO', + message: 'Ouille !' // Obligatoire pour explique ce qui n'a pas réussi' + }, + } + } +} +``` + +### Apis + +Il n'existe pour l'instant qu'un seul plugin qui exporte une API pour les autres plugins, c'est Vault ! +D'autres viendront aider à la séparation des responsabiltés plus tard, stay tuned ! + +Vous pouvez, comme lui, déclarer des apis sur des hooks. Pour l'uniformité, déclarez que c'est un `extends` de `PluginApi` (qui est vide) : + +```ts +// api.ts +import { PluginApi } from '@cpn-console/hooks' + +export class ClusterApi extends PluginApi {} +``` + +```ts +// index.ts + subscribedHooks: { + createCluster: { + api: (args) => new ClusterApi(args.label, args.privacy), + steps: { post: createCluster }, + }, + deleteCluster: { + api: (args) => new ClusterApi(args.label), + }, + }, +``` + +### Dépendances entre les plugins + +La section précédente est bien sympathique mais en l'état, le plugin n'a aucune conscience des apis des autres plugins. + +> Disclaimer: Les plugins peuvent se parler entre eux mais attention ils ne s'importent jamais les uns les autres. C'est le Plugin Manager qui est en charge de fournir tous les objets nécessaires et de faire passe plat. + +Pour y arriver il va falloir deux étapes + +1) Le plugin qui expose l'api doit faire un `declare module` : + +```ts +// index.ts +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + vault: Args extends CreateClusterExecArgs | DeleteClusterExecArgs + ? ClusterApi + : undefined + } +} +``` + +1) Le module l'utilisant doit importer les types : + +```json +// package.json +{ + "peerDependencies": { + "my_plugin": "1.2.3" + } +} +``` + +```ts +// src/env.d.ts +/// +``` + +## Conclusion + +N'hésitez pas à ouvrir des issues si ce n'est pas clair et bon développement ! diff --git a/.jjconflict-base-1/packages/hooks/eslint.config.js b/.jjconflict-base-1/packages/hooks/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-1/packages/hooks/package.json b/.jjconflict-base-1/packages/hooks/package.json new file mode 100644 index 0000000000..e13bdf29ad --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/package.json @@ -0,0 +1,57 @@ +{ + "name": "@cpn-console/hooks", + "type": "module", + "version": "4.1.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'vite-node src/config.ts'", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "test:e2e-ci": "echo 'check cache'", + "test:watch": "vitest" + }, + "dependencies": { + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "json-schema": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/json-schema": "catalog:types", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "undici-types": "catalog:types", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/packages/hooks/src/config.ts b/.jjconflict-base-1/packages/hooks/src/config.ts new file mode 100644 index 0000000000..ab1a702bf9 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/config.ts @@ -0,0 +1,76 @@ +import type { PermissionTarget, PluginConfig } from '@cpn-console/shared' +import { atomicValidators, DEFAULT, pluginConfig } from '@cpn-console/shared' +import { z } from 'zod' +import { objectEntries } from './utils/utils.js' + +export type PluginsManifests = Record +export const pluginsManifests: PluginsManifests = {} + +export const editStrippersGenerator = pluginConfig.transform((arg) => { + const project = { + user: z.object({}), + admin: z.object({}), + } + let global = z.object({}) + + for (const item of arg.project) { + const zAny = atomicValidators[item.kind].optional() + if (item.permissions.user.write) { + project.user = project.user.merge(z.object({ [item.key]: zAny })) + } + if (item.permissions.admin.write) { + project.admin = project.admin.merge(z.object({ [item.key]: zAny })) + } + } + + for (const item of arg.global || []) { + if (item.permissions.admin.write) { + const zAny = atomicValidators[item.kind].optional() + global = global.merge(z.object({ [item.key]: zAny })) + } + } + return { project, global } +}) + +export const editStrippers = { + project: { + admin: z.object({}), + user: z.object({}), + }, + global: z.object({}), +} + +export function addPlugin(pluginName: string, config: PluginConfig, editStrippersObject: typeof editStrippers) { + const editZod = editStrippersGenerator.parse(config) + pluginsManifests[pluginName] = config + editStrippersObject.global = editStrippersObject.global.merge(z.object({ [pluginName]: editZod.global.default({}) })) + editStrippersObject.project.admin = editStrippersObject.project.admin.merge(z.object({ [pluginName]: editZod.project.admin.default({}) })) + editStrippersObject.project.user = editStrippersObject.project.user.merge(z.object({ [pluginName]: editZod.project.user.default({}) })) +} + +interface Row { pluginName: string, key: string, value: string } +interface PopulateManifestsParams { + data: { project?: Row[], global?: Row[] } + permissionTarget: PermissionTarget + select: Partial> + pluginName: string +} +export function populatePluginManifests({ data, select, permissionTarget, pluginName }: PopulateManifestsParams): Partial { + const manifest = structuredClone(pluginsManifests[pluginName]) + + const selected: Partial = {} + for (const [scope] of objectEntries(select).filter(([_scope, bool]) => bool)) { + if (!manifest?.[scope]) continue + selected[scope] = manifest[scope].filter(item => item.permissions[permissionTarget].read || item.permissions[permissionTarget].write).map((item) => { + const row = data[scope]?.find(candidate => candidate.pluginName === pluginName && candidate.key === item.key) + if (item.kind === 'switch') { + const value = atomicValidators.switch.safeParse(row?.value) + item.value = value.success ? value.data : DEFAULT + } else { + item.value = z.coerce.string().parse(row?.value ?? item.value ?? '') + } + return item + }) + } + return selected +} diff --git a/.jjconflict-base-1/packages/hooks/src/hooks/hook-admin-role.ts b/.jjconflict-base-1/packages/hooks/src/hooks/hook-admin-role.ts new file mode 100644 index 0000000000..ff9b55292d --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/hooks/hook-admin-role.ts @@ -0,0 +1,16 @@ +import type { Hook } from './hook.js' +import type { UserObject } from './index.js' +import { createHook } from './hook.js' + +export interface AdminRole { + id: string + name: string + permissions: bigint + position: number + oidcGroup: string + type?: string + members: UserObject[] +} + +export const upsertAdminRole: Hook = createHook() +export const deleteAdminRole: Hook = createHook() diff --git a/.jjconflict-base-1/packages/hooks/src/hooks/hook-cluster.ts b/.jjconflict-base-1/packages/hooks/src/hooks/hook-cluster.ts new file mode 100644 index 0000000000..faeef0d8b7 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/hooks/hook-cluster.ts @@ -0,0 +1,6 @@ +import type { Hook } from './hook.js' +import type { ClusterObject } from './index.js' +import { createHook } from './hook.js' + +export const upsertCluster: Hook = createHook() +export const deleteCluster: Hook = createHook() diff --git a/.jjconflict-base-1/packages/hooks/src/hooks/hook-misc.ts b/.jjconflict-base-1/packages/hooks/src/hooks/hook-misc.ts new file mode 100644 index 0000000000..faf6705c12 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/hooks/hook-misc.ts @@ -0,0 +1,16 @@ +import type { Project, Repository } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +// misc hooks +export type EmptyPayload = Record + +export const checkServices: Hook = createHook() + +// misc project related hooks +export type ProjectLite = Pick + +export const getProjectSecrets: Hook = createHook() + +export type UniqueRepo = ProjectLite & { repo: Omit & { syncAllBranches: boolean, branchName?: string } } +export const syncRepository: Hook = createHook() diff --git a/.jjconflict-base-1/packages/hooks/src/hooks/hook-project-member.ts b/.jjconflict-base-1/packages/hooks/src/hooks/hook-project-member.ts new file mode 100644 index 0000000000..c1d726f599 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/hooks/hook-project-member.ts @@ -0,0 +1,16 @@ +import type { ProjectRole } from './hook-project-role.js' +import type { Project } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +export interface ProjectMember { + userId: string + email: string + firstName: string + lastName: string + roles: ProjectRole[] + project: Project +} + +export const upsertProjectMember: Hook = createHook() +export const deleteProjectMember: Hook = createHook() diff --git a/.jjconflict-base-1/packages/hooks/src/hooks/hook-project-role.ts b/.jjconflict-base-1/packages/hooks/src/hooks/hook-project-role.ts new file mode 100644 index 0000000000..8d047f4d79 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/hooks/hook-project-role.ts @@ -0,0 +1,17 @@ +import type { Project } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +export interface ProjectRole { + id: string + name: string + permissions: string + projectId: string + position: number + type?: string + oidcGroup?: string + project: Project +} + +export const upsertProjectRole: Hook = createHook() +export const deleteProjectRole: Hook = createHook() diff --git a/.jjconflict-base-1/packages/hooks/src/hooks/hook-project.ts b/.jjconflict-base-1/packages/hooks/src/hooks/hook-project.ts new file mode 100644 index 0000000000..86d65eff97 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/hooks/hook-project.ts @@ -0,0 +1,74 @@ +import type { PluginApi } from '../utils/utils.js' +import type { Hook } from './hook.js' +import type { ClusterObject, ExternalRepoUrl, InternalRepoName, IsInfra, IsPrivate, UserObject } from './index.js' +import { createHook } from './hook.js' + +export interface RepoCreds { + username: string + token: string +} + +export interface Role { + name: string + permissions?: string + position: number + type?: string + oidcGroup?: string + users: UserObject[] +} + +export interface EnvironmentApis { + [x: string]: PluginApi +} +export interface Environment { + id: string + name: string + clusterId: ClusterObject['id'] + cpu: number + gpu: number + memory: number + stage: string + autosync: boolean + permissions: { + userId: UserObject['id'] + permissions: { + ro: boolean + rw: boolean + } + }[] + apis: EnvironmentApis +} + +export interface Repository { + id: string + internalRepoName: InternalRepoName + newCreds?: RepoCreds + externalRepoUrl: ExternalRepoUrl + isPrivate: IsPrivate + isInfra: IsInfra + deployRevision: string | null + deployPath: string | null + helmValuesFiles: string | null +} + +export interface ProjectStore { + [x: string]: { [x: string]: string } +} + +export interface Project { + id: string + description: string | null + name: string + status: string + clusters: ClusterObject[] + slug: string + environments: Environment[] + repositories: Repository[] + users: UserObject[] + roles: Role[] + store: ProjectStore + owner: UserObject +} + +export const upsertProject: Hook = createHook() +export const deleteProject: Hook = createHook() diff --git a/.jjconflict-base-1/packages/hooks/src/hooks/hook-user.ts b/.jjconflict-base-1/packages/hooks/src/hooks/hook-user.ts new file mode 100644 index 0000000000..ffb9a9cb97 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/hooks/hook-user.ts @@ -0,0 +1,7 @@ +import type { Hook } from './hook.js' +import type { UserObject } from './index.js' +import { createHook } from './hook.js' + +export type UserEmail = Pick +export type UserAdmin = Pick & { isAdmin: boolean } +export const retrieveUserByEmail: Hook = createHook() diff --git a/.jjconflict-base-1/packages/hooks/src/hooks/hook-zone.ts b/.jjconflict-base-1/packages/hooks/src/hooks/hook-zone.ts new file mode 100644 index 0000000000..dd2ffc7710 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/hooks/hook-zone.ts @@ -0,0 +1,6 @@ +import type { Hook } from './hook.js' +import type { ZoneObject } from './index.js' +import { createHook } from './hook.js' + +export const upsertZone: Hook = createHook() +export const deleteZone: Hook = createHook() diff --git a/.jjconflict-base-1/packages/hooks/src/hooks/hook.spec.ts b/.jjconflict-base-1/packages/hooks/src/hooks/hook.spec.ts new file mode 100644 index 0000000000..3dcfe36fd9 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/hooks/hook.spec.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest' +import { okStatus, PluginApi } from '../utils/utils.ts' +import { createHook, executeStep } from './hook.ts' + +const koStatus = { status: { result: 'KO', message: 'Failed' } } as const +const warningStatus = { status: { result: 'WARNING', message: 'Failed' } } as const + +async function simpleOkHookCall() { + return okStatus +} +async function simpleFailedHookCall() { + return koStatus +} +async function simpleWarningHookCall() { + return warningStatus +} + +describe('test executeStep mechanism', () => { + it('test payload results, everything ok', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleOkHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.failed).toBe(false) + expect(results.warning).toEqual([]) + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...okStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, everything ko', async () => { + const results = await executeStep({ + plugin1: simpleFailedHookCall, + plugin2: simpleFailedHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual([]) + expect(results.failed).contain('plugin1') + expect(results.failed).contain('plugin2') + expect(results.results).toEqual({ + plugin1: { ...koStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...koStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, partial ko', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleFailedHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual([]) + expect(results.failed).not.contain('plugin1') + expect(results.failed).contain('plugin2') + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...koStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, partial warning', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleWarningHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual(['plugin2']) + expect(results.failed).toEqual(false) + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...warningStatus, executionTime: { main: expect.any(Number) } }, + }) + }) +}) + +describe('createHook', () => { + it('test empty hookStructure', async () => { + const hook = createHook(false) + expect(hook).toEqual({ + apis: {}, + steps: { + check: {}, + pre: {}, + main: {}, + post: {}, + revert: {}, + }, + execute: expect.any(Function), + }) + + const hookUnique = createHook(true) + expect(hookUnique.uniquePlugin).toBe('') + }) + + it('test hook execution, simple ok', async () => { + const hook = createHook(false) + hook.steps.main.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, multistep ok', async () => { + const hook = createHook(false) + hook.steps.pre.plugin1 = simpleOkHookCall + hook.steps.main.plugin1 = simpleOkHookCall + hook.steps.post.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.warning).toEqual([]) + expect(hookResult.results).toEqual({ plugin1: { ...okStatus, executionTime: { + pre: expect.any(Number), + main: expect.any(Number), + post: expect.any(Number), + } } }) + }) + + it('test payload results, multistep with warning', async () => { + const hook = createHook(false) + hook.steps.pre.plugin1 = simpleWarningHookCall + hook.steps.main.plugin2 = simpleOkHookCall + hook.steps.post.plugin2 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.warning).toEqual(['plugin1']) + expect(hookResult.results).toEqual({ + plugin1: { + ...warningStatus, + executionTime: { + pre: expect.any(Number), + }, + }, + plugin2: { + ...okStatus, + executionTime: { + main: expect.any(Number), + post: expect.any(Number), + }, + }, + }) + }) + + it('test payload results, main fails', async () => { + const hook = createHook(false) + hook.apis.plugin1 = () => new PluginApi() // à tester ailleurs + hook.steps.pre.plugin1 = simpleOkHookCall + hook.steps.main.plugin1 = simpleFailedHookCall + hook.steps.post.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(['plugin1']) + expect(hookResult.results).toEqual({ + plugin1: { + ...koStatus, + executionTime: { + pre: expect.any(Number), + main: expect.any(Number), + }, + }, + }) + }) +}) diff --git a/.jjconflict-base-1/packages/hooks/src/hooks/hook.ts b/.jjconflict-base-1/packages/hooks/src/hooks/hook.ts new file mode 100644 index 0000000000..1377d9abcc --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/hooks/hook.ts @@ -0,0 +1,192 @@ +import type { PluginApi } from '../utils/utils.js' +import type * as hooks from './index.js' +import { logger } from '@cpn-console/logger/hooks' + +export type DefaultArgs = Record +export type PluginResultStoreValue = string | number | null +export type PluginResultStore = Record +export interface PluginResult { + status: { result: 'OK', message?: string } | { result: 'KO' | 'WARNING', message: string } + store?: PluginResultStore + [key: string]: any +} + +export interface HookPayloadResults { + [x: string]: PluginResult +} +// @ts-ignore +// eslint-disable-next-line unused-imports/no-unused-vars +export interface HookPayloadApis { + [x: string]: PluginApi +} +export type Store = Record> // TO DEPRECIATE USE ONFIG + +export interface Config { + [x: string]: { [x: string]: string } +} + +export interface HookPayload { + args: Args + failed: boolean | string[] + warning: string[] + results: HookPayloadResults + apis: HookPayloadApis + config: Config +} + +export type HookResult = Omit, 'apis'> & { totalExecutionTime: number, messageResume?: string } + +export type StepCall = (payload: HookPayload) => Promise +type HookStep = Record> +export type HookStepsNames = 'check' | 'pre' | 'main' | 'post' | 'revert' +export interface Hook { + uniquePlugin?: string // if plugin register on it no other one can register on it + execute: (args: E, store: Config) => Promise> + apis: Record PluginApi> + steps: Record +} +export type HookList = Record> + +function generateMessageResume(payload: HookPayload): string | undefined { + let messageResume = '' + if (Array.isArray(payload.failed)) { + for (const pluginName of payload.failed) { + messageResume += 'Errors:' + messageResume += `\n${pluginName}: ${payload.results[pluginName].status.message};` + } + } + if (payload.warning.length) { + for (const pluginName of payload.warning) { + messageResume += 'Warnings:' + messageResume += `\n${pluginName}: ${payload.results[pluginName].status.message};` + } + } + return messageResume || undefined +} + +function handleRejectedStepResult( + reason: any, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + logger.error({ plugin: name, step: stepName, err: reason }, 'Hook step failed') + const result: PluginResult = { + status: { + result: 'KO', + message: reason instanceof Error ? reason.message : String(reason), + }, + } + payload.failed = Array.isArray(payload.failed) + ? [...payload.failed, name] + : [name] + return result +} + +function handleFulfilledStepResult( + value: PluginResult, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + if (value.status.result === 'KO') { + payload.failed = Array.isArray(payload.failed) + ? [...payload.failed, name] + : [name] + logger.error({ plugin: name, step: stepName, err: value.status.message, status: value.status.result, message: value.status.message }, 'Hook step failed') + } else if (value.status.result === 'WARNING' && !payload.warning.includes(name)) { + payload.warning.push(name) + logger.warn({ plugin: name, step: stepName, err: value.status.message, status: value.status.result, message: value.status.message }, 'Hook step warning') + } else { + logger.trace({ plugin: name, step: stepName, status: value.status.result }, 'Hook step fulfilled') + } + return value +} + +function handleStepResult( + settled: PromiseSettledResult, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + const result = settled.status === 'fulfilled' + ? handleFulfilledStepResult(settled.value, name, stepName, payload) + : handleRejectedStepResult(settled.reason, name, stepName, payload) + return { ...result, executionTime: payload.results[name].executionTime } +} + +export async function executeStep(step: HookStep, payload: HookPayload, stepName: string) { + const names = Object.keys(step) + const fns = names.map(async (name) => { + if (payload.results[name]?.executionTime) { + payload.results[name].executionTime[stepName] = Date.now() + } else { + payload.results[name] = { + status: { result: 'OK' }, + executionTime: { [stepName]: Date.now() }, + } + } + const fnResult = await step[name](payload) + payload.results[name].executionTime[stepName] = Date.now() - payload.results[name].executionTime[stepName] + return fnResult + }) + const results = await Promise.allSettled(fns) + names.forEach((name, index) => { + payload.results[name] = handleStepResult(results[index], name, stepName, payload) + }) + return payload +} +export function createHook(unique = false) { + const steps: Record = { + check: {}, + pre: {}, + main: {}, + post: {}, + revert: {}, + } + const apis: Record PluginApi> = { + } + const execute = async (args: E, config: Config): Promise> => { + const startTime = Date.now() + const payloadApis: HookPayloadApis = {} + Object.entries(apis).forEach(([pluginName, apiFn]) => { + payloadApis[pluginName] = apiFn(args) + }) + let payload: HookPayload = { + failed: false, + args, + results: {}, + apis: payloadApis, + config, + warning: [], + } + + const executeSteps = ['pre', 'main', 'post'] as const + for (const step of executeSteps) { + payload = await executeStep(steps[step], payload, step) + if (payload.failed) { + payload = await executeStep(steps.revert, payload, 'revert') + break + } + } + return { + args: payload.args, + results: payload.results, + failed: payload.failed, + warning: payload.warning, + totalExecutionTime: Date.now() - startTime, + config, + messageResume: generateMessageResume(payload), + } + } + + const hook: Hook = { + apis, + steps, + execute, + } + if (unique) { + hook.uniquePlugin = '' + } + return hook +} diff --git a/.jjconflict-base-1/packages/hooks/src/hooks/index.ts b/.jjconflict-base-1/packages/hooks/src/hooks/index.ts new file mode 100644 index 0000000000..a783454745 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/hooks/index.ts @@ -0,0 +1,68 @@ +export * from './hook-admin-role.js' +export * from './hook-cluster.js' +export * from './hook-misc.js' +export * from './hook-project-member.js' +export * from './hook-project-role.js' +export * from './hook-project.js' +export * from './hook-user.js' +export * from './hook-zone.js' + +export interface PartialEnvironment { environment: string, stage: string, clusterLabel: string } +export type InternalRepoName = string +export type ExternalRepoUrl = string +export type ExternalUserName = string +export type ExternalToken = string +export type IsPrivate = boolean +export type IsInfra = boolean +export type InternalUrl = string +export interface UserObject { + firstName: string + lastName: string + id: string + email: string +} +export interface EnvironmentObject { + id: string + name: string + createdAt: Date + updatedAt: Date + clusterId: string +} + +export interface KubeUser { + readonly certData?: string + readonly keyData?: string + readonly token?: string + readonly username?: string + readonly password?: string +} + +export interface KubeCluster { + readonly caData?: string + readonly server: string + readonly skipTLSVerify?: boolean + readonly tlsServerName?: string +} + +export interface ZoneObject { + id: string + slug: string + label: string + argocdUrl: string + clusterNames?: string[] +} + +export interface ClusterObject { + id: string + label: string + privacy: 'public' | 'dedicated' + /** + * @deprecated Le nom aléatoire stocké ici n'est plus utilisé, au profit d'un nom caculé et déterministe. + */ + secretName: string + clusterResources: boolean + infos: string | null + cluster: KubeCluster + user: KubeUser + zone: ZoneObject +} diff --git a/.jjconflict-base-1/packages/hooks/src/index.ts b/.jjconflict-base-1/packages/hooks/src/index.ts new file mode 100644 index 0000000000..ad4af3bca8 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/index.ts @@ -0,0 +1,135 @@ +import type { Monitor } from '@cpn-console/shared' +import type { HookStepsNames, StepCall } from './hooks/hook.js' +import type { ServiceInfos } from './services.js' +import type { PluginApi } from './utils/utils.js' +import { logger } from '@cpn-console/logger/hooks' +import { addPlugin, editStrippers } from './config.js' +import * as hooks from './hooks/index.js' +import { servicesInfos } from './services.js' +import { objectEntries } from './utils/utils.js' + +export * from './config.js' + +export type HookChoice = keyof typeof hooks + +type ExecArgs = Parameters[0] + +export type PluginsFunctions = Partial<{ + [C in HookChoice]: { + steps?: Partial<{ + [S in HookStepsNames]: StepCall> + }> + api?: (args: ExecArgs) => PluginApi + } +}> + +export interface Plugin { + infos: ServiceInfos + subscribedHooks: PluginsFunctions + monitor?: Monitor + start?: (options: unknown) => void +} + +export type RegisterFn = (plugin: Plugin) => void +export type UnregisterFn = (name: Plugin['infos']['name']) => void +export interface PluginManager { + servicesInfos: Record + register: RegisterFn + unregister: UnregisterFn +} + +export interface PluginManagerOptions { + startPlugins?: boolean + mockMonitoring?: boolean + mockHooks?: boolean + mockExternalServices?: boolean + externalDir?: string +} + +let config: PluginManagerOptions +function pluginManager(options: PluginManagerOptions): PluginManager { + config = options + const register: RegisterFn = (plugin: Plugin) => { + if (plugin.infos.config) { + addPlugin(plugin.infos.name, plugin.infos.config, editStrippers) + } + + if (plugin.infos.to && config.mockExternalServices) + plugin.infos.to = () => [{ name: 'Lien', to: 'https://theuselessweb.com/' }] + if (plugin.start && options.startPlugins) + plugin.start({}) + const message: string[] = [] + if (plugin.monitor && config.mockMonitoring) { + plugin.monitor.monitorFn = async (instance: Monitor) => instance.lastStatus + } + if (plugin.monitor) + plugin.monitor.refresh() + servicesInfos[plugin.infos.name] = { + ...plugin.infos, + monitor: plugin.monitor, + } + const subscribedHooks = plugin.subscribedHooks + const name = plugin.infos.name + + if (!config.mockHooks) { + for (const [hook, functions] of objectEntries(subscribedHooks)) { + if (!(hook in hooks)) { + logger.warn({ plugin: name, hook }, 'Tried to register on an unknown hook') + continue + } + if (functions?.api) { + hooks[hook].apis[name] = functions.api + } + for (const [step, fn] of objectEntries(functions?.steps ?? {})) { + if (fn === undefined) + continue + if (hook === 'checkServices' && step !== 'check') { + logger.warn({ plugin: name, hook, step }, 'Tried to register an invalid step for checkServices hook') + continue + } + + if ('uniquePlugin' in hooks[hook] && hooks[hook]?.uniquePlugin !== '' && hooks[hook]?.uniquePlugin !== name) { + logger.warn({ plugin: name, hook, registeredBy: hooks[hook].uniquePlugin }, 'Hook is already registered by another plugin') + continue + } + // @ts-ignore + hooks[hook].steps[step][name] = fn + message.push(`${hook}:${step}`) + } + } + } + if (process.env.NODE_ENV !== 'test') { + logger.info({ plugin: name, registrations: message }, 'Plugin registered') + } + } + + const unregister: UnregisterFn = (name) => { + delete servicesInfos[name] + + Object.values(hooks).forEach((hook) => { + delete hook.steps.pre[name] + delete hook.steps.main[name] + delete hook.steps.post[name] + delete hook.steps.revert[name] + delete hook.apis[name] + }) + } + + return { + servicesInfos, + register, + unregister, + } +} + +export { + hooks, + pluginManager, +} + +export * from './hooks/hook.js' +export * from './hooks/index.js' +export * from './services.js' +export * from './utils/crypto.js' +export * from './utils/plugin-result-handler.js' +export * from './utils/utils.js' diff --git a/.jjconflict-base-1/packages/hooks/src/services.ts b/.jjconflict-base-1/packages/hooks/src/services.ts new file mode 100644 index 0000000000..dce8693564 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/services.ts @@ -0,0 +1,40 @@ +import type { Monitor, MonitorInfos, PluginConfig, PluginsUpdateBody } from '@cpn-console/shared' +import type { ClusterObject, EnvironmentObject, ProjectLite, ZoneObject } from './hooks/index.js' + +interface ToUrlObject { to: string, title?: string, description?: string, imgSrc?: string } +export interface ToUrlFnParamaters { + store: PluginsUpdateBody + clusters: Omit[] + zones: ZoneObject[] + environments: EnvironmentObject[] + project: Omit +} +type ToUrlFnResponse = ToUrlObject | ToUrlObject[] | string | void + +export interface ServiceInfos { + name: string + to?: ({ store, clusters, zones, environments, project }: ToUrlFnParamaters) => ToUrlFnResponse + monitor?: Monitor + title: string + imgSrc?: string + description?: string + config?: PluginConfig +} + +export const servicesInfos: Record = {} + +export type ServiceStatus = MonitorInfos & { name: string } +export const services = { + getStatus: (): Array => { + return Object.values(servicesInfos).reduce((acc, serviceInfos) => { + if (!serviceInfos?.monitor?.lastStatus) return acc + return [...acc, { ...serviceInfos.monitor.lastStatus, name: serviceInfos.title }] + }, [] as Array) + }, + refreshStatus: (): Array> => { + // @ts-ignore obligé d'ignore TS ne comprend pas l'interet du filter + return Object.values(servicesInfos) + .filter(servicesInfos => servicesInfos.monitor) + .map(serviceInfos => serviceInfos.monitor?.refresh()) + }, +} diff --git a/.jjconflict-base-1/packages/hooks/src/utils/crypto.ts b/.jjconflict-base-1/packages/hooks/src/utils/crypto.ts new file mode 100644 index 0000000000..5517e2b906 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/utils/crypto.ts @@ -0,0 +1,16 @@ +import type { Project } from '../hooks/index.js' +import crypto, { createHmac } from 'node:crypto' + +export function generateRandomPassword(length = 24) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*' + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +export function generateProjectKey(projectSlug: Project['slug'], repo: Project['repositories'][0]['internalRepoName']) { + const repoHash = createHmac('sha256', '') + .update(repo) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${repo}-${repoHash}` +} diff --git a/.jjconflict-base-1/packages/hooks/src/utils/index.ts b/.jjconflict-base-1/packages/hooks/src/utils/index.ts new file mode 100644 index 0000000000..ffb3bb4ec5 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './crypto.js' +export * from './utils.js' diff --git a/.jjconflict-base-1/packages/hooks/src/utils/plugin-result-handler.ts b/.jjconflict-base-1/packages/hooks/src/utils/plugin-result-handler.ts new file mode 100644 index 0000000000..a90261aede --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/utils/plugin-result-handler.ts @@ -0,0 +1,92 @@ +import type { PluginResult, PluginResultStore, PluginResultStoreValue } from '../hooks/hook.js' + +export class PluginResultBuilder { + private okMessages = [] as string[] + private warnMessages = [] as string[] + private koMessages = [] as string[] + private extras: Record = {} + public store: PluginResultStore = {} + + constructor(okMessage: string | undefined) { + if (okMessage) { + this.okMessages.push(okMessage) + } + } + + addExtra(key: string, value: any) { + this.extras[key] = value + return this + } + + addOkMessage(...messages: string[]) { + this.okMessages.push(...messages) + return this + } + + addKoMessage(...messages: string[]) { + this.koMessages.push(...messages) + return this + } + + addWarnMessage(...messages: string[]) { + this.warnMessages.push(...messages) + return this + } + + setOkMessage(...messages: string[]) { + this.okMessages = messages + return this + } + + setKoMessage(...messages: string[]) { + this.koMessages = messages + return this + } + + setWarnMessage(...messages: string[]) { + this.warnMessages = messages + return this + } + + setToStore(key: string, value: PluginResultStoreValue) { + this.store[key] = value + } + + deleteFromStore(key: string) { + delete this.store[key] + } + + returnUnexpectedError(error: unknown): PluginResult { + this.addKoMessage('UnexpectedError') + this.addExtra('error', error) + return this.getResultObject() + } + + getResultObject(): PluginResult { + const result: PluginResult = { + status: { + result: 'OK', + message: this.okMessages.join('\n') || undefined, + }, + ...this.extras, + } + + if (this.koMessages.length) { + result.status = { + result: 'KO', + message: this.koMessages.join('\n'), + } + } else if (this.warnMessages.length) { + result.status = { + result: 'WARNING', + message: this.warnMessages.join('\n'), + } + } + + if (Object.keys(this.store).length) { + result.store = this.store + } + + return result + } +} diff --git a/.jjconflict-base-1/packages/hooks/src/utils/utils.spec.ts b/.jjconflict-base-1/packages/hooks/src/utils/utils.spec.ts new file mode 100644 index 0000000000..a463bc5fb2 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/utils/utils.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { defaultOrNullish, disabledOrDefaultOrNullish, enabledOrDefaultOrNullish, objectEntries, objectKeys, objectValues, specificallyDisabled, specificallyEnabled } from './utils.ts' + +const object = { test1: 1, test2: 2, 3: 'test3' } + +it('should return object keys', () => { + const keys = objectKeys(object) + + // cannot gaurantee order in keys + expect(keys[0]).contain('3') + expect(keys[1]).contain('test1') + expect(keys[2]).contain('test2') +}) + +it('should return object entries', () => { + const keys = objectEntries(object) + + expect(keys[0]).toEqual(['3', 'test3']) + expect(keys[1]).toEqual(['test1', 1]) + expect(keys[2]).toEqual(['test2', 2]) +}) + +it('should return object values', () => { + const keys = objectValues(object) + + // cannot gaurantee order in values + expect(keys).contain('test3') + expect(keys).contain(1) + expect(keys).contain(2) +}) + +const values = [ + '', + 'nimp', + 'enabled', + 'default', + 'disabled', +] as const +describe('test config parsing', () => { + it('enabledOrDefaultOrNullish', () => { + expect(enabledOrDefaultOrNullish(values[0])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[1])).toBeFalsy() + expect(enabledOrDefaultOrNullish(values[2])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[3])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[4])).toBeFalsy() + }) + it('specificallyDisabled', () => { + expect(specificallyDisabled(values[0])).toBeFalsy() + expect(specificallyDisabled(values[1])).toBeFalsy() + expect(specificallyDisabled(values[2])).toBeFalsy() + expect(specificallyDisabled(values[3])).toBeFalsy() + expect(specificallyDisabled(values[4])).toBeTruthy() + }) + it('specificallyEnabled', () => { + expect(specificallyEnabled(values[0])).toBeFalsy() + expect(specificallyEnabled(values[1])).toBeFalsy() + expect(specificallyEnabled(values[2])).toBeTruthy() + expect(specificallyEnabled(values[3])).toBeFalsy() + expect(specificallyEnabled(values[4])).toBeFalsy() + }) + it('defaultOrNullish', () => { + expect(defaultOrNullish(values[0])).toBeTruthy() + expect(defaultOrNullish(values[1])).toBeFalsy() + expect(defaultOrNullish(values[2])).toBeFalsy() + expect(defaultOrNullish(values[3])).toBeTruthy() + expect(defaultOrNullish(values[4])).toBeFalsy() + }) + it('disabledOrDefault', () => { + expect(disabledOrDefaultOrNullish(values[0])).toBeTruthy() + expect(disabledOrDefaultOrNullish(values[1])).toBeFalsy() + expect(disabledOrDefaultOrNullish(values[2])).toBeFalsy() + expect(disabledOrDefaultOrNullish(values[3])).toBeTruthy() + expect(disabledOrDefaultOrNullish(values[4])).toBeTruthy() + }) +}) diff --git a/.jjconflict-base-1/packages/hooks/src/utils/utils.ts b/.jjconflict-base-1/packages/hooks/src/utils/utils.ts new file mode 100644 index 0000000000..65d03954c2 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/src/utils/utils.ts @@ -0,0 +1,88 @@ +import type { ServiceInfos } from '../services.js' +import { DEFAULT, DISABLED, ENABLED } from '@cpn-console/shared' + +export class PluginApi { } + +export function objectEntries>(obj: Obj): ([keyof Obj, Obj[keyof Obj]])[] { + return Object.entries(obj) as ([keyof Obj, Obj[keyof Obj]])[] +} +export function objectKeys>(obj: Obj): (keyof Obj)[] { + return Object.keys(obj) as (keyof Obj)[] +} +export function objectValues>(obj: Obj): (Obj[keyof Obj])[] { + return Object.values(obj) as (Obj[keyof Obj])[] +} + +type ConfigScope = keyof Required['config'] + +export type DeclareModuleGenerator, 'config'>, Scope extends ConfigScope> = { + [K in Infos['name']]?: { + [P in Infos['config'][Scope][number]['key']]?: string + } +} + +export const enabledOrDefaultOrNullish = (value?: string): boolean | undefined => value ? [ENABLED, DEFAULT].includes(value) : true +export const disabledOrDefaultOrNullish = (value?: string): boolean | undefined => value ? [DISABLED, DEFAULT].includes(value) : true +export const specificallyDisabled = (value?: string): boolean | undefined => value ? value === DISABLED : undefined +export const specificallyEnabled = (value?: string): boolean | undefined => value ? value === ENABLED : undefined +export const defaultOrNullish = (value?: string): boolean | undefined => value ? DEFAULT === value : true + +export const okStatus = { status: { result: 'OK' } } as const + +/** + * Take a list of list of kubernetes resources and ditch duplicates by name + * + * @remarks Kind is ignored. You can pass as many arguments as you want + * + * @example + * ```ts + * const foo = [{ metadata: { name: 'One' }}, { metadata: { name: 'Two' }}] + * const bar = [{ metadata: { name: 'One' }}, { metadata: { name: 'Three' }}] + * const xyz = [{ metadata: { name: 'Four' }}, { metadata: { name: 'Two' }}] + * uniqueResource(foo, bar, xyz) + * // [ + * // { metadata: { name: 'One' }}, + * // { metadata: { name: 'Two' }}, + * // { metadata: { name: 'Three' }}, + * // { metadata: { name: 'Four' }} + * // ] + * ``` + * + */ +export function uniqueResource(...lists: T[][]): T[] { + return lists + .flat() + .reduce((acc, cur) => (acc.some(item => item.metadata?.name === cur.metadata?.name) + ? acc + : [...acc, cur] + ), [] as T[]) +} + +export interface BaseResources { + kind?: string + apiVersion?: string + metadata: { + name?: string + namespace?: string + labels: { + [x: string]: string + } + [x: string]: any + } + [x: string]: any +} + +export interface BareMinimumResource { + metadata: { + name: string + labels: { + [x: string]: string + } + } +} + +export interface ListMinimumResources { + body: { + items: BareMinimumResource[] + } +} diff --git a/.jjconflict-base-1/packages/hooks/tsconfig.eslint.json b/.jjconflict-base-1/packages/hooks/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-1/packages/hooks/tsconfig.json b/.jjconflict-base-1/packages/hooks/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-1/packages/hooks/vitest.config.ts b/.jjconflict-base-1/packages/hooks/vitest.config.ts new file mode 100644 index 0000000000..58225abfa0 --- /dev/null +++ b/.jjconflict-base-1/packages/hooks/vitest.config.ts @@ -0,0 +1,16 @@ +import { configDefaults, defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: ['**/types', '**/mocks', '**/*.spec.ts', '**/*.d.ts'], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude], + }, +}) diff --git a/.jjconflict-base-1/packages/logger/eslint.config.js b/.jjconflict-base-1/packages/logger/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-1/packages/logger/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-1/packages/logger/package.json b/.jjconflict-base-1/packages/logger/package.json new file mode 100644 index 0000000000..2b14869190 --- /dev/null +++ b/.jjconflict-base-1/packages/logger/package.json @@ -0,0 +1,53 @@ +{ + "name": "@cpn-console/logger", + "type": "module", + "version": "1.0.1", + "private": false, + "description": "Cloud PI Native internal logger for legacy code", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "browser": "./dist/browser.js", + "default": "./dist/index.js" + }, + "./browser": { + "types": "./types/browser.d.ts", + "default": "./dist/browser.js" + }, + "./hooks": { + "types": "./types/hooks.d.ts", + "default": "./dist/hooks.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "pino": "catalog:tools", + "pino-pretty": "catalog:tools", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "rimraf": "catalog:tools", + "typescript": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/packages/logger/src/browser.ts b/.jjconflict-base-1/packages/logger/src/browser.ts new file mode 100644 index 0000000000..f1ed417404 --- /dev/null +++ b/.jjconflict-base-1/packages/logger/src/browser.ts @@ -0,0 +1,16 @@ +import { pino, stdSerializers } from 'pino' + +export const logger = pino({ + customLevels: { + audit: 25, + }, + level: 'info', + browser: { + asObject: true, + }, + serializers: { + err: stdSerializers.errWithCause, + }, +}) + +export type Logger = typeof logger diff --git a/.jjconflict-base-1/packages/logger/src/hooks.ts b/.jjconflict-base-1/packages/logger/src/hooks.ts new file mode 100644 index 0000000000..2ee8ad0f1a --- /dev/null +++ b/.jjconflict-base-1/packages/logger/src/hooks.ts @@ -0,0 +1,22 @@ +import type { Logger } from './index.js' +import { logger as baseLogger } from './index.js' + +export const logger: Logger = baseLogger.child( + { scope: 'hooks' }, + { + redact: { + paths: [ + 'err.config.auth.username', + 'err.config.auth.password', + 'err.config.headers', + 'err.config.data', + 'err.config.params.client_secret', + 'err.config.params.clientSecret', + 'err.request.headers', + 'err.response.data', + 'err.response.headers', + ], + remove: true, + }, + }, +) diff --git a/.jjconflict-base-1/packages/logger/src/index.ts b/.jjconflict-base-1/packages/logger/src/index.ts new file mode 100644 index 0000000000..cf2a6e2397 --- /dev/null +++ b/.jjconflict-base-1/packages/logger/src/index.ts @@ -0,0 +1,87 @@ +import type { LoggerOptions } from 'pino' +import { pino, stdSerializers } from 'pino' +import z from 'zod' + +const envSchema = z.object({ + LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error', 'audit']).default('debug'), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), +}) + +export type Env = z.infer['NODE_ENV'] +export type LogLevel = z.infer['LOG_LEVEL'] +const customLevels = { + audit: 25, +} + +const redact: LoggerOptions['redact'] = { + paths: [ + 'password', + '*.password', + 'passwd', + '*.passwd', + 'secret', + '*.secret', + 'token', + '*.token', + 'access_token', + '*.access_token', + 'refresh_token', + '*.refresh_token', + 'client_secret', + '*.client_secret', + 'authorization', + '*.authorization', + 'headers.authorization', + 'headers.cookie', + 'req.headers.authorization', + 'req.headers.cookie', + 'request.headers.authorization', + 'request.headers.cookie', + ], + remove: true, +} + +export function getLoggerOptions(env: Env, level: LogLevel): LoggerOptions { + switch (env) { + case 'development': + return { + transport: { + target: 'pino-pretty', + options: { + translateTime: 'dd/mm/yyyy - HH:MM:ss Z', + ignore: 'pid,hostname', + colorize: true, + singleLine: true, + }, + }, + customLevels, + level, + redact, + serializers: { + err: stdSerializers.errWithCause, + }, + } + case 'production': + return { + customLevels, + level, + redact, + serializers: { + err: stdSerializers.errWithCause, + }, + } + default: + return { + level: 'silent', + } + } +} + +export function getLoggerOptionsFromEnv(): LoggerOptions { + const env = envSchema.parse(process.env) + return getLoggerOptions(env.NODE_ENV, env.LOG_LEVEL) +} + +export const logger = pino(getLoggerOptionsFromEnv()) + +export type Logger = typeof logger diff --git a/.jjconflict-base-1/packages/logger/tsconfig.eslint.json b/.jjconflict-base-1/packages/logger/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-1/packages/logger/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-1/packages/logger/tsconfig.json b/.jjconflict-base-1/packages/logger/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-1/packages/logger/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-1/packages/opencds/Dockerfile b/.jjconflict-base-1/packages/opencds/Dockerfile new file mode 100644 index 0000000000..734496c4fb --- /dev/null +++ b/.jjconflict-base-1/packages/opencds/Dockerfile @@ -0,0 +1,21 @@ +FROM node:24-alpine AS prod + +RUN npm install -g @mockoon/cli@9.3.0 + +# Install curl for healthcheck and tzdata for timezone support. +RUN apk --no-cache add curl tzdata + +# Do not run as root. +RUN adduser --shell /bin/sh --disabled-password --gecos "" mockoon + +COPY packages/opencds/opencds_api.json ./opencds_api.json +RUN chown -R mockoon ./opencds_api.json + +USER mockoon + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3100/api/v1/health || exit 1 + +EXPOSE 3100 + +ENTRYPOINT ["mockoon-cli","start","--disable-log-to-file","--data","./opencds_api.json","--port","3100"] diff --git a/.jjconflict-base-1/packages/opencds/README.adoc b/.jjconflict-base-1/packages/opencds/README.adoc new file mode 100644 index 0000000000..bc9a736541 --- /dev/null +++ b/.jjconflict-base-1/packages/opencds/README.adoc @@ -0,0 +1,58 @@ += À propos + +Ce document va servir de fil conducteur pour toutes les idées autour de l'implémentation de la Console OpenCDS. Il permettra à l'équipe Socle d'échanger (au travers, par exemple, des commentaires de MR) sur les différentes problématiques entourant les travaux. C'est un document vivant, dynamique, qui sera remplacé par un "vrai" document d'architecture par la suite. + +== Cadrage effectué + +- SSO à prévoir avec Keycloak (déjà intégré dans la Console, d'où l'intérêt de l'utiliser pour intégrer le front OpenCDS) +- Rôles à prédéfinir dans *Keycloak* : + - `admin` (a accès à toute l'application) + - `cds` (n'a accès qu'à la partie qui concerne ses projets) + +Flux GUI définis : +- Pour tous les utilisateurs : + - Login/Logout à travers le SSO Keycloak comme la Console +- Utilisateurs `admin`: + - Liste de toutes les CDS + - Relancer une CDS en particulier + - Afficher les "flows" (étapes) d'un CDS +- Utilisateurs `cds`: + - Pouvoir lister les CDS de leur équipe + - Afficher les "flows" (étapes) d'une CDS qu'ils ont le droit de voir + - Avoir un écran de confirmation (on parlera plutôt de "validation" étant donné que la route côté OpenCDS s'appelle `/validate`) de création d'une CDS. Actuellement un mail leur est envoyé qui contient un lien pour validation. Ce lien valide la création de la CDS. Il faudra donc crééer un point d'entrée dans l'application pour plutôt ouvrir un écran de confirmation de la création. + +Lotissement des évolutions: +- [x] Création en tant que fonctionnalité "feature togglée" de Console (on verra plus tard si on en fait quelque chose à part) +- [x] Admin - Liste des CDS +- [x] Admin - Liste des Flow +- [x] Admin - Relancer un CDS en statut "échoué" (`/retry`) +- [ ] CDS - Liste des CDS/Flow de leur périmètre +- [ ] CDS - Écran de confirmation de la création d'un CDS (`/validate`) + += Architecture + +L'architecture retenue est celle consistant à utiliser le serveur ("Back for Front") de la console comme d'un Proxy pour OpenCDS. Ceci permet de réutiliser toute la mécanique d'authentification en SSO par Keycloak, la sérialisation/désérialisation entre `client` et `server-nestjs` (le Frontend et son BFF), ainsi que la génération des types TypeScript (à travers l'utilisation de schémas et de contrats `zod`). De cette manière, l'implémentation des ressources OpenCDS (`service-chains` et `flows`) peut se faire de la même manière que les autres ressources existantes (`cluster`, `projet`, etc.), avec le même niveau de qualité (tests unitaires avec vitest, et E2E avec Playwright). + += Contraintes + +- Console étant un projet OpenSource, et OpenCDS étant un sujet exclusivement "MI", le couplage entre les deux doit être le plus faible possible. Idéalement OpenCDS devrait être une "extension" de la Console de manière à ne jamais être directement référencé dans l'application. +=> Dans un premier temps on va fonctionner avec un "Feature Toggling" rudimentaire (à l'aide de variables d'environnement), pour voir où on va, et ensuite on verra comment éventuellement établir une séparation claire entre la console et sa partie OpenCDS, par exemple dans un projet Git totalement à part. + += Jeu de données + +Afin de pouvoir avancer sans forcément dépendre du backend OpenCDS, un https://mockoon.com/[Mockoon] sera utilisé (./opencds_api.json) pour simuler cette partie. Lors de la convergence entre les développements de la console et ceux côté OpenCDS, une version d'"intégration" de celle-ci sera déployée sur nos environnements PAX (`cpin-hp`, `formation`, et `pax-prod`) afin d'effectuer une recette dédiée. + += Mise en place + +Utilisation de varables d'environnements qui vont nous servir de moyen d'activer la fonctionnalité: + - Côté `apps/client`: + - `OPENCDS_ENABLED`: active ou non la fonctionnalité côté Front (cette activation est indépendante de `apps/server-nestjs`, donc il faut que cette variable d'environnement soit activée pour que la console affiche les écrans concernés par OpenCDS) + - Côté `apps/server-nestjs`: + - `OPENCDS_URL`: Chemin complet (URL + path) du service OpenCDS distant + - `OPENCDS_API_TOKEN`: Définit le token d'authentification au service OpenCDS qui sera utilisé pour chaque requête effectuée + - `OPENCDS_API_TLS_REJECT_UNAUTHORIZED`: Indique si on accepte les certificats non-validés (par exemple les certificats auto-signés, ou signés par une Autorité de Certification inconnue). Cette fonctionnalié permet d'intéragir facilement avec des services OpenCDS de "test" + += Notes + +- Sachant qu'il n'est pas trivial de lancer des services docker-compose de manière conditionnelle, le service `opencds-mockoon` est systématiquement lancé dans les docker-compose concernés (développement local, tests en CI, etc.) +- Les documentations de link:../../apps/client/README.md[la console] et link:../../apps/server-nestjs/README.md[son backend] ont été amendées pour référencer cette documentation, de manière à centraliser ici tous les aspects concernant OpenCDS. diff --git a/.jjconflict-base-1/packages/opencds/opencds_api.json b/.jjconflict-base-1/packages/opencds/opencds_api.json new file mode 100644 index 0000000000..6c92672f03 --- /dev/null +++ b/.jjconflict-base-1/packages/opencds/opencds_api.json @@ -0,0 +1,546 @@ +{ + "uuid": "b263d1c1-3103-4a17-940c-1c92215bb3b8", + "lastMigration": 33, + "name": "Opencds api", + "endpointPrefix": "api/v1/", + "latency": 0, + "port": 3003, + "hostname": "", + "folders": [], + "routes": [ + { + "uuid": "210c5fa6-22dc-4c79-a89e-4289fd2b84bb", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests", + "responses": [ + { + "uuid": "28bd3efb-d903-44c3-b728-66db3af526bc", + "body": "{{setVar 'networks' (array 'RIE' 'INTERNET')}}\n{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n[\n {{#repeat 50 100 comma=true}}\n {{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n {{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n {\n \"id\": \"{{faker 'string.uuid'}}\",\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"commonName\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\",\n \"pai\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"upper\\\" }'}}\",\n \"network\": \"{{oneOf (getVar 'networks')}}\",\n \"createdAt\": \"{{getVar 'createdAt'}}\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n }\n {{/repeat}}\n]", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "0d39381e-e815-40a9-9d47-cc739a8b582c", + "body": "", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "8f00d8d7-7df4-4401-b751-6440ee0e1658", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests/:request_id", + "responses": [ + { + "uuid": "410bd286-4377-47e5-ae3f-e4131fcb0b68", + "body": "{{setVar 'networks' (array 'RIE' 'INTERNET')}}\n{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n{{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'location' (array 'SIR' 'SIL')}}\n{{setVar 'environment' (array 'INT' 'PROD' 'BAS')}}\n{\n \"id\": \"{{urlParam 'request_id'}}\",\n \n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"commonName\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\",\n \"pai\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"upper\\\" }'}}\",\n \"network\": \"{{oneOf (getVar 'networks')}}\",\n \"createdAt\": \"{{getVar 'createdAt'}}\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\",\n\n \"validationId\": \"{{faker 'string.uuid'}}\",\n \"validatedBy\": \"{{faker 'string.uuid'}}\",\n \"ref\": \"{{faker 'string.uuid'}}\",\n \"location\": \"{{oneOf (getVar 'location')}}\",\n \"targetAddress\": \"{{faker 'internet.ipv4'}}\",\n \"projectId\": \"{{faker 'string.uuid'}}\",\n \"env\": \"{{oneOf (getVar 'environment')}}\",\n \"subjectAlternativeName\": [{{#repeat 0 50 comma=true}}\"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\"{{/repeat}}],\n \"redirect\": {{boolean}},\n \"antivirus\": {{#if (eq (boolean) true)}}{\n \"maxFileSize\": {{int 1 100}}\n }{{else}}null{{/if}},\n \"websocket\": {{boolean}},\n \"ipWhiteList\": [{{#repeat 0 10 comma=true}}\"{{faker 'internet.ipv4'}}/{{int 8 32}}\"{{/repeat}}],\n \"sslOutgoing\": {{boolean}}\n}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "41f0a982-3b94-44d7-8177-e120d4741dee", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "904ddbb0-480b-442d-b251-d008d10fa832", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "8ff2f39f-a82f-4364-90ed-135b5ec8f19b", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests/:request_id/flows", + "responses": [ + { + "uuid": "b24f3f54-b45c-4749-a134-dc1fa003a417", + "body": "{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n{{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'json' '' }}\n{\n \"reserve_ip\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"create_cert\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"call_exec\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"activate_ip\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"dns_request\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "e392c78f-a345-4b96-bf0d-74c8094c4097", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "c926d20b-b30f-4293-b583-3eb169943967", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "9cc0eae4-eef1-44ec-92cd-47819501695f", + "type": "http", + "documentation": "", + "method": "post", + "endpoint": "validate/:validation_id", + "responses": [ + { + "uuid": "69bc6592-1208-40a9-a3c0-f8b445fc9f4c", + "body": "", + "latency": 0, + "statusCode": 204, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "179726cf-4c32-4d07-aabf-ead6d4646e17", + "body": "", + "latency": 0, + "statusCode": 429, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "fce2e6b6-0894-4f05-873d-77d8965c4c67", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "79f5ce5f-bf70-4995-bad4-6e82fc72f55f", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "bb61c12f-8185-43fe-9498-22b6661e998c", + "type": "http", + "documentation": "", + "method": "post", + "endpoint": "requests/:request_id/retry", + "responses": [ + { + "uuid": "b703c7f4-7325-448a-ab57-5b122896c3cc", + "body": "", + "latency": 0, + "statusCode": 204, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "e6abdf74-3191-4140-b6ba-3922c52e4c15", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "7b5d5713-0b5c-49d3-b3a2-af6cd366654d", + "body": "", + "latency": 0, + "statusCode": 409, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "5d168446-71d6-456b-9de8-6b6100bd2f8a", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "1055fe07-2f29-4dec-8412-2066fb330b3c", + "type": "http", + "documentation": "OpenCDS Mockoon healthcheck", + "method": "get", + "endpoint": "health", + "responses": [ + { + "uuid": "0d7f2acf-8006-4995-a5e5-39d8ba1858ef", + "body": "{\"status\":\"OK\"}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + } + ], + "rootChildren": [ + { + "type": "route", + "uuid": "210c5fa6-22dc-4c79-a89e-4289fd2b84bb" + }, + { + "type": "route", + "uuid": "8f00d8d7-7df4-4401-b751-6440ee0e1658" + }, + { + "type": "route", + "uuid": "8ff2f39f-a82f-4364-90ed-135b5ec8f19b" + }, + { + "type": "route", + "uuid": "9cc0eae4-eef1-44ec-92cd-47819501695f" + }, + { + "type": "route", + "uuid": "bb61c12f-8185-43fe-9498-22b6661e998c" + }, + { + "type": "route", + "uuid": "1055fe07-2f29-4dec-8412-2066fb330b3c" + } + ], + "proxyMode": false, + "proxyHost": "", + "proxyRemovePrefix": false, + "tlsOptions": { + "enabled": false, + "type": "CERT", + "pfxPath": "", + "certPath": "", + "keyPath": "", + "caPath": "", + "passphrase": "" + }, + "cors": true, + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "proxyReqHeaders": [ + { + "key": "", + "value": "" + } + ], + "proxyResHeaders": [ + { + "key": "", + "value": "" + } + ], + "data": [], + "callbacks": [] +} \ No newline at end of file diff --git a/.jjconflict-base-1/packages/shared/.gitignore b/.jjconflict-base-1/packages/shared/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-1/packages/shared/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-1/packages/shared/eslint.config.js b/.jjconflict-base-1/packages/shared/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-1/packages/shared/package.json b/.jjconflict-base-1/packages/shared/package.json new file mode 100644 index 0000000000..5d3700491b --- /dev/null +++ b/.jjconflict-base-1/packages/shared/package.json @@ -0,0 +1,58 @@ +{ + "name": "@cpn-console/shared", + "type": "module", + "version": "1.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "test:e2e-ci": "echo 'check cache'", + "test:watch": "vitest" + }, + "dependencies": { + "@cpn-console/logger": "workspace:*", + "@ts-rest/core": "catalog:runtime", + "short-uuid": "catalog:runtime", + "zod": "catalog:runtime", + "zod-validation-error": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/packages/shared/src/api-client.ts b/.jjconflict-base-1/packages/shared/src/api-client.ts new file mode 100644 index 0000000000..c842cb9e99 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/api-client.ts @@ -0,0 +1,50 @@ +import type { ApiFetcher } from '@ts-rest/core' +import { initClient, initContract } from '@ts-rest/core' + +export const apiPrefix: string = '/api/v1' +export const apiPrefixV2: string = '/api/v2' + +export const contractInstance: ReturnType = initContract() + +export async function getContract() { + return contractInstance.router({ + AdminTokens: (await import('./contracts/index.js')).adminTokenContract, + AdminRoles: (await import('./contracts/index.js')).adminRoleContract, + Clusters: (await import('./contracts/index.js')).clusterContract, + ServiceChains: (await import('./contracts/index.js')).serviceChainContract, + Deployments: (await import('./contracts/index.js')).deploymentContract, + Environments: (await import('./contracts/index.js')).environmentContract, + EnvironmentsV2: (await import('./contracts/index.js')).environmentContractV2, + Logs: (await import('./contracts/index.js')).logContract, + PersonalAccessTokens: (await import('./contracts/index.js')) + .personalAccessTokenContract, + Projects: (await import('./contracts/index.js')).projectContract, + ProjectsMembers: (await import('./contracts/index.js')) + .projectMemberContract, + ProjectsRoles: (await import('./contracts/index.js')).projectRoleContract, + ProjectServices: (await import('./contracts/index.js')) + .projectServiceContract, + Repositories: (await import('./contracts/index.js')).repositoryContract, + Stages: (await import('./contracts/index.js')).stageContract, + Services: (await import('./contracts/index.js')).serviceContract, + Users: (await import('./contracts/index.js')).userContract, + Zones: (await import('./contracts/index.js')).zoneContract, + System: (await import('./contracts/index.js')).systemContract, + SystemPlugin: (await import('./contracts/index.js')).systemPluginContract, + SystemSettings: (await import('./contracts/index.js')) + .systemSettingsContract, + }) +} + +export async function getApiClient( + baseUrl: string, + baseHeaders: Record, + api: ApiFetcher | undefined, +): Promise { + return initClient(await getContract(), { + baseUrl, + baseHeaders, + api, + validateResponse: false, + }) +} diff --git a/.jjconflict-base-1/packages/shared/src/contracts/_utils.ts b/.jjconflict-base-1/packages/shared/src/contracts/_utils.ts new file mode 100644 index 0000000000..ea2f4ee5ff --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/_utils.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' +import { tokenHeaderName } from '../utils/const.js' + +export const EmptySchema = z.object({}) + +export const ErrorSchema = z.lazy(() => z.object({ + message: z.string() + .optional(), + error: z.unknown().optional(), + stack: z.unknown().optional(), +})) + +export const baseHeaders = z.object({ + [tokenHeaderName]: z.string().min(1).optional(), +}) diff --git a/.jjconflict-base-1/packages/shared/src/contracts/admin-role.ts b/.jjconflict-base-1/packages/shared/src/contracts/admin-role.ts new file mode 100644 index 0000000000..dc6696bee3 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/admin-role.ts @@ -0,0 +1,76 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { AdminRoleSchema } from '../schemas/role.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminRoleContract = contractInstance.router({ + listAdminRoles: { + method: 'GET', + path: '', + responses: { + 200: AdminRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createAdminRole: { + method: 'POST', + path: '', + body: AdminRoleSchema.pick({ name: true }), + responses: { + 200: AdminRoleSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + patchAdminRoles: { + method: 'PATCH', + path: '', + body: AdminRoleSchema.partial({ name: true, permissions: true, position: true, oidcGroup: true, type: true }).array(), + responses: { + 200: AdminRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + adminRoleMemberCounts: { + method: 'GET', + path: `/member-counts`, + responses: { + 200: z.record(z.number().min(0)), // Record + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deleteAdminRole: { + method: 'DELETE', + path: `/:roleId`, + pathParams: z.object({ + roleId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/admin/roles`, +}) diff --git a/.jjconflict-base-1/packages/shared/src/contracts/admin-token.ts b/.jjconflict-base-1/packages/shared/src/contracts/admin-token.ts new file mode 100644 index 0000000000..8df06a7db4 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/admin-token.ts @@ -0,0 +1,54 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { AdminTokenSchema, ExposedAdminTokenSchema } from '../schemas/token.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminTokenContract = contractInstance.router({ + listAdminTokens: { + method: 'GET', + path: '', + query: z.object({ + withRevoked: CoerceBooleanSchema + .optional(), + }), + responses: { + 200: AdminTokenSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createAdminToken: { + method: 'POST', + path: '', + body: AdminTokenSchema.pick({ name: true, permissions: true, expirationDate: true }).required(), + responses: { + 201: ExposedAdminTokenSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deleteAdminToken: { + method: 'DELETE', + path: `/:tokenId`, + pathParams: z.object({ tokenId: z.string().uuid() }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/admin/tokens`, +}) diff --git a/.jjconflict-base-1/packages/shared/src/contracts/cluster.ts b/.jjconflict-base-1/packages/shared/src/contracts/cluster.ts new file mode 100644 index 0000000000..96ada83d77 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/cluster.ts @@ -0,0 +1,138 @@ +import type { ClientInferResponseBody } from '@ts-rest/core' +import type Zod from 'zod' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { + CleanedClusterSchema, + ClusterDetailsSchema, + ClusterUsageSchema, +} from '../schemas/cluster.js' +import { EnvironmentSchema } from '../schemas/environment.js' +import { UserSchema } from '../schemas/user.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const ClusterParams = z.object({ + clusterId: CleanedClusterSchema.shape.id, +}) + +export const clusterContract = contractInstance.router({ + listClusters: { + method: 'GET', + path: '', + summary: 'Get clusters', + description: 'Retrieve clusters authorized for user', + responses: { + 200: z.array(CleanedClusterSchema), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + createCluster: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create cluster', + description: 'Create new cluster.', + body: ClusterDetailsSchema.omit({ id: true }), + responses: { + 201: ClusterDetailsSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterDetails: { + method: 'GET', + path: `/:clusterId`, + summary: 'Get cluster details', + description: 'Retrieved details of a cluster.', + pathParams: ClusterParams, + responses: { + 200: ClusterDetailsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterUsage: { + method: 'GET', + path: `/usage/:clusterId`, + summary: 'Get cluster usage', + description: 'Retrieved resources usage of a cluster.', + pathParams: ClusterParams, + responses: { + 200: ClusterUsageSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterEnvironments: { + method: 'GET', + path: `/:clusterId/environments`, + summary: 'Get cluster envs', + description: 'Retrieved environments linked to a cluster.', + pathParams: ClusterParams, + responses: { + 200: z.array(z.object({ + // TODO: Remettre `ProjectSchemaV2.shape.name` mais attention aux projets non compatibles + project: z.string(), + name: EnvironmentSchema.shape.name, + owner: UserSchema.shape.email.optional(), + cpu: EnvironmentSchema.shape.cpu, + gpu: EnvironmentSchema.shape.gpu, + memory: EnvironmentSchema.shape.memory, + })), + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateCluster: { + method: 'PUT', + path: `/:clusterId`, + summary: 'Update cluster', + description: 'Update a cluster by its ID.', + pathParams: ClusterParams, + body: ClusterDetailsSchema.omit({ id: true }).partial(), + responses: { + 200: ClusterDetailsSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteCluster: { + method: 'DELETE', + path: `/:clusterId`, + summary: 'Delete cluster', + description: 'Delete a cluster by its ID.', + query: z.object({ force: CoerceBooleanSchema.optional() }), + pathParams: ClusterParams, + body: ContractNoBody, + responses: { + 204: z.string() + .nullable(), + 400: ErrorSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/clusters`, +}) + +export type ClusterAssociatedEnvironments = ClientInferResponseBody +export type CreateClusterBody = Zod.infer +export type UpdateClusterBody = Zod.infer diff --git a/.jjconflict-base-1/packages/shared/src/contracts/deployment.ts b/.jjconflict-base-1/packages/shared/src/contracts/deployment.ts new file mode 100644 index 0000000000..968e65e940 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/deployment.ts @@ -0,0 +1,91 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + CreateDeploymentSchema, + DeploymentSchema, + UpdateDeploymentSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const deploymentContract = contractInstance.router({ + createDeployment: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create deployment', + description: 'Create new deployment.', + body: CreateDeploymentSchema, + responses: { + 201: DeploymentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listDeployments: { + method: 'GET', + path: '', + summary: 'Get deployments', + description: 'Retrieved project deployments.', + responses: { + 200: DeploymentSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateDeployment: { + method: 'PUT', + path: `/:deploymentId`, + summary: 'Update deployment', + description: 'Update a deployment by its ID.', + pathParams: z.object({ + deploymentId: z.string() + .uuid(), + }), + body: UpdateDeploymentSchema, + responses: { + 200: DeploymentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteDeployment: { + method: 'DELETE', + path: `/:deploymentId`, + summary: 'Delete deployment', + description: 'Delete a deployment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + deploymentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/deployments`, + pathParams: z.object({ + projectId: z.string().uuid(), + }), +}) + +export type CreateDeploymentBody = ClientInferRequest['body'] +export type UpdateDeploymentBody = ClientInferRequest['body'] diff --git a/.jjconflict-base-1/packages/shared/src/contracts/environment.ts b/.jjconflict-base-1/packages/shared/src/contracts/environment.ts new file mode 100644 index 0000000000..8d9320733e --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/environment.ts @@ -0,0 +1,175 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, apiPrefixV2, contractInstance } from '../api-client.js' +import { + CreateEnvironmentSchema, + EnvironmentSchema, + UpdateEnvironmentSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const environmentContract = contractInstance.router({ + createEnvironment: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create environment', + description: 'Create new environment.', + body: EnvironmentSchema.omit({ id: true, createdAt: true, updatedAt: true }), + responses: { + 201: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listEnvironments: { + method: 'GET', + path: '', + summary: 'Get environments', + description: 'Retrieved project environments.', + query: z.object({ + projectId: z.string() + .uuid(), + }), + responses: { + 200: EnvironmentSchema.omit({ name: true }) + .extend({ name: z.string() }).array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateEnvironment: { + method: 'PUT', + path: `/:environmentId`, + summary: 'Update environment', + description: 'Update a environment by its ID.', + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + body: EnvironmentSchema.pick({ cpu: true, gpu: true, memory: true, autosync: true }), + responses: { + 200: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteEnvironment: { + method: 'DELETE', + path: `/:environmentId`, + summary: 'Delete environment', + description: 'Delete a environment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/environments`, +}) + +export type CreateEnvironmentBody = ClientInferRequest['body'] +export type UpdateEnvironmentBody = ClientInferRequest['body'] + +// NB: les clés de routes servent d'operationId OpenAPI (setOperationId: true) et +// doivent être uniques sur l'ensemble du contrat, d'où le suffixe V2. +export const environmentContractV2 = contractInstance.router({ + createEnvironmentV2: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create environment', + description: 'Create new environment.', + body: CreateEnvironmentSchema, + responses: { + 201: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listEnvironmentsV2: { + method: 'GET', + path: '', + summary: 'Get environments', + description: 'Retrieved project environments.', + responses: { + 200: EnvironmentSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateEnvironmentV2: { + method: 'PUT', + path: '/:environmentId', + summary: 'Update environment', + description: 'Update an environment by its ID.', + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + body: UpdateEnvironmentSchema, + responses: { + 200: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteEnvironmentV2: { + method: 'DELETE', + path: '/:environmentId', + summary: 'Delete environment', + description: 'Delete an environment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefixV2}/projects/:projectId/environments`, + pathParams: z.object({ + projectId: z.string().uuid(), + }), +}) diff --git a/.jjconflict-base-1/packages/shared/src/contracts/index.ts b/.jjconflict-base-1/packages/shared/src/contracts/index.ts new file mode 100644 index 0000000000..c5c88fab16 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/index.ts @@ -0,0 +1,18 @@ +export * from './admin-role.js' +export * from './admin-token.js' +export * from './cluster.js' +export * from './deployment.js' +export * from './environment.js' +export * from './log.js' +export * from './personal-access-token.js' +export * from './project-member.js' +export * from './project-role.js' +export * from './project-service.js' +export * from './project.js' +export * from './repository.js' +export * from './service-chain.js' +export * from './service-monitor.js' +export * from './stage.js' +export * from './system.js' +export * from './user.js' +export * from './zone.js' diff --git a/.jjconflict-base-1/packages/shared/src/contracts/log.ts b/.jjconflict-base-1/packages/shared/src/contracts/log.ts new file mode 100644 index 0000000000..062f2b3adc --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/log.ts @@ -0,0 +1,43 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import type Zod from 'zod' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { CleanLogSchema, LogSchema } from '../schemas/log.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminLogsQuery = z.object({ + offset: z.coerce.number(), + limit: z.coerce.number(), + projectId: z.string().optional(), + clean: CoerceBooleanSchema.default(true), +}) +export type AdminLogsQuery = Zod.infer + +export const logContract = contractInstance.router({ + getLogs: { + method: 'GET', + path: '', + query: adminLogsQuery, + summary: 'Get logs', + description: 'Retrieved all logs.', + responses: { + 200: z.object({ + total: z.number(), + logs: LogSchema.array().or(CleanLogSchema.array()), + }), + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/logs`, +}) + +export type GetLogsQuery = ClientInferRequest['query'] + +export type Log = Zod.infer +export type CleanLog = Zod.infer diff --git a/.jjconflict-base-1/packages/shared/src/contracts/personal-access-token.ts b/.jjconflict-base-1/packages/shared/src/contracts/personal-access-token.ts new file mode 100644 index 0000000000..72e9012596 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/personal-access-token.ts @@ -0,0 +1,49 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ExposedPersonalAccessTokenSchema, PersonalAccessTokenSchema } from '../schemas/token.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const personalAccessTokenContract = contractInstance.router({ + listPersonalAccessTokens: { + method: 'GET', + path: '', + responses: { + 200: PersonalAccessTokenSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createPersonalAccessToken: { + method: 'POST', + path: '', + body: PersonalAccessTokenSchema.pick({ name: true, expirationDate: true }).required(), + responses: { + 201: ExposedPersonalAccessTokenSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deletePersonalAccessToken: { + method: 'DELETE', + path: `/:tokenId`, + pathParams: z.object({ tokenId: z.string().uuid() }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/user/tokens`, +}) diff --git a/.jjconflict-base-1/packages/shared/src/contracts/project-member.ts b/.jjconflict-base-1/packages/shared/src/contracts/project-member.ts new file mode 100644 index 0000000000..ac9797495f --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/project-member.ts @@ -0,0 +1,68 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { MemberSchema } from '../schemas/user.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const projectMemberContract = contractInstance.router({ + listMembers: { + method: 'GET', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + addMember: { + method: 'POST', + path: '', + body: z.object({ email: z.string() }).or(z.object({ userId: z.string() })), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 201: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + patchMembers: { + method: 'PATCH', + path: '', + body: z.object({ + userId: z.string().uuid(), + roles: z.string().uuid().array(), + }).array(), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + removeMember: { + method: 'DELETE', + path: `/:userId`, + pathParams: z.object({ + projectId: z.string().uuid(), + userId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/members`, +}) diff --git a/.jjconflict-base-1/packages/shared/src/contracts/project-role.ts b/.jjconflict-base-1/packages/shared/src/contracts/project-role.ts new file mode 100644 index 0000000000..b909a994db --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/project-role.ts @@ -0,0 +1,80 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ProjectRoleSchema } from '../schemas/role.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const projectRoleContract = contractInstance.router({ + listProjectRoles: { + method: 'GET', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + createProjectRole: { + method: 'POST', + path: '', + body: ProjectRoleSchema.omit({ position: true, id: true, projectId: true }), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + // 200: z.any(), + 201: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + patchProjectRoles: { + method: 'PATCH', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + // body: z.any(), + body: ProjectRoleSchema.pick({ id: true }).merge(ProjectRoleSchema.omit({ id: true, projectId: true }).partial()).array(), + responses: { + // 200: z.any(), + 200: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + projectRoleMemberCounts: { + method: 'GET', + path: `/member-counts`, + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: z.record(z.number().min(0)), // Record + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + deleteProjectRole: { + method: 'DELETE', + path: `/:roleId`, + pathParams: z.object({ + projectId: z.string().uuid(), + roleId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/roles`, +}) diff --git a/.jjconflict-base-1/packages/shared/src/contracts/project-service.ts b/.jjconflict-base-1/packages/shared/src/contracts/project-service.ts new file mode 100644 index 0000000000..a0545d06b2 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/project-service.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { pluginUpdateBody } from '../schemas/config.js' +import { permissionTarget, ServiceSchema } from '../schemas/services.js' +import { baseHeaders, ErrorSchema } from './_utils.js' +import { ProjectParams } from './project.js' + +export const projectServiceContract = contractInstance.router({ + getServices: { + method: 'GET', + path: '', + summary: 'Get Project\'s services', + description: 'Get all informations about services related to a project.', + query: z.object({ permissionTarget }), + pathParams: ProjectParams, + responses: { + 200: ServiceSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateProjectServices: { + method: 'POST', + path: '', + summary: 'Update project service configuration', + description: 'Update project service configuration', + pathParams: ProjectParams, + body: pluginUpdateBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/services`, +}) diff --git a/.jjconflict-base-1/packages/shared/src/contracts/project.ts b/.jjconflict-base-1/packages/shared/src/contracts/project.ts new file mode 100644 index 0000000000..8532849b4e --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/project.ts @@ -0,0 +1,184 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ProjectSchemaV2 } from '../schemas/project.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const ProjectParams = z.object({ + projectId: z.string().regex(/[a-z0-9-]*/), // uuid or slug like +}) + +export const projectContract = contractInstance.router({ + createProject: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create project', + description: 'Create a new project.', + body: ProjectSchemaV2.pick({ + name: true, + description: true, + limitless: true, + hprodCpu: true, + hprodGpu: true, + hprodMemory: true, + prodCpu: true, + prodGpu: true, + prodMemory: true, + }), + responses: { + 201: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + bulkActionProject: { + method: 'POST', + path: '-bulk', + contentType: 'application/json', + summary: 'Perform bulk action on projects', + description: 'Perform bulk action on projects.', + body: z.object({ + action: z.enum(['archive', 'lock', 'unlock', 'replay']), + projectIds: z.string().uuid().array().or(z.literal('all')), + }), + responses: { + 202: null, + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getProject: { + method: 'GET', + path: '/:projectId', + pathParams: ProjectParams, + summary: 'Get a project', + description: 'Get a project', + responses: { + 200: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listProjects: { + method: 'GET', + path: '', + query: ProjectSchemaV2 + .pick({ + id: true, + name: true, + status: true, + locked: true, + description: true, + lastSuccessProvisionningVersion: true, + }) + .extend({ + statusIn: z.string(), + statusNotIn: z.string(), + filter: z.enum(['owned', 'member', 'all']), + search: z.string(), + }) + .partial(), + summary: 'Get projects', + description: 'Get projects with filters', + responses: { + 200: ProjectSchemaV2.omit({ + name: true, + }).extend({ name: z.string() }).array(), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getProjectSecrets: { + method: 'GET', + path: `/:projectId/secrets`, + summary: 'Get project secrets', + description: 'Retrieved a project secrets.', + pathParams: ProjectParams, + responses: { + 200: z.record(z.record(z.string())), + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateProject: { + method: 'PUT', + path: `/:projectId`, + summary: 'Update project', + description: 'Update a project.', + pathParams: ProjectParams, + body: ProjectSchemaV2 + .pick({ + description: true, + everyonePerms: true, + locked: true, + ownerId: true, + limitless: true, + hprodMemory: true, + hprodCpu: true, + hprodGpu: true, + prodMemory: true, + prodCpu: true, + prodGpu: true, + }) + .partial(), + responses: { + 200: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 500: ErrorSchema, + }, + }, + + replayHooksForProject: { + method: 'PUT', + path: `/:projectId/hooks`, + summary: 'Replay hooks for project', + description: 'Replay hooks for a project.', + body: ContractNoBody, + pathParams: ProjectParams, + responses: { + 204: null, + 500: ErrorSchema, + }, + }, + + archiveProject: { + method: 'DELETE', + path: `/:projectId`, + summary: 'Delete project', + description: 'Delete a project.', + pathParams: ProjectParams, + body: ContractNoBody, + responses: { + 204: null, + 500: ErrorSchema, + }, + }, + + getProjectsData: { + method: 'GET', + path: `/data`, + summary: 'Download projects csv report', + description: 'Retrieve all projects data for download as CSV file.', + responses: { + 200: z.string(), + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects`, +}) + +export type CreateProjectBody = ClientInferRequest['body'] diff --git a/.jjconflict-base-1/packages/shared/src/contracts/repository.ts b/.jjconflict-base-1/packages/shared/src/contracts/repository.ts new file mode 100644 index 0000000000..131deee00e --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/repository.ts @@ -0,0 +1,107 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { RepoSchema } from '../schemas/repository.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const repositoryContract = contractInstance.router({ + createRepository: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create repo', + description: 'Create new repo.', + body: RepoSchema.omit({ id: true, createdAt: true, updatedAt: true }), + responses: { + 201: RepoSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listRepositories: { + method: 'GET', + path: '', + summary: 'Get repos', + description: 'Retrieved all repos.', + query: z.object({ + projectId: z.string() + .uuid(), + }), + responses: { + 200: z.array(RepoSchema + .omit({ internalRepoName: true }) + .extend({ internalRepoName: z.string() }), + ), + 500: ErrorSchema, + }, + }, + + syncRepository: { + method: 'POST', + path: `/:repositoryId/sync`, + summary: 'application/json', + description: 'Trigger a gitlab synchronization for a repository', + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + body: z.object({ + syncAllBranches: z.boolean(), + branchName: z.string().optional(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateRepository: { + method: 'PUT', + path: `/:repositoryId`, + summary: 'Update repo', + description: 'Update a repo by its ID.', + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + body: RepoSchema.omit({ createdAt: true, updatedAt: true }).partial(), + responses: { + 200: RepoSchema, + 500: ErrorSchema, + }, + }, + + deleteRepository: { + method: 'DELETE', + path: `/:repositoryId`, + summary: 'Delete repo', + description: 'Delete a repo by its ID.', + body: ContractNoBody, + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/repositories`, +}) + +export type CreateRepositoryBody = ClientInferRequest['body'] + +export type UpdateRepositoryBody = ClientInferRequest['body'] + +export type SyncRepositoryParams = ClientInferRequest['params'] + +export type RepositoryParams = ClientInferRequest['params'] diff --git a/.jjconflict-base-1/packages/shared/src/contracts/service-chain.ts b/.jjconflict-base-1/packages/shared/src/contracts/service-chain.ts new file mode 100644 index 0000000000..e98f864db8 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/service-chain.ts @@ -0,0 +1,99 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainSchema, +} from '../schemas/index.js' +import { baseHeaders, EmptySchema, ErrorSchema } from './_utils.js' + +export const ServiceChainParams = z.object({ + serviceChainId: ServiceChainSchema.shape.id, +}) + +export const ServiceChainValidationParams = z.object({ + validationId: ServiceChainDetailsSchema.shape.validationId, +}) + +export const serviceChainContract = contractInstance.router( + { + listServiceChains: { + method: 'GET', + path: '', + summary: 'Get Service Chains', + description: 'Retrieve Service Chains authorized for user', + responses: { + 200: z.array(ServiceChainSchema), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getServiceChainDetails: { + method: 'GET', + path: `/:serviceChainId`, + summary: 'Get Service Chain details', + description: 'Retrieved details of a Service Chain.', + pathParams: ServiceChainParams, + responses: { + 200: ServiceChainDetailsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + retryServiceChain: { + method: 'POST', + path: `/:serviceChainId/retry`, + summary: 'Retry Service Chain creation', + description: + 'Retry the whole service chain creation from the point it failed.', + pathParams: ServiceChainParams, + body: ContractNoBody, + responses: { + 204: null, + 401: ErrorSchema, + 404: ErrorSchema, + 429: EmptySchema, // Already successful, no retry needed + 500: ErrorSchema, + }, + }, + + validateServiceChain: { + method: 'POST', + path: `/validate/:validationId`, + summary: 'Validate Service Chain creation', + description: 'Trigger the whole service chain creation.', + pathParams: ServiceChainValidationParams, + body: ContractNoBody, + responses: { + 204: null, + 401: ErrorSchema, + 404: ErrorSchema, + 429: EmptySchema, // Validation already done + 500: ErrorSchema, + }, + }, + + getServiceChainFlows: { + method: 'GET', + path: `/:serviceChainId/flows`, + summary: 'Get Service Chain flow details', + description: 'Retrieved flow details of a Service Chain.', + pathParams: ServiceChainParams, + responses: { + 200: ServiceChainFlowsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + }, + { + baseHeaders, + pathPrefix: `${apiPrefix}/service-chains`, + }, +) diff --git a/.jjconflict-base-1/packages/shared/src/contracts/service-monitor.ts b/.jjconflict-base-1/packages/shared/src/contracts/service-monitor.ts new file mode 100644 index 0000000000..c2b02e304b --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/service-monitor.ts @@ -0,0 +1,51 @@ +import type { ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ServiceHealthSchema } from '../schemas/services.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const serviceContract = contractInstance.router({ + getServiceHealth: { + method: 'GET', + path: '/health-services', + summary: 'Get services health', + description: 'Retrieve services health.', + responses: { + 200: ServiceHealthSchema.array(), + 500: ErrorSchema, + }, + }, + + getCompleteServiceHealth: { + method: 'GET', + path: '/complete-services', + summary: 'Get services health with cause', + description: 'Retrieve services health with cause.', + responses: { + 200: ServiceHealthSchema.extend({ + cause: z.any().optional(), + }).array(), + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + refreshServiceHealth: { + method: 'GET', + path: '/refresh-services', + summary: 'Force services health', + description: 'Retrieved services health.', + responses: { + 200: ServiceHealthSchema.array(), + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}`, +}) + +export type ServiceBody = ClientInferResponseBody diff --git a/.jjconflict-base-1/packages/shared/src/contracts/stage.ts b/.jjconflict-base-1/packages/shared/src/contracts/stage.ts new file mode 100644 index 0000000000..a8f20f62cf --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/stage.ts @@ -0,0 +1,95 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { StageSchema } from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const stageContract = contractInstance.router({ + listStages: { + method: 'GET', + path: '', + summary: 'Get stages', + description: 'Retrieved all stages.', + responses: { + 200: StageSchema.array(), + 500: ErrorSchema, + }, + }, + + createStage: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create stage', + description: 'Create new stage.', + body: StageSchema.omit({ id: true }).partial({ clusterIds: true }), + responses: { + 201: StageSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getStageEnvironments: { + method: 'GET', + path: `/:stageId/environments`, + summary: 'Get stages', + description: 'Retrieved all stages.', + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + responses: { + 200: z.array(z.object({ + project: z.string(), + name: z.string(), + cluster: z.string(), + owner: z.string().optional(), + })), + 500: ErrorSchema, + }, + }, + + updateStage: { + method: 'PUT', + path: `/:stageId`, + summary: 'Update stage', + description: 'Update a stage by its ID.', + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + body: StageSchema.pick({ clusterIds: true, name: true }), + responses: { + 200: StageSchema, + 500: ErrorSchema, + }, + }, + + deleteStage: { + method: 'DELETE', + path: `/:stageId`, + summary: 'Delete stage', + description: 'Delete a stage by its ID.', + body: ContractNoBody, + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/stages`, +}) + +export type CreateStageBody = ClientInferRequest['body'] + +export type UpdateStageBody = ClientInferRequest['body'] + +export type StageAssociatedEnvironments = ClientInferResponseBody diff --git a/.jjconflict-base-1/packages/shared/src/contracts/system.ts b/.jjconflict-base-1/packages/shared/src/contracts/system.ts new file mode 100644 index 0000000000..b5c77864fe --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/system.ts @@ -0,0 +1,111 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + pluginSchema, + pluginUpdateBody, + SystemSettingSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const systemContract = contractInstance.router({ + getVersion: { + method: 'GET', + path: `/version`, + summary: 'Get version', + description: 'Retrieve api version.', + responses: { + 200: z.object({ + version: z.string(), + }), + 500: ErrorSchema, + }, + }, + + getHealth: { + method: 'GET', + path: `/healthz`, + summary: 'Get health', + description: 'Retrieve api health infos.', + responses: { + 200: z.object({ + status: z.enum(['OK', 'KO']), + }), + 500: ErrorSchema, + }, + }, +}, { + pathPrefix: `${apiPrefix}`, +}) + +export const systemPluginContract = contractInstance.router({ + getPluginsConfig: { + method: 'GET', + path: '', + summary: 'Get plugins configuration', + description: 'Get plugins configuration', + responses: { + 200: pluginSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updatePluginsConfig: { + method: 'POST', + path: '', + summary: 'Update project service configuration', + description: 'Update project service configuration', + body: pluginUpdateBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/system/plugins`, +}) + +export const systemSettingsContract = contractInstance.router({ + listSystemSettings: { + method: 'GET', + path: '', + summary: 'Get System Settings state', + description: 'Get System Settings state', + query: SystemSettingSchema.pick({ key: true }) + .partial(), + responses: { + 200: SystemSettingSchema.array(), + 500: ErrorSchema, + }, + }, + + upsertSystemSetting: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Update System Settings state', + description: 'Update System Settings state', + body: SystemSettingSchema, + responses: { + 201: SystemSettingSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/system/settings`, +}) + +export type SystemSettings = ClientInferResponseBody + +export type SystemSetting = SystemSettings[number] + +export type UpsertSystemSettingBody = ClientInferRequest['body'] diff --git a/.jjconflict-base-1/packages/shared/src/contracts/user.ts b/.jjconflict-base-1/packages/shared/src/contracts/user.ts new file mode 100644 index 0000000000..508ef91171 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/user.ts @@ -0,0 +1,83 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { UuidOrCsvUuidSchema } from '../schemas/_utils.js' +import { + RoleNameCsvSchema, + UserSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const userContract = contractInstance.router({ + getMatchingUsers: { + method: 'GET', + path: `${apiPrefix}/users/matching`, + query: z.object({ + letters: z.string(), + notInProjectId: z.string().uuid().optional(), + }), + summary: 'Get users by letters matching', + description: 'Retrieved users by letters matching.', + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + auth: { + method: 'GET', + path: `${apiPrefix}/auth`, + summary: 'Login', + description: 'OIDC callback to signin or signup', + responses: { + 200: UserSchema, + 307: null, + 500: ErrorSchema, + }, + }, + + getAllUsers: { + method: 'GET', + path: `${apiPrefix}/users`, + summary: 'Get all users', + description: 'Get all users.', + query: z.object({ + adminRoles: RoleNameCsvSchema + .optional(), + adminRoleIds: UuidOrCsvUuidSchema + .optional(), + memberOfIds: UuidOrCsvUuidSchema + .optional(), + relationType: z.enum(['OR', 'AND']) + .optional(), + }), + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + patchUsers: { + method: 'PATCH', + path: `${apiPrefix}/users`, + summary: 'Patch users', + body: UserSchema.pick({ adminRoleIds: true, id: true }).array(), + description: 'Update user admin role.', + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, +}) + +export type LettersQuery = ClientInferRequest['query'] + +export type AllUsers = ClientInferResponseBody diff --git a/.jjconflict-base-1/packages/shared/src/contracts/zone.ts b/.jjconflict-base-1/packages/shared/src/contracts/zone.ts new file mode 100644 index 0000000000..90a300ba5f --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/contracts/zone.ts @@ -0,0 +1,83 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + ZoneSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const zoneContract = contractInstance.router({ + listZones: { + method: 'GET', + path: '', + summary: 'Get zones', + description: 'Get all zones.', + responses: { + 200: ZoneSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + createZone: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create zone', + description: 'Create new zone.', + body: ZoneSchema + .omit({ id: true }) + .extend({ clusterIds: z.string().uuid().array().optional() }), + responses: { + 201: ZoneSchema, + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateZone: { + method: 'PUT', + path: `/:zoneId`, + summary: 'Update zone', + description: 'Update a zone by its ID.', + pathParams: z.object({ + zoneId: z.string() + .uuid(), + }), + body: ZoneSchema.omit({ id: true, slug: true }), + responses: { + 201: ZoneSchema, + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteZone: { + method: 'DELETE', + path: `/:zoneId`, + summary: 'Delete zone', + description: 'Delete a zone by its ID.', + body: ContractNoBody, + pathParams: z.object({ + zoneId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/zones`, +}) + +export type CreateZoneBody = ClientInferRequest['body'] +export type UpdateZoneBody = ClientInferRequest['body'] diff --git a/.jjconflict-base-1/packages/shared/src/index.ts b/.jjconflict-base-1/packages/shared/src/index.ts new file mode 100644 index 0000000000..8cc07ea318 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/index.ts @@ -0,0 +1,4 @@ +export * from './api-client.js' +export * from './contracts/index.js' +export * from './schemas/index.js' +export * from './utils/index.js' diff --git a/.jjconflict-base-1/packages/shared/src/schemas/_utils.ts b/.jjconflict-base-1/packages/shared/src/schemas/_utils.ts new file mode 100644 index 0000000000..91cea1b16a --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/_utils.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +export const CoerceBooleanSchema = z.boolean() + .or(z.enum(['true', 'false']) + .transform(value => value === 'true')) + +export const dateToString = z.string().or(z.date().transform(date => date.toISOString())) + +export const AtDatesToStringExtend = { + updatedAt: dateToString, + createdAt: dateToString, +} +export const permissionLevelSchema = z.coerce.string() + +export const UuidOrCsvUuidSchema = z.string() + .refine((value) => { + return !value + .split(',') + .some(uuid => !z.string().uuid().safeParse(uuid).success) + }) + .transform(value => value.split(',')) diff --git a/.jjconflict-base-1/packages/shared/src/schemas/cluster.ts b/.jjconflict-base-1/packages/shared/src/schemas/cluster.ts new file mode 100644 index 0000000000..bac22d02c0 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/cluster.ts @@ -0,0 +1,71 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const ClusterPrivacySchema = z.enum(['public', 'dedicated']) + +export const CleanedClusterSchema = z.object({ + id: z.string() + .uuid(), + label: z.string() + .regex(/^[a-z0-9-]+$/i) + .max(50), + infos: z.string() + .max(1000) + .optional() + .nullable() + .transform(value => value ?? ''), + clusterResources: z.boolean(), + privacy: ClusterPrivacySchema, + zoneId: z.string() + .uuid(), + stageIds: z.string() + .uuid() + .array(), + cpu: z.coerce.number().gte(0), + gpu: z.coerce.number().gte(0), + memory: z.coerce.number().gte(0), +}) + +export const KubeconfigSchema = z.object({ + user: z.object({ + username: z.string() + .optional(), + password: z.string() + .optional(), + keyData: z.string() + .optional(), + certData: z.string() + .optional(), + token: z.string() + .optional(), + }), + cluster: z.object({ + server: z.string() + .optional(), + tlsServerName: z.string(), + skipTLSVerify: z.boolean() + .optional(), + caData: z.string() + .optional(), + }), +}) + +export const ClusterDetailsSchema = CleanedClusterSchema.merge(z.object({ + projectIds: z.string() + .uuid() + .array() + .optional(), + kubeconfig: KubeconfigSchema, +})) + +export const ClusterUsageSchema = z.object({ + cpu: z.number(), + gpu: z.number(), + memory: z.number(), +}) + +export type Cluster = Zod.infer +export type ClusterDetails = Zod.infer +export type Kubeconfig = Zod.infer + +export type CleanedCluster = Zod.infer diff --git a/.jjconflict-base-1/packages/shared/src/schemas/config.ts b/.jjconflict-base-1/packages/shared/src/schemas/config.ts new file mode 100644 index 0000000000..cca95debd8 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/config.ts @@ -0,0 +1,84 @@ +import type Zod from 'zod' +import { z } from 'zod' + +const configItemBase = z.object({ + key: z.string().min(2).regex(/[a-z-]/), + description: z.string().optional(), + title: z.string(), + section: z.string().optional(), +}) + +const projectVisibilty = z.object({ + permissions: z.object({ + user: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + admin: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + }), +}) + +const globalVisibilty = z.object({ + permissions: z.object({ + user: z.object({ + read: z.boolean(), + write: z.boolean().refine(value => !value, { message: 'global params can\'t be user writable' }), + }), + admin: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + }), +}) + +export const ENABLED = 'enabled' +export const DISABLED = 'disabled' +export const DEFAULT = 'default' + +export const atomicValidators = { + switch: z.enum([DISABLED, DEFAULT, ENABLED]), + // eslint-disable-next-line regexp/prefer-w, regexp/use-ignore-case + text: z.string().trim().regex(/[a-zA-Z-_0-9 ]*/), +} + +export type SwitchParam = Zod.infer + +const configItemSwitch = z.object({ + kind: z.literal('switch'), + value: atomicValidators.switch, + initialValue: atomicValidators.switch, +}).merge(configItemBase) + +const configItemInput = z.object({ + kind: z.literal('text'), + value: atomicValidators.text, + placeholder: z.string().optional(), +}).merge(configItemBase) + +const configGlobalItemDeclaration = z.discriminatedUnion('kind', [ + configItemSwitch.merge(globalVisibilty), + configItemInput.merge(globalVisibilty), +]) + +export type PluginConfigItem = Zod.infer + +export const configProjectItemDeclaration = z.discriminatedUnion('kind', [ + configItemSwitch.merge(projectVisibilty), + configItemInput.merge(projectVisibilty), +]) + +export const pluginConfig = z.object({ + global: configGlobalItemDeclaration.array(), + project: configProjectItemDeclaration.array(), +}) + +export type PluginConfig = Zod.infer + +export const pluginsPopulatedManifests = pluginConfig.partial() +export type PluginsPopulatedManifests = Zod.infer + +export const pluginUpdateBody = z.record(z.record(z.string())) +export type PluginsUpdateBody = Zod.infer diff --git a/.jjconflict-base-1/packages/shared/src/schemas/deployment.ts b/.jjconflict-base-1/packages/shared/src/schemas/deployment.ts new file mode 100644 index 0000000000..b1e0f8ee47 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/deployment.ts @@ -0,0 +1,70 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestDeploymentName } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' +import { EnvironmentSchema } from './environment.js' +import { RepoSchema } from './repository.js' + +const DeploymentSourceType = z.enum(['git', 'oci']) + +export const DeploymentSourceSchema = z.object({ + id: z.string() + .uuid(), + deploymentId: z.string() + .uuid(), + repositoryId: z.string() + .uuid(), + type: DeploymentSourceType, + repository: RepoSchema, + // Optional deployment settings + targetRevision: z.string().optional(), + path: z.string().optional(), + helmValuesFiles: z.string().optional(), +}).extend(AtDatesToStringExtend) + +export const DeploymentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestDeploymentName), + projectId: z.string() + .uuid(), + environmentId: z.string() + .uuid(), + autosync: z.boolean(), + environment: EnvironmentSchema, + deploymentSources: DeploymentSourceSchema.array(), +}).extend(AtDatesToStringExtend) + +export const CreateDeploymentSchema = DeploymentSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + environment: true, +}).extend({ + deploymentSources: DeploymentSourceSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deploymentId: true, + repository: true, + }).array().min(1, 'Au moins une source de déploiement est requise'), +}) + +export const UpdateDeploymentSchema = CreateDeploymentSchema.extend({ + deploymentSources: DeploymentSourceSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deploymentId: true, + repository: true, + }) + .extend({ id: z.string().uuid().optional() }) + .array().min(1, 'Au moins une source de déploiement est requise'), +}) + +export type Deployment = Zod.infer +export type CreateDeployment = Zod.infer +export type UpdateDeployment = Zod.infer diff --git a/.jjconflict-base-1/packages/shared/src/schemas/environment.ts b/.jjconflict-base-1/packages/shared/src/schemas/environment.ts new file mode 100644 index 0000000000..f3c9bd2d8c --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/environment.ts @@ -0,0 +1,40 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestEnvironmentName } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' + +export const EnvironmentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestEnvironmentName), + projectId: z.string() + .uuid(), + stageId: z.string().uuid(), + clusterId: z.string() + .uuid(), + cpu: z.coerce.number().gte(0), + gpu: z.coerce.number().gte(0), + memory: z.coerce.number().gte(0), + autosync: z.boolean(), +}).extend(AtDatesToStringExtend) + +export const CreateEnvironmentSchema = EnvironmentSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, +}) + +export const UpdateEnvironmentSchema = EnvironmentSchema.pick({ + cpu: true, + gpu: true, + memory: true, + autosync: true, +}) + +export type Environment = Zod.infer +export type CreateEnvironment = Zod.infer +export type UpdateEnvironment = Zod.infer diff --git a/.jjconflict-base-1/packages/shared/src/schemas/index.ts b/.jjconflict-base-1/packages/shared/src/schemas/index.ts new file mode 100644 index 0000000000..236c4124b2 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/index.ts @@ -0,0 +1,15 @@ +export * from './cluster.js' +export * from './config.js' +export * from './deployment.js' +export * from './environment.js' +export * from './log.js' +export * from './project.js' +export * from './repository.js' +export * from './role.js' +export * from './service-chain.js' +export * from './services.js' +export * from './stage.js' +export * from './system.js' +export * from './token.js' +export * from './user.js' +export * from './zone.js' diff --git a/.jjconflict-base-1/packages/shared/src/schemas/log.ts b/.jjconflict-base-1/packages/shared/src/schemas/log.ts new file mode 100644 index 0000000000..a58f2777f2 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/log.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import { AtDatesToStringExtend } from './_utils.js' + +export const CleanLogSchema = z.object({ + id: z.string(), + data: z.object({ + failed: z.boolean().or(z.array(z.string())).optional(), + warning: z.string().array().optional(), + totalExecutionTime: z.number().optional(), + messageResume: z.string().optional(), + }).strip(), + action: z.string(), + userId: z.string().nullable(), +}) + .extend(AtDatesToStringExtend) + +export const LogSchema = z.object({ + id: z.string(), + data: z.object({ + args: z.any(), + failed: z.boolean().or(z.array(z.string())).optional(), + warning: z.string().array().optional(), + results: z.any(), + totalExecutionTime: z.number().optional(), + messageResume: z.string().optional(), + }) + .passthrough() + .transform((data) => { + delete data.config + return data + }), + action: z.string(), + userId: z.string().nullable(), + requestId: z.string().nullable(), + projectId: z.string().nullable(), +}) + .extend(AtDatesToStringExtend) diff --git a/.jjconflict-base-1/packages/shared/src/schemas/project.ts b/.jjconflict-base-1/packages/shared/src/schemas/project.ts new file mode 100644 index 0000000000..6f4e549f78 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/project.ts @@ -0,0 +1,87 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestEnvironmentName, projectStatus } from '../utils/const.js' +import { AtDatesToStringExtend, CoerceBooleanSchema, permissionLevelSchema } from './_utils.js' +import { RepoSchema } from './repository.js' +import { ProjectRoleSchema } from './role.js' +import { MemberSchema, UserSchema } from './user.js' + +export const descriptionMaxLength = 280 +export const projectNameMaxLength = 20 +export const ProjectStatusSchema = z.enum(projectStatus) + +const ProjectEnvironmentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestEnvironmentName), + slug: z.string() + .regex(/^[a-z0-9]+$/), + projectId: z.string() + .uuid(), + stageId: z.string().uuid(), + clusterId: z.string() + .uuid(), + permissions: z.array(z.object({ + id: z.string() + .uuid(), + userId: z.string() + .uuid(), + environmentId: z.string() + .uuid(), + level: z.union([ + z.string(), + z.number() + .int() + .nonnegative() + .max(2), + ]), + })), +}) +export const ProjectSchema = z.object({ + // ProjectInfos + repositories: RepoSchema.array(), + environments: ProjectEnvironmentSchema.array(), +}) + +export type Project = Zod.infer + +export const ProjectSchemaV2 = z.object({ + clusterIds: z.string().uuid().array(), + name: z.string() + .min(2) + .max(projectNameMaxLength) + .regex(/^[a-z0-9]+$/), + slug: z.string(), + description: z.string() + .max(descriptionMaxLength) + .optional(), + status: ProjectStatusSchema, + locked: CoerceBooleanSchema, + limitless: CoerceBooleanSchema, + hprodCpu: z.coerce.number().gte(0), + hprodGpu: z.coerce.number().gte(0), + hprodMemory: z.coerce.number().gte(0), + prodCpu: z.coerce.number().gte(0), + prodGpu: z.coerce.number().gte(0), + prodMemory: z.coerce.number().gte(0), + + id: z.string() + .uuid(), + members: MemberSchema + .array(), + ownerId: z.string() + .uuid(), + owner: UserSchema + .omit({ adminRoleIds: true }), + roles: ProjectRoleSchema + .array(), + everyonePerms: permissionLevelSchema, + lastSuccessProvisionningVersion: z.string() + .nullable(), +}) + .extend(AtDatesToStringExtend) + +export type ProjectV2 = Zod.infer diff --git a/.jjconflict-base-1/packages/shared/src/schemas/repository.ts b/.jjconflict-base-1/packages/shared/src/schemas/repository.ts new file mode 100644 index 0000000000..85675b9123 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/repository.ts @@ -0,0 +1,74 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { forbiddenRepoNames, invalidGitUrl, invalidInternalRepoName, missingCredentials } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' + +export const RepoSchema = z.object({ + id: z.string() + .uuid(), + internalRepoName: z.string() + .min(2, { message: 'Longueur minimum 2 caractères' }) + .max(20, { message: 'Longueur maximum 20 caractères' }) + .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, { message: invalidInternalRepoName }) + .refine(name => !forbiddenRepoNames.includes(name.toLowerCase()), { + message: `Le nom du dépôt choisi est inclus dans la liste d'exclusion: ${forbiddenRepoNames.join(', ')}`, + }), + externalRepoUrl: z.string() + .regex(/^https:\/\/.*\.git$/, { message: invalidGitUrl }) + .url({ message: 'Url invalide' }) + .or(z.literal('')) + .optional(), + isPrivate: z.boolean(), + isInfra: z.boolean(), + externalUserName: z.string() + .optional(), + externalToken: z.string() + .optional(), + projectId: z.string() + .uuid(), + // Optional deployment settings for infra repositories + deployRevision: z.string().optional(), + deployPath: z.string().optional(), + helmValuesFiles: z.string().optional(), +}).extend(AtDatesToStringExtend) + +// To only use in frontend form +export const RepoFormSchema = RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .extend({ isStandalone: z.boolean() }) + +export const UpdateRepoFormSchema = RepoFormSchema + .refine( + ({ isPrivate, externalToken, externalUserName }) => { + if (isPrivate) { + if (!externalToken && !externalUserName) return false + return true + } + return true + }, + { message: missingCredentials, path: ['credentials'] }, + ) + .refine(({ isStandalone, externalRepoUrl }) => { + if (!isStandalone && !externalRepoUrl) { + return false + } + return true + }, { message: 'Veuillez renseignez l\'url du dépôt externe', path: ['externalRepoUrl'] }) + +export const CreateRepoFormSchema = RepoFormSchema + .omit({ id: true, projectId: true }) + .refine(({ isPrivate, externalToken, externalUserName }) => { + if (isPrivate) { + if (!externalToken && !externalUserName) return false + return true + } + return true + }, { message: missingCredentials, path: ['credentials'] }) + .refine(({ isStandalone, externalRepoUrl }) => { + if (!isStandalone && !externalRepoUrl) { + return false + } + return true + }, { message: 'Veuillez renseignez l\'url du dépôt externe', path: ['externalRepoUrl'] }) + +export type Repo = Zod.infer diff --git a/.jjconflict-base-1/packages/shared/src/schemas/role.ts b/.jjconflict-base-1/packages/shared/src/schemas/role.ts new file mode 100644 index 0000000000..0e0a1d226a --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/role.ts @@ -0,0 +1,35 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { permissionLevelSchema } from './_utils.js' + +export const RoleNameSchema = z.string().max(30) + +export const RoleSchema = z.object({ + id: z.string().uuid(), + name: RoleNameSchema, + permissions: permissionLevelSchema, + position: z.number().min(0), + type: z.string().optional(), +}) + +export const ProjectRoleSchema = RoleSchema.extend({ + projectId: z.string().uuid(), + oidcGroup: z.string().optional(), +}) + +export const AdminRoleSchema = RoleSchema.extend({ + oidcGroup: z.string(), + type: z.string().optional().default('managed'), +}) + +export const RoleNameCsvSchema = z.string() + .refine((value) => { + return !value.split(',').some(name => !RoleNameSchema.safeParse(name).success) + }) + .transform(value => value.split(',')) + +export type Role = Zod.infer +export type RoleBigint = Omit, 'permissions'> & { permissions: bigint } +export type AdminRole = Zod.infer +export type ProjectRole = Zod.infer +export type ProjectRoleBigint = Omit, 'permissions'> & { permissions: bigint } diff --git a/.jjconflict-base-1/packages/shared/src/schemas/service-chain.ts b/.jjconflict-base-1/packages/shared/src/schemas/service-chain.ts new file mode 100644 index 0000000000..c364fcb8d0 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/service-chain.ts @@ -0,0 +1,103 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const serviceChainStateEnum = ['opened', 'pending', 'success', 'failed'] as const +export const ServiceChainStateZodEnum = z.enum(serviceChainStateEnum) +export type ServiceChainState = Zod.infer + +export const serviceChainNetworkEnum = ['RIE', 'INTERNET'] as const +export const ServiceChainNetworkZodEnum = z.enum(serviceChainNetworkEnum) +export type ServiceChainNetwork = Zod.infer + +export const serviceChainLocationEnum = ['SIR', 'SIL'] as const +export const ServiceChainLocationZodEnum = z.enum(serviceChainLocationEnum) +export type ServiceChainLocation = Zod.infer + +export const serviceChainEnvironmentEnum = ['INT', 'PROD', 'BAS'] as const +export const ServiceChainEnvironmentZodEnum = z.enum(serviceChainEnvironmentEnum) +export type ServiceChainEnvironment = Zod.infer< + typeof ServiceChainEnvironmentZodEnum +> + +export const ServiceChainSchema = z.object({ + id: z.string().uuid(), + state: ServiceChainStateZodEnum, + commonName: + // @TODO: Replace with z.hostname when upgraded to Zod v4 + z.string(), + pai: z.string(), + network: ServiceChainNetworkZodEnum, + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}) +export type ServiceChain = Zod.infer + +export const ServiceChainListSchema = z.array(ServiceChainSchema) +export type ServiceChainList = Zod.infer + +export const ServiceChainDetailsSchema = ServiceChainSchema.extend({ + validationId: z.string().uuid(), + validatedBy: z.nullable(z.string().uuid()), + ref: z.nullable(z.string().uuid()), + location: ServiceChainLocationZodEnum, + targetAddress: z.string().ip(), + projectId: z.string().uuid(), + env: ServiceChainEnvironmentZodEnum, + subjectAlternativeName: z.array( + // @TODO: Replace with z.hostname when upgraded to Zod v4 + z.string(), + ), + redirect: z.boolean(), + antivirus: z.nullable(z.object({ + maxFileSize: z.number(), + })), + websocket: z.boolean(), + ipWhiteList: z.array(z.string().cidr()), + sslOutgoing: z.boolean(), +}) +export type ServiceChainDetails = Zod.infer + +// JSON as String validation through Zod. Instead of adding yet-another-dependency, +// I merely copied the code as-is. +// Many thanks to JacobWeisenburger/zod_utilz ! +// +// Usage: use stringToJSON() as you would use z.string() to validate strings that must +// contain JSON stringified content +const literalSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), +]) +type Literal = z.infer +type Json = Literal | { [ key: string ]: Json } | Json[] +const jsonSchema: z.ZodType = z.lazy(() => + z.union([ + literalSchema, + z.array(jsonSchema), + z.record(jsonSchema), + ]), +) +export const json = () => jsonSchema + +export const serviceChainFlowStateEnum = ['opened', 'pending', 'success', 'failed'] as const +export const ServiceChainFlowStateZodEnum = z.enum(serviceChainFlowStateEnum) +export type ServiceChainFlowState = Zod.infer + +export const ServiceChainFlowDetailsSchema = z.object({ + state: ServiceChainFlowStateZodEnum, + input: json(), + output: json(), + updatedAt: z.coerce.date(), +}) +export type ServiceChainFlowDetails = Zod.infer + +export const ServiceChainFlowsSchema = z.object({ + reserve_ip: ServiceChainFlowDetailsSchema, + create_cert: z.nullable(ServiceChainFlowDetailsSchema), + call_exec: ServiceChainFlowDetailsSchema, + activate_ip: ServiceChainFlowDetailsSchema, + dns_request: ServiceChainFlowDetailsSchema, +}) +// Flows is on object, so always used in plural sense +export type ServiceChainFlows = Zod.infer diff --git a/.jjconflict-base-1/packages/shared/src/schemas/services.ts b/.jjconflict-base-1/packages/shared/src/schemas/services.ts new file mode 100644 index 0000000000..22c8299f1f --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/services.ts @@ -0,0 +1,36 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { pluginsPopulatedManifests } from './config.js' + +export const serviceUrl = z.object({ + to: z.string(), + name: z.string(), + description: z.string() + .optional(), +}) + +export type ServiceUrl = Zod.infer + +export const ServiceSchema = z.object({ + description: z.string() + .optional(), + title: z.string(), + name: z.string(), + imgSrc: z.string() + .optional(), + urls: serviceUrl.array(), + manifest: pluginsPopulatedManifests, +}) + +export const permissionTarget = z.enum(['user', 'admin']).default('user') + +export type PermissionTarget = Zod.infer +export type ProjectService = Zod.infer + +export const ServiceHealthSchema = z.object({ + name: z.string(), + status: z.enum(['OK', 'Dégradé', 'En échec', 'Inconnu']), + interval: z.number(), + lastUpdateTimestamp: z.number(), + message: z.string(), +}) diff --git a/.jjconflict-base-1/packages/shared/src/schemas/stage.ts b/.jjconflict-base-1/packages/shared/src/schemas/stage.ts new file mode 100644 index 0000000000..51cb72d120 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/stage.ts @@ -0,0 +1,14 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const StageSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/i) + .min(2, { message: 'must be at least 2 character long' }) + .max(20, { message: 'must not exceed 20 characters' }), + clusterIds: z.string().uuid().array(), +}) + +export type Stage = Zod.infer diff --git a/.jjconflict-base-1/packages/shared/src/schemas/system.ts b/.jjconflict-base-1/packages/shared/src/schemas/system.ts new file mode 100644 index 0000000000..d941a5b9d2 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/system.ts @@ -0,0 +1,20 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { configProjectItemDeclaration } from './config.js' + +export const pluginSchema = z.object({ + description: z.string() + .optional(), + title: z.string(), + name: z.string(), + imgSrc: z.string() + .optional(), + manifest: configProjectItemDeclaration.array(), +}) + +export type PluginSchema = Zod.infer + +export const SystemSettingSchema = z.object({ + key: z.string(), + value: z.string(), +}) diff --git a/.jjconflict-base-1/packages/shared/src/schemas/token.ts b/.jjconflict-base-1/packages/shared/src/schemas/token.ts new file mode 100644 index 0000000000..36cd518ac5 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/token.ts @@ -0,0 +1,44 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { dateToString, permissionLevelSchema } from './_utils.js' +import { UserSchema } from './user.js' + +export const TokenSchema = z.object({ + id: z.string().uuid(), + name: z.string() + .max(32, { message: 'Ne peut dépasser 32 caractères' }) + .min(2, { message: 'Ne peut faire moins de 2 caractères' }) + .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, { message: 'Le nom ne peut être constitué que de caractères minuscules, de chiffres et de tirets (-)' }), + lastUse: dateToString.nullable(), + createdAt: dateToString, + owner: UserSchema + .pick({ email: true, firstName: true, lastName: true, id: true, type: true }) + .optional() + .nullable(), + status: z.enum(['active', 'revoked', 'inactive']), +}) + +// Admin Token section +export const AdminTokenSchema = TokenSchema + .extend({ + expirationDate: dateToString.nullable(), + permissions: permissionLevelSchema, + }) + +export const ExposedAdminTokenSchema = AdminTokenSchema.extend({ + password: z.string(), +}) + +export type AdminToken = Zod.infer +export type ExposedAdminToken = Zod.infer + +// PAT section +export const PersonalAccessTokenSchema = TokenSchema.extend({ + expirationDate: dateToString, +}) +export const ExposedPersonalAccessTokenSchema = TokenSchema.extend({ + password: z.string(), +}) + +export type PersonalAccessToken = Zod.infer +export type ExposedPersonalAccessToken = Zod.infer diff --git a/.jjconflict-base-1/packages/shared/src/schemas/user.ts b/.jjconflict-base-1/packages/shared/src/schemas/user.ts new file mode 100644 index 0000000000..2fd89bc285 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/user.ts @@ -0,0 +1,39 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { AtDatesToStringExtend, dateToString } from './_utils.js' + +export const UserSchema = z.object({ + id: z.string() + .uuid(), + firstName: z.string(), + lastName: z.string(), + email: z.string(), + adminRoleIds: z.string().uuid().array(), + type: z.enum(['human', 'ghost', 'bot']), + lastLogin: dateToString.optional().nullable(), +}) + .extend(AtDatesToStringExtend) + +export const MemberSchema = z.object({ + userId: z.string() + .uuid(), + firstName: z.string(), + lastName: z.string(), + email: z.string() + .email(), + roleIds: z.string().uuid().array(), +}) + .or( + z.object({ + user: UserSchema, + roleIds: z.string().uuid().array(), + }).transform(({ user: { adminRoleIds: _, id: userId, ...user }, roleIds }) => ({ userId, roleIds, ...user })), + ) + +export const ProjectMemberSchema = MemberSchema.and(z.object({ + projectId: z.string().uuid(), +})) + +export type User = Zod.infer +export type Member = Zod.infer +export type ProjectMember = Zod.infer diff --git a/.jjconflict-base-1/packages/shared/src/schemas/zone.ts b/.jjconflict-base-1/packages/shared/src/schemas/zone.ts new file mode 100644 index 0000000000..e402a73a6c --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/schemas/zone.ts @@ -0,0 +1,24 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const ZoneSchema = z.object({ + id: z.string() + .uuid(), + slug: z.string() + .min(1) + .max(10) + .regex(/^[a-z0-9-]+$/), + label: z.string() + .min(1) + .max(50), + argocdUrl: z.string() + .min(5, { message: 'Longueur minimum 5 caractères' }) + .url({ message: 'Url invalide' }), + description: z.string() + .max(200) + .optional() + .nullable() + .transform(value => value ?? ''), +}) + +export type Zone = Zod.infer diff --git a/.jjconflict-base-1/packages/shared/src/utils/const.ts b/.jjconflict-base-1/packages/shared/src/utils/const.ts new file mode 100644 index 0000000000..9c80f33998 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/utils/const.ts @@ -0,0 +1,147 @@ +export const adminGroupPath = '/admin' +export const deleteValidationInput = 'DELETE' +export const forbiddenRepoNames = ['mirror', 'infra-apps', 'infra-observability'] + +export const inClusterLabel = 'in-cluster' as const +export const projectIsLockedInfo = 'Le projet est verrouillé, pas d\'action possible' +export const missingCredentials = 'Si le dépôt est privé, vous devez renseigner au moins le nom d\'utilisateur ou le token' +export const invalidGitUrl = 'L\'adresse doit commencer par https et se terminer par .git' +export const invalidInternalRepoName = 'Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique' + +export const fakeToken = 'fakeToken' +export const tokenHeaderName = 'x-dso-token' +export const swaggerUiPath = '/swagger-ui' + +export const levels = [ + 'r', + 'rw', + 'rwd', +] as const +export type PermissionLevels = typeof levels[number] + +export const projectRoles = [ + 'owner', + 'user', +] as const +export type ProjectRoles = typeof projectRoles[number] + +export const longestEnvironmentName = 11 as const +export const longestDeploymentName = 11 as const + +export const allStatus = [ + 'initializing', + 'created', + 'failed', + 'deleting', +] as const + +export const projectStatus = [ + 'initializing', + 'created', + 'failed', + 'archived', + 'warning', +] as const + +export const achievedStatus = [ + 'created', + 'failed', +] as const + +export type AchievedStatus = typeof achievedStatus[number] + +export enum ClusterPrivacy { + PUBLIC = 'public', + DEDICATED = 'dedicated', +} + +export const privacyWording: Record = { + dedicated: { text: 'dédié', icon: 'ri:shield-keyhole-line' }, + public: { text: 'public', icon: 'ri:door-open-line' }, +} + +export enum AllStatus { + CREATED = 'created', + FAILED = 'failed', + DELETING = 'deleting', +} + +export const logActions = [ + 'Create Project', + 'Create Repository', + 'Create Environment', + 'Delete Project', + 'Delete Repository', + 'Delete Environment', +] + +export const statusDict = { + locked: { + false: { + testId: 'unlocked-badge', + type: 'success', + icon: 'ri:lock-unlock-line', + wording: 'déverrouillé', + animation: undefined, + color: 'var(--success-425-625)', + }, + true: { + testId: 'locked-badge', + type: 'warning', + icon: 'ri:lock-line', + wording: 'verrouillé', + animation: undefined, + color: 'var(--warning-425-625)', + }, + }, + status: { + created: { + testId: 'created-badge', + type: 'success', + icon: 'ri:check-line', + wording: 'succès', + animation: undefined, + color: 'var(--success-425-625)', + }, + failed: { + testId: 'failed-badge', + type: 'error', + icon: 'ri:close-line', + wording: 'opérations échouées', + animation: undefined, + color: 'var(--error-425-625)', + }, + initializing: { + testId: 'initializing-badge', + type: 'info', + icon: 'ri:loader-4-line', + animation: 'spin', + wording: 'opérations en cours', + color: 'var(--info-425-625)', + }, + archived: { + testId: 'archived-badge', + type: 'info', + icon: 'ri:archive-line', + wording: 'archivé', + animation: undefined, + color: 'var(--text-mention-grey)', + }, + warning: { + testId: 'warning-badge', + type: 'warning', + icon: 'ri:alert-line', + wording: 'partiellement dégradé', + animation: undefined, + color: 'var(--warning-425-625)', + }, + }, +} as const + +export const servicePluginOrder = [ + 'argocd', + 'gitlab', + 'registry', + 'sonarqube', + 'vault', +] diff --git a/.jjconflict-base-1/packages/shared/src/utils/date.ts b/.jjconflict-base-1/packages/shared/src/utils/date.ts new file mode 100644 index 0000000000..1bc7428288 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/utils/date.ts @@ -0,0 +1,9 @@ +export function formatDate(dateString: string) { + const date = new Date(dateString) + return new Intl.DateTimeFormat('default', { dateStyle: 'long' }).format(date) +} + +export function formatDateTime(dateTimeString: string) { + const date = new Date(dateTimeString) + return new Intl.DateTimeFormat('default', { dateStyle: 'short', timeStyle: 'short' }).format(date) +} diff --git a/.jjconflict-base-1/packages/shared/src/utils/functions.spec.ts b/.jjconflict-base-1/packages/shared/src/utils/functions.spec.ts new file mode 100644 index 0000000000..deecd24656 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/utils/functions.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { calcProjectNameMaxLength, exclude, identity, removeTrailingSlash, shallowEqual, shallowMatch } from './functions.js' + +describe('function utils: identity', () => { + it('should return identity', () => { + expect(identity('Test')).toStrictEqual('Test') + }) +}) + +describe('function utils: exclude', () => { + it('should exclude keys', () => { + const emptyInput = {} + const simpleInput = { + hello: 'world', + foo: 'bar', + } + const simpleExpectedInput = { + foo: 'bar', + } + const complexInput = { + empty: emptyInput, + simple: simpleInput, + array: [emptyInput, simpleInput, simpleInput, emptyInput], + number: 1, + } + const complexExpectedOutput = { + empty: emptyInput, + simple: simpleExpectedInput, + array: [emptyInput, simpleExpectedInput, simpleExpectedInput, emptyInput], + number: 1, + } + + expect(exclude(emptyInput, ['absentKey'])).toStrictEqual({}) + expect(exclude(simpleInput, ['hello'])).toStrictEqual(simpleExpectedInput) + expect(exclude(complexInput, ['hello'])).toStrictEqual(complexExpectedOutput) + }) + + it('should not mutate input', () => { + const simpleInput = { + hello: 'world', + foo: 'bar', + } + exclude(simpleInput, ['hello']) + expect(simpleInput).toStrictEqual(simpleInput) + }) + + it('should stringify Error and not traverse non-serializable objects', () => { + class NotSerializable { + public value = 'secret' + } + + const input = { + err: new Error('boom'), + big: 896n, + date: new Date('2026-04-08T14:32:26.773Z'), + instance: new NotSerializable(), + } + + const transformed = exclude(input, []) + expect(transformed).toMatchObject({ + big: '896', + date: '2026-04-08T14:32:26.773Z', + instance: '[NotSerializable]', + }) + expect(typeof (transformed as any).err).toBe('string') + expect((transformed as any).err).toContain('boom') + }) +}) + +describe('function utils: calcProjectNameMaxLength', () => { + it('should return max length', () => { + expect(calcProjectNameMaxLength()).toStrictEqual(50) + }) +}) + +describe('function utils: removeTrailingSlash', () => { + it('should return string without ending slash', () => { + expect(removeTrailingSlash('mtest')).toStrictEqual('mtest') + expect(removeTrailingSlash('mtest/')).toStrictEqual('mtest') + }) +}) + +describe('function utils: shallowEqual', () => { + it('should return false if value differs', () => { + expect(shallowEqual({ a: 1 }, { a: 2 })).toEqual(false) + }) + it('should return false if missing key', () => { + expect(shallowEqual({ a: 1, b: 2 }, { a: 1 })).toEqual(false) + }) + it('should return true if equal', () => { + expect(shallowEqual({ a: 1 }, { a: 1 })).toEqual(true) + }) + it('should ignore undefined values in object1', () => { + expect(shallowEqual({ a: 1, b: undefined }, { a: 1 })).toEqual(true) + }) + it('should ignore undefined values in object2', () => { + expect(shallowEqual({ a: 1 }, { a: 1, b: undefined })).toEqual(true) + }) + it('should return true even if keys are in different order', () => { + expect(shallowEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toEqual(true) + }) + it('should treat empty objects as equal', () => { + expect(shallowEqual({}, {})).toEqual(true) + }) + it('should treat objects with only undefined keys as equal', () => { + expect(shallowEqual({ a: undefined }, { b: undefined })).toEqual(true) + }) +}) + +describe('function utils: shallowMatch', () => { + it('should return false if key is not equal', () => { + expect(shallowMatch({ a: 1 }, { a: 2 })).toEqual(false) + }) + it('should return false if missing key', () => { + expect(shallowMatch({ a: 1, b: 2 }, { a: 1 })).toEqual(false) + }) + it('should return true if is equal', () => { + expect(shallowMatch({ a: 1 }, { a: 1 })).toEqual(true) + }) +}) diff --git a/.jjconflict-base-1/packages/shared/src/utils/functions.ts b/.jjconflict-base-1/packages/shared/src/utils/functions.ts new file mode 100644 index 0000000000..f38778f36d --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/utils/functions.ts @@ -0,0 +1,290 @@ +import type { ResourceById, ResourceByKey } from './types.js' +import shortUUID from 'short-uuid' +import { longestEnvironmentName } from './const.js' + +/** + * @param {*} value Value wanted to be return as is + * @returns {*} Value returned as is + */ +export const identity = (value: unknown) => value + +export const getLongestStringOfArray = (array: Array) => array.reduce((acc, curr) => acc.length < curr.length ? curr : acc, '') + +export function calcProjectNameMaxLength() { + return 61 - longestEnvironmentName +} + +export const getUniqueListBy = (arr: Array>, key: string) => [...new Map(arr.map(item => [item[key], item])).values()] + +export const isString = (value: any): value is string => typeof value === 'string' + +type ObjToSort = Record + +export function sortArrByObjKeyAsc(arr: T, key: string): T { + return arr.toSorted((a: ObjToSort, b: ObjToSort) => { + const aValue = a[key] + const bValue = b[key] + if (isString(aValue) && isString(bValue)) { + return aValue.localeCompare(bValue.toString(), 'fr', { sensitivity: 'base' }) + } + if (typeof aValue === 'number' && typeof bValue === 'number') { + return aValue - bValue + } + if (typeof aValue === 'boolean' && typeof bValue === 'boolean') { + return Number(bValue) - Number(aValue) + } + return -1 + }) as T +} + +export function removeTrailingSlash(url: string) { + return url?.endsWith('/') + ? url.slice(0, -1) + : url +} + +function excludeCircular(value: unknown, keys: string[], inPath: WeakSet, inArray: boolean): unknown { + if (value === null) return null + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value + if (typeof value === 'bigint') return value.toString() + if (typeof value === 'undefined') return inArray ? null : undefined + if (typeof value === 'function') return inArray ? null : undefined + if (typeof value === 'symbol') return value.toString() + if (typeof value === 'object') { + if (inPath.has(value)) return '[object Circular]' + inPath.add(value) + + if (Array.isArray(value)) return value.map(v => excludeCircular(v, keys, inPath, true)) + + if (value instanceof Date) { + inPath.delete(value) + return value.toISOString() + } + + if (value instanceof Error) { + const payload = { + name: value.name, + message: value.message, + stack: value.stack, + } + inPath.delete(value) + try { + return JSON.stringify(payload) + } catch { + return `${value.name}: ${value.message}` + } + } + + if (value instanceof Map) { + const obj: Record = {} + for (const [k, v] of value.entries()) { + const key = typeof k === 'string' ? k : String(k) + obj[key] = excludeCircular(v, keys, inPath, false) + } + inPath.delete(value) + return obj + } + + if (value instanceof Set) { + const out = Array.from(value.values(), v => excludeCircular(v, keys, inPath, true)) + inPath.delete(value) + return out + } + + if (value instanceof RegExp) { + inPath.delete(value) + return value.toString() + } + + if (value instanceof URL) { + inPath.delete(value) + return value.toString() + } + + if (value instanceof URLSearchParams) { + inPath.delete(value) + return value.toString() + } + + if ('toJSON' in value && typeof value.toJSON === 'function') { + try { + const serialized = value.toJSON() + const out = excludeCircular(serialized, keys, inPath, inArray) + inPath.delete(value) + return out + } catch { + inPath.delete(value) + return '[object Unserializable]' + } + } + + const proto = Object.getPrototypeOf(value) + if (proto !== Object.prototype && proto !== null) { + const ctorName = (value as any)?.constructor?.name + const tag = typeof ctorName === 'string' && ctorName.length ? ctorName : 'Object' + try { + const s = String(value) + const out = s === '[object Object]' ? `[${tag}]` : s + inPath.delete(value) + return out + } catch { + inPath.delete(value) + return `[object ${tag}]` + } + } + + const obj = value as Record + const newObj: Record = {} + for (const [k, v] of Object.entries(obj)) { + if (keys.includes(k)) continue + const next = excludeCircular(v, keys, inPath, false) + if (typeof next === 'undefined') continue + newObj[k] = next + } + inPath.delete(value) + return newObj + } + return String(value) +} + +// Exclude keys from an object +export function exclude(result: T, keys: string[]): T { + const inPath = new WeakSet() + return excludeCircular(result, keys, inPath, false) as T +} + +export type AsyncReturnType Promise> + = T extends (...args: any) => Promise ? R : any + +export function objectEntries>(obj: Obj): ([keyof Obj, Obj[keyof Obj]])[] { + return Object.entries(obj) as ([keyof Obj, Obj[keyof Obj]])[] +} +export function objectKeys>(obj: Obj): (keyof Obj)[] { + return Object.keys(obj) as (keyof Obj)[] +} +export function objectValues>(obj: Obj): (Obj[keyof Obj])[] { + return Object.values(obj) as (Obj[keyof Obj])[] +} + +export function requiredEnv(envName: string): string { + const envValue = process.env[envName] + if (envValue) return envValue + + throw new Error(`env: ${envName} is not defined !`) +} + +export function resourceListToDict(resList: Array): ResourceById { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.id]: curr, + } + }, {} as ResourceById) +} + +export function resourceListToDictByKey(resList: Array): ResourceByKey { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.key]: curr, + } + }, {} as ResourceByKey) +} + +export function shallowEqual(object1: Record, object2: Record) { + const definedObject1 = Object.fromEntries( + Object.entries(object1).filter(([_, v]) => v !== undefined), + ) + const definedObject2 = Object.fromEntries( + Object.entries(object2).filter(([_, v]) => v !== undefined), + ) + const keys1 = Object.keys(definedObject1) + const keys2 = Object.keys(definedObject2) + + if (keys1.length !== keys2.length) { + return false + } + + for (const key of keys1) { + if (definedObject1[key] !== definedObject2[key]) { + return false + } + } + + return true +} + +/** + * Check if all the keys / values in first parameter is present in the the second parameter, the second one can have additional property + * + * @example + * ```ts + * shallowMatch({ a: 'a' }, { a: 'c' }) // false, invalid value + * shallowMatch({ a: 'a' }, { }) // fals, missing property + * shallowMatch({ a: 'a' }, { a: 'a', b: 'b' }) // true, everything is retrieved and equivalent in the second argument + * ``` + * + */ +export function shallowMatch(objectController: Record | undefined, objectToCheck: Record | undefined) { + if (!objectController || !objectToCheck) { + return false + } + + const keys1 = Object.keys(objectController) + const keys2 = Object.keys(objectToCheck) + + if (keys1.length > keys2.length) { + return false + } + + for (const key of keys1) { + if (objectController[key] !== objectToCheck[key]) { + return false + } + } + + return true +} + +export function generateRandomPassword(length = 24, chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*') { + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +export function isAtLeastTomorrow(actualTime: Date) { + const tomorrow = new Date(Date.now()) + tomorrow.setUTCHours(23, 59, 59, 999) + + return actualTime.getTime() > tomorrow.getTime() +} + +export function insert(pseudoArray: T[] | undefined, element: T): T[] { + if (!pseudoArray) { + return [element] + } else if (Array.isArray(pseudoArray)) { + return [...pseudoArray, element] + } else { + throw new TypeError('item is not an ArrayLike') + } +} + +export type ArrayElement + = ArrayType extends readonly (infer ElementType)[] ? ElementType : never + +export const bts = (v: boolean) => v ? 'true' : 'false' +export function stb(v?: string | undefined) { + return v === 'true' + ? true + : v === 'false' ? false : undefined +} + +const uuidTranslator = shortUUID(shortUUID.constants.uuid25Base36, { + consistentLength: false, +}) + +export const compressUUID = uuidTranslator.fromUUID +export const expandUUID = uuidTranslator.toUUID + +export function generateNamespaceName(projectId: string, envId: string) { + return `${compressUUID(envId)}--${compressUUID(projectId)}` +} diff --git a/.jjconflict-base-1/packages/shared/src/utils/index.ts b/.jjconflict-base-1/packages/shared/src/utils/index.ts new file mode 100644 index 0000000000..0600385dce --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/utils/index.ts @@ -0,0 +1,8 @@ +export * from './const.js' +export * from './date.js' +export * from './functions.js' +export * from './permissions.js' +export * from './plugins.js' +export * from './roles.js' +export * from './schemas.js' +export * from './types.js' diff --git a/.jjconflict-base-1/packages/shared/src/utils/permissions.ts b/.jjconflict-base-1/packages/shared/src/utils/permissions.ts new file mode 100644 index 0000000000..040fbf55d1 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/utils/permissions.ts @@ -0,0 +1,345 @@ +/** + * [FR] ATTENTION ! Ce fichier est la base du système de permissions de l'application. + * Ces permissions sont basés sur le Bitwise Permissions System. Les modifier à posteriori pourrait être catastrophique niveau sécurité. + * Veuillez bien étudier le système et lire la documentation. + * + * [EN] This file is the basis of the application's permissions system. + * These permissions are based on the Bitwise Permissions System. Modifying them after the fact could be catastrophic in terms of security. + * Please study the system carefully and read the documentation. + * https://en.wikipedia.org/wiki/Bitwise_operation + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_AND + * https://www.alexhyett.com/bitwise-operators/ + * + * Voici des sources d'inspirations + * https://discordapi.com/permissions.html#32 + * https://discord.com/developers/docs/topics/permissions#permissions + */ +import type { ResourceById } from './types.js' +import { logger as baseLogger } from '@cpn-console/logger' + +const logger = baseLogger.child({ scope: 'utils:permissions' }) + +export function getPermsByUserRoles(userRoles: string[] | undefined, rolesById: ResourceById<{ id: string, permissions: bigint | string }>, basePerms?: bigint | string) { + if (!userRoles) { + return basePerms ? BigInt(basePerms) : 0n + } + return userRoles.reduce((acc, curr) => { + if (!rolesById[curr]) { + logger.warn(`Role ${curr} not found in rolesById`) + return acc + } + return acc | BigInt(rolesById[curr].permissions) + }, basePerms ? BigInt(basePerms) : 0n) +} + +function permissionsParser(a: Record) { + const valuesRegistered = [] as bigint[] + for (const [k, v] of Object.entries(a)) { + if (typeof v !== 'bigint') + throw new Error(`${k} has a invalid value: ${v}, which is not a bigint`) + if (valuesRegistered.includes(v)) + throw new Error(`${k} has a duplicated value: ${v}`) + valuesRegistered.push(v) + } +} + +const bit = (position: bigint) => 1n << position + +// Be very careful and think to apply corresponding updates in database if you modify these values, You'll have to do binary updates in SQL, good luck ! +export const PROJECT_PERMS = { // project permissions + GUEST: bit(0n), + MANAGE: bit(1n), + MANAGE_MEMBERS: bit(2n), + MANAGE_ENVIRONMENTS: bit(3n), + MANAGE_REPOSITORIES: bit(4n), + MANAGE_ROLES: bit(5n), + SEE_SECRETS: bit(6n), + REPLAY_HOOKS: bit(7n), + LIST_ENVIRONMENTS: bit(8n), + LIST_REPOSITORIES: bit(9n), + LIST_MEMBERS: bit(10n), + LIST_ROLES: bit(11n), + MANAGE_DEPLOYMENTS: bit(12n), + LIST_DEPLOYMENTS: bit(13n), +} + +// Be very careful and think to apply corresponding updates in database if you modify these values, You'll have to do binary updates in SQL, good luck ! +export const ADMIN_PERMS = { // admin permissions + LIST: bit(0n), + MANAGE: bit(1n), + MANAGE_USERS: bit(2n), + MANAGE_PROJECTS: bit(3n), + MANAGE_ROLES: bit(4n), + MANAGE_CLUSTERS: bit(5n), + MANAGE_ZONES: bit(6n), + MANAGE_STAGES: bit(7n), + MANAGE_SYSTEM: bit(8n), + LIST_USERS: bit(9n), + LIST_PROJECTS: bit(10n), + LIST_ROLES: bit(11n), + LIST_CLUSTERS: bit(12n), + LIST_ZONES: bit(13n), + LIST_STAGES: bit(14n), + LIST_SYSTEM: bit(15n), + MANAGE_ADMIN_TOKEN: bit(16n), + LIST_ADMIN_TOKEN: bit(17n), +} + +export type ProjectPermsKeys = keyof typeof PROJECT_PERMS +export type AdminPermsKeys = keyof typeof ADMIN_PERMS + +permissionsParser(ADMIN_PERMS) +permissionsParser(PROJECT_PERMS) + +interface ProjectAuthorizedParams { adminPermissions?: bigint | string | null, projectPermissions?: bigint | string } + +export const toBigInt = (value?: bigint | number | string | undefined | null) => value ? BigInt(value) : 0n + +export const AdminAuthorized = { + Manage: (perms?: bigint | string | null) => !!(toBigInt(perms) & ADMIN_PERMS.MANAGE), + ManageUsers: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_USERS | ADMIN_PERMS.MANAGE)), + ManageProjects: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_PROJECTS | ADMIN_PERMS.MANAGE)), + ManageRoles: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ROLES | ADMIN_PERMS.MANAGE)), + ManageClusters: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_CLUSTERS | ADMIN_PERMS.MANAGE)), + ManageZones: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ZONES | ADMIN_PERMS.MANAGE)), + ManageStages: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_STAGES | ADMIN_PERMS.MANAGE)), + ManageSystem: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_SYSTEM | ADMIN_PERMS.MANAGE)), + ManageAdminToken: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ADMIN_TOKEN | ADMIN_PERMS.MANAGE)), + ListUsers: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_USERS | ADMIN_PERMS.MANAGE_USERS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListProjects: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_PROJECTS | ADMIN_PERMS.MANAGE_PROJECTS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListRoles: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ROLES | ADMIN_PERMS.MANAGE_ROLES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListClusters: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_CLUSTERS | ADMIN_PERMS.MANAGE_CLUSTERS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListZones: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ZONES | ADMIN_PERMS.MANAGE_ZONES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListStages: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_STAGES | ADMIN_PERMS.MANAGE_STAGES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListSystem: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_SYSTEM | ADMIN_PERMS.MANAGE_SYSTEM | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListAdminToken: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ADMIN_TOKEN | ADMIN_PERMS.MANAGE_ADMIN_TOKEN | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), +} as const + +export const ProjectAuthorized = { + Manage: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & PROJECT_PERMS.MANAGE), + + ListEnvironments: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE)), + ListRepositories: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_REPOSITORIES | PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.MANAGE)), + ListMembers: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_MEMBERS | PROJECT_PERMS.MANAGE_MEMBERS | PROJECT_PERMS.MANAGE)), + ListRoles: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_ROLES | PROJECT_PERMS.MANAGE_ROLES | PROJECT_PERMS.MANAGE)), + + ManageEnvironments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE)), + ManageRepositories: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.MANAGE)), + + ManageMembers: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_MEMBERS | PROJECT_PERMS.MANAGE)), + + ManageRoles: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_ROLES | PROJECT_PERMS.MANAGE)), + + ReplayHooks: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.MANAGE)), + + SeeSecrets: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.MANAGE)), + ManageDeployments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_DEPLOYMENTS | PROJECT_PERMS.MANAGE)), + ListDeployments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_DEPLOYMENTS | PROJECT_PERMS.MANAGE)), +} as const + +interface ScopePerm { + name: string + perms: Array<{ key: T, label: string, hint?: string }> +} +type PermDetails = Array> + +export const projectPermsDetails: PermDetails = [{ + name: 'Projet', + perms: [{ + key: 'MANAGE', + label: 'Gérer le projet', + hint: 'Permet de gérer tout le projet et ses ressources associées', + }, { + key: 'MANAGE_ROLES', + label: 'Gérer les rôles du projet', + hint: 'ATTENTION : Ce rôle inclut une élévation de privilège ! Permet de gérer les rôles du projet et les membres associés', + }, { + key: 'MANAGE_MEMBERS', + label: 'Gérer les membres du projet', + hint: 'Permet d\'inviter des utilisateurs et de les retirer', + }, { + key: 'LIST_ROLES', + label: 'Voir les rôles du projet', + hint: 'Permet de visualiser les rôles du projet', + }, { + key: 'LIST_MEMBERS', + label: 'Voir les membres du projet', + hint: 'Permet de visualiser les membres du projet', + }, { + key: 'SEE_SECRETS', + label: 'Afficher les secrets', + hint: 'Permet d\'afficher les secrets générés par les services', + }, { + key: 'REPLAY_HOOKS', + label: 'Reprovisionner le projet', + hint: 'Permet de lancer un reprovisionnage du projet', + }], +}, { + name: 'Environnement', + perms: [ + { + key: 'MANAGE_ENVIRONMENTS', + label: 'Gérer les environnements', + hint: 'Permet de créer, éditer, supprimer des environnements', + }, + { + key: 'LIST_ENVIRONMENTS', + label: 'Voir les environnements', + hint: 'Permet de visualiser tous les environnements et leurs configurations', + }, + ], +}, { + name: 'Dépôt', + perms: [ + { + key: 'MANAGE_REPOSITORIES', + label: 'Gérer les dépots', + hint: 'Permet de créer, éditer, supprimer des dépôts', + }, + { + key: 'LIST_REPOSITORIES', + label: 'Voir les dépôts', + hint: 'Permet de visualiser tous les dépôts et leurs configurations', + }, + ], +}, { + name: 'Déploiements', + perms: [ + { + key: 'MANAGE_DEPLOYMENTS', + label: 'Gérer les déploiements', + hint: 'Permet de créer, éditer, supprimer des déploiements', + }, + { + key: 'LIST_DEPLOYMENTS', + label: 'Voir les déploiements', + hint: 'Permet de visualiser tous les déploiements et leurs configurations', + }, + ], +}] as const + +export const adminPermsDetails: PermDetails = [{ + name: 'Global', + perms: [{ + key: 'MANAGE', + label: 'Administration globale', + hint: 'Administration globale de toute la console et de ses ressources', + }, { + key: 'LIST', + label: 'Lecture seule globale', + hint: 'Accès en lecture seule à toute la console et ses ressources', + }], +}, { + name: 'Gestion des utilisateurs', + perms: [{ + key: 'MANAGE_USERS', + label: 'Gérer les utilisateurs', + hint: 'Permet de gérer les utilisateurs de la console', + }, { + key: 'LIST_USERS', + label: 'Voir les utilisateurs', + hint: 'Permet de voir les utilisateurs de la console', + }], +}, { + name: 'Gestion des projets', + perms: [{ + key: 'MANAGE_PROJECTS', + label: 'Gérer les projets', + hint: 'Permet de gérer les projets de la console', + }, { + key: 'LIST_PROJECTS', + label: 'Voir les projets', + hint: 'Permet de voir les projets de la console', + }], +}, { + name: 'Gestion des rôles', + perms: [{ + key: 'MANAGE_ROLES', + label: 'Gérer les rôles', + hint: 'Permet de gérer les rôles de la console', + }, { + key: 'LIST_ROLES', + label: 'Voir les rôles', + hint: 'Permet de voir les rôles de la console', + }], +}, { + name: 'Infrastructure', + perms: [{ + key: 'MANAGE_CLUSTERS', + label: 'Gérer les clusters', + hint: 'Permet de gérer les clusters de la console', + }, { + key: 'LIST_CLUSTERS', + label: 'Voir les clusters', + hint: 'Permet de voir les clusters de la console', + }, { + key: 'MANAGE_ZONES', + label: 'Gérer les zones', + hint: 'Permet de gérer les zones de la console', + }, { + key: 'LIST_ZONES', + label: 'Voir les zones', + hint: 'Permet de voir les zones de la console', + }, { + key: 'MANAGE_STAGES', + label: 'Gérer les types d\'environnement', + hint: 'Permet de gérer les types d\'environnement de la console', + }, { + key: 'LIST_STAGES', + label: 'Voir les types d\'environnement', + hint: 'Permet de voir les types d\'environnement de la console', + }], +}, { + name: 'Système', + perms: [{ + key: 'MANAGE_SYSTEM', + label: 'Gérer le système', + hint: 'Permet de gérer les configurations et logs du système', + }, { + key: 'LIST_SYSTEM', + label: 'Voir le système', + hint: 'Permet de voir les configurations et logs du système', + }], +}, { + name: 'Jetons d’API', + perms: [{ + key: 'MANAGE_ADMIN_TOKEN', + label: 'Gérer les jetons d’API', + hint: 'Permet de créer et révoquer des jetons d’API admin', + }, { + key: 'LIST_ADMIN_TOKEN', + label: 'Voir les jetons d’API', + hint: 'Permet de lister les jetons d’API admin', + }], +}] as const + +export function getAdminPermLabelsByValue(value: bigint | string) { + value = typeof value === 'bigint' ? value : BigInt(value) + return adminPermsDetails + .map(section => section.perms) + .flat() + .filter(permDetail => ADMIN_PERMS[permDetail.key] & value) + .map(permDetail => permDetail.label) +} + +export function getProjectPermLabelsByValue(value: bigint | string) { + value = typeof value === 'bigint' ? value : BigInt(value) + return projectPermsDetails + .map(section => section.perms) + .flat() + .filter(permDetail => PROJECT_PERMS[permDetail.key] & value) + .map(permDetail => permDetail.label) +} diff --git a/.jjconflict-base-1/packages/shared/src/utils/plugins.ts b/.jjconflict-base-1/packages/shared/src/utils/plugins.ts new file mode 100644 index 0000000000..fd907ad899 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/utils/plugins.ts @@ -0,0 +1,42 @@ +export enum MonitorStatus { + OK = 'OK', + WARNING = 'Dégradé', + ERROR = 'En échec', + UNKNOW = 'Inconnu', +} + +export interface MonitorInfos { + lastUpdateTimestamp: number + interval: number + message: string + status: MonitorStatus + cause?: any // should be deserved to admins only +} +export class Monitor { + private intervalTime: number + public monitorFn: (instance: any) => Promise + private intervalID: NodeJS.Timeout | undefined + public lastStatus: MonitorInfos + + constructor(callback: (instance: Monitor) => Promise, interval: number = 5 * 60 * 1000) { + this.intervalTime = interval + this.monitorFn = () => { + this.lastStatus.cause = undefined + return callback(this) + } + this.lastStatus = { + interval: this.intervalTime, + lastUpdateTimestamp: Date.now(), + message: 'En attente d\'une première vérification', + status: MonitorStatus.UNKNOW, + cause: 'App just started', + } + } + + async refresh() { + if (this.intervalID) + clearInterval(this.intervalID) + this.intervalID = setInterval(() => this.monitorFn(this), this.intervalTime) + return this.monitorFn(this) + } +} diff --git a/.jjconflict-base-1/packages/shared/src/utils/roles.spec.ts b/.jjconflict-base-1/packages/shared/src/utils/roles.spec.ts new file mode 100644 index 0000000000..1124f52e69 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/utils/roles.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { + generateSystemRoleType, + getBaseRoleType, + isExternalRoleType, + isGlobalRoleType, + isManagedRoleType, + isSystemRoleType, +} from './roles.js' + +describe('roles', () => { + describe('generateSystemRoleType', () => { + it('prefixes a base type with system:', () => { + expect(generateSystemRoleType('external')).toBe('system:external') + }) + + it('returns system:undefined for undefined input', () => { + expect(generateSystemRoleType(undefined)).toBe('system:undefined') + }) + + it('returns system:null for null input', () => { + expect(generateSystemRoleType(null)).toBe('system:null') + }) + }) + + describe('isSystemRoleType', () => { + it('returns true for system: prefixed values', () => { + expect(isSystemRoleType('system:external')).toBe(true) + expect(isSystemRoleType('system:managed')).toBe(true) + }) + + it('returns false for non-system values', () => { + expect(isSystemRoleType('external')).toBe(false) + expect(isSystemRoleType('managed')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isSystemRoleType(null)).toBe(false) + expect(isSystemRoleType(undefined)).toBe(false) + }) + }) + + describe('getBaseRoleType', () => { + it('strips system: prefix', () => { + expect(getBaseRoleType('system:external')).toBe('external') + expect(getBaseRoleType('system:managed')).toBe('managed') + }) + + it('returns non-system values as-is', () => { + expect(getBaseRoleType('external')).toBe('external') + expect(getBaseRoleType('managed')).toBe('managed') + }) + + it('returns undefined for null or undefined', () => { + expect(getBaseRoleType(null)).toBeUndefined() + expect(getBaseRoleType(undefined)).toBeUndefined() + }) + }) + + describe('isExternalRoleType', () => { + it('returns true for system:external', () => { + expect(isExternalRoleType('system:external')).toBe(true) + }) + + it('returns true for bare external', () => { + expect(isExternalRoleType('external')).toBe(true) + }) + + it('returns false for other system types', () => { + expect(isExternalRoleType('system:managed')).toBe(false) + expect(isExternalRoleType('system:global')).toBe(false) + }) + + it('returns false for non-external types', () => { + expect(isExternalRoleType('managed')).toBe(false) + expect(isExternalRoleType('global')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isExternalRoleType(null)).toBe(false) + expect(isExternalRoleType(undefined)).toBe(false) + }) + }) + + describe('isManagedRoleType', () => { + it('returns true for system:managed', () => { + expect(isManagedRoleType('system:managed')).toBe(true) + }) + + it('returns true for bare managed', () => { + expect(isManagedRoleType('managed')).toBe(true) + }) + + it('returns false for other types', () => { + expect(isManagedRoleType('external')).toBe(false) + expect(isManagedRoleType('global')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isManagedRoleType(null)).toBe(false) + expect(isManagedRoleType(undefined)).toBe(false) + }) + }) + + describe('isGlobalRoleType', () => { + it('returns true for system:global', () => { + expect(isGlobalRoleType('system:global')).toBe(true) + }) + + it('returns true for bare global', () => { + expect(isGlobalRoleType('global')).toBe(true) + }) + + it('returns false for other types', () => { + expect(isGlobalRoleType('external')).toBe(false) + expect(isGlobalRoleType('managed')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isGlobalRoleType(null)).toBe(false) + expect(isGlobalRoleType(undefined)).toBe(false) + }) + }) +}) diff --git a/.jjconflict-base-1/packages/shared/src/utils/roles.ts b/.jjconflict-base-1/packages/shared/src/utils/roles.ts new file mode 100644 index 0000000000..3512475e8e --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/utils/roles.ts @@ -0,0 +1,26 @@ +export const SYSTEM_ROLE_TYPE = 'system' as const + +export function generateSystemRoleType(type: string | null | undefined) { + return `${SYSTEM_ROLE_TYPE}:${type}` +} + +export function isSystemRoleType(type: string | null | undefined) { + return !!type?.startsWith(`${SYSTEM_ROLE_TYPE}:`) +} + +export function getBaseRoleType(type: string | null | undefined) { + if (!type) return undefined + return isSystemRoleType(type) ? type.slice(`${SYSTEM_ROLE_TYPE}:`.length) : type +} + +export function isManagedRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'managed' +} + +export function isGlobalRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'global' +} + +export function isExternalRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'external' +} diff --git a/.jjconflict-base-1/packages/shared/src/utils/schemas.spec.ts b/.jjconflict-base-1/packages/shared/src/utils/schemas.spec.ts new file mode 100644 index 0000000000..5f0c422a13 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/utils/schemas.spec.ts @@ -0,0 +1,349 @@ +import type { Log, ProjectV2 } from '../index.js' +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { ZodError } from 'zod' +import { ClusterDetailsSchema, ClusterPrivacy, ClusterUsageSchema, descriptionMaxLength, EnvironmentSchema, instanciateSchema, LogSchema, parseZodError, ProjectSchemaV2, RepoSchema, StageSchema, UserSchema } from '../index.js' + +describe('schemas utils', () => { + it('should delete config in log', () => { + const toParse: Log = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + action: 'Create a log', + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + data: { + args: {}, + failed: false, + config: {}, + }, + requestId: faker.string.uuid(), + userId: faker.string.uuid(), + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + // @ts-ignore + delete parsed.data.config + expect(LogSchema + .safeParse(toParse)) + .toStrictEqual({ data: parsed, success: true }) + }) + + it('should not validate an undefined object', () => { + // @ts-ignore + expect(RepoSchema.safeParse(undefined).error).toBeInstanceOf(ZodError) + }) + + it('should validate a correct repository schema', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + externalToken: 'eddddsqsq-_', + isPrivate: true, + isInfra: false, + externalUserName: 'clai+re-nlet_', + } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a correct environment schema', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + projectId: faker.string.uuid(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + } + + expect(EnvironmentSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a correct project schema', () => { + const toParse: ProjectV2 = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + slug: faker.lorem.word({ length: { min: 2, max: 10 } }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + description: '', + status: 'created', + locked: false, + clusterIds: [], + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + members: [], + owner: { + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + email: 'invalid-email@mais-pas-grave', + firstName: faker.person.firstName(), + id: faker.string.uuid(), + lastName: faker.person.lastName(), + type: 'human', + }, + everyonePerms: '1', + ownerId: faker.string.uuid(), + roles: [], + lastSuccessProvisionningVersion: null, + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + // @ts-ignore + parsed.owner.updatedAt = parsed.owner.updatedAt.toISOString() + // @ts-ignore + parsed.owner.createdAt = parsed.owner.createdAt.toISOString() + expect(ProjectSchemaV2.safeParse(toParse)).toStrictEqual({ data: parsed, success: true }) + }) + + it('should validate a correct user schema', () => { + const toParse = { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + updatedAt: new Date(), + createdAt: new Date(), + type: 'human', + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + expect(UserSchema.safeParse(toParse)).toStrictEqual({ data: parsed, success: true }) + }) + + it('should validate a correct stage schema', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + clusterIds: [], + } + + expect(StageSchema.safeParse(toParse)).toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a repo business schema', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + externalUserName: 'clairenlet', + externalToken: 'myToken', + isPrivate: true, + isInfra: false, + } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a public cluster details schema', () => { + const toParse = { + id: faker.string.uuid(), + label: 'cluster', + clusterResources: true, + privacy: ClusterPrivacy.PUBLIC, + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + zoneId: faker.string.uuid(), + stageIds: [faker.string.uuid(), faker.string.uuid()], + kubeconfig: { + user: {}, + cluster: { + tlsServerName: 'blabla', + }, + }, + } + + expect(ClusterDetailsSchema + .safeParse(toParse)) + .toStrictEqual({ data: { ...toParse, infos: '' }, success: true }) + }) + + it('should validate a cluster usage schema', () => { + const toParse = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + } + + expect(ClusterUsageSchema + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a repository schema with wrong internal repo name', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: '-candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + isPrivate: false, + isInfra: false, + } + + // @ts-ignore + expect(parseZodError(RepoSchema + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candilib-' + + // @ts-ignore + expect(parseZodError(RepoSchema + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candiLib' + + // @ts-ignore + expect(parseZodError(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candi-lib' + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a too short project name', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.string.alpha({ casing: 'lower' }), + status: 'created', + locked: false, + description: '', + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + // @ts-ignore + expect(parseZodError(ProjectSchemaV2 + .safeParse(toParse) + .error)) + .toMatch('Validation error: String must contain at least 2 character(s) at "name"') + }) + + it('should not validate a too long project name', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.string.alpha({ length: 24, casing: 'lower' }), + status: 'created', + locked: false, + description: '', + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + // @ts-ignore + expect(parseZodError(ProjectSchemaV2 + .safeParse(toParse) + .error)) + .toMatch('Validation error: String must contain at most 20 character(s) at "name"') + }) + + it('should not validate a too long project description', () => { + const toParse = { + id: faker.string.uuid(), + name: 'candilib', + description: faker.string.alpha(descriptionMaxLength + 1), + status: 'created', + locked: false, + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + expect(ProjectSchemaV2 + .safeParse(toParse) + .error).toBeInstanceOf(ZodError) + }) + + it('should validate a single key with given schema', () => { + const toParse = { internalRepoName: 'candilib' } + + expect(RepoSchema + .pick({ internalRepoName: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a single key with given schema', () => { + const toParse = { internalRepoName: 'candi lib' } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .pick({ internalRepoName: true }) + .safeParse(toParse) + // @ts-ignore + .error).toBeInstanceOf(ZodError) + }) + + it('should return truthy schema', () => { + expect(instanciateSchema(RepoSchema.omit({ id: true }), true)).toMatchObject({ + internalRepoName: true, + externalRepoUrl: true, + externalToken: true, + isPrivate: true, + isInfra: true, + externalUserName: true, + projectId: true, + }) + }) + + it('should return true schema', () => { + expect(instanciateSchema(RepoSchema.omit({ id: true }), true)).toMatchObject({ + internalRepoName: true, + externalRepoUrl: true, + externalToken: true, + isPrivate: true, + isInfra: true, + externalUserName: true, + projectId: true, + }) + }) +}) diff --git a/.jjconflict-base-1/packages/shared/src/utils/schemas.ts b/.jjconflict-base-1/packages/shared/src/utils/schemas.ts new file mode 100644 index 0000000000..7330fa943e --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/utils/schemas.ts @@ -0,0 +1,20 @@ +// import Joi from 'joi' +import type { SafeParseReturnType, ZodError, ZodObject } from 'zod' +import { fromZodError } from 'zod-validation-error' + +export type SharedZodError = ZodError +export type SharedSafeParseReturnType = SafeParseReturnType +export const parseZodError = (zodError: ZodError) => fromZodError(zodError).toString() + +export function instanciateSchema, V extends boolean>(schema: T, value: V): Record { + const keys = schema.keyof()._def.values + // @ts-ignore + if (keys.length) { + // @ts-ignore + const entries = schema.keyof()._def.values?.map(key => [key, value]) + // @ts-ignore + return Object.fromEntries(entries) + } + // @ts-ignore + return {} +} diff --git a/.jjconflict-base-1/packages/shared/src/utils/types.ts b/.jjconflict-base-1/packages/shared/src/utils/types.ts new file mode 100644 index 0000000000..babed97bf2 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/src/utils/types.ts @@ -0,0 +1,21 @@ +export type ErrorTypes = 'info' | 'warning' | 'error' | 'success' + +export interface UserProfile { + email: string + id: string + firstName: string + lastName: string + groups: string[] +} + +export type ResourceById = Record + +export type ResourceByKey = Record + +export type Without = { [P in Exclude]?: never } + +export type XOR + = T extends object + ? U extends object + ? (Without & U) | (Without & T) + : U : T diff --git a/.jjconflict-base-1/packages/shared/tsconfig.eslint.json b/.jjconflict-base-1/packages/shared/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-1/packages/shared/tsconfig.json b/.jjconflict-base-1/packages/shared/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-1/packages/shared/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-1/packages/shared/vitest.config.ts b/.jjconflict-base-1/packages/shared/vitest.config.ts new file mode 100644 index 0000000000..fda276425b --- /dev/null +++ b/.jjconflict-base-1/packages/shared/vitest.config.ts @@ -0,0 +1,16 @@ +import { configDefaults, defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: ['**/types', '**/mocks', '**/*.spec.ts', '**/*.d.ts'], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + }, +}) diff --git a/.jjconflict-base-1/packages/test-utils/.gitignore b/.jjconflict-base-1/packages/test-utils/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-1/packages/test-utils/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-1/packages/test-utils/eslint.config.js b/.jjconflict-base-1/packages/test-utils/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-1/packages/test-utils/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-1/packages/test-utils/package.json b/.jjconflict-base-1/packages/test-utils/package.json new file mode 100644 index 0000000000..10d292744f --- /dev/null +++ b/.jjconflict-base-1/packages/test-utils/package.json @@ -0,0 +1,42 @@ +{ + "name": "@cpn-console/test-utils", + "type": "module", + "version": "1.0.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test:e2e-ci": "echo 'check cache'" + }, + "dependencies": { + "@cpn-console/shared": "workspace:^", + "@faker-js/faker": "catalog:test" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "rimraf": "catalog:tools", + "typescript": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/packages/test-utils/src/imports/data.ts b/.jjconflict-base-1/packages/test-utils/src/imports/data.ts new file mode 100644 index 0000000000..e7bc172877 --- /dev/null +++ b/.jjconflict-base-1/packages/test-utils/src/imports/data.ts @@ -0,0 +1,2305 @@ +export const data = { + personalAccessToken: [], + adminToken: [ + { + id: 'e66a5ce2-440d-4a60-ac0d-d439bbaba040', + name: 'test', + hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', // test + permissions: '2n', + userId: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + }, + { + id: 'e66a5ce2-440d-4a60-ac0d-d439bbaba041', + name: 'test', + hash: '4bb47f186df233e48b09d241ee4defb821add0c35ac8311469fe1522c6813dd5', // revoked + permissions: '2n', + userId: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + status: 'revoked', + }, + ], + adminPlugin: [], + adminRole: [ + { + id: '76229c96-4716-45bc-99da-00498ec9018c', + permissions: '3n', + position: 0, + oidcGroup: '/admin', + name: 'Root Administrateur Plateforme', + type: 'system:external', + }, + { + id: '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', + permissions: '3n', + position: 0, + oidcGroup: '/console/admin', + name: 'Administrateur Plateforme', + type: 'system:managed', + }, + { + id: 'eadf604f-5f54-4744-bdfb-4793d2271e9b', + permissions: '2n', + position: 1, + oidcGroup: '', + name: 'Admin Locaux', + type: 'external', + }, + { + id: '35848aa2-e881-4770-9844-0c5c3693e506', + permissions: '1n', + position: 2, + oidcGroup: '/console/readonly', + name: 'Lecture Seule Plateforme', + type: 'system:managed', + }, + { + id: '034f589f-1750-4b15-bb34-4cd995e7fcaa', + permissions: '25608n', + position: 0, + oidcGroup: '', + name: 'Tout le monde', + type: 'global', + }, + ], + kubeconfig: [ + { + id: '2a88634a-0a60-459c-bf68-c4ffb12430a2', + user: { + keyData: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=', + certData: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + }, + cluster: { + server: 'https://public.server:6443', + skipTLSVerify: true, + tlsServerName: 'public.server', + }, + createdAt: '2024-07-24T16:54:14.961Z', + updatedAt: '2024-07-24T16:54:14.961Z', + }, + { + id: '2a88634a-0a60-459c-bf68-c4ffb12430a3', + user: { + keyData: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=', + certData: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + }, + cluster: { + server: 'https://nowhere.server:6443', + skipTLSVerify: true, + tlsServerName: 'nowhere.server', + }, + createdAt: '2024-07-24T16:54:14.964Z', + updatedAt: '2024-07-24T16:54:14.964Z', + }, + { + id: '0e88f000-07e6-4781-a69d-0963489387f7', + user: { + token: 'nyan cat', + }, + cluster: { + server: 'https://nothere.cluster', + skipTLSVerify: false, + tlsServerName: 'nothere.cluster', + }, + createdAt: '2024-07-24T16:54:14.966Z', + updatedAt: '2024-07-24T16:54:14.966Z', + }, + { + id: 'b5662039-a62b-483e-ba54-b12c6f966c96', + user: { + token: 'kirikou', + }, + cluster: { + server: 'https://pwned.cluster', + tlsServerName: 'pwned.cluster', + }, + createdAt: '2024-07-24T16:54:14.969Z', + updatedAt: '2024-07-24T16:54:14.969Z', + }, + ], + zone: [ + { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce1', + slug: 'pr', + label: 'Zone privée', + argocdUrl: 'https://argocd.private-zone.fr', + description: 'Cette zone de déploiement est privée.', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + }, + { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + label: 'publique', + argocdUrl: 'https://argocd.public-zone.fr', + description: 'Zone de diffusion ouverte.', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + }, + ], + cluster: [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + label: 'unused-cluster', + privacy: 'public', + secretName: '3972ac09-6abc-4e49-83b6-d046da5260ed', + clusterResources: false, + memory: 8, + cpu: 10, + gpu: 0, + kubeConfigId: '2a88634a-0a60-459c-bf68-c4ffb12430a3', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + infos: 'Cluster public non utilisé', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + label: 'top-secret-cluster', + privacy: 'dedicated', + secretName: '59be2d50-58f9-42f3-95dc-b0c0518e3d8a', + clusterResources: true, + memory: 8, + cpu: 10, + gpu: 0, + kubeConfigId: '0e88f000-07e6-4781-a69d-0963489387f7', + createdAt: '2023-07-10T19:49:31.691Z', + updatedAt: '2024-07-24T16:54:15.234Z', + infos: null, + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + label: 'pas-top-cluster', + privacy: 'dedicated', + secretName: '94d52618-7869-4192-b33e-85dd0959e815', + clusterResources: false, + memory: 30, + cpu: 20, + gpu: 0, + kubeConfigId: 'b5662039-a62b-483e-ba54-b12c6f966c96', + createdAt: '2023-07-10T19:49:31.697Z', + updatedAt: '2024-07-24T16:54:15.249Z', + infos: 'Floating IP : 0.0.0.0', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + label: 'public1', + privacy: 'public', + secretName: '3972ac09-6abc-4e49-83b6-d046da5260ec', + clusterResources: false, + memory: 100, + cpu: 100, + gpu: 100, + kubeConfigId: '2a88634a-0a60-459c-bf68-c4ffb12430a2', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2024-07-24T16:54:15.261Z', + infos: 'Cluster public proposé par DSO', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + ], + user: [ + { + id: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + firstName: 'Bot Admin', + lastName: 'test', + email: 'c7712841-c0fd-40ff-abbb-9d914bcc907d@bot.io', + createdAt: '2023-11-16T15:30:01.140Z', + updatedAt: '2023-11-16T15:30:01.140Z', + adminRoleIds: [], + type: 'bot', + lastLogin: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + { + id: '387216f1-3b87-4211-9cac-4371125e1175', + firstName: 'Admin', + lastName: 'ADMIN', + email: 'admin@test.com', + createdAt: '2023-07-03T18:01:52.884Z', + updatedAt: '2023-07-06T12:53:39.183Z', + adminRoleIds: [ + '76229c96-4716-45bc-99da-00498ec9018c', + ], + type: 'human', + }, + { + id: '04ac168a-2c4f-4816-9cce-af6c612e5912', + firstName: 'Anonymous', + lastName: 'User', + email: 'anon@user', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + type: 'ghost', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', + firstName: 'Arnaud', + lastName: 'TARDIF', + email: 'arnaud.tardif@test.com', + createdAt: '2023-07-03T14:46:56.773Z', + updatedAt: '2023-07-03T14:46:56.773Z', + adminRoleIds: [], + type: 'human', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', + email: 'test@test.com', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + type: 'human', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + firstName: 'Thibault', + lastName: 'COLIN', + email: 'thibault.colin@test.com', + createdAt: '2023-07-03T14:46:56.772Z', + updatedAt: '2024-07-25T16:18:11.372Z', + type: 'human', + adminRoleIds: [ + 'eadf604f-5f54-4744-bdfb-4793d2271e9b', + ], + }, + ], + log: [ + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1220', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + environment: 'staging', + registryHost: 'blabla.com', + repositories: [], + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'Create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.809Z', + requestId: null, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1221', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + environment: 'staging', + registryHost: 'blabla.com', + repositories: [], + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.819Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1222', + data: { + argo: { + status: { + result: 'OK', + message: 'Not an infra repository', + }, + }, + args: { + id: 'bd934af0-6de2-41b2-a111-6b0c45b82384', + status: 'initializing', + isInfra: false, + project: 'int-2', + createdAt: '2023-06-08T15:15:56.692Z', + isPrivate: false, + projectId: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + updatedAt: '2023-06-08T15:15:56.692Z', + environment: [], + internalUrl: 'https://blabla.com/bla/projects/mi/int-2/repo.git', + externalRepoUrl: 'https://github.com/dnum-mi/dso-console.git', + externalUserName: '', + internalRepoName: 'repo', + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 1, + }, + gitlab: { + vault: [ + { + data: { + GIT_INPUT_URL: 'github.com/dnum-mi/dso-console.git', + GIT_INPUT_USER: '', + GIT_OUTPUT_URL: 'blabla.com/bla/projects/mi/int-2/repo.git', + GIT_OUTPUT_USER: 'root', + GIT_INPUT_PASSWORD: '', + GIT_OUTPUT_PASSWORD: 'password', + }, + name: 'repo-mirror', + }, + ], + status: { + result: 'OK', + message: 'Created', + }, + }, + }, + action: 'Create Repository', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.819Z', + requestId: null, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1223', + data: { + argo: { + status: { + result: 'OK', + message: 'Not an infra repository', + }, + }, + args: { + id: 'bd934af0-6de2-41b2-a111-6b0c45b82384', + status: 'initializing', + isInfra: false, + project: 'int-2', + createdAt: '2023-06-08T15:15:56.692Z', + isPrivate: false, + projectId: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + updatedAt: '2023-06-08T15:15:56.692Z', + environment: [], + internalUrl: 'https://blabla.com/bla/projects/mi/int-2/repo.git', + externalRepoUrl: 'https://github.com/dnum-mi/dso-console.git', + externalUserName: '', + internalRepoName: 'repo', + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 1, + }, + gitlab: { + vault: [ + { + data: { + GIT_INPUT_URL: 'github.com/dnum-mi/dso-console.git', + GIT_INPUT_USER: '', + GIT_OUTPUT_URL: 'blabla.com/bla/projects/mi/int-2/repo.git', + GIT_OUTPUT_USER: 'root', + GIT_INPUT_PASSWORD: '', + GIT_OUTPUT_PASSWORD: 'password', + }, + name: 'repo-mirror', + }, + ], + status: { + result: 'OK', + message: 'Created', + }, + }, + }, + action: 'Create Repository', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1224', + data: { + args: { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + locked: true, + status: 'initializing', + project: 'int-2', + createdAt: '2023-06-08T15:14:53.517Z', + updatedAt: '2023-06-08T15:14:53.517Z', + description: '', + }, + nexus: { + user: { + roles: [ + 'mi-int-2-ID', + ], + source: 'default', + status: 'active', + userId: 'mi-int-2', + lastName: 'Luffy', + readOnly: false, + firstName: 'Monkey D.', + emailAddress: 'claire.nollet@interieur.gouv.fr', + externalRoles: [], + }, + vault: [ + { + data: { + NEXUS_PASSWORD: 'password', + NEXUS_USERNAME: 'mi-int-2', + }, + name: 'NEXUS', + }, + ], + status: { + result: 'OK', + message: 'User Created', + }, + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 4, + }, + keycloak: { + group: { + id: '4a767b5c-1bf4-43b9-8164-5da76ded49a0', + }, + status: { + result: 'OK', + }, + }, + registry: { + vault: [ + { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + name: 'REGISTRY', + }, + ], + result: { + robot: { + id: 70, + name: 'robot$mi-int-2+ci', + secret: 'token', + expires_at: -1, + creation_time: '2023-06-08T15:14:55.813Z', + }, + project: { + name: 'mi-int-2', + metadata: { + public: 'false', + }, + owner_id: 1, + owner_name: 'admin', + project_id: 63, + repo_count: 0, + update_time: '2023-06-08T15:14:55.363Z', + creation_time: '2023-06-08T15:14:55.363Z', + cve_allowlist: { + id: 65, + items: [], + project_id: 63, + update_time: '0001-01-01T00:00:00.000Z', + creation_time: '0001-01-01T00:00:00.000Z', + }, + current_user_role_id: 1, + current_user_role_ids: [ + 1, + ], + }, + }, + status: { + result: 'OK', + message: 'Created', + }, + }, + sonarqube: { + user: { + user: { + name: 'mi-int-2', + email: 'claire.nollet@interieur.gouv.fr', + local: true, + login: 'mi-int-2', + active: true, + scmAccounts: [], + }, + }, + vault: [ + { + data: { + SONAR_TOKEN: 'token', + SONAR_PASSWORD: 'password', + SONAR_USERNAME: 'mi-int-2', + }, + name: 'SONAR', + }, + ], + result: {}, + status: { + result: 'OK', + message: 'User Created', + }, + }, + }, + action: 'Create Environment', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1225', + data: { + args: { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + locked: false, + status: 'created', + project: 'int-2', + createdAt: '2023-06-08T15:14:53.517Z', + updatedAt: '2023-06-08T15:16:49.011Z', + description: '', + }, + nexus: { + status: { + result: 'OK', + message: 'User deleted', + }, + }, + vault: { + status: { + result: 'OK', + }, + secretsDestroyed: 5, + }, + gitlab: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + keycloak: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + registry: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + sonarqube: { + status: { + result: 'OK', + message: 'User anonymized', + }, + }, + }, + action: 'Delete Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1226', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1227', + data: { + argo: { + status: { + result: 'KO', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1228', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1229', + data: { + argo: { + status: { + result: 'KO', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1230', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1231', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1232', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1233', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1234', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1235', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1236', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1237', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1238', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1239', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1240', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1241', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + ], + project: [ + { + id: '22e7044f-8414-435d-9c4a-2df42a65034b', + name: 'betaapp', + slug: 'betaapp', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.814Z', + updatedAt: '2023-07-03T14:46:56.817Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '9dabf3f9-6c86-4358-8598-65007d78df65', + name: 'projecttoarchive', + slug: 'projecttoarchive', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.824Z', + updatedAt: '2023-07-03T14:46:56.830Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + name: 'candilib', + slug: 'candilib', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application de réservation de places à l\'examen du permis B.', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.778Z', + updatedAt: '2023-07-03T14:46:56.783Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '011e7860-04d7-461f-912d-334c622d38c5', + name: 'basegun', + slug: 'basegun', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application d\'aide à la catégorisation d\'armes à feu.', + status: 'created', + locked: false, + createdAt: '2023-07-10T14:46:56.778Z', + updatedAt: '2023-07-10T14:46:56.783Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + name: 'psijfailed', + slug: 'psijfailed', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application de transmission d\'informations entre agents de la PS et de l\'IJ.', + status: 'failed', + locked: true, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '8bd21636-e8ce-4098-8ed8-16cbd60af340', + name: 'projet dégradé', + slug: 'projet dégradé', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Projet testé en état dégradé', + status: 'warning', + locked: true, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '554d9150-9a07-42c1-8207-1163f2f0addd', + name: 'pissenlit', + slug: 'pissenlit', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + name: 'rolistes', + slug: 'rolistes', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '0n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + ], + stage: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + name: 'dev', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + name: 'staging', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + name: 'integration', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + name: 'prod', + }, + ], + environment: [ + { + id: 'bc06ace5-ddf6-4f00-97fa-872922baf078', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.826Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '95ef0d9b-945e-4af6-851c-4c6685ceff20', + name: 'staging', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.829Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: '8d4503eb-64c7-407e-89db-6ab80865071f', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.855Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '3b0cf6c1-251b-4ec6-926f-b54ce1f82560', + name: 'staging', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.859Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: '1b9f1053-fcf5-4053-a7b2-ff8a2c0c1921', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.787Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '1c654f00-4798-4a80-929f-960ddb37885a', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '1c654f00-4798-4a80-929f-960ddb36774b', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38c5', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '2805a1f5-0ca4-46a4-b3d7-5b649aee4a91', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + createdAt: '2023-07-03T14:46:56.808Z', + updatedAt: '2023-07-03T14:46:56.815Z', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '2805a1f5-0ca4-46a4-b3d7-5b649aee4a92', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + createdAt: '2023-07-03T15:56:56.808Z', + updatedAt: '2023-07-03T15:56:56.815Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + ], + projectClusterHistory: [ + { + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38c5', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + }, + { + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + }, + ], + projectMembers: [ + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f9599', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + roleIds: [], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f95b5', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f9595', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + roleIds: [], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + roleIds: [], + }, + ], + projectPlugin: [], + projectRole: [ + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f95b5', + name: 'bg75', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + permissions: '2n', + position: 0, + }, + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f9595', + name: 'bg78', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + permissions: '1n', + position: 0, + }, + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f9599', + name: 'level up', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + permissions: '0n', + position: 0, + }, + ], + repository: [ + { + id: '53891549-e628-4893-8bd3-92abcb71068a', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + internalRepoName: 'beta-front', + externalRepoUrl: 'https://github.com/dnum-mi/beta-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.825Z', + }, + { + id: '26ad0fee-c4bd-462c-a4f7-0f7a713b56f7', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + internalRepoName: 'beta-back', + externalRepoUrl: 'https://github.com/dnum-mi/beta-back.git', + externalUserName: '', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.825Z', + }, + { + id: '83aa3c2a-cdae-4638-b95f-a02b3f31eace', + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + internalRepoName: 'archive-front', + externalRepoUrl: 'https://github.com/dnum-mi/archive-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.857Z', + }, + { + id: 'ad77aa10-a708-4fce-8d0c-15c1b4704309', + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + internalRepoName: 'archive-back', + externalRepoUrl: 'https://github.com/dnum-mi/archive-back.git', + externalUserName: '', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.854Z', + }, + { + id: '299216bb-2dcc-42b5-ac71-6aa001d2dccf', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/dnum-mi/candilib.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.802Z', + }, + { + id: 'c26df1a3-2e03-420d-9894-cd9ce4f98b60', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-back', + externalRepoUrl: 'https://github.com/dnum-mi/psij-back.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.813Z', + }, + { + id: '78f2b122-13cc-406f-bac8-9f0947a23172', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-front', + externalRepoUrl: 'https://github.com/dnum-mi/psij-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '78f2b122-13cc-406f-bac8-9f0947b23172', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-infra', + externalRepoUrl: 'https://github.com/dnum-mi/psij-infra.git', + externalUserName: 'this-is-a-test', + deployRevision: 'main', + deployPath: 'helm/', + helmValuesFiles: 'values.yaml,values.prod.yaml', + isInfra: true, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '9d3e7791-ee67-4282-be95-613c491c2e75', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + internalRepoName: 'pissenlit', + externalRepoUrl: 'https://github.com/dnum-mi/pissenlit.git', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '9d3e7791-ee67-4282-be95-613c491c2e76', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + internalRepoName: 'warhammer', + externalRepoUrl: 'https://github.com/dnum-mi/warhammer.git', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T16:46:56.809Z', + updatedAt: '2023-07-03T16:46:56.816Z', + }, + ], + systemSetting: [ + { + key: 'maintenance', + value: 'off', + }, + + ], + associations: [ + [ + 'cluster', + [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + projects: [], + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + projects: [ + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + }, + ], + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + projects: [ + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + }, + { + id: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: '9dabf3f9-6c86-4358-8598-65007d78df65', + }, + ], + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + projects: [], + }, + ], + ], + [ + 'cluster', + [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + stages: [], + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + ], + ], + ], + deployment: [], + deploymentSource: [], +} diff --git a/.jjconflict-base-1/packages/test-utils/src/index.ts b/.jjconflict-base-1/packages/test-utils/src/index.ts new file mode 100644 index 0000000000..51d39c09b7 --- /dev/null +++ b/.jjconflict-base-1/packages/test-utils/src/index.ts @@ -0,0 +1,5 @@ +export * from './imports/data.js' +export * from './utils/func-utils.js' +export * from './utils/random-project.js' +export * from './utils/random-utils.js' +export * from './utils/types.js' diff --git a/.jjconflict-base-1/packages/test-utils/src/utils/func-utils.ts b/.jjconflict-base-1/packages/test-utils/src/utils/func-utils.ts new file mode 100644 index 0000000000..da5c99a8b4 --- /dev/null +++ b/.jjconflict-base-1/packages/test-utils/src/utils/func-utils.ts @@ -0,0 +1,6 @@ +import type { User } from '@cpn-console/shared' + +export const repeatFn = (nb: number) => (fn: (optionalParam?: any) => any, optionalParam?: any) => Array.from({ length: nb }).map(() => fn(optionalParam)) + +// transform an array of UserModel in object-like usersStore +export const toUsersStore = (users: Array>): Record> => users.reduce((store, user) => ({ ...store, [user.id]: user }), {}) diff --git a/.jjconflict-base-1/packages/test-utils/src/utils/random-project.ts b/.jjconflict-base-1/packages/test-utils/src/utils/random-project.ts new file mode 100644 index 0000000000..73dcdf40bb --- /dev/null +++ b/.jjconflict-base-1/packages/test-utils/src/utils/random-project.ts @@ -0,0 +1,77 @@ +import type { User } from './types.js' +import { projectRoles } from '@cpn-console/shared' +import { repeatFn } from './func-utils.js' +import { + getRandomCluster, + getRandomEnv, + getRandomPerm, + getRandomProject, + getRandomRepo, + getRandomRole, + getRandomStage, + getRandomUser, + getRandomZone, +} from './random-utils.js' + +const basicStages = ['dev', 'staging', 'integration', 'prod'] + +export function createRandomDbSetup({ nbUsers = 1, nbRepo = 3, envs = basicStages }) { + // Create users + const users: User[] = repeatFn(nbUsers)(getRandomUser) + + // Create project + const project = getRandomProject() + + // Create Roles association table + project.roles = users.map(user => ({ + ...getRandomRole(user.id, project.id), + user, + })) + project.roles[0].role = projectRoles[0] + const ownerId = project.roles.find(role => role.role === 'owner')?.userId + + // @ts-ignore + project.members = project.roles.map(({ userId, user: { id: _, ...user }, role }) => { + return { + userId, + role, + ...user, + } + }) + + // Create zone + const zones = [getRandomZone()] + + // Create cluster + const clusters = [getRandomCluster({ projectIds: [project.id], zoneId: zones[0].id })] + // @ts-ignore + project.clusters = clusters + + // Create stages + const stages = basicStages.map(baseEnvironment => getRandomStage(baseEnvironment)) + stages.forEach((stage) => { + // @ts-ignore + stage.clusters = project.clusters + }) + + // Create repositories + project.repositories = repeatFn(nbRepo)(getRandomRepo, project.id) + + // Create environment + project.environments = envs + .map(env => getRandomEnv(env, project.id, stages[0].id, clusters[0].id)) + + // Create permissions + project.environments.forEach((env) => { + env.permissions = users.map(user => + user.id === ownerId ? getRandomPerm(env.id, user, 2) : getRandomPerm(env.id, user), + ) + }) + + return { + users, + stages, + zones, + project, + } +} diff --git a/.jjconflict-base-1/packages/test-utils/src/utils/random-utils.ts b/.jjconflict-base-1/packages/test-utils/src/utils/random-utils.ts new file mode 100644 index 0000000000..3f78ccf346 --- /dev/null +++ b/.jjconflict-base-1/packages/test-utils/src/utils/random-utils.ts @@ -0,0 +1,158 @@ +import type { AchievedStatus, ClusterDetails, ProjectRoles, Stage, Zone } from '@cpn-console/shared' +import type { Cluster, Environment, Log, Member, Permission, Project, Repository, Role, User } from './types.js' +import { achievedStatus, ClusterPrivacy, logActions, projectRoles } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { repeatFn } from './func-utils.js' + +export function getRandomProjectName() { + return faker.lorem.word() +} + +export function getRandomGitUrl() { + const url = `${faker.internet.url().split('.')[0]}.git` + return url.startsWith('https://') ? url : `https://${url.split('://')[1]}` +} + +export function getRandomProject(): Project & { status: AchievedStatus, members?: Member[] } { + const name = getRandomProjectName() + return { + id: faker.string.uuid(), + name, + slug: `${name}-1`, + description: faker.lorem.sentence(8), + status: faker.helpers.arrayElement(achievedStatus), + locked: false, + updatedAt: (new Date()).toISOString(), + createdAt: (new Date()).toISOString(), + } +} + +export function getRandomZone(): Zone { + return { + id: faker.string.uuid(), + slug: faker.lorem.word({ length: { min: 1, max: 10 } }), + label: faker.lorem.word({ length: { min: 1, max: 50 } }), + argocdUrl: faker.internet.url(), + description: faker.lorem.sentence(8), + } +} + +export function getRandomCluster({ projectIds = repeatFn(2)(faker.string.uuid), stageIds = repeatFn(2)(faker.string.uuid), privacy = faker.helpers.arrayElement(Object.values(ClusterPrivacy)), zoneId = faker.string.uuid() }: +{ projectIds?: string[], stageIds?: string[], privacy?: ClusterPrivacy, zoneId?: string }): ClusterDetails { + return { + id: faker.string.uuid(), + label: faker.lorem.word(), + infos: faker.lorem.sentence(8), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + zoneId, + projectIds: privacy === ClusterPrivacy.DEDICATED ? projectIds : [], + stageIds, + kubeconfig: { + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + }, + privacy, + clusterResources: faker.datatype.boolean(), + } +} + +export function getRandomUser(id = faker.string.uuid()): User { + return { + id, + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + } +} + +export function getRandomRole(userId = faker.string.uuid(), projectId = faker.string.uuid(), role: ProjectRoles = projectRoles[1]): Role { + return { + userId, + role, + projectId, + } +} + +export function getRandomMember(userId = faker.string.uuid(), role: ProjectRoles = projectRoles[1]): Member { + return { + userId, + role, + } +} + +export function getRandomRepo(projectId = faker.string.uuid()): Repository { + const repo: Repository = { + id: faker.string.uuid(), + projectId, + internalRepoName: faker.lorem.word(), + externalRepoUrl: getRandomGitUrl(), + isPrivate: faker.datatype.boolean(), + isInfra: faker.datatype.boolean(), + } + if (repo.isPrivate) { + repo.externalUserName = faker.person.firstName() + repo.externalToken = faker.internet.password({ length: 25 }) + } + + return repo +} + +export function getRandomStage(name: string = faker.lorem.word({ length: { min: 2, max: 20 } }), links?: { + clusterIds?: string[] + clusters?: Cluster[] +}): Stage { + return { + id: faker.string.uuid(), + name, + clusterIds: links?.clusterIds ?? links?.clusters?.map(({ id }) => id) ?? [] as string[], + } +} + +export function getRandomEnv(name = faker.lorem.slug(1), projectId = faker.string.uuid(), stageId = faker.string.uuid(), clusterId = faker.string.uuid()): Environment { + return { + id: faker.string.uuid(), + name, + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + projectId, + stageId, + clusterId, + } +} + +export function getRandomPerm(environmentId = faker.string.uuid(), user = getRandomUser(), level = faker.number.int({ min: 0, max: 2 })): Permission { + return { + id: faker.string.uuid(), + environmentId, + userId: user.id, + level, + user, + } +} + +export function getRandomLog(action = faker.helpers.arrayElement(logActions), userId = faker.string.uuid()): Log { + return { + id: faker.string.uuid(), + action, + userId, + updatedAt: (new Date()).toISOString(), + createdAt: (new Date()).toISOString(), + data: { + args: {}, + failed: faker.datatype.boolean(), + results: {}, + totalExecutionTime: 1, + }, + requestId: null, + } +} diff --git a/.jjconflict-base-1/packages/test-utils/src/utils/types.ts b/.jjconflict-base-1/packages/test-utils/src/utils/types.ts new file mode 100644 index 0000000000..14eaf73bb8 --- /dev/null +++ b/.jjconflict-base-1/packages/test-utils/src/utils/types.ts @@ -0,0 +1,112 @@ +export interface Repository { + id: string + projectId: string + internalRepoName: string + externalRepoUrl: string + isPrivate: boolean + isInfra: boolean + createdAt?: Date + updatedAt?: Date + externalUserName?: string + externalToken?: string +} + +export interface User { + id: string + email: string + firstName: string + lastName: string + updatedAt?: Date + createdAt?: Date +} + +export interface Role { + userId: string + projectId: string + role: string + updatedAt?: Date + createdAt?: Date + user?: User +} + +export interface Member { + userId: User['id'] + role: string +} + +export interface Permission { + id: string + environmentId: string + userId: string + level: number + user: User + updatedAt?: Date + createdAt?: Date +} + +export interface Cluster { + id: string + label: string + infos?: string + projectIds?: string[] + stageIds?: string[] + zoneId?: string + user: { + certData: string + keyData: string + } + cluster: { + caData: string + server: string + tlsServerName: string + } + privacy: string + clusterResources: boolean + updatedAt?: Date + createdAt?: Date +} + +export interface Environment { + id: string + name: string + cpu: number + gpu: number + memory: number + projectId: string + clusterId: string + stageId: string + permissions?: Permission[] + cluster?: Cluster + updatedAt?: Date + createdAt?: Date +} + +export interface Log { + id: string + action: string + userId: string + createdAt: Date | string + updatedAt: Date | string + data: { + args: any + failed: boolean + results: any + totalExecutionTime: number + } + requestId: null +} + +export interface Project { + id: string + name: string + slug: string + description: string + status: string + locked: boolean + roles?: Role[] + clusters?: Cluster[] + repositories?: Repository[] + environments?: Environment[] + updatedAt?: Date | string + createdAt?: Date | string +} diff --git a/.jjconflict-base-1/packages/test-utils/tsconfig.eslint.json b/.jjconflict-base-1/packages/test-utils/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-1/packages/test-utils/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-1/packages/test-utils/tsconfig.json b/.jjconflict-base-1/packages/test-utils/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-1/packages/test-utils/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-1/packages/tsconfig/package.json b/.jjconflict-base-1/packages/tsconfig/package.json new file mode 100644 index 0000000000..f90b7b4aee --- /dev/null +++ b/.jjconflict-base-1/packages/tsconfig/package.json @@ -0,0 +1,16 @@ +{ + "name": "@cpn-console/ts-config", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "version": "1.1.0", + "private": false, + "description": "", + "main": "index.js", + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} \ No newline at end of file diff --git a/.jjconflict-base-1/packages/tsconfig/tsconfig.base.json b/.jjconflict-base-1/packages/tsconfig/tsconfig.base.json new file mode 100644 index 0000000000..6a08a00241 --- /dev/null +++ b/.jjconflict-base-1/packages/tsconfig/tsconfig.base.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + "jsx": "preserve", + "lib": ["ESNext"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + + // Base + "paths": { + "@/*": [ + "src/*" + ] + }, + "resolveJsonModule": true, + + "allowImportingTsExtensions": false, + "allowJs": true, + // Best practices + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + // Some stricter flags + "useUnknownInCatchVariables": false, + "declaration": true, + "emitDeclarationOnly": false, + // Output + "noEmit": false, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + } +} diff --git a/.jjconflict-base-1/pgadmin/servers-docker.json b/.jjconflict-base-1/pgadmin/servers-docker.json new file mode 100644 index 0000000000..d1710b3648 --- /dev/null +++ b/.jjconflict-base-1/pgadmin/servers-docker.json @@ -0,0 +1,22 @@ +{ + "Servers": { + "1": { + "Name": "dso-console-db", + "Group": "Servers", + "Host": "postgres", + "Port": 5432, + "MaintenanceDB": "dso-console-db", + "Username": "admin", + "SSLMode": "prefer", + "SSLCert": "/.postgresql/postgresql.crt", + "SSLKey": "/.postgresql/postgresql.key", + "SSLCompression": 0, + "BGColor": "#EEEDE7", + "FGColor": "#05445E", + "Timeout": 10, + "UseSSHTunnel": 0, + "TunnelPort": "22", + "TunnelAuthentication": 0 + } + } +} diff --git a/.jjconflict-base-1/pgadmin/servers-kube.json b/.jjconflict-base-1/pgadmin/servers-kube.json new file mode 100644 index 0000000000..4f16a15fae --- /dev/null +++ b/.jjconflict-base-1/pgadmin/servers-kube.json @@ -0,0 +1,22 @@ +{ + "Servers": { + "1": { + "Name": "dso-console-db", + "Group": "Servers", + "Host": "dso-cpn-console-db-hl", + "Port": 5432, + "MaintenanceDB": "dso-console-db", + "Username": "admin", + "SSLMode": "prefer", + "SSLCert": "/.postgresql/postgresql.crt", + "SSLKey": "/.postgresql/postgresql.key", + "SSLCompression": 0, + "BGColor": "#EEEDE7", + "FGColor": "#05445E", + "Timeout": 10, + "UseSSHTunnel": 0, + "TunnelPort": "22", + "TunnelAuthentication": 0 + } + } +} diff --git a/.jjconflict-base-1/playwright/README.md b/.jjconflict-base-1/playwright/README.md new file mode 100644 index 0000000000..d47e6602c9 --- /dev/null +++ b/.jjconflict-base-1/playwright/README.md @@ -0,0 +1,78 @@ +# À propos + +Ce dossier contient tout ce qui nous est nécessaire pour lancer les tests automatisés avec Playwright. + +Il peut s'agir de tests E2E, d'une brique en particulier, bref tout ce qui est actionnable à partir de l'interface utilisateur. + +## Installer Playwright + +Tout est déjà prévu dans le `pnpm install` que vous avez normalement déjà exécuté à la racine du MonoRepo. Si ce n'est pas le cas vous pouvez relancer la commande dans ce dossier. + +## Installer les dépendances (navigateurs, etc.) + +Afin de pouvoir lancer les tests sur différents navigateurs, Playwright a besoin d'un certain nombre de binaires. +Pour les installer localement il faut lancer la commande suivante : + +```shell +$ pnpm exec playwright install --with-deps +``` + +Note: Il est possible qu'il soit nécessaire que vous fassiez un `sudo` + +## Lancer les tests + +Il faut d'abord lancer une stack, par ex, celle de Dev: + +```shell +$ pnpm run docker:dev +``` +On peut lancer ensuite les tests : + +```shell +$ pnpm run playwright:test --grep @e2e +``` + +OU (autre possibilité), lancer la "console" Playwright pour parcourir/exécuter les tests manuellement : + +```shell +$ pnpm run playwright:test:ui --grep @e2e +``` + +Et pour les tests d'intégration, il faut au préalable exporter des variables d'environnement : + +```shell +$ export KEYCLOAK_DOMAIN=keycloak.example.com +$ export KEYCLOAK_REDIRECT_URI=https://console.example.com +$ export KEYCLOAK_PROTOCOL=https +$ export KEYCLOAK_ADMIN_USERNAME= +$ export KEYCLOAK_ADMIN_PASSWORD= +$ export KEYCLOAK_REALM=dso +$ export CONSOLE_ADMIN_USERNAME= +$ export CONSOLE_ADMIN_PASSWORD= +$ export CONSOLE_ADMIN_EMAIL= +$ export CONSOLE_TEST_USERNAME= +$ export CONSOLE_TEST_PASSWORD= +$ export CONSOLE_TEST_EMAIL= +$ export CONSOLE_SECOND_TEST_USERNAME= +$ export CONSOLE_SECOND_TEST_PASSWORD= +$ export CONSOLE_SECOND_TEST_EMAIL= +$ export CONSOLE_GLOBAL_TIMEOUT='900000' +$ export CONSOLE_EXPECT_TIMEOUT='900000' +$ export CONSOLE_DESTINATION_CLUSTER= +$ export CONSOLE_VALUES_FILE= +``` + +On peut lancer ensuite les tests : + +```shell +$ pnpm run playwright:test:integration --grep @integ +``` + +## Conventions de code + +Afin d'assurer la lisibilité et la maintenabilité des tests Playwright, quelques décisions ont été prises concernant l'écriture du code : +- Pas d'imbrication de if : les conditions sont autorisées dans les fonctions, mais ne doivent pas être imbriquées. +- Arguments explicites : certains arguments initialement optionnels doivent être rendus obligatoires afin de rendre le comportement de la fonction plus clair (par exemple pour les zones publiques, ou les contextes spécifiques). +- Visibilité des tests : il doit être évident, à la lecture du test, de voir ce qui est réellement vérifié. Par conséquent, évitez de masquer les expect à l'intérieur de fonctions utilitaires. Les assertions doivent apparaître directement dans le corps du test lorsqu'elles participent à la vérification d'un comportement. + +Ces conventions ont pour objectif de rendre les tests plus explicites, plus faciles à relire, et de réduire les effets de bord cachés. diff --git a/.jjconflict-base-1/playwright/config/console.ts b/.jjconflict-base-1/playwright/config/console.ts new file mode 100644 index 0000000000..35ea8a13c9 --- /dev/null +++ b/.jjconflict-base-1/playwright/config/console.ts @@ -0,0 +1,83 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +// Retrieve frontend URL from environment variables (see playwright.config.ts) +export const clientURL + = process.env.KEYCLOAK_REDIRECT_URI || 'http://change-me' + +export interface Credentials { + id: string + username: string + password: string + firstName: string + lastName: string + email: string +} + +// Users referenced in Keycloak dev realm (../keycloak/realms/realm-dev.json) +export const adminUser: Credentials = { + id: '387216f1-3b87-4211-9cac-4371125e1175', + username: process.env.CONSOLE_ADMIN_USERNAME?.trim() || 'admin', + password: process.env.CONSOLE_ADMIN_PASSWORD?.trim() || 'admin', + firstName: 'Admin', + lastName: 'ADMIN', + email: process.env.CONSOLE_ADMIN_EMAIL?.trim() || 'admin@test.com', +} +export const testUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + username: process.env.CONSOLE_TEST_USERNAME?.trim() || 'test', + password: process.env.CONSOLE_TEST_PASSWORD?.trim() || 'test', + firstName: 'Jean', + lastName: 'DUPOND', + email: process.env.CONSOLE_TEST_EMAIL?.trim() || 'test@test.com', +} +export const cnolletUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + username: 'cnollet', + password: 'test', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', +} +export const tcolinUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + username: 'tcolin', + password: 'test', + firstName: 'Thibault', + lastName: 'COLIN', + email: 'thibault.colin@test.com', +} + +// User for integration test +export const secondTestUser: Credentials = { + id: 'test', + username: process.env.CONSOLE_SECOND_TEST_USERNAME?.trim() || 'secondtestuser', + password: process.env.CONSOLE_SECOND_TEST_PASSWORD?.trim() || 'secondtestuser', + firstName: 'test', + lastName: 'test', + email: process.env.CONSOLE_SECOND_TEST_EMAIL?.trim() || 'secondtestuser@example.com', +} + +export const currentUser: Credentials = { + id: 'current', + username: process.env.CONSOLE_CURRENT_TEST_USERNAME?.trim() || 'currentuser', + password: process.env.CONSOLE_CURRENT_TEST_PASSWORD?.trim() || 'currentuser', + firstName: 'test', + lastName: 'test', + email: process.env.CONSOLE_CURRENT_TEST_EMAIL?.trim() || 'currentuser@example.com', +} + +export async function signInCloudPiNative({ + page, + credentials, +}: { + page: Page + credentials: Credentials +}) { + const { username, password } = credentials + await page.getByRole('link', { name: 'Se connecter' }).click() + await page.getByRole('textbox', { name: 'Username or email' }).fill(username) + await page.getByRole('textbox', { name: 'Password' }).fill(password) + await page.getByRole('button', { name: 'Sign In' }).click() + await expect(page.locator('#top')).toContainText('Cloud π Native') +} diff --git a/.jjconflict-base-1/playwright/config/keycloak.ts b/.jjconflict-base-1/playwright/config/keycloak.ts new file mode 100644 index 0000000000..e93138829a --- /dev/null +++ b/.jjconflict-base-1/playwright/config/keycloak.ts @@ -0,0 +1,38 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +export interface KeycloakConfig { + url: string + realm: string + adminUser: string + adminPass: string + clientFrontend: string + clientBackend: string +} + +export function loadKeycloakConfig(): KeycloakConfig { + const protocol = process.env.KEYCLOAK_PROTOCOL || 'http' + const domain = process.env.KEYCLOAK_DOMAIN || 'localhost' + const port = process.env.KEYCLOAK_PORT ? `:${process.env.KEYCLOAK_PORT}` : '' + const url = `${protocol}://${domain}${port}` + + return { + url, + realm: process.env.KEYCLOAK_REALM?.trim() || 'dso', + adminUser: process.env.KEYCLOAK_ADMIN_USERNAME?.trim() || 'admin', + adminPass: process.env.KEYCLOAK_ADMIN_PASSWORD?.trim() || 'admin', + clientFrontend: process.env.KEYCLOAK_CLIENT_FRONTEND || 'dso-console-frontend', + clientBackend: process.env.KEYCLOAK_CLIENT_BACKEND || 'dso-console-backend', + } +} + +export async function signInKeycloak( + page: Page, +) { + const keycloakConfig = loadKeycloakConfig() + await page.goto(keycloakConfig.url) + await page.locator('#username').fill(keycloakConfig.adminUser) + await page.locator('#password').fill(keycloakConfig.adminPass) + await page.locator('#kc-login').click() + await expect(page.getByRole('link', { name: 'Logo' })).toBeVisible() +} diff --git a/.jjconflict-base-1/playwright/e2e-tests/admin-logs.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/admin-logs.spec.ts new file mode 100644 index 0000000000..c368f1d939 --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/admin-logs.spec.ts @@ -0,0 +1,75 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +const adminLogsUrlRe = /\/admin\/logs/ +const logCountRe = /Total\s*:\s*(\d+)/ + +async function goToAdminLogs(page: Page) { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationLogs').click() + await expect(page).toHaveURL(adminLogsUrlRe) + await expect(page.getByTestId('logCountInfo')).toBeVisible() +} + +test.describe('Administration logs', { tag: '@e2e' }, () => { + test('Should display logs list, logged in as admin', async ({ page }) => { + await goToAdminLogs(page) + + const logCountText = await page.getByTestId('logCountInfo').textContent() + expect(logCountText).toBeTruthy() + const total = Number((logCountText || '').match(logCountRe)?.[1] || 0) + + const positionInfo = page.getByTestId('positionInfo').first() + const seePreviousPageBtn = page.getByTestId('seePreviousPageBtn').first() + const seeFirstPageBtn = page.getByTestId('seeFirstPageBtn').first() + const seeNextPageBtn = page.getByTestId('seeNextPageBtn').first() + const seeLastPageBtn = page.getByTestId('seeLastPageBtn').first() + + await expect(positionInfo).toContainText('sur') + await expect(seePreviousPageBtn).toBeDisabled() + await expect(seeFirstPageBtn).toBeDisabled() + + const logs = page.locator('[data-testid$="-json"]') + await expect(logs).toHaveCount(Math.min(10, total)) + + if (total > 10) { + await expect(seeNextPageBtn).toBeEnabled() + await expect(seeLastPageBtn).toBeEnabled() + + const firstLogText = await logs.first().textContent() + await seeNextPageBtn.click() + + await expect(seePreviousPageBtn).toBeEnabled() + await expect(seeFirstPageBtn).toBeEnabled() + await expect(positionInfo).toContainText('sur') + await expect(logs.first()).not.toHaveText(firstLogText || '') + + await seeFirstPageBtn.click() + await expect(seePreviousPageBtn).toBeDisabled() + await expect(seeFirstPageBtn).toBeDisabled() + } else { + await expect(seeNextPageBtn).toBeDisabled() + await expect(seeLastPageBtn).toBeDisabled() + } + }) + + test('Should toggle compact logs display, logged in as admin', async ({ page }) => { + await goToAdminLogs(page) + + await expect(page.locator('h1')).toContainText( + 'Journaux des services associés à la chaîne DSO', + ) + + const jsonBoxes = page.locator('.json-box') + await expect(jsonBoxes.first()).toBeVisible() + + await page.getByTestId('showLogsBtn').click() + await expect(jsonBoxes).toHaveCount(0) + + await page.getByTestId('showLogsBtn').click() + await expect(jsonBoxes.first()).toBeVisible() + }) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/admin-stages.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/admin-stages.spec.ts new file mode 100644 index 0000000000..0964f9f100 --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/admin-stages.spec.ts @@ -0,0 +1,131 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative, testUser } from '../config/console' + +import { createProject } from '../helpers/project' +import { createStage, deleteStage } from '../helpers/stage' + +test.describe('Stages administration page', { tag: '@e2e' }, () => { + test('should display default stages list', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + for (const stageName of ['dev', 'integration', 'prod', 'staging']) { + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + } + }) + + test('should create a custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'all' }) + // Validation de la création + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Check stage availability in environment form + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = faker.string.alpha(10).toLowerCase() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page.getByLabel('Choix de la zone cible').selectOption({ label: 'publique' }) + await page.getByLabel('Type d\'environnement').selectOption({ label: stageName }) + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page.getByLabel('Choix du cluster cible').selectOption({ label: 'public1' }) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + }) + + test('should update a custom stage', async ({ page }) => { + // Create stage (1 cluster) + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'first' }) + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + // Update stage (adding 1 extra cluster) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.locator('h1')).toContainText(`Informations du type d\'environnement ${stageName}`) + await expect(page.getByTestId('addStageBtn')).not.toBeVisible() + await expect(page.getByTestId('updateStageBtn')).toBeVisible() + await expect(page.getByTestId('updateStageBtn')).toBeEnabled() + await expect(page.getByTestId('nameInput')).toBeDisabled() + await expect(page.getByTestId('nameInput')).toHaveValue(stageName) + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(1) + await page.locator('.fr-tag').first().click() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(2) + await page.getByTestId('updateStageBtn').click() + // Check stage (2 clusters) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.locator('h1')).toContainText(`Informations du type d\'environnement ${stageName}`) + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(2) + }) + + test('should not be able to create a stage with an existing name', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + // Try to create a stage "dev" + await expect(page.getByTestId('addStageLink')).toBeVisible() + await page.getByTestId('addStageLink').click() + await page.getByTestId('nameInput').fill('dev') + await page.getByTestId('addStageBtn').click() + await expect(page.getByTestId('snackbar').getByText('Un type d\'environnement portant ce nom existe déjà')).toBeVisible() + }) + + test('should delete a custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'first' }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Delete custom stage + await deleteStage({ page, stageName }) + await expect(page.getByTestId(`stageTile-${stageName}`)).not.toBeVisible() + }) + + test('should not delete a used custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'all' }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Use custom stage in an environment + await createProject({ page }) + const envName = faker.string.alpha(10).toLowerCase() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page.getByLabel('Choix de la zone cible').selectOption({ label: 'publique' }) + await page.getByLabel('Type d\'environnement').selectOption({ label: stageName }) + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page.getByLabel('Choix du cluster cible').selectOption({ label: 'public1' }) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Check custom stage in admin view + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.getByTestId('deleteStageZone')).not.toBeVisible() + await expect(page.getByTestId('associatedEnvironmentsZone')).toBeVisible() + await expect(page.getByTestId('associatedEnvironmentsZone')).toContainText('Le type d\'environnement ne peut être supprimé') + await expect(page.getByTestId('associatedEnvironmentsTable')).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable').getByText(envName), + ).toBeVisible() + }) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/admin-users.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/admin-users.spec.ts new file mode 100644 index 0000000000..82e3a15cb4 --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/admin-users.spec.ts @@ -0,0 +1,71 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { setCheckbox, unsetCheckbox } from 'helpers/checkbox' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +interface ApiUser { + id: string + email: string + firstName: string + lastName: string + type?: string +} + +const adminUsersUrlRe = /\/admin\/users/ +const listUsersUrlRe = /\/api\/v1\/users(?:\?|$)/ + +async function goToAdminUsers(page: Page) { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationUsers').click() + await expect(page).toHaveURL(adminUsersUrlRe) + await expect(page.getByTestId('tableAdministrationUsers')).toBeVisible() +} + +test.describe('Administration users', { tag: '@e2e' }, () => { + test('Should display admin users, logged in as admin', async ({ page }) => { + const usersResponse = page.waitForResponse((res) => { + return ( + res.request().method() === 'GET' + && listUsersUrlRe.test(res.url()) + && res.status() >= 200 + && res.status() < 300 + ) + }) + + await goToAdminUsers(page) + + const users = (await (await usersResponse).json()) as ApiUser[] + expect(users.length).toBeGreaterThan(0) + + for (const user of users) { + const row = page.getByTestId(`user-${user.id}`) + await expect(row).toContainText(user.email) + await expect(row).toContainText(user.lastName) + await expect(row).toContainText(user.firstName) + await expect(row).toContainText('202') + } + + await setCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersDisplayId')) + for (const user of users) { + await expect(page.getByTestId(`user-${user.id}`)).toContainText(user.id) + } + + const botUser = users.find(u => u.email.includes('anon@user')) ?? users.find(u => u.type && u.type !== 'human') + if (botUser) { + await expect(page.getByTestId(`user-${botUser.id}`)).toBeVisible() + await setCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersHideBots')) + await expect(page.getByTestId(`user-${botUser.id}`)).toHaveCount(0) + await unsetCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersHideBots')) + } + + const rows = page.getByTestId('tableAdministrationUsers').locator('tbody tr') + expect(await rows.count()).toBeGreaterThanOrEqual(users.length) + + const search = page.getByTestId('tableAdministrationUsersSearch') + await expect(search).toBeVisible() + await search.fill(botUser?.email ?? users[0].email) + await expect(page.getByTestId('tableAdministrationUsers').locator('tbody tr')).toHaveCount(1) + }) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/clusters.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/clusters.spec.ts new file mode 100644 index 0000000000..e7917af0d2 --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/clusters.spec.ts @@ -0,0 +1,235 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' +import { createCluster, deleteCluster } from '../helpers/cluster' +import { createEnvironment } from '../helpers/environment' +import { openClustersAdministration } from '../helpers/navigation' +import { createProject } from '../helpers/project' + +test.describe('Clusters page', () => { + test('should create a public cluster', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'first', + }) + // Validate + await expect(page.getByTestId(`clusterZone-${clusterName}`)).toContainText( + 'publique', + ) + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Public', + ) + }) + + test('should update a public cluster', { tag: '@e2e' }, async ({ page }) => { + const clusterName2 = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'public', + }) + // Update + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('labelInput')).toHaveValue(clusterName) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await page.getByTestId('labelInput').fill(clusterName2) + await expect(page.getByTestId('updateClusterBtn')).toBeVisible() + await expect(page.locator('#projects-select')).not.toBeVisible() + await expect(page.locator('#privacy-select option[selected]')).toHaveText( + 'public', + ) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('1') + await page.getByTestId('updateClusterBtn').click() + // Validate + await expect(page.getByTestId(`clusterResources-${clusterName2}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + }) + + test( + 'should create a dedicated cluster', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Validate + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Dédié', + ) + }, + ) + + test( + 'should associate a project to a dedicated cluster', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a dedicated project + const { name: projectName } = await createProject({ page }) + // Create the cluster + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Validate + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('updateClusterBtn')).toBeVisible() + await expect(page.locator('#projects-select')).toBeVisible() + await expect(page.locator('#privacy-select option[selected]')).toHaveText( + 'dedicated', + ) + // Associating specifically created project + await page.locator('#projects-select').click() + await expect( + page.locator('#projects-select .fr-tag--dismiss'), + ).toHaveCount(0) + await page + .getByTestId('choice-selector-search-projects-select') + .fill(projectName) + await page.locator('#projects-select .fr-tag').click() + await expect( + page.locator('#projects-select .fr-tag--dismiss'), + ).toHaveCount(1) + await page.getByTestId('updateClusterBtn').click() + // Validate + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Dédié', + ) + }, + ) + + test('should create a cluster even if given informations is longer than 200 chars but shorter than 1001 chars', async ({ + page, + }) => { + const informations = faker.string.alpha(1000).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + informations, + }) + // Validate + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('infosInput')).toHaveValue(informations) + }) + + test('should NOT create a cluster when given informations is longer than 1000 chars', async ({ + page, + }) => { + const clusterName = faker.string.alpha(10).toLowerCase() + const informations = faker.string.alpha(1001).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationClusters').click() + await page.getByTestId('addClusterLink').click() + await page.getByTestId('labelInput').fill(clusterName) + await page.getByTestId('infosInput').fill(informations) + await expect(page.getByTestId('addClusterBtn')).toBeDisabled() + }) + + test('should delete a cluster', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Delete + await deleteCluster({ page, clusterName }) + // Validate + await page.getByTestId('projectsSearchInput').fill(clusterName) + await expect(page.getByTestId('noClusterMsg')).toBeVisible() + await expect(page.getByTestId('noClusterMsg')).toHaveText( + 'Aucun cluster trouvé', + ) + }) + + test( + 'should NOT delete a cluster with associated environment', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a public cluster + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'all', + }) + // Create a project and an environment using this cluster + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: clusterName, + cpuInput: '2', + gpuInput: '0', + }) + // Try to delete the cluster + await expect(page.getByText('Opération en cours...')).toHaveCount(0) + await openClustersAdministration({ page }) + await page.getByTestId('projectsSearchInput').fill(clusterName) + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('deleteClusterZone')).not.toBeVisible() + await expect( + page.getByText('Le cluster ne peut être supprimé'), + ).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable'), + ).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable').getByText(envName), + ).toBeVisible() + }, + ) + + test('should NOT update the cluster label if it has associated environment', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a public cluster + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'all', + }) + // Create a project and an environment using this cluster + await createProject({ page }) + await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: clusterName, + cpuInput: '2', + gpuInput: '0', + }) + // Verify that cluster label is disabled + await expect(page.getByText('Opération en cours...')).toHaveCount(0) + await openClustersAdministration({ page }) + await page.getByTestId('projectsSearchInput').fill(clusterName) + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('labelInput')).toHaveValue(clusterName) + await expect(page.getByTestId('labelInput')).toBeDisabled() + }) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/dashboard.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/dashboard.spec.ts new file mode 100644 index 0000000000..cb72d47d0d --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/dashboard.spec.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + signInCloudPiNative, + testUser, +} from '../config/console' +import { createProject, projectSlugTextRegexp } from '../helpers/project' + +test.describe('Dashboard page', () => { + test( + 'Should display a project statuses', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { + name: projectName, + id: projectId, + slug: projectSlug, + } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + expect(await page.getByTestId('project-id').getAttribute('title')).toBe( + projectId, + ) + await expect(page.locator('h1')).toContainText(projectName) + }, + ) + + test( + 'Should add, display and edit description', + { tag: '@e2e' }, + async ({ page, browserName }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const description1 = 'Application de prise de rendez-vous en préfécture.' + const description2 + = 'Application d\'organisation de tournois de pétanque interministériels.' + + // Act 1 + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('setDescriptionBtn').click() + // Yep, we need that for now... + // @TODO Ensure setDescriptionBtn is properly chained to descriptionInput + if (browserName === 'chromium') { + await page.waitForTimeout(1000) + } + await page.getByTestId('descriptionInput').fill(description1) + await page.getByTestId('saveDescriptionBtn').click() + // Assert 1 + await expect(page.getByTestId('descriptionP')).toHaveText(description1) + + // Act 2 + await page.getByTestId('setDescriptionBtn').click() + if (browserName === 'chromium') { + await page.waitForTimeout(1000) + } + await page.getByTestId('descriptionInput').fill(description2) + await page.getByTestId('saveDescriptionBtn').click() + // Assert 2 + await expect(page.getByTestId('descriptionP')).toHaveText(description2) + }, + ) + + test('Should show project secrets', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await expect(page.getByTestId('projectSecretsZone')).not.toBeVisible() + await page.getByTestId('showSecretsBtn').click() + + // Assert + await expect(page.getByTestId('projectSecretsZone')).toBeVisible() + await expect(page.getByTestId('noProjectSecretsP')).toHaveText( + 'Aucun secret à afficher', + ) + }) + + test('Should replay hooks for project', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('snackbar')).toContainText( + 'Le projet a été reprovisionné avec succès', + ) + }) + + test( + 'Should not be able to access project secrets if not owner', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName, slug: projectSlug } = await createProject({ + page, + }) + + // Act + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('projectSecretsZone')).not.toBeVisible() + await expect(page.getByTestId('showSecretsBtn')).not.toBeVisible() + }, + ) + + // @TODO: This test's expectation was reversed in Cypress E2E tests ("you should not be able + // to archive someone else's project"), but the reality is that you actually can do that, + // and Playwright detects it as well. So, for the time being, and in order to make the test + // pass, we reversed this test's expectation: You indeed can (and therefore should be able to) + // archive someone else's project ! + test( + 'Should be able to archive a project if not owner', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName, slug: projectSlug } = await createProject({ + page, + }) + + // Act + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('showArchiveProjectBtn')).toBeVisible() + }, + ) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/deployment.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/deployment.spec.ts new file mode 100644 index 0000000000..77df873337 --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/deployment.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, testUser } from 'config/console' +import { createDeployment } from 'helpers/deployment' +import { createEnvironment } from 'helpers/environment' +import { createProject, deleteProject } from 'helpers/project' +import { createRepository } from 'helpers/repository' + +test.describe('Déploiement', { tag: '@e2e' }, () => { + let projectName: string + + test.beforeEach(async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name } = await createProject({ page }) + projectName = name + }) + + // Not working on CI (need to mock plugins....) + test.skip('should not be able to create a deployment without an environment or a repository', async ({ page }) => { + await page.getByRole('button', { name: 'Ajouter un nouveau déploiement' }).click() + await expect(page.getByText('Pour créer un déploiement, vous devez d\'abord créer un environnement et un dépôt.')).toBeVisible() + }) + + // Not working on CI (need to mock plugins....) + test.skip('should create a deployment', async ({ page }) => { + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + cpuInput: '2', + gpuInput: '0', + }) + const repoName = await createRepository({ page }) + + const deploymentName = await createDeployment({ page, envName, repoName, customStageName: 'dev' }) + await expect(page.getByText(deploymentName)).toBeVisible() + }) + + test.afterEach(async ({ page }) => { + if (!projectName) return + await deleteProject({ page, projectName }) + }) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/environments.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/environments.spec.ts new file mode 100644 index 0000000000..5035f890d1 --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/environments.spec.ts @@ -0,0 +1,335 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + cnolletUser, + signInCloudPiNative, + testUser, +} from '../config/console' + +import { unsetCheckbox } from '../helpers/checkbox' +import { createEnvironment, deleteEnvironment } from '../helpers/environment' +import { createProject } from '../helpers/project' + +test.describe('Environments page', { tag: '@e2e' }, () => { + test('should add environments to an existing project', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + cpuInput: '2', + gpuInput: '0', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + }) + + test('should not add environments to a project without enough hprod GPU', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ + page, + hprodResources: { + cpu: 10, + memory: 10, + gpu: 1, + }, + }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + memoryInput: '2', + gpuInput: '2', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).not.toBeVisible() + await expect( + page + .getByTestId('snackbar') + .getByText( + 'Le projet ne dispose pas de suffisamment de ressources : GPU.', + ), + ).toBeVisible() + }) + + test('should not add incorrect environments', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + + // Incorrect input + await page.getByTestId('environmentNameInput').click() + await page.getByTestId('environmentNameInput').fill(`${envName}-yolo`) + await expect( + page.getByRole('alert').getByText('Le nom de l\'environnment ne doit pas'), + ).toBeVisible() + + // Valid input + await page.getByTestId('cancelEnvironmentBtn').click() + await createEnvironment({ + page, + envName, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + // Second try with same name (invalid) + await createEnvironment({ + page, + envName, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect( + page + .getByTestId('snackbar') + .getByText('Ce nom d\'environnement est déjà pris'), + ).toBeVisible() + }) + + test('should alert cluster unavailability', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'Zone privée' }) + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: 'dev' }) + await expect(page.getByTestId('noClusterOptionAlert')).toBeVisible() + await expect(page.getByLabel('Choix du cluster cible')).not.toBeVisible() + + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'publique' }) + await expect(page.getByTestId('noClusterOptionAlert')).not.toBeVisible() + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + }) + + test('should display zone infos and cluster infos', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'publique' }) + await expect(page.getByTestId('chosenZoneDescription')).toBeVisible() + + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: 'dev' }) + await page + .getByLabel('Choix du cluster cible') + .selectOption({ label: 'public1' }) + await expect(page.getByTestId('clusterInfos')).toBeVisible() + }) + + test('should update environment resources', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toBeVisible() + await expect(page.locator('#zone-select option[selected]')).toHaveText( + 'publique', + ) + await expect(page.locator('#stage-select option[selected]')).toHaveText( + 'dev', + ) + await expect(page.locator('#cluster-select option[selected]')).toHaveText( + 'public1', + ) + await expect(page.getByTestId('memoryInput')).toHaveValue('1') + await expect(page.getByTestId('cpuInput')).toHaveValue('1') + await expect(page.getByTestId('gpuInput')).toHaveValue('1') + await page.getByTestId('memoryInput').fill('1.5') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('10') + await page.getByTestId('putEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1.5GiB 2CPU 10GPU', + ) + }) + + test('should not update environment resources when cluster is too small', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toBeVisible() + await expect(page.locator('#zone-select option[selected]')).toHaveText( + 'publique', + ) + await expect(page.locator('#stage-select option[selected]')).toHaveText( + 'dev', + ) + await expect(page.locator('#cluster-select option[selected]')).toHaveText( + 'public1', + ) + await expect(page.getByTestId('memoryInput')).toHaveValue('1') + await expect(page.getByTestId('cpuInput')).toHaveValue('1') + await expect(page.getByTestId('gpuInput')).toHaveValue('1') + await page.getByTestId('memoryInput').fill('120') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('1') + await page.getByTestId('putEnvironmentBtn').click() + await expect( + page + .getByTestId('snackbar') + .getByText( + 'Le cluster ne dispose pas de suffisamment de ressources : Mémoire.', + ), + ).toBeVisible() + }) + + test('should delete an environment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + await deleteEnvironment({ page, projectName, envName }) + }) + + test('should not be able to delete an environment if not owner', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ + page, + members: [cnolletUser], + }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Sign off and login as another user (project member) + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: cnolletUser }) + // Select previously created project + await page.getByTestId('menuMyProjects').click() + await expect(page.getByTestId('createProjectLink')).toBeVisible() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + // Verify absence of delete button + await page.getByTestId(`environmentTr-${envName}`).click() + await expect( + page.getByTestId('showDeleteEnvironmentBtn'), + ).not.toBeVisible() + }) + + test('should not be able to delete an environment if project locked', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName } = await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Sign off and login as admin to lock the project + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await page.getByTestId('handleProjectLockingBtn').click() + + // Sign off and login back as user + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: testUser }) + // Verify absence of delete button + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId(`environmentTr-${envName}`).click() + await expect( + page.getByTestId('showDeleteEnvironmentBtn'), + ).not.toBeVisible() + }) + + test('should show a warning if autosync is deactivated', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Verify warning message + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('input-checkbox-autosyncCbx')).toBeVisible() + await expect(page.getByTestId('input-checkbox-autosyncCbx')).toBeChecked() + await expect(page.getByTestId('noAutosyncAlert')).not.toBeVisible() + + // Act - Uncheck auto-sync to trigger warning message + await unsetCheckbox(page.getByTestId('input-checkbox-autosyncCbx')) + await expect(page.getByTestId('noAutosyncAlert')).toBeVisible() + await expect(page.getByTestId('noAutosyncAlert')) + .toHaveText('La synchronisation automatique est désactivée. Les déploiements devront être synchronisés manuellement.') + }) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/keycloak.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/keycloak.spec.ts new file mode 100644 index 0000000000..17c87fb6d4 --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/keycloak.spec.ts @@ -0,0 +1,185 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { loadKeycloakConfig, signInKeycloak } from '../config/keycloak' + +const keycloakConfig = loadKeycloakConfig() +const usersToDelete: string[] = [] +const groupsToDelete: string[] = [] + +export async function createUser(page: Page, usersToDelete: string[]) { + await page.getByRole('link', { name: 'Users' }).click() + await page.getByTestId('add-user').click() + const username = faker.internet.username() + usersToDelete.push(username) + await page + .getByRole('textbox', { name: 'Username' }) + .fill(username) + await page.getByTestId('email').fill(faker.internet.email()) + await page.getByTestId('user-creation-save').click() + await expect(page.getByLabel('The user has been created')).toBeVisible() + return username +} + +export async function createGroup(page: Page, groupsToDelete: string[]) { + await page.getByRole('link', { name: 'Groups' }).click() + await page.getByRole('button', { name: 'Create group' }).click() + const groupName = faker.string.alpha(10) + groupsToDelete.push(groupName) + await page.getByTestId('name').fill(groupName) + await page.getByTestId('createGroup').click() + await expect(page.getByLabel('Group created')).toBeVisible() + return groupName +} + +test.describe('Keycloak', () => { + test.beforeEach(async ({ page }) => { + await signInKeycloak(page) + }) + + test('should sign in to master realm', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + // expect also exists in sign in fuction but we check it explicitely here + // for documentation purposes + await expect(page.getByRole('link', { name: 'Logo' })).toBeVisible() + await page.getByTestId('realmSelector').click() + await expect(page.getByRole('menuitem', { name: keycloakConfig.realm })).toBeVisible() + }) + + test('should have a CπN realm', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByTestId('realmSelector').click() + await expect( + page.getByRole('menuitem', { name: keycloakConfig.realm }), + ).toBeVisible() + }) + + test.describe('CπN realm', () => { + test.beforeEach(async ({ page }) => { + await page.getByTestId('realmSelector').click() + await page.getByRole('menuitem', { name: keycloakConfig.realm }).click() + }) + + test('should have required Clients', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByTestId('realmSelector').click() + await page.getByRole('menuitem', { name: keycloakConfig.realm }).click() + await page.getByRole('link', { name: 'Clients' }).click() + await expect(page.getByRole('link', { name: keycloakConfig.clientFrontend })).toBeVisible() + await expect(page.getByRole('link', { name: keycloakConfig.clientBackend })).toBeVisible() + await page.getByRole('link', { name: 'Client scopes' }).click() + await expect(page.getByRole('link', { name: 'generic' })).toBeVisible() + }) + + test('should have required Clients scopes', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByRole('link', { name: 'Client scopes' }).click() + await expect(page.getByRole('link', { name: 'generic' })).toBeVisible() + }) + + test('should create a user', { tag: '@e2e' }, async ({ page }) => { + await createUser(page, usersToDelete) + }) + + test('should create a group', { tag: '@e2e' }, async ({ page }) => { + await createGroup(page, groupsToDelete) + }) + + test('should link a user to a group', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + const username = await createUser(page, usersToDelete) + const groupName = await createGroup(page, groupsToDelete) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .fill(groupName) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page.getByRole('button', { name: groupName }).click() + await page.getByTestId('members').click() + await page.getByRole('button', { name: 'Add member' }).click() + await page + .getByTestId('titleUsersinput') + .getByRole('textbox', { name: 'Search' }) + .fill(username) + await page + .getByTestId('titleUsersinput') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page.getByRole('checkbox', { name: 'Select row 0' }).check() + await page.getByTestId('add').click() + await expect(page.getByText('user added to the group')).toBeVisible() + }) + + test('should delete a group', { tag: '@e2e' }, async ({ page }) => { + const groupName = await createGroup(page, groupsToDelete) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .fill(groupName) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page + .getByRole('treeitem', { name: `${groupName} Actions` }) + .getByLabel('Actions') + .click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByTestId('confirm').click() + }) + + test('should delete a user', { tag: '@e2e' }, async ({ page }) => { + await createUser(page, usersToDelete) + await page.getByTestId('action-dropdown').click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByTestId('confirm').click() + }) + + test('cleanup Keycloak test data', { tag: '@integ' }, async ({ request }) => { + const params = new URLSearchParams() + params.append('grant_type', 'password') + params.append('client_id', 'admin-cli') + params.append('username', keycloakConfig.adminUser) + params.append('password', keycloakConfig.adminPass) + + const tokenRes = await request.post( + `${keycloakConfig.url}/realms/master/protocol/openid-connect/token`, + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: params.toString(), + }, + ) + + if (!tokenRes.ok) { + throw new Error(`Failed to get token: ${tokenRes.statusText}`) + } + + const tokenData = await tokenRes.json() + const authHeader = { Authorization: `Bearer ${tokenData.access_token}` } + + for (const username of usersToDelete) { + const usersRes = await request.get( + `${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users?username=${username}`, + { headers: authHeader }, + ) + const users = await usersRes.json() + for (const user of users) { + await request.delete(`${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users/${user.id}`, { + headers: authHeader, + }) + } + } + + for (const groupName of groupsToDelete) { + const groupsRes = await request.get( + `${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/groups?search=${groupName}`, + { headers: authHeader }, + ) + const groups = await groupsRes.json() + for (const group of groups) { + await request.delete(`${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/groups/${group.id}`, { + headers: authHeader, + }) + } + } + }) + }) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/profile.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/profile.spec.ts new file mode 100644 index 0000000000..0794c9c6e5 --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/profile.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test' + +import { + clientURL, + cnolletUser, + signInCloudPiNative, + tcolinUser, +} from '../config/console' + +test.describe('Profile page', () => { + test('Should display name once logged', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + + await expect(page.getByTestId('menuUserList')).toContainText( + `${cnolletUser.firstName} ${cnolletUser.lastName}`, + ) + }) + + test('Should display profile infos', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.goto(`${clientURL}/profile/info`) + + const locator = expect(page.getByTestId('profileInfos')) + await locator.toContainText( + `${tcolinUser.lastName}, ${tcolinUser.firstName}`, + ) + await locator.toContainText(tcolinUser.id) + await locator.toContainText('Admin') + await locator.toContainText(tcolinUser.email) + }) + + test('Should create a PAT', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.goto(`${clientURL}/profile/tokens`) + + // Act + await page.getByTestId('showNewTokenFormBtn').click() + await page.getByTestId('newTokenName').fill('test2') + await page.getByTestId('expirationDateInput').fill('2100-11-22') + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('newTokenPassword')).toBeVisible() + await page.getByTestId('showNewTokenFormBtn').click() + + // Assert + expect(page.getByTestId('newTokenPassword')).toBeDefined() + const locator = page.locator('tr', { hasText: 'test2' }) + expect(locator).toContainText(new Date().getFullYear().toString()) + expect(locator).toContainText('2100') + expect(locator).toContainText('Jamais') + expect(locator).toContainText('Actif') + await locator.getByTitle('Supprimer').click() + await page.getByTestId('confirmDeletionBtn').click() + await expect(page.getByTestId('tokenTable')).toContainText( + 'Aucune clé d\'api existante', + ) + }) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/project-logs.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/project-logs.spec.ts new file mode 100644 index 0000000000..78c0798827 --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/project-logs.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test' +import { + clientURL, + signInCloudPiNative, + tcolinUser, + testUser, +} from '../config/console' +import { createProject } from '../helpers/project' + +test.describe('Project logs page', () => { + test( + 'Should display project logs as owner', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + // Act + await createProject({ page }) + + // Assert + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + }, + ) + + test( + 'Should display additional logs after reprovisionning a project', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + + // Act + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 7', + ) + }, + ) + + test( + 'Should display project logs as project manager as well as a project member', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + // Act + const { name: projectName } = await createProject({ + page, + members: [tcolinUser], + }) + + // Assert - as Project Owner + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + + // Assert - as Project Member + await page.getByRole('link', { name: 'Se Déconnecter' }).click() + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 7', + ) + }, + ) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/project-roles.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/project-roles.spec.ts new file mode 100644 index 0000000000..23e9cf19fc --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/project-roles.spec.ts @@ -0,0 +1,201 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { setCheckbox, unsetCheckbox } from 'helpers/checkbox' +import { clientURL, cnolletUser, signInCloudPiNative, testUser } from '../config/console' +import { createEnvironment } from '../helpers/environment' +import { createProject, deleteProject } from '../helpers/project' +import { createRepository } from '../helpers/repository' + +async function openProjectByName({ page, projectName }: { page: Page, projectName: string }) { + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() +} + +async function openProjectRoleByName({ page, roleName }: { page: Page, roleName: string }) { + await page.getByTestId('test-tab-roles').click() + await page + .locator('[data-testid$="-tab"]') + .filter({ hasText: roleName }) + .click() +} + +async function assignPerms({ + page, + roleName, + perms, +}: { + page: Page + roleName: string + perms: readonly string[] +}) { + await openProjectRoleByName({ page, roleName }) + const setPermChecked = async (key: string, checked: boolean) => { + const input = page.locator(`#${key}-cbx`) + await expect(input).toBeVisible() + if ((await input.isChecked()) === checked) + return + + const label = page.locator(`label[for="${key}-cbx"]`) + if (await label.count()) { + await label.click() + } else { + await setCheckbox(input) + } + + try { + await expect(input).toBeChecked({ checked }) + } catch { + await input.evaluate((el: HTMLInputElement, nextChecked: boolean) => { + el.checked = nextChecked + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, checked) + await expect(input).toBeChecked({ checked }) + } + } + + for (const key of perms) { + await setPermChecked(key, true) + } + await expect(page.getByTestId('saveBtn')).toBeEnabled() + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') +} + +test.describe.serial('Project roles', { tag: '@e2e' }, () => { + const newRoleName = `role-${faker.string.alpha(10).toLowerCase()}` + let projectName = '' + let repositoryName = '' + let environmentName = '' + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + const project = await createProject({ page, members: [cnolletUser] }) + projectName = project.name + + repositoryName = await createRepository({ page }) + environmentName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + await page.getByTestId('test-tab-roles').click() + await page.getByTestId('addRoleBtn').click() + await expect(page.getByTestId('snackbar')).toContainText('Rôle ajouté') + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRoleName) + await page.getByTestId('saveBtn').click() + + await page.getByRole('tab', { name: 'Membres' }).click() + await setCheckbox(page.getByTestId(`input-checkbox-${cnolletUser.id}-cbx`)) + await page.close() + }) + + test.afterAll(async ({ browser }) => { + if (!projectName) + return + const page = await browser.newPage() + try { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await deleteProject({ page, projectName }) + } finally { + await page.close() + } + }) + + test('Should not grant perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + await openProjectByName({ page, projectName }) + + await page.getByTestId('test-tab-team').click() + await expect(page.getByTestId('teamTable').getByText('Retirer du projet')).toBeVisible() + await expect(page.getByTestId('addUserSuggestionInput')).toHaveCount(0) + await expect(page.getByTestId('showTransferProjectBtn')).toHaveCount(0) + await expect(page.getByTestId('test-tab-roles')).toBeVisible() + }) + + test('System roles forbid edits', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + + await openProjectRoleByName({ page, roleName: 'DevOps' }) + + await expect(page.getByTestId('roleNameInput')).toBeDisabled() + await expect(page.locator('#LIST_ENVIRONMENTS-cbx')).toBeDisabled() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('deleteBtn')).toHaveCount(0) + }) + + test('System roles allow member assignment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + await openProjectRoleByName({ page, roleName: 'DevOps' }) + + const membersTab = page.getByRole('tab', { name: 'Membres' }) + await expect(membersTab).toBeVisible() + await membersTab.click() + + const memberCheckbox = page.getByTestId(`input-checkbox-${cnolletUser.id}-cbx`) + await expect(memberCheckbox).toBeVisible() + + await setCheckbox(memberCheckbox) + await expect(memberCheckbox).toBeChecked() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') + + await unsetCheckbox(memberCheckbox) + await expect(memberCheckbox).not.toBeChecked() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') + }) + + test('Should assign view perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + await assignPerms({ + page, + roleName: newRoleName, + perms: ['LIST_ENVIRONMENTS', 'LIST_REPOSITORIES'], + }) + }) + + test('Should grant view perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + await openProjectByName({ page, projectName }) + await page.getByTestId('test-tab-resources').click() + + await expect(page.getByTestId('noReposTr')).toHaveCount(0) + await expect(page.getByTestId('addRepoLink')).toBeHidden() + await page.getByTestId(`repoTr-${repositoryName}`).click() + await expect(page.getByTestId('syncRepoBtn')).toHaveCount(0) + await expect(page.getByTestId('updateRepoBtn')).toHaveCount(0) + await expect(page.getByTestId('showDeleteRepoBtn')).toHaveCount(0) + await page.locator('.fr-modal__header > button.fr-btn--close').click() + + await expect(page.getByTestId('noEnvsTr')).toHaveCount(0) + await expect(page.getByTestId('addEnvironmentLink')).toBeHidden() + await page.getByTestId(`environmentTr-${environmentName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toHaveCount(0) + await expect(page.getByTestId('showDeleteEnvironmentBtn')).toHaveCount(0) + await page.locator('.fr-modal__header > button.fr-btn--close').click() + + await page.getByTestId('test-tab-team').click() + await expect(page.getByTestId('teamTable').getByText('Retirer du projet')).toBeVisible() + await expect(page.getByTestId('addUserSuggestionInput')).toHaveCount(0) + await expect(page.getByTestId('showTransferProjectBtn')).toHaveCount(0) + + await page.getByTestId('test-tab-roles').click() + await expect(page.getByTestId('insuficientPermsRoles')).toBeVisible() + }) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/projects.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/projects.spec.ts new file mode 100644 index 0000000000..0cf0d62384 --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/projects.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + cnolletUser, + signInCloudPiNative, + testUser, +} from '../config/console' +import { deleteValidationInput } from '../helpers/constants' +import { createProject } from '../helpers/project' +import { createRepository, synchronizeBranchOnRepository } from '../helpers/repository' + +test.describe('Projects page', () => { + test( + 'Should display only projects that user is member of', + { tag: '@e2e' }, + async ({ page }) => { + // Create a project as one user + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ page }) + + // Sign off and login as another user + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: cnolletUser }) + + // Previously created project should not appear for this user + await page.getByTestId('menuMyProjects').click() + await expect(page.getByTestId('createProjectLink')).toBeVisible() + await expect( + page.getByRole('link', { name: projectName }), + ).not.toBeVisible() + }, + ) + + test( + 'Should not keep the same default branch name for all repositories of a projects', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + // Adding a project directly puts us on the newly created project page, + // so no need to add navigation steps to go there + const firstRepositoryName = await createRepository({ page }) + const branchName = await synchronizeBranchOnRepository({ + page, + repositoryName: firstRepositoryName, + }) + const secondRepositoryName = await createRepository({ page }) + await page.getByTestId(`repoTr-${secondRepositoryName}`).click() + await expect(page.getByTestId('resource-modal')).toBeVisible() + await expect(page.getByTestId('branchNameInput')).not.toHaveValue( + branchName, + ) + }, + ) + + // @TODO Archiving and Deleting a project is, basically, the same thing. It seems that + // initially we wanted to delete projects but we moved on to archiving, while not updating + // the frontend x) + test( + 'Should archive a project, when logged in as an admin', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const { id: projectId, name: projectName } = await createProject({ page }) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Tous') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('archiveProjectInput')).not.toBeVisible() + await page.getByTestId('showArchiveProjectBtn').click() + await expect(page.getByTestId('confirmDeletionBtn')).toBeDisabled() + await page.getByTestId('archiveProjectInput').fill(deleteValidationInput) + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Archivés') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + // Projects are renamed (suffixed with a timestamp and `_archived`) so @TODO: Do Better… + await expect(page.getByRole('table', { name: 'Liste des projets' })).toContainText(projectName) + }, + ) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/repos.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/repos.spec.ts new file mode 100644 index 0000000000..41362ea716 --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/repos.spec.ts @@ -0,0 +1,742 @@ +import { expect, test } from '@playwright/test' +import { + clientURL, + signInCloudPiNative, + tcolinUser, + testUser, +} from '../config/console' + +import { setCheckbox, unsetCheckbox } from '../helpers/checkbox' +import { + deleteValidationInput, + fakeToken, + invalidGitUrlErrorMessage, + invalidInternalRepoErrorMessage, +} from '../helpers/constants' +import { createProject, projectSlugTextRegexp } from '../helpers/project' + +test.describe('Repositories', () => { + // @TODO: Rework this Cypress-inherited test (use of following-sibling is a + // good hint that something is very very wrong) + test( + 'Should handle repository form validation', + { tag: ['@e2e', '@repos', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + internalRepoName: 'repo00', + externalRepoUrl: 'https://github.com/externalUser01/repo00.git', + externalUserName: 'user', + externalToken: 'videnden88EHEBdldd_T0k9n', + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + + await page.getByTestId('addRepoLink').click() + await page.locator('dialog').waitFor({ state: 'visible' }) + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await setCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await unsetCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('internalRepoNameInput').fill('$%_>') + await expect( + page + .getByTestId('internalRepoNameInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidInternalRepoErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await expect( + page + .getByTestId('internalRepoNameInput') + .locator('//following-sibling::*[1]'), + ).not.toBeVisible() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.slice(0, -4)) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.slice(8)) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.replace('https', 'http')) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).not.toBeVisible() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalUserNameInput') + .fill(repo.externalUserName) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalUserNameInput').clear() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalTokenInput').clear() + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await unsetCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + }, + ) + + test( + 'Should add a standalone public repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + internalRepoName: 'repo00', + isInfra: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await setCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + await page.getByTestId('addRepoBtn').click() + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.locator('h2')).not.toContainText( + 'Synchroniser le dépôt', + ) + }, + ) + + test( + 'Should add an external public repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser01/repo01.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo01', + isInfra: false, + isPrivate: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external private repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser02/repo02.git', + externalToken: 'hoqjC1vXtABzytBIWBXsdyzubmqMYkgA', + externalUserName: '', + internalRepoName: 'repo02', + isInfra: false, + isPrivate: true, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external public infra repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external private infra repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser04/repo04.git', + externalToken: '', + externalUserName: 'externalUser+04', + internalRepoName: 'repo04', + isInfra: true, + isPrivate: true, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page + .getByTestId('externalUserNameInput') + .fill(repo.externalUserName) + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should update a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Update repository + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('internalRepoNameInput')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill('https://github.com/externalUser04/new-repo.git') + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page.getByTestId('externalUserNameInput').fill('newUser') + await page.getByTestId('externalTokenInput').fill('newToken') + await expect( + page.getByTestId('input-checkbox-infraRepoCbx'), + ).toBeEnabled() + await page.getByTestId('updateRepoBtn').click() + await expect(page.getByTestId(`repoTr-repo03`)).toBeVisible() + await page.reload() + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('externalRepoUrlInput')).toHaveValue( + 'https://github.com/externalUser04/new-repo.git', + ) + await expect( + page.getByTestId('input-checkbox-privateRepoCbx'), + ).toBeChecked() + await expect(page.getByTestId('externalUserNameInput')).toHaveValue( + 'newUser', + ) + await expect(page.getByTestId('externalTokenInput')).toHaveValue( + fakeToken, + ) + }, + ) + + test( + 'Should synchronise a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Update repository + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('branchNameInput')).toHaveValue('main') + await expect(page.getByTestId('branchNameInput')).toBeEnabled() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page + .getByTestId('toggleSyncAllBranches') + .locator('input') + .check({ force: true }) + await expect(page.getByTestId('branchNameInput')).not.toBeVisible() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page + .getByTestId('toggleSyncAllBranches') + .locator('input') + .uncheck({ force: true }) + await expect(page.getByTestId('branchNameInput')).toHaveValue('main') + await expect(page.getByTestId('branchNameInput')).toBeEnabled() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page.getByTestId('syncRepoBtn').click() + await expect( + page.getByText( + 'Travail de synchronisation lancé pour le dépôt repo03Fermer le message', + ), + ).toBeVisible() + + await page.getByTestId('branchNameInput').clear() + await expect(page.getByTestId('syncRepoBtn')).toBeDisabled() + await page.getByTestId('branchNameInput').fill('develop') + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page.getByTestId('syncRepoBtn').click() + await expect( + page.getByText( + 'Travail de synchronisation lancé pour le dépôt repo03Fermer le message', + ), + ).toBeVisible() + }, + ) + + test( + 'Should delete a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Delete repository + // + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('deleteRepoInput')).not.toBeVisible() + await expect(page.getByTestId('deleteRepoZone')).toBeVisible() + await page.getByTestId('showDeleteRepoBtn').click() + await expect(page.getByTestId('deleteRepoBtn')).toBeDisabled() + await page.getByTestId('deleteRepoInput').fill(deleteValidationInput) + await page.getByTestId('deleteRepoBtn').click() + await expect(page.getByTestId('repo-form')).not.toBeVisible() + await page.reload() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).not.toBeVisible() + }, + ) + + test( + 'Should not be able to delete a repository if not owner', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { + id: projectId, + name: projectName, + slug: projectSlug, + } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + + // Assert - Attempt to delete repository + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('deleteRepoZone')).not.toBeVisible() + }, + ) + + test( + 'Should not be able to delete a repository if project locked', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ + page, + credentials: + // Only admin users can lock projects + tcolinUser, + }) + const { + id: projectId, + name: projectName, + slug: projectSlug, + } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Act - Lock the project (have to go through admin menu for that) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('handleProjectLockingBtn').click() + + // Assert - Attempt to delete repository + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('showDeleteRepoBtn')).not.toBeVisible() + }, + ) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/roles.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/roles.spec.ts new file mode 100644 index 0000000000..a81d940536 --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/roles.spec.ts @@ -0,0 +1,228 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from 'config/console' +import { setCheckbox } from 'helpers/checkbox' + +test.describe('Administration Roles', () => { + test( + 'Should list admin roles', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + // Assert + // Obviously, we should not use data-testid that are made of ids… + // @TODO: Do better. + await expect( + page.getByTestId('76229c96-4716-45bc-99da-00498ec9018c-tab'), + ).toBeVisible() + await expect( + page.getByTestId('eadf604f-5f54-4744-bdfb-4793d2271e9b-tab'), + ).toBeVisible() + }, + ) + + test('Should add a new OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newOidcRole = { + name: faker.string.alpha(10).toLowerCase(), + oidcGroupName: `/${faker.string.alpha(10).toLowerCase()}`, + user: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newOidcRole.name) + await page.getByTestId('oidcGroupInput').fill(newOidcRole.oidcGroupName) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).toContainText(newOidcRole.name) + await page.getByTestId('test-members').click() + await expect(page.getByTestId('addUserBtn')).toBeDisabled() + await page + .getByTestId('addUserSuggestionInput') + .locator('input') + .fill(`${newOidcRole.user.email}`) + await page.getByTestId('addUserBtn').click() + + // Assert + const membershipCheckbox = page.getByTestId( + `input-checkbox-${newOidcRole.user.id}-cbx`, + ) + await expect(membershipCheckbox).toBeVisible() + await expect(membershipCheckbox).toBeChecked() + }) + + test('Should add a new non-OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + }) + + test('Should add a user to a role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + user: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create non-OIDC role + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + // Add user to role + await page.getByTestId('test-members').click() + await expect(page.getByTestId('addUserBtn')).toBeDisabled() + await page + .getByTestId('addUserSuggestionInput') + .locator('input') + .fill(`${newRole.user.email}`) + await page.getByTestId('addUserBtn').click() + + // Assert + const membershipCheckbox = page.getByTestId( + `input-checkbox-${newRole.user.id}-cbx`, + ) + await expect(membershipCheckbox).toBeVisible() + await expect(membershipCheckbox).toBeChecked() + }) + + test('Should remove OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newOidcRole = { + name: faker.string.alpha(10).toLowerCase(), + oidcGroupName: `/${faker.string.alpha(10).toLowerCase()}`, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create OIDC role + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newOidcRole.name) + await page.getByTestId('oidcGroupInput').fill(newOidcRole.oidcGroupName) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newOidcRole.name) + // Delete role + await page.getByTestId('deleteBtn').click() + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + }) + + test('Should remove non-OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create OIDC role + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + // Delete role + await page.getByTestId('deleteBtn').click() + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + }) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/service-chains.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/service-chains.spec.ts new file mode 100644 index 0000000000..e1dc6def9d --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/service-chains.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' + +test.describe('Service Chains page', () => { + test.describe('Given an Admin-level user', () => { + test( + 'should list service chains', + { tag: ['@e2e', '@service-chains'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationServiceChains').click() + // We take the first service chain available + await expect( + page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1), + ).toBeVisible() + }, + ) + + test( + 'should show a service chain details', + { tag: ['@e2e', '@service-chains'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationServiceChains').click() + // We take the first service chain available + await page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1) + .click() + await expect( + page.getByRole('heading', { name: 'Chaîne de services' }), + ).toBeVisible() + }, + ) + + test( + 'should show a service chain flows', + { tag: ['@e2e', '@service-chains', '@service-chain-flows'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await expect(page.locator('.fr-collapsing')).toBeVisible() + await expect(page.locator('.fr-collapsing')).not.toBeVisible() + + await page.getByTestId('menuAdministrationServiceChains').click() + await expect( + page.getByTitle('Voir les détails de la chaîne de service').first(), + ).toBeVisible() + // We take the first service chain available + await page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1) + .click() + await expect(page.getByTestId('service-chain-flows')).toBeVisible() + }, + ) + }) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/tokens.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/tokens.spec.ts new file mode 100644 index 0000000000..f6aa4b34db --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/tokens.spec.ts @@ -0,0 +1,37 @@ +import { faker } from '@faker-js/faker' + +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +test.describe('Admin Tokens page', () => { + // @TODO The original test did actually test whether the generated token worked. + // Add a dedicated test to do exactly that. + test( + 'Should create tokens, when logged in as an admin', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const tokenName = faker.string.alpha(10).toLowerCase() + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationToken').click() + await page.getByTestId('showNewTokenFormBtn').click() + + await page.getByTestId('newTokenName').fill(tokenName) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('newTokenPassword')).toBeVisible() + // Reinit form should hide newly generated password + await page.getByTestId('showNewTokenFormBtn').click() + await expect(page.getByTestId('newTokenPassword')).not.toBeVisible() + + await expect(page.getByTestId('tokenTable')).toContainText(tokenName) + }, + ) + + // @TODO Add token deletion test (the original one used "nth" which is not reliable) +}) diff --git a/.jjconflict-base-1/playwright/e2e-tests/zones.spec.ts b/.jjconflict-base-1/playwright/e2e-tests/zones.spec.ts new file mode 100644 index 0000000000..04c61a951b --- /dev/null +++ b/.jjconflict-base-1/playwright/e2e-tests/zones.spec.ts @@ -0,0 +1,193 @@ +import { faker } from '@faker-js/faker' + +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' +import { deleteValidationInput } from '../helpers/constants' + +test.describe('Zone page', () => { + // @TODO: Add clusters to this test to ensure this part of the feature + test('Should create a zone', { tag: ['@e2e', '@need-rework'] }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + expect(expect(page.locator('#clusters-select')).not.toBeVisible()) + await page.getByTestId('addZoneBtn').click() + + await page.reload() + + // Assert + await page.locator(`#zoneTile-${zone.slug}`).click() + await expect(page.getByTestId('slugInput')).toHaveValue(zone.slug) + await expect(page.getByTestId('slugInput')).toBeDisabled() + await expect(page.getByTestId('labelInput')).toHaveValue(zone.label) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await expect(page.getByTestId('argocdUrlInput')).toHaveValue( + zone.argocdUrl, + ) + await expect(page.getByTestId('argocdUrlInput')).toBeEnabled() + await expect(page.getByTestId('descriptionInput')).toHaveValue( + zone.description, + ) + await expect(page.getByTestId('descriptionInput')).toBeEnabled() + await expect(page.locator('#clusters-select')).toBeVisible() + await expect(page.locator('#clusters-select')).toHaveAttribute( + 'disabled', + 'true', + ) + await expect(page.getByTestId('updateZoneBtn')).toBeEnabled() + }) + + test('Should update a zone', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + const updatedZone = { + label: 'Zone Mise à Jour', + argocdUrl: 'https://vousnetesplusici.fr', + description: 'Cette zone a été mise à jour.', + } + // Zone creation (for later update) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + + // Act + await page.locator(`#zoneTile-${zone.slug}`).click() + await page.getByTestId('labelInput').fill(updatedZone.label) + await page.getByTestId('argocdUrlInput').fill(updatedZone.argocdUrl) + await page.getByTestId('descriptionInput').fill(updatedZone.description) + await page.getByTestId('updateZoneBtn').click() + + // Assert + await page.locator(`#zoneTile-${zone.slug}`).click() + await expect(page.getByTestId('slugInput')).toHaveValue(zone.slug) + await expect(page.getByTestId('slugInput')).toBeDisabled() + await expect(page.getByTestId('labelInput')).toHaveValue(updatedZone.label) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await expect(page.getByTestId('argocdUrlInput')).toHaveValue( + updatedZone.argocdUrl, + ) + await expect(page.getByTestId('argocdUrlInput')).toBeEnabled() + await expect(page.getByTestId('descriptionInput')).toHaveValue( + updatedZone.description, + ) + await expect(page.getByTestId('descriptionInput')).toBeEnabled() + await expect(page.locator('#clusters-select')).toBeVisible() + await expect(page.locator('#clusters-select')).toHaveAttribute( + 'disabled', + 'true', + ) + await expect(page.getByTestId('updateZoneBtn')).toBeEnabled() + }) + + test('Should not create a zone if slug is already taken', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + // Create the zone + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + + // Act - attempt to create a zone with the same slug + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(`${zone.label}bis`) + await page.getByTestId('argocdUrlInput').fill(`${zone.argocdUrl}bis`) + await page.getByTestId('descriptionInput').fill(`${zone.description}bis`) + await page.getByTestId('addZoneBtn').click() + + // Assert + const snackbar = page + .getByTestId('snackbar') + .filter({ hasText: `Une zone portant le nom ${zone.slug} existe déjà.` }) + await expect(snackbar).toBeVisible() + }) + + test('Should not delete a zone if associated clusters', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + // No data to arrange, a public zone is present by default (I guess ?) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + + // Assert + await page.getByTestId(`zoneTile-publique`).click() + await expect(page.getByTestId('showDeleteZoneBtn')).not.toBeVisible() + await expect(page.getByTestId('associatedClustersAlert')).toBeVisible() + }) + + test('Should delete a zone', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + // Create the zone + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + // @TODO: Add clusters to this test to ensure we can't delete a zone that has clusters + + // Act + await page.getByTestId('menuAdministrationZones').click() + await page.locator(`#zoneTile-${zone.slug}`).click() + await page.getByTestId('showDeleteZoneBtn').click() + await page.getByTestId('deleteZoneInput').fill(deleteValidationInput) + await page.getByTestId('deleteZoneBtn').click() + + // Assert + await page.getByTestId('menuAdministrationZones').click() + await expect(page.locator(`#zoneTile-${zone.slug}`)).not.toBeVisible() + }) +}) diff --git a/.jjconflict-base-1/playwright/eslint.config.mjs b/.jjconflict-base-1/playwright/eslint.config.mjs new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-1/playwright/eslint.config.mjs @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-1/playwright/helpers/checkbox.ts b/.jjconflict-base-1/playwright/helpers/checkbox.ts new file mode 100644 index 0000000000..b304a5c380 --- /dev/null +++ b/.jjconflict-base-1/playwright/helpers/checkbox.ts @@ -0,0 +1,20 @@ +import type { Locator } from '@playwright/test' +import { expect } from '@playwright/test' + +async function setCheckboxValue(locator: Locator, checked: boolean) { + await expect(locator).toBeVisible() + await locator.evaluate((el: HTMLInputElement, nextChecked: boolean) => { + el.checked = nextChecked + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, checked) + await expect(locator).toBeChecked({ checked }) +} + +export async function setCheckbox(locator: Locator) { + await setCheckboxValue(locator, true) +} + +export async function unsetCheckbox(locator: Locator) { + await setCheckboxValue(locator, false) +} diff --git a/.jjconflict-base-1/playwright/helpers/cluster.ts b/.jjconflict-base-1/playwright/helpers/cluster.ts new file mode 100644 index 0000000000..2239dd8904 --- /dev/null +++ b/.jjconflict-base-1/playwright/helpers/cluster.ts @@ -0,0 +1,124 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openClustersAdministration } from './navigation' + +type ClusterZone = string +type ClusterConfidentiality = 'public' | 'dedicated' +type ClusterAssociateStage = 'first' | 'all' | 'none' + +async function selectClusterZone(page: Page, zone: ClusterZone) { + await page.getByLabel('Zone associée').selectOption({ label: zone }) +} + +async function selectClusterConfidentiality(page: Page, confidentiality: ClusterConfidentiality) { + await page + .getByLabel('Confidentialité du cluster') + .selectOption({ label: confidentiality }) + + const projectsSelect = page.locator('#projects-select') + switch (confidentiality) { + case 'public': + await expect(projectsSelect).not.toBeVisible() + break + case 'dedicated': + await expect(projectsSelect).toBeVisible() + break + } +} + +async function addStageToClusterAssociationByName(page: Page, stageName: string) { + await page + .getByTestId('choice-selector-search-stages-select') + .fill(stageName) + await page.locator('#stages-select .fr-tag').first().click() +} + +async function addStagesToClusterAssociationByNames(page: Page, stageNames: string[]) { + for (const stageName of stageNames) + await addStageToClusterAssociationByName(page, stageName) +} + +async function addFirstStageToClusterAssociation(page: Page) { + await page.locator('#stages-select .fr-tag').first().click() +} + +async function addAllStagesToClusterAssociation(page: Page) { + const numberOfStages = (await page.locator('#stages-select .fr-tag').all()).length + for (let i = 0; i < numberOfStages; i++) + await page.locator('#stages-select .fr-tag').first().click() +} + +async function fillClusterInformations(page: Page, informations: string) { + await page.getByTestId('infosInput').fill(informations) +} + +async function searchCluster(page: Page, clusterName: string) { + await page.getByTestId('projectsSearchInput').fill(clusterName) +} + +async function openCluster(page: Page, clusterName: string) { + await page.getByTestId(`clusterLink-${clusterName}`).click() +} + +async function confirmClusterDeletion(page: Page) { + await expect(page.getByTestId('deleteClusterZone')).toBeVisible() + await page.getByTestId('showDeleteClusterBtn').click() + await expect(page.getByTestId('deleteClusterBtn')).toBeVisible() + await expect(page.getByTestId('deleteClusterBtn')).toBeDisabled() + await page.getByTestId('deleteClusterInput').fill(deleteValidationInput) + await expect(page.getByTestId('deleteClusterBtn')).toBeEnabled() + await page.getByTestId('deleteClusterBtn').click() +} + +export async function createCluster({ + page, + zone, + confidentiality, + associateStage, + associateStageNames, + informations, +}: { + page: Page + zone?: ClusterZone + confidentiality: ClusterConfidentiality + associateStage?: ClusterAssociateStage + associateStageNames?: string[] + informations?: string +}): Promise { + const clusterName = faker.string.alpha(10).toLowerCase() + await openClustersAdministration({ page }) + await page.getByTestId('addClusterLink').click() + await page.getByTestId('labelInput').fill(clusterName) + if (zone) + await selectClusterZone(page, zone) + await selectClusterConfidentiality(page, confidentiality) + if (associateStageNames?.length) + await addStagesToClusterAssociationByNames(page, associateStageNames) + + switch (associateStage ?? 'none') { + case 'first': + await addFirstStageToClusterAssociation(page) + break + case 'all': + await addAllStagesToClusterAssociation(page) + break + case 'none': + break + } + + if (informations) + await fillClusterInformations(page, informations) + await page.getByTestId('addClusterBtn').click() + await searchCluster(page, clusterName) + return clusterName +} + +export async function deleteCluster({ page, clusterName }: { page: Page, clusterName: string }) { + await openClustersAdministration({ page }) + await searchCluster(page, clusterName) + await expect(page.getByTestId(`clusterLink-${clusterName}`)).toBeVisible() + await openCluster(page, clusterName) + await confirmClusterDeletion(page) +} diff --git a/.jjconflict-base-1/playwright/helpers/constants.ts b/.jjconflict-base-1/playwright/helpers/constants.ts new file mode 100644 index 0000000000..df68f8179a --- /dev/null +++ b/.jjconflict-base-1/playwright/helpers/constants.ts @@ -0,0 +1,9 @@ +export const invalidGitUrlErrorMessage = 'L\'adresse doit commencer par https et se terminer par .git' +export const invalidInternalRepoErrorMessage = 'Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique' +export const fakeToken = 'fakeToken' +export const deleteValidationInput = 'DELETE' + +export const snackbarTimeoutMs = 20_000 +export const repoSyncTimeoutMs = 60_000 +export const projectReadinessTimeoutMs = 60_000 +export const pollingFastIntervalMs = 2_000 diff --git a/.jjconflict-base-1/playwright/helpers/deployment.ts b/.jjconflict-base-1/playwright/helpers/deployment.ts new file mode 100644 index 0000000000..7a2850f3fc --- /dev/null +++ b/.jjconflict-base-1/playwright/helpers/deployment.ts @@ -0,0 +1,44 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' + +export async function createDeployment({ + page, + deploymentName, + envName, + repoName, + customStageName, +}: { + page: Page + deploymentName?: string + envName: string + repoName: string + customStageName: string +}): Promise { + deploymentName = deploymentName ?? faker.string.alpha(10).toLocaleLowerCase() + await openDeploymentCreateForm(page) + await fillDeploymentName(page, deploymentName) + await selectDeploymentEnvironment(page, envName, customStageName) + await selectDeploymentRepository(page, repoName) + await submitDeploymentForm(page) + return deploymentName +} + +async function openDeploymentCreateForm(page: Page) { + await page.getByRole('button', { name: 'Ajouter un nouveau déploiement' }).click() +} + +async function fillDeploymentName(page: Page, deploymentName: string) { + await page.getByRole('textbox', { name: 'Nom du déploiement * Ne doit' }).fill(deploymentName) +} + +async function selectDeploymentEnvironment(page: Page, envName: string, customStageName: string) { + await page.getByText(`${envName} ${customStageName}`).click() +} + +async function selectDeploymentRepository(page: Page, repoName: string) { + await page.getByLabel('Dépôt *').selectOption({ label: repoName }) +} + +async function submitDeploymentForm(page: Page) { + await page.getByRole('button', { name: 'Enregistrer' }).click() +} diff --git a/.jjconflict-base-1/playwright/helpers/environment.ts b/.jjconflict-base-1/playwright/helpers/environment.ts new file mode 100644 index 0000000000..2eccb82509 --- /dev/null +++ b/.jjconflict-base-1/playwright/helpers/environment.ts @@ -0,0 +1,89 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openMyProjects } from './navigation' + +type EnvironmentZone = 'publique' | 'Zone privée' + +async function openEnvironmentCreateForm(page: Page) { + await page.getByTestId('addEnvironmentLink').click() +} + +async function fillEnvironmentName(page: Page, envName: string) { + await page.getByTestId('environmentNameInput').fill(envName) +} + +async function selectEnvironmentZone(page: Page, zone: EnvironmentZone) { + await page.getByLabel('Choix de la zone cible').selectOption({ label: zone }) +} + +async function selectEnvironmentStage(page: Page, stageName: string) { + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: stageName }) +} + +async function selectEnvironmentCluster(page: Page, clusterName: string) { + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page + .getByLabel('Choix du cluster cible') + .selectOption({ label: clusterName }) +} + +async function fillEnvironmentResources( + page: Page, + { memoryInput, cpuInput, gpuInput }: { memoryInput: string, cpuInput: string, gpuInput: string }, +) { + await page.getByTestId('memoryInput').fill(memoryInput) + await page.getByTestId('cpuInput').fill(cpuInput) + await page.getByTestId('gpuInput').fill(gpuInput) +} + +async function submitEnvironmentCreation(page: Page) { + await page.getByTestId('addEnvironmentBtn').click() +} + +export async function createEnvironment({ + page, + envName, + zone, + customStageName, + customClusterName, + memoryInput = '1', + cpuInput = '1', + gpuInput = '1', + envsToDelete, +}: { + page: Page + envName?: string + zone: EnvironmentZone + customStageName: string + customClusterName: string + memoryInput?: string + cpuInput?: string + gpuInput?: string + envsToDelete?: string[] +}): Promise { + envName = envName ?? faker.string.alpha(10).toLowerCase() + envsToDelete?.push(envName) + await openEnvironmentCreateForm(page) + await fillEnvironmentName(page, envName) + await selectEnvironmentZone(page, zone) + await selectEnvironmentStage(page, customStageName) + await selectEnvironmentCluster(page, customClusterName) + await fillEnvironmentResources(page, { memoryInput, cpuInput, gpuInput }) + await submitEnvironmentCreation(page) + return envName +} + +export async function deleteEnvironment({ page, projectName, envName }: { page: Page, projectName: string, envName: string }) { + await openMyProjects({ page }) + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId(`environmentTr-${envName}`).click() + await page.getByTestId('showDeleteEnvironmentBtn').click() + await expect(page.getByTestId('deleteEnvironmentZone')).toBeVisible() + await page.getByTestId('deleteEnvironmentInput').fill(deleteValidationInput) + await page.getByTestId('deleteEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).not.toBeVisible() +} diff --git a/.jjconflict-base-1/playwright/helpers/global.d.ts b/.jjconflict-base-1/playwright/helpers/global.d.ts new file mode 100644 index 0000000000..20981cf311 --- /dev/null +++ b/.jjconflict-base-1/playwright/helpers/global.d.ts @@ -0,0 +1,5 @@ +declare global { + interface RegExpConstructor { + escape(string: string): string + } +} diff --git a/.jjconflict-base-1/playwright/helpers/navigation.ts b/.jjconflict-base-1/playwright/helpers/navigation.ts new file mode 100644 index 0000000000..3d4dc22a7d --- /dev/null +++ b/.jjconflict-base-1/playwright/helpers/navigation.ts @@ -0,0 +1,26 @@ +import type { Page } from '@playwright/test' + +export async function openMyProjects({ page }: { page: Page }) { + await page.getByTestId('menuMyProjects').click() +} + +async function ensureAdminMenuVisible(page: Page, menuTestId: string) { + const menuItem = page.getByTestId(menuTestId) + if (!(await menuItem.isVisible())) + await page.getByTestId('menuAdministrationBtn').click() +} + +export async function openClustersAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationClusters') + await page.getByTestId('menuAdministrationClusters').click() +} + +export async function openStagesAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationStages') + await page.getByTestId('menuAdministrationStages').click() +} + +export async function openZonesAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationZones') + await page.getByTestId('menuAdministrationZones').click() +} diff --git a/.jjconflict-base-1/playwright/helpers/project.ts b/.jjconflict-base-1/playwright/helpers/project.ts new file mode 100644 index 0000000000..0d5a8d65d1 --- /dev/null +++ b/.jjconflict-base-1/playwright/helpers/project.ts @@ -0,0 +1,124 @@ +import type { Page } from '@playwright/test' +import type { Credentials } from '../config/console' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput, projectReadinessTimeoutMs } from './constants' +import { openMyProjects } from './navigation' + +const projectUrlRegexp = /\/projects\/[^/]+$/ +const projectSlugPrefixRegexp = /^slug\s*:\s*/i + +interface Resources { + cpu: number + gpu: number + memory: number +} + +async function openProjectCreateForm(page: Page) { + await openMyProjects({ page }) + await page.getByTestId('createProjectLink').click() +} + +async function fillProjectName(page: Page, name: string) { + await page.getByTestId('nameInput').click() + await page.getByTestId('nameInput').fill(name) +} + +async function fillProjectResources({ + page, + hprodResources, + prodResources, +}: { + page: Page + hprodResources?: Resources + prodResources?: Resources +}) { + const fillHprod = async (resources: Resources) => { + await page.getByTestId('cpuHprodInput').fill(resources.cpu.toString()) + await page.getByTestId('gpuHprodInput').fill(resources.gpu.toString()) + await page + .getByTestId('memoryHprodInput') + .fill(resources.memory.toString()) + } + const fillProd = async (resources: Resources) => { + await page.getByTestId('cpuProdInput').fill(resources.cpu.toString()) + await page.getByTestId('gpuProdInput').fill(resources.gpu.toString()) + await page + .getByTestId('memoryProdInput') + .fill(resources.memory.toString()) + } + + if (hprodResources) + await fillHprod(hprodResources) + if (prodResources) + await fillProd(prodResources) +} + +async function enableProjectLimitless(page: Page) { + await page.getByTestId('limitlessProjectSwitch').locator('label').click() +} + +async function addMembersToProject(page: Page, members: Credentials[]) { + await page.getByTestId('test-tab-team').click() + for (const member of members) { + await page + .getByRole('combobox', { name: 'Ajouter un utilisateur' }) + .fill(member.email) + await page.getByTestId('addUserBtn').click() + await expect( + page.getByTestId('teamTable').getByText(member.email), + ).toBeVisible() + } + await page.getByTestId('test-tab-resources').click() +} + +async function getProjectSlugAndId(page: Page) { + const rawSlugText = (await page.getByTestId('project-slug').textContent()) ?? 'no-slug' + const slug = rawSlugText?.replace(projectSlugPrefixRegexp, '').trim() + const id = (await page.getByTestId('project-id').getAttribute('title')) ?? 'no-id' + return { slug, id } +} + +export function projectSlugTextRegexp(projectSlug: string): RegExp { + const escapedSlug = RegExp.escape(projectSlug) + return new RegExp(`^slug:\\s*${escapedSlug}$`) +} + +export async function createProject({ + page, + projectName: name, + members, + hprodResources, + prodResources, +}: { + page: Page + projectName?: string + members?: Credentials[] + hprodResources?: Resources + prodResources?: Resources +}): Promise<{ id: string, slug: string, name: string }> { + name = name ?? faker.string.alpha(10).toLowerCase() + await openProjectCreateForm(page) + await fillProjectName(page, name) + await fillProjectResources({ page, hprodResources, prodResources }) + if (!hprodResources && !prodResources) await enableProjectLimitless(page) + + await page.getByTestId('createProjectBtn').click() + await expect(page).toHaveURL(projectUrlRegexp, { timeout: projectReadinessTimeoutMs }) + await expect(page.getByTestId('project-slug')).toBeVisible({ timeout: projectReadinessTimeoutMs }) + if (members?.length) + await addMembersToProject(page, members) + + const { slug, id } = await getProjectSlugAndId(page) + return { name, slug, id } +} + +export async function deleteProject({ page, projectName }: { page: Page, projectName: string }) { + await openMyProjects({ page }) + await page.getByRole('link', { name: projectName }).click() + await page.getByRole('button', { name: 'Supprimer le projet' }).click() + await page.getByTestId('archiveProjectInput').fill(deleteValidationInput) + await page.getByTestId('confirmDeletionBtn').click() + await openMyProjects({ page }) + await expect(page.getByRole('link', { name: projectName })).not.toBeVisible() +} diff --git a/.jjconflict-base-1/playwright/helpers/repository.ts b/.jjconflict-base-1/playwright/helpers/repository.ts new file mode 100644 index 0000000000..82678de00d --- /dev/null +++ b/.jjconflict-base-1/playwright/helpers/repository.ts @@ -0,0 +1,82 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { repoSyncTimeoutMs } from './constants' +import { waitForSnackbar } from './snackbar' + +const repoSyncApiRegex = /\/api\/.*\/repositories\/.*\/sync$/ + +export function waitForRepoSync({ + page, + timeoutMs = repoSyncTimeoutMs, +}: { + page: Page + timeoutMs?: number +}) { + return page.waitForResponse((response) => { + const request = response.request() + const success = response.status() >= 200 && response.status() < 300 + return request.method() === 'POST' && success && repoSyncApiRegex.test(response.url()) + }, { timeout: timeoutMs }) +} + +async function openAddRepositoryForm(page: Page) { + await page.getByTestId('addRepoLink').click() +} + +async function fillRepositoryUrl(page: Page, externalRepoUrlInput?: string) { + const url = externalRepoUrlInput ?? `${faker.internet.url({ appendSlash: true })}myrepository.git` + await page.getByTestId('externalRepoUrlInput').fill(url) +} + +async function closeResourceModal(page: Page) { + await page + .getByTestId('resource-modal') + .getByRole('button', { name: 'Fermer' }) + .click() +} + +export async function createRepository({ + page, + repositoryName, + externalRepoUrlInput, + infraRepo, +}: { + page: Page + repositoryName?: string + externalRepoUrlInput?: string + infraRepo?: boolean +}) { + repositoryName = repositoryName ?? faker.string.alpha(10).toLowerCase() + await openAddRepositoryForm(page) + await page.getByTestId('internalRepoNameInput').fill(repositoryName) + await fillRepositoryUrl(page, externalRepoUrlInput) + if (infraRepo) + await page.getByText('Dépôt contenant du code d\'').click() + await page.getByTestId('addRepoBtn').click() + await expect(page.getByTestId(`repoTr-${repositoryName}`)).toContainText( + repositoryName, + ) + return repositoryName +} + +export async function synchronizeBranchOnRepository({ + page, + repositoryName, + branchName, +}: { + page: Page + repositoryName: string + branchName?: string +}) { + branchName = branchName ?? faker.string.alpha(10).toLowerCase() + await page.getByTestId(`repoTr-${repositoryName}`).click() + await expect(page.getByTestId('resource-modal')).toBeVisible() + await page.getByTestId('branchNameInput').fill(branchName) + const syncRequest = waitForRepoSync({ page, timeoutMs: repoSyncTimeoutMs }) + await page.getByTestId('syncRepoBtn').click() + await syncRequest + await closeResourceModal(page) + await waitForSnackbar({ page, text: 'Travail de synchronisation lancé' }) + return branchName +} diff --git a/.jjconflict-base-1/playwright/helpers/snackbar.ts b/.jjconflict-base-1/playwright/helpers/snackbar.ts new file mode 100644 index 0000000000..7c7d57d685 --- /dev/null +++ b/.jjconflict-base-1/playwright/helpers/snackbar.ts @@ -0,0 +1,17 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' +import { snackbarTimeoutMs } from './constants' + +export async function waitForSnackbar({ + page, + text, + timeoutMs = snackbarTimeoutMs, +}: { + page: Page + text: string | RegExp + timeoutMs?: number +}) { + await expect(page.getByTestId('snackbar')).toContainText(text, { + timeout: timeoutMs, + }) +} diff --git a/.jjconflict-base-1/playwright/helpers/stage.ts b/.jjconflict-base-1/playwright/helpers/stage.ts new file mode 100644 index 0000000000..f99f261b34 --- /dev/null +++ b/.jjconflict-base-1/playwright/helpers/stage.ts @@ -0,0 +1,76 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openStagesAdministration } from './navigation' + +async function assertStageCreateForm(page: Page) { + await expect(page.locator('h1')).toContainText( + 'Informations du type d\'environnement', + ) + await expect(page.getByTestId('addStageBtn')).toBeVisible() + await expect(page.getByTestId('addStageBtn')).toBeDisabled() + await expect(page.getByTestId('updateStageBtn')).not.toBeVisible() +} + +async function addFirstClusterAssociation(page: Page) { + await page.locator('.fr-tag').first().click() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(1) +} + +async function addAllClusterAssociations(page: Page) { + await expect(page.locator('.fr-tag')).not.toHaveCount(0) + const numberOfAvalaibleClusters = (await page.locator('.fr-tag').all()).length + for (let i = 0; i < numberOfAvalaibleClusters; i++) + await page.locator('.fr-tag').first().click() + + await expect(page.locator('.fr-tag--dismiss')).toHaveCount( + numberOfAvalaibleClusters, + ) +} + +export async function createStage({ + page, + associateToCluster, +}: { + page: Page + associateToCluster: 'first' | 'all' | 'none' +}): Promise { + const stageName = faker.string.alpha(10).toLowerCase() + await openStagesAdministration({ page }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await page.getByTestId('addStageLink').click() + await assertStageCreateForm(page) + await page.getByTestId('nameInput').fill(stageName) + await expect(page.getByTestId('addStageBtn')).toBeEnabled() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(0) + + switch (associateToCluster) { + case 'first': + await addFirstClusterAssociation(page) + break + case 'all': + await addAllClusterAssociations(page) + break + case 'none': + break + } + + await page.getByTestId('addStageBtn').click() + return stageName +} + +export async function deleteStage({ page, stageName }: { page: Page, stageName: string }) { + await openStagesAdministration({ page }) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.getByTestId('deleteStageZone')).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsZone'), + ).not.toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable'), + ).not.toBeVisible() + await page.getByTestId('showDeleteStageBtn').click() + await page.getByTestId('deleteStageInput').fill(deleteValidationInput) + await page.getByTestId('deleteStageBtn').click() +} diff --git a/.jjconflict-base-1/playwright/helpers/zone.ts b/.jjconflict-base-1/playwright/helpers/zone.ts new file mode 100644 index 0000000000..c18973e9ef --- /dev/null +++ b/.jjconflict-base-1/playwright/helpers/zone.ts @@ -0,0 +1,34 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { deleteValidationInput } from './constants' +import { openZonesAdministration } from './navigation' + +async function openZoneCreateForm(page: Page) { + await page.getByTestId('createZoneLink').click() +} + +async function fillZoneCreateForm(page: Page, zoneName: string) { + await page.getByTestId('slugInput').fill(zoneName) + await page.getByTestId('labelInput').fill(zoneName) + await page.getByTestId('argocdUrlInput').fill(faker.internet.url()) + await page + .getByTestId('descriptionInput') + .fill(faker.string.alpha(100).toLowerCase()) +} + +export async function createZone({ page }: { page: Page }): Promise { + const zoneName = faker.string.alpha(10).toLowerCase() + await openZonesAdministration({ page }) + await openZoneCreateForm(page) + await fillZoneCreateForm(page, zoneName) + await page.getByTestId('addZoneBtn').click() + return zoneName +} + +export async function deleteZone({ page, zoneName }: { page: Page, zoneName: string }) { + await openZonesAdministration({ page }) + await page.getByRole('link', { name: zoneName }).click() + await page.getByTestId('showDeleteZoneBtn').click() + await page.getByTestId('deleteZoneInput').fill(deleteValidationInput) + await page.getByTestId('deleteZoneBtn').click() +} diff --git a/.jjconflict-base-1/playwright/integration-tests/admin-setup.spec.ts b/.jjconflict-base-1/playwright/integration-tests/admin-setup.spec.ts new file mode 100644 index 0000000000..da53fa6394 --- /dev/null +++ b/.jjconflict-base-1/playwright/integration-tests/admin-setup.spec.ts @@ -0,0 +1,54 @@ +import { test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' + +import { createCluster, deleteCluster } from '../helpers/cluster' +import { createStage, deleteStage } from '../helpers/stage' +import { createZone, deleteZone } from '../helpers/zone' + +const zonesToDelete: string[] = [] +const stagesToDelete: string[] = [] +const clustersToDelete: string[] = [] + +test.describe('Integration tests for admin', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Admin setup', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + const zoneName = await createZone({ page }) + zonesToDelete.push(zoneName) + // we need to attains 7 stages to be able to use associateStageNames argument in createCluster + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName1 = await createStage({ page, associateToCluster: 'none' }) + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName2 = await createStage({ page, associateToCluster: 'none' }) + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName3 = await createStage({ page, associateToCluster: 'none' }) + stagesToDelete.push(customStageName1) + stagesToDelete.push(customStageName2) + stagesToDelete.push(customStageName3) + const clusterName = await createCluster({ + page, + zone: zoneName, + confidentiality: 'public', + associateStageNames: [customStageName1, customStageName2, customStageName3], + }) + clustersToDelete.push(clusterName) + }) + + test('Cleanup admin test data', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + for (const stageName of stagesToDelete) { + await deleteStage({ page, stageName }) + } + for (const clusterName of clustersToDelete) { + await deleteCluster({ page, clusterName }) + } + for (const zoneName of zonesToDelete) { + await deleteZone({ page, zoneName }) + } + }) +}) diff --git a/.jjconflict-base-1/playwright/integration-tests/gitlab.spec.ts b/.jjconflict-base-1/playwright/integration-tests/gitlab.spec.ts new file mode 100644 index 0000000000..19d5424ddd --- /dev/null +++ b/.jjconflict-base-1/playwright/integration-tests/gitlab.spec.ts @@ -0,0 +1,125 @@ +import { expect, test } from '@playwright/test' +import { clientURL, currentUser, secondTestUser, signInCloudPiNative } from '../config/console' +import { projectReadinessTimeoutMs } from '../helpers/constants' +import { createProject, deleteProject } from '../helpers/project' + +const membersLinkRegexp = /^(members|membres)$/i +const manageLinkRegexp = /^(manage|gérer)$/i +const developerRoleRegexp = /developer/i + +test.describe('GitLab', () => { + test( + 'Should create project and have infra-apps + mirror repos in GitLab', + { tag: ['@integ', '@replayable'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: currentUser }) + + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + try { + await page.getByTestId('test-tab-services').click() + + const gitlabPopup = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const gitlabPage = await gitlabPopup + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + await expect( + gitlabPage.getByTestId('group-name').filter({ hasText: 'infra-apps' }), + ).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await expect( + gitlabPage.getByTestId('group-name').filter({ hasText: 'mirror' }), + ).toBeVisible({ timeout: projectReadinessTimeoutMs }) + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const manageLink = gitlabPage + .getByRole('button', { name: manageLinkRegexp }) + .or(gitlabPage.getByRole('link', { name: manageLinkRegexp })) + .first() + await manageLink.click({ timeout: projectReadinessTimeoutMs }) + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const membersLink = gitlabPage.getByRole('link', { name: membersLinkRegexp }).first() + await membersLink.click({ timeout: projectReadinessTimeoutMs }) + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const currentUserGitlabUsername = currentUser.email.replace('@', '.') + const userRow = gitlabPage.getByRole('row').filter({ hasText: `@${currentUserGitlabUsername}` }).first() + await userRow.scrollIntoViewIfNeeded() + await expect(userRow).toBeVisible({ + timeout: projectReadinessTimeoutMs, + }) + + const botRow = gitlabPage.getByRole('row').filter({ hasText: `${projectSlug}-bot` }).first() + await botRow.scrollIntoViewIfNeeded() + await expect(botRow).toBeVisible({ + timeout: projectReadinessTimeoutMs, + }) + } finally { + await deleteProject({ page, projectName }) + } + }, + ) + + test( + 'Should reflect console role assignment in GitLab builtin role', + { tag: ['@e2e', '@integ', '@replayable'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: currentUser }) + + const { name: projectName } = await createProject({ page }) + + try { + await page.getByTestId('test-tab-team').click() + await page.locator('input[data-testid="addUserSuggestionInput"]').fill(secondTestUser.email) + await page.getByTestId('addUserBtn').click() + const teamMemberRow = page.getByTestId('teamTable').getByRole('row').filter({ hasText: secondTestUser.email }).first() + await expect(teamMemberRow).toBeVisible({ timeout: projectReadinessTimeoutMs }) + const teamMemberUserId = ((await teamMemberRow.locator('code').first().textContent()) ?? '').trim() + expect(teamMemberUserId).not.toEqual('') + + await page.reload() + await expect(page.getByTestId('project-slug')).toBeVisible({ timeout: projectReadinessTimeoutMs }) + + await page.getByTestId('test-tab-roles').click() + await page + .locator('[data-testid$="-tab"]') + .filter({ hasText: 'DevOps' }) + .click() + await page.getByRole('tab', { name: 'Membres' }).click() + const roleMemberCheckbox = page.getByTestId(`input-checkbox-${teamMemberUserId}-cbx`) + await expect(roleMemberCheckbox).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await roleMemberCheckbox.check({ force: true }) + + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour', { timeout: projectReadinessTimeoutMs }) + + await page.getByTestId('test-tab-services').click() + + const gitlabPopup = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const gitlabPage = await gitlabPopup + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const manageLink = gitlabPage + .getByRole('button', { name: manageLinkRegexp }) + .or(gitlabPage.getByRole('link', { name: manageLinkRegexp })) + .first() + await manageLink.click({ timeout: projectReadinessTimeoutMs }) + + const membersLink = gitlabPage.getByRole('link', { name: membersLinkRegexp }).first() + await membersLink.click({ timeout: projectReadinessTimeoutMs }) + + const secondTestUserGitlabUsername = secondTestUser.email.replace('@', '.') + const memberRow = gitlabPage.getByRole('row').filter({ hasText: `@${secondTestUserGitlabUsername}` }).first() + await expect(memberRow).toContainText(developerRoleRegexp, { timeout: projectReadinessTimeoutMs }) + } finally { + await deleteProject({ page, projectName }) + } + }, + ) +}) diff --git a/.jjconflict-base-1/playwright/integration-tests/user-flow.spec.ts b/.jjconflict-base-1/playwright/integration-tests/user-flow.spec.ts new file mode 100644 index 0000000000..a27b10139b --- /dev/null +++ b/.jjconflict-base-1/playwright/integration-tests/user-flow.spec.ts @@ -0,0 +1,390 @@ +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, secondTestUser, signInCloudPiNative, testUser } from '../config/console' +import { pollingFastIntervalMs, projectReadinessTimeoutMs, repoSyncTimeoutMs } from '../helpers/constants' +import { createProject, deleteProject } from '../helpers/project' +import { createRepository } from '../helpers/repository' + +const projectsToDelete: string[] = [] +const projectName = 'socleprojecttest' +const repositoryName = 'socle-project-test' +const destinationCluster = process.env.CONSOLE_DESTINATION_CLUSTER || 'cpin-app-hp' +const helmValuesFiles = process.env.CONSOLE_VALUES_FILE || 'values-cpin-hp.yaml' +projectsToDelete.push(projectName) + +test.describe('Integration tests user flow: project creation', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Preliminary checks', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + // Check all services are healthy + await page.getByTestId('menuServicesHealth').click() + await expect(page.getByTestId('ArgoCD-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Gitlab-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Harbor-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Keycloak-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Nexus-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('SonarQube-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Vault-info').getByText('OK')).toBeVisible() + await expect(page.getByText('Tous les services')).toBeVisible() + // Check maintenance mode is disabled + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationSystemSettings').click() + await expect(page.getByText('Le mode Maintenance est')).not.toBeVisible() + await expect(page.getByText('Désactiver le mode maintenance')).not.toBeVisible() + await expect(page.getByText('Activer le mode maintenance')).toBeVisible() + }) + + test('Project creation and configuration', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page, projectName }) + // Enable Nexus Maven plugin + await page.getByTestId('test-tab-services').click() + await page.getByRole('button', { name: 'Nexus' }).click() + await page + .getByRole('group', { name: 'Activer le dépôt Maven' }) + .locator('input[value="enabled"] + label') + .click() + await page.getByTestId('saveBtn').click() + await expect(page.getByText('Paramètres sauvegardés')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Check if project reprovisioning is successful + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + await expect(page.getByText('Le projet a été reprovisionn')).toBeVisible() + // Add repository to project + await page.getByTestId('test-tab-resources').click() + await createRepository({ + page, + repositoryName, + externalRepoUrlInput: 'https://github.com/cloud-pi-native/socle-project-test.git', + infraRepo: true, + }) + await page.getByRole('cell', { name: repositoryName }).click() + await page.getByTestId('deployRevisionInput').fill('main') + await page.getByTestId('deployPathInput').fill('helm/') + await page.getByTestId('helmValuesFilesTextarea').fill(helmValuesFiles) + await page.getByTestId('updateRepoBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: first checks', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Check Vault kv', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Vault' }).click() + const page1 = await page1Promise + const page2Promise = page1.waitForEvent('popup') + await page1.getByRole('button', { name: 'Sign in with OIDC Provider' }).click() + await page2Promise + await expect(page1.getByRole('link', { name: 'Vault home' })).toBeVisible() + // Check that standard user has access to his project kv + await expect(page1.getByText('No secrets yet')).toBeVisible() + await expect(page1.getByRole('link', { name: 'Create secret' })).toBeVisible() + await page1.getByLabel('breadcrumbs').getByRole('link', { name: 'Secrets' }).click() + // Check that forge-dso kv is not accessible for standard user + await expect(page1.getByRole('link', { name: 'forge-dso' })).not.toBeVisible() + }) + + test('Project permissions', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Add user to project + await page.getByTestId('test-tab-team').click() + await page.getByTestId('addUserSuggestionInput').locator('input').fill(secondTestUser.email) + await page.getByTestId('addUserBtn').click() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Create read-only role + await page.getByTestId('test-tab-roles').click() + await page.getByTestId('addRoleBtn').click() + await page.getByTestId('roleNameInput').fill('readOnly') + await page.getByText('Afficher les secrets Permet d').click() + await page.getByText('Voir les environnements').click() + await page.getByText('Voir les dépôts Permet de').click() + await page.getByTestId('saveBtn').click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Add user to read-only role + await page.getByTestId('test-members').click() + await page.getByLabel('Rôles', { exact: true }).getByText(secondTestUser.email).click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Replay project hooks + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + await expect(page.getByText('Le projet a été reprovisionn')).toBeVisible() + }) + + test('Pipelines run', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if mirror pipeline is successful + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const page1 = await page1Promise + await page1.getByTestId('group-name').filter({ hasText: 'mirror' }).click() + await expect(page1.getByTestId('status_success_borderless-icon')).toBeVisible() + // Check if tests are successful + await page1.getByRole('link', { name: projectName }).click() + await page1.getByTestId('group-name').filter({ hasText: repositoryName }).click() + await page1.getByTestId('ci-icon').click() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-vault' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-nexus' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-harbor' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-sonar' }), + ).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: after pipelines checks', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Prepare ArgoCD deployment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Create environment for project to trigger ArgoCD deployment + await page.getByTestId('test-tab-resources').click() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').click() + await page.getByTestId('environmentNameInput').fill('integ') + await page.getByLabel('Zone *Choix de la zone cible').selectOption({ label: 'DSO' }) + await page.getByLabel('Type d\'environnement *Type d\'').selectOption({ label: 'dev' }) + await page.getByLabel('Cluster *Choix du cluster').selectOption({ label: destinationCluster }) + await page.getByTestId('memoryInput').fill('2') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + }) + + test('View Sonar scan report', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if sonar scan is available + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'SonarQube' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'OpenID Connect Log in with' }).click() + await page1.getByPlaceholder('Search for projects...').fill(projectName) + await page1.getByRole('link', { name: `${projectName}-${repositoryName}` }).click() + await expect( + page1.getByTestId('overview__quality-gate-panel').getByText('Passed', { exact: true }), + ).toBeVisible() + }) + + test('Check Harbor repository', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Harbor' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'LOGIN WITH keycloak' }).click() + await expect(page1.getByRole('button', { name: 'Administration' })).not.toBeVisible() + await expect(page1.getByText('Guest')).toBeVisible() + await expect(page1.getByRole('heading', { name: 'Private' })).toBeVisible() + await page1.getByRole('link', { name: `${projectName}/java-demo` }).click() + await expect(page1.getByRole('button', { name: 'main' })).toBeVisible() + await expect(page1.getByText('Policy')).not.toBeVisible() + // Check trivy scan result, hopefully will stay at C + await expect(page1.getByRole('button', { name: 'C', exact: true })).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: deployment and metrics', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('ArgoCD deployment', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if ArgoCD deployment is successful + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await page1.getByText('app.kubernetes.io/managed-by=dso-console, dso/environment=integ, dso/').click() + await expect(page1.getByText('Synced').nth(1)).toBeVisible() + await expect(page1.getByText('Sync OK')).toBeVisible() + await expect(page1.getByText('Healthy').nth(1)).toBeVisible() + await page1.getByRole('button', { name: ' Details' }).click() + const page2Promise = page1.waitForEvent('popup') + await page1.getByRole('link', { name: `http://${repositoryName}.` }).click() + const page2 = await page2Promise + await expect + .poll( + async () => { + await page2.reload({ waitUntil: 'domcontentloaded' }) + return page2.locator('body').textContent() + }, + { + timeout: repoSyncTimeoutMs, + intervals: [pollingFastIntervalMs], + }, + ) + .toContain('Application is running') + }) + + test('Check Grafana', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Grafana' }).click() + const page1 = await page1Promise + const signInLink = page1.getByRole('link', { name: 'Sign in with grafana-projects' }) + await expect(signInLink).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await signInLink.click({ timeout: projectReadinessTimeoutMs }) + await expect(page1.getByRole('link', { name: 'Grafana', exact: true })).toBeVisible() + await page1.getByTestId('data-testid Toggle menu').click() + await page1.getByRole('button', { name: 'Expand section Dashboards' }).click() + await page1.getByRole('link', { name: 'Dashboards', exact: true }).click() + const dsoDashboard = page1.getByRole('link', { name: 'dso-grafana' }) + await expect(dsoDashboard).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await dsoDashboard.click({ timeout: projectReadinessTimeoutMs }) + // Check if we can see some metrics + await page1.getByRole('link', { name: 'Kubernetes / Views /' }).click() + await expect(page1.getByText('0.100')).toBeVisible() // Cpu request + await expect(page1.getByText('0.500')).toBeVisible() // Cpu limit + await expect(page1.getByText('256')).toBeVisible() // Memory request + await expect(page1.getByText('512')).toBeVisible() // Memory limit + // Check if we can see some logs + await page1.getByTestId('data-testid dso-grafana breadcrumb').click() + await page1.getByRole('link', { name: 'Loki Kubernetes Logs' }).click() + await expect(page1.getByTestId('data-testid Panel status error').first()).not.toBeVisible() + await expect(page1.locator('.rc-drawer-mask')).not.toBeVisible() + await page1.getByTestId('data-testid TimePicker Open Button').click() + await page1.getByText('Last 1 hour').click() + await page1.locator('.css-13x53bc-Icon-topVerticalAlign').first().click() + await expect(page1.getByRole('cell', { name: 'app_kubernetes_io_name' }).nth(1)).toBeVisible() + await expect(page1.getByText('demo-java-helm').nth(1)).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: Cleanup', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Remove permissions and user', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Remove role membership + await page.getByTestId('test-tab-roles').click() + await page.getByRole('button', { name: 'readOnly' }).click() + await page.getByTestId('test-members').click() + await page.getByLabel('Rôles', { exact: true }).getByText(secondTestUser.email).click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Remove role + await page.getByTestId('test-general').click() + await page.getByTestId('deleteBtn').click() + await page.getByText('Rôle supprimé').click() + // Remove project membership + await page.getByTestId('test-tab-team').click() + await page.getByTitle(`Retirer ${secondTestUser.email} du`).click() + }) + + test('Remove stage', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByRole('cell', { name: 'integ' }).click() + await page.getByTestId('showDeleteEnvironmentBtn').click() + await page.getByTestId('deleteEnvironmentInput').fill('DELETE') + await page.getByTestId('deleteEnvironmentBtn').click() + await page.getByRole('heading', { name: 'Opération en cours...' }).click() + await expect( + page.getByRole('cell', { name: 'Aucun environnement existant' }), + ).toBeVisible() + // ArgoCD deployment will be deleted when stage is deleted + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await expect( + page1.getByRole('combobox', { name: 'Search applications...' }), + ).toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-integ-socle-` }), + ).not.toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-cpin-app-hp-integ-root` }), + ).not.toBeVisible() + }) + + // This check will forever fail until we fix + // https://github.com/cloud-pi-native/console/issues/1853 + test('Check Argocd root application deletion', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Tous') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await page.getByRole('cell', { name: projectName }).first().click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await expect( + page1.getByRole('combobox', { name: 'Search applications...' }), + ).toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-cpin-app-hp-integ-root` }), + ).not.toBeVisible() + }) + + test('Remove repository from project', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-resources').click() + await page.getByRole('cell', { name: repositoryName }).click() + await page.getByTestId('showDeleteRepoBtn').click() + await page.getByTestId('deleteRepoInput').fill('DELETE') + await page.getByTestId('deleteRepoBtn').click() + await page.getByRole('heading', { name: 'Opération en cours...' }).click() + await expect(page.getByRole('cell', { name: 'Aucun dépôt existant' })).toBeVisible() + for (const projectName of projectsToDelete) { + await deleteProject({ page, projectName }) + } + }) +}) diff --git a/.jjconflict-base-1/playwright/package.json b/.jjconflict-base-1/playwright/package.json new file mode 100644 index 0000000000..d4b0f175ee --- /dev/null +++ b/.jjconflict-base-1/playwright/package.json @@ -0,0 +1,27 @@ +{ + "name": "@cpn-console/playwright", + "version": "2.0.0", + "private": true, + "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26", + "description": "Our Playwright automated test suite", + "keywords": [ + "playwright", + "tests", + "e2e" + ], + "main": "playwright.config.ts", + "scripts": { + "playwright:test": "pnpm exec playwright test", + "playwright:test:ui": "pnpm exec playwright test --ui", + "integ:playwright:test": "INTEGRATION=true pnpm exec playwright test", + "integ:playwright:test:ui": "INTEGRATION=true pnpm exec playwright test --ui", + "format": "eslint ./ --fix" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@playwright/test": "catalog:test", + "@types/node": "catalog:types" + } +} diff --git a/.jjconflict-base-1/playwright/playwright.config.ts b/.jjconflict-base-1/playwright/playwright.config.ts new file mode 100644 index 0000000000..06a9545f6a --- /dev/null +++ b/.jjconflict-base-1/playwright/playwright.config.ts @@ -0,0 +1,58 @@ +import path from 'node:path' +import { defineConfig, devices } from '@playwright/test' + +process.loadEnvFile(path.resolve(__dirname, '..', 'apps/client', '.env.docker')) + +const isIntegration = process.env.INTEGRATION === 'true' + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: isIntegration ? './integration-tests' : './e2e-tests', + + /* Run tests in files in parallel */ + fullyParallel: true, + + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + + retries: 3, + + workers: process.env.CI ? 1 : undefined, // Default is 50% logical cores + + // The maximum number of test failures forthe whole test suite run. + // After reaching this number, testing will stop and exit with an error. + // Setting to zero (default) disables this behavior. + maxFailures: 1, + + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Retain trace on failed tries. See https://playwright.dev/docs/trace-viewer */ + trace: 'retain-on-failure', + }, + + /* All timeouts are in milliseconds */ + // Timeout for each and every `test` block + timeout: Number(process.env.CONSOLE_GLOBAL_TIMEOUT) || 30_000, + + // Timeout for each and every `expect` command + expect: { + timeout: Number(process.env.CONSOLE_EXPECT_TIMEOUT) || 10_000, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + ], +}) diff --git a/.jjconflict-base-1/playwright/tsconfig.json b/.jjconflict-base-1/playwright/tsconfig.json new file mode 100644 index 0000000000..f9908dab99 --- /dev/null +++ b/.jjconflict-base-1/playwright/tsconfig.json @@ -0,0 +1,41 @@ +{ + "extends": ["@cpn-console/ts-config/tsconfig.base.json"], + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + + "jsx": "preserve", + "lib": ["DOM", "ESNext"], + // Base + "baseUrl": "./", + "rootDir": "./", + "module": "Preserve", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "types": ["node"], + "allowImportingTsExtensions": false, + "allowJs": true, + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useUnknownInCatchVariables": true, + "declaration": true, + "declarationDir": "./types", + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "./dist", + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["./playwright.config.ts", "./config/**/*.ts", "./e2e-tests/**/*.ts", "./helpers/**/*.ts", "./integration-tests/**/*.ts"], + "exclude": [] +} diff --git a/.jjconflict-base-1/plugins/argocd/.gitignore b/.jjconflict-base-1/plugins/argocd/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-1/plugins/argocd/README.md b/.jjconflict-base-1/plugins/argocd/README.md new file mode 100644 index 0000000000..2fe1f154d8 --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/README.md @@ -0,0 +1,200 @@ +# Plugin ArgoCD + +Ce document décrit le fonctionnement du plugin ArgoCD. + +## Hooks et Steps supportés + +Le seul step supporté est pour l'instant le step `main`. Ceci est applicable à tous les hooks de ce plugin. + +## upsertProject hook + +Voici le diagramme de séquence du hook le plus important du plugin (`upsert`) : + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + end + + box Project Cluster + participant ArgoCD_EXT as ArgoCD + participant Kubernetes_EXT as Kubernetes + end + + Bob ->> Console: "Please upsert project" + + loop For every zone/environment + Console ->> Gitlab: Reconcile (create/update/delete) values.yaml files + ArgoCD_EXT ->> Gitlab: Detects new/updated values.yaml files + ArgoCD_EXT ->> Kubernetes_EXT: Apply changes + end +``` + +Les clusters sont dits "externes" car ils sont hors du périmètre de DSO, et gérés de manière autonome par les projets clients, ce qui est une contrainte majeur : on ne peut pas interagir avec l'API Kubernetes (le cluster est considéré comme "air gapped", c'est-à-dire injoignable de l'extérieur, *a fortiori* depuis la Console). C'est en particulier le cas pour les clusters qui se trouvent dans une Zone DR ("Diffusion Restreinte"). + +C'est pourquoi la Console fonctionne avec son paradigme dans lequel c'est le cluster externe qui **vient récupérer les informations pour se mettre à jour**. Et la manière la plus efficace de faire ça, c'est de fonctionner en mode "GitOps", un mode dans lequel la Console crée un fichier de configuration (typiquement un `values.yaml`) qui serait récupéré (d'où le nom de "pull", en opposition au "push" traditionnel dans lequel on va utiliser les API Kubernetes) par le cluster externe afin d'être traité par l'ArgoCD qui lui est rattaché. + +## deleteProject hook + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + participant ArgoCD_DSO as ArgoCD + participant Kubernetes_DSO as Kubernetes + end + + box Project Cluster + participant ArgoCD_EXT as ArgoCD + participant Kubernetes_EXT as Kubernetes + end + + Bob ->> Console: "Please delete project" + + loop For every zone/environment + Console ->> Gitlab: Delete values.yaml files + ArgoCD_EXT ->> Gitlab: Detects deleted values.yaml files + ArgoCD_EXT ->> Kubernetes_EXT: Apply relevant changes (delete resources) + end +``` + +## upsertCluster hook + +Ce hook sert à la création/modification d'un cluster Kubernetes. +Il est à noter la distinction des clusters étiquetés `in-cluster` pour lesquels la gestion des secrets est différente (c'est une exception à la règle). + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please upsert cluster" + + alt cluster has "in-cluster" label + Console ->> Kubernetes: Delete cluster Secret + else cluster DOES NOT HAVE "in-cluster" label + Console ->> Kubernetes: Create cluster Secret + end + + Console ->> Vault: Create entry for cluster data +``` + +## deleteCluster hook + +Ce hook sert à la "suppression" d'un cluster Kubernetes. +Il est à noter que le cluster n'est pas vraiment supprimé, mais "oublié" par la Console, qui n'en garde plus trace. + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please delete cluster" + + Console ->> Kubernetes: Delete cluster Secret + Console ->> Vault: Destroy entry for cluster data +``` + +## upsertZone hook + +Ce hook sert à la création/modification d'une Zone (qui contiendra donc un ou plusieurs clusters Kubernetes) + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please upsert zone" + + Console ->> Vault: Get credentials + Console ->> Gitlab: Create infrastructure project for the zone if needed + Console ->> Gitlab: Create argocd-values.yaml file +``` + +## Deployment as-code (declarative mode) + +```mermaid +--- +config: + layout: default + theme: base + flowchart: + curve: linear + defaultRenderer: "elk" +--- +flowchart TD + subgraph "Helm-charts" + H1@{ shape: doc, label: "Chart 'dso-argocd-zone'" } + H2@{ shape: doc, label: "Chart 'dso-env'" } + H3@{ shape: doc, label: "Chart 'dso-ns'" } + end + subgraph "Gitlab DSO" + subgraph "zone repository" + V1@{ shape: doc, label: "/argocd-values.yaml" } + V2@{ shape: docs, label: "**/values.yaml" } + end + subgraph "project repositories" + V3@{ shape: docs, label: "infra sources" } + end + end + subgraph "DSO Cluster" + A1[Application zone-#lt;name>-app] + A1 --Deploy--> A3[ApplicationSet dso-appset] + A1 --Deploy--> S1[Cluster secrets] + V2 --Generator--> A3 + A3 --Generates--> A4[Application *-root] + V2 --> A4 + H2 -.-> A4 + A4 --Deploys--> A7[Application #lt;project>-env] + A4 --Deploys--> A8@{ shape: procs, label: "Application #lt;project>-#lt;env>-#lt;random-id>" } + + end + S1 --> apps + V1 --> A1 + H1 -.-> A1 + V3 ==> A8 + H3 -.-> A7 + V2 --> A7 + A7 --Deploys--> namespace & R1 & R2 & R3 + A8 ==Deploys==> A10 + A8 --Deploys--> A11 + subgraph apps["Apps Clusters"] + subgraph namespace["Namespace"] + A10@{ shape: procs, label: "K8S resources" } + A11@{ shape: procs, label: "VaultStaticSecrets" } + R1[ResourceQuota] + R2[VaultConnection] + R3[VaultStaticSecret registry-pull-secret] + R3 --> R4[Secret registry-pull-secret] + A11 --> R5@{ shape: procs, label: "Secrets" } + end + end + subgraph "Vault DSO" + kv1["KV Infra"] + kv2@{ shape: procs, label: "KV Projets" } + end + kv1 --> R3 + kv2 --> A11 +``` diff --git a/.jjconflict-base-1/plugins/argocd/eslint.config.js b/.jjconflict-base-1/plugins/argocd/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-1/plugins/argocd/package.json b/.jjconflict-base-1/plugins/argocd/package.json new file mode 100644 index 0000000000..042fad6e4d --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/package.json @@ -0,0 +1,53 @@ +{ + "name": "@cpn-console/argocd-plugin", + "type": "module", + "version": "2.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@himenon/argocd-typescript-openapi": "catalog:runtime", + "axios": "catalog:runtime", + "yaml": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/plugins/argocd/src/cluster.ts b/.jjconflict-base-1/plugins/argocd/src/cluster.ts new file mode 100644 index 0000000000..efdb6de462 --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/src/cluster.ts @@ -0,0 +1,69 @@ +import type { ClusterObject, StepCall } from '@cpn-console/hooks' +import { updateZoneValues } from './utils.js' + +export const upsertCluster: StepCall = async (payload) => { + try { + const cluster = payload.args + const { vault } = payload.apis + const clusterData = { + name: cluster.label, + clusterResources: cluster.clusterResources.toString(), + server: cluster.cluster.server, + config: JSON.stringify(convertConfig(cluster)), + } + await vault.upsert() + await vault.write(clusterData, `clusters/cluster-${cluster.label}/argocd-cluster-secret`) + await updateZoneValues(cluster.zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Cluster secret created/updated', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed create/update cluster secret', + }, + } + } +} + +export const deleteCluster: StepCall = async (payload) => { + try { + await payload.apis.vault.destroy(`clusters/cluster-${payload.args.label}/argocd-cluster-secret`) + await updateZoneValues(payload.args.zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Cluster secret deleted', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed to delete cluster secret', + }, + } + } +} + +// ...désolé +function convertConfig(cluster: ClusterObject) { + return { + ...cluster.user?.username && { username: cluster.user?.username }, + ...cluster.user?.password && { password: cluster.user?.password }, + ...cluster.user?.token && { bearerToken: cluster.user?.token }, + tlsClientConfig: { + ...cluster.user?.keyData && { keyData: cluster.user?.keyData }, + ...cluster.user?.certData && { certData: cluster.user?.certData }, + ...(cluster.cluster?.caData && !cluster.cluster?.skipTLSVerify) && { caData: cluster.cluster?.caData }, + ...cluster.cluster?.skipTLSVerify && { insecure: cluster.cluster.skipTLSVerify }, + serverName: cluster.cluster.tlsServerName, + }, + } +} diff --git a/.jjconflict-base-1/plugins/argocd/src/env.d.ts b/.jjconflict-base-1/plugins/argocd/src/env.d.ts new file mode 100644 index 0000000000..58ef50fd4c --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/src/env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/.jjconflict-base-1/plugins/argocd/src/functions.spec.ts b/.jjconflict-base-1/plugins/argocd/src/functions.spec.ts new file mode 100644 index 0000000000..2a036bc0c4 --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/src/functions.spec.ts @@ -0,0 +1,212 @@ +import type { ClusterObject, Environment, Project, Repository } from '@cpn-console/hooks' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { stringify } from 'yaml' +import { deleteProject, upsertProject } from './functions.js' + +vi.mock('./utils.js', () => ({ + generateAppProjectName: vi.fn(() => 'app-project-name'), + getConfig: vi.fn(() => ({ namespace: 'argocd', url: 'https://argocd.example.com' })), +})) + +vi.mock('@cpn-console/shared', async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + generateNamespaceName: vi.fn(() => 'namespace-name'), + } +}) + +describe('argocd functions', () => { + const mockGitlabApi = { + // Used to get the public group URL for sourceRepositories + getPublicGroupUrl: vi.fn(), + // Used to get or create the infra project where values.yaml is stored + getOrCreateInfraProject: vi.fn(), + // Used to get the infra project details (repo URL) + getProjectById: vi.fn(), + // Used to resolve the public URL of a repository + getPublicRepoUrl: vi.fn(), + // Used to commit the values.yaml file + commitCreateOrUpdate: vi.fn(), + // Used to list files in the infra project for cleanup + listFiles: vi.fn(), + // Used to delete files from the infra project + commitDelete: vi.fn(), + } + // Used to get the RO and RW groups for the environment + const mockKeycloakApi = { + getEnvGroup: vi.fn(), + } + // Used to get user vault secrets + const mockVaultApi = { + Project: { + getValues: vi.fn(), + }, + } + + let mockCluster: ClusterObject + let mockEnvironment: Environment + let mockRepo: Repository + let mockProject: Project + + beforeEach(() => { + vi.clearAllMocks() + + mockCluster = { + id: faker.string.uuid(), + label: faker.string.alphanumeric(10), + zone: { slug: faker.string.alphanumeric(5) }, + privacy: 'public', + } as any + + mockEnvironment = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(5), + clusterId: mockCluster.id, + cpu: faker.number.int({ min: 1, max: 10 }), + memory: faker.number.int({ min: 1, max: 32 }), + gpu: 0, + autosync: true, + } as any + + mockRepo = { + id: faker.string.uuid(), + internalRepoName: faker.string.alphanumeric(10), + isInfra: true, + helmValuesFiles: null, + deployRevision: 'main', + deployPath: '/deploy', + } as any + + mockProject = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(10), + slug: faker.string.alphanumeric(10), + repositories: [mockRepo], + environments: [mockEnvironment], + clusters: [mockCluster], + store: {}, + } as any + }) + + it('upsertProject should succeed', async () => { + const infraProjectId = faker.number.int() + const infraProjectUrl = faker.internet.url() + const gitlabGroupUrl = faker.internet.url() + const gitlabRepoUrl = faker.internet.url() + + mockGitlabApi.getPublicGroupUrl.mockResolvedValue(gitlabGroupUrl) + mockGitlabApi.getOrCreateInfraProject.mockResolvedValue({ id: infraProjectId, http_url_to_repo: infraProjectUrl }) + mockKeycloakApi.getEnvGroup.mockResolvedValue({ subgroups: { RO: '/ro', RW: '/rw' } }) + mockVaultApi.Project.getValues.mockResolvedValue({ secret: 'value' }) + mockGitlabApi.getProjectById.mockResolvedValue({ http_url_to_repo: infraProjectUrl }) + mockGitlabApi.listFiles.mockResolvedValue([]) // No files to delete + mockGitlabApi.getPublicRepoUrl.mockResolvedValue(gitlabRepoUrl) + + const payload = { + args: mockProject, + apis: { + gitlab: mockGitlabApi, + keycloak: mockKeycloakApi, + vault: mockVaultApi, + }, + config: {}, + } as any + + const result = await upsertProject(payload) + + expect(result.status.result).toBe('OK') + + const expectedValues = { + common: { + 'dso/project': mockProject.name, + 'dso/project.id': mockProject.id, + 'dso/project.slug': mockProject.slug, + 'dso/environment': mockEnvironment.name, + 'dso/environment.id': mockEnvironment.id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'app-project-name', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: infraProjectUrl, + valueFileRevision: 'HEAD', + valueFilePath: `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, + roGroup: '/ro', + rwGroup: '/rw', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + projectAdminGroup: `/${mockProject.slug}/console/admin`, + projectDevopsGroup: `/${mockProject.slug}/console/devops`, + projectDevelopperGroup: `/${mockProject.slug}/console/developer`, + projectReadonlyGroup: `/${mockProject.slug}/console/readonly`, + }, + application: { + quota: { + cpu: mockEnvironment.cpu, + gpu: mockEnvironment.gpu, + memory: `${mockEnvironment.memory}Gi`, + }, + sourceRepositories: [ + `${gitlabGroupUrl}/**`, + ], + destination: { + namespace: 'namespace-name', + name: mockCluster.label, + }, + autosync: true, + vault: { secret: 'value' }, + repositories: [ + { + id: mockRepo.id, + name: mockRepo.internalRepoName, + repoURL: gitlabRepoUrl, + targetRevision: 'main', + path: '/deploy', + valueFiles: [], + }, + ], + }, + } + + expect(mockGitlabApi.commitCreateOrUpdate).toHaveBeenCalledWith( + infraProjectId, + stringify(expectedValues), + `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, + ) + expect(mockGitlabApi.commitDelete).toHaveBeenCalledWith(infraProjectId, []) + }) + + it('deleteProject should succeed', async () => { + const infraProjectId = faker.number.int() + mockGitlabApi.getOrCreateInfraProject.mockResolvedValue({ id: infraProjectId }) + mockGitlabApi.listFiles.mockResolvedValue([ + { name: 'values.yaml', path: `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, type: 'blob' }, + { name: 'values.yaml', path: `${mockProject.name}/${mockCluster.label}/old-env/values.yaml`, type: 'blob' }, + ]) + + const payload = { + args: mockProject, + apis: { + gitlab: mockGitlabApi, + }, + } as any + + const result = await deleteProject(payload) + + expect(result.status.result).toBe('OK') + expect(mockGitlabApi.listFiles).toHaveBeenCalledWith(infraProjectId, { + path: `${mockProject.name}/`, + recursive: true, + }) + // Only the stale env file should be deleted (not in project.environments) + expect(mockGitlabApi.commitDelete).toHaveBeenCalledWith(infraProjectId, [ + `${mockProject.name}/${mockCluster.label}/old-env/values.yaml`, + ]) + }) +}) diff --git a/.jjconflict-base-1/plugins/argocd/src/functions.ts b/.jjconflict-base-1/plugins/argocd/src/functions.ts new file mode 100644 index 0000000000..7c1ccbc139 --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/src/functions.ts @@ -0,0 +1,281 @@ +import type { GitlabProjectApi } from '@cpn-console/gitlab-plugin/types/class.js' +import type { + ClusterObject, + Config, + Environment, + Project, + Repository, + StepCall, +} from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' +import { stringify } from 'yaml' +import { + DEFAULT_DSO_ENV_CHART_VERSION, + DEFAULT_DSO_NS_CHART_VERSION, + DEFAULT_PLATFORM_ADMIN_GROUP_PATH, + DEFAULT_PLATFORM_READONLY_GROUP_PATH, + DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX, +} from './infos.js' +import { logger } from './logger.js' +import { generateAppProjectName, getConfig } from './utils.js' + +function splitExtraRepositories(repos?: string): string[] { + return repos ? repos.split(',').map(repo => repo.trim()) : [] +} + +function getValueFilePath(p: Project, c: ClusterObject, e: Environment): string { + return `${p.name}/${c.label}/${e.name}/values.yaml` +} + +export const upsertProject: StepCall = async (payload) => { + const project = payload.args + const gitlabApi = payload.apis.gitlab as unknown as GitlabProjectApi + const keycloakApi = payload.apis.keycloak as any + const vaultApi = payload.apis.vault as unknown as VaultProjectApi + + try { + const infraRepositories = project.repositories.filter( + repo => repo.isInfra, + ) + const sourceRepositories = [ + `${await gitlabApi.getPublicGroupUrl()}/**`, + ...splitExtraRepositories(payload.config.argocd?.extraRepositories), + ...splitExtraRepositories(project.store.argocd?.extraRepositories), + ] + + await Promise.all( + project.environments.map(async (environment) => { + const appNamespace = generateNamespaceName(project.id, environment.id) + const cluster = getCluster(project, environment) + const infraProject = await gitlabApi.getOrCreateInfraProject(cluster.zone.slug) + const appProjectName = generateAppProjectName(project.slug, environment.name) + const envGroup = await (keycloakApi as any).getEnvGroup(environment.name) + const roGroup = envGroup.subgroups.RO + const rwGroup = envGroup.subgroups.RW + + await ensureInfraEnvValues( + project, + environment, + appNamespace, + roGroup, + rwGroup, + appProjectName, + infraRepositories, + infraProject.id, + sourceRepositories, + gitlabApi, + vaultApi, + payload.config, + ) + }), + ) + + await removeInfraEnvValues(project, gitlabApi) + + logger.info({ action: 'upsertProject', projectSlug: project.slug }, 'Hook done') + + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +interface ArgoRepoSource { + repoURL: string + targetRevision: string + path: string + valueFiles: string[] +} + +async function ensureInfraEnvValues( + project: Project, + environment: Environment, + appNamespace: string, + roGroup: string, + rwGroup: string, + appProjectName: string, + infraRepositories: Repository[], + repoId: number, + sourceRepositories: string[], + gitlabApi: GitlabProjectApi, + vaultApi: VaultProjectApi, + config: Config, +) { + const platformAdminGroupPath = config.argocd?.platformAdminGroupPath ?? DEFAULT_PLATFORM_ADMIN_GROUP_PATH + const platformReadonlyGroupPath = config.argocd?.platformReadonlyGroupPath ?? DEFAULT_PLATFORM_READONLY_GROUP_PATH + const projectAdminGroupSuffix = config.argocd?.projectAdminGroupPathSuffix ?? DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX + const projectDevopsGroupSuffix = config.argocd?.projectDevopsGroupPathSuffix ?? DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX + const projectDevelopperGroupSuffix = config.argocd?.projectDevelopperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + const projectReadonlyGroupSuffix = config.argocd?.projectReadonlyGroupPathSuffix ?? DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX + const dsoEnvChartVersion = config.argocd?.dsoEnvChartVersion ?? DEFAULT_DSO_ENV_CHART_VERSION + const dsoNsChartVersion = config.argocd?.dsoNsChartVersion ?? DEFAULT_DSO_NS_CHART_VERSION + const cluster = getCluster(project, environment) + const infraProject = await gitlabApi.getProjectById(repoId) + const valueFilePath = getValueFilePath(project, cluster, environment) + const vaultValues = await vaultApi.Project.getValues() + const repositories: ArgoRepoSource[] = await Promise.all( + infraRepositories.map(async (repository) => { + const repoURL = await gitlabApi.getPublicRepoUrl( + repository.internalRepoName, + ) + const valueFiles = repository.helmValuesFiles + ? repository.helmValuesFiles + .replaceAll('', environment.name) + .split(',') + : [] + return { + id: repository.id, + name: repository.internalRepoName, + repoURL, + targetRevision: repository.deployRevision || 'HEAD', + path: repository.deployPath || '.', + valueFiles, + } + }), + ) + + const values = { + common: { + 'dso/project': project.name, + 'dso/project.id': project.id, + 'dso/project.slug': project.slug, + 'dso/environment': environment.name, + 'dso/environment.id': environment.id, + }, + argocd: { + cluster: inClusterLabel, + namespace: getConfig().namespace, + project: appProjectName, + envChartVersion: dsoEnvChartVersion, + nsChartVersion: dsoNsChartVersion, + }, + environment: { + valueFileRepository: infraProject.http_url_to_repo, + valueFileRevision: 'HEAD', + valueFilePath, + roGroup, + rwGroup, + platformAdminGroup: platformAdminGroupPath, + platformReadonlyGroup: platformReadonlyGroupPath, + projectAdminGroup: `/${project.slug}${projectAdminGroupSuffix}`, + projectDevopsGroup: `/${project.slug}${projectDevopsGroupSuffix}`, + projectDevelopperGroup: `/${project.slug}${projectDevelopperGroupSuffix}`, + projectReadonlyGroup: `/${project.slug}${projectReadonlyGroupSuffix}`, + }, + application: { + quota: { + cpu: environment.cpu, + gpu: environment.gpu, + memory: `${environment.memory}Gi`, + }, + sourceRepositories, + destination: { + namespace: appNamespace, + name: cluster.label, + }, + autosync: environment.autosync, + vault: vaultValues, + repositories, + }, + } + + await gitlabApi.commitCreateOrUpdate(repoId, stringify(values), valueFilePath) +} + +function getCluster(p: Project, e: Environment): ClusterObject { + const c = p.clusters.find(c => c.id === e.clusterId) + if (!c) throw new Error(`Unable to find cluster ${e.id} for env ${e.name}`) + return c +} + +async function removeInfraEnvValues( + project: Project, + gitlabApi: GitlabProjectApi, +) { + for (const z of getDistinctZones(project)) { + const infraProject = await gitlabApi.getOrCreateInfraProject(z) + const existingFiles = await gitlabApi.listFiles(infraProject.id, { + path: `${project.name}/`, + recursive: true, + }) + const neededFiles = project.environments.map(env => + getValueFilePath(project, getCluster(project, env), env), + ) + const filesToDelete: string[] = [] + for (const existingFile of existingFiles) { + if ( + existingFile.name === 'values.yaml' + && !neededFiles.includes(existingFile.path) + ) { + filesToDelete.push(existingFile.path) + } + } + await gitlabApi.commitDelete(infraProject.id, filesToDelete) + } +} + +function getDistinctZones(project: Project) { + const zones: Set = new Set() + project.clusters.forEach(c => zones.add(c.zone.slug)) + return zones +} + +async function cleanupProjectInfra( + project: Project, + gitlabApi: GitlabProjectApi, +) { + for (const z of getDistinctZones(project)) { + const infraProject = await gitlabApi.getOrCreateInfraProject(z) + const existingFiles = await gitlabApi.listFiles(infraProject.id, { + path: `${project.name}/`, + recursive: true, + }) + const filesToDelete = existingFiles + .filter(f => f.name === 'values.yaml') + .map(f => f.path) + await gitlabApi.commitDelete(infraProject.id, filesToDelete) + } +} + +export const deleteProject: StepCall = async (payload) => { + const project = payload.args + const gitlabApi = payload.apis.gitlab as unknown as GitlabProjectApi + + try { + await cleanupProjectInfra(project, gitlabApi) + + logger.info({ action: 'deleteProject', projectSlug: project.slug }, 'Hook done') + + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'deleteProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} diff --git a/.jjconflict-base-1/plugins/argocd/src/index.ts b/.jjconflict-base-1/plugins/argocd/src/index.ts new file mode 100644 index 0000000000..5d7acfdda0 --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/src/index.ts @@ -0,0 +1,43 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import { deleteCluster, upsertCluster } from './cluster.js' +import { deleteProject, upsertProject } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' +import { upsertZone } from './zone.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { + steps: { + main: upsertProject, + }, + }, + deleteProject: { + steps: { + main: deleteProject, + }, + }, + upsertCluster: { + steps: { + main: upsertCluster, + }, + }, + deleteCluster: { + steps: { + main: deleteCluster, + }, + }, + upsertZone: { + steps: { + main: upsertZone, + }, + }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-base-1/plugins/argocd/src/infos.ts b/.jjconflict-base-1/plugins/argocd/src/infos.ts new file mode 100644 index 0000000000..71d672c5e1 --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/src/infos.ts @@ -0,0 +1,133 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const extraRepositoriesDesc = 'appproject.spec.sourceRepos supplémentaires, séparés par des virgules (https://a.com/repo.git,https://b.com/' + +export const DEFAULT_PLATFORM_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_PLATFORM_READONLY_GROUP_PATH = '/console/readonly' +export const DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX = '/console/readonly' +export const DEFAULT_DSO_ENV_CHART_VERSION = 'dso-env-1.6.0' +export const DEFAULT_DSO_NS_CHART_VERSION = 'dso-ns-1.1.5' + +const infos = { + name: 'argocd', + to: ({ zones, project }) => zones.map(z => ({ + to: `${z.argocdUrl}/applications?showFavorites=false&proj=&sync=&health=&namespace=&cluster=&labels=&search=${project.slug}`, + title: `ArgoCD ${z.label}`, + })), + title: 'ArgoCD', + imgSrc: '/img/argocd.svg', + description: 'ArgoCD est un outil déclaratif de livraison continue GitOps pour Kubernetes', + config: { + global: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }, { + key: 'platformAdminGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Admin Group Path', + value: DEFAULT_PLATFORM_ADMIN_GROUP_PATH, + description: 'Chemin du groupe administrateur de plateforme', + }, { + key: 'platformReadonlyGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Readonly Group Path', + value: DEFAULT_PLATFORM_READONLY_GROUP_PATH, + description: 'Chemin du groupe lecture seule de plateforme', + }, { + key: 'projectAdminGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Admin Group Path Suffix', + value: DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe administrateur de projet', + }, { + key: 'projectDevopsGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project DevOps Group Path Suffix', + value: DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe devops de projet', + }, { + key: 'projectDevelopperGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Developer Group Path Suffix', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe développeur de projet', + }, { + key: 'projectReadonlyGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Readonly Group Path Suffix', + value: DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe lecture seule de projet', + }, { + key: 'dsoEnvChartVersion', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Env Chart Version', + value: DEFAULT_DSO_ENV_CHART_VERSION, + description: 'Version du chart Helm dso-env', + placeholder: DEFAULT_DSO_ENV_CHART_VERSION, + }, { + key: 'dsoNsChartVersion', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Namespace Chart Version', + value: DEFAULT_DSO_NS_CHART_VERSION, + description: 'Version du chart Helm dso-ns', + placeholder: DEFAULT_DSO_NS_CHART_VERSION, + }], + project: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-base-1/plugins/argocd/src/logger.ts b/.jjconflict-base-1/plugins/argocd/src/logger.ts new file mode 100644 index 0000000000..45416d7025 --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'argocd' }) diff --git a/.jjconflict-base-1/plugins/argocd/src/monitor.ts b/.jjconflict-base-1/plugins/argocd/src/monitor.ts new file mode 100644 index 0000000000..132a2a4441 --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/src/monitor.ts @@ -0,0 +1,27 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(requiredEnv('ARGOCD_INTERNAL_URL'), { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-base-1/plugins/argocd/src/utils.ts b/.jjconflict-base-1/plugins/argocd/src/utils.ts new file mode 100644 index 0000000000..151f2389ef --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/src/utils.ts @@ -0,0 +1,51 @@ +import type { GitlabZoneApi } from '@cpn-console/gitlab-plugin/types/class.js' +import type { ClusterObject, HookPayloadApis, ZoneObject } from '@cpn-console/hooks' +import { createHmac } from 'node:crypto' +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import { stringify } from 'yaml' + +export function generateAppProjectName(projectSlug: string, env: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${envHash}` +} + +export function generateApplicationName(projectSlug: string, env: string, repo: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${repo}-${envHash}` +} + +const config: { + namespace?: string + url?: string +} = {} + +export function getConfig(): Required { + config.namespace = config.namespace ?? requiredEnv('ARGO_NAMESPACE') + config.url = removeTrailingSlash(requiredEnv('ARGOCD_URL')) + + // @ts-ignore + return config +} + +export async function updateZoneValues(zone: ZoneObject, apis: HookPayloadApis | HookPayloadApis) { + const gitlab = apis.gitlab as unknown as GitlabZoneApi + const vault = apis.vault as any + const values = { + vault: await vault.getValues(), + clusters: zone.clusterNames, + } + const zoneRepo = await gitlab.getOrCreateInfraProject(zone.slug) + await gitlab.commitCreateOrUpdate(zoneRepo.id, stringify(values), 'argocd-values.yaml') + return { + status: { + result: 'OK', + message: 'Zone argocd configuration created/updated', + }, + } +} diff --git a/.jjconflict-base-1/plugins/argocd/src/zone.ts b/.jjconflict-base-1/plugins/argocd/src/zone.ts new file mode 100644 index 0000000000..61e74e765a --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/src/zone.ts @@ -0,0 +1,23 @@ +import type { StepCall, ZoneObject } from '@cpn-console/hooks' +import { updateZoneValues } from './utils.js' + +export const upsertZone: StepCall = async (payload) => { + try { + const zone = payload.args + await updateZoneValues(zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Zone argocd configuration created/updated', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed create/update zone argocd configuration', + }, + } + } +} diff --git a/.jjconflict-base-1/plugins/argocd/tsconfig.eslint.json b/.jjconflict-base-1/plugins/argocd/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-1/plugins/argocd/tsconfig.json b/.jjconflict-base-1/plugins/argocd/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-1/plugins/argocd/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-1/plugins/gitlab/.gitignore b/.jjconflict-base-1/plugins/gitlab/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-1/plugins/gitlab/eslint.config.js b/.jjconflict-base-1/plugins/gitlab/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-1/plugins/gitlab/files/.gitlab-ci.yml b/.jjconflict-base-1/plugins/gitlab/files/.gitlab-ci.yml new file mode 100644 index 0000000000..ca9be29847 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/files/.gitlab-ci.yml @@ -0,0 +1,22 @@ +variables: + PROJECT_NAME: + description: Nom du dépôt (dans ce Gitlab) à synchroniser. + GIT_BRANCH_DEPLOY: + description: Nom de la branche à synchroniser. + value: main + SYNC_ALL: + description: Synchroniser toutes les branches. + value: "false" + +include: + - project: $CATALOG_PATH + file: mirror.yml + ref: main + +repo_pull_sync: + extends: .repo_pull_sync + only: + - api + - triggers + - web + - schedules diff --git a/.jjconflict-base-1/plugins/gitlab/files/mirror.sh b/.jjconflict-base-1/plugins/gitlab/files/mirror.sh new file mode 100644 index 0000000000..8f5fbdfe37 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/files/mirror.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' + +# Console step increment +i=1 + +# Default values +BRANCH_TO_SYNC=main + +print_help() { + TEXT_HELPER="\nThis script aims to send a synchronization request to DSO.\nFollowing flags are available: + -a Api url to send the synchronization request + -b Branch which is wanted to be synchronize for the given repository (default '$BRANCH_TO_SYNC') + -g GitLab token to trigger the pipeline on the gitlab mirror project + -i Gitlab mirror project id + -r Gitlab repository name to mirror + -h Print script help\n" + printf "$TEXT_HELPER" +} + +print_args() { + printf "\nArguments received: + -a API_URL: $API_URL + -b BRANCH_TO_SYNC: $BRANCH_TO_SYNC + -g GITLAB_TRIGGER_TOKEN length: ${#GITLAB_TRIGGER_TOKEN} + -i GITLAB_MIRROR_PROJECT_ID: $GITLAB_MIRROR_PROJECT_ID + -r REPOSITORY_NAME: $REPOSITORY_NAME\n" +} + +# Parse options +while getopts :ha:b:g:i:r: flag +do + case "${flag}" in + a) + API_URL=${OPTARG};; + b) + BRANCH_TO_SYNC=${OPTARG};; + g) + GITLAB_TRIGGER_TOKEN=${OPTARG};; + i) + GITLAB_MIRROR_PROJECT_ID=${OPTARG};; + r) + REPOSITORY_NAME=${OPTARG};; + h) + printf "\nHelp requested.\n" + print_help + printf "\nExiting.\n" + exit 0;; + *) + printf "\nInvalid argument ${OPTARG} (${flag}).\n" + print_help + print_args + exit 1;; + esac +done + +# Test if arguments are missing +if [ -z ${API_URL} ] || [ -z ${BRANCH_TO_SYNC} ] || [ -z ${GITLAB_TRIGGER_TOKEN} ] || [ -z ${GITLAB_MIRROR_PROJECT_ID} ] || [ -z ${REPOSITORY_NAME} ]; then + printf "\nArgument(s) missing!\n" + print_help + print_args + exit 2 +fi + +# Print arguments +print_args + +# Send synchronization request +printf "\n${red}${i}.${no_color} Send request to DSO api.\n\n" + +curl \ + -X POST \ + --fail \ + -F token=${GITLAB_TRIGGER_TOKEN} \ + -F ref=main \ + -F variables[GIT_BRANCH_DEPLOY]=${BRANCH_TO_SYNC} \ + -F variables[PROJECT_NAME]=${REPOSITORY_NAME} \ + "${API_URL}/api/v4/projects/${GITLAB_MIRROR_PROJECT_ID}/trigger/pipeline" diff --git a/.jjconflict-base-1/plugins/gitlab/package.json b/.jjconflict-base-1/plugins/gitlab/package.json new file mode 100644 index 0000000000..5640a675c7 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/package.json @@ -0,0 +1,52 @@ +{ + "name": "@cpn-console/gitlab-plugin", + "type": "module", + "version": "3.3.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "files", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/requester-utils": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/plugins/gitlab/src/class.ts b/.jjconflict-base-1/plugins/gitlab/src/class.ts new file mode 100644 index 0000000000..2ddb702b02 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/class.ts @@ -0,0 +1,686 @@ +import type { Project, ProjectMember, UniqueRepo } from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { AccessTokenScopes, AllRepositoryTreesOptions, CommitAction, CondensedProjectSchema, Gitlab, GroupSchema, MemberSchema, ProjectSchema, ProjectVariableSchema, RepositoryFileExpandedSchema, VariableSchema } from '@gitbeaker/core' +import type { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { createHash } from 'node:crypto' +import { PluginApi } from '@cpn-console/hooks' +import { objectEntries } from '@cpn-console/shared' +import { AccessLevel } from '@gitbeaker/core' +import config from './config.js' +import { + customAttributesFilter, + infraGroupCustomAttributeKey, + managedByConsoleCustomAttributeKey, + projectGroupCustomAttributeKey, + upsertCustomAttribute, +} from './custom-attributes.js' +import { logger } from './logger.js' +import { + find, + getAll, + getApi, + getGroupRootId, + infraAppsRepoName, + internalMirrorRepoName, + MAX_PAGINATION_PER_PAGE, + offsetPaginate, +} from './utils.js' + +type setVariableResult = 'created' | 'updated' | 'already up-to-date' +type AccessLevelAllowed = AccessLevel.NO_ACCESS | AccessLevel.MINIMAL_ACCESS | AccessLevel.GUEST | AccessLevel.REPORTER | AccessLevel.DEVELOPER | AccessLevel.MAINTAINER | AccessLevel.OWNER +const infraGroupName = 'Infra' +const infraGroupPath = 'infra' +export const pluginManagedTopic = 'plugin-managed' + +interface GitlabMirrorSecret { + MIRROR_USER: string + MIRROR_TOKEN: string +} + +interface RepoSelect { + mirror?: CondensedProjectSchema + target?: CondensedProjectSchema +} +type PendingCommits = Record +}> + +interface CreateEmptyRepositoryArgs { + repoName: string + description?: string +} + +export class GitlabApi extends PluginApi { + protected api: Gitlab + private pendingCommits: PendingCommits = {} + + constructor() { + super() + this.api = getApi() + } + + public async createEmptyRepository({ createFirstCommit, groupId, repoName, description, ciConfigPath }: CreateEmptyRepositoryArgs & { + createFirstCommit: boolean + groupId: number + ciConfigPath?: string + }) { + logger.debug({ action: 'createEmptyRepository', repoName, groupId, createFirstCommit, ciConfigPath }, 'Create empty repository') + const project = await this.api.Projects.create({ + name: repoName, + path: repoName, + ciConfigPath, + namespaceId: groupId, + description, + }) + try { + await upsertCustomAttribute('projects', project.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createEmptyRepository', projectId: project.id, err }, 'Failed to upsert project custom attribute') + } + // Dépôt tout juste créé, zéro branche => pas d'erreur (filesTree undefined) + if (createFirstCommit) { + await this.api.Commits.create(project.id, 'main', 'ci: 🌱 First commit', []) + } + return project + } + + public async commitCreateOrUpdate( + repoId: number, + fileContent: string, + filePath: string, + branch: string = 'main', + comment: string = 'ci: :robot_face: Update file content', + ): Promise { + logger.debug({ action: 'commitCreateOrUpdate', repoId, filePath, branch }, 'Schedule commit create/update') + let action: CommitAction['action'] = 'create' + + const existingBranch = await find(offsetPaginate(opts => this.api.Branches.all(repoId, opts), { perPage: MAX_PAGINATION_PER_PAGE }), b => b.name === branch) + if (existingBranch) { + let actualFile: RepositoryFileExpandedSchema | undefined + try { + actualFile = await this.api.RepositoryFiles.show(repoId, filePath, branch) + } catch {} + if (actualFile) { + const newContentDigest = createHash('sha256').update(fileContent).digest('hex') + if (actualFile.content_sha256 === newContentDigest) { + // Already up-to-date + return false + } + // Update needed + action = 'update' + } + } + + const commitAction: CommitAction = { + action, + filePath, + content: fileContent, + } + this.addActions(repoId, branch, comment, [commitAction]) + + return true + } + + /** + * Fonction pour supprimer une liste de fichiers d'un repo + * @param repoId + * @param files + * @param branch + * @param comment + */ + public async commitDelete( + repoId: number, + files: string[], + branch: string = 'main', + comment: string = 'ci: :robot_face: Delete files', + ): Promise { + logger.debug({ action: 'commitDelete', repoId, branch, filesCount: files.length }, 'Schedule commit delete') + if (files.length) { + const commitActions: CommitAction[] = files.map((filePath) => { + return { + action: 'delete', + filePath, + } + }) + this.addActions(repoId, branch, comment, commitActions) + return true + } + return false + } + + private addActions(repoId: number, branch: string, comment: string, commitActions: CommitAction[]) { + if (!this.pendingCommits[repoId]) { + this.pendingCommits[repoId] = { branches: {} } + } + if (this.pendingCommits[repoId].branches[branch]) { + this.pendingCommits[repoId].branches[branch].actions.push(...commitActions) + this.pendingCommits[repoId].branches[branch].messages.push(comment) + } else { + this.pendingCommits[repoId].branches[branch] = { + actions: commitActions, + messages: [comment], + } + } + } + + public async commitFiles() { + let filesUpdated: number = 0 + for (const [id, repo] of objectEntries(this.pendingCommits)) { + for (const [branch, details] of objectEntries(repo.branches)) { + const filesNumber = details.actions.length + if (filesNumber) { + filesUpdated += filesNumber + const message = [`ci: :robot_face: Update ${filesNumber} file${filesNumber > 1 ? 's' : ''}`, ...details.messages.filter(m => m)].join('\n') + logger.debug({ action: 'commitFiles', repoId: id, branch, filesNumber }, 'Commit pending file changes') + await this.api.Commits.create(id, branch, message, details.actions) + } + } + } + return filesUpdated + } + + public async listFiles(repoId: number, options: AllRepositoryTreesOptions = {}) { + options.path = options?.path ?? '/' + options.ref = options?.ref ?? 'main' + options.recursive = options?.recursive ?? false + try { + const files = await this.api.Repositories.allRepositoryTrees(repoId, options) + // if (depth >= 0) { + // for (const file of files) { + // if (file.type !== 'tree') { + // return [] + // } + // const childrenFiles = await this.listFiles(repoId, { depth: depth - 1, ...options, path: file.path }) + // files.push(...childrenFiles) + // } + // } + return files + } catch (error) { + const { cause } = error as GitbeakerRequestError + if (cause?.description.includes('Not Found')) { + // Empty repository, with zero commit ==> Zero files + return [] + } else { + throw error + } + } + } + + public async deleteRepository(repoId: number, fullPath: string) { + logger.info({ action: 'deleteRepository', repoId, fullPath }, 'Delete repository') + await this.api.Projects.remove(repoId) // Marks for deletion + return this.api.Projects.remove(repoId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${repoId}` }) // Effective deletion + } +} + +export class GitlabZoneApi extends GitlabApi { + private infraProjectsByZoneSlug: Map + + constructor() { + super() + this.infraProjectsByZoneSlug = new Map() + } + + // Group Infra + public async getOrCreateInfraGroup(): Promise { + logger.debug({ action: 'getOrCreateInfraGroup', infraGroupName }, 'Get/create infra group') + const rootId = await getGroupRootId() + // Get or create projects_root_dir/infra group + const fast = await find( + offsetPaginate(opts => this.api.Groups.all({ + ...customAttributesFilter(infraGroupCustomAttributeKey, 'true'), + ...opts, + })), + group => group.parent_id === rootId, + ) + + const existingParentGroup = fast ?? await find( + offsetPaginate(opts => this.api.Groups.all({ + search: infraGroupName, + ...opts, + })), + group => group.parent_id === rootId && group.name === infraGroupName, + ) + + const group = existingParentGroup || await this.api.Groups.create(infraGroupName, infraGroupPath, { + parentId: rootId, + projectCreationLevel: 'maintainer', + subgroupCreationLevel: 'owner', + defaultBranchProtection: 0, + description: 'Group that hosts infrastructure-as-code repositories for all zones (ArgoCD pull targets).', + }) + try { + await upsertCustomAttribute('groups', group.id, infraGroupCustomAttributeKey, 'true') + await upsertCustomAttribute('groups', group.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getOrCreateInfraGroup', groupId: group.id, err }, 'Failed to upsert infra group custom attribute') + } + return group + } + + public async getOrCreateInfraProject(zone: string): Promise { + logger.debug({ action: 'getOrCreateInfraProject', zone }, 'Get/create infra project for zone') + if (this.infraProjectsByZoneSlug.has(zone)) { + return this.infraProjectsByZoneSlug.get(zone)! + } + const infraGroup = await this.getOrCreateInfraGroup() + // Get or create projects_root_dir/infra/zone + const fast = await find( + offsetPaginate(opts => this.api.Groups.allProjects(infraGroup.id, { + ...customAttributesFilter(managedByConsoleCustomAttributeKey, 'true'), + search: zone, + simple: true, + ...opts, + })), + repo => repo.name === zone, + ) + const project = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allProjects(infraGroup.id, { + search: zone, + simple: true, + ...opts, + })), + repo => repo.name === zone, + ) ?? await this.createEmptyRepository({ + repoName: zone, + groupId: infraGroup.id, + description: 'Repository hosting deployment files for this zone.', + createFirstCommit: true, + }) + this.infraProjectsByZoneSlug.set(zone, project) + return project + } +} + +export class GitlabProjectApi extends GitlabApi { + private project: Project | UniqueRepo | ProjectMember['project'] + private gitlabGroup: GroupSchema | undefined + private specialRepositories: string[] = [infraAppsRepoName, internalMirrorRepoName] + private zoneApi: GitlabZoneApi + + constructor(project: Project | UniqueRepo | ProjectMember['project']) { + super() + this.project = project + this.api = getApi() + this.zoneApi = new GitlabZoneApi() + } + + // Group Project + private async createProjectGroup(): Promise { + logger.info({ action: 'createProjectGroup', projectSlug: this.project.slug }, 'Create project group') + const parentId = await getGroupRootId() + const existingGroup = await find(offsetPaginate(opts => this.api.Groups.all({ + search: this.project.slug, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), group => group.parent_id === parentId && group.name === this.project.slug) + + if (existingGroup) return existingGroup + + const group = await this.api.Groups.create(this.project.slug, this.project.slug, { + parentId, + projectCreationLevel: 'maintainer', + subgroupCreationLevel: 'owner', + defaultBranchProtection: 0, + }) + try { + await upsertCustomAttribute('groups', group.id, projectGroupCustomAttributeKey, this.project.slug) + await upsertCustomAttribute('groups', group.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createProjectGroup', groupId: group.id, err }, 'Failed to upsert project group custom attribute') + } + return group + } + + public async getProjectGroup(): Promise { + logger.debug({ action: 'getProjectGroup', projectSlug: this.project.slug }, 'Get project group') + if (!this.gitlabGroup) { + logger.debug({ action: 'getProjectGroup', projectSlug: this.project.slug }, 'Search project group') + const parentId = await getGroupRootId() + const fast = await find( + offsetPaginate(opts => this.api.Groups.allSubgroups(parentId, { + ...customAttributesFilter(projectGroupCustomAttributeKey, this.project.slug), + ...opts, + })), + group => group.name === this.project.slug, + ) + + this.gitlabGroup = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allSubgroups(parentId, { + search: this.project.slug, + ...opts, + })), + group => group.name === this.project.slug, + ) + + if (this.gitlabGroup) { + try { + await upsertCustomAttribute('groups', this.gitlabGroup.id, projectGroupCustomAttributeKey, this.project.slug) + await upsertCustomAttribute('groups', this.gitlabGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getProjectGroup', groupId: this.gitlabGroup.id, err }, 'Failed to upsert project group custom attribute') + } + } + } + return this.gitlabGroup + } + + public async getOrCreateProjectGroup(): Promise { + logger.debug({ action: 'getOrCreateProjectGroup', projectSlug: this.project.slug }, 'Get/create project group') + const group = await this.getProjectGroup() + if (group) return group + return this.createProjectGroup() + } + + public async getPublicGroupUrl() { + return new URL(`/${config().projectsRootDir}/${this.project.slug}`, config().publicUrl) + } + + public async getInternalGroupUrl() { + return new URL(`/${config().projectsRootDir}/${this.project.slug}`, config().internalUrl) + } + + // Tokens + public async getProjectMirrorCreds(vaultApi: VaultProjectApi): Promise { + logger.debug({ action: 'getProjectMirrorCreds', projectSlug: this.project.slug }, 'Get/create project mirror credentials') + const tokenName = `${this.project.slug}-bot` + const currentToken = await this.getProjectToken(tokenName) + const creds: GitlabMirrorSecret = { + MIRROR_USER: '', + MIRROR_TOKEN: '', + } + if (currentToken) { + const vaultSecret = await vaultApi.read('tech/GITLAB_MIRROR', { throwIfNoEntry: false }) as { data: GitlabMirrorSecret } + if (vaultSecret) { + try { + const group = await this.getProjectGroup() + if (!group) throw new Error('Group not created yet') + + const res = await fetch(`${config().internalUrl}/api/v4/groups/${group.id}`, { + headers: { 'PRIVATE-TOKEN': vaultSecret.data.MIRROR_TOKEN }, + }) + + if (res.ok) { + return vaultSecret.data // valid token hence early exit + } + + throw new Error('Invalid token') + } catch (error) { + logger.warn({ action: 'getProjectMirrorCreds', err: error }, 'Mirror token invalid, revoking project token') + await this.revokeProjectToken(currentToken.id) + } + } + } + const newToken = await this.createProjectToken(tokenName, ['write_repository', 'read_repository', 'read_api']) + creds.MIRROR_TOKEN = newToken.token + creds.MIRROR_USER = newToken.name + await vaultApi.write(creds, 'tech/GITLAB_MIRROR') + return creds + } + + public async getProjectId(projectName: string) { + logger.debug({ action: 'getProjectId', projectName, projectSlug: this.project.slug }, 'Look up project id') + const projectGroup = await this.getProjectGroup() + if (!projectGroup) throw new Error(`Gitlab inaccessible, impossible de trouver le groupe ${this.project.slug}`) + + const fast = await find( + offsetPaginate(opts => this.api.Groups.allProjects(projectGroup.id, { + ...customAttributesFilter(managedByConsoleCustomAttributeKey, 'true'), + search: projectName, + simple: true, + ...opts, + })), + repo => repo.name === projectName, + ) + + const project = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allProjects(projectGroup.id, { + search: projectName, + simple: true, + ...opts, + })), + repo => repo.name === projectName, + ) + + return project?.id + } + + public async getProjectById(projectId: number) { + return this.api.Projects.show(projectId) + } + + public async getOrCreateInfraProject(zone: string) { + return await this.zoneApi.getOrCreateInfraProject(zone) + } + + public async getProjectToken(tokenName: string) { + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + return find(offsetPaginate(opts => this.api.GroupAccessTokens.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE }), token => token.name === tokenName) + } + + public async createProjectToken(tokenName: string, scopes: AccessTokenScopes[]) { + logger.info({ action: 'createProjectToken', tokenName, projectSlug: this.project.slug }, 'Create project access token') + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + const expiryDate = new Date() + expiryDate.setFullYear(expiryDate.getFullYear() + 1) + return this.api.GroupAccessTokens.create(group.id, tokenName, scopes, expiryDate.toLocaleDateString('en-CA')) + } + + public async revokeProjectToken(tokenId: number) { + logger.info({ action: 'revokeProjectToken', tokenId, projectSlug: this.project.slug }, 'Revoke project access token') + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + return this.api.GroupAccessTokens.revoke(group.id, tokenId) + } + + // Triggers + public async getMirrorProjectTriggerToken(vaultApi: VaultProjectApi) { + logger.debug({ action: 'getMirrorProjectTriggerToken', projectSlug: this.project.slug }, 'Get mirror project trigger token') + const tokenDescription = 'mirroring-from-external-repo' + const gitlabRepositories = await this.listRepositories() + const mirrorRepo = gitlabRepositories.find(repo => repo.name === internalMirrorRepoName) + if (!mirrorRepo) throw new Error('Don\'t know how mirror repo could not exist') + const currentTriggerToken = await find(offsetPaginate(opts => this.api.PipelineTriggerTokens.all(mirrorRepo.id, opts), { perPage: MAX_PAGINATION_PER_PAGE }), token => token.description === tokenDescription) + + const tokenVaultSecret = await vaultApi.read('GITLAB', { throwIfNoEntry: false }) + + if (currentTriggerToken && !tokenVaultSecret?.data?.GIT_MIRROR_TOKEN) { + await this.api.PipelineTriggerTokens.remove(mirrorRepo.id, currentTriggerToken.id) + } + const triggerToken = await this.api.PipelineTriggerTokens.create(mirrorRepo.id, tokenDescription) + return { token: triggerToken.token, repoId: mirrorRepo.id } + } + + // Repositories + public async getPublicRepoUrl(repoName: string) { + return `${await this.getPublicGroupUrl()}/${repoName}.git` + } + + public async getInternalRepoUrl(repoName: string) { + return `${await this.getInternalGroupUrl()}/${repoName}.git` + } + + public async listRepositories() { + const group = await this.getOrCreateProjectGroup() + const projects = await getAll(offsetPaginate(opts => this.api.Groups.allProjects(group.id, { simple: false, ...opts }), { perPage: MAX_PAGINATION_PER_PAGE })) // to refactor with https://github.com/jdalrymple/gitbeaker/pull/3624 + return Promise.all(projects.map(async (project) => { + if (this.specialRepositories.includes(project.name) && (!project.topics || !project.topics.includes(pluginManagedTopic))) { + return this.api.Projects.edit(project.id, { topics: project.topics ? [...project.topics, pluginManagedTopic] : [pluginManagedTopic] }) + } + return project + })) + } + + public async createEmptyProjectRepository({ repoName, description, clone }: CreateEmptyRepositoryArgs & { clone?: boolean }) { + logger.info({ action: 'createEmptyProjectRepository', repoName, projectSlug: this.project.slug, clone }, 'Create empty project repository') + const namespaceId = (await this.getOrCreateProjectGroup()).id + return this.createEmptyRepository({ + repoName, + groupId: namespaceId, + description, + ciConfigPath: clone ? '.gitlab-ci-dso.yml' : undefined, + createFirstCommit: !clone, + }) + } + + // Special Repositories + public async getSpecialRepositories(): Promise { + return this.specialRepositories + } + + public async addSpecialRepositories(name: string) { + logger.debug({ action: 'addSpecialRepositories', name, projectSlug: this.project.slug }, 'Register special repository') + if (!this.specialRepositories.includes(name)) { + this.specialRepositories.push(name) + } + } + + // Group members + public async getGroupMembers() { + const group = await this.getOrCreateProjectGroup() + return getAll(offsetPaginate(opts => this.api.GroupMembers.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async addGroupMember(userId: number, accessLevel: AccessLevelAllowed = AccessLevel.DEVELOPER): Promise { + logger.info({ action: 'addGroupMember', accessLevel, projectSlug: this.project.slug }, 'Add group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.add(group.id, userId, accessLevel) + } + + public async editGroupMember(userId: number, accessLevel: AccessLevelAllowed = AccessLevel.DEVELOPER): Promise { + logger.info({ action: 'editGroupMember', accessLevel, projectSlug: this.project.slug }, 'Edit group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.edit(group.id, userId, accessLevel) + } + + public async removeGroupMember(userId: number) { + logger.info({ action: 'removeGroupMember', projectSlug: this.project.slug }, 'Remove group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.remove(group.id, userId) + } + + // CI Variables + public async getGitlabGroupVariables(): Promise { + const group = await this.getOrCreateProjectGroup() + return await getAll(offsetPaginate(opts => this.api.GroupVariables.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async setGitlabGroupVariable(listVars: VariableSchema[], toSetVariable: VariableSchema): Promise { + const group = await this.getOrCreateProjectGroup() + const currentVariable = listVars.find(v => v.key === toSetVariable.key) + if (currentVariable) { + if ( + currentVariable.masked !== toSetVariable.masked + || currentVariable.value !== toSetVariable.value + || currentVariable.protected !== toSetVariable.protected + || currentVariable.variable_type !== toSetVariable.variable_type + ) { + await this.api.GroupVariables.edit( + group.id, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + filter: { environment_scope: '*' }, + }, + ) + return 'updated' + } + return 'already up-to-date' + } + await this.api.GroupVariables.create( + group.id, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + + }, + ) + return 'created' + } + + public async getGitlabRepoVariables(repoId: number): Promise { + return await getAll(offsetPaginate(opts => this.api.ProjectVariables.all(repoId, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async setGitlabRepoVariable(repoId: number, listVars: VariableSchema[], toSetVariable: ProjectVariableSchema): Promise { + const currentVariable = listVars.find(v => v.key === toSetVariable.key) + if (currentVariable) { + if ( + currentVariable.masked !== toSetVariable.masked + || currentVariable.value !== toSetVariable.value + || currentVariable.protected !== toSetVariable.protected + || currentVariable.variable_type !== toSetVariable.variable_type + ) { + await this.api.ProjectVariables.edit( + repoId, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + filter: { + environment_scope: toSetVariable.environment_scope, + }, + }, + ) + return 'updated' + } + return 'already up-to-date' + } + await this.api.ProjectVariables.create( + repoId, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + }, + ) + return 'created' + } + + // Mirror + public async triggerMirror(targetRepo: string, syncAllBranches: boolean, branchName?: string) { + logger.info({ action: 'triggerMirror', targetRepo, syncAllBranches, branchName, projectSlug: this.project.slug }, 'Trigger repository mirror') + if ((await this.getSpecialRepositories()).includes(targetRepo)) { + throw new Error('User requested for invalid mirroring') + } + const repos = await this.listRepositories() + const { mirror, target }: RepoSelect = repos.reduce((acc, repository) => { + if (repository.name === 'mirror') { + acc.mirror = repository + } + if (repository.name === targetRepo) { + acc.target = repository + } + return acc + }, {} as RepoSelect) + if (!mirror) throw new Error('Unable to find mirror repository') + if (!target) throw new Error('Unable to find target repository') + return this.api.Pipelines.create(mirror.id, 'main', { + variables: [ + { + key: 'SYNC_ALL', + value: syncAllBranches.toString(), + }, + { + key: 'GIT_BRANCH_DEPLOY', + value: branchName ?? '', + }, + { + key: 'PROJECT_NAME', + value: targetRepo, + }, + ], + }) + } +} diff --git a/.jjconflict-base-1/plugins/gitlab/src/config.ts b/.jjconflict-base-1/plugins/gitlab/src/config.ts new file mode 100644 index 0000000000..be14dda99c --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/config.ts @@ -0,0 +1,26 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + token: string + projectsRootDir: string + constructor() { + this.token = requiredEnv('GITLAB_TOKEN') + this.publicUrl = removeTrailingSlash(requiredEnv('GITLAB_URL')) + this.projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') + this.internalUrl = process.env.GITLAB_INTERNAL_URL + ? removeTrailingSlash(process.env.GITLAB_INTERNAL_URL) + : this.publicUrl + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-base-1/plugins/gitlab/src/custom-attributes.ts b/.jjconflict-base-1/plugins/gitlab/src/custom-attributes.ts new file mode 100644 index 0000000000..f9ba81bf56 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/custom-attributes.ts @@ -0,0 +1,31 @@ +import { logger } from './logger.js' +import { getApi } from './utils.js' + +export const groupRootCustomAttributeKey = 'cpn_projects_root_dir' +export const infraGroupCustomAttributeKey = 'cpn_infra_group' +export const projectGroupCustomAttributeKey = 'cpn_project_slug' +export const userIdCustomAttributeKey = 'cpn_user_id' +export const managedByConsoleCustomAttributeKey = 'cpn_managed_by_console' + +export function customAttributesFilter(key: string, value: string) { + return { [`custom_attributes[${key}]`]: value } as Record +} + +export async function upsertCustomAttribute(resource: 'groups' | 'projects' | 'users', id: number, key: string, value: string): Promise { + logger.info({ action: 'upsertCustomAttribute', resource, id, key }, 'Upsert custom attribute') + logger.debug({ action: 'upsertCustomAttribute', resource, id, key }, 'Upsert custom attribute details') + const api = getApi() + try { + if (resource === 'groups') { + await api.GroupCustomAttributes.set(id, key, value) + } else if (resource === 'projects') { + await api.ProjectCustomAttributes.set(id, key, value) + } else { + await api.UserCustomAttributes.set(id, key, value) + } + } catch (err) { + logger.warn({ action: 'upsertCustomAttribute', resource, id, key, err }, 'Failed to upsert custom attribute') + throw err + } + logger.info({ action: 'upsertCustomAttribute', resource, id, key }, 'Custom attribute upserted') +} diff --git a/.jjconflict-base-1/plugins/gitlab/src/env.d.ts b/.jjconflict-base-1/plugins/gitlab/src/env.d.ts new file mode 100644 index 0000000000..46f7f90d7c --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-base-1/plugins/gitlab/src/functions.ts b/.jjconflict-base-1/plugins/gitlab/src/functions.ts new file mode 100644 index 0000000000..392245d2da --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/functions.ts @@ -0,0 +1,374 @@ +import type { AdminRole, ClusterObject, PluginResult, Project, ProjectLite, ProjectMember, StepCall, UniqueRepo, ZoneObject } from '@cpn-console/hooks' +import type { GitlabProjectApi } from './class.js' +import type { VaultSecrets } from './utils.js' +import { okStatus, specificallyDisabled } from '@cpn-console/hooks' +import config from './config.js' +import { deleteGroup } from './group.js' +import { + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, +} from './infos.js' +import { ensureGroup } from './members.js' +import { ensureRepositories } from './repositories.js' +import { createUsername, getUser, upsertUser } from './user.js' +import { cleanGitlabError } from './utils.js' + +// Check +export const checkApi: StepCall = async (payload) => { + try { + const { users } = payload.args + for (const user of users) { + const userInfos = await getUser({ ...user, username: createUsername(user.email) }) + if (userInfos?.id === 1) { + return { + status: { + result: 'KO', + message: 'Gitlab notify: User 1 (root) should not use Console', + }, + } + } + } + + return { + status: { + result: 'OK', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const getDsoProjectSecrets: StepCall = async (payload) => { + try { + if (!specificallyDisabled(payload.config.gitlab?.displayTriggerHint)) { + // TODO déplacer les secrets dans un dossier pour tout lister plutôt que de sélectionner dans le code + const gitlab = (await payload.apis.vault.read('GITLAB')).data as VaultSecrets['GITLAB'] + /* eslint-disable no-template-curly-in-string */ + const curlCommand = [ + 'curl -X POST --fail', + '-F token=\${GIT_MIRROR_TOKEN}', + '-F ref=main', + '-F variables[GIT_BRANCH_DEPLOY]=\${BRANCH_TO_SYNC}', + '-F variables[PROJECT_NAME]=\${REPOSITORY_NAME}', + `"${config().publicUrl}/api/v4/projects/${gitlab.GIT_MIRROR_PROJECT_ID}/trigger/pipeline"`, + ] + /* eslint-enable */ + const secrets: Record = { + GIT_MIRROR_PROJECT_ID: String(gitlab.GIT_MIRROR_PROJECT_ID), + GIT_MIRROR_TOKEN: gitlab.GIT_MIRROR_TOKEN, + 'CURL COMMAND': curlCommand.join(' \\\n '), + } + + return { + status: { + result: 'OK', + message: 'secret retrieved', + }, + secrets, + } + } + return { + status: { + result: 'OK', + message: 'This feature is disabled', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'OK', + message: 'No secrets found for this project', + }, + } + } +} + +export const upsertDsoProject: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + }, + } + try { + const project = payload.args + const { gitlab: gitlabApi, vault: vaultApi } = payload.apis + + await gitlabApi.getOrCreateProjectGroup() + + await Promise.all(project.users.map(user => + ensureGroup(gitlabApi, project, user, payload.config), + )) + + const projectMirrorCreds = await gitlabApi.getProjectMirrorCreds(vaultApi) + await ensureRepositories(gitlabApi, project, vaultApi, { + botAccount: projectMirrorCreds.MIRROR_USER, + token: projectMirrorCreds.MIRROR_TOKEN, + }) + + const destroySecrets = (await vaultApi.list()) + .filter(path => path.endsWith('-mirror')) + .map(path => path.slice(1, path.length - 7)) + .filter(repoName => !project.repositories.some(projectRepo => projectRepo.internalRepoName === repoName)) + + await Promise.all(destroySecrets + .map(repoName => vaultApi.destroy(`${repoName}-mirror`)), + ) + + const mirrorTriggerToken = await gitlabApi.getMirrorProjectTriggerToken(vaultApi) + + const gitlabSecret: VaultSecrets['GITLAB'] = { + PROJECT_SLUG: project.slug, + GIT_MIRROR_PROJECT_ID: mirrorTriggerToken.repoId, + GIT_MIRROR_TOKEN: mirrorTriggerToken.token, + } + + await vaultApi.write(gitlabSecret, 'GITLAB') + + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const deleteDsoProject: StepCall = async (payload) => { + try { + const group = await payload.apis.gitlab.getProjectGroup() + if (group) await deleteGroup(group.id, group.full_path) + + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +export const syncRepository: StepCall = async (payload) => { + const targetRepo = payload.args.repo + const gitlabApi = payload.apis.gitlab + try { + await gitlabApi.triggerMirror(targetRepo.internalRepoName, targetRepo.syncAllBranches, targetRepo.branchName) + return { + status: { + result: 'OK', + message: 'Ci launched', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'Failed to trigger sync', + }, + } + } +} + +export const upsertZone: StepCall = async (payload) => { + const returnResult: PluginResult = okStatus + try { + const gitlabApi = payload.apis.gitlab + await gitlabApi.getOrCreateInfraProject(payload.args.slug) + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const deleteZone: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + message: 'Deleted', + }, + } + try { + const gitlabApi = payload.apis.gitlab + const zoneRepo = await gitlabApi.getOrCreateInfraProject(payload.args.slug) + await gitlabApi.deleteRepository(zoneRepo.id, zoneRepo.path_with_namespace) + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const commitFiles: StepCall = async (payload) => { + const returnResult = payload.results.gitlab + try { + const filesUpdated = await payload.apis.gitlab.commitFiles() + + returnResult.status.message = `${filesUpdated} file${filesUpdated > 1 ? 's' : ''} updated` + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Failed to commit files' + return returnResult + } +} + +export const upsertAdminRole: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH + const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? DEFAULT_AUDITOR_GROUP_PATH + + const isAdmin = role.oidcGroup === adminGroupPath ? true : undefined + const isAuditor = role.oidcGroup === auditorGroupPath ? true : undefined + + if (isAdmin === undefined && isAuditor === undefined) { + return { + status: { + result: 'OK', + message: 'Not a managed role for GitLab plugin', + }, + } + } + + for (const member of role.members) { + await upsertUser(member, isAdmin, isAuditor) + } + + return { + status: { + result: 'OK', + message: 'Members synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error occured while syncing admin role', + }, + } + } +} + +export const deleteAdminRole: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH + const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? DEFAULT_AUDITOR_GROUP_PATH + + const isAdmin = role.oidcGroup === adminGroupPath ? false : undefined + const isAuditor = role.oidcGroup === auditorGroupPath ? false : undefined + + if (isAdmin === undefined && isAuditor === undefined) { + return { + status: { + result: 'OK', + message: 'Not a managed role for GitLab plugin', + }, + } + } + + for (const member of role.members) { + await upsertUser(member, isAdmin, isAuditor) + } + + return { + status: { + result: 'OK', + message: 'Admin role deleted and members synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error occured while deleting admin role', + }, + } + } +} + +export const upsertProjectMember: StepCall = async (payload) => { + const member = payload.args + const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon + + try { + await Promise.all(member.project.users.map(user => + ensureGroup(gitlabApi, member.project, user, payload.config), + )) + + return { + status: { + result: 'OK', + message: 'Member synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error happened while syncing project member', + }, + } + } +} + +export const deleteProjectMember: StepCall = async (payload) => { + const member = payload.args + const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon + + try { + const userInfos = await getUser({ ...member, id: member.userId, username: createUsername(member.email) }) + if (!userInfos) { + return { + status: { + result: 'OK', + message: 'User not found in GitLab', + }, + } + } + + await gitlabApi.removeGroupMember(userInfos.id) + + return { + status: { + result: 'OK', + message: 'Member deleted', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error happened while deleting project member', + }, + } + } +} diff --git a/.jjconflict-base-1/plugins/gitlab/src/group.ts b/.jjconflict-base-1/plugins/gitlab/src/group.ts new file mode 100644 index 0000000000..eabf036561 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/group.ts @@ -0,0 +1,7 @@ +import { getApi } from './utils.js' + +export async function deleteGroup(groupId: number, fullPath: string) { + const api = getApi() + await api.Groups.remove(groupId) // Marks for deletion + return api.Groups.remove(groupId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${groupId}` }) // Effective deletion +} diff --git a/.jjconflict-base-1/plugins/gitlab/src/index.ts b/.jjconflict-base-1/plugins/gitlab/src/index.ts new file mode 100644 index 0000000000..1eb773c6c6 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/index.ts @@ -0,0 +1,116 @@ +import type { DeclareModuleGenerator, DefaultArgs, Plugin, Project, ProjectMember, UniqueRepo, ZoneObject } from '@cpn-console/hooks' +import { GitlabProjectApi, GitlabZoneApi } from './class.js' +import { + checkApi, + commitFiles, + deleteDsoProject, + deleteProjectMember, + deleteZone, + getDsoProjectSecrets, + syncRepository, + upsertAdminRole, + upsertDsoProject, + upsertProjectMember, + upsertZone, +} from './functions.js' +import infos from './infos.js' +import { logger } from './logger.js' +import monitor from './monitor.js' +import { getOrCreateGroupRoot } from './utils.js' + +const onlyApi = { api: (project: Project | UniqueRepo) => new GitlabProjectApi(project) } + +function start() { + getOrCreateGroupRoot().catch((error) => { + logger.error({ action: 'start', err: error }, 'Hook failed') + throw new Error('Error at gitlab plugin start') + }) +} + +export const plugin: Plugin = { + infos, + subscribedHooks: { + deleteProject: { + ...onlyApi, + steps: { + main: deleteDsoProject, + post: commitFiles, + }, + }, + upsertProject: { + ...onlyApi, + steps: { + check: checkApi, + main: upsertDsoProject, + post: commitFiles, + }, + }, + getProjectSecrets: { steps: { main: getDsoProjectSecrets } }, + syncRepository: { + ...onlyApi, + steps: { + main: syncRepository, + post: commitFiles, + }, + }, + upsertCluster: { + api: () => new GitlabZoneApi(), + steps: { + post: commitFiles, + }, + }, + deleteCluster: { + api: () => new GitlabZoneApi(), + steps: { + post: commitFiles, + }, + }, + upsertZone: { + api: () => new GitlabZoneApi(), + steps: { + pre: upsertZone, + post: commitFiles, + }, + }, + deleteZone: { + api: () => new GitlabZoneApi(), + steps: { + main: deleteZone, + }, + }, + upsertAdminRole: { + steps: { + main: upsertAdminRole, + }, + }, + upsertProjectMember: { + api: member => new GitlabProjectApi(member.project), + steps: { + main: upsertProjectMember, + }, + }, + deleteProjectMember: { + api: member => new GitlabProjectApi(member.project), + steps: { + post: deleteProjectMember, + }, + }, + }, + monitor, + start, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + gitlab: Args extends Project | UniqueRepo | ProjectMember['project'] + ? GitlabProjectApi + : Args extends ZoneObject + ? GitlabZoneApi + : never + } + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} + interface PluginResult { + warnReasons?: string[] + } +} diff --git a/.jjconflict-base-1/plugins/gitlab/src/infos.ts b/.jjconflict-base-1/plugins/gitlab/src/infos.ts new file mode 100644 index 0000000000..38f5d10eac --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/infos.ts @@ -0,0 +1,108 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import config from './config.js' + +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer,/console/devops' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly' + +const infos = { + name: 'gitlab', + to: ({ project }) => `${config().publicUrl}/${config().projectsRootDir}/${project.slug}`, + title: 'Gitlab', + imgSrc: '/img/gitlab.svg', + description: 'GitLab est un service d\'hébergement de code source et de pipeline CI/CD', + config: { + global: [ + { + kind: 'switch', + key: 'purge', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Purger les utilisateurs non synchronisés', + value: DISABLED, + description: 'Purger les utilisateurs non synchronisés de GitLab lors de la synchronisation', + }, + { + kind: 'switch', + key: 'displayTriggerHint', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Afficher l\'aide de déclenchement de pipeline', + value: ENABLED, + description: 'Afficher l\'aide de déclenchement de pipeline aux utilisateurs lorsqu\'ils souhaitent afficher les secrets du projet', + }, + { + kind: 'text', + key: 'adminGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Admin', + value: DEFAULT_ADMIN_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'administrateur GitLab', + placeholder: DEFAULT_ADMIN_GROUP_PATH, + }, + { + kind: 'text', + key: 'auditorGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Auditeur', + value: DEFAULT_AUDITOR_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'auditeur GitLab', + placeholder: DEFAULT_AUDITOR_GROUP_PATH, + }, + { + kind: 'text', + key: 'projectMaintainerGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Maintainer', + value: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Maintainer', + placeholder: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectDeveloperGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Developer', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Developer', + placeholder: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectReporterGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Reporter', + value: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Reporter', + placeholder: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + }, + ], + project: [], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-base-1/plugins/gitlab/src/logger.ts b/.jjconflict-base-1/plugins/gitlab/src/logger.ts new file mode 100644 index 0000000000..8d4c5caea7 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'gitlab' }) diff --git a/.jjconflict-base-1/plugins/gitlab/src/members.ts b/.jjconflict-base-1/plugins/gitlab/src/members.ts new file mode 100644 index 0000000000..2e558b18a5 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/members.ts @@ -0,0 +1,69 @@ +import type { Config, Project, Role, UserObject } from '@cpn-console/hooks' +import type { GitlabProjectApi } from './class.js' +import { AccessLevel } from '@gitbeaker/core' +import { + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, +} from './infos.js' +import { createUsername, upsertUser } from './user.js' +import { matchRole } from './utils.js' + +export function getGroupAccessLevelFromProjectRole(project: Project, user: UserObject, config: Config) { + const projectReporterGroupPathSuffixes = (config.gitlab?.projectReporterGroupPathSuffix ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX).split(',') + const projectDeveloperGroupPathSuffixes = (config.gitlab?.projectDeveloperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX).split(',') + const projectMaintainerGroupPathSuffixes = (config.gitlab?.projectMaintainerGroupPathSuffix ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX).split(',') + + const getAccessLevel = (role: Role): number | null => { + // TODO: update to AccessLevel.GUEST once we migrated pre-fine grained + // projects members to developer access level via database migrations + if (!role.oidcGroup) return AccessLevel.DEVELOPER + if (matchRole(project.slug, role.oidcGroup, projectReporterGroupPathSuffixes)) return AccessLevel.REPORTER + if (matchRole(project.slug, role.oidcGroup, projectDeveloperGroupPathSuffixes)) return AccessLevel.DEVELOPER + if (matchRole(project.slug, role.oidcGroup, projectMaintainerGroupPathSuffixes)) return AccessLevel.MAINTAINER + return null + } + + // TODO: update to AccessLevel.GUEST once we migrated pre-fine grained + // projects members to developer access level via database migrations + return project.roles.reduce((highestAccessLevel, role) => { + if (role.users.some(userRole => userRole.id === user.id)) { + const level = getAccessLevel(role) + if (level && level > (highestAccessLevel ?? 0)) return level + } + return highestAccessLevel + }, AccessLevel.DEVELOPER) +} + +export function getGroupAccessLevel(project: Project, user: UserObject, config: Config): number | null { + if (project.owner.id === user.id) return AccessLevel.OWNER + return getGroupAccessLevelFromProjectRole(project, user, config) +} + +export async function ensureGroup( + gitlabApi: GitlabProjectApi, + project: Project, + user: UserObject, + config: Config, +) { + const gitlabUser = await upsertUser({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }) + + const groupMembers = await gitlabApi.getGroupMembers() + const existingMember = groupMembers.find(m => m.username === createUsername(user.email)) + const maxAccessLevel = getGroupAccessLevel(project, user, config) + + if (existingMember && maxAccessLevel) { + if (existingMember.access_level !== maxAccessLevel) { + await gitlabApi.editGroupMember(gitlabUser.id, maxAccessLevel) + } + } else if (maxAccessLevel) { + await gitlabApi.addGroupMember(gitlabUser.id, maxAccessLevel) + } else if (existingMember) { + await gitlabApi.removeGroupMember(gitlabUser.id) + } +} diff --git a/.jjconflict-base-1/plugins/gitlab/src/monitor.ts b/.jjconflict-base-1/plugins/gitlab/src/monitor.ts new file mode 100644 index 0000000000..d190d6ed51 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/monitor.ts @@ -0,0 +1,52 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import config from './config.js' + +enum HealthStatus { + failed = 'failed', + ok = 'ok', +} +type GitlabRes = Record }> +const coreComponents = ['gitaly_check', 'master_check', 'db_check', 'sessions_check'] + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(`${config().internalUrl}/-/readiness?all=1`, { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + const data = res.data as GitlabRes + const failedComponents = Object.entries(data) + .reduce((acc, [name, value]) => { + if (value.status === HealthStatus.failed) return [...acc, name] + return acc + }, [] as string[]) + const failedCoreComponents = failedComponents.filter(name => coreComponents.includes(name)) + + if (failedCoreComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } + if (failedComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.WARNING + instance.lastStatus.message = 'Service dégradé' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Fatal Error' + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-base-1/plugins/gitlab/src/project.ts b/.jjconflict-base-1/plugins/gitlab/src/project.ts new file mode 100644 index 0000000000..df3dc8e67e --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/project.ts @@ -0,0 +1,52 @@ +import * as fs from 'node:fs/promises' +import path from 'node:path' + +import { getApi } from './utils.js' + +export async function provisionMirror(repoId: number) { + const baseDir = path.resolve(import.meta.url, '../../files/').split(':')[1] + + const gitlabCiYml = ( + await fs.readFile(`${baseDir}/.gitlab-ci.yml`) + ).toString() + const mirrorSh = (await fs.readFile(`${baseDir}/mirror.sh`)).toString() + + const mirrorFirstActions: CommitAction[] = [ + { + action: 'create', + filePath: '.gitlab-ci.yml', + content: gitlabCiYml, + execute_filemode: false, + }, + { + action: 'create', + filePath: 'mirror.sh', + content: mirrorSh, + execute_filemode: true, + }, + ] + const api = getApi() + await api.Commits.create( + repoId, + 'main', + 'ci: :construction_worker: first mirror', + mirrorFirstActions, + ) +} + +interface CommitAction { + /** The action to perform */ + action: 'create' | 'delete' | 'move' | 'update' | 'chmod' + /** Full path to the file. Ex. lib/class.rb */ + filePath: string + /** Original full path to the file being moved.Ex.lib / class1.rb */ + previousPath?: string + /** File content, required for all except delete. Optional for move */ + content?: string + /** text or base64. text is default. */ + encoding?: string + /** Last known file commit id. Will be only considered in update, move and delete actions. */ + lastCommitId?: string + /** When true/false enables/disables the execute flag on the file. Only considered for chmod action. */ + execute_filemode?: boolean +} diff --git a/.jjconflict-base-1/plugins/gitlab/src/repositories.ts b/.jjconflict-base-1/plugins/gitlab/src/repositories.ts new file mode 100644 index 0000000000..00797cf446 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/repositories.ts @@ -0,0 +1,93 @@ +import type { Project, Repository } from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { CondensedProjectSchema, ProjectSchema } from '@gitbeaker/rest' +import type { GitlabProjectApi } from './class.js' +import { shallowEqual } from '@cpn-console/shared' +import { pluginManagedTopic } from './class.js' +import { provisionMirror } from './project.js' +import { infraAppsRepoName, internalMirrorRepoName } from './utils.js' + +interface ProjectMirrorCreds { + botAccount: string + token: string +} + +export async function ensureRepositories(gitlabApi: GitlabProjectApi, project: Project, vaultApi: VaultProjectApi, projectMirrorCreds: ProjectMirrorCreds) { + const specialRepos = await gitlabApi.getSpecialRepositories() + const gitlabRepositories = await gitlabApi.listRepositories() + + const promises: Promise[] = [ + // delete excess repositories + ...gitlabRepositories + .filter(gitlabRepository => ( + !specialRepos.includes(gitlabRepository.name) + && !gitlabRepository.topics?.includes(pluginManagedTopic) + && !project.repositories.some(repo => repo.internalRepoName === gitlabRepository.name))) + .map(gitlabRepository => gitlabApi.deleteRepository(gitlabRepository.id, gitlabRepository.path_with_namespace)), + // create missing repositories + ...project.repositories.map(repo => ensureRepositoryExists(gitlabRepositories, repo, gitlabApi, projectMirrorCreds, vaultApi)), + ] + + if (!gitlabRepositories.some(repo => repo.name === infraAppsRepoName)) { + promises.push( + gitlabApi.createEmptyProjectRepository({ repoName: infraAppsRepoName, clone: false }), + ) + } + if (!gitlabRepositories.some(repo => repo.name === internalMirrorRepoName)) { + promises.push( + gitlabApi.createEmptyProjectRepository({ repoName: internalMirrorRepoName, clone: false }) + .then(mirrorRepo => provisionMirror(mirrorRepo.id)), + ) + } + + await Promise.all(promises) +} + +const urnRegexp = /:\/\/(.*)/s + +async function ensureRepositoryExists(gitlabRepositories: CondensedProjectSchema[], repository: Repository, gitlabApi: GitlabProjectApi, projectMirrorCreds: ProjectMirrorCreds, vaultApi: VaultProjectApi) { + const gitlabRepository: CondensedProjectSchema | ProjectSchema | void = gitlabRepositories.find(gitlabRepository => gitlabRepository.name === repository.internalRepoName) + const externalRepoUrn = repository.externalRepoUrl.split(urnRegexp)[1] + const vaultCredsPath = `${repository.internalRepoName}-mirror` + const currentVaultSecret = await vaultApi.read(vaultCredsPath, { throwIfNoEntry: false }) + + if (!gitlabRepository) { + await gitlabApi.createEmptyProjectRepository({ + repoName: repository.internalRepoName, + description: undefined, + clone: !!repository.externalRepoUrl, + }) + } + + if (!repository.externalRepoUrl) { + return currentVaultSecret && vaultApi.destroy(vaultCredsPath) + } + + let gitInputUser: string | undefined + let gitInputPassword: string | undefined + if (currentVaultSecret?.data) { + gitInputUser = currentVaultSecret.data.GIT_INPUT_USER + gitInputPassword = currentVaultSecret.data.GIT_INPUT_PASSWORD + } + + const internalRepoUrl = await gitlabApi.getInternalRepoUrl(repository.internalRepoName) + + const mirrorSecretData = { + GIT_INPUT_URL: externalRepoUrn, + GIT_INPUT_USER: repository.isPrivate + ? (repository.newCreds?.username || gitInputUser) + : undefined, + GIT_INPUT_PASSWORD: repository.isPrivate + ? (repository.newCreds?.token || gitInputPassword) + : undefined, + GIT_OUTPUT_URL: internalRepoUrl.split(urnRegexp)[1], + GIT_OUTPUT_USER: projectMirrorCreds.botAccount, + GIT_OUTPUT_PASSWORD: projectMirrorCreds.token, + } + if ( + !currentVaultSecret?.data + || !shallowEqual(mirrorSecretData, currentVaultSecret.data) + ) { + await vaultApi.write(mirrorSecretData, vaultCredsPath) + } +} diff --git a/.jjconflict-base-1/plugins/gitlab/src/user.ts b/.jjconflict-base-1/plugins/gitlab/src/user.ts new file mode 100644 index 0000000000..5f638e4bc1 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/user.ts @@ -0,0 +1,107 @@ +import type { UserObject } from '@cpn-console/hooks' +import type { CreateUserOptions, SimpleUserSchema } from '@gitbeaker/rest' +import { upsertCustomAttribute, userIdCustomAttributeKey } from './custom-attributes.js' +import { logger } from './logger.js' +import { find, getApi, offsetPaginate } from './utils.js' + +export function createUsername(email: string) { + const parts = email.split('@') + if (parts.length > 0) { + return parts[0] + } + return email +} + +export async function getUser(user: { email: string, username: string, id: string }): Promise { + const api = getApi() + + const isUser = (gitlabUser: SimpleUserSchema) => + gitlabUser?.externUid === user.id + || gitlabUser?.externUid === user.email + || gitlabUser.email === user.email + || gitlabUser.username === user.username + + const fast = await find( + offsetPaginate(opts => api.Users.all({ + externUid: user.email, + provider: 'openid_connect', + orderBy: 'username', + asAdmin: true, + ...opts, + })), + isUser, + ) + + if (!fast) { + logger.debug({ action: 'getUser', user }, 'User not found in fast search') + } + + return fast ?? await find( + offsetPaginate(opts => api.Users.all({ + search: user.username, + asAdmin: true, + ...opts, + })), + isUser, + ) +} + +export async function upsertUser(user: UserObject, isAdmin?: boolean, isAuditor?: boolean): Promise { + const api = getApi() + const username = createUsername(user.email) + const existingUser = await getUser({ ...user, username }) + + const userDefinitionBase: CreateUserOptions = { + // required options + name: `${user.firstName} ${user.lastName}`, + username, + email: user.email, + // sso options + externUid: user.email, + provider: 'openid_connect', + admin: isAdmin, + auditor: isAuditor, + } + + if (existingUser) { + const incorrectProps = Object.entries(userDefinitionBase).reduce((acc, [key, value]) => { + if (existingUser[key] !== value) { + acc.push({ + key, + curr: existingUser[key], + new: value, + }) + } + return acc + }, [] as { key: string, curr: any, new: any }[]) + + if (incorrectProps.length) { + logger.debug({ action: 'upsertUser', changes: incorrectProps }, 'User properties differ from expected') + try { + await api.Users.edit(existingUser.id, userDefinitionBase) + } catch (err) { + logger.error({ action: 'upsertUser', err }, 'Failed to update user') + } + } + try { + await upsertCustomAttribute('users', existingUser.id, userIdCustomAttributeKey, user.id) + } catch (err) { + logger.debug({ action: 'upsertUser', userId: existingUser.id, err }, 'Failed to upsert user custom attribute') + } + return existingUser + } + + const created = await api.Users.create({ + ...userDefinitionBase, + canCreateGroup: false, + forceRandomPassword: true, + projectsLimit: 0, + skipConfirmation: true, + }) + try { + await upsertCustomAttribute('users', created.id, userIdCustomAttributeKey, user.id) + } catch (err) { + logger.debug({ action: 'upsertUser', userId: created.id, err }, 'Failed to upsert user custom attribute') + } + return created +} diff --git a/.jjconflict-base-1/plugins/gitlab/src/utils.ts b/.jjconflict-base-1/plugins/gitlab/src/utils.ts new file mode 100644 index 0000000000..3dbe69b772 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/src/utils.ts @@ -0,0 +1,198 @@ +import type { BaseRequestOptions, Gitlab as IGitlab, OffsetPagination, PaginationRequestOptions } from '@gitbeaker/core' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { Gitlab } from '@gitbeaker/rest' +import config from './config.js' +import { customAttributesFilter, groupRootCustomAttributeKey, managedByConsoleCustomAttributeKey, upsertCustomAttribute } from './custom-attributes.js' +import { logger } from './logger.js' + +let api: IGitlab | undefined + +let groupRootId: number + +export const MAX_PAGINATION_PER_PAGE = 100 + +export async function getGroupRootId(throwIfNotFound?: true): Promise +export async function getGroupRootId(throwIfNotFound?: false): Promise +export async function getGroupRootId(throwIfNotFound?: boolean): Promise { + const gitlabApi = getApi() + const projectRootDir = config().projectsRootDir + logger.debug({ action: 'getGroupRootId', projectRootDir }, 'Resolve group root id') + if (groupRootId) return groupRootId + const fast = await find( + offsetPaginate(opts => gitlabApi.Groups.all({ + ...customAttributesFilter(groupRootCustomAttributeKey, projectRootDir), + ...opts, + })), + grp => grp.full_path === projectRootDir, + ) + const groupRoot = fast ?? await find( + offsetPaginate(opts => gitlabApi.Groups.all({ + search: projectRootDir, + ...opts, + })), + grp => grp.full_path === projectRootDir, + ) + logger.debug({ action: 'getGroupRootId', groupRootId: groupRoot?.id, groupRootPath: groupRoot?.full_path }, 'Resolved group root') + const searchId = groupRoot?.id + if (typeof searchId === 'undefined') { + if (throwIfNotFound) { + throw new Error(`Gitlab inaccessible, impossible de trouver le groupe RACINE ${projectRootDir}`) + } + return searchId + } + groupRootId = searchId + try { + await upsertCustomAttribute('groups', groupRootId, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', groupRootId, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getGroupRootId', groupRootId, err }, 'Failed to upsert group root custom attribute') + } + return groupRootId +} + +async function createGroupRoot(): Promise { + logger.info({ action: 'createGroupRoot', projectRootDir: config().projectsRootDir }, 'Create group root hierarchy') + const gitlabApi = getApi() + const projectRootDir = config().projectsRootDir + const projectRootDirArray = projectRootDir.split('/') + + const rootGroupPath = projectRootDirArray.shift() + if (!rootGroupPath) { + throw new Error('No projectRootDir available') + } + + let parentGroup = await find(offsetPaginate(opts => gitlabApi.Groups.all({ + search: rootGroupPath, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), grp => grp.full_path === rootGroupPath) ?? await gitlabApi.Groups.create(rootGroupPath, rootGroupPath) + + if (parentGroup.full_path === projectRootDir) { + try { + await upsertCustomAttribute('groups', parentGroup.id, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', parentGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createGroupRoot', groupRootId: parentGroup.id, err }, 'Failed to upsert group root custom attribute') + } + return parentGroup.id + } + + for (const path of projectRootDirArray) { + const futureFullPath = `${parentGroup.full_path}/${path}` + parentGroup = await find(offsetPaginate(opts => gitlabApi.Groups.all({ + search: futureFullPath, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), grp => grp.full_path === futureFullPath) ?? await gitlabApi.Groups.create(path, path, { parentId: parentGroup.id, visibility: 'internal' }) + + if (parentGroup.full_path === projectRootDir) { + try { + await upsertCustomAttribute('groups', parentGroup.id, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', parentGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createGroupRoot', groupRootId: parentGroup.id, err }, 'Failed to upsert group root custom attribute') + } + return parentGroup.id + } + } + throw new Error('No projectRootDir available or is malformed') +} + +export async function getOrCreateGroupRoot(): Promise { + return await getGroupRootId(false) ?? createGroupRoot() +} + +export function getApi(): IGitlab { + api ??= new Gitlab({ token: config().token, host: config().internalUrl }) + return api +} + +export const infraAppsRepoName = 'infra-apps' +export const internalMirrorRepoName = 'mirror' + +export interface VaultSecrets { + GITLAB: { + PROJECT_SLUG: string + GIT_MIRROR_PROJECT_ID: number + GIT_MIRROR_TOKEN: string + } +} + +// eslint-disable-next-line regexp/no-super-linear-backtracking +const keyValueRegExp = /\/\/(.*):(.*)@/g + +export function cleanGitlabError(error: T): T { + if (error instanceof GitbeakerRequestError && error.cause?.description) { + error.cause.description = String(error.cause.description).replaceAll(keyValueRegExp, '//MASKED:MASKED@') + } + return error +} + +export function matchRole(projectSlug: string, roleOidcGroup: string, configuredRolePath: string[]) { + return configuredRolePath.some(path => roleOidcGroup === `/${projectSlug}${path}`) +} + +export interface OffsetPaginateOptions { + startPage?: number + perPage?: number + maxPages?: number +} + +export async function* offsetPaginate( + request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, + options?: OffsetPaginateOptions, +): AsyncGenerator { + let page: number | null = options?.startPage ?? 1 + let pagesFetched = 0 + let total: number = 0 + logger.debug({ action: 'offsetPaginate', page }, 'Pagination start') + while (page !== null) { + if (options?.maxPages && pagesFetched >= options.maxPages) { + page = null + continue + } + try { + const { data, paginationInfo } = await request({ + page, + perPage: options?.perPage, + maxPages: options?.maxPages, + showExpanded: true, + pagination: 'offset', + }) + pagesFetched += 1 + total += data.length + logger.debug( + { action: 'offsetPaginate', page, nextPage: paginationInfo.next, items: data.length, total }, + 'Pagination page fetched', + ) + for (const item of data) { + yield item + } + page = paginationInfo.next + } catch (error) { + logger.error({ action: 'offsetPaginate', page, err: error }, 'Pagination request failed') + throw error + } + } + logger.debug({ action: 'offsetPaginate', total }, 'Pagination done') +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} + +export async function find( + iterable: AsyncIterable, + predicate: (item: T) => boolean, +): Promise { + for await (const item of iterable) { + if (predicate(item)) { + return item + } + } + return undefined +} diff --git a/.jjconflict-base-1/plugins/gitlab/tsconfig.eslint.json b/.jjconflict-base-1/plugins/gitlab/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-1/plugins/gitlab/tsconfig.json b/.jjconflict-base-1/plugins/gitlab/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-1/plugins/gitlab/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-1/plugins/harbor/.gitignore b/.jjconflict-base-1/plugins/harbor/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-1/plugins/harbor/eslint.config.js b/.jjconflict-base-1/plugins/harbor/eslint.config.js new file mode 100644 index 0000000000..3148148bf5 --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/eslint.config.js @@ -0,0 +1,4 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +// Generated by `pnpm run swagger:generate` — never linted or hand-edited. +export default eslintConfigBase.append({ ignores: ['src/api/Api.ts'] }) diff --git a/.jjconflict-base-1/plugins/harbor/package.json b/.jjconflict-base-1/plugins/harbor/package.json new file mode 100644 index 0000000000..ec8a51bb24 --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/package.json @@ -0,0 +1,55 @@ +{ + "name": "@cpn-console/harbor-plugin", + "type": "module", + "version": "2.3.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types", + "utils.d.ts" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./", + "swagger:generate": "node ./scripts/generate-api.mjs" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime", + "bytes": "catalog:runtime", + "cron-validator": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "js-yaml": "catalog:tools", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "swagger-typescript-api": "catalog:build", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/plugins/harbor/scripts/generate-api.mjs b/.jjconflict-base-1/plugins/harbor/scripts/generate-api.mjs new file mode 100644 index 0000000000..2633066ec9 --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/scripts/generate-api.mjs @@ -0,0 +1,37 @@ +/** + * Generates `src/api/Api.ts` from Harbor's OpenAPI/Swagger spec. + * + * The spec is fetched and parsed with `js-yaml` before being handed to + * `swagger-typescript-api`. This is deliberate: swagger-typescript-api v13 + * bundles a strict YAML parser that rejects Harbor's multi-line single-quoted + * `description` scalars, so we parse the YAML ourselves and pass the resulting + * JSON spec object programmatically. + * + * Bump HARBOR_TAG to regenerate against a newer Harbor release. + */ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { generateApi } from 'swagger-typescript-api' + +const HARBOR_TAG = process.env.HARBOR_TAG ?? 'v2.13.5' +const SPEC_URL = `https://raw.githubusercontent.com/goharbor/harbor/${HARBOR_TAG}/api/v2.0/swagger.yaml` + +const outputDir = resolve(dirname(fileURLToPath(import.meta.url)), '../src/api') + +console.log(`Fetching Harbor ${HARBOR_TAG} spec from ${SPEC_URL}`) +const res = await fetch(SPEC_URL) +if (!res.ok) { + throw new Error(`Failed to fetch spec (${res.status} ${res.statusText})`) +} + +const spec = yaml.load(await res.text()) + +await generateApi({ + spec, + output: outputDir, + fileName: 'Api.ts', + httpClientType: 'axios', +}) + +console.log(`Generated ${outputDir}/Api.ts`) diff --git a/.jjconflict-base-1/plugins/harbor/src/api/Api.ts b/.jjconflict-base-1/plugins/harbor/src/api/Api.ts new file mode 100644 index 0000000000..2616919eba --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/src/api/Api.ts @@ -0,0 +1,8030 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +/** The error array that describe the errors got during the handling of request */ +export interface Errors { + errors?: Error[]; +} + +/** a model for all the error response coming from harbor */ +export interface Error { + /** The error code */ + code?: string; + /** The error message */ + message?: string; +} + +export interface Search { + /** Search results of the projects that matched the filter keywords. */ + project?: Project[]; + /** Search results of the repositories that matched the filter keywords. */ + repository?: SearchRepository[]; +} + +export interface SearchRepository { + /** The ID of the project that the repository belongs to */ + project_id?: number; + /** The name of the project that the repository belongs to */ + project_name?: string; + /** The flag to indicate the publicity of the project that the repository belongs to (1 is public, 0 is not) */ + project_public?: boolean; + /** The name of the repository */ + repository_name?: string; + /** The count how many times the repository is pulled */ + pull_count?: number; + /** The count of artifacts in the repository */ + artifact_count?: number; +} + +export interface Repository { + /** + * The ID of the repository + * @format int64 + */ + id?: number; + /** + * The ID of the project that the repository belongs to + * @format int64 + */ + project_id?: number; + /** The name of the repository */ + name?: string; + /** The description of the repository */ + description?: string; + /** + * The count of the artifacts inside the repository + * @format int64 + */ + artifact_count: number; + /** + * The count that the artifact inside the repository pulled + * @format int64 + */ + pull_count: number; + /** + * The creation time of the repository + * @format date-time + */ + creation_time?: string | null; + /** + * The update time of the repository + * @format date-time + */ + update_time?: string; +} + +export interface Artifact { + /** + * The ID of the artifact + * @format int64 + */ + id?: number; + /** The type of the artifact, e.g. image, chart, etc */ + type?: string; + /** The media type of the artifact */ + media_type?: string; + /** The manifest media type of the artifact */ + manifest_media_type?: string; + /** The artifact_type in the manifest of the artifact */ + artifact_type?: string; + /** + * The ID of the project that the artifact belongs to + * @format int64 + */ + project_id?: number; + /** + * The ID of the repository that the artifact belongs to + * @format int64 + */ + repository_id?: number; + /** The name of the repository that the artifact belongs to */ + repository_name?: string; + /** The digest of the artifact */ + digest?: string; + /** + * The size of the artifact + * @format int64 + */ + size?: number; + /** The digest of the icon */ + icon?: string; + /** + * The push time of the artifact + * @format date-time + */ + push_time?: string; + /** + * The latest pull time of the artifact + * @format date-time + */ + pull_time?: string; + extra_attrs?: ExtraAttrs; + annotations?: Annotations; + references?: Reference[]; + tags?: Tag[]; + addition_links?: AdditionLinks; + labels?: Label[]; + /** The overview of the scan result. */ + scan_overview?: ScanOverview; + /** The overview of the generating SBOM progress */ + sbom_overview?: SBOMOverview; + accessories?: Accessory[]; +} + +export interface Tag { + /** + * The ID of the tag + * @format int64 + */ + id?: number; + /** + * The ID of the repository that the tag belongs to + * @format int64 + */ + repository_id?: number; + /** + * The ID of the artifact that the tag attached to + * @format int64 + */ + artifact_id?: number; + /** The name of the tag */ + name?: string; + /** + * The push time of the tag + * @format date-time + */ + push_time?: string; + /** + * The latest pull time of the tag + * @format date-time + */ + pull_time?: string; + /** The immutable status of the tag */ + immutable: boolean; +} + +export type ExtraAttrs = Record; + +export type Annotations = Record; + +export type AdditionLinks = Record; + +export interface AdditionLink { + /** The link of the addition */ + href?: string; + /** Determine whether the link is an absolute URL or not */ + absolute: boolean; +} + +export interface Reference { + /** + * The parent ID of the reference + * @format int64 + */ + parent_id?: number; + /** + * The child ID of the reference + * @format int64 + */ + child_id?: number; + /** The digest of the child artifact */ + child_digest?: string; + platform?: Platform; + annotations?: Annotations; + /** The download URLs */ + urls?: string[]; +} + +export interface Platform { + /** The architecture that the artifact applys to */ + architecture?: string; + /** The OS that the artifact applys to */ + os?: string; + /** The version of the OS that the artifact applys to */ + "'os.version'"?: string; + /** The features of the OS that the artifact applys to */ + "'os.features'"?: string[]; + /** The variant of the CPU */ + variant?: string; +} + +export interface Label { + /** + * The ID of the label + * @format int64 + */ + id?: number; + /** The name the label */ + name?: string; + /** The description the label */ + description?: string; + /** The color the label */ + color?: string; + /** The scope the label */ + scope?: string; + /** + * The ID of project that the label belongs to + * @format int64 + */ + project_id?: number; + /** + * The creation time the label + * @format date-time + */ + creation_time?: string; + /** + * The update time of the label + * @format date-time + */ + update_time?: string; +} + +export interface Scanner { + /** + * Name of the scanner + * @example "Trivy" + */ + name?: string; + /** + * Name of the scanner provider + * @example "Aqua Security" + */ + vendor?: string; + /** + * Version of the scanner adapter + * @example "v0.9.1" + */ + version?: string; +} + +/** The scan overview attached in the metadata of tag */ +export type ScanOverview = Record; + +/** The generate SBOM overview information */ +export interface SBOMOverview { + /** + * The start time of the generating sbom report task + * @format date-time + * @example "2006-01-02T14:04:05Z" + */ + start_time?: string; + /** + * The end time of the generating sbom report task + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + end_time?: string; + /** The status of the generating SBOM task */ + scan_status?: string; + /** The digest of the generated SBOM accessory */ + sbom_digest?: string; + /** + * id of the native scan report + * @example "5f62c830-f996-11e9-957f-0242c0a89008" + */ + report_id?: string; + /** + * Time in seconds required to create the report + * @format int64 + * @example 300 + */ + duration?: number; + scanner?: Scanner; +} + +/** The summary for the native report */ +export interface NativeReportSummary { + /** + * id of the native scan report + * @example "5f62c830-f996-11e9-957f-0242c0a89008" + */ + report_id?: string; + /** + * The status of the report generating process + * @example "Success" + */ + scan_status?: string; + /** + * The overall severity + * @example "High" + */ + severity?: string; + /** + * The seconds spent for generating the report + * @format int64 + * @example 300 + */ + duration?: number; + /** VulnerabilitySummary contains the total number of the foun d vulnerabilities number and numbers of each severity level. */ + summary?: VulnerabilitySummary; + /** + * The start time of the scan process that generating report + * @format date-time + * @example "2006-01-02T14:04:05Z" + */ + start_time?: string; + /** + * The end time of the scan process that generating report + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + end_time?: string; + /** + * The complete percent of the scanning which value is between 0 and 100 + * @example 100 + */ + complete_percent?: number; + scanner?: Scanner; +} + +/** VulnerabilitySummary contains the total number of the foun d vulnerabilities number and numbers of each severity level. */ +export interface VulnerabilitySummary { + /** + * The total number of the found vulnerabilities + * @format int + * @example 500 + */ + total: number; + /** + * The number of the fixable vulnerabilities + * @format int + * @example 100 + */ + fixable: number; + /** + * Numbers of the vulnerabilities with different severity + * @example {"Critical":5,"High":5} + */ + summary: Record; +} + +export interface AuditLog { + /** The ID of the audit log entry. */ + id?: number; + /** Username of the user in this log entry. */ + username?: string; + /** Name of the repository in this log entry. */ + resource?: string; + /** Tag of the repository in this log entry. */ + resource_type?: string; + /** The operation against the repository in this log entry. */ + operation?: string; + /** + * The time when this operation is triggered. + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + op_time?: string; +} + +export interface AuditLogExt { + /** The ID of the audit log entry. */ + id?: number; + /** The username of the operator in this log entry. */ + username?: string; + /** Name of the resource in this log entry. */ + resource?: string; + /** Type of the resource in this log entry. */ + resource_type?: string; + /** The operation against the resource in this log entry. */ + operation?: string; + /** The operation's detail description */ + operation_description?: string; + /** the operation's result, true for success, false for fail */ + operation_result: boolean; + /** + * The time when this operation is triggered. + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + op_time?: string; +} + +export interface AuditLogEventType { + /** + * the event type, such as create_user. + * @example "create_user" + */ + event_type?: string; +} + +export interface Metadata { + /** id */ + id?: string; + /** name */ + name?: string; + /** icon */ + icon?: string; + /** maintainers */ + maintainers?: string[]; + /** version */ + version?: string; + /** source */ + source?: string; +} + +export interface Instance { + /** Unique ID */ + id?: number; + /** Instance name */ + name?: string; + /** Description of instance */ + description?: string; + /** Based on which driver, identified by ID */ + vendor?: string; + /** The service endpoint of this instance */ + endpoint?: string; + /** The authentication way supported */ + auth_mode?: string; + /** The auth credential data if exists */ + auth_info?: Record; + /** The health status */ + status?: string; + /** Whether the instance is activated or not */ + enabled: boolean; + /** Whether the instance is default or not */ + default: boolean; + /** Whether the instance endpoint is insecure or not */ + insecure: boolean; + /** + * The timestamp of instance setting up + * @format int64 + */ + setup_timestamp?: number; +} + +export interface PreheatPolicy { + /** The ID of preheat policy */ + id?: number; + /** The Name of preheat policy */ + name?: string; + /** The Description of preheat policy */ + description?: string; + /** The ID of preheat policy project */ + project_id?: number; + /** The ID of preheat policy provider */ + provider_id?: number; + /** The Name of preheat policy provider */ + provider_name?: string; + /** The Filters of preheat policy */ + filters?: string; + /** The Trigger of preheat policy */ + trigger?: string; + /** Whether the preheat policy enabled */ + enabled: boolean; + /** The extra attributes of preheat policy */ + extra_attrs?: string; + /** + * The Create Time of preheat policy + * @format date-time + */ + creation_time?: string; + /** + * The Update Time of preheat policy + * @format date-time + */ + update_time?: string; +} + +export interface Metrics { + /** The count of task */ + task_count?: number; + /** The count of success task */ + success_task_count?: number; + /** The count of error task */ + error_task_count?: number; + /** The count of pending task */ + pending_task_count?: number; + /** The count of running task */ + running_task_count?: number; + /** The count of scheduled task */ + scheduled_task_count?: number; + /** The count of stopped task */ + stopped_task_count?: number; +} + +export interface Execution { + /** The ID of execution */ + id?: number; + /** The vendor type of execution */ + vendor_type?: string; + /** The vendor id of execution */ + vendor_id?: number; + /** The status of execution */ + status?: string; + /** The status message of execution */ + status_message?: string; + metrics?: Metrics; + /** The trigger of execution */ + trigger?: string; + extra_attrs?: ExtraAttrs; + /** The start time of execution */ + start_time?: string; + /** The end time of execution */ + end_time?: string; +} + +export interface Task { + /** The ID of task */ + id?: number; + /** The ID of task execution */ + execution_id?: number; + /** The status of task */ + status?: string; + /** The status message of task */ + status_message?: string; + /** + * The count of task run + * @format int32 + */ + run_count?: number; + extra_attrs?: ExtraAttrs; + /** The creation time of task */ + creation_time?: string; + /** The update time of task */ + update_time?: string; + /** The start time of task */ + start_time?: string; + /** The end time of task */ + end_time?: string; +} + +export interface ProviderUnderProject { + id?: number; + provider?: string; + enabled?: boolean; + default?: boolean; +} + +export interface Icon { + /** The content type of the icon */ + "content-type"?: string; + /** The base64 encoded content of the icon */ + content?: string; +} + +export interface ProjectReq { + /** + * The name of the project. + * @maxLength 255 + */ + project_name?: string; + /** deprecated, reserved for project creation in replication */ + public?: boolean | null; + /** The metadata of the project. */ + metadata?: ProjectMetadata; + /** The CVE allowlist of the project. */ + cve_allowlist?: CVEAllowlist; + /** + * The storage quota of the project. + * @format int64 + */ + storage_limit?: number | null; + /** + * The ID of referenced registry when creating the proxy cache project + * @format int64 + */ + registry_id?: number | null; +} + +export interface Project { + /** + * Project ID + * @format int32 + */ + project_id?: number; + /** + * The owner ID of the project always means the creator of the project. + * @format int32 + */ + owner_id?: number; + /** The name of the project. */ + name?: string; + /** + * The ID of referenced registry when the project is a proxy cache project. + * @format int64 + */ + registry_id?: number; + /** + * The creation time of the project. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the project. + * @format date-time + */ + update_time?: string; + /** A deletion mark of the project. */ + deleted?: boolean; + /** The owner name of the project. */ + owner_name?: string; + /** Correspond to the UI about whether the project's publicity is updatable (for UI) */ + togglable?: boolean; + /** The role ID with highest permission of the current user who triggered the API (for UI). This attribute is deprecated and will be removed in future versions. */ + current_user_role_id?: number; + /** The list of role ID of the current user who triggered the API (for UI) */ + current_user_role_ids?: number[]; + /** The number of the repositories under this project. */ + repo_count: number; + /** The metadata of the project. */ + metadata?: ProjectMetadata; + /** The CVE allowlist of this project. */ + cve_allowlist?: CVEAllowlist; +} + +export interface ProjectDeletable { + /** Whether the project can be deleted. */ + deletable?: boolean; + /** The detail message when the project can not be deleted. */ + message?: string; +} + +export interface ProjectMetadata { + /** The public status of the project. The valid values are "true", "false". */ + public?: string; + /** Whether content trust is enabled or not. If it is enabled, user can't pull unsigned images from this project. The valid values are "true", "false". */ + enable_content_trust?: string | null; + /** Whether cosign content trust is enabled or not. If it is enabled, user can't pull images without cosign signature from this project. The valid values are "true", "false". */ + enable_content_trust_cosign?: string | null; + /** Whether prevent the vulnerable images from running. The valid values are "true", "false". */ + prevent_vul?: string | null; + /** If the vulnerability is high than severity defined here, the images can't be pulled. The valid values are "none", "low", "medium", "high", "critical". */ + severity?: string | null; + /** Whether scan images automatically when pushing. The valid values are "true", "false". */ + auto_scan?: string | null; + /** Whether generating SBOM automatically when pushing a subject artifact. The valid values are "true", "false". */ + auto_sbom_generation?: string | null; + /** Whether this project reuse the system level CVE allowlist as the allowlist of its own. The valid values are "true", "false". If it is set to "true" the actual allowlist associate with this project, if any, will be ignored. */ + reuse_sys_cve_allowlist?: string | null; + /** The ID of the tag retention policy for the project */ + retention_id?: string | null; + /** The bandwidth limit of proxy cache, in Kbps (kilobits per second). It limits the communication between Harbor and the upstream registry, not the client and the Harbor. */ + proxy_speed_kb?: string | null; +} + +export interface ProjectSummary { + /** The number of the repositories under this project. */ + repo_count: number; + /** The total number of project admin members. */ + project_admin_count?: number; + /** The total number of maintainer members. */ + maintainer_count?: number; + /** The total number of developer members. */ + developer_count?: number; + /** The total number of guest members. */ + guest_count?: number; + /** The total number of limited guest members. */ + limited_guest_count?: number; + quota?: ProjectSummaryQuota; + registry?: Registry; +} + +export interface ProjectSummaryQuota { + /** The hard limits of the quota */ + hard?: ResourceList; + /** The used status of the quota */ + used?: ResourceList; +} + +export interface ProjectScanner { + /** The identifier of the scanner registration */ + uuid: string; +} + +/** The CVE Allowlist for system or project */ +export interface CVEAllowlist { + /** ID of the allowlist */ + id?: number; + /** ID of the project which the allowlist belongs to. For system level allowlist this attribute is zero. */ + project_id?: number; + /** the time for expiration of the allowlist, in the form of seconds since epoch. This is an optional attribute, if it's not set the CVE allowlist does not expire. */ + expires_at?: number | null; + items?: CVEAllowlistItem[]; + /** + * The creation time of the allowlist. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the allowlist. + * @format date-time + */ + update_time?: string; +} + +/** The item in CVE allowlist */ +export interface CVEAllowlistItem { + /** The ID of the CVE, such as "CVE-2019-10164" */ + cve_id?: string; +} + +export interface ReplicationPolicy { + /** + * The policy ID. + * @format int64 + */ + id?: number; + /** The policy name. */ + name?: string; + /** The description of the policy. */ + description?: string; + /** The source registry. */ + src_registry?: Registry; + /** The destination registry. */ + dest_registry?: Registry; + /** The destination namespace. */ + dest_namespace?: string; + /** + * Specify how many path components will be replaced by the provided destination namespace. + * The default value is -1 in which case the legacy mode will be applied. + * @format int8 + */ + dest_namespace_replace_count?: number; + trigger?: ReplicationTrigger; + /** The replication policy filter array. */ + filters?: ReplicationFilter[]; + /** Whether to replicate the deletion operation. */ + replicate_deletion?: boolean; + /** Deprecated, use "replicate_deletion" instead. Whether to replicate the deletion operation. */ + deletion?: boolean; + /** Whether to override the resources on the destination registry. */ + override?: boolean; + /** Whether the policy is enabled or not. */ + enabled?: boolean; + /** + * The create time of the policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the policy. + * @format date-time + */ + update_time?: string; + /** + * speed limit for each task + * @format int32 + */ + speed?: number; + /** Whether to enable copy by chunk. */ + copy_by_chunk?: boolean; +} + +export interface ReplicationTrigger { + /** The replication policy trigger type. The valid values are manual, event_based and scheduled. */ + type?: string; + trigger_settings?: ReplicationTriggerSettings; +} + +export interface ReplicationTriggerSettings { + /** The cron string for scheduled trigger */ + cron?: string; +} + +export interface ReplicationFilter { + /** The replication policy filter type. */ + type?: string; + /** The value of replication policy filter. */ + value?: object; + /** matches or excludes the result */ + decoration?: string; +} + +export interface RegistryCredential { + /** Credential type, such as 'basic', 'oauth'. */ + type?: string; + /** Access key, e.g. user name when credential type is 'basic'. */ + access_key?: string; + /** Access secret, e.g. password when credential type is 'basic'. */ + access_secret?: string; +} + +export interface Registry { + /** + * The registry ID. + * @format int64 + */ + id: number; + /** The registry URL string. */ + url?: string; + /** The registry name. */ + name?: string; + credential?: RegistryCredential; + /** Type of the registry, e.g. 'harbor'. */ + type?: string; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean; + /** Description of the registry. */ + description?: string; + /** Health status of the registry. */ + status?: string; + /** + * The create time of the policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the policy. + * @format date-time + */ + update_time?: string; +} + +export interface RegistryUpdate { + /** The registry name. */ + name?: string | null; + /** Description of the registry. */ + description?: string | null; + /** The registry URL. */ + url?: string | null; + /** Credential type of the registry, e.g. 'basic'. */ + credential_type?: string | null; + /** The registry access key. */ + access_key?: string | null; + /** The registry access secret. */ + access_secret?: string | null; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean | null; +} + +export interface RegistryPing { + /** + * The registry ID. + * @format int64 + */ + id?: number | null; + /** Type of the registry, e.g. 'harbor'. */ + type?: string | null; + /** The registry URL. */ + url?: string | null; + /** Credential type of the registry, e.g. 'basic'. */ + credential_type?: string | null; + /** The registry access key. */ + access_key?: string | null; + /** The registry access secret. */ + access_secret?: string | null; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean | null; +} + +/** The registry info contains the base info and capability declarations of the registry */ +export interface RegistryInfo { + /** The registry type */ + type?: string; + /** The description */ + description?: string; + /** The filters that the registry supports */ + supported_resource_filters?: FilterStyle[]; + /** The triggers that the registry supports */ + supported_triggers?: string[]; + /** The registry whether support copy by chunk. */ + supported_copy_by_chunk?: boolean; +} + +/** The registry provider info contains the base info and capability declarations of the registry provider */ +export interface RegistryProviderInfo { + /** The endpoint pattern */ + endpoint_pattern?: RegistryProviderEndpointPattern; + /** The credential pattern */ + credential_pattern?: RegistryProviderCredentialPattern; +} + +/** The registry endpoint pattern */ +export interface RegistryProviderEndpointPattern { + /** The endpoint type */ + endpoint_type?: string; + /** The endpoint list */ + endpoints?: RegistryEndpoint[]; +} + +/** The registry credential pattern */ +export interface RegistryProviderCredentialPattern { + /** The access key type */ + access_key_type?: string; + /** The access key data */ + access_key_data?: string; + /** The access secret type */ + access_secret_type?: string; + /** The access secret data */ + access_secret_data?: string; +} + +/** The style of the resource filter */ +export interface RegistryEndpoint { + /** The endpoint key */ + key?: string; + /** The endpoint value */ + value?: string; +} + +/** The style of the resource filter */ +export interface FilterStyle { + /** The filter type */ + type?: string; + /** The filter style */ + style?: string; + /** The filter values */ + values?: string[]; +} + +export type ResourceList = Record; + +/** The replication execution */ +export interface ReplicationExecution { + /** The ID of the execution */ + id?: number; + /** The ID if the policy that the execution belongs to */ + policy_id?: number; + /** The status of the execution */ + status?: string; + /** The trigger mode */ + trigger?: string; + /** + * The start time + * @format date-time + */ + start_time?: string; + /** + * The end time + * @format date-time + */ + end_time?: string; + /** The status text */ + status_text: string; + /** The total count of all executions */ + total: number; + /** The count of failed executions */ + failed: number; + /** The count of succeed executions */ + succeed: number; + /** The count of in_progress executions */ + in_progress: number; + /** The count of stopped executions */ + stopped: number; +} + +export interface StartReplicationExecution { + /** + * The ID of policy that the execution belongs to. + * @format int64 + */ + policy_id?: number; +} + +/** The replication task */ +export interface ReplicationTask { + /** The ID of the task */ + id?: number; + /** The ID of the execution that the task belongs to */ + execution_id?: number; + /** The status of the task */ + status?: string; + /** The ID of the underlying job that the task related to */ + job_id?: string; + /** The operation of the task */ + operation?: string; + /** The type of the resource that the task operates */ + resource_type?: string; + /** The source resource that the task operates */ + src_resource?: string; + /** The destination resource that the task operates */ + dst_resource?: string; + /** + * The start time of the task + * @format date-time + */ + start_time?: string; + /** + * The end time of the task + * @format date-time + */ + end_time?: string; +} + +export interface Robot { + /** + * The ID of the robot + * @format int64 + */ + id?: number; + /** The name of the robot */ + name?: string; + /** The description of the robot */ + description?: string; + /** The secret of the robot */ + secret?: string; + /** The level of the robot, project or system */ + level?: string; + /** + * The duration of the robot in days, duration must be either -1(Never) or a positive integer + * @format int64 + */ + duration?: number | null; + /** The editable status of the robot */ + editable: boolean; + /** The disable status of the robot */ + disable: boolean; + /** + * The expiration date of the robot + * @format int64 + */ + expires_at?: number; + permissions?: RobotPermission[]; + /** The type of the robot creator, like local(harbor_user) or robot. */ + creator_type?: string; + /** The reference of the robot creator, like the id of harbor user. */ + creator_ref?: number; + /** + * The creation time of the robot. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the robot. + * @format date-time + */ + update_time?: string; +} + +/** The request for robot account creation. */ +export interface RobotCreate { + /** The name of the robot */ + name?: string; + /** The description of the robot */ + description?: string; + /** The secret of the robot */ + secret?: string; + /** The level of the robot, project or system */ + level?: string; + /** The disable status of the robot */ + disable?: boolean; + /** + * The duration of the robot in days, duration must be either -1(Never) or a positive integer + * @format int64 + */ + duration?: number; + permissions?: RobotPermission[]; +} + +/** The response for robot account creation. */ +export interface RobotCreated { + /** + * The ID of the robot + * @format int64 + */ + id?: number; + /** The name of the robot */ + name?: string; + /** The secret of the robot */ + secret?: string; + /** + * The creation time of the robot. + * @format date-time + */ + creation_time?: string; + /** + * The expiration date of the robot + * @format int64 + */ + expires_at?: number; +} + +/** The response for refresh/update robot account secret. */ +export interface RobotSec { + /** The secret of the robot */ + secret?: string; +} + +export interface RobotPermission { + /** The kind of the permission */ + kind?: string; + /** The namespace of the permission */ + namespace?: string; + access?: Access[]; +} + +export interface Access { + /** The resource of the access. Possible resources are listed here for system and project level https://github.com/goharbor/harbor/blob/main/src/common/rbac/const.go */ + resource?: string; + /** The action of the access. Possible actions are *, pull, push, create, read, update, delete, list, operate, scanner-pull and stop. */ + action?: string; + /** The effect of the access */ + effect?: string; +} + +export interface RobotCreateV1 { + /** The name of robot account */ + name?: string; + /** The description of robot account */ + description?: string; + /** The expiration time on or after which the JWT MUST NOT be accepted for processing. */ + expires_at?: number; + /** The permission of robot account */ + access?: Access[]; +} + +export interface Storage { + /** + * Total volume size. + * @format uint64 + */ + total?: number; + /** + * Free volume size. + * @format uint64 + */ + free?: number; +} + +export interface GeneralInfo { + /** + * The banner message for the UI. It is the stringified result of the banner message object. + * @example "{"closable":true,"message":"your banner message content","type":"warning","fromDate":"06/19/2023","toDate":"06/21/2023"}" + */ + banner_message?: string | null; + /** + * The current time of the server. + * @format date-time + */ + current_time?: string | null; + /** The url of registry against which the docker command should be issued. */ + registry_url?: string | null; + /** The external URL of Harbor, with protocol. */ + external_url?: string | null; + /** The auth mode of current Harbor instance. */ + auth_mode?: string | null; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: boolean | null; + /** Indicate who can create projects, it could be 'adminonly' or 'everyone'. */ + project_creation_restriction?: string | null; + /** Indicate whether the Harbor instance enable user to register himself. */ + self_registration?: boolean | null; + /** Indicate whether there is a ca root cert file ready for download in the file system. */ + has_ca_root?: boolean | null; + /** The build version of Harbor. */ + harbor_version?: string | null; + /** The storage provider's name of Harbor registry */ + registry_storage_provider_name?: string | null; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: boolean | null; + /** The flag to indicate whether notification mechanism is enabled on Harbor instance. */ + notification_enable?: boolean | null; + /** The setting of auth proxy this is only available when Harbor relies on authproxy for authentication. */ + authproxy_settings?: AuthproxySetting | null; + /** The OIDC provider name, empty if current auth is not OIDC_auth or OIDC provider is not configured. */ + oidc_provider_name?: string | null; +} + +export interface AuthproxySetting { + /** The fully qualified URI of login endpoint of authproxy, such as 'https://192.168.1.2:8443/login' */ + endpoint?: string; + /** The fully qualified URI of token review endpoint of authproxy, such as 'https://192.168.1.2:8443/tokenreview' */ + tokenreivew_endpoint?: string; + /** The flag to determine whether Harbor can skip search the user/group when adding him as a member. */ + skip_search?: boolean; + /** The flag to determine whether Harbor should verify the certificate when connecting to the auth proxy. */ + verify_cert?: boolean; + /** The certificate to be pinned when connecting auth proxy. */ + server_certificate?: string; +} + +export interface SystemInfo { + /** The storage of system. */ + storage?: Storage[]; +} + +export interface GCHistory { + /** the id of gc job. */ + id?: number; + /** the job name of gc job. */ + job_name?: string; + /** the job kind of gc job. */ + job_kind?: string; + /** the job parameters of gc job. */ + job_parameters?: string; + schedule?: ScheduleObj; + /** the status of gc job. */ + job_status?: string; + /** if gc job was deleted. */ + deleted?: boolean; + /** + * the creation time of gc job. + * @format date-time + */ + creation_time?: string; + /** + * the update time of gc job. + * @format date-time + */ + update_time?: string; +} + +export interface ExecHistory { + /** the id of purge job. */ + id?: number; + /** the job name of purge job. */ + job_name?: string; + /** the job kind of purge job. */ + job_kind?: string; + /** the job parameters of purge job. */ + job_parameters?: string; + schedule?: ScheduleObj; + /** the status of purge job. */ + job_status?: string; + /** if purge job was deleted. */ + deleted?: boolean; + /** + * the creation time of purge job. + * @format date-time + */ + creation_time?: string; + /** + * the update time of purge job. + * @format date-time + */ + update_time?: string; +} + +export interface Schedule { + /** The id of the schedule. */ + id?: number; + /** The status of the schedule. */ + status?: string; + /** + * the creation time of the schedule. + * @format date-time + */ + creation_time?: string; + /** + * the update time of the schedule. + * @format date-time + */ + update_time?: string; + schedule?: ScheduleObj; + /** The parameters of schedule job */ + parameters?: Record; +} + +export interface ScheduleObj { + /** + * The schedule type. The valid values are 'Hourly', 'Daily', 'Weekly', 'Custom', 'Manual', 'None' and 'Schedule'. + * 'Manual' means to trigger it right away, 'Schedule' means to trigger it by a specified cron schedule and + * 'None' means to cancel the schedule. + */ + type?: + | "Hourly" + | "Daily" + | "Weekly" + | "Custom" + | "Manual" + | "None" + | "Schedule"; + /** A cron expression, a time-based job scheduler. */ + cron?: string; + /** + * The next time to schedule to run the job. + * @format date-time + */ + next_scheduled_time?: string; +} + +/** Stats provides the overall progress of the scan all process. */ +export interface Stats { + /** + * The total number of scan processes triggered by the scan all action + * @format int + * @example 100 + */ + total: number; + /** + * The number of the finished scan processes triggered by the scan all action + * @format int + * @example 90 + */ + completed: number; + /** + * The metrics data for the each status + * @example {"Success":5,"Error":2,"Running":3} + */ + metrics?: Record; + /** A flag indicating job status of scan all. */ + ongoing: boolean; + /** The trigger of the scan all job. */ + trigger?: "Manual" | "Schedule" | "Event"; +} + +/** the tag retention metadata */ +export interface RetentionMetadata { + /** templates */ + templates?: RetentionRuleMetadata[]; + /** supported scope selectors */ + scope_selectors?: RetentionSelectorMetadata[]; + /** supported tag selectors */ + tag_selectors?: RetentionSelectorMetadata[]; +} + +/** the tag retention rule metadata */ +export interface RetentionRuleMetadata { + /** rule id */ + rule_template?: string; + /** rule display text */ + display_text?: string; + /** rule action */ + action?: string; + /** rule params */ + params?: RetentionRuleParamMetadata[]; +} + +/** rule param */ +export interface RetentionRuleParamMetadata { + type?: string; + unit?: string; + required?: boolean; +} + +/** retention selector */ +export interface RetentionSelectorMetadata { + display_text?: string; + kind?: string; + decorations?: string[]; +} + +/** retention policy */ +export interface RetentionPolicy { + /** @format int64 */ + id?: number; + algorithm?: string; + rules?: RetentionRule[]; + trigger?: RetentionRuleTrigger; + scope?: RetentionPolicyScope; +} + +export interface RetentionRuleTrigger { + kind?: string; + settings?: object; + references?: object; +} + +export interface RetentionPolicyScope { + level?: string; + ref?: number; +} + +export interface RetentionRule { + id?: number; + priority?: number; + disabled?: boolean; + action?: string; + template?: string; + params?: Record; + tag_selectors?: RetentionSelector[]; + scope_selectors?: Record; +} + +export interface RetentionSelector { + kind?: string; + decoration?: string; + pattern?: string; + extras?: string; +} + +export interface RetentionExecution { + /** @format int64 */ + id?: number; + /** @format int64 */ + policy_id?: number; + start_time?: string; + end_time?: string; + status?: string; + trigger?: string; + dry_run?: boolean; +} + +export interface RetentionExecutionTask { + /** @format int64 */ + id?: number; + /** @format int64 */ + execution_id?: number; + repository?: string; + job_id?: string; + status?: string; + status_code: number; + /** @format int64 */ + status_revision?: number; + start_time?: string; + end_time?: string; + total: number; + retained: number; +} + +export interface QuotaUpdateReq { + /** The new hard limits for the quota */ + hard?: ResourceList; +} + +export type QuotaRefObject = Record; + +/** The quota object */ +export interface Quota { + /** ID of the quota */ + id?: number; + /** The reference object of the quota */ + ref?: QuotaRefObject; + /** The hard limits of the quota */ + hard: ResourceList; + /** The used status of the quota */ + used: ResourceList; + /** + * the creation time of the quota + * @format date-time + */ + creation_time?: string; + /** + * the update time of the quota + * @format date-time + */ + update_time?: string; +} + +/** Registration represents a named configuration for invoking a scanner via its adapter. */ +export interface ScannerRegistration { + /** The unique identifier of this registration. */ + uuid?: string; + /** + * The name of this registration. + * @example "Trivy" + */ + name?: string; + /** + * An optional description of this registration. + * @example "A free-to-use tool that scans container images for package vulnerabilities. + * " + */ + description: string; + /** + * A base URL of the scanner adapter + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url?: string; + /** + * Indicate whether the registration is enabled or not + * @default false + */ + disabled: boolean; + /** + * Indicate if the registration is set as the system default one + * @default false + */ + is_default: boolean; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @default "" + * @example "Bearer" + */ + auth: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential: string; + /** + * Indicate if skip the certificate verification when sending HTTP requests + * @default false + */ + skip_certVerify: boolean; + /** + * Indicate whether use internal registry addr for the scanner to pull content or not + * @default false + */ + use_internal_addr: boolean; + /** + * The creation time of this registration + * @format date-time + */ + create_time?: string; + /** + * The update time of this registration + * @format date-time + */ + update_time?: string; + /** + * Optional property to describe the name of the scanner registration + * @example "Trivy" + */ + adapter?: string; + /** + * Optional property to describe the vendor of the scanner registration + * @example "CentOS" + */ + vendor?: string; + /** + * Optional property to describe the version of the scanner registration + * @example "1.0.1" + */ + version?: string; + /** + * Indicate the healthy of the registration + * @default "" + * @example "healthy" + */ + health?: string; + /** + * Indicates the capabilities of the scanner, e.g. support_vulnerability or support_sbom. + * @example {"support_vulnerability":true,"support_sbom":true} + */ + capabilities?: Record; +} + +export interface ScannerRegistrationReq { + /** + * The name of this registration + * @example "Trivy" + */ + name: string; + /** + * An optional description of this registration. + * @example "A free-to-use tool that scans container images for package vulnerabilities. + * " + */ + description?: string; + /** + * A base URL of the scanner adapter. + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url: string; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @example "Bearer" + */ + auth?: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential?: string; + /** + * Indicate if skip the certificate verification when sending HTTP requests + * @default false + */ + skip_certVerify?: boolean; + /** + * Indicate whether use internal registry addr for the scanner to pull content or not + * @default false + */ + use_internal_addr?: boolean; + /** + * Indicate whether the registration is enabled or not + * @default false + */ + disabled?: boolean; +} + +export interface ScannerRegistrationSettings { + /** + * The name of this registration + * @example "Trivy" + */ + name: string; + /** + * A base URL of the scanner adapter. + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url: string; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @default "" + */ + auth?: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential?: string; +} + +export interface IsDefault { + /** A flag indicating whether a scanner registration is default. */ + is_default?: boolean; +} + +export interface ScannerCapability { + /** + * Specify the type of scanner capability, like vulnerability or sbom + * @example "sbom" + */ + type: string; + consumes_mime_types?: string[]; + produces_mime_types?: string[]; +} + +/** The metadata info of the scanner adapter */ +export interface ScannerAdapterMetadata { + scanner?: Scanner; + capabilities?: ScannerCapability[]; + /** @example {"harbor.scanner-adapter/registry-authorization-type":"Bearer"} */ + properties?: Record; +} + +export interface ImmutableRule { + id?: number; + priority?: number; + disabled?: boolean; + action?: string; + template?: string; + params?: Record; + tag_selectors?: ImmutableSelector[]; + scope_selectors?: Record; +} + +export interface ImmutableSelector { + kind?: string; + decoration?: string; + pattern?: string; + extras?: string; +} + +/** The ldap configure properties */ +export interface LdapConf { + /** The url of ldap service. */ + ldap_url?: string; + /** The search dn of ldap service. */ + ldap_search_dn?: string; + /** The search password of ldap service. */ + ldap_search_password?: string; + /** The base dn of ldap service. */ + ldap_base_dn?: string; + /** The serach filter of ldap service. */ + ldap_filter?: string; + /** The serach uid from ldap service attributes. */ + ldap_uid?: string; + /** + * The serach scope of ldap service. + * @format int64 + */ + ldap_scope?: number; + /** + * The connect timeout of ldap service(second). + * @format int64 + */ + ldap_connection_timeout?: number; + /** Verify Ldap server certificate. */ + ldap_verify_cert?: boolean; +} + +/** The ldap ping result */ +export interface LdapPingResult { + /** Test success */ + success?: boolean; + /** The ping operation output message. */ + message?: string; +} + +export interface LdapImportUsers { + /** selected uid list */ + ldap_uid_list?: string[]; +} + +export interface LdapFailedImportUser { + /** the uid can't add to system. */ + uid?: string; + /** fail reason. */ + error?: string; +} + +export interface LdapUser { + /** ldap username. */ + username?: string; + /** The user realname from "uid" or "cn" attribute. */ + realname?: string; + /** The user email address from "mail" or "email" attribute. */ + email?: string; +} + +export interface UserGroup { + /** The ID of the user group */ + id?: number; + /** The name of the user group */ + group_name?: string; + /** The group type, 1 for LDAP group, 2 for HTTP group, 3 for OIDC group. */ + group_type?: number; + /** The DN of the LDAP group if group type is 1 (LDAP group). */ + ldap_group_dn?: string; +} + +export interface UserGroupSearchItem { + /** The ID of the user group */ + id?: number; + /** The name of the user group */ + group_name?: string; + /** The group type, 1 for LDAP group, 2 for HTTP group, 3 for OIDC group. */ + group_type?: number; +} + +/** Supported webhook event types and notify types. */ +export interface SupportedWebhookEventTypes { + event_type?: EventType[]; + notify_type?: NotifyType[]; + payload_formats?: PayloadFormat[]; +} + +/** + * Webhook supported event type. + * @example "PULL_ARTIFACT" + */ +export type EventType = string; + +/** + * Webhook supported notify type. + * @example "http" + */ +export type NotifyType = string; + +/** + * The type of webhook paylod format. + * @example "CloudEvents" + */ +export type PayloadFormatType = string; + +/** Webhook supported payload format type collections. */ +export interface PayloadFormat { + /** Webhook supported notify type. */ + notify_type?: NotifyType; + /** The supported payload formats for this notify type. */ + formats?: PayloadFormatType[]; +} + +/** The webhook policy target object. */ +export interface WebhookTargetObject { + /** The webhook target notify type. */ + type?: string; + /** The webhook target address. */ + address?: string; + /** The webhook auth header. */ + auth_header?: string; + /** Whether or not to skip cert verify. */ + skip_cert_verify?: boolean; + /** The payload format of webhook, by default is Default for http type. */ + payload_format?: PayloadFormatType; +} + +/** The webhook policy object */ +export interface WebhookPolicy { + /** + * The webhook policy ID. + * @format int64 + */ + id?: number; + /** The name of webhook policy. */ + name?: string; + /** The description of webhook policy. */ + description?: string; + /** The project ID of webhook policy. */ + project_id?: number; + targets?: WebhookTargetObject[]; + event_types?: string[]; + /** The creator of the webhook policy. */ + creator?: string; + /** + * The create time of the webhook policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the webhook policy. + * @format date-time + */ + update_time?: string; + /** Whether the webhook policy is enabled or not. */ + enabled: boolean; +} + +/** The webhook policy and last trigger time group by event type. */ +export interface WebhookLastTrigger { + /** The webhook policy name. */ + policy_name?: string; + /** The webhook event type. */ + event_type?: string; + /** Whether or not the webhook policy enabled. */ + enabled?: boolean; + /** + * The creation time of webhook policy. + * @format date-time + */ + creation_time?: string; + /** + * The last trigger time of webhook policy. + * @format date-time + */ + last_trigger_time?: string; +} + +/** The webhook job. */ +export interface WebhookJob { + /** + * The webhook job ID. + * @format int64 + */ + id?: number; + /** + * The webhook policy ID. + * @format int64 + */ + policy_id?: number; + /** The webhook job event type. */ + event_type?: string; + /** The webhook job notify type. */ + notify_type?: string; + /** The webhook job status. */ + status?: string; + /** The webhook job notify detailed data. */ + job_detail?: string; + /** + * The webhook job creation time. + * @format date-time + */ + creation_time?: string; + /** + * The webhook job update time. + * @format date-time + */ + update_time?: string; +} + +export type InternalConfigurationsResponse = Record< + string, + InternalConfigurationValue +>; + +export interface InternalConfigurationValue { + /** The value of current config item */ + value?: object; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface ConfigurationsResponse { + /** The auth mode of current system, such as "db_auth", "ldap_auth", "oidc_auth" */ + auth_mode?: StringConfigItem; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: BoolConfigItem; + /** The Base DN for LDAP binding. */ + ldap_base_dn?: StringConfigItem; + /** The filter for LDAP search */ + ldap_filter?: StringConfigItem; + /** The base DN to search LDAP group. */ + ldap_group_base_dn?: StringConfigItem; + /** Specify the ldap group which have the same privilege with Harbor admin */ + ldap_group_admin_dn?: StringConfigItem; + /** The attribute which is used as identity of the LDAP group, default is cn.' */ + ldap_group_attribute_name?: StringConfigItem; + /** The filter to search the ldap group */ + ldap_group_search_filter?: StringConfigItem; + /** The scope to search ldap group. ''0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE'' */ + ldap_group_search_scope?: IntegerConfigItem; + /** Attach LDAP user group information in parallel. */ + ldap_group_attach_parallel?: BoolConfigItem; + /** The scope to search ldap users,'0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE' */ + ldap_scope?: IntegerConfigItem; + /** The DN of the user to do the search. */ + ldap_search_dn?: StringConfigItem; + /** Timeout in seconds for connection to LDAP server */ + ldap_timeout?: IntegerConfigItem; + /** The attribute which is used as identity for the LDAP binding, such as "CN" or "SAMAccountname" */ + ldap_uid?: StringConfigItem; + /** The URL of LDAP server */ + ldap_url?: StringConfigItem; + /** Whether verify your OIDC server certificate, disable it if your OIDC server is hosted via self-hosted certificate. */ + ldap_verify_cert?: BoolConfigItem; + /** The user attribute to identify the group membership */ + ldap_group_membership_attribute?: StringConfigItem; + /** Indicate who can create projects, it could be ''adminonly'' or ''everyone''. */ + project_creation_restriction?: StringConfigItem; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: BoolConfigItem; + /** Whether the Harbor instance supports self-registration. If it''s set to false, admin need to add user to the instance. */ + self_registration?: BoolConfigItem; + /** The expiration time of the token for internal Registry, in minutes. */ + token_expiration?: IntegerConfigItem; + /** The client id of UAA */ + uaa_client_id?: StringConfigItem; + /** The client secret of the UAA */ + uaa_client_secret?: StringConfigItem; + /** The endpoint of the UAA */ + uaa_endpoint?: StringConfigItem; + /** Verify the certificate in UAA server */ + uaa_verify_cert?: BoolConfigItem; + /** The endpoint of the HTTP auth */ + http_authproxy_endpoint?: StringConfigItem; + /** The token review endpoint */ + http_authproxy_tokenreview_endpoint?: StringConfigItem; + /** The group which has the harbor admin privileges */ + http_authproxy_admin_groups?: StringConfigItem; + /** The usernames which has the harbor admin privileges */ + http_authproxy_admin_usernames?: StringConfigItem; + /** Verify the HTTP auth provider's certificate */ + http_authproxy_verify_cert?: BoolConfigItem; + /** Search user before onboard */ + http_authproxy_skip_search?: BoolConfigItem; + /** The certificate of the HTTP auth provider */ + http_authproxy_server_certificate?: StringConfigItem; + /** The OIDC provider name */ + oidc_name?: StringConfigItem; + /** The endpoint of the OIDC provider */ + oidc_endpoint?: StringConfigItem; + /** The client ID of the OIDC provider */ + oidc_client_id?: StringConfigItem; + /** The attribute claims the group name */ + oidc_groups_claim?: StringConfigItem; + /** The OIDC group which has the harbor admin privileges */ + oidc_admin_group?: StringConfigItem; + /** The OIDC group filter which filters out the group doesn't match the regular expression */ + oidc_group_filter?: StringConfigItem; + /** The scope of the OIDC provider */ + oidc_scope?: StringConfigItem; + /** The attribute claims the username */ + oidc_user_claim?: StringConfigItem; + /** Verify the OIDC provider's certificate' */ + oidc_verify_cert?: BoolConfigItem; + /** Auto onboard the OIDC user */ + oidc_auto_onboard?: BoolConfigItem; + /** Extra parameters to add when redirect request to OIDC provider */ + oidc_extra_redirect_parms?: StringConfigItem; + /** Extra parameters to logout user session from the OIDC provider */ + oidc_logout?: BoolConfigItem; + /** The robot account token duration in days */ + robot_token_duration?: IntegerConfigItem; + /** The rebot account name prefix */ + robot_name_prefix?: StringConfigItem; + /** Enable notification */ + notification_enable?: BoolConfigItem; + /** Enable quota per project */ + quota_per_project_enable?: BoolConfigItem; + /** The storage quota per project */ + storage_per_project?: IntegerConfigItem; + /** The endpoint of the audit log forwarder */ + audit_log_forward_endpoint?: StringConfigItem; + /** Whether skip the audit log in database */ + skip_audit_log_database?: BoolConfigItem; + /** Whether or not to skip update the pull time for scanner */ + scanner_skip_update_pulltime?: BoolConfigItem; + scan_all_policy?: { + /** The type of scan all policy, currently the valid values are "none" and "daily" */ + type?: string; + /** The parameters of the policy, the values are dependent on the type of the policy. */ + parameter?: { + /** The offset in seconds of UTC 0 o'clock, only valid when the policy type is "daily" */ + daily_time?: number; + }; + }; + /** The session timeout in minutes */ + session_timeout?: IntegerConfigItem; + /** The banner message for the UI.It is the stringified result of the banner message object */ + banner_message?: StringConfigItem; + /** The audit log event types to skip to log in database */ + disabled_audit_log_event_types?: StringConfigItem; +} + +export interface Configurations { + /** The auth mode of current system, such as "db_auth", "ldap_auth", "oidc_auth" */ + auth_mode?: string; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: boolean | null; + /** The Base DN for LDAP binding. */ + ldap_base_dn?: string; + /** The filter for LDAP search */ + ldap_filter?: string; + /** The base DN to search LDAP group. */ + ldap_group_base_dn?: string; + /** Specify the ldap group which have the same privilege with Harbor admin */ + ldap_group_admin_dn?: string; + /** The attribute which is used as identity of the LDAP group, default is cn.' */ + ldap_group_attribute_name?: string; + /** The filter to search the ldap group */ + ldap_group_search_filter?: string; + /** The scope to search ldap group. ''0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE'' */ + ldap_group_search_scope?: number; + /** Attach LDAP user group information in parallel, the parallel worker count is 5 */ + ldap_group_attach_parallel?: boolean; + /** The scope to search ldap users,'0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE' */ + ldap_scope?: number; + /** The DN of the user to do the search. */ + ldap_search_dn?: string; + /** The password of the ldap search dn */ + ldap_search_password?: string; + /** Timeout in seconds for connection to LDAP server */ + ldap_timeout?: number; + /** The attribute which is used as identity for the LDAP binding, such as "CN" or "SAMAccountname" */ + ldap_uid?: string; + /** The URL of LDAP server */ + ldap_url?: string; + /** Whether verify your OIDC server certificate, disable it if your OIDC server is hosted via self-hosted certificate. */ + ldap_verify_cert?: boolean; + /** The user attribute to identify the group membership */ + ldap_group_membership_attribute?: string; + /** Indicate who can create projects, it could be ''adminonly'' or ''everyone''. */ + project_creation_restriction?: string; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: boolean; + /** Whether the Harbor instance supports self-registration. If it''s set to false, admin need to add user to the instance. */ + self_registration?: boolean; + /** The expiration time of the token for internal Registry, in minutes. */ + token_expiration?: number; + /** The client id of UAA */ + uaa_client_id?: string; + /** The client secret of the UAA */ + uaa_client_secret?: string; + /** The endpoint of the UAA */ + uaa_endpoint?: string; + /** Verify the certificate in UAA server */ + uaa_verify_cert?: boolean; + /** The endpoint of the HTTP auth */ + http_authproxy_endpoint?: string; + /** The token review endpoint */ + http_authproxy_tokenreview_endpoint?: string; + /** The group which has the harbor admin privileges */ + http_authproxy_admin_groups?: string; + /** The username which has the harbor admin privileges */ + http_authproxy_admin_usernames?: string; + /** Verify the HTTP auth provider's certificate */ + http_authproxy_verify_cert?: boolean; + /** Search user before onboard */ + http_authproxy_skip_search?: boolean; + /** The certificate of the HTTP auth provider */ + http_authproxy_server_certificate?: string; + /** The OIDC provider name */ + oidc_name?: string; + /** The endpoint of the OIDC provider */ + oidc_endpoint?: string; + /** The client ID of the OIDC provider */ + oidc_client_id?: string; + /** The OIDC provider secret */ + oidc_client_secret?: string; + /** The attribute claims the group name */ + oidc_groups_claim?: string; + /** The OIDC group which has the harbor admin privileges */ + oidc_admin_group?: string; + /** The OIDC group filter which filters out the group name doesn't match the regular expression */ + oidc_group_filter?: string; + /** The scope of the OIDC provider */ + oidc_scope?: string; + /** The attribute claims the username */ + oidc_user_claim?: string; + /** Verify the OIDC provider's certificate' */ + oidc_verify_cert?: boolean; + /** Auto onboard the OIDC user */ + oidc_auto_onboard?: boolean; + /** Extra parameters to add when redirect request to OIDC provider */ + oidc_extra_redirect_parms?: string; + /** Logout OIDC user session */ + oidc_logout?: boolean; + /** The robot account token duration in days */ + robot_token_duration?: number; + /** The rebot account name prefix */ + robot_name_prefix?: string; + /** Enable notification */ + notification_enable?: boolean; + /** Enable quota per project */ + quota_per_project_enable?: boolean; + /** The storage quota per project */ + storage_per_project?: number; + /** The audit log forward endpoint */ + audit_log_forward_endpoint?: string; + /** Skip audit log database */ + skip_audit_log_database?: boolean; + /** The session timeout for harbor, in minutes. */ + session_timeout?: number; + /** Whether or not to skip update pull time for scanner */ + scanner_skip_update_pulltime?: boolean; + /** The banner message for the UI.It is the stringified result of the banner message object */ + banner_message?: string; + /** the list to disable log audit event types. */ + disabled_audit_log_event_types?: string; +} + +export interface StringConfigItem { + /** The string value of current config item */ + value: string; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface BoolConfigItem { + /** The boolean value of current config item */ + value: boolean; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface IntegerConfigItem { + /** The integer value of current config item */ + value: number; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface ProjectMemberEntity { + /** the project member id */ + id?: number; + /** the project id */ + project_id?: number; + /** the name of the group member. */ + entity_name?: string; + /** the name of the role */ + role_name?: string; + /** the role id */ + role_id?: number; + /** the id of entity, if the member is a user, it is user_id in user table. if the member is a user group, it is the user group's ID in user_group table. */ + entity_id?: number; + /** the entity's type, u for user entity, g for group entity. */ + entity_type?: string; +} + +export interface ProjectMember { + /** The role id 1 for projectAdmin, 2 for developer, 3 for guest, 4 for maintainer */ + role_id?: number; + member_user?: UserEntity; + member_group?: UserGroup; +} + +export interface RoleRequest { + /** The role id 1 for projectAdmin, 2 for developer, 3 for guest, 4 for maintainer */ + role_id?: number; +} + +export interface UserEntity { + /** The ID of the user. */ + user_id?: number; + /** The name of the user. */ + username?: string; +} + +export interface UserProfile { + email?: string; + realname?: string; + comment?: string; +} + +export interface UserCreationReq { + /** @maxLength 255 */ + email?: string; + realname?: string; + comment?: string; + password?: string; + /** @maxLength 255 */ + username?: string; +} + +export interface OIDCUserInfo { + /** + * the ID of the OIDC info record + * @format int + */ + id?: number; + /** + * the ID of the user + * @format int + */ + user_id?: number; + /** the concatenation of sub and issuer in the ID token */ + subiss?: string; + /** the secret of the OIDC user that can be used for CLI to push/pull artifacts */ + secret?: string; + /** + * The creation time of the OIDC user info record. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the OIDC user info record. + * @format date-time + */ + update_time?: string; +} + +export interface UserResp { + email?: string; + realname?: string; + comment?: string; + /** @format int */ + user_id?: number; + username?: string; + sysadmin_flag: boolean; + /** indicate the admin privilege is grant by authenticator (LDAP), is always false unless it is the current login user */ + admin_role_in_auth: boolean; + oidc_user_meta?: OIDCUserInfo; + /** + * The creation time of the user. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the user. + * @format date-time + */ + update_time?: string; +} + +export interface UserSysAdminFlag { + /** true-admin, false-not admin. */ + sysadmin_flag?: boolean; +} + +export interface UserSearch { + /** + * The ID of the user. + * @format int + */ + user_id?: number; + username?: string; +} + +export interface PasswordReq { + /** The user's existing password. */ + old_password?: string; + /** New password for marking as to be updated. */ + new_password?: string; +} + +export interface UserSearchRespItem { + /** + * The ID of the user. + * @format int + */ + user_id?: number; + username?: string; +} + +export interface Permission { + /** The permission resoruce */ + resource?: string; + /** The permission action */ + action?: string; +} + +export interface Permissions { + /** The system level permissions */ + system?: Permission[]; + /** The project level permissions */ + project?: Permission[]; +} + +export interface OIDCCliSecretReq { + /** The new secret */ + secret?: string; +} + +/** The system health status */ +export interface OverallHealthStatus { + /** The overall health status. It is "healthy" only when all the components' status are "healthy" */ + status?: string; + components?: ComponentHealthStatus[]; +} + +/** The health status of component */ +export interface ComponentHealthStatus { + /** The component name */ + name?: string; + /** The health status of component. Is either "healthy" or "unhealthy". */ + status?: string; + /** (optional) The error message when the status is "unhealthy" */ + error?: string; +} + +export interface Statistic { + /** + * The count of the private projects + * @format int64 + */ + private_project_count: number; + /** + * The count of the private repositories + * @format int64 + */ + private_repo_count: number; + /** + * The count of the public projects + * @format int64 + */ + public_project_count: number; + /** + * The count of the public repositories + * @format int64 + */ + public_repo_count: number; + /** + * The count of the total projects, only be seen by the system admin + * @format int64 + */ + total_project_count: number; + /** + * The count of the total repositories, only be seen by the system admin + * @format int64 + */ + total_repo_count: number; + /** + * The total storage consumption of blobs, only be seen by the system admin + * @format int64 + */ + total_storage_consumption: number; +} + +/** The accessory of the artifact */ +export interface Accessory { + /** + * The ID of the accessory + * @format int64 + */ + id?: number; + /** + * The artifact id of the accessory + * @format int64 + */ + artifact_id: number; + /** + * Going to be deprecated, use repo and digest for insteand. The subject artifact id of the accessory. + * @format int64 + */ + subject_artifact_id?: number; + /** The subject artifact digest of the accessory */ + subject_artifact_digest: string; + /** The subject artifact repository name of the accessory */ + subject_artifact_repo: string; + /** + * The artifact size of the accessory + * @format int64 + */ + size: number; + /** The artifact digest of the accessory */ + digest: string; + /** The artifact size of the accessory */ + type: string; + /** The icon of the accessory */ + icon: string; + /** + * The creation time of the accessory + * @format date-time + */ + creation_time?: string; +} + +/** The criteria to select the scan data to export. */ +export interface ScanDataExportRequest { + /** Name of the scan data export job */ + job_name?: string; + /** A list of one or more projects for which to export the scan data, currently only one project is supported due to performance concerns, but define as array for extension in the future. */ + projects?: number[]; + /** A list of one or more labels for which to export the scan data, defaults to all if empty */ + labels?: number[]; + /** A list of repositories for which to export the scan data, defaults to all if empty */ + repositories?: string; + /** CVE-IDs for which to export data. Multiple CVE-IDs can be specified by separating using ',' and enclosed between '{}'. Defaults to all if empty */ + cveIds?: string; + /** A list of tags enclosed within '{}'. Defaults to all if empty */ + tags?: string; +} + +/** The metadata associated with the scan data export job */ +export interface ScanDataExportJob { + /** + * The id of the scan data export job + * @format int64 + */ + id?: number; +} + +/** The replication execution */ +export interface ScanDataExportExecution { + /** The ID of the execution */ + id?: number; + /** The ID if the user triggering the export job */ + user_id?: number; + /** The status of the execution */ + status?: string; + /** The trigger mode */ + trigger?: string; + /** + * The start time + * @format date-time + */ + start_time?: string; + /** + * The end time + * @format date-time + */ + end_time?: string; + /** The status text */ + status_text: string; + /** The name of the user triggering the job */ + user_name: string; + /** Indicates whether the export artifact is present in registry */ + file_present: boolean; +} + +/** The list of scan data export executions */ +export interface ScanDataExportExecutionList { + /** The list of scan data export executions */ + items?: ScanDataExportExecution[]; +} + +/** the worker pool of job service */ +export interface WorkerPool { + /** the process id of jobservice */ + pid?: number; + /** the id of the worker pool */ + worker_pool_id?: string; + /** + * The start time of the work pool + * @format date-time + */ + start_at?: string; + /** + * The heartbeat time of the work pool + * @format date-time + */ + heartbeat_at?: string; + /** The concurrency of the work pool */ + concurrency?: number; + /** The host of the work pool */ + host?: string; +} + +/** worker in the pool */ +export interface Worker { + /** the id of the worker */ + id?: string; + /** the id of the worker pool */ + pool_id?: string; + /** the name of the running job in the worker */ + job_name?: string; + /** the id of the running job in the worker */ + job_id?: string; + /** + * The start time of the worker + * @format date-time + */ + start_at?: string | null; + /** the checkin of the running job in the worker */ + check_in?: string; + /** + * The checkin time of the worker + * @format date-time + */ + checkin_at?: string | null; +} + +/** The request to stop, pause or resume */ +export interface ActionRequest { + /** The action of the request, should be stop, pause or resume */ + action?: "stop" | "pause" | "resume"; +} + +/** the job queue info */ +export interface JobQueue { + /** The type of the job queue */ + job_type?: string; + /** The count of jobs in the job queue */ + count?: number; + /** The latency the job queue (seconds) */ + latency?: number; + /** The paused status of the job queue */ + paused: boolean; +} + +/** the schedule task info */ +export interface ScheduleTask { + /** the id of the Schedule task */ + id?: number; + /** the vendor type of the current schedule task */ + vendor_type?: string; + /** the vendor id of the current task */ + vendor_id?: number; + /** the cron of the current schedule task */ + cron?: string; + /** + * the update time of the schedule task + * @format date-time + */ + update_time?: string; +} + +/** the scheduler status */ +export interface SchedulerStatus { + /** if the scheduler is paused */ + paused: boolean; +} + +/** the security summary */ +export interface SecuritySummary { + /** + * the count of critical vulnerabilities + * @format int64 + */ + critical_cnt: number; + /** + * the count of high vulnerabilities + * @format int64 + */ + high_cnt?: number; + /** + * the count of medium vulnerabilities + * @format int64 + */ + medium_cnt: number; + /** + * the count of low vulnerabilities + * @format int64 + */ + low_cnt: number; + /** + * the count of none vulnerabilities + * @format int64 + */ + none_cnt?: number; + /** + * the count of unknown vulnerabilities + * @format int64 + */ + unknown_cnt?: number; + /** + * the count of total vulnerabilities + * @format int64 + */ + total_vuls: number; + /** + * the count of scanned artifacts + * @format int64 + */ + scanned_cnt: number; + /** + * the total count of artifacts + * @format int64 + */ + total_artifact: number; + /** + * the count of fixable vulnerabilities + * @format int64 + */ + fixable_cnt: number; + /** the list of dangerous CVEs */ + dangerous_cves?: DangerousCVE[]; + /** the list of dangerous artifacts */ + dangerous_artifacts?: DangerousArtifact[]; +} + +/** the dangerous CVE information */ +export interface DangerousCVE { + /** the cve id */ + cve_id?: string; + /** the severity of the CVE */ + severity?: string; + /** + * the cvss score v3 + * @format float64 + */ + cvss_score_v3?: number; + /** the description of the CVE */ + desc?: string; + /** the package of the CVE */ + package?: string; + /** the version of the package */ + version?: string; +} + +/** the dangerous artifact information */ +export interface DangerousArtifact { + /** + * the project id of the artifact + * @format int64 + */ + project_id?: number; + /** the repository name of the artifact */ + repository_name?: string; + /** the digest of the artifact */ + digest?: string; + /** the count of critical vulnerabilities */ + critical_cnt: number; + /** + * the count of high vulnerabilities + * @format int64 + */ + high_cnt: number; + /** the count of medium vulnerabilities */ + medium_cnt: number; +} + +/** the vulnerability item info */ +export interface VulnerabilityItem { + /** + * the project ID of the artifact + * @format int64 + */ + project_id?: number; + /** the repository name of the artifact */ + repository_name?: string; + /** the digest of the artifact */ + digest?: string; + /** the tags of the artifact */ + tags?: string[]; + /** the CVE id of the vulnerability. */ + cve_id?: string; + /** the severity of the vulnerability */ + severity?: string; + /** + * the nvd cvss v3 score of the vulnerability + * @format float + */ + cvss_v3_score?: number; + /** the package of the vulnerability */ + package?: string; + /** the version of the package */ + version?: string; + /** the fixed version of the package */ + fixed_version?: string; + /** The description of the vulnerability */ + desc?: string; + /** Links of the vulnerability */ + links?: string[]; +} + +export interface ScanType { + /** The scan type for the scan request. Two options are currently supported, vulnerability and sbom */ + scan_type?: "vulnerability" | "sbom"; +} + +import type { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + HeadersDefaults, + ResponseType, +} from "axios"; +import axios from "axios"; + +export type QueryParamsType = Record; + +export interface FullRequestParams + extends Omit { + /** set parameter to `true` for call `securityWorker` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseType; + /** request body */ + body?: unknown; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig + extends Omit { + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | AxiosRequestConfig | void; + secure?: boolean; + format?: ResponseType; +} + +export enum ContentType { + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public instance: AxiosInstance; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private secure?: boolean; + private format?: ResponseType; + + constructor({ + securityWorker, + secure, + format, + ...axiosConfig + }: ApiConfig = {}) { + this.instance = axios.create({ + ...axiosConfig, + baseURL: axiosConfig.baseURL || "http://localhost/api/v2.0", + }); + this.secure = secure; + this.format = format; + this.securityWorker = securityWorker; + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected mergeRequestParams( + params1: AxiosRequestConfig, + params2?: AxiosRequestConfig, + ): AxiosRequestConfig { + const method = params1.method || (params2 && params2.method); + + return { + ...this.instance.defaults, + ...params1, + ...(params2 || {}), + headers: { + ...((method && + this.instance.defaults.headers[ + method.toLowerCase() as keyof HeadersDefaults + ]) || + {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected stringifyFormItem(formItem: unknown) { + if (typeof formItem === "object" && formItem !== null) { + return JSON.stringify(formItem); + } else { + return `${formItem}`; + } + } + + protected createFormData(input: Record): FormData { + if (input instanceof FormData) { + return input; + } + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + const isFileType = formItem instanceof Blob || formItem instanceof File; + formData.append( + key, + isFileType ? formItem : this.stringifyFormItem(formItem), + ); + } + + return formData; + }, new FormData()); + } + + public request = async ({ + secure, + path, + type, + query, + format, + body, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const responseFormat = format || this.format || undefined; + + if ( + type === ContentType.FormData && + body && + body !== null && + typeof body === "object" + ) { + body = this.createFormData(body as Record); + } + + if ( + type === ContentType.Text && + body && + body !== null && + typeof body !== "string" + ) { + body = JSON.stringify(body); + } + + return this.instance.request({ + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type ? { "Content-Type": type } : {}), + }, + params: query, + responseType: responseFormat, + data: body, + url: path, + }); + }; +} + +/** + * @title Harbor API + * @version 2.0 + * @baseUrl http://localhost/api/v2.0 + * + * These APIs provide services for manipulating Harbor project. + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + health = { + /** + * @description Check the status of Harbor components. This path does not require authentication. + * + * @tags health + * @name GetHealth + * @summary Check the status of Harbor components + * @request GET:/health + * @secure + */ + getHealth: (params: RequestParams = {}) => + this.request({ + path: `/health`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + search = { + /** + * @description The Search endpoint returns information about the projects and repositories offered at public status or related to the current logged in user. The response includes the project and repository list in a proper display order. + * + * @tags search + * @name Search + * @summary Search for projects and repositories + * @request GET:/search + * @secure + */ + search: ( + query: { + /** Search parameter for project and repository name. */ + q: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + statistics = { + /** + * @description Get the statistic information about the projects and repositories + * + * @tags statistic + * @name GetStatistic + * @summary Get the statistic information about the projects and repositories + * @request GET:/statistics + * @secure + */ + getStatistic: (params: RequestParams = {}) => + this.request({ + path: `/statistics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + ldap = { + /** + * @description This endpoint ping the available ldap service for test related configuration parameters. + * + * @tags Ldap + * @name PingLdap + * @summary Ping available ldap service. + * @request POST:/ldap/ping + * @secure + */ + pingLdap: (ldapconf: LdapConf, params: RequestParams = {}) => + this.request({ + path: `/ldap/ping`, + method: "POST", + body: ldapconf, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint searches the available ldap users based on related configuration parameters. Support searched by input ldap configuration, load configuration from the system and specific filter. + * + * @tags Ldap + * @name SearchLdapUser + * @summary Search available ldap users. + * @request GET:/ldap/users/search + * @secure + */ + searchLdapUser: ( + query?: { + /** Registered user ID */ + username?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/ldap/users/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint adds the selected available ldap users to harbor based on related configuration parameters from the system. System will try to guess the user email address and realname, add to harbor user information. If have errors when import user, will return the list of importing failed uid and the failed reason. + * + * @tags Ldap + * @name ImportLdapUser + * @summary Import selected available ldap users. + * @request POST:/ldap/users/import + * @secure + */ + importLdapUser: (uid_list: LdapImportUsers, params: RequestParams = {}) => + this.request({ + path: `/ldap/users/import`, + method: "POST", + body: uid_list, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint searches the available ldap groups based on related configuration parameters. support to search by groupname or groupdn. + * + * @tags Ldap + * @name SearchLdapGroup + * @summary Search available ldap groups. + * @request GET:/ldap/groups/search + * @secure + */ + searchLdapGroup: ( + query?: { + /** Ldap group name */ + groupname?: string; + /** The LDAP group DN */ + groupdn?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/ldap/groups/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + internalconfig = { + /** + * @description This endpoint is for retrieving system configurations that only provides for internal api call. + * + * @tags configure + * @name GetInternalconfig + * @summary Get internal configurations. + * @request GET:/internalconfig + * @secure + */ + getInternalconfig: (params: RequestParams = {}) => + this.request({ + path: `/internalconfig`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + configurations = { + /** + * @description This endpoint is for retrieving system configurations that only provides for admin user. + * + * @tags configure + * @name GetConfigurations + * @summary Get system configurations. + * @request GET:/configurations + * @secure + */ + getConfigurations: (params: RequestParams = {}) => + this.request({ + path: `/configurations`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for modifying system configurations that only provides for admin user. + * + * @tags configure + * @name UpdateConfigurations + * @summary Modify system configurations. + * @request PUT:/configurations + * @secure + */ + updateConfigurations: ( + configurations: Configurations, + params: RequestParams = {}, + ) => + this.request({ + path: `/configurations`, + method: "PUT", + body: configurations, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + projects = { + /** + * @description This endpoint returns projects created by Harbor. + * + * @tags project + * @name ListProjects + * @summary List projects + * @request GET:/projects + * @secure + */ + listProjects: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** The name of project. */ + name?: string; + /** The project is public or private. */ + public?: boolean; + /** The name of project owner. */ + owner?: string; + /** + * Bool value indicating whether return detailed information of the project + * @default true + */ + with_detail?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is used to check if the project name provided already exist. + * + * @tags project + * @name HeadProject + * @summary Check if the project name user provided already exists. + * @request HEAD:/projects + * @secure + */ + headProject: ( + query: { + /** Project name for checking exists. */ + project_name: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects`, + method: "HEAD", + query: query, + secure: true, + ...params, + }), + + /** + * @description This endpoint is for user to create a new project. + * + * @tags project + * @name CreateProject + * @summary Create a new project. + * @request POST:/projects + * @secure + */ + createProject: (project: ProjectReq, params: RequestParams = {}) => + this.request({ + path: `/projects`, + method: "POST", + body: project, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint returns specific project information by project ID. + * + * @tags project + * @name GetProject + * @summary Return specific project detail information + * @request GET:/projects/{project_name_or_id} + * @secure + */ + getProject: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is aimed to update the properties of a project. + * + * @tags project + * @name UpdateProject + * @summary Update properties for a selected project. + * @request PUT:/projects/{project_name_or_id} + * @secure + */ + updateProject: ( + projectNameOrId: string, + project: ProjectReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "PUT", + body: project, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is aimed to delete project by project ID. + * + * @tags project + * @name DeleteProject + * @summary Delete project by projectID + * @request DELETE:/projects/{project_name_or_id} + * @secure + */ + deleteProject: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Get the deletable status of the project + * + * @tags project + * @name GetProjectDeletable + * @summary Get the deletable status of the project + * @request GET:/projects/{project_name_or_id}/_deletable + * @secure + */ + getProjectDeletable: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/_deletable`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get summary of the project. + * + * @tags project + * @name GetProjectSummary + * @summary Get summary of the project. + * @request GET:/projects/{project_name_or_id}/summary + * @secure + */ + getProjectSummary: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}/summary`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get all project member information + * + * @tags member + * @name ListProjectMembers + * @summary Get all project member information + * @request GET:/projects/{project_name_or_id}/members + * @secure + */ + listProjectMembers: ( + projectNameOrId: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The entity name to search. */ + entityname?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create project member relationship, the member can be one of the user_member and group_member, The user_member need to specify user_id or username. If the user already exist in harbor DB, specify the user_id, If does not exist in harbor DB, it will SearchAndOnBoard the user. The group_member need to specify id or ldap_group_dn. If the group already exist in harbor DB. specify the user group's id, If does not exist, it will SearchAndOnBoard the group. + * + * @tags member + * @name CreateProjectMember + * @summary Create project member + * @request POST:/projects/{project_name_or_id}/members + * @secure + */ + createProjectMember: ( + projectNameOrId: string, + project_member: ProjectMember, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members`, + method: "POST", + body: project_member, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the project member information + * + * @tags member + * @name GetProjectMember + * @summary Get the project member information + * @request GET:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + getProjectMember: ( + projectNameOrId: string, + mid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update project member relationship + * + * @tags member + * @name UpdateProjectMember + * @summary Update project member + * @request PUT:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + updateProjectMember: ( + projectNameOrId: string, + mid: number, + role: RoleRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "PUT", + body: role, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags member + * @name DeleteProjectMember + * @summary Delete project member + * @request DELETE:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + deleteProjectMember: ( + projectNameOrId: string, + mid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Get the metadata of the specific project + * + * @tags projectMetadata + * @name ListProjectMetadatas + * @summary Get the metadata of the specific project + * @request GET:/projects/{project_name_or_id}/metadatas/ + * @secure + */ + listProjectMetadatas: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Add metadata for the specific project + * + * @tags projectMetadata + * @name AddProjectMetadatas + * @summary Add metadata for the specific project + * @request POST:/projects/{project_name_or_id}/metadatas/ + * @secure + */ + addProjectMetadatas: ( + projectNameOrId: string, + metadata: Annotations, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/`, + method: "POST", + body: metadata, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific metadata of the specific project + * + * @tags projectMetadata + * @name GetProjectMetadata + * @summary Get the specific metadata of the specific project + * @request GET:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + getProjectMetadata: ( + projectNameOrId: string, + metaName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update the specific metadata for the specific project + * + * @tags projectMetadata + * @name UpdateProjectMetadata + * @summary Update the specific metadata for the specific project + * @request PUT:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + updateProjectMetadata: ( + projectNameOrId: string, + metaName: string, + metadata: Annotations, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "PUT", + body: metadata, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the specific metadata for the specific project + * + * @tags projectMetadata + * @name DeleteProjectMetadata + * @summary Delete the specific metadata for the specific project + * @request DELETE:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + deleteProjectMetadata: ( + projectNameOrId: string, + metaName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List repositories of the specified project + * + * @tags repository + * @name ListRepositories + * @summary List repositories + * @request GET:/projects/{project_name}/repositories + * @secure + */ + listRepositories: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the repository specified by name + * + * @tags repository + * @name GetRepository + * @summary Get repository + * @request GET:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + getRepository: ( + projectName: string, + repositoryName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update the repository specified by name + * + * @tags repository + * @name UpdateRepository + * @summary Update repository + * @request PUT:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + updateRepository: ( + projectName: string, + repositoryName: string, + repository: Repository, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "PUT", + body: repository, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the repository specified by name + * + * @tags repository + * @name DeleteRepository + * @summary Delete repository + * @request DELETE:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + deleteRepository: ( + projectName: string, + repositoryName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List artifacts under the specific project and repository. Except the basic properties, the other supported queries in "q" includes "tags=*" to list only tagged artifacts, "tags=nil" to list only untagged artifacts, "tags=~v" to list artifacts whose tag fuzzy matches "v", "tags=v" to list artifact whose tag exactly matches "v", "labels=(id1, id2)" to list artifacts that both labels with id1 and id2 are added to + * + * @tags artifact + * @name ListArtifacts + * @summary List artifacts + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts + * @secure + */ + listArtifacts: ( + projectName: string, + repositoryName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are included inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are included inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is included inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifacts, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the immutable status is included inside the tags of the returning artifacts. Only works when setting "with_immutable_status=true" + * @default false + */ + with_immutable_status?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. Only works when setting "with_accessory=true" + * @default false + */ + with_accessory?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Copy the artifact specified in the "from" parameter to the repository. + * + * @tags artifact + * @name CopyArtifact + * @summary Copy artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts + * @secure + */ + copyArtifact: ( + projectName: string, + repositoryName: string, + query: { + /** The artifact from which the new artifact is copied from, the format should be "project/repository:tag" or "project/repository@digest". */ + from: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts`, + method: "POST", + query: query, + secure: true, + ...params, + }), + + /** + * @description Get the artifact specified by the reference under the project and repository. The reference can be digest or tag. + * + * @tags artifact + * @name GetArtifact + * @summary Get the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference} + * @secure + */ + getArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are inclued inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are inclued inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is inclued inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifact, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. + * @default false + */ + with_accessory?: boolean; + /** + * Specify whether the signature is inclued inside the returning artifacts + * @default false + */ + with_signature?: boolean; + /** + * Specify whether the immutable status is inclued inside the tags of the returning artifacts. + * @default false + */ + with_immutable_status?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the artifact specified by the reference under the project and repository. The reference can be digest or tag + * + * @tags artifact + * @name DeleteArtifact + * @summary Delete the specific artifact + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference} + * @secure + */ + deleteArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Scan the specified artifact + * + * @tags scan + * @name ScanArtifact + * @summary Scan the artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan + * @secure + */ + scanArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + scanType: ScanType, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan`, + method: "POST", + body: scanType, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Cancelling a scan job for a particular artifact + * + * @tags scan + * @name StopScanArtifact + * @summary Cancelling a scan job for a particular artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan/stop + * @secure + */ + stopScanArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + scanType: ScanType, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan/stop`, + method: "POST", + body: scanType, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the log of the scan report + * + * @tags scan + * @name GetReportLog + * @summary Get the log of the scan report + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan/{report_id}/log + * @secure + */ + getReportLog: ( + projectName: string, + repositoryName: string, + reference: string, + reportId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan/${reportId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description Create a tag for the specified artifact + * + * @tags artifact + * @name CreateTag + * @summary Create tag + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags + * @secure + */ + createTag: ( + projectName: string, + repositoryName: string, + reference: string, + tag: Tag, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags`, + method: "POST", + body: tag, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List tags of the specific artifact + * + * @tags artifact + * @name ListTags + * @summary List tags + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags + * @secure + */ + listTags: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the immutable status is included inside the returning tags + * @default false + */ + with_immutable_status?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the tag of the specified artifact + * + * @tags artifact + * @name DeleteTag + * @summary Delete tag + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags/{tag_name} + * @secure + */ + deleteTag: ( + projectName: string, + repositoryName: string, + reference: string, + tagName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags/${tagName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List accessories of the specific artifact + * + * @tags artifact + * @name ListAccessories + * @summary List accessories + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/accessories + * @secure + */ + listAccessories: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/accessories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the vulnerabilities addition of the artifact specified by the reference under the project and repository. + * + * @tags artifact + * @name GetVulnerabilitiesAddition + * @summary Get the vulnerabilities addition of the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/additions/vulnerabilities + * @secure + */ + getVulnerabilitiesAddition: ( + projectName: string, + repositoryName: string, + reference: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/additions/vulnerabilities`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the addition of the artifact specified by the reference under the project and repository. + * + * @tags artifact + * @name GetAddition + * @summary Get the addition of the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/additions/{addition} + * @secure + */ + getAddition: ( + projectName: string, + repositoryName: string, + reference: string, + addition: + | "build_history" + | "values.yaml" + | "readme.md" + | "dependencies" + | "sbom" + | "license" + | "files", + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/additions/${addition}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Add label to the specified artiact. + * + * @tags artifact + * @name AddLabel + * @summary Add label to artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/labels + * @secure + */ + addLabel: ( + projectName: string, + repositoryName: string, + reference: string, + label: Label, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/labels`, + method: "POST", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Remove the label from the specified artiact. + * + * @tags artifact + * @name RemoveLabel + * @summary Remove label from artifact + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/labels/{label_id} + * @secure + */ + removeLabel: ( + projectName: string, + repositoryName: string, + reference: string, + labelId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/labels/${labelId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List artifacts of the specified project + * + * @tags project + * @name ListArtifactsOfProject + * @summary List artifacts + * @request GET:/projects/{project_name_or_id}/artifacts + * @secure + */ + listArtifactsOfProject: ( + projectNameOrId: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are included inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are included inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is included inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifacts, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the immutable status is included inside the tags of the returning artifacts. Only works when setting "with_immutable_status=true" + * @default false + */ + with_immutable_status?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. Only works when setting "with_accessory=true" + * @default false + */ + with_accessory?: boolean; + /** + * Specify whether only the latest pushed artifact of each repository is included inside the returning artifacts. Only works when either artifact_type or media_type is included in the query. + * @default false + */ + latest_in_repository?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/artifacts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the scanner registration of the specified project. If no scanner registration is configured for the specified project, the system default scanner registration will be returned. + * + * @tags project + * @name GetScannerOfProject + * @summary Get project level scanner + * @request GET:/projects/{project_name_or_id}/scanner + * @secure + */ + getScannerOfProject: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Set one of the system configured scanner registration as the indepndent scanner of the specified project. + * + * @tags project + * @name SetScannerOfProject + * @summary Configure scanner for the specified project + * @request PUT:/projects/{project_name_or_id}/scanner + * @secure + */ + setScannerOfProject: ( + projectNameOrId: string, + payload: ProjectScanner, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner`, + method: "PUT", + body: payload, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Retrieve the system configured scanner registrations as candidates of setting project level scanner. + * + * @tags project + * @name ListScannerCandidatesOfProject + * @summary Get scanner registration candidates for configurating project level scanner + * @request GET:/projects/{project_name_or_id}/scanner/candidates + * @secure + */ + listScannerCandidatesOfProject: ( + projectNameOrId: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner/candidates`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get recent logs of the projects, it only query the previous version's audit log + * + * @tags project + * @name GetLogs + * @summary Get recent logs of the projects (deprecated) + * @request GET:/projects/{project_name}/logs + * @secure + */ + getLogs: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/logs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get recent logs of the projects + * + * @tags project + * @name GetLogExts + * @summary Get recent logs of the projects + * @request GET:/projects/{project_name}/auditlog-exts + * @secure + */ + getLogExts: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/auditlog-exts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a preheat policy under a project + * + * @tags preheat + * @name CreatePolicy + * @summary Create a preheat policy under a project + * @request POST:/projects/{project_name}/preheat/policies + * @secure + */ + createPolicy: ( + projectName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List preheat policies + * + * @tags preheat + * @name ListPolicies + * @summary List preheat policies + * @request GET:/projects/{project_name}/preheat/policies + * @secure + */ + listPolicies: ( + projectName: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a preheat policy + * + * @tags preheat + * @name GetPolicy + * @summary Get a preheat policy + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + getPolicy: ( + projectName: string, + preheatPolicyName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update preheat policy + * + * @tags preheat + * @name UpdatePolicy + * @summary Update preheat policy + * @request PUT:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + updatePolicy: ( + projectName: string, + preheatPolicyName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Manual preheat + * + * @tags preheat + * @name ManualPreheat + * @summary Manual preheat + * @request POST:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + manualPreheat: ( + projectName: string, + preheatPolicyName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete a preheat policy + * + * @tags preheat + * @name DeletePolicy + * @summary Delete a preheat policy + * @request DELETE:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + deletePolicy: ( + projectName: string, + preheatPolicyName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List executions for the given policy + * + * @tags preheat + * @name ListExecutions + * @summary List executions for the given policy + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions + * @secure + */ + listExecutions: ( + projectName: string, + preheatPolicyName: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a execution detail by id + * + * @tags preheat + * @name GetExecution + * @summary Get a execution detail by id + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id} + * @secure + */ + getExecution: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop a execution + * + * @tags preheat + * @name StopExecution + * @summary Stop a execution + * @request PATCH:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id} + * @secure + */ + stopExecution: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + execution: Execution, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}`, + method: "PATCH", + body: execution, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List all the related tasks for the given execution + * + * @tags preheat + * @name ListTasks + * @summary List all the related tasks for the given execution + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id}/tasks + * @secure + */ + listTasks: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the log text stream of the specified task for the given execution + * + * @tags preheat + * @name GetPreheatLog + * @summary Get the log text stream of the specified task for the given execution + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id}/tasks/{task_id}/logs + * @secure + */ + getPreheatLog: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}/tasks/${taskId}/logs`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description Get all providers at project level + * + * @tags preheat + * @name ListProvidersUnderProject + * @summary Get all providers at project level + * @request GET:/projects/{project_name}/preheat/providers + * @secure + */ + listProvidersUnderProject: ( + projectName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/providers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the immutable tag rules of a project + * + * @tags immutable + * @name ListImmuRules + * @summary List all immutable tag rules of current project + * @request GET:/projects/{project_name_or_id}/immutabletagrules + * @secure + */ + listImmuRules: ( + projectNameOrId: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint add an immutable tag rule to the project + * + * @tags immutable + * @name CreateImmuRule + * @summary Add an immutable tag rule to current project + * @request POST:/projects/{project_name_or_id}/immutabletagrules + * @secure + */ + createImmuRule: ( + projectNameOrId: string, + ImmutableRule: ImmutableRule, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules`, + method: "POST", + body: ImmutableRule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags immutable + * @name UpdateImmuRule + * @summary Update the immutable tag rule or enable or disable the rule + * @request PUT:/projects/{project_name_or_id}/immutabletagrules/{immutable_rule_id} + * @secure + */ + updateImmuRule: ( + projectNameOrId: string, + immutableRuleId: number, + ImmutableRule: ImmutableRule, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules/${immutableRuleId}`, + method: "PUT", + body: ImmutableRule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags immutable + * @name DeleteImmuRule + * @summary Delete the immutable tag rule. + * @request DELETE:/projects/{project_name_or_id}/immutabletagrules/{immutable_rule_id} + * @secure + */ + deleteImmuRule: ( + projectNameOrId: string, + immutableRuleId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules/${immutableRuleId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns webhook policies of a project. + * + * @tags webhook + * @name ListWebhookPoliciesOfProject + * @summary List project webhook policies. + * @request GET:/projects/{project_name_or_id}/webhook/policies + * @secure + */ + listWebhookPoliciesOfProject: ( + projectNameOrId: string, + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint create a webhook policy if the project does not have one. + * + * @tags webhook + * @name CreateWebhookPolicyOfProject + * @summary Create project webhook policy. + * @request POST:/projects/{project_name_or_id}/webhook/policies + * @secure + */ + createWebhookPolicyOfProject: ( + projectNameOrId: string, + policy: WebhookPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint returns specified webhook policy of a project. + * + * @tags webhook + * @name GetWebhookPolicyOfProject + * @summary Get project webhook policy + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + getWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is aimed to update the webhook policy of a project. + * + * @tags webhook + * @name UpdateWebhookPolicyOfProject + * @summary Update webhook policy of a project. + * @request PUT:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + updateWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + policy: WebhookPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is aimed to delete webhookpolicy of a project. + * + * @tags webhook + * @name DeleteWebhookPolicyOfProject + * @summary Delete webhook policy of a project + * @request DELETE:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + deleteWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns the executions of a specific webhook policy. + * + * @tags webhook + * @name ListExecutionsOfWebhookPolicy + * @summary List executions for a specific webhook policy + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions + * @secure + */ + listExecutionsOfWebhookPolicy: ( + projectNameOrId: string, + webhookPolicyId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the tasks of a specific webhook execution. + * + * @tags webhook + * @name ListTasksOfWebhookExecution + * @summary List tasks for a specific webhook execution + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions/{execution_id}/tasks + * @secure + */ + listTasksOfWebhookExecution: ( + projectNameOrId: string, + webhookPolicyId: number, + executionId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions/${executionId}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the logs of a specific webhook task. + * + * @tags webhook + * @name GetLogsOfWebhookTask + * @summary Get logs for a specific webhook task + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions/{execution_id}/tasks/{task_id}/log + * @secure + */ + getLogsOfWebhookTask: ( + projectNameOrId: string, + webhookPolicyId: number, + executionId: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions/${executionId}/tasks/${taskId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns last trigger information of project webhook policy. + * + * @tags webhook + * @name LastTrigger + * @summary Get project webhook policy last trigger info + * @request GET:/projects/{project_name_or_id}/webhook/lasttrigger + * @deprecated + * @secure + */ + lastTrigger: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/lasttrigger`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns webhook jobs of a project. + * + * @tags webhookjob + * @name ListWebhookJobs + * @summary List project webhook jobs + * @request GET:/projects/{project_name_or_id}/webhook/jobs + * @deprecated + * @secure + */ + listWebhookJobs: ( + projectNameOrId: string, + query: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * The policy ID. + * @format int64 + */ + policy_id: number; + /** The status of webhook job. */ + status?: string[]; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/jobs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get supported event types and notify types. + * + * @tags webhook + * @name GetSupportedEventTypes + * @summary Get supported event types and notify types. + * @request GET:/projects/{project_name_or_id}/webhook/events + * @secure + */ + getSupportedEventTypes: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/events`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + repositories = { + /** + * @description List all authorized repositories + * + * @tags repository + * @name ListAllRepositories + * @summary List all authorized repositories + * @request GET:/repositories + * @secure + */ + listAllRepositories: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/repositories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + auditLogs = { + /** + * @description This endpoint let the user see the recent operation logs of projects which the user is a member with project admin role,, or return all audit logs for system admin user, it only query the audit log in previous version. + * + * @tags auditlog + * @name ListAuditLogs + * @summary Get recent logs of projects which the user is a member with project admin role, or return all audit logs for system admin user (deprecated) + * @request GET:/audit-logs + * @secure + */ + listAuditLogs: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/audit-logs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + auditlogExts = { + /** + * @description This endpoint let user see the recent operation logs of the projects which he is member with project_admin role, or return all audit logs for system admin user. + * + * @tags auditlog + * @name ListAuditLogExts + * @summary Get recent logs of the projects which the user is a member with project_admin role, or return all audit logs for system admin user + * @request GET:/auditlog-exts + * @secure + */ + listAuditLogExts: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/auditlog-exts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get all event types of audit log + * + * @tags auditlog + * @name ListAuditLogEventTypes + * @summary Get all event types of audit log + * @request GET:/auditlog-exts/events + * @secure + */ + listAuditLogEventTypes: (params: RequestParams = {}) => + this.request({ + path: `/auditlog-exts/events`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + p2P = { + /** + * @description List P2P providers + * + * @tags preheat + * @name ListProviders + * @summary List P2P providers + * @request GET:/p2p/preheat/providers + * @secure + */ + listProviders: (params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/providers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint checks status of a instance, the instance can be given by ID or Endpoint URL (together with credential) + * + * @tags preheat + * @name PingInstances + * @summary Ping status of a instance. + * @request POST:/p2p/preheat/instances/ping + * @secure + */ + pingInstances: (instance: Instance, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/ping`, + method: "POST", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List P2P provider instances + * + * @tags preheat + * @name ListInstances + * @summary List P2P provider instances + * @request GET:/p2p/preheat/instances + * @secure + */ + listInstances: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/p2p/preheat/instances`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create p2p provider instances + * + * @tags preheat + * @name CreateInstance + * @summary Create p2p provider instances + * @request POST:/p2p/preheat/instances + * @secure + */ + createInstance: (instance: Instance, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances`, + method: "POST", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get a P2P provider instance + * + * @tags preheat + * @name GetInstance + * @summary Get a P2P provider instance + * @request GET:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + getInstance: (preheatInstanceName: string, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specified P2P provider instance + * + * @tags preheat + * @name DeleteInstance + * @summary Delete the specified P2P provider instance + * @request DELETE:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + deleteInstance: (preheatInstanceName: string, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the specified P2P provider instance + * + * @tags preheat + * @name UpdateInstance + * @summary Update the specified P2P provider instance + * @request PUT:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + updateInstance: ( + preheatInstanceName: string, + instance: Instance, + params: RequestParams = {}, + ) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "PUT", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + usergroups = { + /** + * @description Get all user groups information, it is open for system admin + * + * @tags usergroup + * @name ListUserGroups + * @summary Get all user groups information + * @request GET:/usergroups + * @secure + */ + listUserGroups: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** search with ldap group DN */ + ldap_group_dn?: string; + /** group name need to search, fuzzy matches */ + group_name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create user group information + * + * @tags usergroup + * @name CreateUserGroup + * @summary Create user group + * @request POST:/usergroups + * @secure + */ + createUserGroup: (usergroup: UserGroup, params: RequestParams = {}) => + this.request({ + path: `/usergroups`, + method: "POST", + body: usergroup, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is to search groups by group name. It's open for all authenticated requests. + * + * @tags usergroup + * @name SearchUserGroups + * @summary Search groups by groupname + * @request GET:/usergroups/search + * @secure + */ + searchUserGroups: ( + query: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Group name for filtering results. */ + groupname: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get user group information + * + * @tags usergroup + * @name GetUserGroup + * @summary Get user group information + * @request GET:/usergroups/{group_id} + * @secure + */ + getUserGroup: (groupId: number, params: RequestParams = {}) => + this.request({ + path: `/usergroups/${groupId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update user group information + * + * @tags usergroup + * @name UpdateUserGroup + * @summary Update group information + * @request PUT:/usergroups/{group_id} + * @secure + */ + updateUserGroup: ( + groupId: number, + usergroup: UserGroup, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups/${groupId}`, + method: "PUT", + body: usergroup, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete user group + * + * @tags usergroup + * @name DeleteUserGroup + * @summary Delete user group + * @request DELETE:/usergroups/{group_id} + * @secure + */ + deleteUserGroup: (groupId: number, params: RequestParams = {}) => + this.request({ + path: `/usergroups/${groupId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + icons = { + /** + * @description Get the artifact icon with the specified digest. As the original icon image is resized and encoded before returning, the parameter "digest" in the path doesn't match the hash of the returned content + * + * @tags icon + * @name GetIcon + * @summary Get artifact icon + * @request GET:/icons/{digest} + * @secure + */ + getIcon: (digest: string, params: RequestParams = {}) => + this.request({ + path: `/icons/${digest}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + robots = { + /** + * @description List the robot accounts with the specified level and project. + * + * @tags robot + * @name ListRobot + * @summary Get robot account + * @request GET:/robots + * @secure + */ + listRobot: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/robots`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a robot account + * + * @tags robot + * @name CreateRobot + * @summary Create a robot account + * @request POST:/robots + * @secure + */ + createRobot: (robot: RobotCreate, params: RequestParams = {}) => + this.request({ + path: `/robots`, + method: "POST", + body: robot, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns specific robot account information by robot ID. + * + * @tags robot + * @name GetRobotById + * @summary Get a robot account + * @request GET:/robots/{robot_id} + * @secure + */ + getRobotById: (robotId: number, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint updates specific robot account information by robot ID. + * + * @tags robot + * @name UpdateRobot + * @summary Update a robot account + * @request PUT:/robots/{robot_id} + * @secure + */ + updateRobot: (robotId: number, robot: Robot, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "PUT", + body: robot, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Refresh the robot secret + * + * @tags robot + * @name RefreshSec + * @summary Refresh the robot secret + * @request PATCH:/robots/{robot_id} + * @secure + */ + refreshSec: ( + robotId: number, + robotSec: RobotSec, + params: RequestParams = {}, + ) => + this.request({ + path: `/robots/${robotId}`, + method: "PATCH", + body: robotSec, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint deletes specific robot account information by robot ID. + * + * @tags robot + * @name DeleteRobot + * @summary Delete a robot account + * @request DELETE:/robots/{robot_id} + * @secure + */ + deleteRobot: (robotId: number, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + quotas = { + /** + * @description List quotas + * + * @tags quota + * @name ListQuotas + * @summary List quotas + * @request GET:/quotas + * @secure + */ + listQuotas: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The reference type of quota. */ + reference?: string; + /** The reference id of quota. */ + reference_id?: string; + /** + * Sort method, valid values include: + * 'hard.resource_name', '-hard.resource_name', 'used.resource_name', '-used.resource_name'. + * Here '-' stands for descending order, resource_name should be the real resource name of the quota. + */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/quotas`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the specified quota + * + * @tags quota + * @name GetQuota + * @summary Get the specified quota + * @request GET:/quotas/{id} + * @secure + */ + getQuota: (id: number, params: RequestParams = {}) => + this.request({ + path: `/quotas/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update hard limits of the specified quota + * + * @tags quota + * @name UpdateQuota + * @summary Update the specified quota + * @request PUT:/quotas/{id} + * @secure + */ + updateQuota: ( + id: number, + hard: QuotaUpdateReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/quotas/${id}`, + method: "PUT", + body: hard, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + replication = { + /** + * @description List replication policies + * + * @tags replication + * @name ListReplicationPolicies + * @summary List replication policies + * @request GET:/replication/policies + * @secure + */ + listReplicationPolicies: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Deprecated, use "query" instead. The policy name. */ + name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a replication policy + * + * @tags replication + * @name CreateReplicationPolicy + * @summary Create a replication policy + * @request POST:/replication/policies + * @secure + */ + createReplicationPolicy: ( + policy: ReplicationPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific replication policy + * + * @tags replication + * @name GetReplicationPolicy + * @summary Get the specific replication policy + * @request GET:/replication/policies/{id} + * @secure + */ + getReplicationPolicy: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/policies/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specific replication policy + * + * @tags replication + * @name DeleteReplicationPolicy + * @summary Delete the specific replication policy + * @request DELETE:/replication/policies/{id} + * @secure + */ + deleteReplicationPolicy: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/policies/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the replication policy + * + * @tags replication + * @name UpdateReplicationPolicy + * @summary Update the replication policy + * @request PUT:/replication/policies/{id} + * @secure + */ + updateReplicationPolicy: ( + id: number, + policy: ReplicationPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies/${id}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List replication executions + * + * @tags replication + * @name ListReplicationExecutions + * @summary List replication executions + * @request GET:/replication/executions + * @secure + */ + listReplicationExecutions: ( + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The ID of the policy that the executions belong to. */ + policy_id?: number; + /** The execution status. */ + status?: string; + /** The trigger mode. */ + trigger?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Start one replication execution according to the policy + * + * @tags replication + * @name StartReplication + * @summary Start one replication execution + * @request POST:/replication/executions + * @secure + */ + startReplication: ( + execution: StartReplicationExecution, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions`, + method: "POST", + body: execution, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the replication execution specified by ID + * + * @tags replication + * @name GetReplicationExecution + * @summary Get the specific replication execution + * @request GET:/replication/executions/{id} + * @secure + */ + getReplicationExecution: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/executions/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the replication execution specified by ID + * + * @tags replication + * @name StopReplication + * @summary Stop the specific replication execution + * @request PUT:/replication/executions/{id} + * @secure + */ + stopReplication: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/executions/${id}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description List replication tasks for a specific execution + * + * @tags replication + * @name ListReplicationTasks + * @summary List replication tasks for a specific execution + * @request GET:/replication/executions/{id}/tasks + * @secure + */ + listReplicationTasks: ( + id: number, + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The task status. */ + status?: string; + /** The resource type. */ + resource_type?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions/${id}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the log of the specific replication task + * + * @tags replication + * @name GetReplicationLog + * @summary Get the log of the specific replication task + * @request GET:/replication/executions/{id}/tasks/{task_id}/log + * @secure + */ + getReplicationLog: ( + id: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions/${id}/tasks/${taskId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description List registry adapters + * + * @tags registry + * @name ListRegistryProviderTypes + * @summary List registry adapters + * @request GET:/replication/adapters + * @secure + */ + listRegistryProviderTypes: (params: RequestParams = {}) => + this.request({ + path: `/replication/adapters`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description List all registered registry provider information + * + * @tags registry + * @name ListRegistryProviderInfos + * @summary List all registered registry provider information + * @request GET:/replication/adapterinfos + * @secure + */ + listRegistryProviderInfos: (params: RequestParams = {}) => + this.request, Errors>({ + path: `/replication/adapterinfos`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + registries = { + /** + * @description Create a registry + * + * @tags registry + * @name CreateRegistry + * @summary Create a registry + * @request POST:/registries + * @secure + */ + createRegistry: (registry: Registry, params: RequestParams = {}) => + this.request({ + path: `/registries`, + method: "POST", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List the registries + * + * @tags registry + * @name ListRegistries + * @summary List the registries + * @request GET:/registries + * @secure + */ + listRegistries: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Deprecated, use `q` instead. */ + name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/registries`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Check status of a registry + * + * @tags registry + * @name PingRegistry + * @summary Check status of a registry + * @request POST:/registries/ping + * @secure + */ + pingRegistry: (registry: RegistryPing, params: RequestParams = {}) => + this.request({ + path: `/registries/ping`, + method: "POST", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific registry + * + * @tags registry + * @name GetRegistry + * @summary Get the specific registry + * @request GET:/registries/{id} + * @secure + */ + getRegistry: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specific registry + * + * @tags registry + * @name DeleteRegistry + * @summary Delete the specific registry + * @request DELETE:/registries/{id} + * @secure + */ + deleteRegistry: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the registry + * + * @tags registry + * @name UpdateRegistry + * @summary Update the registry + * @request PUT:/registries/{id} + * @secure + */ + updateRegistry: ( + id: number, + registry: RegistryUpdate, + params: RequestParams = {}, + ) => + this.request({ + path: `/registries/${id}`, + method: "PUT", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the registry info + * + * @tags registry + * @name GetRegistryInfo + * @summary Get the registry info + * @request GET:/registries/{id}/info + * @secure + */ + getRegistryInfo: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}/info`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + scans = { + /** + * @description Get the metrics of the latest scan all process + * + * @tags scanAll + * @name GetLatestScanAllMetrics + * @summary Get the metrics of the latest scan all process + * @request GET:/scans/all/metrics + * @secure + */ + getLatestScanAllMetrics: (params: RequestParams = {}) => + this.request({ + path: `/scans/all/metrics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the metrics of the latest scheduled scan all process + * + * @tags scanAll + * @name GetLatestScheduledScanAllMetrics + * @summary Get the metrics of the latest scheduled scan all process + * @request GET:/scans/schedule/metrics + * @deprecated + * @secure + */ + getLatestScheduledScanAllMetrics: (params: RequestParams = {}) => + this.request({ + path: `/scans/schedule/metrics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + systeminfo = { + /** + * @description This API is for retrieving general system info, this can be called by anonymous request. Some attributes will be omitted in the response when this API is called by anonymous request. + * + * @tags systeminfo + * @name GetSystemInfo + * @summary Get general system info + * @request GET:/systeminfo + * @secure + */ + getSystemInfo: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for retrieving system volume info that only provides for admin user. Note that the response only reflects the storage status of local disk. + * + * @tags systeminfo + * @name GetVolumes + * @summary Get system volume info (total/free size). + * @request GET:/systeminfo/volumes + * @secure + */ + getVolumes: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo/volumes`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for downloading a default root certificate. + * + * @tags systeminfo + * @name GetCert + * @summary Get default root certificate. + * @request GET:/systeminfo/getcert + * @secure + */ + getCert: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo/getcert`, + method: "GET", + secure: true, + ...params, + }), + }; + system = { + /** + * @description Test the OIDC endpoint, the setting of the endpoint is provided in the request. This API can only be called by system admin. + * + * @tags oidc + * @name PingOidc + * @summary Test the OIDC endpoint. + * @request POST:/system/oidc/ping + * @secure + */ + pingOidc: ( + endpoint: { + /** The URL of OIDC endpoint to be tested. */ + url?: string; + /** Whether the certificate should be verified */ + verify_cert?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/oidc/ping`, + method: "POST", + body: endpoint, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let user get gc execution history. + * + * @tags gc + * @name GetGcHistory + * @summary Get gc results. + * @request GET:/system/gc + * @secure + */ + getGcHistory: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/gc`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user get gc status filtered by specific ID. + * + * @tags gc + * @name GetGc + * @summary Get gc status. + * @request GET:/system/gc/{gc_id} + * @secure + */ + getGc: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the GC execution specified by ID + * + * @tags gc + * @name StopGc + * @summary Stop the specific GC execution + * @request PUT:/system/gc/{gc_id} + * @secure + */ + stopGc: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description This endpoint let user get gc job logs filtered by specific ID. + * + * @tags gc + * @name GetGcLog + * @summary Get gc job log. + * @request GET:/system/gc/{gc_id}/log + * @secure + */ + getGcLog: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint is for get schedule of gc job. + * + * @tags gc + * @name GetGcSchedule + * @summary Get gc's schedule. + * @request GET:/system/gc/schedule + * @secure + */ + getGcSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for update gc schedule. + * + * @tags gc + * @name CreateGcSchedule + * @summary Create a gc schedule. + * @request POST:/system/gc/schedule + * @secure + */ + createGcSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for update gc schedule. + * + * @tags gc + * @name UpdateGcSchedule + * @summary Update gc's schedule. + * @request PUT:/system/gc/schedule + * @secure + */ + updateGcSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description get purge job execution history. + * + * @tags purge + * @name GetPurgeHistory + * @summary Get purge job results. + * @request GET:/system/purgeaudit + * @secure + */ + getPurgeHistory: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/purgeaudit`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user get purge job status filtered by specific ID. + * + * @tags purge + * @name GetPurgeJob + * @summary Get purge job status. + * @request GET:/system/purgeaudit/{purge_id} + * @secure + */ + getPurgeJob: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the purge audit log execution specified by ID + * + * @tags purge + * @name StopPurge + * @summary Stop the specific purge audit log execution + * @request PUT:/system/purgeaudit/{purge_id} + * @secure + */ + stopPurge: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description This endpoint let user get purge job logs filtered by specific ID. + * + * @tags purge + * @name GetPurgeJobLog + * @summary Get purge job log. + * @request GET:/system/purgeaudit/{purge_id}/log + * @secure + */ + getPurgeJobLog: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint is for get schedule of purge job. + * + * @tags purge + * @name GetPurgeSchedule + * @summary Get purge's schedule. + * @request GET:/system/purgeaudit/schedule + * @secure + */ + getPurgeSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for update purge job schedule. + * + * @tags purge + * @name CreatePurgeSchedule + * @summary Create a purge job schedule. + * @request POST:/system/purgeaudit/schedule + * @secure + */ + createPurgeSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for update purge job schedule. + * + * @tags purge + * @name UpdatePurgeSchedule + * @summary Update purge job's schedule. + * @request PUT:/system/purgeaudit/schedule + * @secure + */ + updatePurgeSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the system level allowlist of CVE. This API can be called by all authenticated users. + * + * @tags SystemCVEAllowlist + * @name GetSystemCveAllowlist + * @summary Get the system level allowlist of CVE. + * @request GET:/system/CVEAllowlist + * @secure + */ + getSystemCveAllowlist: (params: RequestParams = {}) => + this.request({ + path: `/system/CVEAllowlist`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This API overwrites the system level allowlist of CVE with the list in request body. Only system Admin has permission to call this API. + * + * @tags SystemCVEAllowlist + * @name PutSystemCveAllowlist + * @summary Update the system level allowlist of CVE. + * @request PUT:/system/CVEAllowlist + * @secure + */ + putSystemCveAllowlist: ( + allowlist: CVEAllowlist, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/CVEAllowlist`, + method: "PUT", + body: allowlist, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for getting a schedule for the scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name GetScanAllSchedule + * @summary Get scan all's schedule. + * @request GET:/system/scanAll/schedule + * @secure + */ + getScanAllSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for updating the schedule of scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name UpdateScanAllSchedule + * @summary Update scan all's schedule. + * @request PUT:/system/scanAll/schedule + * @secure + */ + updateScanAllSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for creating a schedule or a manual trigger for the scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name CreateScanAllSchedule + * @summary Create a schedule or a manual trigger for the scan all job. + * @request POST:/system/scanAll/schedule + * @secure + */ + createScanAllSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Stop scanAll job execution + * + * @tags scanAll + * @name StopScanAll + * @summary Stop scanAll job execution + * @request POST:/system/scanAll/stop + * @secure + */ + stopScanAll: (params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/stop`, + method: "POST", + secure: true, + ...params, + }), + }; + jobservice = { + /** + * @description Get worker pools + * + * @tags jobservice + * @name GetWorkerPools + * @summary Get worker pools + * @request GET:/jobservice/pools + * @secure + */ + getWorkerPools: (params: RequestParams = {}) => + this.request({ + path: `/jobservice/pools`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get workers in current pool + * + * @tags jobservice + * @name GetWorkers + * @summary Get workers + * @request GET:/jobservice/pools/{pool_id}/workers + * @secure + */ + getWorkers: (poolId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/pools/${poolId}/workers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop running job + * + * @tags jobservice + * @name StopRunningJob + * @summary Stop running job + * @request PUT:/jobservice/jobs/{job_id} + * @secure + */ + stopRunningJob: (jobId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/jobs/${jobId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description Get job log by job id, it is only used by administrator + * + * @tags jobservice + * @name ActionGetJobLog + * @summary Get job log by job id + * @request GET:/jobservice/jobs/{job_id}/log + * @secure + */ + actionGetJobLog: (jobId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/jobs/${jobId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description list job queue + * + * @tags jobservice + * @name ListJobQueues + * @summary list job queues + * @request GET:/jobservice/queues + * @secure + */ + listJobQueues: (params: RequestParams = {}) => + this.request({ + path: `/jobservice/queues`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description stop and clean, pause, resume pending jobs in the queue + * + * @tags jobservice + * @name ActionPendingJobs + * @summary stop and clean, pause, resume pending jobs in the queue + * @request PUT:/jobservice/queues/{job_type} + * @secure + */ + actionPendingJobs: ( + jobType: string, + action_request: ActionRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/jobservice/queues/${jobType}`, + method: "PUT", + body: action_request, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + schedules = { + /** + * @description List schedules + * + * @tags schedule + * @name ListSchedules + * @request GET:/schedules + * @secure + */ + listSchedules: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/schedules`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get scheduler paused status + * + * @tags schedule + * @name GetSchedulePaused + * @request GET:/schedules/{job_type}/paused + * @secure + */ + getSchedulePaused: (jobType: string, params: RequestParams = {}) => + this.request({ + path: `/schedules/${jobType}/paused`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + ping = { + /** + * @description This API simply replies a pong to indicate the process to handle API is up, disregarding the health status of dependent components. This path does not require any authentication. + * + * @tags ping + * @name GetPing + * @summary Ping Harbor to check if the API server is alive. + * @request GET:/ping + * @secure + */ + getPing: (params: RequestParams = {}) => + this.request({ + path: `/ping`, + method: "GET", + secure: true, + ...params, + }), + }; + retentions = { + /** + * @description Get Retention Metadatas. + * + * @tags Retention + * @name GetRentenitionMetadata + * @summary Get Retention Metadatas + * @request GET:/retentions/metadatas + * @secure + */ + getRentenitionMetadata: (params: RequestParams = {}) => + this.request({ + path: `/retentions/metadatas`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when no retention policy binded to project yet. + * + * @tags Retention + * @name CreateRetention + * @summary Create Retention Policy + * @request POST:/retentions + * @secure + */ + createRetention: (policy: RetentionPolicy, params: RequestParams = {}) => + this.request({ + path: `/retentions`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention Policy. + * + * @tags Retention + * @name GetRetention + * @summary Get Retention Policy + * @request GET:/retentions/{id} + * @secure + */ + getRetention: (id: number, params: RequestParams = {}) => + this.request({ + path: `/retentions/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when retention policy has already binded to project. + * + * @tags Retention + * @name UpdateRetention + * @summary Update Retention Policy + * @request PUT:/retentions/{id} + * @secure + */ + updateRetention: ( + id: number, + policy: RetentionPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when retention policy has already binded to project. + * + * @tags Retention + * @name DeleteRetention + * @summary Delete Retention Policy + * @request DELETE:/retentions/{id} + * @secure + */ + deleteRetention: (id: number, params: RequestParams = {}) => + this.request({ + path: `/retentions/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Trigger a Retention Execution, if dry_run is True, nothing would be deleted actually. + * + * @tags Retention + * @name TriggerRetentionExecution + * @summary Trigger a Retention Execution + * @request POST:/retentions/{id}/executions + * @secure + */ + triggerRetentionExecution: ( + id: number, + body: { + dry_run?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions`, + method: "POST", + body: body, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention executions, execution status may be delayed before job service schedule it up. + * + * @tags Retention + * @name ListRetentionExecutions + * @summary Get Retention executions + * @request GET:/retentions/{id}/executions + * @secure + */ + listRetentionExecutions: ( + id: number, + query?: { + /** + * The page number. + * @format int64 + */ + page?: number; + /** + * The size of per page. + * @format int64 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop a Retention execution, only support "stop" action now. + * + * @tags Retention + * @name OperateRetentionExecution + * @summary Stop a Retention execution + * @request PATCH:/retentions/{id}/executions/{eid} + * @secure + */ + operateRetentionExecution: ( + id: number, + eid: number, + body: { + action?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}`, + method: "PATCH", + body: body, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention tasks, each repository as a task. + * + * @tags Retention + * @name ListRetentionTasks + * @summary Get Retention tasks + * @request GET:/retentions/{id}/executions/{eid}/tasks + * @secure + */ + listRetentionTasks: ( + id: number, + eid: number, + query?: { + /** + * The page number. + * @format int64 + */ + page?: number; + /** + * The size of per page. + * @format int64 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get Retention job task log, tags ratain or deletion detail will be shown in a table. + * + * @tags Retention + * @name GetRetentionTaskLog + * @summary Get Retention job task log + * @request GET:/retentions/{id}/executions/{eid}/tasks/{tid} + * @secure + */ + getRetentionTaskLog: ( + id: number, + eid: number, + tid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}/tasks/${tid}`, + method: "GET", + secure: true, + ...params, + }), + }; + scanners = { + /** + * @description Returns a list of currently configured scanner registrations. + * + * @tags scanner + * @name ListScanners + * @summary List scanner registrations + * @request GET:/scanners + * @secure + */ + listScanners: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Creats a new scanner registration with the given data. + * + * @tags scanner + * @name CreateScanner + * @summary Create a scanner registration + * @request POST:/scanners + * @secure + */ + createScanner: ( + registration: ScannerRegistrationReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners`, + method: "POST", + body: registration, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Pings scanner adapter to test endpoint URL and authorization settings. + * + * @tags scanner + * @name PingScanner + * @summary Tests scanner registration settings + * @request POST:/scanners/ping + * @secure + */ + pingScanner: ( + settings: ScannerRegistrationSettings, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/ping`, + method: "POST", + body: settings, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Retruns the details of the specified scanner registration. + * + * @tags scanner + * @name GetScanner + * @summary Get a scanner registration details + * @request GET:/scanners/{registration_id} + * @secure + */ + getScanner: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Updates the specified scanner registration. + * + * @tags scanner + * @name UpdateScanner + * @summary Update a scanner registration + * @request PUT:/scanners/{registration_id} + * @secure + */ + updateScanner: ( + registrationId: string, + registration: ScannerRegistrationReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/${registrationId}`, + method: "PUT", + body: registration, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Deletes the specified scanner registration. + * + * @tags scanner + * @name DeleteScanner + * @summary Delete a scanner registration + * @request DELETE:/scanners/{registration_id} + * @secure + */ + deleteScanner: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}`, + method: "DELETE", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Set the specified scanner registration as the system default one. + * + * @tags scanner + * @name SetScannerAsDefault + * @summary Set system default scanner registration + * @request PATCH:/scanners/{registration_id} + * @secure + */ + setScannerAsDefault: ( + registrationId: string, + payload: IsDefault, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/${registrationId}`, + method: "PATCH", + body: payload, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the metadata of the specified scanner registration, including the capabilities and customized properties. + * + * @tags scanner + * @name GetScannerMetadata + * @summary Get the metadata of the specified scanner registration + * @request GET:/scanners/{registration_id}/metadata + * @secure + */ + getScannerMetadata: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}/metadata`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + users = { + /** + * No description + * + * @tags user + * @name ListUsers + * @summary List users + * @request GET:/users + * @secure + */ + listUsers: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This API can be used only when the authentication mode is for local DB. When self registration is disabled. + * + * @tags user + * @name CreateUser + * @summary Create a local user. + * @request POST:/users + * @secure + */ + createUser: (userReq: UserCreationReq, params: RequestParams = {}) => + this.request({ + path: `/users`, + method: "POST", + body: userReq, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetCurrentUserInfo + * @summary Get current user info. + * @request GET:/users/current + * @secure + */ + getCurrentUserInfo: (params: RequestParams = {}) => + this.request({ + path: `/users/current`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is to search the users by username. It's open for all authenticated requests. + * + * @tags user + * @name SearchUsers + * @summary Search users by username + * @request GET:/users/search + * @secure + */ + searchUsers: ( + query: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Username for filtering results. */ + username: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetUser + * @summary Get a user's profile. + * @request GET:/users/{user_id} + * @secure + */ + getUser: (userId: number, params: RequestParams = {}) => + this.request({ + path: `/users/${userId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags user + * @name UpdateUserProfile + * @summary Update user's profile. + * @request PUT:/users/{user_id} + * @secure + */ + updateUserProfile: ( + userId: number, + profile: UserProfile, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}`, + method: "PUT", + body: profile, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let administrator of Harbor mark a registered user as removed.It actually won't be deleted from DB. + * + * @tags user + * @name DeleteUser + * @summary Mark a registered user as be removed. + * @request DELETE:/users/{user_id} + * @secure + */ + deleteUser: (userId: number, params: RequestParams = {}) => + this.request({ + path: `/users/${userId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * No description + * + * @tags user + * @name SetUserSysAdmin + * @summary Update a registered user to change to be an administrator of Harbor. + * @request PUT:/users/{user_id}/sysadmin + * @secure + */ + setUserSysAdmin: ( + userId: number, + sysadmin_flag: UserSysAdminFlag, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/sysadmin`, + method: "PUT", + body: sysadmin_flag, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for user to update password. Users with the admin role can change any user's password. Regular users can change only their own password. + * + * @tags user + * @name UpdateUserPassword + * @summary Change the password on a user that already exists. + * @request PUT:/users/{user_id}/password + * @secure + */ + updateUserPassword: ( + userId: number, + password: PasswordReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/password`, + method: "PUT", + body: password, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetCurrentUserPermissions + * @summary Get current user permissions. + * @request GET:/users/current/permissions + * @secure + */ + getCurrentUserPermissions: ( + query?: { + /** The scope for the permission */ + scope?: string; + /** + * If true, the resources in the response are relative to the scope, + * eg for resource '/project/1/repository' if relative is 'true' then the resource in response will be 'repository'. + */ + relative?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/current/permissions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user generate a new CLI secret for himself. This API only works when auth mode is set to 'OIDC'. Once this API returns with successful status, the old secret will be invalid, as there will be only one CLI secret for a user. + * + * @tags user + * @name SetCliSecret + * @summary Set CLI secret for a user. + * @request PUT:/users/{user_id}/cli_secret + * @secure + */ + setCliSecret: ( + userId: number, + secret: OIDCCliSecretReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/cli_secret`, + method: "PUT", + body: secret, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + labels = { + /** + * @description This endpoint let user list labels by name, scope and project_id + * + * @tags label + * @name ListLabels + * @summary List labels according to the query strings. + * @request GET:/labels + * @secure + */ + listLabels: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The label name. */ + name?: string; + /** The label scope. Valid values are g and p. g for global labels and p for project labels. */ + scope?: string; + /** + * Relevant project ID, required when scope is p. + * @format int64 + */ + project_id?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/labels`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user creates a label. + * + * @tags label + * @name CreateLabel + * @summary Post creates a label + * @request POST:/labels + * @secure + */ + createLabel: (label: Label, params: RequestParams = {}) => + this.request({ + path: `/labels`, + method: "POST", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let user get the label by specific ID. + * + * @tags label + * @name GetLabelById + * @summary Get the label specified by ID. + * @request GET:/labels/{label_id} + * @secure + */ + getLabelById: (labelId: number, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user update label properties. + * + * @tags label + * @name UpdateLabel + * @summary Update the label properties. + * @request PUT:/labels/{label_id} + * @secure + */ + updateLabel: (labelId: number, label: Label, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "PUT", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the label specified by ID. + * + * @tags label + * @name DeleteLabel + * @summary Delete the label specified by ID. + * @request DELETE:/labels/{label_id} + * @secure + */ + deleteLabel: (labelId: number, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + export = { + /** + * @description Export scan data for selected projects + * + * @tags scan data export + * @name ExportScanData + * @summary Export scan data for selected projects + * @request POST:/export/cve + * @secure + */ + exportScanData: ( + criteria: ScanDataExportRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve`, + method: "POST", + body: criteria, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description Get the scan data export execution specified by ID + * + * @tags scan data export + * @name GetScanDataExportExecution + * @summary Get the specific scan data export execution + * @request GET:/export/cve/execution/{execution_id} + * @secure + */ + getScanDataExportExecution: ( + executionId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve/execution/${executionId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a list of specific scan data export execution jobs for a specified user + * + * @tags scan data export + * @name GetScanDataExportExecutionList + * @summary Get a list of specific scan data export execution jobs for a specified user + * @request GET:/export/cve/executions + * @secure + */ + getScanDataExportExecutionList: (params: RequestParams = {}) => + this.request({ + path: `/export/cve/executions`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Download the scan data report. Default format is CSV + * + * @tags scan data export + * @name DownloadScanData + * @summary Download the scan data export file + * @request GET:/export/cve/download/{execution_id} + * @secure + */ + downloadScanData: ( + executionId: number, + query?: { + /** The format of the data to be exported. e.g. CSV or PDF */ + format?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve/download/${executionId}`, + method: "GET", + query: query, + secure: true, + ...params, + }), + }; + security = { + /** + * @description Retrieve the vulnerability summary of the system + * + * @tags securityhub + * @name GetSecuritySummary + * @summary Get vulnerability system summary + * @request GET:/security/summary + * @secure + */ + getSecuritySummary: ( + query?: { + /** + * Specify whether the dangerous CVEs are included inside summary information + * @default false + */ + with_dangerous_cve?: boolean; + /** + * Specify whether the dangerous Artifact are included inside summary information + * @default false + */ + with_dangerous_artifact?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/security/summary`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the vulnerability list. use q to pass the query condition, supported conditions: cve_id(exact match) cvss_score_v3(range condition) severity(exact match) repository_name(exact match) project_id(exact match) package(exact match) tag(exact match) digest(exact match) + * + * @tags securityhub + * @name ListVulnerabilities + * @summary Get the vulnerability list. + * @request GET:/security/vul + * @secure + */ + listVulnerabilities: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Enable to ignore X-Total-Count when the total count > 1000, if the total count is less than 1000, the real total count is returned, else -1. + * @default false + */ + tune_count?: boolean; + /** + * Specify whether the tag information is included inside vulnerability information + * @default false + */ + with_tag?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/security/vul`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + permissions = { + /** + * @description This endpoint is for retrieving resource and action info that only provides for admin user(system admin and project admin). + * + * @tags permissions + * @name GetPermissions + * @summary Get system or project level permissions info. + * @request GET:/permissions + * @secure + */ + getPermissions: (params: RequestParams = {}) => + this.request({ + path: `/permissions`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; +} diff --git a/.jjconflict-base-1/plugins/harbor/src/env.d.ts b/.jjconflict-base-1/plugins/harbor/src/env.d.ts new file mode 100644 index 0000000000..44377abce9 --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-base-1/plugins/harbor/src/functions.ts b/.jjconflict-base-1/plugins/harbor/src/functions.ts new file mode 100644 index 0000000000..ea902dae27 --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/src/functions.ts @@ -0,0 +1,159 @@ +import type { PluginResult, Project, ProjectLite, StepCall } from '@cpn-console/hooks' +import type { VaultRobotSecret } from './robot.js' +import { + + specificallyDisabled, + specificallyEnabled, +} from '@cpn-console/hooks' +import { DEFAULT, ENABLED } from '@cpn-console/shared' +// @ts-ignore pas de typage disponible pour le paquet bytes +import bytes from 'bytes' +import { logger } from './logger.js' +import { addProjectGroupMember } from './permission.js' +import { addRetentionPolicy } from './policy.js' +import { createProject, deleteProject } from './project.js' +import { deleteRobot, ensureRobot, roAccess, rwAccess } from './robot.js' +import { + getApi, + getConfig, + projectRobotName, + roRobotName, + rwRobotName, +} from './utils.js' + +export const createDsoProject: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + }, + } + const warnReasons: string[] = [] + try { + const project = payload.args + const projectName = project.slug + const { vault: vaultApi, keycloak: keycloakApi } = payload.apis + + const publishRoRobotProject = project.store.registry?.publishProjectRobot + const publishRoRobotConfig = payload.config.registry?.publishProjectRobot + const createProjectRobot + = specificallyEnabled(publishRoRobotProject) + || (specificallyEnabled(publishRoRobotConfig) + && !specificallyDisabled(publishRoRobotProject)) + + const quotaHardLimit + = project.store.registry?.quotaHardLimit + || payload.config.registry?.quotaHardLimit + const quotaHardLimitBytes = quotaHardLimit + ? bytes.parse(quotaHardLimit) + : undefined + + const [projectCreated, oidcGroup] = await Promise.all([ + createProject( + projectName, + quotaHardLimitBytes === 1 ? undefined : quotaHardLimitBytes, + ), + keycloakApi.getProjectGroupPath(), + ]) + const api = getApi() + + if (!projectCreated.project_id) + throw new Error('Unable to retrieve project_id') + const projectId = projectCreated.project_id + + await Promise.all([ + ensureRobot(projectName, projectId, roRobotName, vaultApi, roAccess, api), // cette ligne en premier sinon ça foire au dessus + ensureRobot(projectName, projectId, rwRobotName, vaultApi, rwAccess, api), + addProjectGroupMember(projectName, oidcGroup), + addRetentionPolicy(projectName, projectId), + createProjectRobot + ? ensureRobot(projectName, projectId, projectRobotName, vaultApi, roAccess, api) + : deleteRobot(projectName, projectId, projectRobotName, vaultApi, api), + ]) + returnResult.status.message = `Created${createProjectRobot ? ' , with project robot' : ''}` + returnResult.store = { + projectId: projectCreated.project_id, + } + if (warnReasons.length) { + returnResult.status.result = 'WARNING' + returnResult.status.message = warnReasons.join(', ') + } + logger.info({ action: 'createDsoProject', projectSlug: projectName, result: returnResult.status.result, createProjectRobot }, 'Hook done') + return returnResult + } catch (error) { + logger.error({ action: 'createDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const deleteDsoProject: StepCall = async (payload) => { + try { + const project = payload.args + const projectName = project.slug + + await deleteProject(projectName) + + logger.info({ action: 'deleteDsoProject', projectSlug: projectName, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } catch (error) { + logger.error({ action: 'deleteDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const getProjectSecrets: StepCall = async ({ + args: project, + apis: { vault: vaultApi }, + config, +}) => { + logger.debug({ action: 'getProjectSecrets', projectSlug: project.slug }, 'Hook done') + const publishRoRobotProject = project.store.registry?.publishProjectRobot + const publishRoRobotConfig = config.registry?.publishProjectRobot + const projectRobotEnabled + = publishRoRobotProject === ENABLED + || (publishRoRobotConfig === ENABLED + && (!publishRoRobotProject || publishRoRobotProject === DEFAULT)) + + const VaultRobotSecret = projectRobotEnabled + ? ((await vaultApi.read(`REGISTRY/${projectRobotName}`, { + throwIfNoEntry: false, + })) as { data: VaultRobotSecret } | undefined) + : undefined + let secrets: { [x: string]: string } = { + 'Registry base path': `${getConfig().host}/${project.slug}/`, + } + + if (projectRobotEnabled) { + secrets = VaultRobotSecret?.data + ? { + ...secrets, + ...VaultRobotSecret.data, + } + : { + ...secrets, + '/!\\': 'Vous n\'avez pas de robot de lecture veuillez reprovisionner', + } + } + return { + status: { + result: 'OK', + }, + secrets, + } +} diff --git a/.jjconflict-base-1/plugins/harbor/src/index.ts b/.jjconflict-base-1/plugins/harbor/src/index.ts new file mode 100644 index 0000000000..c005ffd840 --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/src/index.ts @@ -0,0 +1,19 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import { createDsoProject, deleteDsoProject, getProjectSecrets } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { steps: { post: createDsoProject } }, + deleteProject: { steps: { main: deleteDsoProject } }, + getProjectSecrets: { steps: { main: getProjectSecrets } }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-base-1/plugins/harbor/src/infos.ts b/.jjconflict-base-1/plugins/harbor/src/infos.ts new file mode 100644 index 0000000000..97c3de42eb --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/src/infos.ts @@ -0,0 +1,71 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED } from '@cpn-console/shared' +import { getConfig } from './utils.js' + +const quotaDescription = '-1 -> illimité, sinon 100MB / 1.2GB (unités : B, KB, MB, GB, TB)), max 1024TB' +const infos = { + name: 'registry', + to: ({ store }) => store?.registry?.projectId ? `${getConfig().url}/harbor/projects/${store.registry.projectId}` : `${getConfig().url}/`, + title: 'Harbor', + imgSrc: '/img/harbor.svg', + description: 'Harbor stocke, analyse et distribue vos images de conteneurs', + config: { + project: [{ + permissions: { + admin: { read: false, write: false }, + user: { read: false, write: false }, + }, + key: 'projectId', + kind: 'text', + title: 'Num du projet Harbor', + value: '', + }, { + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot projet', + description: 'Activer le robot de projet (read-only) et afficher ses identifiants aux utilisateurs', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota', + value: '', + description: `Stockage limite (vide utilisation du paramètre global, ${quotaDescription}`, + placeholder: '', + }], + global: [{ + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot RO aux projets', + description: 'Définit le comportement en l\'absence de ce paramétrage au niveau projet', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota par défaut', + value: '-1', + description: `Stockage limite par projet (${quotaDescription}`, + placeholder: '-1', + }], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-base-1/plugins/harbor/src/kubeSecret.ts b/.jjconflict-base-1/plugins/harbor/src/kubeSecret.ts new file mode 100644 index 0000000000..acf1ec297b --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/src/kubeSecret.ts @@ -0,0 +1,14 @@ +// Utils +export function getSecretObject({ DOCKER_CONFIG }: { DOCKER_CONFIG: string }) { + const b64dockerConfig = Buffer.from(DOCKER_CONFIG).toString('base64') + return { + kind: 'Secret', + data: { + '.dockerconfigjson': b64dockerConfig, + }, + type: 'kubernetes.io/dockerconfigjson', + metadata: { + name: 'registry-pull-secret', + }, + } +} diff --git a/.jjconflict-base-1/plugins/harbor/src/logger.ts b/.jjconflict-base-1/plugins/harbor/src/logger.ts new file mode 100644 index 0000000000..82f73cecb6 --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'harbor' }) diff --git a/.jjconflict-base-1/plugins/harbor/src/monitor.ts b/.jjconflict-base-1/plugins/harbor/src/monitor.ts new file mode 100644 index 0000000000..eaa73c6ea1 --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/src/monitor.ts @@ -0,0 +1,52 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import { getApi } from './utils.js' + +enum HealthStatus { + healthy = 'healthy', + unhealthy = 'unhealthy', +} +const coreComponents = ['core', 'database', 'portal', 'registry', 'registryctl'] + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await getApi().health.getHealth({ + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + const data = res.data + if (data.status === HealthStatus.healthy) { + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + const failedCoreComponents = data.components + ? data.components + .filter(component => + component.status === HealthStatus.unhealthy + && component.name + && coreComponents.includes(component.name), + ) + : [] + + if (failedCoreComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.WARNING + instance.lastStatus.message = 'Service dégradé' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Fatal Error' + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-base-1/plugins/harbor/src/permission.ts b/.jjconflict-base-1/plugins/harbor/src/permission.ts new file mode 100644 index 0000000000..05e30d4d6c --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/src/permission.ts @@ -0,0 +1,26 @@ +import { getApi } from './utils.js' + +export async function addProjectGroupMember(projectName: string, groupName: string, accessLevel: number = 3): Promise { + const api = getApi() + const members = await api.projects.listProjectMembers(projectName) + const member = members.data.find(m => m.entity_name === groupName) + if (member?.id) { // member.id is always set pb comes from bad swagger + if (member.role_id !== accessLevel && member.entity_type !== 'g') { + // le membre semble être incorrectement paramétré, suppression pour recréation + await api.projects.deleteProjectMember(projectName, member.id) + } else { + // tout va bien + return + } + } + // Création du membre + await api.projects.createProjectMember(projectName, { + + role_id: accessLevel, + member_group: { + + group_name: groupName, + group_type: 3, + }, + }) +} diff --git a/.jjconflict-base-1/plugins/harbor/src/policy.ts b/.jjconflict-base-1/plugins/harbor/src/policy.ts new file mode 100644 index 0000000000..4945ad51cc --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/src/policy.ts @@ -0,0 +1,135 @@ +import { isValidCron } from 'cron-validator' +import { logger } from './logger.js' +import { getApi } from './utils.js' + +// https://github.com/goharbor/harbor/blob/main/src/server/v2.0/handler/retention.go +export type RuleTemplate + = | 'always' + | 'latestPulledK' + | 'latestPushedK' + | 'nDaysSinceLastPull' + | 'nDaysSinceLastPush' + +const allowed: RuleTemplate[] = [ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +] + +export const harborRuleTemplate: RuleTemplate = allowed.includes(process.env.HARBOR_RULE_TEMPLATE as RuleTemplate) + ? (process.env.HARBOR_RULE_TEMPLATE as RuleTemplate) + : 'latestPushedK' + +const countEnv = Number(process.env.HARBOR_RULE_COUNT) +export const harborRuleCount = !Number.isNaN(countEnv) && countEnv > 0 + ? countEnv + : harborRuleTemplate === 'always' + ? 1 + : 10 + +const defaultCron = '0 22 2 * * *' +const envCron = process.env.HARBOR_RETENTION_CRON?.trim() + +export const harborRetentionCron = envCron && isValidCron(envCron, { seconds: true }) + ? envCron + : defaultCron + +export interface TagSelector { + kind: 'doublestar' | 'label' + decoration: 'matches' | 'excludes' | 'repoMatches' | 'repoExcludes' + pattern: string +} + +export interface Rule { + disabled: boolean + action: 'retain' | 'delete' + template: RuleTemplate + params: Record + tag_selectors: TagSelector[] + scope_selectors: Record +} + +export interface Trigger { + kind: 'Schedule' + settings: { cron: string } + references: unknown[] +} + +export interface Policy { + algorithm: 'or' | 'and' + rules: Rule[] + trigger: Trigger + scope: { level: 'project', ref: number } +} + +export function makeDefaultPolicy(projectId: number): Policy { + return { + algorithm: 'or', + scope: { level: 'project', ref: projectId }, + rules: [ + { + disabled: false, + action: 'retain', + template: harborRuleTemplate, + params: { [harborRuleTemplate]: harborRuleCount }, + tag_selectors: [ + { kind: 'doublestar', decoration: 'matches', pattern: '**' }, + ], + scope_selectors: { + repository: [ + { kind: 'doublestar', decoration: 'repoMatches', pattern: '**' }, + ], + }, + }, + ], + trigger: { + kind: 'Schedule', + settings: { cron: harborRetentionCron }, + references: [], + }, + } +} + +export async function addRetentionPolicy( + projectName: string, + harborProjectId: number, +): Promise { + const api = getApi() + const ref = Number(harborProjectId) + if (Number.isNaN(ref)) throw new Error(`Invalid projectId: ${harborProjectId}`) + + const policy: Policy = makeDefaultPolicy(ref) + const project = await api.projects.getProject(projectName) + const retentionId = Number(project?.data?.metadata?.retention_id) + + try { + if (retentionId && !Number.isNaN(retentionId)) { + await api.retentions.updateRetention(retentionId, policy as unknown as any) + } else { + await api.retentions.createRetention(policy as unknown as any) + } + } catch (err: any) { + const payload = JSON.stringify(policy, null, 2) + const details = err?.response?.data ?? 'Unknown error' + logger.error( + { + action: 'addRetentionPolicy', + projectSlug: projectName, + projectName, + projectId: project?.data?.project_id, + retentionId, + payload, + details, + err, + }, + 'Failed to apply Harbor retention policy', + ) + throw new Error( + `Retention policy failed for project "${projectName}": ${ + typeof details === 'string' ? details : JSON.stringify(details) + }`, + ) + } +} diff --git a/.jjconflict-base-1/plugins/harbor/src/project.ts b/.jjconflict-base-1/plugins/harbor/src/project.ts new file mode 100644 index 0000000000..6b8a7a0e6e --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/src/project.ts @@ -0,0 +1,70 @@ +import type { Project as HarborProject, Quota } from './api/Api.js' +import { logger } from './logger.js' +import { getApi } from './utils.js' + +export async function createProject(projectName: string, storageLimit: number = -1): Promise { + logger.debug({ action: 'createProject', projectSlug: projectName, storageLimit }, 'Creating Harbor project if missing') + const api = getApi() + const existingProject = await api.projects.getProject(projectName, { + headers: { + 'X-Is-Resource-Name': true, + }, + validateStatus: () => true, + }) + + if (existingProject.status === 200) { + const projectId = existingProject.data.project_id as number + const refQuotas = await api.quotas.listQuotas({ + reference_id: String(projectId), + }) + const hardQuota = refQuotas.data.find(quota => quota.ref?.id === projectId) as Quota + + if (hardQuota.hard.storage !== storageLimit) { + await api.quotas.updateQuota(projectId, { + hard: { + storage: storageLimit, + }, + }) + } + return existingProject.data + } + + await api.projects.createProject({ + project_name: projectName, + metadata: { + auto_scan: 'true', + }, + storage_limit: storageLimit, + }, { + validateStatus: () => true, + }) + + const newProject = await api.projects.getProject(projectName) + return newProject.data +} + +async function removeRepositories(projectName: string) { + const api = getApi() + const repositories = await api.projects.listRepositories(projectName, { page_size: 100 }) + + for (const repo of repositories.data) { + // TODO mauvais swagger name existe forcément + const repoName = repo.name?.split('/').slice(1).join('/') as string + await api.projects.deleteRepository(projectName, repoName) + } + if (repositories.headers['x-total-count'] > repositories.data.length) { + await removeRepositories(projectName) + } +} + +export async function deleteProject(projectName: string) { + const api = getApi() + const project = await api.projects.getProject(projectName, { + validateStatus: status => [200, 404].includes(status), + }) + + if (project.status === 200) { + await removeRepositories(projectName) + await api.projects.deleteProject(projectName) + } +} diff --git a/.jjconflict-base-1/plugins/harbor/src/robot.ts b/.jjconflict-base-1/plugins/harbor/src/robot.ts new file mode 100644 index 0000000000..90c2ee23eb --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/src/robot.ts @@ -0,0 +1,133 @@ +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { Access, Robot, RobotCreated } from './api/Api.js' +import type { HarborApi } from './utils.js' +import { logger } from './logger.js' +import { getConfig, toVaultSecret } from './utils.js' + +// A project only ever owns a handful of robots (ro, rw and optionally the +// project robot — 3 in practice), so fetching a single page is enough; we never +// need to paginate the robot listing. +const ROBOT_LIST_PAGE_SIZE = 100 + +export interface VaultRobotSecret { + // {"auths":{"registry-host.com":{"auth":"","email":""}}}, + DOCKER_CONFIG: string + // registry-host.com, + HOST: string + TOKEN: string + // robot$+ + USERNAME: string +} + +export const getRobot = async (projectName: string, projectId: number, robotName: string, api: HarborApi): Promise => getRobotByName(projectId, `robot$${projectName}+${robotName}`, api) + +export async function ensureRobot(projectName: string, projectId: number, robotName: string, vaultApi: VaultProjectApi, access: Access[], api: HarborApi): Promise { + logger.debug({ action: 'ensureRobot', projectSlug: projectName, robotName }, 'Ensuring Harbor robot') + const vaultPath = `REGISTRY/${robotName}` + const robot = await getRobot(projectName, projectId, robotName, api) + const vaultRobotSecret = await vaultApi.read(vaultPath, { throwIfNoEntry: false }) as { data: VaultRobotSecret } | undefined + + let creds: VaultRobotSecret + // only regenerate robot if there is a change with the host + if (vaultRobotSecret?.data && vaultRobotSecret.data.HOST === getConfig().host) { + creds = vaultRobotSecret.data + } else if (robot) { + creds = toVaultSecret(await regenerateRobot(projectName, projectId, robotName, access, api) as Required) + await vaultApi.write(creds, vaultPath) + } else { + creds = toVaultSecret(await createRobot(projectName, robotName, access, api) as Required) + await vaultApi.write(creds, vaultPath) + } + return creds +} + +export async function deleteRobot(projectName: string, projectId: number, robotName: string, vaultApi: VaultProjectApi, api: HarborApi): Promise { + logger.info({ action: 'deleteRobot', projectSlug: projectName, robotName }, 'Deleting Harbor robot') + const vaultPath = `REGISTRY/${robotName}` + const robot = await getRobot(projectName, projectId, robotName, api) + if (robot?.id) { + api.robots.deleteRobot(robot.id) + } + const vaultRobotSecret = await vaultApi.read(vaultPath, { throwIfNoEntry: false }) as { data: VaultRobotSecret } | undefined + + if (vaultRobotSecret) { + await vaultApi.destroy(vaultPath) + } +} + +export async function createRobot(projectName: string, robotName: string, access: Access[], api: HarborApi) { + logger.info({ action: 'createRobot', projectSlug: projectName, robotName }, 'Creating Harbor robot') + return (await api.robots.createRobot(getRobotPermissions(projectName, robotName, access))).data +} + +export async function regenerateRobot(projectName: string, projectId: number, robotName: string, access: Access[], api: HarborApi): Promise { + logger.info({ action: 'regenerateRobot', projectSlug: projectName, robotName }, 'Regenerating Harbor robot') + const robot = await getRobot(projectName, projectId, robotName, api) + if (robot?.id) + await api.robots.deleteRobot(robot.id) + return createRobot(projectName, robotName, access, api) +} + +export async function getRobotByName(projectId: number, robotName: string, api: HarborApi): Promise { + logger.debug({ action: 'getRobotByName', projectId, robotName }, 'Listing Harbor robots to find matching name') + const listRobots = await api.robots.listRobot({ + q: `Level=project,ProjectID=${projectId}`, + page_size: ROBOT_LIST_PAGE_SIZE, + }) + return listRobots.data.find(({ name }) => name === robotName) +} + +function getRobotPermissions(projectName: string, robotName: string, access: Access[]) { + return { + name: robotName, + duration: -1, + description: 'robot for ci builds', + disable: false, + level: 'project', + permissions: [{ + namespace: projectName, + kind: 'project', + access, + }], + } +} + +export const roAccess: Access[] = [{ + resource: 'repository', + action: 'pull', +}, { + resource: 'artifact', + action: 'read', +}] + +export const rwAccess: Access[] = [...roAccess, { + resource: 'repository', + action: 'list', +}, { + resource: 'tag', + action: 'list', +}, { + resource: 'artifact', + action: 'list', +}, { + resource: 'scan', + action: 'create', +}, { + resource: 'scan', + action: 'stop', +}, { + resource: 'repository', + action: 'push', +}, { + resource: 'artifact-label', + action: 'create', +}, { + resource: 'artifact-label', + action: 'delete', +}, { + resource: 'tag', + action: 'create', +}, { + resource: 'tag', + action: 'delete', +}] diff --git a/.jjconflict-base-1/plugins/harbor/src/utils.ts b/.jjconflict-base-1/plugins/harbor/src/utils.ts new file mode 100644 index 0000000000..abb2b471ba --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/src/utils.ts @@ -0,0 +1,61 @@ +import type { RobotCreated } from './api/Api.js' +import type { VaultRobotSecret } from './robot.js' +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import { Api } from './api/Api.js' + +const config: { + url?: string + internalUrl?: string + host?: string +} = {} + +export function getConfig(): Required { + config.url = config.url ?? removeTrailingSlash(requiredEnv('HARBOR_URL')) + config.internalUrl = config.internalUrl ?? removeTrailingSlash(requiredEnv('HARBOR_INTERNAL_URL')) + config.host = config.host ?? config?.url?.split('://')[1] + // @ts-ignore + return config +} + +function getApiConfig() { + return { + auth: { + username: requiredEnv('HARBOR_ADMIN'), + password: requiredEnv('HARBOR_ADMIN_PASSWORD'), + }, + baseURL: `${getConfig().internalUrl}/api/v2.0/`, + } +} + +let api: Api> | undefined + +export type HarborApi = Api> +export function getApi(): HarborApi { + if (!api) { + api = new Api(getApiConfig()) + } + return api +} + +export function toVaultSecret(robot: Required): VaultRobotSecret { + const auth = `${robot.name}:${robot.secret}` + const buff = Buffer.from(auth) + const b64auth = buff.toString('base64') + return { + DOCKER_CONFIG: JSON.stringify({ + auths: { + [getConfig().host]: { + auth: b64auth, + email: '', + }, + }, + }), + HOST: getConfig().host, + TOKEN: robot.secret, + USERNAME: robot.name, + } +} + +export const rwRobotName = 'rw-robot' +export const roRobotName = 'ro-robot' +export const projectRobotName = 'project-robot' diff --git a/.jjconflict-base-1/plugins/harbor/tsconfig.eslint.json b/.jjconflict-base-1/plugins/harbor/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-1/plugins/harbor/tsconfig.json b/.jjconflict-base-1/plugins/harbor/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-1/plugins/harbor/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-1/plugins/keycloak/.gitignore b/.jjconflict-base-1/plugins/keycloak/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-1/plugins/keycloak/eslint.config.js b/.jjconflict-base-1/plugins/keycloak/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-1/plugins/keycloak/package.json b/.jjconflict-base-1/plugins/keycloak/package.json new file mode 100644 index 0000000000..d0cb9e3ece --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/package.json @@ -0,0 +1,49 @@ +{ + "name": "@cpn-console/keycloak-plugin", + "type": "module", + "version": "2.1.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@keycloak/keycloak-admin-client": "catalog:runtime", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/plugins/keycloak/src/class.ts b/.jjconflict-base-1/plugins/keycloak/src/class.ts new file mode 100644 index 0000000000..36f3dc045e --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/src/class.ts @@ -0,0 +1,33 @@ +import { PluginApi } from '@cpn-console/hooks' +import { consoleGroupName } from './group.js' + +interface KeycloakEnv { + path: string + subgroups: { // Key is the group name, value is the full path + RO: string + RW: string + } +} + +export class KeycloakProjectApi extends PluginApi { + private readonly projectSlug: string + + constructor(projectSlug: string) { + super() + this.projectSlug = projectSlug + } + + public async getProjectGroupPath(): Promise { + return `/${this.projectSlug}` + } + + public async getEnvGroup(environment: string): Promise { + return { + path: `/${this.projectSlug}/${consoleGroupName}/${environment}`, + subgroups: { + RO: `/${this.projectSlug}/${consoleGroupName}/${environment}/RO`, + RW: `/${this.projectSlug}/${consoleGroupName}/${environment}/RW`, + }, + } + } +} diff --git a/.jjconflict-base-1/plugins/keycloak/src/client.ts b/.jjconflict-base-1/plugins/keycloak/src/client.ts new file mode 100644 index 0000000000..484dc8789a --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/src/client.ts @@ -0,0 +1,28 @@ +import KcAdminClient from '@keycloak/keycloak-admin-client' +import getConfig from './config.js' +import { logger } from './logger.js' + +export async function getkcClient() { + const kcClient = new KcAdminClient({ + baseUrl: getConfig().url, + }) + + await kcClient.auth({ + clientId: 'admin-cli', + grantType: 'password', + username: process.env.KEYCLOAK_ADMIN, + password: process.env.KEYCLOAK_ADMIN_PASSWORD, + }) + kcClient.setConfig({ realmName: getConfig().realm }) + return kcClient +} + +export function start() { + getkcClient().catch((error) => { + logger.error({ err: error }, 'Failed to start Keycloak plugin') + if (process.env.IGNORE_PLUGINS_START_FAIL?.includes('keycloak')) { + return + } + throw new Error('failed to start keycloak plugin') + }) +} diff --git a/.jjconflict-base-1/plugins/keycloak/src/config.ts b/.jjconflict-base-1/plugins/keycloak/src/config.ts new file mode 100644 index 0000000000..d249e99b5b --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/src/config.ts @@ -0,0 +1,23 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + url: string + realm: string + + constructor() { + this.url = process.env.KEYCLOAK_INTERNAL_URL + ? removeTrailingSlash(process.env.KEYCLOAK_INTERNAL_URL) + : `${requiredEnv('KEYCLOAK_PROTOCOL')}://${requiredEnv('KEYCLOAK_DOMAIN')}` + this.realm = requiredEnv('KEYCLOAK_REALM') + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-base-1/plugins/keycloak/src/env.d.ts b/.jjconflict-base-1/plugins/keycloak/src/env.d.ts new file mode 100644 index 0000000000..81cd034697 --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/.jjconflict-base-1/plugins/keycloak/src/functions.ts b/.jjconflict-base-1/plugins/keycloak/src/functions.ts new file mode 100644 index 0000000000..9bf08d971d --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/src/functions.ts @@ -0,0 +1,478 @@ +import type { AdminRole, Project, ProjectMember, StepCall, UserEmail, ZoneObject } from '@cpn-console/hooks' +import type { ProjectRole } from '@cpn-console/shared' +import type ClientRepresentation from '@keycloak/keycloak-admin-client/lib/defs/clientRepresentation.js' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js' +import type { CustomGroup } from './group.js' +import { generateRandomPassword, PluginResultBuilder } from '@cpn-console/hooks' +import { isExternalRoleType } from '@cpn-console/shared' +import { getkcClient } from './client.js' +import { consoleGroupName, deleteGroup, getAllSubgroups, getGroupByName, getOrCreateChildGroup, getOrCreateGroupByPath, getOrCreateProjectGroup } from './group.js' +import { logger } from './logger.js' + +export const retrieveKeycloakUserByEmail: StepCall = async ({ args: { email } }) => { + const kcClient = await getkcClient() + try { + const user = (await kcClient.users.find({ email }))[0] + logger.debug({ action: 'retrieveKeycloakUserByEmail', found: Boolean(user) }, 'Hook done') + + return { + status: { result: 'OK' }, + user, + } + } catch (error) { + logger.error({ action: 'retrieveKeycloakUserByEmail', err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const deleteProject: StepCall = async ({ args: project }) => { + const projectSlug = project.slug + try { + const kcClient = await getkcClient() + const group = await getGroupByName(kcClient, projectSlug) + if (group?.id) { + await kcClient.groups.del({ id: group.id }) + logger.info({ action: 'deleteProject', projectSlug, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + logger.info({ action: 'deleteProject', projectSlug, outcome: 'already-missing' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already Missing', + }, + } + } catch (error) { + logger.error({ action: 'deleteProject', projectSlug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const upsertProject: StepCall = async ({ args: project }) => { + const projectSlug = project.slug + const pluginResult = new PluginResultBuilder('Up-to-date') + try { + const kcClient = await getkcClient() + const projectGroup = await getOrCreateProjectGroup(kcClient, projectSlug) + + const groupMembers = await kcClient.groups.listMembers({ id: projectGroup.id }) + + await Promise.all([ + ...groupMembers.map((member, index) => { + if (!project.users.some(({ id }) => id === member.id)) { + return kcClient.users.delFromGroup({ + // @ts-ignore id is present on user, bad typing in lib + id: member.id, + groupId: projectGroup.id, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak project group') + pluginResult.addExtra(`remove-${index}`, err) + }) + } + return undefined + }), + ...project.users.map((user, index) => { + if (!groupMembers.some(({ id }) => id === user.id)) { + return kcClient.users.addToGroup({ + id: user.id, + groupId: projectGroup.id, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t add user to keycloak project group') + pluginResult.addExtra(`add-${index}`, err) + }) + } + return undefined + }), + ]) + + // Ensure envs subgroups exists + const projectGroups = await getAllSubgroups(kcClient, projectGroup.id, 0) + + const consoleGroup: Required = projectGroups.find(({ name }) => name === consoleGroupName) as Required + ?? await getOrCreateChildGroup(kcClient, projectGroup.id, consoleGroupName) as Required + + const envGroups = await getAllSubgroups(kcClient, consoleGroup.id, 0) as CustomGroup[] + + const promises: Promise[] = [] + for (const environment of project.environments) { + const envGroup: Required = envGroups.find(group => group.name === environment.name) as Required + ?? await getOrCreateChildGroup(kcClient, consoleGroup.id, environment.name) + + const [roGroup, rwGroup] = await Promise.all([ + getOrCreateChildGroup(kcClient, envGroup.id, 'RO'), + getOrCreateChildGroup(kcClient, envGroup.id, 'RW'), + ]) + + // Ensure envs permissions membership exists + for (const permission of environment.permissions) { + if (permission.permissions.ro) { + promises.push(kcClient.users.addToGroup({ id: permission.userId, groupId: roGroup.id })) + } else { + promises.push(kcClient.users.delFromGroup({ id: permission.userId, groupId: roGroup.id })) + } + if (permission.permissions.rw) { + promises.push(kcClient.users.addToGroup({ id: permission.userId, groupId: rwGroup.id })) + } else { + promises.push(kcClient.users.delFromGroup({ id: permission.userId, groupId: rwGroup.id })) + } + } + } + + await Promise.all(promises) + + const envGroupIdsToDelete = envGroups + .filter(subGroup => !project.environments.some(({ name }) => name === subGroup.name)) + .map(subGroup => subGroup.id) + .filter((id): id is string => !!id) + if (envGroupIdsToDelete.length) { + await Promise.all(envGroupIdsToDelete.map(id => kcClient.groups.del({ id }))) + } + + logger.info({ action: 'upsertProject', projectSlug }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const upsertZone: StepCall = async ({ args: zone, apis }) => { + const zoneSlug = zone.slug + try { + const kcClient = await getkcClient() + const argocdUrl = zone.argocdUrl + const clientId = getClientZoneId(zone) + const client: ClientRepresentation = { + clientId, + clientAuthenticatorType: 'client-secret', + protocol: 'openid-connect', + publicClient: false, + defaultClientScopes: ['generic'], + redirectUris: [`${argocdUrl}/auth/callback`], + webOrigins: [argocdUrl], + rootUrl: argocdUrl, + adminUrl: argocdUrl, + baseUrl: '/applications', + } + const result = await kcClient.clients.find({ clientId, max: 1 }) + let outcome: 'updated' | 'created' + if (result.length > 0 && result[0].id) { + await kcClient.clients.update({ id: result[0].id }, client) + outcome = 'updated' + } else { + const password = generateRandomPassword(30) + await apis.vault.write({ clientSecret: password }, 'keycloak') + await kcClient.clients.create({ + secret: password, + ...client, + }) + outcome = 'created' + } + logger.info({ action: 'upsertZone', zoneSlug, clientId, outcome }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'upsertZone', zoneSlug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +export const deleteZone: StepCall = async ({ args: zone }) => { + const zoneSlug = zone.slug + try { + const kcClient = await getkcClient() + const clientId = getClientZoneId(zone) + const result = await kcClient.clients.find({ clientId, max: 1 }) + if (result.length > 0 && result[0].id) { + await kcClient.clients.del({ id: result[0].id }) + logger.info({ action: 'deleteZone', zoneSlug, clientId, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + logger.info({ action: 'deleteZone', zoneSlug, clientId, outcome: 'already-missing' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already Missing', + }, + } + } catch (error) { + logger.error({ action: 'deleteZone', zoneSlug, clientId: getClientZoneId(zone), err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const upsertAdminRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) return { status: { result: 'OK', message: 'No OIDC Group defined' } } + if (isExternalRoleType(role.type)) return { status: { result: 'OK', message: 'Skipped (external system role)' } } + const pluginResult = new PluginResultBuilder('Up-to-date') + try { + const kcClient = await getkcClient() + const group = await getOrCreateGroupByPath(kcClient, role.oidcGroup) + const groupMembers = await kcClient.groups.listMembers({ id: group.id }) + + await Promise.all([ + ...groupMembers.map((member, index) => { + if (member.id && !role.members.some(({ id }) => id === member.id)) { + return kcClient.users.delFromGroup({ + id: member.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak admin group') + pluginResult.addExtra(`remove-${index}`, err) + }) + } + return undefined + }), + ...role.members.map((user, index) => { + if (!groupMembers.some(({ id }) => id === user.id)) { + return kcClient.users.addToGroup({ + id: user.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t add user to keycloak admin group') + pluginResult.addExtra(`add-${index}`, err) + }) + } + return undefined + }), + ]) + + logger.info({ action: 'upsertAdminRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertAdminRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const deleteAdminRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) return { status: { result: 'OK', message: 'No OIDC Group defined' } } + if (isExternalRoleType(role.type)) return { status: { result: 'OK', message: 'Skipped (external system role)' } } + const pluginResult = new PluginResultBuilder('Deleted') + try { + const kcClient = await getkcClient() + let group: GroupRepresentation | undefined + if (role.oidcGroup.startsWith('/')) { + const name = role.oidcGroup.split('/').pop() || '' + const groups = await kcClient.groups.find({ search: name }) + group = groups.find(g => g.path === role.oidcGroup) + } else { + const groupOrVoid = await getGroupByName(kcClient, role.oidcGroup) + group = groupOrVoid || undefined + } + + if (group?.id) { + await Promise.all(role.members.map((user, index) => { + return kcClient.users.delFromGroup({ + id: user.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak admin group') + pluginResult.addExtra(`remove-${index}`, err) + }) + })) + } + logger.info({ action: 'deleteAdminRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'deleteAdminRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const upsertProjectRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) { + return { + status: { + result: 'OK', + message: 'No OIDC group defined', + }, + } + } + try { + const kcClient = await getkcClient() + await getOrCreateGroupByPath(kcClient, role.oidcGroup) + logger.info({ action: 'upsertProjectRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Synced', + }, + } + } catch (error) { + logger.error({ action: 'upsertProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed to sync role', + }, + } + } +} + +export const deleteProjectRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) { + return { + status: { + result: 'OK', + message: 'No OIDC group defined', + }, + } + } + try { + const kcClient = await getkcClient() + const [projectName, pluginName, roleName] = role.oidcGroup.split('/').slice(1) + if (!projectName || !pluginName || !roleName) throw new Error('Invalid OIDC group format') + const projectGroup = await getGroupByName(kcClient, projectName) + if (projectGroup?.id) { + const pluginGroups = await getAllSubgroups(kcClient, projectGroup.id, 0) + const pluginGroup = pluginGroups.find(({ name }) => name === pluginName) as Required | undefined + if (pluginGroup?.id) { + const roleGroups = await getAllSubgroups(kcClient, pluginGroup.id, 0) + const roleGroup = roleGroups.find(({ name }) => name === roleName) as Required | undefined + if (roleGroup?.id) { + await deleteGroup(kcClient, roleGroup.id) + logger.info({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + } + } + logger.info({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, outcome: 'already-deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already deleted', + }, + } + } catch (error) { + logger.error({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed to delete role', + }, + } + } +} + +export const upsertProjectMember: StepCall = async ({ args: member }) => { + const pluginResult = new PluginResultBuilder('Synced') + try { + const kcClient = await getkcClient() + + const projectGroup = await getOrCreateProjectGroup(kcClient, member.project.slug) + const consoleGroup = await getOrCreateChildGroup(kcClient, projectGroup.id, consoleGroupName) + const allRoleGroups = await getAllSubgroups(kcClient, consoleGroup.id, 0) + const userGroups = await kcClient.users.listGroups({ id: member.userId }) + + const userRolesOidcGroups = member.roles + .map(r => r.oidcGroup) + .filter((g): g is string => !!g) + + // Sync Roles + let groupMembershipChanges = 0 + for (const roleGroup of allRoleGroups) { + if (!roleGroup.id || !roleGroup.path) continue + const isMember = userGroups.some(ug => ug.id === roleGroup.id) + const shouldBeMember = userRolesOidcGroups.includes(roleGroup.path) + + if (shouldBeMember && !isMember) { + groupMembershipChanges += 1 + await kcClient.users.addToGroup({ id: member.userId, groupId: roleGroup.id }) + } else if (!shouldBeMember && isMember) { + groupMembershipChanges += 1 + await kcClient.users.delFromGroup({ id: member.userId, groupId: roleGroup.id }) + } + } + + logger.info({ action: 'upsertProjectMember', projectSlug: member.project.slug, groupMembershipChanges }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertProjectMember', projectSlug: member.project.slug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const deleteProjectMember: StepCall = async ({ args: member }) => { + const pluginResult = new PluginResultBuilder('Deleted') + try { + const kcClient = await getkcClient() + if (!member.userId) return pluginResult.getResultObject() + + const projectGroup = await getGroupByName(kcClient, member.project.slug) + if (!projectGroup?.id) return pluginResult.getResultObject() + + const userGroups = await kcClient.users.listGroups({ id: member.userId }) + const projectGroups = userGroups.filter(g => g.path?.startsWith(projectGroup.path!)) + + let removedCount = 0 + for (const group of projectGroups) { + if (group.id) { + await kcClient.users.delFromGroup({ id: member.userId, groupId: group.id }) + removedCount += 1 + } + } + + logger.info({ action: 'deleteProjectMember', projectSlug: member.project.slug, removedCount }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'deleteProjectMember', projectSlug: member.project.slug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +function getClientZoneId(zone: ZoneObject): string { + return `argocd-${zone.slug}-zone` +} diff --git a/.jjconflict-base-1/plugins/keycloak/src/group.ts b/.jjconflict-base-1/plugins/keycloak/src/group.ts new file mode 100644 index 0000000000..09c4cdc077 --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/src/group.ts @@ -0,0 +1,123 @@ +import type KeycloakAdminClient from '@keycloak/keycloak-admin-client' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js' + +export const consoleGroupName = 'console' + +export type CustomGroup = Required> +export async function getGroupByName(kcClient: KeycloakAdminClient, name: string): Promise { + const groupSearch = await kcClient.groups.find({ search: name }) + return groupSearch.find(grp => grp.name === name) +} + +export async function getAllSubgroups(kcClient: KeycloakAdminClient, parentId: string, first: number, subgroups: GroupRepresentation[] = []): Promise { + const newSubgroups = [ + ...subgroups, + ...await kcClient.groups.listSubGroups({ parentId, briefRepresentation: false, max: 10, first }), + ] + if (newSubgroups.length - subgroups.length === 10) { + return getAllSubgroups(kcClient, parentId, first + 10, newSubgroups) + } + return newSubgroups +} + +export async function getOrCreateChildGroup(kcClient: KeycloakAdminClient, parentId: string, name: string, subGroups: GroupRepresentation[] | undefined = []): Promise { + if (Array.isArray(subGroups) && subGroups.length > 0) { + const matchingGroup = subGroups.find(({ name: groupName }) => groupName === name) as Required | undefined + if (matchingGroup) { + return { + id: matchingGroup.id, + subGroups: matchingGroup.subGroups || [], + subGroupCount: matchingGroup.subGroups?.length || 0, + name, + } + } + } + subGroups = await getAllSubgroups(kcClient, parentId, 0) + + const matchingGroup = subGroups?.find(({ name: groupName }) => groupName === name) as Required | undefined + if (!matchingGroup) { + const newGroup = await kcClient.groups.createChildGroup({ id: parentId }, { name }) + return { + id: newGroup.id, + subGroups: [], + subGroupCount: 0, + name, + } + } + return { + id: matchingGroup.id, + subGroups: matchingGroup.subGroups || [], + subGroupCount: matchingGroup.subGroups?.length || 0, + name, + } +} + +export async function getOrCreateProjectGroup(kcClient: KeycloakAdminClient, name: string): Promise> & { subGroups: Required[] }> { + const existingGroup = await getGroupByName(kcClient, name) as Required + if (!existingGroup) { + const newGroup = await kcClient.groups.create({ name }) + return { + id: newGroup.id, + subGroups: [], + subGroupCount: 0, + name, + } + } + return { + id: existingGroup.id, + // @ts-ignore mauvais typage de librairie les subGroups ont forcément un id + subGroups: existingGroup.subGroups || [], + subGroupCount: existingGroup.subGroups?.length || 0, + name: existingGroup.name, + } +} + +export async function getOrCreateGroupByName(kcClient: KeycloakAdminClient, name: string): Promise> { + const group = await getGroupByName(kcClient, name) + if (group) return group as Required + const newGroup = await kcClient.groups.create({ name }) + const created = await kcClient.groups.findOne({ id: newGroup.id }) + if (!created) throw new Error(`Failed to retrieve created group: ${name}`) + return created as Required +} + +export async function getOrCreateGroupByPath(kcClient: KeycloakAdminClient, path: string): Promise> { + if (!path.startsWith('/')) { + return getOrCreateGroupByName(kcClient, path) + } + + const name = path.split('/').pop() || '' + const groups = await kcClient.groups.find({ search: name }) + const existingGroup = groups.find(g => g.path === path) + + if (existingGroup) return existingGroup as Required + + const groupNames = path.split('/').filter(Boolean) + let parentId: string | undefined + + for (const groupName of groupNames) { + const subGroups = parentId + ? await kcClient.groups.listSubGroups({ parentId }) + : await kcClient.groups.find({ search: groupName }) + const existingSubGroup = subGroups.find(g => g.name === groupName) + + if (existingSubGroup) { + parentId = existingSubGroup.id + } else { + const newGroup = parentId + ? await kcClient.groups.createChildGroup({ id: parentId }, { name: groupName }) + : await kcClient.groups.create({ name: groupName }) + parentId = newGroup.id + } + } + + if (parentId) { + const group = await kcClient.groups.findOne({ id: parentId }) + if (group) return group as Required + } + throw new Error(`Failed to create group path: ${path}`) +} + +export async function deleteGroup(kcClient: KeycloakAdminClient, groupId: string) { + await kcClient.groups.del({ id: groupId }) +} diff --git a/.jjconflict-base-1/plugins/keycloak/src/index.ts b/.jjconflict-base-1/plugins/keycloak/src/index.ts new file mode 100644 index 0000000000..a5fda76e1f --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/src/index.ts @@ -0,0 +1,67 @@ +import type { DefaultArgs, Plugin, Project, ProjectLite } from '@cpn-console/hooks' +import { KeycloakProjectApi } from './class.js' +import { start } from './client.js' +import { + deleteAdminRole, + deleteProject, + deleteProjectMember, + deleteProjectRole, + deleteZone, + retrieveKeycloakUserByEmail, + upsertAdminRole, + upsertProject, + upsertProjectMember, + upsertProjectRole, + upsertZone, +} from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + deleteProject: { + api: project => new KeycloakProjectApi(project.slug), + steps: { post: deleteProject }, + }, + upsertProject: { + api: project => new KeycloakProjectApi(project.slug), + steps: { main: upsertProject }, + }, + upsertProjectRole: { + steps: { main: upsertProjectRole }, + }, + upsertProjectMember: { + steps: { main: upsertProjectMember }, + }, + deleteProjectMember: { + steps: { post: deleteProjectMember }, + }, + upsertZone: { + steps: { main: upsertZone }, + }, + deleteZone: { + steps: { post: deleteZone }, + }, + deleteProjectRole: { + steps: { post: deleteProjectRole }, + }, + retrieveUserByEmail: { steps: { main: retrieveKeycloakUserByEmail } }, + upsertAdminRole: { + steps: { main: upsertAdminRole }, + }, + deleteAdminRole: { + steps: { post: deleteAdminRole }, + }, + }, + monitor, + start, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + keycloak: Args extends (ProjectLite | Project) + ? KeycloakProjectApi + : undefined + } +} diff --git a/.jjconflict-base-1/plugins/keycloak/src/infos.ts b/.jjconflict-base-1/plugins/keycloak/src/infos.ts new file mode 100644 index 0000000000..3b3980194f --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/src/infos.ts @@ -0,0 +1,12 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos: ServiceInfos = { + name: 'keycloak', + title: 'Keycloak', + config: { + global: [], + project: [], + }, +} + +export default infos diff --git a/.jjconflict-base-1/plugins/keycloak/src/logger.ts b/.jjconflict-base-1/plugins/keycloak/src/logger.ts new file mode 100644 index 0000000000..1578c26c49 --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'keycloak' }) diff --git a/.jjconflict-base-1/plugins/keycloak/src/monitor.ts b/.jjconflict-base-1/plugins/keycloak/src/monitor.ts new file mode 100644 index 0000000000..4f1df8d648 --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/src/monitor.ts @@ -0,0 +1,27 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(requiredEnv('KEYCLOAK_URL'), { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-base-1/plugins/keycloak/tsconfig.eslint.json b/.jjconflict-base-1/plugins/keycloak/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-1/plugins/keycloak/tsconfig.json b/.jjconflict-base-1/plugins/keycloak/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-1/plugins/keycloak/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-1/plugins/nexus/.gitignore b/.jjconflict-base-1/plugins/nexus/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-1/plugins/nexus/README.md b/.jjconflict-base-1/plugins/nexus/README.md new file mode 100644 index 0000000000..a8ee9f797a --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/README.md @@ -0,0 +1,13 @@ +# Introduction + +Ce plugin permet de déployer des dépôts à la demande sur nexus. Les seules technologies prisent en charge actuellement sont Maven et NPM. + +## Configuration + +| Env var | valeur possible | description | +| ---------------------- | --------------- | --------------------------------------------------------------------------------------------- | +| NEXUS_ADMIN | chaine | Nom d'utilisateur admin Nexus | +| NEXUS_ADMIN_PASSWORD | chaine | Mot de passe Nexus | +| NEXUS_URL | *url* | Url public d'accés au Nexus | +| NEXUS_INTERNAL_URL | *url* ou vide | url par laquelle la console interroge le service Nexus, si absent utilisation de l'url public | +| NEXUS__SECRET_EXPOSE_INTERNAL_URL | "true" ou vide | Exposition ou non de l'url interne dans la remontée des secrets du projet | diff --git a/.jjconflict-base-1/plugins/nexus/eslint.config.js b/.jjconflict-base-1/plugins/nexus/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-1/plugins/nexus/package.json b/.jjconflict-base-1/plugins/nexus/package.json new file mode 100644 index 0000000000..0a758fd7d9 --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/package.json @@ -0,0 +1,49 @@ +{ + "name": "@cpn-console/nexus-plugin", + "type": "module", + "version": "2.3.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/plugins/nexus/src/config.ts b/.jjconflict-base-1/plugins/nexus/src/config.ts new file mode 100644 index 0000000000..aecdc37956 --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/src/config.ts @@ -0,0 +1,29 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + secretExposedUrl: string + user: string + password: string + + constructor() { + this.password = requiredEnv('NEXUS_ADMIN_PASSWORD') + this.user = requiredEnv('NEXUS_ADMIN') + this.publicUrl = removeTrailingSlash(requiredEnv('NEXUS_URL')) + this.internalUrl = process.env.NEXUS_INTERNAL_URL + ? removeTrailingSlash(process.env.NEXUS_INTERNAL_URL) + : this.publicUrl + this.secretExposedUrl = process.env.NEXUS__SECRET_EXPOSE_INTERNAL_URL === 'true' ? this.internalUrl : this.publicUrl + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-base-1/plugins/nexus/src/env.d.ts b/.jjconflict-base-1/plugins/nexus/src/env.d.ts new file mode 100644 index 0000000000..b34e01d65a --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-base-1/plugins/nexus/src/functions.ts b/.jjconflict-base-1/plugins/nexus/src/functions.ts new file mode 100644 index 0000000000..5841f1c018 --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/src/functions.ts @@ -0,0 +1,28 @@ +import getConfig from './config.js' + +let axiosOptions: { + baseURL: string + auth: { + username: string + password: string + } + headers: { + Accept: string + } +} + +export function getAxiosOptions(): Required { + if (!axiosOptions) { + axiosOptions = { + baseURL: `${getConfig().internalUrl}/service/rest/v1/`, + auth: { + username: getConfig().user, + password: getConfig().password, + }, + headers: { + Accept: 'application/json', + }, + } + } + return axiosOptions +} diff --git a/.jjconflict-base-1/plugins/nexus/src/index.ts b/.jjconflict-base-1/plugins/nexus/src/index.ts new file mode 100644 index 0000000000..3457e0c021 --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/src/index.ts @@ -0,0 +1,19 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import infos from './infos.js' +import monitor from './monitor.js' +import { createNexusProject, deleteNexusProject, getSecrets } from './project.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { steps: { main: createNexusProject } }, + deleteProject: { steps: { main: deleteNexusProject } }, + getProjectSecrets: { steps: { main: getSecrets } }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-base-1/plugins/nexus/src/infos.ts b/.jjconflict-base-1/plugins/nexus/src/infos.ts new file mode 100644 index 0000000000..83abdb71aa --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/src/infos.ts @@ -0,0 +1,116 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos = { + name: 'nexus', + title: 'Nexus', + imgSrc: '/img/nexus.png', + description: 'Nexus permet de gérer les binaires et artefacts de build à travers la chaîne logistique logicielle', + config: { + project: [ + { + key: 'activateNpmRepo', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt NPM', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'npmWritePolicy', + section: 'NPM', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture', + value: 'allow', + description: 'Politique d\'écriture des dépôts NPM, valeurs possibles: allow / allow_once, deny, replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'activateMavenRepo', + section: 'Maven', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt Maven', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'mavenSnapshotWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Snapshot', + value: 'allow', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'mavenReleaseWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Release', + value: 'allow_once', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + ], + global: [ + { + key: 'activateNpmRepoDefaultValue', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Créer un dépôt NPM privé (comportement par défaut des projets)', + value: 'disabled', + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'activateMavenRepoDefaultValue', + section: 'Maven', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Créer un dépôt MAVEN privé (comportement par défaut des projets)', + value: 'disabled', + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'enablePlugin', + kind: 'switch', + initialValue: 'enabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Activer/Désactiver entièrement le plugin Nexus', + value: 'enabled', + description: 'Défaut: Activé', + }, + ], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-base-1/plugins/nexus/src/logger.ts b/.jjconflict-base-1/plugins/nexus/src/logger.ts new file mode 100644 index 0000000000..952b553155 --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'nexus' }) diff --git a/.jjconflict-base-1/plugins/nexus/src/maven.ts b/.jjconflict-base-1/plugins/nexus/src/maven.ts new file mode 100644 index 0000000000..4fea9e16cc --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/src/maven.ts @@ -0,0 +1,193 @@ +import type { AxiosInstance } from 'axios' +import type { WritePolicy } from './utils.js' +import getConfig from './config.js' +import { deleteIfExists } from './utils.js' + +// Retro-compatibilty, maven is a special case with bad name formats +function getRepoNames(projectName: string) { // Unique function per language cause names are unique per repo + return { + hosted: [ + { + repo: `${projectName}-repository-release`, + privilege: `${projectName}-privilege-release`, + }, + { + repo: `${projectName}-repository-snapshot`, + privilege: `${projectName}-privilege-snapshot`, + }, + ], + group: { + repo: `${projectName}-repository-group`, + privilege: `${projectName}-privilege-group`, + }, + } +} + +async function provisionMavenHosted(axiosInstance: AxiosInstance, repoName: string, writePolicy: WritePolicy) { + const repo = await axiosInstance({ + method: 'GET', + url: `/repositories/maven/hosted/${repoName}`, + validateStatus: code => [200, 404].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/maven/hosted', + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/maven/hosted/${repoName}`, + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [204].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/maven/hosted', + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/maven/hosted/${repoName}`, + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [204].includes(code), + }) + } + } +} +interface MavenOptions { + snapshotWritePolicy: WritePolicy + releaseWritePolicy: WritePolicy +} +export async function createMavenRepo(axiosInstance: AxiosInstance, projectName: string, options: MavenOptions) { + const names = getRepoNames(projectName) + + // create local repo maven + await Promise.all([ + provisionMavenHosted(axiosInstance, names.hosted[0].repo, options.releaseWritePolicy), + provisionMavenHosted(axiosInstance, names.hosted[1].repo, options.snapshotWritePolicy), + ]) + + // create maven group + await axiosInstance({ + method: 'post', + url: '/repositories/maven/group', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + 'maven-public', + ], + }, + }, + validateStatus: code => [201, 400].includes(code), + }) + + // create privileges + for (const name of [...names.hosted, names.group]) { + await axiosInstance({ + method: 'post', + url: '/security/privileges/repository-view', + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'maven2', + repository: name.repo, + }, + validateStatus: code => [201, 400].includes(code), + }) + } + return names +} + +export async function deleteMavenRepo(axiosInstance: AxiosInstance, projectName: string) { + const names = getRepoNames(projectName) + const repoPaths = [names.group, ...names.hosted] + const privileges = [...names.hosted, names.group] + const pathsToDelete = [ + // delete privileges + ...privileges.map(({ privilege }) => `/security/privileges/${privilege}`), + // delete local repo maven snapshot + ...repoPaths.map(repo => `/repositories/${repo.repo}`), + ] + for (const path of pathsToDelete) { + await deleteIfExists(path, axiosInstance) + } +} + +export function getMavenUrls(projectName: string) { + const nexusUrl = getConfig().secretExposedUrl + const names = getRepoNames(projectName) + return { + MAVEN_REPO_RELEASE: `${nexusUrl}/${names.hosted[0].repo}`, + MAVEN_REPO_SNAPSHOT: `${nexusUrl}/${names.hosted[1].repo}`, + } +} diff --git a/.jjconflict-base-1/plugins/nexus/src/monitor.ts b/.jjconflict-base-1/plugins/nexus/src/monitor.ts new file mode 100644 index 0000000000..a79babebeb --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/src/monitor.ts @@ -0,0 +1,79 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import { getAxiosOptions } from './functions.js' + +const coreComponents = [ + 'Blob Stores Ready', + 'File Blob Stores Path', +] +const auxComponents = [ + 'Available CPUs', + 'Blob Stores Quota', + 'Blob Stores Ready', + 'Default Admin Credentials', + 'DefaultRoleRealm', + 'File Blob Stores Path', + 'NuGet V2 repositories', + 'Scheduler', + 'com.codahale.metrics.health.jvm.ThreadDeadlockHealthCheck', + 'Node Health Check', + 'Re-encryption required', + 'Default Admin Credentials', + 'Recent version', +] + +type NexusRes = Record + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const status = await axios.get('/status', { + validateStatus: () => true, + ...getAxiosOptions(), + }) + const statusCheck = await axios.get('/status/check', { + validateStatus: res => [200, 503].includes(res), + ...getAxiosOptions(), + }) + const statusWritable = await axios.get('/status/writable', { + validateStatus: res => [200, 503].includes(res), + ...getAxiosOptions(), + }) + if (status.status === 503 || statusWritable.status === 503) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Nexus semble planté' + return instance.lastStatus + } + if (statusCheck.status === 200) { + const data = statusCheck.data as NexusRes + + const failedCoreComponents = coreComponents.filter(component => !data[component]?.healthy) + const failedAuxComponents = auxComponents.filter(component => !data[component]?.healthy) + if (failedAuxComponents.length || failedCoreComponents.length) { + instance.lastStatus.status = failedCoreComponents.length + ? MonitorStatus.ERROR + : MonitorStatus.WARNING + instance.lastStatus.message = failedCoreComponents.length + ? 'Des composants critiques sont en erreur' + : 'Le service est partiellement dégradé' + + instance.lastStatus.cause = `Les composants suivants sont en erreurs: ${[...failedCoreComponents, ...failedAuxComponents].join(', ')}` + return instance.lastStatus + } + } + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-base-1/plugins/nexus/src/npm.ts b/.jjconflict-base-1/plugins/nexus/src/npm.ts new file mode 100644 index 0000000000..02bc3573f0 --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/src/npm.ts @@ -0,0 +1,167 @@ +import type { AxiosInstance } from 'axios' +import type { WritePolicy } from './utils.js' +import getConfig from './config.js' +import { deleteIfExists } from './utils.js' + +function getRepoNames(projectName: string) { // Unique function per language cause names are unique per repo + return { + hosted: [ + { + repo: `${projectName}-npm`, + privilege: `${projectName}-npm-privilege`, + }, + ], + group: { + repo: `${projectName}-npm-group`, + privilege: `${projectName}-npm-group-privilege`, + }, + } +} +export async function createNpmRepo(axiosInstance: AxiosInstance, projectName: string, writePolicy: WritePolicy) { + const names = getRepoNames(projectName) + // create local repo maven + for (const repVersion of names.hosted) { + const repo = await axiosInstance({ + method: 'GET', + url: `/repositories/npm/hosted/${repVersion.repo}`, + validateStatus: code => [200, 404].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/npm/hosted', + data: { + name: repVersion.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/npm/hosted/${repVersion.repo}`, + data: { + name: repVersion.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + }, + validateStatus: code => [204].includes(code), + }) + } + } + // create maven group + const group = await axiosInstance({ + method: 'get', + url: `/repositories/npm/group/${names.group.repo}`, + validateStatus: code => [200, 404].includes(code), + }) + if (group.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/npm/group', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + ], + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + url: `/repositories/npm/group/${names.group.repo}`, + method: 'put', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + ], + }, + }, + validateStatus: code => [204].includes(code), + }) + } + + for (const name of [...names.hosted, names.group]) { + const privilege = await axiosInstance({ + method: 'get', + url: `/security/privileges/${name.privilege}`, + validateStatus: code => [200, 404].includes(code), + }) + if (privilege.status === 404) { + await axiosInstance({ + method: 'post', + url: '/security/privileges/repository-view', + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'npm', + repository: name.repo, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/security/privileges/repository-view/${name.privilege}`, + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'npm', + repository: name.repo, + }, + validateStatus: code => [204].includes(code), + }) + } + } + return names +} + +export async function deleteNpmRepo(axiosInstance: AxiosInstance, projectName: string) { + const names = getRepoNames(projectName) + const repoPaths = [names.group, ...names.hosted] + const privileges = [...names.hosted, names.group] + const pathsToDelete = [ + // delete privileges + ...privileges.map(({ privilege }) => `/security/privileges/${privilege}`), + // delete local repo maven snapshot + ...repoPaths.map(repo => `/repositories/${repo.repo}`), + ] + for (const path of pathsToDelete) { + await deleteIfExists(path, axiosInstance) + } +} + +export function getNpmUrls(projectName: string) { + const nexusUrl = getConfig().secretExposedUrl + const names = getRepoNames(projectName) + return { + NPM_REPO: `${nexusUrl}/${names.hosted[0].repo}`, + } +} diff --git a/.jjconflict-base-1/plugins/nexus/src/project.ts b/.jjconflict-base-1/plugins/nexus/src/project.ts new file mode 100644 index 0000000000..2363ca05a1 --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/src/project.ts @@ -0,0 +1,245 @@ +import type { Project, ProjectLite, StepCall } from '@cpn-console/hooks' +import type { WritePolicy } from './utils.js' +import { generateRandomPassword, specificallyDisabled } from '@cpn-console/hooks' +import axios from 'axios' +import { getAxiosOptions } from './functions.js' +import { logger } from './logger.js' +import { createMavenRepo, deleteMavenRepo, getMavenUrls } from './maven.js' +import { createNpmRepo, deleteNpmRepo, getNpmUrls } from './npm.js' +import { deleteIfExists, getTechUsed, parseProjectOptions, updateStore } from './utils.js' + +const getAxiosInstance = () => axios.create(getAxiosOptions()) + +export const deleteNexusProject: StepCall = async ({ args: project }) => { + const axiosInstance = getAxiosInstance() + const projectName = project.slug + try { + await deleteMavenRepo(axiosInstance, projectName) + await deleteNpmRepo(axiosInstance, projectName) + await Promise.all([ + // delete role + deleteIfExists(`/security/roles/${projectName}-ID`, axiosInstance), + // delete user + axiosInstance({ + method: 'delete', + url: `/security/users/${projectName}`, + validateStatus: code => code === 404 || code < 300, + }), + ]) + + logger.info({ action: 'deleteNexusProject', projectSlug: project.slug, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Project deleted from Nexus', + }, + store: { + activateNpmRepo: 'disabled', + activateMavenRepo: 'disabled', + }, + } + } catch (error) { + logger.error({ action: 'deleteNexusProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const createNexusProject: StepCall = async (payload) => { + try { + if (specificallyDisabled(payload.config.nexus?.enablePlugin)) { + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'disabled' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Nexus plugin is disabled', + }, + } + } + if (!payload.apis.vault) throw new Error('no Vault available') + + const axiosInstance = getAxiosInstance() + const projectName = payload.args.slug + const owner = payload.args.owner + const res: any = {} + const options = parseProjectOptions(payload.args.store.nexus) + const failedProvisionning: Partial> = {} + const techUsed = getTechUsed(payload) + + if (options.keysInError.length) { + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'invalid-config', invalidKeyCount: options.keysInError.length }, 'Hook done') + return { + status: { + result: 'WARNING', + message: `${options.keysInError.join(', ')} ${options.keysInError.length > 1 ? 'are' : 'is'} invalid, provisionning canceled, fix it and try again`, + }, + warnReasons: [`invalid key(s): ${options.keysInError.join(', ')}`], + } + } + const privilegesToAccess = [] as string[] + + try { + if (techUsed.maven) { + const names = await createMavenRepo(axiosInstance, projectName, { + releaseWritePolicy: options.mavenReleaseWritePolicy as WritePolicy, + snapshotWritePolicy: options.mavenSnapshotWritePolicy as WritePolicy, + }) + privilegesToAccess.push(names.group.privilege, ...names.hosted.map(({ privilege }) => privilege)) + } else { + await deleteMavenRepo(axiosInstance, projectName) + } + } catch (error) { + failedProvisionning.maven = { error, message: `Maven failed to ${techUsed.maven ? 'provision' : 'delete'} repositories please try again in few minutes` } + } + + try { + if (techUsed.npm) { + const names = await createNpmRepo(axiosInstance, projectName, options.npmWritePolicy as WritePolicy) + privilegesToAccess.push(names.group.privilege, ...names.hosted.map(({ privilege }) => privilege)) + } else { + await deleteNpmRepo(axiosInstance, projectName) + } + } catch (error) { + failedProvisionning.npm = { error, message: `Npm failed to ${techUsed.npm ? 'provision' : 'delete'} repositories please try again in few minutes` } + } + + const roleId = `${projectName}-ID` + // create role + const role = await axiosInstance({ + method: 'GET', + url: `security/roles/${roleId}`, + validateStatus: code => [200, 404].includes(code), + }) + if (role.status === 404) { + await axiosInstance({ + method: 'post', + url: '/security/roles', + data: { + id: `${projectName}-ID`, + name: `${projectName}-role`, + description: 'desc', + privileges: privilegesToAccess, + }, + validateStatus: code => [200].includes(code), + }) + } else if (role.status === 200) { + await axiosInstance({ + method: 'PUT', + url: `security/roles/${roleId}`, + data: { + id: `${projectName}-ID`, + name: `${projectName}-role`, + privileges: privilegesToAccess, + }, + validateStatus: code => [204].includes(code), + }) + } + + const vaultNexusSecret = await payload.apis.vault.read('NEXUS', { throwIfNoEntry: false }) + let currentPwd: string = vaultNexusSecret?.NEXUS_PASSWORD + + const newPwd = generateRandomPassword(30) + const getUser = await axiosInstance({ + url: `/security/users?userId=${projectName}`, + }) as { data: { userId: string }[] } + const user = getUser.data.find(user => user.userId === projectName) + if (user) { + res.user = getUser.data[0] + res.status = { result: 'OK', message: 'User already exists' } + if (!vaultNexusSecret) { + await axiosInstance({ + method: 'put', + url: `/security/users/${projectName}/change-password`, + data: newPwd, + headers: { + 'Content-Type': 'text/plain', + }, + }) + currentPwd = newPwd + } + } else { + // createUser + await axiosInstance({ + method: 'post', + url: '/security/users', + data: { + userId: projectName, + firstName: 'Monkey D.', + lastName: 'Luffy', + emailAddress: owner.email, + password: newPwd, + status: 'active', + roles: [`${projectName}-ID`], + }, + }) + currentPwd = newPwd + } + + if (!getUser.data.length || (getUser.data.length && !vaultNexusSecret)) { // conditions précédentes, si non existent ou si modp a dû être changé + await payload.apis.vault.write({ + NEXUS_PASSWORD: currentPwd, + NEXUS_USERNAME: projectName, + }, 'NEXUS') + } + + if (Object.keys(failedProvisionning).length) { + const failed = Object.values(failedProvisionning) + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'partial-failure', failedProvisionCount: failed.length }, 'Hook done') + return { + status: { + result: 'WARNING', + message: failed.map(({ message }) => message).join('; '), + }, + errors: failed.map(({ error }) => error), + } + } + + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'up-to-date' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + store: updateStore(payload.args.store.nexus), + } + } catch (error) { + logger.error({ action: 'createNexusProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Fail Create repositories', + }, + } + } +} + +export const getSecrets: StepCall = async (payload) => { + if (specificallyDisabled(payload.config.nexus?.enablePlugin)) { + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug, outcome: 'disabled' }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } + const projectName = payload.args.slug + const techUsed = getTechUsed(payload) + + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + secrets: { + ...techUsed.maven && getMavenUrls(projectName), + ...techUsed.npm && getNpmUrls(projectName), + }, + } +} diff --git a/.jjconflict-base-1/plugins/nexus/src/utils.ts b/.jjconflict-base-1/plugins/nexus/src/utils.ts new file mode 100644 index 0000000000..5c52c4435f --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/src/utils.ts @@ -0,0 +1,80 @@ +import type { HookPayload, Project, ProjectLite } from '@cpn-console/hooks' +import type { AxiosInstance } from 'axios' +import { defaultOrNullish, specificallyEnabled } from '@cpn-console/hooks' + +export async function deleteIfExists(url: string, axiosInstance: AxiosInstance) { + const res = await axiosInstance({ + method: 'get', + url, + validateStatus: code => code === 404 || code < 300, + }) + if (res.status !== 404) { + // delete maven group + return axiosInstance({ + method: 'delete', + url, + validateStatus: code => code === 404 || code < 300, + }) + } + return res +} + +export function getTechUsed(payload: HookPayload) { + const projectStore = payload.args.store.nexus + const globalConfig = payload.config.nexus + const techUsed = { + maven: false, + npm: false, + } + if (specificallyEnabled(projectStore?.activateMavenRepo)) { + techUsed.maven = true + } else if (defaultOrNullish(projectStore?.activateMavenRepo) && specificallyEnabled(globalConfig?.activateMavenRepoDefaultValue)) { + techUsed.maven = true + } + if (specificallyEnabled(projectStore?.activateNpmRepo)) { + techUsed.npm = true + } else if (defaultOrNullish(projectStore?.activateMavenRepo) && specificallyEnabled(globalConfig?.activateMavenRepoDefaultValue)) { + techUsed.npm = true + } + return techUsed +} + +export const writePolicyEnum = ['ALLOW', 'ALLOW_ONCE', 'DENY', 'REPLICATION_ONLY'] as const +export type WritePolicy = typeof writePolicyEnum[number] + +export function parseWritePolicy(inputTrial?: string): WritePolicy | 'ERROR' { + const input = inputTrial?.toUpperCase() ?? 'ALLOW' + return writePolicyEnum.find(policy => policy === input) ?? 'ERROR' +} + +export function parseProjectOptions(options: ProjectLite['store']['nexus']) { + // @ts-expect-error + const mavenReleaseWritePolicy = parseWritePolicy(options?.mavenReleaseWritePolicy ?? options?.mavenWritePolicy) + // @ts-expect-error + const mavenSnapshotWritePolicy = parseWritePolicy(options?.mavenSnapshotWritePolicy ?? options?.mavenWritePolicy) + const npmWritePolicy = parseWritePolicy(options?.npmWritePolicy) + const keysInError: (keyof NonNullable>)[] = [] + if (mavenReleaseWritePolicy === 'ERROR') keysInError.push('mavenReleaseWritePolicy') + if (mavenSnapshotWritePolicy === 'ERROR') keysInError.push('mavenSnapshotWritePolicy') + if (npmWritePolicy === 'ERROR') keysInError.push('npmWritePolicy') + return { + keysInError, + mavenReleaseWritePolicy, + mavenSnapshotWritePolicy, + npmWritePolicy, + } +} + +export function updateStore(store?: ProjectLite['store']['nexus']) { + if (!store) return {} + // @ts-expect-error + if (store.mavenWritePolicy) { + // @ts-expect-error + store.mavenReleaseWritePolicy = store.mavenReleaseWritePolicy ?? store.mavenWritePolicy + // @ts-expect-error + store.mavenSnapshotWritePolicy = store.mavenSnapshotWritePolicy ?? store.mavenWritePolicy + // @ts-expect-error + store.mavenWritePolicy = '' + } + return store +} diff --git a/.jjconflict-base-1/plugins/nexus/tsconfig.eslint.json b/.jjconflict-base-1/plugins/nexus/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-1/plugins/nexus/tsconfig.json b/.jjconflict-base-1/plugins/nexus/tsconfig.json new file mode 100644 index 0000000000..fb5ba5c23c --- /dev/null +++ b/.jjconflict-base-1/plugins/nexus/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "useUnknownInCatchVariables": true, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-1/plugins/sonarqube/.gitignore b/.jjconflict-base-1/plugins/sonarqube/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-1/plugins/sonarqube/eslint.config.js b/.jjconflict-base-1/plugins/sonarqube/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-1/plugins/sonarqube/package.json b/.jjconflict-base-1/plugins/sonarqube/package.json new file mode 100644 index 0000000000..703362ca0d --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/package.json @@ -0,0 +1,50 @@ +{ + "name": "@cpn-console/sonarqube-plugin", + "type": "module", + "version": "2.0.5", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/plugins/sonarqube/src/check.ts b/.jjconflict-base-1/plugins/sonarqube/src/check.ts new file mode 100644 index 0000000000..67a1e79cf2 --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/src/check.ts @@ -0,0 +1,45 @@ +import type { PluginResult } from '@cpn-console/hooks' +import { getAxiosInstance } from './tech.js' + +let status: PluginResult + +export async function getStatus() { + if (!status?.result) { + status = await check() + } + return status +} + +export async function check(): Promise { + const axiosInstance = getAxiosInstance() + + try { + const health = await axiosInstance({ + url: 'system/info', + }) + const res: PluginResult = { + status: { + result: 'OK', + }, + } + + if (health.data.Health === 'RED') { + res.status.result = 'KO' + } + if (health.data['Health Causes']) { + res.status.message = health.data['Health Causes'].join('\n') + } + + return res + } catch (error) { + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + updatedAt: Date.now(), + } + } +} diff --git a/.jjconflict-base-1/plugins/sonarqube/src/env.d.ts b/.jjconflict-base-1/plugins/sonarqube/src/env.d.ts new file mode 100644 index 0000000000..219601dae5 --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/src/env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/.jjconflict-base-1/plugins/sonarqube/src/functions.ts b/.jjconflict-base-1/plugins/sonarqube/src/functions.ts new file mode 100644 index 0000000000..2a72978cc7 --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/src/functions.ts @@ -0,0 +1,352 @@ +import type { Project, StepCall } from '@cpn-console/hooks' +import type { KeycloakProjectApi } from '@cpn-console/keycloak-plugin/types/class.js' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { SonarPaging } from './project.js' +import type { VaultSonarSecret } from './tech.js' +import type { SonarUser } from './user.js' +import { generateProjectKey } from '@cpn-console/hooks' +import { adminGroupPath } from '@cpn-console/shared' +import { ensureGroupExists, findGroupByName } from './group.js' +import { logger } from './logger.js' +import { + createDsoRepository, + deleteDsoRepository, + ensureRepositoryConfiguration, + files, + findSonarProjectsForDsoProjects, +} from './project.js' +import { getAxiosInstance } from './tech.js' +import { ensureUserExists } from './user.js' + +const globalPermissions = [ + 'admin', + 'profileadmin', + 'gateadmin', + 'scan', + 'provisioning', +] + +const projectPermissions = [ + 'admin', + 'codeviewer', + 'issueadmin', + 'securityhotspotadmin', + 'scan', + 'user', +] + +export async function initSonar() { + try { + await setTemplatePermisions() + await createAdminGroup() + await setAdminPermisions() + logger.info({ action: 'initSonar' }, 'Hook done') + } catch (error) { + logger.error({ action: 'initSonar', err: error }, 'Hook failed') + throw error + } +} + +async function createAdminGroup() { + const axiosInstance = getAxiosInstance() + const adminGroup = await findGroupByName(adminGroupPath) + if (!adminGroup) { + await axiosInstance({ + method: 'post', + params: { + name: adminGroupPath, + description: 'DSO platform admins', + }, + url: 'user_groups/create', + }) + } +} + +async function setAdminPermisions() { + const axiosInstance = getAxiosInstance() + for (const permission of globalPermissions) { + await axiosInstance({ + method: 'post', + params: { + groupName: adminGroupPath, + permission, + }, + url: 'permissions/add_group', + }) + } +} + +async function setTemplatePermisions() { + const axiosInstance = getAxiosInstance() + await axiosInstance({ + method: 'post', + params: { name: 'Forge Default' }, + url: 'permissions/create_template', + validateStatus: code => [200, 400].includes(code), + }) + for (const permission of projectPermissions) { + await axiosInstance({ + method: 'post', + params: { + templateName: 'Forge Default', + permission, + }, + url: 'permissions/add_project_creator_to_template', + }) + await axiosInstance({ + method: 'post', + params: { + groupName: 'sonar-administrators', + templateName: 'Forge Default', + permission, + }, + url: 'permissions/add_group_to_template', + }) + } + await axiosInstance({ + method: 'post', + params: { + templateName: 'Forge Default', + }, + url: 'permissions/set_default_template', + }) +} + +export const upsertProject: StepCall = async (payload) => { + try { + const project = payload.args + const { + vault: vaultApi, + keycloak: keycloakApi, + }: { + vault: VaultProjectApi + keycloak: KeycloakProjectApi + } = payload.apis + const { slug: projectSlug } = project + const username = project.slug + const keycloakGroupPath = await keycloakApi.getProjectGroupPath() + const sonarRepositories + = await findSonarProjectsForDsoProjects(projectSlug) + + await Promise.all([ + ensureUserAndVault(vaultApi, username, projectSlug), + ensureGroupExists(keycloakGroupPath), + + // Remove excess repositories + ...sonarRepositories + .filter( + sonarRepository => + !project.repositories.some( + repo => repo.internalRepoName === sonarRepository.repository, + ), + ) + .map(sonarRepository => deleteDsoRepository(sonarRepository.key)), + + // Create or configure needed repos + ...project.repositories.map(async (repository) => { + const projectKey = generateProjectKey( + projectSlug, + repository.internalRepoName, + ) + if ( + !sonarRepositories.some( + sonarRepository => + sonarRepository.repository === repository.internalRepoName, + ) + ) { + await createDsoRepository(projectSlug, repository.internalRepoName) + } + await ensureRepositoryConfiguration( + projectKey, + username, + keycloakGroupPath, + ) + }), + ]) + + logger.info({ action: 'upsertProject', projectSlug }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } catch (error) { + logger.error( + { action: 'upsertProject', projectSlug: payload.args.slug, err: error }, + 'Hook failed', + ) + return { + status: { + result: 'WARNING', + message: 'Failed to reconcile', + }, + errors: { + main: error, + }, + } + } +} + +export const setVariables: StepCall = async (payload) => { + const returnResponse = payload.results.sonarqube + try { + const project = payload.args + const { slug: projectSlug } = project + const { gitlab: gitlabApi } = payload.apis + + const sonarSecret = await payload.apis.vault.read('SONAR') + const listGroupVars = await gitlabApi.getGitlabGroupVariables() + await Promise.all([ + // Sonar vars saving in CI (repositories) + ...project.repositories.map(async (repo) => { + const projectKey = generateProjectKey( + projectSlug, + repo.internalRepoName, + ) + const repoId = await payload.apis.gitlab.getProjectId( + repo.internalRepoName, + ) + if (!repoId) return + const listVars = await gitlabApi.getGitlabRepoVariables(repoId) + return [ + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + key: 'PROJECT_KEY', + masked: false, + protected: false, + value: projectKey, + variable_type: 'env_var', + environment_scope: '*', + }), + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + key: 'PROJECT_NAME', + masked: false, + protected: false, + value: `${projectSlug}-${repo.internalRepoName}`, + variable_type: 'env_var', + environment_scope: '*', + }), + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + variable_type: 'file', + key: 'SONAR_PROJECT_PROPERTIES', + masked: false, + protected: false, + value: files['sonar-project.properties'](projectKey), + environment_scope: '*', + }), + ] + }), + // Sonar vars saving in CI (group) + gitlabApi.setGitlabGroupVariable(listGroupVars, { + key: 'SONAR_TOKEN', + masked: true, + protected: false, + value: sonarSecret.data.SONAR_TOKEN, + variable_type: 'env_var', + }), + ]) + + if (payload.results.sonarqube.status.result === 'WARNING') { + returnResponse.status.message = `main message: ${payload.results.sonarqube.status.message}, post message: OK` + } + logger.info({ action: 'setVariables', projectSlug }, 'Hook done') + return returnResponse + } catch (error) { + returnResponse.status.result = 'WARNING' + returnResponse.status.message = `main message: ${payload.results.sonarqube.status.message}, post message: Failed to reconcile` + returnResponse.errors = { + ...returnResponse.errors, + post: error, + } + logger.error( + { action: 'setVariables', projectSlug: payload.args.slug, err: error }, + 'Hook failed', + ) + return returnResponse + } +} + +export const deleteProject: StepCall = async (payload) => { + const axiosInstance = getAxiosInstance() + + const project = payload.args + const { slug: projectSlug } = project + const username = projectSlug + try { + const sonarRepositories + = await findSonarProjectsForDsoProjects(projectSlug) + await Promise.all(sonarRepositories.map(repo => deleteRepo(repo.key))) + const users: { paging: SonarPaging, users: SonarUser[] } = ( + await axiosInstance({ + url: 'users/search', + params: { + q: username, + }, + }) + )?.data + const user = users.users.find(u => u.login === username) + if (!user) { + return { + status: { + result: 'OK', + message: 'Already missing', + }, + } + } + await axiosInstance({ + url: 'users/deactivate', + params: { + login: username, + anonymize: true, + }, + method: 'post', + }) + logger.info( + { action: 'deleteProject', projectSlug, outcome: 'user-anonymized' }, + 'Hook done', + ) + return { + status: { + result: 'OK', + message: 'User anonymized', + }, + } + } catch (error) { + logger.error( + { action: 'deleteProject', projectSlug, err: error }, + 'Hook failed', + ) + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +async function deleteRepo(projectKey: string) { + return getAxiosInstance()({ + url: 'projects/delete', + params: { + project: projectKey, + }, + method: 'post', + }) +} + +async function ensureUserAndVault( + vaultApi: VaultProjectApi, + username: string, + projectSlug: string, +) { + const vaultUserSecret = (await vaultApi.read('SONAR', { + throwIfNoEntry: false, + })) as VaultSonarSecret | undefined + const newUserSecret = await ensureUserExists( + username, + projectSlug, + vaultUserSecret, + ) + if (newUserSecret) await vaultApi.write(newUserSecret, 'SONAR') +} diff --git a/.jjconflict-base-1/plugins/sonarqube/src/group.ts b/.jjconflict-base-1/plugins/sonarqube/src/group.ts new file mode 100644 index 0000000000..08ae823069 --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/src/group.ts @@ -0,0 +1,35 @@ +import type { SonarPaging } from './project.js' +import { getAxiosInstance } from './tech.js' + +export interface SonarGroup { + id: string + name: string + description: string + membersCount: number + default: boolean +} + +export async function findGroupByName(name: string): Promise { + const axiosInstance = getAxiosInstance() + const groupsSearch: { paging: SonarPaging, groups: SonarGroup[] } = (await axiosInstance({ + url: 'user_groups/search', + params: { + q: name, + }, + }))?.data + return groupsSearch.groups.find(g => g.name === name) +} + +export async function ensureGroupExists(groupName: string) { + const axiosInstance = getAxiosInstance() + const group = await findGroupByName(groupName) + if (!group) { + await axiosInstance({ + url: 'user_groups/create', + method: 'post', + params: { + name: groupName, + }, + }) + } +} diff --git a/.jjconflict-base-1/plugins/sonarqube/src/index.ts b/.jjconflict-base-1/plugins/sonarqube/src/index.ts new file mode 100644 index 0000000000..a05d49e210 --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/src/index.ts @@ -0,0 +1,37 @@ +import type { HookStepsNames, Plugin } from '@cpn-console/hooks' +import { getStatus } from './check.js' +import { deleteProject, initSonar, setVariables, upsertProject } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +function start(_options: unknown) { + try { + initSonar() + getStatus() + } catch {} +} + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { + steps: { + main: upsertProject, + post: setVariables, + }, + }, + deleteProject: { + steps: { + main: deleteProject, + }, + }, + }, + start, + monitor, +} + +declare module '@cpn-console/hooks' { + interface PluginResult { + errors?: Partial> + } +} diff --git a/.jjconflict-base-1/plugins/sonarqube/src/infos.ts b/.jjconflict-base-1/plugins/sonarqube/src/infos.ts new file mode 100644 index 0000000000..94ea6616cc --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/src/infos.ts @@ -0,0 +1,12 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { getConfig } from './tech.js' + +const infos: ServiceInfos = { + name: 'sonarqube', + to: () => `${getConfig().url}/projects`, + title: 'SonarQube', + imgSrc: '/img/sonarqube.svg', + description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', +} + +export default infos diff --git a/.jjconflict-base-1/plugins/sonarqube/src/logger.ts b/.jjconflict-base-1/plugins/sonarqube/src/logger.ts new file mode 100644 index 0000000000..7683fa7961 --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'sonarqube' }) diff --git a/.jjconflict-base-1/plugins/sonarqube/src/monitor.ts b/.jjconflict-base-1/plugins/sonarqube/src/monitor.ts new file mode 100644 index 0000000000..d4fe929477 --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/src/monitor.ts @@ -0,0 +1,41 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import { getAxiosOptions } from './tech.js' + +const statusMap = { + GREEN: MonitorStatus.OK, + YELLOW: MonitorStatus.WARNING, + RED: MonitorStatus.ERROR, +} +const messageMap = { + GREEN: MonitorStatus.OK, + YELLOW: 'Service dégradé', + RED: 'Service en panne', +} +interface SonarRes { + health: keyof typeof statusMap + causes: string[] +} + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get('/system/health', { + validateStatus: res => res === 200, + ...getAxiosOptions(), + }) + const data = res.data as SonarRes + + instance.lastStatus.status = statusMap[data.health] + instance.lastStatus.message = messageMap[data.health] + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-base-1/plugins/sonarqube/src/project.ts b/.jjconflict-base-1/plugins/sonarqube/src/project.ts new file mode 100644 index 0000000000..16082a8d5e --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/src/project.ts @@ -0,0 +1,160 @@ +import { generateProjectKey } from '@cpn-console/hooks' +import { logger } from './logger.js' +import { getAxiosInstance } from './tech.js' + +export interface SonarPaging { + pageIndex: number + pageSize: number + total: number +} + +export type Qualifiers + = 'BRC' // - Sub - projects + | 'DIR' // - Directories + | 'FIL' // - Files + | 'TRK' // - Projects + | 'UTS' // - Test Files + +export interface SonarProject { + key: string // unique key name + name: string + qualifier: Qualifiers + visibility: 'private' | 'public' + lastAnalysisDate?: string + revision?: string +} + +const robotPermissions = [ + 'user', + 'codeviewer', + 'admin', + 'scan', +] +const groupPermissions = [ + 'user', + 'codeviewer', + 'issueadmin', + 'securityhotspotadmin', + 'scan', +] + +export async function createDsoRepository(projectSlug: string, internalRepoName: string, sonarProjectKey?: string) { + const sonarProjectName = `${projectSlug}-${internalRepoName}` + if (!sonarProjectKey) + sonarProjectKey = generateProjectKey(projectSlug, internalRepoName) + return createProject(sonarProjectKey, sonarProjectName) +} + +export async function ensureRepositoryConfiguration(projectKey: string, login: string, groupName: string) { + const axiosInstance = getAxiosInstance() + + for (const permission of robotPermissions) { + await axiosInstance({ + url: 'permissions/add_user', + method: 'post', + params: { + projectKey, + permission, + login, + }, + }) + } + for (const permission of groupPermissions) { + await axiosInstance({ + url: 'permissions/add_group', + method: 'post', + params: { + projectKey, + permission, + groupName, + }, + }) + } +} + +export async function createProject(projectKey: string, projectName: string) { + return getAxiosInstance()({ + url: 'projects/create', + method: 'post', + params: { + project: projectKey, + visibility: 'private', + name: projectName, + mainbranch: 'main', + }, + }) +} + +export async function deleteDsoRepository(projectKey: string) { + const axiosInstance = getAxiosInstance() + + await axiosInstance({ + url: 'projects/delete', + method: 'post', + params: { + project: projectKey, + }, + }) +} + +interface SonarProjectResult { + projectSlug: string + repository: string + key: string +} + +function filterProjectsOwning(repos: { key: string }[], projectSlug: string): SonarProjectResult[] { + return repos.reduce((acc, repo) => { + let isOrphan = true + + const sonarKey = repo.key + const keyElements = sonarKey.split('-') + keyElements.pop() + for (let i = keyElements.length - 1; i > 0; i--) { + const project = keyElements.slice(0, i).join('-') + const repository = keyElements.slice(i).join('-') + const keyComputed = generateProjectKey(project, repository) + if (keyComputed === sonarKey) { + if (project === projectSlug) { + acc.push({ + projectSlug, + repository, + key: sonarKey, + }) + } + isOrphan = false + } + } + if (isOrphan) { + logger.warn({ sonarKey, projectSlug }, 'Orphan SonarQube project key does not match expected pattern') + } + return acc + }, [] as SonarProjectResult[]) +} + +export async function findSonarProjectsForDsoProjects(projectSlug: string) { + const axiosInstance = getAxiosInstance() + let foundProjectKeys: SonarProjectResult[] = [] + + let page = 0 + const pageSize = 100 + let total = 0 + do { + page++ + const similarProjects = await axiosInstance.get('projects/search', { + params: { + q: projectSlug, + p: page, + ps: pageSize, + }, + }) + total = similarProjects.data.paging.total + foundProjectKeys = [...foundProjectKeys, ...filterProjectsOwning(similarProjects.data.components, projectSlug)] + } while (page * pageSize < total) + + return foundProjectKeys +} + +export const files = { + 'sonar-project.properties': (key: string) => `sonar.projectKey=${key}\nsonar.qualitygate.wait=true\n`, +} diff --git a/.jjconflict-base-1/plugins/sonarqube/src/tech.ts b/.jjconflict-base-1/plugins/sonarqube/src/tech.ts new file mode 100644 index 0000000000..e820d8d381 --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/src/tech.ts @@ -0,0 +1,39 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +const config: { + url?: string + internalUrl?: string + user?: string + password?: string +} = {} + +export function getConfig(): Required { + config.url = config.url ?? removeTrailingSlash(requiredEnv('SONARQUBE_URL')) + config.internalUrl = config.internalUrl ?? removeTrailingSlash(requiredEnv('SONARQUBE_INTERNAL_URL')) + config.user = config.user ?? requiredEnv('SONAR_API_TOKEN') + // @ts-ignore + return config +} +export function getAxiosOptions() { + return { + baseURL: `${getConfig().internalUrl}/api/`, + auth: { + username: getConfig().user, + password: '', // Token is used, so password is useless + }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } +} + +export function getAxiosInstance() { + return axios.create(getAxiosOptions()) +} + +export interface VaultSonarSecret { + SONAR_USERNAME: string + SONAR_PASSWORD: string + SONAR_TOKEN: string +} diff --git a/.jjconflict-base-1/plugins/sonarqube/src/user.ts b/.jjconflict-base-1/plugins/sonarqube/src/user.ts new file mode 100644 index 0000000000..0dae43617d --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/src/user.ts @@ -0,0 +1,101 @@ +import type { SonarPaging } from './project.js' +import type { VaultSonarSecret } from './tech.js' +// import type { StepCall, ArchiveProjectExecArgs } from '@cpn-console/hooks' +import { generateRandomPassword } from '@cpn-console/hooks' +import { getAxiosInstance } from './tech.js' + +export interface SonarUser { + login: string + name: string + active: boolean + email: string + groups: string[] + tokensCount: number + local: boolean + externalIdentity: string + externalProvider: string + avatar: string + lastConnectionDate: Date + managed: boolean + sonarLintLastConnectionDate: Date +} + +export async function createUser(username: string, projectSlug: string) { + const axiosInstance = getAxiosInstance() + const fakeEmail = `${projectSlug}@${projectSlug}` + + const newPwd = generateRandomPassword(30) + await axiosInstance({ + url: 'users/create', + method: 'post', + params: { + email: fakeEmail, + local: 'true', + login: username, + name: username, + password: newPwd, + }, + }) + return newPwd +} + +export async function changeToken(username: string) { + const axiosInstance = getAxiosInstance() + await axiosInstance({ + url: 'user_tokens/revoke', + method: 'post', + params: { + login: username, + name: `Sonar Token for ${username}`, + }, + }) + const newToken = await axiosInstance({ + url: 'user_tokens/generate', + method: 'post', + params: { + login: username, + name: `Sonar Token for ${username}`, + }, + }) + return newToken.data.token +} + +export async function getUser(username: string): Promise { + const axiosInstance = getAxiosInstance() + let page = 1 + const pageSize = 100 + while (true) { + const response = await axiosInstance({ + url: 'users/search', + params: { + q: username, + ps: pageSize, + p: page, + }, + }) + const users: { paging: SonarPaging, users: SonarUser[] } = response.data + const found = users.users.find(user => user.login === username) + if (found) return found + if (!users.users.length || users.paging.pageIndex * users.paging.pageSize >= users.paging.total) { + break + } + page += 1 + } +} + +export async function ensureUserExists(username: string, projectSlug: string, vaultUserSecret: VaultSonarSecret | undefined): Promise { + const user = await getUser(username) + if (!user) { + return { + SONAR_PASSWORD: await createUser(username, projectSlug), + SONAR_TOKEN: await changeToken(username), + SONAR_USERNAME: username, + } + } else if (!vaultUserSecret) { + return { + SONAR_PASSWORD: 'not initialized', + SONAR_TOKEN: await changeToken(username), + SONAR_USERNAME: username, + } + } +} diff --git a/.jjconflict-base-1/plugins/sonarqube/tsconfig.eslint.json b/.jjconflict-base-1/plugins/sonarqube/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-1/plugins/sonarqube/tsconfig.json b/.jjconflict-base-1/plugins/sonarqube/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-1/plugins/sonarqube/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-1/plugins/vault/.gitignore b/.jjconflict-base-1/plugins/vault/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-base-1/plugins/vault/README.md b/.jjconflict-base-1/plugins/vault/README.md new file mode 100644 index 0000000000..bc7c4d1ec9 --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/README.md @@ -0,0 +1,14 @@ +# Introduction + +Plugin de gestion du plugin Vault + +## Configuration + +| Env var | valeur possible | description | +| --------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| VAULT_TOKEN | chaine | Root token ou token ayant les pleins droits sur le Vault | +| VAULT_URL | *url* | Url public d'accès au Vault | +| VAULT_INTERNAL_URL | *url* ou vide | Url par laquelle la console interroge le service Vault, si absent utilisation de l'url public | +| VAULT__HIDE_PROJECT_SERVICE | "true" ou vide | Décide si le plugin masque ou non le service dans l'interface du projet. Ne désactive pas la fonctionnalité du store projet seulement l'affichage. | +| VAULT__DISABLE_VAULT_SECRETS | "true" ou vide | Désactiver le déploiement des ressources nécessaires au Vault Secret Operator dans le namespace cible | +| VAULT__DEPLOY_VAULT_CONNECTION_IN_NS | "true" ou vide | Activer le déploiement du vaultconnections dans le namespace utilisateur, si vide ou faux, s'assure que la ressource n'existe pas | diff --git a/.jjconflict-base-1/plugins/vault/eslint.config.js b/.jjconflict-base-1/plugins/vault/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-base-1/plugins/vault/package.json b/.jjconflict-base-1/plugins/vault/package.json new file mode 100644 index 0000000000..b8fb478904 --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/package.json @@ -0,0 +1,47 @@ +{ + "name": "@cpn-console/vault-plugin", + "type": "module", + "version": "2.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-base-1/plugins/vault/src/config.ts b/.jjconflict-base-1/plugins/vault/src/config.ts new file mode 100644 index 0000000000..abce2040b0 --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/src/config.ts @@ -0,0 +1,32 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + token: string + projectsRootDir: string + hideProjectService: boolean + disableVaultSecrets: boolean + deployVaultConnectionInNs: boolean + constructor() { + this.token = requiredEnv('VAULT_TOKEN') + this.publicUrl = removeTrailingSlash(requiredEnv('VAULT_URL')) + this.projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') + this.internalUrl = process.env.VAULT_INTERNAL_URL + ? removeTrailingSlash(process.env.VAULT_INTERNAL_URL) + : this.publicUrl + this.hideProjectService = process.env.VAULT__HIDE_PROJECT_SERVICE === 'true' + this.disableVaultSecrets = process.env.VAULT__DISABLE_VAULT_SECRETS === 'true' + this.deployVaultConnectionInNs = process.env.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS === 'true' + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-base-1/plugins/vault/src/functions.ts b/.jjconflict-base-1/plugins/vault/src/functions.ts new file mode 100644 index 0000000000..2e3d98838b --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/src/functions.ts @@ -0,0 +1,112 @@ +import type { + Project, + ProjectLite, + StepCall, + ZoneObject, +} from '@cpn-console/hooks' +import { okStatus } from '@cpn-console/hooks' +import { logger } from './logger.js' + +export const upsertProject: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.Project.upsert() + logger.info({ action: 'upsertProject', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const archiveDsoProject: StepCall = async (payload) => { + try { + if (!payload.apis.vault) throw new Error('no Vault available') + await payload.apis.vault.Project.delete() + const allSecrets = await payload.apis.vault.list('/') + const promisesDestroy = allSecrets.map((path) => { + return payload.apis.vault.destroy(path) + }) + await Promise.all(promisesDestroy) + + logger.info({ action: 'archiveDsoProject', projectSlug: payload.args.slug, secretsDestroyed: allSecrets.length }, 'Hook done') + return { + status: { + result: 'OK', + }, + secretsDestroyed: allSecrets.length, + } + } catch (error) { + logger.error({ action: 'archiveDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const getSecrets: StepCall = async (payload) => { + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + secrets: { + '.spec.mount': `${payload.args.slug}`, + '.spec.vaultAuthRef': 'vault-auth', + }, + } +} +export const upsertZone: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.upsert() + logger.info({ action: 'upsertZone', zoneSlug: payload.args.slug }, 'Hook done') + return okStatus + } catch (error) { + logger.error({ action: 'upsertZone', zoneSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} +export const deleteZone: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.delete() + logger.info({ action: 'deleteZone', zoneSlug: payload.args.slug }, 'Hook done') + return okStatus + } catch (error) { + logger.error({ action: 'deleteZone', zoneSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} diff --git a/.jjconflict-base-1/plugins/vault/src/index.ts b/.jjconflict-base-1/plugins/vault/src/index.ts new file mode 100644 index 0000000000..e73ba4ab91 --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/src/index.ts @@ -0,0 +1,59 @@ +import type { ClusterObject, DefaultArgs, Plugin, Project, ProjectLite, ZoneObject } from '@cpn-console/hooks' +import { archiveDsoProject, deleteZone, getSecrets, upsertProject, upsertZone } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' +import { VaultProjectApi } from './vault-project-api.js' +import { VaultZoneApi } from './vault-zone-api.js' + +const onlyApi = { api: (project: ProjectLite) => new VaultProjectApi(project) } + +export const plugin: Plugin = { + infos, + subscribedHooks: { + getProjectSecrets: { + ...onlyApi, + steps: { + main: getSecrets, + }, + }, + upsertProject: { + ...onlyApi, + steps: { + main: upsertProject, + }, + }, + deleteProject: { + ...onlyApi, + steps: { post: archiveDsoProject }, // Destroy all secrets for project + }, + upsertZone: { + api: (zone: ZoneObject) => new VaultZoneApi(zone.slug), + steps: { + pre: upsertZone, + }, + }, + deleteZone: { + api: (zone: ZoneObject) => new VaultZoneApi(zone.slug), + steps: { + main: deleteZone, + }, + }, + upsertCluster: { + api: (c: ClusterObject) => new VaultZoneApi(c.zone.slug), + }, + deleteCluster: { + api: (c: ClusterObject) => new VaultZoneApi(c.zone.slug), + }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + vault: Args extends (ProjectLite | Project) + ? VaultProjectApi + : Args extends (ZoneObject | ClusterObject) + ? VaultZoneApi + : never + } +} diff --git a/.jjconflict-base-1/plugins/vault/src/infos.ts b/.jjconflict-base-1/plugins/vault/src/infos.ts new file mode 100644 index 0000000000..976f1f4218 --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/src/infos.ts @@ -0,0 +1,14 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import getConfig from './config.js' + +const infos: ServiceInfos = { + name: 'vault', + to: ({ project }) => getConfig().hideProjectService + ? undefined + : `${getConfig().publicUrl}/ui/vault/secrets/${project.slug}`, + title: 'Vault', + imgSrc: '/img/vault.svg', + description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', +} + +export default infos diff --git a/.jjconflict-base-1/plugins/vault/src/logger.ts b/.jjconflict-base-1/plugins/vault/src/logger.ts new file mode 100644 index 0000000000..f596e24a6f --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'vault' }) diff --git a/.jjconflict-base-1/plugins/vault/src/monitor.ts b/.jjconflict-base-1/plugins/vault/src/monitor.ts new file mode 100644 index 0000000000..091b850649 --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/src/monitor.ts @@ -0,0 +1,45 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import getConfig from './config.js' + +const vaultStatusCode = [200, 429, 472, 473, 501, 503] + +type VaultRes = { + initialized: boolean + sealed: boolean + standby: boolean + performance_standby: boolean + replication_performance_mode: string + replication_dr_mode: string + server_time_utc: number + version: string +} | undefined + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(`${getConfig().internalUrl}/v1/sys/health?standbyok=true`, { + headers: { + 'X-Vault-Token': getConfig().token, + }, + validateStatus: res => vaultStatusCode.includes(res), + }) + if (res.status === 200) { + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + } else { + instance.lastStatus.status = MonitorStatus.ERROR + const data = res.data as VaultRes + instance.lastStatus.message = data?.sealed ? 'Le Vault est scellé' : 'Vault en erreur' + } + } catch (error) { + instance.lastStatus.message = 'Error lors la requete' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + // test + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-base-1/plugins/vault/src/utils.ts b/.jjconflict-base-1/plugins/vault/src/utils.ts new file mode 100644 index 0000000000..d7036913f4 --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/src/utils.ts @@ -0,0 +1,21 @@ +import type { AxiosInstance } from 'axios' + +export async function getAuthMethod(axiosInstance: AxiosInstance, token: string) { + const response = await axiosInstance({ + method: 'get', + url: '/v1/sys/auth', + headers: { 'X-Vault-Token': token }, + }) + return response.data +} + +export const minimumConfig = { + type: 'kv', + options: { + version: 2, + }, +} +export function generateKVConfigUpdate(config: Record): typeof minimumConfig | void { + if (config?.type !== minimumConfig.type) return minimumConfig + if (config?.options?.version !== minimumConfig.options.version) return minimumConfig +} diff --git a/.jjconflict-base-1/plugins/vault/src/vault-api.ts b/.jjconflict-base-1/plugins/vault/src/vault-api.ts new file mode 100644 index 0000000000..a09cc7c338 --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/src/vault-api.ts @@ -0,0 +1,165 @@ +import type { AxiosInstance } from 'axios' +import { PluginApi } from '@cpn-console/hooks' +import axios from 'axios' +import getConfig from './config.js' +import { + generateKVConfigUpdate, +} from './utils.js' + +export interface AppRoleCredentials { + url: string + coreKvName: string + roleId: string + secretId: string +} + +export abstract class VaultApi extends PluginApi { + protected readonly axios: AxiosInstance + private token: string | undefined = undefined + + constructor() { + super() + this.axios = axios.create({ + baseURL: getConfig().internalUrl, + headers: { + 'X-Vault-Token': getConfig().token, + }, + }) + } + + protected async getToken() { + if (!this.token) { + this.token = (await this.axios.post('/v1/auth/token/create')).data.auth + .client_token as string + } + return this.token + } + + protected async destroy(path: string, kvName: string) { + if (path.startsWith('/')) path = path.slice(1) + return await this.axios({ + method: 'delete', + url: `/v1/${kvName}/metadata/${path}`, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + } + + Kv = { + upsert: async (kvName: string) => { + const token = await this.getToken() + const kvRes = await this.axios({ + method: 'get', + url: `/v1/sys/mounts/${kvName}/tune`, + headers: { 'X-Vault-Token': token }, + validateStatus: code => [400, 200].includes(code), + }) + if (kvRes.status === 400) { + await this.axios({ + method: 'post', + url: `/v1/sys/mounts/${kvName}`, + headers: { + 'X-Vault-Token': token, + }, + data: { + type: 'kv', + config: { + force_no_cache: true, + }, + options: { + version: 2, + }, + }, + }) + } else { + // means 200 status + const configUpdate = generateKVConfigUpdate(kvRes.data) + if (configUpdate) { + await this.axios({ + method: 'put', + url: `/v1/sys/mounts/${kvName}/tune`, + headers: { + 'X-Vault-Token': token, + }, + data: configUpdate, + }) + } + } + }, + delete: async (kvName: string) => { + const token = await this.getToken() + return await this.axios({ + method: 'delete', + url: `/v1/sys/mounts/${kvName}`, + headers: { 'X-Vault-Token': token }, + validateStatus: code => [400, 200, 204].includes(code), + }) + }, + } + + Policy = { + upsert: async (policyName: string, policy: string) => { + await this.axios({ + method: 'put', + url: `/v1/sys/policies/acl/${policyName}`, + data: { policy }, + headers: { + 'X-Vault-Token': await this.getToken(), + 'Content-Type': 'application/json', + }, + }) + }, + delete: async (policyName: string) => { + await this.axios({ + method: 'delete', + url: `/v1/sys/policies/acl/${policyName}`, + headers: { + 'X-Vault-Token': await this.getToken(), + 'Content-Type': 'application/json', + }, + }) + }, + } + + Role = { + upsert: async (roleName: string, policies: string[]) => { + await this.axios({ + method: 'post', + url: `/v1/auth/approle/role/${roleName}`, + data: { + secret_id_num_uses: '0', + secret_id_ttl: '0', + token_max_ttl: '0', + token_num_uses: '0', + token_ttl: '0', + token_type: 'batch', + token_policies: policies, + }, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + delete: async (roleName: string) => { + await this.axios.delete(`/v1/auth/approle/role/${roleName}`, { + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + getCredentials: async (roleName: string) => { + const { data: dataRole } = await this.axios.get( + `/v1/auth/approle/role/${roleName}/role-id`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + const { data: dataSecret } = await this.axios.put( + `/v1/auth/approle/role/${roleName}/secret-id`, + 'null', // Force empty data + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + return { + roleId: dataRole.data?.role_id, + secretId: dataSecret.data?.secret_id, + } + }, + } +} diff --git a/.jjconflict-base-1/plugins/vault/src/vault-project-api.ts b/.jjconflict-base-1/plugins/vault/src/vault-project-api.ts new file mode 100644 index 0000000000..45ec6280c9 --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/src/vault-project-api.ts @@ -0,0 +1,207 @@ +import type { ProjectLite } from '@cpn-console/hooks' +import getConfig from './config.js' +import { + getAuthMethod, +} from './utils.js' +import { VaultApi } from './vault-api.js' + +interface ReadOptions { + throwIfNoEntry: boolean +} +export interface AppRoleCredentials { + url: string + coreKvName: string + roleId: string + secretId: string +} + +interface VaultValuesWithoutCredentials { + /** Slash-separated directory (root node of all Gitlab projects) */ + projectsRootDir: string +} + +type VaultValues = AppRoleCredentials & VaultValuesWithoutCredentials + +export class VaultProjectApi extends VaultApi { + private readonly basePath: string + private readonly roleName: string + private readonly projectRootDir: string + private readonly defaultAppRoleCredentials: AppRoleCredentials + private readonly coreKvName: string = 'forge-dso' + private readonly projectKvName: string + private readonly groupName: string + private readonly policyName: { + techRO: string + appFull: string + } + + constructor(project: ProjectLite) { + super() + this.basePath = project.slug + this.roleName = project.slug + this.projectRootDir = getConfig().projectsRootDir + this.projectKvName = project.slug + this.groupName = project.slug + this.policyName = { + techRO: `tech--${project.slug}--ro`, + appFull: `app--${project.slug}--admin`, + } + this.defaultAppRoleCredentials = { + url: getConfig().deployVaultConnectionInNs ? getConfig().publicUrl : '', + coreKvName: this.coreKvName, + roleId: 'none', + secretId: 'none', + } + } + + public async list(path: string = '/'): Promise { + if (!path.startsWith('/')) path = `/${path}` + + const listSecretPath: string[] = [] + const response = await this.axios({ + url: `/v1/${this.coreKvName}/metadata/${this.projectRootDir}/${this.basePath}${path}/`, + headers: { + 'X-Vault-Token': await this.getToken(), + }, + method: 'list', + validateStatus: code => [200, 404].includes(code), + }) + + if (response.status === 404) return listSecretPath + for (const key of response.data.data.keys) { + if (key.endsWith('/')) { + const subSecrets = await this.list( + `${path.substring(this.basePath.length)}/${key}`, + ) + subSecrets.forEach((secret) => { + listSecretPath.push(`${key}${secret}`) + }) + } else { + listSecretPath.push(`/${key}`) + } + } + return listSecretPath.flat() + } + + public async read( + path: string = '/', + options: ReadOptions = { throwIfNoEntry: true }, + ) { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.get( + `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + validateStatus: status => + (options.throwIfNoEntry ? [200] : [200, 404]).includes(status), + }, + ) + return response.data.data + } + + public async write(body: object, path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.post( + `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + data: body, + }, + ) + return await response.data + } + + public async destroy(path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + return super.destroy( + `${this.projectRootDir}/${this.basePath}/${path}`, + this.coreKvName, + ) + } + + Project = { + upsert: async () => { + await this.Kv.upsert(this.projectKvName) + await this.Policy.upsert( + this.policyName.appFull, + `path "${this.projectKvName}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + ) + await this.Policy.upsert( + this.policyName.techRO, + `path "${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + ) + await this.Group.upsert() + await this.Role.upsert(this.roleName, [ + this.policyName.techRO, + this.policyName.appFull, + ]) + }, + delete: async () => { + await this.Kv.delete(this.projectKvName) + await this.Policy.delete(this.policyName.appFull) + await this.Policy.delete(this.policyName.techRO) + await this.Group.delete() + await this.Role.delete(this.roleName) + }, + getCredentials: async (): Promise => { + const creds = await this.Role.getCredentials(this.roleName) + return { + ...this.defaultAppRoleCredentials, + ...creds, + } + }, + getValues: async (): Promise => { + return { + projectsRootDir: getConfig().projectsRootDir, + ...(await this.Project.getCredentials()), + } + }, + } + + Group = { + upsert: async () => { + // TODO check api responses for POST and GET maybe duplicates + await this.axios({ + method: 'post', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + data: { + name: this.groupName, + type: 'external', + policies: [this.policyName.appFull], + }, + }) + + const response = await this.axios({ + method: 'get', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + validateStatus: code => [404, 200].includes(code), + }) + const group = response.data + const groupAliasName = `/${this.groupName}` + if (group.data.alias?.name === groupAliasName) { + return + } + const methods = await getAuthMethod(this.axios, await this.getToken()) + + await this.axios({ + url: `/v1/identity/group-alias`, + method: 'post', + headers: { 'X-Vault-Token': await this.getToken() }, + data: { + name: groupAliasName, + mount_accessor: methods['oidc/'].accessor, + canonical_id: group.data.id, + }, + }) + }, + delete: async () => { + await this.axios({ + method: 'delete', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + } +} diff --git a/.jjconflict-base-1/plugins/vault/src/vault-zone-api.ts b/.jjconflict-base-1/plugins/vault/src/vault-zone-api.ts new file mode 100644 index 0000000000..378d393083 --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/src/vault-zone-api.ts @@ -0,0 +1,63 @@ +import getConfig from './config.js' +import { VaultApi } from './vault-api.js' + +interface VaultCredentials { + url: string + kvName: string + roleId: string + secretId: string +} +type VaultValues = VaultCredentials + +export class VaultZoneApi extends VaultApi { + private readonly kvName: string + private readonly policyName: string + private readonly roleName: string + constructor(name: string) { + super() + this.kvName = `zone-${name}` + this.policyName = `tech--${this.kvName}--ro` + this.roleName = `zone-${name}` + } + + public async upsert() { + await this.Kv.upsert(this.kvName) + await this.Policy.upsert( + this.policyName, + `path "${this.kvName}/*" { capabilities = ["read"] }`, + ) + await this.Role.upsert(this.roleName, [this.policyName]) + } + + public async delete() { + await this.Kv.delete(this.kvName) + await this.Policy.delete(this.policyName) + await this.Role.delete(this.roleName) + } + + public async write(body: object, path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.post(`/v1/${this.kvName}/data/${path}`, { + headers: { 'X-Vault-Token': await this.getToken() }, + data: body, + }) + return await response.data + } + + public async destroy(path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + return super.destroy(path, this.kvName) + } + + public async getCredentials(): Promise { + return { + url: getConfig().publicUrl, + kvName: this.kvName, + ...(await this.Role.getCredentials(this.roleName)), + } as VaultCredentials + } + + public async getValues(): Promise { + return this.getCredentials() + } +} diff --git a/.jjconflict-base-1/plugins/vault/src/vso.ts b/.jjconflict-base-1/plugins/vault/src/vso.ts new file mode 100644 index 0000000000..579eb8d8ff --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/src/vso.ts @@ -0,0 +1,55 @@ +import type { AppRoleCredentials } from './vault-project-api.js' + +export function generateVsoVaultConnection(creds: AppRoleCredentials) { + return { + apiVersion: 'secrets.hashicorp.com/v1beta1', + kind: 'VaultConnection', + metadata: { + name: 'default', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + spec: { + address: creds.url, + }, + } +} + +export function generateVsoSecret(creds: AppRoleCredentials) { + return { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: 'vso-approle', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + stringData: { + id: creds.secretId, + }, + } +} +export function generateVaultAuth(creds: AppRoleCredentials, vaultConnectionRef: string | null = null) { + return { + apiVersion: 'secrets.hashicorp.com/v1beta1', + kind: 'VaultAuth', + metadata: { + name: 'vault-auth', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + spec: { + vaultConnectionRef, + method: 'appRole', + mount: 'approle', + appRole: { + roleId: creds.roleId, + secretRef: 'vso-approle', + }, + allowedNamespaces: null, + }, + } +} diff --git a/.jjconflict-base-1/plugins/vault/tsconfig.eslint.json b/.jjconflict-base-1/plugins/vault/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-base-1/plugins/vault/tsconfig.json b/.jjconflict-base-1/plugins/vault/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-base-1/plugins/vault/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-base-1/pnpm-lock.yaml b/.jjconflict-base-1/pnpm-lock.yaml new file mode 100644 index 0000000000..4265e63be7 --- /dev/null +++ b/.jjconflict-base-1/pnpm-lock.yaml @@ -0,0 +1,18500 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + injectWorkspacePackages: true + +catalogs: + build: + '@iconify/types': + specifier: ^2.0.0 + version: 2.0.0 + '@nestjs/cli': + specifier: ^11.0.16 + version: 11.0.16 + '@nestjs/schematics': + specifier: ^11.0.9 + version: 11.0.9 + '@nestjs/testing': + specifier: ^11.1.16 + version: 11.1.16 + '@unocss/transformer-directives': + specifier: ^0.65.4 + version: 0.65.4 + '@vitejs/plugin-vue': + specifier: ^6.0.4 + version: 6.0.4 + '@vue/tsconfig': + specifier: ^0.7.0 + version: 0.7.0 + fastify-plugin: + specifier: ^5.1.0 + version: 5.1.0 + swagger-typescript-api: + specifier: ^13.3.1 + version: 13.3.1 + ts-patch: + specifier: ^3.3.0 + version: 3.3.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-transform-paths: + specifier: ^3.5.6 + version: 3.5.6 + unocss: + specifier: ^66.6.6 + version: 66.6.6 + unplugin-auto-import: + specifier: ^0.18.6 + version: 0.18.6 + unplugin-vue-components: + specifier: ^0.27.5 + version: 0.27.5 + vite: + specifier: ^7.3.2 + version: 7.3.3 + vite-node: + specifier: ^6.0.0 + version: 6.0.0 + vite-plugin-pwa: + specifier: ^1.2.0 + version: 1.2.0 + vue-tsc: + specifier: ^2.2.12 + version: 2.2.12 + workbox-window: + specifier: ^7.4.0 + version: 7.4.0 + otel: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@opentelemetry/auto-instrumentations-node': + specifier: ^0.70.1 + version: 0.70.1 + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0 + '@opentelemetry/exporter-trace-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0 + '@opentelemetry/instrumentation-nestjs-core': + specifier: ^0.58.0 + version: 0.58.0 + '@opentelemetry/instrumentation-pino': + specifier: ^0.59.0 + version: 0.59.0 + '@opentelemetry/sdk-metrics': + specifier: ^2.5.1 + version: 2.6.0 + '@opentelemetry/sdk-node': + specifier: ^0.212.0 + version: 0.212.0 + runtime: + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 + '@fastify/helmet': + specifier: ^13.0.2 + version: 13.0.2 + '@fastify/otel': + specifier: ^0.18.1 + version: 0.18.1 + '@fastify/session': + specifier: ^11.1.1 + version: 11.1.1 + '@fastify/swagger': + specifier: ^9.7.0 + version: 9.7.0 + '@fastify/swagger-ui': + specifier: ^5.2.6 + version: 5.2.6 + '@gitbeaker/core': + specifier: ^40.6.0 + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: ^40.6.0 + version: 40.6.0 + '@gitbeaker/rest': + specifier: ^40.6.0 + version: 40.6.0 + '@gouvfr/dsfr': + specifier: ^1.14.4 + version: 1.14.4 + '@gouvminint/vue-dsfr': + specifier: ^8.15.0 + version: 8.15.0 + '@himenon/argocd-typescript-openapi': + specifier: ^1.2.2 + version: 1.2.2 + '@iconify-json/ri': + specifier: ^1.2.10 + version: 1.2.10 + '@iconify/vue': + specifier: ^4.3.0 + version: 4.3.0 + '@keycloak/keycloak-admin-client': + specifier: ^26.5.5 + version: 26.7.0 + '@kubernetes-models/argo-cd': + specifier: ^2.7.2 + version: 2.7.2 + '@nestjs/cache-manager': + specifier: ^3.1.2 + version: 3.1.3 + '@nestjs/common': + specifier: ^11.1.16 + version: 11.1.16 + '@nestjs/config': + specifier: ^4.0.3 + version: 4.0.3 + '@nestjs/core': + specifier: ^11.1.16 + version: 11.1.16 + '@nestjs/event-emitter': + specifier: ^3.0.1 + version: 3.0.1 + '@nestjs/jwt': + specifier: ^11.0.2 + version: 11.0.2 + '@nestjs/platform-fastify': + specifier: ^11.1.16 + version: 11.1.27 + '@nestjs/schedule': + specifier: ^6.1.1 + version: 6.1.1 + '@nestjs/swagger': + specifier: ^11.4.1 + version: 11.4.1 + '@nestjs/terminus': + specifier: ^11.1.1 + version: 11.1.1 + '@prisma/client': + specifier: ^6.19.2 + version: 6.19.2 + '@ts-rest/core': + specifier: ^3.52.1 + version: 3.52.1 + '@ts-rest/fastify': + specifier: ^3.52.1 + version: 3.52.1 + '@ts-rest/open-api': + specifier: ^3.52.1 + version: 3.52.1 + axios: + specifier: ^1.15.1 + version: 1.16.0 + bytes: + specifier: ^3.1.2 + version: 3.1.2 + cache-manager: + specifier: ^7.2.8 + version: 7.2.8 + cron-validator: + specifier: ^1.4.0 + version: 1.4.0 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + fastify: + specifier: ^5.8.5 + version: 5.8.5 + fastify-keycloak-adapter: + specifier: ^3.0.0 + version: 3.0.2 + javascript-time-ago: + specifier: ^2.6.4 + version: 2.6.4 + json-2-csv: + specifier: ^5.5.10 + version: 5.5.10 + json-schema: + specifier: ^0.4.0 + version: 0.4.0 + jszip: + specifier: ^3.10.1 + version: 3.10.1 + keycloak-js: + specifier: ^26.2.3 + version: 26.2.3 + mustache: + specifier: ^4.2.0 + version: 4.2.0 + nanoid: + specifier: 5.0.9 + version: 5.0.9 + nestjs-pino: + specifier: ^4.6.0 + version: 4.6.0 + p-debounce: + specifier: ^4.0.0 + version: 4.0.0 + pinia: + specifier: ^2.3.1 + version: 2.3.1 + prisma: + specifier: ^6.19.2 + version: 6.19.2 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + short-uuid: + specifier: ^5.2.0 + version: 5.2.0 + undici: + specifier: ^7.24.0 + version: 7.24.5 + vitest-mock-extended: + specifier: ^2.0.2 + version: 2.0.2 + vue: + specifier: ^3.5.30 + version: 3.5.30 + vue-router: + specifier: ^4.6.4 + version: 4.6.4 + vue3-json-viewer: + specifier: ^2.4.1 + version: 2.4.1 + xbytes: + specifier: ^1.9.1 + version: 1.9.1 + yaml: + specifier: ^2.7.1 + version: 2.9.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 + zod-validation-error: + specifier: ^3.5.4 + version: 3.5.4 + test: + '@faker-js/faker': + specifier: ^9.9.0 + version: 9.9.0 + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 + '@vitest/coverage-v8': + specifier: ^4.1.5 + version: 4.1.5 + globals: + specifier: ^16.5.0 + version: 16.5.0 + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + msw: + specifier: ^2.12.10 + version: 2.12.10 + vitest: + specifier: ^4.1.5 + version: 4.1.5 + tools: + '@antfu/eslint-config': + specifier: ^8.2.0 + version: 8.2.0 + '@commitlint/cli': + specifier: ^20.5.3 + version: 20.5.3 + '@commitlint/config-conventional': + specifier: ^20.5.3 + version: 20.5.3 + '@eslint/eslintrc': + specifier: ^3.3.5 + version: 3.3.5 + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 + '@vue/eslint-config-typescript': + specifier: ^14.7.0 + version: 14.7.0 + chalk: + specifier: ^5.6.2 + version: 5.6.2 + eslint: + specifier: ^10.5.0 + version: 10.5.0 + eslint-plugin-vue: + specifier: ^10.8.0 + version: 10.8.0 + husky: + specifier: ^9.1.7 + version: 9.1.7 + js-yaml: + specifier: ^4.1.0 + version: 4.2.0 + lint-staged: + specifier: ^16.4.0 + version: 16.4.0 + nodemon: + specifier: ^3.1.14 + version: 3.1.14 + pino: + specifier: ^9.14.0 + version: 9.14.0 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + rimraf: + specifier: ^6.1.3 + version: 6.1.3 + stylelint: + specifier: ^17.11.0 + version: 17.11.0 + stylelint-config-html: + specifier: ^1.1.0 + version: 1.1.0 + stylelint-config-recommended-vue: + specifier: ^1.6.1 + version: 1.6.1 + stylelint-config-standard: + specifier: ^40.0.0 + version: 40.0.0 + typescript-eslint: + specifier: ^8.57.0 + version: 8.57.0 + vue-eslint-parser: + specifier: ^10.4.0 + version: 10.4.0 + types: + '@types/jsdom': + specifier: ^21.1.7 + version: 21.1.7 + '@types/json-schema': + specifier: ^7.0.15 + version: 7.0.15 + '@types/node': + specifier: ^24.12.0 + version: 24.12.0 + undici-types: + specifier: ^7.16.0 + version: 7.16.0 + +importers: + + .: + devDependencies: + '@commitlint/cli': + specifier: catalog:tools + version: 20.5.3(@types/node@24.12.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3) + '@commitlint/config-conventional': + specifier: catalog:tools + version: 20.5.3 + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:packages/eslintconfig + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + husky: + specifier: catalog:tools + version: 9.1.7 + lint-staged: + specifier: catalog:tools + version: 16.4.0 + + apps/client: + dependencies: + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@gouvfr/dsfr': + specifier: catalog:runtime + version: 1.14.4 + '@gouvminint/vue-dsfr': + specifier: catalog:runtime + version: 8.15.0(@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3)))(vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)) + '@iconify-json/ri': + specifier: catalog:runtime + version: 1.2.10 + '@iconify/vue': + specifier: catalog:runtime + version: 4.3.0(vue@3.5.30(typescript@5.9.3)) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@vue/tsconfig': + specifier: catalog:build + version: 0.7.0(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + axios: + specifier: catalog:runtime + version: 1.16.0 + javascript-time-ago: + specifier: catalog:runtime + version: 2.6.4 + jszip: + specifier: catalog:runtime + version: 3.10.1 + keycloak-js: + specifier: catalog:runtime + version: 26.2.3 + nanoid: + specifier: catalog:runtime + version: 5.0.9 + p-debounce: + specifier: catalog:runtime + version: 4.0.0 + pinia: + specifier: catalog:runtime + version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + vue: + specifier: catalog:runtime + version: 3.5.30(typescript@5.9.3) + vue-router: + specifier: catalog:runtime + version: 4.6.4(vue@3.5.30(typescript@5.9.3)) + vue3-json-viewer: + specifier: catalog:runtime + version: 2.4.1(vue@3.5.30(typescript@5.9.3)) + xbytes: + specifier: catalog:runtime + version: 1.9.1 + yaml: + specifier: catalog:runtime + version: 2.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@iconify/types': + specifier: catalog:build + version: 2.0.0 + '@types/jsdom': + specifier: catalog:types + version: 21.1.7 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@unocss/transformer-directives': + specifier: catalog:build + version: 0.65.4 + '@vitejs/plugin-vue': + specifier: catalog:build + version: 6.0.4(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3)) + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + '@vue/eslint-config-typescript': + specifier: catalog:tools + version: 14.7.0(eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + chalk: + specifier: catalog:tools + version: 5.6.2 + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + eslint-plugin-vue: + specifier: catalog:tools + version: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + jsdom: + specifier: catalog:test + version: 25.0.1 + rimraf: + specifier: catalog:tools + version: 6.1.3 + stylelint: + specifier: catalog:tools + version: 17.11.0(typescript@5.9.3) + stylelint-config-html: + specifier: catalog:tools + version: 1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-recommended-vue: + specifier: catalog:tools + version: 1.6.1(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-standard: + specifier: catalog:tools + version: 40.0.0(stylelint@17.11.0(typescript@5.9.3)) + typescript: + specifier: catalog:build + version: 5.9.3 + unocss: + specifier: catalog:build + version: 66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + unplugin-auto-import: + specifier: catalog:build + version: 0.18.6(rollup@2.80.0) + unplugin-vue-components: + specifier: catalog:build + version: 0.27.5(@babel/parser@7.29.7)(rollup@2.80.0)(vue@3.5.30(typescript@5.9.3)) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vite-plugin-pwa: + specifier: catalog:build + version: 1.2.0(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + vue-eslint-parser: + specifier: catalog:tools + version: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + vue-tsc: + specifier: catalog:build + version: 2.2.12(typescript@5.9.3) + workbox-window: + specifier: catalog:build + version: 7.4.0 + + apps/server: + dependencies: + '@cpn-console/argocd-plugin': + specifier: workspace:^ + version: link:../../plugins/argocd + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../../plugins/gitlab + '@cpn-console/harbor-plugin': + specifier: workspace:^ + version: link:../../plugins/harbor + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../../plugins/keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/nexus-plugin': + specifier: workspace:^ + version: link:../../plugins/nexus + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/sonarqube-plugin': + specifier: workspace:^ + version: link:../../plugins/sonarqube + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../../plugins/vault + '@fastify/cookie': + specifier: catalog:runtime + version: 11.0.2 + '@fastify/helmet': + specifier: catalog:runtime + version: 13.0.2 + '@fastify/otel': + specifier: catalog:runtime + version: 0.18.1(@opentelemetry/api@1.9.1) + '@fastify/session': + specifier: catalog:runtime + version: 11.1.1 + '@fastify/swagger': + specifier: catalog:runtime + version: 9.7.0 + '@fastify/swagger-ui': + specifier: catalog:runtime + version: 5.2.6 + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + '@kubernetes-models/argo-cd': + specifier: catalog:runtime + version: 2.7.2 + '@opentelemetry/auto-instrumentations-node': + specifier: catalog:otel + version: 0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': + specifier: catalog:otel + version: 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': + specifier: catalog:otel + version: 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: catalog:otel + version: 0.212.0(@opentelemetry/api@1.9.1) + '@prisma/client': + specifier: catalog:runtime + version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@ts-rest/fastify': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76) + '@ts-rest/open-api': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76) + axios: + specifier: catalog:runtime + version: 1.16.0 + date-fns: + specifier: catalog:runtime + version: 4.1.0 + fastify: + specifier: catalog:runtime + version: 5.8.5 + fastify-keycloak-adapter: + specifier: catalog:runtime + version: 3.0.2 + json-2-csv: + specifier: catalog:runtime + version: 5.5.10 + mustache: + specifier: catalog:runtime + version: 4.2.0 + prisma: + specifier: catalog:runtime + version: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + undici: + specifier: catalog:runtime + version: 7.24.5 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + fastify-plugin: + specifier: catalog:build + version: 5.1.0 + nodemon: + specifier: catalog:tools + version: 3.1.14 + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + rimraf: + specifier: catalog:tools + version: 6.1.3 + ts-patch: + specifier: catalog:build + version: 3.3.0 + typescript: + specifier: catalog:build + version: 5.9.3 + typescript-transform-paths: + specifier: catalog:build + version: 3.5.6(typescript@5.9.3) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + apps/server-nestjs: + dependencies: + '@cpn-console/argocd-plugin': + specifier: workspace:^ + version: link:../../plugins/argocd + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../../plugins/gitlab + '@cpn-console/harbor-plugin': + specifier: workspace:^ + version: link:../../plugins/harbor + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../../plugins/keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/nexus-plugin': + specifier: workspace:^ + version: link:../../plugins/nexus + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/sonarqube-plugin': + specifier: workspace:^ + version: link:../../plugins/sonarqube + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../../plugins/vault + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + '@keycloak/keycloak-admin-client': + specifier: catalog:runtime + version: 26.7.0 + '@kubernetes-models/argo-cd': + specifier: catalog:runtime + version: 2.7.2 + '@nestjs/cache-manager': + specifier: catalog:runtime + version: 3.1.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2) + '@nestjs/common': + specifier: catalog:runtime + version: 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: catalog:runtime + version: 4.0.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: catalog:runtime + version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter': + specifier: catalog:runtime + version: 3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/jwt': + specifier: catalog:runtime + version: 11.0.2(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/platform-fastify': + specifier: catalog:runtime + version: 11.1.27(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/schedule': + specifier: catalog:runtime + version: 6.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/swagger': + specifier: catalog:runtime + version: 11.4.1(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2) + '@nestjs/terminus': + specifier: catalog:runtime + version: 11.1.1(@grpc/grpc-js@1.14.3)(@grpc/proto-loader@0.8.0)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@opentelemetry/api': + specifier: catalog:otel + version: 1.9.1 + '@opentelemetry/auto-instrumentations-node': + specifier: catalog:otel + version: 0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': + specifier: catalog:otel + version: 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': + specifier: catalog:otel + version: 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: catalog:otel + version: 0.212.0(@opentelemetry/api@1.9.1) + '@prisma/client': + specifier: catalog:runtime + version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@ts-rest/fastify': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76) + '@ts-rest/open-api': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76) + cache-manager: + specifier: catalog:runtime + version: 7.2.8 + date-fns: + specifier: catalog:runtime + version: 4.1.0 + fastify: + specifier: catalog:runtime + version: 5.8.5 + fastify-keycloak-adapter: + specifier: catalog:runtime + version: 3.0.2 + json-2-csv: + specifier: catalog:runtime + version: 5.5.10 + mustache: + specifier: catalog:runtime + version: 4.2.0 + nestjs-pino: + specifier: catalog:runtime + version: 4.6.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) + prisma: + specifier: catalog:runtime + version: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + reflect-metadata: + specifier: catalog:runtime + version: 0.2.2 + rxjs: + specifier: catalog:runtime + version: 7.8.2 + undici: + specifier: catalog:runtime + version: 7.24.5 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + yaml: + specifier: catalog:runtime + version: 2.9.0 + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@eslint/eslintrc': + specifier: catalog:tools + version: 3.3.5 + '@eslint/js': + specifier: catalog:tools + version: 9.39.4 + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@nestjs/cli': + specifier: catalog:build + version: 11.0.16(@types/node@24.12.0)(esbuild@0.27.3)(lightningcss@1.32.0) + '@nestjs/schematics': + specifier: catalog:build + version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) + '@nestjs/testing': + specifier: catalog:build + version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16) + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + globals: + specifier: catalog:test + version: 16.5.0 + msw: + specifier: catalog:test + version: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + rimraf: + specifier: catalog:tools + version: 6.1.3 + ts-patch: + specifier: catalog:build + version: 3.3.0 + typescript: + specifier: catalog:build + version: 5.9.3 + typescript-eslint: + specifier: catalog:tools + version: 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + packages/eslintconfig: + devDependencies: + '@antfu/eslint-config': + specifier: catalog:tools + version: 8.2.0(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5) + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + + packages/hooks: + dependencies: + '@cpn-console/logger': + specifier: workspace:^ + version: link:../logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../shared + json-schema: + specifier: catalog:runtime + version: 0.4.0 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/json-schema': + specifier: catalog:types + version: 7.0.15 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + undici-types: + specifier: catalog:types + version: 7.16.0 + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + + packages/logger: + dependencies: + pino: + specifier: catalog:tools + version: 9.14.0 + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + + packages/shared: + dependencies: + '@cpn-console/logger': + specifier: workspace:* + version: link:../logger + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + short-uuid: + specifier: catalog:runtime + version: 5.2.0 + zod: + specifier: catalog:runtime + version: 3.25.76 + zod-validation-error: + specifier: catalog:runtime + version: 3.5.4(zod@3.25.76) + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + packages/test-utils: + dependencies: + '@cpn-console/shared': + specifier: workspace:^ + version: link:../shared + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + + packages/tsconfig: {} + + playwright: + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@playwright/test': + specifier: catalog:test + version: 1.59.1 + '@types/node': + specifier: catalog:types + version: 24.12.0 + + plugins/argocd: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@himenon/argocd-typescript-openapi': + specifier: catalog:runtime + version: 1.2.2 + axios: + specifier: catalog:runtime + version: 1.16.0 + yaml: + specifier: catalog:runtime + version: 2.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/gitlab: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/harbor: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + bytes: + specifier: catalog:runtime + version: 3.1.2 + cron-validator: + specifier: catalog:runtime + version: 1.4.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + js-yaml: + specifier: catalog:tools + version: 4.2.0 + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + swagger-typescript-api: + specifier: catalog:build + version: 13.3.1(magicast@0.5.3) + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/keycloak: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@keycloak/keycloak-admin-client': + specifier: catalog:runtime + version: 26.7.0 + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/nexus: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/sonarqube: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/vault: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + +packages: + + '@anatine/zod-openapi@1.14.2': + resolution: {integrity: sha512-q0qHfnuNYVKu0Swrnnvfj9971AEyW7c8v9jCOZGCl5ZbyGMNG4RPyJkRcMi/JC8CRfdOe0IDfNm1nNsi2avprg==} + peerDependencies: + openapi3-ts: ^2.0.0 || ^3.0.0 + zod: ^3.20.0 + + '@angular-devkit/core@19.2.17': + resolution: {integrity: sha512-Ah008x2RJkd0F+NLKqIpA34/vUGwjlprRCkvddjDopAWRzYn6xCkz1Tqwuhn0nR1Dy47wTLKYD999TYl5ONOAQ==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/core@19.2.19': + resolution: {integrity: sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/schematics-cli@19.2.19': + resolution: {integrity: sha512-7q9UY6HK6sccL9F3cqGRUwKhM7b/XfD2YcVaZ2WD7VMaRlRm85v6mRjSrfKIAwxcQU0UK27kMc79NIIqaHjzxA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + hasBin: true + + '@angular-devkit/schematics@19.2.17': + resolution: {integrity: sha512-ADfbaBsrG8mBF6Mfs+crKA/2ykB8AJI50Cv9tKmZfwcUcyAdmTr+vVvhsBCfvUAEokigSsgqgpYxfkJVxhJYeg==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@angular-devkit/schematics@19.2.19': + resolution: {integrity: sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@antfu/eslint-config@8.2.0': + resolution: {integrity: sha512-spfwYXMNrlkl69riTSBnbC0C2K8EVfVMOK3ceP2EpAAioyfprIW1gTwyLRtd9jZSFeNdX4mFNAIG+o0sOneOfA==} + hasBin: true + peerDependencies: + '@angular-eslint/eslint-plugin': ^21.1.0 + '@angular-eslint/eslint-plugin-template': ^21.1.0 + '@angular-eslint/template-parser': ^21.1.0 + '@eslint-react/eslint-plugin': ^3.0.0 + '@next/eslint-plugin-next': '>=15.0.0' + '@prettier/plugin-xml': ^3.4.1 + '@unocss/eslint-plugin': '>=0.50.0' + astro-eslint-parser: ^1.0.2 + eslint: ^9.10.0 || ^10.0.0 + eslint-plugin-astro: ^1.2.0 + eslint-plugin-format: '>=0.1.0' + eslint-plugin-jsx-a11y: '>=6.10.2' + eslint-plugin-react-refresh: ^0.5.0 + eslint-plugin-solid: ^0.14.3 + eslint-plugin-svelte: '>=2.35.1' + eslint-plugin-vuejs-accessibility: ^2.4.1 + prettier-plugin-astro: ^0.14.0 + prettier-plugin-slidev: ^1.0.5 + svelte-eslint-parser: '>=0.37.0' + peerDependenciesMeta: + '@angular-eslint/eslint-plugin': + optional: true + '@angular-eslint/eslint-plugin-template': + optional: true + '@angular-eslint/template-parser': + optional: true + '@eslint-react/eslint-plugin': + optional: true + '@next/eslint-plugin-next': + optional: true + '@prettier/plugin-xml': + optional: true + '@unocss/eslint-plugin': + optional: true + astro-eslint-parser: + optional: true + eslint-plugin-astro: + optional: true + eslint-plugin-format: + optional: true + eslint-plugin-jsx-a11y: + optional: true + eslint-plugin-react-refresh: + optional: true + eslint-plugin-solid: + optional: true + eslint-plugin-svelte: + optional: true + eslint-plugin-vuejs-accessibility: + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-slidev: + optional: true + svelte-eslint-parser: + optional: true + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@antfu/utils@0.7.10': + resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + + '@apideck/better-ajv-errors@0.3.7': + resolution: {integrity: sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==} + engines: {node: '>=10'} + peerDependencies: + ajv: '>=8' + + '@apidevtools/json-schema-ref-parser@14.0.1': + resolution: {integrity: sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==} + engines: {node: '>= 16'} + + '@apidevtools/openapi-schemas@2.1.0': + resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} + engines: {node: '>=10'} + + '@apidevtools/swagger-methods@3.0.2': + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + + '@apidevtools/swagger-parser@12.1.0': + resolution: {integrity: sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==} + peerDependencies: + openapi-types: '>=7' + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.29.7': + resolution: {integrity: sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.29.7': + resolution: {integrity: sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.29.7': + resolution: {integrity: sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7': + resolution: {integrity: sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7': + resolution: {integrity: sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7': + resolution: {integrity: sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7': + resolution: {integrity: sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7': + resolution: {integrity: sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7': + resolution: {integrity: sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.29.7': + resolution: {integrity: sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-arrow-functions@7.29.7': + resolution: {integrity: sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.7': + resolution: {integrity: sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.29.7': + resolution: {integrity: sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.29.7': + resolution: {integrity: sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.29.7': + resolution: {integrity: sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.29.7': + resolution: {integrity: sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.29.7': + resolution: {integrity: sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.29.7': + resolution: {integrity: sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.29.7': + resolution: {integrity: sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.29.7': + resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.29.7': + resolution: {integrity: sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.29.7': + resolution: {integrity: sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-dynamic-import@7.29.7': + resolution: {integrity: sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.29.7': + resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.29.7': + resolution: {integrity: sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.29.7': + resolution: {integrity: sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.29.7': + resolution: {integrity: sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.29.7': + resolution: {integrity: sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.29.7': + resolution: {integrity: sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.29.7': + resolution: {integrity: sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7': + resolution: {integrity: sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.29.7': + resolution: {integrity: sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.29.7': + resolution: {integrity: sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@7.29.7': + resolution: {integrity: sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-umd@7.29.7': + resolution: {integrity: sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.29.7': + resolution: {integrity: sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7': + resolution: {integrity: sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.29.7': + resolution: {integrity: sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.29.7': + resolution: {integrity: sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.29.7': + resolution: {integrity: sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.29.7': + resolution: {integrity: sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.29.7': + resolution: {integrity: sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.29.7': + resolution: {integrity: sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.29.7': + resolution: {integrity: sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.29.7': + resolution: {integrity: sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.29.7': + resolution: {integrity: sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.7': + resolution: {integrity: sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regexp-modifiers@7.29.7': + resolution: {integrity: sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-reserved-words@7.29.7': + resolution: {integrity: sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.29.7': + resolution: {integrity: sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.29.7': + resolution: {integrity: sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.29.7': + resolution: {integrity: sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.29.7': + resolution: {integrity: sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.29.7': + resolution: {integrity: sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.29.7': + resolution: {integrity: sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.29.7': + resolution: {integrity: sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.29.7': + resolution: {integrity: sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7': + resolution: {integrity: sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.29.7': + resolution: {integrity: sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@biomejs/js-api@4.0.0': + resolution: {integrity: sha512-EOArR/6drRzM1/hwOIz1pZw90FL31Ud4Y7hEHGWVtMNmAwS9SrwZ8hMENGlLVXCeGW/kL46p8kX7eO6x9Nmezg==} + peerDependencies: + '@biomejs/wasm-bundler': ^2.3.0 + '@biomejs/wasm-nodejs': ^2.3.0 + '@biomejs/wasm-web': ^2.3.0 + peerDependenciesMeta: + '@biomejs/wasm-bundler': + optional: true + '@biomejs/wasm-nodejs': + optional: true + '@biomejs/wasm-web': + optional: true + + '@biomejs/wasm-nodejs@2.4.6': + resolution: {integrity: sha512-YRhedzOovXDMs+ZKzi/ZcDTefZIMcrp3z0Ruq+abrNb622aYUh/2m1Ooj8BcG4anGUGR6o+dTCbvAEx9vc74hw==} + + '@borewit/text-codec@0.2.1': + resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + + '@cacheable/memory@2.0.8': + resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} + + '@cacheable/utils@2.4.0': + resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==} + + '@clack/core@1.3.0': + resolution: {integrity: sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.3.0': + resolution: {integrity: sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw==} + engines: {node: '>= 20.12.0'} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@commitlint/cli@20.5.3': + resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@20.5.3': + resolution: {integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@20.5.0': + resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} + engines: {node: '>=v18'} + + '@commitlint/ensure@20.5.3': + resolution: {integrity: sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@20.0.0': + resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} + engines: {node: '>=v18'} + + '@commitlint/format@20.5.0': + resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@20.5.0': + resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} + engines: {node: '>=v18'} + + '@commitlint/lint@20.5.3': + resolution: {integrity: sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==} + engines: {node: '>=v18'} + + '@commitlint/load@20.5.3': + resolution: {integrity: sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==} + engines: {node: '>=v18'} + + '@commitlint/message@20.4.3': + resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} + engines: {node: '>=v18'} + + '@commitlint/parse@20.5.0': + resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} + engines: {node: '>=v18'} + + '@commitlint/read@20.5.0': + resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@20.5.3': + resolution: {integrity: sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==} + engines: {node: '>=v18'} + + '@commitlint/rules@20.5.3': + resolution: {integrity: sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@20.0.0': + resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} + engines: {node: '>=v18'} + + '@commitlint/top-level@20.4.3': + resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} + engines: {node: '>=v18'} + + '@commitlint/types@20.5.0': + resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} + engines: {node: '>=v18'} + + '@conventional-changelog/git-client@2.7.0': + resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} + engines: {node: '>=18'} + peerDependencies: + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.4.0 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@csstools/media-query-list-parser@5.0.0': + resolution: {integrity: sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/selector-resolve-nested@4.0.0': + resolution: {integrity: sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@csstools/selector-specificity@6.0.0': + resolution: {integrity: sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@e18e/eslint-plugin@0.3.0': + resolution: {integrity: sha512-hHgfpxsrZ2UYHcicA+tGZnmk19uJTaye9VH79O+XS8R4ona2Hx3xjhXghclNW58uXMk3xXlbYEOMr8thsoBmWg==} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + oxlint: ^1.55.0 + peerDependenciesMeta: + eslint: + optional: true + oxlint: + optional: true + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@es-joy/jsdoccomment@0.84.0': + resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@es-joy/jsdoccomment@0.86.0': + resolution: {integrity: sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-plugin-eslint-comments@4.7.1': + resolution: {integrity: sha512-Ql2nJFwA8wUGpILYGOQaT1glPsmvEwE0d+a+l7AALLzQvInqdbXJdx7aSu0DpUX9dB1wMVBMhm99/++S3MdEtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/compat@2.0.3': + resolution: {integrity: sha512-SjIJhGigp8hmd1YGIBwh7Ovri7Kisl42GYFjrOyHhtfYGGoLW6teYi/5p8W50KSsawUPpuLOSmsq1bD0NGQLBw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^8.40 || 9 || 10 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/markdown@8.0.1': + resolution: {integrity: sha512-WWKmld/EyNdEB8GMq7JMPX1SDWgyJAM1uhtCi5ySrqYQM4HQjmg11EX/q3ZpnpRXHfdccFtli3NBvvGaYjWyQw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exodus/schemasafe@1.3.0': + resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} + + '@faker-js/faker@9.9.0': + resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/formbody@8.0.2': + resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/helmet@13.0.2': + resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==} + + '@fastify/jwt@9.1.0': + resolution: {integrity: sha512-CiGHCnS5cPMdb004c70sUWhQTfzrJHAeTywt7nVw6dAiI0z1o4WRvU94xfijhkaId4bIxTCOjFgn4sU+Gvk43w==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/otel@0.18.1': + resolution: {integrity: sha512-7TYQXrOBRwCuTiwQm/2qCPO37af011934clxBj6F7KyF9a2a9MB+aSrWv8vMVp5N8rZC1AQ4pUp8uDs5Z40UPQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/session@11.1.1': + resolution: {integrity: sha512-nuKwTHxh3eJsI4NJeXoYVGzXUsg+kH1WfHgS7IofVyVhmjc+A6qGr+29WQy8hYZiNtmCjfG415COpf5xTBkW4Q==} + + '@fastify/static@9.1.3': + resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} + + '@fastify/swagger-ui@5.2.6': + resolution: {integrity: sha512-OMnms0O5s9wb6wis/K5nlrAMLsgUbr1GA8uphM41IasWe3AFdgxz6r/3bA9HTxlDNUYc2FGGKeqMp3ntxmSiNA==} + + '@fastify/swagger@9.7.0': + resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} + + '@gitbeaker/core@40.6.0': + resolution: {integrity: sha512-tVVm8ZPrS9YCHEcuPV8vD1IcEf9POpdygWo+kPvkK7LcC36EERVcXagb8snEaGgGLfUaVQh8qP4iDZgPnP3YBQ==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/requester-utils@40.6.0': + resolution: {integrity: sha512-DQu2l3iXtB+8e1Ye2ekeUHABt4mGMRTLtuVWtFqf74sqJnerHNOxVOjPn19qu/nKdvKR3ZLwSRTtPzEsxgcShg==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/rest@40.6.0': + resolution: {integrity: sha512-sAwYJclU3NlB/gdxqhH6Hnmy5LWzvW7D3W33eShQEnxMhM0VjnFHPHcgJLQCIux3hMiub1uGtTw1hBJTxDc2mQ==} + engines: {node: '>=18.20.0'} + + '@gouvfr/dsfr@1.14.4': + resolution: {integrity: sha512-Ca17apmTyAF2tMFwMcLwQN7fTlbfMFeyYSLUIUmjoSg1XVF4vDDkbvvKBpWJWaoTLDSFZBbvvHzcDyxw9wSkLA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@gouvminint/vue-dsfr@8.15.0': + resolution: {integrity: sha512-e9q7xJeWpGLLFmbe8YCeZ3bgsBM4LWMMiRVmde5DeAfILXewPh3uFKGMphWVKVKnz3tnxrgYM0GoGOn3I9FoYQ==} + engines: {node: '>=22.0.0', pnpm: '>=10.0.0'} + hasBin: true + peerDependencies: + '@iconify/vue': ^4.2.0 + vue: ^3.5.27 + vue-router: ^4.6.4 + + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + + '@himenon/argocd-typescript-openapi@1.2.2': + resolution: {integrity: sha512-xS8HAzhRSQXqMfD779EgbZmPcgkaNW8Hf2anrwvvPYb9Tt0vP9CtzOqj+8GYSOUQqgcRFejmCR9WoAgZJamB1g==} + engines: {pnpm: '>=8'} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@iconify-json/ri@1.2.10': + resolution: {integrity: sha512-WWMhoncVVM+Xmu9T5fgu2lhYRrKTEWhKk3Com0KiM111EeEsRLiASjpsFKnC/SrB6covhUp95r2mH8tGxhgd5Q==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + + '@iconify/vue@4.3.0': + resolution: {integrity: sha512-Xq0h6zMrHBbrW8jXJ9fISi+x8oDQllg5hTDkDuxnWiskJ63rpJu9CvJshj8VniHVTbsxCg9fVoPAaNp3RQI5OQ==} + peerDependencies: + vue: '>=3' + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.3.2': + resolution: {integrity: sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@keycloak/keycloak-admin-client@26.7.0': + resolution: {integrity: sha512-QEsX19sFCvknBzV2zelEkZ7waglCqzayY1zDtoRQinHuUiAe2f7q+xXT6R8XNukn+rShB41TOlG4QR2Bt6hxhw==} + engines: {node: '>=18'} + + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@kubernetes-models/apimachinery@2.2.0': + resolution: {integrity: sha512-7Yj3aMz/nCTdr221wHOMsw5vAMJnLqANMI3BTuCmYjbP+7/tGAaOsiFwT75F4TJH45Dk1P6wcAEqDeCD1ur82Q==} + engines: {node: '>=14'} + + '@kubernetes-models/argo-cd@2.7.2': + resolution: {integrity: sha512-Dge+7Uq54dt5c3pzusOw8i26FHWqJiyiFqHKQKHUNjOq2zb3e7LY2OYBrNP7hDSoM0Ap5K9cJTMgNzsaaKdjOQ==} + engines: {node: '>=14'} + + '@kubernetes-models/base@5.0.1': + resolution: {integrity: sha512-Yfzmh965y/hT8qs9eE+Ms9sO1iT31c/sPoJerBFRGS9KechXXfxHgeqtpOzq+eByQ1INblkcZ09rt2V0FxG0lA==} + engines: {node: '>=14'} + + '@kubernetes-models/validate@4.0.0': + resolution: {integrity: sha512-rsfF5t3sd5c+3ejUgcy8q0cVG2/BxT064L4Xz+CuKEe014u8T4MtFZiWjWZFpfMXSGKzFYEA6DJYm9CqjmIfZg==} + engines: {node: '>=14'} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@microsoft/kiota-abstractions@1.0.0-preview.103': + resolution: {integrity: sha512-om7nKH8nWyEAQ0lgbIdKdrkmznhkdDJdliCnAIo31X/hlUah1aBzvfUx+pbyp67Q040yLK+2J6P7AnvGSoN/qQ==} + + '@microsoft/kiota-http-fetchlibrary@1.0.0-preview.103': + resolution: {integrity: sha512-DbhGL+pdVjtv2AVvxB4fxvNC/GMzKbYyeQsknaXJXwqnPR/oIClWjaXlIxWL5SBK72DOmrZ18leaWMB2vfWvpw==} + + '@microsoft/kiota-serialization-form@1.0.0-preview.103': + resolution: {integrity: sha512-AD5Q59Qu61DfhaLLN7Mvgx4Zw0Zmftqx04XAbrRreC+r9hwT1BqTf0EReCraMzEoC4sdrp/8haI2Arm8dWUWuQ==} + + '@microsoft/kiota-serialization-json@1.0.0-preview.103': + resolution: {integrity: sha512-dTr8q6H42TSe+HaCPBXU4Pv6ypo7MjMGRo/r9GRbnCuy8VCWfe+Vh38rnAZJx4r8cmvOUD7Yw74IJ6q4Hjv3mg==} + + '@microsoft/kiota-serialization-multipart@1.0.0-preview.103': + resolution: {integrity: sha512-b3ePXYgFv/2TQtbzFQ8ZY+hzMtCRkz4Cx5y/Pf8vtoQu56KU0/9rYq5kA9wySDdCx/oZxfubQQRV/8QSTxmejw==} + + '@microsoft/kiota-serialization-text@1.0.0-preview.103': + resolution: {integrity: sha512-FkiboqpNtT2F40xUC9LETHK9ckHd+WuocLJE70ljI4EYvD59MQDsrTERYAwbmkfPIJ6JRE4OfLXFVh510LG1/Q==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} + engines: {node: '>=18'} + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nestjs/cache-manager@3.1.3': + resolution: {integrity: sha512-HMtiOfHz75NZX7mJn1VnZGLSachVI04TnUc5wvEogIaKwk5BDQHgtP5htxreizjv7oxKalJbuTyxtiF6bE+bgQ==} + peerDependencies: + '@nestjs/common': ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 || ^11.0.0 + cache-manager: '>=6' + keyv: '>=5' + rxjs: ^7.8.1 + + '@nestjs/cli@11.0.16': + resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==} + engines: {node: '>= 20.11'} + hasBin: true + peerDependencies: + '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 + '@swc/core': ^1.3.62 + peerDependenciesMeta: + '@swc/cli': + optional: true + '@swc/core': + optional: true + + '@nestjs/common@11.1.16': + resolution: {integrity: sha512-JSIeW+USuMJkkcNbiOdcPkVCeI3TSnXstIVEPpp3HiaKnPRuSbUUKm9TY9o/XpIcPHWUOQItAtC5BiAwFdVITQ==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/config@4.0.3': + resolution: {integrity: sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + '@nestjs/core@11.1.16': + resolution: {integrity: sha512-tXWXyCiqWthelJjrE0KLFjf0O98VEt+WPVx5CrqCf+059kIxJ8y1Vw7Cy7N4fwQafWNrmFL2AfN87DDMbVAY0w==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/event-emitter@3.0.1': + resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/jwt@11.0.2': + resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/platform-express@11.1.16': + resolution: {integrity: sha512-IOegr5+ZfUiMKgk+garsSU4MOkPRhm46e6w8Bp1GcO4vCdl9Piz6FlWAzKVfa/U3Hn/DdzSVJOW3TWcQQFdBDw==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + + '@nestjs/platform-fastify@11.1.27': + resolution: {integrity: sha512-rZMPS0RMP9P2O7Y/shAP0kKwrHElGO9cPTlgtdWpQ6JEX58uhYEJLGsU/X9a9l18DNSxxb3YjrEiVVuI2Iv6MA==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@fastify/view': ^10.0.0 || ^11.0.0 || ^12.0.0 + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + '@fastify/view': + optional: true + + '@nestjs/schedule@6.1.1': + resolution: {integrity: sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/schematics@11.0.9': + resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} + peerDependencies: + typescript: '>=4.8.2' + + '@nestjs/swagger@11.4.1': + resolution: {integrity: sha512-GuGzs8F1Cb3n+eEarmOqB4nt2ai+x4XGOYUXNYplOtDeB59DaFY5E16bsHsBWXiWgD1ywbyKQ5OVv02bQtB1Dw==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/terminus@11.1.1': + resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==} + peerDependencies: + '@grpc/grpc-js': '*' + '@grpc/proto-loader': '*' + '@mikro-orm/core': '*' + '@mikro-orm/nestjs': '*' + '@nestjs/axios': ^2.0.0 || ^3.0.0 || ^4.0.0 + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/microservices': ^10.0.0 || ^11.0.0 + '@nestjs/mongoose': ^11.0.0 + '@nestjs/sequelize': ^10.0.0 || ^11.0.0 + '@nestjs/typeorm': ^10.0.0 || ^11.0.0 + '@prisma/client': '*' + mongoose: '*' + reflect-metadata: 0.1.x || 0.2.x + rxjs: 7.x + sequelize: '*' + typeorm: '*' + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + '@grpc/proto-loader': + optional: true + '@mikro-orm/core': + optional: true + '@mikro-orm/nestjs': + optional: true + '@nestjs/axios': + optional: true + '@nestjs/microservices': + optional: true + '@nestjs/mongoose': + optional: true + '@nestjs/sequelize': + optional: true + '@nestjs/typeorm': + optional: true + '@prisma/client': + optional: true + mongoose: + optional: true + sequelize: + optional: true + typeorm: + optional: true + + '@nestjs/testing@11.1.16': + resolution: {integrity: sha512-E7/aUCxzeMSJV80L5GWGIuiMyR/1ncS7uOIetAImfbS4ATE1/h2GBafk0qpk+vjFtPIbtoh9BWDGICzUEU5jDA==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nuxt/opencollective@0.4.1': + resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} + engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} + hasBin: true + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@opentelemetry/api-logs@0.212.0': + resolution: {integrity: sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.213.0': + resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.214.0': + resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/auto-instrumentations-node@0.70.1': + resolution: {integrity: sha512-r8BKs0rHtBAzZViPIuzSD2eh65fOPau0NqVsca2sACuZ6LFGu6a+QMhqq7skXz+/OqKwFr/7/b6VsaNMS+zZpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.4.1 + '@opentelemetry/core': ^2.0.0 + + '@opentelemetry/configuration@0.212.0': + resolution: {integrity: sha512-D8sAY6RbqMa1W8lCeiaSL2eMCW2MF87QI3y+I6DQE1j+5GrDMwiKPLdzpa/2/+Zl9v1//74LmooCTCJBvWR8Iw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/context-async-hooks@2.5.1': + resolution: {integrity: sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.5.1': + resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.212.0': + resolution: {integrity: sha512-/0bk6fQG+eSFZ4L6NlckGTgUous/ib5+OVdg0x4OdwYeHzV3lTEo3it1HgnPY6UKpmX7ki+hJvxjsOql8rCeZA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.212.0': + resolution: {integrity: sha512-JidJasLwG/7M9RTxV/64xotDKmFAUSBc9SNlxI32QYuUMK5rVKhHNWMPDzC7E0pCAL3cu+FyiKvsTwLi2KqPYw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.212.0': + resolution: {integrity: sha512-RpKB5UVfxc7c6Ta1UaCrxXDTQ0OD7BCGT66a97Q5zR1x3+9fw4dSaiqMXT/6FAWj2HyFbem6Rcu1UzPZikGTWQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0': + resolution: {integrity: sha512-/6Gqf9wpBq22XsomR1i0iPGnbQtCq2Vwnrq5oiDPjYSqveBdK1jtQbhGfmpK2mLLxk4cPDtD1ZEYdIou5K8EaA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.212.0': + resolution: {integrity: sha512-8hgBw3aTTRpSTkU4b9MLf/2YVLnfWp+hfnLq/1Fa2cky+vx6HqTodo+Zv1GTIrAKMOOwgysOjufy0gTxngqeBg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0': + resolution: {integrity: sha512-yw3fTIw4KQIRXC/ZyYQq5gtA3Ogfdfz/g5HVgleobQAcjUUE8Nj3spGMx8iQPp+S+u6/js7BixufRkXhzLmpJA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.212.0': + resolution: {integrity: sha512-C7I4WN+ghn3g7SnxXm2RK3/sRD0k/BYcXaK6lGU3yPjiM7a1M25MLuM6zY3PeVPPzzTZPfuS7+wgn/tHk768Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0': + resolution: {integrity: sha512-geHF+zZaDb0/WRkJTxR8o8dG4fCWT/Wq7HBdNZCxwH5mxhwRi/5f37IDYH7nvU+dwU6IeY4Pg8TPI435JCiNkg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.212.0': + resolution: {integrity: sha512-hJFLhCJba5MW5QHexZMHZdMhBfNqNItxOsN0AZojwD1W2kU9xM+BEICowFGJFo/vNV+I2BJvTtmuKafeDSAo7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.212.0': + resolution: {integrity: sha512-9xTuYWp8ClBhljDGAoa0NSsJcsxJsC9zCFKMSZJp1Osb9pjXCMRdA6fwXtlubyqe7w8FH16EWtQNKx/FWi+Ghw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.212.0': + resolution: {integrity: sha512-v/0wMozNoiEPRolzC4YoPo4rAT0q8r7aqdnRw3Nu7IDN0CGFzNQazkfAlBJ6N5y0FYJkban7Aw5WnN73//6YlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.212.0': + resolution: {integrity: sha512-d1ivqPT0V+i0IVOOdzGaLqonjtlk5jYrW7ItutWzXL/Mk+PiYb59dymy/i2reot9dDnBFWfrsvxyqdutGF5Vig==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0': + resolution: {integrity: sha512-six3vPq3sL+ge1iZOfKEg+RHuFQhGb8ZTdlvD234w/0gi8ty/qKD46qoGpKvM3amy5yYunWBKiFBW47WaVS26w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.5.1': + resolution: {integrity: sha512-Me6JVO7WqXGXsgr4+7o+B7qwKJQbt0c8WamFnxpkR43avgG9k/niTntwCaXiXUTjonWy0+61ZuX6CGzj9nn8CQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-amqplib@0.59.0': + resolution: {integrity: sha512-xscSgOJA+GHphESDZxBHNk/zjNaEgoeufMwmiqYdL+qM27Xw3BbR9vN6Ucbq9dW6Y+oYUPgTTj17qf+Za4+uzg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-lambda@0.64.0': + resolution: {integrity: sha512-vYhM/a8fG34/Dl/Q9gfv5Ih3OFPgqeyn79S8FN+Xs/QZw6h6L8a1lDa3CyigyicOXLCmVIM7Fc9vFD4BGqgGLA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-sdk@0.67.0': + resolution: {integrity: sha512-btpwJnZ2RBXDh/pTpfVpInpBu9Pedi+lbLKbt3naB344SggbbYnIdT7u8EzmGIApWi9EV91vw7hm896I7nESQA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-bunyan@0.57.0': + resolution: {integrity: sha512-W4zLz1Y9ptCsdL+QMXR7xQaBHkJivLBmVlLCjUe23rX4V8E65fGAtlIJSKTKAfz4aEgtWgQAGMdkeqACwG0Caw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cassandra-driver@0.57.0': + resolution: {integrity: sha512-xLwrK+XnN32IB5i6t/a2j+SVdjlq/BIgjpVRkke4HAsKjoSMy1GeSI+ZOiJffRLFb4MojcvH4RG2+nEg1uC6Zg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.55.0': + resolution: {integrity: sha512-UfGw7ubKKZBoTRjxi5KlfeECEaXZinS20RdRNlZE5tVF+O17hJOnrcGwAoQAHp6eYmxI2jW9IQ4t6450gnNF9g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cucumber@0.28.0': + resolution: {integrity: sha512-kim+bRxu4LZqKEyF2SgO01tgG88W+/iYltyP1XjT31FIXzlBjzQpwtSLLM8byayO85mcZIBha54WSNFDLM/7qQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-dataloader@0.29.0': + resolution: {integrity: sha512-220WjRb1G1UiAKbVblSMxwxxFdpyB4wj1XYIO9BJs5r62Azj2dL5fyZiXK3/WO6wB3uLul9R946iKI1bpPxktQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dns@0.55.0': + resolution: {integrity: sha512-cfWLaFi22V+sQrKY7t6QroYzT3kO9m3PpkN1OXYmuCyfwxQaXOVlF8NSAHtua/RQYw0aQl+2fe6JOWyJdEZiwA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.60.0': + resolution: {integrity: sha512-KghHCDqKq0D7iuPIVCuPSXut5WVAI6uwKcPrhwTUJL5VE2LC18id2vKoiAm1V8XvVlgIGAiECtEvbrFwkTCj3g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fastify@0.56.0': + resolution: {integrity: sha512-zotOPoZsWtMF47BjottK23XaaBSmVuwG5D/R3FlGfAAwMNFoDR3IY1OGO9v9KfOU/1/xDVkxsQ22NFfu9lE8aA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.31.0': + resolution: {integrity: sha512-C7tdXGDnkMgLVlE79VSekB+Y+P345zKUigvFMs5M7U0GIYA8ERx3FS0aAcY/ICIq9YwRmH2uuMb++Br5M2vNUg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.55.0': + resolution: {integrity: sha512-7hWiyLbEX/dIS4LZy/h8VaAQPs8oBeEqsrysDWbos0b9PF414L6Rsbi2um/omtxIs+GTvsbuqDscWigeaxyWdA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.60.0': + resolution: {integrity: sha512-XPATrmxAd2tFCsYbJ3eVIXt+gyvMKjc36QQuQxjtssMnAbw006Le9b5lKs7WXik7ItOpM1exATi1aDdOcCjRRg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-grpc@0.212.0': + resolution: {integrity: sha512-r1t7LNKWVhSQMUrBdDJtooFmmLZ93kGuFixqeXPoUP8W+chJCxhey9l0c0+L3xriNdyB7TzvkKHhPXUDevgVEA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.58.0': + resolution: {integrity: sha512-reuRApR2KHm2VsfyDgsrLhNE+IOy4uIU6n3oMjUleReHacEEZmf4vXxdt4/qcmJ6GoUXnRN2AOu3s5N3pMrgYA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.212.0': + resolution: {integrity: sha512-t2nt16Uyv9irgR+tqnX96YeToOStc3X5js7Ljn3EKlI2b4Fe76VhMkTXtsTQ0aId6AsYgefrCRnXSCo/Fn/vww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.60.0': + resolution: {integrity: sha512-R+nnbPD9l2ruzu248qM3YDWzpdmWVaFFFv08lQqsc0EP4pT/B1GGUg06/tHOSo3L5njB2eejwyzpkvJkjaQEMA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.21.0': + resolution: {integrity: sha512-lkLrILnKGO7SHw1xPJnuGx2S4XwbKmQiJyzUGuEImRoU/6Gj0Nka0lkbeRd4ANN20dxr/mLdXIsUsk6DzTrX6A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.56.0': + resolution: {integrity: sha512-pKqtY5lbAQ70MC5K/BJeAA1t2gAUlRBZBAJ5ergRUNs5jw8zbdOXEZOLztiuNvQqD2z4a9N0Tkde9JMFm2pKMQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.60.0': + resolution: {integrity: sha512-UOmu2y2LHgPzKsm9xd0sCQJimr11YP4MKFc190Do1ufd8qds7Zd5BI3f6TudqYhH9dUIhojsQyUaS6K4nv5FsQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.56.0': + resolution: {integrity: sha512-vXtOValhKRgWA9tLAiTU3P37Q31OveRuM2N5iLSVHl4GzkMBQ5p50A9kSKvt5gReL6BzFDXPCM9ItJiAhSS2KQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-memcached@0.55.0': + resolution: {integrity: sha512-kdhW/j5X+vNCAvHVc50PZfvE7diUScg1ZkBaNFRygY3Z6IUjgPLR0luWQMDPSFun6AVo1HaMDPxbUqJrot6qrA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.65.0': + resolution: {integrity: sha512-hOAJRs5vrY7fZolSYUXmf29Y+HFDHWrek0DeLq82uwMPjPSda7h6oumQnqEX5olzw357q/QG39/uJdkclJ/JUg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.58.0': + resolution: {integrity: sha512-3L0Fqo1y2oreISFPWaqdt/bg3NhLgrkn5U/E/9RNG1QaM81drTMBCHseMY1q8SlejjE43ZWOy+0KbmRBlUPJ+g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.58.0': + resolution: {integrity: sha512-EubjV1XZb7XHrENqF7TW2lnah+KN0LddMneKNAB8PjGVKL5lJkVV/vhJ6EIcUNn9nCWmAwZ3GRcFVEDKCnyXfQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.58.0': + resolution: {integrity: sha512-wZDrBCL3WfJclV6KywWVV3/B2ZiUYmDQdgyu3pq4jK/5qSfoDmezHzT/Nayln5MVVWMAGXIMLrCj8BKa6jaKQQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-nestjs-core@0.58.0': + resolution: {integrity: sha512-0lE9oW8j6nmvBHJoOxIQgKzMQQYNfX1nhiWZdXD0sNAMFsWBtvECWS7NAPSroKrEP53I04TcHCyyhcK4I9voXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-net@0.56.0': + resolution: {integrity: sha512-h69x7U6f86mP3gGWWTaMkQZk0K3tBvpVMIU7E0q2kkVw6eZ5TqFm9rkaEy38moQmixiDFQ9j/2/cwxG9P7ZEeA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-openai@0.10.0': + resolution: {integrity: sha512-0lV2zxge2mMaruVCw/bmypWVu+aJ76rc0HBvAVFCPUI3zzJdgBZJZafGIHZ1IB2F6VvrDFL+JstEnle6V8brvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-oracledb@0.37.0': + resolution: {integrity: sha512-OzMghtAEAEkXlkUrZI4QcXSZq0MILeU6WC0/N5+1MSkuIkruIeaRw99/RtyS2of8vlPDa8XbbXl32Q1RM3wSyg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.64.0': + resolution: {integrity: sha512-NbfB/rlfsRI3zpTjnbvJv3qwuoGLsN8FxR/XoI+ZTn1Rs62x1IenO+TSSvk4NO+7FlXpd2MiOe8LT/oNbydHGA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.58.0': + resolution: {integrity: sha512-rgy+tA7cDjuSq6dXAO40OiYP25azIDHMBtxG3RzSmCBVEYdjggl6btyuLVasX6VkOOhP2gf6PBuLMNxVwaIqAw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.59.0': + resolution: {integrity: sha512-IgImVFtWjfMmqxc0NIe3iSjp+J3Asf9lLX8reouUFUk3Aa/qJQO5PEvOtO3sNQtJBkC9bAd1OQdFaFWSFQc03g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.60.0': + resolution: {integrity: sha512-Ea/GffmmzIVHc9geaMjT94IR7poVZzIv4Kk/Lw0tbxGD3cBYcMUsLFVajKxpZsE1NRCECFpidAWeifCIKD0inw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-restify@0.57.0': + resolution: {integrity: sha512-kO6MsZFU+RdXOKhsKw8SOSBYGYCdFSlza+mpBQRl1DQmveZcnidchv4V5JQPtNgHxCGH+1n3hDpLdxdGUbJPNA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-router@0.56.0': + resolution: {integrity: sha512-PHECDGQElLazI/QbHU16C5m9fDC7DGJk+jLIwO5ca6bcp7bXhUPPUTT78l7da2pDsrz4mhv5ytYNZmBbW/Q3rA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-runtime-node@0.25.0': + resolution: {integrity: sha512-XaCmwBSui5KeTn8M6OzaEn1rEsNWtUkjuc1ylg0tqQTLHibNQ0n7f8v4zdF6x/nBV1OnsiYlN8RLHauGemv/TA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-socket.io@0.59.0': + resolution: {integrity: sha512-71DnM/FEqH0PjvU2uZvzWJeaGyVIy3rJKk8rZrxg/aS2QT3qLGb+UPL/B+1vOw4pzDPn4papLTSMpLVF9G8uvw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.31.0': + resolution: {integrity: sha512-HoF2EtcyP3JR4R3jLPHohZ9lFcj1QLJyGmFfLKDTvUUjPiFuK4XZ6L1OV9HhaqvN0xY+tWKfNdCPS3r33rd0Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.22.0': + resolution: {integrity: sha512-yb6vEWUPOrD5i7yR1XceEEqiVHbMgr5YnUPnom5eQVCjvrTkEVswyrf9i+vvJR+28wrNqILIIphWgOOx6BjnTQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation-winston@0.56.0': + resolution: {integrity: sha512-ITIA0Qe61CQ6FQU/bN23pNBvJ+5U0ofoASMOOYrODtXyV9wI267AigNTTwDmv2Myt8dPEFvvVFJZKhiZLIpehA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.212.0': + resolution: {integrity: sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.213.0': + resolution: {integrity: sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.214.0': + resolution: {integrity: sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.212.0': + resolution: {integrity: sha512-HoMv5pQlzbuxiMS0hN7oiUtg8RsJR5T7EhZccumIWxYfNo/f4wFc7LPDfFK6oHdG2JF/+qTocfqIHoom+7kLpw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.213.0': + resolution: {integrity: sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.212.0': + resolution: {integrity: sha512-YidOSlzpsun9uw0iyIWrQp6HxpMtBlECE3tiHGAsnpEqJWbAUWcMnIffvIuvTtTQ1OyRtwwaE79dWSQ8+eiB7g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.212.0': + resolution: {integrity: sha512-bj7zYFOg6Db7NUwsRZQ/WoVXpAf41WY2gsd3kShSfdpZQDRKHWJiRZIg7A8HvWsf97wb05rMFzPbmSHyjEl9tw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.213.0': + resolution: {integrity: sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@2.5.1': + resolution: {integrity: sha512-AU6sZgunZrZv/LTeHP+9IQsSSH5p3PtOfDPe8VTdwYH69nZCfvvvXehhzu+9fMW2mgJMh5RVpiH8M9xuYOu5Dg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.5.1': + resolution: {integrity: sha512-8+SB94/aSIOVGDUPRFSBRHVUm2A8ye1vC6/qcf/D+TF4qat7PC6rbJhRxiUGDXZtMtKEPM/glgv5cBGSJQymSg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.38.2': + resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resource-detector-alibaba-cloud@0.33.3': + resolution: {integrity: sha512-Ep3LDWALU+wCgGzAa1rgFXh3TObahN7HaQZntAeVQnnNhZ3VSXnBniRGeSCTIRvRr7YdZvq+G+rstixtAN5Ugw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-aws@2.13.0': + resolution: {integrity: sha512-ZPCn7gZhGqUYUoD+RCHIlayoHBMaJaEjfqlgz2EPKoXJ4y7Ru7CUm+Tm3yJVMKF92cN9xUQR0j5KALyF0fg9aw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-azure@0.20.0': + resolution: {integrity: sha512-iRy+O2cB6DOlQ/OONaK+L8Cp8nLS89dZVRp6KgnFAfzykXuq9Ws/ygJKcU3CCmjkgY5j2Vk3uVTre/E35bWhYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-container@0.8.4': + resolution: {integrity: sha512-kIvGHkMSacp+kb7btTuXbOAIWLyOCO+P/h/8xxaeLcp5ptmHRZ67uEdLAQo61ApdayFB/uqjJ9gY4x2/i/KsoA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-gcp@0.47.0': + resolution: {integrity: sha512-57T/kRVdU0ch1P4KPEkmU2b5mWNlUs8hHgqrBYVF+fNZMc1jMdL1mANZhEzoLtWKIeoCEy+57Itt7RkXAYNJiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resources@2.5.1': + resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.212.0': + resolution: {integrity: sha512-qglb5cqTf0mOC1sDdZ7nfrPjgmAqs2OxkzOPIf2+Rqx8yKBK0pS7wRtB1xH30rqahBIut9QJDbDePyvtyqvH/Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.213.0': + resolution: {integrity: sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.5.1': + resolution: {integrity: sha512-RKMn3QKi8nE71ULUo0g/MBvq1N4icEBo7cQSKnL3URZT16/YH3nSVgWegOjwx7FRBTrjOIkMJkCUn/ZFIEfn4A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.6.0': + resolution: {integrity: sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.212.0': + resolution: {integrity: sha512-tJzVDk4Lo44MdgJLlP+gdYdMnjxSNsjC/IiTxj5CFSnsjzpHXwifgl3BpUX67Ty3KcdubNVfedeBc/TlqHXwwg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.5.1': + resolution: {integrity: sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.6.0': + resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.5.1': + resolution: {integrity: sha512-9lopQ6ZoElETOEN0csgmtEV5/9C7BMfA7VtF4Jape3i954b6sTY2k3Xw3CxUTKreDck/vpAuJM+EDo4zheUw+A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.41.2': + resolution: {integrity: sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + + '@ota-meshi/ast-token-store@0.3.0': + resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@oxc-parser/binding-android-arm-eabi@0.115.0': + resolution: {integrity: sha512-VoB2rhgoqgYf64d6Qs5emONQW8ASiTc0xp+aUE4JUhxjX+0pE3gblTYDO0upcN5vt9UlBNmUhAwfSifkfre7nw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.115.0': + resolution: {integrity: sha512-lWRX75u+gqfB4TF3pWCHuvhaeneAmRl2b2qNBcl4S6yJ0HtnT4VXOMEZrq747i4Zby1ZTxj6mtOe678Bg8gRLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.115.0': + resolution: {integrity: sha512-ii/oOZjfGY1aszXTy29Z5DRyCEnBOrAXDVCvfdfXFQsOZlbbOa7NMHD7D+06YFe5qdxfmbWAYv4yn6QJi/0d2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.115.0': + resolution: {integrity: sha512-R/sW/p8l77wglbjpMcF+h/3rWbp9zk1mRP3U14mxTYIC2k3m+aLBpXXgk2zksqf9qKk5mcc4GIYsuCn9l8TgDg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.115.0': + resolution: {integrity: sha512-CSJ5ldNm9wIGGkhaIJeGmxRMZbgxThRN+X1ufYQQUNi5jZDV/U3C2QDMywpP93fczNBj961hXtcUPO/oVGq4Pw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.115.0': + resolution: {integrity: sha512-uWFwssE5dHfQ8lH+ktrsD9JA49+Qa0gtxZHUs62z1e91NgGz6O7jefHGI6aygNyKNS45pnnBSDSP/zV977MsOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.115.0': + resolution: {integrity: sha512-fZbqt8y/sKQ+v6bBCuv/mYYFoC0+fZI3mGDDEemmDOhT78+aUs2+4ZMdbd2btlXmnLaScl37r8IRbhnok5Ka9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.115.0': + resolution: {integrity: sha512-1ej/MjuTY9tJEunU/hUPIFmgH5PqgMQoRjNOvOkibtJ3Zqlw/+Lc+HGHDNET8sjbgIkWzdhX+p4J96A5CPdbag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.115.0': + resolution: {integrity: sha512-HjsZbJPH9mMd4swJRywVMsDZsJX0hyKb1iNHo5ijRl5yhtbO3lj7ImSrrL1oZ1VEg0te4iKmDGGz/6YPLd1G8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.115.0': + resolution: {integrity: sha512-zhhePoBrd7kQx3oClX/W6NldsuCbuMqaN9rRsY+6/WoorAb4j490PG/FjqgAXscWp2uSW2WV9L+ksn0wHrvsrg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.115.0': + resolution: {integrity: sha512-t/IRojvUE9XrKu+/H1b8YINug+7Q6FLls5rsm2lxB5mnS8GN/eYAYrPgHkcg9/1SueRDSzGpDYu3lGWTObk1zw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.115.0': + resolution: {integrity: sha512-79jBHSSh/YpQRAmvYoaCfpyToRbJ/HBrdB7hxK2ku2JMehjopTVo+xMJss/RV7/ZYqeezgjvKDQzapJbgcjVZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.115.0': + resolution: {integrity: sha512-nA1TpxkhNTIOMMyiSSsa7XIVJVoOU/SsVrHIz3gHvWweB5PHCQfO7w+Lb2EP0lBWokv7HtA/KbF7aLDoXzmuMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.115.0': + resolution: {integrity: sha512-9iVX789DoC3SaOOG+X6NcF/tVChgLp2vcHffzOC2/Z1JTPlz6bMG2ogvcW6/9s0BG2qvhNQImd+gbWYeQbOwVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.115.0': + resolution: {integrity: sha512-RmQmk+mjCB0nMNfEYhaCxwofLo1Z95ebHw1AGvRiWGCd4zhCNOyskgCbMogIcQzSB3SuEKWgkssyaiQYVAA4hQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.115.0': + resolution: {integrity: sha512-viigraWWQhhDvX5aGq+wrQq58k00Xq3MHz/0R4AFMxGlZ8ogNonpEfNc73Q5Ly87Z6sU9BvxEdG0dnYTfVnmew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.115.0': + resolution: {integrity: sha512-IzGCrMwXhpb4kTXy/8lnqqqwjI7eOvy+r9AhVw+hsr8t1ecBBEHprcNy0aKatFHN6hsX7UMHHQmBAQjVvL/p1A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.115.0': + resolution: {integrity: sha512-/ym+Absk/TLFvbhh3se9XYuI1D7BrUVHw4RaG/2dmWKgBenrZHaJsgnRb7NJtaOyjEOLIPtULx1wDdVL0SX2eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.115.0': + resolution: {integrity: sha512-AQSZjIR+b+Te7uaO/hGTMjT8/oxlYrvKrOTi4KTHF/O6osjHEatUQ3y6ZW2+8+lJxy20zIcGz6iQFmFq/qDKkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.115.0': + resolution: {integrity: sha512-oxUl82N+fIO9jIaXPph8SPPHQXrA08BHokBBJW8ct9F/x6o6bZE6eUAhUtWajbtvFhL8UYcCWRMba+kww6MBlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.115.0': + resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + + '@oxc-project/types@0.128.0': + resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@prisma/client@6.19.2': + resolution: {integrity: sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.2': + resolution: {integrity: sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==} + + '@prisma/debug@6.19.2': + resolution: {integrity: sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines@6.19.2': + resolution: {integrity: sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==} + + '@prisma/fetch-engine@6.19.2': + resolution: {integrity: sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==} + + '@prisma/get-platform@6.19.2': + resolution: {integrity: sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.18': + resolution: {integrity: sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.18': + resolution: {integrity: sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': + resolution: {integrity: sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': + resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': + resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': + resolution: {integrity: sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': + resolution: {integrity: sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': + resolution: {integrity: sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.18': + resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==} + + '@rolldown/pluginutils@1.0.0-rc.2': + resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + + '@rollup/plugin-babel@5.3.1': + resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} + engines: {node: '>= 10.0.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@types/babel__core': ^7.1.9 + rollup: ^1.20.0||^2.0.0 + peerDependenciesMeta: + '@types/babel__core': + optional: true + + '@rollup/plugin-node-resolve@15.3.1': + resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-replace@2.4.2': + resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 + + '@rollup/plugin-terser@0.4.4': + resolution: {integrity: sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@3.1.0': + resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} + engines: {node: '>= 8.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0 + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@simple-libs/child-process-utils@1.0.2': + resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} + engines: {node: '>=18'} + + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} + + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@std-uritemplate/std-uritemplate@2.0.12': + resolution: {integrity: sha512-s/R828vYTrFPgAOFAUiHuP4i1eSZHCO9PKVfzVR+2DFERJ/G71BpZJVObM2yy3xeMjMW3wB61gvanb6/pV1lJw==} + + '@stylistic/eslint-plugin@5.10.0': + resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + '@surma/rollup-plugin-off-main-thread@2.2.3': + resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} + + '@swc/helpers@0.5.19': + resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@ts-rest/core@3.52.1': + resolution: {integrity: sha512-tAjz7Kxq/grJodcTA1Anop4AVRDlD40fkksEV5Mmal88VoZeRKAG8oMHsDwdwPZz+B/zgnz0q2sF+cm5M7Bc7g==} + peerDependencies: + '@types/node': ^18.18.7 || >=20.8.4 + zod: ^3.22.3 + peerDependenciesMeta: + '@types/node': + optional: true + zod: + optional: true + + '@ts-rest/fastify@3.52.1': + resolution: {integrity: sha512-y+w3ENayNI77T0l2gcr9mobF3Nfc4yIb4487mvNMsMUkGLMHmaWzhLy3Mo/mswuoA+tVdOkrD8tJD38bLY7iYQ==} + peerDependencies: + '@ts-rest/core': ~3.52.0 + fastify: ^4.0.0 + zod: ^3.22.3 + peerDependenciesMeta: + zod: + optional: true + + '@ts-rest/open-api@3.52.1': + resolution: {integrity: sha512-VY91g6HFzCe1/fNgX4Y3HzaJbIdztCGHTg34drkh7bKgtNOizLJLXV0wwIkMwLGvjhRHojJxnwYxHjGMIkScwg==} + peerDependencies: + '@ts-rest/core': ~3.52.0 + zod: ^3.22.3 + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/aws-lambda@8.10.161': + resolution: {integrity: sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/bunyan@1.8.11': + resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@0.0.39': + resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/memcached@2.2.10': + resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/mysql@2.15.27': + resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} + + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + + '@types/oracledb@6.5.2': + resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==} + + '@types/pg-pool@2.0.7': + resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@types/swagger-schema-official@2.0.25': + resolution: {integrity: sha512-T92Xav+Gf/Ik1uPW581nA+JftmjWPgskw/WBf4TJzxRG/SJ+DfNnNE+WuZ4mrXuzflQMqMkm1LSYjzYW7MB1Cg==} + + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@typescript-eslint/eslint-plugin@8.57.0': + resolution: {integrity: sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/eslint-plugin@8.59.2': + resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.57.0': + resolution: {integrity: sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.59.2': + resolution: {integrity: sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.57.0': + resolution: {integrity: sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.59.2': + resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/rule-tester@8.57.0': + resolution: {integrity: sha512-qs4OapXmAIj3so85/20lQG1WrBSSvDE/3b42Orl3lpZkaOlNXtbfKzL+9EPaY5wSEgdlhKEpympAMFHPG9i72Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + '@typescript-eslint/scope-manager@8.57.0': + resolution: {integrity: sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.59.2': + resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.57.0': + resolution: {integrity: sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/tsconfig-utils@8.59.2': + resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.57.0': + resolution: {integrity: sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.59.2': + resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.57.0': + resolution: {integrity: sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.59.2': + resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.57.0': + resolution: {integrity: sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/typescript-estree@8.59.2': + resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.57.0': + resolution: {integrity: sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.59.2': + resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.57.0': + resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.59.2': + resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unocss/cli@66.6.6': + resolution: {integrity: sha512-78SY8j4hAVelK+vP/adsDGaSjEITasYLFECJLHWxUJSzK+G9UIc5wtL/u4jA+zKvwVkHcDvbkcO5K6wwwpAixg==} + engines: {node: '>=14'} + hasBin: true + + '@unocss/config@66.6.6': + resolution: {integrity: sha512-menlnkqAFX/4wR2aandY8hSqrt01JE+rOzvtQxWaBt8kf1du62b0sS72FE5Z40n6HlEsEbF91N9FCfhnzG6i6g==} + engines: {node: '>=14'} + + '@unocss/core@0.65.4': + resolution: {integrity: sha512-a2JOoFutrhqd5RgPhIR5FIXrDoHDU3gwCbPrpT6KYTjsqlSc/fv02yZ+JGOZFN3MCFhCmaPTs+idDFtwb3xU8g==} + + '@unocss/core@66.6.6': + resolution: {integrity: sha512-Sbbx0ZQqmV8K2lg8E+z9MJzWb1MgRtJnvqzxDIrNuBjXasKhbcFt5wEMBtEZJOr63Z4ck0xThhZK53HmYT2jmg==} + + '@unocss/extractor-arbitrary-variants@66.6.6': + resolution: {integrity: sha512-uMzekF2miZRUwSZGvy3yYQiBAcSAs9LiXK8e3NjldxEw8xcRDWgTErxgStRoBeAD6UyzDcg/Cvwtf2guMbtR+g==} + + '@unocss/inspector@66.6.6': + resolution: {integrity: sha512-CpXIsqHwxCXJtUjUz6S29diHCIA+EJ1u5WML/6m2YPI4ObgWAVKrExy09inSg2icS52lFkWWdWQSeqc9kl5W6Q==} + + '@unocss/preset-attributify@66.6.6': + resolution: {integrity: sha512-3H12UI1rBt60PQy+S4IEeFYWu1/WQFuc2yhJ5mu/RCvX5/qwlIGanBpuh+xzTPXU1fWBlZN68yyO9uWOQgTqZQ==} + + '@unocss/preset-icons@66.6.6': + resolution: {integrity: sha512-HfIEEqf3jyKexOB2Sux556n0NkPoUftb2H4+Cf7prJvKHopMkZ/OUkXjwvUlxt1e5UpAEaIa0A2Ir7+ApxXoGA==} + + '@unocss/preset-mini@66.6.6': + resolution: {integrity: sha512-k+/95PKMPOK57cJcSmz34VkIFem8BlujRRx6/L0Yusw7vLJMh98k0rPhC5s+NomZ/d9ZPgbNylskLhItJlak3w==} + + '@unocss/preset-tagify@66.6.6': + resolution: {integrity: sha512-KgBXYPYS0g4TVC3NLiIB78YIqUlvDLanz1EHIDo34rOTUfMgY8Uf5VuDJAzMu4Sc0LiwwBJbk6nIG9/Zm7ufWg==} + + '@unocss/preset-typography@66.6.6': + resolution: {integrity: sha512-SM1km5nqt15z4sTabfOobSC633I5Ol5nnme6JFTra4wiyCUNs+Cg31nJ6jnopWDUT4SEAXqfUH7jKSSoCnI6ZA==} + + '@unocss/preset-uno@66.6.6': + resolution: {integrity: sha512-40PcBDtlhW7QP7e/WOxC684IhN5T1dXvj1dgx9ZzK+8lEDGjcX7bN2noW4aSenzSrHymeSsMrL/0ltL4ED/5Zw==} + + '@unocss/preset-web-fonts@66.6.6': + resolution: {integrity: sha512-5ikwgrJB8VPzKd0bqgGNgYUGix90KFnVtKJPjWTP5qsv3+ZtZnea1rRbAFl8i2t52hg35msNBsQo+40IC3xB6A==} + + '@unocss/preset-wind3@66.6.6': + resolution: {integrity: sha512-rk6gPPIQ7z2DVucOqp7XZ4vGpKAuzBV1vtUDvDh5WscxzO/QlqaeTfTALk5YgGpmLaF4+ns6FrTgLjV+wHgHuQ==} + + '@unocss/preset-wind4@66.6.6': + resolution: {integrity: sha512-caTDM9rZSlp4tyPWWAnwMvQr2PXq53LsEYwd3N8zj0ou2hcsqptJvF+mFvyhvGF66x26wWJr/FwuUEhh7qycaw==} + + '@unocss/preset-wind@66.6.6': + resolution: {integrity: sha512-TMy3lZ35FP/4QqDHOLWZmV+RoOGWUDqnDEOTjOKI1CQARGta0ppUmq+IZMuI1ZJLuOa4OZ9V6SfnwMXwRLgXmw==} + + '@unocss/rule-utils@0.65.4': + resolution: {integrity: sha512-+EzdJEWcqGcO6HwbBTe7vEdBRpuKkBiz4MycQeLD6GEio04T45y6VHHO7/WTqxltbO4YwwW9/s2TKRMxKtoG8g==} + engines: {node: '>=14'} + + '@unocss/rule-utils@66.6.6': + resolution: {integrity: sha512-krWtQKGshOaqQMuxeGq1NOA8NL35VdpYlmQEWOe39BY6TACT51bgQFu40MRfsAIMZZtoGS2YYTrnHojgR92omw==} + engines: {node: '>=14'} + + '@unocss/transformer-attributify-jsx@66.6.6': + resolution: {integrity: sha512-NnDchmN2EeFLy4lfVqDgNe9j1+w2RLL2L9zKECXs5g6rDVfeeEK6FNgxSq3XnPcKltjNCy1pF4MaDOROG7r8yA==} + + '@unocss/transformer-compile-class@66.6.6': + resolution: {integrity: sha512-KKssJxU8fZ9x84yznIirbtta2sB0LN/3lm0bp+Wl1298HITaNiVeG2n26iStQ3N7r240xRN2RarxncSVCMFwWw==} + + '@unocss/transformer-directives@0.65.4': + resolution: {integrity: sha512-zkoDEwzPkgXi6ohW7P11gbArwfTRMZ9knYSUYoPEltQz+UZYzeRQ85exiAmdz5MsbCAuhQEr577Kd/CWfhjEuA==} + + '@unocss/transformer-directives@66.6.6': + resolution: {integrity: sha512-CReFTcBfMtKkRvzIqxL20VptWt5C1Om27dwoKzyVFBXv0jzViWysbu0y0AQg3bsgD4cFqndFyAGyeL84j0nbKg==} + + '@unocss/transformer-variant-group@66.6.6': + resolution: {integrity: sha512-j4L/0Tw6AdMVB2dDnuBlDbevyL1/0CAk88a77VF/VjgEIBwB9VXsCCUsxz+2Dohcl7N2GMm7+kpaWA6qt2PSaA==} + + '@unocss/vite@66.6.6': + resolution: {integrity: sha512-DgG7KcUUMtoDhPOlFf2l4dR+66xZ23SdZvTYpikk5nZfLCzZd62vedutD7x0bTR6VpK2YRq39B+F+Z6TktNY/w==} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0 + + '@vitejs/plugin-vue@6.0.4': + resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vue: ^3.2.25 + + '@vitest/coverage-v8@4.1.5': + resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} + peerDependencies: + '@vitest/browser': 4.1.5 + vitest: 4.1.5 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/eslint-plugin@1.6.17': + resolution: {integrity: sha512-sIVY9ZeVcXyPxFCNRkIt8Yw4keKIcUyp9/8qnmuomPwE+ST1htw5sZsbqdUMTiah9SmCg1JYoK9RqdDtPeNYYg==} + engines: {node: '>=18'} + peerDependencies: + '@typescript-eslint/eslint-plugin': '*' + eslint: '>=8.57.0' + typescript: '>=5.0.0' + vitest: '*' + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + typescript: + optional: true + vitest: + optional: true + + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.30': + resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==} + + '@vue/compiler-dom@3.5.30': + resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==} + + '@vue/compiler-sfc@3.5.30': + resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==} + + '@vue/compiler-ssr@3.5.30': + resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/eslint-config-typescript@14.7.0': + resolution: {integrity: sha512-iegbMINVc+seZ/QxtzWiOBozctrHiF2WvGedruu2EbLujg9VuU0FQiNcN2z1ycuaoKKpF4m2qzB5HDEMKbxtIg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.10.0 || ^10.0.0 + eslint-plugin-vue: ^9.28.0 || ^10.0.0 + typescript: '>=4.8.4' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.30': + resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==} + + '@vue/runtime-core@3.5.30': + resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==} + + '@vue/runtime-dom@3.5.30': + resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==} + + '@vue/server-renderer@3.5.30': + resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==} + peerDependencies: + vue: 3.5.30 + + '@vue/shared@3.5.30': + resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==} + + '@vue/tsconfig@0.7.0': + resolution: {integrity: sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==} + peerDependencies: + typescript: 5.x + vue: ^3.4.0 + peerDependenciesMeta: + typescript: + optional: true + vue: + optional: true + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats-draft2019@1.6.1: + resolution: {integrity: sha512-JQPvavpkWDvIsBp2Z33UkYCtXCSpW4HD3tAZ+oL4iEFOk9obQZffx0yANwECt6vzr6ET+7HN5czRyqXbnq/u0Q==} + peerDependencies: + ajv: '*' + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + any-base@1.1.0: + resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + are-docs-informative@0.0.2: + resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + axios-retry@4.5.0: + resolution: {integrity: sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==} + peerDependencies: + axios: 0.x || 1.x + + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.14.2: + resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.38: + resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} + engines: {node: '>=6.0.0'} + hasBin: true + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bn.js@4.12.3: + resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + brorand@1.1.0: + resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builtin-modules@5.0.0: + resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} + engines: {node: '>=18.20'} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + + c12@3.3.3: + resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + cache-manager@7.2.8: + resolution: {integrity: sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==} + + cacheable@2.3.3: + resolution: {integrity: sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + camelize-ts@3.0.0: + resolution: {integrity: sha512-cgRwKKavoDKLTjO4FQTs3dRBePZp/2Y9Xpud0FhuCOTE86M2cniKN4CCXgRnsyXNMmQMifVHcv6SPaMtTx6ofQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + check-disk-space@3.4.0: + resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==} + engines: {node: '>=16'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cidr-regex@3.1.1: + resolution: {integrity: sha512-RBqYd32aDwbCMFJRL6wHOlDNYJsPNTt8vC82ErHF5vKt8QQzxm1FrkW8s/R5pVrXMf17sba09Uoy91PKiddAsw==} + engines: {node: '>=10'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.1: + resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clean-regexp@1.0.0: + resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} + engines: {node: '>=4'} + + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + clipboard@2.0.11: + resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + comment-json@4.4.1: + resolution: {integrity: sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==} + engines: {node: '>= 6'} + + comment-parser@1.4.5: + resolution: {integrity: sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==} + engines: {node: '>= 12.0.0'} + + comment-parser@1.4.6: + resolution: {integrity: sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==} + engines: {node: '>= 12.0.0'} + + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@9.3.1: + resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} + engines: {node: '>=18'} + + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} + hasBin: true + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cron-validator@1.4.0: + resolution: {integrity: sha512-wGcJ9FCy65iaU6egSH8b5dZYJF7GU/3Jh06wzaT9lsa5dbqExjljmu+0cJ8cpKn+vUyZa/EM4WAxeLR6SypJXw==} + + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto-random-string@2.0.0: + resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} + engines: {node: '>=8'} + + css-functions-list@3.3.3: + resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} + engines: {node: '>=12'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + deeks@3.1.0: + resolution: {integrity: sha512-e7oWH1LzIdv/prMQ7pmlDlaVoL64glqzvNgkgQNgyec9ORPHrT2jaOqMtRyqJuwWjtfb6v+2rk9pmaHj+F137A==} + engines: {node: '>= 16'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegate@3.2.0: + resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + + doc-path@4.1.1: + resolution: {integrity: sha512-h1ErTglQAVv2gCnOpD3sFS6uolDbOKHDU1BZq+Kl3npPqroU3dYL42lUgMfd5UimlwtRgp7C9dLGwqQ5D2HYgQ==} + engines: {node: '>=16'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + dompurify@3.3.2: + resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} + engines: {node: '>=20'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + effect@3.18.4: + resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-to-chromium@1.5.376: + resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + + elliptic@6.6.1: + resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.24.0: + resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.1: + resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} + engines: {node: '>= 0.4'} + + es-toolkit@1.48.1: + resolution: {integrity: sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==} + + es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-flat-gitignore@2.3.0: + resolution: {integrity: sha512-bg4ZLGgoARg1naWfsINUUb/52Ksw/K22K+T16D38Y8v+/sGwwIYrGvH/JBjOin+RQtxxC9tzNNiy4shnGtGyyQ==} + peerDependencies: + eslint: ^9.5.0 || ^10.0.0 + + eslint-flat-config-utils@3.2.0: + resolution: {integrity: sha512-PHgo1X5uqIorJONLVD9BIaOSdoYFD3z/AeJljdqDPlWVRpeCYkDbK9k0AXoYVqqNJr6FEYIEr5Rm2TSktLQcHw==} + + eslint-json-compat-utils@0.2.3: + resolution: {integrity: sha512-RbBmDFyu7FqnjE8F0ZxPNzx5UaptdeS9Uu50r7A+D7s/+FCX+ybiyViYEgFUaFIFqSWJgZRTpL5d8Kanxxl2lQ==} + engines: {node: '>=12'} + peerDependencies: + '@eslint/json': '*' + eslint: '*' + jsonc-eslint-parser: ^2.4.0 || ^3.0.0 + peerDependenciesMeta: + '@eslint/json': + optional: true + + eslint-merge-processors@2.0.0: + resolution: {integrity: sha512-sUuhSf3IrJdGooquEUB5TNpGNpBoQccbnaLHsb1XkBLUPPqCNivCpY05ZcpCOiV9uHwO2yxXEWVczVclzMxYlA==} + peerDependencies: + eslint: '*' + + eslint-plugin-antfu@3.2.2: + resolution: {integrity: sha512-Qzixht2Dmd/pMbb5EnKqw2V8TiWHbotPlsORO8a+IzCLFwE0RxK8a9k4DCTFPzBwyxJzH+0m2Mn8IUGeGQkyUw==} + peerDependencies: + eslint: '*' + + eslint-plugin-command@3.5.2: + resolution: {integrity: sha512-PA59QAkQDwvcCMEt5lYLJLI3zDGVKJeC4id/pcRY2XdRYhSGW7iyYT1VC1N3bmpuvu6Qb/9QptiS3GJMjeGTJg==} + peerDependencies: + '@typescript-eslint/rule-tester': '*' + '@typescript-eslint/typescript-estree': '*' + '@typescript-eslint/utils': '*' + eslint: '*' + + eslint-plugin-depend@1.5.0: + resolution: {integrity: sha512-i3UeLYmclf1Icp35+6W7CR4Bp2PIpDgBuf/mpmXK5UeLkZlvYJ21VuQKKHHAIBKRTPivPGX/gZl5JGno1o9Y0A==} + peerDependencies: + eslint: '>=8.40.0' + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-import-lite@0.6.0: + resolution: {integrity: sha512-80vevx2A7i3H7n1/6pqDO8cc5wRz6OwLDvIyVl9UflBV1N1f46e9Ihzi65IOLYoSxM6YykK2fTw1xm0Ixx6aTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + eslint-plugin-jsdoc@62.9.0: + resolution: {integrity: sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-jsonc@3.1.2: + resolution: {integrity: sha512-dopTxdB22iuOkgKyJCupEC5IYBItUT4J/teq1H5ddUObcaYhOURxtJElZczdcYnnKCghNU/vccuyPkliy2Wxsg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-n@17.24.0: + resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=8.23.0' + + eslint-plugin-no-only-tests@3.3.0: + resolution: {integrity: sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==} + engines: {node: '>=5.0.0'} + + eslint-plugin-perfectionist@5.9.0: + resolution: {integrity: sha512-8TWzg02zmnBdZwCkWLi8jhzqXI+fE7Z/RwV8SL6xD45tJ8Bp3wGuYL2XtQgfe/Wd0eBqOUX+s6ey73IyszvKTA==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-pnpm@1.6.0: + resolution: {integrity: sha512-dxmt9r3zvPaft6IugS4i0k16xag3fTbOvm/road5uV9Y8qUCQT0xzheSh3gMlYAlC6vXRpfArBDsTZ7H7JKCbg==} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + eslint-plugin-regexp@3.1.0: + resolution: {integrity: sha512-qGXIC3DIKZHcK1H9A9+Byz9gmndY6TTSRkSMTZpNXdyCw2ObSehRgccJv35n9AdUakEjQp5VFNLas6BMXizCZg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-toml@1.3.1: + resolution: {integrity: sha512-1l00fBP03HIt9IPV7ZxBi7x0y0NMdEZmakL1jBD6N/FoKBvfKxPw5S8XkmzBecOnFBTn5Z8sNJtL5vdf9cpRMQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-unicorn@64.0.0: + resolution: {integrity: sha512-rNZwalHh8i0UfPlhNwg5BTUO1CMdKNmjqe+TgzOTZnpKoi8VBgsW7u9qCHIdpxEzZ1uwrJrPF0uRb7l//K38gA==} + engines: {node: ^20.10.0 || >=21.0.0} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-unused-imports@4.4.1: + resolution: {integrity: sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==} + peerDependencies: + '@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0 + eslint: ^10.0.0 || ^9.0.0 || ^8.0.0 + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + + eslint-plugin-vue@10.8.0: + resolution: {integrity: sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-eslint-parser: ^10.0.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-plugin-yml@3.3.1: + resolution: {integrity: sha512-isntsZchaTqDMNNkD+CakrgA/pdUoJ45USWBKpuqfAW1MCuw731xX/vrXfoJFZU3tTFr24nCbDYmDfT2+g4QtQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + eslint: '>=9.38.0' + + eslint-processor-vue-blocks@2.0.0: + resolution: {integrity: sha512-u4W0CJwGoWY3bjXAuFpc/b6eK3NQEI8MoeW7ritKj3G3z/WtHrKjkqf+wk8mPEy5rlMGS+k6AZYOw2XBoN/02Q==} + peerDependencies: + '@vue/compiler-sfc': ^3.3.0 + eslint: '>=9.0.0' + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.5.0: + resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@1.0.1: + resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eta@3.5.0: + resolution: {integrity: sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==} + engines: {node: '>=6.0.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-copy@4.0.2: + resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + + fast-jwt@5.0.6: + resolution: {integrity: sha512-LPE7OCGUl11q3ZgW681cEU2d0d2JZ37hhJAmetCgNyW8waVaJVZXhyFF6U2so1Iim58Yc7pfxJe2P7MNetQH2g==} + engines: {node: '>=20'} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastfall@1.5.1: + resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==} + engines: {node: '>=0.10.0'} + + fastify-keycloak-adapter@3.0.2: + resolution: {integrity: sha512-Zm5PowNKxgHPztM1wC0hJmnFYPrUoduREEIzxwz2dnTpMm5qZkp7btweTuGW5AzDbjBu3EVM0fNyPxyR3OkwwA==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + + fastparallel@2.4.1: + resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fastseries@1.7.2: + resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==} + + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + file-entry-cache@11.1.2: + resolution: {integrity: sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-type@21.3.0: + resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} + engines: {node: '>=20'} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flat-cache@6.1.20: + resolution: {integrity: sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + focus-trap-vue@4.1.0: + resolution: {integrity: sha512-RvK28mED+4fqoqnJQyadfoiKZU2G/QEez+EnJtdsg83DjiuKetimvDGas9ot4p5bn1J09rDmKLtKFMBkwQc+JA==} + peerDependencies: + focus-trap: ^7.0.0 + vue: ^3.0.0 + + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fork-ts-checker-webpack-plugin@9.1.0: + resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} + engines: {node: '>=14.21.3'} + peerDependencies: + typescript: '>3.6.0' + webpack: ^5.11.0 + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fp-ts@2.16.11: + resolution: {integrity: sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-monkey@1.1.0: + resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-own-enumerable-property-symbols@3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + git-raw-commits@5.0.1: + resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} + engines: {node: '>=18'} + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. + hasBin: true + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + global-prefix@4.0.0: + resolution: {integrity: sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==} + engines: {node: '>=16'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@16.2.0: + resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} + engines: {node: '>=20'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + good-listener@1.2.2: + resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grant@5.4.24: + resolution: {integrity: sha512-PD5AvSI7wgCBDi2mEd6M/TIe+70c/fVc3Ik4B0s4mloWTy9J800eUEcxivOiyqSP9wvBy2QjWq1JR8gOfDMnEg==} + engines: {node: '>=12.0.0'} + + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + + hashery@1.5.0: + resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==} + engines: {node: '>=20'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + hmac-drbg@1.0.1: + resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-tags@5.1.0: + resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==} + engines: {node: '>=20.10'} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http2-client@1.3.5: + resolution: {integrity: sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-in-the-middle@2.0.6: + resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + + import-in-the-middle@3.0.0: + resolution: {integrity: sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==} + engines: {node: '>=18'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + io-ts@2.2.22: + resolution: {integrity: sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA==} + peerDependencies: + fp-ts: ^2.5.0 + + ip-regex@4.3.0: + resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} + engines: {node: '>=8'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-builtin-module@5.0.0: + resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} + engines: {node: '>=18.20'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-cidr@4.0.2: + resolution: {integrity: sha512-z4a1ENUajDbEl/Q6/pVBpTR1nBjjEE1X7qb7bmWYanNnPoKAvUCPFKeXV6Fe4mgTkWKBqiHIcwsI3SndiO5FeA==} + engines: {node: '>=10'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@1.0.1: + resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} + engines: {node: '>=0.10.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-regexp@1.0.0: + resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} + engines: {node: '>=0.10.0'} + + is-retry-allowed@2.2.0: + resolution: {integrity: sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==} + engines: {node: '>=10'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + javascript-time-ago@2.6.4: + resolution: {integrity: sha512-7K/Z37LuwVaxxjutUDd1pXpznufPcox0b1UYu00ksAMMlV6IsxIvduwL3kgfPxuBVF8jVj7nhrKMPDslMq94aQ==} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + jsdoc-type-pratt-parser@7.1.1: + resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} + engines: {node: '>=20.0.0'} + + jsdoc-type-pratt-parser@7.2.0: + resolution: {integrity: sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==} + engines: {node: '>=20.0.0'} + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-2-csv@5.5.10: + resolution: {integrity: sha512-Dep8wO3Fr5wNjQevO2Z8Y7yeee/nYSGRsi7q6zJDKEVHxXkXT+v21vxHmDX923UzmCXXkSo62HaTz6eTWzFLaw==} + engines: {node: '>= 16'} + deprecated: A security vulnerability has been reported with the preventCsvInjection option which has been fixed in version 5.5.11. Please upgrade as soon as possible. + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-eslint-parser@3.1.0: + resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jwk-to-pem@2.0.7: + resolution: {integrity: sha512-cSVphrmWr6reVchuKQZdfSs4U9c5Y4hwZggPoz6cbVnTpAVgGRpEuQng86IyqLeGZlhTh+c4MAreB6KbdQDKHQ==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + katex@0.16.45: + resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} + hasBin: true + + keycloak-js@26.2.3: + resolution: {integrity: sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + + loader-runner@4.3.2: + resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} + engines: {node: '>=6.11.5'} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + magic-regexp@0.10.0: + resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} + + magic-string@0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mathml-tag-names@4.0.0: + resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + memfs@3.5.3: + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} + engines: {node: '>= 4.0.0'} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} + engines: {node: '>=20'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimalistic-crypto-utils@1.0.1: + resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.1: + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} + + mnemonist@0.40.4: + resolution: {integrity: sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==} + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + module-replacements@2.11.0: + resolution: {integrity: sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA==} + + moo@0.5.3: + resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.12.10: + resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + multer@2.1.1: + resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} + engines: {node: '>= 10.16.0'} + + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@5.0.9: + resolution: {integrity: sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==} + engines: {node: ^18 || >=20} + hasBin: true + + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + natural-orderby@5.0.0: + resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} + engines: {node: '>=18'} + + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + nestjs-pino@4.6.0: + resolution: {integrity: sha512-MzSgnOu9MhRT/f7MsvoDnxat11D9JRJYwL1t+tI6J44UrNz9rUVDpceEh9VFsyfiiIJKUri5S+/snMOoaWh7YA==} + engines: {node: '>= 14'} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + pino: ^7.5.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-emoji@1.11.0: + resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + + node-fetch-h2@2.3.0: + resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==} + engines: {node: 4.x || >=6.0.0} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-readfiles@0.2.0: + resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==} + + node-releases@2.0.48: + resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} + engines: {node: '>=18'} + + nodemon@3.1.14: + resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + nypm@0.6.5: + resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} + engines: {node: '>=18'} + hasBin: true + + oas-kit-common@1.0.8: + resolution: {integrity: sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==} + + oas-linter@3.2.2: + resolution: {integrity: sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==} + + oas-resolver@2.5.6: + resolution: {integrity: sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==} + hasBin: true + + oas-schema-walker@1.1.5: + resolution: {integrity: sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==} + + oas-validator@5.0.8: + resolution: {integrity: sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==} + + oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-deep-merge@2.0.0: + resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + + openapi3-ts@2.0.2: + resolution: {integrity: sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + oxc-parser@0.115.0: + resolution: {integrity: sha512-2w7Xn3CbS/zwzSY82S5WLemrRu3CT57uF7Lx8llrE/2bul6iMTcJE4Rbls7GDNbLn3ttATI68PfOz2Pt3KZ2cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-walker@0.7.0: + resolution: {integrity: sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A==} + peerDependencies: + oxc-parser: '>=0.98.0' + + p-debounce@4.0.0: + resolution: {integrity: sha512-4Ispi9I9qYGO4lueiLDhe4q4iK5ERK8reLsuzH6BPaXn53EGaua8H66PXIFGrW897hwjXp+pVLrm/DLxN0RF0A==} + engines: {node: '>=12'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-gitignore@2.0.0: + resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==} + engines: {node: '>=14'} + + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch-browser@2.2.6: + resolution: {integrity: sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==} + engines: {node: '>=8.6'} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-http@11.0.0: + resolution: {integrity: sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + pnpm-workspace-yaml@1.6.0: + resolution: {integrity: sha512-uUy4dK3E11sp7nK+hnT7uAWfkBMe00KaUw8OG3NuNlYQoTk4sc9pcdIy1+XIP85v9Tvr02mK3JPaNNrP0QyRaw==} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-html@1.8.1: + resolution: {integrity: sha512-OLF6P7qctfAWayOhLpcVnTGqVeJzu2W3WpIYelfz2+JV5oGxfkcEvweN9U4XpeqE0P98dcD9ssusGwlF0TK0uQ==} + engines: {node: ^12 || >=14} + + postcss-safe-parser@6.0.0: + resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.3.3 + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + + pretty-bytes@6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} + + prisma@6.19.2: + resolution: {integrity: sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + protobufjs@7.6.4: + resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} + engines: {node: '>=12.0.0'} + + protobufjs@8.0.0: + resolution: {integrity: sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==} + engines: {node: '>=12.0.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qified@0.6.0: + resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} + engines: {node: '>=20'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + rate-limiter-flexible@4.0.1: + resolution: {integrity: sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ==} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + re2-wasm@1.0.2: + resolution: {integrity: sha512-VXUdgSiUrE/WZXn6gUIVVIsg0+Hp6VPZPOaHCay+OuFKy6u/8ktmeNEf+U5qSA8jzGGFsg8jrDNu1BeHpz2pJA==} + engines: {node: '>=10'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + refa@0.12.1: + resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + reftools@1.1.9: + resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regexp-ast-analysis@0.7.1: + resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + + relative-time-format@1.1.12: + resolution: {integrity: sha512-qaZBjmRIuXLfuLnzgqpFdBPa5W0euSX1tMnoMUHGPphLwJmrt8xbNiOIHrlvYOD6oNJ0M5owPCZyPibI8de5pQ==} + + request-compose@2.1.7: + resolution: {integrity: sha512-27amNkWTK4Qq25XEwdmrhb4VLMiQzRSKuDfsy1o1griykcyXk5MxMHmJG+OKTRdO9PgsO7Kkn7GrEkq0UAIIMQ==} + engines: {node: '>=12.0.0'} + + request-oauth@1.0.1: + resolution: {integrity: sha512-85THTg1RgOYtqQw42JON6AqvHLptlj1biw265Tsq4fD4cPdUvhDB2Qh9NTv17yCD322ROuO9aOmpc4GyayGVBA==} + engines: {node: '>=8.0.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + + rolldown@1.0.0-rc.18: + resolution: {integrity: sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@2.80.0: + resolution: {integrity: sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==} + engines: {node: '>=10.0.0'} + hasBin: true + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + schemes@1.4.0: + resolution: {integrity: sha512-ImFy9FbCsQlVgnE3TCWmLPCFnVzx0lHL/l+umHplDqAKd0dzFpnS6lFZIpagBlYhKwzVmlV36ec0Y1XTu8JBAQ==} + + scslre@0.3.0: + resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} + engines: {node: ^14.0.0 || >=16.0.0} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + select@1.1.2: + resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + short-uuid@5.2.0: + resolution: {integrity: sha512-296/Nzi4DmANh93iYBwT4NoYRJuHnKEzefrkSagQbTH/A6NTaB68hSPDjm5IlbI5dx9FXdmtqPcj6N5H+CPm6w==} + engines: {node: '>=14'} + + should-equal@2.0.0: + resolution: {integrity: sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==} + + should-format@3.0.3: + resolution: {integrity: sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==} + + should-type-adaptors@1.1.0: + resolution: {integrity: sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==} + + should-type@1.4.0: + resolution: {integrity: sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==} + + should-util@1.0.1: + resolution: {integrity: sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==} + + should@13.2.3: + resolution: {integrity: sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + + smob@1.6.2: + resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} + engines: {node: '>=20.0.0'} + + smtp-address-parser@1.1.0: + resolution: {integrity: sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==} + engines: {node: '>=0.10'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions + + sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@4.0.0: + resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + steed@1.1.3: + resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-object@3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-comments@2.0.1: + resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} + engines: {node: '>=10'} + + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + strtok3@10.3.4: + resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} + engines: {node: '>=18'} + + stylelint-config-html@1.1.0: + resolution: {integrity: sha512-IZv4IVESjKLumUGi+HWeb7skgO6/g4VMuAYrJdlqQFndgbj6WJAXPhaysvBiXefX79upBdQVumgYcdd17gCpjQ==} + engines: {node: ^12 || >=14} + peerDependencies: + postcss-html: ^1.0.0 + stylelint: '>=14.0.0' + + stylelint-config-recommended-vue@1.6.1: + resolution: {integrity: sha512-lLW7hTIMBiTfjenGuDq2kyHA6fBWd/+Df7MO4/AWOxiFeXP9clbpKgg27kHfwA3H7UNMGC7aeP3mNlZB5LMmEQ==} + engines: {node: ^12 || >=14} + peerDependencies: + postcss-html: ^1.0.0 + stylelint: '>=14.0.0' + + stylelint-config-recommended@18.0.0: + resolution: {integrity: sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint-config-standard@40.0.0: + resolution: {integrity: sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint@17.11.0: + resolution: {integrity: sha512-/3czzmbF9XdGWvReDF3Ex4R23Ajolo7j8RB2bFNEqk6Ht356nlpVV+G5bG2Qt8AW1ofJzXztBRDnAtd7cgowWA==} + engines: {node: '>=20.19.0'} + hasBin: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@4.4.0: + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} + engines: {node: '>=20'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + swagger-schema-official@2.0.0-bab6bed: + resolution: {integrity: sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA==} + + swagger-typescript-api@13.3.1: + resolution: {integrity: sha512-fSYf55kY0nV2TxS131h35sl+HaLNiZabF+A5ffFC7TCMlHTAVCSlmsh3MPXFaWYk8CcMLSFg4q4vDlWkhBDdAA==} + engines: {node: '>=20'} + hasBin: true + + swagger-ui-dist@5.32.4: + resolution: {integrity: sha512-0AADFFQNJzExEN49SrD/34Nn9cxNxVLiydYl2MBwSZFPVXNkVwC/EFAjoezGGqE8oDegiDC+p47t8lKObCinMQ==} + + swagger2openapi@7.0.8: + resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==} + hasBin: true + + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + temp-dir@2.0.0: + resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} + engines: {node: '>=8'} + + tempy@0.6.0: + resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==} + engines: {node: '>=10'} + + terser-webpack-plugin@5.6.1: + resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + engines: {node: '>=10'} + hasBin: true + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tiny-emitter@2.1.0: + resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyduration@3.4.1: + resolution: {integrity: sha512-NemFoamVYn7TmtwZKZ3OiliM9fZkr6EWiTM+wKknco6POSy2gS689xx/pXip0JYp40HXpUw6k65CUYHWYUXdaA==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts-core@7.4.3: + resolution: {integrity: sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tldts@7.4.3: + resolution: {integrity: sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + toml-eslint-parser@1.0.3: + resolution: {integrity: sha512-A5F0cM6+mDleacLIEUkmfpkBbnHJFV1d2rprHU2MXNk7mlxHq2zGojA+SRvQD1RoMo9gqjZPWEaKG4v1BQ48lw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-declaration-location@1.0.7: + resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} + peerDependencies: + typescript: '>=4.0.0' + + ts-deepmerge@6.2.1: + resolution: {integrity: sha512-8CYSLazCyj0DJDpPIxOFzJG46r93uh6EynYjuey+bxcLltBeqZL7DMfaE5ZPzZNFlav7wx+2TDa/mBl8gkTYzw==} + engines: {node: '>=14.13.1'} + + ts-essentials@10.1.1: + resolution: {integrity: sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==} + peerDependencies: + typescript: '>=4.5.0' + peerDependenciesMeta: + typescript: + optional: true + + ts-patch@3.3.0: + resolution: {integrity: sha512-zAOzDnd5qsfEnjd9IGy1IRuvA7ygyyxxdxesbhMdutt8AHFjD8Vw8hU2rMF89HX1BKRWFYqKHrO8Q6lw0NeUZg==} + hasBin: true + + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.16.0: + resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} + engines: {node: '>=10'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + engines: {node: '>=20'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + type-level-regexp@0.1.17: + resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript-eslint@8.57.0: + resolution: {integrity: sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript-transform-paths@3.5.6: + resolution: {integrity: sha512-3eQTG6Ogt+pgPEh45uX2s9OwcfAVjWnyNO+osjYcOqYaWDVMIFUkqW8e0O1cOaVwdMqQFQf6alDT+76xmeS2Ag==} + peerDependencies: + typescript: '>=3.6.5' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + unconfig@7.5.0: + resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@7.24.5: + resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} + engines: {node: '>=20.18.1'} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + + unimport@3.14.6: + resolution: {integrity: sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==} + + unique-string@2.0.0: + resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} + engines: {node: '>=8'} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unocss@66.6.6: + resolution: {integrity: sha512-PRKK945e2oZKHV664MA5Z9CDHbvY/V79IvTOUWKZ514jpl3UsJU3sS+skgxmKJSmwrWvXE5OVcmPthJrD/7vxg==} + engines: {node: '>=14'} + peerDependencies: + '@unocss/astro': 66.6.6 + '@unocss/postcss': 66.6.6 + '@unocss/webpack': 66.6.6 + peerDependenciesMeta: + '@unocss/astro': + optional: true + '@unocss/postcss': + optional: true + '@unocss/webpack': + optional: true + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unplugin-auto-import@0.18.6: + resolution: {integrity: sha512-LMFzX5DtkTj/3wZuyG5bgKBoJ7WSgzqSGJ8ppDRdlvPh45mx6t6w3OcbExQi53n3xF5MYkNGPNR/HYOL95KL2A==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': ^3.2.2 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + + unplugin-vue-components@0.27.5: + resolution: {integrity: sha512-m9j4goBeNwXyNN8oZHHxvIIYiG8FQ9UfmKWeNllpDvhU7btKNNELGPt+o3mckQKuPwrE7e0PvCsx+IWuDSD9Vg==} + engines: {node: '>=14'} + peerDependencies: + '@babel/parser': ^7.15.8 + '@nuxt/kit': ^3.2.2 + vue: 2 || 3 + peerDependenciesMeta: + '@babel/parser': + optional: true + '@nuxt/kit': + optional: true + + unplugin@1.16.1: + resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} + engines: {node: '>=14.0.0'} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + + upath@1.2.0: + resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} + engines: {node: '>=4'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-template@3.1.1: + resolution: {integrity: sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-node@6.0.0: + resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + vite-plugin-pwa@1.2.0: + resolution: {integrity: sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@vite-pwa/assets-generator': ^1.0.0 + vite: ^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + workbox-build: ^7.4.0 + workbox-window: ^7.4.0 + peerDependenciesMeta: + '@vite-pwa/assets-generator': + optional: true + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vite@8.0.11: + resolution: {integrity: sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest-mock-extended@2.0.2: + resolution: {integrity: sha512-n3MBqVITKyclZ0n0y66hkT4UiiEYFQn9tteAnIxT0MPz1Z8nFcPUG3Cf0cZOyoPOj/cq6Ab1XFw2lM/qM5EDWQ==} + peerDependencies: + typescript: 3.x || 4.x || 5.x + vitest: '>=2.0.0' + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-eslint-parser@10.4.0: + resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue3-json-viewer@2.4.1: + resolution: {integrity: sha512-Z1sunvS58lJ3ZcpNhl3jYQapBVw2wjnXbemigfMWm3QnjCeg3CPMq8R6pxHUYahxMfPKLvrbGve6mUXqhWyLaQ==} + peerDependencies: + vue: ^3.5.16 + + vue@3.5.30: + resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + watchpack@2.5.2: + resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + webpack-node-externals@3.0.0: + resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} + engines: {node: '>=6'} + + webpack-sources@3.5.0: + resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} + engines: {node: '>=10.13.0'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + webpack@5.104.1: + resolution: {integrity: sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + wildcard-match@5.1.4: + resolution: {integrity: sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g==} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + workbox-background-sync@7.4.0: + resolution: {integrity: sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==} + + workbox-broadcast-update@7.4.0: + resolution: {integrity: sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==} + + workbox-build@7.4.0: + resolution: {integrity: sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==} + engines: {node: '>=20.0.0'} + + workbox-cacheable-response@7.4.0: + resolution: {integrity: sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==} + + workbox-core@7.4.0: + resolution: {integrity: sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==} + + workbox-expiration@7.4.0: + resolution: {integrity: sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==} + + workbox-google-analytics@7.4.0: + resolution: {integrity: sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==} + + workbox-navigation-preload@7.4.0: + resolution: {integrity: sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==} + + workbox-precaching@7.4.0: + resolution: {integrity: sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==} + + workbox-range-requests@7.4.0: + resolution: {integrity: sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==} + + workbox-recipes@7.4.0: + resolution: {integrity: sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==} + + workbox-routing@7.4.0: + resolution: {integrity: sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==} + + workbox-strategies@7.4.0: + resolution: {integrity: sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==} + + workbox-streams@7.4.0: + resolution: {integrity: sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==} + + workbox-sw@7.4.0: + resolution: {integrity: sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==} + + workbox-window@7.4.0: + resolution: {integrity: sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xbytes@1.9.1: + resolution: {integrity: sha512-29E0ygMFWrM5JW2W1ypmezjyR2FOS5aCszdLe620ymSJBoZzL0/RCLJKCoUFu3DfQGhZ/FWykEBp5dfEy6+hjA==} + engines: {node: '>=1'} + + xcase@2.0.1: + resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml-eslint-parser@2.0.0: + resolution: {integrity: sha512-h0uDm97wvT2bokfwwTmY6kJ1hp6YDFL0nRHwNKz8s/VD1FH/vvZjAKoMUE+un0eaYBSG7/c6h+lJTP+31tjgTw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yummies@7.10.0: + resolution: {integrity: sha512-BXMmEYzFr/DtSvr1VMvctPVxQ/BZnx35oY2AGs6clkqgWnflsjqfuVle+8rH2uiMenmEf4I3OH9bRRW86yYyyg==} + peerDependencies: + mobx: ^6.12.4 + react: ^18 || ^19 + peerDependenciesMeta: + mobx: + optional: true + react: + optional: true + + zod-validation-error@3.5.4: + resolution: {integrity: sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.24.4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@anatine/zod-openapi@1.14.2(openapi3-ts@2.0.2)(zod@3.25.76)': + dependencies: + openapi3-ts: 2.0.2 + ts-deepmerge: 6.2.1 + zod: 3.25.76 + + '@angular-devkit/core@19.2.17(chokidar@4.0.3)': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + jsonc-parser: 3.3.1 + picomatch: 4.0.2 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/core@19.2.19(chokidar@4.0.3)': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + jsonc-parser: 3.3.1 + picomatch: 4.0.2 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/schematics-cli@19.2.19(@types/node@24.12.0)(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) + '@inquirer/prompts': 7.3.2(@types/node@24.12.0) + ansi-colors: 4.1.3 + symbol-observable: 4.0.0 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - '@types/node' + - chokidar + + '@angular-devkit/schematics@19.2.17(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.17(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@19.2.19(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@antfu/eslint-config@8.2.0(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5)': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@clack/prompts': 1.3.0 + '@e18e/eslint-plugin': 0.3.0(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint/markdown': 8.0.1 + '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@vitest/eslint-plugin': 1.6.17(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5) + ansis: 4.2.0 + cac: 7.0.0 + eslint: 10.5.0(jiti@2.6.1) + eslint-config-flat-gitignore: 2.3.0(eslint@10.5.0(jiti@2.6.1)) + eslint-flat-config-utils: 3.2.0 + eslint-merge-processors: 2.0.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-antfu: 3.2.2(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-import-lite: 0.6.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-jsdoc: 62.9.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-jsonc: 3.1.2(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-n: 17.24.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-no-only-tests: 3.3.0 + eslint-plugin-perfectionist: 5.9.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-pnpm: 1.6.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-regexp: 3.1.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-toml: 1.3.1(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-unicorn: 64.0.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + eslint-plugin-yml: 3.3.1(eslint@10.5.0(jiti@2.6.1)) + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1)) + globals: 17.6.0 + local-pkg: 1.1.2 + parse-gitignore: 2.0.0 + toml-eslint-parser: 1.0.3 + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + yaml-eslint-parser: 2.0.0 + transitivePeerDependencies: + - '@eslint/json' + - '@typescript-eslint/rule-tester' + - '@typescript-eslint/typescript-estree' + - '@typescript-eslint/utils' + - '@vue/compiler-sfc' + - oxlint + - supports-color + - typescript + - vitest + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.2.4 + + '@antfu/utils@0.7.10': {} + + '@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)': + dependencies: + ajv: 8.20.0 + jsonpointer: 5.0.1 + leven: 3.1.0 + + '@apidevtools/json-schema-ref-parser@14.0.1': + dependencies: + '@types/json-schema': 7.0.15 + js-yaml: 4.2.0 + + '@apidevtools/openapi-schemas@2.1.0': {} + + '@apidevtools/swagger-methods@3.0.2': {} + + '@apidevtools/swagger-parser@12.1.0(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 14.0.1 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@5.5.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + debug: 4.4.3(supports-color@5.5.0) + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-wrap-function': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helper-wrap-function@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + + '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/template': 7.29.7 + + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-dotall-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-keys@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-dynamic-import@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-exponentiation-operator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-member-expression-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-amd@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-systemjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regenerator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regexp-modifiers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-reserved-words@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-spread@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-typeof-symbol@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-property-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/preset-env@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7) + '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7) + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-systemjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.7) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + core-js-compat: 3.49.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/types': 7.29.7 + esutils: 2.0.3 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@biomejs/js-api@4.0.0(@biomejs/wasm-nodejs@2.4.6)': + optionalDependencies: + '@biomejs/wasm-nodejs': 2.4.6 + + '@biomejs/wasm-nodejs@2.4.6': {} + + '@borewit/text-codec@0.2.1': {} + + '@cacheable/memory@2.0.8': + dependencies: + '@cacheable/utils': 2.4.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.4.0': + dependencies: + hashery: 1.5.0 + keyv: 5.6.0 + + '@clack/core@1.3.0': + dependencies: + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@clack/prompts@1.3.0': + dependencies: + '@clack/core': 1.3.0 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@colors/colors@1.5.0': + optional: true + + '@commitlint/cli@20.5.3(@types/node@24.12.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': + dependencies: + '@commitlint/format': 20.5.0 + '@commitlint/lint': 20.5.3 + '@commitlint/load': 20.5.3(@types/node@24.12.0)(typescript@5.9.3) + '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) + '@commitlint/types': 20.5.0 + tinyexec: 1.2.4 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-conventionalcommits: 9.3.1 + + '@commitlint/config-validator@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + ajv: 8.20.0 + + '@commitlint/ensure@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + es-toolkit: 1.48.1 + + '@commitlint/execute-rule@20.0.0': {} + + '@commitlint/format@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + semver: 7.8.5 + + '@commitlint/lint@20.5.3': + dependencies: + '@commitlint/is-ignored': 20.5.0 + '@commitlint/parse': 20.5.0 + '@commitlint/rules': 20.5.3 + '@commitlint/types': 20.5.0 + + '@commitlint/load@20.5.3(@types/node@24.12.0)(typescript@5.9.3)': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/execute-rule': 20.0.0 + '@commitlint/resolve-extends': 20.5.3 + '@commitlint/types': 20.5.0 + cosmiconfig: 9.0.2(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@24.12.0)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3) + es-toolkit: 1.48.1 + is-plain-obj: 4.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@20.4.3': {} + + '@commitlint/parse@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 + + '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': + dependencies: + '@commitlint/top-level': 20.4.3 + '@commitlint/types': 20.5.0 + git-raw-commits: 5.0.1(conventional-commits-parser@6.4.0) + minimist: 1.2.8 + tinyexec: 1.2.4 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@20.5.3': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/types': 20.5.0 + es-toolkit: 1.48.1 + global-directory: 5.0.0 + import-meta-resolve: 4.2.0 + resolve-from: 5.0.0 + + '@commitlint/rules@20.5.3': + dependencies: + '@commitlint/ensure': 20.5.3 + '@commitlint/message': 20.4.3 + '@commitlint/to-lines': 20.0.0 + '@commitlint/types': 20.5.0 + + '@commitlint/to-lines@20.0.0': {} + + '@commitlint/top-level@20.4.3': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@20.5.0': + dependencies: + conventional-commits-parser: 6.4.0 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@2.7.0(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.8.5 + optionalDependencies: + conventional-commits-parser: 6.4.0 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/css-tokenizer@4.0.0': {} + + '@csstools/media-query-list-parser@5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/selector-resolve-nested@4.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@e18e/eslint-plugin@0.3.0(eslint@10.5.0(jiti@2.6.1))': + dependencies: + eslint-plugin-depend: 1.5.0(eslint@10.5.0(jiti@2.6.1)) + optionalDependencies: + eslint: 10.5.0(jiti@2.6.1) + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@es-joy/jsdoccomment@0.84.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.61.1 + comment-parser: 1.4.5 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.1.1 + + '@es-joy/jsdoccomment@0.86.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.61.1 + comment-parser: 1.4.6 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.2.0 + + '@es-joy/resolve.exports@1.2.0': {} + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@eslint-community/eslint-plugin-eslint-comments@4.7.1(eslint@10.5.0(jiti@2.6.1))': + dependencies: + escape-string-regexp: 4.0.0 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.6.1))': + dependencies: + eslint: 10.5.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/compat@2.0.3(eslint@10.5.0(jiti@2.6.1))': + dependencies: + '@eslint/core': 1.2.1 + optionalDependencies: + eslint: 10.5.0(jiti@2.6.1) + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3(supports-color@5.5.0) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/markdown@8.0.1': + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + github-slugger: 2.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-frontmatter: 2.0.1 + mdast-util-gfm: 3.1.0 + mdast-util-math: 3.0.0 + micromark-extension-frontmatter: 2.0.0 + micromark-extension-gfm: 3.0.0 + micromark-extension-math: 3.1.0 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@exodus/schemasafe@1.3.0': {} + + '@faker-js/faker@9.9.0': {} + + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.1.1 + fastify-plugin: 5.1.0 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/formbody@8.0.2': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/helmet@13.0.2': + dependencies: + fastify-plugin: 5.1.0 + helmet: 8.1.0 + + '@fastify/jwt@9.1.0': + dependencies: + '@fastify/error': 4.2.0 + '@lukeed/ms': 2.0.2 + fast-jwt: 5.0.6 + fastify-plugin: 5.1.0 + steed: 1.1.3 + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/otel@0.18.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/session@11.1.1': + dependencies: + fastify-plugin: 5.1.0 + safe-stable-stringify: 2.5.0 + + '@fastify/static@9.1.3': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@fastify/swagger-ui@5.2.6': + dependencies: + '@fastify/static': 9.1.3 + fastify-plugin: 5.1.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + + '@fastify/swagger@9.7.0': + dependencies: + fastify-plugin: 5.1.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + transitivePeerDependencies: + - supports-color + + '@gitbeaker/core@40.6.0': + dependencies: + '@gitbeaker/requester-utils': 40.6.0 + qs: 6.15.2 + xcase: 2.0.1 + + '@gitbeaker/requester-utils@40.6.0': + dependencies: + picomatch-browser: 2.2.6 + qs: 6.15.2 + rate-limiter-flexible: 4.0.1 + xcase: 2.0.1 + + '@gitbeaker/rest@40.6.0': + dependencies: + '@gitbeaker/core': 40.6.0 + '@gitbeaker/requester-utils': 40.6.0 + + '@gouvfr/dsfr@1.14.4': {} + + '@gouvminint/vue-dsfr@8.15.0(@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3)))(vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@gouvfr/dsfr': 1.14.4 + '@iconify/vue': 4.3.0(vue@3.5.30(typescript@5.9.3)) + focus-trap: 7.8.0 + focus-trap-vue: 4.1.0(focus-trap@7.8.0)(vue@3.5.30(typescript@5.9.3)) + vue: 3.5.30(typescript@5.9.3) + vue-router: 4.6.4(vue@3.5.30(typescript@5.9.3)) + + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.4 + yargs: 17.7.3 + + '@himenon/argocd-typescript-openapi@1.2.2': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@iconify-json/ri@1.2.10': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.1 + + '@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@iconify/types': 2.0.0 + vue: 3.5.30(typescript@5.9.3) + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/confirm@5.1.21(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/core@10.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/editor@4.2.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/external-editor': 1.0.3(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/expand@4.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/external-editor@1.0.3(@types/node@24.12.0)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/number@3.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/password@4.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/prompts@7.10.1(@types/node@24.12.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.12.0) + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@inquirer/editor': 4.2.23(@types/node@24.12.0) + '@inquirer/expand': 4.0.23(@types/node@24.12.0) + '@inquirer/input': 4.3.1(@types/node@24.12.0) + '@inquirer/number': 3.0.23(@types/node@24.12.0) + '@inquirer/password': 4.0.23(@types/node@24.12.0) + '@inquirer/rawlist': 4.1.11(@types/node@24.12.0) + '@inquirer/search': 3.2.2(@types/node@24.12.0) + '@inquirer/select': 4.4.2(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/prompts@7.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.12.0) + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@inquirer/editor': 4.2.23(@types/node@24.12.0) + '@inquirer/expand': 4.0.23(@types/node@24.12.0) + '@inquirer/input': 4.3.1(@types/node@24.12.0) + '@inquirer/number': 3.0.23(@types/node@24.12.0) + '@inquirer/password': 4.0.23(@types/node@24.12.0) + '@inquirer/rawlist': 4.1.11(@types/node@24.12.0) + '@inquirer/search': 3.2.2(@types/node@24.12.0) + '@inquirer/select': 4.4.2(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/rawlist@4.1.11(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/search@3.2.2(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/select@4.4.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/type@3.0.10(@types/node@24.12.0)': + optionalDependencies: + '@types/node': 24.12.0 + + '@isaacs/cliui@9.0.0': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@js-sdsl/ordered-map@4.4.2': {} + + '@keycloak/keycloak-admin-client@26.7.0': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + '@microsoft/kiota-http-fetchlibrary': 1.0.0-preview.103 + '@microsoft/kiota-serialization-form': 1.0.0-preview.103 + '@microsoft/kiota-serialization-json': 1.0.0-preview.103 + '@microsoft/kiota-serialization-multipart': 1.0.0-preview.103 + '@microsoft/kiota-serialization-text': 1.0.0-preview.103 + camelize-ts: 3.0.0 + url-template: 3.1.1 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@kubernetes-models/apimachinery@2.2.0': + dependencies: + '@kubernetes-models/base': 5.0.1 + '@kubernetes-models/validate': 4.0.0 + '@swc/helpers': 0.5.19 + + '@kubernetes-models/argo-cd@2.7.2': + dependencies: + '@kubernetes-models/apimachinery': 2.2.0 + '@kubernetes-models/base': 5.0.1 + '@kubernetes-models/validate': 4.0.0 + '@swc/helpers': 0.5.19 + + '@kubernetes-models/base@5.0.1': + dependencies: + '@kubernetes-models/validate': 4.0.0 + is-plain-object: 5.0.0 + tslib: 2.8.1 + + '@kubernetes-models/validate@4.0.0': + dependencies: + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-formats-draft2019: 1.6.1(ajv@8.20.0) + ajv-i18n: 4.2.0(ajv@8.20.0) + is-cidr: 4.0.2 + tslib: 2.8.1 + optionalDependencies: + re2-wasm: 1.0.2 + + '@lukeed/csprng@1.1.0': {} + + '@lukeed/ms@2.0.2': {} + + '@microsoft/kiota-abstractions@1.0.0-preview.103': + dependencies: + '@opentelemetry/api': 1.9.1 + '@std-uritemplate/std-uritemplate': 2.0.12 + tinyduration: 3.4.1 + tslib: 2.8.1 + + '@microsoft/kiota-http-fetchlibrary@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + '@opentelemetry/api': 1.9.1 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-form@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-json@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-multipart@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-text@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/tsdoc@0.16.0': {} + + '@mswjs/interceptors@0.41.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@nestjs/cache-manager@3.1.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cache-manager: 7.2.8 + keyv: 5.6.0 + rxjs: 7.8.2 + + '@nestjs/cli@11.0.16(@types/node@24.12.0)(esbuild@0.27.3)(lightningcss@1.32.0)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics-cli': 19.2.19(@types/node@24.12.0)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@24.12.0) + '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.9.3) + ansis: 4.2.0 + chokidar: 4.0.3 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)) + glob: 13.0.0 + node-emoji: 1.11.0 + ora: 5.4.1 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.9.3 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/html' + - '@types/node' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + - webpack-cli + + '@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.0 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/config@4.0.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.2.3 + dotenv-expand: 12.0.3 + lodash: 4.17.23 + rxjs: 7.8.2 + + '@nestjs/core@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxt/opencollective': 0.4.1 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.3.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + + '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + eventemitter2: 6.4.9 + + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.6 + express: 5.2.1 + multer: 2.1.1 + path-to-regexp: 8.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@nestjs/platform-fastify@11.1.27(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@fastify/cors': 11.2.0 + '@fastify/formbody': 8.0.2 + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-querystring: 1.1.2 + fastify: 5.8.5 + fastify-plugin: 6.0.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + path-to-regexp: 8.4.2 + reusify: 1.1.0 + tslib: 2.8.1 + optionalDependencies: + '@fastify/static': 9.1.3 + + '@nestjs/schedule@6.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 + + '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': + dependencies: + '@angular-devkit/core': 19.2.17(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.17(chokidar@4.0.3) + comment-json: 4.4.1 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.9.3 + transitivePeerDependencies: + - chokidar + + '@nestjs/swagger@11.4.1(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.4 + optionalDependencies: + '@fastify/static': 9.1.3 + + '@nestjs/terminus@11.1.1(@grpc/grpc-js@1.14.3)(@grpc/proto-loader@0.8.0)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + boxen: 5.1.2 + check-disk-space: 3.4.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + optionalDependencies: + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.8.0 + '@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + + '@nestjs/testing@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nuxt/opencollective@0.4.1': + dependencies: + consola: 3.4.2 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@opentelemetry/api-logs@0.212.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.213.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.214.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/auto-instrumentations-node@0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-amqplib': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-lambda': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-sdk': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-bunyan': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cassandra-driver': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-connect': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cucumber': 0.28.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dataloader': 0.29.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dns': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-express': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fastify': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fs': 0.31.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-generic-pool': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-graphql': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-hapi': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-kafkajs': 0.21.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-knex': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-koa': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-lru-memoizer': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-memcached': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongodb': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongoose': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql2': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-net': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-openai': 0.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-oracledb': 0.37.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-redis': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-restify': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-router': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-runtime-node': 0.25.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-socket.io': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-tedious': 0.31.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': 0.22.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-winston': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-alibaba-cloud': 0.33.3(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-aws': 2.13.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-azure': 0.20.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-container': 0.8.4(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-gcp': 0.47.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/configuration@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + yaml: 2.9.0 + + '@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/exporter-logs-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/exporter-trace-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/instrumentation-amqplib@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-lambda@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/aws-lambda': 8.10.161 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-sdk@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-bunyan@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@types/bunyan': 1.8.11 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cassandra-driver@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cucumber@0.28.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.29.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dns@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-express@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fastify@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.31.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.21.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-memcached@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/memcached': 2.2.10 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/mysql': 2.15.27 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-nestjs-core@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-net@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-openai@0.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-oracledb@0.37.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/oracledb': 6.5.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-restify@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-router@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-runtime-node@0.25.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-socket.io@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-tedious@0.31.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.22.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-winston@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + import-in-the-middle: 3.0.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + import-in-the-middle: 3.0.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + protobufjs: 8.0.0 + + '@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + protobufjs: 7.6.4 + + '@opentelemetry/propagator-b3@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/redis-common@0.38.2': {} + + '@opentelemetry/resource-detector-alibaba-cloud@0.33.3(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resource-detector-aws@2.13.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resource-detector-azure@0.20.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resource-detector-container@0.8.4(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resource-detector-gcp@0.47.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + gcp-metadata: 8.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-logs@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-metrics@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/configuration': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-trace-node@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/semantic-conventions@1.41.1': {} + + '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + + '@ota-meshi/ast-token-store@0.3.0': {} + + '@oxc-parser/binding-android-arm-eabi@0.115.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.115.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.115.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.115.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.115.0': + optional: true + + '@oxc-project/types@0.115.0': {} + + '@oxc-project/types@0.128.0': {} + + '@pinojs/redact@0.4.0': {} + + '@pkgr/core@0.2.9': {} + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + '@polka/url@1.0.0-next.29': {} + + '@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@6.19.2(magicast@0.3.5)': + dependencies: + c12: 3.1.0(magicast@0.3.5) + deepmerge-ts: 7.1.5 + effect: 3.18.4 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.2': {} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.2 + '@prisma/get-platform': 6.19.2 + + '@prisma/fetch-engine@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.2 + + '@prisma/get-platform@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.18': {} + + '@rolldown/pluginutils@1.0.0-rc.2': {} + + '@rollup/plugin-babel@5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.80.0)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@rollup/pluginutils': 3.1.0(rollup@2.80.0) + rollup: 2.80.0 + optionalDependencies: + '@types/babel__core': 7.20.5 + transitivePeerDependencies: + - supports-color + + '@rollup/plugin-node-resolve@15.3.1(rollup@2.80.0)': + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.12 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/plugin-replace@2.4.2(rollup@2.80.0)': + dependencies: + '@rollup/pluginutils': 3.1.0(rollup@2.80.0) + magic-string: 0.25.9 + rollup: 2.80.0 + + '@rollup/plugin-terser@0.4.4(rollup@2.80.0)': + dependencies: + serialize-javascript: 6.0.2 + smob: 1.6.2 + terser: 5.48.0 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/pluginutils@3.1.0(rollup@2.80.0)': + dependencies: + '@types/estree': 0.0.39 + estree-walker: 1.0.1 + picomatch: 2.3.2 + rollup: 2.80.0 + + '@rollup/pluginutils@5.4.0(rollup@2.80.0)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@scarf/scarf@1.4.0': {} + + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 + + '@simple-libs/stream-utils@1.2.0': {} + + '@sindresorhus/base62@1.0.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@std-uritemplate/std-uritemplate@2.0.12': {} + + '@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/types': 8.61.1 + eslint: 10.5.0(jiti@2.6.1) + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + estraverse: 5.3.0 + picomatch: 4.0.4 + + '@surma/rollup-plugin-off-main-thread@2.2.3': + dependencies: + ejs: 3.1.10 + json5: 2.2.3 + magic-string: 0.25.9 + string.prototype.matchall: 4.0.12 + + '@swc/helpers@0.5.19': + dependencies: + tslib: 2.8.1 + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3(supports-color@5.5.0) + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76)': + optionalDependencies: + '@types/node': 24.12.0 + zod: 3.25.76 + + '@ts-rest/fastify@3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76)': + dependencies: + '@ts-rest/core': 3.52.1(@types/node@24.12.0)(zod@3.25.76) + fastify: 5.8.5 + optionalDependencies: + zod: 3.25.76 + + '@ts-rest/open-api@3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76)': + dependencies: + '@anatine/zod-openapi': 1.14.2(openapi3-ts@2.0.2)(zod@3.25.76) + '@ts-rest/core': 3.52.1(@types/node@24.12.0)(zod@3.25.76) + openapi3-ts: 2.0.2 + zod: 3.25.76 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aws-lambda@8.10.161': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + optional: true + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + optional: true + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + optional: true + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + optional: true + + '@types/bunyan@1.8.11': + dependencies: + '@types/node': 24.12.0 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.12.0 + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.9 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + + '@types/esrecurse@4.3.1': {} + + '@types/estree@0.0.39': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/jsdom@21.1.7': + dependencies: + '@types/node': 24.12.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.12.0 + + '@types/katex@0.16.8': {} + + '@types/luxon@3.7.1': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/memcached@2.2.10': + dependencies: + '@types/node': 24.12.0 + + '@types/ms@2.1.0': {} + + '@types/mysql@2.15.27': + dependencies: + '@types/node': 24.12.0 + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + '@types/oracledb@6.5.2': + dependencies: + '@types/node': 24.12.0 + + '@types/pg-pool@2.0.7': + dependencies: + '@types/pg': 8.15.6 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 24.12.0 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + + '@types/resolve@1.20.2': {} + + '@types/statuses@2.0.6': {} + + '@types/swagger-schema-official@2.0.25': {} + + '@types/tedious@4.0.14': + dependencies: + '@types/node': 24.12.0 + + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': {} + + '@types/unist@3.0.3': {} + + '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/type-utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.0 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/type-utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.57.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3(supports-color@5.5.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3(supports-color@5.5.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + ajv: 6.15.0 + eslint: 10.5.0(jiti@2.6.1) + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/scope-manager@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + + '@typescript-eslint/scope-manager@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + + '@typescript-eslint/tsconfig-utils@8.57.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.57.0': {} + + '@typescript-eslint/types@8.59.2': {} + + '@typescript-eslint/types@8.61.1': {} + + '@typescript-eslint/typescript-estree@8.57.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.57.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + eslint-visitor-keys: 5.0.1 + + '@typescript-eslint/visitor-keys@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + eslint-visitor-keys: 5.0.1 + + '@unocss/cli@66.6.6': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + '@unocss/preset-wind4': 66.6.6 + '@unocss/transformer-directives': 66.6.6 + cac: 6.7.14 + chokidar: 5.0.0 + colorette: 2.0.20 + consola: 3.4.2 + magic-string: 0.30.21 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + tinyglobby: 0.2.17 + unplugin-utils: 0.3.1 + + '@unocss/config@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + colorette: 2.0.20 + consola: 3.4.2 + unconfig: 7.5.0 + + '@unocss/core@0.65.4': {} + + '@unocss/core@66.6.6': {} + + '@unocss/extractor-arbitrary-variants@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/inspector@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + colorette: 2.0.20 + gzip-size: 6.0.0 + sirv: 3.0.2 + + '@unocss/preset-attributify@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/preset-icons@66.6.6': + dependencies: + '@iconify/utils': 3.1.0 + '@unocss/core': 66.6.6 + ofetch: 1.5.1 + + '@unocss/preset-mini@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/extractor-arbitrary-variants': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-tagify@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/preset-typography@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-uno@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + + '@unocss/preset-web-fonts@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + ofetch: 1.5.1 + + '@unocss/preset-wind3@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-mini': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-wind4@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/extractor-arbitrary-variants': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-wind@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + + '@unocss/rule-utils@0.65.4': + dependencies: + '@unocss/core': 0.65.4 + magic-string: 0.30.21 + + '@unocss/rule-utils@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + magic-string: 0.30.21 + + '@unocss/transformer-attributify-jsx@66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@unocss/core': 66.6.6 + oxc-parser: 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + oxc-walker: 0.7.0(oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@unocss/transformer-compile-class@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/transformer-directives@0.65.4': + dependencies: + '@unocss/core': 0.65.4 + '@unocss/rule-utils': 0.65.4 + css-tree: 3.2.1 + + '@unocss/transformer-directives@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + css-tree: 3.2.1 + + '@unocss/transformer-variant-group@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/vite@66.6.6(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/inspector': 66.6.6 + chokidar: 5.0.0 + magic-string: 0.30.21 + pathe: 2.0.3 + tinyglobby: 0.2.17 + unplugin-utils: 0.3.1 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + + '@vitejs/plugin-vue@6.0.4(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vue: 3.5.30(typescript@5.9.3) + + '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.5 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + '@vitest/eslint-plugin@1.6.17(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + typescript: 5.9.3 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + + '@vitest/mocker@4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.30': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.30 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.30': + dependencies: + '@vue/compiler-core': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-sfc@3.5.30': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.30 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.30': + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + fast-glob: 3.3.3 + typescript-eslint: 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.30 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.30': + dependencies: + '@vue/shared': 3.5.30 + + '@vue/runtime-core@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/runtime-dom@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/runtime-core': 3.5.30 + '@vue/shared': 3.5.30 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + vue: 3.5.30(typescript@5.9.3) + + '@vue/shared@3.5.30': {} + + '@vue/tsconfig@0.7.0(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))': + optionalDependencies: + typescript: 5.9.3 + vue: 3.5.30(typescript@5.9.3) + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + abstract-logging@2.0.1: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + optional: true + + acorn-import-attributes@1.9.5(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-import-phases@1.0.4(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + agent-base@7.1.4: {} + + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats-draft2019@1.6.1(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + punycode: 2.3.1 + schemes: 1.4.0 + smtp-address-parser: 1.1.0 + uri-js: 4.4.1 + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-i18n@4.2.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv-keywords@3.5.2(ajv@6.15.0): + dependencies: + ajv: 6.15.0 + + ajv-keywords@5.1.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + fast-deep-equal: 3.1.3 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alien-signals@1.0.13: {} + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-colors@4.1.3: {} + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + ansis@4.2.0: {} + + any-base@1.1.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + append-field@1.0.0: + optional: true + + are-docs-informative@0.0.2: {} + + argparse@2.0.1: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-ify@1.0.0: {} + + array-timsort@1.0.3: {} + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + astral-regex@2.0.0: {} + + async-function@1.0.0: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + atomic-sleep@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + axios-retry@4.5.0(axios@1.16.0): + dependencies: + axios: 1.16.0 + is-retry-allowed: 2.2.0 + + axios@1.16.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.38: {} + + bignumber.js@9.3.1: {} + + binary-extensions@2.3.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bn.js@4.12.3: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + optional: true + + boolbase@1.0.0: {} + + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + brorand@1.1.0: + optional: true + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.38 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.376 + node-releases: 2.0.48 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builtin-modules@5.0.0: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + optional: true + + bytes@3.1.2: {} + + c12@3.1.0(magicast@0.3.5): + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.3.5 + + c12@3.3.3(magicast@0.5.3): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 17.3.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.5.3 + + cac@6.7.14: {} + + cac@7.0.0: {} + + cache-manager@7.2.8: + dependencies: + '@cacheable/utils': 2.4.0 + keyv: 5.6.0 + + cacheable@2.3.3: + dependencies: + '@cacheable/memory': 2.0.8 + '@cacheable/utils': 2.4.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.6.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + call-me-maybe@1.0.2: {} + + callsites@3.1.0: {} + + camelcase@6.3.0: {} + + camelize-ts@3.0.0: {} + + caniuse-lite@1.0.30001799: {} + + ccount@2.0.1: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + change-case@5.4.4: {} + + character-entities@2.0.2: {} + + chardet@2.1.1: {} + + check-disk-space@3.4.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chrome-trace-event@1.0.4: {} + + ci-info@4.4.0: {} + + cidr-regex@3.1.1: + dependencies: + ip-regex: 4.3.0 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.1: {} + + cjs-module-lexer@2.2.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clean-regexp@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + + cli-boxes@2.2.1: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.1 + + cli-width@4.1.0: {} + + clipboard@2.0.11: + dependencies: + good-listener: 1.2.2 + select: 1.1.2 + tiny-emitter: 2.1.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@14.0.3: {} + + commander@2.20.3: {} + + commander@4.1.1: {} + + commander@8.3.0: {} + + comment-json@4.4.1: + dependencies: + array-timsort: 1.0.3 + core-util-is: 1.0.3 + esprima: 4.0.1 + + comment-parser@1.4.5: {} + + comment-parser@1.4.6: {} + + common-tags@1.8.2: {} + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + optional: true + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + consola@3.4.2: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: + optional: true + + content-type@2.0.0: + optional: true + + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: + optional: true + + cookie@0.7.2: + optional: true + + cookie@1.1.1: {} + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.2 + + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + optional: true + + cosmiconfig-typescript-loader@6.3.0(@types/node@24.12.0)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3): + dependencies: + '@types/node': 24.12.0 + cosmiconfig: 9.0.2(typescript@5.9.3) + jiti: 2.6.1 + typescript: 5.9.3 + + cosmiconfig@8.3.6(typescript@5.9.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.2.0 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.3 + + cosmiconfig@9.0.2(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + cron-validator@1.4.0: {} + + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypto-random-string@2.0.0: {} + + css-functions-list@3.3.3: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-uri-to-buffer@4.0.1: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + date-fns@4.1.0: {} + + dateformat@4.6.3: {} + + dayjs@1.11.19: {} + + de-indent@1.0.2: {} + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + decimal.js@10.6.0: {} + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + deeks@3.1.0: {} + + deep-is@0.1.4: {} + + deepmerge-ts@7.1.5: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + defu@6.1.4: {} + + delayed-stream@1.0.0: {} + + delegate@3.2.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff-sequences@29.6.3: {} + + discontinuous-range@1.0.0: {} + + doc-path@4.1.1: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + dompurify@3.3.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.2.3: {} + + dotenv@17.3.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer@0.1.2: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: + optional: true + + effect@3.18.4: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-to-chromium@1.5.376: {} + + elliptic@6.6.1: + dependencies: + bn.js: 4.12.3 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + optional: true + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + empathic@2.0.0: {} + + encodeurl@2.0.0: + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.24.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + env-paths@2.2.1: {} + + environment@1.1.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.1 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.22 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.1.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-to-primitive@1.3.1: + dependencies: + es-abstract-get: 1.0.0 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es-toolkit@1.48.1: {} + + es6-promise@3.3.1: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-compat-utils@0.5.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + semver: 7.8.5 + + eslint-config-flat-gitignore@2.3.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/compat': 2.0.3(eslint@10.5.0(jiti@2.6.1)) + eslint: 10.5.0(jiti@2.6.1) + + eslint-flat-config-utils@3.2.0: + dependencies: + '@eslint/config-helpers': 0.5.5 + pathe: 2.0.3 + + eslint-json-compat-utils@0.2.3(eslint@10.5.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + esquery: 1.7.0 + jsonc-eslint-parser: 3.1.0 + + eslint-merge-processors@2.0.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-antfu@3.2.2(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-command@3.5.2(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@es-joy/jsdoccomment': 0.84.0 + '@typescript-eslint/rule-tester': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-depend@1.5.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + empathic: 2.0.0 + eslint: 10.5.0(jiti@2.6.1) + module-replacements: 2.11.0 + semver: 7.8.5 + + eslint-plugin-es-x@7.8.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + eslint: 10.5.0(jiti@2.6.1) + eslint-compat-utils: 0.5.1(eslint@10.5.0(jiti@2.6.1)) + + eslint-plugin-import-lite@0.6.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-jsdoc@62.9.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@es-joy/jsdoccomment': 0.86.0 + '@es-joy/resolve.exports': 1.2.0 + are-docs-informative: 0.0.2 + comment-parser: 1.4.6 + debug: 4.4.3(supports-color@5.5.0) + escape-string-regexp: 4.0.0 + eslint: 10.5.0(jiti@2.6.1) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.0 + parse-imports-exports: 0.2.4 + semver: 7.8.5 + spdx-expression-parse: 4.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color + + eslint-plugin-jsonc@3.1.2(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + diff-sequences: 29.6.3 + eslint: 10.5.0(jiti@2.6.1) + eslint-json-compat-utils: 0.2.3(eslint@10.5.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0) + jsonc-eslint-parser: 3.1.0 + natural-compare: 1.4.0 + synckit: 0.11.12 + transitivePeerDependencies: + - '@eslint/json' + + eslint-plugin-n@17.24.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + enhanced-resolve: 5.24.0 + eslint: 10.5.0(jiti@2.6.1) + eslint-plugin-es-x: 7.8.0(eslint@10.5.0(jiti@2.6.1)) + get-tsconfig: 4.13.6 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.8.5 + ts-declaration-location: 1.0.7(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + eslint-plugin-no-only-tests@3.3.0: {} + + eslint-plugin-perfectionist@5.9.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + natural-orderby: 5.0.0 + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-pnpm@1.6.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + empathic: 2.0.0 + eslint: 10.5.0(jiti@2.6.1) + jsonc-eslint-parser: 3.1.0 + pathe: 2.0.3 + pnpm-workspace-yaml: 1.6.0 + tinyglobby: 0.2.17 + yaml: 2.9.0 + yaml-eslint-parser: 2.0.0 + + eslint-plugin-regexp@3.1.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + comment-parser: 1.4.6 + eslint: 10.5.0(jiti@2.6.1) + jsdoc-type-pratt-parser: 7.2.0 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + scslre: 0.3.0 + + eslint-plugin-toml@1.3.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + toml-eslint-parser: 1.0.3 + transitivePeerDependencies: + - supports-color + + eslint-plugin-unicorn@64.0.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + change-case: 5.4.4 + ci-info: 4.4.0 + clean-regexp: 1.0.0 + core-js-compat: 3.49.0 + eslint: 10.5.0(jiti@2.6.1) + find-up-simple: 1.0.1 + globals: 17.6.0 + indent-string: 5.0.0 + is-builtin-module: 5.0.0 + jsesc: 3.1.0 + pluralize: 8.0.0 + regexp-tree: 0.1.27 + regjsparser: 0.13.2 + semver: 7.8.5 + strip-indent: 4.1.1 + + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + + eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + eslint: 10.5.0(jiti@2.6.1) + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.1 + semver: 7.8.5 + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + xml-name-validator: 4.0.0 + optionalDependencies: + '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + + eslint-plugin-yml@3.3.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + debug: 4.4.3(supports-color@5.5.0) + diff-sequences: 29.6.3 + escape-string-regexp: 5.0.0 + eslint: 10.5.0(jiti@2.6.1) + natural-compare: 1.4.0 + yaml-eslint-parser: 2.0.0 + transitivePeerDependencies: + - supports-color + + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@vue/compiler-sfc': 3.5.30 + eslint: 10.5.0(jiti@2.6.1) + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.5.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@5.5.0) + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-walker@1.0.1: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + eta@3.5.0: {} + + etag@1.8.1: + optional: true + + eventemitter2@6.4.9: {} + + eventemitter3@5.0.4: {} + + events@3.3.0: {} + + expect-type@1.3.0: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + optional: true + + exsolve@1.0.8: {} + + extend@3.0.2: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-copy@4.0.2: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-jwt@5.0.6: + dependencies: + '@lukeed/ms': 2.0.2 + asn1.js: 5.4.1 + ecdsa-sig-formatter: 1.0.11 + mnemonist: 0.40.4 + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.2: {} + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + + fastest-levenshtein@1.0.16: {} + + fastfall@1.5.1: + dependencies: + reusify: 1.1.0 + + fastify-keycloak-adapter@3.0.2: + dependencies: + '@fastify/cookie': 11.0.2 + '@fastify/jwt': 9.1.0 + '@fastify/session': 11.1.1 + axios: 1.16.0 + axios-retry: 4.5.0(axios@1.16.0) + fastify-plugin: 5.1.0 + fp-ts: 2.16.11 + grant: 5.4.24 + io-ts: 2.2.22(fp-ts@2.16.11) + qs: 6.15.2 + wildcard-match: 5.1.4 + transitivePeerDependencies: + - debug + + fastify-plugin@5.1.0: {} + + fastify-plugin@6.0.0: {} + + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 9.14.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.0 + + fastparallel@2.4.1: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fastseries@1.7.2: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + + fault@2.0.1: + dependencies: + format: 0.2.2 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + file-entry-cache@11.1.2: + dependencies: + flat-cache: 6.1.20 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-type@21.3.0: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.4 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + optional: true + + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + find-up-simple@1.0.1: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flat-cache@6.1.20: + dependencies: + cacheable: 2.3.3 + flatted: 3.4.2 + hookified: 1.15.1 + + flatted@3.4.2: {} + + focus-trap-vue@4.1.0(focus-trap@7.8.0)(vue@3.5.30(typescript@5.9.3)): + dependencies: + focus-trap: 7.8.0 + vue: 3.5.30(typescript@5.9.3) + + focus-trap@7.8.0: + dependencies: + tabbable: 6.4.0 + + follow-redirects@1.16.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)): + dependencies: + '@babel/code-frame': 7.29.7 + chalk: 4.1.2 + chokidar: 4.0.3 + cosmiconfig: 8.3.6(typescript@5.9.3) + deepmerge: 4.3.1 + fs-extra: 10.1.0 + memfs: 3.5.3 + minimatch: 3.1.5 + node-abort-controller: 3.1.1 + schema-utils: 3.3.0 + semver: 7.8.5 + tapable: 2.3.3 + typescript: 5.9.3 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + format@0.2.2: {} + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded-parse@2.1.2: {} + + forwarded@0.2.0: + optional: true + + fp-ts@2.16.11: {} + + fresh@2.0.0: + optional: true + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-monkey@1.1.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-own-enumerable-property-symbols@3.0.2: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.5 + pathe: 2.0.3 + + git-raw-commits@5.0.1(conventional-commits-parser@6.4.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + github-slugger@2.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + glob@13.0.0: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + global-directory@5.0.0: + dependencies: + ini: 6.0.0 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + global-prefix@4.0.0: + dependencies: + ini: 4.1.3 + kind-of: 6.0.3 + which: 4.0.0 + + globals@14.0.0: {} + + globals@15.15.0: {} + + globals@16.5.0: {} + + globals@17.6.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@16.2.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + + globjoin@0.1.4: {} + + globrex@0.1.2: {} + + good-listener@1.2.2: + dependencies: + delegate: 3.2.0 + + google-logging-utils@1.1.3: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + grant@5.4.24: + dependencies: + qs: 6.15.2 + request-compose: 2.1.7 + request-oauth: 1.0.1 + optionalDependencies: + cookie: 0.7.2 + cookie-signature: 1.2.2 + jwk-to-pem: 2.0.7 + jws: 4.0.1 + + graphql@16.14.2: {} + + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + + has-bigints@1.1.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-flag@5.0.1: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + optional: true + + hashery@1.5.0: + dependencies: + hookified: 1.15.1 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + headers-polyfill@4.0.3: {} + + helmet@8.1.0: {} + + help-me@5.0.0: {} + + hmac-drbg@1.0.1: + dependencies: + hash.js: 1.1.7 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + optional: true + + hookified@1.15.1: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + html-entities@2.6.0: {} + + html-escaper@2.0.2: {} + + html-tags@5.1.0: {} + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + http2-client@1.3.5: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + husky@9.1.7: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + idb@7.1.1: {} + + ieee754@1.2.1: {} + + ignore-by-default@1.0.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immediate@3.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-in-the-middle@2.0.6: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + import-in-the-middle@3.0.0: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + import-meta-resolve@4.2.0: {} + + imurmurhash@0.1.4: {} + + indent-string@5.0.0: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ini@4.1.3: {} + + ini@6.0.0: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + + io-ts@2.2.22(fp-ts@2.16.11): + dependencies: + fp-ts: 2.16.11 + + ip-regex@4.3.0: {} + + ipaddr.js@1.9.1: + optional: true + + ipaddr.js@2.4.0: {} + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-builtin-module@5.0.0: + dependencies: + builtin-modules: 5.0.0 + + is-callable@1.2.7: {} + + is-cidr@4.0.2: + dependencies: + cidr-regex: 3.1.1 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-map@2.0.3: {} + + is-module@1.0.0: {} + + is-negative-zero@2.0.3: {} + + is-node-process@1.2.0: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-obj@1.0.1: {} + + is-obj@2.0.0: {} + + is-path-inside@4.0.0: {} + + is-plain-obj@4.1.0: {} + + is-plain-object@5.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: + optional: true + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-regexp@1.0.0: {} + + is-retry-allowed@2.2.0: {} + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-unicode-supported@0.1.0: {} + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterare@1.2.1: {} + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + javascript-time-ago@2.6.4: + dependencies: + relative-time-format: 1.1.12 + + jest-worker@27.5.1: + dependencies: + '@types/node': 24.12.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jiti@2.6.1: {} + + joycon@3.1.1: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + jsdoc-type-pratt-parser@7.1.1: {} + + jsdoc-type-pratt-parser@7.2.0: {} + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-2-csv@5.5.10: + dependencies: + deeks: 3.1.0 + doc-path: 4.1.1 + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + fast-uri: 3.1.2 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema@0.4.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-eslint-parser@3.1.0: + dependencies: + acorn: 8.17.0 + eslint-visitor-keys: 5.0.1 + semver: 7.8.5 + + jsonc-parser@3.3.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonpointer@5.0.1: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwk-to-pem@2.0.7: + dependencies: + asn1.js: 5.4.1 + elliptic: 6.6.1 + safe-buffer: 5.2.1 + optional: true + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + katex@0.16.45: + dependencies: + commander: 8.3.0 + + keycloak-js@26.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.2.4 + yaml: 2.9.0 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + load-esm@1.0.3: {} + + loader-runner@4.3.2: {} + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.1 + pkg-types: 1.3.1 + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.1 + pkg-types: 2.3.0 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.debounce@4.0.8: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash.sortby@4.7.0: {} + + lodash.truncate@4.4.2: {} + + lodash@4.17.23: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + long@5.3.2: {} + + longest-streak@3.1.0: {} + + lru-cache@10.4.3: {} + + lru-cache@11.5.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + luxon@3.7.2: {} + + magic-regexp@0.10.0: + dependencies: + estree-walker: 3.0.3 + magic-string: 0.30.21 + mlly: 1.8.1 + regexp-tree: 0.1.27 + type-level-regexp: 0.1.17 + ufo: 1.6.3 + unplugin: 2.3.11 + + magic-string@0.25.9: + dependencies: + sourcemap-codec: 1.4.8 + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + optional: true + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + markdown-table@3.0.4: {} + + math-intrinsics@1.1.0: {} + + mathml-tag-names@4.0.0: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.27.1: {} + + media-typer@0.3.0: + optional: true + + media-typer@1.1.0: + optional: true + + memfs@3.5.3: + dependencies: + fs-monkey: 1.1.0 + + meow@13.2.0: {} + + meow@14.1.0: {} + + merge-descriptors@2.0.0: + optional: true + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.45 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3(supports-color@5.5.0) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: + optional: true + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + optional: true + + mime@3.0.0: {} + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimalistic-assert@1.0.1: {} + + minimalistic-crypto-utils@1.0.1: + optional: true + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.1 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mlly@1.8.1: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + mnemonist@0.40.4: + dependencies: + obliterator: 2.0.5 + + module-details-from-path@1.0.4: {} + + module-replacements@2.11.0: {} + + moo@0.5.3: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.7.0 + until-async: 3.0.2 + yargs: 17.7.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + muggle-string@0.4.1: {} + + multer@2.1.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + optional: true + + mustache@4.2.0: {} + + mute-stream@2.0.0: {} + + nanoid@3.3.15: {} + + nanoid@5.0.9: {} + + nanoid@5.1.6: {} + + natural-compare@1.4.0: {} + + natural-orderby@5.0.0: {} + + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.3 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + + negotiator@1.0.0: + optional: true + + neo-async@2.6.2: {} + + nestjs-pino@4.6.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2): + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + pino: 10.3.1 + pino-http: 11.0.0 + rxjs: 7.8.2 + + node-abort-controller@3.1.1: {} + + node-domexception@1.0.0: {} + + node-emoji@1.11.0: + dependencies: + lodash: 4.18.1 + + node-fetch-h2@2.3.0: + dependencies: + http2-client: 1.3.5 + + node-fetch-native@1.6.7: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-readfiles@0.2.0: + dependencies: + es6-promise: 3.3.1 + + node-releases@2.0.48: {} + + nodemon@3.1.14: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 10.2.5 + pstree.remy: 1.1.8 + semver: 7.8.5 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nwsapi@2.2.24: {} + + nypm@0.6.5: + dependencies: + citty: 0.2.1 + pathe: 2.0.3 + tinyexec: 1.2.4 + + oas-kit-common@1.0.8: + dependencies: + fast-safe-stringify: 2.1.1 + + oas-linter@3.2.2: + dependencies: + '@exodus/schemasafe': 1.3.0 + should: 13.2.3 + yaml: 1.10.3 + + oas-resolver@2.5.6: + dependencies: + node-fetch-h2: 2.3.0 + oas-kit-common: 1.0.8 + reftools: 1.1.9 + yaml: 1.10.3 + yargs: 17.7.3 + + oas-schema-walker@1.1.5: {} + + oas-validator@5.0.8: + dependencies: + call-me-maybe: 1.0.2 + oas-kit-common: 1.0.8 + oas-linter: 3.2.2 + oas-resolver: 2.5.6 + oas-schema-walker: 1.1.5 + reftools: 1.1.9 + should: 13.2.3 + yaml: 1.10.3 + + oauth-sign@0.9.0: {} + + object-assign@4.1.1: + optional: true + + object-deep-merge@2.0.0: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + obliterator@2.0.5: {} + + obug@2.1.3: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.3 + + ohash@2.0.11: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + optional: true + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + openapi-types@12.1.3: {} + + openapi3-ts@2.0.2: + dependencies: + yaml: 1.10.3 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + outvariant@1.4.3: {} + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + dependencies: + '@oxc-project/types': 0.115.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.115.0 + '@oxc-parser/binding-android-arm64': 0.115.0 + '@oxc-parser/binding-darwin-arm64': 0.115.0 + '@oxc-parser/binding-darwin-x64': 0.115.0 + '@oxc-parser/binding-freebsd-x64': 0.115.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.115.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.115.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.115.0 + '@oxc-parser/binding-linux-arm64-musl': 0.115.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.115.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.115.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.115.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.115.0 + '@oxc-parser/binding-linux-x64-gnu': 0.115.0 + '@oxc-parser/binding-linux-x64-musl': 0.115.0 + '@oxc-parser/binding-openharmony-arm64': 0.115.0 + '@oxc-parser/binding-wasm32-wasi': 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@oxc-parser/binding-win32-arm64-msvc': 0.115.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.115.0 + '@oxc-parser/binding-win32-x64-msvc': 0.115.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + oxc-walker@0.7.0(oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)): + dependencies: + magic-regexp: 0.10.0 + oxc-parser: 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + + p-debounce@4.0.0: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + package-manager-detector@1.6.0: {} + + pako@1.0.11: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-gitignore@2.0.0: {} + + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-statements@1.0.11: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: + optional: true + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + + path-to-regexp@6.3.0: {} + + path-to-regexp@8.3.0: {} + + path-to-regexp@8.4.2: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + pg-int8@1.0.1: {} + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + picocolors@1.1.1: {} + + picomatch-browser@2.2.6: {} + + picomatch@2.3.2: {} + + picomatch@4.0.2: {} + + picomatch@4.0.4: {} + + pinia@2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.30(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@vue/composition-api' + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-http@11.0.0: + dependencies: + get-caller-file: 2.0.5 + pino: 10.3.1 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.1.0 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.1 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + + pluralize@8.0.0: {} + + pnpm-workspace-yaml@1.6.0: + dependencies: + yaml: 2.9.0 + + possible-typed-array-names@1.1.0: {} + + postcss-html@1.8.1: + dependencies: + htmlparser2: 8.0.2 + js-tokens: 9.0.1 + postcss: 8.5.15 + postcss-safe-parser: 6.0.0(postcss@8.5.15) + + postcss-safe-parser@6.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-safe-parser@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prelude-ls@1.2.1: {} + + pretty-bytes@5.6.0: {} + + pretty-bytes@6.1.1: {} + + prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.2(magicast@0.3.5) + '@prisma/engines': 6.19.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + process-nextick-args@2.0.1: {} + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + protobufjs@7.6.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 24.12.0 + long: 5.3.2 + + protobufjs@8.0.0: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 24.12.0 + long: 5.3.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + optional: true + + proxy-from-env@2.1.0: {} + + pstree.remy@1.1.8: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + qified@0.6.0: + dependencies: + hookified: 1.15.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + + quansync@0.2.11: {} + + quansync@1.0.0: {} + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + range-parser@1.2.1: + optional: true + + rate-limiter-flexible@4.0.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + optional: true + + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + + re2-wasm@1.0.2: + optional: true + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + readdirp@4.1.2: {} + + readdirp@5.0.0: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + refa@0.12.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + + reflect-metadata@0.2.2: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + reftools@1.1.9: {} + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regexp-ast-analysis@0.7.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + + regexp-tree@0.1.27: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.2 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 + + relative-time-format@1.1.12: {} + + request-compose@2.1.7: {} + + request-oauth@1.0.1: + dependencies: + oauth-sign: 0.9.0 + qs: 6.15.2 + uuid: 8.3.2 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + + reserved-identifiers@1.2.0: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + ret@0.1.15: {} + + ret@0.5.0: {} + + rettime@0.10.1: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + + rolldown@1.0.0-rc.18: + dependencies: + '@oxc-project/types': 0.128.0 + '@rolldown/pluginutils': 1.0.0-rc.18 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.18 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.18 + '@rolldown/binding-darwin-x64': 1.0.0-rc.18 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.18 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.18 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.18 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.18 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.18 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.18 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.18 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.18 + + rollup@2.80.0: + optionalDependencies: + fsevents: 2.3.3 + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + optional: true + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.15.0 + ajv-keywords: 3.5.2(ajv@6.15.0) + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-keywords: 5.1.0(ajv@8.20.0) + + schemes@1.4.0: + dependencies: + extend: 3.0.2 + + scslre@0.3.0: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + + scule@1.3.0: {} + + secure-json-parse@4.1.0: {} + + select@1.1.2: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + optional: true + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + optional: true + + set-cookie-parser@2.7.2: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + short-uuid@5.2.0: + dependencies: + any-base: 1.1.0 + uuid: 9.0.1 + + should-equal@2.0.0: + dependencies: + should-type: 1.4.0 + + should-format@3.0.3: + dependencies: + should-type: 1.4.0 + should-type-adaptors: 1.1.0 + + should-type-adaptors@1.1.0: + dependencies: + should-type: 1.4.0 + should-util: 1.0.1 + + should-type@1.4.0: {} + + should-util@1.0.1: {} + + should@13.2.3: + dependencies: + should-equal: 2.0.0 + should-format: 3.0.3 + should-type: 1.4.0 + should-type-adaptors: 1.1.0 + should-util: 1.0.1 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.5 + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sisteransi@1.0.5: {} + + slash@5.1.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + smob@1.6.2: {} + + smtp-address-parser@1.1.0: + dependencies: + nearley: 2.20.1 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.4: {} + + source-map@0.8.0-beta.0: + dependencies: + whatwg-url: 7.1.0 + + sourcemap-codec@1.4.8: {} + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@4.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.1.0: {} + + steed@1.1.3: + dependencies: + fastfall: 1.5.1 + fastparallel: 2.4.1 + fastq: 1.20.1 + fastseries: 1.7.2 + reusify: 1.1.0 + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + streamsearch@1.1.0: + optional: true + + strict-event-emitter@0.5.1: {} + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-object@3.3.0: + dependencies: + get-own-enumerable-property-symbols: 3.0.2 + is-obj: 1.0.1 + is-regexp: 1.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-comments@2.0.1: {} + + strip-indent@4.1.1: {} + + strip-json-comments@3.1.1: {} + + strip-json-comments@5.0.3: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + strtok3@10.3.4: + dependencies: + '@tokenizer/token': 0.3.0 + + stylelint-config-html@1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + postcss-html: 1.8.1 + stylelint: 17.11.0(typescript@5.9.3) + + stylelint-config-recommended-vue@1.6.1(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + postcss-html: 1.8.1 + semver: 7.8.5 + stylelint: 17.11.0(typescript@5.9.3) + stylelint-config-html: 1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-recommended: 18.0.0(stylelint@17.11.0(typescript@5.9.3)) + + stylelint-config-recommended@18.0.0(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + stylelint: 17.11.0(typescript@5.9.3) + + stylelint-config-standard@40.0.0(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + stylelint: 17.11.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.11.0(typescript@5.9.3)) + + stylelint@17.11.0(typescript@5.9.3): + dependencies: + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) + colord: 2.9.3 + cosmiconfig: 9.0.2(typescript@5.9.3) + css-functions-list: 3.3.3 + css-tree: 3.2.1 + debug: 4.4.3(supports-color@5.5.0) + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.2 + global-modules: 2.0.0 + globby: 16.2.0 + globjoin: 0.1.4 + html-tags: 5.1.0 + ignore: 7.0.5 + import-meta-resolve: 4.2.0 + is-plain-object: 5.0.0 + mathml-tag-names: 4.0.0 + meow: 14.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-safe-parser: 7.0.1(postcss@8.5.15) + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + string-width: 8.2.1 + supports-hyperlinks: 4.4.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 7.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@10.2.2: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@4.4.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-tags@1.0.0: {} + + swagger-schema-official@2.0.0-bab6bed: {} + + swagger-typescript-api@13.3.1(magicast@0.5.3): + dependencies: + '@apidevtools/swagger-parser': 12.1.0(openapi-types@12.1.3) + '@biomejs/js-api': 4.0.0(@biomejs/wasm-nodejs@2.4.6) + '@biomejs/wasm-nodejs': 2.4.6 + '@types/swagger-schema-official': 2.0.25 + c12: 3.3.3(magicast@0.5.3) + citty: 0.2.1 + consola: 3.4.2 + es-toolkit: 1.48.1 + eta: 3.5.0 + nanoid: 5.1.6 + openapi-types: 12.1.3 + swagger-schema-official: 2.0.0-bab6bed + swagger2openapi: 7.0.8 + type-fest: 5.7.0 + typescript: 5.9.3 + yaml: 2.9.0 + yummies: 7.10.0 + transitivePeerDependencies: + - '@biomejs/wasm-bundler' + - '@biomejs/wasm-web' + - encoding + - magicast + - mobx + - react + + swagger-ui-dist@5.32.4: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger2openapi@7.0.8: + dependencies: + call-me-maybe: 1.0.2 + node-fetch: 2.7.0 + node-fetch-h2: 2.3.0 + node-readfiles: 0.2.0 + oas-kit-common: 1.0.8 + oas-resolver: 2.5.6 + oas-schema-walker: 1.1.5 + oas-validator: 5.0.8 + reftools: 1.1.9 + yaml: 1.10.3 + yargs: 17.7.3 + transitivePeerDependencies: + - encoding + + symbol-observable@4.0.0: {} + + symbol-tree@3.2.4: {} + + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + + tabbable@6.4.0: {} + + table@6.9.0: + dependencies: + ajv: 8.20.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tagged-tag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tapable@2.3.3: {} + + temp-dir@2.0.0: {} + + tempy@0.6.0: + dependencies: + is-stream: 2.0.1 + temp-dir: 2.0.0 + type-fest: 0.16.0 + unique-string: 2.0.0 + + terser-webpack-plugin@5.6.1(esbuild@0.27.3)(lightningcss@1.32.0)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.48.0 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + optionalDependencies: + esbuild: 0.27.3 + lightningcss: 1.32.0 + + terser@5.48.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.17.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tiny-emitter@2.1.0: {} + + tinybench@2.9.0: {} + + tinyduration@3.4.1: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tldts-core@6.1.86: {} + + tldts-core@7.4.3: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tldts@7.4.3: + dependencies: + tldts-core: 7.4.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + + toad-cache@3.7.0: {} + + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.1 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + toml-eslint-parser@1.0.3: + dependencies: + eslint-visitor-keys: 5.0.1 + + totalist@3.0.1: {} + + touch@3.1.1: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.3 + + tr46@0.0.3: {} + + tr46@1.0.1: + dependencies: + punycode: 2.3.1 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-declaration-location@1.0.7(typescript@5.9.3): + dependencies: + picomatch: 4.0.4 + typescript: 5.9.3 + + ts-deepmerge@6.2.1: {} + + ts-essentials@10.1.1(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + ts-patch@3.3.0: + dependencies: + chalk: 4.1.2 + global-prefix: 4.0.0 + minimist: 1.2.8 + resolve: 1.22.12 + semver: 7.8.5 + strip-ansi: 6.0.1 + + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.24.0 + tapable: 2.3.3 + tsconfig-paths: 4.2.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.16.0: {} + + type-fest@0.20.2: {} + + type-fest@5.7.0: + dependencies: + tagged-tag: 1.0.0 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + optional: true + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + optional: true + + type-level-regexp@0.1.17: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typedarray@0.0.6: + optional: true + + typescript-eslint@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript-transform-paths@3.5.6(typescript@5.9.3): + dependencies: + minimatch: 9.0.9 + typescript: 5.9.3 + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + unconfig@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + defu: 6.1.4 + jiti: 2.6.1 + quansync: 1.0.0 + unconfig-core: 7.5.0 + + undefsafe@2.0.5: {} + + undici-types@7.16.0: {} + + undici@7.24.5: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + unicorn-magic@0.4.0: {} + + unimport@3.14.6(rollup@2.80.0): + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + acorn: 8.17.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + fast-glob: 3.3.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.1 + pathe: 2.0.3 + picomatch: 4.0.4 + pkg-types: 1.3.1 + scule: 1.3.0 + strip-literal: 2.1.1 + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + + unique-string@2.0.0: + dependencies: + crypto-random-string: 2.0.0 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universalify@2.0.1: {} + + unocss@66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@unocss/cli': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/preset-attributify': 66.6.6 + '@unocss/preset-icons': 66.6.6 + '@unocss/preset-mini': 66.6.6 + '@unocss/preset-tagify': 66.6.6 + '@unocss/preset-typography': 66.6.6 + '@unocss/preset-uno': 66.6.6 + '@unocss/preset-web-fonts': 66.6.6 + '@unocss/preset-wind': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + '@unocss/preset-wind4': 66.6.6 + '@unocss/transformer-attributify-jsx': 66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@unocss/transformer-compile-class': 66.6.6 + '@unocss/transformer-directives': 66.6.6 + '@unocss/transformer-variant-group': 66.6.6 + '@unocss/vite': 66.6.6(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - vite + + unpipe@1.0.0: + optional: true + + unplugin-auto-import@0.18.6(rollup@2.80.0): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + fast-glob: 3.3.3 + local-pkg: 0.5.1 + magic-string: 0.30.21 + minimatch: 9.0.9 + unimport: 3.14.6(rollup@2.80.0) + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + + unplugin-vue-components@0.27.5(@babel/parser@7.29.7)(rollup@2.80.0)(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + fast-glob: 3.3.3 + local-pkg: 0.5.1 + magic-string: 0.30.21 + minimatch: 9.0.9 + mlly: 1.8.1 + unplugin: 1.16.1 + vue: 3.5.30(typescript@5.9.3) + optionalDependencies: + '@babel/parser': 7.29.7 + transitivePeerDependencies: + - rollup + - supports-color + + unplugin@1.16.1: + dependencies: + acorn: 8.17.0 + webpack-virtual-modules: 0.6.2 + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.17.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + until-async@3.0.2: {} + + upath@1.2.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-template@3.1.1: {} + + util-deprecate@1.0.2: {} + + uuid@8.3.2: {} + + uuid@9.0.1: {} + + vary@1.1.2: + optional: true + + vite-node@6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0): + dependencies: + cac: 7.0.0 + es-module-lexer: 2.1.0 + obug: 2.1.3 + pathe: 2.0.3 + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + vite-plugin-pwa@1.2.0(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): + dependencies: + debug: 4.4.3(supports-color@5.5.0) + pretty-bytes: 6.1.1 + tinyglobby: 0.2.17 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + workbox-build: 7.4.0(@types/babel__core@7.20.5) + workbox-window: 7.4.0 + transitivePeerDependencies: + - supports-color + + vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.59.0 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + terser: 5.48.0 + yaml: 2.9.0 + + vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.0-rc.18 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.0 + esbuild: 0.27.3 + fsevents: 2.3.3 + jiti: 2.6.1 + terser: 5.48.0 + yaml: 2.9.0 + + vitest-mock-extended@2.0.2(typescript@5.9.3)(vitest@4.1.5): + dependencies: + ts-essentials: 10.1.1(typescript@5.9.3) + typescript: 5.9.3 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.0 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + jsdom: 25.0.1 + transitivePeerDependencies: + - msw + + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.0 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + jsdom: 25.0.1 + transitivePeerDependencies: + - msw + + vscode-uri@3.1.0: {} + + vue-demi@0.14.10(vue@3.5.30(typescript@5.9.3)): + dependencies: + vue: 3.5.30(typescript@5.9.3) + + vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.30(typescript@5.9.3) + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue3-json-viewer@2.4.1(vue@3.5.30(typescript@5.9.3)): + dependencies: + clipboard: 2.0.11 + vue: 3.5.30(typescript@5.9.3) + + vue@3.5.30(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-sfc': 3.5.30 + '@vue/runtime-dom': 3.5.30 + '@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3)) + '@vue/shared': 3.5.30 + optionalDependencies: + typescript: 5.9.3 + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + watchpack@2.5.2: + dependencies: + graceful-fs: 4.2.11 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@4.0.2: {} + + webidl-conversions@7.0.0: {} + + webpack-node-externals@3.0.0: {} + + webpack-sources@3.5.0: {} + + webpack-virtual-modules@0.6.2: {} + + webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.17.0 + acorn-import-phases: 1.0.4(acorn@8.17.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.24.0 + es-module-lexer: 2.1.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.2 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.1(esbuild@0.27.3)(lightningcss@1.32.0)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)) + watchpack: 2.5.2 + webpack-sources: 3.5.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + whatwg-url@7.1.0: + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.22 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + wildcard-match@5.1.4: {} + + word-wrap@1.2.5: {} + + workbox-background-sync@7.4.0: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.0 + + workbox-broadcast-update@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-build@7.4.0(@types/babel__core@7.20.5): + dependencies: + '@apideck/better-ajv-errors': 0.3.7(ajv@8.20.0) + '@babel/core': 7.29.7 + '@babel/preset-env': 7.29.7(@babel/core@7.29.7) + '@babel/runtime': 7.29.7 + '@rollup/plugin-babel': 5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.80.0) + '@rollup/plugin-node-resolve': 15.3.1(rollup@2.80.0) + '@rollup/plugin-replace': 2.4.2(rollup@2.80.0) + '@rollup/plugin-terser': 0.4.4(rollup@2.80.0) + '@surma/rollup-plugin-off-main-thread': 2.2.3 + ajv: 8.20.0 + common-tags: 1.8.2 + fast-json-stable-stringify: 2.1.0 + fs-extra: 9.1.0 + glob: 11.1.0 + lodash: 4.18.1 + pretty-bytes: 5.6.0 + rollup: 2.80.0 + source-map: 0.8.0-beta.0 + stringify-object: 3.3.0 + strip-comments: 2.0.1 + tempy: 0.6.0 + upath: 1.2.0 + workbox-background-sync: 7.4.0 + workbox-broadcast-update: 7.4.0 + workbox-cacheable-response: 7.4.0 + workbox-core: 7.4.0 + workbox-expiration: 7.4.0 + workbox-google-analytics: 7.4.0 + workbox-navigation-preload: 7.4.0 + workbox-precaching: 7.4.0 + workbox-range-requests: 7.4.0 + workbox-recipes: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + workbox-streams: 7.4.0 + workbox-sw: 7.4.0 + workbox-window: 7.4.0 + transitivePeerDependencies: + - '@types/babel__core' + - supports-color + + workbox-cacheable-response@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-core@7.4.0: {} + + workbox-expiration@7.4.0: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.0 + + workbox-google-analytics@7.4.0: + dependencies: + workbox-background-sync: 7.4.0 + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-navigation-preload@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-precaching@7.4.0: + dependencies: + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-range-requests@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-recipes@7.4.0: + dependencies: + workbox-cacheable-response: 7.4.0 + workbox-core: 7.4.0 + workbox-expiration: 7.4.0 + workbox-precaching: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-routing@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-strategies@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-streams@7.4.0: + dependencies: + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + + workbox-sw@7.4.0: {} + + workbox-window@7.4.0: + dependencies: + '@types/trusted-types': 2.0.7 + workbox-core: 7.4.0 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@7.0.1: + dependencies: + signal-exit: 4.1.0 + + ws@8.21.0: {} + + xbytes@1.9.1: {} + + xcase@2.0.1: {} + + xml-name-validator@4.0.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml-eslint-parser@2.0.0: + dependencies: + eslint-visitor-keys: 5.0.1 + yaml: 2.9.0 + + yaml@1.10.3: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + yummies@7.10.0: + dependencies: + class-variance-authority: 0.7.1 + clsx: 2.1.1 + dayjs: 1.11.19 + dompurify: 3.3.2 + nanoid: 5.1.6 + tailwind-merge: 3.5.0 + + zod-validation-error@3.5.4(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + + zwitch@2.0.4: {} diff --git a/.jjconflict-base-1/pnpm-workspace.yaml b/.jjconflict-base-1/pnpm-workspace.yaml new file mode 100644 index 0000000000..a812ffa983 --- /dev/null +++ b/.jjconflict-base-1/pnpm-workspace.yaml @@ -0,0 +1,162 @@ +packages: + - packages/** + - plugins/** + - apps/** + - "!apps/server/src/plugins/external/**" + - playwright/** +allowBuilds: + "@nestjs/core": true + "@prisma/client": true + "@prisma/engines": true + "@scarf/scarf": true + esbuild: true + msw: true + prisma: true + protobufjs: true + vue-demi: true +allowUnusedPatches: true +autoInstallPeers: true +injectWorkspacePackages: true +# minimum number of minutes that must pass after a version is published before +# pnpm will install it. This applies to all dependencies, including transitive ones. +minimumReleaseAge: 1440 # 1 day +catalogMode: strict +cleanupUnusedCatalogs: true +shellEmulator: true +trustPolicy: no-downgrade +# Check these after each update. This list should be empty ! +trustPolicyExclude: + - rollup@2.80.0 + - semver@6.3.1 + - chokidar@4.0.3 + - pino@9.14.0 + - typescript-transform-paths@3.5.6 + - undici-types@6.21.0 +catalogs: + runtime: + "@gitbeaker/core": ^40.6.0 + "@gitbeaker/requester-utils": ^40.6.0 + "@gitbeaker/rest": ^40.6.0 + "@gouvfr/dsfr": ^1.14.4 + "@gouvminint/vue-dsfr": ^8.15.0 + "@himenon/argocd-typescript-openapi": ^1.2.2 + "@iconify-json/ri": ^1.2.10 + "@iconify/vue": ^4.3.0 + "@keycloak/keycloak-admin-client": ^26.5.5 + "@kubernetes-models/argo-cd": ^2.7.2 + "@nestjs/cache-manager": ^3.1.2 + "@nestjs/common": ^11.1.16 + "@nestjs/config": ^4.0.3 + "@nestjs/core": ^11.1.16 + "@nestjs/event-emitter": ^3.0.1 + "@nestjs/jwt": ^11.0.2 + "@nestjs/platform-fastify": ^11.1.16 + "@nestjs/schedule": ^6.1.1 + "@nestjs/swagger": ^11.4.1 + "@nestjs/terminus": ^11.1.1 + "@prisma/client": ^6.19.2 + "@ts-rest/core": ^3.52.1 + "@ts-rest/fastify": ^3.52.1 + "@ts-rest/open-api": ^3.52.1 + axios: ^1.15.1 + cache-manager: ^7.2.8 + date-fns: ^4.1.0 + fastify: ^5.8.5 + fastify-keycloak-adapter: ^3.0.0 + javascript-time-ago: ^2.6.4 + json-2-csv: ^5.5.10 + json-schema: ^0.4.0 + jszip: ^3.10.1 + keycloak-js: ^26.2.3 + mustache: ^4.2.0 + nanoid: 5.0.9 + nestjs-pino: ^4.6.0 + p-debounce: ^4.0.0 + pinia: ^2.3.1 + prisma: ^6.19.2 + reflect-metadata: ^0.2.2 + rxjs: ^7.8.2 + short-uuid: ^5.2.0 + undici: ^7.24.0 + vitest-mock-extended: ^2.0.2 + vue: ^3.5.30 + vue-router: ^4.6.4 + vue3-json-viewer: ^2.4.1 + xbytes: ^1.9.1 + yaml: ^2.7.1 + zod: ^3.25.76 + zod-validation-error: ^3.5.4 + "@fastify/cookie": ^11.0.2 + "@fastify/helmet": ^13.0.2 + "@fastify/otel": ^0.18.1 + "@fastify/session": ^11.1.1 + "@fastify/swagger": ^9.7.0 + "@fastify/swagger-ui": ^5.2.6 + bytes: ^3.1.2 + cron-validator: ^1.4.0 + build: + "@iconify/types": ^2.0.0 + "@nestjs/cli": ^11.0.16 + "@nestjs/schematics": ^11.0.9 + "@nestjs/testing": ^11.1.16 + "@unocss/transformer-directives": ^0.65.4 + "@vitejs/plugin-vue": ^6.0.4 + "@vue/tsconfig": ^0.7.0 + fastify-plugin: ^5.1.0 + swagger-typescript-api: ^13.3.1 + ts-patch: ^3.3.0 + typescript: ^5.9.3 + typescript-transform-paths: ^3.5.6 + unocss: ^66.6.6 + unplugin-auto-import: ^0.18.6 + unplugin-vue-components: ^0.27.5 + vite: ^7.3.2 + vite-node: ^6.0.0 + vite-plugin-pwa: ^1.2.0 + vue-tsc: ^2.2.12 + workbox-window: ^7.4.0 + test: + "@playwright/test": ^1.59.1 + "@faker-js/faker": ^9.9.0 + "@vitest/coverage-v8": ^4.1.5 + globals: ^16.5.0 + jsdom: ^25.0.1 + msw: ^2.12.10 + vitest: ^4.1.5 + types: + "@types/jsdom": ^21.1.7 + "@types/json-schema": ^7.0.15 + "@types/node": ^24.12.0 + undici-types: ^7.16.0 + otel: + "@opentelemetry/api": ^1.9.0 + "@opentelemetry/auto-instrumentations-node": ^0.70.1 + "@opentelemetry/exporter-metrics-otlp-proto": ^0.213.0 + "@opentelemetry/exporter-trace-otlp-proto": ^0.213.0 + "@opentelemetry/instrumentation-nestjs-core": ^0.58.0 + "@opentelemetry/instrumentation-pino": ^0.59.0 + "@opentelemetry/sdk-metrics": ^2.5.1 + "@opentelemetry/sdk-node": ^0.212.0 + tools: + "@antfu/eslint-config": ^8.2.0 + "@commitlint/cli": ^20.5.3 + "@commitlint/config-conventional": ^20.5.3 + "@eslint/eslintrc": ^3.3.5 + "@eslint/js": ^9.39.4 + "@vue/eslint-config-typescript": ^14.7.0 + chalk: ^5.6.2 + eslint: ^10.5.0 + eslint-plugin-vue: ^10.8.0 + husky: ^9.1.7 + js-yaml: ^4.1.0 + lint-staged: ^16.4.0 + nodemon: ^3.1.14 + pino: ^9.14.0 + pino-pretty: ^13.1.3 + rimraf: ^6.1.3 + stylelint: ^17.11.0 + stylelint-config-html: ^1.1.0 + stylelint-config-recommended-vue: ^1.6.1 + stylelint-config-standard: ^40.0.0 + typescript-eslint: ^8.57.0 + vue-eslint-parser: ^10.4.0 diff --git a/.jjconflict-base-1/release-please-config.json b/.jjconflict-base-1/release-please-config.json new file mode 100644 index 0000000000..473308bd27 --- /dev/null +++ b/.jjconflict-base-1/release-please-config.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "versioning": "always-bump-minor", + "pull-request-title-pattern": "chore${scope}: Release${component} v${version}", + "release-type": "node", + "changelog-type": "default", + "changelog-sections": [ + { + "type": "feat", + "hidden": false, + "section": "Features" + }, + { + "type": "feature", + "hidden": false, + "section": "Features" + }, + { + "type": "fix", + "hidden": false, + "section": "Bug Fixes" + }, + { + "type": "chore", + "hidden": false, + "section": "Miscellaneous Chores" + }, + { + "type": "build", + "hidden": false, + "section": "Miscellaneous Chores" + }, + { + "type": "docs", + "hidden": false, + "section": "Docs" + }, + { + "type": "refactor", + "hidden": false, + "section": "Refactoring" + }, + { + "type": "revert", + "hidden": false, + "section": "Reverted commits" + } + ], + "packages": { + ".": { + "package-name": "console", + "include-component-in-tag": false + } + } +} diff --git a/.jjconflict-base-1/scripts/run-build.sh b/.jjconflict-base-1/scripts/run-build.sh new file mode 100755 index 0000000000..9e53b07cf8 --- /dev/null +++ b/.jjconflict-base-1/scripts/run-build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd $(dirname "$COMPOSE_FILE") \ + && docker buildx bake --file $(basename "$COMPOSE_FILE") --load --allow=fs.read=.. \ + && cd - > /dev/null diff --git a/.jjconflict-base-1/scripts/update-hosts.sh b/.jjconflict-base-1/scripts/update-hosts.sh new file mode 100755 index 0000000000..0de643ec93 --- /dev/null +++ b/.jjconflict-base-1/scripts/update-hosts.sh @@ -0,0 +1,130 @@ +#!/bin/bash + +set -xe +set -o pipefail + +# Colorize terminal +export red='\e[0;31m' +export no_color='\033[0m' + +# Get versions +export DOCKER_VERSION="$(docker --version)" + +# Default +export SCRIPTPATH="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" +export HELM_RELEASE_NAME="dso" +export INTEGRATION_ARG="" +export INTEGRATION_ARGS_UTILS="" +export CI_ARGS="" + + +# Declare script helper +export TEXT_HELPER="\nThis script aims to manage a local kubernetes cluster using Kind also known as Kubernetes in Docker. +Following flags are available: + + -c Command tu run. Multiple commands can be provided as a comma separated list. + Available commands are : + create - Create kind cluster. + clean - Delete images in kind cluster (keep only infra resources and ingress controller). + delete - Delete kind cluster. + build - Build, push and load docker images from compose file into cluster nodes. + load - Load docker images from compose file into cluster nodes. + dev - Run application in development mode. + prod - Run application in production mode. + integ - Run application in integration mode (need to combine with 'dev' or 'prod'). + + -d Domains to add in /etc/hosts for local services resolution. Comma separated list. This will require sudo. + + -f Path to the docker-compose file that will be used with Kind. + + -i Install kind. + + -k Path to the kubeconfig to use. + + -t Tag used to deploy application images. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hc:d:f:ik:t: flag; do + case "${flag}" in + c) + export COMMAND=${OPTARG};; + d) + export DOMAINS=${OPTARG};; + f) + export COMPOSE_FILE=${OPTARG};; + i) + export INSTALL_KIND=true;; + k) + export KUBECONFIG_HOST_PATH=${OPTARG};; + t) + export TAG=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Utils +install_kind() { + printf "\n\n${red}[kind wrapper].${no_color} Install kind...\n\n" + if [ "$(uname)" = "Linux" ]; then + export OS=linux + elif [ "$(uname)" = "Darwin" ]; then + export OS=darwin + else + printf "\n\nNo installation available for your system, plese refer to the installation guide\n\n" + exit 0 + fi + + if [ "$(uname -m)" = "x86_64" ]; then + export ARCH=amd64 + elif [ "$(uname -m)" = "arm64" ] || [ "$(uname -m)" = "aarch64" ]; then + export ARCH=arm64 + fi + + curl -Lo ./kind "https://kind.sigs.k8s.io/dl/v0.20.0/kind-$OS-$ARCH" + chmod +x ./kind + mv ./kind /usr/local/bin/kind + + printf "\n\n$(kind --version) installed\n\n" +} + +if [ "$INSTALL_KIND" = "true" ] && [ -z "$(kind --version)" ]; then + install_kind +fi + + +# Script condition +if [ -z "$(kind --version)" ]; then + echo "\nYou need to install kind to run this script.\n" + print_help + exit 1 +fi + +if [[ "$COMMAND" =~ "build" ]] && [ ! -f "$(readlink -f $COMPOSE_FILE)" ]; then + echo "\nDocker compose file $COMPOSE_FILE does not exist.\n" + print_help + exit 1 +fi + + +# Add local services to /etc/hosts +if [ ! -z "$DOMAINS" ]; then + printf "\n\n${red}[kind wrapper].${no_color} Add services local domains to /etc/hosts\n\n" + + export FORMATED_DOMAINS="$(echo "$DOMAINS" | sed 's/,/\ /g')" + if [ "$(grep -c "$FORMATED_DOMAINS" /etc/hosts)" -ge 1 ]; then + printf "\n\n${red}[kind wrapper].${no_color} Services local domains already added to /etc/hosts\n\n" + else + sudo sh -c "echo $'\n\n# Kind\n127.0.0.1 $FORMATED_DOMAINS' >> /etc/hosts" + + printf "\n\n${red}[kind wrapper].${no_color} Services local domains successfully added to /etc/hosts\n\n" + fi +fi diff --git a/.jjconflict-side-0/.dockerignore b/.jjconflict-side-0/.dockerignore new file mode 100644 index 0000000000..2edecc5c93 --- /dev/null +++ b/.jjconflict-side-0/.dockerignore @@ -0,0 +1,26 @@ +# Npm +node_modules +npm-debug.log + +# Git +.gitkeep + +# Lint +.eslintrc.js +.eslintrc.cjs +.eslintrc.json +.eslintignore +.stylelintrc.js +.stylelintrc.cjs + +# Tests +.turbo/ +coverage/ + +# Docker +**/Dockerfile +.dockerignore +docker/ + +# Env +.env* \ No newline at end of file diff --git a/.jjconflict-side-0/.gitattributes b/.jjconflict-side-0/.gitattributes new file mode 100644 index 0000000000..2e7bbd226d --- /dev/null +++ b/.jjconflict-side-0/.gitattributes @@ -0,0 +1,2 @@ +# Generated API clients — collapse in diffs and exclude from language stats. +plugins/harbor/src/api/Api.ts linguist-generated=true diff --git a/.jjconflict-side-0/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.jjconflict-side-0/.github/ISSUE_TEMPLATE/BUG-REPORT.yml new file mode 100644 index 0000000000..f87368e72d --- /dev/null +++ b/.jjconflict-side-0/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -0,0 +1,98 @@ +name: 🐛 Rapport de bug +description: Créer un ticket pour un bug. +title: "🐛 [BUG] - " +labels: [bug] +body: + - type: textarea + id: description + attributes: + label: Description + description: Veuillez saisir une description explicite de votre problème. + placeholder: Description courte et explicite de votre incident... + validations: + required: true + + - type: textarea + id: reprod + attributes: + label: Etapes de reproduction + description: Veuillez saisir une description explicite pour reproduire votre problème. + value: | + 1. Aller à '...' + 2. Cliquer sur '....' + 3. Scroller jusqu'à '....' + 4. Voir l'erreur + render: bash + validations: + required: true + + - type: textarea + id: screenshot + attributes: + label: "Captures d'écran" + description: Si nécessaire, ajoutez des captures d'écran pour expliquer votre problème. + value: | + ![DESCRIPTION](LINK.png) + render: bash + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Logs + description: Veuillez copier/coller tous les logs pertinents. Ces données seront automatiquement formatées en code. + validations: + required: false + + - type: dropdown + id: browsers + attributes: + label: Navigateurs + description: Quels sont les navigateurs sur lesquels vous constatez le problème ? + multiple: true + options: + - Firefox + - Chrome + - Chromium + - Brave + - Safari + - Microsoft Edge + - Opera + validations: + required: false + + - type: dropdown + id: os + attributes: + label: OS + description: Quels sont les systèmes d'exploitation sur lesquels vous constatez le problème ? + multiple: true + options: + - Windows + - Linux + - Mac + validations: + required: false + + - type: input + id: affected-versions + attributes: + label: Version de la console impactée + description: Quelle version de la console est impactée par le bug ? + placeholder: v1.2.3 + validations: + required: false + + - type: checkboxes + id: dod + attributes: + label: Définition du fini + description: Cochez les différents élément de la Definition Of Done. + options: + - label: Le correctif est terminé + required: false + - label: Les tests liés à ce correctif ont été ajoutés + required: false + - label: La communication avec les autres équipes impliquées par ce correctif a été faite + required: false diff --git a/.jjconflict-side-0/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.jjconflict-side-0/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml new file mode 100644 index 0000000000..2d55aae3ad --- /dev/null +++ b/.jjconflict-side-0/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml @@ -0,0 +1,64 @@ +name: 💡 Demande de fonctionnalité +description: Créer un ticket pour demander une nouvelle fonctionnalité. +title: "💡 [REQUEST] - <title>" +labels: [enhancement] +body: + - type: textarea + id: summary + attributes: + label: Description + description: Veuillez saisir une brève explication de la fonctionnalité. + placeholder: Décrivez en quelques lignes la fonctionnalité désirée. + validations: + required: true + + - type: textarea + id: implementation_pr + attributes: + label: PRs liées + description: Pull request qui répond à la fonctionnalité. + placeholder: "#Pull Request ID" + validations: + required: false + + - type: textarea + id: reference_issues + attributes: + label: Issues liées + description: Issues associée à cette demande de fonctionnalité. + placeholder: "#Issues IDs" + validations: + required: false + + - type: textarea + id: basic_example + attributes: + label: Exemples simples + description: Indiquez ici quelques exemples de base de votre fonctionnalité. + placeholder: Quelques mots sur votre demande de fonctionnalité. + validations: + required: false + + - type: textarea + id: tech + attributes: + label: Spécifications techniques + description: Spcifiez les détails techniques liés à votre demande. + placeholder: Une description technique de la fonctionnalité. + validations: + required: false + + - type: checkboxes + id: dod + attributes: + label: Définition du fini + description: Cochez les différents élément de la Definition Of Done. + options: + - label: La fonctionnalité est terminée + required: false + - label: Les tests liés à cette fonctionnalité ont été ajoutés + required: false + - label: La documentation liée à cette fonctionnalité a été ajoutée (cf. https://github.com/cloud-pi-native/documentation) + required: false + - label: La communication avec les autres équipes impliquées par cette fonctionnalité a été faite + required: false diff --git a/.jjconflict-side-0/.github/ISSUE_TEMPLATE/config.yml b/.jjconflict-side-0/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..b565340e24 --- /dev/null +++ b/.jjconflict-side-0/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: 📚 Documentation + url: https://cloud-pi-native.fr + about: Cet outil de suivi des problèmes n'est pas destiné aux problèmes de documentation. Veuillez ouvrir un ticket sur le dépôt de documentation. + - name: 🚀 Déploiement + url: https://github.com/cloud-pi-native/socle + about: Cet outil de suivi des problèmes n'est pas destiné aux problèmes de déploiement de la plateforme. Veuillez ouvrir un ticket sur le dépôt de socle. diff --git a/.jjconflict-side-0/.github/PULL_REQUEST_TEMPLATE.md b/.jjconflict-side-0/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..1730631ac9 --- /dev/null +++ b/.jjconflict-side-0/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +## Issues liées + +Issues numéro: + +--------- + +<!-- Ne soumettez pas de mises à jour des dépendances à moins qu'elles ne corrigent un problème. --> + +<!-- Veuillez essayer de limiter votre Pull Request à un seul type (correction de bogue, fonctionnalité, etc.). Soumettez plusieurs PRs si nécessaire. --> + +## Quel est le comportement actuel ? +<!-- Veuillez décrire le comportement actuel que vous modifiez. --> + +## Quel est le nouveau comportement ? +<!-- Veuillez décrire le comportement ou les changements apportés par cette PR. --> + +## Cette PR introduit-elle un breaking change ? +<!-- Si un breaking change est introduit, veuillez décrire ci-dessous l'impact et la procédure de migration pour les applications existantes. --> + +## Autres informations +<!-- Toute autre information importante pour la PR, telle que des captures d'écran montrant l'aspect du composant avant et après la modification. --> diff --git a/.jjconflict-side-0/.github/SECURITY.md b/.jjconflict-side-0/.github/SECURITY.md new file mode 100644 index 0000000000..9aaaf8698f --- /dev/null +++ b/.jjconflict-side-0/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +Contact: cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-side-0/.github/labeler/build.yml b/.jjconflict-side-0/.github/labeler/build.yml new file mode 100644 index 0000000000..68a15e21bc --- /dev/null +++ b/.jjconflict-side-0/.github/labeler/build.yml @@ -0,0 +1,3 @@ +built: + - changed-files: + - any-glob-to-any-file: "**" diff --git a/.jjconflict-side-0/.github/workflows/job-build.yml b/.jjconflict-side-0/.github/workflows/job-build.yml new file mode 100644 index 0000000000..f1a1f96e51 --- /dev/null +++ b/.jjconflict-side-0/.github/workflows/job-build.yml @@ -0,0 +1,256 @@ +name: Build + +permissions: + contents: read + packages: write + +on: + workflow_call: + inputs: + REGISTRY: + required: true + type: string + NAMESPACE: + required: true + type: string + BUILD_AMD64: + required: true + type: boolean + BUILD_ARM64: + required: true + type: boolean + TAG: + required: true + type: string + MAJOR_TAG: + required: false + type: string + MINOR_TAG: + required: false + type: string + PATCH_TAG: + required: false + type: string + USE_QEMU: + required: true + type: boolean + PR_NUMBER: + required: false + type: string + secrets: + ARGOCD_TOKEN: + required: false + workflow_dispatch: + inputs: + REGISTRY: + description: Target registry to push images + required: true + type: string + default: ghcr.io + NAMESPACE: + description: Target namespace to the given registry + required: true + type: string + default: cloud-pi-native/console + BUILD_AMD64: + description: Build for amd64 + required: true + type: boolean + default: true + BUILD_ARM64: + description: Build for arm64 + required: true + type: boolean + default: true + USE_QEMU: + description: Use QEMU emulator for arm64 + required: true + type: boolean + default: false + PR_NUMBER: + description: ID number of the pull request to use for deploy preview app + required: false + type: string + +jobs: + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + lower-branch: ${{ steps.lower-branch.outputs.LOWER_BRANCH }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + - name: Get lowercase branch name + id: lower-branch + run: | + echo "LOWER_BRANCH=$(echo '${{ github.head_ref || github.ref_name }}' | sed 's/\//-/g' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + + build: + name: Build application + runs-on: ${{ matrix.runners }} + needs: + - matrix + strategy: + fail-fast: false + matrix: + runners: ${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && !inputs.USE_QEMU && fromJson('["ubuntu-latest", "ARM64"]')) || (inputs.BUILD_ARM64 && !inputs.USE_QEMU && fromJson('["ARM64"]')) || fromJson('["ubuntu-latest"]') }} + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Get short SHA + id: short-sha + run: | + echo "SHORT_SHA=sha-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Cache Docker layers + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: /tmp/.buildx-cache + key: ${{ matrix.images.name == 'server' && format('buildx-{0}-{1}-{2}-{3}', runner.os, runner.arch, matrix.images.name, hashFiles(format('apps/{0}/**', matrix.images.name),'packages/shared/**','plugins/**')) || format('buildx-{0}-{1}-{2}-{3}', runner.os, runner.arch, matrix.images.name, hashFiles(format('apps/{0}/**', matrix.images.name),'packages/shared/**')) }} + restore-keys: | + buildx-${{ runner.os }}-${{ runner.arch }}-${{ matrix.images.name }}- + + - name: Set up QEMU (for multi platform build) + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + if: ${{ inputs.USE_QEMU }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ inputs.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + logout: true + + - name: Build and push docker image + id: build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: ${{ matrix.images.build.context }} + file: ${{ matrix.images.build.dockerfile }} + provenance: false + platforms: ${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && inputs.USE_QEMU && 'linux/amd64,linux/arm64') || (inputs.BUILD_ARM64 && 'linux/arm64') || (inputs.BUILD_AMD64 && 'linux/amd64') }} + outputs: type=image,name=${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }},push-by-digest=true,name-canonical=true,push=true + build-args: | + APP_VERSION=${{ inputs.TAG || steps.short-sha.outputs.SHORT_SHA }} + # cache-from: type=gha + # cache-to: type=gha,mode=max + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new + + # Necessary to avoid forever growing cache + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + + - name: Export digest + run: | + mkdir -p /tmp/digests/${{ matrix.images.name }} + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${{ matrix.images.name }}/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: digests-${{ matrix.images.name }}.${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && inputs.USE_QEMU && 'multiarch') || (inputs.BUILD_ARM64 && 'arm64') || (inputs.BUILD_AMD64 && 'amd64') }}.digest + path: /tmp/digests/${{ matrix.images.name }}/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge digest + runs-on: ubuntu-latest + needs: + - matrix + - build + strategy: + fail-fast: false + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Download artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: digests-${{ matrix.images.name }}.*.digest + path: /tmp/digests/${{ matrix.images.name }} + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Docker meta + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + with: + images: ${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }} + tags: | + type=raw,value=${{ needs.matrix.outputs.lower-branch }},enable=${{ github.head_ref != 'main' }} + type=raw,value=${{ inputs.TAG }},enable=${{ inputs.TAG != '' }} + type=raw,value=${{ inputs.MAJOR_TAG }}.${{ inputs.MINOR_TAG }},enable=${{ inputs.MAJOR_TAG != '' && inputs.MINOR_TAG != '' }} + type=raw,value=${{ inputs.MAJOR_TAG }},enable=${{ inputs.MAJOR_TAG != '' }} + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ inputs.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + logout: true + + - name: Create manifest list and push + working-directory: /tmp/digests/${{ matrix.images.name }} + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ steps.meta.outputs.version }} + + preview: + name: Redeploy preview app + if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'preview') && inputs.PR_NUMBER != '' }} + runs-on: ubuntu-latest + needs: + - merge + steps: + - name: Replace template fields + id: feed-template + run: | + echo "ARGOCD_APP_NAME=$(echo '${{ vars.ARGOCD_APP_NAME_TEMPLATE }}' | sed 's|<pr_number>|${{ inputs.PR_NUMBER }}|g')" >> $GITHUB_OUTPUT + echo "ARGOCD_SYNC_PAYLOAD=$(echo '${{ vars.ARGOCD_SYNC_PAYLOAD_TEMPLATE }}' | sed 's|<pr_number>|${{ inputs.PR_NUMBER }}|g')" >> $GITHUB_OUTPUT + + - name: Deploy new image + run: | + set -e + + RESPONSE_CODE=$(curl -s -w "%{http_code}" -o >(RESPONSE_BODY=$(cat)) -X POST ${{ vars.ARGOCD_URL }}/api/v1/applications/${{ steps.feed-template.outputs.ARGOCD_APP_NAME }}/sync \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${{ secrets.ARGOCD_TOKEN }}" \ + -d '${{ steps.feed-template.outputs.ARGOCD_SYNC_PAYLOAD }}') + + echo "HTTP response code: $RESPONSE_CODE" + + if [ "$RESPONSE_CODE" -ne 200 ]; then + echo "Error: Non-200 HTTP response detected." + echo "Response body: $RESPONSE_BODY" + exit 1 + fi + continue-on-error: true diff --git a/.jjconflict-side-0/.github/workflows/job-label.yml b/.jjconflict-side-0/.github/workflows/job-label.yml new file mode 100644 index 0000000000..1fd944015f --- /dev/null +++ b/.jjconflict-side-0/.github/workflows/job-label.yml @@ -0,0 +1,25 @@ +name: Add labels on PR + +permissions: + contents: read + pull-requests: write + +on: + workflow_call: + inputs: + CONF_PATH: + required: true + type: string + +jobs: + label: + name: Add labels on PR + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Add pull request label + uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + with: + configuration-path: ${{ inputs.CONF_PATH }} diff --git a/.jjconflict-side-0/.github/workflows/job-lint.yml b/.jjconflict-side-0/.github/workflows/job-lint.yml new file mode 100644 index 0000000000..bbbae077d6 --- /dev/null +++ b/.jjconflict-side-0/.github/workflows/job-lint.yml @@ -0,0 +1,79 @@ +name: Lint + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: false + type: string + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + +jobs: + lint: + name: Lint codebase + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check lint error + run: | + ./ci/scripts/run-tests.sh -l + + - name: Validate nginx-strangler config + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends nginx gettext-base + # Préparer un répertoire de test isolé avec la config substituée + mkdir -p /tmp/nginx-test/conf.d /tmp/nginx-test/logs + envsubst '${LEGACY_UPSTREAM} ${NESTJS_UPSTREAM}' \ + < apps/nginx-strangler/conf.d/routing.conf \ + > /tmp/nginx-test/conf.d/routing.conf + # Adapter nginx.conf pour l'environnement CI (user www-data, paths accessibles) + sed \ + -e 's|^user .*|user www-data;|' \ + -e 's|pid .*|pid /tmp/nginx-test/nginx.pid;|' \ + -e 's|error_log .*|error_log /tmp/nginx-test/logs/error.log notice;|' \ + -e 's|access_log .*|access_log /tmp/nginx-test/logs/access.log main;|' \ + -e 's|include /etc/nginx/conf\.d/\*\.conf|include /tmp/nginx-test/conf.d/*.conf|' \ + apps/nginx-strangler/nginx.conf > /tmp/nginx-test/nginx.conf + nginx -t -c /tmp/nginx-test/nginx.conf + env: + LEGACY_UPSTREAM: "127.0.0.1:8080" + NESTJS_UPSTREAM: "127.0.0.1:3001" diff --git a/.jjconflict-side-0/.github/workflows/job-npm.yml b/.jjconflict-side-0/.github/workflows/job-npm.yml new file mode 100644 index 0000000000..51e4f3eedc --- /dev/null +++ b/.jjconflict-side-0/.github/workflows/job-npm.yml @@ -0,0 +1,247 @@ +name: Publish npm + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + PUBLISH_APPS: + required: true + type: boolean + PUBLISH_PACKAGES: + required: true + type: boolean + PUBLISH_PLUGINS: + required: true + type: boolean + CHECK_CHANGES: + required: true + type: boolean + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used + required: true + type: string + PUBLISH_APPS: + description: Publish apps modules + required: true + type: boolean + default: false + PUBLISH_PACKAGES: + description: Publish packages modules + required: true + type: boolean + default: false + PUBLISH_PLUGINS: + description: Publish plugins modules + required: true + type: boolean + default: true + CHECK_CHANGES: + required: true + type: boolean + default: true + +jobs: + path-filter: + if: ${{ inputs.CHECK_CHANGES }} + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.filter.outputs.changes }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + eslint-config: + - 'packages/eslintconfig/src/**' + shared: + - 'packages/shared/src/**' + hooks: + - 'packages/hooks/src/**' + ts-config: + - 'packages/tsconfig/**' + logger: + - 'packages/logger/**' + argocd: + - 'plugins/argocd/src/**' + gitlab: + - 'plugins/gitlab/src/**' + harbor: + - 'plugins/harbor/src/**' + keycloak: + - 'plugins/keycloak/src/**' + nexus: + - 'plugins/nexus/src/**' + sonarqube: + - 'plugins/sonarqube/src/**' + vault: + - 'plugins/vault/src/**' + + check-update: + name: Check for package version update + if: ${{ fromJson(needs.path-filter.outputs.packages).include[0] }} # skip if the matrix is empty! + runs-on: ubuntu-latest + needs: + - path-filter + strategy: + matrix: + packages: ${{ fromJSON(needs.path-filter.outputs.packages) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Check changes + id: check + run: | + PACKAGE_NAME=$(jq -cr --arg p "${{ matrix.packages }}" '[.packages[], .plugins[]][] | select(.name | test($p)?) | .name' < ./ci/matrix-npm.json) + PACKAGE_PATH=$(jq -cr --arg p "${{ matrix.packages }}" '[.packages[], .plugins[]][] | select(.name | test($p)?) | .path' < ./ci/matrix-npm.json) + PACKAGE_VERSION=$(jq -cr '.version' < ${PACKAGE_PATH}/package.json) + if [ "$(curl -s -o /dev/null -I -w %{http_code} https://registry.npmjs.org/${PACKAGE_NAME})" = 404 ]; then + REMOTE=false + else + REMOTE=$(pnpm view ${PACKAGE_NAME} --json | jq --arg v "$PACKAGE_VERSION" 'any(.versions[] == $v; .)') + fi + if [ "$REMOTE" = "true" ]; then + echo "WARNING=true" >> $GITHUB_OUTPUT + echo "PACKAGE_NAME=$PACKAGE_NAME" >> $GITHUB_OUTPUT + echo "PACKAGE_PATH=$PACKAGE_PATH" >> $GITHUB_OUTPUT + echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_OUTPUT + else + echo "WARNING=false" >> $GITHUB_OUTPUT + fi + + matrix: + name: Generate publish matrix + needs: + - path-filter + if: ${{ needs.path-filter.outputs.packages != '' && (inputs.PUBLISH_APPS || inputs.PUBLISH_PACKAGES || inputs.PUBLISH_PLUGINS) }} + runs-on: ubuntu-latest + outputs: + publish-matrix: ${{ steps.packages-matrix.outputs.PUBLISH_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: packages-matrix + run: | + PUBLISH_MATRIX="[]" + APPS_MATRIX="$(jq -c '.apps' < ./ci/matrix-npm.json)" + PACKAGES_MATRIX="$(jq -c '.packages' < ./ci/matrix-npm.json)" + PLUGINS_MATRIX="$(jq -c '.plugins' < ./ci/matrix-npm.json)" + if [ "${{ inputs.PUBLISH_APPS }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$APPS_MATRIX" '$acc + $cur')" + fi + if [ "${{ inputs.PUBLISH_PACKAGES }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$PACKAGES_MATRIX" '$acc + $cur')" + fi + if [ "${{ inputs.PUBLISH_PLUGINS }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$PLUGINS_MATRIX" '$acc + $cur')" + fi + echo "PUBLISH_MATRIX=$PUBLISH_MATRIX" >> $GITHUB_OUTPUT + + publish-npm: + name: Publish on npm + if: ${{ needs.path-filter.outputs.packages != '' && (inputs.PUBLISH_APPS || inputs.PUBLISH_PACKAGES || inputs.PUBLISH_PLUGINS) }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + needs: + - path-filter + - matrix + strategy: + # Allow jobs to be processed independently of their siblings status + fail-fast: false + matrix: + modules: ${{ fromJSON(needs.matrix.outputs.publish-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.ref }} + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Check for new package version + id: check-version + run: | + PACKAGE_VERSION=$(jq -cr '.version' < ${{ matrix.modules.path }}/package.json) + if [ "$(curl -s -o /dev/null -I -w %{http_code} https://registry.npmjs.org/${{ matrix.modules.name }})" = 404 ]; then + REMOTE=false + else + REMOTE=$(pnpm view ${{ matrix.modules.name }} --json | jq --arg v "$PACKAGE_VERSION" 'any(.versions[] == $v; .)') + fi + if [ "$REMOTE" = "true" ]; then + echo "Package ${{ matrix.modules.name }} - version $PACKAGE_VERSION already exists" + else + echo "Package ${{ matrix.modules.name }} - version $PACKAGE_VERSION does not exist" + fi + echo "REMOTE=$REMOTE" >> $GITHUB_OUTPUT + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + with: + node-version: "${{ inputs.NODE_VERSION }}" + registry-url: "https://registry.npmjs.org" + + - name: Get pnpm store directory + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install base dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: pnpm install --frozen-lockfile + + - name: Build internal dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter "./packages/**" run build && pnpm --filter "./plugins/**" run build + + - name: Update PNPM internal links with built internal dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm install --frozen-lockfile + + - name: Build package + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter ${{ matrix.modules.name }} run build + + - name: Publish package + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter ${{ matrix.modules.name }} publish --provenance --no-git-checks --report-summary diff --git a/.jjconflict-side-0/.github/workflows/job-playwright.yml b/.jjconflict-side-0/.github/workflows/job-playwright.yml new file mode 100644 index 0000000000..ec0a3fe695 --- /dev/null +++ b/.jjconflict-side-0/.github/workflows/job-playwright.yml @@ -0,0 +1,114 @@ +name: Playwright Tests + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + TAG: + required: true + type: string + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + TAG: + description: Container images version to use for console client and server/server-nestjs + required: true + type: string + default: latest +jobs: + test: + timeout-minutes: 60 + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache Node.js files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install Node.js dependencies + run: pnpm install --frozen-lockfile + + - name: Build internal dependencies + run: pnpm --filter "./packages/**" build + + - name: Get used Playwright version + id: playwright-version + run: echo "PLAYWRIGHT_VERSION=$(pnpm --dir playwright exec playwright -V | awk '{ print $2 }')" >> $GITHUB_ENV + + - name: Restore cache - Playwright browser binaries + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + id: playwright-cache + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} + + - name: Install Playwright system dependencies + run: pnpm --dir playwright exec playwright install --with-deps + + - name: Save cache - Playwright browser binaries + if: always() && steps.playwright-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} + + - name: Initialize application environment for tests + run: | + ./ci/scripts/init-env.sh + docker pull ghcr.io/cloud-pi-native/console/server:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/server:${{ inputs.TAG }} dso-console/server:ci + docker pull ghcr.io/cloud-pi-native/console/nginx-strangler:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/nginx-strangler:${{ inputs.TAG }} dso-console/nginx-strangler:ci + docker pull ghcr.io/cloud-pi-native/console/server-nestjs:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/server-nestjs:${{ inputs.TAG }} dso-console/server-nestjs:ci + docker pull ghcr.io/cloud-pi-native/console/client:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/client:${{ inputs.TAG }} dso-console/client:ci + docker pull ghcr.io/cloud-pi-native/console/opencds-mockoon:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/opencds-mockoon:${{ inputs.TAG }} dso-console/opencds-mockoon:ci + docker compose -f ./docker/docker-compose.ci.yml up --no-build -d --remove-orphans + + - name: Run Playwright tests + run: pnpm --dir playwright exec playwright test --grep @e2e + + - name: Clean up docker resources (containers, volumes) + run: docker compose -f ./docker/docker-compose.ci.yml down -v --remove-orphans + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright/playwright-report/ + retention-days: 30 diff --git a/.jjconflict-side-0/.github/workflows/job-release-please.yml b/.jjconflict-side-0/.github/workflows/job-release-please.yml new file mode 100644 index 0000000000..b4789c2cbb --- /dev/null +++ b/.jjconflict-side-0/.github/workflows/job-release-please.yml @@ -0,0 +1,84 @@ +name: Release + +permissions: + contents: write + pull-requests: write + +on: + workflow_call: + outputs: + release-created: + description: Has the releease been created + value: ${{ jobs.release.outputs.release-created }} + major-tag: + description: Major version tag + value: ${{ jobs.release.outputs.major-tag }} + minor-tag: + description: Minor version tag + value: ${{ jobs.release.outputs.minor-tag }} + patch-tag: + description: Patch version tag + value: ${{ jobs.release.outputs.patch-tag }} + +jobs: + release: + name: Create new release + runs-on: ubuntu-latest + outputs: + release-created: ${{ steps.release.outputs.release_created }} + major-tag: ${{ steps.release.outputs.major }} + minor-tag: ${{ steps.release.outputs.minor }} + patch-tag: ${{ steps.release.outputs.patch }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Update release-please config for hotfix branches + if: startsWith(github.ref_name, 'hotfix/') + run: | + # Update versioning to always-bump-patch for hotfix branches + jq '.versioning = "always-bump-patch"' release-please-config.json > tmp.json && mv tmp.json release-please-config.json + echo "Updated release-please-config.json versioning to always-bump-patch" + shell: bash + + - name: Create merge request for new release + uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 + id: release + with: + config-file: release-please-config.json + target-branch: ${{ github.ref_name }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Edit packages versions + if: ${{ ! steps.release.outputs.release_created && steps.release.outputs.pr }} + run: | + export BRANCH=release-please--branches--main--components--console + git fetch + git checkout "$BRANCH" + VERSION=$(jq -r .version package.json) + for pkg in $(jq -r '.apps | map(.path + "/package.json") | .[]' < ./ci/matrix-npm.json); do + yq e -i ".version |= \"$VERSION\"" $pkg + yq e -i ".publishConfig.tag |= \"latest\"" $pkg + done + git add . + # Emulate the author of the existing commit (the bot). + git config user.email "$(git log -n 1 --format='%ae')" + git config user.name "$(git log -n 1 --format='%an')" + # Update the PR. + git commit --amend --no-edit + git push -f + + - name: Tag major and minor versions + if: ${{ steps.release.outputs.release_created }} + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git remote add gh-token "https://${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" + git tag -d v${{ steps.release.outputs.major }} || true + git tag -d v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} || true + git push origin :v${{ steps.release.outputs.major }} || true + git push origin :v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} || true + git tag -a v${{ steps.release.outputs.major }} -m "Release v${{ steps.release.outputs.major }}" + git tag -a v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} -m "Release v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}" + git push origin v${{ steps.release.outputs.major }} + git push origin v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} diff --git a/.jjconflict-side-0/.github/workflows/job-scan.yml b/.jjconflict-side-0/.github/workflows/job-scan.yml new file mode 100644 index 0000000000..3efab69fc5 --- /dev/null +++ b/.jjconflict-side-0/.github/workflows/job-scan.yml @@ -0,0 +1,148 @@ +name: Vulnerability scan + +permissions: + contents: read + pull-requests: write + +on: + workflow_call: + inputs: + REGISTRY: + required: true + type: string + NAMESPACE: + required: true + type: string + TAG: + required: true + type: string + workflow_dispatch: + inputs: + REGISTRY: + description: Target registry to push images + required: true + type: string + default: ghcr.io + NAMESPACE: + description: Target namespace to the given registry + required: true + type: string + default: cloud-pi-native/console + TAG: + description: Tag used to scan images + required: true + type: string + default: latest + +jobs: + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + images-scan: + name: Scan images vulnerabilities + runs-on: ubuntu-latest + needs: + - matrix + strategy: + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run Trivy vulnerability scanner on images + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: "${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ inputs.TAG }}" + format: template + template: "@/contrib/sarif.tpl" + vuln-type: "os,library" + ignore-unfixed: true + output: trivy-results.sarif + github-pat: ${{ secrets.GITHUB_TOKEN }} + cache: "false" + continue-on-error: true + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: trivy-results.sarif + continue-on-error: true + + - name: Warn about error + if: failure() + run: | + echo ":warning: The Trivy scan report for image `${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ inputs.TAG }}` was not successfully completed and could not be uploaded to the Github Security Panel." >> $GITHUB_STEP_SUMMARY + + config-scan: + name: Scan config files vulnerabilities + runs-on: ubuntu-latest + needs: + - matrix + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run Trivy vulnerability scanner on config files + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + scan-ref: . + scan-type: config + format: template + template: "@/contrib/sarif.tpl" + skip-dirs: "**/node_modules,ci" + ignore-unfixed: true + output: trivy-results.sarif + github-pat: ${{ secrets.GITHUB_TOKEN }} + cache: "false" + continue-on-error: true + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: trivy-results.sarif + continue-on-error: true + + - name: Warn about error + if: failure() + run: | + echo ":warning: The Trivy scan report for configuration was not successfully completed and could not be uploaded to the Github Security Panel." >> $GITHUB_STEP_SUMMARY + + scan-notif: + name: Notify users + runs-on: ubuntu-latest + needs: + - matrix + - images-scan + - config-scan + if: ${{ github.event_name == 'pull_request' }} + env: + MERGE_REQUEST_ID: ${{ github.event.pull_request.number }} + COMMENT: | + 🤖 Hey ! + + The security scan report for the current pull request is available [here](https://github.com/${{ github.repository }}/security/code-scanning?query=is%3Aopen+branch%3Amain+pr%3A${{ github.event.pull_request.number || github.event.number }}). + steps: + - name: Add comment on Merge Request + id: add-comment-on-merge-request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.MERGE_REQUEST_ID, + body: process.env.COMMENT, + }); diff --git a/.jjconflict-side-0/.github/workflows/job-tests-unit.yml b/.jjconflict-side-0/.github/workflows/job-tests-unit.yml new file mode 100644 index 0000000000..993ff302cf --- /dev/null +++ b/.jjconflict-side-0/.github/workflows/job-tests-unit.yml @@ -0,0 +1,177 @@ +name: Tests - unit + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + secrets: + SONAR_HOST_URL: + required: false + SONAR_TOKEN: + required: false + SONAR_PROJECT_KEY: + required: false + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + +jobs: + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm -r install --frozen-lockfile + + - name: Build internal dependencies (packages) + run: pnpm --filter "./packages/**" build + + - name: Build internal plugins + run: pnpm --filter "./plugins/**" build + + - name: Run unit tests + run: | + ./ci/scripts/init-env.sh + ./ci/scripts/run-tests.sh -u + + - name: Upload vitest coverage artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: unit-tests-coverage + path: | + ./apps/**/coverage/lcov.info + ./packages/**/coverage/lcov.info + retention-days: 1 + + check-secrets: + name: Check if Sonar secrets are set + runs-on: ubuntu-latest + needs: + - unit-tests + outputs: + run-scan: ${{ steps.check-secrets.outputs.run-scan }} + steps: + - name: Check for sonar secrets + id: check-secrets + run: | + if [ "${{ secrets.SONAR_HOST_URL }}" != "" ] && [ "${{ secrets.SONAR_TOKEN }}" != "" ] && [ "${{ secrets.SONAR_PROJECT_KEY }}" != "" ]; then + echo "run-scan=true" >> $GITHUB_OUTPUT + else + echo "run-scan=false" >> $GITHUB_OUTPUT + fi + + code-scan: + name: Run code quality analysis + runs-on: ubuntu-latest + needs: + - unit-tests + - check-secrets + if: ${{ needs.check-secrets.outputs.run-scan == 'true' }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Download artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unit-tests-coverage + path: ./coverage + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set sonarqube args + id: sonar-args + run: | + echo "SONAR_ARGS_PR=-Dsonar.pullrequest.provider=github -Dsonar.pullrequest.key=${{ github.event.number }} -Dsonar.pullrequest.branch=${{ github.head_ref }} -Dsonar.pullrequest.base=${{ github.base_ref }} -Dsonar.pullrequest.github.repository=${{ github.repository }}" >> $GITHUB_OUTPUT + echo "SONAR_ARGS_BRANCH=-Dsonar.branch.name=${{ github.ref_name }}" >> $GITHUB_OUTPUT + + - name: SonarQube Scan + uses: sonarsource/sonarqube-scan-action@299e4b793aaa83bf2aba7c9c14bedbb485688ec4 # v7.1.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + args: > + -Dsonar.projectKey=${{ secrets.SONAR_PROJECT_KEY }} + -Dsonar.sources=apps,packages + -Dsonar.javascript.node.maxspace=4096 + -Dsonar.javascript.lcov.reportPaths=coverage/apps/server/coverage/lcov.info,coverage/apps/client/coverage/lcov.info,coverage/packages/shared/coverage/lcov.info + -Dsonar.coverage.exclusions=**/*.spec.js,**/*.spec.ts,**/*.vue,**/assets/**,**/packages/test-utils/**,apps/server/src/plugins/** + -Dsonar.cpd.exclusions=**/*.spec.js,**/*.spec.ts + -Dsonar.scm.provider=git + ${{ github.event_name == 'pull_request' && steps.sonar-args.outputs.SONAR_ARGS_PR || steps.sonar-args.outputs.SONAR_ARGS_BRANCH }} + continue-on-error: true + + - name: SonarQube Quality Gate check + id: sonarqube-quality-gate-check + uses: sonarsource/sonarqube-quality-gate-action@cf038b0e0cdecfa9e56c198bbb7d21d751d62c3b # v1.2.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + timeout-minutes: 5 + continue-on-error: true diff --git a/.jjconflict-side-0/.github/workflows/workflow-clean-cache.yml b/.jjconflict-side-0/.github/workflows/workflow-clean-cache.yml new file mode 100644 index 0000000000..d1e5b60e95 --- /dev/null +++ b/.jjconflict-side-0/.github/workflows/workflow-clean-cache.yml @@ -0,0 +1,94 @@ +name: Clean cache + +permissions: + contents: read + # For cache cleaning + actions: write + # For artifacts deletion + packages: write + +on: + pull_request: + types: + - closed + workflow_dispatch: + inputs: + PR_NUMBER: + description: Related pull request Id + required: false + type: number + BRANCH_NAME: + description: Related branch name + required: false + type: string + +jobs: + cleanup-cache: + name: Delete github cache + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Clean cache for closed branch + run: | + REPO=${{ github.repository }} + if [ -n "${{ inputs.BRANCH_NAME }}" ]; then + BRANCH="${{ inputs.BRANCH_NAME }}" + else + BRANCH="refs/pull/${{ github.event.pull_request.number || inputs.PR_NUMBER }}/merge" + fi + + echo "Fetching list of cache key" + cacheKeysForPR=$(gh cache list -R $REPO --ref $BRANCH -L 100 | cut -f 1 ) + + ## Setting this to not fail the workflow while deleting cache keys. + set +e + echo "Deleting caches..." + for cacheKey in $cacheKeysForPR; do + gh cache delete $cacheKey -R $REPO + done + echo "Done" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + cleanup-image: + name: Delete image from ghcr.io + runs-on: ubuntu-latest + needs: + - matrix + strategy: + fail-fast: false + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Get repository owner and name + id: image-infos + run: | + echo "ORG_NAME=$(echo '${{ github.repository }}' | cut -d "/" -f 1)" >> $GITHUB_OUTPUT + echo "REPO_NAME=$(echo '${{ github.repository }}' | cut -d "/" -f 2)" >> $GITHUB_OUTPUT + + - name: Delete ${{ matrix.images.name }} image + run: | + ./ci/scripts/delete-image.sh \ + -o "${{ steps.image-infos.outputs.ORG_NAME }}" \ + -i "${{ steps.image-infos.outputs.REPO_NAME }}/${{ matrix.images.name }}" \ + -t "pr-${{ github.event.pull_request.number || github.event.number }}" \ + -g "${{ secrets.GITHUB_TOKEN }}" diff --git a/.jjconflict-side-0/.github/workflows/workflow-continuous-integration.yml b/.jjconflict-side-0/.github/workflows/workflow-continuous-integration.yml new file mode 100644 index 0000000000..1553fddc93 --- /dev/null +++ b/.jjconflict-side-0/.github/workflows/workflow-continuous-integration.yml @@ -0,0 +1,150 @@ +name: Continuous Integration + +permissions: + contents: read + # Needed for build job + packages: write + # Needed for build-label and scan-vuln jobs + pull-requests: write + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + branches: + - "**" + workflow_dispatch: + +env: + NODE_VERSION: 24.13.1 + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: false + USE_QEMU: false + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ steps.filter.outputs.apps }} + packages: ${{ steps.filter.outputs.packages }} + e2e: ${{ steps.filter.outputs.e2e }} + ci: ${{ steps.filter.outputs.ci }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + ci: + - '.github/workflows/**' + e2e: + - 'playwright/**' + + expose-vars: + runs-on: ubuntu-latest + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + lint: + uses: ./.github/workflows/job-lint.yml + needs: + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + + unit-tests: + uses: ./.github/workflows/job-tests-unit.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' }} + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + secrets: + SONAR_HOST_URL: "${{ secrets.SONAR_HOST_URL }}" + SONAR_TOKEN: "${{ secrets.SONAR_TOKEN }}" + SONAR_PROJECT_KEY: "${{ secrets.SONAR_PROJECT_KEY }}" + + build: + uses: ./.github/workflows/job-build.yml + if: ${{ github.event.pull_request.number && (needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true') }} + needs: + - path-filter + - expose-vars + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.pull_request.number || github.event.number }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.number }} + secrets: + ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }} + + build-label: + uses: ./.github/workflows/job-label.yml + needs: + - expose-vars + - build + with: + CONF_PATH: ./.github/labeler/build.yml + + scan-vuln: + uses: ./.github/workflows/job-scan.yml + needs: + - expose-vars + - build + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.pull_request.number || github.event.number }} + + # Workaround for required status check in protection branches (see. https://github.com/orgs/community/discussions/13690) + all-jobs-passed: + name: Check jobs status + runs-on: ubuntu-latest + if: ${{ always() }} + needs: + - path-filter + - expose-vars + - lint + - unit-tests + - build + - scan-vuln + steps: + - name: Check status of all required jobs + run: |- + NEEDS_CONTEXT='${{ toJson(needs) }}' + JOB_IDS=$(echo "$NEEDS_CONTEXT" | jq -r 'keys[]') + for JOB_ID in $JOB_IDS; do + RESULT=$(echo "$NEEDS_CONTEXT" | jq -r ".[\"$JOB_ID\"].result") + echo "$JOB_ID job result: $RESULT" + if [[ $RESULT != "success" && $RESULT != "skipped" ]]; then + echo "***" + echo "Error: The $JOB_ID job did not pass." + exit 1 + fi + done + echo "All jobs passed or were skipped." diff --git a/.jjconflict-side-0/.github/workflows/workflow-create-or-update-release.yml b/.jjconflict-side-0/.github/workflows/workflow-create-or-update-release.yml new file mode 100644 index 0000000000..70d77d68c7 --- /dev/null +++ b/.jjconflict-side-0/.github/workflows/workflow-create-or-update-release.yml @@ -0,0 +1,93 @@ +name: Create/update next release + +permissions: + contents: write + pull-requests: write + # Needed for build job + packages: write + +on: + push: + branches: + - main + - hotfix/* + +env: + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: true + USE_QEMU: false + NODE_VERSION: 24.13.1 + +jobs: + expose-vars: + runs-on: ubuntu-latest + outputs: + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + NODE_VERSION: ${{ env.NODE_VERSION }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars" + + release: + uses: ./.github/workflows/job-release-please.yml + + build-amd64: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.release.outputs.release-created == 'true' }} + needs: + - expose-vars + - release + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: ${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} + MAJOR_TAG: ${{ needs.release.outputs.major-tag }} + MINOR_TAG: ${{ needs.release.outputs.minor-tag }} + PATCH_TAG: ${{ needs.release.outputs.patch-tag }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: false + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + + build-arm64: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.release.outputs.release-created == 'true' }} + needs: + - expose-vars + - release + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: ${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} + MAJOR_TAG: ${{ needs.release.outputs.major-tag }} + MINOR_TAG: ${{ needs.release.outputs.minor-tag }} + PATCH_TAG: ${{ needs.release.outputs.patch-tag }} + BUILD_AMD64: false + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + + update-chart: + runs-on: ubuntu-latest + needs: + - expose-vars + - release + - build-amd64 + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: helm-charts + - name: Trigger helm-charts update + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + run: | + gh workflow --repo cloud-pi-native/helm-charts run update-chart.yaml -f APP_VERSION=${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} -f CHART_NAME=dso-console diff --git a/.jjconflict-side-0/.github/workflows/workflow-create-preview-comment.yml b/.jjconflict-side-0/.github/workflows/workflow-create-preview-comment.yml new file mode 100644 index 0000000000..e91b3731b1 --- /dev/null +++ b/.jjconflict-side-0/.github/workflows/workflow-create-preview-comment.yml @@ -0,0 +1,43 @@ +name: Add preview comment + +permissions: + pull-requests: write + +on: + pull_request: + types: + - labeled + branches: + - "**" + +jobs: + preview-comment: + name: Add comment with preview infos + if: contains(github.event.pull_request.labels.*.name, 'preview') + runs-on: ubuntu-latest + steps: + - name: Generate app url + id: generate-url + run: | + echo "CONSOLE_URL=$(echo "${{ vars.ARGOCD_CONSOLE_URL_TEMPLATE }}" | sed 's|<pr_number>|${{ github.event.number }}|g')" >> $GITHUB_OUTPUT + + - name: Add comment on Merge Request + id: add-comment-on-merge-request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MERGE_REQUEST_ID: ${{ github.event.pull_request.number }} + COMMENT: | + 🤖 Hey ! + + A preview of the application is available at : ${{ steps.generate-url.outputs.CONSOLE_URL }} + + *Please be patient, deployment may take a few minutes.* + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.MERGE_REQUEST_ID, + body: process.env.COMMENT, + }); diff --git a/.jjconflict-side-0/.github/workflows/workflow-merge-queue.yml b/.jjconflict-side-0/.github/workflows/workflow-merge-queue.yml new file mode 100644 index 0000000000..cca23047d1 --- /dev/null +++ b/.jjconflict-side-0/.github/workflows/workflow-merge-queue.yml @@ -0,0 +1,166 @@ +name: Merge Queue + +permissions: + contents: read + # Needed for build job + packages: write + # Needed for build-label job + pull-requests: write + +on: + workflow_dispatch: + inputs: + PR_NUMBER: + description: Pull request number to run checks for + required: true + type: string + merge_group: + types: [checks_requested] + +env: + NODE_VERSION: 24.13.1 + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: false + USE_QEMU: false + MERGE_GROUP_HEAD_SHA: "${{ github.event.merge_group.head_sha || github.sha }}" + MERGE_GROUP_HEAD_REF: "${{ github.event.merge_group.head_ref || github.ref_name }}" + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.apps }} + packages: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.packages }} + e2e: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.e2e }} + ci: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.ci }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + if: ${{ github.event_name != 'workflow_dispatch' }} + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + ci: + - '.github/workflows/**' + e2e: + - 'playwright/**' + + expose-vars: + runs-on: ubuntu-latest + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + MERGE_GROUP_HEAD_REF: ${{ env.MERGE_GROUP_HEAD_REF }} + MERGE_GROUP_HEAD_SHA: ${{ env.MERGE_GROUP_HEAD_SHA }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + lint: + uses: ./.github/workflows/job-lint.yml + needs: + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + + unit-tests: + uses: ./.github/workflows/job-tests-unit.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + secrets: + SONAR_HOST_URL: "${{ secrets.SONAR_HOST_URL }}" + SONAR_TOKEN: "${{ secrets.SONAR_TOKEN }}" + SONAR_PROJECT_KEY: "${{ secrets.SONAR_PROJECT_KEY }}" + + build: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + PR_NUMBER: ${{ github.event.inputs.PR_NUMBER || github.event.number || '' }} + secrets: + ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }} + + build-label: + uses: ./.github/workflows/job-label.yml + needs: + - expose-vars + - build + with: + CONF_PATH: ./.github/labeler/build.yml + + playwright-tests: + uses: ./.github/workflows/job-playwright.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + - build + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + + scan-vuln: + uses: ./.github/workflows/job-scan.yml + needs: + - expose-vars + - build + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + + # Workaround for required status check in protection branches (see. https://github.com/orgs/community/discussions/13690) + all-jobs-passed: + name: Check jobs status + runs-on: ubuntu-latest + if: ${{ always() }} + needs: + - path-filter + - expose-vars + - lint + - unit-tests + - build + - playwright-tests + - scan-vuln + steps: + - name: Check status of all required jobs + run: |- + NEEDS_CONTEXT='${{ toJson(needs) }}' + JOB_IDS=$(echo "$NEEDS_CONTEXT" | jq -r 'keys[]') + for JOB_ID in $JOB_IDS; do + RESULT=$(echo "$NEEDS_CONTEXT" | jq -r ".[\"$JOB_ID\"].result") + echo "$JOB_ID job result: $RESULT" + if [[ $RESULT != "success" && $RESULT != "skipped" ]]; then + echo "***" + echo "Error: The $JOB_ID job did not pass." + exit 1 + fi + done + echo "All jobs passed or were skipped." diff --git a/.jjconflict-side-0/.github/workflows/workflow-post-merge.yml b/.jjconflict-side-0/.github/workflows/workflow-post-merge.yml new file mode 100644 index 0000000000..da61baa341 --- /dev/null +++ b/.jjconflict-side-0/.github/workflows/workflow-post-merge.yml @@ -0,0 +1,56 @@ +name: Post-merge + +on: + push: + branches: + - main + +env: + NODE_VERSION: 24.13.1 + +permissions: + contents: read + id-token: write + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ steps.filter.outputs.apps }} + packages: ${{ steps.filter.outputs.packages }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + + expose-vars: + runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.draft }} + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + npm-publish: + uses: ./.github/workflows/job-npm.yml + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + PUBLISH_PACKAGES: true + PUBLISH_PLUGINS: true + CHECK_CHANGES: true + # Deprecated variable: we never publish the apps on NPM registry + PUBLISH_APPS: false diff --git a/.jjconflict-side-0/.gitignore b/.jjconflict-side-0/.gitignore new file mode 100644 index 0000000000..0d127c9b66 --- /dev/null +++ b/.jjconflict-side-0/.gitignore @@ -0,0 +1,77 @@ +# code +.vscode/* +!.vscode/vue-dsfr.code-snippets +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + + +# modules +node_modules +**/node_modules + +# env +.env +.env.ci +.env.test +.env.prod +.env.int +.env.integ +.env.codespace +.env.docker + +# init-db +apps/server/src/init/db/imports/data + +# act +**/act/artifacts +**/act/docker/registry/auth +**/act/docker/registry/data + +# vitest +coverage +apps/client/vite.config.*.timestamp-*.mjs +apps/server/vite.config.*.timestamp-*.mjs + +# sonarqube +.scannerwork + +# keycloak +keycloak/data + +# turbo +.turbo + +# kube config +.kubeconfig + +# dist +dist +build +components.d.ts +*.tsbuildinfo + +# types +types/ + +# plugins +apps/server/src/plugins/external + +# helm +charts/ + +# integration helm values +**/env/dso-values-integ.yaml +**/env/kind-values.yml + +# tmp dev files +ci/kind/configs/rendered + +external-plugins + +# Playwright +playwright/test-results/ +playwright/playwright-report/ +playwright/blob-report/ +playwright/playwright/.cache/ diff --git a/.jjconflict-side-0/.husky/commit-msg b/.jjconflict-side-0/.husky/commit-msg new file mode 100755 index 0000000000..8761c9bac9 --- /dev/null +++ b/.jjconflict-side-0/.husky/commit-msg @@ -0,0 +1 @@ +pnpx commitlint --edit ${1} diff --git a/.jjconflict-side-0/.lintstagedrc b/.jjconflict-side-0/.lintstagedrc new file mode 100644 index 0000000000..741d5e9ca3 --- /dev/null +++ b/.jjconflict-side-0/.lintstagedrc @@ -0,0 +1,8 @@ +{ + "**/apps/client/**/*.{vue,css}": "pnpm --filter=@cpn-console/client run lint:style --", + "**/apps/client/**/*": "pnpm --filter=@cpn-console/client run lint:ts --", + "**/apps/server/**/*": "pnpm --filter=@cpn-console/server run lint --", + "**/packages/**/*": "pnpm --filter=./packages/* run lint --", + "**/plugins/**/*": "pnpm --filter=./plugins/* run lint --", + "**/*": "pnpm run lint:root --" +} diff --git a/.jjconflict-side-0/.pinact.yaml b/.jjconflict-side-0/.pinact.yaml new file mode 100644 index 0000000000..3b20588ab3 --- /dev/null +++ b/.jjconflict-side-0/.pinact.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/pinact/refs/heads/main/json-schema/pinact.json +# pinact - https://github.com/suzuki-shunsuke/pinact +version: 3 +# files: +# - pattern: action.yaml +# - pattern: */action.yaml + +# separator: " # " + +ignore_actions: +# - name: slsa-framework/slsa-github-generator/\.github/workflows/generator_generic_slsa3\.yml +# ref: v\d+\.\d+\.\d+ +# - name: actions/.* +# ref: main +# - name: suzuki-shunsuke/.* +# ref: release-.* diff --git a/.jjconflict-side-0/.release-please-manifest.json b/.jjconflict-side-0/.release-please-manifest.json new file mode 100644 index 0000000000..18953d7442 --- /dev/null +++ b/.jjconflict-side-0/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "9.22.2" +} diff --git a/.jjconflict-side-0/.vscode/extensions.json b/.jjconflict-side-0/.vscode/extensions.json new file mode 100644 index 0000000000..3795e131e1 --- /dev/null +++ b/.jjconflict-side-0/.vscode/extensions.json @@ -0,0 +1,30 @@ +{ + "recommendations": [ + // Cross-editor config + "editorconfig.editorconfig", + // Git + "mhutchie.git-graph", + "eamodio.gitlens", + // Node / npm + "christian-kohler.npm-intellisense", + // JavaScript + "dbaeumer.vscode-eslint", + // Vue.js + "vue.volar", + "vue.vscode-typescript-vue-plugin", + // Vitejs + "antfu.vite", + "antfu.browse-lite", + // Firefox Debugger + "firefox-devtools.vscode-firefox-debug", + // CSS + "csstools.postcss", + "stylelint.vscode-stylelint", + // YAML + "redhat.vscode-yaml", + // Makdown + "yzhang.markdown-all-in-one", + // Kubernetes + "ms-kubernetes-tools.vscode-kubernetes-tools" + ] +} diff --git a/.jjconflict-side-0/.vscode/launch.json b/.jjconflict-side-0/.vscode/launch.json new file mode 100644 index 0000000000..fbfb2f1013 --- /dev/null +++ b/.jjconflict-side-0/.vscode/launch.json @@ -0,0 +1,326 @@ +{ + // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. + // Pointez pour afficher la description des attributs existants. + // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Server (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server", + "run", + "integ" + ], + "envFile": "${workspaceFolder}/apps/server/.env.integ", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server", + "run", + "dev" + ], + "envFile": "${workspaceFolder}/apps/server/.env", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Infra (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "run", + "integ" + ], + "console": "internalConsole", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Infra (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "run", + "dev" + ], + "console": "internalConsole", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server NestJS (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server-nestjs", + "run", + "start:dev" + ], + "envFile": "${workspaceFolder}/apps/server-nestjs/.env.integ", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server NestJS (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server-nestjs", + "run", + "start:dev" + ], + "envFile": "${workspaceFolder}/apps/server-nestjs/.env", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Attach Server", + "type": "node", + "request": "attach", + "port": 9229, + "address": "127.0.0.1", + "localRoot": "${workspaceFolder}/apps/server", + "remoteRoot": "/app/apps/server", + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ], + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Chrome) (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "integ", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env.integ", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Chrome)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Chrome) (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "dev", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Chrome)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Firefox) (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "integ", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env.integ", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Firefox)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Firefox) (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "dev", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Firefox)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Launch Client (Chrome)", + "type": "chrome", + "request": "launch", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}/apps/client", + "presentation": { + "hidden": true + } + }, + { + "name": "Launch Client (Firefox)", + "type": "firefox", + "request": "launch", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}/apps/client", + "presentation": { + "hidden": true + } + } + ], + "compounds": [ + { + "name": "Launch (Chrome) (Integ)", + "configurations": [ + "Launch Infra (Integ)", + "Launch Server (Integ)", + "Launch Server NestJS (Integ)", + "Serve Client (Chrome) (Integ)" + ] + }, + { + "name": "Launch (Chrome) (Dev)", + "configurations": [ + "Launch Infra (Dev)", + "Launch Server (Dev)", + "Launch Server NestJS (Dev)", + "Serve Client (Chrome) (Dev)" + ] + }, + { + "name": "Launch (Firefox) (Integ)", + "configurations": [ + "Launch Infra (Integ)", + "Launch Server (Integ)", + "Launch Server NestJS (Integ)", + "Serve Client (Firefox) (Integ)" + ] + }, + { + "name": "Launch (Firefox) (Dev)", + "configurations": [ + "Launch Infra (Dev)", + "Launch Server (Dev)", + "Launch Server NestJS (Dev)", + "Serve Client (Firefox) (Dev)" + ] + } + ] +} diff --git a/.jjconflict-side-0/.vscode/settings.json b/.jjconflict-side-0/.vscode/settings.json new file mode 100644 index 0000000000..1b93a34e04 --- /dev/null +++ b/.jjconflict-side-0/.vscode/settings.json @@ -0,0 +1,120 @@ +{ + // visuals + "window.autoDetectColorScheme": true, + "editor.fontLigatures": "'ss01', 'ss02', 'ss03', 'ss06', 'zero'", + "editor.cursorBlinking": "phase", + "editor.suggestSelection": "first", + "editor.wordWrap": "on", + "editor.bracketPairColorization.enabled": true, + "editor.tabSize": 2, + "search.exclude": { + "**/.git": true, + "**/.nuxt": true, + "**/.pnpm": true, + "**/.yarn": true, + "**/.turbo": true, + "**/dist/**": true, + "**/out/**": true, + "**/logs": true, + "**/node_modules": true, + "**/package-lock.json": true, + "**/pnpm-lock.yaml": true, + "**/yarn.lock": true + }, + // general + "editor.unicodeHighlight.invisibleCharacters": false, + "workbench.startupEditor": "none", + // git + "git.autofetch": true, + "git.untrackedChanges": "separate", + // github + "githubPullRequests.pullBranch": "never", + // eslint + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "vue", + "html", + "markdown", + "json", + "jsonc", + "yaml", + "github-actions-workflow", + "toml", + "xml", + "gql", + "graphql", + "astro", + "svelte", + "css", + "less", + "scss", + "pcss", + "postcss" + ], + // stylelint + "css.validate": true, + "less.validate": true, + "scss.validate": true, + "files.associations": { + "*.css": "css" + }, + // languages + "[javascript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "javascript.preferences.quoteStyle": "single", + "javascript.preferences.importModuleSpecifierEnding": "js", + "javascript.suggest.completeJSDocs": true, + "javascript.suggest.jsdoc.generateReturns": true, + "[typescript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "typescript.preferences.quoteStyle": "single", + "typescript.preferences.importModuleSpecifierEnding": "js", + "typescript.suggest.completeJSDocs": true, + "typescript.suggest.jsdoc.generateReturns": true, + "[vue]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[css]": { + "editor.defaultFormatter": "stylelint.vscode-stylelint" + }, + "[html]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[markdown]": { + "editor.quickSuggestions": { + "other": true, + "comments": true, + "strings": true + }, + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.defaultFormatter": "yzhang.markdown-all-in-one" + }, + // conventional commits + "conventionalCommits.showNewVersionNotes": false, + // kubernetes + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "vscode-kubernetes.log-viewer.follow": true, + "vscode-kubernetes.log-viewer.timestamp": false, + "vscode-kubernetes.log-viewer.since": -1, + "vscode-kubernetes.log-viewer.tail": -1, + "vscode-kubernetes.log-viewer.destination": "Webview", + "vscode-kubernetes.log-viewer.wrap": true, + // sonarqube + "sonarlint.connectedMode.project": { + "connectionId": "https-sonarqube-fabrique-numerique-fr", + "projectKey": "cloud-pi-native_console_AYsa46O31ebDtzs-pYyZ" + }, + "prisma-smart-formatter.typescript.defaultFormatter": "dbaeumer.vscode-eslint", + "prisma-smart-formatter.prisma.defaultFormatter": "Prisma.prisma" +} diff --git a/.jjconflict-side-0/.vscode/vue-dsfr.code-snippets b/.jjconflict-side-0/.vscode/vue-dsfr.code-snippets new file mode 100644 index 0000000000..2533f522d8 --- /dev/null +++ b/.jjconflict-side-0/.vscode/vue-dsfr.code-snippets @@ -0,0 +1,823 @@ +{ + "template": { + "scope": "vue", + "prefix": "template", + "body": [ + "<template>", + " $1", + "</template>" + ], + "description": "Create <template> block" + }, + "script": { + "scope": "vue", + "prefix": "script", + "body": [ + "<script>", + "import { defineComponent } from 'vue';", + "", + "export default defineComponent({", + " $2", + "});", + "</script>" + ], + "description": "Create <script> block" + }, + "script setup": { + "scope": "vue", + "prefix": "script setup", + "body": [ + "<script setup>", + "defineProps({", + " $1", + "});", + "</script>" + ], + "description": "Create <script setup> + <script> blocks" + }, + "style": { + "scope": "vue", + "prefix": "scoped-style", + "body": [ + "<style scoped>", + "$1", + "</style>" + ], + "description": "Create <style> block" + }, + "v-for": { + "scope": "html", + "prefix": "v-for", + "body": [ + "<template v-for=\"$1 in $2\">", + " $3", + "</template>" + ], + "description": "Create html tag with v-for" + }, + "v-if": { + "scope": "html", + "prefix": "v-if", + "body": [ + "<template v-if=\"$1\">", + " $2", + "</template>" + ], + "description": "Create html tag with v-if" + }, + "v-else-if": { + "scope": "html", + "prefix": "v-else-if", + "body": [ + "<template v-else-if=\"$1\">", + " $2", + "</template>" + ], + "description": "Create html tag with v-else-if" + }, + "v-else": { + "scope": "html", + "prefix": "v-else", + "body": [ + "<template v-else>", + " $1", + "</template>" + ], + "description": "Create html tag with v-else" + }, + "DsfrAccordion": { + "scope": "html", + "prefix": "DsfrAccordion", + "body": [ + "<DsfrAccordion", + " :expanded-id=\"${1:expandedId}\"", + " :title=\"${2:accordionTitle}\"", + ">", + " $3", + "</DsfrAccordion>", + ], + "description": "Create DsfrAccordion boilerplate" + }, + "DsfrAccordionsGroup": { + "scope": "html", + "prefix": "DsfrAccordionsGroup", + "body": [ + "<DsfrAccordionsGroup>", + " <li>", + " <DsfrAccordion", + " :expanded-id=\"${1:expandedId}\"", + " :title=\"${2:accordionTitle}\"", + " @expand=\"expandedId = $event\"", + " >", + " </li>", + "</DsfrAccordionsGroup>", + ], + "description": "Create DsfrAccordionsGroup boilerplate" + }, + "DsfrAlert": { + "scope": "html", + "prefix": "DsfrAlert", + "body": [ + "<DsfrAlert", + " :title=\"${1:alertTitle}\"", + " :description=\"${2:alertDescription}\"", + " :type=\"${3:alertType}\"", + " :closed=\"${4:!openAlert}\"", + " ${5:closeable}", + " @close=\"${6:closeAlert()}\"", + "/>", + ], + "description": "Create DsfrAlert boilerplate" + }, + "DsfrBadge": { + "scope": "html", + "prefix": "DsfrBadge", + "body": [ + "<DsfrBadge", + " :label=\"${1}\"", + " :type=\"${2:success}\"", + "/>", + ], + "description": "Create DsfrBadge boilerplate" + }, + "DsfrBreadcrumb": { + "scope": "html", + "prefix": "DsfrBreadcrumb", + "body": [ + "<DsfrBreadcrumb", + " :links=\"${1:links}\"", + "/>", + ], + "description": "Create DsfrBreadcrumb boilerplate" + }, + "DsfrButton": { + "scope": "html", + "prefix": "DsfrButton", + "body": [ + "<DsfrButton", + " type=\"${1:buttonType}\"", + " :label=\"${2:buttonLabel}\"", + " ${3:secondary}", + " @click=\"${4:onClick()}\"", + "/>", + ], + "description": "Create DsfrButton boilerplate" + }, + "DsfrButtonGroup": { + "scope": "html", + "prefix": "DsfrButtonGroup", + "body": [ + "<DsfrButtonGroup", + " :buttons=\"${1:buttons}\"", + " :label=\"${2:buttonLabel}\"", + " ${3:secondary}", + " @click=\"${4:onClick()}\"", + "/>", + ], + "description": "Create DsfrButtonGroup boilerplate" + }, + "DsfrCallout": { + "scope": "html", + "prefix": "DsfrCallout", + "body": [ + "<DsfrCallout", + " :title=\"${1:title}\"", + " :content=\"${2:content}\"", + " :button=\"${3:button}\"", + " :icon=\"${4:icon}\"", + "/>", + ], + "description": "Create DsfrCallout boilerplate" + }, + "DsfrCard": { + "scope": "html", + "prefix": "DsfrCard", + "body": [ + "<DsfrCard", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :detail=\"${3:detail}\"", + " :link=\"${4:link}\"", + " :alt-img=\"${5:altImg}\"", + " :img-src=\"${6:imgSrc}\"", + "/>", + ], + "description": "Create DsfrCard boilerplate" + }, + "DsfrCheckbox": { + "scope": "html", + "prefix": "DsfrCheckbox", + "body": [ + "<DsfrCheckbox", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + " :name=\"${3:name}\"", + " :modelValue=\"${4:checked}\"", + " @update:model-value=\"updateChecked($event)\"", + "/>", + ], + "description": "Create DsfrCheckbox boilerplate" + }, + "DsfrCheckboxSet": { + "scope": "html", + "prefix": "DsfrCheckboxSet", + "body": [ + "<DsfrCheckboxSet", + " v-model=\"${1:selectedCheckbox}\"", + " :legend=\"${2:legend}\"", + " :options=\"${3:options}\"", + "/>", + ], + "description": "Create DsfrCheckboxSet boilerplate" + }, + "DsfrConsent": { + "scope": "html", + "prefix": "DsfrConsent", + "body": [ + "<DsfrConsent", + " :url=\"${1:url}\"", + " @accept-all=\"${2:onAcceptAll()}\"", + " @refuse-all=\"${3:onRefuseAll()}\"", + " @customize=\"${3:onCustomize()}\"", + "/>", + ], + "description": "Create DsfrConsent boilerplate" + }, + "DsfrFieldset": { + "scope": "html", + "prefix": "DsfrFieldset", + "body": [ + "<DsfrFieldset", + " :legend=\"${1:legend}\"", + " :hint=\"${2:hint}\"", + " :legend-class=\"${1:legendClass}\"", + " :hint-class=\"${1:hintClass}\"", + " :legend-id=\"${1:legendId}\"", + "/>", + ], + "description": "Create DsfrFieldset boilerplate" + }, + "DsfrFileUpload": { + "scope": "html", + "prefix": "DsfrFileUpload", + "body": [ + "<DsfrFileUpload", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + "/>", + ], + "description": "Create DsfrFileUpload boilerplate" + }, + "DsfrFollow": { + "scope": "html", + "prefix": "DsfrFollow", + "body": [ + "<DsfrFollow", + " :networks=\"${1:networks}\"", + " :newsletter-data=\"${2:newsletterData}\"", + "/>", + ], + "description": "Create DsfrFollow boilerplate" + }, + "DsfrFranceConnect": { + "scope": "html", + "prefix": "DsfrFranceConnect", + "body": [ + "<DsfrFranceConnect ${1:secure} />", + ], + "description": "Create DsfrFranceConnect boilerplate" + }, + "DsfrNewsLetter": { + "scope": "html", + "prefix": "DsfrNewsLetter", + "body": [ + "<DsfrNewsLetter", + " v-model:email=\"${1:email}\"", + " :title=\"${2:title}\"", + " :description=\"${3:description}\"", + " :labelEmail=\"${4:labelEmail}\"", + " :inputTitle=\"${5:inputTitle}\"", + " :placeholder=\"${6:placeholder}\"", + " :hintText=\"${7:hintText}\"", + " :buttonText=\"${8:buttonText}\"", + "/>", + ], + "description": "Create DsfrNewsLetter boilerplate" + }, + "DsfrSocialNetworks": { + "scope": "html", + "prefix": "DsfrSocialNetworks", + "body": [ + "<DsfrSocialNetworks", + " :networks=\"${1:networks}\"", + "/>", + ], + "description": "Create DsfrSocialNetworks boilerplate" + }, + "DsfrFooter": { + "scope": "html", + "prefix": "DsfrFooter", + "body": [ + "<DsfrFooter", + " :a11y-compliance=\"${1:a11yCompliance}\"", + " :logo-text=\"${2:logoText}\"", + " :legal-link=\"${3:legalLink}\"", + " :personal-data-link=\"${4:personalDataLink}\"", + " :cookies-link=\"${5:cookiesLink}\"", + " :a11y-compliance-link=\"${6:a11yComplianceLink}\"", + " :desc-text=\"${7:descText}\"", + " :home-link=\"${8:homeLink}\"", + " :partners=\"${9:partners}\"", + " :ecosystem-links=\"${10:ecosystemLinks}\"", + "/>", + ], + "description": "Create DsfrFooter boilerplate" + }, + "DsfrHeader": { + "scope": "html", + "prefix": "DsfrHeader", + "body": [ + "<DsfrHeader", + " :service-title=\"${1:serviceTitle}\"", + " :service-description=\"${2:serviceDescription}\"", + " :logo-text=\"${3:logoText}\"", + " :quick-links=\"${4:quickLinks}\"", + " ${5:show-search}", + "/>", + ], + "description": "Create DsfrHeader boilerplate" + }, + "DsfrHighlight": { + "scope": "html", + "prefix": "DsfrHighlight", + "body": [ + "<DsfrHighlight", + " :text=\"${1:text}\"", + "/>", + ], + "description": "Create DsfrHighlight boilerplate" + }, + "DsfrInput": { + "scope": "html", + "prefix": "DsfrInput", + "body": [ + "<DsfrInput", + " v-model=\"${1:inputValue}\"", + " type=\"${2:inputType}\"", + " :label=\"${3:inputLabel}\"", + " ${4:label-visible}", + " :placeholder=\"${5:inputPlaceholder}\"", + "/>", + ], + "description": "Create DsfrInput boilerplate" + }, + "DsfrLogo": { + "scope": "html", + "prefix": "DsfrLogo", + "body": [ + "<DsfrLogo", + " logo-text=\"$1\"", + "/>", + ], + "description": "Create DsfrLogo boilerplate" + }, + "DsfrPicture": { + "scope": "html", + "prefix": "DsfrPicture", + "body": [ + "<DsfrPicture", + " :src=\"${1:src}\"", + " :alt=\"${2:alt}\"", + " :title=\"${3:title}\"", + " :legend=\"${4:legend}\"", + "/>", + ], + "description": "Create DsfrPicture boilerplate" + }, + "DsfrVideo": { + "scope": "html", + "prefix": "DsfrVideo", + "body": [ + "<DsfrVideo", + " :src=\"${1:src}\"", + " :legend=\"${2:legend}\"", + "/>", + ], + "description": "Create DsfrVideo boilerplate" + }, + "DsfrModal": { + "scope": "html", + "prefix": "DsfrModal", + "body": [ + "<DsfrButton", + " label=\"${1:Ouvre la modale}\"", + " ref=\"modalOrigin\"", + " @click=\"${2:open()}\"", + "/>", + "<DsfrModal", + " :opened=\"${3:opened}\"", + " :title=\"${4:title}\"", + " :origin=\"$refs.modalOrigin\"", + ">", + " $5", + "</DsfrModal>", + ], + "description": "Create DsfrModal boilerplate" + }, + "DsfrNavigation": { + "scope": "html", + "prefix": "DsfrNavigation", + "body": [ + "<DsfrNavigation", + " :nav-items=\"${1:navItems}\"", + "/>", + ], + "description": "Create DsfrNavigation boilerplate" + }, + "DsfrNavigationItem": { + "scope": "html", + "prefix": "DsfrNavigationItem", + "body": [ + "<DsfrNavigationItem :active=\"${1:active}\">", + " $2", + "</DsfrNavigationItem>", + ], + "description": "Create DsfrNavigationItem boilerplate" + }, + "DsfrNavigationMegaMenu": { + "scope": "html", + "prefix": "DsfrNavigationMegaMenu", + "body": [ + "<DsfrNavigationMegaMenu", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :link=\"${3:link}\"", + " :menus=\"${4:menus}\"", + " :expanded-id=\"${5:expandedId}\"", + " :@click=\"${6:toggle($event)}\"", + ">", + " $7", + "</DsfrNavigationMegaMenu>", + ], + "description": "Create DsfrNavigationMegaMenu boilerplate" + }, + "DsfrNavigationMegaMenuCategory": { + "scope": "html", + "prefix": "DsfrNavigationMegaMenuCategory", + "body": [ + "<DsfrNavigationMegaMenuCategory", + " :title=\"${1:title}\"", + " :links=\"${2:links}\"", + "/>", + ], + "description": "Create DsfrNavigationMegaMenuCategory boilerplate" + }, + "DsfrNavigationMenu": { + "scope": "html", + "prefix": "DsfrNavigationMenu", + "body": [ + "<DsfrNavigationMenu", + " :title=\"${1:title}\"", + " :links=\"${2:links}\"", + " :expanded-id=\"${3:expandedId}\"", + " :@click=\"${4:toggle($event)}\"", + "/>", + ], + "description": "Create DsfrNavigationMenu boilerplate" + }, + "DsfrNavigationMenuItem": { + "scope": "html", + "prefix": "DsfrNavigationMenuItem", + "body": [ + "<DsfrNavigationMenuItem", + " :active=\"${1:active}\"", + ">", + "</DsfrNavigationMenuItem>", + ], + "description": "Create DsfrNavigationMenuItem boilerplate" + }, + "DsfrNavigationMenuLink": { + "scope": "html", + "prefix": "DsfrNavigationMenuLink", + "body": [ + "<DsfrNavigationMenuLink", + " :to=\"${1:to}\"", + " :text=\"${2:text}\"", + "/>", + ], + "description": "Create DsfrNavigationMenuLink boilerplate" + }, + "DsfrPagination": { + "scope": "html", + "prefix": "DsfrPagination", + "body": [ + "<DsfrPagination", + " :pages=\"${1:pages}\"", + " v-model:current-page=\"${2:currentPage}\"", + "/>", + ], + "description": "Create DsfrPagination boilerplate" + }, + "DsfrQuote": { + "scope": "html", + "prefix": "DsfrQuote", + "body": [ + "<DsfrQuote", + " :quote=\"${1:quote}\"", + " :author=\"${2:author}\"", + " :details=\"${3:details}\"", + " :illustration=\"${4:illustration}\"", + " :source=\"${5:source}\"", + " :sourceUrl=\"${6:sourceUrl}\"", + " :quoteImage=\"${7:quoteImage}\"", + "/>", + ], + "description": "Create DsfrQuote boilerplate" + }, + "DsfrRadioButton": { + "scope": "html", + "prefix": "DsfrRadioButton", + "body": [ + "<DsfrRadioButton", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + " :name=\"${3:name}\"", + " :value=\"${4:value}\"", + " :model-value=\"${5:modelValue}\"", + " @update:model-value=\"${6:updateCheckedValue($event)}\"", + "/>", + ], + "description": "Create DsfrRadioButton boilerplate" + }, + "DsfrRadioButtonSet": { + "scope": "html", + "prefix": "DsfrRadioButtonSet", + "body": [ + "<DsfrRadioButtonSet", + " v-model=\"${1:modelValue}\"", + " :legend=\"${2:legend}\"", + " name=\"${2:radio-set}\"", + " :options=\"${4:options}\"", + " @update:model-value=\"${6:updateCheckedValue($event)}\"", + "/>", + ], + "description": "Create DsfrRadioButtonSet boilerplate" + }, + "DsfrSearchBar": { + "scope": "html", + "prefix": "DsfrSearchBar", + "body": [ + "<DsfrSearchBar", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :placeholder=\"${3:placeholder}\"", + " ${4:label-visible}", + " :button-text=\"${5:buttonText}\"", + "/>", + ], + "description": "Create DsfrSearchBar boilerplate" + }, + "DsfrSelect": { + "scope": "html", + "prefix": "DsfrSelect", + "body": [ + "<DsfrSelect", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :options=\"${3:options}\"", + "/>", + ], + "description": "Create DsfrSelect boilerplate" + }, + "DsfrShare": { + "scope": "html", + "prefix": "DsfrShare", + "body": [ + "<DsfrShare", + " :title=\"${1:title}\"", + " :networks=\"${2:networks}\"", + " :mail=\"${3:mail}\"", + " :copy-label=\"${4:copyLabel}\"", + "/>", + ], + "description": "Create DsfrShare boilerplate" + }, + "DsfrSideMenu": { + "scope": "html", + "prefix": "DsfrSideMenu", + "body": [ + "<DsfrSideMenu", + " :heading-title=\"${1:headingTitle}\"", + " :button-label=\"${2:buttonLabel}\"", + " :menu-items=\"${3:menuItems}\"", + " @toggle-expand=\"${4:toggleExpand($event)}\"", + "/>", + ], + "description": "Create DsfrSideMenu boilerplate" + }, + "DsfrSideMenuButton": { + "scope": "html", + "prefix": "DsfrSideMenuButton", + "body": [ + "<DsfrSideMenuButton", + " :expanded=\"${1:expanded}\"", + " :button-label=\"${2:buttonLabel}\"", + " :control-id=\"${3:controlId}\"", + " @toggle-expand=\"${4:toggleExpand($event)}\"", + "/>", + ], + "description": "Create DsfrSideMenuButton boilerplate" + }, + "DsfrSideMenuLink": { + "scope": "html", + "prefix": "DsfrSideMenuLink", + "body": [ + "<DsfrSideMenuLink", + " :active=\"${1:active}\"", + " :to=\"${2:to}\"", + "/>", + ], + "description": "Create DsfrSideMenuLink boilerplate" + }, + "DsfrSideMenuList": { + "scope": "html", + "prefix": "DsfrSideMenuList", + "body": [ + "<DsfrSideMenuList", + " :menu-items=\"${1:menuItems}\"", + " @toggle-expand=\"${2:toggleExpand}\"", + "/>", + ], + "description": "Create DsfrSideMenuList boilerplate" + }, + "DsfrSideMenuListItem": { + "scope": "html", + "prefix": "DsfrSideMenuListItem", + "body": [ + "<DsfrSideMenuListItem", + " :active=\"${1:active}\"", + ">", + " $2", + "</DsfrSideMenuListItem>", + ], + "description": "Create DsfrSideMenuListItem boilerplate" + }, + "DsfrSkipLinks": { + "scope": "html", + "prefix": "DsfrSkipLinks", + "body": [ + "<DsfrSkipLinks", + " :links=\"${1:links}\"", + "/>", + ], + "description": "Create DsfrSkipLinks boilerplate" + }, + "DsfrTable": { + "scope": "html", + "prefix": "DsfrTable", + "body": [ + "<DsfrTable", + " :title=\"${1:title}\"", + " :headers=\"${2:headers}\"", + " :rows=\"${3:rows}\"", + "/>", + ], + "description": "Create DsfrTable boilerplate" + }, + "DsfrTableCell": { + "scope": "html", + "prefix": "DsfrTableCell", + "body": [ + "<DsfrTableCell", + " :field=\"${1:field}\"", + "/>", + ], + "description": "Create DsfrTableCell boilerplate" + }, + "DsfrTableHeader": { + "scope": "html", + "prefix": "DsfrTableHeader", + "body": [ + "<DsfrTableHeader", + " :header=\"${1:header}\"", + " :header-attrs=\"${2:headerAttrs}\"", + "/>", + ], + "description": "Create DsfrTableHeader boilerplate" + }, + "DsfrTableHeaders": { + "scope": "html", + "prefix": "DsfrTableHeaders", + "body": [ + "<DsfrTableHeaders", + " :headers=\"${1:headers}\"", + "/>", + ], + "description": "Create DsfrTableHeaders boilerplate" + }, + "DsfrTableRow": { + "scope": "html", + "prefix": "DsfrTableRow", + "body": [ + "<DsfrTableRow", + " :row-data=\"${1:rowData}\"", + "/>", + ], + "description": "Create DsfrTableRow boilerplate" + }, + "DsfrTabContent": { + "scope": "html", + "prefix": "DsfrTabContent", + "body": [ + "<DsfrTabContent", + " :panel-id=\"${1:panelId}\"", + " :tab-id=\"${2:tabId}\"", + " :selected=\"${3:selected}\"", + " :asc=\"${4:asc}\"", + ">", + " $5", + "</DsfrTabContent>", + ], + "description": "Create DsfrTabContent boilerplate" + }, + "DsfrTabItem": { + "scope": "html", + "prefix": "DsfrTabItem", + "body": [ + "<DsfrTabItem", + " :tab-id=\"${2:tabId}\"", + " :panel-id=\"${1:panelId}\"", + " :selected=\"${3:selected}\"", + " :icon=\"${4:icon}\"", + ">", + " $5", + "</DsfrTabItem>", + ], + "description": "Create DsfrTabItem boilerplate" + }, + "DsfrTabs": { + "scope": "html", + "prefix": "DsfrTabs", + "body": [ + "<DsfrTabs", + " :tab-list-name=\"${1:tabListName}\"", + " :tab-titles=\"${2:tabTitles}\"", + " :tab-contents=\"${3:tabContents}\"", + "/>", + ], + "description": "Create DsfrTabs boilerplate" + }, + "DsfrTag": { + "scope": "html", + "prefix": "DsfrTag", + "body": [ + "<DsfrTag", + " :label=\"${1:label}\"", + "/>", + ], + "description": "Create DsfrTag boilerplate" + }, + "DsfrTags": { + "scope": "html", + "prefix": "DsfrTags", + "body": [ + "<DsfrTags", + " :tags=\"${1:tags}\"", + "/>", + ], + "description": "Create DsfrTags boilerplate" + }, + "DsfrTile": { + "scope": "html", + "prefix": "DsfrTile", + "body": [ + "<DsfrTile", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :to=\"${3:to}\"", + " :rows=\"${4:rows}\"", + "/>", + ], + "description": "Create DsfrTile boilerplate" + }, + "DsfrTiles": { + "scope": "html", + "prefix": "DsfrTiles", + "body": [ + "<DsfrTiles :tiles=\"${1:tiles}\" />", + ], + "description": "Create DsfrTiles boilerplate" + }, + "DsfrToggleSwitch": { + "scope": "html", + "prefix": "DsfrToggleSwitch", + "body": [ + "<DsfrToggleSwitch", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :hint=\"${2:hint}\"", + "/>", + ], + "description": "Create DsfrToggleSwitch boilerplate" + }, +} \ No newline at end of file diff --git a/.jjconflict-side-0/AGENTS.md b/.jjconflict-side-0/AGENTS.md new file mode 100644 index 0000000000..77866c7703 --- /dev/null +++ b/.jjconflict-side-0/AGENTS.md @@ -0,0 +1,85 @@ +# Console Cloud Pi Native + +pnpm monorepo. Node >= 24, pnpm v11.8 + +## Structure + +- `apps/client` : Vue 3 + Vite + vue-dsfr (French gov design system), Pinia, UnoCSS +- `apps/server` : Fastify 4 + Prisma 6 (PostgreSQL), contract-first API via @ts-rest +- `apps/server-nestjs` : NestJS rewrite (in progress) +- `plugins/*` : argocd, gitlab, harbor, keycloak, kubernetes, nexus, sonarqube, vault +- `packages/shared` : API contracts (@ts-rest), types, permissions (BigInt bitmasks) +- `packages/hooks` : plugin hook system (core of plugin architecture) +- `packages/test-utils`, `packages/eslintconfig`, `packages/tsconfig` +- `playwright/` : E2E tests (dedicated workspace) +- `docker/` : docker-compose files (local, dev, integ, prod, ci) +- `ci/scripts/` : init-env.sh, setup.sh, cleanup.sh, run-tests.sh +- `keycloak/` : DSFR theme and dev realm data + +## Server architecture (Fastify) + +Resource-based organization in `apps/server/src/resources/`. Each resource follows a 3-file pattern: +- `router.ts` : route handlers (auth, permissions, delegates to business) +- `business.ts` : business logic, orchestrates queries + hook calls +- `queries.ts` : Prisma database queries + +API contracts defined in `@cpn-console/shared`, shared with client via @ts-rest. +Auth: Keycloak + Fastify session. Permissions: BigInt bitmasks (`ProjectAuthorized`, `AdminAuthorized`). + +## Plugin / Hook system + +Hook lifecycle: `pre` -> `main` -> `post` (sequential steps, parallel plugin execution). On failure: `revert`. +Plugins are statically imported in `apps/server/src/plugins.ts`, then external plugins dynamically loaded from `/plugins`. +Each plugin: `index.ts` (Plugin interface), `infos.ts` (metadata/config), `functions.ts` (hook handlers). +Plugins use TS module augmentation to extend `ProjectStore` and `Config` interfaces. + +## Database (Prisma) + +Multi-file schema in `apps/server/src/prisma/schema/*.prisma` (project, user, token, admin, topography). +Migrations: standard Prisma Migrate. Major version data migrations in `migrations/v9/`. + +## Environment config + +- Files: `.env`, `.env.docker`, `.env.integ` in `apps/client/`, `apps/server/`, `apps/server-nestjs/` +- Templates: `*-example` suffix (git-tracked), active files gitignored +- Override chain (weakest to strongest): `.env` < `.env.docker` (if DOCKER=true) < `.env.integ` (if INTEGRATION=true) < explicit env vars +- Server loading: `apps/server/src/utils/env.ts` | Client: `apps/client/vite.config.ts` + +## Testing + +- **Vitest**: unit tests everywhere (server, client, packages, plugins) — colocated `*.spec.ts` files +- **Playwright**: E2E in `playwright/` (Chromium + Firefox, parallel) +- Commands: `pnpm test` (all unit), `pnpm playwright:test` + +## Code quality + +- ESLint 9 flat config based on `@antfu/eslint-config` (no Prettier, except server-nestjs) +- Stylelint for CSS/Vue in client +- Husky hooks: pre-commit (lint-staged), commit-msg (commitlint), pre-push (unit tests) +- Conventional commits enforced: `feat`, `fix`, `chore`, `docs`, `refactor`, `revert`, `build` + +## TypeScript + +- Shared base: `packages/tsconfig/tsconfig.base.json` — ESNext, NodeNext, strict, `@/* -> src/*` alias +- Server: extends shared base, uses `ts-patch`/`tspc` for path transform in emitted JS +- Client: does NOT extend shared base, uses `Bundler` module resolution +- server-nestjs: standalone config with `emitDecoratorMetadata` + `experimentalDecorators` + +## Main commands + +- `pnpm dev` : local Docker infra (keycloak, postgres, pgadmin) + server/client +- `pnpm docker:dev` : fully containerized with Docker Compose Watch +- `pnpm docker:integ` / `pnpm integ` : integration mode (remote env) +- `pnpm fullsetup` : full install (deps, prisma generate, build, Docker images) +- `pnpm fullclean` : full cleanup (builds, node_modules, Docker images) + +## Git & Release + +- Branches: `main` (protected) + `hotfix/*` +- Release Please for automated versioning, changelogs, npm publish, Docker images, Helm chart updates +- PR template: `.github/PULL_REQUEST_TEMPLATE.md` + +## Conventions + +- Template env files use `-example` suffix (not `.example`) +- `ci/scripts/init-env.sh` copies `*-example` to active equivalents (non-destructive) diff --git a/.jjconflict-side-0/CHANGELOG.md b/.jjconflict-side-0/CHANGELOG.md new file mode 100644 index 0000000000..642437ef86 --- /dev/null +++ b/.jjconflict-side-0/CHANGELOG.md @@ -0,0 +1,3507 @@ +# Changelog + +## [9.22.2](https://github.com/cloud-pi-native/console/compare/v9.22.1...v9.22.2) (2026-07-24) + + +### Bug Fixes + +* ArgoCD internal url for health check ([9429667](https://github.com/cloud-pi-native/console/commit/942966785e23fca986246948033afe60d40d7232)) + +## [9.22.1](https://github.com/cloud-pi-native/console/compare/v9.22.0...v9.22.1) (2026-07-21) + + +### Bug Fixes + +* **plugins/nexus:** remove invalid cleanup policy from repo provisioning to fix Nexus 3.85+ ([8b99cb6](https://github.com/cloud-pi-native/console/commit/8b99cb6a7807dac1f6ba525a6ee3164f3fe13141)) + +## [9.22.0](https://github.com/cloud-pi-native/console/compare/v9.21.0...v9.22.0) (2026-07-21) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** restrict deployment visibility to console admins ([def61e7](https://github.com/cloud-pi-native/console/commit/def61e76b3a867445c5ddb1c08e0f3af59b188ae)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([52c8173](https://github.com/cloud-pi-native/console/commit/52c8173f5610e83836b661d7472c0fe3ff22f70f)) +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* :construction: make all new cluster external by default ([dcd203f](https://github.com/cloud-pi-native/console/commit/dcd203f3569d93a74e0d627f6cf3eb898d6b338d)) +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* add old-server new Nest.js services to CpinModule ([a17c109](https://github.com/cloud-pi-native/console/commit/a17c109616270115af701366ae8014e05cb41e7c)) +* add server-nestjs README to explain what we are doing ([57a4ca5](https://github.com/cloud-pi-native/console/commit/57a4ca51086b7c7af44a1df83cd272b82eb9a9f8)) +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* apply prettier on old-server ([6a781ac](https://github.com/cloud-pi-native/console/commit/6a781ac80c756924c53e28815064197b63508ff4)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* bump turbo to v2.7.1 ([d955e52](https://github.com/cloud-pi-native/console/commit/d955e52e65ed829053fdb120b217cb2744bd02c2)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** trigger Playwright E2E tests before Cypress E2E tests ([2262f37](https://github.com/cloud-pi-native/console/commit/2262f3717101833217d5963580e07fb4f69e9847)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* **client:** commit auto-imports.d.ts ([4252266](https://github.com/cloud-pi-native/console/commit/42522663edf5151ce7e38c36d4eb5d2d13b24a48)) +* **client:** do not install pnpm twice ([43ff3a3](https://github.com/cloud-pi-native/console/commit/43ff3a3e6690c901d4905236b627016ee8b4f043)) +* **client:** ensure build fails if typing system is not sound ([97297b5](https://github.com/cloud-pi-native/console/commit/97297b58a80bee7167171d39b16404f077fef03b)) +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* **client:** fix types ([131b2b6](https://github.com/cloud-pi-native/console/commit/131b2b64926493d94ba1e07c3501e331d0fcac8c)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* convert cypress dashboard e2e tests to playwright ([2badd91](https://github.com/cloud-pi-native/console/commit/2badd91181d4960d40898cfa01fbcfd0b13667aa)) +* convert plugin manager to NestJS service ([2ae46e3](https://github.com/cloud-pi-native/console/commit/2ae46e38787b3521911786898bee881c23fdb08b)) +* disable tests from nest.js for now ([5137c10](https://github.com/cloud-pi-native/console/commit/5137c10ed9296c761c3d31877bf54a411bbee140)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **e2e:** avoid launching Playwright and Cypress E2E tests at the same time ([1f203a8](https://github.com/cloud-pi-native/console/commit/1f203a8d35d6b1e4e69076f289256c7192a356da)) +* **e2e:** migrate Admin Role tests from Cypress to Playwright ([2112398](https://github.com/cloud-pi-native/console/commit/2112398f85b1ab7a5e332b798f68d4f04894d51d)) +* **e2e:** migrate Duplicate Zone test from Cypress to Playwright ([748ec80](https://github.com/cloud-pi-native/console/commit/748ec80917af7ef34b1f7922a13703706e5e41cc)) +* **e2e:** migrate last Role tests from Cypress to Playwright ([176ea34](https://github.com/cloud-pi-native/console/commit/176ea3481fc5b282497ea82a7562d735b11b325f)) +* **e2e:** migrate last Zone tests from Cypress to Playwright ([dcff338](https://github.com/cloud-pi-native/console/commit/dcff3384e695e95c9d2ae2b08486ce4ca47fa769)) +* **e2e:** migrate Project archive test from Cypress to Playwright ([0567931](https://github.com/cloud-pi-native/console/commit/0567931723cccf2fc54cb5560c6dd00043211f58)) +* **e2e:** migrate repository tests from Cypress to Playwright ([8370b8b](https://github.com/cloud-pi-native/console/commit/8370b8b4adbe5c1b65bc5f45bd50c860beeba96c)) +* **e2e:** migrate some Zone tests from Cypress to Playwright ([425e8c9](https://github.com/cloud-pi-native/console/commit/425e8c9672b276507983b6b0685bcf475bdb7c6c)) +* **e2e:** migrate Token tests from Cypress to Playwright ([86cb815](https://github.com/cloud-pi-native/console/commit/86cb8158285be552a1d0e94ebd30600858b9193a)) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* fix all typescript import errors by massively creating NestJs services ([68b7cb7](https://github.com/cloud-pi-native/console/commit/68b7cb75407dbb8d630dd4a50e4832e97796008a)) +* fix any type in AppService ([627e1e9](https://github.com/cloud-pi-native/console/commit/627e1e9f0cac82bcaa9b3b3a6337c3f2b447e1e5)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) +* **gitlab-plugin:** interalize awaited import to avoid top-level-await errors ([6472c74](https://github.com/cloud-pi-native/console/commit/6472c742c9ec74f834da994db697fcfb7d385c7e)) +* **gitlab:** purge useless file ([2637156](https://github.com/cloud-pi-native/console/commit/263715641844bb7d766730a3de9240531c9f26e3)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **hotfix/revert-server-nestjs-offload:** Release v9.20.1 ([8ebeae7](https://github.com/cloud-pi-native/console/commit/8ebeae7717d0aeac93ce14102a137f4aac62c409)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* initialize nestjs dedicated app ([a533963](https://github.com/cloud-pi-native/console/commit/a53396329455c9c856274f3c419e2ff377e12dba)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) +* **main:** release 9.10.0 ([c87ad31](https://github.com/cloud-pi-native/console/commit/c87ad3114149c4af995be42e130d03efd2e301d9)) +* **main:** release 9.10.1 ([c4b4649](https://github.com/cloud-pi-native/console/commit/c4b464985cfafc7d60246621e9beb82760b73200)) +* **main:** release 9.10.2 ([20a233a](https://github.com/cloud-pi-native/console/commit/20a233af09a3ef918a7b1de36057374dd4817726)) +* **main:** release 9.11.0 ([c03e2ef](https://github.com/cloud-pi-native/console/commit/c03e2ef8c4e448784528ae7dee68edff36e48930)) +* **main:** release 9.11.1 ([7580384](https://github.com/cloud-pi-native/console/commit/7580384525c7cdddbd51e7b0b9b4154ca624b058)) +* **main:** release 9.11.2 ([c7de3a1](https://github.com/cloud-pi-native/console/commit/c7de3a15bd54730655e3a75523c29e9f313a18d9)) +* **main:** release 9.11.3 ([9d2df48](https://github.com/cloud-pi-native/console/commit/9d2df48e3588a819fe5a63467dee773c5e1963c3)) +* **main:** release 9.11.4 ([9c873e1](https://github.com/cloud-pi-native/console/commit/9c873e1b5d1ec3d59b86b6ae19b0fe055f3ed669)) +* **main:** release 9.11.5 ([5fc68de](https://github.com/cloud-pi-native/console/commit/5fc68de5cf2a22f33171f03132798df20701df1b)) +* **main:** release 9.8.1 ([bb94301](https://github.com/cloud-pi-native/console/commit/bb9430196e042847931719ec518d5b9685a67585)) +* **main:** release 9.9.0 ([aa755da](https://github.com/cloud-pi-native/console/commit/aa755dac97ffd563bb0684fe669268b95c6d1362)) +* **main:** Release v9.12.0 ([3a14b03](https://github.com/cloud-pi-native/console/commit/3a14b03a7284ee8ee8a5e9d54d7ba842832c4017)) +* **main:** Release v9.12.0 ([4f6e1a8](https://github.com/cloud-pi-native/console/commit/4f6e1a8d8b630335c89a12b4daf4941e1dcdc32a)) +* **main:** Release v9.13.0 ([4daaa1d](https://github.com/cloud-pi-native/console/commit/4daaa1dce01276f83c6870ffe1c13af096547621)) +* **main:** Release v9.13.0 ([613ba30](https://github.com/cloud-pi-native/console/commit/613ba30b9f7f41e6db64fa8957709bca96e53847)) +* **main:** Release v9.13.1 ([0b39ff1](https://github.com/cloud-pi-native/console/commit/0b39ff1747d65fae9d4d4c6409421a30b1c57fc4)) +* **main:** Release v9.13.1 ([96ed6a3](https://github.com/cloud-pi-native/console/commit/96ed6a318a74978a9c2b8713d65100f1c0ca2f3d)) +* **main:** Release v9.13.2 ([400e9a7](https://github.com/cloud-pi-native/console/commit/400e9a71b28879c2bcd382ed4a43f55757d7aa2e)) +* **main:** Release v9.13.2 ([e2d09e3](https://github.com/cloud-pi-native/console/commit/e2d09e3f476fc958967089bc0ac9c5054b7eb5ac)) +* **main:** Release v9.14.0 ([5c62e19](https://github.com/cloud-pi-native/console/commit/5c62e191e0ab426dff4b5c390817a15345e3bdb0)) +* **main:** Release v9.14.1 ([8bccc81](https://github.com/cloud-pi-native/console/commit/8bccc81517f8cf39ddc5e1be972d4efa701b5e41)) +* **main:** Release v9.15.0 ([7b50776](https://github.com/cloud-pi-native/console/commit/7b50776d35fa73d1a28f6fb21010ffad85bafaf1)) +* **main:** Release v9.16.0 ([68abf2e](https://github.com/cloud-pi-native/console/commit/68abf2e5afd1fac52fad95c1319a4b50e8ac6275)) +* **main:** Release v9.16.1 ([3ba4c0b](https://github.com/cloud-pi-native/console/commit/3ba4c0bf08d899b614875cac99af7345a61998c8)) +* **main:** Release v9.17.0 ([a4061a6](https://github.com/cloud-pi-native/console/commit/a4061a61137c40c5b496e6649645b0b017723407)) +* **main:** Release v9.17.1 ([f8f7952](https://github.com/cloud-pi-native/console/commit/f8f795208ce32b5539e4d06c84f8579d1ae7ad2d)) +* **main:** Release v9.18.0 ([775bd1e](https://github.com/cloud-pi-native/console/commit/775bd1e82a598278ae2317bb775054b9e0627a36)) +* **main:** Release v9.18.1 ([261d9a3](https://github.com/cloud-pi-native/console/commit/261d9a367b71e8598c7956e4b76b612550244ffd)) +* **main:** Release v9.18.2 ([0a75369](https://github.com/cloud-pi-native/console/commit/0a753695320428332119401d6de53dc576538d72)) +* **main:** Release v9.18.3 ([f8cadc4](https://github.com/cloud-pi-native/console/commit/f8cadc47d40393c8fd040b0740957cff39ae6cb4)) +* **main:** Release v9.18.4 ([71dc32b](https://github.com/cloud-pi-native/console/commit/71dc32bf1f3d16a87a39f5d6378f3b7876bf2e42)) +* **main:** Release v9.19.0 ([c7d0f41](https://github.com/cloud-pi-native/console/commit/c7d0f41d7ecfd5d4645872de743d83d90b97bdab)) +* **main:** Release v9.20.0 ([9ee41b3](https://github.com/cloud-pi-native/console/commit/9ee41b36597cdd79c632ae3b2d3eb5b032781819)) +* **main:** Release v9.20.2 ([4075691](https://github.com/cloud-pi-native/console/commit/4075691de972a9d181dbed02a9aa2d801dc026f2)) +* **main:** Release v9.21.0 ([27e7f3a](https://github.com/cloud-pi-native/console/commit/27e7f3acff20927d8753d6bc67cebc4ed686f5ab)) +* move pnpm config to pnpm-workspace.yaml to avoid future issues with .npmrc ([00974c9](https://github.com/cloud-pi-native/console/commit/00974c9fcb98d223923c5062ea854ffd5ddeeb95)) +* move the entirety of apps/server in server-nestjs ([8437837](https://github.com/cloud-pi-native/console/commit/8437837fadca7dd79ec76cd3f004a83881891c29)) +* **nest-js:** add basic config and logger modules based on existing implementation ([429e4ba](https://github.com/cloud-pi-native/console/commit/429e4ba046f076e65320c3b4714aea878c99b754)) +* **nest-js:** initialize a bunch of module/services to move old-server code ([935f1d7](https://github.com/cloud-pi-native/console/commit/935f1d7fdef9ea68a404f01be07fa1b62b670dfd)) +* **nest-js:** update old-server code from main ([09f3729](https://github.com/cloud-pi-native/console/commit/09f3729078482abce704a2e885f6230134071f57)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** don't bother with webkit for now ([7d80b33](https://github.com/cloud-pi-native/console/commit/7d80b33f6ed5d364b526475e312679330825bd3c)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **playwright:** migrate E2E Project logs tests from Cypress ([089f0bc](https://github.com/cloud-pi-native/console/commit/089f0bcb6f443bc43c2936d039088b76bab10a01)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* **plugins:** update formatting ([86f8bad](https://github.com/cloud-pi-native/console/commit/86f8badfc617809d6f10e9c869b9fbce201de953)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* remove .js extensions from imports ([c8774f3](https://github.com/cloud-pi-native/console/commit/c8774f323c45ac85269d5c47eecf0813c632f879)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) +* remove configuration for tools that are not explicitely supported on the project ([e1be3d5](https://github.com/cloud-pi-native/console/commit/e1be3d5903a8a85d651785b6ef747040b9f29a41)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* rename nest.js base AppModule to MainModule for clarification with our own AppService ([8a20228](https://github.com/cloud-pi-native/console/commit/8a2022815fe363aa5626b0b4fcc8ab832dca12de)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) +* **server-nestjs:** add code to DatabaseService and thus finalize ApplicationInitializationService ([b12c207](https://github.com/cloud-pi-native/console/commit/b12c2079aaa944282ff5365504baf0095bd14e84)) +* **server-nestjs:** add code to PluginManagementService ([2d3133d](https://github.com/cloud-pi-native/console/commit/2d3133d5203fc94e71d3e4a2dfb5d4fc4e6bd784)) +* **server-nestjs:** add database initialization code in DatabaseInitializationService ([73c27d9](https://github.com/cloud-pi-native/console/commit/73c27d93ab779155a7e2c1c353909f3e5338b818)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) +* **server-nestjs:** convert all router services to NestJS classes ([2dd2c3e](https://github.com/cloud-pi-native/console/commit/2dd2c3ea697c6b508e89e107a689f79a8b39a6a1)) +* **server-nestjs:** delete unneeded code ([bbe07b2](https://github.com/cloud-pi-native/console/commit/bbe07b27bfd3d6c198e17a642cc441c919392d85)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** fix a fastify type issue ([868f1bc](https://github.com/cloud-pi-native/console/commit/868f1bc813f880963d13bccffd7814a54166f17a)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* **server-nestjs:** fix pnpm format command ([133254c](https://github.com/cloud-pi-native/console/commit/133254c1d285f45bb0385fda11d2f5b700f4ab20)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* **server-nestjs:** initialize core module and its AppService ([91d9596](https://github.com/cloud-pi-native/console/commit/91d9596765ced2a941f8e31c514e2b6c3969ef95)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove now useless old-server directory ([47d7536](https://github.com/cloud-pi-native/console/commit/47d7536a66e152b64fd69b0c57f72ab23a27d0e0)) +* **server-nestjs:** remove Router module ([8104360](https://github.com/cloud-pi-native/console/commit/8104360de9236f0f2c61dba2273b6d8cb368a356)) +* **server-nestjs:** remove test scripts (we'll migrate to vitest soon) ([afc4e91](https://github.com/cloud-pi-native/console/commit/afc4e912e88084a073b00e5213b2eed182b6c78b)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rename vite file to avoid compilation issues for now ([bd15699](https://github.com/cloud-pi-native/console/commit/bd15699e39d11ddbb6304366a41d67b8564c425a)) +* **server-nestjs:** retrieve prisma directory from old-server as-is ([a89fd36](https://github.com/cloud-pi-native/console/commit/a89fd368a1bc1c4d3eb1e535b0d4d776322e622f)) +* **server-nestjs:** rework configuration service to properly instanciate logger service ([64a113d](https://github.com/cloud-pi-native/console/commit/64a113d6591739557d459562acc57456fc3f74b9)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **server-nestjs:** update README with a plan for the future ([fc336d4](https://github.com/cloud-pi-native/console/commit/fc336d4b6793898a9190f585b39a9ba63c5e528b)) +* **server-nestjs:** update README with the modularization target ([d98bdfa](https://github.com/cloud-pi-native/console/commit/d98bdfa428290e7a5358a7caf33137a13815239c)) +* **server-nestjs:** use old-server eslint config ([d0f80ee](https://github.com/cloud-pi-native/console/commit/d0f80ee7bf88b7d26407b78a9792af699fb463e8)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* start integration old-server into nestjs ([d83b1cd](https://github.com/cloud-pi-native/console/commit/d83b1cdf47ba140a50a83da02c1c6a8fb024c9b9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) +* update browserlist/caniuse-lite ([23617f9](https://github.com/cloud-pi-native/console/commit/23617f9e051caf7e600360a92bae76ee6c8af575)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* update old-server import aliases ([d89735e](https://github.com/cloud-pi-native/console/commit/d89735e299b541efa4967a835c3cd0ece7d1ffbb)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) +* **vault:** explicit that VaultApi base class is abstract and must not be exported ([3673c4c](https://github.com/cloud-pi-native/console/commit/3673c4cf96bfc40bfac114fe7b67dee425b372da)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Docs + +* :memo: add notes regarding services communication ([ca4a057](https://github.com/cloud-pi-native/console/commit/ca4a0578d3e815f93aa1e57cbb880601354d600d)) +* :memo: Ajout du lien vers le MODOP de gestion des versions de dépendances ([7151293](https://github.com/cloud-pi-native/console/commit/715129312a6150f887298d1dc493ef7876c51e28)) +* :memo: update release documentation ([cea92a9](https://github.com/cloud-pi-native/console/commit/cea92a9aa651af68499ae318e4a7c27e037c706c)) +* :memo: use Nexus internal url for CI jobs ([2193ec3](https://github.com/cloud-pi-native/console/commit/2193ec3041fb0d78cc7a1fbe841fd451d2a7b095)) +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + + +### Refactoring + +* :bookmark: add tracing information on Argo apps ([340299b](https://github.com/cloud-pi-native/console/commit/340299b58e702a4258c5d767e3fdf4fc692f7d73)) +* :fire: remove AppRole enabled check to simplify the code ([95c7159](https://github.com/cloud-pi-native/console/commit/95c71595d7aac32fa211d18880fd5e7ed3bf4c53)) +* add PluginResultBuilder for improved result management in project upsert ([80d5bc4](https://github.com/cloud-pi-native/console/commit/80d5bc498422c1eb79134c54c9facc9c7a15e279)) +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) +* split Gitlab classes in separate files ([0d673a5](https://github.com/cloud-pi-native/console/commit/0d673a5d282e9dc82a816d16ac18c8452f740c1f)) +* split Vault classes in separate files ([15bc0ce](https://github.com/cloud-pi-native/console/commit/15bc0ce32dc74e8719c7a202a2e3e406aee50762)) + + +### Reverted commits + +* "feat(server-nestjs): add autoSync and suspended flags to plugin services" ([2116663](https://github.com/cloud-pi-native/console/commit/2116663621cf1d522ca73a0e76ea6b9ed4f9ff6d)) +* "fix: playwright test expect mock" ([d2a746c](https://github.com/cloud-pi-native/console/commit/d2a746c237af4d1cbfe38e8d090599bf26176525)) +* "refactor(server-nestjs): offload project route to new implementation" ([8a964bc](https://github.com/cloud-pi-native/console/commit/8a964bc5d2d223e9c2f2d975cc703a18be81fc45)) +* **client:** switch environments back from API v2 ([933977c](https://github.com/cloud-pi-native/console/commit/933977c8b3434d3db97c0f4300bb2d4bcc393588)) + +## [9.21.0](https://github.com/cloud-pi-native/console/compare/v9.20.2...v9.21.0) (2026-07-21) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** restrict deployment visibility to console admins ([def61e7](https://github.com/cloud-pi-native/console/commit/def61e76b3a867445c5ddb1c08e0f3af59b188ae)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([52c8173](https://github.com/cloud-pi-native/console/commit/52c8173f5610e83836b661d7472c0fe3ff22f70f)) +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* :construction: make all new cluster external by default ([dcd203f](https://github.com/cloud-pi-native/console/commit/dcd203f3569d93a74e0d627f6cf3eb898d6b338d)) +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* add old-server new Nest.js services to CpinModule ([a17c109](https://github.com/cloud-pi-native/console/commit/a17c109616270115af701366ae8014e05cb41e7c)) +* add server-nestjs README to explain what we are doing ([57a4ca5](https://github.com/cloud-pi-native/console/commit/57a4ca51086b7c7af44a1df83cd272b82eb9a9f8)) +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* apply prettier on old-server ([6a781ac](https://github.com/cloud-pi-native/console/commit/6a781ac80c756924c53e28815064197b63508ff4)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* bump turbo to v2.7.1 ([d955e52](https://github.com/cloud-pi-native/console/commit/d955e52e65ed829053fdb120b217cb2744bd02c2)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** trigger Playwright E2E tests before Cypress E2E tests ([2262f37](https://github.com/cloud-pi-native/console/commit/2262f3717101833217d5963580e07fb4f69e9847)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* **client:** commit auto-imports.d.ts ([4252266](https://github.com/cloud-pi-native/console/commit/42522663edf5151ce7e38c36d4eb5d2d13b24a48)) +* **client:** do not install pnpm twice ([43ff3a3](https://github.com/cloud-pi-native/console/commit/43ff3a3e6690c901d4905236b627016ee8b4f043)) +* **client:** ensure build fails if typing system is not sound ([97297b5](https://github.com/cloud-pi-native/console/commit/97297b58a80bee7167171d39b16404f077fef03b)) +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* **client:** fix types ([131b2b6](https://github.com/cloud-pi-native/console/commit/131b2b64926493d94ba1e07c3501e331d0fcac8c)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* convert cypress dashboard e2e tests to playwright ([2badd91](https://github.com/cloud-pi-native/console/commit/2badd91181d4960d40898cfa01fbcfd0b13667aa)) +* convert plugin manager to NestJS service ([2ae46e3](https://github.com/cloud-pi-native/console/commit/2ae46e38787b3521911786898bee881c23fdb08b)) +* disable tests from nest.js for now ([5137c10](https://github.com/cloud-pi-native/console/commit/5137c10ed9296c761c3d31877bf54a411bbee140)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **e2e:** avoid launching Playwright and Cypress E2E tests at the same time ([1f203a8](https://github.com/cloud-pi-native/console/commit/1f203a8d35d6b1e4e69076f289256c7192a356da)) +* **e2e:** migrate Admin Role tests from Cypress to Playwright ([2112398](https://github.com/cloud-pi-native/console/commit/2112398f85b1ab7a5e332b798f68d4f04894d51d)) +* **e2e:** migrate Duplicate Zone test from Cypress to Playwright ([748ec80](https://github.com/cloud-pi-native/console/commit/748ec80917af7ef34b1f7922a13703706e5e41cc)) +* **e2e:** migrate last Role tests from Cypress to Playwright ([176ea34](https://github.com/cloud-pi-native/console/commit/176ea3481fc5b282497ea82a7562d735b11b325f)) +* **e2e:** migrate last Zone tests from Cypress to Playwright ([dcff338](https://github.com/cloud-pi-native/console/commit/dcff3384e695e95c9d2ae2b08486ce4ca47fa769)) +* **e2e:** migrate profile tests from Cypress to Playwright ([d7201b6](https://github.com/cloud-pi-native/console/commit/d7201b6d253d342f6a1656d179d0a9d322745c33)) +* **e2e:** migrate Project archive test from Cypress to Playwright ([0567931](https://github.com/cloud-pi-native/console/commit/0567931723cccf2fc54cb5560c6dd00043211f58)) +* **e2e:** migrate repository tests from Cypress to Playwright ([8370b8b](https://github.com/cloud-pi-native/console/commit/8370b8b4adbe5c1b65bc5f45bd50c860beeba96c)) +* **e2e:** migrate some Zone tests from Cypress to Playwright ([425e8c9](https://github.com/cloud-pi-native/console/commit/425e8c9672b276507983b6b0685bcf475bdb7c6c)) +* **e2e:** migrate Token tests from Cypress to Playwright ([86cb815](https://github.com/cloud-pi-native/console/commit/86cb8158285be552a1d0e94ebd30600858b9193a)) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* fix all typescript import errors by massively creating NestJs services ([68b7cb7](https://github.com/cloud-pi-native/console/commit/68b7cb75407dbb8d630dd4a50e4832e97796008a)) +* fix any type in AppService ([627e1e9](https://github.com/cloud-pi-native/console/commit/627e1e9f0cac82bcaa9b3b3a6337c3f2b447e1e5)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) +* **gitlab-plugin:** interalize awaited import to avoid top-level-await errors ([6472c74](https://github.com/cloud-pi-native/console/commit/6472c742c9ec74f834da994db697fcfb7d385c7e)) +* **gitlab:** purge useless file ([2637156](https://github.com/cloud-pi-native/console/commit/263715641844bb7d766730a3de9240531c9f26e3)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **hotfix/revert-server-nestjs-offload:** Release v9.20.1 ([8ebeae7](https://github.com/cloud-pi-native/console/commit/8ebeae7717d0aeac93ce14102a137f4aac62c409)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* initialize nestjs dedicated app ([a533963](https://github.com/cloud-pi-native/console/commit/a53396329455c9c856274f3c419e2ff377e12dba)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) +* **main:** release 9.10.0 ([c87ad31](https://github.com/cloud-pi-native/console/commit/c87ad3114149c4af995be42e130d03efd2e301d9)) +* **main:** release 9.10.1 ([c4b4649](https://github.com/cloud-pi-native/console/commit/c4b464985cfafc7d60246621e9beb82760b73200)) +* **main:** release 9.10.2 ([20a233a](https://github.com/cloud-pi-native/console/commit/20a233af09a3ef918a7b1de36057374dd4817726)) +* **main:** release 9.11.0 ([c03e2ef](https://github.com/cloud-pi-native/console/commit/c03e2ef8c4e448784528ae7dee68edff36e48930)) +* **main:** release 9.11.1 ([7580384](https://github.com/cloud-pi-native/console/commit/7580384525c7cdddbd51e7b0b9b4154ca624b058)) +* **main:** release 9.11.2 ([c7de3a1](https://github.com/cloud-pi-native/console/commit/c7de3a15bd54730655e3a75523c29e9f313a18d9)) +* **main:** release 9.11.3 ([9d2df48](https://github.com/cloud-pi-native/console/commit/9d2df48e3588a819fe5a63467dee773c5e1963c3)) +* **main:** release 9.11.4 ([9c873e1](https://github.com/cloud-pi-native/console/commit/9c873e1b5d1ec3d59b86b6ae19b0fe055f3ed669)) +* **main:** release 9.11.5 ([5fc68de](https://github.com/cloud-pi-native/console/commit/5fc68de5cf2a22f33171f03132798df20701df1b)) +* **main:** release 9.8.1 ([bb94301](https://github.com/cloud-pi-native/console/commit/bb9430196e042847931719ec518d5b9685a67585)) +* **main:** release 9.9.0 ([aa755da](https://github.com/cloud-pi-native/console/commit/aa755dac97ffd563bb0684fe669268b95c6d1362)) +* **main:** Release v9.12.0 ([3a14b03](https://github.com/cloud-pi-native/console/commit/3a14b03a7284ee8ee8a5e9d54d7ba842832c4017)) +* **main:** Release v9.12.0 ([4f6e1a8](https://github.com/cloud-pi-native/console/commit/4f6e1a8d8b630335c89a12b4daf4941e1dcdc32a)) +* **main:** Release v9.13.0 ([4daaa1d](https://github.com/cloud-pi-native/console/commit/4daaa1dce01276f83c6870ffe1c13af096547621)) +* **main:** Release v9.13.0 ([613ba30](https://github.com/cloud-pi-native/console/commit/613ba30b9f7f41e6db64fa8957709bca96e53847)) +* **main:** Release v9.13.1 ([0b39ff1](https://github.com/cloud-pi-native/console/commit/0b39ff1747d65fae9d4d4c6409421a30b1c57fc4)) +* **main:** Release v9.13.1 ([96ed6a3](https://github.com/cloud-pi-native/console/commit/96ed6a318a74978a9c2b8713d65100f1c0ca2f3d)) +* **main:** Release v9.13.2 ([400e9a7](https://github.com/cloud-pi-native/console/commit/400e9a71b28879c2bcd382ed4a43f55757d7aa2e)) +* **main:** Release v9.13.2 ([e2d09e3](https://github.com/cloud-pi-native/console/commit/e2d09e3f476fc958967089bc0ac9c5054b7eb5ac)) +* **main:** Release v9.14.0 ([5c62e19](https://github.com/cloud-pi-native/console/commit/5c62e191e0ab426dff4b5c390817a15345e3bdb0)) +* **main:** Release v9.14.1 ([8bccc81](https://github.com/cloud-pi-native/console/commit/8bccc81517f8cf39ddc5e1be972d4efa701b5e41)) +* **main:** Release v9.15.0 ([7b50776](https://github.com/cloud-pi-native/console/commit/7b50776d35fa73d1a28f6fb21010ffad85bafaf1)) +* **main:** Release v9.16.0 ([68abf2e](https://github.com/cloud-pi-native/console/commit/68abf2e5afd1fac52fad95c1319a4b50e8ac6275)) +* **main:** Release v9.16.1 ([3ba4c0b](https://github.com/cloud-pi-native/console/commit/3ba4c0bf08d899b614875cac99af7345a61998c8)) +* **main:** Release v9.17.0 ([a4061a6](https://github.com/cloud-pi-native/console/commit/a4061a61137c40c5b496e6649645b0b017723407)) +* **main:** Release v9.17.1 ([f8f7952](https://github.com/cloud-pi-native/console/commit/f8f795208ce32b5539e4d06c84f8579d1ae7ad2d)) +* **main:** Release v9.18.0 ([775bd1e](https://github.com/cloud-pi-native/console/commit/775bd1e82a598278ae2317bb775054b9e0627a36)) +* **main:** Release v9.18.1 ([261d9a3](https://github.com/cloud-pi-native/console/commit/261d9a367b71e8598c7956e4b76b612550244ffd)) +* **main:** Release v9.18.2 ([0a75369](https://github.com/cloud-pi-native/console/commit/0a753695320428332119401d6de53dc576538d72)) +* **main:** Release v9.18.3 ([f8cadc4](https://github.com/cloud-pi-native/console/commit/f8cadc47d40393c8fd040b0740957cff39ae6cb4)) +* **main:** Release v9.18.4 ([71dc32b](https://github.com/cloud-pi-native/console/commit/71dc32bf1f3d16a87a39f5d6378f3b7876bf2e42)) +* **main:** Release v9.19.0 ([c7d0f41](https://github.com/cloud-pi-native/console/commit/c7d0f41d7ecfd5d4645872de743d83d90b97bdab)) +* **main:** Release v9.20.0 ([9ee41b3](https://github.com/cloud-pi-native/console/commit/9ee41b36597cdd79c632ae3b2d3eb5b032781819)) +* **main:** Release v9.20.2 ([4075691](https://github.com/cloud-pi-native/console/commit/4075691de972a9d181dbed02a9aa2d801dc026f2)) +* move pnpm config to pnpm-workspace.yaml to avoid future issues with .npmrc ([00974c9](https://github.com/cloud-pi-native/console/commit/00974c9fcb98d223923c5062ea854ffd5ddeeb95)) +* move the entirety of apps/server in server-nestjs ([8437837](https://github.com/cloud-pi-native/console/commit/8437837fadca7dd79ec76cd3f004a83881891c29)) +* **nest-js:** add basic config and logger modules based on existing implementation ([429e4ba](https://github.com/cloud-pi-native/console/commit/429e4ba046f076e65320c3b4714aea878c99b754)) +* **nest-js:** initialize a bunch of module/services to move old-server code ([935f1d7](https://github.com/cloud-pi-native/console/commit/935f1d7fdef9ea68a404f01be07fa1b62b670dfd)) +* **nest-js:** update old-server code from main ([09f3729](https://github.com/cloud-pi-native/console/commit/09f3729078482abce704a2e885f6230134071f57)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** don't bother with webkit for now ([7d80b33](https://github.com/cloud-pi-native/console/commit/7d80b33f6ed5d364b526475e312679330825bd3c)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **playwright:** migrate E2E Project logs tests from Cypress ([089f0bc](https://github.com/cloud-pi-native/console/commit/089f0bcb6f443bc43c2936d039088b76bab10a01)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* **plugins:** update formatting ([86f8bad](https://github.com/cloud-pi-native/console/commit/86f8badfc617809d6f10e9c869b9fbce201de953)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* remove .js extensions from imports ([c8774f3](https://github.com/cloud-pi-native/console/commit/c8774f323c45ac85269d5c47eecf0813c632f879)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) +* remove configuration for tools that are not explicitely supported on the project ([e1be3d5](https://github.com/cloud-pi-native/console/commit/e1be3d5903a8a85d651785b6ef747040b9f29a41)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* rename nest.js base AppModule to MainModule for clarification with our own AppService ([8a20228](https://github.com/cloud-pi-native/console/commit/8a2022815fe363aa5626b0b4fcc8ab832dca12de)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) +* **server-nestjs:** add code to DatabaseService and thus finalize ApplicationInitializationService ([b12c207](https://github.com/cloud-pi-native/console/commit/b12c2079aaa944282ff5365504baf0095bd14e84)) +* **server-nestjs:** add code to PluginManagementService ([2d3133d](https://github.com/cloud-pi-native/console/commit/2d3133d5203fc94e71d3e4a2dfb5d4fc4e6bd784)) +* **server-nestjs:** add database initialization code in DatabaseInitializationService ([73c27d9](https://github.com/cloud-pi-native/console/commit/73c27d93ab779155a7e2c1c353909f3e5338b818)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) +* **server-nestjs:** convert all router services to NestJS classes ([2dd2c3e](https://github.com/cloud-pi-native/console/commit/2dd2c3ea697c6b508e89e107a689f79a8b39a6a1)) +* **server-nestjs:** delete unneeded code ([bbe07b2](https://github.com/cloud-pi-native/console/commit/bbe07b27bfd3d6c198e17a642cc441c919392d85)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** fix a fastify type issue ([868f1bc](https://github.com/cloud-pi-native/console/commit/868f1bc813f880963d13bccffd7814a54166f17a)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* **server-nestjs:** fix pnpm format command ([133254c](https://github.com/cloud-pi-native/console/commit/133254c1d285f45bb0385fda11d2f5b700f4ab20)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* **server-nestjs:** initialize core module and its AppService ([91d9596](https://github.com/cloud-pi-native/console/commit/91d9596765ced2a941f8e31c514e2b6c3969ef95)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove now useless old-server directory ([47d7536](https://github.com/cloud-pi-native/console/commit/47d7536a66e152b64fd69b0c57f72ab23a27d0e0)) +* **server-nestjs:** remove Router module ([8104360](https://github.com/cloud-pi-native/console/commit/8104360de9236f0f2c61dba2273b6d8cb368a356)) +* **server-nestjs:** remove test scripts (we'll migrate to vitest soon) ([afc4e91](https://github.com/cloud-pi-native/console/commit/afc4e912e88084a073b00e5213b2eed182b6c78b)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rename vite file to avoid compilation issues for now ([bd15699](https://github.com/cloud-pi-native/console/commit/bd15699e39d11ddbb6304366a41d67b8564c425a)) +* **server-nestjs:** retrieve prisma directory from old-server as-is ([a89fd36](https://github.com/cloud-pi-native/console/commit/a89fd368a1bc1c4d3eb1e535b0d4d776322e622f)) +* **server-nestjs:** rework configuration service to properly instanciate logger service ([64a113d](https://github.com/cloud-pi-native/console/commit/64a113d6591739557d459562acc57456fc3f74b9)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **server-nestjs:** update README with a plan for the future ([fc336d4](https://github.com/cloud-pi-native/console/commit/fc336d4b6793898a9190f585b39a9ba63c5e528b)) +* **server-nestjs:** update README with the modularization target ([d98bdfa](https://github.com/cloud-pi-native/console/commit/d98bdfa428290e7a5358a7caf33137a13815239c)) +* **server-nestjs:** use old-server eslint config ([d0f80ee](https://github.com/cloud-pi-native/console/commit/d0f80ee7bf88b7d26407b78a9792af699fb463e8)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* start integration old-server into nestjs ([d83b1cd](https://github.com/cloud-pi-native/console/commit/d83b1cdf47ba140a50a83da02c1c6a8fb024c9b9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) +* update browserlist/caniuse-lite ([23617f9](https://github.com/cloud-pi-native/console/commit/23617f9e051caf7e600360a92bae76ee6c8af575)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* update old-server import aliases ([d89735e](https://github.com/cloud-pi-native/console/commit/d89735e299b541efa4967a835c3cd0ece7d1ffbb)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) +* **vault:** explicit that VaultApi base class is abstract and must not be exported ([3673c4c](https://github.com/cloud-pi-native/console/commit/3673c4cf96bfc40bfac114fe7b67dee425b372da)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Docs + +* :memo: add notes regarding services communication ([ca4a057](https://github.com/cloud-pi-native/console/commit/ca4a0578d3e815f93aa1e57cbb880601354d600d)) +* :memo: Ajout du lien vers le MODOP de gestion des versions de dépendances ([7151293](https://github.com/cloud-pi-native/console/commit/715129312a6150f887298d1dc493ef7876c51e28)) +* :memo: update release documentation ([cea92a9](https://github.com/cloud-pi-native/console/commit/cea92a9aa651af68499ae318e4a7c27e037c706c)) +* :memo: use Nexus internal url for CI jobs ([2193ec3](https://github.com/cloud-pi-native/console/commit/2193ec3041fb0d78cc7a1fbe841fd451d2a7b095)) +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + + +### Refactoring + +* :bookmark: add tracing information on Argo apps ([340299b](https://github.com/cloud-pi-native/console/commit/340299b58e702a4258c5d767e3fdf4fc692f7d73)) +* :fire: remove AppRole enabled check to simplify the code ([95c7159](https://github.com/cloud-pi-native/console/commit/95c71595d7aac32fa211d18880fd5e7ed3bf4c53)) +* add PluginResultBuilder for improved result management in project upsert ([80d5bc4](https://github.com/cloud-pi-native/console/commit/80d5bc498422c1eb79134c54c9facc9c7a15e279)) +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) +* split Gitlab classes in separate files ([0d673a5](https://github.com/cloud-pi-native/console/commit/0d673a5d282e9dc82a816d16ac18c8452f740c1f)) +* split Vault classes in separate files ([15bc0ce](https://github.com/cloud-pi-native/console/commit/15bc0ce32dc74e8719c7a202a2e3e406aee50762)) + + +### Reverted commits + +* "feat(server-nestjs): add autoSync and suspended flags to plugin services" ([2116663](https://github.com/cloud-pi-native/console/commit/2116663621cf1d522ca73a0e76ea6b9ed4f9ff6d)) +* "fix: playwright test expect mock" ([d2a746c](https://github.com/cloud-pi-native/console/commit/d2a746c237af4d1cbfe38e8d090599bf26176525)) +* "refactor(server-nestjs): offload project route to new implementation" ([8a964bc](https://github.com/cloud-pi-native/console/commit/8a964bc5d2d223e9c2f2d975cc703a18be81fc45)) +* **client:** switch environments back from API v2 ([933977c](https://github.com/cloud-pi-native/console/commit/933977c8b3434d3db97c0f4300bb2d4bcc393588)) + +## [9.20.2](https://github.com/cloud-pi-native/console/compare/v9.20.1...v9.20.2) (2026-07-21) + + +### Bug Fixes + +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) + +## [9.20.1](https://github.com/cloud-pi-native/console/compare/v9.20.0...v9.20.1) (2026-07-17) + + +### Bug Fixes + +* **client:** restrict deployment visibility to console admins ([d836948](https://github.com/cloud-pi-native/console/commit/d836948e2c6689445d473c562a2be189873c75af)) + + +### Reverted commits + +* **client:** switch environments back from API v2 ([9c1245a](https://github.com/cloud-pi-native/console/commit/9c1245a56f650d4819f27f926457810f33b67753)) +* feat(server-nestjs): add autoSync and suspended flags to plugin services ([a48697d](https://github.com/cloud-pi-native/console/commit/a48697d46fad4fbbf54567988d0e99433cb5c4cc)) +* fix: playwright test expect mock ([828db42](https://github.com/cloud-pi-native/console/commit/828db421f6ce4f7eda293a744fda9f22882f7a8c)) +* refactor(server-nestjs): offload project route to new implementation ([99a7f5d](https://github.com/cloud-pi-native/console/commit/99a7f5d29becefcdad24331004263756a6950774)) + +## [9.20.0](https://github.com/cloud-pi-native/console/compare/v9.19.0...v9.20.0) (2026-07-17) + +### Features + +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) + + +### Bug Fixes + +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) + + +### Docs + +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) + + +### Refactoring + +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) + +## [9.19.0](https://github.com/cloud-pi-native/console/compare/v9.18.4...v9.19.0) (2026-06-19) + + +### Features + +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) + + +### Bug Fixes + +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) + + +### Miscellaneous Chores + +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) + + +### Refactoring + +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) + +## [9.18.4](https://github.com/cloud-pi-native/console/compare/v9.18.3...v9.18.4) (2026-06-10) + + +### Bug Fixes + +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) + +## [9.18.3](https://github.com/cloud-pi-native/console/compare/v9.18.2...v9.18.3) (2026-06-01) + + +### Bug Fixes + +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) + + +### Docs + +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) + +## [9.18.2](https://github.com/cloud-pi-native/console/compare/v9.18.1...v9.18.2) (2026-05-22) + + +### Bug Fixes + +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) + + +### Miscellaneous Chores + +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) + + +### Docs + +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) + + +### Refactoring + +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) + +## [9.18.1](https://github.com/cloud-pi-native/console/compare/v9.18.0...v9.18.1) (2026-05-20) + + +### Bug Fixes + +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) + +## [9.18.0](https://github.com/cloud-pi-native/console/compare/v9.17.1...v9.18.0) (2026-05-18) + + +### Features + +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) + + +### Bug Fixes + +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) + + +### Miscellaneous Chores + +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) + +## [9.17.1](https://github.com/cloud-pi-native/console/compare/v9.17.0...v9.17.1) (2026-05-05) + + +### Bug Fixes + +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) + +## [9.17.0](https://github.com/cloud-pi-native/console/compare/v9.16.1...v9.17.0) (2026-04-30) + + +### Features + +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) + + +### Bug Fixes + +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) + + +### Miscellaneous Chores + +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) + + +### Docs + +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) + + +### Refactoring + +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) + +## [9.16.1](https://github.com/cloud-pi-native/console/compare/v9.16.0...v9.16.1) (2026-04-13) + + +### Bug Fixes + +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) + + +### Miscellaneous Chores + +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) + +## [9.16.0](https://github.com/cloud-pi-native/console/compare/v9.15.0...v9.16.0) (2026-04-10) + + +### Features + +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) + + +### Bug Fixes + +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) + + +### Miscellaneous Chores + +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) + +## [9.15.0](https://github.com/cloud-pi-native/console/compare/v9.14.1...v9.15.0) (2026-04-09) + + +### Features + +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) + + +### Bug Fixes + +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) + + +### Miscellaneous Chores + +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Refactoring + +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) + +## [9.14.1](https://github.com/cloud-pi-native/console/compare/v9.14.0...v9.14.1) (2026-03-25) + + +### Bug Fixes + +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) + + +### Miscellaneous Chores + +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) + + +### Docs + +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + +## [9.14.0](https://github.com/cloud-pi-native/console/compare/v9.13.2...v9.14.0) (2026-03-23) + + +### Features + +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) + + +### Miscellaneous Chores + +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) + + +### Docs + +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) + +## [9.13.2](https://github.com/cloud-pi-native/console/compare/v9.13.1...v9.13.2) (2026-02-24) + + +### Bug Fixes + +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) + + +### Miscellaneous Chores + +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) + + +### Docs + +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) + +## [9.13.1](https://github.com/cloud-pi-native/console/compare/v9.13.0...v9.13.1) (2026-02-17) + + +### Miscellaneous Chores + +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) + +## [9.13.0](https://github.com/cloud-pi-native/console/compare/v9.12.0...v9.13.0) (2026-02-16) + + +### Features + +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) + + +### Bug Fixes + +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) + + +### Miscellaneous Chores + +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) + + +### Docs + +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) + +## [9.12.0](https://github.com/cloud-pi-native/console/compare/v9.11.5...v9.12.0) (2026-02-03) + + +### Features + +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) + + +### Miscellaneous Chores + +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) + + +### Docs + +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) + +## [9.11.5](https://github.com/cloud-pi-native/console/compare/v9.11.4...v9.11.5) (2026-01-26) + + +### Bug Fixes + +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) + +## [9.11.4](https://github.com/cloud-pi-native/console/compare/v9.11.3...v9.11.4) (2026-01-20) + + +### Bug Fixes + +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) + +## [9.11.3](https://github.com/cloud-pi-native/console/compare/v9.11.2...v9.11.3) (2026-01-13) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) + +## [9.11.2](https://github.com/cloud-pi-native/console/compare/v9.11.1...v9.11.2) (2026-01-08) + + +### Bug Fixes + +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) + +## [9.11.1](https://github.com/cloud-pi-native/console/compare/v9.11.0...v9.11.1) (2026-01-06) + + +### Bug Fixes + +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) + +## [9.11.0](https://github.com/cloud-pi-native/console/compare/v9.10.2...v9.11.0) (2025-12-22) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) + +## [9.10.2](https://github.com/cloud-pi-native/console/compare/v9.10.1...v9.10.2) (2025-12-18) + + +### Bug Fixes + +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) + +## [9.10.1](https://github.com/cloud-pi-native/console/compare/v9.10.0...v9.10.1) (2025-12-18) + + +### Bug Fixes + +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) + +## [9.10.0](https://github.com/cloud-pi-native/console/compare/v9.9.0...v9.10.0) (2025-12-12) + + +### Features + +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) + + +### Bug Fixes + +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) + +## [9.9.0](https://github.com/cloud-pi-native/console/compare/v9.8.1...v9.9.0) (2025-11-27) + + +### Features + +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) + +## [9.8.1](https://github.com/cloud-pi-native/console/compare/v9.8.0...v9.8.1) (2025-11-21) + + +### Bug Fixes + +* :bug: display associated project on cluster form ([488ff24](https://github.com/cloud-pi-native/console/commit/488ff24dab0d90a6af83e96bdbe815a0f955220e)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* **ci:** remove PNPM_VERSION from Github Actions ([bd1f4af](https://github.com/cloud-pi-native/console/commit/bd1f4af2592658f7edcbcada13f1ea6cafcab3d4)) +* rely on packageManager property to retrieve the right version of pnpm ([7b20b83](https://github.com/cloud-pi-native/console/commit/7b20b832b6f83d367c33573d87c444a8da315703)) +* **shared:** explicitely import Zod type ([2a8a4d8](https://github.com/cloud-pi-native/console/commit/2a8a4d8074e45742769fb2f451672777c4f4afa5)) +* **tests:** ensure Cypress tests are green again ([5b7042b](https://github.com/cloud-pi-native/console/commit/5b7042b0be48697ce9dfa4a69377a04ceae8fb5c)) + +## [9.8.0](https://github.com/cloud-pi-native/console/compare/v9.7.2...v9.8.0) (2025-11-17) + + +### Features + +* :fire: de-duplicates Argo CD links for default zone ([2db26cd](https://github.com/cloud-pi-native/console/commit/2db26cdfce71ffbf330bdbe07a48f1276bad7cd9)) +* :poop: change the naming convention of cluster secret ([a471766](https://github.com/cloud-pi-native/console/commit/a47176643cb9a97f41c1345477da60bce5935f85)) + + +### Bug Fixes + +* :bug: improve number inputs for decimal values ([d69a8ba](https://github.com/cloud-pi-native/console/commit/d69a8ba547174be521ac8cab0e47db2448415a61)) +* **#1640:** :bug: only regenerate robot secret if host changes ([e5d3ba8](https://github.com/cloud-pi-native/console/commit/e5d3ba87f895451795be1d7fa42a389e63521a68)) + + +### Performance Improvements + +* :zap: direct service communication for Gitlab pipelines ([4991451](https://github.com/cloud-pi-native/console/commit/4991451f3d0c7dbac32cf5fed1fc880b29a37a1c)) + +## [9.7.2](https://github.com/cloud-pi-native/console/compare/v9.7.1...v9.7.2) (2025-10-27) + + +### Bug Fixes + +* release please condition ([c293866](https://github.com/cloud-pi-native/console/commit/c29386619ff3624d22a2a72f9753bc443045b5c0)) + +## [9.7.1](https://github.com/cloud-pi-native/console/compare/v9.7.0...v9.7.1) (2025-10-27) + + +### Bug Fixes + +* :bug: use the renamed path of repository/group being deleted ([38b8cff](https://github.com/cloud-pi-native/console/commit/38b8cffaff3484257104d325dd9e034bfa2611fa)) + +## [9.7.0](https://github.com/cloud-pi-native/console/compare/v9.6.0...v9.7.0) (2025-10-27) + + +### Features + +* :sparkles: add support for SonarQube and Harbor internal url ([43ce886](https://github.com/cloud-pi-native/console/commit/43ce88690883d84214c29a4cb910f81609e8cb4d)) + +## [9.6.0](https://github.com/cloud-pi-native/console/compare/v9.5.0...v9.6.0) (2025-10-14) + + +### Features + +* :sparkles: allow cluster name update ([161086d](https://github.com/cloud-pi-native/console/commit/161086dfd7c226eac65b3f5e3c835b7f923cf511)) +* :sparkles: allow cluster name update ([0cbc53e](https://github.com/cloud-pi-native/console/commit/0cbc53eb7f3b0e41f128e1066f584aadb55b9766)), closes [#1582](https://github.com/cloud-pi-native/console/issues/1582) + +## [9.5.0](https://github.com/cloud-pi-native/console/compare/v9.4.0...v9.5.0) (2025-10-10) + + +### Features + +* :sparkles: add project usage on resources tab ([1ee66ce](https://github.com/cloud-pi-native/console/commit/1ee66ce8211c2017b328887b7800a2e1ab537300)) +* :sparkles: add resources occupancy on clusters ([75ce5e9](https://github.com/cloud-pi-native/console/commit/75ce5e9450ca68d45d2635b29e0ff8bd8d8614d7)) +* :sparkles: add tags retention policy rules ([a0e3f98](https://github.com/cloud-pi-native/console/commit/a0e3f9839d18c9459264e452b14cc87359a3f6c8)) +* :sparkles: check cluster resources when adding an environment ([826f1e9](https://github.com/cloud-pi-native/console/commit/826f1e9d8f46bf2aed419c7c395ef336131849dc)) +* :sparkles: save resources constraints for the project ([58e6d7a](https://github.com/cloud-pi-native/console/commit/58e6d7ad2d06fbb9e766fa755ccd7ab44556409e)) +* **client:** add service chain details form ([d56e68c](https://github.com/cloud-pi-native/console/commit/d56e68c626ee854fd12e60694acbc87d178ee270)) +* move environments and stages scenario from cypress to playwright ([c4e703f](https://github.com/cloud-pi-native/console/commit/c4e703f92f964e5850ca16ae7d066dc6e7d692cf)) +* **opencds:** add 'list all service chains' section in admin side menu ([f58de6e](https://github.com/cloud-pi-native/console/commit/f58de6e55ad23ed8f8e81aa769f76f9adc62b7b1)) +* **opencds:** add client-side service chain details page ([478bc35](https://github.com/cloud-pi-native/console/commit/478bc35408c43da95da875b105f1cd8222dfa217)) +* **opencds:** add feature toggling default value ([5dc6071](https://github.com/cloud-pi-native/console/commit/5dc607178179c5ab76dedf0d84ab22d3e08cabf5)) +* **opencds:** add flows retrieval and display as a stepper ([7194218](https://github.com/cloud-pi-native/console/commit/71942182464d6c42da0ab818f28ee60024c2e056)) +* **opencds:** add mockoon docker image for local dev and ci considerations ([0cf8de5](https://github.com/cloud-pi-native/console/commit/0cf8de5cd04860cdcfe2259640abffd48881ac5b)) +* **opencds:** adjust service chain list view ([fa0f5c3](https://github.com/cloud-pi-native/console/commit/fa0f5c3b91d940547ef132ad443f3b0a187238aa)) +* **opencds:** clean up mockoon json ([8bd1f74](https://github.com/cloud-pi-native/console/commit/8bd1f7483d5b772705c317510cded0552cad40c6)) +* **opencds:** handle OpenCDS API token-based security ([409ae6e](https://github.com/cloud-pi-native/console/commit/409ae6e40a2e42b54fd95a05904c35e1efd18371)) +* **opencds:** use a badge for state on service-chain list ([e3c1340](https://github.com/cloud-pi-native/console/commit/e3c134095e0dc7fb8009bfdb8fc14e6e8d62959b)) +* remove quota table ([1561d8d](https://github.com/cloud-pi-native/console/commit/1561d8da82ab1413047a3573269ddc4a133360ca)) +* remove Quota table references to use Environment attributes instead ([03297ec](https://github.com/cloud-pi-native/console/commit/03297ec88fb69834c6d76f61b25309ce1a363f4f)) +* update Cluster model to store resources capability ([6167743](https://github.com/cloud-pi-native/console/commit/6167743743c91f5e109da7297de275ea9d4d23d3)) +* update Environment form to use new resources attributes ([ff58b26](https://github.com/cloud-pi-native/console/commit/ff58b26662db51f385ce49ac14b4073bd5cf71cf)) + + +### Bug Fixes + +* :bug: update zone values when modifying a cluster ([253acb9](https://github.com/cloud-pi-native/console/commit/253acb9d6106727fc11b22c8268cfcd07320001a)), closes [#1642](https://github.com/cloud-pi-native/console/issues/1642) +* :lipstick: show resources in clusters and environment tables ([8494e2a](https://github.com/cloud-pi-native/console/commit/8494e2ad19afc7e0c8afb65ccc2ee216d7399615)) +* config and locator due to keycloak upgrade ([516d9d5](https://github.com/cloud-pi-native/console/commit/516d9d5bc34b5261490170f2c356c050168f54e5)) +* **opencds:** fix validatedBy pseudo-null value conflicting with OpenCDS contract in server ([cde51a2](https://github.com/cloud-pi-native/console/commit/cde51a2f08cfd64e57714f6096a6ec2d6bb8962f)) +* **playwright:** bump playwright to 1.54.2 ([44ea5e3](https://github.com/cloud-pi-native/console/commit/44ea5e3f5093a45cf95266c0691dad60ca33de26)) +* **playwright:** fix flaky test on cluster informations by adding a loader ([ffc7b43](https://github.com/cloud-pi-native/console/commit/ffc7b436efa7af16a1a7d166c52d02317c26ab74)) + +## [9.4.0](https://github.com/cloud-pi-native/console/compare/v9.3.3...v9.4.0) (2025-08-25) + + +### Features + +* :rocket: empty Vault url to use the default VaultConnection ([e2d3b28](https://github.com/cloud-pi-native/console/commit/e2d3b287964952b0f6dd0a00ea97499827cb7198)) +* :sparkles: create contact email environment variable ([779cb4a](https://github.com/cloud-pi-native/console/commit/779cb4a83492d4159a7efa868822adc5640dd6f8)) +* ✨ Add the repo UID display on console ([2ed10e4](https://github.com/cloud-pi-native/console/commit/2ed10e4b612687b9a18441458749d2821bcff43e)) +* ✨ Variabilize clipboard timeout ([2ed10e4](https://github.com/cloud-pi-native/console/commit/2ed10e4b612687b9a18441458749d2821bcff43e)) +* add Playwright E2E tests for Keycloak ([1524bba](https://github.com/cloud-pi-native/console/commit/1524bba8d6adcc8b360d740e85b51fcf8db71964)) +* add README for ArgoCD plugin ([5d66223](https://github.com/cloud-pi-native/console/commit/5d66223c5e6af45fdc02d3f1e062a6424b0e6cdc)) +* allow cluster informations property to be up to 1000 characters long ([9fe3182](https://github.com/cloud-pi-native/console/commit/9fe3182a5c771552f23f1e147475acd606efe67a)) +* configure Playwright Github workflow ([4ac7f55](https://github.com/cloud-pi-native/console/commit/4ac7f55f6fc6d3d747bafad20efa7184e608b146)) +* **playwright:** add Project page tests ([419ea3c](https://github.com/cloud-pi-native/console/commit/419ea3c735dd3ece8e02ce2d77beaa815170f7cd)) + + +### Bug Fixes + +* :bug: adapt status analysis to Nexus 3.83.1 response ([4efa720](https://github.com/cloud-pi-native/console/commit/4efa720c50e766068d3c8773df83e3754d29c9f1)) +* **#1681:** :bug: missing kv creation if not exists ([e862c5c](https://github.com/cloud-pi-native/console/commit/e862c5ca51dee5a0216f2c66040f90d510579df4)) +* **1638:** add validation for repo names in Zod schema ([48621bd](https://github.com/cloud-pi-native/console/commit/48621bdc63446be7cd5e38ca13b98762af322c16)) +* **client:** ensure branch name is reset to default value after synchronization on a repository ([af0ea44](https://github.com/cloud-pi-native/console/commit/af0ea44186dd8c22c370c5c7edd77b4807fb9e80)) +* **client:** fix Teams E2E cypress being broken after keycloak realm update ([9c95fdf](https://github.com/cloud-pi-native/console/commit/9c95fdffbb208cae9b930fc0d2ca9516bb2cbe9b)) +* fix docker build permissions to allow local path import ([c0aaa2f](https://github.com/cloud-pi-native/console/commit/c0aaa2f29017845575c38ef83d18850f6ae741cb)) +* fix docker build permissions to allow local path import ([6b9461d](https://github.com/cloud-pi-native/console/commit/6b9461dd5e481486a5d1e479f4364348e1cf4fda)) +* fix formatting issue ([662a834](https://github.com/cloud-pi-native/console/commit/662a8342bd83242a61eceae900eb328b1eab5cc5)) +* fix typo ([c20bd60](https://github.com/cloud-pi-native/console/commit/c20bd60e462f59222b1d873d65dcb2ec980b6c33)) +* **playwright:** ensure keycloak tests work for all languages ([44e27a3](https://github.com/cloud-pi-native/console/commit/44e27a3a8d666949ad29d26b8cbd77da50b067bb)) +* **playwright:** fix format command not working ([578e4b3](https://github.com/cloud-pi-native/console/commit/578e4b3e5a0538ac41e9410d09b47f45b98f30bb)) +* remove cluster resources to avoid confusion on its usage ([2ecf9d7](https://github.com/cloud-pi-native/console/commit/2ecf9d70330581b9cb757fa5822a85c6464e0bec)) +* **tests:** fix admin id in pgsql import data not being consistent with keycloak ([3ee2583](https://github.com/cloud-pi-native/console/commit/3ee2583c6f1f7761b5586047b6a93d40c3fb5d58)) + +## [9.3.3](https://github.com/cloud-pi-native/console/compare/v9.3.2...v9.3.3) (2025-06-12) + + +### Bug Fixes + +* :bug: fix gitlab deletion API request ([7acd71b](https://github.com/cloud-pi-native/console/commit/7acd71bc9aed370882b41f31c37dcbb8122bdd3d)) + +## [9.3.2](https://github.com/cloud-pi-native/console/compare/v9.3.1...v9.3.2) (2025-06-12) + + +### Bug Fixes + +* :bug: add missing custom source repositories in argocd values (pulling mode) ([5330f7a](https://github.com/cloud-pi-native/console/commit/5330f7ae16565d7f6e0840fab7bb86691c6aeb4a)) +* fix admin roles not being retrieved when a new user logs in for the first time ([e40c00b](https://github.com/cloud-pi-native/console/commit/e40c00b1b25ecb4026612018737a6a0a1abd5aec)) +* fix hiding tile header for all tiles instead of just the projects ones ([156e75a](https://github.com/cloud-pi-native/console/commit/156e75a215bd18a90b9abe6adee5a300ea180bbd)) + +## [9.3.1](https://github.com/cloud-pi-native/console/compare/v9.3.0...v9.3.1) (2025-05-23) + + +### Bug Fixes + +* :bug: adapt status analysis to Nexus 3.78 response ([95001c2](https://github.com/cloud-pi-native/console/commit/95001c2d96afa101e9867da177d5eed8dcc6b40d)) + +## [9.3.0](https://github.com/cloud-pi-native/console/compare/v9.2.1...v9.3.0) (2025-05-19) + + +### Features + +* :zap: optimization of gitlab API calls ([d9b236e](https://github.com/cloud-pi-native/console/commit/d9b236ebe032d4976094fed757467165ac40df25)) + + +### Bug Fixes + +* :bug: add missing scope when creating keycloak client ([ec24df1](https://github.com/cloud-pi-native/console/commit/ec24df116469237ad85e67d183560976283fc1ec)) +* :wrench: duplicate changelog entries ([#1587](https://github.com/cloud-pi-native/console/issues/1587)) ([ea9fdec](https://github.com/cloud-pi-native/console/commit/ea9fdec8e15132d537f888aec0369ccb655a3667)) +* allowed sources repositories ([a5ce7ad](https://github.com/cloud-pi-native/console/commit/a5ce7ad7a6a3cc17fa8726a6be42cdf53828034b)) + +## [9.2.1](https://github.com/cloud-pi-native/console/compare/v9.2.0...v9.2.1) (2025-03-25) + + +### Bug Fixes + +* :bug: update condition to allow private quota modification ([f4c34bb](https://github.com/cloud-pi-native/console/commit/f4c34bb1e462a9d393c4af231a0f524eb20fcad1)) + +## [9.2.0](https://github.com/cloud-pi-native/console/compare/v9.1.0...v9.2.0) (2025-03-21) + + +### Features + +* :zap: limit gitlab api calls to list cicd variables in sonar plugin ([c1b3299](https://github.com/cloud-pi-native/console/commit/c1b32998f4f340c945c657d2dbbb81160fc054f8)) + +## [9.1.0](https://github.com/cloud-pi-native/console/compare/v9.0.0...v9.1.0) (2025-02-19) + + +### Features + +* :sparkles: display last refresh monitor and display cause to admins ([054ea37](https://github.com/cloud-pi-native/console/commit/054ea37d556b1c6e0fabf0e52f4611395aec18b4)) + + +### Bug Fixes + +* :alien: fix nexus monitoring ([8bb7448](https://github.com/cloud-pi-native/console/commit/8bb74480deaa2b88628d2a715496e93aa4d27629)) +* :bug: missing icons ([d2b98c2](https://github.com/cloud-pi-native/console/commit/d2b98c201fb9f4ce3181c2d2b55fdac2a85f11f5)) + +## [9.0.0](https://github.com/cloud-pi-native/console/compare/v8.24.2...v9.0.0) (2025-02-14) + + +### ⚠ BREAKING CHANGES + +* :triangular_flag_on_post: remove organizations final part + +### Features + +* :sparkles: auto create projectRootDir in Gitlab ([b4f1212](https://github.com/cloud-pi-native/console/commit/b4f12120e81d8d689b1d65de12ed06c65a95279b)) +* :triangular_flag_on_post: remove organizations final part ([73ce9a9](https://github.com/cloud-pi-native/console/commit/73ce9a9497f44bb4d831aed8c25f9ee2e8a337f5)) +* search cluster, table display ([d21be53](https://github.com/cloud-pi-native/console/commit/d21be53e168a820f500334d97faa73ae30aeaf6e)) +* unique dashboard ([d21be53](https://github.com/cloud-pi-native/console/commit/d21be53e168a820f500334d97faa73ae30aeaf6e)) + + +### Bug Fixes + +* :ambulance: bad migration ([baa0712](https://github.com/cloud-pi-native/console/commit/baa071203693db68154ffda61f99a66a72a2c1c8)) +* :ambulance: forgot to use slug in gitlab group ([aa22f19](https://github.com/cloud-pi-native/console/commit/aa22f1963d0d87d28d1a9d74fbf13ac207545382)) +* :bug: auto re login when session expiry ([6c16e88](https://github.com/cloud-pi-native/console/commit/6c16e884d505372a8d2142d5870aed635ac4afbd)) + +## [8.24.2](https://github.com/cloud-pi-native/console/compare/v8.24.1...v8.24.2) (2025-02-06) + + +### Bug Fixes + +* :recycle: add projectId to url params ([3986376](https://github.com/cloud-pi-native/console/commit/3986376d418c017f8edc802ad237df4e3a29df25)) + +## [8.24.1](https://github.com/cloud-pi-native/console/compare/v8.24.0...v8.24.1) (2025-02-04) + + +### Bug Fixes + +* :ambulance: owner of a project cannot see attached private quota ([297a4ac](https://github.com/cloud-pi-native/console/commit/297a4ac410c739142d3ef88f1da58492881ddb6f)) + +## [8.24.0](https://github.com/cloud-pi-native/console/compare/v8.23.4...v8.24.0) (2025-01-28) + + +### Features + +* :sparkles: add an 'external' boolean properties on clusters ([4a64abc](https://github.com/cloud-pi-native/console/commit/4a64abccea40d2675f435b6cec21bb4f9aa3aa43)) +* :sparkles: vault plugin, switch to deploy or not vaultconnections ([cc9c4e6](https://github.com/cloud-pi-native/console/commit/cc9c4e6ad40412921a8d7afc888f014d92ef46c8)) + + +### Bug Fixes + +* :alien: gitlab response changed on upgrade ([d595f3a](https://github.com/cloud-pi-native/console/commit/d595f3ac80e84070e27ed257d4b8879ee2611d46)) +* :bug: names displayed twice at project creation ([079a20a](https://github.com/cloud-pi-native/console/commit/079a20a4e5d910e64e43a6df2925acee5ea7368c)) + +## [8.23.4](https://github.com/cloud-pi-native/console/compare/v8.23.3...v8.23.4) (2025-01-24) + + +### Bug Fixes + +* :bug: duplicates name should not be already allowed ([eb2a4db](https://github.com/cloud-pi-native/console/commit/eb2a4db44596ad2f9f6affbbdb2dea84cb28067c)) +* fix gitlab, no first commit on cloned repository ([2d7038f](https://github.com/cloud-pi-native/console/commit/2d7038fd1f5aefb67259666086048d11a6d5537f)) + +## [8.23.3](https://github.com/cloud-pi-native/console/compare/v8.23.2...v8.23.3) (2025-01-21) + + +### Bug Fixes + +* :ambulance: fix gitlab ci config file ([2c2a287](https://github.com/cloud-pi-native/console/commit/2c2a287a76723dfffecd9ae892a8897f03aef161)) + +## [8.23.2](https://github.com/cloud-pi-native/console/compare/v8.23.1...v8.23.2) (2025-01-21) + + +### Bug Fixes + +* :bug: add a better patch capability for kubernetes ([0050394](https://github.com/cloud-pi-native/console/commit/005039450701f9d77a4479487ba872af602ec689)) +* :bug: can't retrieved enc ns name for gitops cluster ([dcf3c1a](https://github.com/cloud-pi-native/console/commit/dcf3c1a147755ae698ba09f7bb7d1d79d13cc1d1)) +* :triangular_flag_on_post: no more cloned repository at gitlab createProject ([211d070](https://github.com/cloud-pi-native/console/commit/211d070d77802d894c70c584fa2c41e86a297295)) + +## [8.23.1](https://github.com/cloud-pi-native/console/compare/v8.23.0...v8.23.1) (2025-01-20) + + +### Bug Fixes + +* :ambulance: bad gitlab url fn ([2ec5332](https://github.com/cloud-pi-native/console/commit/2ec5332ce320e40da131ff537974c169716e3ac5)) + +## [8.23.0](https://github.com/cloud-pi-native/console/compare/v8.22.1...v8.23.0) (2025-01-20) + + +### Features + +* :fire: remove organization feature ([3b331f6](https://github.com/cloud-pi-native/console/commit/3b331f680716880e7ae4e33b6e441452e2816f3c)) +* :sparkles: store cluster secret in Vault ([cea79ef](https://github.com/cloud-pi-native/console/commit/cea79efc6ecb4c33d868d7f5044ddc7825c69ed5)) + + +### Bug Fixes + +* :bug: test if description is string before using replaceAll function ([d5b1234](https://github.com/cloud-pi-native/console/commit/d5b12342c236dca1231d05528f6ccd7746f62309)) + +## [8.22.1](https://github.com/cloud-pi-native/console/compare/v8.22.0...v8.22.1) (2025-01-09) + + +### Bug Fixes + +* :zap: package icons in client ([73fa90d](https://github.com/cloud-pi-native/console/commit/73fa90dd8aa0fe9c7fa8075ada31eea39af808e5)) +* patch vue-dsfr ([02079aa](https://github.com/cloud-pi-native/console/commit/02079aaf569b80f1a6a245504757524826a7ab3c)) + +## [8.22.0](https://github.com/cloud-pi-native/console/compare/v8.21.0...v8.22.0) (2025-01-08) + + +### Features + +* :sparkles: store cluster secret in Vault ([3935ca6](https://github.com/cloud-pi-native/console/commit/3935ca68cb0927bf150e15c3b205baebafa5cb98)) +* track project version provisioning ([227baf6](https://github.com/cloud-pi-native/console/commit/227baf6802687c9555d3931071a7e049d2756db1)) + + +### Bug Fixes + +* :bug: downgrade gitlab lib to avoid upsert user warning ([90faeff](https://github.com/cloud-pi-native/console/commit/90faeff2978e0ee2db7624b895550db278aa9c17)) +* :bug: nexus auto disable repo config on delete ([1295ece](https://github.com/cloud-pi-native/console/commit/1295ece0312a6c7b319c379e88be1ee570d75499)) + +## [8.21.0](https://github.com/cloud-pi-native/console/compare/v8.20.0...v8.21.0) (2024-12-16) + + +### Features + +* :construction: zone kv store management ([628a6e5](https://github.com/cloud-pi-native/console/commit/628a6e57cb439c8b899acdb8bf1995eedbe0a184)) + + +### Bug Fixes + +* :bug: fix not displaying swagger ([6f708a1](https://github.com/cloud-pi-native/console/commit/6f708a150402835c4ef044bdb8e69f20dfe3ee32)) +* :bug: retry delete project is failed ([c5856fa](https://github.com/cloud-pi-native/console/commit/c5856fadc49acd238ee61d289cf8441a5b3a2e18)) + +## [8.20.0](https://github.com/cloud-pi-native/console/compare/v8.19.1...v8.20.0) (2024-11-25) + + +### Features + +* :sparkles: bulk actions on projects ([951fc15](https://github.com/cloud-pi-native/console/commit/951fc152016e00e3bac6cb1d9920998e3cfb4a80)) + +## [8.19.1](https://github.com/cloud-pi-native/console/compare/v8.19.0...v8.19.1) (2024-11-23) + + +### Bug Fixes + +* :ambulance: broken admin token ([a1eff7f](https://github.com/cloud-pi-native/console/commit/a1eff7f96c6793338019eea0676b8f6dea4c0056)) + +## [8.19.0](https://github.com/cloud-pi-native/console/compare/v8.18.2...v8.19.0) (2024-11-22) + + +### Features + +* :art: use upsert zone hook to manage infra repo ([6fe47d1](https://github.com/cloud-pi-native/console/commit/6fe47d1922359c4934b5364fa3d87dd86cec129b)) +* :sparkles: easy in-cluster config ([01435e2](https://github.com/cloud-pi-native/console/commit/01435e2595383b4be00bdafb45970161765da6b9)) +* :sparkles: enable pwa on frontend ([c6c03f3](https://github.com/cloud-pi-native/console/commit/c6c03f3dadf9e954353ad11c29c19d50538d1e2a)) + + +### Bug Fixes + +* :bug: vault forgot to set kv to v2 ([90dca64](https://github.com/cloud-pi-native/console/commit/90dca64aca9d64b8491b332d0151f1be68dcbccc)) + +## [8.18.2](https://github.com/cloud-pi-native/console/compare/v8.18.1...v8.18.2) (2024-11-20) + + +### Bug Fixes + +* :bug: fix vault detect api hashicorp ([86959f5](https://github.com/cloud-pi-native/console/commit/86959f5874f60e898442ba4f734ff1a5581998a6)) + +## [8.18.1](https://github.com/cloud-pi-native/console/compare/v8.18.0...v8.18.1) (2024-11-20) + + +### Bug Fixes + +* :ambulance: fix pat migration ([df3f699](https://github.com/cloud-pi-native/console/commit/df3f699ddc972a2b2001a20d5e31dd5ba9ad7482)) + +## [8.18.0](https://github.com/cloud-pi-native/console/compare/v8.17.2...v8.18.0) (2024-11-20) + + +### Features + +* :sparkles: build project kv with config for projects ([f1c687f](https://github.com/cloud-pi-native/console/commit/f1c687f55ee4c8b8c2f342e431fb3f0982ffd72c)) +* ✨ add PAT ([2c35cd7](https://github.com/cloud-pi-native/console/commit/2c35cd72a1d0fb14f8c1a633b86e7ef00da80bab)) + + +### Bug Fixes + +* :bug: fix weird front behviours ([e06cf11](https://github.com/cloud-pi-native/console/commit/e06cf117c0fef4f49749d80a7ac5b8de813c6ed7)) + +## [8.17.2](https://github.com/cloud-pi-native/console/compare/v8.17.1...v8.17.2) (2024-11-14) + + +### Bug Fixes + +* :zap: disallow recursive list to avoid big latency ([1fed78c](https://github.com/cloud-pi-native/console/commit/1fed78c1742b70918c9bab1ff616f0cf681d66fa)) + +## [8.17.1](https://github.com/cloud-pi-native/console/compare/v8.17.0...v8.17.1) (2024-11-08) + + +### Bug Fixes + +* :ambulance: fix not displayed repositories in admin view ([48c0a92](https://github.com/cloud-pi-native/console/commit/48c0a92339db999c598fe24cdc9302b28cffa14d)) +* :ambulance: internal gitlab url ([16f195e](https://github.com/cloud-pi-native/console/commit/16f195e55222de023046aaabefa9b6d7f79062ee)) + +## [8.17.0](https://github.com/cloud-pi-native/console/compare/v8.16.0...v8.17.0) (2024-11-07) + + +### Features + +* :sparkles: config items section ([7c41eea](https://github.com/cloud-pi-native/console/commit/7c41eeaec2855355c0fa683d4b7ec81210eae6af)) +* :sparkles: hooks returned store accept null ([1255a32](https://github.com/cloud-pi-native/console/commit/1255a32237a6775e465fc0efe4dfe10bc9463816)) +* :sparkles: nexus fine write policy ([14897e6](https://github.com/cloud-pi-native/console/commit/14897e685ff6665da35464d93571a972e2c8dae9)) +* :sparkles: record and display last time a user logged ([1e8323b](https://github.com/cloud-pi-native/console/commit/1e8323bbfcd611891fe741434d4cb5f780f45dec)) +* gitlab optional internal url ([c1643a1](https://github.com/cloud-pi-native/console/commit/c1643a1eaae350220c4ecf114c7fa16bfb1983a1)) + +## [8.16.0](https://github.com/cloud-pi-native/console/compare/v8.15.1...v8.16.0) (2024-11-06) + + +### Features + +* :lipstick: disable cluster selection when creating a zone ([a8bb19c](https://github.com/cloud-pi-native/console/commit/a8bb19cb4ff50b69b892547e05b1541e322b0ecd)) +* :lipstick: user list rework ([5c13b9d](https://github.com/cloud-pi-native/console/commit/5c13b9d756c549a2f12d99e7f7384690f3166553)) +* :sparkles: nexus customizable write policy ([78a004b](https://github.com/cloud-pi-native/console/commit/78a004bc72e97b3a98c963560e8c11db45b34b67)) + + +### Bug Fixes + +* :bug: minors plugins fix and front ([22937c8](https://github.com/cloud-pi-native/console/commit/22937c875480dc0b6bf67f0784311f98300df04d)) + +## [8.15.1](https://github.com/cloud-pi-native/console/compare/v8.15.0...v8.15.1) (2024-10-29) + + +### Bug Fixes + +* :bug: update depracated import assertion type syntax ([d8f2072](https://github.com/cloud-pi-native/console/commit/d8f20728a0d96c6799e5bba7e821ed12356d6393)) + +## [8.15.0](https://github.com/cloud-pi-native/console/compare/v8.14.0...v8.15.0) (2024-10-29) + + +### Features + +* :lipstick: adding the project id in namespace labels ([eace611](https://github.com/cloud-pi-native/console/commit/eace611838cd2a5ce0e457971914c9d3bf29e6d0)) +* 🚸 rework project selection ([1641fc9](https://github.com/cloud-pi-native/console/commit/1641fc974f455734bedeb0cf2fdaf7356c9b850b)) + +## [8.14.0](https://github.com/cloud-pi-native/console/compare/v8.13.2...v8.14.0) (2024-10-17) + + +### Features + +* :lipstick: add argocd urls for each zone ([cf467ae](https://github.com/cloud-pi-native/console/commit/cf467aeaf82c44099f8b964dd4ef2e00a5323a90)) +* :sparkles: add npm support in npm ([60b7752](https://github.com/cloud-pi-native/console/commit/60b7752fd31ca36860e64df9b320d8468163d0d4)) +* :sparkles: introduce warn status on project ([9d69357](https://github.com/cloud-pi-native/console/commit/9d69357fde2014c1fe134803f8361680bac1d3fc)) +* :sparkles: keycloak client management for configuring sso oidc of each argocd zone ([44b3b06](https://github.com/cloud-pi-native/console/commit/44b3b06e3882fe2a60691a8bd29572e023b32146)) +* :sparkles: return warns and errors message from plugins to user ([be4443f](https://github.com/cloud-pi-native/console/commit/be4443fc10db5669578279fbcbac2b4d67adcb52)) +* :sparkles: show short logs about project to users ([4888e64](https://github.com/cloud-pi-native/console/commit/4888e64527d2627fc2e320300a39745e2571b73c)) + +## [8.13.2](https://github.com/cloud-pi-native/console/compare/v8.13.1...v8.13.2) (2024-10-02) + + +### Bug Fixes + +* :ambulance: no check on api response ([cfc56db](https://github.com/cloud-pi-native/console/commit/cfc56db5b4cdab03b3515d2cae1477e7546dfef8)) + +## [8.13.1](https://github.com/cloud-pi-native/console/compare/v8.13.0...v8.13.1) (2024-09-27) + + +### Bug Fixes + +* :loud_sound: change log logic in sonarqube plugin ([5006562](https://github.com/cloud-pi-native/console/commit/500656244f12d0d1edeb995e30be8cfa95ca8a4f)) + +## [8.13.0](https://github.com/cloud-pi-native/console/compare/v8.12.3...v8.13.0) (2024-09-27) + + +### Features + +* :loud_sound: add logger object ([02c6715](https://github.com/cloud-pi-native/console/commit/02c67150d738ae200f8937adb88bf32001ff2b8c)) +* :sparkles: plugins can return warning to not interupt hook ([3e132c3](https://github.com/cloud-pi-native/console/commit/3e132c3238cc98d1d75ec956ce55efebe2c5304a)) + + +### Bug Fixes + +* :bug: change imports exports to not use z.lazy, fix swagger-ui ([64a60fe](https://github.com/cloud-pi-native/console/commit/64a60fe6bc84ea9de51519049252957726963afa)) +* :bug: dsfr tabs require rewrite DsoHome.vue ([ea03623](https://github.com/cloud-pi-native/console/commit/ea0362303757ac239e5b753af6777514cb714b03)) + +## [8.12.3](https://github.com/cloud-pi-native/console/compare/v8.12.2...v8.12.3) (2024-09-19) + + +### Bug Fixes + +* :passport_control: allow more actions to admin token ([81fa606](https://github.com/cloud-pi-native/console/commit/81fa60653e2bfbcb37251bda448ea3d2f9805eb3)) + +## [8.12.2](https://github.com/cloud-pi-native/console/compare/v8.12.1...v8.12.2) (2024-09-19) + + +### Bug Fixes + +* :ambulance: cbx not checked cause vuedsfr breaking change ([c7e607f](https://github.com/cloud-pi-native/console/commit/c7e607fa6a373866489d7e72e7234107d76a3b7e)) + +## [8.12.1](https://github.com/cloud-pi-native/console/compare/v8.12.0...v8.12.1) (2024-09-17) + + +### Bug Fixes + +* :bug: fix behaviour of complex querying on user list ([b63bd9a](https://github.com/cloud-pi-native/console/commit/b63bd9a56c118fe52ab38589a28c90777c5a557e)) + +## [8.12.0](https://github.com/cloud-pi-native/console/compare/v8.11.1...v8.12.0) (2024-09-17) + + +### Features + +* :sparkles: add admins api tokens ([e141642](https://github.com/cloud-pi-native/console/commit/e141642eb2b717c29cf91f482e91761e7ce4bfa8)) +* :sparkles: add users search filter (admin) ([cc1ab56](https://github.com/cloud-pi-native/console/commit/cc1ab56b2ac496ffd01196ee4d97362ab7a08e03)) + +## [8.11.1](https://github.com/cloud-pi-native/console/compare/v8.11.0...v8.11.1) (2024-09-02) + + +### Bug Fixes + +* :lipstick: better repo ui ([084fc91](https://github.com/cloud-pi-native/console/commit/084fc91332fbdd6052cadbde204df77cd772a07d)) + +## [8.11.0](https://github.com/cloud-pi-native/console/compare/v8.10.1...v8.11.0) (2024-08-29) + + +### Features + +* :sparkles: standalone repo ([ea9a2ba](https://github.com/cloud-pi-native/console/commit/ea9a2bac3fc9696fde6f1ae56b916e180ae19039)) + + +### Bug Fixes + +* :ambulance: edit previous migrations ([2a87288](https://github.com/cloud-pi-native/console/commit/2a87288ea99c19df706fe2f68173ba4bea919a17)) + + +### Performance Improvements + +* :zap: avoid unuseful keycloak call and auto-refresh ([8dcb1c2](https://github.com/cloud-pi-native/console/commit/8dcb1c24e9cce760e278222dcb84c62dff4118a1)) + +## [8.10.1](https://github.com/cloud-pi-native/console/compare/v8.10.0...v8.10.1) (2024-08-27) + + +### Bug Fixes + +* :ambulance: missed admin authorized to remove a member ([15eafa9](https://github.com/cloud-pi-native/console/commit/15eafa9996d7e1f827f5eb1cf6aef425f4620a2b)) + +## [8.10.0](https://github.com/cloud-pi-native/console/compare/v8.9.4...v8.10.0) (2024-08-26) + + +### Features + +* :sparkles: add project dedicated approle and policy in vault, to be used by argocd ([e354978](https://github.com/cloud-pi-native/console/commit/e354978392b8c5d7a4c3591388f0f0a9aa3d7865)) +* :sparkles: introduce fine grained perms and and roles ([ab0a7a5](https://github.com/cloud-pi-native/console/commit/ab0a7a55e42ef94a18e529b168d5f299687ca526)) +* :sparkles: maintenance mode ([fdbd5f0](https://github.com/cloud-pi-native/console/commit/fdbd5f0356a16c24ef677f8a06309aac89af3a4c)) +* :sparkles: option to mirror all branches ([655b032](https://github.com/cloud-pi-native/console/commit/655b0329599fe7e0857e487e3b0fd3ecb49b94ac)) + +## [8.9.4](https://github.com/cloud-pi-native/console/compare/v8.9.3...v8.9.4) (2024-08-09) + + +### Bug Fixes + +* :ambulance: handle missing commit in gitlab repo ([2ef9930](https://github.com/cloud-pi-native/console/commit/2ef993001770e76be5bdf2795a0cbe8e302cf71a)) + +## [8.9.3](https://github.com/cloud-pi-native/console/compare/v8.9.2...v8.9.3) (2024-08-08) + + +### Bug Fixes + +* :ambulance: fix error on getArgoRepo when the infra repository is empty (zero commit) ([bf3f3f1](https://github.com/cloud-pi-native/console/commit/bf3f3f10c0e50eaf2f2b788b587c04e27f1eff00)) + +## [8.9.2](https://github.com/cloud-pi-native/console/compare/v8.9.1...v8.9.2) (2024-07-19) + + +### Bug Fixes + +* :ambulance: clone private repositories in gitlab plugin ([d42bbf8](https://github.com/cloud-pi-native/console/commit/d42bbf8a02950a6f9d0d3218bd7bc40cec203843)) + +## [8.9.1](https://github.com/cloud-pi-native/console/compare/v8.9.0...v8.9.1) (2024-07-18) + + +### Bug Fixes + +* :bug: search on HEAD ref instead of main ([ac13b5f](https://github.com/cloud-pi-native/console/commit/ac13b5fca8c952841e162ed300fdce099c74ac9b)) + +## [8.9.0](https://github.com/cloud-pi-native/console/compare/v8.8.2...v8.9.0) (2024-07-17) + + +### Features + +* :rocket: add helm detection and list values files ([006ccba](https://github.com/cloud-pi-native/console/commit/006ccbaa6d4710af8f1495ae8dadccf729b7dcc6)) + +## [8.8.2](https://github.com/cloud-pi-native/console/compare/v8.8.1...v8.8.2) (2024-07-15) + + +### Bug Fixes + +* :art: harmonize hook calling in business ([4effaab](https://github.com/cloud-pi-native/console/commit/4effaab7f9c80561d0fd52e365822d806a5426f5)) +* :bug: don't ignore user token when testing cluster deployment mode ([cc488c3](https://github.com/cloud-pi-native/console/commit/cc488c3053fadffba040f8396eebfa27bf6c1f79)) +* :bug: env name error not displayed ([d2f84e8](https://github.com/cloud-pi-native/console/commit/d2f84e822b94074615df3385f0c088582ceaf3cf)) +* :bug: require tls serverName ([9d4dea2](https://github.com/cloud-pi-native/console/commit/9d4dea22e68aab7ef6ee6b74a16e92a397002709)) + +## [8.8.1](https://github.com/cloud-pi-native/console/compare/v8.8.0...v8.8.1) (2024-07-09) + + +### Bug Fixes + +* :ambulance: not ready for project reponse validation ([c77dd68](https://github.com/cloud-pi-native/console/commit/c77dd6889886b3c867294437e5377573771a7ff0)) +* :ambulance: second try to fix ([1ef6244](https://github.com/cloud-pi-native/console/commit/1ef6244d7536de5704ec6fc9a83b0cfc5d806147)) + +## [8.8.1](https://github.com/cloud-pi-native/console/compare/v8.8.0...v8.8.1) (2024-07-09) + + +### Bug Fixes + +* :ambulance: not ready for project reponse validation ([c77dd68](https://github.com/cloud-pi-native/console/commit/c77dd6889886b3c867294437e5377573771a7ff0)) + +## [8.8.0](https://github.com/cloud-pi-native/console/compare/v8.7.1...v8.8.0) (2024-07-09) + + +### Features + +* :sparkles: query filter admin projects ([feb701d](https://github.com/cloud-pi-native/console/commit/feb701d9d36db0120032f0c1f8888a893c799495)) + +## [8.7.1](https://github.com/cloud-pi-native/console/compare/v8.7.0...v8.7.1) (2024-07-05) + + +### Bug Fixes + +* :bug: insert user when first accessing admin pages ([095b9de](https://github.com/cloud-pi-native/console/commit/095b9de4e282712919e932bf67b15fdaeec96932)) +* :lipstick: repo should be able to change infra, not infra ([1069728](https://github.com/cloud-pi-native/console/commit/10697280499b44b1a1eef28a71d510bfe8916cf3)) + +## [8.7.0](https://github.com/cloud-pi-native/console/compare/v8.6.0...v8.7.0) (2024-07-03) + + +### Features + +* :necktie: hide private quotas for all on environment form ([ef08f29](https://github.com/cloud-pi-native/console/commit/ef08f29080d2be32236839be1e402fe76f44f465)) + + +### Bug Fixes + +* :lock: serialize sevice monitor response ([26ee98f](https://github.com/cloud-pi-native/console/commit/26ee98fa21984e57eaae8251dc14394f0e94c4b9)) + +## [8.6.0](https://github.com/cloud-pi-native/console/compare/v8.5.0...v8.6.0) (2024-06-28) + + +### Features + +* :art: change subpath of deployments ([d7b57ca](https://github.com/cloud-pi-native/console/commit/d7b57ca095bcb1ae8fcde5ead7a533b0f0ce81e5)) +* :sparkles: add user leo poumailloux ([0147e07](https://github.com/cloud-pi-native/console/commit/0147e071bbdfbb8c666d20247bd95938f645f152)) +* :sparkles: give project ownership ([dc6fa79](https://github.com/cloud-pi-native/console/commit/dc6fa798fe2fe37395c521482bbc857eb74b50d1)) +* :sparkles: truncate description ([8191a41](https://github.com/cloud-pi-native/console/commit/8191a41ae808980d400f1e65499e012d0d192c28)) + + +### Bug Fixes + +* :bug: handle vault ha for status monitor ([3692e01](https://github.com/cloud-pi-native/console/commit/3692e010d9d4c0c03e8403b4d5df3591f0312209)) +* :bug: interface suppression & add explication ([4476c9b](https://github.com/cloud-pi-native/console/commit/4476c9bacdcc748ca0d2d1029f8980392a2ea068)) +* :bug: isolation des groupes d'envs dans keycloak ([91b5d55](https://github.com/cloud-pi-native/console/commit/91b5d551a88f5059868a80271be5b37c451839d0)) +* :bug: prevent env deletion if project is locked ([d75a5cf](https://github.com/cloud-pi-native/console/commit/d75a5cfc4e86741a91ba31d0c2d2fc2b21fa5226)) +* :bug: prevent env deletion if project is locked ([a273869](https://github.com/cloud-pi-native/console/commit/a273869ba421ee37fc342c585c38c978aab5d411)) +* :bug: support slashes in syncRepository branchName ([d304427](https://github.com/cloud-pi-native/console/commit/d304427c8bd0af8e7080ec9480143d0abab3bb76)) + +## [8.5.0](https://github.com/cloud-pi-native/console/compare/v8.4.1...v8.5.0) (2024-06-10) + + +### Features + +* :sparkles: add text filter for admin user list ([d44f142](https://github.com/cloud-pi-native/console/commit/d44f142e9f559f7714e3eb71650c1dd3c8a325cd)) +* :sparkles: allow harbor quota limit ([9644d03](https://github.com/cloud-pi-native/console/commit/9644d037ea2642ba9e456e362c18a1eed520b66b)) +* :sparkles: argocd extra repositories ([303f4a2](https://github.com/cloud-pi-native/console/commit/303f4a2fac89ce511aeac9cf87dbf6221908e373)) + + +### Bug Fixes + +* :bug: keycloak crashed when more than 10 environments ([f3d4f5d](https://github.com/cloud-pi-native/console/commit/f3d4f5d3cc5b49946cc4e1ab969895229524f335)) +* :bug: manage if no users in project ([cd23a59](https://github.com/cloud-pi-native/console/commit/cd23a5940eddf1d8509bf7594842db5bcc5fa1ae)) +* :bug: why is it useful ? ([ee2bf5c](https://github.com/cloud-pi-native/console/commit/ee2bf5ce588a92b303b3af8db2bf24ed999822eb)) + +## [8.4.1](https://github.com/cloud-pi-native/console/compare/v8.4.0...v8.4.1) (2024-05-31) + + +### Bug Fixes + +* :bug: add try catch to parsing plugin error ([b958529](https://github.com/cloud-pi-native/console/commit/b95852942863f75eda7458e7593e09579c7811d5)) + +## [8.4.0](https://github.com/cloud-pi-native/console/compare/v8.3.0...v8.4.0) (2024-05-29) + + +### Features + +* :sparkles: create a user usable robot for harbor ([a71dfc9](https://github.com/cloud-pi-native/console/commit/a71dfc992b62a6c3c69dcc6d7de98b8d2d72a79c)) +* :sparkles: customize keycloak theme ([7c21375](https://github.com/cloud-pi-native/console/commit/7c21375d1ad56a03f84c25d04cbe0ada7eac8d5c)) +* :sparkles: option for gitlab display secret ([28d3ebc](https://github.com/cloud-pi-native/console/commit/28d3ebce4d6912904e1f21cd3c126ca342fc3cfb)) + + +### Bug Fixes + +* :art: squash const ([cd1be8c](https://github.com/cloud-pi-native/console/commit/cd1be8c8f282be045ffce473387b21ed95355f5e)) +* :bug: fallback if no owners are found ([629ef40](https://github.com/cloud-pi-native/console/commit/629ef40afb3c6ff0a22b0f17c2c86a343463a8ba)) +* :bug: missing await and ambiguous message ([0530b45](https://github.com/cloud-pi-native/console/commit/0530b4520a5bc05d6a45f9f76029c898f960ee34)) +* :bug: update cluster logic ([65ba7e1](https://github.com/cloud-pi-native/console/commit/65ba7e10f0d7dd9e1675da633c922fff8499ee95)) + +## [8.3.0](https://github.com/cloud-pi-native/console/compare/v8.2.2...v8.3.0) (2024-05-16) + + +### Features + +* :sparkles: handle keycloak admin group membership from console ([4741878](https://github.com/cloud-pi-native/console/commit/4741878fdfa4d784ed2e7695407ebce6b00b4850)) + + +### Bug Fixes + +* :ambulance: possible missed clusters in payload ([47efcc8](https://github.com/cloud-pi-native/console/commit/47efcc893f272823b5d7d7f4b78b9c29ddad9095)) +* :ambulance: unhandled promise gitlab ([592035c](https://github.com/cloud-pi-native/console/commit/592035c95605f004e62cba71faff36ea189d6173)) + +## [8.2.2](https://github.com/cloud-pi-native/console/compare/v8.2.1...v8.2.2) (2024-05-13) + + +### Bug Fixes + +* :bug: fix update cluster ([f02958c](https://github.com/cloud-pi-native/console/commit/f02958ca3888fdb452a545fd94b8d20f9ebb93ed)) +* :bug: stores should have unique names ([6e8032a](https://github.com/cloud-pi-native/console/commit/6e8032a68b71d7ea86b8153d0984013e5bd4c8df)) + +## [8.2.1](https://github.com/cloud-pi-native/console/compare/v8.2.0...v8.2.1) (2024-05-06) + + +### Bug Fixes + +* :fire: remove unjustified refine ([9b1d41e](https://github.com/cloud-pi-native/console/commit/9b1d41ec402446678518bb39c0f3cff543afceb3)) +* :mute: remove problematic log ([639e982](https://github.com/cloud-pi-native/console/commit/639e982df49ccb5888adb26f3a0b1ca62fe53973)) + +## [8.2.0](https://github.com/cloud-pi-native/console/compare/v8.1.1...v8.2.0) (2024-05-03) + + +### Features + +* :sparkles: adding the value file commit ([6ea872b](https://github.com/cloud-pi-native/console/commit/6ea872b498e658113983cdc2b80d9d2ca3cf770d)) +* :sparkles: can configure plugins globally or by project ([15b66c1](https://github.com/cloud-pi-native/console/commit/15b66c1be31311e3b6062c3d771fb3ecc3dcbfa8)) + + +### Bug Fixes + +* :bug: override plugins unzip if already exists ([ff65133](https://github.com/cloud-pi-native/console/commit/ff651332fcee6ae752bdf4533ebbb88f216bfe95)) + +## [8.1.1](https://github.com/cloud-pi-native/console/compare/v8.1.0...v8.1.1) (2024-04-24) + + +### Bug Fixes + +* :ambulance: correct sql query for zoneId ([c47dae4](https://github.com/cloud-pi-native/console/commit/c47dae4a4f74cd51e6c9fe19856d4b8eea08cbb8)) + +## [8.1.0](https://github.com/cloud-pi-native/console/compare/v8.0.3...v8.1.0) (2024-04-24) + + +### Features + +* :safety_vest: allow unlogged users to access services health ([e2fc942](https://github.com/cloud-pi-native/console/commit/e2fc9423b107f1afd9fdbe1b017b7dd1f3d2eab1)) +* :safety_vest: check existing zone slug before creating one ([072fffb](https://github.com/cloud-pi-native/console/commit/072fffbd1d4788329fb76b927242854feb25d31b)) +* :sparkles: add zones ([53d155f](https://github.com/cloud-pi-native/console/commit/53d155f244a846e0cf23d3cf75528e6d937fe0ce)) +* :sparkles: allow repo sync from console ui ([1cd1c93](https://github.com/cloud-pi-native/console/commit/1cd1c93a9e1d95663e51f4de24a5b518fa2bee1a)) +* :sparkles: display project services in admin view ([a5d53fd](https://github.com/cloud-pi-native/console/commit/a5d53fd586dd912b13b72196ed99c2354c022448)) + + +### Bug Fixes + +* :bug: app version in client footer ([ac2f26e](https://github.com/cloud-pi-native/console/commit/ac2f26e88ca3fa593668d4f9641465b09855c663)) +* :bug: get only client app version from env on production build ([3bb3017](https://github.com/cloud-pi-native/console/commit/3bb3017f968e8e93b955b33cb494d51ecce5521a)) +* :bug: handle stage and zone changes in environment form ([5aa0a58](https://github.com/cloud-pi-native/console/commit/5aa0a585e9903e0a29f7bec29e4a08090109542b)) + +## [8.0.3](https://github.com/cloud-pi-native/console/compare/v8.0.2...v8.0.3) (2024-04-16) + + +### Bug Fixes + +* :ambulance: gitlab secret ([0c03a95](https://github.com/cloud-pi-native/console/commit/0c03a9551377705fced2dedf1b9e8063df9d2748)) + +## [8.0.2](https://github.com/cloud-pi-native/console/compare/v8.0.1...v8.0.2) (2024-04-11) + + +### Bug Fixes + +* :lock: remove old robot permission that harbor does not support anymore ([1d988ce](https://github.com/cloud-pi-native/console/commit/1d988ce86b6b338a576d517816f013295b2f18d3)) + +## [8.0.1](https://github.com/cloud-pi-native/console/compare/v8.0.0...v8.0.1) (2024-04-04) + + +### Bug Fixes + +* :ambulance: change kubernetes logic ([3679c22](https://github.com/cloud-pi-native/console/commit/3679c2221c961b4887e1191229edaacd6e34649b)) + +## [8.0.0](https://github.com/cloud-pi-native/console/compare/v7.0.1...v8.0.0) (2024-04-02) + + +### ⚠ BREAKING CHANGES + +* :sparkles: simplify hooks for idempotency + +### Features + +* :safety_vest: allow + sign for repo externalUserName ([4a695ef](https://github.com/cloud-pi-native/console/commit/4a695eff71ea6a03f839d19d2e2526180ea5161b)) +* :sparkles: add replay hooks button for project ([9147231](https://github.com/cloud-pi-native/console/commit/91472317ebd112b4f360d859d25d8793c89eb331)) +* :sparkles: add sonar properties var file to gitlab ([0462a36](https://github.com/cloud-pi-native/console/commit/0462a364b54e66639ade6997f6f4c48e9fe305c6)) +* :sparkles: simplify hooks for idempotency ([aac0a2d](https://github.com/cloud-pi-native/console/commit/aac0a2d331acd4e86531591de99cc944584f7684)) +* 🚧 création du repo infra-apps à la création du projet ([17007f7](https://github.com/cloud-pi-native/console/commit/17007f7eb0bb8c9ba612cf70536236b428b801da)) + + +### Bug Fixes + +* :ambulance: gitlab plugin deleted public repo secret ([46002b0](https://github.com/cloud-pi-native/console/commit/46002b007e9dfd69ce5daeee33cfd61164e0ed8e)) +* :ambulance: gitlab plugin deleted public repo secret ([353f43e](https://github.com/cloud-pi-native/console/commit/353f43eba74d5d0b0a95ace2ee65d9c51c0685b7)) +* :bug: reload user projects after hooks replay ([ae57ed0](https://github.com/cloud-pi-native/console/commit/ae57ed017dac4e5601cdf80a02598967bf75364b)) +* :lipstick: improve logs ui ([ec6ecb2](https://github.com/cloud-pi-native/console/commit/ec6ecb283f0c7c37589c02e194f1b101e9dc10c2)) +* :zap: manage hook concurrency execution ([6af135f](https://github.com/cloud-pi-native/console/commit/6af135f0548d232ef5bd190375057da0968d8f94)) + +## [7.0.1](https://github.com/cloud-pi-native/console/compare/v7.0.0...v7.0.1) (2024-03-18) + + +### Bug Fixes + +* :bug: add a common error parser for logs ([2391439](https://github.com/cloud-pi-native/console/commit/2391439ef6147119863278bd69ac01797b368f76)) +* :bug: add a common error parser for logs ([b6f28d7](https://github.com/cloud-pi-native/console/commit/b6f28d738c03c3c023d16cf658de8cdc384aa6df)) +* :bug: correctly handle external plugins init ([91e5d7f](https://github.com/cloud-pi-native/console/commit/91e5d7f27f90c1e249ed9e295b05400f06565c2d)) +* :green_heart: fix ci-cd build and release ([a036562](https://github.com/cloud-pi-native/console/commit/a036562f005ba0550c4bf25248227aee02357371)) + +## [7.0.0](https://github.com/cloud-pi-native/console/compare/console-v6.5.1...console-v7.0.0) (2024-03-13) + + +### ⚠ BREAKING CHANGES + +* :art: split plugins +* :boom: environment management + +### Features + +* :alien: synch controllers with plugin calls ([d0a271d](https://github.com/cloud-pi-native/console/commit/d0a271d188840d8682b6abe412ee8da4db7992b1)) +* :boom: environment management ([b4f3793](https://github.com/cloud-pi-native/console/commit/b4f379315803aee1264192c16851af049fa661ee)) +* :children_crossing: add confirm box for updating an organization ([5c3cd20](https://github.com/cloud-pi-native/console/commit/5c3cd207221a22ed5d93bdf8638b23c9849897f6)) +* :children_crossing: add default branch to sync in mirror pipeline ([45dbc01](https://github.com/cloud-pi-native/console/commit/45dbc01c4a95d04a201b2c5e198e3b192e1e5fe2)) +* :children_crossing: add filtering on admin projects view ([dc5412c](https://github.com/cloud-pi-native/console/commit/dc5412c1a80ce84cd563376a0b7f72da7926a858)) +* :children_crossing: better ux for forms in tiles ([da68243](https://github.com/cloud-pi-native/console/commit/da68243a91865732db46b9ae610a82eeec5c9bf2)) +* :children_crossing: improve home page and update doc link ([038d982](https://github.com/cloud-pi-native/console/commit/038d982ead7bbfa70debdf902d5c46e8518c1e60)) +* :children_crossing: inform users that clipboard works only with https ([909c3b6](https://github.com/cloud-pi-native/console/commit/909c3b672bcae1401d2133c8fff94cdca91f61bc)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :iphone: more responsive sideMenu and permissionForm ([c840306](https://github.com/cloud-pi-native/console/commit/c8403067224b5ad90e34b8233a1830ceeec8b715)) +* :loud_sound: add payload and full hook response in validation hooks ([adabb02](https://github.com/cloud-pi-native/console/commit/adabb02fd0b8028b1fcdf1113744234a925d72ce)) +* :loud_sound: add reqId in db logs ([2b7f22b](https://github.com/cloud-pi-native/console/commit/2b7f22b7e24748dc37c8623bbda3e914ed7cae00)) +* :necktie: do not fail and lock project for membership reasons ([91038d5](https://github.com/cloud-pi-native/console/commit/91038d5ed3533e4d4eacb8eac3539f29a60ab091)) +* :passport_control: update harbor project member role ([4419dd5](https://github.com/cloud-pi-native/console/commit/4419dd5f395f79a9eb99e8c0f305a7433ad84f7e)) +* :safety_vest: update password character support ([c82f35f](https://github.com/cloud-pi-native/console/commit/c82f35fb7f20ad06c0a63eb518407d17ddb88b51)) +* :sparkles: add backoffice for admins ([fadfdfc](https://github.com/cloud-pi-native/console/commit/fadfdfc43c427261adc3e31dbb1907d6dcefc5cb)) +* :sparkles: add infos key for cluster ([25ab8b8](https://github.com/cloud-pi-native/console/commit/25ab8b8d821c37f4491bd8fea075bdc7fbfcfc93)) +* :sparkles: add or remove a team member via keycloak ([0563d20](https://github.com/cloud-pi-native/console/commit/0563d20ca93a25decc7dbb5d945e2b738b0d575a)) +* :sparkles: add project environments to hook payload ([7aa8117](https://github.com/cloud-pi-native/console/commit/7aa81179ac95afdddf711e1a0a4b3c3a45a35c5a)) +* :sparkles: add quota and stage management for admin ([80ce642](https://github.com/cloud-pi-native/console/commit/80ce642b69a5eaaf1b53c7d884b70889c4e71d9d)) +* :sparkles: add quotas selection for environment ([bb97f5f](https://github.com/cloud-pi-native/console/commit/bb97f5f17c5e7b497eccb655a6eb5da692f7df73)) +* :sparkles: add stage in setPermission payload ([e920805](https://github.com/cloud-pi-native/console/commit/e920805a3181ae98bf98b774d19e02d1e8a2139c)) +* :sparkles: add stage management for admin ([e133853](https://github.com/cloud-pi-native/console/commit/e13385347610014a4edcc0b2ff12252386328cb8)) +* :sparkles: add tlsServerName input in cluster form ([4bdc01e](https://github.com/cloud-pi-native/console/commit/4bdc01ef9dd5b2041a34491e46b9759e5c6652f0)) +* :sparkles: better integration of sonarqube ([c0a0517](https://github.com/cloud-pi-native/console/commit/c0a05173a158b74d752aafaccdff58fc3b29c382)) +* :sparkles: delete cluster if no environment suscribed ([8edc50a](https://github.com/cloud-pi-native/console/commit/8edc50a85f266f1135b340d6df39a48ea7ac746b)) +* :sparkles: disallow hyphen on project, environment and stage ([f9b05ee](https://github.com/cloud-pi-native/console/commit/f9b05eefee41f7d514d749a0ac3fa1c1d063f051)) +* :sparkles: display cluster infos to users on environment form ([4afdf34](https://github.com/cloud-pi-native/console/commit/4afdf34fc40df7789a00fe6d159ee73370b94cb9)) +* :sparkles: download projects data button for admins ([cb38e75](https://github.com/cloud-pi-native/console/commit/cb38e7573314ba0980c4161d1a77a9e83fddb236)) +* :sparkles: enable admin cluster update ([f81008e](https://github.com/cloud-pi-native/console/commit/f81008e97c25bc73466b6257bd4cb2dc0bde9163)) +* :sparkles: handle gitlab membership ([7ff68bd](https://github.com/cloud-pi-native/console/commit/7ff68bdf8f9a16562cfc00fbdb0c322b0f09943d)) +* :sparkles: lock and unlock projects according to their org status ([3b9444a](https://github.com/cloud-pi-native/console/commit/3b9444aeaa2fab76461946e9a4d00e8afe4a62f1)) +* :sparkles: retrieve user from kc if does not exist in db ([3d4461a](https://github.com/cloud-pi-native/console/commit/3d4461ae7d82e6f64a1644921893d89f41e9a2d9)) +* :sparkles: services secrets improvement ([40e3ddd](https://github.com/cloud-pi-native/console/commit/40e3ddd2acd7e000b03f5bfa5663533449d315bf)) +* :sparkles: to fn, more infos, multi tiles ([8c4de83](https://github.com/cloud-pi-native/console/commit/8c4de83cf3b4bc0e335292d437ddf7d22305ff93)) +* :speech_balloon: translate stage in front for better understanding ([5a66479](https://github.com/cloud-pi-native/console/commit/5a6647980319524098a72fcbd2be6affbda3f3d0)) +* :technologist: add images auto-scan to harbor settings ([507c6e6](https://github.com/cloud-pi-native/console/commit/507c6e6a541b19502084276db30cf834e586d955)) +* :technologist: add swagger for server api ([abe15d2](https://github.com/cloud-pi-native/console/commit/abe15d2ceb93039ecd795389156f8730e5327841)) + + +### Bug Fixes + +* :alien: add owner in archiveProject hookpayload ([5c99183](https://github.com/cloud-pi-native/console/commit/5c99183f03afe0c20980c9649ce37788b833a351)) +* :alien: change logo for svg ([aa8315b](https://github.com/cloud-pi-native/console/commit/aa8315b63617c8413a448499e2ab0e68c216faef)) +* :alien: fetch does not support proxy natively ([20d531d](https://github.com/cloud-pi-native/console/commit/20d531d622e6fe9e2869e13db0d205b44ad602ce)) +* :alien: sync controllers require bigger axios timeout ([5116bbd](https://github.com/cloud-pi-native/console/commit/5116bbdb8f49b73c920ac04421ca05ccabef902c)) +* :ambulance: bad logic register plugins ([049040e](https://github.com/cloud-pi-native/console/commit/049040eafda26214afbc2118cb56542b133feca5)) +* :ambulance: preserve status colum in project ([febfaff](https://github.com/cloud-pi-native/console/commit/febfaff702d6a55210723ce213d603ad059ceaed)) +* :ambulance: register cluster destination via kubeconfig ([0e8d310](https://github.com/cloud-pi-native/console/commit/0e8d3102267268c595d172808e30183dbe4b3aad)) +* :ambulance: send internalRepoName to plugins on updateRepository hook ([2bde004](https://github.com/cloud-pi-native/console/commit/2bde0045592f2898975633c0c6f959311234f7f3)) +* :art: can disble plugins by name, and some fixes ([f2bd397](https://github.com/cloud-pi-native/console/commit/f2bd397e0526d76d439ef8f38b5dcebdfbaa8ede)) +* :art: refactor of admin project types and refs ([be0684f](https://github.com/cloud-pi-native/console/commit/be0684f378696a24304d7abecd6b90c0738e1785)) +* :bento: update MultiSelector component ([9a44bb3](https://github.com/cloud-pi-native/console/commit/9a44bb35d8aa0da1aaf8f96a745c28f97f6ea53c)) +* :bug: add conditional chaining to avoid error in dsobadge ([56ad040](https://github.com/cloud-pi-native/console/commit/56ad0403242222f50ef8abe898b46c241d40b66d)) +* :bug: add missing await in gitlab createProjectMirror ([f1bf907](https://github.com/cloud-pi-native/console/commit/f1bf9072b88abec3f08654720f64571aa421db34)) +* :bug: change appproject policy ([e20b6c4](https://github.com/cloud-pi-native/console/commit/e20b6c431348b08bed7ea215a967c51d0e6187f0)) +* :bug: clusters related bugs ([c2ba63e](https://github.com/cloud-pi-native/console/commit/c2ba63e94d87bdf1741e9a51e4522c3f77b354de)) +* :bug: create appropriate folders for server image ([3027fef](https://github.com/cloud-pi-native/console/commit/3027fef08ebc8a58e2e705645febaf40af7314f5)) +* :bug: filter archived project from cluster form ([50913f5](https://github.com/cloud-pi-native/console/commit/50913f57def7d8e3e36f63a5876e476e64884828)) +* :bug: fix endless redirect on login if authenticated ([461c6bd](https://github.com/cloud-pi-native/console/commit/461c6bd385f44a551fe9af1027c6beca1e7fa9ec)) +* :bug: fix gitlab group deletion ([12a35d0](https://github.com/cloud-pi-native/console/commit/12a35d08619fbeb231d4e7931fb7fdb51ef63fde)) +* :bug: fix missing extensions ([4cd4b21](https://github.com/cloud-pi-native/console/commit/4cd4b212e88d30b447d5674cf157ce71862fe14a)) +* :bug: fix not updated argocd cluster and not using insecure in config ([69e7a9a](https://github.com/cloud-pi-native/console/commit/69e7a9ac7a584d76ce047b8330356ce2387218d7)) +* :bug: fix types, add void and handle it ([110cfed](https://github.com/cloud-pi-native/console/commit/110cfedfcb2a58757b5ad47f96f6323cbe034550)) +* :bug: import new shared package ([ff0b0db](https://github.com/cloud-pi-native/console/commit/ff0b0db9dcc96c10b409b51bd4180c03b5b01478)) +* :bug: launch e2e test in open mode without turbo ([d3497d1](https://github.com/cloud-pi-native/console/commit/d3497d13e3862d7701cbbd2da65cb7a571032eb2)) +* :bug: manage cancel emit on update repo form ([16df7b4](https://github.com/cloud-pi-native/console/commit/16df7b43d584fb1edfa5a01938356b04b785429b)) +* :bug: missed limits ([f2ba02d](https://github.com/cloud-pi-native/console/commit/f2ba02d41e923b0add154fc7cb5ae7d8a2c89370)) +* :bug: missing check in search group ([7fb65c1](https://github.com/cloud-pi-native/console/commit/7fb65c12754eea955c7ba942329cf5bfa612f8b7)) +* :bug: missing check nexus find user ([993dd2a](https://github.com/cloud-pi-native/console/commit/993dd2ab30687d0ea23000596f909b77b3be70e5)) +* :bug: missing check nexus find user ([e0f1674](https://github.com/cloud-pi-native/console/commit/e0f167424df8e0c01619a0da38bf3e5723014ee1)) +* :bug: now the admin can chosse skip tls api kube ([e09aa2a](https://github.com/cloud-pi-native/console/commit/e09aa2ac659e1439f59050c56256f530b9f8cae6)) +* :bug: optional paramater to call repeatFn ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: pin pnpm version in dockerfiles ([b494a2f](https://github.com/cloud-pi-native/console/commit/b494a2f0d190e3da81f9765dded76187333e61af)) +* :bug: pnpm lock ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: retrieve kc user bug fix ([aaf61c5](https://github.com/cloud-pi-native/console/commit/aaf61c51a288cd7998a5d3883cf467471b42c3cb)) +* :bug: run.sh js to ts reference ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: send cluster kubeconfig in hook paylaod ([e21c692](https://github.com/cloud-pi-native/console/commit/e21c6923d016bb59a69be457d5d4efc08243a771)) +* :bug: share loadingct display knowledge ([8520a5c](https://github.com/cloud-pi-native/console/commit/8520a5c827d05316e9dedc59dd90a92fb3f513d4)) +* :bug: should not set caData if skipTLSVerify is true ([ccd66f8](https://github.com/cloud-pi-native/console/commit/ccd66f83be75850ffe47e87ff405fcf9cf136e3c)) +* :bug: update compile cmd to build in plugins pkgs ([b9e4424](https://github.com/cloud-pi-native/console/commit/b9e44246c22238c3cdb383515816951ace4f65c2)) +* :bug: use argoCD regex for internalRepoName ([6c2ed3f](https://github.com/cloud-pi-native/console/commit/6c2ed3f682514b947e66547ce136d148b9e2d686)) +* :bug: various fixes around plugins and controllers ([6271c67](https://github.com/cloud-pi-native/console/commit/6271c67f3d8471ee6afc3409c5d753f7ade950f0)) +* :card_file_box: set all project status to failed for v6 release ([b689c35](https://github.com/cloud-pi-native/console/commit/b689c356f27e769b18aabab2f2510dd24354d95f)) +* :construction: fix configs ([ff77d7a](https://github.com/cloud-pi-native/console/commit/ff77d7ab10aecdf92643f9bcece9ef3360bc4c46)) +* :goal_net: add try catch in checkApi func ([baf38af](https://github.com/cloud-pi-native/console/commit/baf38af3f96fe227a934228d7875181e1407c8e9)) +* :goal_net: allow deleting environment if not found ([a2dc7da](https://github.com/cloud-pi-native/console/commit/a2dc7daf96942121e9291063569ff9c6612bd345)) +* :goal_net: throw error if catched in business ([1790ec2](https://github.com/cloud-pi-native/console/commit/1790ec274e965d5d62a0693937eec5ee765602e2)) +* :goal_net: validate schema before creating repo or env ([23535c3](https://github.com/cloud-pi-native/console/commit/23535c3e0c9923df0cc8adb5fb646dc2dd21fb7c)) +* :heavy_plus_sign: fix pnpm-lock file ([93aa64e](https://github.com/cloud-pi-native/console/commit/93aa64e146e11f30c8305f85d7ff03b696b0297b)) +* :label: add missing fastify keycloak adapter patch to improve types ([2d4ca1f](https://github.com/cloud-pi-native/console/commit/2d4ca1fc64c374cd06cef5b27842502db59a0c1c)) +* :lipstick: add icons ([ed1887b](https://github.com/cloud-pi-native/console/commit/ed1887baf6a65d0d58c6e399991a1419351c3883)) +* :lipstick: fix padding ([907d424](https://github.com/cloud-pi-native/console/commit/907d424a94f2dfc7e7110882438130bdb5dc4cd8)) +* :lipstick: restyle logs interface ([fc362cf](https://github.com/cloud-pi-native/console/commit/fc362cf461c65f6e4e8e7b6b2d92c83a41844bdf)) +* :lock: root token is not used in new repo credential when synced ([c92ea8b](https://github.com/cloud-pi-native/console/commit/c92ea8b5ef95097b07c2bbda435ce433f5bba53d)) +* :pushpin: pin axios version to avoid vite error ([88c6d30](https://github.com/cloud-pi-native/console/commit/88c6d30b5ab14cd9ca899fdf7cfcd17faa067903)) +* :rewind: fix bugs introduced during my vacations ([9ee467c](https://github.com/cloud-pi-native/console/commit/9ee467c69b590a4630dae19f42f2fc5c9321953e)) +* :safety_vest: validate user schema before trying to insert it in db ([ab8e6ef](https://github.com/cloud-pi-native/console/commit/ab8e6efdb00ec29c6a9a1e0f89c58ffbc999c966)) +* :technologist: change domains for dev and int ([0de00ab](https://github.com/cloud-pi-native/console/commit/0de00abf78543bc769f80be2f0e59795cfefefa5)) +* :technologist: fix docker volumes for integ mode ([80af072](https://github.com/cloud-pi-native/console/commit/80af072bfc0affa8ab0b415a3f6d63d17ceaf2c2)) +* :technologist: fix import kubeconfig in integration ([51af3dd](https://github.com/cloud-pi-native/console/commit/51af3dd16128a6d4a1e676921937005e295acfc9)) +* :test_tube: trying to fix ct test ([c67366e](https://github.com/cloud-pi-native/console/commit/c67366e0b319de7edd681b9ba9265091be5c9e32)) +* :test_tube: trying to optimize dependancies for failing modules ([ec97602](https://github.com/cloud-pi-native/console/commit/ec9760236a380efdcabc5bc0f0cb7e9b23c07adb)) +* :wrench: handle no values for disabledPlugins ([d0d46a3](https://github.com/cloud-pi-native/console/commit/d0d46a38570db6f626f5879da180829216826fa8)) + + +### Performance Improvements + +* :construction: skip failing test for merge ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) + + +### Reverts + +* :card_file_box: add missing default for projectStatus ([2263988](https://github.com/cloud-pi-native/console/commit/22639888442da48b097f936d0afb62652d262b02)) +* :card_file_box: add missing default for projectStatus ([5653165](https://github.com/cloud-pi-native/console/commit/565316504a1baa90d2c7c999c0ba6810151324f5)) +* :poop: comment harbor projet member role update ([63926ae](https://github.com/cloud-pi-native/console/commit/63926ae9c5a5fe6b9e6e8a2d0007f55d186b0f36)) + + +### Build System + +* :art: split plugins ([f4ac305](https://github.com/cloud-pi-native/console/commit/f4ac30587917b78d6d0f3e58cdda1b8a08123b48)) + +## [6.5.2](https://github.com/cloud-pi-native/console/compare/v6.5.1...v6.5.2) (2024-03-13) + + +### Bug Fixes + +* :bug: missing check in search group ([7fb65c1](https://github.com/cloud-pi-native/console/commit/7fb65c12754eea955c7ba942329cf5bfa612f8b7)) +* :bug: missing check nexus find user ([e0f1674](https://github.com/cloud-pi-native/console/commit/e0f167424df8e0c01619a0da38bf3e5723014ee1)) + +## [6.5.1](https://github.com/cloud-pi-native/console/compare/v6.5.0...v6.5.1) (2024-01-25) + + +### Bug Fixes + +* :bug: share loadingct display knowledge ([8520a5c](https://github.com/cloud-pi-native/console/commit/8520a5c827d05316e9dedc59dd90a92fb3f513d4)) + +## [6.5.0](https://github.com/cloud-pi-native/console/compare/v6.4.0...v6.5.0) (2024-01-25) + + +### Features + +* :children_crossing: better ux for forms in tiles ([da68243](https://github.com/cloud-pi-native/console/commit/da68243a91865732db46b9ae610a82eeec5c9bf2)) +* :loud_sound: add payload and full hook response in validation hooks ([adabb02](https://github.com/cloud-pi-native/console/commit/adabb02fd0b8028b1fcdf1113744234a925d72ce)) +* :loud_sound: add reqId in db logs ([2b7f22b](https://github.com/cloud-pi-native/console/commit/2b7f22b7e24748dc37c8623bbda3e914ed7cae00)) +* :sparkles: add project environments to hook payload ([7aa8117](https://github.com/cloud-pi-native/console/commit/7aa81179ac95afdddf711e1a0a4b3c3a45a35c5a)) +* :sparkles: disallow hyphen on project, environment and stage ([f9b05ee](https://github.com/cloud-pi-native/console/commit/f9b05eefee41f7d514d749a0ac3fa1c1d063f051)) +* :sparkles: download projects data button for admins ([cb38e75](https://github.com/cloud-pi-native/console/commit/cb38e7573314ba0980c4161d1a77a9e83fddb236)) +* :sparkles: to fn, more infos, multi tiles ([8c4de83](https://github.com/cloud-pi-native/console/commit/8c4de83cf3b4bc0e335292d437ddf7d22305ff93)) + + +### Bug Fixes + +* :goal_net: validate schema before creating repo or env ([23535c3](https://github.com/cloud-pi-native/console/commit/23535c3e0c9923df0cc8adb5fb646dc2dd21fb7c)) +* :pushpin: pin axios version to avoid vite error ([88c6d30](https://github.com/cloud-pi-native/console/commit/88c6d30b5ab14cd9ca899fdf7cfcd17faa067903)) + +## [6.4.0](https://github.com/cloud-pi-native/console/compare/v6.3.1...v6.4.0) (2023-12-20) + + +### Features + +* :children_crossing: add filtering on admin projects view ([dc5412c](https://github.com/cloud-pi-native/console/commit/dc5412c1a80ce84cd563376a0b7f72da7926a858)) +* :sparkles: display cluster infos to users on environment form ([4afdf34](https://github.com/cloud-pi-native/console/commit/4afdf34fc40df7789a00fe6d159ee73370b94cb9)) +* :speech_balloon: translate stage in front for better understanding ([5a66479](https://github.com/cloud-pi-native/console/commit/5a6647980319524098a72fcbd2be6affbda3f3d0)) + + +### Bug Fixes + +* :art: refactor of admin project types and refs ([be0684f](https://github.com/cloud-pi-native/console/commit/be0684f378696a24304d7abecd6b90c0738e1785)) + +## [6.3.1](https://github.com/cloud-pi-native/console/compare/v6.3.0...v6.3.1) (2023-12-12) + + +### Bug Fixes + +* :bug: missed limits ([f2ba02d](https://github.com/cloud-pi-native/console/commit/f2ba02d41e923b0add154fc7cb5ae7d8a2c89370)) + +## [6.3.0](https://github.com/cloud-pi-native/console/compare/v6.2.0...v6.3.0) (2023-12-11) + + +### Features + +* :sparkles: add backoffice for admins ([fadfdfc](https://github.com/cloud-pi-native/console/commit/fadfdfc43c427261adc3e31dbb1907d6dcefc5cb)) + + +### Bug Fixes + +* :bug: fix gitlab group deletion ([12a35d0](https://github.com/cloud-pi-native/console/commit/12a35d08619fbeb231d4e7931fb7fdb51ef63fde)) + +## [6.2.0](https://github.com/cloud-pi-native/console/compare/v6.1.0...v6.2.0) (2023-11-20) + + +### Features + +* :sparkles: delete cluster if no environment suscribed ([8edc50a](https://github.com/cloud-pi-native/console/commit/8edc50a85f266f1135b340d6df39a48ea7ac746b)) + + +### Bug Fixes + +* :alien: add owner in archiveProject hookpayload ([5c99183](https://github.com/cloud-pi-native/console/commit/5c99183f03afe0c20980c9649ce37788b833a351)) +* :bug: use argoCD regex for internalRepoName ([6c2ed3f](https://github.com/cloud-pi-native/console/commit/6c2ed3f682514b947e66547ce136d148b9e2d686)) + +## [6.1.0](https://github.com/cloud-pi-native/console/compare/v6.0.0...v6.1.0) (2023-11-13) + + +### Features + +* :sparkles: add quota and stage management for admin ([80ce642](https://github.com/cloud-pi-native/console/commit/80ce642b69a5eaaf1b53c7d884b70889c4e71d9d)) +* :sparkles: add stage management for admin ([e133853](https://github.com/cloud-pi-native/console/commit/e13385347610014a4edcc0b2ff12252386328cb8)) + + +### Bug Fixes + +* :bug: add missing await in gitlab createProjectMirror ([f1bf907](https://github.com/cloud-pi-native/console/commit/f1bf9072b88abec3f08654720f64571aa421db34)) +* :bug: fix endless redirect on login if authenticated ([461c6bd](https://github.com/cloud-pi-native/console/commit/461c6bd385f44a551fe9af1027c6beca1e7fa9ec)) +* :bug: should not set caData if skipTLSVerify is true ([ccd66f8](https://github.com/cloud-pi-native/console/commit/ccd66f83be75850ffe47e87ff405fcf9cf136e3c)) +* :lock: root token is not used in new repo credential when synced ([c92ea8b](https://github.com/cloud-pi-native/console/commit/c92ea8b5ef95097b07c2bbda435ce433f5bba53d)) + +## [6.0.0](https://github.com/cloud-pi-native/console/compare/v5.11.0...v6.0.0) (2023-10-27) + + +### ⚠ BREAKING CHANGES + +* :boom: environment management + +### Features + +* :boom: environment management ([b4f3793](https://github.com/cloud-pi-native/console/commit/b4f379315803aee1264192c16851af049fa661ee)) +* :sparkles: services secrets improvement ([40e3ddd](https://github.com/cloud-pi-native/console/commit/40e3ddd2acd7e000b03f5bfa5663533449d315bf)) + + +### Bug Fixes + +* :alien: change logo for svg ([aa8315b](https://github.com/cloud-pi-native/console/commit/aa8315b63617c8413a448499e2ab0e68c216faef)) +* :bug: add conditional chaining to avoid error in dsobadge ([56ad040](https://github.com/cloud-pi-native/console/commit/56ad0403242222f50ef8abe898b46c241d40b66d)) +* :bug: launch e2e test in open mode without turbo ([d3497d1](https://github.com/cloud-pi-native/console/commit/d3497d13e3862d7701cbbd2da65cb7a571032eb2)) +* :card_file_box: set all project status to failed for v6 release ([b689c35](https://github.com/cloud-pi-native/console/commit/b689c356f27e769b18aabab2f2510dd24354d95f)) + +## [5.11.0](https://github.com/cloud-pi-native/console/compare/v5.10.1...v5.11.0) (2023-10-13) + + +### Features + +* :children_crossing: add default branch to sync in mirror pipeline ([45dbc01](https://github.com/cloud-pi-native/console/commit/45dbc01c4a95d04a201b2c5e198e3b192e1e5fe2)) + + +### Bug Fixes + +* :goal_net: add try catch in checkApi func ([baf38af](https://github.com/cloud-pi-native/console/commit/baf38af3f96fe227a934228d7875181e1407c8e9)) + +## [5.10.1](https://github.com/cloud-pi-native/console/compare/v5.10.0...v5.10.1) (2023-10-10) + +### Bug Fixes + +* :ambulance: send internalRepoName to plugins on updateRepository hook ([2bde004](https://github.com/cloud-pi-native/console/commit/2bde0045592f2898975633c0c6f959311234f7f3)) + +## [5.10.0](https://github.com/cloud-pi-native/console/compare/v5.9.1...v5.10.0) (2023-10-03) + +### Features + +* :iphone: more responsive sideMenu and permissionForm ([c840306](https://github.com/cloud-pi-native/console/commit/c8403067224b5ad90e34b8233a1830ceeec8b715)) +* :sparkles: enable admin cluster update ([f81008e](https://github.com/cloud-pi-native/console/commit/f81008e97c25bc73466b6257bd4cb2dc0bde9163)) + +## [5.9.1](https://github.com/cloud-pi-native/console/compare/v5.9.0...v5.9.1) (2023-09-28) + +### Bug Fixes + +* :bug: fix not updated argocd cluster and not using insecure in config ([69e7a9a](https://github.com/cloud-pi-native/console/commit/69e7a9ac7a584d76ce047b8330356ce2387218d7)) + +## [5.9.0](https://github.com/cloud-pi-native/console/compare/v5.8.2...v5.9.0) (2023-09-25) + +### Features + +* :technologist: add swagger for server api ([abe15d2](https://github.com/cloud-pi-native/console/commit/abe15d2ceb93039ecd795389156f8730e5327841)) + +### Bug Fixes + +* :ambulance: register cluster destination via kubeconfig ([0e8d310](https://github.com/cloud-pi-native/console/commit/0e8d3102267268c595d172808e30183dbe4b3aad)) +* :safety_vest: validate user schema before trying to insert it in db ([ab8e6ef](https://github.com/cloud-pi-native/console/commit/ab8e6efdb00ec29c6a9a1e0f89c58ffbc999c966)) + +## [5.8.2](https://github.com/cloud-pi-native/console/compare/v5.8.1...v5.8.2) (2023-09-20) + +### Bug Fixes + +* :wrench: handle no values for disabledPlugins ([d0d46a3](https://github.com/cloud-pi-native/console/commit/d0d46a38570db6f626f5879da180829216826fa8)) + +## [5.8.1](https://github.com/cloud-pi-native/console/compare/v5.8.0...v5.8.1) (2023-09-18) + +### Bug Fixes + +* :ambulance: bad logic register plugins ([049040e](https://github.com/cloud-pi-native/console/commit/049040eafda26214afbc2118cb56542b133feca5)) + +## [5.8.0](https://github.com/cloud-pi-native/console/compare/v5.7.0...v5.8.0) (2023-09-18) + +### Features + +* :passport_control: update harbor project member role ([4419dd5](https://github.com/cloud-pi-native/console/commit/4419dd5f395f79a9eb99e8c0f305a7433ad84f7e)) +* :sparkles: add infos key for cluster ([25ab8b8](https://github.com/cloud-pi-native/console/commit/25ab8b8d821c37f4491bd8fea075bdc7fbfcfc93)) +* :sparkles: add tlsServerName input in cluster form ([4bdc01e](https://github.com/cloud-pi-native/console/commit/4bdc01ef9dd5b2041a34491e46b9759e5c6652f0)) +* :technologist: add images auto-scan to harbor settings ([507c6e6](https://github.com/cloud-pi-native/console/commit/507c6e6a541b19502084276db30cf834e586d955)) + +### Bug Fixes + +* :alien: fetch does not support proxy natively ([20d531d](https://github.com/cloud-pi-native/console/commit/20d531d622e6fe9e2869e13db0d205b44ad602ce)) +* :art: can disble plugins by name, and some fixes ([f2bd397](https://github.com/cloud-pi-native/console/commit/f2bd397e0526d76d439ef8f38b5dcebdfbaa8ede)) +* :bug: clusters related bugs ([c2ba63e](https://github.com/cloud-pi-native/console/commit/c2ba63e94d87bdf1741e9a51e4522c3f77b354de)) +* :bug: now the admin can chosse skip tls api kube ([e09aa2a](https://github.com/cloud-pi-native/console/commit/e09aa2ac659e1439f59050c56256f530b9f8cae6)) +* :goal_net: throw error if catched in business ([1790ec2](https://github.com/cloud-pi-native/console/commit/1790ec274e965d5d62a0693937eec5ee765602e2)) +* :heavy_plus_sign: fix pnpm-lock file ([93aa64e](https://github.com/cloud-pi-native/console/commit/93aa64e146e11f30c8305f85d7ff03b696b0297b)) + +### Reverts + +* :poop: comment harbor projet member role update ([63926ae](https://github.com/cloud-pi-native/console/commit/63926ae9c5a5fe6b9e6e8a2d0007f55d186b0f36)) + +## [5.7.0](https://github.com/cloud-pi-native/console/compare/v5.6.0...v5.7.0) (2023-09-07) + +### Features + +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :safety_vest: update password character support ([c82f35f](https://github.com/cloud-pi-native/console/commit/c82f35fb7f20ad06c0a63eb518407d17ddb88b51)) +* :sparkles: handle gitlab membership ([7ff68bd](https://github.com/cloud-pi-native/console/commit/7ff68bdf8f9a16562cfc00fbdb0c322b0f09943d)) + +### Bug Fixes + +* :bug: change appproject policy ([e20b6c4](https://github.com/cloud-pi-native/console/commit/e20b6c431348b08bed7ea215a967c51d0e6187f0)) +* :bug: import new shared package ([ff0b0db](https://github.com/cloud-pi-native/console/commit/ff0b0db9dcc96c10b409b51bd4180c03b5b01478)) + +### Reverts + +* :card_file_box: add missing default for projectStatus ([2263988](https://github.com/cloud-pi-native/console/commit/22639888442da48b097f936d0afb62652d262b02)) +* :card_file_box: add missing default for projectStatus ([5653165](https://github.com/cloud-pi-native/console/commit/565316504a1baa90d2c7c999c0ba6810151324f5)) + +## [5.6.0](https://github.com/cloud-pi-native/console/compare/v5.5.0...v5.6.0) (2023-08-28) + +### Features + +* :sparkles: better integration of sonarqube ([c0a0517](https://github.com/cloud-pi-native/console/commit/c0a05173a158b74d752aafaccdff58fc3b29c382)) + +### Bug Fixes + +* :ambulance: preserve status colum in project ([febfaff](https://github.com/cloud-pi-native/console/commit/febfaff702d6a55210723ce213d603ad059ceaed)) +* :bug: optional paramater to call repeatFn ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: pnpm lock ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: run.sh js to ts reference ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :lipstick: fix padding ([907d424](https://github.com/cloud-pi-native/console/commit/907d424a94f2dfc7e7110882438130bdb5dc4cd8)) +* :lipstick: restyle logs interface ([fc362cf](https://github.com/cloud-pi-native/console/commit/fc362cf461c65f6e4e8e7b6b2d92c83a41844bdf)) + +### Performance Improvements + +* :construction: skip failing test for merge ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) + +## [5.5.0](https://github.com/cloud-pi-native/console/compare/v5.4.0...v5.5.0) (2023-08-22) + +### Features + +* :children_crossing: inform users that clipboard works only with https ([909c3b6](https://github.com/cloud-pi-native/console/commit/909c3b672bcae1401d2133c8fff94cdca91f61bc)) +* :sparkles: retrieve user from kc if does not exist in db ([3d4461a](https://github.com/cloud-pi-native/console/commit/3d4461ae7d82e6f64a1644921893d89f41e9a2d9)) + +### Bug Fixes + +* :alien: sync controllers require bigger axios timeout ([5116bbd](https://github.com/cloud-pi-native/console/commit/5116bbdb8f49b73c920ac04421ca05ccabef902c)) +* :bento: update MultiSelector component ([9a44bb3](https://github.com/cloud-pi-native/console/commit/9a44bb35d8aa0da1aaf8f96a745c28f97f6ea53c)) +* :bug: filter archived project from cluster form ([50913f5](https://github.com/cloud-pi-native/console/commit/50913f57def7d8e3e36f63a5876e476e64884828)) +* :bug: fix types, add void and handle it ([110cfed](https://github.com/cloud-pi-native/console/commit/110cfedfcb2a58757b5ad47f96f6323cbe034550)) +* :bug: manage cancel emit on update repo form ([16df7b4](https://github.com/cloud-pi-native/console/commit/16df7b43d584fb1edfa5a01938356b04b785429b)) +* :bug: retrieve kc user bug fix ([aaf61c5](https://github.com/cloud-pi-native/console/commit/aaf61c51a288cd7998a5d3883cf467471b42c3cb)) +* :bug: various fixes around plugins and controllers ([6271c67](https://github.com/cloud-pi-native/console/commit/6271c67f3d8471ee6afc3409c5d753f7ade950f0)) +* :goal_net: allow deleting environment if not found ([a2dc7da](https://github.com/cloud-pi-native/console/commit/a2dc7daf96942121e9291063569ff9c6612bd345)) +* :lipstick: add icons ([ed1887b](https://github.com/cloud-pi-native/console/commit/ed1887baf6a65d0d58c6e399991a1419351c3883)) +* :rewind: fix bugs introduced during my vacations ([9ee467c](https://github.com/cloud-pi-native/console/commit/9ee467c69b590a4630dae19f42f2fc5c9321953e)) +* :technologist: fix import kubeconfig in integration ([51af3dd](https://github.com/cloud-pi-native/console/commit/51af3dd16128a6d4a1e676921937005e295acfc9)) + +## [5.4.0](https://github.com/cloud-pi-native/console/compare/v5.3.0...v5.4.0) (2023-08-02) + +### Features + +* :children_crossing: improve home page and update doc link ([038d982](https://github.com/cloud-pi-native/console/commit/038d982ead7bbfa70debdf902d5c46e8518c1e60)) +* :sparkles: add or remove a team member via keycloak ([0563d20](https://github.com/cloud-pi-native/console/commit/0563d20ca93a25decc7dbb5d945e2b738b0d575a)) + +### Bug Fixes + +* :technologist: change domains for dev and int ([0de00ab](https://github.com/cloud-pi-native/console/commit/0de00abf78543bc769f80be2f0e59795cfefefa5)) + +## [5.3.0](https://github.com/cloud-pi-native/console/compare/v5.2.1...v5.3.0) (2023-08-01) + +### Features + +* :alien: synch controllers with plugin calls ([d0a271d](https://github.com/cloud-pi-native/console/commit/d0a271d188840d8682b6abe412ee8da4db7992b1)) +* :children_crossing: add confirm box for updating an organization ([5c3cd20](https://github.com/cloud-pi-native/console/commit/5c3cd207221a22ed5d93bdf8638b23c9849897f6)) +* :sparkles: lock and unlock projects according to their org status ([3b9444a](https://github.com/cloud-pi-native/console/commit/3b9444aeaa2fab76461946e9a4d00e8afe4a62f1)) + +### Bug Fixes + +* :bug: create appropriate folders for server image ([3027fef](https://github.com/cloud-pi-native/console/commit/3027fef08ebc8a58e2e705645febaf40af7314f5)) +* :bug: fix missing extensions ([4cd4b21](https://github.com/cloud-pi-native/console/commit/4cd4b212e88d30b447d5674cf157ce71862fe14a)) +* :bug: pin pnpm version in dockerfiles ([b494a2f](https://github.com/cloud-pi-native/console/commit/b494a2f0d190e3da81f9765dded76187333e61af)) + +## [5.2.1](https://github.com/cloud-pi-native/console/compare/v5.2.0...v5.2.1) (2023-07-21) + +### Bug Fixes + +* :adhesive_bandage: update kaniko stage name in gitlab ci ([7a71712](https://github.com/cloud-pi-native/console/commit/7a71712075d7aed5aeeb00adb1d2ec51b6d717ab)) + +## [5.2.0](https://github.com/cloud-pi-native/console/compare/v5.1.0...v5.2.0) (2023-07-20) + +### Features + +* :children_crossing: improve gitlab mirroring by using a single mirror repo ([914bbe4](https://github.com/cloud-pi-native/console/commit/914bbe4546a3e876a6cd8847523f18e95d688d44)) +* :sparkles: add id in admin user table ([d1ebcea](https://github.com/cloud-pi-native/console/commit/d1ebcea1768993b6ab31e5f45a6642ac3577cfc5)) + +### Bug Fixes + +* :bug: add missing data to add argo cluster ([fc53f54](https://github.com/cloud-pi-native/console/commit/fc53f54d8f2d211857aa82c43589b9ee26e214b8)) +* :bug: delete kube resources when deleting a project ([5e8cd51](https://github.com/cloud-pi-native/console/commit/5e8cd514edc8a0eb4f5d49208cb17b6da82f4fd7)) +* :bug: fix argo plugin ([b1dc1fb](https://github.com/cloud-pi-native/console/commit/b1dc1fb60ba89b534c3e3cd357b89a4e08b74838)) + +## [5.1.0](https://github.com/cloud-pi-native/console/compare/v5.0.0...v5.1.0) (2023-07-18) + +### Features + +* :alien: add project infos for canel plugin ([a4516a1](https://github.com/cloud-pi-native/console/commit/a4516a14b2439dd3a74540168aee6daa586c7308)) +* :alien: add updateProject hook for description update for example ([7e97505](https://github.com/cloud-pi-native/console/commit/7e97505b5a75c3f6c680d5b1bb6f18a9e45b87b3)) + +### Bug Fixes + +* :bug: fix projectLimit for gitlab plugin ([13fca89](https://github.com/cloud-pi-native/console/commit/13fca89f37a16b980dce7f16e87151510d6f0b63)) +* :bug: missing await before plugins hook and logs recording on archiveProject ([e221341](https://github.com/cloud-pi-native/console/commit/e221341a7db27d67bfa40e4231d47de1e5e8648f)) +* :bug: missing await before plugins hook and logs recording on archiveProject ([2515c62](https://github.com/cloud-pi-native/console/commit/2515c624afd1d89718a98085c5e41c1c996157b8)) + +## [5.0.0](https://github.com/cloud-pi-native/console/compare/v4.1.0...v5.0.0) (2023-07-13) + +### ⚠ BREAKING CHANGES + +* :boom: migrate from sequelize to prisma + +### Features + +* :children_crossing: add organization to differenciate project ([5814d7e](https://github.com/cloud-pi-native/console/commit/5814d7e00b5a6a335a16059b62f7af456d3b4a58)) +* :sparkles: allow updating repo ([27b59cf](https://github.com/cloud-pi-native/console/commit/27b59cfc5cacdf0e96b0fd22b090ce4ab038792d)) +* :sparkles: always show ci form ([753d64b](https://github.com/cloud-pi-native/console/commit/753d64bc011701dcc9297097b1a0aec87cef6c03)) +* :tada: introduce multi-cluster deployment ([0e6c065](https://github.com/cloud-pi-native/console/commit/0e6c065d421c117050e00944ca68114414a05dc7)) +* :technologist: add config for codespace ([1ff7996](https://github.com/cloud-pi-native/console/commit/1ff79966454be7961ba80049be65815cfce1d4cf)) + +### Bug Fixes + +* :alien: change gitlab api calls ([f04dc87](https://github.com/cloud-pi-native/console/commit/f04dc87208ca19ee2b06ff2c35535e51a72f84cd)) +* :art: finish pr ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :art: quote fixes ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :bug: add module on shared ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :bug: fix imports, change dependencies ([41f427f](https://github.com/cloud-pi-native/console/commit/41f427fe17daef7ef1b3c330ec5635338edfda59)) +* :bug: fix logs pagination bug ([7f3a990](https://github.com/cloud-pi-native/console/commit/7f3a99085f701d02367fab04f194dc0c0e85356d)) +* :bug: forgot await in argo plugin ([f4504d4](https://github.com/cloud-pi-native/console/commit/f4504d4b1ccc999274c3267508c943f23fd8690b)) +* :bug: retrieve also inactive orgs on admin project list page ([4b8977e](https://github.com/cloud-pi-native/console/commit/4b8977e01214295f3090ab16a92bde4b7d7a64f9)) +* :construction_worker: fix CI: tsconfig is needed ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :construction: in progress ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :lipstick: more contrasted icon color for archived project ([790d276](https://github.com/cloud-pi-native/console/commit/790d2761d3f77756144172e3ccd80bcb0df4572d)) + +### Code Refactoring + +* :boom: migrate from sequelize to prisma ([37b065d](https://github.com/cloud-pi-native/console/commit/37b065d6cff844dfd1b9d2276831eddefa64a71e)) + +## [4.1.0](https://github.com/cloud-pi-native/console/compare/v4.0.0...v4.1.0) (2023-06-12) + +### Features + +* :sparkles: add admin log page ([41058ee](https://github.com/cloud-pi-native/console/commit/41058ee51899d4ec351ae7adad10eb3fefa99f8b)) + +### Bug Fixes + +* :bug: harbor, fix remove repos from unknown project ([8a18381](https://github.com/cloud-pi-native/console/commit/8a18381c2197634d90649a8520dd068a59e5369c)) +* :bug: test if external dir exists ([3276400](https://github.com/cloud-pi-native/console/commit/3276400a00b3435eb87e225e0e8deadd5a311b0d)) +* :zap: gitlab, stabilize user search and lifecycle ([179c912](https://github.com/cloud-pi-native/console/commit/179c91269ecf04ff6a489a5d92f490b940446e70)) + +## [4.0.0](https://github.com/dnum-mi/dso-console/compare/v3.4.1...v4.0.0) (2023-06-08) + +### ⚠ BREAKING CHANGES + +* :boom: clarify business logic on status and locking + +### Features + +* :alien: add hook to synchronize organizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :alien: add hook to synchronize organizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :alien: add keys to source organizations ([626c031](https://github.com/dnum-mi/dso-console/commit/626c03135571199c3a1f1e72e19132bc651288a3)) +* :boom: if status: failed (aka plugins failed), project remains locked ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) +* :children_crossing: improve user experience on dso-console ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :children_crossing: improve ux for danger zone ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :children_crossing: improve UX on permission form ([fb72076](https://github.com/dnum-mi/dso-console/commit/fb72076f16f79df456392fc7f9a7129ab1655832)) +* :children_crossing: redirect to dashboard when selecting a project ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :necktie: allow only one plugin to register on hook fetchOrganizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :sparkles: add project admin view ([b49ff96](https://github.com/dnum-mi/dso-console/commit/b49ff967853e31ca6cbcd8cd2ff78b299023788e)) + +### Bug Fixes + +* :art: temp fix labels, add check, refacto get ns ([a43f50c](https://github.com/dnum-mi/dso-console/commit/a43f50cb93b2453f025b9d4284f2b65b6134161a)) +* :bug: fix payload, fix check step ([ff76b27](https://github.com/dnum-mi/dso-console/commit/ff76b2750352cbe7b2d49ff5fb6e4d3e424b328f)) +* :bug: remove harbor repos before projects ([7239329](https://github.com/dnum-mi/dso-console/commit/72393297dcfaf44938e2814cccfd8366613ba75c)) +* :necktie: clarify business logic on status and locking ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) + +### Code Refactoring + +* :boom: clarify business logic on status and locking ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) + +## [3.4.1](https://github.com/dnum-mi/dso-console/compare/v3.4.0...v3.4.1) (2023-05-22) + +### Bug Fixes + +* :art: change ns labels ([ae14d67](https://github.com/dnum-mi/dso-console/commit/ae14d6736a3b3276b3406562709cf77e3aab0668)) +* :construction: hooks payload send full owner object, bug getSingleOwnerByProjectId ([0e74895](https://github.com/dnum-mi/dso-console/commit/0e74895695b42d4e13a645ef4005afbffec59b64)) + +## [3.4.0](https://github.com/dnum-mi/dso-console/compare/v3.3.1...v3.4.0) (2023-05-15) + +### Features + +* :alien: owner name is required to create Canel user ([e4aa5e9](https://github.com/dnum-mi/dso-console/commit/e4aa5e9662fb0494ef43b7f52140c9c11702613f)) + +## [3.3.1](https://github.com/dnum-mi/dso-console/compare/v3.3.0...v3.3.1) (2023-05-11) + +### Bug Fixes + +* :ambulance: keycloak now sends group path ([913f402](https://github.com/dnum-mi/dso-console/commit/913f40295f7f0eab17964e72e38e61200523456e)) + +## [3.3.0](https://github.com/dnum-mi/dso-console/compare/v3.2.3...v3.3.0) (2023-05-11) + +### Features + +* :sparkles: add organization page for admins ([5e6b7fa](https://github.com/dnum-mi/dso-console/commit/5e6b7fa252285b432ef5869aedf8881b642eff53)) + +### Bug Fixes + +* :ambulance: handle unocss migration for media queries ([8fdf445](https://github.com/dnum-mi/dso-console/commit/8fdf4455a34a274c3723685928549bb30bb04762)) +* :sparkles: set ingress secretName ([8be3479](https://github.com/dnum-mi/dso-console/commit/8be34796b15219a6b4f77585662b21d8d04c76c8)) + +## [3.2.3](https://github.com/dnum-mi/dso-console/compare/v3.2.2...v3.2.3) (2023-05-09) + +### Bug Fixes + +* :ambulance: keycloak group id, typo ([1aedf77](https://github.com/dnum-mi/dso-console/commit/1aedf77b4a74698bb1d52cbfe6863bd249a7eadb)) + +## [3.2.2](https://github.com/dnum-mi/dso-console/compare/v3.2.1...v3.2.2) (2023-05-09) + +### Bug Fixes + +* :ambulance: missing ownerid at env init ([14696ff](https://github.com/dnum-mi/dso-console/commit/14696ffc24a2bfe5345b135e99f48502ba0d2fae)) + +## [3.2.1](https://github.com/dnum-mi/dso-console/compare/v3.2.0...v3.2.1) (2023-05-09) + +### Bug Fixes + +* :ambulance: habor missing slash on oidc group ([e85b903](https://github.com/dnum-mi/dso-console/commit/e85b9031f77c7feba05f04b9444c5d6862342246)) +* :ambulance: keycloak do not add owner in group env ([f20b3da](https://github.com/dnum-mi/dso-console/commit/f20b3da4a8666d3b2912c3b7147819818a96c8ed)) + +## [3.2.0](https://github.com/dnum-mi/dso-console/compare/v3.1.0...v3.2.0) (2023-05-05) + +### Features + +* :lipstick: better ui for permission form ([56e0739](https://github.com/dnum-mi/dso-console/commit/56e0739d935d96c4eedd55807151f72f8fafcb6b)) +* :sparkles: add description field for a project ([e9d65ed](https://github.com/dnum-mi/dso-console/commit/e9d65eda3084ac46b48f8e7a4a7e78324cf8d351)) +* :sparkles: add download link for includes.zip ([0e067d1](https://github.com/dnum-mi/dso-console/commit/0e067d1965e39084d3f21191e27aa3b82948df17)) +* :sparkles: introduce users admin view ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) + +### Bug Fixes + +* :alien: refacto plugins to check services before save in database ([c58f3da](https://github.com/dnum-mi/dso-console/commit/c58f3dab057fd39f62e20ca3951278a87f6cfad0)) +* :bug: forget default value for log table ([ade449b](https://github.com/dnum-mi/dso-console/commit/ade449b0a834f850d4f64c94cfb489174b946463)) +* :construction: fix e2e test admin (temporary) ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) +* :construction: use ingress ([621d5ee](https://github.com/dnum-mi/dso-console/commit/621d5eee80adfb5f14459b93c7f51b431dd61c31)) +* :fire: remove routes ([65dabcb](https://github.com/dnum-mi/dso-console/commit/65dabcb28f8ede298975a2cec3e7ff5623ee4d8c)) +* :loud_sound: log database sync error ([3e73dac](https://github.com/dnum-mi/dso-console/commit/3e73dac0fc470051c5aaf410ebbbf28cafc9ee0e)) +* :wrench: update helm values ([16d3223](https://github.com/dnum-mi/dso-console/commit/16d32233c84001b8aa206a76b8ac3b772875cb64)) + +### Performance Improvements + +* :art: admin users no custom exception ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) + +## [3.1.0](https://github.com/dnum-mi/dso-console/compare/v3.0.0...v3.1.0) (2023-04-18) + +### Features + +* :children_crossing: add deployment infos below environment tiles ([992b34e](https://github.com/dnum-mi/dso-console/commit/992b34e070401ae330cab239d73bf27b56ba516c)) +* :sparkles: add custom ca to server ([8583577](https://github.com/dnum-mi/dso-console/commit/85835775d1bf21dfadffe5094dc1a513d6782525)) +* :sparkles: introduce environment management page ([3e77a67](https://github.com/dnum-mi/dso-console/commit/3e77a679446853b981d9bf98f1bd62795ce5cf1d)) + +### Bug Fixes + +* :bug: fix git output url ([e3402b4](https://github.com/dnum-mi/dso-console/commit/e3402b44dddbd71805293305df181321af04dafe)) +* :bug: forget delete mirror repository secret ([f00aac7](https://github.com/dnum-mi/dso-console/commit/f00aac7f6315a1f27ee05ebaa6863feb466ac654)) + +## [3.0.0](https://github.com/dnum-mi/dso-console/compare/v2.3.0...v3.0.0) (2023-04-14) + +### ⚠ BREAKING CHANGES + +* **api:** :building_construction: replace ansible with plugins + +### Features + +* :art: load kubeconfig only if if a path is set ([a9d9338](https://github.com/dnum-mi/dso-console/commit/a9d93387cc7ff407ea71edb848e664fb651fc09a)) +* :bento: add Marianne police ([c5af057](https://github.com/dnum-mi/dso-console/commit/c5af05774332d97fc5b669aeaa60f9fca6075998)) +* :boom: change helm deployment ([c1368c6](https://github.com/dnum-mi/dso-console/commit/c1368c69de93357c66709110f3c57d22a62af8ad)) +* :lipstick: add dark theme ([cfc16d8](https://github.com/dnum-mi/dso-console/commit/cfc16d81391c28447d02297a9ebdf22c1f142c6f)) +* :lipstick: add dark theme ([cfc16d8](https://github.com/dnum-mi/dso-console/commit/cfc16d81391c28447d02297a9ebdf22c1f142c6f)) +* :loud_sound: add a short description for database logging ([4146f5a](https://github.com/dnum-mi/dso-console/commit/4146f5ae7048fa280a654b5471a32968af159b2f)) +* :technologist: do not drop database in integration dev ([521d503](https://github.com/dnum-mi/dso-console/commit/521d503cdf5e2c24f9e148bc6435db485e1252e7)) + +### Bug Fixes + +* :ambulance: add kaniko proxy ([1564b6f](https://github.com/dnum-mi/dso-console/commit/1564b6f7a696c13846799b38557b1003d2cb87c8)) +* :ambulance: stabilize gitlab search and vault secret name ([044da15](https://github.com/dnum-mi/dso-console/commit/044da1511ff8a5f16136807c70194d662c08db26)) +* :bug: display hyphen only if service message exists ([ba5ed65](https://github.com/dnum-mi/dso-console/commit/ba5ed659954235f8b00e78df24029f1186d23b7a)) +* :bug: fix controllers logic ([251fba4](https://github.com/dnum-mi/dso-console/commit/251fba44ebd93e645487da63710add4d9382521d)) +* :bug: use proxy on services healthcheck ([85eef14](https://github.com/dnum-mi/dso-console/commit/85eef144f8c87760c61203de7944229af2828047)) +* :lipstick: make snackbar bakground -color adaptive to theme variations ([851a004](https://github.com/dnum-mi/dso-console/commit/851a004837eb901d02bbf65e20697e457ecde6e2)) +* :wrench: rename kubeconfig var ([61fdfbf](https://github.com/dnum-mi/dso-console/commit/61fdfbf3bb47b51225d2cf3c00b167c46ff1bedf)) + +### Code Refactoring + +* **api:** :building_construction: replace ansible with plugins ([59cd5fe](https://github.com/dnum-mi/dso-console/commit/59cd5fef0799ff3dd5df7ec42ef879647a6de78a)) + +## [2.3.0](https://github.com/dnum-mi/dso-console/compare/v2.2.1...v2.3.0) (2023-03-22) + +### Features + +* :alien: fetch services to get their statuses ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) +* :art: review tobi ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) +* :monocle_face: sort projects by ASC name ([ccb7fbb](https://github.com/dnum-mi/dso-console/commit/ccb7fbb5066da6ebc1776a893c86d8d80a292856)) +* :sparkles: build project services url in api ([2173f8a](https://github.com/dnum-mi/dso-console/commit/2173f8abade9848c83cc68e4a68da02982d4a887)) +* :sparkles: new page dedicated to services ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) + +### Bug Fixes + +* :bug: fix container env ([f9fd457](https://github.com/dnum-mi/dso-console/commit/f9fd45715d94789e02d8c88991b02fe9831e4460)) + +## [2.2.1](https://github.com/dnum-mi/dso-console/compare/v2.2.0...v2.2.1) (2023-03-14) + +### Bug Fixes + +* :bug: token should authorize "-" ([277f292](https://github.com/dnum-mi/dso-console/commit/277f29232fd5713c0a6e5c42f29ba257b71cdb75)) + +### Performance Improvements + +* :zap: no need to call /account endpoint ([3b278f7](https://github.com/dnum-mi/dso-console/commit/3b278f7e8a2fc22cf3a9aec8b1319c83a934a7f6)) + +## [2.2.0](https://github.com/dnum-mi/dso-console/compare/v2.1.1...v2.2.0) (2023-03-13) + +### Features + +* :sparkles: display project and repositories statuses in dashboard and repo pages ([ba27167](https://github.com/dnum-mi/dso-console/commit/ba2716798e8d39e5470b6fe0e97acc3a54480612)) + +### Bug Fixes + +* :bug: add joi schema validation in repo form ([8524e50](https://github.com/dnum-mi/dso-console/commit/8524e50cca3a97965b6cc2eb104620aec2d3e5dd)) +* :bug: do nothing when tile is disabled ([e09043c](https://github.com/dnum-mi/dso-console/commit/e09043c34a21315abd8c0c3d1570488057a3d1a6)) +* :bug: wrong key for project archived ([8e6b27b](https://github.com/dnum-mi/dso-console/commit/8e6b27b576e7ad28bde1b8a00b84acf366531993)) + +### Performance Improvements + +* :fire: no external token in database, no crypto needed ([0a3fb8c](https://github.com/dnum-mi/dso-console/commit/0a3fb8c5403215e1ae3d24fb8ea7cfcf43a8060f)) + +## [2.1.1](https://github.com/dnum-mi/dso-console/compare/v2.1.0...v2.1.1) (2023-02-27) + +### Bug Fixes + +* :globe_with_meridians: translate error messages in french ([a2827c3](https://github.com/dnum-mi/dso-console/commit/a2827c3746aad94293de183725bd2e77ac34f525)) + +## [2.1.0](https://github.com/dnum-mi/dso-console/compare/v2.0.0...v2.1.0) (2023-02-27) + +### Features + +* :children_crossing: redirect to services when selecting a project ([3849dad](https://github.com/dnum-mi/dso-console/commit/3849dad734e07ae0250962c8a71c1eb3470614c2)) +* :lipstick: whoami in sidemenu rather than snackbar ([70fcbf5](https://github.com/dnum-mi/dso-console/commit/70fcbf537fa32a04538d10673a1d0b4246fddd65)) +* :memo: ajout d'un schéma d'architecture ([72f61fb](https://github.com/dnum-mi/dso-console/commit/72f61fbf6060455f56d578003e927196c0292a50)) +* :necktie: add constraint for project name length ([f2d3975](https://github.com/dnum-mi/dso-console/commit/f2d3975f8cf5124142edc38a64f219ac9a223985)) +* :sparkles: add snackbar to handle errors ([0b5fa71](https://github.com/dnum-mi/dso-console/commit/0b5fa71b5d993fb7529f4fa2b74d78dacb0b33d3)) +* add active column on organization table, and client get only active: true ([de7b9d0](https://github.com/dnum-mi/dso-console/commit/de7b9d01bf878ee1cf298736e83410905390a5a3)) + +### Bug Fixes + +* :ambulance: bad owner w/ new queries ([5153b62](https://github.com/dnum-mi/dso-console/commit/5153b621c0707a438f191dd2b06d8c801804f014)) +* :ambulance: log more of ansible output ([e2b10d6](https://github.com/dnum-mi/dso-console/commit/e2b10d6875605c836606cb5ba79c963787d4c177)) +* :bug: error in ansible route ([a470677](https://github.com/dnum-mi/dso-console/commit/a470677a5d5c3b2b262434adc3f399f948caccd2)) +* :bug: error in ansible route ([1565411](https://github.com/dnum-mi/dso-console/commit/1565411524a2c17d0e7de79e5965562053a2024b)) +* :goal_net: catch fetch errors and send it to front ([fcca40b](https://github.com/dnum-mi/dso-console/commit/fcca40b2efbc5d38ae8fe0001043671756ebda34)) +* :goal_net: optional chaining to avoid errors on selected project ([6750077](https://github.com/dnum-mi/dso-console/commit/67500771939efb2e2329d8ab94407588aa76fa5b)) +* :goal_net: return if user does not have project ([f2a3d74](https://github.com/dnum-mi/dso-console/commit/f2a3d7449abc729b5506aaaf43a163e600c51bcb)) +* :lipstick: enforce ui on disabled dsfrtiles ([4de5d25](https://github.com/dnum-mi/dso-console/commit/4de5d257d847c4ecff137face3cbb276d543b779)) +* :truck: rename generated gitlab ci file ([d3e637e](https://github.com/dnum-mi/dso-console/commit/d3e637e04ef008c5316b7e720a9fc905c0ab0000)) +* :wrench: update postgres image ([16e5d55](https://github.com/dnum-mi/dso-console/commit/16e5d557c52e149def7f7c216371ba23fc721bbf)) + +### Reverts + +* :rewind: rebase error ([728a704](https://github.com/dnum-mi/dso-console/commit/728a704522a3f5cfcc2dc5879671a83a051a82c6)) + +## [2.0.0](https://github.com/dnum-mi/dso-console/compare/v1.0.0...v2.0.0) (2023-02-22) + +### ⚠ BREAKING CHANGES + +* **database:** :card_file_box: new database model, queries, + +### Features + +* :art: easier generation of files object ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :art: specific routes, add validation schema ([69645b9](https://github.com/dnum-mi/dso-console/commit/69645b9b392f8f56b8d3606780dfa63098fa5240)) +* :art: use only one get route ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: mob review ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :card_file_box: add an association table for users and projects ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: add destroy functions for db ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: wip : working with @ArnaudTa on liveshare ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: add delete repo button ([ad64daf](https://github.com/dnum-mi/dso-console/commit/ad64dafce222ea572e6169c502aaed08562a70e7)) +* :construction: Add download and copy, missing format ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: add status and locked for project ([1bb55b8](https://github.com/dnum-mi/dso-console/commit/1bb55b8758eecced9204aa7b77f404ac6a3d45b0)) +* :construction: adding api implementation ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :construction: all controllers writtend ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: applying review requested changes ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: commit à ecraser - wip fs file generator ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: feature to achieve later, keep asking for project status ([57846f9](https://github.com/dnum-mi/dso-console/commit/57846f923f15771186b8a7816324df42d55d86cb)) +* :construction: first database model ([69f51f2](https://github.com/dnum-mi/dso-console/commit/69f51f2eb16b052d4fddbb909ac7a5f0913b2c94)) +* :construction: new project controller ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: remove done todos ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: uncomment path for delete repo and projects ([8fb7cd9](https://github.com/dnum-mi/dso-console/commit/8fb7cd9b5c67857862cebfde091a3b5bbba54c93)) +* :construction: wip - ci files generated by server ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: wip downloading file ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: wip fix unit tests ([31c3a86](https://github.com/dnum-mi/dso-console/commit/31c3a863104f553adcefb5e78d64613e32f65704)) +* :lipstick: fix snackbar on bottom left ([7823c99](https://github.com/dnum-mi/dso-console/commit/7823c991b4adf62e380193d7f1d9fc33108dc368)) +* :lock: add crypto module to encrypt externalToken or other data if needed ([c5806b3](https://github.com/dnum-mi/dso-console/commit/c5806b392ec2b203f0d54a62527c9d96570f2918)) +* :necktie: cannot update nor delete permission if not permitted - controller side ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :necktie: disable delete permission for owner ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :necktie: permissions should be enabled only for permitted users ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :passport_control: organization controller ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :sparkles: add archiveProject btn ([e412223](https://github.com/dnum-mi/dso-console/commit/e41222346a4e3d9d60100d59b4edf160a2319c08)) +* :sparkles: add backend function to generate random passwords ([5244597](https://github.com/dnum-mi/dso-console/commit/52445972b7be13862d395059f82fae81b7efa495)) +* :sparkles: add delete playbook call in deleteRepo controller ([8abe695](https://github.com/dnum-mi/dso-console/commit/8abe6950e8f26aa01b7b1a95e4fbc4627f26d925)) +* :sparkles: add delete trigger for environment ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :sparkles: add environment and permissions management ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :sparkles: add environment page ([a5a5129](https://github.com/dnum-mi/dso-console/commit/a5a5129e89cb9594204b08c9cc76c3a30bb04eb7)) +* :sparkles: Add GitLab ci generation in repoForm ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: Add gitlabCi form for repo ([839f1e1](https://github.com/dnum-mi/dso-console/commit/839f1e18bf99f1bef53ed3ab39a64ab097c6a0d8)) +* :sparkles: add GitlabCI generated file ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: add helm charts ([e540325](https://github.com/dnum-mi/dso-console/commit/e5403252cd7d888c839be63758c15c1fdce76c71)) +* :sparkles: add log table in model ([642b245](https://github.com/dnum-mi/dso-console/commit/642b245a4a0ffa8877c900fa786345fd484f60f8)) +* :sparkles: add misc routes, healthz/version ([520976d](https://github.com/dnum-mi/dso-console/commit/520976ddfebba2d05385ba66d27af809e8736287)) +* :sparkles: ajout d'un système de role propre à la console avec authentification admin ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :sparkles: ansible-api respond playbook rc, server stores it in db ([7774045](https://github.com/dnum-mi/dso-console/commit/7774045ae6404e1f0de810bb09a7832c0292baba)) +* :sparkles: call delete repo and archive project playbooks ([97a7b13](https://github.com/dnum-mi/dso-console/commit/97a7b1300847fafa5053d58fb05079ef52a9d041)) +* :sparkles: ciForm ok ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: display current user name in closeable alert ([349ade9](https://github.com/dnum-mi/dso-console/commit/349ade9f5b8017bf206aff2c894a56cc675d6873)) +* :sparkles: get user's group from keycloak ([8311622](https://github.com/dnum-mi/dso-console/commit/83116226e7a3e172a94b4f988d037ec1dcc8aaf4)) +* :sparkles: record ansible responses in database ([5753883](https://github.com/dnum-mi/dso-console/commit/5753883ae32f36e28a128f396df3f9669e5a854b)) +* :sparkles: wip components for environment managing ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :test_tube: ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: rewrite random utils for tests ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* **Helm:** :art: add env per container ([48b41cf](https://github.com/dnum-mi/dso-console/commit/48b41cfea037ce90812197990c54d2726fb75ac7)) +* **Helm:** :art: variabilize server imports ([0973646](https://github.com/dnum-mi/dso-console/commit/09736463d3011f96348f94cf3ad8cc69c9c0f07e)) + +### Bug Fixes + +* :alembic: changement du keycloak flow ([1af0d85](https://github.com/dnum-mi/dso-console/commit/1af0d8504766f3f0ebab92feff8f4795e4e1c67e)) +* :art: variabilize pvc name ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: add await on start server functions and fix tests ([c8e66f8](https://github.com/dnum-mi/dso-console/commit/c8e66f87f4ccd4be1775b32729f12d9c0e919f35)) +* :bug: add refresh keycloak token ([b758e44](https://github.com/dnum-mi/dso-console/commit/b758e44241c868a1e8a1546271f1806069370964)) +* :bug: bind do not always works ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: change create project and change message logs ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: controller response before ansible fetch ([8983c45](https://github.com/dnum-mi/dso-console/commit/8983c457cd8de96d117d51881668b37c3b4f9407)) +* :bug: delete default argo repo at deleting ([5fafe99](https://github.com/dnum-mi/dso-console/commit/5fafe9919b7d08447e356eb260795a6cb91f3ba3)) +* :bug: fix projectAddUserController ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: flatMap didn't work as expected ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: forgot to load repositories data ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: import env ([98c1632](https://github.com/dnum-mi/dso-console/commit/98c16329e8fbd3b0bcabf2ac132d53931ef7f9bf)) +* :bug: init db doit être séquentiel pour ne pas violer l'intégrité des tables ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: isInfra & isPrivate false if undefined ([2fa987d](https://github.com/dnum-mi/dso-console/commit/2fa987d0872f6884627d6060c64d27d932a2db5c)) +* :bug: isInfra not send to ansible ([b049746](https://github.com/dnum-mi/dso-console/commit/b049746b97e5529d6f5368e045eb96a266bb432b)) +* :bug: mode dégradé si payload n'est pas bon ([14309cf](https://github.com/dnum-mi/dso-console/commit/14309cf41835f59168c72b6a4840fcf1ae8264d2)) +* :bug: repositories may be empty ([584798f](https://github.com/dnum-mi/dso-console/commit/584798f1ebba5894b2f9181cbe0f9bdbd49cdd6c)) +* :bug: review changes ([5e4bdb7](https://github.com/dnum-mi/dso-console/commit/5e4bdb73414102728e68d56fe30d65a7f718a0f7)) +* :bug: send proper data to ansible ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: send200 if code is OK ([08ca7eb](https://github.com/dnum-mi/dso-console/commit/08ca7ebdf0b24dabc60baa631c4887854780465c)) +* :bug: show permissionForm only when environment is created ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: stabilize playbookDir in different execution execution ([69645b9](https://github.com/dnum-mi/dso-console/commit/69645b9b392f8f56b8d3606780dfa63098fa5240)) +* :bug: update ansible calls ([04ee9ff](https://github.com/dnum-mi/dso-console/commit/04ee9fff7e78bbe4305ad7a1923df842a80a49ad)) +* :bug: update orgName generation to new db schema ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: use flatMap to iterate over two arrays ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: wip bug ansible repositoryInitializing ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: wip working on todo's ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: working on todo's ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: organization should be a FK in projects ([d014387](https://github.com/dnum-mi/dso-console/commit/d014387c8d09182675ddf98d381e27b78bc28545)) +* :card_file_box: turn envList into array of objects ([41f0278](https://github.com/dnum-mi/dso-console/commit/41f027858f108fb056958c118bf664a141e7623c)) +* :construction_worker: Copy test-utils in client docker ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: trying to log details for ansible api call ([ae944ae](https://github.com/dnum-mi/dso-console/commit/ae944aea77d0f13798c63e9bf9690363f75e9bc9)) +* :construction: wip ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :fire: remove bad log and comms ([748c43d](https://github.com/dnum-mi/dso-console/commit/748c43d93212a79b2dd04e527f97da5dd5d585b5)) +* :goal_net: add front error management for createProject ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :goal_net: catch errors in front when calling back ([5331773](https://github.com/dnum-mi/dso-console/commit/53317734961895bf348c758774d23c2505f812a8)) +* :lipstick: update UI for new DSFR version ([62750c4](https://github.com/dnum-mi/dso-console/commit/62750c478ecac453542e5933d1464af2a752e2d4)) +* :lock: filter users sensitive infos ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :lock: improve security using node utility function to compare hashes ([ea0e730](https://github.com/dnum-mi/dso-console/commit/ea0e73084a04c85d69e8d556e73241982df5d40f)) +* :loud_sound: mute logs for health route ([b1eaad5](https://github.com/dnum-mi/dso-console/commit/b1eaad5701ac73b131b0cb8692111ae5fd0a8d84)) +* :mute: remove sequelize logs to preserve data ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :necktie: permission level goes from 0 to 3, 0 by default, 3 for owner ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :pencil2: typo on var name ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :recycle: change where directive to through associations ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :recycle: fix ([8d2c911](https://github.com/dnum-mi/dso-console/commit/8d2c911b855f19b0a6568e2857129c9c62fcfcc9)) +* :recycle: use sequelize querytypes ([d45472a](https://github.com/dnum-mi/dso-console/commit/d45472a89a5be2a74c1af2da14d41ceb5d118284)) +* :rotating_light: Sonar warnings ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :rotating_light: SonarQube warning ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: fix TU ([26bd4f1](https://github.com/dnum-mi/dso-console/commit/26bd4f1477bd3664472ca4d8989630f09c2a7e0b)) +* :test_tube: use dedicated en var for ct tests ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: wip fix repo ansible fetch failed ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :truck: fix generateCIFiles feature ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :truck: rename folder for generating files ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) + +### Performance Improvements + +* :art: use map instead of forEach for array generation ([03c78fa](https://github.com/dnum-mi/dso-console/commit/03c78fa5f28bb86fad7e3e1b142a1c71d1639054)) +* :stethoscope: add health probes in templates ([d645f2e](https://github.com/dnum-mi/dso-console/commit/d645f2e35460ba92a67aeb0ea65ac9356ed36381)) + +### Reverts + +* :egg: hide environments and permissions feature waiting for playbooks ([edebee9](https://github.com/dnum-mi/dso-console/commit/edebee944905f5723518aaca0c37a51deeac80a6)) +* :rewind: hide snackbar for now ([6afe9e8](https://github.com/dnum-mi/dso-console/commit/6afe9e81bc5b32cb544456d7e697be66ea0349be)) + +### Code Refactoring + +* **database:** :card_file_box: new database model, queries, ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) + +## 1.0.0 (2022-12-16) + +### Features + +* :alembic: clone in entrypoint ([f97edb0](https://github.com/dnum-mi/dso-console/commit/f97edb07c31036cb1fc1882610e2f78185106028)) +* :art: baudoin's review ([656aac6](https://github.com/dnum-mi/dso-console/commit/656aac693a4c21a64402dd9bcd8b978f2fb63b55)) +* :art: introduce CLONE_DIR, where to clone ([263ea79](https://github.com/dnum-mi/dso-console/commit/263ea79deef5d9f58e2fc10ffcf98e31a4b41bc2)) +* :art: spread instead of push ([530b366](https://github.com/dnum-mi/dso-console/commit/530b366368b8eb1a2f2597362ecc2c5aa2bc1145)) +* :bento: Add images for services logos in public folder ([6d8e91c](https://github.com/dnum-mi/dso-console/commit/6d8e91ca9837dd60f603d5ed364e11fb3485a5b5)) +* :bento: Definitive logos for services ([e2e6270](https://github.com/dnum-mi/dso-console/commit/e2e627081224433e0d190876a8791aa1d2ea8d6b)) +* :boom: first query successfull ([61a7cd5](https://github.com/dnum-mi/dso-console/commit/61a7cd5857a6e6c648219d20399c19e8672a37db)) +* :boom: first query successfull ([36c973e](https://github.com/dnum-mi/dso-console/commit/36c973ebaa9f3a52669d0f921eedfb8aee6c4a9f)) +* :bricks: change routes ([af30bd8](https://github.com/dnum-mi/dso-console/commit/af30bd895bef8203b3e06facfa04d93be83b86f9)) +* :card_file_box: Add api getProjectById ([98cd231](https://github.com/dnum-mi/dso-console/commit/98cd23165e72add49762bc31b81c54526f42b768)) +* :card_file_box: Add api getProjectById ([98976d0](https://github.com/dnum-mi/dso-console/commit/98976d0a33cf65ee3be4baad629dae107410c6e3)) +* :card_file_box: Add createProject query ([9bcfb05](https://github.com/dnum-mi/dso-console/commit/9bcfb059d2c3c51c64ee223b777da5d999339e44)) +* :card_file_box: Add owner from keycloak in project ([5aa9aad](https://github.com/dnum-mi/dso-console/commit/5aa9aad9f12b5c5060139baebd5b2869ea91b1f4)) +* :card_file_box: Add owner from keycloak in project ([cadb474](https://github.com/dnum-mi/dso-console/commit/cadb4745648ecdfc0ffbbc045ca4a054ee062615)) +* :card_file_box: Add postgres and pgadmin ([76374e6](https://github.com/dnum-mi/dso-console/commit/76374e6acd72ba89ab15bdf4c7e1ff82f00e110d)) +* :card_file_box: Add user id in project queries ([48949df](https://github.com/dnum-mi/dso-console/commit/48949df562179493e2b524b541129ff520913d86)) +* :card_file_box: Create database & table on server start if not exists ([14b9f6b](https://github.com/dnum-mi/dso-console/commit/14b9f6b5359e33bd345eccb1be081698fbda8b59)) +* :card_file_box: Wip add createProject api ([1414bc0](https://github.com/dnum-mi/dso-console/commit/1414bc0ed59f63fd41f21dcc76dcebebd1a5c4cf)) +* :card_file_box: Wip app and server js ([2d4efdf](https://github.com/dnum-mi/dso-console/commit/2d4efdf91f98a82fcd18c3887c1943a283c653c8)) +* :construction: (wip) add project mongoose model ([76e8d77](https://github.com/dnum-mi/dso-console/commit/76e8d77a3342ff58707b47977a8cdd14a721339d)) +* :construction: (wip) trying to connect to keycloak with server ([2401fda](https://github.com/dnum-mi/dso-console/commit/2401fdaa383a38632e95d1097e4a3766ef7f6563)) +* :construction: Add check in addRepo and addUser queries ([c2bad66](https://github.com/dnum-mi/dso-console/commit/c2bad663b9aba71dffd9e023cbd098a38353ee0d)) +* :construction: Add DsfrTable for users ([8b63e84](https://github.com/dnum-mi/dso-console/commit/8b63e84bd6923f5128efbd427e659418794f7eaf)) +* :construction: Add DsoTeam table ([ca3e2c3](https://github.com/dnum-mi/dso-console/commit/ca3e2c3462d9e17d90e10afed4432b24735ded24)) +* :construction: Add final todos ([c224592](https://github.com/dnum-mi/dso-console/commit/c2245927e6a2b61cd3bf4fbef4fddf5708ccd20f)) +* :construction: Add OrderProject ([f4d2962](https://github.com/dnum-mi/dso-console/commit/f4d2962ec7b76a13c7f9849302f3a0279e79a442)) +* :construction: Add postgres and pgadmin ([a7ce8c5](https://github.com/dnum-mi/dso-console/commit/a7ce8c5ff9aee0003f7d2066a6f2e172b9d5bd08)) +* :construction: Add postgres and pgadmin ([6fdf560](https://github.com/dnum-mi/dso-console/commit/6fdf56045839636324015739852ee85d5685d584)) +* :construction: Add removeUserFromProject function ([9430fc6](https://github.com/dnum-mi/dso-console/commit/9430fc697e9761c3e79f0d37bef16dcf52f1e889)) +* :construction: Add routes for projects ([cd14601](https://github.com/dnum-mi/dso-console/commit/cd146017e1a6646777451e0ed711b9d6556e5393)) +* :construction: Add SideMenu to app ([3373f8c](https://github.com/dnum-mi/dso-console/commit/3373f8cf18607293f1081172ed93e8dabd69fd28)) +* :construction: Configure keycloak for frontend ([d8074ac](https://github.com/dnum-mi/dso-console/commit/d8074ac468a562322a8d44ed5d08b26be56cc7e8)) +* :construction: Failing specs projects ([a56cb8c](https://github.com/dnum-mi/dso-console/commit/a56cb8cb0685e3173b867519d09f4ca41b217959)) +* :construction: Func removeUserFromProject ([95fdc7f](https://github.com/dnum-mi/dso-console/commit/95fdc7f8cd8285bf18257364f833b9eb84588cf1)) +* :construction: Handle menu collapse in sm mode ([5237e89](https://github.com/dnum-mi/dso-console/commit/5237e892f86f5c4abd170562867a4745e2121bb9)) +* :construction: trying to access server /api/v-4/version ([a27483c](https://github.com/dnum-mi/dso-console/commit/a27483c6525091eca564b9ad615636edf79c5283)) +* :construction: trying to access server /api/v1/version ([2e43c8f](https://github.com/dnum-mi/dso-console/commit/2e43c8fd0c13f59bd40567912815a605c43bf0dc)) +* :construction: wip add node-postgres ([50ef526](https://github.com/dnum-mi/dso-console/commit/50ef526f2e647d5a16254468e9e83996e71baab1)) +* :construction: wip business rules orderProject ([24f7daa](https://github.com/dnum-mi/dso-console/commit/24f7daaae5cba5d7fdb6ee1ebc700c9aba1a0830)) +* :construction: wip pg ([391bccb](https://github.com/dnum-mi/dso-console/commit/391bccbbf34c9a365eed847824edb718643a8d73)) +* :construction: working on api x keycloak ([183f4d8](https://github.com/dnum-mi/dso-console/commit/183f4d8c9e49407f500444210f2f143dcf1433c3)) +* :hammer: Add turbo ([a3fdc96](https://github.com/dnum-mi/dso-console/commit/a3fdc96e23fbd3c66dea95ce89829dbe873b835a)) +* :hammer: Update docker compose scripts for pnpm ([537ba52](https://github.com/dnum-mi/dso-console/commit/537ba52f8c75659a8204beb5e48fa2ff11572be2)) +* :heavy_plus_sign: Add axios for api management ([591b012](https://github.com/dnum-mi/dso-console/commit/591b012df344e0842ce3848eaae4aa0c3beceb73)) +* :heavy_plus_sign: Add fastify keycloak adapter ([575e8d7](https://github.com/dnum-mi/dso-console/commit/575e8d71aa5d4fe9a0f2da94c484de2867ca5c9a)) +* :lipstick: Resize sidemenu and content ([61670d8](https://github.com/dnum-mi/dso-console/commit/61670d816aa3b70aa164e3d8531b12b3f835fcc6)) +* :lipstick: thiner projects subheader ([4427945](https://github.com/dnum-mi/dso-console/commit/442794546e8977dc3298a5ec92e68a30ef3775eb)) +* :lipstick: Update navigation in frontend ([5751ed3](https://github.com/dnum-mi/dso-console/commit/5751ed36dbdbeceb832c9bcc0c9972fca2abd26c)) +* :lock: Add keycloak token check on ansible api ([afe18f5](https://github.com/dnum-mi/dso-console/commit/afe18f58dc7c041adb40b1dee7617cb6b38ef9f5)) +* :loud_sound: forward request id to ansible api ([b6107bb](https://github.com/dnum-mi/dso-console/commit/b6107bbc322d6740bfbe7345e5865af6df4cb219)) +* :memo: Add documentation inside application ([0d70a0e](https://github.com/dnum-mi/dso-console/commit/0d70a0e8c6b4248e427e63d987dce7498f0c64ee)) +* :necktie: Add removeuser func ([3da7701](https://github.com/dnum-mi/dso-console/commit/3da77019f350b4dee5031ffbf5a97735299b0df9)) +* :necktie: users[].email must be unique ([d74b99d](https://github.com/dnum-mi/dso-console/commit/d74b99d642cdb3dea3fcd18116f644aa34542dbf)) +* :poop: dinindex turbo ([d7e78cb](https://github.com/dnum-mi/dso-console/commit/d7e78cb1e15c7008f761af42961bdc2754b117d0)) +* :poop: Pass current location to redirectUri ([ee65e42](https://github.com/dnum-mi/dso-console/commit/ee65e42daaff2fb20c4a9142a5ba87357e415088)) +* :poop: test 2 ([5c3b1da](https://github.com/dnum-mi/dso-console/commit/5c3b1da4c4d95ce3a9294c175f4826e174ae6436)) +* :poop: try to remove .tubro/ ([01ba3ef](https://github.com/dnum-mi/dso-console/commit/01ba3efaa8efacbb97638c5e89aebd3255149d2b)) +* :poop: turbo unindexation test ([2e7ba79](https://github.com/dnum-mi/dso-console/commit/2e7ba79848aae2fc174e364de867ff59159ed39c)) +* :recycle: Baudoin's review, userProfile in store ([c1a34b4](https://github.com/dnum-mi/dso-console/commit/c1a34b4c2c48455874829966d220b0b3137a010c)) +* :recycle: Harmonize api's names ([71ed577](https://github.com/dnum-mi/dso-console/commit/71ed577474dbe431cc1ebdb83336510ac93c58c8)) +* :recycle: Merge conf and init in a single keycloak file ([e777b02](https://github.com/dnum-mi/dso-console/commit/e777b028ce94df3288ab03bc6bdeea2ba2408fde)) +* :recycle: service url construction ([6b25ab3](https://github.com/dnum-mi/dso-console/commit/6b25ab3d40449da2b50ecd12604c6030aa0b287d)) +* :see_no_evil: Ignore .tubro ([d927f05](https://github.com/dnum-mi/dso-console/commit/d927f0559ecff8d3805709d9eca0487786295b52)) +* :sparkles: Add allTiles for all services, adapt projectTiles to selectedProject ([d207065](https://github.com/dnum-mi/dso-console/commit/d207065a628e26293f441a38ae9dd17950188d7c)) +* :sparkles: Add DsoService content ([024cbd0](https://github.com/dnum-mi/dso-console/commit/024cbd0c50475413a2a41097e301c88c87885363)) +* :sparkles: Add envList to project ([02e5215](https://github.com/dnum-mi/dso-console/commit/02e5215ec25a9dc7decd6c7d4d26c68a3a1b5fa5)) +* :sparkles: Add infra repo checkbox ([b0c0602](https://github.com/dnum-mi/dso-console/commit/b0c06020ea9f2aa1580514aa9498aec38cb3824b)) +* :sparkles: Add sidemenu and tmp routes ([be02a0c](https://github.com/dnum-mi/dso-console/commit/be02a0c2184b8ff92b13745efdfdda5626d91ec6)) +* :sparkles: Add specific endpoints to console api ([a35b9cc](https://github.com/dnum-mi/dso-console/commit/a35b9ccf452db0db438990633eded6b8d5ae0be7)) +* :sparkles: Add user to project ([85d5a1c](https://github.com/dnum-mi/dso-console/commit/85d5a1c98f9374ba9c7fb9b4f179bb8f40a6b0f3)) +* :sparkles: Add users[i].id in query ([35a36b4](https://github.com/dnum-mi/dso-console/commit/35a36b437d224b3d4b490a7c0ff2fdfe42f80410)) +* :sparkles: Add vault url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :sparkles: Add windicss to project ([8db02f5](https://github.com/dnum-mi/dso-console/commit/8db02f531f6f4dbc768ae726597b957cb8cb8e3a)) +* :sparkles: Create projects view ([aa0d833](https://github.com/dnum-mi/dso-console/commit/aa0d83356ab679261893cb563237062cdbbc04e6)) +* :sparkles: Finish v1 orderProject form ([590eb74](https://github.com/dnum-mi/dso-console/commit/590eb744f9daab7b9e34e6e2e9a376257f8290da)) +* :sparkles: Prepare ansible call for project provisioning ([a3d0918](https://github.com/dnum-mi/dso-console/commit/a3d09185837675f7954dad3185651ca300373bb1)) +* :sparkles: Separate ansible wrapper in a new api called ansible-api ([bcd64db](https://github.com/dnum-mi/dso-console/commit/bcd64dbdfa3c043bcf7c77f783ce1d36e43e4d05)) +* :speech_balloon: Correct services url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :speech_balloon: Update app's title ([869e2a3](https://github.com/dnum-mi/dso-console/commit/869e2a34455f1adb80d52e7fc5c4bc4c884368dd)) +* :speech_balloon: Update services url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :technologist: Add husky unit tests for server ([75e3a23](https://github.com/dnum-mi/dso-console/commit/75e3a23199ba15de9229809558a2b2569e38bdd4)) +* :technologist: Add watch src for hot reload ([e4e129d](https://github.com/dnum-mi/dso-console/commit/e4e129db4803f3bc5ed5a1ee3fc759ebcef38d89)) +* :truck: Better images for logos ([8ce9112](https://github.com/dnum-mi/dso-console/commit/8ce911280348e49d9f197dc9947d0d7d5bf5cee4)) +* :truck: Create folder projects in views, create store project ([a1df0f6](https://github.com/dnum-mi/dso-console/commit/a1df0f6bfcdbdba96d9b8c1c77e8500074f59512)) +* login on keycloack TEST ([20ab492](https://github.com/dnum-mi/dso-console/commit/20ab492a78f5e3e81a3295a3416a4422422d4244)) +* page login ([0c3946b](https://github.com/dnum-mi/dso-console/commit/0c3946b0ff04889b8b0fcf7b5160fea4409b3e8d)) +* **projectInit:** :building_construction: Add client folder ([9f3e90d](https://github.com/dnum-mi/dso-console/commit/9f3e90d305aebc6d9508d4570ffc0d1fac9502f2)) +* **projectInit:** :heavy_plus_sign: Add Stylelint ([21f33e0](https://github.com/dnum-mi/dso-console/commit/21f33e0554b04932a35ca723934c24bd93122513)) +* **projectInit:** :tada: Vite vue3 basic installation ([41aeb82](https://github.com/dnum-mi/dso-console/commit/41aeb824d02ba387bc8c842b07351fd55b221782)) + +### Bug Fixes + +* :alembic: edit code to run with new ansible directories ([8822d95](https://github.com/dnum-mi/dso-console/commit/8822d953e93db6f4ddc00383e7ad4a8c01e8cbaf)) +* :alembic: edit server image, kubeconfig fix for ansible ([5e02c5e](https://github.com/dnum-mi/dso-console/commit/5e02c5ef97a5fc67cfd30d296b36bf339fff8251)) +* :alembic: Try to fix keycloak redirect ([954bc51](https://github.com/dnum-mi/dso-console/commit/954bc513be0ff9a1e46b140af334273f710b9ec2)) +* :ambulance: fix a commit written too late in the night ([ee0cc99](https://github.com/dnum-mi/dso-console/commit/ee0cc99fab9a243ffddcc5ed874cbcd449d319f4)) +* :art: add env for keycloak in FRONT ([dea4e38](https://github.com/dnum-mi/dso-console/commit/dea4e38b36d9bb672f4f9e1079e9540d0e93d5af)) +* :art: rename file user in stores ([86be41e](https://github.com/dnum-mi/dso-console/commit/86be41e2ee7d6c8d0a0baad9507fe46fb8a527b4)) +* :bug: Add case sensitivity for init deb script ([699dedd](https://github.com/dnum-mi/dso-console/commit/699dedd5c8b149d8de83b632184fdb28b15784fc)) +* :bug: Add tagName (type) so it's closeable ([8aa65d5](https://github.com/dnum-mi/dso-console/commit/8aa65d53d03641ae30b0f989874f4acb85889b3f)) +* :bug: duplicated line ([2dad819](https://github.com/dnum-mi/dso-console/commit/2dad819540e76852251ef3ac8b7c3618abec0c71)) +* :bug: Finish removeUser feature ([25a4f36](https://github.com/dnum-mi/dso-console/commit/25a4f363ca99b3dd5b9a9adf6cf940021c509a5e)) +* :bug: Fix after dsfr's upgrade ([9273618](https://github.com/dnum-mi/dso-console/commit/92736182d04f6a965b15d0514a8c18e329ebec7a)) +* :bug: Fix bugs revealed by e2e tests ([f9ff1fc](https://github.com/dnum-mi/dso-console/commit/f9ff1fc8c633b894864489cb7b8c51eaf733e012)) +* :bug: Fix db init if not exists ([d3a7b77](https://github.com/dnum-mi/dso-console/commit/d3a7b779fb00399a94e6db570583bed77e4fc355)) +* :bug: Fix endless redirection ([7762a83](https://github.com/dnum-mi/dso-console/commit/7762a8358b6a76879e41ac6ea5a29765ec36a2e1)) +* :bug: Fix init db error ([9b50685](https://github.com/dnum-mi/dso-console/commit/9b5068519d7d26126c9730d32b375cb9e9243e92)) +* :bug: Fix multiples bugs ([fc94d99](https://github.com/dnum-mi/dso-console/commit/fc94d992ee0495113f7e5f4cd165f90b2460dd9d)) +* :bug: Fix potentital bugs ([ec04eee](https://github.com/dnum-mi/dso-console/commit/ec04eeeeb745be5e4a4ab5ad8a1c60e08cb74168)) +* :bug: Handle cases where no users ([47befb9](https://github.com/dnum-mi/dso-console/commit/47befb9e0dcfa07e733596aa5ccd9323e7447ad6)) +* :bug: Handle cases where users key does not exist in project ([98381dc](https://github.com/dnum-mi/dso-console/commit/98381dc3a746993c563b0b817cd6e985c40d4cc6)) +* :bug: Improve backend & fix in adequation of front changes ([a4df195](https://github.com/dnum-mi/dso-console/commit/a4df19533cd2e634149c549e5a717bf7b5bd1280)) +* :bug: kc logout ([c3621b9](https://github.com/dnum-mi/dso-console/commit/c3621b9f4db92b1a73a21b53d3894d79f2bdeb1d)) +* :bug: Last reviews ([4f39f8a](https://github.com/dnum-mi/dso-console/commit/4f39f8aea216aee29ff1128a7c5b60174e98b489)) +* :bug: multiple fixes, increase some log verbosity ([39e543b](https://github.com/dnum-mi/dso-console/commit/39e543bc8071d8323af96e85a6f6c8b7beffce19)) +* :bug: Reinit projects in store before pushing query result ([53d6c3f](https://github.com/dnum-mi/dso-console/commit/53d6c3f94e1211a69034c580abc6bd2731d7e717)) +* :bug: remove extra properties, it failed tests ([98abead](https://github.com/dnum-mi/dso-console/commit/98abeadf4ad5aad0eaab3f706500e27c98444ce3)) +* :bug: Remove unintentionaly added import ([8c6cab6](https://github.com/dnum-mi/dso-console/commit/8c6cab6fa05b57639003ea1e514da973d79fad3b)) +* :bug: Review tobi, an array even empty is always true ([9fe7068](https://github.com/dnum-mi/dso-console/commit/9fe706894c930fa5f2d187c0ce1500d75a797e29)) +* :bug: update for production ready ([4e80d61](https://github.com/dnum-mi/dso-console/commit/4e80d61fd3f9ec0526d99b3f3fec65a4ddfa78b0)) +* :coffin: Remove test class ([38a39ed](https://github.com/dnum-mi/dso-console/commit/38a39ed849fdcc7f62bdb24223f5cd74407e5aa9)) +* :coffin: Remove unused function ([b557bdc](https://github.com/dnum-mi/dso-console/commit/b557bdcb5b8fcb927ffc30cf9e4c0830d35b5363)) +* :construction_worker: Remove prod flag for dev stage in server dockerfile ([3e28a00](https://github.com/dnum-mi/dso-console/commit/3e28a00d1a652d46fcb0072471090ee2ad0fb899)) +* :construction: broken install in test yml ([1121d8b](https://github.com/dnum-mi/dso-console/commit/1121d8b7f90fc301a303c8705a039e1175d55eae)) +* :construction: Fix init keycloak in frontend ([0b41546](https://github.com/dnum-mi/dso-console/commit/0b41546b355e3583dd02fc1793c24c30af860030)) +* :construction: Wip reinit newUser object ([699a0bb](https://github.com/dnum-mi/dso-console/commit/699a0bbf8600efed4d1bf62a07d02f78c0e1c38c)) +* :construction: working on keyclock + pinia ([95d5822](https://github.com/dnum-mi/dso-console/commit/95d5822b23277b24a87f6f6d6c8ca77fc6a49926)) +* :fire: Remove useless silentchecksso & move cypress/vue to optional dep ([3b85f7a](https://github.com/dnum-mi/dso-console/commit/3b85f7a7f792d6fcb0b7f9bf8b94fe82b02ed577)) +* :globe_with_meridians: All english for dso-console ([6d45cba](https://github.com/dnum-mi/dso-console/commit/6d45cba12a6540dd13a933f6940b91701dbdab34)) +* :green_heart: Fix e2e tests in ci & improve code structure ([0857f70](https://github.com/dnum-mi/dso-console/commit/0857f703f0445cd49940d3337c4d94c9af4c70ea)) +* :lock: add binding in sql request to avoid sql injection ([46f5d04](https://github.com/dnum-mi/dso-console/commit/46f5d048e9df384c8f116bfdd8c9b7005160b5bf)) +* :loud_sound: Add logs for investigation ([b8f4867](https://github.com/dnum-mi/dso-console/commit/b8f48679f350ef34e856925fc9ff42ecfa1f281a)) +* :memo: Last review ([f37ad55](https://github.com/dnum-mi/dso-console/commit/f37ad5549d4e863cc78ed970c0525b4ed421b7f1)) +* :package: add jmespath lib ([5fcdd41](https://github.com/dnum-mi/dso-console/commit/5fcdd41c74ca678ce8cdfa79e5626d4eaeaba14c)) +* :pencil2: Typo in js ([ef99847](https://github.com/dnum-mi/dso-console/commit/ef99847908ce21cd28bddc8787101d9448c1976d)) +* :recycle: Refacto brainstorm thibault ([4e24417](https://github.com/dnum-mi/dso-console/commit/4e24417f7ac7a09dd57aa93f19d174ff679dcf74)) +* :rewind: Readd vitest config in shared ([2ec8b14](https://github.com/dnum-mi/dso-console/commit/2ec8b149a8d47136ac8305359688679e1eac1316)) +* :rewind: Rebase bug fix ([dbfaa2b](https://github.com/dnum-mi/dso-console/commit/dbfaa2b4732f51d6e1de0f731c55fa25518cc91c)) +* :rotating_light: Warn in console for missing required id ([db26c58](https://github.com/dnum-mi/dso-console/commit/db26c587106f1be4bf4765a0ea207adde71bb0ff)) +* :technologist: Add env exemple ([64fec78](https://github.com/dnum-mi/dso-console/commit/64fec783624dd4e3a625dbdd16f0bf9736adcc0d)) +* :test_tube: (to remove) failing tests skipped ([aeaded1](https://github.com/dnum-mi/dso-console/commit/aeaded1671f5de18d9e613fa32fda162a6147a27)) +* :white_check_mark: Correct api call in repo e2e test ([64d5196](https://github.com/dnum-mi/dso-console/commit/64d51968bad97f50f36a1619916b7c2b82378078)) +* :white_check_mark: Fix e2e tests on repo ([de0c0a5](https://github.com/dnum-mi/dso-console/commit/de0c0a59e02b639a67066c28b9532a5d38e12e98)) +* :zap: remove ansible legacy from server ([68c9dc9](https://github.com/dnum-mi/dso-console/commit/68c9dc9100aff3e71ccdab18608f61f9420e720f)) +* conf keycloak for cors from localhost:8080 ([1e06ff0](https://github.com/dnum-mi/dso-console/commit/1e06ff033094024d4e1a4f66bab5443b4505247c)) +* login on keycloack TEST ([c983eed](https://github.com/dnum-mi/dso-console/commit/c983eedccdba49d024ba6e5726f69afd33e577a1)) +* **projectInit:** :bricks: Add ports in vite config ([5de6e86](https://github.com/dnum-mi/dso-console/commit/5de6e86e02343ff910023681960e3bf7a788becd)) +* **projectInit:** :bug: Add context in docker-compose ([3ac3a72](https://github.com/dnum-mi/dso-console/commit/3ac3a7297c1b81fdb93ed099605ceeb2044ad617)) +* **projectInit:** :hammer: Fix run test script for client ([272f007](https://github.com/dnum-mi/dso-console/commit/272f007803d35e2113646d57b715e84249fa65bf)) +* remove double code in dockerfile ([2951672](https://github.com/dnum-mi/dso-console/commit/29516725acf793168854296f6f35088ef5b3447c)) +* rename and add volumes for keycloak ([3d0d999](https://github.com/dnum-mi/dso-console/commit/3d0d999630906223e8cb89fa51b31022e04e1659)) +* run cypress in container ([4589169](https://github.com/dnum-mi/dso-console/commit/4589169d0bb0fd1c368c08869e80251d0d5f691a)) +* run with delay 10 second to wait keycloak initialize ([b736991](https://github.com/dnum-mi/dso-console/commit/b736991208b9a5fc0880fba294fa96dc073f4c8a)) +* update gitignore for all node_modules ([3988743](https://github.com/dnum-mi/dso-console/commit/39887438eceefe4b08b80ad056f2992baaf78494)) +* update implicit flow in config keycloak ([06d2f77](https://github.com/dnum-mi/dso-console/commit/06d2f77fcc07db2de780867b0bda1f49294b349e)) +* update pnpm lock ([344261d](https://github.com/dnum-mi/dso-console/commit/344261d2e1b37e8df9eed0cca90ed8e21697d17a)) + +### Reverts + +* :recycle: Move code to another git branch ([1c632c7](https://github.com/dnum-mi/dso-console/commit/1c632c715ffc01100d75c46c1e84a565195a10b3)) diff --git a/.jjconflict-side-0/CONTRIBUTING.md b/.jjconflict-side-0/CONTRIBUTING.md new file mode 100644 index 0000000000..50318b28ee --- /dev/null +++ b/.jjconflict-side-0/CONTRIBUTING.md @@ -0,0 +1,115 @@ +# Contribuer à Console Cloud Pi Native + +Merci de contribuer à la Console Cloud Pi Native. Ce guide s'applique aux +contributions internes et externes, qu'elles concernent du code, de la +configuration, des dépendances ou de la documentation. + +## Principes + +La Console fournit une interface unifiée pour piloter les services de l'offre +Cloud Pi Native, automatiser le cycle de vie des projets et appliquer les +exigences DevSecOps de sécurité, de qualité, de maintenabilité et +d'interopérabilité. + +Toute évolution doit démontrer son alignement avec cette vision avant toute +implémentation. Une contribution qui ne sert pas l'offre, son architecture ou +sa feuille de route ne peut pas être acceptée. + +## Qualifier le sujet avant de développer + +Toute contribution de code doit être adossée à un ticket. Ce ticket décrit : + +- le besoin traité et son alignement avec la vision produit ou la feuille de route ; +- le périmètre fonctionnel et technique de la contribution ; +- les critères d'acceptation ; +- les impacts connus sur la sécurité, les contrats API, les données, l'exploitation et la documentation. + +Pour une correction de bug, une procédure de reproduction, un test automatisé +de non-régression et, pour une interface, des captures d'écran sont fortement +recommandés lorsqu'ils sont pertinents. Leur absence ne bloque pas la +contribution lorsqu'ils ne peuvent pas s'appliquer. Pour une fonctionnalité, +joindre des maquettes ou croquis, même rudimentaires, est encouragé. + +Les corrections strictement documentaires ou typographiques, sans impact +fonctionnel, sont les seules contributions qui ne nécessitent pas de ticket. +Elles restent soumises au contrôle de périmètre et aux exigences de qualité +adaptées. + +## Respecter le périmètre + +Une pull request ou merge request ne contient que les changements nécessaires +au sujet décrit par son ticket. Les commits, fichiers ou changements sans +rapport direct avec ce sujet sont rejetés et doivent être proposés séparément, +avec leur propre ticket. + +Utilisez le [template de pull request](.github/PULL_REQUEST_TEMPLATE.md) pour +présenter le comportement modifié et les informations utiles à la revue. Son +évolution est traitée séparément de ce guide. + +## Obtenir les validations requises + +Une approbation explicite de l'équipe Socle est obligatoire pour toute +contribution de code qui n'est ni une correction de bug ni un changement +trivial. + +L'approbation explicite de l'équipe Socle est obligatoire dans tous les cas +pour : + +- l'ajout, la suppression ou la mise à niveau d'une dépendance Node.js ; +- l'ajout ou la mise à niveau d'un outil installé dans une image Docker ; +- tout autre changement de dépendance ayant un impact sur la chaîne d'approvisionnement, la sécurité ou la maintenance. + +Les corrections de bugs et les changements triviaux ne dispensent ni du +contrôle de périmètre, ni de la revue de code, ni des contrôles qualité. Une +dépendance ajoutée pour corriger un bug doit donc recevoir l'approbation +explicite de l'équipe Socle. + +## Cibler le backend approuvé + +`apps/server` est le backend historique et ne reçoit plus de contribution. +Toute pull request ou merge request qui modifie ce dossier est rejetée, y +compris pour une correction de bug. + +Les évolutions backend ciblent exclusivement `apps/server-nestjs`. Elles +suivent le [plan de modularisation backend vers NestJS](apps/server-nestjs/documentation/Modularisation-de-console-server/README.md), l'[état de migration](apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md), les conventions NestJS et la stratégie de tests associée. Toute dérogation est une décision explicite de l'équipe Socle, hors du flux normal de contribution. + +## Respecter l'architecture et la qualité + +Respectez les frontières et conventions de la zone modifiée : + +- le frontend Vue 3 et le système de design existant ; +- les modules NestJS et Prisma dans `apps/server-nestjs` pour le backend ; +- les contrats partagés, permissions et schémas de données ; +- les plugins et leurs mécanismes de cycle de vie lorsqu'ils sont concernés. + +Ajoutez ou adaptez les tests proportionnés au risque et au périmètre : tests +unitaires ou d'intégration pour la logique et les contrats, et tests Playwright +pour les parcours utilisateurs affectés. Mettez à jour la documentation lorsqu'un +comportement, une API, une configuration, une opération ou une migration est +affectée. + +Avant soumission, exécutez les contrôles adaptés à votre modification : + +```bash +pnpm lint +pnpm test +pnpm playwright:test +``` + +Le test Playwright est requis lorsque la contribution affecte un parcours +utilisateur. Respectez aussi TypeScript strict, ESLint, Stylelint pour le +client, les hooks Husky et la CI. Les messages de commit suivent Conventional +Commits et sont rédigés en anglais. Rebasez votre branche sur `origin/main` +avant la demande de fusion. + +## Documents de référence + +Consultez et citez les références appropriées dans le ticket : + +- la [documentation publique Cloud Pi Native](https://github.com/cloud-pi-native/documentation), notamment l'[introduction de la plateforme](https://github.com/cloud-pi-native/documentation/blob/main/docs/platform/introduction.md) et la [feuille de route](https://github.com/cloud-pi-native/documentation/blob/main/docs/platform/roadmap.md) ; +- l'[architecture et les conventions du dépôt](README.md) ; +- le [plan de modularisation NestJS](apps/server-nestjs/documentation/Modularisation-de-console-server/README.md) ; + +La feuille de route publique est actuellement en cours de rédaction. Son +indisponibilité ne bloque pas la qualification : le ticket doit alors exposer +une justification concise au regard de la vision produit. diff --git a/.jjconflict-side-0/ENVIRONMENTS.md b/.jjconflict-side-0/ENVIRONMENTS.md new file mode 100644 index 0000000000..011939c593 --- /dev/null +++ b/.jjconflict-side-0/ENVIRONMENTS.md @@ -0,0 +1,244 @@ +# Fichiers de configuration d'environnements + +Cette documentation a pour but de détailler tout ce qui concerne la gestion des configuration d'environnements (appelé couramment "fichiers .env"). + +Comme vous aurez pu le constater, il y a beaucoup de choses à configurer pour un projet d'ampleur comme l'est la Console de CPiN, et il y a des cas d'usages très spécifiques qui seront décrits ici. Vous avez également la possibilité de composer votre propre manière de gérer vous variables d'environments en vous basant sur ce qui a été fait pour nous. + +## Contexte : migration progressive vers NestJS (Strangler Fig) + +Le backend de la console est en cours de migration de `apps/server` (Fastify/legacy) vers `apps/server-nestjs` (NestJS). Cette migration est progressive, route par route, selon le [Strangler Fig Pattern](https://martinfowler.com/bliki/StranglerFigApplication.html). + +Pour orchestrer cette migration, un **nginx-strangler** a été introduit (ticket [#1885](https://github.com/cloud-pi-native/console/issues/1885)). Il s'intercale entre le `client` et les backends, et route chaque requête API vers le bon service : + +``` +[client :8080] + │ proxy /api → nginx-strangler:8080 + ▼ +[nginx-strangler :8080] + ├── /api/[routes migrées] ──→ server-nestjs:3001 (NestJS) + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +**Conséquences pratiques pour les développeurs :** + +- `server-nestjs` est désormais présent dans tous les `docker-compose` aux côtés de `server`. +- Le `nginx-strangler` est le nouveau point d'entrée unique pour les appels API — le `client` ne pointe plus directement vers `server`. +- Consultez [`MODULARISATION-STATUT.md`](apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md) avant de développer sur un module backend, pour savoir s'il est en cours de migration (zones en feature freeze). +- Si vous travaillez sur un module déjà migré vers `server-nestjs`, développez dans `apps/server-nestjs`, pas dans `apps/server`. + +Pour plus de détails sur la stratégie de migration : [`apps/nginx-strangler/README.md`](apps/nginx-strangler/README.md). + +--- + +## Cas d'usage supportés + +Avant de décrire dans le détail comment configurer les différents environments, il est important de rappeler les Cas d'Usage qui sont supportés par les scripts de CPiN: + +- **Développement totalement en local** : vous déployez l'entièreté de l'écosystème de `console`, sans considération d'un branchement à l'extérieur, et vous développez directement en mode "serve" sur `client`, `server`, `server-nestjs`, un `plugin` en particulier, ou peut-être même une combinaison des trois. +- **Développement conteneurisé en local** : vous déployez l'entièreté de l'écosystème de `console`, sans considération d'un branchement à l'extérieur, et même les composants fondamentaux de console (comme `client`, `server` et `server-nestjs`) dans des conteneurs à l'aide d'images construites précédemment (en local, ou alors tirée depuis le registre de l'organisation CPiN) +- **Développement conteneurisé hybride** : vous déployez localement seulement une partie de l'écosystème de `console` (`client` et/ou `server`/`server-nestjs`, de manière à utiliser votre code local) et pour le reste (Base de données de `console`, `keycloak`, `gitlab`, les clusters applicatifs, etc.), vous vous branchez à un environnement existant (appelé `integ` pour `INTEGRATION`). Ce cas d'usage est très pratique pour tester votre code avec de "vraies données" d'un environnement fonctionnel (comme notre environnement interne `cpin-hp`) +- Les cas de déploiements finaux du système complet, qui sont eux adressé par un chart Helm stocké dans [le dépôt `helm-charts`](https://github.com/cloud-pi-native/helm-charts) + +Maintenant que ces définitions sont établies, passons à la configuration pour chacun des cas d'usage + +## Considérations communes à tous les cas + +Le mode de fonctionnement de la configuration des environnements est assez classique : les applications `client`, `server` et `server-nestjs` ont besoin d'avoir certaines variables d'environnements définies. + +Le mécanisme de surcharge des différentes configurations fonctionne de cette manière : + +``` +->: «surcharge» + +var d'env settée explicitement -> fichier .env.docker (si contexte docker) -> fichier .env.integ (si INTEGRATION=true) -> fichier .env +``` + +## Prégénération des fichiers .env, .env.docker, et .env.integ + +Un script permet de copier facilement les fichiers `.env*-example` en leur équivalent `.env*`: [`./ci/scripts/init-env.sh`](./ci/scripts/init-env.sh). + +> Il faut ensuite remplir ces fichiers, car ils ne sont là que simplement copiés avec les valeurs par défaut + +## Configuration pour le développement entièrement en local + +Le développement entièrement en local suit cette organisation des composants: + +```mermaid +--- +title: Architecture mode entièrement local +--- +graph LR + + subgraph DEV_LOCAL["DEV LOCAL"] + direction LR + NAV_L["Navigateur"] + NGINX_L(("nginx (client)\n:8080\nLOCAL")) + STRANGLER_L(("nginx-strangler\n:8080\n🐳 exposé :4000")) + SERVER_L(("server\n:4001\nLOCAL")) + NESTJS_L(("server-nestjs\n:3001\nLOCAL")) + + NAV_L --> NGINX_L + NGINX_L -- "/api" --> STRANGLER_L + STRANGLER_L -- "défaut\n(pas migré)" --> SERVER_L + STRANGLER_L -- "migré" --> NESTJS_L + end + + style DEV_LOCAL fill:#fff,stroke:#333,stroke-width:2px + style NAV_L fill:#fff,stroke:#333 +``` + +Docker Compose utilisé : [`docker/docker-compose.local.yml`](docker/docker-compose.local.yml) (infrastructure uniquement : Keycloak, PostgreSQL, pgAdmin, OpenCDS mock, **nginx-strangler, et Jaeger**) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/server/.env` +- `apps/server-nestjs/.env` *(si vous travaillez sur server-nestjs)* + +Les valeurs par défaut, disponibles dans les fichiers `.env-example`, sont suffisantes dans 99% des cas. Cela dit, c'est un cas d'usage assez restreint car la console se reposant sur quelques composants externes comme une base de données PostgreSQL ou un serveur IAM comme `keycloak`, il faut configurer manuellement les `.env` pour pointer sur les bonnes URLs, etc. Pas infaisable, mais pas très pratique au quotidien, hors des cas simple de build des applications. + +**Commandes de lancement :** + +```bash +# Lance l'infrastructure (Keycloak, PostgreSQL, pgAdmin, OpenCDS mock, nginx-strangler, Jaeger) +pnpm dev + +# Puis dans d'autres terminaux, lancer les serveurs et le client manuellement : +pnpm --filter server run dev +pnpm --filter server-nestjs run start:dev # nouveau backend NestJS +pnpm --filter client run dev +``` + +### Faire transiter les appels API par le nginx-strangler en dev local + +Par défaut, le proxy Vite du `client` pointe directement vers `server` (port `4000`). Pour faire transiter les appels via le `nginx-strangler` (utile pour tester le routage de migration ou travailler sur `server-nestjs`), une seule variable suffit dans `apps/client/.env` : + +```bash +# apps/client/.env +SERVER_PORT=8082 # port du nginx-strangler exposé par docker-compose.local.yml + # (au lieu de 4000 qui pointe directement vers server) +``` + +Le `nginx-strangler` est automatiquement lancé par `pnpm dev` via `docker-compose.local.yml`. Il écoute sur `localhost:8082` et redirige vers les deux backends natifs (`server:4000` et `server-nestjs:3001`) selon la configuration de [`apps/nginx-strangler/conf.d/routing.conf`](apps/nginx-strangler/conf.d/routing.conf). + +> **Note :** si vous ne travaillez pas sur la migration NestJS, vous n'avez pas besoin de changer `SERVER_PORT` — le comportement par défaut (proxy direct vers `server:4000`) reste identique. + +### Observabilité : Jaeger + OpenTelemetry (traces) + +Le fichier [`docker/docker-compose.local.yml`](docker/docker-compose.local.yml) démarre un service `jaeger` (image `jaegertracing/all-in-one`) pour collecter et visualiser les traces. + +- UI Jaeger : http://localhost:16686 +- Endpoints de collecte exposés sur la machine hôte : + - OTLP gRPC : `localhost:4317` + - OTLP HTTP (protobuf) : `http://localhost:4318` + +Dans `apps/server-nestjs`, l'instrumentation OpenTelemetry est initialisée au démarrage via [`src/instrumentation.ts`](apps/server-nestjs/src/instrumentation.ts) (appelée depuis `main.ts`) et exporte via OTLP. + +Pour vérifier rapidement : + +1. Démarrer l'infra : `pnpm dev` (Jaeger inclus). +2. Démarrer `server-nestjs` : `pnpm --filter server-nestjs run start:dev`. +3. Exécuter une requête sur une route backend (depuis le client ou un `curl`). +4. Ouvrir http://localhost:16686 et chercher le service `cloud-pi-native-console`. + +## Configuration pour le développement conteneurisé en local + +Docker Compose utilisé : [`docker/docker-compose.dev.yml`](docker/docker-compose.dev.yml) (tout conteneurisé avec Docker Compose Watch) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/client/.env.docker` +- `apps/server/.env` +- `apps/server/.env.docker` +- `apps/server-nestjs/.env` +- `apps/server-nestjs/.env.docker` + +Cette configuration est déjà plus intéressante, car elle s'appuie sur les conteneurs définis dans [ce docker-compose](docker/docker-compose.dev.yml), qui lance notamment une base de données PostreSQL (ainsi qu'un `pgadmin`), et un serveur Keycloak préchargé avec un royaume qui contient un jeu de données. Le docker-compose contient des instructions `develop` qui permettent soit de synchroniser certains fichiers, soit de carrément reconstruire l'image et de relancer le service concerné. De cette manière vous pouvez développer en laissant les conteneurs tourner. C'est un peu moins performant qu'un travail totalement en local, mais ça a le mérite d'être plus proche du déploiement cible. + +Le `nginx-strangler` et `server-nestjs` sont inclus dans ce docker-compose et démarrent automatiquement. Le `client` pointe vers `nginx-strangler` — le routage API est donc toujours actif, même si aucune route n'est encore basculée vers NestJS (tout passe par `server` en fallback). + +**Commande de lancement :** + +```bash +# Lance l'ensemble des conteneurs (client, server, server-nestjs, nginx-strangler, +# keycloak, postgres, pgadmin, opencds mock) avec Docker Compose Watch +pnpm docker:dev +``` + +## Configuration pour le développement hybride avec un environnement d'intégration existant + +Le développement hybride branché sur l'environnement d'intégration suit cette organisation des composants: + +```mermaid +--- +title: Architecture mode hybride branché sur l'environment d'intégration +--- +graph LR + + subgraph DEV_INTEG["DEV INTEG"] + direction LR + NAV_I["Navigateur"] + CURL_I["curl directs\n(tests)"] + INTEG["INTEG\n(keycloak, gitlab, etc.)"] + + subgraph DOCKER_NET["🐳 Réseau Docker"] + direction LR + NGINX_I(("nginx (client)\n:8080\nexposé :8080")) + STRANGLER_I(("nginx-strangler\n:8080\nexposé :4000")) + SERVER_I(("server\n:8080\nexposé :4001")) + NESTJS_I(("server-nestjs\n:3001\nexposé :3001")) + + NGINX_I -- "/api" --> STRANGLER_I + STRANGLER_I -- "pas migré" --> SERVER_I + STRANGLER_I -- "migré" --> NESTJS_I + end + + NAV_I --> NGINX_I + SERVER_I -. "accès externe" .-> INTEG + NESTJS_I -. "accès externe" .-> INTEG + + CURL_I -- ":8080" --> NGINX_I + CURL_I -- ":4000" --> STRANGLER_I + CURL_I -- ":4001" --> SERVER_I + CURL_I -- ":3001" --> NESTJS_I + end + + style DEV_INTEG fill:#fff,stroke:#333,stroke-width:2px + style DOCKER_NET fill:#e6f3ff,stroke:#2196F3,stroke-width:2px + style NAV_I fill:#fff,stroke:#333 + style CURL_I fill:#fff,stroke:#333 + style INTEG fill:#fff,stroke:#333,stroke-dasharray: 5 5 +``` + +Docker Compose utilisé : [`docker/docker-compose.integ.yml`](docker/docker-compose.integ.yml) (sans Keycloak, branché sur l'environnement d'intégration distant) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/client/.env.docker` +- `apps/client/.env.integ` +- `apps/server/.env` +- `apps/server/.env.docker` +- `apps/server/.env.integ` +- `apps/server-nestjs/.env` +- `apps/server-nestjs/.env.docker` +- `apps/server-nestjs/.env.integ` *(si vous travaillez sur server-nestjs en mode integ)* + +Cette configuration est une itération de la précédente. Dans ce cas d'usage le Keycloak n'est pas créé en tant que conteneur, car on est supposé se brancher sur l'environnement d'intégration défini dans les fichiers `.env.integ`. Le contenu de ces fichiers (en particulier celui de `apps/server`) est donc clé. + +Le `nginx-strangler` et `server-nestjs` sont également inclus dans ce docker-compose. + +**Commandes de lancement :** + +```bash +# Option 1 : Tout conteneurisé, branché sur l'environnement d'intégration +pnpm docker:integ + +# Option 2 : Seulement l'infra en Docker (postgres, pgadmin), server et client en local avec mode integ +pnpm integ +# Puis dans d'autres terminaux : +pnpm --filter server run dev +pnpm --filter server-nestjs run start:dev +pnpm --filter client run dev +``` diff --git a/.jjconflict-side-0/LICENSE b/.jjconflict-side-0/LICENSE new file mode 100644 index 0000000000..4b139aa8bc --- /dev/null +++ b/.jjconflict-side-0/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Ministère de l’Intérieur et des Outre-Mer + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/.jjconflict-side-0/README.md b/.jjconflict-side-0/README.md new file mode 100644 index 0000000000..3a89503a2b --- /dev/null +++ b/.jjconflict-side-0/README.md @@ -0,0 +1,409 @@ +# Console Cloud π Native + +La console Cloud π Native est une application web ayant pour but de piloter des services dans un cluster Kubernetes afin de fournir une plateforme cloud qui accompagne les produits numériques lors de toutes les phases de leur cycle de vie. + +Cette console offre une interface unifiée vers un ensemble de services tout en garantissant une cohérence globale du système avec la création automatique d'un certain nombre de ressources comme les comptes d'accès, les robots ou encore des ressources Kubernetes. +En addition du provisionnement automatique, elle garantit aussi le contrôle d'accès aux ressources du projet à l'aide d'une gestion d'équipe, de permissions, de quotas, etc. + +<img src="./misc/images/projects.png" width="30%"></img> <img src="./misc/images/repositories.png" width="30%"></img> <img src="./misc/images/environments.png" width="30%"></img> <img src="./misc/images/services.png" width="30%"></img> <img src="./misc/images/members.png" width="30%"></img> <img src="./misc/images/status.png" width="30%"></img> + +## Démarrage Rapide (OnBoarding) + +Afin de démarrer rapidement l'application pour la découvrir il vous faudra d'abord cloner ce dépôt, puis satisfaire à un certain nombre de prérequis. + +### Cloner le projet + +```bash +git clone https://github.com/cloud-pi-native/console.git + +# Se rendre dans le dossier du projet +cd console +``` + +### Prérequis + +#### Prérequis techniques du projet + +- [Docker >= v27](https://docs.docker.com/get-docker/) Orchestrateur de conteneurs + - [Plugin compose >= v2.35](https://github.com/docker/compose) (attention à ne pas avoir une vieille version qui traînerait dans `~/.docker/cli-plugins/` !). Permet de composer plusieurs conteneurs Docker + - [Plugin buildx](https://github.com/docker/buildx) Permet d'étendre les capacités de Docker à l'aide de BuildKit +- [Node.js >= v24](https://nodejs.org/en/download/) Environnement d'exécution JavaScript +- [PnPM >= v10](https://pnpm.io/installation) Gestionnaire de paquets pour JavaScript + +#### Prérequis de configuration du projet + +Afin de pouvoir démarrer l'application, il vous faudra également récupérer (ou bien créer vous-même) les fichiers : + +- `apps/client/.env.docker` +- `apps/client/.env.integ` +- `apps/server/.env.docker` +- `apps/server/.env.integ` + +Si vous ne les avez pas déjà, référez-vous à [./ENVIRONMENTS.md](./ENVIRONMENTS.md) qui est la documentation consacrée au sujet. + +### Lancement rapide utilisant l'application en local et les services sur un environnement distant + +Maintenant que vous avez les prérequis projets (prérequis techniques et de configuration), vous pouvez construire et lancer l'application localement, en la couplant avec les services (par exemple Keycloak) d'un environnement d'intégration : + +```bash +# Installer toutes les dépendances +$ pnpm install + +# Initialiser la base de données PostgreSQL lancée localement +$ pnpm db:generate + +# Construire les applications client et server +$ pnpm build + +# Construire les images dockers client et server, puis lancer la stack docker compose d'intégration +$ export KUBECONFIG_HOST_PATH="$HOME/.kube/config" && pnpm run docker:integ +``` + +Cette série de commandes va installer les diverses dépendances, construire l'intégralité de l'application Console (client et serveur) dans des images docker locales, et finalement lancer l'application en la connectant dans votre infrastructure d'Intégration existante. + +Si vous voulez une vue plus approfondie du lancement de l'application, vous pouvez vous référer à la [section détaillée à ce sujet](#développement). + +## Architecture + +Ce projet est construit avec [NodeJS](https://nodejs.org/), [VueJS](https://vuejs.org/), [Postgres](https://www.postgresql.org/) et [Keycloak](https://www.keycloak.org/). +Le serveur et le client sont livrés sous forme d'images [Docker](https://www.docker.com/) et sont déployées à l'aide de [Helm](https://helm.sh/) dans [Kubernetes](https://kubernetes.io/). + +### API + +Le serveur est construit selon une architecture __core / plugins__ pour favoriser l'évolutivité et l'ajout de nouvelles fonctionnalités / la gestion de nouveaux services. Pour ce faire, les plugins s'enregistrent auprès de différents `hooks` (qui suivent le cycle de vie d'un projet au sein de l'application), ces derniers seront déclenchés par les contrôleurs de l'application. + +Plusieurs plugins sont nativement enregistrés auprès du serveur pour assurer le bon fonctionnement de la plateforme, à savoir : + +- [Argocd](https://argo-cd.readthedocs.io/en/stable/) +- [Gitlab](https://about.gitlab.com/) +- [Harbor](https://goharbor.io/) +- [Keycloak](https://www.keycloak.org/) +- [Kubernetes](https://kubernetes.io/) +- [Nexus](https://www.sonatype.com/products/sonatype-nexus-repository) +- [Sonarqube](https://www.sonarsource.com/products/sonarqube/) +- [Vault](https://www.vaultproject.io/) + +> Pour plus d'informations sur le développement d'un plugin, voir : +> - [documentation des plugins](./misc/plugins.md). +> - [documentation du module hooks](./packages/hooks/README.md). +> - [exemple de plugin](https://github.com/cloud-pi-native/console-plugin-helloworld). + +### Gestion des rôles + +La console gère le contrôle d'accès via des rôles (administration de la console et rôles au niveau projet) composés d'un nom, d'un ensemble de permissions (bitmask) et d'un type (`type`). + +Chaque rôle est associé à un groupe OIDC/Keycloak (`oidcGroup`) mais la source de vérité peut varier selon le type : + +- `managed` : la console est source de vérité pour les membres et les permissions, puis synchronise l'appartenance avec le groupe `oidcGroup`. +- `external` : le groupe OIDC est la source de vérité pour l'appartenance ; la console s'aligne dessus. +- `global` : rôle d'administration destiné à porter des permissions transverses telle que le role "Tout le monde". + +Le préfixe `system:` est un qualifiant (pas un type à part entière) utilisé pour marquer les rôles fournis/maintenus par la plateforme, par exemple `system:managed`, `system:external`, `system:global`. + +Dans l'interface, un rôle dont le type commence par `system:` est considéré comme non modifiable : ses champs (nom/type/groupe/permissions) sont en lecture seule et la suppression est désactivée. Selon l'écran, la gestion des membres peut rester disponible. + +## Développement + +Le développement s'effectue à l'aide de Docker *(le client et le serveur peuvent tourner en local ou dans Docker)*. + +### Construire l'application + +Une fois le projet cloné, lancez les commandes suivantes dans votre terminal pour installer le projet et construire l'ensemble de l'application `console` : + +```shell +# Installer les dépendances du projet +pnpm install + +# Créer les fichiers d'environnement exemples +./ci/scripts/init-env.sh + +# Générer le client Prisma côté serveur +pnpm --filter @cpn-console/server run db:generate + +# Construire tous les paquets applicatifs +pnpm build +``` + +#### Lancer l'application + +L'application peut se lancer de plusieurs manières, à savoir : + +__Déploiement local du client et du serveur de la console, le reste dans des conteneurs :__ + +```shell +# Lancer keycloak, postgres et pgadmin dans des conteneurs +pnpm run dev + +# Lancer le serveur dans un shell (la commande va afficher les logs) +pnpm --filter @cpn-console/server run dev + +# Lancer le client dans un shell (la commande va afficher les logs) +pnpm --filter @cpn-console/client run dev + +# …une fois que vous terminé d'utiliser l'application: + +# Supprimer les conteneurs keycloak, postgres et pgadmin +pnpm run dev:clean + +# Supprimer les conteneurs keycloak, postgres et pgadmin (supprime les volumes docker) +pnpm run dev:delete +``` + +__Tout dans des conteneurs :__ + +```shell +# Lancer l'application +pnpm run docker:dev + +# Supprimer les conteneurs +pnpm run docker:dev:clean + +# Supprimer les conteneurs (supprime également les volumes !) +pnpm run docker:dev:delete +``` + +> Pour lancer le debugger Node.js sur le serveur, dans les fichiers `docker-compose` remplacer la directive `command: ["dev"]` par `command: ["debug"]`. + +#### Intégration + +L'application peut se lancer de plusieurs manières, à savoir : + +__Déploiement local du client et du serveur de la console, le reste dans des conteneurs :__ + +```shell +# Lancer postgres et pgadmin dans des conteneurs +# (le keycloak utilisé devra être configuré dans les .env.integ) +pnpm run integ + +# Lancer le serveur +pnpm --filter @cpn-console/server run integ + +# Lancer le client +pnpm --filter @cpn-console/client run integ + +# Supprimer les conteneurs postgres et pgadmin +pnpm run integ:clean + +# Supprimer les conteneurs postgres et pgadmin (supprime les volumes docker) +pnpm run integ:delete +``` + +__Tout dans des conteneurs :__ + +```shell +# Lancer l'application +pnpm run docker:integ + +# Supprimer les conteneurs +pnpm run docker:integ:clean + +# Supprimer les conteneurs (supprime les volumes docker) +pnpm run docker:integ:delete +``` + +#### Utilitaires + +Les commandes utilitaires de l'application : + +```shell +# Formattage du code +pnpm run format + +# Lancer la vérification syntaxique +pnpm run lint + +# Lancer les tests unitaires +pnpm run test + +# Lancer les tests de composants +pnpm run test:ct +``` + +L'intégralité des commandes est disponibles dans le fichier [package.json](./package.json) à la racine du projet, vous pouvez lancer ces dernières à l'aide de la commande `pnpm run <le_nom_du_script>`. + +### Tests Playwright + +Se référer à la [documentation concernée](./playwright/README.md). + +### Accès aux services + +Les services sont disponibles via les ports suivants : + +| Service | Url (local/docker) | +| ---------------------------------------------- | ----------------------- | +| Interface graphique *- (client)* | <http://localhost:8080> | +| Serveur *- (api)* | <http://localhost:4000> | +| Interface d'administration de base de données | <http://localhost:8081> | +| Interface d'administration du serveur keycloak | <http://localhost:8090> | + +*__Notes:__ ⚠ Il est possible que le navigateur utilisé (particulièrement Brave ou Firefox) bloque les cookies utilisés entre le frontend et keycloak, il est nécessaire de désactiver les protections de ce type dans votre navigateur (ex: Brave Shield).* + +### Informations de connexion + +Les comptes utilisés pendant le développement sont les suivants : + +| Service | Nom d'utilisateur | Mot de passe | +| ------------------ | ----------------- | ------------ | +| Keycloak *(admin)* | `admin` | `admin` | +| Keycloak *(user)* | `test` | `test` | +| PgAdmin | `admin@dso.fr` | `admin` | +| Postgres | `admin@dso.fr` | `admin` | + +> La liste complète des comptes Keycloak pré-créés est disponible [ici](./keycloak/realms/realm-dev.json). + +> Le nom de la base de données est : `dso-console-db`. + +### Variables d'environnements + +__Local / Docker:__ + +Les variables d'environnements sont gérées localement via des fichiers `.env` (local) / `.env.docker` (docker) dans les dossiers `./apps/server` et `./apps/client`, aux précédents fichiers s'ajoute un fichier `.env.integ` utilisé pour le mode intégration (local et docker). + +### Base de données + +Pour faciliter les opérations de migrations de base de données via [Prisma](https://www.prisma.io/), un script est disponible : + +```shell +# Lancer le script +pnpm --filter @cpn-console/server run db:wrapper + +# Voir l'aide du script +pnpm --filter @cpn-console/server run db:wrapper -h +``` + +### Débogage avec VS Code + +Le projet est configuré pour être débogué directement dans VS Code via le fichier `.vscode/launch.json`. +Rendez-vous dans l'onglet "Run and Debug" (ou `Ctrl+Shift+D`) pour voir les configurations disponibles. + +#### Configurations disponibles + +- **Launch Server** : Lance le serveur en local (hors Docker) et attache le débogueur. + - *Note : Assurez-vous que les bases de données (Postgres) et Keycloak sont accessibles (ex: lancés via `pnpm run dev` dans un autre terminal pour les dépendances).* +- **Attach Server** : S'attache au processus Node.js du serveur. + - Utile lorsque le serveur tourne dans un conteneur Docker. + - Pour que cela fonctionne, le serveur doit être lancé avec le mode debug activé. +- **Serve Client (Chrome/Firefox)** : Lance l'application client en local et ouvre une instance de navigateur dédiée au débogage. +- **Launch (Chrome/Firefox)** : Lance simultanément le serveur et le client pour une session de débogage complète (Compound task). + +#### Déboguer avec d'autres outils (Chrome DevTools, WebStorm, etc.) + +Le serveur Node.js expose le port de débogage `9229` lorsqu'il est lancé en mode debug (ou avec l'option `--inspect`). Vous pouvez utiliser n'importe quel client de débogage compatible avec le protocole Inspector de Node.js. + +**Exemple avec Chrome DevTools :** + +1. Lancez le serveur en mode debug (voir section précédente pour Docker, ou `pnpm run debug` en local). +2. Ouvrez Chrome et naviguez vers `chrome://inspect`. +3. Cliquez sur "Open dedicated DevTools for Node" ou attendez que votre target apparaisse sous "Remote Target". +4. Vous pouvez maintenant mettre des points d'arrêt, inspecter les variables, etc. + +### Construction des images + +Ce dépôt utilise des fichiers docker-compose pour construire les images docker: + +- [docker-compose.local.yml](./docker/docker-compose.local.yml) pour le développement local (`client`/`server`/`server-nestjs` lancés localement, et une "glue" docker pour les joindre) +- [docker-compose.dev.yml](./docker/docker-compose.dev.yml) pour la construction des images docker du mode développement. +- [docker-compose.ci.yml](./docker/docker-compose.ci.yml) pour la construction des images docker utilisées dans nos pipelines d'Intégration Continue. + +## Configuration du Keycloak + +Pour pouvoir gérer les droits utilisateurs des services le pod `server` doit accéder aux groupes des users. Cela signifie modifier le clientScope `profile`: + +- Onglet `Mappers` +- `Add Mappers > By configuration > Group Membership` + - Name: `groups` + - Token Claim Name: `groups` + - Full group path: `off` + - Add to ID token: `on` + - Add to access token: `on` + - Add to userinfo: `off` + +> En environnement de dev l'import par défaut prévoit déjà cette modification. + +Les utilisateurs faisant parti du group `admin` ont également accès à l'interface administrateur de la console une fois connectés via un onglet supplémentaire `Administration` dans le menu latéral de l'application. + +## Tableau des ressources, terminologie + +| Console Cloud Pi | Projet | Environnement | Dépots | Utilisateur / membre | +| ---------------- | ---------------------------- | ------------- | --------------------------------------- | -------------------- | +| __Openshift__ | | Namespace | | | +| __ArgoCD__ | | | (infra) Secret, AppProject, Application | | +| __Gitlab__ | Group | | Repository (Dépôt) | User | +| __Harbor__ | Project | | Repository [1] | | +| __Keycloak__ | | Group | | User / member | +| __Sonar__ | User | | | | +| __Nexus__ | Repositories, role, user ... | | | | + +[1] N'est pas crée par la console mais par le produit de la CI + +## Architecture du dépôt + +La gestion des dépendances est effectuée à l'aide de [pnpm](https://pnpm.io/) selon la structure de dossiers suivante : + +- Les différentes briques applicatives se trouvent dans le dossier `apps/`. +- Les bibliothèques additionnelles se trouvent dans le dossier `packages/`. +- Les plugins core se trouvent dans le dossier `plugins/`. + +*Schema de l'architecture du monorepo :* + +```shell +./ +├── apps +│ ├── client +│ └── server +├── packages +│ ├── eslintconfig +│ ├── hooks +│ ├── shared +│ ├── test-utils +│ └── tsconfig +├── plugins +│ ├── argocd +│ ├── gitlab +│ ├── harbor +│ ├── keycloak +│ ├── kubernetes +│ ├── nexus +│ ├── sonarqube +│ └── vault +├── package.json +├── pnpm-lock.yaml +├── pnpm-workspace.yaml +└── README.md +``` + +## Organisation avec Git + +Une requête de fusion ("merge request") doit être faite avec la branche `main` comme destination. + +La branche de base **doit** être à jour avec `origin/main` (utiliser `git pull origin/main && git rebase origin/main` ou l'option de rebasage dans l'interface Web si besoin) avant la demande de fusion (MR). + +## Conventions de nommage + +Cf. [Conventions - MIOM Fabrique Numérique](https://docs.fabrique-numerique.fr/conventions/nommage.html). + +Les commits doivent suivre la spécification des [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). Cette norme est utilisée pour construire la nouvelle version, voir ci-après. + +Il est possible d'ajouter l'[extension VSCode](https://github.com/vivaxy/vscode-conventional-commits) pour faciliter la création des commits. + +> Les messages de commits sont rédigés en anglais. + +## Gestion des versions + +Se référer à [./RELEASE.md](./RELEASE.md). + +## Gestion des dépendances + +### Mise à jour des dépendances + +À propos du cycle de vie des dépendances, et notamment leur mise à jour, un document spécifique est disponible [ici](https://github.com/cloud-pi-native/documentation-interne-socle/blob/main/Divers/Mode-op%C3%A9ratoire-mont%C3%A9es-version-librairies.md). Ce document détaille la stratégie choisie en interne de mise à jour des dépendances de la Console CPiN. + +Concernant la mise à jour de `browserlist` et de `caniuse-lite`, utiliser la commande suivante sachant qu'on utilise `pnpm` : + +```shell +$ pnpm update caniuse-lite -r +``` + +### Dépendances Helm + +La console embarque une dépendance avec le Helm Chart utilisé par ArgoCD pour consommer le fichier values qu'elle construit. Afin d'assurer la compatibilité, il faut toujours que la version du Helm chart associé ait été publiée au préalable (voir [la documentation dédiée](https://github.com/cloud-pi-native/helm-charts?tab=readme-ov-file#contribution)). diff --git a/.jjconflict-side-0/RELEASE.md b/.jjconflict-side-0/RELEASE.md new file mode 100644 index 0000000000..300f31fd01 --- /dev/null +++ b/.jjconflict-side-0/RELEASE.md @@ -0,0 +1,110 @@ +# Gestion des versions + +Ce document décrit comment est géré le versionnement de `console`, c'est-à-dire : + +- Préparer la création d'une nouvelle version de `console` au fur et à mesure des ajouts +- Créer effectivement la nouvelle version +- Mettre à jour le chart Helm concerné +- Publier les nouvelles versions de modules NPM concernés + +## Incrémentation des versions + +Afin d'éviter une confusion entre les hotfixes, qui sont des versions `PATCH` qui ensuite rétroportées sur la branche principale (`main`), et les versions régulières qui n'ont que des commits `fix:` (et donc produisent par défaut, elles aussi, des versions `PATCH`), nous avons décidé d'adopter le protocole de versionnement suivant : + +- Les versions régulières sur `main` sont par défaut des `MINOR` (et très rarement des `MAJOR`) +- Les versions de hotfix d'une version en particulier sont **forcément** des `PATCH` (ceci est forcé dans le [flux de travail GitHub `Create new release`](https://github.com/cloud-pi-native/console/tree/main/.github/workflows/job-release-please.yml#L39)) + +La structure de versionnement de `console` est donc **`MAJOR`.`MINOR`.`HOTFIX`** + +## Schéma récapitulatif + +Les sections suivantes vont expliciter ce schéma: + +```mermaid +gitGraph + commit id: "…previous commits" + commit id: "Add basic features" + + branch release-please-main + checkout release-please-main + commit id:"bump to v1.2.0" tag: "v1.2.0" + + checkout main + merge release-please-main + checkout release-please-main + branch "hotfix/urgent-fix-for-v1.2.0" + commit id: "Fix stuff" + branch release-please-hotfix_v1.2.1 + commit id:"bump to v1.2.1" tag: "v1.2.1" + checkout "hotfix/urgent-fix-for-v1.2.0" + merge release-please-hotfix_v1.2.1 + + checkout main + commit id: "Add features" + checkout release-please-main + commit id:"bump to v1.3.0" tag: "v1.3.0" + merge main + commit id:"bump to v1.3.0" tag: "v1.3.0" + checkout main + commit id: "More features" + checkout release-please-main + merge main + commit id:"bump to v1.3.0 (recreated)" tag: "v1.3.0 (recreated)" + checkout main + merge release-please-main + + checkout main + merge "hotfix/urgent-fix-for-v1.2.0" + commit id:"backport v1.2.1 fix onto main" + checkout release-please-main + commit id:"bump to v1.4.0" tag: "v1.4.0" + merge main + commit id:"bump to v1.4.0" tag: "v1.4.0" + checkout main + merge release-please-main +``` + +## Versionnement de Console + +Le flux de travail qui créé les nouvelles versions s'intitule [`create-or-update-release`](https://github.com/cloud-pi-native/console/blob/main/.github/workflows/workflow-create-or-update-release.yml) et est déclenché à chaque nouveau commit sur `main` (soit lorsqu'on fusionne une requête de fusion, soit un commit poussé en outrepassant l'interdiction de pousser sur `main`) ou sur une branche `hotfix/*`. + +Le flux de travail utilise [release-please-action](https://github.com/googleapis/release-please-action) pour automatiquement générer les tags Git ainsi que les nouvelles versions sur GitHub. À chaque fois que du code est poussé dans la branche `main` ou un branche `hotfix/*`, une requête de fusion de version est créée en analysant les messages de commits pour déterminer le numéro de version à créer (`PATCH`, `MINOR`, ou `MAJOR`). Si une requête de fusion de nouvelle version existe déjà, elle est mise à jour afin de refléter les nouveaux changements ajoutés à la future nouvelle version. + +Les différent types de commits (`chore:`, `feat:`, `fix:` etc.) vont alimenter différentes sections de la `CHANGELOG`. Ces sections sont décrites dans la configuration de release-please, [`./release-please-config.json`](./release-please-config.json) + +Lorsqu'une requête de fusion de version (sur `main` ou `hotfix/*`) est fusionnée, les images de conteneur des applications (`client`, `server`, etc.) sont alors créées et hébergées dans la [registry Github associée au dépôt](https://github.com/orgs/cloud-pi-native/packages?repo_name=console) avec les tags appropriés (qui reflètent les tags git concernés). + +> Seuls les tags "complets" (`vX.Y.Z`) sont immutables, les tags "partiels" (`vX` et `vX.Y`) sont recréés pour relier la dernière version concernée. C'est pour ça que lorsque vous tirez les changements de `main` il est recommandé de faire un `git pull --tags --force` afin de forcer la recréation de vos tags locaux pour ces tags partiels. + +## Versionnement du chart Helm `dso-console` + +Le déploiement de `console` se fait préférablement à l'aide de son chart Helm, nommé [`dso-console`](https://github.com/cloud-pi-native/helm-charts/tree/main/charts/dso-console). + +La dernière étape du flux de travail de création de nouvelles versions (cf. section ci-dessus) est la création automatique d'une requête de fusion dans le dépôt `helm-charts` pour la mise à jour du chart Helm `dso-console`. Une fusion manuelle sur ce dépôt est alors nécessaire pour déclencher la publication de la nouvelle version du chart Helm (embarquant donc la nouvelle version de `console`). Exemple d'une telle requête de fusion de nouvelle version du chart Helm : https://github.com/cloud-pi-native/helm-charts/pull/204. + +> Les versions "régulières" (`MAJOR` ou `MINOR` depuis `main`) et les versions "hotfixes" (`PATCH` depuis `hotfix/*`) produisent le même type de requête de fusion côté `helm-charts`, car du point de vue de ce dépôt toute mise à jour de l'application est un `PATCH` bump côté chart. + +## Versionnement des modules NPM + +La publication des nouvelles versions de modules npm du dépôt est automatique et est inclus dans le flux de travail de création d'une nouvelle version. Il analyse les numéros de version présents dans les différents fichiers `package.json` pour déterminer si une nouvelle version du module doit être créée et publiée. + +> Il est possible de créer une version de pré-release d'un module npm en modifiant la clé `publishConfig.tag` dans le `package.json` avec par exemple `beta` pour générer une version beta. + +## Hotfixes + +Autant que faire se peut il vaut mieux privilégier le "Fix Forward" avec de nouvelles versions, afin d'éviter la charger de générer/rétroporter un hotfix. + +Ceci étant dit, il arrivera, hélas, qu'un hotfix soit nécessaire sur une version livrée. + +Voici donc le processus compatible avec l'utilisation de `release-please`: + +- Se placer localement sur le tag de la version concernée: `$ git checkout v1.2.0` (`v1.2.0` est ici la version à hotfixer) +- En tirer une branche dédiée au hotfix: `$ git checkout -b hotfix/my-urgent-hotfix-for-v1.2.0` (Note: Il n'est pas nécessaire de spécifier la version dans le nom de la branche, mais ça peut aider à la lecture et ainsi confirmer la version concernée) +- Faire les modifications nécessaires, committer, etc. +- Pousser la nouvelle branche sur le dépôt Github +- ⚠ Si vous voulez faire une "preview" de cette branche il faudra très probablement créer une **autre branche** qui cible `main` et y résoudre les conflits avant de faire une `preview`. En effet il est fortement probable que si vous faites un hotfix, la branche `main` aura déjà une nouvelle version (sinon vous ne feriez pas un hotfix, vous feriez simplement une nouvelle version 😁). Or il faut savoir que [Github ne permet pas l'exécution des workflows en cas de conflits](https://github.com/orgs/community/discussions/26304). Donc si vous voulez une `preview` du hotfix il faudra une MR dédiée, et dont vous aurez résolu les hotfixes (ça devrait être normalement limité aux fichiers contenant la version comme `CHANGELOG.md`). C'est pénible, mais c'est comme ça. +- Une fois la nouvelle branche poussée, `release-please` va être déclenché par le flux de travail Github `create-or-update-release` afin de créer une requête de fusion pour la nouvelle version hotfixée (avec comme cible la branche de hotfix). Il est d'ailleurs à noter que dans le cas d'un hotfix **on ne fait qu'une montée du "PATCH"** (ici on obtiendra donc la version `v1.2.1`, qui est alors le premier hotfix de la version `v1.2.0`) quelque soit les commits (donc même un `feat!` ne fera pas de montée majeure) +- Valider la MR de version hotfixée (créée donc par `release-please`) à l'aide du flux de travail Github `Continuous Integration` +- Une fois la MR de version hotfixée validée et fusionnée, la nouvelle version est créée et, comme pour les versions traditionnelles, une requête de fusion est crée dans le dépôt `helm-charts` pour avoir là aussi une version hotfixée (mais, pour le chart Helm, c'est considéré comme une version classique) +- Il faudra ensuite faire des picorages (`git cherry-pick`) ou une MR de la branche de hotfix vers `main` afin d'intégrer le ou les commits de hotfix dans la prochaine version officielle +- ⚠ Il faut retagger le commit de hotfix quand on a backporté le fix + le commit de bump de la release hotfix sur `main`, car sinon la Release côté GitHub pointe vers un tag dont le commit n'est pas sur `main` (le SHA a logiquement changé lorsqu'il a été backporté). Refaire donc localement un `git tag vX.Y.Z commit-de-release-backporté-sur-main` et pusher avec `git push --tags --force` pour forcer la mise à jour du tag. SI VOUS NE FAITES PAS ÇA LES CHANGELOG VONT TOTALEMENT PARTIR EN VRILLE ⚠ diff --git a/.jjconflict-side-0/apps/client/.env-example b/.jjconflict-side-0/apps/client/.env-example new file mode 100644 index 0000000000..2f23f06f97 --- /dev/null +++ b/.jjconflict-side-0/apps/client/.env-example @@ -0,0 +1,24 @@ +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Hôte du serveur backend vers lequel le client proxifie les appels API +SERVER_HOST=localhost +# Port d'écoute du serveur backend +# En développement local avec le nginx-strangler (pnpm dev +# + docker-compose.local.yml), il vaut mieux mettre SERVER_PORT=4000 pour que +# le proxy Vite passe par nginx-strangler plutôt que directement par server (4001) +# ou server-nestjs (3001). +SERVER_PORT=4000 +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté frontend +KEYCLOAK_CLIENT_ID=dso-console-frontend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# Active/désactive le module OpenCDS (true | false) +OPENCDS_ENABLED=false +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-side-0/apps/client/.env.docker-example b/.jjconflict-side-0/apps/client/.env.docker-example new file mode 100644 index 0000000000..1f05a462c6 --- /dev/null +++ b/.jjconflict-side-0/apps/client/.env.docker-example @@ -0,0 +1,21 @@ +# Spécifique à ce ficher. Permet de dire que la stack est conteneurisée +DOCKER=true +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Hôte du nginx vers lequel le client proxifie les appels API +SERVER_HOST=nginx-strangler +SERVER_PORT=8080 +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté frontend +KEYCLOAK_CLIENT_ID=dso-console-frontend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# Active/désactive le module OpenCDS (true | false) +OPENCDS_ENABLED=false +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-side-0/apps/client/.env.integ-example b/.jjconflict-side-0/apps/client/.env.integ-example new file mode 100644 index 0000000000..4f870dfd2f --- /dev/null +++ b/.jjconflict-side-0/apps/client/.env.integ-example @@ -0,0 +1,11 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Domaine du Keycloak de l'environnement d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak de l'environnement d'intégration +KEYCLOAK_REALM= +# En intégration, Keycloak est généralement exposé en HTTPS +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak frontend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= diff --git a/.jjconflict-side-0/apps/client/.eslintrc-auto-import.json b/.jjconflict-side-0/apps/client/.eslintrc-auto-import.json new file mode 100644 index 0000000000..253b4b4683 --- /dev/null +++ b/.jjconflict-side-0/apps/client/.eslintrc-auto-import.json @@ -0,0 +1,93 @@ +{ + "globals": { + "Component": true, + "ComponentPublicInstance": true, + "ComputedRef": true, + "DirectiveBinding": true, + "EffectScope": true, + "ExtractDefaultPropTypes": true, + "ExtractPropTypes": true, + "ExtractPublicPropTypes": true, + "InjectionKey": true, + "MaybeRef": true, + "MaybeRefOrGetter": true, + "PropType": true, + "Ref": true, + "VNode": true, + "WritableComputedRef": true, + "acceptHMRUpdate": true, + "computed": true, + "createApp": true, + "createPinia": true, + "customRef": true, + "defineAsyncComponent": true, + "defineComponent": true, + "defineStore": true, + "effectScope": true, + "getActivePinia": true, + "getCurrentInstance": true, + "getCurrentScope": true, + "h": true, + "inject": true, + "isProxy": true, + "isReactive": true, + "isReadonly": true, + "isRef": true, + "mapActions": true, + "mapGetters": true, + "mapState": true, + "mapStores": true, + "mapWritableState": true, + "markRaw": true, + "nextTick": true, + "onActivated": true, + "onBeforeMount": true, + "onBeforeRouteLeave": true, + "onBeforeRouteUpdate": true, + "onBeforeUnmount": true, + "onBeforeUpdate": true, + "onDeactivated": true, + "onErrorCaptured": true, + "onMounted": true, + "onRenderTracked": true, + "onRenderTriggered": true, + "onScopeDispose": true, + "onServerPrefetch": true, + "onUnmounted": true, + "onUpdated": true, + "onWatcherCleanup": true, + "provide": true, + "reactive": true, + "readonly": true, + "ref": true, + "resolveComponent": true, + "setActivePinia": true, + "setMapStoreSuffix": true, + "shallowReactive": true, + "shallowReadonly": true, + "shallowRef": true, + "storeToRefs": true, + "toRaw": true, + "toRef": true, + "toRefs": true, + "toValue": true, + "triggerRef": true, + "unref": true, + "useAttrs": true, + "useCssModule": true, + "useCssVars": true, + "useId": true, + "useLink": true, + "useModel": true, + "useRoute": true, + "useRouter": true, + "useScheme": true, + "useSlots": true, + "useTabs": true, + "useTemplateRef": true, + "watch": true, + "watchEffect": true, + "watchPostEffect": true, + "watchSyncEffect": true + } +} diff --git a/.jjconflict-side-0/apps/client/.stylelintrc.js b/.jjconflict-side-0/apps/client/.stylelintrc.js new file mode 100644 index 0000000000..86614a8803 --- /dev/null +++ b/.jjconflict-side-0/apps/client/.stylelintrc.js @@ -0,0 +1,40 @@ +export default { + root: true, + extends: [ + 'stylelint-config-standard', + 'stylelint-config-html/vue', + 'stylelint-config-html', + 'stylelint-config-recommended-vue', + ], + ignoreDisables: false, + rules: { + 'at-rule-no-deprecated': [ + true, + { + ignoreAtRules: [ + 'windi', + 'apply', + 'include', + 'variants', + 'responsive', + 'screen', + ], + }, + ], + 'at-rule-no-unknown': [ + true, + { + ignoreAtRules: [ + 'windi', + 'apply', + 'include', + 'variants', + 'responsive', + 'screen', + ], + }, + ], + 'no-descending-specificity': null, + 'selector-class-pattern': '^((sm|md|lg|xl|2xl):)?[a-z][-_/a-z0-9]*$', + }, +} diff --git a/.jjconflict-side-0/apps/client/Dockerfile b/.jjconflict-side-0/apps/client/Dockerfile new file mode 100644 index 0000000000..b6784f3a82 --- /dev/null +++ b/.jjconflict-side-0/apps/client/Dockerfile @@ -0,0 +1,48 @@ +# Dev stage ------------------------------------------------------------------------ +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Install pnpm version defined in package.json "packageManager" property +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY --chown=node:root apps/client/package.json ./apps/client/package.json +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +RUN pnpm install --ignore-scripts # --no-optional + +COPY --chown=node:root packages/ ./packages/ +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +COPY --chown=node:root apps/client/ ./apps/client/ +RUN pnpm --filter client run icons + +ENTRYPOINT [ "pnpm", "--filter", "client", "run", "dev" ] + + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +ARG APP_VERSION +RUN pnpm --filter @cpn-console/client run build + + +# Prod stage ----------------------------------------------------------------------- +FROM nginxinc/nginx-unprivileged:1.31-alpine AS prod + +COPY --chown=nginx:root --from=build /app/apps/client/dist /etc/nginx/html/ +COPY --chown=nginx:root --chmod=660 ./apps/client/nginx/default.conf /etc/nginx/conf.d/default.conf +COPY --chown=nginx:root --chmod=750 ./apps/client/nginx/entrypoint.sh /docker-entrypoint.d/99-envsubst-js-assets.sh + +RUN chmod -R g=u /etc/nginx/html + +EXPOSE 8080 diff --git a/.jjconflict-side-0/apps/client/README.md b/.jjconflict-side-0/apps/client/README.md new file mode 100644 index 0000000000..00810a3778 --- /dev/null +++ b/.jjconflict-side-0/apps/client/README.md @@ -0,0 +1,56 @@ +# Console Cloud π Native - Client + +## Installation + +```sh +npm install +``` + +## Lancement de l'app + +```sh +npm run dev +``` + +## Lancement des tests unitaires + +```sh +npm run test +``` + +## Formattage du code + +```sh +# Lister les problèmes de formatage +npm run lint + +# Régler automatiquement les problèmes de formatage +npm run format +``` + +## Build de l'app + +```sh +npm run build +``` + +## Prévisualisation du code de production + +```sh +npm run preview +``` + +## Récupérer toutes les icones du projets +`grep -r -oh "ri:[a-z0-9-]*" . | sort | uniq | cut -d ':' -f 2 | awk NF | awk '{print " \047" $1 "\047,"}'` + +## Activation OpenCDS + +Se référer à [la documentation dédiée](../../packages/opencds/README.adoc) + +## Crédits + +- [vue](https://github.com/vuejs/) +- [vue-dsfr](https://github.com/dnum-mi/vue-dsfr) +- [vite](https://github.com/vitejs/vite) +- [stylelint](https://github.com/stylelint/stylelint) +- [eslint](https://github.com/eslint/eslint) diff --git a/.jjconflict-side-0/apps/client/eslint.config.js b/.jjconflict-side-0/apps/client/eslint.config.js new file mode 100644 index 0000000000..88c06179d3 --- /dev/null +++ b/.jjconflict-side-0/apps/client/eslint.config.js @@ -0,0 +1,6 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase.overrideRules({ + 'e18e/prefer-static-regex': 'off', + 'vue/no-required-prop-with-default': 'off', +}) diff --git a/.jjconflict-side-0/apps/client/index.html b/.jjconflict-side-0/apps/client/index.html new file mode 100644 index 0000000000..b57fbfc4ab --- /dev/null +++ b/.jjconflict-side-0/apps/client/index.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html lang="fr"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" href="/favicon.ico" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Console Cloud π Native + + +
+ + + diff --git a/.jjconflict-side-0/apps/client/jsconfig.json b/.jjconflict-side-0/apps/client/jsconfig.json new file mode 100644 index 0000000000..a5cee14f16 --- /dev/null +++ b/.jjconflict-side-0/apps/client/jsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "jsx": "preserve", + "baseUrl": ".", + "module": "ESNext", + "moduleResolution": "node", + "paths": { + "@/*": [ + "./src/*" + ] + }, + "sourceMap": true + }, + "exclude": [ + "node_modules" + ] +} diff --git a/.jjconflict-side-0/apps/client/nginx/README.md b/.jjconflict-side-0/apps/client/nginx/README.md new file mode 100644 index 0000000000..b2e55f8ea1 --- /dev/null +++ b/.jjconflict-side-0/apps/client/nginx/README.md @@ -0,0 +1,5 @@ +# À propos + +Ce nginx est celui utilisé à fin d'exposer les fichiers de l'application "SPA" (Single Page Application) de la Console. + +**À ne pas confondre avec [`nginx-strangler`](../../nginx-strangler) qui est le composant permettant le transfert progressif des routes de `server` vers `server-nestjs`** diff --git a/.jjconflict-side-0/apps/client/nginx/default.conf b/.jjconflict-side-0/apps/client/nginx/default.conf new file mode 100644 index 0000000000..60cfae693b --- /dev/null +++ b/.jjconflict-side-0/apps/client/nginx/default.conf @@ -0,0 +1,16 @@ +server { + listen 8080; + server_name localhost; + root /etc/nginx/html; + index index.html; + + large_client_header_buffers 4 32k; + + location / { + try_files $uri $uri/ @rewrites; + } + + location @rewrites { + rewrite "^(.+)$" /index.html last; + } +} diff --git a/.jjconflict-side-0/apps/client/nginx/default.docker.conf b/.jjconflict-side-0/apps/client/nginx/default.docker.conf new file mode 100644 index 0000000000..74bca2057d --- /dev/null +++ b/.jjconflict-side-0/apps/client/nginx/default.docker.conf @@ -0,0 +1,35 @@ +upstream api { + # Toutes les requêtes API transitent par nginx-strangler qui orchestre + # la bascule progressive vers server-nestjs (Strangler Fig Pattern, ticket #1885) + server nginx-strangler:8080; +} + +server { + listen 8080; + server_name localhost; + root /etc/nginx/html; + index index.html; + + large_client_header_buffers 4 32k; + + location / { + try_files $uri $uri/ @rewrites; + } + + location @rewrites { + rewrite "^(.+)$" /index.html last; + } + + location ~ ^/(api|swagger-ui) { + rewrite "^(.*)$" $1 break; + add_header Access-Control-Allow-Origin '$http_origin'; + proxy_pass http://api; + proxy_redirect off; + proxy_buffering off; + proxy_http_version 1.1; + + # For WebSockets + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/.jjconflict-side-0/apps/client/nginx/entrypoint.sh b/.jjconflict-side-0/apps/client/nginx/entrypoint.sh new file mode 100755 index 0000000000..98dfe3bcd9 --- /dev/null +++ b/.jjconflict-side-0/apps/client/nginx/entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +ASSETS_DIR=/etc/nginx/html/assets + +make_envsubst_vars() { + fmt= + for name do + fmt="${fmt}${fmt:+ }\${$name}" + done + + printf '%s\n' "$fmt" +} + +ENV_VARS=$(make_envsubst_vars \ + SERVER_HOST \ + SERVER_PORT \ + OPENCDS_ENABLED \ + KEYCLOAK_PROTOCOL \ + KEYCLOAK_DOMAIN \ + KEYCLOAK_REALM \ + KEYCLOAK_CLIENT_ID \ + KEYCLOAK_REDIRECT_URI \ + CONTACT_EMAIL \ +) + +echo "Replacing env variables in JavaScript asset files..." +for file in $ASSETS_DIR/*.js; do + echo "Processing $file ..."; + envsubst "${ENV_VARS}" \ + < $file \ + > $file-out && \ + mv $file-out $file +done +echo "Done !" diff --git a/.jjconflict-side-0/apps/client/package.json b/.jjconflict-side-0/apps/client/package.json new file mode 100644 index 0000000000..a897dccf6f --- /dev/null +++ b/.jjconflict-side-0/apps/client/package.json @@ -0,0 +1,88 @@ +{ + "name": "@cpn-console/client", + "type": "module", + "version": "9.22.1", + "private": false, + "description": "cpn-console client", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "scripts": { + "build": "pnpm run icons && pnpm run type-check && pnpm run build-only", + "build-only": "vite --mode=production build", + "build:clean": "rimraf ./dist ./tsconfig.tsbuildinfo", + "dev": "vite --mode=development", + "format": "NODE_OPTIONS='--no-warnings=ExperimentalWarning' eslint ./ --fix", + "format:style": "stylelint ./src/**/*.{css,vue} --fix", + "icons": "vue-dsfr-icons -s scripts/icons.js -t src/icon-collections.ts", + "integ": "vite --mode=integration", + "lint": "pnpm run --parallel \"/^lint:.*/\"", + "lint:style": "stylelint ./src/**/*.{css,vue}", + "lint:ts": "NODE_OPTIONS='--no-warnings=ExperimentalWarning' eslint ./", + "preview": "vite preview --port 8080", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "type-check": "vue-tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@gouvfr/dsfr": "catalog:runtime", + "@gouvminint/vue-dsfr": "catalog:runtime", + "@iconify-json/ri": "catalog:runtime", + "@iconify/vue": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@vue/tsconfig": "catalog:build", + "axios": "catalog:runtime", + "javascript-time-ago": "catalog:runtime", + "jszip": "catalog:runtime", + "keycloak-js": "catalog:runtime", + "nanoid": "catalog:runtime", + "p-debounce": "catalog:runtime", + "pinia": "catalog:runtime", + "vue": "catalog:runtime", + "vue-router": "catalog:runtime", + "vue3-json-viewer": "catalog:runtime", + "xbytes": "catalog:runtime", + "yaml": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@iconify/types": "catalog:build", + "@types/jsdom": "catalog:types", + "@types/node": "catalog:types", + "@unocss/transformer-directives": "catalog:build", + "@vitejs/plugin-vue": "catalog:build", + "@vitest/coverage-v8": "catalog:test", + "@vue/eslint-config-typescript": "catalog:tools", + "chalk": "catalog:tools", + "eslint": "catalog:tools", + "eslint-plugin-vue": "catalog:tools", + "jsdom": "catalog:test", + "rimraf": "catalog:tools", + "stylelint": "catalog:tools", + "stylelint-config-html": "catalog:tools", + "stylelint-config-recommended-vue": "catalog:tools", + "stylelint-config-standard": "catalog:tools", + "typescript": "catalog:build", + "unocss": "catalog:build", + "unplugin-auto-import": "catalog:build", + "unplugin-vue-components": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vite-plugin-pwa": "catalog:build", + "vitest": "catalog:test", + "vue-eslint-parser": "catalog:tools", + "vue-tsc": "catalog:build", + "workbox-window": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/apps/client/public/browserconfig.xml b/.jjconflict-side-0/apps/client/public/browserconfig.xml new file mode 100644 index 0000000000..4826e1357a --- /dev/null +++ b/.jjconflict-side-0/apps/client/public/browserconfig.xml @@ -0,0 +1,2 @@ + +#ffffff diff --git a/.jjconflict-side-0/apps/client/public/favicon.ico b/.jjconflict-side-0/apps/client/public/favicon.ico new file mode 100644 index 0000000000..d5b9b0fcb9 Binary files /dev/null and b/.jjconflict-side-0/apps/client/public/favicon.ico differ diff --git a/.jjconflict-side-0/apps/client/public/img/architecture.png b/.jjconflict-side-0/apps/client/public/img/architecture.png new file mode 100644 index 0000000000..46a52e9c98 Binary files /dev/null and b/.jjconflict-side-0/apps/client/public/img/architecture.png differ diff --git a/.jjconflict-side-0/apps/client/public/img/argocd.svg b/.jjconflict-side-0/apps/client/public/img/argocd.svg new file mode 100644 index 0000000000..fb7bc6fe84 --- /dev/null +++ b/.jjconflict-side-0/apps/client/public/img/argocd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.jjconflict-side-0/apps/client/public/img/gitlab.svg b/.jjconflict-side-0/apps/client/public/img/gitlab.svg new file mode 100644 index 0000000000..602ffcc063 --- /dev/null +++ b/.jjconflict-side-0/apps/client/public/img/gitlab.svg @@ -0,0 +1 @@ +GitLab home page \ No newline at end of file diff --git a/.jjconflict-side-0/apps/client/public/img/harbor.svg b/.jjconflict-side-0/apps/client/public/img/harbor.svg new file mode 100644 index 0000000000..c5d042f6b6 --- /dev/null +++ b/.jjconflict-side-0/apps/client/public/img/harbor.svg @@ -0,0 +1,482 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.jjconflict-side-0/apps/client/public/img/logo-marianne.png b/.jjconflict-side-0/apps/client/public/img/logo-marianne.png new file mode 100644 index 0000000000..786e3d61f4 Binary files /dev/null and b/.jjconflict-side-0/apps/client/public/img/logo-marianne.png differ diff --git a/.jjconflict-side-0/apps/client/public/img/nexus.png b/.jjconflict-side-0/apps/client/public/img/nexus.png new file mode 100644 index 0000000000..809e5fbe1b Binary files /dev/null and b/.jjconflict-side-0/apps/client/public/img/nexus.png differ diff --git a/.jjconflict-side-0/apps/client/public/img/sonarqube.svg b/.jjconflict-side-0/apps/client/public/img/sonarqube.svg new file mode 100644 index 0000000000..c6bb30720f --- /dev/null +++ b/.jjconflict-side-0/apps/client/public/img/sonarqube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.jjconflict-side-0/apps/client/public/img/vault.svg b/.jjconflict-side-0/apps/client/public/img/vault.svg new file mode 100644 index 0000000000..d5be8c74d2 --- /dev/null +++ b/.jjconflict-side-0/apps/client/public/img/vault.svg @@ -0,0 +1 @@ +Vault \ No newline at end of file diff --git a/.jjconflict-side-0/apps/client/public/manifest.json b/.jjconflict-side-0/apps/client/public/manifest.json new file mode 100644 index 0000000000..a99d9409c7 --- /dev/null +++ b/.jjconflict-side-0/apps/client/public/manifest.json @@ -0,0 +1,41 @@ +{ + "name": "VueDsfr App", + "icons": [ + { + "src": "/icons/android-icon-36x36.png", + "sizes": "36x36", + "type": "image/png", + "density": "0.75" + }, + { + "src": "/icons/android-icon-48x48.png", + "sizes": "48x48", + "type": "image/png", + "density": "1.0" + }, + { + "src": "/icons/android-icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "density": "1.5" + }, + { + "src": "/icons/android-icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "density": "2.0" + }, + { + "src": "/icons/android-icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "density": "3.0" + }, + { + "src": "/icons/android-icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "density": "4.0" + } + ] +} diff --git a/.jjconflict-side-0/apps/client/scripts/create-collections.js b/.jjconflict-side-0/apps/client/scripts/create-collections.js new file mode 100644 index 0000000000..da1b4a6ef3 --- /dev/null +++ b/.jjconflict-side-0/apps/client/scripts/create-collections.js @@ -0,0 +1,18 @@ +#!env node +import path from 'node:path' + +import { createCustomCollectionFile } from '@gouvminint/vue-dsfr/meta' +import { Command } from 'commander' + +const program = new Command() + +program + .option('-s, --source ', 'Chemin vers le fichier de tuples [IconifyJSON, string[]]') + .option('-t, --target ', 'Chemin vers le fichier destination (src/icons.ts par défaut)') + .parse(process.argv) + +const options = program.opts() + +if (options.source && options.target) { + createCustomCollectionFile(path.resolve(process.cwd(), options.source), path.resolve(process.cwd(), options.target)) +} diff --git a/.jjconflict-side-0/apps/client/scripts/icons.js b/.jjconflict-side-0/apps/client/scripts/icons.js new file mode 100644 index 0000000000..c275232c2a --- /dev/null +++ b/.jjconflict-side-0/apps/client/scripts/icons.js @@ -0,0 +1,75 @@ +// @ts-check +import { icons as riCollection } from '@iconify-json/ri' + +/** + * @type {string[]} + */ +const riIconNames = [ + 'account-circle-line', + 'add-line', + 'admin-line', + 'arrow-drop-left-line', + 'arrow-drop-right-line', + 'arrow-go-back-line', + 'arrow-left-double-line', + 'arrow-right-double-line', + 'arrow-right-line', + 'arrow-right-s-line', + 'award-line', + 'building-line', + 'checkbox-blank-circle-fill', + 'checkbox-circle-line', + 'clipboard-line', + 'close-line', + 'code-s-slash-line', + 'dashboard-line', + 'delete-bin-7-line', + 'exchange-line', + 'eye-line', + 'eye-off-line', + 'file-download-line', + 'file-info-line', + 'filter-line', + 'filter-off-line', + 'flow-chart', + 'focus-3-line', + 'folder-line', + 'folder-shield-2-line', + 'folders-line', + 'folder-user-line', + 'github-line', + 'git-merge-line', + 'git-repository-private-line', + 'global-line', + 'key-2-line', + 'loader-4-line', + 'lock-line', + 'lock-unlock-line', + 'mail-line', + 'moon-clear-line', + 'newspaper-line', + 'pencil-line', + 'question-line', + 'refresh-line', + 'restart-line', + 'send-plane-line', + 'server-line', + 'settings-3-line', + 'shapes-line', + 'shield-check-line', + 'sound-module-line', + 'stock-line', + 'sun-line', + 'team-line', + 'tools-line', + 'upload-cloud-line', + 'user-add-line', + 'user-shared-2-line', +] + +/** + * @type {[import('@iconify/vue').IconifyJSON, string[]][]} + */ +export const collectionsToFilter = [ + [riCollection, riIconNames], +] diff --git a/.jjconflict-side-0/apps/client/src/App.vue b/.jjconflict-side-0/apps/client/src/App.vue new file mode 100644 index 0000000000..91e0e48742 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/App.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/api/xhr-client.ts b/.jjconflict-side-0/apps/client/src/api/xhr-client.ts new file mode 100644 index 0000000000..427c6b36ba --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/api/xhr-client.ts @@ -0,0 +1,57 @@ +import type { ApiFetcherArgs } from '@ts-rest/core' +import { logger } from '@cpn-console/logger/browser' +import { apiPrefix, getApiClient } from '@cpn-console/shared' +import { tsRestFetchApi } from '@ts-rest/core' +import { getKeycloak } from '@/utils/keycloak/keycloak.js' + +export const apiClient = await getApiClient( + '', + {}, + async (args: ApiFetcherArgs): Promise<{ status: number, body: { status: number, error: string } | unknown, headers: Headers }> => { + // Paths that do not require token + const validPaths = [`${apiPrefix}/version`, '/login', `${apiPrefix}/services`] + if (validPaths.some(validPath => args.path?.startsWith(validPath))) { + return tsRestFetchApi(args) + } + + // Envs that do not require token + if (process.env.NODE_ENV === 'test' && process.env.CT === 'true') { + return tsRestFetchApi(args) + } + + // Generate token + const keycloak = getKeycloak() + const token = keycloak.token + if (token) { + args.headers.Authorization = `Bearer ${token}` + } + + return tsRestFetchApi(args) + }, +) + +export function extractData(response: T, expectedStatus: S): Extract['body'] { + if (response.status === 401) { + const keycloak = getKeycloak() + if (!keycloak.authenticated) { + keycloak.login() + } + } + if (response.status >= 400) { + // `message` d'abord : les erreurs gérées du legacy (ErrorResType) et celles de NestJS + // (HttpException) portent le vrai message dans `message` — chez NestJS, `error` ne contient + // que la raison HTTP générique ("Forbidden", "Bad Request"). `error` reste en repli pour + // les erreurs non gérées du legacy (setErrorHandler), qui n'ont pas de champ `message`. + // @ts-ignore + throw new Error(response.body?.message ?? response.body?.error ?? 'Erreur inconnue') + } + if (response.status === expectedStatus) return response.body + try { + throw new Error(`Erreur lors de la requete, reçu ${response.status}, attendu ${expectedStatus}`) + } catch (error: unknown) { + if (error instanceof Error) { + logger.error({ err: error }, 'Request failed') + } + throw error + } +} diff --git a/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Bold.otf b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Bold.otf new file mode 100755 index 0000000000..4a051bcd7a Binary files /dev/null and b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Bold.otf differ diff --git a/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-BoldItalic.otf b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-BoldItalic.otf new file mode 100755 index 0000000000..d24f18d012 Binary files /dev/null and b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-BoldItalic.otf differ diff --git a/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-ExtraBold.otf b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-ExtraBold.otf new file mode 100755 index 0000000000..77b5d8ad1d Binary files /dev/null and b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-ExtraBold.otf differ diff --git a/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf new file mode 100755 index 0000000000..b5a48d1946 Binary files /dev/null and b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf differ diff --git a/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Light.otf b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Light.otf new file mode 100755 index 0000000000..5a7b8aad90 Binary files /dev/null and b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Light.otf differ diff --git a/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-LightItalic.otf b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-LightItalic.otf new file mode 100755 index 0000000000..15747f0a79 Binary files /dev/null and b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-LightItalic.otf differ diff --git a/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Medium.otf b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Medium.otf new file mode 100755 index 0000000000..cd15454d1b Binary files /dev/null and b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Medium.otf differ diff --git a/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-MediumItalic.otf b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-MediumItalic.otf new file mode 100755 index 0000000000..d7069ddb95 Binary files /dev/null and b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-MediumItalic.otf differ diff --git a/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Regular.otf b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Regular.otf new file mode 100755 index 0000000000..b8aa87d247 Binary files /dev/null and b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Regular.otf differ diff --git a/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-RegularItalic.otf b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-RegularItalic.otf new file mode 100755 index 0000000000..e0b98c8956 Binary files /dev/null and b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-RegularItalic.otf differ diff --git a/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Thin.otf b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Thin.otf new file mode 100755 index 0000000000..055d889cab Binary files /dev/null and b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-Thin.otf differ diff --git a/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-ThinItalic.otf b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-ThinItalic.otf new file mode 100755 index 0000000000..23fe8e8a97 Binary files /dev/null and b/.jjconflict-side-0/apps/client/src/assets/fonts/Marianne-ThinItalic.otf differ diff --git a/.jjconflict-side-0/apps/client/src/auto-imports.d.ts b/.jjconflict-side-0/apps/client/src/auto-imports.d.ts new file mode 100644 index 0000000000..4372345fd5 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/auto-imports.d.ts @@ -0,0 +1,173 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + const EffectScope: typeof import('vue')['EffectScope'] + const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] + const computed: typeof import('vue')['computed'] + const createApp: typeof import('vue')['createApp'] + const createPinia: typeof import('pinia')['createPinia'] + const customRef: typeof import('vue')['customRef'] + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] + const defineComponent: typeof import('vue')['defineComponent'] + const defineStore: typeof import('pinia')['defineStore'] + const effectScope: typeof import('vue')['effectScope'] + const getActivePinia: typeof import('pinia')['getActivePinia'] + const getCurrentInstance: typeof import('vue')['getCurrentInstance'] + const getCurrentScope: typeof import('vue')['getCurrentScope'] + const h: typeof import('vue')['h'] + const inject: typeof import('vue')['inject'] + const isProxy: typeof import('vue')['isProxy'] + const isReactive: typeof import('vue')['isReactive'] + const isReadonly: typeof import('vue')['isReadonly'] + const isRef: typeof import('vue')['isRef'] + const mapActions: typeof import('pinia')['mapActions'] + const mapGetters: typeof import('pinia')['mapGetters'] + const mapState: typeof import('pinia')['mapState'] + const mapStores: typeof import('pinia')['mapStores'] + const mapWritableState: typeof import('pinia')['mapWritableState'] + const markRaw: typeof import('vue')['markRaw'] + const nextTick: typeof import('vue')['nextTick'] + const onActivated: typeof import('vue')['onActivated'] + const onBeforeMount: typeof import('vue')['onBeforeMount'] + const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave'] + const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate'] + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] + const onDeactivated: typeof import('vue')['onDeactivated'] + const onErrorCaptured: typeof import('vue')['onErrorCaptured'] + const onMounted: typeof import('vue')['onMounted'] + const onRenderTracked: typeof import('vue')['onRenderTracked'] + const onRenderTriggered: typeof import('vue')['onRenderTriggered'] + const onScopeDispose: typeof import('vue')['onScopeDispose'] + const onServerPrefetch: typeof import('vue')['onServerPrefetch'] + const onUnmounted: typeof import('vue')['onUnmounted'] + const onUpdated: typeof import('vue')['onUpdated'] + const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] + const provide: typeof import('vue')['provide'] + const reactive: typeof import('vue')['reactive'] + const readonly: typeof import('vue')['readonly'] + const ref: typeof import('vue')['ref'] + const resolveComponent: typeof import('vue')['resolveComponent'] + const setActivePinia: typeof import('pinia')['setActivePinia'] + const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] + const shallowReactive: typeof import('vue')['shallowReactive'] + const shallowReadonly: typeof import('vue')['shallowReadonly'] + const shallowRef: typeof import('vue')['shallowRef'] + const storeToRefs: typeof import('pinia')['storeToRefs'] + const toRaw: typeof import('vue')['toRaw'] + const toRef: typeof import('vue')['toRef'] + const toRefs: typeof import('vue')['toRefs'] + const toValue: typeof import('vue')['toValue'] + const triggerRef: typeof import('vue')['triggerRef'] + const unref: typeof import('vue')['unref'] + const useAttrs: typeof import('vue')['useAttrs'] + const useCssModule: typeof import('vue')['useCssModule'] + const useCssVars: typeof import('vue')['useCssVars'] + const useId: typeof import('vue')['useId'] + const useLink: typeof import('vue-router')['useLink'] + const useModel: typeof import('vue')['useModel'] + const useRoute: typeof import('vue-router')['useRoute'] + const useRouter: typeof import('vue-router')['useRouter'] + const useScheme: typeof import('@gouvminint/vue-dsfr')['useScheme'] + const useSlots: typeof import('vue')['useSlots'] + const useTabs: typeof import('@gouvminint/vue-dsfr')['useTabs'] + const useTemplateRef: typeof import('vue')['useTemplateRef'] + const watch: typeof import('vue')['watch'] + const watchEffect: typeof import('vue')['watchEffect'] + const watchPostEffect: typeof import('vue')['watchPostEffect'] + const watchSyncEffect: typeof import('vue')['watchSyncEffect'] +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') +} + +// for vue template auto import +import { UnwrapRef } from 'vue' +declare module 'vue' { + interface GlobalComponents {} + interface ComponentCustomProperties { + readonly EffectScope: UnwrapRef + readonly acceptHMRUpdate: UnwrapRef + readonly computed: UnwrapRef + readonly createApp: UnwrapRef + readonly createPinia: UnwrapRef + readonly customRef: UnwrapRef + readonly defineAsyncComponent: UnwrapRef + readonly defineComponent: UnwrapRef + readonly defineStore: UnwrapRef + readonly effectScope: UnwrapRef + readonly getActivePinia: UnwrapRef + readonly getCurrentInstance: UnwrapRef + readonly getCurrentScope: UnwrapRef + readonly h: UnwrapRef + readonly inject: UnwrapRef + readonly isProxy: UnwrapRef + readonly isReactive: UnwrapRef + readonly isReadonly: UnwrapRef + readonly isRef: UnwrapRef + readonly mapActions: UnwrapRef + readonly mapGetters: UnwrapRef + readonly mapState: UnwrapRef + readonly mapStores: UnwrapRef + readonly mapWritableState: UnwrapRef + readonly markRaw: UnwrapRef + readonly nextTick: UnwrapRef + readonly onActivated: UnwrapRef + readonly onBeforeMount: UnwrapRef + readonly onBeforeRouteLeave: UnwrapRef + readonly onBeforeRouteUpdate: UnwrapRef + readonly onBeforeUnmount: UnwrapRef + readonly onBeforeUpdate: UnwrapRef + readonly onDeactivated: UnwrapRef + readonly onErrorCaptured: UnwrapRef + readonly onMounted: UnwrapRef + readonly onRenderTracked: UnwrapRef + readonly onRenderTriggered: UnwrapRef + readonly onScopeDispose: UnwrapRef + readonly onServerPrefetch: UnwrapRef + readonly onUnmounted: UnwrapRef + readonly onUpdated: UnwrapRef + readonly onWatcherCleanup: UnwrapRef + readonly provide: UnwrapRef + readonly reactive: UnwrapRef + readonly readonly: UnwrapRef + readonly ref: UnwrapRef + readonly resolveComponent: UnwrapRef + readonly setActivePinia: UnwrapRef + readonly setMapStoreSuffix: UnwrapRef + readonly shallowReactive: UnwrapRef + readonly shallowReadonly: UnwrapRef + readonly shallowRef: UnwrapRef + readonly storeToRefs: UnwrapRef + readonly toRaw: UnwrapRef + readonly toRef: UnwrapRef + readonly toRefs: UnwrapRef + readonly toValue: UnwrapRef + readonly triggerRef: UnwrapRef + readonly unref: UnwrapRef + readonly useAttrs: UnwrapRef + readonly useCssModule: UnwrapRef + readonly useCssVars: UnwrapRef + readonly useId: UnwrapRef + readonly useLink: UnwrapRef + readonly useModel: UnwrapRef + readonly useRoute: UnwrapRef + readonly useRouter: UnwrapRef + readonly useScheme: UnwrapRef + readonly useSlots: UnwrapRef + readonly useTabs: UnwrapRef + readonly useTemplateRef: UnwrapRef + readonly watch: UnwrapRef + readonly watchEffect: UnwrapRef + readonly watchPostEffect: UnwrapRef + readonly watchSyncEffect: UnwrapRef + } +} \ No newline at end of file diff --git a/.jjconflict-side-0/apps/client/src/components/AdminRoleForm.vue b/.jjconflict-side-0/apps/client/src/components/AdminRoleForm.vue new file mode 100644 index 0000000000..bf129c8ad7 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/AdminRoleForm.vue @@ -0,0 +1,304 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/Badge.vue b/.jjconflict-side-0/apps/client/src/components/Badge.vue new file mode 100644 index 0000000000..1dd98c5cda --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/Badge.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ChoiceSelector.vue b/.jjconflict-side-0/apps/client/src/components/ChoiceSelector.vue new file mode 100644 index 0000000000..b615ab6750 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ChoiceSelector.vue @@ -0,0 +1,272 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ClusterForm.vue b/.jjconflict-side-0/apps/client/src/components/ClusterForm.vue new file mode 100644 index 0000000000..81f51ebcaf --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ClusterForm.vue @@ -0,0 +1,532 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ConfigParam.vue b/.jjconflict-side-0/apps/client/src/components/ConfigParam.vue new file mode 100644 index 0000000000..b36d3820c7 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ConfigParam.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/components/DeleteForm.vue b/.jjconflict-side-0/apps/client/src/components/DeleteForm.vue new file mode 100644 index 0000000000..3716065a14 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/DeleteForm.vue @@ -0,0 +1,73 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/DeploymentCard.vue b/.jjconflict-side-0/apps/client/src/components/DeploymentCard.vue new file mode 100644 index 0000000000..cceb650e6f --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/DeploymentCard.vue @@ -0,0 +1,46 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/DeploymentModal.vue b/.jjconflict-side-0/apps/client/src/components/DeploymentModal.vue new file mode 100644 index 0000000000..2323b7cea9 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/DeploymentModal.vue @@ -0,0 +1,182 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/DeploymentRepoOption.vue b/.jjconflict-side-0/apps/client/src/components/DeploymentRepoOption.vue new file mode 100644 index 0000000000..0aba4f3fc3 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/DeploymentRepoOption.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/components/DeploymentRepoSelect.vue b/.jjconflict-side-0/apps/client/src/components/DeploymentRepoSelect.vue new file mode 100644 index 0000000000..9af01fb8c6 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/DeploymentRepoSelect.vue @@ -0,0 +1,65 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/DeploymentResources.vue b/.jjconflict-side-0/apps/client/src/components/DeploymentResources.vue new file mode 100644 index 0000000000..948c34c059 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/DeploymentResources.vue @@ -0,0 +1,96 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/DsoBadge.vue b/.jjconflict-side-0/apps/client/src/components/DsoBadge.vue new file mode 100644 index 0000000000..0f33785b50 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/DsoBadge.vue @@ -0,0 +1,52 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/DsoSnackbar.vue b/.jjconflict-side-0/apps/client/src/components/DsoSnackbar.vue new file mode 100644 index 0000000000..a91ff46ee8 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/DsoSnackbar.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/components/EnvironmentForm.vue b/.jjconflict-side-0/apps/client/src/components/EnvironmentForm.vue new file mode 100644 index 0000000000..1035945aca --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/EnvironmentForm.vue @@ -0,0 +1,396 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ErrorGoBackToProjects.vue b/.jjconflict-side-0/apps/client/src/components/ErrorGoBackToProjects.vue new file mode 100644 index 0000000000..3dc6c155f1 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ErrorGoBackToProjects.vue @@ -0,0 +1,25 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/Loader.vue b/.jjconflict-side-0/apps/client/src/components/Loader.vue new file mode 100644 index 0000000000..5ba76adde9 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/Loader.vue @@ -0,0 +1,21 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/LoadingCt.vue b/.jjconflict-side-0/apps/client/src/components/LoadingCt.vue new file mode 100644 index 0000000000..a36af47cc3 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/LoadingCt.vue @@ -0,0 +1,29 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/LogsViewer.vue b/.jjconflict-side-0/apps/client/src/components/LogsViewer.vue new file mode 100644 index 0000000000..2ce24862f4 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/LogsViewer.vue @@ -0,0 +1,218 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/OperationPanel.vue b/.jjconflict-side-0/apps/client/src/components/OperationPanel.vue new file mode 100644 index 0000000000..e308f41fb7 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/OperationPanel.vue @@ -0,0 +1,36 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/PaginationCt.vue b/.jjconflict-side-0/apps/client/src/components/PaginationCt.vue new file mode 100644 index 0000000000..ef0e1e2c73 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/PaginationCt.vue @@ -0,0 +1,74 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ProjectAction.vue b/.jjconflict-side-0/apps/client/src/components/ProjectAction.vue new file mode 100644 index 0000000000..e05dc0bbd2 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ProjectAction.vue @@ -0,0 +1,51 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ProjectArchiveButton.vue b/.jjconflict-side-0/apps/client/src/components/ProjectArchiveButton.vue new file mode 100644 index 0000000000..1d9fe1b0f5 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ProjectArchiveButton.vue @@ -0,0 +1,74 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ProjectBanner.vue b/.jjconflict-side-0/apps/client/src/components/ProjectBanner.vue new file mode 100644 index 0000000000..05ab11ea11 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ProjectBanner.vue @@ -0,0 +1,185 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ProjectClustersInfos.vue b/.jjconflict-side-0/apps/client/src/components/ProjectClustersInfos.vue new file mode 100644 index 0000000000..d7af0874ed --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ProjectClustersInfos.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ProjectLockButton.vue b/.jjconflict-side-0/apps/client/src/components/ProjectLockButton.vue new file mode 100644 index 0000000000..ac700f5cc4 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ProjectLockButton.vue @@ -0,0 +1,32 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ProjectLogsViewer.vue b/.jjconflict-side-0/apps/client/src/components/ProjectLogsViewer.vue new file mode 100644 index 0000000000..fba2129414 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ProjectLogsViewer.vue @@ -0,0 +1,47 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ProjectReplayButton.vue b/.jjconflict-side-0/apps/client/src/components/ProjectReplayButton.vue new file mode 100644 index 0000000000..efb2a90886 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ProjectReplayButton.vue @@ -0,0 +1,62 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ProjectResources.vue b/.jjconflict-side-0/apps/client/src/components/ProjectResources.vue new file mode 100644 index 0000000000..0c0d25e2a5 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ProjectResources.vue @@ -0,0 +1,478 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ProjectRoleForm.vue b/.jjconflict-side-0/apps/client/src/components/ProjectRoleForm.vue new file mode 100644 index 0000000000..d20f31c5d9 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ProjectRoleForm.vue @@ -0,0 +1,201 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ProjectRoles.vue b/.jjconflict-side-0/apps/client/src/components/ProjectRoles.vue new file mode 100644 index 0000000000..5760069994 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ProjectRoles.vue @@ -0,0 +1,162 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ProjectSecretsButton.vue b/.jjconflict-side-0/apps/client/src/components/ProjectSecretsButton.vue new file mode 100644 index 0000000000..05f1f423e4 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ProjectSecretsButton.vue @@ -0,0 +1,93 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ProjectSettings.vue b/.jjconflict-side-0/apps/client/src/components/ProjectSettings.vue new file mode 100644 index 0000000000..4bcd1dc66a --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ProjectSettings.vue @@ -0,0 +1,164 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ReloadPrompt.vue b/.jjconflict-side-0/apps/client/src/components/ReloadPrompt.vue new file mode 100644 index 0000000000..aa914e8ecb --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ReloadPrompt.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/components/RepoForm.vue b/.jjconflict-side-0/apps/client/src/components/RepoForm.vue new file mode 100644 index 0000000000..0a38b379cd --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/RepoForm.vue @@ -0,0 +1,397 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/SelectProject.vue b/.jjconflict-side-0/apps/client/src/components/SelectProject.vue new file mode 100644 index 0000000000..5c98b13e21 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/SelectProject.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ServiceChainForm.vue b/.jjconflict-side-0/apps/client/src/components/ServiceChainForm.vue new file mode 100644 index 0000000000..eec5f94223 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ServiceChainForm.vue @@ -0,0 +1,423 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ServicesConfig.vue b/.jjconflict-side-0/apps/client/src/components/ServicesConfig.vue new file mode 100644 index 0000000000..1cd4a36f9b --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ServicesConfig.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/components/SideMenu.vue b/.jjconflict-side-0/apps/client/src/components/SideMenu.vue new file mode 100644 index 0000000000..ecde0c9aef --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/SideMenu.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/components/StageForm.vue b/.jjconflict-side-0/apps/client/src/components/StageForm.vue new file mode 100644 index 0000000000..afca19b513 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/StageForm.vue @@ -0,0 +1,212 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/SuggestionInput.vue b/.jjconflict-side-0/apps/client/src/components/SuggestionInput.vue new file mode 100644 index 0000000000..fb704e409a --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/SuggestionInput.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/components/TeamCt.vue b/.jjconflict-side-0/apps/client/src/components/TeamCt.vue new file mode 100644 index 0000000000..d850569e77 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/TeamCt.vue @@ -0,0 +1,259 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/TokenForm.vue b/.jjconflict-side-0/apps/client/src/components/TokenForm.vue new file mode 100644 index 0000000000..2d743348ec --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/TokenForm.vue @@ -0,0 +1,159 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/components/ZoneForm.vue b/.jjconflict-side-0/apps/client/src/components/ZoneForm.vue new file mode 100644 index 0000000000..57c0b8db5d --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/components/ZoneForm.vue @@ -0,0 +1,225 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/env.d.ts b/.jjconflict-side-0/apps/client/src/env.d.ts new file mode 100644 index 0000000000..c725a2cc66 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/env.d.ts @@ -0,0 +1,37 @@ +/// +/// + +declare module 'virtual:pwa-register/vue' { + import type { Ref } from 'vue' + import type { RegisterSWOptions } from 'vite-plugin-pwa/types' + + export type { RegisterSWOptions } + + export function useRegisterSW(options?: RegisterSWOptions): { + needRefresh: Ref + offlineReady: Ref + updateServiceWorker: (reloadPage?: boolean) => Promise + } +} + +declare module 'vue3-json-viewer' { + import { AllowedComponentProps, App, Component, ComponentCustomProps, VNodeProps } from 'vue'; + + interface JsonViewerProps { + value: Record | Array | string | number | boolean; + expanded: boolean; + expandDepth: number; + copyable: boolean | object; + sort: boolean; + boxed: boolean; + theme: string; //"dark" | "light" + previewMode: boolean; + timeformat: (value: any) => string; + } + + type JsonViewerType = JsonViewerProps & VNodeProps & AllowedComponentProps & ComponentCustomProps; + const JsonViewer: Component; + export { JsonViewer }; + const def: { install: (app: App) => void }; + export default def; +} diff --git a/.jjconflict-side-0/apps/client/src/icon-collections.ts b/.jjconflict-side-0/apps/client/src/icon-collections.ts new file mode 100644 index 0000000000..de8e82fe94 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/icon-collections.ts @@ -0,0 +1,5 @@ +import type { IconifyJSON } from '@iconify/vue' + +const collections: IconifyJSON[] = [{ prefix: 'ri', icons: { 'account-circle-line': { body: '' }, 'add-line': { body: '' }, 'admin-line': { body: '' }, 'arrow-drop-left-line': { body: '' }, 'arrow-drop-right-line': { body: '' }, 'arrow-go-back-line': { body: '' }, 'arrow-left-double-line': { body: '' }, 'arrow-right-double-line': { body: '' }, 'arrow-right-line': { body: '' }, 'arrow-right-s-line': { body: '' }, 'award-line': { body: '' }, 'building-line': { body: '' }, 'checkbox-blank-circle-fill': { body: '' }, 'checkbox-circle-line': { body: '' }, 'clipboard-line': { body: '' }, 'close-line': { body: '' }, 'code-s-slash-line': { body: '' }, 'dashboard-line': { body: '' }, 'delete-bin-7-line': { body: '' }, 'exchange-line': { body: '' }, 'eye-line': { body: '' }, 'eye-off-line': { body: '' }, 'file-download-line': { body: '' }, 'file-info-line': { body: '' }, 'filter-line': { body: '' }, 'filter-off-line': { body: '' }, 'flow-chart': { body: '' }, 'focus-3-line': { body: '' }, 'folder-line': { body: '' }, 'folder-shield-2-line': { body: '' }, 'folder-user-line': { body: '' }, 'folders-line': { body: '' }, 'git-merge-line': { body: '' }, 'git-repository-private-line': { body: '' }, 'github-line': { body: '' }, 'global-line': { body: '' }, 'key-2-line': { body: '' }, 'loader-4-line': { body: '' }, 'lock-line': { body: '' }, 'lock-unlock-line': { body: '' }, 'mail-line': { body: '' }, 'moon-clear-line': { body: '' }, 'newspaper-line': { body: '' }, 'pencil-line': { body: '' }, 'question-line': { body: '' }, 'refresh-line': { body: '' }, 'restart-line': { body: '' }, 'send-plane-line': { body: '' }, 'server-line': { body: '' }, 'settings-3-line': { body: '' }, 'shapes-line': { body: '' }, 'shield-check-line': { body: '' }, 'sound-module-line': { body: '' }, 'stock-line': { body: '' }, 'sun-line': { body: '' }, 'team-line': { body: '' }, 'tools-line': { body: '' }, 'upload-cloud-line': { body: '' }, 'user-add-line': { body: '' }, 'user-shared-2-line': { body: '' } }, width: 24, height: 24 }] +export const ri = { accountCircleLine: 'ri:account-circle-line', addLine: 'ri:add-line', adminLine: 'ri:admin-line', arrowDropLeftLine: 'ri:arrow-drop-left-line', arrowDropRightLine: 'ri:arrow-drop-right-line', arrowGoBackLine: 'ri:arrow-go-back-line', arrowLeftDoubleLine: 'ri:arrow-left-double-line', arrowRightDoubleLine: 'ri:arrow-right-double-line', arrowRightLine: 'ri:arrow-right-line', arrowRightSLine: 'ri:arrow-right-s-line', awardLine: 'ri:award-line', buildingLine: 'ri:building-line', checkboxBlankCircleFill: 'ri:checkbox-blank-circle-fill', checkboxCircleLine: 'ri:checkbox-circle-line', clipboardLine: 'ri:clipboard-line', closeLine: 'ri:close-line', codeSSlashLine: 'ri:code-s-slash-line', dashboardLine: 'ri:dashboard-line', deleteBin7Line: 'ri:delete-bin-7-line', exchangeLine: 'ri:exchange-line', eyeLine: 'ri:eye-line', eyeOffLine: 'ri:eye-off-line', fileDownloadLine: 'ri:file-download-line', fileInfoLine: 'ri:file-info-line', filterLine: 'ri:filter-line', filterOffLine: 'ri:filter-off-line', flowChart: 'ri:flow-chart', focus3Line: 'ri:focus-3-line', folderLine: 'ri:folder-line', folderShield2Line: 'ri:folder-shield-2-line', folderUserLine: 'ri:folder-user-line', foldersLine: 'ri:folders-line', gitMergeLine: 'ri:git-merge-line', gitRepositoryPrivateLine: 'ri:git-repository-private-line', githubLine: 'ri:github-line', globalLine: 'ri:global-line', key2Line: 'ri:key-2-line', loader4Line: 'ri:loader-4-line', lockLine: 'ri:lock-line', lockUnlockLine: 'ri:lock-unlock-line', mailLine: 'ri:mail-line', moonClearLine: 'ri:moon-clear-line', newspaperLine: 'ri:newspaper-line', pencilLine: 'ri:pencil-line', questionLine: 'ri:question-line', refreshLine: 'ri:refresh-line', restartLine: 'ri:restart-line', sendPlaneLine: 'ri:send-plane-line', serverLine: 'ri:server-line', settings3Line: 'ri:settings-3-line', shapesLine: 'ri:shapes-line', shieldCheckLine: 'ri:shield-check-line', soundModuleLine: 'ri:sound-module-line', stockLine: 'ri:stock-line', sunLine: 'ri:sun-line', teamLine: 'ri:team-line', toolsLine: 'ri:tools-line', uploadCloudLine: 'ri:upload-cloud-line', userAddLine: 'ri:user-add-line', userShared2Line: 'ri:user-shared-2-line' } as const +export default collections diff --git a/.jjconflict-side-0/apps/client/src/main.css b/.jjconflict-side-0/apps/client/src/main.css new file mode 100644 index 0000000000..58f1adca7f --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/main.css @@ -0,0 +1,194 @@ +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-Regular.otf'); +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-RegularItalic.otf'); + font-style: italic; +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-Bold.otf'); + font-weight: 700; +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-BoldItalic.otf'); + font-weight: 700; + font-style: italic; +} + + +html, +body, +#app { + font-family: Marianne, sans-serif; + height: 100%; +} + +.h-full { + height: 100%; +} + +.disabled { + color: var(--text-disabled-grey); + cursor: not-allowed; +} + +.fr-fieldset { + margin: 1rem 0 0; + align-items: flex-start; + min-width: 0; +} + +.fr-checkbox-group { + margin-bottom: 1rem; +} + +.dso-footer.fr-footer { + box-shadow: inset 0 2px 0 0 var(--border-plain-blue-france); +} + +.dso-footer .fr-footer__bottom { + display: none; +} + +.dso-footer .fr-footer__content-desc { + text-align: right; + margin-right: 25px; +} + +.danger-zone { + border-bottom: solid var(--border-plain-warning); + margin-top: 2rem; + padding: 2rem; + background-color: var(--background-alt-grey); +} + +.fr-alert { + background-color: var(--background-alt-grey); +} + +.fr-alert pre { + white-space: normal; +} + +.log-panel, .log-btn { + background-color: var(--background-default-grey); +} + +.log-panel { + box-shadow: inset 0 0 0 1px var(--border-action-high-blue-france); +} + +.log-box--success{ + border-color: var(--background-contrast-success); +} + +.log-box--warning{ + border-color: var(--background-contrast-warning); +} + +.log-box--error{ + border-color: var(--background-contrast-error); +} + +.danger-zone-btns { + @apply flex justify-between items-center gap-4 { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + describe('detect project slug helper', () => { + let projectStore: ReturnType + let slug: string + let uuid: string + + beforeEach(() => { + projectStore = useProjectStore() + slug = faker.lorem.slug() + uuid = faker.string.uuid() + const recent = faker.date.recent() + const ownerId = faker.string.uuid() + const project: ProjectV2 = { + id: uuid, + clusterIds: [], + description: '', + everyonePerms: '0', + name: slug, + slug, + locked: false, + owner: { + type: 'human', + id: ownerId, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + createdAt: recent.toString(), + updatedAt: recent.toString(), + lastLogin: recent.toString(), + }, + ownerId, + roles: [], + members: [], + createdAt: recent.toString(), + updatedAt: recent.toString(), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + status: 'created', + lastSuccessProvisionningVersion: null, + } + projectStore.updateStore([project]) + }) + + it('it should return project\'slug with uuid passed', () => { + const slugFound = detectProjectslug({ + params: { + slug: uuid, + }, + }) + expect(slugFound).toEqual(slug) + }) + + it('it should return project\'slug with slug passed', () => { + const slugFound = detectProjectslug({ + params: { + slug, + }, + }) + expect(slugFound).toEqual(slug) + }) + }) + + describe('navigation with real router instance', () => { + it('renders home and navigates to projects', async () => { + const router = createAppRouter('') + const userStore = useUserStore() + const systemStore = useSystemSettingsStore() + + // Ensure global guard does not redirect to /login + // by simulating an authenticated user. + userStore.isLoggedIn = true + userStore.setIsLoggedIn = async () => {} + systemStore.listSystemSettings = async () => {} + router.push('/') + await router.isReady() + + expect(router.currentRoute.value.name).toEqual('Home') + + await router.push('/projects') + await router.isReady() + + expect(router.currentRoute.value.matched.some(r => r.name === 'Projects')).toBe(true) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/client/src/router/index.ts b/.jjconflict-side-0/apps/client/src/router/index.ts new file mode 100644 index 0000000000..002e8421a5 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/router/index.ts @@ -0,0 +1,326 @@ +import type { + RouteLocationNormalizedGeneric, + RouteRecordRaw, +} from 'vue-router' +import { logger } from '@cpn-console/logger/browser' +import { AdminAuthorized, swaggerUiPath } from '@cpn-console/shared' +import { + createRouter, + createWebHistory, +} from 'vue-router' +import { useProjectStore } from '@/stores/project.js' +import { useSystemSettingsStore } from '@/stores/system-settings.js' + +import { useUserStore } from '@/stores/user.js' +import { uuid } from '@/utils/regex.js' +import DsoHome from '@/views/DsoHome.vue' +import NotFound from '@/views/NotFound.vue' + +const AdminCluster = () => import('@/views/admin/AdminCluster.vue') +const AdminServiceChain = () => import('@/views/admin/AdminServiceChain.vue') +const ServicesHealth = () => import('@/views/ServicesHealth.vue') +const CreateProject = () => import('@/views/CreateProject.vue') +const ProfileWrapper = () => import('@/views/profile/ProfileWrapper.vue') +const UserInfo = () => import('@/views/profile/UserInfo.vue') +const PersonalAccessTokens = () => import('@/views/profile/PersonalAccessTokens.vue') +const DsoProjects = () => import('@/views/projects/DsoProjects.vue') +const DsoAdmin = () => import('@/views/admin/DsoAdmin.vue') +const ListUser = () => import('@/views/admin/ListUser.vue') +const ProjectDashboard = () => import('@/views/ProjectDashboard.vue') +const ListProjects = () => import('@/views/admin/ListProjects.vue') +const ListLogs = () => import('@/views/admin/ListLogs.vue') +const AdminRoles = () => import('@/views/admin/AdminRoles.vue') +const ListClusters = () => import('@/views/admin/ListClusters.vue') +const ListServiceChains = () => import('@/views/admin/ListServiceChains.vue') +const ListStages = () => import('@/views/admin/ListStages.vue') +const ListZones = () => import('@/views/admin/ListZones.vue') +const ListPlugins = () => import('@/views/admin/ListPlugins.vue') +const SystemSettings = () => import('@/views/admin/SystemSettings.vue') +const Maintenance = () => import('@/views/DsoMaintenance.vue') +const AdminTokens = () => import('@/views/admin/AdminTokens.vue') + +const MAIN_TITLE = 'Console Cloud π Native' + +export function detectProjectslug(to: Pick) { + const slugParam = to.params.slug as string + return uuid.test(slugParam) + ? useProjectStore().projects.find(project => project.id === slugParam)?.slug + : slugParam +} + +export const routes: Readonly = [ + { + path: '/login', + name: 'Login', + beforeEnter: async (_to, _from) => { + const userStore = useUserStore() + await userStore.login() + }, + component: DsoProjects, + }, + { + path: '/logout', + name: 'Logout', + beforeEnter: async (_to, _from) => { + const userStore = useUserStore() + await userStore.logout() + }, + component: DsoHome, + }, + { + path: '/', + name: 'Home', + component: DsoHome, + }, + { + path: '/profile', + name: 'Profile', + component: ProfileWrapper, + children: [ + { + path: 'info', + name: 'UserInfo', + component: UserInfo, + }, + { + path: 'tokens', + name: 'PersonalAccessTokens', + component: PersonalAccessTokens, + }, + ], + }, + { + path: '/maintenance', + name: 'Maintenance', + component: Maintenance, + }, + { + path: '/services-health', + name: 'ServicesHealth', + component: ServicesHealth, + }, + + { + path: '/projects', + children: [ + { + path: '', + name: 'Projects', + component: DsoProjects, + }, + { + path: ':slug', + name: 'Project', + component: ProjectDashboard, + async beforeEnter(to, _from, next) { + if (typeof to.params.slug !== 'string') { + return next({ name: 'Projects' }) + } + if (!(to.params.slug in useProjectStore().projectsBySlug)) { + await useProjectStore().getProject(to.params.slug).catch(async () => { + logger.warn({ slug: to.params.slug }, 'Unable to find project information, redirecting to /projects') + return next({ name: 'Projects' }) + }) + } + useProjectStore().lastSelectedProjectSlug = to.params.slug + return next() + }, + props(to) { + return { + projectSlug: detectProjectslug(to), + parentRoute: 'Projects', + asProfile: 'user', + tab: to.query.tab, + } + }, + }, + ], + }, + { + path: '/create-project', + name: 'CreateProject', + component: CreateProject, + }, + { + name: 'ParentAdmin', + path: '/admin', + component: DsoAdmin, + children: [ + { + path: 'users', + name: 'ListUser', + component: ListUser, + }, + { + path: 'projects', + children: [ + { + path: '', + name: 'ListProjects', + component: ListProjects, + }, + { + path: ':slug', + name: 'AdminProject', + component: ProjectDashboard, + async beforeEnter(to, _from, next) { + if (typeof to.params.slug !== 'string') { + return next('/admin/projects') + } + await useProjectStore().getProject(to.params.slug) + return next() + }, + props(to) { + return { + projectSlug: detectProjectslug(to), + parentRoute: 'ListProjects', + asProfile: 'admin', + } + }, + }, + ], + }, + { + path: 'service-chains', + children: [ + { + path: '', + name: 'ListServiceChains', + component: ListServiceChains, + }, + { + path: ':id', + name: 'AdminServiceChain', + component: AdminServiceChain, + props(to) { + return { + id: to.params.id, + } + }, + }, + ], + }, + { + path: 'logs', + name: 'ListLogs', + component: ListLogs, + }, + { + path: 'clusters', + children: [ + { + path: '', + name: 'ListClusters', + component: ListClusters, + }, + { + path: ':id', + name: 'AdminCluster', + component: AdminCluster, + props(to) { + return { + id: to.params.id, + } + }, + }, + ], + }, + { + path: 'stages', + name: 'ListStages', + component: ListStages, + }, + { + path: 'zones', + name: 'ListZones', + component: ListZones, + }, + { + path: 'plugins', + name: 'ListPlugins', + component: ListPlugins, + }, + { + path: 'system-settings', + name: 'SystemSettings', + component: SystemSettings, + }, + { + path: 'roles', + name: 'AdminRoles', + component: AdminRoles, + }, + { + path: 'tokens', + name: 'AdminTokens', + component: AdminTokens, + }, + ], + }, + { + path: `${swaggerUiPath}-server`, + name: 'Swagger-server', + // no component, swagger plugin + components: {}, + strict: false, + }, + { + path: `${swaggerUiPath}-server-nestjs`, + name: 'Swagger-server-nestjs', + // no component, swagger plugin + components: {}, + strict: false, + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: NotFound, + }, +] + +export function createAppRouter(base?: string) { + const router = createRouter({ + history: createWebHistory(base ?? (import.meta.env?.BASE_URL || '')), + scrollBehavior: (to) => { if (to.hash && !to.hash.startsWith('#state=')) return ({ el: to.hash }) }, + routes, + }) + router.beforeEach((to) => { + const specificTitle = to.meta.title ? `${to.meta.title} - ` : '' + document.title = `${specificTitle}${MAIN_TITLE}` + }) + router.beforeEach(async (to, _from, next) => { + const validPath = new Set(['Login', 'Home', 'Doc', 'NotFound', 'ServicesHealth', 'Maintenance', 'Logout', 'Swagger-server', 'Swagger-server-nestjs']) + const userStore = useUserStore() + const systemStore = useSystemSettingsStore() + await userStore.setIsLoggedIn() + if ( + !validPath.has(to.name?.toString() ?? '') + && !userStore.isLoggedIn + ) { + return next('/login') + } + if (to.name === 'Login' && userStore.isLoggedIn) { + return next('/') + } + if ( + !validPath.has(to.name?.toString() ?? '') + && userStore.isLoggedIn + ) { + await systemStore.listSystemSettings() + if (systemStore.systemSettingsByKey.maintenance?.value === 'on' && !AdminAuthorized.Manage(userStore.adminPerms)) return next('/maintenance') + } + next() + }) + return router +} + +const router = createAppRouter() + +export const isInProject = computed(() => router.currentRoute.value.matched.some(route => route.name === 'Project')) +export const selectedProjectSlug = computed(() => { + if (router.currentRoute.value.matched.some(route => route.name === 'Project')) { + return router.currentRoute.value.params.slug as string + } + return undefined +}) +export default router diff --git a/.jjconflict-side-0/apps/client/src/stores/admin-role.ts b/.jjconflict-side-0/apps/client/src/stores/admin-role.ts new file mode 100644 index 0000000000..fa70004e0f --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/admin-role.ts @@ -0,0 +1,61 @@ +import type { AdminRole, adminRoleContract } from '@cpn-console/shared' +import { AdminAuthorized } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '../api/xhr-client.js' +import { useUserStore } from './user.js' + +export const useAdminRoleStore = defineStore('adminRole', () => { + const userStore = useUserStore() + const roles = ref([]) + const memberCounts = ref>({}) + + const countMembersRoles = async () => { + memberCounts.value + = await apiClient.AdminRoles.adminRoleMemberCounts().then((res: any) => + extractData(res, 200), + ) + return memberCounts.value + } + + const listRoles = async () => { + roles.value = await apiClient.AdminRoles.listAdminRoles().then((response: any) => + extractData(response, 200), + ) + if (AdminAuthorized.ListRoles(userStore.adminPerms)) { + await countMembersRoles() + } + return roles.value + } + + const createRole = async () => { + roles.value = await apiClient.AdminRoles.createAdminRole({ + body: { name: 'Nouveau rôle' }, + }).then((res: any) => extractData(res, 201)) + } + + const deleteRole = async (roleId: AdminRole['id']) => { + await apiClient.AdminRoles.deleteAdminRole({ params: { roleId } }).then( + (res: any) => extractData(res, 204), + ) + await listRoles() + } + + const patchRoles = async ( + body: typeof adminRoleContract.patchAdminRoles.body._type, + ) => { + roles.value = await apiClient.AdminRoles.patchAdminRoles({ + body, + }).then((res: any) => extractData(res, 200)) + } + + return { + roles, + memberCounts, + countMembersRoles, + listRoles, + createRole, + deleteRole, + patchRoles, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/admin-token.spec.ts b/.jjconflict-side-0/apps/client/src/stores/admin-token.spec.ts new file mode 100644 index 0000000000..96a91a1b94 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/admin-token.spec.ts @@ -0,0 +1,57 @@ +import type { ExposedAdminToken } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useAdminTokenStore } from './admin-token.js' + +const apiClientGet = vi.spyOn(apiClient.AdminTokens, 'listAdminTokens') +const apiClientPost = vi.spyOn(apiClient.AdminTokens, 'createAdminToken') +const apiClientDelete = vi.spyOn(apiClient.AdminTokens, 'deleteAdminToken') + +describe('cluster Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get tokens list by api call', async () => { + const data = [] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminTokenStore = useAdminTokenStore() + + await adminTokenStore.listTokens() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should add token by api call', async () => { + const data: ExposedAdminToken = { + name: 'test2', + expirationDate: null, + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + lastUse: null, + password: 'password', + permissions: '2', + } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: data })) + const adminTokenStore = useAdminTokenStore() + + const res = await adminTokenStore.createToken({ name: data.name, permissions: data.permissions }) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should delete token by api call', async () => { + const tokenId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204 })) + const adminTokenStore = useAdminTokenStore() + + await adminTokenStore.deleteToken(tokenId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/admin-token.ts b/.jjconflict-side-0/apps/client/src/stores/admin-token.ts new file mode 100644 index 0000000000..3b76dc2fe8 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/admin-token.ts @@ -0,0 +1,33 @@ +import type { AdminToken, adminTokenContract } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '../api/xhr-client.js' + +export const useAdminTokenStore = defineStore('adminToken', () => { + const listTokens = async ( + query: typeof adminTokenContract.listAdminTokens.query._type = {}, + ) => { + return apiClient.AdminTokens.listAdminTokens({ query }).then( + (response: any) => extractData(response, 200), + ) + } + + const createToken = async ( + body: typeof adminTokenContract.createAdminToken.body._type, + ) => { + return apiClient.AdminTokens.createAdminToken({ body }).then((res: any) => + extractData(res, 201), + ) + } + + const deleteToken = async (tokenId: AdminToken['id']) => { + await apiClient.AdminTokens.deleteAdminToken({ params: { tokenId } }).then( + (res: any) => extractData(res, 204), + ) + } + + return { + listTokens, + createToken, + deleteToken, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/cluster.spec.ts b/.jjconflict-side-0/apps/client/src/stores/cluster.spec.ts new file mode 100644 index 0000000000..903c45450e --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/cluster.spec.ts @@ -0,0 +1,128 @@ +import { ClusterPrivacy } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useClusterStore } from './cluster.js' + +const apiClientGet = vi.spyOn(apiClient.Clusters, 'getClusterDetails') +const apiClientGetClusterEnvironments = vi.spyOn(apiClient.Clusters, 'getClusterEnvironments') +const apiClientPost = vi.spyOn(apiClient.Clusters, 'createCluster') +const apiClientPut = vi.spyOn(apiClient.Clusters, 'updateCluster') +const apiClientDelete = vi.spyOn(apiClient.Clusters, 'deleteCluster') + +describe('cluster Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get clusters list by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b7', + label: 'cluster2', + infos: 'infos', + kubeconfig: { + cluster: { + tlsServerName: '8790044f-8414-569f-9c4a-2df42a879879', + }, + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + }, + clusterResources: true, + privacy: 'public', + } + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + await adminClusterStore.getClusterDetails(data.id) + + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should get cluster\'s associated environments by api call', async () => { + const clusterId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + const data = { + environments: [], + } + + apiClientGetClusterEnvironments.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.getClusterAssociatedEnvironments(clusterId) + + expect(res).toBe(data) + expect(apiClientGetClusterEnvironments).toHaveBeenCalledTimes(1) + }) + + it('should add cluster by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + label: 'cluster1', + projectIds: [ + '22e7044f-8414-435d-9c4a-2df42a65034b', + ], + kubeconfig: { + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + }, + clusterResources: true, + privacy: ClusterPrivacy.DEDICATED, + } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.addCluster(data) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update cluster by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + projectIds: [ + '22e7044f-8414-435d-9c4a-2df42a65034b', + ], + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + clusterResources: true, + privacy: ClusterPrivacy.DEDICATED, + } + apiClientPut.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.updateCluster(data) + + expect(res).toBe(data) + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete cluster by api call', async () => { + const clusterId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204 })) + const adminClusterStore = useClusterStore() + + await adminClusterStore.deleteCluster(clusterId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/cluster.ts b/.jjconflict-side-0/apps/client/src/stores/cluster.ts new file mode 100644 index 0000000000..3f94777f2b --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/cluster.ts @@ -0,0 +1,65 @@ +import type { CleanedCluster, Cluster, CreateClusterBody, UpdateClusterBody } from '@cpn-console/shared' +import { + resourceListToDict, + +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useClusterStore = defineStore('cluster', () => { + const clusters = ref>([]) + const clustersById = computed(() => resourceListToDict(clusters.value)) + + const getClusters = async () => { + clusters.value = await apiClient.Clusters.listClusters().then( + (response: any) => extractData(response, 200), + ) + return clusters.value + } + + const getClusterDetails = async (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterDetails({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const getClusterUsage = async (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterUsage({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const getClusterAssociatedEnvironments = (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterEnvironments({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const addCluster = (cluster: CreateClusterBody) => + apiClient.Clusters.createCluster({ body: cluster }).then((response: any) => + extractData(response, 201), + ) + + const updateCluster = ({ + id, + ...body + }: UpdateClusterBody & { id: Cluster['id'] }) => + apiClient.Clusters.updateCluster({ body, params: { clusterId: id } }).then( + (response: any) => extractData(response, 200), + ) + + const deleteCluster = (clusterId: Cluster['id']) => + apiClient.Clusters.deleteCluster({ params: { clusterId } }).then( + (response: any) => extractData(response, 204), + ) + + return { + clusters, + clustersById, + getClusterAssociatedEnvironments, + addCluster, + updateCluster, + deleteCluster, + getClusterDetails, + getClusterUsage, + getClusters, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/log.spec.ts b/.jjconflict-side-0/apps/client/src/stores/log.spec.ts new file mode 100644 index 0000000000..e9b9d0b796 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/log.spec.ts @@ -0,0 +1,32 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useLogStore } from './log.js' + +const apiClientGet = vi.spyOn(apiClient.Logs, 'getLogs') + +describe('log Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get logs list and total by api call', async () => { + const data = { + total: 1, + logs: [ + { id: 'thisIsAnId', data: {}, action: 'Create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565' }, + ], + } + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminLogStore = useLogStore() + + await adminLogStore.getAllLogs({ offset: 5, limit: 10 }) + + expect(adminLogStore.count).toEqual(data.total) + expect(adminLogStore.logs).toStrictEqual(data.logs) + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/log.ts b/.jjconflict-side-0/apps/client/src/stores/log.ts new file mode 100644 index 0000000000..3b1266cc48 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/log.ts @@ -0,0 +1,30 @@ +import type { GetLogsQuery, Log } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useLogStore = defineStore('log', () => { + const logs = ref([]) + const count = ref(undefined) + const needRefresh = ref(false) + + const getAllLogs = async ({ offset, limit }: GetLogsQuery = { offset: 0, limit: 100 }) => { + const res = await apiClient.Logs.getLogs({ query: { offset, limit, clean: false } }) + .then((response: any) => extractData(response, 200)) + count.value = res.total + logs.value = res.logs as Log[] + } + + const listLogs = async ({ offset, limit, clean, projectId }: GetLogsQuery = { offset: 0, limit: 10 }) => { + return apiClient.Logs.getLogs({ query: { offset, limit, clean, projectId } }) + .then((response: any) => extractData(response, 200)) + } + + return { + logs, + count, + needRefresh, + getAllLogs, + listLogs, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/plugins.ts b/.jjconflict-side-0/apps/client/src/stores/plugins.ts new file mode 100644 index 0000000000..99dd9f7abd --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/plugins.ts @@ -0,0 +1,19 @@ +import type { PluginsUpdateBody, ProjectService } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const usePluginsConfigStore = defineStore('plugins', () => { + const services: ProjectService[] = [] + + const getPluginsConfig = () => apiClient.SystemPlugin.getPluginsConfig() + .then((response: any) => extractData(response, 200)) + + const updatePluginsConfig = (body: PluginsUpdateBody) => apiClient.SystemPlugin.updatePluginsConfig({ body }) + .then((response: any) => extractData(response, 204)) + + return { + services, + getPluginsConfig, + updatePluginsConfig, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/project.spec.ts b/.jjconflict-side-0/apps/client/src/stores/project.spec.ts new file mode 100644 index 0000000000..6e57c0e9c4 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/project.spec.ts @@ -0,0 +1,94 @@ +import { createRandomDbSetup, getRandomMember, getRandomProject } from '@cpn-console/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client' +import { useProjectStore } from './project' + +const getProject = vi.spyOn(apiClient.Projects, 'getProject') +const listProjects = vi.spyOn(apiClient.Projects, 'listProjects') +const listEnvironments = vi.spyOn(apiClient.Environments, 'listEnvironments') +const listRepositories = vi.spyOn(apiClient.Repositories, 'listRepositories') +const apiClientPost = vi.spyOn(apiClient.Projects, 'createProject') + +describe('project Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should retrieve projects by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.environments + delete randomDbSetup.project.repositories + delete randomDbSetup.project.clusters + const projects = [randomDbSetup.project] + + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: projects, headers: {} })) + + await projectStore.listProjects() + + expect(listProjects).toHaveBeenCalledTimes(1) + expect(projectStore.projects).toMatchObject(projects) + }) + + it('should retrieve user\'s projects by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.clusters + const projects = [randomDbSetup.project] + + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: projects, headers: {} })) + listEnvironments.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.environments, headers: {} })) + listRepositories.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.repositories, headers: {} })) + + await projectStore.listMyProjects() + + expect(listProjects).toHaveBeenCalledTimes(1) + }) + + it('should retrieve one project by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.clusters + const project = randomDbSetup.project + + getProject.mockReturnValueOnce(Promise.resolve({ status: 200, body: project, headers: {} })) + listEnvironments.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.environments, headers: {} })) + listRepositories.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.repositories, headers: {} })) + + await projectStore.getProject('foo') + + expect(getProject).toHaveBeenCalledTimes(1) + }) + + it('should create a project by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + const projects = [randomDbSetup.project] + const newProject = getRandomProject() + newProject.members = [getRandomMember(randomDbSetup.users[0].id, 'owner')] + + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: newProject, headers: {} })) + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: [...projects, newProject], headers: {} })) + + await projectStore.createProject(newProject) + + expect(apiClientPost).toHaveBeenCalledTimes(1) + expect(projectStore.myProjects).toHaveLength(1) + }) +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/project.ts b/.jjconflict-side-0/apps/client/src/stores/project.ts new file mode 100644 index 0000000000..44e59d56a8 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/project.ts @@ -0,0 +1,119 @@ +import type { + CreateProjectBody, + projectContract, + ProjectV2, +} from '@cpn-console/shared' +import pDebounce from 'p-debounce' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' +import { Project } from '@/utils/project-utils.js' +import { useUserStore } from './user.js' + +export const useProjectStore = defineStore('project', () => { + const userStore = useUserStore() + + const amIPartOf = (project: ProjectV2) => + project.status !== 'archived' + && (project.ownerId === userStore.userProfile?.id + || project.members.some( + member => member.userId === userStore.userProfile?.id, + )) + + // mostly for admin views + const projectsBySlug = ref>({}) + + const projects = computed(() => + Object.values(projectsBySlug.value).sort((p1, p2) => + p1.slug.localeCompare(p2.slug), + ), + ) + + const myProjects = computed(() => + Object.values(projectsBySlug.value).filter( + project => project.status !== 'archived' && amIPartOf(project), + ), + ) + + const updateStore = async (projectsRecieved: ProjectV2[]) => { + return projectsRecieved.map((project) => { + if (project.slug in projectsBySlug.value) { + return projectsBySlug.value[project.slug].Commands.updateData(project) + } + const newProject = new Project(project) + projectsBySlug.value[project.slug] = newProject + return newProject + }) + } + + const selectFromStore = (slugs: ProjectV2['slug'][]) => { + return slugs + .filter(slug => slug in projectsBySlug.value) + .map(slug => projectsBySlug.value[slug]) + } + + const getProject = async (projectId: ProjectV2['id']) => { + const res = await apiClient.Projects.getProject({ + params: { projectId }, + }).then((response: any) => extractData(response, 200)) + return (await updateStore([res]))[0] + } + + const listProjects = async ( + query: typeof projectContract.listProjects.query._type = { + filter: 'member', + statusNotIn: 'archived', + }, + ) => { + const res = await apiClient.Projects.listProjects({ query }).then( + (response: any) => extractData(response, 200), + ) + await updateStore(res) + return selectFromStore(res.map((project: any) => project.slug)) + } + + const listMyProjects = pDebounce(async () => { + const res = await apiClient.Projects.listProjects({ + query: { filter: 'member', statusNotIn: 'archived' }, + }).then((response: any) => extractData(response, 200)) + for (const storedProject of myProjects.value) { + if ( + !res.some((responseProject: any) => responseProject.id === storedProject.id) + ) { + delete projectsBySlug.value[storedProject.slug] + } + } + await updateStore(res) + return selectFromStore(res.map((project: any) => project.slug)) + }, 200) + + const createProject = async (body: CreateProjectBody) => { + const project = await apiClient.Projects.createProject({ body }).then( + (response: any) => extractData(response, 201), + ) + projectsBySlug.value[project.slug] = new Project(project) + return project + } + + const generateProjectsData = () => + apiClient.Projects.getProjectsData().then((response: any) => + extractData(response, 200), + ) + + // Should only be used for components and vue outside of Project route and its children, consider using the props instead + // Should only be update by beforeEnter() of Project route + const lastSelectedProjectSlug = ref() + + return { + projects, + projectsBySlug, + myProjects, + lastSelectedProjectSlug, + getProject, + listProjects, + listMyProjects, + createProject, + generateProjectsData, + updateStore, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/service-chain.ts b/.jjconflict-side-0/apps/client/src/stores/service-chain.ts new file mode 100644 index 0000000000..802a594e0d --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/service-chain.ts @@ -0,0 +1,53 @@ +import type { ServiceChain, ServiceChainList } from '@cpn-console/shared' +import { + resourceListToDict, + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useServiceChainStore = defineStore('serviceChain', () => { + const serviceChains = ref([]) + const serviceChainsById = computed(() => + resourceListToDict(serviceChains.value), + ) + + const getServiceChainsList = async () => { + serviceChains.value + = await apiClient.ServiceChains.listServiceChains().then((response: any) => + extractData(response, 200), + ) + return serviceChains.value + } + + const getServiceChainDetails = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.getServiceChainDetails({ + params: { serviceChainId }, + }).then((response: any) => + ServiceChainDetailsSchema.parse(extractData(response, 200)), + ) + + const getServiceChainFlows = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.getServiceChainFlows({ + params: { serviceChainId }, + }).then((response: any) => + ServiceChainFlowsSchema.parse(extractData(response, 200)), + ) + + const retryServiceChain = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.retryServiceChain({ + params: { serviceChainId }, + }) + + return { + serviceChains, + serviceChainsById, + getServiceChainDetails, + getServiceChainFlows, + getServiceChainsList, + retryServiceChain, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/services-monitor.spec.ts b/.jjconflict-side-0/apps/client/src/stores/services-monitor.spec.ts new file mode 100644 index 0000000000..a2d72c8417 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/services-monitor.spec.ts @@ -0,0 +1,60 @@ +import type { ServiceBody } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useServiceStore } from './services-monitor.js' + +const apiClientGet = vi.spyOn(apiClient.Services, 'getServiceHealth') + +describe('service Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get services health by api call (healthy)', async () => { + const data: ServiceBody = [ + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'OK', name: 'Keycloak', status: 'OK' }, + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'Service perdu', name: 'Gitlab', status: 'OK' }, + ] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const serviceStore = useServiceStore() + + expect(serviceStore.servicesHealth).toMatchObject({}) + expect(serviceStore.services).toMatchObject([]) + + await serviceStore.checkServicesHealth() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(serviceStore.servicesHealth).toMatchObject({ + message: 'Tous les services fonctionnent normalement', + serviceStatus: 'OK', + status: 'success', + }) + expect(serviceStore.services).toMatchObject(data) + }) + + it('should get services health by api call (unhealthy)', async () => { + const data = [ + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'OK', name: 'Keycloak', status: 'OK' }, + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'Service perdu', name: 'Gitlab', status: 'Inconnu' }, + ] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const serviceStore = useServiceStore() + + expect(serviceStore.servicesHealth).toMatchObject({}) + expect(serviceStore.services).toMatchObject([]) + + await serviceStore.checkServicesHealth() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(serviceStore.servicesHealth).toMatchObject({ + message: 'Échec lors de la dernière vérification', + serviceStatus: 'Inconnu', + status: undefined, + }) + expect(serviceStore.services).toMatchObject(data) + }) +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/services-monitor.ts b/.jjconflict-side-0/apps/client/src/stores/services-monitor.ts new file mode 100644 index 0000000000..d810ef328b --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/services-monitor.ts @@ -0,0 +1,125 @@ +import type { ServiceBody } from '@cpn-console/shared' +// @ts-ignore '@gouvminint/vue-dsfr' missing types +import type { DsfrAlertType } from '@gouvminint/vue-dsfr' +import { MonitorStatus } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export type ServicesHealth = { + message: string + status: DsfrAlertType | undefined + dotColor: 'gray' | 'red' | 'green' | 'orange' | 'blue' +} | Record + +interface ServiceHealthOption { message: string, status: DsfrAlertType | undefined, serviceStatus: MonitorStatus, dotColor: ServicesHealth['dotColor'] } + +export const alertTypeMapper: Record = { + [MonitorStatus.OK]: 'success', + [MonitorStatus.WARNING]: 'warning', + [MonitorStatus.ERROR]: 'error', + [MonitorStatus.UNKNOW]: undefined, +} + +const serviceHealthOptions = { + fetching: { + message: 'Vérification de l\'état des services...', + status: 'info', + serviceStatus: MonitorStatus.UNKNOW, + dotColor: 'blue', + }, + fetchError: { + message: 'Échec lors de la dernière vérification', + status: alertTypeMapper[MonitorStatus.UNKNOW], + serviceStatus: MonitorStatus.UNKNOW, + dotColor: 'gray', + }, + error: { + message: 'Un ou plusieurs services dysfonctionnent', + status: alertTypeMapper[MonitorStatus.ERROR], + serviceStatus: MonitorStatus.ERROR, + dotColor: 'red', + }, + warn: { + message: 'Un ou plusieurs services sont partiellement dégradés', + status: alertTypeMapper[MonitorStatus.WARNING], + serviceStatus: MonitorStatus.WARNING, + dotColor: 'orange', + }, + ok: { + message: 'Tous les services fonctionnent normalement', + status: alertTypeMapper[MonitorStatus.OK], + serviceStatus: MonitorStatus.OK, + dotColor: 'green', + }, +} as const satisfies Record + +export const useServiceStore = defineStore('serviceMonitor', () => { + const callStastus = ref<'ok' | 'fetching' | 'error'>('fetching') + + const displayCause = ref(false) + const services = ref([]) + + const serviceHealthIndex = computed(() => { + if (callStastus.value === 'fetching') { + return 'fetching' + } + if (services.value.some(({ status }) => status === 'Dégradé')) { + return 'warn' + } + if (services.value.some(({ status }) => status === 'En échec')) { + return 'error' + } + if (services.value.some(({ status }) => status === 'Inconnu')) { + return 'fetchError' + } + return 'ok' + }) + + const servicesHealth = computed(() => serviceHealthOptions[serviceHealthIndex.value]) + let interval: NodeJS.Timeout + + const clear = () => interval && clearInterval(interval) + + const checkServicesHealth = async () => { + callStastus.value = 'fetching' + try { + services.value = await (displayCause.value + ? apiClient.Services.getCompleteServiceHealth() + : apiClient.Services.getServiceHealth()) + .then((res: any) => extractData(res, 200)) + callStastus.value = 'ok' + } catch { + callStastus.value = 'error' + } + } + + const refreshServicesHealth = async () => { + await apiClient.Services.refreshServiceHealth() + .then((res: any) => extractData(res, 200)) + return checkServicesHealth() + } + + const startHealthPolling = async () => { + if (!interval) return + + clear() + await checkServicesHealth() + interval = setInterval(checkServicesHealth, 300_000_000) + } + + async function toggleDisplayCause() { + displayCause.value = !displayCause.value + await checkServicesHealth() + } + + return { + displayCause, + servicesHealth, + services, + checkServicesHealth, + refreshServicesHealth, + toggleDisplayCause, + startHealthPolling, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/snackbar.spec.ts b/.jjconflict-side-0/apps/client/src/stores/snackbar.spec.ts new file mode 100644 index 0000000000..4fe623ecb3 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/snackbar.spec.ts @@ -0,0 +1,155 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSnackbarStore } from './snackbar.js' + +describe('snackbar Store', () => { + beforeEach(() => { + vi.clearAllTimers() + vi.useFakeTimers() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should display message in snackbar with default values', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(6000) + + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display message in snackbar with custom timer', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message, 'info', 10000) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(6000) + + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(4000) + + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display message in snackbar with custom type', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + const type = 'warning' + snackbarStore.setMessage(message, type) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + }) + + it('should display message in snackbar and don\'t hide it if type error', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + const type = 'error' + snackbarStore.setMessage(message, type) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type) + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display last message in snackbar', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message1 = 'this is a message' + const type1 = 'warning' + snackbarStore.setMessage(message1, type1) + + expect(snackbarStore.message).toEqual(message1) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type1) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + const message2 = 'this is another message' + const type2 = 'info' + snackbarStore.setMessage(message2, type2) + + expect(snackbarStore.message).toEqual(message2) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type2) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + }) + + it('should hide message in snackbar', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + snackbarStore.hideMessage() + + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/snackbar.ts b/.jjconflict-side-0/apps/client/src/stores/snackbar.ts new file mode 100644 index 0000000000..badfb2e072 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/snackbar.ts @@ -0,0 +1,42 @@ +import type { ErrorTypes } from '@cpn-console/shared' +import type { Ref } from 'vue' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useSnackbarStore = defineStore('snackbar', () => { + const defaultTimeout: number = 6000 + const message: Ref = ref(undefined) + const isOpen: Ref = ref(false) + const type: Ref = ref('info') + const timeoutId: Ref | undefined> = ref(undefined) + const isWaitingForResponse = ref(false) + + const hideMessage = () => { + isOpen.value = false + clearTimeout(timeoutId.value) + timeoutId.value = undefined + } + + const setMessage = (errorMessage: string, errorType: ErrorTypes = 'info', timeout: number = defaultTimeout) => { + if (timeoutId.value) { + clearTimeout(timeoutId.value) + timeoutId.value = undefined + } + if (errorType !== 'error') { + timeoutId.value = setTimeout(hideMessage, timeout) + } + message.value = errorMessage + isOpen.value = true + type.value = errorType + } + + return { + message, + isOpen, + type, + timeoutId, + isWaitingForResponse, + setMessage, + hideMessage, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/stage.spec.ts b/.jjconflict-side-0/apps/client/src/stores/stage.spec.ts new file mode 100644 index 0000000000..542c5c4c1c --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/stage.spec.ts @@ -0,0 +1,96 @@ +import type { Stage } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useStageStore } from './stage.js' + +const apiClientListStages = vi.spyOn(apiClient.Stages, 'listStages') +const apiClientGet = vi.spyOn(apiClient.Stages, 'getStageEnvironments') +const apiClientPost = vi.spyOn(apiClient.Stages, 'createStage') +const apiClientPut = vi.spyOn(apiClient.Stages, 'updateStage') +const apiClientDelete = vi.spyOn(apiClient.Stages, 'deleteStage') + +describe('stage Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get stages list by api call', async () => { + const data = [ + { id: 'id1', name: 'dev' }, + { id: 'id2', name: 'int' }, + { id: 'id3', name: 'prod' }, + ] as Stage[] + apiClientListStages.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + await stageStore.getAllStages() + + expect(stageStore.stages).toEqual(data) + expect(apiClientListStages).toHaveBeenCalledTimes(1) + }) + + it('should get a stage\'s associated environments by api call', async () => { + const stageId = 'stageId' + const data = [ + { name: 'env1' }, + { name: 'env2' }, + { name: 'env3' }, + ] as { name: string, project: string, cluster: string, owner: string }[] + apiClientGet.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.getStageAssociatedEnvironments(stageId) + + expect(res).toBe(data) + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should create a stage by api call', async () => { + const data = { + name: 'int', + } as Stage + + apiClientPost.mockResolvedValueOnce({ status: 201, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.addStage(data) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update a stage\'s quotas and clusters associated by api call', async () => { + const stageId = 'stageId' + const quotaIds = ['stage1'] + const clusterIds = ['cluster1'] + const data = { + name: 'stageA', + quotaIds, + clusterIds, + id: stageId, + } + + apiClientPut.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.updateStage(stageId, { clusterIds, name: 'stageA' }) + + expect(res).toBe(data) + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete a stage by api call', async () => { + const stageId = 'stageId' + + apiClientDelete.mockResolvedValueOnce({ status: 204, body: null, headers: null }) + const stageStore = useStageStore() + + await stageStore.deleteStage(stageId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/stage.ts b/.jjconflict-side-0/apps/client/src/stores/stage.ts new file mode 100644 index 0000000000..de6d3824bf --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/stage.ts @@ -0,0 +1,45 @@ +import type { CreateStageBody, Stage, UpdateStageBody } from '@cpn-console/shared' +import { + + resourceListToDict, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useStageStore = defineStore('stage', () => { + const stages = ref([]) + const stagesById = computed(() => resourceListToDict(stages.value)) + + const getAllStages = async () => { + stages.value = await apiClient.Stages.listStages() + .then((response: any) => extractData(response, 200)) + return stages.value + } + + const getStageAssociatedEnvironments = (stageId: string) => + apiClient.Stages.getStageEnvironments({ params: { stageId } }) + .then((response: any) => extractData(response, 200)) + + const addStage = (body: CreateStageBody) => + apiClient.Stages.createStage({ body }) + .then((response: any) => extractData(response, 201)) + + const updateStage = (stageId: string, body: UpdateStageBody) => + apiClient.Stages.updateStage({ params: { stageId }, body }) + .then((response: any) => extractData(response, 200)) + + const deleteStage = (stageId: string) => + apiClient.Stages.deleteStage({ params: { stageId } }) + .then((response: any) => extractData(response, 204)) + + return { + stages, + stagesById, + getAllStages, + getStageAssociatedEnvironments, + addStage, + updateStage, + deleteStage, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/system-settings.spec.ts b/.jjconflict-side-0/apps/client/src/stores/system-settings.spec.ts new file mode 100644 index 0000000000..0cf6eca2eb --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/system-settings.spec.ts @@ -0,0 +1,55 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useSystemSettingsStore } from './system-settings.js' + +const listSystemSettings = vi.spyOn(apiClient.SystemSettings, 'listSystemSettings') +const upsertSystemSetting = vi.spyOn(apiClient.SystemSettings, 'upsertSystemSetting') + +describe('system Settings Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get system settings list by api call', async () => { + const data = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'miom' }, + ] + listSystemSettings.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const systemSettingsStore = useSystemSettingsStore() + + await systemSettingsStore.listSystemSettings() + + expect(systemSettingsStore.systemSettings).toEqual(data) + expect(listSystemSettings).toHaveBeenCalledTimes(1) + }) + + it('should upsert a system setting by api call', async () => { + const data = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'miom' }, + ] + const newSystemSetting = { key: 'organisation', value: 'mj' } + const newData = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'mj' }, + ] + + upsertSystemSetting.mockReturnValueOnce(Promise.resolve({ status: 201, body: newSystemSetting })) + const systemSettingsStore = useSystemSettingsStore() + systemSettingsStore.systemSettings = data + + const res = await systemSettingsStore.upsertSystemSetting(data) + + expect(res).toEqual(newSystemSetting) + expect(systemSettingsStore.systemSettings).toEqual(newData) + expect(upsertSystemSetting).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/system-settings.ts b/.jjconflict-side-0/apps/client/src/stores/system-settings.ts new file mode 100644 index 0000000000..8eebac262e --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/system-settings.ts @@ -0,0 +1,36 @@ +import type { + SystemSettings, + systemSettingsContract, + UpsertSystemSettingBody, +} from '@cpn-console/shared' +import { + resourceListToDictByKey, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useSystemSettingsStore = defineStore('systemSettings', () => { + const systemSettings = ref([]) + const systemSettingsByKey = computed(() => resourceListToDictByKey(systemSettings.value)) + + const listSystemSettings = async (query: typeof systemSettingsContract.listSystemSettings.query._type = {}) => { + systemSettings.value = await apiClient.SystemSettings.listSystemSettings(query) + .then((response: any) => extractData(response, 200)) + } + + const upsertSystemSetting = async (newSystemSetting: UpsertSystemSettingBody) => { + const res = await apiClient.SystemSettings.upsertSystemSetting({ body: newSystemSetting }) + .then((response: any) => extractData(response, 201)) + systemSettings.value = systemSettings.value + .toSpliced(systemSettings.value + .findIndex(systemSetting => systemSetting.key === res.key), 1, res) + return res + } + + return { + systemSettings, + systemSettingsByKey, + listSystemSettings, + upsertSystemSetting, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/token.ts b/.jjconflict-side-0/apps/client/src/stores/token.ts new file mode 100644 index 0000000000..d19b832333 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/token.ts @@ -0,0 +1,36 @@ +import type { + PersonalAccessToken, + personalAccessTokenContract, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '../api/xhr-client.js' + +export const useTokenStore = defineStore('token', () => { + const listPersonalAccessTokens = async () => { + return apiClient.PersonalAccessTokens.listPersonalAccessTokens().then( + (response: any) => extractData(response, 200), + ) + } + + const createPersonalAccessToken = async ( + body: typeof personalAccessTokenContract.createPersonalAccessToken.body._type, + ) => { + return apiClient.PersonalAccessTokens.createPersonalAccessToken({ + body, + }).then((res: any) => extractData(res, 201)) + } + + const deletePersonalAccessToken = async ( + tokenId: PersonalAccessToken['id'], + ) => { + await apiClient.PersonalAccessTokens.deletePersonalAccessToken({ + params: { tokenId }, + }).then((res: any) => extractData(res, 204)) + } + + return { + listPersonalAccessTokens, + createPersonalAccessToken, + deletePersonalAccessToken, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/user.spec.ts b/.jjconflict-side-0/apps/client/src/stores/user.spec.ts new file mode 100644 index 0000000000..8ca9e7a521 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/user.spec.ts @@ -0,0 +1,80 @@ +import Keycloak from 'keycloak-js' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useUserStore } from './user.js' + +const authMock = vi.spyOn(apiClient.Users, 'auth') +const listAdminRoles = vi.spyOn(apiClient.AdminRoles, 'listAdminRoles') + +authMock.mockResolvedValue(Promise.resolve({ status: 200, body: {} })) +listAdminRoles.mockResolvedValue(Promise.resolve({ status: 200, body: [] })) + +vi.mock('keycloak-js', () => { + const Keycloak = vi.fn() + Keycloak.prototype.authenticated = true + Keycloak.prototype.idTokenParsed = { + email: 'michel.michel@test.com', + sub: 'userId', + given_name: 'Michel', + family_name: 'MICHEL', + groups: ['/admin'], + } + Keycloak.prototype.login = vi.fn() + Keycloak.prototype.logout = vi.fn() + + return { default: Keycloak } +}) +const keycloak = new Keycloak() + +describe('user Store', () => { + beforeEach(() => { + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + afterEach(() => { + vi.clearAllMocks() + }) + + it('should retrieve isLoggedIn from Keycloak (true)', async () => { + const userStore = useUserStore() + + expect(userStore.isLoggedIn).toBeUndefined() + + await userStore.setIsLoggedIn() + + expect(userStore.isLoggedIn).toEqual(true) + }) + + it('should retrieve userProfile from Keycloak', async () => { + const userStore = useUserStore() + + expect(userStore.userProfile).toMatchObject({}) + + userStore.setUserProfile() + + expect(userStore.userProfile).toMatchObject({ + email: 'michel.michel@test.com', + id: 'userId', + firstName: 'Michel', + lastName: 'MICHEL', + }) + }) + + it('should call Keycloak login function', async () => { + const userStore = useUserStore() + + await userStore.login() + + expect(keycloak.login).toBeCalledTimes(1) + }) + + it('should call Keycloak logout function', async () => { + const userStore = useUserStore() + + await userStore.logout() + + expect(keycloak.logout).toBeCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/user.ts b/.jjconflict-side-0/apps/client/src/stores/user.ts new file mode 100644 index 0000000000..f860e6d037 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/user.ts @@ -0,0 +1,58 @@ +import type { AdminRole, User, UserProfile } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' +import { getKeycloak, getUserProfile, keycloakLogin, keycloakLogout } from '@/utils/keycloak/keycloak.js' +import { useAdminRoleStore } from './admin-role.js' +import { useSystemSettingsStore } from './system-settings.js' + +export const useUserStore = defineStore('user', () => { + const adminRoleStore = useAdminRoleStore() + const systemSettingsStore = useSystemSettingsStore() + const isLoggedIn = ref() + const userProfile = ref() + const apiAuthInfos = ref() + + const myAdminRoles = computed(() => adminRoleStore.roles?.filter(adminRole => apiAuthInfos.value?.adminRoleIds.includes(adminRole.id)) ?? []) + + const adminPerms = computed(() => { + if (!apiAuthInfos.value) return null + const globalRoles = adminRoleStore.roles?.filter(role => role.type === 'global') ?? [] + const globalPerms = globalRoles.reduce((acc, role) => acc | BigInt(role.permissions), 0n) + const adminPerms = myAdminRoles.value.reduce((acc, role) => acc | BigInt(role.permissions), 0n) + return globalPerms | adminPerms + }) + + const setUserProfile = async () => { + userProfile.value = getUserProfile() + await systemSettingsStore.listSystemSettings().catch(() => undefined) + await apiClient.Users.auth() + .then((res: any) => apiAuthInfos.value = extractData(res, 200)) + } + + const setIsLoggedIn = async () => { + const keycloak = getKeycloak() + if (keycloak.authenticated !== isLoggedIn.value) { + isLoggedIn.value = keycloak.authenticated + if (isLoggedIn.value) { + await setUserProfile() + } + } + } + + const login = () => keycloakLogin() + + const logout = () => keycloakLogout() + + return { + isLoggedIn, + setIsLoggedIn, + userProfile, + apiAuthInfos, + myAdminRoles, + adminPerms, + setUserProfile, + login, + logout, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/users.ts b/.jjconflict-side-0/apps/client/src/stores/users.ts new file mode 100644 index 0000000000..4bacc644da --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/users.ts @@ -0,0 +1,23 @@ +import type { userContract } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useUsersStore = defineStore('users', () => { + const listUsers = async (query: typeof userContract.getAllUsers.query._input) => + apiClient.Users.getAllUsers({ query }) + .then((res: any) => extractData(res, 200)) + + const listMatchingUsers = async (query: typeof userContract.getMatchingUsers.query._type) => + apiClient.Users.getMatchingUsers({ query }) + .then((res: any) => extractData(res, 200)) + + const patchUsers = async (body: typeof userContract.patchUsers.body._type) => + apiClient.Users.patchUsers({ body }) + .then((res: any) => extractData(res, 200)) + + return { + listUsers, + listMatchingUsers, + patchUsers, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/zone.spec.ts b/.jjconflict-side-0/apps/client/src/stores/zone.spec.ts new file mode 100644 index 0000000000..087ee45034 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/zone.spec.ts @@ -0,0 +1,67 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useZoneStore } from './zone.js' + +const apiClientGet = vi.spyOn(apiClient.Zones, 'listZones') +const apiClientPost = vi.spyOn(apiClient.Zones, 'createZone') +const apiClientPut = vi.spyOn(apiClient.Zones, 'updateZone') +const apiClientDelete = vi.spyOn(apiClient.Zones, 'deleteZone') + +describe('zone Store', () => { + beforeEach(() => { + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + afterEach(() => { + vi.clearAllMocks() + }) + + it('should retrieve all zones', async () => { + const zoneStore = useZoneStore() + + expect(zoneStore.zones).toEqual([]) + + const zones = [{ id: 'zoneId' }, { id: 'anotherZoneId' }] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: zones })) + + await zoneStore.getAllZones() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(zoneStore.zones).toMatchObject(zones) + }) + + it('should create a new zone', async () => { + const adminZoneStore = useZoneStore() + + const zone = { label: 'Zone à défendre', slug: 'zad' } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, data: zone })) + + await adminZoneStore.createZone(zone) + + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update a zone', async () => { + const adminZoneStore = useZoneStore() + + const zone = { id: 'zoneId', label: 'Zod à update' } + apiClientPut.mockReturnValueOnce(Promise.resolve({ status: 200, data: zone })) + + await adminZoneStore.updateZone(zone.id, zone) + + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete a zone', async () => { + const adminZoneStore = useZoneStore() + + const zoneId = 'zoneId' + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204, data: null })) + + await adminZoneStore.deleteZone(zoneId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-0/apps/client/src/stores/zone.ts b/.jjconflict-side-0/apps/client/src/stores/zone.ts new file mode 100644 index 0000000000..0c81a3e0f1 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/stores/zone.ts @@ -0,0 +1,40 @@ +import type { CreateZoneBody, UpdateZoneBody, Zone } from '@cpn-console/shared' +import { + + resourceListToDict, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useZoneStore = defineStore('zone', () => { + const zones = ref([]) + const zonesById = computed(() => resourceListToDict(zones.value)) + + const getAllZones = async () => { + zones.value = await apiClient.Zones.listZones() + .then((response: any) => extractData(response, 200)) + return zones.value + } + + const createZone = (body: CreateZoneBody) => + apiClient.Zones.createZone({ body }) + .then((response: any) => extractData(response, 201)) + + const updateZone = (zoneId: Zone['id'], data: UpdateZoneBody) => + apiClient.Zones.updateZone({ body: data, params: { zoneId } }) + .then((response: any) => extractData(response, 200)) + + const deleteZone = (zoneId: Zone['id']) => + apiClient.Zones.deleteZone({ params: { zoneId } }) + .then((response: any) => extractData(response, 204)) + + return { + zones, + zonesById, + getAllZones, + createZone, + updateZone, + deleteZone, + } +}) diff --git a/.jjconflict-side-0/apps/client/src/utils/env.ts b/.jjconflict-side-0/apps/client/src/utils/env.ts new file mode 100644 index 0000000000..189eb7143e --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/utils/env.ts @@ -0,0 +1,20 @@ +/* eslint-disable no-template-curly-in-string */ +export const serverHost: string = process.env.SERVER_HOST ?? '${SERVER_HOST}' + +export const serverPort: string = process.env.SERVER_PORT ?? '${SERVER_PORT}' + +export const clientPort: string = process.env.CLIENT_PORT ?? '${CLIENT_PORT}' + +export const keycloakProtocol: string = process.env.KEYCLOAK_PROTOCOL ?? '${KEYCLOAK_PROTOCOL}' + +export const keycloakDomain: string = process.env.KEYCLOAK_DOMAIN ?? '${KEYCLOAK_DOMAIN}' + +export const keycloakRealm: string = process.env.KEYCLOAK_REALM ?? '${KEYCLOAK_REALM}' + +export const keycloakClientId: string = process.env.KEYCLOAK_CLIENT_ID ?? '${KEYCLOAK_CLIENT_ID}' + +export const keycloakRedirectUri: string = process.env.KEYCLOAK_REDIRECT_URI ?? '${KEYCLOAK_REDIRECT_URI}' + +export const contactEmail: string = process.env.CONTACT_EMAIL ?? '${CONTACT_EMAIL}' + +export const openCDSEnabled: string = process.env.OPENCDS_ENABLED ?? '${OPENCDS_ENABLED}' diff --git a/.jjconflict-side-0/apps/client/src/utils/func.spec.ts b/.jjconflict-side-0/apps/client/src/utils/func.spec.ts new file mode 100644 index 0000000000..75ef96cead --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/utils/func.spec.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('localeParseFloat EN tests', () => { + let localeParseFloatEN: (s: string) => number + beforeEach(async () => { + vi.resetModules() + vi.resetAllMocks() + Object.defineProperty(navigator, 'language', { + value: 'en-EN', + configurable: true, + }); + ({ localeParseFloat: localeParseFloatEN } = await import('./func.js')) + }) + it('should parse invalid float FR in locale EN', async () => { + const result = localeParseFloatEN('4,25') + expect(result).toBe(425) + }) + + it('should parse valid float EN in locale EN', async () => { + const result = localeParseFloatEN('4.25') + expect(result).toBe(4.25) + }) +}) + +describe('localeParseFloat FR tests', () => { + let localeParseFloatFR: (s: string) => number + beforeEach(async () => { + vi.resetModules() + vi.resetAllMocks() + Object.defineProperty(navigator, 'language', { + value: 'fr-FR', + configurable: true, + }); + ({ localeParseFloat: localeParseFloatFR } = await import('./func.js')) + }) + it('should parse valid float FR in locale FR', async () => { + const result = localeParseFloatFR('4,25') + expect(result).toBe(4.25) + }) + it('should parse valid float EN in locale FR', async () => { + const result = localeParseFloatFR('4.25') + expect(result).toBe(4.25) + }) +}) diff --git a/.jjconflict-side-0/apps/client/src/utils/func.ts b/.jjconflict-side-0/apps/client/src/utils/func.ts new file mode 100644 index 0000000000..71f1a61b5e --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/utils/func.ts @@ -0,0 +1,103 @@ +import type { Ref } from 'vue' +import { useSnackbarStore } from '@/stores/snackbar.js' + +const LOCALE = navigator.language.slice(0, 2) +// Get the thousands and decimal separator characters used in the locale. +const [,THOUSANDS_SEPARATOR,,,,DECIMAL_SEPARATOR] = 1111.1.toLocaleString(LOCALE) +// 0.1 number as local string to use in labels and placeholders +export const ONE_TENTH_STR = 0.1.toLocaleString() + +export async function copyContent(content: string): Promise { + const snackbarStore = useSnackbarStore() + try { + await navigator.clipboard.writeText(content) + snackbarStore.setMessage('Donnée copiée', 'success') + } catch (error: any) { + snackbarStore.setMessage(error?.message, 'error') + } +} + +export function toCodeComponent(value: string) { + return { + component: 'code', + text: value, + title: 'Copier la valeur', + class: 'fr-text-default--info text-xs cursor-pointer', + onClick: () => copyContent(value), + } +} + +const maxDescriptionLength = 60 +export function truncateDescription(description: string) { + let innerHTML: string + + if (description.length <= maxDescriptionLength) { + innerHTML = description + } else { + const lastSpaceIndex = description.slice(0, maxDescriptionLength).lastIndexOf(' ') + innerHTML = `${description.slice(0, lastSpaceIndex > 0 ? lastSpaceIndex : maxDescriptionLength)} ...` + } + + return { + id: 'description', + 'data-testid': 'description', + component: 'span', + open: false, + title: description, + innerHTML, + } +} + +export function clickInDialog(e?: MouseEvent | TouchEvent, fn?: () => void) { + // @ts-ignore + if (e && e.target?.tagName !== 'DIALOG') { + return + } + fn?.() +} + +function randomId() { + String.fromCharCode(97) + return (Array.from({ length: 6 })).map(() => String.fromCharCode(97 + Math.floor(Math.random() * 26))) +} +export function getRandomId(suffix?: string, prefix?: string) { + return (prefix ? (`${prefix}-`) : '') + randomId() + (suffix ? (`-${suffix}`) : '') +} + +export function toKebabCase(value: string) { + return value + .trim() + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +/** + * Replace current locale separators occurences before parsing a floating Number. + * See https://stackoverflow.com/a/59679285 + * @param s String to parse + * @returns Number + */ +export function localeParseFloat(s: string): number { + // Remove thousand separators, and put a point where the decimal separator occurs + const delocalizedInput = s.replaceAll(THOUSANDS_SEPARATOR, '').replaceAll(DECIMAL_SEPARATOR, '.') + // Now it can be parsed + return Number.parseFloat(delocalizedInput) +} + +export async function scrollToFirstError(container: Ref) { + await nextTick() + + if (!container.value) return + + const firstErrorElement = container.value.querySelector('.fr-error-text, .fr-input-group--error') + + if (firstErrorElement) { + firstErrorElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }) + } +} diff --git a/.jjconflict-side-0/apps/client/src/utils/keycloak/keycloak.spec.ts b/.jjconflict-side-0/apps/client/src/utils/keycloak/keycloak.spec.ts new file mode 100644 index 0000000000..63760697cf --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/utils/keycloak/keycloak.spec.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getKeycloak, getUserProfile, keycloakInit, keycloakLogin, keycloakLogout } from './keycloak.js' + +const userToken = { + email: 'test@test.com', + sub: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + given_name: 'Jean', + family_name: 'DUPOND', +} +const userStored = { + email: 'test@test.com', + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', +} +const keycloak = getKeycloak() + +vi.mock('keycloak-js', () => { + class Keycloak { + idTokenParsed: Record + init: () => void + login: () => void + logout: () => void + + constructor() { + this.idTokenParsed = userToken + this.init = vi.fn() + this.login = vi.fn() + this.logout = vi.fn() + } + } + return { default: Keycloak } +}) + +const mockedKeycloakInit = vi.spyOn(keycloak, 'init') +const mockedKeycloakLogin = vi.spyOn(keycloak, 'login') +const mockedKeycloakLogout = vi.spyOn(keycloak, 'logout') + +describe('keycloak-init', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return Keycloak instance', () => { + const keycloak = getKeycloak() + + expect(keycloak).toBeInstanceOf(Object) + }) + + it('should return keycloak user profile', () => { + const keycloakUser = getUserProfile() + + expect(keycloakUser).toBeInstanceOf(Object) + expect(keycloakUser).toMatchObject(userStored) + }) + + it('should init keycloak', async () => { + await keycloakInit() + + expect(mockedKeycloakInit.mock.calls).toHaveLength(1) + }) + + it('should throw an error if keycloak can\'t be initialize', async () => { + const error = new Error('Failed to init keycloak') + try { + mockedKeycloakInit.mockReturnValueOnce(Promise.reject(error)) + await keycloakInit() + } catch (e) { + expect(mockedKeycloakInit.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) + + it('should start login process to keycloak', async () => { + await keycloakLogin() + + expect(mockedKeycloakLogin.mock.calls).toHaveLength(1) + }) + + it('should throw an error if login process to keycloak failed', async () => { + const error = new Error('Failed to start login process keycloak') + try { + mockedKeycloakLogin.mockReturnValueOnce(Promise.reject(error)) + await keycloakLogin() + } catch (e) { + expect(mockedKeycloakLogin.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) + + it('should start logout process to keycloak', async () => { + await keycloakLogout() + + expect(mockedKeycloakLogout.mock.calls).toHaveLength(1) + }) + + it('should throw an error if logout process to keycloak failed', async () => { + const error = new Error('Failed to start logout process keycloak') + try { + mockedKeycloakLogout.mockReturnValueOnce(Promise.reject(error)) + await keycloakLogout() + } catch (e) { + expect(mockedKeycloakLogout.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) +}) diff --git a/.jjconflict-side-0/apps/client/src/utils/keycloak/keycloak.ts b/.jjconflict-side-0/apps/client/src/utils/keycloak/keycloak.ts new file mode 100644 index 0000000000..2d36a097c5 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/utils/keycloak/keycloak.ts @@ -0,0 +1,101 @@ +import type { UserProfile } from '@cpn-console/shared' +import type { KeycloakConfig, KeycloakInitOptions } from 'keycloak-js' +import { logger } from '@cpn-console/logger/browser' +import Keycloak from 'keycloak-js' +import { + keycloakClientId, + keycloakDomain, + keycloakProtocol, + keycloakRealm, + keycloakRedirectUri, +} from '../env.js' + +export const keycloakInitOptions: KeycloakInitOptions = { + onLoad: 'check-sso', + flow: 'standard', + redirectUri: keycloakRedirectUri, +} + +export const keycloakConfig: KeycloakConfig = { + url: `${keycloakProtocol}://${keycloakDomain}`, + realm: keycloakRealm, + clientId: keycloakClientId, +} + +let keycloak: Keycloak + +export function getKeycloak() { + if (!keycloak) { + keycloak = new Keycloak(keycloakConfig) + keycloak.onAuthSuccess = () => { + if (!(keycloak.refreshTokenParsed?.exp && keycloak.tokenParsed?.exp && keycloak.refreshTokenParsed.exp > keycloak.tokenParsed.exp)) { + return + } + logger.warn('Keycloak misconfiguration: refreshToken should not expire before token') + const refreshTokenDelay = (keycloak.tokenParsed.exp * 1000 - Date.now()) / 2 + setTimeout(() => { + keycloak.updateToken() + }, refreshTokenDelay) + } + keycloak.onTokenExpired = () => { + keycloak.updateToken(30) + } + } + return keycloak +} + +export function getUserProfile(): UserProfile { + try { + const keycloak = getKeycloak() + const { email, sub: id, given_name: firstName, family_name: lastName, groups } = keycloak.idTokenParsed as { email: string, sub: string, given_name: string, firstName: string, family_name: string, lastName: string, groups: string[] } + return { + email, + id, + firstName, + lastName, + groups, + } + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de récupération du profil keycloak de l\'utilisateur') + } +} + +export async function keycloakInit() { + const currentUrl = new URL(window.location.href) + const redirectUri = `${window.location.origin}${currentUrl.pathname}${currentUrl.search}` + try { + const { onLoad, flow } = keycloakInitOptions + const keycloak = getKeycloak() + await keycloak.init({ + onLoad, + flow, + redirectUri, + }) + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec d\'initialisation du keycloak') + } +} + +export async function keycloakLogin() { + try { + const keycloak = getKeycloak() + const currentUrl = new URL(window.location.href) + const redirectUri = `${window.location.origin}${currentUrl.pathname}${currentUrl.search}` + await keycloak.login({ redirectUri }) + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de connexion au keycloak') + } +} + +export async function keycloakLogout() { + try { + const keycloak = getKeycloak() + await keycloak.logout() + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de déconnexion du keycloak') + } +} diff --git a/.jjconflict-side-0/apps/client/src/utils/project-utils.ts b/.jjconflict-side-0/apps/client/src/utils/project-utils.ts new file mode 100644 index 0000000000..e45746f660 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/utils/project-utils.ts @@ -0,0 +1,414 @@ +import type { + CreateDeploymentBody, + CreateEnvironmentBody, + CreateRepositoryBody, + Deployment, + Environment, + GetLogsQuery, + PermissionTarget, + PluginsUpdateBody, + projectContract, + projectMemberContract, + projectRoleContract, + ProjectService, + ProjectV2, + Repo, + RepositoryParams, + Role, + UpdateDeploymentBody, + UpdateEnvironmentBody, + UpdateRepositoryBody, + User, +} from '@cpn-console/shared' +import type { Ref } from 'vue' +import { + getPermsByUserRoles, + objectKeys, + PROJECT_PERMS, + resourceListToDict, +} from '@cpn-console/shared' + +import { + apiClient, + extractData, +} from '@/api/xhr-client.js' +import { useLogStore } from '@/stores/log.js' +import { useUserStore } from '@/stores/user.js' +import { getRandomId } from './func.js' + +export type ProjectOperations = 'create' + | 'delete' + | 'deploymentManagement' + | 'envManagement' + | 'repoManagement' + | 'teamManagement' + | 'searchSecret' + | 'replay' + | 'update' + | 'lockHandling' + | 'saveServices' + | 'roleManagement' + +function calculateProjectPerms(project: ProjectV2 | undefined, userId: string | undefined) { + if (!project || !userId) return 0n + if (userId === project?.ownerId) return PROJECT_PERMS.MANAGE + const selfMember = project.members.find(member => member.userId === userId) + if (!selfMember) return 0n + + return getPermsByUserRoles(selfMember.roleIds, resourceListToDict(project.roles), project.everyonePerms) +} + +export class Project implements ProjectV2 { + id: string + clusterIds: string[] + description: string | undefined + everyonePerms: string + name: string + slug: string + locked: boolean + owner: Omit + ownerId: string + roles: { id: string, name: string, permissions: string, position: number, projectId: string, oidcGroup?: string, type?: string }[] + members: ({ userId: string, firstName: string, lastName: string, email: string, roleIds: string[] } | { updatedAt: string, createdAt: string, firstName: string, lastName: string, email: string, userId: string, roleIds: string[] })[] + createdAt: string + updatedAt: string + limitless: boolean + hprodMemory: number + hprodCpu: number + hprodGpu: number + prodMemory: number + prodCpu: number + prodGpu: number + status: ProjectV2['status'] + operationsInProgress: Ref + myPerms: bigint + repositories: Ref + environments: Ref + deployments: Ref + services: ProjectService[] = [] + lastSuccessProvisionningVersion: string | null + needReplay: Ref + + constructor(project: ProjectV2) { + this.id = project.id + this.clusterIds = project.clusterIds + this.description = project.description + this.everyonePerms = project.everyonePerms + this.name = project.name + this.slug = project.slug + this.locked = project.locked + this.owner = project.owner + this.ownerId = project.ownerId + this.roles = project.roles + this.members = project.members + this.createdAt = project.createdAt + this.updatedAt = project.updatedAt + this.limitless = project.limitless + this.hprodMemory = project.hprodMemory + this.hprodCpu = project.hprodCpu + this.hprodGpu = project.hprodGpu + this.prodMemory = project.prodMemory + this.prodCpu = project.prodCpu + this.prodGpu = project.prodGpu + this.lastSuccessProvisionningVersion = project.lastSuccessProvisionningVersion + this.status = project.status + this.myPerms = calculateProjectPerms(this, useUserStore().userProfile?.id) + this.operationsInProgress = ref([]) + this.environments = ref([]) + this.repositories = ref([]) + this.needReplay = ref(false) + this.deployments = ref([]) + } + + private removeOperation(operationName: ProjectOperations) { + setTimeout(() => { + useLogStore().needRefresh = true + }, 100) + this.operationsInProgress.value = this.operationsInProgress.value.filter(op => op !== operationName) + return this.operationsInProgress.value + } + + private addOperation(operationName: ProjectOperations): () => void { + this.needReplay.value = ['teamManagement', 'saveServices', 'roleManagement'].includes(operationName) + if (this.operationsInProgress.value.includes(operationName)) { + operationName += getRandomId() + } + if (this.operationsInProgress.value.length <= 1) { + this.operationsInProgress.value.push(operationName) + } else { + return () => {} + } + return () => { this.removeOperation(operationName) } + } + + private computePerms() { + this.myPerms = calculateProjectPerms(this, useUserStore().userProfile?.id) + } + + Commands = { + update: async (data: typeof projectContract.updateProject.body._type) => { + const callback = this.addOperation('update') + try { + const project = await apiClient.Projects.updateProject({ body: data, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + .finally(() => callback()) + return this.Commands.updateData(project) + } finally { + callback() + } + }, + updateData: (project: Partial) => { + for (const key of objectKeys(project)) { + // @ts-ignore + this[key] = project[key] + } + this.computePerms() + return undefined + }, + refresh: async () => { + const project = await apiClient.Projects.getProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.Commands.updateData(project) + await Promise.all([ + this.Repositories.list(), + this.Environments.list(), + ]) + return undefined + }, + replay: async () => { + const callback = this.addOperation('update') + try { + await apiClient.Projects.replayHooksForProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 204)) + return this.Commands.refresh() + } finally { + callback() + } + }, + delete: async () => { + const callback = this.addOperation('delete') + try { + await apiClient.Projects.archiveProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 204)) + this.status = 'archived' + } catch { + await this.Commands.refresh() + } finally { + callback() + } + }, + } + + Members = { + list: async () => { + this.members = await apiClient.ProjectsMembers.listMembers({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.members + }, + create: async (email: string) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.addMember({ params: { projectId: this.id }, body: { email } }) + .then((response: any) => extractData(response, 201)) + return this.Members.list() + } finally { callback() } + }, + delete: async (userId: User['id']) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.removeMember({ params: { projectId: this.id, userId } }) + .then((response: any) => extractData(response, 200)) + return this.Members.list() + } finally { callback() } + }, + patch: async (body: typeof projectMemberContract.patchMembers.body._type) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.patchMembers({ params: { projectId: this.id }, body }) + .then((response: any) => extractData(response, 200)) + return this.Members.list() + } finally { callback() } + }, + getCandidateUsers: async (letters: string) => { + return apiClient.Users.getMatchingUsers({ query: { letters, notInProjectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + } + + Deployments = { + list: async () => { + this.deployments.value = await apiClient.Deployments.listDeployments({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.deployments.value + }, + create: async (deploymentData: Omit) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.createDeployment({ + params: { projectId: this.id }, + body: { ...deploymentData, projectId: this.id }, + }) + .then((response: any) => extractData(response, 201)) + } finally { callback() } + }, + update: async (id: Deployment['id'], deployment: UpdateDeploymentBody) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.updateDeployment({ + params: { projectId: this.id, deploymentId: id }, + body: deployment, + }) + .then((response: any) => extractData(response, 200)) + } finally { callback() } + }, + delete: async (deploymentId: Deployment['id']) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.deleteDeployment({ + params: { projectId: this.id, deploymentId }, + }) + .then((response: any) => extractData(response, 204)) + } finally { callback() } + }, + } + + Environments = { + list: async () => { + this.environments.value = await apiClient.Environments.listEnvironments({ query: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.environments.value + }, + create: async (envData: Omit) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.createEnvironment({ body: { ...envData, projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + return this.Environments.list() + } finally { callback() } + }, + update: async (id: Environment['id'], environment: UpdateEnvironmentBody) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.updateEnvironment({ body: environment, params: { environmentId: id } }) + .then((response: any) => extractData(response, 200)) + await this.Environments.list() + return this.environments + } finally { callback() } + }, + delete: async (environmentId: Environment['id']) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.deleteEnvironment({ params: { environmentId } }) + .then((response: any) => extractData(response, 204)) + await this.Environments.list() + return this.environments + } finally { callback() } + }, + } + + Repositories = { + list: async () => { + this.repositories.value = await apiClient.Repositories.listRepositories({ query: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.repositories.value + }, + sync: async (repositoryId: Repo['id'], { branchName, syncAllBranches = false }: { branchName?: string, syncAllBranches?: boolean }) => { + const callback = this.addOperation('repoManagement') + try { + return apiClient.Repositories.syncRepository({ + params: { repositoryId }, + body: { branchName, syncAllBranches }, + }) + .then((response: any) => extractData(response, 204)) + } finally { callback() } + }, + create: async (repoData: Omit) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.createRepository({ body: { ...repoData, projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + return this.Repositories.list() + } finally { callback() } + }, + update: async (id: RepositoryParams['repositoryId'], repoData: Omit) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.updateRepository({ body: { ...repoData, projectId: this.id }, params: { repositoryId: id } }) + .then((response: any) => extractData(response, 200)) + return this.Repositories.list() + } finally { callback() } + }, + delete: async (repositoryId: Repo['id']) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.deleteRepository({ params: { repositoryId } }) + .then((response: any) => extractData(response, 204)) + return this.Repositories.list() + } finally { callback() } + }, + } + + Roles = { + countMembers: async () => { + return apiClient.ProjectsRoles.projectRoleMemberCounts({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + list: async () => { + this.roles = await apiClient.ProjectsRoles.listProjectRoles({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.computePerms() + return this.roles + }, + patch: async (body: typeof projectRoleContract.patchProjectRoles.body._type) => { + const callback = this.addOperation('roleManagement') + try { + this.roles = await apiClient.ProjectsRoles.patchProjectRoles({ body, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.computePerms() + return this.roles + } finally { callback() } + }, + create: async (body: typeof projectRoleContract.createProjectRole.body._type) => { + this.roles = await apiClient.ProjectsRoles.createProjectRole({ body, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + this.computePerms() + return this.roles + }, + delete: async (roleId: Role['id']) => { + const callback = this.addOperation('roleManagement') + try { + await apiClient.ProjectsRoles.deleteProjectRole({ params: { projectId: this.id, roleId } }) + .then((response: any) => extractData(response, 204)) + this.computePerms() + return this.Roles.list() + } finally { callback() } + }, + } + + Services = { + getSecrets: async () => { + return apiClient.Projects.getProjectSecrets({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + list: async (permissionTarget: PermissionTarget = 'user') => { + this.services = await apiClient.ProjectServices.getServices({ params: { projectId: this.id }, query: { permissionTarget } }) + .then((response: any) => extractData(response, 200)) + return this.services + }, + update: async (body: PluginsUpdateBody) => { + const callback = this.addOperation('saveServices') + try { + await apiClient.ProjectServices.updateProjectServices({ params: { projectId: this.id }, body }) + .then((response: any) => extractData(response, 204)) + return this.Services.list() + } finally { callback() } + }, + } + + Logs = { + list: async ({ offset, limit, clean }: GetLogsQuery = { offset: 0, limit: 10 }) => { + return apiClient.Logs.getLogs({ query: { offset, limit, clean, projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + } +} diff --git a/.jjconflict-side-0/apps/client/src/utils/regex.spec.ts b/.jjconflict-side-0/apps/client/src/utils/regex.spec.ts new file mode 100644 index 0000000000..353338ee8c --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/utils/regex.spec.ts @@ -0,0 +1,25 @@ +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { email, noSpace, uuid } from '@/utils/regex.js' + +describe('regex', () => { + it('should return false (noSpace)', () => { + expect(noSpace.test('te st')).toStrictEqual(false) + }) + it('should return true (noSpace)', () => { + expect(noSpace.test('test')).toStrictEqual(true) + }) + + it('should return false (email)', () => { + expect(email.test('prenom.nom')).toStrictEqual(false) + }) + it('should return true (email)', () => { + expect(email.test('prenom.nom@interieur.gouv.fr')).toStrictEqual(true) + }) + it('should return true (uuid)', () => { + expect(uuid.test(faker.string.uuid())).toStrictEqual(true) + }) + it('should return false (uuid)', () => { + expect(uuid.test('abc')).toStrictEqual(false) + }) +}) diff --git a/.jjconflict-side-0/apps/client/src/utils/regex.ts b/.jjconflict-side-0/apps/client/src/utils/regex.ts new file mode 100644 index 0000000000..38ded36071 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/utils/regex.ts @@ -0,0 +1,5 @@ +export const noSpace: RegExp = /^\S*$/ + +export const email: RegExp = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/ + +export const uuid: RegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i diff --git a/.jjconflict-side-0/apps/client/src/views/CreateProject.vue b/.jjconflict-side-0/apps/client/src/views/CreateProject.vue new file mode 100644 index 0000000000..797df6602d --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/CreateProject.vue @@ -0,0 +1,148 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/DsoHome.vue b/.jjconflict-side-0/apps/client/src/views/DsoHome.vue new file mode 100644 index 0000000000..6e141e518b --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/DsoHome.vue @@ -0,0 +1,183 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/DsoMaintenance.vue b/.jjconflict-side-0/apps/client/src/views/DsoMaintenance.vue new file mode 100644 index 0000000000..e220e5965c --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/DsoMaintenance.vue @@ -0,0 +1,36 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/NotFound.vue b/.jjconflict-side-0/apps/client/src/views/NotFound.vue new file mode 100644 index 0000000000..a0e59fb9cf --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/NotFound.vue @@ -0,0 +1,22 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/ProjectDashboard.vue b/.jjconflict-side-0/apps/client/src/views/ProjectDashboard.vue new file mode 100644 index 0000000000..d411ed7e78 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/ProjectDashboard.vue @@ -0,0 +1,344 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/ServicesHealth.vue b/.jjconflict-side-0/apps/client/src/views/ServicesHealth.vue new file mode 100644 index 0000000000..ca9f021061 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/ServicesHealth.vue @@ -0,0 +1,107 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/admin/AdminCluster.vue b/.jjconflict-side-0/apps/client/src/views/admin/AdminCluster.vue new file mode 100644 index 0000000000..fe87036105 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/admin/AdminCluster.vue @@ -0,0 +1,101 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/admin/AdminRoles.vue b/.jjconflict-side-0/apps/client/src/views/admin/AdminRoles.vue new file mode 100644 index 0000000000..3c4357c340 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/admin/AdminRoles.vue @@ -0,0 +1,127 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/admin/AdminServiceChain.vue b/.jjconflict-side-0/apps/client/src/views/admin/AdminServiceChain.vue new file mode 100644 index 0000000000..cee1e4baa2 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/admin/AdminServiceChain.vue @@ -0,0 +1,42 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/admin/AdminTokens.vue b/.jjconflict-side-0/apps/client/src/views/admin/AdminTokens.vue new file mode 100644 index 0000000000..e4cb9e9e6c --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/admin/AdminTokens.vue @@ -0,0 +1,147 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/admin/DsoAdmin.vue b/.jjconflict-side-0/apps/client/src/views/admin/DsoAdmin.vue new file mode 100644 index 0000000000..415c8d7276 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/admin/DsoAdmin.vue @@ -0,0 +1,14 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/admin/ListClusters.vue b/.jjconflict-side-0/apps/client/src/views/admin/ListClusters.vue new file mode 100644 index 0000000000..7074ceee8a --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/admin/ListClusters.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/views/admin/ListLogs.vue b/.jjconflict-side-0/apps/client/src/views/admin/ListLogs.vue new file mode 100644 index 0000000000..2fab771995 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/admin/ListLogs.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/views/admin/ListPlugins.vue b/.jjconflict-side-0/apps/client/src/views/admin/ListPlugins.vue new file mode 100644 index 0000000000..250e3938ec --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/admin/ListPlugins.vue @@ -0,0 +1,140 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/admin/ListProjects.vue b/.jjconflict-side-0/apps/client/src/views/admin/ListProjects.vue new file mode 100644 index 0000000000..171f4ea557 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/admin/ListProjects.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/views/admin/ListServiceChains.vue b/.jjconflict-side-0/apps/client/src/views/admin/ListServiceChains.vue new file mode 100644 index 0000000000..2894d83443 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/admin/ListServiceChains.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/src/views/admin/ListStages.vue b/.jjconflict-side-0/apps/client/src/views/admin/ListStages.vue new file mode 100644 index 0000000000..060aec61fb --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/admin/ListStages.vue @@ -0,0 +1,169 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/admin/ListUser.vue b/.jjconflict-side-0/apps/client/src/views/admin/ListUser.vue new file mode 100644 index 0000000000..6fd88db2d3 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/admin/ListUser.vue @@ -0,0 +1,261 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/admin/ListZones.vue b/.jjconflict-side-0/apps/client/src/views/admin/ListZones.vue new file mode 100644 index 0000000000..18698cadfa --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/admin/ListZones.vue @@ -0,0 +1,173 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/admin/SystemSettings.vue b/.jjconflict-side-0/apps/client/src/views/admin/SystemSettings.vue new file mode 100644 index 0000000000..8054ef29f5 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/admin/SystemSettings.vue @@ -0,0 +1,29 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/profile/PersonalAccessTokens.vue b/.jjconflict-side-0/apps/client/src/views/profile/PersonalAccessTokens.vue new file mode 100644 index 0000000000..f97b54cb59 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/profile/PersonalAccessTokens.vue @@ -0,0 +1,128 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/profile/ProfileWrapper.vue b/.jjconflict-side-0/apps/client/src/views/profile/ProfileWrapper.vue new file mode 100644 index 0000000000..d31d761dfd --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/profile/ProfileWrapper.vue @@ -0,0 +1,7 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/profile/UserInfo.vue b/.jjconflict-side-0/apps/client/src/views/profile/UserInfo.vue new file mode 100644 index 0000000000..c3b9e4a1b5 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/profile/UserInfo.vue @@ -0,0 +1,66 @@ + + + diff --git a/.jjconflict-side-0/apps/client/src/views/projects/DsoProjects.vue b/.jjconflict-side-0/apps/client/src/views/projects/DsoProjects.vue new file mode 100644 index 0000000000..d4174d1337 --- /dev/null +++ b/.jjconflict-side-0/apps/client/src/views/projects/DsoProjects.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/.jjconflict-side-0/apps/client/tsconfig.eslint.json b/.jjconflict-side-0/apps/client/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-0/apps/client/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-0/apps/client/tsconfig.json b/.jjconflict-side-0/apps/client/tsconfig.json new file mode 100644 index 0000000000..7f8a3442bf --- /dev/null +++ b/.jjconflict-side-0/apps/client/tsconfig.json @@ -0,0 +1,62 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + + "jsx": "preserve", + "lib": ["DOM", "ESNext"], + // Base + "baseUrl": "./", + "rootDir": "./src", + "module": "Preserve", + "moduleResolution": "Bundler", + "paths": { + "@/*": [ + "src/*" + ] + }, + "resolveJsonModule": true, + "types": [ + "vitest", + "vite/client", + "vite-plugin-pwa/vue", + "vite-plugin-pwa/client" + ], + "allowImportingTsExtensions": false, + "allowJs": true, + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useUnknownInCatchVariables": true, + "declaration": true, + "declarationDir": "./types", + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "./dist", + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "vueCompilerOptions": {}, + "include": [ + "./src/**/*.ts", + "./src/**/*.js", + "./src/**/*.vue", + "./src/**/*.d.ts" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-0/apps/client/uno.config.ts b/.jjconflict-side-0/apps/client/uno.config.ts new file mode 100644 index 0000000000..6035f5b009 --- /dev/null +++ b/.jjconflict-side-0/apps/client/uno.config.ts @@ -0,0 +1,23 @@ +import { + defineConfig, + presetIcons, + presetUno, + transformerDirectives, + transformerVariantGroup, +} from 'unocss' + +export default defineConfig({ + presets: [ + presetUno(), + presetIcons({ + scale: 1.2, + extraProperties: { + display: 'inline-block', + }, + }), + ], + transformers: [ + transformerDirectives(), + transformerVariantGroup(), + ], +}) diff --git a/.jjconflict-side-0/apps/client/vite.config.ts b/.jjconflict-side-0/apps/client/vite.config.ts new file mode 100644 index 0000000000..817f1306c6 --- /dev/null +++ b/.jjconflict-side-0/apps/client/vite.config.ts @@ -0,0 +1,145 @@ +import fs from 'node:fs' +import { fileURLToPath, URL } from 'node:url' +import { + vueDsfrAutoimportPreset, + vueDsfrComponentResolver, +} from '@gouvminint/vue-dsfr/meta' +import vue from '@vitejs/plugin-vue' +import UnoCSS from 'unocss/vite' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { defineConfig } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) + process.loadEnvFile('.env.integ') + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) + process.loadEnvFile('.env') + +const serverHost = process.env.SERVER_HOST ?? 'localhost' +const serverPort = process.env.SERVER_PORT ?? 4000 +const clientPort = process.env.CLIENT_PORT ?? 8080 + +const define = process.env.NODE_ENV === 'production' + ? { 'process.env': { APP_VERSION: process.env.APP_VERSION } } + : { 'process.env': process.env } + +// https://vitejs.dev/config/ +export default defineConfig({ + server: { + host: '0.0.0.0', + port: Number(clientPort) || 8080, + proxy: { + '^/api': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + '^/swagger-ui-server': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + '^/swagger-ui-server-nestjs': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + }, + }, + define, + plugins: [ + vue(), + AutoImport({ + imports: [ + // @ts-ignore + 'vue', + // @ts-ignore + 'vue-router', + // @ts-ignore + 'pinia', + // @ts-ignore + vueDsfrAutoimportPreset, + ], + vueTemplate: true, + dts: './src/auto-imports.d.ts', + eslintrc: { + enabled: true, + filepath: './.eslintrc-auto-import.json', + globalsPropValue: true, + }, + }), + Components({ + extensions: ['vue'], + dirs: [ + './src/components', + './src/views', + ], + include: [/\.vue$/, /\.vue\?vue/], + dts: './src/components.d.ts', + resolvers: [ + vueDsfrComponentResolver, + ], + }), + UnoCSS({ + extendTheme: (theme) => { + return { + ...theme, + breakpoints: { + ...theme.breakpoints, + dsfrmenu: '992px', + }, + } + }, + }), + VitePWA({ + registerType: 'prompt', // autoUpdate + // disable: true, + // selfDestroying: true, + workbox: { + maximumFileSizeToCacheInBytes: 5_000_000, + cleanupOutdatedCaches: true, + navigateFallbackDenylist: [ + /^\/api/, + /^\/swagger-ui-server/, + /^\/swagger-ui-server-nestjs/, + ], + }, + devOptions: { + enabled: false, + }, + manifest: { + name: 'Console Cloud Pi Native', + short_name: 'CPiN', + description: 'Une console web pour les controler tous', + start_url: '/', + display: 'standalone', + background_color: '#ffffff', + theme_color: '#42b883', + icons: [ + { + src: '/favicon.ico', + sizes: '16x16', + type: 'image/png', + }, + ], + }, + }), + ], + base: '/', + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + dedupe: ['vue'], + }, + build: { + target: 'ESNext', + }, + optimizeDeps: { + include: [ + 'jszip', + ], + }, +}) diff --git a/.jjconflict-side-0/apps/client/vitest.config.ts b/.jjconflict-side-0/apps/client/vitest.config.ts new file mode 100644 index 0000000000..7aa0e9fda4 --- /dev/null +++ b/.jjconflict-side-0/apps/client/vitest.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import { mergeConfig } from 'vite' +import { configDefaults, defineConfig } from 'vitest/config' +import viteConfig from './vite.config' + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + reporters: ['default', 'hanging-process'], + environment: 'jsdom', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: [ + '**/types', + '**/mocks', + '**/*.spec.ts', + '**/*.d.ts', + '**/*.vue', + 'src/main.ts', + 'src/icons.ts', + 'src/router/index.ts', + ], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + root: fileURLToPath(new URL('./', import.meta.url)), + pool: 'forks', + }, + }), +) diff --git a/.jjconflict-side-0/apps/nginx-strangler/Dockerfile b/.jjconflict-side-0/apps/nginx-strangler/Dockerfile new file mode 100644 index 0000000000..ec2c6753fd --- /dev/null +++ b/.jjconflict-side-0/apps/nginx-strangler/Dockerfile @@ -0,0 +1,32 @@ +FROM nginxinc/nginx-unprivileged:1.31-alpine AS prod + +USER root + +# On supprime la config par défaut +RUN rm /etc/nginx/conf.d/default.conf + +# Config principale +COPY apps/nginx-strangler/nginx.conf /etc/nginx/nginx.conf + +# Template de routing (sera substitué au démarrage) +COPY apps/nginx-strangler/conf.d/routing.conf /etc/nginx/templates/routing.conf.template + +# Donner à l'utilisateur nginx et au groupe root les droits d'écriture sur conf.d/ (pour subst au démarrage) +# et sur les répertoires de logs/pid nécessaires en mode non-root +# OpenShift exécute les conteneurs avec un UID arbitraire appartenant au groupe root. +RUN chown -R nginx:root \ + /etc/nginx/nginx.conf \ + /etc/nginx/conf.d \ + /etc/nginx/templates \ + /etc/nginx/mime.types \ + /var/cache/nginx \ + /var/log/nginx \ + && touch /var/run/nginx.pid \ + && chown nginx:root /var/run/nginx.pid && chmod g+w /var/run/nginx.pid + +USER nginx + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/health || exit 1 + +EXPOSE 8080 diff --git a/.jjconflict-side-0/apps/nginx-strangler/README.md b/.jjconflict-side-0/apps/nginx-strangler/README.md new file mode 100644 index 0000000000..f7bd5af63e --- /dev/null +++ b/.jjconflict-side-0/apps/nginx-strangler/README.md @@ -0,0 +1,120 @@ +# nginx-strangler + +Reverse proxy dédié à la migration progressive du backend `server` +(Fastify/legacy) vers `server-nestjs` (NestJS), selon le [Strangler FigPattern](https://martinfowler.com/bliki/StranglerFigApplication.html). + +Ticket de référence : [#1885](https://github.com/cloud-pi-native/console/issues/1885) + +--- + +## Architecture + +``` +[Client nginx :8080] + │ proxy_pass → nginx-strangler:8080 + ▼ +[nginx-strangler :8080] + ├── /api/[routes migrées] ──→ server-nestjs:3001 + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +--- + +## Variables d'environnement + +| Variable | Description | Exemple Docker | Exemple local | +|---|---|---|---| +| `LEGACY_UPSTREAM` | Adresse du backend Fastify legacy | `server:8080` | `127.0.0.1:4000` | +| `NESTJS_UPSTREAM` | Adresse du backend NestJS | `server-nestjs:3001` | `127.0.0.1:3001` | + +Ces variables sont substituées dans `conf.d/routing.conf` via `envsubst` au démarrage du conteneur. + +--- + +## Basculer une route vers NestJS + +Éditer `conf.d/routing.conf` et ajouter un bloc `location` **avant** le bloc fallback `/api/`, en respectant le format suivant : + +```nginx +# [Vague/Itération X - nom-du-module] AAAA-MM-JJ +location = /api/v1/ma-route { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Puis recharger nginx sans downtime : + +```bash +docker compose exec nginx-strangler nginx -t # valider la config +docker compose exec nginx-strangler nginx -s reload +``` + +### Modificateurs de location + +| Modificateur | Usage | +|---|---| +| `=` | Route exacte (ex: `GET /api/v1/system/health`) | +| `^~` | Préfixe prioritaire (ex: tout un module `/api/v1/users/`) | +| *(aucun)* | Préfixe standard | + +--- + +## Rollback d'une route + +En cas de problème sur une route basculée : + +```bash +# 1. Dans conf.d/routing.conf, commenter le bloc concerné : +# # [Vague X - module] AAAA-MM-JJ ← ROLLBACK AAAA-MM-JJ +# # location = /api/v1/xxx { ... } + +# 2. Recharger nginx sans downtime +docker compose exec nginx-strangler nginx -s reload + +# 3. Vérifier les logs +docker compose logs -f nginx-strangler | grep "/api/v1/xxx" +``` + +--- + +## Lancer en dev local (pnpm dev) + +Quand `server` et `server-nestjs` tournent nativement (hors Docker), le +`nginx-strangler` reste lui en Docker avec des upstreams pointant vers les +ports "locaux" des deux services: + +```bash +# Dans docker/docker-compose.local.yml, le service nginx-strangler utilise : +# LEGACY_UPSTREAM=127.0.0.1:4000 +# NESTJS_UPSTREAM=127.0.0.1:3001 + +docker compose -f docker/docker-compose.local.yml up nginx-strangler +``` + +--- + +## Vérification de la configuration + +```bash +# Tester la syntaxe (depuis l'hôte) +docker compose exec nginx-strangler nginx -t + +# Voir la config résolue (après substitution envsubst) +docker compose exec nginx-strangler cat /etc/nginx/conf.d/routing.conf + +# Logs en temps réel +docker compose logs -f nginx-strangler +``` + +--- + +## Liens + +- Plan de mise en place : [`apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md`](../server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md) +- Stratégie de modularisation : [`apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md`](../server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md) +- Cartographie des modules : [`apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md`](../server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) diff --git a/.jjconflict-side-0/apps/nginx-strangler/conf.d/routing.conf b/.jjconflict-side-0/apps/nginx-strangler/conf.d/routing.conf new file mode 100644 index 0000000000..25bf6f7192 --- /dev/null +++ b/.jjconflict-side-0/apps/nginx-strangler/conf.d/routing.conf @@ -0,0 +1,106 @@ +# ============================================================================= +# nginx-strangler — Configuration de routage "as-code" +# +# Ce fichier est le cœur du pattern Strangler Fig. +# Il route progressivement les routes de server (legacy) vers server-nestjs. +# +# Variables d'environnement substituées au démarrage (via envsubst) : +# LEGACY_UPSTREAM — adresse du backend Fastify legacy (ex: server:8080) +# NESTJS_UPSTREAM — adresse du backend NestJS (ex: server-nestjs:3001) +# +# Format d'une bascule : +# # [Vague X - module] AAAA-MM-JJ +# location [= | ^~ | ~] /api/v1/xxx { +# proxy_pass http://server-nestjs; +# include /etc/nginx/conf.d/_proxy_headers.conf; +# } +# +# Procédure de rollback d'une route : +# 1. Commenter le bloc location concerné (ajouter "# ROLLBACK AAAA-MM-JJ") +# 2. docker compose exec nginx-strangler nginx -s reload +# ============================================================================= + +upstream server-legacy { + server ${LEGACY_UPSTREAM}; +} + +upstream server-nestjs { + server ${NESTJS_UPSTREAM}; +} + +server { + listen 8080; + + # Taille des headers (nécessaire pour les tokens Keycloak) + large_client_header_buffers 4 32k; + + # Healthcheck de nginx-strangler lui-même + location = /health { + access_log off; + add_header 'Content-Type' 'application/json'; + return 200 '{"status":"UP"}'; + } + + # ── Swagger UI ──────────────────────────────────────────────────────────────── + location /swagger-ui-server-nestjs { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /swagger-ui-server { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── Routes migrées vers NestJS ──────────────────────────────────────────────── + location /api/v1/service-chains { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ~ ^/api/v1/projects/[^/]+/deployments(.*)$ { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Les routes /api/v2 n'existent que sur server-nestjs + location /api/v2/ { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── Routes par défaut (pour l'instant dans le legacy) ───────────────────────── + location /api/ { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_redirect off; + } + +} diff --git a/.jjconflict-side-0/apps/nginx-strangler/conf.d/routing.local.conf.example b/.jjconflict-side-0/apps/nginx-strangler/conf.d/routing.local.conf.example new file mode 100644 index 0000000000..b8f62e2e8b --- /dev/null +++ b/.jjconflict-side-0/apps/nginx-strangler/conf.d/routing.local.conf.example @@ -0,0 +1,64 @@ +# ============================================================================= +# Exemple de configuration nginx-strangler pour le développement local +# (pnpm dev — server et server-nestjs tournent nativement, hors Docker) +# +# Ce fichier est fourni à titre documentaire. +# En pratique, le docker-compose.local.yml injecte directement les variables +# d'environnement LEGACY_UPSTREAM et NESTJS_UPSTREAM dans le conteneur +# nginx-strangler via envsubst, sans qu'il soit nécessaire de modifier ce fichier. +# +# Pour lancer le nginx-strangler en mode local : +# +# docker compose -f docker/docker-compose.local.yml up nginx-strangler +# +# Les valeurs par défaut dans docker-compose.local.yml : +# LEGACY_UPSTREAM=host.docker.internal:4000 (server Fastify natif) +# NESTJS_UPSTREAM=host.docker.internal:3001 (server-nestjs natif) +# +# Si vos servers écoutent sur des ports différents, surcharger les variables : +# +# LEGACY_UPSTREAM=host.docker.internal:4000 \ +# NESTJS_UPSTREAM=host.docker.internal:3001 \ +# docker compose -f docker/docker-compose.local.yml up nginx-strangler +# +# Le nginx-strangler est alors accessible sur http://localhost:8082 +# (le client Vue.js en dev natif doit pointer son proxy /api vers localhost:8082) +# ============================================================================= + +upstream server-legacy { + server host.docker.internal:4000; +} + +upstream server-nestjs { + server host.docker.internal:3001; +} + +server { + listen 8080; + + # ── Routes migrées vers NestJS ──────────────────────────────────────────── + # (vide au démarrage — toutes les routes sont en fallback sur server-legacy) + + # ── Fallback : tout le reste vers le server legacy ──────────────────────── + location /api/ { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_redirect off; + } + + location /swagger-ui { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/.jjconflict-side-0/apps/nginx-strangler/nginx.conf b/.jjconflict-side-0/apps/nginx-strangler/nginx.conf new file mode 100644 index 0000000000..657e2100dd --- /dev/null +++ b/.jjconflict-side-0/apps/nginx-strangler/nginx.conf @@ -0,0 +1,28 @@ +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + 'upstream=$upstream_addr rt=$request_time'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + + # Taille des headers (nécessaire pour les tokens Keycloak) + large_client_header_buffers 4 32k; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/.jjconflict-side-0/apps/server-nestjs/.env-example b/.jjconflict-side-0/apps/server-nestjs/.env-example new file mode 100644 index 0000000000..1da8adba5c --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/.env-example @@ -0,0 +1,61 @@ +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Activation des plugins externes (true | false). Actif par défaut en développement. +USE_PLUGINS=true +# HOME=/home/node +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# Identifiant de l'administrateur Keycloak sur le royaume KEYCLOAK_REALM +KEYCLOAK_ADMIN=admin +# Mot de passe de l'administrateur Keycloak sur le royaume KEYCLOAK_REALM +KEYCLOAK_ADMIN_PASSWORD=admin +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 +# Interface d'écoute du serveur backend +SERVER_HOST=localhost +# Port d'écoute du serveur backend +SERVER_PORT=3001 +# URL de connexion à la base de données PostgreSQL (format Prisma) +DB_URL=postgresql://admin:admin@localhost:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +OPENCDS_URL= +# URL interne de l'API OpenCDS +OPENCDS_INTERNAL_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_GITLAB=false +USE_NEXUS=false +USE_SONARQUBE=false +USE_REGISTRY=false +USE_HARBOR=false +USE_OPENCDS=false +USE_ARGOCD=false diff --git a/.jjconflict-side-0/apps/server-nestjs/.env.docker-example b/.jjconflict-side-0/apps/server-nestjs/.env.docker-example new file mode 100644 index 0000000000..d0cf596b3a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/.env.docker-example @@ -0,0 +1,64 @@ +# Indique que l'application tourne dans un conteneur Docker +DOCKER=true +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Activation des plugins externes (true | false). +USE_PLUGINS=fasle +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine du Keycloak (nom du service Docker Compose + port interne) +KEYCLOAK_DOMAIN=keycloak:8080 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http en Docker local) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# Identifiant de l'administrateur Keycloak +KEYCLOAK_ADMIN=admin +# Mot de passe de l'administrateur Keycloak +KEYCLOAK_ADMIN_PASSWORD=admin +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 +# Interface d'écoute du serveur backend +SERVER_HOST=0.0.0.0 +# Port d'écoute du serveur dans le réseau Docker +SERVER_PORT=3001 +# URL de connexion PostgreSQL (utilise le nom de service Docker 'postgres') +DB_URL=postgresql://admin:admin@postgres:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +# Par défaut configuré sur le conteneur Mockoon (à changer sur envs non-PAX) +OPENCDS_URL= +# URL interne de l'API OpenCDS +OPENCDS_INTERNAL_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_GITLAB=false +USE_VAULT=false +USE_NEXUS=false +USE_SONARQUBE=false +USE_REGISTRY=false +USE_HARBOR=false +USE_OPENCDS=false +USE_ARGOCD=false diff --git a/.jjconflict-side-0/apps/server-nestjs/.env.integ-example b/.jjconflict-side-0/apps/server-nestjs/.env.integ-example new file mode 100644 index 0000000000..f0bf743326 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/.env.integ-example @@ -0,0 +1,130 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Désactive l'initialisation des données de démo (l'intégration a ses propres données) +DEV_SETUP="false" +# Active le mode intégration (charge ce fichier en surcharge) +INTEGRATION=true +# Activation des plugins externes (true | false). Désactivé en mode intégration. +USE_PLUGINS=false + +# --- Keycloak --- +# Protocole (généralement https en intégration) +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= +# Secret du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_SECRET= +# Domaine du Keycloak d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak d'intégration +KEYCLOAK_REALM= + +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 + +# --- ArgoCD --- +# Namespace Kubernetes dans lequel ArgoCD est déployé +ARGO_NAMESPACE= +# URL de l'API ArgoCD +ARGOCD_URL= +# URL interne de l'API ArgoCD +ARGOCD_INTERNAL_URL= + +# --- GitLab --- +# Token d'accès personnel GitLab (scope api) +GITLAB_TOKEN= +# URL de l'instance GitLab +GITLAB_URL= +# URL interne de l'API Gitlab +GITLAB_INTERNAL_URL= + +# --- Harbor --- +# Nom d'utilisateur admin Harbor +HARBOR_ADMIN= +# Mot de passe admin Harbor +HARBOR_ADMIN_PASSWORD= +# URL de l'instance Harbor +HARBOR_URL= +# URL interne de l'API Harbor +HARBOR_INTERNAL_URL= + +# --- Keycloak (admin) --- +# Utilisateur admin Keycloak pour la gestion des royaumes/clients +KEYCLOAK_ADMIN= +# Mot de passe admin Keycloak +KEYCLOAK_ADMIN_PASSWORD= +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL complète de Keycloak (peut différer de KEYCLOAK_DOMAIN) +KEYCLOAK_URL= + +# --- Nexus --- +# Nom d'utilisateur admin Nexus +NEXUS_ADMIN= +# Mot de passe admin Nexus +NEXUS_ADMIN_PASSWORD= +# URL de l'instance Nexus +NEXUS_URL= +# URL interne de l'API Nexus +NEXUS_INTERNAL_URL= + +# --- Divers --- +# Répertoire racine des projets dans Gitlab +PROJECTS_ROOT_DIR= + +# --- SonarQube --- +# Token d'API SonarQube +SONAR_API_TOKEN= +# URL de l'instance SonarQube +SONARQUBE_URL= +# URL interne de l'API SonarQube +SONARQUBE_INTERNAL_URL= + +# --- Vault --- +# Token d'authentification Vault +VAULT_TOKEN= +# URL de l'instance Vault +VAULT_URL= +# URL interne de l'API Vault +VAULT_INTERNAL_URL= + +# --- Kubeconfig --- +# Chemin vers le kubeconfig sur la machine hôte (monté en volume dans Docker) +KUBECONFIG_HOST_PATH= +# Chemin vers le kubeconfig à l'intérieur du conteneur +KUBECONFIG_PATH=$HOME/.kube/config +# Contexte Kubernetes à utiliser +KUBECONFIG_CTX= + +# Répertoire contenant les plugins externes (monté en volume dans Docker) +EXTERNAL_PLUGINS_DIR_HOST_PATH=/path/to/plugins + +# Informations pour le plugin Observabilité +GRAFANA_URL= +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 + +# --- Configuration proxy (si nécessaire pour accéder aux services externes) --- +# HTTP_PROXY= +# HTTPS_PROXY= +# NO_PROXY= + +# Configuration OpenCDS +OPENCDS_URL= +OPENCDS_INTERNAL_URL= +OPENCDS_API_TOKEN=token +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false + +# --- Activation des plugins externes --- +# Désactivés par défaut en intégration : renseignez les variables et passez à "true" pour activer. +USE_KEYCLOAK=true +USE_GITLAB=true +USE_VAULT=true +USE_NEXUS=true +USE_SONARQUBE=true +USE_REGISTRY=true +USE_HARBOR=true +USE_OPENCDS=true +USE_ARGOCD=true diff --git a/.jjconflict-side-0/apps/server-nestjs/.gitignore b/.jjconflict-side-0/apps/server-nestjs/.gitignore new file mode 100644 index 0000000000..4b56acfbeb --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/.gitignore @@ -0,0 +1,56 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/.jjconflict-side-0/apps/server-nestjs/.prettierrc b/.jjconflict-side-0/apps/server-nestjs/.prettierrc new file mode 100644 index 0000000000..8deec5d940 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/.prettierrc @@ -0,0 +1,12 @@ +{ + "plugins": ["@trivago/prettier-plugin-sort-imports"], + "importOrderParserPlugins": ["typescript", "decorators"], + "importOrder": ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true, + "printWidth": 80, + "semi": true, + "singleQuote": true, + "tabWidth": 4, + "trailingComma": "all" +} diff --git a/.jjconflict-side-0/apps/server-nestjs/Dockerfile b/.jjconflict-side-0/apps/server-nestjs/Dockerfile new file mode 100644 index 0000000000..6eb7f5f180 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/Dockerfile @@ -0,0 +1,106 @@ +# Base / dev stage ---------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Activer pnpm via corepack (version définie dans package.json "packageManager") +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +# Copier les manifestes workspace (nécessaires pour que pnpm résolve les dépendances) +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ + +# Manifestes des packages workspace dont server-nestjs dépend +COPY --chown=node:root apps/server-nestjs/package.json ./apps/server-nestjs/package.json + +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +COPY --chown=node:root plugins/argocd/package.json ./plugins/argocd/package.json +COPY --chown=node:root plugins/gitlab/package.json ./plugins/gitlab/package.json +COPY --chown=node:root plugins/harbor/package.json ./plugins/harbor/package.json +COPY --chown=node:root plugins/keycloak/package.json ./plugins/keycloak/package.json +COPY --chown=node:root plugins/nexus/package.json ./plugins/nexus/package.json +COPY --chown=node:root plugins/sonarqube/package.json ./plugins/sonarqube/package.json +COPY --chown=node:root plugins/vault/package.json ./plugins/vault/package.json + +# Installer toutes les dépendances afin de permettre le build des libs partagées +RUN pnpm install --frozen-lockfile + +# Copier les sources des packages workspace +COPY --chown=node:root plugins/ ./plugins/ +COPY --chown=node:root packages/ ./packages/ +COPY --chown=node:root apps/server-nestjs/ ./apps/server-nestjs/ + +# Build des libs partagées (génère dist/ et types/) +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +# Build hooks (génère dist/ et types/ nécessaires aux imports) +RUN pnpm --filter @cpn-console/hooks run build + +# Réinjecter les libs buildées dans node_modules (injectWorkspacePackages copie au moment de l'install) +RUN pnpm --filter @cpn-console/server-nestjs install --frozen-lockfile + +# Générer le client Prisma (schéma multi-fichiers : pointer sur le dossier) +RUN pnpm --filter @cpn-console/server-nestjs exec prisma generate \ + --schema=src/prisma/schema + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/api/v1/healthz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" + +ENTRYPOINT ["pnpm", "--filter", "server-nestjs", "run"] +CMD ["start:dev"] + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +# Build shared (génère dist/ et types/) +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +# Build hooks (génère dist/ et types/ nécessaires aux imports) +RUN pnpm --filter @cpn-console/hooks run build + +# Réinjecter shared buildé dans node_modules (injectWorkspacePackages copie au moment de l'install) +RUN pnpm --filter @cpn-console/server-nestjs install --frozen-lockfile + +# Build server-nestjs +RUN pnpm --filter @cpn-console/server-nestjs run build + +# Export @cpn-console/server-nestjs to target build directory +RUN pnpm --filter @cpn-console/server-nestjs --prod deploy build + +# Prod stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS prod + +ARG APP_VERSION +ENV APP_VERSION=$APP_VERSION + +VOLUME ["/plugins"] +WORKDIR /app + +RUN mkdir -p /home/node/logs && chmod 770 -R /home/node/logs \ + && mkdir -p /home/node/.npm && chmod 770 -R /home/node/.npm \ + && chown node:root /app + +# Copier l'artefact de build et les node_modules de production +COPY --chown=node:root --from=build /app/build . +COPY --chown=node:root --from=build /app/apps/server-nestjs/dist ./dist +# COPY --chown=node:root --from=build /app/build/node_modules ./node_modules + +# Regénérer le client Prisma dans l'image de production +RUN npm run db:generate + +USER node +EXPOSE 3001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/api/v1/healthz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" + +ENTRYPOINT ["node", "dist/src/main"] diff --git a/.jjconflict-side-0/apps/server-nestjs/README.md b/.jjconflict-side-0/apps/server-nestjs/README.md new file mode 100644 index 0000000000..62673164cc --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/README.md @@ -0,0 +1,543 @@ +# À propos + +Ce dossier contient une nouvelle version de `server`, basée sur NestJS. + +## Objectifs + +Rappel : L'objectif principal de cette démarche est de préparer l'extraction de +chaque "plugin" en tant que module NestJS incluant à la fois la partie Front et +la partie Backend pour une meilleure composition de la Console. + +On va profiter de cette vision pour passer, côté `server` d'un "Back For +Front", à un backend qui va gérer à la fois le Front et le Back de différentes +fonctionnalités (appelée "Plugins"), et ce parfois de manière dynamique (mais +déjà en statique, ce sera pas mal 😅). + +## Conséquences pour apps/server + +On va donc passer de ça : + +```mermaid +flowchart TD + + %% --- Top-level --- + NestJS["Nest.js"] + FutursModules["Futurs modules
Nest.js correctement
découpés..."] + MainModule["MainModule"] + + NestJS --> MainModule + MainModule --> CPinModule + MainModule -.-> FutursModules + + CPinModule["CPinModule
(Contient TOUT le code actuel de 'server')"] + + %% --- Core services --- + ConnectionService["ConnectionService"] + LoggerService["LoggerService"] + FastifyService["FastifyService"] + ServerService["ServerService"] + AppService["AppService"] + ResourceService["ResourceService"] + PrepareAppService["PrepareAppService"] + InitDbService["InitDbService"] + PluginService["PluginService"] + + %% --- Router services --- + AdminRoleRouterService["AdminRoleRouterService"] + AdminTokenRouterService["AdminTokenRouterService"] + ClusterRouterService["ClusterRouterService"] + OtherRouterService["...RouterService"] + + %% --- External services --- + Gitlab["Gitlab"] + ArgoCD["ArgoCD"] + Dots["..."] + + %% --- CPinModule connections --- + CPinModule --> ConnectionService + CPinModule --> AppService + CPinModule --> LoggerService + CPinModule --> FastifyService + CPinModule --> ServerService + CPinModule --> ResourceService + CPinModule --> PrepareAppService + + %% --- AppService central connections --- + ConnectionService --> AppService + LoggerService --> AppService + FastifyService --> AppService + ServerService --> AppService + + AppService --> ResourceService + AppService --> PrepareAppService + AppService --> ServerService + AppService --> FastifyService + AppService --> LoggerService + AppService --> ConnectionService + + %% --- ResourceService to routers --- + ResourceService --> AdminRoleRouterService + ResourceService --> AdminTokenRouterService + ResourceService --> ClusterRouterService + ResourceService --> OtherRouterService + + %% --- PrepareAppService --- + PrepareAppService --> InitDbService + PrepareAppService --> PluginService + PrepareAppService --> ServerService + + %% --- PluginService external interactions --- + PluginService --> Gitlab + PluginService --> ArgoCD + PluginService --> Dots + + %% --- Bounding box (visual grouping only) --- + subgraph CPinBlock[" "] + CPinModule + ConnectionService + LoggerService + FastifyService + ServerService + AppService + ResourceService + PrepareAppService + InitDbService + PluginService + AdminRoleRouterService + AdminTokenRouterService + ClusterRouterService + OtherRouterService + end +``` + +à ça : + +```mermaid +flowchart TD + + %% --- Top-level Nest module --- + NestJS["Point d'entrée de NestJS"] + MainModule["MainModule"] + CPinModule["CPinModule"] + FutursModules["Futurs modules
NestJS correctement
découpés..."] + + NestJS --> MainModule + MainModule --> CPinModule + MainModule -.-> FutursModules + + %% --- Layering for clarity --- + subgraph LayerInit["Initialisation de l'application"] + ApplicationInitializationService["ApplicationInitializationService"] + DatabaseInitializationService["DatabaseInitializationService"] + PluginManagementService["PluginManagementService"] + end + + subgraph LayerCore["Coeur de l'application"] + AppService["AppService"] + RouterService["RouterService"] + ServerService["ServerService"] + end + + subgraph LayerInfra["Couche Infrastructure"] + LoggerService["LoggerService"] + ConfigurationService["ConfigurationService"] + DatabaseService["DatabaseService"] + FastifyService["FastifyService"] + HTTPClientService["HTTPClientService"] + end + + OtherAPIService["APIs externes
(par ex. OpenCDS)"] + HTTPClientService --> OtherAPIService + + subgraph LayerBusiness["Modules métiers"] + subgraph AdminRole["Admin Roles"] + AdminRoleRouterService["AdminRoleRouterService"] + AdminRoleBusinessService["AdminRoleBusinessService"] + AdminRoleDTOService["AdminRoleDTOService"] + AdminRoleRouterService --> AdminRoleBusinessService + AdminRoleRouterService --> LoggerService + AdminRoleBusinessService --> AdminRoleDTOService + AdminRoleBusinessService --> LoggerService + AdminRoleDTOService --> LoggerService + AdminRoleDTOService --> DatabaseService + end + subgraph AdminToken["Admin Tokens"] + AdminTokenRouterService["AdminTokenRouterService"] + AdminTokenBusinessService["AdminTokenBusinessService"] + AdminTokenDTOService["AdminTokenDTOService"] + AdminTokenRouterService --> AdminTokenBusinessService + AdminTokenRouterService --> LoggerService + AdminTokenBusinessService --> AdminTokenDTOService + AdminTokenBusinessService --> LoggerService + AdminTokenDTOService --> DatabaseService + AdminTokenDTOService --> LoggerService + end + subgraph ServiceChain["Service chains"] + ServiceChainRouterService["ServiceChainRouterService"] + ServiceChainBusinessService["ServiceChainBusinessService"] + ServiceChainRouterService --> ServiceChainBusinessService + ServiceChainRouterService --> LoggerService + ServiceChainBusinessService --> HTTPClientService + ServiceChainBusinessService --> LoggerService + + end + subgraph Cluster["Clusters"] + ClusterRouterService["ClusterRouterService"] + ClusterBusinessService["ClusterBusinessService"] + ClusterDTOService["ClusterDTOService"] + ClusterRouterService --> ClusterBusinessService + ClusterRouterService --> LoggerService + ClusterBusinessService --> ClusterDTOService + ClusterBusinessService --> LoggerService + ClusterDTOService --> DatabaseService + ClusterDTOService --> LoggerService + end + OtherBusinessModules["...Other Business Modules"] + end + + RouterService --> AdminRoleRouterService + RouterService --> AdminTokenRouterService + RouterService --> ClusterRouterService + RouterService --> ServiceChainRouterService + RouterService --> OtherBusinessModules + RouterService --> LoggerService + + subgraph LayerPlugins["Plugins compatibles CPiN"] + Gitlab["Gitlab"] + ArgoCD["ArgoCD"] + Kubernetes["Kubernetes"] + Dots["..."] + end + + %% --- Module wiring --- + CPinModule --> ApplicationInitializationService + + %% Application initialization + ApplicationInitializationService --> LoggerService + ApplicationInitializationService --> ConfigurationService + ApplicationInitializationService --> FastifyService + ApplicationInitializationService --> AppService + ApplicationInitializationService --> DatabaseInitializationService + DatabaseInitializationService --> DatabaseService + DatabaseInitializationService --> LoggerService + ApplicationInitializationService --> PluginManagementService + ApplicationInitializationService --> LoggerService + + %% App Core internal flow + AppService --> RouterService + AppService --> ServerService + AppService --> LoggerService + ServerService --> LoggerService + + %% Plugin Management + PluginManagementService --> Gitlab + PluginManagementService --> ArgoCD + PluginManagementService --> Kubernetes + PluginManagementService --> Dots + PluginManagementService --> LoggerService + Gitlab --> LoggerService + ArgoCD --> LoggerService + Kubernetes --> LoggerService + Dots --> LoggerService +``` + +et enfin à ça : + +```mermaid +flowchart TD + + %% --- Top-level Nest module --- + NestJS["Point d'entrée de NestJS"] + MainModule["MainModule"] + + NestJS --> MainModule + + subgraph CoreModule["CoreModule"] + ApplicationInitializationService["ApplicationInitializationService"] + end + + subgraph InfrastructureModule["InfrastructureModule"] + LoggerService["LoggerService"] + ConfigurationService["ConfigurationService"] + DatabaseService["DatabaseService"] + HTTPClientService["HTTPClientService"] + end + + OtherAPIService["APIs externes
(par ex. OpenCDS)"] + HTTPClientService --> OtherAPIService + + subgraph MandatoryModules["Modules obligatoires CPiN"] + subgraph GitlabModule["GitlabModule"] + GitlabController["GitlabController"] + GitlabBusinessService["GitlabBusinessService"] + GitlabDTOService["GitlabDTOService"] + GitlabController --> GitlabBusinessService + GitlabController --> LoggerService + GitlabBusinessService --> GitlabDTOService + GitlabBusinessService --> LoggerService + GitlabDTOService --> DatabaseService + GitlabDTOService --> LoggerService + end + + subgraph ArgoCDModule["ArgoCDModule"] + ArgoCDController["ArgoCDController"] + ArgoCDBusinessService["ArgoCDBusinessService"] + ArgoCDDTOService["ArgoCDDTOService"] + ArgoCDController --> ArgoCDBusinessService + ArgoCDController --> LoggerService + ArgoCDBusinessService --> ArgoCDDTOService + ArgoCDBusinessService --> LoggerService + ArgoCDDTOService --> DatabaseService + ArgoCDDTOService --> LoggerService + end + + subgraph KubernetesModule["KubernetesModule"] + KubernetesController["KubernetesController"] + KubernetesBusinessService["KubernetesBusinessService"] + KubernetesDTOService["KubernetesDTOService"] + KubernetesController --> KubernetesBusinessService + KubernetesController --> LoggerService + KubernetesBusinessService --> KubernetesDTOService + KubernetesBusinessService --> LoggerService + KubernetesDTOService --> DatabaseService + KubernetesDTOService --> LoggerService + end + + subgraph AdminRoleModule["AdminRoleModule"] + AdminRoleController["AdminRoleController"] + AdminRoleBusinessService["AdminRoleBusinessService"] + AdminRoleDTOService["AdminRoleDTOService"] + AdminRoleController --> AdminRoleBusinessService + AdminRoleController --> LoggerService + AdminRoleBusinessService --> AdminRoleDTOService + AdminRoleBusinessService --> LoggerService + AdminRoleDTOService --> LoggerService + AdminRoleDTOService --> DatabaseService + end + + subgraph AdminTokenModule["AdminTokenModule"] + AdminTokenController["AdminTokenController"] + AdminTokenBusinessService["AdminTokenBusinessService"] + AdminTokenDTOService["AdminTokenDTOService"] + AdminTokenController --> AdminTokenBusinessService + AdminTokenController --> LoggerService + AdminTokenBusinessService --> AdminTokenDTOService + AdminTokenBusinessService --> LoggerService + AdminTokenDTOService --> DatabaseService + AdminTokenDTOService --> LoggerService + end + + subgraph ClusterModule["ClusterModule"] + ClusterController["ClusterController"] + ClusterBusinessService["ClusterBusinessService"] + ClusterDTOService["ClusterDTOService"] + ClusterController --> ClusterBusinessService + ClusterController --> LoggerService + ClusterBusinessService --> ClusterDTOService + ClusterBusinessService --> LoggerService + ClusterDTOService --> DatabaseService + ClusterDTOService --> LoggerService + end + OtherBusinessModules["...Other Business Modules"] + end + + CoreModule --> GitlabModule + CoreModule --> ArgoCDModule + CoreModule --> KubernetesModule + CoreModule --> AdminRoleModule + CoreModule --> AdminTokenModule + CoreModule --> ClusterModule + + subgraph ThirdPartyModules["Modules optionnels de CPin"] + subgraph ServiceChainModule["ServiceChainModule"] + ServiceChainController["ServiceChainController"] + ServiceChainBusinessService["ServiceChainBusinessService"] + ServiceChainController --> ServiceChainBusinessService + ServiceChainController --> LoggerService + ServiceChainBusinessService --> HTTPClientService + ServiceChainBusinessService --> LoggerService + end + end + + CoreModule --> ServiceChainModule + + %% --- Module wiring --- + MainModule --> ApplicationInitializationService + + %% Application initialization + ApplicationInitializationService --> LoggerService + ApplicationInitializationService --> ConfigurationService + ApplicationInitializationService --> LoggerService +``` + +## Architecture du module ServiceChain (Vague 1) + +Le module ServiceChain est le **premier module métier migré** depuis le legacy +vers server-nestjs via le pattern Strangler Fig. Il sert de proxy HTTP vers +l'API externe OpenCDS (gestion des chaînes de service réseau). + +### Flux de proxying OpenCDS + +``` +┌──────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ Client │────▶│ nginx-strangler │────▶│ server-nestjs │────▶│ API OpenCDS │ +│ (browser/ │ │ │ │ │ │ (externe) │ +│ script) │ │ /api/v1/ │ │ ServiceChain │ │ │ +│ │◀────│ service-chains │◀────│ Controller │◀────│ /requests │ +│ │ │ ──▶ nestjs │ │ ──▶ Service │ │ /validate │ +└──────────┘ │ │ │ ──▶ axios │ │ /flows │ + │ /api/* (reste) │ └──────────────────┘ └──────────────┘ + │ ──▶ legacy │ + └─────────────────┘ +``` + +### Structure du module + +``` +src/cpin-module/ +├── infrastructure/ +│ └── auth/ # Auth transverse (réutilisable) +│ ├── auth.module.ts # Module NestJS +│ ├── auth.service.ts # Lookup token SHA256 → Prisma +│ ├── admin-permission.guard.ts # Guard : vérifie x-dso-token + permissions +│ ├── admin-permission.decorator.ts @RequireAdminPermission('ListSystem') +│ └── admin-permission.guard.spec.ts +│ +└── service-chain/ # Module métier + ├── service-chain.module.ts # Imports: ConfigurationModule, AuthModule + ├── service-chain.controller.ts # 5 endpoints sous /api/v1/service-chains + ├── service-chain.service.ts # Proxy axios → OpenCDS + validation Zod + ├── service-chain.controller.spec.ts + └── service-chain.service.spec.ts +``` + +### Authentification + +Seule l'**auth par token** (`x-dso-token`) est supportée pour l'instant. +L'auth par session Keycloak sera ajoutée lors de la migration globale du +mécanisme de session vers NestJS (les cookies de session Fastify ne sont pas +déchiffrables par Express). + +``` + Requête HTTP + │ + ▼ + ┌──────────────────────┐ + │ AdminPermissionGuard │ + │ │ + │ 1. Lire header │ + │ x-dso-token │──── absent ──▶ 401 Unauthorized + │ │ + │ 2. SHA256(token) │ + │ → Prisma lookup │──── invalide ──▶ 401 Unauthorized + │ (PersonalAccess │ (expiré, révoqué, introuvable) + │ Token ou Admin │ + │ Token) │ + │ │ + │ 3. Vérifier perms │ + │ bitwise via │──── insuffisant ──▶ 403 Forbidden + │ AdminAuthorized │ + │ │ + │ 4. OK → continuer │──────────────────▶ Controller + └──────────────────────┘ +``` + +### Endpoints + +| Méthode | Route | Permission | Description | +|---------|-------|------------|-------------| +| `GET` | `/api/v1/service-chains` | `ListSystem` | Liste toutes les chaînes | +| `GET` | `/api/v1/service-chains/:id` | `ListSystem` | Détails d'une chaîne | +| `GET` | `/api/v1/service-chains/:id/flows` | `ListSystem` | Flux d'une chaîne | +| `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | Relancer une chaîne | +| `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | Valider une chaîne | + +### Différences avec le legacy + +- **403 systématique** : le legacy retournait `[]` sur `GET /` sans permission ; + le NestJS retourne 403 pour tous les endpoints sans permission. +- **Pas d'auth session** : seul `x-dso-token` fonctionne (le legacy supportait + aussi les sessions Keycloak). +- **Validation UUID** : les paramètres d'URL sont validés via `ParseUUIDPipe` + (400 si format invalide). + +### Variables d'environnement + +| Variable | Description | Défaut | +|----------|-------------|--------| +| `OPENCDS_URL` | URL de base de l'API OpenCDS | _(vide = désactivé)_ | +| `OPENCDS_API_TOKEN` | Token d'API OpenCDS (header `X-API-Key`) | — | +| `OPENCDS_API_TLS_REJECT_UNAUTHORIZED` | Vérification TLS (`true`/`false`) | `true` | + +--- + +Pour mettre à jour `old-server` (après avoir rebasé sur `origin/master`, par exemple) : + +```bash +server-nestjs/$ rm -rf src/cpin-module/old-server +server-nestjs/$ cp -r ../server src/cpin-module/old-server +server-nestjs/$ find src/cpin-module/old-server -type f -iname "*.ts" -exec sed -i -e "s#@/#@old-server/#g" {} \; +server-nestjs/$ find src/cpin-module/old-server -type f -iname "*.ts" -exec sed -i -e "s#\.[jt]s'#'#g" {} \; +``` + +## Fichiers à supprimer dans le futur + +Certains fichiers de `old-server` servait de "framework" pour le backend, et +vont donc être réécrits en tant que modules/services NestJS. On va garder la +liste ici, ce qui permettra de ne pas être constamment en conflit sur le code +de `server`. En attendant de pouvoir s'en débarrasser, et afin de s'assurer que +leur code n'est pas utilisé dans d'autres parties du backend, on va commenter +l'intégralité de ces fichiers (comme ça pas d'erreur d'import quand on les +supprimera). + +Voilà donc la liste des fichiers "dépréciés" : + +``` +old-server/src/app.ts -> Réécrit en AppService +old-server/src/connect.ts -> Réécrit en DatabaseService +old-server/src/init/db/* (à part dump.ts) -> Réécrit en DatabaseInitializationService +old-server/src/resources/**/router.ts -> Réécrit en **RouterService +old-server/src/resources/index.ts -> Réécrit en RouterService +old-server/src/server.ts -> Intégré à ApplicationInitializationService +old-server/src/utils/env.ts -> Réécrit en ConfigurationModule +old-server/src/utils/fastify.ts -> Réécrit en FastifyService +old-server/src/utils/keycloak-utils.ts -> Intégré dans AppService +old-server/src/utils/keycloak.ts -> Intégré dans AppService +old-server/src/utils/logger.ts -> Réécrit en LoggerModule +old-server/src/utils/plugin.ts -> Réécrit en PluginManagementService +``` + +## Prochaines itérations sur le sujet + +Tâches à réaliser par la suite dans d'autres itérations/tickets/etc. : + +- Migrer une fonctionnalité "verticale" complète (Route, Contract, Controller, + Business, Queries, Prisma schema) dans son propre module NestJS qui sera + importé dans `MainModule` (et pas dans `CpinModule` qui devra disparaître + à terme). +- Définir la liste de ces fonctionnalités verticales, et planifier l'extraction + de certaines d'entre elles (OpenCDS, typiquement, qui n'a rien à faire dans + le code de base de la Console) +- Migrer la base actuelle de NestJS de Jest vers Vitest et s'assurer que les + tests unitaires passent à nouveaux (attention, certains d'entre eux devront + être adaptés vu qu'on a commencé à réécrire du code au standard NestJS) +- Intérgrer `server-nestjs` dans tous les `docker compose` et les différents + scripts PNPM/Bash qui font tout le sel de nos process de dev +- Revoir les imports de données (le fameux `dump.ts`). C'était déjà une + mauvaise idée à l'époque, ça l'est encore plus aujourd'hui. On ferait mieux + d'utiliser un side-container pour ça +- Être capable de déployer `server-nestjs` en parallèle de `server`, avec + probablement un reverse proxy dédié à la migration des routes de l'ancien + vers le nouveau (à rediscuter plus concrètement) + +Les étapes d'après-après (quand on sera sereins sur la migration de `server` +vers `server-nestjs`) : + +- Tester les capacités de NestJS SSR (Server Side Rendering), notamment + vis-à-vis de nos composants VueJS. Il y aura probablement des sujets autour + de l'isolation du code VueJS des différentes fonctionnalités afin de + faciliter leur extraction de `client` vers `server` +- Implémenter une fonctionnalité du Front en tant que module NestJS SSR + (OpenCDS est un très bon cas d'étude grâce à son côté très isolé dans le + code). Idéalement ce code "Front" ajouté "côté Backend" devra être colocalisé + avec le code "Backend" correspondant. diff --git a/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md b/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md new file mode 100644 index 0000000000..1973831edb --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md @@ -0,0 +1,214 @@ +# Modularisation Backend : Node.js Server → NestJS + +## 🎯 Objectifs de la modularisation + +### Principes directeurs + +- La Console a évolué très rapidement. Depuis sa création jusqu'à récemment, la + priorité a été donnée à l'ajout de fonctionnalités, sans forcément avoir le + temps de consolider le socle technique. La modularisation de sa partie + serveur, à l'aide d'un framework robuste comme NestJS, permettra une + évolution saine de la plate-forme. +- Il y a une volonté de permettre davantage de souplesse dans la configuration + de la console (utilisation d'un plugin en remplacement d'un autre), afin de + permettre aux projets qui voudraient avoir "leur" console de la configurer + comme ils veulent +- Le code du serveur de la console est assez daté, dans ses paradigmes et ses + dépendances. Un nettoyage de ce code par une remise à plat de la cartographie + des interdépendances entre les différents modules permettra d'améliorer les + choses module par module + +### Objectifs techniques +- **Modularisation** : Découpage en modules métier cohérents et maintenables +- **Architecture propre** : Injection de dépendances, séparation des responsabilités +- **Scalabilité** : Base solide pour les évolutions futures +- **Qualité code** : Standards NestJS, TypeScript strict + +### Objectifs organisationnels +- **Continuité produit** : Zéro impact sur les développements en cours +- **Montée en compétence** : Équipe formée progressivement à NestJS +- **Risque maîtrisé** : Modularisation progressive sans interruption de service + +## 📊 Contexte technique + +### Stack actuelle +- **Backend** : Node.js (avec ts-rest/fastify et injection de dépendance manuelle des plugins) +- **ORM** : Prisma avec PostgreSQL +- **Auth** : OIDC avec Keycloak +- **Tests** : Playwright (E2E), Vitest (unitaires) +- **Déploiement** : Docker Compose +- **CI/CD** : Pipeline existante + +### Stack cible +- **Backend** : NestJS (TypeScript) +- **ORM** : Prisma (conservé, partagé) +- **Auth** : OIDC Keycloak +- **Tests** : Playwright + Vitest (conservés) +- **Déploiement** : Docker Compose (phase transition) → NestJS seul +- **CI/CD** : Adaptée pour build et le déploiement des 2 images en parallèle + +### Métriques du projet +- **Routes** : ~100 +- **Équipe** : ~5 développeurs +- **Timeline** : 3 mois (12 sprints) +- **Couverture tests E2E** : 33% → objectif 50% + +## 🏗️ Stratégie : Strangler Fig Pattern + +### Principe +Le nouveau backend `server-nestjs` coexiste avec l'ancien et "étouffe" progressivement `server` en reprenant route par route ses fonctionnalités. + +### Pourquoi cette approche ? +✅ **Zero downtime** : Service continu pendant la modularisation +✅ **Rollback facile** : Retour arrière immédiat via configuration nginx +✅ **Équipe non bloquée** : Développements possibles en parallèle +✅ **Validation progressive** : Tests en prod sur petit périmètre +✅ **Risque maîtrisé** : Pas de big bang + +### Architecture de transition + +``` +┌─────────────┐ +│ Nginx │ ← Point d'entrée unique (port 80) +│ (Reverse │ +│ Proxy) │ +└──────┬──────┘ + │ + ├─────────────┐ + │ │ + Migré Pas encore + │ │ + │ │ +┌──────▼──────┐ ┌────▼───────┐ +│ NestJS API │ │ Server API │ +│ (3001) │ │ (3000) │ +└──────┬──────┘ └────┬───────┘ + │ │ + └─────────────│ + │ + ┌──────▼─────┐ + │ PostgreSQL │ + │ (Prisma) │ + └────────────┘ +``` + +## 📋 Documents de référence + +Cette modularisation est documentée dans plusieurs fichiers : + +1. **[00-OVERVIEW.md](00-OVERVIEW.md)** *(ce document)* - Vue d'ensemble +3. **[01-MODULARISATION-STRATEGY.md](01-MODULARISATION-STRATEGY.md)** - Stratégie et méthodologie +7. **[MODULARISATION_STATUS.md](MODULARISATION_STATUS.md)** - Suivi en temps réel (fichier vivant) +8. **[MODULARISATION_MAP.md](MODULARISATION_MAP.md)** - Cartographie des modules (fichier vivant) + +## 🚀 Démarrage rapide + +### Pour l'équipe de développement +1. Lire ce document (OVERVIEW) +2. Consulter [MODULARISATION\_STATUS.md](MODULARISATION_STATUS.md) pour connaître l'état actuel +3. Suivre le canal Mattermost `#backend-modularisation` +4. Respecter les zones en modularisation (🚧) avant de développer + +### Pour le lead technique de la modularisation +1. Lire tous les documents dans l'ordre +2. Personnaliser [MODULARISATION\_MAP.md](MODULARISATION_MAP.md) avec la cartographie réelle +3. Configurer l'infrastructure +4. Lancer la communication + +## 🎯 Principes directeurs + +### Technique +- **Parité fonctionnelle** : Chaque route migrée doit être strictement équivalent à son prédécesseur +- **Tests de contrat** : Validation automatique de la parité comportementale +- **Monitoring** : Surveillance continue des performances et erreurs +- **Documentation** : Chaque décision architecturale documentée (ADR) + +### Organisationnel +- **Communication proactive** : Annonce claire des zones en modularisation +- **Collaboration** : "Pair programming" encouragé pour montée en compétence +- **Pragmatisme** : Adapter le planning selon les contraintes business +- **Célébration** : Reconnaître les étapes franchies + +## ⚠️ Règles critiques + +### À FAIRE +✅ Annoncer début/fin de modularisation d'un module sur Mattermost +✅ Mettre à jour MODULARISATION\_STATUS.md à chaque changement +✅ Écrire des tests de contrat avant de migrer une route +✅ Déployer progressivement (intégration → staging → prod) +✅ Monitorer 24-48 h après chaque modularisation + +### À NE PAS FAIRE + +❌ Migrer un module pendant qu'une fonctionnalité y est développée +❌ Modifier le schéma Prisma pendant la modularisation (sauf critique) +❌ Déployer sans tests E2E passants +❌ Oublier de mettre à jour la config nginx pour indiquer la bascule d'une route +❌ Développer sur `server` (et pas sur `server-nestjs`) un module déjà migré + +## 📞 Contacts + +### Responsable modularisation +- **Lead technique** : @stephane.trebel +- **Canal Mattermost** : #backend-modularisation +- **Réunions** : Hebdo modularisation (les jeudis à 10h, 30 min) + +### Escalade +- **Problème technique bloquant** : Contacter @lead-dev immédiatement +- **Conflit de développement** : Contacter @lead-dev immédiatement +- **Incident de Production** : Retour en arrière Nginx sur la route concernée + +## 📈 Métriques de succès + +### Fin de modularisation (sprint 12) +- [ ] 100% des routes migrées sur NestJS +- [ ] code de `server` supprimé du dépôt Git de `console` +- [ ] Couverture des Tests E2E ≥ 50% des cas d'usage de `console` +- [ ] Documentation technique complète des modules (cartographie, dépendances inter-modules, etc.) +- [ ] 0 régression fonctionnelle +- [ ] Équipe autonome sur NestJS + +### Indicateurs intermédiaires +- **S4** : 20% migré (Modules transverses (logger, configuration, etc.)) +- **S6** : 40% migré (Modules "verticaux" comme Zone, Cluster, etc.) +- **S8** : 60% migré +- **S10** : 80% migré (Plugins, comme ArgoCD, Gitlab, etc.) +- **S12** : 100% migré + +### Migrations anticipées +- **ServiceChain (OpenCDS)** : Migré le 2026-04-09 (prévu V3/S8). Ce module + isolé (proxy HTTP vers API externe, aucune + dépendance entrante) a servi de premier test de bout en bout du pipeline de + migration : module NestJS + `InfrastructureModule` + `UserGuard` + + nginx-strangler + Docker. + +## 🗓️ Dates clés + +- **S1-S2** : Cartographie et setup +- **S3-S4** : Modularisation des modules transverses +- **S5-S6** : Modularisation des modules métiers "cœur" (Zone, Cluster, etc.) +- **S7-S10** : Modularisation modules plugins (ArgoCD, Gitlab, etc.) +- **S11-S12** : Finalisation et nettoyage du code (suppression reverse proxy, suppression de `server`, etc.) + +*Dates indicatives, ajustables selon les contraintes business* + +## 📚 Ressources + +### Documentation NestJS + +- [Documentation officielle NestJS](https://docs.nestjs.com/) +- [Prisma avec NestJS](https://docs.nestjs.com/recipes/prisma) +- [Authentification OIDC NestJS](https://docs.nestjs.com/recipes/passport) + +### Outils + +- [Madge](https://github.com/pahen/madge) - Analyse de dépendances +- [Compodoc](https://compodoc.app/) - Documentation auto +- [Playwright](https://playwright.dev/) - Tests E2E +- [Vitest](https://vitest.dev/) - Tests unitaires + +--- + +**Version** : 1.2 +**Dernière mise à jour** : 2026-06-16 +**Prochaine revue** : Fin S2 diff --git a/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md b/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md new file mode 100644 index 0000000000..10c05e2525 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md @@ -0,0 +1,313 @@ +# Stratégie de Modularisation - console/server → console/server-nestjs + +## 🎯 Approche globale + +Sachant que nous avons des fonctionalités assez bien identifiées (AdminTokens, Plugin Gitlab, etc.), nous allons utiliser une approche « Modularisation par verticaux ». + +### Principe + +Plutôt que de migrer par couches horizontales (tous les DTOs, puis tous les services, puis tous les controllers, puis tous les plugins), nous migrerons **module complet par module complet**. + +### Pourquoi cette approche ? + +✅ **Valeur métier immédiate** : Chaque itération livre un module fonctionnel +✅ **Tests E2E possibles** : Validation complète de la non-régression dès la fin du module +✅ **Risque isolé** : Un problème n'affecte qu'un seul domaine métier +✅ **Rollback granulaire** : Retour arrière possible module par module +✅ **Équipe non bloquée** : Autres modules restent disponibles pour développement +✅ **Reprise du typage** : Beaucoup de modules ont un typage Typescript lacunaire, voire inexistant (`any`). Migrer un module sera l'opportunité de reprendre ces aspects et définir clairement l'"API" du Module + +### Anatomie d'un vertical + +``` +ZoneModule (exemple) +├── Model (Prisma) ← Déjà existant, partagé +├── Repository/Service ← Modularisation du code métier +├── Controller/Routes ← Modularisation des endpoints +├── DTOs & Validation ← Ajout validation NestJS +├── Tests unitaires ← Nouveaux tests Vitest +└── Tests E2E ← Adaptation tests Playwright si besoin (normalement jamais) +``` + +## Phase 1: 📊 Cartographie et priorisation du code de console/server actuel + +### Objectifs de la cartographie + +1. **Inventaire exhaustif** : Lister tous les endpoints et leurs dépendances +2. **Graphe de dépendances** : Identifier les modules couplés entre eux (typiquement Vault et…tout le reste) +3. **Priorisation** : Définir l'ordre optimal de modularisation (de "bas en haut", donc de ceux qui ont le moins de dépendances à ceux qui dépendent de presque tout le monde) +4. **Estimation** : Évaluer grosse maille la charge de chaque module + +### Outils d'analyse automatique + +Une manière de faire qui combine une approche automatique (on parcourt tout ce qu'on peut) et une approche empirique (on regarde ce que ça donne) peut se faire de la manière suivante : + +```bash +# 1. Installer les outils +npm install -D madge dependency-cruiser + +# 2. Générer le graphe de dépendances +npx madge --image dependency-graph.png --extensions js,ts server/src + +# 3. Détecter les cycles de dépendances +npx madge --circular --extensions js,ts server/src + +# 4. Analyser les violations architecturales +npx depcruise src --output-type err +``` + +### Ordre de modularisation recommandé + +**Stratégie en 3 phases** : + +1. **Phase d'apprentissage (S3-S4)** : Modules simples mais critiques + - Auth : Critique mais bien délimité + - Objectif : Valider l'approche, former l'équipe + +2. **Phase de vélocité (S5-S8)** : Modules métier principaux + - Cluster, Zone, etc. + - Objectif : Migrer le gros du trafic + +3. **Phase de finalisation (S9-S12)** : Modules périphériques + - Plugins qui doivent exister impérativement (Kubernetes, Gitlab, etc. -LISTE À DEFINIR-) + - Objectif : Compléter la modularisation, suppression de `server` et renommage de `server-nestjs` en `server` + +## 🔄 Gestion du passage simple singleton node.js → Injection de dépendances dans des services singleton + +### Le problème des singletons purement "node.js" + +Les singletons actuellement utilisés dans le code sont ceux de Node.js. Ce sont +des instances certes uniques, mais qui reposent intégralement sur une logique +d'import des fichiers. Cette logique n'est pas mauvaise en soi (et fonctionne +encore maintenant), mais elle est très limitée, car elle ne nous permet pas de +configurer les "modules" importés (changer une dépendance par une autre, par +exemple). Par essence les modules node.js ne permettent pas d'injection de +dépendance, en particulier au runtime. + +```javascript +// ❌ Pattern singleton dans le code server +const prisma = require('../db').default; + +async findById(id) { + return prisma.user.findUnique({ where: { id } }); +} + +async create(data) { + return prisma.user.create({ data }); +} + +// Export d'une instance unique (singleton) +module.exports = { findById, create }; +``` + +**Problèmes** : +- État partagé mutable +- Difficile à tester (mocking complexe) +- Pas de contrôle du cycle de vie +- Couplage fort avec les dépendances +- Typage souvent lacunaire + +### Solution : Migrer vers un vrai service singleton de type NestJS + +```typescript +// nest-backend/src/users/users.service.ts +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { User } from '@prisma/client'; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id } }); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + return user; + } + + async create(createUserDto: CreateUserDto): Promise { + return this.prisma.user.create({ + data: createUserDto, + }); + } + + // Ajouter validation, transformation, logique métier +} +``` + +### Checklist de modularisation d'un singleton node.js -> singleton NestJS + +Pour chaque service singleton node.js à migrer : + +- [ ] **Identifier** : Lister tous les singletons node.js (grep `module.exports =` ou `export default`) +- [ ] **Analyser** : Documenter l'interface publique (méthodes exposées) +- [ ] **État mutable** : Identifier les propriétés d'instance partagées +- [ ] **Dépendances** : Lister ce dont le singleton dépend +- [ ] **Créer le service** : Reproduire l'interface du singleton en NestJS +- [ ] **Tests de contrat** : Valider la parité comportementale avant/après +- [ ] **Migrer progressivement** : Remplacer les usages un par un +- [ ] **Nettoyage** : Supprimer le singleton node.js une fois inutilisé + +## 📝 Méthodologie de modularisation d'un module + +### Processus en 7 étapes + +#### 1. Analyse et préparation (Jour 1) + +```bash +# Analyser les dépendances du module +npx madge --depends my-module old-server/src + +# Lister les endpoints concernés +grep -r "router.*mymodule" old-server/src/routes + +# Identifier les tests E2E existants +grep -r "my-module" playwright/tests/e2e +``` + +**Livrables** : +- Liste des routes/endpoints à migrer +- Graphe des dépendances +- Plan de tests + +#### 2. Setup du nouveau module NestJS (Jour 1) + +**Note**: Afin d'accélérer la création des entités NestJS (services, modules, +contrôleurs, etc.), on pourra utiliser [la CLI `nest`](https://docs.nestjs.com/cli/usages#nest-generate) +pour générer des modèles (*templates*). + +```bash +# Générer le module avec NestJS CLI (par ex pour un module UserModule) +cd nest-backend +nest g module users +nest g controller users +nest g service users +``` + +**Structure créée** : +``` +src/users/ +├── users.module.ts +├── users.controller.ts +├── users.service.ts +├── dto/ +│ ├── create-user.dto.ts +│ └── update-user.dto.ts +├── entities/ +│ └── user.entity.ts +└── users.controller.spec.ts +``` + +#### 3. Modularisation de la logique métier (Jours 2-3) + +**Ordre de modularisation** : +1. DTOs et contrat de validation, suivant les besoins (certains services ont + besoin d'un DTO puisqu'ils utilisent la BDD, d'autres non) +2. Service (logique métier du module) +3. Controller (routes du module) +4. Guards et interceptors pour les contrôles qui peuvent être factorisés + +#### 4. Écriture des tests unitaires pour le nouveau service (Jour 3) + +#### 5. Adaptation des tests E2E si nécessaire (Jour 4) + +#### 6. Configuration du nginx étrangleur et déploiement (Jour 5) + +**Mise à jour de `apps/nginx-strangler/routing.conf`** : + +```nginx +# [S5-S6] Users - 2026-02-23 +location /api/xxxxx { + proxy_pass http://server-nest; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location /api/yyyyy { + proxy_pass http://server-nest; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Note: Prévoir un "blue-green" afin de permettre un passage souple et serein +entre les anciens et nouveaux services. + +**Déploiement progressif** : + +```bash +# 1. Backup de la config actuelle +cp apps/nginx-strangler/routing.conf apps/nginx-strangler/routing.conf.backup + +# 2. Mise à jour de la config +# (éditer apps/nginx-strangler/routing.conf) + +# 3. Validation de la config nginx +docker-compose exec nginx nginx -t + +# 4. Rechargement sans downtime +docker-compose exec nginx nginx -s reload + +# 5. Monitoring (surveiller les logs pendant 5-10min) +docker-compose logs -f nginx | grep users +``` + +Note: Prévoir un retour arrière en cas de problème ⚠ + +#### 7. Monitoring et validation (Jours 5-7) + +Note: Faire un point sur la métrologie existante. Envisager si besoin de +fiabiliser la stack d'observabilité, implémenter Loki et remonter des métriques +si besoin). + +**Métriques à surveiller** : + +```bash +# Logs en temps réel +docker-compose logs -f nest-api | grep users + +# Taux d'erreur +docker-compose logs nest-api | grep ERROR | grep users | wc -l + +# Temps de réponse moyen (exemple avec awk) +docker-compose logs nginx | grep "/api/users" | \ + awk '{print $NF}' | awk -F'=' '{sum+=$2; count++} END {print sum/count}' +``` + +**Validation** : +- [ ] Aucune erreur 5xx pendant 24h +- [ ] Temps de réponse comparable au code `server` (±20%) +- [ ] Tests E2E passants +- [ ] Recette utilisateurs (si applicable) + +## 🚨 Gestion des situations complexes + +### Cas 1 : Module avec beaucoup de dépendances + +**Stratégie** : +1. Migrer d'abord les dépendances (Users, Products) +2. Créer des adaptateurs temporaires (des services "passe-plat") pour les modules qui ne sont pas encore migrés +3. Tester intensivement les interactions entre modules (typage fort, tests d'intégration, E2E, etc.) + +## ✅ Checklist de fin de module + +Avant de considérer un module comme "migré" : + +- [ ] Tous les endpoints du module redirigent vers `server-nestjs` +- [ ] Tests de contrat passants (parité avec le code `server`) +- [ ] Tests E2E passants (éventuellement adaptés) +- [ ] Configuration nginx mise à jour et déployée +- [ ] Monitoring actif +- [ ] Aucune régression détectée +- [ ] Documentation technique à jour +- [ ] MODULARISATION_STATUS.md mis à jour +- [ ] Annonce de fin sur Mattermost diff --git a/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md b/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md new file mode 100644 index 0000000000..257b260401 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md @@ -0,0 +1,120 @@ +# Architecture d’un module (pattern `apps/server-nestjs/src/modules/*`) + +Les modules NestJS métier vivent dans `src/modules//` et suivent un découpage “vertical slice” avec des responsabilités explicites : **client**, **service (API publique)**, **controller service (orchestration)**, **datastore**, **utils** et **tests**. + +Exemples concrets : + +- Module GitLab : `src/modules/gitlab/` +- Module Keycloak : `src/modules/keycloak/` + +## Structure type + +```txt +src/modules// +├── .module.ts +├── .constants.ts +├── -client.service.ts +├── .service.ts +├── -datastore.service.ts +├── .utils.ts +├── -testing.utils.ts +└── *.spec.ts +``` + +## Sens des dépendances (flow recommandé) + +Objectif : un flux de dépendances lisible et sans cycles. + +```txt +.service.ts + ↙ ↘ +-client.service.ts -datastore.service.ts +``` + +Règles pratiques : + +- Le `.service.ts` est un entrypoint interne (cron, events, reconcile) et orchestre en appelant directement le `client` (et le `datastore` si nécessaire), sans dépendre du `service`. +- Le `service` contient les règles métier (décisions, transformations, validations) et s’appuie sur le `client` (et le `datastore` quand la lecture/écriture DB fait partie du cas d’usage). +- Le `client` encapsule l’accès à une API externe (initialisation + appels + erreurs bas niveau). +- Le `datastore` encapsule l’accès DB (Prisma) et expose des méthodes de lecture/écriture typées. +- Les `utils` restent “purs” (pas d’IO, pas d’injection Nest). +- Les `testing utils` centralisent les factories/fixtures pour réduire la duplication dans les tests. + +## Composants + +### `.module.ts` + +Rôle : + +- Déclare les providers, imports, exports du module. +- Exporte le `service` du module (`.service.ts`) qui constitue l’API publique. + +### `-client.service.ts` + +Rôle : + +- Adapter vers le système externe (SDK HTTP, client Keycloak, client GitLab, etc.). +- Conserver un contrat stable pour le reste du module. +- Mapper/normaliser les erreurs externes si nécessaire. + +À éviter : + +- Décisions métier (permissions, synchronisation, règles de purge) : elles vont dans `.service.ts` ou le controller service. + +### `.service.ts` + +Rôle : + +- Orchestrateur de workflows : `@Cron`, `@OnEvent`, reconcile périodique, tâches “batch”. +- Coordination entre `client` et `datastore` (sans dépendre du `service`). +- Garde-fous “safety” avant opérations destructrices (ex: suppression de groupes orphelins). + +### `-datastore.service.ts` + +Rôle : + +- Accès DB via Prisma (select/include, transactions, pagination). +- Exposition de types agrégés utiles au domaine (ex: `ProjectWithDetails`). + +À éviter : + +- Appliquer des règles métier (ex: calcul de permissions) : on garde le datastore centré persistence. + +### `.utils.ts` + +Rôle : + +- Fonctions utilitaires pures : mapping, helpers de collections, types partagés. +- Aucune dépendance Nest, aucune lecture/écriture DB, aucun appel réseau. + +### `-testing.utils.ts` + +Rôle : + +- Factories typées pour les structures fréquemment utilisées en tests. +- Support d’`overrides` pour construire rapidement des variantes. +- Centralisation des erreurs/fake responses spécifiques au module (quand utile). + +## Tests (Vitest) + +### `.service.spec.ts` + +Cible : + +- Orchestration : séquences d’appels, side-effects attendus, reconcile. + +Approche : + +- Mock du `service`, du `datastore`, et des appels externes. +- Vérification des appels effectués et des paramètres attendus. + +### `-datastore.service.spec.ts` (si présent) + +Cible : + +- Forme des requêtes Prisma, mapping de résultat, typage de l’agrégat renvoyé. + +Approche : + +- Mock de Prisma/DatabaseService, pas de logique métier. + diff --git a/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md b/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md new file mode 100644 index 0000000000..a5e6b10c1d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md @@ -0,0 +1,1276 @@ +# Cartographie des modules - Modularisation Backend + +> Derniere mise a jour : **2026-06-16** (refonte Infrastructure/Auth/Permission) + +--- + +## Objectif de ce document + +Cartographier l'ensemble des modules de l'application backend actuelle pour : +1. Identifier les dependances entre modules +2. Prioriser l'ordre de modularisation +3. Estimer la charge de chaque module +4. Faciliter la communication sur les zones en modularisation + +--- + +## Decisions structurantes + +| Decision | Choix | +|----------|-------| +| Approche migration | Bottom-up (feuilles d'abord, puis remontée vers les modules couples) | +| Contrats API | Decorateurs NestJS natifs + class-validator (abandon de ts-rest) | +| queries-index.ts | Supprime des le depart : chaque module NestJS possede ses propres queries Prisma | +| Systeme d'evenements | `@nestjs/event-emitter` (remplacement progressif de `@cpn-console/hooks`) | +| Plugins | Deviennent des modules NestJS (encapsulation puis reecriture progressive) | +| Module project | Decoupe en 3 sous-modules : ProjectCore, ProjectSecrets, ProjectBulk | +| Equipe dediee | 2 developpeurs en parallele | + +--- + +## Vue synthetique + +**75 routes metier** reparties en **18 modules metier** (dont project eclate en 3), +plus **5 couches transverses** et **7 plugins** a encapsuler. + +### Legende des statuts + +- FAIT : Deja implemente dans `server-nestjs` +- A CREER : N'existe pas encore +- A MIGRER : Existe dans `server`, a migrer vers `server-nestjs` + +--- + +## Architecture lasagnes - Etat des lieux + +``` + Couche Etat actuel server-nestjs ++--------------------------------------------------------------+ +| Couche 6 : Modules metier (controllers, DTOs) | A MIGRER ++--------------------------------------------------------------+ +| Couche 5 : Plugins (modules NestJS injectables) | A CREER ++--------------------------------------------------------------+ +| Couche 4 : Evenements (EventEmitter, remplacement hooks) | A CREER ++--------------------------------------------------------------+ +| Couche 3 : Securite (AuthService, UserGuard, ProjectGuard, Filters) | PARTIEL ++--------------------------------------------------------------+ +| Couche 2 : Core (AppService, FastifyService) | FAIT ++--------------------------------------------------------------+ +| Couche 1 : Infrastructure (Config, Logger, DB, HTTP) | FAIT ++--------------------------------------------------------------+ +``` + +Les couches 1 et 2 sont en place. La couche 3 est **partiellement** en place : +l'auth par token (`x-dso-token`) et le bearer JWT Keycloak passent désormais +par `AuthService`. La couche de permissions est structurée autour de +`InfrastructureModule -> PermissionModule -> UserModule / ProjectModule` +avec `UserGuard`, `ProjectGuard`, `UserService` et `ProjectService`. +Il reste a homogénéiser les usages des decorateurs de contexte utilisateur +(`@AuthUser()`), et le `GlobalExceptionFilter`. +Les couches 4 et 5 restent a creer. + +--- + +## Couche 0 - Socle transverse (S3, priorite absolue) + +Ces elements ne sont pas dans la matrice de scoring car ce ne sont pas des +modules metier. Ils conditionnent cependant la migration de **tous** les modules +qui suivent. Les creer en premier permet que chaque migration de module soit un +exercice de "remplissage" des couches superieures, sans reinventer +l'infrastructure a chaque fois. + +### 0a. AuthService + decorateur @AuthUser() — PARTIEL + +**Sprint** : S3 (Dev A) +**Remplace** : `authUser()` dans `apps/server/src/utils/controller.ts` + +**Etat actuel (2026-04-09)** : La partie auth par token est implementee dans +`infrastructure/auth/` : +- ✅ `AuthService` : validation token SHA256, lookup Prisma + (PersonalAccessToken + AdminToken), verification status/expiration, calcul + permissions bitwise (y compris roles globaux) +- ✅ `AuthService` : route `x-dso-token` et bearer JWT Keycloak vers + `DsoTokenService` / `KeycloakJwtService` +- ✅ `UserGuard` : applique les requirements de permission et injecte le + contexte utilisateur dans la request +- ✅ `@AuthUser()` : decorateur de parametre pour le contexte utilisateur +- ✅ `@RequireAdminPermission()` / `@RequireProjectPermission()` : metadata + de permissions admin et projet + +**Reste a faire** : +- Harmonisation des controles d'accès entre modules restants +- Chargement optionnel du projet (par slug, id, environmentId, repositoryId) +- Formalisation des types de contexte utilisateur/projet partages + +### 0b. GlobalExceptionFilter + +**Sprint** : S3 (Dev B) +**Remplace** : Les classes de `apps/server/src/utils/errors.ts` + +Mapping des erreurs : + +| Classe actuelle (server) | Equivalent NestJS | +|--------------------------|-------------------| +| `BadRequest400` | `BadRequestException` | +| `Unauthorized401` | `UnauthorizedException` | +| `Forbidden403` | `ForbiddenException` | +| `NotFound404` | `NotFoundException` | +| `Unprocessable422` | `UnprocessableEntityException` | +| `Internal500` | `InternalServerErrorException` | + +Le filtre global intercepte les `HttpException` NestJS et les formate dans le +schema de reponse attendu par le client (`{ status, body: { message } }`). + +### 0c. PermissionModule + decorateurs de permissions + +**Sprint** : S3-S4 (Dev A) +**Remplace** : `getProjectPermissions`, `AdminAuthorized`, `ProjectAuthorized` dans `utils/controller.ts` + +Fonctionnalites : +- Decorateurs `@RequireAdminPermission()` et `@RequireProjectPermission()` sur les methodes de controller +- Guard `UserGuard` qui verifie les bitmasks de permissions admin et projet +- Support des deux niveaux : `AdminRole` (global) et `ProjectRole` (scope projet) + +### 0d. ValidationPipe global + +**Sprint** : S3 (Dev B) +**Remplace** : La validation Zod des contrats ts-rest + +Configuration : +- `class-validator` + `class-transformer` en pipe global +- `whitelist: true` (strip des proprietes non declarees) +- `forbidNonWhitelisted: true` (erreur si proprietes inconnues) +- `transform: true` (transformation automatique des types) + +### 0e. EventEmitterModule + +**Sprint** : S4-S5 (Dev A) +**Remplace** : `@cpn-console/hooks` (premiere iteration) + +Mise en place de `@nestjs/event-emitter` avec : +- Evenements types pour chaque hook actuel (ProjectUpserted, ClusterDeleted, etc.) +- Pattern `@OnEvent('project.upserted')` pour les abonnements +- Pas de steps pre/check/main/post dans cette v1 : orchestration lineaire + +Ce module sera utilise par les modules metier des la Vague 2 (quand les modules +avec hooks commencent a etre migres). + +--- + +## Matrice de priorisation + +### Criteres de scoring + +| Critere | Poids | Description | +|---------|-------|-------------| +| Dependances faibles | 40% | Moins de dependances sortantes = plus facile a extraire (bottom-up) | +| Criticite | 30% | Balance risque/valeur. Un module critique et bien delimite score haut | +| Trafic | 20% | Volume raisonnable pour validation de la migration | +| Difficulte (inversee) | 10% | Complexite technique inversee (facile = score eleve) | + +### Formule + +``` +Score = (Dependances x 0.4) + (Criticite x 0.3) + (Trafic x 0.2) + (Difficulte x 0.1) + +Echelle : 1-10 pour chaque critere. +Plus le score est eleve, plus le module est prioritaire. +``` + +### Resultats ordonnes par score + +| Rang | Module | Type | Score | Vague | Sprint | Statut | +|------|--------|------|-------|-------|--------|--------| +| 1 | vault (encapsulation) | Plugin | 8.5 | V3 | S7-S8 | ✅ MIGRE | +| 2 | system (health/version) | Metier | 7.8 | V1 | S3 | ✅ MIGRE | +| 3 | system/settings | Metier | 7.4 | V1 | S3 | ✅ MIGRE | +| 4 | system/config | Metier | 7.4 | V1 | S3-S4 | | +| 5 | keycloak (encapsulation) | Plugin | 7.4 | V3 | S8 | ✅ MIGRE | +| 6 | admin-token | Metier | 7.1 | V1 | S3-S4 | | +| 7 | user/tokens | Metier | 7.1 | V1 | S3-S4 | | +| 8 | gitlab (encapsulation) | Plugin | 6.7 | V4 | S9 | ✅ MIGRE | +| 9 | service-monitor | Metier | 6.6 | V2 | S5 | | +| 10 | user | Metier | 6.6 | V2 | S5 | | +| 11 | stage | Metier | 6.5 | V2 | S5-S6 | | +| 12 | log | Metier | 6.5 | V1 | S4 | | +| 13 | zone | Metier | 6.4 | V2 | S6 | | +| 14 | environment | Metier | 6.3 | V3 | S7 | | +| 15 | admin-role | Metier | 6.1 | V2 | S5 | | +| 16 | project-core | Metier | 5.8 | V4 | S9 | | +| 17 | service-chain | Metier | 5.9 | V3 | S8 | ✅ MIGRE | +| 18 | repository | Metier | 5.8 | V3 | S7-S8 | | +| 19 | cluster | Metier | 5.7 | V3 | S7 | | +| 20 | harbor (encapsulation) | Plugin | 5.6 | V4 | S9-S10 | ✅ MIGRE | +| 21 | project-service | Metier | 5.6 | V3 | S8 | | +| 22 | argocd (encapsulation) | Plugin | 5.3 | V5 | S10-S11 | ✅ MIGRE | +| 23 | project-role | Metier | 5.2 | V3 | S7-S8 | | +| 24 | nexus (encapsulation) | Plugin | 5.1 | V4 | S10 | ✅ MIGRE | +| 25 | project-member | Metier | 4.7 | V3 | S8 | | +| 26 | project-secrets | Metier | 4.6 | V4 | S9 | | +| 27 | project-bulk | Metier | 4.2 | V4 | S9-S10 | | +| 28 | sonarqube (encapsulation) | Plugin | 4.2 | V5 | S11 | | + +**Note** : Le score brut ne dicte pas directement l'ordre de migration. +L'ordre reel est contraint par le graphe de dependances (bottom-up), les +pre-requis transverses, et la parallelisation a 2 devs. C'est pourquoi `vault` +(score 8.5) est en Vague 3 et non en Vague 1 : son encapsulation NestJS +necessite l'EventEmitter et un pattern valide sur les modules metier simples. + +--- + +## Vague 1 - Quick wins et validation du pattern (S3-S4) + +**Objectif** : Valider le pattern NestJS complet (Controller, Service, Prisma, +AuthGuard, DTOs class-validator, ExceptionFilter). Former l'equipe. Deployer +les premiers modules via Nginx. + +**Livrable fin S4** : 13 routes migrees (~17%) + +### 1. system (health/version) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.8 | +| **Sprint** | S3 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/version` - Version de l'application +- `GET /api/v1/healthz` - Health check + +**Dependances sortantes** : Aucune (lecture de variable d'env) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Module le plus simple du codebase. Ideal pour valider le pipeline complet : + creation controller NestJS, deploiement Docker, bascule Nginx +- Pas d'authentification requise sur ces routes + +**Estimation** : 0.5 jour + +--- + +### 2. system/settings — ✅ MIGRE (2026-04-28) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.4 | +| **Sprint** | S3 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/system/settings` - Liste des parametres systeme +- `PUT /api/v1/system/settings` - Mise a jour d'un parametre + +**Dependances sortantes** : Aucune (queries propres, Prisma direct) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Premiere occasion de valider le pattern CRUD NestJS avec DTOs +- Auth requise (admin uniquement) + +**Estimation** : 1 jour + +--- + +### 3. system/config + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.4 | +| **Sprint** | S3-S4 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/plugins-config` - Configuration des plugins +- `PUT /api/v1/admin/plugins-config` - Mise a jour de la configuration + +**Dependances sortantes** : Aucune (queries propres) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth admin requise +- Gestion de la configuration JSON des plugins (AdminPlugin en BDD) + +**Estimation** : 1 jour + +--- + +### 4. admin-token + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 7.1 | +| **Sprint** | S3-S4 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/admin/tokens` - Liste des tokens de service admin +- `POST /api/v1/admin/tokens` - Creation d'un token +- `DELETE /api/v1/admin/tokens/:tokenId` - Suppression d'un token + +**Dependances sortantes** : Aucune (Prisma direct, pas de queries-index) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth admin requise +- Premier module avec CRUD complet et generation de token (hash bcrypt) +- Pas de hook-wrapper utilise + +**Estimation** : 1 jour + +--- + +### 5. user/tokens + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 7.1 | +| **Sprint** | S3-S4 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/users/me/tokens` - Liste des tokens personnels +- `POST /api/v1/users/me/tokens` - Creation d'un token personnel +- `DELETE /api/v1/users/me/tokens/:tokenId` - Suppression d'un token + +**Dependances sortantes** : Aucune (Prisma direct) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth utilisateur requise (scope personnel : l'utilisateur gere ses propres tokens) +- Similaire a admin-token, bon pour paralleliser +- Hash bcrypt des tokens + +**Estimation** : 1 jour + +--- + +### 6. log + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 1 | +| **Score** | 6.5 | +| **Sprint** | S4 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/logs` - Logs d'audit du projet + +**Dependances sortantes** : `queries-index` (lecture seule via `getAllLogs`) +**Dependances entrantes** : Aucune (beaucoup de modules *ecrivent* des logs +via `addLogs`, mais ce module ne fait que *lire*) + +**Points d'attention** : +- Premiere rencontre avec queries-index. La strategie est de creer les queries + Prisma directement dans le module NestJS (pas de barrel partage) +- Auth utilisateur requise, scope projet +- Read-only : pas de hooks + +**Estimation** : 0.5 jour + +--- + +## Vague 2 - Fondations metier et premiers hooks (S5-S6) + +**Objectif** : Migrer les modules qui sont des dependances pour d'autres. +Introduction de l'EventEmitter dans les modules avec hooks. `stage`, `zone` +et `user` sont des pre-requis pour la Vague 3 (cluster, project-member). + +**Livrable fin S6** : 34 routes migrees (~45%) + +### 7. service-monitor + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 6.6 | +| **Sprint** | S5 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/services` - Sante des services (plugins) +- `GET /api/v1/services/all` - Sante complete de tous les services +- `POST /api/v1/services/refresh` - Rafraichissement des statuts + +**Dependances sortantes** : `@cpn-console/hooks` (appel direct des moniteurs de plugins) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Premier module utilisant le systeme de hooks (via les moniteurs de plugins) +- Bon premier test de l'EventEmitter : les plugins exposent leur moniteur, + le service-monitor les interroge +- Pas de queries Prisma (pas d'interaction BDD) + +**Estimation** : 1 jour + +--- + +### 8. user + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.6 | +| **Sprint** | S5 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/users` - Recherche d'utilisateurs (matching) +- `POST /api/v1/auth` - Authentification / synchronisation session +- `GET /api/v1/admin/users` - Liste complete des utilisateurs (admin) +- `PATCH /api/v1/admin/users` - Modification utilisateurs (admin) + +**Dependances sortantes** : `queries-index` (getMatchingUsers, getUsers), hooks (adminRole.upsert) +**Dependances entrantes** : `project-member` (importe `logViaSession`), `utils/controller.ts` (importe `logViaSession`, `logViaToken`) + +**Points d'attention** : +- Module critique : `logViaSession` et `logViaToken` sont consommes par le + systeme d'auth. Ces fonctions auront ete portees dans l'AuthGuard (Couche 0), + donc la dependance entrante est deja resolue +- Hooks utilises (adminRole.upsert) : necessite l'EventEmitter +- Pre-requis pour `project-member` (Vague 3) + +**Estimation** : 2 jours + +--- + +### 9. admin-role + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 6.1 | +| **Sprint** | S5 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/roles` - Liste des roles admin +- `POST /api/v1/admin/roles` - Creation d'un role +- `PATCH /api/v1/admin/roles` - Modification des roles +- `GET /api/v1/admin/roles/member-counts` - Comptage des membres par role +- `DELETE /api/v1/admin/roles/:roleId` - Suppression d'un role + +**Dependances sortantes** : `queries-index` (getAdminRoleById, listAdminRoles), hooks (adminRole.upsert, adminRole.delete) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Valide le pattern hooks + EventEmitter dans un contexte admin +- Permissions admin (bitmask) : valide `UserGuard` +- Queries a internaliser dans le module + +**Estimation** : 1.5 jours + +--- + +### 10. stage + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 6.5 | +| **Sprint** | S5-S6 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/stages` - Liste des stages (dev, staging, prod, etc.) +- `GET /api/v1/stages/:stageId/environments` - Environnements d'un stage +- `POST /api/v1/stages` - Creation d'un stage +- `PUT /api/v1/stages/:stageId` - Modification d'un stage +- `DELETE /api/v1/stages/:stageId` - Suppression d'un stage + +**Dependances sortantes** : `queries-index` (nombreuses queries stage + cluster) +**Dependances entrantes** : `cluster/business.ts` (importe directement `linkClusterToStages`) + +**Points d'attention** : +- Pre-requis critique pour `cluster` (Vague 3). La fonction `linkClusterToStages` + exportee par stage est consommee directement par cluster. Dans l'architecture + NestJS, ce sera un service injectable du StageModule exporte et importe par + ClusterModule +- Pas de hooks directs +- Beaucoup de queries a internaliser (11 fonctions dans queries-index) + +**Estimation** : 2 jours + +--- + +### 11. zone + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.4 | +| **Sprint** | S6 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/zones` - Liste des zones +- `POST /api/v1/zones` - Creation d'une zone +- `PUT /api/v1/zones/:zoneId` - Modification d'une zone +- `DELETE /api/v1/zones/:zoneId` - Suppression d'une zone + +**Dependances sortantes** : `queries-index` (addLogs), `./queries` (linkZoneToClusters), hooks (zone.upsert, zone.delete) +**Dependances entrantes** : Aucune directe (mais les hooks zone declenchent des actions dans les plugins keycloak, vault, gitlab) + +**Points d'attention** : +- Hooks utilises : zone.upsert et zone.delete declenchent des operations dans + 5 plugins (vault, keycloak, gitlab, argocd). C'est le premier module ou + l'EventEmitter sera teste en profondeur +- Interaction avec clusters via `linkZoneToClusters` +- Gestion des logs d'audit (addLogs) + +**Estimation** : 2 jours + +--- + +## Vague 3 - Modules couples + debut encapsulation plugins (S7-S8) + +**Objectif** : Migrer les modules a dependances croisees directes. En parallele, +debut d'encapsulation des plugins feuilles (vault, keycloak) dans des modules +NestJS injectables. + +**Livrable fin S8** : 66 routes migrees (~88%) + plugins vault et keycloak encapsules + +### 12. cluster + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 7 | +| **Score** | 5.7 | +| **Sprint** | S7 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/clusters` - Liste des clusters +- `GET /api/v1/clusters/:clusterId` - Details d'un cluster +- `GET /api/v1/clusters/:clusterId/usage` - Utilisation d'un cluster +- `POST /api/v1/clusters` - Creation d'un cluster +- `GET /api/v1/clusters/:clusterId/environments` - Environnements d'un cluster +- `PUT /api/v1/clusters/:clusterId` - Modification d'un cluster +- `DELETE /api/v1/clusters/:clusterId` - Suppression d'un cluster + +**Dependances sortantes** : +- `queries-index` (15+ fonctions : addLogs, createCluster, deleteCluster, + getClusterById, getClusterByLabel, getClusterDetails, getClusterEnvironments, + getProjectsByClusterId, linkClusterToProjects, linkZoneToClusters, listClusters, + listStagesByClusterId, removeClusterFromProject, removeClusterFromStage, updateCluster) +- **`stage/business.ts`** (import direct : `linkClusterToStages`) +- hooks (cluster.upsert, cluster.delete) +- `utils/business.ts` (Result monad) + +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Module le plus complexe en termes de dependances sortantes +- La dependance directe vers `stage/business` sera resolue par un import du + StageModule dans ClusterModule (stage migre en Vague 2) +- 15+ queries a internaliser dans le module +- Hooks cluster declenchent des operations dans vault et argocd +- Gestion des kubeconfigs (donnees sensibles) + +**Estimation** : 3 jours + +--- + +### 13. environment + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.3 | +| **Sprint** | S7 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/environments` - Liste des environnements +- `POST /api/v1/projects/:projectId/environments` - Creation d'un environnement +- `PUT /api/v1/environments/:environmentId` - Modification d'un environnement +- `DELETE /api/v1/environments/:environmentId` - Suppression d'un environnement + +**Dependances sortantes** : `queries-index` (addLogs, deleteEnvironment, getEnvironmentsByProjectId, initializeEnvironment, updateEnvironment), hooks +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Utilise le pattern `Result` (monad Success/Failure) de `utils/business.ts`. + A decider si on conserve ce pattern dans NestJS ou si on passe aux exceptions +- Scope projet : necessite `ProjectGuard` avec `ProjectAuthorized` +- Hooks environnement declenchent des operations dans les plugins + +**Estimation** : 2 jours + +--- + +### 14. repository + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.8 | +| **Sprint** | S7-S8 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/repositories` - Liste des depots +- `POST /api/v1/projects/:projectId/repositories/sync` - Synchronisation d'un depot +- `POST /api/v1/projects/:projectId/repositories` - Creation d'un depot +- `PUT /api/v1/repositories/:repositoryId` - Modification d'un depot +- `DELETE /api/v1/repositories/:repositoryId` - Suppression d'un depot + +**Dependances sortantes** : `queries-index` (addLogs, deleteRepository, getProjectInfosAndRepos, getProjectRepositories, initializeRepository, updateRepository), hooks (project.upsert, misc.syncRepository) +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Le hook `misc.syncRepository` est specifique a ce module (declenchement de + pipeline miroir GitLab) +- Scope projet : `ProjectGuard` requis +- Gestion des credentials de miroir (donnees sensibles dans Vault) + +**Estimation** : 2 jours + +--- + +### 15. project-role + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.2 | +| **Sprint** | S7-S8 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects/:projectId/roles` - Liste des roles projet +- `POST /api/v1/projects/:projectId/roles` - Creation d'un role +- `PATCH /api/v1/projects/:projectId/roles` - Modification des roles +- `GET /api/v1/projects/:projectId/roles/member-counts` - Comptage des membres +- `DELETE /api/v1/projects/:projectId/roles/:roleId` - Suppression d'un role + +**Dependances sortantes** : `queries-index` (deleteRole, listMembers, listRoles, updateRole), hooks (projectRole.upsert, projectRole.delete) +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Pattern similaire a admin-role (migre en Vague 2) mais scope projet +- Bitmask de permissions projet +- Hooks projectRole declenchent des operations dans Keycloak + +**Estimation** : 1.5 jours + +--- + +### 16. project-member + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 4.7 | +| **Sprint** | S8 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects/:projectId/members` - Liste des membres +- `POST /api/v1/projects/:projectId/members` - Ajout d'un membre +- `PATCH /api/v1/projects/:projectId/members` - Modification des membres +- `DELETE /api/v1/projects/:projectId/members/:memberId` - Retrait d'un membre + +**Dependances sortantes** : +- **`user/business.ts`** (import direct : `logViaSession`) +- `queries-index` (addLogs, deleteMember, listMembers, upsertMember) +- hooks (user.retrieveUserByEmail, projectMember.upsert, projectMember.delete) + +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- La dependance vers `user/business.ts` (`logViaSession`) aura ete resolue + par l'AuthGuard (Couche 0). Pas de dependance residuelle vers UserModule +- Hooks projectMember declenchent des operations dans Keycloak +- Recherche d'utilisateur par email via Keycloak (hook user.retrieveUserByEmail) + +**Estimation** : 1.5 jours + +--- + +### 17. project-service + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 5.6 | +| **Sprint** | S8 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/services` - Liste des services/plugins du projet +- `PUT /api/v1/projects/:projectId/services` - Mise a jour des services + +**Dependances sortantes** : `queries-index` (getAdminPlugin, getProjectInfosByIdOrThrow, getProjectStore, getPublicClusters, saveProjectStore) +**Dependances entrantes** : `utils/hook-wrapper.ts` (importe `ConfigRecords` type et `dbToObj`) + +**Points d'attention** : +- Ce module exporte des types et fonctions utilitaires consommes par hook-wrapper. + Dans l'architecture NestJS, ces types seront definis dans un module partage + ou dans le ProjectServiceModule exporte +- Gestion de la configuration plugin par projet (JSON store) +- Pas de hooks directs + +**Estimation** : 1 jour + +--- + +### 18. service-chain — ✅ MIGRE (2026-04-09) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.9 | +| **Sprint prevu** | S8 | +| **Migration effective** | 2026-04-09 | +| **Dev** | @stephane.trebel | + +**Routes (implementation reelle)** : +- `GET /api/v1/service-chains` - Liste des chaines de service +- `GET /api/v1/service-chains/:id` - Details d'une chaine +- `GET /api/v1/service-chains/:id/flows` - Flux de la chaine +- `POST /api/v1/service-chains/:id/retry` - Relance d'une chaine +- `POST /api/v1/service-chains/validate/:id` - Validation + +**Dependances sortantes** : API externe OpenCDS (HTTP via axios) +**Dependances entrantes** : Aucune + +**Implementation** : +- Module place dans la catégorie `ThirdPartyModules` (futur module optionnel CPin) comme envisagé +- Auth par token et bearer JWT via `AuthService` et `UserGuard` +- Validation UUID sur les parametres d'URL (`ParseUUIDPipe`) +- Validation des reponses OpenCDS via schemas Zod de `@cpn-console/shared` +- Telemetrie via `@StartActiveSpan()` (OpenTelemetry) +- Utilisation d'un service dédié `OpenCdsClientService` afin de gérer les requêtes vers le service OpenCDS externe + +**Differences avec le legacy** : +- 403 systematique si permissions insuffisantes (le legacy renvoyait `[]` sur GET /) +- Pas d'auth session Keycloak (token uniquement) +- Validation UUID stricte (400 si format invalide) +- Client OpenCDS dédié dans server-nestjs + +**Fichiers** : +- `src/cpin-module/service-chain/service-chain.*.ts` +- `src/cpin-module/service-chain/open-cds-client.*.ts` +- `src/modules/infrastructure/auth/` (AuthModule partage) + +--- + +### Plugin : vault (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 7 | +| **Score** | 8.5 | +| **Sprint** | S7-S8 | +| **Dev** | A (en parallele de cluster) | + +**Encapsulation** : Wrapper les 3 classes existantes (VaultApi, VaultProjectApi, +VaultZoneApi) dans un `VaultModule` NestJS avec des services `@Injectable()`. +La logique interne reste intacte. Le module expose `VaultService`, +`VaultProjectService`, `VaultZoneService` via DI. + +**Dependances sortantes (plugins)** : Aucune (feuille du graphe de plugins) +**Dependances entrantes (plugins)** : gitlab, keycloak, harbor, nexus, sonarqube, argocd (tous) + +**Points d'attention** : +- Plugin fondation de toute la chaine. Doit etre encapsule en premier +- Les autres plugins consomment les API classes de vault via le systeme de hooks. + Dans NestJS, ils importeront VaultModule et injecteront les services +- Configuration par variables d'environnement (a integrer dans ConfigurationService) + +**Estimation encapsulation** : 2 jours +**Estimation reecriture complete** : 5 jours (ulterieur) + +--- + +### Plugin : keycloak (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 11 (le plus grand nombre) | +| **Score** | 7.4 | +| **Sprint** | S8 | +| **Dev** | B (en parallele de project-member/service-chain) | + +**Encapsulation** : Wrapper `KeycloakProjectApi` et les fonctions de `functions.ts` +dans un `KeycloakModule` NestJS. + +**Dependances sortantes (plugins)** : vault (runtime via `payload.apis.vault`) +**Dependances entrantes (plugins)** : argocd, harbor, sonarqube + +**Points d'attention** : +- 11 hooks souscrits couvrant projets, zones, roles, membres, utilisateurs +- Le plus grand nombre de fonctions exportees (12) +- Utilise `@keycloak/keycloak-admin-client` (dependance externe) +- Necessite vault encapsule avant (depend de VaultModule) + +**Estimation encapsulation** : 2 jours +**Estimation reecriture complete** : 5-7 jours (ulterieur) + +--- + +## Vague 4 - Module project (decoupe) + plugins intermediaires (S9-S10) + +**Objectif** : Migrer le coeur metier (project) en 3 sous-modules independants. +Encapsuler les plugins intermediaires. + +**Livrable fin S10** : 75 routes migrees (100% des routes metier) + plugins +vault, keycloak, gitlab, harbor, nexus encapsules + +### 19. project-core + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.8 | +| **Sprint** | S9 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects` - Liste des projets +- `GET /api/v1/projects/:projectId` - Details d'un projet +- `POST /api/v1/projects` - Creation d'un projet +- `PUT /api/v1/projects/:projectId` - Modification d'un projet +- `DELETE /api/v1/projects/:projectId` - Archivage d'un projet + +**Dependances sortantes** : `queries-index` (addLogs, getProjectOrThrow, getSlugs, initializeProject, listProjects, lockProject, updateProject), hooks (project.upsert, project.delete, projectRole.upsert) +**Dependances entrantes** : Presque tous les modules a scope projet + +**Points d'attention** : +- Coeur absolu du systeme. Toute erreur ici impacte tout +- Le CRUD projet declenche des hooks vers TOUS les plugins (upsertProject, + deleteProject). C'est le plus gros consommateur de l'EventEmitter +- Gestion du statut projet (created/failed/warning/archived) via state machine +- Verrouillage projet (`lockProject`) pendant les operations longues +- La creation declenche : Keycloak (groupes), Vault (KV store), GitLab (groupe + + repos), Harbor (registre), Nexus (repos), SonarQube (projets), ArgoCD (config) + +**Estimation** : 3 jours + +--- + +### 20. project-secrets + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 1 | +| **Score** | 4.6 | +| **Sprint** | S9 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/secrets` - Secrets du projet + +**Dependances sortantes** : hooks (project.getSecrets) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Appelle le hook `getProjectSecrets` qui interroge chaque plugin pour ses + secrets (Vault, GitLab mirror token, Harbor robot, Nexus credentials, etc.) +- Donnees sensibles : attention au logging et a la serialisation +- Necessite que les plugins soient encapsules pour fonctionner correctement + +**Estimation** : 1 jour + +--- + +### 21. project-bulk + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 4.2 | +| **Sprint** | S9-S10 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/projects/data` - Export bulk des donnees projets +- `POST /api/v1/admin/projects/bulk` - Action bulk sur les projets +- `POST /api/v1/projects/:projectId/replay-hooks` - Rejeu des hooks d'un projet + +**Dependances sortantes** : `queries-index` (getAllProjectsDataForExport, deleteAllEnvironmentForProject, deleteAllRepositoryForProject), hooks (project.upsert pour replay) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Routes admin uniquement +- Le bulk action itere sur les projets avec une limite de parallelisme + (`parallelBulkLimit` dans la config). Attention aux performances +- Le replay-hooks re-execute toute la chaine de hooks pour un projet +- L'export data peut generer de gros payloads + +**Estimation** : 2 jours + +--- + +### Plugin : gitlab (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 8 | +| **Score** | 6.7 | +| **Sprint** | S9 | +| **Dev** | B | + +**Encapsulation** : Wrapper les 3 classes (GitlabApi, GitlabZoneApi, +GitlabProjectApi) dans un `GitlabModule` NestJS. + +**Dependances sortantes (plugins)** : vault (VaultProjectApi pour secrets) +**Dependances entrantes (plugins)** : argocd, nexus, sonarqube + +**Points d'attention** : +- Plugin le plus complexe (13 fichiers source, classe de 570 lignes) +- Utilise `@gitbeaker/core` et `@gitbeaker/rest` (dependances externes) +- Systeme de "pending commits" (commits bufferises puis envoyes en batch) +- Necessite vault encapsule avant + +**Estimation encapsulation** : 2-3 jours +**Estimation reecriture complete** : 7-10 jours (ulterieur) + +--- + +### Plugin : harbor (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 3 | +| **Score** | 5.6 | +| **Sprint** | S9-S10 | +| **Dev** | A | + +**Encapsulation** : Wrapper dans un `HarborModule` NestJS. + +**Dependances sortantes (plugins)** : keycloak (getProjectGroupPath), vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- API generee (fichier `api/Api.ts`) +- Gestion des quotas (parsing de tailles humaines "100MB", "1.2GB") +- Necessite keycloak et vault encapsules avant + +**Estimation encapsulation** : 1.5 jours + +--- + +### Plugin : nexus (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 3 | +| **Score** | 5.1 | +| **Sprint** | S10 | +| **Dev** | B | + +**Encapsulation** : Wrapper dans un `NexusModule` NestJS. + +**Dependances sortantes (plugins)** : vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Gestion Maven + NPM (repos group/hosted/snapshot) +- Configuration riche (activation par techno) +- Necessite vault encapsule avant + +**Estimation encapsulation** : 1.5 jours + +--- + +## Vague 5 - Plugins terminaux + finalisation (S10-S12) + +**Objectif** : Encapsuler les derniers plugins. Demarrer le nettoyage. + +**Livrable fin S11** : Tous les plugins encapsules en modules NestJS + +### Plugin : argocd (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 5 | +| **Score** | 5.3 | +| **Sprint** | S10-S11 | +| **Dev** | A | + +**Encapsulation** : Wrapper dans un `ArgoCDModule` NestJS. + +**Dependances sortantes (plugins)** : gitlab (GitlabProjectApi), keycloak (KeycloakProjectApi), vault (VaultProjectApi, VaultZoneApi) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Le plugin le plus couple (depend de 3 autres plugins) +- Generation YAML complexe (value files pour Helm) +- Necessite gitlab, keycloak et vault encapsules avant +- Dernier plugin avec dependances, donc naturellement parmi les derniers + +**Estimation encapsulation** : 2 jours + +--- + +### Plugin : sonarqube (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 2 | +| **Score** | 4.2 | +| **Sprint** | S11 | +| **Dev** | B | + +**Encapsulation** : Wrapper dans un `SonarQubeModule` NestJS. + +**Dependances sortantes (plugins)** : gitlab (GitlabProjectApi pour CI vars), keycloak (getProjectGroupPath), vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Depend de 3 autres plugins (comme argocd) +- Interaction avec l'API SonarQube + ecriture de variables CI dans GitLab +- Necessite gitlab, keycloak et vault encapsules avant + +**Estimation encapsulation** : 1.5 jours + +--- + +## Vague 6 - Nettoyage (S11-S12) + +- Suppression du reverse proxy Nginx (tout pointe vers `server-nestjs`) +- Suppression du code `apps/server` +- Renommage `server-nestjs` en `server` +- Suppression de `@cpn-console/hooks` (remplace par EventEmitter NestJS) +- Suppression de `ts-rest` cote server (remplace par decorateurs NestJS) +- Mise a jour de tous les `docker-compose` et scripts PNPM/Bash +- Reecriture progressive des plugins (au-dela de l'encapsulation) -- planifiee + dans des iterations futures + +--- + +## Graphe de dependances + +### Graphe actuel (genere par Madge) + +![Graphe des dependances de `server`](./graph-dependances-server-avant.png) + +Commande : + +``` +npx madge --image dependency-graph.png --extensions js,ts apps/server/src +``` + +### Graphe cible des modules NestJS + +```mermaid +flowchart TD + subgraph Transverse["Couche 0 : Socle transverse"] + AuthGuard["AuthService + @AuthUser()"] + ExceptionFilter["GlobalExceptionFilter"] + PermissionsGuard["UserGuard + ProjectGuard"] + ValidationPipe["ValidationPipe global"] + EventEmitter["EventEmitterModule"] + end + + subgraph Infrastructure["Couche 1-2 : Infrastructure (FAIT)"] + ConfigModule["ConfigurationModule"] + LoggerModule["LoggerModule"] + DatabaseService["DatabaseService (Prisma)"] + AppService["AppService"] + FastifyService["FastifyService"] + end + + subgraph Feuilles["Vague 1 : Modules feuilles"] + SystemModule["SystemModule (health, version)"] + SettingsModule["SystemSettingsModule"] + ConfigPluginModule["SystemConfigModule"] + AdminTokenModule["AdminTokenModule"] + UserTokensModule["UserTokensModule"] + LogModule["LogModule"] + end + + subgraph Fondations["Vague 2 : Fondations metier"] + ServiceMonitorModule["ServiceMonitorModule"] + UserModule["UserModule"] + AdminRoleModule["AdminRoleModule"] + StageModule["StageModule"] + ZoneModule["ZoneModule"] + end + + subgraph Couples["Vague 3 : Modules couples"] + ClusterModule["ClusterModule"] + EnvironmentModule["EnvironmentModule"] + RepositoryModule["RepositoryModule"] + ProjectRoleModule["ProjectRoleModule"] + ProjectMemberModule["ProjectMemberModule"] + ProjectServiceModule["ProjectServiceModule"] + ServiceChainModule["ServiceChainModule"] + end + + subgraph ProjectGroup["Vague 4 : Project (decoupe)"] + ProjectCoreModule["ProjectCoreModule"] + ProjectSecretsModule["ProjectSecretsModule"] + ProjectBulkModule["ProjectBulkModule"] + end + + subgraph Plugins["Vague 3-5 : Plugins NestJS"] + VaultModule["VaultModule"] + KeycloakModule["KeycloakModule"] + GitlabModule["GitlabModule"] + HarborModule["HarborModule"] + NexusModule["NexusModule"] + ArgoCDModule["ArgoCDModule"] + SonarQubeModule["SonarQubeModule"] + end + + %% Dependances infrastructure + Feuilles --> AuthGuard + Feuilles --> ExceptionFilter + Feuilles --> ValidationPipe + Fondations --> EventEmitter + Fondations --> PermissionsGuard + + %% Dependances inter-modules metier + ClusterModule --> StageModule + ProjectMemberModule -.-> UserModule + + %% Dependances plugins + KeycloakModule --> VaultModule + GitlabModule --> VaultModule + HarborModule --> KeycloakModule + HarborModule --> VaultModule + NexusModule --> VaultModule + ArgoCDModule --> GitlabModule + ArgoCDModule --> KeycloakModule + ArgoCDModule --> VaultModule + SonarQubeModule --> GitlabModule + SonarQubeModule --> KeycloakModule + SonarQubeModule --> VaultModule + + %% Evenements + ProjectCoreModule -.->|evenements| Plugins + ZoneModule -.->|evenements| Plugins + ClusterModule -.->|evenements| Plugins +``` + +### Dependances directes inter-modules (code actuel) + +| Module source | Importe depuis | Fonction | Resolution NestJS | +|---------------|----------------|----------|-------------------| +| cluster | stage/business | `linkClusterToStages` | ClusterModule importe StageModule | +| project-member | user/business | `logViaSession` | Resolu par AuthService / AuthUser (Couche 0) | +| utils/controller | user/business | `logViaSession`, `logViaToken` | Resolu par AuthService / AuthUser (Couche 0) | +| utils/hook-wrapper | project-service/business | `ConfigRecords`, `dbToObj` | Types partages ou ProjectServiceModule exporte | + +### Dependances inter-plugins + +``` +vault (feuille -- aucune dependance plugin) + | + +--- keycloak (vault en runtime) + | | + | +--- harbor (keycloak + vault) + | +--- argocd (gitlab + keycloak + vault) + | +--- sonarqube (gitlab + keycloak + vault) + | + +--- gitlab (vault) + | + +--- nexus (vault, gitlab en devDependency) + +--- argocd (gitlab + keycloak + vault) + +--- sonarqube (gitlab + keycloak + vault) +``` + +--- + +## Modules transverses + +### Middlewares globaux + +| Middleware | Statut | Module NestJS | +|-----------|--------|---------------| +| CORS | A configurer | AppService (Fastify) | +| Helmet (securite) | FAIT | AppService | +| Cookie + Session | FAIT | AppService | +| Body parser | FAIT | Fastify (natif) | +| Compression | A evaluer | Fastify compression plugin | +| Logging (requetes) | FAIT | LoggerModule (nestjs-pino) | +| Configuration (.env) | FAIT | ConfigurationModule | +| Validation (DTOs) | A CREER | ValidationPipe global (Couche 0) | + +### Services partages + +| Service | Statut | Module NestJS | +|---------|--------|---------------| +| PrismaService (DB) | FAIT | DatabaseService | +| LoggerService | FAIT | LoggerModule | +| ConfigService (env) | FAIT | ConfigurationModule | +| HttpClientService | STUB (vide) | A implementer dans InfrastructureModule | +| ServerService (ts-rest) | STUB | A supprimer (abandon ts-rest) | + +--- + +## Checklist de cartographie + +### Sprint 1 + +- [x] Installer outils d'analyse (Madge) +- [x] Generer graphe de dependances automatique +- [x] Lister toutes les routes (75 routes identifiees) +- [x] Identifier les modules metier principaux (16 domaines + 7 plugins) +- [x] Premiere version de cette cartographie + +### Sprint 2 + +- [x] Affiner les dependances inter-modules +- [x] Calculer les scores de priorisation (28 unites scorees) +- [x] Definir l'ordre de modularisation (6 vagues, bottom-up) +- [x] Documenter les points d'attention par module +- [x] Finaliser les estimations de charge +- [x] Completer ce fichier +- [ ] Valider l'ordre de modularisation avec l'equipe + +--- + +## Estimations de charge par vague + +| Vague | Sprint | Routes | Modules | Charge estimee (j/p) | Objectif | +|-------|--------|--------|---------|----------------------|----------| +| V0 | S3 | 0 | 5 transverses | 5 j/p | Socle (Guards, Filters, Pipes, EventEmitter) | +| V1 | S3-S4 | 13 | 6 | 5 j/p | Quick wins, validation pattern | +| V2 | S5-S6 | 21 | 5 | 9 j/p | Fondations metier, premiers hooks | +| V3 | S7-S8 | 32 | 7 metier + 2 plugins | 17 j/p | Modules couples, vault + keycloak | +| V4 | S9-S10 | 9 | 3 metier + 3 plugins | 13 j/p | Project decoupe, gitlab + harbor + nexus | +| V5 | S10-S11 | 0 | 2 plugins | 3.5 j/p | argocd + sonarqube | +| V6 | S11-S12 | 0 | 0 | 5 j/p | Nettoyage, suppression server | +| **Total** | **S3-S12** | **75** | **28** | **~57.5 j/p** | | + +Avec 2 developpeurs dedies : **~29 jours calendaires** (soit ~6 sprints d'une +semaine), ce qui s'inscrit dans la fenetre S3-S12 (10 sprints disponibles). +La marge de 4 sprints absorbe les imprevisibles, la montee en competence +NestJS, et les eventuelles sessions de pair programming sur les modules +complexes. + +--- + +## Propagation de la modularisation + +Mettre a jour dans ce document : +- Le chemin parcouru par un module migre (quel environnement, a quel + moment/jalon, et avec quel resultat succes/echec) +- Tracer les manques de tests d'integration ou E2E sur un module suivant les + retours effectues +- Expliciter les regles de suivi dans la metrologie de la migration d'un module + (avant/apres, payload et requetes concernees, etc.) + +--- + +## Contact + +**Responsable cartographie** : @stephane.trebel +**Questions** : #backend-modularisation sur Mattermost + +--- + +**Version** : 2.0 +**Derniere mise a jour** : 2026-02-23 +**Prochaine revision majeure** : Fin S4 (premier bilan post-Vague 1) diff --git a/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md b/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md new file mode 100644 index 0000000000..fe899d0bdf --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md @@ -0,0 +1,214 @@ +# État de la modularisation Backend → NestJS + +> 📋 **Ce fichier est mis à jour en temps réel** +> Dernière mise à jour : **2026-07-23** + +--- + +## 🎯 Progression globale + +![Progress](https://progress-bar.dev/7/?title=modularisation&width=400) + +**~60%** complété (16/18 modules métier migrés, 45/75 routes) + +--- + +## 📊 Vue d'ensemble + +| Statut | Nombre de modules | % du total | +|--------|-------------------|------------| +| ✅ Migré | 16 | ~89% | +| 🚧 En cours | 0 | 0% | +| 📅 Planifié | 2 | ~11% | +| ⏳ En attente de cartographie | 0 | 0% | + +--- + +## ✅ Modules migrés + +### ServiceChain (OpenCDS) — migré le 2026-04-09 + +Module proxy HTTP vers l'API externe OpenCDS (gestion des chaînes de service +réseau). Migré en avance de phase par rapport au planning initial (prévu V3/S8), +profitant de son isolement complet vis-à-vis du reste du codebase. + +- **Routes** : 5 (`/api/v1/service-chains/...`) +- **Auth** : `AuthService` unifie `x-dso-token` et bearer JWT Keycloak +- **Nginx** : Bascule effectuée dans `nginx-strangler/conf.d/routing.conf` +- **Tests** : Controller + Service couverts (Vitest) +- **Différences avec le legacy** : + - 403 systématique si permissions insuffisantes (le legacy renvoyait `[]` sur GET /) + - Validation UUID sur les paramètres d'URL (400 si format invalide) + +| Méthode | Route | Permission | +|---------|-------|------------| +| `GET` | `/api/v1/service-chains` | `ListSystem` | +| `GET` | `/api/v1/service-chains/:id` | `ListSystem` | +| `GET` | `/api/v1/service-chains/:id/flows` | `ListSystem` | +| `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | +| `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | + +### Infrastructure transverse déployée + +En support de cette migration, les éléments d'infrastructure suivants ont été +créés : + +- **AuthModule** (`infrastructure/auth/`) : `AuthService` (validation token + SHA256 via Prisma + bearer JWT Keycloak), `DsoTokenModule`, + `KeycloakJwtModule` et décorateur `@AuthUser()` +- **PermissionModule** (`infrastructure/permission/`) : `UserModule` + (`UserGuard`, `UserService`, `UserPolicy`) + `ProjectModule` + (`ProjectGuard`, `ProjectLoaderService`, `ProjectService`, `ProjectPolicy`) +- **Nginx strangler** : Reverse proxy configuré pour router les routes migrées + vers server-nestjs, le reste vers le legacy +- **Docker** : Build order corrigé (shared avant server-nestjs) + +> **Limitation connue** : l'auth par session Keycloak et le flux bearer JWT ont +> désormais la même entrée publique (`AuthService.authenticate(request, ...)`), +> mais les usages côté contrôleurs restent encore à homogénéiser au fil des +> modules migrés. + +--- + +## 🚧 En cours de modularisation + +### Aucune modularisation en cours + +--- + +## 📅 Modules planifiés + +> Ces informations seront affinées après la cartographie (fin S2) + +### Sprint 3-4 (27 janvier - 9 février) +- **Module** : Auth +- **Responsable** : @stephane.trebel +- **Routes** : ~5 +- **Criticité** : 🔴 Haute +- **Statut** : 📅 Planifié + +### Sprint 5-6 (10-23 février) +- **Module** : Users +- **Responsable** : TBD +- **Routes** : ~10 +- **Criticité** : 🔴 Haute +- **Statut** : 📅 Planifié + +### Sprint 7-8 (24 février - 9 mars) +- **Module** : À définir (selon cartographie) +- **Responsable** : TBD +- **Routes** : TBD +- **Statut** : 📅 Planifié + +### Sprint 9-10 (10-23 mars) +- **Module** : À définir (selon cartographie) +- **Responsable** : TBD +- **Routes** : TBD +- **Statut** : 📅 Planifié + +### Sprint 11-12 (24 mars - 6 avril) +- **Modules** : Finalisation + cleanup +- **Responsable** : Équipe +- **Objectif** : Suppression du legacy + +--- + +## 🚫 Zones en feature freeze + +### Aucune zone gelée actuellement + +**Règle** : Quand un module passe en status 🚧 (En cours), il est automatiquement en feature freeze. + +**Que faire si vous devez travailler sur une zone gelée ?** +1. Vérifier l'urgence réelle (Critique / Importante / Normale) +2. Consulter la [matrice de décision](04-COMMUNICATION-PLAN.md#matrice-de-décision-pour-développeurs) +3. Coordonner avec le responsable de la modularisation +4. Annoncer sur #backend-modularisation + +--- + +## 📈 Métriques de qualité + +### Couverture de tests + +| Type | Initial | Actuel | Objectif | +|------|---------|--------|----------| +| E2E Playwright | 33% | 33% | 50% | +| Unitaires Vitest | ? | ? | 70% | +| Tests de contrat | 0% | 0% | 100% | + +### Routes par statut + +- **Total** : ~75 routes métier +- **Migrés** : 45 (~60%) +- **En cours** : 0 (0%) +- **Restants** : ~30 (~40%) + +--- + +## 🗓️ Dates clés + +| Date | Événement | +|------|-----------| +| 07/01/2026 | Début du projet (S1) | +| 26/01/2026 | Fin de la cartographie (S2) | +| 27/01/2026 | Début modularisation Auth (S3) | +| 09/02/2026 | Fin modularisation Auth (S4) - 20% complété | +| 09/03/2026 | Point mi-parcours - 60% complété | +| 26/03/2026 | Migration ServiceChain (OpenCDS) finalisée — 1er module métier migré | +| 06/04/2026 | Fin de modularisation - 100% complété | + +--- + +## 📞 Contacts & Communication + +### Canaux +- **Slack** : #backend-modularisation +- **Meeting hebdo** : Vendredi 16h (30min) +- **Lead technique** : @stephane.trebel + +### Besoin d'aide ? +- **Question technique** : Poser sur #backend-modularisation +- **Conflit de développement** : Contacter @stephane.trebel +- **Urgence production** : Suivre la procédure de rollback (voir [01-TECHNICAL-SETUP.md](01-TECHNICAL-SETUP.md)) + +--- + +## 📚 Documentation + +- [00-OVERVIEW.md](00-OVERVIEW.md) - Vue d'ensemble du projet +- [01-TECHNICAL-SETUP.md](01-TECHNICAL-SETUP.md) - Configuration technique +- [02-modularisation-STRATEGY.md](02-modularisation-STRATEGY.md) - Stratégie de modularisation +- [03-PLANNING.md](03-PLANNING.md) - Planning détaillé 12 sprints +- [04-COMMUNICATION-PLAN.md](04-COMMUNICATION-PLAN.md) - Plan de communication +- [05-TESTING-STRATEGY.md](05-TESTING-STRATEGY.md) - Stratégie de tests +- [modularisation_MAP.md](modularisation_MAP.md) - Cartographie des modules + +--- + +## 🔄 Historique des changements + +### 2026-04-09 +- ✅ Migration du module **ServiceChain (OpenCDS)** — 5 routes, proxy HTTP vers API externe +- ✅ Création de l'**AuthModule** (infrastructure/auth/) : auth par token `x-dso-token` +- ✅ Configuration **nginx-strangler** pour les routes service-chain +- ✅ Fix Docker : build order shared → server-nestjs +- ✅ Mise à jour de ce fichier de suivi + +### 2026-01-07 (S1) +- ✅ Création du fichier de suivi +- ✅ Initialisation de la documentation +- ✅ Kickoff du projet + +--- + +## 📝 Notes + +> Ce fichier sera mis à jour régulièrement (minimum une fois par sprint, idéalement en temps réel). +> Toute l'équipe peut consulter ce fichier pour connaître l'état actuel de la modularisation. +> Pour proposer des modifications ou signaler des incohérences, utiliser #backend-modularisation. + +--- + +**Version du fichier** : 1.1 +**Responsable de mise à jour** : Lead technique backend diff --git a/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md b/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md new file mode 100644 index 0000000000..811aa04f0c --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md @@ -0,0 +1,120 @@ +# Documentation de Modularisation du Backend de Console (apps/server) vers NestJS + +Ce dossier contient toute la documentation nécessaire pour mener à bien la modularisation du backend Node.js vers NestJS. + +## 📁 Structure de la documentation + +### Documents principaux (à lire dans l'ordre) + +1. **00-OVERVIEW.md** - Vue d'ensemble du projet de modularisation + - Objectifs et contexte + - Stratégie "Strangler Pattern" + - Principes directeurs + - Métriques de succès + +2. **01-TECHNICAL-SETUP.md** - Configuration technique détaillée + - Configuration Nginx (reverse proxy) qui va progressivement "étrangler" `server` au profit de `server-nestjs` + - Gestion Prisma/BDD partagée entre `server` et `server-nestjs` + - Déploiement `docker compose` + +3. **02-MODULARISATION-STRATEGY.md** - Stratégie et méthodologie + - Modularisation par slices verticales + - Cartographie et priorisation + - Gestion singletons → Injection de dépendances + - Méthodologie en 7 étapes par module + - Architecture Decision Records (ADR) + +4. **03-PLANNING.md** - Planning détaillé sur les prochains sprints + - Découpage sprint par sprint + - Sprints 1-2 : Fondations + - Sprints 3-4 : Modules transverses de `server` (logger, etc.) + - Sprints 5-6 : Modules applicatifs (Zones, Clusters, etc.) + - Sprints 7-10 : Modules métier (les fameux plugins) + - Sprints 11-12 : Finalisation + - Métriques et KPIs + +5. **04-COMMUNICATION-PLAN.md** - Plan de communication équipe + - Canaux de communication (Mattermost, fichiers, réunions) + - Gestion des zones en modularisation + - Modèles d'annonces concernant la modularisation + - Matrice de décision pour développeurs + - Procédure en cas de conflit + +6. **05-TESTING-STRATEGY.md** - Stratégie de tests + - Tests de contrat (nouveaux, critiques) + - Tests E2E Playwright + - Tests unitaires Vitest + - Tests d'intégration + - Workflow de tests pendant modularisation + - Intégration CI/CD + +### Fichiers vivants (mis à jour régulièrement) + +7. **MODULARISATION_STATUT.md** - Suivi en temps réel de l'avancement + - Progression globale + - Modules migrés + - Modules en cours + - Zones en "feature freeze" du fait de leur modularisation en cours 🚧 + - Dates clés + +8. **MODULARISATION_CARTOGRAPHIE.md** - Cartographie des modules + - Liste de tous les modules avec métadonnées + - Graphe de dépendances + - Scoring et priorisation des modules + - Estimation de charge + +## 🚀 Par où commencer ? + +### Si vous êtes le lead technique de la modularisation + +1. Lire **00-OVERVIEW.md** pour la vue d'ensemble +2. Consulter **01-TECHNICAL-SETUP.md** pour préparer l'infrastructure +3. Étudier **02-MODULARISATION-STRATEGY.md** pour la méthodologie +4. Planifier avec **03-PLANNING.md** +5. Organiser la communication avec **04-COMMUNICATION-PLAN.md** +6. Mettre en place les tests avec **05-TESTING-STRATEGY.md** +7. Compléter **MODULARISATION_MAP.md** lors de la cartographie (S1-S2) +8. Maintenir **MODULARISATION_STATUT.md** à jour tout au long de la modularisation + +### Si vous êtes développeur dans l'équipe + +1. Lire **00-OVERVIEW.md** pour comprendre le projet +2. Consulter **MODULARISATION_STATUT.md** AVANT de traiter un ticket, pour éviter de toucher à une zone en cours de modularisation +3. Suivre le canal Mattermost #backend-modularisation qui sera créé dans cet objectif +4. Respecter les zones en modularisation (🚧) +5. Consulter **04-COMMUNICATION-PLAN.md** en cas de conflit + +## 📊 Contexte du projet + +- **Durée** : 3 mois (12 semaines) +- **Équipe** : 5 développeurs +- **Endpoints** : ~100 +- **Approche** : Strangler Fig Pattern (modularisation progressive) +- **Stack cible** : NestJS + Prisma + Keycloak OIDC + +## 🎯 Objectifs + +- ✅ 100% des endpoints migrés sur NestJS +- ✅ 0 régression fonctionnelle +- ✅ Couverture tests E2E ≥ 50% +- ✅ Équipe autonome sur NestJS +- ✅ Documentation complète + +## 📞 Contact + +- **Lead technique** : @stephane.trebel +- **Canal Mattermost** : #backend-modularisation +- **Réunion hebdo** : Jeudi 10h (30min) + +## 📚 Ressources externes + +- [NestJS Documentation](https://docs.nestjs.com/) +- [Prisma Documentation](https://www.prisma.io/docs) +- [Playwright Documentation](https://playwright.dev/) +- [Vitest Documentation](https://vitest.dev/) + +--- + +**Bonne modularisation ! 🚀** + +*Version 1.0 - Dernière mise à jour : 2026-01-07* diff --git a/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png b/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png new file mode 100644 index 0000000000..569dd60e19 Binary files /dev/null and b/.jjconflict-side-0/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png differ diff --git a/.jjconflict-side-0/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md b/.jjconflict-side-0/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md new file mode 100644 index 0000000000..b7ba2c4258 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md @@ -0,0 +1,271 @@ +# Plan : Nginx "étrangleur" pour la transition `server` → `server-nestjs` + +> Ticket de référence : [#1885](https://github.com/cloud-pi-native/console/issues/1885) +> PR en cours : [#1951](https://github.com/cloud-pi-native/console/pull/1951) — branche `add-server-nextjs-to-local-dev` +> Dernière mise à jour : 2026-02-26 + +--- + +## Contexte et état des lieux + +| Élément | État | +|---|---| +| `server` (Fastify) | Port `8080` interne, exposé `4001` sur l'hôte | +| Nginx actuel | Embarqué dans l'image `client`, proxifie `/api` + `/swagger-ui` vers `server:8080` — upstream changé vers `nginx-strangler:8080` | +| `server-nestjs` | Dockerisé (port `3001`), ajouté dans tous les docker-compose | +| `nginx-strangler` | Service dédié créé dans `apps/nginx-strangler/`, fallback total sur `server` legacy, exposé sur le port `4000` pour se "substituer" à `server` | +| Envs couverts | Local dev, docker-compose (dev/ci/integ) | + +--- + +## Architecture cible + +``` +[Navigateur / API client] + │ + ▼ +┌────────────────────┐ +│ Client (8080) │ ← nginx embarqué dans l'image client +│ nginx "front" │ SPA + proxy /api → nginx-strangler +└────────┬───────────┘ + │ proxy_pass http://nginx-strangler:8080 + ▼ +┌────────────────────┐ +│ nginx-strangler │ ← service dédié (port 8080 interne, non-root) +│ (routing as-code) │ +└──────┬─────────────┘ + │ + ├── /api/[routes migrées] ──→ server-nestjs:3001 + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +**Principe clé** : le nginx client existant reste inchangé dans son rôle SPA, mais son upstream `api` est redirigé vers `nginx-strangler` plutôt que directement vers `server`. Le `nginx-strangler` devient l'unique point de routage des appels API. + +--- + +## Décisions structurantes + +| Décision | Choix | +|---|---| +| Emplacement du nginx-strangler | Répertoire `apps/nginx-strangler/` à la racine du dépôt | +| Port interne de `nginx-strangler` | `8080` (non-root, Trivy DS002) | +| Port interne de `server-nestjs` | `3001` (distinct du legacy sur `8080`) | +| Envs PAX / MinInt | Couverts via docker-compose uniquement dans ce ticket | +| Config nginx | `envsubst` pour substituer les upstreams selon l'environnement | +| Rollback | Commenter une `location` dans `routing.conf` + `nginx -s reload` | +| Sécurité image nginx | `USER nginx`, `HEALTHCHECK`, `chown` sur les répertoires système nginx | +| Sécurité image server-nestjs | `USER node`, `HEALTHCHECK` sur le stage `prod` (Trivy DS026) | + +--- + +## Découvertes en cours de réalisation + +- Le nginx existant dans l'image `client` proxifiait directement vers `server:8080` — upstream changé vers `nginx-strangler:8080` +- `server-nestjs/src/main.ts` utilisait `process.env.PORT ?? 0` au lieu de `ConfigurationService.port` — corrigé +- Le schéma Prisma de `server-nestjs` est multi-fichiers dans `src/prisma/schema/` — `prisma generate` doit pointer sur le dossier, pas sur `schema.prisma` +- En CI (`job-lint.yml`), `nginx -t` échoue si `apt-get update -qq` n'est pas exécuté avant l'install, et si les chemins `pid`/`error_log`/`access_log` ne sont pas patchés vers `/tmp/` (runner non-root) +- En CI (`job-playwright.yml`), les images `server-nestjs:ci` et `nginx-strangler:ci` doivent être buildées localement avant le `docker compose up --no-build` (même pattern que `opencds-mockoon`) + +--- + +## Phase 1 — Socle nginx-strangler ✅ + +### Tâche 1.1 : Créer `apps/nginx-strangler/` ✅ + +``` +apps/nginx-strangler/ +├── Dockerfile # image nginx:1.27-alpine, USER nginx, HEALTHCHECK, EXPOSE 8080, envsubst entrypoint +├── nginx.conf # directives globales (worker_processes, logs, include conf.d/) +├── conf.d/ +│ ├── routing.conf # fichier "vivant" des routes (commenté par vague de migration) +│ └── routing.local.conf.example # template documentaire pour le mode local natif +└── README.md # procédures opérationnelles (déploiement, rollback, ajout de route, dev local) +``` + +### Tâche 1.2 : Contenu initial de `routing.conf` ✅ + +Le fichier utilise des variables d'environnement substituées via `envsubst` pour permettre la réutilisation de la même image dans tous les contextes (Docker, local natif) : + +```nginx +upstream server-legacy { + server ${LEGACY_UPSTREAM}; +} + +upstream server-nestjs { + server ${NESTJS_UPSTREAM}; +} + +server { + listen 8080; + + # ── Routes migrées vers NestJS ────────────────────────────────────────── + # Format : # [Vague X - Module] AAAA-MM-JJ + # (vide au démarrage — toutes les routes sont en fallback sur server-legacy) + + # ── Fallback : tout le reste vers le server legacy ─────────────────────── + location /api/ { + proxy_pass http://server-legacy; + ... + } + + location /swagger-ui { + proxy_pass http://server-legacy; + ... + } +} +``` + +Exemple de bascule future (à ajouter au fil des vagues) : +```nginx +# [Vague 1 - system/health] 2026-03-XX +location = /api/v1/system/health { + proxy_pass http://server-nestjs; + ... +} +``` + +--- + +## Phase 2 — Dockerisation de `server-nestjs` ✅ + +### Tâche 2.1 : Fix du port dans `main.ts` ✅ + +`apps/server-nestjs/src/main.ts` utilisait `process.env.PORT ?? 0` — câblé sur `ConfigurationService.port`. + +### Tâche 2.2 : Créer `apps/server-nestjs/Dockerfile` ✅ + +Multi-stage : `base` → `deps` → `build` → `prod`. `prisma generate --schema=src/prisma/schema` (dossier multi-fichiers). `USER node`, `HEALTHCHECK`, `EXPOSE 3001`. + +### Tâche 2.3 : `apps/server-nestjs/.env.docker-example` ✅ + +`SERVER_PORT=3001` présent. + +--- + +## Phase 3 — Mise à jour des docker-compose ✅ + +### Tâche 3.1 : `docker/docker-compose.dev.yml` ✅ + +Services `server-nestjs` (avec Docker Compose Watch) et `nginx-strangler` ajoutés. `client` dépend de `nginx-strangler`. + +### Tâche 3.2 : `docker/docker-compose.prod.yml` ✅ + +Idem sans Watch. Bug `depends_on` vide sur `opencds-mockoon` corrigé. + +### Tâche 3.3 : `docker/docker-compose.ci.yml` ✅ + +Idem prod. + +### Tâche 3.4 : `docker/docker-compose.integ.yml` ✅ + +Idem dev (avec Watch) + volumes kubeconfig. + +### Tâche 3.5 : `docker/docker-compose.local.yml` ✅ + +`nginx-strangler` ajouté avec `host.docker.internal`, port `8082:8080`, `extra_hosts: host-gateway`. + +### Tâche 3.6 : `apps/client/nginx/default.docker.conf` ✅ + +Upstream `server:8080` → `nginx-strangler:8080`. + +--- + +## Phase 4 — Dev local (pnpm dev natif) ✅ + +### Tâche 4.1 : Template de config pour le mode local ✅ + +`apps/nginx-strangler/conf.d/routing.local.conf.example` créé avec `host.docker.internal` comme valeurs d'exemple. + +### Tâche 4.2 : Documentation ✅ + +- `ENVIRONMENTS.md` : refonte complète avec section Strangler Fig, `server-nestjs` mentionné partout, sous-section dédiée au geste manuel `SERVER_PORT=8082` +- `apps/client/.env-example` : note sur `SERVER_PORT=8082` pour passer par le nginx-strangler en local + +Variables par contexte : + +| Contexte | `LEGACY_UPSTREAM` | `NESTJS_UPSTREAM` | +|---|---|---| +| Docker (dev/prod/ci/integ) | `server:8080` | `server-nestjs:3001` | +| Local (pnpm dev) | `127.0.0.1:4001` | `127.0.0.1:3001` | +| PAX / MinInt | À adapter selon la topologie réseau | + +--- + +## Phase 5 — Validation et outillage + +### Tâche 5.1 : Validation `nginx -t` dans le CI ✅ + +Étape ajoutée dans `.github/workflows/job-lint.yml` : +- `apt-get update -qq` + install `nginx gettext-base` +- `envsubst` sur `routing.conf` +- `sed` pour patcher `user`, `pid`, `error_log`, `access_log` vers des chemins accessibles en non-root +- `nginx -t -c /tmp/nginx-test/nginx.conf` + +### Tâche 5.2 : Smoke test ❌ (à faire) + +Vérifier dans les tests Playwright que `/api/v1/system/health` répond correctement en passant par le nginx-strangler (et non plus directement par `server`). + +### Tâche 5.3 : Procédure de rollback documentée ✅ + +Documentée dans `apps/nginx-strangler/README.md` : + +```bash +# 1. Commenter la location concernée dans routing.conf +# 2. Recharger nginx sans downtime +docker compose exec nginx-strangler nginx -s reload +# 3. Vérifier les logs +docker compose logs -f nginx-strangler | grep /api/v1/xxx +``` + +### Tâche 5.4 (ajout) : Build des images CI dans job-playwright ✅ + +`docker buildx build -t dso-console/server-nestjs:ci` et `nginx-strangler:ci` ajoutés dans `.github/workflows/job-playwright.yml` avant le `docker compose up --no-build`. + +--- + +## Critères d'acceptation + +- [x] Le service `nginx-strangler` démarre correctement dans tous les docker-compose +- [x] Les appels `/api/*` continuent de fonctionner sans régression (fallback total sur `server`) +- [x] `nginx -t` passe en CI +- [x] La config est documentée et le format de bascule des routes est clairement défini +- [x] La procédure de rollback est documentée +- [x] Le dev local (`pnpm dev`) fonctionne avec le nginx-strangler en Docker +- [ ] Smoke test Playwright sur `/api/v1/system/health` via nginx-strangler + +--- + +## Ce qui reste à faire (post-merge PR #1951) + +1. **Tâche 5.2** : Smoke test Playwright (optionnel pour la PR courante, recommandé avant Vague 1) +2. **Vague 1** : Basculer les premiers modules dans `apps/nginx-strangler/conf.d/routing.conf` : + - `system` (health, config, settings) + - `admin-token` + - `user/tokens` + - `log` + - Référence : [`MODULARISATION-CARTOGRAPHIE.md`](../Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) + +--- + +## Séquençage initial et estimation de charge + +``` +Phase 1 : Créer apps/nginx-strangler/ (structure + routing.conf) Dev A ~0.5j ✅ +Phase 2 : Dockeriser server-nestjs (Dockerfile + port) Dev B ~1j ✅ + + Modifier upstream nginx client Dev A ~0.5h ✅ +Phase 3 : Mettre à jour tous les docker-compose Dev A+B ~1j ✅ +Phase 4 : Config locale (envsubst + doc) Dev A ~0.5j ✅ +Phase 5 : CI + smoke test + rollback doc Dev B ~0.5j 🔄 (5.1 ✅, 5.2 ❌, 5.3 ✅) +───────────────────────────────────────────────────────────────────── +Total estimé ~3.5 j/p +``` + +--- + +## Liens + +- Ticket GitHub : [#1885](https://github.com/cloud-pi-native/console/issues/1885) +- PR : [#1951](https://github.com/cloud-pi-native/console/pull/1951) +- Documentation Nginx strangler dans la stratégie de modularisation : [`01-MODULARISATION-STRATEGIE.md`](../Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md) +- Cartographie des modules (vagues de migration) : [`MODULARISATION-CARTOGRAPHIE.md`](../Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) +- Suivi de l'avancement : [`MODULARISATION-STATUT.md`](../Modularisation-de-console-server/MODULARISATION-STATUT.md) diff --git a/.jjconflict-side-0/apps/server-nestjs/eslint.config.mjs b/.jjconflict-side-0/apps/server-nestjs/eslint.config.mjs new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/eslint.config.mjs @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-0/apps/server-nestjs/nest-cli.json b/.jjconflict-side-0/apps/server-nestjs/nest-cli.json new file mode 100644 index 0000000000..f9aa683b1a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/package.json b/.jjconflict-side-0/apps/server-nestjs/package.json new file mode 100644 index 0000000000..79126d32e2 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/package.json @@ -0,0 +1,111 @@ +{ + "name": "@cpn-console/server-nestjs", + "version": "9.22.1", + "private": true, + "description": "", + "author": "", + "license": "UNLICENSED", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "scripts": { + "prebuild": "pnpm i --frozen-lockfile", + "build": "nest build", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev --name dso", + "db:reset": "prisma migrate reset", + "format": "eslint ./ --fix", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "start": "nest start", + "start:debug": "nest start --debug --watch", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "pretest": "pnpm run db:generate", + "test": "vitest run", + "test:watch": "vitest", + "pretest:cov": "pnpm run db:generate", + "test:cov": "vitest run --coverage", + "test:debug": "vitest --inspect" + }, + "dependencies": { + "@cpn-console/argocd-plugin": "workspace:^", + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/harbor-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/nexus-plugin": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/sonarqube-plugin": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/requester-utils": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "@keycloak/keycloak-admin-client": "catalog:runtime", + "@kubernetes-models/argo-cd": "catalog:runtime", + "@nestjs/cache-manager": "catalog:runtime", + "@nestjs/common": "catalog:runtime", + "@nestjs/config": "catalog:runtime", + "@nestjs/core": "catalog:runtime", + "@nestjs/event-emitter": "catalog:runtime", + "@nestjs/jwt": "catalog:runtime", + "@nestjs/platform-fastify": "catalog:runtime", + "@nestjs/schedule": "catalog:runtime", + "@nestjs/swagger": "catalog:runtime", + "@nestjs/terminus": "catalog:runtime", + "@opentelemetry/api": "catalog:otel", + "@opentelemetry/auto-instrumentations-node": "catalog:otel", + "@opentelemetry/exporter-metrics-otlp-proto": "catalog:otel", + "@opentelemetry/exporter-trace-otlp-proto": "catalog:otel", + "@opentelemetry/instrumentation-nestjs-core": "catalog:otel", + "@opentelemetry/instrumentation-pino": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-node": "catalog:otel", + "@prisma/client": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@ts-rest/fastify": "catalog:runtime", + "@ts-rest/open-api": "catalog:runtime", + "cache-manager": "catalog:runtime", + "date-fns": "catalog:runtime", + "fastify": "catalog:runtime", + "fastify-keycloak-adapter": "catalog:runtime", + "json-2-csv": "catalog:runtime", + "mustache": "catalog:runtime", + "nestjs-pino": "catalog:runtime", + "prisma": "catalog:runtime", + "reflect-metadata": "catalog:runtime", + "rxjs": "catalog:runtime", + "undici": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime", + "yaml": "catalog:runtime", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@eslint/eslintrc": "catalog:tools", + "@eslint/js": "catalog:tools", + "@faker-js/faker": "catalog:test", + "@nestjs/cli": "catalog:build", + "@nestjs/schematics": "catalog:build", + "@nestjs/testing": "catalog:build", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "eslint": "catalog:tools", + "globals": "catalog:test", + "msw": "catalog:test", + "pino-pretty": "catalog:tools", + "rimraf": "catalog:tools", + "ts-patch": "catalog:build", + "typescript": "catalog:build", + "typescript-eslint": "catalog:tools", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/prisma.config.ts b/.jjconflict-side-0/apps/server-nestjs/prisma.config.ts new file mode 100644 index 0000000000..ff5aaf9186 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/prisma.config.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' +import { defineConfig } from 'prisma/config' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +export default defineConfig({ + schema: path.join('src', 'prisma', 'schema'), + migrations: { + path: path.join('src', 'prisma', 'migrations'), + }, +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/__mocks__/prisma.ts b/.jjconflict-side-0/apps/server-nestjs/src/__mocks__/prisma.ts new file mode 100644 index 0000000000..2a871fd476 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/__mocks__/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-side-0/apps/server-nestjs/src/config/argocd.config.ts b/.jjconflict-side-0/apps/server-nestjs/src/config/argocd.config.ts new file mode 100644 index 0000000000..a7f1ecc094 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/config/argocd.config.ts @@ -0,0 +1,25 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const argocdFeatureSchema = z.object({ + ARGO_NAMESPACE: z.string().default('argocd'), + ARGOCD_URL: z.string().url(), + ARGOCD_INTERNAL_URL: z.string().url().optional(), + ARGOCD_EXTRA_REPOSITORIES: z.string().min(1), + DSO_ENV_CHART_VERSION: z.string().default('dso-env-1.6.0'), + DSO_NS_CHART_VERSION: z.string().default('dso-ns-1.1.5'), + VAULT__DEPLOY_VAULT_CONNECTION_IN_NS: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform(raw => ({ + namespace: raw.ARGO_NAMESPACE, + url: raw.ARGOCD_URL, + internalUrl: raw.ARGOCD_INTERNAL_URL, + extraRepositories: raw.ARGOCD_EXTRA_REPOSITORIES, + dsoEnvChartVersion: raw.DSO_ENV_CHART_VERSION, + dsoNsChartVersion: raw.DSO_NS_CHART_VERSION, + vaultDeployVaultConnectionInNs: raw.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS, + internalOrPublicUrl: raw.ARGOCD_INTERNAL_URL ?? raw.ARGOCD_URL, + probeUrl: new URL('/api/version', raw.ARGOCD_URL).toString(), +})) + +export const argocdConfigFactory = registerAs('argocd', () => argocdFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/config/base.config.ts b/.jjconflict-side-0/apps/server-nestjs/src/config/base.config.ts new file mode 100644 index 0000000000..f35ba27f19 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/config/base.config.ts @@ -0,0 +1,32 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, truthySchema } from './config.utils' + +const baseFeatureSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).optional(), + CI: flag(truthySchema.default('false')), + SERVER_HOST: z.string().default('localhost'), + SERVER_PORT: z.string().transform(Number).default('0'), + APP_VERSION: z.string().optional().default('unknown'), + DB_URL: z.string().url().optional(), + PROJECTS_ROOT_DIR: z.string().min(1, 'PROJECTS_ROOT_DIR is required'), + HTTP_PROXY: z.string().url().optional(), +}).transform((raw) => { + const nodeEnv = raw.NODE_ENV ?? 'production' + return { + isTest: raw.NODE_ENV === 'test', + isDev: raw.NODE_ENV === 'development', + isCI: raw.CI, + isProd: nodeEnv === 'production', + serverHost: raw.SERVER_HOST, + serverPort: raw.SERVER_PORT, + appVersion: raw.NODE_ENV === 'production' ? raw.APP_VERSION : 'dev', + dbUrl: raw.DB_URL, + projectsRootDir: raw.PROJECTS_ROOT_DIR, + httpProxy: raw.HTTP_PROXY, + } +}) + +export type BaseConfig = z.infer + +export const baseConfigFactory = registerAs('base', () => baseFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/config/config.utils.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/config/config.utils.spec.ts new file mode 100644 index 0000000000..155c025349 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/config/config.utils.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import z from 'zod' +import { csv, flag, truthySchema } from './config.utils' + +describe('config.utils', () => { + describe('flag', () => { + it('coerces true/1 (case-insensitive) to true', () => { + const schema = flag(truthySchema.default('false')) + expect(schema.parse('true')).toBe(true) + expect(schema.parse('1')).toBe(true) + expect(schema.parse('TRUE')).toBe(true) + }) + + it('coerces false/0 to false', () => { + const schema = flag(truthySchema.default('true')) + expect(schema.parse('false')).toBe(false) + expect(schema.parse('0')).toBe(false) + }) + + it('falls back to the schema default when missing', () => { + expect(flag(truthySchema.default('false')).parse(undefined)).toBe(false) + expect(flag(truthySchema.default('true')).parse(undefined)).toBe(true) + }) + }) + + describe('csv', () => { + const schema = csv(z.string()) + + it('splits, trims and drops empty parts', () => { + expect(schema.parse('a, b ,,c')).toEqual(['a', 'b', 'c']) + }) + + it('maps missing/empty to an empty array', () => { + expect(schema.parse(undefined)).toEqual([]) + expect(schema.parse('')).toEqual([]) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/config/config.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/config/config.utils.ts new file mode 100644 index 0000000000..f06cde73f8 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/config/config.utils.ts @@ -0,0 +1,27 @@ +import z from 'zod' + +// Shared truthy enum for flag(): 'true'/'false'/'1'/'0'. +export const truthySchema = z.enum(['true', 'false', '1', '0']) + +// Boolean flag. Strictly takes a z string schema (e.g. truthySchema.default('true')). +// Lowercases, then coerces 'true'/'1' -> true, 'false'/'0' -> false. +// Missing value falls back to the schema's default. +export function flag(schema: z.ZodType) { + return z + .preprocess( + val => (typeof val === 'string' ? val.toLowerCase() : val), + schema, + ) + .transform(val => val === 'true' || val === '1') +} + +// Comma-separated string -> array of schema-validated, trimmed non-empty parts. +// Empty/missing -> []. Strictly takes a z string schema (applied per element). +export function csv>(schema: T) { + return z + .preprocess( + val => (typeof val === 'string' ? val : val ?? ''), + z.string().transform(value => value.split(',').map(part => part.trim()).filter(Boolean)), + ) + .pipe(z.array(schema)) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/config/gitlab.config.ts b/.jjconflict-side-0/apps/server-nestjs/src/config/gitlab.config.ts new file mode 100644 index 0000000000..d376279d4f --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/config/gitlab.config.ts @@ -0,0 +1,28 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const gitlabFeatureSchema = z.object({ + GITLAB_TOKEN: z.string().min(1), + GITLAB_URL: z.string().url(), + GITLAB_INTERNAL_URL: z.string().url().optional(), + GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS: z.coerce.number().int().positive().default(180), + GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + GITLAB__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), + PROJECTS_ROOT_DIR: z.string().min(1), +}).transform((raw) => { + const urlBase = raw.GITLAB_INTERNAL_URL ?? raw.GITLAB_URL + return { + token: raw.GITLAB_TOKEN, + url: raw.GITLAB_URL, + internalUrl: raw.GITLAB_INTERNAL_URL, + secretExposeInternalUrl: raw.GITLAB__SECRET_EXPOSE_INTERNAL_URL, + mirrorTokenExpirationDays: raw.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS, + mirrorTokenRotationThresholdDays: raw.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS, + projectRootDir: raw.PROJECTS_ROOT_DIR, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/-/health', urlBase).toString(), + } +}) + +export const gitlabConfigFactory = registerAs('gitlab', () => gitlabFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/config/harbor.config.ts b/.jjconflict-side-0/apps/server-nestjs/src/config/harbor.config.ts new file mode 100644 index 0000000000..3e4d093272 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/config/harbor.config.ts @@ -0,0 +1,34 @@ +import type { HarborConfig } from '../modules/registry/harbor.module-definition' +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const harborFeatureSchema = z.object({ + HARBOR_URL: z.string().url(), + HARBOR_INTERNAL_URL: z.string().url().optional(), + HARBOR_ADMIN: z.string().min(1), + HARBOR_ADMIN_PASSWORD: z.string().min(1), + HARBOR_RULE_TEMPLATE: z.string().optional(), + HARBOR_RULE_COUNT: z.string().optional(), + HARBOR_RETENTION_CRON: z.string().default('0 22 2 * * *'), + HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + HARBOR_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform((raw) => { + const urlBase = raw.HARBOR_INTERNAL_URL ?? raw.HARBOR_URL + return { + url: raw.HARBOR_URL, + internalUrl: raw.HARBOR_INTERNAL_URL, + admin: raw.HARBOR_ADMIN, + adminPassword: raw.HARBOR_ADMIN_PASSWORD, + ruleTemplate: raw.HARBOR_RULE_TEMPLATE, + ruleCount: raw.HARBOR_RULE_COUNT, + retentionCron: raw.HARBOR_RETENTION_CRON, + robotRotationThresholdDays: raw.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.HARBOR_PROJECT_SLUG_CACHE_TTL_MS, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/api/v2.0/ping', urlBase).toString(), + } +}) + +export type HarborAppConfig = HarborConfig + +export const harborConfigFactory = registerAs('harbor', () => harborFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/config/keycloak.config.ts b/.jjconflict-side-0/apps/server-nestjs/src/config/keycloak.config.ts new file mode 100644 index 0000000000..576cd9c996 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/config/keycloak.config.ts @@ -0,0 +1,50 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const keycloakFeatureSchema = z.object({ + KEYCLOAK_PROTOCOL: z.enum(['http', 'https']).default('https'), + KEYCLOAK_DOMAIN: z.string().min(1), + KEYCLOAK_PUBLIC_PROTOCOL: z.enum(['http', 'https']).default('https'), + KEYCLOAK_PUBLIC_DOMAIN: z.string().min(1), + KEYCLOAK_REALM: z.string().min(1), + KEYCLOAK_CLIENT_ID: z.string().min(1), + KEYCLOAK_CLIENT_SECRET: z.string().min(1), + KEYCLOAK_ADMIN: z.string().min(1), + KEYCLOAK_ADMIN_PASSWORD: z.string().min(1), + KEYCLOAK_ADMIN_CLIENT_ID: z.string().default('admin-cli'), + KEYCLOAK_REDIRECT_URI: z.string().url(), + KEYCLOAK_JWKS_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + KEYCLOAK_JWKS_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), + KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + ADMIN_KC_USER_ID: z.preprocess( + value => (typeof value === 'string' ? value.split(',').map(part => part.trim()).filter(Boolean) : value), + z.array(z.string()).default([]), + ), +}).transform((raw) => { + const keycloakUrl = `${raw.KEYCLOAK_PROTOCOL}://${raw.KEYCLOAK_DOMAIN}` + const keycloakRealmUrl = `${keycloakUrl}/realms/${raw.KEYCLOAK_REALM}` + return { + protocol: raw.KEYCLOAK_PROTOCOL, + domain: raw.KEYCLOAK_DOMAIN, + publicProtocol: raw.KEYCLOAK_PUBLIC_PROTOCOL, + publicDomain: raw.KEYCLOAK_PUBLIC_DOMAIN, + realm: raw.KEYCLOAK_REALM, + clientId: raw.KEYCLOAK_CLIENT_ID, + clientSecret: raw.KEYCLOAK_CLIENT_SECRET, + admin: raw.KEYCLOAK_ADMIN, + adminPassword: raw.KEYCLOAK_ADMIN_PASSWORD, + adminClientId: raw.KEYCLOAK_ADMIN_CLIENT_ID, + redirectUri: raw.KEYCLOAK_REDIRECT_URI, + jwksCacheTtlMs: raw.KEYCLOAK_JWKS_CACHE_TTL_MS, + jwksTimeoutMs: raw.KEYCLOAK_JWKS_TIMEOUT_MS, + openidConfigurationCacheTtlMs: raw.KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS, + adminKcUserId: raw.ADMIN_KC_USER_ID, + url: keycloakUrl, + realmUrl: keycloakRealmUrl, + openidConfigurationUrl: `${keycloakRealmUrl}/.well-known/openid-configuration`, + } +}) + +export type KeycloakConfig = z.infer + +export const keycloakConfigFactory = registerAs('keycloak', () => keycloakFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/config/nexus.config.ts b/.jjconflict-side-0/apps/server-nestjs/src/config/nexus.config.ts new file mode 100644 index 0000000000..cfdc23000b --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/config/nexus.config.ts @@ -0,0 +1,24 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const nexusFeatureSchema = z.object({ + NEXUS_URL: z.string().url(), + NEXUS_INTERNAL_URL: z.string().url().optional(), + NEXUS_ADMIN: z.string().min(1), + NEXUS_ADMIN_PASSWORD: z.string().min(1), + NEXUS__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + const urlBase = raw.NEXUS_INTERNAL_URL ?? raw.NEXUS_URL + return { + url: raw.NEXUS_URL, + internalUrl: raw.NEXUS_INTERNAL_URL, + admin: raw.NEXUS_ADMIN, + adminPassword: raw.NEXUS_ADMIN_PASSWORD, + secretExposeInternalUrl: raw.NEXUS__SECRET_EXPOSE_INTERNAL_URL, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/service/rest/v1/status', urlBase).toString(), + } +}) + +export const nexusConfigFactory = registerAs('nexus', () => nexusFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/config/opencds.config.ts b/.jjconflict-side-0/apps/server-nestjs/src/config/opencds.config.ts new file mode 100644 index 0000000000..fb1da23b96 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/config/opencds.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const opencdsFeatureSchema = z.object({ + OPENCDS_URL: z.string().url(), + OPENCDS_INTERNAL_URL: z.string().url().optional(), + OPENCDS_API_TOKEN: z.string().min(1), + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + const probeBase = raw.OPENCDS_INTERNAL_URL ?? raw.OPENCDS_URL + return { + url: raw.OPENCDS_URL, + internalUrl: raw.OPENCDS_INTERNAL_URL, + probeUrl: new URL('/api/v1/health', probeBase).toString(), + apiToken: raw.OPENCDS_API_TOKEN, + apiTlsRejectUnauthorized: raw.OPENCDS_API_TLS_REJECT_UNAUTHORIZED, + } +}) + +export const openCdsConfigFactory = registerAs('opencds', () => opencdsFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/config/registry.config.ts b/.jjconflict-side-0/apps/server-nestjs/src/config/registry.config.ts new file mode 100644 index 0000000000..c30ef12c50 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/config/registry.config.ts @@ -0,0 +1,30 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const registryFeatureSchema = z.object({ + REGISTRY_URL: z.string().url(), + REGISTRY_INTERNAL_URL: z.string().url().optional(), + REGISTRY_ADMIN: z.string().min(1), + REGISTRY_ADMIN_PASSWORD: z.string().min(1), + REGISTRY_RULE_TEMPLATE: z.string().min(1), + REGISTRY_RULE_COUNT: z.coerce.number().int().nonnegative().optional(), + REGISTRY_RETENTION_CRON: z.string().default('0 22 2 * * *'), + REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + REGISTRY_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform((raw) => { + const urlBase = raw.REGISTRY_INTERNAL_URL ?? raw.REGISTRY_URL + return { + url: raw.REGISTRY_URL, + internalUrl: raw.REGISTRY_INTERNAL_URL, + admin: raw.REGISTRY_ADMIN, + adminPassword: raw.REGISTRY_ADMIN_PASSWORD, + ruleTemplate: raw.REGISTRY_RULE_TEMPLATE, + ruleCount: raw.REGISTRY_RULE_COUNT, + retentionCron: raw.REGISTRY_RETENTION_CRON, + robotRotationThresholdDays: raw.REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.REGISTRY_PROJECT_SLUG_CACHE_TTL_MS, + internalOrPublicUrl: urlBase, + } +}) + +export const registryConfigFactory = registerAs('registry', () => registryFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/config/sonarqube.config.ts b/.jjconflict-side-0/apps/server-nestjs/src/config/sonarqube.config.ts new file mode 100644 index 0000000000..78fdd00939 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/config/sonarqube.config.ts @@ -0,0 +1,19 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const sonarqubeFeatureSchema = z.object({ + SONARQUBE_URL: z.string().url(), + SONARQUBE_INTERNAL_URL: z.string().url().optional(), + SONAR_API_TOKEN: z.string().min(1), +}).transform((raw) => { + const urlBase = raw.SONARQUBE_INTERNAL_URL ?? raw.SONARQUBE_URL + return { + url: raw.SONARQUBE_URL, + internalUrl: raw.SONARQUBE_INTERNAL_URL, + apiToken: raw.SONAR_API_TOKEN, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/api/system/health', urlBase).toString(), + } +}) + +export const sonarqubeConfigFactory = registerAs('sonarqube', () => sonarqubeFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/config/vault.config.ts b/.jjconflict-side-0/apps/server-nestjs/src/config/vault.config.ts new file mode 100644 index 0000000000..83aca3cb62 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/config/vault.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const vaultFeatureSchema = z.object({ + VAULT_TOKEN: z.string().min(1), + VAULT_URL: z.string().url(), + VAULT_INTERNAL_URL: z.string().url().optional(), + VAULT_KV_NAME: z.string().default('forge-dso'), +}).transform((raw) => { + const urlBase = raw.VAULT_INTERNAL_URL ?? raw.VAULT_URL + return { + token: raw.VAULT_TOKEN, + url: raw.VAULT_URL, + internalUrl: raw.VAULT_INTERNAL_URL, + kvName: raw.VAULT_KV_NAME, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/v1/sys/health', urlBase).toString(), + } +}) + +export const vaultConfigFactory = registerAs('vault', () => vaultFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/main.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/main.module.ts new file mode 100644 index 0000000000..87f8cb8180 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/main.module.ts @@ -0,0 +1,53 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { ScheduleModule } from '@nestjs/schedule' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from './config/base.config' +import { DeploymentModule } from './modules/deployment/deployment.module' +import { EnvironmentModule } from './modules/environment/environment.module' +import { HealthzModule } from './modules/healthz/healthz.module' +import { InfrastructureModule } from './modules/infrastructure/infrastructure.module' +import { KeycloakModule } from './modules/keycloak/keycloak.module' +import { LogModule } from './modules/log/log.module' +import { ProjectBulkModule } from './modules/project-bulk/project-bulk.module' +import { ProjectHooksModule } from './modules/project-hooks/project-hooks.module' +import { ProjectMembersModule } from './modules/project-members/project-members.module' +import { ProjectRolesModule } from './modules/project-roles/project-roles.module' +import { ProjectSecretsModule } from './modules/project-secrets/project-secrets.module' +import { ProjectServicesModule } from './modules/project-services/project-services.module' +import { ProjectModule } from './modules/project/project.module' +import { ServiceChainModule } from './modules/service-chain/service-chain.module' +import { SystemSettingsModule } from './modules/system-settings/system-settings.module' +import { VersionModule } from './modules/version/version.module' +import { getDotenvPaths } from './utils/dotenv.utils' + +@Module({ + imports: [ + ConfigModule.forRoot({ + envFilePath: getDotenvPaths(), + isGlobal: true, + load: [baseConfigFactory], + }), + TerminusModule.forRoot(), + InfrastructureModule, + HealthzModule, + KeycloakModule, + ScheduleModule.forRoot(), + SystemSettingsModule, + ServiceChainModule, + ProjectModule, + ProjectHooksModule, + ProjectSecretsModule, + ProjectServicesModule, + ProjectBulkModule, + ProjectMembersModule, + ProjectRolesModule, + LogModule, + DeploymentModule, + EnvironmentModule, + VersionModule, + ], + controllers: [], + providers: [], +}) +export class MainModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/main.ts b/.jjconflict-side-0/apps/server-nestjs/src/main.ts new file mode 100644 index 0000000000..600f7232f7 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/main.ts @@ -0,0 +1,66 @@ +import type { NestFastifyApplication } from '@nestjs/platform-fastify' +import type { BaseConfig } from './modules/infrastructure/config/base.config' +import { NestFactory } from '@nestjs/core' +import { FastifyAdapter } from '@nestjs/platform-fastify' +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' +import { NodeSDK, resources } from '@opentelemetry/sdk-node' +import { Logger } from 'nestjs-pino' +import { MainModule } from './main.module' +import { BASE_CONFIG } from './modules/infrastructure/config/base.module' + +const SERVICE_NAME = 'console-pi-native-console' + +const telemetry = new NodeSDK({ + traceExporter: new OTLPTraceExporter({}), + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + }), + instrumentations: [ + getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-nestjs-core': { enabled: true }, + '@opentelemetry/instrumentation-pino': { enabled: true }, + }), + ], + resource: resources.resourceFromAttributes({ + 'service.name': SERVICE_NAME, + }), +}) + +async function bootstrap() { + telemetry.start() + + const app = await NestFactory.create(MainModule, new FastifyAdapter(), { + bufferLogs: true, + }) + + app.useLogger(app.get(Logger)) + app.flushLogs() + app.enableShutdownHooks() + app.getHttpAdapter().getInstance().addHook('onClose', async () => { + await telemetry.shutdown() + }) + + const config = app.get(BASE_CONFIG) + + // Setup swagger-ui route + const swaggerConfig = new DocumentBuilder() + .setTitle('Console Cloud π Native') + .setDescription('Description de l\' API Cloud π Native') + .setVersion('1.0') + .addTag('console') + .build() + const documentFactory = () => SwaggerModule.createDocument(app, swaggerConfig) + SwaggerModule.setup('swagger-ui-server-nestjs', app, documentFactory) + + await app.listen(config.serverPort, config.serverHost) + + const serverUrl = await app.getUrl() + const logger = app.get(Logger) + logger.log(`NestJS server running on: ${serverUrl}`) +} + +void bootstrap() diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts new file mode 100644 index 0000000000..d3103a2e31 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts @@ -0,0 +1,104 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + helmValuesFiles: true, + deployRevision: true, + deployPath: true, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + deployments: { + select: { + id: true, + name: true, + autosync: true, + environment: { + select: { + id: true, + name: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + deploymentSources: { + select: { + type: true, + path: true, + targetRevision: true, + helmValuesFiles: true, + repository: { + select: { + id: true, + internalRepoName: true, + }, + }, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class ArgoCDDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts new file mode 100644 index 0000000000..f79fc15424 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts @@ -0,0 +1,23 @@ +import type { ArgocdConfig } from './argocd.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ARGOCD_CONFIG } from './argocd.module-definition' + +@Injectable() +export class ArgoCDHealthService { + constructor( + @Inject(ARGOCD_CONFIG) private readonly argocdConfig: ArgocdConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.argocdConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts new file mode 100644 index 0000000000..7388fc6dd8 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts @@ -0,0 +1,169 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Injectable } from '@nestjs/common' +import { + DEFAULT_DSO_ENV_CHART_VERSION, + DEFAULT_DSO_NS_CHART_VERSION, + DSO_ENV_CHART_VERSION_PLUGIN_KEY, + DSO_NS_CHART_VERSION_PLUGIN_KEY, + EXTRA_REPOSITORIES_PLUGIN_KEY, + PLATFORM_ADMIN_GROUP_PATH, + PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + PLATFORM_READONLY_GROUP_PATH, + PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY, + PLATFORM_SECURITY_GROUP_PATH, + PROJECT_ADMIN_GROUP_PATH_SUFFIX, + PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_READONLY_GROUP_PATH_SUFFIX, + PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_GROUP_PATH_SUFFIX, +} from './argocd.constants' + +@Injectable() +export class ArgoCDPluginService { + infos(): ServiceInfos { + const extraRepositoriesDesc = 'appproject.spec.sourceRepos supplémentaires, séparés par des virgules (https://a.com/repo.git,https://b.com/' + + return { + name: 'argocd', + to: ({ zones, project }) => zones.map(z => ({ + to: new URL(`applications?showFavorites=false&proj=&sync=&health=&namespace=&cluster=&labels=&search=${project.slug}`, z.argocdUrl).toString(), + title: `ArgoCD ${z.label}`, + })), + title: 'ArgoCD', + imgSrc: '/img/argocd.svg', + description: 'ArgoCD est un outil déclaratif de livraison continue GitOps pour Kubernetes', + config: { + global: [{ + key: EXTRA_REPOSITORIES_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }, { + key: PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Admin Group Path', + value: PLATFORM_ADMIN_GROUP_PATH, + description: 'Chemin du groupe administrateur de plateforme', + }, { + key: PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Readonly Group Path', + value: PLATFORM_READONLY_GROUP_PATH, + description: 'Chemin du groupe lecture seule de plateforme', + }, { + key: 'platformSecurityGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Security Group Path', + value: PLATFORM_SECURITY_GROUP_PATH, + description: 'Chemin du groupe sécurité de plateforme', + }, { + key: PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Admin Group Path Suffix', + value: PROJECT_ADMIN_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe administrateur de projet', + }, { + key: PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project DevOps Group Path Suffix', + value: PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe devops de projet', + }, { + key: PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Developer Group Path Suffix', + value: PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe développeur de projet', + }, { + key: PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Readonly Group Path Suffix', + value: PROJECT_READONLY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe lecture seule de projet', + }, { + key: 'projectSecurityGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Security Group Path Suffix', + value: PROJECT_SECURITY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe sécurité de projet', + }, { + key: DSO_ENV_CHART_VERSION_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Env Chart Version', + value: DEFAULT_DSO_ENV_CHART_VERSION, + description: 'Version du chart Helm dso-env', + placeholder: DEFAULT_DSO_ENV_CHART_VERSION, + }, { + key: DSO_NS_CHART_VERSION_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Namespace Chart Version', + value: DEFAULT_DSO_NS_CHART_VERSION, + description: 'Version du chart Helm dso-ns', + placeholder: DEFAULT_DSO_NS_CHART_VERSION, + }], + project: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts new file mode 100644 index 0000000000..65d58f8955 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts @@ -0,0 +1,94 @@ +import type { ProjectWithDetails } from './argocd-datastore.service' + +import { faker } from '@faker-js/faker' + +export function makeProjectDeploymentSource( + overrides: Partial = {}, +): ProjectWithDetails['deployments'][number]['deploymentSources'][number] { + return { + type: 'git', + path: '.', + targetRevision: 'HEAD', + helmValuesFiles: '', + repository: makeProjectRepository(), + ...overrides, + } satisfies ProjectWithDetails['deployments'][number]['deploymentSources'][number] +} + +export function makeProjectDeployment( + overrides: Partial = {}, +): ProjectWithDetails['deployments'][number] { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + environment: makeProjectEnvironment(), + autosync: true, + deploymentSources: [ + { + type: 'git', + path: '.', + targetRevision: 'HEAD', + helmValuesFiles: '', + repository: makeProjectRepository(), + }, + ], + ...overrides, + } satisfies ProjectWithDetails['deployments'][number] +} + +export function makeProjectRepository( + overrides: Partial = {}, +): ProjectWithDetails['repositories'][number] { + return { + id: faker.string.uuid(), + internalRepoName: faker.word.noun(), + isInfra: false, + deployRevision: 'HEAD', + deployPath: '.', + helmValuesFiles: '', + ...overrides, + } satisfies ProjectWithDetails['repositories'][number] +} + +export function makeCluster( + overrides: Partial = {}, +): ProjectWithDetails['environments'][number]['cluster'] { + return { + id: faker.string.uuid(), + label: faker.word.noun(), + zone: { + slug: faker.word.noun(), + }, + ...overrides, + } satisfies ProjectWithDetails['environments'][number]['cluster'] +} + +export function makeProjectEnvironment( + overrides: Partial = {}, +): ProjectWithDetails['environments'][number] { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + cluster: makeCluster(), + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + ...overrides, + } satisfies ProjectWithDetails['environments'][number] +} + +export function makeProjectWithDetails( + overrides: Partial = {}, +): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(faker.word.words({ count: 2 })).toLowerCase(), + name: faker.word.noun(), + environments: [makeProjectEnvironment()], + repositories: [makeProjectRepository()], + plugins: [], + deployments: [makeProjectDeployment()], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.constants.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.constants.ts new file mode 100644 index 0000000000..d856cf98bf --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.constants.ts @@ -0,0 +1,30 @@ +// ArgoCD plugin constants +export const PLUGIN_NAME = 'argocd' + +// Platform-level group paths +export const CONSOLE_ADMIN_GROUP_PATH = '/console/admin' +export const PLATFORM_ADMIN_GROUP_PATH = '/console/admin' +export const PLATFORM_READONLY_GROUP_PATH = '/console/readonly' +export const PLATFORM_SECURITY_GROUP_PATH = '/console/security' + +// Suffix for project group path +export const PROJECT_ADMIN_GROUP_PATH_SUFFIX = '/console/admin' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const PROJECT_READONLY_GROUP_PATH_SUFFIX = '/console/readonly' +export const PROJECT_SECURITY_GROUP_PATH_SUFFIX = '/console/security' + +// Default chart versions +export const DEFAULT_DSO_ENV_CHART_VERSION = 'dso-env-1.6.0' +export const DEFAULT_DSO_NS_CHART_VERSION = 'dso-ns-1.1.5' + +// Plugin configuration keys +export const EXTRA_REPOSITORIES_PLUGIN_KEY = 'extraRepositories' +export const PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY = 'platformAdminGroupPath' +export const PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY = 'platformReadonlyGroupPath' +export const PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectAdminGroupPathSuffix' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevopsGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevelopperGroupPathSuffix' +export const PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReadonlyGroupPathSuffix' +export const DSO_ENV_CHART_VERSION_PLUGIN_KEY = 'dsoEnvChartVersion' +export const DSO_NS_CHART_VERSION_PLUGIN_KEY = 'dsoNsChartVersion' diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts new file mode 100644 index 0000000000..a134c72f4d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts @@ -0,0 +1,19 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface ArgocdConfig { + namespace: string + url: string + internalUrl: string + extraRepositories: string + dsoEnvChartVersion: string + dsoNsChartVersion: string + vaultDeployVaultConnectionInNs: boolean + internalOrPublicUrl?: string +} + +export interface ArgoCDModuleOptions extends ArgocdConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('argocd') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as ARGOCD_CONFIG } diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts new file mode 100644 index 0000000000..e3e4064a44 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts @@ -0,0 +1,38 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ArgoCDModule } from './argocd.module' +import { ArgoCDService } from './argocd.service' + +describe('argocdModule', () => { + beforeEach(() => { + vi.stubEnv('PROJECTS_ROOT_DIR', 'tmp/projects') + vi.stubEnv('VAULT_URL', 'https://vault.example') + vi.stubEnv('VAULT_INTERNAL_URL', 'https://vault.internal') + vi.stubEnv('VAULT_TOKEN', 'token') + vi.stubEnv('ARGOCD_URL', 'https://argocd.example') + vi.stubEnv('ARGOCD_INTERNAL_URL', 'https://argocd.internal') + vi.stubEnv('ARGOCD_EXTRA_REPOSITORIES', 'repo') + vi.stubEnv('GITLAB_URL', 'https://gitlab.example') + vi.stubEnv('GITLAB_INTERNAL_URL', 'https://gitlab.internal') + vi.stubEnv('GITLAB_TOKEN', 'token') + }) + + afterEach(() => vi.unstubAllEnvs()) + + it('registers ArgoCDService when USE_ARGOCD=true', async () => { + vi.stubEnv('USE_ARGOCD', 'true') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD')], + }).compile() + expect(module.get(ArgoCDService)).toBeInstanceOf(ArgoCDService) + }) + + it('omits ArgoCDService when USE_ARGOCD=false', async () => { + vi.stubEnv('USE_ARGOCD', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD')], + }).compile() + expect(() => module.get(ArgoCDService)).toThrow() + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.module.ts new file mode 100644 index 0000000000..e38fad5b8b --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { argocdConfigFactory } from '../../config/argocd.config' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' +import { GitlabModule } from '../gitlab/gitlab.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { ArgoCDHealthService } from './argocd-health.service' +import { ArgoCDPluginService } from './argocd-plugin.service' +import { ArgoCDService } from './argocd.service' + +@Module({ + imports: [DatabaseModule, GitlabModule, TerminusModule, VaultModule, ConfigModule.forFeature(argocdConfigFactory), ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + providers: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService, ArgoCDDatastoreService], + exports: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService], +}) +export class ArgoCDModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts new file mode 100644 index 0000000000..2c0511c4bf --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -0,0 +1,541 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { ArgocdConfig } from './argocd.module-definition' +import { generateNamespaceName } from '@cpn-console/shared' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { stringify } from 'yaml' +import { GitlabClientService } from '../gitlab/gitlab-client.service' +import { makeCommitAction, makeProjectSchema, makeRepositoryTreeSchema } from '../gitlab/gitlab-testing.utils' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { makeProjectDeployment, makeProjectDeploymentSource, makeProjectEnvironment, makeProjectRepository, makeProjectWithDetails } from './argocd-testing.utils' +import { ARGOCD_CONFIG } from './argocd.module-definition' +import { ArgoCDService } from './argocd.service' + +describe('argoCDService', () => { + let service: ArgoCDService + let datastore: DeepMockProxy + let gitlab: DeepMockProxy + let vault: DeepMockProxy + let argocdConfig: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + datastore = mockDeep() + gitlab = mockDeep() + vault = mockDeep() + argocdConfig = mockDeep({ + namespace: 'argocd', + url: 'https://argocd.internal', + internalUrl: undefined, + extraRepositories: 'repo3', + dsoEnvChartVersion: 'dso-env-1.6.0', + dsoNsChartVersion: 'dso-ns-1.1.5', + vaultDeployVaultConnectionInNs: false, + internalOrPublicUrl: undefined, + }) + baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep({ + url: 'https://vault.internal', + kvName: 'kv', + internalUrl: undefined, + }) + + const module = await Test.createTestingModule({ + providers: [ + ArgoCDService, + { provide: ArgoCDDatastoreService, useValue: datastore }, + { provide: ARGOCD_CONFIG, useValue: argocdConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: GitlabClientService, useValue: gitlab }, + { provide: VaultClientService, useValue: vault }, + ], + }).compile() + + service = module.get(ArgoCDService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('should return OK result when ensureProject succeeds', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [], + repositories: [], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) + gitlab.maybeCreateCommit.mockResolvedValue(undefined) + + const result = await service.handleUpsert(mockProject) + + expect(result).toEqual({ + argocd: expect.objectContaining({ + status: 'OK', + message: 'Up to date', + executionTime: expect.any(Number), + }), + }) + }) + + it('should return KO result when ensureProject throws', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const error = new Error('GitLab unreachable') + gitlab.getOrCreateInfraGroupRepo.mockRejectedValue(error) + + const result = await service.handleUpsert(mockProject) + + expect(result).toEqual({ + argocd: expect.objectContaining({ + status: 'KO', + message: 'GitLab unreachable', + executionTime: expect.any(Number), + error, + }), + }) + }) + }) + + it('should sync project environments', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + makeProjectEnvironment({ name: 'prod', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + // Verify Gitlab calls + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'dev', + 'dso/environment.id': mockProject.environments[0].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-dev-6293', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/dev/values.yaml', + roGroup: '/project-1/console/dev/RO', + rwGroup: '/project-1/console/dev/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockProject.environments[0].id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + id: mockProject.repositories[0].id, + name: 'infra-repo', + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: 'HEAD', + path: '.', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/dev/values.yaml', + }, + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'prod', + 'dso/environment.id': mockProject.environments[1].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-prod-c626', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/prod/values.yaml', + roGroup: '/project-1/console/prod/RO', + rwGroup: '/project-1/console/prod/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockProject.environments[1].id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + id: mockProject.repositories[0].id, + name: 'infra-repo', + repoURL: 'https://gitlab.internal/group/project-1/infra-repo.git', + targetRevision: 'HEAD', + path: '.', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/prod/values.yaml', + }, + ]), + ) + + expect(gitlab.listFiles).toHaveBeenCalledWith(infraProject, { + path: 'Project 1/', + recursive: true, + }) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(2) + }) + + it('should delete values file when an environment is removed', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([ + makeRepositoryTreeSchema( + { name: 'values.yaml', path: 'Project 1/cluster-1/dev/values.yaml' }, + ), + makeRepositoryTreeSchema( + { name: 'values.yaml', path: 'Project 1/cluster-1/prod/values.yaml' }, + ), + ]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + expect.objectContaining({ + action: 'create', + filePath: 'Project 1/cluster-1/dev/values.yaml', + }), + { + action: 'delete', + filePath: 'Project 1/cluster-1/prod/values.yaml', + }, + ]), + ) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(1) + }) + + it('should not commit when there is no diff', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + + gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) + + await expect(service.handleCron()).resolves.not.toThrow() + + expect(gitlab.maybeCreateCommit).not.toHaveBeenCalled() + }) + + it('should sync project deployments', async () => { + const mockRepo = makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true }) + const mockDevEnv = makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }) + const mockProdEnv = makeProjectEnvironment({ name: 'prod', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }) + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + mockDevEnv, + mockProdEnv, + ], + repositories: [mockRepo], + plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], + deployments: [ + makeProjectDeployment({ + environment: mockDevEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: 'dev' })], + }), + makeProjectDeployment({ + environment: mockDevEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: '1.0.0', path: 'service-1' })], + }), + makeProjectDeployment({ + environment: mockProdEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: 'prod' })], + }), + ], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + // Verify Gitlab calls + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'dev', + 'dso/environment.id': mockProject.environments[0].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-dev-6293', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/dev/values.yaml', + roGroup: '/project-1/console/dev/RO', + rwGroup: '/project-1/console/dev/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockDevEnv.id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + name: 'infra-repo', + id: mockRepo.id, + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: 'dev', + path: '.', + valueFiles: [], + }, + { + name: 'infra-repo', + id: mockRepo.id, + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: '1.0.0', + path: 'service-1', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/dev/values.yaml', + }, + ]), + ) + + expect(gitlab.listFiles).toHaveBeenCalledWith(infraProject, { + path: 'Project 1/', + recursive: true, + }) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(4) // 2 environments + 2 deployments + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.service.ts new file mode 100644 index 0000000000..cbfa862247 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -0,0 +1,662 @@ +import type { CommitAction, CondensedProjectSchema, ProjectSchema, SimpleProjectSchema } from '@gitbeaker/core' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { ProjectWithDetails } from './argocd-datastore.service' +import type { ArgocdConfig } from './argocd.module-definition' +import { createHmac } from 'node:crypto' +import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { stringify } from 'yaml' +import { GitlabClientService } from '../gitlab/gitlab-client.service' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { + CONSOLE_ADMIN_GROUP_PATH, + PLATFORM_ADMIN_GROUP_PATH, + PLATFORM_READONLY_GROUP_PATH, + PLATFORM_SECURITY_GROUP_PATH, + PROJECT_ADMIN_GROUP_PATH_SUFFIX, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + PROJECT_READONLY_GROUP_PATH_SUFFIX, + PROJECT_SECURITY_GROUP_PATH_SUFFIX, +} from './argocd.constants' +import { ARGOCD_CONFIG } from './argocd.module-definition' + +@Injectable() +export class ArgoCDService { + private readonly logger = new Logger(ArgoCDService.name) + + constructor( + @Inject(ArgoCDDatastoreService) private readonly datastore: ArgoCDDatastoreService, + @Inject(ARGOCD_CONFIG) private readonly argocdConfig: ArgocdConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('ArgoCDService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('argocd', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`ArgoCD sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('argocd', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`ArgoCD sync completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + this.logger.log('Starting ArgoCD reconciliation') + const projects = await this.datastore.getAllProjects() + const span = trace.getActiveSpan() + span?.setAttribute('argocd.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for ArgoCD reconciliation`) + await this.ensureProjects(projects) + this.logger.log(`ArgoCD reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('argocd.projects.count', projects.length) + this.logger.verbose(`Reconciling ArgoCD projects (count=${projects.length})`) + await Promise.all(projects.map(project => this.ensureProject(project))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling ArgoCD project ${project.slug}`) + await this.ensureZones(project) + this.logger.verbose(`ArgoCD project reconciled (${project.slug})`) + } + + @StartActiveSpan() + private async ensureZones( + project: ProjectWithDetails, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const zones = getDistinctZones(project) + span?.setAttribute('argocd.zones.count', zones.length) + this.logger.verbose(`Reconciling ArgoCD zones for project ${project.slug} (count=${zones.length})`) + await Promise.all(zones.map(zoneSlug => this.ensureZone(project, zoneSlug))) + } + + @StartActiveSpan() + private async ensureZone( + project: ProjectWithDetails, + zoneSlug: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const infraProject = await this.gitlab.getOrCreateInfraGroupRepo(zoneSlug) + span?.setAttributes({ + 'argocd.repo.id': infraProject.id, + 'argocd.repo.path': infraProject.path_with_namespace, + 'zone.slug': zoneSlug, + }) + this.logger.verbose(`Reconciling ArgoCD zone for project ${project.slug} in zone ${zoneSlug} (repoId=${infraProject.id})`) + + const actions = await this.generateEnvironmentActions(project, infraProject, zoneSlug) + + span?.setAttribute('argocd.repo.actions.count', actions.length) + if (actions.length === 0) { + this.logger.verbose(`No ArgoCD changes need to be committed for project ${project.slug} in zone ${zoneSlug}`) + return + } + + this.logger.log(`Applying ArgoCD changes for project ${project.slug} in zone ${zoneSlug} (actions=${actions.length})`) + await this.gitlab.maybeCreateCommit(infraProject, `ci: :robot_face: Sync ${project.slug}`, actions) + } + + private async generateEnvironmentActions( + project: ProjectWithDetails, + infraProject: ProjectSchema, + zoneSlug: string, + ) { + const environmentActions = await this.generateEnvironmentsUpdateActions( + project, + project.environments, + infraProject, + zoneSlug, + ) + const deploymentActions = await this.generateDeploymentsUpdateActions( + project, + infraProject, + zoneSlug, + ) + const purgeEnvironmentActions = await this.generatePurgeEnvironmentActions( + project, + infraProject, + zoneSlug, + ) + return [ + ...(deploymentActions.length ? deploymentActions : environmentActions), + ...purgeEnvironmentActions, + ] satisfies CommitAction[] + } + + private async generatePurgeEnvironmentActions( + project: ProjectWithDetails, + infraProject: CondensedProjectSchema, + zoneSlug: string, + ): Promise { + const neededFiles = new Set() + const clusterLabelsInZone = new Set( + project.environments + .filter(e => e.cluster.zone.slug === zoneSlug) + .map(e => e.cluster.label), + ) + + project.environments.forEach((env) => { + if (env.cluster?.zone.slug !== zoneSlug) return + neededFiles.add(formatEnvironmentValuesFilePath(project, env.cluster, env)) + }) + + const existingFiles = await this.gitlab.listFiles(infraProject, { + path: `${project.name}/`, + recursive: true, + }) + + const projectPrefix = `${project.name}/` + const actions = existingFiles + .filter((existingFile) => { + if (existingFile.name !== 'values.yaml') return false + if (!existingFile.path.startsWith(projectPrefix)) return false + + const remaining = existingFile.path.slice(projectPrefix.length) + const clusterLabel = remaining.split('/')[0] + if (!clusterLabel || !clusterLabelsInZone.has(clusterLabel)) return false + + return !neededFiles.has(existingFile.path) + }) + .map(existingFile => ({ action: 'delete', filePath: existingFile.path } satisfies CommitAction)) + + this.logger.verbose(`Computed ArgoCD purge actions for project ${project.slug} in zone ${zoneSlug} (actions=${actions.length})`) + + return actions + } + + private async generateEnvironmentsUpdateActions( + project: ProjectWithDetails, + environments: ProjectWithDetails['environments'], + infraProject: SimpleProjectSchema, + zoneSlug: string, + ): Promise { + this.logger.verbose(`Computing ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug} (environments=${environments.length})`) + const actions = await Promise.all( + environments + .filter(env => env.cluster?.zone.slug === zoneSlug) + .map(env => this.generateEnvironmentUpdateAction(project, env, infraProject)), + ) + const filteredActions: CommitAction[] = actions.filter(a => !!a) + this.logger.verbose(`Computed ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug} (actions=${filteredActions.length})`) + return filteredActions + } + + private async generateEnvironmentUpdateAction( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + infraProject: SimpleProjectSchema, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const vaultValues = await this.generateVaultValues(project.slug) + const cluster = environment.cluster + if (!cluster) { + this.logger.warn(`Cluster not found for environment ${environment.id} in project ${project.slug}`) + throw new Error(`Cluster not found for environment ${environment.id}`) + } + span?.setAttribute('zone.slug', cluster.zone.slug) + + const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment) + + const gitlabPublicProjectUrl = `${(await this.gitlab.getOrCreateProjectGroupPublicUrl())}/${project.slug}` + + const values = formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories: this.argocdConfig.extraRepositories, + infraProject, + valueFilePath, + vaultValues, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, + }) + + return this.gitlab.generateCreateOrUpdateAction( + infraProject, + 'main', + valueFilePath, + stringify(values), + ) + } + + private async generateDeploymentsUpdateActions( + project: ProjectWithDetails, + infraProject: SimpleProjectSchema, + zoneSlug: string, + ): Promise { + this.logger.verbose(`Computing ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug} (environments=${project.deployments.length})`) + const environments = project.deployments.reduce< + Record + >((acc, deployment) => { + const environment = deployment.environment + if (acc[environment.id]) acc[environment.id].deployments.push(deployment) + else acc[environment.id] = { environment, deployments: [deployment] } + return acc + }, {}) + + const actions = await Promise.all( + Object.values(environments) + .filter(({ environment }) => environment.cluster?.zone.slug === zoneSlug) + .map(({ environment, deployments }) => this.generateEnvironmentWithDeploymentsUpdateAction(project, environment, deployments, infraProject)), + ) + + const filteredActions: CommitAction[] = actions.filter(a => !!a) + this.logger.verbose(`Computed ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug} (actions=${filteredActions.length})`) + return filteredActions + } + + private async generateEnvironmentWithDeploymentsUpdateAction( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + deployments: ProjectWithDetails['deployments'][number][], + infraProject: SimpleProjectSchema, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const vaultValues = await this.generateVaultValues(project.slug) + const cluster = environment.cluster + if (!cluster) { + this.logger.warn(`Cluster not found for environment ${environment.id} in project ${project.slug}`) + throw new Error(`Cluster not found for environment ${environment.id}`) + } + span?.setAttribute('zone.slug', cluster.zone.slug) + + const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment) + + const gitlabPublicProjectUrl = `${(await this.gitlab.getOrCreateProjectGroupPublicUrl())}/${project.slug}` + + const values = formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories: this.argocdConfig.extraRepositories, + infraProject, + valueFilePath, + vaultValues, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, + deployments, + }) + + return this.gitlab.generateCreateOrUpdateAction( + infraProject, + 'main', + valueFilePath, + stringify(values), + ) + } + + private async generateVaultValues(projectSlug: string): Promise> { + this.logger.verbose(`Generating Vault project values for projectSlug=${projectSlug}`) + const roleId = await this.vault.getAuthApproleRoleRoleId(projectSlug).catch(() => { + this.logger.warn(`Couldn't find app role (project=${projectSlug})`) + return undefined + }) + const secretId = await this.vault.createAuthApproleRoleSecretId(projectSlug).catch(() => { + this.logger.warn(`Couldn't find secret (project=${projectSlug})`) + return undefined + }) + return { + projectsRootDir: this.baseConfig.projectsRootDir, + url: this.argocdConfig.vaultDeployVaultConnectionInNs ? this.vaultConfig.url : '', + coreKvName: this.vaultConfig.kvName, + roleId: roleId ?? 'none', + secretId: secretId ?? 'none', + } + } +} + +interface ValuesSchema { + common: { + 'dso/project': string + 'dso/project.id': string + 'dso/project.slug': string + 'dso/environment': string + 'dso/environment.id': string + } + argocd: { + cluster: string + namespace: string + project: string + envChartVersion: string + nsChartVersion: string + } + environment: { + valueFileRepository: string + valueFileRevision: string + valueFilePath: string + roGroup: string + rwGroup: string + consoleAdminGroup: string + platformAdminGroup: string + platformReadonlyGroup: string + platformSecurityGroup: string + projectAdminGroup: string + projectDevopsGroup: string + projectDevelopperGroup: string + projectSecurityGroup: string + projectReadonlyGroup: string + } + application: { + quota: { + cpu: number + gpu: number + memory: string + } + sourceRepositories: string[] + destination: { + namespace: string + name: string + } + autosync: boolean + vault: Record + repositories: { + name: string + id: string + repoURL: string + targetRevision: string + path: string + valueFiles: string[] + }[] + } + features: { + fineGrainedRoles: { + enabled: boolean + } + } +} + +function formatReadOnlyGroupName(projectSlug: string, environmentName: string) { + return generateEnvironmentConsoleGroupPath(projectSlug, environmentName, 'RO') +} + +function formatReadWriteGroupName(projectSlug: string, environmentName: string) { + return generateEnvironmentConsoleGroupPath(projectSlug, environmentName, 'RW') +} + +function generateEnvironmentConsoleGroupPath(projectSlug: string, environmentName: string, access: 'RO' | 'RW') { + return `/${projectSlug}/console/${environmentName}/${access}` +} + +function generateProjectConsoleGroupPath(projectSlug: string, suffix: string) { + return `/${projectSlug}${suffix}` +} + +function formatAppProjectName(projectSlug: string, env: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${envHash}` +} + +function formatEnvironmentValuesFilePath(project: { name: string }, cluster: { label: string }, env: { name: string }): string { + return `${project.name}/${cluster.label}/${env.name}/values.yaml` +} + +function getDistinctZones(project: ProjectWithDetails) { + const zones = new Set() + project.environments.forEach(e => zones.add(e.cluster.zone.slug)) + return [...zones] +} + +function splitExtraRepositories(extraRepositories: string | undefined): string[] { + if (!extraRepositories) return [] + return extraRepositories.split(',').map(r => r.trim()).filter(r => r.length > 0) +} + +function formatRepositoriesValues( + repositories: ProjectWithDetails['repositories'], + gitlabPublicProjectUrl: string, + envName: string, +) { + return repositories + .filter(repo => repo.isInfra) + .map((repository) => { + const valueFiles = splitExtraRepositories(repository.helmValuesFiles?.replaceAll('', envName)) + return { + id: repository.id, + name: repository.internalRepoName, + repoURL: `${gitlabPublicProjectUrl}/${repository.internalRepoName}.git`, + targetRevision: repository.deployRevision || 'HEAD', + path: repository.deployPath || '.', + valueFiles, + } satisfies ValuesSchema['application']['repositories'][number] + }) +} + +function formatRepositoriesValuesFromDeployments( + deployments: ProjectWithDetails['deployments'][number][], + gitlabPublicProjectUrl: string, + envName: string, +) { + return deployments.flatMap(deployment => + deployment.deploymentSources + .map((source) => { + const valueFiles = splitExtraRepositories(source.helmValuesFiles?.replaceAll('', envName)) + return { + name: source.repository.internalRepoName, + id: source.repository.id, + repoURL: `${gitlabPublicProjectUrl}/${source.repository.internalRepoName}.git`, + targetRevision: source.targetRevision || 'HEAD', + path: source.path || '.', + valueFiles, + } satisfies ValuesSchema['application']['repositories'][number] + }), + ) +} + +function formatEnvironmentValues( + project: ProjectWithDetails, + infraProject: SimpleProjectSchema, + valueFilePath: string, + roGroup: string, + rwGroup: string, +) { + return { + valueFileRepository: infraProject.http_url_to_repo, + valueFileRevision: 'HEAD', + valueFilePath, + roGroup, + rwGroup, + consoleAdminGroup: CONSOLE_ADMIN_GROUP_PATH, + platformAdminGroup: PLATFORM_ADMIN_GROUP_PATH, + platformReadonlyGroup: PLATFORM_READONLY_GROUP_PATH, + platformSecurityGroup: PLATFORM_SECURITY_GROUP_PATH, + projectAdminGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_ADMIN_GROUP_PATH_SUFFIX), + projectDevopsGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_DEVOPS_GROUP_PATH_SUFFIX), + projectDevelopperGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX), + projectSecurityGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_SECURITY_GROUP_PATH_SUFFIX), + projectReadonlyGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_READONLY_GROUP_PATH_SUFFIX), + } satisfies ValuesSchema['environment'] +} + +interface FormatSourceRepositoriesValuesOptions { + gitlabPublicProjectUrl: string + argocdExtraRepositories?: string + projectPlugins?: ProjectWithDetails['plugins'] +} + +function formatSourceRepositoriesValues( + { gitlabPublicProjectUrl, argocdExtraRepositories, projectPlugins }: FormatSourceRepositoriesValuesOptions, +): string[] { + let projectExtraRepositories = '' + if (projectPlugins) { + const argocdPlugin = projectPlugins.find(p => p.pluginName === 'argocd' && p.key === 'extraRepositories') + if (argocdPlugin) projectExtraRepositories = argocdPlugin.value + } + + return [ + `${gitlabPublicProjectUrl}/**`, + ...splitExtraRepositories(argocdExtraRepositories), + ...splitExtraRepositories(projectExtraRepositories), + ] +} + +interface FormatCommonOptions { + project: ProjectWithDetails + environment: ProjectWithDetails['environments'][number] +} + +function formatCommon({ project, environment }: FormatCommonOptions) { + return { + 'dso/project': project.name, + 'dso/project.id': project.id, + 'dso/project.slug': project.slug, + 'dso/environment': environment.name, + 'dso/environment.id': environment.id, + } satisfies ValuesSchema['common'] +} + +interface FormatArgoCDValuesOptions { + namespace: string + project: string + envChartVersion: string + nsChartVersion: string +} + +function formatArgoCDValues(options: FormatArgoCDValuesOptions) { + const { namespace, project, envChartVersion, nsChartVersion } = options + return { + cluster: inClusterLabel, + namespace, + project, + envChartVersion, + nsChartVersion, + } satisfies ValuesSchema['argocd'] +} + +interface FormatValuesOptions { + project: ProjectWithDetails + environment: ProjectWithDetails['environments'][number] + cluster: ProjectWithDetails['environments'][number]['cluster'] + gitlabPublicProjectUrl: string + argocdExtraRepositories?: string + vaultValues: Record + infraProject: SimpleProjectSchema + valueFilePath: string + argoNamespace: string + envChartVersion: string + nsChartVersion: string + deployments?: ProjectWithDetails['deployments'][number][] +} + +function formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories, + vaultValues, + infraProject, + valueFilePath, + argoNamespace, + envChartVersion, + nsChartVersion, + deployments, +}: FormatValuesOptions) { + return { + common: formatCommon({ project, environment }), + argocd: formatArgoCDValues({ + namespace: argoNamespace, + project: formatAppProjectName(project.slug, environment.name), + envChartVersion, + nsChartVersion, + }), + environment: formatEnvironmentValues( + project, + infraProject, + valueFilePath, + formatReadOnlyGroupName(project.slug, environment.name), + formatReadWriteGroupName(project.slug, environment.name), + ), + application: { + quota: { + cpu: environment.cpu, + gpu: environment.gpu, + memory: `${environment.memory}Gi`, + }, + sourceRepositories: formatSourceRepositoriesValues({ + gitlabPublicProjectUrl, + argocdExtraRepositories, + projectPlugins: project.plugins, + }), + destination: { + namespace: generateNamespaceName(project.id, environment.id), + name: cluster.label, + }, + autosync: environment.autosync, + vault: vaultValues, + repositories: deployments + ? formatRepositoriesValuesFromDeployments(deployments, gitlabPublicProjectUrl, environment.name) + : formatRepositoriesValues( + project.repositories, + gitlabPublicProjectUrl, + environment.name, + ), + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + } satisfies ValuesSchema +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/configurable-feature-module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/configurable-feature-module.ts new file mode 100644 index 0000000000..85b2418316 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/configurable-feature-module.ts @@ -0,0 +1,78 @@ +import type { DynamicModule } from '@nestjs/common' +import { ConfigurableModuleBuilder } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' + +// Minimal shape of the generated configurable class, so consumers (e.g. `XModule.forRoot(...)`) +// see the `forRoot` static method. The arg is `TOptions` (plain object, for tests) OR a +// `ConfigFactory` (e.g. `registerAs('', ...)`), whose `.asProvider()` is wired by +// `forRoot` into this module's options token. +export interface ConfigurableCls { + new(...args: any[]): any + forRoot: (arg: TOptions | any) => DynamicModule +} + +// Builds a configurable module that OWNS its config contract (interface embedded in-module, +// no ../../config / registerAs dependency inside the module). The module is wired from the +// outside via `XModule.forRoot(xxxConfigFactory)`, where `xxxConfigFactory` is the +// `registerAs('', ...)` result from `config/*.config.ts`. `` MUST equal +// `token` so `ConfigModule.forFeature` registers it under `CONFIGURATION()`. +// `isGlobal` makes the module (and its config token) available app-wide via @Global(). +// ponytail: native forRoot only takes a plain options object; we wrap it so it also accepts +// a `ConfigFactory` and wires it (via ConfigModule.forFeature + a provider under the token). +function isConfigFactory(arg: unknown): boolean { + return typeof arg === 'function' && 'asProvider' in (arg as any) + && typeof (arg as any).asProvider === 'function' +} + +function isConfigProvider(arg: unknown): arg is { provide: unknown, useFactory?: unknown, useValue?: unknown } { + return Boolean(arg) && typeof arg === 'object' + && 'provide' in (arg as Record) + && ('useFactory' in (arg as Record) || 'useValue' in (arg as Record)) +} + +export function createConfigurableModule(token: string, isGlobal = false) { + const { ConfigurableModuleClass } = new ConfigurableModuleBuilder({ optionsInjectionToken: token }) + .setClassMethodName('forRoot') + .setFactoryMethodName('useFactory') + .setExtras( + { isGlobal }, + (definition: DynamicModule, extras) => ({ + ...definition, + exports: [...(definition.exports ?? []), token], + global: extras.isGlobal, + }), + ) + .build() + + class WrappedModuleClass extends (ConfigurableModuleClass as any) { + static forRoot(arg: any): DynamicModule { + const def = (super.forRoot as (o: unknown) => DynamicModule)({}) as DynamicModule + if (isConfigFactory(arg)) { + const provider = (arg as any).asProvider() + return { + ...def, + imports: [(ConfigModule as any).forFeature(arg), ...(def.imports ?? [])], + providers: [ + ...(def.providers ?? []), + { provide: token, useFactory: provider.useFactory, inject: provider.inject }, + ], + exports: [...(def.exports ?? []), token], + } + } + if (isConfigProvider(arg)) { + const provider = arg as any + return { + ...def, + providers: [...(def.providers ?? []), provider], + exports: [...(def.exports ?? []), provider.provide], + } + } + return (super.forRoot as (o: unknown) => DynamicModule)(arg) + } + } + + return { + ConfigurableModuleClass: WrappedModuleClass as unknown as ConfigurableCls, + MODULE_OPTIONS_TOKEN: token, + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts new file mode 100644 index 0000000000..01267db740 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts @@ -0,0 +1,155 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' + +const mockDeployment = { + name: 'dep1', + id: 'dep1', + projectId: 'id', + memory: 3, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: 'envId', +} + +describe('deploymentDatastoreService', () => { + let module: TestingModule + let service: DeploymentDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + DeploymentDatastoreService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(DeploymentDatastoreService) + }) + + describe('getDeploymentById', () => { + it('should return a deployment with sources and repository', async () => { + const deployment = { ...mockDeployment } + + prisma.deployment.findUnique.mockResolvedValue(deployment) + + const result = await service.getDeploymentById('dep1') + + expect(prisma.deployment.findUnique).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + }) + expect(result).toEqual(deployment) + }) + }) + + describe('getDeploymentsByProjectId', () => { + it('should return deployments for a project', async () => { + const deployments = [{ ...mockDeployment }] + + prisma.deployment.findMany.mockResolvedValue(deployments) + + const result = await service.getDeploymentsByProjectId('project1') + + expect(prisma.deployment.findMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }) + expect(result).toEqual(deployments) + }) + }) + + describe('createDeployment', () => { + it('should create a deployment', async () => { + const created = { + ...mockDeployment, + name: 'test', + } + + const data = { + ...created, + project: { connect: { id: 'id' } }, + environment: { connect: { id: 'envId' } }, + } + + prisma.deployment.create.mockResolvedValue(data) + + const result = await service.createDeployment({ + ...data, + }) + + expect(prisma.deployment.create).toHaveBeenCalledWith({ data }) + expect(result).toEqual(data) + }) + }) + + describe('updateDeployment', () => { + it('should update a deployment', async () => { + const updated = { ...mockDeployment, name: 'updated' } + + prisma.deployment.update.mockResolvedValue(updated) + + const result = await service.updateDeployment('dep1', { + ...mockDeployment, + name: 'updated', + }) + + expect(prisma.deployment.update).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + data: { ...mockDeployment, name: 'updated' }, + }) + expect(result).toEqual(updated) + }) + }) + + describe('deleteDeployment', () => { + it('should delete a deployment', async () => { + const deleted = { ...mockDeployment } + + prisma.deployment.delete.mockResolvedValue(deleted) + + const result = await service.deleteDeployment('dep1') + + expect(prisma.deployment.delete).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + }) + expect(result).toEqual(deleted) + }) + }) + + describe('deleteAllDeploymentsByProjectId', () => { + it('should delete all deployments for a project', async () => { + const response = { count: 3 } + + prisma.deployment.deleteMany.mockResolvedValue(response) + + const result = await service.deleteAllDeploymentsByProjectId('project1') + + expect(prisma.deployment.deleteMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + }) + expect(result).toEqual(response) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts new file mode 100644 index 0000000000..859a299494 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts @@ -0,0 +1,56 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +@Injectable() +export class DeploymentDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + getDeploymentById(deploymentId: string) { + return this.prisma.deployment.findUnique({ + where: { id: deploymentId }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + }) + } + + getDeploymentsByProjectId(projectId: string) { + return this.prisma.deployment.findMany({ + where: { projectId }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }) + } + + createDeployment(data: Prisma.DeploymentCreateInput) { + return this.prisma.deployment.create({ data }) + } + + updateDeployment(deploymentId: string, data: Prisma.DeploymentUpdateInput) { + return this.prisma.deployment.update({ + where: { id: deploymentId }, + data, + }) + } + + deleteDeployment(deploymentId: string) { + return this.prisma.deployment.delete({ + where: { id: deploymentId }, + }) + } + + deleteAllDeploymentsByProjectId(projectId: string) { + return this.prisma.deployment.deleteMany({ + where: { projectId }, + }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts new file mode 100644 index 0000000000..20f7e249a3 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts @@ -0,0 +1,85 @@ +import type { Deployment, DeploymentSource, Environment, Prisma, Repository } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeDeployment(overrides: Partial = {}): Deployment { + return { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + autosync: true, + environmentId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } +} + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + projectId: faker.string.uuid(), + memory: 2, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export function makeRepository(overrides: Partial = {}): Repository { + return { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: faker.string.alphanumeric(8).toLowerCase(), + externalRepoUrl: '', + externalUserName: '', + isInfra: false, + isPrivate: false, + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } +} + +type DeploymentSourceWithRepository = DeploymentSource & { repository: Repository } + +export function makeDeploymentSource(overrides: Partial = {}): DeploymentSourceWithRepository { + const repositoryId = overrides.repositoryId ?? faker.string.uuid() + return { + id: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + deploymentId: faker.string.uuid(), + repositoryId, + type: 'git', + targetRevision: 'main', + path: '/app', + helmValuesFiles: '', + repository: makeRepository({ id: repositoryId }), + ...overrides, + } +} + +export type DeploymentWithRelations = Prisma.DeploymentGetPayload<{ + include: { + environment: true + deploymentSources: { include: { repository: true } } + } +}> + +export function makeDeploymentWithRelations(overrides: Partial = {}): DeploymentWithRelations { + const base = makeDeployment(overrides) + return { + ...base, + environment: overrides.environment ?? makeEnvironment({ id: base.environmentId, projectId: base.projectId }), + deploymentSources: overrides.deploymentSources ?? [makeDeploymentSource({ deploymentId: base.id })], + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts new file mode 100644 index 0000000000..53f28c259f --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts @@ -0,0 +1,139 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { makeDeployment, makeDeploymentWithRelations } from './deployment-testing.utils' +import { DeploymentController } from './deployment.controller' +import { DeploymentService } from './deployment.service' + +describe('deploymentController', () => { + let module: TestingModule + let controller: DeploymentController + let service: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + + const project: ProjectContext = { id: projectId, slug: 'my-project' } + const user: UserContext = { userId } + const request = { id: requestId } as FastifyRequest + + const validCreateDeployment = { + name: 'dev', + projectId, + environmentId: '22222222-2222-2222-2222-222222222222', + autosync: true, + deploymentSources: [ + { + repositoryId: '33333333-3333-3333-3333-333333333333', + type: 'git', + targetRevision: 'main', + path: '/app', + }, + ], + } satisfies CreateDeployment + + const validUpdateDeployment = { + ...validCreateDeployment, + deploymentSources: [ + { + id: '44444444-4444-4444-4444-444444444444', + repositoryId: '33333333-3333-3333-3333-333333333333', + type: 'git', + targetRevision: 'develop', + path: '/updated-app', + }, + ], + } satisfies UpdateDeployment + + beforeEach(async () => { + service = mockDeep() + + module = await Test.createTestingModule({ + controllers: [DeploymentController], + providers: [ + { provide: DeploymentService, useValue: service }, + ], + }) + .overrideGuard(ProjectGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(DeploymentController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call deploymentService.listByProjectId with projectId', async () => { + const expectedResult = [makeDeploymentWithRelations({ projectId })] + + service.listByProjectId.mockResolvedValue(expectedResult) + + const result = await controller.list(project) + + expect(service.listByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('create', () => { + it('should validate body and call deploymentService.createDeployment', async () => { + const expectedResult = makeDeployment() + + service.createDeployment.mockResolvedValue(expectedResult) + + const result = await controller.create(validCreateDeployment, project, user, request) + + expect(service.createDeployment).toHaveBeenCalledWith( + projectId, + validCreateDeployment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('update', () => { + it('should validate body and call deploymentService.updateDeployment', async () => { + const deploymentId = '55555555-5555-5555-5555-555555555555' + const expectedResult = makeDeployment({ id: deploymentId }) + + service.updateDeployment.mockResolvedValue(expectedResult) + + const result = await controller.update(deploymentId, validUpdateDeployment, project, user, request) + + expect(service.updateDeployment).toHaveBeenCalledWith( + projectId, + deploymentId, + validUpdateDeployment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('delete', () => { + it('should call deploymentService.deleteDeployment with deploymentId', async () => { + const deploymentId = '55555555-5555-5555-5555-555555555555' + + service.deleteDeployment.mockResolvedValue(undefined) + + const result = await controller.delete(deploymentId, project, user, request) + + expect(service.deleteDeployment).toHaveBeenCalledWith(projectId, deploymentId, userId, requestId) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.controller.ts new file mode 100644 index 0000000000..d3a8706e01 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.controller.ts @@ -0,0 +1,63 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { CreateDeploymentSchema, UpdateDeploymentSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { DeploymentService } from './deployment.service' + +@Controller('api/v1/projects/:projectId/deployments') +@UseGuards(ProjectGuard) +export class DeploymentController { + constructor(@Inject(DeploymentService) private readonly deploymentService: DeploymentService) {} + + @Get('') + @RequireAdminPermission('ListProjects') + @RequireProjectPermission('ListDeployments') + list(@Project() project: ProjectContext) { + return this.deploymentService.listByProjectId(project.id) + } + + @Post('') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.CREATED) + create( + @Body(new ZodValidationPipe(CreateDeploymentSchema)) data: CreateDeployment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.createDeployment(project.id, data, user.userId, request.id) + } + + @Put(':deploymentId') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.OK) + update( + @Param('deploymentId') deploymentId: string, @Body(new ZodValidationPipe(UpdateDeploymentSchema)) + data: UpdateDeployment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.updateDeployment(project.id, deploymentId, data, user.userId, request.id) + } + + @Delete(':deploymentId') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.NO_CONTENT) + delete( + @Param('deploymentId') deploymentId: string, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.deleteDeployment(project.id, deploymentId, user.userId, request.id) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.module.ts new file mode 100644 index 0000000000..cc0f6e1a91 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { ProjectModule } from '../project/project.module' +import { DeploymentDatastoreService } from './deployment-datastore.service' +import { DeploymentController } from './deployment.controller' +import { DeploymentService } from './deployment.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, EventsModule, ProjectModule, ProjectPermissionModule], + controllers: [DeploymentController], + providers: [ + DeploymentDatastoreService, + DeploymentService, + ], +}) +export class DeploymentModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts new file mode 100644 index 0000000000..3b9e19c058 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts @@ -0,0 +1,222 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' +import { makeDeployment, makeDeploymentSource, makeDeploymentWithRelations } from './deployment-testing.utils' +import { DeploymentService } from './deployment.service' + +describe('deploymentService', () => { + let module: TestingModule + let service: DeploymentService + let datastore: DeepMockProxy + let appEvents: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + const deploymentId = '22222222-2222-2222-2222-222222222222' + + const okArgoCDResults = { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } } + + const validCreateDeployment = { + name: 'mydeployment', + projectId, + environmentId: '33333333-3333-3333-3333-333333333333', + autosync: true, + deploymentSources: [ + { + repositoryId: '44444444-4444-4444-4444-444444444444', + type: 'git', + targetRevision: 'main', + path: '/app', + helmValuesFiles: 'values.yaml', + }, + ], + } satisfies CreateDeployment + + const validUpdateDeployment = { + ...validCreateDeployment, + deploymentSources: [ + { + id: '55555555-5555-5555-5555-555555555555', + repositoryId: '44444444-4444-4444-4444-444444444444', + type: 'git', + targetRevision: 'develop', + path: '/updated-app', + helmValuesFiles: 'updated-values.yaml', + }, + ], + } satisfies UpdateDeployment + + beforeEach(async () => { + datastore = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + DeploymentService, + { provide: DeploymentDatastoreService, useValue: datastore }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(DeploymentService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('listByProjectId', () => { + it('should return deployments by projectId', async () => { + const deployments = [makeDeploymentWithRelations({ id: deploymentId, projectId })] + datastore.getDeploymentsByProjectId.mockResolvedValue(deployments) + + const result = await service.listByProjectId(projectId) + + expect(datastore.getDeploymentsByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(deployments) + }) + }) + + describe('createDeployment', () => { + it('should create deployment and trigger a project reconciliation', async () => { + const createdDeployment = makeDeployment({ id: deploymentId, projectId }) + + datastore.createDeployment.mockResolvedValue(createdDeployment) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + const result = await service.createDeployment(projectId, validCreateDeployment, userId, requestId) + + expect(datastore.createDeployment).toHaveBeenCalledWith({ + name: validCreateDeployment.name, + project: { connect: { id: projectId } }, + autosync: validCreateDeployment.autosync, + environment: { connect: { id: validCreateDeployment.environmentId } }, + deploymentSources: { + createMany: { + data: validCreateDeployment.deploymentSources.map(source => ({ + type: source.type, + repositoryId: source.repositoryId, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + })), + }, + }, + }) + + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Create Deployment', userId, requestId }) + expect(result).toEqual(createdDeployment) + }) + + it('should return the deployment even when the reconciliation fails', async () => { + const createdDeployment = makeDeployment({ id: deploymentId, projectId }) + datastore.createDeployment.mockResolvedValue(createdDeployment) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + const result = await service.createDeployment(projectId, validCreateDeployment, userId, requestId) + + expect(result).toEqual(createdDeployment) + }) + }) + + describe('updateDeployment', () => { + it('should update deployment and trigger a project reconciliation', async () => { + const existingDeployment = makeDeploymentWithRelations({ + id: deploymentId, + projectId, + deploymentSources: [ + makeDeploymentSource({ id: '55555555-5555-5555-5555-555555555555', deploymentId }), + makeDeploymentSource({ id: '66666666-6666-6666-6666-666666666666', deploymentId }), + ], + }) + + const updatedDeployment = makeDeployment({ id: deploymentId, projectId }) + + datastore.getDeploymentById.mockResolvedValue(existingDeployment) + datastore.updateDeployment.mockResolvedValue(updatedDeployment) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + const result = await service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId) + + expect(datastore.updateDeployment).toHaveBeenCalledWith( + deploymentId, + expect.objectContaining({ + name: validUpdateDeployment.name, + deploymentSources: { + deleteMany: { + id: { in: ['66666666-6666-6666-6666-666666666666'] }, + }, + upsert: expect.any(Array), + }, + }), + ) + + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Update Deployment', userId, requestId }) + expect(result).toEqual(updatedDeployment) + }) + + it('should throw if deployment does not exist', async () => { + datastore.getDeploymentById.mockResolvedValue(null) + + await expect( + service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId), + ).rejects.toThrow(`Deployment with id ${deploymentId} not found`) + + expect(datastore.updateDeployment).not.toHaveBeenCalled() + }) + + it('should return the deployment even when the reconciliation fails', async () => { + const existingDeployment = makeDeploymentWithRelations({ + id: deploymentId, + projectId, + deploymentSources: [ + makeDeploymentSource({ id: '55555555-5555-5555-5555-555555555555', deploymentId }), + ], + }) + const updatedDeployment = makeDeployment({ id: deploymentId, projectId }) + datastore.getDeploymentById.mockResolvedValue(existingDeployment) + datastore.updateDeployment.mockResolvedValue(updatedDeployment) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + const result = await service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId) + + expect(result).toEqual(updatedDeployment) + }) + }) + + describe('deleteDeployment', () => { + it('should delete deployment and trigger a project reconciliation', async () => { + datastore.deleteDeployment.mockResolvedValue(makeDeployment({ id: deploymentId, projectId })) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + await service.deleteDeployment(projectId, deploymentId, userId, requestId) + + expect(datastore.deleteDeployment).toHaveBeenCalledWith(deploymentId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Delete Deployment', userId, requestId }) + }) + + it('should resolve even when the reconciliation fails', async () => { + datastore.deleteDeployment.mockResolvedValue(makeDeployment({ id: deploymentId, projectId })) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + await expect(service.deleteDeployment(projectId, deploymentId, userId, requestId)).resolves.toBeUndefined() + }) + }) + + describe('deleteAllDeploymentsByProjectId', () => { + it('should delete all deployments and upsert project', async () => { + datastore.deleteAllDeploymentsByProjectId.mockResolvedValue({ count: 1 }) + + await service.deleteAllDeploymentsByProjectId(projectId) + + expect(datastore.deleteAllDeploymentsByProjectId).toHaveBeenCalledWith(projectId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Delete all project deployments' }) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.service.ts new file mode 100644 index 0000000000..beec47e562 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/deployment/deployment.service.ts @@ -0,0 +1,108 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { EventLogAction } from '../events/app-events.service' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' + +@Injectable() +export class DeploymentService { + private readonly logger = new Logger(DeploymentService.name) + + constructor( + @Inject(DeploymentDatastoreService) private readonly deploymentDatastoreService: DeploymentDatastoreService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async listByProjectId(projectId: string) { + return this.deploymentDatastoreService.getDeploymentsByProjectId(projectId) + } + + async createDeployment(projectId: string, deploymentToCreate: CreateDeployment, userId: string, requestId: string) { + const deployment = await this.deploymentDatastoreService.createDeployment({ + name: deploymentToCreate.name, + project: { connect: { id: projectId } }, + autosync: deploymentToCreate.autosync, + environment: { connect: { id: deploymentToCreate.environmentId } }, + deploymentSources: { + createMany: { + data: deploymentToCreate.deploymentSources.map(({ type, repositoryId, targetRevision, path, helmValuesFiles }) => ({ + type, + repositoryId, + targetRevision, + path, + helmValuesFiles, + })), + }, + }, + }) + + this.reconcileProject(projectId, 'Create Deployment', userId, requestId) + return deployment + } + + async updateDeployment(projectId: string, deploymentId: string, deploymentToUpdate: UpdateDeployment, userId: string, requestId: string) { + const existing = await this.deploymentDatastoreService.getDeploymentById(deploymentId) + if (!existing) throw new Error(`Deployment with id ${deploymentId} not found`) + + const incomingDeploymentSourceIds = new Set( + deploymentToUpdate.deploymentSources + .filter(s => s.id) + .map(s => s.id), + ) + + const deploymentSourcesToDelete = existing.deploymentSources.filter( + e => !incomingDeploymentSourceIds.has(e.id), + ) + + const deployment = await this.deploymentDatastoreService.updateDeployment(deploymentId, { + name: deploymentToUpdate.name, + autosync: deploymentToUpdate.autosync, + environment: { connect: { id: deploymentToUpdate.environmentId } }, + deploymentSources: { + deleteMany: { + id: { in: deploymentSourcesToDelete.map(s => s.id) }, + }, + upsert: deploymentToUpdate.deploymentSources.map(source => ({ + where: { id: source.id ?? crypto.randomUUID() }, + update: { + repository: { connect: { id: source.repositoryId } }, + type: source.type, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + }, + create: { + repository: { connect: { id: source.repositoryId } }, + type: source.type, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + }, + })), + }, + }) + this.reconcileProject(projectId, 'Update Deployment', userId, requestId) + return deployment + } + + async deleteDeployment(projectId: string, deploymentId: string, userId: string, requestId: string) { + await this.deploymentDatastoreService.deleteDeployment(deploymentId) + this.reconcileProject(projectId, 'Delete Deployment', userId, requestId) + } + + async deleteAllDeploymentsByProjectId(projectId: string) { + await this.deploymentDatastoreService.deleteAllDeploymentsByProjectId(projectId) + await this.appEvents.emitProjectEvent('project.upsert', projectId, { action: 'Delete all project deployments' }) + } + + /** + * Triggers the project reconciliation without blocking the response: listener + * outcomes (including failures) are persisted in the admin log by AppEventsService. + */ + private reconcileProject(projectId: string, action: EventLogAction, userId: string, requestId: string) { + this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) + .catch((error: unknown) => { + this.logger.error(`project.upsert reconciliation failed (projectId=${projectId})`, error instanceof Error ? error.stack : String(error)) + }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts new file mode 100644 index 0000000000..6189f20316 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts @@ -0,0 +1,181 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeCluster, makeEnvironment, makeStage } from './environment-testing.utils' + +describe('environmentDatastoreService', () => { + let module: TestingModule + let service: EnvironmentDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentDatastoreService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(EnvironmentDatastoreService) + }) + + describe('getProjectEnvironment', () => { + it('should return an environment with its cluster scoped to the project', async () => { + const environment = makeEnvironment({ id: 'env1', projectId: 'project1' }) + prisma.environment.findFirst.mockResolvedValue(environment) + + const result = await service.getProjectEnvironment('project1', 'env1') + + expect(prisma.environment.findFirst).toHaveBeenCalledWith({ + where: { id: 'env1', projectId: 'project1' }, + include: { cluster: true }, + }) + expect(result).toEqual(environment) + }) + }) + + describe('getEnvironmentsByProjectId', () => { + it('should return environments for a project with their stage', async () => { + const environments = [makeEnvironment({ projectId: 'project1' })] + prisma.environment.findMany.mockResolvedValue(environments) + + const result = await service.getEnvironmentsByProjectId('project1') + + expect(prisma.environment.findMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + include: { stage: true }, + }) + expect(result).toEqual(environments) + }) + }) + + describe('getEnvironmentByName', () => { + it('should look the environment up by its project unique name', async () => { + prisma.environment.findUnique.mockResolvedValue(null) + + const result = await service.getEnvironmentByName('project1', 'dev') + + expect(prisma.environment.findUnique).toHaveBeenCalledWith({ + where: { projectId_name: { projectId: 'project1', name: 'dev' } }, + }) + expect(result).toBeNull() + }) + }) + + describe('getStageById', () => { + it('should return the stage', async () => { + const stage = makeStage({ id: 'stage1' }) + prisma.stage.findUnique.mockResolvedValue(stage) + + const result = await service.getStageById('stage1') + + expect(prisma.stage.findUnique).toHaveBeenCalledWith({ where: { id: 'stage1' } }) + expect(result).toEqual(stage) + }) + }) + + describe('getProdStageIds', () => { + it('should return the ids of the prod stages', async () => { + prisma.stage.findMany.mockResolvedValue([makeStage({ id: 'stage1', name: 'prod' })]) + + await service.getProdStageIds() + + expect(prisma.stage.findMany).toHaveBeenCalledWith({ + select: { id: true }, + where: { name: 'prod' }, + }) + }) + }) + + describe('getAvailableCluster', () => { + it('should only match public clusters or dedicated clusters of the project', async () => { + const cluster = makeCluster({ id: 'cluster1' }) + prisma.cluster.findFirst.mockResolvedValue(cluster) + + const result = await service.getAvailableCluster('cluster1', 'project1') + + expect(prisma.cluster.findFirst).toHaveBeenCalledWith({ + where: { + OR: [{ + id: 'cluster1', + privacy: 'public', + }, { + id: 'cluster1', + privacy: 'dedicated', + projects: { some: { id: 'project1' } }, + }], + }, + }) + expect(result).toEqual(cluster) + }) + }) + + describe('sumEnvironmentResources', () => { + it('should aggregate cpu, gpu and memory', async () => { + await service.sumEnvironmentResources({ clusterId: 'cluster1' }) + + expect(prisma.environment.aggregate).toHaveBeenCalledWith({ + _sum: { + cpu: true, + gpu: true, + memory: true, + }, + where: { clusterId: 'cluster1' }, + }) + }) + }) + + describe('createEnvironment', () => { + it('should create an environment', async () => { + const environment = makeEnvironment() + prisma.environment.create.mockResolvedValue(environment) + + const result = await service.createEnvironment({ + projectId: environment.projectId, + name: environment.name, + clusterId: environment.clusterId, + stageId: environment.stageId, + cpu: environment.cpu, + gpu: environment.gpu, + memory: environment.memory, + autosync: environment.autosync, + }) + + expect(prisma.environment.create).toHaveBeenCalled() + expect(result).toEqual(environment) + }) + }) + + describe('updateEnvironment', () => { + it('should update the environment resources', async () => { + const environment = makeEnvironment({ id: 'env1' }) + prisma.environment.update.mockResolvedValue(environment) + + const result = await service.updateEnvironment('env1', { cpu: 2, gpu: 0, memory: 4, autosync: false }) + + expect(prisma.environment.update).toHaveBeenCalledWith({ + where: { id: 'env1' }, + data: { cpu: 2, gpu: 0, memory: 4, autosync: false }, + }) + expect(result).toEqual(environment) + }) + }) + + describe('deleteEnvironment', () => { + it('should delete the environment', async () => { + const environment = makeEnvironment({ id: 'env1' }) + prisma.environment.delete.mockResolvedValue(environment) + + const result = await service.deleteEnvironment('env1') + + expect(prisma.environment.delete).toHaveBeenCalledWith({ where: { id: 'env1' } }) + expect(result).toEqual(environment) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts new file mode 100644 index 0000000000..1dab19eb23 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts @@ -0,0 +1,99 @@ +import type { Cluster, Environment, Prisma, Project, Stage } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PROD_STAGE_NAME } from './environment.constants' + +export type EnvironmentWithCluster = Environment & { cluster: Cluster } +export type EnvironmentWithStage = Environment & { stage: Stage } + +export interface EnvironmentResourcesSum { + _sum: { + cpu: number | null + gpu: number | null + memory: number | null + } +} + +@Injectable() +export class EnvironmentDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + getProjectEnvironment(projectId: string, environmentId: string): Promise { + return this.prisma.environment.findFirst({ + where: { id: environmentId, projectId }, + include: { cluster: true }, + }) + } + + getEnvironmentsByProjectId(projectId: string): Promise { + return this.prisma.environment.findMany({ + where: { projectId }, + include: { stage: true }, + }) + } + + getEnvironmentByName(projectId: string, name: string): Promise { + return this.prisma.environment.findUnique({ + where: { projectId_name: { projectId, name } }, + }) + } + + getStageById(stageId: string): Promise { + return this.prisma.stage.findUnique({ where: { id: stageId } }) + } + + getProdStageIds(): Promise<{ id: string }[]> { + return this.prisma.stage.findMany({ + select: { id: true }, + where: { name: PROD_STAGE_NAME }, + }) + } + + /** Only public clusters or dedicated clusters attached to the project are usable. */ + getAvailableCluster(clusterId: string, projectId: string): Promise { + return this.prisma.cluster.findFirst({ + where: { + OR: [{ + id: clusterId, + privacy: 'public', + }, { + id: clusterId, + privacy: 'dedicated', + projects: { some: { id: projectId } }, + }], + }, + }) + } + + getProjectById(projectId: string): Promise { + return this.prisma.project.findUniqueOrThrow({ where: { id: projectId } }) + } + + sumEnvironmentResources(where: Prisma.EnvironmentWhereInput): Promise { + return this.prisma.environment.aggregate({ + _sum: { + cpu: true, + gpu: true, + memory: true, + }, + where, + }) + } + + createEnvironment(data: Prisma.EnvironmentUncheckedCreateInput): Promise { + return this.prisma.environment.create({ data }) + } + + updateEnvironment(environmentId: string, data: Pick): Promise { + return this.prisma.environment.update({ + where: { id: environmentId }, + data, + }) + } + + deleteEnvironment(environmentId: string): Promise { + return this.prisma.environment.delete({ + where: { id: environmentId }, + }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts new file mode 100644 index 0000000000..5f0d0ee58d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts @@ -0,0 +1,87 @@ +import type { Cluster, Environment, Project, Stage } from '@prisma/client' +import type { EnvironmentWithCluster, EnvironmentWithStage } from './environment-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + projectId: faker.string.uuid(), + memory: 2, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export function makeCluster(overrides: Partial = {}): Cluster { + return { + id: faker.string.uuid(), + label: faker.string.alphanumeric(8).toLowerCase(), + privacy: 'public', + secretName: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + infos: null, + memory: 0, + cpu: 0, + gpu: 0, + zoneId: faker.string.uuid(), + ...overrides, + } +} + +export function makeStage(overrides: Partial = {}): Stage { + return { + id: faker.string.uuid(), + name: 'dev', + ...overrides, + } +} + +export function makeProject(overrides: Partial = {}): Project { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + description: '', + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.string.alphanumeric(8).toLowerCase(), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } +} + +export function makeEnvironmentWithCluster(overrides: Partial = {}): EnvironmentWithCluster { + const base = makeEnvironment(overrides) + return { + ...base, + cluster: overrides.cluster ?? makeCluster({ id: base.clusterId }), + } +} + +export function makeEnvironmentWithStage(overrides: Partial = {}): EnvironmentWithStage { + const base = makeEnvironment(overrides) + return { + ...base, + stage: overrides.stage ?? makeStage({ id: base.stageId }), + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts new file mode 100644 index 0000000000..1933528eb8 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts @@ -0,0 +1,222 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { BadRequestException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeCluster, makeEnvironment, makeEnvironmentWithCluster, makeProject, makeStage } from './environment-testing.utils' +import { EnvironmentValidationService } from './environment-validation.service' + +describe('environmentValidationService', () => { + let module: TestingModule + let service: EnvironmentValidationService + let datastore: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const environmentId = '22222222-2222-2222-2222-222222222222' + const clusterId = '33333333-3333-3333-3333-333333333333' + const stageId = '44444444-4444-4444-4444-444444444444' + + const validCreateEnvironment = { + name: 'dev', + clusterId, + stageId, + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + datastore = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentValidationService, + { provide: EnvironmentDatastoreService, useValue: datastore }, + ], + }).compile() + + service = module.get(EnvironmentValidationService) + }) + + describe('validateCreate', () => { + it('should pass when the stage, name and cluster are valid and no limit applies', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + // cpu/memory at 0 means the cluster resources are not configured + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)).resolves.toBeUndefined() + }) + + it('should reject when the environment name is already taken', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(makeEnvironment({ projectId, name: validCreateEnvironment.name })) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow(BadRequestException) + }) + + it('should reject when the stage does not exist', async () => { + datastore.getStageById.mockResolvedValue(null) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Type d\'environnement invalide.') + }) + + it('should reject when the cluster is not available for the project', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(null) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Cluster invalide.') + }) + + it('should reject when the cluster does not have enough resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 4, gpu: 0, memory: 8 })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le cluster ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ clusterId }) + }) + + it('should reject when the project does not have enough prod resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'prod' })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + prodCpu: 2, + prodGpu: 0, + prodMemory: 4, + })) + datastore.getProdStageIds.mockResolvedValue([{ id: stageId }]) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 1, gpu: 0, memory: 2 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le projet ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + + it('should reject when the project does not have enough non-prod resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'dev' })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + hprodCpu: 2, + hprodGpu: 0, + hprodMemory: 4, + })) + datastore.getProdStageIds.mockResolvedValue([{ id: '55555555-5555-5555-5555-555555555555' }]) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 1, gpu: 0, memory: 2 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le projet ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + }) + + describe('validateUpdate', () => { + it('should pass when the project is limitless and the cluster limits are not configured', async () => { + const environment = makeEnvironmentWithCluster({ + projectId, + cluster: makeCluster({ id: clusterId, cpu: 0, memory: 0 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + }) + + it('should reject when the cluster does not have enough resources', async () => { + const environment = makeEnvironmentWithCluster({ + projectId, + cluster: makeCluster({ id: clusterId, cpu: 4, gpu: 0, memory: 8 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)) + .rejects.toThrow('Le cluster ne dispose pas de suffisamment de ressources : CPU, GPU, Mémoire.') + }) + + it('should not count the environment being updated in the cluster consumed resources', async () => { + const environment = makeEnvironmentWithCluster({ + id: environmentId, + projectId, + cluster: makeCluster({ id: clusterId, cpu: 8, gpu: 2, memory: 16 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + // the sum only covers the other environments of the cluster + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ + clusterId, + id: { not: environmentId }, + }) + }) + + it('should not count the environment being updated in the project consumed resources', async () => { + const environment = makeEnvironmentWithCluster({ + id: environmentId, + projectId, + stageId, + cluster: makeCluster({ id: clusterId, cpu: 0, memory: 0 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + prodCpu: 8, + prodGpu: 2, + prodMemory: 16, + })) + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'prod' })) + datastore.getProdStageIds.mockResolvedValue([{ id: stageId }]) + // the sum only covers the other prod environments of the project + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ + projectId, + stageId: { in: [stageId] }, + id: { not: environmentId }, + }) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-validation.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-validation.service.ts new file mode 100644 index 0000000000..f81672410a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment-validation.service.ts @@ -0,0 +1,152 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Cluster, Prisma, Project } from '@prisma/client' +import type { EnvironmentWithCluster } from './environment-datastore.service' +import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { PROD_STAGE_NAME } from './environment.constants' + +interface EnvironmentResources { + cpu: number + gpu: number + memory: number +} + +/** cpu and memory both at 0 mean the limits were never configured, so quota checks do not apply. */ +function areResourceLimitsConfigured(limit: EnvironmentResources): boolean { + return limit.cpu !== 0 || limit.memory !== 0 +} + +/** + * Business rules for environments: name unicity, stage and cluster validity, + * cluster and project resource quotas. Existence/ownership (404) concerns stay + * in EnvironmentService; this service only decides whether a request is allowed (400). + */ +@Injectable() +export class EnvironmentValidationService { + constructor( + @Inject(EnvironmentDatastoreService) private readonly environmentDatastoreService: EnvironmentDatastoreService, + ) {} + + async validateCreate(projectId: string, input: CreateEnvironment): Promise { + const errorMessages: string[] = [] + const [stage, sameNameEnvironment, cluster] = await Promise.all([ + this.environmentDatastoreService.getStageById(input.stageId), + this.environmentDatastoreService.getEnvironmentByName(projectId, input.name), + this.environmentDatastoreService.getAvailableCluster(input.clusterId, projectId), + ]) + + if (sameNameEnvironment) errorMessages.push('Ce nom d\'environnement est déjà pris.') + if (!stage) errorMessages.push('Type d\'environnement invalide.') + if (cluster) { + const clusterError = await this.checkClusterResources(input, cluster) + if (clusterError) errorMessages.push(clusterError) + + const project = await this.environmentDatastoreService.getProjectById(projectId) + const projectError = await this.checkProjectResources(input, input.stageId, project) + if (projectError) errorMessages.push(projectError) + } else { + errorMessages.push('Cluster invalide.') + } + + if (errorMessages.length > 0) { + throw new BadRequestException(errorMessages.join('\n')) + } + } + + async validateUpdate(environment: EnvironmentWithCluster, input: UpdateEnvironment): Promise { + const errorMessages: string[] = [] + const clusterError = await this.checkClusterResources(input, environment.cluster, environment.id) + if (clusterError) errorMessages.push(clusterError) + + const project = await this.environmentDatastoreService.getProjectById(environment.projectId) + const projectError = await this.checkProjectResources(input, environment.stageId, project, environment.id) + if (projectError) errorMessages.push(projectError) + + if (errorMessages.length > 0) { + throw new BadRequestException(errorMessages.join('\n')) + } + } + + private async checkClusterResources(request: EnvironmentResources, cluster: Cluster, excludedEnvironmentId?: string): Promise { + const overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: cluster.cpu, gpu: cluster.gpu, memory: cluster.memory }, + where: { clusterId: cluster.id }, + excludedEnvironmentId, + }) + if (overflowResources.length > 0) { + return `Le cluster ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.` + } + return undefined + } + + private async checkProjectResources(request: EnvironmentResources, stageId: string, project: Project, excludedEnvironmentId?: string): Promise { + if (project.limitless) { + // No limits + return undefined + } + const [stage, prodStages] = await Promise.all([ + this.environmentDatastoreService.getStageById(stageId), + this.environmentDatastoreService.getProdStageIds(), + ]) + const prodStageIds = prodStages.map(s => s.id) + + let overflowResources: string[] + if (stage?.name === PROD_STAGE_NAME) { + overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: project.prodCpu, gpu: project.prodGpu, memory: project.prodMemory }, + where: { + projectId: project.id, + stageId: { in: prodStageIds }, + }, + excludedEnvironmentId, + }) + } else { // hprod + overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: project.hprodCpu, gpu: project.hprodGpu, memory: project.hprodMemory }, + where: { + projectId: project.id, + stageId: { notIn: prodStageIds }, + }, + excludedEnvironmentId, + }) + } + if (overflowResources.length > 0) { + return `Le projet ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.` + } + return undefined + } + + /** + * On update, the environment being updated must be excluded from the consumed-resources + * sum: its requested resources replace its current ones. Counting both would reject + * legitimate updates within the quota (#1911). + */ + private async getOverflowResources({ request, limit, where, excludedEnvironmentId }: { + request: EnvironmentResources + limit: EnvironmentResources + where: Prisma.EnvironmentWhereInput + excludedEnvironmentId?: string + }): Promise { + if (!areResourceLimitsConfigured(limit)) { + return [] + } + const environmentResources = await this.environmentDatastoreService.sumEnvironmentResources( + excludedEnvironmentId === undefined ? where : { ...where, id: { not: excludedEnvironmentId } }, + ) + // A null sum means no environment matched: nothing is consumed yet. + const insufficientResources: string[] = [] + if ((environmentResources._sum.cpu ?? 0) + request.cpu > limit.cpu) { + insufficientResources.push('CPU') + } + if ((environmentResources._sum.gpu ?? 0) + request.gpu > limit.gpu) { + insufficientResources.push('GPU') + } + if ((environmentResources._sum.memory ?? 0) + request.memory > limit.memory) { + insufficientResources.push('Mémoire') + } + return insufficientResources + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.constants.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.constants.ts new file mode 100644 index 0000000000..b55c418847 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.constants.ts @@ -0,0 +1,2 @@ +/** Name of the stage whose environments consume the project's production resource quotas. */ +export const PROD_STAGE_NAME = 'prod' diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts new file mode 100644 index 0000000000..1449e91a3a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts @@ -0,0 +1,128 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { makeEnvironment, makeEnvironmentWithStage } from './environment-testing.utils' +import { EnvironmentController } from './environment.controller' +import { EnvironmentService } from './environment.service' + +describe('environmentController', () => { + let module: TestingModule + let controller: EnvironmentController + let service: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + + const project: ProjectContext = { id: projectId, slug: 'my-project' } + const user: UserContext = { userId } + const request = { id: requestId } as FastifyRequest + + const validCreateEnvironment = { + name: 'dev', + clusterId: '22222222-2222-2222-2222-222222222222', + stageId: '33333333-3333-3333-3333-333333333333', + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + service = mockDeep() + + module = await Test.createTestingModule({ + controllers: [EnvironmentController], + providers: [ + { provide: EnvironmentService, useValue: service }, + ], + }) + .overrideGuard(ProjectGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(EnvironmentController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call environmentService.listByProjectId with projectId', async () => { + const expectedResult = [makeEnvironmentWithStage({ projectId })] + + service.listByProjectId.mockResolvedValue(expectedResult) + + const result = await controller.list(project) + + expect(service.listByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('create', () => { + it('should validate body and call environmentService.createEnvironment', async () => { + const expectedResult = makeEnvironment({ projectId }) + + service.createEnvironment.mockResolvedValue(expectedResult) + + const result = await controller.create(validCreateEnvironment, project, user, request) + + expect(service.createEnvironment).toHaveBeenCalledWith( + projectId, + validCreateEnvironment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('update', () => { + it('should validate body and call environmentService.updateEnvironment', async () => { + const environmentId = '44444444-4444-4444-4444-444444444444' + const expectedResult = makeEnvironment({ id: environmentId, projectId }) + + service.updateEnvironment.mockResolvedValue(expectedResult) + + const result = await controller.update(environmentId, validUpdateEnvironment, project, user, request) + + expect(service.updateEnvironment).toHaveBeenCalledWith( + projectId, + environmentId, + validUpdateEnvironment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('delete', () => { + it('should call environmentService.deleteEnvironment with environmentId', async () => { + const environmentId = '44444444-4444-4444-4444-444444444444' + + service.deleteEnvironment.mockResolvedValue(undefined) + + const result = await controller.delete(environmentId, project, user, request) + + expect(service.deleteEnvironment).toHaveBeenCalledWith(projectId, environmentId, userId, requestId) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.controller.ts new file mode 100644 index 0000000000..7589f241f8 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.controller.ts @@ -0,0 +1,73 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Environment } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { EnvironmentWithStage } from './environment-datastore.service' +import { CreateEnvironmentSchema, UpdateEnvironmentSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { EnvironmentService } from './environment.service' + +@Controller('api/v2/projects/:projectId/environments') +@UseGuards(ProjectGuard) +export class EnvironmentController { + constructor(@Inject(EnvironmentService) private readonly environmentService: EnvironmentService) {} + + @Get('') + @RequireAdminPermission('ListProjects') + @RequireProjectPermission('ListEnvironments') + list(@Project() project: ProjectContext): Promise { + return this.environmentService.listByProjectId(project.id) + } + + @Post('') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.CREATED) + create( + @Body(new ZodValidationPipe(CreateEnvironmentSchema)) data: CreateEnvironment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.createEnvironment(project.id, data, user.userId, request.id) + } + + @Put(':environmentId') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.OK) + update( + @Param('environmentId', ParseUUIDPipe) environmentId: string, + @Body(new ZodValidationPipe(UpdateEnvironmentSchema)) data: UpdateEnvironment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.updateEnvironment(project.id, environmentId, data, user.userId, request.id) + } + + @Delete(':environmentId') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.NO_CONTENT) + delete( + @Param('environmentId', ParseUUIDPipe) environmentId: string, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.deleteEnvironment(project.id, environmentId, user.userId, request.id) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.module.ts new file mode 100644 index 0000000000..a9c4f597d4 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { EnvironmentValidationService } from './environment-validation.service' +import { EnvironmentController } from './environment.controller' +import { EnvironmentService } from './environment.service' + +@Module({ + imports: [AppEventsModule, InfrastructureModule], + controllers: [EnvironmentController], + providers: [ + EnvironmentDatastoreService, + EnvironmentValidationService, + EnvironmentService, + ], +}) +export class EnvironmentModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.service.spec.ts new file mode 100644 index 0000000000..d9baf3db40 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.service.spec.ts @@ -0,0 +1,183 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeEnvironment, makeEnvironmentWithCluster, makeEnvironmentWithStage } from './environment-testing.utils' +import { EnvironmentValidationService } from './environment-validation.service' +import { EnvironmentService } from './environment.service' + +describe('environmentService', () => { + let module: TestingModule + let service: EnvironmentService + let datastore: DeepMockProxy + let validation: DeepMockProxy + let appEvents: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + const environmentId = '22222222-2222-2222-2222-222222222222' + const clusterId = '33333333-3333-3333-3333-333333333333' + const stageId = '44444444-4444-4444-4444-444444444444' + + const validCreateEnvironment = { + name: 'dev', + clusterId, + stageId, + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + datastore = mockDeep() + validation = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentService, + { provide: EnvironmentDatastoreService, useValue: datastore }, + { provide: EnvironmentValidationService, useValue: validation }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(EnvironmentService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('listByProjectId', () => { + it('should return environments by projectId', async () => { + const environments = [makeEnvironmentWithStage({ id: environmentId, projectId })] + datastore.getEnvironmentsByProjectId.mockResolvedValue(environments) + + const result = await service.listByProjectId(projectId) + + expect(datastore.getEnvironmentsByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(environments) + }) + }) + + describe('createEnvironment', () => { + it('should validate, create the environment and trigger the project reconciliation', async () => { + const environment = makeEnvironment({ id: environmentId, projectId, clusterId, stageId }) + validation.validateCreate.mockResolvedValue(undefined) + datastore.createEnvironment.mockResolvedValue(environment) + appEvents.emitProjectEvent.mockResolvedValue({}) + + const result = await service.createEnvironment(projectId, validCreateEnvironment, userId, requestId) + + expect(validation.validateCreate).toHaveBeenCalledWith(projectId, validCreateEnvironment) + expect(datastore.createEnvironment).toHaveBeenCalledWith({ + projectId, + name: validCreateEnvironment.name, + clusterId, + stageId, + cpu: validCreateEnvironment.cpu, + gpu: validCreateEnvironment.gpu, + memory: validCreateEnvironment.memory, + autosync: validCreateEnvironment.autosync, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Create Environment', + userId, + requestId, + }) + expect(result).toEqual(environment) + }) + + it('should not create the environment when the validation fails', async () => { + validation.validateCreate.mockRejectedValue(new BadRequestException('Cluster invalide.')) + + await expect(service.createEnvironment(projectId, validCreateEnvironment, userId, requestId)) + .rejects.toThrow(BadRequestException) + expect(datastore.createEnvironment).not.toHaveBeenCalled() + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) + }) + + describe('updateEnvironment', () => { + it('should validate, update the environment and trigger the project reconciliation', async () => { + const existing = makeEnvironmentWithCluster({ id: environmentId, projectId }) + const updated = makeEnvironment({ id: environmentId, projectId, ...validUpdateEnvironment }) + datastore.getProjectEnvironment.mockResolvedValue(existing) + validation.validateUpdate.mockResolvedValue(undefined) + datastore.updateEnvironment.mockResolvedValue(updated) + appEvents.emitProjectEvent.mockResolvedValue({}) + + const result = await service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId) + + expect(validation.validateUpdate).toHaveBeenCalledWith(existing, validUpdateEnvironment) + expect(datastore.updateEnvironment).toHaveBeenCalledWith(environmentId, validUpdateEnvironment) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Update Environment', + userId, + requestId, + }) + expect(result).toEqual(updated) + }) + + it('should reject when the environment is not found in the project', async () => { + datastore.getProjectEnvironment.mockResolvedValue(null) + + await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) + .rejects.toThrow(NotFoundException) + expect(datastore.getProjectEnvironment).toHaveBeenCalledWith(projectId, environmentId) + expect(validation.validateUpdate).not.toHaveBeenCalled() + expect(datastore.updateEnvironment).not.toHaveBeenCalled() + }) + + it('should not update the environment when the validation fails', async () => { + datastore.getProjectEnvironment.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId, projectId })) + validation.validateUpdate.mockRejectedValue(new BadRequestException('Le cluster ne dispose pas de suffisamment de ressources : CPU.')) + + await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) + .rejects.toThrow(BadRequestException) + expect(datastore.updateEnvironment).not.toHaveBeenCalled() + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) + }) + + describe('deleteEnvironment', () => { + it('should delete the environment and trigger the project reconciliation', async () => { + datastore.getProjectEnvironment.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId, projectId })) + datastore.deleteEnvironment.mockResolvedValue(makeEnvironment({ id: environmentId, projectId })) + appEvents.emitProjectEvent.mockResolvedValue({}) + + await service.deleteEnvironment(projectId, environmentId, userId, requestId) + + expect(datastore.deleteEnvironment).toHaveBeenCalledWith(environmentId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Delete Environment', + userId, + requestId, + }) + }) + + it('should reject when the environment is not found in the project', async () => { + datastore.getProjectEnvironment.mockResolvedValue(null) + + await expect(service.deleteEnvironment(projectId, environmentId, userId, requestId)) + .rejects.toThrow(NotFoundException) + expect(datastore.getProjectEnvironment).toHaveBeenCalledWith(projectId, environmentId) + expect(datastore.deleteEnvironment).not.toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.service.ts new file mode 100644 index 0000000000..d181c84966 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/environment/environment.service.ts @@ -0,0 +1,81 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Environment } from '@prisma/client' +import type { EventLogAction } from '../events/app-events.service' +import type { EnvironmentWithCluster, EnvironmentWithStage } from './environment-datastore.service' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { EnvironmentValidationService } from './environment-validation.service' + +@Injectable() +export class EnvironmentService { + private readonly logger = new Logger(EnvironmentService.name) + + constructor( + @Inject(EnvironmentDatastoreService) private readonly environmentDatastoreService: EnvironmentDatastoreService, + @Inject(EnvironmentValidationService) private readonly environmentValidationService: EnvironmentValidationService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async listByProjectId(projectId: string): Promise { + return this.environmentDatastoreService.getEnvironmentsByProjectId(projectId) + } + + async createEnvironment(projectId: string, environmentToCreate: CreateEnvironment, userId: string, requestId: string): Promise { + await this.environmentValidationService.validateCreate(projectId, environmentToCreate) + + const environment = await this.environmentDatastoreService.createEnvironment({ + projectId, + name: environmentToCreate.name, + clusterId: environmentToCreate.clusterId, + stageId: environmentToCreate.stageId, + cpu: environmentToCreate.cpu, + gpu: environmentToCreate.gpu, + memory: environmentToCreate.memory, + autosync: environmentToCreate.autosync, + }) + + this.reconcileProject(projectId, 'Create Environment', userId, requestId) + return environment + } + + async updateEnvironment(projectId: string, environmentId: string, environmentToUpdate: UpdateEnvironment, userId: string, requestId: string): Promise { + const existingEnvironment = await this.getProjectEnvironmentOrThrow(projectId, environmentId) + await this.environmentValidationService.validateUpdate(existingEnvironment, environmentToUpdate) + + const environment = await this.environmentDatastoreService.updateEnvironment(environmentId, { + cpu: environmentToUpdate.cpu, + gpu: environmentToUpdate.gpu, + memory: environmentToUpdate.memory, + autosync: environmentToUpdate.autosync, + }) + + this.reconcileProject(projectId, 'Update Environment', userId, requestId) + return environment + } + + async deleteEnvironment(projectId: string, environmentId: string, userId: string, requestId: string): Promise { + await this.getProjectEnvironmentOrThrow(projectId, environmentId) + await this.environmentDatastoreService.deleteEnvironment(environmentId) + this.reconcileProject(projectId, 'Delete Environment', userId, requestId) + } + + private async getProjectEnvironmentOrThrow(projectId: string, environmentId: string): Promise { + const environment = await this.environmentDatastoreService.getProjectEnvironment(projectId, environmentId) + if (!environment) { + throw new NotFoundException('Environnement introuvable') + } + return environment + } + + /** + * Triggers the project reconciliation without blocking the response: listener + * outcomes (including failures) are persisted in the admin log by AppEventsService. + */ + private reconcileProject(projectId: string, action: EventLogAction, userId: string, requestId: string): void { + this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) + .catch((error: unknown) => { + this.logger.error(`project.upsert reconciliation failed (projectId=${projectId})`, error instanceof Error ? error.stack : String(error)) + }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.module.ts new file mode 100644 index 0000000000..08130cfc7b --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { LogModule } from '../log/log.module' +import { AppEventsService } from './app-events.service' + +@Module({ + imports: [InfrastructureModule, LogModule], + providers: [AppEventsService], + exports: [AppEventsService], +}) +export class AppEventsModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.service.spec.ts new file mode 100644 index 0000000000..7bd6fa2ea2 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.service.spec.ts @@ -0,0 +1,178 @@ +import type { EventEmitter2 as EventEmitter2Type } from '@nestjs/event-emitter' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { projectSelect } from '../project/project-queries.utils' +import { makeProject } from '../project/project-testing.utils' +import { AppEventsService } from './app-events.service' + +describe('appEventsService', () => { + let module: TestingModule + let service: AppEventsService + let prisma: DeepMockProxy + let eventEmitter: DeepMockProxy + let logs: DeepMockProxy + let config: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + eventEmitter = mockDeep({ emitAsync: vi.fn().mockResolvedValue([]) }) + logs = mockDeep() + config = mockDeep({ appVersion: 'test-version' }) + + module = await Test.createTestingModule({ + providers: [ + AppEventsService, + { provide: PrismaService, useValue: prisma }, + { provide: EventEmitter2, useValue: eventEmitter }, + { provide: LogService, useValue: logs }, + { provide: BASE_CONFIG, useValue: config }, + ], + }).compile() + + service = module.get(AppEventsService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('loads the project by id, emits and logs the merged listener results', async () => { + const project = makeProject() + prisma.project.findUnique.mockResolvedValue(project) + eventEmitter.emitAsync.mockResolvedValue([ + undefined, // listener not yet migrated to capturePluginResult + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + { argocd: { status: 'KO', message: 'Sync failed', executionTime: 20, error: new Error('Sync failed') } }, + ]) + + const results = await service.emitProjectEvent('project.upsert', project.id, { + action: 'Create Project', + userId: 'user-id', + requestId: 'request-id', + }) + + expect(prisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: project.id }, + select: projectSelect, + }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', project) + expect(results).toEqual({ + gitlab: expect.objectContaining({ status: 'OK' }), + argocd: expect.objectContaining({ status: 'KO' }), + }) + expect(logs.addLog).toHaveBeenCalledWith({ + action: 'Create Project', + userId: 'user-id', + requestId: 'request-id', + projectId: project.id, + data: { + args: project, + failed: ['argocd'], + results: { + gitlab: { + status: { result: 'OK', message: 'Up to date' }, + executionTime: { main: 10 }, + }, + argocd: { + status: { result: 'KO', message: 'Sync failed' }, + executionTime: { main: 20 }, + error: expect.stringContaining('"message":"Sync failed"'), + }, + }, + totalExecutionTime: expect.any(Number), + messageResume: 'Errors:\nargocd: Sync failed;', + }, + }) + }) + + it('marks the project as failed when a listener reports a KO result', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { argocd: { status: 'KO', message: 'Sync failed', executionTime: 20, error: new Error('Sync failed') } }, + ]) + + await service.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: project.id }, + data: { status: 'failed' }, + }) + }) + + it('marks the project as created with the provisioning version after a successful upsert', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + ]) + + await service.emitProjectEvent('project.upsert', project, { action: 'Create Project' }) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: project.id }, + data: { status: 'created', lastSuccessProvisionningVersion: 'test-version' }, + }) + }) + + it('does not touch the project status after a successful delete', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + ]) + + await service.emitProjectEvent('project.delete', project, { action: 'Delete all project resources' }) + + expect(prisma.project.update).not.toHaveBeenCalled() + }) + + it('uses a provided project snapshot without fetching it again', async () => { + const project = makeProject() + + await service.emitProjectEvent('project.delete', project, { action: 'Delete all project resources' }) + + expect(prisma.project.findUnique).not.toHaveBeenCalled() + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.delete', project) + }) + + it('defaults the log userId and requestId to null', async () => { + const project = makeProject() + + await service.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Update Project', + userId: null, + requestId: null, + })) + }) + + it('skips emitting and logging when the project cannot be found', async () => { + prisma.project.findUnique.mockResolvedValue(null) + + const results = await service.emitProjectEvent('project.upsert', 'missing-id', { action: 'Update Project' }) + + expect(results).toEqual({}) + expect(eventEmitter.emitAsync).not.toHaveBeenCalled() + expect(logs.addLog).not.toHaveBeenCalled() + }) + + it('emits and logs project member events with their payload as args', async () => { + const payload = { projectId: 'project-id', userId: 'user-id' } + + await service.emitProjectMemberEvent('projectMember.upsert', payload, { action: 'Add Project Member' }) + + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.upsert', payload) + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Add Project Member', + projectId: 'project-id', + data: expect.objectContaining({ args: payload }), + })) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.service.ts new file mode 100644 index 0000000000..7de8bcf562 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -0,0 +1,138 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { PluginResults } from '../plugin/plugin.utils' +import type { ProjectWithDetails } from '../project/project-queries.utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { getFailedPlugins, mergePluginResults } from '../plugin/plugin.utils' +import { getProject } from '../project/project-queries.utils' +import { formatEventLogData, isPluginResults } from './app-events.utils' + +export type ProjectEventName = 'project.upsert' | 'project.delete' +export type ProjectMemberEventName = 'projectMember.upsert' | 'projectMember.delete' + +export interface ProjectMemberEventPayload { + projectId: string + userId: string +} + +/** Admin-log action labels (legacy hooks wording). */ +export type EventLogAction + = | 'Create Project' | 'Update Project' | 'Delete all project resources' + | 'Replay hooks for Project' | 'Upsert Project Role' + | 'Create Deployment' | 'Update Deployment' | 'Delete Deployment' + | 'Delete all project deployments' + | 'Create Environment' | 'Update Environment' | 'Delete Environment' + | 'Add Project Member' | 'Update Project Member' | 'Remove Project Member' + +export interface EventContext { + /** Action label persisted in the admin log. */ + action: EventLogAction + userId?: string | null + requestId?: string | null +} + +/** + * Single entry point to emit domain events: emits through EventEmitter2, merges the + * `PluginResults` returned by every `capturePluginResult` listener and persists one admin + * log per event. Emitters only provide an id and the log context. + */ +@Injectable() +export class AppEventsService { + private readonly logger = new Logger(AppEventsService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, + @Inject(LogService) private readonly logs: LogService, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + /** + * Emits a project event and logs the merged listener results. + * + * Pass a project id in the general case: the project is loaded here, after the + * emitting transaction commits, so every listener sees the same committed state. + * Pass an already-loaded snapshot when the row no longer reflects what listeners + * must act on (e.g. archiving renames the slug before emitting `project.delete`). + */ + async emitProjectEvent( + event: ProjectEventName, + projectOrId: string | ProjectWithDetails, + context: EventContext, + ): Promise { + const project = typeof projectOrId === 'string' + ? await getProject(this.prisma, projectOrId) + : projectOrId + + if (!project) { + this.logger.warn(`${event} skipped: project not found (projectId=${String(projectOrId)})`) + return {} + } + + const results = await this.emitAndLog(event, project, project.id, context) + await this.updateProjectStatus(event, project.id, results) + return results + } + + async emitProjectMemberEvent( + event: ProjectMemberEventName, + payload: ProjectMemberEventPayload, + context: EventContext, + ): Promise { + return this.emitAndLog(event, payload, payload.projectId, context) + } + + private async emitAndLog( + event: string, + payload: unknown, + projectId: string, + context: EventContext, + ): Promise { + const start = process.hrtime.bigint() + const responses = await this.eventEmitter.emitAsync(event, payload) + const totalExecutionTime = Number(process.hrtime.bigint() - start) / 1_000_000 + + const results = mergePluginResults(responses.filter(isPluginResults)) + this.logger.log(`${event} completed (projectId=${projectId}, services=${Object.keys(results).join(',') || 'none'})`) + + await this.logs.addLog({ + action: context.action, + data: formatEventLogData(payload, results, totalExecutionTime), + userId: context.userId ?? null, + requestId: context.requestId ?? null, + projectId, + }) + + return results + } + + /** + * Reflects the listeners' outcome on the project row (legacy hooks behavior): + * any KO result marks the project `failed`; a fully successful upsert marks it + * `created` and records the provisioning version. A successful `project.delete` + * leaves the `archived` status set when the project was archived. + */ + private async updateProjectStatus( + event: ProjectEventName, + projectId: string, + results: PluginResults, + ): Promise { + const failed = getFailedPlugins(results) + + if (failed.length) { + this.logger.warn(`${event} marked project as failed (projectId=${projectId}, failed=${failed.join(',')})`) + await this.prisma.project.update({ where: { id: projectId }, data: { status: 'failed' } }) + return + } + + if (event === 'project.upsert') { + await this.prisma.project.update({ + where: { id: projectId }, + data: { status: 'created', lastSuccessProvisionningVersion: this.baseConfig.appVersion }, + }) + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts new file mode 100644 index 0000000000..3c1ba22a7a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts @@ -0,0 +1,96 @@ +import type { PluginResults } from '../plugin/plugin.utils' +import { NetworkError } from '@keycloak/keycloak-admin-client' +import { describe, expect, it } from 'vitest' +import { formatEventLogData, isPluginResults, serializeError } from './app-events.utils' + +describe('isPluginResults', () => { + it('should accept an object produced by a capturePluginResult handler', () => { + expect(isPluginResults({ gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } })).toBe(true) + expect(isPluginResults({ argocd: { status: 'KO', message: 'boom', executionTime: 10, error: new Error('boom') } })).toBe(true) + }) + + it('should reject non-result responses', () => { + expect(isPluginResults(undefined)).toBe(false) + expect(isPluginResults(null)).toBe(false) + expect(isPluginResults('ok')).toBe(false) + expect(isPluginResults({})).toBe(false) + expect(isPluginResults({ gitlab: { foo: 'bar' } })).toBe(false) + expect(isPluginResults({ gitlab: { status: 'MAYBE' } })).toBe(false) + }) +}) + +describe('serializeError', () => { + it('should serialize an Error with name, message and stack', () => { + const parsed = JSON.parse(serializeError(new Error('boom'))) + expect(parsed).toEqual({ name: 'Error', message: 'boom', stack: expect.any(String) }) + }) + + it('should include HTTP details from errors carrying a fetch Response', () => { + const error = new NetworkError('Network response was not OK.', { + response: new Response('conflict', { status: 409 }), + responseData: { errorMessage: 'Sibling group named \'admin\' already exists.' }, + }) + const parsed = JSON.parse(serializeError(error)) + expect(parsed).toEqual({ + name: 'Error', + message: 'Network response was not OK.', + status: 409, + url: expect.any(String), + responseData: { errorMessage: 'Sibling group named \'admin\' already exists.' }, + stack: expect.any(String), + }) + }) + + it('should serialize non-Error values', () => { + expect(serializeError({ code: 42 })).toBe('{"code":42}') + expect(serializeError('boom')).toBe('"boom"') + }) +}) + +describe('formatEventLogData', () => { + const args = { id: 'project-1', slug: 'project-1' } + + it('should format results in the LogSchema shape', () => { + const results: PluginResults = { + gitlab: { status: 'OK', message: 'Up to date', executionTime: 12.4 }, + argocd: { status: 'KO', message: 'Sync failed', executionTime: 100.9, error: new Error('Sync failed') }, + } + + expect(formatEventLogData(args, results, 120.6)).toEqual({ + args, + failed: ['argocd'], + results: { + gitlab: { + status: { result: 'OK', message: 'Up to date' }, + executionTime: { main: 12 }, + }, + argocd: { + status: { result: 'KO', message: 'Sync failed' }, + executionTime: { main: 101 }, + error: expect.stringContaining('"message":"Sync failed"'), + }, + }, + totalExecutionTime: 121, + messageResume: 'Errors:\nargocd: Sync failed;', + }) + }) + + it('should omit the failed list and produce a success resume when everything is OK', () => { + const results: PluginResults = { + gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 }, + } + + const data = formatEventLogData(args, results, 10) + expect(data).not.toHaveProperty('failed') + expect(data.messageResume).toBe('Success') + }) + + it('should handle events without any listener', () => { + expect(formatEventLogData(args, {}, 1)).toEqual({ + args, + results: {}, + totalExecutionTime: 1, + messageResume: 'Success', + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.utils.ts new file mode 100644 index 0000000000..a9cf62706b --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/events/app-events.utils.ts @@ -0,0 +1,77 @@ +import type { LogData } from '../log/log.service' +import type { PluginName, PluginResult, PluginResults } from '../plugin/plugin.utils' +import { getErrorHttpDetails } from '../../utils/http.utils' +import { getFailedPlugins } from '../plugin/plugin.utils' + +/** Per-service result as persisted in the admin logs (legacy hooks format, parsed by LogSchema). */ +export interface LoggablePluginResult { + status: { + result: 'OK' | 'KO' + message?: string + } + executionTime: { + main: number + } + error?: string +} + +function isPluginResult(value: unknown): value is PluginResult { + return typeof value === 'object' + && value !== null + && 'status' in value + && ((value as PluginResult).status === 'OK' || (value as PluginResult).status === 'KO') +} + +/** Narrows an `emitAsync` response to a `PluginResults` object produced by `capturePluginResult` handlers. */ +export function isPluginResults(response: unknown): response is PluginResults { + if (typeof response !== 'object' || response === null) return false + const values = Object.values(response) + return values.length > 0 && values.every(isPluginResult) +} + +export function serializeError(error: unknown): string { + if (error instanceof Error) { + return JSON.stringify({ + name: error.name, + message: error.message, + ...getErrorHttpDetails(error), + stack: error.stack, + }) + } + try { + return JSON.stringify(error) + } catch { + return String(error) + } +} + +function toLoggableResult(result: PluginResult): LoggablePluginResult { + return { + status: { + result: result.status, + ...(result.message ? { message: result.message } : {}), + }, + executionTime: { main: Math.round(result.executionTime) }, + ...(result.status === 'KO' ? { error: serializeError(result.error) } : {}), + } +} + +function buildMessageResume(results: PluginResults, failed: PluginName[]): string { + if (!failed.length) return 'Success' + const errorLines = failed.map(service => `${service}: ${results[service]?.message};`).join('\n') + return `Errors:\n${errorLines}` +} + +/** Builds the log `data` payload for an event: the emitted args plus every listener's result. */ +export function formatEventLogData(args: unknown, results: PluginResults, totalExecutionTime: number): LogData { + const failed = getFailedPlugins(results) + const entries = Object.entries(results) as [PluginName, PluginResult][] + + return { + args, + ...(failed.length ? { failed } : {}), + results: Object.fromEntries(entries.map(([service, result]) => [service, toLoggableResult(result)])), + totalExecutionTime: Math.round(totalExecutionTime), + messageResume: buildMessageResume(results, failed), + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts new file mode 100644 index 0000000000..0db65f4630 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts @@ -0,0 +1,689 @@ +import type { ExpandedGroupSchema, Gitlab as GitlabApi, ProjectSchema } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { MockedFunction } from 'vitest' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' +import { + makeAccessTokenExposedSchema, + makeAccessTokenSchema, + makeExpandedGroupSchema, + makeExpandedUserSchema, + makeGitbeakerRequestError, + makeGroupSchema, + makeMemberSchema, + makeOffsetPagination, + makePipelineTriggerToken, + makeProjectSchema, + makeRepositoryFileExpandedSchema, + makeRepositoryTreeSchema, +} from './gitlab-testing.utils' +import { + GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, + MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, + PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, + USER_ID_CUSTOM_ATTRIBUTE_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +describe('gitlab-client', () => { + let service: GitlabClientService + let gitlabApi: DeepMockProxy + + beforeEach(async () => { + gitlabApi = mockDeep() + const config = mockDeep({ + url: 'https://gitlab.internal', + token: 'token', + internalUrl: 'https://gitlab.internal', + projectRootDir: 'forge', + mirrorTokenExpirationDays: 30, + internalOrPublicUrl: 'https://gitlab.internal', + }) + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GitlabClientService, + { provide: GITLAB_REST_CLIENT, useValue: gitlabApi }, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + service = module.get(GitlabClientService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('getOrCreateInfraProject', () => { + it('should create infra project if not exists', async () => { + const zoneSlug = 'zone-1' + const rootId = 123 + const infraGroupId = 456 + const projectId = 789 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + + gitlabApi.Groups.show.mockResolvedValueOnce({ id: rootId, full_path: 'forge' } as ExpandedGroupSchema) + + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + gitlabApi.Groups.create.mockResolvedValue({ id: infraGroupId, full_path: 'forge/infra' } as ExpandedGroupSchema) + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.create.mockResolvedValue({ + id: projectId, + path_with_namespace: 'forge/infra/zone-1', + http_url_to_repo: 'https://gitlab.internal/infra/zone-1.git', + } as ProjectSchema) + + const result = await service.getOrCreateInfraGroupRepo(zoneSlug) + + expect(result).toEqual({ + id: projectId, + http_url_to_repo: 'https://gitlab.internal/infra/zone-1.git', + path_with_namespace: 'forge/infra/zone-1', + }) + expect(gitlabApi.Groups.create).toHaveBeenCalledWith('infra', 'infra', expect.any(Object)) + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(rootId, GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(infraGroupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(infraGroupId, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ + name: zoneSlug, + path: zoneSlug, + namespaceId: infraGroupId, + })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + }) + + describe('commitCreateOrUpdate', () => { + it('should create commit if file not exists', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'content' + const filePath = 'file.txt' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + const notFoundError = makeGitbeakerRequestError({ description: '404 File Not Found' }) + gitlabRepositoryFilesShowMock.mockRejectedValue(notFoundError) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + message, + [{ action: 'create', filePath, content }], + ) + }) + + it('should update commit if content differs', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'new content' + const filePath = 'file.txt' + const oldHash = 'oldhash' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + gitlabRepositoryFilesShowMock.mockResolvedValue(makeRepositoryFileExpandedSchema({ content_sha256: oldHash })) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + message, + [{ action: 'update', filePath, content }], + ) + }) + + it('should do nothing if content matches', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'content' + const filePath = 'file.txt' + const hash = 'ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + gitlabRepositoryFilesShowMock.mockResolvedValue(makeRepositoryFileExpandedSchema({ content_sha256: hash })) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).not.toHaveBeenCalled() + }) + }) + + describe('getOrCreateProjectGroup', () => { + it('should create project group if not exists', async () => { + const projectSlug = 'project-1' + const rootId = 123 + const groupId = 456 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce(makeExpandedGroupSchema({ id: rootId, full_path: 'forge' })) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.create.mockResolvedValue(makeExpandedGroupSchema({ id: groupId, name: projectSlug, path: projectSlug, full_path: `forge/${projectSlug}` })) + + const result = await service.getOrCreateProjectSubGroup(projectSlug) + + expect(result).toEqual(expect.objectContaining({ id: groupId, name: projectSlug })) + expect(gitlabApi.Groups.create).toHaveBeenCalledWith(projectSlug, projectSlug, expect.objectContaining({ + parentId: rootId, + })) + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(groupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(groupId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + }) + + it('should return existing group', async () => { + const projectSlug = 'project-1' + const rootId = 123 + const groupId = 456 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce(makeExpandedGroupSchema({ id: rootId, full_path: 'forge' })) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: rootId, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectSubGroup(projectSlug) + + expect(result).toEqual({ id: groupId, name: projectSlug, parent_id: rootId, full_path: 'forge/project-1' }) + expect(gitlabApi.Groups.create).not.toHaveBeenCalled() + }) + }) + + describe('repositories', () => { + it('should return internal repo url', async () => { + const projectSlug = 'project-1' + const repoName = 'repo-1' + const rootId = 123 + const groupId = 1 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectGroupInternalRepoUrl(projectSlug, repoName) + expect(result).toBe('https://gitlab.internal/forge/project-1/repo-1.git') + }) + + it('should upsert mirror repo', async () => { + const projectSlug = 'project-1' + const repoId = 1 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValue({ + data: [{ id: repoId, path_with_namespace: 'forge/project-1/mirror' }], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: 'mirror' } as ProjectSchema) + + const result = await service.upsertProjectMirrorRepo(projectSlug) + + expect(result).toEqual({ id: repoId, name: 'mirror' }) + expect(gitlabApi.Projects.edit).toHaveBeenCalledWith(repoId, expect.objectContaining({ + name: 'mirror', + path: 'mirror', + })) + }) + + it('should set managed custom attribute when upserting a project repo', async () => { + const projectSlug = 'project-1' + const repoName = 'repo-1' + const repoId = 101 + + gitlabApi.Projects.show.mockResolvedValue(makeProjectSchema({ id: repoId })) + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: repoName } as ProjectSchema) + + const result = await service.upsertProjectGroupRepo(projectSlug, repoName, 'desc') + + expect(result).toEqual({ id: repoId, name: repoName }) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(repoId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + + describe('upsertUser', () => { + it('should create user and set custom attribute if not exists', async () => { + const consoleUser = { id: 'u1', email: 'new@example.com', firstName: 'New', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'new', + name: 'New User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([]) + gitlabApi.Users.create.mockResolvedValue(makeExpandedUserSchema({ id: 999, email: consoleUser.email })) + + const result = await service.upsertUser(gitlabUser, { cpnUserId: consoleUser.id }) + + expect(result).toEqual(expect.objectContaining({ id: 999, email: consoleUser.email })) + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'new@example.com', + username: 'new', + name: 'New User', + externUid: 'new@example.com', + provider: 'openid_connect', + skipConfirmation: true, + })) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(999, USER_ID_CUSTOM_ATTRIBUTE_KEY, consoleUser.id) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(999, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + + it('should set custom attribute if user exists', async () => { + const consoleUser = { id: 'u1', email: 'existing@example.com', firstName: 'Existing', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'existing', + name: 'Existing User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([makeExpandedUserSchema({ id: 1000, email: consoleUser.email })]) + + const result = await service.upsertUser(gitlabUser, { cpnUserId: consoleUser.id }) + + expect(result).toEqual(expect.objectContaining({ id: 1000, email: consoleUser.email })) + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.objectContaining({ + email: 'existing@example.com', + username: 'existing', + name: 'Existing User', + externUid: 'existing@example.com', + provider: 'openid_connect', + })) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(1000, USER_ID_CUSTOM_ATTRIBUTE_KEY, consoleUser.id) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(1000, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.Users.create).not.toHaveBeenCalled() + }) + + it('should set admin flag when provided', async () => { + const consoleUser = { id: 'u1', email: 'admin@example.com', firstName: 'Admin', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'admin', + name: 'Admin User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([]) + gitlabApi.Users.create.mockResolvedValue(makeExpandedUserSchema({ id: 999, email: consoleUser.email })) + + await service.upsertUser({ ...gitlabUser, admin: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'admin@example.com', + username: 'admin', + name: 'Admin User', + externUid: 'admin@example.com', + provider: 'openid_connect', + admin: true, + skipConfirmation: true, + })) + }) + + it('should not disable existing admin flag when enabling auditor flag', async () => { + const consoleUser = { id: 'u1', email: 'admin-auditor@example.com', firstName: 'Admin', lastName: 'Auditor' } + const gitlabUser = { + email: consoleUser.email, + username: 'admin-auditor', + name: 'Admin Auditor', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([ + makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_admin: true }), + ]) + + await service.upsertUser({ ...gitlabUser, auditor: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.not.objectContaining({ + admin: true, + })) + }) + + it('should not disable existing auditor flag when enabling admin flag', async () => { + const consoleUser = { id: 'u1', email: 'auditor-admin@example.com', firstName: 'Auditor', lastName: 'Admin' } + const gitlabUser = { + email: consoleUser.email, + username: 'auditor-admin', + name: 'Auditor Admin', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([ + makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_auditor: true }), + ]) + + await service.upsertUser({ ...gitlabUser, admin: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.not.objectContaining({ + auditor: true, + })) + }) + }) + + it('should create pipeline trigger token if not exists', async () => { + const projectSlug = 'project-1' + const repoId = 1 + const tokenDescription = 'mirroring-from-external-repo' + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValue({ + data: [{ id: repoId, path_with_namespace: 'forge/project-1/mirror' }], + paginationInfo: { next: null }, + }) + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: 'mirror' } as ProjectSchema) + + const gitlabPipelineTriggerTokensAllMock = gitlabApi.PipelineTriggerTokens.all as MockedFunction + gitlabPipelineTriggerTokensAllMock.mockResolvedValue({ + data: [], + paginationInfo: makeOffsetPagination({ next: null }), + }) + + gitlabApi.PipelineTriggerTokens.create.mockResolvedValue(makePipelineTriggerToken({ id: 2, description: tokenDescription })) + + const result = await service.getOrCreateMirrorPipelineTriggerToken(projectSlug) + + expect(result).toEqual(expect.objectContaining({ id: 2, description: tokenDescription })) + expect(gitlabApi.PipelineTriggerTokens.create).toHaveBeenCalledWith(repoId, tokenDescription) + }) + }) + + describe('group Members', () => { + it('should get group members', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const members = [makeMemberSchema({ id: 1, name: 'user' })] + const gitlabGroupMembersAllMock = gitlabApi.GroupMembers.all as MockedFunction + gitlabGroupMembersAllMock.mockResolvedValue(members) + + const result = await service.getGroupMembers(group) + expect(result).toEqual(members) + expect(gitlabApi.GroupMembers.all).toHaveBeenCalledWith(groupId) + }) + + it('should add group member', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const userId = 2 + const accessLevel = 30 + gitlabApi.GroupMembers.add.mockResolvedValue(makeMemberSchema({ id: userId })) + + await service.addGroupMember(group, userId, accessLevel) + expect(gitlabApi.GroupMembers.add).toHaveBeenCalledWith(groupId, userId, accessLevel) + }) + + it('should remove group member', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const userId = 2 + gitlabApi.GroupMembers.remove.mockResolvedValue(undefined) + + await service.removeGroupMember(group, userId) + expect(gitlabApi.GroupMembers.remove).toHaveBeenCalledWith(groupId, userId) + }) + }) + + describe('createProjectMirrorAccessToken', () => { + it('should create project access token with correct scopes', async () => { + const projectSlug = 'project-1' + const groupId = 456 + const tokenName = `${projectSlug}-bot` + const token = makeAccessTokenExposedSchema({ id: 1, name: tokenName, token: 'secret-token' }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: 123, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce({ id: 123, full_path: 'forge' } as ExpandedGroupSchema) + + const gitlabGroupsAllSubgroupsMock = gitlabApi.Groups.allSubgroups as MockedFunction + gitlabGroupsAllSubgroupsMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: 123, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + gitlabApi.GroupAccessTokens.create.mockResolvedValue(token) + + const result = await service.createMirrorAccessToken(projectSlug) + + expect(result).toEqual(token) + expect(gitlabApi.GroupAccessTokens.create).toHaveBeenCalledWith( + groupId, + tokenName, + ['write_repository', 'read_repository', 'read_api'], + expect.any(String), + ) + }) + }) + + describe('getOrCreateProjectGroupRepo', () => { + it('should return existing repo', async () => { + const subGroupPath = 'project-1' + const repoName = 'repo-1' + const fullPath = `${subGroupPath}/${repoName}` + const projectId = 789 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [{ id: projectId, path_with_namespace: `forge/${fullPath}` }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectGroupRepo(subGroupPath, fullPath) + + expect(result).toEqual(expect.objectContaining({ id: projectId })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, 'project-1') + }) + + it('should create repo if not exists', async () => { + const subGroupPath = 'project-1' + const repoName = 'repo-1' + const fullPath = `${subGroupPath}/${repoName}` + const projectId = 789 + const groupId = 456 + const rootId = 123 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: subGroupPath, parent_id: rootId, full_path: `forge/${subGroupPath}` }], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.create.mockResolvedValue({ id: projectId, name: repoName } as ProjectSchema) + + const result = await service.getOrCreateProjectGroupRepo(subGroupPath, fullPath) + + expect(result).toEqual(expect.objectContaining({ id: projectId })) + expect(gitlabApi.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ + name: repoName, + path: repoName, + namespaceId: groupId, + })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, 'project-1') + }) + }) + + describe('getFile', () => { + it('should return file content', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const file = makeRepositoryFileExpandedSchema({ content: 'content' }) + + gitlabApi.RepositoryFiles.show.mockResolvedValue(file) + + const result = await service.getFile(repo, filePath, ref) + expect(result).toEqual(file) + }) + + it('should return undefined on 404', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const error = makeGitbeakerRequestError({ description: '404 File Not Found' }) + + gitlabApi.RepositoryFiles.show.mockRejectedValue(error) + + const result = await service.getFile(repo, filePath, ref) + expect(result).toBeUndefined() + }) + + it('should throw on other errors', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const error = new Error('Some other error') + + gitlabApi.RepositoryFiles.show.mockRejectedValue(error) + + await expect(service.getFile(repo, filePath, ref)).rejects.toThrow(error) + }) + }) + + describe('listFiles', () => { + it('should return files', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const files = [makeRepositoryTreeSchema({ path: 'file.txt' })] + + gitlabApi.Repositories.allRepositoryTrees.mockResolvedValue(files) + + const result = await service.listFiles(repo) + expect(result).toEqual(files) + }) + + it('should return empty array on 404', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const error = makeGitbeakerRequestError({ description: '404 Tree Not Found' }) + + gitlabApi.Repositories.allRepositoryTrees.mockRejectedValue(error) + + const result = await service.listFiles(repo) + expect(result).toEqual([]) + }) + }) + + describe('getProjectToken', () => { + it('should return specific token', async () => { + const projectSlug = 'project-1' + const groupId = 456 + const tokenName = `${projectSlug}-bot` + const token = makeAccessTokenSchema({ id: 1, name: tokenName }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: 123, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce({ id: 123, full_path: 'forge' } as ExpandedGroupSchema) + const gitlabGroupsAllSubgroupsMock = gitlabApi.Groups.allSubgroups as MockedFunction + gitlabGroupsAllSubgroupsMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: 123, full_path: `forge/${projectSlug}` }], + paginationInfo: { next: null }, + }) + + const gitlabGroupAccessTokensAllMock = gitlabApi.GroupAccessTokens.all as MockedFunction + gitlabGroupAccessTokensAllMock.mockResolvedValue({ + data: [token], + paginationInfo: makeOffsetPagination({ next: null }), + }) + + const result = await service.getProjectToken(projectSlug) + expect(result).toEqual(token) + }) + }) + + describe('createUser', () => { + it('should create user', async () => { + const email = 'user@example.com' + const username = 'user' + const name = 'User Name' + const user = makeExpandedUserSchema({ id: 1, username }) + + gitlabApi.Users.create.mockResolvedValue(user) + + const result = await service.createUser({ email, username, name }) + + expect(result).toEqual(user) + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email, + username, + name, + skipConfirmation: true, + })) + }) + }) + + describe('commitMirror', () => { + it('should create mirror commit', async () => { + const repoId = 1 + + await service.commitMirror(repoId) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + expect.any(String), + expect.arrayContaining([ + expect.objectContaining({ filePath: '.gitlab-ci.yml', action: 'create' }), + expect.objectContaining({ filePath: 'mirror.sh', action: 'create' }), + ]), + ) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts new file mode 100644 index 0000000000..d1a755fc9a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -0,0 +1,554 @@ +import type { + AccessLevel, + AccessTokenScopes, + BaseRequestOptions, + CommitAction, + CondensedGroupSchema, + CondensedProjectSchema, + EditUserOptions, + ExpandedUserSchema, + Gitlab, + GroupSchema, + OffsetPagination, + PaginationRequestOptions, + PipelineTriggerTokenSchema, + SimpleUserSchema, +} from '@gitbeaker/core' +import type { GitlabConfig } from './gitlab.module-definition' +import { join } from 'node:path' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { find } from '../../utils/iterable.utils' +import { + GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_PATH, + MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, + MIRROR_REPO_NAME, + PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, + TOKEN_DESCRIPTION, + TOPIC_PLUGIN_MANAGED, + USER_ID_CUSTOM_ATTRIBUTE_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { generateGitlabCIConfigContent, generateMirrorScriptContent, hasFileContentChanged } from './gitlab.utils' + +export const GITLAB_REST_CLIENT = Symbol('GITLAB_REST_CLIENT') + +type With = T & Required> +export type CondensedGroupSchemaWith = With +export type CondensedProjectSchemaWith = With +export type EditUserOptionsWith = With +type UserSchema = SimpleUserSchema | ExpandedUserSchema + +export interface OffsetPaginateOptions { + startPage?: number + perPage?: number + maxPages?: number +} + +@Injectable() +export class GitlabClientService { + private readonly logger = new Logger(GitlabClientService.name) + + constructor( + @Inject(GITLAB_CONFIG) readonly config: GitlabConfig, + @Inject(GITLAB_REST_CLIENT) private readonly client: Gitlab, + ) { + } + + async upsertGroupCustomAttribute(groupId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab group custom attribute (groupId=${groupId}, key=${key})`) + try { + await this.client.GroupCustomAttributes.set(groupId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab group custom attribute (groupId=${groupId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + async upsertProjectCustomAttribute(projectId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab project custom attribute (projectId=${projectId}, key=${key})`) + try { + await this.client.ProjectCustomAttributes.set(projectId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab project custom attribute (projectId=${projectId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + async upsertUserCustomAttribute(userId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab user custom attribute (userId=${userId}, key=${key})`) + try { + await this.client.UserCustomAttributes.set(userId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab user custom attribute (userId=${userId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + private async setManagedUserAttributes(userId: number, cpnUserId: string) { + await this.upsertUserCustomAttribute(userId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + await this.upsertUserCustomAttribute(userId, USER_ID_CUSTOM_ATTRIBUTE_KEY, cpnUserId) + } + + private async setManagedInfraProjectAttributes(projectId: number) { + await this.upsertProjectCustomAttribute(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedProjectAttributes(projectId: number, projectSlug: string) { + await this.upsertProjectCustomAttribute(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + await this.upsertProjectCustomAttribute(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + } + + private async setManagedGroupAttributes(groupId: number) { + await this.upsertGroupCustomAttribute(groupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedRootGroupAttributes(groupId: number) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedInfraGroupAttributes(groupId: number) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedProjectGroupAttributes(groupId: number, projectSlug: string) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + } + + async getGroupByPath(path: string) { + this.logger.verbose(`Looking up a GitLab group by path ${path}`) + return find( + this.offsetPaginate(opts => this.client.Groups.all({ search: path, orderBy: 'path', ...opts })), + g => g.full_path === path, + ) + } + + async createGroup(path: string) { + this.logger.log(`Creating a GitLab group at path ${path}`) + const created = await this.client.Groups.create(path, path) + if (this.config.projectRootDir && created.full_path === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(created.id) + } + if (this.config.projectRootDir && created.full_path === `${this.config.projectRootDir}/${INFRA_GROUP_PATH}`) { + await this.setManagedInfraGroupAttributes(created.id) + } + return created + } + + async createSubGroup(parentGroup: CondensedGroupSchemaWith<'id' | 'full_path'>, name: string, fullPath: string) { + this.logger.log(`Creating a GitLab subgroup ${fullPath} (parentId=${parentGroup.id})`) + const created = await this.client.Groups.create(name, name, { parentId: parentGroup.id }) + if (this.config.projectRootDir && fullPath === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(created.id) + } else if (this.config.projectRootDir && fullPath === `${this.config.projectRootDir}/${INFRA_GROUP_PATH}`) { + await this.setManagedInfraGroupAttributes(created.id) + } else if (this.config.projectRootDir && fullPath.startsWith(`${this.config.projectRootDir}/`) && !fullPath.slice(this.config.projectRootDir.length + 1).includes('/')) { + const projectSlug = fullPath.slice(this.config.projectRootDir.length + 1) + if (projectSlug && projectSlug !== INFRA_GROUP_PATH) { + await this.setManagedProjectGroupAttributes(created.id, projectSlug) + } + } + return created + } + + async getOrCreateGroupByPath(path: string) { + const parts = path.split('/') + const rootGroupPath = parts.shift() + if (!rootGroupPath) throw new Error('Invalid projects root dir') + + this.logger.verbose(`Resolving GitLab group path ${path} (depth=${1 + parts.length})`) + let parentGroup = await this.getGroupByPath(rootGroupPath) ?? await this.createGroup(rootGroupPath) + if (this.config.projectRootDir && parentGroup.full_path === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(parentGroup.id) + } + + let currentFullPath: string + for (const part of parts) { + currentFullPath = `${parentGroup.full_path}/${part}` + parentGroup = await this.getGroupByPath(currentFullPath) ?? await this.createSubGroup(parentGroup, part, currentFullPath) + } + + this.logger.verbose(`GitLab group path resolved (path=${path}, groupId=${parentGroup.id})`) + return parentGroup + } + + async getOrCreateProjectGroup() { + if (!this.config.projectRootDir) throw new Error('projectRootDir not configured') + return this.getOrCreateGroupByPath(this.config.projectRootDir) + } + + async getOrCreateProjectSubGroup(subGroupPath: string) { + const fullPath = this.config.projectRootDir + ? `${this.config.projectRootDir}/${subGroupPath}` + : subGroupPath + return this.getOrCreateGroupByPath(fullPath) + } + + async getOrCreateProjectGroupPublicUrl(): Promise { + const projectGroup = await this.getOrCreateProjectGroup() + return new URL(projectGroup.full_path, this.config.url).toString() + } + + async getOrCreateInfraGroupRepoPublicUrl(repoName: string): Promise { + const projectGroup = await this.getOrCreateProjectGroup() + return new URL(`${projectGroup.full_path}/${INFRA_GROUP_PATH}/${repoName}.git`, this.config.url).toString() + } + + async getOrCreateProjectGroupInternalRepoUrl(subGroupPath: string, repoName: string): Promise { + const projectGroup = await this.getOrCreateProjectSubGroup(subGroupPath) + const urlBase = this.config.internalOrPublicUrl + if (!urlBase) throw new Error('GITLAB_URL is required') + return `${urlBase}/${projectGroup.full_path}/${repoName}.git` + } + + private async getOrCreateRepo(subGroupPath: string) { + const fullPath = this.config.projectRootDir + ? `${this.config.projectRootDir}/${subGroupPath}` + : subGroupPath + this.logger.verbose(`Resolving a GitLab project repository by path ${fullPath}`) + try { + const existingRepo = await this.client.Projects.show(fullPath) + if (existingRepo) { + this.logger.verbose(`Found a GitLab project repository (path=${fullPath}, repoId=${existingRepo.id})`) + return existingRepo + } + } catch (error) { + if (!(error instanceof GitbeakerRequestError) || !error.cause?.description?.includes('404')) { + throw error + } + } + const repo = await find( + this.offsetPaginate(opts => this.client.Projects.all({ + search: fullPath, + orderBy: 'path', + ...opts, + })), + p => p.path_with_namespace === fullPath, + ) + if (repo) { + this.logger.verbose(`Found a GitLab project repository via search (path=${fullPath}, repoId=${repo.id})`) + return repo + } + const parts = subGroupPath.split('/') + const repoName = parts.pop() + if (!repoName) throw new Error('Invalid repo path') + const parentGroup = await this.getOrCreateProjectSubGroup(parts.join('/')) + try { + const created = await this.client.Projects.create({ + name: repoName, + path: repoName, + namespaceId: parentGroup.id, + defaultBranch: 'main', + }) + this.logger.log(`Created a GitLab project repository (path=${fullPath}, repoId=${created.id})`) + return created + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('has already been taken')) { + this.logger.warn(`GitLab project repository already exists (race); reloading ${fullPath}`) + const reloaded = await this.client.Projects.show(fullPath) + return reloaded + } + throw error + } + } + + async getOrCreateProjectGroupRepo(projectSlug: string, subGroupPath: string) { + const repo = await this.getOrCreateRepo(subGroupPath) + await this.setManagedProjectAttributes(repo.id, projectSlug) + return repo + } + + async getOrCreateInfraGroupRepo(path: string) { + const fullPath = join(INFRA_GROUP_PATH, path) + const repo = await this.getOrCreateRepo(fullPath) + await this.setManagedInfraProjectAttributes(repo.id) + return repo + } + + async getFile(repo: CondensedProjectSchemaWith<'id'>, filePath: string, ref: string = 'main') { + try { + return await this.client.RepositoryFiles.show(repo.id, filePath, ref) + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('Not Found')) { + this.logger.debug(`GitLab file not found (repoId=${repo.id}, ref=${ref}, filePath=${filePath})`) + return + } + throw error + } + } + + async maybeCreateCommit( + repo: CondensedProjectSchemaWith<'id'>, + message: string, + actions: CommitAction[], + ref: string = 'main', + ): Promise { + if (actions.length === 0) { + this.logger.debug(`No GitLab commit actions to create (repoId=${repo.id}, ref=${ref})`) + return + } + this.logger.log(`Creating a GitLab commit (repoId=${repo.id}, ref=${ref}, actions=${actions.length})`) + await this.client.Commits.create(repo.id, ref, message, actions) + this.logger.verbose(`GitLab commit created (repoId=${repo.id}, ref=${ref}, actions=${actions.length})`) + } + + async generateCreateOrUpdateAction(repo: CondensedProjectSchemaWith<'id'>, ref: string, filePath: string, content: string): Promise { + const file = await this.getFile(repo, filePath, ref) + if (file && !hasFileContentChanged(file, content)) { + this.logger.debug(`GitLab file is up to date; skipping commit action (repoId=${repo.id}, ref=${ref}, filePath=${filePath})`) + return null + } + this.logger.verbose(`Prepared a GitLab commit action (repoId=${repo.id}, ref=${ref}, filePath=${filePath}, action=${file ? 'update' : 'create'})`) + return { + action: file ? 'update' : 'create', + filePath, + content, + } + } + + async listFiles(repo: CondensedProjectSchemaWith<'id'>, options: { path?: string, recursive?: boolean, ref?: string } = {}) { + try { + const path = options.path ?? '/' + const recursive = options.recursive ?? false + const ref = options.ref ?? 'main' + this.logger.verbose(`Listing GitLab repository tree (repoId=${repo.id}, ref=${ref}, path=${path}, recursive=${recursive})`) + const files = await this.client.Repositories.allRepositoryTrees(repo.id, { + path: options.path ?? '/', + recursive: options.recursive ?? false, + ref: options.ref ?? 'main', + }) + this.logger.verbose(`Listed GitLab repository tree (repoId=${repo.id}, ref=${ref}, path=${path}, count=${files.length})`) + return files + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('Not Found')) { + return [] + } + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('404 Tree Not Found')) { + return [] + } + throw error + } + } + + async getProjectGroup(projectSlug: string): Promise { + const parentGroup = await this.getOrCreateProjectGroup() + return find( + this.offsetPaginate(opts => this.client.Groups.allSubgroups(parentGroup.id, opts)), + g => g.name === projectSlug, + ) + } + + async deleteGroup(group: CondensedGroupSchemaWith<'id' | 'full_path'>): Promise { + this.logger.verbose(`Deleting GitLab group ${group.full_path} (groupId=${group.id})`) + await this.client.Groups.remove(group.id) + } + + async getGroupMembers(group: CondensedGroupSchemaWith<'id'>) { + this.logger.verbose(`Loading GitLab group members (groupId=${group.id})`) + return this.client.GroupMembers.all(group.id) + } + + async addGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number, accessLevel: Exclude) { + this.logger.verbose(`Adding a GitLab group member (groupId=${group.id}, userId=${userId}, accessLevel=${accessLevel})`) + return this.client.GroupMembers.add(group.id, userId, accessLevel) + } + + async editGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number, accessLevel: Exclude) { + this.logger.verbose(`Editing a GitLab group member (groupId=${group.id}, userId=${userId}, accessLevel=${accessLevel})`) + return this.client.GroupMembers.edit(group.id, userId, accessLevel) + } + + async removeGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number) { + this.logger.verbose(`Removing a GitLab group member (groupId=${group.id}, userId=${userId})`) + return this.client.GroupMembers.remove(group.id, userId) + } + + async getUserByEmail(email: string) { + const users = await this.client.Users.all({ search: email, orderBy: 'username' }) + if (users.length === 0) return null + return users[0] as UserSchema + } + + async createUser(user: EditUserOptions) { + this.logger.log(`Creating a GitLab user (email=${user.email}, username=${user.username})`) + return await this.client.Users.create({ + ...user, + skipConfirmation: true, + }) as UserSchema + } + + async upsertUser( + user: Omit, 'externUid' | 'provider'>, + options: { cpnUserId: string }, + ) { + const existing = await this.getUserByEmail(user.email) + + const editOptions: EditUserOptions = { + ...user, + externUid: user.email, + provider: 'openid_connect', + } + const gitlabUser = existing ?? await this.createUser(editOptions) + + if (existing) { + const hasDiff = Object.entries(editOptions).some(([key, value]) => { + if (value === undefined) return false + return (existing as Record)[key] !== value + }) + if (hasDiff) { + await this.client.Users.edit(gitlabUser.id, editOptions) + } + } + await this.setManagedUserAttributes(gitlabUser.id, options.cpnUserId) + return gitlabUser + } + + async* getRepos(projectSlug: string) { + const group = await this.getOrCreateProjectSubGroup(projectSlug) + const repos = this.offsetPaginate(opts => this.client.Groups.allProjects(group.id, { simple: false, ...opts })) + for await (const repo of repos) { + yield repo + } + } + + async upsertProjectGroupRepo(projectSlug: string, repoName: string, description?: string) { + const fullPath = `${projectSlug}/${repoName}` + const repo = await this.getOrCreateProjectGroupRepo(projectSlug, fullPath) + const updated = await this.client.Projects.edit(repo.id, { + name: repoName, + path: repoName, + topics: [TOPIC_PLUGIN_MANAGED], + description, + }) + return updated + } + + async deleteProjectGroupRepo(projectSlug: string, repoName: string) { + const fullPath = `${projectSlug}/${repoName}` + const repo = await this.getOrCreateProjectGroupRepo(projectSlug, fullPath) + return this.client.Projects.remove(repo.id) + } + + async commitMirror(repoId: number) { + this.logger.log(`Creating a GitLab mirror bootstrap commit (repoId=${repoId})`) + const actions: CommitAction[] = [ + { + action: 'create', + filePath: '.gitlab-ci.yml', + content: generateGitlabCIConfigContent(), + execute_filemode: false, + }, + { + action: 'create', + filePath: 'mirror.sh', + content: generateMirrorScriptContent(), + execute_filemode: true, + }, + ] + + await this.client.Commits.create( + repoId, + 'main', + 'ci: :construction_worker: first mirror', + actions, + ) + this.logger.verbose(`GitLab mirror bootstrap commit created (repoId=${repoId}, actions=${actions.length})`) + } + + async upsertProjectMirrorRepo(projectSlug: string) { + return this.upsertProjectGroupRepo(projectSlug, MIRROR_REPO_NAME) + } + + async getProjectToken(projectSlug: string) { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + return find( + this.offsetPaginate<{ name: string }>( + opts => this.client.GroupAccessTokens.all(group.id, opts) as unknown as Promise<{ data: { name: string }[], paginationInfo: OffsetPagination }>, + ), + token => token.name === `${projectSlug}-bot`, + ) + } + + async createProjectToken(projectSlug: string, tokenName: string, scopes: AccessTokenScopes[]) { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + const expirationDays = Number(this.config.mirrorTokenExpirationDays) + const effectiveExpirationDays = Number.isFinite(expirationDays) && expirationDays > 0 ? expirationDays : 30 + const expiryDate = new Date(Date.now() + effectiveExpirationDays * 24 * 60 * 60 * 1000) + this.logger.log(`Creating a GitLab group access token (projectSlug=${projectSlug}, tokenName=${tokenName}, expiry=${expiryDate.toISOString().slice(0, 10)})`) + return this.client.GroupAccessTokens.create(group.id, tokenName, scopes, expiryDate.toISOString().slice(0, 10)) + } + + async createMirrorAccessToken(projectSlug: string) { + const tokenName = `${projectSlug}-bot` + return this.createProjectToken(projectSlug, tokenName, ['write_repository', 'read_repository', 'read_api']) + } + + async getOrCreateMirrorPipelineTriggerToken(projectSlug: string): Promise { + const mirrorRepo = await this.upsertProjectMirrorRepo(projectSlug) + this.logger.verbose(`Resolving a GitLab pipeline trigger token (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + const currentTriggerToken = await find( + this.offsetPaginate(opts => this.client.PipelineTriggerTokens.all(mirrorRepo.id, opts)), + token => token.description === TOKEN_DESCRIPTION, + ) + if (currentTriggerToken) { + this.logger.verbose(`GitLab pipeline trigger token found (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + return currentTriggerToken + } + const created = await this.client.PipelineTriggerTokens.create(mirrorRepo.id, TOKEN_DESCRIPTION) + this.logger.log(`GitLab pipeline trigger token created (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + return created + } + + private async* offsetPaginate( + request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, + options?: OffsetPaginateOptions, + ): AsyncGenerator { + let page: number | null = options?.startPage ?? 1 + let pagesFetched = 0 + let total = 0 + + this.logger.debug(`Pagination start (page=${page})`) + + while (page !== null) { + if (options?.maxPages && pagesFetched >= options.maxPages) { + page = null + continue + } + + try { + const { data, paginationInfo } = await request({ + page, + perPage: options?.perPage, + maxPages: options?.maxPages, + showExpanded: true, + pagination: 'offset', + }) + + pagesFetched += 1 + total += data.length + + const nextPage = paginationInfo.next ?? null + this.logger.debug(`Pagination page fetched (page=${page}, nextPage=${nextPage ?? 'null'}, items=${data.length}, total=${total})`) + + for (const item of data) { + yield item + } + + page = nextPage + } catch (error) { + if (error instanceof Error) { + this.logger.error(`Pagination request failed (page=${page}): ${error.message}`, error.stack) + } else { + this.logger.error(`Pagination request failed (page=${page}): ${String(error)}`) + } + throw error + } + } + + this.logger.debug(`Pagination done (total=${total})`) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts new file mode 100644 index 0000000000..5f72414b87 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts @@ -0,0 +1,133 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + owner: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + roles: { + select: { + id: true, + oidcGroup: true, + }, + }, + members: { + select: { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + }, + }, + roleIds: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + isPrivate: true, + externalRepoUrl: true, + externalUserName: true, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class GitlabDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: 'gitlab', + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } + + async getAdminRolesByOidcGroups(oidcGroups: string[]): Promise<{ id: string, oidcGroup: string }[]> { + return this.prisma.adminRole.findMany({ + where: { + oidcGroup: { + in: oidcGroups, + }, + }, + select: { + id: true, + oidcGroup: true, + }, + }) + } + + async getUser(id: string) { + return this.prisma.user.findUnique({ + where: { + id, + }, + }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts new file mode 100644 index 0000000000..0c4bdf8c41 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts @@ -0,0 +1,23 @@ +import type { GitlabConfig } from './gitlab.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +@Injectable() +export class GitlabHealthService { + constructor( + @Inject(GITLAB_CONFIG) private readonly gitlabConfig: GitlabConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.gitlabConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts new file mode 100644 index 0000000000..7f4d85f694 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { GitlabPluginService } from './gitlab-plugin.service' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +describe('gitlabPluginService', () => { + let service: GitlabPluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://gitlab.public', + projectRootDir: 'forge', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + GitlabPluginService, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(GitlabPluginService) + }) + + it('should expose the legacy project url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams({ project: { id: '', name: '', slug: 'dulei' } })) + + expect(url).toBe('https://gitlab.public/forge/dulei') + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts new file mode 100644 index 0000000000..3ce12a442c --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts @@ -0,0 +1,113 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { GitlabConfig } from './gitlab.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { DEFAULT_ADMIN_GROUP_PATH, DEFAULT_AUDITOR_GROUP_PATH, DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, PURGE_PLUGIN_KEY } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +@Injectable() +export class GitlabPluginService { + constructor( + @Inject(GITLAB_CONFIG) + private readonly gitlabConfig: GitlabConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'gitlab', + to: ({ project }) => new URL(`${this.gitlabConfig.projectRootDir}/${project.slug}`, this.gitlabConfig.url).toString(), + title: 'Gitlab', + imgSrc: '/img/gitlab.svg', + description: 'GitLab est un service d\'hébergement de code source et de pipeline CI/CD', + config: { + global: [ + { + kind: 'switch', + key: PURGE_PLUGIN_KEY, + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Purger les utilisateurs non synchronisés', + value: DISABLED, + description: 'Purger les utilisateurs non synchronisés de GitLab lors de la synchronisation', + }, + { + kind: 'switch', + key: 'displayTriggerHint', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Afficher l\'aide de déclenchement de pipeline', + value: ENABLED, + description: 'Afficher l\'aide de déclenchement de pipeline aux utilisateurs lorsqu\'ils souhaitent afficher les secrets du projet', + }, + { + kind: 'text', + key: 'adminGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Admin', + value: DEFAULT_ADMIN_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'administrateur GitLab', + placeholder: DEFAULT_ADMIN_GROUP_PATH, + }, + { + kind: 'text', + key: 'auditorGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Auditeur', + value: DEFAULT_AUDITOR_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'auditeur GitLab', + placeholder: DEFAULT_AUDITOR_GROUP_PATH, + }, + { + kind: 'text', + key: 'projectMaintainerGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Maintainer', + value: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Maintainer', + placeholder: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectDeveloperGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Developer', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Developer', + placeholder: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectReporterGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Reporter', + value: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Reporter', + placeholder: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + }, + ], + project: [], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts new file mode 100644 index 0000000000..678e3e1e36 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts @@ -0,0 +1,432 @@ +import type { + AccessTokenExposedSchema, + AccessTokenSchema, + CommitAction, + ExpandedGroupSchema, + ExpandedUserSchema, + GroupSchema, + MemberSchema, + OffsetPagination, + PipelineTriggerTokenSchema, + ProjectSchema, + RepositoryFileExpandedSchema, + RepositoryTreeSchema, +} from '@gitbeaker/core' +import type { AdminRole, Project, User } from '@prisma/client' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { faker } from '@faker-js/faker' +import { AccessLevel } from '@gitbeaker/core' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { ProjectStatus } from '@prisma/client' +import { PLUGIN_NAME } from './gitlab.constants' + +export function makeExpandedUserSchema(overrides: Partial = {}): ExpandedUserSchema { + const isoDate = faker.date.past().toISOString() + return { + id: 1, + name: 'User', + username: 'user', + state: 'active', + avatar_url: '', + web_url: 'https://gitlab.example/users/user', + created_at: isoDate, + locked: null, + bio: null, + bot: false, + location: null, + public_email: null, + skype: null, + linkedin: null, + twitter: null, + discord: null, + website_url: null, + pronouns: null, + organization: null, + job_title: null, + work_information: null, + followers: null, + following: null, + local_time: null, + is_followed: null, + is_admin: null, + last_sign_in_at: isoDate, + confirmed_at: isoDate, + last_activity_on: isoDate, + email: 'user@example.com', + theme_id: 1, + color_scheme_id: 1, + projects_limit: 0, + current_sign_in_at: null, + note: null, + identities: null, + can_create_group: false, + can_create_project: false, + two_factor_enabled: false, + external: false, + private_profile: null, + namespace_id: null, + created_by: null, + ...overrides, + } satisfies ExpandedUserSchema +} + +export function makeMemberSchema(overrides: Partial = {}) { + return { + id: 1, + username: 'user', + name: 'User', + state: 'active', + avatar_url: '', + web_url: 'https://gitlab.example/users/user', + expires_at: faker.date.future().toISOString(), + access_level: 30, + email: 'user@example.com', + group_saml_identity: { + extern_uid: '', + provider: '', + saml_provider_id: 1, + }, + ...overrides, + } satisfies MemberSchema +} + +export function makeGroupSchema(overrides: Partial = {}) { + return { + id: 123, + web_url: 'https://gitlab.example/groups/forge', + name: 'forge', + avatar_url: '', + full_name: 'forge', + full_path: 'forge', + path: 'forge', + description: '', + visibility: 'private', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: 'maintainer', + subgroup_creation_level: 'maintainer', + lfs_enabled: true, + default_branch_protection: 0, + request_access_enabled: false, + created_at: faker.date.past().toISOString(), + parent_id: 0, + ...overrides, + } satisfies GroupSchema +} + +export function makeExpandedGroupSchema(overrides: Partial = {}): ExpandedGroupSchema { + return { + id: 123, + web_url: 'https://gitlab.example/groups/forge', + name: 'forge', + avatar_url: '', + full_name: 'forge', + full_path: 'forge', + path: 'forge', + description: '', + visibility: 'private', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: 'maintainer', + subgroup_creation_level: 'maintainer', + lfs_enabled: true, + default_branch_protection: 0, + request_access_enabled: false, + created_at: faker.date.past().toISOString(), + parent_id: 0, + runners_token: 'runners-token', + file_template_project_id: 1, + shared_with_groups: null, + ...overrides, + } satisfies ExpandedGroupSchema +} + +export function makeProjectSchema(overrides: Partial = {}) { + return { + id: 1, + web_url: 'https://gitlab.example/projects/1', + name: 'repo', + path: 'repo', + description: '', + name_with_namespace: 'forge / repo', + path_with_namespace: 'forge/repo', + created_at: faker.date.past().toISOString(), + default_branch: 'main', + topics: [], + ssh_url_to_repo: 'ssh://gitlab.example/forge/repo.git', + http_url_to_repo: 'https://gitlab.example/forge/repo.git', + readme_url: '', + forks_count: 0, + avatar_url: null, + star_count: 0, + last_activity_at: faker.date.future().toISOString(), + namespace: { id: 1, name: 'forge', path: 'forge', kind: 'group', full_path: 'forge', avatar_url: '', web_url: 'https://gitlab.example/groups/forge' }, + description_html: '', + visibility: 'private', + empty_repo: false, + owner: { id: 1, name: 'Owner', created_at: faker.date.past().toISOString() }, + issues_enabled: true, + open_issues_count: 0, + merge_requests_enabled: true, + jobs_enabled: true, + wiki_enabled: true, + snippets_enabled: true, + can_create_merge_request_in: true, + resolve_outdated_diff_discussions: false, + container_registry_access_level: 'enabled', + security_and_compliance_access_level: 'enabled', + container_expiration_policy: { + cadence: '1d', + enabled: false, + keep_n: null, + older_than: null, + name_regex_delete: null, + name_regex_keep: null, + next_run_at: faker.date.future().toISOString(), + }, + updated_at: faker.date.past().toISOString(), + creator_id: 1, + import_url: null, + import_type: null, + import_status: 'none', + import_error: null, + permissions: { + project_access: { access_level: 0, notification_level: 0 }, + group_access: { access_level: 0, notification_level: 0 }, + }, + archived: false, + license_url: '', + license: { key: 'mit', name: 'MIT', nickname: 'MIT', html_url: '', source_url: '' }, + shared_runners_enabled: true, + group_runners_enabled: true, + runners_token: '', + ci_default_git_depth: 0, + ci_forward_deployment_enabled: false, + ci_forward_deployment_rollback_allowed: false, + ci_allow_fork_pipelines_to_run_in_parent_project: false, + ci_separated_caches: false, + ci_restrict_pipeline_cancellation_role: '', + public_jobs: false, + shared_with_groups: null, + repository_storage: '', + only_allow_merge_if_pipeline_succeeds: false, + allow_merge_on_skipped_pipeline: false, + restrict_user_defined_variables: false, + only_allow_merge_if_all_discussions_are_resolved: false, + remove_source_branch_after_merge: false, + printing_merge_requests_link_enabled: false, + request_access_enabled: false, + merge_method: '', + squash_option: '', + auto_devops_enabled: false, + auto_devops_deploy_strategy: '', + mirror: false, + mirror_user_id: 1, + mirror_trigger_builds: false, + only_mirror_protected_branches: false, + mirror_overwrites_diverged_branches: false, + external_authorization_classification_label: '', + packages_enabled: false, + service_desk_enabled: false, + service_desk_address: 'service-desk@example.com', + service_desk_reply_to: 'service-desk@example.com', + autoclose_referenced_issues: false, + suggestion_commit_message: 'Add suggestion commit message', + enforce_auth_checks_on_uploads: false, + merge_commit_template: 'Add suggestion commit message', + squash_commit_template: 'Add suggestion commit message', + issue_branch_template: 'Add suggestion commit message', + marked_for_deletion_on: faker.date.future().toISOString(), + compliance_frameworks: [], + warn_about_potentially_unwanted_characters: false, + container_registry_image_prefix: 'registry.gitlab.example/forge/repo', + _links: { + self: 'https://gitlab.example/projects/1', + issues: 'https://gitlab.example/projects/1/issues', + merge_requests: 'https://gitlab.example/projects/1/merge_requests', + repo_branches: 'https://gitlab.example/projects/1/repository/branches', + labels: 'https://gitlab.example/projects/1/labels', + events: 'https://gitlab.example/projects/1/events', + members: 'https://gitlab.example/projects/1/members', + cluster_agents: 'https://gitlab.example/projects/1/cluster_agents', + }, + ...overrides, + } satisfies ProjectSchema +} + +export function makeProject(overrides: Partial = {}) { + return { + id: faker.string.uuid(), + slug: faker.lorem.slug(), + name: faker.company.name(), + description: faker.lorem.sentence(), + status: faker.helpers.enumValue(ProjectStatus), + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + everyonePerms: 0n, + ownerId: faker.string.uuid(), + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } satisfies Project +} + +export function makeProjectWithDetails(overrides: Partial = {}) { + return { + id: faker.string.uuid(), + slug: faker.lorem.slug(), + name: faker.company.name(), + description: faker.lorem.sentence(), + owner: { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + }, + plugins: [], + roles: [], + members: [], + repositories: [], + clusters: [], + ...overrides, + } satisfies ProjectWithDetails +} +export function makePipelineTriggerToken(overrides: Partial = {}) { + return { + id: 1, + description: 'mirroring-from-external-repo', + created_at: faker.date.past().toISOString(), + last_used: null, + token: 'trigger-token', + updated_at: faker.date.past().toISOString(), + owner: null, + repoId: 1, + ...overrides, + } satisfies PipelineTriggerTokenSchema +} + +export function makeOffsetPagination(overrides: Partial = {}) { + return { + total: 1, + next: null, + current: 1, + previous: null, + perPage: 20, + totalPages: 1, + ...overrides, + } satisfies OffsetPagination +} + +export function makeAccessTokenSchema(overrides: Partial = {}) { + const isoDate = faker.date.past().toISOString() + return { + id: 1, + user_id: 1, + name: 'token', + expires_at: isoDate, + active: true, + created_at: isoDate, + revoked: false, + access_level: AccessLevel.DEVELOPER, + ...overrides, + } satisfies AccessTokenSchema +} + +export function makeAccessTokenExposedSchema(overrides: Partial = {}) { + return { + ...makeAccessTokenSchema(), + token: 'secret-token', + ...overrides, + } satisfies AccessTokenExposedSchema +} + +export function makeRepositoryFileExpandedSchema(overrides: Partial = {}) { + return { + file_name: 'file.txt', + file_path: 'file.txt', + size: 7, + encoding: 'base64', + content: 'content', + content_sha256: 'sha256', + ref: 'main', + blob_id: 'blob', + commit_id: 'commit', + last_commit_id: 'last-commit', + ...overrides, + } satisfies RepositoryFileExpandedSchema +} + +export function makeRepositoryTreeSchema(overrides: Partial = {}) { + return { + id: 'id', + name: 'file.txt', + type: 'blob', + path: 'file.txt', + mode: '100644', + ...overrides, + } satisfies RepositoryTreeSchema +} + +export function makeGitbeakerRequestError(params: { message?: string, status?: number, statusText?: string, description: string }) { + const request = new Request('https://gitlab.internal.example/api') + const response = new Response(null, { status: params.status ?? 404, statusText: params.statusText ?? 'Not Found' }) + return new GitbeakerRequestError(params.message ?? params.statusText ?? 'Error', { + cause: { + description: params.description, + request, + response, + }, + }) +} + +export function makeCommitAction(overrides: Partial = {}) { + return { + action: 'create', + filePath: 'file.txt', + content: 'content', + ...overrides, + } satisfies CommitAction +} + +export function makeAdminPlugin(overrides: { value?: string, pluginName?: string, key?: string } = {}) { + return { + pluginName: overrides.pluginName ?? PLUGIN_NAME, + key: overrides.key ?? 'token', + value: overrides.value ?? faker.string.alphanumeric(20), + } +} + +export function makeAdminRole(overrides: { id?: string, oidcGroup?: string } = {}) { + return { + id: overrides.id ?? faker.string.uuid(), + name: faker.word.words(), + permissions: 0n, + position: 0, + oidcGroup: overrides.oidcGroup ?? faker.word.words(), + type: 'managed', + } satisfies AdminRole +} + +export function makeUser(overrides: { id?: string, email?: string, firstName?: string, lastName?: string } = {}) { + return { + id: overrides.id ?? faker.string.uuid(), + email: overrides.email ?? faker.internet.email(), + firstName: overrides.firstName ?? faker.person.firstName(), + lastName: overrides.lastName ?? faker.person.lastName(), + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: null, + adminRoleIds: [], + type: 'human' as const, + } satisfies User +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts new file mode 100644 index 0000000000..d4d331cd2e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts @@ -0,0 +1,34 @@ +// GitLab plugin constants +export const PLUGIN_NAME = 'gitlab' + +// Infrastructure integration +export const INFRA_GROUP_NAME = 'Infra' +export const INFRA_GROUP_PATH = 'infra' +export const INFRA_APPS_REPO_NAME = 'infra-apps' +export const MIRROR_REPO_NAME = 'mirror' + +// Managed resources sentinel +export const TOPIC_PLUGIN_MANAGED = 'plugin-managed' +export const TOKEN_DESCRIPTION = 'mirroring-from-external-repo' + +// Default group paths for console roles +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly,/console/security' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin,/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly,/console/security' + +// Plugin configuration keys +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const AUDITOR_GROUP_PATH_PLUGIN_KEY = 'auditorGroupPath' +export const PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReporterGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDeveloperGroupPathSuffix' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectMaintainerGroupPathSuffix' +export const PURGE_PLUGIN_KEY = 'purge' + +// Custom attribute keys used in GitLab groups +export const GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY = 'cpn_projects_root_dir' +export const INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY = 'cpn_infra_group' +export const PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY = 'cpn_project_slug' +export const USER_ID_CUSTOM_ATTRIBUTE_KEY = 'cpn_user_id' +export const MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY = 'cpn_managed_by_console' diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts new file mode 100644 index 0000000000..3bb17e6b57 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts @@ -0,0 +1,19 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface GitlabConfig { + token?: string + url: string + internalUrl: string + mirrorTokenExpirationDays: number + mirrorTokenRotationThresholdDays: number + projectRootDir: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface GitlabModuleOptions extends GitlabConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('gitlab') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as GITLAB_CONFIG } diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts new file mode 100644 index 0000000000..f08dc6f312 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts @@ -0,0 +1,33 @@ +import type { GitlabConfig } from './gitlab.module-definition' +import { Gitlab } from '@gitbeaker/rest' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { GitlabHealthService } from './gitlab-health.service' +import { GitlabPluginService } from './gitlab-plugin.service' +import { ConfigurableModuleClass, GITLAB_CONFIG } from './gitlab.module-definition' +import { GitlabService } from './gitlab.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule], + providers: [ + { + provide: GITLAB_REST_CLIENT, + inject: [GITLAB_CONFIG], + useFactory: (config: GitlabConfig) => new Gitlab({ + token: config.token, + host: config.internalOrPublicUrl, + }), + }, + GitlabClientService, + GitlabDatastoreService, + GitlabHealthService, + GitlabPluginService, + GitlabService, + ], + exports: [GitlabClientService, GitlabHealthService, GitlabPluginService, GitlabService], +}) +export class GitlabModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts new file mode 100644 index 0000000000..da46c5fe4f --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -0,0 +1,492 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { AccessLevel } from '@gitbeaker/core' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { VaultClientService } from '../vault/vault-client.service' +import { GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { makeAccessTokenExposedSchema, makeExpandedUserSchema, makeGroupSchema, makeMemberSchema, makePipelineTriggerToken, makeProjectSchema, makeProjectWithDetails } from './gitlab-testing.utils' +import { PLUGIN_NAME, TOPIC_PLUGIN_MANAGED } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { GitlabService } from './gitlab.service' + +describe('gitlabService', () => { + let service: GitlabService + let gitlab: DeepMockProxy + let vault: DeepMockProxy + let datastore: DeepMockProxy + + beforeEach(async () => { + gitlab = mockDeep() + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + getAdminRolesByOidcGroups: vi.fn().mockResolvedValue([]), + }) + vault = mockDeep({ + writeGitlabMirrorCreds: vi.fn().mockResolvedValue(undefined), + deleteGitlabMirrorCreds: vi.fn().mockResolvedValue(undefined), + writeTechReadOnlyCreds: vi.fn().mockResolvedValue(undefined), + writeMirrorTriggerToken: vi.fn().mockResolvedValue(undefined), + readTechnReadOnlyCreds: vi.fn().mockResolvedValue(null), + readGitlabMirrorCreds: vi.fn().mockResolvedValue(null), + }) + const config = mockDeep({ projectRootDir: 'forge' }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + GitlabService, + { provide: GitlabClientService, useValue: gitlab }, + { provide: GitlabDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(GitlabService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('should reconcile project members and repositories', async () => { + const project = makeProjectWithDetails() + const group = makeGroupSchema({ + id: 123, + full_path: 'forge/console/project-1', + full_name: 'forge/console/project-1', + name: 'project-1', + path: 'project-1', + parent_id: 1, + }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectGroupRepo.mockResolvedValue(makeProjectSchema({ id: 1 })) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/repo') + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + gitlab.upsertUser.mockResolvedValue(makeExpandedUserSchema({ id: 123, username: 'user' })) + + await service.handleUpsert(project) + + expect(gitlab.getOrCreateProjectSubGroup).toHaveBeenCalledWith(project.slug) + expect(gitlab.getGroupMembers).toHaveBeenCalledWith(group) + expect(gitlab.getRepos).toHaveBeenCalledWith(project.slug) + }) + + it('should remove orphan member if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 999, username: 'orphan' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).toHaveBeenCalledWith(group, 999) + }) + + it('should not remove managed user (bot) even if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 888, username: 'group_123_bot' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).not.toHaveBeenCalled() + }) + + it('should not remove orphan member if purge disabled', async () => { + const project = makeProjectWithDetails() + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 999, username: 'orphan' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).not.toHaveBeenCalled() + }) + + it('should delete orphan repositories if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepo = makeProjectSchema({ name: 'orphan-repo', topics: [TOPIC_PLUGIN_MANAGED] }) + const unmanagedRepo = makeProjectSchema({ name: 'unmanaged-repo', topics: [] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepo + yield unmanagedRepo + })()) + gitlab.deleteProjectGroupRepo.mockResolvedValue(undefined) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).toHaveBeenCalledWith(project.slug, 'orphan-repo') + expect(gitlab.deleteProjectGroupRepo).toHaveBeenCalledTimes(1) + }) + + it('should not delete orphan repositories if purge disabled', async () => { + const project = makeProjectWithDetails({ + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepo = makeProjectSchema({ name: 'orphan-repo', topics: [TOPIC_PLUGIN_MANAGED] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepo + })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).not.toHaveBeenCalled() + }) + + it('should not delete orphan repositories without the correct topic even if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepoWithoutTopic = makeProjectSchema({ name: 'orphan-repo', topics: [] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepoWithoutTopic + })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).not.toHaveBeenCalled() + }) + + it('should create gitlab user if not exists', async () => { + const project = makeProjectWithDetails({ + owner: { id: 'o1', email: 'owner@example.com', firstName: 'Owner', lastName: 'User', adminRoleIds: [] }, + members: [{ user: { id: 'u1', email: 'new@example.com', firstName: 'New', lastName: 'User', adminRoleIds: [] }, roleIds: [] }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'new@example.com' ? 999 : 998, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'new@example.com' }), + expect.objectContaining({ cpnUserId: 'u1' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'owner@example.com' }), + expect.objectContaining({ cpnUserId: 'o1' }), + ) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 999, AccessLevel.GUEST) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 998, AccessLevel.OWNER) + }) + + it('should map roles to access levels and apply highest level', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-reporter', oidcGroup: '/project-1/console/readonly' }, + { id: 'r-developer', oidcGroup: '/project-1/console/developer' }, + { id: 'r-devops', oidcGroup: '/project-1/console/devops' }, + { id: 'r-maintainer', oidcGroup: '/project-1/console/admin' }, + { id: 'r-unknown', oidcGroup: '/other/group' }, + ], + members: [ + { user: { id: 'u1', email: 'reporter@example.com', firstName: 'Rep', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter'] }, + { user: { id: 'u2', email: 'developer@example.com', firstName: 'Dev', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-developer'] }, + { user: { id: 'u3', email: 'devops@example.com', firstName: 'Ops', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-devops'] }, + { user: { id: 'u4', email: 'maintainer@example.com', firstName: 'Main', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-maintainer'] }, + { user: { id: 'u5', email: 'mixed@example.com', firstName: 'Mixed', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter', 'r-developer'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + const idByEmail: Record = { + 'reporter@example.com': 101, + 'developer@example.com': 102, + 'devops@example.com': 103, + 'maintainer@example.com': 104, + 'mixed@example.com': 105, + 'owner@example.com': 100, + } + return makeExpandedUserSchema({ + id: idByEmail[user.email] ?? 999, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 101, AccessLevel.REPORTER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 102, AccessLevel.DEVELOPER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 103, AccessLevel.MAINTAINER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 104, AccessLevel.MAINTAINER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.DEVELOPER) + }) + + it('should prioritize higher access level when oidc group appears in multiple paths', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-devops', oidcGroup: '/project-1/console/devops' }, + ], + members: [ + { user: { id: 'u1', email: 'devops@example.com', firstName: 'Ops', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-devops'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'devops@example.com' ? 101 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 101, AccessLevel.MAINTAINER) + }) + + it('should map security project role to reporter access level', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-security', oidcGroup: '/project-1/console/security' }, + ], + members: [ + { user: { id: 'u1', email: 'security@example.com', firstName: 'Sec', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-security'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'security@example.com' ? 105 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.REPORTER) + }) + + it('should downgrade existing member to guest when no role maps to an access level', async () => { + const project = makeProjectWithDetails({ + roles: [{ id: 'r-unknown', oidcGroup: '/other/group' }], + members: [{ user: { id: 'u1', email: 'no-access@example.com', firstName: 'No', lastName: 'Access', adminRoleIds: [] }, roleIds: ['r-unknown'] }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 105, username: 'no-access', access_level: AccessLevel.REPORTER })]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'no-access@example.com' ? 105 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.editGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.GUEST) + expect(gitlab.removeGroupMember).not.toHaveBeenCalledWith(group, 105) + }) + + it('should bind builtin roles (admin/auditor) when role ids are resolved', async () => { + const project = makeProjectWithDetails({ + owner: { id: 'o1', email: 'owner@example.com', firstName: 'Owner', lastName: 'User', adminRoleIds: ['admin-role-id'] }, + members: [ + { user: { id: 'u1', email: 'admin@example.com', firstName: 'Admin', lastName: 'User', adminRoleIds: ['admin-role-id'] }, roleIds: [] }, + { user: { id: 'u2', email: 'auditor@example.com', firstName: 'Auditor', lastName: 'User', adminRoleIds: ['auditor-role-id'] }, roleIds: [] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + datastore.getAdminPluginConfig.mockImplementation(async (_pluginName: string, key: string) => { + if (key === 'adminGroupPath') return '/console/admin' + if (key === 'auditorGroupPath') return '/console/readonly' + return null + }) + datastore.getAdminRolesByOidcGroups.mockResolvedValue([ + { id: 'admin-role-id', oidcGroup: '/console/admin' }, + { id: 'auditor-role-id', oidcGroup: '/console/readonly' }, + ]) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: faker.number.int(), + email: user.email, + username: user.email.split('@')[0], + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'admin@example.com', admin: true, auditor: false }), + expect.objectContaining({ cpnUserId: 'u1' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'auditor@example.com', admin: false, auditor: true }), + expect.objectContaining({ cpnUserId: 'u2' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'owner@example.com', admin: true, auditor: false }), + expect.objectContaining({ cpnUserId: 'o1' }), + ) + }) + + it('should configure repository mirroring if external url is present', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + repositories: [{ + id: 'r1', + internalRepoName: 'repo-1', + externalRepoUrl: 'https://github.com/org/repo.git', + isPrivate: true, + externalUserName: 'user', + isInfra: false, + }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const gitlabRepo = makeProjectSchema({ id: 101, name: 'repo-1', path: 'repo-1', path_with_namespace: 'forge/console/project-1/repo-1' }) + const accessToken = makeAccessTokenExposedSchema({ + name: 'bot', + scopes: ['read_api'], + access_level: 40, + }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { yield gitlabRepo })()) + gitlab.getOrCreateProjectGroupInternalRepoUrl.mockResolvedValue('https://gitlab.internal/group/repo-1.git') + gitlab.createMirrorAccessToken.mockResolvedValue(accessToken) + vault.readTechnReadOnlyCreds.mockResolvedValue(null) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.createMirrorAccessToken).toHaveBeenCalledWith('project-1') + expect(gitlab.upsertProjectMirrorRepo).toHaveBeenCalledWith('project-1') + + expect(vault.writeGitlabMirrorCreds).toHaveBeenCalledWith( + 'project-1', + 'repo-1', + expect.objectContaining({ + GIT_INPUT_URL: 'github.com/org/repo.git', + GIT_OUTPUT_USER: 'bot', + GIT_OUTPUT_PASSWORD: accessToken.token, + }), + ) + expect(vault.writeTechReadOnlyCreds).toHaveBeenCalledWith('project-1', { + MIRROR_USER: 'bot', + MIRROR_TOKEN: accessToken.token, + }) + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects', async () => { + const projects = [makeProjectWithDetails({ id: 'p1', slug: 'project-1' })] + datastore.getAllProjects.mockResolvedValue(projects) + + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(gitlab.getOrCreateProjectSubGroup).toHaveBeenCalledWith('project-1') + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts new file mode 100644 index 0000000000..fd214154c1 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -0,0 +1,528 @@ +import type { CondensedGroupSchema, MemberSchema, ProjectSchema } from '@gitbeaker/core' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultSecret } from '../vault/vault-client.service' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import type { GitlabConfig } from './gitlab.module-definition' +import { specificallyEnabled } from '@cpn-console/hooks' +import { AccessLevel } from '@gitbeaker/core' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { getAll } from '../../utils/iterable.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + AUDITOR_GROUP_PATH_PLUGIN_KEY, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + INFRA_APPS_REPO_NAME, + PLUGIN_NAME, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PURGE_PLUGIN_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { + adminRoleFlag, + daysAgoFromNow, + generateAccessLevelMapping, + generateAdminRoleMapping, + generateName, + generateProjectRoleGroupPath, + generateUsername, + generateUsernameCandidates, + getProjectPluginConfig, + isOwnedRepo, + isOwnedUser, + isSystemRepo, +} from './gitlab.utils' + +type ProjectAccessLevel = Exclude + +@Injectable() +export class GitlabService { + private readonly logger = new Logger(GitlabService.name) + + constructor( + @Inject(GitlabDatastoreService) private readonly datastore: GitlabDatastoreService, + @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(GITLAB_CONFIG) private readonly gitlabConfig: GitlabConfig, + ) { + this.logger.log('GitLabService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('gitlab', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`GitLab sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('gitlab', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`GitLab sync completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + span?.setAttribute('gitlab.projects.count', 0) + this.logger.log('Starting GitLab reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('gitlab.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for GitLab reconciliation`) + await this.ensureProjectGroups(projects) + this.logger.log(`GitLab reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('gitlab.projects.count', projects.length) + this.logger.verbose(`Reconciling GitLab project groups (${projects.length})`) + await Promise.all(projects.map(p => this.ensureProjectGroup(p))) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling GitLab project group (${project.slug})`) + const group = await this.gitlab.getOrCreateProjectSubGroup(project.slug) + const members = await this.gitlab.getGroupMembers(group) + this.logger.verbose(`Loaded GitLab project group state (${project.slug}): groupId=${group.id} members=${members.length}`) + await this.ensureProjectGroupMembers(project, group, members) + await this.ensureProjectRepos(project) + await this.purgeOrphanRepos(project) + await this.ensureSystemRepos(project) + this.logger.verbose(`GitLab project group reconciled (${project.slug})`) + } + + @StartActiveSpan() + private async ensureProjectGroupMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling GitLab group members for project ${project.slug} (groupId=${group.id}, members=${members.length})`) + const { adminRoleId, auditorRoleId } = await this.getAdminRoleIds(project) + await this.addMissingMembers(project, group, members, adminRoleId, auditorRoleId) + await this.addMissingOwnerMember(project, group, members, adminRoleId, auditorRoleId) + await this.purgeOrphanMembers(project, group, members) + } + + private async addMissingMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + adminRoleId?: string, + auditorRoleId?: string, + ) { + const membersById = new Map(members.map(m => [m.id, m])) + const groupPaths = await this.getProjectRoleGroupPaths(project) + const accessLevelByUserId = generateAccessLevelMapping(project, groupPaths) + + await Promise.all(project.members.map(async ({ user }) => { + const gitlabUser = await this.gitlab.upsertUser({ + email: user.email, + username: generateUsername(user.email), + name: generateName(user.firstName, user.lastName), + admin: adminRoleFlag(user, adminRoleId), + auditor: adminRoleFlag(user, auditorRoleId), + }, { + cpnUserId: user.id, + }) + if (!gitlabUser) { + this.logger.warn(`Unable to resolve a GitLab user for a project member (project=${project.slug}, userId=${user.id}, email=${user.email})`) + return + } + const accessLevel = accessLevelByUserId.get(user.id) ?? AccessLevel.GUEST + await this.ensureGroupMemberAccessLevel(group, gitlabUser.id, accessLevel, membersById) + })) + } + + private async ensureGroupMemberAccessLevel( + group: CondensedGroupSchema, + gitlabUserId: number, + accessLevel: ProjectAccessLevel, + membersById: Map, + ) { + const existingMember = membersById.get(gitlabUserId) + + if (accessLevel === AccessLevel.NO_ACCESS) { + if (existingMember) { + await this.gitlab.removeGroupMember(group, gitlabUserId) + } + return + } + + if (!existingMember) { + await this.gitlab.addGroupMember(group, gitlabUserId, accessLevel) + return + } + + if (existingMember.access_level !== accessLevel) { + await this.gitlab.editGroupMember(group, gitlabUserId, accessLevel) + } + } + + private async addMissingOwnerMember( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + adminRoleId?: string, + auditorRoleId?: string, + ) { + const gitlabUser = await this.gitlab.upsertUser({ + email: project.owner.email, + username: generateUsername(project.owner.email), + name: generateName(project.owner.firstName, project.owner.lastName), + admin: adminRoleFlag(project.owner, adminRoleId), + auditor: adminRoleFlag(project.owner, auditorRoleId), + }, { + cpnUserId: project.owner.id, + }) + if (!gitlabUser) { + this.logger.warn(`Unable to resolve the GitLab owner account (project=${project.slug}, ownerId=${project.owner.id}, email=${project.owner.email})`) + return + } + const membersById = new Map(members.map(m => [m.id, m])) + await this.ensureGroupMemberAccessLevel(group, gitlabUser.id, AccessLevel.OWNER, membersById) + } + + private async getAdminRoleIds(project: ProjectWithDetails): Promise<{ adminRoleId?: string, auditorRoleId?: string }> { + const adminGroupPath = await this.getAdminGroupPath(project) + const auditorGroupPath = await this.getAuditorGroupPath(project) + const roles = await this.datastore.getAdminRolesByOidcGroups([adminGroupPath, auditorGroupPath]) + return generateAdminRoleMapping(roles, adminGroupPath, auditorGroupPath) + } + + private async getAdminGroupPath(project: ProjectWithDetails): Promise { + return await this.getAdminOrProjectPluginConfig(project, ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getAuditorGroupPath(project: ProjectWithDetails): Promise { + return await this.getAdminOrProjectPluginConfig(project, AUDITOR_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_AUDITOR_GROUP_PATH + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + if (!project) return undefined + return getProjectPluginConfig(project, key) ?? undefined + } + + private async getProjectRoleGroupPaths(project: ProjectWithDetails): Promise<{ reporter: string[], developer: string[], maintainer: string[] }> { + const [reporter, developer, maintainer] = await Promise.all([ + this.getProjectReporterGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectMaintainerGroupPaths(project), + ]) + + return { + reporter, + developer, + maintainer, + } + } + + private async getProjectReporterGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectMaintainerGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + @StartActiveSpan() + private async purgeOrphanMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'group.id': group.id, + 'members.total': members.length, + }) + const purgeConfig = getProjectPluginConfig(project, PURGE_PLUGIN_KEY) + const usernames = new Set([ + ...generateUsernameCandidates(project.owner.email), + ...project.members.flatMap(m => generateUsernameCandidates(m.user.email)), + ]) + const emails = new Set([ + project.owner.email.toLowerCase(), + ...project.members.map(m => m.user.email.toLowerCase()), + ]) + + const orphans = members.filter((member) => { + if (isOwnedUser(member)) return false + if (usernames.has(member.username)) return false + if (member.email && emails.has(member.email.toLowerCase())) return false + return true + }) + span?.setAttribute('orphans.count', orphans.length) + + if (specificallyEnabled(purgeConfig)) { + span?.setAttribute('purge.enabled', true) + let removedCount = 0 + await Promise.all(orphans.map(async (orphan) => { + await this.gitlab.removeGroupMember(group, orphan.id) + removedCount++ + this.logger.log(`Removed a user from the GitLab group (groupId=${group.id}, username=${orphan.username})`) + })) + span?.setAttribute('orphans.removed.count', removedCount) + } else { + span?.setAttribute('purge.enabled', false) + let warnedCount = 0 + for (const orphan of orphans) { + warnedCount++ + this.logger.warn(`User is in the GitLab group but not in the project (purge disabled, username=${orphan.username})`) + } + span?.setAttribute('orphans.warned.count', warnedCount) + } + } + + @StartActiveSpan() + private async ensureProjectRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'repositories.count': project.repositories.length, + }) + const gitlabRepositories = await getAll(this.gitlab.getRepos(project.slug)) + span?.setAttribute('gitlab.repositories.count', gitlabRepositories.length) + let mirroringEnabledCount = 0 + let mirroringDisabledCount = 0 + for (const repo of project.repositories) { + const externalHost = this.getExternalRepoHost(repo.externalRepoUrl) + span?.addEvent('gitlab.repo.reconcile', { + 'repository.name': repo.internalRepoName, + 'repository.isPrivate': repo.isPrivate, + ...(externalHost ? { 'repository.external.host': externalHost } : {}), + 'repository.external': !!repo.externalRepoUrl, + }) + await this.ensureRepository(project, repo, gitlabRepositories) + + if (repo.externalRepoUrl) { + span?.setAttribute('repository.mirroring', true) + mirroringEnabledCount++ + await this.configureRepositoryMirroring(project, repo) + } else { + span?.setAttribute('repository.mirroring', false) + mirroringDisabledCount++ + await this.vault.deleteGitlabMirrorCreds(project.slug, repo.internalRepoName) + } + } + span?.setAttribute('repositories.mirroring.enabled.count', mirroringEnabledCount) + span?.setAttribute('repositories.mirroring.disabled.count', mirroringDisabledCount) + } + + @StartActiveSpan() + private async purgeOrphanRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const gitlabRepositories = await getAll(this.gitlab.getRepos(project.slug)) + span?.setAttribute('gitlab.repositories.count', gitlabRepositories.length) + + const orphanRepos = gitlabRepositories.filter(r => isOwnedRepo(r) && !isSystemRepo(project, r)) + span?.setAttribute('orphan.repositories.count', orphanRepos.length) + + if (specificallyEnabled(getProjectPluginConfig(project, PURGE_PLUGIN_KEY))) { + span?.setAttribute('purge.enabled', true) + let removedCount = 0 + await Promise.all(orphanRepos.map(async (orphan) => { + await this.gitlab.deleteProjectGroupRepo(project.slug, orphan.name) + removedCount++ + this.logger.log(`Removed a repository from the GitLab project (project=${project.slug}, repoName=${orphan.name})`) + })) + + span?.setAttribute('orphan.repositories.removed.count', removedCount) + } else { + span?.setAttribute('purge.enabled', false) + let warnedCount = 0 + for (const orphan of orphanRepos) { + warnedCount++ + this.logger.warn(`Repository is in GitLab but not in the project definition (purge disabled, project=${project.slug}, repoName=${orphan.name})`) + } + span?.setAttribute('managed.repositories.warned.count', warnedCount) + } + } + + private async ensureRepository( + project: ProjectWithDetails, + repo: ProjectWithDetails['repositories'][number], + gitlabRepositories: ProjectSchema[], + ) { + return gitlabRepositories.find(r => r.name === repo.internalRepoName) + ?? await this.gitlab.upsertProjectGroupRepo( + project.slug, + repo.internalRepoName, + ) + } + + @StartActiveSpan() + private async configureRepositoryMirroring( + project: ProjectWithDetails, + repo: ProjectWithDetails['repositories'][number], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('repository.internalRepoName', repo.internalRepoName) + span?.setAttribute('repository.isPrivate', repo.isPrivate) + const externalHost = this.getExternalRepoHost(repo.externalRepoUrl) + if (externalHost) span?.setAttribute('repository.external.host', externalHost) + + const currentVaultSecret = await this.vault.readGitlabMirrorCreds(project.slug, repo.internalRepoName) + span?.setAttribute('vault.secret.exists', !!currentVaultSecret) + if (!currentVaultSecret) { + this.logger.warn(`No existing mirror credentials found in Vault; rotating new credentials (project=${project.slug}, repoName=${repo.internalRepoName})`) + } + + const internalRepoUrl = await this.gitlab.getOrCreateProjectGroupInternalRepoUrl(project.slug, repo.internalRepoName) + const externalRepoUrn = repo.externalRepoUrl.split('://')[1] + const internalRepoUrn = internalRepoUrl.split('://')[1] + span?.setAttribute('repository.externalRepoUrn', externalRepoUrn) + span?.setAttribute('repository.internalRepoUrn', internalRepoUrn) + + const projectMirrorCreds = await this.getOrRotateMirrorCreds(project.slug) + + const mirrorSecretData = { + GIT_INPUT_URL: externalRepoUrn, + GIT_INPUT_USER: repo.isPrivate ? repo.externalUserName : undefined, + GIT_INPUT_PASSWORD: currentVaultSecret?.data?.GIT_INPUT_PASSWORD, // Preserve existing password as it's not in DB + GIT_OUTPUT_URL: internalRepoUrn, + GIT_OUTPUT_USER: projectMirrorCreds.MIRROR_USER, + GIT_OUTPUT_PASSWORD: projectMirrorCreds.MIRROR_TOKEN, + } + + // Write to vault if changed + // Using simplified check + await this.vault.writeGitlabMirrorCreds(project.slug, repo.internalRepoName, mirrorSecretData) + span?.setAttribute('vault.secret.written', true) + } + + @StartActiveSpan() + private async ensureSystemRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + await Promise.all([ + this.ensureInfraAppsRepo(project.slug), + this.ensureMirrorRepo(project.slug), + ]) + } + + private async ensureInfraAppsRepo(projectSlug: string) { + await this.gitlab.upsertProjectGroupRepo(projectSlug, INFRA_APPS_REPO_NAME) + } + + private async ensureMirrorRepo(projectSlug: string) { + const mirrorRepo = await this.gitlab.upsertProjectMirrorRepo(projectSlug) + if (mirrorRepo.empty_repo) { + await this.gitlab.commitMirror(mirrorRepo.id) + } + await this.ensureMirrorRepoTriggerToken(projectSlug) + } + + @StartActiveSpan() + private async ensureMirrorRepoTriggerToken(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const triggerToken = await this.gitlab.getOrCreateMirrorPipelineTriggerToken(projectSlug) + const gitlabSecret = { + PROJECT_SLUG: projectSlug, + GIT_MIRROR_PROJECT_ID: triggerToken.repoId, + GIT_MIRROR_TOKEN: triggerToken.token, + } + await this.vault.writeMirrorTriggerToken(gitlabSecret) + span?.setAttribute('vault.secret.written', true) + } + + @StartActiveSpan() + private async getOrRotateMirrorCreds(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const vaultSecret = await this.vault.readTechnReadOnlyCreds(projectSlug) + if (!vaultSecret) return this.createMirrorAccessToken(projectSlug) + + const isExpiring = this.isMirrorCredsExpiring(vaultSecret) + span?.setAttribute('mirror.creds.expiring', isExpiring) + if (!isExpiring) { + span?.setAttribute('mirror.creds.rotated', false) + return vaultSecret.data as { MIRROR_USER: string, MIRROR_TOKEN: string } + } + return this.createMirrorAccessToken(projectSlug) + } + + @StartActiveSpan() + private async createMirrorAccessToken(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('mirror.creds.rotated', true) + const token = await this.gitlab.createMirrorAccessToken(projectSlug) + const creds = { + MIRROR_USER: token.name, + MIRROR_TOKEN: token.token, + } + await this.vault.writeTechReadOnlyCreds(projectSlug, creds) + span?.setAttribute('vault.secret.written', true) + return creds + } + + private isMirrorCredsExpiring(vaultSecret: VaultSecret): boolean { + if (!vaultSecret?.metadata?.created_time) return false + const createdTime = new Date(vaultSecret.metadata.created_time) + return daysAgoFromNow(createdTime) > this.gitlabConfig.mirrorTokenRotationThresholdDays + } + + private getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { + if (!externalRepoUrl) return undefined + try { + return new URL(externalRepoUrl).host + } catch { + return undefined + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts new file mode 100644 index 0000000000..63f574bd3c --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts @@ -0,0 +1,233 @@ +import type { MemberSchema, ProjectSchema } from '@gitbeaker/core' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { createHash } from 'node:crypto' +import { AccessLevel } from '@gitbeaker/core' +import { stringify } from 'yaml' +import { TOPIC_PLUGIN_MANAGED } from './gitlab.constants' + +export type ProjectAccessLevel = Exclude + +export function getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { + if (!externalRepoUrl) return undefined + try { + return new URL(externalRepoUrl).host + } catch { + return undefined + } +} + +export function hasFileContentChanged(file: { content_sha256?: string } | null | undefined, content: string): boolean { + return file?.content_sha256 !== digestContent(content) +} + +export function digestContent(content: string): string { + return createHash('sha256').update(content).digest('hex') +} + +export function generateUsername(email: string): string { + const localPart = email.split('@')[0] + return localPart.replaceAll(/[^\w-]/g, '') +} + +export function generateUsernameCandidates(email: string): string[] { + const username = generateUsername(email) + return [username, `${username}_1`, `${username}_2`, `${username}_3`] +} + +export function generateName(firstName: string | null, lastName: string | null): string { + const parts = [firstName, lastName].filter(Boolean) + return parts.length > 0 ? parts.join(' ') : 'User' +} + +export function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string): string[] { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${projectSlug}${path}`) +} + +export function generateAdminRoleMapping( + roles: ProjectWithDetails['roles'], + adminGroupPath: string, + auditorGroupPath: string, +): { adminRoleId?: string, auditorRoleId?: string } { + const roleIdByOidcGroup = new Map(roles.map(r => [r.oidcGroup, r.id] as [string | null, string])) + return { + adminRoleId: roleIdByOidcGroup.get(adminGroupPath), + auditorRoleId: roleIdByOidcGroup.get(auditorGroupPath), + } +} + +export function generateAccessLevelMapping( + project: ProjectWithDetails, + groupPaths: { reporter: string[], developer: string[], maintainer: string[] }, +): Map { + const getAccessLevelFromOidcGroup = (oidcGroup: string | null): ProjectAccessLevel | null => { + if (!oidcGroup) return null + if (groupPaths.maintainer.includes(oidcGroup)) return AccessLevel.MAINTAINER + if (groupPaths.developer.includes(oidcGroup)) return AccessLevel.DEVELOPER + if (groupPaths.reporter.includes(oidcGroup)) return AccessLevel.REPORTER + return null + } + + const roleAccessLevelById = new Map( + project.roles.map(role => [role.id, getAccessLevelFromOidcGroup(role.oidcGroup)]), + ) + + return project.members.reduce((acc, membership) => { + const highest = membership.roleIds.reduce((highest: ProjectAccessLevel | null, roleId) => { + const level = roleAccessLevelById.get(roleId) + if (level !== null && level !== undefined && (highest === null || level > highest)) return level + return highest + }, null) + acc.set(membership.user.id, highest ?? AccessLevel.GUEST) + return acc + }, new Map()) +} + +export function generateGitlabCIConfigContent() { + return stringify({ + variables: { + PROJECT_NAME: { + description: 'Nom du dépôt (dans ce Gitlab) à synchroniser.', + }, + GIT_BRANCH_DEPLOY: { + description: 'Nom de la branche à synchroniser.', + value: 'main', + }, + SYNC_ALL: { + description: 'Synchroniser toutes les branches.', + value: 'false', + }, + }, + include: [ + { + project: '$CATALOG_PATH', + file: 'mirror.yml', + ref: 'main', + }, + ], + repo_pull_sync: { + extends: '.repo_pull_sync', + only: [ + 'api', + 'triggers', + 'web', + 'schedules', + ], + }, + }) +} + +export function generateMirrorScriptContent() { + return `#!/bin/bash + +set -e + +# Colorize terminal +red='\\e[0;31m' +no_color='\\033[0m' + +# Console step increment +i=1 + +# Default values +BRANCH_TO_SYNC=main + +print_help() { + TEXT_HELPER="\\nThis script aims to send a synchronization request to DSO.\\nFollowing flags are available: + -a Api url to send the synchronization request + -b Branch which is wanted to be synchronize for the given repository (default '$BRANCH_TO_SYNC') + -g GitLab token to trigger the pipeline on the gitlab mirror project + -i Gitlab mirror project id + -r Gitlab repository name to mirror + -h Print script help\\n" + printf "$TEXT_HELPER" +} + +print_args() { + printf "\\nArguments received: + -a API_URL: $API_URL + -b BRANCH_TO_SYNC: $BRANCH_TO_SYNC + -g GITLAB_TRIGGER_TOKEN length: \${#GITLAB_TRIGGER_TOKEN} + -i GITLAB_MIRROR_PROJECT_ID: $GITLAB_MIRROR_PROJECT_ID + -r REPOSITORY_NAME: $REPOSITORY_NAME\\n" +} + +# Parse options +while getopts :ha:b:g:i:r: flag +do + case "\${flag}" in + a) + API_URL=\${OPTARG};; + b) + BRANCH_TO_SYNC=\${OPTARG};; + g) + GITLAB_TRIGGER_TOKEN=\${OPTARG};; + i) + GITLAB_MIRROR_PROJECT_ID=\${OPTARG};; + r) + REPOSITORY_NAME=\${OPTARG};; + h) + printf "\\nHelp requested.\\n" + print_help + printf "\\nExiting.\\n" + exit 0;; + *) + printf "\\nInvalid argument \${OPTARG} (\${flag}).\\n" + print_help + print_args + exit 1;; + esac +done + +# Test if arguments are missing +if [ -z \${API_URL} ] || [ -z \${BRANCH_TO_SYNC} ] || [ -z \${GITLAB_TRIGGER_TOKEN} ] || [ -z \${GITLAB_MIRROR_PROJECT_ID} ] || [ -z \${REPOSITORY_NAME} ]; then + printf "\\nArgument(s) missing!\\n" + print_help + print_args + exit 2 +fi + +# Print arguments +print_args + +# Send synchronization request +printf "\\n\${red}\${i}.\${no_color} Send request to DSO api.\\n\\n" + +curl \\ + -X POST \\ + --fail \\ + -F token=\${GITLAB_TRIGGER_TOKEN} \\ + -F ref=main \\ + -F variables[GIT_BRANCH_DEPLOY]=\${BRANCH_TO_SYNC} \\ + -F variables[PROJECT_NAME]=\${REPOSITORY_NAME} \\ + "\${API_URL}/api/v4/projects/\${GITLAB_MIRROR_PROJECT_ID}/trigger/pipeline" +` +} + +const ownedUserRegex = /group_\d+_bot/u + +export function isOwnedUser(member: MemberSchema) { + return ownedUserRegex.test(member.username) +} + +export function isOwnedRepo(repo: ProjectSchema) { + return repo.topics?.includes(TOPIC_PLUGIN_MANAGED) ?? false +} + +export function isSystemRepo(project: ProjectWithDetails, repo: ProjectSchema) { + return project.repositories.some(r => r.internalRepoName === repo.name) +} + +export function getProjectPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} +export function daysAgoFromNow(date: Date) { + return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) +} + +export function adminRoleFlag(user: ProjectWithDetails['members'][0]['user'], adminRoleId?: string) { + return adminRoleId ? user.adminRoleIds?.includes(adminRoleId) : undefined +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/healthz/healthz.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/healthz/healthz.controller.ts new file mode 100644 index 0000000000..b18ec7aadd --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/healthz/healthz.controller.ts @@ -0,0 +1,14 @@ +import type { HealthzService } from './healthz.service' +import { Controller, Get } from '@nestjs/common' +import { HealthCheck } from '@nestjs/terminus' + +@Controller('api/v1/healthz') +export class HealthzController { + constructor(private readonly healthz: HealthzService) {} + + @Get() + @HealthCheck() + check() { + return this.healthz.check() + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/healthz/healthz.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/healthz/healthz.module.ts new file mode 100644 index 0000000000..2b2cfaa615 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/healthz/healthz.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { ArgoCDModule } from '../argocd/argocd.module' +import { GitlabModule } from '../gitlab/gitlab.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { NexusModule } from '../nexus/nexus.module' +import { OpenCdsModule } from '../opencds/opencds.module' +import { RegistryModule } from '../registry/registry.module' +import { VaultModule } from '../vault/vault.module' +import { HealthzController } from './healthz.controller' +import { HealthzService } from './healthz.service' + +@Module({ + imports: [ + TerminusModule.forRoot(), + ConfigModule.forRoot(), + DatabaseModule, + KeycloakModule, + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + VaultModule, + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(RegistryModule, 'USE_REGISTRY'), + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(OpenCdsModule, 'USE_OPENCDS'), + ], + controllers: [HealthzController], + providers: [HealthzService], +}) +export class HealthzModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts new file mode 100644 index 0000000000..288e80d353 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts @@ -0,0 +1,106 @@ +import { HealthCheckService, TerminusModule } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { OpenCdsHealthService } from '../opencds/opencds-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { VaultHealthService } from '../vault/vault-health.service' +import { HealthzService } from './healthz.service' + +describe('healthzService', () => { + afterEach(() => vi.clearAllMocks()) + + describe('with all optional probes registered', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let keycloak: ReturnType> + let gitlab: ReturnType> + let vault: ReturnType> + let nexus: ReturnType> + let registry: ReturnType> + let argocd: ReturnType> + let opencds: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + keycloak = mockDeep() + gitlab = mockDeep() + vault = mockDeep() + nexus = mockDeep() + registry = mockDeep() + argocd = mockDeep() + opencds = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + { provide: KeycloakHealthService, useValue: keycloak }, + { provide: GitlabHealthService, useValue: gitlab }, + { provide: VaultHealthService, useValue: vault }, + { provide: NexusHealthService, useValue: nexus }, + { provide: RegistryHealthService, useValue: registry }, + { provide: ArgoCDHealthService, useValue: argocd }, + { provide: OpenCdsHealthService, useValue: opencds }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('checks every injected probe, including those wired via @Optional', async () => { + await service.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(keycloak.check).toHaveBeenCalledWith('keycloak') + expect(gitlab.check).toHaveBeenCalledWith('gitlab') + expect(vault.check).toHaveBeenCalledWith('vault') + expect(nexus.check).toHaveBeenCalledWith('nexus') + expect(registry.check).toHaveBeenCalledWith('registry') + expect(argocd.check).toHaveBeenCalledWith('argocd') + expect(opencds.check).toHaveBeenCalledWith('opencds') + }) + }) + + describe('without optional probes registered (@Optional -> undefined)', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('omits probes whose services are not registered', async () => { + await service.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(healthCheck.check).toHaveBeenCalledWith([expect.any(Function)]) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/healthz/healthz.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/healthz/healthz.service.ts new file mode 100644 index 0000000000..67619844e0 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/healthz/healthz.service.ts @@ -0,0 +1,41 @@ +import type { HealthIndicatorFunction } from '@nestjs/terminus' +import { Inject, Injectable, Optional } from '@nestjs/common' +import { HealthCheckService } from '@nestjs/terminus' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { OpenCdsHealthService } from '../opencds/opencds-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { VaultHealthService } from '../vault/vault-health.service' + +@Injectable() +export class HealthzService { + // Optional modules (gated by USE_*) resolve to undefined when not registered. + constructor( + @Inject(HealthCheckService) private readonly health: HealthCheckService, + @Inject(DatabaseHealthService) @Optional() private readonly database?: DatabaseHealthService, + @Inject(KeycloakHealthService) @Optional() private readonly keycloak?: KeycloakHealthService, + @Inject(GitlabHealthService) @Optional() private readonly gitlab?: GitlabHealthService, + @Inject(VaultHealthService) @Optional() private readonly vault?: VaultHealthService, + @Inject(NexusHealthService) @Optional() private readonly nexus?: NexusHealthService, + @Inject(RegistryHealthService) @Optional() private readonly registry?: RegistryHealthService, + @Inject(ArgoCDHealthService) @Optional() private readonly argocd?: ArgoCDHealthService, + @Inject(OpenCdsHealthService) @Optional() private readonly opencds?: OpenCdsHealthService, + ) {} + + check() { + const checks: HealthIndicatorFunction[] = [] + // Each health service's own check() reports 'Not configured' when its URL is absent. + if (this.database) checks.push(() => this.database!.check('database')) + if (this.keycloak) checks.push(() => this.keycloak!.check('keycloak')) + if (this.gitlab) checks.push(() => this.gitlab!.check('gitlab')) + if (this.vault) checks.push(() => this.vault!.check('vault')) + if (this.nexus) checks.push(() => this.nexus!.check('nexus')) + if (this.registry) checks.push(() => this.registry!.check('registry')) + if (this.argocd) checks.push(() => this.argocd!.check('argocd')) + if (this.opencds) checks.push(() => this.opencds!.check('opencds')) + return this.health.check(checks) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts new file mode 100644 index 0000000000..d170ff59ee --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts @@ -0,0 +1,86 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { HttpArgumentsHost } from '@nestjs/common/interfaces' +import type { AdminToken, PersonalAccessToken, User } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from './auth-user.decorator' +import { faker } from '@faker-js/faker' +import { mockDeep } from 'vitest-mock-extended' + +export function makeAuthRequest(headers: FastifyRequest['headers'] = {}): FastifyRequest { + const request = mockDeep() + request.headers = headers + return request +} + +export function makeExecutionContext(headers: FastifyRequest['headers'] = {}): DeepMockProxy { + const context = mockDeep() + const httpArgumentsHost = mockDeep() + const request = mockDeep() + + request.headers = headers + httpArgumentsHost.getRequest.mockReturnValue(request) + context.switchToHttp.mockReturnValue(httpArgumentsHost) + + return context +} + +export function makePersonalAccessToken(overrides: { + id?: string + userId?: string + adminRoleIds?: string[] + type?: User['type'] + status?: PersonalAccessToken['status'] + expirationDate?: PersonalAccessToken['expirationDate'] + lastUse?: PersonalAccessToken['lastUse'] +} = {}): PersonalAccessToken & { owner: { id: string, adminRoleIds: string[], type: string } } { + const id = overrides.id ?? 'pat-id' + const ownerId = overrides.userId ?? 'owner-id' + return { + id, + name: 'test-token', + status: overrides.status ?? 'active', + expirationDate: overrides.expirationDate ?? faker.date.future(), + lastUse: overrides.lastUse ?? null, + createdAt: faker.date.past(), + hash: 'hash', + userId: ownerId, + owner: { + id: ownerId, + adminRoleIds: overrides.adminRoleIds ?? [], + type: overrides.type ?? 'human', + }, + } +} + +export function makeAdminToken(overrides: { + id?: string + userId?: string + adminRoleIds?: string[] + type?: User['type'] + status?: AdminToken['status'] + permissions?: bigint + expirationDate?: AdminToken['expirationDate'] + lastUse?: AdminToken['lastUse'] +} = {}): AdminToken & { owner: { id: string, adminRoleIds: string[], type: string } } { + const id = overrides.id ?? 'admin-token-id' + const ownerId = overrides.userId ?? 'owner-id' + const type = overrides.type ?? 'human' + + return { + id, + name: 'admin-token', + status: overrides.status ?? 'active', + expirationDate: overrides.expirationDate ?? null, + permissions: overrides.permissions ?? 256n, + lastUse: overrides.lastUse ?? null, + createdAt: faker.date.past(), + hash: 'hash', + userId: ownerId, + owner: { + id: ownerId, + adminRoleIds: overrides.adminRoleIds ?? [], + type, + }, + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts new file mode 100644 index 0000000000..f018ac647e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts @@ -0,0 +1,21 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { User } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import { createParamDecorator } from '@nestjs/common' + +export interface UserContext { + userId: string + adminPermissions?: bigint + userType?: User['type'] +} + +export const AuthUser = createParamDecorator( + (_: unknown, ctx: ExecutionContext): UserContext => { + const request = ctx.switchToHttp().getRequest() + return { + userId: request.userId, + adminPermissions: request.adminPermissions, + userType: request.userType, + } + }, +) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts new file mode 100644 index 0000000000..23f7129d2e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../config/keycloak.config' +import { AuthService } from './auth.service' +import { DsoTokenModule } from './dso-token/dso-token.module' +import { KeycloakJwtModule } from './keycloak-jwt/keycloak-jwt.module' + +@Module({ + imports: [ConfigModule.forFeature(keycloakConfigFactory), DsoTokenModule, KeycloakJwtModule], + providers: [ + AuthService, + ], + exports: [ + AuthService, + KeycloakJwtModule, + ], +}) +export class AuthModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts new file mode 100644 index 0000000000..aa0fe99424 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts @@ -0,0 +1,59 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeAuthRequest } from './auth-testing.utils' +import { AuthService } from './auth.service' +import { DsoTokenService } from './dso-token/dso-token.service' +import { KeycloakJwtService } from './keycloak-jwt/keycloak-jwt.service' + +describe('authService', () => { + let module: TestingModule + let service: AuthService + let dsoTokenService: DeepMockProxy + let keycloakJwtService: DeepMockProxy + + beforeEach(async () => { + dsoTokenService = mockDeep() + keycloakJwtService = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: DsoTokenService, useValue: dsoTokenService }, + { provide: KeycloakJwtService, useValue: keycloakJwtService }, + ], + }).compile() + + service = module.get(AuthService) + }) + + it('should authenticate a Fastify request directly', async () => { + dsoTokenService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 0n, userType: 'human' }) + const request = makeAuthRequest({ 'x-dso-token': 'token' }) + + const result = await service.authenticate(request) + + expect(result).toEqual({ userId: 'u1', adminPermissions: 0n, userType: 'human' }) + expect(dsoTokenService.authenticate).toHaveBeenCalledWith(request, undefined) + }) + + it('should authenticate a Keycloak bearer token from the request header', async () => { + keycloakJwtService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 8n, userType: 'human' }) + + const request = makeAuthRequest({ authorization: 'Bearer jwt-token' }) + const result = await service.authenticate(request) + + expect(keycloakJwtService.authenticate).toHaveBeenCalledWith( + request, + undefined, + ) + expect(result).toEqual({ userId: 'u1', adminPermissions: 8n, userType: 'human' }) + }) + + it('should throw 401 when no supported auth header exists', async () => { + await expect(service.authenticate(makeAuthRequest({}))).rejects.toThrow(UnauthorizedException) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts new file mode 100644 index 0000000000..9ee491473c --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts @@ -0,0 +1,37 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from './auth-user.decorator' +import type { AuthRequirements } from './auth.utils' +import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common' +import { DsoTokenService } from './dso-token/dso-token.service' +import { KeycloakJwtService } from './keycloak-jwt/keycloak-jwt.service' + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name) + + constructor( + @Inject(DsoTokenService) private readonly dsoTokenService: DsoTokenService, + @Inject(KeycloakJwtService) private readonly keycloakJwtService: KeycloakJwtService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + const dsoTokenResult = await this.dsoTokenService.authenticate(request, requirements) + if (dsoTokenResult) { + this.logger.debug(`Auth authenticated request (requestId=${request.id}, authSource=dso-token, userId=${dsoTokenResult.userId}, adminPermissions=${dsoTokenResult.adminPermissions?.toString()}, userType=${dsoTokenResult.userType})`) + return dsoTokenResult + } + + const bearerTokenResult = await this.keycloakJwtService.authenticate(request, requirements) + if (bearerTokenResult) { + this.logger.debug(`Auth authenticated request (requestId=${request.id}, authSource=keycloak-jwt, userId=${bearerTokenResult.userId}, adminPermissions=${bearerTokenResult.adminPermissions?.toString()}, userType=${bearerTokenResult.userType})`) + return bearerTokenResult + } + + this.logger.warn(`Auth rejected request (requestId=${request.id}, hasDsoTokenHeader=${typeof request.headers['x-dso-token'] === 'string'}, hasAuthorizationHeader=${typeof request.headers.authorization === 'string'})`) + + throw new UnauthorizedException('Not authenticated') + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts new file mode 100644 index 0000000000..3b4d0c3ed9 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts @@ -0,0 +1,14 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from './auth-user.decorator' + +export interface AuthRequirements { + includeAdminRoleIds?: boolean + includeUserType?: boolean +} + +export interface AuthProvider { + authenticate: ( + request: FastifyRequest, + requirements?: AuthRequirements, + ) => Promise +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts new file mode 100644 index 0000000000..ed96461131 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../../database/database.module' +import { DsoTokenService } from './dso-token.service' + +@Module({ + imports: [DatabaseModule], + providers: [DsoTokenService], + exports: [DsoTokenService], +}) +export class DsoTokenModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts new file mode 100644 index 0000000000..f9ab618442 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts @@ -0,0 +1,118 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { createHash } from 'node:crypto' +import { faker } from '@faker-js/faker' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../../database/prisma.service' +import { makeAdminToken, makePersonalAccessToken } from '../auth-testing.utils' +import { DsoTokenService } from './dso-token.service' + +describe('dsoTokenService', () => { + let module: TestingModule + let service: DsoTokenService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep({ + adminRole: { + findMany: vi.fn().mockResolvedValue([]), + }, + }) + + module = await Test.createTestingModule({ + providers: [ + DsoTokenService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(DsoTokenService) + }) + + it('should validate a PersonalAccessToken and return raw token result', async () => { + const rawToken = faker.string.alphanumeric(32) + const patMock = makePersonalAccessToken({ adminRoleIds: [faker.string.uuid()] }) + prisma.personalAccessToken.findFirst.mockResolvedValue(patMock) + + const result = await service.validateToken(rawToken, { includeAdminRoleIds: true, includeUserType: true }) + + expect(prisma.personalAccessToken.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { hash: createHash('sha256').update(rawToken).digest('hex') } }), + ) + expect(result).toBeDefined() + if (result?.kind !== 'personal') throw new Error('Expected personal token result') + expect(result.ownerAdminRoleIds).toHaveLength(1) + expect(result.userType).toBe('human') + }) + + it('should validate an AdminToken and return raw token result', async () => { + const userId = faker.string.uuid() + prisma.personalAccessToken.findFirst.mockResolvedValue(null) + prisma.adminToken.findFirst.mockResolvedValue( + makeAdminToken({ + id: faker.string.uuid(), + userId, + permissions: 256n, + }), + ) + + const result = await service.validateToken(faker.string.alphanumeric(24), { includeAdminRoleIds: true, includeUserType: true }) + + expect(result).toBeDefined() + if (result?.kind !== 'admin') throw new Error('Expected admin token result') + expect(result.permissions).toBe(256n) + expect(result.userType).toBe('human') + }) + + it('should return undefined when no token found', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue(null) + prisma.adminToken.findFirst.mockResolvedValue(null) + + const result = await service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true }) + + expect(result).toBeUndefined() + }) + + it('should throw 401 when token is inactive', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue( + makePersonalAccessToken({ status: 'revoked', expirationDate: new Date(Date.now() + 86400000) }), + ) + + await expect(service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true })).rejects.toThrow(UnauthorizedException) + }) + + it('should throw 401 when token is expired', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue( + makePersonalAccessToken({ expirationDate: new Date(Date.now() - 1000) }), + ) + + await expect(service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true })).rejects.toThrow(UnauthorizedException) + }) + + it('should update lastUse and lastLogin', async () => { + const patMock = makePersonalAccessToken({}) + prisma.personalAccessToken.findFirst.mockResolvedValue(patMock) + + await service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true }) + + expect(prisma.personalAccessToken.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: patMock.id }, data: { lastUse: expect.any(String) } }), + ) + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: patMock.owner.id }, data: { lastLogin: expect.any(String) } }), + ) + }) + + it('should authenticate a DSO token from the request header', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue(makePersonalAccessToken({})) + + const result = await service.authenticateHeaders({ 'x-dso-token': 'token' }) + + expect(typeof result?.userId).toBe('string') + expect(result?.userType).toBe('human') + expect(result?.adminPermissions).toBe(0n) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts new file mode 100644 index 0000000000..1e27c96d63 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts @@ -0,0 +1,150 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../auth-user.decorator' +import type { AuthProvider, AuthRequirements } from '../auth.utils' +import type { AdminTokenWithOwner, AuthToken, PersonalAccessTokenWithOwner } from './dso-token.utils' +import { createHash } from 'node:crypto' +import { tokenHeaderName } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { PrismaService } from '../../database/prisma.service' +import { makeAdminTokenSelect, makePersonalAccessTokenSelect, validateToken } from './dso-token.utils' + +@Injectable() +export class DsoTokenService implements AuthProvider { + private readonly logger = new Logger(DsoTokenService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + return this.authenticateHeaders(request.headers, requirements) + } + + async authenticateHeaders( + headers: FastifyRequest['headers'], + requirements?: AuthRequirements, + ): Promise { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? true + const includeUserType = requirements?.includeUserType ?? true + + const tokenValue = headers[tokenHeaderName] + if (typeof tokenValue !== 'string') { + return undefined + } + + const tokenResult = await this.validateToken(tokenValue, { includeAdminRoleIds, includeUserType }) + if (!tokenResult) return undefined + + const adminPermissions = includeAdminRoleIds + ? await this.resolveAdminPermissions(tokenResult) + : undefined + + return { + userId: tokenResult.userId, + adminPermissions, + userType: tokenResult.userType, + } + } + + async validateToken( + rawToken: string, + requirements: Required, + ): Promise { + this.logger.debug(`validateToken started`) + const hash = createHash('sha256').update(rawToken).digest('hex') + const result = await this.findAndValidateToken(hash, requirements) + if (!result) { + this.logger.warn(`validateToken token not found`) + return undefined + } + this.logger.debug(`validateToken completed (userId=${result.userId})`) + return result + } + + private async findAndValidateToken(hash: string, requirements: Required): Promise { + const personalAccessTokenResult = await this.findAndValidatePersonalAccessToken(hash, requirements) + if (personalAccessTokenResult) { + return personalAccessTokenResult + } + + const adminTokenResult = await this.findAndValidateAdminToken(hash, requirements) + if (adminTokenResult) { + return adminTokenResult + } + + return undefined + } + + private async findAndValidatePersonalAccessToken(hash: string, requirements: Required): Promise { + const pat = await this.prisma.personalAccessToken.findFirst({ + select: makePersonalAccessTokenSelect(requirements), + where: { hash }, + }) satisfies PersonalAccessTokenWithOwner | null + if (pat) { + validateToken(pat) + await this.updateLastUse('personalAccessToken', pat.id, pat.owner.id) + return { + kind: 'personal' as const, + userId: pat.owner.id, + ownerAdminRoleIds: requirements.includeAdminRoleIds ? (pat.owner.adminRoleIds ?? []) : undefined, + userType: requirements.includeUserType ? (pat.owner.type ?? undefined) : undefined, + } + } + + return undefined + } + + private async findAndValidateAdminToken(hash: string, requirements: Required): Promise { + const adminToken = await this.prisma.adminToken.findFirst({ + select: makeAdminTokenSelect(requirements), + where: { hash }, + }) satisfies AdminTokenWithOwner | null + if (adminToken) { + validateToken(adminToken) + await this.updateLastUse('adminToken', adminToken.id, adminToken.owner.id) + return { + kind: 'admin' as const, + userId: adminToken.owner.id, + permissions: adminToken.permissions, + userType: adminToken.owner.type ?? undefined, + } + } + + return undefined + } + + private async updateLastUse(model: 'personalAccessToken' | 'adminToken', tokenId: string, userId: string) { + const now = new Date().toISOString() + if (model === 'personalAccessToken') { + await this.prisma.personalAccessToken.update({ where: { id: tokenId }, data: { lastUse: now } }) + } else { + await this.prisma.adminToken.update({ where: { id: tokenId }, data: { lastUse: now } }) + } + await this.prisma.user.update({ where: { id: userId }, data: { lastLogin: now } }) + } + + private async resolveAdminPermissions(result: AuthToken): Promise { + const globalRoles = await this.prisma.adminRole.findMany({ + where: { type: 'global' }, + select: { permissions: true }, + }) + const globalPerms = globalRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + + if (result.kind === 'admin') { + return globalPerms | result.permissions + } + const ownerAdminRoleIds = result.ownerAdminRoleIds ?? [] + if (!ownerAdminRoleIds.length) { + return globalPerms + } + const roles = await this.prisma.adminRole.findMany({ + select: { permissions: true }, + where: { id: { in: ownerAdminRoleIds } }, + }) + const tokenPerms = roles.reduce((acc, curr) => acc | curr.permissions, 0n) + return globalPerms | tokenPerms + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts new file mode 100644 index 0000000000..297533b572 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts @@ -0,0 +1,72 @@ +import type { AdminToken, Prisma, User } from '@prisma/client' +import type { AuthRequirements } from '../auth.utils' +import { UnauthorizedException } from '@nestjs/common' + +export type AuthToken + = | { kind: 'admin', userId: string, permissions: bigint, userType?: User['type'] } + | { kind: 'personal', userId: string, ownerAdminRoleIds?: string[], userType?: User['type'] } + +export function makeAdminTokenSelect(requirements?: AuthRequirements): Prisma.AdminTokenSelect { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? false + const includeUserType = requirements?.includeUserType ?? false + + return { + id: true, + name: true, + ...(includeAdminRoleIds ? { owner: { select: { adminRoleIds: true } } } : {}), + ...(includeUserType ? { owner: { select: { type: true } } } : {}), + ...(includeAdminRoleIds && includeUserType ? { owner: { select: { adminRoleIds: true, type: true } } } : {}), + } satisfies Prisma.AdminTokenSelect +} + +export function makePersonalAccessTokenSelect(requirements?: AuthRequirements): Prisma.PersonalAccessTokenSelect { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? false + const includeUserType = requirements?.includeUserType ?? false + + return { + id: true, + name: true, + owner: { + select: { + id: true, + ...(includeAdminRoleIds ? { adminRoleIds: true } : {}), + ...(includeUserType ? { type: true } : {}), + ...(includeAdminRoleIds && includeUserType ? { adminRoleIds: true, type: true } : {}), + }, + }, + } satisfies Prisma.PersonalAccessTokenSelect +} + +export interface PersonalAccessTokenWithOwner { + id: string + status: AdminToken['status'] + expirationDate: Date | null + owner: { + id: string + adminRoleIds?: string[] | null + type?: User['type'] | null + } +} + +export interface AdminTokenWithOwner { + id: string + status: AdminToken['status'] + expirationDate: Date | null + owner: { + id: string + adminRoleIds?: string[] | null + type?: User['type'] | null + } +} + +export type Token = PersonalAccessTokenWithOwner | AdminTokenWithOwner + +export function validateToken(token: Token): void { + if (token.expirationDate && token.expirationDate < new Date()) { + throw new UnauthorizedException(`Token expired: ${token.expirationDate}`) + } + + if (token.status === 'revoked') { + throw new UnauthorizedException('Token revoked') + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts new file mode 100644 index 0000000000..9b29a144f2 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts @@ -0,0 +1,45 @@ +import { faker } from '@faker-js/faker' + +export function makeMockUser(overrides: Partial<{ + id: string + firstName: string + lastName: string + email: string + createdAt: Date + updatedAt: Date + lastLogin: Date | null + adminRoleIds: string[] + type: 'human' | 'bot' | 'ghost' +}> = {}) { + return { + id: faker.string.uuid(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email().toLowerCase(), + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: null, + adminRoleIds: [], + type: 'human' as const, + ...overrides, + } +} + +export function makeMockAdminRole(overrides: Partial<{ + id: string + name: string + permissions: bigint + position: number + oidcGroup: string + type: string +}> = {}) { + return { + id: faker.string.uuid(), + name: faker.lorem.words(2), + permissions: 1n, + position: 0, + oidcGroup: '', + type: 'managed', + ...overrides, + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts new file mode 100644 index 0000000000..6c7851d92d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common' +import { JwtModule } from '@nestjs/jwt' +import { DatabaseModule } from '../../database/database.module' +import { KeycloakSecretProviderModule } from '../keycloak-secret-provider/keycloak-secret-provider.module' +import { KeycloakSecretProviderService } from '../keycloak-secret-provider/keycloak-secret-provider.service' +import { KeycloakJwtService } from './keycloak-jwt.service' + +@Module({ + imports: [ + DatabaseModule, + JwtModule.registerAsync({ + imports: [KeycloakSecretProviderModule], + inject: [KeycloakSecretProviderService], + useFactory: async (client: KeycloakSecretProviderService) => { + // The issuer is fetched from the openid-configuration endpoint + // rather than reconstructed from env vars, as the server may be + // behind a reverse proxy that differs from the public-facing domain. + const issuer = await client.fetchIssuer() + return { + secretOrKeyProvider: (requestType, tokenOrPayload) => client.getSecret(requestType, tokenOrPayload), + // Keycloak tokens currently do not include an audience claim for this app. + verifyOptions: { + algorithms: ['RS256'], + issuer, + }, + } + }, + }), + ], + providers: [KeycloakJwtService], + exports: [KeycloakJwtService, JwtModule], +}) +export class KeycloakJwtModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts new file mode 100644 index 0000000000..ed2c588caf --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts @@ -0,0 +1,170 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { UnauthorizedException } from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../../database/prisma.service' +import { makeAuthRequest } from '../auth-testing.utils' +import { makeMockAdminRole, makeMockUser } from './keycloak-jwt-testing.utils' +import { KeycloakJwtService } from './keycloak-jwt.service' + +describe('keycloakJwtService', () => { + let module: TestingModule + let service: KeycloakJwtService + let prisma: DeepMockProxy + let jwtService: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + jwtService = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + KeycloakJwtService, + { provide: PrismaService, useValue: prisma }, + { provide: JwtService, useValue: jwtService }, + ], + }).compile() + + service = module.get(KeycloakJwtService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('validatePayload', () => { + it('should recompute active admin roles from the current Keycloak group membership', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue( + makeMockUser({ + id: payload.sub, + adminRoleIds: ['stale-oidc-role', 'manual-role'], + }), + ) + prisma.adminRole.findMany.mockResolvedValue([ + makeMockAdminRole({ + id: 'stale-oidc-role', + oidcGroup: '/stale-group', + permissions: 8n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'manual-role', + oidcGroup: '', + permissions: 16n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'current-oidc-role', + oidcGroup: '/current-group', + permissions: 32n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'global-role', + oidcGroup: '', + permissions: 4n, + type: 'global', + }), + ]) + + const result = await service.validatePayload(payload) + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: payload.sub }, + data: { + adminRoleIds: ['manual-role', 'current-oidc-role', 'global-role'], + lastLogin: expect.any(String), + }, + }) + expect(result).toEqual({ + userId: payload.sub, + adminPermissions: 52n, + userType: 'human', + }) + }) + + it('should reject when the local user is missing', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue(null) + + await expect(service.validatePayload(payload)).rejects.toBeInstanceOf(UnauthorizedException) + expect(prisma.adminRole.findMany).not.toHaveBeenCalled() + expect(prisma.user.update).not.toHaveBeenCalled() + }) + + it('should skip admin role resolution when permissions are not required', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue( + makeMockUser({ + id: payload.sub, + type: 'human', + }), + ) + + const result = await service.validatePayload( + payload, + { includeAdminRoleIds: false, includeUserType: true }, + ) + + expect(prisma.adminRole.findMany).not.toHaveBeenCalled() + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: payload.sub }, + data: { + lastLogin: expect.any(String), + }, + }) + expect(result).toEqual({ + userId: payload.sub, + adminPermissions: undefined, + userType: 'human', + }) + }) + }) + + describe('authenticate', () => { + it('should authenticate a bearer token from the request', async () => { + jwtService.verifyAsync.mockResolvedValue({ + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: [], + }) + prisma.user.findUnique.mockResolvedValue(makeMockUser({})) + prisma.adminRole.findMany.mockResolvedValue([]) + + const result = await service.authenticate( + makeAuthRequest({ authorization: 'Bearer jwt-token' }), + ) + + expect(jwtService.verifyAsync).toHaveBeenCalledWith('jwt-token') + expect(result?.userId).toBeDefined() + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts new file mode 100644 index 0000000000..9e1eb00db7 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts @@ -0,0 +1,123 @@ +import type { Prisma } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { IncomingHttpHeaders } from 'node:http' +import type { UserContext } from '../auth-user.decorator' +import type { AuthProvider, AuthRequirements } from '../auth.utils' +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { z } from 'zod' +import { PrismaService } from '../../database/prisma.service' + +const KeycloakPayloadSchema = z.object({ + sub: z.string(), + email: z.string().optional().default(''), + given_name: z.string().optional().default(''), + family_name: z.string().optional().default(''), + groups: z.array(z.string()).optional().default([]), +}) + +type KeycloakPayload = z.infer + +@Injectable() +export class KeycloakJwtService implements AuthProvider { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(JwtService) private readonly jwtService: JwtService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + return this.authenticateHeaders(request.headers, requirements) + } + + async authenticateHeaders( + headers: IncomingHttpHeaders, + requirements?: AuthRequirements, + ): Promise { + const authHeader = headers.authorization + if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) { + return undefined + } + + try { + const jwt = authHeader.slice(7) + const payload = await this.jwtService.verifyAsync(jwt) + const parsedPayload = KeycloakPayloadSchema.parse(payload) + return await this.validatePayload(parsedPayload, requirements) + } catch (error) { + throw new UnauthorizedException( + error instanceof Error ? error.message : 'Authentication failed', + ) + } + } + + async validatePayload( + payload: KeycloakPayload, + requirements?: AuthRequirements, + ): Promise { + const authRequirements = normalizeRequirements(requirements) + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + select: makeUserSelect(authRequirements), + }) + if (!user) { + throw new UnauthorizedException('Not authenticated') + } + + let adminPermissions: bigint | undefined + let mergedRoleIds: string[] | undefined + + if (authRequirements.includeAdminRoleIds) { + const adminRoleIds = 'adminRoleIds' in user ? user.adminRoleIds : [] + const groups = payload.groups + + const matchingAdminRoles = await this.prisma.adminRole.findMany({ + where: { + OR: [ + { oidcGroup: { in: groups } }, + { id: { in: adminRoleIds } }, + { type: 'global' }, + ], + }, + }) + + const activeAdminRoles = matchingAdminRoles.filter(({ oidcGroup, type }) => + type === 'global' || !oidcGroup || groups.includes(oidcGroup), + ) + + mergedRoleIds = [...new Set(activeAdminRoles.map(({ id }) => id))] + adminPermissions = activeAdminRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + } + + await this.prisma.user.update({ + where: { id: payload.sub }, + data: { + ...(mergedRoleIds ? { adminRoleIds: mergedRoleIds } : {}), + lastLogin: new Date().toISOString(), + }, + }) + + return { + userId: payload.sub, + adminPermissions, + userType: 'type' in user ? user.type : undefined, + } + } +} + +function makeUserSelect(requirements: Required) { + return { + id: true, + ...(requirements.includeAdminRoleIds ? { adminRoleIds: true } : {}), + ...(requirements.includeUserType ? { type: true } : {}), + } satisfies Prisma.UserSelect +} + +function normalizeRequirements(requirements: AuthRequirements = {}): Required { + return { + includeAdminRoleIds: requirements.includeAdminRoleIds ?? true, + includeUserType: requirements.includeUserType ?? true, + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts new file mode 100644 index 0000000000..4c5ef71a1b --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts @@ -0,0 +1,22 @@ +import { generateKeyPairSync } from 'node:crypto' + +export function makeJwksResponse(kid: string): Response { + const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const jwk = publicKey.export({ format: 'jwk' }) + return new Response(JSON.stringify({ + keys: [ + { + kid, + kty: 'RSA', + use: 'sig', + n: jwk.n, + e: jwk.e, + }, + ], + }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts new file mode 100644 index 0000000000..42f606a58c --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts @@ -0,0 +1,2 @@ +export const KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX = 'jwks:' +export const KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY = `openid-configuration:` diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts new file mode 100644 index 0000000000..602419cdcf --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts @@ -0,0 +1,15 @@ +import { CacheModule } from '@nestjs/cache-manager' +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../../config/keycloak.config' +import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' + +@Module({ + imports: [ + CacheModule.register(), + ConfigModule.forFeature(keycloakConfigFactory), + ], + providers: [KeycloakSecretProviderService], + exports: [KeycloakSecretProviderService], +}) +export class KeycloakSecretProviderModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts new file mode 100644 index 0000000000..9d7e632122 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts @@ -0,0 +1,211 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { KeycloakConfig } from '../../../keycloak/keycloak.module-definition' +import { faker } from '@faker-js/faker' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { JwtSecretRequestType } from '@nestjs/jwt' +import { Test } from '@nestjs/testing' +import { createCache } from 'cache-manager' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KEYCLOAK_CONFIG } from '../../../keycloak/keycloak.module-definition' +import { makeJwksResponse } from './keycloak-secret-provider-testing.utils' +import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' +import { createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' + +describe('keycloakSecretProviderService', () => { + let module: TestingModule + let service: KeycloakSecretProviderService + let config: DeepMockProxy + let fetchMock: ReturnType + let cache: ReturnType + + beforeEach(async () => { + const keycloakDomain = faker.internet.domainName() + const keycloakRealm = faker.lorem.word() + const openidConfigurationUrl = `https://${keycloakDomain}/realms/${keycloakRealm}/.well-known/openid-configuration` + + config = mockDeep({ + protocol: 'https', + domain: keycloakDomain, + realm: keycloakRealm, + openidConfigurationUrl, + jwksTimeoutMs: 1_000, + jwksCacheTtlMs: 300_000, + openidConfigurationCacheTtlMs: 300_000, + }) + fetchMock = vi.fn() + cache = createCache() + + vi.clearAllMocks() + vi.unstubAllGlobals() + vi.stubGlobal('fetch', fetchMock) + + module = await Test.createTestingModule({ + providers: [ + KeycloakSecretProviderService, + { provide: KEYCLOAK_CONFIG, useValue: config }, + { provide: CACHE_MANAGER, useValue: cache }, + ], + }).compile() + + service = module.get(KeycloakSecretProviderService) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should fetch JWKS from Keycloak and parse the response', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + const internalJwksUri = `${config.protocol}://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-1')) + + const jwks = await service.fetchSigningKeys() + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0]?.[0]).toBe(config.openidConfigurationUrl) + expect(fetchMock.mock.calls[1]?.[0]).toBe(internalJwksUri) + expect(jwks).toEqual({ + keys: [ + { + kid: 'kid-1', + kty: 'RSA', + use: 'sig', + n: expect.any(String), + e: expect.any(String), + }, + ], + }) + }) + + it('should abort and return undefined when the JWKS request exceeds the timeout', async () => { + vi.useFakeTimers() + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockImplementationOnce((_url, init?: RequestInit) => new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + })) + + const promise = service.fetchSigningKeys() + await vi.advanceTimersByTimeAsync(1_000) + + await expect(promise).resolves.toBeUndefined() + }) + + it('should return undefined when Keycloak returns a non-OK response', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(new Response('', { status: 500, statusText: 'Internal Server Error' })) + + await expect(service.fetchSigningKeys()).resolves.toBeUndefined() + }) + + it('should resolve a PEM public key from the JWKS', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-2')) + + const publicKey = await service.fetchPublicKey('kid-2') + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(publicKey).toContain('BEGIN RSA PUBLIC KEY') + expect(await cache.get(createKeycloakSecretProviderPublicKeyCacheKey('kid-2'))).toBe(publicKey) + }) + + it('should reuse the cached PEM public key', async () => { + await cache.set( + createKeycloakSecretProviderPublicKeyCacheKey('cached-kid'), + '-----BEGIN RSA PUBLIC KEY-----\ncached\n-----END RSA PUBLIC KEY-----', + ) + + const publicKey = await service.fetchPublicKey('cached-kid') + + expect(fetchMock).not.toHaveBeenCalled() + expect(publicKey).toBe('-----BEGIN RSA PUBLIC KEY-----\ncached\n-----END RSA PUBLIC KEY-----') + }) + + it('should resolve the secret directly from the JWT token and request type', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-3')) + const header = Buffer.from(JSON.stringify({ kid: 'kid-3' })).toString('base64url') + + const secret = await service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(secret).toContain('BEGIN RSA PUBLIC KEY') + }) + + it('should reject signing requests', async () => { + await expect(service.getSecret(JwtSecretRequestType.SIGN, 'payload')).rejects.toThrow('Signing is not supported') + }) + + it('should reject non-string tokens', async () => { + await expect(service.getSecret(JwtSecretRequestType.VERIFY, Buffer.from('payload'))).rejects.toThrow( + 'Unsupported token type', + ) + }) + + it('should reject malformed JWTs', async () => { + await expect(service.getSecret(JwtSecretRequestType.VERIFY, 'only.two')).rejects.toThrow('Invalid JWT format') + }) + + it('should reject JWTs without a kid header', async () => { + const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url') + + await expect(service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`)).rejects.toThrow( + 'Missing kid', + ) + }) + + it('should reject JWTs when the key cannot be resolved', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ keys: [] }))) + const header = Buffer.from(JSON.stringify({ kid: 'missing-kid' })).toString('base64url') + + await expect(service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`)).rejects.toThrow( + 'Unknown signing key', + ) + }) + + it('should resolve the issuer from openid-configuration', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + + await expect(service.fetchIssuer()).resolves.toBe(issuer) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('should replace the discovered JWKS domain with the configured internal Keycloak domain', async () => { + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + issuer: `https://${config.domain}/realms/${config.realm}`, + jwks_uri: publicJwksUri, + }))) + + await expect(service.fetchJwksUri()).resolves.toBe( + `https://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs`, + ) + }) + + it('should keep the discovered JWKS URI unchanged when no internal Keycloak domain is configured', async () => { + config.domain = undefined + const publicJwksUri = `https://public.example.test/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + issuer: 'https://public.example.test/realms/test', + jwks_uri: publicJwksUri, + }))) + + await expect(service.fetchJwksUri()).resolves.toBe(publicJwksUri) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts new file mode 100644 index 0000000000..69a19bb5fa --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts @@ -0,0 +1,162 @@ +import type { Cache } from 'cache-manager' +import type { KeycloakConfig } from '../../../keycloak/keycloak.module-definition' +import { createPublicKey } from 'node:crypto' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { JwtSecretRequestType } from '@nestjs/jwt' +import { z } from 'zod' +import { KEYCLOAK_CONFIG } from '../../../keycloak/keycloak.module-definition' +import { createKeycloakSecretProviderOpenIdConfigurationCacheKey, createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' + +const OpenidConfigurationSchema = z.object({ + issuer: z.string().url(), + jwks_uri: z.string().url(), +}) + +type OpenidConfiguration = z.infer + +const JwksResponseSchema = z.object({ + keys: z.array(z.object({ + kid: z.string(), + kty: z.string(), + use: z.string(), + n: z.string(), + e: z.string(), + })), +}) + +type JwksResponse = z.infer + +const JwtHeaderSchema = z.object({ + kid: z.string(), +}) + +@Injectable() +export class KeycloakSecretProviderService { + private readonly logger = new Logger(KeycloakSecretProviderService.name) + + constructor( + @Inject(CACHE_MANAGER) private readonly cache: Cache, + @Inject(KEYCLOAK_CONFIG) private readonly keycloakConfig: KeycloakConfig, + ) {} + + async fetchOpenIdConfig(): Promise { + const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(this.keycloakConfig.openidConfigurationUrl) + const cached = await this.cache.get(cacheKey) + if (cached) return cached + + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) + if (!response.ok) { + this.logger.error(`Failed to fetch openid-configuration: ${response.status} ${response.statusText}`) + return undefined + } + + const raw = await response.json() + const config = OpenidConfigurationSchema.safeParse(raw) + if (!config.success) { + this.logger.error('openid-configuration response missing jwks_uri') + return undefined + } + + await this.cache.set(cacheKey, config.data, this.keycloakConfig.openidConfigurationCacheTtlMs) + return config.data + } + + async fetchIssuer(): Promise { + const config = await this.fetchOpenIdConfig() + return config?.issuer + } + + async fetchJwksUri(): Promise { + const config = await this.fetchOpenIdConfig() + return config ? this.replaceJwksUriDomainWithInternalDomain(config.jwks_uri) : undefined + } + + private replaceJwksUriDomainWithInternalDomain(jwksUri: string): string { + if (!this.keycloakConfig.domain) return jwksUri + const url = new URL(jwksUri) + url.protocol = this.keycloakConfig.protocol + url.host = this.keycloakConfig.domain + this.logger.log(`Replacing JWKS URI domain: ${jwksUri} -> ${url.toString()}`) + return url.toString() + } + + async fetchSigningKeys(): Promise { + const jwksUri = await this.fetchJwksUri() + if (!jwksUri) return undefined + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), this.keycloakConfig.jwksTimeoutMs) + + try { + const response = await fetch(jwksUri, { signal: controller.signal }) + if (!response.ok) { + this.logger.error(`Failed to fetch JWKS: ${response.status} ${response.statusText}`) + return undefined + } + + const raw = await response.json() + return JwksResponseSchema.parse(raw) + } catch (error) { + this.logger.error(`Failed to fetch JWKS: ${error instanceof Error ? error.message : String(error)}`) + return undefined + } finally { + clearTimeout(timeout) + } + } + + async fetchPublicKey(kid: string): Promise { + const cacheKey = createKeycloakSecretProviderPublicKeyCacheKey(kid) + const cached = await this.cache.get(cacheKey) + if (cached) return cached + + const jwks = await this.fetchSigningKeys() + if (!jwks) return undefined + + const key = jwks.keys.find(candidate => candidate.kid === kid && candidate.use === 'sig' && candidate.kty === 'RSA') + if (!key) return undefined + + const publicKey = createPublicKey({ + key: { kty: 'RSA', n: key.n, e: key.e }, + format: 'jwk', + }) + + const pem = publicKey.export({ format: 'pem', type: 'pkcs1' }) as string + await this.cache.set(cacheKey, pem, this.keycloakConfig.jwksCacheTtlMs) + return pem + } + + async getSecret( + requestType: JwtSecretRequestType, + tokenOrPayload: string | object | Buffer, + ): Promise { + if (requestType === JwtSecretRequestType.SIGN) { + throw new TypeError('Signing is not supported') + } + + if (typeof tokenOrPayload !== 'string') { + throw new TypeError('Unsupported token type') + } + + const parts = tokenOrPayload.split('.') + if (parts.length !== 3) { + throw new TypeError('Invalid JWT format') + } + + const rawHeader = JSON.parse( + Buffer.from(parts[0].replaceAll('-', '+').replaceAll('_', '/'), 'base64').toString(), + ) + const header = JwtHeaderSchema.safeParse(rawHeader) + if (!header.success) { + throw new TypeError('Missing kid') + } + const { kid } = header.data + + const publicKey = await this.fetchPublicKey(kid) + if (!publicKey) { + throw new Error('Unknown signing key') + } + + return publicKey + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts new file mode 100644 index 0000000000..b13beeed98 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts @@ -0,0 +1,9 @@ +import { KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY, KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX } from './keycloak-secret-provider.constants' + +export function createKeycloakSecretProviderPublicKeyCacheKey(kid: string): string { + return `${KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX}${kid}` +} + +export function createKeycloakSecretProviderOpenIdConfigurationCacheKey(url: string): string { + return `${KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY}${url}` +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts new file mode 100644 index 0000000000..d1853a3219 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts @@ -0,0 +1,27 @@ +export interface BaseConfig { + nodeEnv: 'development' | 'production' | 'test' + isTest: boolean + isDev: boolean + isCI: boolean + isProd: boolean + integration: boolean + ci: boolean + devSetup: boolean + docker: boolean + serverHost: string + serverPort: number + appVersion: string + dbUrl?: string + sessionSecret?: string + contactEmail: string + mockPlugins: boolean + projectsRootDir?: string + pluginsDir: string + httpProxy?: string + httpsProxy?: string +} + +// Runtime config (BASE_CONFIG token) is owned by the global config/base.config.ts +// (which calls registerAs('base', ...)); re-exported here so services keep a single +// stable import surface (the module) while config depends on the module. +export { BASE_CONFIG } from './base.module' diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts new file mode 100644 index 0000000000..5ee8150bd8 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts @@ -0,0 +1,13 @@ +import type { BaseConfig } from './base.config' +import { Global, Module } from '@nestjs/common' +import { createConfigurableModule } from '../../configurable-feature-module' + +export interface BaseModuleOptions extends BaseConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('base', true) + +@Global() +@Module({}) +export class BaseModule extends ConfigurableModuleClass {} + +export const BASE_CONFIG = MODULE_OPTIONS_TOKEN diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts new file mode 100644 index 0000000000..7c42430723 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts @@ -0,0 +1,52 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import { HealthIndicatorService } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { DatabaseHealthService } from './database-health.service' +import { PrismaService } from './prisma.service' + +describe('databaseHealthService', () => { + let service: DatabaseHealthService + let prisma: DeepMockProxy + let indicatorSession: { up: ReturnType, down: ReturnType } + + beforeEach(async () => { + prisma = mockDeep() + const healthIndicatorMock = mockDeep() + indicatorSession = { + up: vi.fn().mockReturnValue({ status: 'up' }), + down: vi.fn().mockReturnValue({ status: 'down' }), + } + vi.mocked(healthIndicatorMock.check).mockReturnValue(indicatorSession as any) + + const module = await Test.createTestingModule({ + providers: [ + DatabaseHealthService, + { provide: PrismaService, useValue: prisma }, + { provide: HealthIndicatorService, useValue: healthIndicatorMock }, + ], + }).compile() + + service = module.get(DatabaseHealthService) + }) + + it('reports up when the query succeeds', async () => { + prisma.$queryRaw.mockResolvedValueOnce(undefined as any) + + const result = await service.check('database') + + expect(prisma.$queryRaw).toHaveBeenCalled() + expect(indicatorSession.up).toHaveBeenCalled() + expect(result).toEqual({ status: 'up' }) + }) + + it('reports down when the query throws', async () => { + prisma.$queryRaw.mockRejectedValueOnce(new Error('boom')) + + const result = await service.check('database') + + expect(indicatorSession.down).toHaveBeenCalledWith({ message: 'boom' }) + expect(result).toEqual({ status: 'down' }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts new file mode 100644 index 0000000000..1e37899c9b --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts @@ -0,0 +1,23 @@ +import { Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { PrismaService } from './prisma.service' + +@Injectable() +export class DatabaseHealthService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + await this.prisma.$queryRaw`SELECT 1` + return indicator.up() + } catch (error) { + return indicator.down({ + message: error instanceof Error ? error.message : String(error), + }) + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts new file mode 100644 index 0000000000..7e909376ec --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseHealthService } from './database-health.service' +import { DatabaseService } from './database.service' +import { PrismaService } from './prisma.service' + +@Module({ + imports: [TerminusModule], + providers: [DatabaseHealthService, DatabaseService, PrismaService], + exports: [DatabaseHealthService, DatabaseService, PrismaService], +}) +export class DatabaseModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts new file mode 100644 index 0000000000..3f373ba1eb --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts @@ -0,0 +1,30 @@ +import type { ConfigType } from '@nestjs/config' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../config/base.config' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../../infrastructure/config/base.config' +import { DatabaseService } from './database.service' +import { PrismaService } from './prisma.service' + +describe('databaseService', () => { + let service: DatabaseService + + beforeEach(async () => { + const baseConfig = mockDeep() + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabaseService, + { provide: PrismaService, useValue: mockDeep() }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(DatabaseService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts new file mode 100644 index 0000000000..5d9d0c32f7 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts @@ -0,0 +1,73 @@ +import type { BaseConfig } from '../../infrastructure/config/base.config' +import { setTimeout } from 'node:timers/promises' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { BASE_CONFIG } from '../../infrastructure/config/base.config' +import { PrismaService } from './prisma.service' + +@Injectable() +export class DatabaseService { + private readonly loggerService = new Logger(DatabaseService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(BASE_CONFIG) private readonly configurationService: BaseConfig, + ) { + this.DELAY_BEFORE_RETRY + = this.configurationService.isTest || this.configurationService.isCI + ? 1000 + : 10000 + } + + DELAY_BEFORE_RETRY!: number + closingConnections = false + + async getConnection(triesLeft = 5): Promise { + if (this.closingConnections || triesLeft <= 0) { + throw new Error('Unable to connect to Postgres server') + } + triesLeft-- + + try { + if ( + this.configurationService.isDev + || this.configurationService.isTest + || this.configurationService.isCI + ) { + this.loggerService.log( + `Trying to connect to Postgres with: ${this.configurationService.dbUrl}`, + ) + } + await this.prisma.$connect() + + this.loggerService.log('Connected to Postgres!') + } catch (error) { + if (triesLeft > 0) { + this.loggerService.error(error) + this.loggerService.log( + `Could not connect to Postgres: ${error.message}`, + ) + this.loggerService.log(`Retrying (${triesLeft} tries left)`) + await setTimeout(this.DELAY_BEFORE_RETRY) + return this.getConnection(triesLeft) + } + + this.loggerService.log( + `Could not connect to Postgres: ${error.message}`, + ) + this.loggerService.log('Out of retries') + error.message = `Out of retries, last error: ${error.message}` + throw error + } + } + + async closeConnections() { + this.closingConnections = true + try { + await this.prisma.$disconnect() + } catch (error) { + this.loggerService.error(error) + } finally { + this.closingConnections = false + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts new file mode 100644 index 0000000000..f10a31e573 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts @@ -0,0 +1,14 @@ +import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common' +import { Injectable } from '@nestjs/common' +import { PrismaClient } from '@prisma/client' + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect() + } + + async onModuleDestroy() { + await this.$disconnect() + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts new file mode 100644 index 0000000000..8eb62bea67 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitterModule } from '@nestjs/event-emitter' +import { baseConfigFactory } from '../../../config/base.config' + +@Module({ + imports: [EventEmitterModule.forRoot(), ConfigModule.forFeature(baseConfigFactory)], + exports: [EventEmitterModule], +}) +export class EventsModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts new file mode 100644 index 0000000000..25b2a4e695 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from './auth/auth.module' +import { DatabaseModule } from './database/database.module' +import { EventsModule } from './events/events.module' +import { LoggerModule } from './logger/logger.module' +import { PermissionModule } from './permission/permission.module' + +@Module({ + providers: [], + imports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + exports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], +}) +export class InfrastructureModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts new file mode 100644 index 0000000000..257ef429f8 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts @@ -0,0 +1,37 @@ +import type { BaseConfig } from '../config/base.config' +import { getLoggerOptions } from '@cpn-console/logger' +import { Module } from '@nestjs/common' +import { LoggerModule as PinoLoggerModule } from 'nestjs-pino' +import { BASE_CONFIG } from '../config/base.config' + +@Module({ + imports: [ + PinoLoggerModule.forRootAsync({ + imports: [], + inject: [BASE_CONFIG], + useFactory: async (baseConfig: BaseConfig) => { + return { + pinoHttp: { + ...getLoggerOptions(baseConfig.isProd ? 'production' : 'development', baseConfig.isTest ? 'info' : 'debug'), + customLogLevel: (req, res, err) => { + if (err || res.statusCode >= 500) { + return 'error' + } + if (res.statusCode >= 400) { + return 'warn' + } + // kube liveness/readiness probes hit healthz constantly, only log it on failure + if (req.url?.split('?')[0] === '/api/v1/healthz') { + return 'silent' + } + return 'info' + }, + }, + } + }, + }), + ], + controllers: [], + providers: [], +}) +export class LoggerModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts new file mode 100644 index 0000000000..c2f926a351 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { ProjectPermissionModule } from './project/project.module' +import { UserPermissionModule } from './user/user.module' + +@Module({ + imports: [UserPermissionModule, ProjectPermissionModule], + exports: [UserPermissionModule, ProjectPermissionModule], +}) +export class PermissionModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts new file mode 100644 index 0000000000..7863f5ad4b --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_ACCESS_KEY = 'project-access' + +export function RequireProjectAccess() { + return SetMetadata(PROJECT_ACCESS_KEY, true) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts new file mode 100644 index 0000000000..ee11a3df91 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts @@ -0,0 +1,52 @@ +import type { FastifyRequest } from 'fastify' +import type { ProjectRequirements } from './project-loader.utils' +import type { ProjectContext } from './project.guard' +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../../database/prisma.service' +import { isUuid, makeProjectSelect, resolveProjectPermissions } from './project-loader.utils' + +type RequestWithProjectParams = FastifyRequest<{ Params?: ProjectParams }> + +interface ProjectParams { + projectId?: string + projectSlug?: string +} + +@Injectable() +export class ProjectPermissionLoaderService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async load(request: RequestWithProjectParams, userId: string, requirements?: ProjectRequirements): Promise { + let where: { id: string } | { slug: string } + if (request.params?.projectId) { + where = isUuid(request.params.projectId) ? { id: request.params.projectId } : { slug: request.params.projectId } + } else if (request.params?.projectSlug) { + where = { slug: request.params.projectSlug } + } else { + throw new BadRequestException('projectId or projectSlug is required') + } + + const raw = await this.prisma.project.findUnique({ + where, + select: makeProjectSelect(requirements), + }) + if (!raw) throw new NotFoundException('Projet introuvable') + + const project: ProjectContext = { + id: raw.id, + slug: raw.slug, + } + + if (requirements?.includeStatus) { + project.status = raw.status + } + if (requirements?.includeLocked) { + project.locked = raw.locked + } + if (requirements?.includePermissions) { + project.projectPermissions = resolveProjectPermissions(raw, userId) + } + + return project + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts new file mode 100644 index 0000000000..16e48b3fac --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts @@ -0,0 +1,53 @@ +import type { Prisma } from '@prisma/client' +import { PROJECT_PERMS } from '@cpn-console/shared' + +export interface ProjectRequirements { + includeStatus?: boolean + includeLocked?: boolean + includePermissions?: boolean +} + +export function makeProjectSelect(requirements?: ProjectRequirements): Prisma.ProjectSelect { + const includePermissions = requirements?.includePermissions ?? true + const includeStatus = requirements?.includeStatus ?? true + const includeLocked = requirements?.includeLocked ?? true + + return { + id: true, + slug: true, + ...(includeStatus ? { status: true } : {}), + ...(includeLocked ? { locked: true } : {}), + ...(includePermissions + ? { + ownerId: true, + everyonePerms: true, + roles: { select: { id: true, permissions: true } }, + members: { select: { userId: true, roleIds: true } }, + } + : {}), + } satisfies Prisma.ProjectSelect +} + +export function resolveProjectPermissions( + raw: { ownerId: string, everyonePerms: bigint, roles: Array<{ id: string, permissions: bigint }>, members: Array<{ userId: string, roleIds: string[] }> }, + userId: string, +): bigint { + if (raw.ownerId === userId) { + return PROJECT_PERMS.MANAGE + } + + const member = raw.members.find(m => m.userId === userId) + if (!member) { + return 0n + } + + const memberRoles = raw.roles.filter(role => member.roleIds.includes(role.id)) + return memberRoles.reduce( + (acc, curr) => acc | curr.permissions, + raw.everyonePerms | PROJECT_PERMS.GUEST, + ) +} + +export function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts new file mode 100644 index 0000000000..ac63012cc3 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_LOCKED_KEY = 'project-locked' + +export function RequireProjectLocked(locked: boolean) { + return SetMetadata(PROJECT_LOCKED_KEY, locked) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts new file mode 100644 index 0000000000..da064283ff --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts @@ -0,0 +1,8 @@ +import type { ProjectAuthorized } from '@cpn-console/shared' +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_PERMISSION_KEY = 'project-permission' + +export function RequireProjectPermission(...permissions: (keyof typeof ProjectAuthorized)[]) { + return SetMetadata(PROJECT_PERMISSION_KEY, permissions) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts new file mode 100644 index 0000000000..f1b2f05f48 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts @@ -0,0 +1,8 @@ +import type { Project } from '@prisma/client' +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_STATUS_KEY = 'project-status' + +export function RequireProjectStatus(...statuses: Project['status'][]) { + return SetMetadata(PROJECT_STATUS_KEY, statuses) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts new file mode 100644 index 0000000000..921cffd845 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts @@ -0,0 +1,14 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { FastifyRequest } from 'fastify' +import type { ProjectContext } from './project.guard' +import { createParamDecorator } from '@nestjs/common' + +export const Project = createParamDecorator( + (_: unknown, ctx: ExecutionContext): ProjectContext => { + const request = ctx.switchToHttp().getRequest() + if (!request.project) { + throw new Error('Project context is missing from the request') + } + return request.project + }, +) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts new file mode 100644 index 0000000000..8d4a5ba4c6 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts @@ -0,0 +1,63 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ProjectContext } from './project.guard' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AuthService } from '../../auth/auth.service' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectGuard } from './project.guard' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' +import { makeExecutionContext, makeProjectContext, makeProjectPolicy } from './project.testing.utils' + +describe('projectGuard', () => { + let module: TestingModule + let guard: ProjectGuard + let authService: DeepMockProxy + let projectService: DeepMockProxy + let projectPolicy: DeepMockProxy + let loader: DeepMockProxy + + beforeEach(async () => { + authService = mockDeep() + projectService = mockDeep() + projectPolicy = mockDeep() + loader = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectGuard, + { provide: AuthService, useValue: authService }, + { provide: ProjectPermissionService, useValue: projectService }, + { provide: ProjectPermissionPolicy, useValue: projectPolicy }, + { provide: ProjectPermissionLoaderService, useValue: loader }, + ], + }).compile() + + guard = module.get(ProjectGuard) + }) + + it('throws 401 when userId is not set on the request', async () => { + projectPolicy.build.mockReturnValue(makeProjectPolicy()) + authService.authenticate.mockRejectedValue(new UnauthorizedException()) + const ctx = makeExecutionContext({}) + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException) + }) + + it('loads project and validates', async () => { + const project: ProjectContext = makeProjectContext({}) + const policy = makeProjectPolicy({ projectPermissions: ['Manage'] }) + projectPolicy.build.mockReturnValue(policy) + authService.authenticate.mockResolvedValue({ userId: 'member1', adminPermissions: 0n }) + loader.load.mockResolvedValue(project) + const request = { userId: 'member1', adminPermissions: 0n, params: { projectId: 'p1' } } + const ctx = makeExecutionContext(request) + + const result = await guard.canActivate(ctx) + + expect(result).toBe(true) + expect(loader.load).toHaveBeenCalledWith(request, 'member1', expect.anything()) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts new file mode 100644 index 0000000000..b5ef931868 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts @@ -0,0 +1,94 @@ +import type { CanActivate, ExecutionContext } from '@nestjs/common' +import type { Project } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../../auth/auth-user.decorator' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { AuthService } from '../../auth/auth.service' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' + +export interface ProjectContext { + id: string + slug: string + locked?: boolean + status?: Project['status'] + projectPermissions?: bigint +} + +export interface RequestWithProjectContext extends FastifyRequest { + project?: ProjectContext +} + +type RequestWithUserContext = FastifyRequest & { + userId?: string + adminPermissions?: bigint + userType?: string +} + +interface ProjectParams { + projectId?: string + projectSlug?: string +} + +@Injectable() +export class ProjectGuard implements CanActivate { + private readonly logger = new Logger(ProjectGuard.name) + + constructor( + @Inject(AuthService) private readonly authService: AuthService, + @Inject(ProjectPermissionService) private readonly projectService: ProjectPermissionService, + @Inject(ProjectPermissionLoaderService) private readonly loader: ProjectPermissionLoaderService, + @Inject(ProjectPermissionPolicy) private readonly projectPolicy: ProjectPermissionPolicy, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const policy = this.projectPolicy.build(context) + const request = this.getRequest(context) + const user = await this.authenticate(request) + const project = await this.loadProject(request, user.userId) + + this.projectService.validate(policy, project, user) + + return true + } + + private getRequest(context: ExecutionContext): RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }> { + return context.switchToHttp().getRequest< + RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }> + >() + } + + private async authenticate( + request: RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }>, + ): Promise { + try { + const user = await this.authService.authenticate(request, { + includeAdminRoleIds: true, + includeUserType: true, + }) + + request.userId = user.userId + if (user.adminPermissions !== undefined) request.adminPermissions = user.adminPermissions + if (user.userType !== undefined) request.userType = user.userType + + return user + } catch (error) { + this.logger.warn(`Project access auth rejected (requestId=${request.id}, error=${error instanceof Error ? error.message : String(error)})`) + throw error + } + } + + private async loadProject( + request: RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }>, + userId: string, + ): Promise { + const project = await this.loader.load(request, userId, { + includeStatus: true, + includeLocked: true, + includePermissions: true, + }) + request.project = project + return project + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts new file mode 100644 index 0000000000..6561dd78dd --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../../auth/auth.module' +import { DatabaseModule } from '../../database/database.module' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectGuard } from './project.guard' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + ], + providers: [ + ProjectGuard, + ProjectPermissionLoaderService, + ProjectPermissionService, + ProjectPermissionPolicy, + ], + exports: [ + ProjectGuard, + ProjectPermissionLoaderService, + ProjectPermissionService, + ProjectPermissionPolicy, + ], +}) +export class ProjectPermissionModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts new file mode 100644 index 0000000000..9d4098d93e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts @@ -0,0 +1,37 @@ +import type { AdminAuthorized, ProjectAuthorized } from '@cpn-console/shared' +import type { ExecutionContext } from '@nestjs/common' +import type { Project, User } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { ADMIN_PERMISSIONS_KEY } from '../user/user-admin-permission.decorator' +import { USER_TYPES_KEY } from '../user/user-type.decorator' +import { PROJECT_ACCESS_KEY } from './project-access.decorator' +import { PROJECT_LOCKED_KEY } from './project-locked.decorator' +import { PROJECT_PERMISSION_KEY } from './project-permission.decorator' +import { PROJECT_STATUS_KEY } from './project-status.decorator' + +export interface ProjectPolicyConfig { + adminPermissions: (keyof typeof AdminAuthorized)[] + userTypes: User['type'][] + projectPermissions: (keyof typeof ProjectAuthorized)[] + projectStatuses: Project['status'][] + projectLocked?: boolean + projectAccess: boolean +} + +@Injectable() +export class ProjectPermissionPolicy { + constructor(@Inject(Reflector) private readonly reflector: Reflector) {} + + build(context: ExecutionContext): ProjectPolicyConfig { + const targets = [context.getHandler(), context.getClass()] + return { + adminPermissions: this.reflector.getAllAndOverride<(keyof typeof AdminAuthorized)[] | undefined>(ADMIN_PERMISSIONS_KEY, targets) ?? [], + userTypes: this.reflector.getAllAndOverride(USER_TYPES_KEY, targets) ?? [], + projectPermissions: this.reflector.getAllAndOverride<(keyof typeof ProjectAuthorized)[] | undefined>(PROJECT_PERMISSION_KEY, targets) ?? [], + projectStatuses: this.reflector.getAllAndOverride(PROJECT_STATUS_KEY, targets) ?? [], + projectLocked: this.reflector.getAllAndOverride(PROJECT_LOCKED_KEY, targets), + projectAccess: this.reflector.getAllAndOverride(PROJECT_ACCESS_KEY, targets) ?? false, + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts new file mode 100644 index 0000000000..f25771dd42 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts @@ -0,0 +1,72 @@ +import type { ProjectStatus, User } from '@prisma/client' +import type { UserContext } from '../../auth/auth-user.decorator' +import type { ProjectContext } from './project.guard' +import type { ProjectPolicyConfig } from './project.policy' +import { AdminAuthorized, ProjectAuthorized } from '@cpn-console/shared' +import { ForbiddenException, Injectable, Logger } from '@nestjs/common' + +@Injectable() +export class ProjectPermissionService { + private readonly logger = new Logger(ProjectPermissionService.name) + + validate(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + this.validateUserType(policy, user.userType) + this.validateProjectStatus(policy, project.status) + this.validateProjectLock(policy, project.locked) + this.validateProjectAccess(policy, project, user) + this.validateProjectPermissions(policy, project, user) + } + + validateProjectStatus(policy: ProjectPolicyConfig, projectStatus: ProjectStatus | undefined): void { + if (policy.projectStatuses.length > 0 && (!projectStatus || !policy.projectStatuses.includes(projectStatus))) { + this.logger.warn(`project auth denied: invalid project status (projectStatus=${projectStatus}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Statut de projet invalide') + } + } + + validateProjectLock(policy: ProjectPolicyConfig, projectLocked: boolean | undefined): void { + if (policy.projectLocked !== undefined && projectLocked !== policy.projectLocked) { + this.logger.warn(`project auth denied: invalid lock status (projectLocked=${projectLocked}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('État de verrouillage invalide') + } + } + + validateUserType(policy: ProjectPolicyConfig, userType: string | undefined): void { + if (!policy.userTypes.length) return + if (typeof userType !== 'string' || !policy.userTypes.includes(userType as User['type'])) { + this.logger.warn(`project auth denied: invalid user type (userType=${userType}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Cannot find requestor in request context') + } + } + + validateProjectAccess(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + if (!policy.projectAccess) return + + const adminPermissions = user.adminPermissions ?? 0n + const hasAccess = AdminAuthorized.Manage(adminPermissions) || Boolean(project.projectPermissions) + if (!hasAccess) { + this.logger.warn( + `project auth denied: missing project access (projectId=${project.id}, projectSlug=${project.slug}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, projectPermissions=${project.projectPermissions?.toString()}, policy=${JSON.stringify(policy)})`, + ) + throw new ForbiddenException('Permissions insuffisantes pour ce projet') + } + } + + validateProjectPermissions(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + if (!policy.projectPermissions.length) return + + const hasPermission = policy.projectPermissions.every( + permName => ProjectAuthorized[permName]({ + adminPermissions: user.adminPermissions, + projectPermissions: project.projectPermissions, + }), + ) + + if (!hasPermission) { + this.logger.warn( + `project auth denied: missing project permissions (projectId=${project.id}, projectSlug=${project.slug}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, projectPermissions=${project.projectPermissions?.toString()}, policy=${JSON.stringify(policy)})`, + ) + throw new ForbiddenException('Permissions insuffisantes pour ce projet') + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts new file mode 100644 index 0000000000..b2480f78cf --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts @@ -0,0 +1,37 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { HttpArgumentsHost } from '@nestjs/common/interfaces' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ProjectContext } from './project.guard' +import type { ProjectPolicyConfig } from './project.policy' +import { mockDeep } from 'vitest-mock-extended' + +export function makeExecutionContext(request: Record): DeepMockProxy { + const ctx = mockDeep() + const httpArgs = mockDeep() + httpArgs.getRequest.mockReturnValue(request) + ctx.switchToHttp.mockReturnValue(httpArgs) + return ctx +} + +export function makeProjectContext(overrides: Partial = {}): ProjectContext { + return { + id: 'p1', + slug: 'project', + locked: false, + status: 'created', + projectPermissions: 6n, + ...overrides, + } +} + +export function makeProjectPolicy(overrides: Partial = {}): ProjectPolicyConfig { + return { + adminPermissions: [], + userTypes: [], + projectPermissions: [], + projectStatuses: [], + projectLocked: undefined, + projectAccess: false, + ...overrides, + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts new file mode 100644 index 0000000000..ab70852172 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts @@ -0,0 +1,8 @@ +import type { AdminAuthorized } from '@cpn-console/shared' +import { SetMetadata } from '@nestjs/common' + +export const ADMIN_PERMISSIONS_KEY = 'admin-permissions' + +export function RequireAdminPermission(...permissions: (keyof typeof AdminAuthorized)[]) { + return SetMetadata(ADMIN_PERMISSIONS_KEY, permissions) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts new file mode 100644 index 0000000000..c2e004cc46 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts @@ -0,0 +1,25 @@ +import type { AdminAuthorized } from '@cpn-console/shared' +import type { ExecutionContext } from '@nestjs/common' +import type { User as PrismaUser } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { ADMIN_PERMISSIONS_KEY } from './user-admin-permission.decorator' +import { USER_TYPES_KEY } from './user-type.decorator' + +export interface UserPolicyConfig { + adminPermissions: (keyof typeof AdminAuthorized)[] + userTypes: PrismaUser['type'][] +} + +@Injectable() +export class UserPermissionPolicy { + constructor(@Inject(Reflector) private readonly reflector: Reflector) {} + + build(context: ExecutionContext): UserPolicyConfig { + const targets = [context.getHandler(), context.getClass()] + return { + adminPermissions: this.reflector.getAllAndOverride<(keyof typeof AdminAuthorized)[] | undefined>(ADMIN_PERMISSIONS_KEY, targets) ?? [], + userTypes: this.reflector.getAllAndOverride(USER_TYPES_KEY, targets) ?? [], + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts new file mode 100644 index 0000000000..dc6b09af03 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts @@ -0,0 +1,17 @@ +import type { UserContext } from '../../auth/auth-user.decorator' +import type { UserPolicyConfig } from './user-policy.service' + +export function makeUserPolicy(overrides: Partial = {}): UserPolicyConfig { + return { + adminPermissions: [], + userTypes: [], + ...overrides, + } +} + +export function makeUserContext(overrides: Partial = {}): UserContext { + return { + userId: 'test-user', + ...overrides, + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts new file mode 100644 index 0000000000..ee14dbbb38 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts @@ -0,0 +1,8 @@ +import type { User } from '@prisma/client' +import { SetMetadata } from '@nestjs/common' + +export const USER_TYPES_KEY = 'user-types' + +export function RequireUserType(...types: User['type'][]) { + return SetMetadata(USER_TYPES_KEY, types) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts new file mode 100644 index 0000000000..447b77477e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts @@ -0,0 +1,105 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserPolicyConfig } from './user-policy.service' +import { ForbiddenException, UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeExecutionContext } from '../../auth/auth-testing.utils' +import { AuthService } from '../../auth/auth.service' +import { UserPermissionPolicy } from './user-policy.service' +import { UserGuard } from './user.guard' +import { UserPermissionService } from './user.service' + +describe('userGuard', () => { + let module: TestingModule + let guard: UserGuard + let authService: DeepMockProxy + let userService: DeepMockProxy + let userPolicy: DeepMockProxy + + beforeEach(async () => { + authService = mockDeep() + userService = mockDeep() + userPolicy = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + UserGuard, + { provide: AuthService, useValue: authService }, + { provide: UserPermissionService, useValue: userService }, + { provide: UserPermissionPolicy, useValue: userPolicy }, + ], + }).compile() + + guard = module.get(UserGuard) + }) + + it('throws 401 when the auth service rejects the request', async () => { + authService.authenticate.mockRejectedValue(new UnauthorizedException()) + userPolicy.build.mockReturnValue({ adminPermissions: [], userTypes: [] }) + const ctx = makeExecutionContext({}) + + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException) + }) + + it('authenticates without loading optional auth data when no policy metadata is set', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1' }) + userPolicy.build.mockReturnValue({ adminPermissions: [], userTypes: [] }) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + const result = await guard.canActivate(ctx) + const request = ctx.switchToHttp().getRequest() + + expect(result).toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(request.userId).toBe('u1') + }) + + it('validates required admin permissions', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 32768n }) + const policy: UserPolicyConfig = { adminPermissions: ['ListSystem'], userTypes: [] } + userPolicy.build.mockReturnValue(policy) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + await expect(guard.canActivate(ctx)).resolves.toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(userService.validate).toHaveBeenCalled() + }) + + it('throws 403 when required admin permissions are missing', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 0n }) + const policy: UserPolicyConfig = { adminPermissions: ['ManageSystem'], userTypes: [] } + userPolicy.build.mockReturnValue(policy) + userService.validate.mockImplementation(() => { throw new ForbiddenException() }) + const ctx = makeExecutionContext({ 'x-dso-token': 'tok' }) + + await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException) + }) + + it('validates required user types', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', userType: 'human' }) + const policy: UserPolicyConfig = { adminPermissions: [], userTypes: ['human'] } + userPolicy.build.mockReturnValue(policy) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + await expect(guard.canActivate(ctx)).resolves.toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(userService.validate).toHaveBeenCalled() + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts new file mode 100644 index 0000000000..28c339accd --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts @@ -0,0 +1,60 @@ +import type { CanActivate, ExecutionContext } from '@nestjs/common' +import type { FastifyRequest } from 'fastify' +import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common' +import { AuthService } from '../../auth/auth.service' +import { UserPermissionPolicy } from './user-policy.service' +import { UserPermissionService } from './user.service' + +type RequestWithAuthContext = FastifyRequest & { + userId?: string + adminPermissions?: bigint + userType?: string +} + +@Injectable() +export class UserGuard implements CanActivate { + private readonly logger = new Logger(UserGuard.name) + + constructor( + @Inject(AuthService) private readonly authService: AuthService, + @Inject(UserPermissionService) private readonly userService: UserPermissionService, + @Inject(UserPermissionPolicy) private readonly userPolicy: UserPermissionPolicy, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const policy = this.userPolicy.build(context) + const request = this.getRequest(context) + const user = await this.authenticate(request) + + this.validate(policy, user) + this.logger.debug(`User auth granted (requestId=${request.id}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, userType=${user.userType})`) + + return true + } + + private getRequest(context: ExecutionContext): RequestWithAuthContext { + return context.switchToHttp().getRequest() + } + + private async authenticate(request: RequestWithAuthContext) { + try { + const user = await this.authService.authenticate(request, { + includeAdminRoleIds: true, + includeUserType: true, + }) + + request.userId = user.userId + if (user.adminPermissions !== undefined) request.adminPermissions = user.adminPermissions + if (user.userType !== undefined) request.userType = user.userType + + return user + } catch (error) { + this.logger.warn(`User auth ${error instanceof UnauthorizedException ? 'rejected' : 'denied'} (requestId=${request.id}, error=${error instanceof Error ? error.message : String(error)})`) + throw error + } + } + + private validate(policy: ReturnType, user: Awaited>) { + this.userService.validate(policy, user) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts new file mode 100644 index 0000000000..613821ed2a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../../auth/auth.module' +import { DatabaseModule } from '../../database/database.module' +import { UserPermissionPolicy } from './user-policy.service' +import { UserGuard } from './user.guard' +import { UserPermissionService } from './user.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + ], + providers: [ + UserGuard, + UserPermissionService, + UserPermissionPolicy, + ], + exports: [ + UserGuard, + UserPermissionService, + UserPermissionPolicy, + ], +}) +export class UserPermissionModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts new file mode 100644 index 0000000000..4ad5b84704 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts @@ -0,0 +1,73 @@ +import type { UserContext } from '../../auth/auth-user.decorator' +import { AdminAuthorized } from '@cpn-console/shared' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeUserContext, makeUserPolicy } from './user-testing.utils' +import { UserPermissionService } from './user.service' + +describe('userService', () => { + let service: UserPermissionService + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [UserPermissionService], + }).compile() + + service = module.get(UserPermissionService) + }) + + describe('validateAdminPermissions', () => { + it('does not throw when no admin permissions are required', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({}), 0n)).not.toThrow() + }) + + it('does not throw when the user has the required admin permission', () => { + const adminPerms = AdminAuthorized.ListSystem(32768n) ? 32768n : 0n + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ListSystem'] }), adminPerms)).not.toThrow() + }) + + it('throws ForbiddenException when the user lacks the required admin permission', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ManageSystem'] }), 0n)).toThrow(ForbiddenException) + }) + + it('validates multiple required admin permissions', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ManageSystem', 'ListSystem'] }), 0n)).toThrow(ForbiddenException) + }) + + it('treats undefined adminPermissions as 0n', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['Manage'] }), undefined)).toThrow(ForbiddenException) + }) + }) + + describe('validateUserType', () => { + it('does not throw when no user types are required', () => { + expect(() => service.validateUserType(makeUserPolicy({}), 'human')).not.toThrow() + }) + + it('does not throw when the user type is in the allowed list', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), 'human')).not.toThrow() + }) + + it('throws ForbiddenException when the user type is not in the allowed list', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), 'service')).toThrow(ForbiddenException) + }) + + it('throws ForbiddenException when userType is undefined', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), undefined)).toThrow(ForbiddenException) + }) + }) + + describe('validate', () => { + it('delegates to validateAdminPermissions and validateUserType', () => { + const spyPerms = vi.spyOn(service, 'validateAdminPermissions') + const spyType = vi.spyOn(service, 'validateUserType') + const user: UserContext = makeUserContext({ adminPermissions: 1n, userType: 'human' }) + + service.validate(makeUserPolicy({}), user) + + expect(spyPerms).toHaveBeenCalled() + expect(spyType).toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts new file mode 100644 index 0000000000..3784e2feb6 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts @@ -0,0 +1,36 @@ +import type { User as PrismaUser } from '@prisma/client' +import type { UserContext } from '../../auth/auth-user.decorator' +import type { UserPolicyConfig } from './user-policy.service' +import { AdminAuthorized } from '@cpn-console/shared' +import { ForbiddenException, Injectable, Logger } from '@nestjs/common' + +@Injectable() +export class UserPermissionService { + private readonly logger = new Logger(UserPermissionService.name) + + validate(policy: UserPolicyConfig, user: UserContext): void { + this.validateAdminPermissions(policy, user.adminPermissions) + this.validateUserType(policy, user.userType) + } + + validateAdminPermissions(policy: UserPolicyConfig, adminPermissions: bigint | undefined): void { + if (!policy.adminPermissions.length) return + + const hasPermission = policy.adminPermissions.every( + permName => AdminAuthorized[permName](adminPermissions ?? 0n), + ) + + if (!hasPermission) { + this.logger.warn(`User auth denied: missing admin permissions (adminPermissions=${adminPermissions?.toString()}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Permissions administrateur insuffisantes') + } + } + + validateUserType(policy: UserPolicyConfig, userType: string | undefined): void { + if (!policy.userTypes.length) return + if (typeof userType !== 'string' || !policy.userTypes.includes(userType as PrismaUser['type'])) { + this.logger.warn(`User auth denied: invalid user type (userType=${userType}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Cannot find requestor in request context') + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts new file mode 100644 index 0000000000..6fd1db8ee6 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts @@ -0,0 +1,100 @@ +import { BadRequestException } from '@nestjs/common' +import { beforeEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { ZodValidationPipe } from './zod-validation.pipe' + +describe('zodValidationPipe', () => { + const schema = z.object({ + name: z.string(), + age: z.number().int().positive(), + }) + + let pipe: ZodValidationPipe + + beforeEach(() => { + pipe = new ZodValidationPipe(schema) + }) + + describe('transform', () => { + it('should return validated data when input is valid', () => { + const validInput = { + name: 'Alice', + age: 30, + } + + const result = pipe.transform(validInput) + + expect(result).toEqual(validInput) + }) + + it('should strip unknown fields if schema does not allow them', () => { + const input = { + name: 'Alice', + age: 30, + extra: 'remove me', + } + + const result = pipe.transform(input) + + expect(result).toEqual({ + name: 'Alice', + age: 30, + }) + }) + + it('should throw BadRequestException when required fields are missing', () => { + const invalidInput = { + name: 'Alice', + } + + expect(() => pipe.transform(invalidInput)).toThrow( + BadRequestException, + ) + }) + + it('should throw BadRequestException when field types are invalid', () => { + const invalidInput = { + name: 'Alice', + age: '30', + } + + expect(() => pipe.transform(invalidInput)).toThrow( + BadRequestException, + ) + }) + + it('should include flattened Zod errors in the exception response', () => { + const invalidInput = { + name: 123, + age: -5, + } + + try { + pipe.transform(invalidInput) + throw new Error('Expected transform to throw') + } catch (error) { + expect(error).toBeInstanceOf(BadRequestException) + + if (error instanceof BadRequestException) { + const response = error.getResponse() as any + + expect(response).toHaveProperty('fieldErrors') + expect(response.fieldErrors).toHaveProperty('name') + expect(response.fieldErrors).toHaveProperty('age') + } + } + }) + + it('should throw when input is null', () => { + expect(() => pipe.transform(null)).toThrow( + BadRequestException, + ) + }) + + it('should throw when input is undefined', () => { + expect(() => pipe.transform(undefined)).toThrow( + BadRequestException, + ) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts new file mode 100644 index 0000000000..066fa255d4 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts @@ -0,0 +1,17 @@ +import type { PipeTransform } from '@nestjs/common' +import type { ZodSchema } from 'zod' +import { BadRequestException } from '@nestjs/common' + +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: ZodSchema) {} + + transform(value: unknown) { + const result = this.schema.safeParse(value) + + if (!result.success) { + throw new BadRequestException(result.error.flatten()) + } + + return result.data + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts new file mode 100644 index 0000000000..3056b5bcb1 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts @@ -0,0 +1,2 @@ +export const TRACER_NAME = '@cpn-console/server-nestjs' +export const SERVICE_NAME = 'console-pi-native-console' diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts new file mode 100644 index 0000000000..950f09d0cc --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts @@ -0,0 +1,74 @@ +import type { Span as OpenTelemetrySpan, SpanOptions } from '@opentelemetry/api' +import { SpanStatusCode, trace } from '@opentelemetry/api' +import { TRACER_NAME } from './telemetry.constants' + +export type TypedMethodDecorator = any>( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, +) => void + +export function StartActiveSpan(options?: SpanOptions): TypedMethodDecorator { + return any>( + _target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ): void => { + const original = descriptor.value + if (!original) return + + descriptor.value = function (this: ThisParameterType, ...args: Parameters): ReturnType { + const tracer = trace.getTracer(TRACER_NAME) + const className = this?.constructor?.name ?? 'Unknown' + const spanName = `${className}.${String(propertyKey)}` + + const runInActiveSpan = (span: OpenTelemetrySpan) => { + try { + const result = original.apply(this, args) + + if (isPromiseLike(result)) { + return handlePromiseResult(span, result) as ReturnType + } + + span.end() + return result + } catch (error) { + recordException(span, error) + span.end() + throw error + } + } + + if (options) { + return tracer.startActiveSpan(spanName, options, runInActiveSpan) as ReturnType + } + return tracer.startActiveSpan(spanName, runInActiveSpan) as ReturnType + } as T + } +} + +function isPromiseLike(value: unknown): value is Promise { + if (!value) return false + return typeof (value as Promise).then === 'function' +} + +async function handlePromiseResult(span: OpenTelemetrySpan, promise: Promise): Promise { + try { + return await promise + } catch (error) { + recordException(span, error) + throw error + } finally { + span.end() + } +} + +function recordException(span: OpenTelemetrySpan, error: unknown): void { + // If it's an actual Error object, OpenTelemetry captures the stack trace automatically + if (error instanceof Error) { + span.recordException(error) + } else { + span.recordException(String(error)) + } + span.setStatus({ code: SpanStatusCode.ERROR }) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts new file mode 100644 index 0000000000..f30d6c6af7 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts @@ -0,0 +1,251 @@ +import type { TestingModule } from '@nestjs/testing' +import type { KeycloakConfig } from './keycloak.module-definition' +import KcAdminClient from '@keycloak/keycloak-admin-client' +import { ScheduleModule } from '@nestjs/schedule' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' +import { ADMIN_TOKEN_REFRESH_INTERVAL_MS } from './keycloak.constants' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +const keycloakUrl = 'https://keycloak.internal' +const projectRealm = 'project-realm' +// The only handled token endpoint is the master realm's: a token request +// against any other realm is unhandled and fails the test, which enforces +// the realm pinning of authenticate() +const tokenUrl = `${keycloakUrl}/realms/master/protocol/openid-connect/token` +const childrenUrl = `${keycloakUrl}/admin/realms/${projectRealm}/groups/:parentId/children` + +const server = setupServer() + +function useTokenEndpoint({ rejectGrant = () => false }: { rejectGrant?: (grantType: string | null) => boolean } = {}) { + const tokenRequests: URLSearchParams[] = [] + let issued = 0 + server.use( + http.post(tokenUrl, async ({ request }) => { + const body = new URLSearchParams(await request.text()) + tokenRequests.push(body) + if (rejectGrant(body.get('grant_type'))) { + return HttpResponse.json({ error: 'invalid_grant' }, { status: 401 }) + } + issued++ + return HttpResponse.json({ access_token: `access-token-${issued}`, refresh_token: `refresh-token-${issued}`, expires_in: 60 }) + }), + ) + return tokenRequests +} + +function createKeycloakClientServiceTestingModule(config: Partial = {}) { + return Test.createTestingModule({ + imports: [ScheduleModule.forRoot()], + providers: [ + KeycloakClientService, + { provide: KEYCLOAK_ADMIN_CLIENT, useValue: new KcAdminClient({ baseUrl: keycloakUrl }) }, + { + provide: KEYCLOAK_CONFIG, + useValue: mockDeep({ + realm: projectRealm, + admin: 'admin', + adminPassword: 'admin-password', + adminClientId: 'admin-cli', + ...config, + }), + }, + ], + }) +} + +describe('keycloakClientService authentication lifecycle', () => { + let module: TestingModule + let client: KcAdminClient + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + vi.useFakeTimers() + module = await createKeycloakClientServiceTestingModule().compile() + client = module.get(KEYCLOAK_ADMIN_CLIENT) + }) + afterEach(async () => { + // close() re-runs init() on a module whose init() already failed; swallow + // that rethrow so the "initial authentication fails" test can clean up + await module.close().catch(() => {}) + server.resetHandlers() + vi.useRealTimers() + }) + afterAll(() => server.close()) + + it('should authenticate with the password grant then switch to the project realm', async () => { + const tokenRequests = useTokenEndpoint() + + await module.init() + + expect(tokenRequests).toHaveLength(1) + expect(Object.fromEntries(tokenRequests[0])).toMatchObject({ + grant_type: 'password', + client_id: 'admin-cli', + username: 'admin', + password: 'admin-password', + }) + expect(client.accessToken).toBe('access-token-1') + expect(client.realmName).toBe(projectRealm) + }) + + it('should refresh the token periodically with the rotated refresh token so it never expires', async () => { + const tokenRequests = useTokenEndpoint() + await module.init() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(2) + expect(Object.fromEntries(tokenRequests[1])).toMatchObject({ + grant_type: 'refresh_token', + client_id: 'admin-cli', + refresh_token: 'refresh-token-1', + }) + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(3) + expect(tokenRequests[2].get('refresh_token')).toBe('refresh-token-2') + expect(client.accessToken).toBe('access-token-3') + expect(client.realmName).toBe(projectRealm) + }) + + it('should fall back to a full re-authentication when the refresh grant fails', async () => { + const tokenRequests = useTokenEndpoint({ rejectGrant: grantType => grantType === 'refresh_token' }) + await module.init() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + + expect(tokenRequests.map(body => body.get('grant_type'))).toEqual(['password', 'refresh_token', 'password']) + expect(client.accessToken).toBe('access-token-2') + expect(client.realmName).toBe(projectRealm) + }) + + it('should keep refreshing and restore the project realm when both grants fail', async () => { + let keycloakIsDown = false + const tokenRequests = useTokenEndpoint({ rejectGrant: () => keycloakIsDown }) + await module.init() + + keycloakIsDown = true + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests.map(body => body.get('grant_type'))).toEqual(['password', 'refresh_token', 'password']) + expect(client.realmName).toBe(projectRealm) + + keycloakIsDown = false + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(4) + expect(tokenRequests[3].get('grant_type')).toBe('refresh_token') + expect(client.accessToken).toBe('access-token-2') + }) + + it('should stop refreshing the token on module destroy', async () => { + const tokenRequests = useTokenEndpoint() + await module.init() + expect(tokenRequests).toHaveLength(1) + + await module.close() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 3) + expect(tokenRequests).toHaveLength(1) + }) + + it('should rethrow when the initial authentication fails', async () => { + const tokenRequests = useTokenEndpoint({ rejectGrant: () => true }) + + await expect(module.init()).rejects.toThrow('invalid_grant') + expect(client.realmName).toBe('master') + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) + expect(tokenRequests).toHaveLength(1) + }) +}) + +describe('getOrCreateSubGroupByName', () => { + let module: TestingModule + let service: KeycloakClientService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + module = await createKeycloakClientServiceTestingModule().compile() + service = module.get(KeycloakClientService) + useTokenEndpoint() + await module.init() + }) + afterEach(async () => { + await module.close() + server.resetHandlers() + }) + afterAll(() => server.close()) + + it('should return the existing subgroup without creating it', async () => { + // No POST handler: a create attempt would fail the test + server.use( + http.get(childrenUrl, ({ params }) => { + expect(params.parentId).toBe('parent-id') + return HttpResponse.json([{ id: 'sub-id', name: 'sub' }]) + }), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toMatchObject({ id: 'sub-id', name: 'sub' }) + }) + + it('should create the subgroup when it does not exist', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([])), + http.post(childrenUrl, async ({ request, params }) => { + expect(params.parentId).toBe('parent-id') + expect(await request.json()).toEqual({ name: 'sub' }) + return new HttpResponse(null, { + status: 201, + headers: { location: `${keycloakUrl}/admin/realms/${projectRealm}/groups/created-id` }, + }) + }), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toEqual({ id: 'created-id', name: 'sub' }) + }) + + it('should re-fetch the subgroup when a concurrent creation causes a 409', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([]), { once: true }), + http.get(childrenUrl, () => HttpResponse.json([{ id: 'concurrent-id', name: 'sub' }])), + http.post(childrenUrl, () => + HttpResponse.json({ errorMessage: 'Sibling group named \'sub\' already exists.' }, { status: 409 })), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toMatchObject({ id: 'concurrent-id', name: 'sub' }) + }) + + it('should rethrow the 409 when the subgroup still cannot be found', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([])), + http.post(childrenUrl, () => + HttpResponse.json({ errorMessage: 'Sibling group named \'sub\' already exists.' }, { status: 409 })), + ) + + await expect(service.getOrCreateSubGroupByName('parent-id', 'sub')).rejects.toThrow('Sibling group named') + }) + + it('should rethrow non-409 errors without re-fetching', async () => { + let listCalls = 0 + server.use( + http.get(childrenUrl, () => { + listCalls++ + return HttpResponse.json([]) + }), + http.post(childrenUrl, () => + HttpResponse.json({ error: 'unauthorized' }, { status: 401 })), + ) + + await expect(service.getOrCreateSubGroupByName('parent-id', 'sub')).rejects.toThrow('unauthorized') + expect(listCalls).toBe(1) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts new file mode 100644 index 0000000000..bb106d64e2 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -0,0 +1,343 @@ +import type KcAdminClient from '@keycloak/keycloak-admin-client' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { Credentials } from '@keycloak/keycloak-admin-client/lib/utils/auth' +import type { OnModuleInit } from '@nestjs/common' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import type { KeycloakConfig } from './keycloak.module-definition' +import type { GroupRepresentationWith } from './keycloak.utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { Interval } from '@nestjs/schedule' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { getErrorResponseStatus } from '../../utils/http.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { ADMIN_AUTH_REALM, ADMIN_TOKEN_REFRESH_INTERVAL_MS, CONSOLE_GROUP_NAME, PASSWORD_GRANT_TYPE, REFRESH_TOKEN_GRANT_TYPE, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +export const KEYCLOAK_ADMIN_CLIENT = Symbol('KEYCLOAK_ADMIN_CLIENT') + +@Injectable() +export class KeycloakClientService implements OnModuleInit { + private readonly logger = new Logger(KeycloakClientService.name) + + private authenticated = false + + constructor( + @Inject(KEYCLOAK_CONFIG) private readonly keycloakConfig: KeycloakConfig, + @Inject(KEYCLOAK_ADMIN_CLIENT) private readonly client: KcAdminClient, + ) { + } + + async* getAllGroups() { + let first = 0 + while (true) { + const fetched = await this.client.groups.find({ first, max: SUBGROUPS_PAGINATE_QUERY_MAX, briefRepresentation: false }) + this.logger.verbose(`Loaded a Keycloak groups page (first=${first}, count=${fetched.length})`) + if (fetched.length === 0) break + for (const group of fetched) { + yield group + } + if (fetched.length < SUBGROUPS_PAGINATE_QUERY_MAX) break + first += SUBGROUPS_PAGINATE_QUERY_MAX + } + } + + @StartActiveSpan() + async getGroupByName(name: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + const groups = await this.client.groups.find({ search: name, briefRepresentation: false }) ?? [] + return groups.find(g => g.name === name) + } + + async getGroupByPath(path: string): Promise { + const parts = path.split('/').filter(Boolean) + this.logger.verbose(`Resolving Keycloak group path ${path} (depth=${parts.length})`) + let current: GroupRepresentationWith<'id'> | undefined + if (parts.length === 0) return undefined + + for (const name of parts) { + current = current + ? await this.getSubGroupByName(current.id, name) + : await this.getRootGroupByName(name) + + if (!current) { + this.logger.verbose(`Keycloak group path segment was not found (path=${path}, missing=${name})`) + return undefined + } + } + this.logger.verbose(`Keycloak group path resolved (path=${path}, groupId=${current?.id})`) + return current + } + + private async getSubGroupByName(parentId: string, name: string): Promise | undefined> { + for await (const subgroup of this.getSubGroups(parentId)) { + if (subgroup.name === name) { + const parsed = z.object({ id: z.string() }).and(z.record(z.string(), z.unknown())).safeParse(subgroup) + return parsed.success ? parsed.data : undefined + } + } + return undefined + } + + private async getRootGroupByName(name: string): Promise | undefined> { + const candidates = await this.client.groups.find({ search: name, briefRepresentation: false }) ?? [] + const match = candidates.find(g => g.path === `/${name}`) ?? candidates.find(g => g.name === name) + const parsed = z.object({ id: z.string() }).and(z.record(z.string(), z.unknown())).safeParse(match) + return parsed.success ? parsed.data : undefined + } + + async deleteGroup(id: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', id) + this.logger.log(`Deleting Keycloak group (groupId=${id})`) + await this.client.groups.del({ id }) + } + + async getGroupMembers(groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + const members = await this.client.groups.listMembers({ id: groupId }) + this.logger.verbose(`Loaded Keycloak group members (groupId=${groupId}, count=${members?.length ?? 0})`) + return members || [] + } + + async getUserByEmail(email: string): Promise { + const users = await this.client.users.find({ + email, + exact: true, + max: 1, + }) + return users[0] + } + + @StartActiveSpan() + async createGroup(name: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + this.logger.debug(`Creating Keycloak group ${name}`) + const result = await this.client.groups.create({ name }) + return { ...result, name } as GroupRepresentation + } + + async addUserToGroup(userId: string, groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + this.logger.verbose(`Adding user to Keycloak group (userId=${userId}, groupId=${groupId})`) + return this.client.users.addToGroup({ id: userId, groupId }) + } + + async removeUserFromGroup(userId: string, groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + this.logger.verbose(`Removing user from Keycloak group (userId=${userId}, groupId=${groupId})`) + return this.client.users.delFromGroup({ id: userId, groupId }) + } + + async* getSubGroups(parentId: string) { + let first = 0 + while (true) { + const page = await this.client.groups.listSubGroups({ + parentId, + briefRepresentation: false, + max: SUBGROUPS_PAGINATE_QUERY_MAX, + first, + }) + this.logger.verbose(`Loaded a Keycloak subgroups page (parentId=${parentId}, first=${first}, count=${page.length})`) + if (page.length === 0) break + for (const subgroup of page) { + yield subgroup + } + if (page.length < SUBGROUPS_PAGINATE_QUERY_MAX) break + first += SUBGROUPS_PAGINATE_QUERY_MAX + } + } + + async getOrCreateGroupByPath(path: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.path.depth', path.split('/').filter(Boolean).length) + this.logger.verbose(`Ensuring Keycloak group path exists: ${path}`) + const existingGroup = await this.getGroupByPath(path) + if (existingGroup) { + this.logger.verbose(`Keycloak group already exists at path ${path}`) + return existingGroup + } + + const parts = path.split('/').filter(Boolean) + let parentId: string | undefined + let current: GroupRepresentationWith<'id' | 'name'> | undefined + + for (const name of parts.values()) { + if (current) { + if (!parentId) parentId = current.id + const next = z.object({ id: z.string(), name: z.string() }).safeParse(await this.getOrCreateSubGroupByName(parentId, name)) + if (next.success) current = next.data + } else { + const next = z.object({ id: z.string(), name: z.string() }).safeParse(await this.getGroupByName(name) ?? await this.createGroup(name)) + if (next.success) current = next.data + } + parentId = current?.id + } + + if (current) { + this.logger.log(`Created Keycloak group path ${path} (groupId=${current.id})`) + } + return { ...current, path } as GroupRepresentation + } + + @StartActiveSpan() + async getOrCreateSubGroupByName(parentId: string, name: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + span?.setAttribute('parent.id', parentId) + const existing = await this.findSubGroupByName(parentId, name) + if (existing) return existing + + this.logger.debug(`Creating Keycloak subgroup ${name} under parentId=${parentId}`) + try { + const createdGroup = await this.client.groups.createChildGroup({ id: parentId }, { name }) + return { id: createdGroup.id, name } satisfies GroupRepresentation + } catch (err) { + // A concurrent reconciliation may have created the subgroup between the + // scan and the create; treat the 409 as "already exists" and re-fetch it + if (getErrorResponseStatus(err) !== 409) throw err + this.logger.verbose(`Keycloak subgroup ${name} was created concurrently under parentId=${parentId}, fetching it`) + const subgroup = await this.findSubGroupByName(parentId, name) + if (!subgroup) throw err + return subgroup + } + } + + private async findSubGroupByName(parentId: string, name: string): Promise { + for await (const subgroup of this.getSubGroups(parentId)) { + if (subgroup.name === name) { + return subgroup + } + } + return undefined + } + + async getOrCreateConsoleGroup(projectGroup: GroupRepresentationWith<'id'>) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', projectGroup.id) + this.logger.verbose(`Ensuring Keycloak console group exists (projectGroupId=${projectGroup.id})`) + return this.getOrCreateSubGroupByName(projectGroup.id, CONSOLE_GROUP_NAME) + } + + async getOrCreateRoleGroup( + consoleGroup: GroupRepresentationWith<'id' | 'name'>, + oidcGroup: string, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.group.id': consoleGroup.id, + 'role.oidc_group.present': !!oidcGroup, + 'role.oidc_group.depth': oidcGroup.split('/').filter(Boolean).length, + }) + const parts = oidcGroup.split('/').filter(Boolean) + if (parts.length === 0) { + throw new Error(`Invalid oidcGroup for project role: "${oidcGroup}"`) + } + + let current = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(consoleGroup.id, parts[0])) + + for (const name of parts.slice(1)) { + current = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(current.id, name)) + } + + return { ...current, path: `/${consoleGroup.name}/${parts.join('/')}` } satisfies GroupRepresentation + } + + async getOrCreateEnvironmentGroups(consoleGroup: GroupRepresentationWith<'id'>, environment: ProjectWithDetails['environments'][number]) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.group.id': consoleGroup.id, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const envGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(consoleGroup.id, environment.name)) + const [roGroup, rwGroup] = await Promise.all([ + this.getOrCreateSubGroupByName(envGroup.id, 'RO'), + this.getOrCreateSubGroupByName(envGroup.id, 'RW'), + ]) + this.logger.verbose(`Resolved Keycloak environment groups (consoleGroupId=${consoleGroup.id}, env=${environment.name}, envGroupId=${envGroup.id})`) + return { roGroup, rwGroup } + } + + async onModuleInit() { + try { + this.logger.log(`Authenticating Keycloak admin client (realm=${this.keycloakConfig.realm})`) + await this.authenticate(this.passwordCredentials()) + } catch (err) { + if (err instanceof Error) { + this.logger.error(`Keycloak Admin Client authentication failed: ${err.message}`, err.stack) + } else { + this.logger.error(`Keycloak Admin Client authentication failed: ${String(err)}`) + } + throw err + } + this.client.setConfig({ realmName: this.keycloakConfig.realm }) + this.authenticated = true + this.logger.log(`Keycloak Admin Client authenticated (realm=${this.keycloakConfig.realm})`) + } + + // The admin client never refreshes its token on its own; without this the + // access token expires (~60s) and every admin call fails with a 401 + @Interval(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + async refreshAdminToken() { + if (!this.authenticated) return + try { + await this.authenticate(this.refreshTokenCredentials()) + } catch (refreshErr) { + // The refresh token itself can expire or be revoked (e.g. Keycloak + // restart); fall back to a full re-authentication to recover + this.logger.warn(`Keycloak Admin Client token refresh failed, re-authenticating: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`) + try { + await this.authenticate(this.passwordCredentials()) + } catch (err) { + this.logger.error(`Keycloak Admin Client re-authentication failed: ${err instanceof Error ? err.message : String(err)}`) + } + } + } + + private passwordCredentials(): Credentials { + return { + clientId: this.keycloakConfig.adminClientId, + grantType: PASSWORD_GRANT_TYPE, + username: this.keycloakConfig.admin, + password: this.keycloakConfig.adminPassword, + } + } + + private refreshTokenCredentials(): Credentials { + return { + clientId: this.keycloakConfig.adminClientId, + grantType: REFRESH_TOKEN_GRANT_TYPE, + refreshToken: this.client.refreshToken, + } + } + + // auth() resolves the token endpoint from client.realmName, which onModuleInit + // switches to the project realm — the admin user lives in the master realm. + // Restore the realm even when auth fails: the still-valid previous token keeps + // serving admin calls, and those must target the project realm, not master + private async authenticate(credentials: Credentials) { + const realmName = this.client.realmName + this.client.setConfig({ realmName: ADMIN_AUTH_REALM }) + try { + await this.client.auth(credentials) + } finally { + this.client.setConfig({ realmName }) + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts new file mode 100644 index 0000000000..0c0a028836 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts @@ -0,0 +1,88 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + slug: true, + ownerId: true, + everyonePerms: true, + members: { + select: { + roleIds: true, + user: { + select: { + id: true, + email: true, + }, + }, + }, + }, + roles: { + select: { + id: true, + permissions: true, + oidcGroup: true, + type: true, + }, + }, + environments: { + select: { + id: true, + name: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export const adminRoleSelect = { + id: true, + oidcGroup: true, + type: true, +} satisfies Prisma.AdminRoleSelect + +export type AdminRoleWithDetails = Prisma.AdminRoleGetPayload<{ + select: typeof adminRoleSelect +}> + +export const userAdminRoleSelect = { + id: true, + adminRoleIds: true, +} satisfies Prisma.UserSelect + +export type UserWithAdminRoles = Prisma.UserGetPayload<{ + select: typeof userAdminRoleSelect +}> + +@Injectable() +export class KeycloakDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getAllAdminRoles(): Promise { + return this.prisma.adminRole.findMany({ + select: adminRoleSelect, + }) + } + + async getAllUsersWithAdminRoleIds(): Promise { + return this.prisma.user.findMany({ + select: userAdminRoleSelect, + }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts new file mode 100644 index 0000000000..6d78945601 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts @@ -0,0 +1,25 @@ +import type { KeycloakConfig } from './keycloak.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +@Injectable() +export class KeycloakHealthService { + constructor( + @Inject(KEYCLOAK_CONFIG) + private readonly keycloakConfig: KeycloakConfig, + @Inject(HealthIndicatorService) + private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts new file mode 100644 index 0000000000..a0b5e2dfac --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts @@ -0,0 +1,16 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Injectable } from '@nestjs/common' + +@Injectable() +export class KeycloakPluginService { + infos(): ServiceInfos { + return { + name: 'keycloak', + title: 'Keycloak', + config: { + global: [], + project: [], + }, + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts new file mode 100644 index 0000000000..17fb5c5f0d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts @@ -0,0 +1,87 @@ +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { ProjectWithDetails } from './keycloak-datastore.service' + +import { faker } from '@faker-js/faker' + +export function makeUserRepresentation( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + username: faker.internet.username(), + enabled: true, + ...overrides, + } satisfies UserRepresentation +} + +export function makeGroupRepresentation( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + path: `/${faker.word.noun()}`, + subGroups: [], + ...overrides, + } satisfies GroupRepresentation +} + +export function makeProjectUser( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + ...overrides, + } satisfies ProjectWithDetails['members'][number]['user'] +} + +export function makeProjectMember( + overrides: Partial = {}, +) { + return { + roleIds: [], + user: makeProjectUser(), + ...overrides, + } satisfies ProjectWithDetails['members'][number] +} + +export function makeProjectRole( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + permissions: 0n, + oidcGroup: '', + type: 'managed', + ...overrides, + } satisfies ProjectWithDetails['roles'][number] +} + +export function makeProjectEnvironment( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + ...overrides, + } satisfies ProjectWithDetails['environments'][number] +} + +export function makeProjectWithDetails( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(faker.word.words({ count: 2 })).toLowerCase(), + ownerId: faker.string.uuid(), + everyonePerms: 0n, + plugins: [], + members: [], + roles: [], + environments: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts new file mode 100644 index 0000000000..e964d9e74c --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts @@ -0,0 +1,20 @@ +// Keycloak plugin constants +export const PLUGIN_NAME = 'keycloak' + +// Name for the console group for admin and project roles +export const CONSOLE_GROUP_NAME = 'console' + +// Realm hosting the admin user used by the admin client (password grant on admin-cli) +export const ADMIN_AUTH_REALM = 'master' + +// OAuth2 grant types (RFC 6749) used to authenticate the admin client +export const PASSWORD_GRANT_TYPE = 'password' +export const REFRESH_TOKEN_GRANT_TYPE = 'refresh_token' + +// Keycloak's default access token lifespan is 60s; refresh well before it +// expires so a slow or delayed tick still lands within the lifespan +export const ADMIN_TOKEN_REFRESH_INTERVAL_MS = 45_000 + +// Maximum number of entities returned in a paginated query +export const GROUPS_PAGINATE_QUERY_MAX = 20 +export const SUBGROUPS_PAGINATE_QUERY_MAX = 20 diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts new file mode 100644 index 0000000000..8245a556b9 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts @@ -0,0 +1,29 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface KeycloakConfig { + protocol: string + domain?: string + publicProtocol: string + publicDomain?: string + realm?: string + clientId?: string + clientSecret?: string + admin?: string + adminPassword?: string + adminClientId: string + redirectUri?: string + jwksCacheTtlMs: number + jwksTimeoutMs: number + openidConfigurationCacheTtlMs: number + adminKcUserId: string[] + url?: string + realmUrl?: string + openidConfigurationUrl?: string +} + +export interface KeycloakModuleOptions extends KeycloakConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('keycloak') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as KEYCLOAK_CONFIG } diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts new file mode 100644 index 0000000000..7186de782a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts @@ -0,0 +1,31 @@ +import type { KeycloakConfig } from './keycloak.module-definition' +import KcAdminClient from '@keycloak/keycloak-admin-client' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { KeycloakHealthService } from './keycloak-health.service' +import { KeycloakPluginService } from './keycloak-plugin.service' +import { ConfigurableModuleClass, KEYCLOAK_CONFIG } from './keycloak.module-definition' +import { KeycloakService } from './keycloak.service' + +@Module({ + imports: [DatabaseModule, TerminusModule], + providers: [ + { + inject: [KEYCLOAK_CONFIG], + provide: KEYCLOAK_ADMIN_CLIENT, + useFactory: (config: KeycloakConfig) => new KcAdminClient({ + baseUrl: config.url, + }), + }, + KeycloakClientService, + KeycloakDatastoreService, + KeycloakHealthService, + KeycloakPluginService, + KeycloakService, + ], + exports: [KeycloakClientService, KeycloakHealthService, KeycloakPluginService, KeycloakService], +}) +export class KeycloakModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts new file mode 100644 index 0000000000..e5b116688a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -0,0 +1,450 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { AdminRoleWithDetails, ProjectWithDetails, UserWithAdminRoles } from './keycloak-datastore.service' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { + makeGroupRepresentation, + makeProjectEnvironment, + makeProjectMember, + makeProjectRole, + makeProjectUser, + makeProjectWithDetails, + makeUserRepresentation, +} from './keycloak-testing.utils' +import { KeycloakService } from './keycloak.service' + +describe('keycloakService', () => { + let service: KeycloakService + let keycloak: DeepMockProxy + let datastore: DeepMockProxy + + beforeEach(async () => { + keycloak = mockDeep({ + getOrCreateConsoleGroup: vi.fn().mockResolvedValue(makeGroupRepresentation({ id: 'console-group-id', name: 'console' })), + getOrCreateEnvironmentGroups: vi.fn().mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'rw-id', name: 'RW' }), + }), + getAllGroups: vi.fn().mockImplementation(async function* () { /* empty by default */ }), + deleteGroup: vi.fn().mockResolvedValue(undefined), + }) + datastore = mockDeep({ + getAllAdminRoles: vi.fn().mockResolvedValue([]), + getAllUsersWithAdminRoleIds: vi.fn().mockResolvedValue([]), + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + KeycloakService, + { provide: KeycloakClientService, useValue: keycloak }, + { provide: KeycloakDatastoreService, useValue: datastore }, + ], + }).compile() + + service = moduleRef.get(KeycloakService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('reconcile', () => { + const mockProject: ProjectWithDetails = makeProjectWithDetails({ + id: 'project-id', + slug: 'test-project', + ownerId: 'owner-id', + everyonePerms: 0n, + }) + + it('should not sync system external admin roles', async () => { + const adminRoles: AdminRoleWithDetails[] = [ + { id: 'admin-role-id', oidcGroup: '/console/system-external', type: 'system:external' }, + ] + const users: UserWithAdminRoles[] = [ + { id: 'user-1', adminRoleIds: ['admin-role-id'] }, + ] + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + + await service.handleCron() + + expect(keycloak.getOrCreateGroupByPath).not.toHaveBeenCalledWith('/console/system-external') + expect(keycloak.getGroupMembers).not.toHaveBeenCalled() + expect(keycloak.addUserToGroup).not.toHaveBeenCalled() + expect(keycloak.removeUserFromGroup).not.toHaveBeenCalled() + }) + + it('should sync admin role groups', async () => { + const adminRoles: AdminRoleWithDetails[] = [ + { id: 'admin-role-id', oidcGroup: '/console/admin', type: 'managed' }, + ] + const users: UserWithAdminRoles[] = [ + { id: 'user-1', adminRoleIds: ['admin-role-id'] }, + { id: 'user-2', adminRoleIds: [] }, + ] + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + + const adminGroup = makeGroupRepresentation({ id: 'kc-group-id', name: 'admin', path: '/console/admin' }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(adminGroup) + + keycloak.getGroupMembers.mockResolvedValue([ + makeUserRepresentation({ id: 'user-2' }), + ]) + + await service.handleCron() + + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/console/admin') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'kc-group-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'kc-group-id') + }) + + it('should purge orphans', async () => { + datastore.getAllProjects.mockResolvedValue([mockProject]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project', subGroups: [] }) + const orphanGroup = makeGroupRepresentation({ + id: 'orphan-id', + name: 'orphan-project', + subGroups: [makeGroupRepresentation({ name: 'console' })], + }) + + keycloak.getAllGroups.mockImplementation(async function* () { + yield projectGroup + yield orphanGroup + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(keycloak.getAllGroups).toHaveBeenCalled() + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/test-project') + expect(keycloak.deleteGroup).toHaveBeenCalledWith('orphan-id') + }) + + it('should sync project members', async () => { + const projectWithMembers = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: [], + }), + ], + }) + datastore.getAllProjects.mockResolvedValue([projectWithMembers]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + + // Current members: user-2 (extra), missing user-1 + keycloak.getGroupMembers.mockResolvedValue([ + makeUserRepresentation({ id: 'user-2', email: 'user2@example.com' }), + ]) + + keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should add missing member + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'group-id') + // Should add owner (missing in group members) + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('owner-id', 'group-id') + // Should remove extra member + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'group-id') + }) + + it('should sync OIDC role groups', async () => { + const roleWithOidc = makeProjectRole({ + id: 'role-oidc', + permissions: 0n, + oidcGroup: '/oidc-group', + type: 'managed', + }) + const projectWithRole = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-oidc'], + }), + ], + roles: [roleWithOidc], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRole]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const roleGroup = makeGroupRepresentation({ id: 'role-group-id', name: 'oidc-group', path: '/console/oidc-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockResolvedValue(roleGroup) + + // Project members: owner + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + // Role group members: user-2 (extra), missing user-1 + if (groupId === 'role-group-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2', email: 'user2@example.com' })]) + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should create/get role group + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/oidc-group') + // Should add user-1 to role group + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'role-group-id') + // Should remove user-2 from role group + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'role-group-id') + }) + + it('should sync environment groups', async () => { + const projectWithEnv = makeProjectWithDetails({ + ...mockProject, + environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], + }) + datastore.getAllProjects.mockResolvedValue([projectWithEnv]) + + const projectGroup = makeGroupRepresentation({ + id: 'group-id', + name: 'test-project', + subGroups: [makeGroupRepresentation({ name: 'console', id: 'console-id' })], + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + + // Mock console group retrieval + keycloak.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' }), + }) + keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + if (name === 'dev') return Promise.resolve(makeGroupRepresentation({ id: 'dev-id', name: 'dev' })) + if (name === 'RO') return Promise.resolve(makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' })) + if (name === 'RW') return Promise.resolve(makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' })) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name })) + }) + + // Mock existing environments: 'staging' (extra) + keycloak.getSubGroups.mockImplementation(async function* (parentId) { + if (parentId === 'console-id') { + yield makeGroupRepresentation({ id: 'staging-id', name: 'staging' }) + } + if (parentId === 'staging-id') { + yield makeGroupRepresentation({ name: 'RO' }) + yield makeGroupRepresentation({ name: 'RW' }) + } + }) + + await service.handleCron() + + // Should create dev group + expect(keycloak.getOrCreateConsoleGroup).toHaveBeenCalledWith({ id: 'group-id', name: 'test-project' }) + // Should create RO/RW groups + expect(keycloak.getOrCreateEnvironmentGroups).toHaveBeenCalledWith({ id: 'console-id', name: 'console' }, projectWithEnv.environments[0]) + // Should delete staging group + expect(keycloak.deleteGroup).toHaveBeenCalledWith('staging-id') + }) + + it('should sync environment permissions', async () => { + const userRo = makeUserRepresentation({ id: 'user-ro', email: 'ro@example.com' }) + const userRw = makeUserRepresentation({ id: 'user-rw', email: 'rw@example.com' }) + const userNone = makeUserRepresentation({ id: 'user-none', email: 'none@example.com' }) + + const projectWithEnvAndMembers = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: userRo.id, email: userRo.email }), + roleIds: ['role-ro'], + }), + makeProjectMember({ + user: makeProjectUser({ id: userRw.id, email: userRw.email }), + roleIds: ['role-rw'], + }), + makeProjectMember({ + user: makeProjectUser({ id: userNone.id, email: userNone.email }), + roleIds: [], + }), + ], + roles: [ + makeProjectRole({ id: 'role-ro', permissions: 256n, oidcGroup: '', type: 'managed' }), + makeProjectRole({ id: 'role-rw', permissions: 8n, oidcGroup: '', type: 'managed' }), + ], + environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], + }) + datastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) + + const projectGroup = makeGroupRepresentation({ + id: 'group-id', + name: 'test-project', + subGroups: [makeGroupRepresentation({ name: 'console', id: 'console-id' })], + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' }), + }) + + // Project group members (assume all are in project group for simplicity) + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([userRo, userRw, userNone]) + // RO group has userNone (extra), missing userRo + if (groupId === 'dev-ro-id') return Promise.resolve([userNone]) + // RW group has userNone (extra), missing userRw + if (groupId === 'dev-rw-id') return Promise.resolve([userNone]) + return Promise.resolve([]) + }) + + keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + if (name === 'dev') return Promise.resolve(makeGroupRepresentation({ id: 'dev-id', name: 'dev' })) + if (name === 'RO') return Promise.resolve(makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' })) + if (name === 'RW') return Promise.resolve(makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' })) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name })) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Sync RO + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-ro', 'dev-ro-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-ro-id') + // Sync RW + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-rw', 'dev-rw-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-rw-id') + }) + + it('should handle different role types (managed, external, global)', async () => { + const roleManaged = makeProjectRole({ id: 'role-managed', permissions: 0n, oidcGroup: '/managed-group', type: 'managed' }) + const roleExternal = makeProjectRole({ id: 'role-external', permissions: 0n, oidcGroup: '/external-group', type: 'external' }) + const roleGlobal = makeProjectRole({ id: 'role-global', permissions: 0n, oidcGroup: '/global-group', type: 'global' }) + + const projectWithRoles = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-managed', 'role-external', 'role-global'], + }), + ], + roles: [roleManaged, roleExternal, roleGlobal], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const managedGroup = makeGroupRepresentation({ id: 'managed-id', name: 'managed-group' }) + const externalGroup = makeGroupRepresentation({ id: 'external-id', name: 'external-group' }) + const globalGroup = makeGroupRepresentation({ id: 'global-id', name: 'global-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockImplementation((_consoleGroup, oidcGroup) => { + if (oidcGroup === '/managed-group') return Promise.resolve({ ...managedGroup, path: '/console/managed-group' }) + if (oidcGroup === '/external-group') return Promise.resolve({ ...externalGroup, path: '/console/external-group' }) + if (oidcGroup === '/global-group') return Promise.resolve({ ...globalGroup, path: '/console/global-group' }) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name: oidcGroup, path: `/console/${oidcGroup}` })) + }) + + // Group members + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + + // Managed: has extra user-2, missing user-1 + if (groupId === 'managed-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + // External: has extra user-2, missing user-1 + if (groupId === 'external-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + // Global: create group if it doesn't exist but no members + if (groupId === 'global-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Managed: should add user-1, remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/managed-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'managed-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'managed-id') + + // External: should add user-1, NOT remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/external-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'external-id') + expect(keycloak.removeUserFromGroup).not.toHaveBeenCalledWith('user-2', 'external-id') + + // Global: should sync group but no members + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/global-group') + }) + + it('should treat system-prefixed role types as their base type', async () => { + const roleSystemManaged = makeProjectRole({ id: 'role-system-managed', permissions: 0n, oidcGroup: '/system-managed-group', type: 'system:managed' }) + + const projectWithRoles = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-system-managed'], + }), + ], + roles: [roleSystemManaged], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const systemManagedGroup = makeGroupRepresentation({ id: 'system-managed-id', name: 'system-managed-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockResolvedValue({ ...systemManagedGroup, path: '/console/system-managed-group' }) + + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + + // has extra user-2, missing user-1 + if (groupId === 'system-managed-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // system:managed behaves like managed: add user-1, remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/system-managed-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'system-managed-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'system-managed-id') + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts new file mode 100644 index 0000000000..f737e77bcd --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -0,0 +1,670 @@ +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { AdminRoleWithDetails, ProjectWithDetails, UserWithAdminRoles } from './keycloak-datastore.service' +import type { GroupRepresentationWith } from './keycloak.utils' +import { getBaseRoleType, getPermsByUserRoles, isExternalRoleType, ProjectAuthorized, resourceListToDict } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { getErrorResponseStatus } from '../../utils/http.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { isMember, isNonEmptyGroupPath, isOwnedProjectGroup, normalizeGroupPath } from './keycloak.utils' + +@Injectable() +export class KeycloakService { + private readonly logger = new Logger(KeycloakService.name) + + constructor( + @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, + @Inject(KeycloakDatastoreService) private readonly datastore: KeycloakDatastoreService, + ) { + this.logger.log('KeycloakService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('keycloak', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroups([project]) + this.logger.log(`Keycloak sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('keycloak', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.purgeOrphanGroups([project]) + this.logger.log(`Keycloak cleanup completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting periodic Keycloak reconciliation') + const [projects, adminRoles, users] = await Promise.all([ + this.datastore.getAllProjects(), + this.datastore.getAllAdminRoles(), + this.datastore.getAllUsersWithAdminRoleIds(), + ]) + span?.setAttributes({ + 'keycloak.projects.count': projects.length, + 'keycloak.admin_roles.count': adminRoles.length, + 'keycloak.users.count': users.length, + }) + this.logger.debug(`Reconciling Keycloak state (projects=${projects.length}, adminRoles=${adminRoles.length}, users=${users.length})`) + await Promise.all([ + this.ensureAdminRoleGroups(adminRoles, users), + this.ensureProjectGroups(projects), + ]) + await this.purgeOrphanGroups(projects) + this.logger.log(`Keycloak reconciliation completed (projects=${projects.length}, adminRoles=${adminRoles.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.projects.count', projects.length) + this.logger.verbose(`Reconciling Keycloak project groups (${projects.length})`) + await Promise.all(projects.map(project => this.ensureProjectGroup(project))) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'project.members.count': project.members.length, + 'project.roles.count': project.roles.length, + 'project.environments.count': project.environments.length, + }) + this.logger.verbose(`Reconciling Keycloak project group (${project.slug}): members=${project.members.length} roles=${project.roles.length}`) + + const projectGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateGroupByPath(`/${project.slug}`)) + + span?.setAttribute('keycloak.project_group.id', projectGroup.id) + + await Promise.all([ + this.ensureProjectGroupMembers(project, projectGroup), + this.ensureConsoleGroup(project, projectGroup), + ]) + this.logger.verbose(`Keycloak project group reconciled (${project.slug}): groupId=${projectGroup.id}`) + } + + @StartActiveSpan() + private async ensureConsoleGroup(project: ProjectWithDetails, group: GroupRepresentationWith<'id'>) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.console_group.id', group.id) + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateConsoleGroup(group)) + this.logger.verbose(`Reconciling Keycloak console group (${project.slug}): projectGroupId=${group.id} consoleGroupId=${consoleGroup.id}`) + await Promise.all([ + this.ensureRoleGroups(project, consoleGroup), + this.ensureEnvironmentGroups(project, consoleGroup), + this.purgeOrphanEnvironmentGroups(project, consoleGroup), + ]) + } + + @StartActiveSpan() + private async ensureAdminRoleGroups( + adminRoles: AdminRoleWithDetails[], + users: UserWithAdminRoles[], + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.admin_roles.count': adminRoles.length, + 'keycloak.users.count': users.length, + }) + const rolesWithOidcGroup = adminRoles.filter(r => isNonEmptyGroupPath(r.oidcGroup) && !isExternalRoleType(r.type)) + span?.setAttribute('keycloak.admin_roles.oidc_group.count', rolesWithOidcGroup.length) + await Promise.all(rolesWithOidcGroup.map(role => this.ensureAdminRoleGroup(role, users))) + } + + @StartActiveSpan() + private async ensureAdminRoleGroup( + role: AdminRoleWithDetails, + users: UserWithAdminRoles[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('admin_role.id', role.id) + span?.setAttribute('admin_role.oidc_group.present', isNonEmptyGroupPath(role.oidcGroup)) + const roleGroupPath = normalizeGroupPath(role.oidcGroup) + if (!roleGroupPath) return + + span?.setAttribute('keycloak.group.path', roleGroupPath) + const roleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateGroupByPath(roleGroupPath)) + span?.setAttribute('keycloak.group.id', roleGroup.id) + + const groupMembers = await this.keycloak.getGroupMembers(roleGroup.id) + + const desiredUserIds = new Set( + users + .filter(u => u.adminRoleIds.includes(role.id)) + .map(u => u.id), + ) + span?.setAttribute('keycloak.group.members.desired', desiredUserIds.size) + + let addedCount = 0 + let removedCount = 0 + + await Promise.all([ + ...Array.from(desiredUserIds, async (userId) => { + if (!groupMembers.some(m => m.id === userId)) { + addedCount++ + await this.maybeAddUserToGroup(userId, roleGroup.id, roleGroup.name) + } + }), + ...groupMembers.map(async (member) => { + if (member.id && !desiredUserIds.has(member.id)) { + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, roleGroup.id, roleGroup.name) + } + }), + ]) + + span?.setAttribute('keycloak.group.members.added', addedCount) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + @StartActiveSpan() + private async purgeOrphanGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + const groups = map(this.keycloak.getAllGroups(), async (group) => { + return z.object({ + id: z.string(), + name: z.string(), + subGroups: z.array(z.object({ name: z.string() })), + }).parse(group) + }) + const projectSlugs = new Set(projects.map(p => p.slug)) + this.logger.verbose(`Scanning Keycloak groups for orphan cleanup (projects=${projects.length})`) + const promises: Promise[] = [] + let purgedCount = 0 + + for await (const group of groups) { + if (!projectSlugs.has(group.name)) { + if (isOwnedProjectGroup(group)) { + this.logger.log(`Deleting an orphan Keycloak group (groupId=${group.id}, groupName=${group.name})`) + purgedCount++ + promises.push(this.keycloak.deleteGroup(group.id)) + } + } + } + span?.setAttribute('purged.count', purgedCount) + await Promise.all(promises) + this.logger.log(`Orphan Keycloak group cleanup completed (purged=${purgedCount})`) + } + + private async maybeAddUserToGroup(userId: string, groupId: string, groupName: string) { + try { + await this.keycloak.addUserToGroup(userId, groupId) + this.logger.log(`Added user to Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } catch (e) { + const status = getErrorResponseStatus(e) + if (status === 404) { + this.logger.warn(`User not found in Keycloak, skipping addition: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else if (status === 409) { + this.logger.verbose(`User already a member of Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else { + throw e + } + } + } + + private async maybeRemoveUserFromGroup(userId: string, groupId: string, groupName: string) { + try { + await this.keycloak.removeUserFromGroup(userId, groupId) + this.logger.log(`Removed user from Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } catch (e) { + if (getErrorResponseStatus(e) === 404) { + this.logger.warn(`User not found in Keycloak, skipping removal: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else { + throw e + } + } + } + + @StartActiveSpan() + private async ensureProjectGroupMembers( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('keycloak.group.id', group.id) + + const groupMembers = await this.keycloak.getGroupMembers(group.id) + const desiredUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + + span?.setAttribute('keycloak.group.members.current', groupMembers.length) + span?.setAttribute('keycloak.group.members.desired', desiredUserIds.size) + + let addedCount = 0 + let removedCount = 0 + + await Promise.all([ + ...Array.from(desiredUserIds, async (userId) => { + if (!groupMembers.some(m => m.id === userId)) { + addedCount++ + await this.maybeAddUserToGroup(userId, group.id, group.name) + } + }), + ...groupMembers.map(async (member) => { + if (member.id && !desiredUserIds.has(member.id)) { + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, group.id, group.name) + } + }), + ]) + + span?.setAttribute('keycloak.group.members.added', addedCount) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + @StartActiveSpan() + private async ensureRoleGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'keycloak.group.id': group.id, + 'project.roles.count': project.roles.length, + }) + + const rolesWithOidcGroup = project.roles.filter(r => isNonEmptyGroupPath(r.oidcGroup)) + span?.setAttribute('project.roles.oidc_group.count', rolesWithOidcGroup.length) + + await Promise.all(rolesWithOidcGroup.map(role => this.ensureRoleGroup(project, role, group))) + } + + @StartActiveSpan() + private async ensureRoleGroup( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('role.oidc_group.present', isNonEmptyGroupPath(role.oidcGroup)) + if (!isNonEmptyGroupPath(role.oidcGroup)) return + + span?.setAttribute('role.oidc_group.depth', role.oidcGroup.split('/').filter(Boolean).length) + + const roleGroup = await this.keycloak.getOrCreateRoleGroup(group, role.oidcGroup) + span?.setAttribute('keycloak.group.id', roleGroup.id) + span?.setAttribute('keycloak.group.path', roleGroup.path) + + const groupMembers = await this.keycloak.getGroupMembers(roleGroup.id) + span?.setAttribute('keycloak.group.members.current', groupMembers.length) + + switch (getBaseRoleType(role.type)) { + case 'managed': + await Promise.all([ + this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers), + this.purgeOrphanRoleGroupMembers(project, role, roleGroup, groupMembers), + ]) + break + case 'external': + await this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers) + break + case 'global': + await this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers) + break + default: + throw new Error(`Unknown role type ${role.type}`) + } + } + + @StartActiveSpan() + private async ensureRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.members.current', members.length) + + const desiredMemberIds = project.members + .filter(m => m.roleIds.includes(role.id)) + .map(m => m.user.id) + + span?.setAttribute('keycloak.group.members.desired', desiredMemberIds.length) + + let addedCount = 0 + await Promise.all(project.members.map(async (member) => { + if (!members.some(m => m.id === member.user.id) && member.roleIds.includes(role.id)) { + addedCount++ + await this.maybeAddUserToGroup(member.user.id, group.id, group.name) + } + })) + + span?.setAttribute('keycloak.group.members.added', addedCount) + } + + @StartActiveSpan() + private async purgeOrphanRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.members.current', members.length) + + let removedCount = 0 + await Promise.all(members.map(async (member) => { + if (!isMember(project, member)) { + if (!member.id) { + throw new Error(`Failed to create or retrieve role group for ${role.oidcGroup}`) + } + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, group.id, group.name) + } + })) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + private async ensureEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id'>, + ) { + await Promise.all(project.environments.map(environment => + this.ensureEnvironmentGroup(project, environment, group))) + } + + @StartActiveSpan() + private async ensureEnvironmentGroup( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + group: GroupRepresentationWith<'id'>, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + 'project.roles.count': project.roles.length, + }) + + const { roGroup, rwGroup } = z.object({ + roGroup: z.object({ + id: z.string(), + name: z.string(), + }), + rwGroup: z.object({ + id: z.string(), + name: z.string(), + }), + }).parse(await this.keycloak.getOrCreateEnvironmentGroups(group, environment)) + + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + + const rolesById = resourceListToDict(project.roles) + + const [roMembers, rwMembers] = await Promise.all([ + this.keycloak.getGroupMembers(roGroup.id), + this.keycloak.getGroupMembers(rwGroup.id), + ]) + + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + + await Promise.all([ + this.ensureEnvironmentGroupMembers( + project, + environment, + rolesById, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + this.purgeOrphanEnvironmentGroupMembers( + project, + environment, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + ]) + } + + @StartActiveSpan() + private async ensureEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + rolesById: Record, + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('environment.id', environment.id) + span?.setAttribute('environment.name', environment.name) + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + this.logger.verbose(`Reconciling Keycloak environment group members: project=${project.slug} env=${environment.name} roMembers=${roMembers.length} rwMembers=${rwMembers.length}`) + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + span?.setAttribute('project.users.count', projectUserIds.size) + + let roAdded = 0 + let roRemoved = 0 + let rwAdded = 0 + let rwRemoved = 0 + + await Promise.all(Array.from(projectUserIds, async (userId) => { + const perms = this.getUserPermissions(project, rolesById, userId) + + const isInRo = roMembers.some(m => m.id === userId) + if (perms.ro && !isInRo) { + roAdded++ + await this.maybeAddUserToGroup(userId, roGroup.id, `RO group for ${environment.name}`) + } else if (!perms.ro && isInRo) { + roRemoved++ + await this.maybeRemoveUserFromGroup(userId, roGroup.id, `RO group for ${environment.name}`) + } + + const isInRw = rwMembers.some(m => m.id === userId) + if (perms.rw && !isInRw) { + rwAdded++ + await this.maybeAddUserToGroup(userId, rwGroup.id, `RW group for ${environment.name}`) + } else if (!perms.rw && isInRw) { + rwRemoved++ + await this.maybeRemoveUserFromGroup(userId, rwGroup.id, `RW group for ${environment.name}`) + } + })) + + span?.setAttribute('keycloak.env_group.ro.members.added', roAdded) + span?.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) + span?.setAttribute('keycloak.env_group.rw.members.added', rwAdded) + span?.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) + this.logger.verbose(`Keycloak environment group members reconciled: project=${project.slug} env=${environment.name} roAdded=${roAdded} roRemoved=${roRemoved} rwAdded=${rwAdded} rwRemoved=${rwRemoved}`) + } + + @StartActiveSpan() + private async purgeOrphanEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('environment.id', environment.id) + span?.setAttribute('environment.name', environment.name) + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + this.logger.verbose(`Purging orphan Keycloak environment group members: project=${project.slug} env=${environment.name} roMembers=${roMembers.length} rwMembers=${rwMembers.length}`) + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + span?.setAttribute('project.users.count', projectUserIds.size) + + let roRemoved = 0 + let rwRemoved = 0 + + await Promise.all([ + ...roMembers.map(async (member) => { + if (!member.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + if (!projectUserIds.has(member.id)) { + roRemoved++ + await this.maybeRemoveUserFromGroup(member.id, roGroup.id, `RO group for ${environment.name}`) + } + }), + ...rwMembers.map(async (member) => { + if (!member.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + if (!projectUserIds.has(member.id)) { + rwRemoved++ + await this.maybeRemoveUserFromGroup(member.id, rwGroup.id, `RW group for ${environment.name}`) + } + }), + ]) + + span?.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) + span?.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) + this.logger.log(`Orphan Keycloak environment group member cleanup completed: project=${project.slug} env=${environment.name} roRemoved=${roRemoved} rwRemoved=${rwRemoved}`) + } + + @StartActiveSpan() + private async purgeOrphanEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.name', group.name) + this.logger.verbose(`Scanning Keycloak environment groups for orphan cleanup: project=${project.slug} groupId=${group.id} groupName=${group.name}`) + + const envGroups = map(this.keycloak.getSubGroups(group.id), envGroup => z.object({ + id: z.string(), + name: z.string(), + }).parse(envGroup)) + + const promises: Promise[] = [] + let orphanCount = 0 + + for await (const envGroup of envGroups) { + span?.setAttribute('keycloak.env_group.id', envGroup.id) + span?.setAttribute('keycloak.env_group.name', envGroup.name) + + const subGroups = await getAll(map(this.keycloak.getSubGroups(envGroup.id), subgroup => z.object({ + name: z.string(), + }).parse(subgroup))) + + if (this.isEnvironmentGroup(subGroups) && !this.isOwnedEnvironmentGroup(project, envGroup)) { + orphanCount++ + this.logger.log(`Deleting orphan Keycloak environment group: project=${project.slug} envGroupId=${envGroup.id} envGroupName=${envGroup.name}`) + promises.push( + this.keycloak.deleteGroup(envGroup.id) + .catch(err => this.logger.warn(`Failed to delete orphan Keycloak environment group: project=${project.slug} envGroupId=${envGroup.id} envGroupName=${envGroup.name} err=${err instanceof Error ? err.message : String(err)}`)), + ) + } + } + + span?.setAttribute('keycloak.env_groups.orphan.count', orphanCount) + await Promise.all(promises) + this.logger.log(`Orphan Keycloak environment group cleanup completed: project=${project.slug} groupId=${group.id} orphanCount=${orphanCount}`) + } + + private isEnvironmentGroup( + subGroups: GroupRepresentationWith<'name'>[], + ) { + return subGroups.some(subgroup => subgroup.name === 'RO' || subgroup.name === 'RW') + } + + private isOwnedEnvironmentGroup( + project: ProjectWithDetails, + group: GroupRepresentationWith<'name'>, + ) { + return project.environments.some(e => e.name === group.name) + } + + private getUserPermissions( + project: ProjectWithDetails, + rolesById: Record, + userId: string, + ) { + if (userId === project.ownerId) return { ro: true, rw: true } + const member = project.members.find(m => m.user.id === userId) + if (!member) return { ro: false, rw: false } + + const projectPermissions = getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) + + return { + ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions }), + rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions }), + } + } +} + +async function* map( + iterable: AsyncIterable, + mapper: (value: T, index: number) => U | Promise, +): AsyncIterable { + let index = 0 + for await (const value of iterable) { + yield await mapper(value, index++) + } +} + +async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts new file mode 100644 index 0000000000..ac56609793 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts @@ -0,0 +1,25 @@ +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import { CONSOLE_GROUP_NAME } from './keycloak.constants' + +type With = T & Required> +export type GroupRepresentationWith = With + +export function isMember(project: ProjectWithDetails, member: UserRepresentation): boolean { + return project.members.some(m => m.user.id === member.id) || project.ownerId === member.id +} + +export function isNonEmptyGroupPath(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +export function normalizeGroupPath(value: unknown): string | undefined { + if (!isNonEmptyGroupPath(value)) return undefined + const trimmed = value.trim() + return trimmed.startsWith('/') ? trimmed : `/${trimmed}` +} + +export function isOwnedProjectGroup(group: GroupRepresentationWith<'subGroups'>): boolean { + return !!group.subGroups.some(sg => sg.name === CONSOLE_GROUP_NAME) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log-queries.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log-queries.utils.ts new file mode 100644 index 0000000000..7c8e6de59f --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log-queries.utils.ts @@ -0,0 +1,30 @@ +import type { Prisma } from '@prisma/client' + +const logSelect = { + id: true, + createdAt: true, + updatedAt: true, + action: true, + userId: true, + requestId: true, + projectId: true, + data: true, +} satisfies Prisma.LogSelect + +export type LogSelect = Prisma.LogGetPayload<{ + select: typeof logSelect +}> + +export function countLogs(tx: Prisma.TransactionClient, projectId: string | null | undefined) { + return tx.log.count({ where: { projectId } }) +} + +export function listLogs(tx: Prisma.TransactionClient, projectId: string | null | undefined, offset: number, limit: number) { + return tx.log.findMany({ + orderBy: { createdAt: 'desc' }, + skip: offset, + take: limit, + where: { projectId }, + select: logSelect, + }) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log-testing.utils.ts new file mode 100644 index 0000000000..1e90dffb17 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log-testing.utils.ts @@ -0,0 +1,16 @@ +import type { Log } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeLog(overrides: Partial = {}): Log { + return { + id: faker.string.uuid(), + data: {}, + action: faker.word.verb(), + userId: null, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + requestId: faker.string.uuid(), + projectId: faker.string.uuid(), + ...overrides, + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.controller.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.controller.spec.ts new file mode 100644 index 0000000000..fa9dfd0118 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.controller.spec.ts @@ -0,0 +1,79 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { ADMIN_PERMS } from '@cpn-console/shared' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectPermissionLoaderService } from '../infrastructure/permission/project/project-loader.service' +import { makeProjectContext } from '../infrastructure/permission/project/project.testing.utils' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { LogController } from './log.controller' +import { LogService } from './log.service' + +describe('logController', () => { + let module: TestingModule + let controller: LogController + let logs: DeepMockProxy + let projectLoader: DeepMockProxy + + beforeEach(async () => { + logs = mockDeep() + projectLoader = mockDeep() + + module = await Test.createTestingModule({ + controllers: [LogController], + providers: [ + { provide: LogService, useValue: logs }, + { provide: ProjectPermissionLoaderService, useValue: projectLoader }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(LogController) + }) + + it('returns logs for system admins without project lookup', async () => { + logs.getLogs.mockResolvedValueOnce({ total: 1, logs: [] }) + + const response = await controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: undefined }, + { userId: 'user-1', adminPermissions: ADMIN_PERMS.LIST_SYSTEM, userType: 'human' }, + ) + + expect(projectLoader.load).not.toHaveBeenCalled() + expect(logs.getLogs).toHaveBeenCalledWith({ limit: 10, offset: 0, clean: false, projectId: undefined }) + expect(response).toEqual({ total: 1, logs: [] }) + }) + + it('forces clean logs for project-scoped non-admin access', async () => { + projectLoader.load.mockResolvedValueOnce(makeProjectContext({ projectPermissions: 1n })) + logs.getLogs.mockResolvedValueOnce({ total: 1, logs: [] }) + + await controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: 'project-1' }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + ) + + expect(projectLoader.load).toHaveBeenCalledTimes(1) + expect(logs.getLogs).toHaveBeenCalledWith({ limit: 10, offset: 0, clean: true, projectId: 'project-1' }) + }) + + it('rejects non-admin access without projectId', async () => { + await expect(controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: undefined }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + )).rejects.toThrow(ForbiddenException) + }) + + it('rejects non-admin access when user has no project permissions', async () => { + projectLoader.load.mockResolvedValueOnce(makeProjectContext({ projectPermissions: 0n })) + + await expect(controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: 'project-1' }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + )).rejects.toThrow(ForbiddenException) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.controller.ts new file mode 100644 index 0000000000..951ee4f472 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.controller.ts @@ -0,0 +1,47 @@ +import type { AdminLogsQuery } from '@cpn-console/shared' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { AdminAuthorized, logContract } from '@cpn-console/shared' +import { Controller, ForbiddenException, Get, Inject, Query, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { ProjectPermissionLoaderService } from '../infrastructure/permission/project/project-loader.service' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { LogService } from './log.service' + +@Controller('api/v1/logs') +@UseGuards(UserGuard) +export class LogController { + constructor( + @Inject(LogService) private readonly logs: LogService, + @Inject(ProjectPermissionLoaderService) private readonly projectLoader: ProjectPermissionLoaderService, + ) {} + + @Get('') + async getLogs( + @Query(new ZodValidationPipe(logContract.getLogs.query)) query: AdminLogsQuery, + @AuthUser() user: UserContext, + ) { + const isSystemAdmin = AdminAuthorized.ListSystem(user.adminPermissions ?? 0n) + let effectiveQuery = query + + if (!isSystemAdmin) { + if (!query.projectId) { + throw new ForbiddenException() + } + + const project = await this.projectLoader.load( + { params: { projectId: query.projectId } } as Parameters[0], + user.userId, + { includePermissions: true, includeLocked: false, includeStatus: false }, + ) + + if (!project.projectPermissions) { + throw new ForbiddenException() + } + + effectiveQuery = { ...query, clean: true } + } + + return this.logs.getLogs(effectiveQuery) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.module.ts new file mode 100644 index 0000000000..f80194cb54 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { LogController } from './log.controller' +import { LogService } from './log.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, UserPermissionModule], + controllers: [LogController], + providers: [LogService], + exports: [LogService], +}) +export class LogModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.service.spec.ts new file mode 100644 index 0000000000..608bafbdbd --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.service.spec.ts @@ -0,0 +1,67 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeLog } from './log-testing.utils' +import { LogService } from './log.service' + +describe('logService', () => { + let module: TestingModule + let service: LogService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + LogService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(LogService) + }) + + it('should map clean logs', async () => { + const dbLogs = [makeLog({ + data: { args: {} }, + action: 'Action', + })] + + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + + const { logs } = await service.getLogs({ + limit: 10, + offset: 10, + clean: true, + projectId: undefined, + }) + + expect(logs[0]).not.toHaveProperty('requestId') + expect(logs[0].data).not.toHaveProperty('results') + expect(logs[0].data).not.toHaveProperty('args') + expect(logs[0].data).not.toHaveProperty('config') + }) + + it('should not filter admin logs', async () => { + const dbLogs = [makeLog({ + data: { args: {} }, + action: 'Action', + })] + + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + + const { logs } = await service.getLogs({ + limit: 10, + offset: 10, + clean: false, + projectId: undefined, + }) + + expect(logs[0].data).toHaveProperty('args') + expect(logs[0].data).not.toHaveProperty('config') + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.service.ts new file mode 100644 index 0000000000..8aa403a41c --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/log/log.service.ts @@ -0,0 +1,74 @@ +import type { AdminLogsQuery } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import { CleanLogSchema, exclude } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { countLogs, listLogs } from './log-queries.utils' + +/** + * Shape of the `data` JSON column, aligned with the LogSchema contract in + * `@cpn-console/shared` so the console can parse and display every entry. + */ +export interface LogData { + /** Payload the action/event was invoked with (e.g. the full project). */ + args?: unknown + /** Names of the services that reported a KO result. */ + failed?: boolean | string[] + /** Per-service outcome, keyed by service name. */ + results?: Record + totalExecutionTime?: number + messageResume?: string + /** Extra keys are persisted as-is, except the sensitive ones stripped by addLog. */ + [key: string]: unknown +} + +interface AddLogArgs { + action: string + data: LogData + userId?: string | null + requestId?: string | null + projectId?: string | null +} + +@Injectable() +export class LogService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + ) {} + + async getLogs(params: AdminLogsQuery) { + const [total, logs] = await this.getLogsWithCount(params.projectId, params.offset, params.limit) + + return { + total, + logs: params.clean + ? logs.map(log => CleanLogSchema.parse(log)) + : logs, + } + } + + async getLogsWithCount(projectId: string | null | undefined, offset: number, limit: number) { + return this.prisma.$transaction([ + countLogs(this.prisma, projectId), + listLogs(this.prisma, projectId, offset, limit), + ]) + } + + async addLog({ + action, + data, + userId = null, + requestId = null, + projectId = null, + }: AddLogArgs) { + return this.prisma.log.create({ + data: { + action, + userId, + requestId, + projectId, + data: exclude(data, ['cluster', 'user', 'newCreds', 'apis', 'config']) as Prisma.InputJsonValue, + }, + }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts new file mode 100644 index 0000000000..5ac2c471b3 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts @@ -0,0 +1,82 @@ +import type { ConfigType } from '@nestjs/config' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { nexusConfigFactory } from '../../config/nexus.config' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { NexusClientService } from './nexus-client.service' +import { NexusHttpClientService } from './nexus-http-client.service' +import { NEXUS_CONFIG } from './nexus.module-definition' + +const nexusUrl = 'https://nexus.internal' + +const server = setupServer() +const nexusAdminPassword = faker.internet.password() +const basicAuth = `Basic ${Buffer.from(`admin:${nexusAdminPassword}`, 'utf8').toString('base64')}` + +describe('nexusClientService', () => { + let service: NexusClientService + let config: DeepMockProxy> + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + beforeEach(async () => { + config = mockDeep>({ + internalUrl: nexusUrl, + admin: 'admin', + adminPassword: nexusAdminPassword, + internalOrPublicUrl: nexusUrl, + }) + + const module = await Test.createTestingModule({ + providers: [ + NexusClientService, + NexusHttpClientService, + { + provide: NEXUS_CONFIG, + useValue: config, + }, + ], + }).compile() + + service = module.get(NexusClientService) + }) + + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should return null on 404 (getRepositoriesMavenHosted)', async () => { + server.use( + http.get(`${nexusUrl}/service/rest/v1/repositories/maven/hosted/:name`, ({ request }) => { + expect(request.headers.get('authorization')).toBe(basicAuth) + return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.getRepositoriesMavenHosted('missing')).resolves.toBeNull() + }) + + it('should send basic auth and plain text body on change-password', async () => { + server.use( + http.put(`${nexusUrl}/service/rest/v1/security/users/:userId/change-password`, async ({ request, params }) => { + expect(request.method).toBe('PUT') + expect(request.url).toBe(`${nexusUrl}/service/rest/v1/security/users/u1/change-password`) + expect(params.userId).toBe('u1') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('content-type')).toContain('text/plain') + expect(await request.text()).toBe('pw123') + return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) + }), + ) + + await service.updateSecurityUsersChangePassword('u1', 'pw123') + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts new file mode 100644 index 0000000000..85f9e81d62 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts @@ -0,0 +1,297 @@ +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { NexusError, NexusHttpClientService } from './nexus-http-client.service' + +interface NexusRepositoryStorage { + blobStoreName: string + strictContentTypeValidation: boolean + writePolicy?: string +} + +interface NexusRepositoryCleanup { + policyNames: string[] +} + +interface NexusRepositoryComponent { + proprietaryComponents: boolean +} + +interface NexusRepositoryGroup { + memberNames: string[] +} + +export interface NexusMavenHostedRepository { + name: string + online: boolean + storage: NexusRepositoryStorage & { writePolicy: string } + cleanup?: NexusRepositoryCleanup + component: NexusRepositoryComponent + maven: { + versionPolicy: string + layoutPolicy: string + contentDisposition: string + } +} + +interface NexusMavenHostedRepositoryUpsertRequest extends NexusMavenHostedRepository {} + +export interface NexusMavenGroupRepository { + name: string + online: boolean + storage: Omit + group: NexusRepositoryGroup +} + +interface NexusMavenGroupRepositoryUpsertRequest extends NexusMavenGroupRepository {} + +export interface NexusNpmHostedRepository { + name: string + online: boolean + storage: NexusRepositoryStorage & { writePolicy: string } + cleanup?: NexusRepositoryCleanup + component: NexusRepositoryComponent +} + +interface NexusNpmHostedRepositoryUpsertRequest extends NexusNpmHostedRepository {} + +export interface NexusNpmGroupRepository { + name: string + online: boolean + storage: Omit + group: NexusRepositoryGroup +} + +interface NexusNpmGroupRepositoryUpsertRequest extends NexusNpmGroupRepository {} + +interface NexusRepositoryViewPrivilege { + name: string + description: string + actions: string[] + format: string + repository: string +} + +interface NexusRepositoryViewPrivilegeUpsertRequest extends NexusRepositoryViewPrivilege {} + +interface NexusRole { + id: string + name: string + privileges: string[] + source?: string + roles?: string[] + description?: string +} + +interface NexusRoleCreateRequest extends NexusRole { + description: string +} + +interface NexusRoleUpdateRequest extends NexusRole {} + +interface NexusUserCreateRequest { + userId: string + firstName: string + lastName: string + emailAddress: string + password: string + status: string + roles: string[] +} + +export interface NexusPrivilege extends NexusRepositoryViewPrivilege { + type: string +} + +@Injectable() +export class NexusClientService { + constructor( + @Inject(NexusHttpClientService) private readonly http: NexusHttpClientService, + ) {} + + @StartActiveSpan() + async getRepositoriesMavenHosted(name: string) { + try { + const res = await this.http.fetch(`repositories/maven/hosted/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createRepositoriesMavenHosted(body: NexusMavenHostedRepositoryUpsertRequest) { + await this.http.fetch('repositories/maven/hosted', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesMavenHosted(name: string, body: NexusMavenHostedRepositoryUpsertRequest) { + await this.http.fetch(`repositories/maven/hosted/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async createRepositoriesMavenGroup(body: NexusMavenGroupRepositoryUpsertRequest) { + await this.http.fetch('repositories/maven/group', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesMavenGroup(name: string, body: NexusMavenGroupRepositoryUpsertRequest) { + await this.http.fetch(`repositories/maven/group/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getRepositoriesMavenGroup(name: string) { + try { + const res = await this.http.fetch(`repositories/maven/group/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async getRepositoriesNpmHosted(name: string) { + try { + const res = await this.http.fetch(`repositories/npm/hosted/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createRepositoriesNpmHosted(body: NexusNpmHostedRepositoryUpsertRequest) { + await this.http.fetch('repositories/npm/hosted', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesNpmHosted(name: string, body: NexusNpmHostedRepositoryUpsertRequest) { + await this.http.fetch(`repositories/npm/hosted/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getRepositoriesNpmGroup(name: string): Promise { + try { + const res = await this.http.fetch(`repositories/npm/group/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async postRepositoriesNpmGroup(body: NexusNpmGroupRepositoryUpsertRequest) { + await this.http.fetch('repositories/npm/group', { method: 'POST', body }) + } + + @StartActiveSpan() + async putRepositoriesNpmGroup(name: string, body: NexusNpmGroupRepositoryUpsertRequest) { + await this.http.fetch(`repositories/npm/group/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getSecurityPrivileges(name: string): Promise { + try { + const res = await this.http.fetch(`security/privileges/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createSecurityPrivilegesRepositoryView(body: NexusRepositoryViewPrivilegeUpsertRequest) { + await this.http.fetch('security/privileges/repository-view', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateSecurityPrivilegesRepositoryView(name: string, body: NexusRepositoryViewPrivilegeUpsertRequest) { + await this.http.fetch(`security/privileges/repository-view/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async deleteSecurityPrivileges(name: string) { + try { + await this.http.fetch(`security/privileges/${name}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async getSecurityRoles(id: string): Promise { + try { + const res = await this.http.fetch(`security/roles/${id}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createSecurityRoles(body: NexusRoleCreateRequest) { + await this.http.fetch('security/roles', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateSecurityRoles(id: string, body: NexusRoleUpdateRequest) { + await this.http.fetch(`security/roles/${id}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async deleteSecurityRoles(id: string) { + try { + await this.http.fetch(`security/roles/${id}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async getSecurityUsers(userId: string): Promise<{ userId: string }[]> { + const query = new URLSearchParams({ userId }).toString() + const res = await this.http.fetch<{ userId: string }[]>(`security/users?${query}`) + return res.data ?? [] + } + + @StartActiveSpan() + async updateSecurityUsersChangePassword(userId: string, password: string) { + await this.http.fetch(`security/users/${userId}/change-password`, { + method: 'PUT', + body: password, + headers: { 'Content-Type': 'text/plain' }, + }) + } + + @StartActiveSpan() + async createSecurityUsers(body: NexusUserCreateRequest) { + await this.http.fetch('security/users', { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSecurityUsers(userId: string) { + try { + await this.http.fetch(`security/users/${userId}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async deleteRepositoriesByName(name: string) { + try { + await this.http.fetch(`repositories/${name}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts new file mode 100644 index 0000000000..ddbb28b966 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts @@ -0,0 +1,66 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './nexus.constants' + +export const projectSelect = { + slug: true, + owner: { + select: { + email: true, + firstName: true, + lastName: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class NexusDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts new file mode 100644 index 0000000000..5914403606 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts @@ -0,0 +1,27 @@ +import type { NexusConfig } from './nexus.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { NEXUS_CONFIG } from './nexus.module-definition' + +@Injectable() +export class NexusHealthService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const credentials = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` + const encoded = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${encoded}` + try { + const response = await fetch(this.nexusConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts new file mode 100644 index 0000000000..8575a4cd18 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts @@ -0,0 +1,129 @@ +import type { NexusConfig } from './nexus.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { NEXUS_CONFIG } from './nexus.module-definition' + +export interface NexusFetchOptions { + method?: string + body?: unknown + headers?: Record +} + +export interface NexusResponse { + status: HttpStatus + data: T | null +} + +export type NexusErrorKind + = | 'NotConfigured' + | 'HttpError' + | 'Unexpected' + +export class NexusError extends Error { + readonly kind: NexusErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + + constructor( + kind: NexusErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string } = {}, + ) { + super(message) + this.name = 'NexusError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + } +} + +@Injectable() +export class NexusHttpClientService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + ) {} + + @StartActiveSpan() + async fetch(path: string, options: NexusFetchOptions = {}): Promise> { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('nexus.method', method) + span?.setAttribute('nexus.path', path) + + const request = this.createRequest(path, method, options.body, options.headers) + const response = await fetch(request).catch((error) => { + throw new NexusError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('nexus.http.status', response.status) + const result = await handleResponse(response) + if (!response.ok) { + throw new NexusError('HttpError', `Request failed: ${method} ${path} responded ${result.status} ${response.statusText}`, { + status: result.status, + method, + path, + statusText: response.statusText, + }) + } + return result + } + + private get baseUrl() { + const url = this.nexusConfig.internalOrPublicUrl + if (!url) { + throw new NexusError('NotConfigured', 'NEXUS_INTERNAL_URL or NEXUS_URL is required') + } + return url + } + + private get apiBaseUrl() { + return new URL('service/rest/v1/', this.baseUrl).toString() + } + + private get basicAuth() { + if (!this.nexusConfig.admin) { + throw new NexusError('NotConfigured', 'NEXUS_ADMIN is required') + } + if (!this.nexusConfig.adminPassword) { + throw new NexusError('NotConfigured', 'NEXUS_ADMIN_PASSWORD is required') + } + const raw = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` + return Buffer.from(raw, 'utf8').toString('base64') + } + + private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record): Request { + const url = new URL(path, this.apiBaseUrl).toString() + const headers: Record = { + Authorization: `Basic ${this.basicAuth}`, + ...extraHeaders, + } + let requestBody: string | undefined + if (body !== undefined) { + if (typeof body === 'string') { + requestBody = body + headers['Content-Type'] = 'text/plain' + } else { + requestBody = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + } + return new Request(url, { method, headers, body: requestBody }) + } +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts new file mode 100644 index 0000000000..eab6e89046 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts @@ -0,0 +1,37 @@ +import type { ConfigType } from '@nestjs/config' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { nexusConfigFactory } from '../../config/nexus.config' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { NexusPluginService } from './nexus-plugin.service' +import { NEXUS_CONFIG } from './nexus.module-definition' + +describe('nexusPluginService', () => { + let service: NexusPluginService + let config: DeepMockProxy> + + beforeEach(async () => { + config = mockDeep>({ + url: 'https://nexus.public/', + internalUrl: 'https://nexus.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + NexusPluginService, + { provide: NEXUS_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(NexusPluginService) + }) + + it('returns the public Nexus url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://nexus.public/') + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts new file mode 100644 index 0000000000..3682cba90e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts @@ -0,0 +1,176 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { NexusConfig } from './nexus.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { NEXUS_CONFIG } from './nexus.module-definition' + +@Injectable() +export class NexusPluginService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'nexus', + to: () => this.nexusConfig.url, + title: 'Nexus', + imgSrc: '/img/nexus.png', + description: 'Nexus permet de gérer les binaires et artefacts de build à travers la chaîne logistique logicielle', + config: { + project: [ + { + key: 'activateNpmRepo', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt NPM', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'npmWritePolicy', + section: 'NPM', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture', + value: 'allow', + description: 'Politique d\'écriture des dépôts NPM, valeurs possibles: allow / allow_once, deny, replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'activateMavenRepo', + section: 'Maven', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt Maven', + value: DISABLED, + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'mavenSnapshotWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Snapshot', + value: 'allow', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'mavenReleaseWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Release', + value: 'allow_once', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + ], + global: [ + { + key: 'platformWriteGroupPaths', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemins des groupes OIDC plateforme en écriture', + value: '/console/admin', + description: 'Liste séparée par des virgules des chemins des groupes OIDC ayant accès en écriture aux dépôts Nexus de la plateforme', + placeholder: '/console/admin', + }, + { + key: 'platformReadGroupPaths', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemins des groupes OIDC plateforme en lecture', + value: '/console/readonly,/console/security', + description: 'Liste séparée par des virgules des chemins des groupes OIDC ayant accès en lecture aux dépôts Nexus de la plateforme', + placeholder: '/console/readonly,/console/security', + }, + { + key: 'projectWriteGroupPathSuffixes', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixes des groupes OIDC projet en écriture', + value: '/console/admin,/console/devops', + description: 'Liste séparée par des virgules des suffixes des chemins des groupes OIDC ayant accès en écriture aux dépôts Nexus du projet', + placeholder: '/console/admin,/console/devops', + }, + { + key: 'projectReadGroupPathSuffixes', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixes des groupes OIDC projet en lecture', + value: '/console/readonly,/console/security,/console/developer', + description: 'Liste séparée par des virgules des suffixes des chemins des groupes OIDC ayant accès en lecture aux dépôts Nexus du projet', + placeholder: '/console/readonly,/console/security,/console/developer', + }, + { + key: 'activateNpmRepoDefaultValue', + section: 'NPM', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Créer un dépôt NPM privé (comportement par défaut des projets)', + value: DISABLED, + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'activateMavenRepoDefaultValue', + section: 'Maven', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Créer un dépôt MAVEN privé (comportement par défaut des projets)', + value: DISABLED, + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'enablePlugin', + kind: 'switch', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Activer/Désactiver entièrement le plugin Nexus', + value: ENABLED, + description: 'Défaut: Activé', + }, + ], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts new file mode 100644 index 0000000000..4cdc734919 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts @@ -0,0 +1,15 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + slug: faker.internet.domainWord(), + owner: { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + }, + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.constants.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.constants.ts new file mode 100644 index 0000000000..151b2e56b1 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.constants.ts @@ -0,0 +1,32 @@ +// Name of the Nexus plugin used throughout the application +export const PLUGIN_NAME = 'nexus' + +// Configuration keys for enabling Maven and NPM repositories +export const NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO = 'activateMavenRepo' +export const NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO = 'activateNpmRepo' + +// Configuration keys for Maven snapshot, release, and NPM write policies +export const NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY = 'mavenSnapshotWritePolicy' +export const NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY = 'mavenReleaseWritePolicy' +export const NEXUS_CONFIG_KEY_NPM_WRITE_POLICY = 'npmWritePolicy' + +// Default write policy values for Maven snapshots, releases, and NPM packages +export const DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY = 'allow' +export const DEFAULT_MAVEN_RELEASE_WRITE_POLICY = 'allow_once' +export const DEFAULT_NPM_WRITE_POLICY = 'allow' + +// Default group paths granting write and read access at the platform level +export const DEFAULT_PLATFORM_WRITE_GROUP_PATHS = '/console/admin' +export const DEFAULT_PLATFORM_READ_GROUP_PATHS = '/console/readonly,/console/security' + +// Default group path suffixes granting write and read access at the project level +export const DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES = '/console/admin,/console/devops' +export const DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES = '/console/readonly,/console/security,/console/developer' + +// Plugin configuration keys for platform-level group paths +export const PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY = 'platformWriteGroupPaths' +export const PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY = 'platformReadGroupPaths' + +// Plugin configuration keys for project-level group path suffixes +export const PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectWriteGroupPathSuffixes' +export const PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectReadGroupPathSuffixes' diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts new file mode 100644 index 0000000000..998c0b3b7a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts @@ -0,0 +1,18 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface NexusConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + secretExposeInternalUrl: boolean + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface NexusModuleOptions extends NexusConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('nexus') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as NEXUS_CONFIG } diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.module.ts new file mode 100644 index 0000000000..25f0e992ce --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from '../../config/base.config' +import { nexusConfigFactory } from '../../config/nexus.config' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { NexusHealthService } from './nexus-health.service' +import { NexusHttpClientService } from './nexus-http-client.service' +import { NexusPluginService } from './nexus-plugin.service' +import { ConfigurableModuleClass } from './nexus.module-definition' +import { NexusService } from './nexus.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule, ConfigModule.forFeature(nexusConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + providers: [ + NexusHealthService, + NexusPluginService, + NexusService, + NexusDatastoreService, + NexusHttpClientService, + NexusClientService, + ], + exports: [NexusClientService, NexusHealthService, NexusPluginService, NexusService], +}) +export class NexusModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts new file mode 100644 index 0000000000..9963b5e4c5 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -0,0 +1,202 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { NexusConfig } from './nexus.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { makeProjectWithDetails } from './nexus-testing.utils' +import { + NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, + NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, + PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, + PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, +} from './nexus.constants' +import { NEXUS_CONFIG } from './nexus.module-definition' +import { NexusService } from './nexus.service' + +describe('nexusService', () => { + let service: NexusService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let config: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + getRepositoriesMavenHosted: vi.fn().mockResolvedValue(null), + getRepositoriesMavenGroup: vi.fn().mockResolvedValue(null), + getRepositoriesNpmHosted: vi.fn().mockResolvedValue(null), + getRepositoriesNpmGroup: vi.fn().mockResolvedValue(null), + getSecurityPrivileges: vi.fn().mockResolvedValue(null), + getSecurityRoles: vi.fn().mockResolvedValue(null), + getSecurityUsers: vi.fn().mockResolvedValue([]), + }) + datastore = mockDeep({ + getAllProjects: vi.fn().mockResolvedValue([]), + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + read: vi.fn().mockRejectedValue(new VaultError('NotFound', 'Not Found')), + }) + config = mockDeep({}) + baseConfig = mockDeep({ projectsRootDir: 'forge' }) + vaultConfig = mockDeep({}) + + const module = await Test.createTestingModule({ + providers: [ + NexusService, + { provide: NexusClientService, useValue: client }, + { provide: NexusDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: NEXUS_CONFIG, useValue: config }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + ], + }).compile() + + service = module.get(NexusService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('handleUpsert should reconcile based on computed flags', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: DISABLED }, + ], + }) + + await service.handleUpsert(project) + + expect(client.createRepositoriesMavenHosted).toHaveBeenCalled() + expect(client.deleteRepositoriesByName).toHaveBeenCalled() + expect(vault.write).toHaveBeenCalledWith( + expect.objectContaining({ + NEXUS_USERNAME: project.slug, + NEXUS_PASSWORD: expect.any(String), + }), + `forge/${project.slug}/tech/NEXUS`, + ) + }) + + it('handleDelete should delete project', async () => { + const project = makeProjectWithDetails() + await service.handleDelete(project) + expect(client.deleteSecurityRoles).toHaveBeenCalledWith(`${project.slug}-ID`) + expect(client.deleteSecurityUsers).toHaveBeenCalledWith(project.slug) + expect(vault.delete).toHaveBeenCalledWith(`forge/${project.slug}/tech/NEXUS`) + }) + + it('handleCron should reconcile all projects', async () => { + const projects = [ + makeProjectWithDetails({ plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }] }), + makeProjectWithDetails({ plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }] }), + ] + + datastore.getAllProjects.mockResolvedValue(projects) + + await service.handleCron() + + expect(client.createSecurityUsers).toHaveBeenCalledTimes(2) + }) + + it('reuses existing vault password and does not rotate Nexus user password', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + }) + + datastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { + if (key === PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) return ' ' + if (key === PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) return ' ' + return null + }) + + vault.read.mockResolvedValue(makeVaultSecret({ data: { NEXUS_PASSWORD: 'existing' } })) + client.getSecurityUsers.mockResolvedValue([{ userId: project.slug }]) + + await service.handleUpsert(project) + + expect(client.updateSecurityUsersChangePassword).not.toHaveBeenCalled() + expect(client.createSecurityUsers).not.toHaveBeenCalled() + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + NEXUS_USERNAME: project.slug, + NEXUS_PASSWORD: 'existing', + }), `forge/${project.slug}/tech/NEXUS`) + }) + + it('deletes group repos before their hosted members', async () => { + const project = makeProjectWithDetails() + + await service.handleDelete(project) + + const deletedRepos = client.deleteRepositoriesByName.mock.calls.map(([name]) => name) + expect(deletedRepos.indexOf(`${project.slug}-repository-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-repository-snapshot`)) + expect(deletedRepos.indexOf(`${project.slug}-repository-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-repository-release`)) + expect(deletedRepos.indexOf(`${project.slug}-npm-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-npm`)) + }) + + it('tolerates platform role failures when ensuring platform roles', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + }) + const staleProject = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }], + }) + + datastore.getAllProjects.mockResolvedValue([project, staleProject]) + client.createSecurityRoles.mockImplementation(async (body) => { + if (body.id.startsWith('console-')) throw new Error('Request failed: POST security/roles responded 400 Bad Request') + }) + + await expect(service.handleUpsert(project)).resolves.not.toThrow() + + expect(client.createSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({ id: 'console-admin' })) + }) + + it('dedupes project group roles by role id and keeps the highest privileges', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, + { pluginName: PLUGIN_NAME, key: PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, + ], + }) + + datastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { + if (key === PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) return ' ' + if (key === PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) return ' ' + return null + }) + + await service.handleUpsert(project) + + expect(client.createSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({ + id: `${project.slug}-console-devops`, + privileges: expect.arrayContaining([`${project.slug}-privilege-group`]), + })) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.service.ts new file mode 100644 index 0000000000..52061ace32 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -0,0 +1,696 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { NexusPrivilege } from './nexus-client.service' +import type { ProjectWithDetails } from './nexus-datastore.service' +import type { NexusConfig } from './nexus.module-definition' +import type { + MavenHostedRepoKind, +} from './nexus.utils' +import { specificallyEnabled } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { + DEFAULT_MAVEN_RELEASE_WRITE_POLICY, + DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY, + DEFAULT_NPM_WRITE_POLICY, + DEFAULT_PLATFORM_READ_GROUP_PATHS, + DEFAULT_PLATFORM_WRITE_GROUP_PATHS, + DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES, + NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, + NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, + NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY, + NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY, + NEXUS_CONFIG_KEY_NPM_WRITE_POLICY, + PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, + PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, +} from './nexus.constants' +import { NEXUS_CONFIG } from './nexus.module-definition' +import { + generateMavenHostedRepoName, + generateNpmHostedRepoName, + generateRandomPassword, + getPluginConfig, + getProjectVaultPath, +} from './nexus.utils' + +export interface EnsureMavenReposOptions { + snapshotWritePolicy: string + releaseWritePolicy: string +} + +@Injectable() +export class NexusService { + private readonly logger = new Logger(NexusService.name) + + constructor( + @Inject(NexusDatastoreService) private readonly datastore: NexusDatastoreService, + @Inject(NexusClientService) private readonly client: NexusClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) { + this.logger.log('NexusService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('nexus', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + await this.ensureProject(project) + const projects = await this.datastore.getAllProjects() + await this.ensurePlatformRoles(projects) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('nexus', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.deleteProject(project) + const projects = await this.datastore.getAllProjects() + await this.ensurePlatformRoles(projects) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Nexus reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('nexus.projects.count', projects.length) + await this.ensureProjects(projects) + await this.ensurePlatformRoles(projects) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('nexus.projects.count', projects.length) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const enableMaven = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO)) ?? false + const enableNpm = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO)) ?? false + + const mavenSnapshotWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY) ?? DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY + const mavenReleaseWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY) ?? DEFAULT_MAVEN_RELEASE_WRITE_POLICY + const npmWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_NPM_WRITE_POLICY) ?? DEFAULT_NPM_WRITE_POLICY + + await Promise.all([ + enableMaven + ? this.ensureMavenRepos(project, { snapshotWritePolicy: mavenSnapshotWritePolicy, releaseWritePolicy: mavenReleaseWritePolicy }) + : this.deleteMavenRepos(project), + enableNpm + ? this.ensureNpmRepos(project, npmWritePolicy) + : this.deleteNpmRepos(project), + ]) + + const privileges = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + ] + : []), + ] + const readOnlyPrivileges = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + : []), + ] + await this.ensureRole(project, privileges) + await this.ensureUser(project) + await this.ensureProjectGroupRoles(project, { readOnlyPrivileges, writePrivileges: privileges }) + } + + private async upsertPrivilege(body: NexusPrivilege) { + const existing = await this.client.getSecurityPrivileges(body.name) + if (!existing) { + await this.client.createSecurityPrivilegesRepositoryView(body) + return + } + await this.client.updateSecurityPrivilegesRepositoryView(body.name, body) + } + + private async ensureMavenHostedRepo(repoName: string, writePolicy: string) { + const existing = await this.client.getRepositoriesMavenHosted(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + } + if (!existing) { + await this.client.createRepositoriesMavenHosted(body) + return + } + await this.client.updateRepositoriesMavenHosted(repoName, body) + } + + private async ensureNpmHostedRepo(repoName: string, writePolicy: string) { + const existing = await this.client.getRepositoriesNpmHosted(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + } + if (!existing) { + await this.client.createRepositoriesNpmHosted(body) + return + } + await this.client.updateRepositoriesNpmHosted(repoName, body) + } + + private async ensureNpmGroupRepo(repoName: string, memberNames: string[]) { + const existing = await this.client.getRepositoriesNpmGroup(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames, + }, + } + if (!existing) { + await this.client.postRepositoriesNpmGroup(body) + return + } + await this.client.putRepositoriesNpmGroup(repoName, body) + } + + private async ensureMavenHostedRepos(args: { + releaseRepoName: string + snapshotRepoName: string + releaseWritePolicy: string + snapshotWritePolicy: string + }) { + await Promise.all([ + this.ensureMavenHostedRepo(args.snapshotRepoName, args.snapshotWritePolicy), + this.ensureMavenHostedRepo(args.releaseRepoName, args.releaseWritePolicy), + ]) + } + + private async ensureMavenRepos(project: ProjectWithDetails, options: EnsureMavenReposOptions) { + const releaseRepoName = generateMavenHostedRepoName(project, 'release') + const snapshotRepoName = generateMavenHostedRepoName(project, 'snapshot') + const groupRepoName = generateMavenGroupRepoName(project) + + const releasePrivilege = generateMavenHostedPrivilegeName(project, 'release') + const snapshotPrivilege = generateMavenHostedPrivilegeName(project, 'snapshot') + const groupPrivilege = generateMavenGroupPrivilegeName(project) + const releasePrivilegeReadonly = generateMavenHostedPrivilegeNameReadonly(project, 'release') + const snapshotPrivilegeReadonly = generateMavenHostedPrivilegeNameReadonly(project, 'snapshot') + const groupPrivilegeReadonly = generateMavenGroupPrivilegeNameReadonly(project) + + await this.ensureMavenHostedRepos({ + releaseRepoName, + snapshotRepoName, + releaseWritePolicy: options.releaseWritePolicy, + snapshotWritePolicy: options.snapshotWritePolicy, + }) + + await this.ensureMavenGroupRepo( + groupRepoName, + [releaseRepoName, snapshotRepoName, 'maven-public'], + ) + + const privilegesToEnsureWrite = [ + { repo: releaseRepoName, privilege: releasePrivilege }, + { repo: snapshotRepoName, privilege: snapshotPrivilege }, + { repo: groupRepoName, privilege: groupPrivilege }, + ] + const privilegesToEnsureReadonly = [ + { repo: releaseRepoName, privilege: releasePrivilegeReadonly }, + { repo: snapshotRepoName, privilege: snapshotPrivilegeReadonly }, + { repo: groupRepoName, privilege: groupPrivilegeReadonly }, + ] + await Promise.all([ + this.ensureRepositoryViewPrivileges({ + project, + type: 'maven', + format: 'maven2', + entries: privilegesToEnsureWrite, + actions: ['all'], + }), + this.ensureRepositoryViewPrivileges({ + project, + type: 'maven', + format: 'maven2', + entries: privilegesToEnsureReadonly, + actions: ['read', 'browse'], + }), + ]) + } + + private async ensureMavenGroupRepo(repoName: string, memberNames: string[]) { + const existing = await this.client.getRepositoriesMavenGroup(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames, + }, + } + if (!existing) { + await this.client.createRepositoriesMavenGroup(body) + return + } + await this.client.updateRepositoriesMavenGroup(repoName, body) + } + + private async ensureRepositoryViewPrivileges(args: { + project: ProjectWithDetails + type: string + format: string + entries: Array<{ repo: string, privilege: string }> + actions: string[] + }) { + for (const entry of args.entries) { + await this.upsertPrivilege({ + type: args.type, + name: entry.privilege, + description: `Privilege for organization ${args.project.slug} for repo ${entry.repo}`, + actions: args.actions, + format: args.format, + repository: entry.repo, + }) + } + } + + private async deleteMavenRepos(project: ProjectWithDetails) { + const hostedRepoNames = [ + generateMavenHostedRepoName(project, 'release'), + generateMavenHostedRepoName(project, 'snapshot'), + ] + const privileges = [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + await Promise.all(privileges.map(privilege => this.client.deleteSecurityPrivileges(privilege))) + // Nexus responds 500 when deleting a hosted repo still referenced by a group, so delete the group first + await this.client.deleteRepositoriesByName(generateMavenGroupRepoName(project)) + await Promise.all(hostedRepoNames.map(repo => this.client.deleteRepositoriesByName(repo))) + } + + private async ensureNpmRepos(project: ProjectWithDetails, writePolicy: string) { + const hostedRepoName = generateNpmHostedRepoName(project) + const groupRepoName = generateNpmGroupRepoName(project) + + const hostedPrivilege = generateNpmHostedPrivilegeName(project) + const groupPrivilege = generateNpmGroupPrivilegeName(project) + const hostedPrivilegeReadonly = generateNpmHostedPrivilegeNameReadonly(project) + const groupPrivilegeReadonly = generateNpmGroupPrivilegeNameReadonly(project) + + await this.ensureNpmHostedRepo(hostedRepoName, writePolicy) + await this.ensureNpmGroupRepo(groupRepoName, [hostedRepoName]) + + const privilegesToEnsureWrite = [ + { repo: hostedRepoName, privilege: hostedPrivilege }, + { repo: groupRepoName, privilege: groupPrivilege }, + ] + const privilegesToEnsureReadonly = [ + { repo: hostedRepoName, privilege: hostedPrivilegeReadonly }, + { repo: groupRepoName, privilege: groupPrivilegeReadonly }, + ] + await Promise.all([ + this.ensureRepositoryViewPrivileges({ + project, + type: 'npm', + format: 'npm', + entries: privilegesToEnsureWrite, + actions: ['all'], + }), + this.ensureRepositoryViewPrivileges({ + project, + type: 'npm', + format: 'npm', + entries: privilegesToEnsureReadonly, + actions: ['read', 'browse'], + }), + ]) + } + + private async deleteNpmRepos(project: ProjectWithDetails) { + const privileges = [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + await Promise.all(privileges.map(privilege => this.client.deleteSecurityPrivileges(privilege))) + // Nexus responds 500 when deleting a hosted repo still referenced by a group, so delete the group first + await this.client.deleteRepositoriesByName(generateNpmGroupRepoName(project)) + await this.client.deleteRepositoriesByName(generateNpmHostedRepoName(project)) + } + + private async ensureRole(project: ProjectWithDetails, privileges: string[]) { + const roleId = `${project.slug}-ID` + const role = await this.client.getSecurityRoles(roleId) + if (!role) { + await this.client.createSecurityRoles({ + id: roleId, + name: `${project.slug}-role`, + description: 'desc', + privileges, + }) + return + } + await this.client.updateSecurityRoles(roleId, { + id: roleId, + name: `${project.slug}-role`, + privileges, + }) + } + + private async ensureUser(project: ProjectWithDetails) { + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') + let existingPassword: string | undefined + try { + existingPassword = await this.vault.read(vaultPath).then(res => res.data?.NEXUS_PASSWORD) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') { + existingPassword = undefined + } else { + throw error + } + } + + const ensuredPassword = existingPassword ?? generateRandomPassword(30) + const users = await this.client.getSecurityUsers(project.slug) + const existing = users.find(u => u.userId === project.slug) + if (existing) { + if (!existingPassword || existingPassword !== ensuredPassword) { + await this.client.updateSecurityUsersChangePassword(project.slug, ensuredPassword) + } + } else { + await this.client.createSecurityUsers({ + userId: project.slug, + firstName: project.owner.firstName, + lastName: project.owner.lastName, + emailAddress: project.owner.email, + password: ensuredPassword, + status: 'active', + roles: [`${project.slug}-ID`], + }) + } + + await this.vault.write({ + NEXUS_PASSWORD: ensuredPassword, + NEXUS_USERNAME: project.slug, + }, vaultPath) + } + + private async ensureSecurityRole(id: string, privileges: string[]) { + const role = await this.client.getSecurityRoles(id) + if (!role) { + await this.client.createSecurityRoles({ + id, + name: id, + description: 'desc', + privileges, + }) + return + } + await this.client.updateSecurityRoles(id, { + id, + name: id, + privileges, + }) + } + + private async getOptionalConfigValue(project: ProjectWithDetails, key: string) { + const projectValue = getPluginConfig(project, key) + if (projectValue) return projectValue + return await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + } + + private async ensureProjectGroupRoles(project: ProjectWithDetails, args: { readOnlyPrivileges: string[], writePrivileges: string[] }) { + const rawWriteSuffixes = await this.getOptionalConfigValue(project, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES + + const rawReadSuffixes = await this.getOptionalConfigValue(project, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES + + const writeGroupPaths = generateProjectRoleGroupPath(project, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES) + const readGroupPaths = generateProjectRoleGroupPath(project, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES) + + const byId = generateRolePrivilegesMapping({ + readGroupPaths, + writeGroupPaths, + readOnlyPrivileges: args.readOnlyPrivileges, + writePrivileges: args.writePrivileges, + }) + + await Promise.all(Array.from(byId.entries(), ([id, privileges]) => this.ensureSecurityRole(id, privileges))) + } + + private async ensurePlatformRoles(projects: ProjectWithDetails[]) { + const rawWriteGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) + ?? DEFAULT_PLATFORM_WRITE_GROUP_PATHS + + const rawReadGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) + ?? DEFAULT_PLATFORM_READ_GROUP_PATHS + + const readonlyPrivileges = new Set() + const writePrivileges = new Set() + for (const project of projects) { + const computed = computeProjectPrivileges(project) + for (const privilege of computed.readOnly) readonlyPrivileges.add(privilege) + for (const privilege of computed.write) writePrivileges.add(privilege) + } + + const byId = generateRolePrivilegesMapping({ + readGroupPaths: parseOidcGroupPaths(rawReadGroupPaths || DEFAULT_PLATFORM_READ_GROUP_PATHS), + writeGroupPaths: parseOidcGroupPaths(rawWriteGroupPaths || DEFAULT_PLATFORM_WRITE_GROUP_PATHS), + readOnlyPrivileges: [...readonlyPrivileges], + writePrivileges: [...writePrivileges], + }) + + // Platform roles aggregate privileges of every Nexus-enabled project; a stale + // project (e.g. privileges missing on this instance) must not block the current one. + const entries = Array.from(byId.entries()) + const results = await Promise.allSettled(entries.map(([id, privileges]) => this.ensureSecurityRole(id, privileges))) + results.forEach((result, index) => { + if (result.status === 'rejected') { + this.logger.warn(`Failed to ensure platform role ${entries[index][0]}: ${result.reason instanceof Error ? result.reason.message : result.reason}`) + } + }) + } + + private async deleteProjectGroupRoles(project: ProjectWithDetails) { + const rawWriteSuffixes = await this.getOptionalConfigValue(project, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES + + const rawReadSuffixes = await this.getOptionalConfigValue(project, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES + + const groupPaths = [ + ...generateProjectRoleGroupPath(project, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES), + ...generateProjectRoleGroupPath(project, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES), + ] + + const ids = [...new Set(groupPaths.map(generateRoleId))] + await Promise.all(ids.map(id => this.client.deleteSecurityRoles(id))) + } + + @StartActiveSpan() + private async deleteProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + await Promise.all([ + this.deleteMavenRepos(project), + this.deleteNpmRepos(project), + ]) + + await Promise.all([ + this.deleteProjectGroupRoles(project), + this.client.deleteSecurityRoles(`${project.slug}-ID`), + this.client.deleteSecurityUsers(project.slug), + ]) + + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') + try { + await this.vault.delete(vaultPath) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + } +} + +function generateMavenHostedPrivilegeName(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${project.slug}-privilege-${kind}` +} + +function generateMavenGroupRepoName(project: ProjectWithDetails) { + return `${project.slug}-repository-group` +} + +function generateMavenGroupPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-privilege-group` +} + +function generateNpmHostedPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-npm-privilege` +} + +function generateNpmGroupRepoName(project: ProjectWithDetails) { + return `${project.slug}-npm-group` +} + +function generateNpmGroupPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-npm-group-privilege` +} + +function generateMavenHostedPrivilegeNameReadonly(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${generateMavenHostedPrivilegeName(project, kind)}-ro` +} + +function generateMavenGroupPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateMavenGroupPrivilegeName(project)}-ro` +} + +function generateNpmHostedPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateNpmHostedPrivilegeName(project)}-ro` +} + +function generateNpmGroupPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateNpmGroupPrivilegeName(project)}-ro` +} + +function generateProjectRoleGroupPath(project: ProjectWithDetails, rawGroupPathSuffixes: string) { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${project.slug}${path}`) +} + +function parseOidcGroupPaths(rawGroupPaths: string) { + return rawGroupPaths + .split(',') + .map(path => path.trim()) + .filter(Boolean) +} + +function generateRolePrivilegesMapping(args: { readGroupPaths: string[], writeGroupPaths: string[], readOnlyPrivileges: string[], writePrivileges: string[] }) { + const byId = new Map() + for (const groupPath of args.readGroupPaths) byId.set(generateRoleId(groupPath), args.readOnlyPrivileges) + for (const groupPath of args.writeGroupPaths) byId.set(generateRoleId(groupPath), args.writePrivileges) + return byId +} + +function generateRoleId(groupPath: string) { + const trimmed = groupPath.trim() + const withoutLeadingSlash = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed + return withoutLeadingSlash.replaceAll('/', '-') +} + +function computeProjectPrivileges(project: ProjectWithDetails) { + const enableMaven = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO)) ?? false + const enableNpm = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO)) ?? false + + const write = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + ] + : []), + ] + + const readOnly = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + : []), + ] + + return { readOnly, write } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.utils.ts new file mode 100644 index 0000000000..6e9e0e7645 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/nexus/nexus.utils.ts @@ -0,0 +1,26 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { randomBytes } from 'node:crypto' + +export function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +export function generateRandomPassword(length: number) { + const raw = randomBytes(Math.ceil(length * 0.75)).toString('base64url') + return raw.slice(0, length) +} + +export function getProjectVaultPath(projectRootDir: string, projectSlug: string, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return `${projectRootDir}/${projectSlug}/${normalized}` +} + +export type MavenHostedRepoKind = 'release' | 'snapshot' + +export function generateMavenHostedRepoName(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${project.slug}-repository-${kind}` +} + +export function generateNpmHostedRepoName(project: ProjectWithDetails) { + return `${project.slug}-npm` +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts new file mode 100644 index 0000000000..5f008fd21e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts @@ -0,0 +1,25 @@ +import type { OpenCdsConfig } from './opencds.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { OPENCDS_CONFIG } from './opencds.module-definition' + +@Injectable() +export class OpenCdsHealthService { + constructor( + @Inject(OPENCDS_CONFIG) private readonly opencdsConfig: OpenCdsConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const headers: Record = {} + headers.Authorization = `Bearer ${this.opencdsConfig.apiToken}` + const response = await fetch(this.opencdsConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts new file mode 100644 index 0000000000..5a31df9912 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts @@ -0,0 +1,16 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface OpenCdsConfig { + url: string + internalUrl: string + probeUrl?: string + apiToken: string + apiTlsRejectUnauthorized: boolean +} + +export interface OpenCdsModuleOptions extends OpenCdsConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('opencds') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as OPENCDS_CONFIG } diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/opencds/opencds.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/opencds/opencds.module.ts new file mode 100644 index 0000000000..edc85a987a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/opencds/opencds.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { OpenCdsHealthService } from './opencds-health.service' +import { ConfigurableModuleClass } from './opencds.module-definition' + +@Module({ + imports: [TerminusModule], + providers: [OpenCdsHealthService], + exports: [OpenCdsHealthService], +}) +export class OpenCdsModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts new file mode 100644 index 0000000000..1ef4cac975 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts @@ -0,0 +1,65 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginModule } from './plugin.module' +import { PluginService } from './plugin.service' + +describe('pluginModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits PluginService when USE_ARGOCD=false', async () => { + vi.stubEnv('USE_ARGOCD', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_ARGOCD')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_GITLAB=false', async () => { + vi.stubEnv('USE_GITLAB', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_GITLAB')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_REGISTRY=false', async () => { + vi.stubEnv('USE_REGISTRY', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_REGISTRY')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_KEYCLOAK=false', async () => { + vi.stubEnv('USE_KEYCLOAK', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_KEYCLOAK')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_NEXUS=false', async () => { + vi.stubEnv('USE_NEXUS', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_NEXUS')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_SONARQUBE=false', async () => { + vi.stubEnv('USE_SONARQUBE', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_SONARQUBE')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_VAULT')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.module.ts new file mode 100644 index 0000000000..d74a259e4e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { ArgoCDModule } from '../argocd/argocd.module' +import { GitlabModule } from '../gitlab/gitlab.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { NexusModule } from '../nexus/nexus.module' +import { RegistryModule } from '../registry/registry.module' +import { SonarqubeModule } from '../sonarqube/sonarqube.module' +import { VaultModule } from '../vault/vault.module' +import { PluginService } from './plugin.service' + +@Module({ + imports: [ + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + ConditionalModule.registerWhen(RegistryModule, 'USE_REGISTRY'), + KeycloakModule, + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(SonarqubeModule, 'USE_SONARQUBE'), + VaultModule, + ], + providers: [PluginService], + exports: [PluginService], +}) +export class PluginModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.service.ts new file mode 100644 index 0000000000..1b9303ee53 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.service.ts @@ -0,0 +1,46 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { ArgoCDPluginService } from '../argocd/argocd-plugin.service' +import { GitlabPluginService } from '../gitlab/gitlab-plugin.service' +import { KeycloakPluginService } from '../keycloak/keycloak-plugin.service' +import { NexusPluginService } from '../nexus/nexus-plugin.service' +import { RegistryPluginService } from '../registry/registry-plugin.service' +import { SonarqubePluginService } from '../sonarqube/sonarqube-plugin.service' +import { VaultPluginService } from '../vault/vault-plugin.service' + +@Injectable() +export class PluginService { + private readonly logger = new Logger(PluginService.name) + + constructor( + @Inject(ArgoCDPluginService) private readonly argoCDPlugin: ArgoCDPluginService, + @Inject(GitlabPluginService) private readonly gitlabPlugin: GitlabPluginService, + @Inject(RegistryPluginService) private readonly registryPlugin: RegistryPluginService, + @Inject(KeycloakPluginService) private readonly keycloakPlugin: KeycloakPluginService, + @Inject(NexusPluginService) private readonly nexusPlugin: NexusPluginService, + @Inject(SonarqubePluginService) private readonly sonarqubePlugin: SonarqubePluginService, + @Inject(VaultPluginService) private readonly vaultPlugin: VaultPluginService, + ) {} + + async infos(projectId: string): Promise { + const plugins = [ + ['argocd', () => this.argoCDPlugin.infos()], + ['gitlab', () => this.gitlabPlugin.infos()], + ['registry', () => this.registryPlugin.infos(projectId)], + ['keycloak', () => this.keycloakPlugin.infos()], + ['nexus', () => this.nexusPlugin.infos()], + ['sonarqube', () => this.sonarqubePlugin.infos()], + ['vault', () => this.vaultPlugin.infos()], + ] as const + + const settled = await Promise.allSettled(plugins.map(([, loadInfos]) => loadInfos())) + return settled.flatMap((result, index) => { + const [pluginName] = plugins[index] + if (result.status === 'fulfilled') { + return [result.value] + } + this.logger.warn(`Skipping project service plugin ${pluginName} because infos() failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`) + return [] + }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts new file mode 100644 index 0000000000..3a2ecd238e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { capturePluginResult, getFailedPlugins, mergePluginResults } from './plugin.utils' + +describe('capturePluginResult', () => { + it('should resolve a successful task into an OK result with the default message, keyed under the plugin name', async () => { + await expect(capturePluginResult('gitlab', async () => {})).resolves.toEqual({ + gitlab: { + status: 'OK', + message: 'Up to date', + executionTime: expect.any(Number), + }, + }) + }) + + it('should use the string returned by the task as message', async () => { + await expect(capturePluginResult('gitlab', async () => 'Everything synced')).resolves.toEqual({ + gitlab: expect.objectContaining({ status: 'OK', message: 'Everything synced' }), + }) + }) + + it('should resolve a throwing task into a KO result instead of rejecting', async () => { + const task = async () => { + throw new Error('GitLab unreachable') + } + await expect(capturePluginResult('gitlab', task)).resolves.toEqual({ + gitlab: { + status: 'KO', + message: 'GitLab unreachable', + executionTime: expect.any(Number), + error: new Error('GitLab unreachable'), + }, + }) + }) + + it('should report a fallback message for non-Error throws', async () => { + const task = async () => { + throw 'boom' // eslint-disable-line no-throw-literal + } + await expect(capturePluginResult('gitlab', task)).resolves.toEqual({ + gitlab: expect.objectContaining({ status: 'KO', message: 'Erreur inconnue', error: 'boom' }), + }) + }) + + it('should report under the given plugin name', async () => { + await expect(capturePluginResult('nexus', async () => 'custom message')).resolves.toEqual({ + nexus: expect.objectContaining({ status: 'OK', message: 'custom message' }), + }) + }) +}) + +describe('mergePluginResults', () => { + it('should return empty object when given empty array', () => { + expect(mergePluginResults([])).toEqual({}) + }) + + it('should return single result as-is', () => { + const result = [{ argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }] + expect(mergePluginResults(result)).toEqual(result[0]) + }) + + it('should merge multiple results into one', () => { + const results = [ + { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }, + { gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 } }, + ] + expect(mergePluginResults(results)).toEqual({ + argocd: { status: 'OK', message: 'Up to date', executionTime: 10 }, + gitlab: { status: 'OK', message: 'Synced', executionTime: 20 }, + }) + }) + + it('should have later entries overwrite earlier ones for the same plugin', () => { + const error = new Error('sync error') + const results = [ + { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }, + { argocd: { status: 'KO' as const, message: 'Failed', executionTime: 20, error } }, + ] + expect(mergePluginResults(results)).toEqual({ + argocd: { status: 'KO', message: 'Failed', executionTime: 20, error }, + }) + }) +}) + +describe('getFailedPlugins', () => { + it('should return empty array when all plugins are OK', () => { + const results = { + argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 }, + gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 }, + } + expect(getFailedPlugins(results)).toEqual([]) + }) + + it('should return names of KO plugins', () => { + const error = new Error('sync error') + const results = { + argocd: { status: 'KO' as const, message: 'Failed', executionTime: 10, error }, + gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 }, + } + expect(getFailedPlugins(results)).toEqual(['argocd']) + }) + + it('should return all plugins when all fail', () => { + const error = new Error('error') + const results = { + argocd: { status: 'KO' as const, message: 'Failed', executionTime: 10, error }, + gitlab: { status: 'KO' as const, message: 'Failed', executionTime: 20, error }, + } + const failed = getFailedPlugins(results) + expect(failed).toHaveLength(2) + expect(failed).toContain('argocd') + expect(failed).toContain('gitlab') + }) + + it('should return empty array for empty result', () => { + expect(getFailedPlugins({})).toEqual([]) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.utils.ts new file mode 100644 index 0000000000..a54447a6a0 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/plugin/plugin.utils.ts @@ -0,0 +1,107 @@ +import type { ToUrlFnParamaters } from '@cpn-console/hooks' +import { Logger } from '@nestjs/common' + +export function makeToUrlParams(overrides: Partial = {}): ToUrlFnParamaters { + return { + store: {}, + clusters: [], + zones: [], + environments: [], + project: { id: '', slug: 'dulei', name: '' }, + ...overrides, + } +} +export type PluginName = 'argocd' + | 'gitlab' + | 'nexus' + | 'vault' + | 'keycloak' + | 'harbor' + | 'sonarqube' + | 'observability' + +export type PluginResult = { + status: 'OK' + message: string + executionTime: number +} | { + status: 'KO' + message: string + executionTime: number + error: unknown +} + +export type PluginResults = Partial> + +export type RequiredPluginResult + = { [K in T]: PluginResult } & PluginResults + +const logger = new Logger('PluginResult') + +/** + * Runs a plugin task and always resolves with the task's outcome (status, + * message, execution time and error, if any) keyed under the given plugin + * name, instead of throwing, so the event emitter can merge and persist the + * results of every listener. + * + * The task keeps its natural shape: do the work, throw on failure, optionally + * return a message string to override the default 'Up to date'. + * + * The `@OnEvent` handler stays a thin, honestly-typed wrapper, while the + * traced work method keeps `@StartActiveSpan` so the span still records the + * exception on failure: + * + * ```ts + * @OnEvent('project.upsert') + * async handleUpsert(project: ProjectWithDetails): Promise> { + * return capturePluginResult('argocd', () => this.syncProject(project)) + * } + * + * @StartActiveSpan() + * private async syncProject(project: ProjectWithDetails) { ... } + * ``` + */ +export async function capturePluginResult

( + plugin: P, + task: () => Promise, +): Promise> { + const start = process.hrtime.bigint() + const elapsedMs = () => Number(process.hrtime.bigint() - start) / 1_000_000 + + try { + const message = await task() + return keyedBy(plugin, { + status: 'OK', + message: typeof message === 'string' ? message : 'Up to date', + executionTime: elapsedMs(), + }) + } catch (error: unknown) { + logger.error(`${plugin} handler failed`, error) + return keyedBy(plugin, { + status: 'KO', + message: error instanceof Error ? error.message : 'Erreur inconnue', + executionTime: elapsedMs(), + error, + }) + } +} + +// TypeScript widens a computed property with a generic key to an index +// signature instead of Record, so the assertion is confined here. +function keyedBy

(plugin: P, result: PluginResult): RequiredPluginResult

{ + return { [plugin]: result } as RequiredPluginResult

+} + +export function mergePluginResults(responses: PluginResults[]): PluginResults { + return responses.reduce((merged, currentResponse) => { + return { ...merged, ...currentResponse } + }, {} as PluginResults) +} + +export function getFailedPlugins(response: PluginResults): PluginName[] { + const entries = Object.entries(response) as [PluginName, PluginResult][] + + return entries + .filter(([_, result]) => result.status === 'KO') + .map(([pluginName]) => pluginName) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts new file mode 100644 index 0000000000..73858d492b --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts @@ -0,0 +1,16 @@ +import type { Prisma } from '@prisma/client' + +export const projectNotArchivedSelect = { + id: true, +} satisfies Prisma.ProjectSelect + +export type ProjectIdNotArchived = Prisma.ProjectGetPayload<{ + select: typeof projectNotArchivedSelect +}> + +export function listProjectIdsNotArchived(tx: Prisma.TransactionClient): Promise { + return tx.project.findMany({ + select: projectNotArchivedSelect, + where: { status: { not: 'archived' } }, + }) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts new file mode 100644 index 0000000000..82874da42d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts @@ -0,0 +1,36 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { projectContract } from '@cpn-console/shared' +import { Body, Controller, HttpCode, HttpStatus, Inject, Logger, Post, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectBulkService } from './project-bulk.service' + +@Controller('api/v1/projects-bulk') +export class ProjectBulkController { + private readonly logger = new Logger(ProjectBulkController.name) + + constructor( + @Inject(ProjectBulkService) private readonly projectBulk: ProjectBulkService, + ) {} + + @Post('') + @HttpCode(HttpStatus.ACCEPTED) + @UseGuards(UserGuard) + @RequireAdminPermission('Manage') + async bulkAction( + @Body(new ZodValidationPipe(projectContract.bulkActionProject.body)) body: typeof projectContract.bulkActionProject.body._type, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + const target = body.projectIds === 'all' + ? 'all' + : `count=${body.projectIds.length}` + + this.logger.log(`project.bulkAction requested (action=${body.action}, target=${target})`) + await this.projectBulk.bulkAction(body, user.userId, request.id) + this.logger.log(`project.bulkAction accepted (action=${body.action})`) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts new file mode 100644 index 0000000000..fbb1d9bf76 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { ProjectHooksModule } from '../project-hooks/project-hooks.module' +import { ProjectModule } from '../project/project.module' +import { ProjectBulkController } from './project-bulk.controller' +import { ProjectBulkService } from './project-bulk.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ProjectModule, + ProjectHooksModule, + ], + controllers: [ProjectBulkController], + providers: [ProjectBulkService], +}) +export class ProjectBulkModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts new file mode 100644 index 0000000000..37ec381d2e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts @@ -0,0 +1,85 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { ProjectHooksService } from '../project-hooks/project-hooks.service' +import { makeProject } from '../project/project-testing.utils' +import { ProjectService } from '../project/project.service' +import { ProjectBulkService } from './project-bulk.service' + +describe('projectBulkService', () => { + let module: TestingModule + let service: ProjectBulkService + let prisma: DeepMockProxy + let project: DeepMockProxy + let projectHooks: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + project = mockDeep() + projectHooks = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectBulkService, + { provide: PrismaService, useValue: prisma }, + { provide: ProjectService, useValue: project }, + { provide: ProjectHooksService, useValue: projectHooks }, + ], + }).compile() + + service = module.get(ProjectBulkService) + }) + + it('processes specific project ids', async () => { + const projectIds = [faker.string.uuid(), faker.string.uuid()] + + await service.bulkAction({ action: 'archive', projectIds }, 'user-id', 'request-id') + + expect(project.archive).toHaveBeenCalledTimes(2) + expect(project.archive).toHaveBeenCalledWith(projectIds[0], 'user-id', 'request-id') + expect(project.archive).toHaveBeenCalledWith(projectIds[1], 'user-id', 'request-id') + }) + + it('resolves "all" to all non-archived project ids', async () => { + const project1Id = faker.string.uuid() + const project2Id = faker.string.uuid() + + prisma.project.findMany.mockResolvedValue([makeProject({ id: project1Id }), makeProject({ id: project2Id })]) + + await service.bulkAction({ action: 'archive', projectIds: 'all' }, 'user-id', 'request-id') + + expect(prisma.project.findMany).toHaveBeenCalledWith({ + select: { id: true }, + where: { status: { not: 'archived' } }, + }) + expect(project.archive).toHaveBeenCalledTimes(2) + }) + + it('lock action updates locked to true via project hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'lock', projectIds: [projectId] }) + + expect(projectHooks.updateProjectLocked).toHaveBeenCalledWith(projectId, true) + }) + + it('unlock action updates locked to false via project hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'unlock', projectIds: [projectId] }) + + expect(projectHooks.updateProjectLocked).toHaveBeenCalledWith(projectId, false) + }) + + it('replay action triggers hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'replay', projectIds: [projectId] }, 'user-id', 'request-id') + + expect(projectHooks.replay).toHaveBeenCalledWith(projectId, 'user-id', 'request-id') + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts new file mode 100644 index 0000000000..5989764d17 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts @@ -0,0 +1,96 @@ +import type { projectContract } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { ProjectHooksService } from '../project-hooks/project-hooks.service' +import { ProjectService } from '../project/project.service' +import { listProjectIdsNotArchived } from './project-bulk-queries.utils' + +@Injectable() +export class ProjectBulkService { + private readonly logger = new Logger(ProjectBulkService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(ProjectService) private readonly project: ProjectService, + @Inject(ProjectHooksService) private readonly projectHooks: ProjectHooksService, + ) {} + + @StartActiveSpan() + async bulkAction( + data: typeof projectContract.bulkActionProject.body._type, + requestorUserId?: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.bulk.action', data.action) + const projectIdsLog = data.projectIds === 'all' ? 'all' : `count=${data.projectIds.length}` + this.logger.log(`project.bulkAction started (action=${data.action}, projectIds=${projectIdsLog})`) + + try { + const projectIds = await this.resolveProjectIds(data.projectIds) + span?.setAttribute('project.bulk.count', projectIds.length) + + const results = await this.runTasks(projectIds, data.action, requestorUserId, requestId) + const summary = this.summarizeTasks(results) + + span?.setAttributes({ + 'project.bulk.fulfilled': summary.fulfilled, + 'project.bulk.rejected': summary.rejected, + }) + this.logger.log(`project.bulkAction completed (action=${data.action}, projectCount=${projectIds.length}, fulfilled=${summary.fulfilled}, rejected=${summary.rejected})`) + } catch (error) { + const projectIdsLabel = data.projectIds === 'all' + ? 'all' + : `count=${data.projectIds.length}` + const errorMessage = error instanceof Error ? error.message : String(error) + + this.logger.error( + `project.bulkAction failed (action=${data.action}, projectIds=${projectIdsLabel}): ${errorMessage}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async resolveProjectIds(projectIds: string[] | 'all'): Promise { + if (projectIds === 'all') { + const result = await listProjectIdsNotArchived(this.prisma) + return result.map(p => p.id) + } + return projectIds + } + + private async runTasks( + projectIds: string[], + action: string, + requestorUserId?: string, + requestId?: string, + ): Promise[]> { + const tasks = projectIds.map((projectId) => { + if (action === 'archive') { + return this.project.archive(projectId, requestorUserId, requestId) + } + if (action === 'lock' || action === 'unlock') { + return this.projectHooks.updateProjectLocked(projectId, action === 'lock') + } + if (action === 'replay') { + return this.projectHooks.replay(projectId, requestorUserId, requestId) + } + return Promise.resolve() + }) + return Promise.allSettled(tasks) + } + + private summarizeTasks(results: PromiseSettledResult[]): { fulfilled: number, rejected: number } { + return results.reduce( + (acc, r) => { + if (r.status === 'fulfilled') acc.fulfilled += 1 + else acc.rejected += 1 + return acc + }, + { fulfilled: 0, rejected: 0 }, + ) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts new file mode 100644 index 0000000000..9dd8e67e34 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts @@ -0,0 +1,32 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Controller, HttpCode, HttpStatus, Inject, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ProjectHooksService } from './project-hooks.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectHooksController { + constructor( + @Inject(ProjectHooksService) private readonly projectHooks: ProjectHooksService, + ) {} + + @Put('/:projectId/hooks') + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ReplayHooks') + async replay( + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + await this.projectHooks.replay(project.id, user.userId, request.id) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts new file mode 100644 index 0000000000..ff83fb82fb --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { LogModule } from '../log/log.module' +import { ProjectHooksController } from './project-hooks.controller' +import { ProjectHooksService } from './project-hooks.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, EventsModule, LogModule, ProjectPermissionModule], + controllers: [ProjectHooksController], + providers: [ProjectHooksService], + exports: [ProjectHooksService], +}) +export class ProjectHooksModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts new file mode 100644 index 0000000000..92d9ddf77e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts @@ -0,0 +1,80 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { projectSelect } from '../project/project-queries.utils' +import { makeProject } from '../project/project-testing.utils' +import { ProjectHooksService } from './project-hooks.service' + +describe('projectHooksService', () => { + let module: TestingModule + let service: ProjectHooksService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectHooksService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(ProjectHooksService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('updateProjectLocked updates the project locked flag', async () => { + const project = makeProject({ locked: true }) + prisma.project.update.mockResolvedValue(project) + + await service.updateProjectLocked('project-id', true) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 'project-id' }, + data: { locked: true }, + select: projectSelect, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', project, { action: 'Update Project' }) + }) + + it('replayHooks loads the project and emits project.upsert', async () => { + const project = makeProject() + prisma.project.findFirst.mockResolvedValue(project) + + const requestId = 'request-id' + const userId = 'user-id' + + await service.replay('project-id', userId, requestId) + + expect(prisma.project.findFirst).toHaveBeenCalledWith({ + where: { id: 'project-id', status: { not: 'archived' } }, + select: projectSelect, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', project, { + action: 'Replay hooks for Project', + userId, + requestId, + }) + }) + + it('replayHooks rejects locked projects', async () => { + const project = makeProject({ locked: true }) + prisma.project.findFirst.mockResolvedValue(project) + + await expect(service.replay('project-id', 'user-id', 'request-id')).rejects.toThrow(ForbiddenException) + + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts new file mode 100644 index 0000000000..b51cd4df76 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts @@ -0,0 +1,46 @@ +import { ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { getProjectNotArchived, projectSelect } from '../project/project-queries.utils' + +@Injectable() +export class ProjectHooksService { + private readonly logger = new Logger(ProjectHooksService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async updateProjectLocked(projectId: string, locked: boolean): Promise { + const project = await this.prisma.project.update({ + where: { id: projectId }, + data: { locked }, + select: projectSelect, + }) + await this.appEvents.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + } + + async replay(projectId: string, requestorUserId?: string, requestId?: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.replayHooks started (projectId=${projectId})`) + const project = await getProjectNotArchived(this.prisma, projectId) + if (!project) { + this.logger.warn(`project.replayHooks notFound (projectId=${projectId})`) + return + } + if (project.locked) { + this.logger.warn(`project.replayHooks locked (projectId=${projectId})`) + throw new ForbiddenException('Veuillez déverrouiller le projet pour rejouer les webhooks') + } + span?.setAttribute('project.slug', project.slug) + await this.appEvents.emitProjectEvent('project.upsert', project, { + action: 'Replay hooks for Project', + userId: requestorUserId, + requestId, + }) + this.logger.log(`project.replayHooks completed (projectId=${projectId})`) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts new file mode 100644 index 0000000000..ad12c2486d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts @@ -0,0 +1,78 @@ +import type { Prisma } from '@prisma/client' + +export const projectMemberWithUser = { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, +} satisfies Prisma.ProjectMembersInclude + +export const projectOwnerIdSelect = { + ownerId: true, +} satisfies Prisma.ProjectSelect + +export type ProjectMemberWithUser = Prisma.ProjectMembersGetPayload<{ + include: typeof projectMemberWithUser +}> + +export type ProjectOwnerId = Prisma.ProjectGetPayload<{ + select: typeof projectOwnerIdSelect +}> + +export type AddMemberInput = { email: string } | { userId: string } +export interface PatchMemberInput { userId: string, roles: string[] } + +export function listProjectMembersWithUser(tx: Prisma.TransactionClient, projectId: string) { + return tx.projectMembers.findMany({ + where: { projectId }, + include: projectMemberWithUser, + }) +} + +export function upsertProjectMember( + tx: Prisma.TransactionClient, + projectId: string, + member: PatchMemberInput, +) { + const { userId, roles } = member + return tx.projectMembers.upsert({ + where: { projectId_userId: { projectId, userId } }, + create: { projectId, userId, roleIds: roles }, + update: { roleIds: roles }, + }) +} + +export function upsertProjectMemberIfMissing(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.upsert({ + where: { projectId_userId: { projectId, userId } }, + create: { projectId, userId, roleIds: [] }, + update: {}, + }) +} + +export function createProjectMember(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.create({ data: { projectId, userId } }) +} + +export function deleteProjectMember(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.delete({ where: { projectId_userId: { projectId, userId } } }) +} + +export function getHumanUser(tx: Prisma.TransactionClient, opts: { userId?: string, email?: string }) { + const { userId, email } = opts + return tx.user.findFirst({ + where: { + type: 'human', + ...(typeof userId === 'string' ? { id: userId } : {}), + ...(typeof email === 'string' ? { email } : {}), + }, + }) +} + +export function getProjectOwnerId(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectOwnerIdSelect }) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts new file mode 100644 index 0000000000..e01e7f37ea --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts @@ -0,0 +1,11 @@ +import type { ProjectMembers } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } satisfies ProjectMembers +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.controller.ts new file mode 100644 index 0000000000..1761096669 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.controller.ts @@ -0,0 +1,73 @@ +import type { Member } from '@cpn-console/shared' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { AddMemberInput, PatchMemberInput } from './project-members-queries.utils' +import { projectMemberContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Logger, Param, Patch, Post, UseGuards } from '@nestjs/common' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectMembersService } from './project-members.service' +import { generateProjectMember } from './project-members.utils' + +@Controller('api/v1/projects/:projectId/members') +@UseGuards(ProjectGuard) +export class ProjectMembersController { + private readonly logger = new Logger(ProjectMembersController.name) + + constructor( + @Inject(ProjectMembersService) private readonly projectMembers: ProjectMembersService, + ) {} + + @Get() + @RequireProjectPermission('ListMembers') + async list( + @Project() project: ProjectContext, + ): Promise { + return (await this.projectMembers.list(project.id)).map(generateProjectMember) + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async add( + @Body(new ZodValidationPipe(projectMemberContract.addMember.body)) body: AddMemberInput, + @Project() project: ProjectContext, + ): Promise { + const members = await this.projectMembers.add(project.id, body) + this.logger.log(`projectMembers.add completed (memberCount=${members.length})`) + return members.map(generateProjectMember) + } + + @Patch() + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async patch( + @Body(new ZodValidationPipe(projectMemberContract.patchMembers.body)) body: PatchMemberInput[], + @Project() project: ProjectContext, + ): Promise { + const members = await this.projectMembers.patch(project.id, body) + this.logger.log(`projectMembers.patchMembers completed (projectId=${project.id}, memberCount=${members.length})`) + return members.map(generateProjectMember) + } + + @Delete('/:userId') + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async remove( + @Project() project: ProjectContext, + @Param('userId') userId: string, + ): Promise { + const members = await this.projectMembers.remove(project.id, userId) + this.logger.log(`projectMembers.remove completed (projectId=${project.id}, userId=${userId}, memberCount=${members.length})`) + return members.map(generateProjectMember) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.module.ts new file mode 100644 index 0000000000..481aeddcef --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { keycloakConfigFactory } from '../../config/keycloak.config' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { ProjectMembersController } from './project-members.controller' +import { ProjectMembersService } from './project-members.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, KeycloakModule.forRoot(keycloakConfigFactory.asProvider()), ProjectPermissionModule], + controllers: [ProjectMembersController], + providers: [ProjectMembersService], +}) +export class ProjectMembersModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts new file mode 100644 index 0000000000..48ca9eeaaa --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts @@ -0,0 +1,268 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { KeycloakClientService } from '../keycloak/keycloak-client.service' +import { + makeProject, + makeProjectMembers, + makeProjectMemberWithUser, + makeUser, +} from '../project/project-testing.utils' +import { ProjectMembersService } from './project-members.service' + +describe('projectMembersService', () => { + let module: TestingModule + let service: ProjectMembersService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + let keycloak: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + keycloak = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectMembersService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + { provide: KeycloakClientService, useValue: keycloak }, + ], + }).compile() + + service = module.get(ProjectMembersService) + }) + + describe('list', () => { + it('returns members', async () => { + const projectId = faker.string.uuid() + const user1 = makeUser() + const user2 = makeUser() + prisma.projectMembers.findMany.mockResolvedValue([ + makeProjectMemberWithUser(user1, { roleIds: [faker.string.uuid()] }), + makeProjectMemberWithUser(user2), + ]) + + const result = await service.list(projectId) + + expect(result).toHaveLength(2) + expect(result[0]).toHaveProperty('roleIds') + expect(result[0]).toHaveProperty('user') + expect(result[0].user.id).toBe(user1.id) + }) + + it('returns empty array when no members', async () => { + const projectId = faker.string.uuid() + prisma.projectMembers.findMany.mockResolvedValue([]) + + const result = await service.list(projectId) + + expect(result).toEqual([]) + }) + }) + + describe('add', () => { + it('adds member by userId and returns updated member list', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const newUserId = faker.string.uuid() + const body = { userId: newUserId } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ id: newUserId })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId, userId: newUserId })) + const memberUser = makeUser() + tx.projectMembers.findMany.mockResolvedValue([makeProjectMemberWithUser(memberUser)]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.add(projectId, body) + + expect(tx.projectMembers.upsert).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId, userId: newUserId } }, + create: { projectId, userId: newUserId, roleIds: [] }, + update: {}, + }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: newUserId, + }, { action: 'Add Project Member' }) + expect(result).toBeDefined() + }) + + it('adds member by email and returns updated member list', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const email = faker.internet.email() + const body = { email } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ email })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId })) + tx.projectMembers.findMany.mockResolvedValue([]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.add(projectId, body) + + expect(tx.user.findFirst).toHaveBeenCalledWith({ + where: { email, type: 'human' }, + }) + expect(result).toBeDefined() + }) + + it('throws when adding owner as member', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const body = { userId: ownerId } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ id: ownerId })) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow('Le owner ne peut pas être ajouté à cette liste') + }) + + it('throws NotFoundException when user not found by userId', async () => { + const projectId = faker.string.uuid() + const body = { userId: faker.string.uuid() } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId: faker.string.uuid() })) + tx.user.findFirst.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow(NotFoundException) + }) + + it('falls back to Keycloak when user not found locally by email', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const email = faker.internet.email() + const keycloakUserId = faker.string.uuid() + const body = { email } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(null) + tx.user.upsert.mockResolvedValue(makeUser({ id: keycloakUserId, email })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId, userId: keycloakUserId })) + tx.projectMembers.findMany.mockResolvedValue([]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + keycloak.getUserByEmail.mockResolvedValue(makeUser({ + id: keycloakUserId, + email, + firstName: 'Keycloak', + lastName: 'User', + })) + + const result = await service.add(projectId, body) + + expect(keycloak.getUserByEmail).toHaveBeenCalledWith(email) + expect(tx.user.upsert).toHaveBeenCalledWith({ + where: { id: keycloakUserId }, + create: { + id: keycloakUserId, + email, + firstName: 'Keycloak', + lastName: 'User', + adminRoleIds: [], + type: 'human', + }, + update: { + email, + firstName: 'Keycloak', + lastName: 'User', + type: 'human', + }, + }) + expect(result).toBeDefined() + }) + + it('throws NotFoundException when user not found by email anywhere', async () => { + const projectId = faker.string.uuid() + const body = { email: faker.internet.email() } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId: faker.string.uuid() })) + tx.user.findFirst.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow(NotFoundException) + }) + + it('throws NotFoundException when project does not exist', async () => { + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.add(faker.string.uuid(), { userId: faker.string.uuid() }), + ).rejects.toThrow(NotFoundException) + }) + }) + + describe('patch', () => { + it('upserts multiple members and emits events', async () => { + const projectId = faker.string.uuid() + const members = [ + { userId: faker.string.uuid(), roles: [faker.string.uuid()] }, + { userId: faker.string.uuid(), roles: [] }, + ] + + const tx = mockDeep() + prisma.$transaction.mockImplementation(async cb => cb(tx)) + tx.projectMembers.findMany.mockResolvedValue([]) + + await service.patch(projectId, members) + + expect(tx.projectMembers.upsert).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: members[0].userId, + }, { action: 'Update Project Member' }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: members[1].userId, + }, { action: 'Update Project Member' }) + }) + }) + + describe('remove', () => { + it('deletes member, emits event, returns updated list', async () => { + const projectId = faker.string.uuid() + const userId = faker.string.uuid() + + const tx = mockDeep() + tx.projectMembers.delete.mockResolvedValue(makeProjectMembers({ projectId, userId })) + const memberUser = makeUser() + tx.projectMembers.findMany.mockResolvedValue([makeProjectMemberWithUser(memberUser)]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.remove(projectId, userId) + + expect(tx.projectMembers.delete).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId, userId } }, + }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.delete', { + projectId, + userId, + }, { action: 'Remove Project Member' }) + expect(result).toBeDefined() + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.service.ts new file mode 100644 index 0000000000..4daa282eaa --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.service.ts @@ -0,0 +1,164 @@ +import type { AddMemberInput, PatchMemberInput } from './project-members-queries.utils' +import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { KeycloakClientService } from '../keycloak/keycloak-client.service' +import { deleteProjectMember, getHumanUser, getProjectOwnerId, listProjectMembersWithUser, upsertProjectMember, upsertProjectMemberIfMissing } from './project-members-queries.utils' + +@Injectable() +export class ProjectMembersService { + private readonly logger = new Logger(ProjectMembersService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, + ) {} + + @StartActiveSpan() + async list(projectId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.debug(`projectMembers.listMembers started (projectId=${projectId})`) + const members = await listProjectMembersWithUser(this.prisma, projectId) + span?.setAttribute('project.members.count', members.length) + this.logger.debug(`projectMembers.listMembers completed (projectId=${projectId}, count=${members.length})`) + return members + } + + @StartActiveSpan() + async add( + projectId: string, + body: AddMemberInput, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + const lookupBy = 'userId' in body ? 'userId' : 'email' + span?.setAttribute('project.member.lookupBy', lookupBy) + const userIdCandidate = 'userId' in body ? body.userId : undefined + this.logger.log(`projectMembers.addMember started (projectId=${projectId}, lookupBy=${lookupBy}, userId=${userIdCandidate})`) + try { + const result = await this.prisma.$transaction(async (tx) => { + const project = await getProjectOwnerId(tx, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + + const userId = 'userId' in body ? body.userId : undefined + const email = 'email' in body ? body.email : undefined + + const userDb = await this.resolveHumanUser(tx, { userId, email }) + if (!userDb) throw new NotFoundException('Utilisateur introuvable') + + if (userDb.id === project.ownerId) { + throw new BadRequestException('Le owner ne peut pas être ajouté à cette liste') + } + + await upsertProjectMemberIfMissing(tx, projectId, userDb.id) + + const members = await listProjectMembersWithUser(tx, projectId) + return { userId: userDb.id, members } + }) + await this.appEvents.emitProjectMemberEvent('projectMember.upsert', { projectId, userId: result.userId }, { action: 'Add Project Member' }) + span?.setAttribute('project.member.userId', result.userId) + span?.setAttribute('project.members.count', result.members.length) + this.logger.log(`projectMembers.addMember completed (projectId=${projectId}, userId=${result.userId}, memberCount=${result.members.length})`) + return result.members + } catch (error) { + this.logger.error( + `projectMembers.addMember failed (projectId=${projectId}, lookupBy=${lookupBy}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async resolveHumanUser( + tx: Parameters[0], + opts: { userId?: string, email?: string }, + ) { + const userDb = await getHumanUser(tx, opts) + if (userDb || !opts.email) { + return userDb + } + + const keycloakUser = await this.keycloak.getUserByEmail(opts.email) + if (!keycloakUser) { + return null + } + + const keycloakUserId = keycloakUser.id ?? opts.email + return tx.user.upsert({ + where: { id: keycloakUserId }, + create: { + id: keycloakUserId, + email: keycloakUser.email ?? opts.email, + firstName: keycloakUser.firstName ?? keycloakUser.email ?? opts.email, + lastName: keycloakUser.lastName ?? '', + adminRoleIds: [], + type: 'human', + }, + update: { + email: keycloakUser.email ?? opts.email, + firstName: keycloakUser.firstName ?? keycloakUser.email ?? opts.email, + lastName: keycloakUser.lastName ?? '', + type: 'human', + }, + }) + } + + @StartActiveSpan() + async patch( + projectId: string, + body: PatchMemberInput[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('project.members.patch.count', body.length) + this.logger.log(`projectMembers.patchMembers started (projectId=${projectId}, patchCount=${body.length})`) + try { + const members = await this.prisma.$transaction(async (tx) => { + for (const member of body) { + await upsertProjectMember(tx, projectId, member) + } + return listProjectMembersWithUser(tx, projectId) + }) + await Promise.all( + body.map(member => this.appEvents.emitProjectMemberEvent('projectMember.upsert', { projectId, userId: member.userId }, { action: 'Update Project Member' })), + ) + span?.setAttribute('project.members.count', members.length) + this.logger.log(`projectMembers.patchMembers completed (projectId=${projectId}, memberCount=${members.length})`) + return members + } catch (error) { + this.logger.error( + `projectMembers.patchMembers failed (projectId=${projectId}, patchCount=${body.length}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async remove(projectId: string, userId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('project.member.userId', userId) + this.logger.log(`projectMembers.removeMember started (projectId=${projectId}, userId=${userId})`) + try { + const members = await this.prisma.$transaction(async (tx) => { + await deleteProjectMember(tx, projectId, userId) + return listProjectMembersWithUser(tx, projectId) + }) + await this.appEvents.emitProjectMemberEvent('projectMember.delete', { projectId, userId }, { action: 'Remove Project Member' }) + span?.setAttribute('project.members.count', members.length) + this.logger.log(`projectMembers.removeMember completed (projectId=${projectId}, userId=${userId}, memberCount=${members.length})`) + return members + } catch (error) { + this.logger.error( + `projectMembers.removeMember failed (projectId=${projectId}, userId=${userId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.utils.ts new file mode 100644 index 0000000000..4131cfbebd --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-members/project-members.utils.ts @@ -0,0 +1,13 @@ +import type { Member } from '@cpn-console/shared' +import type { ProjectMemberWithUser } from './project-members-queries.utils' + +export function generateProjectMember(member: ProjectMemberWithUser): Member { + const { roleIds, user } = member + return { + userId: user.id, + roleIds, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts new file mode 100644 index 0000000000..20a4355031 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts @@ -0,0 +1,47 @@ +import type { Prisma } from '@prisma/client' + +export const projectRoleWithProjectSelect = { + id: true, + name: true, + permissions: true, + position: true, + oidcGroup: true, + type: true, + projectId: true, + project: { + select: { + slug: true, + members: { + distinct: 'userId', + }, + }, + }, +} satisfies Prisma.ProjectRoleSelect + +export const projectRoleForDeleteSelect = { + type: true, + projectId: true, +} satisfies Prisma.ProjectRoleSelect + +export const projectForRoleContextSelect = { + id: true, + slug: true, + status: true, + locked: true, +} satisfies Prisma.ProjectSelect + +export type ProjectRoleWithProject = Prisma.ProjectRoleGetPayload<{ select: typeof projectRoleWithProjectSelect }> + +export type ProjectForRoleContext = Prisma.ProjectRoleGetPayload<{ select: typeof projectForRoleContextSelect }> + +export function getProjectBySlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: { slug: true } }) +} + +export function getProjectRoleForDelete(tx: Prisma.TransactionClient, roleId: string) { + return tx.projectRole.findUnique({ where: { id: roleId }, select: projectRoleForDeleteSelect }) +} + +export function getProjectForUpsert(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectForRoleContextSelect }) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts new file mode 100644 index 0000000000..c9b74e9e28 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts @@ -0,0 +1,71 @@ +import type { Project, ProjectMembers, ProjectRole } from '@prisma/client' +import { faker } from '@faker-js/faker' + +interface ProjectRoleWithMembers extends ProjectRole { + members?: Array +} + +interface ProjectRoleWithProject extends ProjectRole { + project: Pick +} + +export function makeProjectRole(overrides: Partial = {}): ProjectRole { + return { + id: faker.string.uuid(), + name: faker.person.jobTitle(), + permissions: faker.number.bigInt({ min: 0n, max: 64n }), + projectId: faker.string.uuid(), + position: faker.number.int({ min: 0, max: 100 }), + oidcGroup: '', + type: 'managed', + ...overrides, + } +} + +export function makeProjectRoleWithMembers(overrides: Partial = {}): ProjectRoleWithMembers { + return { + ...makeProjectRole(overrides), + members: overrides.members ?? [], + } +} + +export function makeProjectRoleWithProject(overrides: Partial = {}): ProjectRoleWithProject { + const role = makeProjectRole(overrides) + return { + ...role, + project: overrides.project ?? { slug: faker.helpers.slugify(faker.company.name()) }, + } +} + +export function makeProject(overrides: Partial = {}): Project { + return { + id: faker.string.uuid(), + name: faker.company.name(), + description: '', + status: 'initializing', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.helpers.slugify(faker.company.name()), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } +} + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts new file mode 100644 index 0000000000..c7fb6a706d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts @@ -0,0 +1,70 @@ +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { projectRoleContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Patch, Post, UseGuards } from '@nestjs/common' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectRolesService } from './project-roles.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectRolesController { + constructor( + @Inject(ProjectRolesService) private readonly projectRoles: ProjectRolesService, + ) {} + + @Get('/:projectId/roles') + @RequireProjectPermission('ListRoles') + async list( + @Project() project: ProjectContext, + ) { + return this.projectRoles.list(project.id) + } + + @Post('/:projectId/roles') + @HttpCode(HttpStatus.CREATED) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async create( + @Body(new ZodValidationPipe(projectRoleContract.createProjectRole.body)) body: typeof projectRoleContract.createProjectRole.body._type, + @Project() project: ProjectContext, + ) { + return this.projectRoles.create(project.id, body) + } + + @Patch('/:projectId/roles') + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async update( + @Body(new ZodValidationPipe(projectRoleContract.patchProjectRoles.body)) body: typeof projectRoleContract.patchProjectRoles.body._type, + @Project() project: ProjectContext, + ) { + return this.projectRoles.update(project.id, body) + } + + @Get('/:projectId/roles/member-counts') + @RequireProjectPermission('ListRoles') + async countMembers( + @Project() project: ProjectContext, + ) { + return this.projectRoles.countMembers(project.id) + } + + @Delete('/:projectId/roles/:roleId') + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async delete( + @Param('roleId') roleId: string, + @Project() _project: ProjectContext, + ): Promise { + return this.projectRoles.delete(roleId) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts new file mode 100644 index 0000000000..e6bf20ad5f --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { ProjectRolesController } from './project-roles.controller' +import { ProjectRolesService } from './project-roles.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, ProjectPermissionModule], + controllers: [ProjectRolesController], + providers: [ProjectRolesService], + exports: [ProjectRolesService], +}) +export class ProjectRolesModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts new file mode 100644 index 0000000000..ef4a761800 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts @@ -0,0 +1,181 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeProject, makeProjectMembers, makeProjectRole, makeProjectRoleWithProject } from './project-roles-testing.utils' +import { ProjectRolesService } from './project-roles.service' + +describe('projectRolesService', () => { + let module: TestingModule + let service: ProjectRolesService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectRolesService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(ProjectRolesService) + }) + + it('lists roles with permissions as strings and stripped oidcGroup', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + prisma.project.findUnique.mockResolvedValue(makeProject({ id: projectId, slug })) + prisma.projectRole.findMany.mockResolvedValue([makeProjectRoleWithProject({ + name: 'test', + permissions: 4n, + position: 0, + projectId, + oidcGroup: '/slug/console/admin', + project: { slug }, + })]) + + const roles = await service.list(projectId) + + expect(roles[0].permissions).toBe('4') + expect(roles[0].oidcGroup).toBe('/console/admin') + }) + + it('creates a role and emits project update', async () => { + const projectId = faker.string.uuid() + const slug = faker.string.alphanumeric(8).toLowerCase() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + prisma.projectRole.findFirst.mockResolvedValue(makeProjectRole({ position: 0 })) + prisma.projectRole.create.mockResolvedValue(makeProjectRole()) + prisma.projectRole.findMany.mockResolvedValue([]) + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + + await service.create(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + name: 'test', + permissions: 4n, + position: 1, + projectId, + }), + })) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', expect.anything(), { action: 'Upsert Project Role' }) + }) + + it('rejects system role creation', async () => { + const projectId = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug: 'slug' })) + + await expect(service.create(projectId, { name: 'test', permissions: '4', type: 'system:managed' })) + .rejects.toThrow(BadRequestException) + }) + + it('patches roles and validates positions', async () => { + const projectId = faker.string.uuid() + const slug = faker.string.alphanumeric(8).toLowerCase() + const roleId = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + prisma.projectRole.findMany.mockResolvedValue([makeProjectRoleWithProject({ + id: roleId, + name: 'test', + permissions: 4n, + position: 0, + projectId, + oidcGroup: '/slug/console/admin', + project: { slug }, + })]) + prisma.projectRole.update.mockResolvedValue(makeProjectRole({ id: roleId })) + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + + await service.update(projectId, [{ id: roleId, name: 'updated', position: 0 }]) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: roleId }, + data: expect.objectContaining({ + name: 'updated', + position: 0, + }), + })) + }) + + it('rejects incoherent positions', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + const roleId1 = faker.string.uuid() + const roleId2 = faker.string.uuid() + prisma.projectRole.findMany.mockResolvedValue([ + makeProjectRoleWithProject({ id: roleId1, name: 'a', permissions: 1n, position: 0, projectId, oidcGroup: '', project: { slug } }), + makeProjectRoleWithProject({ id: roleId2, name: 'b', permissions: 1n, position: 1, projectId, oidcGroup: '', project: { slug } }), + ]) + + await expect(service.update(projectId, [{ id: roleId1, position: 1 }])) + .rejects.toThrow(BadRequestException) + }) + + it('counts role members', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + const roleId1 = faker.string.uuid() + const roleId2 = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + prisma.projectRole.findMany.mockResolvedValue([ + makeProjectRoleWithProject({ id: roleId1, name: 'a', permissions: 1n, position: 0, projectId, oidcGroup: '', project: { slug } }), + makeProjectRoleWithProject({ id: roleId2, name: 'b', permissions: 1n, position: 1, projectId, oidcGroup: '', project: { slug } }), + ]) + prisma.projectMembers.findMany.mockResolvedValue([ + makeProjectMembers({ roleIds: [roleId1, roleId2] }), + makeProjectMembers({ roleIds: [roleId2] }), + ]) + + await expect(service.countMembers(projectId)).resolves.toEqual({ + [roleId1]: 1, + [roleId2]: 2, + }) + }) + + it('deletes role and cleans member role ids', async () => { + const projectId = faker.string.uuid() + const roleId = faker.string.uuid() + prisma.projectRole.findUnique.mockResolvedValue(makeProjectRole({ type: 'managed', projectId })) + prisma.project.findUnique.mockResolvedValue(makeProject({ id: projectId, slug: 'slug' })) + + const tx = mockDeep() + tx.projectRole.delete.mockResolvedValue(makeProjectRole()) + tx.projectMembers.findMany.mockResolvedValue([ + makeProjectMembers({ userId: faker.string.uuid(), roleIds: [roleId] }), + makeProjectMembers({ userId: faker.string.uuid(), roleIds: [roleId, faker.string.uuid()] }), + ]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.delete(roleId) + + expect(tx.projectMembers.update).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', expect.anything(), { action: 'Upsert Project Role' }) + }) + + it('rejects system role deletion', async () => { + prisma.projectRole.findUnique.mockResolvedValue(makeProjectRole({ type: 'system:managed', projectId: faker.string.uuid() })) + + await expect(service.delete(faker.string.uuid())) + .rejects.toThrow(BadRequestException) + }) + + it('rejects missing role deletion', async () => { + prisma.projectRole.findUnique.mockResolvedValue(null) + + await expect(service.delete(faker.string.uuid())) + .rejects.toThrow(NotFoundException) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts new file mode 100644 index 0000000000..a8c537b5fd --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts @@ -0,0 +1,130 @@ +import type { Project, ProjectRole } from '@prisma/client' +import type { + CreateProjectRoleInput, + PatchProjectRolesInput, +} from './project-roles.utils' +import { isSystemRoleType } from '@cpn-console/shared' +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { getProjectBySlug, getProjectRoleForDelete, projectRoleWithProjectSelect } from './project-roles-queries.utils' +import { + buildUpdatedProjectRoles, + toProjectRoleResponse, + validatePatchedProjectRolePositions, +} from './project-roles.utils' + +@Injectable() +export class ProjectRolesService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async list(projectId: Project['id']) { + const roles = await this.prisma.projectRole.findMany({ + where: { projectId }, + orderBy: { position: 'asc' }, + select: projectRoleWithProjectSelect, + }) + return roles.map(role => toProjectRoleResponse(role)) + } + + async update(projectId: Project['id'], roles: PatchProjectRolesInput) { + const project = await getProjectBySlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + + const dbRoles = await this.prisma.projectRole.findMany({ where: { projectId }, orderBy: { position: 'asc' } }) + const { requestedPositionsCount, updatedRoles } = buildUpdatedProjectRoles(project.slug, dbRoles, roles) + validatePatchedProjectRolePositions(dbRoles.length, requestedPositionsCount) + + for (const { id, ...role } of updatedRoles) { + await this.prisma.projectRole.update({ where: { id }, data: role }) + } + + await this.emitProjectUpsert(projectId) + return this.list(projectId) + } + + async create(projectId: Project['id'], role: CreateProjectRoleInput) { + const project = await getProjectBySlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + if (isSystemRoleType(role.type)) { + throw new BadRequestException('Impossible de créer un rôle système') + } + + const dbMaxPosRole = (await this.prisma.projectRole.findFirst({ + where: { projectId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + if (role.oidcGroup && !role.oidcGroup.startsWith('/')) { + throw new BadRequestException('oidcGroup doit commencer par /') + } + + await this.prisma.projectRole.create({ + data: { + ...role, + projectId, + position: dbMaxPosRole + 1, + permissions: BigInt(role.permissions), + oidcGroup: role.oidcGroup ? `/${project.slug}${role.oidcGroup}` : undefined, + }, + }) + + await this.emitProjectUpsert(projectId) + return this.list(projectId) + } + + async countMembers(projectId: Project['id']) { + const roles = await this.list(projectId) + const members = await this.prisma.projectMembers.findMany({ where: { projectId } }) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) + + for (const { roleIds } of members) { + for (const roleId of roleIds) { + rolesCounts[roleId]++ + } + } + + return rolesCounts + } + + async delete(roleId: ProjectRole['id']) { + const role = await getProjectRoleForDelete(this.prisma, roleId) + if (!role) throw new NotFoundException('Rôle introuvable') + if (isSystemRoleType(role.type)) { + throw new BadRequestException('Ce rôle système ne peut pas être supprimé') + } + + await this.prisma.$transaction(async (tx) => { + await tx.projectRole.delete({ where: { id: roleId } }) + const attachedMembers = await tx.projectMembers.findMany({ + where: { projectId: role.projectId, roleIds: { has: roleId } }, + }) + + for (const member of attachedMembers) { + await tx.projectMembers.update({ + where: { + projectId_userId: { + projectId: role.projectId, + userId: member.userId, + }, + }, + data: { + roleIds: { + set: member.roleIds.filter(existingRoleId => existingRoleId !== roleId), + }, + }, + }) + } + }) + + await this.emitProjectUpsert(role.projectId) + } + + private async emitProjectUpsert(projectId: string): Promise { + await this.appEvents.emitProjectEvent('project.upsert', projectId, { action: 'Upsert Project Role' }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts new file mode 100644 index 0000000000..c83bb2bb54 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts @@ -0,0 +1,108 @@ +import type { ProjectRole as SharedProjectRole } from '@cpn-console/shared' +import type { ProjectRole } from '@prisma/client' +import type { ProjectRoleWithProject } from './project-roles-queries.utils' +import { isSystemRoleType } from '@cpn-console/shared' +import { BadRequestException } from '@nestjs/common' + +export interface ProjectRoleResponse { + id: string + name: string + oidcGroup?: string | null + type?: string | null + permissions: string + userCount?: number +} + +export type CreateProjectRoleInput = Omit +export type PatchProjectRoleInput = Pick & Partial> +export type PatchProjectRolesInput = PatchProjectRoleInput[] + +export function toProjectRoleResponse( + role: ProjectRoleWithProject, +): ProjectRoleResponse { + return { + id: role.id, + name: role.name, + oidcGroup: stripProjectSlugFromOidcGroup(role.oidcGroup, role.project.slug), + type: role.type ?? 'managed', + permissions: role.permissions.toString(), + userCount: role.project.members?.length ?? 0, + } +} + +export function buildUpdatedProjectRoles( + projectSlug: string, + dbRoles: ProjectRole[], + roles: PatchProjectRolesInput, +): { requestedPositionsCount: number, updatedRoles: ProjectRole[] } { + const requestedPositions = new Set() + const updatedRoles: ProjectRole[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (!matchingRole) continue + + validateProjectRolePatch(dbRole, matchingRole) + + if (matchingRole.position !== undefined) { + requestedPositions.add(matchingRole.position) + } + + updatedRoles.push(mergeProjectRolePatch(projectSlug, dbRole, matchingRole)) + } + + return { requestedPositionsCount: requestedPositions.size, updatedRoles } +} + +export function validatePatchedProjectRolePositions(dbRolesCount: number, requestedPositionsCount: number): void { + if (requestedPositionsCount && requestedPositionsCount !== dbRolesCount) { + throw new BadRequestException('Les numéros de position des rôles sont incohérentes') + } +} + +function validateProjectRolePatch(dbRole: ProjectRole, matchingRole: PatchProjectRoleInput): void { + if (isSystemRoleType(dbRole.type)) { + throw new BadRequestException('Ce rôle système ne peut pas être modifié') + } + + if (isSystemRoleType(matchingRole.type)) { + throw new BadRequestException('Impossible de modifier un rôle en rôle système') + } + + if (matchingRole.oidcGroup && !matchingRole.oidcGroup.startsWith('/')) { + throw new BadRequestException('oidcGroup doit commencer par /') + } +} + +function mergeProjectRolePatch( + projectSlug: string, + dbRole: ProjectRole, + matchingRole: PatchProjectRoleInput, +): ProjectRole { + const permissions = typeof matchingRole.permissions === 'string' + ? BigInt(matchingRole.permissions) + : dbRole.permissions + const oidcGroup = typeof matchingRole.oidcGroup === 'string' + ? `/${projectSlug}${matchingRole.oidcGroup}` + : dbRole.oidcGroup + + return { + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup, + type: matchingRole.type ?? dbRole.type, + projectId: dbRole.projectId, + } +} + +const oidcRegexp = /^\/[^/]+/ + +function stripProjectSlugFromOidcGroup(oidcGroup: string, projectSlug: string): string | null { + if (!oidcGroup.startsWith(`/${projectSlug}`)) { + return oidcGroup.replace(oidcRegexp, '') + } + + return oidcGroup.slice(projectSlug.length + 1) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts new file mode 100644 index 0000000000..f6595cbc81 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts @@ -0,0 +1,13 @@ +import type { Prisma } from '@prisma/client' + +export const projectSlugSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export type ProjectSlug = Prisma.ProjectGetPayload<{ + select: typeof projectSlugSelect +}> + +export function getProjectSlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectSlugSelect }) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts new file mode 100644 index 0000000000..89d0174b4a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts @@ -0,0 +1,21 @@ +import type { VaultMetadata, VaultSecret } from '../vault/vault-client.service' +import { faker } from '@faker-js/faker' + +export function makeVaultSecret = Record>(overrides: Partial> = {}): VaultSecret { + return { + data: {} as T, + metadata: makeVaultMetadata(), + ...overrides, + } satisfies VaultSecret +} + +function makeVaultMetadata(overrides: Partial = {}): VaultMetadata { + return { + created_time: faker.date.past().toISOString(), + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + ...overrides, + } satisfies VaultMetadata +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts new file mode 100644 index 0000000000..ae99445888 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, Inject, UseGuards } from '@nestjs/common' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ProjectSecretsService } from './project-secrets.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectSecretsController { + constructor( + @Inject(ProjectSecretsService) private readonly projectSecrets: ProjectSecretsService, + ) {} + + @Get('/:projectId/secrets') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectPermission('SeeSecrets') + async get( + @Project() project: { id: string }, + ): Promise>> { + return this.projectSecrets.get(project.id) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts new file mode 100644 index 0000000000..1ecc4f73e6 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts @@ -0,0 +1,17 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProjectSecretsModule } from './project-secrets.module' +import { ProjectSecretsService } from './project-secrets.service' + +describe('projectSecretsModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ProjectSecretsService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ProjectSecretsModule, 'USE_VAULT')], + }).compile() + expect(() => module.get(ProjectSecretsService)).toThrow() + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts new file mode 100644 index 0000000000..16a6c23b13 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { VaultModule } from '../vault/vault.module' +import { ProjectSecretsController } from './project-secrets.controller' +import { ProjectSecretsService } from './project-secrets.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, VaultModule, ConfigModule.forFeature(baseConfigFactory)], + controllers: [ProjectSecretsController], + providers: [ProjectSecretsService], + exports: [ProjectSecretsService], +}) +export class ProjectSecretsModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts new file mode 100644 index 0000000000..62586bd954 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts @@ -0,0 +1,106 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeProject } from '../project/project-testing.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { VaultService } from '../vault/vault.service' +import { makeVaultSecret } from './project-secrets-testing.utils' +import { ProjectSecretsService } from './project-secrets.service' + +describe('projectSecretsService', () => { + let module: TestingModule + let service: ProjectSecretsService + let prisma: DeepMockProxy + let vault: DeepMockProxy + let vaultClient: DeepMockProxy + let config: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + vault = mockDeep() + vaultClient = mockDeep() + config = mockDeep({ projectsRootDir: '/vault' }) + vaultConfig = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectSecretsService, + { provide: PrismaService, useValue: prisma }, + { provide: BASE_CONFIG, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: VaultService, useValue: vault }, + { provide: VaultClientService, useValue: vaultClient }, + ], + }).compile() + + service = module.get(ProjectSecretsService) + }) + + it('returns parsed secrets from vault', async () => { + const projectId = 'project-id' + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproject' })) + vault.listProjectSecrets.mockResolvedValue(['group1/secret1']) + vaultClient.read.mockResolvedValue(makeVaultSecret({ data: { key1: 'value1', key2: 42, key3: true, key4: null } })) + + const result = await service.get(projectId) + + expect(prisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: projectId }, + select: { slug: true }, + }) + expect(vault.listProjectSecrets).toHaveBeenCalledWith('myproject') + expect(result).toHaveProperty('group1') + expect(result.group1).toHaveProperty('secret1.key1', 'value1') + expect(result.group1).toHaveProperty('secret1.key2', '42') + expect(result.group1).toHaveProperty('secret1.key3', 'true') + expect(result.group1).toHaveProperty('secret1.key4', '') + }) + + it('handles nested secret paths', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue(['group1/sub/path']) + vaultClient.read.mockResolvedValue(makeVaultSecret({ data: { nested: 'value' } })) + + const result = await service.get('project-id') + + expect(result.group1).toHaveProperty('sub/path.nested', 'value') + }) + + it('returns empty object when no secrets exist', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue([]) + + const result = await service.get('project-id') + + expect(result).toEqual({}) + }) + + it('returns empty object when secret listing fails', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockRejectedValue(new Error('vault unavailable')) + + const result = await service.get('project-id') + + expect(result).toEqual({}) + }) + + it('skips secrets that fail to read', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue(['group1/s1', 'group1/s2']) + vaultClient.read + .mockRejectedValueOnce(new Error('vault error')) + .mockResolvedValueOnce(makeVaultSecret({ data: { key: 'val' } })) + + const result = await service.get('project-id') + + expect(result.group1).toEqual({ 's2.key': 'val' }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts new file mode 100644 index 0000000000..2f9525b41f --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts @@ -0,0 +1,88 @@ +import type { ConfigType } from '@nestjs/config' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { z } from 'zod' +import { baseConfigFactory } from '../../config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultService } from '../vault/vault.service' +import { generateProjectPath } from '../vault/vault.utils' +import { getProjectSlug } from './project-secrets-queries.utils' + +const SecretValueSchema = z.union([ + z.string(), + z.undefined().transform(() => ''), + z.number().transform(String), + z.bigint().transform(String), + z.boolean().transform(String), + z.null().transform(() => ''), +]).catch('') + +export function parseSecretValue(value: string): string { + return SecretValueSchema.parse(value) +} + +@Injectable() +export class ProjectSecretsService { + private readonly logger = new Logger(ProjectSecretsService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, + @Inject(VaultService) private readonly vault: VaultService, + @Inject(VaultClientService) private readonly vaultClient: VaultClientService, + ) {} + + @StartActiveSpan() + async get(projectId: string): Promise>> { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.get started (projectId=${projectId})`) + try { + const project = await getProjectSlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + span?.setAttribute('project.slug', project.slug) + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, project.slug) + + const result: Record> = {} + const relativePaths = await this.vault.listProjectSecrets(project.slug).catch((error) => { + this.logger.warn( + `project.get secret listing failed (projectId=${projectId}, slug=${project.slug}): ${error instanceof Error ? error.message : String(error)}; returning an empty result`, + ) + return [] as string[] + }) + span?.setAttribute('vault.secretFiles.count', relativePaths.length) + this.logger.debug(`project.get listed (projectId=${projectId}, slug=${project.slug}, secretFiles=${relativePaths.length})`) + + for (const relativePath of relativePaths) { + const fullPath = `${projectPath}/${relativePath}` + const secret = await this.vaultClient.read>(fullPath).catch(() => null) + if (!secret?.data) continue + + const [group, ...rest] = relativePath.split('/').filter(Boolean) + if (!group) continue + const prefix = rest.length ? `${rest.join('/')}.` : '' + const groupObj = (result[group] ??= {}) + for (const [key, value] of Object.entries(secret.data)) { + groupObj[`${prefix}${key}`] = parseSecretValue(value) + } + } + + const groupCount = Object.keys(result).length + const keyCount = Object.values(result).reduce((acc, group) => acc + Object.keys(group).length, 0) + span?.setAttributes({ + 'vault.secretGroups.count': groupCount, + 'vault.secretKeys.count': keyCount, + }) + this.logger.log(`project.get completed (projectId=${projectId}, slug=${project.slug}, groupCount=${groupCount}, keyCount=${keyCount})`) + return result + } catch (error) { + this.logger.error( + `project.get failed (projectId=${projectId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts new file mode 100644 index 0000000000..ef539dae4a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts @@ -0,0 +1,117 @@ +import type { Prisma } from '@prisma/client' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + clusters: { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + infos: true, + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + createdAt: true, + updatedAt: true, + clusterId: true, + cluster: { + select: { + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectPluginSelect = { + key: true, + pluginName: true, + projectId: true, + value: true, +} satisfies Prisma.ProjectPluginSelect + +export const adminPluginSelect = { + key: true, + pluginName: true, + value: true, +} satisfies Prisma.AdminPluginSelect + +export const publicClusterSelect = { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + infos: true, + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, +} satisfies Prisma.ClusterFindManyArgs + +export type ProjectPlugin = Prisma.ProjectPluginGetPayload<{ + select: typeof projectPluginSelect +}> + +export type AdminPlugin = Prisma.AdminPluginGetPayload<{ + select: typeof adminPluginSelect +}> + +export type PublicCluster = Prisma.ClusterGetPayload<{ + select: typeof publicClusterSelect.select +}> + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export async function getServicesQueryData(tx: Prisma.TransactionClient, projectId: string) { + return Promise.all([ + tx.project.findUnique({ + where: { id: projectId }, + select: projectSelect, + }), + tx.projectPlugin.findMany({ + where: { projectId }, + select: projectPluginSelect, + }), + tx.adminPlugin.findMany({ + select: adminPluginSelect, + }), + tx.cluster.findMany({ + where: { privacy: 'public' }, + select: publicClusterSelect.select, + }), + ]) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts new file mode 100644 index 0000000000..6a52d2b057 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts @@ -0,0 +1,231 @@ +import type { Plugin } from '@cpn-console/hooks' +import type { PluginsUpdateBody } from '@cpn-console/shared' +import type { Cluster, Environment, ProjectPlugin as PrismaProjectPlugin, Project, Zone } from '@prisma/client' +import type { AdminPlugin, ProjectPlugin, ProjectWithDetails, PublicCluster } from './project-services-queries.utils' +import { DEFAULT } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +/** Narrow zone shape the service reads from `PublicCluster.zone` / environment clusters. */ +export type PublicZone = PublicCluster['zone'] + +export function makeServicesPluginName(): string { + return faker.helpers.slugify(faker.company.name()) +} + +export function makePlugin(overrides: Partial = {}): Plugin { + const pluginName = overrides.name ?? makeServicesPluginName() + + return { + infos: { + name: pluginName, + title: faker.commerce.productName(), + description: faker.lorem.sentence(), + imgSrc: `/${faker.helpers.slugify(faker.commerce.product())}.svg`, + config: { + global: [], + project: [ + { + kind: 'switch', + key: 'enabled', + title: 'Enabled', + value: DEFAULT, + initialValue: DEFAULT, + permissions: { + user: { read: true, write: true }, + admin: { read: true, write: true }, + }, + }, + ], + }, + to: ({ project }) => `/projects/${project.slug}`, + ...overrides, + }, + subscribedHooks: {}, + } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + name: faker.company.name(), + slug: faker.helpers.slugify(faker.company.name()), + description: faker.lorem.sentence(), + clusters: [makePublicCluster()], + environments: [], + ...overrides, + } +} + +export function makeProjectPlugin(overrides: Partial = {}): ProjectPlugin { + return { + pluginName: makeServicesPluginName(), + projectId: faker.string.uuid(), + key: 'enabled', + value: DEFAULT, + ...overrides, + } +} + +export function makeServicesAdminPlugin(overrides: Partial = {}): AdminPlugin { + const pluginName = overrides.pluginName ?? makeServicesPluginName() + const adminPlugin = { + pluginName, + key: 'enabled', + value: DEFAULT, + } + return { ...adminPlugin, ...overrides, pluginName } +} + +export function makePublicCluster(overrides: Partial = {}): PublicCluster { + const resolvedZone = overrides.zone ?? makeServicesZone() + return { + id: faker.string.uuid(), + label: faker.company.name(), + privacy: 'public', + clusterResources: false, + infos: null, + zone: resolvedZone, + ...overrides, + } +} + +export function makeServicesZone(overrides: Partial = {}): PublicZone { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`zone-${faker.string.alphanumeric(8)}`), + label: faker.location.city(), + argocdUrl: `https://${faker.helpers.slugify(faker.location.city())}.example.com`, + ...overrides, + } +} + +export function makeServicesEnvironment(overrides: Partial & { cluster?: PublicCluster } = {}): ProjectWithDetails['environments'][number] { + const { cluster: clusterOverride, ...restOverrides } = overrides + const cluster = clusterOverride ?? makePublicCluster() + return { + id: faker.string.uuid(), + name: faker.word.noun(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: cluster.id, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + cluster, + ...restOverrides, + } +} + +export function makeServicesUpdateBody(pluginName: string = makeServicesPluginName()): PluginsUpdateBody { + return { + [pluginName]: { + enabled: DEFAULT, + }, + } +} + +export type ClusterWithZone = Cluster & { zone: Zone } + +export function makeCluster(overrides: Partial = {}): Cluster { + return { + id: faker.string.uuid(), + label: faker.company.name(), + privacy: 'public', + secretName: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + infos: null, + memory: faker.number.float({ min: 0, max: 1024, fractionDigits: 2 }), + cpu: faker.number.float({ min: 0, max: 64, fractionDigits: 2 }), + gpu: faker.number.float({ min: 0, max: 8, fractionDigits: 2 }), + zoneId: faker.string.uuid(), + ...overrides, + } +} + +export function makeClusterWithZone(overrides: Partial = {}): ClusterWithZone { + const resolvedZone = overrides.zone ?? { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`zone-${faker.string.alphanumeric(8)}`), + label: faker.location.city(), + description: faker.lorem.sentence(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + argocdUrl: `https://${faker.helpers.slugify(faker.location.city())}.example.com`, + } + const { zone: _zone, ...clusterOverrides } = overrides + return { + ...makeCluster(clusterOverrides), + zoneId: clusterOverrides.zoneId ?? resolvedZone.id, + zone: resolvedZone, + } +} + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + projectId: faker.string.uuid(), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export type EnvironmentWithCluster = Environment & { cluster: ClusterWithZone } + +export function makeEnvironmentWithCluster(overrides: Partial = {}): EnvironmentWithCluster { + const resolvedCluster = overrides.cluster ?? makeClusterWithZone() + const { cluster: _cluster, ...envOverrides } = overrides + return { + ...makeEnvironment(envOverrides), + clusterId: envOverrides.clusterId ?? resolvedCluster.id, + cluster: resolvedCluster, + } +} + +export type ProjectWithRelations = Project & { + plugins: PrismaProjectPlugin[] + clusters: ClusterWithZone[] + environments: EnvironmentWithCluster[] +} + +export function makeProject(overrides: Partial = {}): ProjectWithRelations { + return { + id: faker.string.uuid(), + name: faker.company.name(), + description: faker.lorem.sentence(), + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.helpers.slugify(faker.company.name()), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + plugins: [], + clusters: [], + environments: [], + ...overrides, + } +} + +export function makeProjectWithRelations(overrides: Partial = {}): ProjectWithRelations { + return makeProject(overrides) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.controller.ts new file mode 100644 index 0000000000..00ff45d9f2 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.controller.ts @@ -0,0 +1,49 @@ +import type { PermissionTarget, PluginsUpdateBody } from '@cpn-console/shared' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { AdminAuthorized, projectServiceContract } from '@cpn-console/shared' +import { Body, Controller, ForbiddenException, Get, HttpCode, HttpStatus, Inject, Post, Query, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectServicesService } from './project-services.service' + +@Controller('api/v1/projects/:projectId/services') +@UseGuards(ProjectGuard) +export class ProjectServicesController { + constructor( + @Inject(ProjectServicesService) private readonly projectServices: ProjectServicesService, + ) {} + + @Get() + @RequireProjectPermission('ListEnvironments') + async get( + @Query(new ZodValidationPipe(projectServiceContract.getServices.query)) query: { permissionTarget: PermissionTarget }, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + ) { + if (query.permissionTarget === 'admin' && !AdminAuthorized.Manage(user.adminPermissions ?? 0n)) { + throw new ForbiddenException('Vous ne pouvez pas demander les paramètres admin') + } + + return this.projectServices.get(project.id, query.permissionTarget) + } + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + async update( + @Body(new ZodValidationPipe(projectServiceContract.updateProjectServices.body)) body: PluginsUpdateBody, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + ): Promise { + const allowedRoles: Array<'user' | 'admin'> = AdminAuthorized.Manage(user.adminPermissions ?? 0n) ? ['user', 'admin'] : ['user'] + await this.projectServices.update(project.id, body, allowedRoles) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts new file mode 100644 index 0000000000..375e371303 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts @@ -0,0 +1,17 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProjectServicesModule } from './project-services.module' +import { ProjectServicesService } from './project-services.service' + +describe('projectServicesModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ProjectServicesService when USE_PLUGINS=false', async () => { + vi.stubEnv('USE_PLUGINS', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ProjectServicesModule, 'USE_PLUGINS')], + }).compile() + expect(() => module.get(ProjectServicesService)).toThrow() + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.module.ts new file mode 100644 index 0000000000..599b8cae0a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { PluginModule } from '../plugin/plugin.module' +import { ProjectServicesController } from './project-services.controller' +import { ProjectServicesService } from './project-services.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, ConditionalModule.registerWhen(PluginModule, 'USE_PLUGINS')], + controllers: [ProjectServicesController], + providers: [ProjectServicesService], + exports: [ProjectServicesService], +}) +export class ProjectServicesModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts new file mode 100644 index 0000000000..f0702f9a97 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts @@ -0,0 +1,175 @@ +import type { Plugin, ServiceInfos } from '@cpn-console/hooks' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ClusterWithZone, ProjectWithRelations } from './project-services-testing.utils' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PluginService } from '../plugin/plugin.service' +import { + makeClusterWithZone, + makeEnvironmentWithCluster, + makePlugin, + makeProjectPlugin, + makeProjectWithRelations, + makeServicesUpdateBody, +} from './project-services-testing.utils' +import { ProjectServicesService } from './project-services.service' + +function makeEmptyServiceInfo(name: string): ServiceInfos { + return { + name, + title: name, + config: { + global: [], + project: [], + }, + } +} + +describe('servicesService', () => { + let module: TestingModule + let service: ProjectServicesService + let prisma: DeepMockProxy + let projectId: string + let pluginName: string + let projectSlug: string + let serviceTitle: string + let serviceDescription: string + let serviceImgSrc: string + let project: ProjectWithRelations + let cluster: ClusterWithZone + let plugin: Plugin + + beforeEach(async () => { + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(faker.company.name()) + serviceTitle = faker.commerce.productName() + serviceDescription = faker.lorem.sentence() + serviceImgSrc = `/${faker.helpers.slugify(faker.commerce.product())}.svg` + + plugin = makePlugin({ + title: serviceTitle, + description: serviceDescription, + imgSrc: serviceImgSrc, + to: ({ project }) => `/projects/${project.slug}`, + }) + pluginName = plugin.infos.name + project = makeProjectWithRelations({ id: projectId, slug: projectSlug }) + cluster = makeClusterWithZone() + + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectServicesService, + { provide: PrismaService, useValue: prisma }, + { provide: PluginService, useValue: { infos: async () => [plugin.infos, makeEmptyServiceInfo('gitlab'), makeEmptyServiceInfo('harbor'), makeEmptyServiceInfo('keycloak'), makeEmptyServiceInfo('nexus'), makeEmptyServiceInfo('sonarqube'), makeEmptyServiceInfo('vault')] } }, + ], + }).compile() + + service = module.get(ProjectServicesService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('get returns the service with actual hooks data', async () => { + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectPlugin.findMany.mockResolvedValue([ + makeProjectPlugin({ projectId, pluginName, value: ENABLED }), + ]) + prisma.adminPlugin.findMany.mockResolvedValue([]) + prisma.cluster.findMany.mockResolvedValue([cluster]) + + const result = await service.get(projectId, 'user') + const e2eService = result.find(entry => entry.name === pluginName) + + expect(e2eService).toMatchObject({ + name: pluginName, + title: serviceTitle, + imgSrc: serviceImgSrc, + urls: [{ to: `/projects/${projectSlug}`, name: '' }], + }) + expect(e2eService?.manifest.project?.[0]).toMatchObject({ + key: 'enabled', + value: ENABLED, + }) + }) + + it('get includes zones coming from project environments', async () => { + const baseZone = makeClusterWithZone().zone + const environmentZone = makeClusterWithZone({ + zone: { + ...baseZone, + argocdUrl: 'https://argocd.example.com', + }, + }) + const projectWithEnvironmentZones = makeProjectWithRelations({ + clusters: [], + environments: [ + makeEnvironmentWithCluster({ + cluster: environmentZone, + }), + ], + }) + const envZonePlugin = makePlugin({ + to: ({ zones }) => zones.map(zone => ({ + to: `${zone.argocdUrl}/applications?search=${zone.slug}`, + title: zone.label, + })), + }) + + prisma.project.findUnique.mockResolvedValue(projectWithEnvironmentZones) + prisma.projectPlugin.findMany.mockResolvedValue([]) + prisma.adminPlugin.findMany.mockResolvedValue([]) + prisma.cluster.findMany.mockResolvedValue([]) + + const envModule = await Test.createTestingModule({ + providers: [ + ProjectServicesService, + { provide: PrismaService, useValue: prisma }, + { provide: PluginService, useValue: { infos: async () => [envZonePlugin.infos] } }, + ], + }).compile() + const envService = envModule.get(ProjectServicesService) + + const result = await envService.get(projectId, 'user') + + expect(result.find(entry => entry.name === envZonePlugin.infos.name)).toMatchObject({ + urls: [{ to: `${environmentZone.zone.argocdUrl}/applications?search=${environmentZone.zone.slug}`, name: environmentZone.zone.label }], + }) + await envModule.close() + }) + + it('update stores project configuration through the real utility', async () => { + const data = makeServicesUpdateBody(pluginName) + + await service.update(projectId, data, ['user']) + + expect(prisma.projectPlugin.upsert).toHaveBeenCalledWith({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key: 'enabled', + }, + }, + create: { + projectId, + pluginName, + key: 'enabled', + value: data[pluginName].enabled, + }, + update: { + pluginName, + key: 'enabled', + value: data[pluginName].enabled, + }, + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.service.ts new file mode 100644 index 0000000000..572e947767 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.service.ts @@ -0,0 +1,105 @@ +import type { ZoneObject } from '@cpn-console/hooks' +import type { PermissionTarget, PluginsUpdateBody, ProjectService } from '@cpn-console/shared' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PluginService } from '../plugin/plugin.service' +import { getServicesQueryData } from './project-services-queries.utils' +import { + buildProjectEditStrippers, + generatePluginsUpdateBody, + normalizeServiceUrls, + parsePluginsUpdateBody, + populateServiceManifest, + saveProjectStore, +} from './project-services.utils' + +@Injectable() +export class ProjectServicesService { + private readonly logger = new Logger(ProjectServicesService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(PluginService) private readonly plugin: PluginService, + ) {} + + async get(projectId: string, permissionTarget: PermissionTarget): Promise { + const [project, projectStore, globalConfig, publicClusters] = await getServicesQueryData(this.prisma, projectId) + + if (!project) { + throw new NotFoundException('Projet introuvable') + } + + const store = generatePluginsUpdateBody([...projectStore, ...globalConfig]) + const clusters = [...project.clusters, ...publicClusters] + const zones = new Map() + for (const cluster of clusters) { + zones.set(cluster.zone.id, cluster.zone) + } + for (const environment of project.environments) { + const zone = environment.cluster?.zone + if (zone) zones.set(zone.id, zone) + } + const serviceInfos = await this.plugin.infos(projectId) + + const services = await Promise.all(serviceInfos.map(async (serviceInfo) => { + const { name, title, to, imgSrc, description } = serviceInfo + let toResponse: unknown = [] + if (to) { + try { + toResponse = to({ + clusters: clusters.map(c => ({ + id: c.id, + label: c.label, + privacy: c.privacy, + clusterResources: c.clusterResources, + infos: c.infos, + zone: c.zone, + })), + zones: [...zones.values()], + environments: project.environments, + project: { + id: project.id, + name: project.name, + slug: project.slug, + }, + store, + }) + } catch (error) { + this.logger.debug({ err: error, service: name }, 'Service URL generation failed, returning no URLs') + } + } + + const urls = normalizeServiceUrls(toResponse) + const manifest = populateServiceManifest({ + service: serviceInfo, + data: { + project: projectStore, + global: globalConfig, + }, + permissionTarget, + select: { + global: true, + project: true, + }, + }) + + return { imgSrc, title, name, urls, manifest, description } + })) + + return services.filter(service => service.urls.length || service.manifest.global?.length || service.manifest.project?.length) + } + + async update(projectId: string, data: PluginsUpdateBody, stripperRoles: Array<'user' | 'admin'>) { + this.logger.log(`Update project services started (projectId=${projectId}, pluginCount=${Object.keys(data).length}, stripperRoles=${stripperRoles.join(',')})`) + const editStrippers = buildProjectEditStrippers(await this.plugin.infos(projectId)) + + for (const role of stripperRoles) { + const parsedData = editStrippers.project[role].safeParse(data) + if (!parsedData.success) continue + + await saveProjectStore(parsePluginsUpdateBody(parsedData.data), projectId, this.prisma) + } + + this.logger.log(`Update project services completed (projectId=${projectId})`) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.utils.ts new file mode 100644 index 0000000000..c5961851e1 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project-services/project-services.utils.ts @@ -0,0 +1,125 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { PermissionTarget, PluginConfig, PluginsUpdateBody, ServiceUrl } from '@cpn-console/shared' +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { editStrippersGenerator } from '@cpn-console/hooks' +import { atomicValidators, DEFAULT } from '@cpn-console/shared' +import { z } from 'zod' + +export interface PluginRecord { + pluginName: string + key: string + value: string +} + +export function generatePluginsUpdateBody(records: PluginRecord[]): PluginsUpdateBody { + const obj: PluginsUpdateBody = {} + for (const record of records) { + if (!obj[record.pluginName]) obj[record.pluginName] = {} + obj[record.pluginName][record.key] = record.value + } + return obj +} + +export function parsePluginsUpdateBody(obj: PluginsUpdateBody): PluginRecord[] { + return Object.entries(obj) + .flatMap(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) +} + +export function buildProjectEditStrippers(services: ServiceInfos[]) { + const project = { + user: z.object({}), + admin: z.object({}), + } + let global = z.object({}) + + for (const service of services) { + if (!service.config) continue + const editZod = editStrippersGenerator.parse(service.config) + global = global.merge(z.object({ [service.name]: editZod.global.default({}) })) + project.admin = project.admin.merge(z.object({ [service.name]: editZod.project.admin.default({}) })) + project.user = project.user.merge(z.object({ [service.name]: editZod.project.user.default({}) })) + } + + return { project, global } +} + +interface ServiceManifestParams { + service: ServiceInfos + data: { project?: PluginRecord[], global?: PluginRecord[] } + permissionTarget: PermissionTarget + select: Partial> +} + +export function populateServiceManifest({ service, data, select, permissionTarget }: ServiceManifestParams): Partial { + const manifest = structuredClone(service.config) + + const selected: Partial = {} + for (const [scope] of Object.entries(select).filter(([_scope, bool]) => bool)) { + if (!manifest?.[scope]) continue + selected[scope] = manifest[scope].filter(item => item.permissions[permissionTarget].read || item.permissions[permissionTarget].write).map((item) => { + const row = data[scope]?.find(candidate => candidate.pluginName === service.name && candidate.key === item.key) + if (item.kind === 'switch') { + const value = atomicValidators.switch.safeParse(row?.value) + item.value = value.success ? value.data : DEFAULT + } else { + item.value = z.coerce.string().parse(row?.value ?? item.value ?? '') + } + return item + }) + } + return selected +} + +export function normalizeServiceUrls(toResponse: unknown): ServiceUrl[] { + if (Array.isArray(toResponse)) { + return toResponse.map(res => ({ name: res.title ?? '', description: res.description ?? '', to: res.to })) + } + + if (typeof toResponse === 'string') { + return [{ to: toResponse, name: '' }] + } + + if (toResponse) { + return [{ name: (toResponse as { title?: string }).title ?? '', to: (toResponse as { to: string }).to }] + } + + return [] +} + +export async function saveProjectStore(records: PluginRecord[], projectId: string, prisma: PrismaService) { + for (const { pluginName, key, value } of records) { + if (value === null) { + await prisma.projectPlugin.delete({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } else { + await prisma.projectPlugin.upsert({ + create: { + pluginName, + projectId, + key, + value: value.toString(), + }, + update: { + key, + value: value.toString(), + pluginName, + }, + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project-queries.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project-queries.utils.ts new file mode 100644 index 0000000000..8469ab9cee --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project-queries.utils.ts @@ -0,0 +1,311 @@ +import type { Prisma } from '@prisma/client' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + status: true, + locked: true, + limitless: true, + hprodCpu: true, + hprodGpu: true, + hprodMemory: true, + prodCpu: true, + prodGpu: true, + prodMemory: true, + everyonePerms: true, + ownerId: true, + createdAt: true, + updatedAt: true, + lastSuccessProvisionningVersion: true, + owner: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + type: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + }, + }, + members: { + select: { + roleIds: true, + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + type: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + }, + }, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + roles: { + select: { + id: true, + name: true, + permissions: true, + position: true, + oidcGroup: true, + type: true, + projectId: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + isPrivate: true, + externalRepoUrl: true, + externalUserName: true, + helmValuesFiles: true, + deployRevision: true, + deployPath: true, + createdAt: true, + updatedAt: true, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + clusterId: true, + stageId: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + deployments: { + select: { + id: true, + name: true, + autosync: true, + createdAt: true, + updatedAt: true, + environment: { + select: { + id: true, + name: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + deploymentSources: { + select: { + id: true, + type: true, + path: true, + targetRevision: true, + helmValuesFiles: true, + repository: { + select: { + id: true, + internalRepoName: true, + }, + }, + }, + }, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectForUpdateSelect = { + id: true, + ownerId: true, + status: true, + locked: true, + members: { + select: { + userId: true, + user: { + select: { + type: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectForDataSelect = { + name: true, + description: true, + createdAt: true, + updatedAt: true, + environments: { + select: { + name: true, + stage: true, + cluster: { + select: { label: true }, + }, + }, + }, + owner: true, +} satisfies Prisma.ProjectSelect + +export const projectIdSelect = { + id: true, +} satisfies Prisma.ProjectSelect + +export const projectSlugSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export const projectContextSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export const projectForUpsertSelect = { + id: true, + slug: true, + status: true, + locked: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export type ProjectUpdateContext = Prisma.ProjectGetPayload<{ + select: typeof projectForUpdateSelect +}> + +export type ProjectDataExport = Prisma.ProjectGetPayload<{ + select: typeof projectForDataSelect +}> + +export type ProjectContext = Prisma.ProjectGetPayload<{ + select: typeof projectContextSelect +}> + +export type ProjectForUpsert = Prisma.ProjectGetPayload<{ + select: typeof projectForUpsertSelect +}> + +export function getProject(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectSelect }) +} + +export function getProjectNotArchived(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findFirst({ where: { id: projectId, status: { not: 'archived' } }, select: projectSelect }) +} + +export function listProjects(tx: Prisma.TransactionClient, whereAnd: Prisma.ProjectWhereInput[]) { + return tx.project.findMany({ + where: { AND: whereAnd }, + select: projectSelect, + }) +} + +export function listProjectSlugsForPrefix(tx: Prisma.TransactionClient, prefix: string) { + return tx.project.findMany({ + where: { slug: { startsWith: prefix } }, + select: { slug: true }, + }) +} + +export function getProjectSlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: { slug: true } }) +} + +export function getProjectContext(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectContextSelect }) +} + +export function listProjectsForDataExport(tx: Prisma.TransactionClient) { + return tx.project.findMany({ + select: projectForDataSelect, + }) +} + +export function createProject(tx: Prisma.TransactionClient, data: Prisma.ProjectCreateInput) { + return tx.project.create({ + data, + select: projectIdSelect, + }) +} + +export function getNotArchivedProjectForUpdate(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findFirst({ + where: { id: projectId, status: { not: 'archived' } }, + select: projectForUpdateSelect, + }) +} + +export function getProjectForUpsert(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectForUpsertSelect }) +} + +export function updateProject(tx: Prisma.TransactionClient, projectId: string, data: Prisma.ProjectUpdateInput) { + return tx.project.update({ where: { id: projectId }, data }) +} + +export function deleteProjectDependencies(tx: Prisma.TransactionClient, projectId: string) { + return Promise.all([ + tx.repository.deleteMany({ where: { projectId } }), + tx.environment.deleteMany({ where: { projectId } }), + tx.deployment.deleteMany({ where: { projectId } }), + ]) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project-testing.utils.ts new file mode 100644 index 0000000000..4205b88470 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project-testing.utils.ts @@ -0,0 +1,161 @@ +import type { projectContract } from '@cpn-console/shared' +import type { Prisma, ProjectMembers, User } from '@prisma/client' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { projectSelect, ProjectWithDetails } from './project-queries.utils' +import { PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +export function makeUser(overrides: Partial = {}): User { + return { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastLogin: null, + ...overrides, + } +} + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } +} + +type ProjectMembersWithUser = ProjectMembers & { user: User } + +export function makeProjectMemberWithUser(user: User, overrides: Partial = {}): ProjectMembersWithUser { + return { + ...makeProjectMembers({ userId: user.id, ...overrides }), + user, + } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + const owner = overrides.owner ?? makeUser() + const id = overrides.id ?? faker.string.uuid() + return { + id, + name: faker.string.alphanumeric(8).toLowerCase(), + slug: faker.string.alphanumeric(8).toLowerCase(), + description: faker.lorem.sentence(), + status: 'created', + locked: false, + limitless: false, + hprodCpu: 1, + hprodGpu: 0, + hprodMemory: 2, + prodCpu: 1, + prodGpu: 0, + prodMemory: 2, + everyonePerms: PROJECT_PERMS.GUEST, + ownerId: owner.id, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastSuccessProvisionningVersion: null, + owner, + members: [], + plugins: [], + roles: [], + repositories: [], + environments: [], + deployments: [], + clusters: [], + ...overrides, + } +} + +export function makeProjectContext(overrides: Partial = {}): ProjectContext { + return { + id: faker.string.uuid(), + slug: faker.string.alphanumeric(8).toLowerCase(), + locked: false, + status: 'created', + ...overrides, + } +} + +type Project = Prisma.ProjectGetPayload<{ select: typeof projectSelect }> + +export function makeProject(overrides: Partial = {}): Project { + const id = overrides.id ?? faker.string.uuid() + return { + id, + name: faker.string.alphanumeric(8).toLowerCase(), + slug: faker.string.alphanumeric(8).toLowerCase(), + description: '', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 896n, + ownerId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastSuccessProvisionningVersion: null, + owner: { + id: faker.string.uuid(), + email: 'owner@example.com', + firstName: 'Owner', + lastName: 'Test', + adminRoleIds: [], + type: 'human', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastLogin: faker.date.past(), + }, + members: [], + plugins: [], + roles: [], + repositories: [], + environments: [], + deployments: [], + clusters: [], + ...overrides, + } +} + +export type ProjectWithMembers = Prisma.ProjectGetPayload<{ + include: { members: { include: { user: true } } } +}> + +export function makeProjectWithMembersResult( + project: ProjectWithDetails, + members: Array> = [], +): ProjectWithMembers { + return { ...project, members } as ProjectWithMembers +} + +export function makeCreateProjectBody(overrides: Partial = {}): typeof projectContract.createProject.body._type { + return { + name: faker.string.alphanumeric({ length: faker.number.int({ min: 2, max: 20 }) }).toLowerCase(), + description: faker.lorem.sentence(), + limitless: true, + hprodCpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + hprodGpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + hprodMemory: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodCpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodGpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodMemory: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + ...overrides, + } +} + +export function makeListProjectsQuery(overrides: Partial = {}): typeof projectContract.listProjects.query._type { + return { + filter: 'member', + ...overrides, + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.controller.ts new file mode 100644 index 0000000000..a10afe449f --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.controller.ts @@ -0,0 +1,91 @@ +import type { ProjectV2 } from '@cpn-console/shared' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { AdminAuthorized, projectContract } from '@cpn-console/shared' +import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, Inject, Post, Put, Query, Req, UseGuards } from '@nestjs/common' +import { json2csv } from 'json-2-csv' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectAccess } from '../infrastructure/permission/project/project-access.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectService } from './project.service' +import { generateProjectV2 } from './project.utils' + +@Controller('api/v1/projects') +export class ProjectController { + constructor( + @Inject(ProjectService) private readonly project: ProjectService, + ) {} + + @Get('/data') + @UseGuards(UserGuard) + @RequireAdminPermission('Manage') + async getProjectsData(): Promise { + return json2csv(await this.project.getProjectsData()) + } + + @Get('') + @UseGuards(UserGuard) + async list( + @Query(new ZodValidationPipe(projectContract.listProjects.query)) query: typeof projectContract.listProjects.query._type, + @AuthUser() user: UserContext, + ): Promise { + if (query.filter === 'all' && !AdminAuthorized.Manage(user.adminPermissions)) { + throw new ForbiddenException('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + return (await this.project.listProjects(query, user)).map(generateProjectV2) + } + + @Post('') + @HttpCode(HttpStatus.CREATED) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageProjects') + async create( + @Body(new ZodValidationPipe(projectContract.createProject.body)) body: typeof projectContract.createProject.body._type, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return generateProjectV2(await this.project.create(body, user.userId, request.id)) + } + + @Get('/:projectId') + @UseGuards(ProjectGuard) + @RequireProjectAccess() + async get( + @Project() project: ProjectContext, + ): Promise { + return generateProjectV2(await this.project.get(project.id)) + } + + @Put('/:projectId') + @HttpCode(HttpStatus.OK) + @UseGuards(ProjectGuard) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectPermission('Manage') + async update( + @Body(new ZodValidationPipe(projectContract.updateProject.body)) data: typeof projectContract.updateProject.body._type, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return generateProjectV2(await this.project.update(data, user, project.id, request.id)) + } + + @Delete('/:projectId') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(ProjectGuard) + @RequireProjectPermission('Manage') + async archive( + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.project.archive(project.id, user.userId, request.id) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.module.ts new file mode 100644 index 0000000000..4e50667c67 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { LogModule } from '../log/log.module' +import { VaultModule } from '../vault/vault.module' +import { ProjectController } from './project.controller' +import { ProjectService } from './project.service' + +@Module({ + imports: [ + AppEventsModule, + AuthModule, + DatabaseModule, + EventsModule, + ProjectPermissionModule, + UserPermissionModule, + LogModule, + VaultModule, + ConfigModule.forFeature(baseConfigFactory), + ], + controllers: [ProjectController], + providers: [ProjectService], + exports: [ProjectService], +}) +export class ProjectModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.service.spec.ts new file mode 100644 index 0000000000..76de94e2fa --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -0,0 +1,521 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import { faker } from '@faker-js/faker' +import { + ForbiddenException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { + makeCreateProjectBody, + makeListProjectsQuery, + makeProject, + makeProjectContext, + makeProjectMemberWithUser, + makeProjectWithDetails, + makeProjectWithMembersResult, + makeUser, +} from './project-testing.utils' +import { ProjectService } from './project.service' +import { generateSlug } from './project.utils' + +describe('projectService', () => { + let module: TestingModule + let service: ProjectService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + let config: DeepMockProxy + let vaultConfig: DeepMockProxy + let logs: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + config = mockDeep({ appVersion: 'dev' }) + vaultConfig = mockDeep() + logs = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + { provide: BASE_CONFIG, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: LogService, useValue: logs }, + ], + }).compile() + + service = module.get(ProjectService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('create', () => { + it('generates slug, creates project, emits event, returns ProjectV2', async () => { + const userId = faker.string.uuid() + const body = makeCreateProjectBody() + const existingSlugs = [body.name, `${body.name}-1`] + const expectedSlug = generateSlug(body.name, existingSlugs) + const tx = mockDeep() + + tx.project.findMany.mockResolvedValue(existingSlugs.map(slug => makeProject({ slug }))) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const pwd = makeProjectWithDetails({ slug: expectedSlug }) + pwd.roles = Array.from({ length: 4 }, (_, index) => ({ + id: faker.string.uuid(), + name: `role-${index}`, + permissions: 0n, + position: index, + oidcGroup: '', + type: 'managed', + projectId: pwd.id, + })) as never + tx.project.create.mockResolvedValue(makeProject({ id: pwd.id })) + tx.project.findUnique.mockResolvedValue(pwd) + + const requestId = faker.string.uuid() + const result = await service.create(body, userId, requestId) + + expect(tx.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { slug: { startsWith: body.name } } }), + ) + expect(tx.project.create).toHaveBeenCalled() + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', pwd.id, { + action: 'Create Project', + userId, + requestId, + }) + expect(logs.addLog).toHaveBeenCalledTimes(4) + expect(result).toBeDefined() + expect(result.slug).toBe(expectedSlug) + }) + + it('throws InternalServerErrorException when project cannot be loaded after creation', async () => { + const userId = faker.string.uuid() + const body = makeCreateProjectBody() + + const tx = mockDeep() + tx.project.findMany.mockResolvedValue([]) + tx.project.create.mockResolvedValue(makeProject({ id: 'test-id' })) + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.create(body, userId, faker.string.uuid())) + .rejects.toThrow(InternalServerErrorException) + }) + }) + + describe('list', () => { + it('returns projects filtered by member for non-admin', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + const projects = [makeProjectWithDetails(), makeProjectWithDetails()] + prisma.project.findMany.mockResolvedValue(projects) + + const result = await service.listProjects( + makeListProjectsQuery(), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalled() + expect(result).toHaveLength(2) + expect(result[0]).toHaveProperty('id') + expect(result[0]).toHaveProperty('slug') + }) + + it('allows admin-only "all" filter for admin users', async () => { + const userId = faker.string.uuid() + const adminPerms = BigInt(2) + const user = { userId, adminPermissions: adminPerms } satisfies UserContext + prisma.project.findMany.mockResolvedValue([makeProjectWithDetails()]) + + const result = await service.listProjects( + makeListProjectsQuery({ filter: 'all' }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalled() + expect(result).toHaveLength(1) + }) + + it('forbids "all" filter for non-admin users', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + + await expect( + service.listProjects(makeListProjectsQuery({ filter: 'all' }), user), + ).rejects.toThrow(ForbiddenException) + }) + + it('filters by status', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + prisma.project.findMany.mockResolvedValue([]) + + await service.listProjects( + makeListProjectsQuery({ status: 'created' }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ status: 'created' }), + ]), + }), + }), + ) + }) + + it('filters by search term', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + const search = 'myproject' + prisma.project.findMany.mockResolvedValue([]) + + await service.listProjects( + makeListProjectsQuery({ search }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ + OR: [ + { name: { contains: search } }, + { owner: { email: { contains: search } } }, + ], + }), + ]), + }), + }), + ) + }) + }) + + describe('get', () => { + it('returns ProjectV2 for non-archived project', async () => { + const ctx = makeProjectContext({ status: 'created' }) + const pwd = makeProjectWithDetails({ id: ctx.id, status: 'created' }) + prisma.project.findFirst.mockResolvedValue(pwd) + + const result = await service.get(ctx.id) + + expect(result).toBeDefined() + expect(result.id).toBe(ctx.id) + expect(result.status).toBe('created') + }) + + it('throws NotFoundException for archived project', async () => { + const ctx = makeProjectContext({ status: 'archived' }) + prisma.project.findFirst.mockResolvedValue(null) + + await expect(service.get(ctx.id)).rejects.toThrow(NotFoundException) + }) + + it('throws NotFoundException when project cannot be loaded', async () => { + const ctx = makeProjectContext({ status: 'created' }) + prisma.project.findFirst.mockResolvedValue(null) + + await expect(service.get(ctx.id)).rejects.toThrow(NotFoundException) + }) + }) + + describe('update', () => { + it('updates description and returns updated project', async () => { + const ctx = makeProjectContext({ status: 'created', locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id }) + const updatedPwd = makeProjectWithDetails({ id: ctx.id, description: 'Updated desc' }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(updatedPwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const requestId = faker.string.uuid() + const result = await service.update( + { description: 'Updated desc' }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + requestId, + ) + + expect(result.description).toBe('Updated desc') + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', ctx.id, { + action: 'Update Project', + userId: requestorId, + requestId, + }) + }) + + it('strips locked field for non-admin', async () => { + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: true }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: {}, + }), + ) + }) + + it('allows admin to update locked field', async () => { + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const adminPerms = BigInt(2) + const pwd = makeProjectWithDetails({ id: ctx.id }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: true }, + { userId: requestorId, adminPermissions: adminPerms }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: expect.objectContaining({ locked: true }), + }), + ) + }) + + it('strips ownerId for non-owner non-admin', async () => { + const ownerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { ownerId: faker.string.uuid() }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: {}, + }), + ) + }) + + it('throws ForbiddenException when project is locked and not unlocking', async () => { + const ctx = makeProjectContext({ locked: true }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id, locked: true }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { description: 'test' }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow(ForbiddenException) + }) + + it('allows admin to unlock a locked project', async () => { + const ctx = makeProjectContext({ locked: true }) + const requestorId = faker.string.uuid() + const adminPerms = BigInt(2) + const pwd = makeProjectWithDetails({ id: ctx.id, locked: true }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: false }, + { userId: requestorId, adminPermissions: adminPerms }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalled() + }) + + it('validates new owner is a member of the project', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow('Le nouveau propriétaire doit faire partie des membres actuels du projet') + }) + + it('validates new owner is a human account', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue( + makeProjectWithMembersResult(pwd, [makeProjectMemberWithUser(makeUser({ id: newOwnerId, type: 'bot' }))]), + ) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow('Seuls les comptes humains peuvent être propriétaire de projets') + }) + + it('transfers ownership correctly', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue( + makeProjectWithMembersResult(pwd, [makeProjectMemberWithUser( + makeUser({ id: newOwnerId, type: 'human' }), + { roleIds: [faker.string.uuid()] }, + )]), + ) + tx.project.findUnique.mockResolvedValue( + makeProjectWithDetails({ id: ctx.id, ownerId: newOwnerId }), + ) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.projectMembers.delete).toHaveBeenCalledWith({ + where: { projectId_userId: { userId: newOwnerId, projectId: ctx.id } }, + }) + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + owner: { connect: { id: newOwnerId } }, + }, + }), + ) + expect(result).toBeDefined() + }) + }) + + describe('archive', () => { + it('deletes related data, emits event, renames and archives project', async () => { + const projectId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: projectId, name: 'myproject', slug: 'myproject' }) + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(pwd) + tx.repository.deleteMany.mockResolvedValue({ count: 2 }) + tx.environment.deleteMany.mockResolvedValue({ count: 3 }) + tx.deployment.deleteMany.mockResolvedValue({ count: 1 }) + tx.project.update.mockResolvedValue(makeProject({ id: projectId })) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const requestId = faker.string.uuid() + const requestorId = faker.string.uuid() + + await service.archive(projectId, requestorId, requestId) + + expect(tx.repository.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.environment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.deployment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.delete', pwd, { + action: 'Delete all project resources', + userId: requestorId, + requestId, + }) + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: projectId }, + data: expect.objectContaining({ + status: 'archived', + locked: true, + clusters: { set: [] }, + }), + }), + ) + }) + + it('throws NotFoundException when project does not exist', async () => { + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.archive(faker.string.uuid())) + .rejects.toThrow(NotFoundException) + }) + }) + + describe('getData', () => { + it('returns CSV data array', async () => { + prisma.project.findMany.mockResolvedValue([makeProjectWithDetails()]) + + const result = await service.getProjectsData() + + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(1) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.service.ts new file mode 100644 index 0000000000..cbb6a8cf1c --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.service.ts @@ -0,0 +1,333 @@ +import type { projectContract } from '@cpn-console/shared' +import type { ConfigType } from '@nestjs/config' +import type { Prisma } from '@prisma/client' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectDataExport, ProjectUpdateContext, ProjectWithDetails } from './project-queries.utils' +import { AdminAuthorized } from '@cpn-console/shared' +import { BadRequestException, ForbiddenException, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { baseConfigFactory } from '../../config/base.config' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { LogService } from '../log/log.service' +import { createProjectMember, deleteProjectMember } from '../project-members/project-members-queries.utils' +import { + createProject, + deleteProjectDependencies, + getNotArchivedProjectForUpdate, + getProject, + listProjectSlugsForPrefix as listProjectSlugsForSlugPrefix, + projectForDataSelect, + projectSelect, + updateProject, +} from './project-queries.utils' +import { generateProjectCreateInput, generateProjectWhereInput, generateSlug, parseProjectUpdateInput } from './project.utils' + +@Injectable() +export class ProjectService { + private readonly logger = new Logger(ProjectService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, + @Inject(LogService) private readonly logs: LogService, + ) {} + + @StartActiveSpan() + async getProjectsData(): Promise { + const span = trace.getActiveSpan() + this.logger.log('project.getData requested') + const data = await this.listProjectsForDataExport() + span?.setAttribute('project.data.count', data.length) + this.logger.log(`project.getData completed (count=${data.length})`) + return data + } + + @StartActiveSpan() + async listProjects( + query: typeof projectContract.listProjects.query._type, + user: UserContext, + ): Promise { + const span = trace.getActiveSpan() + const { filter = 'member' } = query + span?.setAttribute('project.list.filter', filter) + span?.setAttribute('user.id', user.userId) + + if (filter === 'all' && !AdminAuthorized.Manage(user.adminPermissions)) { + this.logger.warn(`project.list forbidden (requestorUserId=${user.userId}, filter=${filter})`) + throw new ForbiddenException('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + + const whereAnd = generateProjectWhereInput({ + query, + requestorUserId: user.userId, + appVersion: this.baseConfig.appVersion, + }) + + this.logger.debug(`project.list started (requestorUserId=${user.userId}, filter=${filter})`) + const projects = await this.fetchProjects(whereAnd) + span?.setAttribute('project.list.count', projects.length) + this.logger.debug(`project.list completed (requestorUserId=${user.userId}, filter=${filter}, count=${projects.length})`) + + return projects + } + + @StartActiveSpan() + async get(projectId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.debug(`project.get started (projectId=${projectId})`) + const project = await this.getProjectNotArchived(projectId) + if (!project) { + this.logger.warn(`project.get notFound (projectId=${projectId})`) + throw new NotFoundException('Projet introuvable') + } + this.logger.debug(`project.get completed (projectId=${projectId})`) + return project + } + + @StartActiveSpan() + async create( + body: typeof projectContract.createProject.body._type, + requestorUserId: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('user.id', requestorUserId) + this.logger.log(`project.create started (requestorUserId=${requestorUserId}, projectName=${body.name})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const existingSlugs = await listProjectSlugsForSlugPrefix(tx, body.name) + const slug = generateSlug(body.name, existingSlugs.map(s => s.slug)) + + const created = await createProject(tx, generateProjectCreateInput(body, requestorUserId, slug)) + const loaded = await getProject(tx, created.id) + if (!loaded) throw new InternalServerErrorException('Project created but cannot be loaded') + return loaded + }) + await this.appEvents.emitProjectEvent('project.upsert', project.id, { + action: 'Create Project', + userId: requestorUserId, + requestId, + }) + await Promise.all(project.roles.map(async role => this.logProjectRoleAction( + 'Upsert Project Role', + project, + role.id, + `Rôle synchronisé: ${role.name}`, + requestorUserId, + requestId, + ))) + span?.setAttribute('project.id', project.id) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.create completed (requestorUserId=${requestorUserId}, projectId=${project.id}, slug=${project.slug})`) + return project + } catch (error) { + this.logger.error( + `project.create failed (requestorUserId=${requestorUserId}, projectName=${body.name}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + async update( + body: typeof projectContract.updateProject.body._type, + user: UserContext, + projectId: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('user.id', user.userId) + this.logger.log(`project.update started (projectId=${projectId}, requestorUserId=${user.userId})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const projectDb = await getNotArchivedProjectForUpdate(tx, projectId) + if (!projectDb) throw new NotFoundException('Projet introuvable') + + const { effectiveData, strippedKeys } = this.stripProjectUpdateBody(body, user, projectDb) + this.logProjectUpdateStrippedFields(span, projectId, user.userId, strippedKeys) + if (projectDb.locked && effectiveData.locked !== false) { + throw new ForbiddenException('Veuillez déverrouiller le projet pour le mettre à jour') + } + + await this.updateProjectOwnerIfNeeded(tx, projectDb, effectiveData, projectId, user.userId) + + const updateData = parseProjectUpdateInput(effectiveData) + const effectiveKeys = Object.keys(effectiveData) + span?.setAttribute('project.update.effectiveKeys.count', effectiveKeys.length) + await updateProject(tx, projectId, updateData) + + const updated = await getProject(tx, projectId) + if (!updated) throw new NotFoundException('Projet introuvable') + this.logger.log(`project.update dbUpdated (projectId=${projectId}, requestorUserId=${user.userId}, effectiveKeys=${effectiveKeys.join(',')})`) + return updated + }) + await this.appEvents.emitProjectEvent('project.upsert', project.id, { + action: 'Update Project', + userId: user.userId, + requestId, + }) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.update completed (projectId=${projectId}, requestorUserId=${user.userId})`) + return project + } catch (error) { + this.logger.error( + `project.update failed (projectId=${projectId}, requestorUserId=${user.userId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async archive(projectId: string, requestorUserId?: string, requestId?: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.archive started (projectId=${projectId})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const loaded = await getProject(tx, projectId) + if (!loaded) throw new NotFoundException('Projet introuvable') + + await deleteProjectDependencies(tx, projectId) + + const archivedSuffix = `${Date.now()}_archived` + await updateProject(tx, projectId, { + name: `${loaded.name}_${archivedSuffix}`, + slug: `${loaded.slug}_${archivedSuffix}`, + status: 'archived', + locked: true, + clusters: { set: [] }, + }) + + return loaded + }) + // pass the pre-archive snapshot: the row was renamed (slug suffixed) in the + // transaction above, listeners must clean up resources named after the old slug + await this.appEvents.emitProjectEvent('project.delete', project, { + action: 'Delete all project resources', + userId: requestorUserId, + requestId, + }) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.archive completed (projectId=${projectId}, slug=${project.slug})`) + } catch (error) { + this.logger.error( + `project.archive failed (projectId=${projectId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async logProjectRoleAction( + action: string, + project: ProjectWithDetails, + roleId: string, + messageResume: string, + userId: string | undefined, + requestId: string | undefined, + ): Promise { + await this.logs.addLog({ + action, + data: { + args: { projectId: project.id, roleId }, + messageResume, + results: { projectId: project.id, roleId }, + }, + userId, + requestId, + projectId: project.id, + }) + } + + private async listProjectsForDataExport(): Promise { + return this.prisma.project.findMany({ + select: projectForDataSelect, + }) + } + + private async fetchProjects(whereAnd: Prisma.ProjectWhereInput[]): Promise { + return this.prisma.project.findMany({ + where: { AND: whereAnd }, + select: projectSelect, + }) + } + + private async getProjectNotArchived(projectId: string): Promise { + return this.prisma.project.findFirst({ + where: { id: projectId, status: { not: 'archived' } }, + select: projectSelect, + }) + } + + private stripProjectUpdateBody( + body: typeof projectContract.updateProject.body._type, + user: UserContext, + project: ProjectUpdateContext, + ): { effectiveData: Record, strippedKeys: string[] } { + const isOwner = project.ownerId === user.userId + const isAdmin = AdminAuthorized.Manage(user.adminPermissions) + const effectiveData: Record = { ...body } + const strippedKeys: string[] = [] + + if (!isAdmin) { + if ('locked' in effectiveData) strippedKeys.push('locked') + delete effectiveData.locked + + if (!isOwner) { + if ('ownerId' in effectiveData) strippedKeys.push('ownerId') + delete effectiveData.ownerId + } + } + + return { effectiveData, strippedKeys } + } + + private logProjectUpdateStrippedFields( + span: ReturnType, + projectId: string, + requestorUserId: string, + strippedKeys: string[], + ): void { + if (!strippedKeys.length) return + + span?.setAttribute('project.update.strippedKeys.count', strippedKeys.length) + this.logger.debug(`project.update strippedFields (projectId=${projectId}, requestorUserId=${requestorUserId}, strippedKeys=${strippedKeys.join(',')})`) + } + + private async updateProjectOwnerIfNeeded( + tx: Prisma.TransactionClient, + project: ProjectUpdateContext, + effectiveData: Record, + projectId: string, + requestorUserId: string, + ): Promise { + const ownerIdCandidate = effectiveData.ownerId as string | undefined + if (!ownerIdCandidate || ownerIdCandidate === project.ownerId) return + + this.logger.log(`project.update ownerChange started (projectId=${projectId}, requestorUserId=${requestorUserId}, previousOwnerId=${project.ownerId}, nextOwnerId=${ownerIdCandidate})`) + + const memberCandidate = project.members.find(member => member.userId === ownerIdCandidate) + if (!memberCandidate) { + throw new BadRequestException('Le nouveau propriétaire doit faire partie des membres actuels du projet') + } + if (memberCandidate.user.type !== 'human') { + throw new BadRequestException('Seuls les comptes humains peuvent être propriétaire de projets') + } + + const oldOwnerIsMember = project.members.some(member => member.userId === project.ownerId) + if (!oldOwnerIsMember) { + await createProjectMember(tx, project.id, project.ownerId) + } + + await deleteProjectMember(tx, project.id, ownerIdCandidate) + await updateProject(tx, project.id, { owner: { connect: { id: ownerIdCandidate } } }) + + this.logger.log(`project.update ownerChange completed (projectId=${projectId}, requestorUserId=${requestorUserId}, previousOwnerId=${project.ownerId}, nextOwnerId=${ownerIdCandidate})`) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.utils.ts new file mode 100644 index 0000000000..bb50d21fdf --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/project/project.utils.ts @@ -0,0 +1,226 @@ +import type { CreateProjectBody, projectContract } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import type { ProjectWithDetails } from './project-queries.utils' +import { PROJECT_PERMS, ProjectSchemaV2, ProjectStatusSchema } from '@cpn-console/shared' +import { ProjectStatus } from '@prisma/client' +import { z } from 'zod' + +export function generateSlug(prefix: string, existingSlugs: string[] = []) { + if (!existingSlugs.includes(prefix)) return prefix + + let suffix = 1 + while (existingSlugs.includes(`${prefix}-${suffix}`)) { + suffix += 1 + } + return `${prefix}-${suffix}` +} + +export function generateProjectCreateInput( + data: CreateProjectBody, + ownerId: string, + slug: string, +): Prisma.ProjectCreateInput { + return { + name: data.name, + slug, + description: data.description ?? '', + status: ProjectStatus.created, + locked: false, + limitless: z.boolean().parse(data.limitless), + hprodCpu: data.hprodCpu, + hprodGpu: data.hprodGpu, + hprodMemory: data.hprodMemory, + prodCpu: data.prodCpu, + prodGpu: data.prodGpu, + prodMemory: data.prodMemory, + owner: { connect: { id: ownerId } }, + roles: { + create: [ + { + name: 'Administrateur', + permissions: PROJECT_PERMS.MANAGE, + position: 0, + oidcGroup: `/${slug}/console/admin`, + type: 'system:managed', + }, + { + name: 'DevOps', + permissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS + | PROJECT_PERMS.MANAGE_REPOSITORIES + | PROJECT_PERMS.REPLAY_HOOKS + | PROJECT_PERMS.SEE_SECRETS + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 1, + oidcGroup: `/${slug}/console/devops`, + type: 'system:managed', + }, + { + name: 'Développeur', + permissions: PROJECT_PERMS.MANAGE_REPOSITORIES + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 2, + oidcGroup: `/${slug}/console/developer`, + type: 'system:managed', + }, + { + name: 'Lecture seule', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 3, + oidcGroup: `/${slug}/console/readonly`, + type: 'system:managed', + }, + ], + }, + } +} + +export const ProjectV2ResponseSchema = ProjectSchemaV2.omit({ name: true }).extend({ + name: z.string(), +}) + +export function generateProjectV2(project: ProjectWithDetails) { + const payload = { + id: project.id, + name: project.name, + slug: project.slug, + description: project.description, + status: project.status, + locked: project.locked, + limitless: project.limitless, + hprodCpu: project.hprodCpu, + hprodGpu: project.hprodGpu, + hprodMemory: project.hprodMemory, + prodCpu: project.prodCpu, + prodGpu: project.prodGpu, + prodMemory: project.prodMemory, + everyonePerms: project.everyonePerms, + ownerId: project.ownerId, + owner: project.owner, + members: project.members.map(m => ({ + userId: m.user.id, + email: m.user.email, + firstName: m.user.firstName, + lastName: m.user.lastName, + roleIds: m.roleIds, + })), + roles: project.roles.map(role => ({ + id: role.id, + name: role.name, + permissions: role.permissions, + position: role.position, + projectId: role.projectId, + oidcGroup: role.oidcGroup ? role.oidcGroup.replace(`/${project.slug}`, '') : '', + type: role.type, + })), + clusterIds: project.clusters.map(c => c.id), + lastSuccessProvisionningVersion: project.lastSuccessProvisionningVersion, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + } + + return ProjectV2ResponseSchema.parse(payload) +} + +export function generateProjectWhereInput(opts: { + query: typeof projectContract.listProjects.query._type + requestorUserId: string + appVersion: string +}): Prisma.ProjectWhereInput[] { + const projectStatus = ProjectStatusSchema.options + const { status, statusIn, statusNotIn, filter = 'member', ...rest } = opts.query + + const whereAnd: Prisma.ProjectWhereInput[] = [] + if (rest.id) whereAnd.push({ id: rest.id }) + if (rest.locked !== undefined) whereAnd.push({ locked: rest.locked }) + if (rest.name) whereAnd.push({ name: rest.name }) + if (rest.description) whereAnd.push({ description: { contains: rest.description } }) + + const statusWhere = parseEnumWhereFilter({ + enumValues: projectStatus, + eqValue: status, + inValues: statusIn, + notInValues: statusNotIn, + }) + if (statusWhere) whereAnd.push({ status: statusWhere }) + + if (rest.lastSuccessProvisionningVersion) { + if (rest.lastSuccessProvisionningVersion === 'outdated') { + whereAnd.push({ lastSuccessProvisionningVersion: { not: opts.appVersion } }) + } else if (rest.lastSuccessProvisionningVersion === 'last') { + whereAnd.push({ lastSuccessProvisionningVersion: { equals: opts.appVersion } }) + } else { + whereAnd.push({ lastSuccessProvisionningVersion: rest.lastSuccessProvisionningVersion }) + } + } + + if (rest.search) { + whereAnd.push({ + OR: [ + { name: { contains: rest.search } }, + { owner: { email: { contains: rest.search } } }, + ], + }) + } + + if (filter === 'owned') { + whereAnd.push({ ownerId: opts.requestorUserId }) + } else if (filter === 'member') { + whereAnd.push({ + OR: [ + { members: { some: { userId: opts.requestorUserId } } }, + { ownerId: opts.requestorUserId }, + ], + }) + } + + return whereAnd +} + +export function parseEnumWhereFilter({ + enumValues, + eqValue, + inValues, + notInValues, +}: { + enumValues: T + eqValue: T[number] | undefined + inValues: string | undefined + notInValues: string | undefined +}): + | T[number] + | { in: T[number][] } + | { notIn: T[number][] } + | undefined { + if (eqValue) { + return eqValue + } + if (inValues) { + return { in: parseCsvEnumList(enumValues, inValues) } + } + if (notInValues) { + return { notIn: parseCsvEnumList(enumValues, notInValues) } + } +} + +const ProjectUpdateDataSchema = z.object({ + description: z.string().optional(), + locked: z.boolean().optional(), + limitless: z.boolean().optional(), + hprodCpu: z.number().optional(), + hprodGpu: z.number().optional(), + hprodMemory: z.number().optional(), + prodCpu: z.number().optional(), + prodGpu: z.number().optional(), + prodMemory: z.number().optional(), + everyonePerms: z.union([z.string(), z.number(), z.bigint()]).transform(BigInt).optional(), +}).passthrough() + +export function parseProjectUpdateInput(effectiveData: Record): Prisma.ProjectUpdateInput { + return ProjectUpdateDataSchema.parse(effectiveData) satisfies Prisma.ProjectUpdateInput +} + +function parseCsvEnumList(toMatch: T, inputs: string): T[number][] { + return inputs.split(',').filter(i => toMatch.includes(i)) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts new file mode 100644 index 0000000000..f9e676753d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts @@ -0,0 +1,22 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface HarborConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + ruleTemplate?: string + ruleCount?: string + retentionCron: string + robotRotationThresholdDays: number + projectSlugCacheTtlMs: number + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface HarborModuleOptions extends HarborConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('harbor') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as HARBOR_CONFIG } diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts new file mode 100644 index 0000000000..057bd83f04 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts @@ -0,0 +1,96 @@ +import type { HarborConfig } from './harbor.module-definition' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { VaultClientService } from '../vault/vault-client.service' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryHttpClientService } from './registry-http-client.service' + +const harborUrl = 'https://harbor.example' +const harborAdminPassword = faker.internet.password() +const basicAuth = `Basic ${Buffer.from(`admin:${harborAdminPassword}`, 'utf8').toString('base64')}` + +const server = setupServer() + +describe('registryService', () => { + let service: RegistryClientService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + beforeEach(async () => { + const harborConfig = mockDeep({ + url: harborUrl, + internalUrl: harborUrl, + internalOrPublicUrl: harborUrl, + admin: 'admin', + adminPassword: harborAdminPassword, + ruleTemplate: 'latestPushedK', + ruleCount: '10', + retentionCron: '0 22 2 * * *', + }) + + const module = await Test.createTestingModule({ + providers: [ + RegistryClientService, + RegistryHttpClientService, + { + provide: VaultClientService, + useValue: {}, + }, + { + provide: HARBOR_CONFIG, + useValue: harborConfig, + }, + ], + }).compile() + service = module.get(RegistryClientService) + }) + + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should send basic auth and JSON body on createProject', async () => { + server.use( + http.post(`${harborUrl}/api/v2.0/projects`, async ({ request }) => { + expect(request.method).toBe('POST') + expect(request.url).toBe(`${harborUrl}/api/v2.0/projects`) + expect(request.headers.get('accept')).toBe('application/json') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('content-type')).toContain('application/json') + expect(await request.json()).toEqual({ + project_name: 'myproj', + metadata: { auto_scan: 'true' }, + storage_limit: -1, + }) + return HttpResponse.json({}, { status: HttpStatus.CREATED }) + }), + ) + + await service.createProject('myproj', -1) + }) + + it('should send X-Is-Resource-Name on getProjectByName', async () => { + server.use( + http.get(`${harborUrl}/api/v2.0/projects/:projectName`, async ({ request, params }) => { + expect(request.method).toBe('GET') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('x-is-resource-name')).toBe('true') + expect(params.projectName).toBe('myproj') + return HttpResponse.json({ project_id: 123, metadata: {} }) + }), + ) + + const res = await service.getProjectByName('myproj') + + expect(res).toMatchObject({ status: HttpStatus.OK, data: { project_id: 123 } }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-client.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-client.service.ts new file mode 100644 index 0000000000..0e85eff9c9 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-client.service.ts @@ -0,0 +1,226 @@ +import type { RegistryQuery, RegistryResponse } from './registry-http-client.service' +import { Inject, Injectable } from '@nestjs/common' +import { RegistryHttpClientService } from './registry-http-client.service' +import { ROBOT_LIST_PAGE_SIZE } from './registry.constants' + +export const roAccess: HarborAccess[] = [ + { resource: 'repository', action: 'pull' }, + { resource: 'artifact', action: 'read' }, +] + +export const rwAccess: HarborAccess[] = [ + ...roAccess, + { resource: 'repository', action: 'list' }, + { resource: 'tag', action: 'list' }, + { resource: 'artifact', action: 'list' }, + { resource: 'scan', action: 'create' }, + { resource: 'scan', action: 'stop' }, + { resource: 'repository', action: 'push' }, + { resource: 'artifact-label', action: 'create' }, + { resource: 'artifact-label', action: 'delete' }, + { resource: 'tag', action: 'create' }, + { resource: 'tag', action: 'delete' }, +] + +export interface HarborAccess { + resource: string + action: string +} + +export interface HarborProject { + project_id?: number + metadata?: { + retention_id?: number | string + } +} + +export interface HarborRobot { + id?: number + name?: string +} + +export interface HarborRobotCreated { + id?: number + name: string + secret: string +} + +export interface HarborMember { + id?: number + entity_name?: string + entity_type?: string + role_id?: number +} + +export interface HarborGroupMemberRequest { + role_id: number + member_group: { + group_name: string + group_type: number + } +} + +export interface HarborProjectQuota { + ref?: { id?: number } + hard?: { storage?: number } +} + +export interface HarborRobotPermission { + namespace: string + kind: 'project' + access: HarborAccess[] +} + +export interface HarborRobotCreateRequest { + name: string + duration: number + description: string + disable: boolean + level: 'project' + permissions: HarborRobotPermission[] +} + +export interface HarborRetentionRule { + disabled: boolean + action: 'retain' + template: string + params: Record + tag_selectors: Array<{ kind: string, decoration: string, pattern: string }> + scope_selectors: { + repository: Array<{ kind: string, decoration: string, pattern: string }> + } +} + +export interface HarborRetentionPolicy { + algorithm: 'or' | 'and' + scope: { level: 'project', ref: number } + rules: HarborRetentionRule[] + trigger: { + kind: 'Schedule' + settings: { cron?: string } + references: unknown[] + } +} + +@Injectable() +export class RegistryClientService { + constructor( + @Inject(RegistryHttpClientService) private readonly http: RegistryHttpClientService, + ) {} + + async getProjectByName(projectName: string): Promise> { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async createProject(projectName: string, storageLimit: number) { + return this.http.fetch('projects', { + method: 'POST', + body: { + project_name: projectName, + metadata: { auto_scan: 'true' }, + storage_limit: storageLimit, + }, + }) + } + + async deleteProjectByName(projectName: string) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async listQuotas(projectId: number) { + return this.http.fetch(`quotas?reference_id=${encodeURIComponent(String(projectId))}`, { + method: 'GET', + }) + } + + async updateQuota(projectId: number, storageLimit: number) { + return this.http.fetch(`quotas/${encodeURIComponent(String(projectId))}`, { + method: 'PUT', + body: { + hard: { + storage: storageLimit, + }, + }, + }) + } + + async getGroupMembers(projectName: string) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async addGroupMember(projectName: string, body: HarborGroupMemberRequest) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members`, { + method: 'POST', + headers: { 'X-Is-Resource-Name': 'true' }, + body, + }) + } + + async removeGroupMember(projectName: string, memberId: number) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members/${encodeURIComponent(String(memberId))}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + getProjectRobots(projectId: number): AsyncGenerator { + return this.paginate('robots', { + q: `Level=project,ProjectID=${projectId}`, + }) + } + + async createRobot(body: HarborRobotCreateRequest) { + return this.http.fetch('robots', { + method: 'POST', + body, + }) + } + + async deleteRobot(robotId: number): Promise { + return this.http.fetch(`robots/${encodeURIComponent(String(robotId))}`, { + method: 'DELETE', + }) + } + + async getRetentionId(projectName: string): Promise { + const project = await this.getProjectByName(projectName) + if (project.status !== 200 || !project.data) return null + const retentionId = Number(project.data?.metadata?.retention_id) + return Number.isFinite(retentionId) ? retentionId : null + } + + async createRetention(body: HarborRetentionPolicy) { + return this.http.fetch('retentions', { + method: 'POST', + body, + }) + } + + async updateRetention(retentionId: number, body: HarborRetentionPolicy) { + return this.http.fetch(`retentions/${encodeURIComponent(String(retentionId))}`, { + method: 'PUT', + body, + }) + } + + private async* paginate(path: string, query: RegistryQuery): AsyncGenerator { + for (let page = 1; ; page++) { + const response = await this.http.fetch(path, { + method: 'GET', + query: { ...query, page, page_size: ROBOT_LIST_PAGE_SIZE }, + }) + if (response.status !== 200 || !response.data?.length) return + yield* response.data + if (response.data.length < ROBOT_LIST_PAGE_SIZE) return + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts new file mode 100644 index 0000000000..9d0a511ef6 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts @@ -0,0 +1,62 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './registry.constants' + +export const projectSelect = { + slug: true, + plugins: { + where: { + pluginName: PLUGIN_NAME, + }, + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class RegistryDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return await this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return await this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-health.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-health.service.ts new file mode 100644 index 0000000000..4ffab35aad --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -0,0 +1,27 @@ +import type { HarborConfig } from './harbor.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { HARBOR_CONFIG } from './harbor.module-definition' + +@Injectable() +export class RegistryHealthService { + constructor( + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const credentials = `${this.harborConfig.admin}:${this.harborConfig.adminPassword}` + const base64 = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${base64}` + try { + const response = await fetch(this.harborConfig.probeUrl, { method: 'GET', headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts new file mode 100644 index 0000000000..bc3848b0cd --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts @@ -0,0 +1,109 @@ +import type { HarborConfig } from './harbor.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { encodeBasicAuth } from './registry.utils' + +export type RegistryQuery = Record + +export interface RegistryFetchOptions { + method?: string + headers?: Record + body?: unknown + query?: RegistryQuery +} + +export interface RegistryResponse { + status: HttpStatus + data: T | null +} + +export type RegistryErrorKind + = | 'NotConfigured' + | 'Unexpected' + +export class RegistryError extends Error { + readonly kind: RegistryErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + + constructor( + kind: RegistryErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string } = {}, + ) { + super(message) + this.name = 'RegistryError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + } +} + +@Injectable() +export class RegistryHttpClientService { + constructor( + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + ) {} + + private get apiBaseUrl() { + return new URL('api/v2.0/', this.harborConfig.internalOrPublicUrl).toString() + } + + private get defaultHeaders() { + return { Accept: 'application/json', Authorization: `Basic ${encodeBasicAuth(this.harborConfig.admin, this.harborConfig.adminPassword)}` } + } + + async fetch( + path: string, + options: RegistryFetchOptions = {}, + ): Promise> { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('registry.method', method) + span?.setAttribute('registry.path', path) + + const request = this.createRequest(path, method, options.body, options.headers, options.query) + const response = await fetch(request).catch((error) => { + throw new RegistryError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('registry.http.status', response.status) + return await handleResponse(response) + } + + private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record, query?: RegistryQuery): Request { + const url = new URL(path, this.apiBaseUrl) + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, String(value)) + } + } + const headers: Record = { + ...this.defaultHeaders, + ...extraHeaders, + } + let requestBody: string | undefined + if (body !== undefined) { + requestBody = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + return new Request(url, { method, headers, body: requestBody }) + } +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts new file mode 100644 index 0000000000..b4d14b009f --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts @@ -0,0 +1,77 @@ +import type { Cache } from 'cache-manager' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { HarborConfig } from './harbor.module-definition' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { RegistryPluginService } from './registry-plugin.service' +import { makeProjectWithDetails } from './registry-testing.utils' + +describe('registryPluginService', () => { + let service: RegistryPluginService + let harborConfig: DeepMockProxy + let datastore: DeepMockProxy + let registryClient: DeepMockProxy + let cache: DeepMockProxy + + beforeEach(async () => { + harborConfig = mockDeep({ + url: 'https://harbor.example/', + projectSlugCacheTtlMs: 300000, + }) + datastore = mockDeep() + registryClient = mockDeep() + cache = mockDeep() + + const moduleRef = await Test.createTestingModule({ + providers: [ + RegistryPluginService, + { provide: HARBOR_CONFIG, useValue: harborConfig }, + { provide: RegistryDatastoreService, useValue: datastore }, + { provide: RegistryClientService, useValue: registryClient }, + { provide: CACHE_MANAGER, useValue: cache }, + ], + }).compile() + + service = moduleRef.get(RegistryPluginService) + }) + + it('uses the stored Harbor project id when available', async () => { + cache.get.mockResolvedValue('dulei') + registryClient.getProjectByName.mockResolvedValue({ + status: HttpStatus.OK, + data: { project_id: 144, metadata: {} }, + }) + const infos = await service.infos('dulei') + const url = infos.to?.(makeToUrlParams({ + project: { id: '', name: '', slug: 'dulei' }, + store: { registry: { projectId: '144' } }, + })) + + expect(url).toBe('https://harbor.example/harbor/projects/144/') + expect(datastore.getProject).not.toHaveBeenCalled() + expect(registryClient.getProjectByName).toHaveBeenCalledWith('dulei') + }) + + it('falls back to Harbor lookup when the store is empty', async () => { + cache.get.mockResolvedValue(undefined) + datastore.getProject.mockResolvedValue(makeProjectWithDetails({ slug: 'dulei' })) + registryClient.getProjectByName.mockResolvedValue({ + status: HttpStatus.OK, + data: { project_id: 144, metadata: {} }, + }) + + const infos = await service.infos('dulei') + const url = infos.to?.(makeToUrlParams()) + + expect(registryClient.getProjectByName).toHaveBeenCalledWith('dulei') + expect(url).toBe('https://harbor.example/harbor/projects/144/') + expect(cache.set).toHaveBeenCalledWith('registry:project-slug:dulei', 'dulei', 300000) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts new file mode 100644 index 0000000000..e502423c5f --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts @@ -0,0 +1,143 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { Cache } from 'cache-manager' +import type { HarborConfig } from './harbor.module-definition' +import { DISABLED } from '@cpn-console/shared' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { createProjectSlugCacheKey } from './registry.utils' + +@Injectable() +export class RegistryPluginService { + private readonly logger = new Logger(RegistryPluginService.name) + + constructor( + @Inject(HARBOR_CONFIG) + private readonly harborConfig: HarborConfig, + @Inject(RegistryDatastoreService) + private readonly datastore: RegistryDatastoreService, + @Inject(RegistryClientService) + private readonly registryClient: RegistryClientService, + @Inject(CACHE_MANAGER) + private readonly cache: Cache, + ) {} + + private async resolveProjectSlug(projectId: string): Promise { + const cacheKey = createProjectSlugCacheKey(projectId) + const cached = await this.cache.get(cacheKey) + if (cached !== undefined) return cached ?? undefined + + const project = await this.datastore.getProject(projectId) + const slug = project?.slug ?? null + await this.cache.set(cacheKey, slug, this.harborConfig.projectSlugCacheTtlMs) + return slug ?? undefined + } + + private async resolveHarborProjectId(projectSlug: string): Promise { + try { + const harborProject = await this.registryClient.getProjectByName(projectSlug) + const harborProjectId = Number(harborProject.data?.project_id) + if (harborProject.status !== 200 || !Number.isFinite(harborProjectId)) { + return undefined + } + this.logger.log(`Successfully resolve harbor project id for project slug ${projectSlug}: ${harborProjectId}`) + return harborProjectId + } catch (error) { + this.logger.error(`Failed to resolve harbor project id for project slug ${projectSlug}: ${error}`) + return undefined + } + } + + private resolveHarborProjectUrl(harborProjectId: number): string { + return new URL(`harbor/projects/${harborProjectId}/`, this.harborConfig.url).toString() + } + + private async resolveProjectUrl(projectId: string): Promise { + const projectSlug = await this.resolveProjectSlug(projectId) + if (!projectSlug) { + return undefined + } + + const harborProjectId = await this.resolveHarborProjectId(projectSlug) + if (harborProjectId === undefined) { + return undefined + } + + return this.resolveHarborProjectUrl(harborProjectId) + } + + async infos(projectId: string): Promise { + const quotaDescription = '-1 -> illimité, sinon 100MB / 1.2GB (unités : B, KB, MB, GB, TB)), max 1024TB' + const projectUrl = await this.resolveProjectUrl(projectId) + if (!projectUrl) { + throw new Error('Project not found') + } + + return { + name: 'registry', + to: () => projectUrl, + title: 'Harbor', + imgSrc: '/img/harbor.svg', + description: 'Harbor stocke, analyse et distribue vos images de conteneurs', + config: { + project: [{ + permissions: { + admin: { read: false, write: false }, + user: { read: false, write: false }, + }, + key: 'projectId', + kind: 'text', + title: 'Num du projet Harbor', + value: '', + }, { + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot projet', + description: 'Activer le robot de projet (read-only) et afficher ses identifiants aux utilisateurs', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota', + value: '', + description: `Stockage limite (vide utilisation du paramètre global, ${quotaDescription}`, + placeholder: '', + }], + global: [{ + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot RO aux projets', + description: 'Définit le comportement en l\'absence de ce paramétrage au niveau projet', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota par défaut', + value: '-1', + description: `Stockage limite par projet (${quotaDescription}`, + placeholder: '-1', + }], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts new file mode 100644 index 0000000000..c04eae0429 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts @@ -0,0 +1,24 @@ +import type { ProjectWithDetails } from './registry-datastore.service' +import type { RegistryResponse } from './registry-http-client.service' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' + +export function makeOkResponse(data: T): RegistryResponse { + return { status: HttpStatus.OK, data } +} + +export function makeCreatedResponse(data: T): RegistryResponse { + return { status: HttpStatus.CREATED, data } +} + +export function makeNoContent(): RegistryResponse { + return { status: HttpStatus.NO_CONTENT, data: null } +} + +export function makeProjectWithDetails(overrides: Partial = {}) { + return { + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.constants.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.constants.ts new file mode 100644 index 0000000000..e4c054daab --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.constants.ts @@ -0,0 +1,52 @@ +// Registry plugin identification +export const PLUGIN_NAME = 'harbor' + +// Registry configuration keys +export const REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT = 'quotaHardLimit' +export const REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT = 'publishProjectRobot' + +// Default platform-level group paths +export const DEFAULT_PLATFORM_ADMIN_GROUP_PATHS = '/console/admin' +export const DEFAULT_PLATFORM_GUEST_GROUP_PATHS = '/console/security,/console/readonly' + +// Default project-level group path suffixes +export const DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES = '/console/admin' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES = '/console/developer' +export const DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES = '/console/security,/console/readonly' + +// Platform group path plugin configuration keys +export const PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY = 'platformAdminGroupPath' +export const PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY = 'platformGuestGroupPaths' + +// Project group path suffixes plugin configuration keys +export const PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectAdminGroupPathSuffixes' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectMaintainerGroupPathSuffixes' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectDeveloperGroupPathSuffixes' +export const PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectGuestGroupPathSuffixes' + +// Registry robot name identifiers +export const ROBOT_NAME_RO = 'ro-robot' +export const ROBOT_NAME_RW = 'rw-robot' +export const ROBOT_NAME_PROJECT = 'project-robot' + +// Page size used when listing robots. A project only ever owns a handful of +// robots (ro, rw and optionally the project robot — 3 in practice), so a single +// page is plenty; this simply bounds each pagination request. +export const ROBOT_LIST_PAGE_SIZE = 100 + +// Allowed Harbor retention rule templates +export const ALLOWED_RETENTION_RULE_TEMPLATES = [ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +] as const + +// Harbor role identifiers +export const HARBOR_ROLE_PROJECT_ADMIN = 1 +export const HARBOR_ROLE_DEVELOPER = 2 +export const HARBOR_ROLE_GUEST = 3 +export const HARBOR_ROLE_MAINTAINER = 4 +export const HARBOR_ROLE_LIMITED_GUEST = 5 diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.module-definition.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.module-definition.ts new file mode 100644 index 0000000000..c994e3c6cc --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.module-definition.ts @@ -0,0 +1,21 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface RegistryConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + ruleTemplate: string + ruleCount?: number + retentionCron: string + robotRotationThresholdDays: number + projectSlugCacheTtlMs: number + internalOrPublicUrl?: string +} + +export interface RegistryModuleOptions extends RegistryConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('registry') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as REGISTRY_CONFIG } diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.module.ts new file mode 100644 index 0000000000..c93e994bce --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.module.ts @@ -0,0 +1,23 @@ +import { CacheModule } from '@nestjs/cache-manager' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { ConfigurableModuleClass as HarborConfigurableModuleClass } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { RegistryHealthService } from './registry-health.service' +import { RegistryHttpClientService } from './registry-http-client.service' +import { RegistryPluginService } from './registry-plugin.service' +import { ConfigurableModuleClass } from './registry.module-definition' +import { RegistryService } from './registry.service' + +@Module({}) +export class HarborModule extends HarborConfigurableModuleClass {} + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule, HarborModule, CacheModule.register()], + providers: [RegistryHealthService, RegistryPluginService, RegistryService, RegistryDatastoreService, RegistryHttpClientService, RegistryClientService], + exports: [RegistryHealthService, RegistryPluginService, RegistryService], +}) +export class RegistryModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.service.spec.ts new file mode 100644 index 0000000000..94cf3b9d05 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -0,0 +1,322 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { HarborConfig } from './harbor.module-definition' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from '../vault/vault-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { makeCreatedResponse, makeNoContent, makeOkResponse, makeProjectWithDetails } from './registry-testing.utils' +import { + PLUGIN_NAME, + REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, + REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, + ROBOT_NAME_PROJECT, +} from './registry.constants' +import { RegistryService } from './registry.service' + +describe('registryService', () => { + let service: RegistryService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let harborConfig: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + getProjectByName: vi.fn().mockResolvedValue(makeOkResponse({ project_id: 123, metadata: {} })), + listQuotas: vi.fn().mockResolvedValue(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])), + getRetentionId: vi.fn().mockResolvedValue(null), + createRetention: vi.fn().mockResolvedValue(makeCreatedResponse(null)), + getGroupMembers: vi.fn().mockResolvedValue(makeOkResponse([])), + getProjectRobots: vi.fn(async function* () {}), + addGroupMember: vi.fn().mockResolvedValue(makeCreatedResponse(null)), + removeGroupMember: vi.fn().mockResolvedValue(makeNoContent()), + deleteProjectByName: vi.fn().mockResolvedValue(makeNoContent()), + }) + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + read: vi.fn().mockResolvedValue(makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: 'robot$myproj+ro-robot', + TOKEN: 'secret', + }, + })), + write: vi.fn().mockResolvedValue(undefined), + }) + harborConfig = mockDeep({ + url: 'https://harbor.example', + internalUrl: 'https://harbor.example', + admin: 'admin', + adminPassword: faker.internet.password(), + ruleTemplate: 'latestPushedK', + ruleCount: '10', + retentionCron: '0 22 2 * * *', + robotRotationThresholdDays: 90, + }) + baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep({}) + + const module = await Test.createTestingModule({ + providers: [ + RegistryService, + { provide: RegistryClientService, useValue: client }, + { provide: RegistryDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: HARBOR_CONFIG, useValue: harborConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + ], + }).compile() + + service = module.get(RegistryService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('adds expected Harbor group memberships based on defaults', async () => { + const project = makeProjectWithDetails() + + await service.handleUpsert(project) + + const expected = [ + { groupName: `/${project.slug}`, roleId: 5 }, + { groupName: '/console/admin', roleId: 1 }, + { groupName: '/console/readonly', roleId: 3 }, + { groupName: '/console/security', roleId: 3 }, + { groupName: `/${project.slug}/console/readonly`, roleId: 3 }, + { groupName: `/${project.slug}/console/security`, roleId: 3 }, + { groupName: `/${project.slug}/console/developer`, roleId: 3 }, + { groupName: `/${project.slug}/console/devops`, roleId: 3 }, + { groupName: `/${project.slug}/console/admin`, roleId: 2 }, + ] + + expect(client.addGroupMember).toHaveBeenCalledTimes(expected.length) + for (const e of expected) { + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: e.roleId, + member_group: { + group_name: e.groupName, + group_type: 3, + }, + }) + } + }) + + it('reconciles an existing group membership when role differs', async () => { + const project = makeProjectWithDetails() + client.getGroupMembers.mockResolvedValueOnce(makeOkResponse([ + { id: 10, entity_name: `/${project.slug}/console/admin`, entity_type: 'g', role_id: 3 }, + ])) + + await service.handleUpsert(project) + + expect(client.removeGroupMember).toHaveBeenCalledWith(project.slug, 10) + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: 2, + member_group: { + group_name: `/${project.slug}/console/admin`, + group_type: 3, + }, + }) + }) + + it('returns a KO result when project admin membership creation fails', async () => { + const project = makeProjectWithDetails() + client.addGroupMember.mockImplementation(async (_projectName, body) => { + if (body.member_group.group_name === `/${project.slug}/console/admin` && body.role_id === 2) { + return { status: HttpStatus.BAD_REQUEST, data: null } + } + return { status: HttpStatus.CREATED, data: null } + }) + + await expect(service.handleUpsert(project)).resolves.toEqual({ + harbor: expect.objectContaining({ + status: 'KO', + message: expect.stringContaining('Harbor create member failed'), + }), + }) + + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: 2, + member_group: { + group_name: `/${project.slug}/console/admin`, + group_type: 3, + }, + }) + }) + + it('updates quota when it differs', async () => { + client.listQuotas.mockResolvedValueOnce(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])) + + await service.handleUpsert(makeProjectWithDetails({ + slug: 'myproj', + plugins: [ + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1024' }, + ], + })) + + expect(client.updateQuota).toHaveBeenCalledWith(123, 1024) + }) + + it('reuses robot secret when vault secret host matches', async () => { + const project = makeProjectWithDetails() + + await service.handleUpsert(project) + + expect(vault.read).toHaveBeenCalledTimes(2) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/ro-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/rw-robot`) + expect(client.getProjectRobots).not.toHaveBeenCalled() + expect(client.createRobot).not.toHaveBeenCalled() + expect(client.deleteRobot).not.toHaveBeenCalled() + expect(vault.write).not.toHaveBeenCalled() + }) + + it('rotates robot and writes secret when vault secret host differs', async () => { + const project = makeProjectWithDetails() + vault.read.mockImplementation(async (path: string) => { + if (path === `forge/${project.slug}/REGISTRY/ro-robot`) { + return makeVaultSecret({ + data: { + HOST: 'other.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'old', + }, + }) + } + return makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+rw-robot`, + TOKEN: 'secret', + }, + }) + }) + + client.getProjectRobots.mockImplementation(async function* () { + yield { id: 11, name: `robot$${project.slug}+ro-robot` } + }) + client.deleteRobot.mockResolvedValue(makeNoContent()) + client.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) + + await service.handleUpsert(project) + + expect(client.deleteRobot).toHaveBeenCalledWith(11) + expect(client.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + HOST: 'harbor.example', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'newsecret', + }), `forge/${project.slug}/REGISTRY/ro-robot`) + }) + + it('rotates robot and writes secret when vault secret is expiring', async () => { + const project = makeProjectWithDetails() + const old = makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'old', + }, + }) + old.metadata.created_time = new Date(Date.now() - 1000 * 60 * 60 * 24 * 120).toISOString() + + vault.read.mockImplementation(async (path: string) => { + if (path === `forge/${project.slug}/REGISTRY/ro-robot`) return old + return makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+rw-robot`, + TOKEN: 'secret', + }, + }) + }) + + client.getProjectRobots.mockImplementation(async function* () { + yield { id: 11, name: `robot$${project.slug}+ro-robot` } + }) + client.deleteRobot.mockResolvedValue(makeNoContent()) + client.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) + + await service.handleUpsert(project) + + expect(client.deleteRobot).toHaveBeenCalledWith(11) + expect(client.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + HOST: 'harbor.example', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'newsecret', + }), `forge/${project.slug}/REGISTRY/ro-robot`) + }) + + it('parses plugin config and enables project robot publishing', async () => { + const project = makeProjectWithDetails({ + plugins: [ + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1gb' }, + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, value: ENABLED }, + ], + }) + client.getProjectByName.mockResolvedValue(makeOkResponse({ project_id: 1, metadata: {} })) + + await service.handleUpsert(project) + + expect(client.updateQuota).toHaveBeenCalledWith(1, 1024 ** 3) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/ro-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/rw-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/${ROBOT_NAME_PROJECT}`) + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects', async () => { + datastore.getAllProjects.mockResolvedValue([ + makeProjectWithDetails({ slug: 'project-1' }), + makeProjectWithDetails({ slug: 'project-2' }), + ]) + + await service.handleCron() + + expect(client.getGroupMembers).toHaveBeenCalledWith('project-1') + expect(client.getGroupMembers).toHaveBeenCalledWith('project-2') + }) + }) + + describe('handleDelete', () => { + it('should delete project when it exists', async () => { + const project = makeProjectWithDetails() + await service.handleDelete(project) + expect(client.deleteProjectByName).toHaveBeenCalledWith(project.slug) + }) + + it('should not delete project when it does not exist', async () => { + client.getProjectByName.mockResolvedValueOnce({ status: HttpStatus.NOT_FOUND, data: null }) + await service.handleDelete(makeProjectWithDetails()) + expect(client.deleteProjectByName).not.toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.service.ts new file mode 100644 index 0000000000..43dc140455 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -0,0 +1,525 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultSecret } from '../vault/vault-client.service' +import type { HarborConfig } from './harbor.module-definition' +import type { + HarborAccess, + HarborGroupMemberRequest, + HarborMember, + HarborProjectQuota, + HarborRetentionPolicy, + HarborRobotCreateRequest, +} from './registry-client.service' +import type { ProjectWithDetails } from './registry-datastore.service' +import type { RuleTemplate, VaultRobotSecret } from './registry.utils' +import { specificallyEnabled } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { find } from '../../utils/iterable.utils' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService, roAccess, rwAccess } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { + ALLOWED_RETENTION_RULE_TEMPLATES, + DEFAULT_PLATFORM_ADMIN_GROUP_PATHS, + DEFAULT_PLATFORM_GUEST_GROUP_PATHS, + DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES, + HARBOR_ROLE_DEVELOPER, + HARBOR_ROLE_GUEST, + HARBOR_ROLE_LIMITED_GUEST, + HARBOR_ROLE_PROJECT_ADMIN, + PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, + REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, + ROBOT_NAME_PROJECT, + ROBOT_NAME_RO, + ROBOT_NAME_RW, +} from './registry.constants' +import { generateVaultRobotSecret, getHostFromUrl, getProjectVaultPath, parseBytes } from './registry.utils' + +@Injectable() +export class RegistryService { + private readonly logger = new Logger(RegistryService.name) + + constructor( + @Inject(RegistryClientService) private readonly client: RegistryClientService, + @Inject(RegistryDatastoreService) private readonly datastore: RegistryDatastoreService, + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('RegistryService initialized') + } + + private get host() { + return getHostFromUrl(this.harborConfig.url) + } + + private async getRobot(project: ProjectWithDetails, harborProjectId: number, robotName: string) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.robot.name': robotName, + }) + const fullName = generateRobotFullName(project, robotName) + return find(this.client.getProjectRobots(harborProjectId), r => r?.name === fullName) + } + + private async createProjectRobot(project: ProjectWithDetails, robotName: string, access: HarborAccess[]) { + const created = await this.client.createRobot( + generateRobotPermissions(project, robotName, access), + ) + if (created.status >= 300 || !created.data) { + throw new Error(`Harbor create robot failed (${created.status})`) + } + return created.data + } + + private async rotateRobot(project: ProjectWithDetails, harborProjectId: number, robotName: string, access: HarborAccess[]) { + const existing = await this.getRobot(project, harborProjectId, robotName) + if (existing?.id) { + await this.client.deleteRobot(existing.id) + } + return this.createProjectRobot(project, robotName, access) + } + + private async ensureRobotSecret(project: ProjectWithDetails, harborProjectId: number, robotName: string, access: HarborAccess[]) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.robot.name': robotName, + }) + const relativeVaultPath = `REGISTRY/${robotName}` + const vaultPath = getProjectVaultPath(project, this.baseConfig.projectsRootDir, relativeVaultPath) + const vaultRobotSecret = await this.vault.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + + const expiring = vaultRobotSecret + ? this.isRobotSecretExpiring(vaultRobotSecret) + : false + + span?.setAttributes({ + 'vault.secret.exists': !!vaultRobotSecret, + 'registry.robot.secret.expiring': expiring, + }) + + if (vaultRobotSecret?.data?.HOST === this.host && !expiring) { + span?.setAttribute('vault.secret.reused', true) + return vaultRobotSecret.data + } + + const existing = await this.getRobot(project, harborProjectId, robotName) + const created = existing + ? await this.rotateRobot(project, harborProjectId, robotName, access) + : await this.createProjectRobot(project, robotName, access) + const fullName = generateRobotFullName(project, robotName) + const secret = generateVaultRobotSecret(this.host, fullName, created.secret) + await this.vault.write(secret, vaultPath) + span?.setAttribute('vault.secret.written', true) + return secret + } + + private isRobotSecretExpiring(vaultSecret: VaultSecret): boolean { + const createdTimeRaw = vaultSecret?.metadata?.created_time + if (!createdTimeRaw) return false + const createdTime = new Date(createdTimeRaw) + return daysAgoFromNow(createdTime) > this.harborConfig.robotRotationThresholdDays + } + + private async ensureProjectGroupMember( + projectSlug: string, + groupName: string, + accessLevel: number, + membersByName: Map, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'registry.group.name': groupName, + 'registry.group.access_level': accessLevel, + }) + const existing = membersByName.get(groupName) + + if (existing?.id) { + if (existing.role_id !== accessLevel || existing.entity_type !== 'g') { + await this.client.removeGroupMember(projectSlug, Number(existing.id)) + membersByName.delete(groupName) + } else { + span?.setAttribute('registry.member.exists', true) + return + } + } + + const body: HarborGroupMemberRequest = { + role_id: accessLevel, + member_group: { + group_name: groupName, + group_type: 3, + }, + } + const created = await this.client.addGroupMember(projectSlug, body) + if (created.status >= 300) { + throw new Error(`Harbor create member failed (${created.status})`) + } + span?.setAttribute('registry.member.created', true) + } + + private async ensureProjectGroupMembers(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const members = await this.client.getGroupMembers(project.slug) + if (members.status !== 200 || !members.data) { + throw new Error(`Harbor list members failed (${members.status})`) + } + + const membersByName = new Map() + for (const member of members.data) { + const name = member?.entity_name + if (name) membersByName.set(name, member) + } + + const byGroupName = await this.generateAccessLevelMapping(project) + + await Promise.all( + Array.from(byGroupName.entries(), ([groupName, accessLevel]) => this.ensureProjectGroupMember( + project.slug, + groupName, + accessLevel, + membersByName, + )), + ) + } + + private async ensureProjectQuota(project: ProjectWithDetails, storageLimit: number) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.storage_limit.bytes': storageLimit, + }) + const existing = await this.client.getProjectByName(project.slug) + if (existing.status === 200 && existing.data) { + const projectId = Number(existing.data.project_id) + if (!Number.isFinite(projectId)) return existing.data + + const quotas = await this.client.listQuotas(projectId) + if (quotas.status === 200 && quotas.data) { + const hardQuota = quotas.data.find((q: HarborProjectQuota) => q?.ref?.id === projectId) + if (hardQuota?.hard?.storage !== storageLimit) { + await this.client.updateQuota(projectId, storageLimit) + span?.setAttribute('registry.quota.updated', true) + } + } + return existing.data + } + + const created = await this.client.createProject(project.slug, storageLimit) + if (created.status >= 300) { + throw new Error(`Harbor create project failed (${created.status})`) + } + span?.setAttribute('registry.project.created', true) + + const fetched = await this.client.getProjectByName(project.slug) + if (fetched.status !== 200 || !fetched.data) { + throw new Error(`Harbor get project failed (${fetched.status})`) + } + return fetched.data + } + + private async ensureRetentionPolicy(project: ProjectWithDetails, harborProjectId: number) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.project.id': harborProjectId, + }) + const policy = generateRetentionPolicy(harborProjectId, { + harborRuleTemplate: this.harborConfig.ruleTemplate, + harborRuleCount: this.harborConfig.ruleCount, + harborRetentionCron: this.harborConfig.retentionCron, + }) + const retentionId = await this.client.getRetentionId(project.slug) + span?.setAttribute('registry.retention.exists', !!retentionId) + const result = retentionId + ? await this.client.updateRetention(retentionId, policy) + : await this.client.createRetention(policy) + if (result.status >= 300) { + throw new Error(`Harbor retention policy failed (${result.status})`) + } + } + + @StartActiveSpan() + async ensureProject(project: ProjectWithDetails, options: { storageLimitBytes?: number, publishProjectRobot?: boolean } = {}) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.publish_project_robot': !!options.publishProjectRobot, + }) + const storageLimit = options.storageLimitBytes ?? -1 + const harborProject = await this.ensureProjectQuota(project, storageLimit) + const harborProjectId = Number(harborProject.project_id) + if (!Number.isFinite(harborProjectId)) + throw new Error('Unable to retrieve Harbor project_id') + + await Promise.all([ + this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_RO, roAccess), + this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_RW, rwAccess), + this.ensureProjectGroupMembers(project), + this.ensureRetentionPolicy(project, harborProjectId), + options.publishProjectRobot + ? this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_PROJECT, roAccess) + : Promise.resolve(), + ]) + + return { + projectId: harborProjectId, + basePath: `${this.host}/${project.slug}/`, + } + } + + @StartActiveSpan() + async deleteProject(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const existing = await this.client.getProjectByName(projectSlug) + if (existing.status === 404) { + span?.setAttribute('registry.project.exists', false) + return + } + const deleted = await this.client.deleteProjectByName(projectSlug) + if (deleted.status >= 300 && deleted.status !== 404) { + throw new Error(`Harbor delete project failed (${deleted.status})`) + } + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('harbor', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + const quotaConfigRaw = getPluginConfig(project, REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT) + const publishConfig = getPluginConfig(project, REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT) + const parsedQuota = quotaConfigRaw ? parseBytes(String(quotaConfigRaw)) : undefined + const storageLimitBytes = parsedQuota ?? -1 + const publishProjectRobot = specificallyEnabled(publishConfig) + await this.ensureProject(project, { storageLimitBytes, publishProjectRobot }) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('harbor', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.deleteProject(project.slug) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Registry reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('registry.projects.count', projects.length) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string) { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return getPluginConfig(project, key) + } + + private async getPlatformAdminGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_PLATFORM_ADMIN_GROUP_PATHS + return parseGroupPaths(raw) + } + + private async getPlatformGuestGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY) ?? DEFAULT_PLATFORM_GUEST_GROUP_PATHS + return parseGroupPaths(raw) + } + + private async getProjectAdminGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectMaintainerGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectGuestGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async generateAccessLevelMapping(project: ProjectWithDetails) { + const [ + platformAdminGroupPaths, + platformGuestGroupPaths, + projectAdminGroupPaths, + projectMaintainerGroupPaths, + projectDeveloperGroupPaths, + projectGuestGroupPaths, + ] = await Promise.all([ + this.getPlatformAdminGroupPaths(project), + this.getPlatformGuestGroupPaths(project), + this.getProjectAdminGroupPaths(project), + this.getProjectMaintainerGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectGuestGroupPaths(project), + ]) + + const roles = generateHarborAccessLevelMapping({ + guest: projectGuestGroupPaths, + developer: projectDeveloperGroupPaths, + maintainer: projectMaintainerGroupPaths, + admin: projectAdminGroupPaths, + platformAdmin: platformAdminGroupPaths, + platformGuest: platformGuestGroupPaths, + }) + return new Map([ + [`/${project.slug}`, HARBOR_ROLE_LIMITED_GUEST], + ...roles, + ]) + } +} + +function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +function parseGroupPaths(raw: string): string[] { + return raw + .split(',') + .map(path => path.trim()) + .filter(Boolean) +} + +function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string) { + return parseGroupPaths(rawGroupPathSuffixes).map(path => `/${projectSlug}${path}`) +} + +function generateHarborAccessLevelMapping(args: { guest: string[], developer: string[], maintainer: string[], admin: string[], platformAdmin: string[], platformGuest: string[] }) { + const byGroupName = new Map() + for (const groupName of args.guest) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.developer) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.maintainer) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.admin) byGroupName.set(groupName, HARBOR_ROLE_DEVELOPER) + for (const groupName of args.platformAdmin) byGroupName.set(groupName, HARBOR_ROLE_PROJECT_ADMIN) + for (const groupName of args.platformGuest) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + return byGroupName +} + +function daysAgoFromNow(date: Date) { + return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) +} + +function generateRobotFullName(project: ProjectWithDetails, robotName: string) { + return `robot$${project.slug}+${robotName}` +} + +function generateRobotPermissions(project: ProjectWithDetails, robotName: string, access: HarborAccess[]): HarborRobotCreateRequest { + return { + name: robotName, + duration: -1, + description: 'robot for ci builds', + disable: false, + level: 'project', + permissions: [{ + namespace: project.slug, + kind: 'project', + access, + }], + } +} + +function generateRetentionPolicy( + projectId: number, + options: { + harborRuleTemplate?: string + harborRuleCount?: string + harborRetentionCron?: string + }, +): HarborRetentionPolicy { + let template: RuleTemplate = 'latestPushedK' + if (isRuleTemplate(options.harborRuleTemplate)) { + template = options.harborRuleTemplate + } + + const rawCount = Number(options.harborRuleCount) + let count: number + if (Number.isFinite(rawCount) && rawCount > 0) { + count = rawCount + } else if (template === 'always') { + count = 1 + } else { + count = 10 + } + + return { + algorithm: 'or', + scope: { level: 'project', ref: projectId }, + rules: [ + { + disabled: false, + action: 'retain', + template, + params: { [template]: count }, + tag_selectors: [ + { kind: 'doublestar', decoration: 'matches', pattern: '**' }, + ], + scope_selectors: { + repository: [ + { kind: 'doublestar', decoration: 'repoMatches', pattern: '**' }, + ], + }, + }, + ], + trigger: { + kind: 'Schedule', + settings: { cron: options.harborRetentionCron }, + references: [], + }, + } +} + +function isRuleTemplate(value: unknown): value is RuleTemplate { + if (typeof value !== 'string') return false + for (const template of ALLOWED_RETENTION_RULE_TEMPLATES) { + if (template === value) return true + } + return false +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.utils.ts new file mode 100644 index 0000000000..2befbdbd12 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -0,0 +1,86 @@ +import type { ProjectWithDetails } from './registry-datastore.service' +import type { ALLOWED_RETENTION_RULE_TEMPLATES } from './registry.constants' +import { removeTrailingSlash } from '@cpn-console/shared' + +export type RuleTemplate = typeof ALLOWED_RETENTION_RULE_TEMPLATES[number] + +export function createProjectSlugCacheKey(projectId: string) { + return `registry:project-slug:${projectId}` +} + +const protocolPrefixRegex = /^https?:\/\//u +const parseBytesRegex = /^(\d+(?:\.\d+)?)(?:\s*(kb|mb|gb|tb|[kmgtb]))?$/u + +export function getHostFromUrl(url: string) { + return removeTrailingSlash(url).replace(protocolPrefixRegex, '').split('/')[0] +} + +export function encodeBasicAuth(username: string, password: string) { + return Buffer.from(`${username}:${password}`).toString('base64') +} + +export interface VaultRobotSecret { + DOCKER_CONFIG: string + HOST: string + TOKEN: string + USERNAME: string +} + +export function generateVaultRobotSecret(host: string, robotName: string, robotSecret: string): VaultRobotSecret { + const auth = `${robotName}:${robotSecret}` + const b64auth = Buffer.from(auth).toString('base64') + return { + DOCKER_CONFIG: JSON.stringify({ + auths: { + [host]: { + auth: b64auth, + email: '', + }, + }, + }), + HOST: host, + TOKEN: robotSecret, + USERNAME: robotName, + } +} + +export function getProjectVaultPath(project: ProjectWithDetails, projectRootDir: string | undefined, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return projectRootDir + ? `${projectRootDir}/${project.slug}/${normalized}` + : `${project.slug}/${normalized}` +} + +export function parseBytes(input: string | number | undefined) { + if (input === undefined || input === null) return undefined + if (typeof input === 'number' && Number.isFinite(input)) return input + const raw = String(input).trim().toLowerCase() + if (!raw) return undefined + const match = parseBytesRegex.exec(raw) + if (!match) { + return Number.isFinite(Number(raw)) ? Number(raw) : undefined + } + const value = Number(match[1]) + const unit = (match[2] ?? 'b').toLowerCase() + const pow = parseUnit(unit) + return Math.round(value * 1024 ** pow) +} + +function parseUnit(unit: string) { + switch (unit) { + case 'kb': + case 'k': + return 1 + case 'mb': + case 'm': + return 2 + case 'gb': + case 'g': + return 3 + case 'tb': + case 't': + return 4 + default: + return 0 + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts new file mode 100644 index 0000000000..51e59317ee --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts @@ -0,0 +1,199 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Dispatcher, RequestInit } from 'undici' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { OpenCdsConfig } from '../opencds/opencds.module-definition' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { Agent, fetch, Headers, ProxyAgent, Response } from 'undici' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { OPENCDS_CONFIG } from '../opencds/opencds.module-definition' +import { OpenCdsClientError, OpenCdsClientService } from './open-cds-client.service' + +vi.mock('undici', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + Agent: vi.fn(), + fetch: vi.fn(), + ProxyAgent: vi.fn(), + } +}) + +function mockFetchResponse(response: Response): void { + vi.mocked(fetch).mockResolvedValue(response) +} + +function getLastFetchCall(): [string, RequestInit] { + const [url, init] = vi.mocked(fetch).mock.lastCall as [string, RequestInit] + return [url, init] +} + +describe('openCdsClientService', () => { + let module: TestingModule + let service: OpenCdsClientService + let openCdsConfig: Partial + let baseConfig: DeepMockProxy + let tlsDispatcher: Pick + let proxyDispatcher: Pick + + beforeEach(async () => { + vi.clearAllMocks() + vi.unstubAllEnvs() + + tlsDispatcher = { dispatch: vi.fn() } + proxyDispatcher = { dispatch: vi.fn() } + + class MockAgent { + dispatch = tlsDispatcher + }; + + class ProxyMockAgent { + dispatch = proxyDispatcher + }; + + vi.mocked(Agent).mockImplementation(MockAgent as any) + vi.mocked(ProxyAgent).mockImplementation(ProxyMockAgent as any) + + openCdsConfig = { + url: 'https://opencds.example.com/root/api/', + apiToken: 'test-token', + apiTlsRejectUnauthorized: true, + } + baseConfig = mockDeep({ httpProxy: undefined }) + + module = await Test.createTestingModule({ + providers: [ + OpenCdsClientService, + { provide: OPENCDS_CONFIG, useValue: openCdsConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(OpenCdsClientService) + }) + + it('builds GET requests with an Axios-compatible URL, API key header and TLS-aware dispatcher', async () => { + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + const result = await service.get<{ ok: boolean }>('/requests') + + expect(Agent).toHaveBeenCalledWith({ + connect: { + rejectUnauthorized: true, + }, + }) + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests') + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('GET') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(result).toEqual({ ok: true }) + }) + + it('uses ProxyAgent when HTTP_PROXY is configured and preserves TLS settings for the upstream request', async () => { + baseConfig.httpProxy = 'http://proxy.internal:3128' + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + await service.get('/requests') + + expect(ProxyAgent).toHaveBeenCalledWith({ + requestTls: { + rejectUnauthorized: true, + }, + uri: 'http://proxy.internal:3128', + }) + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests') + expect(init.dispatcher?.dispatch).toBe(proxyDispatcher) + expect(init.method).toBe('GET') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + }) + + it('applies query parameters and omits undefined values on GET', async () => { + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + await service.get('/requests', { + query: { + page: 2, + active: true, + search: 'alpha', + ignored: undefined, + }, + }) + + const [url] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests?page=2&active=true&search=alpha') + }) + + it('sends POST without body and without forcing JSON content type', async () => { + mockFetchResponse(new Response(null, { status: HttpStatus.NO_CONTENT })) + + await service.post('/validate/id') + + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/validate/id') + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('POST') + expect(init.signal).toBeUndefined() + expect(init.body).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(new Headers(init.headers).has('Content-Type')).toBe(false) + }) + + it('serializes POST bodies as JSON and sets the content type', async () => { + mockFetchResponse(new Response(null, { status: HttpStatus.NO_CONTENT })) + + await service.post('/validate/id', { + requestId: '123', + enabled: true, + }) + + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/validate/id') + expect(init.body).toBe(JSON.stringify({ + requestId: '123', + enabled: true, + })) + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('POST') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(new Headers(init.headers).get('Content-Type')).toBe('application/json') + }) + + it('throws a dedicated error with HTTP status context for non-OK responses', async () => { + mockFetchResponse(new Response('upstream failure', { + status: HttpStatus.BAD_GATEWAY, + statusText: 'Bad Gateway', + })) + + await expect(service.get('/requests')).rejects.toMatchObject({ + body: 'upstream failure', + message: 'OpenCDS request failed with 502 Bad Gateway', + name: OpenCdsClientError.name, + status: HttpStatus.BAD_GATEWAY, + statusText: 'Bad Gateway', + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts new file mode 100644 index 0000000000..c2760046d2 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts @@ -0,0 +1,129 @@ +import type { HttpStatus } from '@nestjs/common' +import type { Dispatcher, HeadersInit } from 'undici' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { OpenCdsConfig } from '../opencds/opencds.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { Agent, fetch, Headers, ProxyAgent } from 'undici' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { OPENCDS_CONFIG } from '../opencds/opencds.module-definition' +import { throwIfNotOk } from './service-chain.utils' + +const URL_REGEX = /^https?:\/\// +const START_SLASHES_REGEX = /^\/+/ +const END_SLASHES_REGEX = /\/+$/ + +export interface OpenCdsRequestOptions { + headers?: HeadersInit + signal?: AbortSignal + query?: Record +} + +export class OpenCdsClientError extends Error { + constructor( + public readonly status: HttpStatus, + public readonly statusText: string, + public readonly body?: string, + ) { + super(`OpenCDS request failed with ${status} ${statusText}`) + } +} + +@Injectable() +export class OpenCdsClientService { + constructor( + @Inject(OPENCDS_CONFIG) private readonly opencdsConfig: OpenCdsConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + private readonly logger = new Logger(OpenCdsClientService.name) + + async get(path: string, options?: OpenCdsRequestOptions): Promise { + const url = this.buildUrl(path, options?.query) + this.logger.debug(`Retrieving data from URL: ${url}`) + + const headers = this.buildHeaders(options?.headers) + + const response = await fetch(url, { + dispatcher: this.buildDispatcher(), + headers, + method: 'GET', + signal: options?.signal, + }) + + await throwIfNotOk(response) + + return (await response.json()) as T + } + + async post( + path: string, + body?: TBody, + options?: OpenCdsRequestOptions, + ): Promise { + const hasBody = body !== undefined + + const response = await fetch(this.buildUrl(path, options?.query), { + body: hasBody ? JSON.stringify(body) : undefined, + dispatcher: this.buildDispatcher(), + headers: this.buildHeaders(options?.headers, hasBody), + method: 'POST', + signal: options?.signal, + }) + + await throwIfNotOk(response) + } + + private buildUrl( + path: string, + query?: OpenCdsRequestOptions['query'], + ): string { + if (!this.opencdsConfig.url) throw new Error('OpenCDS is disabled') + + const resolvedPath = URL_REGEX.test(path) + ? path + : `${this.opencdsConfig.url.replace(END_SLASHES_REGEX, '')}/${path.replace(START_SLASHES_REGEX, '')}` + + const url = new URL(resolvedPath) + + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined) { + url.searchParams.append(key, String(value)) + } + } + + return url.toString() + } + + private buildHeaders( + headers?: OpenCdsRequestOptions['headers'], + hasJsonBody = false, + ): Headers { + const mergedHeaders = new Headers(headers) + mergedHeaders.set('X-API-Key', this.opencdsConfig.apiToken ?? '') + + if (hasJsonBody) { + mergedHeaders.set('Content-Type', 'application/json') + } + + return mergedHeaders + } + + private buildDispatcher(): Dispatcher { + const httpProxy = this.baseConfig.httpProxy + + if (httpProxy) { + return new ProxyAgent({ + requestTls: { + rejectUnauthorized: this.opencdsConfig.apiTlsRejectUnauthorized, + }, + uri: httpProxy, + }) + } + + return new Agent({ + connect: { + rejectUnauthorized: this.opencdsConfig.apiTlsRejectUnauthorized, + }, + }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts new file mode 100644 index 0000000000..2318edb937 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts @@ -0,0 +1,126 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ServiceChainController } from './service-chain.controller' +import { ServiceChainService } from './service-chain.service' + +describe('serviceChainController', () => { + let module: TestingModule + let controller: ServiceChainController + let service: MockProxy + + const uuid = '550e8400-e29b-41d4-a716-446655440000' + const date = new Date('2026-01-01T00:00:00.000Z') + const serviceChain: ServiceChain = { + id: uuid, + state: 'opened', + commonName: 'test.example.com', + pai: 'test-pai', + network: 'RIE', + createdAt: date, + updatedAt: date, + } + const serviceChainDetails: ServiceChainDetails = { + ...serviceChain, + validationId: uuid, + validatedBy: null, + ref: null, + location: 'SIR', + targetAddress: '10.0.0.1', + projectId: uuid, + env: 'INT', + subjectAlternativeName: [], + redirect: false, + antivirus: null, + websocket: false, + ipWhiteList: [], + sslOutgoing: false, + } + const serviceChainFlows: ServiceChainFlows = { + reserve_ip: { state: 'success', input: {}, output: {}, updatedAt: date }, + create_cert: null, + call_exec: { state: 'success', input: {}, output: {}, updatedAt: date }, + activate_ip: { state: 'success', input: {}, output: {}, updatedAt: date }, + dns_request: { state: 'success', input: {}, output: {}, updatedAt: date }, + } + + beforeEach(async () => { + service = mock() + + module = await Test.createTestingModule({ + controllers: [ServiceChainController], + providers: [ + { provide: ServiceChainService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(ServiceChainController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call service.list()', async () => { + const mockResult = [serviceChain] + service.list.mockResolvedValue(mockResult) + + const result = await controller.list() + + expect(service.list).toHaveBeenCalled() + expect(result).toEqual(mockResult) + }) + }) + + describe('getDetails', () => { + it('should call service.getDetails() with id', async () => { + const mockResult = serviceChainDetails + service.getDetails.mockResolvedValue(mockResult) + + const result = await controller.getDetails(uuid) + + expect(service.getDetails).toHaveBeenCalledWith(uuid) + expect(result).toEqual(mockResult) + }) + }) + + describe('retry', () => { + it('should call service.retry() with id', async () => { + service.retry.mockResolvedValue() + + await controller.retry(uuid) + + expect(service.retry).toHaveBeenCalledWith(uuid) + }) + }) + + describe('validate', () => { + it('should call service.validate() with validationId', async () => { + service.validate.mockResolvedValue() + + await controller.validate(uuid) + + expect(service.validate).toHaveBeenCalledWith(uuid) + }) + }) + + describe('getFlows', () => { + it('should call service.getFlows() with id', async () => { + const mockResult = serviceChainFlows + service.getFlows.mockResolvedValue(mockResult) + + const result = await controller.getFlows(uuid) + + expect(service.getFlows).toHaveBeenCalledWith(uuid) + expect(result).toEqual(mockResult) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts new file mode 100644 index 0000000000..1caea91186 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Inject, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ServiceChainService } from './service-chain.service' + +@Controller('api/v1/service-chains') +export class ServiceChainController { + constructor( + @Inject(ServiceChainService) + private readonly serviceChainService: ServiceChainService, + ) {} + + @Post('validate/:validationId') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async validate(@Param('validationId', ParseUUIDPipe) validationId: string) { + await this.serviceChainService.validate(validationId) + } + + @Get() + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async list() { + return this.serviceChainService.list() + } + + @Get(':serviceChainId') + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async getDetails(@Param('serviceChainId', ParseUUIDPipe) id: string) { + return this.serviceChainService.getDetails(id) + } + + @Post(':serviceChainId/retry') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async retry(@Param('serviceChainId', ParseUUIDPipe) id: string) { + await this.serviceChainService.retry(id) + } + + @Get(':serviceChainId/flows') + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async getFlows(@Param('serviceChainId', ParseUUIDPipe) id: string) { + return this.serviceChainService.getFlows(id) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts new file mode 100644 index 0000000000..52964ab4b2 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { openCdsConfigFactory } from '../../config/opencds.config' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { OpenCdsClientService } from './open-cds-client.service' +import { ServiceChainController } from './service-chain.controller' +import { ServiceChainService } from './service-chain.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ConfigModule.forFeature(openCdsConfigFactory), + ConfigModule.forFeature(baseConfigFactory), + ], + controllers: [ServiceChainController], + providers: [OpenCdsClientService, ServiceChainService], + exports: [ServiceChainService], +}) +export class ServiceChainModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts new file mode 100644 index 0000000000..e92351a0b2 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts @@ -0,0 +1,124 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { OpenCdsClientService } from './open-cds-client.service' +import { ServiceChainService } from './service-chain.service' + +describe('serviceChainService', () => { + let module: TestingModule + let service: ServiceChainService + let openCdsClient: MockProxy + + beforeEach(async () => { + openCdsClient = mock() + + module = await Test.createTestingModule({ + providers: [ + ServiceChainService, + { provide: OpenCdsClientService, useValue: openCdsClient }, + ], + }).compile() + + service = module.get(ServiceChainService) + }) + + const uuid = '550e8400-e29b-41d4-a716-446655440000' + + const mockServiceChain: ServiceChain = { + id: uuid, + state: 'opened', + commonName: 'test.example.com', + pai: 'test-pai', + network: 'RIE', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const mockServiceChainDetails: ServiceChainDetails = { + ...mockServiceChain, + validationId: uuid, + validatedBy: null, + ref: null, + location: 'SIR', + targetAddress: '10.0.0.1', + projectId: uuid, + env: 'INT', + subjectAlternativeName: [], + redirect: false, + antivirus: null, + websocket: false, + ipWhiteList: [], + sslOutgoing: false, + } + const flowDetails: NonNullable = { + state: 'success', + input: {}, + output: {}, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const mockServiceChainFlows: ServiceChainFlows = { + reserve_ip: flowDetails, + create_cert: null, + call_exec: flowDetails, + activate_ip: flowDetails, + dns_request: flowDetails, + } + + describe('list', () => { + it('should call GET /requests and parse response', async () => { + openCdsClient.get.mockResolvedValue([mockServiceChain]) + + const result = await service.list() + + expect(openCdsClient.get).toHaveBeenCalledWith('/requests') + expect(result).toHaveLength(1) + expect(result[0].id).toBe(uuid) + }) + }) + + describe('getDetails', () => { + it('should call GET /requests/:id and parse response', async () => { + openCdsClient.get.mockResolvedValue(mockServiceChainDetails) + + const result = await service.getDetails(uuid) + + expect(openCdsClient.get).toHaveBeenCalledWith(`/requests/${uuid}`) + expect(result.id).toBe(uuid) + expect(result.location).toBe('SIR') + }) + }) + + describe('retry', () => { + it('should call POST /requests/:id/retry', async () => { + openCdsClient.post.mockResolvedValue() + + await service.retry(uuid) + + expect(openCdsClient.post).toHaveBeenCalledWith(`/requests/${uuid}/retry`) + }) + }) + + describe('validate', () => { + it('should call POST /validate/:validationId', async () => { + openCdsClient.post.mockResolvedValue() + + await service.validate(uuid) + + expect(openCdsClient.post).toHaveBeenCalledWith(`/validate/${uuid}`) + }) + }) + + describe('getFlows', () => { + it('should call GET /requests/:id/flows and parse response', async () => { + openCdsClient.get.mockResolvedValue(mockServiceChainFlows) + + const result = await service.getFlows(uuid) + + expect(openCdsClient.get).toHaveBeenCalledWith(`/requests/${uuid}/flows`) + expect(result.reserve_ip.state).toBe('success') + expect(result.create_cert).toBeNull() + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts new file mode 100644 index 0000000000..d40a031fdf --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts @@ -0,0 +1,45 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import { + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainListSchema, +} from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { OpenCdsClientService } from './open-cds-client.service' + +@Injectable() +export class ServiceChainService { + constructor(@Inject(OpenCdsClientService) private readonly openCdsClient: OpenCdsClientService) {} + + @StartActiveSpan() + async list(): Promise { + return ServiceChainListSchema.parse( + await this.openCdsClient.get('/requests'), + ) + } + + @StartActiveSpan() + async getDetails(id: string): Promise { + return ServiceChainDetailsSchema.parse( + await this.openCdsClient.get(`/requests/${id}`), + ) + } + + @StartActiveSpan() + async retry(id: string): Promise { + await this.openCdsClient.post(`/requests/${id}/retry`) + } + + @StartActiveSpan() + async validate(validationId: string): Promise { + await this.openCdsClient.post(`/validate/${validationId}`) + } + + @StartActiveSpan() + async getFlows(id: string): Promise { + return ServiceChainFlowsSchema.parse( + await this.openCdsClient.get(`/requests/${id}/flows`), + ) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts new file mode 100644 index 0000000000..3500ad7ec9 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts @@ -0,0 +1,20 @@ +import type { HttpStatus } from '@nestjs/common' +import type { Response } from 'undici' + +export class OpenCdsClientError extends Error { + constructor( + public readonly status: HttpStatus, + public readonly statusText: string, + public readonly body?: string, + ) { + super(`OpenCDS request failed with ${status} ${statusText}`) + this.name = 'OpenCdsClientError' + } +} + +export async function throwIfNotOk(response: T): Promise { + if (!response.ok) { + const body = await response.text() + throw new OpenCdsClientError(response.status, response.statusText, body) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts new file mode 100644 index 0000000000..26a5d114c4 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts @@ -0,0 +1,203 @@ +import type { AddPermissionGroupParams, CreateUserParams, DeactivateUserParams, RevokeUserTokenParams } from './sonarqube-client.service' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' +import { makeSonarqubeGeneratedToken, makeSonarqubeGroup, makeSonarqubePaging, makeSonarqubeProject, makeSonarqubeUser } from './sonarqube-testing.utils' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +const sonarUrl = 'https://sonarqube.internal' +const sonarToken = 'my-token' +const sonarAuthHeader = `Bearer ${sonarToken}` + +const server = setupServer() + +describe('sonarqubeClientService', () => { + let service: SonarqubeClientService + let config: ReturnType> + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + config = mockDeep({ + apiToken: sonarToken, + internalOrPublicUrl: sonarUrl, + }) + + const module = await Test.createTestingModule({ + providers: [ + SonarqubeClientService, + SonarqubeHttpClientService, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = module.get(SonarqubeClientService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('userGroupsSearch', () => { + it('should GET user_groups/search with auth', async () => { + const group = makeSonarqubeGroup({ name: 'my-group' }) + server.use( + http.get(`${sonarUrl}/api/user_groups/search`, ({ request }) => { + expect(request.headers.get('authorization')).toBe(sonarAuthHeader) + expect(new URL(request.url).searchParams.get('q')).toBe('my-group') + return HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), groups: [group] }) + }), + ) + const result = await service.searchUserGroup({ q: 'my-group' }) + expect(result.groups).toEqual([group]) + }) + }) + + describe('userGroupsCreate', () => { + it('should POST user_groups/create', async () => { + server.use( + http.post(`${sonarUrl}/api/user_groups/create`, ({ request }) => { + expect(new URL(request.url).searchParams.get('name')).toBe('new-group') + return HttpResponse.json({}) + }), + ) + await expect(service.createUserGroup({ name: 'new-group' })).resolves.not.toThrow() + }) + }) + + describe('usersSearch', () => { + it('should GET users/search', async () => { + const user = makeSonarqubeUser({ login: 'my-user' }) + server.use( + http.get(`${sonarUrl}/api/users/search`, () => + HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), users: [user] })), + ) + const result = await service.searchUsers({ q: 'my-user' }) + expect(result.users).toEqual([user]) + }) + }) + + describe('usersCreate', () => { + it('should POST users/create with all params as query string', async () => { + const user = { + email: faker.internet.email(), + local: 'true', + login: faker.internet.username(), + name: faker.internet.username(), + password: faker.internet.password(), + } satisfies CreateUserParams + server.use( + http.post(`${sonarUrl}/api/users/create`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('login')).toBe(user.login) + expect(params.get('email')).toBe(user.email) + expect(params.get('local')).toBe(user.local) + return HttpResponse.json({}) + }), + ) + await service.createUser(user) + }) + }) + + describe('usersDeactivate', () => { + it('should POST users/deactivate with anonymize param', async () => { + const user = { + login: faker.internet.username(), + anonymize: true, + } satisfies DeactivateUserParams + server.use( + http.post(`${sonarUrl}/api/users/deactivate`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('login')).toBe(user.login) + expect(params.get('anonymize')).toBe(String(user.anonymize)) + return HttpResponse.json({}) + }), + ) + await service.deactivateUser(user) + }) + }) + + describe('userTokensRevoke / userTokensGenerate', () => { + it('should POST user_tokens/revoke', async () => { + const token = makeSonarqubeGeneratedToken() + const revoke = { + login: token.login, + name: token.name, + } satisfies RevokeUserTokenParams + server.use( + http.post(`${sonarUrl}/api/user_tokens/revoke`, () => HttpResponse.json({})), + ) + await expect(service.revokeUserToken(revoke)).resolves.not.toThrow() + }) + + it('should POST user_tokens/generate and return the token', async () => { + const generated = makeSonarqubeGeneratedToken() + server.use( + http.post(`${sonarUrl}/api/user_tokens/generate`, () => HttpResponse.json(generated)), + ) + const result = await service.generateUserToken({ login: generated.login, name: generated.name }) + expect(result.token).toBe(generated.token) + }) + }) + + describe('projectsSearch', () => { + it('should GET projects/search', async () => { + const project = makeSonarqubeProject() + server.use( + http.get(`${sonarUrl}/api/projects/search`, () => + HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), components: [project] })), + ) + const result = await service.searchProject({ q: project.name }) + expect(result.components).toEqual([project]) + }) + }) + + describe('projectsDelete', () => { + it('should POST projects/delete with project key as query param', async () => { + const project = makeSonarqubeProject() + server.use( + http.post(`${sonarUrl}/api/projects/delete`, ({ request }) => { + expect(new URL(request.url).searchParams.get('project')).toBe(project.key) + return HttpResponse.json({}) + }), + ) + await service.deleteProject({ project: project.key }) + }) + }) + + describe('permissionsAddGroup', () => { + it('should POST permissions/add_group with global params', async () => { + const group = { + groupName: '/admin', + permission: 'admin', + } satisfies AddPermissionGroupParams + server.use( + http.post(`${sonarUrl}/api/permissions/add_group`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('groupName')).toBe(group.groupName) + expect(params.get('permission')).toBe(group.permission) + expect(params.has('projectKey')).toBe(false) + return HttpResponse.json({}) + }), + ) + await service.addPermissionGroup(group) + }) + + it('should POST permissions/add_group with projectKey for project-scoped call', async () => { + server.use( + http.post(`${sonarUrl}/api/permissions/add_group`, ({ request }) => { + expect(new URL(request.url).searchParams.get('projectKey')).toBe('proj-key') + return HttpResponse.json({}) + }), + ) + await service.addPermissionGroup({ groupName: '/proj', permission: 'scan', projectKey: 'proj-key' }) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts new file mode 100644 index 0000000000..7cc63c64a4 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts @@ -0,0 +1,287 @@ +import type { + SONARQUBE_PROJECT_QUALIFIER_APPLICATION, + SONARQUBE_PROJECT_QUALIFIER_BRANCH, + SONARQUBE_PROJECT_QUALIFIER_DIRECTORY, + SONARQUBE_PROJECT_QUALIFIER_FILE, + SONARQUBE_PROJECT_QUALIFIER_LIBRARY, + SONARQUBE_PROJECT_QUALIFIER_PROJECT, + SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW, + SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST, + SONARQUBE_PROJECT_QUALIFIER_VIEW, +} from './sonarqube.constants' +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' + +export interface SonarqubePaging { + pageIndex: number + pageSize: number + total: number +} + +export interface SonarqubeGroup { + id: string + name: string + description: string + membersCount: number + default: boolean +} + +export interface SonarqubeUser { + login: string + name: string + active: boolean + email: string + groups: string[] + tokensCount: number + local: boolean + externalIdentity: string + externalProvider: string + managed: boolean +} + +export type SonarqubeProjectQualifier + = | typeof SONARQUBE_PROJECT_QUALIFIER_APPLICATION + | typeof SONARQUBE_PROJECT_QUALIFIER_BRANCH + | typeof SONARQUBE_PROJECT_QUALIFIER_DIRECTORY + | typeof SONARQUBE_PROJECT_QUALIFIER_FILE + | typeof SONARQUBE_PROJECT_QUALIFIER_LIBRARY + | typeof SONARQUBE_PROJECT_QUALIFIER_PROJECT + | typeof SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST + | typeof SONARQUBE_PROJECT_QUALIFIER_VIEW + | typeof SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW + +export interface SonarqubeProject { + key: string + name: string + qualifier: SonarqubeProjectQualifier + visibility: 'private' | 'public' + lastAnalysisDate?: string + revision?: string +} + +export interface SonarqubeProjectResult { + projectSlug: string + repository: string + key: string +} + +export interface SonarqubeGeneratedToken { + token: string + login: string + name: string +} + +type BaseParams = Record + +export interface SearchUserGroupParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateUserGroupParams extends BaseParams { + name: string + description?: string +} + +export interface CreatePermissionTemplateParams extends BaseParams { + name: string + description?: string + projectKeyPattern?: string +} + +export interface SearchPermissionTemplatesParams extends BaseParams { + q?: string +} + +export interface SetPermissionDefaultTemplateParams extends BaseParams { + templateName: string + projectKeyPattern?: string +} + +export interface AddPermissionProjectCreatorToTemplateParams extends BaseParams { + templateName: string + permission: string +} + +export interface AddPermissionGroupToTemplateParams extends BaseParams { + groupName: string + templateName: string + permission: string +} + +export interface AddPermissionGroupParams extends BaseParams { + groupName: string + permission: string + projectKey?: string +} + +export interface AddPermissionUserParams extends BaseParams { + projectKey: string + permission: string + login: string +} + +export interface SearchUsersParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateUserParams extends BaseParams { + email: string + local: string + login: string + name: string + password: string +} + +export interface DeactivateUserParams extends BaseParams { + login: string + anonymize: boolean +} + +export interface RevokeUserTokenParams extends BaseParams { + login: string + name: string +} + +export interface GenerateUserTokenParams extends BaseParams { + login: string + name: string +} + +export interface SearchProjectParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateProjectParams extends BaseParams { + project: string + visibility: string + name: string + mainbranch: string +} + +export interface DeleteProjectParams extends BaseParams { + project: string +} + +export interface SearchUserGroupResponse { + paging: SonarqubePaging + groups: SonarqubeGroup[] +} + +export interface SonarqubePermissionTemplate { + id: string + name: string + description?: string +} + +export interface SearchPermissionTemplatesResponse { + permissionTemplates: SonarqubePermissionTemplate[] +} + +export interface SearchUsersResponse { + paging: SonarqubePaging + users: SonarqubeUser[] +} + +export interface SearchProjectResponse { + paging: SonarqubePaging + components: SonarqubeProject[] +} + +@Injectable() +export class SonarqubeClientService { + constructor( + @Inject(SonarqubeHttpClientService) private readonly http: SonarqubeHttpClientService, + ) {} + + @StartActiveSpan() + searchUserGroup(params: SearchUserGroupParams) { + return this.http.fetch('user_groups/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createUserGroup(params: CreateUserGroupParams) { + await this.http.fetch('user_groups/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async createPermissionTemplate(params: CreatePermissionTemplateParams) { + await this.http.fetch('permissions/create_template', { method: 'POST', params }) + } + + @StartActiveSpan() + searchPermissionTemplates(params: SearchPermissionTemplatesParams) { + return this.http.fetch('permissions/search_templates', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async setPermissionDefaultTemplate(params: SetPermissionDefaultTemplateParams) { + await this.http.fetch('permissions/set_default_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionProjectCreatorToTemplate(params: AddPermissionProjectCreatorToTemplateParams) { + await this.http.fetch('permissions/add_project_creator_to_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionGroupToTemplate(params: AddPermissionGroupToTemplateParams) { + await this.http.fetch('permissions/add_group_to_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionGroup(params: AddPermissionGroupParams) { + await this.http.fetch('permissions/add_group', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionUser(params: AddPermissionUserParams) { + await this.http.fetch('permissions/add_user', { method: 'POST', params }) + } + + @StartActiveSpan() + searchUsers(params: SearchUsersParams) { + return this.http.fetch('users/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createUser(params: CreateUserParams) { + await this.http.fetch('users/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async deactivateUser(params: DeactivateUserParams) { + await this.http.fetch('users/deactivate', { method: 'POST', params }) + } + + @StartActiveSpan() + async revokeUserToken(params: RevokeUserTokenParams) { + await this.http.fetch('user_tokens/revoke', { method: 'POST', params }) + } + + @StartActiveSpan() + generateUserToken(params: GenerateUserTokenParams) { + return this.http.fetch('user_tokens/generate', { method: 'POST', params }).then(res => res.data!) + } + + @StartActiveSpan() + searchProject(params: SearchProjectParams) { + return this.http.fetch('projects/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createProject(params: CreateProjectParams) { + await this.http.fetch('projects/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async deleteProject(params: DeleteProjectParams) { + await this.http.fetch('projects/delete', { method: 'POST', params }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts new file mode 100644 index 0000000000..bcb3143972 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts @@ -0,0 +1,68 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './sonarqube.constants' + +export const projectSelect = { + id: true, + slug: true, + repositories: { + select: { + internalRepoName: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class SonarqubeDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig( + pluginName: string, + key: string, + ): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts new file mode 100644 index 0000000000..8f77620a8c --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -0,0 +1,26 @@ +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +@Injectable() +export class SonarqubeHealthService { + constructor( + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const bearerToken = Buffer.from(`${this.sonarqubeConfig.apiToken}:`, 'utf-8').toString('base64') + headers.Authorization = `Bearer ${bearerToken}` + try { + const response = await fetch(this.sonarqubeConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts new file mode 100644 index 0000000000..6ee07f8585 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts @@ -0,0 +1,103 @@ +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +export interface SonarqubeFetchOptions { + method?: string + params?: Record +} + +export interface SonarqubeResponse { + status: HttpStatus + data: T | null +} + +export type SonarqubeErrorKind = 'NotConfigured' | 'ClientError' | 'ServerError' | 'Unexpected' + +export class SonarqubeError extends Error { + readonly kind: SonarqubeErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + + constructor( + kind: SonarqubeErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string } = {}, + ) { + super(message) + this.name = 'SonarqubeError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + } +} + +@Injectable() +export class SonarqubeHttpClientService { + constructor( + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + ) {} + + private get apiBaseUrl(): string { + return new URL('api/', this.sonarqubeConfig.internalOrPublicUrl).toString() + } + + private get defaultHeaders(): Record { + if (!this.sonarqubeConfig.apiToken) throw new SonarqubeError('NotConfigured', 'SONAR_API_TOKEN is required') + return { + Authorization: `Bearer ${this.sonarqubeConfig.apiToken}`, + } + } + + async fetch(path: string, options: SonarqubeFetchOptions = {}): Promise> { + const span = trace.getActiveSpan() + const method = (options.method ?? 'GET').toUpperCase() + span?.setAttribute('sonarqube.method', method) + span?.setAttribute('sonarqube.path', path) + + const request = this.createRequest(path, method, options.params) + const response = await fetch(request).catch((error) => { + throw new SonarqubeError('Unexpected', error instanceof Error ? error.message : String(error), { method, path }) + }) + + span?.setAttribute('sonarqube.http.status', response.status) + const result = await handleResponse(response) + if (response.status >= 400) { + const kind = response.status >= 500 ? 'ServerError' : 'ClientError' + throw new SonarqubeError(kind, formatErrorMessage(response.status, result.data), { status: response.status, method, path }) + } + return result + } + + private createRequest(path: string, method: string, params?: Record): Request { + const url = new URL(path, this.apiBaseUrl) + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) url.searchParams.append(key, String(value)) + } + } + return new Request(url.toString(), { method, headers: this.defaultHeaders }) + } +} + +function formatErrorMessage(status: number, data: unknown): string { + const errors = (data as { errors?: { msg?: unknown }[] } | null)?.errors + const details = Array.isArray(errors) + ? errors.map(e => e?.msg).filter((msg): msg is string => typeof msg === 'string').join('; ') + : '' + return details + ? `SonarQube API responded with status ${status}: ${details}` + : `SonarQube API responded with status ${status}` +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts new file mode 100644 index 0000000000..671a19562a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { SonarqubePluginService } from './sonarqube-plugin.service' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +describe('sonarqubePluginService', () => { + let service: SonarqubePluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://sonar.public/', + internalUrl: 'https://sonar.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + SonarqubePluginService, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(SonarqubePluginService) + }) + + it('returns the public SonarQube url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://sonar.public/projects') + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts new file mode 100644 index 0000000000..d1ffd9387e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts @@ -0,0 +1,22 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Inject, Injectable } from '@nestjs/common' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +@Injectable() +export class SonarqubePluginService { + constructor( + @Inject(SONARQUBE_CONFIG) + private readonly sonarqubeConfig: SonarqubeConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'sonarqube', + to: () => new URL('projects', this.sonarqubeConfig.url).toString(), + title: 'SonarQube', + imgSrc: '/img/sonarqube.svg', + description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts new file mode 100644 index 0000000000..0b7e8c0bbc --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts @@ -0,0 +1,90 @@ +import type { SonarqubeGeneratedToken, SonarqubeGroup, SonarqubePaging, SonarqubeProject, SonarqubeUser } from './sonarqube-client.service' +import type { ProjectWithDetails } from './sonarqube-datastore.service' +import { faker } from '@faker-js/faker' +import { SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' + +export function makeUserToken(overrides: Partial = {}) { + return { + token: faker.string.uuid(), + login: faker.internet.username(), + name: faker.person.fullName(), + ...overrides, + } satisfies SonarqubeGeneratedToken +} + +export function makeEmptyGroupsResponse() { + return { paging: makeSonarqubePaging(), groups: [] } +} + +export function makeEmptyUsersResponse() { + return { paging: makeSonarqubePaging(), users: [] } +} + +export function makeEmptyProjectsResponse() { + return { paging: makeSonarqubePaging(), components: [] } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.internet.domainWord(), + repositories: [], + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + +export function makeSonarqubeGroup(overrides: Partial = {}): SonarqubeGroup { + return { + id: faker.string.uuid(), + name: faker.internet.domainWord(), + description: '', + membersCount: 0, + default: false, + ...overrides, + } satisfies SonarqubeGroup +} + +export function makeSonarqubeUser(overrides: Partial = {}): SonarqubeUser { + return { + login: faker.internet.username(), + name: faker.person.fullName(), + active: true, + email: faker.internet.email(), + groups: [], + tokensCount: 0, + local: true, + externalIdentity: '', + externalProvider: '', + managed: false, + ...overrides, + } satisfies SonarqubeUser +} + +export function makeSonarqubeProject(overrides: Partial = {}): SonarqubeProject { + return { + key: faker.string.alphanumeric(20), + name: faker.internet.domainWord(), + qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, + visibility: 'private', + ...overrides, + } satisfies SonarqubeProject +} + +export function makeSonarqubePaging(overrides: Partial = {}): SonarqubePaging { + return { + pageIndex: 1, + pageSize: 100, + total: 0, + ...overrides, + } satisfies SonarqubePaging +} + +export function makeSonarqubeGeneratedToken(overrides: Partial = {}): SonarqubeGeneratedToken { + return { + token: faker.string.alphanumeric(40), + login: faker.internet.username(), + name: `Sonar Token for ${faker.internet.username()}`, + ...overrides, + } satisfies SonarqubeGeneratedToken +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts new file mode 100644 index 0000000000..9618e9341e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts @@ -0,0 +1,51 @@ +export const PLUGIN_NAME = 'sonarqube' +export const DEFAULT_PERMISSION_TEMPLATE_NAME = 'Forge Default' + +// SonarQube global permission names +export const GLOBAL_ADMIN_PERMISSIONS = ['admin', 'profileadmin', 'gateadmin', 'scan', 'provisioning'] as const + +// Permission template — grants to project creator and sonar-administrators on new projects +export const DEFAULT_TEMPLATE_PERMISSIONS = ['admin', 'codeviewer', 'issueadmin', 'securityhotspotadmin', 'scan', 'user'] as const + +// Project-level permission sets per role (SonarQube permission API names) +export const PROJECT_ADMIN_PERMISSIONS = ['admin', 'scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_DEVOPS_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_DEVELOPER_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_SECURITY_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_READONLY_PERMISSIONS = ['user', 'codeviewer'] as const + +// CI robot/service account — needs Execute Analysis + Browse + See Source Code +export const ROBOT_PROJECT_PERMISSIONS = ['scan', 'user', 'codeviewer'] as const + +// Default platform-wide Keycloak group paths +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_READONLY_GROUP_PATH = '/console/readonly' +export const DEFAULT_SECURITY_GROUP_PATH = '/console/security' + +// Default project role group path suffixes (appended to /{projectSlug}) +export const DEFAULT_PROJECT_ADMIN_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVOPS_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_SECURITY_SUFFIX = '/console/security' +export const DEFAULT_PROJECT_READONLY_SUFFIX = '/console/readonly' + +// Admin plugin config keys for overriding defaults +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const READONLY_GROUP_PATH_PLUGIN_KEY = 'readonlyGroupPath' +export const SECURITY_GROUP_PATH_PLUGIN_KEY = 'securityGroupPath' +export const PROJECT_ADMIN_SUFFIX_PLUGIN_KEY = 'projectAdminSuffix' +export const PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY = 'projectDevopsSuffix' +export const PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY = 'projectDeveloperSuffix' +export const PROJECT_SECURITY_SUFFIX_PLUGIN_KEY = 'projectSecuritySuffix' +export const PROJECT_READONLY_SUFFIX_PLUGIN_KEY = 'projectReadonlySuffix' + +// SonarQube project qualifier identifiers +export const SONARQUBE_PROJECT_QUALIFIER_APPLICATION = 'APP' +export const SONARQUBE_PROJECT_QUALIFIER_BRANCH = 'BRC' +export const SONARQUBE_PROJECT_QUALIFIER_DIRECTORY = 'DIR' +export const SONARQUBE_PROJECT_QUALIFIER_FILE = 'FIL' +export const SONARQUBE_PROJECT_QUALIFIER_LIBRARY = 'LIB' +export const SONARQUBE_PROJECT_QUALIFIER_PROJECT = 'TRK' +export const SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW = 'SVW' +export const SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST = 'UTS' +export const SONARQUBE_PROJECT_QUALIFIER_VIEW = 'VW' diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts new file mode 100644 index 0000000000..ef4dac617c --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts @@ -0,0 +1,16 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface SonarqubeConfig { + url: string + internalUrl: string + apiToken: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface SonarqubeModuleOptions extends SonarqubeConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('sonarqube') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as SONARQUBE_CONFIG } diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts new file mode 100644 index 0000000000..f054302ff4 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { SonarqubeHealthService } from './sonarqube-health.service' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' +import { SonarqubePluginService } from './sonarqube-plugin.service' +import { ConfigurableModuleClass } from './sonarqube.module-definition' +import { SonarqubeService } from './sonarqube.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule], + providers: [ + SonarqubeHealthService, + SonarqubeHttpClientService, + SonarqubeClientService, + SonarqubeDatastoreService, + SonarqubePluginService, + SonarqubeService, + ], + exports: [SonarqubeClientService, SonarqubeHealthService, SonarqubePluginService, SonarqubeService], +}) +export class SonarqubeModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts new file mode 100644 index 0000000000..e7d0ef90e1 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -0,0 +1,291 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { generateProjectKey } from '../../utils/crypto.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { + makeEmptyGroupsResponse, + makeEmptyProjectsResponse, + makeEmptyUsersResponse, + makeProjectWithDetails, + makeSonarqubePaging, + makeSonarqubeUser, + makeUserToken, +} from './sonarqube-testing.utils' +import { PLUGIN_NAME, SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' +import { SonarqubeService } from './sonarqube.service' + +describe('sonarqubeService', () => { + let service: SonarqubeService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let config: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + searchUserGroup: vi.fn().mockResolvedValue(makeEmptyGroupsResponse()), + createUserGroup: vi.fn().mockResolvedValue(undefined), + createPermissionTemplate: vi.fn().mockResolvedValue(undefined), + searchPermissionTemplates: vi.fn().mockResolvedValue({ permissionTemplates: [] }), + setPermissionDefaultTemplate: vi.fn().mockResolvedValue(undefined), + addPermissionGroupToTemplate: vi.fn().mockResolvedValue(undefined), + addPermissionGroup: vi.fn().mockResolvedValue(undefined), + addPermissionUser: vi.fn().mockResolvedValue(undefined), + searchUsers: vi.fn().mockResolvedValue(makeEmptyUsersResponse()), + createUser: vi.fn().mockResolvedValue(undefined), + deactivateUser: vi.fn().mockResolvedValue(undefined), + revokeUserToken: vi.fn().mockResolvedValue(undefined), + searchProject: vi.fn().mockResolvedValue(makeEmptyProjectsResponse()), + createProject: vi.fn().mockResolvedValue(undefined), + deleteProject: vi.fn().mockResolvedValue(undefined), + }) + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + readSonarqubeUser: vi.fn().mockResolvedValue(null), + writeSonarqubeUser: vi.fn().mockResolvedValue(undefined), + deleteSonarqubeUser: vi.fn().mockResolvedValue(undefined), + }) + config = mockDeep({ + internalOrPublicUrl: 'https://sonarqube.internal', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + SonarqubeService, + { provide: SonarqubeClientService, useValue: client }, + { provide: SonarqubeDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(SonarqubeService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('init', () => { + it('should set up the permission template', async () => { + await service.init() + expect(client.createPermissionTemplate).toHaveBeenCalledWith({ name: 'Forge Default' }) + expect(client.setPermissionDefaultTemplate).toHaveBeenCalledWith({ templateName: 'Forge Default' }) + }) + + it('should not recreate the permission template when it already exists', async () => { + client.searchPermissionTemplates.mockResolvedValue({ + permissionTemplates: [{ id: '1', name: 'Forge Default' }], + }) + await service.init() + expect(client.createPermissionTemplate).not.toHaveBeenCalled() + expect(client.setPermissionDefaultTemplate).toHaveBeenCalledWith({ templateName: 'Forge Default' }) + }) + + it('should create /console/admin group with global permissions when it does not exist', async () => { + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/admin' })) + }) + + it('should create /console/readonly and /console/security platform groups', async () => { + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/readonly' })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/security' })) + }) + + it('should not create groups that already exist', async () => { + client.searchUserGroup.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 1 }), + groups: [{ id: '1', name: '/console/admin', description: '', membersCount: 1, default: false }], + }) + await service.init() + expect(client.createUserGroup).not.toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) + }) + + it('should use custom group paths from admin plugin config', async () => { + datastore.getAdminPluginConfig.mockImplementation((_plugin, key) => { + if (key === 'adminGroupPath') return Promise.resolve('/custom/admin') + return Promise.resolve(null) + }) + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/custom/admin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/custom/admin' })) + }) + }) + + describe('handleUpsert', () => { + it('should create the 5 project role groups in SonarQube', async () => { + const project = makeProjectWithDetails() + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/admin` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/devops` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/developer` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/security` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/readonly` })) + }) + + it('should create a new user and write vault credentials', async () => { + const project = makeProjectWithDetails() + const userToken = makeUserToken({ login: project.slug }) + client.generateUserToken.mockResolvedValue(userToken) + + await service.handleUpsert(project) + + expect(client.createUser).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(client.generateUserToken).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(vault.writeSonarqubeUser).toHaveBeenCalledWith(project.slug, expect.objectContaining({ SONAR_USERNAME: project.slug, SONAR_TOKEN: userToken.token })) + }) + + it('should set role-based permissions on new repositories', async () => { + const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'repo' }] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.createProject).toHaveBeenCalledWith(expect.objectContaining({ visibility: 'private', name: `${project.slug}-repo` })) + expect(client.addPermissionUser).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/admin` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/devops` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/readonly' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/security' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer`, permission: 'issueadmin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer`, permission: 'securityhotspotadmin' })) + }) + + it('should not recreate user or write vault when both user and secret exist', async () => { + const project = makeProjectWithDetails({ slug: 'existing', repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.createUser).not.toHaveBeenCalled() + expect(client.generateUserToken).not.toHaveBeenCalled() + expect(vault.writeSonarqubeUser).not.toHaveBeenCalled() + }) + + it('should rotate token when user exists but vault secret is missing', async () => { + const project = makeProjectWithDetails({ repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + + await service.handleUpsert(project) + + expect(client.createUser).not.toHaveBeenCalled() + expect(client.generateUserToken).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(vault.writeSonarqubeUser).toHaveBeenCalledWith(project.slug, expect.objectContaining({ SONAR_PASSWORD: 'not initialized' })) + }) + + it('should delete sonarqube projects for removed repositories', async () => { + const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'kept' }] }) + const keptKey = generateProjectKey(project.slug, 'kept') + const removedKey = generateProjectKey(project.slug, 'removed') + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 2 }), + components: [ + { key: keptKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + { key: removedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + ], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.deleteProject).toHaveBeenCalledWith({ project: removedKey }) + expect(client.deleteProject).not.toHaveBeenCalledWith({ project: keptKey }) + }) + + it('should not delete sonarqube projects whose key was not generated by the console', async () => { + const project = makeProjectWithDetails({ slug: 'my', repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 2 }), + components: [ + // manually created project, hash suffix does not match generateProjectKey + { key: 'my-manual-project', name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + // belongs to project "my-app" (repo "x"), not to project "my" + { key: generateProjectKey('my-app', 'x'), name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + ], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.deleteProject).not.toHaveBeenCalled() + }) + + it('should use comma-separated group path suffixes from project plugin config', async () => { + const project = makeProjectWithDetails({ + repositories: [{ internalRepoName: 'repo' }], + plugins: [{ pluginName: PLUGIN_NAME, key: 'projectAdminSuffix', value: '/console/admin,/console/owner' }], + }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/admin` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/owner` })) + }) + }) + + describe('handleDelete', () => { + it('should delete sonarqube projects, anonymize user and remove vault entry', async () => { + const project = makeProjectWithDetails({ slug: 'doomed' }) + const doomedKey = generateProjectKey('doomed', 'repo') + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 1 }), + components: [{ key: doomedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: 'doomed' })] }) + + await service.handleDelete(project) + + expect(client.deleteProject).toHaveBeenCalledWith({ project: doomedKey }) + expect(client.deactivateUser).toHaveBeenCalledWith({ login: 'doomed', anonymize: true }) + expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('doomed') + }) + + it('should skip anonymization when the user does not exist', async () => { + const project = makeProjectWithDetails({ slug: 'no-user' }) + + await service.handleDelete(project) + + expect(client.deactivateUser).not.toHaveBeenCalled() + expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('no-user') + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects and run init', async () => { + const projects = [ + makeProjectWithDetails({ repositories: [] }), + makeProjectWithDetails({ repositories: [] }), + ] + datastore.getAllProjects.mockResolvedValue(projects) + client.generateUserToken.mockImplementation(({ login }) => Promise.resolve(makeUserToken({ login }))) + + await service.handleCron() + + expect(client.searchProject).toHaveBeenCalledTimes(2) + expect(client.createPermissionTemplate).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts new file mode 100644 index 0000000000..a4500dcca9 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -0,0 +1,456 @@ +import type { OnModuleInit } from '@nestjs/common' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { SonarqubeUserSecret } from '../vault/vault-client.service' +import type { SonarqubeProjectResult, SonarqubeUser } from './sonarqube-client.service' +import type { ProjectWithDetails } from './sonarqube-datastore.service' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { generateProjectKey, generateRandomPassword } from '../../utils/crypto.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_PERMISSION_TEMPLATE_NAME, + DEFAULT_PROJECT_ADMIN_SUFFIX, + DEFAULT_PROJECT_DEVELOPER_SUFFIX, + DEFAULT_PROJECT_DEVOPS_SUFFIX, + DEFAULT_PROJECT_READONLY_SUFFIX, + DEFAULT_PROJECT_SECURITY_SUFFIX, + DEFAULT_READONLY_GROUP_PATH, + DEFAULT_SECURITY_GROUP_PATH, + DEFAULT_TEMPLATE_PERMISSIONS, + GLOBAL_ADMIN_PERMISSIONS, + PLUGIN_NAME, + PROJECT_ADMIN_PERMISSIONS, + PROJECT_ADMIN_SUFFIX_PLUGIN_KEY, + PROJECT_DEVELOPER_PERMISSIONS, + PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_PERMISSIONS, + PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY, + PROJECT_READONLY_PERMISSIONS, + PROJECT_READONLY_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_PERMISSIONS, + PROJECT_SECURITY_SUFFIX_PLUGIN_KEY, + READONLY_GROUP_PATH_PLUGIN_KEY, + ROBOT_PROJECT_PERMISSIONS, + SECURITY_GROUP_PATH_PLUGIN_KEY, +} from './sonarqube.constants' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +interface SonarqubeRolePaths { + admin: string[] + devops: string[] + developer: string[] + security: string[] + readonly: string[] +} + +@Injectable() +export class SonarqubeService implements OnModuleInit { + private readonly logger = new Logger(SonarqubeService.name) + + constructor( + @Inject(SonarqubeDatastoreService) private readonly datastore: SonarqubeDatastoreService, + @Inject(SonarqubeClientService) private readonly client: SonarqubeClientService, + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('SonarqubeService initialized') + } + + async onModuleInit() { + await this.init().catch(error => this.logger.error({ error }, 'SonarQube initialization failed')) + } + + @StartActiveSpan() + async init(): Promise { + this.logger.log('Initializing SonarQube platform configuration') + const adminGroupPath = await this.getAdminGroupPath() + const [readonlyGroupPath, securityGroupPath] = await Promise.all([ + this.getReadonlyGroupPath(), + this.getSecurityGroupPath(), + ]) + await this.ensureDefaultPermissionTemplate() + await Promise.all([ + this.ensureGroupWithGlobalPermissions(adminGroupPath, GLOBAL_ADMIN_PERMISSIONS), + this.ensureGroup(readonlyGroupPath), + this.ensureGroup(securityGroupPath), + ]) + this.logger.log('SonarQube platform configuration initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('sonarqube', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`SonarQube sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('sonarqube', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.deleteProjectGroup(project) + this.logger.log(`SonarQube deletion completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting SonarQube reconciliation') + await this.init().catch(error => this.logger.error('SonarQube init during cron failed', error)) + const projects = await this.datastore.getAllProjects() + span?.setAttribute('sonarqube.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for SonarQube reconciliation`) + await this.ensureProjectGroups(projects) + this.logger.log(`SonarQube reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('sonarqube.projects.count', projects.length) + await Promise.all(projects.map(p => + this.ensureProjectGroup(p).catch(error => + this.logger.error(`Failed to reconcile SonarQube project (slug=${p.slug})`, error), + ), + )) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const rolePaths = await this.getProjectRoleGroupPaths(project) + await Promise.all([ + this.ensureUser(project.slug, project.slug), + this.ensureProjectSonarGroups(rolePaths), + this.ensureProjectRepositories(project, rolePaths), + ]) + } + + @StartActiveSpan() + private async deleteProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const sonarProjects = await this.findProjectsForSlug(project.slug) + span?.setAttribute('sonarqube.projects.count', sonarProjects.length) + this.logger.log(`Deleting ${sonarProjects.length} SonarQube repositories for project ${project.slug}`) + + await Promise.all(sonarProjects.map(async (sp) => { + await this.client.deleteProject({ project: sp.key }) + this.logger.verbose(`Deleted SonarQube repository (key=${sp.key})`) + })) + + const user = await this.findUser(project.slug) + if (user) { + await this.client.deactivateUser({ login: project.slug, anonymize: true }) + this.logger.log(`Anonymized SonarQube user (login=${project.slug})`) + } else { + this.logger.verbose(`SonarQube user not found, skipping anonymization (login=${project.slug})`) + } + + await this.vault.deleteSonarqubeUser(project.slug) + this.logger.verbose(`Deleted SonarQube vault credentials (slug=${project.slug})`) + } + + @StartActiveSpan() + private async ensureDefaultPermissionTemplate(): Promise { + this.logger.verbose(`Ensuring SonarQube permission template (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + const { permissionTemplates } = await this.client.searchPermissionTemplates({ q: DEFAULT_PERMISSION_TEMPLATE_NAME }) + + if (permissionTemplates.some(t => t.name.toLowerCase() === DEFAULT_PERMISSION_TEMPLATE_NAME.toLowerCase())) { + this.logger.verbose(`SonarQube permission template already exists (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } else { + await this.client.createPermissionTemplate({ name: DEFAULT_PERMISSION_TEMPLATE_NAME }) + this.logger.log(`Created SonarQube permission template (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } + await Promise.all(DEFAULT_TEMPLATE_PERMISSIONS.map(permission => + this.client.addPermissionProjectCreatorToTemplate({ templateName: DEFAULT_PERMISSION_TEMPLATE_NAME, permission }), + )) + await Promise.all(DEFAULT_TEMPLATE_PERMISSIONS.map(permission => + this.client.addPermissionGroupToTemplate({ groupName: 'sonar-administrators', templateName: DEFAULT_PERMISSION_TEMPLATE_NAME, permission }), + )) + await this.client.setPermissionDefaultTemplate({ templateName: DEFAULT_PERMISSION_TEMPLATE_NAME }) + this.logger.log(`SonarQube permission template ensured (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } + + @StartActiveSpan() + private async ensureUser(username: string, projectSlug: string): Promise { + const existingSecret = await this.vault.readSonarqubeUser(projectSlug) + const user = await this.findUser(username) + let newSecret: SonarqubeUserSecret | undefined + + if (!user) { + this.logger.log(`Creating SonarQube user (login=${username})`) + const password = generateRandomPassword(30) + await this.client.createUser({ email: `${projectSlug}@${projectSlug}`, local: 'true', login: username, name: username, password }) + const token = await this.rotateToken(username) + newSecret = { SONAR_USERNAME: username, SONAR_PASSWORD: password, SONAR_TOKEN: token } + } else if (existingSecret) { + this.logger.verbose(`SonarQube user already exists with vault credentials (login=${username})`) + } else { + this.logger.warn(`SonarQube user exists but vault secret is missing, rotating token (login=${username})`) + const token = await this.rotateToken(username) + newSecret = { SONAR_USERNAME: username, SONAR_PASSWORD: 'not initialized', SONAR_TOKEN: token } + } + + if (newSecret) { + await this.vault.writeSonarqubeUser(projectSlug, newSecret) + this.logger.log(`Stored SonarQube credentials in vault (slug=${projectSlug})`) + } + } + + private async ensureProjectSonarGroups(rolePaths: SonarqubeRolePaths): Promise { + const allGroups = [ + ...rolePaths.admin, + ...rolePaths.devops, + ...rolePaths.developer, + ...rolePaths.security, + ...rolePaths.readonly, + ] + await Promise.all(allGroups.map(group => this.ensureGroup(group))) + } + + @StartActiveSpan() + private async ensureProjectRepositories(project: ProjectWithDetails, rolePaths: SonarqubeRolePaths): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('repositories.count', project.repositories.length) + + const [readonlyGroupPath, securityGroupPath, existingSonarProjects] = await Promise.all([ + this.getReadonlyGroupPath(), + this.getSecurityGroupPath(), + this.findProjectsForSlug(project.slug), + ]) + + const orphans = existingSonarProjects.filter(sp => !project.repositories.some(r => r.internalRepoName === sp.repository)) + if (orphans.length) this.logger.log(`Removing ${orphans.length} orphan SonarQube repositories for project ${project.slug}`) + + await Promise.all([ + ...orphans.map(async (sp) => { + await this.client.deleteProject({ project: sp.key }) + this.logger.verbose(`Deleted orphan SonarQube repository (key=${sp.key})`) + }), + ...project.repositories.map(async (repository) => { + const projectKey = generateProjectKey(project.slug, repository.internalRepoName) + if (!existingSonarProjects.some(sp => sp.repository === repository.internalRepoName)) { + await this.client.createProject({ + project: projectKey, + visibility: 'private', + name: `${project.slug}-${repository.internalRepoName}`, + mainbranch: 'main', + }) + this.logger.log(`Created SonarQube repository (key=${projectKey})`) + } + await this.ensureProjectPermissions(projectKey, project.slug, rolePaths, readonlyGroupPath, securityGroupPath) + this.logger.verbose(`Ensured permissions on SonarQube repository (key=${projectKey})`) + }), + ]) + } + + private async ensureProjectPermissions( + projectKey: string, + login: string, + rolePaths: SonarqubeRolePaths, + readonlyGroupPath: string, + securityGroupPath: string, + ): Promise { + await Promise.all([ + ...ROBOT_PROJECT_PERMISSIONS.map(permission => + this.client.addPermissionUser({ projectKey, permission, login }), + ), + ...buildGroupPermissions(rolePaths, readonlyGroupPath, securityGroupPath).flatMap(({ groupName, permissions }) => + permissions.map(permission => this.client.addPermissionGroup({ projectKey, permission, groupName })), + ), + ]) + } + + private async ensureGroupWithGlobalPermissions(groupName: string, permissions: readonly string[]): Promise { + await this.ensureGroup(groupName) + await Promise.all(permissions.map(permission => + this.client.addPermissionGroup({ groupName, permission }), + )) + } + + private async ensureGroup(groupName: string): Promise { + const result = await this.client.searchUserGroup({ q: groupName }) + if (result.groups.some(g => g.name === groupName)) { + this.logger.verbose(`SonarQube group already exists (name=${groupName})`) + } else { + await this.client.createUserGroup({ name: groupName }) + this.logger.log(`Created SonarQube group (name=${groupName})`) + } + } + + private async rotateToken(login: string): Promise { + const name = `Sonar Token for ${login}` + await this.client.revokeUserToken({ login, name }).catch(() => {}) + const { token } = await this.client.generateUserToken({ login, name }) + this.logger.log(`Rotated SonarQube token (login=${login})`) + return token + } + + private async getAdminGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, ADMIN_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getReadonlyGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, READONLY_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_READONLY_GROUP_PATH + } + + private async getSecurityGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, SECURITY_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_SECURITY_GROUP_PATH + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return getProjectPluginConfig(project, key) ?? undefined + } + + private async getProjectRoleGroupPaths(project: ProjectWithDetails): Promise { + const [admin, devops, developer, security, readonly] = await Promise.all([ + this.getProjectAdminGroupPaths(project), + this.getProjectDevopsGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectSecurityGroupPaths(project), + this.getProjectReadonlyGroupPaths(project), + ]) + return { admin, devops, developer, security, readonly } + } + + private async getProjectAdminGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_ADMIN_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_ADMIN_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_ADMIN_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDevopsGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVOPS_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVELOPER_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectSecurityGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_SECURITY_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_SECURITY_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_SECURITY_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectReadonlyGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_READONLY_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_READONLY_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_READONLY_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async findUser(login: string): Promise { + let page = 1 + const pageSize = 100 + while (true) { + const response = await this.client.searchUsers({ q: login, ps: pageSize, p: page }) + const found = response.users.find(u => u.login === login) + if (found) return found + if (!response.users.length || response.paging.pageIndex * response.paging.pageSize >= response.paging.total) return undefined + page++ + } + } + + private async findProjectsForSlug(projectSlug: string): Promise { + let found: SonarqubeProjectResult[] = [] + let page = 0 + const pageSize = 100 + let total = 0 + do { + page++ + const result = await this.client.searchProject({ q: projectSlug, p: page, ps: pageSize }) + total = result.paging.total + found = [...found, ...filterProjectsOwningSlug(result.components, projectSlug)] + } while (page * pageSize < total) + return found + } +} + +function getProjectPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string): string[] { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(suffix => `/${projectSlug}${suffix}`) +} + +function buildGroupPermissions( + rolePaths: SonarqubeRolePaths, + readonlyGroupPath: string, + securityGroupPath: string, +): { groupName: string, permissions: readonly string[] }[] { + return [ + ...rolePaths.admin.map(groupName => ({ groupName, permissions: PROJECT_ADMIN_PERMISSIONS })), + ...rolePaths.devops.map(groupName => ({ groupName, permissions: PROJECT_DEVOPS_PERMISSIONS })), + ...rolePaths.developer.map(groupName => ({ groupName, permissions: PROJECT_DEVELOPER_PERMISSIONS })), + ...rolePaths.security.map(groupName => ({ groupName, permissions: PROJECT_SECURITY_PERMISSIONS })), + ...rolePaths.readonly.map(groupName => ({ groupName, permissions: PROJECT_READONLY_PERMISSIONS })), + { groupName: securityGroupPath, permissions: PROJECT_SECURITY_PERMISSIONS }, + { groupName: readonlyGroupPath, permissions: PROJECT_READONLY_PERMISSIONS }, + ] +} + +function filterProjectsOwningSlug( + components: { key: string }[], + projectSlug: string, +): SonarqubeProjectResult[] { + return components.reduce((acc, { key: sonarKey }) => { + const parts = sonarKey.split('-') + parts.pop() + for (let i = parts.length - 1; i > 0; i--) { + const project = parts.slice(0, i).join('-') + const repository = parts.slice(i).join('-') + // recompute the key (with its HMAC suffix) so keys not managed by the + // console, or owned by a project whose slug is a prefix, are never claimed + if (generateProjectKey(project, repository) === sonarKey && project === projectSlug) { + acc.push({ projectSlug, repository, key: sonarKey }) + break + } + } + return acc + }, []) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts new file mode 100644 index 0000000000..8b8fc8adff --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts @@ -0,0 +1,6 @@ +export function sonarProjectPropertiesFile(projectKey: string) { + return [ + `sonar.projectKey=${projectKey}`, + 'sonar.qualitygate.wait=true', + ] +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts new file mode 100644 index 0000000000..f4450b21d7 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts @@ -0,0 +1,11 @@ +import type { Prisma } from '@prisma/client' + +export const adminPluginSelect = { + pluginName: true, + key: true, + value: true, +} satisfies Prisma.AdminPluginSelect + +export type AdminPluginSelect = Prisma.AdminPluginGetPayload<{ + select: typeof adminPluginSelect +}> diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config.controller.ts new file mode 100644 index 0000000000..7f74d4d40d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config.controller.ts @@ -0,0 +1,27 @@ +import type { PluginsUpdateBody } from '@cpn-console/shared' +import { pluginUpdateBody } from '@cpn-console/shared' +import { Body, Controller, Get, HttpCode, HttpStatus, Inject, Post, UseGuards } from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { SystemConfigService } from './system-config.service' + +@Controller('api/v1/system/plugins') +export class SystemConfigController { + constructor(@Inject(SystemConfigService) private readonly service: SystemConfigService) {} + + @Get() + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async get() { + return this.service.list() + } + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async update(@Body(new ZodValidationPipe(pluginUpdateBody)) body: PluginsUpdateBody) { + await this.service.update(body) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config.module.ts new file mode 100644 index 0000000000..b0622abf84 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { SystemConfigController } from './system-config.controller' +import { SystemConfigService } from './system-config.service' + +@Module({ + imports: [DatabaseModule, UserPermissionModule], + controllers: [SystemConfigController], + providers: [SystemConfigService], +}) +export class SystemConfigModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts new file mode 100644 index 0000000000..0ba0742991 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts @@ -0,0 +1,41 @@ +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { SystemConfigService } from './system-config.service' + +describe('systemConfigService', () => { + let prisma: ReturnType> + let service: SystemConfigService + + beforeEach(() => { + prisma = mockDeep() + service = new SystemConfigService(prisma) + }) + + it('returns plugin config items', async () => { + prisma.adminPlugin.findMany.mockResolvedValue([ + { pluginName: 'argocd', key: 'url', value: 'https://argocd' }, + ]) + + const result = await service.list() + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + }) + + it('updates plugins config from body via transaction', async () => { + prisma.$transaction.mockImplementation(async (callback: any) => callback(prisma)) + prisma.adminPlugin.upsert.mockResolvedValue({ pluginName: 'argocd', key: 'url', value: 'https://argocd' }) + + await service.update({ argocd: { url: 'https://argocd' } }) + + expect(prisma.$transaction).toHaveBeenCalledTimes(1) + }) + + it('does not call upsert for empty body', async () => { + prisma.$transaction.mockImplementation(async (callback: any) => callback(prisma)) + + await service.update({}) + + expect(prisma.adminPlugin.upsert).toHaveBeenCalledTimes(0) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config.service.ts new file mode 100644 index 0000000000..100cee22bc --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-config/system-config.service.ts @@ -0,0 +1,46 @@ +import type { PluginsUpdateBody } from '@cpn-console/shared' +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { BadRequestException, Injectable } from '@nestjs/common' + +@Injectable() +export class SystemConfigService { + constructor(private readonly prisma: PrismaService) {} + + async list() { + const globalConfig = await this.prisma.adminPlugin.findMany({ + select: { key: true, value: true, pluginName: true }, + }) + + return Object.values(servicesInfos).map(({ name, title, imgSrc, description }) => { + const manifest = populatePluginManifests({ + data: { global: globalConfig }, + permissionTarget: 'admin', + pluginName: name, + select: { global: true, project: false }, + }) + return { imgSrc, title, name, manifest: manifest.global ?? [], description } + }).filter(plugin => plugin.manifest.length > 0) + } + + async update(body: PluginsUpdateBody) { + const parsedData = editStrippers.global.safeParse(body) + if (!parsedData.success) { + throw new BadRequestException(parsedData.error.message) + } + + const records = Object.entries(parsedData.data) + .flatMap(([pluginName, values]: [string, Record]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value: String(value) }))) + + await this.prisma.$transaction(async (tx) => { + for (const { pluginName, key, value } of records) { + await tx.adminPlugin.upsert({ + create: { pluginName, key, value }, + update: { key, value, pluginName }, + where: { pluginName_key: { pluginName, key } }, + }) + } + }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts new file mode 100644 index 0000000000..bdc5e91f25 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts @@ -0,0 +1,15 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +export function makeSystemSetting() { + return { + key: faker.string.alphanumeric(), + value: faker.string.alphanumeric(), + } satisfies SystemSetting +} + +export function makeSystemSettings() { + return faker.helpers.multiple(() => makeSystemSetting(), { + count: faker.number.int({ min: 1, max: 10 }), + }) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts new file mode 100644 index 0000000000..9644dc97f0 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts @@ -0,0 +1,24 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { SystemSettingSchema } from '@cpn-console/shared' +import { Body, Controller, Get, Inject, Put, Query } from '@nestjs/common' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { SystemSettingsService } from './system-settings.service' + +@Controller('api/v1/system/settings') +export class SystemSettingsController { + constructor(@Inject(SystemSettingsService) private readonly service: SystemSettingsService) {} + + @Get() + async list( + @Query() query: string, + ) { + return this.service.list(query) + } + + @Put(':key') + async upsert( + @Body(new ZodValidationPipe(SystemSettingSchema)) data: SystemSetting, + ) { + return this.service.upsert(data) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts new file mode 100644 index 0000000000..704b575e14 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { SystemSettingsController } from './system-settings.controller' +import { SystemSettingsService } from './system-settings.service' + +@Module({ + imports: [DatabaseModule], + controllers: [SystemSettingsController], + providers: [SystemSettingsService], + exports: [SystemSettingsService], +}) +export class SystemSettingsModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts new file mode 100644 index 0000000000..ae9ec6e161 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts @@ -0,0 +1,84 @@ +import type { TestingModule } from '@nestjs/testing' +import type { PrismaClient } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeSystemSetting, makeSystemSettings } from './system-settings-testing.utils' +import { SystemSettingsService } from './system-settings.service' + +describe('systemSettingsService', () => { + let module: TestingModule + let service: SystemSettingsService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + SystemSettingsService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(SystemSettingsService) + }) + + describe('list', () => { + it('should return all settings', async () => { + const systemSettings = makeSystemSettings() + + prisma.systemSetting.findMany.mockResolvedValue(systemSettings) + + const result = await service.list() + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: undefined } }) + expect(result).toEqual(systemSettings) + }) + + it('should return empty array if no settings are found', async () => { + prisma.systemSetting.findMany.mockResolvedValue([]) + + const result = await service.list() + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: undefined } }) + expect(result).toEqual([]) + }) + + it('should return setting by key', async () => { + const systemSettings = makeSystemSettings() + const systemSetting = faker.helpers.arrayElement(systemSettings) + prisma.systemSetting.findMany.mockResolvedValue([systemSetting]) + + const result = await service.list(systemSetting.key) + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: systemSetting.key } }) + expect(result).toEqual([systemSetting]) + }) + + it('should return empty array if key is not found', async () => { + const key = faker.string.alphanumeric(10) + prisma.systemSetting.findMany.mockResolvedValue([]) + + const result = await service.list(key) + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key } }) + expect(result).toEqual([]) + }) + }) + + describe('upsert', () => { + it('should update setting if it exists', async () => { + const systemSetting = makeSystemSetting() + prisma.systemSetting.upsert.mockResolvedValue(systemSetting) + + const result = await service.upsert(systemSetting) + + expect(result.key).toBe(systemSetting.key) + expect(result.value).toBe(systemSetting.value) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts new file mode 100644 index 0000000000..06c5e72603 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts @@ -0,0 +1,20 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +@Injectable() +export class SystemSettingsService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(key?: string): Promise { + return this.prisma.systemSetting.findMany({ where: { key } }) + } + + async upsert(data: SystemSetting): Promise { + return this.prisma.systemSetting.upsert({ + create: data, + update: data, + where: { key: data.key }, + }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts new file mode 100644 index 0000000000..d6cce4272f --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts @@ -0,0 +1,109 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from './vault-client.service' +import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { VAULT_CONFIG } from './vault.module-definition' + +const vaultUrl = 'https://vault.internal' + +const server = setupServer( + http.post(`${vaultUrl}/v1/auth/token/create`, () => { + return HttpResponse.json({ auth: { client_token: 'token' } }) + }), + http.get(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ data: { data: { secret: 'value' }, metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 } } }) + }), + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({}) + }), + http.delete(`${vaultUrl}/v1/kv/metadata/:path`, () => { + return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) + }), +) + +describe('vault', () => { + let service: VaultClientService + + beforeAll(() => server.listen()) + beforeEach(async () => { + const config = mockDeep({ + token: 'token', + url: vaultUrl, + internalUrl: vaultUrl, + kvName: 'kv', + internalOrPublicUrl: vaultUrl, + }) + const baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + + const module = await Test.createTestingModule({ + providers: [ + VaultClientService, + VaultHttpClientService, + { provide: VAULT_CONFIG, useValue: config }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(VaultClientService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + describe('read', () => { + it('should read secret', async () => { + const result = await service.read('path') + expect(result).toEqual({ + data: { secret: 'value' }, + metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 }, + }) + }) + + it('should throw if 404', async () => { + server.use( + http.get(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.read('path')).rejects.toBeInstanceOf(VaultError) + await expect(service.read('path')).rejects.toMatchObject({ kind: 'NotFound', status: HttpStatus.NOT_FOUND }) + }) + }) + + describe('write', () => { + it('should write secret', async () => { + await expect(service.write({ secret: 'value' }, 'path')).resolves.toBeUndefined() + }) + + it('should expose reasons on error', async () => { + server.use( + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ errors: ['No secret engine mount at test-project/'] }, { status: HttpStatus.BAD_REQUEST }) + }), + ) + + await expect(service.write({ secret: 'value' }, 'path')).rejects.toBeInstanceOf(VaultError) + await expect(service.write({ secret: 'value' }, 'path')).rejects.toMatchObject({ + kind: 'HttpError', + status: HttpStatus.BAD_REQUEST, + reasons: ['No secret engine mount at test-project/'], + }) + await expect(service.write({ secret: 'value' }, 'path')).rejects.toThrow('Request failed') + }) + }) + + describe('delete', () => { + it('should delete secret', async () => { + await expect(service.delete('path')).resolves.toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-client.service.ts new file mode 100644 index 0000000000..dd07abebbd --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -0,0 +1,410 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { VAULT_CONFIG } from './vault.module-definition' +import { generateGitlabMirrorCredPath, generateSonarqubeCredPath, generateTechReadOnlyCredPath } from './vault.utils' + +export interface VaultSysPoliciesAclUpsertRequest { + policy: string +} + +export interface VaultSysMountCreateRequest { + type: string + config: { + force_no_cache: boolean + } + options: { + version: number + } +} + +export interface VaultSysMountTuneRequest { + options: { + version: number + } +} + +export interface VaultAuthApproleRoleUpsertRequest { + secret_id_num_uses: string + secret_id_ttl: string + token_max_ttl: string + token_num_uses: string + token_ttl: string + token_type: string + token_policies: string[] +} + +export interface VaultIdentityGroupUpsertRequest { + name: string + type: string + policies: string[] +} + +export interface VaultIdentityGroupAliasCreateRequest { + name: string + mount_accessor: string + canonical_id: string +} + +export interface VaultAuthMethod { + accessor: string + type: string + description?: string +} + +export interface VaultSysAuthResponse { + data: Record +} + +export interface VaultIdentityGroupResponse { + data: { + id: string + name: string + alias?: { + id?: string + name?: string + } + } +} + +export interface SonarqubeUserSecret { + SONAR_USERNAME: string + SONAR_PASSWORD: string + SONAR_TOKEN: string +} + +export interface VaultMetadata { + created_time: string + custom_metadata: Record | null + deletion_time: string + destroyed: boolean + version: number +} + +export interface VaultSecret { + data: T + metadata: VaultMetadata +} + +export interface VaultResponse { + data: VaultSecret +} + +export interface VaultListResponse { + data: { + keys: string[] + } +} + +export interface VaultRoleIdResponse { + data: { + role_id: string + } +} + +export interface VaultSecretIdResponse { + data: { + secret_id: string + } +} + +@Injectable() +export class VaultClientService { + private readonly logger = new Logger(VaultClientService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultHttpClientService) private readonly http: VaultHttpClientService, + ) { + } + + @StartActiveSpan() + async getKvData(kvName: string, path: string): Promise> { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Reading Vault KV data (kvName=${kvName}, path=${path})`) + const response = await this.http.fetch>(`${kvName}/data/${path}`) + if (!response?.data) { + throw new VaultError('InvalidResponse', 'Missing "data" field', { method: 'GET', path: `${kvName}/data/${path}` }) + } + return response.data + } + + @StartActiveSpan() + async upsertKvData(kvName: string, path: string, body: { data: T }): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Writing Vault KV data (kvName=${kvName}, path=${path})`) + await this.http.fetch(`${kvName}/data/${path}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async read(path: string): Promise> { + this.logger.debug(`Reading Vault KV secret at ${path}`) + return await this.getKvData(this.vaultConfig.kvName, path) + } + + @StartActiveSpan() + async write(data: T, path: string): Promise { + this.logger.debug(`Writing Vault KV secret at ${path}`) + await this.upsertKvData(this.vaultConfig.kvName, path, { data }) + } + + @StartActiveSpan() + async delete(path: string): Promise { + this.logger.debug(`Deleting Vault KV secret at ${path}`) + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.path', path) + return await this.deleteKvMetadata(this.vaultConfig.kvName, path) + } + + @StartActiveSpan() + async readGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Reading Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + return await this.read(vaultCredsPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeGitlabMirrorCreds(projectSlug: string, repoName: string, data: Record): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Writing Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + await this.write(data, vaultCredsPath) + } + + @StartActiveSpan() + async deleteGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Deleting Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + await this.delete(vaultCredsPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + }) + } + + @StartActiveSpan() + async readTechnReadOnlyCreds(projectSlug: string): Promise { + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + return await this.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeTechReadOnlyCreds(projectSlug: string, creds: Record): Promise { + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + await this.write(creds, vaultPath) + } + + @StartActiveSpan() + async readSonarqubeUser(projectSlug: string): Promise | null> { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Reading Vault SonarQube user credentials (projectSlug=${projectSlug})`) + return await this.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeSonarqubeUser(projectSlug: string, secret: SonarqubeUserSecret): Promise { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Writing Vault SonarQube user credentials (projectSlug=${projectSlug})`) + await this.write(secret, vaultPath) + } + + @StartActiveSpan() + async deleteSonarqubeUser(projectSlug: string): Promise { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Deleting Vault SonarQube user credentials (projectSlug=${projectSlug})`) + await this.delete(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + }) + } + + @StartActiveSpan() + async writeMirrorTriggerToken(secret: Record): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.path', 'GITLAB') + await this.write(secret, 'GITLAB') + } + + @StartActiveSpan() + async deleteKvMetadata(kvName: string, path: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + try { + await this.http.fetch(`${kvName}/metadata/${path}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + } + + @StartActiveSpan() + async listKvMetadata(kvName: string, path: string): Promise { + try { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Listing Vault KV metadata (kvName=${kvName}, path=${path})`) + const response = await this.http.fetch(`${kvName}/metadata/${path}`, { method: 'LIST' }) + if (!response?.data?.keys) { + throw new VaultError('InvalidResponse', 'Missing "data.keys" field', { method: 'LIST', path: `${kvName}/metadata/${path}` }) + } + return response.data.keys + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return [] + throw error + } + } + + @StartActiveSpan() + async upsertSysPoliciesAcl(policyName: string, body: VaultSysPoliciesAclUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault ACL policy ${policyName}`) + await this.http.fetch(`sys/policies/acl/${policyName}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSysPoliciesAcl(policyName: string): Promise { + this.logger.verbose(`Deleting Vault ACL policy ${policyName}`) + await this.http.fetch(`sys/policies/acl/${policyName}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async createSysMount(name: string, body: VaultSysMountCreateRequest): Promise { + this.logger.verbose(`Creating Vault mount ${name} (version=${body.options.version})`) + await this.http.fetch(`sys/mounts/${name}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async tuneSysMount(name: string, body: VaultSysMountTuneRequest): Promise { + this.logger.verbose(`Tuning Vault mount ${name} (version=${body.options.version})`) + await this.http.fetch(`sys/mounts/${name}/tune`, { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSysMounts(name: string): Promise { + this.logger.verbose(`Deleting Vault mount ${name}`) + await this.http.fetch(`sys/mounts/${name}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async upsertAuthApproleRole(roleName: string, body: VaultAuthApproleRoleUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault AppRole ${roleName} (policies=${body.token_policies.length})`) + await this.http.fetch(`auth/approle/role/${roleName}`, { + method: 'POST', + body, + }) + } + + @StartActiveSpan() + async deleteAuthApproleRole(roleName: string): Promise { + this.logger.verbose(`Deleting Vault AppRole ${roleName}`) + await this.http.fetch(`auth/approle/role/${roleName}`, { method: 'DELETE' }) + } + + async getAuthApproleRoleRoleId(roleName: string) { + const path = `auth/approle/role/${roleName}/role-id` + this.logger.verbose(`Reading Vault AppRole role-id for ${roleName}`) + const response = await this.http.fetch(path) + const roleId = response?.data?.role_id + if (!roleId) { + throw new VaultError('InvalidResponse', `Vault role-id not found for role ${roleName}`, { method: 'GET', path }) + } + return roleId + } + + @StartActiveSpan() + async createAuthApproleRoleSecretId(roleName: string) { + const path = `auth/approle/role/${roleName}/secret-id` + this.logger.verbose(`Creating Vault AppRole secret-id for ${roleName}`) + const response = await this.http.fetch(path, { method: 'POST' }) + const secretId = response?.data?.secret_id + if (!secretId) { + throw new VaultError('InvalidResponse', `Vault secret-id not generated for role ${roleName}`, { method: 'POST', path }) + } + return secretId + } + + async getSysAuth(): Promise> { + this.logger.verbose('Listing Vault auth methods') + const response = await this.http.fetch('sys/auth') + return response?.data ?? {} + } + + @StartActiveSpan() + async upsertIdentityGroupName(groupName: string, body: VaultIdentityGroupUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault identity group ${groupName} (policies=${body.policies.length})`) + await this.http.fetch(`identity/group/name/${groupName}`, { + method: 'POST', + body, + }) + } + + @StartActiveSpan() + async getIdentityGroupName(groupName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.name', groupName) + const path = `identity/group/name/${groupName}` + const response = await this.http.fetch(path) + if (!response) throw new VaultError('InvalidResponse', 'Empty response', { method: 'GET', path }) + return response + } + + @StartActiveSpan() + async deleteIdentityGroupName(groupName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.name', groupName) + this.logger.verbose(`Deleting Vault identity group ${groupName}`) + await this.http.fetch(`identity/group/name/${groupName}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async createIdentityGroupAlias(body: VaultIdentityGroupAliasCreateRequest): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.alias', body.name) + this.logger.verbose(`Creating Vault identity group alias (aliasName=${body.name}, canonicalId=${body.canonical_id})`) + await this.http.fetch('identity/group-alias', { method: 'POST', body }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts new file mode 100644 index 0000000000..dfb48d9a71 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts @@ -0,0 +1,84 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + environments: { + select: { + id: true, + clusterId: true, + cpu: true, + memory: true, + autosync: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export const zoneSelect = { + id: true, + slug: true, + clusters: { + select: { + projects: { + select: { + id: true, + }, + }, + }, + }, +} satisfies Prisma.ZoneSelect + +export type ZoneWithDetails = Prisma.ZoneGetPayload<{ + select: typeof zoneSelect +}> + +@Injectable() +export class VaultDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ where: { id }, select: projectSelect }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } + + async getAllZones(): Promise { + return this.prisma.zone.findMany({ + select: zoneSelect, + }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-health.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-health.service.ts new file mode 100644 index 0000000000..daddabd5b4 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-health.service.ts @@ -0,0 +1,23 @@ +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { VAULT_CONFIG } from './vault.module-definition' + +@Injectable() +export class VaultHealthService { + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.vaultConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts new file mode 100644 index 0000000000..2a80532efe --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts @@ -0,0 +1,135 @@ +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus, Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VAULT_CONFIG } from './vault.module-definition' + +export interface VaultFetchOptions { + method?: string + body?: unknown +} + +export type VaultErrorKind + = | 'NotConfigured' + | 'NotFound' + | 'HttpError' + | 'InvalidResponse' + | 'ParseError' + | 'Unexpected' + +export class VaultError extends Error { + readonly kind: VaultErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + readonly reasons?: string[] + + constructor( + kind: VaultErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string, reasons?: string[] } = {}, + ) { + super(message) + this.name = 'VaultError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + this.reasons = details.reasons + } +} + +@Injectable() +export class VaultHttpClientService { + private readonly logger = new Logger(VaultHttpClientService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + ) {} + + @StartActiveSpan() + async fetch( + path: string, + options: VaultFetchOptions = {}, + ): Promise { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('vault.method', method) + span?.setAttribute('vault.path', path) + + this.logger.verbose(`Starting Vault request: ${method} ${path}`) + this.logger.debug(`Vault request: ${method} ${path}`) + const request = this.createRequest(path, method, options.body) + const response = await fetch(request).catch((error) => { + if (error instanceof Error) { + this.logger.error(`Vault request failed: ${method} ${path}: ${error.message}`, error.stack) + } else { + this.logger.error(`Vault request failed: ${method} ${path}: ${String(error)}`) + } + throw new VaultError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('vault.http.status', response.status) + this.logger.debug(`Vault response: ${method} ${path} status=${response.status}`) + + const parsed = await this.handleResponse(response, method, path) + this.logger.verbose(`Completed Vault request: ${method} ${path} status=${response.status} outcome=${parsed === null ? 'no-content' : 'ok'}`) + return parsed + } + + private get apiBaseUrl() { + return new URL('v1/', this.vaultConfig.internalOrPublicUrl).toString() + } + + private createRequest(path: string, method: string, body?: unknown): Request { + const url = new URL(path, this.apiBaseUrl).toString() + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Vault-Token': this.vaultConfig.token, + } + + return new Request(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }) + } + + private async handleResponse(response: Response, method: string, path: string): Promise { + if (response.status === HttpStatus.NO_CONTENT) return null + + if (!response.ok) { + await this.throwForStatus(response, method, path) + } + + return await response.json() + } + + private async throwForStatus(response: Response, method: string, path: string): Promise { + const responseBody = await response.json() + const vaultErrorBody = z.object({ errors: z.array(z.string()) }).safeParse(responseBody) + const reasons = vaultErrorBody.success ? vaultErrorBody.data.errors : undefined + const reasonsPart = reasons?.length ? ` reasons=${reasons.join('; ')}` : '' + this.logger.warn(`Vault request returned error: ${method} ${path} status=${response.status} statusText=${response.statusText}${reasonsPart}`) + + if (response.status === 404) { + throw new VaultError('NotFound', 'Not Found', { + status: 404, + method, + path, + statusText: response.statusText, + reasons, + }) + } + + throw new VaultError('HttpError', 'Request failed', { + status: response.status, + method, + path, + statusText: response.statusText, + reasons, + }) + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts new file mode 100644 index 0000000000..a1d5e06eea --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { VaultConfig } from './vault.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { VaultPluginService } from './vault-plugin.service' +import { VAULT_CONFIG } from './vault.module-definition' + +describe('vaultPluginService', () => { + let service: VaultPluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://vault.public/', + internalUrl: 'https://vault.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + VaultPluginService, + { provide: VAULT_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(VaultPluginService) + }) + + it('returns the public Vault url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://vault.public/ui/vault/secrets/dulei') + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts new file mode 100644 index 0000000000..0095ac34cf --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts @@ -0,0 +1,21 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable } from '@nestjs/common' +import { VAULT_CONFIG } from './vault.module-definition' + +@Injectable() +export class VaultPluginService { + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'vault', + to: ({ project }) => new URL(`ui/vault/secrets/${project.slug}`, this.vaultConfig.url).toString(), + title: 'Vault', + imgSrc: '/img/vault.svg', + description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', + } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts new file mode 100644 index 0000000000..2397b97bdc --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts @@ -0,0 +1,43 @@ +import type { VaultSecret } from './vault-client.service' +import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + name: faker.company.name(), + description: faker.company.buzzPhrase(), + environments: [], + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + +export function makeZoneWithDetails(overrides: Partial = {}): ZoneWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-zone-${faker.string.uuid()}`), + clusters: [], + ...overrides, + } satisfies ZoneWithDetails +} + +export function makeVaultSecret(overrides: Partial = {}): VaultSecret { + return { + data: {}, + metadata: makeVaultSecretMetadata(), + ...overrides, + } satisfies VaultSecret +} + +export function makeVaultSecretMetadata(overrides: Partial = {}): VaultSecret['metadata'] { + return { + created_time: faker.date.soon().toISOString(), + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + ...overrides, + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.constants.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.constants.ts new file mode 100644 index 0000000000..22adfa3275 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.constants.ts @@ -0,0 +1,34 @@ +// Plugin name used to identify the Vault integration +export const PLUGIN_NAME = 'vault' + +// Platform-level policy names for RBAC +export const PLATFORM_ADMIN_POLICY_NAME = 'platform--admin' +export const PLATFORM_READONLY_POLICY_NAME = 'platform--readonly' +export const PLATFORM_SECURITY_POLICY_NAME = 'platform--security' + +// Console group names for user role assignment +export const CONSOLE_ADMIN_GROUP_NAME = 'console-admin' +export const CONSOLE_READONLY_GROUP_NAME = 'console-readonly' +export const CONSOLE_SECURITY_GROUP_NAME = 'console-security' + +// Default LDAP/AD group paths for console access +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly' +export const DEFAULT_SECURITY_GROUP_PATH = '/console/security' + +// Default suffixes for project-scoped group paths +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly' +export const DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX = '/console/security' + +// Plugin configuration keys for custom group paths +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const AUDITOR_GROUP_PATH_PLUGIN_KEY = 'auditorGroupPath' +export const SECURITY_GROUP_PATH_PLUGIN_KEY = 'securityGroupPath' +export const PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReporterGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDeveloperGroupPathSuffix' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevopsGroupPathSuffix' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectMaintainerGroupPathSuffix' +export const PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectSecurityGroupPathSuffix' diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.module-definition.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.module-definition.ts new file mode 100644 index 0000000000..d0f1b425cf --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.module-definition.ts @@ -0,0 +1,17 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface VaultConfig { + token?: string + url: string + internalUrl: string + kvName: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface VaultModuleOptions extends VaultConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('vault') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as VAULT_CONFIG } diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.module.ts new file mode 100644 index 0000000000..367a1527a2 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { VaultHealthService } from './vault-health.service' +import { VaultHttpClientService } from './vault-http-client.service' +import { VaultPluginService } from './vault-plugin.service' +import { ConfigurableModuleClass } from './vault.module-definition' +import { VaultService } from './vault.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + providers: [ + VaultHealthService, + VaultHttpClientService, + VaultClientService, + VaultPluginService, + VaultService, + VaultDatastoreService, + ], + exports: [VaultClientService, VaultHealthService, VaultPluginService, VaultService], +}) +export class VaultModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.service.spec.ts new file mode 100644 index 0000000000..d46cb7b75c --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -0,0 +1,153 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { makeProjectWithDetails, makeVaultSecret, makeZoneWithDetails } from './vault-testing.utils' +import { VAULT_CONFIG } from './vault.module-definition' +import { VaultService } from './vault.service' + +const projectRoleGroupNameRegex = /^project-(.*)-(admin|devops|developer|readonly|security)$/ + +describe('vaultService', () => { + let service: VaultService + let datastore: DeepMockProxy + let client: DeepMockProxy + + beforeEach(async () => { + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + client = mockDeep({ + createSysMount: vi.fn().mockResolvedValue(undefined), + tuneSysMount: vi.fn().mockResolvedValue(undefined), + deleteSysMounts: vi.fn().mockResolvedValue(undefined), + upsertSysPoliciesAcl: vi.fn().mockResolvedValue(undefined), + deleteSysPoliciesAcl: vi.fn().mockResolvedValue(undefined), + upsertAuthApproleRole: vi.fn().mockResolvedValue(undefined), + deleteAuthApproleRole: vi.fn().mockResolvedValue(undefined), + getIdentityGroupName: vi.fn(async (groupName: string) => makeVaultSecret({ data: { id: 'gid', name: groupName } })), + deleteIdentityGroupName: vi.fn().mockResolvedValue(undefined), + getSysAuth: vi.fn().mockResolvedValue({ 'oidc/': { accessor: 'oidc-accessor', type: 'oidc' } }), + createIdentityGroupAlias: vi.fn().mockResolvedValue(undefined), + listKvMetadata: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + }) + + const vaultConfig = mockDeep({ kvName: 'kv' }) + const baseConfig = mockDeep({ projectsRootDir: 'forge' }) + + const module = await Test.createTestingModule({ + providers: [ + VaultService, + { provide: VaultClientService, useValue: client }, + { provide: VaultDatastoreService, useValue: datastore }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(VaultService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should reconcile on cron', async () => { + const projects = faker.helpers.multiple(() => makeProjectWithDetails()) + const zones = faker.helpers.multiple(() => makeZoneWithDetails()) + + datastore.getAllProjects.mockResolvedValue(projects) + datastore.getAllZones.mockResolvedValue(zones) + + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(datastore.getAllZones).toHaveBeenCalled() + expect(client.createSysMount).toHaveBeenCalledTimes(projects.length + zones.length) + projects.forEach((project) => { + expect(client.createSysMount).toHaveBeenCalledWith(project.slug, expect.any(Object)) + }) + zones.forEach((zone) => { + expect(client.createSysMount).toHaveBeenCalledWith(`zone-${zone.slug}`, expect.any(Object)) + }) + }) + + it('should upsert project on event', async () => { + const project = makeProjectWithDetails() + + client.getIdentityGroupName.mockImplementation(async (groupName: string) => { + const projectRoleMatch = groupName.match(projectRoleGroupNameRegex) + if (projectRoleMatch) { + const projectSlug = projectRoleMatch[1] + const role = projectRoleMatch[2] + return { data: { id: 'gid', name: groupName, alias: { name: `/${projectSlug}/console/${role}` } } } + } + + if (groupName === 'console-admin') return { data: { id: 'gid', name: groupName, alias: { name: '/console/admin' } } } + if (groupName === 'console-readonly') return { data: { id: 'gid', name: groupName, alias: { name: '/console/readonly' } } } + if (groupName === 'console-security') return { data: { id: 'gid', name: groupName, alias: { name: '/console/security' } } } + + return { data: { id: 'gid', name: groupName } } + }) + + await service.handleUpsert(project) + + expect(client.createSysMount).toHaveBeenCalledWith(project.slug, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`app--${project.slug}--admin`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`tech--${project.slug}--ro`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--devops`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--developer`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--readonly`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--security`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--admin', expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--readonly', expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--security', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-admin', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-readonly', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-security', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-admin`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-devops`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-developer`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`, expect.any(Object)) + expect(client.createIdentityGroupAlias).not.toHaveBeenCalled() + + const policyCalls = client.upsertSysPoliciesAcl.mock.calls as Array<[string, { policy: string }]> + expect(policyCalls.find(([name]) => name === `project--${project.slug}--developer`)?.[1].policy) + .toBe(`path "${project.slug}/data/*" { capabilities = ["list"] }`) + expect(policyCalls.find(([name]) => name === `project--${project.slug}--readonly`)?.[1].policy) + .toBe(`path "${project.slug}/data/*" { capabilities = ["list"] }`) + expect(policyCalls.find(([name]) => name === `project--${project.slug}--security`)?.[1].policy) + .toBe([ + `path "${project.slug}/metadata/*" { capabilities = ["list"] }`, + `path "transit/keys/${project.slug}/*" { capabilities = ["list"] }`, + ].join('\n')) + }) + + it('should delete project and destroy secrets on event', async () => { + const project = makeProjectWithDetails() + + await service.handleDelete(project) + + expect(client.deleteSysMounts).toHaveBeenCalledWith(project.slug) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`app--${project.slug}--admin`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`tech--${project.slug}--ro`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--devops`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--developer`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--readonly`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--security`) + expect(client.deleteAuthApproleRole).toHaveBeenCalledWith(project.slug) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-admin`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-devops`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-developer`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.service.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.service.ts new file mode 100644 index 0000000000..7128c24963 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -0,0 +1,594 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { VaultError } from './vault-http-client.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + AUDITOR_GROUP_PATH_PLUGIN_KEY, + CONSOLE_ADMIN_GROUP_NAME, + CONSOLE_READONLY_GROUP_NAME, + CONSOLE_SECURITY_GROUP_NAME, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX, + DEFAULT_SECURITY_GROUP_PATH, + PLATFORM_ADMIN_POLICY_NAME, + PLATFORM_READONLY_POLICY_NAME, + PLATFORM_SECURITY_POLICY_NAME, + PLUGIN_NAME, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + SECURITY_GROUP_PATH_PLUGIN_KEY, +} from './vault.constants' +import { VAULT_CONFIG } from './vault.module-definition' +import { generateProjectPath } from './vault.utils' + +type ProjectScope = 'admin' | 'devops' | 'developer' | 'readonly' | 'security' + +@Injectable() +export class VaultService { + private readonly logger = new Logger(VaultService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultDatastoreService) private readonly datastore: VaultDatastoreService, + @Inject(VaultClientService) private readonly client: VaultClientService, + ) { + this.logger.log('VaultService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('vault', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`Vault project sync completed for ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('vault', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await Promise.all([ + this.deleteProject(project), + this.deleteProjectSecrets(project.slug), + ]) + this.logger.log(`Vault project cleanup completed for ${project.slug}`) + } + + @OnEvent('zone.upsert') + async handleUpsertZone(zone: ZoneWithDetails): Promise> { + return capturePluginResult('vault', () => this.syncZone(zone)) + } + + @StartActiveSpan() + private async syncZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.log(`Handling a zone upsert event for ${zone.slug}`) + await this.ensureZone(zone) + this.logger.log(`Vault zone sync completed for ${zone.slug}`) + } + + @OnEvent('zone.delete') + async handleDeleteZone(zone: ZoneWithDetails): Promise> { + return capturePluginResult('vault', () => this.cleanupZone(zone)) + } + + @StartActiveSpan() + private async cleanupZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.log(`Handling a zone delete event for ${zone.slug}`) + await this.deleteZone(zone.slug) + this.logger.log(`Vault zone cleanup completed for ${zone.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Vault reconciliation') + const [projects, zones] = await Promise.all([ + this.datastore.getAllProjects(), + this.datastore.getAllZones(), + ]) + + span?.setAttributes({ + 'vault.projects.count': projects.length, + 'vault.zones.count': zones.length, + }) + this.logger.log(`Loaded state for Vault reconciliation (projects=${projects.length}, zones=${zones.length})`) + await Promise.all([ + this.ensureProjects(projects), + this.ensureZones(zones), + ]) + this.logger.log(`Vault reconciliation completed (projects=${projects.length} zones=${zones.length})`) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('vault.projects.count', projects.length) + this.logger.verbose(`Reconciling Vault projects (count=${projects.length})`) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling Vault project ${project.slug}`) + await this.upsertProject(project) + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return project.plugins?.find(p => p.pluginName === PLUGIN_NAME && p.key === key)?.value + } + + private async getAdminGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getAuditorGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, AUDITOR_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_AUDITOR_GROUP_PATH + } + + private async getSecurityGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, SECURITY_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_SECURITY_GROUP_PATH + } + + private async getProjectMaintainerGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX + } + + private async getProjectDevopsGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX + } + + private async getProjectDeveloperGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + } + + private async getProjectReporterGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX + } + + private async getProjectSecurityGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX + } + + @StartActiveSpan() + private async ensureZones(zones: ZoneWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('vault.zones.count', zones.length) + this.logger.verbose(`Reconciling Vault zones (count=${zones.length})`) + await Promise.all(zones.map(z => this.ensureZone(z))) + } + + @StartActiveSpan() + private async ensureZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.verbose(`Reconciling Vault zone ${zone.slug}`) + await this.upsertZone(zone.slug) + } + + private async upsertMount(kvName: string): Promise { + const createBody = { + type: 'kv', + config: { + force_no_cache: true, + }, + options: { + version: 2, + }, + } + const tuneBody = { + options: { + version: 2, + }, + } + try { + await this.client.createSysMount(kvName, createBody) + this.logger.log(`Created Vault mount ${kvName}`) + } catch (error) { + if (error instanceof VaultError && error.kind === 'HttpError' && error.status === 400) { + await this.client.tuneSysMount(kvName, tuneBody) + this.logger.log(`Vault mount ${kvName} already existed, so it was tuned to the expected settings`) + return + } + throw error + } + } + + private async deleteMount(kvName: string): Promise { + try { + await this.client.deleteSysMounts(kvName) + this.logger.log(`Deleted Vault mount ${kvName}`) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') { + this.logger.warn(`Vault mount ${kvName} was already missing`) + return + } + throw error + } + } + + @StartActiveSpan() + async upsertZone(zoneName: string): Promise { + const kvName = generateZoneName(zoneName) + const span = trace.getActiveSpan() + span?.setAttribute('zone.name', zoneName) + span?.setAttribute('vault.kv.name', kvName) + const policyName = generateZoneTechReadOnlyPolicyName(zoneName) + + await this.upsertMount(kvName) + await this.client.upsertSysPoliciesAcl(policyName, { + policy: `path "${kvName}/*" { capabilities = ["read"] }`, + }) + await this.client.upsertAuthApproleRole(kvName, generateApproleRoleBody([policyName])) + } + + @StartActiveSpan() + async deleteZone(zoneName: string): Promise { + const kvName = generateZoneName(zoneName) + const span = trace.getActiveSpan() + span?.setAttribute('zone.name', zoneName) + span?.setAttribute('vault.kv.name', kvName) + const policyName = generateZoneTechReadOnlyPolicyName(zoneName) + const roleName = kvName + + await this.deleteMount(kvName) + + const settled = await Promise.allSettled([ + this.client.deleteSysPoliciesAcl(policyName), + this.client.deleteAuthApproleRole(roleName), + ]) + + for (const result of settled) { + if (result.status !== 'rejected') continue + const error = result.reason + if (error instanceof VaultError && error.kind === 'NotFound') continue + throw error + } + } + + @StartActiveSpan() + async upsertProject(project: ProjectWithDetails): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('vault.kv.name', project.slug) + const appPolicyName = generateAppAdminPolicyName(project) + const techPolicyName = generateTechReadOnlyPolicyName(project) + const projectDevopsPolicyName = generateProjectPolicyName(project, 'devops') + const projectDeveloperPolicyName = generateProjectPolicyName(project, 'developer') + const projectReadOnlyPolicyName = generateProjectPolicyName(project, 'readonly') + const projectSecurityPolicyName = generateProjectPolicyName(project, 'security') + await this.upsertMount(project.slug) + + const [ + adminGroupPath, + auditorGroupPath, + securityGroupPath, + maintainerGroupPathSuffix, + devopsGroupPathSuffix, + developerGroupPathSuffix, + reporterGroupPathSuffix, + securityGroupPathSuffix, + ] = await Promise.all([ + this.getAdminGroupPath(project), + this.getAuditorGroupPath(project), + this.getSecurityGroupPath(project), + this.getProjectMaintainerGroupPathSuffix(project), + this.getProjectDevopsGroupPathSuffix(project), + this.getProjectDeveloperGroupPathSuffix(project), + this.getProjectReporterGroupPathSuffix(project), + this.getProjectSecurityGroupPathSuffix(project), + ]) + + const projectAdminGroupPaths = generateProjectRoleGroupPaths(project, maintainerGroupPathSuffix) + const projectDevopsGroupPaths = generateProjectRoleGroupPaths(project, devopsGroupPathSuffix) + const projectDeveloperGroupPaths = generateProjectRoleGroupPaths(project, developerGroupPathSuffix) + const projectReadOnlyGroupPaths = generateProjectRoleGroupPaths(project, reporterGroupPathSuffix) + const projectSecurityGroupPaths = generateProjectRoleGroupPaths(project, securityGroupPathSuffix) + + await Promise.all([ + this.ensureAppAdminPolicy(appPolicyName, project.slug), + this.ensureTechReadOnlyPolicy(techPolicyName, project.slug), + this.ensureProjectDevopsPolicy(projectDevopsPolicyName, project.slug), + this.ensureProjectReadOnlyPolicy(projectDeveloperPolicyName, project.slug), + this.ensureProjectReadOnlyPolicy(projectReadOnlyPolicyName, project.slug), + this.createProjectSecurityPolicy(projectSecurityPolicyName, project.slug), + this.ensurePlatformAdminPolicy(PLATFORM_ADMIN_POLICY_NAME), + this.ensurePlatformReadOnlyPolicy(PLATFORM_READONLY_POLICY_NAME), + this.ensurePlatformSecurityPolicy(PLATFORM_SECURITY_POLICY_NAME), + this.ensureIdentityGroup(CONSOLE_ADMIN_GROUP_NAME, [PLATFORM_ADMIN_POLICY_NAME], adminGroupPath), + this.ensureIdentityGroup(CONSOLE_READONLY_GROUP_NAME, [PLATFORM_READONLY_POLICY_NAME], auditorGroupPath), + this.ensureIdentityGroup(CONSOLE_SECURITY_GROUP_NAME, [PLATFORM_SECURITY_POLICY_NAME], securityGroupPath), + ...projectAdminGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'admin'), [appPolicyName], groupPath)), + ...projectDevopsGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'devops'), [projectDevopsPolicyName], groupPath)), + ...projectDeveloperGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'developer'), [projectDeveloperPolicyName], groupPath)), + ...projectReadOnlyGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'readonly'), [projectReadOnlyPolicyName], groupPath)), + ...projectSecurityGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'security'), [projectSecurityPolicyName], groupPath)), + this.client.upsertAuthApproleRole(project.slug, generateApproleRoleBody([techPolicyName, appPolicyName])), + ]) + } + + @StartActiveSpan() + async deleteProject(project: ProjectWithDetails): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('vault.kv.name', project.slug) + const appPolicyName = generateAppAdminPolicyName(project) + const techPolicyName = generateTechReadOnlyPolicyName(project) + const projectDevopsPolicyName = generateProjectPolicyName(project, 'devops') + const projectDeveloperPolicyName = generateProjectPolicyName(project, 'developer') + const projectReadOnlyPolicyName = generateProjectPolicyName(project, 'readonly') + const projectSecurityPolicyName = generateProjectPolicyName(project, 'security') + + await this.deleteMount(project.slug) + + const settled = await Promise.allSettled([ + this.client.deleteSysPoliciesAcl(appPolicyName), + this.client.deleteSysPoliciesAcl(techPolicyName), + this.client.deleteSysPoliciesAcl(projectDevopsPolicyName), + this.client.deleteSysPoliciesAcl(projectDeveloperPolicyName), + this.client.deleteSysPoliciesAcl(projectReadOnlyPolicyName), + this.client.deleteSysPoliciesAcl(projectSecurityPolicyName), + this.client.deleteAuthApproleRole(project.slug), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'admin')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'devops')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'developer')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'readonly')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'security')), + ]) + for (const result of settled) { + if (result.status !== 'rejected') continue + const error = result.reason + if (error instanceof VaultError && error.kind === 'NotFound') continue + throw error + } + } + + @StartActiveSpan() + private async ensureIdentityGroup(groupName: string, policies: string[], groupAliasName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'vault.group.name': groupName, + 'vault.policies.count': policies.length, + }) + await this.client.upsertIdentityGroupName(groupName, { + name: groupName, + type: 'external', + policies, + }) + + const groupResult = await this.client.getIdentityGroupName(groupName) + if (!groupResult.data?.id) { + throw new VaultError('InvalidResponse', `Vault group not found after upsert: ${groupName}`, { method: 'GET', path: `/v1/identity/group/name/${groupName}` }) + } + + const normalizedAliasName = groupAliasName.startsWith('/') ? groupAliasName : `/${groupAliasName}` + if (groupResult.data.alias?.name === normalizedAliasName) return + + const methods = await this.client.getSysAuth() + const oidc = methods['oidc/'] + if (!oidc?.accessor) { + throw new VaultError('InvalidResponse', 'Vault OIDC auth method not found (expected "oidc/")', { method: 'GET', path: '/v1/sys/auth' }) + } + try { + span?.setAttributes({ + 'vault.group.alias.name': normalizedAliasName, + 'vault.oidc.accessor': oidc.accessor, + }) + await this.client.createIdentityGroupAlias({ + name: normalizedAliasName, + mount_accessor: oidc.accessor, + canonical_id: groupResult.data.id, + }) + } catch (error) { + if (error instanceof VaultError && error.kind === 'HttpError' && error.status === 400) return + throw error + } + } + + async ensureAppAdminPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${projectSlug}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + }) + } + + async ensureProjectDevopsPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "${projectSlug}/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + `path "${projectSlug}/metadata/*" { capabilities = ["read", "list"] }`, + `path "${projectSlug}/delete/*" { capabilities = ["update"] }`, + `path "${projectSlug}/undelete/*" { capabilities = ["update"] }`, + `path "${projectSlug}/destroy/*" { capabilities = ["update"] }`, + ].join('\n'), + }) + } + + async ensureProjectReadOnlyPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${projectSlug}/data/*" { capabilities = ["list"] }`, + }) + } + + async createProjectSecurityPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "${projectSlug}/metadata/*" { capabilities = ["list"] }`, + `path "transit/keys/${projectSlug}/*" { capabilities = ["list"] }`, + ].join('\n'), + }) + } + + async ensurePlatformAdminPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "sys/*" { capabilities = ["create", "read", "update", "delete", "list", "sudo"] }`, + }) + } + + async ensurePlatformReadOnlyPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "sys/health" { capabilities = ["read"] }`, + `path "sys/mounts" { capabilities = ["read"] }`, + `path "sys/mounts/*" { capabilities = ["read"] }`, + `path "sys/auth" { capabilities = ["read"] }`, + `path "sys/auth/*" { capabilities = ["read"] }`, + `path "sys/policies/*" { capabilities = ["read", "list"] }`, + ].join('\n'), + }) + } + + async ensurePlatformSecurityPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "sys/audit" { capabilities = ["read", "list"] }`, + `path "sys/audit/*" { capabilities = ["read", "list"] }`, + `path "sys/policies/*" { capabilities = ["read", "list"] }`, + `path "sys/auth" { capabilities = ["read"] }`, + `path "sys/auth/*" { capabilities = ["read"] }`, + ].join('\n'), + }) + } + + async ensureTechReadOnlyPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${this.vaultConfig.kvName}/data/${projectSlug}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + }) + } + + async listProjectSecrets(projectSlug: string): Promise { + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) + return this.listRecursive(this.vaultConfig.kvName, projectPath, '') + } + + @StartActiveSpan() + async deleteProjectSecrets(projectSlug: string): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'vault.kv.name': this.vaultConfig.kvName, + }) + const secrets = await this.listProjectSecrets(projectSlug) + span?.setAttribute('vault.secrets.count', secrets.length) + + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) + await Promise.allSettled(secrets.map(async (relativePath) => { + const fullPath = `${projectPath}/${relativePath}` + try { + await this.client.delete(fullPath) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + })) + } + + private async listRecursive( + kvName: string, + basePath: string, + relativePath: string, + ): Promise { + const combined = relativePath.length === 0 ? basePath : `${basePath}/${relativePath}` + const keys = await this.client.listKvMetadata(kvName, combined) + if (keys.length === 0) return [] + + const results: string[] = [] + for (const key of keys) { + if (key.endsWith('/')) { + const nestedRel = relativePath.length === 0 ? key.slice(0, -1) : `${relativePath}/${key.slice(0, -1)}` + const nested = await this.listRecursive(kvName, basePath, nestedRel) + results.push(...nested) + } else { + results.push(relativePath.length === 0 ? key : `${relativePath}/${key}`) + } + } + return results + } +} + +function generateTechReadOnlyPolicyName(project: ProjectWithDetails) { + return `tech--${project.slug}--ro` +} + +function generateAppAdminPolicyName(project: ProjectWithDetails) { + return `app--${project.slug}--admin` +} + +function generateProjectPolicyName(project: ProjectWithDetails, scope: ProjectScope) { + return `project--${project.slug}--${scope}` +} + +function generateProjectGroupName(project: ProjectWithDetails, scope: ProjectScope) { + return `project-${project.slug}-${scope}` +} + +function generateProjectRoleGroupPaths(project: ProjectWithDetails, rawGroupPathSuffixes: string) { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${project.slug}${path}`) +} + +function generateZoneName(name: string) { + return `zone-${name}` +} + +function generateZoneTechReadOnlyPolicyName(zoneName: string) { + return `tech--${generateZoneName(zoneName)}--ro` +} + +function generateApproleRoleBody(policies: string[]) { + return { + secret_id_num_uses: '0', + secret_id_ttl: '0', + token_max_ttl: '0', + token_num_uses: '0', + token_ttl: '0', + token_type: 'batch', + token_policies: policies, + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.utils.ts new file mode 100644 index 0000000000..6e170bede2 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/vault/vault.utils.ts @@ -0,0 +1,15 @@ +export function generateProjectPath(projectRootDir: string, projectSlug: string) { + return `${projectRootDir}/${projectSlug}` +} + +export function generateGitlabMirrorCredPath(projectRootDir: string, projectSlug: string, repoName: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/${repoName}-mirror` +} + +export function generateTechReadOnlyCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/tech/GITLAB_MIRROR` +} + +export function generateSonarqubeCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/SONAR` +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/version/version.controller.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/version/version.controller.ts new file mode 100644 index 0000000000..7dc6a91019 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/version/version.controller.ts @@ -0,0 +1,15 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import { Controller, Get, Inject } from '@nestjs/common' +import { BASE_CONFIG } from '../infrastructure/config/base.config' + +@Controller('api/v1/version') +export class VersionController { + constructor( + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + @Get() + getVersion() { + return { version: this.baseConfig.appVersion } + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/modules/version/version.module.ts b/.jjconflict-side-0/apps/server-nestjs/src/modules/version/version.module.ts new file mode 100644 index 0000000000..c537037b5b --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/modules/version/version.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common' +import { VersionController } from './version.controller' + +@Module({ + controllers: [VersionController], +}) +export class VersionModule {} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql new file mode 100644 index 0000000000..f2f4e7b0b7 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql @@ -0,0 +1,151 @@ +-- CreateTable +CREATE TABLE "Environment" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Environment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Log" ( + "id" UUID NOT NULL, + "data" JSONB NOT NULL, + "action" TEXT NOT NULL DEFAULT '', + "userId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Log_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Organization" ( + "id" UUID NOT NULL, + "source" TEXT NOT NULL, + "name" TEXT NOT NULL, + "label" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Permission" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "environmentId" UUID NOT NULL, + "level" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Permission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "organizationId" UUID NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL, + "locked" BOOLEAN NOT NULL DEFAULT false, + "services" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Repository" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "internalRepoName" TEXT NOT NULL, + "externalRepoUrl" TEXT NOT NULL, + "externalUserName" TEXT, + "externalToken" TEXT, + "isInfra" BOOLEAN NOT NULL DEFAULT false, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Repository_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" UUID NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Role" ( + "userId" UUID NOT NULL, + "projectId" UUID NOT NULL, + "role" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Role_pkey" PRIMARY KEY ("userId","projectId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_id_key" ON "Organization"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_name_key" ON "Organization"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_label_key" ON "Organization"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_id_key" ON "Permission"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_userId_environmentId_key" ON "Permission"("userId", "environmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_id_key" ON "Project"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Role_userId_projectId_key" ON "Role"("userId", "projectId"); + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql new file mode 100644 index 0000000000..26e1ade3f2 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql @@ -0,0 +1,85 @@ +-- CreateEnum +CREATE TYPE "ClusterPrivacy" AS ENUM ('public', 'dedicated'); + +-- CreateTable +CREATE TABLE "Cluster" ( + "id" UUID NOT NULL, + "label" VARCHAR(50) NOT NULL, + "privacy" "ClusterPrivacy" NOT NULL DEFAULT 'dedicated', + "secretName" VARCHAR(50) NOT NULL, + "clusterResources" BOOLEAN NOT NULL DEFAULT false, + "kubeConfigId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cluster_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Kubeconfig" ( + "id" UUID NOT NULL, + "user" JSONB NOT NULL, + "cluster" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "parentClusterId" UUID, + + CONSTRAINT "Kubeconfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_ClusterToEnvironment" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateTable +CREATE TABLE "_ClusterToProject" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_id_key" ON "Cluster"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_label_key" ON "Cluster"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_secretName_key" ON "Cluster"("secretName"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_kubeConfigId_key" ON "Cluster"("kubeConfigId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_id_key" ON "Kubeconfig"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_parentClusterId_key" ON "Kubeconfig"("parentClusterId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToEnvironment_AB_unique" ON "_ClusterToEnvironment"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToEnvironment_B_index" ON "_ClusterToEnvironment"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToProject_AB_unique" ON "_ClusterToProject"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToProject_B_index" ON "_ClusterToProject"("B"); + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_kubeConfigId_fkey" FOREIGN KEY ("kubeConfigId") REFERENCES "Kubeconfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_B_fkey" FOREIGN KEY ("B") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_B_fkey" FOREIGN KEY ("B") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql new file mode 100644 index 0000000000..8f3fb5ff9f --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `parentClusterId` on the `Kubeconfig` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Kubeconfig_parentClusterId_key"; + +-- AlterTable +ALTER TABLE "Kubeconfig" DROP COLUMN "parentClusterId"; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql new file mode 100644 index 0000000000..4eb37edc53 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql @@ -0,0 +1,10 @@ +CREATE TYPE "ProjectStatus" AS ENUM ('initializing', 'created', 'failed', 'archived'); + +ALTER TABLE public."Project" ALTER COLUMN status TYPE "ProjectStatus" USING + case + when status = 'created' then 'created'::"ProjectStatus" + when status = 'failed' then 'failed'::"ProjectStatus" + when status = 'archived' then 'archived'::"ProjectStatus" + else 'initializing'::"ProjectStatus" + end; +ALTER TABLE public."Project" ALTER COLUMN status SET DEFAULT 'initializing'; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql new file mode 100644 index 0000000000..f402a0e3d4 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "infos" VARCHAR(200); diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql new file mode 100644 index 0000000000..f553e7880e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `externalToken` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "externalToken"; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql new file mode 100644 index 0000000000..394b650a51 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql @@ -0,0 +1,81 @@ +-- CreateEnum +CREATE TYPE "QuotaStageStatus" AS ENUM +('active', 'pendingDelete'); + +-- Create new tables +-- CreateTable +CREATE TABLE "Quota" +( + "id" UUID NOT NULL, + "memory" VARCHAR NOT NULL, + "cpu" REAL NOT NULL, + "name" VARCHAR NOT NULL, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "Quota_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Stage" +( + "id" UUID NOT NULL, + "name" VARCHAR NOT NULL, + + CONSTRAINT "Stage_pkey" PRIMARY KEY ("id") +); + +-- Associate Quotas and Stages +-- CreateTable +CREATE TABLE "QuotaStage" +( + "id" UUID NOT NULL, + "quotaId" UUID NOT NULL, + "stageId" UUID NOT NULL, + "status" "QuotaStageStatus" NOT NULL DEFAULT 'active', + + CONSTRAINT "QuotaStage_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "Quota_id_key" ON "Quota"("id"); +CREATE UNIQUE INDEX "Quota_name_key" ON "Quota"("name"); +CREATE UNIQUE INDEX "Stage_id_key" ON "Stage"("id"); +CREATE UNIQUE INDEX "Stage_name_key" ON "Stage"("name"); +CREATE UNIQUE INDEX "QuotaStage_id_key" ON "QuotaStage"("id"); +CREATE UNIQUE INDEX "QuotaStage_quotaId_stageId_key" ON "QuotaStage"("quotaId", "stageId"); +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Create default values for Quotas and Stages +-- Quota +INSERT INTO "Quota" + (id, cpu, memory, "name", "isPrivate") +VALUES + ('5a57b62f-2465-4fb6-a853-5a751d099199', 2, '4Gi', 'small', false), + ('08770663-3b76-4af6-8978-9f75eda4faa7', 4, '8Gi', 'medium', false), + ('b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 6, '12Gi', 'large', false), + ('97b851e8-9067-4a3d-a0e8-c3a6820c49be', 8, '16Gi', 'xlarge', false); + +-- Stage +INSERT INTO "Stage" + (id, "name") +VALUES + ('4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', 'dev'), + ('38fa869d-6267-441d-af7f-e0548fd06b7e', 'staging'), + ('d434310e-7850-4d59-b47f-0772edf50582', 'integration'), + ('9b3e9991-896d-4d90-bdc5-a34be8c06b8f', 'prod'); + +-- QuotaStage +INSERT INTO "QuotaStage" + (id, "quotaId", "stageId") +VALUES + ('0cb0c549-560e-4f26-8f4e-832dd722f68a', '5a57b62f-2465-4fb6-a853-5a751d099199', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('0530e9c9-b37d-4dec-93e6-1895f700e61c', '5a57b62f-2465-4fb6-a853-5a751d099199', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('8a99db49-b7b1-44bf-865d-5e709e8aa0fc', '5a57b62f-2465-4fb6-a853-5a751d099199', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('67561f00-d219-4ca6-b94a-3ee83f09d2d6', '5a57b62f-2465-4fb6-a853-5a751d099199', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('8b3c201e-7518-4254-a94a-16c404e46936', '08770663-3b76-4af6-8978-9f75eda4faa7', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('9157ae12-3e39-43f8-a24f-ae5d9c6b69b7', '08770663-3b76-4af6-8978-9f75eda4faa7', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('c733a1dd-c9fd-4def-b29e-df49ef7b6698', '08770663-3b76-4af6-8978-9f75eda4faa7', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('15a51f47-0ab2-4a94-a808-722639d8c092', '08770663-3b76-4af6-8978-9f75eda4faa7', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('cb66e80c-2304-472d-bc19-a411011674ca', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('59fb0e79-3a76-4b96-81d4-63f4caa98cfa', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('4174b22c-2bee-4f4a-9d85-da7b5463f214', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql new file mode 100644 index 0000000000..8c98a7f74e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql @@ -0,0 +1,35 @@ +-- Multiplication des environnements par clusteurs +ALTER TABLE "Environment" ADD COLUMN "clusterId" UUID; + +DO +$$ +DECLARE + perm record; + cte record; + env_uuid UUID; +BEGIN + FOR cte IN SELECT "B" AS environmentId, "A" AS "clusterId", "name", "projectId", status, "updatedAt", "createdAt" + FROM public."_ClusterToEnvironment", public."Environment" + WHERE public."Environment".id = "B" + LOOP + env_uuid := gen_random_uuid(); + INSERT INTO public."Environment" (id, "name", "projectId", "clusterId", status, "createdAt", "updatedAt") VALUES + (env_uuid, cte."name", cte."projectId", cte."clusterId", cte.status, cte."createdAt", cte."updatedAt"); + + FOR perm in SELECT * FROM public."Permission" WHERE "environmentId" = cte.environmentId + LOOP + INSERT INTO public."Permission" (id, "level", "createdAt", "updatedAt", "environmentId", "userId") VALUES + (gen_random_uuid(), perm."level", perm."createdAt", perm."updatedAt", env_uuid, perm."userId"); + END LOOP; + END LOOP; +END; +$$ +; +DELETE FROM public."Environment" WHERE "clusterId" is null; +ALTER TABLE public."Environment" ALTER COLUMN "clusterId" SET NOT NULL; +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_clusterId_fkey" FOREIGN KEY ("clusterId") REFERENCES "Cluster"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Delete old _ClusterToEnvironment +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_A_fkey"; +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_B_fkey"; +DROP TABLE "_ClusterToEnvironment"; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql new file mode 100644 index 0000000000..035cd1c855 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql @@ -0,0 +1,36 @@ +-- Associate cluster to Stages +-- CreateTable +CREATE TABLE "_ClusterToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToStage_AB_unique" ON "_ClusterToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToStage_B_index" ON "_ClusterToStage"("B"); + +DO +$$ +DECLARE + cluster record; + cte record; + env_uuid UUID; +BEGIN + FOR cluster IN SELECT id + FROM public."Cluster" + LOOP + INSERT INTO public."_ClusterToStage" ("A", "B") VALUES + (cluster.id, '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + (cluster.id, '38fa869d-6267-441d-af7f-e0548fd06b7e'), + (cluster.id, 'd434310e-7850-4d59-b47f-0772edf50582'), + (cluster.id, '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); + END LOOP; +END; +$$ diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql new file mode 100644 index 0000000000..43793bdb49 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaStageId" UUID; +UPDATE "Environment" SET "quotaStageId" = '8b3c201e-7518-4254-a94a-16c404e46936' WHERE "name" = 'dev'; +UPDATE "Environment" SET "quotaStageId" = '9157ae12-3e39-43f8-a24f-ae5d9c6b69b7' WHERE "name" = 'staging'; +UPDATE "Environment" SET "quotaStageId" = '4174b22c-2bee-4f4a-9d85-da7b5463f214' WHERE "name" = 'integration'; +UPDATE "Environment" SET "quotaStageId" = 'de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a' WHERE "name" = 'prod'; +ALTER TABLE "Environment" ALTER COLUMN "name" SET DATA TYPE VARCHAR(11); +ALTER TABLE "Environment" ALTER COLUMN "quotaStageId" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaStageId_fkey" FOREIGN KEY ("quotaStageId") REFERENCES "QuotaStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql new file mode 100644 index 0000000000..9af004b6a3 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- lock all projects +UPDATE public."Project" SET "locked"=true \ No newline at end of file diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql new file mode 100644 index 0000000000..d970d3965a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- set all projects to failed to avoid unlock them +UPDATE public."Project" SET "status" = 'failed' WHERE "status" != 'archived' \ No newline at end of file diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql new file mode 100644 index 0000000000..c387d9885c --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ADD COLUMN "requestId" VARCHAR(21); diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql new file mode 100644 index 0000000000..18e20262c8 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `status` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the column `status` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "status"; + +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "status"; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql new file mode 100644 index 0000000000..f784b21567 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql @@ -0,0 +1,46 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "zoneId" UUID; + +-- CreateTable +CREATE TABLE "Zone" +( + "id" UUID NOT NULL, + "slug" VARCHAR(10) NOT NULL, + "label" VARCHAR(50) NOT NULL, + "description" VARCHAR(200), + "createdAt" TIMESTAMP +(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP +(3) NOT NULL, + + CONSTRAINT "Zone_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_id_key" ON "Zone"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_slug_key" ON "Zone"("slug"); + +-- Create default zone +INSERT INTO "Zone" + (id, "slug", "label", "description", "updatedAt") +VALUES + ('a66c4230-eba6-41f1-aae5-bb1e4f90cce0', 'default', 'Zone Défaut', 'Zone par défaut, à changer', CURRENT_TIMESTAMP); + +-- Set default zoneId for current clusters +UPDATE "Cluster" +SET "zoneId" += 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0' +WHERE "zoneId" +IS NULL; + +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "zoneId" +SET +NOT NULL; + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") +ON DELETE RESTRICT ON +UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql new file mode 100644 index 0000000000..cb4f7ad968 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "ProjectPlugin" ( + "pluginName" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "AdminPlugin" ( + "pluginName" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectPlugin_projectId_pluginName_key_key" ON "ProjectPlugin"("projectId", "pluginName", "key"); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminPlugin_pluginName_key_key" ON "AdminPlugin"("pluginName", "key"); + +-- AddForeignKey +ALTER TABLE "ProjectPlugin" ADD CONSTRAINT "ProjectPlugin_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql new file mode 100644 index 0000000000..11672324f8 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql @@ -0,0 +1,19 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id, services FROM public."Project" LOOP + -- Extrait 'registry.id' de la colonne JSON 'services' + registry_id := (SELECT (project_row.services -> 'registry' ->> 'id')::TEXT); + -- Si 'registry.id' existe, insérer dans la table 'config' + IF registry_id IS NOT NULL THEN + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'registry', 'projectId', registry_id::TEXT); + END IF; + END LOOP; +END $$; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "services"; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql new file mode 100644 index 0000000000..9c7d5a8f8f --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "RoleList" AS ENUM ('owner', 'user'); + +-- AlterTable +ALTER TABLE public."Role" ALTER COLUMN "role" TYPE "RoleList" USING + case + when role = 'owner' then 'owner'::"RoleList" + else 'user'::"RoleList" + end; \ No newline at end of file diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql new file mode 100644 index 0000000000..45a4a5d1ea --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "ProjectClusterHistory" ( + "projectId" UUID NOT NULL, + "clusterId" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectClusterHistory_projectId_clusterId_key" ON "ProjectClusterHistory"("projectId", "clusterId"); diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql new file mode 100644 index 0000000000..2b1641a657 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql @@ -0,0 +1,11 @@ +DO $$ +DECLARE + env_row RECORD; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR env_row IN SELECT "projectId", "clusterId" FROM public."Environment" LOOP + INSERT INTO public."ProjectClusterHistory" ("projectId", "clusterId") + VALUES (env_row."projectId", env_row."clusterId") + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql new file mode 100644 index 0000000000..5e7ff03d45 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql @@ -0,0 +1,58 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaId" UUID, +ADD COLUMN "stageId" UUID; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "_QuotaToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_QuotaToStage_AB_unique" ON "_QuotaToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_QuotaToStage_B_index" ON "_QuotaToStage"("B"); + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +DO $$ +DECLARE + quota_stage_row RECORD; +BEGIN + FOR quota_stage_row IN SELECT * FROM public."QuotaStage" loop + UPDATE public."Environment" SET "stageId" = quota_stage_row."stageId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + UPDATE public."Environment" SET "quotaId" = quota_stage_row."quotaId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + insert into public."_QuotaToStage" values (quota_stage_row."quotaId", quota_stage_row."stageId"); + END LOOP; +END $$; + +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaStageId_fkey"; + +-- AlterTable +ALTER TABLE "Environment" ALTER COLUMN "quotaId" SET NOT NULL, +ALTER COLUMN "stageId" SET NOT NULL, +DROP COLUMN "quotaStageId"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_stageId_fkey"; + +-- DropTable +DROP TABLE "QuotaStage"; + +-- DropEnum +DROP TYPE "QuotaStageStatus"; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql new file mode 100644 index 0000000000..0036da8a12 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Made the column `description` on table `Project` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "description" SET NOT NULL, +ALTER COLUMN "description" SET DEFAULT ''; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql new file mode 100644 index 0000000000..ed6ae9b84e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql @@ -0,0 +1,198 @@ +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_environmentId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_environmentId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_userId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_userId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_projectId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_projectId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_userId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_userId_fkey"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectMembers') THEN + CREATE TABLE "ProjectMembers" ( + "projectId" UUID NOT NULL, + "userId" UUID NOT NULL, + "roleIds" TEXT[] + ); + END IF; +END $$; + +-- AlterTable +ALTER TABLE "Log" ADD COLUMN IF NOT EXISTS "projectId" UUID; + +INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt") +VALUES('04ac168a-2c4f-4816-9cce-af6c612e5912'::uuid, 'Anonymous', 'User', 'anon@user', '2023-07-03 14:46:56.770', '2023-07-03 14:46:56.770') +ON CONFLICT (id) DO NOTHING; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "everyonePerms" BIGINT NOT NULL DEFAULT 896, +ADD COLUMN IF NOT EXISTS "ownerId" UUID; + +DO $$ +DECLARE + role_row RECORD; +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR role_row IN SELECT "userId", "projectId", "role" FROM public."Role" LOOP + INSERT INTO public."ProjectMembers" ("userId", "projectId", "roleIds") VALUES (role_row."userId", role_row."projectId", '{}'); + IF role_row."role" = 'owner'::public."RoleList" THEN + UPDATE public."Project" + SET "ownerId"=role_row."userId" + WHERE id=role_row."projectId"::uuid; + END IF; + END LOOP; + END IF; +END $$; + +UPDATE public."Project" +SET "ownerId"='04ac168a-2c4f-4816-9cce-af6c612e5912' +WHERE "ownerId" IS NULL; + +ALTER TABLE public."Project" ALTER COLUMN "ownerId" SET NOT NULL; + +DELETE FROM public."ProjectMembers" pm +USING public."Project" p +WHERE pm."userId" = p."ownerId" +AND pm."projectId" = p."id"; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Permission') THEN + DROP TABLE "Permission"; + END IF; +END $$; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + DROP TABLE "Role"; + END IF; +END $$; + +-- DropEnum if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'RoleList') THEN + DROP TYPE "RoleList"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'AdminRole') THEN + CREATE TABLE "AdminRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "AdminRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectRole') THEN + CREATE TABLE "ProjectRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "projectId" UUID NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "ProjectRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_id_key') THEN + CREATE UNIQUE INDEX "AdminRole_id_key" ON "AdminRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_name_key') THEN + CREATE UNIQUE INDEX "AdminRole_name_key" ON "AdminRole"("name"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectMembers_projectId_userId_key') THEN + CREATE UNIQUE INDEX "ProjectMembers_projectId_userId_key" ON "ProjectMembers"("projectId", "userId"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectRole_id_key') THEN + CREATE UNIQUE INDEX "ProjectRole_id_key" ON "ProjectRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'Environment_projectId_name_key') THEN + CREATE UNIQUE INDEX "Environment_projectId_name_key" ON "Environment"("projectId", "name"); + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Log_projectId_fkey') THEN + ALTER TABLE "Log" ADD CONSTRAINT "Log_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Project_ownerId_fkey') THEN + ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_projectId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_userId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectRole_projectId_fkey') THEN + ALTER TABLE "ProjectRole" ADD CONSTRAINT "ProjectRole_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "adminRoleIds" TEXT[]; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql new file mode 100644 index 0000000000..c9b41827bf --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "AdminRole_name_key"; + +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "oidcGroup" TEXT; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql new file mode 100644 index 0000000000..265f262abb --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `oidcGroup` on table `AdminRole` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable + +UPDATE public."AdminRole" +SET "oidcGroup"='' +WHERE "oidcGroup" IS NULL; + +ALTER TABLE "AdminRole" ALTER COLUMN "oidcGroup" SET NOT NULL, +ALTER COLUMN "oidcGroup" SET DEFAULT ''; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql new file mode 100644 index 0000000000..4fc2768605 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "SystemSetting" +( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "SystemSetting_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemSetting_key_key" ON "SystemSetting"("key"); + +-- Create maintenance setting +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('maintenance', 'off'); diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql new file mode 100644 index 0000000000..95ab54869d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql @@ -0,0 +1,3 @@ +INSERT INTO public."AdminRole" +(id, "name", permissions, "position", "oidcGroup") +VALUES('76229c96-4716-45bc-99da-00498ec9018c'::uuid, 'Admin', 2, 0, '/admin'); \ No newline at end of file diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql new file mode 100644 index 0000000000..c116482186 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Made the column `externalUserName` on table `Repository` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +UPDATE "Repository" SET "externalUserName" = '' WHERE "externalUserName" IS NULL; + +ALTER TABLE "Repository" ALTER COLUMN "externalUserName" SET NOT NULL, +ALTER COLUMN "externalUserName" SET DEFAULT '', +ALTER COLUMN "externalRepoUrl" SET DEFAULT ''; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql new file mode 100644 index 0000000000..b0472cd806 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "TokenStatus" AS ENUM ('active', 'revoked'); + +-- CreateTable +CREATE TABLE "AdminToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "userId" UUID, + "expirationDate" TIMESTAMP(3), + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "AdminToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminToken_id_key" ON "AdminToken"("id"); + +-- AddForeignKey +ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql new file mode 100644 index 0000000000..47488b00c5 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "Log" DROP CONSTRAINT "Log_userId_fkey"; + +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "userId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql new file mode 100644 index 0000000000..18eca3eadf --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "requestId" SET DATA TYPE VARCHAR(36); diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql new file mode 100644 index 0000000000..74e0946f0a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Zone" ADD COLUMN "argocdUrl" TEXT NOT NULL DEFAULT 'https://example.com'; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql new file mode 100644 index 0000000000..41dac75359 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ProjectStatus" ADD VALUE 'warning'; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql new file mode 100644 index 0000000000..ef888d5e50 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql @@ -0,0 +1,12 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id FROM public."Project" WHERE status <> 'archived'::public."ProjectStatus" LOOP + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'nexus', 'activateMavenRepo', 'enabled') + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql new file mode 100644 index 0000000000..a57e5956c3 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql @@ -0,0 +1,12 @@ +-- CreateEnum +CREATE TYPE "UserType" AS ENUM ('human', 'bot', 'ghost'); + +-- AlterEnum +ALTER TYPE "TokenStatus" ADD VALUE 'inactive'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "type" "UserType" NOT NULL DEFAULT 'human'; +UPDATE "User" SET type = 'ghost' WHERE id = '04ac168a-2c4f-4816-9cce-af6c612e5912'; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "type" DROP DEFAULT; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql new file mode 100644 index 0000000000..71e15a312c --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql @@ -0,0 +1,84 @@ +-- CreateTable (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'PersonalAccessToken') THEN + CREATE TABLE "PersonalAccessToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "userId" UUID NOT NULL, + "expirationDate" TIMESTAMP(3) NOT NULL, + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'PersonalAccessToken_id_key') THEN + CREATE UNIQUE INDEX "PersonalAccessToken_id_key" ON "PersonalAccessToken"("id"); + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'PersonalAccessToken_userId_fkey') THEN + ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- Process AdminToken (idempotent) +DO $$ +DECLARE + admin_token record; + user_uuid UUID; +BEGIN + FOR admin_token IN SELECT "name", "id" + FROM public."AdminToken" + LOOP + -- Generate new UUID if user does not exist + user_uuid := COALESCE( + (SELECT id FROM public."User" WHERE email = concat(admin_token.name, '@bot.id')), + gen_random_uuid() + ); + + -- Insert user if not already exists + INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt", "type") + VALUES(user_uuid, 'Bot Admin', admin_token.name, concat(admin_token.name, '@bot.id'), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'bot') + ON CONFLICT (id) DO NOTHING; + + -- Update AdminToken with the new user ID + UPDATE public."AdminToken" SET "userId" = user_uuid WHERE id = admin_token.id; + END LOOP; +END $$; + +-- Alter AdminToken userId column to NOT NULL (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'AdminToken' AND column_name = 'userId' AND is_nullable = 'NO') THEN + ALTER TABLE public."AdminToken" ALTER COLUMN "userId" SET NOT NULL; + END IF; +END $$; + +-- DropForeignKey if exists (idempotent) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" DROP CONSTRAINT "AdminToken_userId_fkey"; + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql new file mode 100644 index 0000000000..521b2b10ac --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastLogin" TIMESTAMP(3); diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql new file mode 100644 index 0000000000..a7500833b9 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "slug" TEXT; + +UPDATE public."Project" p +SET slug = ( + SELECT concat(org.name, '-', subp.name) FROM public."Project" subp + LEFT JOIN public."Organization" org on org."id" = subp."organizationId" + WHERE subp.id = p.id +); + +ALTER TABLE public."Project" ALTER COLUMN "slug" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug"); diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql new file mode 100644 index 0000000000..b143cbeb9b --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "lastSuccessProvisionningVersion" TEXT; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql new file mode 100644 index 0000000000..7a88681901 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToProject_AB_unique"; + +-- AlterTable +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToStage_AB_unique"; + +-- AlterTable +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_QuotaToStage_AB_unique"; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql new file mode 100644 index 0000000000..21ce77b8d5 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "external" BOOLEAN NOT NULL DEFAULT false; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql new file mode 100644 index 0000000000..ac63cd639d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql @@ -0,0 +1,25 @@ +-- Vérifie les versions dans la table Project +DO $$ +DECLARE + project_id TEXT; + project_name TEXT; + last_version TEXT; +BEGIN + -- Boucle sur les projets non archivés + FOR project_id, project_name, last_version IN ( + SELECT id, name, "lastSuccessProvisionningVersion" + FROM "Project" + WHERE "status" != 'archived' + ) + LOOP + -- Vérifie si la version est NULL + IF last_version IS NULL THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version NULL.', project_name, project_id; + END IF; + + -- Vérifie si la version est inférieure à 8.23.0 selon SemVer + IF (string_to_array(last_version, '.')::int[] < ARRAY[8,23,0]) THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version (%), inférieure à 8.23.0.', project_name, project_id, last_version; + END IF; + END LOOP; +END $$; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql new file mode 100644 index 0000000000..54871c9017 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `organizationId` on the `Project` table. All the data in the column will be lost. + - You are about to drop the `Organization` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Project" DROP CONSTRAINT "Project_organizationId_fkey"; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "organizationId"; + +-- DropTable +DROP TABLE "Organization"; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql new file mode 100644 index 0000000000..68ca0df2fa --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "infos" SET DATA TYPE VARCHAR(1000); diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql new file mode 100644 index 0000000000..8364090d8d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql @@ -0,0 +1,44 @@ +-- AlterTable +ALTER TABLE "Environment" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; + +COMMENT ON COLUMN "Environment".cpu IS 'CPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".gpu IS 'GPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".memory IS 'Memory value as GigaBytes (1 and 0.01 are valid values)'; + +-- Use values from Quota. Memory is an extract of q.memory numeric value as it contains a unit (e.g. '2Gi'). +UPDATE "Environment" +SET cpu = q.cpu, memory = COALESCE(NULLIF(regexp_replace(q.memory, '\D', '','g'), ''), '0')::numeric +FROM "Quota" q +WHERE "quotaId" = q."id"; + +/* + Warnings: + + - You are about to drop the column `quotaId` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the `Quota` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_QuotaToStage` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_B_fkey"; + +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "quotaId", +ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- DropTable +DROP TABLE "Quota"; + +-- DropTable +DROP TABLE "_QuotaToStage"; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql new file mode 100644 index 0000000000..77f32b5ab5 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Cluster" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql new file mode 100644 index 0000000000..decca804aa --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Project" +ADD COLUMN "limitless" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "hprodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodMemory" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodMemory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql new file mode 100644 index 0000000000..95f3a689db --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql @@ -0,0 +1,4 @@ +-- Rename default zone +UPDATE "Zone" +SET ("label", "description") = ('DSO', 'Zone par défaut') +WHERE slug = 'default'; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql new file mode 100644 index 0000000000..aadb6cdba9 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Repository" ADD COLUMN "deployRevision" TEXT, +ADD COLUMN "deployPath" TEXT, +ADD COLUMN "helmValuesFiles" TEXT; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql new file mode 100644 index 0000000000..15cf56ab82 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql @@ -0,0 +1,12 @@ +-- Eliminate NULLs +UPDATE "Repository" SET "deployRevision" = '' WHERE "deployRevision" IS NULL; +UPDATE "Repository" SET "deployPath" = '' WHERE "deployPath" IS NULL; +UPDATE "Repository" SET "helmValuesFiles" = '' WHERE "helmValuesFiles" IS NULL; + +-- AlterTable +ALTER TABLE "Repository" ALTER COLUMN "deployRevision" SET NOT NULL, +ALTER COLUMN "deployRevision" SET DEFAULT '', +ALTER COLUMN "deployPath" SET NOT NULL, +ALTER COLUMN "deployPath" SET DEFAULT '', +ALTER COLUMN "helmValuesFiles" SET NOT NULL, +ALTER COLUMN "helmValuesFiles" SET DEFAULT ''; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql new file mode 100644 index 0000000000..1292495121 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "autosync" BOOLEAN NOT NULL DEFAULT true; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql new file mode 100644 index 0000000000..04c98df036 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "oidcGroup" TEXT NOT NULL DEFAULT ''; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql new file mode 100644 index 0000000000..40da41a75d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- Update AdminRole system roles +UPDATE "AdminRole" SET "type" = 'system' WHERE "name" IN ('Admin', 'Admin Locaux'); + +-- Update ProjectRole system roles +UPDATE "ProjectRole" SET "type" = 'system' WHERE "name" IN ('Administrateur', 'DevOps', 'Développeur', 'Lecture seule'); diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql new file mode 100644 index 0000000000..92c200d261 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql @@ -0,0 +1,26 @@ +-- Update existing Admin role to be system role 'Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + "oidcGroup" = '/admin', + "position" = 0 +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Lecture Seule Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '35848aa2-e881-4770-9844-0c5c3693e506'::uuid, + 'Lecture Seule Plateforme', + 1, -- Assuming 1n means bit 0 + 2, + '/readonly', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Lecture Seule Plateforme', + "type" = 'system', + "permissions" = 1, + "oidcGroup" = '/readonly'; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql new file mode 100644 index 0000000000..2a4afa8971 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql @@ -0,0 +1,28 @@ +-- Update existing Admin role to be system role 'Root Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Root Administrateur Plateforme' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Administrateur Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf'::uuid, + 'Administrateur Plateforme', + 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + 0, + '/console/admin', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, + "oidcGroup" = '/console/admin'; + +-- Update 'Lecture Seule Plateforme' system role +UPDATE "AdminRole" +SET + "oidcGroup" = '/console/readonly' +WHERE "id" = '35848aa2-e881-4770-9844-0c5c3693e506'::uuid; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql new file mode 100644 index 0000000000..f0cdb04873 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `external` on the `Cluster` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Cluster" DROP COLUMN "external"; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql new file mode 100644 index 0000000000..e07cad777b --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql @@ -0,0 +1,5 @@ +-- Enable legacy default permissions flag +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('refined-permissions', 'off'); diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql new file mode 100644 index 0000000000..efb6bc67d4 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql @@ -0,0 +1,3 @@ +-- Remove 'refined-permissions' system setting +DELETE FROM "SystemSetting" +WHERE "key" = 'refined-permissions'; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql new file mode 100644 index 0000000000..d019403c44 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql @@ -0,0 +1,15 @@ +-- Add global 'Tout le monde' role +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '034f589f-1750-4b15-bb34-4cd995e7fcaa'::uuid, + 'Tout le monde', + 25608, -- MANAGE_PROJECTS | LIST_PROJECTS | LIST_ZONES | LIST_STAGES + 0, + '', + 'global' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Tout le monde', + "type" = 'global', + "permissions" = 25608; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql new file mode 100644 index 0000000000..4fd0d80eb2 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql @@ -0,0 +1,25 @@ +-- Alter existing column to change the default value from 'custom' to 'managed' for AdminRole type +ALTER TABLE "AdminRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Alter existing column to change the default value from 'custom' to 'managed' for ProjectRole type +ALTER TABLE "ProjectRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Migrate system roles to managed roles +UPDATE "AdminRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate system roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate custom roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'custom'; + +-- Update existing Admin role to be an externally managed role +UPDATE "AdminRole" +SET "type" = 'external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql new file mode 100644 index 0000000000..d2691666bd --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql @@ -0,0 +1,15 @@ +-- Migrate well-known project roles to the new "system:"-prefixed type +UPDATE "ProjectRole" +SET "type" = 'system:managed' +WHERE + "type" = 'managed' + AND "oidcGroup" ~ '^/[^/]+/console/(admin|devops|developer|readonly)$'; + +-- Migrate well-known admin roles to the new "system:"-prefixed type +UPDATE "AdminRole" +SET "type" = 'system:managed' +WHERE id IN ( + '76229c96-4716-45bc-99da-00498ec9018c', -- Root Administrateur Plateforme + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', -- Administrateur Plateforme + '35848aa2-e881-4770-9844-0c5c3693e506' -- Lecture Seule Plateforme +); diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql new file mode 100644 index 0000000000..23593ba4f4 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "hprodCpu" DROP DEFAULT, +ALTER COLUMN "hprodGpu" DROP DEFAULT, +ALTER COLUMN "hprodMemory" DROP DEFAULT, +ALTER COLUMN "prodCpu" DROP DEFAULT, +ALTER COLUMN "prodGpu" DROP DEFAULT, +ALTER COLUMN "prodMemory" DROP DEFAULT; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql new file mode 100644 index 0000000000..7e3eefb25d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql @@ -0,0 +1,4 @@ +-- Set the Root Administrateur Plateforme admin role to external system-managed type +UPDATE "AdminRole" +SET "type" = 'system:external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql new file mode 100644 index 0000000000..ca81594339 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "DeploymentSourceType" AS ENUM ('git', 'oci'); + +-- CreateTable +CREATE TABLE "Deployment" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "name" VARCHAR(11) NOT NULL, + "autosync" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "environmentId" UUID NOT NULL, + + CONSTRAINT "Deployment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeploymentSource" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deploymentId" UUID NOT NULL, + "repositoryId" UUID NOT NULL, + "type" "DeploymentSourceType" NOT NULL, + "targetRevision" TEXT NOT NULL DEFAULT '', + "path" TEXT NOT NULL DEFAULT '', + "helmValuesFiles" TEXT NOT NULL DEFAULT '', + + CONSTRAINT "DeploymentSource_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Deployment_id_key" ON "Deployment"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentSource_id_key" ON "DeploymentSource"("id"); + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "Deployment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql new file mode 100644 index 0000000000..ad5a64d336 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_environmentId_fkey"; + +-- DropForeignKey +ALTER TABLE "DeploymentSource" DROP CONSTRAINT "DeploymentSource_repositoryId_fkey"; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/migration_lock.toml b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000..044d57cdb0 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/admin.prisma b/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/admin.prisma new file mode 100644 index 0000000000..c4ca38ccb1 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/admin.prisma @@ -0,0 +1,21 @@ +model AdminPlugin { + pluginName String + key String + value String + + @@unique([pluginName, key]) +} + +model AdminRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") +} + +model SystemSetting { + key String @id @unique + value String +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/project.prisma b/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/project.prisma new file mode 100644 index 0000000000..6bcc5ce949 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/project.prisma @@ -0,0 +1,144 @@ +model Deployment { + id String @id @unique @default(uuid()) @db.Uuid + projectId String @db.Uuid + name String @db.VarChar(11) + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + environmentId String @db.Uuid + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model DeploymentSource { + id String @id @unique @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deploymentId String @db.Uuid + repositoryId String @db.Uuid + type DeploymentSourceType + targetRevision String @default("") + path String @default("") + helmValuesFiles String @default("") + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) + deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) +} + +model Environment { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(11) + projectId String @db.Uuid + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clusterId String @db.Uuid + stageId String @db.Uuid + cluster Cluster @relation(fields: [clusterId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + stage Stage @relation(fields: [stageId], references: [id]) + deployments Deployment[] + + @@unique([projectId, name]) +} + +model Repository { + id String @id @default(uuid()) @db.Uuid + projectId String @db.Uuid + internalRepoName String + externalRepoUrl String @default("") + externalUserName String @default("") + isInfra Boolean @default(false) + isPrivate Boolean @default(false) + deployRevision String @default("") + deployPath String @default("") + helmValuesFiles String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model ProjectClusterHistory { + projectId String @db.Uuid + clusterId String @db.Uuid + + @@unique([projectId, clusterId]) +} + +model ProjectMembers { + projectId String @db.Uuid + userId String @db.Uuid + roleIds String[] + project Project @relation(fields: [projectId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@unique([projectId, userId]) +} + +model ProjectPlugin { + pluginName String + projectId String @db.Uuid + key String + value String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, pluginName, key]) +} + +model ProjectRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + projectId String @db.Uuid + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") + project Project @relation(fields: [projectId], references: [id]) +} + +model Project { + id String @id @unique @default(uuid()) @db.Uuid + name String + description String @default("") + status ProjectStatus @default(initializing) + locked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + everyonePerms BigInt @default(896) + ownerId String @db.Uuid + environments Environment[] + logs Log[] + owner User @relation(fields: [ownerId], references: [id]) + members ProjectMembers[] + plugins ProjectPlugin[] + roles ProjectRole[] + repositories Repository[] + clusters Cluster[] @relation("ClusterToProject") + slug String @unique + limitless Boolean @default(true) + hprodCpu Float @db.Real + hprodGpu Float @db.Real + hprodMemory Float @db.Real + prodCpu Float @db.Real + prodGpu Float @db.Real + prodMemory Float @db.Real + lastSuccessProvisionningVersion String? + deployments Deployment[] +} + +enum ProjectStatus { + initializing + created + failed + archived + warning +} + +enum DeploymentSourceType { + git + oci +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/schema.prisma b/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/schema.prisma new file mode 100644 index 0000000000..aadf7fea13 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/schema.prisma @@ -0,0 +1,21 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DB_URL") +} + +model Log { + id String @id @default(uuid()) @db.Uuid + data Json + action String @default("") + userId String? @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + requestId String? @db.VarChar(36) + projectId String? @db.Uuid + project Project? @relation(fields: [projectId], references: [id]) + user User? @relation(fields: [userId], references: [id]) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/token.prisma b/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/token.prisma new file mode 100644 index 0000000000..c0c55751c5 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/token.prisma @@ -0,0 +1,30 @@ +model AdminToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + userId String @db.Uuid + expirationDate DateTime? + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +model PersonalAccessToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + userId String @db.Uuid + expirationDate DateTime + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +enum TokenStatus { + active + revoked + inactive +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/topography.prisma b/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/topography.prisma new file mode 100644 index 0000000000..63d90abb8a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/topography.prisma @@ -0,0 +1,52 @@ +model Cluster { + id String @id @unique @default(uuid()) @db.Uuid + label String @unique @db.VarChar(50) + privacy ClusterPrivacy @default(dedicated) + secretName String @unique @default(uuid()) @db.VarChar(50) + clusterResources Boolean @default(false) + kubeConfigId String @unique @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + infos String? @db.VarChar(1000) + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + zoneId String @db.Uuid + kubeconfig Kubeconfig @relation(fields: [kubeConfigId], references: [id], onDelete: Cascade) + zone Zone @relation(fields: [zoneId], references: [id]) + environments Environment[] + projects Project[] @relation("ClusterToProject") + stages Stage[] @relation("ClusterToStage") +} + +model Kubeconfig { + id String @id @unique @default(uuid()) @db.Uuid + user Json + cluster Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + parentCluster Cluster? +} + +model Stage { + id String @id @unique @default(uuid()) @db.Uuid + name String @unique @db.VarChar + environments Environment[] + clusters Cluster[] @relation("ClusterToStage") +} + +model Zone { + id String @id @unique @default(uuid()) @db.Uuid + slug String @unique @db.VarChar(10) + label String @db.VarChar(50) + description String? @db.VarChar(200) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + argocdUrl String @default("https://example.com") + clusters Cluster[] +} + +enum ClusterPrivacy { + public + dedicated +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/user.prisma b/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/user.prisma new file mode 100644 index 0000000000..e90fb69f83 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/prisma/schema/user.prisma @@ -0,0 +1,23 @@ +model User { + id String @id @db.Uuid + firstName String + lastName String + email String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? + adminRoleIds String[] + type UserType + + logs Log[] + projectsOwned Project[] + adminTokens AdminToken[] + projectMembers ProjectMembers[] + personalAccessTokens PersonalAccessToken[] +} + +enum UserType { + human + bot + ghost +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/utils/crypto.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/utils/crypto.spec.ts new file mode 100644 index 0000000000..090c58583a --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/utils/crypto.spec.ts @@ -0,0 +1,33 @@ +import { generateProjectKey as legacyGenerateProjectKey } from '@cpn-console/hooks' +import { describe, expect, it } from 'vitest' +import { generateProjectKey } from './crypto.utils' + +describe('generateProjectKey', () => { + it('matches the legacy @cpn-console/hooks implementation byte-for-byte', () => { + const cases: [string, string][] = [ + ['my-app', 'my-repo'], + ['my-app', 'front'], + ['my-app-2', 'front'], + ['slug-with-many-dashes', 'repo-with-dashes'], + ['a', 'b'], + ] + for (const [slug, repo] of cases) { + expect(generateProjectKey(slug, repo)).toBe(legacyGenerateProjectKey(slug, repo)) + } + }) + + it('produces stable known keys', () => { + // Golden values: existing SonarQube projects were created with these exact keys, + // and reconciliation recomputes them to decide ownership. Any change here means + // the console would stop recognizing (and could delete) existing projects. + expect(generateProjectKey('my-app', 'my-repo')).toBe('my-app-my-repo-923f') + expect(generateProjectKey('my-app', 'front')).toBe('my-app-front-1013') + expect(generateProjectKey('my-app', 'api')).toBe('my-app-api-a814') + }) + + it('disambiguates identical slug-repo concatenations via the hash suffix', () => { + // "my-app" + "front-api" and "my-app-front" + "api" share the prefix + // "my-app-front-api-"; only the repo hash tells them apart. + expect(generateProjectKey('my-app', 'front-api')).not.toBe(generateProjectKey('my-app-front', 'api')) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/utils/crypto.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/utils/crypto.utils.ts new file mode 100644 index 0000000000..f12bebcd13 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/utils/crypto.utils.ts @@ -0,0 +1,18 @@ +import crypto, { createHmac } from 'node:crypto' + +export function generateRandomPassword(length = 24) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*' + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +// Must stay byte-for-byte identical to the legacy @cpn-console/hooks implementation: +// existing SonarQube project keys were generated with it, and ownership matching +// recomputes keys to decide which projects to reconcile or delete. +export function generateProjectKey(projectSlug: string, internalRepoName: string) { + const repoHash = createHmac('sha256', '') + .update(internalRepoName) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${internalRepoName}-${repoHash}` +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/utils/dotenv.utils.spec.ts b/.jjconflict-side-0/apps/server-nestjs/src/utils/dotenv.utils.spec.ts new file mode 100644 index 0000000000..b5c7014c46 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/utils/dotenv.utils.spec.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDotenvPaths, getExistingDotenvPaths } from './dotenv.utils' + +describe('dotenv.utils', () => { + afterEach(() => vi.unstubAllEnvs()) + + describe('getDotenvPaths', () => { + it('returns [".env"] when no env vars set', () => { + expect(getDotenvPaths()).toEqual(['.env']) + }) + + it('returns [".env", ".env.integ"] when INTEGRATION=true', () => { + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env', '.env.integ']) + }) + + it('returns [".env.docker"] when DOCKER=true', () => { + vi.stubEnv('DOCKER', 'true') + expect(getDotenvPaths()).toEqual(['.env.docker']) + }) + + it('returns [".env.integ", ".env.docker"] when DOCKER=true and INTEGRATION=true', () => { + vi.stubEnv('DOCKER', 'true') + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env.integ', '.env.docker']) + }) + }) + + describe('getExistingDotenvPaths', () => { + it('filters to only existing files', () => { + const paths = getExistingDotenvPaths() + expect(paths.every(p => typeof p === 'string')).toBe(true) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/src/utils/dotenv.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/utils/dotenv.utils.ts new file mode 100644 index 0000000000..7d3e0a0501 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/utils/dotenv.utils.ts @@ -0,0 +1,26 @@ +import fs from 'node:fs' + +export function getDotenvPaths(): string[] { + const paths: string[] = [] + + // Load .env unless DOCKER=true + if (process.env.DOCKER !== 'true') { + paths.push('.env') + } + + // Load .env.integ if INTEGRATION=true + if (process.env.INTEGRATION === 'true') { + paths.push('.env.integ') + } + + // Load .env.docker if DOCKER=true + if (process.env.DOCKER === 'true') { + paths.push('.env.docker') + } + + return paths +} + +export function getExistingDotenvPaths(): string[] { + return getDotenvPaths().filter(path => fs.existsSync(path)) +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/utils/http.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/utils/http.utils.ts new file mode 100644 index 0000000000..b85cc2f78b --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/utils/http.utils.ts @@ -0,0 +1,20 @@ +/** HTTP status carried by errors such as keycloak-admin-client's NetworkError. */ +export function getErrorResponseStatus(error: unknown): number | undefined { + if (error instanceof Error && 'response' in error && error.response instanceof Response) { + return error.response.status + } + return undefined +} + +/** HTTP details (status, url, response body) attached to errors carrying a fetch Response. */ +export function getErrorHttpDetails(error: Error): Record { + const details: Record = {} + if ('response' in error && error.response instanceof Response) { + details.status = error.response.status + details.url = error.response.url + } + if ('responseData' in error && error.responseData !== undefined && error.responseData !== '') { + details.responseData = error.responseData + } + return details +} diff --git a/.jjconflict-side-0/apps/server-nestjs/src/utils/iterable.utils.ts b/.jjconflict-side-0/apps/server-nestjs/src/utils/iterable.utils.ts new file mode 100644 index 0000000000..a46ef80632 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/src/utils/iterable.utils.ts @@ -0,0 +1,26 @@ +export async function* map( + iterable: AsyncIterable, + mapper: (value: T, index: number) => U | Promise, +): AsyncIterable { + let index = 0 + for await (const value of iterable) { + yield await mapper(value, index++) + } +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} + +export async function find(generator: AsyncGenerator, predicate: (item: T) => boolean): Promise { + for await (const item of generator) { + if (predicate(item)) return item + } + return undefined +} diff --git a/.jjconflict-side-0/apps/server-nestjs/test/argocd.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/argocd.e2e-spec.ts new file mode 100644 index 0000000000..65e0ea7497 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/argocd.e2e-spec.ts @@ -0,0 +1,314 @@ +import type { CommitAction, Gitlab } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { baseConfigFactory } from '../src/config/base.config' +import { projectSelect } from '../src/modules/argocd/argocd-datastore.service' +import { ArgoCDModule } from '../src/modules/argocd/argocd.module' +import { ArgoCDService } from '../src/modules/argocd/argocd.service' +import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunArgoCDE2E + = Boolean(process.env.E2E) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + && Boolean(process.env.DB_URL) + +const describeWithArgoCD = describe.runIf(canRunArgoCDE2E) + +describeWithArgoCD('ArgoCDController (e2e)', {}, () => { + let moduleRef: TestingModule + let argocdController: ArgoCDService + let gitlab: GitlabClientService + let gitlabClient: Gitlab + let vault: VaultClientService + let prisma: PrismaService + let config: BaseConfig + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + let zoneId: string + let zoneSlug: string + let kubeconfigId: string + let clusterId: string + let clusterLabel: string + let stageId: string + let envDevId: string + let envProdId: string + let envDevName: string + let envProdName: string + + let infraRepoId: number | undefined + let infraRepoPath: string + let vaultProjectValuesPath: string | undefined + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ArgoCDModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + argocdController = moduleRef.get(ArgoCDService) + gitlab = moduleRef.get(GitlabClientService) + gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) + vault = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + config = moduleRef.get(baseConfigFactory) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + zoneId = faker.string.uuid() + zoneSlug = faker.string.alphanumeric({ length: 10 }).toLowerCase() + kubeconfigId = faker.string.uuid() + clusterId = faker.string.uuid() + clusterLabel = faker.helpers.slugify(`cluster-${faker.string.uuid()}`.slice(0, 40)) + stageId = faker.string.uuid() + envDevId = faker.string.uuid() + envProdId = faker.string.uuid() + envDevName = 'dev' + envProdName = 'prod' + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.zone.create({ + data: { + id: zoneId, + slug: zoneSlug, + label: `Zone ${zoneSlug}`, + argocdUrl: 'https://example.com', + }, + }) + + await prisma.kubeconfig.create({ + data: { + id: kubeconfigId, + user: { token: faker.string.alphanumeric({ length: 16 }) }, + cluster: { server: 'https://example.com' }, + }, + }) + + await prisma.cluster.create({ + data: { + id: clusterId, + label: clusterLabel, + secretName: faker.string.uuid(), + kubeConfigId: kubeconfigId, + infos: null, + memory: 100, + cpu: 100, + gpu: 0, + zoneId, + }, + }) + + await prisma.stage.create({ + data: { + id: stageId, + name: faker.helpers.slugify(`stage-${faker.string.uuid()}`), + }, + }) + + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + clusters: { + connect: { id: clusterId }, + }, + repositories: { + create: { + internalRepoName: zoneSlug, + isInfra: true, + deployRevision: 'HEAD', + deployPath: '.', + helmValuesFiles: '', + }, + }, + environments: { + create: [ + { + id: envDevId, + name: envDevName, + clusterId, + stageId, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + }, + { + id: envProdId, + name: envProdName, + clusterId, + stageId, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + }, + ], + }, + }, + }) + + infraRepoPath = `${config.projectsRootDir}/infra/${zoneSlug}` + try { + const existing = await gitlabClient.Projects.show(infraRepoPath) + if (existing.empty_repo || existing.default_branch !== 'main') { + await gitlabClient.Projects.remove(existing.id).catch(() => {}) + throw new Error('Recreate infra repo') + } + infraRepoId = existing.id + } catch (error: any) { + const description = error?.cause?.description ?? '' + if ( + !(typeof description === 'string' && description.includes('404')) + && !(error instanceof Error && error.message === 'Recreate infra repo') + ) { + throw error + } + + const infraGroup = await gitlab.getOrCreateProjectSubGroup('infra') + const created = await gitlabClient.Projects.create({ + name: zoneSlug, + path: zoneSlug, + namespaceId: infraGroup.id, + initializeWithReadme: true, + defaultBranch: 'main', + }) + infraRepoId = created.id + } + + vaultProjectValuesPath = `${config.projectsRootDir}/${testProjectId}` + await vault.write({ e2e: true }, vaultProjectValuesPath) + }) + + afterAll(async () => { + if (vaultProjectValuesPath) { + await vault.delete(vaultProjectValuesPath).catch(() => {}) + } + + if (infraRepoId) { + await gitlabClient.Projects.remove(infraRepoId).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.stage.deleteMany({ where: { id: stageId } }).catch(() => {}) + await prisma.cluster.deleteMany({ where: { id: clusterId } }).catch(() => {}) + await prisma.kubeconfig.deleteMany({ where: { id: kubeconfigId } }).catch(() => {}) + await prisma.zone.deleteMany({ where: { id: zoneId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should commit environment values and cleanup stale values in the zone infra repo', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + const infraProject = await gitlab.getOrCreateInfraGroupRepo(zoneSlug) + infraRepoId = infraProject.id + + const staleFilePath = `${project.name}/${clusterLabel}/stale/values.yaml` + if (!infraRepoId) throw new Error('Missing infra repo id') + const staleAction = await gitlab.generateCreateOrUpdateAction(infraProject, 'main', staleFilePath, 'stale: true\n') + await gitlab.maybeCreateCommit(infraProject, 'ci: :robot_face: Seed stale values', staleAction ? [staleAction] : []) + + await argocdController.handleUpsert(project) + + const expectedFilePath = `${project.name}/${clusterLabel}/${envDevName}/values.yaml` + const file = await gitlabClient.RepositoryFiles.show(infraRepoId, expectedFilePath, 'main') + const raw = Buffer.from(file.content, 'base64').toString('utf8') + const values = parse(raw) + + expect(values?.common?.['dso/project.slug']).toBe(testProjectSlug) + expect(values?.common?.['dso/environment']).toBe(envDevName) + expect(values?.environment?.valueFilePath).toBe(expectedFilePath) + expect(values?.application?.destination?.name).toBe(clusterLabel) + expect(values?.application?.autosync).toBe(true) + + const shouldBeDeleted = await gitlab.getFile(infraProject, staleFilePath, 'main') + expect(shouldBeDeleted).toBeUndefined() + }, 144000) + + it('should update existing values and delete values of a removed environment', async () => { + const before = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + const infraProject = await gitlab.getOrCreateInfraGroupRepo(zoneSlug) + infraRepoId = infraProject.id + + const devFilePath = `${before.name}/${clusterLabel}/${envDevName}/values.yaml` + const prodFilePath = `${before.name}/${clusterLabel}/${envProdName}/values.yaml` + + const seededActions = (await Promise.all([ + gitlab.generateCreateOrUpdateAction(infraProject, 'main', devFilePath, 'old: true\n'), + gitlab.generateCreateOrUpdateAction(infraProject, 'main', prodFilePath, 'old: true\n'), + ])).filter((action): action is NonNullable => action !== null) + await gitlab.maybeCreateCommit(infraProject, 'ci: :robot_face: Seed existing values', seededActions as CommitAction[]) + + await prisma.environment.deleteMany({ where: { id: envProdId } }) + + const after = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await argocdController.handleUpsert(after) + + const updatedDev = await gitlabClient.RepositoryFiles.show(infraRepoId, devFilePath, 'main') + const devRaw = Buffer.from(updatedDev.content, 'base64').toString('utf8') + const devValues = parse(devRaw) + expect(devValues?.common?.['dso/project.slug']).toBe(testProjectSlug) + expect(devValues?.common?.['dso/environment']).toBe(envDevName) + + const prodFile = await gitlab.getFile(infraProject, prodFilePath, 'main') + expect(prodFile).toBeUndefined() + }, 72000) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/test/gitlab.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/gitlab.e2e-spec.ts new file mode 100644 index 0000000000..e8d19c497b --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/gitlab.e2e-spec.ts @@ -0,0 +1,242 @@ +import type { ExpandedUserSchema, Gitlab } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' +import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' +import { projectSelect } from '../src/modules/gitlab/gitlab-datastore.service' +import { GitlabModule } from '../src/modules/gitlab/gitlab.module' +import { GitlabService } from '../src/modules/gitlab/gitlab.service' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunGitlabE2E + = Boolean(process.env.E2E) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + && Boolean(process.env.DB_URL) + +const describeWithGitLab = describe.runIf(canRunGitlabE2E) + +describeWithGitLab('GitlabController (e2e)', {}, () => { + let moduleRef: TestingModule + let gitlabController: GitlabService + let gitlabService: GitlabClientService + let gitlabClient: Gitlab + let vaultService: VaultClientService + let prisma: PrismaService + let config: BaseConfig + + let testProjectId: string + let testProjectSlug: string + let ownerId: string + let ownerUser: ExpandedUserSchema + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [GitlabModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + gitlabController = moduleRef.get(GitlabService) + gitlabService = moduleRef.get(GitlabClientService) + gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) + vaultService = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + config = moduleRef.get(baseConfigFactory) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + const ownerEmail = `test-owner-${ownerId}@example.com` + + // Create owner in GitLab + ownerUser = await gitlabClient.Users.create({ + name: 'Test Owner', + password: faker.internet.password({ length: 24 }), + username: `test-owner-${ownerId}`, + email: ownerEmail, + skipConfirmation: true, + }) + + // Create owner in DB + await prisma.user.create({ + data: { + id: ownerId, + email: ownerUser.email.toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + await prisma.repository.create({ + data: { + projectId: testProjectId, + internalRepoName: 'app', + externalRepoUrl: 'https://example.com/example.git', + isPrivate: false, + }, + }) + }) + + afterAll(async () => { + // Clean GitLab group + if (testProjectSlug && config.projectsRootDir) { + const fullPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = await gitlabService.getGroupByPath(fullPath) + if (group) { + await gitlabService.deleteGroup(group).catch(() => {}) + } + } + + // Clean Vault + if (testProjectSlug && config.projectsRootDir) { + const vaultPath = `${config.projectsRootDir}/${testProjectSlug}` + await vaultService.delete(`${vaultPath}/tech/GITLAB_MIRROR`).catch(() => {}) + await vaultService.delete(`${vaultPath}/app-mirror`).catch(() => {}) + } + + // Clean DB + if (prisma) { + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + if (ownerUser?.id) { + await gitlabClient.Users.remove(ownerUser.id).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should reconcile and create project group in GitLab and Vault secrets', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await gitlabController.handleUpsert(project) + + // Assert + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = z.object({ + id: z.number(), + name: z.string(), + full_path: z.string(), + web_url: z.string(), + }).parse(await gitlabService.getGroupByPath(groupPath)) + expect(group.full_path).toBe(groupPath) + + // Check membership + const members = await gitlabService.getGroupMembers(group) + const isMember = members.some(m => m.id === ownerUser.id) + expect(isMember).toBe(true) + + const repoVaultPath = `${config.projectsRootDir}/${testProjectSlug}/app-mirror` + const repoSecret = await vaultService.read(repoVaultPath) + expect(repoSecret?.data?.GIT_OUTPUT_USER).toBeTruthy() + expect(repoSecret?.data?.GIT_OUTPUT_PASSWORD).toBeTruthy() + }, 72000) + + describe('project members', () => { + let newUserId: string | undefined + let newUserGitlabId: number | undefined + + beforeAll(async () => { + newUserId = faker.string.uuid() + const newUser = await gitlabClient.Users.create({ + email: faker.internet.email().toLowerCase(), + username: faker.internet.username(), + name: `${faker.person.firstName()} ${faker.person.lastName()}`, + password: faker.internet.password({ length: 24 }), + skipConfirmation: true, + }) + newUserGitlabId = newUser.id + + await prisma.user.create({ + data: { + id: newUserId, + email: newUser.email, + firstName: 'Test', + lastName: 'User', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: newUserId, + roleIds: [], + }, + }) + }) + + afterAll(async () => { + if (newUserGitlabId) { + await gitlabClient.Users.remove(newUserGitlabId).catch(() => {}) + } + if (prisma && newUserId) { + await prisma.projectMembers.deleteMany({ where: { userId: newUserId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: newUserId } }).catch(() => {}) + } + }) + + it('should add member to GitLab group when added in DB', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await gitlabController.handleUpsert(project) + + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = z.object({ + id: z.number(), + name: z.string(), + web_url: z.string(), + }).parse(await gitlabService.getGroupByPath(groupPath)) + + const members = await gitlabService.getGroupMembers(group) + const isNewMemberPresent = members.some(m => m.id === newUserGitlabId) + expect(isNewMemberPresent).toBe(true) + }, 72000) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/test/keycloak.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/keycloak.e2e-spec.ts new file mode 100644 index 0000000000..9875253678 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -0,0 +1,537 @@ +import type KcAdminClient from '@keycloak/keycloak-admin-client' +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Logger } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { projectSelect } from '../src/modules/keycloak/keycloak-datastore.service' +import { KeycloakModule } from '../src/modules/keycloak/keycloak.module' +import { KeycloakService } from '../src/modules/keycloak/keycloak.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunKeycloakE2E + = Boolean(process.env.E2E) + && Boolean(process.env.KEYCLOAK_DOMAIN) + && Boolean(process.env.KEYCLOAK_REALM) + && Boolean(process.env.KEYCLOAK_PROTOCOL) + && Boolean(process.env.KEYCLOAK_ADMIN) + && Boolean(process.env.KEYCLOAK_ADMIN_PASSWORD) + +const describeWithKeycloak = describe.runIf(canRunKeycloakE2E) + +describeWithKeycloak('KeycloakController (e2e)', () => { + let moduleRef: TestingModule + let keycloakController: KeycloakService + let keycloak: KeycloakClientService + let keycloakAdminClient: KcAdminClient + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + let testRoleName: string + let testRoleId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [KeycloakModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + keycloakController = moduleRef.get(KeycloakService) + keycloak = moduleRef.get(KeycloakClientService) + keycloakAdminClient = moduleRef.get(KEYCLOAK_ADMIN_CLIENT) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + testRoleName = faker.helpers.slugify(`test-role-${faker.string.uuid()}`) + testRoleId = faker.string.uuid() + + const ownerEmail = faker.internet.email({ firstName: 'test-owner', provider: 'example.com' }) + + // Create owner in Keycloak + const createdUser = await keycloakAdminClient.users.create({ + id: ownerId, + username: `test-owner-${ownerId}`, + email: ownerEmail, + enabled: true, + firstName: 'Test', + lastName: 'Owner', + }) + if (createdUser.id) { + ownerId = createdUser.id + } + + // Create owner in DB + await prisma.user.create({ + data: { + id: ownerId, + email: ownerEmail, + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + try { + // Clean Keycloak + const group = await keycloak.getGroupByPath(`/${testProjectSlug}`) + if (group?.id) { + await keycloak.deleteGroup(group.id) + } + + // Clean owner user + if (ownerId) { + await keycloakAdminClient.users.del({ id: ownerId }).catch(() => {}) + if (prisma) { + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + } + + // Clean DB + if (prisma) { + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId } }) + // Prisma cascade delete should handle roles/envs if configured correctly, but explicit delete is safer + // We catch errors to avoid failing cleanup if tables/relations are different + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + } + } catch (e: any) { + Logger.warn(`Cleanup failed: ${e.message}`) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('should reconcile and create groups in Keycloak', async () => { + // Create Project in DB + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + roles: { + create: { + id: testRoleId, + name: testRoleName, + oidcGroup: `/${testRoleName}`, + permissions: BigInt(0), + position: 0, + }, + }, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await keycloakController.handleUpsert(project) + + // Assert + // Check main project group + const projectGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + expect(projectGroup.name).toBe(testProjectSlug) + + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console`)) + expect(consoleGroup.name).toBe('console') + + // Check role group + const roleGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(roleGroup.name).toBe(testRoleName) + + // Check membership (owner should be added) + const members = await keycloak.getGroupMembers(projectGroup.id) + const isMember = members.some(m => m.id === ownerId) + expect(isMember).toBe(true) + }, 60000) + + it('should add member to project group when added in DB', async () => { + // Create another user in Keycloak and DB + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-${newUserId}@example.com` + + // Create in Keycloak + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'User', + }) + + // Create in DB + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'User', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [testRoleId], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await keycloakController.handleUpsert(project) + + // Assert + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + const members = await keycloak.getGroupMembers(projectGroup.id) + const isMember = members.some(m => m.id === kcUser.id) + expect(isMember).toBe(true) + + // Check role group membership + const roleGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + const roleMembers = await keycloak.getGroupMembers(roleGroup.id) + const isRoleMember = roleMembers.some(m => m.id === kcUser.id) + expect(isRoleMember).toBe(true) + + // Cleanup user + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should remove member from project group when removed in DB', async () => { + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-remove-${newUserId}@example.com` + + // Create in Keycloak + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-remove-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserRemove', + }) + + // Create in DB + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserRemove', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [], // No roles + }, + }) + + let project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync add + await keycloakController.handleUpsert(project) + + // Verify added + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Remove from DB + await prisma.projectMembers.delete({ + where: { + projectId_userId: { + projectId: testProjectId, + userId: kcUser.id, + }, + }, + }) + + project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync remove + await keycloakController.handleUpsert(project) + + // Verify removed + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should handle non-existent users gracefully', async () => { + // Add a member in DB that does not exist in Keycloak + const fakeUserId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: fakeUserId, + email: `fake-${fakeUserId}@example.com`, + firstName: 'Fake', + lastName: 'User', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: fakeUserId, + roleIds: [], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act - should not throw + await expect(keycloakController.handleUpsert(project)).resolves.not.toThrow() + + // Cleanup + await prisma.projectMembers.deleteMany({ where: { userId: fakeUserId } }) + await prisma.user.delete({ where: { id: fakeUserId } }) + }, 60000) + + it('should add user back to Keycloak group if missing but present in DB', async () => { + // Create user and add to project in DB + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-sync-${newUserId}@example.com` + + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-sync-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserSync', + }) + + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserSync', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync to ensure they are added initially + await keycloakController.handleUpsert(project) + + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + + // Manually remove user from Keycloak group + await keycloak.removeUserFromGroup(kcUser.id, projectGroup.id) + + // Verify removal + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Sync again + await keycloakController.handleUpsert(project) + + // Verify added back + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should remove user from Keycloak group if present but missing in DB', async () => { + // Create user + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-orphan-${newUserId}@example.com` + + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-orphan-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserOrphan', + }) + + // We only need them in Keycloak for this test, but the controller checks if user is in DB to define "missing". + // Actually, `deleteExtraProjectMembers` iterates over Keycloak group members. + // So we don't strictly need the user in DB, but to be "clean" we should probably have them in DB but NOT in the project. + + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserOrphan', + type: 'human', + }, + }) + + // Get project from DB (user is NOT a member) + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync to create group + await keycloakController.handleUpsert(project) + + // Manually add user to Keycloak group + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + await keycloak.addUserToGroup(kcUser.id, projectGroup.id) + + // Verify added + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Sync again to remove user + await keycloakController.handleUpsert(project) + + // Verify removed + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should recreate project group if deleted in Keycloak', async () => { + // Ensure project exists and is synced + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await keycloakController.handleUpsert(project) + + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + + // Delete group in Keycloak + await keycloak.deleteGroup(projectGroup.id) + + // Verify deleted + const deletedProjectGroup = await keycloak.getGroupByPath(`/${testProjectSlug}`) + expect(deletedProjectGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedProjectGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + expect(recreatedProjectGroup?.name).toBe(testProjectSlug) + }, 60000) + + it('should recreate role group if deleted in Keycloak', async () => { + // Ensure project exists and is synced + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await keycloakController.handleUpsert(project) + + const roleGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + + // Delete role group in Keycloak + await keycloak.deleteGroup(roleGroup.id) + + // Verify deleted + const deletedRoleGroup = await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`) + expect(deletedRoleGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedRoleGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(recreatedRoleGroup?.name).toBe(testRoleName) + }, 60000) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/test/log.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/log.e2e-spec.ts new file mode 100644 index 0000000000..2b5f509b3f --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/log.e2e-spec.ts @@ -0,0 +1,188 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { LogModule } from '../src/modules/log/log.module' +import { LogService } from '../src/modules/log/log.service' + +const canRunLogE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + +const describeWithLog = describe.runIf(canRunLogE2E) + +describeWithLog('LogService (e2e)', () => { + let moduleRef: TestingModule + let logService: LogService + let prisma: PrismaService + + let ownerId: string + let projectId: string + let projectSlug: string + let projectLogId: string + let globalLogId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [LogModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + logService = moduleRef.get(LogService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + if (projectLogId) { + await prisma.log.deleteMany({ where: { id: projectLogId } }).catch(() => {}) + } + if (globalLogId) { + await prisma.log.deleteMany({ where: { id: globalLogId } }).catch(() => {}) + } + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should persist logs and return clean/project-filtered results', async () => { + const projectLog = await logService.addLog({ + action: 'project-upsert', + userId: ownerId, + projectId, + requestId: faker.string.uuid(), + data: { + args: { slug: projectSlug }, + results: { ok: true }, + config: { hidden: true }, + cluster: { id: 'cluster-1' }, + user: { id: ownerId }, + newCreds: { token: 'secret' }, + apis: ['v1'], + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }, + }) + projectLogId = projectLog.id + + const globalLog = await logService.addLog({ + action: 'global-upsert', + data: { + args: { enabled: true }, + results: { ok: true }, + config: { hidden: true }, + }, + }) + globalLogId = globalLog.id + + const storedLog = await prisma.log.findUniqueOrThrow({ + where: { id: projectLog.id }, + }) + expect(storedLog.data).toMatchObject({ + args: { slug: projectSlug }, + results: { ok: true }, + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }) + expect(storedLog.data).not.toHaveProperty('config') + expect(storedLog.data).not.toHaveProperty('cluster') + expect(storedLog.data).not.toHaveProperty('user') + expect(storedLog.data).not.toHaveProperty('newCreds') + expect(storedLog.data).not.toHaveProperty('apis') + + const { total: projectTotal, logs: projectLogs } = await logService.getLogs({ + offset: 0, + limit: 10, + projectId, + clean: false, + }) + expect(projectTotal).toBe(1) + expect(projectLogs).toHaveLength(1) + expect(projectLogs[0]).toMatchObject({ + id: projectLog.id, + action: 'project-upsert', + userId: ownerId, + projectId, + }) + expect(projectLogs[0].data).toMatchObject({ + args: { slug: projectSlug }, + results: { ok: true }, + warning: ['careful'], + }) + expect(projectLogs[0].data).not.toHaveProperty('config') + + const { total: allTotal, logs: allLogs } = await logService.getLogs({ + offset: 0, + limit: 10, + projectId: undefined, + clean: true, + }) + expect(allTotal).toBeGreaterThan(2) + expect(allLogs).toHaveLength(10) + expect(allLogs[0]).toMatchObject({ + id: globalLog.id, + action: 'global-upsert', + userId: null, + }) + expect(allLogs[0].data).not.toHaveProperty('args') + expect(allLogs[0].data).not.toHaveProperty('results') + expect(allLogs[0].data).not.toHaveProperty('config') + expect(allLogs[1]).toMatchObject({ + id: projectLog.id, + action: 'project-upsert', + userId: ownerId, + }) + expect(allLogs[1].data).toMatchObject({ + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }) + expect(allLogs[1].data).not.toHaveProperty('args') + expect(allLogs[1].data).not.toHaveProperty('results') + expect(allLogs[1].data).not.toHaveProperty('config') + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/test/nexus.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/nexus.e2e-spec.ts new file mode 100644 index 0000000000..0c5b45eb5d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/nexus.e2e-spec.ts @@ -0,0 +1,156 @@ +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { NexusClientService } from '../src/modules/nexus/nexus-client.service' +import { projectSelect } from '../src/modules/nexus/nexus-datastore.service' +import { makeProjectWithDetails } from '../src/modules/nexus/nexus-testing.utils' +import { NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, PLUGIN_NAME } from '../src/modules/nexus/nexus.constants' +import { NexusModule } from '../src/modules/nexus/nexus.module' +import { NexusService } from '../src/modules/nexus/nexus.service' +import { getProjectVaultPath } from '../src/modules/nexus/nexus.utils' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunNexusE2E + = Boolean(process.env.E2E) + && Boolean(process.env.NEXUS_URL) + && Boolean(process.env.NEXUS_ADMIN) + && Boolean(process.env.NEXUS_ADMIN_PASSWORD) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithNexus = describe.runIf(canRunNexusE2E) + +describeWithNexus('NexusController (e2e)', () => { + let moduleRef: TestingModule + let nexusController: NexusService + let nexusClient: NexusClientService + let vaultService: VaultClientService + let config: BaseConfig + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [NexusModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + nexusController = moduleRef.get(NexusService) + nexusClient = moduleRef.get(NexusClientService) + vaultService = moduleRef.get(VaultClientService) + config = moduleRef.get(baseConfigFactory) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (testProjectSlug) { + await nexusController.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should reconcile project in Nexus (repos, role, user, vault secret)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + plugins: { + create: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }, + ], + }, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await nexusController.handleUpsert(project) + + const mavenReleaseRepo = `${testProjectSlug}-repository-release` + const mavenSnapshotRepo = `${testProjectSlug}-repository-snapshot` + const mavenGroupRepo = `${testProjectSlug}-repository-group` + + const npmHostedRepo = `${testProjectSlug}-npm` + const npmGroupRepo = `${testProjectSlug}-npm-group` + + const [releaseRepo, snapshotRepo, groupRepo, npmRepo, npmGroup] = await Promise.all([ + nexusClient.getRepositoriesMavenHosted(mavenReleaseRepo), + nexusClient.getRepositoriesMavenHosted(mavenSnapshotRepo), + nexusClient.getRepositoriesMavenGroup(mavenGroupRepo), + nexusClient.getRepositoriesNpmHosted(npmHostedRepo), + nexusClient.getRepositoriesNpmGroup(npmGroupRepo), + ]) + + expect(releaseRepo).toBeTruthy() + expect(snapshotRepo).toBeTruthy() + expect(groupRepo).toBeTruthy() + expect(npmRepo).toBeTruthy() + expect(npmGroup).toBeTruthy() + + const roleId = `${testProjectSlug}-ID` + const role = await nexusClient.getSecurityRoles(roleId) + expect(role).toBeTruthy() + + const users = await nexusClient.getSecurityUsers(testProjectSlug) + expect(users.some(u => u.userId === testProjectSlug)).toBe(true) + + const vaultPath = getProjectVaultPath(config.projectsRootDir, testProjectSlug, 'tech/NEXUS') + const secret = await vaultService.read(vaultPath) + expect(secret.data?.NEXUS_USERNAME).toBe(testProjectSlug) + expect(secret.data?.NEXUS_PASSWORD).toBeTruthy() + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/test/project-bulk.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/project-bulk.e2e-spec.ts new file mode 100644 index 0000000000..3a1e80f39d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/project-bulk.e2e-spec.ts @@ -0,0 +1,109 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectBulkModule } from '../src/modules/project-bulk/project-bulk.module' +import { ProjectBulkService } from '../src/modules/project-bulk/project-bulk.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectBulkE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectBulk = describe.runIf(canRunProjectBulkE2E) + +describeWithProjectBulk('ProjectBulkService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectBulkService + let eventEmitter: EventEmitter2 + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectBulkModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectBulkService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('locks and unlocks projects', async () => { + await service.bulkAction({ action: 'lock', projectIds: [projectId] }) + const locked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } }) + expect(locked.locked).toBe(true) + + await service.bulkAction({ action: 'unlock', projectIds: [projectId] }) + const unlocked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } }) + expect(unlocked.locked).toBe(false) + }) + + it('replays hooks for projects', async () => { + await service.bulkAction({ action: 'replay', projectIds: [projectId] }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith( + 'project.upsert', + expect.objectContaining({ id: projectId, slug: projectSlug }), + ) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/test/project-hooks.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/project-hooks.e2e-spec.ts new file mode 100644 index 0000000000..ab6e75184e --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/project-hooks.e2e-spec.ts @@ -0,0 +1,119 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectHooksModule } from '../src/modules/project-hooks/project-hooks.module' +import { ProjectHooksService } from '../src/modules/project-hooks/project-hooks.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectHooksE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectHooks = describe.runIf(canRunProjectHooksE2E) + +describeWithProjectHooks('ProjectHooksService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectHooksService + let eventEmitter: DeepMockProxy + let vaultService: DeepMockProxy + let vaultClient: DeepMockProxy + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + vaultService = mockDeep() + vaultService.listProjectSecrets.mockResolvedValue([]) + vaultClient = mockDeep() + + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectHooksModule], + providers: [ + { provide: VaultService, useValue: vaultService }, + { provide: VaultClientService, useValue: vaultClient }, + ], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectHooksService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('replays hooks through the event system', async () => { + await service.replay(projectId) + + expect(eventEmitter.emitAsync).toHaveBeenCalledWith( + 'project.upsert', + expect.objectContaining({ id: projectId, slug: projectSlug }), + ) + }) + + it('rejects replayHooks when the project does not exist', async () => { + const result = await service.replay(faker.string.uuid()) + expect(result).toBeUndefined() + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/test/project-members.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/project-members.e2e-spec.ts new file mode 100644 index 0000000000..10dec97860 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/project-members.e2e-spec.ts @@ -0,0 +1,169 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { ProjectMembersModule } from '../src/modules/project-members/project-members.module' +import { ProjectMembersService } from '../src/modules/project-members/project-members.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectMembersE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectMembers = describe.runIf(canRunProjectMembersE2E) + +describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectMembersService + let eventEmitter: DeepMockProxy + let keycloakClient: DeepMockProxy + + let ownerId: string + let memberId: string + + beforeAll(async () => { + keycloakClient = mockDeep() + keycloakClient.getUserByEmail.mockResolvedValue(undefined) + + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectMembersModule], + providers: [ + { provide: KeycloakClientService, useValue: keycloakClient }, + ], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectMembersService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + memberId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.user.create({ + data: { + id: memberId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Member', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.user.deleteMany({ where: { id: memberId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('rejects addMember when project does not exist', async () => { + await expect(service.add(faker.string.uuid(), { userId: memberId })).rejects.toThrow(NotFoundException) + }) + + it('rejects listMembers when project does not exist', async () => { + await expect(service.list(faker.string.uuid())).resolves.toEqual([]) + }) + + describe('with project', () => { + let projectId: string + + beforeEach(async () => { + eventEmitter.emitAsync.mockClear() + + projectId = faker.string.uuid() + const projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + + eventEmitter.emitAsync.mockClear() + }) + + afterEach(async () => { + await prisma.projectMembers.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + }) + + it('list', async () => { + const members = await service.list(projectId) + expect(members).toHaveLength(0) + }) + + it('add', async () => { + const afterAdd = await service.add(projectId, { userId: memberId }) + expect(afterAdd.some(m => m.userId === memberId)).toBe(true) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.upsert', { projectId, userId: memberId }) + }) + + it('patch', async () => { + await service.add(projectId, { userId: memberId }) + const roleId = faker.string.uuid() + const afterPatch = await service.patch(projectId, [{ userId: memberId, roles: [roleId] }]) + expect(afterPatch.find(m => m.userId === memberId)?.roleIds).toContain(roleId) + }) + + it('remove', async () => { + await service.add(projectId, { userId: memberId }) + const afterRemove = await service.remove(projectId, memberId) + expect(afterRemove.some(m => m.userId === memberId)).toBe(false) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.delete', { projectId, userId: memberId }) + }) + + it('rejects addMember when adding owner', async () => { + await expect(service.add(projectId, { userId: ownerId })).rejects.toThrow(BadRequestException) + }) + + it('rejects addMember when user does not exist', async () => { + await expect(service.add(projectId, { userId: faker.string.uuid() })).rejects.toThrow(NotFoundException) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/test/project-roles.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/project-roles.e2e-spec.ts new file mode 100644 index 0000000000..0f530ebf53 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/project-roles.e2e-spec.ts @@ -0,0 +1,238 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectRolesModule } from '../src/modules/project-roles/project-roles.module' +import { ProjectRolesService } from '../src/modules/project-roles/project-roles.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectRolesE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectRoles = describe.runIf(canRunProjectRolesE2E) + +describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectRolesService + let eventEmitter: DeepMockProxy + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectRolesModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectRolesService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-roles-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E roles test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.projectRole.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('lists roles for a project', async () => { + const role = await prisma.projectRole.create({ + data: { + projectId, + name: 'admin', + permissions: 4n, + position: 0, + type: 'managed', + oidcGroup: `/${projectSlug}/admin`, + }, + }) + + const roles = await service.list(projectId) + + expect(roles).toHaveLength(1) + expect(roles[0].id).toBe(role.id) + expect(roles[0].permissions).toBe('4') + expect(roles[0].oidcGroup).toBe('/admin') + }) + + it('creates a role and emits project upsert', async () => { + const role = await service.create(projectId, { name: 'developer', permissions: '2' }) + + expect(role.length).toBeGreaterThanOrEqual(1) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', expect.objectContaining({ id: projectId })) + + const dbRole = await prisma.projectRole.findFirst({ + where: { projectId, name: 'developer' }, + }) + expect(dbRole).not.toBeNull() + expect(dbRole?.permissions).toBe(2n) + }) + + it('patches roles and updates position', async () => { + const existingRole = await prisma.projectRole.create({ + data: { + projectId, + name: 'tester', + permissions: 1n, + position: 5, + type: 'managed', + oidcGroup: '', + }, + }) + + await prisma.projectRole.create({ + data: { + projectId, + name: 'other', + permissions: 2n, + position: 6, + type: 'managed', + oidcGroup: '', + }, + }) + + const roles = await service.update(projectId, [{ + id: existingRole.id, + name: 'qa', + }]) + + const updated = roles.find(r => r.id === existingRole.id) + expect(updated?.name).toBe('qa') + }) + + it('counts role members', async () => { + const roleId = faker.string.uuid() + await prisma.projectRole.create({ + data: { + id: roleId, + projectId, + name: 'counted', + permissions: 1n, + position: 10, + type: 'managed', + oidcGroup: '', + }, + }) + + const userId = faker.string.uuid() + await prisma.user.create({ + data: { + id: userId, + email: faker.internet.email().toLowerCase(), + firstName: 'Member', + lastName: 'Test', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId, + userId, + roleIds: [roleId], + }, + }) + + const counts = await service.countMembers(projectId) + expect(counts[roleId]).toBe(1) + }) + + it('deletes a role and cleans member role ids', async () => { + const roleId = faker.string.uuid() + const userId = faker.string.uuid() + + await prisma.projectRole.create({ + data: { + id: roleId, + projectId, + name: 'to-delete', + permissions: 1n, + position: 20, + type: 'managed', + oidcGroup: '', + }, + }) + + await prisma.user.create({ + data: { + id: userId, + email: faker.internet.email().toLowerCase(), + firstName: 'Delete', + lastName: 'Test', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId, + userId, + roleIds: [roleId], + }, + }) + + await service.delete(roleId) + + const member = await prisma.projectMembers.findUnique({ + where: { projectId_userId: { projectId, userId } }, + }) + expect(member?.roleIds).not.toContain(roleId) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', expect.objectContaining({ id: projectId })) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/test/project-secrets.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/project-secrets.e2e-spec.ts new file mode 100644 index 0000000000..bb30aff5d7 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/project-secrets.e2e-spec.ts @@ -0,0 +1,145 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectSecretsModule } from '../src/modules/project-secrets/project-secrets.module' +import { ProjectSecretsService } from '../src/modules/project-secrets/project-secrets.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { generateProjectPath } from '../src/modules/vault/vault.utils' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectSecretsE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithProjectSecrets = describe.runIf(canRunProjectSecretsE2E) + +describeWithProjectSecrets('ProjectSecretsService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectSecretsService + let vaultClient: VaultClientService + + let ownerId: string + let projectId: string + let projectSlug: string + let projectPath: string + let secretAPath: string + let secretBPath: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectSecretsModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectSecretsService) + vaultClient = moduleRef.get(VaultClientService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + projectPath = generateProjectPath(process.env.PROJECTS_ROOT_DIR, projectSlug) + secretAPath = `${projectPath}/group1/secret1` + secretBPath = `${projectPath}/group2/secret2` + + await Promise.all([secretAPath, secretBPath].map(path => vaultClient.delete(path).catch(() => {}))) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + await Promise.all([secretAPath, secretBPath].map(path => vaultClient.delete(path).catch(() => {}))) + + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('returns an empty secret map when no Vault secrets exist', async () => { + const secrets = await service.get(projectId) + expect(secrets).toEqual({}) + }) + + describe('when Vault secrets exist', () => { + beforeAll(async () => { + await Promise.all([ + vaultClient.write({ + key1: 'value1', + key2: 42, + }, secretAPath), + vaultClient.write({ + key3: false, + key4: null, + }, secretBPath), + ]) + }) + + afterAll(async () => { + await Promise.all([ + vaultClient.delete(secretAPath).catch(() => {}), + vaultClient.delete(secretBPath).catch(() => {}), + ]) + }) + + it('parses real Vault values into grouped secrets', async () => { + const secrets = await service.get(projectId) + + expect(secrets.group1).toEqual({ + 'secret1.key1': 'value1', + 'secret1.key2': '42', + }) + expect(secrets.group2).toEqual({ + 'secret2.key3': 'false', + 'secret2.key4': '', + }) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/test/project-services.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/project-services.e2e-spec.ts new file mode 100644 index 0000000000..25612a1f99 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/project-services.e2e-spec.ts @@ -0,0 +1,144 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectServicesModule } from '../src/modules/project-services/project-services.module' +import { ProjectServicesService } from '../src/modules/project-services/project-services.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunServicesE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + && Boolean(process.env.ARGOCD_URL) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.HARBOR_URL) + && Boolean(process.env.HARBOR_ADMIN) + && Boolean(process.env.HARBOR_ADMIN_PASSWORD) + && Boolean(process.env.KEYCLOAK_DOMAIN) + && Boolean(process.env.KEYCLOAK_REALM) + && Boolean(process.env.KEYCLOAK_PROTOCOL) + && Boolean(process.env.NEXUS_URL) + && Boolean(process.env.NEXUS_ADMIN) + && Boolean(process.env.NEXUS_ADMIN_PASSWORD) + && Boolean(process.env.SONARQUBE_URL) + && Boolean(process.env.SONAR_API_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithServices = describe.runIf(canRunServicesE2E) + +const PLUGIN_NAME = 'gitlab' + +describeWithServices('ProjectServicesService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectServicesService + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectServicesModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectServicesService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.projectPlugin.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('get returns services for a project member', async () => { + const services = await service.get(projectId, 'user') + const gitlabService = services.find(entry => entry.name === PLUGIN_NAME) + + expect(gitlabService).toBeTruthy() + expect(gitlabService?.name).toBe(PLUGIN_NAME) + }) + + it('rejects get when project does not exist', async () => { + await expect(service.get(faker.string.uuid(), 'user')).rejects.toThrow(NotFoundException) + }) + + it('update stores project configuration', async () => { + await service.update(projectId, { + gitlab: { + enabled: 'enabled', + }, + }, ['user']) + + const stored = await prisma.projectPlugin.findUniqueOrThrow({ + where: { + projectId_pluginName_key: { + projectId, + pluginName: PLUGIN_NAME, + key: 'user.enabled', + }, + }, + select: { + value: true, + }, + }) + + expect(stored.value).toBe('enabled') + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/test/project.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/project.e2e-spec.ts new file mode 100644 index 0000000000..0aa6b9011d --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/project.e2e-spec.ts @@ -0,0 +1,153 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ForbiddenException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectPermissionModule } from '../src/modules/infrastructure/permission/project/project.module' +import { makeCreateProjectBody } from '../src/modules/project/project-testing.utils' +import { ProjectService } from '../src/modules/project/project.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProject = describe.runIf(canRunProjectE2E) + +describeWithProject('ProjectService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectService + let eventEmitter: DeepMockProxy + + let ownerId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectPermissionModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('ProjectService.create', async () => { + const createBody = makeCreateProjectBody({ + name: faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`), + description: 'Initial description', + }) + const created = await service.create(createBody, ownerId) + await prisma.project.deleteMany({ where: { id: created.id } }).catch(() => {}) + expect(created.id).toBeTruthy() + }) + + it('rejects ProjectService.list filter=all for non-admin', async () => { + await expect( + service.listProjects({ filter: 'all' }, { userId: ownerId, adminPermissions: 0n }), + ).rejects.toThrow(ForbiddenException) + }) + + it('rejects ProjectService.get when project does not exist', async () => { + await expect(service.get(faker.string.uuid())).rejects.toThrow(NotFoundException) + }) + + describe('with project', () => { + let projectId: string + let projectName: string + + beforeEach(async () => { + eventEmitter.emitAsync.mockClear() + + const createBody = makeCreateProjectBody({ + name: faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`), + description: 'Initial description', + }) + + const created = await service.create(createBody, ownerId) + projectId = created.id + projectName = created.name + + eventEmitter.emitAsync.mockClear() + }) + + afterEach(async () => { + await prisma.projectMembers.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + }) + + it('ProjectService.get', async () => { + const fetched = await service.get(projectId) + expect(fetched.id).toBe(projectId) + expect(fetched.ownerId).toBe(ownerId) + }) + + it('ProjectService.list', async () => { + const listResult = await service.listProjects({ filter: 'member' }, { userId: ownerId, adminPermissions: 0n }) + expect(listResult.some(p => p.id === projectId)).toBe(true) + }) + + it('ProjectService.getData', async () => { + const dataExport = await service.getProjectsData() + expect(Array.isArray(dataExport)).toBe(true) + expect(dataExport.some(p => p.name === projectName)).toBe(true) + }) + + it('ProjectService.update', async () => { + const updated = await service.update( + { description: 'Updated description' }, + { userId: ownerId, adminPermissions: 0n }, + projectId, + ) + expect(updated.description).toBe('Updated description') + }) + + it('ProjectService.archive', async () => { + await service.archive(projectId) + const archived = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { status: true, locked: true } }) + expect(archived.status).toBe('archived') + expect(archived.locked).toBe(true) + }) + + it('rejects ProjectService.update when project is locked', async () => { + await prisma.project.update({ where: { id: projectId }, data: { locked: true } }) + await expect( + service.update({ description: 'nope' }, { userId: ownerId, adminPermissions: 0n }, projectId), + ).rejects.toThrow(ForbiddenException) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/test/registry.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/registry.e2e-spec.ts new file mode 100644 index 0000000000..2fdf921814 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/registry.e2e-spec.ts @@ -0,0 +1,92 @@ +import type { ConfigType } from '@nestjs/config' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { harborConfigFactory } from '../src/config/harbor.config' +import { RegistryClientService } from '../src/modules/registry/registry-client.service' +import { makeProjectWithDetails } from '../src/modules/registry/registry-testing.utils' +import { ROBOT_NAME_PROJECT, ROBOT_NAME_RO, ROBOT_NAME_RW } from '../src/modules/registry/registry.constants' +import { RegistryModule } from '../src/modules/registry/registry.module' +import { RegistryService } from '../src/modules/registry/registry.service' +import { getHostFromUrl, getProjectVaultPath } from '../src/modules/registry/registry.utils' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' +import { getAll } from '../src/utils/iterable.utils' + +const canRunRegistryE2E + = Boolean(process.env.E2E) + && Boolean(process.env.HARBOR_URL) + && Boolean(process.env.HARBOR_ADMIN) + && Boolean(process.env.HARBOR_ADMIN_PASSWORD) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithRegistry = describe.runIf(canRunRegistryE2E) + +describeWithRegistry('RegistryService (e2e)', () => { + let moduleRef: TestingModule + let registry: RegistryService + let client: RegistryClientService + let vault: VaultClientService + let config: BaseConfig + let harborConfig: ConfigType + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory, harborConfigFactory] }), RegistryModule], + }) + .compile() + + await moduleRef.init() + + registry = moduleRef.get(RegistryService) + client = moduleRef.get(RegistryClientService) + vault = moduleRef.get(VaultClientService) + config = moduleRef.get(baseConfigFactory) + harborConfig = moduleRef.get(harborConfigFactory) + + projectSlug = faker.helpers.slugify(`test-project-${faker.string.alphanumeric({ length: 10 }).toLowerCase()}`).slice(0, 50) + }) + + afterAll(async () => { + if (vault && config && projectSlug) { + const paths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectsRootDir, `REGISTRY/${name}`)) + await Promise.all(paths.map(path => vault.delete(path).catch(() => {}))) + } + + if (registry && projectSlug) { + await registry.deleteProject(projectSlug).catch(() => {}) + } + + await moduleRef?.close() + }) + + it('should provision project in Harbor and write robot secrets to Vault', async () => { + const result = await registry.ensureProject({ slug: projectSlug, plugins: [] }, { publishProjectRobot: true }) + expect(result.basePath).toBe(`${getHostFromUrl(harborConfig.url)}/${projectSlug}/`) + + const project = await client.getProjectByName(projectSlug) + expect(project.status).toBe(200) + + const robots = await getAll(client.getProjectRobots(Number(project.data?.project_id))) + const robotNames = robots.flatMap(r => r.name ? [r.name] : []) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RO}`) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RW}`) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) + + const vaultPaths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectsRootDir, `REGISTRY/${name}`)) + const [roSecret, rwSecret, projectSecret] = await Promise.all(vaultPaths.map(path => vault.read(path))) + expect(roSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(rwSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(projectSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(roSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RO}`) + expect(rwSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RW}`) + expect(projectSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) + }) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/test/sonarqube.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/sonarqube.e2e-spec.ts new file mode 100644 index 0000000000..b8037965ef --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/sonarqube.e2e-spec.ts @@ -0,0 +1,183 @@ +import type { TestingModule } from '@nestjs/testing' +import { generateProjectKey } from '@cpn-console/hooks' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { SonarqubeClientService } from '../src/modules/sonarqube/sonarqube-client.service' +import { projectSelect } from '../src/modules/sonarqube/sonarqube-datastore.service' +import { makeProjectWithDetails } from '../src/modules/sonarqube/sonarqube-testing.utils' +import { SonarqubeModule } from '../src/modules/sonarqube/sonarqube.module' +import { SonarqubeService } from '../src/modules/sonarqube/sonarqube.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunSonarqubeE2E + = Boolean(process.env.E2E) + && Boolean(process.env.SONARQUBE_URL) + && Boolean(process.env.SONAR_API_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithSonarqube = describe.runIf(canRunSonarqubeE2E) + +describeWithSonarqube('SonarqubeService (e2e)', () => { + let moduleRef: TestingModule + let sonarqubeService: SonarqubeService + let sonarqubeClient: SonarqubeClientService + let vaultService: VaultClientService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + let testRepoName: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [SonarqubeModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + sonarqubeService = moduleRef.get(SonarqubeService) + sonarqubeClient = moduleRef.get(SonarqubeClientService) + vaultService = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-sonar-${faker.string.alphanumeric(8).toLowerCase()}`) + testRepoName = faker.helpers.slugify(`test-sonar-${faker.string.alphanumeric(8).toLowerCase()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (sonarqubeService && testProjectSlug) { + await sonarqubeService.handleDelete( + makeProjectWithDetails({ slug: testProjectSlug, repositories: [] }), + ).catch(() => {}) + } + + if (prisma) { + await prisma.repository.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should create platform groups during initialization', async () => { + // init() is triggered by moduleRef.init() via onModuleInit — groups must already exist + const [adminResult, readonlyResult, securityResult] = await Promise.all([ + sonarqubeClient.searchUserGroup({ q: '/console/admin' }), + sonarqubeClient.searchUserGroup({ q: '/console/readonly' }), + sonarqubeClient.searchUserGroup({ q: '/console/security' }), + ]) + + expect(adminResult.groups.some(g => g.name === '/console/admin')).toBe(true) + expect(readonlyResult.groups.some(g => g.name === '/console/readonly')).toBe(true) + expect(securityResult.groups.some(g => g.name === '/console/security')).toBe(true) + }) + + it('should reconcile project in SonarQube (groups, user, repository, vault secret)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E SonarQube Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + await prisma.repository.create({ + data: { + projectId: testProjectId, + internalRepoName: testRepoName, + isPrivate: false, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await sonarqubeService.handleUpsert(project) + + // All 5 project role groups should exist in SonarQube + const projectGroupNames = [ + `/${testProjectSlug}/console/admin`, + `/${testProjectSlug}/console/devops`, + `/${testProjectSlug}/console/developer`, + `/${testProjectSlug}/console/security`, + `/${testProjectSlug}/console/readonly`, + ] + for (const groupName of projectGroupNames) { + const result = await sonarqubeClient.searchUserGroup({ q: groupName }) + expect(result.groups.some(g => g.name === groupName), `group ${groupName} should exist`).toBe(true) + } + + // Robot/CI user should exist + const usersResult = await sonarqubeClient.searchUsers({ q: testProjectSlug }) + expect(usersResult.users.some(u => u.login === testProjectSlug)).toBe(true) + + // SonarQube analysis project for the repository should exist + const projectKey = generateProjectKey(testProjectSlug, testRepoName) + const projectsResult = await sonarqubeClient.searchProject({ q: testProjectSlug }) + expect(projectsResult.components.some(p => p.key === projectKey)).toBe(true) + + // Vault credentials should be written with correct username and token + const vaultSecret = await vaultService.readSonarqubeUser(testProjectSlug) + expect(vaultSecret?.data?.SONAR_USERNAME).toBe(testProjectSlug) + expect(vaultSecret?.data?.SONAR_TOKEN).toBeTruthy() + expect(vaultSecret?.data?.SONAR_PASSWORD).toBeTruthy() + }, 30000) + + it('should delete the project from SonarQube and remove vault credentials', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await sonarqubeService.handleDelete(project) + + // SonarQube analysis project should be removed + const projectKey = generateProjectKey(testProjectSlug, testRepoName) + const projectsResult = await sonarqubeClient.searchProject({ q: testProjectSlug }) + expect(projectsResult.components.some(p => p.key === projectKey)).toBe(false) + + // Vault credentials should be removed + const vaultSecret = await vaultService.readSonarqubeUser(testProjectSlug) + expect(vaultSecret).toBeNull() + }, 30000) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/test/vault.e2e-spec.ts b/.jjconflict-side-0/apps/server-nestjs/test/vault.e2e-spec.ts new file mode 100644 index 0000000000..6947cb9b53 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/test/vault.e2e-spec.ts @@ -0,0 +1,108 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { projectSelect } from '../src/modules/vault/vault-datastore.service' +import { makeProjectWithDetails } from '../src/modules/vault/vault-testing.utils' +import { VaultModule } from '../src/modules/vault/vault.module' +import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunVaultE2E + = Boolean(process.env.E2E) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithVault = describe.runIf(canRunVaultE2E) + +describeWithVault('VaultController (e2e)', () => { + let moduleRef: TestingModule + let vaultController: VaultService + let vaultClient: VaultClientService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + vaultController = moduleRef.get(VaultService) + vaultClient = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (testProjectSlug) { + await vaultController.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('should reconcile project in Vault (mount, group, role)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await vaultController.handleUpsert(project) + + const group = await vaultClient.getIdentityGroupName(testProjectSlug) + expect(group.data?.id).toBeTruthy() + expect(group.data?.name).toBe(testProjectSlug) + expect(group.data?.alias?.name).toBe(`/${testProjectSlug}`) + }, 180000) +}) diff --git a/.jjconflict-side-0/apps/server-nestjs/tsconfig.build.json b/.jjconflict-side-0/apps/server-nestjs/tsconfig.build.json new file mode 100644 index 0000000000..64f86c6bd2 --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/.jjconflict-side-0/apps/server-nestjs/tsconfig.json b/.jjconflict-side-0/apps/server-nestjs/tsconfig.json new file mode 100644 index 0000000000..48b6403d6f --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "incremental": true, + "target": "ES2023", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "baseUrl": "./", + "module": "nodenext", + "moduleResolution": "nodenext", + "resolvePackageJsonExports": true, + "strictBindCallApply": false, + "strictNullChecks": true, + "noFallthroughCasesInSwitch": false, + "noImplicitAny": false, + "declaration": true, + "outDir": "./dist", + "removeComments": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + } +} diff --git a/.jjconflict-side-0/apps/server-nestjs/vitest.config.ts b/.jjconflict-side-0/apps/server-nestjs/vitest.config.ts new file mode 100644 index 0000000000..b0337f6d0b --- /dev/null +++ b/.jjconflict-side-0/apps/server-nestjs/vitest.config.ts @@ -0,0 +1,22 @@ +import path from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: [ + { find: '@cpn-console/logger/hooks', replacement: path.resolve(__dirname, '../../packages/logger/src/hooks.ts') }, + { find: '@cpn-console/logger', replacement: path.resolve(__dirname, '../../packages/logger/src/index.ts') }, + { find: '@cpn-console/shared', replacement: path.resolve(__dirname, '../../packages/shared/src/index.ts') }, + { find: '@cpn-console/hooks', replacement: path.resolve(__dirname, '../../packages/hooks/src/index.ts') }, + ], + }, + test: { + globals: true, + environment: 'node', + include: ['src/**/*.spec.ts', 'test/**/*.e2e-spec.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}) diff --git a/.jjconflict-side-0/apps/server/.env-example b/.jjconflict-side-0/apps/server/.env-example new file mode 100644 index 0000000000..e26aaeea95 --- /dev/null +++ b/.jjconflict-side-0/apps/server/.env-example @@ -0,0 +1,46 @@ +# Niveau de log +LOG_LEVEL=debug +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 + +# Port d'écoute du serveur backend +SERVER_PORT=4001 +# URL de connexion à la base de données PostgreSQL (format Prisma) +DB_URL=postgresql://admin:admin@localhost:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +OPENCDS_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true + +# Dossier de stockage des plugins externes +EXTERNAL_PLUGINS_DIR_HOST_PATH=/home/stephane/projets/clients/cloud-pi/plugins +# URL du dashboard d'observabilité (géré par Grafana) +GRAFANA_URL= +# Version du chart "observability" à déployer pour les projets +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 +# Inutilisé ? +# SONARQUBE_INTERNAL_URL= diff --git a/.jjconflict-side-0/apps/server/.env.docker-example b/.jjconflict-side-0/apps/server/.env.docker-example new file mode 100644 index 0000000000..571be2ff45 --- /dev/null +++ b/.jjconflict-side-0/apps/server/.env.docker-example @@ -0,0 +1,31 @@ +# Niveau de log +LOG_LEVEL=debug +# Indique que l'application tourne dans un conteneur Docker +DOCKER=true +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine du Keycloak (nom du service Docker Compose + port interne) +KEYCLOAK_DOMAIN=keycloak:8080 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http en Docker local) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 + +# Port d'écoute du serveur dans le réseau Docker +SERVER_PORT=8080 +# URL de connexion PostgreSQL (utilise le nom de service Docker 'postgres') +DB_URL=postgresql://admin:admin@postgres:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-side-0/apps/server/.env.integ-example b/.jjconflict-side-0/apps/server/.env.integ-example new file mode 100644 index 0000000000..1bcb5f1565 --- /dev/null +++ b/.jjconflict-side-0/apps/server/.env.integ-example @@ -0,0 +1,108 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Désactive l'initialisation des données de démo (l'intégration a ses propres données) +DEV_SETUP="false" +# Active le mode intégration (charge ce fichier en surcharge) +INTEGRATION=true + +# --- Keycloak --- +# Protocole (généralement https en intégration) +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= +# Secret du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_SECRET= +# Domaine du Keycloak d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak d'intégration +KEYCLOAK_REALM= + +# --- ArgoCD --- +# Namespace Kubernetes dans lequel ArgoCD est déployé +ARGO_NAMESPACE= +# URL de l'API ArgoCD +ARGOCD_URL= +# URL interne de l'API ArgoCD +ARGOCD_INTERNAL_URL= + +# --- GitLab --- +# Token d'accès personnel GitLab (scope api) +GITLAB_TOKEN= +# URL de l'instance GitLab +GITLAB_URL= +# URL interne de l'API Gitlab +GITLAB_INTERNAL_URL= + +# --- Harbor --- +# Nom d'utilisateur admin Harbor +HARBOR_ADMIN= +# Mot de passe admin Harbor +HARBOR_ADMIN_PASSWORD= +# URL de l'instance Harbor +HARBOR_URL= +# URL interne de l'API Harbor +HARBOR_INTERNAL_URL= + +# --- Keycloak (admin) --- +# Utilisateur admin Keycloak pour la gestion des royaumes/clients +KEYCLOAK_ADMIN= +# Mot de passe admin Keycloak +KEYCLOAK_ADMIN_PASSWORD= +# URL complète de Keycloak (peut différer de KEYCLOAK_DOMAIN) +KEYCLOAK_URL= + +# --- Nexus --- +# Nom d'utilisateur admin Nexus +NEXUS_ADMIN= +# Mot de passe admin Nexus +NEXUS_ADMIN_PASSWORD= +# URL de l'instance Nexus +NEXUS_URL= +# URL interne de l'API Nexus +NEXUS_INTERNAL_URL= + +# --- Divers --- +# Répertoire racine des projets dans Gitlab +PROJECTS_ROOT_DIR= + +# --- SonarQube --- +# Token d'API SonarQube +SONAR_API_TOKEN= +# URL de l'instance SonarQube +SONARQUBE_URL= +# URL interne de l'API SonarQube +SONARQUBE_INTERNAL_URL= + +# --- Vault --- +# Token d'authentification Vault +VAULT_TOKEN= +# URL de l'instance Vault +VAULT_URL= +# URL interne de l'API Vault +VAULT_INTERNAL_URL= + +# --- Kubeconfig --- +# Chemin vers le kubeconfig sur la machine hôte (monté en volume dans Docker) +KUBECONFIG_HOST_PATH= +# Chemin vers le kubeconfig à l'intérieur du conteneur +KUBECONFIG_PATH=$HOME/.kube/config +# Contexte Kubernetes à utiliser +KUBECONFIG_CTX= + +# Répertoire contenant les plugins externes (monté en volume dans Docker) +EXTERNAL_PLUGINS_DIR_HOST_PATH=/path/to/plugins + +# Informations pour le plugin Observabilité +GRAFANA_URL= +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 + +# --- Configuration proxy (si nécessaire pour accéder aux services externes) --- +# HTTP_PROXY= +# HTTPS_PROXY= +# NO_PROXY= + +# Configuration OpenCDS +OPENCDS_URL= +OPENCDS_API_TOKEN=token +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false diff --git a/.jjconflict-side-0/apps/server/Dockerfile b/.jjconflict-side-0/apps/server/Dockerfile new file mode 100644 index 0000000000..47ca58e741 --- /dev/null +++ b/.jjconflict-side-0/apps/server/Dockerfile @@ -0,0 +1,66 @@ +# Base stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Install pnpm version defined in package.json "packageManager" property +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY --chown=node:root apps/server/package.json ./apps/server/package.json + +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +COPY --chown=node:root plugins/argocd/package.json ./plugins/argocd/package.json +COPY --chown=node:root plugins/gitlab/package.json ./plugins/gitlab/package.json +COPY --chown=node:root plugins/harbor/package.json ./plugins/harbor/package.json +COPY --chown=node:root plugins/keycloak/package.json ./plugins/keycloak/package.json +COPY --chown=node:root plugins/nexus/package.json ./plugins/nexus/package.json +COPY --chown=node:root plugins/sonarqube/package.json ./plugins/sonarqube/package.json +COPY --chown=node:root plugins/vault/package.json ./plugins/vault/package.json + +RUN pnpm install --frozen-lockfile +COPY --chown=node:root plugins/ ./plugins/ +COPY --chown=node:root packages/ ./packages/ + +COPY --chown=node:root apps/server/ ./apps/server/ +# Generate Prisma client +RUN pnpm --filter server run db:generate +ENTRYPOINT [ "pnpm", "--filter", "server", "run" ] +CMD [ "dev" ] + + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +# Build @cpn-console/server console-related dependencies +RUN pnpm run build +# Build @cpn-console/server +RUN pnpm --filter @cpn-console/server run build +# Export @cpn-console/server to target build directory +RUN pnpm --filter @cpn-console/server --prod deploy build + + +# Prod stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS prod + +ARG APP_VERSION +ENV APP_VERSION=$APP_VERSION + +VOLUME [ "/plugins" ] +WORKDIR /app +RUN mkdir -p /home/node/logs && chmod 770 -R /home/node/logs \ + && mkdir -p /home/node/.npm && chmod 770 -R /home/node/.npm \ + && chown node:root /app +COPY --chown=node:root --from=build /app/build . +RUN npm run db:generate +USER node +EXPOSE 8080 +ENTRYPOINT ["npm", "start"] diff --git a/.jjconflict-side-0/apps/server/README.md b/.jjconflict-side-0/apps/server/README.md new file mode 100644 index 0000000000..37b89ac99b --- /dev/null +++ b/.jjconflict-side-0/apps/server/README.md @@ -0,0 +1,39 @@ +# Console Cloud π Native - Serveur + +## Installation + +```sh +npm install +``` + +## Lancement de l'app + +```sh +npm run dev +``` + +## Lancement des tests unitaires + +```sh +npm run test +``` + +## Formattage du code + +```sh +# Lister les problèmes de formatage +npm run lint + +# Régler automatiquement les problèmes de formatage +npm run format +``` + +## Lancement de l'app en mode production + +```sh +npm start +``` + +## Activation OpenCDS + +Se référer à [la documentation dédiée](../../packages/opencds/README.adoc) diff --git a/.jjconflict-side-0/apps/server/eslint.config.js b/.jjconflict-side-0/apps/server/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-0/apps/server/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-0/apps/server/nodemon.json b/.jjconflict-side-0/apps/server/nodemon.json new file mode 100644 index 0000000000..a83d0540d3 --- /dev/null +++ b/.jjconflict-side-0/apps/server/nodemon.json @@ -0,0 +1,4 @@ +{ + "watch": ["server.ts", "src/"], + "ext": "js, ts" +} diff --git a/.jjconflict-side-0/apps/server/package.json b/.jjconflict-side-0/apps/server/package.json new file mode 100644 index 0000000000..3096b8ea59 --- /dev/null +++ b/.jjconflict-side-0/apps/server/package.json @@ -0,0 +1,109 @@ +{ + "name": "@cpn-console/server", + "type": "module", + "version": "9.22.1", + "description": "cpn-console server", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/bootstrap.js", + "module": "dist/bootstrap.js", + "types": "types/bootstrap.d.ts", + "files": [ + "dist", + "prisma.config.ts", + "src" + ], + "scripts": { + "prebuild": "pnpm i --frozen-lockfile", + "build": "tspc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "db:deploy": "prisma migrate deploy", + "db:diff": "prisma migrate diff", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev --name dso", + "db:reset": "prisma migrate reset", + "predev": "pnpm run db:deploy", + "dev": "nodemon --exec 'vite-node src/bootstrap.ts'", + "predebug": "pnpm run db:migrate", + "debug": "nodemon --exec 'NODE_OPTIONS=\"--inspect-brk=0.0.0.0\" vite-node src/bootstrap.ts'", + "format": "eslint ./ --fix", + "preinteg": "INTEGRATION=true pnpm run db:migrate", + "integ": "INTEGRATION=true nodemon --exec 'vite-node src/bootstrap.ts'", + "lint": "eslint ./", + "prepublishOnly": "pnpm run db:generate", + "prestart": "npm run db:deploy", + "start": "node --enable-source-maps dist/bootstrap.js", + "pretest": "pnpm run db:generate", + "test": "vitest run", + "pretest:cov": "pnpm run db:generate", + "test:cov": "vitest run --coverage", + "test:e2e": "pnpm run dev", + "test:e2e-ci": "pnpm run start" + }, + "dependencies": { + "@cpn-console/argocd-plugin": "workspace:^", + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/harbor-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/nexus-plugin": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/sonarqube-plugin": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@fastify/cookie": "catalog:runtime", + "@fastify/helmet": "catalog:runtime", + "@fastify/otel": "catalog:runtime", + "@fastify/session": "catalog:runtime", + "@fastify/swagger": "catalog:runtime", + "@fastify/swagger-ui": "catalog:runtime", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "@kubernetes-models/argo-cd": "catalog:runtime", + "@opentelemetry/auto-instrumentations-node": "catalog:otel", + "@opentelemetry/exporter-metrics-otlp-proto": "catalog:otel", + "@opentelemetry/exporter-trace-otlp-proto": "catalog:otel", + "@opentelemetry/instrumentation-nestjs-core": "catalog:otel", + "@opentelemetry/instrumentation-pino": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-node": "catalog:otel", + "@prisma/client": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@ts-rest/fastify": "catalog:runtime", + "@ts-rest/open-api": "catalog:runtime", + "axios": "catalog:runtime", + "date-fns": "catalog:runtime", + "fastify": "catalog:runtime", + "fastify-keycloak-adapter": "catalog:runtime", + "json-2-csv": "catalog:runtime", + "mustache": "catalog:runtime", + "prisma": "catalog:runtime", + "undici": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "fastify-plugin": "catalog:build", + "nodemon": "catalog:tools", + "pino-pretty": "catalog:tools", + "rimraf": "catalog:tools", + "ts-patch": "catalog:build", + "typescript": "catalog:build", + "typescript-transform-paths": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/apps/server/prisma.config.ts b/.jjconflict-side-0/apps/server/prisma.config.ts new file mode 100644 index 0000000000..ff5aaf9186 --- /dev/null +++ b/.jjconflict-side-0/apps/server/prisma.config.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' +import { defineConfig } from 'prisma/config' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +export default defineConfig({ + schema: path.join('src', 'prisma', 'schema'), + migrations: { + path: path.join('src', 'prisma', 'migrations'), + }, +}) diff --git a/.jjconflict-side-0/apps/server/src/__mocks__/prisma.ts b/.jjconflict-side-0/apps/server/src/__mocks__/prisma.ts new file mode 100644 index 0000000000..075578c96c --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/__mocks__/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma.js') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-side-0/apps/server/src/__mocks__/utils/hook-wrapper.ts b/.jjconflict-side-0/apps/server/src/__mocks__/utils/hook-wrapper.ts new file mode 100644 index 0000000000..f7fdd811a2 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/__mocks__/utils/hook-wrapper.ts @@ -0,0 +1,42 @@ +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../utils/hook-wrapper.ts') + +export const hook = { + adminRole: { + delete: vi.fn(), + upsert: vi.fn(), + }, + cluster: { + delete: vi.fn(), + upsert: vi.fn(), + }, + misc: { + checkServices: vi.fn(), + syncRepository: vi.fn(), + }, + project: { + upsert: vi.fn(), + delete: vi.fn(), + getSecrets: vi.fn(), + }, + projectRole: { + upsert: vi.fn(), + delete: vi.fn(), + }, + user: { + retrieveUserByEmail: vi.fn(), + }, + zone: { + delete: vi.fn(), + upsert: vi.fn(), + }, +} as const + +const hookMock = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(hookMock) +}) diff --git a/.jjconflict-side-0/apps/server/src/app.spec.ts b/.jjconflict-side-0/apps/server/src/app.spec.ts new file mode 100644 index 0000000000..e540174ed4 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/app.spec.ts @@ -0,0 +1,21 @@ +import { apiPrefix } from '@cpn-console/shared' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import app from './app.js' +import { getRandomRequestor, setRequestor } from './utils/mocks.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) + +describe('app', () => { + beforeEach(() => { + setRequestor(getRandomRequestor()) + }) + afterAll(async () => { + await app.close() + }) + + it('should respond 404 on unknown route', async () => { + const response = await app.inject() + .get(`${apiPrefix}/miss`) + expect(response.statusCode).toBe(404) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/app.ts b/.jjconflict-side-0/apps/server/src/app.ts new file mode 100644 index 0000000000..996db320fa --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/app.ts @@ -0,0 +1,63 @@ +import type { FastifyRequest } from 'fastify' +import { apiPrefix, getContract } from '@cpn-console/shared' +import fastifyCookie from '@fastify/cookie' +import helmet from '@fastify/helmet' +import fastifySession from '@fastify/session' +import fastifySwagger from '@fastify/swagger' +import fastifySwaggerUi from '@fastify/swagger-ui' +import { initServer } from '@ts-rest/fastify' +import { generateOpenApi } from '@ts-rest/open-api' +import fastify from 'fastify' +import keycloak from 'fastify-keycloak-adapter' +import { apiRouter } from './resources/index.js' +import { isDev, isInt, isTest } from './utils/env.js' +import { fastifyConf, swaggerConf, swaggerUiConf } from './utils/fastify.js' +import { keycloakConf, sessionConf } from './utils/keycloak.js' + +export const serverInstance: ReturnType = initServer() + +const openApiDocument = generateOpenApi(await getContract(), swaggerConf, { + setOperationId: true, +}) + +const app = fastify(fastifyConf) + .register(helmet, () => ({ + contentSecurityPolicy: !(isInt || isDev || isTest), + })) + .register(fastifyCookie) + .register(fastifySession, sessionConf) + // @ts-ignore + .register(keycloak, keycloakConf) + .register(fastifySwagger, { transformObject: () => openApiDocument }) + .register(fastifySwaggerUi, swaggerUiConf) + .register(apiRouter()) + .addHook('onRoute', (opts) => { + if (opts.path === `${apiPrefix}/healthz`) { + opts.logLevel = 'silent' + } + }) + .setErrorHandler((err: Error, req: FastifyRequest, reply) => { + const statusCode = 500 + // @ts-ignore vérifier l'objet + const message = err.description || err.message + reply + .status(statusCode) + .send({ status: statusCode, error: message, stack: err.stack }) + req.log.error({ err, reqId: req.id }, 'Unhandled request error') + }) + .addHook('onResponse', (req, res) => { + const user = req.session?.user as { + id: string + } + if (res.statusCode < 400) { + req.log.info({ status: res.statusCode, userId: user?.id }) + } else if (res.statusCode < 500) { + req.log.warn({ status: res.statusCode, userId: user?.id }) + } else { + req.log.error({ status: res.statusCode, userId: user?.id }) + } + }) + +await app.ready() + +export default app diff --git a/.jjconflict-side-0/apps/server/src/bootstrap.ts b/.jjconflict-side-0/apps/server/src/bootstrap.ts new file mode 100644 index 0000000000..83c833f007 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/bootstrap.ts @@ -0,0 +1,3 @@ +import './runtime.js' +import './telemetry.js' +import './server.js' diff --git a/.jjconflict-side-0/apps/server/src/connect.spec.ts b/.jjconflict-side-0/apps/server/src/connect.spec.ts new file mode 100644 index 0000000000..363c89d290 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/connect.spec.ts @@ -0,0 +1,86 @@ +import { logger } from '@cpn-console/logger' +import { PrismaClientInitializationError } from '@prisma/client/runtime/library.js' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from './__mocks__/prisma.js' +import app from './app.js' +import { getConnection } from './connect.js' + +vi.mock( + 'fastify-keycloak-adapter', + (await import('./utils/mocks.js')).mockSessionPlugin, +) +vi.mock('@/resources/queries-index.js') +vi.mock('./models/log.js', () => getModel('getLogModel')) +vi.mock('./models/repository.js', () => getModel('getRepositoryModel')) +vi.mock('./models/permission.js', () => getModel('getPermissionModel')) +vi.mock('./models/environment.js', () => getModel('getEnvironmentModel')) +vi.mock('./models/project.js', () => getModel('getProjectModel')) +vi.mock('./models/user.js', () => getModel('getUserModel')) +vi.mock('./models/users-projects.js', () => getModel('getRolesModel')) +vi.mock('./models/zone.js', () => getModel('getZoneModel')) +vi.mock('./prisma.js') +vi.mock('./utils/env.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isTest: true, + } +}) + +vi.spyOn(app, 'listen') +const infoSpy = vi.spyOn(logger, 'info') +const errorSpy = vi.spyOn(logger, 'error') +const debugSpy = vi.spyOn(logger, 'debug') + +function getModel(modelName: string) { + return { + [modelName]: vi.fn(() => ({ + sync: vi.fn(), + hasMany: vi.fn(), + belongsTo: vi.fn(), + belongsToMany: vi.fn(), + })), + } +} + +describe('connect', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should connect to postgres', async () => { + await getConnection() + + expect(debugSpy.mock.calls).toHaveLength(1) + expect(debugSpy.mock.calls[0]?.[1]).toBe('Connecting to Postgres') + + expect(infoSpy.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toContainEqual(['Connected to Postgres!']) + }) + + it( + 'should fail to connect once, then connect to postgres', + { timeout: 10_000 }, + async () => { + const errorToCatch = new PrismaClientInitializationError( + 'Failed to connect', + '2.19.0', + 'P1001', + ) + + prisma.$connect.mockRejectedValueOnce(errorToCatch) + await getConnection() + + expect(errorSpy.mock.calls).toHaveLength(1) + expect(errorSpy.mock.calls[0]?.[1]).toBe('Could not connect to Postgres') + + expect(infoSpy.mock.calls).toHaveLength(2) + expect(infoSpy.mock.calls[0]?.[1]).toBe('Retrying Postgres connection') + expect(infoSpy.mock.calls).toContainEqual(['Connected to Postgres!']) + + expect(debugSpy.mock.calls).toHaveLength(2) + expect(debugSpy.mock.calls[0]?.[1]).toBe('Connecting to Postgres') + expect(debugSpy.mock.calls[1]?.[1]).toBe('Connecting to Postgres') + }, + ) +}) diff --git a/.jjconflict-side-0/apps/server/src/connect.ts b/.jjconflict-side-0/apps/server/src/connect.ts new file mode 100644 index 0000000000..31d608a66e --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/connect.ts @@ -0,0 +1,63 @@ +import { setTimeout } from 'node:timers/promises' +import { logger } from '@cpn-console/logger' +import prisma from './prisma.js' +import { + dbUrl, + isCI, + isDev, + isTest, +} from './utils/env.js' + +const DELAY_BEFORE_RETRY = isTest ? 0 : isCI ? 1000 : 10000 +let closingConnections = false +const leadingSlashRegExp = /^\// + +function parseDbUrl(url: string | undefined) { + if (!url) return undefined + try { + const parsed = new URL(url) + return { + host: parsed.host, + database: parsed.pathname?.replace(leadingSlashRegExp, '') || undefined, + } + } catch { + return undefined + } +} + +export async function getConnection(triesLeft = 5): Promise { + if (closingConnections || triesLeft <= 0) { + throw new Error('Unable to connect to Postgres server') + } + triesLeft-- + + try { + if (isDev || isTest || isCI) + logger.debug({ db: parseDbUrl(dbUrl) }, 'Connecting to Postgres') + await prisma.$connect() + + logger.info('Connected to Postgres!') + } catch (err) { + if (triesLeft > 0) { + logger.error({ err }, 'Could not connect to Postgres') + logger.info({ triesLeft }, 'Retrying Postgres connection') + await setTimeout(DELAY_BEFORE_RETRY) + return getConnection(triesLeft) + } + + logger.error({ err }, 'Out of retries connecting to Postgres') + err.message = `Out of retries connecting to Postgres, last error: ${err.message}` + throw err + } +} + +export async function closeConnections() { + closingConnections = true + try { + await prisma.$disconnect() + } catch (error) { + logger.error({ err: error }, 'Could not disconnect from Postgres') + } finally { + closingConnections = false + } +} diff --git a/.jjconflict-side-0/apps/server/src/init/db/dump.ts b/.jjconflict-side-0/apps/server/src/init/db/dump.ts new file mode 100644 index 0000000000..6709946605 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/init/db/dump.ts @@ -0,0 +1,28 @@ +// @ts-nocheck + +/** + * How to use ? + * npx vite-node src/init/db/dump.ts + * format ./data.ts with linter + * cut/paste to packages/test-utils/src/imports/data.ts + */ + +import { writeFileSync } from 'node:fs' +import { Prisma } from '@prisma/client' +import prisma from '@/prisma.js' +import { associations, manyToManyRelation, modelKeys, models, resourceListToDict } from './utils.js' + +const Models = resourceListToDict(Prisma.dmmf.datamodel.models) + +for (const modelKey of modelKeys) { + const modelDatas = await prisma[modelKey].findMany() + models[modelKey] = modelDatas +} +for (const [model, targetModel, relationKey] of manyToManyRelation) { + const modelKey = model.slice(0, 1).toLocaleLowerCase() + model.slice(1) + const modelDatas = await prisma[modelKey].findMany({ select: { [Models[model].id]: true, [relationKey]: { select: { [Models[targetModel].id]: true } } } }) + associations.push([modelKey, modelDatas]) +} +const a = JSON.stringify({ ...models, associations }, null, 2) + +writeFileSync('./data.ts', `export const data = ${a}`) diff --git a/.jjconflict-side-0/apps/server/src/init/db/index.ts b/.jjconflict-side-0/apps/server/src/init/db/index.ts new file mode 100644 index 0000000000..fe37b33281 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/init/db/index.ts @@ -0,0 +1,51 @@ +import { logger } from '@cpn-console/logger' +import prisma from '@/prisma.js' +import { modelKeys } from './utils.js' + +type ExtractKeysWithFields = { + [K in keyof T]: T[K] extends { fields: any } ? K : never +}[keyof T] + +type Models = ExtractKeysWithFields + +type Imports = Partial> & { + associations: [Models, any[]] +} + +export async function initDb(data: Imports) { + const dataStringified = JSON.stringify(data) + const dataParsed = JSON.parse(dataStringified, (key, value) => { + try { + if (['permissions', 'everyonePerms'].includes(key)) { + return BigInt(value.slice(0, value.length - 1)) + } + } catch { + return value + } + return value + }) + logger.info('Drop tables') + for (const modelKey of modelKeys.toReversed()) { + // @ts-ignore + await prisma[modelKey].deleteMany() + } + logger.info('Import models') + for (const modelKey of modelKeys) { + // @ts-ignore + await prisma[modelKey].createMany({ data: dataParsed[modelKey] }) + } + logger.info('Import associations') + for (const [modelKey, rows] of dataParsed.associations) { + for (const row of rows) { + const idKey = 'id' + const connectKeys = Object.keys(row).filter(key => key !== idKey) + const dataConnects = connectKeys.reduce((acc, curr) => { + acc[curr] = { connect: row[curr] } + return acc + }, {} as Record) + // @ts-ignore + await prisma[modelKey].update({ where: { id: row.id }, data: dataConnects }) + } + } + logger.info('End import') +} diff --git a/.jjconflict-side-0/apps/server/src/init/db/utils.spec.ts b/.jjconflict-side-0/apps/server/src/init/db/utils.spec.ts new file mode 100644 index 0000000000..9b831ee089 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/init/db/utils.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { modelKeys, moveBefore, resourceListToDict } from './utils.js' + +vi.mock('fs', () => ({ writeFileSync: vi.fn() })) +for (const modelKey of modelKeys) { + prisma[modelKey].findMany.mockResolvedValue([]) +} + +describe('test moveBefore', () => { + it('should be moved', () => { + const arr = ['a', 'b', 'c'] + const arrSorted = moveBefore(arr, 'c', 'b') + expect(arrSorted).toEqual(['a', 'c', 'b']) + + const arrSorted2 = moveBefore(arr, 'c', 'a') + expect(arrSorted2).toEqual(['c', 'a', 'b']) + }) + it('should not be moved', () => { + const arr = ['a', 'b', 'c'] + const arrSorted = moveBefore(arr, 'b', 'c') + expect(arrSorted).toEqual(false) + + const arrSorted2 = moveBefore(arr, 'a', 'c') + expect(arrSorted2).toEqual(false) + + const arrSorted3 = moveBefore(arr, 'c', 'c') + expect(arrSorted3).toEqual(false) + }) +}) + +it('test resourceListToDict (by name)', () => { + const list = [ + { name: 'a', value: 1 }, + { name: 'b', value: 2 }, + { name: 'c', value: 3 }, + ] + const dict = resourceListToDict(list) + expect(dict).toEqual({ + a: { name: 'a', value: 1 }, + b: { name: 'b', value: 2 }, + c: { name: 'c', value: 3 }, + }) +}) + +it('stringify bigint', () => { + const list = { name: 'a', value: 1n } + + const dict = JSON.stringify(list) + + expect(dict).toEqual('{"name":"a","value":"1n"}') +}) diff --git a/.jjconflict-side-0/apps/server/src/init/db/utils.ts b/.jjconflict-side-0/apps/server/src/init/db/utils.ts new file mode 100644 index 0000000000..e4b941089f --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/init/db/utils.ts @@ -0,0 +1,85 @@ +// @ts-nocheck +import { Prisma } from '@prisma/client' + +// eslint-disable-next-line no-extend-native +BigInt.prototype.toJSON = function () { + return `${this.toString()}n` +} + +export type ResourceByName = Record +export function resourceListToDict(resList: Array): ResourceByName { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.name]: curr, + } + }, {} as ResourceByName) +} + +// @ts-ignore +const Models = resourceListToDict(Prisma.dmmf.datamodel.models) +let ModelsNames = Object.keys(Models) +let ModelsOrder = [...ModelsNames] + +export function moveBefore(arr: T, toMove: T[number], ref: T[number]): T | false { + const iref = arr.indexOf(ref) + const moveref = arr.indexOf(toMove) + if (moveref <= iref) return false + return [ + ...arr.slice(0, iref), + arr[moveref], + ...arr.slice(iref, moveref), + ...arr.slice(moveref + 1), + ] as T +} + +export const manyToManyRelation: [string, string, string][] = [] +function sort() { + let hasChanged = false + for (const model of ModelsNames) { + for (const field of Models[model].fields) { + if (field.isId) Models[model].id = field.name + if (field.type in Models) { + const relationField = Models[field.type].fields.find(({ type }) => type === model) + if (!relationField) throw new Error('unable to find matching model') + if ( + (relationField.isRequired && field.isRequired && !relationField.isList) + || (relationField.isRequired && !field.isRequired) + ) { + const moveRes = moveBefore(ModelsOrder, model, field.type) + if (moveRes) { + hasChanged = true + ModelsOrder = moveRes + } + } + if ( + field.isList && relationField.isList + && !manyToManyRelation.some(test => + (test[0] === model && test[1] === field.type) || (test[0] === field.type && test[1] === model)) + ) { + manyToManyRelation.push([model, field.type, field.name]) + } + } + } + } + ModelsNames = ModelsOrder + if (hasChanged) sort() +} + +sort() + +// special case to study +const logUserCase = moveBefore(ModelsOrder, 'User', 'Log') +if (logUserCase) { + ModelsOrder = logUserCase +} +const logProjectCase = moveBefore(ModelsOrder, 'Project', 'Log') +if (logProjectCase) { + ModelsOrder = logProjectCase +} + +export const models: Record = {} +export const associations: Record = [] +export const modelKeys = ModelsOrder.map(model => model.slice(0, 1).toLocaleLowerCase() + model.slice(1)) diff --git a/.jjconflict-side-0/apps/server/src/mocks/prisma.ts b/.jjconflict-side-0/apps/server/src/mocks/prisma.ts new file mode 100644 index 0000000000..075578c96c --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/mocks/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma.js') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-side-0/apps/server/src/mocks/utils.ts b/.jjconflict-side-0/apps/server/src/mocks/utils.ts new file mode 100644 index 0000000000..c57dd0f52b --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/mocks/utils.ts @@ -0,0 +1,24 @@ +import type { User } from '@cpn-console/test-utils' +import fp from 'fastify-plugin' + +let requestor: User + +export function setRequestor(user: User) { + requestor = user +} + +export function getRequestor() { + return requestor +} + +export async function mockSessionPlugin() { + const sessionPlugin = (app, opt, next) => { + app.addHook('onRequest', (req, res, next) => { + req.session = { user: getRequestor() } + next() + }) + next() + } + + return { default: fp(sessionPlugin) } +} diff --git a/.jjconflict-side-0/apps/server/src/plugins.ts b/.jjconflict-side-0/apps/server/src/plugins.ts new file mode 100644 index 0000000000..fcb54775f2 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/plugins.ts @@ -0,0 +1,47 @@ +import type { Plugin } from '@cpn-console/hooks' +import { readdirSync, statSync } from 'node:fs' +import { plugin as argo } from '@cpn-console/argocd-plugin' +import { plugin as gitlab } from '@cpn-console/gitlab-plugin' +import { plugin as harbor } from '@cpn-console/harbor-plugin' +import { pluginManager } from '@cpn-console/hooks' +import { plugin as keycloak } from '@cpn-console/keycloak-plugin' +import { logger as baseLogger } from '@cpn-console/logger' +import { plugin as nexus } from '@cpn-console/nexus-plugin' +import { plugin as sonarqube } from '@cpn-console/sonarqube-plugin' +import { plugin as vault } from '@cpn-console/vault-plugin' +import { pluginsDir } from './utils/env.js' +import { pluginManagerOptions } from './utils/plugins.js' + +const logger = baseLogger.child({ scope: 'plugin-manager' }) + +export async function initPm() { + const pm = pluginManager(pluginManagerOptions) + pm.register(argo) + pm.register(gitlab) + pm.register(harbor) + pm.register(keycloak) + pm.register(nexus) + pm.register(sonarqube) + pm.register(vault) + + if (!statSync(pluginsDir, { + throwIfNoEntry: false, + })) { + return pm + } + for (const dirName of readdirSync(pluginsDir)) { + const moduleAbsPath = `${pluginsDir}/${dirName}` + try { + statSync(`${moduleAbsPath}/package.json`) + const pkg = await import(`${moduleAbsPath}/package.json`, { with: { type: 'json' } }) + const entrypoint = pkg.default.module || pkg.default.main + if (!entrypoint) throw new Error(`No entrypoint found in package.json : ${pkg.default.name}`) + const { plugin } = await import(`${moduleAbsPath}/${entrypoint}`) as { plugin: Plugin } + pm.register(plugin) + } catch (error) { + logger.error({ err: error, moduleAbsPath }, 'Could not import external plugin module') + } + } + + return pm +} diff --git a/.jjconflict-side-0/apps/server/src/prepare-app.spec.ts b/.jjconflict-side-0/apps/server/src/prepare-app.spec.ts new file mode 100644 index 0000000000..5086faa776 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prepare-app.spec.ts @@ -0,0 +1,70 @@ +import { logger } from '@cpn-console/logger' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from './app.js' +import { getConnection } from './connect.js' +import { initDb } from './init/db/index.js' +import { getPreparedApp } from './prepare-app.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) +vi.mock('./connect.js') +vi.mock('./index.js') +vi.mock('./init/db/index.js', () => ({ initDb: vi.fn() })) + +vi.spyOn(app, 'listen') +vi.spyOn(logger, 'warn') +vi.spyOn(logger, 'error') +vi.spyOn(logger, 'debug') + +describe('server', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should getConnection', async () => { + await getPreparedApp() + + expect(getConnection).toHaveBeenCalledTimes(1) + expect(initDb.mock.calls).toHaveLength(1) + }) + + it('should throw an error on connection to DB', async () => { + const error = new Error('This is OK!') + getConnection.mockRejectedValueOnce(error) + + let response + await getPreparedApp() + .catch((err) => { response = err }) + + expect(getConnection.mock.calls).toHaveLength(1) + expect(app.listen.mock.calls).toHaveLength(0) + expect(response).toMatchObject(error) + }) + + it('should throw an error on initDb import if module is not found', async () => { + const infoSpy = vi.spyOn(logger, 'info') + const error = new Error('Failed to load') + initDb.mockRejectedValueOnce(error) + + await getPreparedApp() + + expect(initDb.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toHaveLength(3) + }) + + it('should throw an error on initDb import', async () => { + const infoSpy = vi.spyOn(logger, 'info') + const error = new Error('This is OK!') + initDb.mockRejectedValueOnce(error) + + let response + try { + await getPreparedApp() + } catch (err) { + response = err + } + + expect(initDb.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toHaveLength(2) + expect(response).toMatchObject(error) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/prepare-app.ts b/.jjconflict-side-0/apps/server/src/prepare-app.ts new file mode 100644 index 0000000000..000734714b --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prepare-app.ts @@ -0,0 +1,96 @@ +import { rm } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { logger } from '@cpn-console/logger' +import app from './app.js' +import { getConnection } from './connect.js' +import { initDb } from './init/db/index.js' +import { initPm } from './plugins.js' +import { isCI, isDev, isDevSetup, isInt, isProd, isTest, port } from './utils/env.js' + +async function initializeDB(path: string) { + logger.info('Starting init DB...') + const { data } = await import(path) + await initDb(data) + logger.info('initDb invoked successfully') +} + +export async function startServer(defaultPort: number = (port ? +port : 8080)) { + try { + await getConnection() + } catch (error) { + logger.error({ err: error }, 'Database connection failed') + if (!(error instanceof Error)) return + throw error + } + + initPm() + + logger.info('Reading init database file') + + try { + const dataPath = (isProd || isInt) + ? './init/db/imports/data.js' + : '@cpn-console/test-utils/src/imports/data.ts' + await initializeDB(dataPath) + if (isProd && !isDevSetup) { + logger.info('Cleaning up imported data file...') + const __filename = fileURLToPath(import.meta.url) + const __dirname = dirname(__filename) + await rm(resolve(__dirname, dataPath)) + logger.info(`Successfully deleted '${dataPath}'`) + } + } catch (err) { + if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.message?.includes('Failed to load') || err?.message?.includes('Cannot find module')) { + logger.info('No initDb file, skipping') + } else { + logger.warn({ err }, 'Init DB failed') + throw err + } + } + + try { + await app.listen({ host: '0.0.0.0', port: defaultPort ?? 8080 }) + } catch (error) { + logger.error({ err: error }, 'Failed to start HTTP server') + process.exit(1) + } + logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) +} + +export async function getPreparedApp() { + try { + await getConnection() + } catch (error) { + logger.error({ err: error }, 'Database connection failed') + throw error + } + + initPm() + + logger.info('Reading init database file') + + try { + const dataPath = (isProd || isInt) + ? './init/db/imports/data.js' + : '@cpn-console/test-utils/src/imports/data.ts' + await initializeDB(dataPath) + if (isProd && !isDevSetup) { + logger.info('Cleaning up imported data file...') + const __filename = fileURLToPath(import.meta.url) + const __dirname = dirname(__filename) + await rm(resolve(__dirname, dataPath)) + logger.info(`Successfully deleted '${dataPath}'`) + } + } catch (err) { + if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.message?.includes('Failed to load') || err?.message?.includes('Cannot find module')) { + logger.info('No initDb file, skipping') + } else { + logger.warn({ err }, 'Init DB failed') + throw err + } + } + + logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) + return app +} diff --git a/.jjconflict-side-0/apps/server/src/prisma.ts b/.jjconflict-side-0/apps/server/src/prisma.ts new file mode 100644 index 0000000000..4590932b68 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +export default prisma diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql new file mode 100644 index 0000000000..f2f4e7b0b7 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql @@ -0,0 +1,151 @@ +-- CreateTable +CREATE TABLE "Environment" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Environment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Log" ( + "id" UUID NOT NULL, + "data" JSONB NOT NULL, + "action" TEXT NOT NULL DEFAULT '', + "userId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Log_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Organization" ( + "id" UUID NOT NULL, + "source" TEXT NOT NULL, + "name" TEXT NOT NULL, + "label" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Permission" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "environmentId" UUID NOT NULL, + "level" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Permission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "organizationId" UUID NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL, + "locked" BOOLEAN NOT NULL DEFAULT false, + "services" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Repository" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "internalRepoName" TEXT NOT NULL, + "externalRepoUrl" TEXT NOT NULL, + "externalUserName" TEXT, + "externalToken" TEXT, + "isInfra" BOOLEAN NOT NULL DEFAULT false, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Repository_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" UUID NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Role" ( + "userId" UUID NOT NULL, + "projectId" UUID NOT NULL, + "role" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Role_pkey" PRIMARY KEY ("userId","projectId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_id_key" ON "Organization"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_name_key" ON "Organization"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_label_key" ON "Organization"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_id_key" ON "Permission"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_userId_environmentId_key" ON "Permission"("userId", "environmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_id_key" ON "Project"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Role_userId_projectId_key" ON "Role"("userId", "projectId"); + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql new file mode 100644 index 0000000000..26e1ade3f2 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql @@ -0,0 +1,85 @@ +-- CreateEnum +CREATE TYPE "ClusterPrivacy" AS ENUM ('public', 'dedicated'); + +-- CreateTable +CREATE TABLE "Cluster" ( + "id" UUID NOT NULL, + "label" VARCHAR(50) NOT NULL, + "privacy" "ClusterPrivacy" NOT NULL DEFAULT 'dedicated', + "secretName" VARCHAR(50) NOT NULL, + "clusterResources" BOOLEAN NOT NULL DEFAULT false, + "kubeConfigId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cluster_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Kubeconfig" ( + "id" UUID NOT NULL, + "user" JSONB NOT NULL, + "cluster" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "parentClusterId" UUID, + + CONSTRAINT "Kubeconfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_ClusterToEnvironment" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateTable +CREATE TABLE "_ClusterToProject" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_id_key" ON "Cluster"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_label_key" ON "Cluster"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_secretName_key" ON "Cluster"("secretName"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_kubeConfigId_key" ON "Cluster"("kubeConfigId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_id_key" ON "Kubeconfig"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_parentClusterId_key" ON "Kubeconfig"("parentClusterId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToEnvironment_AB_unique" ON "_ClusterToEnvironment"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToEnvironment_B_index" ON "_ClusterToEnvironment"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToProject_AB_unique" ON "_ClusterToProject"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToProject_B_index" ON "_ClusterToProject"("B"); + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_kubeConfigId_fkey" FOREIGN KEY ("kubeConfigId") REFERENCES "Kubeconfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_B_fkey" FOREIGN KEY ("B") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_B_fkey" FOREIGN KEY ("B") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql new file mode 100644 index 0000000000..8f3fb5ff9f --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `parentClusterId` on the `Kubeconfig` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Kubeconfig_parentClusterId_key"; + +-- AlterTable +ALTER TABLE "Kubeconfig" DROP COLUMN "parentClusterId"; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql new file mode 100644 index 0000000000..4eb37edc53 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql @@ -0,0 +1,10 @@ +CREATE TYPE "ProjectStatus" AS ENUM ('initializing', 'created', 'failed', 'archived'); + +ALTER TABLE public."Project" ALTER COLUMN status TYPE "ProjectStatus" USING + case + when status = 'created' then 'created'::"ProjectStatus" + when status = 'failed' then 'failed'::"ProjectStatus" + when status = 'archived' then 'archived'::"ProjectStatus" + else 'initializing'::"ProjectStatus" + end; +ALTER TABLE public."Project" ALTER COLUMN status SET DEFAULT 'initializing'; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql new file mode 100644 index 0000000000..f402a0e3d4 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "infos" VARCHAR(200); diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql new file mode 100644 index 0000000000..f553e7880e --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `externalToken` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "externalToken"; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql new file mode 100644 index 0000000000..394b650a51 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql @@ -0,0 +1,81 @@ +-- CreateEnum +CREATE TYPE "QuotaStageStatus" AS ENUM +('active', 'pendingDelete'); + +-- Create new tables +-- CreateTable +CREATE TABLE "Quota" +( + "id" UUID NOT NULL, + "memory" VARCHAR NOT NULL, + "cpu" REAL NOT NULL, + "name" VARCHAR NOT NULL, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "Quota_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Stage" +( + "id" UUID NOT NULL, + "name" VARCHAR NOT NULL, + + CONSTRAINT "Stage_pkey" PRIMARY KEY ("id") +); + +-- Associate Quotas and Stages +-- CreateTable +CREATE TABLE "QuotaStage" +( + "id" UUID NOT NULL, + "quotaId" UUID NOT NULL, + "stageId" UUID NOT NULL, + "status" "QuotaStageStatus" NOT NULL DEFAULT 'active', + + CONSTRAINT "QuotaStage_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "Quota_id_key" ON "Quota"("id"); +CREATE UNIQUE INDEX "Quota_name_key" ON "Quota"("name"); +CREATE UNIQUE INDEX "Stage_id_key" ON "Stage"("id"); +CREATE UNIQUE INDEX "Stage_name_key" ON "Stage"("name"); +CREATE UNIQUE INDEX "QuotaStage_id_key" ON "QuotaStage"("id"); +CREATE UNIQUE INDEX "QuotaStage_quotaId_stageId_key" ON "QuotaStage"("quotaId", "stageId"); +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Create default values for Quotas and Stages +-- Quota +INSERT INTO "Quota" + (id, cpu, memory, "name", "isPrivate") +VALUES + ('5a57b62f-2465-4fb6-a853-5a751d099199', 2, '4Gi', 'small', false), + ('08770663-3b76-4af6-8978-9f75eda4faa7', 4, '8Gi', 'medium', false), + ('b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 6, '12Gi', 'large', false), + ('97b851e8-9067-4a3d-a0e8-c3a6820c49be', 8, '16Gi', 'xlarge', false); + +-- Stage +INSERT INTO "Stage" + (id, "name") +VALUES + ('4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', 'dev'), + ('38fa869d-6267-441d-af7f-e0548fd06b7e', 'staging'), + ('d434310e-7850-4d59-b47f-0772edf50582', 'integration'), + ('9b3e9991-896d-4d90-bdc5-a34be8c06b8f', 'prod'); + +-- QuotaStage +INSERT INTO "QuotaStage" + (id, "quotaId", "stageId") +VALUES + ('0cb0c549-560e-4f26-8f4e-832dd722f68a', '5a57b62f-2465-4fb6-a853-5a751d099199', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('0530e9c9-b37d-4dec-93e6-1895f700e61c', '5a57b62f-2465-4fb6-a853-5a751d099199', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('8a99db49-b7b1-44bf-865d-5e709e8aa0fc', '5a57b62f-2465-4fb6-a853-5a751d099199', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('67561f00-d219-4ca6-b94a-3ee83f09d2d6', '5a57b62f-2465-4fb6-a853-5a751d099199', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('8b3c201e-7518-4254-a94a-16c404e46936', '08770663-3b76-4af6-8978-9f75eda4faa7', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('9157ae12-3e39-43f8-a24f-ae5d9c6b69b7', '08770663-3b76-4af6-8978-9f75eda4faa7', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('c733a1dd-c9fd-4def-b29e-df49ef7b6698', '08770663-3b76-4af6-8978-9f75eda4faa7', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('15a51f47-0ab2-4a94-a808-722639d8c092', '08770663-3b76-4af6-8978-9f75eda4faa7', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('cb66e80c-2304-472d-bc19-a411011674ca', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('59fb0e79-3a76-4b96-81d4-63f4caa98cfa', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('4174b22c-2bee-4f4a-9d85-da7b5463f214', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql new file mode 100644 index 0000000000..8c98a7f74e --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql @@ -0,0 +1,35 @@ +-- Multiplication des environnements par clusteurs +ALTER TABLE "Environment" ADD COLUMN "clusterId" UUID; + +DO +$$ +DECLARE + perm record; + cte record; + env_uuid UUID; +BEGIN + FOR cte IN SELECT "B" AS environmentId, "A" AS "clusterId", "name", "projectId", status, "updatedAt", "createdAt" + FROM public."_ClusterToEnvironment", public."Environment" + WHERE public."Environment".id = "B" + LOOP + env_uuid := gen_random_uuid(); + INSERT INTO public."Environment" (id, "name", "projectId", "clusterId", status, "createdAt", "updatedAt") VALUES + (env_uuid, cte."name", cte."projectId", cte."clusterId", cte.status, cte."createdAt", cte."updatedAt"); + + FOR perm in SELECT * FROM public."Permission" WHERE "environmentId" = cte.environmentId + LOOP + INSERT INTO public."Permission" (id, "level", "createdAt", "updatedAt", "environmentId", "userId") VALUES + (gen_random_uuid(), perm."level", perm."createdAt", perm."updatedAt", env_uuid, perm."userId"); + END LOOP; + END LOOP; +END; +$$ +; +DELETE FROM public."Environment" WHERE "clusterId" is null; +ALTER TABLE public."Environment" ALTER COLUMN "clusterId" SET NOT NULL; +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_clusterId_fkey" FOREIGN KEY ("clusterId") REFERENCES "Cluster"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Delete old _ClusterToEnvironment +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_A_fkey"; +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_B_fkey"; +DROP TABLE "_ClusterToEnvironment"; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql new file mode 100644 index 0000000000..035cd1c855 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql @@ -0,0 +1,36 @@ +-- Associate cluster to Stages +-- CreateTable +CREATE TABLE "_ClusterToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToStage_AB_unique" ON "_ClusterToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToStage_B_index" ON "_ClusterToStage"("B"); + +DO +$$ +DECLARE + cluster record; + cte record; + env_uuid UUID; +BEGIN + FOR cluster IN SELECT id + FROM public."Cluster" + LOOP + INSERT INTO public."_ClusterToStage" ("A", "B") VALUES + (cluster.id, '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + (cluster.id, '38fa869d-6267-441d-af7f-e0548fd06b7e'), + (cluster.id, 'd434310e-7850-4d59-b47f-0772edf50582'), + (cluster.id, '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); + END LOOP; +END; +$$ diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql new file mode 100644 index 0000000000..43793bdb49 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaStageId" UUID; +UPDATE "Environment" SET "quotaStageId" = '8b3c201e-7518-4254-a94a-16c404e46936' WHERE "name" = 'dev'; +UPDATE "Environment" SET "quotaStageId" = '9157ae12-3e39-43f8-a24f-ae5d9c6b69b7' WHERE "name" = 'staging'; +UPDATE "Environment" SET "quotaStageId" = '4174b22c-2bee-4f4a-9d85-da7b5463f214' WHERE "name" = 'integration'; +UPDATE "Environment" SET "quotaStageId" = 'de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a' WHERE "name" = 'prod'; +ALTER TABLE "Environment" ALTER COLUMN "name" SET DATA TYPE VARCHAR(11); +ALTER TABLE "Environment" ALTER COLUMN "quotaStageId" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaStageId_fkey" FOREIGN KEY ("quotaStageId") REFERENCES "QuotaStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql new file mode 100644 index 0000000000..9af004b6a3 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- lock all projects +UPDATE public."Project" SET "locked"=true \ No newline at end of file diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql new file mode 100644 index 0000000000..d970d3965a --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- set all projects to failed to avoid unlock them +UPDATE public."Project" SET "status" = 'failed' WHERE "status" != 'archived' \ No newline at end of file diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql new file mode 100644 index 0000000000..c387d9885c --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ADD COLUMN "requestId" VARCHAR(21); diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql new file mode 100644 index 0000000000..18e20262c8 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `status` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the column `status` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "status"; + +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "status"; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql new file mode 100644 index 0000000000..f784b21567 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql @@ -0,0 +1,46 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "zoneId" UUID; + +-- CreateTable +CREATE TABLE "Zone" +( + "id" UUID NOT NULL, + "slug" VARCHAR(10) NOT NULL, + "label" VARCHAR(50) NOT NULL, + "description" VARCHAR(200), + "createdAt" TIMESTAMP +(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP +(3) NOT NULL, + + CONSTRAINT "Zone_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_id_key" ON "Zone"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_slug_key" ON "Zone"("slug"); + +-- Create default zone +INSERT INTO "Zone" + (id, "slug", "label", "description", "updatedAt") +VALUES + ('a66c4230-eba6-41f1-aae5-bb1e4f90cce0', 'default', 'Zone Défaut', 'Zone par défaut, à changer', CURRENT_TIMESTAMP); + +-- Set default zoneId for current clusters +UPDATE "Cluster" +SET "zoneId" += 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0' +WHERE "zoneId" +IS NULL; + +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "zoneId" +SET +NOT NULL; + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") +ON DELETE RESTRICT ON +UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql new file mode 100644 index 0000000000..cb4f7ad968 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "ProjectPlugin" ( + "pluginName" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "AdminPlugin" ( + "pluginName" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectPlugin_projectId_pluginName_key_key" ON "ProjectPlugin"("projectId", "pluginName", "key"); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminPlugin_pluginName_key_key" ON "AdminPlugin"("pluginName", "key"); + +-- AddForeignKey +ALTER TABLE "ProjectPlugin" ADD CONSTRAINT "ProjectPlugin_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql new file mode 100644 index 0000000000..11672324f8 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql @@ -0,0 +1,19 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id, services FROM public."Project" LOOP + -- Extrait 'registry.id' de la colonne JSON 'services' + registry_id := (SELECT (project_row.services -> 'registry' ->> 'id')::TEXT); + -- Si 'registry.id' existe, insérer dans la table 'config' + IF registry_id IS NOT NULL THEN + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'registry', 'projectId', registry_id::TEXT); + END IF; + END LOOP; +END $$; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "services"; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql new file mode 100644 index 0000000000..9c7d5a8f8f --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "RoleList" AS ENUM ('owner', 'user'); + +-- AlterTable +ALTER TABLE public."Role" ALTER COLUMN "role" TYPE "RoleList" USING + case + when role = 'owner' then 'owner'::"RoleList" + else 'user'::"RoleList" + end; \ No newline at end of file diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql new file mode 100644 index 0000000000..45a4a5d1ea --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "ProjectClusterHistory" ( + "projectId" UUID NOT NULL, + "clusterId" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectClusterHistory_projectId_clusterId_key" ON "ProjectClusterHistory"("projectId", "clusterId"); diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql new file mode 100644 index 0000000000..2b1641a657 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql @@ -0,0 +1,11 @@ +DO $$ +DECLARE + env_row RECORD; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR env_row IN SELECT "projectId", "clusterId" FROM public."Environment" LOOP + INSERT INTO public."ProjectClusterHistory" ("projectId", "clusterId") + VALUES (env_row."projectId", env_row."clusterId") + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql new file mode 100644 index 0000000000..5e7ff03d45 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql @@ -0,0 +1,58 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaId" UUID, +ADD COLUMN "stageId" UUID; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "_QuotaToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_QuotaToStage_AB_unique" ON "_QuotaToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_QuotaToStage_B_index" ON "_QuotaToStage"("B"); + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +DO $$ +DECLARE + quota_stage_row RECORD; +BEGIN + FOR quota_stage_row IN SELECT * FROM public."QuotaStage" loop + UPDATE public."Environment" SET "stageId" = quota_stage_row."stageId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + UPDATE public."Environment" SET "quotaId" = quota_stage_row."quotaId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + insert into public."_QuotaToStage" values (quota_stage_row."quotaId", quota_stage_row."stageId"); + END LOOP; +END $$; + +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaStageId_fkey"; + +-- AlterTable +ALTER TABLE "Environment" ALTER COLUMN "quotaId" SET NOT NULL, +ALTER COLUMN "stageId" SET NOT NULL, +DROP COLUMN "quotaStageId"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_stageId_fkey"; + +-- DropTable +DROP TABLE "QuotaStage"; + +-- DropEnum +DROP TYPE "QuotaStageStatus"; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql new file mode 100644 index 0000000000..0036da8a12 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Made the column `description` on table `Project` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "description" SET NOT NULL, +ALTER COLUMN "description" SET DEFAULT ''; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql new file mode 100644 index 0000000000..ed6ae9b84e --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql @@ -0,0 +1,198 @@ +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_environmentId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_environmentId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_userId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_userId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_projectId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_projectId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_userId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_userId_fkey"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectMembers') THEN + CREATE TABLE "ProjectMembers" ( + "projectId" UUID NOT NULL, + "userId" UUID NOT NULL, + "roleIds" TEXT[] + ); + END IF; +END $$; + +-- AlterTable +ALTER TABLE "Log" ADD COLUMN IF NOT EXISTS "projectId" UUID; + +INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt") +VALUES('04ac168a-2c4f-4816-9cce-af6c612e5912'::uuid, 'Anonymous', 'User', 'anon@user', '2023-07-03 14:46:56.770', '2023-07-03 14:46:56.770') +ON CONFLICT (id) DO NOTHING; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "everyonePerms" BIGINT NOT NULL DEFAULT 896, +ADD COLUMN IF NOT EXISTS "ownerId" UUID; + +DO $$ +DECLARE + role_row RECORD; +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR role_row IN SELECT "userId", "projectId", "role" FROM public."Role" LOOP + INSERT INTO public."ProjectMembers" ("userId", "projectId", "roleIds") VALUES (role_row."userId", role_row."projectId", '{}'); + IF role_row."role" = 'owner'::public."RoleList" THEN + UPDATE public."Project" + SET "ownerId"=role_row."userId" + WHERE id=role_row."projectId"::uuid; + END IF; + END LOOP; + END IF; +END $$; + +UPDATE public."Project" +SET "ownerId"='04ac168a-2c4f-4816-9cce-af6c612e5912' +WHERE "ownerId" IS NULL; + +ALTER TABLE public."Project" ALTER COLUMN "ownerId" SET NOT NULL; + +DELETE FROM public."ProjectMembers" pm +USING public."Project" p +WHERE pm."userId" = p."ownerId" +AND pm."projectId" = p."id"; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Permission') THEN + DROP TABLE "Permission"; + END IF; +END $$; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + DROP TABLE "Role"; + END IF; +END $$; + +-- DropEnum if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'RoleList') THEN + DROP TYPE "RoleList"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'AdminRole') THEN + CREATE TABLE "AdminRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "AdminRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectRole') THEN + CREATE TABLE "ProjectRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "projectId" UUID NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "ProjectRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_id_key') THEN + CREATE UNIQUE INDEX "AdminRole_id_key" ON "AdminRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_name_key') THEN + CREATE UNIQUE INDEX "AdminRole_name_key" ON "AdminRole"("name"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectMembers_projectId_userId_key') THEN + CREATE UNIQUE INDEX "ProjectMembers_projectId_userId_key" ON "ProjectMembers"("projectId", "userId"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectRole_id_key') THEN + CREATE UNIQUE INDEX "ProjectRole_id_key" ON "ProjectRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'Environment_projectId_name_key') THEN + CREATE UNIQUE INDEX "Environment_projectId_name_key" ON "Environment"("projectId", "name"); + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Log_projectId_fkey') THEN + ALTER TABLE "Log" ADD CONSTRAINT "Log_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Project_ownerId_fkey') THEN + ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_projectId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_userId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectRole_projectId_fkey') THEN + ALTER TABLE "ProjectRole" ADD CONSTRAINT "ProjectRole_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "adminRoleIds" TEXT[]; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql new file mode 100644 index 0000000000..c9b41827bf --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "AdminRole_name_key"; + +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "oidcGroup" TEXT; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql new file mode 100644 index 0000000000..265f262abb --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `oidcGroup` on table `AdminRole` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable + +UPDATE public."AdminRole" +SET "oidcGroup"='' +WHERE "oidcGroup" IS NULL; + +ALTER TABLE "AdminRole" ALTER COLUMN "oidcGroup" SET NOT NULL, +ALTER COLUMN "oidcGroup" SET DEFAULT ''; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql new file mode 100644 index 0000000000..4fc2768605 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "SystemSetting" +( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "SystemSetting_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemSetting_key_key" ON "SystemSetting"("key"); + +-- Create maintenance setting +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('maintenance', 'off'); diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql new file mode 100644 index 0000000000..95ab54869d --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql @@ -0,0 +1,3 @@ +INSERT INTO public."AdminRole" +(id, "name", permissions, "position", "oidcGroup") +VALUES('76229c96-4716-45bc-99da-00498ec9018c'::uuid, 'Admin', 2, 0, '/admin'); \ No newline at end of file diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql new file mode 100644 index 0000000000..c116482186 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Made the column `externalUserName` on table `Repository` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +UPDATE "Repository" SET "externalUserName" = '' WHERE "externalUserName" IS NULL; + +ALTER TABLE "Repository" ALTER COLUMN "externalUserName" SET NOT NULL, +ALTER COLUMN "externalUserName" SET DEFAULT '', +ALTER COLUMN "externalRepoUrl" SET DEFAULT ''; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240916141253_token/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240916141253_token/migration.sql new file mode 100644 index 0000000000..b0472cd806 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240916141253_token/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "TokenStatus" AS ENUM ('active', 'revoked'); + +-- CreateTable +CREATE TABLE "AdminToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "userId" UUID, + "expirationDate" TIMESTAMP(3), + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "AdminToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminToken_id_key" ON "AdminToken"("id"); + +-- AddForeignKey +ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql new file mode 100644 index 0000000000..47488b00c5 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "Log" DROP CONSTRAINT "Log_userId_fkey"; + +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "userId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql new file mode 100644 index 0000000000..18eca3eadf --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "requestId" SET DATA TYPE VARCHAR(36); diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql new file mode 100644 index 0000000000..74e0946f0a --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Zone" ADD COLUMN "argocdUrl" TEXT NOT NULL DEFAULT 'https://example.com'; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql new file mode 100644 index 0000000000..41dac75359 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ProjectStatus" ADD VALUE 'warning'; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql new file mode 100644 index 0000000000..ef888d5e50 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql @@ -0,0 +1,12 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id FROM public."Project" WHERE status <> 'archived'::public."ProjectStatus" LOOP + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'nexus', 'activateMavenRepo', 'enabled') + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql new file mode 100644 index 0000000000..a57e5956c3 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql @@ -0,0 +1,12 @@ +-- CreateEnum +CREATE TYPE "UserType" AS ENUM ('human', 'bot', 'ghost'); + +-- AlterEnum +ALTER TYPE "TokenStatus" ADD VALUE 'inactive'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "type" "UserType" NOT NULL DEFAULT 'human'; +UPDATE "User" SET type = 'ghost' WHERE id = '04ac168a-2c4f-4816-9cce-af6c612e5912'; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "type" DROP DEFAULT; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql new file mode 100644 index 0000000000..71e15a312c --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql @@ -0,0 +1,84 @@ +-- CreateTable (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'PersonalAccessToken') THEN + CREATE TABLE "PersonalAccessToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "userId" UUID NOT NULL, + "expirationDate" TIMESTAMP(3) NOT NULL, + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'PersonalAccessToken_id_key') THEN + CREATE UNIQUE INDEX "PersonalAccessToken_id_key" ON "PersonalAccessToken"("id"); + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'PersonalAccessToken_userId_fkey') THEN + ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- Process AdminToken (idempotent) +DO $$ +DECLARE + admin_token record; + user_uuid UUID; +BEGIN + FOR admin_token IN SELECT "name", "id" + FROM public."AdminToken" + LOOP + -- Generate new UUID if user does not exist + user_uuid := COALESCE( + (SELECT id FROM public."User" WHERE email = concat(admin_token.name, '@bot.id')), + gen_random_uuid() + ); + + -- Insert user if not already exists + INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt", "type") + VALUES(user_uuid, 'Bot Admin', admin_token.name, concat(admin_token.name, '@bot.id'), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'bot') + ON CONFLICT (id) DO NOTHING; + + -- Update AdminToken with the new user ID + UPDATE public."AdminToken" SET "userId" = user_uuid WHERE id = admin_token.id; + END LOOP; +END $$; + +-- Alter AdminToken userId column to NOT NULL (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'AdminToken' AND column_name = 'userId' AND is_nullable = 'NO') THEN + ALTER TABLE public."AdminToken" ALTER COLUMN "userId" SET NOT NULL; + END IF; +END $$; + +-- DropForeignKey if exists (idempotent) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" DROP CONSTRAINT "AdminToken_userId_fkey"; + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql new file mode 100644 index 0000000000..521b2b10ac --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastLogin" TIMESTAMP(3); diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql new file mode 100644 index 0000000000..a7500833b9 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "slug" TEXT; + +UPDATE public."Project" p +SET slug = ( + SELECT concat(org.name, '-', subp.name) FROM public."Project" subp + LEFT JOIN public."Organization" org on org."id" = subp."organizationId" + WHERE subp.id = p.id +); + +ALTER TABLE public."Project" ALTER COLUMN "slug" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug"); diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql new file mode 100644 index 0000000000..b143cbeb9b --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "lastSuccessProvisionningVersion" TEXT; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql new file mode 100644 index 0000000000..7a88681901 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToProject_AB_unique"; + +-- AlterTable +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToStage_AB_unique"; + +-- AlterTable +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_QuotaToStage_AB_unique"; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql new file mode 100644 index 0000000000..21ce77b8d5 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "external" BOOLEAN NOT NULL DEFAULT false; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql new file mode 100644 index 0000000000..ac63cd639d --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql @@ -0,0 +1,25 @@ +-- Vérifie les versions dans la table Project +DO $$ +DECLARE + project_id TEXT; + project_name TEXT; + last_version TEXT; +BEGIN + -- Boucle sur les projets non archivés + FOR project_id, project_name, last_version IN ( + SELECT id, name, "lastSuccessProvisionningVersion" + FROM "Project" + WHERE "status" != 'archived' + ) + LOOP + -- Vérifie si la version est NULL + IF last_version IS NULL THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version NULL.', project_name, project_id; + END IF; + + -- Vérifie si la version est inférieure à 8.23.0 selon SemVer + IF (string_to_array(last_version, '.')::int[] < ARRAY[8,23,0]) THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version (%), inférieure à 8.23.0.', project_name, project_id, last_version; + END IF; + END LOOP; +END $$; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql new file mode 100644 index 0000000000..54871c9017 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `organizationId` on the `Project` table. All the data in the column will be lost. + - You are about to drop the `Organization` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Project" DROP CONSTRAINT "Project_organizationId_fkey"; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "organizationId"; + +-- DropTable +DROP TABLE "Organization"; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql new file mode 100644 index 0000000000..68ca0df2fa --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "infos" SET DATA TYPE VARCHAR(1000); diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql new file mode 100644 index 0000000000..8364090d8d --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql @@ -0,0 +1,44 @@ +-- AlterTable +ALTER TABLE "Environment" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; + +COMMENT ON COLUMN "Environment".cpu IS 'CPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".gpu IS 'GPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".memory IS 'Memory value as GigaBytes (1 and 0.01 are valid values)'; + +-- Use values from Quota. Memory is an extract of q.memory numeric value as it contains a unit (e.g. '2Gi'). +UPDATE "Environment" +SET cpu = q.cpu, memory = COALESCE(NULLIF(regexp_replace(q.memory, '\D', '','g'), ''), '0')::numeric +FROM "Quota" q +WHERE "quotaId" = q."id"; + +/* + Warnings: + + - You are about to drop the column `quotaId` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the `Quota` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_QuotaToStage` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_B_fkey"; + +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "quotaId", +ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- DropTable +DROP TABLE "Quota"; + +-- DropTable +DROP TABLE "_QuotaToStage"; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql new file mode 100644 index 0000000000..77f32b5ab5 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Cluster" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql new file mode 100644 index 0000000000..decca804aa --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Project" +ADD COLUMN "limitless" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "hprodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodMemory" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodMemory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql new file mode 100644 index 0000000000..95f3a689db --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql @@ -0,0 +1,4 @@ +-- Rename default zone +UPDATE "Zone" +SET ("label", "description") = ('DSO', 'Zone par défaut') +WHERE slug = 'default'; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql new file mode 100644 index 0000000000..aadb6cdba9 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Repository" ADD COLUMN "deployRevision" TEXT, +ADD COLUMN "deployPath" TEXT, +ADD COLUMN "helmValuesFiles" TEXT; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql new file mode 100644 index 0000000000..15cf56ab82 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql @@ -0,0 +1,12 @@ +-- Eliminate NULLs +UPDATE "Repository" SET "deployRevision" = '' WHERE "deployRevision" IS NULL; +UPDATE "Repository" SET "deployPath" = '' WHERE "deployPath" IS NULL; +UPDATE "Repository" SET "helmValuesFiles" = '' WHERE "helmValuesFiles" IS NULL; + +-- AlterTable +ALTER TABLE "Repository" ALTER COLUMN "deployRevision" SET NOT NULL, +ALTER COLUMN "deployRevision" SET DEFAULT '', +ALTER COLUMN "deployPath" SET NOT NULL, +ALTER COLUMN "deployPath" SET DEFAULT '', +ALTER COLUMN "helmValuesFiles" SET NOT NULL, +ALTER COLUMN "helmValuesFiles" SET DEFAULT ''; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql new file mode 100644 index 0000000000..1292495121 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "autosync" BOOLEAN NOT NULL DEFAULT true; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql new file mode 100644 index 0000000000..04c98df036 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "oidcGroup" TEXT NOT NULL DEFAULT ''; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql new file mode 100644 index 0000000000..40da41a75d --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- Update AdminRole system roles +UPDATE "AdminRole" SET "type" = 'system' WHERE "name" IN ('Admin', 'Admin Locaux'); + +-- Update ProjectRole system roles +UPDATE "ProjectRole" SET "type" = 'system' WHERE "name" IN ('Administrateur', 'DevOps', 'Développeur', 'Lecture seule'); diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql new file mode 100644 index 0000000000..92c200d261 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql @@ -0,0 +1,26 @@ +-- Update existing Admin role to be system role 'Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + "oidcGroup" = '/admin', + "position" = 0 +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Lecture Seule Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '35848aa2-e881-4770-9844-0c5c3693e506'::uuid, + 'Lecture Seule Plateforme', + 1, -- Assuming 1n means bit 0 + 2, + '/readonly', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Lecture Seule Plateforme', + "type" = 'system', + "permissions" = 1, + "oidcGroup" = '/readonly'; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql new file mode 100644 index 0000000000..2a4afa8971 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql @@ -0,0 +1,28 @@ +-- Update existing Admin role to be system role 'Root Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Root Administrateur Plateforme' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Administrateur Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf'::uuid, + 'Administrateur Plateforme', + 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + 0, + '/console/admin', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, + "oidcGroup" = '/console/admin'; + +-- Update 'Lecture Seule Plateforme' system role +UPDATE "AdminRole" +SET + "oidcGroup" = '/console/readonly' +WHERE "id" = '35848aa2-e881-4770-9844-0c5c3693e506'::uuid; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql new file mode 100644 index 0000000000..f0cdb04873 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `external` on the `Cluster` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Cluster" DROP COLUMN "external"; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql new file mode 100644 index 0000000000..e07cad777b --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql @@ -0,0 +1,5 @@ +-- Enable legacy default permissions flag +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('refined-permissions', 'off'); diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql new file mode 100644 index 0000000000..efb6bc67d4 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql @@ -0,0 +1,3 @@ +-- Remove 'refined-permissions' system setting +DELETE FROM "SystemSetting" +WHERE "key" = 'refined-permissions'; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql new file mode 100644 index 0000000000..d019403c44 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql @@ -0,0 +1,15 @@ +-- Add global 'Tout le monde' role +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '034f589f-1750-4b15-bb34-4cd995e7fcaa'::uuid, + 'Tout le monde', + 25608, -- MANAGE_PROJECTS | LIST_PROJECTS | LIST_ZONES | LIST_STAGES + 0, + '', + 'global' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Tout le monde', + "type" = 'global', + "permissions" = 25608; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql new file mode 100644 index 0000000000..4fd0d80eb2 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql @@ -0,0 +1,25 @@ +-- Alter existing column to change the default value from 'custom' to 'managed' for AdminRole type +ALTER TABLE "AdminRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Alter existing column to change the default value from 'custom' to 'managed' for ProjectRole type +ALTER TABLE "ProjectRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Migrate system roles to managed roles +UPDATE "AdminRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate system roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate custom roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'custom'; + +-- Update existing Admin role to be an externally managed role +UPDATE "AdminRole" +SET "type" = 'external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql new file mode 100644 index 0000000000..d2691666bd --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql @@ -0,0 +1,15 @@ +-- Migrate well-known project roles to the new "system:"-prefixed type +UPDATE "ProjectRole" +SET "type" = 'system:managed' +WHERE + "type" = 'managed' + AND "oidcGroup" ~ '^/[^/]+/console/(admin|devops|developer|readonly)$'; + +-- Migrate well-known admin roles to the new "system:"-prefixed type +UPDATE "AdminRole" +SET "type" = 'system:managed' +WHERE id IN ( + '76229c96-4716-45bc-99da-00498ec9018c', -- Root Administrateur Plateforme + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', -- Administrateur Plateforme + '35848aa2-e881-4770-9844-0c5c3693e506' -- Lecture Seule Plateforme +); diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql new file mode 100644 index 0000000000..23593ba4f4 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "hprodCpu" DROP DEFAULT, +ALTER COLUMN "hprodGpu" DROP DEFAULT, +ALTER COLUMN "hprodMemory" DROP DEFAULT, +ALTER COLUMN "prodCpu" DROP DEFAULT, +ALTER COLUMN "prodGpu" DROP DEFAULT, +ALTER COLUMN "prodMemory" DROP DEFAULT; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql new file mode 100644 index 0000000000..7e3eefb25d --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql @@ -0,0 +1,4 @@ +-- Set the Root Administrateur Plateforme admin role to external system-managed type +UPDATE "AdminRole" +SET "type" = 'system:external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql new file mode 100644 index 0000000000..ca81594339 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "DeploymentSourceType" AS ENUM ('git', 'oci'); + +-- CreateTable +CREATE TABLE "Deployment" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "name" VARCHAR(11) NOT NULL, + "autosync" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "environmentId" UUID NOT NULL, + + CONSTRAINT "Deployment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeploymentSource" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deploymentId" UUID NOT NULL, + "repositoryId" UUID NOT NULL, + "type" "DeploymentSourceType" NOT NULL, + "targetRevision" TEXT NOT NULL DEFAULT '', + "path" TEXT NOT NULL DEFAULT '', + "helmValuesFiles" TEXT NOT NULL DEFAULT '', + + CONSTRAINT "DeploymentSource_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Deployment_id_key" ON "Deployment"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentSource_id_key" ON "DeploymentSource"("id"); + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "Deployment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql new file mode 100644 index 0000000000..ad5a64d336 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_environmentId_fkey"; + +-- DropForeignKey +ALTER TABLE "DeploymentSource" DROP CONSTRAINT "DeploymentSource_repositoryId_fkey"; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-0/apps/server/src/prisma/migrations/migration_lock.toml b/.jjconflict-side-0/apps/server/src/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000..044d57cdb0 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/.jjconflict-side-0/apps/server/src/prisma/schema/admin.prisma b/.jjconflict-side-0/apps/server/src/prisma/schema/admin.prisma new file mode 100644 index 0000000000..c4ca38ccb1 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/schema/admin.prisma @@ -0,0 +1,21 @@ +model AdminPlugin { + pluginName String + key String + value String + + @@unique([pluginName, key]) +} + +model AdminRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") +} + +model SystemSetting { + key String @id @unique + value String +} diff --git a/.jjconflict-side-0/apps/server/src/prisma/schema/project.prisma b/.jjconflict-side-0/apps/server/src/prisma/schema/project.prisma new file mode 100644 index 0000000000..6bcc5ce949 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/schema/project.prisma @@ -0,0 +1,144 @@ +model Deployment { + id String @id @unique @default(uuid()) @db.Uuid + projectId String @db.Uuid + name String @db.VarChar(11) + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + environmentId String @db.Uuid + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model DeploymentSource { + id String @id @unique @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deploymentId String @db.Uuid + repositoryId String @db.Uuid + type DeploymentSourceType + targetRevision String @default("") + path String @default("") + helmValuesFiles String @default("") + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) + deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) +} + +model Environment { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(11) + projectId String @db.Uuid + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clusterId String @db.Uuid + stageId String @db.Uuid + cluster Cluster @relation(fields: [clusterId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + stage Stage @relation(fields: [stageId], references: [id]) + deployments Deployment[] + + @@unique([projectId, name]) +} + +model Repository { + id String @id @default(uuid()) @db.Uuid + projectId String @db.Uuid + internalRepoName String + externalRepoUrl String @default("") + externalUserName String @default("") + isInfra Boolean @default(false) + isPrivate Boolean @default(false) + deployRevision String @default("") + deployPath String @default("") + helmValuesFiles String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model ProjectClusterHistory { + projectId String @db.Uuid + clusterId String @db.Uuid + + @@unique([projectId, clusterId]) +} + +model ProjectMembers { + projectId String @db.Uuid + userId String @db.Uuid + roleIds String[] + project Project @relation(fields: [projectId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@unique([projectId, userId]) +} + +model ProjectPlugin { + pluginName String + projectId String @db.Uuid + key String + value String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, pluginName, key]) +} + +model ProjectRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + projectId String @db.Uuid + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") + project Project @relation(fields: [projectId], references: [id]) +} + +model Project { + id String @id @unique @default(uuid()) @db.Uuid + name String + description String @default("") + status ProjectStatus @default(initializing) + locked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + everyonePerms BigInt @default(896) + ownerId String @db.Uuid + environments Environment[] + logs Log[] + owner User @relation(fields: [ownerId], references: [id]) + members ProjectMembers[] + plugins ProjectPlugin[] + roles ProjectRole[] + repositories Repository[] + clusters Cluster[] @relation("ClusterToProject") + slug String @unique + limitless Boolean @default(true) + hprodCpu Float @db.Real + hprodGpu Float @db.Real + hprodMemory Float @db.Real + prodCpu Float @db.Real + prodGpu Float @db.Real + prodMemory Float @db.Real + lastSuccessProvisionningVersion String? + deployments Deployment[] +} + +enum ProjectStatus { + initializing + created + failed + archived + warning +} + +enum DeploymentSourceType { + git + oci +} diff --git a/.jjconflict-side-0/apps/server/src/prisma/schema/schema.prisma b/.jjconflict-side-0/apps/server/src/prisma/schema/schema.prisma new file mode 100644 index 0000000000..aadf7fea13 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/schema/schema.prisma @@ -0,0 +1,21 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DB_URL") +} + +model Log { + id String @id @default(uuid()) @db.Uuid + data Json + action String @default("") + userId String? @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + requestId String? @db.VarChar(36) + projectId String? @db.Uuid + project Project? @relation(fields: [projectId], references: [id]) + user User? @relation(fields: [userId], references: [id]) +} diff --git a/.jjconflict-side-0/apps/server/src/prisma/schema/token.prisma b/.jjconflict-side-0/apps/server/src/prisma/schema/token.prisma new file mode 100644 index 0000000000..c0c55751c5 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/schema/token.prisma @@ -0,0 +1,30 @@ +model AdminToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + userId String @db.Uuid + expirationDate DateTime? + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +model PersonalAccessToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + userId String @db.Uuid + expirationDate DateTime + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +enum TokenStatus { + active + revoked + inactive +} diff --git a/.jjconflict-side-0/apps/server/src/prisma/schema/topography.prisma b/.jjconflict-side-0/apps/server/src/prisma/schema/topography.prisma new file mode 100644 index 0000000000..63d90abb8a --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/schema/topography.prisma @@ -0,0 +1,52 @@ +model Cluster { + id String @id @unique @default(uuid()) @db.Uuid + label String @unique @db.VarChar(50) + privacy ClusterPrivacy @default(dedicated) + secretName String @unique @default(uuid()) @db.VarChar(50) + clusterResources Boolean @default(false) + kubeConfigId String @unique @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + infos String? @db.VarChar(1000) + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + zoneId String @db.Uuid + kubeconfig Kubeconfig @relation(fields: [kubeConfigId], references: [id], onDelete: Cascade) + zone Zone @relation(fields: [zoneId], references: [id]) + environments Environment[] + projects Project[] @relation("ClusterToProject") + stages Stage[] @relation("ClusterToStage") +} + +model Kubeconfig { + id String @id @unique @default(uuid()) @db.Uuid + user Json + cluster Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + parentCluster Cluster? +} + +model Stage { + id String @id @unique @default(uuid()) @db.Uuid + name String @unique @db.VarChar + environments Environment[] + clusters Cluster[] @relation("ClusterToStage") +} + +model Zone { + id String @id @unique @default(uuid()) @db.Uuid + slug String @unique @db.VarChar(10) + label String @db.VarChar(50) + description String? @db.VarChar(200) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + argocdUrl String @default("https://example.com") + clusters Cluster[] +} + +enum ClusterPrivacy { + public + dedicated +} diff --git a/.jjconflict-side-0/apps/server/src/prisma/schema/user.prisma b/.jjconflict-side-0/apps/server/src/prisma/schema/user.prisma new file mode 100644 index 0000000000..e90fb69f83 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/prisma/schema/user.prisma @@ -0,0 +1,23 @@ +model User { + id String @id @db.Uuid + firstName String + lastName String + email String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? + adminRoleIds String[] + type UserType + + logs Log[] + projectsOwned Project[] + adminTokens AdminToken[] + projectMembers ProjectMembers[] + personalAccessTokens PersonalAccessToken[] +} + +enum UserType { + human + bot + ghost +} diff --git a/.jjconflict-side-0/apps/server/src/resources/admin-role/business.spec.ts b/.jjconflict-side-0/apps/server/src/resources/admin-role/business.spec.ts new file mode 100644 index 0000000000..9f9a7051ea --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/admin-role/business.spec.ts @@ -0,0 +1,262 @@ +import type { AdminRole, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400 } from '../../utils/errors.ts' +import { countRolesMembers, createRole, deleteRole, listRoles, patchRoles } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +describe('test admin-role business', () => { + describe('listRoles', () => { + it('should stringify bigint', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + const response = await listRoles() + expect(response).toContainEqual(expect.objectContaining({ permissions: '4', type: 'managed' })) + }) + }) + + describe('createRole', () => { + it('should create role with incremented position when position 0 is the highest', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 1 } }) + }) + + it('should create role with incremented position with bigger position', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 51 } }) + }) + + it('should create role with incremented position with no role in db', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(null) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 0 } }) + }) + }) + describe('deleteRole', () => { + const roleId = faker.string.uuid() + it('should delete role and remove id from concerned users', async () => { + const users = [{ + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roleId], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }, { + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roleId, faker.string.uuid()], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }] as const satisfies User[] + + const dbRole: AdminRole = { + name: 'Admin', + id: roleId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.user.findMany.mockResolvedValue(users) + prisma.adminRole.findMany.mockResolvedValueOnce([]) + prisma.adminRole.findUnique.mockResolvedValueOnce(dbRole) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.delete.mockResolvedValue({ args: {}, failed: false, results: {} }) + await deleteRole(roleId, faker.string.uuid()) + + expect(prisma.user.findMany).toHaveBeenCalledTimes(2) + expect(prisma.user.update).toHaveBeenNthCalledWith(1, { where: { id: users[0].id }, data: { adminRoleIds: [] } }) + expect(prisma.user.update).toHaveBeenNthCalledWith(2, { where: { id: users[1].id }, data: { adminRoleIds: [users[1].adminRoleIds[1]] } }) + expect(prisma.adminRole.delete).toHaveBeenCalledWith({ where: { id: roleId } }) + }) + }) + + describe('countRolesMembers', () => { + it('should return aggregated role member counts', async () => { + const roles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + type: 'managed', + }] as const satisfies AdminRole[] + + const users = [{ + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roles[0].id, roles[1].id], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }, { + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roles[1].id], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }] as const satisfies User[] + prisma.adminRole.findMany.mockResolvedValue(roles) + prisma.user.findMany.mockResolvedValue(users) + + const response = await countRolesMembers() + + expect(response).toEqual({ [roles[0].id]: 1, [roles[1].id]: 2 }) + }) + }) + describe('patchRoles', () => { + const dbRoles: AdminRole[] = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + type: 'managed', + }] + + it('should do nothing', async () => { + prisma.adminRole.findMany.mockResolvedValue([]) + await patchRoles([], faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 1 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(updateRoles, faker.string.uuid()) + + expect(response).instanceOf(BadRequest400) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + it('should return 400 if incoherent positions (missing roles)', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[1].id, position: 1 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(updateRoles, faker.string.uuid()) + + expect(response).instanceOf(BadRequest400) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + it('should update positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 0 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await patchRoles(updateRoles, faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(2) + }) + it('should update permissions', async () => { + const updateRoles: (Pick & { permissions?: string })[] = [ + { id: dbRoles[1].id, permissions: '0' }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await patchRoles(updateRoles, faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(1) + expect(prisma.adminRole.update).toHaveBeenCalledWith({ + data: { + name: dbRoles[1].name, + oidcGroup: dbRoles[1].oidcGroup, + permissions: 0n, + position: 1, + type: 'managed', + }, + where: { + id: dbRoles[1].id, + }, + }) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/admin-role/business.ts b/.jjconflict-side-0/apps/server/src/resources/admin-role/business.ts new file mode 100644 index 0000000000..f81fd65b75 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/admin-role/business.ts @@ -0,0 +1,111 @@ +import type { AdminRole, adminRoleContract } from '@cpn-console/shared' +import type { Project, ProjectRole } from '@prisma/client' +import type { ErrorResType } from '@/utils/errors.js' +import prisma from '@/prisma.js' +import { addLogs, getAdminRoleById, listAdminRoles } from '@/resources/queries-index.js' +import { BadRequest400 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function listRoles() { + return listAdminRoles() + .then(roles => roles.map(role => ({ ...role, permissions: role.permissions.toString(), type: role.type ?? 'managed' }))) +} + +export async function patchRoles( + roles: typeof adminRoleContract.patchAdminRoles.body._type, + requestId: string, +): Promise { + const dbRoles = await prisma.adminRole.findMany() + const positionsAvailable: number[] = [] + const updatedRoles: (Omit & { permissions: bigint })[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (matchingRole) { + if (typeof matchingRole.position !== 'undefined' && !positionsAvailable.includes(matchingRole.position)) { + positionsAvailable.push(matchingRole.position) + } + updatedRoles.push({ + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions: matchingRole.permissions ? BigInt(matchingRole.permissions) : dbRole.permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup: matchingRole.oidcGroup ?? dbRole.oidcGroup, + type: matchingRole.type ?? dbRole.type, + }) + } + } + + if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes') + for (const { id, ...role } of updatedRoles) { + await prisma.adminRole.update({ where: { id }, data: role }) + const hookReply = await hook.adminRole.upsert(id) + await addLogs({ action: 'Update Admin Role', data: hookReply, requestId }) + } + + return listRoles() +} + +export async function createRole( + role: typeof adminRoleContract.createAdminRole.body._type, + requestId: string, +) { + const dbMaxPosRole = (await prisma.adminRole.findFirst({ + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + const createdRole = await prisma.adminRole.create({ + data: { + ...role, + position: dbMaxPosRole + 1, + permissions: 0n, + }, + }) + + const hookReply = await hook.adminRole.upsert(createdRole.id) + await addLogs({ action: 'Create Admin Role', data: hookReply, requestId }) + + return listRoles() +} + +export async function countRolesMembers() { + const roles = await prisma.adminRole.findMany({ where: { oidcGroup: { equals: '' } }, select: { id: true } }) + const roleIds = roles.map(role => role.id) + const users = await prisma.user.findMany({ + where: { adminRoleIds: { hasSome: roleIds } }, + select: { adminRoleIds: true }, + }) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) // {role uuid: 0} + for (const { adminRoleIds } of users) { + for (const roleId of adminRoleIds) { + rolesCounts[roleId]++ + } + } + return rolesCounts +} + +export async function deleteRole( + roleId: Project['id'], + requestId: string, +) { + const role = await getAdminRoleById(roleId) + if (role) { + const hookReply = await hook.adminRole.delete(role) + await addLogs({ action: 'Delete Admin Role', data: hookReply, requestId }) + } + + const allUsers = await prisma.user.findMany({ + where: { + adminRoleIds: { has: roleId }, + }, + }) + for (const user of allUsers) { + await prisma.user.update({ + where: { id: user.id }, + data: { adminRoleIds: user.adminRoleIds.filter(adminRoleId => adminRoleId !== roleId) }, + }) + } + await prisma.adminRole.delete({ where: { id: roleId } }) + return null +} diff --git a/.jjconflict-side-0/apps/server/src/resources/admin-role/queries.ts b/.jjconflict-side-0/apps/server/src/resources/admin-role/queries.ts new file mode 100644 index 0000000000..9c29eb0b36 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/admin-role/queries.ts @@ -0,0 +1,40 @@ +import type { + AdminRole, + Prisma, +} from '@prisma/client' +import prisma from '@/prisma.js' + +export const listAdminRoles = () => prisma.adminRole.findMany({ orderBy: { position: 'asc' } }) + +export function createAdminRole(data: Pick) { + return prisma.adminRole.create({ + data: { + name: data.name, + permissions: 0n, + position: data.position, + type: 'managed', + }, + }) +} + +export function updateAdminRole(id: AdminRole['id'], data: Pick) { + return prisma.adminRole.updateMany({ + where: { id }, + data, + }) +} + +export function deleteAdminRole(id: AdminRole['id']) { + return prisma.adminRole.delete({ + where: { + id, + }, + }) +} + +export async function getAdminRoleById(id: string) { + const role = await prisma.adminRole.findUnique({ where: { id } }) + if (!role) return null + const members = await prisma.user.findMany({ where: { adminRoleIds: { has: id } } }) + return { ...role, members } +} diff --git a/.jjconflict-side-0/apps/server/src/resources/admin-role/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/admin-role/router.spec.ts new file mode 100644 index 0000000000..dab0f79268 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/admin-role/router.spec.ts @@ -0,0 +1,184 @@ +import { ADMIN_PERMS, adminRoleContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListRolesMock = vi.spyOn(business, 'listRoles') +const businessCreateRoleMock = vi.spyOn(business, 'createRole') +const businessPatchRolesMock = vi.spyOn(business, 'patchRoles') +const businessCountRolesMembersMock = vi.spyOn(business, 'countRolesMembers') +const businessDeleteRoleMock = vi.spyOn(business, 'deleteRole') + +describe('test adminRoleContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listAdminRoles', () => { + it('should return list of admin roles', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const roles = [{ id: faker.string.uuid(), name: 'Role 1', oidcGroup: '', position: 0, permissions: '1', type: 'managed' }] + businessListRolesMock.mockResolvedValueOnce(roles) + + const response = await app.inject() + .get(adminRoleContract.listAdminRoles.path) + .end() + + expect(businessListRolesMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(roles) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('createAdminRole', () => { + it('should create a role for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + const newRole = { id: 'newRole', name: 'New Role' } + const roleData = { name: 'New Role' } + + authUserMock.mockResolvedValueOnce(user) + businessCreateRoleMock.mockResolvedValueOnce(newRole) + + const response = await app.inject() + .post(adminRoleContract.createAdminRole.path) + .body(roleData) + .end() + + expect(businessCreateRoleMock).toHaveBeenCalledWith(roleData, expect.any(String)) + expect(response.json()).toEqual(newRole) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(adminRoleContract.createAdminRole.path) + .body({ name: 'New Role' }) + .end() + + expect(businessCreateRoleMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('patchAdminRoles', () => { + const updatedRoles = [{ id: faker.string.uuid(), name: 'Role 1', oidcGroup: '', position: 0, permissions: '1', type: 'managed' }] + const rolesData = [{ id: updatedRoles[0].id, name: 'Updated Role', type: 'managed' }] + it('should update roles for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessPatchRolesMock.mockResolvedValueOnce(updatedRoles) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledWith(rolesData, expect.any(String)) + expect(response.json()).toEqual(updatedRoles) + expect(response.statusCode).toEqual(200) + }) + + it('should return error if business logic fails', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessPatchRolesMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledWith(rolesData, expect.any(String)) + expect(response.statusCode).toEqual(400) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('adminRoleMemberCounts', () => { + it('should return counts of role members for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + const counts = { role1: 5, role2: 3 } + + authUserMock.mockResolvedValueOnce(user) + businessCountRolesMembersMock.mockResolvedValueOnce(counts) + + const response = await app.inject() + .get(adminRoleContract.adminRoleMemberCounts.path) + .end() + + expect(businessCountRolesMembersMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(counts) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 if user is not admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(adminRoleContract.adminRoleMemberCounts.path) + .end() + + expect(businessCountRolesMembersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteAdminRole', () => { + const roleId = faker.string.uuid() + it('should delete a role for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessDeleteRoleMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .delete(adminRoleContract.deleteAdminRole.path.replace(':roleId', roleId)) + .end() + + expect(businessDeleteRoleMock).toHaveBeenCalledWith(roleId, expect.any(String)) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(adminRoleContract.deleteAdminRole.path.replace(':roleId', roleId)) + .end() + + expect(businessDeleteRoleMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/admin-role/router.ts b/.jjconflict-side-0/apps/server/src/resources/admin-role/router.ts new file mode 100644 index 0000000000..4141a2353e --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/admin-role/router.ts @@ -0,0 +1,78 @@ +import { AdminAuthorized, adminRoleContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + countRolesMembers, + createRole, + deleteRole, + listRoles, + patchRoles, +} from './business.js' + +export function adminRoleRouter() { + return serverInstance.router(adminRoleContract, { + // Récupérer des projets + listAdminRoles: async () => { + const body = await listRoles() + + return { + status: 200, + body, + } + }, + + createAdminRole: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await createRole(body, req.id) + + return { + status: 201, + body: resBody, + } + }, + + patchAdminRoles: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await patchRoles(body, req.id) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + adminRoleMemberCounts: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await countRolesMembers() + + return { + status: 200, + body: resBody, + } + }, + + deleteAdminRole: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await deleteRole(params.roleId, req.id) + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/admin-token/business.spec.ts b/.jjconflict-side-0/apps/server/src/resources/admin-token/business.spec.ts new file mode 100644 index 0000000000..2a698ac744 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/admin-token/business.spec.ts @@ -0,0 +1,73 @@ +import type { AdminToken } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { createToken, deleteToken, listTokens } from './business.ts' + +describe('test admin-token business', () => { + describe('listTokens', () => { + it('should stringify bigint', async () => { + const partialtoken: Partial = { + permissions: 4n, + } + + prisma.adminToken.findMany.mockResolvedValueOnce([partialtoken]) + const response = await listTokens({}) + expect(response).toEqual([{ permissions: '4' }]) + }) + it('should return revoked', async () => { + const partialtoken: Partial = { + permissions: 4n, + status: 'revoked', + } + + prisma.adminToken.findMany.mockResolvedValueOnce([partialtoken]) + const response = await listTokens({ withRevoked: true }) + expect(response).toEqual([{ ...partialtoken, permissions: '4' }]) + }) + }) + + describe('createToken', () => { + it('should create ', async () => { + const dbToken: Partial = undefined + const userId = faker.string.uuid() + const createdToken: AdminToken = { + expirationDate: null, + id: faker.string.uuid(), + name: 'test', + permissions: '2', + } + prisma.adminToken.findUnique.mockResolvedValueOnce(dbToken) + prisma.adminToken.create.mockResolvedValueOnce(createdToken) + await createToken({ name: 'test', permissions: '2', expirationDate: null }, userId, undefined) + + expect(prisma.adminToken.create).toHaveBeenCalledWith({ + data: { + name: 'test', + hash: expect.any(String), + permissions: 2n, + userId: expect.any(String), + expirationDate: undefined, + }, + omit: expect.any(Object), + include: { + owner: true, + }, + }) + }) + it('should not create cause expiration is too short', async () => { + const expirationDate = new Date() + await createToken({ name: 'test', permissions: '2', expirationDate: expirationDate.toISOString() }) + + expect(prisma.adminToken.create).toHaveBeenCalledTimes(0) + }) + }) + + describe('deleteToken', () => { + it('should delete token', async () => { + prisma.adminToken.delete.mockResolvedValueOnce(undefined) + await deleteToken(faker.string.uuid()) + expect(prisma.adminToken.updateMany).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/admin-token/business.ts b/.jjconflict-side-0/apps/server/src/resources/admin-token/business.ts new file mode 100644 index 0000000000..0702fde2b9 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/admin-token/business.ts @@ -0,0 +1,75 @@ +import type { adminTokenContract } from '@cpn-console/shared' +import type { $Enums, AdminToken, Prisma } from '@prisma/client' +import { createHash, randomUUID } from 'node:crypto' +import { logger as baseLogger } from '@cpn-console/logger' +import { generateRandomPassword, isAtLeastTomorrow } from '@cpn-console/shared' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../prisma.js' + +const logger = baseLogger.child({ scope: 'resource:admin-token' }) + +export async function listTokens(query: typeof adminTokenContract.listAdminTokens.query._type) { + const where = { + status: { + in: ['active'] as $Enums.TokenStatus[], + }, + } as const satisfies Prisma.AdminTokenWhereInput + + if (query?.withRevoked) where.status.in.push('revoked') + + return prisma.adminToken.findMany({ + omit: { hash: true }, + include: { owner: true }, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + where, + }).then(tokens => + tokens.map(({ permissions, ...token }) => ({ permissions: permissions.toString(), ...token })), + ) +} + +export async function createToken(data: typeof adminTokenContract.createAdminToken.body._type) { + if (data.expirationDate && !isAtLeastTomorrow(new Date(data.expirationDate))) { + return new BadRequest400('Date d\'expiration trop courte') + } + logger.info({ tokenName: data.name, expirationDate: data.expirationDate ?? null }, 'Create admin token started') + const password = generateRandomPassword(48, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-') + const hash = createHash('sha256').update(password).digest('hex') + const botUserId = randomUUID() + await prisma.user.create({ + data: { + firstName: 'Bot Admin', + lastName: data.name, + type: 'bot', + id: botUserId, + email: `${botUserId}@bot.io`, + }, + }) + const token = await prisma.adminToken.create({ + data: { + ...data, + hash, + permissions: BigInt(data.permissions), + expirationDate: data.expirationDate ? new Date(data.expirationDate) : undefined, + userId: botUserId, + }, + omit: { hash: true }, + include: { owner: true }, + }) + logger.info({ adminTokenId: token.id, botUserId, expirationDate: token.expirationDate ?? null, status: token.status }, 'Create admin token completed') + return { + ...token, + password, + permissions: token.permissions.toString(), + } +} + +export async function deleteToken(id: AdminToken['id']) { + logger.info({ adminTokenId: id }, 'Revoke admin token started') + return prisma.adminToken.updateMany({ + where: { id }, + data: { + status: 'revoked', + expirationDate: new Date(Date.now()), + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/admin-token/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/admin-token/router.spec.ts new file mode 100644 index 0000000000..61efec35fc --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/admin-token/router.spec.ts @@ -0,0 +1,161 @@ +import type { ExposedAdminToken } from '@cpn-console/shared' +import type { AdminToken } from '@prisma/client' +import { ADMIN_PERMS, adminTokenContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListTokensMock = vi.spyOn(business, 'listTokens') +const businessCreateTokenMock = vi.spyOn(business, 'createToken') +const businessDeleteTokenMock = vi.spyOn(business, 'deleteToken') + +describe('test adminTokenContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listAdminTokens', () => { + it('should return list of admin tokens', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_ADMIN_TOKEN) + authUserMock.mockResolvedValueOnce(user) + + const tokens: AdminToken[] = [{ + id: faker.string.uuid(), + name: 'token1', + permissions: '2', + lastUse: (new Date()).toISOString(), + expirationDate: null, + status: 'active', + createdAt: (new Date(Date.now())).toISOString(), + }] + businessListTokensMock.mockResolvedValueOnce(tokens) + + const response = await app.inject() + .get(adminTokenContract.listAdminTokens.path) + .end() + + expect(businessListTokensMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(tokens) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(adminTokenContract.listAdminTokens.path) + .end() + + expect(businessListTokensMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createAdminToken', () => { + it('should create a token for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + const newToken = { + id: faker.string.uuid(), + name: 'test', + lastUse: null, + expirationDate: null, + password: faker.string.alpha({ casing: 'lower', length: 10 }), + permissions: '2', + createdAt: (new Date(Date.now())).toISOString(), + status: 'active', + } + const tokenData: ExposedAdminToken = { + name: newToken.name, + permissions: newToken.permissions, + expirationDate: null, + } + + authUserMock.mockResolvedValueOnce(user) + businessCreateTokenMock.mockResolvedValueOnce(newToken) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body(tokenData) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledWith(tokenData) + expect(response.json()).toEqual(newToken) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body({ + name: 'new-token', + expirationDate: null, + permissions: '4', + }) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + authUserMock.mockResolvedValueOnce(user) + businessCreateTokenMock.mockResolvedValueOnce(new BadRequest400('Invalid date')) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body({ + name: 'new-token', + expirationDate: null, + permissions: '4', + }) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(400) + }) + }) + + describe('deleteAdminToken', () => { + const tokenId = faker.string.uuid() + it('should delete a token for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + authUserMock.mockResolvedValueOnce(user) + businessDeleteTokenMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .delete(adminTokenContract.deleteAdminToken.path.replace(':tokenId', tokenId)) + .end() + + expect(businessDeleteTokenMock).toHaveBeenCalledWith(tokenId) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(adminTokenContract.deleteAdminToken.path.replace(':tokenId', tokenId)) + .end() + + expect(businessDeleteTokenMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/admin-token/router.ts b/.jjconflict-side-0/apps/server/src/resources/admin-token/router.ts new file mode 100644 index 0000000000..305b6e527a --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/admin-token/router.ts @@ -0,0 +1,46 @@ +import { AdminAuthorized, adminTokenContract } from '@cpn-console/shared' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { serverInstance } from '../../app.js' +import { createToken, deleteToken, listTokens } from './business.js' + +export function adminTokenRouter() { + return serverInstance.router(adminTokenContract, { + listAdminTokens: async ({ request: req, query }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListAdminToken(perms.adminPermissions)) return new Forbidden403() + const body = await listTokens(query) + + return { + status: 200, + body, + } + }, + + createAdminToken: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageAdminToken(perms.adminPermissions)) return new Forbidden403() + const body = await createToken(data) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + deleteAdminToken: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageAdminToken(perms.adminPermissions)) return new Forbidden403() + await deleteToken(params.tokenId) + + return { + status: 204, + body: null, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/cluster/business.spec.ts b/.jjconflict-side-0/apps/server/src/resources/cluster/business.spec.ts new file mode 100644 index 0000000000..c003e66ae1 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/cluster/business.spec.ts @@ -0,0 +1,171 @@ +import type { Cluster, Environment } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400, ErrorResType, NotFound404, Unprocessable422 } from '../../utils/errors.ts' +import { createCluster, deleteCluster, getClusterAssociatedEnvironments, getClusterDetails, getClusterUsage, listClusters, updateCluster } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const userId = faker.string.uuid() +const requestId = faker.string.uuid() +const cluster: Cluster = { + id: faker.string.uuid(), + infos: faker.lorem.lines(2), + privacy: 'public', + createdAt: new Date(), + updatedAt: new Date(), + zoneId: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + label: faker.string.alpha(10), + secretName: faker.string.alpha(10), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), +} +describe('test Cluster business logic', () => { + describe('listClusters', () => { + it('should filter for user', async () => { + prisma.cluster.findMany.mockResolvedValue([]) + await listClusters(userId) + expect(prisma.cluster.findMany).toHaveBeenCalledTimes(1) + expect(prisma.cluster.findMany).toHaveBeenCalledWith({ select: expect.any(Object), where: { OR: [{ privacy: 'public' }, expect.any(Object), expect.any(Object), expect.any(Object)] } }) + }) + it('should not filter', async () => { + const dbStages = [{ id: faker.string.uuid() }] + prisma.cluster.findMany.mockResolvedValue([{ stages: dbStages }] as unknown as Cluster[]) + const response = await listClusters() + expect(prisma.cluster.findMany).toHaveBeenCalledTimes(1) + expect(prisma.cluster.findMany).toHaveBeenCalledWith({ select: expect.any(Object), where: {} }) + expect(response[0].stageIds).toStrictEqual([dbStages[0].id]) + }) + }) + + describe('getClusterAssociatedEnvironments', () => { + it('should list all environments attached to a cluster', async () => { + const envName = faker.string.alpha(8) + const projectName = faker.string.alpha(8) + const ownerEmail = faker.internet.email() + const cpu = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const gpu = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const memory = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const envs = [{ name: envName, cpu, gpu, memory, project: { name: projectName, owner: { email: ownerEmail } } }] as unknown as Environment[] + prisma.environment.findMany.mockResolvedValue(envs) + const response = await getClusterAssociatedEnvironments(cluster.id) + expect(response).toStrictEqual([{ + name: envName, + project: projectName, + owner: ownerEmail, + cpu, + gpu, + memory, + }]) + }) + }) + + describe('getClusterDetails', () => { + it('should return a cluster details', async () => { + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + await getClusterDetails(cluster.id) + }) + it('should return a cluster details, without infos in db', async () => { + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, infos: null, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + const response = await getClusterDetails(cluster.id) + expect(response.infos).toBe('') + }) + }) + + describe('getClusterUsage', () => { + it('should return a cluster usage', async () => { + prisma.environment.aggregate.mockResolvedValue({ _count: {}, _avg: {}, _min: {}, _max: {}, _sum: { + cpu: 10, + gpu: 5, + memory: 20, + } }) + const response = await getClusterUsage(cluster.id) + expect(response).toStrictEqual({ + cpu: 10, + gpu: 5, + memory: 20, + }) + }) + }) + + describe('createCluster', () => { + it('should create cluster', async () => { + hook.cluster.upsert.mockResolvedValue({ failed: false }) + prisma.cluster.findUnique.mockResolvedValue(null) + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + prisma.cluster.create.mockResolvedValue(cluster) + + const response = await createCluster({ + infos: faker.string.alpha(10), + zoneId: faker.string.uuid(), + privacy: 'public', + stageIds: [], + clusterResources: false, + kubeconfig: { cluster: { tlsServerName: faker.internet.domainName() }, user: {} }, + label: faker.string.alpha(10), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + }, userId, requestId) + + expect(response).not.instanceOf(ErrorResType) + expect(prisma.cluster.create).toHaveBeenCalled() + }) + }) + + describe('updateCluster', () => { + it('should update cluster', async () => { + hook.cluster.upsert.mockResolvedValue({ failed: false }) + prisma.cluster.findUnique.mockResolvedValue(cluster) + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + prisma.cluster.update.mockResolvedValue(cluster) + + const response = await updateCluster({ + infos: faker.string.alpha(10), + zoneId: faker.string.uuid(), + privacy: 'public', + stageIds: [], + }, cluster.id, userId, requestId) + + expect(response).not.instanceOf(ErrorResType) + expect(prisma.cluster.update).toHaveBeenCalled() + }) + it('should return 404', async () => { + prisma.cluster.findUnique.mockResolvedValue(null) + const response = await updateCluster({ infos: faker.string.alpha(10) }, cluster.id, userId, requestId) + expect(response).instanceOf(NotFound404) + }) + }) + + describe('deleteCluster', () => { + it('should delete cluster', async () => { + hook.cluster.delete.mockResolvedValue({}) + await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(prisma.cluster.delete).toHaveBeenCalledTimes(1) + }) + it('should return failed hook', async () => { + hook.cluster.delete.mockResolvedValue({ failed: true }) + const response = await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(response).instanceOf(Unprocessable422) + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + }) + it('should not delete cluster, env attached', async () => { + prisma.environment.findFirst.mockResolvedValue({ id: faker.string.uuid() } as Environment) + const response = await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) +}) + +// findUniqueOrThrow diff --git a/.jjconflict-side-0/apps/server/src/resources/cluster/business.ts b/.jjconflict-side-0/apps/server/src/resources/cluster/business.ts new file mode 100644 index 0000000000..ef542ce33a --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/cluster/business.ts @@ -0,0 +1,230 @@ +import type { Cluster, clusterContract, ClusterDetails, Kubeconfig } from '@cpn-console/shared' +import type { Prisma, Project, User } from '@prisma/client' +import type { Resources } from '@/types/index.js' +import { ClusterDetailsSchema, ClusterPrivacy } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + addLogs, + createCluster as createClusterQuery, + deleteCluster as deleteClusterQuery, + getClusterById, + getClusterByLabel, + getClusterDetails as getClusterDetailsQuery, + getClusterEnvironments, + getProjectsByClusterId, + linkClusterToProjects, + linkZoneToClusters, + listClusters as listClustersQuery, + listStagesByClusterId, + removeClusterFromProject, + removeClusterFromStage, + updateCluster as updateClusterQuery, +} from '@/resources/queries-index.js' +import { linkClusterToStages } from '@/resources/stage/business.js' +import { validateSchema } from '@/utils/business.js' +import { BadRequest400, ErrorResType, NotFound404, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function listClusters(userId?: User['id']) { + const where: Prisma.ClusterWhereInput = userId + ? { + OR: [ + // Sélectionne tous les clusters publics + { privacy: 'public' }, + // Sélectionne les clusters associés aux projets dont l'user est membre + { + projects: { some: { members: { some: { userId } } } }, + }, + // Sélectionne les clusters associés aux projets dont l'user est owner + { + projects: { some: { ownerId: userId } }, + }, + // Sélectionne les clusters associés aux environnments appartenant à des projets dont l'user est membre + { + environments: { some: { project: { members: { some: { userId } } } } }, + }, + ], + } + : {} + const clusters = await listClustersQuery(where) + return clusters.map(({ stages, ...cluster }) => ({ + ...cluster, + stageIds: stages.map(({ id }) => id), + })) +} + +export async function getClusterAssociatedEnvironments(clusterId: string) { + const clusterEnvironments = await getClusterEnvironments(clusterId) + + return clusterEnvironments.map((environment) => { + return ({ + project: environment.project?.name, + name: environment.name, + owner: environment.project.owner.email, + cpu: environment.cpu, + gpu: environment.gpu, + memory: environment.memory, + }) + }) +} + +export async function getClusterDetails(clusterId: string): Promise { + const { infos, projects, stages, kubeconfig, ...details } = await getClusterDetailsQuery(clusterId) + return { + ...details, + infos: infos ?? '', + projectIds: projects.map(project => project.id), + stageIds: stages.map(({ id }) => id), + kubeconfig: { + cluster: kubeconfig.cluster as unknown as Kubeconfig['cluster'], + user: kubeconfig.user as unknown as Kubeconfig['user'], + }, + } +} + +export async function getClusterUsage(clusterId: string): Promise { + const clusterUsage = await prisma.environment.aggregate({ + _sum: { + memory: true, + cpu: true, + gpu: true, + }, + where: { + clusterId, + }, + }) + return { + cpu: clusterUsage._sum.cpu ?? 0, + gpu: clusterUsage._sum.gpu ?? 0, + memory: clusterUsage._sum.memory ?? 0, + } +} + +export async function createCluster(data: typeof clusterContract.createCluster.body._type, userId: User['id'], requestId: string) { + const isLabelTaken = await getClusterByLabel(data.label) + if (isLabelTaken) return new BadRequest400('Ce label existe déjà pour un autre cluster') + + data.projectIds = data.privacy === ClusterPrivacy.PUBLIC + ? [] + : data.projectIds ?? [] + + const { + projectIds, + stageIds, + kubeconfig, + zoneId, + ...clusterData + } = data + + const clusterCreated = await createClusterQuery(clusterData, kubeconfig, zoneId) + + if (data.privacy === ClusterPrivacy.DEDICATED && projectIds.length) { + await linkClusterToProjects(clusterCreated.id, projectIds) + } + + if (stageIds?.length) { + await linkClusterToStages(clusterCreated.id, stageIds) + } + + const hookReply = await hook.cluster.upsert(clusterCreated.id, zoneId) + await addLogs({ action: 'Create Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la création du cluster') + } + + return getClusterDetails(clusterCreated.id) +} + +export async function updateCluster(data: typeof clusterContract.updateCluster.body._type, clusterId: Cluster['id'], userId: User['id'], requestId: string): Promise { + if (data?.privacy === ClusterPrivacy.PUBLIC) delete data.projectIds + + const schemaValidation = ClusterDetailsSchema.partial().safeParse({ ...data, id: clusterId }) + const validateResult = validateSchema(schemaValidation) + if (validateResult instanceof ErrorResType) return validateResult + + const dbCluster = await getClusterById(clusterId) + if (!dbCluster) return new NotFound404() + + const { + projectIds, + stageIds, + kubeconfig, + zoneId, + ...clusterData + } = data + + const clusterUpdated = await updateClusterQuery(clusterId, clusterData, + // @ts-ignore + kubeconfig) + + // zone + if (zoneId) { + await linkZoneToClusters(zoneId, [clusterId]) + } + + // projects + const dbProjects = await getProjectsByClusterId(clusterId) + + let projectsToRemove: Project['id'][] = [] + + if (projectIds && clusterUpdated.privacy === ClusterPrivacy.DEDICATED) { + await linkClusterToProjects(clusterId, projectIds) + projectsToRemove = dbProjects?.map(project => project.id)?.filter(dbProjectId => !projectIds.includes(dbProjectId)) ?? [] + } else if (clusterUpdated.privacy === ClusterPrivacy.PUBLIC) { + projectsToRemove = dbProjects?.map(project => project.id) ?? [] + } + + for (const projectId of projectsToRemove) { + await removeClusterFromProject(clusterUpdated.id, projectId) + } + + // stages + if (stageIds) { + await linkClusterToStages(clusterId, stageIds) + + const dbStages = await listStagesByClusterId(clusterId) + if (dbStages) { + for (const stage of dbStages) { + if (!stageIds.includes(stage.id)) { + await removeClusterFromStage(clusterUpdated.id, stage.id) + } + } + } + } + + const hookReply = await hook.cluster.upsert(clusterId, dbCluster.zoneId) + await addLogs({ action: 'Update Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la mise à jour du cluster') + } + + return getClusterDetails(clusterId) +} + +interface DeleteClusterArgs { + clusterId: Cluster['id'] + userId?: User['id'] + requestId: string + force?: boolean +} +export async function deleteCluster({ clusterId, requestId, force, userId }: DeleteClusterArgs) { + let message: string | null = null + if (force) { + const envs = await prisma.environment.deleteMany({ + where: { clusterId }, + }) + message = `${envs.count} environnements supprimés de force, n'oubliez pas de reprovisionner les projets concernés` + } else { + const environment = await prisma.environment.findFirst({ where: { clusterId } }) + if (environment) return new BadRequest400('Impossible de supprimer le cluster, des environnements en activité y sont déployés') + } + + const hookReply = await hook.cluster.delete(clusterId) + await addLogs({ action: 'Delete Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la suppression du cluster') + } + + await deleteClusterQuery(clusterId) + return message +} diff --git a/.jjconflict-side-0/apps/server/src/resources/cluster/queries.ts b/.jjconflict-side-0/apps/server/src/resources/cluster/queries.ts new file mode 100644 index 0000000000..f66e1fc0bc --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/cluster/queries.ts @@ -0,0 +1,309 @@ +import type { Cluster, Environment, Kubeconfig, Prisma, Project, Stage } from '@prisma/client' +import prisma from '@/prisma.js' + +export async function getClustersAssociatedWithProject(projectId: Project['id']) { + const [ + clusterIdsHistory, + clusterIdsEnv, + ] = await Promise.all([ + prisma.projectClusterHistory.findMany({ + select: { + clusterId: true, + }, + where: { + projectId, + }, + }).then(history => history.map(({ clusterId }) => clusterId)), + prisma.cluster.findMany({ + where: { environments: { some: { project: { id: projectId } } } }, + select: { id: true }, + }).then(cluster => cluster.map(({ id }) => id)), + ]) + const clusterIds = [ + ...clusterIdsHistory, + ...clusterIdsEnv.filter(id => !clusterIdsHistory.includes(id)), + ] + return prisma.cluster.findMany({ + where: { id: { in: clusterIds } }, + select: { + id: true, + infos: true, + label: true, + privacy: true, + secretName: true, + kubeconfig: true, + clusterResources: true, + cpu: true, + gpu: true, + memory: true, + zone: { + select: { + id: true, + slug: true, + argocdUrl: true, + label: true, + }, + }, + }, + }) +} + +export async function updateProjectClusterHistory(projectId: Project['id'], clusterIds: Cluster['id'][]) { + return prisma.$transaction([ + prisma.projectClusterHistory.deleteMany({ + where: { + AND: { + projectId, + clusterId: { notIn: clusterIds }, + }, + }, + }), + prisma.projectClusterHistory.createMany({ + data: clusterIds.map(clusterId => ({ clusterId, projectId })), + skipDuplicates: true, + }), + ]) +} + +export function getClusterById(id: Cluster['id']) { + return prisma.cluster.findUnique({ + where: { id }, + include: { kubeconfig: true }, + }) +} + +export function getClusterByIdOrThrow(id: Cluster['id']) { + return prisma.cluster.findUniqueOrThrow({ + where: { id }, + include: { kubeconfig: true, zone: true }, + }) +} + +export function getClusterEnvironments(clusterId: Cluster['id']) { + return prisma.environment.findMany({ + where: { clusterId }, + select: { + name: true, + cpu: true, + gpu: true, + memory: true, + project: { + select: { + slug: true, + name: true, + owner: true, + members: true, + }, + }, + }, + }) +} + +export function getClusterDetails(id: Cluster['id']) { + return prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { + createdAt: true, + projects: { + select: { + id: true, + }, + }, + id: true, + clusterResources: true, + infos: true, + label: true, + privacy: true, + kubeconfig: true, + stages: true, + updatedAt: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export function getClustersByIds(clusterIds: Cluster['id'][]) { + return prisma.cluster.findMany({ + where: { + id: { in: clusterIds }, + }, + include: { kubeconfig: true }, + }) +} + +export function getPublicClusters() { + return prisma.cluster.findMany({ + where: { privacy: 'public' }, + include: { zone: true }, + }) +} + +export async function getClusterNamesByZoneId(zoneId: string) { + const clusterNames = await prisma.cluster.findMany({ + where: { zoneId }, + select: { + label: true, + }, + }) + return clusterNames.map(({ label }) => label) +} + +export function getClusterByLabel(label: Cluster['label']) { + return prisma.cluster.findUnique({ where: { label } }) +} + +export function getClusterByEnvironmentId(id: Environment['id']) { + return prisma.cluster.findMany({ + where: { + environments: { + some: { id }, + }, + }, + include: { kubeconfig: true }, + }) +} + +export function getClustersWithProjectIdAndConfig() { + return prisma.cluster.findMany({ + select: { + id: true, + stages: true, + projects: { + where: { + status: { not: 'archived' }, + }, + select: { + id: true, + name: true, + slug: true, + status: true, + }, + }, + clusterResources: true, + label: true, + infos: true, + privacy: true, + secretName: true, + kubeconfig: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export function listClusters(where: Prisma.ClusterWhereInput) { + return prisma.cluster.findMany({ + where, + select: { + id: true, + label: true, + stages: true, + clusterResources: true, + privacy: true, + infos: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export async function getProjectsByClusterId(id: Cluster['id']) { + return (await prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { projects: true }, + }))?.projects +} + +export async function listStagesByClusterId(id: Cluster['id']) { + return (await prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { stages: true }, + }))?.stages +} + +export function createCluster(data: Omit, kubeconfig: Pick, zoneId: string) { + return prisma.cluster.create({ + data: { + ...data, + // @ts-ignore + kubeconfig: { create: kubeconfig }, + zone: { + connect: { id: zoneId }, + }, + }, + }) +} + +export function updateCluster(id: Cluster['id'], data: Partial>, kubeconfig: Pick) { + return prisma.cluster.update({ + where: { id }, + data: { + ...data, + kubeconfig: { + // @ts-ignore + update: kubeconfig, + }, + }, + }) +} + +export function linkClusterToProjects(id: Cluster['id'], projectIds: Project['id'][]) { + return prisma.cluster.update({ + where: { id }, + data: { + projects: { + connect: projectIds.map(projectId => ({ id: projectId })), + }, + }, + }) +} + +export function linkClusterToStages(id: Cluster['id'], stageIds: Stage['id'][]) { + return prisma.cluster.update({ + where: { id }, + data: { + stages: { + connect: stageIds.map(stageId => ({ id: stageId })), + }, + }, + }) +} + +export function removeClusterFromProject(id: Cluster['id'], projectId: Project['id']) { + return prisma.cluster.update({ + where: { id }, + data: { + projects: { + disconnect: { + id: projectId, + }, + }, + }, + }) +} + +export function removeClusterFromStage(id: Cluster['id'], stageId: Stage['id']) { + return prisma.cluster.update({ + where: { id }, + data: { + stages: { + disconnect: { + id: stageId, + }, + }, + }, + }) +} + +export function deleteCluster(id: Cluster['id']) { + return prisma.cluster.delete({ + where: { id }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/cluster/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/cluster/router.spec.ts new file mode 100644 index 0000000000..528858b052 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/cluster/router.spec.ts @@ -0,0 +1,308 @@ +import type { ClusterDetails, Environment } from '@cpn-console/shared' +import { ADMIN_PERMS, clusterContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listClusters') +const businessGetDetailsMock = vi.spyOn(business, 'getClusterDetails') +const businessGetUsageMock = vi.spyOn(business, 'getClusterUsage') +const businessGetEnvironmentsMock = vi.spyOn(business, 'getClusterAssociatedEnvironments') +const businessCreateMock = vi.spyOn(business, 'createCluster') +const businessUpdateMock = vi.spyOn(business, 'updateCluster') +const businessDeleteMock = vi.spyOn(business, 'deleteCluster') + +describe('test clusterContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listClusters', () => { + it('as non admin', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + businessListMock.mockResolvedValueOnce([]) + const response = await app.inject() + .get(clusterContract.listClusters.path) + .end() + + expect(businessListMock).toHaveBeenCalledWith(user.user.id) + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('as admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + + authUserMock.mockResolvedValueOnce(user) + + businessListMock.mockResolvedValueOnce([]) + const response = await app.inject() + .get(clusterContract.listClusters.path) + .end() + + expect(businessListMock).toHaveBeenCalledWith() + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getClusterDetails', () => { + it('should return cluster details', async () => { + const cluster: ClusterDetails = { + id: faker.string.uuid(), + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetDetailsMock.mockResolvedValueOnce(cluster) + const response = await app.inject() + .get(clusterContract.getClusterDetails.path.replace(':clusterId', cluster.id)) + .end() + + expect(businessGetDetailsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(cluster) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterDetails.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetDetailsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getClusterUsage', () => { + it('should return cluster usage', async () => { + const resources = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetUsageMock.mockResolvedValueOnce(resources) + const response = await app.inject() + .get(clusterContract.getClusterUsage.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetUsageMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(resources) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterUsage.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetUsageMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getClusterEnvironments', () => { + it('should return cluster environments', async () => { + const envs: Environment[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(envs) + const response = await app.inject() + .get(clusterContract.getClusterEnvironments.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterEnvironments.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createCluster', () => { + const cluster: ClusterDetails = { + id: faker.string.uuid(), + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + + it('should return created cluster', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(cluster) + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.json()).toEqual(cluster) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateCluster', () => { + const clusterId = faker.string.uuid() + const cluster: Omit = { + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + + it('should return created cluster', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: clusterId, ...cluster }) + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.json()).toEqual({ id: clusterId, ...cluster }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteCluster', () => { + it('should return empty when delete', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/cluster/router.ts b/.jjconflict-side-0/apps/server/src/resources/cluster/router.ts new file mode 100644 index 0000000000..3924bce6a9 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/cluster/router.ts @@ -0,0 +1,131 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, clusterContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + createCluster, + deleteCluster, + getClusterAssociatedEnvironments, + getClusterDetails as getClusterDetailsBusiness, + getClusterUsage, + listClusters, + updateCluster, +} from './business.js' +import '@/types/index.js' + +export function clusterRouter() { + return serverInstance.router(clusterContract, { + listClusters: async ({ request: req }) => { + const { adminPermissions, user } = await authUser(req) + + let body: AsyncReturnType = [] + if (AdminAuthorized.ListClusters(adminPermissions)) { + body = await listClusters() + } else if (user) { + body = await listClusters(user.id) + } + + return { + status: 200, + body, + } + }, + + getClusterDetails: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const cluster = await getClusterDetailsBusiness(clusterId) + + return { + status: 200, + body: cluster, + } + }, + + getClusterUsage: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const usage = await getClusterUsage(clusterId) + + return { + status: 200, + body: usage, + } + }, + + createCluster: async ({ request: req, body: data }) => { + const { adminPermissions, user } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + + if (!user) return new Unauthorized401('Require to be requested from user not api key') + const body = await createCluster(data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + getClusterEnvironments: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const environments = await getClusterAssociatedEnvironments(clusterId) + + return { + status: 200, + body: environments, + } + }, + + updateCluster: async ({ request: req, params, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const clusterId = params.clusterId + const body = await updateCluster(data, clusterId, user.id, req.id) + + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + deleteCluster: async ({ request: req, params, query: { force } }) => { + const { user, adminPermissions, tokenId } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + if (!user?.id && !tokenId) return new Unauthorized401('Your identity has not been found') + + const clusterId = params.clusterId + const body = await deleteCluster({ + clusterId, + userId: user?.id, + requestId: req.id, + force, + }) + + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/environment/business.spec.ts b/.jjconflict-side-0/apps/server/src/resources/environment/business.spec.ts new file mode 100644 index 0000000000..7bfebb5b2e --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/environment/business.spec.ts @@ -0,0 +1,353 @@ +import type { Cluster, Environment, Project, ProjectMembers, ProjectRole, Stage, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { Result } from '../../utils/business.js' +import { checkClusterResources, checkProjectResources, createEnvironment, deleteEnvironment, getProjectEnvironments, updateEnvironment } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const user: User = { + id: faker.string.uuid(), + createdAt: new Date(), + updatedAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + lastLogin: null, +} +const project: Project & { + clusters: Pick[] + members: ProjectMembers[] + roles: ProjectRole[] + owner: User +} = { + createdAt: new Date(), + updatedAt: new Date(), + description: '', + everyonePerms: 649n, + id: faker.string.uuid(), + locked: false, + name: faker.string.alphanumeric(8), + status: 'created', + ownerId: faker.string.uuid(), + owner: user, + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + clusters: [], + roles: [], + members: [], + slug: faker.string.alphanumeric(8), + lastSuccessProvisionningVersion: faker.string.numeric(), +} + +describe('test environment business', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getProjectEnvironments', () => { + it('should query environment for projectId', async () => { + prisma.environment.findMany.mockResolvedValue([]) + const projectId = faker.string.uuid() + await getProjectEnvironments(projectId) + + expect(prisma.environment.findMany).toHaveBeenCalledTimes(1) + }) + }) + + describe('createEnvironment', () => { + const clusterId = faker.string.uuid() + const stageId = faker.string.uuid() + const env = { name: 'new-env' } + it('should create environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const stageId = faker.string.uuid() + + prisma.environment.create.mockResolvedValue({ clusterId } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await createEnvironment({ + userId: user.id, + projectId: project.id, + name: env.name, + cpu: 0.1, + gpu: 0.5, + memory: 2.0, + clusterId, + stageId, + requestId, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should create environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + + prisma.environment.create.mockResolvedValue({ clusterId } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await createEnvironment({ + userId: user.id, + projectId: project.id, + name: env.name, + cpu: 0.1, + gpu: 0.5, + memory: 2.0, + clusterId, + stageId, + requestId, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('updateEnvironment', () => { + it('should update environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.update.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await updateEnvironment({ + user, + environmentId, + requestId, + cpu: 2.0, + gpu: 4.0, + memory: 12.5, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should update environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.update.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await updateEnvironment({ + user, + environmentId, + requestId, + cpu: 2.0, + gpu: 4.0, + memory: 12.5, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('deleteEnvironment', () => { + it('should delete environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.delete.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await deleteEnvironment({ environmentId, userId: user.id, projectId: project.id, requestId }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.delete).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should delete environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.delete.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await deleteEnvironment({ environmentId, userId: user.id, projectId: project.id, requestId }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.delete).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('checkClusterResources', () => { + it('should authorize cluster not yet configured', async () => { + const cluster: Cluster = { + cpu: 0, + gpu: 0, + memory: 0, + } as Cluster + const result = await checkClusterResources({ cpu: 1, gpu: 0, memory: 1 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should authorize cluster not yet used', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 0, + gpu: 0, + memory: 0, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 7 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should authorize cluster used but not full', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 2, + gpu: 0, + memory: 2, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 6 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should refuse cluster without enough space', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 5, + gpu: 0, + memory: 5, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 6 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le cluster ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + it('should refuse cluster without GPU', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 2, + gpu: 0, + memory: 2, + }, + } as any) + const result = await checkClusterResources({ cpu: 2, gpu: 1, memory: 2 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le cluster ne dispose pas de suffisamment de ressources : GPU.') + }) + }) + + describe('checkProjectResources', () => { + const prodStage: Stage = { + id: faker.string.uuid(), + name: 'prod', + } + const hprodStage: Stage = { + id: faker.string.uuid(), + name: 'hprod', + } + it('should authorize prod deployment for project with hprod resource but no prod resources', async () => { + const project: Project = { + hprodCpu: 10, + hprodGpu: 10, + hprodMemory: 10, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + } as Project + prisma.stage.findUnique.mockResolvedValue(prodStage) + prisma.stage.findMany.mockResolvedValue([prodStage]) + const result = await checkProjectResources({ cpu: 1, gpu: 0, memory: 1, stageId: prodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should refuse hprod deployment for project with hprod resource but no prod resources', async () => { + const project: Project = { + hprodCpu: 10, + hprodGpu: 10, + hprodMemory: 10, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + } as Project + prisma.stage.findUnique.mockResolvedValue(hprodStage) + prisma.stage.findMany.mockResolvedValue([prodStage] as Stage[]) + prisma.environment.aggregate.mockResolvedValue({ + _sum: { cpu: 0, gpu: 0, memory: 0 }, + } as any) + const result = await checkProjectResources({ cpu: 20, gpu: 20, memory: 20, stageId: hprodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le projet ne dispose pas de suffisamment de ressources : CPU, GPU, Mémoire.') + }) + it('should refuse overloading hprod deployment', async () => { + const project: Project = { + hprodCpu: 20, + hprodGpu: 20, + hprodMemory: 20, + prodCpu: 10, + prodGpu: 10, + prodMemory: 10, + } as Project + prisma.stage.findUnique.mockResolvedValue(hprodStage) + prisma.stage.findMany.mockResolvedValue([prodStage] as Stage[]) + prisma.environment.aggregate.mockResolvedValue({ + _sum: { cpu: 15, gpu: 15, memory: 15 }, + } as any) + const result = await checkProjectResources({ cpu: 5, gpu: 6, memory: 5, stageId: hprodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le projet ne dispose pas de suffisamment de ressources : GPU.') + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/environment/business.ts b/.jjconflict-side-0/apps/server/src/resources/environment/business.ts new file mode 100644 index 0000000000..2f068dedf8 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/environment/business.ts @@ -0,0 +1,306 @@ +import type { Cluster, Environment, Project, Stage, User } from '@prisma/client' +import type { Resources, UserDetails } from '@/types/index.js' +import prisma from '@/prisma.js' +import { + addLogs, + deleteEnvironment as deleteEnvironmentQuery, + getEnvironmentsByProjectId, + initializeEnvironment, + updateEnvironment as updateEnvironmentQuery, +} from '@/resources/queries-index.js' +import { Result } from '@/utils/business.js' +import { hook } from '@/utils/hook-wrapper.js' + +export function getProjectEnvironments(projectId: Project['id']) { + return getEnvironmentsByProjectId(projectId) +} + +// Routes logic +interface CreateEnvironmentParam { + userId: User['id'] + projectId: Project['id'] + name: Environment['name'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + clusterId: Environment['clusterId'] + stageId: Stage['id'] + requestId: string +} + +interface CreateEnvironmentResult { + id: Environment['id'] + createdAt: Date + updatedAt: Date + projectId: Project['id'] + name: Environment['name'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + clusterId: Environment['clusterId'] + stageId: Stage['id'] +} + +export async function createEnvironment({ + userId, + projectId, + name, + cpu, + gpu, + memory, + autosync, + clusterId, + stageId, + requestId, +}: CreateEnvironmentParam): Promise> { + const environment = await initializeEnvironment({ projectId, name, cpu, gpu, memory, autosync, clusterId, stageId }) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Create Environment', data: results, userId, requestId, projectId }) + if (results.failed) { + return Result.fail('Echec des services à la création de l\'environnement') + } + + return Result.succeed({ + ...environment, + stageId, + }) +} + +interface UpdateEnvironmentParam { + user: UserDetails + environmentId: Environment['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + requestId: string +} + +export async function updateEnvironment({ + user, + environmentId, + requestId, + cpu, + gpu, + memory, + autosync, +}: UpdateEnvironmentParam) { + const env = await updateEnvironmentQuery({ + id: environmentId, + cpu, + gpu, + memory, + autosync, + }) + const { results } = await hook.project.upsert(env.projectId) + await addLogs({ action: 'Update Environment', data: results, userId: user.id, requestId, projectId: env.projectId }) + if (results.failed) { + return Result.fail('Echec des services à la mise à jour de l\'environnement') + } + + return Result.succeed(env) +} + +interface DeleteEnvironmentParam { + userId?: User['id'] + environmentId: Environment['id'] + projectId: Project['id'] + requestId: string +} + +export async function deleteEnvironment({ + userId, + environmentId, + projectId, + requestId, +}: DeleteEnvironmentParam) { + const env = await deleteEnvironmentQuery(environmentId) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Delete Environment', data: results, userId, requestId, projectId: env.projectId }) + if (results.failed) { + return Result.fail('Echec des services à la suppression de l\'environnement') + } + return Result.succeed(null) +} + +export async function checkEnvironmentCreate(input: { + clusterId: Cluster['id'] + projectId: Project['id'] + name: Environment['name'] + stageId: Stage['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}): Promise> { + const errorMessages: string[] = [] + const [stage, sameNameEnvironment, cluster] = await Promise.all([ + input.stageId + ? prisma.stage.findUnique({ where: { id: input.stageId } }) + : undefined, + input.name + ? prisma.environment.findUnique({ where: { projectId_name: { projectId: input.projectId, name: input.name } } }) + : undefined, + input.clusterId + ? prisma.cluster.findFirst({ + where: { + OR: [{ // un cluster public + id: input.clusterId, + privacy: 'public', + }, { + id: input.clusterId, // un cluster dédié rattaché au projet + privacy: 'dedicated', + projects: { some: { id: input.projectId } }, + }], + }, + }) + : undefined, + ]) + if (sameNameEnvironment) errorMessages.push('Ce nom d\'environnement est déjà pris.') + if (!stage) errorMessages.push('Type d\'environnment invalide.') + if (!cluster) { + errorMessages.push('Cluster invalide.') + } else { + const resourceCheckResult = await checkClusterResources(input, cluster) + if (resourceCheckResult.isError) { + errorMessages.push(resourceCheckResult.error) + } + const project = await prisma.project.findUniqueOrThrow({ where: { id: input.projectId } }) + const projectCheckResult = await checkProjectResources(input, project) + if (projectCheckResult.isError) { + errorMessages.push(projectCheckResult.error) + } + } + if (errorMessages.length > 0) { + return Result.fail(errorMessages.join('\n')) + } + return Result.succeed(true) +} + +export async function checkClusterResources(input: { + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}, cluster: Cluster): Promise> { + if (cluster.cpu === 0 && cluster.memory === 0) { + // Unconfigured cluster + return Result.succeed(true) + } + const unsufficientResource = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: cluster.cpu, gpu: cluster.gpu, memory: cluster.memory }, + where: { + cluster: { + id: cluster.id, + }, + }, + }) + if (unsufficientResource.length > 0) { + return Result.fail(`Le cluster ne dispose pas de suffisamment de ressources : ${unsufficientResource.join(', ')}.`) + } + return Result.succeed(true) +} + +export async function checkProjectResources(input: { + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + stageId: Environment['stageId'] +}, project: Project): Promise> { + if (project.limitless) { + // No limits + return Result.succeed(true) + } + const stage = await prisma.stage.findUnique({ where: { id: input.stageId } }) + const prodStages = await prisma.stage.findMany({ select: { id: true }, where: { name: 'prod' } }) + let overflowResources: string[] + if (stage?.name === 'prod') { + overflowResources = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: project.prodCpu, gpu: project.prodGpu, memory: project.prodMemory }, + where: { + projectId: project.id, + stageId: { + in: prodStages.map(s => s.id), + }, + }, + }) + } else { // hprod + overflowResources = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: project.hprodCpu, gpu: project.hprodGpu, memory: project.hprodMemory }, + where: { + projectId: project.id, + stageId: { + notIn: prodStages.map(s => s.id), + }, + }, + }) + } + if (overflowResources.length > 0) { + return Result.fail(`Le projet ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.`) + } + return Result.succeed(true) +} + +export async function checkEnvironmentUpdate(input: { + environmentId: Environment['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}): Promise> { + const environment = await prisma.environment.findUniqueOrThrow({ + select: { cluster: true, projectId: true, stageId: true }, + where: { id: input.environmentId }, + }) + const cluster = await prisma.cluster.findUniqueOrThrow({ + where: { id: environment.cluster.id }, + }) + const errorMessages: string[] = [] + const resourceCheckResult = await checkClusterResources(input, cluster) + if (resourceCheckResult.isError) { + errorMessages.push(resourceCheckResult.error) + } + const project = await prisma.project.findUniqueOrThrow({ where: { id: environment.projectId } }) + const projectCheckResult = await checkProjectResources({ stageId: environment.stageId, ...input }, project) + if (projectCheckResult.isError) { + errorMessages.push(projectCheckResult.error) + } + if (errorMessages.length > 0) { + return Result.fail(errorMessages.join('\n')) + } + return Result.succeed(true) +} + +export async function getOverflowResources({ request, limit, where }: { + request: Resources + limit: Resources + where: any +}): Promise { + if (limit.cpu === 0 && limit.memory === 0) { + // Unconfigured project prod resources + return [] + } + const environmentResources = await prisma.environment.aggregate({ + _sum: { + memory: true, + cpu: true, + gpu: true, + }, + where, + }) + const unsufficientResource: string[] = [] + if ((environmentResources._sum.cpu ?? 0) + request.cpu > limit.cpu) { + unsufficientResource.push('CPU') + } + if ((environmentResources._sum.gpu ?? 0) + request.gpu > limit.gpu) { + unsufficientResource.push('GPU') + } + if ((environmentResources._sum.memory ?? 0) + request.memory > limit.memory) { + unsufficientResource.push('Mémoire') + } + return unsufficientResource +} diff --git a/.jjconflict-side-0/apps/server/src/resources/environment/queries.ts b/.jjconflict-side-0/apps/server/src/resources/environment/queries.ts new file mode 100644 index 0000000000..6b228e2687 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/environment/queries.ts @@ -0,0 +1,99 @@ +import type { Environment, Prisma, Project } from '@prisma/client' +import prisma from '@/prisma.js' + +// SELECT +export function getEnvironmentByIdOrThrow(id: Environment['id']) { + return prisma.environment.findUniqueOrThrow({ where: { id }, include: { stage: true } }) +} + +export function getEnvironmentInfos(id: Environment['id']) { + return prisma.environment.findUniqueOrThrow({ + where: { id }, + include: { + project: { + select: { + owner: true, + name: true, + id: true, + status: true, + repositories: { + where: { isInfra: true }, + }, + locked: true, + clusters: { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + }, + }, + }, + }, + stage: true, + }, + }) +} + +export async function getEnvironmentsByProjectId(projectId: Project['id']) { + return prisma.environment.findMany({ + where: { projectId }, + include: { + stage: true, + }, + }) +} + +export function getEnvironmentByIdWithCluster(id: Environment['id']) { + return prisma.environment.findUnique({ + where: { id }, + include: { + cluster: { + include: { kubeconfig: true }, + }, + }, + }) +} + +// INSERT +export function initializeEnvironment(data: Prisma.EnvironmentUncheckedCreateInput) { + return prisma.environment.create({ + data, + include: { + project: { + include: { + repositories: { + where: { isInfra: true }, + }, + }, + }, + }, + }) +} + +export function updateEnvironment({ id, cpu, gpu, memory, autosync }: { id: Environment['id'], cpu: Environment['cpu'], gpu: Environment['gpu'], memory: Environment['memory'], autosync: Environment['autosync'] }) { + return prisma.environment.update({ + where: { + id, + }, + data: { + cpu, + gpu, + memory, + autosync, + }, + }) +} + +// DELETE +export function deleteEnvironment(id: Environment['id']) { + return prisma.environment.delete({ + where: { id }, + }) +} + +export function deleteAllEnvironmentForProject(id: Project['id']) { + return prisma.environment.deleteMany({ + where: { projectId: id }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/environment/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/environment/router.spec.ts new file mode 100644 index 0000000000..2fbff26774 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/environment/router.spec.ts @@ -0,0 +1,340 @@ +import type { Environment } from '@cpn-console/shared' +import { environmentContract, PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { atDates, getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetProjectEnvironmentsMock = vi.spyOn(business, 'getProjectEnvironments') +const businessCreateEnvironmentMock = vi.spyOn(business, 'createEnvironment') +const businessUpdateEnvironmentMock = vi.spyOn(business, 'updateEnvironment') +const businessDeleteEnvironmentMock = vi.spyOn(business, 'deleteEnvironment') +const businessCheckEnvironmentCreateMock = vi.spyOn(business, 'checkEnvironmentCreate') +const businessCheckEnvironmentUpdateMock = vi.spyOn(business, 'checkEnvironmentUpdate') + +describe('environmentRouter tests', () => { + let projectId: string + let environmentId: string + let environmentData: Omit + + beforeEach(() => { + vi.resetAllMocks() + projectId = faker.string.uuid() + environmentId = faker.string.uuid() + environmentData = { + projectId, + name: 'envname', + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + } + }) + + describe('listEnvironments', () => { + it('should return environments for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetProjectEnvironmentsMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(environmentContract.listEnvironments.path) + .query({ projectId }) + .end() + + expect(businessGetProjectEnvironmentsMock).toHaveBeenCalledWith(projectId) + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for non member of projectId query ', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(environmentContract.listEnvironments.path) + .query({ projectId }) + .end() + + expect(businessGetProjectEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createEnvironment', () => { + it('should create environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ success: true }) + businessCreateEnvironmentMock.mockResolvedValueOnce({ + success: true, + data: { id: environmentId, ...environmentData, ...atDates }, + }) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.json()).toMatchObject({ id: environmentId, ...environmentData }) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ success: true, message: 'pas d erreur' }) + businessCreateEnvironmentMock.mockResolvedValueOnce({ isError: true, message: 'une erreur' }) + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(500) + }) + it('should pass invalid reason error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ isError: true, message: 'une erreur' }) + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateEnvironment', () => { + let updateData: { cpu: number, gpu: number, memory: number } + beforeEach(() => { + updateData = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + } + }) + + it('should update environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentUpdateMock.mockResolvedValueOnce({ success: true, value: true }) + businessUpdateEnvironmentMock.mockResolvedValueOnce({ success: true, data: { id: environmentId, ...environmentData, ...atDates } }) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.json()).toMatchObject({ id: environmentId, ...environmentData }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateEnvironmentMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(500) + }) + + it('should pass invalid reason error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentUpdateMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('deleteEnvironment', () => { + it('should delete environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteEnvironmentMock.mockResolvedValueOnce({ success: true }) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteEnvironmentMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(500) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/environment/router.ts b/.jjconflict-side-0/apps/server/src/resources/environment/router.ts new file mode 100644 index 0000000000..7ef91c05ff --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/environment/router.ts @@ -0,0 +1,107 @@ +import { environmentContract, ProjectAuthorized } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { BadRequest400, Forbidden403, Internal500, Unauthorized401 } from '@/utils/errors.js' +import { checkEnvironmentCreate, checkEnvironmentUpdate, createEnvironment, deleteEnvironment, getProjectEnvironments, updateEnvironment } from './business.js' + +export function environmentRouter() { + return serverInstance.router(environmentContract, { + listEnvironments: async ({ request: req, query }) => { + const projectId = query.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListEnvironments(perms)) return new Forbidden403() + const body = await getProjectEnvironments(projectId) + + return { + status: 200, + body, + } + }, + + createEnvironment: async ({ request: req, body: requestBody }) => { + const projectId = requestBody.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const checkCreateResult = await checkEnvironmentCreate({ ...requestBody }) + if (checkCreateResult.isError) return new BadRequest400(checkCreateResult.error) + + const result = await createEnvironment({ + userId: perms.user.id, + projectId, + name: requestBody.name, + clusterId: requestBody.clusterId, + cpu: requestBody.cpu, + gpu: requestBody.gpu, + memory: requestBody.memory, + autosync: requestBody.autosync, + stageId: requestBody.stageId, + requestId: req.id, + }) + if (result.isError) { + return new Internal500(result.error) + } + return { + status: 201, + body: result.data, + } + }, + + updateEnvironment: async ({ request: req, body: requestBody, params }) => { + const { environmentId } = params + const perms = await authUser(req, { environmentId }) + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const checkUpdateResult = await checkEnvironmentUpdate({ environmentId, ...requestBody }) + if (checkUpdateResult.isError) return new BadRequest400(checkUpdateResult.error) + + const result = await updateEnvironment({ + user: perms.user, + environmentId, + cpu: requestBody.cpu, + gpu: requestBody.gpu, + memory: requestBody.memory, + autosync: requestBody.autosync, + requestId: req.id, + }) + if (result.isError) { + return new Internal500(result.error) + } + return { + status: 200, + body: result.data, + } + }, + + deleteEnvironment: async ({ request: req, params }) => { + const { environmentId } = params + const perms = await authUser(req, { environmentId }) + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const result = await deleteEnvironment({ + userId: perms.user?.id, + environmentId, + requestId: req.id, + projectId: perms.projectId, + }) + if (result.isError) { + return new Internal500(result.error) + } + + return { + status: 204, + body: result.data, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/index.ts b/.jjconflict-side-0/apps/server/src/resources/index.ts new file mode 100644 index 0000000000..93a285f9ac --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/index.ts @@ -0,0 +1,49 @@ +import type { FastifyInstance } from 'fastify' +import { serverInstance } from '@/app.js' + +import { adminRoleRouter } from './admin-role/router.js' +import { adminTokenRouter } from './admin-token/router.js' +import { clusterRouter } from './cluster/router.js' +import { environmentRouter } from './environment/router.js' +import { logRouter } from './log/router.js' +import { projectMemberRouter } from './project-member/router.js' +import { projectRoleRouter } from './project-role/router.js' +import { projectServiceRouter } from './project-service/router.js' +import { projectRouter } from './project/router.js' +import { repositoryRouter } from './repository/router.js' +import { serviceChainRouter } from './service-chain/router.js' +import { serviceMonitorRouter } from './service-monitor/router.js' +import { stageRouter } from './stage/router.js' +import { pluginConfigRouter } from './system/config/router.js' +import { systemRouter } from './system/router.js' +import { systemSettingsRouter } from './system/settings/router.js' +import { userRouter } from './user/router.js' +import { personalAccessTokenRouter } from './user/tokens/router.js' +import { zoneRouter } from './zone/router.js' + +// relax validation schema if NO_VALIDATION env var is set to true. +// /!\ It can lead to security leaks !!!! +const validateTrue = { responseValidation: process.env.NO_VALIDATION !== 'true' } +export function apiRouter() { + return async (app: FastifyInstance) => { + await app.register(serverInstance.plugin(adminRoleRouter()), validateTrue) + await app.register(serverInstance.plugin(adminTokenRouter()), validateTrue) + await app.register(serverInstance.plugin(clusterRouter()), validateTrue) + await app.register(serverInstance.plugin(serviceChainRouter()), validateTrue) + await app.register(serverInstance.plugin(environmentRouter()), validateTrue) + await app.register(serverInstance.plugin(logRouter()), validateTrue) + await app.register(serverInstance.plugin(personalAccessTokenRouter()), validateTrue) + await app.register(serverInstance.plugin(projectRouter()), validateTrue) + await app.register(serverInstance.plugin(projectMemberRouter()), validateTrue) + await app.register(serverInstance.plugin(projectRoleRouter()), validateTrue) + await app.register(serverInstance.plugin(projectServiceRouter()), validateTrue) + await app.register(serverInstance.plugin(repositoryRouter()), validateTrue) + await app.register(serverInstance.plugin(serviceMonitorRouter()), validateTrue) + await app.register(serverInstance.plugin(pluginConfigRouter()), validateTrue) + await app.register(serverInstance.plugin(stageRouter()), validateTrue) + await app.register(serverInstance.plugin(systemRouter()), validateTrue) + await app.register(serverInstance.plugin(systemSettingsRouter()), validateTrue) + await app.register(serverInstance.plugin(userRouter()), validateTrue) + await app.register(serverInstance.plugin(zoneRouter()), validateTrue) + } +} diff --git a/.jjconflict-side-0/apps/server/src/resources/log/business.spec.ts b/.jjconflict-side-0/apps/server/src/resources/log/business.spec.ts new file mode 100644 index 0000000000..8a4d080e48 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/log/business.spec.ts @@ -0,0 +1,42 @@ +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { getLogs } from './business.ts' + +describe('test log business', () => { + it('should map filter (clean logs)', async () => { + const dbLogs = [{ + data: { args: {} }, + createdAt: new Date(), + updatedAt: new Date(), + userId: null, + action: 'Action', + id: faker.string.uuid(), + }] + const query = { limit: 10, offset: 10, clean: true, projectId: undefined } + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + const [_total, logs] = await getLogs(query) + + expect(logs[0]).not.haveOwnProperty('requestId') + expect(logs[0].data).not.haveOwnProperty('results') + expect(logs[0].data).not.haveOwnProperty('args') + expect(logs[0].data).not.haveOwnProperty('config') + }) + + it('should not filter (admin logs)', async () => { + const dbLogs = [{ + data: { args: {} }, + createdAt: new Date(), + updatedAt: new Date(), + userId: null, + action: 'Action', + id: faker.string.uuid(), + }] + const query = { limit: 10, offset: 10, clean: false, projectId: undefined } + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + const [_total, logs] = await getLogs(query) + + expect(logs[0].data).haveOwnProperty('args') + expect(logs[0].data).not.haveOwnProperty('config') + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/log/business.ts b/.jjconflict-side-0/apps/server/src/resources/log/business.ts new file mode 100644 index 0000000000..9a0182e7e1 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/log/business.ts @@ -0,0 +1,13 @@ +import type { logContract } from '@cpn-console/shared' +import { CleanLogSchema } from '@cpn-console/shared' +import { getAllLogs } from '@/resources/queries-index.js' + +export async function getLogs({ offset, limit, projectId, clean }: typeof logContract.getLogs.query._type) { + const [total, logs] = await getAllLogs({ skip: offset, take: limit, where: { projectId } }) + return [ + total, + clean + ? logs.map(log => CleanLogSchema.parse(log)) + : logs, + ] +} diff --git a/.jjconflict-side-0/apps/server/src/resources/log/queries.ts b/.jjconflict-side-0/apps/server/src/resources/log/queries.ts new file mode 100644 index 0000000000..3851a8f13f --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/log/queries.ts @@ -0,0 +1,57 @@ +import type { Log, Prisma, Project, User } from '@prisma/client' +import { exclude } from '@cpn-console/shared' +import prisma from '@/prisma.js' + +// SELECT +export function getAllLogsForUser(user: User, offset = 0) { + return prisma.log.findMany({ + where: { userId: user.id }, + take: 100, + skip: offset, + }) +} + +export function getAllLogs({ skip = 0, take = 5, where }: Prisma.LogFindManyArgs) { + return prisma.$transaction([ + prisma.log.count({ where }), + prisma.log.findMany({ + orderBy: { + createdAt: 'desc', + }, + skip, + take, + where, + }), + ]) +} + +// CREATE +interface AddLogsArgs { + action: Log['action'] + data: Record + userId?: User['id'] | null + requestId: string + projectId?: Project['id'] +} +export function addLogs({ action, data, requestId, userId = null, projectId }: AddLogsArgs) { + return prisma.log.create({ + data: { + action, + userId, + data: exclude(data, ['cluster', 'user', 'newCreds', 'apis']), + requestId, + projectId, + }, + }) +} + +// TECH +export function _createLog(data: Parameters[0]['create']) { + return prisma.log.upsert({ + where: { + id: data.id, + }, + create: data, + update: data, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/log/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/log/router.spec.ts new file mode 100644 index 0000000000..911f69305d --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/log/router.spec.ts @@ -0,0 +1,93 @@ +import { ADMIN_PERMS, logContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetLogsMock = vi.spyOn(business, 'getLogs') + +describe('test logContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getLogs', () => { + it('should return logs for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const logs = [] + const total = 1 + + authUserMock.mockResolvedValueOnce(user) + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0 }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ total, logs }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin, no projectId', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0 }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return logs for non-admin, with projectId', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 1n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + const projectId = faker.string.uuid() + + const logs = [] + const total = 1 + + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0, projectId, clean: false }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledWith({ clean: true, limit: 10, offset: 0, projectId }) + expect(response.statusCode).toEqual(200) + }) + + it('should not return logs for non-admin, with projectId', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + const projectId = faker.string.uuid() + + const logs = [] + const total = 1 + + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0, projectId, clean: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/log/router.ts b/.jjconflict-side-0/apps/server/src/resources/log/router.ts new file mode 100644 index 0000000000..72e8712f0b --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/log/router.ts @@ -0,0 +1,30 @@ +import type { CleanLog, Log, XOR } from '@cpn-console/shared' +import type { UserProfile, UserProjectProfile } from '@/utils/controller.js' +import { AdminAuthorized, logContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getLogs } from './business.js' + +export function logRouter() { + return serverInstance.router(logContract, { + // Récupérer des logs + getLogs: async ({ request: req, query }) => { + const perms: XOR = query.projectId + ? await authUser(req, { id: query.projectId }) + : await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) { + if (!perms.projectPermissions) return new Forbidden403() + query.clean = true + } + + const [total, logs] = await getLogs(query) as [number, unknown[]] as [number, Array] + + return { + status: 200, + body: { total, logs }, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/project-member/business.ts b/.jjconflict-side-0/apps/server/src/resources/project-member/business.ts new file mode 100644 index 0000000000..4144b81813 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project-member/business.ts @@ -0,0 +1,73 @@ +import type { projectMemberContract, XOR } from '@cpn-console/shared' +import type { Project, User } from '@prisma/client' +import { logger as baseLogger } from '@cpn-console/logger' +import { UserSchema } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + addLogs, + deleteMember, + listMembers as listMembersQuery, + upsertMember, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' +import { logViaSession } from '../user/business.js' + +const logger = baseLogger.child({ scope: 'resource:project-member' }) + +export const listMembers = async (projectId: Project['id']) => listMembersQuery(projectId) + +export async function addMember(projectId: Project['id'], user: XOR<{ userId: string }, { email: string }>, requestorId: User['id'], requestId: string, projectOwnerId: Project['ownerId']) { + let userInDb: User | undefined | null + + logger.info({ requestId, requestorId, projectId, identifierType: user.userId ? 'userId' : 'email' }, 'Add project member started') + if (user.userId) { + userInDb = await prisma.user.findUnique({ where: { id: user.userId, type: 'human' } }) + } else if (user.email) { + userInDb = await prisma.user.findUnique({ where: { email: user.email, type: 'human' } }) + } else { + return new BadRequest400('Veuillez spécifiez au moins un userId ou un email') + } + if (userInDb) { + if (userInDb.id === projectOwnerId) return new BadRequest400('Le owner ne peut pas être ajouté à cette liste') + } else if (user.email) { + const hookReply = await hook.user.retrieveUserByEmail(user.email) + await addLogs({ action: 'Retrieve User By Email', data: hookReply, userId: requestorId, requestId }) + if (hookReply.failed) { + logger.error({ requestId, requestorId, projectId }, 'Add project member failed during user lookup hooks') + throw new BadRequest400('Echec de la recherche auprès des services externes') + } + + const retrievedUser = hookReply.results.keycloak?.user + if (!retrievedUser) return new BadRequest400('Utilisateur introuvable') + const userValidated = UserSchema.pick({ email: true, firstName: true, lastName: true, id: true }).safeParse(retrievedUser) + if (!userValidated.success) return new BadRequest400('L\'utilisateur trouvé ne remplit pas les conditions de vérification') + const logResults = await logViaSession({ ...userValidated.data, groups: [] }) + userInDb = logResults.user + } else { + return new NotFound404() + } + + await upsertMember({ projectId, userId: userInDb.id, roleIds: [] }) + await hook.projectMember.upsert(projectId, userInDb.id) + logger.info({ requestId, requestorId, projectId, userId: userInDb.id }, 'Add project member completed') + return listMembers(projectId) +} + +export async function patchMembers(projectId: Project['id'], members: typeof projectMemberContract.patchMembers.body._type) { + logger.info({ projectId, membersCount: members.length }, 'Patch project members started') + for (const member of members) { + await upsertMember({ projectId, userId: member.userId, roleIds: member.roles }) + await hook.projectMember.upsert(projectId, member.userId) + } + logger.info({ projectId, membersCount: members.length }, 'Patch project members completed') + return listMembers(projectId) +} + +export async function removeMember(projectId: Project['id'], userId: User['id']) { + logger.info({ projectId, userId }, 'Remove project member started') + await hook.projectMember.delete(projectId, userId) + await deleteMember({ projectId, userId }) + logger.info({ projectId, userId }, 'Remove project member completed') + return listMembers(projectId) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/project-member/queries.ts b/.jjconflict-side-0/apps/server/src/resources/project-member/queries.ts new file mode 100644 index 0000000000..a4ceb00df1 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project-member/queries.ts @@ -0,0 +1,33 @@ +import type { + Prisma, + + Project, +} from '@prisma/client' + +import prisma from '@/prisma.js' + +export const listMembers = (projectId: Project['id']) => prisma.projectMembers.findMany({ where: { projectId }, include: { user: true } }) + +export function upsertMember(data: Prisma.ProjectMembersUncheckedCreateInput) { + return prisma.projectMembers.upsert({ + where: { + projectId_userId: { + userId: data.userId, + projectId: data.projectId, + }, + }, + create: data, + update: { + roleIds: data.roleIds, + }, + include: { user: true }, + }) +} + +export function deleteMember(data: Prisma.ProjectMembersWhereUniqueInput['projectId_userId']) { + return prisma.projectMembers.delete({ + where: { + projectId_userId: data, + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/project-member/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/project-member/router.spec.ts new file mode 100644 index 0000000000..6ebb1d50a2 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project-member/router.spec.ts @@ -0,0 +1,294 @@ +import type { Member } from '@cpn-console/shared' +import { PROJECT_PERMS, projectMemberContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMembersMock = vi.spyOn(business, 'listMembers') +const businessAddMemberMock = vi.spyOn(business, 'addMember') +const businessPatchMembersMock = vi.spyOn(business, 'patchMembers') +const businessRemoveMemberMock = vi.spyOn(business, 'removeMember') + +describe('projectMemberRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const userId = faker.string.uuid() + + describe('listMembers', () => { + it('should return members for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessListMembersMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectMemberContract.listMembers.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectMemberContract.listMembers.path.replace(':projectId', projectId)) + .end() + + expect(businessListMembersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('addMember', () => { + const memberData: Partial = { + userId: faker.string.uuid(), + } + + it('should add member for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const newMember = { + ...memberData, + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + roleIds: [], + } + + businessAddMemberMock.mockResolvedValueOnce([newMember]) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.json()).toEqual([newMember]) + expect(response.statusCode).toEqual(201) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + businessAddMemberMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('patchMembers', () => { + const patchData = [{ userId: faker.string.uuid(), roles: [] }] + + it('should patch members for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchMembersMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('removeMember', () => { + it('should remove member for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessRemoveMemberMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should be able leave a project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessRemoveMemberMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/project-member/router.ts b/.jjconflict-side-0/apps/server/src/resources/project-member/router.ts new file mode 100644 index 0000000000..e9755fab57 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project-member/router.ts @@ -0,0 +1,77 @@ +import { ProjectAuthorized, projectMemberContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + addMember, + listMembers, + patchMembers, + removeMember, +} from './business.js' + +export function projectMemberRouter() { + return serverInstance.router(projectMemberContract, { + listMembers: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListMembers(perms)) return new Forbidden403() + const body = await listMembers(projectId) + + return { + status: 200, + body, + } + }, + + addMember: async ({ request: req, params, body }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageMembers(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await addMember(projectId, body, perms.user.id, req.id, perms.projectOwnerId) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 201, + body: resBody, + } + }, + + patchMembers: async ({ request: req, params, body }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageMembers(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await patchMembers(projectId, body) + + return { + status: 200, + body: resBody, + } + }, + + removeMember: async ({ request: req, params }) => { + const { projectId, userId } = params + const perms = await authUser(req, { id: projectId }) + + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (!ProjectAuthorized.ManageMembers(perms) && userId !== perms.user?.id) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await removeMember(projectId, params.userId) + + return { + status: 200, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/project-role/business.spec.ts b/.jjconflict-side-0/apps/server/src/resources/project-role/business.spec.ts new file mode 100644 index 0000000000..89a70147fd --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project-role/business.spec.ts @@ -0,0 +1,357 @@ +import type { Project, ProjectMembers, ProjectRole } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../__mocks__/prisma.js' +import { countRolesMembers, createRole, deleteRole, listRoles, patchRoles } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', () => ({ + hook: { + project: { + upsert: vi.fn(), + delete: vi.fn(), + getSecrets: vi.fn(), + }, + projectRole: { + upsert: vi.fn(), + delete: vi.fn(), + }, + user: { + retrieveUserByEmail: vi.fn(), + }, + }, +})) + +describe('test project-role business', () => { + const project: Project = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + slug: faker.lorem.word({ length: { min: 2, max: 10 } }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + description: faker.lorem.sentence({ min: 2, max: 10 }), + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + everyonePerms: 0n, + ownerId: faker.string.uuid(), + lastSuccessProvisionningVersion: null, + } + const projectId = faker.string.uuid() + + describe('listRoles', () => { + it('should stringify bigint', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + const response = await listRoles(projectId) + expect(response).toContainEqual(expect.objectContaining({ permissions: '4' })) + }) + + it('should strip oidcGroup prefix', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: `/${project.slug}/console/admin`, + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValueOnce(project) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + + const response = await listRoles(projectId) + expect(response[0].oidcGroup).toBe('/console/admin') + }) + }) + + describe('createRole', () => { + it('should create role with incremented position when position 0 is the highest', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 1, projectId } }) + }) + + it('should create role with incremented position with bigger position', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 51, projectId } }) + }) + + it('should create role with incremented position with no role in db', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(null) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.projectRole.findFirst.mockResolvedValueOnce(null) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 0, projectId } }) + }) + + it('should create role with enforced oidcGroup prefix', async () => { + const dbRole: any = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: `/${project.slug}/console/admin`, + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValueOnce(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + + await createRole(projectId, { name: 'test', permissions: '4', oidcGroup: '/console/admin' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + oidcGroup: `/${project.slug}/console/admin`, + }), + })) + }) + }) + + describe('deleteRole', () => { + const roleId = faker.string.uuid() + it('should delete role and remove id from concerned users', async () => { + const dbRole: ProjectRole = { + id: roleId, + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + const members = [{ + userId: faker.string.uuid(), + projectId, + roleIds: [roleId], + }, { + userId: faker.string.uuid(), + projectId, + roleIds: [roleId, faker.string.uuid()], + }] as const satisfies Partial[] + + prisma.projectRole.findUnique.mockResolvedValue(dbRole) + prisma.projectMembers.findMany.mockResolvedValueOnce(members) + prisma.projectRole.findMany.mockResolvedValueOnce([]) + prisma.projectRole.delete.mockResolvedValue(dbRole) + await deleteRole(roleId) + + expect(prisma.projectMembers.update).toHaveBeenNthCalledWith(1, { where: expect.any(Object), data: { roleIds: { set: [] } } }) + expect(prisma.projectMembers.update).toHaveBeenNthCalledWith(2, { where: expect.any(Object), data: { roleIds: { set: [members[1].roleIds[1]] } } }) + expect(prisma.projectRole.delete).toHaveBeenCalledWith({ where: { id: roleId } }) + }) + }) + + describe.skip('countRolesMembers', () => { + it('should return aggregated role member counts', async () => { + const roles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + }] as const satisfies ProjectRole[] + + const members = [{ + userId: faker.string.uuid(), + projectId, + roleIds: [roles[0].id, roles[1].id], + }, { + userId: faker.string.uuid(), + projectId, + roleIds: [roles[1].id], + }] as const satisfies ProjectMembers[] + + prisma.projectRole.findMany.mockResolvedValue(roles) + prisma.projectMembers.findMany.mockResolvedValue(members) + + const response = await countRolesMembers(projectId) + + expect(response).toEqual({ [roles[0].id]: 1, [roles[1].id]: 2 }) + }) + }) + + describe('patchRoles', () => { + const dbRoles: ProjectRole[] = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + projectId, + oidcGroup: 'group1', + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + projectId, + oidcGroup: 'group2', + type: 'managed', + }] + + it('should do nothing', async () => { + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue([]) + await patchRoles(projectId, []) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 1 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(projectId, updateRoles) + + expect(response).instanceOf(BadRequest400) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions (missing)', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[1].id, position: 1 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(projectId, updateRoles) + + expect(response).instanceOf(BadRequest400) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should update positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 0 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledTimes(2) + }) + + it('should update permissions', async () => { + const updateRoles: (Pick & { permissions: string })[] = [ + { id: dbRoles[1].id, permissions: '0' }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + data: { + name: dbRoles[1].name, + permissions: 0n, + position: 1, + oidcGroup: dbRoles[1].oidcGroup, + projectId, + type: 'managed', + }, + where: { + id: dbRoles[1].id, + }, + })) + }) + + it('should update role with enforced oidcGroup prefix', async () => { + const updateRoles: any[] = [ + { id: dbRoles[1].id, oidcGroup: '/console/admin' }, + ] + + prisma.project.findUnique.mockResolvedValue(project) + + const dbRoleWithPrefix = { ...dbRoles[1], oidcGroup: `/${project.slug}/console/group2` } + prisma.projectRole.findMany.mockResolvedValue([dbRoleWithPrefix]) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + oidcGroup: `/${project.slug}/console/admin`, + }), + })) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/project-role/business.ts b/.jjconflict-side-0/apps/server/src/resources/project-role/business.ts new file mode 100644 index 0000000000..52bd8966b3 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project-role/business.ts @@ -0,0 +1,121 @@ +import type { projectRoleContract } from '@cpn-console/shared' +import type { Project, ProjectRole } from '@prisma/client' +import { isSystemRoleType } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + deleteRole as deleteRoleQuery, + listMembers, + listRoles as listRolesQuery, + updateRole, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +const oidcRegexp = /^\/[^/]+/ + +export async function listRoles(projectId: Project['id']) { + const roles = await listRolesQuery(projectId) + return roles.map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ? role.oidcGroup.replace(oidcRegexp, '') : role.oidcGroup, + })) +} + +export async function patchRoles(projectId: Project['id'], roles: typeof projectRoleContract.patchProjectRoles.body._type) { + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } }) + if (!project) throw new NotFound404() + const dbRoles = await listRolesQuery(projectId) + const positionsAvailable: number[] = [] + + const updatedRoles: (Omit & { permissions: bigint })[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (matchingRole) { + if (isSystemRoleType(dbRole.type)) { + return new BadRequest400('Ce rôle système ne peut pas être modifié') + } + if (isSystemRoleType(matchingRole.type)) { + return new BadRequest400('Impossible de modifier un rôle en rôle système') + } + if (typeof matchingRole?.position !== 'undefined' && !positionsAvailable.includes(matchingRole?.position)) { + positionsAvailable.push(matchingRole.position) + } + if (matchingRole.oidcGroup && !matchingRole.oidcGroup.startsWith('/')) { + return new BadRequest400('oidcGroup doit commencer par /') + } + updatedRoles.push({ + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions: matchingRole.permissions ? BigInt(matchingRole.permissions) : dbRole.permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup: matchingRole.oidcGroup ? `/${project.slug}${matchingRole.oidcGroup}` : dbRole.oidcGroup, + type: matchingRole.type ?? dbRole.type, + projectId: dbRole.projectId, + }) + } + } + + if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes') + for (const { id, ...role } of updatedRoles) { + await updateRole(id, role) + await hook.projectRole.upsert(id) + } + + return listRoles(projectId) +} + +export async function createRole(projectId: Project['id'], role: typeof projectRoleContract.createProjectRole.body._type) { + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } }) + if (!project) throw new NotFound404() + if (isSystemRoleType(role.type)) { + throw new BadRequest400('Impossible de créer un rôle système') + } + const dbMaxPosRole = (await prisma.projectRole.findFirst({ + where: { projectId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + if (role.oidcGroup && !role.oidcGroup.startsWith('/')) { + throw new BadRequest400('oidcGroup doit commencer par /') + } + + const createdRole = await prisma.projectRole.create({ + data: { + ...role, + projectId, + position: dbMaxPosRole + 1, + permissions: BigInt(role.permissions), + oidcGroup: role.oidcGroup ? `/${project.slug}${role.oidcGroup}` : undefined, + }, + }) + + await hook.projectRole.upsert(createdRole.id) + + return listRoles(projectId) +} + +export async function countRolesMembers(projectId: Project['id']) { + const roles = await listRoles(projectId) + const members = await listMembers(projectId) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) // {role uuid: 0} + for (const { roleIds } of members) { + for (const roleId of roleIds) { + rolesCounts[roleId]++ + } + } + return rolesCounts +} + +export async function deleteRole(roleId: Project['id']) { + const role = await prisma.projectRole.findUnique({ where: { id: roleId }, select: { type: true } }) + if (!role) throw new NotFound404() + if (isSystemRoleType(role.type)) { + throw new BadRequest400('Ce rôle système ne peut pas être supprimé') + } + await hook.projectRole.delete(roleId) + await deleteRoleQuery(roleId) + return null +} diff --git a/.jjconflict-side-0/apps/server/src/resources/project-role/queries.ts b/.jjconflict-side-0/apps/server/src/resources/project-role/queries.ts new file mode 100644 index 0000000000..3e2aa4a0fa --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project-role/queries.ts @@ -0,0 +1,59 @@ +import type { + Prisma, + Project, + ProjectRole, +} from '@prisma/client' + +import prisma from '@/prisma.js' + +export const getRole = (id: ProjectRole['id']) => prisma.projectRole.findUnique({ where: { id } }) + +export const listRoles = (projectId: Project['id']) => prisma.projectRole.findMany({ where: { projectId }, orderBy: { position: 'asc' } }) + +export function createRole(data: Pick) { + return prisma.projectRole.create({ + data: { + name: data.name, + permissions: 0n, + position: data.position, + projectId: data.projectId, + oidcGroup: data.oidcGroup, + type: 'managed', + }, + }) +} + +export function updateRole(id: ProjectRole['id'], data: Pick) { + return prisma.projectRole.update({ + where: { id }, + data, + }) +} + +export async function deleteRole(id: ProjectRole['id']) { + const role = await prisma.projectRole.delete({ + where: { + id, + }, + }) + const attachedMembers = await prisma.projectMembers.findMany({ + where: { projectId: role.projectId, roleIds: { has: id } }, + }) + for (const member of attachedMembers) { + await prisma.projectMembers.update({ + where: { + projectId_userId: { + projectId: role.projectId, + userId: member.userId, + }, + }, + data: { + roleIds: { + set: member.roleIds.filter(roleId => roleId !== id), + }, + }, + }) + } +} + +export const getProjectRoleById = (id: ProjectRole['id']) => prisma.projectRole.findUnique({ where: { id }, include: { project: true } }) diff --git a/.jjconflict-side-0/apps/server/src/resources/project-role/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/project-role/router.spec.ts new file mode 100644 index 0000000000..dd42fbc7ad --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project-role/router.spec.ts @@ -0,0 +1,299 @@ +import { PROJECT_PERMS, projectRoleContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('./business.js') +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) + +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessCreateRoleMock = vi.spyOn(business, 'createRole') +const businessDeleteRoleMock = vi.spyOn(business, 'deleteRole') +const businessListRolesMock = vi.spyOn(business, 'listRoles') +const businessPatchRolesMock = vi.spyOn(business, 'patchRoles') + +describe('tests projectRoleContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const roleId = faker.string.uuid() + + describe('listProjectRoles', () => { + it('should return 403 for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessListRolesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectRoleContract.listProjectRoles.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectRoleContract.listProjectRoles.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createProjectRole', () => { + it('should create role for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('patchProjectRoles', () => { + it('should patch roles for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchRolesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchRolesMock.mockResolvedValue(new BadRequest400('une erreur')) + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('projectRoleMemberCounts', () => { + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectRoleContract.projectRoleMemberCounts.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteProjectRole', () => { + it('should delete role for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteRoleMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 404 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/project-role/router.ts b/.jjconflict-side-0/apps/server/src/resources/project-role/router.ts new file mode 100644 index 0000000000..f7aa2ef902 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project-role/router.ts @@ -0,0 +1,88 @@ +import { ProjectAuthorized, projectRoleContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + countRolesMembers, + createRole, + deleteRole, + listRoles, + patchRoles, +} from './business.js' + +export function projectRoleRouter() { + return serverInstance.router(projectRoleContract, { + // Récupérer des projets + listProjectRoles: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ListRoles(perms)) return new Forbidden403() + + const body = await listRoles(projectId) + + return { + status: 200, + body, + } + }, + + createProjectRole: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await createRole(projectId, body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 201, + body: resBody, + } + }, + + patchProjectRoles: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await patchRoles(projectId, body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + projectRoleMemberCounts: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ListRoles(perms)) return new Forbidden403() + + const resBody = await countRolesMembers(projectId) + + return { + status: 200, + body: resBody, + } + }, + + deleteProjectRole: async ({ request: req, params: { projectId, roleId } }) => { + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await deleteRole(roleId) + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/project-service/business.ts b/.jjconflict-side-0/apps/server/src/resources/project-service/business.ts new file mode 100644 index 0000000000..bce70064ef --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project-service/business.ts @@ -0,0 +1,103 @@ +import type { ZoneObject } from '@cpn-console/hooks' +import type { + PermissionTarget, + PluginsUpdateBody, + ServiceUrl, +} from '@cpn-console/shared' +import type { Project, ProjectPlugin } from '@prisma/client' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { + getAdminPlugin, + getProjectInfosByIdOrThrow, + getProjectStore, + getPublicClusters, + saveProjectStore, +} from '@/resources/queries-index.js' + +const logger = baseLogger.child({ scope: 'resource:project-service' }) + +export type ConfigRecords = { + key: string + pluginName: string + value: string | number | null +}[] + +export function dbToObj(records: Omit[]): PluginsUpdateBody { + const obj: PluginsUpdateBody = {} + for (const record of records) { + if (!obj[record.pluginName]) obj[record.pluginName] = {} + obj[record.pluginName][record.key] = record.value + } + return obj +} + +export function objToDb(obj: PluginsUpdateBody): ConfigRecords { + return Object.entries(obj) + .map(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) + .flat() +} + +export async function getProjectServices(projectId: Project['id'], permissionTarget: PermissionTarget) { + // Pré-requis + const project = await getProjectInfosByIdOrThrow(projectId) + + const [projectStore, globalConfig] = await Promise.all([ + getProjectStore(projectId), + getAdminPlugin(), + ]) + const store = dbToObj([...projectStore, ...globalConfig]) + + const publicClusters = await getPublicClusters() + project.clusters = [...project.clusters, ...publicClusters] + const zones: Map = new Map() // Pour dédoublonnage des zones + project.clusters.map(c => zones.set(c.zone.id, c.zone)) + + return Object.values(servicesInfos).map(({ name, title, to, imgSrc, description }) => { + let urls: ServiceUrl[] = [] + const toResponse = to + ? to({ + clusters: project.clusters, + zones: [...zones.values()], + environments: project.environments, + project, + store, + }) + : [] + if (Array.isArray(toResponse)) { + urls = toResponse.map(res => ({ name: res.title ?? '', description: res.description ?? '', to: res.to })) + } else if (typeof toResponse === 'string') { + urls = [{ to: toResponse, name: '' }] + } else if (toResponse) { + urls = [{ name: toResponse.title ?? '', to: toResponse.to }] + } + const manifest = populatePluginManifests({ + data: { + project: projectStore, + global: globalConfig, + }, + permissionTarget, + pluginName: name, + select: { + global: true, + project: true, + }, + }) + return { imgSrc, title, name, urls, manifest, description } + }).filter(s => s.urls.length || s.manifest.global?.length || s.manifest.project?.length) +} + +export async function updateProjectServices(projectId: Project['id'], data: PluginsUpdateBody, stripperRoles: Array<'user' | 'admin'>) { + const pluginKeysCount = Object.fromEntries( + Object.entries(data).map(([pluginName, values]) => [pluginName, Object.keys(values).length]), + ) + logger.info({ projectId, pluginCount: Object.keys(data).length, pluginKeysCount, stripperRoles }, 'Update project services started') + for (const role of stripperRoles) { + const parsedData = editStrippers.project[role].safeParse(data) + if (!parsedData.success) continue + await saveProjectStore(objToDb(parsedData.data), projectId) + } + logger.info({ projectId }, 'Update project services completed') + return null +} diff --git a/.jjconflict-side-0/apps/server/src/resources/project-service/queries.ts b/.jjconflict-side-0/apps/server/src/resources/project-service/queries.ts new file mode 100644 index 0000000000..cf353614b2 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project-service/queries.ts @@ -0,0 +1,54 @@ +import type { Project } from '@prisma/client' +import type { ConfigRecords } from './business.js' +import prisma from '@/prisma.js' + +// CONFIG +export function getProjectStore(projectId: Project['id']) { + return prisma.projectPlugin.findMany({ + where: { projectId }, + select: { + key: true, + pluginName: true, + value: true, + }, + }) +} + +export const getAdminPlugin = prisma.adminPlugin.findMany + +export async function saveProjectStore(records: ConfigRecords, projectId: Project['id']) { + for (const { pluginName, key, value } of records) { + if (value === null) { + await prisma.projectPlugin.delete({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } else { + await prisma.projectPlugin.upsert({ + create: { + pluginName, + projectId, + key, + value: value.toString(), + }, + update: { + key, + value: value.toString(), + pluginName, + }, + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } + } +} diff --git a/.jjconflict-side-0/apps/server/src/resources/project-service/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/project-service/router.spec.ts new file mode 100644 index 0000000000..20398a0d08 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project-service/router.spec.ts @@ -0,0 +1,160 @@ +import { ADMIN_PERMS, PROJECT_PERMS, projectServiceContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetServicesMock = vi.spyOn(business, 'getProjectServices') +const businessUpdateServicesMock = vi.spyOn(business, 'updateProjectServices') + +describe('projectServiceRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + + describe('getServices', () => { + it('should return services for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .query({ permissionTarget: 'user' }) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledWith(projectId, 'user') + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should not return admin services for non admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .query({ permissionTarget: 'admin' }) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return services for admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledWith(projectId, 'user') + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateProjectServices', () => { + const updateData = { serviceA: { param1: 'value' } } + + it('should update services for project manager', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateServicesMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(businessUpdateServicesMock).toHaveBeenCalledWith(projectId, updateData, ['user']) + expect(response.statusCode).toEqual(204) + }) + + it('should update services for project admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateServicesMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(businessUpdateServicesMock).toHaveBeenCalledWith(projectId, updateData, ['user', 'admin']) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/project-service/router.ts b/.jjconflict-side-0/apps/server/src/resources/project-service/router.ts new file mode 100644 index 0000000000..e5a1f07159 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project-service/router.ts @@ -0,0 +1,38 @@ +import { AdminAuthorized, ProjectAuthorized, projectServiceContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getProjectServices, updateProjectServices } from './business.js' + +export function projectServiceRouter() { + return serverInstance.router(projectServiceContract, { + // Récupérer les services d'un projet + getServices: async ({ request: req, params: { projectId }, query }) => { + const perms = await authUser(req, { id: projectId }) + if (!perms.projectPermissions && !ProjectAuthorized.Manage(perms)) return new Forbidden403() + if (query.permissionTarget === 'admin' && !AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403('Vous ne pouvez pas demander les paramètres admin') + + const body = await getProjectServices(projectId, query.permissionTarget) + + return { + status: 200, + body, + } + }, + + updateProjectServices: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.Manage(perms) && !perms.projectPermissions) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + + const allowedRoles: Array<'user' | 'admin'> = AdminAuthorized.Manage(perms.adminPermissions) ? ['user', 'admin'] : ['user'] + + const resBody = await updateProjectServices(projectId, body, allowedRoles) + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/project/business.spec.ts b/.jjconflict-side-0/apps/server/src/resources/project/business.spec.ts new file mode 100644 index 0000000000..a9f6c4fa62 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project/business.spec.ts @@ -0,0 +1,365 @@ +import type { Cluster, Project, ProjectMembers, ProjectRole, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { + BadRequest400, + ErrorResType, + Unprocessable422, +} from '../../utils/errors.js' +import { dbToObj } from '../project-service/business.ts' +import * as userBusiness from '../user/business.js' +import { archiveProject, chunk, createProject, generateProjectsData, generateSlug, getProjectSecrets, listProjects, replayHooks, updateProject } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const logViaSessionMock = vi.spyOn(userBusiness, 'logViaSession') + +const projectId = faker.string.uuid() + +const user: User = { + id: faker.string.uuid(), + createdAt: new Date(), + updatedAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + lastLogin: null, +} +const project: Project & { + clusters: Pick[] + members: ProjectMembers[] + roles: ProjectRole[] + owner: User +} = { + createdAt: new Date(), + updatedAt: new Date(), + description: '', + everyonePerms: 649n, + id: faker.string.uuid(), + locked: false, + name: faker.string.alphanumeric(8), + status: 'created', + ownerId: faker.string.uuid(), + clusters: [], + roles: [], + members: [], +} +const reqId = faker.string.uuid() +describe('test project business utils', () => { + it('should transform arrow ', async () => { + const result = dbToObj([{ key: 'test', pluginName: 'test', value: 'test' }]) + expect(result).toEqual({ test: { test: 'test' } }) + }) +}) + +describe('test project business logic', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listProjects', () => { + it('should return stringified perms', async () => { + prisma.project.findMany.mockResolvedValue([{ everyonePerms: 5n, clusters: [], roles: [{ permissions: 28n }] }]) + const response = await listProjects({}, user.id) + expect(response[0].everyonePerms).toBe('5') + expect(response[0].roles[0].permissions).toBe('28') + }) + }) + describe('getProjectSecrets', () => { + const getResultsHook = { + failed: false, + args: {}, + results: { + registry: { + secrets: { + token: 'myToken', + }, + status: { + failed: false, + }, + }, + }, + } + it('should return transform secret', async () => { + hook.project.getSecrets.mockResolvedValue(getResultsHook) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + + // according to src/utils/mocks.ts + expect(JSON.stringify(response)).toContain('myToken') + }) + + it('should return projects secrets', async () => { + hook.project.getSecrets.mockResolvedValue(getResultsHook) + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + prisma.project.findMany.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + // according to src/utils/mocks.ts + expect(JSON.stringify(response)).toContain('myToken') + }) + + it('should return hook error', async () => { + hook.project.getSecrets.mockResolvedValue({ failed: true }) + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + prisma.project.findMany.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + // according to src/utils/mocks.ts + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('createProject', () => { + it('should create project', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.create.mockResolvedValue({ ...project, status: 'initializing' }) + prisma.project.findFirst.mockResolvedValue(undefined) + prisma.project.findMany.mockResolvedValue([]) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const projectRes = await createProject(project, user, reqId) + + expect(projectRes.name).toEqual(project.name) + expect(prisma.project.create).toHaveBeenCalledTimes(1) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should return plugins failed', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.create.mockResolvedValue({ ...project, status: 'initializing' }) + prisma.project.findFirst.mockResolvedValue(undefined) + prisma.project.findMany.mockResolvedValue([]) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const response = await createProject(project, user, reqId) + + expect(prisma.project.create).toHaveBeenCalledTimes(1) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + describe('updateProject', () => { + const updatedProjet = { + description: faker.lorem.lines(2), + everyonePerms: '5', + } + const reqId = faker.string.uuid() + const members: ProjectMembers[] = [{ userId: faker.string.uuid(), projectId: project.id, roleIds: [], user: { type: 'human' } }] + it('should update project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + await updateProject({ ...updatedProjet, ownerId: members[0].userId }, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(2) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should update nothing', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + await updateProject({ }, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(0) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should not update if project archived', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, status: 'archived' }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const response = await updateProject({ }, project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should not update project, cause missing member', async () => { + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members: [] }) + + const response = await updateProject({ ownerId: members[0].userId }, project.id, user, reqId) + + expect(prisma.project.findUniqueOrThrow).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(BadRequest400) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + expect(prisma.log.update).toHaveBeenCalledTimes(0) + }) + + it('should return plugins failed', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const response = await updateProject(updatedProjet, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + describe('replayHooks', () => { + const reqId = faker.string.uuid() + + it('should replay hooks', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + await replayHooks(project.id, user, reqId) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should not replay hooks on archived project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'archived' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should not replay hooks on locked project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: true, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should update nothing and return error', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: true } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('archiveProject', () => { + it('should archive project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeNull() + expect(prisma.project.update).toHaveBeenLastCalledWith({ + where: { id: project.id }, + data: { + clusters: { set: [] }, + }, + }) + }) + + it('should not archive a project already archived', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false, status: 'archived' }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + }) + + it('should not archive a project locked', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: true, status: 'created' }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + }) + + it('should return hook fail', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false }) + hook.project.delete.mockResolvedValue({ results: { failed: true }, project: Promise.resolve({ status: 'failed' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('generateProjectsData', () => { + it('shoud return string, very bad test ...', async () => { + prisma.project.findMany.mockResolvedValue([{ name: 'test' }]) + const response = await generateProjectsData() + expect(response).toBeTypeOf('string') + }) + }) +}) + +describe('chunk function', () => { + it('should return 5 elements', () => { + const letters = ['A', 'B', 'C', 'D', 'E'] + expect(chunk(letters, 5)).toEqual([letters]) + }) + it('should return 3,2 elements', () => { + const letters = ['A', 'B', 'C', 'D', 'E'] + expect(chunk(letters, 3)).toEqual([['A', 'B', 'C'], ['D', 'E']]) + }) + it('should return 4 elements', () => { + const letters = ['A', 'B', 'C', 'D'] + expect(chunk(letters, 5)).toEqual([letters]) + }) +}) + +const slugRegexp = /-[01]$/ +const slugFourRegexp = /-4$/ +const slugEmptySpaceRegexp = /-2$/ + +describe('generateSlug', () => { + it('should return prefix, no array', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix) + expect(generated).toEqual(prefix) + }) + it('should return prefix, empty array', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, []) + expect(generated).toEqual(prefix) + }) + it('should return prefix, no match', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [faker.string.alphanumeric(5), faker.string.alphanumeric(5)]) + expect(generated).toEqual(prefix) + }) + it('should return generated slug at 1 or 0, all matchs', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix]) + expect(generated).match(slugRegexp) + }) + it('should return generated slug at 4, all matchs', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix, `${prefix}-0`, `${prefix}-1`, `${prefix}-2`, `${prefix}-3`]) + expect(generated).match(slugFourRegexp) + }) + it('should fill empty space', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix, `${prefix}-0`, `${prefix}-1`, `${prefix}-3`]) + expect(generated).match(slugEmptySpaceRegexp) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/project/business.ts b/.jjconflict-side-0/apps/server/src/resources/project/business.ts new file mode 100644 index 0000000000..b27f16bf64 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project/business.ts @@ -0,0 +1,306 @@ +import type { projectContract } from '@cpn-console/shared' +import type { Project, User } from '@prisma/client' +import type { UserDetails } from '@/types/index.js' +import type { ErrorResType } from '@/utils/errors.js' +import { servicesInfos } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { ProjectStatusSchema } from '@cpn-console/shared' +import { json2csv } from 'json-2-csv' +import prisma from '@/prisma.js' +import { + addLogs, + deleteAllEnvironmentForProject, + deleteAllRepositoryForProject, + getAllProjectsDataForExport, + getProjectOrThrow, + getSlugs, + initializeProject, + listProjects as listProjectsQuery, + lockProject, + updateProject as updateProjectQuery, +} from '@/resources/queries-index.js' +import { whereBuilder } from '@/utils/controller.js' +import { parallelBulkLimit } from '@/utils/env.js' +import { BadRequest400, Forbidden403, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +const logger = baseLogger.child({ scope: 'resource:project' }) + +export function generateSlug(prefix: string, existingSlugs?: string[]) { + if (!existingSlugs?.includes(prefix)) { + return prefix + } + let idx = 1 + let generated = `${prefix}-${idx}` + while (existingSlugs.includes(generated)) { + idx++ + generated = `${prefix}-${idx}` + } + return generated +} + +const projectStatus = ProjectStatusSchema._def.values +export async function listProjects({ status, statusIn, statusNotIn, filter = 'member', ...query }: typeof projectContract.listProjects.query._type, userId: User['id'] | undefined) { + return listProjectsQuery({ + ...query, + status: whereBuilder({ enumValues: projectStatus, eqValue: status, inValues: statusIn, notInValues: statusNotIn }), + filter, + userId, + }).then(projects => projects.map(({ clusters, ...project }) => ({ + ...project, + clusterIds: clusters.map(({ id }) => id), + roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + everyonePerms: project.everyonePerms.toString(), + }))) +} + +export async function getProjectSecrets(projectId: string) { + const hookReply = await hook.project.getSecrets(projectId) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la récupération des secrets du projet') + } + + return Object.fromEntries( + Object.entries(hookReply.results) + // @ts-ignore + .filter(([_key, value]) => Object.keys(value.secrets).length) + // @ts-ignore + .map(([key, value]) => [servicesInfos[key]?.title, value.secrets]), + ) +} + +export async function createProject(dataDto: typeof projectContract.createProject.body._type, requestor: UserDetails, requestId: string) { + if (requestor.type !== 'human') return new BadRequest400('Seuls les comptes humains peuvent créer des projets') + + let slug = dataDto.name + logger.info({ requestId, userId: requestor.id, slugPrefix: slug }, 'Create project started') + const projectsWithSamePrefix = await getSlugs(slug) + slug = generateSlug(slug, projectsWithSamePrefix?.map(project => project.slug)) + + // Actions + const project = await initializeProject({ ...dataDto, slug, ownerId: requestor.id }) + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Project initialized') + + const { results, project: projectInfos } = await hook.project.upsert(project.id) + await addLogs({ action: 'Create Project', data: results, userId: requestor.id, requestId, projectId: project.id }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project failed during upsert hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project upsert hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services à la création du projet') + } + + for (const role of projectInfos.roles) { + const roleResult = await hook.projectRole.upsert(role.id) + await addLogs({ action: 'Upsert Project Role', data: roleResult.results, userId: requestor.id, requestId, projectId: project.id }) + } + + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project completed') + return { + ...projectInfos, + clusterIds: projectInfos.clusters.map(({ id }) => id), + everyonePerms: projectInfos.everyonePerms.toString(), + roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + } +} + +export async function getProject(projectId: Project['id']) { + return getProjectOrThrow(projectId).then(({ clusters, ...project }) => ({ + ...project, + clusterIds: clusters.map(({ id }) => id), + roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + everyonePerms: project.everyonePerms.toString(), + })) +} + +export async function updateProject( + { description, ownerId: ownerIdCandidate, everyonePerms, locked, ...data }: typeof projectContract.updateProject.body._type, + projectId: Project['id'], + requestor: UserDetails, + requestId: string, +) { + const changedFields = Object.entries({ + ...data, + description, + locked, + ownerId: ownerIdCandidate, + everyonePerms, + }).filter(([, value]) => typeof value !== 'undefined').map(([key]) => key) + logger.info({ requestId, userId: requestor.id, projectId, changedFields }, 'Update project started') + + // Actions + const projectDb = await prisma.project.findUniqueOrThrow({ + where: { id: projectId }, + include: { members: { include: { user: true } } }, + }) + + if (projectDb.status === 'archived') return new Forbidden403('Le projet est archivé') + + if (ownerIdCandidate && ownerIdCandidate !== projectDb.ownerId) { + const memberCandidate = projectDb.members.find(member => member.userId === ownerIdCandidate) + if (!memberCandidate) { + return new BadRequest400('Le nouveau propriétaire doit faire partie des membres actuels du projet') + } + if (memberCandidate.user.type !== 'human') return new BadRequest400('Seuls les comptes humains peuvent être propriétaire de projets') + if (!projectDb.members.some(member => member.userId === projectDb.ownerId)) { + await prisma.projectMembers.create({ + data: { userId: projectDb.ownerId, projectId }, + }) + } + await prisma.$transaction([ + prisma.projectMembers.delete({ + where: { projectId_userId: { userId: ownerIdCandidate, projectId } }, + }), + prisma.project.update({ where: { id: projectId }, data: { ownerId: ownerIdCandidate } }), + ]) + } + + if (typeof description !== 'undefined' || typeof everyonePerms !== 'undefined' || typeof locked !== 'undefined') { + await updateProjectQuery(projectId, { + description, + locked, + ...everyonePerms && { everyonePerms: BigInt(everyonePerms) }, + ...data, + }) + } + + const { results, project: projectInfos } = await hook.project.upsert(projectId) + await addLogs({ action: 'Update Project', data: results, userId: requestor.id, requestId, projectId: projectInfos.id }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId }, 'Update project failed during upsert hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId }, 'Update project upsert hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services à la mise à jour du projet') + } + + logger.info({ requestId, userId: requestor.id, projectId }, 'Update project completed') + return { + ...projectInfos, + clusterIds: projectInfos.clusters.map(({ id }) => id), + everyonePerms: projectInfos.everyonePerms.toString(), + roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${projectInfos.slug}`, '') : role.oidcGroup })), + } +} + +interface ReplayHooksArgs { + projectId: Project['id'] + userId?: User['id'] + requestId: string +} +export async function replayHooks({ projectId, userId, requestId }: ReplayHooksArgs): Promise { + logger.info({ requestId, userId, projectId }, 'Replay project hooks started') + const projectDb = await prisma.project.findUniqueOrThrow({ + where: { id: projectId }, + include: { members: { include: { user: true } } }, + }) + if (projectDb.locked) return new Forbidden403('Le projet est verrouillé') + if (projectDb.status === 'archived') return new Forbidden403('Le projet est archivé') + // Actions + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Replay hooks for Project', data: results, userId, requestId, projectId }) + if (results.failed) { + logger.error({ requestId, userId, projectId }, 'Replay project hooks failed') + } else { + logger.info({ requestId, userId, projectId }, 'Replay project hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services au reprovisionnement du projet') + } + return null +} + +export async function archiveProject(projectId: Project['id'], requestor: UserDetails, requestId: string): Promise { + logger.info({ requestId, userId: requestor.id, projectId }, 'Archive project started') + // Actions + // Fetch project state before hook so plugins see repos and environments + const projectDb = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, include: { members: { include: { user: true } } } }) + + if (projectDb.locked) return new Forbidden403('Le projet est verrouillé') + if (projectDb.status === 'archived') return new BadRequest400('Le projet est archivé') + if (projectDb.locked) { + await lockProject(projectId) + } + + // -- début - Suppression projet -- + const { results, project } = await hook.project.delete(projectId) + await addLogs({ action: 'Delete all project resources', data: results, userId: requestor.id, requestId, projectId }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId }, 'Archive project failed during delete hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId, status: project.status }, 'Archive project delete hooks completed') + } + if (project.status !== 'archived' && !projectDb.locked) { + await prisma.project.update({ where: { id: projectId }, data: { locked: false } }) + } + if (results.failed) { + return new Unprocessable422('Echec des services à la suppression du projet') + } + + // Empty the project after successful hooks so plugins still have access to repos and environments + await Promise.all([ + deleteAllRepositoryForProject(projectId), + deleteAllEnvironmentForProject(projectId), + ]) + + // Retrait clusters -- + await prisma.project.update({ + where: { id: projectId }, + data: { + clusters: { set: [] }, + }, + }) + + // -- fin - Suppression projet -- + logger.info({ requestId, userId: requestor.id, projectId }, 'Archive project completed') + return null +} + +export async function generateProjectsData() { + const projects = await getAllProjectsDataForExport() + + return json2csv(projects, { + emptyFieldValue: '', + }) +} + +export async function bulkActionProject(data: typeof projectContract.bulkActionProject.body._type, requestor: UserDetails, requestId: string) { + if (data.projectIds === 'all') { + data.projectIds = (await prisma.project.findMany({ + select: { id: true }, + where: { status: { not: 'archived' } }, + })).map(({ id }) => id) + } + bulkExector(data.projectIds + .map((projectId) => { + if (data.action === 'archive') { + return () => archiveProject(projectId, requestor, requestId) + } + if (data.action === 'lock') { + return () => updateProject({ locked: true }, projectId, requestor, requestId) + } + if (data.action === 'unlock') { + return () => updateProject({ locked: false }, projectId, requestor, requestId) + } + if (data.action === 'replay') { + return () => replayHooks({ projectId, userId: requestor.id, requestId }) + } + // should never been called + return async () => {} + })) +} + +export function chunk(arr: T[], size: number): T[][] { + return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => + arr.slice(i * size, i * size + size)) +} + +async function bulkExector(toExecute: Array<() => Promise>) { + const toExecuteChunked = chunk(toExecute, parallelBulkLimit) + for (const chunkToExecute of toExecuteChunked) { + await Promise.allSettled(chunkToExecute.map(fn => fn())) + } +} diff --git a/.jjconflict-side-0/apps/server/src/resources/project/queries.ts b/.jjconflict-side-0/apps/server/src/resources/project/queries.ts new file mode 100644 index 0000000000..5957b15478 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project/queries.ts @@ -0,0 +1,374 @@ +import type { projectContract, XOR } from '@cpn-console/shared' +import type { + Prisma, + Project, + User, +} from '@prisma/client' +import { PROJECT_PERMS } from '@cpn-console/shared' +import { + ProjectStatus, +} from '@prisma/client' +import prisma from '@/prisma.js' +import { appVersion } from '@/utils/env.js' +import { uuid } from '@/utils/queries-tools.js' + +type ProjectUpdate = Partial> +export function updateProject(id: Project['id'], data: ProjectUpdate) { + return prisma.project.update({ + where: { id }, + data, + include: { members: true }, + }) +} + +// SELECT +type FilterWhere = XOR<{ + userId?: User['id'] + filter: 'all' +}, { + userId: User['id'] | undefined + filter: 'owned' | 'member' +}> +type ListProjectWhere = Omit<(typeof projectContract.listProjects.query._type), 'status_in' | 'status_not_in' | 'status'> + & Pick + & FilterWhere +export async function listProjects({ + description, + locked, + name, + status, + id, + filter, + userId, + search, + lastSuccessProvisionningVersion, +}: ListProjectWhere) { + const whereAnd: Prisma.ProjectWhereInput[] = [] + if (id) whereAnd.push({ id }) + if (locked != null) whereAnd.push({ locked }) + if (name) whereAnd.push({ name }) + if (status) whereAnd.push({ status }) + if (description) whereAnd.push({ description: { contains: description } }) + if (lastSuccessProvisionningVersion) { + if (lastSuccessProvisionningVersion === 'outdated') whereAnd.push({ lastSuccessProvisionningVersion: { not: appVersion } }) + else if (lastSuccessProvisionningVersion === 'last') whereAnd.push({ lastSuccessProvisionningVersion: { equals: appVersion } }) + else whereAnd.push({ lastSuccessProvisionningVersion }) + } + if (search) { + whereAnd.push({ OR: [{ + name: { contains: search }, + }, { + owner: { email: { contains: search } }, + }] }) + } + + if (filter === 'owned') { + whereAnd.push({ ownerId: userId }) + } else if (filter === 'member') { + whereAnd.push({ OR: [{ + members: { some: { userId } }, + }, { + ownerId: userId, + }] }) + } + + return prisma.project.findMany({ + where: { AND: whereAnd }, + include: { + clusters: { select: { id: true } }, + members: { include: { user: true } }, + roles: true, + owner: true, + }, + }) +} + +export function getProjectOrThrow(id: Project['id'] | Project['slug']) { + return prisma.project.findFirstOrThrow({ + where: uuid.test(id) + ? { id } + : { slug: id }, + include: { + clusters: { select: { id: true } }, + members: { include: { user: true } }, + roles: true, + owner: true, + }, + }) +} + +export function getProjectInfosByIdOrThrow(projectId: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { + id: projectId, + }, + include: { + environments: true, + clusters: { include: { zone: true } }, + }, + }) +} + +export function getProjectMembers(projectId: Project['id']) { + return prisma.projectMembers.findMany({ + where: { + projectId, + }, + include: { user: true }, + }) +} + +export function getProjectById(id: Project['id']) { + return prisma.project.findUnique({ where: { id } }) +} + +export const baseProjectIncludes = { + members: { include: { user: true } }, + clusters: true, + roles: true, + owner: true, +} as const + +export function getProjectInfos(id: Project['id']) { + return prisma.project.findUnique({ + where: { id }, + include: baseProjectIncludes, + }) +} + +export function getProjectInfosOrThrow(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: baseProjectIncludes, + }) +} + +export function getProjectInfosAndRepos(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: { + ...baseProjectIncludes, + repositories: true, + }, + }) +} + +export function getSlugs(slugPrefix: string) { + return prisma.project.findMany({ + where: { + slug: { startsWith: slugPrefix }, + }, + }) +} + +export function getAllProjectsDataForExport() { + return prisma.project.findMany({ + select: { + name: true, + description: true, + createdAt: true, + updatedAt: true, + environments: { + select: { + name: true, + stage: true, + cluster: { + select: { label: true }, + }, + }, + }, + owner: true, + }, + }) +} + +export function getRolesByProjectId(projectId: Project['id']) { + return prisma.projectRole.findMany({ + where: { projectId }, + }) +} + +const clusterInfosSelect = { + id: true, + infos: true, + label: true, + privacy: true, + secretName: true, + kubeconfig: true, + clusterResources: true, + cpu: true, + gpu: true, + memory: true, + zone: { + select: { + id: true, + slug: true, + argocdUrl: true, + label: true, + }, + }, +} +export function getHookProjectInfos(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: { + members: { include: { user: true }, where: { user: { type: 'human' } } }, + clusters: { select: clusterInfosSelect }, + environments: { + include: { + stage: true, + cluster: { + select: clusterInfosSelect, + }, + }, + }, + repositories: true, + plugins: { + select: { + key: true, + pluginName: true, + value: true, + }, + }, + owner: true, + roles: true, + }, + }) +} + +// CREATE +interface CreateProjectParams { + name: Project['name'] + description?: Project['description'] + ownerId: User['id'] + slug: Project['slug'] + limitless: boolean + hprodCpu: number + hprodGpu: number + hprodMemory: number + prodCpu: number + prodGpu: number + prodMemory: number +} + +export function initializeProject(params: CreateProjectParams) { + return prisma.project.create({ + data: { + description: params.description ?? '', + status: ProjectStatus.created, + locked: false, + roles: { + create: [ + { + name: 'Administrateur', + permissions: PROJECT_PERMS.MANAGE, + position: 0, + oidcGroup: `/${params.slug}/console/admin`, + type: 'system:managed', + }, + { + name: 'DevOps', + permissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 1, + oidcGroup: `/${params.slug}/console/devops`, + type: 'system:managed', + }, + { + name: 'Développeur', + permissions: PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 2, + oidcGroup: `/${params.slug}/console/developer`, + type: 'system:managed', + }, + { + name: 'Sécurité', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 3, + oidcGroup: `/${params.slug}/console/security`, + type: 'system:managed', + }, + { + name: 'Lecture seule', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 4, + oidcGroup: `/${params.slug}/console/readonly`, + type: 'system:managed', + }, + ], + }, + ...params, + }, + }) +} + +// UPDATE +export function lockProject(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { locked: true }, + }) +} + +export function updateProjectCreated(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { + status: ProjectStatus.created, + lastSuccessProvisionningVersion: appVersion, + }, + include: baseProjectIncludes, + }) +} + +export function updateProjectFailed(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { status: ProjectStatus.failed }, + include: baseProjectIncludes, + }) +} + +export function updateProjectWarning(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { status: ProjectStatus.warning }, + include: baseProjectIncludes, + }) +} + +export function addUserToProject({ project, user }: { project: Project, user: User }) { + return prisma.projectMembers.create({ + data: { + userId: user.id, + projectId: project.id, + }, + }) +} + +export function removeUserFromProject({ projectId, userId }: { projectId: Project['id'], userId: User['id'] }) { + return prisma.projectMembers.delete({ + where: { + projectId_userId: { + projectId, + userId, + }, + }, + }) +} + +export async function archiveProject(id: Project['id']) { + const project = await prisma.project.findUnique({ + where: { id }, + select: { name: true, slug: true }, + }) + return prisma.project.update({ + where: { id }, + data: { + name: `${project?.name}_${Date.now()}_archived`, + slug: `${project?.slug}_${Date.now()}_archived`, + status: ProjectStatus.archived, + locked: true, + }, + include: baseProjectIncludes, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/project/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/project/router.spec.ts new file mode 100644 index 0000000000..8c5a2ed820 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project/router.spec.ts @@ -0,0 +1,473 @@ +import type { ProjectV2 } from '@cpn-console/shared' +import type { UserDetails } from '../../types/index.js' +import { ADMIN_PERMS, PROJECT_PERMS, projectContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getRandomRequestor, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listProjects') +const businessCreateMock = vi.spyOn(business, 'createProject') +const businessUpdateMock = vi.spyOn(business, 'updateProject') +const businessDeleteMock = vi.spyOn(business, 'archiveProject') +const businessSyncMock = vi.spyOn(business, 'replayHooks') +const bulkActionProjectMock = vi.spyOn(business, 'bulkActionProject') +const businessGetSecretsMock = vi.spyOn(business, 'getProjectSecrets') +const businessGenerateDataMock = vi.spyOn(business, 'generateProjectsData') + +describe('test projectContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + const projectOwner: ProjectV2['owner'] = { + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + id: faker.string.uuid(), + type: 'human', + } + const projectId = faker.string.uuid() + const project: Omit = { + name: faker.string.alpha({ length: 10, casing: 'lower' }), + slug: faker.string.alpha({ length: 5, casing: 'lower' }), + description: faker.string.alpha({ length: 5 }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + clusterIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + locked: false, + status: 'created', + everyonePerms: '0', + members: [], + owner: projectOwner, + ownerId: projectOwner.id, + roles: [], + lastSuccessProvisionningVersion: null, + } + describe('check unauthorized user on project behaviour', () => { + // UPDATE + it('on Update', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + it('on Update without enough perms', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // REPLAY + it('on replay', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(businessSyncMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // SECRETS + it('on see secret', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(businessGetSecretsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // ARCHIVE + it('on archive', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', projectId)) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + }) + describe('listProjects', () => { + it('should return list of projects', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + const projects: any[] = [] + businessListMock.mockResolvedValueOnce(projects) + const response = await app.inject() + .get(projectContract.listProjects.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(projects) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin with "all" filter', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + const projects: any[] = [] + businessListMock.mockResolvedValueOnce(projects) + + const response = await app.inject() + .get(projectContract.listProjects.path) + .query({ filter: 'all' }) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return list of projects for admin', async () => { + const projects: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(projects) + + const response = await app.inject() + .get(projectContract.listProjects.path) + .query({ filter: 'all' }) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createProject', () => { + it('should create and return project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce({ id: projectId, ...project }) + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: projectId, ...project }) + expect(response.statusCode).toEqual(201) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateProject', () => { + const projectUpdated: Partial = { description: faker.string.alpha({ length: 5 }) } + + it('should update and return project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should not update ownerId if not permitted', async () => { + const userDetails = getRandomRequestor() + const projectPerms = getProjectMockInfos({ projectOwnerId: faker.string.uuid(), projectPermissions: PROJECT_PERMS.MANAGE }) + const projectUpdated = { ownerId: faker.string.uuid(), description: faker.lorem.words() } + const user = getUserMockInfos(0n, userDetails as UserDetails, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith({ description: projectUpdated.description }, projectId, user.user, expect.any(String)) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should update ownerId and return project', async () => { + const requestor = getRandomRequestor() + const projectPerms = getProjectMockInfos({ projectOwnerId: requestor.id, projectPermissions: PROJECT_PERMS.MANAGE }) + const projectUpdated = { ownerId: faker.string.uuid(), description: faker.lorem.words() } + const user = getUserMockInfos(0n, requestor as UserDetails, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith(projectUpdated, projectId, user.user, expect.any(String)) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(400) + }) + }) + + describe('archiveProject', () => { + it('should archive project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return projects data for admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getProjectSecrets', () => { + it('should return project secrets for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const secrets = {} + businessGetSecretsMock.mockResolvedValueOnce(secrets) + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(businessGetSecretsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(secrets) + expect(response.statusCode).toEqual(200) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetSecretsMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized access to secrets', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('replayHooksForProject', () => { + it('should replay hooks for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(businessSyncMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized access to replay hooks', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getProjectsData', () => { + it('should return projects data for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + const data = '' + businessGenerateDataMock.mockResolvedValueOnce(data) + const response = await app.inject() + .get(projectContract.getProjectsData.path) + .end() + + expect(businessGenerateDataMock).toHaveBeenCalledTimes(1) + expect(response.body).toEqual(data) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin user', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectsData.path) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('bulkActionProject', () => { + it('should executebulk for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + const response = await app.inject() + .post(projectContract.bulkActionProject.path) + .body({ action: 'lock', projectIds: [projectId] }) + .end() + + expect(response.json()).toBeNull() + expect(bulkActionProjectMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(202) + }) + + it('should return 403 for unauthorized access to bulk update', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const response = await app.inject() + .post(projectContract.bulkActionProject.path) + .body({ action: 'lock', projectIds: [projectId] }) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/project/router.ts b/.jjconflict-side-0/apps/server/src/resources/project/router.ts new file mode 100644 index 0000000000..b969303ed2 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/project/router.ts @@ -0,0 +1,186 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, ProjectAuthorized, projectContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, NotFound404, Unauthorized401 } from '@/utils/errors.js' +import { + archiveProject, + bulkActionProject, + createProject, + generateProjectsData, + getProject, + getProjectSecrets, + listProjects, + replayHooks, + updateProject, +} from './business.js' + +export function projectRouter() { + return serverInstance.router(projectContract, { + + // Récupérer des projets + listProjects: async ({ request: req, query }) => { + const { adminPermissions, user } = await authUser(req) + let body: AsyncReturnType = [] + + if (adminPermissions && !user) { // c'est donc un compte de service + query.filter = 'all' + } + if (query.filter === 'all' && !AdminAuthorized.Manage(adminPermissions)) { + return new Forbidden403('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + + body = await listProjects( + query, + user?.id, + ) + + return { + status: 200, + body, + } + }, + + // Récupérer les secrets d'un projet + getProjectSecrets: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.SeeSecrets(perms)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await getProjectSecrets(projectId) + + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Créer un projet + createProject: async ({ request: req, body: data }) => { + const perms = await authUser(req) + if (perms.user?.type !== 'human') return new Unauthorized401('Cannot find requestor in database') + if (!AdminAuthorized.ManageProjects(perms.adminPermissions)) return new Forbidden403() + + const body = await createProject(data, perms.user, req.id) + + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Récuperer un seul projet + getProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.projectId) return new NotFound404() + if (!perms.projectPermissions && !AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new NotFound404() + + const body = await getProject(projectId) + + return { + status: 200, + body, + } + }, + + // Mettre à jour un projet + updateProject: async ({ request: req, params, body: data }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!ProjectAuthorized.Manage(perms)) return new Forbidden403() + + const isAdmin = AdminAuthorized.Manage(perms.adminPermissions) + const isOwner = perms.projectOwnerId === perms.user.id + if (!isAdmin) { + delete data.locked + if (!isOwner) delete data.ownerId // impossible de toucher à cette clé + } + if (perms.projectLocked) { + if (!isAdmin) return new Forbidden403('Le projet est verrouillé') + if (data.locked !== false) return new Forbidden403('Veuillez déverrouiler le projet pour le mettre à jour') + } + + const body = await updateProject(data, projectId, perms.user, req.id) + + if (body instanceof ErrorResType) return body + return { + status: 200, + body, + } + }, + + // Reprovisionner un projet + replayHooksForProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ReplayHooks(perms)) return new Forbidden403() + + const body = await replayHooks({ + projectId, + userId: perms.user?.id, + requestId: req.id, + }) + + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + + // Archiver un projet + archiveProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!ProjectAuthorized.Manage(perms)) return new Forbidden403() + + const body = await archiveProject(projectId, perms.user, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + // Récupérer les données de tous les projets pour export + getProjectsData: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + const body = await generateProjectsData() + + return { + status: 200, + body, + } + }, + + bulkActionProject: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + + await bulkActionProject(body, perms.user, req.id) + + return { + status: 202, + body: null, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/queries-index.ts b/.jjconflict-side-0/apps/server/src/resources/queries-index.ts new file mode 100644 index 0000000000..a2c9787d2a --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/queries-index.ts @@ -0,0 +1,14 @@ +export * from '@/resources/admin-role/queries.js' +export * from '@/resources/cluster/queries.js' +export * from '@/resources/environment/queries.js' +export * from '@/resources/log/queries.js' +export * from '@/resources/project-member/queries.js' +export * from '@/resources/project-role/queries.js' +export * from '@/resources/project-service/queries.js' +export * from '@/resources/project/queries.js' +export * from '@/resources/repository/queries.js' +export * from '@/resources/service-chain/queries.js' +export * from '@/resources/stage/queries.js' +export * from '@/resources/system/settings/queries.js' +export * from '@/resources/user/queries.js' +export * from '@/resources/zone/queries.js' diff --git a/.jjconflict-side-0/apps/server/src/resources/repository/business.ts b/.jjconflict-side-0/apps/server/src/resources/repository/business.ts new file mode 100644 index 0000000000..473c9c73a7 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/repository/business.ts @@ -0,0 +1,115 @@ +import type { CreateRepositoryBody, UpdateRepositoryBody } from '@cpn-console/shared' +import type { Project, Repository, User } from '@prisma/client' +import { addLogs, deleteRepository as deleteRepositoryQuery, getProjectInfosAndRepos, getProjectRepositories as getProjectRepositoriesQuery, initializeRepository, updateRepository as updateRepositoryQuery } from '@/resources/queries-index.js' +import { BadRequest400, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function getProjectRepositories(projectId: Project['id']) { + return getProjectRepositoriesQuery(projectId) +} + +export async function syncRepository({ + repositoryId, + userId, + syncAllBranches, + branchName, + requestId, +}: { + repositoryId: Repository['id'] + userId: User['id'] + syncAllBranches: boolean + branchName?: string + requestId: string +}) { + const hookReply = await hook.misc.syncRepository(repositoryId, { syncAllBranches, branchName }) + await addLogs({ action: 'Sync Repository', data: hookReply, userId, requestId, projectId: hookReply.args.id }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la synchronisation du dépôt') + } + return null +} + +export async function createRepository({ + data, + userId, + requestId, +}: { + data: CreateRepositoryBody + userId: User['id'] + requestId: string +}) { + const project = await getProjectInfosAndRepos(data.projectId) + + if (project.repositories?.find(repo => repo.internalRepoName === data.internalRepoName)) return new BadRequest400(`Le nom du dépôt interne ${data.internalRepoName} existe déjà en base pour ce projet`) + const dbData = { ...data, isInfra: !!data.isInfra, isPrivate: !!data.isPrivate } + delete dbData.externalToken + + const repo = await initializeRepository(dbData) + const { results } = await hook.project.upsert(project.id, data.isPrivate + ? { + [repo.internalRepoName]: { + token: data.externalToken ?? '', + username: data.externalUserName ?? '', + }, + } + : undefined) + await addLogs({ action: 'Create Repository', data: results, userId, requestId, projectId: repo.projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services lors de la création du dépôt') + } + + if (data.externalRepoUrl) { + await syncRepository({ repositoryId: repo.id, requestId, syncAllBranches: true, userId }) + } + return repo +} + +export async function updateRepository({ + repositoryId, + data, + userId, + requestId, +}: { + repositoryId: Repository['id'] + data: Partial + userId: User['id'] + requestId: string +}) { + const dbData = { ...data } + delete dbData.externalToken + const repo = await updateRepositoryQuery(repositoryId, dbData) + + const { results } = await hook.project.upsert(repo.projectId, { + [repo.internalRepoName]: { + username: repo.externalUserName ?? '', + token: data.externalToken ?? '', + }, + }) + await addLogs({ action: 'Update Repository', data: results, userId, requestId, projectId: repo.projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services à la mise à jour du dépôt') + } + + return repo +} + +export async function deleteRepository({ + repositoryId, + userId, + requestId, + projectId, +}: { + repositoryId: Repository['id'] + userId: User['id'] + requestId: string + projectId: Project['id'] +}) { + await deleteRepositoryQuery(repositoryId) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Delete Repository', data: results, userId, requestId, projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services à la suppression du dépôt') + } + return null +} diff --git a/.jjconflict-side-0/apps/server/src/resources/repository/queries.ts b/.jjconflict-side-0/apps/server/src/resources/repository/queries.ts new file mode 100644 index 0000000000..277e7af602 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/repository/queries.ts @@ -0,0 +1,62 @@ +import type { Project, Repository } from '@prisma/client' +import prisma from '@/prisma.js' + +// SELECT +export function getRepositoryById(id: Repository['id']) { + return prisma.repository.findUniqueOrThrow({ where: { id } }) +} + +export function getProjectRepositories(projectId: Project['id']) { + return prisma.repository.findMany({ where: { projectId } }) +} + +// CREATE +type RepositoryCreate = Pick + & Partial> + +export function initializeRepository({ projectId, internalRepoName, externalRepoUrl, isInfra, isPrivate, externalUserName, deployRevision, deployPath, helmValuesFiles }: RepositoryCreate) { + return prisma.repository.create({ + data: { + projectId, + internalRepoName, + externalRepoUrl, + externalUserName, + isInfra, + isPrivate, + deployRevision, + deployPath, + helmValuesFiles, + }, + }) +} + +export function getHookRepository(id: Repository['id']) { + return prisma.repository.findUniqueOrThrow({ + where: { + id, + }, + include: { + project: true, + }, + }) +} + +// UPDATE +export function updateRepository(id: Repository['id'], infos: Partial) { + return prisma.repository.update({ where: { id }, data: { ...infos } }) +} + +// DELETE +export async function deleteRepository(id: Repository['id']) { + const doesRepoExist = await getRepositoryById(id) + if (!doesRepoExist) throw new Error('Le dépôt interne demandé n\'existe pas en base pour ce projet') + return prisma.repository.delete({ where: { id } }) +} + +export function deleteAllRepositoryForProject(id: Project['id']) { + return prisma.repository.deleteMany({ where: { projectId: id } }) +} + +export function _createRepository(data: Parameters[0]['create']) { + return prisma.repository.upsert({ create: data, update: data, where: { id: data.id } }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/repository/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/repository/router.spec.ts new file mode 100644 index 0000000000..ae08505be0 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/repository/router.spec.ts @@ -0,0 +1,404 @@ +import { PROJECT_PERMS, repositoryContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { atDates, getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessCreateMock = vi.spyOn(business, 'createRepository') +const businessUpdateMock = vi.spyOn(business, 'updateRepository') +const businessDeleteMock = vi.spyOn(business, 'deleteRepository') +const businessSyncMock = vi.spyOn(business, 'syncRepository') +const businessGetProjectRepositoriesMock = vi.spyOn(business, 'getProjectRepositories') + +describe('repositoryRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const repositoryId = faker.string.uuid() + const repositoryData = { + projectId, + externalRepoUrl: `${faker.internet.url()}.git`, + isPrivate: true, + externalToken: faker.string.alpha(), + externalUserName: faker.internet.username(), + isInfra: false, + internalRepoName: faker.string.alpha({ length: 5, casing: 'lower' }), + } + + describe('listRepositories', () => { + it('should return repositories for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetProjectRepositoriesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(repositoryContract.listRepositories.path) + .query({ projectId }) + .end() + + expect(businessGetProjectRepositoriesMock).toHaveBeenCalledWith(projectId) + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.REPLAY_HOOKS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(repositoryContract.listRepositories.path) + .query({ projectId }) + .end() + + expect(businessGetProjectRepositoriesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('syncRepository', () => { + it('should synchronize repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(204) + expect(businessSyncMock).toHaveBeenCalledWith({ repositoryId, userId: user.user.id, branchName: 'main', requestId: expect.any(String), syncAllBranches: false }) + }) + + it('should return 403 for forbidden sync attempt', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('createRepository', () => { + it('should create repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...atDates }) + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(201) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should return 403 for insuficient permissions', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateRepository', () => { + const repoUpdateData = { isInfra: true } + it('should update repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...repoUpdateData, ...atDates }) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(200) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData, ...repoUpdateData }) + }) + + it('should update repository and drop creds if is not private', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const repoUpdateData = { isPrivate: false, externalUserName: 'test' } + businessUpdateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...repoUpdateData, ...atDates }) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith({ data: { isPrivate: false }, repositoryId, requestId: expect.any(String), userId: user.user.id }) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData, ...repoUpdateData }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not enough permissions', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(400) + }) + // TODO add tests about filtering + }) + + describe('deleteRepository', () => { + it('should delete repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not enough privilege', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/repository/router.ts b/.jjconflict-side-0/apps/server/src/resources/repository/router.ts new file mode 100644 index 0000000000..a9e93209ee --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/repository/router.ts @@ -0,0 +1,133 @@ +import { fakeToken, ProjectAuthorized, repositoryContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { filterObjectByKeys } from '@/utils/queries-tools.js' +import { + createRepository, + deleteRepository, + getProjectRepositories, + syncRepository, + updateRepository, +} from './business.js' + +export function repositoryRouter() { + return serverInstance.router(repositoryContract, { + // Récupérer tous les repositories d'un projet + listRepositories: async ({ request: req, query }) => { + const projectId = query.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListRepositories(perms)) return new Forbidden403() + const body = await getProjectRepositories(projectId) + + return { + status: 200, + body, + } + }, + + // Synchroniser un repository + syncRepository: async ({ request: req, params, body }) => { + const { repositoryId } = params + const perms = await authUser(req, { repositoryId }) + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const { syncAllBranches, branchName } = body + + const resBody = await syncRepository({ repositoryId, userId: perms.user.id, branchName, requestId: req.id, syncAllBranches }) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 204, + body: resBody, + } + }, + + // Créer un repository + createRepository: async ({ request: req, body: data }) => { + const projectId = data.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await createRepository({ data, userId: perms.user.id, requestId: req.id }) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Mettre à jour un repository + updateRepository: async ({ request: req, params, body }) => { + const repositoryId = params.repositoryId + const perms = await authUser(req, { repositoryId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const keysAllowedForUpdate = [ + 'externalRepoUrl', + 'isPrivate', + 'externalToken', + 'externalUserName', + 'isInfra', + 'deployRevision', + 'deployPath', + 'helmValuesFiles', + ] + const data = filterObjectByKeys(body, keysAllowedForUpdate) + + if (data.externalToken === fakeToken) { + delete data.externalToken + } + + if (data.isPrivate === false) { + delete data.externalToken + delete data.externalUserName + } + + const resBody = await updateRepository({ repositoryId, data, userId: perms.user.id, requestId: req.id }) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + // Supprimer un repository + deleteRepository: async ({ request: req, params }) => { + const repositoryId = params.repositoryId + const perms = await authUser(req, { repositoryId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await deleteRepository({ + repositoryId, + userId: perms.user.id, + requestId: req.id, + projectId: perms.projectId, + }) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/service-chain/business.spec.ts b/.jjconflict-side-0/apps/server/src/resources/service-chain/business.spec.ts new file mode 100644 index 0000000000..a3903e6eff --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/service-chain/business.spec.ts @@ -0,0 +1,171 @@ +import type { + ServiceChain, + ServiceChainDetails, + ServiceChainFlows, +} from '@cpn-console/shared' +import type { Mock } from 'vitest' +import { + serviceChainEnvironmentEnum, + serviceChainFlowStateEnum, + serviceChainLocationEnum, + serviceChainNetworkEnum, + serviceChainStateEnum, +} from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import axios from 'axios' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + getServiceChainDetails, + getServiceChainFlows, + listServiceChains, + retryServiceChain, + validateServiceChain, +} from './business.ts' + +vi.mock('axios') + +let serviceChain: ServiceChain +let serviceChainDetails: ServiceChainDetails +let serviceChainFlows: ServiceChainFlows + +describe('test ServiceChain business logic', () => { + beforeEach(() => { + serviceChain = { + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + } + + serviceChainDetails = { + ...serviceChain, + validationId: faker.string.uuid(), + validatedBy: faker.helpers.maybe(() => faker.string.uuid()) || null, + ref: faker.string.uuid(), + location: faker.helpers.arrayElement(serviceChainLocationEnum), + targetAddress: faker.internet.ipv4(), + projectId: faker.string.uuid(), + env: faker.helpers.arrayElement(serviceChainEnvironmentEnum), + subjectAlternativeName: faker.helpers.uniqueArray( + faker.internet.domainName, + 3, + ), + redirect: faker.datatype.boolean(), + antivirus: + faker.helpers.maybe(() => ({ + maxFileSize: faker.number.int(), + })) || null, // undefined is not wanted here + websocket: faker.datatype.boolean(), + ipWhiteList: faker.helpers + .uniqueArray(faker.internet.ipv4, 5) + .map(e => `${e}/32`), // We want a CIDR here + sslOutgoing: faker.datatype.boolean(), + } + + serviceChainFlows = { + reserve_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + create_cert: faker.helpers.maybe(() => ({ + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + })) || null, + call_exec: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + activate_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + dns_request: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + } + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('listServiceChains', () => { + it('should return a list of service chains', async () => { + const input = [serviceChain]; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await listServiceChains() + + expect(result).toStrictEqual(input) + }) + }) + + describe('getServiceChainDetails', () => { + it('should return a service chain details', async () => { + const input = serviceChainDetails; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await getServiceChainDetails(faker.string.uuid()) + + expect(result).toStrictEqual(input) + }) + }) + + describe('retryServiceChain', () => { + it('should trigger a service chain retry attempt', async () => { + const input = {}; + (axios.create as Mock).mockReturnValue({ + post: () => ({ data: input }), + }) + + const result = await retryServiceChain(faker.string.uuid()) + + expect(result.data).toStrictEqual(input) + }) + }) + + describe('validateServiceChain', () => { + it('should trigger a service chain validate attempt', async () => { + const input = {}; + (axios.create as Mock).mockReturnValue({ + post: () => ({ data: input }), + }) + + const result = await validateServiceChain(faker.string.uuid()) + + expect(result.data).toStrictEqual(input) + }) + }) + + describe('getServiceChainFlows', () => { + it('should return a service chain flows', async () => { + const input = serviceChainFlows; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await getServiceChainFlows(faker.string.uuid()) + + expect(result).toStrictEqual(input) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/service-chain/business.ts b/.jjconflict-side-0/apps/server/src/resources/service-chain/business.ts new file mode 100644 index 0000000000..43ffb986e6 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/service-chain/business.ts @@ -0,0 +1,27 @@ +import { + getServiceChainDetails as getServiceChainDetailsQuery, + getServiceChainFlows as getServiceChainFlowsQuery, + listServiceChains as listServiceChainsQuery, + retryServiceChain as retryServiceChainQuery, + validateServiceChain as validateServiceChainQuery, +} from '@/resources/queries-index.js' + +export async function listServiceChains() { + return listServiceChainsQuery() +} + +export async function getServiceChainDetails(serviceChainId: string) { + return getServiceChainDetailsQuery(serviceChainId) +} + +export async function retryServiceChain(serviceChainId: string) { + return retryServiceChainQuery(serviceChainId) +} + +export async function validateServiceChain(validationId: string) { + return validateServiceChainQuery(validationId) +} + +export async function getServiceChainFlows(serviceChainId: string) { + return getServiceChainFlowsQuery(serviceChainId) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/service-chain/queries.ts b/.jjconflict-side-0/apps/server/src/resources/service-chain/queries.ts new file mode 100644 index 0000000000..0207266c8a --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/service-chain/queries.ts @@ -0,0 +1,59 @@ +import type { ServiceChain } from '@cpn-console/shared' +import https from 'node:https' +import { + + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainListSchema, +} from '@cpn-console/shared' +import axios from 'axios' + +const openCDSEnvVar = 'OPENCDS_URL' +const openCDSTargetURL = process.env[openCDSEnvVar] +const openCDSDisabledErrorMessage = `OpenCDS is disabled, please set ${openCDSEnvVar} in your relevant .env file. See .env-example` + +function getClient() { + if (!openCDSTargetURL) { + throw new Error(openCDSDisabledErrorMessage) + } + return axios.create({ + baseURL: openCDSTargetURL, + httpsAgent: new https.Agent({ + rejectUnauthorized: + // We want it to be `false` only if it has explicitly + // been stated as "false" in the env vars + process.env.OPENCDS_API_TLS_REJECT_UNAUTHORIZED !== 'false', + }), + headers: { + 'X-API-Key': process.env.OPENCDS_API_TOKEN, + }, + }) +} + +export async function listServiceChains() { + return ServiceChainListSchema.parse( + (await getClient().get(`/requests`)).data, + ) +} + +export async function getServiceChainDetails( + serviceChainId: ServiceChain['id'], +) { + return ServiceChainDetailsSchema.parse( + (await getClient().get(`/requests/${serviceChainId}`)).data, + ) +} + +export async function retryServiceChain(serviceChainId: ServiceChain['id']) { + return await getClient().post(`/requests/${serviceChainId}/retry`) +} + +export async function validateServiceChain(validationId: string) { + return await getClient().post(`/validate/${validationId}`) +} + +export async function getServiceChainFlows(serviceChainId: ServiceChain['id']) { + return ServiceChainFlowsSchema.parse( + (await getClient().get(`/requests/${serviceChainId}/flows`)).data, + ) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/service-chain/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/service-chain/router.spec.ts new file mode 100644 index 0000000000..c69c14edff --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/service-chain/router.spec.ts @@ -0,0 +1,307 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import { + ADMIN_PERMS, + serviceChainContract, + ServiceChainDetailsSchema, + serviceChainEnvironmentEnum, + ServiceChainFlowsSchema, + serviceChainFlowStateEnum, + ServiceChainListSchema, + serviceChainLocationEnum, + serviceChainNetworkEnum, + serviceChainStateEnum, +} from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock( + 'fastify-keycloak-adapter', + (await import('../../utils/mocks.js')).mockSessionPlugin, +) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListServiceChainsMock = vi.spyOn(business, 'listServiceChains') +const businessGetServiceChainDetailsMock = vi.spyOn(business, 'getServiceChainDetails') +const businessRetryServiceChainMock = vi.spyOn(business, 'retryServiceChain') +const businessValidateServiceChainMock = vi.spyOn(business, 'validateServiceChain') +const businessGetServiceChainsFlowsMock = vi.spyOn(business, 'getServiceChainFlows') + +describe('test ServiceChainContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listServiceChains', () => { + it('as non admin', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + businessListServiceChainsMock.mockResolvedValueOnce([]) + const response = await app + .inject() + .get(serviceChainContract.listServiceChains.path) + .end() + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('as admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const serviceChainList = faker.helpers.multiple(() => ({ + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + })) + + authUserMock.mockResolvedValueOnce(user) + + businessListServiceChainsMock.mockResolvedValueOnce(serviceChainList) + const response = await app + .inject() + .get(serviceChainContract.listServiceChains.path) + .end() + + expect(businessListServiceChainsMock).toHaveBeenCalledWith() + + expect(ServiceChainListSchema.parse(response.json())).toStrictEqual( + serviceChainList, + ) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getServiceChainDetails', () => { + it('should return serviceChain details', async () => { + const serviceChainDetails: ServiceChainDetails = { + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + validationId: faker.string.uuid(), + validatedBy: faker.string.uuid(), + ref: faker.string.uuid(), + location: faker.helpers.arrayElement(serviceChainLocationEnum), + targetAddress: faker.internet.ipv4(), + projectId: faker.string.uuid(), + env: faker.helpers.arrayElement(serviceChainEnvironmentEnum), + subjectAlternativeName: faker.helpers.uniqueArray( + faker.internet.domainName, + 3, + ), + redirect: faker.datatype.boolean(), + antivirus: + faker.helpers.maybe(() => ({ + maxFileSize: faker.number.int(), + })) || null, // undefined is not wanted here + websocket: faker.datatype.boolean(), + ipWhiteList: faker.helpers + .uniqueArray(faker.internet.ipv4, 5) + .map(e => `${e}/32`), // We want a CIDR here + sslOutgoing: faker.datatype.boolean(), + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessGetServiceChainDetailsMock.mockResolvedValueOnce(serviceChainDetails) + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainDetails.path.replace( + ':serviceChainId', + serviceChainDetails.id, + ), + ) + .end() + + expect(ServiceChainDetailsSchema.parse(response.json())).toEqual( + serviceChainDetails, + ) + expect(response.statusCode).toEqual(200) + expect(businessGetServiceChainDetailsMock).toHaveBeenCalledTimes(1) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainDetails.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessGetServiceChainDetailsMock).toHaveBeenCalledTimes(0) + }) + }) + + describe('retryServiceChain', () => { + it('should return 204', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessRetryServiceChainMock.mockResolvedValueOnce({ + status: 204, + body: undefined, + }) + const response = await app + .inject() + .post( + serviceChainContract.retryServiceChain.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.body).toEqual('') + expect(businessRetryServiceChainMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(204) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .post( + serviceChainContract.retryServiceChain.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessRetryServiceChainMock).toHaveBeenCalledTimes(0) + }) + }) + + describe('validateServiceChain', () => { + it('should return 204', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessValidateServiceChainMock.mockResolvedValueOnce({ + status: 204, + body: undefined, + }) + const response = await app + .inject() + .post( + serviceChainContract.validateServiceChain.path.replace( + ':validationId', + faker.string.uuid(), + ), + ) + .end() + + expect(businessValidateServiceChainMock).toHaveBeenCalledTimes(1) + expect(response.body).toEqual('') + expect(response.statusCode).toEqual(204) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .post( + serviceChainContract.validateServiceChain.path.replace( + ':validationId', + faker.string.uuid(), + ), + ) + .end() + + expect(businessValidateServiceChainMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getServiceChainFlows', () => { + it('should return serviceChain flows', async () => { + const serviceChainFlows: ServiceChainFlows = { + reserve_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + create_cert: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + call_exec: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + activate_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + dns_request: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessGetServiceChainsFlowsMock.mockResolvedValueOnce(serviceChainFlows) + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainFlows.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(ServiceChainFlowsSchema.parse(response.json())).toEqual( + serviceChainFlows, + ) + expect(response.statusCode).toEqual(200) + expect(businessGetServiceChainsFlowsMock).toHaveBeenCalledTimes(1) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainFlows.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessGetServiceChainsFlowsMock).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/service-chain/router.ts b/.jjconflict-side-0/apps/server/src/resources/service-chain/router.ts new file mode 100644 index 0000000000..5888418cfe --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/service-chain/router.ts @@ -0,0 +1,94 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, serviceChainContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { + getServiceChainDetails as getServiceChainDetailsBusiness, + getServiceChainFlows as getServiceChainFlowsBusiness, + listServiceChains as listServiceChainsBusiness, + retryServiceChain as retryServiceChainBusiness, + validateServiceChain as validateServiceChainBusiness, +} from './business.js' +import '@/types/index.js' + +export function serviceChainRouter() { + return serverInstance.router(serviceChainContract, { + listServiceChains: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + let body: AsyncReturnType = [] + if (AdminAuthorized.ListSystem(adminPermissions)) { + body = await listServiceChainsBusiness() + } + + return { + status: 200, + body, + } + }, + + getServiceChainDetails: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + const serviceChainDetails + = await getServiceChainDetailsBusiness(serviceChainId) + + return { + status: 200, + body: serviceChainDetails, + } + }, + + retryServiceChain: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + await retryServiceChainBusiness(serviceChainId) + + return { + status: 204, + body: null, + } + }, + + validateServiceChain: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.validationId + await validateServiceChainBusiness(serviceChainId) + + return { + status: 204, + body: null, + } + }, + + getServiceChainFlows: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + const serviceChainFlows + = await getServiceChainFlowsBusiness(serviceChainId) + + return { + status: 200, + body: serviceChainFlows, + } + }, + + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/service-monitor/business.ts b/.jjconflict-side-0/apps/server/src/resources/service-monitor/business.ts new file mode 100644 index 0000000000..fa61d5a6de --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/service-monitor/business.ts @@ -0,0 +1,9 @@ +import { services } from '@cpn-console/hooks' + +export function checkServicesHealth() { + return services.getStatus() +} + +export async function refreshServicesHealth() { + return Promise.all(services.refreshStatus()) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/service-monitor/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/service-monitor/router.spec.ts new file mode 100644 index 0000000000..898ed61e5a --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/service-monitor/router.spec.ts @@ -0,0 +1,78 @@ +import type { ServiceStatus } from '@cpn-console/hooks' +import { ADMIN_PERMS, MonitorStatus, serviceContract } from '@cpn-console/shared' +import { describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +const authUserMock = vi.spyOn(utilsController, 'authUser') + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const businessCheckMock = vi.spyOn(business, 'checkServicesHealth') +const businessRefreshMock = vi.spyOn(business, 'refreshServicesHealth') + +describe('test serviceContract', () => { + const services: ServiceStatus[] = [{ interval: 1, lastUpdateTimestamp: 1, message: 'OK', name: 'A service', status: MonitorStatus.OK }] + const servicesComplete: ServiceStatus[] = [{ cause: 'error', interval: 1, lastUpdateTimestamp: 1, message: 'OK', name: 'A service', status: MonitorStatus.OK }] + + it('should return complete services, with cause', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(servicesComplete) + expect(response.statusCode).toEqual(200) + }) + + it('should not return complete services, forbidden', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return services', async () => { + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(services) + expect(response.statusCode).toEqual(200) + }) + + it('should refresh services', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessRefreshMock.mockResolvedValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(servicesComplete) + expect(response.statusCode).toEqual(200) + }) + + it('should refresh services, cause forbidden', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + businessRefreshMock.mockResolvedValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.statusCode).toEqual(403) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/service-monitor/router.ts b/.jjconflict-side-0/apps/server/src/resources/service-monitor/router.ts new file mode 100644 index 0000000000..ace5831c3e --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/service-monitor/router.ts @@ -0,0 +1,44 @@ +import { AdminAuthorized, serviceContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { checkServicesHealth, refreshServicesHealth } from './business.js' + +export function serviceMonitorRouter() { + return serverInstance.router(serviceContract, { + getServiceHealth: async () => { + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + + getCompleteServiceHealth: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ListSystem(adminPermissions)) return new Forbidden403() + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + + refreshServiceHealth: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageSystem(adminPermissions)) return new Forbidden403() + + await refreshServicesHealth() + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/stage/business.spec.ts b/.jjconflict-side-0/apps/server/src/resources/stage/business.spec.ts new file mode 100644 index 0000000000..ba43c2cb75 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/stage/business.spec.ts @@ -0,0 +1,113 @@ +import type { Environment, Stage } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { BadRequest400, NotFound404 } from '../../utils/errors.ts' +import { createStage, deleteStage, getStageAssociatedEnvironments, listStages, updateStage } from './business.ts' + +describe('test stage busines logic', () => { + let stage: Stage + beforeEach(() => { + vi.resetAllMocks() + stage = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + } + }) + describe('createStage', () => { + it('should create a stage', async () => { + prisma.stage.findUnique.mockResolvedValue(null) + prisma.stage.create.mockResolvedValue({ id: stage.id } as Stage) + await createStage({ name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(1) + }) + it('should not create a stage, name conflict', async () => { + prisma.stage.findUnique.mockResolvedValue({ id: stage.id } as Stage) + const response = await createStage({ name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) + + describe('updateStage', () => { + it('should update a stage', async () => { + const dbClusters = [{ id: faker.string.uuid() }] + const newClusters = [faker.string.uuid()] + prisma.stage.findUnique.mockResolvedValue({ ...stage, clusters: dbClusters } as Stage) + prisma.stage.update.mockResolvedValue({ id: stage.id } as Stage) + const response = await updateStage(stage.id, { name: stage.name, clusterIds: newClusters }) + expect(prisma.cluster.update).toHaveBeenCalledTimes(1) + expect(prisma.cluster.update).toHaveBeenCalledWith({ where: { id: dbClusters[0].id }, data: { + stages: { + disconnect: { + id: stage.id, + }, + }, + } }) + expect(prisma.stage.update).toHaveBeenCalledTimes(1) + expect(prisma.stage.update).toHaveBeenCalledWith({ where: { id: stage.id }, data: { + clusters: { + connect: [{ + id: newClusters[0], + }], + }, + } }) + expect(response.clusterIds).toBe(newClusters) + }) + it('should do nothing', async () => { + prisma.stage.findUnique.mockResolvedValue({ ...stage, clusters: [] } as Stage) + await updateStage(stage.id, { clusterIds: [], name: stage.name }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + }) + it('should return not found', async () => { + prisma.stage.findUnique.mockResolvedValue(null) + const response = await updateStage(stage.id, { name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + expect(response).instanceOf(NotFound404) + }) + }) + + describe('deleteStage', () => { + it('should delete a stage', async () => { + prisma.environment.findFirst.mockResolvedValue(null) + prisma.stage.delete.mockResolvedValue({ id: stage.id } as Stage) + await deleteStage(stage.id) + expect(prisma.stage.delete).toHaveBeenCalledTimes(1) + }) + it('should not delete a stage, environment attached', async () => { + prisma.environment.findFirst.mockResolvedValue({ id: faker.string.uuid() } as Environment) + const response = await deleteStage(stage.id) + expect(prisma.stage.delete).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) + + describe('listStages', () => { + const clusterAssociated = [{ id: faker.string.uuid() }] + it('should list all stages (admin, no userId provided)', async () => { + prisma.stage.findMany.mockResolvedValue([{ clusters: clusterAssociated }] as unknown as Stage[]) + const response = await listStages() + expect(response[0].clusterIds).toStrictEqual([clusterAssociated[0].id]) + expect(prisma.stage.findMany).toHaveBeenCalledTimes(1) + expect(prisma.stage.findMany).toHaveBeenCalledWith({ include: { clusters: true } }) + }) + }) + + describe('getStageAssociatedEnvironments', () => { + it('should list all environments attached to a stage stages', async () => { + const envName = faker.string.alpha(8) + const projectSlug = faker.string.alpha(8) + const clusterLabel = faker.string.alpha(8) + const ownerEmail = faker.internet.email() + const envs = [{ name: envName, project: { slug: projectSlug, owner: { email: ownerEmail } }, cluster: { label: clusterLabel } }] + prisma.environment.findMany.mockResolvedValue(envs as unknown as Environment[]) + const response = await getStageAssociatedEnvironments(stage.id) + expect(response).toStrictEqual([{ + name: envName, + project: projectSlug, + owner: ownerEmail, + cluster: clusterLabel, + }]) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/stage/business.ts b/.jjconflict-side-0/apps/server/src/resources/stage/business.ts new file mode 100644 index 0000000000..db6faf686e --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/stage/business.ts @@ -0,0 +1,97 @@ +import type { CreateStageBody, UpdateStageBody } from '@cpn-console/shared' +import type { Cluster, Stage } from '@prisma/client' +import prisma from '@/prisma.js' +import { + createStage as createStageQuery, + deleteStage as deleteStageQuery, + getAllStageIds, + getStageAssociatedEnvironmentById, + getStageById, + getStageByName, + linkClusterToStages as linkClusterToStagesQuery, + linkStageToClusters, + listStages as listStagesQuery, + removeClusterFromStage, + updateStageName, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' + +export async function getStageAssociatedEnvironments(stageId: Stage['id']) { + const environments = await getStageAssociatedEnvironmentById(stageId) + return environments.map(env => ({ + project: env.project.slug, + name: env.name, + cluster: env.cluster.label, + owner: env.project.owner.email, + })) +} + +export async function createStage({ clusterIds = [], name }: CreateStageBody) { + const isNameTaken = await getStageByName(name) + if (isNameTaken) return new BadRequest400('Un type d\'environnement portant ce nom existe déjà') + + const stage = await createStageQuery({ name }) + + if (clusterIds.length) { + await linkStageToClusters(stage.id, clusterIds) + } + + return { + id: stage.id, + name: stage.name, + clusterIds, + } +} + +export async function updateStage(stageId: Stage['id'], { clusterIds, name }: UpdateStageBody) { + const dbStage = await getStageById(stageId) + if (!dbStage) return new NotFound404() + if (name !== dbStage.name) { + await updateStageName(stageId, name) + } + // Remove clusters + const dbClusters = dbStage.clusters + if (dbClusters?.length) { + const clustersToRemove = dbClusters.filter(dbCluster => !clusterIds.includes(dbCluster.id)) + for (const clusterToRemove of clustersToRemove) { + await removeClusterFromStage(clusterToRemove.id, stageId) + } + } + // Add clusters + if (clusterIds.length) { + await linkStageToClusters(stageId, clusterIds) + } + + return { + id: stageId, + name: name ?? dbStage.name, + clusterIds: clusterIds ?? dbStage.clusters.map(({ id }) => id), + } +} + +export async function deleteStage(stageId: Stage['id']) { + const attachedEnvironment = await prisma.environment.findFirst({ where: { stageId }, select: { id: true } }) + if (attachedEnvironment) return new BadRequest400('Impossible de supprimer le stage, des environnements en activité y ont souscrit') + + await deleteStageQuery(stageId) + return null +} + +export async function listStages() { + const stages = await listStagesQuery() + + return stages.map((stage) => { + return { + id: stage.id, + name: stage.name, + clusterIds: stage.clusters.map(({ id }) => id), + } + }) +} + +export async function linkClusterToStages(clusterId: Cluster['id'], stageIds: Stage['id'][], linkToAll: boolean = false) { + if (linkToAll === true) { + stageIds = await getAllStageIds() + } + await linkClusterToStagesQuery(clusterId, stageIds) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/stage/queries.ts b/.jjconflict-side-0/apps/server/src/resources/stage/queries.ts new file mode 100644 index 0000000000..98d526600a --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/stage/queries.ts @@ -0,0 +1,111 @@ +import type { Cluster, Stage } from '@prisma/client' +import prisma from '@/prisma.js' + +export function listStages() { + return prisma.stage.findMany({ + include: { + clusters: true, + }, + }) +} + +export async function getAllStageIds() { + return (await prisma.stage.findMany({ + select: { + id: true, + }, + })).map(({ id }) => id) +} + +export function getStageById(id: Stage['id']) { + return prisma.stage.findUnique({ + where: { id }, + include: { + clusters: true, + }, + }) +} + +export function getStageByIdOrThrow(id: Stage['id']) { + return prisma.stage.findUniqueOrThrow({ + where: { id }, + include: { + clusters: true, + }, + }) +} + +export function getStageAssociatedEnvironmentById(id: Stage['id']) { + return prisma.environment.findMany({ + where: { + stageId: id, + }, + select: { + name: true, + cluster: { + select: { + label: true, + }, + }, + project: { + select: { + name: true, + owner: true, + slug: true, + }, + }, + }, + }) +} + +export function getStageAssociatedEnvironmentLengthById(id: Stage['id']) { + return prisma.environment.count({ + where: { + stageId: id, + }, + }) +} + +export function getStageByName(name: Stage['name']) { + return prisma.stage.findUnique({ + where: { name }, + }) +} + +export function linkStageToClusters(id: Stage['id'], clusterIds: Cluster['id'][]) { + return prisma.stage.update({ + where: { + id, + }, + data: { + clusters: { + connect: clusterIds.map(clusterId => ({ id: clusterId })), + }, + }, + }) +} + +export function createStage({ name }: { name: Stage['name'] }) { + return prisma.stage.create({ + data: { + name, + }, + }) +} + +export function updateStageName(id: Stage['id'], name: Stage['name']) { + return prisma.stage.update({ + where: { + id, + }, + data: { + name, + }, + }) +} + +export function deleteStage(id: Stage['id']) { + return prisma.stage.delete({ + where: { id }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/stage/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/stage/router.spec.ts new file mode 100644 index 0000000000..3e62a482fc --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/stage/router.spec.ts @@ -0,0 +1,204 @@ +import type { Stage } from '@cpn-console/shared' +import { ADMIN_PERMS, stageContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listStages') +const businessGetEnvironmentsMock = vi.spyOn(business, 'getStageAssociatedEnvironments') +const businessCreateMock = vi.spyOn(business, 'createStage') +const businessUpdateMock = vi.spyOn(business, 'updateStage') +const businessDeleteMock = vi.spyOn(business, 'deleteStage') + +describe('test stageContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listStages', () => { + it('should return list of stages', async () => { + const stages: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_STAGES) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(stages) + + const response = await app.inject() + .get(stageContract.listStages.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(stages) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getStageEnvironments', () => { + it('should return stage environments for admin', async () => { + const environments: any = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(environments) + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(environments) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createStage', () => { + const stage: Stage = { id: faker.string.uuid(), name: faker.string.alpha({ length: 5 }), clusterIds: [] } + + it('should create and return stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(stage) + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(stage) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateStage', () => { + const stageId = faker.string.uuid() + const stage = { name: faker.string.alpha({ length: 5 }), clusterIds: [] } + + it('should update and return stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: stageId, ...stage }) + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: stageId, ...stage }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteStage', () => { + it('should delete stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/stage/router.ts b/.jjconflict-side-0/apps/server/src/resources/stage/router.ts new file mode 100644 index 0000000000..73202d4549 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/stage/router.ts @@ -0,0 +1,92 @@ +import { AdminAuthorized, stageContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + createStage, + deleteStage, + getStageAssociatedEnvironments, + listStages, + updateStage, +} from './business.js' + +export function stageRouter() { + return serverInstance.router(stageContract, { + + // Récupérer les types d'environnement disponibles + listStages: async () => { + const body = await listStages() + + return { + status: 200, + body, + } + }, + + // Récupérer les environnements associés au stage + getStageEnvironments: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + const body = await getStageAssociatedEnvironments(stageId) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Créer un stage + createStage: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const body = await createStage(data) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Modifier une association stage / clusters + updateStage: async ({ request: req, params, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + + const body = await updateStage(stageId, data) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Supprimer un stage + deleteStage: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + + const body = await deleteStage(stageId) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/system/config/business.spec.ts b/.jjconflict-side-0/apps/server/src/resources/system/config/business.spec.ts new file mode 100644 index 0000000000..35d4844070 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/system/config/business.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import prisma from '../../../__mocks__/prisma.js' +import { objToDb, updatePluginConfig } from './business.ts' + +describe('test system/config business', () => { + const config = { test: { key1: 'value1' } } + it('should transform object to db row', () => { + const response = objToDb({ test: { key1: 'value1' } }) + expect(response).toEqual([{ pluginName: 'test', key: 'key1', value: 'value1' }]) + }) + describe('updatePluginConfig', () => { + it('should update', async () => { + prisma.adminPlugin.upsert.mockResolvedValue(null) + await updatePluginConfig(config) + }) + it('should update 0 items cause missing manifest', async () => { + // @ts-ignore + await updatePluginConfig({ test: { key: 1 } }) + expect(prisma.adminPlugin.upsert).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/system/config/business.ts b/.jjconflict-side-0/apps/server/src/resources/system/config/business.ts new file mode 100644 index 0000000000..111cb4cd1b --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/system/config/business.ts @@ -0,0 +1,50 @@ +import type { + PluginsUpdateBody, +} from '@cpn-console/shared' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { BadRequest400 } from '@/utils/errors.js' +import { + getAdminPlugin, + savePluginsConfig, +} from './queries.js' + +export type ConfigRecords = { + key: string + pluginName: string + value: string +}[] + +export function objToDb(obj: PluginsUpdateBody): ConfigRecords { + return Object.entries(obj) + .map(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) + .flat() +} + +export async function getPluginsConfig() { + const globalConfig = await getAdminPlugin() + + return Object.values(servicesInfos).map(({ name, title, imgSrc, description }) => { + const manifest = populatePluginManifests({ + data: { + global: globalConfig, + }, + permissionTarget: 'admin', + pluginName: name, + select: { + global: true, + project: false, + }, + }) + return { imgSrc, title, name, manifest: manifest.global ?? [], description } + }).filter(plugin => plugin.manifest.length > 0) +} + +export async function updatePluginConfig(data: PluginsUpdateBody) { + const parsedData = editStrippers.global.safeParse(data) + if (!parsedData.success) return new BadRequest400(parsedData.error.message) + const records = objToDb(parsedData.data) + + await savePluginsConfig(records) + return null +} diff --git a/.jjconflict-side-0/apps/server/src/resources/system/config/queries.ts b/.jjconflict-side-0/apps/server/src/resources/system/config/queries.ts new file mode 100644 index 0000000000..69808e506a --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/system/config/queries.ts @@ -0,0 +1,28 @@ +import type { ConfigRecords } from './business.js' +import prisma from '@/prisma.js' + +// CONFIG +export const getAdminPlugin = prisma.adminPlugin.findMany + +export async function savePluginsConfig(records: ConfigRecords) { + for (const { pluginName, key, value } of records) { + await prisma.adminPlugin.upsert({ + create: { + pluginName, + key, + value: String(value), + }, + update: { + key, + value: String(value), + pluginName, + }, + where: { + pluginName_key: { + pluginName, + key, + }, + }, + }) + } +} diff --git a/.jjconflict-side-0/apps/server/src/resources/system/config/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/system/config/router.spec.ts new file mode 100644 index 0000000000..423a1b7b38 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/system/config/router.spec.ts @@ -0,0 +1,96 @@ +import { ADMIN_PERMS, systemPluginContract } from '@cpn-console/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../../app.js' +import * as utilsController from '../../../utils/controller.js' +import { BadRequest400 } from '../../../utils/errors.js' +import { getUserMockInfos } from '../../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetPluginsConfigMock = vi.spyOn(business, 'getPluginsConfig') +const businessUpdatePluginConfigMock = vi.spyOn(business, 'updatePluginConfig') + +describe('test systemPluginContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getPluginsConfig', () => { + it('should return plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const pluginsConfig: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetPluginsConfigMock.mockResolvedValueOnce(pluginsConfig) + + const response = await app.inject() + .get(systemPluginContract.getPluginsConfig.path) + .end() + + expect(businessGetPluginsConfigMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(pluginsConfig) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(systemPluginContract.getPluginsConfig.path) + .end() + + expect(businessGetPluginsConfigMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updatePluginsConfig', () => { + const newConfig = { plugin1: { keyId: 'value' } } + it('should update plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpdatePluginConfigMock.mockResolvedValueOnce(newConfig) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return error if business logic fails', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpdatePluginConfigMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(400) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/system/config/router.ts b/.jjconflict-side-0/apps/server/src/resources/system/config/router.ts new file mode 100644 index 0000000000..d541dc1778 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/system/config/router.ts @@ -0,0 +1,38 @@ +import { AdminAuthorized, systemPluginContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { getPluginsConfig, updatePluginConfig } from './business.js' + +export function pluginConfigRouter() { + return serverInstance.router(systemPluginContract, { + // Récupérer les configurations plugins + getPluginsConfig: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) return new Forbidden403() + + const services = await getPluginsConfig() + + return { + status: 200, + body: services, + + } + }, + // Mettre à jour les configurations plugins + updatePluginsConfig: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) return new Forbidden403() + + const resBody = await updatePluginConfig(body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/system/index.ts b/.jjconflict-side-0/apps/server/src/resources/system/index.ts new file mode 100644 index 0000000000..a45d7accc7 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/system/index.ts @@ -0,0 +1 @@ +export * from './router.js' diff --git a/.jjconflict-side-0/apps/server/src/resources/system/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/system/router.spec.ts new file mode 100644 index 0000000000..62ad30d7f8 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/system/router.spec.ts @@ -0,0 +1,25 @@ +import { systemContract } from '@cpn-console/shared' +import { describe, expect, it, vi } from 'vitest' +import app from '../../app.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) + +describe('system - router', () => { + it('should send application version', async () => { + const response = await app.inject() + .get(systemContract.getVersion.path) + .end() + + expect(response.statusCode).toBe(200) + expect(response.json()).toStrictEqual({ version: process.env.APP_VERSION || 'dev' }) + }) + + it('should send application health with status OK', async () => { + const response = await app.inject() + .get(systemContract.getHealth.path) + .end() + + expect(response.statusCode).toBe(200) + expect(response.json()).toStrictEqual({ status: 'OK' }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/system/router.ts b/.jjconflict-side-0/apps/server/src/resources/system/router.ts new file mode 100644 index 0000000000..9976884963 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/system/router.ts @@ -0,0 +1,21 @@ +import { systemContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { appVersion } from '@/utils/env.js' + +export function systemRouter() { + return serverInstance.router(systemContract, { + getVersion: async () => ({ + status: 200, + body: { + version: appVersion, + }, + }), + + getHealth: async () => ({ + status: 200, + body: { + status: 'OK', + }, + }), + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/system/settings/business.ts b/.jjconflict-side-0/apps/server/src/resources/system/settings/business.ts new file mode 100644 index 0000000000..5e562353b0 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/system/settings/business.ts @@ -0,0 +1,9 @@ +import type { UpsertSystemSettingBody } from '@cpn-console/shared' +import { + getSystemSettings as getSystemSettingsQuery, + upsertSystemSetting as upsertSystemSettingQuery, +} from './queries.js' + +export const getSystemSettings = (key?: string) => getSystemSettingsQuery({ key }) + +export const upsertSystemSetting = (newSystemSetting: UpsertSystemSettingBody) => upsertSystemSettingQuery(newSystemSetting) diff --git a/.jjconflict-side-0/apps/server/src/resources/system/settings/queries.ts b/.jjconflict-side-0/apps/server/src/resources/system/settings/queries.ts new file mode 100644 index 0000000000..c64cb3b74c --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/system/settings/queries.ts @@ -0,0 +1,18 @@ +import type { Prisma, SystemSetting } from '@prisma/client' +import prisma from '@/prisma.js' + +export function upsertSystemSetting(newSystemSetting: SystemSetting) { + return prisma.systemSetting.upsert({ + create: { + ...newSystemSetting, + }, + update: { + value: newSystemSetting.value, + }, + where: { + key: newSystemSetting.key, + }, + }) +} + +export const getSystemSettings = (where?: Prisma.SystemSettingWhereInput) => prisma.systemSetting.findMany({ where }) diff --git a/.jjconflict-side-0/apps/server/src/resources/system/settings/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/system/settings/router.spec.ts new file mode 100644 index 0000000000..469ee45538 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/system/settings/router.spec.ts @@ -0,0 +1,82 @@ +import { ADMIN_PERMS, systemSettingsContract } from '@cpn-console/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../../app.js' +import * as utilsController from '../../../utils/controller.js' +import { getUserMockInfos } from '../../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetSystemSettingsMock = vi.spyOn(business, 'getSystemSettings') +const businessUpsertSystemSettingMock = vi.spyOn(business, 'upsertSystemSetting') + +describe('test systemSettingsContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listSystemSettings', () => { + it('should return plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const systemSettings: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetSystemSettingsMock.mockResolvedValueOnce(systemSettings) + + const response = await app.inject() + .get(systemSettingsContract.listSystemSettings.path) + .end() + + expect(businessGetSystemSettingsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(systemSettings) + expect(response.statusCode).toEqual(200) + }) + + it('should return 200 for anybody', async () => { + const user = getUserMockInfos(0n) + const systemSettings: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetSystemSettingsMock.mockResolvedValueOnce(systemSettings) + + const response = await app.inject() + .get(systemSettingsContract.listSystemSettings.path) + .end() + + expect(businessGetSystemSettingsMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('upsertSystemSetting', () => { + const newConfig = { key: 'key1', value: 'value1' } + it('should update system setting, authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpsertSystemSettingMock.mockResolvedValueOnce(newConfig) + + const response = await app.inject() + .post(systemSettingsContract.upsertSystemSetting.path) + .body(newConfig) + .end() + + expect(businessUpsertSystemSettingMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(systemSettingsContract.upsertSystemSetting.path) + .body(newConfig) + .end() + + expect(businessUpsertSystemSettingMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/system/settings/router.ts b/.jjconflict-side-0/apps/server/src/resources/system/settings/router.ts new file mode 100644 index 0000000000..98bce89e2d --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/system/settings/router.ts @@ -0,0 +1,31 @@ +import { AdminAuthorized, systemSettingsContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getSystemSettings, upsertSystemSetting } from './business.js' + +export function systemSettingsRouter() { + return serverInstance.router(systemSettingsContract, { + listSystemSettings: async ({ query }) => { + const systemSettings = await getSystemSettings(query.key) + + return { + status: 200, + body: systemSettings, + } + }, + + upsertSystemSetting: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) return new Forbidden403() + + const systemSetting = await upsertSystemSetting(data) + + return { + status: 201, + body: systemSetting, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/user/business.spec.ts b/.jjconflict-side-0/apps/server/src/resources/user/business.spec.ts new file mode 100644 index 0000000000..a86c0535ae --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/user/business.spec.ts @@ -0,0 +1,227 @@ +import type { UserDetails } from '../../types/index.ts' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { getMatchingUsers, getUsers, logViaSession, logViaToken, patchUsers, TokenInvalidReason } from './business.ts' +import * as queries from './queries.js' + +const getUsersQueryMock = vi.spyOn(queries, 'getUsers') +const getMatchingUsersQueryMock = vi.spyOn(queries, 'getMatchingUsers') + +describe('test users business', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const user = { + adminRoleIds: [], + createdAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + id: faker.string.uuid(), + lastName: faker.person.lastName(), + updatedAt: new Date(), + } + const projectId = faker.string.uuid() + const adminRoleId = faker.string.uuid() + describe('patchUsers', () => { + it('should do nothing', async () => { + prisma.user.update.mockResolvedValue(null) + + await patchUsers([]) + + expect(prisma.user.update).toHaveBeenCalledTimes(0) + }) + + it('should update a user adminRoleIds', async () => { + const userUpdated = { id: user.id, adminRoleIds: user.adminRoleIds } + + prisma.user.update.mockResolvedValue(user) + + prisma.user.findMany.mockResolvedValue([]) + + await patchUsers([userUpdated]) + expect(prisma.user.update).toHaveBeenCalledTimes(1) + expect(prisma.user.findMany).toHaveBeenCalledTimes(2) + + await patchUsers([userUpdated, userUpdated]) + expect(prisma.user.update).toHaveBeenCalledTimes(3) + }) + }) + describe('getUsers', () => { + it('should query without where', async () => { + prisma.user.update.mockResolvedValue(null) + + await getUsers({}) + + expect(getUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getUsersQueryMock).toHaveBeenCalledWith({ AND: [] }) + }) + it('should query with filter adminRoleIds', async () => { + prisma.user.update.mockResolvedValue(null) + + await getUsers({ adminRoleIds: [adminRoleId] }) + + expect(getUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getUsersQueryMock).toHaveBeenCalledWith({ AND: [{ adminRoleIds: { hasEvery: [adminRoleId] } }] }) + }) + }) + + describe('getMatchingUsers', () => { + const AND = [ + { + OR: [ + { + email: { + contains: 'abc', + mode: 'insensitive', + }, + }, + { + firstName: { + contains: 'abc', + mode: 'insensitive', + }, + }, + { + lastName: { + contains: 'abc', + mode: 'insensitive', + }, + }, + ], + }, + { + type: 'human', + }, + ] + it('should query only with letters ', async () => { + prisma.user.update.mockResolvedValue(null) + + await getMatchingUsers({ letters: 'abc' }) + + expect(getMatchingUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getMatchingUsersQueryMock).toHaveBeenCalledWith({ AND }) + }) + it('should query with letters and projectId', async () => { + prisma.user.update.mockResolvedValue(null) + + await getMatchingUsers({ letters: 'abc', notInProjectId: projectId }) + + expect(getMatchingUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getMatchingUsersQueryMock).toHaveBeenCalledWith({ AND: [...[{ + projectMembers: { + none: { + projectId, + }, + }, + }, { + projectsOwned: { + none: { + id: projectId, + }, + }, + }], ...AND] }) + }) + }) + describe('logViaSession', () => { + // ça ne teste pas tout mais c'est déjà bien hein + const adminRoles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: 0n, + position: 0, + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '/admin', + permissions: 0n, + position: 0, + }] + const userToLog: UserDetails = { + id: faker.string.uuid(), + email: user.email, + firstName: user.firstName, + groups: [], + lastName: user.lastName, + } + it('should create user and return adminPerms', async () => { + prisma.adminRole.findMany.mockResolvedValue(adminRoles) + prisma.user.findUnique.mockResolvedValue(undefined) + prisma.user.create.mockResolvedValue(user) + prisma.user.update.mockResolvedValue(user) + const response = await logViaSession(userToLog) + expect(response.adminPerms).toBe(0n) + expect(prisma.user.create).toHaveBeenCalledTimes(1) + }) + it('should update user and return adminPerms', async () => { + prisma.adminRole.findMany.mockResolvedValue(adminRoles) + prisma.user.findUnique.mockResolvedValue(user) + prisma.user.update.mockResolvedValue(user) + const response = await logViaSession(userToLog) + expect(response.adminPerms).toEqual(0n) + expect(prisma.user.create).toHaveBeenCalledTimes(0) + }) + }) +}) + +describe('logViaToken', () => { + const nextYear = new Date() + const lastYear = new Date() + nextYear.setFullYear((new Date()).getFullYear() + 1) + lastYear.setFullYear((new Date()).getFullYear() - 1) + const baseToken = { + createdAt: new Date(), + hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + id: faker.string.uuid(), + lastUse: null, + permissions: 2n, + userId: null, + status: 'active', + } as const + + beforeEach(() => { + // Mock the global roles lookup + prisma.adminRole.findMany.mockResolvedValue([]) + }) + + it('should return identity', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken }) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(2n) + }) + + it('should return identity based on pat', async () => { + const pat = structuredClone(baseToken) + delete pat.permissions + pat.owner = { adminRoleIds: null } + prisma.personalAccessToken.findFirst.mockResolvedValueOnce(pat) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(0n) + }) + + it('should return identity, with expirationDate', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, expirationDate: nextYear }) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(2n) + }) + + it('should return cause revoked', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, status: 'revoked' }) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.INACTIVE) + }) + + it('should return cause expired', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, expirationDate: lastYear }) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.EXPIRED) + }) + + it('should return cause not found', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce(undefined) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.NOT_FOUND) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/user/business.ts b/.jjconflict-side-0/apps/server/src/resources/user/business.ts new file mode 100644 index 0000000000..acbf67bb8e --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/user/business.ts @@ -0,0 +1,221 @@ +import type { userContract, XOR } from '@cpn-console/shared' +import type { AdminRole, AdminToken, PersonalAccessToken, Prisma, User } from '@prisma/client' +import type { UserDetails } from '@/types/index.js' +import { createHash } from 'node:crypto' +import prisma from '@/prisma.js' +import { getMatchingUsers as getMatchingUsersQuery, getUsers as getUsersQuery } from '@/resources/queries-index.js' +import { BadRequest400 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function getUsers(query: typeof userContract.getAllUsers.query._type, relationType: 'OR' | 'AND' = 'AND') { + const whereInputs: Prisma.UserWhereInput[] = [] + if (query.adminRoleIds?.length) { + whereInputs.push({ adminRoleIds: { hasEvery: query.adminRoleIds } }) + } + if (query.adminRoles?.length) { + const roles = query.adminRoles + ? await prisma.adminRole.findMany({ where: { name: { in: query.adminRoles } } }) + : [] + + const adminRoleNameNotFound = query.adminRoles?.find(nameQueried => !roles.some(({ name }) => name === nameQueried)) + if (adminRoleNameNotFound) { + return new BadRequest400(`Unable to find adminRole ${adminRoleNameNotFound}`) + } + whereInputs.push({ adminRoleIds: { hasEvery: roles.map(({ id }) => id) } }) + } + if (query.memberOfIds) { + whereInputs.push({ + AND: query.memberOfIds.map(id => ({ + OR: [ + { projectsOwned: { some: { id } } }, + { ProjectMembers: { some: { project: { id } } } }, + ], + })), + }) + } + + return getUsersQuery({ [relationType]: whereInputs }) +} + +export async function getMatchingUsers(query: typeof userContract.getMatchingUsers.query._type) { + const AND: Prisma.UserWhereInput[] = [] + if (query.notInProjectId) { + AND.push({ projectMembers: { none: { projectId: query.notInProjectId } } }) + AND.push({ projectsOwned: { none: { id: query.notInProjectId } } }) + } + const filter = { contains: query.letters, mode: 'insensitive' } as const // Default value: default + if (query.letters) { + AND.push({ + OR: [{ + email: filter, + }, { + firstName: filter, + }, { + lastName: filter, + }], + }) + AND.push({ type: 'human' }) + } + + return getMatchingUsersQuery({ + AND, + }) +} + +export async function patchUsers(users: typeof userContract.patchUsers.body._type) { + const userIds = users.map(u => u.id) + const usersBefore = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, adminRoleIds: true }, + }) + + for (const user of users) { + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + adminRoleIds: user.adminRoleIds, + }, + }) + } + + const impactedRoleIds = new Set() + for (const user of users) { + const before = usersBefore.find(u => u.id === user.id) + if (before) { + before.adminRoleIds.forEach(id => impactedRoleIds.add(id)) + } + user.adminRoleIds?.forEach(id => impactedRoleIds.add(id)) + } + + for (const roleId of impactedRoleIds) { + await hook.adminRole.upsert(roleId) + } + + return prisma.user.findMany({ + where: { + id: { in: users.map(({ id }) => id) }, + }, + }) +} + +export enum TokenInvalidReason { + INACTIVE = 'Not active', + EXPIRED = 'Expired', + NOT_FOUND = 'Not authenticated', +} + +export type UserTrial = Omit +export async function logViaSession({ id, email, groups, ...user }: UserTrial): Promise<{ user: User, adminPerms: bigint }> { + let userDb = await prisma.user.findUnique({ + where: { id }, + }) + + if (!userDb) { + userDb = await prisma.user.create({ data: { email, id, ...user, adminRoleIds: [], type: 'human' } }) + } + + const matchingAdminRoles = await prisma.adminRole.findMany({ + where: { OR: [{ oidcGroup: { in: groups } }, { id: { in: userDb.adminRoleIds } }, { type: 'global' }] }, + }) + + const oidcRoleIds = matchingAdminRoles + .filter(({ oidcGroup }) => oidcGroup && groups.includes(oidcGroup)) + .map(({ id }) => id) + + // On enregistre en bdd uniquement les roles de l'utilisateur + // qui ne viennent pas de keycloak + const updatedUser = await prisma.user.update({ where: { id }, data: { ...user, lastLogin: (new Date()).toISOString() } }) + .then(user => ({ ...user, adminRoleIds: [...new Set([...user.adminRoleIds, ...oidcRoleIds])] })) + return { + user: updatedUser, + adminPerms: sumAdminPerms(matchingAdminRoles), + } +} + +type UserWithTokenId = Omit & { tokenId: string } +export async function logViaToken(pass: string): Promise<({ user: UserWithTokenId, adminPerms: bigint }) | TokenInvalidReason> { + const passHash = createHash('sha256').update(pass).digest('hex') + + let token: (XOR & { owner: User }) | TokenInvalidReason | undefined + const tokenLoginMethods = [findPersonalAccessToken, findAdminToken] + for (const tokenLoginMethod of tokenLoginMethods) { + token = await tokenLoginMethod(passHash) + if (token) { + break + } + } + + if (typeof token === 'string') { + return token + } + if (!token) { + return TokenInvalidReason.NOT_FOUND + } + + const globalRoles = await prisma.adminRole.findMany({ where: { type: 'global' }, select: { permissions: true } }) + const globalPerms = globalRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + const adminPerms = token?.permissions ?? await getAdminRolesAndSum(token.owner.adminRoleIds) + + return { + user: { + ...token.owner, + tokenId: token.id, + }, + adminPerms: globalPerms | adminPerms, + } +} + +function isTokenInvalid(token: AdminToken | PersonalAccessToken): TokenInvalidReason | undefined { + if (token.status !== 'active') { + return TokenInvalidReason.INACTIVE + } + const currentDate = new Date() + if (token.expirationDate && currentDate.getTime() > token.expirationDate?.getTime()) { + return TokenInvalidReason.EXPIRED + } +} + +function sumAdminPerms(roles: AdminRole[]): bigint { + if (!roles.length) { + return 0n + } + return roles.reduce((acc, curr) => acc | curr.permissions, 0n) +} + +async function getAdminRolesAndSum(roles: AdminRole['id'][] | null): Promise { + if (!roles?.length) { + return 0n + } + return sumAdminPerms(await prisma.adminRole.findMany({ + where: { id: { in: roles } }, + })) +} + +// List all token tpe authentication +async function findPersonalAccessToken(digest: string): Promise<(PersonalAccessToken & { owner: User }) | undefined | TokenInvalidReason> { + const token = await prisma.personalAccessToken.findFirst({ where: { hash: digest }, include: { owner: true } }) + if (!token) + return undefined + const invalidReason = isTokenInvalid(token) + if (invalidReason) { + return invalidReason + } + await prisma.personalAccessToken.update({ where: { id: token.id }, data: { lastUse: (new Date()).toISOString() } }) + await prisma.user.update({ where: { id: token.owner.id }, data: { lastLogin: (new Date()).toISOString() } }) + return token +} + +async function findAdminToken(digest: string): Promise<(AdminToken & { owner: User }) | undefined | TokenInvalidReason> { + const token = await prisma.adminToken.findFirst({ where: { hash: digest }, include: { owner: true } }) + if (!token) + return undefined + const invalidReason = isTokenInvalid(token) + if (invalidReason) { + return invalidReason + } + await prisma.adminToken.update({ where: { id: token.id }, data: { lastUse: (new Date()).toISOString() } }) + await prisma.user.update({ where: { id: token.userId }, data: { lastLogin: (new Date()).toISOString() } }) + return token +} diff --git a/.jjconflict-side-0/apps/server/src/resources/user/queries.ts b/.jjconflict-side-0/apps/server/src/resources/user/queries.ts new file mode 100644 index 0000000000..1e33a8128b --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/user/queries.ts @@ -0,0 +1,60 @@ +import type { Prisma, User } from '@prisma/client' +import prisma from '@/prisma.js' + +type UserCreate = Omit + +// SELECT +export const getUsers = (where?: Prisma.UserWhereInput) => prisma.user.findMany({ where }) + +export async function getUserInfos(id: User['id']) { + return prisma.user.findMany({ + where: { id }, + include: { + logs: true, + }, + }) +} + +export function getMatchingUsers(where: Prisma.UserWhereInput) { + return prisma.user.findMany({ + where, + take: 5, + }) +} + +export function getUserById(id: User['id']) { + return prisma.user.findUnique({ where: { id } }) +} + +export function getUserOrThrow(id: User['id']) { + return prisma.user.findUniqueOrThrow({ + where: { id }, + }) +} + +export function getUserByEmail(email: User['email']) { + return prisma.user.findUnique({ where: { email } }) +} + +// CREATE +export async function createUser({ id, email, firstName, lastName, type }: UserCreate) { + const user = await getUserByEmail(email) + if (user) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') + return prisma.user.create({ data: { id, email, firstName, lastName, type } }) +} + +// UPDATE +export async function updateUserById({ id, email, firstName, lastName }: UserCreate) { + const user = await getUserById(id) + const isEmailAlreadyTaken = await getUserByEmail(email) + if (!user) throw new Error('L\'utilisateur demandé n\'existe pas') + if (isEmailAlreadyTaken) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') + if (user && !isEmailAlreadyTaken) { + return prisma.user.update({ where: { id }, data: { email, firstName, lastName } }) + } +} + +// TECH +export function _createUser(data: Prisma.UserCreateInput) { + return prisma.user.upsert({ where: { id: data.id }, create: data, update: data }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/user/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/user/router.spec.ts new file mode 100644 index 0000000000..f47c9aa311 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/user/router.spec.ts @@ -0,0 +1,142 @@ +import { ADMIN_PERMS, userContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos, setRequestor } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetMatchingMock = vi.spyOn(business, 'getMatchingUsers') +const businessLogViaSessionMock = vi.spyOn(business, 'logViaSession') +const businessGetUsersMock = vi.spyOn(business, 'getUsers') +const businessPatchMock = vi.spyOn(business, 'patchUsers') + +describe('test userContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getMatchingUsers', () => { + it('should return matching users', async () => { + const usersMatching: any[] = [] + businessGetMatchingMock.mockResolvedValueOnce(usersMatching) + + const response = await app.inject() + .get(userContract.getMatchingUsers.path) + .query({ letters: faker.person.fullName() }) + .end() + + expect(businessGetMatchingMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(usersMatching) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('auth', () => { + it('should return logged user', async () => { + const user = { + id: faker.string.uuid(), + adminRoleIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + type: 'human' as const, + lastName: faker.person.lastName(), + lastLogin: (new Date()).toISOString(), + } + // @ts-ignore + setRequestor(user) + // @ts-ignore + businessLogViaSessionMock.mockResolvedValueOnce({ user, adminPerms: 0n }) + + const response = await app.inject() + .get(userContract.auth.path) + .end() + + expect(businessLogViaSessionMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(user) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getAllUsers', () => { + it('should return all users for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + const users: any[] = [] + authUserMock.mockResolvedValueOnce(user) + businessGetUsersMock.mockResolvedValueOnce(users) + + const response = await app.inject() + .get(userContract.getAllUsers.path) + .query({ role: 'admin' }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetUsersMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(users) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(userContract.getAllUsers.path) + .query({ role: 'admin' }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetUsersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('patchUsers', () => { + const usersPatchData = [{ + id: faker.string.uuid(), + adminRoleIds: [], + }] + const usersReturn = [{ + id: faker.string.uuid(), + adminRoleIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + type: 'human', + }] + + it('should patch and return users for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + authUserMock.mockResolvedValueOnce(user) + + businessPatchMock.mockResolvedValueOnce(usersReturn) + const response = await app.inject() + .patch(userContract.patchUsers.path) + .body(usersPatchData) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessPatchMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(usersReturn) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(userContract.patchUsers.path) + .body(usersPatchData) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessPatchMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/user/router.ts b/.jjconflict-side-0/apps/server/src/resources/user/router.ts new file mode 100644 index 0000000000..e745dbec43 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/user/router.ts @@ -0,0 +1,72 @@ +import type { + UserTrial, +} from './business.js' +import { AdminAuthorized, userContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + getMatchingUsers, + getUsers, + logViaSession, + patchUsers, +} from './business.js' +import '@/types/index.js' + +export function userRouter() { + return serverInstance.router(userContract, { + getMatchingUsers: async ({ query }) => { + const usersMatching = await getMatchingUsers(query) + + return { + status: 200, + body: usersMatching, + } + }, + + auth: async ({ request: req }) => { + const user = req.session.user + + if (!user) return new Unauthorized401() + + const { user: body } = await logViaSession(user as unknown as UserTrial) + + return { + status: 200, + body, + } + }, + + getAllUsers: async ({ + request: req, + query: { relationType, ...query }, + }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageUsers(perms.adminPermissions)) + return new Forbidden403() + + const body = await getUsers(query, relationType) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + patchUsers: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageUsers(perms.adminPermissions)) + return new Forbidden403() + + const users = await patchUsers(body) + + return { + status: 200, + body: users, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/user/tokens/business.ts b/.jjconflict-side-0/apps/server/src/resources/user/tokens/business.ts new file mode 100644 index 0000000000..e378d3b79b --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/user/tokens/business.ts @@ -0,0 +1,51 @@ +import type { personalAccessTokenContract } from '@cpn-console/shared' +import type { AdminToken, User } from '@prisma/client' +import { createHash } from 'node:crypto' +import { generateRandomPassword, isAtLeastTomorrow } from '@cpn-console/shared' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../../prisma.js' + +export async function listTokens(userId: User['id']) { + return prisma.personalAccessToken.findMany({ + omit: { hash: true }, + include: { owner: true }, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + where: { userId }, + }) +} + +export async function createToken(data: typeof personalAccessTokenContract.createPersonalAccessToken.body._type, userId: User['id']) { + if (data.expirationDate && !isAtLeastTomorrow(new Date(data.expirationDate))) { + return new BadRequest400('Date d\'expiration trop courte') + } + const password = generateRandomPassword(48, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-') + const hash = createHash('sha256').update(password).digest('hex') + const token = await prisma.personalAccessToken.create({ + data: { + ...data, + hash, + expirationDate: new Date(data.expirationDate), + userId, + }, + omit: { hash: true }, + include: { owner: true }, + }) + return { + ...token, + password, + } +} + +export async function deleteToken(id: AdminToken['id'], userId: User['id']) { + const token = await prisma.personalAccessToken.findUnique({ + where: { + id, + userId, + }, + }) + if (token) { + return prisma.personalAccessToken.delete({ + where: { id }, + }) + } +} diff --git a/.jjconflict-side-0/apps/server/src/resources/user/tokens/router.ts b/.jjconflict-side-0/apps/server/src/resources/user/tokens/router.ts new file mode 100644 index 0000000000..369ded2b1a --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/user/tokens/router.ts @@ -0,0 +1,48 @@ +import { personalAccessTokenContract } from '@cpn-console/shared' + +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { createToken, deleteToken, listTokens } from './business.js' +import '@/types/index.js' + +export function personalAccessTokenRouter() { + return serverInstance.router(personalAccessTokenContract, { + listPersonalAccessTokens: async ({ request: req }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + const body = await listTokens(perms.user.id) + + return { + status: 200, + body, + } + }, + + createPersonalAccessToken: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + const body = await createToken(data, perms.user.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + deletePersonalAccessToken: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + await deleteToken(params.tokenId, perms.user.id) + + return { + status: 204, + body: null, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/zone/business.spec.ts b/.jjconflict-side-0/apps/server/src/resources/zone/business.spec.ts new file mode 100644 index 0000000000..2b42464e26 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/zone/business.spec.ts @@ -0,0 +1,133 @@ +import type { Cluster, Zone } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400 } from '../../utils/errors.ts' +import { createZone, deleteZone, listZones, updateZone } from './business.ts' +import * as queries from './queries.js' + +const userId = faker.string.uuid() +const reqId = faker.string.uuid() +const linkZoneToClustersMock = vi.spyOn(queries, 'linkZoneToClusters') +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +describe('test zone business', () => { + const zones: Zone[] = [{ + id: faker.string.uuid(), + label: faker.string.alphanumeric(), + argocdUrl: faker.internet.url(), + createdAt: new Date(), + updatedAt: new Date(), + description: faker.lorem.lines(1), + slug: faker.string.alphanumeric(5), + }, { + id: faker.string.uuid(), + label: faker.string.alphanumeric(), + argocdUrl: faker.internet.url(), + createdAt: new Date(), + updatedAt: new Date(), + description: faker.lorem.lines(1), + slug: faker.string.alphanumeric(6), + }] + + const clusters: Pick[] = [ + { id: faker.string.uuid() }, + { id: faker.string.uuid() }, + ] + + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listZones', () => { + it('should return zones', async () => { + prisma.zone.findMany.mockResolvedValueOnce(zones) + + const response = await listZones() + expect(response).toEqual(zones) + }) + }) + describe('createZone', () => { + it('should create zone without description and clusterIds', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl } + + hook.zone.upsert.mockResolvedValue({}) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).toEqual(zones[0]) + expect(prisma.zone.create).toHaveBeenCalledWith({ + data: { + slug: newZone.slug, + label: newZone.label, + argocdUrl: newZone.argocdUrl, + description: undefined, + }, + }) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(0) + }) + it('should create zone with description and clusterIds', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl, clusterIds: clusters.map(({ id }) => id), description: faker.lorem.lines(2) } + + hook.zone.upsert.mockResolvedValue({}) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).toEqual(zones[0]) + expect(prisma.zone.create).toHaveBeenCalledWith({ + data: { + description: newZone.description, + label: newZone.label, + argocdUrl: newZone.argocdUrl, + slug: newZone.slug, + }, + }) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(1) + }) + it('should not create zone, conflict label', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl } + + prisma.zone.findUnique.mockResolvedValueOnce(zones[0]) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).instanceOf(BadRequest400) + expect(prisma.zone.create).toHaveBeenCalledTimes(0) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(0) + }) + }) + describe('updateZone', () => { + it('should filter keys and update zone', async () => { + prisma.zone.update.mockResolvedValueOnce(zones[0]) + hook.zone.upsert.mockResolvedValue({}) + await updateZone(zones[0].id, { + description: '', + label: zones[0].label, + argocdUrl: zones[0].argocdUrl, + extraKey: 1, + }, userId, reqId) + expect(prisma.zone.update).toHaveBeenCalledWith({ where: { id: zones[0].id }, data: { + description: '', + label: zones[0].label, + argocdUrl: zones[0].argocdUrl, + } }) + }) + }) + describe('deleteZone', () => { + it('should not delete zone, cluster attached', async () => { + prisma.cluster.findFirst.mockResolvedValueOnce(clusters[0]) + const response = await deleteZone(zones[0].id, userId, reqId) + expect(response).instanceOf(BadRequest400) + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + }) + it('should delete zone', async () => { + prisma.cluster.findFirst.mockResolvedValueOnce(undefined) + hook.zone.delete.mockResolvedValue({}) + const response = await deleteZone(zones[0].id, userId, reqId) + expect(response).toEqual(null) + expect(prisma.zone.delete).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/zone/business.ts b/.jjconflict-side-0/apps/server/src/resources/zone/business.ts new file mode 100644 index 0000000000..eb3155ce60 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/zone/business.ts @@ -0,0 +1,78 @@ +import type { User, Zone } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { BadRequest400, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' +import { addLogs } from '../queries-index.js' +import { linkZoneToClusters } from './queries.js' + +export const listZones = prisma.zone.findMany + +export async function createZone( + data: { slug: string, label: string, argocdUrl: string, description?: string | null, clusterIds?: string[] }, + userId: User['id'], + requestId: string, +) { + const { slug, label, argocdUrl, description, clusterIds } = data + + const existingZone = await prisma.zone.findUnique({ + where: { slug }, + }) + + if (existingZone) return new BadRequest400(`Une zone portant le nom ${slug} existe déjà.`) + const zone = await prisma.zone.create({ + data: { + slug, + label, + argocdUrl, + description, + }, + }) + if (clusterIds) { + await linkZoneToClusters(zone.id, clusterIds) + } + const hookReply = await hook.zone.upsert(zone.id) + await addLogs({ action: 'Create zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la création de la zone') + } + return zone +} + +export async function updateZone( + zoneId: Zone['id'], + data: Pick, + userId: User['id'], + requestId: string, +) { + const { label, argocdUrl, description } = data + + const updatedZone = await prisma.zone.update({ + where: { + id: zoneId, + }, + data: { + label, + argocdUrl, + description, + }, + }) + const hookReply = await hook.zone.upsert(updatedZone.id) + await addLogs({ action: 'Update zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la mise à jour de la zone') + } + return updatedZone +} + +export async function deleteZone(zoneId: Zone['id'], userId: User['id'], requestId: string) { + const attachedCluster = await prisma.cluster.findFirst({ where: { zoneId }, select: { id: true } }) + if (attachedCluster) return new BadRequest400('Vous ne pouvez supprimer cette zone, car des clusters y sont associés.') + + const hookReply = await hook.zone.delete(zoneId) + await addLogs({ action: 'Delete zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la suppression de la zone') + } + await prisma.zone.delete({ where: { id: zoneId } }) + return null +} diff --git a/.jjconflict-side-0/apps/server/src/resources/zone/queries.ts b/.jjconflict-side-0/apps/server/src/resources/zone/queries.ts new file mode 100644 index 0000000000..1390bb153a --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/zone/queries.ts @@ -0,0 +1,21 @@ +import type { Cluster, Zone } from '@prisma/client' +import prisma from '@/prisma.js' + +export function getZoneByIdOrThrow(id: Zone['id']) { + return prisma.zone.findUniqueOrThrow({ + where: { id }, + }) +} + +export function linkZoneToClusters(zoneId: Zone['id'], clusterIds: Cluster['id'][]) { + return prisma.zone.update({ + where: { + id: zoneId, + }, + data: { + clusters: { + connect: clusterIds.map(clusterId => ({ id: clusterId })), + }, + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/resources/zone/router.spec.ts b/.jjconflict-side-0/apps/server/src/resources/zone/router.spec.ts new file mode 100644 index 0000000000..0ee9c7f394 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/zone/router.spec.ts @@ -0,0 +1,164 @@ +import type { Zone } from '@cpn-console/shared' +import { ADMIN_PERMS, zoneContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listZones') +const businessCreateMock = vi.spyOn(business, 'createZone') +const businessUpdateMock = vi.spyOn(business, 'updateZone') +const businessDeleteMock = vi.spyOn(business, 'deleteZone') + +describe('test zoneContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listZones', () => { + it('should return list of zones', async () => { + const zones: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_ZONES) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(zones) + + const response = await app.inject() + .get(zoneContract.listZones.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(zones) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('createZone', () => { + const zone = { id: faker.string.uuid(), label: faker.string.alpha({ length: 5 }), argocdUrl: faker.internet.url(), slug: faker.string.alpha({ length: 5, casing: 'lower' }), description: '' } + + it('should create and return zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(zone) + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(zone) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateZone', () => { + const zoneId = faker.string.uuid() + const zone: Omit = { label: faker.string.alpha({ length: 5 }), slug: faker.string.alpha({ length: 5, casing: 'lower' }), argocdUrl: faker.internet.url(), description: '' } + + it('should update and return zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: zoneId, ...zone }) + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: zoneId, ...zone }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteZone', () => { + it('should delete zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/resources/zone/router.ts b/.jjconflict-side-0/apps/server/src/resources/zone/router.ts new file mode 100644 index 0000000000..c682826c3e --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/resources/zone/router.ts @@ -0,0 +1,67 @@ +import { AdminAuthorized, zoneContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { createZone, deleteZone, listZones, updateZone } from './business.js' + +export function zoneRouter() { + return serverInstance.router(zoneContract, { + listZones: async () => { + const zones = await listZones() + + return { + status: 200, + body: zones, + } + }, + + createZone: async ({ request: req, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const body = await createZone(data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + updateZone: async ({ request: req, params, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const zoneId = params.zoneId + + const body = await updateZone(zoneId, data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + deleteZone: async ({ request: req, params }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + const zoneId = params.zoneId + + const body = await deleteZone(zoneId, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-side-0/apps/server/src/runtime.ts b/.jjconflict-side-0/apps/server/src/runtime.ts new file mode 100644 index 0000000000..d5b8822533 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/runtime.ts @@ -0,0 +1,22 @@ +import { Agent, cacheStores, interceptors, ProxyAgent, setGlobalDispatcher } from 'undici' + +const base = process.env.HTTP_PROXY ? new ProxyAgent(process.env.HTTP_PROXY) : new Agent() + +// Undici’s cache interceptor follows RFC 7234: +// 1. Only GET/HEAD are cached (configurable via `methods`). +// 2. Cache-Control directives are obeyed: +// - `no-store` → never cache +// - `max-age`, `s-maxage`, `expires` → freshness lifetime +// - `private` → skip if shared store +// - `no-cache` → revalidate with ETag/Last-Modified +// 3. Heuristic caching (status 200 without explicit freshness) +// uses 10 % of time since Last-Modified if present. +// 4. Stale responses are served while revalidating in background +// when `stale-while-revalidate` is present. +const client = base.compose( + interceptors.cache({ + store: new cacheStores.SqliteCacheStore(), + methods: ['GET', 'HEAD'], + }), +) +setGlobalDispatcher(client) diff --git a/.jjconflict-side-0/apps/server/src/server.spec.ts b/.jjconflict-side-0/apps/server/src/server.spec.ts new file mode 100644 index 0000000000..5dc093888a --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/server.spec.ts @@ -0,0 +1,57 @@ +import { logger } from '@cpn-console/logger' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { closeConnections } from './connect.js' +import { exitGracefully, handleExit } from './server.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) +vi.mock('./init/db/index.js', () => ({ initDb: vi.fn() })) +vi.mock('./connect.js') + +vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + +vi.mock('./prepare-app.js', () => { + const app = { + listen: vi.fn(), + close: vi.fn(async () => {}), + } + return { + getPreparedApp: () => Promise.resolve(app), + } +}) +vi.spyOn(logger, 'warn') +vi.spyOn(logger, 'debug') +const infoSpy = vi.spyOn(logger, 'info') +const errorSpy = vi.spyOn(logger, 'error') +const fatalSpy = vi.spyOn(logger, 'fatal') + +describe('server', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should call closeConnections without parameter', async () => { + await exitGracefully() + + expect(closeConnections).toHaveBeenCalledTimes(1) + expect(vi.mocked(closeConnections).mock.calls[0]).toHaveLength(0) + expect(errorSpy).toHaveBeenCalledTimes(0) + }) + + it('should log an error', async () => { + await exitGracefully(new Error('error')) + + expect(closeConnections).toHaveBeenCalledTimes(1) + expect(vi.mocked(closeConnections).mock.calls[0]).toHaveLength(0) + expect(fatalSpy).toHaveBeenCalledTimes(1) + expect(fatalSpy.mock.calls[0]?.[0]).toBeInstanceOf(Error) + expect(infoSpy).toHaveBeenCalledTimes(2) + }) + + it('should call process.on 4 times', () => { + const processOn = vi.spyOn(process, 'on') + + handleExit() + + expect(processOn).toHaveBeenCalledTimes(5) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/server.ts b/.jjconflict-side-0/apps/server/src/server.ts new file mode 100644 index 0000000000..93425dae79 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/server.ts @@ -0,0 +1,44 @@ +import { logger } from '@cpn-console/logger' +import { closeConnections } from './connect.js' +import { getPreparedApp } from './prepare-app.js' +import { isCI, isDev, isDevSetup, isProd, isTest, port } from './utils/env.js' + +const app = await getPreparedApp() + +try { + await app.listen({ host: '0.0.0.0', port: +(port ?? 8080) }) +} catch (error) { + logger.error({ err: error }, 'Failed to start server') + process.exit(1) +} + +logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) + +export async function exitGracefully(error?: Error) { + if (error instanceof Error) { + logger.fatal(error) + } + await app.close() + logger.info('Closing connections...') + await closeConnections() + logger.info('Exiting...') + process.exit(error instanceof Error ? 1 : 0) +} + +function logExitCode(code: number) { + logger.warn(`received signal: ${code}`) +} + +function logUnhandledRejection(reason: unknown, promise: Promise) { + logger.error({ promise, err: reason }, 'Unhandled Rejection') +} + +export function handleExit() { + process.on('exit', logExitCode) + process.on('SIGINT', exitGracefully) + process.on('SIGTERM', exitGracefully) + process.on('uncaughtException', exitGracefully) + process.on('unhandledRejection', logUnhandledRejection) +} + +handleExit() diff --git a/.jjconflict-side-0/apps/server/src/telemetry.ts b/.jjconflict-side-0/apps/server/src/telemetry.ts new file mode 100644 index 0000000000..65b59dddab --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/telemetry.ts @@ -0,0 +1,26 @@ +import { FastifyOtelInstrumentation } from '@fastify/otel' +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' +import { NodeSDK } from '@opentelemetry/sdk-node' + +const sdk = new NodeSDK({ + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({}), + }), + serviceName: 'console-pi-native-console', + traceExporter: new OTLPTraceExporter({}), + instrumentations: [ + getNodeAutoInstrumentations(), + new FastifyOtelInstrumentation({ + registerOnInitialization: true, + }), + ], +}) + +sdk.start() + +process.once('beforeExit', () => { + sdk.shutdown() +}) diff --git a/.jjconflict-side-0/apps/server/src/types/index.ts b/.jjconflict-side-0/apps/server/src/types/index.ts new file mode 100644 index 0000000000..ed39a8f94b --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/types/index.ts @@ -0,0 +1,22 @@ +import { User } from '@cpn-console/shared' + +export type UserDetails = { + id: string + firstName: string + lastName: string + email: string + groups: string[] + type: User['type'], +} + +export interface Resources { + cpu: number + gpu: number + memory: number +} + +declare module 'fastify' { + interface Session { + user?: UserDetails + } +} diff --git a/.jjconflict-side-0/apps/server/src/utils/business.ts b/.jjconflict-side-0/apps/server/src/utils/business.ts new file mode 100644 index 0000000000..10fe3ced2f --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/business.ts @@ -0,0 +1,42 @@ +import type { SharedSafeParseReturnType } from '@cpn-console/shared' +import { parseZodError } from '@cpn-console/shared' +import { BadRequest400 } from './errors.js' + +export type Success = Result +export type Failure = Result +export class Result { + protected constructor( + readonly success: boolean, + readonly value: T | string, + ) {} + + static succeed(value: T): Success { + return new Result(true, value) as Success + } + + static fail(message: string): Failure { + return new Result(false, message) as Failure + } + + get isSuccess(): boolean { + return this.success + } + + get isError(): boolean { + return !this.success + } + + get data(): T { + if (this.success) return this.value as T + throw new Error('Cannot get data from a Failure') + } + + get error(): string { + if (!this.success) return this.value as string + throw new Error('Cannot get error from a Success') + } +} + +export function validateSchema(schemaValidation: SharedSafeParseReturnType) { + if (!schemaValidation.success) return new BadRequest400(parseZodError(schemaValidation.error)) +} diff --git a/.jjconflict-side-0/apps/server/src/utils/controller.ts b/.jjconflict-side-0/apps/server/src/utils/controller.ts new file mode 100644 index 0000000000..57ab7865af --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/controller.ts @@ -0,0 +1,171 @@ +import type { XOR } from '@cpn-console/shared' +import type { Cluster, Prisma, Project, ProjectMembers, ProjectRole } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserTrial } from '@/resources/user/business.js' +import type { UserDetails } from '@/types/index.js' +import { PROJECT_PERMS as PP, PROJECT_PERMS, projectIsLockedInfo, tokenHeaderName } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { logViaSession, logViaToken } from '@/resources/user/business.js' +import { Unauthorized401 } from './errors.js' +import { uuid } from './queries-tools.js' + +export type RequireOnlyOne + = Pick> + & { + [K in Keys]-?: + Required> + & Partial, undefined>> + }[Keys] + +type ErrorMessagePredicate = () => string | undefined +export function getErrorMessage(...fns: ErrorMessagePredicate[]) { + for (const f of fns) { + const error = f() + if (error) { + return error + } + } +} + +/** + * Renvoie une erreur si le projet est verrouillé + */ +export function checkProjectLocked(project: { locked: boolean }): string { + return project.locked + ? projectIsLockedInfo + : '' +} + +export function checkLocked(project: { locked: Project['locked'] }): string { + return checkProjectLocked(project) +} + +export function checkClusterUnavailable(clusterId: Cluster['id'], authorizedClusterIds: Cluster['id'][]): string { + return authorizedClusterIds.includes(clusterId) + ? '' + : 'Ce cluster n\'est pas disponible pour cette combinaison projet et stage' +} + +export const splitStringsFilterArray = >(toMatch: T, inputs: string): T => inputs.split(',').filter(i => toMatch.includes(i)) as unknown as T + +type StringArray = string[] +interface WhereBuilderParams { + enumValues: T + eqValue: T[number] | undefined + inValues: string | undefined + notInValues: string | undefined +} + +export function whereBuilder({ enumValues, eqValue, inValues, notInValues }: WhereBuilderParams) { + if (eqValue) { + return eqValue + } else if (inValues) { + return { in: splitStringsFilterArray(enumValues, inValues) } + } else if (notInValues) { + return { notIn: splitStringsFilterArray(enumValues, notInValues) } + } +} + +type ProjectMinimalPerms = Pick & { roles: ProjectRole[], members: ProjectMembers[] } +export interface UserProfile { user?: UserDetails, adminPermissions: bigint, tokenId?: string } +export interface ProjectPermState { projectPermissions?: bigint, projectId: Project['id'], projectLocked: boolean, projectStatus: Project['status'], projectOwnerId: Project['ownerId'] } +export type UserProjectProfile = UserProfile & ProjectPermState + +type ProjectUniqueFinder = XOR< + { slug: string }, + XOR<{ environmentId: string }, XOR<{ repositoryId: string }, { id: string }>> +> + +const projectPermsSelect = { roles: true, members: true, everyonePerms: true, ownerId: true, id: true, locked: true, status: true } as const satisfies Prisma.ProjectSelect + +export async function authUser(req: FastifyRequest): Promise +export async function authUser(req: FastifyRequest, projectUnique: ProjectUniqueFinder): Promise +export async function authUser(req: FastifyRequest, projectUnique?: ProjectUniqueFinder): Promise { + let adminPermissions: bigint = 0n + let tokenId: string | undefined + const reqUser: UserTrial = req.session.user as unknown as UserTrial + let user: UserDetails | undefined + + if (req.session.user) { + const loginResult = await logViaSession(reqUser) + user = { + ...loginResult.user, + groups: reqUser.groups, + } + adminPermissions = loginResult.adminPerms + } else { + const tokenHeader = req.headers[tokenHeaderName] + if (typeof tokenHeader === 'string') { + const resultToken = await logViaToken(tokenHeader) + if (typeof resultToken === 'string') { + throw new Unauthorized401(resultToken) + } + adminPermissions = resultToken.adminPerms ?? 0n + tokenId = resultToken.user.tokenId + if (!user && resultToken.user) { + user = { ...resultToken.user, groups: [] } + } + } + } + + const baseReturnInfos = { + user, + adminPermissions, + tokenId, + } + if (!projectUnique || !user) { + return baseReturnInfos + } + let project: ProjectMinimalPerms | null | undefined + + if (projectUnique.repositoryId) { + project = (await prisma.repository.findUnique({ + where: { id: projectUnique.repositoryId }, + select: { project: { select: projectPermsSelect } }, + }))?.project + } else if (projectUnique.environmentId) { + project = (await prisma.environment.findUnique({ + where: { id: projectUnique.environmentId }, + select: { project: { select: projectPermsSelect } }, + }))?.project + } else if (projectUnique.id) { + project = uuid.test(projectUnique.id) + ? await prisma.project.findUnique({ + where: { id: projectUnique.id }, + select: projectPermsSelect, + }) + : await prisma.project.findUnique({ + where: { slug: projectUnique.id }, + select: projectPermsSelect, + }) + } else if (projectUnique.slug) { + project = await prisma.project.findFirstOrThrow({ + where: { slug: projectUnique.slug }, + select: projectPermsSelect, + }) + } + if (!project) { + return baseReturnInfos + } + + const projectPermissions = getProjectPermissions(project, user) + + return { + user, + adminPermissions, + projectPermissions, + projectId: project.id, + projectLocked: project.locked, + projectStatus: project.status, + projectOwnerId: project.ownerId, + } +} + +function getProjectPermissions(project: ProjectMinimalPerms, user: UserDetails): bigint | undefined { + if (project.ownerId === user.id) return PP.MANAGE + const member = project.members.find(member => member.userId === user.id) + if (!member) return + + const memberRoles = project.roles.filter(role => member.roleIds.includes(role.id)) + return memberRoles.reduce((acc, curr) => acc | curr.permissions, project.everyonePerms | PROJECT_PERMS.GUEST) +} diff --git a/.jjconflict-side-0/apps/server/src/utils/date.spec.ts b/.jjconflict-side-0/apps/server/src/utils/date.spec.ts new file mode 100644 index 0000000000..7abcaa1aaa --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/date.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { getJSDateFromUtcIso } from './date.js' + +describe('date-util', () => { + it('should return a native Date object', () => { + const date = '2022-10-11' + + const received = getJSDateFromUtcIso(date) + + expect(received.getMonth()).toBe(9) + expect(received.getFullYear()).toBe(2022) + expect(received.getDate()).toBeGreaterThan(10) + expect(received.getDate()).toBeLessThan(12) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/utils/date.ts b/.jjconflict-side-0/apps/server/src/utils/date.ts new file mode 100644 index 0000000000..87473d2625 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/date.ts @@ -0,0 +1,5 @@ +import { parseISO } from 'date-fns' + +export function getJSDateFromUtcIso(dateUtcIso: string) { + return parseISO(dateUtcIso) +} diff --git a/.jjconflict-side-0/apps/server/src/utils/env.ts b/.jjconflict-side-0/apps/server/src/utils/env.ts new file mode 100644 index 0000000000..2ca618fd19 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/env.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +// application mode +export const isDev = process.env.NODE_ENV === 'development' +export const isTest = process.env.NODE_ENV === 'test' +export const isProd = process.env.NODE_ENV === 'production' +export const isInt = process.env.INTEGRATION === 'true' +export const isCI = process.env.CI === 'true' +export const isDevSetup = process.env.DEV_SETUP === 'true' + +// app +export const port = process.env.SERVER_PORT +export const appVersion = isProd + ? (process.env.APP_VERSION ?? 'unknown') + : 'dev' + +// db +export const dbUrl = process.env.DB_URL + +// keycloak +export const sessionSecret = process.env.SESSION_SECRET +export const keycloakProtocol = process.env.KEYCLOAK_PROTOCOL +export const keycloakDomain = process.env.KEYCLOAK_DOMAIN +export const keycloakRealm = process.env.KEYCLOAK_REALM +export const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID +export const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET +export const keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI +export const adminsUserId = process.env.ADMIN_KC_USER_ID + ? process.env.ADMIN_KC_USER_ID.split(',') + : [] + +export const contactEmail = process.env.CONTACT_EMAIL ?? 'cloudpinative-relations@interieur.gouv.fr' + +// plugins +export const mockPlugins = process.env.MOCK_PLUGINS === 'true' +export const projectRootDir = process.env.PROJECTS_ROOT_DIR +export const pluginsDir = process.env.PLUGINS_DIR ?? '/plugins' +export const NODE_ENV = process.env.NODE_ENV === 'test' + ? 'test' + : process.env.NODE_ENV === 'development' + ? 'development' + : 'production' + +// server tuning +export const parallelBulkLimit = process.env.PARALLEL_BULK_LIMIT ? Number(process.env.PARALLEL_BULK_LIMIT) : 5 diff --git a/.jjconflict-side-0/apps/server/src/utils/errors.ts b/.jjconflict-side-0/apps/server/src/utils/errors.ts new file mode 100644 index 0000000000..0f1dd07fbe --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/errors.ts @@ -0,0 +1,48 @@ +export class ErrorResType { + readonly status: 400 | 401 | 403 | 404 | 422 | 500 + body: { message: string } = { message: '' } + constructor(code: 400 | 401 | 403 | 404 | 422 | 500) { + this.status = code + } +} +export class BadRequest400 extends ErrorResType { + constructor(message: string) { + super(400) + this.body.message = message ?? 'Bad Request' + } +} + +export class Unauthorized401 extends ErrorResType { + constructor(message?: string) { + super(401) + this.body.message = message ?? 'Unauthorized' + } +} + +export class Forbidden403 extends ErrorResType { + constructor(message?: string) { + super(403) + this.body.message = message ?? 'Forbidden' + } +} + +export class NotFound404 extends ErrorResType { + constructor() { + super(404) + this.body.message = 'Not Found' + } +} + +export class Unprocessable422 extends ErrorResType { + constructor(message?: string) { + super(422) + this.body.message = message ?? 'Unprocessable Entity' + } +} + +export class Internal500 extends ErrorResType { + constructor(message: string) { + super(500) + this.body.message = message + } +} diff --git a/.jjconflict-side-0/apps/server/src/utils/fastify.ts b/.jjconflict-side-0/apps/server/src/utils/fastify.ts new file mode 100644 index 0000000000..c51281e3e9 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/fastify.ts @@ -0,0 +1,54 @@ +import type { FastifySwaggerUiOptions } from '@fastify/swagger-ui' +import type { generateOpenApi } from '@ts-rest/open-api' +import type { FastifyServerOptions } from 'fastify' +import { randomUUID } from 'node:crypto' +import { getLoggerOptionsFromEnv } from '@cpn-console/logger' +import { swaggerUiPath } from '@cpn-console/shared' +import { + appVersion, + keycloakClientId, + keycloakClientSecret, + keycloakRealm, + keycloakRedirectUri, +} from './env.js' + +export const fastifyConf: FastifyServerOptions = { + maxParamLength: 5000, + logger: getLoggerOptionsFromEnv(), + genReqId: () => randomUUID(), +} + +const externalDocs = { + description: 'External documentation.', + url: 'https://cloud-pi-native.fr', +} + +export const swaggerConf: Parameters[1] = { + info: { + title: 'Console Cloud Pi Native', + description: 'API de gestion des ressources Cloud Pi Native.', + version: appVersion, + }, + + externalDocs, + servers: [ + { + url: keycloakRedirectUri, + }, + ], +} + +export const swaggerUiConf: FastifySwaggerUiOptions = { + routePrefix: `${swaggerUiPath}-server`, + uiConfig: { + docExpansion: 'list', + deepLinking: false, + }, + initOAuth: { + clientId: keycloakClientId, + clientSecret: keycloakClientSecret, + realm: keycloakRealm, + appName: 'Cloud Pi Native', + scopes: 'openid generic', + }, +} diff --git a/.jjconflict-side-0/apps/server/src/utils/hook-wrapper.spec.ts b/.jjconflict-side-0/apps/server/src/utils/hook-wrapper.spec.ts new file mode 100644 index 0000000000..c2f4e3ebcc --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/hook-wrapper.spec.ts @@ -0,0 +1,239 @@ +import type { KubeCluster, KubeUser, Project as ProjectPayload, Store } from '@cpn-console/hooks' +import type { ProjectInfos, ReposCreds } from './hook-wrapper.ts' +import { describe, expect, it } from 'vitest' +import { transformToHookProject } from './hook-wrapper.ts' + +const associatedCluster = { + id: 'f0e39981-0b6d-4c16-aa96-225062b75767', + infos: '', + label: 'carno', + privacy: 'dedicated', + secretName: '4a38422c-29e1-4b61-b533-edaa1b8a9b60', + kubeconfig: { + id: 'c8ba6db2-9a1d-4d6b-8b5e-2902cecd1437', + user: { + keyData: 'REDACTED', + certData: 'REDACTED', + }, + cluster: { + caData: 'REDACTED', + server: 'https://api-server:6443', + skipTLSVerify: false, + tlsServerName: 'api-server', + }, + createdAt: '2024-05-02T09:17:27.882Z', + updatedAt: '2024-05-02T09:17:27.882Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0', + slug: 'default', + }, +} +const nonAssociatedCluster = { + id: 'f0e39981-0b6d-4c16-aa96-225062b75111', + infos: '', + label: 'carno2', + privacy: 'dedicated', + secretName: '4a38422c-29e1-4b61-b533-edaa1b8a9111', + kubeconfig: { + id: 'c8ba6db2-9a1d-4d6b-8b5e-2902cecd1111', + user: { + keyData: 'REDACTED', + certData: 'REDACTED', + }, + cluster: { + caData: 'REDACTED', + server: 'https://api-server:6443', + skipTLSVerify: false, + tlsServerName: 'api-server', + }, + createdAt: '2024-05-02T09:17:27.882Z', + updatedAt: '2024-05-02T09:17:27.882Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0', + slug: 'default', + }, +} +const project: ProjectInfos = { + id: '011e7860-04d7-461f-912d-334c622d38b3', + name: 'candilib', + description: 'Application de réservation de places à l\'examen du permis B.', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.778Z', + updatedAt: '2023-07-03T14:46:56.783Z', + everyonePerms: 896n, + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + members: [], + clusters: [associatedCluster, nonAssociatedCluster], + environments: [ + { + id: '1b9f1053-fcf5-4053-a7b2-ff8a2c0c1921', + name: 'dev', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.787Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + quota: { + id: '5a57b62f-2465-4fb6-a853-5a751d099199', + memory: '4Gi', + cpu: 2, + name: 'small', + isPrivate: false, + }, + stage: { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + name: 'dev', + }, + cluster: { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + infos: 'Floating IP : 0.0.0.0', + label: 'pas-top-cluster', + privacy: 'dedicated', + secretName: '94d52618-7869-4192-b33e-85dd0959e815', + kubeconfig: { + id: 'b5662039-a62b-483e-ba54-b12c6f966c96', + user: { + token: 'kirikou', + }, + cluster: { + server: 'https://pwned.cluster', + tlsServerName: 'pwned.cluster', + }, + createdAt: '2024-07-24T16:54:14.969Z', + updatedAt: '2024-07-24T16:54:14.969Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + }, + }, + }, + { + id: '1c654f00-4798-4a80-929f-960ddb37885a', + name: 'integration', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + quota: { + id: '5a57b62f-2465-4fb6-a853-5a751d099199', + memory: '4Gi', + cpu: 2, + name: 'small', + isPrivate: false, + }, + stage: { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + name: 'integration', + }, + cluster: { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + infos: null, + label: 'top-secret-cluster', + privacy: 'dedicated', + secretName: '59be2d50-58f9-42f3-95dc-b0c0518e3d8a', + kubeconfig: { + id: '0e88f000-07e6-4781-a69d-0963489387f7', + user: { + token: 'nyan cat', + }, + cluster: { + server: 'https://nothere.cluster', + skipTLSVerify: false, + tlsServerName: 'nothere.cluster', + }, + createdAt: '2024-07-24T16:54:14.966Z', + updatedAt: '2024-07-24T16:54:14.966Z', + }, + clusterResources: true, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + }, + }, + }, + ], + repositories: [ + { + id: '299216bb-2dcc-42b5-ac71-6aa001d2dccf', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/dnum-mi/candilib.git', + externalUserName: 'this-is-a-test', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.802Z', + }, + ], + plugins: [], + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', + email: 'test@test.com', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + }, + roles: [], +} + +describe('transformToHookProject', () => { + // Mock data + const mockStore: Store = {} + const mockReposCreds: ReposCreds = { + console: { + token: 'test', + username: 'test', + }, + } + + it('transforme correctement le projet en objet Payload', () => { + const result: ProjectPayload = transformToHookProject(project, mockStore, mockReposCreds) + + // Asserts pour vérifier la transformation + + // Assert sur la transformation des utilisateurs + expect(result.users).toEqual([project.owner]) + + // Assert sur la transformation des rôles + expect(result.roles).toEqual([{ + name: 'owner', + position: 0, + users: [project.owner], + }]) + + // Assert sur la transformation des clusters + expect(result.clusters).toEqual([associatedCluster, nonAssociatedCluster].map(({ kubeconfig, ...cluster }) => ({ + user: kubeconfig.user as unknown as KubeUser, + cluster: kubeconfig.cluster as unknown as KubeCluster, + ...cluster, + privacy: cluster.privacy, + }))) + + // Assert sur la transformation des environnements + expect(result.environments).toEqual(project.environments.map(({ permissions: _, stage, quota, ...environment }) => ({ + quota, + stage: stage.name, + permissions: [{ permissions: { rw: true, ro: true }, userId: project.ownerId }], + ...environment, + apis: {}, + }))) + + // Assert sur la transformation des repositories + expect(result.repositories).toEqual(project.repositories.map(repo => ({ ...repo, newCreds: mockReposCreds[repo.internalRepoName] }))) + + // Assert sur le store + expect(result.store).toEqual(mockStore) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/utils/hook-wrapper.ts b/.jjconflict-side-0/apps/server/src/utils/hook-wrapper.ts new file mode 100644 index 0000000000..0df3d89a4b --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/hook-wrapper.ts @@ -0,0 +1,419 @@ +import type { ClusterObject, HookResult, KubeCluster, KubeUser, Project as ProjectPayload, RepoCreds, Repository, Store, ZoneObject } from '@cpn-console/hooks' +import type { AsyncReturnType } from '@cpn-console/shared' +import type { Cluster, Kubeconfig, Project, ProjectMembers, ProjectRole, Zone } from '@prisma/client' +import type { ConfigRecords } from '@/resources/project-service/business.js' +import { hooks } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { getPermsByUserRoles, ProjectAuthorized, resourceListToDict } from '@cpn-console/shared' +import { dbToObj } from '@/resources/project-service/business.js' +import { archiveProject, getAdminPlugin, getAdminRoleById, getClusterByIdOrThrow, getClusterNamesByZoneId, getClustersAssociatedWithProject, getHookProjectInfos, getHookRepository, getProjectStore, getRole, getZoneByIdOrThrow, saveProjectStore, updateProjectClusterHistory, updateProjectCreated, updateProjectFailed, updateProjectWarning } from '@/resources/queries-index.js' +import { genericProxy } from './proxy.js' + +export type ReposCreds = Record +export type ProjectInfos = AsyncReturnType + +const logger = baseLogger.child({ scope: 'utils:hook-wrapper' }) + +function summarizeHookResultForLogs(hookResult: HookResult) { + const nonOkResults = Object.fromEntries( + Object.entries(hookResult.results ?? {}) + .filter(([_pluginName, result]) => result?.status?.result !== 'OK') + .map(([pluginName, result]) => [ + pluginName, + { + result: result.status.result, + message: result.status.result === 'OK' ? undefined : result.status.message, + }, + ]), + ) + + return { + failed: hookResult.failed, + warning: hookResult.warning, + messageResume: hookResult.messageResume, + totalExecutionTime: hookResult.totalExecutionTime, + nonOkResults, + } +} + +async function getProjectPayload(projectId: Project['id'], reposCreds?: ReposCreds) { + const [ + project, + store, + clusters, + ] = await Promise.all([ + getHookProjectInfos(projectId), + getProjectStore(projectId), + getClustersAssociatedWithProject(projectId), + ]) + + return transformToHookProject({ + ...project, + clusters, + }, dbToObj(store), reposCreds) +} + +async function upsertProject(projectId: Project['id'], reposCreds?: ReposCreds) { + const [payload, config] = await Promise.all([ + getProjectPayload(projectId, reposCreds), + getAdminPlugin(), + ]) + + const results = await hooks.upsertProject.execute(payload, dbToObj(config)) + + const records: ConfigRecords = Object.entries(results.results).reduce((acc, [pluginName, result]) => { + if (result.store) { + return [...acc, ...Object.entries(result.store).map(([key, value]) => ({ pluginName, key, value: String(value) }))] + } + return acc + }, [] as ConfigRecords) + + await saveProjectStore(records, projectId) + const project = await manageProjectStatus(projectId, results, 'upsert', payload.environments.map(env => env.clusterId)) + return { + results, + project, + } +} +const project = { + upsert: async (projectId: Project['id'], reposCreds?: ReposCreds) => { + const first = await upsertProject(projectId, reposCreds) + if (!first.results.failed) { + return first + } + + logger.warn({ + action: 'upsertProject', + projectId, + attempt: 1, + maxAttempts: 2, + ...summarizeHookResultForLogs(first.results), + }, 'Hook upsertProject failed, retrying once') + + const second = await upsertProject(projectId, reposCreds) + const logPayload = { + action: 'upsertProject', + projectId, + attempt: 2, + maxAttempts: 2, + ...summarizeHookResultForLogs(second.results), + } + if (second.results.failed) { + logger.error(logPayload, 'Hook upsertProject retry failed') + } else { + logger.info(logPayload, 'Hook upsertProject retry succeeded') + } + + return second + }, + delete: async (projectId: Project['id']) => { + const [payload, config] = await Promise.all([ + getProjectPayload(projectId), + getAdminPlugin(), + ]) + const results = await hooks.deleteProject.execute(payload, dbToObj(config)) + return { + results, + project: await manageProjectStatus(projectId, results, 'delete', []), + } + }, + getSecrets: async (projectId: Project['id']) => { + const project = await getHookProjectInfos(projectId) + const store = dbToObj(await getProjectStore(project.id)) + const config = dbToObj(await getAdminPlugin()) + + return hooks.getProjectSecrets.execute({ ...project, store }, config) + }, +} as const + +type ProjectAction = keyof typeof project +async function manageProjectStatus(projectId: Project['id'], hookReply: HookResult, action: ProjectAction, envClusterIds: Cluster['id'][]): Promise> { + if (!hookReply.failed && hookReply.results?.kubernetes) { + await updateProjectClusterHistory(projectId, envClusterIds) + } + if (hookReply.failed) { + return updateProjectFailed(projectId) + } else if (hookReply.warning.length) { + return updateProjectWarning(projectId) + } else if (action === 'upsert') { + return updateProjectCreated(projectId) + } else if (action === 'delete') { + return archiveProject(projectId) + } + throw new Error('unknown action') +} + +const cluster = { + upsert: async (clusterId: Cluster['id'], previousZoneId: Cluster['zoneId']) => { + const cluster = await getClusterByIdOrThrow(clusterId) + const clusterObject = cluster as unknown as ClusterObject + const store = dbToObj(await getAdminPlugin()) + if (cluster.zoneId !== previousZoneId) { + // Upsert on the old zone to remove cluster + const previousClusterObject = { + ...cluster, + } as unknown as ClusterObject + previousClusterObject.zone = await getZoneByIdOrThrow(previousZoneId) + previousClusterObject.zone.clusterNames = await getClusterNamesByZoneId(previousZoneId) + const hookResult = await hooks.upsertCluster.execute({ + ...cluster.kubeconfig as unknown as Pick, + ...previousClusterObject, + }, store) + if (hookResult.failed) { + return hookResult + } + } + clusterObject.zone.clusterNames = await getClusterNamesByZoneId(cluster.zoneId) + return hooks.upsertCluster.execute({ + ...cluster.kubeconfig as unknown as Pick, + ...clusterObject, + }, store) + }, + delete: async (clusterId: Cluster['id']) => { + const cluster = await getClusterByIdOrThrow(clusterId) + const clusterObject = cluster as unknown as ClusterObject + const clusterNames = await getClusterNamesByZoneId(cluster.zoneId) + clusterObject.zone.clusterNames = clusterNames.filter(c => c !== cluster.label) + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteCluster.execute({ + ...cluster.kubeconfig as unknown as ClusterObject, + ...clusterObject, + }, store) + }, +} as const + +const user = { + retrieveUserByEmail: async (email: string) => { + const config = dbToObj(await getAdminPlugin()) + return hooks.retrieveUserByEmail.execute({ email }, config) + }, +} as const + +const projectMember = { + upsert: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { + const project = await getHookProjectInfos(projectId) + const projectStore = dbToObj(await getProjectStore(project.id)) + const hookProject = transformToHookProject(project, projectStore) + const store = dbToObj(await getAdminPlugin()) + + const member = project.members.find(m => m.userId === userId) + if (!member) throw new Error('Member not found') + + const memberRoles = project.roles + .filter(role => member.roleIds.includes(role.id)) + .map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ?? undefined, + project: hookProject, + })) + + const payload = { + userId: member.userId, + roleIds: member.roleIds, + firstName: member.user.firstName, + lastName: member.user.lastName, + email: member.user.email, + type: member.user.type as 'human' | 'bot' | 'ghost', + createdAt: member.user.createdAt.toISOString(), + updatedAt: member.user.updatedAt.toISOString(), + lastLogin: member.user.lastLogin?.toISOString(), + projectId: project.id, + roles: memberRoles, + project: hookProject, + } + + return hooks.upsertProjectMember.execute(payload, store) + }, + delete: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { + const project = await getHookProjectInfos(projectId) + const projectStore = dbToObj(await getProjectStore(project.id)) + const hookProject = transformToHookProject(project, projectStore) + const store = dbToObj(await getAdminPlugin()) + + const member = project.members.find(m => m.userId === userId) + if (!member) throw new Error('Member not found') + + const memberRoles = project.roles + .filter(role => member.roleIds.includes(role.id)) + .map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ?? undefined, + project: hookProject, + })) + + const payload = { + userId: member.userId, + roleIds: member.roleIds, + firstName: member.user.firstName, + lastName: member.user.lastName, + email: member.user.email, + type: member.user.type as 'human' | 'bot' | 'ghost', + createdAt: member.user.createdAt.toISOString(), + updatedAt: member.user.updatedAt.toISOString(), + lastLogin: member.user.lastLogin?.toISOString(), + projectId: project.id, + roles: memberRoles, + project: hookProject, + } + + return hooks.deleteProjectMember.execute(payload, store) + }, +} as const + +const projectRole = { + upsert: async (roleId: ProjectRole['id']) => { + const role = await getRole(roleId) + if (!role) throw new Error('Role not found') + + const project = await getHookProjectInfos(role.projectId) + const projectStore = dbToObj(await getProjectStore(role.projectId)) + const hookProject = transformToHookProject(project, projectStore) + + const rolePayload = { + ...role, + permissions: role.permissions.toString(), + project: hookProject, + } + const store = dbToObj(await getAdminPlugin()) + return hooks.upsertProjectRole.execute(rolePayload, store) + }, + delete: async (roleId: ProjectRole['id']) => { + const role = await getRole(roleId) + if (!role) throw new Error('Role not found') + + const project = await getHookProjectInfos(role.projectId) + const projectStore = dbToObj(await getProjectStore(role.projectId)) + const hookProject = transformToHookProject(project, projectStore) + + const rolePayload = { + ...role, + permissions: role.permissions.toString(), + project: hookProject, + } + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteProjectRole.execute(rolePayload, store) + }, +} as const + +const zone = { + upsert: async (zoneId: Zone['id']) => { + const zone: ZoneObject = await getZoneByIdOrThrow(zoneId) + zone.clusterNames = await getClusterNamesByZoneId(zoneId) + const store = dbToObj(await getAdminPlugin()) + return hooks.upsertZone.execute(zone, store) + }, + delete: async (zoneId: Zone['id']) => { + const zone = await getZoneByIdOrThrow(zoneId) + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteZone.execute(zone, store) + }, +} as const + +const misc = { + checkServices: async () => { + const config = dbToObj(await getAdminPlugin()) + return hooks.checkServices.execute({}, config) + }, + syncRepository: async (repoId: string, { syncAllBranches, branchName }: { syncAllBranches: boolean, branchName?: string }) => { + const { project, ...repoInfos } = await getHookRepository(repoId) + const store = dbToObj(await getProjectStore(project.id)) + const payload = { + repo: { ...repoInfos, syncAllBranches, branchName }, + ...project, + store, + } + const config = dbToObj(await getAdminPlugin()) + return hooks.syncRepository.execute(payload, config) + }, +} as const + +const adminRole = { + upsert: async (roleId: string) => { + const role = await getAdminRoleById(roleId) + if (!role) throw new Error('Role not found') + const config = dbToObj(await getAdminPlugin()) + return hooks.upsertAdminRole.execute(role, config) + }, + delete: async (role: AsyncReturnType) => { + if (!role) throw new Error('Role is required') + const config = dbToObj(await getAdminPlugin()) + return hooks.deleteAdminRole.execute(role, config) + }, +} as const + +export const hook = { + // @ts-ignore TODO voir comment opti la signature de la fonction + misc: genericProxy(misc), + // @ts-ignore TODO voir comment opti la signature de la fonction + project: genericProxy(project, { upsert: ['delete'], delete: ['upsert', 'delete'], getSecrets: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + projectRole: genericProxy(projectRole, { delete: ['upsert', 'delete'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + projectMember: genericProxy(projectMember, { delete: ['upsert'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + cluster: genericProxy(cluster, { delete: ['upsert', 'delete'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + zone: genericProxy(zone, { delete: ['upsert'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + user: genericProxy(user, {}), + // @ts-ignore TODO voir comment opti la signature de la fonction + adminRole: genericProxy(adminRole, { delete: ['upsert'], upsert: ['delete'] }), +} + +function formatClusterInfos({ kubeconfig, ...cluster }: Omit + & { kubeconfig: Kubeconfig, zone: Pick }) { + return { + user: kubeconfig.user as unknown as KubeUser, + cluster: kubeconfig.cluster as unknown as KubeCluster, + ...cluster, + privacy: cluster.privacy, + } +} +export type RolesById = Record + +export function transformToHookProject(project: ProjectInfos, store: Store, reposCreds: ReposCreds = {}): ProjectPayload { + const clusters = project.clusters.map(cluster => formatClusterInfos(cluster)) + const rolesById = resourceListToDict(project.roles) + + return ({ + ...project, + clusters, + environments: project.environments.map(({ stage, ...environment }) => ({ + stage: stage.name, + permissions: [ + { permissions: { rw: true, ro: true }, userId: project.ownerId }, + ...project.members.map(member => ({ + userId: member.userId, + permissions: { + ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions: getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) }), + rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions: getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) }), + }, + })), + ], + ...environment, + apis: {}, + })), + repositories: project.repositories.map(repo => ({ ...repo, newCreds: reposCreds[repo.internalRepoName] })), + store, + users: [project.owner, ...project.members.map(({ user }) => user)], + roles: [ + { + name: 'owner', + position: 0, + users: [project.owner], + }, + ...project.roles.map(role => ({ + name: role.name, + permissions: role.permissions.toString(), + position: role.position, + type: role.type, + oidcGroup: role.oidcGroup, + users: project.members + .filter(member => member.roleIds.includes(role.id)) + .map(member => member.user), + })), + ], + }) +} diff --git a/.jjconflict-side-0/apps/server/src/utils/keycloak-utils.spec.ts b/.jjconflict-side-0/apps/server/src/utils/keycloak-utils.spec.ts new file mode 100644 index 0000000000..42c9cfae04 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/keycloak-utils.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { userPayloadMapper } from './keycloak-utils.js' + +describe('keycloak', () => { + it('should map keycloak user object to DSO user object without groups', () => { + const payload = { + sub: 'thisIsAnId', + email: 'test@test.com', + given_name: 'Jean', + family_name: 'DUPOND', + } + const desired = { + id: 'thisIsAnId', + email: 'test@test.com', + firstName: 'Jean', + lastName: 'DUPOND', + groups: [], + } + + const transformed = userPayloadMapper(payload) + + expect(transformed).toMatchObject(desired) + }) + + it('should map keycloak user object to DSO user object with groups', () => { + const payload = { + sub: 'thisIsAnId', + email: 'test@test.com', + given_name: 'Jean', + family_name: 'DUPOND', + groups: ['group1'], + } + const desired = { + id: 'thisIsAnId', + email: 'test@test.com', + firstName: 'Jean', + lastName: 'DUPOND', + groups: ['group1'], + } + + const transformed = userPayloadMapper(payload) + + expect(transformed).toMatchObject(desired) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/utils/keycloak-utils.ts b/.jjconflict-side-0/apps/server/src/utils/keycloak-utils.ts new file mode 100644 index 0000000000..a86a5f13c3 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/keycloak-utils.ts @@ -0,0 +1,27 @@ +import type { FastifyRequest } from 'fastify' +import { tokenHeaderName } from '@cpn-console/shared' + +interface KeycloakPayload { + sub: string + email: string + given_name: string + family_name: string + groups: string[] +} + +export function userPayloadMapper(userPayload: KeycloakPayload) { + return { + id: userPayload.sub, + email: userPayload.email, + firstName: userPayload.given_name, + lastName: userPayload.family_name, + groups: userPayload.groups || [], + } +} + +export function bypassFn(request: FastifyRequest) { + try { + return !!request.headers[tokenHeaderName] + } catch {} + return false +} diff --git a/.jjconflict-side-0/apps/server/src/utils/keycloak.ts b/.jjconflict-side-0/apps/server/src/utils/keycloak.ts new file mode 100644 index 0000000000..73137efc3b --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/keycloak.ts @@ -0,0 +1,42 @@ +import type { KeycloakOptions } from 'fastify-keycloak-adapter' +import { serviceContract, swaggerUiPath, systemContract } from '@cpn-console/shared' +import { + keycloakClientId, + keycloakClientSecret, + keycloakDomain, + keycloakProtocol, + keycloakRealm, + keycloakRedirectUri, + sessionSecret, +} from './env.js' +import { bypassFn, userPayloadMapper } from './keycloak-utils.js' + +export const keycloakConf = { + appOrigin: keycloakRedirectUri ?? 'http://localhost:8080', + keycloakSubdomain: `${keycloakDomain}/realms/${keycloakRealm}`, + clientId: keycloakClientId ?? '', + clientSecret: keycloakClientSecret ?? '', + useHttps: keycloakProtocol === 'https', + disableCookiePlugin: true, + disableSessionPlugin: true, + // @ts-ignore + userPayloadMapper, + retries: 5, + excludedPatterns: [ + systemContract.getVersion.path, + systemContract.getHealth.path, + serviceContract.getServiceHealth.path, + `${swaggerUiPath}-server/**`, + ], + bypassFn, +} as const satisfies KeycloakOptions + +export const sessionConf = { + cookieName: 'sessionId', + secret: sessionSecret || 'a-very-strong-secret-with-more-than-32-char', + cookie: { + httpOnly: true, + secure: true, + }, + expires: 1_800_000, +} diff --git a/.jjconflict-side-0/apps/server/src/utils/mocks.ts b/.jjconflict-side-0/apps/server/src/utils/mocks.ts new file mode 100644 index 0000000000..16169f4d31 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/mocks.ts @@ -0,0 +1,152 @@ +import type { PluginsManifests, RepoCreds, ServiceInfos } from '@cpn-console/hooks' +import type { Repository } from '@prisma/client' +import type { UserDetails } from '../types/index.js' +import type * as utilsController from '../utils/controller.js' +import { editStrippers, populatePluginManifests } from '@cpn-console/hooks' +import { DEFAULT, DISABLED, PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import fp from 'fastify-plugin' + +let requestor: Requestor + +export function setRequestor(user: Requestor = getRandomRequestor()) { + requestor = user +} + +export function getRequestor() { + return requestor +} + +export async function mockSessionPlugin() { + const sessionPlugin = (app: any, opt: any, next: any) => { + app.addHook('onRequest', (req: any, res: any, next: any) => { + req.session = { user: getRequestor() } + next() + }) + next() + } + + return { default: fp(sessionPlugin) } +} + +export async function mockHooksPackage() { + const hookTemplate = { + execute: () => ({ + args: {}, + failed: false, + }), + validate: () => ({ + failed: false, + }), + } + + return { + editStrippers, + populatePluginManifests, + services: { + getStatus: () => [], + refreshStatus: async () => [], + }, + PluginApi: class { }, + servicesInfos: { + registry: { title: 'Harbor', name: 'registry', to: () => 'test' }, + plugin2: { title: 'Plugin2', name: 'plugin2', to: () => ({ to: 'test', title: 'Test' }) }, + plugin3: { title: 'Plugin3', name: 'plugin3', to: () => [{ to: 'test', title: 'Test' }] }, + plugin4: { title: 'Plugin4', name: 'plugin4', to: () => [{ to: 'test' }] }, + plugin5: { title: 'Plugin5', name: 'plugin5' }, + } as Record, + pluginsManifests: { + registry: { + title: 'Harbor', + global: [{ + kind: 'switch', + initialValue: DEFAULT, + key: 'test2', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Test2', + value: DEFAULT, + description: 'description', + }], + project: [{ + kind: 'switch', + key: 'test2', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Test', + value: DEFAULT, + initialValue: DISABLED, + }], + }, + } as PluginsManifests, + hooks: { + // projects + getProjectSecrets: { + execute: () => ({ + failed: false, + args: {}, + results: { + registry: { + secrets: { + token: 'myToken', + }, + status: { + failed: false, + }, + }, + }, + }), + }, + upsertProject: hookTemplate, + deleteProject: hookTemplate, + // clusters + upsertCluster: hookTemplate, + deleteCluster: hookTemplate, + // user + retrieveUserByEmail: hookTemplate, + }, + } +} + +export type ReposCreds = Record + +type Requestor = Partial +export function getRandomRequestor(user?: Requestor): Partial { + return { + id: user?.id ?? faker.string.uuid(), + email: user?.email ?? faker.internet.email(), + firstName: user?.firstName ?? faker.person.firstName(), + lastName: user?.lastName ?? faker.person.lastName(), + type: 'human', + ...user?.groups !== null && { groups: user?.groups ?? [] }, + } +} + +export function getUserMockInfos(adminPermissions?: bigint, user?: UserDetails): utilsController.UserProfile & utilsController.ProjectPermState +export function getUserMockInfos(adminPermissions?: bigint, user?: UserDetails, project?: utilsController.ProjectPermState): utilsController.UserProjectProfile & utilsController.ProjectPermState +export function getUserMockInfos(adminPermissions: bigint = 0n, user = getRandomRequestor(), project?: utilsController.ProjectPermState): utilsController.UserProfile | utilsController.UserProjectProfile { + return { + adminPermissions, + user, + ...project, + } +} + +export function getProjectMockInfos({ projectId, projectLocked, projectOwnerId, projectPermissions, projectStatus }: Partial): utilsController.ProjectPermState { + return { + projectId: projectId ?? faker.string.uuid(), + projectLocked: projectLocked ?? false, + projectOwnerId: projectOwnerId ?? faker.string.uuid(), + projectStatus: projectStatus ?? 'created', + projectPermissions: projectPermissions ?? PROJECT_PERMS.MANAGE, + } +} + +export const atDates = { + createdAt: new Date(), + updatedAt: new Date(), +} diff --git a/.jjconflict-side-0/apps/server/src/utils/plugins.ts b/.jjconflict-side-0/apps/server/src/utils/plugins.ts new file mode 100644 index 0000000000..9876677766 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/plugins.ts @@ -0,0 +1,9 @@ +import type { PluginManagerOptions } from '@cpn-console/hooks' +import { isCI, isInt, isProd } from './env.js' + +export const pluginManagerOptions: PluginManagerOptions = { + mockHooks: isCI || (!isProd && !isInt), + mockMonitoring: isCI || (!isProd && !isInt), + mockExternalServices: isCI || (!isProd && !isInt), + startPlugins: (!isCI && isProd) || isInt, +} diff --git a/.jjconflict-side-0/apps/server/src/utils/proxy.spec.ts b/.jjconflict-side-0/apps/server/src/utils/proxy.spec.ts new file mode 100644 index 0000000000..7fdc203833 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/proxy.spec.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { genericProxy } from './proxy.js' + +// Création d'une cible de test +const target = { + async fetchData(id: string) { + return { id, data: 'Mocked data' } + }, + async otherMethod(id: string) { + return { id, data: 'Mocked data' } + }, +} + +describe('test calls without ID passed', () => { + // Test d'appel de méthode sans ID + it('calling method without ID', async () => { + const proxied = genericProxy(target) + const result = await proxied.fetchData() + expect(result).toEqual({ id: undefined, data: 'Mocked data' }) + }) + + // Fonction de test asynchrone pour tester le cas où aucune ID n'est fournie + it('test when no ID is provided', async () => { + // Création d'une cible de test + const target = { + async fetchData() { + return 'No ID provided' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Appel à la méthode fetchData sans ID + const result = await proxied.fetchData() + + // Vérification que le résultat est correct + expect(result).toBe('No ID provided') + }) + + // Fonction de test asynchrone pour tester le cas où aucune ID n'est fournie avec une promesse en cours + it('test when no ID is provided with pending promise', async () => { + // Création d'une cible de test + const target = { + async fetchData() { + return new Promise(resolve => setTimeout(resolve, 100, 'Pending result')) + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Appel à la méthode fetchData sans ID + const promise1 = proxied.fetchData() + const promise2 = proxied.fetchData() // Deuxième appel avant la résolution du premier + + // Attendre que la première promesse se résolve + const result1 = await promise1 + + // Vérification que le résultat de la première promesse est correct + expect(result1).toBe('Pending result') + + // Attendre que la deuxième promesse se résolve + const result2 = await promise2 + + // Vérification que le résultat de la deuxième promesse est correct + expect(result2).toBe('Pending result') + }) + // Test pour vérifier que l'erreur est levée lorsque args est fourni sans ID + it('test error when args provided without ID', async () => { + // Création d'une cible de test + const target = { + async fetchData(_id: string, _args: any) { + return 'No ID provided' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + const args = { key: 'value' } + + // Appel de la fonction fetchData avec des arguments mais sans ID + await expect(proxied.fetchData(undefined, args)).rejects.toThrow('ID is required when args are provided') + }) +}) + +describe('test calls with ID passed', () => { + // Test d'appel de méthode avec ID + it('calling method with ID', async () => { + const proxied = genericProxy(target) + const result = await proxied.fetchData('123') + expect(result).toEqual({ id: '123', data: 'Mocked data' }) + }) + + // Test d'appel de méthode avec exclusion en cours + it('calling method with exclusion in progress', async () => { + const proxied = genericProxy(target, { fetchData: ['otherMethod'] }) + // Simuler une exécution en cours pour la méthode exclue + proxied.otherMethod('456') + + // Maintenant, tenter d'appeler fetchData pour le même ID devrait échouer + await expect(proxied.fetchData('456')).rejects.toThrow( + 'otherMethod in progress on 456, can\'t fetchData', + ) + }) + + // Fonction de test asynchrone pour tester le mélange des nextArgs + it('test mixing nextArgs from concurrent promises', async () => { + // Création d'une cible de test + const target = { + async fetchData(id: string, args?: object) { + return { id, args } + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + const promise1 = proxied.fetchData('123', { key1: 'value1' }) + // Appels successifs à fetchData avec différents arguments + const promise2 = proxied.fetchData('123', { key2: 'value2' }) + + // Promesse concurrente avec des nextArgs différents + const promise3 = proxied.fetchData('123', { key3: 'value3' }) + + // Attendre que les promesses se résolvent + const result1 = await promise1 + const result2 = await promise2 + const result3 = await promise3 + + // Vérification que les nextArgs de promise2 et promise3 ont été correctement mélangés + expect(result1.args).toEqual({ key1: 'value1' }) + expect(result2.args).toEqual({ key2: 'value2', key3: 'value3' }) + expect(result3.args).toEqual({ key2: 'value2', key3: 'value3' }) + }) + + it('test rejection of set attempt', () => { + // Création d'une cible de test + const target = { + async fetchData() { + return 'Mocked data' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Tentative de définir une nouvelle propriété sur le proxy + const setAttempt = () => { + proxied.fetchData = () => new Promise(resolve => resolve('illegal')) + } + + // Vérification que la tentative de set est rejetée + expect(setAttempt).toThrow(TypeError) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/utils/proxy.ts b/.jjconflict-side-0/apps/server/src/utils/proxy.ts new file mode 100644 index 0000000000..ef915a7d15 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/proxy.ts @@ -0,0 +1,78 @@ +// @ts-nocheck un enfer à typer, pour plus tard +type Tracker> = Record + nextArgs?: [string] +}>> | Promise + +type Target = Record Promise> +type Excludes = Partial>> | undefined +const toTarget = (target: T) => ({ tracker: {} as Tracker, methods: target }) + +// @ts-ignore +export function genericProxy(proxied: T, excludes: Excludes = {}): T { + return new Proxy(toTarget(proxied), { + get({ methods, tracker }, property: string) { + if (!(property in methods)) return + return async (...args) => { + const id = args[0] as string + + if (!id && args.length > 0) { + throw new Error('ID is required when args are provided') + } + + if (!id) { + if (tracker[property] instanceof Promise) { + return tracker[property] + } + const p = methods[property]() + if (p instanceof Promise) { + tracker[property] = p + p.then(() => { + delete tracker[property] + }) + } + return p + } + if (!tracker[property]) { + tracker[property] = {} + } + + for (const testExclude of excludes[property] ?? []) { + // @ts-ignore + if (tracker?.[testExclude]?.[id]?.currentExec) { + throw new Error(`${String(testExclude)} in progress on ${id}, can't ${String(property)}`) + } + } + + if (id in tracker[property]) { + if (args[1]) { + tracker[property][id].nextArgs = { + ...(tracker[property][id].nextArgs ?? {}), + ...args[1], + } + } + if (tracker[property][id].currentExec) { + return new Promise((resolve) => { + tracker[property][id].currentExec.then(() => { + resolve(tracker[property][id].currentExec ?? methods[property](id, tracker[property][id].nextArgs)) + }) + }) + } + } + const p = methods[property](...args) + tracker[property][id] = { + currentExec: p, + nextArgs: undefined, + } + tracker[property][id].currentExec = p + p.then(() => { + tracker[property][id].currentExec = undefined + }) + return p + } + }, + set() { + return false + }, + }) as T +} diff --git a/.jjconflict-side-0/apps/server/src/utils/queries-tools.spec.ts b/.jjconflict-side-0/apps/server/src/utils/queries-tools.spec.ts new file mode 100644 index 0000000000..e37ed94c25 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/queries-tools.spec.ts @@ -0,0 +1,47 @@ +import { exclude } from '@cpn-console/shared' +import { describe, expect, it } from 'vitest' +import { filterObjectByKeys } from './queries-tools.js' + +describe('queries-tools', () => { + it('should return a filtered object (filterObjectByKeys)', () => { + const initial = { + id: 'thisIsAnId', + name: 'alsoKeepThisKey', + description: 'keepThisKey', + } + const desired = { + name: 'alsoKeepThisKey', + description: 'keepThisKey', + } + + const transformed = filterObjectByKeys(initial, ['name', 'description']) + + expect(transformed).toMatchObject(desired) + }) + + it('should return a filtered object (exclude)', () => { + const initial = { + id: 'thisIsAnId', + name: 'myProjectName', + environment: { + permissions: { + password: 'secret', + id: 'notSecret', + }, + }, + } + const desired = { + id: 'thisIsAnId', + name: 'myProjectName', + environment: { + permissions: { + id: 'notSecret', + }, + }, + } + + const transformed = exclude(initial, ['password']) + + expect(transformed).toMatchObject(desired) + }) +}) diff --git a/.jjconflict-side-0/apps/server/src/utils/queries-tools.ts b/.jjconflict-side-0/apps/server/src/utils/queries-tools.ts new file mode 100644 index 0000000000..856ca277f1 --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/queries-tools.ts @@ -0,0 +1,11 @@ +export const dbKeysExcluded = ['updatedAt', 'createdAt'] + +// TODO +// @ts-ignore supprimer cette fonction et utiliser des schémas zod où elle est utilisé +export function filterObjectByKeys(obj, keys) { + return Object.fromEntries( + Object.entries(obj)?.filter(([key, _value]) => keys.includes(key)), + ) +} + +export const uuid: RegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i diff --git a/.jjconflict-side-0/apps/server/src/utils/random.spec.ts b/.jjconflict-side-0/apps/server/src/utils/random.spec.ts new file mode 100644 index 0000000000..3c33d6a4ee --- /dev/null +++ b/.jjconflict-side-0/apps/server/src/utils/random.spec.ts @@ -0,0 +1,148 @@ +import { createRandomDbSetup } from '@cpn-console/test-utils' +import { describe, expect, it } from 'vitest' + +describe('random utils', () => { + // TODO + it.skip('should create a random db for tests', () => { + const db = createRandomDbSetup({ nbUsers: 3, nbRepo: 1, envs: ['dev', 'prod'] }) + expect(db).toEqual( + expect.objectContaining({ + stages: expect.arrayContaining([ + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + ]), + quotas: expect.arrayContaining([ + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + ]), + project: expect.objectContaining({ + id: expect.any(String), + name: expect.any(String), + clusters: expect.arrayContaining([{ + caData: expect.any(String), + server: expect.any(String), + tlsServername: expect.any(String), + }]), + status: expect.any(String), + locked: expect.any(Boolean), + roles: expect.arrayContaining([ + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + ]), + repositories: expect.any(Array), + environments: expect.arrayContaining([ + { + id: expect.any(String), + stageId: expect.any(String), + projectId: expect.any(String), + quotaId: expect.any(String), + status: expect.any(String), + permissions: expect.any(Array), + clusters: expect.any(Array), + }, + { + id: expect.any(String), + stageId: expect.any(String), + projectId: expect.any(String), + quotaId: expect.any(String), + status: expect.any(String), + permissions: expect.any(Array), + clusters: expect.any(Array), + }, + ]), + }), + users: expect.arrayContaining([ + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + ]), + }), + ) + }) +}) diff --git a/.jjconflict-side-0/apps/server/tsconfig.json b/.jjconflict-side-0/apps/server/tsconfig.json new file mode 100644 index 0000000000..a3397898ca --- /dev/null +++ b/.jjconflict-side-0/apps/server/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "useUnknownInCatchVariables": false, + "declarationDir": "./types", + "outDir": "./dist", + "plugins": [{ "transform": "typescript-transform-paths" }] + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__", + "./src/mocks/utils.ts", + "./src/utils/mocks.ts" + ] +} diff --git a/.jjconflict-side-0/apps/server/vite.config.ts b/.jjconflict-side-0/apps/server/vite.config.ts new file mode 100644 index 0000000000..f9a3e50fae --- /dev/null +++ b/.jjconflict-side-0/apps/server/vite.config.ts @@ -0,0 +1,18 @@ +/// +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + poolMatchGlobs: [ + ['**/resources/**/*.spec.ts', 'forks'], + ], + }, +}) diff --git a/.jjconflict-side-0/apps/server/vitest-init.ts b/.jjconflict-side-0/apps/server/vitest-init.ts new file mode 100644 index 0000000000..596420f7de --- /dev/null +++ b/.jjconflict-side-0/apps/server/vitest-init.ts @@ -0,0 +1,11 @@ +process.env.ARGOCD_URL = 'https://argo-cd.readthedocs.io' +process.env.GITLAB_URL = 'https://gitlab.com' +process.env.HARBOR_URL = 'https://goharbor.io' +process.env.NEXUS_URL = 'https://sonatype.com/products/nexus-repository' +process.env.SONARQUBE_URL = 'https://www.sonarqube.org' +process.env.VAULT_URL = 'https://www.vaultproject.io' +process.env.PROJECTS_ROOT_DIR = 'forge-mi/projects' +process.env.KEYCLOAK_REDIRECT_URI = 'http://console.dso.local' +process.env.CONTACT_EMAIL = 'cloudpinative-relations@interieur.gouv.fr' +process.env.OPENCDS_URL = 'https://opencds.gouv.fr' +process.env.OPENCDS_API_TOKEN = 'test_token' diff --git a/.jjconflict-side-0/apps/server/vitest.config.ts b/.jjconflict-side-0/apps/server/vitest.config.ts new file mode 100644 index 0000000000..9dc913021e --- /dev/null +++ b/.jjconflict-side-0/apps/server/vitest.config.ts @@ -0,0 +1,35 @@ +import { fileURLToPath } from 'node:url' +import { mergeConfig } from 'vite' +import { configDefaults, defineConfig } from 'vitest/config' +import viteConfig from './vite.config' + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + reporters: ['default', 'hanging-process'], + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: [ + '**/types', + '**/mocks', + '**/*.spec.ts', + '**/*.d.ts', + '**/*.vue', + '**/queries.ts', + '**/mocks.ts', + '**/*.sql', + ], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + setupFiles: ['./vitest-init.ts'], + root: fileURLToPath(new URL('./', import.meta.url)), + pool: 'forks', + }, + }), +) diff --git a/.jjconflict-side-0/ci/matrix-docker.json b/.jjconflict-side-0/ci/matrix-docker.json new file mode 100644 index 0000000000..3550832dba --- /dev/null +++ b/.jjconflict-side-0/ci/matrix-docker.json @@ -0,0 +1,42 @@ +[ + { + "name": "server", + "build": { + "context": "./", + "dockerfile": "./apps/server/Dockerfile", + "target": "prod" + } + }, + { + "name": "nginx-strangler", + "build": { + "context": "./", + "dockerfile": "./apps/nginx-strangler/Dockerfile", + "target": "prod" + } + }, + { + "name": "server-nestjs", + "build": { + "context": "./", + "dockerfile": "./apps/server-nestjs/Dockerfile", + "target": "prod" + } + }, + { + "name": "client", + "build": { + "context": "./", + "dockerfile": "./apps/client/Dockerfile", + "target": "prod" + } + }, + { + "name": "opencds-mockoon", + "build": { + "context": "./", + "dockerfile": "./packages/opencds/Dockerfile", + "target": "prod" + } + } +] diff --git a/.jjconflict-side-0/ci/matrix-npm.json b/.jjconflict-side-0/ci/matrix-npm.json new file mode 100644 index 0000000000..b4805a8be6 --- /dev/null +++ b/.jjconflict-side-0/ci/matrix-npm.json @@ -0,0 +1,72 @@ +{ + "apps": [ + { + "name": "@cpn-console/server", + "path": "./apps/server" + }, + { + "name": "@cpn-console/server-nestjs", + "path": "./apps/server-nestjs" + }, + { + "name": "@cpn-console/client", + "path": "./apps/client" + } + ], + "packages": [ + { + "name": "@cpn-console/shared", + "path": "./packages/shared" + }, + { + "name": "@cpn-console/hooks", + "path": "./packages/hooks" + }, + { + "name": "@cpn-console/logger", + "path": "./packages/logger" + }, + { + "name": "@cpn-console/test-utils", + "path": "./packages/test-utils" + }, + { + "name": "@cpn-console/eslint-config", + "path": "./packages/eslintconfig" + }, + { + "name": "@cpn-console/ts-config", + "path": "./packages/tsconfig" + } + ], + "plugins": [ + { + "name": "@cpn-console/argocd-plugin", + "path": "./plugins/argocd" + }, + { + "name": "@cpn-console/gitlab-plugin", + "path": "./plugins/gitlab" + }, + { + "name": "@cpn-console/harbor-plugin", + "path": "./plugins/harbor" + }, + { + "name": "@cpn-console/keycloak-plugin", + "path": "./plugins/keycloak" + }, + { + "name": "@cpn-console/nexus-plugin", + "path": "./plugins/nexus" + }, + { + "name": "@cpn-console/sonarqube-plugin", + "path": "./plugins/sonarqube" + }, + { + "name": "@cpn-console/vault-plugin", + "path": "./plugins/vault" + } + ] +} diff --git a/.jjconflict-side-0/ci/scripts/build-matrix.sh b/.jjconflict-side-0/ci/scripts/build-matrix.sh new file mode 100755 index 0000000000..301a203ea5 --- /dev/null +++ b/.jjconflict-side-0/ci/scripts/build-matrix.sh @@ -0,0 +1,205 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Get versions +DOCKER_VERSION="$(docker --version)" + + +# Default +# REGISTRY="docker.io" +TAGS="latest" +COMMIT_SHA="$(git rev-parse --short HEAD)" +PLATFORMS="linux/amd64" +CSV=false +RECURSIVE=false + +unset MAJOR_VERSION +unset MINOR_VERSION +unset PATCH_VERSION + +# Declare script helper +TEXT_HELPER="\nThis script aims to build matrix for Github CI/CD. It will parse the given docker-compose file and return a json object with images infos (name, tag, context, dockerfile and if it need to be build) +Following flags are available: + + -a Create recursive tags, if it match 'x.x.x' it will create 'x.x' and 'x'. + + -c Use csv list formated output for tags instead of json array. + + -f Docker-compose file used to build matrix. + + -n Namespace used to tag images. e.g 'username/reponame'. + + -p Target platforms used to build matrix (List/CSV format. ex: 'linux/amd64,linux/arm64'). + Default is '$PLATFORMS'. + + -r Registry host used to build matrix. + Default is '$REGISTRY'. + + -t Docker tag used to build matrix. + Default is '$TAGS'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hacf:n:p:r:t: flag; do + case "${flag}" in + a) + RECURSIVE=true;; + c) + CSV=true;; + f) + COMPOSE_FILE=${OPTARG};; + n) + NAMESPACE=${OPTARG};; + p) + PLATFORMS=${OPTARG};; + r) + REGISTRY=${OPTARG};; + t) + TAGS=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ ! -f "$(readlink -f $COMPOSE_FILE)" ]; then + echo "\nDocker compose file $COMPOSE_FILE does not exist." + print_help + exit 1 +fi + + +if [ "$REGISTRY" ] && [[ "$REGISTRY" != */ ]]; then + REGISTRY="$REGISTRY/" +fi + +if [ "$NAMESPACE" ] && [[ "$NAMESPACE" != */ ]]; then + NAMESPACE="$NAMESPACE/" +fi + + +# Build core matrix +MATRIX=$(cat "$COMPOSE_FILE" \ + | docker run -i --rm mikefarah/yq -o=json \ + | jq \ + --arg d "$(dirname $(readlink -f $COMPOSE_FILE))" \ + --arg p "$PLATFORMS" \ + --arg r "$REGISTRY" \ + --arg t "$TAGS" \ + '.services | to_entries | map({ + image: (.value.image), + name: (.value.image | split(":")[0] | split("/")[-1]), + build: ( + if .value.build then { + context: ($d + "/" + .value.build.context), + dockerfile: ($d + "/" + .value.build.context + "/" + .value.build.dockerfile), + target: (.value.build.target), + platforms: [], + tags: [] + } + else + false + end) + })') + +# Add tags in matrix +for t in $(echo $TAGS | tr "," "\n"); do + if [[ "$t" == *"."*"."* ]] && [[ "$RECURSIVE" == "true" ]]; then + MAJOR_VERSION="$(echo $t | cut -d "." -f 1)" + MINOR_VERSION="$(echo $t | cut -d "." -f 2)" + PATCH_VERSION="$(echo $t | cut -d "." -f 3)" + + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg r "$REGISTRY" \ + --arg n "$NAMESPACE" \ + --arg major "$MAJOR_VERSION" \ + --arg minor "$MINOR_VERSION" \ + 'map(. | + if .build != false then + .build.tags += [ + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $major), + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $major + "." + $minor) + ] + else + . + end + )') + fi + + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg t "$t" \ + --arg r "$REGISTRY" \ + --arg n "$NAMESPACE" \ + 'map(. | + if .build != false then + .build.tags += [ + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $t) + ] + else + . + end + )') +done + +# Add platforms in matrix +for p in $(echo $PLATFORMS | tr "," "\n"); do + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg p "$p" \ + 'map(. | + if .build != false then + .build.platforms += [ + ($p) + ] + else + . + end + )') +done + +# Update image key with first tag +MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg t "$TAGS" \ + 'map(. | + if .build != false then + .image = (.image | split(":")[0] | split("/")[-1] + ":" + ($t | split(",")[0])) + else + . + end + )') + +# Convert tags & platforms from json array to csv list (use join instead of @csv to don't get unwanted quotes) +if [ "$CSV" == "true" ]; then + MATRIX=$(echo "$MATRIX" \ + | jq -r \ + 'map(. | + if .build != false then + .build.tags = (.build.tags | join(",")) | + .build.platforms = (.build.platforms | join(",")) + else + . + end + )') +fi + +echo "$MATRIX" | jq . \ No newline at end of file diff --git a/.jjconflict-side-0/ci/scripts/build-tags.sh b/.jjconflict-side-0/ci/scripts/build-tags.sh new file mode 100755 index 0000000000..63f795c710 --- /dev/null +++ b/.jjconflict-side-0/ci/scripts/build-tags.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +set -e + +unset MAJOR_VERSION +unset MINOR_VERSION +unset PATCH_VERSION + +# Declare script helper +TEXT_HELPER="\nThis script aims to build docker tags based on image and set of tags. +Following flags are available: + + -i Image used to create tags. + + -t Docker tag used to build matrix. + Default is '$TAGS'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hi:t: flag; do + case "${flag}" in + i) + IMAGE=${OPTARG};; + t) + TAGS=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ -z "$IMAGE" ] || [ -z "$TAGS" ]; then + echo "\nArguments missing." + print_help + exit 1 +fi + + +for TAG in $(echo $TAGS | tr "," "\n"); do + if [[ "$TAG" == *"."*"."* ]]; then + MAJOR_VERSION="$(echo $TAG | cut -d "." -f 1)" + MINOR_VERSION="$(echo $TAG | cut -d "." -f 1-2)" + if [ -z "$DOCKER_TAGS" ]; then + DOCKER_TAGS="$IMAGE:$MAJOR_VERSION,$IMAGE:$MINOR_VERSION,$IMAGE:$TAG" + else + DOCKER_TAGS="$DOCKER_TAGS,$IMAGE:$TAG,$IMAGE:$MAJOR_VERSION,$IMAGE:$MINOR_VERSION,$IMAGE:$TAG" + fi + else + if [ -z "$DOCKER_TAGS" ]; then + DOCKER_TAGS="$IMAGE:$TAG" + else + DOCKER_TAGS="$DOCKER_TAGS,$IMAGE:$TAG" + fi + fi +done + +echo "$DOCKER_TAGS" diff --git a/.jjconflict-side-0/ci/scripts/cleanup.sh b/.jjconflict-side-0/ci/scripts/cleanup.sh new file mode 100755 index 0000000000..acef1973a0 --- /dev/null +++ b/.jjconflict-side-0/ci/scripts/cleanup.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +cd "$PROJECT_DIR" + + +printf "\n${red}${i}.${no_color} Remove build\n" +i=$(($i + 1)) + +pnpm run build:clean + + +printf "\n${red}${i}.${no_color} Remove node_modules\n" +i=$(($i + 1)) + +while true; do + read -p "Do you want to remove node_modules ('rm -rf $PROJECT_DIR/**/node_modules')? " yn + case $yn in + [Yy]*) + for n in $(find $PROJECT_DIR -depth 3 -type d -name node_modules); do + echo "Delete '$n'" + rm -rf "$n" + done + # find $PROJECT_DIR -type d -name node_modules -delete + break;; + [Nn]*) + echo "\nKeeping node_modules\n" + break;; + *) + echo "\nPlease answer y or n.\n";; + esac +done + + +printf "\n${red}${i}.${no_color} Remove docker stuff\n" +i=$(($i + 1)) + +if docker image inspect dso-console/client:dev > /dev/null 2>&1; then + docker image rm dso-console/client:dev +fi +if docker image inspect dso-console/client:prod > /dev/null 2>&1; then + docker image rm dso-console/client:prod +fi +if docker image inspect dso-console/server:dev > /dev/null 2>&1; then + docker image rm dso-console/server:dev +fi +if docker image inspect dso-console/server:prod > /dev/null 2>&1; then + docker image rm dso-console/server:prod +fi + +if docker volume inspect docker_dso-postgres-data-dev > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-dev +fi +if docker volume inspect docker_dso-postgres-data-integ > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-integ +fi +if docker volume inspect docker_dso-postgres-data-local > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-local +fi diff --git a/.jjconflict-side-0/ci/scripts/delete-image.sh b/.jjconflict-side-0/ci/scripts/delete-image.sh new file mode 100755 index 0000000000..d1e0e70e59 --- /dev/null +++ b/.jjconflict-side-0/ci/scripts/delete-image.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' + +# Get versions +DOCKER_VERSION="$(docker --version)" +DOCKER_BUILDX_VERSION="$(docker buildx version)" + +# Declare script helper +TEXT_HELPER="\nThis script aims to delete an image with all its subsequent images in ghcr. +Following flags are available: + + -g Github token to perform api calls. + + -i Image name used for api calls. + + -o Github organization used for api calls. + + -t Image tage to delete. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hg:i:o:t: flag; do + case "${flag}" in + g) + GITHUB_TOKEN=${OPTARG};; + i) + IMAGE_NAME=${OPTARG};; + o) + ORG=${OPTARG};; + t) + TAG=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +checkDockerRunning () { + if [ ! -x "$(command -v docker)" ]; then + printf "\nThis script uses docker, and it isn't running - please start docker and try again!\n" + exit 1 + fi +} + +checkBuildxPlugin () { + if [ ! "$DOCKER_BUILDX_VERSION" ]; then + printf "\nThis script uses docker buildx plugin, and it isn't installed - please install docker buildx plugin and try again!\n" + exit 1 + fi +} + +# Settings +printf "\nScript settings: + -> docker version: ${DOCKER_VERSION} + -> docker buildx version: ${DOCKER_BUILDX_VERSION}\n" + + +if [ -z "$GITHUB_TOKEN" ] || [ -z "$ORG" ] || [ -z "$IMAGE_NAME" ] || [ -z "$TAG" ]; then + echo "\nYMissing arguments ...\n" + print_help + exit 1 +fi + +IMAGE_NAME_URL_ENCODED="$(jq -rn --arg x ${IMAGE_NAME} '$x | @uri')" +IMAGES=$(curl -s \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions?per_page=100") + +# Delete subsequent images +while read -r SHA; do + IMAGE_ID=$(echo "$IMAGES" | jq -r --arg s "$SHA" '.[] | select(.name==$s) | .id') + + printf "\n${red}[Delete ghcr image].${no_color} Deleting subsequent image '$ORG/$IMAGE_NAME@$SHA'\n" + + curl -s \ + -X DELETE \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions/${IMAGE_ID}" +done <<< "$(docker buildx imagetools inspect ghcr.io/${ORG}/${IMAGE_NAME}:${TAG} --raw | jq -r '.manifests[] | .digest')" + +# Delete main images +while read -r IMAGE_ID; do + + printf "\n${red}[Delete ghcr image].${no_color} Deleting GH package version: ${IMAGE_ID}'\n" + + curl -s \ + -X DELETE \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions/${IMAGE_ID}" +done <<< "$(echo "$IMAGES" | jq -r --arg t "$TAG" '.[] | select(.metadata.container.tags[] | contains($t)) | .id')" diff --git a/.jjconflict-side-0/ci/scripts/init-env.sh b/.jjconflict-side-0/ci/scripts/init-env.sh new file mode 100755 index 0000000000..0c6c5d3feb --- /dev/null +++ b/.jjconflict-side-0/ci/scripts/init-env.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Declare script helper +TEXT_HELPER="\nThis script aims to copy .env-example files into .env files at project initialization. +Following flags are available: + + -h Print script help\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts h flag +do + case "${flag}" in + h | *) + print_help + exit 0;; + esac +done + + +find $PROJECT_DIR -type f -name ".env*-example" -or -name "*-example.yaml" | while read f; do + if [ ! -f "${f/-example/}" ]; then + printf "\n${red}Copy${no_color}: '$f' + ${red}to${no_color}: '${f/-example/}'\n" + cp "$f" "${f/-example/}" + else + printf "\nFile '${f/-example/}' already exists\n" + fi +done diff --git a/.jjconflict-side-0/ci/scripts/run-tests.sh b/.jjconflict-side-0/ci/scripts/run-tests.sh new file mode 100755 index 0000000000..cf18c018fe --- /dev/null +++ b/.jjconflict-side-0/ci/scripts/run-tests.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Get versions +NODE_VERSION="$(node --version)" +NPM_VERSION="$(npm --version)" +DOCKER_VERSION="$(docker --version)" +DOCKER_BUILDX_VERSION="$(docker buildx version)" + +# Default +RUN_LINT="false" +RUN_UNIT_TESTS="false" +RUN_E2E_TESTS="false" +RUN_STATUS_CHECK="false" + +# Declare script helper +TEXT_HELPER="\nThis script aims to run application tests. +Following flags are available: + + -e Run e2e tests + + -l Run lint + + -s Run deployement status check + + -t (Optional) Tag used for docker images in e2e tests + + -u Run unit tests + + -h Print script help\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts heklst:u flag +do + case "${flag}" in + e) + RUN_E2E_TESTS=true;; + l) + RUN_LINT=true;; + s) + RUN_STATUS_CHECK=true;; + t) + TAG=${OPTARG};; + u) + RUN_UNIT_TESTS=true;; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ "$RUN_LINT" == "false" ] && [ "$RUN_UNIT_TESTS" == "false" ] && [ "$RUN_E2E_TESTS" == "false" ] && [ "$RUN_STATUS_CHECK" == "false" ]; then + printf "\nArgument(s) missing, you don't specify any kind of test to run.\n" + print_help + exit 1 +fi + +if [ "$RUN_E2E_TESTS" == "true" ] && [ -z "$TAG" ]; then + printf "\nArgument(s) missing, you don't specify the TAG used to pull docker images for e2e tests.\n" + print_help + exit 1 +fi + +checkDockerRunning () { + if [ ! -x "$(command -v docker)" ]; then + printf "\nThis script uses docker, and it isn't running - please start docker and try again!\n" + exit 1 + fi +} + +checkBuildxPlugin () { + if [ ! "$DOCKER_BUILDX_VERSION" ]; then + printf "\nThis script uses docker buildx plugin, and it isn't installed - please install docker buildx plugin and try again!\n" + exit 1 + fi +} + + +# Settings +printf "\nScript settings: + -> node version: ${NODE_VERSION} + -> npm version: ${NPM_VERSION} + -> docker version: ${DOCKER_VERSION} + -> docker buildx version: ${DOCKER_BUILDX_VERSION} + -> run unit tests: ${RUN_UNIT_TESTS} + -> run e2e tests: ${RUN_E2E_TESTS} + -> run deploy status check: ${RUN_STATUS_CHECK}\n" + + +cd "$PROJECT_DIR" + +# Run lint +if [ "$RUN_LINT" == "true" ]; then + npm run lint +fi + + +# Run unit tests +if [ "$RUN_UNIT_TESTS" == "true" ]; then + npm run test:cov +fi + +# Run e2e tests +if [ "$RUN_E2E_TESTS" == "true" ]; then + checkDockerRunning + + printf "\n${red}${i}.${no_color} Launch e2e tests\n" + i=$(($i + 1)) + + npm --prefix $PROJECT_DIR/packages/shared run build + npm --prefix $PROJECT_DIR/packages/test-utils run build + + if [[ -n "$TAG" ]]; then + docker pull ghcr.io/cloud-pi-native/console/server:$TAG && docker tag ghcr.io/cloud-pi-native/console/server:$TAG dso-console/server:ci + docker pull ghcr.io/cloud-pi-native/console/server-nestjs:$TAG && docker tag ghcr.io/cloud-pi-native/console/server-nestjs:$TAG dso-console/server-nestjs:ci + docker pull ghcr.io/cloud-pi-native/console/client:$TAG && docker tag ghcr.io/cloud-pi-native/console/client:$TAG dso-console/client:ci + docker pull ghcr.io/cloud-pi-native/console/nginx-strangler:$TAG && docker tag ghcr.io/cloud-pi-native/console/client:$TAG dso-console/nginx-strangler:ci + fi + + npm run docker:e2e-ci + + printf "\n${red}${i}.${no_color} Remove resources\n" + i=$(($i + 1)) + + npm run docker:e2e-ci:delete +fi diff --git a/.jjconflict-side-0/ci/scripts/setup.sh b/.jjconflict-side-0/ci/scripts/setup.sh new file mode 100755 index 0000000000..4917b17cf8 --- /dev/null +++ b/.jjconflict-side-0/ci/scripts/setup.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +cd "$PROJECT_DIR" + + +printf "\n${red}${i}.${no_color} Install dependencies\n" +i=$(($i + 1)) + +pnpm install + + +printf "\n${red}${i}.${no_color} Generate Prisma client\n" +i=$(($i + 1)) + +pnpm --filter=server run db:generate + + +printf "\n${red}${i}.${no_color} Build packges\n" +i=$(($i + 1)) + +pnpm run build --force + + +printf "\n${red}${i}.${no_color} Build docker dev images\n" +i=$(($i + 1)) + +pnpm run docker:dev:build + + +printf "\n${red}${i}.${no_color} Build docker prod images\n" +i=$(($i + 1)) + +pnpm run docker:prod:build diff --git a/.jjconflict-side-0/ci/scripts/vuln-report.sh b/.jjconflict-side-0/ci/scripts/vuln-report.sh new file mode 100755 index 0000000000..9bf3f6c169 --- /dev/null +++ b/.jjconflict-side-0/ci/scripts/vuln-report.sh @@ -0,0 +1,244 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + + +# Declare script helper +TEXT_HELPER="\nThis script aims to build a markdown style vulnerability report for the trivy json scan results. It will read the files and then build a markdown summary (compatible with Github issue) summarising the vulnerabilities for each image and configuration file for the given input folder. + +Following flags are available: + + -i Input folder that contain trivy json scan result files into subfolders 'images/' and 'configs/'. + Please provide the path without last slash (i.e not like this 'path/to/folder/'). + + -o Output file for the generated markdown report. + Please provide the path with the markdown file extension (i.e '.md'). + + -p Github project (format as '/'). + It can be set in GitHub Actions as '\${{ github.repository }}'. + + -r Github actions run ID. + It can be set in GitHub Actions as '\${{ github.run_id }}'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hi:o:p:r: flag +do + case "${flag}" in + i) + INPUT="${OPTARG}";; + o) + OUTPUT="${OPTARG}";; + p) + REPO="${OPTARG}";; + r) + RUN_ID="${OPTARG}";; + h | *) + print_help + exit 0;; + esac +done + + +NOW="$(date +'%Y-%m-%d at %H:%M')" +ARTIFACT_DOWNLOAD_URL="https://github.com/${REPO}/actions/runs/${RUN_ID}" + + +if [ -z "$INPUT" ] || [ -z "$OUTPUT" ] || [ -z "$REPO" ] || [ -z "$RUN_ID" ]; then + echo "Argument(s) missing, you should set all paramters." + print_help + exit 0 +fi + + +_jq() { + echo ${1} | base64 --decode | jq -r ${2} +} + + +printf "\n${red}${i}.${no_color} Build vulnerability report\n" +i=$(($i + 1)) + +VULNERABILITY_REPORT_BODY="# Security report + +:robot: This report was generated automatically the $NOW. + +It summarizes the result of a vulnerability scan of Trivy for each docker image and configuration file. +You should take a look at it and possibly modify some dependencies to fix vulnerabilities or update your configuration files." + +#--------------------# +# Images scan report # +#--------------------# +if [[ -n $(find $INPUT/images/*) ]]; then + FORMATED_REPORT=$(jq -s '.' $INPUT/images/* | jq '[ .[] | { + name: .ArtifactName, + type: .ArtifactType, + results: [ + .Results[] | select(.Vulnerabilities != null) | { + class: .Class, + target: .Target, + type: .Type, + length: .Vulnerabilities | (if length == 0 then "-" else length end), + vulnerabilityType: { + critical: [.Vulnerabilities[] | select(.Severity == "CRITICAL")] | (if length == 0 then "-" else length end), + high: [.Vulnerabilities[] | select(.Severity == "HIGH")] | (if length == 0 then "-" else length end), + medium: [.Vulnerabilities[] | select(.Severity == "MEDIUM")] | (if length == 0 then "-" else length end), + low: [.Vulnerabilities[] | select(.Severity == "LOW")] | (if length == 0 then "-" else length end), + unknown: [.Vulnerabilities[] | select(.Severity == "UNKNOWN")] | (if length == 0 then "-" else length end) + } + } + ] + } ]') + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +## Images" + + for row in $(echo "${FORMATED_REPORT}" | jq -r '.[] | @base64'); do + IMAGE_NAME=$(echo "$(_jq "${row}" '.')" | jq -r '.name') + OS_TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .length ] | add') + OS_CRITICAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.critical ] | add') + OS_HIGH_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.high ] | add') + OS_MEDIUM_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.medium ] | add') + OS_LOW_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.low ] | add') + OS_UNKNOWN_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.unknown ] | add') + + if [[ $OS_TOTAL_COUNT = 'null' ]]; then + IMAGE_SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + IMAGE_SCAN_SUMMARY=" +| Critical | High | Medium | Low | Unknown | Total | +|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:| +| $OS_CRITICAL_COUNT | $OS_HIGH_COUNT | $OS_MEDIUM_COUNT | $OS_LOW_COUNT | $OS_UNKNOWN_COUNT | $OS_TOTAL_COUNT| +" + fi + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +### \`$IMAGE_NAME\` + +**OS Packages** + +$IMAGE_SCAN_SUMMARY" + + DEP_TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "lang-pkgs") | .length ] | add') + if [[ $DEP_TOTAL_COUNT = 'null' ]]; then + SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + SCAN_SUMMARY=" +| Target | Critical | High | Medium | Low | Unknown | Total | +|:-----------|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:|" + + for row2 in $(echo "$(_jq ${row} '.')" | jq -r '.results[] | select(.class == "lang-pkgs") | @base64'); do + FULL_NAME=$(echo "$(_jq ${row2} '.')" | jq -r '.target') + TOTAL_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '.length') + CRITICAL_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.critical ] | add') + HIGH_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.high ] | add') + MEDIUM_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.medium ] | add') + LOW_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.low ] | add') + UNKNOWN_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.unknown ] | add') + + SCAN_SUMMARY="$SCAN_SUMMARY +| $FULL_NAME | $CRITICAL_COUNT | $HIGH_COUNT | $MEDIUM_COUNT | $LOW_COUNT | $UNKNOWN_COUNT | $TOTAL_COUNT |" + + done + fi + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY +**Language-specific packages** + +$SCAN_SUMMARY" + done + + SCAN_IMAGES=true +fi + +#--------------------# +# Config scan report # +#--------------------# +if [[ -n $(find $INPUT/configs/*) ]]; then + FORMATED_REPORT=$(jq -s '.' $INPUT/configs/* | jq '.[] | { + name: .ArtifactName, + type: .ArtifactType, + results: [ + .Results[] | select(.Misconfigurations != null) | { + class: .Class, + target: .Target, + type: .Type, + length: .Misconfigurations | (if length == 0 then "-" else length end), + vulnerabilityType: { + critical: [.Misconfigurations[] | select(.Severity == "CRITICAL")] | (if length == 0 then "-" else length end), + high: [.Misconfigurations[] | select(.Severity == "HIGH")] | (if length == 0 then "-" else length end), + medium: [.Misconfigurations[] | select(.Severity == "MEDIUM")] | (if length == 0 then "-" else length end), + low: [.Misconfigurations[] | select(.Severity == "LOW")] | (if length == 0 then "-" else length end), + unknown: [.Misconfigurations[] | select(.Severity == "UNKNOWN")] | (if length == 0 then "-" else length end) + } + } + ] + }') + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +## Configs" + + TOTAL_CONFIG=$(echo $FORMATED_REPORT | jq -r '[ .results[].length ] | add') + if [[ $TOTAL_CONFIG = 'null' ]]; then + SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + SCAN_SUMMARY=" +| Target | Critical | High | Medium | Low | Unknown | Total | +|:-----------|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:|" + fi + + for row in $(echo "${FORMATED_REPORT}" | jq -r '.results[] | @base64'); do + FULL_NAME=$(echo "$(_jq "${row}" '.')" | jq -r '.target') + TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '.length') + CRITICAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.critical ] | add') + HIGH_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.high ] | add') + MEDIUM_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.medium ] | add') + LOW_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.low ] | add') + UNKNOWN_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.unknown ] | add') + + SCAN_SUMMARY="$SCAN_SUMMARY +| $FULL_NAME | $CRITICAL_COUNT | $HIGH_COUNT | $MEDIUM_COUNT | $LOW_COUNT | $UNKNOWN_COUNT | $TOTAL_COUNT|" + + done + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY +$SCAN_SUMMARY" + + SCAN_CONFIG=true +fi + +if [ "$SCAN_DEPENDENCIES" = true ] || [ "$SCAN_CONFIG" = true ] || [ "$SCAN_IMAGES" = true ]; then + printf "\n${red}${i}.${no_color} Add artifacts download url to the vulnerability report body\n" + i=$(($i + 1)) + + VULNERABILITY_REPORT_BODY=$(cat < "$OUTPUT" +fi diff --git a/.jjconflict-side-0/commitlint.config.cjs b/.jjconflict-side-0/commitlint.config.cjs new file mode 100644 index 0000000000..070c7b1c34 --- /dev/null +++ b/.jjconflict-side-0/commitlint.config.cjs @@ -0,0 +1,9 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'body-leading-blank': [ + 2, // error + 'always', + ], + }, +}; diff --git a/.jjconflict-side-0/docker/docker-compose.ci.yml b/.jjconflict-side-0/docker/docker-compose.ci.yml new file mode 100644 index 0000000000..b8358950ea --- /dev/null +++ b/.jjconflict-side-0/docker/docker-compose.ci.yml @@ -0,0 +1,151 @@ +services: + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + ports: + - 8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - /opt/bitnami/keycloak/data/h2 + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + ports: + - 8081:80 + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: prod + image: dso-console/server:ci + container_name: dso-console_server + user: root + depends_on: + postgres: + condition: service_started + keycloak: + condition: service_healthy + volumes: + - ../packages/test-utils/src/imports/data.ts:/app/dist/init/db/imports/data.js + env_file: + - ../apps/server/.env.docker + environment: + NODE_ENV: production + CI: "true" + DEV_SETUP: "true" + networks: + - dso-network + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: prod + image: dso-console/server-nestjs:ci + container_name: dso-console_server-nestjs + depends_on: + postgres: + condition: service_started + keycloak: + condition: service_healthy + opencds-mockoon: + condition: service_healthy + env_file: + - ../apps/server-nestjs/.env.docker + environment: + NODE_ENV: "production" + CI: "true" + DEV_SETUP: "true" + OPENCDS_URL: "http://dso-opencds_mockoon:3100/api/v1" + OPENCDS_API_TOKEN: "token" + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: "false" + networks: + - dso-network + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:ci + container_name: dso-console_nginx-strangler + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + + client: + build: + context: .. + dockerfile: apps/client/Dockerfile + target: prod + image: dso-console/client:ci + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + user: root + volumes: + - ../apps/client/nginx/default.docker.conf:/etc/nginx/conf.d/default.conf + env_file: + - ../apps/client/.env.docker + environment: + OPENCDS_ENABLED: "true" + networks: + - dso-network + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:ci + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + name: dso-network + +volumes: + dso-keycloak-dsfr-theme: diff --git a/.jjconflict-side-0/docker/docker-compose.dev.yml b/.jjconflict-side-0/docker/docker-compose.dev.yml new file mode 100644 index 0000000000..98f2a31933 --- /dev/null +++ b/.jjconflict-side-0/docker/docker-compose.dev.yml @@ -0,0 +1,240 @@ +services: + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + ports: + - 8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - ../keycloak/data/dev:/opt/bitnami/keycloak/data/h2 + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-dev:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + - postgres + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-dev:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: dev + image: dso-console/server:dev + container_name: dso-console_server + command: [dev] + depends_on: + keycloak: + condition: service_healthy + postgres: + condition: service_started + ports: + - 4001:8080 + - 9229:9229 + env_file: + - ../apps/server/.env.docker + develop: + watch: + - action: sync + path: ../apps/server + target: /app/apps/server + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server/package.json + networks: + - dso-network + attach: true + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: dev + image: dso-console/server-nestjs:dev + container_name: dso-console_server-nestjs + command: [start:dev] + ports: + - 3001:3001 + depends_on: + keycloak: + condition: service_healthy + postgres: + condition: service_started + opencds-mockoon: + condition: service_healthy + env_file: + - ../apps/server-nestjs/.env.docker + develop: + watch: + - action: sync + path: ../apps/server-nestjs + target: /app/apps/server-nestjs + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - dist/ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server-nestjs/package.json + networks: + - dso-network + attach: true + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:dev + container_name: dso-console_nginx-strangler + ports: + - 4000:8080 + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + attach: false + + client: + restart: unless-stopped + build: + context: .. + dockerfile: apps/client/Dockerfile + target: dev + image: dso-console/client:dev + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + env_file: + - ../apps/client/.env.docker + develop: + watch: + - action: sync + path: ../apps/client + target: /app/apps/client + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: rebuild + path: ../apps/client/package.json + networks: + - dso-network + attach: true + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:dev + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + driver: bridge + +volumes: + dso-keycloak-dsfr-theme: + dso-pgadmin-data-dev: + dso-postgres-data-dev: diff --git a/.jjconflict-side-0/docker/docker-compose.integ.yml b/.jjconflict-side-0/docker/docker-compose.integ.yml new file mode 100644 index 0000000000..385f5e85e7 --- /dev/null +++ b/.jjconflict-side-0/docker/docker-compose.integ.yml @@ -0,0 +1,220 @@ +services: + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-integ:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + postgres: + condition: service_started + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-integ:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: dev + image: dso-console/server:integ + container_name: dso-console_server + command: [dev] + depends_on: + postgres: + condition: service_started + ports: + - 4001:8080 + - 9229:9229 + volumes: + - ${KUBECONFIG_HOST_PATH}:${KUBECONFIG_PATH:-/home/node/.kube/config} + - ${EXTERNAL_PLUGINS_DIR_HOST_PATH}:/plugins + env_file: + - ../apps/server/.env.docker + - ../apps/server/.env.integ + develop: + watch: + - action: sync + path: ../apps/server + target: /app/apps/server + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server/package.json + networks: + - dso-network + attach: true + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: dev + image: dso-console/server-nestjs:integ + container_name: dso-console_server-nestjs + command: [start:dev] + depends_on: + postgres: + condition: service_started + opencds-mockoon: + condition: service_healthy + ports: + - 3001:3001 + volumes: + - ${KUBECONFIG_HOST_PATH}:${KUBECONFIG_PATH:-/home/node/.kube/config} + - ${EXTERNAL_PLUGINS_DIR_HOST_PATH}:/plugins + env_file: + - ../apps/server-nestjs/.env.docker + - ../apps/server-nestjs/.env.integ + develop: + watch: + - action: sync + path: ../apps/server-nestjs + target: /app/apps/server-nestjs + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - dist/ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server-nestjs/package.json + networks: + - dso-network + attach: true + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:integ + container_name: dso-console_nginx-strangler + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + ports: + - 4000:8080 + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + attach: false + + client: + restart: unless-stopped + build: + context: .. + dockerfile: apps/client/Dockerfile + target: dev + image: dso-console/client:integ + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + env_file: + - ../apps/client/.env.docker + - ../apps/client/.env.integ + develop: + watch: + - action: sync + path: ../apps/client + target: /app/apps/client + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: rebuild + path: ../apps/client/package.json + networks: + - dso-network + attach: true + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:integ + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + driver: bridge + +volumes: + dso-pgadmin-data-integ: + dso-postgres-data-integ: diff --git a/.jjconflict-side-0/docker/docker-compose.local.yml b/.jjconflict-side-0/docker/docker-compose.local.yml new file mode 100644 index 0000000000..80473f1823 --- /dev/null +++ b/.jjconflict-side-0/docker/docker-compose.local.yml @@ -0,0 +1,146 @@ +services: + keycloak-fetch-theme: + restart: no + image: docker.io/curlimages/curl:8.8.0 + container_name: dso-console_keycloak-fetch + volumes: + - dso-keycloak-dsfr-theme:/tmp:rw + command: -LfS -o /tmp/keycloak-theme-dsfr.jar https://github.com/codegouvfr/keycloak-theme-dsfr/releases/download/v2.3.3/keycloak-theme-for-kc-26.0-to-26.1.jar + networks: + - dso-network + attach: false + + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + depends_on: + keycloak-fetch-theme: + condition: service_completed_successfully + ports: + - 127.0.0.1:8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - ../keycloak/data/dev:/opt/bitnami/keycloak/data/h2 + - type: volume + source: dso-keycloak-dsfr-theme + target: /opt/bitnami/keycloak/providers/keycloak-theme-dsfr.jar + volume: + subpath: keycloak-theme-dsfr.jar + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + DSFR_THEME_HOME_URL: http://localhost:8080 + DSFR_THEME_SERVICE_TITLE: Console Cloud π Native + DSFR_THEME_BRAND_TOP: Ministère
de l'Intérieur
et des Outre-Mer + DSFR_THEME_TOS_URL: + DSFR_THEME_CONTACT_EMAIL: cloudpinative-relations@interieur.gouv.fr + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-local:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:local + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + postgres: + condition: service_started + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-local:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + nginx-strangler: + # En mode local (pnpm dev), server et server-nestjs tournent nativement sur la machine. + # Le nginx-strangler reste en Docker et atteint les processes natifs via host.docker.internal. + # (voir .env-example de chaque app). + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:local + container_name: dso-console_nginx-strangler + extra_hosts: + - host.docker.internal:${HOST_GATEWAY_IP:-host-gateway} + ports: + - 4000:8080 + environment: + LEGACY_UPSTREAM: "host.docker.internal:4001" + NESTJS_UPSTREAM: "host.docker.internal:3001" + networks: + - dso-network + attach: false + + jaeger: + restart: unless-stopped + image: jaegertracing/all-in-one:1.76.0 + container_name: dso-console_jaeger + environment: + COLLECTOR_ZIPKIN_HOST_PORT: :9411 + ports: + - 16686:16686 + - 4317:4317 + - 4318:4318 + - 14250:14250 + - 14268:14268 + - 14269:14269 + - 9411:9411 + networks: + - dso-network + attach: false + +networks: + dso-network: + driver: bridge + +volumes: + dso-keycloak-dsfr-theme: + dso-keycloak-data-local: + dso-pgadmin-data-local: + dso-postgres-data-local: diff --git a/.jjconflict-side-0/eslint.config.js b/.jjconflict-side-0/eslint.config.js new file mode 100644 index 0000000000..50992e5703 --- /dev/null +++ b/.jjconflict-side-0/eslint.config.js @@ -0,0 +1,9 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase.append({ + ignores: [ + 'apps/', + 'packages/', + 'plugins/', + ], +}) diff --git a/.jjconflict-side-0/keycloak/mock-sso.md b/.jjconflict-side-0/keycloak/mock-sso.md new file mode 100644 index 0000000000..cd9b5c2982 --- /dev/null +++ b/.jjconflict-side-0/keycloak/mock-sso.md @@ -0,0 +1,34 @@ +# Keycloak + +## Development + +### Start only keycloak + +Go at the root level of the git project : + +```sh +# Go at the root level of the git project +cd `git rev-parse --show-toplevel` + +# Start keycloak +docker compose -f ./docker/docker-compose.dev.yml up keycloak +``` + +Admin page url: + +__*Login:*__ `admin` +__*Password:*__ `admin` + +## Docs + +### Get Started + + + +### Other doc + +* [Keycloak](https://github.com/keycloak/keycloak) - Keycloak Server and Java adapters +* [Keycloak Documentation](https://github.com/keycloak/keycloak-documentation) - Documentation for Keycloak +* [Keycloak QuickStarts](https://github.com/keycloak/keycloak-quickstarts) - QuickStarts for getting started with Keycloak +* [Keycloak Node.js Connect](https://github.com/keycloak/keycloak-nodejs-connect) - Node.js adapter for Keycloak +* [Keycloak Node.js Admin Client](https://github.com/keycloak/keycloak-nodejs-admin-client) - Node.js library for Keycloak Admin REST API diff --git a/.jjconflict-side-0/keycloak/realms/realm-dev.json b/.jjconflict-side-0/keycloak/realms/realm-dev.json new file mode 100644 index 0000000000..f7e932d8c0 --- /dev/null +++ b/.jjconflict-side-0/keycloak/realms/realm-dev.json @@ -0,0 +1,577 @@ +{ + "realm": "dso", + "enabled": true, + "accessTokenLifespan": 300, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "ssoSessionIdleTimeout": 600, + "ssoSessionMaxLifespan": 36000, + "sslRequired": "none", + "registrationAllowed": false, + "privateKey": "MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=", + "publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "requiredCredentials": ["password"], + "attributes": { + "frontendUrl": "http://localhost:8090" + }, + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "groups": [ + { + "id": "07169ee1-d967-48da-a784-1b1ccbdaea7a", + "name": "admin", + "path": "/admin", + "attributes": {}, + "realmRoles": [], + "clientRoles": {}, + "subGroups": [] + } + ], + "users": [ + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6565", + "username": "test", + "enabled": true, + "email": "test@test.com", + "emailVerified": true, + "firstName": "Jean", + "lastName": "DUPOND", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6566", + "username": "tcolin", + "enabled": true, + "email": "thibault.colin@test.com", + "emailVerified": true, + "firstName": "Thibault", + "lastName": "COLIN", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + }, + "groups": ["admin"] + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6567", + "username": "cnollet", + "enabled": true, + "email": "claire.nollet@test.com", + "emailVerified": true, + "firstName": "Claire", + "lastName": "NOLLET", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "58e13168-d533-446b-b6a1-936da0a9d7b7", + "username": "lpoumailloux", + "enabled": true, + "email": "leo.poumailloux@test.com", + "emailVerified": true, + "firstName": "Léo", + "lastName": "POUMAILLOUX", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6568", + "username": "btran", + "enabled": true, + "email": "baudoin.tran@test.com", + "emailVerified": true, + "firstName": "Baudoin", + "lastName": "TRAN", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6569", + "username": "atardif", + "enabled": true, + "email": "arnaud.tardif@test.com", + "emailVerified": true, + "firstName": "Arnaud", + "lastName": "TARDIF", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "387216f1-3b87-4211-9cac-4371125e1175", + "username": "admin", + "enabled": true, + "email": "admin@test.com", + "emailVerified": true, + "firstName": "Admin", + "lastName": "ADMIN", + "credentials": [ + { + "type": "password", + "value": "admin" + } + ], + "realmRoles": ["user", "admin"], + "clientRoles": { + "realm-management": ["realm-admin"], + "account": ["manage-account"] + }, + "groups": ["admin"] + } + ], + "roles": { + "realm": [ + { + "name": "user", + "description": "User privileges" + }, + { + "name": "admin", + "description": "Administrator privileges" + } + ] + }, + "clients": [ + { + "id": "e7d105eb-28d4-4557-a274-eb354a60a33b", + "clientId": "dso-console-frontend", + "name": "dso-console-frontend", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["*"], + "webOrigins": ["*"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "id.token.as.detached.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "require.pushed.authorization.requests": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": ["generic", "basic"], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "access": { + "view": true, + "configure": true, + "manage": true + } + }, + { + "id": "e7d105eb-28d4-4557-a274-eb354a60a33c", + "clientId": "dso-console-backend", + "name": "dso-console-backend", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "client-secret-backend", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "id.token.as.detached.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "require.pushed.authorization.requests": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": ["generic"], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "access": { + "view": true, + "configure": true, + "manage": true + } + } + ], + "clientScopes": [ + { + "id": "41b313bd-c5dc-448f-8056-007d25e42027", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "a067866c-7812-47c6-aa28-9e3421fd673b", + "name": "basic", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "910359ad-0368-44dd-b10c-d1cae6c6dd17", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "1611458d-38ab-4a9f-859e-1812d251c1ac", + "name": "generic", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "e0491f43-3e4c-4649-b16e-3c0e47bbc2c0", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "000f3a6c-bec3-40dd-8397-0c45019df7e0", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "2acd698c-03d2-4d0b-99a4-db00ed08f25d", + "name": "name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "13e8d53a-415f-4f98-a133-8795e0173efd", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "5a8652b6-9a42-48e5-819f-59259c349883", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "9d090281-1d17-4283-8fb7-183e1b536328", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "79bccac1-d0f1-4c28-a040-e0e640889e52", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "5ca2ebe3-ef32-4f62-bcad-e764c70d20e6", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "7d976f67-441d-4491-a1fe-f17343a03120", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "bb8a7b6e-2f1a-46b2-91e1-8d027dabe93d", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "84629e2c-c129-4d49-bb04-a03a5124ed47", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "f5c4b940-47ad-4734-8712-4f53357cff1c", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "e1e82435-1b52-4b41-aa0e-14a3e9779670", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "userinfo.token.claim": "true" + } + }, + { + "id": "c3803e56-b542-45e3-a780-0fb70841dd5c", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + } + ], + "loginWithEmailAllowed": true, + "registrationEmailAsUsername": false, + "loginTheme": "DSFR", + "accountTheme": "DSFR", + "adminTheme": "DSFR", + "emailTheme": "DSFR", + "internationalizationEnabled": true, + "supportedLocales": ["en", "fr"], + "defaultLocale": "fr" +} diff --git a/.jjconflict-side-0/migrations/v9/Dockerfile b/.jjconflict-side-0/migrations/v9/Dockerfile new file mode 100644 index 0000000000..5365c15bc1 --- /dev/null +++ b/.jjconflict-side-0/migrations/v9/Dockerfile @@ -0,0 +1,7 @@ +FROM docker.io/node:22.14.0-bullseye-slim +WORKDIR /app +COPY ./package.json package.json +RUN npm i +COPY ./migrate.js migrate.js +CMD [ "npm", "start" ] +USER node diff --git a/.jjconflict-side-0/migrations/v9/Migrate v9-FR.md b/.jjconflict-side-0/migrations/v9/Migrate v9-FR.md new file mode 100644 index 0000000000..f27cb4619f --- /dev/null +++ b/.jjconflict-side-0/migrations/v9/Migrate v9-FR.md @@ -0,0 +1,42 @@ +# Migration vers la v9 +## Qu'est-ce qui change ? +Nous avons pris la décision d'abandonner la fonctionnalité d'organisation, dès lors le projet ne sera plus rattaché à rien. Cela entraîne quelques problèmes de suivi des ressources, mais si vous suivez les instructions ci-dessous, tout ira bien. + +Les organisations reviendront à l'avenir, mais sous une forme complètement différente. + +## IMPORTANT +Sauvegardez votre instance gitlab et le kv Vault !!! + +## Préparation des ressources +Depuis la version 8.22.0, le serveur api stocke son numéro de version pour chaque projet provisionné avec succès. + +Avant de passer à la version 9, vous DEVEZ provisionner tous les projets actifs au moins à la version 8.23.0. Si vous ne le faites pas, le script de migration SQL lèvera une exception. + +Un bon suivi des ressources est essentiel. Si un projet ne peut pas être provisionné avec succès, vous pouvez toujours changer sa valeur dans la base de données directement, mais assurez-vous que les balises suivantes sont appliquées sur les ressources kubernetes correspondantes : +- dso/project.id : (namespace, applications, appprojects) +- dso/project.slug : (namespace, applications, appprojects) +- dso/environment.id : (namespace, applications, appprojects) +- dso/repository.id : (namespace, applications) + +## Migration des ressources utilisateurs +Vous pouvez lancer cette image (une seule fois) avant ou après la mise à jour vers la v9. + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: migrate-v9 +spec: + containers: + - image: ghcr.io/cloud-pi-native/migration:v9 + name: migrate-v9 + resources: {} + envFrom: + - secretRef: + name: dso-config + restartPolicy: Never +``` +kubectl run --image docker ghcr.io/cloud-pi-native/migration:v9 + +### À propos de Gitlab +Parce que gitlab effectue de nombreuses tâches de manière asynchrone, nous ne pouvons pas supprimer les anciens groupes d'organisation après avoir transféré les groupes de projet. Vous devrez vous connecter manuellement pour les rechercher et les supprimer. Si vous ne le faites pas, ce n'est pas grave... diff --git a/.jjconflict-side-0/migrations/v9/Migrate v9.md b/.jjconflict-side-0/migrations/v9/Migrate v9.md new file mode 100644 index 0000000000..9b7e94bacd --- /dev/null +++ b/.jjconflict-side-0/migrations/v9/Migrate v9.md @@ -0,0 +1,42 @@ +# Migrating to v9 +## What's changing? +We've taken the decision to abandon the organization feature, so the project won't be attached to anything. This is causing some resource tracking problems, but if you follow the instructions below, you'll be fine. + +Organizations will return in future, but in a completely different form. + +## IMPORTANT +Backup your gitlab instance and the vault KV !!! + +## Resource preparation +Since version 8.22.0, the api server stores its version number for each successfully provisioned project. + +Before upgrading to version 9, you MUST provision all active projects to at least version 8.23.0. If you fail to do so, the SQL migration script will throw an exception. + +Good resource tracking is essential. If a project cannot be provisioned successfully, you can always change its value in the database directly, but make sure the following tags are applied on the corresponding kubernetes resources: +- dso/project.id : (namespace, applications, appprojects) +- dso/project.slug : (namespace, applications, appprojects) +- dso/environment.id : (namespace, applications, appprojects) +- dso/repository.id : (namespace, applications) + +## Migrate user resources +You can run this image (once only) before or after upgrading to v9 + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: migrate-v9 +spec: + containers: + - image: ghcr.io/cloud-pi-native/migration:v9 + name: migrate-v9 + resources: {} + envFrom: + - secretRef: + name: dso-config + restartPolicy: Never +``` +kubectl run --image docker ghcr.io/cloud-pi-native/migration:v9 + +### About Gitlab +Because gitlab performs many tasks asynchronously, we can't delete the old organization groups after transferring the project groups. You'll have to log in manually to search for and delete them. If you don't, it's not a big deal... diff --git a/.jjconflict-side-0/migrations/v9/migrate.js b/.jjconflict-side-0/migrations/v9/migrate.js new file mode 100644 index 0000000000..ed8d048084 --- /dev/null +++ b/.jjconflict-side-0/migrations/v9/migrate.js @@ -0,0 +1,142 @@ +import { logger as baseLogger } from '@cpn-console/logger/hooks' +import { Gitlab } from '@gitbeaker/rest' +import axios from 'axios' + +const logger = baseLogger.child({ scope: 'migration:v9' }) + +export function removeTrailingSlash(url) { + return url?.endsWith('/') + ? url.slice(0, -1) + : url +} + +export function requiredEnv(envName) { + const envValue = process.env[envName] + if (envValue) return envValue + + throw new Error(`env: ${envName} is not defined !`) +} + +const gitlabToken = requiredEnv('GITLAB_TOKEN') +const gitlabPublicUrl = removeTrailingSlash(requiredEnv('GITLAB_URL')) +const projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') +const gitlabInternalUrl = process.env.GITLAB_INTERNAL_URL + ? removeTrailingSlash(process.env.GITLAB_INTERNAL_URL) + : gitlabPublicUrl + +const vaultPublicUrl = removeTrailingSlash(requiredEnv('VAULT_URL')) + +const axiosInstance = axios.create({ + baseURL: vaultPublicUrl, + headers: { + 'X-Vault-Token': requiredEnv('VAULT_TOKEN'), + }, +}) + +const api = new Gitlab({ token: gitlabToken, host: gitlabInternalUrl }) + +const groupRootSearch = await api.Groups.search(projectsRootDir) +const groupRootId = (groupRootSearch.find(grp => grp.full_path === projectsRootDir))?.id + +const organizationGroups = await api.Groups.allDescendantGroups(groupRootId, { perPage: 300 }) + +logger.info({ organizationGroupsCount: organizationGroups.length }, 'Loaded organization groups') +if (organizationGroups.length > 300) { + throw new Error('increase perPage, you could miss some results') +} + +for (const organizationGroup of organizationGroups) { + if (organizationGroup.name === 'Infra') continue + logger.info({ organizationGroupId: organizationGroup.id, organizationGroupName: organizationGroup.name }, 'Processing organization group') + const projectGroups = await api.Groups.allDescendantGroups(organizationGroup.id, { perPage: 300 }) + if (projectGroups.length > 300) { + throw new Error('increase perPage, you could miss some projects group results') + } + + for (const projectGroup of projectGroups) { + const newName = `${organizationGroup.name}-${projectGroup.name}` + logger.info({ projectGroupId: projectGroup.id, newName }, 'Renaming and transferring project group') + + try { + const renamedGroup = await api.Groups.edit(projectGroup.id, { name: newName, path: newName }) + await api.Groups.transfer(renamedGroup.id, { groupId: groupRootId }) + } catch (error) { + logger.warn({ err: error, projectGroupId: projectGroup.id, organizationGroupId: organizationGroup.id }, 'Could not rename/transfer project group') + } + } +} + +const coreKvName = 'forge-dso' + +const vaultToken = (await axiosInstance.post('/v1/auth/token/create')) + .data.auth.client_token + +function transformPath(path) { + if (!path.startsWith('/')) { + path = `/${path}` + } + const parts = path.split('/') + return `/${parts[1]}-${parts[2]}/${parts.slice(3).join('/')}` +} +const secretsMapper = {} + +async function list(path = '/') { + if (!path.startsWith('/')) + path = `/${path}` + const response = await axiosInstance({ + url: `/v1/${coreKvName}/metadata/${projectsRootDir}${path}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + method: 'list', + validateStatus: code => [200, 404].includes(code), + }) + + if (response.status === 404) return [] + for (const key of response.data.data.keys) { + if (key.endsWith('/')) { + await list(`${path}${key}`) + } else { + secretsMapper[`${path}${key}`] = transformPath(`${path}${key}`) + } + } +} + +try { + await list() + logger.info({ secretsCount: Object.keys(secretsMapper).length }, 'Collected secrets mapping') +} catch (error) { + logger.error({ err: error }, 'Failed while collecting secrets mapping') +} + +for (const [source, destination] of Object.entries(secretsMapper)) { + const secretContent = await axiosInstance({ + url: `/v1/${coreKvName}/data/${projectsRootDir}${source}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + method: 'get', + validateStatus: code => [200, 404].includes(code), + }) + const data = secretContent.data.data.data + + try { + await axiosInstance({ + method: 'POST', + url: `/v1/${coreKvName}/data/${projectsRootDir}${destination}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + data: { data }, + }) + await axiosInstance({ + method: 'delete', + url: `/v1/${coreKvName}/metadata/${projectsRootDir}${source}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + }) + } catch (error) { + logger.error({ err: error, source, destination }, 'Failed while moving secret') + } +} diff --git a/.jjconflict-side-0/migrations/v9/package.json b/.jjconflict-side-0/migrations/v9/package.json new file mode 100644 index 0000000000..c49c17280c --- /dev/null +++ b/.jjconflict-side-0/migrations/v9/package.json @@ -0,0 +1,15 @@ +{ + "name": "dso-v9-migration", + "version": "1.0.0", + "description": "", + "author": "Cloud Pi Native", + "license": "ISC", + "main": "index.js", + "scripts": { + "start": "node migrate.js" + }, + "dependencies": { + "@gitbeaker/rest": "catalog:runtime", + "axios": "catalog:runtime" + } +} \ No newline at end of file diff --git a/.jjconflict-side-0/misc/business-rules.md b/.jjconflict-side-0/misc/business-rules.md new file mode 100644 index 0000000000..04f1a4fee9 --- /dev/null +++ b/.jjconflict-side-0/misc/business-rules.md @@ -0,0 +1,63 @@ +# Console Cloud π Native + +## Règles métier + +### Formulaire de commande d'un espace projet + +| Champ | Type | Description | +| --------------------- | ------ | ------------------------------------- | +| Nom de l'organisation | string | obligatoire | +| Nom du projet | string | obligatoire, `regex([a-z0-9-]{2,53})` | + +### Formulaire de synchronisation d'un dépôt + +| Champ | Type | Description | +| ----------------------------------------------- | ------- | ----------------------------------------- | +| Nom du dépôt Git interne | string | obligatoire, `regex([a-z0-9-]{2,53})` | +| Url du dépôt Git externe | string | obligatoire, `regex(^https://)` | +| Dépôt externe privé | boolean | obligatoire | +| Nom d'utilisateur lié au token du dépôt externe | string | obligatoire si le dépôt externe est privé | +| Token d'accès au dépôt externe | string | obligatoire si le dépôt externe est privé | +| Dépôt d'infrastructure | boolean | obligatoire | + +### Status et verrouillage + +#### Schema + +- un `project` peut avoir pour `status: Enum('initializing', 'created', 'failed', 'archived')` +- un `repository` et un `environnement` peuvent avoir pour `status: Enum('initializing', 'created', 'failed', 'deleting')` +- un `project` peut être `locked (boolean)` + +#### Principe + +- `initializing` : la ressource est en cours de création, des opérations sont en cours côté `plugins` - le `project` est `locked: true` +- `created` : la ressource est créée, les opérations des `plugins` se sont bien déroulées - le `project` est `locked: false` +- `failed` : la ressource est créée, les opérations des `plugins` ont échoué - le `project` est `locked: true` +- `deleting` : la ressource est en cours de suppression, des opérations sont en cours côté `plugins` - le `project` est `locked: true` +- `archived` : les opérations des `plugins` se sont bien déroulées, le projet et son équipe sont toujours présents en base de données pour archive, mais invisibles des utilisateurs hors `admin` - le `project` est `locked: true` +- `project.locked: true` : toute opération de Create / Update sur le projet et ses ressource est interdite. + +### Permissions d'environnement + +Trois niveaux de permissions différents, +| Valeur en base | Description dans le front | Niveaux | +| -------------- | ------------------------- | ------------- | +| 0 | r | Lecture | +| 1 | rw | + Ecriture | +| 2 | rwd | + Suppression | + +## Points d'API + +voir swagger : + +--- + +## TODO: Idées + +### Admin + +- Lister chacune des tables +- Mise en place d'une table d'historisation des status des projets +- Mettre en place un point d'api pour gérer l'équipe d'admin (création/suppression) +- Possibilité de réexecuter dans keycloak les droits présents dans la DB (synchro DB - keycloak) +- Route qui récapitule l'ensemble des ressources argo et supprime toutes les autres diff --git a/.jjconflict-side-0/misc/controller-plugins-communication.png b/.jjconflict-side-0/misc/controller-plugins-communication.png new file mode 100644 index 0000000000..b446155dc5 Binary files /dev/null and b/.jjconflict-side-0/misc/controller-plugins-communication.png differ diff --git a/.jjconflict-side-0/misc/controllers.md b/.jjconflict-side-0/misc/controllers.md new file mode 100644 index 0000000000..d194fec2fd --- /dev/null +++ b/.jjconflict-side-0/misc/controllers.md @@ -0,0 +1,29 @@ +# Logique controllers + +```mermaid +flowchart TD + A[(db: find prérequis)] + A -->|KO| B>send code 400, 401, 403, 404] + A -->|try| D(db: update projectLocked = true + status = initializing + upsert/delete + hook: plugins) + D -->|catch| E[(db: update status = failed)] + E --> B + D -->|OK| G[(db: update projectLocked = false + status = created)] + G -->|OK| H>send code 200, 201] + G -->|catch| E +``` + +## Principe + +### Controller + +- reçoit la requête +- appelle la couche business +- renvoie la réponse + +### Business + +- est appelé par le controller +- appelle l'ORM pour les opérations en bdd +- appelle éventuellement les plugins +- gère les erreurs (try catch) +- renvoie un résultat ou une erreur au controller diff --git a/.jjconflict-side-0/misc/images/environments.png b/.jjconflict-side-0/misc/images/environments.png new file mode 100644 index 0000000000..2c9f47a130 Binary files /dev/null and b/.jjconflict-side-0/misc/images/environments.png differ diff --git a/.jjconflict-side-0/misc/images/members.png b/.jjconflict-side-0/misc/images/members.png new file mode 100644 index 0000000000..bcd9955ba6 Binary files /dev/null and b/.jjconflict-side-0/misc/images/members.png differ diff --git a/.jjconflict-side-0/misc/images/projects.png b/.jjconflict-side-0/misc/images/projects.png new file mode 100644 index 0000000000..de48c1d125 Binary files /dev/null and b/.jjconflict-side-0/misc/images/projects.png differ diff --git a/.jjconflict-side-0/misc/images/repositories.png b/.jjconflict-side-0/misc/images/repositories.png new file mode 100644 index 0000000000..b1029c947d Binary files /dev/null and b/.jjconflict-side-0/misc/images/repositories.png differ diff --git a/.jjconflict-side-0/misc/images/services.png b/.jjconflict-side-0/misc/images/services.png new file mode 100644 index 0000000000..4b346ebf31 Binary files /dev/null and b/.jjconflict-side-0/misc/images/services.png differ diff --git a/.jjconflict-side-0/misc/images/status.png b/.jjconflict-side-0/misc/images/status.png new file mode 100644 index 0000000000..472e57d330 Binary files /dev/null and b/.jjconflict-side-0/misc/images/status.png differ diff --git a/.jjconflict-side-0/misc/plugins.md b/.jjconflict-side-0/misc/plugins.md new file mode 100644 index 0000000000..b062c3285d --- /dev/null +++ b/.jjconflict-side-0/misc/plugins.md @@ -0,0 +1,67 @@ +# Plugins + +## Lexique + +Un plugin `core` est un service de base associé à la chaîne DSO. +Un plugin `external` est un service supplémentaire facultatif. +Un `hook` est un point de déclenchement dont le cycle de vie comprend plusieurs étapes (`step`) : `pre`, `main`, `post`, etc. +Tout `plugin` peut s'enregistrer sur un `hook`, à une étape donnée. + +## Principe + +Exemple : + +1. Le plugin `gitlab` s'enregistre sur le hook `createProject`, à l'étape `main`. +2. Le controller `createProjectController` déclenche le hook `createProject` en lui associant un `payload`. +3. Le plugin `gitlab` réagit au déclenchement du hook `createProject` et reçoit le payload associé du controller. +4. Le plugin `gitlab` effectue ses opérations (ex: création d'un groupe GitLab pour le projet). +5. Le plugin `gitlab` renvoie un objet `result` au controller `createProjectController`, contenant un `status` indiquant si tout s'est déroulé sans erreur ou non. +6. Le controller `createProjectController` enregistre ce `result` en base de donnée et s'arrête si le `status` est en erreur. + +## Modèles + +### Entries + +```js +{ + // args come from controllers + args: { + projectName: 'toto', + }, + failed: true || undefined + /* + [pluginName]: + [pluginName]: + ... + */ +} +``` + +### Result + +Note: Si un module réutilise son propre payload, il doit penser à renvoyer le précédent s'il ne veut pas l'écraser. Charge aussi au plugin de gérer les erreurs des étapes précédentes. + +```js +{ + status: { + result: string('KO' || 'OK'), + message: string(), + }, + vault: [ + { + name: string(), + data: { + // secret data + } + } + ] || undefined + // otherKey: {}, + // anotherKey: {}, +} +``` + +## Notes + +La communication des services associés à la chaîne DSO se fait au maximum en direct par les services Kubernetes via les url internes. On note toutefois les exceptions suivantes : +- Argocd communique avec l'url publique de Gitlab peu importe s'ils sont sur le même cluster. +- L'url publique de Harbor est utilisée dans les jobs de CI : il faudrait sinon une `imagePullSecret` avec url interne pour les jobs de CI et une avec url publique pour les pods des clusters applicatifs. diff --git a/.jjconflict-side-0/package.json b/.jjconflict-side-0/package.json new file mode 100644 index 0000000000..70b33dd885 --- /dev/null +++ b/.jjconflict-side-0/package.json @@ -0,0 +1,66 @@ +{ + "name": "@cpn-console/root", + "type": "module", + "version": "9.22.2", + "private": true, + "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "engines": { + "node": ">=24.0.0 <25.0.0", + "npm": ">=11.0.0" + }, + "scripts": { + "build": "pnpm -r run build", + "build:clean": "pnpm -r run build:clean", + "db:generate": "pnpm -r run db:generate", + "dev": "docker compose -f ./docker/docker-compose.local.yml up -d --remove-orphans && echo '\nStart applications with commands:\n\n pnpm --filter @cpn-console/server run dev\n pnpm --filter @cpn-console/client run dev\n pnpm --filter @cpn-console/server-nestjs run dev\n\nMake sure you provide env variables in `.env` files.'", + "dev:clean": "docker compose -f ./docker/docker-compose.local.yml down --remove-orphans", + "dev:delete": "docker compose -f ./docker/docker-compose.local.yml down -v --remove-orphans", + "docker:dev": "docker compose -f ./docker/docker-compose.dev.yml up --menu", + "docker:dev:build": "export COMPOSE_FILE=./docker/docker-compose.dev.yml && ./scripts/run-build.sh $COMPOSE_FILE", + "docker:dev:clean": "docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans", + "docker:dev:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:dev' -q) ; true", + "docker:e2e": "docker compose -f ./docker/docker-compose.dev.yml up ${TAG:+--no-build} -d --remove-orphans; pnpm playwright:test; docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans", + "docker:e2e-ci": "docker compose -f ./docker/docker-compose.ci.yml up ${TAG:+--no-build} -d --remove-orphans; pnpm playwright:test", + "docker:e2e-ci:delete": "docker compose -f ./docker/docker-compose.ci.yml down -v --remove-orphans", + "docker:integ": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.integ.yml up --menu", + "docker:integ:build": "export $(cat apps/server/.env.integ | grep -v '#' | xargs) && export COMPOSE_FILE=./docker/docker-compose.integ.yml && cd $(dirname $COMPOSE_FILE) && docker buildx bake --allow=fs.read=.. --file $(basename $COMPOSE_FILE) --load && cd - > /dev/null", + "docker:integ:clean": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.integ.yml down --remove-orphans", + "docker:integ:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:integ' -q) ; true", + "docker:ci": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.ci.yml up --menu", + "docker:ci:build": "export $(cat apps/server/.env.integ | grep -v '#' | xargs) && export COMPOSE_FILE=./docker/docker-compose.ci.yml && cd $(dirname $COMPOSE_FILE) && docker buildx bake --allow=fs.read=.. --file $(basename $COMPOSE_FILE) --load && cd - > /dev/null", + "docker:ci:clean": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.ci.yml down --remove-orphans", + "docker:ci:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:ci' -q) ; true", + "format": "pnpm -r run format", + "format:root": "eslint . --fix", + "format:style": "pnpm -r run format:style", + "fullclean": "sh ./ci/scripts/cleanup.sh", + "fullsetup": "sh ./ci/scripts/setup.sh", + "integ": "docker compose -f ./docker/docker-compose.local.yml up -d --remove-orphans postgres pgadmin && echo '\nStart server and client with commands:\n\n pnpm --filter @cpn-console/server run integ\n pnpm --filter @cpn-console/client run integ\n pnpm --filter @cpn-console/server-nestjs run integ\n\nMake sure you provide env variables in `.env.integ` files.'", + "integ:clean": "docker compose -f ./docker/docker-compose.local.yml down --remove-orphans", + "integ:delete": "docker compose -f ./docker/docker-compose.local.yml down -v --remove-orphans", + "lint": "pnpm -r run lint", + "lint:root": "eslint .", + "lint:style": "pnpm -r run lint:style", + "lint:ts": "pnpm -r run lint:ts", + "playwright:test": "pnpm --dir playwright run playwright:test", + "playwright:test:ui": "pnpm --dir playwright run playwright:test:ui", + "playwright:test:integration": "pnpm --dir playwright run playwright:test:integration", + "prepare": "husky", + "test": "pnpm -r --no-bail run test", + "test:cov": "pnpm -r --no-bail run test:cov", + "test:e2e": "pnpm kube:e2e", + "test:e2e-ci": "pnpm kube:prod; pnpm kube:e2e-ci" + }, + "devDependencies": { + "@commitlint/cli": "catalog:tools", + "@commitlint/config-conventional": "catalog:tools", + "@cpn-console/eslint-config": "workspace:^", + "eslint": "catalog:tools", + "husky": "catalog:tools", + "lint-staged": "catalog:tools" + } +} diff --git a/.jjconflict-side-0/packages/eslintconfig/eslint.config.js b/.jjconflict-side-0/packages/eslintconfig/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-0/packages/eslintconfig/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-0/packages/eslintconfig/package.json b/.jjconflict-side-0/packages/eslintconfig/package.json new file mode 100644 index 0000000000..e81e294cbe --- /dev/null +++ b/.jjconflict-side-0/packages/eslintconfig/package.json @@ -0,0 +1,21 @@ +{ + "name": "@cpn-console/eslint-config", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "type": "module", + "version": "1.0.2", + "license": "ISC", + "main": "src/index.js", + "types": "types/index.d.ts", + "devDependencies": { + "@antfu/eslint-config": "catalog:tools", + "eslint": "catalog:tools" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/packages/eslintconfig/src/index.js b/.jjconflict-side-0/packages/eslintconfig/src/index.js new file mode 100644 index 0000000000..79867556e6 --- /dev/null +++ b/.jjconflict-side-0/packages/eslintconfig/src/index.js @@ -0,0 +1,41 @@ +import antfu from '@antfu/eslint-config' + +export default antfu( + { + stylistic: { + overrides: { + 'antfu/consistent-chaining': 'off', + 'antfu/if-newline': 'off', + 'antfu/no-top-level-await': 'off', + 'node/prefer-global/process': ['error', 'always'], + 'node/prefer-global/buffer': ['error', 'always'], + 'style/comma-dangle': ['error', 'always-multiline'], + 'style/quote-props': ['error', 'as-needed', { keywords: false, unnecessary: true }], + 'style/brace-style': ['error', '1tbs', { allowSingleLine: true }], + 'style/max-statements-per-line': ['error', { max: 2 }], + 'ts/ban-ts-comment': 'off', + }, + }, + typescript: true, + vue: true, + yaml: { + overrides: { + 'yaml/quotes': ['error', { prefer: 'double' }], + 'yaml/indent': ['error', 2, { indentBlockSequences: true, indicatorValueIndent: 2 }], + }, + }, + ignores: [ + '**/node_modules', + '**/prisma/migrations', + '**/pnpm-lock.yaml', + '**/dist/', + '**/types/', + '**/coverage/', + '**/templates/*.{yaml,yml}', + '**/Chart.yaml', + '**/*.d.ts', + '**/*.md/*.js', + '**/*.md/*.ts', + ], + }, +) diff --git a/.jjconflict-side-0/packages/hooks/.gitignore b/.jjconflict-side-0/packages/hooks/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-0/packages/hooks/README.md b/.jjconflict-side-0/packages/hooks/README.md new file mode 100644 index 0000000000..e0149e15c8 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/README.md @@ -0,0 +1,245 @@ +# Plugin manager + +## Présentation + +Le plugin manager ou communément appelés "hooks" est une interface permettant au serveur d'api (`apps/server`) de déclencher des hooks "déclencheurs / événements" pour que les plugins puissent piloter et configurer des outils. + +Les plugins obligatoires de l'application sont souvent appelés `corePlugins`. + +L'objectif est que le server soit totalement agnostique des actions des plugins et même de leur existence. De plus le serveur d'API essayera au démarrage de charger des plugins dans un dossier spécifique, on les distingue en les appelant `externalPlugins`. + +## Développement de plugin + +Cette section est dédiée à tous ceux voulant améliorer les corePlugins ou en créer de nouveaux. + +Pour commencer je vous conseille d'aller regarder le [plugin vault](../../plugins/vault) + +### Interface + +Tout paquet existant devrait avoir un export nommé `plugin` et de type `Plugin`. + +Ce typage vous assurera de bien fournir toutes les clés nécessaires au bon fonctionnement du plugin : + +```ts +// index.ts +import type { Plugin } from '@cpn-console/hooks' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: {}, + monitor, +} +``` + +### Infos + +Ce sont les infos de bases de votre application : + +```ts +// infos.ts +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos: ServiceInfos = { + name: 'my_plugin', // il serait bien que ça ne change jamais, imaginez que c'est un identifiant unique. + to: ({ projectSlug }) => `${process.env.MON_URL}/${projectSlug}`, + title: 'Mon super plugin', + imgSrc: 'https://un_lien_vers/image_externe.svg/', // préférez le svg + description: 'La description générale de mon plugin', +} + +export default infos +``` + +La fonction `to` peux renvoyer une `String` ou un objet contenant une clé `to` et potentiellement d'autres informations ou encore un tableau de cet objet : + +```ts +// Valid +const to1 = () => 'une url' +const to2 = () => ({ to: 'url', title: 'Un titre', description: 'description', imgSrc: 'url' }) +function to3() { + return [ + { to: 'url générale', title: 'Service générale', description: 'description générale' }, + { to: 'url1', title: 'Service 1', description: 'description 1' }, + { to: 'url2', title: 'Service 2', description: 'description 2' }, + ] +} +// Invalid +const to3 = () => ['url', 'url1', 'url2'] +``` + +### Monitoring + +Pour offrir un (et un seul) service de monitoring sommaire de l'outil que le plugin manipule, vous pouvez créer une instance de la classe `Monitor` et l'initialiser avec une fonction dont vous aurez seul la maîtrise. +Cette fonction sera éxécutée par un `setInterval` toutes les 5 min ou selon le temps en miliseconds que vous aurez fourni : + +```ts +// monitor.ts +import { Monitor, type MonitorInfos, MonitorStatus } from '@cpn-console/shared' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = (new Date()).getTime() + // Votre fonction ne devrait jamais lever d'exception + try { + // faites des trucs + // mettez à jour les clés en fonction de votre résultat: + // instance.lastStatus.message = 'Tout va bien' + // instance.lastStatus.status = MonitorStatus.OK + } catch (error) { + instance.lastStatus.message = 'Error lors la requete' + instance.lastStatus.status = MonitorStatus.UNKNOW + // la clé cause n'est pour l'instant jamais retourné à l'utilisateur ni stocké, ça sera pour une prochaine PR + instance.lastStatus.cause = error + } + // c'est bien de le retourner mais on s'en fiche un peu + return instance.lastStatus +} + +export default new Monitor(monitor) +``` + +### SubscribedHooks + +Pour informer le plugin manager sur quels hooks vous voulez exécuter une fonction, vous devez fournir un objet qui aura cette structure : + +```ts + subscribedHooks: { + createProject: { + steps: { + pre: createDsoProjectFirst, + post: createDsoProjectLast, + }, + }, + archiveProject: { + steps: { post: archiveDsoProject }, + }, + }, +``` + +### Fonctions (StepCalls) + +Pensez à typer vos fonctions comme suit pour que TS soit capable de vous notifier si vous avez associé un mauvais payload à un hook. + +**Vos fonctions ne doivent pas non plus lever d'exceptions non gérées.** +**Et elles doivent toutes retourner un statut !** + +Vous pouvez aussi retourner des clés supplémentaires. Ces clés sont accessibles par tous les plugins dans `payload.results[nom_du_plugin]`. + +Attention, chaque fonction écrase le résultat de la step d'avant : + +```ts +export const createDsoProjectFirst: StepCall = async (payload) => { + try { + // fais des trucs + // oh j'ai un payload typé ! + return { + status: { + result: 'OK', + message: 'ça s\'est bien passé' // optionnel si OK + }, + foo: { + bar: 'complétement facultatif' + } + } + } catch (error) { + return { + status: { + result: 'KO', + message: 'Ouille !' // Obligatoire pour explique ce qui n'a pas réussi' + }, + } + } +} + +export const createDsoProjectLast: StepCall = async (payload) => { + try { + // fais des trucs + // oh j'ai un payload typé ! + return { + status: { + result: 'OK', + message: `${payload.results.my_plugin.foo.bar} a vraiment bien été crée` // optionnel si OK + }, + une_clé: { + newProjectName: 'complétement facultatif' + } + } + } catch (error) { + return { + status: { + result: 'KO', + message: 'Ouille !' // Obligatoire pour explique ce qui n'a pas réussi' + }, + } + } +} +``` + +### Apis + +Il n'existe pour l'instant qu'un seul plugin qui exporte une API pour les autres plugins, c'est Vault ! +D'autres viendront aider à la séparation des responsabiltés plus tard, stay tuned ! + +Vous pouvez, comme lui, déclarer des apis sur des hooks. Pour l'uniformité, déclarez que c'est un `extends` de `PluginApi` (qui est vide) : + +```ts +// api.ts +import { PluginApi } from '@cpn-console/hooks' + +export class ClusterApi extends PluginApi {} +``` + +```ts +// index.ts + subscribedHooks: { + createCluster: { + api: (args) => new ClusterApi(args.label, args.privacy), + steps: { post: createCluster }, + }, + deleteCluster: { + api: (args) => new ClusterApi(args.label), + }, + }, +``` + +### Dépendances entre les plugins + +La section précédente est bien sympathique mais en l'état, le plugin n'a aucune conscience des apis des autres plugins. + +> Disclaimer: Les plugins peuvent se parler entre eux mais attention ils ne s'importent jamais les uns les autres. C'est le Plugin Manager qui est en charge de fournir tous les objets nécessaires et de faire passe plat. + +Pour y arriver il va falloir deux étapes + +1) Le plugin qui expose l'api doit faire un `declare module` : + +```ts +// index.ts +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + vault: Args extends CreateClusterExecArgs | DeleteClusterExecArgs + ? ClusterApi + : undefined + } +} +``` + +1) Le module l'utilisant doit importer les types : + +```json +// package.json +{ + "peerDependencies": { + "my_plugin": "1.2.3" + } +} +``` + +```ts +// src/env.d.ts +/// +``` + +## Conclusion + +N'hésitez pas à ouvrir des issues si ce n'est pas clair et bon développement ! diff --git a/.jjconflict-side-0/packages/hooks/eslint.config.js b/.jjconflict-side-0/packages/hooks/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-0/packages/hooks/package.json b/.jjconflict-side-0/packages/hooks/package.json new file mode 100644 index 0000000000..e13bdf29ad --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/package.json @@ -0,0 +1,57 @@ +{ + "name": "@cpn-console/hooks", + "type": "module", + "version": "4.1.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'vite-node src/config.ts'", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "test:e2e-ci": "echo 'check cache'", + "test:watch": "vitest" + }, + "dependencies": { + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "json-schema": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/json-schema": "catalog:types", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "undici-types": "catalog:types", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/packages/hooks/src/config.ts b/.jjconflict-side-0/packages/hooks/src/config.ts new file mode 100644 index 0000000000..ab1a702bf9 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/config.ts @@ -0,0 +1,76 @@ +import type { PermissionTarget, PluginConfig } from '@cpn-console/shared' +import { atomicValidators, DEFAULT, pluginConfig } from '@cpn-console/shared' +import { z } from 'zod' +import { objectEntries } from './utils/utils.js' + +export type PluginsManifests = Record +export const pluginsManifests: PluginsManifests = {} + +export const editStrippersGenerator = pluginConfig.transform((arg) => { + const project = { + user: z.object({}), + admin: z.object({}), + } + let global = z.object({}) + + for (const item of arg.project) { + const zAny = atomicValidators[item.kind].optional() + if (item.permissions.user.write) { + project.user = project.user.merge(z.object({ [item.key]: zAny })) + } + if (item.permissions.admin.write) { + project.admin = project.admin.merge(z.object({ [item.key]: zAny })) + } + } + + for (const item of arg.global || []) { + if (item.permissions.admin.write) { + const zAny = atomicValidators[item.kind].optional() + global = global.merge(z.object({ [item.key]: zAny })) + } + } + return { project, global } +}) + +export const editStrippers = { + project: { + admin: z.object({}), + user: z.object({}), + }, + global: z.object({}), +} + +export function addPlugin(pluginName: string, config: PluginConfig, editStrippersObject: typeof editStrippers) { + const editZod = editStrippersGenerator.parse(config) + pluginsManifests[pluginName] = config + editStrippersObject.global = editStrippersObject.global.merge(z.object({ [pluginName]: editZod.global.default({}) })) + editStrippersObject.project.admin = editStrippersObject.project.admin.merge(z.object({ [pluginName]: editZod.project.admin.default({}) })) + editStrippersObject.project.user = editStrippersObject.project.user.merge(z.object({ [pluginName]: editZod.project.user.default({}) })) +} + +interface Row { pluginName: string, key: string, value: string } +interface PopulateManifestsParams { + data: { project?: Row[], global?: Row[] } + permissionTarget: PermissionTarget + select: Partial> + pluginName: string +} +export function populatePluginManifests({ data, select, permissionTarget, pluginName }: PopulateManifestsParams): Partial { + const manifest = structuredClone(pluginsManifests[pluginName]) + + const selected: Partial = {} + for (const [scope] of objectEntries(select).filter(([_scope, bool]) => bool)) { + if (!manifest?.[scope]) continue + selected[scope] = manifest[scope].filter(item => item.permissions[permissionTarget].read || item.permissions[permissionTarget].write).map((item) => { + const row = data[scope]?.find(candidate => candidate.pluginName === pluginName && candidate.key === item.key) + if (item.kind === 'switch') { + const value = atomicValidators.switch.safeParse(row?.value) + item.value = value.success ? value.data : DEFAULT + } else { + item.value = z.coerce.string().parse(row?.value ?? item.value ?? '') + } + return item + }) + } + return selected +} diff --git a/.jjconflict-side-0/packages/hooks/src/hooks/hook-admin-role.ts b/.jjconflict-side-0/packages/hooks/src/hooks/hook-admin-role.ts new file mode 100644 index 0000000000..ff9b55292d --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/hooks/hook-admin-role.ts @@ -0,0 +1,16 @@ +import type { Hook } from './hook.js' +import type { UserObject } from './index.js' +import { createHook } from './hook.js' + +export interface AdminRole { + id: string + name: string + permissions: bigint + position: number + oidcGroup: string + type?: string + members: UserObject[] +} + +export const upsertAdminRole: Hook = createHook() +export const deleteAdminRole: Hook = createHook() diff --git a/.jjconflict-side-0/packages/hooks/src/hooks/hook-cluster.ts b/.jjconflict-side-0/packages/hooks/src/hooks/hook-cluster.ts new file mode 100644 index 0000000000..faeef0d8b7 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/hooks/hook-cluster.ts @@ -0,0 +1,6 @@ +import type { Hook } from './hook.js' +import type { ClusterObject } from './index.js' +import { createHook } from './hook.js' + +export const upsertCluster: Hook = createHook() +export const deleteCluster: Hook = createHook() diff --git a/.jjconflict-side-0/packages/hooks/src/hooks/hook-misc.ts b/.jjconflict-side-0/packages/hooks/src/hooks/hook-misc.ts new file mode 100644 index 0000000000..faf6705c12 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/hooks/hook-misc.ts @@ -0,0 +1,16 @@ +import type { Project, Repository } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +// misc hooks +export type EmptyPayload = Record + +export const checkServices: Hook = createHook() + +// misc project related hooks +export type ProjectLite = Pick + +export const getProjectSecrets: Hook = createHook() + +export type UniqueRepo = ProjectLite & { repo: Omit & { syncAllBranches: boolean, branchName?: string } } +export const syncRepository: Hook = createHook() diff --git a/.jjconflict-side-0/packages/hooks/src/hooks/hook-project-member.ts b/.jjconflict-side-0/packages/hooks/src/hooks/hook-project-member.ts new file mode 100644 index 0000000000..c1d726f599 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/hooks/hook-project-member.ts @@ -0,0 +1,16 @@ +import type { ProjectRole } from './hook-project-role.js' +import type { Project } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +export interface ProjectMember { + userId: string + email: string + firstName: string + lastName: string + roles: ProjectRole[] + project: Project +} + +export const upsertProjectMember: Hook = createHook() +export const deleteProjectMember: Hook = createHook() diff --git a/.jjconflict-side-0/packages/hooks/src/hooks/hook-project-role.ts b/.jjconflict-side-0/packages/hooks/src/hooks/hook-project-role.ts new file mode 100644 index 0000000000..8d047f4d79 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/hooks/hook-project-role.ts @@ -0,0 +1,17 @@ +import type { Project } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +export interface ProjectRole { + id: string + name: string + permissions: string + projectId: string + position: number + type?: string + oidcGroup?: string + project: Project +} + +export const upsertProjectRole: Hook = createHook() +export const deleteProjectRole: Hook = createHook() diff --git a/.jjconflict-side-0/packages/hooks/src/hooks/hook-project.ts b/.jjconflict-side-0/packages/hooks/src/hooks/hook-project.ts new file mode 100644 index 0000000000..86d65eff97 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/hooks/hook-project.ts @@ -0,0 +1,74 @@ +import type { PluginApi } from '../utils/utils.js' +import type { Hook } from './hook.js' +import type { ClusterObject, ExternalRepoUrl, InternalRepoName, IsInfra, IsPrivate, UserObject } from './index.js' +import { createHook } from './hook.js' + +export interface RepoCreds { + username: string + token: string +} + +export interface Role { + name: string + permissions?: string + position: number + type?: string + oidcGroup?: string + users: UserObject[] +} + +export interface EnvironmentApis { + [x: string]: PluginApi +} +export interface Environment { + id: string + name: string + clusterId: ClusterObject['id'] + cpu: number + gpu: number + memory: number + stage: string + autosync: boolean + permissions: { + userId: UserObject['id'] + permissions: { + ro: boolean + rw: boolean + } + }[] + apis: EnvironmentApis +} + +export interface Repository { + id: string + internalRepoName: InternalRepoName + newCreds?: RepoCreds + externalRepoUrl: ExternalRepoUrl + isPrivate: IsPrivate + isInfra: IsInfra + deployRevision: string | null + deployPath: string | null + helmValuesFiles: string | null +} + +export interface ProjectStore { + [x: string]: { [x: string]: string } +} + +export interface Project { + id: string + description: string | null + name: string + status: string + clusters: ClusterObject[] + slug: string + environments: Environment[] + repositories: Repository[] + users: UserObject[] + roles: Role[] + store: ProjectStore + owner: UserObject +} + +export const upsertProject: Hook = createHook() +export const deleteProject: Hook = createHook() diff --git a/.jjconflict-side-0/packages/hooks/src/hooks/hook-user.ts b/.jjconflict-side-0/packages/hooks/src/hooks/hook-user.ts new file mode 100644 index 0000000000..ffb9a9cb97 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/hooks/hook-user.ts @@ -0,0 +1,7 @@ +import type { Hook } from './hook.js' +import type { UserObject } from './index.js' +import { createHook } from './hook.js' + +export type UserEmail = Pick +export type UserAdmin = Pick & { isAdmin: boolean } +export const retrieveUserByEmail: Hook = createHook() diff --git a/.jjconflict-side-0/packages/hooks/src/hooks/hook-zone.ts b/.jjconflict-side-0/packages/hooks/src/hooks/hook-zone.ts new file mode 100644 index 0000000000..dd2ffc7710 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/hooks/hook-zone.ts @@ -0,0 +1,6 @@ +import type { Hook } from './hook.js' +import type { ZoneObject } from './index.js' +import { createHook } from './hook.js' + +export const upsertZone: Hook = createHook() +export const deleteZone: Hook = createHook() diff --git a/.jjconflict-side-0/packages/hooks/src/hooks/hook.spec.ts b/.jjconflict-side-0/packages/hooks/src/hooks/hook.spec.ts new file mode 100644 index 0000000000..3dcfe36fd9 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/hooks/hook.spec.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest' +import { okStatus, PluginApi } from '../utils/utils.ts' +import { createHook, executeStep } from './hook.ts' + +const koStatus = { status: { result: 'KO', message: 'Failed' } } as const +const warningStatus = { status: { result: 'WARNING', message: 'Failed' } } as const + +async function simpleOkHookCall() { + return okStatus +} +async function simpleFailedHookCall() { + return koStatus +} +async function simpleWarningHookCall() { + return warningStatus +} + +describe('test executeStep mechanism', () => { + it('test payload results, everything ok', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleOkHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.failed).toBe(false) + expect(results.warning).toEqual([]) + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...okStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, everything ko', async () => { + const results = await executeStep({ + plugin1: simpleFailedHookCall, + plugin2: simpleFailedHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual([]) + expect(results.failed).contain('plugin1') + expect(results.failed).contain('plugin2') + expect(results.results).toEqual({ + plugin1: { ...koStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...koStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, partial ko', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleFailedHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual([]) + expect(results.failed).not.contain('plugin1') + expect(results.failed).contain('plugin2') + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...koStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, partial warning', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleWarningHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual(['plugin2']) + expect(results.failed).toEqual(false) + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...warningStatus, executionTime: { main: expect.any(Number) } }, + }) + }) +}) + +describe('createHook', () => { + it('test empty hookStructure', async () => { + const hook = createHook(false) + expect(hook).toEqual({ + apis: {}, + steps: { + check: {}, + pre: {}, + main: {}, + post: {}, + revert: {}, + }, + execute: expect.any(Function), + }) + + const hookUnique = createHook(true) + expect(hookUnique.uniquePlugin).toBe('') + }) + + it('test hook execution, simple ok', async () => { + const hook = createHook(false) + hook.steps.main.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, multistep ok', async () => { + const hook = createHook(false) + hook.steps.pre.plugin1 = simpleOkHookCall + hook.steps.main.plugin1 = simpleOkHookCall + hook.steps.post.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.warning).toEqual([]) + expect(hookResult.results).toEqual({ plugin1: { ...okStatus, executionTime: { + pre: expect.any(Number), + main: expect.any(Number), + post: expect.any(Number), + } } }) + }) + + it('test payload results, multistep with warning', async () => { + const hook = createHook(false) + hook.steps.pre.plugin1 = simpleWarningHookCall + hook.steps.main.plugin2 = simpleOkHookCall + hook.steps.post.plugin2 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.warning).toEqual(['plugin1']) + expect(hookResult.results).toEqual({ + plugin1: { + ...warningStatus, + executionTime: { + pre: expect.any(Number), + }, + }, + plugin2: { + ...okStatus, + executionTime: { + main: expect.any(Number), + post: expect.any(Number), + }, + }, + }) + }) + + it('test payload results, main fails', async () => { + const hook = createHook(false) + hook.apis.plugin1 = () => new PluginApi() // à tester ailleurs + hook.steps.pre.plugin1 = simpleOkHookCall + hook.steps.main.plugin1 = simpleFailedHookCall + hook.steps.post.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(['plugin1']) + expect(hookResult.results).toEqual({ + plugin1: { + ...koStatus, + executionTime: { + pre: expect.any(Number), + main: expect.any(Number), + }, + }, + }) + }) +}) diff --git a/.jjconflict-side-0/packages/hooks/src/hooks/hook.ts b/.jjconflict-side-0/packages/hooks/src/hooks/hook.ts new file mode 100644 index 0000000000..1377d9abcc --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/hooks/hook.ts @@ -0,0 +1,192 @@ +import type { PluginApi } from '../utils/utils.js' +import type * as hooks from './index.js' +import { logger } from '@cpn-console/logger/hooks' + +export type DefaultArgs = Record +export type PluginResultStoreValue = string | number | null +export type PluginResultStore = Record +export interface PluginResult { + status: { result: 'OK', message?: string } | { result: 'KO' | 'WARNING', message: string } + store?: PluginResultStore + [key: string]: any +} + +export interface HookPayloadResults { + [x: string]: PluginResult +} +// @ts-ignore +// eslint-disable-next-line unused-imports/no-unused-vars +export interface HookPayloadApis { + [x: string]: PluginApi +} +export type Store = Record> // TO DEPRECIATE USE ONFIG + +export interface Config { + [x: string]: { [x: string]: string } +} + +export interface HookPayload { + args: Args + failed: boolean | string[] + warning: string[] + results: HookPayloadResults + apis: HookPayloadApis + config: Config +} + +export type HookResult = Omit, 'apis'> & { totalExecutionTime: number, messageResume?: string } + +export type StepCall = (payload: HookPayload) => Promise +type HookStep = Record> +export type HookStepsNames = 'check' | 'pre' | 'main' | 'post' | 'revert' +export interface Hook { + uniquePlugin?: string // if plugin register on it no other one can register on it + execute: (args: E, store: Config) => Promise> + apis: Record PluginApi> + steps: Record +} +export type HookList = Record> + +function generateMessageResume(payload: HookPayload): string | undefined { + let messageResume = '' + if (Array.isArray(payload.failed)) { + for (const pluginName of payload.failed) { + messageResume += 'Errors:' + messageResume += `\n${pluginName}: ${payload.results[pluginName].status.message};` + } + } + if (payload.warning.length) { + for (const pluginName of payload.warning) { + messageResume += 'Warnings:' + messageResume += `\n${pluginName}: ${payload.results[pluginName].status.message};` + } + } + return messageResume || undefined +} + +function handleRejectedStepResult( + reason: any, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + logger.error({ plugin: name, step: stepName, err: reason }, 'Hook step failed') + const result: PluginResult = { + status: { + result: 'KO', + message: reason instanceof Error ? reason.message : String(reason), + }, + } + payload.failed = Array.isArray(payload.failed) + ? [...payload.failed, name] + : [name] + return result +} + +function handleFulfilledStepResult( + value: PluginResult, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + if (value.status.result === 'KO') { + payload.failed = Array.isArray(payload.failed) + ? [...payload.failed, name] + : [name] + logger.error({ plugin: name, step: stepName, err: value.status.message, status: value.status.result, message: value.status.message }, 'Hook step failed') + } else if (value.status.result === 'WARNING' && !payload.warning.includes(name)) { + payload.warning.push(name) + logger.warn({ plugin: name, step: stepName, err: value.status.message, status: value.status.result, message: value.status.message }, 'Hook step warning') + } else { + logger.trace({ plugin: name, step: stepName, status: value.status.result }, 'Hook step fulfilled') + } + return value +} + +function handleStepResult( + settled: PromiseSettledResult, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + const result = settled.status === 'fulfilled' + ? handleFulfilledStepResult(settled.value, name, stepName, payload) + : handleRejectedStepResult(settled.reason, name, stepName, payload) + return { ...result, executionTime: payload.results[name].executionTime } +} + +export async function executeStep(step: HookStep, payload: HookPayload, stepName: string) { + const names = Object.keys(step) + const fns = names.map(async (name) => { + if (payload.results[name]?.executionTime) { + payload.results[name].executionTime[stepName] = Date.now() + } else { + payload.results[name] = { + status: { result: 'OK' }, + executionTime: { [stepName]: Date.now() }, + } + } + const fnResult = await step[name](payload) + payload.results[name].executionTime[stepName] = Date.now() - payload.results[name].executionTime[stepName] + return fnResult + }) + const results = await Promise.allSettled(fns) + names.forEach((name, index) => { + payload.results[name] = handleStepResult(results[index], name, stepName, payload) + }) + return payload +} +export function createHook(unique = false) { + const steps: Record = { + check: {}, + pre: {}, + main: {}, + post: {}, + revert: {}, + } + const apis: Record PluginApi> = { + } + const execute = async (args: E, config: Config): Promise> => { + const startTime = Date.now() + const payloadApis: HookPayloadApis = {} + Object.entries(apis).forEach(([pluginName, apiFn]) => { + payloadApis[pluginName] = apiFn(args) + }) + let payload: HookPayload = { + failed: false, + args, + results: {}, + apis: payloadApis, + config, + warning: [], + } + + const executeSteps = ['pre', 'main', 'post'] as const + for (const step of executeSteps) { + payload = await executeStep(steps[step], payload, step) + if (payload.failed) { + payload = await executeStep(steps.revert, payload, 'revert') + break + } + } + return { + args: payload.args, + results: payload.results, + failed: payload.failed, + warning: payload.warning, + totalExecutionTime: Date.now() - startTime, + config, + messageResume: generateMessageResume(payload), + } + } + + const hook: Hook = { + apis, + steps, + execute, + } + if (unique) { + hook.uniquePlugin = '' + } + return hook +} diff --git a/.jjconflict-side-0/packages/hooks/src/hooks/index.ts b/.jjconflict-side-0/packages/hooks/src/hooks/index.ts new file mode 100644 index 0000000000..a783454745 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/hooks/index.ts @@ -0,0 +1,68 @@ +export * from './hook-admin-role.js' +export * from './hook-cluster.js' +export * from './hook-misc.js' +export * from './hook-project-member.js' +export * from './hook-project-role.js' +export * from './hook-project.js' +export * from './hook-user.js' +export * from './hook-zone.js' + +export interface PartialEnvironment { environment: string, stage: string, clusterLabel: string } +export type InternalRepoName = string +export type ExternalRepoUrl = string +export type ExternalUserName = string +export type ExternalToken = string +export type IsPrivate = boolean +export type IsInfra = boolean +export type InternalUrl = string +export interface UserObject { + firstName: string + lastName: string + id: string + email: string +} +export interface EnvironmentObject { + id: string + name: string + createdAt: Date + updatedAt: Date + clusterId: string +} + +export interface KubeUser { + readonly certData?: string + readonly keyData?: string + readonly token?: string + readonly username?: string + readonly password?: string +} + +export interface KubeCluster { + readonly caData?: string + readonly server: string + readonly skipTLSVerify?: boolean + readonly tlsServerName?: string +} + +export interface ZoneObject { + id: string + slug: string + label: string + argocdUrl: string + clusterNames?: string[] +} + +export interface ClusterObject { + id: string + label: string + privacy: 'public' | 'dedicated' + /** + * @deprecated Le nom aléatoire stocké ici n'est plus utilisé, au profit d'un nom caculé et déterministe. + */ + secretName: string + clusterResources: boolean + infos: string | null + cluster: KubeCluster + user: KubeUser + zone: ZoneObject +} diff --git a/.jjconflict-side-0/packages/hooks/src/index.ts b/.jjconflict-side-0/packages/hooks/src/index.ts new file mode 100644 index 0000000000..ad4af3bca8 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/index.ts @@ -0,0 +1,135 @@ +import type { Monitor } from '@cpn-console/shared' +import type { HookStepsNames, StepCall } from './hooks/hook.js' +import type { ServiceInfos } from './services.js' +import type { PluginApi } from './utils/utils.js' +import { logger } from '@cpn-console/logger/hooks' +import { addPlugin, editStrippers } from './config.js' +import * as hooks from './hooks/index.js' +import { servicesInfos } from './services.js' +import { objectEntries } from './utils/utils.js' + +export * from './config.js' + +export type HookChoice = keyof typeof hooks + +type ExecArgs = Parameters[0] + +export type PluginsFunctions = Partial<{ + [C in HookChoice]: { + steps?: Partial<{ + [S in HookStepsNames]: StepCall> + }> + api?: (args: ExecArgs) => PluginApi + } +}> + +export interface Plugin { + infos: ServiceInfos + subscribedHooks: PluginsFunctions + monitor?: Monitor + start?: (options: unknown) => void +} + +export type RegisterFn = (plugin: Plugin) => void +export type UnregisterFn = (name: Plugin['infos']['name']) => void +export interface PluginManager { + servicesInfos: Record + register: RegisterFn + unregister: UnregisterFn +} + +export interface PluginManagerOptions { + startPlugins?: boolean + mockMonitoring?: boolean + mockHooks?: boolean + mockExternalServices?: boolean + externalDir?: string +} + +let config: PluginManagerOptions +function pluginManager(options: PluginManagerOptions): PluginManager { + config = options + const register: RegisterFn = (plugin: Plugin) => { + if (plugin.infos.config) { + addPlugin(plugin.infos.name, plugin.infos.config, editStrippers) + } + + if (plugin.infos.to && config.mockExternalServices) + plugin.infos.to = () => [{ name: 'Lien', to: 'https://theuselessweb.com/' }] + if (plugin.start && options.startPlugins) + plugin.start({}) + const message: string[] = [] + if (plugin.monitor && config.mockMonitoring) { + plugin.monitor.monitorFn = async (instance: Monitor) => instance.lastStatus + } + if (plugin.monitor) + plugin.monitor.refresh() + servicesInfos[plugin.infos.name] = { + ...plugin.infos, + monitor: plugin.monitor, + } + const subscribedHooks = plugin.subscribedHooks + const name = plugin.infos.name + + if (!config.mockHooks) { + for (const [hook, functions] of objectEntries(subscribedHooks)) { + if (!(hook in hooks)) { + logger.warn({ plugin: name, hook }, 'Tried to register on an unknown hook') + continue + } + if (functions?.api) { + hooks[hook].apis[name] = functions.api + } + for (const [step, fn] of objectEntries(functions?.steps ?? {})) { + if (fn === undefined) + continue + if (hook === 'checkServices' && step !== 'check') { + logger.warn({ plugin: name, hook, step }, 'Tried to register an invalid step for checkServices hook') + continue + } + + if ('uniquePlugin' in hooks[hook] && hooks[hook]?.uniquePlugin !== '' && hooks[hook]?.uniquePlugin !== name) { + logger.warn({ plugin: name, hook, registeredBy: hooks[hook].uniquePlugin }, 'Hook is already registered by another plugin') + continue + } + // @ts-ignore + hooks[hook].steps[step][name] = fn + message.push(`${hook}:${step}`) + } + } + } + if (process.env.NODE_ENV !== 'test') { + logger.info({ plugin: name, registrations: message }, 'Plugin registered') + } + } + + const unregister: UnregisterFn = (name) => { + delete servicesInfos[name] + + Object.values(hooks).forEach((hook) => { + delete hook.steps.pre[name] + delete hook.steps.main[name] + delete hook.steps.post[name] + delete hook.steps.revert[name] + delete hook.apis[name] + }) + } + + return { + servicesInfos, + register, + unregister, + } +} + +export { + hooks, + pluginManager, +} + +export * from './hooks/hook.js' +export * from './hooks/index.js' +export * from './services.js' +export * from './utils/crypto.js' +export * from './utils/plugin-result-handler.js' +export * from './utils/utils.js' diff --git a/.jjconflict-side-0/packages/hooks/src/services.ts b/.jjconflict-side-0/packages/hooks/src/services.ts new file mode 100644 index 0000000000..dce8693564 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/services.ts @@ -0,0 +1,40 @@ +import type { Monitor, MonitorInfos, PluginConfig, PluginsUpdateBody } from '@cpn-console/shared' +import type { ClusterObject, EnvironmentObject, ProjectLite, ZoneObject } from './hooks/index.js' + +interface ToUrlObject { to: string, title?: string, description?: string, imgSrc?: string } +export interface ToUrlFnParamaters { + store: PluginsUpdateBody + clusters: Omit[] + zones: ZoneObject[] + environments: EnvironmentObject[] + project: Omit +} +type ToUrlFnResponse = ToUrlObject | ToUrlObject[] | string | void + +export interface ServiceInfos { + name: string + to?: ({ store, clusters, zones, environments, project }: ToUrlFnParamaters) => ToUrlFnResponse + monitor?: Monitor + title: string + imgSrc?: string + description?: string + config?: PluginConfig +} + +export const servicesInfos: Record = {} + +export type ServiceStatus = MonitorInfos & { name: string } +export const services = { + getStatus: (): Array => { + return Object.values(servicesInfos).reduce((acc, serviceInfos) => { + if (!serviceInfos?.monitor?.lastStatus) return acc + return [...acc, { ...serviceInfos.monitor.lastStatus, name: serviceInfos.title }] + }, [] as Array) + }, + refreshStatus: (): Array> => { + // @ts-ignore obligé d'ignore TS ne comprend pas l'interet du filter + return Object.values(servicesInfos) + .filter(servicesInfos => servicesInfos.monitor) + .map(serviceInfos => serviceInfos.monitor?.refresh()) + }, +} diff --git a/.jjconflict-side-0/packages/hooks/src/utils/crypto.ts b/.jjconflict-side-0/packages/hooks/src/utils/crypto.ts new file mode 100644 index 0000000000..5517e2b906 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/utils/crypto.ts @@ -0,0 +1,16 @@ +import type { Project } from '../hooks/index.js' +import crypto, { createHmac } from 'node:crypto' + +export function generateRandomPassword(length = 24) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*' + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +export function generateProjectKey(projectSlug: Project['slug'], repo: Project['repositories'][0]['internalRepoName']) { + const repoHash = createHmac('sha256', '') + .update(repo) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${repo}-${repoHash}` +} diff --git a/.jjconflict-side-0/packages/hooks/src/utils/index.ts b/.jjconflict-side-0/packages/hooks/src/utils/index.ts new file mode 100644 index 0000000000..ffb3bb4ec5 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './crypto.js' +export * from './utils.js' diff --git a/.jjconflict-side-0/packages/hooks/src/utils/plugin-result-handler.ts b/.jjconflict-side-0/packages/hooks/src/utils/plugin-result-handler.ts new file mode 100644 index 0000000000..a90261aede --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/utils/plugin-result-handler.ts @@ -0,0 +1,92 @@ +import type { PluginResult, PluginResultStore, PluginResultStoreValue } from '../hooks/hook.js' + +export class PluginResultBuilder { + private okMessages = [] as string[] + private warnMessages = [] as string[] + private koMessages = [] as string[] + private extras: Record = {} + public store: PluginResultStore = {} + + constructor(okMessage: string | undefined) { + if (okMessage) { + this.okMessages.push(okMessage) + } + } + + addExtra(key: string, value: any) { + this.extras[key] = value + return this + } + + addOkMessage(...messages: string[]) { + this.okMessages.push(...messages) + return this + } + + addKoMessage(...messages: string[]) { + this.koMessages.push(...messages) + return this + } + + addWarnMessage(...messages: string[]) { + this.warnMessages.push(...messages) + return this + } + + setOkMessage(...messages: string[]) { + this.okMessages = messages + return this + } + + setKoMessage(...messages: string[]) { + this.koMessages = messages + return this + } + + setWarnMessage(...messages: string[]) { + this.warnMessages = messages + return this + } + + setToStore(key: string, value: PluginResultStoreValue) { + this.store[key] = value + } + + deleteFromStore(key: string) { + delete this.store[key] + } + + returnUnexpectedError(error: unknown): PluginResult { + this.addKoMessage('UnexpectedError') + this.addExtra('error', error) + return this.getResultObject() + } + + getResultObject(): PluginResult { + const result: PluginResult = { + status: { + result: 'OK', + message: this.okMessages.join('\n') || undefined, + }, + ...this.extras, + } + + if (this.koMessages.length) { + result.status = { + result: 'KO', + message: this.koMessages.join('\n'), + } + } else if (this.warnMessages.length) { + result.status = { + result: 'WARNING', + message: this.warnMessages.join('\n'), + } + } + + if (Object.keys(this.store).length) { + result.store = this.store + } + + return result + } +} diff --git a/.jjconflict-side-0/packages/hooks/src/utils/utils.spec.ts b/.jjconflict-side-0/packages/hooks/src/utils/utils.spec.ts new file mode 100644 index 0000000000..a463bc5fb2 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/utils/utils.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { defaultOrNullish, disabledOrDefaultOrNullish, enabledOrDefaultOrNullish, objectEntries, objectKeys, objectValues, specificallyDisabled, specificallyEnabled } from './utils.ts' + +const object = { test1: 1, test2: 2, 3: 'test3' } + +it('should return object keys', () => { + const keys = objectKeys(object) + + // cannot gaurantee order in keys + expect(keys[0]).contain('3') + expect(keys[1]).contain('test1') + expect(keys[2]).contain('test2') +}) + +it('should return object entries', () => { + const keys = objectEntries(object) + + expect(keys[0]).toEqual(['3', 'test3']) + expect(keys[1]).toEqual(['test1', 1]) + expect(keys[2]).toEqual(['test2', 2]) +}) + +it('should return object values', () => { + const keys = objectValues(object) + + // cannot gaurantee order in values + expect(keys).contain('test3') + expect(keys).contain(1) + expect(keys).contain(2) +}) + +const values = [ + '', + 'nimp', + 'enabled', + 'default', + 'disabled', +] as const +describe('test config parsing', () => { + it('enabledOrDefaultOrNullish', () => { + expect(enabledOrDefaultOrNullish(values[0])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[1])).toBeFalsy() + expect(enabledOrDefaultOrNullish(values[2])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[3])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[4])).toBeFalsy() + }) + it('specificallyDisabled', () => { + expect(specificallyDisabled(values[0])).toBeFalsy() + expect(specificallyDisabled(values[1])).toBeFalsy() + expect(specificallyDisabled(values[2])).toBeFalsy() + expect(specificallyDisabled(values[3])).toBeFalsy() + expect(specificallyDisabled(values[4])).toBeTruthy() + }) + it('specificallyEnabled', () => { + expect(specificallyEnabled(values[0])).toBeFalsy() + expect(specificallyEnabled(values[1])).toBeFalsy() + expect(specificallyEnabled(values[2])).toBeTruthy() + expect(specificallyEnabled(values[3])).toBeFalsy() + expect(specificallyEnabled(values[4])).toBeFalsy() + }) + it('defaultOrNullish', () => { + expect(defaultOrNullish(values[0])).toBeTruthy() + expect(defaultOrNullish(values[1])).toBeFalsy() + expect(defaultOrNullish(values[2])).toBeFalsy() + expect(defaultOrNullish(values[3])).toBeTruthy() + expect(defaultOrNullish(values[4])).toBeFalsy() + }) + it('disabledOrDefault', () => { + expect(disabledOrDefaultOrNullish(values[0])).toBeTruthy() + expect(disabledOrDefaultOrNullish(values[1])).toBeFalsy() + expect(disabledOrDefaultOrNullish(values[2])).toBeFalsy() + expect(disabledOrDefaultOrNullish(values[3])).toBeTruthy() + expect(disabledOrDefaultOrNullish(values[4])).toBeTruthy() + }) +}) diff --git a/.jjconflict-side-0/packages/hooks/src/utils/utils.ts b/.jjconflict-side-0/packages/hooks/src/utils/utils.ts new file mode 100644 index 0000000000..65d03954c2 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/src/utils/utils.ts @@ -0,0 +1,88 @@ +import type { ServiceInfos } from '../services.js' +import { DEFAULT, DISABLED, ENABLED } from '@cpn-console/shared' + +export class PluginApi { } + +export function objectEntries>(obj: Obj): ([keyof Obj, Obj[keyof Obj]])[] { + return Object.entries(obj) as ([keyof Obj, Obj[keyof Obj]])[] +} +export function objectKeys>(obj: Obj): (keyof Obj)[] { + return Object.keys(obj) as (keyof Obj)[] +} +export function objectValues>(obj: Obj): (Obj[keyof Obj])[] { + return Object.values(obj) as (Obj[keyof Obj])[] +} + +type ConfigScope = keyof Required['config'] + +export type DeclareModuleGenerator, 'config'>, Scope extends ConfigScope> = { + [K in Infos['name']]?: { + [P in Infos['config'][Scope][number]['key']]?: string + } +} + +export const enabledOrDefaultOrNullish = (value?: string): boolean | undefined => value ? [ENABLED, DEFAULT].includes(value) : true +export const disabledOrDefaultOrNullish = (value?: string): boolean | undefined => value ? [DISABLED, DEFAULT].includes(value) : true +export const specificallyDisabled = (value?: string): boolean | undefined => value ? value === DISABLED : undefined +export const specificallyEnabled = (value?: string): boolean | undefined => value ? value === ENABLED : undefined +export const defaultOrNullish = (value?: string): boolean | undefined => value ? DEFAULT === value : true + +export const okStatus = { status: { result: 'OK' } } as const + +/** + * Take a list of list of kubernetes resources and ditch duplicates by name + * + * @remarks Kind is ignored. You can pass as many arguments as you want + * + * @example + * ```ts + * const foo = [{ metadata: { name: 'One' }}, { metadata: { name: 'Two' }}] + * const bar = [{ metadata: { name: 'One' }}, { metadata: { name: 'Three' }}] + * const xyz = [{ metadata: { name: 'Four' }}, { metadata: { name: 'Two' }}] + * uniqueResource(foo, bar, xyz) + * // [ + * // { metadata: { name: 'One' }}, + * // { metadata: { name: 'Two' }}, + * // { metadata: { name: 'Three' }}, + * // { metadata: { name: 'Four' }} + * // ] + * ``` + * + */ +export function uniqueResource(...lists: T[][]): T[] { + return lists + .flat() + .reduce((acc, cur) => (acc.some(item => item.metadata?.name === cur.metadata?.name) + ? acc + : [...acc, cur] + ), [] as T[]) +} + +export interface BaseResources { + kind?: string + apiVersion?: string + metadata: { + name?: string + namespace?: string + labels: { + [x: string]: string + } + [x: string]: any + } + [x: string]: any +} + +export interface BareMinimumResource { + metadata: { + name: string + labels: { + [x: string]: string + } + } +} + +export interface ListMinimumResources { + body: { + items: BareMinimumResource[] + } +} diff --git a/.jjconflict-side-0/packages/hooks/tsconfig.eslint.json b/.jjconflict-side-0/packages/hooks/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-0/packages/hooks/tsconfig.json b/.jjconflict-side-0/packages/hooks/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-0/packages/hooks/vitest.config.ts b/.jjconflict-side-0/packages/hooks/vitest.config.ts new file mode 100644 index 0000000000..58225abfa0 --- /dev/null +++ b/.jjconflict-side-0/packages/hooks/vitest.config.ts @@ -0,0 +1,16 @@ +import { configDefaults, defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: ['**/types', '**/mocks', '**/*.spec.ts', '**/*.d.ts'], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude], + }, +}) diff --git a/.jjconflict-side-0/packages/logger/eslint.config.js b/.jjconflict-side-0/packages/logger/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-0/packages/logger/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-0/packages/logger/package.json b/.jjconflict-side-0/packages/logger/package.json new file mode 100644 index 0000000000..2b14869190 --- /dev/null +++ b/.jjconflict-side-0/packages/logger/package.json @@ -0,0 +1,53 @@ +{ + "name": "@cpn-console/logger", + "type": "module", + "version": "1.0.1", + "private": false, + "description": "Cloud PI Native internal logger for legacy code", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "browser": "./dist/browser.js", + "default": "./dist/index.js" + }, + "./browser": { + "types": "./types/browser.d.ts", + "default": "./dist/browser.js" + }, + "./hooks": { + "types": "./types/hooks.d.ts", + "default": "./dist/hooks.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "pino": "catalog:tools", + "pino-pretty": "catalog:tools", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "rimraf": "catalog:tools", + "typescript": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/packages/logger/src/browser.ts b/.jjconflict-side-0/packages/logger/src/browser.ts new file mode 100644 index 0000000000..f1ed417404 --- /dev/null +++ b/.jjconflict-side-0/packages/logger/src/browser.ts @@ -0,0 +1,16 @@ +import { pino, stdSerializers } from 'pino' + +export const logger = pino({ + customLevels: { + audit: 25, + }, + level: 'info', + browser: { + asObject: true, + }, + serializers: { + err: stdSerializers.errWithCause, + }, +}) + +export type Logger = typeof logger diff --git a/.jjconflict-side-0/packages/logger/src/hooks.ts b/.jjconflict-side-0/packages/logger/src/hooks.ts new file mode 100644 index 0000000000..2ee8ad0f1a --- /dev/null +++ b/.jjconflict-side-0/packages/logger/src/hooks.ts @@ -0,0 +1,22 @@ +import type { Logger } from './index.js' +import { logger as baseLogger } from './index.js' + +export const logger: Logger = baseLogger.child( + { scope: 'hooks' }, + { + redact: { + paths: [ + 'err.config.auth.username', + 'err.config.auth.password', + 'err.config.headers', + 'err.config.data', + 'err.config.params.client_secret', + 'err.config.params.clientSecret', + 'err.request.headers', + 'err.response.data', + 'err.response.headers', + ], + remove: true, + }, + }, +) diff --git a/.jjconflict-side-0/packages/logger/src/index.ts b/.jjconflict-side-0/packages/logger/src/index.ts new file mode 100644 index 0000000000..cf2a6e2397 --- /dev/null +++ b/.jjconflict-side-0/packages/logger/src/index.ts @@ -0,0 +1,87 @@ +import type { LoggerOptions } from 'pino' +import { pino, stdSerializers } from 'pino' +import z from 'zod' + +const envSchema = z.object({ + LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error', 'audit']).default('debug'), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), +}) + +export type Env = z.infer['NODE_ENV'] +export type LogLevel = z.infer['LOG_LEVEL'] +const customLevels = { + audit: 25, +} + +const redact: LoggerOptions['redact'] = { + paths: [ + 'password', + '*.password', + 'passwd', + '*.passwd', + 'secret', + '*.secret', + 'token', + '*.token', + 'access_token', + '*.access_token', + 'refresh_token', + '*.refresh_token', + 'client_secret', + '*.client_secret', + 'authorization', + '*.authorization', + 'headers.authorization', + 'headers.cookie', + 'req.headers.authorization', + 'req.headers.cookie', + 'request.headers.authorization', + 'request.headers.cookie', + ], + remove: true, +} + +export function getLoggerOptions(env: Env, level: LogLevel): LoggerOptions { + switch (env) { + case 'development': + return { + transport: { + target: 'pino-pretty', + options: { + translateTime: 'dd/mm/yyyy - HH:MM:ss Z', + ignore: 'pid,hostname', + colorize: true, + singleLine: true, + }, + }, + customLevels, + level, + redact, + serializers: { + err: stdSerializers.errWithCause, + }, + } + case 'production': + return { + customLevels, + level, + redact, + serializers: { + err: stdSerializers.errWithCause, + }, + } + default: + return { + level: 'silent', + } + } +} + +export function getLoggerOptionsFromEnv(): LoggerOptions { + const env = envSchema.parse(process.env) + return getLoggerOptions(env.NODE_ENV, env.LOG_LEVEL) +} + +export const logger = pino(getLoggerOptionsFromEnv()) + +export type Logger = typeof logger diff --git a/.jjconflict-side-0/packages/logger/tsconfig.eslint.json b/.jjconflict-side-0/packages/logger/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-0/packages/logger/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-0/packages/logger/tsconfig.json b/.jjconflict-side-0/packages/logger/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-0/packages/logger/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-0/packages/opencds/Dockerfile b/.jjconflict-side-0/packages/opencds/Dockerfile new file mode 100644 index 0000000000..734496c4fb --- /dev/null +++ b/.jjconflict-side-0/packages/opencds/Dockerfile @@ -0,0 +1,21 @@ +FROM node:24-alpine AS prod + +RUN npm install -g @mockoon/cli@9.3.0 + +# Install curl for healthcheck and tzdata for timezone support. +RUN apk --no-cache add curl tzdata + +# Do not run as root. +RUN adduser --shell /bin/sh --disabled-password --gecos "" mockoon + +COPY packages/opencds/opencds_api.json ./opencds_api.json +RUN chown -R mockoon ./opencds_api.json + +USER mockoon + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3100/api/v1/health || exit 1 + +EXPOSE 3100 + +ENTRYPOINT ["mockoon-cli","start","--disable-log-to-file","--data","./opencds_api.json","--port","3100"] diff --git a/.jjconflict-side-0/packages/opencds/README.adoc b/.jjconflict-side-0/packages/opencds/README.adoc new file mode 100644 index 0000000000..bc9a736541 --- /dev/null +++ b/.jjconflict-side-0/packages/opencds/README.adoc @@ -0,0 +1,58 @@ += À propos + +Ce document va servir de fil conducteur pour toutes les idées autour de l'implémentation de la Console OpenCDS. Il permettra à l'équipe Socle d'échanger (au travers, par exemple, des commentaires de MR) sur les différentes problématiques entourant les travaux. C'est un document vivant, dynamique, qui sera remplacé par un "vrai" document d'architecture par la suite. + +== Cadrage effectué + +- SSO à prévoir avec Keycloak (déjà intégré dans la Console, d'où l'intérêt de l'utiliser pour intégrer le front OpenCDS) +- Rôles à prédéfinir dans *Keycloak* : + - `admin` (a accès à toute l'application) + - `cds` (n'a accès qu'à la partie qui concerne ses projets) + +Flux GUI définis : +- Pour tous les utilisateurs : + - Login/Logout à travers le SSO Keycloak comme la Console +- Utilisateurs `admin`: + - Liste de toutes les CDS + - Relancer une CDS en particulier + - Afficher les "flows" (étapes) d'un CDS +- Utilisateurs `cds`: + - Pouvoir lister les CDS de leur équipe + - Afficher les "flows" (étapes) d'une CDS qu'ils ont le droit de voir + - Avoir un écran de confirmation (on parlera plutôt de "validation" étant donné que la route côté OpenCDS s'appelle `/validate`) de création d'une CDS. Actuellement un mail leur est envoyé qui contient un lien pour validation. Ce lien valide la création de la CDS. Il faudra donc crééer un point d'entrée dans l'application pour plutôt ouvrir un écran de confirmation de la création. + +Lotissement des évolutions: +- [x] Création en tant que fonctionnalité "feature togglée" de Console (on verra plus tard si on en fait quelque chose à part) +- [x] Admin - Liste des CDS +- [x] Admin - Liste des Flow +- [x] Admin - Relancer un CDS en statut "échoué" (`/retry`) +- [ ] CDS - Liste des CDS/Flow de leur périmètre +- [ ] CDS - Écran de confirmation de la création d'un CDS (`/validate`) + += Architecture + +L'architecture retenue est celle consistant à utiliser le serveur ("Back for Front") de la console comme d'un Proxy pour OpenCDS. Ceci permet de réutiliser toute la mécanique d'authentification en SSO par Keycloak, la sérialisation/désérialisation entre `client` et `server-nestjs` (le Frontend et son BFF), ainsi que la génération des types TypeScript (à travers l'utilisation de schémas et de contrats `zod`). De cette manière, l'implémentation des ressources OpenCDS (`service-chains` et `flows`) peut se faire de la même manière que les autres ressources existantes (`cluster`, `projet`, etc.), avec le même niveau de qualité (tests unitaires avec vitest, et E2E avec Playwright). + += Contraintes + +- Console étant un projet OpenSource, et OpenCDS étant un sujet exclusivement "MI", le couplage entre les deux doit être le plus faible possible. Idéalement OpenCDS devrait être une "extension" de la Console de manière à ne jamais être directement référencé dans l'application. +=> Dans un premier temps on va fonctionner avec un "Feature Toggling" rudimentaire (à l'aide de variables d'environnement), pour voir où on va, et ensuite on verra comment éventuellement établir une séparation claire entre la console et sa partie OpenCDS, par exemple dans un projet Git totalement à part. + += Jeu de données + +Afin de pouvoir avancer sans forcément dépendre du backend OpenCDS, un https://mockoon.com/[Mockoon] sera utilisé (./opencds_api.json) pour simuler cette partie. Lors de la convergence entre les développements de la console et ceux côté OpenCDS, une version d'"intégration" de celle-ci sera déployée sur nos environnements PAX (`cpin-hp`, `formation`, et `pax-prod`) afin d'effectuer une recette dédiée. + += Mise en place + +Utilisation de varables d'environnements qui vont nous servir de moyen d'activer la fonctionnalité: + - Côté `apps/client`: + - `OPENCDS_ENABLED`: active ou non la fonctionnalité côté Front (cette activation est indépendante de `apps/server-nestjs`, donc il faut que cette variable d'environnement soit activée pour que la console affiche les écrans concernés par OpenCDS) + - Côté `apps/server-nestjs`: + - `OPENCDS_URL`: Chemin complet (URL + path) du service OpenCDS distant + - `OPENCDS_API_TOKEN`: Définit le token d'authentification au service OpenCDS qui sera utilisé pour chaque requête effectuée + - `OPENCDS_API_TLS_REJECT_UNAUTHORIZED`: Indique si on accepte les certificats non-validés (par exemple les certificats auto-signés, ou signés par une Autorité de Certification inconnue). Cette fonctionnalié permet d'intéragir facilement avec des services OpenCDS de "test" + += Notes + +- Sachant qu'il n'est pas trivial de lancer des services docker-compose de manière conditionnelle, le service `opencds-mockoon` est systématiquement lancé dans les docker-compose concernés (développement local, tests en CI, etc.) +- Les documentations de link:../../apps/client/README.md[la console] et link:../../apps/server-nestjs/README.md[son backend] ont été amendées pour référencer cette documentation, de manière à centraliser ici tous les aspects concernant OpenCDS. diff --git a/.jjconflict-side-0/packages/opencds/opencds_api.json b/.jjconflict-side-0/packages/opencds/opencds_api.json new file mode 100644 index 0000000000..6c92672f03 --- /dev/null +++ b/.jjconflict-side-0/packages/opencds/opencds_api.json @@ -0,0 +1,546 @@ +{ + "uuid": "b263d1c1-3103-4a17-940c-1c92215bb3b8", + "lastMigration": 33, + "name": "Opencds api", + "endpointPrefix": "api/v1/", + "latency": 0, + "port": 3003, + "hostname": "", + "folders": [], + "routes": [ + { + "uuid": "210c5fa6-22dc-4c79-a89e-4289fd2b84bb", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests", + "responses": [ + { + "uuid": "28bd3efb-d903-44c3-b728-66db3af526bc", + "body": "{{setVar 'networks' (array 'RIE' 'INTERNET')}}\n{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n[\n {{#repeat 50 100 comma=true}}\n {{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n {{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n {\n \"id\": \"{{faker 'string.uuid'}}\",\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"commonName\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\",\n \"pai\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"upper\\\" }'}}\",\n \"network\": \"{{oneOf (getVar 'networks')}}\",\n \"createdAt\": \"{{getVar 'createdAt'}}\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n }\n {{/repeat}}\n]", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "0d39381e-e815-40a9-9d47-cc739a8b582c", + "body": "", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "8f00d8d7-7df4-4401-b751-6440ee0e1658", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests/:request_id", + "responses": [ + { + "uuid": "410bd286-4377-47e5-ae3f-e4131fcb0b68", + "body": "{{setVar 'networks' (array 'RIE' 'INTERNET')}}\n{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n{{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'location' (array 'SIR' 'SIL')}}\n{{setVar 'environment' (array 'INT' 'PROD' 'BAS')}}\n{\n \"id\": \"{{urlParam 'request_id'}}\",\n \n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"commonName\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\",\n \"pai\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"upper\\\" }'}}\",\n \"network\": \"{{oneOf (getVar 'networks')}}\",\n \"createdAt\": \"{{getVar 'createdAt'}}\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\",\n\n \"validationId\": \"{{faker 'string.uuid'}}\",\n \"validatedBy\": \"{{faker 'string.uuid'}}\",\n \"ref\": \"{{faker 'string.uuid'}}\",\n \"location\": \"{{oneOf (getVar 'location')}}\",\n \"targetAddress\": \"{{faker 'internet.ipv4'}}\",\n \"projectId\": \"{{faker 'string.uuid'}}\",\n \"env\": \"{{oneOf (getVar 'environment')}}\",\n \"subjectAlternativeName\": [{{#repeat 0 50 comma=true}}\"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\"{{/repeat}}],\n \"redirect\": {{boolean}},\n \"antivirus\": {{#if (eq (boolean) true)}}{\n \"maxFileSize\": {{int 1 100}}\n }{{else}}null{{/if}},\n \"websocket\": {{boolean}},\n \"ipWhiteList\": [{{#repeat 0 10 comma=true}}\"{{faker 'internet.ipv4'}}/{{int 8 32}}\"{{/repeat}}],\n \"sslOutgoing\": {{boolean}}\n}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "41f0a982-3b94-44d7-8177-e120d4741dee", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "904ddbb0-480b-442d-b251-d008d10fa832", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "8ff2f39f-a82f-4364-90ed-135b5ec8f19b", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests/:request_id/flows", + "responses": [ + { + "uuid": "b24f3f54-b45c-4749-a134-dc1fa003a417", + "body": "{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n{{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'json' '' }}\n{\n \"reserve_ip\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"create_cert\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"call_exec\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"activate_ip\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"dns_request\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "e392c78f-a345-4b96-bf0d-74c8094c4097", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "c926d20b-b30f-4293-b583-3eb169943967", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "9cc0eae4-eef1-44ec-92cd-47819501695f", + "type": "http", + "documentation": "", + "method": "post", + "endpoint": "validate/:validation_id", + "responses": [ + { + "uuid": "69bc6592-1208-40a9-a3c0-f8b445fc9f4c", + "body": "", + "latency": 0, + "statusCode": 204, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "179726cf-4c32-4d07-aabf-ead6d4646e17", + "body": "", + "latency": 0, + "statusCode": 429, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "fce2e6b6-0894-4f05-873d-77d8965c4c67", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "79f5ce5f-bf70-4995-bad4-6e82fc72f55f", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "bb61c12f-8185-43fe-9498-22b6661e998c", + "type": "http", + "documentation": "", + "method": "post", + "endpoint": "requests/:request_id/retry", + "responses": [ + { + "uuid": "b703c7f4-7325-448a-ab57-5b122896c3cc", + "body": "", + "latency": 0, + "statusCode": 204, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "e6abdf74-3191-4140-b6ba-3922c52e4c15", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "7b5d5713-0b5c-49d3-b3a2-af6cd366654d", + "body": "", + "latency": 0, + "statusCode": 409, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "5d168446-71d6-456b-9de8-6b6100bd2f8a", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "1055fe07-2f29-4dec-8412-2066fb330b3c", + "type": "http", + "documentation": "OpenCDS Mockoon healthcheck", + "method": "get", + "endpoint": "health", + "responses": [ + { + "uuid": "0d7f2acf-8006-4995-a5e5-39d8ba1858ef", + "body": "{\"status\":\"OK\"}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + } + ], + "rootChildren": [ + { + "type": "route", + "uuid": "210c5fa6-22dc-4c79-a89e-4289fd2b84bb" + }, + { + "type": "route", + "uuid": "8f00d8d7-7df4-4401-b751-6440ee0e1658" + }, + { + "type": "route", + "uuid": "8ff2f39f-a82f-4364-90ed-135b5ec8f19b" + }, + { + "type": "route", + "uuid": "9cc0eae4-eef1-44ec-92cd-47819501695f" + }, + { + "type": "route", + "uuid": "bb61c12f-8185-43fe-9498-22b6661e998c" + }, + { + "type": "route", + "uuid": "1055fe07-2f29-4dec-8412-2066fb330b3c" + } + ], + "proxyMode": false, + "proxyHost": "", + "proxyRemovePrefix": false, + "tlsOptions": { + "enabled": false, + "type": "CERT", + "pfxPath": "", + "certPath": "", + "keyPath": "", + "caPath": "", + "passphrase": "" + }, + "cors": true, + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "proxyReqHeaders": [ + { + "key": "", + "value": "" + } + ], + "proxyResHeaders": [ + { + "key": "", + "value": "" + } + ], + "data": [], + "callbacks": [] +} \ No newline at end of file diff --git a/.jjconflict-side-0/packages/shared/.gitignore b/.jjconflict-side-0/packages/shared/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-0/packages/shared/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-0/packages/shared/eslint.config.js b/.jjconflict-side-0/packages/shared/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-0/packages/shared/package.json b/.jjconflict-side-0/packages/shared/package.json new file mode 100644 index 0000000000..5d3700491b --- /dev/null +++ b/.jjconflict-side-0/packages/shared/package.json @@ -0,0 +1,58 @@ +{ + "name": "@cpn-console/shared", + "type": "module", + "version": "1.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "test:e2e-ci": "echo 'check cache'", + "test:watch": "vitest" + }, + "dependencies": { + "@cpn-console/logger": "workspace:*", + "@ts-rest/core": "catalog:runtime", + "short-uuid": "catalog:runtime", + "zod": "catalog:runtime", + "zod-validation-error": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/packages/shared/src/api-client.ts b/.jjconflict-side-0/packages/shared/src/api-client.ts new file mode 100644 index 0000000000..c842cb9e99 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/api-client.ts @@ -0,0 +1,50 @@ +import type { ApiFetcher } from '@ts-rest/core' +import { initClient, initContract } from '@ts-rest/core' + +export const apiPrefix: string = '/api/v1' +export const apiPrefixV2: string = '/api/v2' + +export const contractInstance: ReturnType = initContract() + +export async function getContract() { + return contractInstance.router({ + AdminTokens: (await import('./contracts/index.js')).adminTokenContract, + AdminRoles: (await import('./contracts/index.js')).adminRoleContract, + Clusters: (await import('./contracts/index.js')).clusterContract, + ServiceChains: (await import('./contracts/index.js')).serviceChainContract, + Deployments: (await import('./contracts/index.js')).deploymentContract, + Environments: (await import('./contracts/index.js')).environmentContract, + EnvironmentsV2: (await import('./contracts/index.js')).environmentContractV2, + Logs: (await import('./contracts/index.js')).logContract, + PersonalAccessTokens: (await import('./contracts/index.js')) + .personalAccessTokenContract, + Projects: (await import('./contracts/index.js')).projectContract, + ProjectsMembers: (await import('./contracts/index.js')) + .projectMemberContract, + ProjectsRoles: (await import('./contracts/index.js')).projectRoleContract, + ProjectServices: (await import('./contracts/index.js')) + .projectServiceContract, + Repositories: (await import('./contracts/index.js')).repositoryContract, + Stages: (await import('./contracts/index.js')).stageContract, + Services: (await import('./contracts/index.js')).serviceContract, + Users: (await import('./contracts/index.js')).userContract, + Zones: (await import('./contracts/index.js')).zoneContract, + System: (await import('./contracts/index.js')).systemContract, + SystemPlugin: (await import('./contracts/index.js')).systemPluginContract, + SystemSettings: (await import('./contracts/index.js')) + .systemSettingsContract, + }) +} + +export async function getApiClient( + baseUrl: string, + baseHeaders: Record, + api: ApiFetcher | undefined, +): Promise { + return initClient(await getContract(), { + baseUrl, + baseHeaders, + api, + validateResponse: false, + }) +} diff --git a/.jjconflict-side-0/packages/shared/src/contracts/_utils.ts b/.jjconflict-side-0/packages/shared/src/contracts/_utils.ts new file mode 100644 index 0000000000..ea2f4ee5ff --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/_utils.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' +import { tokenHeaderName } from '../utils/const.js' + +export const EmptySchema = z.object({}) + +export const ErrorSchema = z.lazy(() => z.object({ + message: z.string() + .optional(), + error: z.unknown().optional(), + stack: z.unknown().optional(), +})) + +export const baseHeaders = z.object({ + [tokenHeaderName]: z.string().min(1).optional(), +}) diff --git a/.jjconflict-side-0/packages/shared/src/contracts/admin-role.ts b/.jjconflict-side-0/packages/shared/src/contracts/admin-role.ts new file mode 100644 index 0000000000..dc6696bee3 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/admin-role.ts @@ -0,0 +1,76 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { AdminRoleSchema } from '../schemas/role.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminRoleContract = contractInstance.router({ + listAdminRoles: { + method: 'GET', + path: '', + responses: { + 200: AdminRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createAdminRole: { + method: 'POST', + path: '', + body: AdminRoleSchema.pick({ name: true }), + responses: { + 200: AdminRoleSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + patchAdminRoles: { + method: 'PATCH', + path: '', + body: AdminRoleSchema.partial({ name: true, permissions: true, position: true, oidcGroup: true, type: true }).array(), + responses: { + 200: AdminRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + adminRoleMemberCounts: { + method: 'GET', + path: `/member-counts`, + responses: { + 200: z.record(z.number().min(0)), // Record + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deleteAdminRole: { + method: 'DELETE', + path: `/:roleId`, + pathParams: z.object({ + roleId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/admin/roles`, +}) diff --git a/.jjconflict-side-0/packages/shared/src/contracts/admin-token.ts b/.jjconflict-side-0/packages/shared/src/contracts/admin-token.ts new file mode 100644 index 0000000000..8df06a7db4 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/admin-token.ts @@ -0,0 +1,54 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { AdminTokenSchema, ExposedAdminTokenSchema } from '../schemas/token.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminTokenContract = contractInstance.router({ + listAdminTokens: { + method: 'GET', + path: '', + query: z.object({ + withRevoked: CoerceBooleanSchema + .optional(), + }), + responses: { + 200: AdminTokenSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createAdminToken: { + method: 'POST', + path: '', + body: AdminTokenSchema.pick({ name: true, permissions: true, expirationDate: true }).required(), + responses: { + 201: ExposedAdminTokenSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deleteAdminToken: { + method: 'DELETE', + path: `/:tokenId`, + pathParams: z.object({ tokenId: z.string().uuid() }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/admin/tokens`, +}) diff --git a/.jjconflict-side-0/packages/shared/src/contracts/cluster.ts b/.jjconflict-side-0/packages/shared/src/contracts/cluster.ts new file mode 100644 index 0000000000..96ada83d77 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/cluster.ts @@ -0,0 +1,138 @@ +import type { ClientInferResponseBody } from '@ts-rest/core' +import type Zod from 'zod' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { + CleanedClusterSchema, + ClusterDetailsSchema, + ClusterUsageSchema, +} from '../schemas/cluster.js' +import { EnvironmentSchema } from '../schemas/environment.js' +import { UserSchema } from '../schemas/user.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const ClusterParams = z.object({ + clusterId: CleanedClusterSchema.shape.id, +}) + +export const clusterContract = contractInstance.router({ + listClusters: { + method: 'GET', + path: '', + summary: 'Get clusters', + description: 'Retrieve clusters authorized for user', + responses: { + 200: z.array(CleanedClusterSchema), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + createCluster: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create cluster', + description: 'Create new cluster.', + body: ClusterDetailsSchema.omit({ id: true }), + responses: { + 201: ClusterDetailsSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterDetails: { + method: 'GET', + path: `/:clusterId`, + summary: 'Get cluster details', + description: 'Retrieved details of a cluster.', + pathParams: ClusterParams, + responses: { + 200: ClusterDetailsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterUsage: { + method: 'GET', + path: `/usage/:clusterId`, + summary: 'Get cluster usage', + description: 'Retrieved resources usage of a cluster.', + pathParams: ClusterParams, + responses: { + 200: ClusterUsageSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterEnvironments: { + method: 'GET', + path: `/:clusterId/environments`, + summary: 'Get cluster envs', + description: 'Retrieved environments linked to a cluster.', + pathParams: ClusterParams, + responses: { + 200: z.array(z.object({ + // TODO: Remettre `ProjectSchemaV2.shape.name` mais attention aux projets non compatibles + project: z.string(), + name: EnvironmentSchema.shape.name, + owner: UserSchema.shape.email.optional(), + cpu: EnvironmentSchema.shape.cpu, + gpu: EnvironmentSchema.shape.gpu, + memory: EnvironmentSchema.shape.memory, + })), + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateCluster: { + method: 'PUT', + path: `/:clusterId`, + summary: 'Update cluster', + description: 'Update a cluster by its ID.', + pathParams: ClusterParams, + body: ClusterDetailsSchema.omit({ id: true }).partial(), + responses: { + 200: ClusterDetailsSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteCluster: { + method: 'DELETE', + path: `/:clusterId`, + summary: 'Delete cluster', + description: 'Delete a cluster by its ID.', + query: z.object({ force: CoerceBooleanSchema.optional() }), + pathParams: ClusterParams, + body: ContractNoBody, + responses: { + 204: z.string() + .nullable(), + 400: ErrorSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/clusters`, +}) + +export type ClusterAssociatedEnvironments = ClientInferResponseBody +export type CreateClusterBody = Zod.infer +export type UpdateClusterBody = Zod.infer diff --git a/.jjconflict-side-0/packages/shared/src/contracts/deployment.ts b/.jjconflict-side-0/packages/shared/src/contracts/deployment.ts new file mode 100644 index 0000000000..968e65e940 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/deployment.ts @@ -0,0 +1,91 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + CreateDeploymentSchema, + DeploymentSchema, + UpdateDeploymentSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const deploymentContract = contractInstance.router({ + createDeployment: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create deployment', + description: 'Create new deployment.', + body: CreateDeploymentSchema, + responses: { + 201: DeploymentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listDeployments: { + method: 'GET', + path: '', + summary: 'Get deployments', + description: 'Retrieved project deployments.', + responses: { + 200: DeploymentSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateDeployment: { + method: 'PUT', + path: `/:deploymentId`, + summary: 'Update deployment', + description: 'Update a deployment by its ID.', + pathParams: z.object({ + deploymentId: z.string() + .uuid(), + }), + body: UpdateDeploymentSchema, + responses: { + 200: DeploymentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteDeployment: { + method: 'DELETE', + path: `/:deploymentId`, + summary: 'Delete deployment', + description: 'Delete a deployment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + deploymentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/deployments`, + pathParams: z.object({ + projectId: z.string().uuid(), + }), +}) + +export type CreateDeploymentBody = ClientInferRequest['body'] +export type UpdateDeploymentBody = ClientInferRequest['body'] diff --git a/.jjconflict-side-0/packages/shared/src/contracts/environment.ts b/.jjconflict-side-0/packages/shared/src/contracts/environment.ts new file mode 100644 index 0000000000..8d9320733e --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/environment.ts @@ -0,0 +1,175 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, apiPrefixV2, contractInstance } from '../api-client.js' +import { + CreateEnvironmentSchema, + EnvironmentSchema, + UpdateEnvironmentSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const environmentContract = contractInstance.router({ + createEnvironment: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create environment', + description: 'Create new environment.', + body: EnvironmentSchema.omit({ id: true, createdAt: true, updatedAt: true }), + responses: { + 201: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listEnvironments: { + method: 'GET', + path: '', + summary: 'Get environments', + description: 'Retrieved project environments.', + query: z.object({ + projectId: z.string() + .uuid(), + }), + responses: { + 200: EnvironmentSchema.omit({ name: true }) + .extend({ name: z.string() }).array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateEnvironment: { + method: 'PUT', + path: `/:environmentId`, + summary: 'Update environment', + description: 'Update a environment by its ID.', + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + body: EnvironmentSchema.pick({ cpu: true, gpu: true, memory: true, autosync: true }), + responses: { + 200: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteEnvironment: { + method: 'DELETE', + path: `/:environmentId`, + summary: 'Delete environment', + description: 'Delete a environment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/environments`, +}) + +export type CreateEnvironmentBody = ClientInferRequest['body'] +export type UpdateEnvironmentBody = ClientInferRequest['body'] + +// NB: les clés de routes servent d'operationId OpenAPI (setOperationId: true) et +// doivent être uniques sur l'ensemble du contrat, d'où le suffixe V2. +export const environmentContractV2 = contractInstance.router({ + createEnvironmentV2: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create environment', + description: 'Create new environment.', + body: CreateEnvironmentSchema, + responses: { + 201: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listEnvironmentsV2: { + method: 'GET', + path: '', + summary: 'Get environments', + description: 'Retrieved project environments.', + responses: { + 200: EnvironmentSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateEnvironmentV2: { + method: 'PUT', + path: '/:environmentId', + summary: 'Update environment', + description: 'Update an environment by its ID.', + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + body: UpdateEnvironmentSchema, + responses: { + 200: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteEnvironmentV2: { + method: 'DELETE', + path: '/:environmentId', + summary: 'Delete environment', + description: 'Delete an environment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefixV2}/projects/:projectId/environments`, + pathParams: z.object({ + projectId: z.string().uuid(), + }), +}) diff --git a/.jjconflict-side-0/packages/shared/src/contracts/index.ts b/.jjconflict-side-0/packages/shared/src/contracts/index.ts new file mode 100644 index 0000000000..c5c88fab16 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/index.ts @@ -0,0 +1,18 @@ +export * from './admin-role.js' +export * from './admin-token.js' +export * from './cluster.js' +export * from './deployment.js' +export * from './environment.js' +export * from './log.js' +export * from './personal-access-token.js' +export * from './project-member.js' +export * from './project-role.js' +export * from './project-service.js' +export * from './project.js' +export * from './repository.js' +export * from './service-chain.js' +export * from './service-monitor.js' +export * from './stage.js' +export * from './system.js' +export * from './user.js' +export * from './zone.js' diff --git a/.jjconflict-side-0/packages/shared/src/contracts/log.ts b/.jjconflict-side-0/packages/shared/src/contracts/log.ts new file mode 100644 index 0000000000..062f2b3adc --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/log.ts @@ -0,0 +1,43 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import type Zod from 'zod' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { CleanLogSchema, LogSchema } from '../schemas/log.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminLogsQuery = z.object({ + offset: z.coerce.number(), + limit: z.coerce.number(), + projectId: z.string().optional(), + clean: CoerceBooleanSchema.default(true), +}) +export type AdminLogsQuery = Zod.infer + +export const logContract = contractInstance.router({ + getLogs: { + method: 'GET', + path: '', + query: adminLogsQuery, + summary: 'Get logs', + description: 'Retrieved all logs.', + responses: { + 200: z.object({ + total: z.number(), + logs: LogSchema.array().or(CleanLogSchema.array()), + }), + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/logs`, +}) + +export type GetLogsQuery = ClientInferRequest['query'] + +export type Log = Zod.infer +export type CleanLog = Zod.infer diff --git a/.jjconflict-side-0/packages/shared/src/contracts/personal-access-token.ts b/.jjconflict-side-0/packages/shared/src/contracts/personal-access-token.ts new file mode 100644 index 0000000000..72e9012596 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/personal-access-token.ts @@ -0,0 +1,49 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ExposedPersonalAccessTokenSchema, PersonalAccessTokenSchema } from '../schemas/token.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const personalAccessTokenContract = contractInstance.router({ + listPersonalAccessTokens: { + method: 'GET', + path: '', + responses: { + 200: PersonalAccessTokenSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createPersonalAccessToken: { + method: 'POST', + path: '', + body: PersonalAccessTokenSchema.pick({ name: true, expirationDate: true }).required(), + responses: { + 201: ExposedPersonalAccessTokenSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deletePersonalAccessToken: { + method: 'DELETE', + path: `/:tokenId`, + pathParams: z.object({ tokenId: z.string().uuid() }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/user/tokens`, +}) diff --git a/.jjconflict-side-0/packages/shared/src/contracts/project-member.ts b/.jjconflict-side-0/packages/shared/src/contracts/project-member.ts new file mode 100644 index 0000000000..ac9797495f --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/project-member.ts @@ -0,0 +1,68 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { MemberSchema } from '../schemas/user.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const projectMemberContract = contractInstance.router({ + listMembers: { + method: 'GET', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + addMember: { + method: 'POST', + path: '', + body: z.object({ email: z.string() }).or(z.object({ userId: z.string() })), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 201: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + patchMembers: { + method: 'PATCH', + path: '', + body: z.object({ + userId: z.string().uuid(), + roles: z.string().uuid().array(), + }).array(), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + removeMember: { + method: 'DELETE', + path: `/:userId`, + pathParams: z.object({ + projectId: z.string().uuid(), + userId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/members`, +}) diff --git a/.jjconflict-side-0/packages/shared/src/contracts/project-role.ts b/.jjconflict-side-0/packages/shared/src/contracts/project-role.ts new file mode 100644 index 0000000000..b909a994db --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/project-role.ts @@ -0,0 +1,80 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ProjectRoleSchema } from '../schemas/role.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const projectRoleContract = contractInstance.router({ + listProjectRoles: { + method: 'GET', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + createProjectRole: { + method: 'POST', + path: '', + body: ProjectRoleSchema.omit({ position: true, id: true, projectId: true }), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + // 200: z.any(), + 201: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + patchProjectRoles: { + method: 'PATCH', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + // body: z.any(), + body: ProjectRoleSchema.pick({ id: true }).merge(ProjectRoleSchema.omit({ id: true, projectId: true }).partial()).array(), + responses: { + // 200: z.any(), + 200: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + projectRoleMemberCounts: { + method: 'GET', + path: `/member-counts`, + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: z.record(z.number().min(0)), // Record + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + deleteProjectRole: { + method: 'DELETE', + path: `/:roleId`, + pathParams: z.object({ + projectId: z.string().uuid(), + roleId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/roles`, +}) diff --git a/.jjconflict-side-0/packages/shared/src/contracts/project-service.ts b/.jjconflict-side-0/packages/shared/src/contracts/project-service.ts new file mode 100644 index 0000000000..a0545d06b2 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/project-service.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { pluginUpdateBody } from '../schemas/config.js' +import { permissionTarget, ServiceSchema } from '../schemas/services.js' +import { baseHeaders, ErrorSchema } from './_utils.js' +import { ProjectParams } from './project.js' + +export const projectServiceContract = contractInstance.router({ + getServices: { + method: 'GET', + path: '', + summary: 'Get Project\'s services', + description: 'Get all informations about services related to a project.', + query: z.object({ permissionTarget }), + pathParams: ProjectParams, + responses: { + 200: ServiceSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateProjectServices: { + method: 'POST', + path: '', + summary: 'Update project service configuration', + description: 'Update project service configuration', + pathParams: ProjectParams, + body: pluginUpdateBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/services`, +}) diff --git a/.jjconflict-side-0/packages/shared/src/contracts/project.ts b/.jjconflict-side-0/packages/shared/src/contracts/project.ts new file mode 100644 index 0000000000..8532849b4e --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/project.ts @@ -0,0 +1,184 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ProjectSchemaV2 } from '../schemas/project.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const ProjectParams = z.object({ + projectId: z.string().regex(/[a-z0-9-]*/), // uuid or slug like +}) + +export const projectContract = contractInstance.router({ + createProject: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create project', + description: 'Create a new project.', + body: ProjectSchemaV2.pick({ + name: true, + description: true, + limitless: true, + hprodCpu: true, + hprodGpu: true, + hprodMemory: true, + prodCpu: true, + prodGpu: true, + prodMemory: true, + }), + responses: { + 201: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + bulkActionProject: { + method: 'POST', + path: '-bulk', + contentType: 'application/json', + summary: 'Perform bulk action on projects', + description: 'Perform bulk action on projects.', + body: z.object({ + action: z.enum(['archive', 'lock', 'unlock', 'replay']), + projectIds: z.string().uuid().array().or(z.literal('all')), + }), + responses: { + 202: null, + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getProject: { + method: 'GET', + path: '/:projectId', + pathParams: ProjectParams, + summary: 'Get a project', + description: 'Get a project', + responses: { + 200: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listProjects: { + method: 'GET', + path: '', + query: ProjectSchemaV2 + .pick({ + id: true, + name: true, + status: true, + locked: true, + description: true, + lastSuccessProvisionningVersion: true, + }) + .extend({ + statusIn: z.string(), + statusNotIn: z.string(), + filter: z.enum(['owned', 'member', 'all']), + search: z.string(), + }) + .partial(), + summary: 'Get projects', + description: 'Get projects with filters', + responses: { + 200: ProjectSchemaV2.omit({ + name: true, + }).extend({ name: z.string() }).array(), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getProjectSecrets: { + method: 'GET', + path: `/:projectId/secrets`, + summary: 'Get project secrets', + description: 'Retrieved a project secrets.', + pathParams: ProjectParams, + responses: { + 200: z.record(z.record(z.string())), + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateProject: { + method: 'PUT', + path: `/:projectId`, + summary: 'Update project', + description: 'Update a project.', + pathParams: ProjectParams, + body: ProjectSchemaV2 + .pick({ + description: true, + everyonePerms: true, + locked: true, + ownerId: true, + limitless: true, + hprodMemory: true, + hprodCpu: true, + hprodGpu: true, + prodMemory: true, + prodCpu: true, + prodGpu: true, + }) + .partial(), + responses: { + 200: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 500: ErrorSchema, + }, + }, + + replayHooksForProject: { + method: 'PUT', + path: `/:projectId/hooks`, + summary: 'Replay hooks for project', + description: 'Replay hooks for a project.', + body: ContractNoBody, + pathParams: ProjectParams, + responses: { + 204: null, + 500: ErrorSchema, + }, + }, + + archiveProject: { + method: 'DELETE', + path: `/:projectId`, + summary: 'Delete project', + description: 'Delete a project.', + pathParams: ProjectParams, + body: ContractNoBody, + responses: { + 204: null, + 500: ErrorSchema, + }, + }, + + getProjectsData: { + method: 'GET', + path: `/data`, + summary: 'Download projects csv report', + description: 'Retrieve all projects data for download as CSV file.', + responses: { + 200: z.string(), + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects`, +}) + +export type CreateProjectBody = ClientInferRequest['body'] diff --git a/.jjconflict-side-0/packages/shared/src/contracts/repository.ts b/.jjconflict-side-0/packages/shared/src/contracts/repository.ts new file mode 100644 index 0000000000..131deee00e --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/repository.ts @@ -0,0 +1,107 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { RepoSchema } from '../schemas/repository.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const repositoryContract = contractInstance.router({ + createRepository: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create repo', + description: 'Create new repo.', + body: RepoSchema.omit({ id: true, createdAt: true, updatedAt: true }), + responses: { + 201: RepoSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listRepositories: { + method: 'GET', + path: '', + summary: 'Get repos', + description: 'Retrieved all repos.', + query: z.object({ + projectId: z.string() + .uuid(), + }), + responses: { + 200: z.array(RepoSchema + .omit({ internalRepoName: true }) + .extend({ internalRepoName: z.string() }), + ), + 500: ErrorSchema, + }, + }, + + syncRepository: { + method: 'POST', + path: `/:repositoryId/sync`, + summary: 'application/json', + description: 'Trigger a gitlab synchronization for a repository', + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + body: z.object({ + syncAllBranches: z.boolean(), + branchName: z.string().optional(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateRepository: { + method: 'PUT', + path: `/:repositoryId`, + summary: 'Update repo', + description: 'Update a repo by its ID.', + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + body: RepoSchema.omit({ createdAt: true, updatedAt: true }).partial(), + responses: { + 200: RepoSchema, + 500: ErrorSchema, + }, + }, + + deleteRepository: { + method: 'DELETE', + path: `/:repositoryId`, + summary: 'Delete repo', + description: 'Delete a repo by its ID.', + body: ContractNoBody, + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/repositories`, +}) + +export type CreateRepositoryBody = ClientInferRequest['body'] + +export type UpdateRepositoryBody = ClientInferRequest['body'] + +export type SyncRepositoryParams = ClientInferRequest['params'] + +export type RepositoryParams = ClientInferRequest['params'] diff --git a/.jjconflict-side-0/packages/shared/src/contracts/service-chain.ts b/.jjconflict-side-0/packages/shared/src/contracts/service-chain.ts new file mode 100644 index 0000000000..e98f864db8 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/service-chain.ts @@ -0,0 +1,99 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainSchema, +} from '../schemas/index.js' +import { baseHeaders, EmptySchema, ErrorSchema } from './_utils.js' + +export const ServiceChainParams = z.object({ + serviceChainId: ServiceChainSchema.shape.id, +}) + +export const ServiceChainValidationParams = z.object({ + validationId: ServiceChainDetailsSchema.shape.validationId, +}) + +export const serviceChainContract = contractInstance.router( + { + listServiceChains: { + method: 'GET', + path: '', + summary: 'Get Service Chains', + description: 'Retrieve Service Chains authorized for user', + responses: { + 200: z.array(ServiceChainSchema), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getServiceChainDetails: { + method: 'GET', + path: `/:serviceChainId`, + summary: 'Get Service Chain details', + description: 'Retrieved details of a Service Chain.', + pathParams: ServiceChainParams, + responses: { + 200: ServiceChainDetailsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + retryServiceChain: { + method: 'POST', + path: `/:serviceChainId/retry`, + summary: 'Retry Service Chain creation', + description: + 'Retry the whole service chain creation from the point it failed.', + pathParams: ServiceChainParams, + body: ContractNoBody, + responses: { + 204: null, + 401: ErrorSchema, + 404: ErrorSchema, + 429: EmptySchema, // Already successful, no retry needed + 500: ErrorSchema, + }, + }, + + validateServiceChain: { + method: 'POST', + path: `/validate/:validationId`, + summary: 'Validate Service Chain creation', + description: 'Trigger the whole service chain creation.', + pathParams: ServiceChainValidationParams, + body: ContractNoBody, + responses: { + 204: null, + 401: ErrorSchema, + 404: ErrorSchema, + 429: EmptySchema, // Validation already done + 500: ErrorSchema, + }, + }, + + getServiceChainFlows: { + method: 'GET', + path: `/:serviceChainId/flows`, + summary: 'Get Service Chain flow details', + description: 'Retrieved flow details of a Service Chain.', + pathParams: ServiceChainParams, + responses: { + 200: ServiceChainFlowsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + }, + { + baseHeaders, + pathPrefix: `${apiPrefix}/service-chains`, + }, +) diff --git a/.jjconflict-side-0/packages/shared/src/contracts/service-monitor.ts b/.jjconflict-side-0/packages/shared/src/contracts/service-monitor.ts new file mode 100644 index 0000000000..c2b02e304b --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/service-monitor.ts @@ -0,0 +1,51 @@ +import type { ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ServiceHealthSchema } from '../schemas/services.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const serviceContract = contractInstance.router({ + getServiceHealth: { + method: 'GET', + path: '/health-services', + summary: 'Get services health', + description: 'Retrieve services health.', + responses: { + 200: ServiceHealthSchema.array(), + 500: ErrorSchema, + }, + }, + + getCompleteServiceHealth: { + method: 'GET', + path: '/complete-services', + summary: 'Get services health with cause', + description: 'Retrieve services health with cause.', + responses: { + 200: ServiceHealthSchema.extend({ + cause: z.any().optional(), + }).array(), + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + refreshServiceHealth: { + method: 'GET', + path: '/refresh-services', + summary: 'Force services health', + description: 'Retrieved services health.', + responses: { + 200: ServiceHealthSchema.array(), + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}`, +}) + +export type ServiceBody = ClientInferResponseBody diff --git a/.jjconflict-side-0/packages/shared/src/contracts/stage.ts b/.jjconflict-side-0/packages/shared/src/contracts/stage.ts new file mode 100644 index 0000000000..a8f20f62cf --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/stage.ts @@ -0,0 +1,95 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { StageSchema } from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const stageContract = contractInstance.router({ + listStages: { + method: 'GET', + path: '', + summary: 'Get stages', + description: 'Retrieved all stages.', + responses: { + 200: StageSchema.array(), + 500: ErrorSchema, + }, + }, + + createStage: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create stage', + description: 'Create new stage.', + body: StageSchema.omit({ id: true }).partial({ clusterIds: true }), + responses: { + 201: StageSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getStageEnvironments: { + method: 'GET', + path: `/:stageId/environments`, + summary: 'Get stages', + description: 'Retrieved all stages.', + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + responses: { + 200: z.array(z.object({ + project: z.string(), + name: z.string(), + cluster: z.string(), + owner: z.string().optional(), + })), + 500: ErrorSchema, + }, + }, + + updateStage: { + method: 'PUT', + path: `/:stageId`, + summary: 'Update stage', + description: 'Update a stage by its ID.', + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + body: StageSchema.pick({ clusterIds: true, name: true }), + responses: { + 200: StageSchema, + 500: ErrorSchema, + }, + }, + + deleteStage: { + method: 'DELETE', + path: `/:stageId`, + summary: 'Delete stage', + description: 'Delete a stage by its ID.', + body: ContractNoBody, + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/stages`, +}) + +export type CreateStageBody = ClientInferRequest['body'] + +export type UpdateStageBody = ClientInferRequest['body'] + +export type StageAssociatedEnvironments = ClientInferResponseBody diff --git a/.jjconflict-side-0/packages/shared/src/contracts/system.ts b/.jjconflict-side-0/packages/shared/src/contracts/system.ts new file mode 100644 index 0000000000..b5c77864fe --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/system.ts @@ -0,0 +1,111 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + pluginSchema, + pluginUpdateBody, + SystemSettingSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const systemContract = contractInstance.router({ + getVersion: { + method: 'GET', + path: `/version`, + summary: 'Get version', + description: 'Retrieve api version.', + responses: { + 200: z.object({ + version: z.string(), + }), + 500: ErrorSchema, + }, + }, + + getHealth: { + method: 'GET', + path: `/healthz`, + summary: 'Get health', + description: 'Retrieve api health infos.', + responses: { + 200: z.object({ + status: z.enum(['OK', 'KO']), + }), + 500: ErrorSchema, + }, + }, +}, { + pathPrefix: `${apiPrefix}`, +}) + +export const systemPluginContract = contractInstance.router({ + getPluginsConfig: { + method: 'GET', + path: '', + summary: 'Get plugins configuration', + description: 'Get plugins configuration', + responses: { + 200: pluginSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updatePluginsConfig: { + method: 'POST', + path: '', + summary: 'Update project service configuration', + description: 'Update project service configuration', + body: pluginUpdateBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/system/plugins`, +}) + +export const systemSettingsContract = contractInstance.router({ + listSystemSettings: { + method: 'GET', + path: '', + summary: 'Get System Settings state', + description: 'Get System Settings state', + query: SystemSettingSchema.pick({ key: true }) + .partial(), + responses: { + 200: SystemSettingSchema.array(), + 500: ErrorSchema, + }, + }, + + upsertSystemSetting: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Update System Settings state', + description: 'Update System Settings state', + body: SystemSettingSchema, + responses: { + 201: SystemSettingSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/system/settings`, +}) + +export type SystemSettings = ClientInferResponseBody + +export type SystemSetting = SystemSettings[number] + +export type UpsertSystemSettingBody = ClientInferRequest['body'] diff --git a/.jjconflict-side-0/packages/shared/src/contracts/user.ts b/.jjconflict-side-0/packages/shared/src/contracts/user.ts new file mode 100644 index 0000000000..508ef91171 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/user.ts @@ -0,0 +1,83 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { UuidOrCsvUuidSchema } from '../schemas/_utils.js' +import { + RoleNameCsvSchema, + UserSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const userContract = contractInstance.router({ + getMatchingUsers: { + method: 'GET', + path: `${apiPrefix}/users/matching`, + query: z.object({ + letters: z.string(), + notInProjectId: z.string().uuid().optional(), + }), + summary: 'Get users by letters matching', + description: 'Retrieved users by letters matching.', + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + auth: { + method: 'GET', + path: `${apiPrefix}/auth`, + summary: 'Login', + description: 'OIDC callback to signin or signup', + responses: { + 200: UserSchema, + 307: null, + 500: ErrorSchema, + }, + }, + + getAllUsers: { + method: 'GET', + path: `${apiPrefix}/users`, + summary: 'Get all users', + description: 'Get all users.', + query: z.object({ + adminRoles: RoleNameCsvSchema + .optional(), + adminRoleIds: UuidOrCsvUuidSchema + .optional(), + memberOfIds: UuidOrCsvUuidSchema + .optional(), + relationType: z.enum(['OR', 'AND']) + .optional(), + }), + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + patchUsers: { + method: 'PATCH', + path: `${apiPrefix}/users`, + summary: 'Patch users', + body: UserSchema.pick({ adminRoleIds: true, id: true }).array(), + description: 'Update user admin role.', + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, +}) + +export type LettersQuery = ClientInferRequest['query'] + +export type AllUsers = ClientInferResponseBody diff --git a/.jjconflict-side-0/packages/shared/src/contracts/zone.ts b/.jjconflict-side-0/packages/shared/src/contracts/zone.ts new file mode 100644 index 0000000000..90a300ba5f --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/contracts/zone.ts @@ -0,0 +1,83 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + ZoneSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const zoneContract = contractInstance.router({ + listZones: { + method: 'GET', + path: '', + summary: 'Get zones', + description: 'Get all zones.', + responses: { + 200: ZoneSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + createZone: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create zone', + description: 'Create new zone.', + body: ZoneSchema + .omit({ id: true }) + .extend({ clusterIds: z.string().uuid().array().optional() }), + responses: { + 201: ZoneSchema, + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateZone: { + method: 'PUT', + path: `/:zoneId`, + summary: 'Update zone', + description: 'Update a zone by its ID.', + pathParams: z.object({ + zoneId: z.string() + .uuid(), + }), + body: ZoneSchema.omit({ id: true, slug: true }), + responses: { + 201: ZoneSchema, + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteZone: { + method: 'DELETE', + path: `/:zoneId`, + summary: 'Delete zone', + description: 'Delete a zone by its ID.', + body: ContractNoBody, + pathParams: z.object({ + zoneId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/zones`, +}) + +export type CreateZoneBody = ClientInferRequest['body'] +export type UpdateZoneBody = ClientInferRequest['body'] diff --git a/.jjconflict-side-0/packages/shared/src/index.ts b/.jjconflict-side-0/packages/shared/src/index.ts new file mode 100644 index 0000000000..8cc07ea318 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/index.ts @@ -0,0 +1,4 @@ +export * from './api-client.js' +export * from './contracts/index.js' +export * from './schemas/index.js' +export * from './utils/index.js' diff --git a/.jjconflict-side-0/packages/shared/src/schemas/_utils.ts b/.jjconflict-side-0/packages/shared/src/schemas/_utils.ts new file mode 100644 index 0000000000..91cea1b16a --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/_utils.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +export const CoerceBooleanSchema = z.boolean() + .or(z.enum(['true', 'false']) + .transform(value => value === 'true')) + +export const dateToString = z.string().or(z.date().transform(date => date.toISOString())) + +export const AtDatesToStringExtend = { + updatedAt: dateToString, + createdAt: dateToString, +} +export const permissionLevelSchema = z.coerce.string() + +export const UuidOrCsvUuidSchema = z.string() + .refine((value) => { + return !value + .split(',') + .some(uuid => !z.string().uuid().safeParse(uuid).success) + }) + .transform(value => value.split(',')) diff --git a/.jjconflict-side-0/packages/shared/src/schemas/cluster.ts b/.jjconflict-side-0/packages/shared/src/schemas/cluster.ts new file mode 100644 index 0000000000..bac22d02c0 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/cluster.ts @@ -0,0 +1,71 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const ClusterPrivacySchema = z.enum(['public', 'dedicated']) + +export const CleanedClusterSchema = z.object({ + id: z.string() + .uuid(), + label: z.string() + .regex(/^[a-z0-9-]+$/i) + .max(50), + infos: z.string() + .max(1000) + .optional() + .nullable() + .transform(value => value ?? ''), + clusterResources: z.boolean(), + privacy: ClusterPrivacySchema, + zoneId: z.string() + .uuid(), + stageIds: z.string() + .uuid() + .array(), + cpu: z.coerce.number().gte(0), + gpu: z.coerce.number().gte(0), + memory: z.coerce.number().gte(0), +}) + +export const KubeconfigSchema = z.object({ + user: z.object({ + username: z.string() + .optional(), + password: z.string() + .optional(), + keyData: z.string() + .optional(), + certData: z.string() + .optional(), + token: z.string() + .optional(), + }), + cluster: z.object({ + server: z.string() + .optional(), + tlsServerName: z.string(), + skipTLSVerify: z.boolean() + .optional(), + caData: z.string() + .optional(), + }), +}) + +export const ClusterDetailsSchema = CleanedClusterSchema.merge(z.object({ + projectIds: z.string() + .uuid() + .array() + .optional(), + kubeconfig: KubeconfigSchema, +})) + +export const ClusterUsageSchema = z.object({ + cpu: z.number(), + gpu: z.number(), + memory: z.number(), +}) + +export type Cluster = Zod.infer +export type ClusterDetails = Zod.infer +export type Kubeconfig = Zod.infer + +export type CleanedCluster = Zod.infer diff --git a/.jjconflict-side-0/packages/shared/src/schemas/config.ts b/.jjconflict-side-0/packages/shared/src/schemas/config.ts new file mode 100644 index 0000000000..cca95debd8 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/config.ts @@ -0,0 +1,84 @@ +import type Zod from 'zod' +import { z } from 'zod' + +const configItemBase = z.object({ + key: z.string().min(2).regex(/[a-z-]/), + description: z.string().optional(), + title: z.string(), + section: z.string().optional(), +}) + +const projectVisibilty = z.object({ + permissions: z.object({ + user: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + admin: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + }), +}) + +const globalVisibilty = z.object({ + permissions: z.object({ + user: z.object({ + read: z.boolean(), + write: z.boolean().refine(value => !value, { message: 'global params can\'t be user writable' }), + }), + admin: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + }), +}) + +export const ENABLED = 'enabled' +export const DISABLED = 'disabled' +export const DEFAULT = 'default' + +export const atomicValidators = { + switch: z.enum([DISABLED, DEFAULT, ENABLED]), + // eslint-disable-next-line regexp/prefer-w, regexp/use-ignore-case + text: z.string().trim().regex(/[a-zA-Z-_0-9 ]*/), +} + +export type SwitchParam = Zod.infer + +const configItemSwitch = z.object({ + kind: z.literal('switch'), + value: atomicValidators.switch, + initialValue: atomicValidators.switch, +}).merge(configItemBase) + +const configItemInput = z.object({ + kind: z.literal('text'), + value: atomicValidators.text, + placeholder: z.string().optional(), +}).merge(configItemBase) + +const configGlobalItemDeclaration = z.discriminatedUnion('kind', [ + configItemSwitch.merge(globalVisibilty), + configItemInput.merge(globalVisibilty), +]) + +export type PluginConfigItem = Zod.infer + +export const configProjectItemDeclaration = z.discriminatedUnion('kind', [ + configItemSwitch.merge(projectVisibilty), + configItemInput.merge(projectVisibilty), +]) + +export const pluginConfig = z.object({ + global: configGlobalItemDeclaration.array(), + project: configProjectItemDeclaration.array(), +}) + +export type PluginConfig = Zod.infer + +export const pluginsPopulatedManifests = pluginConfig.partial() +export type PluginsPopulatedManifests = Zod.infer + +export const pluginUpdateBody = z.record(z.record(z.string())) +export type PluginsUpdateBody = Zod.infer diff --git a/.jjconflict-side-0/packages/shared/src/schemas/deployment.ts b/.jjconflict-side-0/packages/shared/src/schemas/deployment.ts new file mode 100644 index 0000000000..b1e0f8ee47 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/deployment.ts @@ -0,0 +1,70 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestDeploymentName } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' +import { EnvironmentSchema } from './environment.js' +import { RepoSchema } from './repository.js' + +const DeploymentSourceType = z.enum(['git', 'oci']) + +export const DeploymentSourceSchema = z.object({ + id: z.string() + .uuid(), + deploymentId: z.string() + .uuid(), + repositoryId: z.string() + .uuid(), + type: DeploymentSourceType, + repository: RepoSchema, + // Optional deployment settings + targetRevision: z.string().optional(), + path: z.string().optional(), + helmValuesFiles: z.string().optional(), +}).extend(AtDatesToStringExtend) + +export const DeploymentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestDeploymentName), + projectId: z.string() + .uuid(), + environmentId: z.string() + .uuid(), + autosync: z.boolean(), + environment: EnvironmentSchema, + deploymentSources: DeploymentSourceSchema.array(), +}).extend(AtDatesToStringExtend) + +export const CreateDeploymentSchema = DeploymentSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + environment: true, +}).extend({ + deploymentSources: DeploymentSourceSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deploymentId: true, + repository: true, + }).array().min(1, 'Au moins une source de déploiement est requise'), +}) + +export const UpdateDeploymentSchema = CreateDeploymentSchema.extend({ + deploymentSources: DeploymentSourceSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deploymentId: true, + repository: true, + }) + .extend({ id: z.string().uuid().optional() }) + .array().min(1, 'Au moins une source de déploiement est requise'), +}) + +export type Deployment = Zod.infer +export type CreateDeployment = Zod.infer +export type UpdateDeployment = Zod.infer diff --git a/.jjconflict-side-0/packages/shared/src/schemas/environment.ts b/.jjconflict-side-0/packages/shared/src/schemas/environment.ts new file mode 100644 index 0000000000..f3c9bd2d8c --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/environment.ts @@ -0,0 +1,40 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestEnvironmentName } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' + +export const EnvironmentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestEnvironmentName), + projectId: z.string() + .uuid(), + stageId: z.string().uuid(), + clusterId: z.string() + .uuid(), + cpu: z.coerce.number().gte(0), + gpu: z.coerce.number().gte(0), + memory: z.coerce.number().gte(0), + autosync: z.boolean(), +}).extend(AtDatesToStringExtend) + +export const CreateEnvironmentSchema = EnvironmentSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, +}) + +export const UpdateEnvironmentSchema = EnvironmentSchema.pick({ + cpu: true, + gpu: true, + memory: true, + autosync: true, +}) + +export type Environment = Zod.infer +export type CreateEnvironment = Zod.infer +export type UpdateEnvironment = Zod.infer diff --git a/.jjconflict-side-0/packages/shared/src/schemas/index.ts b/.jjconflict-side-0/packages/shared/src/schemas/index.ts new file mode 100644 index 0000000000..236c4124b2 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/index.ts @@ -0,0 +1,15 @@ +export * from './cluster.js' +export * from './config.js' +export * from './deployment.js' +export * from './environment.js' +export * from './log.js' +export * from './project.js' +export * from './repository.js' +export * from './role.js' +export * from './service-chain.js' +export * from './services.js' +export * from './stage.js' +export * from './system.js' +export * from './token.js' +export * from './user.js' +export * from './zone.js' diff --git a/.jjconflict-side-0/packages/shared/src/schemas/log.ts b/.jjconflict-side-0/packages/shared/src/schemas/log.ts new file mode 100644 index 0000000000..a58f2777f2 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/log.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import { AtDatesToStringExtend } from './_utils.js' + +export const CleanLogSchema = z.object({ + id: z.string(), + data: z.object({ + failed: z.boolean().or(z.array(z.string())).optional(), + warning: z.string().array().optional(), + totalExecutionTime: z.number().optional(), + messageResume: z.string().optional(), + }).strip(), + action: z.string(), + userId: z.string().nullable(), +}) + .extend(AtDatesToStringExtend) + +export const LogSchema = z.object({ + id: z.string(), + data: z.object({ + args: z.any(), + failed: z.boolean().or(z.array(z.string())).optional(), + warning: z.string().array().optional(), + results: z.any(), + totalExecutionTime: z.number().optional(), + messageResume: z.string().optional(), + }) + .passthrough() + .transform((data) => { + delete data.config + return data + }), + action: z.string(), + userId: z.string().nullable(), + requestId: z.string().nullable(), + projectId: z.string().nullable(), +}) + .extend(AtDatesToStringExtend) diff --git a/.jjconflict-side-0/packages/shared/src/schemas/project.ts b/.jjconflict-side-0/packages/shared/src/schemas/project.ts new file mode 100644 index 0000000000..6f4e549f78 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/project.ts @@ -0,0 +1,87 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestEnvironmentName, projectStatus } from '../utils/const.js' +import { AtDatesToStringExtend, CoerceBooleanSchema, permissionLevelSchema } from './_utils.js' +import { RepoSchema } from './repository.js' +import { ProjectRoleSchema } from './role.js' +import { MemberSchema, UserSchema } from './user.js' + +export const descriptionMaxLength = 280 +export const projectNameMaxLength = 20 +export const ProjectStatusSchema = z.enum(projectStatus) + +const ProjectEnvironmentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestEnvironmentName), + slug: z.string() + .regex(/^[a-z0-9]+$/), + projectId: z.string() + .uuid(), + stageId: z.string().uuid(), + clusterId: z.string() + .uuid(), + permissions: z.array(z.object({ + id: z.string() + .uuid(), + userId: z.string() + .uuid(), + environmentId: z.string() + .uuid(), + level: z.union([ + z.string(), + z.number() + .int() + .nonnegative() + .max(2), + ]), + })), +}) +export const ProjectSchema = z.object({ + // ProjectInfos + repositories: RepoSchema.array(), + environments: ProjectEnvironmentSchema.array(), +}) + +export type Project = Zod.infer + +export const ProjectSchemaV2 = z.object({ + clusterIds: z.string().uuid().array(), + name: z.string() + .min(2) + .max(projectNameMaxLength) + .regex(/^[a-z0-9]+$/), + slug: z.string(), + description: z.string() + .max(descriptionMaxLength) + .optional(), + status: ProjectStatusSchema, + locked: CoerceBooleanSchema, + limitless: CoerceBooleanSchema, + hprodCpu: z.coerce.number().gte(0), + hprodGpu: z.coerce.number().gte(0), + hprodMemory: z.coerce.number().gte(0), + prodCpu: z.coerce.number().gte(0), + prodGpu: z.coerce.number().gte(0), + prodMemory: z.coerce.number().gte(0), + + id: z.string() + .uuid(), + members: MemberSchema + .array(), + ownerId: z.string() + .uuid(), + owner: UserSchema + .omit({ adminRoleIds: true }), + roles: ProjectRoleSchema + .array(), + everyonePerms: permissionLevelSchema, + lastSuccessProvisionningVersion: z.string() + .nullable(), +}) + .extend(AtDatesToStringExtend) + +export type ProjectV2 = Zod.infer diff --git a/.jjconflict-side-0/packages/shared/src/schemas/repository.ts b/.jjconflict-side-0/packages/shared/src/schemas/repository.ts new file mode 100644 index 0000000000..85675b9123 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/repository.ts @@ -0,0 +1,74 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { forbiddenRepoNames, invalidGitUrl, invalidInternalRepoName, missingCredentials } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' + +export const RepoSchema = z.object({ + id: z.string() + .uuid(), + internalRepoName: z.string() + .min(2, { message: 'Longueur minimum 2 caractères' }) + .max(20, { message: 'Longueur maximum 20 caractères' }) + .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, { message: invalidInternalRepoName }) + .refine(name => !forbiddenRepoNames.includes(name.toLowerCase()), { + message: `Le nom du dépôt choisi est inclus dans la liste d'exclusion: ${forbiddenRepoNames.join(', ')}`, + }), + externalRepoUrl: z.string() + .regex(/^https:\/\/.*\.git$/, { message: invalidGitUrl }) + .url({ message: 'Url invalide' }) + .or(z.literal('')) + .optional(), + isPrivate: z.boolean(), + isInfra: z.boolean(), + externalUserName: z.string() + .optional(), + externalToken: z.string() + .optional(), + projectId: z.string() + .uuid(), + // Optional deployment settings for infra repositories + deployRevision: z.string().optional(), + deployPath: z.string().optional(), + helmValuesFiles: z.string().optional(), +}).extend(AtDatesToStringExtend) + +// To only use in frontend form +export const RepoFormSchema = RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .extend({ isStandalone: z.boolean() }) + +export const UpdateRepoFormSchema = RepoFormSchema + .refine( + ({ isPrivate, externalToken, externalUserName }) => { + if (isPrivate) { + if (!externalToken && !externalUserName) return false + return true + } + return true + }, + { message: missingCredentials, path: ['credentials'] }, + ) + .refine(({ isStandalone, externalRepoUrl }) => { + if (!isStandalone && !externalRepoUrl) { + return false + } + return true + }, { message: 'Veuillez renseignez l\'url du dépôt externe', path: ['externalRepoUrl'] }) + +export const CreateRepoFormSchema = RepoFormSchema + .omit({ id: true, projectId: true }) + .refine(({ isPrivate, externalToken, externalUserName }) => { + if (isPrivate) { + if (!externalToken && !externalUserName) return false + return true + } + return true + }, { message: missingCredentials, path: ['credentials'] }) + .refine(({ isStandalone, externalRepoUrl }) => { + if (!isStandalone && !externalRepoUrl) { + return false + } + return true + }, { message: 'Veuillez renseignez l\'url du dépôt externe', path: ['externalRepoUrl'] }) + +export type Repo = Zod.infer diff --git a/.jjconflict-side-0/packages/shared/src/schemas/role.ts b/.jjconflict-side-0/packages/shared/src/schemas/role.ts new file mode 100644 index 0000000000..0e0a1d226a --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/role.ts @@ -0,0 +1,35 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { permissionLevelSchema } from './_utils.js' + +export const RoleNameSchema = z.string().max(30) + +export const RoleSchema = z.object({ + id: z.string().uuid(), + name: RoleNameSchema, + permissions: permissionLevelSchema, + position: z.number().min(0), + type: z.string().optional(), +}) + +export const ProjectRoleSchema = RoleSchema.extend({ + projectId: z.string().uuid(), + oidcGroup: z.string().optional(), +}) + +export const AdminRoleSchema = RoleSchema.extend({ + oidcGroup: z.string(), + type: z.string().optional().default('managed'), +}) + +export const RoleNameCsvSchema = z.string() + .refine((value) => { + return !value.split(',').some(name => !RoleNameSchema.safeParse(name).success) + }) + .transform(value => value.split(',')) + +export type Role = Zod.infer +export type RoleBigint = Omit, 'permissions'> & { permissions: bigint } +export type AdminRole = Zod.infer +export type ProjectRole = Zod.infer +export type ProjectRoleBigint = Omit, 'permissions'> & { permissions: bigint } diff --git a/.jjconflict-side-0/packages/shared/src/schemas/service-chain.ts b/.jjconflict-side-0/packages/shared/src/schemas/service-chain.ts new file mode 100644 index 0000000000..c364fcb8d0 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/service-chain.ts @@ -0,0 +1,103 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const serviceChainStateEnum = ['opened', 'pending', 'success', 'failed'] as const +export const ServiceChainStateZodEnum = z.enum(serviceChainStateEnum) +export type ServiceChainState = Zod.infer + +export const serviceChainNetworkEnum = ['RIE', 'INTERNET'] as const +export const ServiceChainNetworkZodEnum = z.enum(serviceChainNetworkEnum) +export type ServiceChainNetwork = Zod.infer + +export const serviceChainLocationEnum = ['SIR', 'SIL'] as const +export const ServiceChainLocationZodEnum = z.enum(serviceChainLocationEnum) +export type ServiceChainLocation = Zod.infer + +export const serviceChainEnvironmentEnum = ['INT', 'PROD', 'BAS'] as const +export const ServiceChainEnvironmentZodEnum = z.enum(serviceChainEnvironmentEnum) +export type ServiceChainEnvironment = Zod.infer< + typeof ServiceChainEnvironmentZodEnum +> + +export const ServiceChainSchema = z.object({ + id: z.string().uuid(), + state: ServiceChainStateZodEnum, + commonName: + // @TODO: Replace with z.hostname when upgraded to Zod v4 + z.string(), + pai: z.string(), + network: ServiceChainNetworkZodEnum, + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}) +export type ServiceChain = Zod.infer + +export const ServiceChainListSchema = z.array(ServiceChainSchema) +export type ServiceChainList = Zod.infer + +export const ServiceChainDetailsSchema = ServiceChainSchema.extend({ + validationId: z.string().uuid(), + validatedBy: z.nullable(z.string().uuid()), + ref: z.nullable(z.string().uuid()), + location: ServiceChainLocationZodEnum, + targetAddress: z.string().ip(), + projectId: z.string().uuid(), + env: ServiceChainEnvironmentZodEnum, + subjectAlternativeName: z.array( + // @TODO: Replace with z.hostname when upgraded to Zod v4 + z.string(), + ), + redirect: z.boolean(), + antivirus: z.nullable(z.object({ + maxFileSize: z.number(), + })), + websocket: z.boolean(), + ipWhiteList: z.array(z.string().cidr()), + sslOutgoing: z.boolean(), +}) +export type ServiceChainDetails = Zod.infer + +// JSON as String validation through Zod. Instead of adding yet-another-dependency, +// I merely copied the code as-is. +// Many thanks to JacobWeisenburger/zod_utilz ! +// +// Usage: use stringToJSON() as you would use z.string() to validate strings that must +// contain JSON stringified content +const literalSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), +]) +type Literal = z.infer +type Json = Literal | { [ key: string ]: Json } | Json[] +const jsonSchema: z.ZodType = z.lazy(() => + z.union([ + literalSchema, + z.array(jsonSchema), + z.record(jsonSchema), + ]), +) +export const json = () => jsonSchema + +export const serviceChainFlowStateEnum = ['opened', 'pending', 'success', 'failed'] as const +export const ServiceChainFlowStateZodEnum = z.enum(serviceChainFlowStateEnum) +export type ServiceChainFlowState = Zod.infer + +export const ServiceChainFlowDetailsSchema = z.object({ + state: ServiceChainFlowStateZodEnum, + input: json(), + output: json(), + updatedAt: z.coerce.date(), +}) +export type ServiceChainFlowDetails = Zod.infer + +export const ServiceChainFlowsSchema = z.object({ + reserve_ip: ServiceChainFlowDetailsSchema, + create_cert: z.nullable(ServiceChainFlowDetailsSchema), + call_exec: ServiceChainFlowDetailsSchema, + activate_ip: ServiceChainFlowDetailsSchema, + dns_request: ServiceChainFlowDetailsSchema, +}) +// Flows is on object, so always used in plural sense +export type ServiceChainFlows = Zod.infer diff --git a/.jjconflict-side-0/packages/shared/src/schemas/services.ts b/.jjconflict-side-0/packages/shared/src/schemas/services.ts new file mode 100644 index 0000000000..22c8299f1f --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/services.ts @@ -0,0 +1,36 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { pluginsPopulatedManifests } from './config.js' + +export const serviceUrl = z.object({ + to: z.string(), + name: z.string(), + description: z.string() + .optional(), +}) + +export type ServiceUrl = Zod.infer + +export const ServiceSchema = z.object({ + description: z.string() + .optional(), + title: z.string(), + name: z.string(), + imgSrc: z.string() + .optional(), + urls: serviceUrl.array(), + manifest: pluginsPopulatedManifests, +}) + +export const permissionTarget = z.enum(['user', 'admin']).default('user') + +export type PermissionTarget = Zod.infer +export type ProjectService = Zod.infer + +export const ServiceHealthSchema = z.object({ + name: z.string(), + status: z.enum(['OK', 'Dégradé', 'En échec', 'Inconnu']), + interval: z.number(), + lastUpdateTimestamp: z.number(), + message: z.string(), +}) diff --git a/.jjconflict-side-0/packages/shared/src/schemas/stage.ts b/.jjconflict-side-0/packages/shared/src/schemas/stage.ts new file mode 100644 index 0000000000..51cb72d120 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/stage.ts @@ -0,0 +1,14 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const StageSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/i) + .min(2, { message: 'must be at least 2 character long' }) + .max(20, { message: 'must not exceed 20 characters' }), + clusterIds: z.string().uuid().array(), +}) + +export type Stage = Zod.infer diff --git a/.jjconflict-side-0/packages/shared/src/schemas/system.ts b/.jjconflict-side-0/packages/shared/src/schemas/system.ts new file mode 100644 index 0000000000..d941a5b9d2 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/system.ts @@ -0,0 +1,20 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { configProjectItemDeclaration } from './config.js' + +export const pluginSchema = z.object({ + description: z.string() + .optional(), + title: z.string(), + name: z.string(), + imgSrc: z.string() + .optional(), + manifest: configProjectItemDeclaration.array(), +}) + +export type PluginSchema = Zod.infer + +export const SystemSettingSchema = z.object({ + key: z.string(), + value: z.string(), +}) diff --git a/.jjconflict-side-0/packages/shared/src/schemas/token.ts b/.jjconflict-side-0/packages/shared/src/schemas/token.ts new file mode 100644 index 0000000000..36cd518ac5 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/token.ts @@ -0,0 +1,44 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { dateToString, permissionLevelSchema } from './_utils.js' +import { UserSchema } from './user.js' + +export const TokenSchema = z.object({ + id: z.string().uuid(), + name: z.string() + .max(32, { message: 'Ne peut dépasser 32 caractères' }) + .min(2, { message: 'Ne peut faire moins de 2 caractères' }) + .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, { message: 'Le nom ne peut être constitué que de caractères minuscules, de chiffres et de tirets (-)' }), + lastUse: dateToString.nullable(), + createdAt: dateToString, + owner: UserSchema + .pick({ email: true, firstName: true, lastName: true, id: true, type: true }) + .optional() + .nullable(), + status: z.enum(['active', 'revoked', 'inactive']), +}) + +// Admin Token section +export const AdminTokenSchema = TokenSchema + .extend({ + expirationDate: dateToString.nullable(), + permissions: permissionLevelSchema, + }) + +export const ExposedAdminTokenSchema = AdminTokenSchema.extend({ + password: z.string(), +}) + +export type AdminToken = Zod.infer +export type ExposedAdminToken = Zod.infer + +// PAT section +export const PersonalAccessTokenSchema = TokenSchema.extend({ + expirationDate: dateToString, +}) +export const ExposedPersonalAccessTokenSchema = TokenSchema.extend({ + password: z.string(), +}) + +export type PersonalAccessToken = Zod.infer +export type ExposedPersonalAccessToken = Zod.infer diff --git a/.jjconflict-side-0/packages/shared/src/schemas/user.ts b/.jjconflict-side-0/packages/shared/src/schemas/user.ts new file mode 100644 index 0000000000..2fd89bc285 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/user.ts @@ -0,0 +1,39 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { AtDatesToStringExtend, dateToString } from './_utils.js' + +export const UserSchema = z.object({ + id: z.string() + .uuid(), + firstName: z.string(), + lastName: z.string(), + email: z.string(), + adminRoleIds: z.string().uuid().array(), + type: z.enum(['human', 'ghost', 'bot']), + lastLogin: dateToString.optional().nullable(), +}) + .extend(AtDatesToStringExtend) + +export const MemberSchema = z.object({ + userId: z.string() + .uuid(), + firstName: z.string(), + lastName: z.string(), + email: z.string() + .email(), + roleIds: z.string().uuid().array(), +}) + .or( + z.object({ + user: UserSchema, + roleIds: z.string().uuid().array(), + }).transform(({ user: { adminRoleIds: _, id: userId, ...user }, roleIds }) => ({ userId, roleIds, ...user })), + ) + +export const ProjectMemberSchema = MemberSchema.and(z.object({ + projectId: z.string().uuid(), +})) + +export type User = Zod.infer +export type Member = Zod.infer +export type ProjectMember = Zod.infer diff --git a/.jjconflict-side-0/packages/shared/src/schemas/zone.ts b/.jjconflict-side-0/packages/shared/src/schemas/zone.ts new file mode 100644 index 0000000000..e402a73a6c --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/schemas/zone.ts @@ -0,0 +1,24 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const ZoneSchema = z.object({ + id: z.string() + .uuid(), + slug: z.string() + .min(1) + .max(10) + .regex(/^[a-z0-9-]+$/), + label: z.string() + .min(1) + .max(50), + argocdUrl: z.string() + .min(5, { message: 'Longueur minimum 5 caractères' }) + .url({ message: 'Url invalide' }), + description: z.string() + .max(200) + .optional() + .nullable() + .transform(value => value ?? ''), +}) + +export type Zone = Zod.infer diff --git a/.jjconflict-side-0/packages/shared/src/utils/const.ts b/.jjconflict-side-0/packages/shared/src/utils/const.ts new file mode 100644 index 0000000000..9c80f33998 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/utils/const.ts @@ -0,0 +1,147 @@ +export const adminGroupPath = '/admin' +export const deleteValidationInput = 'DELETE' +export const forbiddenRepoNames = ['mirror', 'infra-apps', 'infra-observability'] + +export const inClusterLabel = 'in-cluster' as const +export const projectIsLockedInfo = 'Le projet est verrouillé, pas d\'action possible' +export const missingCredentials = 'Si le dépôt est privé, vous devez renseigner au moins le nom d\'utilisateur ou le token' +export const invalidGitUrl = 'L\'adresse doit commencer par https et se terminer par .git' +export const invalidInternalRepoName = 'Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique' + +export const fakeToken = 'fakeToken' +export const tokenHeaderName = 'x-dso-token' +export const swaggerUiPath = '/swagger-ui' + +export const levels = [ + 'r', + 'rw', + 'rwd', +] as const +export type PermissionLevels = typeof levels[number] + +export const projectRoles = [ + 'owner', + 'user', +] as const +export type ProjectRoles = typeof projectRoles[number] + +export const longestEnvironmentName = 11 as const +export const longestDeploymentName = 11 as const + +export const allStatus = [ + 'initializing', + 'created', + 'failed', + 'deleting', +] as const + +export const projectStatus = [ + 'initializing', + 'created', + 'failed', + 'archived', + 'warning', +] as const + +export const achievedStatus = [ + 'created', + 'failed', +] as const + +export type AchievedStatus = typeof achievedStatus[number] + +export enum ClusterPrivacy { + PUBLIC = 'public', + DEDICATED = 'dedicated', +} + +export const privacyWording: Record = { + dedicated: { text: 'dédié', icon: 'ri:shield-keyhole-line' }, + public: { text: 'public', icon: 'ri:door-open-line' }, +} + +export enum AllStatus { + CREATED = 'created', + FAILED = 'failed', + DELETING = 'deleting', +} + +export const logActions = [ + 'Create Project', + 'Create Repository', + 'Create Environment', + 'Delete Project', + 'Delete Repository', + 'Delete Environment', +] + +export const statusDict = { + locked: { + false: { + testId: 'unlocked-badge', + type: 'success', + icon: 'ri:lock-unlock-line', + wording: 'déverrouillé', + animation: undefined, + color: 'var(--success-425-625)', + }, + true: { + testId: 'locked-badge', + type: 'warning', + icon: 'ri:lock-line', + wording: 'verrouillé', + animation: undefined, + color: 'var(--warning-425-625)', + }, + }, + status: { + created: { + testId: 'created-badge', + type: 'success', + icon: 'ri:check-line', + wording: 'succès', + animation: undefined, + color: 'var(--success-425-625)', + }, + failed: { + testId: 'failed-badge', + type: 'error', + icon: 'ri:close-line', + wording: 'opérations échouées', + animation: undefined, + color: 'var(--error-425-625)', + }, + initializing: { + testId: 'initializing-badge', + type: 'info', + icon: 'ri:loader-4-line', + animation: 'spin', + wording: 'opérations en cours', + color: 'var(--info-425-625)', + }, + archived: { + testId: 'archived-badge', + type: 'info', + icon: 'ri:archive-line', + wording: 'archivé', + animation: undefined, + color: 'var(--text-mention-grey)', + }, + warning: { + testId: 'warning-badge', + type: 'warning', + icon: 'ri:alert-line', + wording: 'partiellement dégradé', + animation: undefined, + color: 'var(--warning-425-625)', + }, + }, +} as const + +export const servicePluginOrder = [ + 'argocd', + 'gitlab', + 'registry', + 'sonarqube', + 'vault', +] diff --git a/.jjconflict-side-0/packages/shared/src/utils/date.ts b/.jjconflict-side-0/packages/shared/src/utils/date.ts new file mode 100644 index 0000000000..1bc7428288 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/utils/date.ts @@ -0,0 +1,9 @@ +export function formatDate(dateString: string) { + const date = new Date(dateString) + return new Intl.DateTimeFormat('default', { dateStyle: 'long' }).format(date) +} + +export function formatDateTime(dateTimeString: string) { + const date = new Date(dateTimeString) + return new Intl.DateTimeFormat('default', { dateStyle: 'short', timeStyle: 'short' }).format(date) +} diff --git a/.jjconflict-side-0/packages/shared/src/utils/functions.spec.ts b/.jjconflict-side-0/packages/shared/src/utils/functions.spec.ts new file mode 100644 index 0000000000..deecd24656 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/utils/functions.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { calcProjectNameMaxLength, exclude, identity, removeTrailingSlash, shallowEqual, shallowMatch } from './functions.js' + +describe('function utils: identity', () => { + it('should return identity', () => { + expect(identity('Test')).toStrictEqual('Test') + }) +}) + +describe('function utils: exclude', () => { + it('should exclude keys', () => { + const emptyInput = {} + const simpleInput = { + hello: 'world', + foo: 'bar', + } + const simpleExpectedInput = { + foo: 'bar', + } + const complexInput = { + empty: emptyInput, + simple: simpleInput, + array: [emptyInput, simpleInput, simpleInput, emptyInput], + number: 1, + } + const complexExpectedOutput = { + empty: emptyInput, + simple: simpleExpectedInput, + array: [emptyInput, simpleExpectedInput, simpleExpectedInput, emptyInput], + number: 1, + } + + expect(exclude(emptyInput, ['absentKey'])).toStrictEqual({}) + expect(exclude(simpleInput, ['hello'])).toStrictEqual(simpleExpectedInput) + expect(exclude(complexInput, ['hello'])).toStrictEqual(complexExpectedOutput) + }) + + it('should not mutate input', () => { + const simpleInput = { + hello: 'world', + foo: 'bar', + } + exclude(simpleInput, ['hello']) + expect(simpleInput).toStrictEqual(simpleInput) + }) + + it('should stringify Error and not traverse non-serializable objects', () => { + class NotSerializable { + public value = 'secret' + } + + const input = { + err: new Error('boom'), + big: 896n, + date: new Date('2026-04-08T14:32:26.773Z'), + instance: new NotSerializable(), + } + + const transformed = exclude(input, []) + expect(transformed).toMatchObject({ + big: '896', + date: '2026-04-08T14:32:26.773Z', + instance: '[NotSerializable]', + }) + expect(typeof (transformed as any).err).toBe('string') + expect((transformed as any).err).toContain('boom') + }) +}) + +describe('function utils: calcProjectNameMaxLength', () => { + it('should return max length', () => { + expect(calcProjectNameMaxLength()).toStrictEqual(50) + }) +}) + +describe('function utils: removeTrailingSlash', () => { + it('should return string without ending slash', () => { + expect(removeTrailingSlash('mtest')).toStrictEqual('mtest') + expect(removeTrailingSlash('mtest/')).toStrictEqual('mtest') + }) +}) + +describe('function utils: shallowEqual', () => { + it('should return false if value differs', () => { + expect(shallowEqual({ a: 1 }, { a: 2 })).toEqual(false) + }) + it('should return false if missing key', () => { + expect(shallowEqual({ a: 1, b: 2 }, { a: 1 })).toEqual(false) + }) + it('should return true if equal', () => { + expect(shallowEqual({ a: 1 }, { a: 1 })).toEqual(true) + }) + it('should ignore undefined values in object1', () => { + expect(shallowEqual({ a: 1, b: undefined }, { a: 1 })).toEqual(true) + }) + it('should ignore undefined values in object2', () => { + expect(shallowEqual({ a: 1 }, { a: 1, b: undefined })).toEqual(true) + }) + it('should return true even if keys are in different order', () => { + expect(shallowEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toEqual(true) + }) + it('should treat empty objects as equal', () => { + expect(shallowEqual({}, {})).toEqual(true) + }) + it('should treat objects with only undefined keys as equal', () => { + expect(shallowEqual({ a: undefined }, { b: undefined })).toEqual(true) + }) +}) + +describe('function utils: shallowMatch', () => { + it('should return false if key is not equal', () => { + expect(shallowMatch({ a: 1 }, { a: 2 })).toEqual(false) + }) + it('should return false if missing key', () => { + expect(shallowMatch({ a: 1, b: 2 }, { a: 1 })).toEqual(false) + }) + it('should return true if is equal', () => { + expect(shallowMatch({ a: 1 }, { a: 1 })).toEqual(true) + }) +}) diff --git a/.jjconflict-side-0/packages/shared/src/utils/functions.ts b/.jjconflict-side-0/packages/shared/src/utils/functions.ts new file mode 100644 index 0000000000..f38778f36d --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/utils/functions.ts @@ -0,0 +1,290 @@ +import type { ResourceById, ResourceByKey } from './types.js' +import shortUUID from 'short-uuid' +import { longestEnvironmentName } from './const.js' + +/** + * @param {*} value Value wanted to be return as is + * @returns {*} Value returned as is + */ +export const identity = (value: unknown) => value + +export const getLongestStringOfArray = (array: Array) => array.reduce((acc, curr) => acc.length < curr.length ? curr : acc, '') + +export function calcProjectNameMaxLength() { + return 61 - longestEnvironmentName +} + +export const getUniqueListBy = (arr: Array>, key: string) => [...new Map(arr.map(item => [item[key], item])).values()] + +export const isString = (value: any): value is string => typeof value === 'string' + +type ObjToSort = Record + +export function sortArrByObjKeyAsc(arr: T, key: string): T { + return arr.toSorted((a: ObjToSort, b: ObjToSort) => { + const aValue = a[key] + const bValue = b[key] + if (isString(aValue) && isString(bValue)) { + return aValue.localeCompare(bValue.toString(), 'fr', { sensitivity: 'base' }) + } + if (typeof aValue === 'number' && typeof bValue === 'number') { + return aValue - bValue + } + if (typeof aValue === 'boolean' && typeof bValue === 'boolean') { + return Number(bValue) - Number(aValue) + } + return -1 + }) as T +} + +export function removeTrailingSlash(url: string) { + return url?.endsWith('/') + ? url.slice(0, -1) + : url +} + +function excludeCircular(value: unknown, keys: string[], inPath: WeakSet, inArray: boolean): unknown { + if (value === null) return null + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value + if (typeof value === 'bigint') return value.toString() + if (typeof value === 'undefined') return inArray ? null : undefined + if (typeof value === 'function') return inArray ? null : undefined + if (typeof value === 'symbol') return value.toString() + if (typeof value === 'object') { + if (inPath.has(value)) return '[object Circular]' + inPath.add(value) + + if (Array.isArray(value)) return value.map(v => excludeCircular(v, keys, inPath, true)) + + if (value instanceof Date) { + inPath.delete(value) + return value.toISOString() + } + + if (value instanceof Error) { + const payload = { + name: value.name, + message: value.message, + stack: value.stack, + } + inPath.delete(value) + try { + return JSON.stringify(payload) + } catch { + return `${value.name}: ${value.message}` + } + } + + if (value instanceof Map) { + const obj: Record = {} + for (const [k, v] of value.entries()) { + const key = typeof k === 'string' ? k : String(k) + obj[key] = excludeCircular(v, keys, inPath, false) + } + inPath.delete(value) + return obj + } + + if (value instanceof Set) { + const out = Array.from(value.values(), v => excludeCircular(v, keys, inPath, true)) + inPath.delete(value) + return out + } + + if (value instanceof RegExp) { + inPath.delete(value) + return value.toString() + } + + if (value instanceof URL) { + inPath.delete(value) + return value.toString() + } + + if (value instanceof URLSearchParams) { + inPath.delete(value) + return value.toString() + } + + if ('toJSON' in value && typeof value.toJSON === 'function') { + try { + const serialized = value.toJSON() + const out = excludeCircular(serialized, keys, inPath, inArray) + inPath.delete(value) + return out + } catch { + inPath.delete(value) + return '[object Unserializable]' + } + } + + const proto = Object.getPrototypeOf(value) + if (proto !== Object.prototype && proto !== null) { + const ctorName = (value as any)?.constructor?.name + const tag = typeof ctorName === 'string' && ctorName.length ? ctorName : 'Object' + try { + const s = String(value) + const out = s === '[object Object]' ? `[${tag}]` : s + inPath.delete(value) + return out + } catch { + inPath.delete(value) + return `[object ${tag}]` + } + } + + const obj = value as Record + const newObj: Record = {} + for (const [k, v] of Object.entries(obj)) { + if (keys.includes(k)) continue + const next = excludeCircular(v, keys, inPath, false) + if (typeof next === 'undefined') continue + newObj[k] = next + } + inPath.delete(value) + return newObj + } + return String(value) +} + +// Exclude keys from an object +export function exclude(result: T, keys: string[]): T { + const inPath = new WeakSet() + return excludeCircular(result, keys, inPath, false) as T +} + +export type AsyncReturnType Promise> + = T extends (...args: any) => Promise ? R : any + +export function objectEntries>(obj: Obj): ([keyof Obj, Obj[keyof Obj]])[] { + return Object.entries(obj) as ([keyof Obj, Obj[keyof Obj]])[] +} +export function objectKeys>(obj: Obj): (keyof Obj)[] { + return Object.keys(obj) as (keyof Obj)[] +} +export function objectValues>(obj: Obj): (Obj[keyof Obj])[] { + return Object.values(obj) as (Obj[keyof Obj])[] +} + +export function requiredEnv(envName: string): string { + const envValue = process.env[envName] + if (envValue) return envValue + + throw new Error(`env: ${envName} is not defined !`) +} + +export function resourceListToDict(resList: Array): ResourceById { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.id]: curr, + } + }, {} as ResourceById) +} + +export function resourceListToDictByKey(resList: Array): ResourceByKey { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.key]: curr, + } + }, {} as ResourceByKey) +} + +export function shallowEqual(object1: Record, object2: Record) { + const definedObject1 = Object.fromEntries( + Object.entries(object1).filter(([_, v]) => v !== undefined), + ) + const definedObject2 = Object.fromEntries( + Object.entries(object2).filter(([_, v]) => v !== undefined), + ) + const keys1 = Object.keys(definedObject1) + const keys2 = Object.keys(definedObject2) + + if (keys1.length !== keys2.length) { + return false + } + + for (const key of keys1) { + if (definedObject1[key] !== definedObject2[key]) { + return false + } + } + + return true +} + +/** + * Check if all the keys / values in first parameter is present in the the second parameter, the second one can have additional property + * + * @example + * ```ts + * shallowMatch({ a: 'a' }, { a: 'c' }) // false, invalid value + * shallowMatch({ a: 'a' }, { }) // fals, missing property + * shallowMatch({ a: 'a' }, { a: 'a', b: 'b' }) // true, everything is retrieved and equivalent in the second argument + * ``` + * + */ +export function shallowMatch(objectController: Record | undefined, objectToCheck: Record | undefined) { + if (!objectController || !objectToCheck) { + return false + } + + const keys1 = Object.keys(objectController) + const keys2 = Object.keys(objectToCheck) + + if (keys1.length > keys2.length) { + return false + } + + for (const key of keys1) { + if (objectController[key] !== objectToCheck[key]) { + return false + } + } + + return true +} + +export function generateRandomPassword(length = 24, chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*') { + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +export function isAtLeastTomorrow(actualTime: Date) { + const tomorrow = new Date(Date.now()) + tomorrow.setUTCHours(23, 59, 59, 999) + + return actualTime.getTime() > tomorrow.getTime() +} + +export function insert(pseudoArray: T[] | undefined, element: T): T[] { + if (!pseudoArray) { + return [element] + } else if (Array.isArray(pseudoArray)) { + return [...pseudoArray, element] + } else { + throw new TypeError('item is not an ArrayLike') + } +} + +export type ArrayElement + = ArrayType extends readonly (infer ElementType)[] ? ElementType : never + +export const bts = (v: boolean) => v ? 'true' : 'false' +export function stb(v?: string | undefined) { + return v === 'true' + ? true + : v === 'false' ? false : undefined +} + +const uuidTranslator = shortUUID(shortUUID.constants.uuid25Base36, { + consistentLength: false, +}) + +export const compressUUID = uuidTranslator.fromUUID +export const expandUUID = uuidTranslator.toUUID + +export function generateNamespaceName(projectId: string, envId: string) { + return `${compressUUID(envId)}--${compressUUID(projectId)}` +} diff --git a/.jjconflict-side-0/packages/shared/src/utils/index.ts b/.jjconflict-side-0/packages/shared/src/utils/index.ts new file mode 100644 index 0000000000..0600385dce --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/utils/index.ts @@ -0,0 +1,8 @@ +export * from './const.js' +export * from './date.js' +export * from './functions.js' +export * from './permissions.js' +export * from './plugins.js' +export * from './roles.js' +export * from './schemas.js' +export * from './types.js' diff --git a/.jjconflict-side-0/packages/shared/src/utils/permissions.ts b/.jjconflict-side-0/packages/shared/src/utils/permissions.ts new file mode 100644 index 0000000000..040fbf55d1 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/utils/permissions.ts @@ -0,0 +1,345 @@ +/** + * [FR] ATTENTION ! Ce fichier est la base du système de permissions de l'application. + * Ces permissions sont basés sur le Bitwise Permissions System. Les modifier à posteriori pourrait être catastrophique niveau sécurité. + * Veuillez bien étudier le système et lire la documentation. + * + * [EN] This file is the basis of the application's permissions system. + * These permissions are based on the Bitwise Permissions System. Modifying them after the fact could be catastrophic in terms of security. + * Please study the system carefully and read the documentation. + * https://en.wikipedia.org/wiki/Bitwise_operation + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_AND + * https://www.alexhyett.com/bitwise-operators/ + * + * Voici des sources d'inspirations + * https://discordapi.com/permissions.html#32 + * https://discord.com/developers/docs/topics/permissions#permissions + */ +import type { ResourceById } from './types.js' +import { logger as baseLogger } from '@cpn-console/logger' + +const logger = baseLogger.child({ scope: 'utils:permissions' }) + +export function getPermsByUserRoles(userRoles: string[] | undefined, rolesById: ResourceById<{ id: string, permissions: bigint | string }>, basePerms?: bigint | string) { + if (!userRoles) { + return basePerms ? BigInt(basePerms) : 0n + } + return userRoles.reduce((acc, curr) => { + if (!rolesById[curr]) { + logger.warn(`Role ${curr} not found in rolesById`) + return acc + } + return acc | BigInt(rolesById[curr].permissions) + }, basePerms ? BigInt(basePerms) : 0n) +} + +function permissionsParser(a: Record) { + const valuesRegistered = [] as bigint[] + for (const [k, v] of Object.entries(a)) { + if (typeof v !== 'bigint') + throw new Error(`${k} has a invalid value: ${v}, which is not a bigint`) + if (valuesRegistered.includes(v)) + throw new Error(`${k} has a duplicated value: ${v}`) + valuesRegistered.push(v) + } +} + +const bit = (position: bigint) => 1n << position + +// Be very careful and think to apply corresponding updates in database if you modify these values, You'll have to do binary updates in SQL, good luck ! +export const PROJECT_PERMS = { // project permissions + GUEST: bit(0n), + MANAGE: bit(1n), + MANAGE_MEMBERS: bit(2n), + MANAGE_ENVIRONMENTS: bit(3n), + MANAGE_REPOSITORIES: bit(4n), + MANAGE_ROLES: bit(5n), + SEE_SECRETS: bit(6n), + REPLAY_HOOKS: bit(7n), + LIST_ENVIRONMENTS: bit(8n), + LIST_REPOSITORIES: bit(9n), + LIST_MEMBERS: bit(10n), + LIST_ROLES: bit(11n), + MANAGE_DEPLOYMENTS: bit(12n), + LIST_DEPLOYMENTS: bit(13n), +} + +// Be very careful and think to apply corresponding updates in database if you modify these values, You'll have to do binary updates in SQL, good luck ! +export const ADMIN_PERMS = { // admin permissions + LIST: bit(0n), + MANAGE: bit(1n), + MANAGE_USERS: bit(2n), + MANAGE_PROJECTS: bit(3n), + MANAGE_ROLES: bit(4n), + MANAGE_CLUSTERS: bit(5n), + MANAGE_ZONES: bit(6n), + MANAGE_STAGES: bit(7n), + MANAGE_SYSTEM: bit(8n), + LIST_USERS: bit(9n), + LIST_PROJECTS: bit(10n), + LIST_ROLES: bit(11n), + LIST_CLUSTERS: bit(12n), + LIST_ZONES: bit(13n), + LIST_STAGES: bit(14n), + LIST_SYSTEM: bit(15n), + MANAGE_ADMIN_TOKEN: bit(16n), + LIST_ADMIN_TOKEN: bit(17n), +} + +export type ProjectPermsKeys = keyof typeof PROJECT_PERMS +export type AdminPermsKeys = keyof typeof ADMIN_PERMS + +permissionsParser(ADMIN_PERMS) +permissionsParser(PROJECT_PERMS) + +interface ProjectAuthorizedParams { adminPermissions?: bigint | string | null, projectPermissions?: bigint | string } + +export const toBigInt = (value?: bigint | number | string | undefined | null) => value ? BigInt(value) : 0n + +export const AdminAuthorized = { + Manage: (perms?: bigint | string | null) => !!(toBigInt(perms) & ADMIN_PERMS.MANAGE), + ManageUsers: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_USERS | ADMIN_PERMS.MANAGE)), + ManageProjects: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_PROJECTS | ADMIN_PERMS.MANAGE)), + ManageRoles: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ROLES | ADMIN_PERMS.MANAGE)), + ManageClusters: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_CLUSTERS | ADMIN_PERMS.MANAGE)), + ManageZones: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ZONES | ADMIN_PERMS.MANAGE)), + ManageStages: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_STAGES | ADMIN_PERMS.MANAGE)), + ManageSystem: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_SYSTEM | ADMIN_PERMS.MANAGE)), + ManageAdminToken: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ADMIN_TOKEN | ADMIN_PERMS.MANAGE)), + ListUsers: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_USERS | ADMIN_PERMS.MANAGE_USERS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListProjects: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_PROJECTS | ADMIN_PERMS.MANAGE_PROJECTS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListRoles: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ROLES | ADMIN_PERMS.MANAGE_ROLES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListClusters: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_CLUSTERS | ADMIN_PERMS.MANAGE_CLUSTERS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListZones: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ZONES | ADMIN_PERMS.MANAGE_ZONES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListStages: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_STAGES | ADMIN_PERMS.MANAGE_STAGES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListSystem: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_SYSTEM | ADMIN_PERMS.MANAGE_SYSTEM | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListAdminToken: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ADMIN_TOKEN | ADMIN_PERMS.MANAGE_ADMIN_TOKEN | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), +} as const + +export const ProjectAuthorized = { + Manage: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & PROJECT_PERMS.MANAGE), + + ListEnvironments: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE)), + ListRepositories: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_REPOSITORIES | PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.MANAGE)), + ListMembers: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_MEMBERS | PROJECT_PERMS.MANAGE_MEMBERS | PROJECT_PERMS.MANAGE)), + ListRoles: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_ROLES | PROJECT_PERMS.MANAGE_ROLES | PROJECT_PERMS.MANAGE)), + + ManageEnvironments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE)), + ManageRepositories: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.MANAGE)), + + ManageMembers: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_MEMBERS | PROJECT_PERMS.MANAGE)), + + ManageRoles: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_ROLES | PROJECT_PERMS.MANAGE)), + + ReplayHooks: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.MANAGE)), + + SeeSecrets: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.MANAGE)), + ManageDeployments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_DEPLOYMENTS | PROJECT_PERMS.MANAGE)), + ListDeployments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_DEPLOYMENTS | PROJECT_PERMS.MANAGE)), +} as const + +interface ScopePerm { + name: string + perms: Array<{ key: T, label: string, hint?: string }> +} +type PermDetails = Array> + +export const projectPermsDetails: PermDetails = [{ + name: 'Projet', + perms: [{ + key: 'MANAGE', + label: 'Gérer le projet', + hint: 'Permet de gérer tout le projet et ses ressources associées', + }, { + key: 'MANAGE_ROLES', + label: 'Gérer les rôles du projet', + hint: 'ATTENTION : Ce rôle inclut une élévation de privilège ! Permet de gérer les rôles du projet et les membres associés', + }, { + key: 'MANAGE_MEMBERS', + label: 'Gérer les membres du projet', + hint: 'Permet d\'inviter des utilisateurs et de les retirer', + }, { + key: 'LIST_ROLES', + label: 'Voir les rôles du projet', + hint: 'Permet de visualiser les rôles du projet', + }, { + key: 'LIST_MEMBERS', + label: 'Voir les membres du projet', + hint: 'Permet de visualiser les membres du projet', + }, { + key: 'SEE_SECRETS', + label: 'Afficher les secrets', + hint: 'Permet d\'afficher les secrets générés par les services', + }, { + key: 'REPLAY_HOOKS', + label: 'Reprovisionner le projet', + hint: 'Permet de lancer un reprovisionnage du projet', + }], +}, { + name: 'Environnement', + perms: [ + { + key: 'MANAGE_ENVIRONMENTS', + label: 'Gérer les environnements', + hint: 'Permet de créer, éditer, supprimer des environnements', + }, + { + key: 'LIST_ENVIRONMENTS', + label: 'Voir les environnements', + hint: 'Permet de visualiser tous les environnements et leurs configurations', + }, + ], +}, { + name: 'Dépôt', + perms: [ + { + key: 'MANAGE_REPOSITORIES', + label: 'Gérer les dépots', + hint: 'Permet de créer, éditer, supprimer des dépôts', + }, + { + key: 'LIST_REPOSITORIES', + label: 'Voir les dépôts', + hint: 'Permet de visualiser tous les dépôts et leurs configurations', + }, + ], +}, { + name: 'Déploiements', + perms: [ + { + key: 'MANAGE_DEPLOYMENTS', + label: 'Gérer les déploiements', + hint: 'Permet de créer, éditer, supprimer des déploiements', + }, + { + key: 'LIST_DEPLOYMENTS', + label: 'Voir les déploiements', + hint: 'Permet de visualiser tous les déploiements et leurs configurations', + }, + ], +}] as const + +export const adminPermsDetails: PermDetails = [{ + name: 'Global', + perms: [{ + key: 'MANAGE', + label: 'Administration globale', + hint: 'Administration globale de toute la console et de ses ressources', + }, { + key: 'LIST', + label: 'Lecture seule globale', + hint: 'Accès en lecture seule à toute la console et ses ressources', + }], +}, { + name: 'Gestion des utilisateurs', + perms: [{ + key: 'MANAGE_USERS', + label: 'Gérer les utilisateurs', + hint: 'Permet de gérer les utilisateurs de la console', + }, { + key: 'LIST_USERS', + label: 'Voir les utilisateurs', + hint: 'Permet de voir les utilisateurs de la console', + }], +}, { + name: 'Gestion des projets', + perms: [{ + key: 'MANAGE_PROJECTS', + label: 'Gérer les projets', + hint: 'Permet de gérer les projets de la console', + }, { + key: 'LIST_PROJECTS', + label: 'Voir les projets', + hint: 'Permet de voir les projets de la console', + }], +}, { + name: 'Gestion des rôles', + perms: [{ + key: 'MANAGE_ROLES', + label: 'Gérer les rôles', + hint: 'Permet de gérer les rôles de la console', + }, { + key: 'LIST_ROLES', + label: 'Voir les rôles', + hint: 'Permet de voir les rôles de la console', + }], +}, { + name: 'Infrastructure', + perms: [{ + key: 'MANAGE_CLUSTERS', + label: 'Gérer les clusters', + hint: 'Permet de gérer les clusters de la console', + }, { + key: 'LIST_CLUSTERS', + label: 'Voir les clusters', + hint: 'Permet de voir les clusters de la console', + }, { + key: 'MANAGE_ZONES', + label: 'Gérer les zones', + hint: 'Permet de gérer les zones de la console', + }, { + key: 'LIST_ZONES', + label: 'Voir les zones', + hint: 'Permet de voir les zones de la console', + }, { + key: 'MANAGE_STAGES', + label: 'Gérer les types d\'environnement', + hint: 'Permet de gérer les types d\'environnement de la console', + }, { + key: 'LIST_STAGES', + label: 'Voir les types d\'environnement', + hint: 'Permet de voir les types d\'environnement de la console', + }], +}, { + name: 'Système', + perms: [{ + key: 'MANAGE_SYSTEM', + label: 'Gérer le système', + hint: 'Permet de gérer les configurations et logs du système', + }, { + key: 'LIST_SYSTEM', + label: 'Voir le système', + hint: 'Permet de voir les configurations et logs du système', + }], +}, { + name: 'Jetons d’API', + perms: [{ + key: 'MANAGE_ADMIN_TOKEN', + label: 'Gérer les jetons d’API', + hint: 'Permet de créer et révoquer des jetons d’API admin', + }, { + key: 'LIST_ADMIN_TOKEN', + label: 'Voir les jetons d’API', + hint: 'Permet de lister les jetons d’API admin', + }], +}] as const + +export function getAdminPermLabelsByValue(value: bigint | string) { + value = typeof value === 'bigint' ? value : BigInt(value) + return adminPermsDetails + .map(section => section.perms) + .flat() + .filter(permDetail => ADMIN_PERMS[permDetail.key] & value) + .map(permDetail => permDetail.label) +} + +export function getProjectPermLabelsByValue(value: bigint | string) { + value = typeof value === 'bigint' ? value : BigInt(value) + return projectPermsDetails + .map(section => section.perms) + .flat() + .filter(permDetail => PROJECT_PERMS[permDetail.key] & value) + .map(permDetail => permDetail.label) +} diff --git a/.jjconflict-side-0/packages/shared/src/utils/plugins.ts b/.jjconflict-side-0/packages/shared/src/utils/plugins.ts new file mode 100644 index 0000000000..fd907ad899 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/utils/plugins.ts @@ -0,0 +1,42 @@ +export enum MonitorStatus { + OK = 'OK', + WARNING = 'Dégradé', + ERROR = 'En échec', + UNKNOW = 'Inconnu', +} + +export interface MonitorInfos { + lastUpdateTimestamp: number + interval: number + message: string + status: MonitorStatus + cause?: any // should be deserved to admins only +} +export class Monitor { + private intervalTime: number + public monitorFn: (instance: any) => Promise + private intervalID: NodeJS.Timeout | undefined + public lastStatus: MonitorInfos + + constructor(callback: (instance: Monitor) => Promise, interval: number = 5 * 60 * 1000) { + this.intervalTime = interval + this.monitorFn = () => { + this.lastStatus.cause = undefined + return callback(this) + } + this.lastStatus = { + interval: this.intervalTime, + lastUpdateTimestamp: Date.now(), + message: 'En attente d\'une première vérification', + status: MonitorStatus.UNKNOW, + cause: 'App just started', + } + } + + async refresh() { + if (this.intervalID) + clearInterval(this.intervalID) + this.intervalID = setInterval(() => this.monitorFn(this), this.intervalTime) + return this.monitorFn(this) + } +} diff --git a/.jjconflict-side-0/packages/shared/src/utils/roles.spec.ts b/.jjconflict-side-0/packages/shared/src/utils/roles.spec.ts new file mode 100644 index 0000000000..1124f52e69 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/utils/roles.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { + generateSystemRoleType, + getBaseRoleType, + isExternalRoleType, + isGlobalRoleType, + isManagedRoleType, + isSystemRoleType, +} from './roles.js' + +describe('roles', () => { + describe('generateSystemRoleType', () => { + it('prefixes a base type with system:', () => { + expect(generateSystemRoleType('external')).toBe('system:external') + }) + + it('returns system:undefined for undefined input', () => { + expect(generateSystemRoleType(undefined)).toBe('system:undefined') + }) + + it('returns system:null for null input', () => { + expect(generateSystemRoleType(null)).toBe('system:null') + }) + }) + + describe('isSystemRoleType', () => { + it('returns true for system: prefixed values', () => { + expect(isSystemRoleType('system:external')).toBe(true) + expect(isSystemRoleType('system:managed')).toBe(true) + }) + + it('returns false for non-system values', () => { + expect(isSystemRoleType('external')).toBe(false) + expect(isSystemRoleType('managed')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isSystemRoleType(null)).toBe(false) + expect(isSystemRoleType(undefined)).toBe(false) + }) + }) + + describe('getBaseRoleType', () => { + it('strips system: prefix', () => { + expect(getBaseRoleType('system:external')).toBe('external') + expect(getBaseRoleType('system:managed')).toBe('managed') + }) + + it('returns non-system values as-is', () => { + expect(getBaseRoleType('external')).toBe('external') + expect(getBaseRoleType('managed')).toBe('managed') + }) + + it('returns undefined for null or undefined', () => { + expect(getBaseRoleType(null)).toBeUndefined() + expect(getBaseRoleType(undefined)).toBeUndefined() + }) + }) + + describe('isExternalRoleType', () => { + it('returns true for system:external', () => { + expect(isExternalRoleType('system:external')).toBe(true) + }) + + it('returns true for bare external', () => { + expect(isExternalRoleType('external')).toBe(true) + }) + + it('returns false for other system types', () => { + expect(isExternalRoleType('system:managed')).toBe(false) + expect(isExternalRoleType('system:global')).toBe(false) + }) + + it('returns false for non-external types', () => { + expect(isExternalRoleType('managed')).toBe(false) + expect(isExternalRoleType('global')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isExternalRoleType(null)).toBe(false) + expect(isExternalRoleType(undefined)).toBe(false) + }) + }) + + describe('isManagedRoleType', () => { + it('returns true for system:managed', () => { + expect(isManagedRoleType('system:managed')).toBe(true) + }) + + it('returns true for bare managed', () => { + expect(isManagedRoleType('managed')).toBe(true) + }) + + it('returns false for other types', () => { + expect(isManagedRoleType('external')).toBe(false) + expect(isManagedRoleType('global')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isManagedRoleType(null)).toBe(false) + expect(isManagedRoleType(undefined)).toBe(false) + }) + }) + + describe('isGlobalRoleType', () => { + it('returns true for system:global', () => { + expect(isGlobalRoleType('system:global')).toBe(true) + }) + + it('returns true for bare global', () => { + expect(isGlobalRoleType('global')).toBe(true) + }) + + it('returns false for other types', () => { + expect(isGlobalRoleType('external')).toBe(false) + expect(isGlobalRoleType('managed')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isGlobalRoleType(null)).toBe(false) + expect(isGlobalRoleType(undefined)).toBe(false) + }) + }) +}) diff --git a/.jjconflict-side-0/packages/shared/src/utils/roles.ts b/.jjconflict-side-0/packages/shared/src/utils/roles.ts new file mode 100644 index 0000000000..3512475e8e --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/utils/roles.ts @@ -0,0 +1,26 @@ +export const SYSTEM_ROLE_TYPE = 'system' as const + +export function generateSystemRoleType(type: string | null | undefined) { + return `${SYSTEM_ROLE_TYPE}:${type}` +} + +export function isSystemRoleType(type: string | null | undefined) { + return !!type?.startsWith(`${SYSTEM_ROLE_TYPE}:`) +} + +export function getBaseRoleType(type: string | null | undefined) { + if (!type) return undefined + return isSystemRoleType(type) ? type.slice(`${SYSTEM_ROLE_TYPE}:`.length) : type +} + +export function isManagedRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'managed' +} + +export function isGlobalRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'global' +} + +export function isExternalRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'external' +} diff --git a/.jjconflict-side-0/packages/shared/src/utils/schemas.spec.ts b/.jjconflict-side-0/packages/shared/src/utils/schemas.spec.ts new file mode 100644 index 0000000000..5f0c422a13 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/utils/schemas.spec.ts @@ -0,0 +1,349 @@ +import type { Log, ProjectV2 } from '../index.js' +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { ZodError } from 'zod' +import { ClusterDetailsSchema, ClusterPrivacy, ClusterUsageSchema, descriptionMaxLength, EnvironmentSchema, instanciateSchema, LogSchema, parseZodError, ProjectSchemaV2, RepoSchema, StageSchema, UserSchema } from '../index.js' + +describe('schemas utils', () => { + it('should delete config in log', () => { + const toParse: Log = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + action: 'Create a log', + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + data: { + args: {}, + failed: false, + config: {}, + }, + requestId: faker.string.uuid(), + userId: faker.string.uuid(), + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + // @ts-ignore + delete parsed.data.config + expect(LogSchema + .safeParse(toParse)) + .toStrictEqual({ data: parsed, success: true }) + }) + + it('should not validate an undefined object', () => { + // @ts-ignore + expect(RepoSchema.safeParse(undefined).error).toBeInstanceOf(ZodError) + }) + + it('should validate a correct repository schema', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + externalToken: 'eddddsqsq-_', + isPrivate: true, + isInfra: false, + externalUserName: 'clai+re-nlet_', + } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a correct environment schema', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + projectId: faker.string.uuid(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + } + + expect(EnvironmentSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a correct project schema', () => { + const toParse: ProjectV2 = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + slug: faker.lorem.word({ length: { min: 2, max: 10 } }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + description: '', + status: 'created', + locked: false, + clusterIds: [], + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + members: [], + owner: { + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + email: 'invalid-email@mais-pas-grave', + firstName: faker.person.firstName(), + id: faker.string.uuid(), + lastName: faker.person.lastName(), + type: 'human', + }, + everyonePerms: '1', + ownerId: faker.string.uuid(), + roles: [], + lastSuccessProvisionningVersion: null, + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + // @ts-ignore + parsed.owner.updatedAt = parsed.owner.updatedAt.toISOString() + // @ts-ignore + parsed.owner.createdAt = parsed.owner.createdAt.toISOString() + expect(ProjectSchemaV2.safeParse(toParse)).toStrictEqual({ data: parsed, success: true }) + }) + + it('should validate a correct user schema', () => { + const toParse = { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + updatedAt: new Date(), + createdAt: new Date(), + type: 'human', + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + expect(UserSchema.safeParse(toParse)).toStrictEqual({ data: parsed, success: true }) + }) + + it('should validate a correct stage schema', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + clusterIds: [], + } + + expect(StageSchema.safeParse(toParse)).toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a repo business schema', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + externalUserName: 'clairenlet', + externalToken: 'myToken', + isPrivate: true, + isInfra: false, + } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a public cluster details schema', () => { + const toParse = { + id: faker.string.uuid(), + label: 'cluster', + clusterResources: true, + privacy: ClusterPrivacy.PUBLIC, + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + zoneId: faker.string.uuid(), + stageIds: [faker.string.uuid(), faker.string.uuid()], + kubeconfig: { + user: {}, + cluster: { + tlsServerName: 'blabla', + }, + }, + } + + expect(ClusterDetailsSchema + .safeParse(toParse)) + .toStrictEqual({ data: { ...toParse, infos: '' }, success: true }) + }) + + it('should validate a cluster usage schema', () => { + const toParse = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + } + + expect(ClusterUsageSchema + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a repository schema with wrong internal repo name', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: '-candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + isPrivate: false, + isInfra: false, + } + + // @ts-ignore + expect(parseZodError(RepoSchema + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candilib-' + + // @ts-ignore + expect(parseZodError(RepoSchema + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candiLib' + + // @ts-ignore + expect(parseZodError(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candi-lib' + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a too short project name', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.string.alpha({ casing: 'lower' }), + status: 'created', + locked: false, + description: '', + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + // @ts-ignore + expect(parseZodError(ProjectSchemaV2 + .safeParse(toParse) + .error)) + .toMatch('Validation error: String must contain at least 2 character(s) at "name"') + }) + + it('should not validate a too long project name', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.string.alpha({ length: 24, casing: 'lower' }), + status: 'created', + locked: false, + description: '', + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + // @ts-ignore + expect(parseZodError(ProjectSchemaV2 + .safeParse(toParse) + .error)) + .toMatch('Validation error: String must contain at most 20 character(s) at "name"') + }) + + it('should not validate a too long project description', () => { + const toParse = { + id: faker.string.uuid(), + name: 'candilib', + description: faker.string.alpha(descriptionMaxLength + 1), + status: 'created', + locked: false, + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + expect(ProjectSchemaV2 + .safeParse(toParse) + .error).toBeInstanceOf(ZodError) + }) + + it('should validate a single key with given schema', () => { + const toParse = { internalRepoName: 'candilib' } + + expect(RepoSchema + .pick({ internalRepoName: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a single key with given schema', () => { + const toParse = { internalRepoName: 'candi lib' } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .pick({ internalRepoName: true }) + .safeParse(toParse) + // @ts-ignore + .error).toBeInstanceOf(ZodError) + }) + + it('should return truthy schema', () => { + expect(instanciateSchema(RepoSchema.omit({ id: true }), true)).toMatchObject({ + internalRepoName: true, + externalRepoUrl: true, + externalToken: true, + isPrivate: true, + isInfra: true, + externalUserName: true, + projectId: true, + }) + }) + + it('should return true schema', () => { + expect(instanciateSchema(RepoSchema.omit({ id: true }), true)).toMatchObject({ + internalRepoName: true, + externalRepoUrl: true, + externalToken: true, + isPrivate: true, + isInfra: true, + externalUserName: true, + projectId: true, + }) + }) +}) diff --git a/.jjconflict-side-0/packages/shared/src/utils/schemas.ts b/.jjconflict-side-0/packages/shared/src/utils/schemas.ts new file mode 100644 index 0000000000..7330fa943e --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/utils/schemas.ts @@ -0,0 +1,20 @@ +// import Joi from 'joi' +import type { SafeParseReturnType, ZodError, ZodObject } from 'zod' +import { fromZodError } from 'zod-validation-error' + +export type SharedZodError = ZodError +export type SharedSafeParseReturnType = SafeParseReturnType +export const parseZodError = (zodError: ZodError) => fromZodError(zodError).toString() + +export function instanciateSchema, V extends boolean>(schema: T, value: V): Record { + const keys = schema.keyof()._def.values + // @ts-ignore + if (keys.length) { + // @ts-ignore + const entries = schema.keyof()._def.values?.map(key => [key, value]) + // @ts-ignore + return Object.fromEntries(entries) + } + // @ts-ignore + return {} +} diff --git a/.jjconflict-side-0/packages/shared/src/utils/types.ts b/.jjconflict-side-0/packages/shared/src/utils/types.ts new file mode 100644 index 0000000000..babed97bf2 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/src/utils/types.ts @@ -0,0 +1,21 @@ +export type ErrorTypes = 'info' | 'warning' | 'error' | 'success' + +export interface UserProfile { + email: string + id: string + firstName: string + lastName: string + groups: string[] +} + +export type ResourceById = Record + +export type ResourceByKey = Record + +export type Without = { [P in Exclude]?: never } + +export type XOR + = T extends object + ? U extends object + ? (Without & U) | (Without & T) + : U : T diff --git a/.jjconflict-side-0/packages/shared/tsconfig.eslint.json b/.jjconflict-side-0/packages/shared/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-0/packages/shared/tsconfig.json b/.jjconflict-side-0/packages/shared/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-0/packages/shared/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-0/packages/shared/vitest.config.ts b/.jjconflict-side-0/packages/shared/vitest.config.ts new file mode 100644 index 0000000000..fda276425b --- /dev/null +++ b/.jjconflict-side-0/packages/shared/vitest.config.ts @@ -0,0 +1,16 @@ +import { configDefaults, defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: ['**/types', '**/mocks', '**/*.spec.ts', '**/*.d.ts'], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + }, +}) diff --git a/.jjconflict-side-0/packages/test-utils/.gitignore b/.jjconflict-side-0/packages/test-utils/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-0/packages/test-utils/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-0/packages/test-utils/eslint.config.js b/.jjconflict-side-0/packages/test-utils/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-0/packages/test-utils/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-0/packages/test-utils/package.json b/.jjconflict-side-0/packages/test-utils/package.json new file mode 100644 index 0000000000..10d292744f --- /dev/null +++ b/.jjconflict-side-0/packages/test-utils/package.json @@ -0,0 +1,42 @@ +{ + "name": "@cpn-console/test-utils", + "type": "module", + "version": "1.0.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test:e2e-ci": "echo 'check cache'" + }, + "dependencies": { + "@cpn-console/shared": "workspace:^", + "@faker-js/faker": "catalog:test" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "rimraf": "catalog:tools", + "typescript": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/packages/test-utils/src/imports/data.ts b/.jjconflict-side-0/packages/test-utils/src/imports/data.ts new file mode 100644 index 0000000000..e7bc172877 --- /dev/null +++ b/.jjconflict-side-0/packages/test-utils/src/imports/data.ts @@ -0,0 +1,2305 @@ +export const data = { + personalAccessToken: [], + adminToken: [ + { + id: 'e66a5ce2-440d-4a60-ac0d-d439bbaba040', + name: 'test', + hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', // test + permissions: '2n', + userId: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + }, + { + id: 'e66a5ce2-440d-4a60-ac0d-d439bbaba041', + name: 'test', + hash: '4bb47f186df233e48b09d241ee4defb821add0c35ac8311469fe1522c6813dd5', // revoked + permissions: '2n', + userId: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + status: 'revoked', + }, + ], + adminPlugin: [], + adminRole: [ + { + id: '76229c96-4716-45bc-99da-00498ec9018c', + permissions: '3n', + position: 0, + oidcGroup: '/admin', + name: 'Root Administrateur Plateforme', + type: 'system:external', + }, + { + id: '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', + permissions: '3n', + position: 0, + oidcGroup: '/console/admin', + name: 'Administrateur Plateforme', + type: 'system:managed', + }, + { + id: 'eadf604f-5f54-4744-bdfb-4793d2271e9b', + permissions: '2n', + position: 1, + oidcGroup: '', + name: 'Admin Locaux', + type: 'external', + }, + { + id: '35848aa2-e881-4770-9844-0c5c3693e506', + permissions: '1n', + position: 2, + oidcGroup: '/console/readonly', + name: 'Lecture Seule Plateforme', + type: 'system:managed', + }, + { + id: '034f589f-1750-4b15-bb34-4cd995e7fcaa', + permissions: '25608n', + position: 0, + oidcGroup: '', + name: 'Tout le monde', + type: 'global', + }, + ], + kubeconfig: [ + { + id: '2a88634a-0a60-459c-bf68-c4ffb12430a2', + user: { + keyData: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=', + certData: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + }, + cluster: { + server: 'https://public.server:6443', + skipTLSVerify: true, + tlsServerName: 'public.server', + }, + createdAt: '2024-07-24T16:54:14.961Z', + updatedAt: '2024-07-24T16:54:14.961Z', + }, + { + id: '2a88634a-0a60-459c-bf68-c4ffb12430a3', + user: { + keyData: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=', + certData: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + }, + cluster: { + server: 'https://nowhere.server:6443', + skipTLSVerify: true, + tlsServerName: 'nowhere.server', + }, + createdAt: '2024-07-24T16:54:14.964Z', + updatedAt: '2024-07-24T16:54:14.964Z', + }, + { + id: '0e88f000-07e6-4781-a69d-0963489387f7', + user: { + token: 'nyan cat', + }, + cluster: { + server: 'https://nothere.cluster', + skipTLSVerify: false, + tlsServerName: 'nothere.cluster', + }, + createdAt: '2024-07-24T16:54:14.966Z', + updatedAt: '2024-07-24T16:54:14.966Z', + }, + { + id: 'b5662039-a62b-483e-ba54-b12c6f966c96', + user: { + token: 'kirikou', + }, + cluster: { + server: 'https://pwned.cluster', + tlsServerName: 'pwned.cluster', + }, + createdAt: '2024-07-24T16:54:14.969Z', + updatedAt: '2024-07-24T16:54:14.969Z', + }, + ], + zone: [ + { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce1', + slug: 'pr', + label: 'Zone privée', + argocdUrl: 'https://argocd.private-zone.fr', + description: 'Cette zone de déploiement est privée.', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + }, + { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + label: 'publique', + argocdUrl: 'https://argocd.public-zone.fr', + description: 'Zone de diffusion ouverte.', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + }, + ], + cluster: [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + label: 'unused-cluster', + privacy: 'public', + secretName: '3972ac09-6abc-4e49-83b6-d046da5260ed', + clusterResources: false, + memory: 8, + cpu: 10, + gpu: 0, + kubeConfigId: '2a88634a-0a60-459c-bf68-c4ffb12430a3', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + infos: 'Cluster public non utilisé', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + label: 'top-secret-cluster', + privacy: 'dedicated', + secretName: '59be2d50-58f9-42f3-95dc-b0c0518e3d8a', + clusterResources: true, + memory: 8, + cpu: 10, + gpu: 0, + kubeConfigId: '0e88f000-07e6-4781-a69d-0963489387f7', + createdAt: '2023-07-10T19:49:31.691Z', + updatedAt: '2024-07-24T16:54:15.234Z', + infos: null, + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + label: 'pas-top-cluster', + privacy: 'dedicated', + secretName: '94d52618-7869-4192-b33e-85dd0959e815', + clusterResources: false, + memory: 30, + cpu: 20, + gpu: 0, + kubeConfigId: 'b5662039-a62b-483e-ba54-b12c6f966c96', + createdAt: '2023-07-10T19:49:31.697Z', + updatedAt: '2024-07-24T16:54:15.249Z', + infos: 'Floating IP : 0.0.0.0', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + label: 'public1', + privacy: 'public', + secretName: '3972ac09-6abc-4e49-83b6-d046da5260ec', + clusterResources: false, + memory: 100, + cpu: 100, + gpu: 100, + kubeConfigId: '2a88634a-0a60-459c-bf68-c4ffb12430a2', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2024-07-24T16:54:15.261Z', + infos: 'Cluster public proposé par DSO', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + ], + user: [ + { + id: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + firstName: 'Bot Admin', + lastName: 'test', + email: 'c7712841-c0fd-40ff-abbb-9d914bcc907d@bot.io', + createdAt: '2023-11-16T15:30:01.140Z', + updatedAt: '2023-11-16T15:30:01.140Z', + adminRoleIds: [], + type: 'bot', + lastLogin: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + { + id: '387216f1-3b87-4211-9cac-4371125e1175', + firstName: 'Admin', + lastName: 'ADMIN', + email: 'admin@test.com', + createdAt: '2023-07-03T18:01:52.884Z', + updatedAt: '2023-07-06T12:53:39.183Z', + adminRoleIds: [ + '76229c96-4716-45bc-99da-00498ec9018c', + ], + type: 'human', + }, + { + id: '04ac168a-2c4f-4816-9cce-af6c612e5912', + firstName: 'Anonymous', + lastName: 'User', + email: 'anon@user', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + type: 'ghost', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', + firstName: 'Arnaud', + lastName: 'TARDIF', + email: 'arnaud.tardif@test.com', + createdAt: '2023-07-03T14:46:56.773Z', + updatedAt: '2023-07-03T14:46:56.773Z', + adminRoleIds: [], + type: 'human', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', + email: 'test@test.com', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + type: 'human', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + firstName: 'Thibault', + lastName: 'COLIN', + email: 'thibault.colin@test.com', + createdAt: '2023-07-03T14:46:56.772Z', + updatedAt: '2024-07-25T16:18:11.372Z', + type: 'human', + adminRoleIds: [ + 'eadf604f-5f54-4744-bdfb-4793d2271e9b', + ], + }, + ], + log: [ + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1220', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + environment: 'staging', + registryHost: 'blabla.com', + repositories: [], + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'Create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.809Z', + requestId: null, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1221', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + environment: 'staging', + registryHost: 'blabla.com', + repositories: [], + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.819Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1222', + data: { + argo: { + status: { + result: 'OK', + message: 'Not an infra repository', + }, + }, + args: { + id: 'bd934af0-6de2-41b2-a111-6b0c45b82384', + status: 'initializing', + isInfra: false, + project: 'int-2', + createdAt: '2023-06-08T15:15:56.692Z', + isPrivate: false, + projectId: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + updatedAt: '2023-06-08T15:15:56.692Z', + environment: [], + internalUrl: 'https://blabla.com/bla/projects/mi/int-2/repo.git', + externalRepoUrl: 'https://github.com/dnum-mi/dso-console.git', + externalUserName: '', + internalRepoName: 'repo', + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 1, + }, + gitlab: { + vault: [ + { + data: { + GIT_INPUT_URL: 'github.com/dnum-mi/dso-console.git', + GIT_INPUT_USER: '', + GIT_OUTPUT_URL: 'blabla.com/bla/projects/mi/int-2/repo.git', + GIT_OUTPUT_USER: 'root', + GIT_INPUT_PASSWORD: '', + GIT_OUTPUT_PASSWORD: 'password', + }, + name: 'repo-mirror', + }, + ], + status: { + result: 'OK', + message: 'Created', + }, + }, + }, + action: 'Create Repository', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.819Z', + requestId: null, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1223', + data: { + argo: { + status: { + result: 'OK', + message: 'Not an infra repository', + }, + }, + args: { + id: 'bd934af0-6de2-41b2-a111-6b0c45b82384', + status: 'initializing', + isInfra: false, + project: 'int-2', + createdAt: '2023-06-08T15:15:56.692Z', + isPrivate: false, + projectId: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + updatedAt: '2023-06-08T15:15:56.692Z', + environment: [], + internalUrl: 'https://blabla.com/bla/projects/mi/int-2/repo.git', + externalRepoUrl: 'https://github.com/dnum-mi/dso-console.git', + externalUserName: '', + internalRepoName: 'repo', + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 1, + }, + gitlab: { + vault: [ + { + data: { + GIT_INPUT_URL: 'github.com/dnum-mi/dso-console.git', + GIT_INPUT_USER: '', + GIT_OUTPUT_URL: 'blabla.com/bla/projects/mi/int-2/repo.git', + GIT_OUTPUT_USER: 'root', + GIT_INPUT_PASSWORD: '', + GIT_OUTPUT_PASSWORD: 'password', + }, + name: 'repo-mirror', + }, + ], + status: { + result: 'OK', + message: 'Created', + }, + }, + }, + action: 'Create Repository', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1224', + data: { + args: { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + locked: true, + status: 'initializing', + project: 'int-2', + createdAt: '2023-06-08T15:14:53.517Z', + updatedAt: '2023-06-08T15:14:53.517Z', + description: '', + }, + nexus: { + user: { + roles: [ + 'mi-int-2-ID', + ], + source: 'default', + status: 'active', + userId: 'mi-int-2', + lastName: 'Luffy', + readOnly: false, + firstName: 'Monkey D.', + emailAddress: 'claire.nollet@interieur.gouv.fr', + externalRoles: [], + }, + vault: [ + { + data: { + NEXUS_PASSWORD: 'password', + NEXUS_USERNAME: 'mi-int-2', + }, + name: 'NEXUS', + }, + ], + status: { + result: 'OK', + message: 'User Created', + }, + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 4, + }, + keycloak: { + group: { + id: '4a767b5c-1bf4-43b9-8164-5da76ded49a0', + }, + status: { + result: 'OK', + }, + }, + registry: { + vault: [ + { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + name: 'REGISTRY', + }, + ], + result: { + robot: { + id: 70, + name: 'robot$mi-int-2+ci', + secret: 'token', + expires_at: -1, + creation_time: '2023-06-08T15:14:55.813Z', + }, + project: { + name: 'mi-int-2', + metadata: { + public: 'false', + }, + owner_id: 1, + owner_name: 'admin', + project_id: 63, + repo_count: 0, + update_time: '2023-06-08T15:14:55.363Z', + creation_time: '2023-06-08T15:14:55.363Z', + cve_allowlist: { + id: 65, + items: [], + project_id: 63, + update_time: '0001-01-01T00:00:00.000Z', + creation_time: '0001-01-01T00:00:00.000Z', + }, + current_user_role_id: 1, + current_user_role_ids: [ + 1, + ], + }, + }, + status: { + result: 'OK', + message: 'Created', + }, + }, + sonarqube: { + user: { + user: { + name: 'mi-int-2', + email: 'claire.nollet@interieur.gouv.fr', + local: true, + login: 'mi-int-2', + active: true, + scmAccounts: [], + }, + }, + vault: [ + { + data: { + SONAR_TOKEN: 'token', + SONAR_PASSWORD: 'password', + SONAR_USERNAME: 'mi-int-2', + }, + name: 'SONAR', + }, + ], + result: {}, + status: { + result: 'OK', + message: 'User Created', + }, + }, + }, + action: 'Create Environment', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1225', + data: { + args: { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + locked: false, + status: 'created', + project: 'int-2', + createdAt: '2023-06-08T15:14:53.517Z', + updatedAt: '2023-06-08T15:16:49.011Z', + description: '', + }, + nexus: { + status: { + result: 'OK', + message: 'User deleted', + }, + }, + vault: { + status: { + result: 'OK', + }, + secretsDestroyed: 5, + }, + gitlab: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + keycloak: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + registry: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + sonarqube: { + status: { + result: 'OK', + message: 'User anonymized', + }, + }, + }, + action: 'Delete Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1226', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1227', + data: { + argo: { + status: { + result: 'KO', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1228', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1229', + data: { + argo: { + status: { + result: 'KO', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1230', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1231', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1232', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1233', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1234', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1235', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1236', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1237', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1238', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1239', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1240', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1241', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + ], + project: [ + { + id: '22e7044f-8414-435d-9c4a-2df42a65034b', + name: 'betaapp', + slug: 'betaapp', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.814Z', + updatedAt: '2023-07-03T14:46:56.817Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '9dabf3f9-6c86-4358-8598-65007d78df65', + name: 'projecttoarchive', + slug: 'projecttoarchive', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.824Z', + updatedAt: '2023-07-03T14:46:56.830Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + name: 'candilib', + slug: 'candilib', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application de réservation de places à l\'examen du permis B.', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.778Z', + updatedAt: '2023-07-03T14:46:56.783Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '011e7860-04d7-461f-912d-334c622d38c5', + name: 'basegun', + slug: 'basegun', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application d\'aide à la catégorisation d\'armes à feu.', + status: 'created', + locked: false, + createdAt: '2023-07-10T14:46:56.778Z', + updatedAt: '2023-07-10T14:46:56.783Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + name: 'psijfailed', + slug: 'psijfailed', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application de transmission d\'informations entre agents de la PS et de l\'IJ.', + status: 'failed', + locked: true, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '8bd21636-e8ce-4098-8ed8-16cbd60af340', + name: 'projet dégradé', + slug: 'projet dégradé', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Projet testé en état dégradé', + status: 'warning', + locked: true, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '554d9150-9a07-42c1-8207-1163f2f0addd', + name: 'pissenlit', + slug: 'pissenlit', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + name: 'rolistes', + slug: 'rolistes', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '0n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + ], + stage: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + name: 'dev', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + name: 'staging', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + name: 'integration', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + name: 'prod', + }, + ], + environment: [ + { + id: 'bc06ace5-ddf6-4f00-97fa-872922baf078', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.826Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '95ef0d9b-945e-4af6-851c-4c6685ceff20', + name: 'staging', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.829Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: '8d4503eb-64c7-407e-89db-6ab80865071f', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.855Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '3b0cf6c1-251b-4ec6-926f-b54ce1f82560', + name: 'staging', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.859Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: '1b9f1053-fcf5-4053-a7b2-ff8a2c0c1921', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.787Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '1c654f00-4798-4a80-929f-960ddb37885a', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '1c654f00-4798-4a80-929f-960ddb36774b', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38c5', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '2805a1f5-0ca4-46a4-b3d7-5b649aee4a91', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + createdAt: '2023-07-03T14:46:56.808Z', + updatedAt: '2023-07-03T14:46:56.815Z', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '2805a1f5-0ca4-46a4-b3d7-5b649aee4a92', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + createdAt: '2023-07-03T15:56:56.808Z', + updatedAt: '2023-07-03T15:56:56.815Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + ], + projectClusterHistory: [ + { + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38c5', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + }, + { + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + }, + ], + projectMembers: [ + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f9599', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + roleIds: [], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f95b5', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f9595', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + roleIds: [], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + roleIds: [], + }, + ], + projectPlugin: [], + projectRole: [ + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f95b5', + name: 'bg75', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + permissions: '2n', + position: 0, + }, + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f9595', + name: 'bg78', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + permissions: '1n', + position: 0, + }, + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f9599', + name: 'level up', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + permissions: '0n', + position: 0, + }, + ], + repository: [ + { + id: '53891549-e628-4893-8bd3-92abcb71068a', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + internalRepoName: 'beta-front', + externalRepoUrl: 'https://github.com/dnum-mi/beta-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.825Z', + }, + { + id: '26ad0fee-c4bd-462c-a4f7-0f7a713b56f7', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + internalRepoName: 'beta-back', + externalRepoUrl: 'https://github.com/dnum-mi/beta-back.git', + externalUserName: '', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.825Z', + }, + { + id: '83aa3c2a-cdae-4638-b95f-a02b3f31eace', + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + internalRepoName: 'archive-front', + externalRepoUrl: 'https://github.com/dnum-mi/archive-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.857Z', + }, + { + id: 'ad77aa10-a708-4fce-8d0c-15c1b4704309', + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + internalRepoName: 'archive-back', + externalRepoUrl: 'https://github.com/dnum-mi/archive-back.git', + externalUserName: '', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.854Z', + }, + { + id: '299216bb-2dcc-42b5-ac71-6aa001d2dccf', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/dnum-mi/candilib.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.802Z', + }, + { + id: 'c26df1a3-2e03-420d-9894-cd9ce4f98b60', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-back', + externalRepoUrl: 'https://github.com/dnum-mi/psij-back.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.813Z', + }, + { + id: '78f2b122-13cc-406f-bac8-9f0947a23172', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-front', + externalRepoUrl: 'https://github.com/dnum-mi/psij-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '78f2b122-13cc-406f-bac8-9f0947b23172', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-infra', + externalRepoUrl: 'https://github.com/dnum-mi/psij-infra.git', + externalUserName: 'this-is-a-test', + deployRevision: 'main', + deployPath: 'helm/', + helmValuesFiles: 'values.yaml,values.prod.yaml', + isInfra: true, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '9d3e7791-ee67-4282-be95-613c491c2e75', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + internalRepoName: 'pissenlit', + externalRepoUrl: 'https://github.com/dnum-mi/pissenlit.git', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '9d3e7791-ee67-4282-be95-613c491c2e76', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + internalRepoName: 'warhammer', + externalRepoUrl: 'https://github.com/dnum-mi/warhammer.git', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T16:46:56.809Z', + updatedAt: '2023-07-03T16:46:56.816Z', + }, + ], + systemSetting: [ + { + key: 'maintenance', + value: 'off', + }, + + ], + associations: [ + [ + 'cluster', + [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + projects: [], + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + projects: [ + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + }, + ], + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + projects: [ + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + }, + { + id: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: '9dabf3f9-6c86-4358-8598-65007d78df65', + }, + ], + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + projects: [], + }, + ], + ], + [ + 'cluster', + [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + stages: [], + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + ], + ], + ], + deployment: [], + deploymentSource: [], +} diff --git a/.jjconflict-side-0/packages/test-utils/src/index.ts b/.jjconflict-side-0/packages/test-utils/src/index.ts new file mode 100644 index 0000000000..51d39c09b7 --- /dev/null +++ b/.jjconflict-side-0/packages/test-utils/src/index.ts @@ -0,0 +1,5 @@ +export * from './imports/data.js' +export * from './utils/func-utils.js' +export * from './utils/random-project.js' +export * from './utils/random-utils.js' +export * from './utils/types.js' diff --git a/.jjconflict-side-0/packages/test-utils/src/utils/func-utils.ts b/.jjconflict-side-0/packages/test-utils/src/utils/func-utils.ts new file mode 100644 index 0000000000..da5c99a8b4 --- /dev/null +++ b/.jjconflict-side-0/packages/test-utils/src/utils/func-utils.ts @@ -0,0 +1,6 @@ +import type { User } from '@cpn-console/shared' + +export const repeatFn = (nb: number) => (fn: (optionalParam?: any) => any, optionalParam?: any) => Array.from({ length: nb }).map(() => fn(optionalParam)) + +// transform an array of UserModel in object-like usersStore +export const toUsersStore = (users: Array>): Record> => users.reduce((store, user) => ({ ...store, [user.id]: user }), {}) diff --git a/.jjconflict-side-0/packages/test-utils/src/utils/random-project.ts b/.jjconflict-side-0/packages/test-utils/src/utils/random-project.ts new file mode 100644 index 0000000000..73dcdf40bb --- /dev/null +++ b/.jjconflict-side-0/packages/test-utils/src/utils/random-project.ts @@ -0,0 +1,77 @@ +import type { User } from './types.js' +import { projectRoles } from '@cpn-console/shared' +import { repeatFn } from './func-utils.js' +import { + getRandomCluster, + getRandomEnv, + getRandomPerm, + getRandomProject, + getRandomRepo, + getRandomRole, + getRandomStage, + getRandomUser, + getRandomZone, +} from './random-utils.js' + +const basicStages = ['dev', 'staging', 'integration', 'prod'] + +export function createRandomDbSetup({ nbUsers = 1, nbRepo = 3, envs = basicStages }) { + // Create users + const users: User[] = repeatFn(nbUsers)(getRandomUser) + + // Create project + const project = getRandomProject() + + // Create Roles association table + project.roles = users.map(user => ({ + ...getRandomRole(user.id, project.id), + user, + })) + project.roles[0].role = projectRoles[0] + const ownerId = project.roles.find(role => role.role === 'owner')?.userId + + // @ts-ignore + project.members = project.roles.map(({ userId, user: { id: _, ...user }, role }) => { + return { + userId, + role, + ...user, + } + }) + + // Create zone + const zones = [getRandomZone()] + + // Create cluster + const clusters = [getRandomCluster({ projectIds: [project.id], zoneId: zones[0].id })] + // @ts-ignore + project.clusters = clusters + + // Create stages + const stages = basicStages.map(baseEnvironment => getRandomStage(baseEnvironment)) + stages.forEach((stage) => { + // @ts-ignore + stage.clusters = project.clusters + }) + + // Create repositories + project.repositories = repeatFn(nbRepo)(getRandomRepo, project.id) + + // Create environment + project.environments = envs + .map(env => getRandomEnv(env, project.id, stages[0].id, clusters[0].id)) + + // Create permissions + project.environments.forEach((env) => { + env.permissions = users.map(user => + user.id === ownerId ? getRandomPerm(env.id, user, 2) : getRandomPerm(env.id, user), + ) + }) + + return { + users, + stages, + zones, + project, + } +} diff --git a/.jjconflict-side-0/packages/test-utils/src/utils/random-utils.ts b/.jjconflict-side-0/packages/test-utils/src/utils/random-utils.ts new file mode 100644 index 0000000000..3f78ccf346 --- /dev/null +++ b/.jjconflict-side-0/packages/test-utils/src/utils/random-utils.ts @@ -0,0 +1,158 @@ +import type { AchievedStatus, ClusterDetails, ProjectRoles, Stage, Zone } from '@cpn-console/shared' +import type { Cluster, Environment, Log, Member, Permission, Project, Repository, Role, User } from './types.js' +import { achievedStatus, ClusterPrivacy, logActions, projectRoles } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { repeatFn } from './func-utils.js' + +export function getRandomProjectName() { + return faker.lorem.word() +} + +export function getRandomGitUrl() { + const url = `${faker.internet.url().split('.')[0]}.git` + return url.startsWith('https://') ? url : `https://${url.split('://')[1]}` +} + +export function getRandomProject(): Project & { status: AchievedStatus, members?: Member[] } { + const name = getRandomProjectName() + return { + id: faker.string.uuid(), + name, + slug: `${name}-1`, + description: faker.lorem.sentence(8), + status: faker.helpers.arrayElement(achievedStatus), + locked: false, + updatedAt: (new Date()).toISOString(), + createdAt: (new Date()).toISOString(), + } +} + +export function getRandomZone(): Zone { + return { + id: faker.string.uuid(), + slug: faker.lorem.word({ length: { min: 1, max: 10 } }), + label: faker.lorem.word({ length: { min: 1, max: 50 } }), + argocdUrl: faker.internet.url(), + description: faker.lorem.sentence(8), + } +} + +export function getRandomCluster({ projectIds = repeatFn(2)(faker.string.uuid), stageIds = repeatFn(2)(faker.string.uuid), privacy = faker.helpers.arrayElement(Object.values(ClusterPrivacy)), zoneId = faker.string.uuid() }: +{ projectIds?: string[], stageIds?: string[], privacy?: ClusterPrivacy, zoneId?: string }): ClusterDetails { + return { + id: faker.string.uuid(), + label: faker.lorem.word(), + infos: faker.lorem.sentence(8), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + zoneId, + projectIds: privacy === ClusterPrivacy.DEDICATED ? projectIds : [], + stageIds, + kubeconfig: { + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + }, + privacy, + clusterResources: faker.datatype.boolean(), + } +} + +export function getRandomUser(id = faker.string.uuid()): User { + return { + id, + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + } +} + +export function getRandomRole(userId = faker.string.uuid(), projectId = faker.string.uuid(), role: ProjectRoles = projectRoles[1]): Role { + return { + userId, + role, + projectId, + } +} + +export function getRandomMember(userId = faker.string.uuid(), role: ProjectRoles = projectRoles[1]): Member { + return { + userId, + role, + } +} + +export function getRandomRepo(projectId = faker.string.uuid()): Repository { + const repo: Repository = { + id: faker.string.uuid(), + projectId, + internalRepoName: faker.lorem.word(), + externalRepoUrl: getRandomGitUrl(), + isPrivate: faker.datatype.boolean(), + isInfra: faker.datatype.boolean(), + } + if (repo.isPrivate) { + repo.externalUserName = faker.person.firstName() + repo.externalToken = faker.internet.password({ length: 25 }) + } + + return repo +} + +export function getRandomStage(name: string = faker.lorem.word({ length: { min: 2, max: 20 } }), links?: { + clusterIds?: string[] + clusters?: Cluster[] +}): Stage { + return { + id: faker.string.uuid(), + name, + clusterIds: links?.clusterIds ?? links?.clusters?.map(({ id }) => id) ?? [] as string[], + } +} + +export function getRandomEnv(name = faker.lorem.slug(1), projectId = faker.string.uuid(), stageId = faker.string.uuid(), clusterId = faker.string.uuid()): Environment { + return { + id: faker.string.uuid(), + name, + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + projectId, + stageId, + clusterId, + } +} + +export function getRandomPerm(environmentId = faker.string.uuid(), user = getRandomUser(), level = faker.number.int({ min: 0, max: 2 })): Permission { + return { + id: faker.string.uuid(), + environmentId, + userId: user.id, + level, + user, + } +} + +export function getRandomLog(action = faker.helpers.arrayElement(logActions), userId = faker.string.uuid()): Log { + return { + id: faker.string.uuid(), + action, + userId, + updatedAt: (new Date()).toISOString(), + createdAt: (new Date()).toISOString(), + data: { + args: {}, + failed: faker.datatype.boolean(), + results: {}, + totalExecutionTime: 1, + }, + requestId: null, + } +} diff --git a/.jjconflict-side-0/packages/test-utils/src/utils/types.ts b/.jjconflict-side-0/packages/test-utils/src/utils/types.ts new file mode 100644 index 0000000000..14eaf73bb8 --- /dev/null +++ b/.jjconflict-side-0/packages/test-utils/src/utils/types.ts @@ -0,0 +1,112 @@ +export interface Repository { + id: string + projectId: string + internalRepoName: string + externalRepoUrl: string + isPrivate: boolean + isInfra: boolean + createdAt?: Date + updatedAt?: Date + externalUserName?: string + externalToken?: string +} + +export interface User { + id: string + email: string + firstName: string + lastName: string + updatedAt?: Date + createdAt?: Date +} + +export interface Role { + userId: string + projectId: string + role: string + updatedAt?: Date + createdAt?: Date + user?: User +} + +export interface Member { + userId: User['id'] + role: string +} + +export interface Permission { + id: string + environmentId: string + userId: string + level: number + user: User + updatedAt?: Date + createdAt?: Date +} + +export interface Cluster { + id: string + label: string + infos?: string + projectIds?: string[] + stageIds?: string[] + zoneId?: string + user: { + certData: string + keyData: string + } + cluster: { + caData: string + server: string + tlsServerName: string + } + privacy: string + clusterResources: boolean + updatedAt?: Date + createdAt?: Date +} + +export interface Environment { + id: string + name: string + cpu: number + gpu: number + memory: number + projectId: string + clusterId: string + stageId: string + permissions?: Permission[] + cluster?: Cluster + updatedAt?: Date + createdAt?: Date +} + +export interface Log { + id: string + action: string + userId: string + createdAt: Date | string + updatedAt: Date | string + data: { + args: any + failed: boolean + results: any + totalExecutionTime: number + } + requestId: null +} + +export interface Project { + id: string + name: string + slug: string + description: string + status: string + locked: boolean + roles?: Role[] + clusters?: Cluster[] + repositories?: Repository[] + environments?: Environment[] + updatedAt?: Date | string + createdAt?: Date | string +} diff --git a/.jjconflict-side-0/packages/test-utils/tsconfig.eslint.json b/.jjconflict-side-0/packages/test-utils/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-0/packages/test-utils/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-0/packages/test-utils/tsconfig.json b/.jjconflict-side-0/packages/test-utils/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-0/packages/test-utils/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-0/packages/tsconfig/package.json b/.jjconflict-side-0/packages/tsconfig/package.json new file mode 100644 index 0000000000..f90b7b4aee --- /dev/null +++ b/.jjconflict-side-0/packages/tsconfig/package.json @@ -0,0 +1,16 @@ +{ + "name": "@cpn-console/ts-config", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "version": "1.1.0", + "private": false, + "description": "", + "main": "index.js", + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} \ No newline at end of file diff --git a/.jjconflict-side-0/packages/tsconfig/tsconfig.base.json b/.jjconflict-side-0/packages/tsconfig/tsconfig.base.json new file mode 100644 index 0000000000..6a08a00241 --- /dev/null +++ b/.jjconflict-side-0/packages/tsconfig/tsconfig.base.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + "jsx": "preserve", + "lib": ["ESNext"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + + // Base + "paths": { + "@/*": [ + "src/*" + ] + }, + "resolveJsonModule": true, + + "allowImportingTsExtensions": false, + "allowJs": true, + // Best practices + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + // Some stricter flags + "useUnknownInCatchVariables": false, + "declaration": true, + "emitDeclarationOnly": false, + // Output + "noEmit": false, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + } +} diff --git a/.jjconflict-side-0/pgadmin/servers-docker.json b/.jjconflict-side-0/pgadmin/servers-docker.json new file mode 100644 index 0000000000..d1710b3648 --- /dev/null +++ b/.jjconflict-side-0/pgadmin/servers-docker.json @@ -0,0 +1,22 @@ +{ + "Servers": { + "1": { + "Name": "dso-console-db", + "Group": "Servers", + "Host": "postgres", + "Port": 5432, + "MaintenanceDB": "dso-console-db", + "Username": "admin", + "SSLMode": "prefer", + "SSLCert": "/.postgresql/postgresql.crt", + "SSLKey": "/.postgresql/postgresql.key", + "SSLCompression": 0, + "BGColor": "#EEEDE7", + "FGColor": "#05445E", + "Timeout": 10, + "UseSSHTunnel": 0, + "TunnelPort": "22", + "TunnelAuthentication": 0 + } + } +} diff --git a/.jjconflict-side-0/pgadmin/servers-kube.json b/.jjconflict-side-0/pgadmin/servers-kube.json new file mode 100644 index 0000000000..4f16a15fae --- /dev/null +++ b/.jjconflict-side-0/pgadmin/servers-kube.json @@ -0,0 +1,22 @@ +{ + "Servers": { + "1": { + "Name": "dso-console-db", + "Group": "Servers", + "Host": "dso-cpn-console-db-hl", + "Port": 5432, + "MaintenanceDB": "dso-console-db", + "Username": "admin", + "SSLMode": "prefer", + "SSLCert": "/.postgresql/postgresql.crt", + "SSLKey": "/.postgresql/postgresql.key", + "SSLCompression": 0, + "BGColor": "#EEEDE7", + "FGColor": "#05445E", + "Timeout": 10, + "UseSSHTunnel": 0, + "TunnelPort": "22", + "TunnelAuthentication": 0 + } + } +} diff --git a/.jjconflict-side-0/playwright/README.md b/.jjconflict-side-0/playwright/README.md new file mode 100644 index 0000000000..d47e6602c9 --- /dev/null +++ b/.jjconflict-side-0/playwright/README.md @@ -0,0 +1,78 @@ +# À propos + +Ce dossier contient tout ce qui nous est nécessaire pour lancer les tests automatisés avec Playwright. + +Il peut s'agir de tests E2E, d'une brique en particulier, bref tout ce qui est actionnable à partir de l'interface utilisateur. + +## Installer Playwright + +Tout est déjà prévu dans le `pnpm install` que vous avez normalement déjà exécuté à la racine du MonoRepo. Si ce n'est pas le cas vous pouvez relancer la commande dans ce dossier. + +## Installer les dépendances (navigateurs, etc.) + +Afin de pouvoir lancer les tests sur différents navigateurs, Playwright a besoin d'un certain nombre de binaires. +Pour les installer localement il faut lancer la commande suivante : + +```shell +$ pnpm exec playwright install --with-deps +``` + +Note: Il est possible qu'il soit nécessaire que vous fassiez un `sudo` + +## Lancer les tests + +Il faut d'abord lancer une stack, par ex, celle de Dev: + +```shell +$ pnpm run docker:dev +``` +On peut lancer ensuite les tests : + +```shell +$ pnpm run playwright:test --grep @e2e +``` + +OU (autre possibilité), lancer la "console" Playwright pour parcourir/exécuter les tests manuellement : + +```shell +$ pnpm run playwright:test:ui --grep @e2e +``` + +Et pour les tests d'intégration, il faut au préalable exporter des variables d'environnement : + +```shell +$ export KEYCLOAK_DOMAIN=keycloak.example.com +$ export KEYCLOAK_REDIRECT_URI=https://console.example.com +$ export KEYCLOAK_PROTOCOL=https +$ export KEYCLOAK_ADMIN_USERNAME= +$ export KEYCLOAK_ADMIN_PASSWORD= +$ export KEYCLOAK_REALM=dso +$ export CONSOLE_ADMIN_USERNAME= +$ export CONSOLE_ADMIN_PASSWORD= +$ export CONSOLE_ADMIN_EMAIL= +$ export CONSOLE_TEST_USERNAME= +$ export CONSOLE_TEST_PASSWORD= +$ export CONSOLE_TEST_EMAIL= +$ export CONSOLE_SECOND_TEST_USERNAME= +$ export CONSOLE_SECOND_TEST_PASSWORD= +$ export CONSOLE_SECOND_TEST_EMAIL= +$ export CONSOLE_GLOBAL_TIMEOUT='900000' +$ export CONSOLE_EXPECT_TIMEOUT='900000' +$ export CONSOLE_DESTINATION_CLUSTER= +$ export CONSOLE_VALUES_FILE= +``` + +On peut lancer ensuite les tests : + +```shell +$ pnpm run playwright:test:integration --grep @integ +``` + +## Conventions de code + +Afin d'assurer la lisibilité et la maintenabilité des tests Playwright, quelques décisions ont été prises concernant l'écriture du code : +- Pas d'imbrication de if : les conditions sont autorisées dans les fonctions, mais ne doivent pas être imbriquées. +- Arguments explicites : certains arguments initialement optionnels doivent être rendus obligatoires afin de rendre le comportement de la fonction plus clair (par exemple pour les zones publiques, ou les contextes spécifiques). +- Visibilité des tests : il doit être évident, à la lecture du test, de voir ce qui est réellement vérifié. Par conséquent, évitez de masquer les expect à l'intérieur de fonctions utilitaires. Les assertions doivent apparaître directement dans le corps du test lorsqu'elles participent à la vérification d'un comportement. + +Ces conventions ont pour objectif de rendre les tests plus explicites, plus faciles à relire, et de réduire les effets de bord cachés. diff --git a/.jjconflict-side-0/playwright/config/console.ts b/.jjconflict-side-0/playwright/config/console.ts new file mode 100644 index 0000000000..35ea8a13c9 --- /dev/null +++ b/.jjconflict-side-0/playwright/config/console.ts @@ -0,0 +1,83 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +// Retrieve frontend URL from environment variables (see playwright.config.ts) +export const clientURL + = process.env.KEYCLOAK_REDIRECT_URI || 'http://change-me' + +export interface Credentials { + id: string + username: string + password: string + firstName: string + lastName: string + email: string +} + +// Users referenced in Keycloak dev realm (../keycloak/realms/realm-dev.json) +export const adminUser: Credentials = { + id: '387216f1-3b87-4211-9cac-4371125e1175', + username: process.env.CONSOLE_ADMIN_USERNAME?.trim() || 'admin', + password: process.env.CONSOLE_ADMIN_PASSWORD?.trim() || 'admin', + firstName: 'Admin', + lastName: 'ADMIN', + email: process.env.CONSOLE_ADMIN_EMAIL?.trim() || 'admin@test.com', +} +export const testUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + username: process.env.CONSOLE_TEST_USERNAME?.trim() || 'test', + password: process.env.CONSOLE_TEST_PASSWORD?.trim() || 'test', + firstName: 'Jean', + lastName: 'DUPOND', + email: process.env.CONSOLE_TEST_EMAIL?.trim() || 'test@test.com', +} +export const cnolletUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + username: 'cnollet', + password: 'test', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', +} +export const tcolinUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + username: 'tcolin', + password: 'test', + firstName: 'Thibault', + lastName: 'COLIN', + email: 'thibault.colin@test.com', +} + +// User for integration test +export const secondTestUser: Credentials = { + id: 'test', + username: process.env.CONSOLE_SECOND_TEST_USERNAME?.trim() || 'secondtestuser', + password: process.env.CONSOLE_SECOND_TEST_PASSWORD?.trim() || 'secondtestuser', + firstName: 'test', + lastName: 'test', + email: process.env.CONSOLE_SECOND_TEST_EMAIL?.trim() || 'secondtestuser@example.com', +} + +export const currentUser: Credentials = { + id: 'current', + username: process.env.CONSOLE_CURRENT_TEST_USERNAME?.trim() || 'currentuser', + password: process.env.CONSOLE_CURRENT_TEST_PASSWORD?.trim() || 'currentuser', + firstName: 'test', + lastName: 'test', + email: process.env.CONSOLE_CURRENT_TEST_EMAIL?.trim() || 'currentuser@example.com', +} + +export async function signInCloudPiNative({ + page, + credentials, +}: { + page: Page + credentials: Credentials +}) { + const { username, password } = credentials + await page.getByRole('link', { name: 'Se connecter' }).click() + await page.getByRole('textbox', { name: 'Username or email' }).fill(username) + await page.getByRole('textbox', { name: 'Password' }).fill(password) + await page.getByRole('button', { name: 'Sign In' }).click() + await expect(page.locator('#top')).toContainText('Cloud π Native') +} diff --git a/.jjconflict-side-0/playwright/config/keycloak.ts b/.jjconflict-side-0/playwright/config/keycloak.ts new file mode 100644 index 0000000000..e93138829a --- /dev/null +++ b/.jjconflict-side-0/playwright/config/keycloak.ts @@ -0,0 +1,38 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +export interface KeycloakConfig { + url: string + realm: string + adminUser: string + adminPass: string + clientFrontend: string + clientBackend: string +} + +export function loadKeycloakConfig(): KeycloakConfig { + const protocol = process.env.KEYCLOAK_PROTOCOL || 'http' + const domain = process.env.KEYCLOAK_DOMAIN || 'localhost' + const port = process.env.KEYCLOAK_PORT ? `:${process.env.KEYCLOAK_PORT}` : '' + const url = `${protocol}://${domain}${port}` + + return { + url, + realm: process.env.KEYCLOAK_REALM?.trim() || 'dso', + adminUser: process.env.KEYCLOAK_ADMIN_USERNAME?.trim() || 'admin', + adminPass: process.env.KEYCLOAK_ADMIN_PASSWORD?.trim() || 'admin', + clientFrontend: process.env.KEYCLOAK_CLIENT_FRONTEND || 'dso-console-frontend', + clientBackend: process.env.KEYCLOAK_CLIENT_BACKEND || 'dso-console-backend', + } +} + +export async function signInKeycloak( + page: Page, +) { + const keycloakConfig = loadKeycloakConfig() + await page.goto(keycloakConfig.url) + await page.locator('#username').fill(keycloakConfig.adminUser) + await page.locator('#password').fill(keycloakConfig.adminPass) + await page.locator('#kc-login').click() + await expect(page.getByRole('link', { name: 'Logo' })).toBeVisible() +} diff --git a/.jjconflict-side-0/playwright/e2e-tests/admin-logs.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/admin-logs.spec.ts new file mode 100644 index 0000000000..c368f1d939 --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/admin-logs.spec.ts @@ -0,0 +1,75 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +const adminLogsUrlRe = /\/admin\/logs/ +const logCountRe = /Total\s*:\s*(\d+)/ + +async function goToAdminLogs(page: Page) { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationLogs').click() + await expect(page).toHaveURL(adminLogsUrlRe) + await expect(page.getByTestId('logCountInfo')).toBeVisible() +} + +test.describe('Administration logs', { tag: '@e2e' }, () => { + test('Should display logs list, logged in as admin', async ({ page }) => { + await goToAdminLogs(page) + + const logCountText = await page.getByTestId('logCountInfo').textContent() + expect(logCountText).toBeTruthy() + const total = Number((logCountText || '').match(logCountRe)?.[1] || 0) + + const positionInfo = page.getByTestId('positionInfo').first() + const seePreviousPageBtn = page.getByTestId('seePreviousPageBtn').first() + const seeFirstPageBtn = page.getByTestId('seeFirstPageBtn').first() + const seeNextPageBtn = page.getByTestId('seeNextPageBtn').first() + const seeLastPageBtn = page.getByTestId('seeLastPageBtn').first() + + await expect(positionInfo).toContainText('sur') + await expect(seePreviousPageBtn).toBeDisabled() + await expect(seeFirstPageBtn).toBeDisabled() + + const logs = page.locator('[data-testid$="-json"]') + await expect(logs).toHaveCount(Math.min(10, total)) + + if (total > 10) { + await expect(seeNextPageBtn).toBeEnabled() + await expect(seeLastPageBtn).toBeEnabled() + + const firstLogText = await logs.first().textContent() + await seeNextPageBtn.click() + + await expect(seePreviousPageBtn).toBeEnabled() + await expect(seeFirstPageBtn).toBeEnabled() + await expect(positionInfo).toContainText('sur') + await expect(logs.first()).not.toHaveText(firstLogText || '') + + await seeFirstPageBtn.click() + await expect(seePreviousPageBtn).toBeDisabled() + await expect(seeFirstPageBtn).toBeDisabled() + } else { + await expect(seeNextPageBtn).toBeDisabled() + await expect(seeLastPageBtn).toBeDisabled() + } + }) + + test('Should toggle compact logs display, logged in as admin', async ({ page }) => { + await goToAdminLogs(page) + + await expect(page.locator('h1')).toContainText( + 'Journaux des services associés à la chaîne DSO', + ) + + const jsonBoxes = page.locator('.json-box') + await expect(jsonBoxes.first()).toBeVisible() + + await page.getByTestId('showLogsBtn').click() + await expect(jsonBoxes).toHaveCount(0) + + await page.getByTestId('showLogsBtn').click() + await expect(jsonBoxes.first()).toBeVisible() + }) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/admin-stages.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/admin-stages.spec.ts new file mode 100644 index 0000000000..0964f9f100 --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/admin-stages.spec.ts @@ -0,0 +1,131 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative, testUser } from '../config/console' + +import { createProject } from '../helpers/project' +import { createStage, deleteStage } from '../helpers/stage' + +test.describe('Stages administration page', { tag: '@e2e' }, () => { + test('should display default stages list', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + for (const stageName of ['dev', 'integration', 'prod', 'staging']) { + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + } + }) + + test('should create a custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'all' }) + // Validation de la création + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Check stage availability in environment form + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = faker.string.alpha(10).toLowerCase() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page.getByLabel('Choix de la zone cible').selectOption({ label: 'publique' }) + await page.getByLabel('Type d\'environnement').selectOption({ label: stageName }) + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page.getByLabel('Choix du cluster cible').selectOption({ label: 'public1' }) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + }) + + test('should update a custom stage', async ({ page }) => { + // Create stage (1 cluster) + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'first' }) + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + // Update stage (adding 1 extra cluster) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.locator('h1')).toContainText(`Informations du type d\'environnement ${stageName}`) + await expect(page.getByTestId('addStageBtn')).not.toBeVisible() + await expect(page.getByTestId('updateStageBtn')).toBeVisible() + await expect(page.getByTestId('updateStageBtn')).toBeEnabled() + await expect(page.getByTestId('nameInput')).toBeDisabled() + await expect(page.getByTestId('nameInput')).toHaveValue(stageName) + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(1) + await page.locator('.fr-tag').first().click() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(2) + await page.getByTestId('updateStageBtn').click() + // Check stage (2 clusters) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.locator('h1')).toContainText(`Informations du type d\'environnement ${stageName}`) + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(2) + }) + + test('should not be able to create a stage with an existing name', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + // Try to create a stage "dev" + await expect(page.getByTestId('addStageLink')).toBeVisible() + await page.getByTestId('addStageLink').click() + await page.getByTestId('nameInput').fill('dev') + await page.getByTestId('addStageBtn').click() + await expect(page.getByTestId('snackbar').getByText('Un type d\'environnement portant ce nom existe déjà')).toBeVisible() + }) + + test('should delete a custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'first' }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Delete custom stage + await deleteStage({ page, stageName }) + await expect(page.getByTestId(`stageTile-${stageName}`)).not.toBeVisible() + }) + + test('should not delete a used custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'all' }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Use custom stage in an environment + await createProject({ page }) + const envName = faker.string.alpha(10).toLowerCase() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page.getByLabel('Choix de la zone cible').selectOption({ label: 'publique' }) + await page.getByLabel('Type d\'environnement').selectOption({ label: stageName }) + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page.getByLabel('Choix du cluster cible').selectOption({ label: 'public1' }) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Check custom stage in admin view + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.getByTestId('deleteStageZone')).not.toBeVisible() + await expect(page.getByTestId('associatedEnvironmentsZone')).toBeVisible() + await expect(page.getByTestId('associatedEnvironmentsZone')).toContainText('Le type d\'environnement ne peut être supprimé') + await expect(page.getByTestId('associatedEnvironmentsTable')).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable').getByText(envName), + ).toBeVisible() + }) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/admin-users.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/admin-users.spec.ts new file mode 100644 index 0000000000..82e3a15cb4 --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/admin-users.spec.ts @@ -0,0 +1,71 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { setCheckbox, unsetCheckbox } from 'helpers/checkbox' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +interface ApiUser { + id: string + email: string + firstName: string + lastName: string + type?: string +} + +const adminUsersUrlRe = /\/admin\/users/ +const listUsersUrlRe = /\/api\/v1\/users(?:\?|$)/ + +async function goToAdminUsers(page: Page) { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationUsers').click() + await expect(page).toHaveURL(adminUsersUrlRe) + await expect(page.getByTestId('tableAdministrationUsers')).toBeVisible() +} + +test.describe('Administration users', { tag: '@e2e' }, () => { + test('Should display admin users, logged in as admin', async ({ page }) => { + const usersResponse = page.waitForResponse((res) => { + return ( + res.request().method() === 'GET' + && listUsersUrlRe.test(res.url()) + && res.status() >= 200 + && res.status() < 300 + ) + }) + + await goToAdminUsers(page) + + const users = (await (await usersResponse).json()) as ApiUser[] + expect(users.length).toBeGreaterThan(0) + + for (const user of users) { + const row = page.getByTestId(`user-${user.id}`) + await expect(row).toContainText(user.email) + await expect(row).toContainText(user.lastName) + await expect(row).toContainText(user.firstName) + await expect(row).toContainText('202') + } + + await setCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersDisplayId')) + for (const user of users) { + await expect(page.getByTestId(`user-${user.id}`)).toContainText(user.id) + } + + const botUser = users.find(u => u.email.includes('anon@user')) ?? users.find(u => u.type && u.type !== 'human') + if (botUser) { + await expect(page.getByTestId(`user-${botUser.id}`)).toBeVisible() + await setCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersHideBots')) + await expect(page.getByTestId(`user-${botUser.id}`)).toHaveCount(0) + await unsetCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersHideBots')) + } + + const rows = page.getByTestId('tableAdministrationUsers').locator('tbody tr') + expect(await rows.count()).toBeGreaterThanOrEqual(users.length) + + const search = page.getByTestId('tableAdministrationUsersSearch') + await expect(search).toBeVisible() + await search.fill(botUser?.email ?? users[0].email) + await expect(page.getByTestId('tableAdministrationUsers').locator('tbody tr')).toHaveCount(1) + }) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/clusters.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/clusters.spec.ts new file mode 100644 index 0000000000..e7917af0d2 --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/clusters.spec.ts @@ -0,0 +1,235 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' +import { createCluster, deleteCluster } from '../helpers/cluster' +import { createEnvironment } from '../helpers/environment' +import { openClustersAdministration } from '../helpers/navigation' +import { createProject } from '../helpers/project' + +test.describe('Clusters page', () => { + test('should create a public cluster', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'first', + }) + // Validate + await expect(page.getByTestId(`clusterZone-${clusterName}`)).toContainText( + 'publique', + ) + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Public', + ) + }) + + test('should update a public cluster', { tag: '@e2e' }, async ({ page }) => { + const clusterName2 = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'public', + }) + // Update + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('labelInput')).toHaveValue(clusterName) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await page.getByTestId('labelInput').fill(clusterName2) + await expect(page.getByTestId('updateClusterBtn')).toBeVisible() + await expect(page.locator('#projects-select')).not.toBeVisible() + await expect(page.locator('#privacy-select option[selected]')).toHaveText( + 'public', + ) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('1') + await page.getByTestId('updateClusterBtn').click() + // Validate + await expect(page.getByTestId(`clusterResources-${clusterName2}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + }) + + test( + 'should create a dedicated cluster', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Validate + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Dédié', + ) + }, + ) + + test( + 'should associate a project to a dedicated cluster', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a dedicated project + const { name: projectName } = await createProject({ page }) + // Create the cluster + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Validate + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('updateClusterBtn')).toBeVisible() + await expect(page.locator('#projects-select')).toBeVisible() + await expect(page.locator('#privacy-select option[selected]')).toHaveText( + 'dedicated', + ) + // Associating specifically created project + await page.locator('#projects-select').click() + await expect( + page.locator('#projects-select .fr-tag--dismiss'), + ).toHaveCount(0) + await page + .getByTestId('choice-selector-search-projects-select') + .fill(projectName) + await page.locator('#projects-select .fr-tag').click() + await expect( + page.locator('#projects-select .fr-tag--dismiss'), + ).toHaveCount(1) + await page.getByTestId('updateClusterBtn').click() + // Validate + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Dédié', + ) + }, + ) + + test('should create a cluster even if given informations is longer than 200 chars but shorter than 1001 chars', async ({ + page, + }) => { + const informations = faker.string.alpha(1000).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + informations, + }) + // Validate + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('infosInput')).toHaveValue(informations) + }) + + test('should NOT create a cluster when given informations is longer than 1000 chars', async ({ + page, + }) => { + const clusterName = faker.string.alpha(10).toLowerCase() + const informations = faker.string.alpha(1001).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationClusters').click() + await page.getByTestId('addClusterLink').click() + await page.getByTestId('labelInput').fill(clusterName) + await page.getByTestId('infosInput').fill(informations) + await expect(page.getByTestId('addClusterBtn')).toBeDisabled() + }) + + test('should delete a cluster', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Delete + await deleteCluster({ page, clusterName }) + // Validate + await page.getByTestId('projectsSearchInput').fill(clusterName) + await expect(page.getByTestId('noClusterMsg')).toBeVisible() + await expect(page.getByTestId('noClusterMsg')).toHaveText( + 'Aucun cluster trouvé', + ) + }) + + test( + 'should NOT delete a cluster with associated environment', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a public cluster + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'all', + }) + // Create a project and an environment using this cluster + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: clusterName, + cpuInput: '2', + gpuInput: '0', + }) + // Try to delete the cluster + await expect(page.getByText('Opération en cours...')).toHaveCount(0) + await openClustersAdministration({ page }) + await page.getByTestId('projectsSearchInput').fill(clusterName) + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('deleteClusterZone')).not.toBeVisible() + await expect( + page.getByText('Le cluster ne peut être supprimé'), + ).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable'), + ).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable').getByText(envName), + ).toBeVisible() + }, + ) + + test('should NOT update the cluster label if it has associated environment', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a public cluster + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'all', + }) + // Create a project and an environment using this cluster + await createProject({ page }) + await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: clusterName, + cpuInput: '2', + gpuInput: '0', + }) + // Verify that cluster label is disabled + await expect(page.getByText('Opération en cours...')).toHaveCount(0) + await openClustersAdministration({ page }) + await page.getByTestId('projectsSearchInput').fill(clusterName) + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('labelInput')).toHaveValue(clusterName) + await expect(page.getByTestId('labelInput')).toBeDisabled() + }) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/dashboard.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/dashboard.spec.ts new file mode 100644 index 0000000000..cb72d47d0d --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/dashboard.spec.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + signInCloudPiNative, + testUser, +} from '../config/console' +import { createProject, projectSlugTextRegexp } from '../helpers/project' + +test.describe('Dashboard page', () => { + test( + 'Should display a project statuses', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { + name: projectName, + id: projectId, + slug: projectSlug, + } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + expect(await page.getByTestId('project-id').getAttribute('title')).toBe( + projectId, + ) + await expect(page.locator('h1')).toContainText(projectName) + }, + ) + + test( + 'Should add, display and edit description', + { tag: '@e2e' }, + async ({ page, browserName }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const description1 = 'Application de prise de rendez-vous en préfécture.' + const description2 + = 'Application d\'organisation de tournois de pétanque interministériels.' + + // Act 1 + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('setDescriptionBtn').click() + // Yep, we need that for now... + // @TODO Ensure setDescriptionBtn is properly chained to descriptionInput + if (browserName === 'chromium') { + await page.waitForTimeout(1000) + } + await page.getByTestId('descriptionInput').fill(description1) + await page.getByTestId('saveDescriptionBtn').click() + // Assert 1 + await expect(page.getByTestId('descriptionP')).toHaveText(description1) + + // Act 2 + await page.getByTestId('setDescriptionBtn').click() + if (browserName === 'chromium') { + await page.waitForTimeout(1000) + } + await page.getByTestId('descriptionInput').fill(description2) + await page.getByTestId('saveDescriptionBtn').click() + // Assert 2 + await expect(page.getByTestId('descriptionP')).toHaveText(description2) + }, + ) + + test('Should show project secrets', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await expect(page.getByTestId('projectSecretsZone')).not.toBeVisible() + await page.getByTestId('showSecretsBtn').click() + + // Assert + await expect(page.getByTestId('projectSecretsZone')).toBeVisible() + await expect(page.getByTestId('noProjectSecretsP')).toHaveText( + 'Aucun secret à afficher', + ) + }) + + test('Should replay hooks for project', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('snackbar')).toContainText( + 'Le projet a été reprovisionné avec succès', + ) + }) + + test( + 'Should not be able to access project secrets if not owner', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName, slug: projectSlug } = await createProject({ + page, + }) + + // Act + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('projectSecretsZone')).not.toBeVisible() + await expect(page.getByTestId('showSecretsBtn')).not.toBeVisible() + }, + ) + + // @TODO: This test's expectation was reversed in Cypress E2E tests ("you should not be able + // to archive someone else's project"), but the reality is that you actually can do that, + // and Playwright detects it as well. So, for the time being, and in order to make the test + // pass, we reversed this test's expectation: You indeed can (and therefore should be able to) + // archive someone else's project ! + test( + 'Should be able to archive a project if not owner', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName, slug: projectSlug } = await createProject({ + page, + }) + + // Act + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('showArchiveProjectBtn')).toBeVisible() + }, + ) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/deployment.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/deployment.spec.ts new file mode 100644 index 0000000000..77df873337 --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/deployment.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, testUser } from 'config/console' +import { createDeployment } from 'helpers/deployment' +import { createEnvironment } from 'helpers/environment' +import { createProject, deleteProject } from 'helpers/project' +import { createRepository } from 'helpers/repository' + +test.describe('Déploiement', { tag: '@e2e' }, () => { + let projectName: string + + test.beforeEach(async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name } = await createProject({ page }) + projectName = name + }) + + // Not working on CI (need to mock plugins....) + test.skip('should not be able to create a deployment without an environment or a repository', async ({ page }) => { + await page.getByRole('button', { name: 'Ajouter un nouveau déploiement' }).click() + await expect(page.getByText('Pour créer un déploiement, vous devez d\'abord créer un environnement et un dépôt.')).toBeVisible() + }) + + // Not working on CI (need to mock plugins....) + test.skip('should create a deployment', async ({ page }) => { + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + cpuInput: '2', + gpuInput: '0', + }) + const repoName = await createRepository({ page }) + + const deploymentName = await createDeployment({ page, envName, repoName, customStageName: 'dev' }) + await expect(page.getByText(deploymentName)).toBeVisible() + }) + + test.afterEach(async ({ page }) => { + if (!projectName) return + await deleteProject({ page, projectName }) + }) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/environments.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/environments.spec.ts new file mode 100644 index 0000000000..5035f890d1 --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/environments.spec.ts @@ -0,0 +1,335 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + cnolletUser, + signInCloudPiNative, + testUser, +} from '../config/console' + +import { unsetCheckbox } from '../helpers/checkbox' +import { createEnvironment, deleteEnvironment } from '../helpers/environment' +import { createProject } from '../helpers/project' + +test.describe('Environments page', { tag: '@e2e' }, () => { + test('should add environments to an existing project', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + cpuInput: '2', + gpuInput: '0', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + }) + + test('should not add environments to a project without enough hprod GPU', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ + page, + hprodResources: { + cpu: 10, + memory: 10, + gpu: 1, + }, + }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + memoryInput: '2', + gpuInput: '2', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).not.toBeVisible() + await expect( + page + .getByTestId('snackbar') + .getByText( + 'Le projet ne dispose pas de suffisamment de ressources : GPU.', + ), + ).toBeVisible() + }) + + test('should not add incorrect environments', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + + // Incorrect input + await page.getByTestId('environmentNameInput').click() + await page.getByTestId('environmentNameInput').fill(`${envName}-yolo`) + await expect( + page.getByRole('alert').getByText('Le nom de l\'environnment ne doit pas'), + ).toBeVisible() + + // Valid input + await page.getByTestId('cancelEnvironmentBtn').click() + await createEnvironment({ + page, + envName, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + // Second try with same name (invalid) + await createEnvironment({ + page, + envName, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect( + page + .getByTestId('snackbar') + .getByText('Ce nom d\'environnement est déjà pris'), + ).toBeVisible() + }) + + test('should alert cluster unavailability', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'Zone privée' }) + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: 'dev' }) + await expect(page.getByTestId('noClusterOptionAlert')).toBeVisible() + await expect(page.getByLabel('Choix du cluster cible')).not.toBeVisible() + + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'publique' }) + await expect(page.getByTestId('noClusterOptionAlert')).not.toBeVisible() + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + }) + + test('should display zone infos and cluster infos', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'publique' }) + await expect(page.getByTestId('chosenZoneDescription')).toBeVisible() + + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: 'dev' }) + await page + .getByLabel('Choix du cluster cible') + .selectOption({ label: 'public1' }) + await expect(page.getByTestId('clusterInfos')).toBeVisible() + }) + + test('should update environment resources', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toBeVisible() + await expect(page.locator('#zone-select option[selected]')).toHaveText( + 'publique', + ) + await expect(page.locator('#stage-select option[selected]')).toHaveText( + 'dev', + ) + await expect(page.locator('#cluster-select option[selected]')).toHaveText( + 'public1', + ) + await expect(page.getByTestId('memoryInput')).toHaveValue('1') + await expect(page.getByTestId('cpuInput')).toHaveValue('1') + await expect(page.getByTestId('gpuInput')).toHaveValue('1') + await page.getByTestId('memoryInput').fill('1.5') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('10') + await page.getByTestId('putEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1.5GiB 2CPU 10GPU', + ) + }) + + test('should not update environment resources when cluster is too small', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toBeVisible() + await expect(page.locator('#zone-select option[selected]')).toHaveText( + 'publique', + ) + await expect(page.locator('#stage-select option[selected]')).toHaveText( + 'dev', + ) + await expect(page.locator('#cluster-select option[selected]')).toHaveText( + 'public1', + ) + await expect(page.getByTestId('memoryInput')).toHaveValue('1') + await expect(page.getByTestId('cpuInput')).toHaveValue('1') + await expect(page.getByTestId('gpuInput')).toHaveValue('1') + await page.getByTestId('memoryInput').fill('120') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('1') + await page.getByTestId('putEnvironmentBtn').click() + await expect( + page + .getByTestId('snackbar') + .getByText( + 'Le cluster ne dispose pas de suffisamment de ressources : Mémoire.', + ), + ).toBeVisible() + }) + + test('should delete an environment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + await deleteEnvironment({ page, projectName, envName }) + }) + + test('should not be able to delete an environment if not owner', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ + page, + members: [cnolletUser], + }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Sign off and login as another user (project member) + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: cnolletUser }) + // Select previously created project + await page.getByTestId('menuMyProjects').click() + await expect(page.getByTestId('createProjectLink')).toBeVisible() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + // Verify absence of delete button + await page.getByTestId(`environmentTr-${envName}`).click() + await expect( + page.getByTestId('showDeleteEnvironmentBtn'), + ).not.toBeVisible() + }) + + test('should not be able to delete an environment if project locked', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName } = await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Sign off and login as admin to lock the project + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await page.getByTestId('handleProjectLockingBtn').click() + + // Sign off and login back as user + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: testUser }) + // Verify absence of delete button + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId(`environmentTr-${envName}`).click() + await expect( + page.getByTestId('showDeleteEnvironmentBtn'), + ).not.toBeVisible() + }) + + test('should show a warning if autosync is deactivated', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Verify warning message + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('input-checkbox-autosyncCbx')).toBeVisible() + await expect(page.getByTestId('input-checkbox-autosyncCbx')).toBeChecked() + await expect(page.getByTestId('noAutosyncAlert')).not.toBeVisible() + + // Act - Uncheck auto-sync to trigger warning message + await unsetCheckbox(page.getByTestId('input-checkbox-autosyncCbx')) + await expect(page.getByTestId('noAutosyncAlert')).toBeVisible() + await expect(page.getByTestId('noAutosyncAlert')) + .toHaveText('La synchronisation automatique est désactivée. Les déploiements devront être synchronisés manuellement.') + }) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/keycloak.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/keycloak.spec.ts new file mode 100644 index 0000000000..17c87fb6d4 --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/keycloak.spec.ts @@ -0,0 +1,185 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { loadKeycloakConfig, signInKeycloak } from '../config/keycloak' + +const keycloakConfig = loadKeycloakConfig() +const usersToDelete: string[] = [] +const groupsToDelete: string[] = [] + +export async function createUser(page: Page, usersToDelete: string[]) { + await page.getByRole('link', { name: 'Users' }).click() + await page.getByTestId('add-user').click() + const username = faker.internet.username() + usersToDelete.push(username) + await page + .getByRole('textbox', { name: 'Username' }) + .fill(username) + await page.getByTestId('email').fill(faker.internet.email()) + await page.getByTestId('user-creation-save').click() + await expect(page.getByLabel('The user has been created')).toBeVisible() + return username +} + +export async function createGroup(page: Page, groupsToDelete: string[]) { + await page.getByRole('link', { name: 'Groups' }).click() + await page.getByRole('button', { name: 'Create group' }).click() + const groupName = faker.string.alpha(10) + groupsToDelete.push(groupName) + await page.getByTestId('name').fill(groupName) + await page.getByTestId('createGroup').click() + await expect(page.getByLabel('Group created')).toBeVisible() + return groupName +} + +test.describe('Keycloak', () => { + test.beforeEach(async ({ page }) => { + await signInKeycloak(page) + }) + + test('should sign in to master realm', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + // expect also exists in sign in fuction but we check it explicitely here + // for documentation purposes + await expect(page.getByRole('link', { name: 'Logo' })).toBeVisible() + await page.getByTestId('realmSelector').click() + await expect(page.getByRole('menuitem', { name: keycloakConfig.realm })).toBeVisible() + }) + + test('should have a CπN realm', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByTestId('realmSelector').click() + await expect( + page.getByRole('menuitem', { name: keycloakConfig.realm }), + ).toBeVisible() + }) + + test.describe('CπN realm', () => { + test.beforeEach(async ({ page }) => { + await page.getByTestId('realmSelector').click() + await page.getByRole('menuitem', { name: keycloakConfig.realm }).click() + }) + + test('should have required Clients', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByTestId('realmSelector').click() + await page.getByRole('menuitem', { name: keycloakConfig.realm }).click() + await page.getByRole('link', { name: 'Clients' }).click() + await expect(page.getByRole('link', { name: keycloakConfig.clientFrontend })).toBeVisible() + await expect(page.getByRole('link', { name: keycloakConfig.clientBackend })).toBeVisible() + await page.getByRole('link', { name: 'Client scopes' }).click() + await expect(page.getByRole('link', { name: 'generic' })).toBeVisible() + }) + + test('should have required Clients scopes', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByRole('link', { name: 'Client scopes' }).click() + await expect(page.getByRole('link', { name: 'generic' })).toBeVisible() + }) + + test('should create a user', { tag: '@e2e' }, async ({ page }) => { + await createUser(page, usersToDelete) + }) + + test('should create a group', { tag: '@e2e' }, async ({ page }) => { + await createGroup(page, groupsToDelete) + }) + + test('should link a user to a group', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + const username = await createUser(page, usersToDelete) + const groupName = await createGroup(page, groupsToDelete) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .fill(groupName) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page.getByRole('button', { name: groupName }).click() + await page.getByTestId('members').click() + await page.getByRole('button', { name: 'Add member' }).click() + await page + .getByTestId('titleUsersinput') + .getByRole('textbox', { name: 'Search' }) + .fill(username) + await page + .getByTestId('titleUsersinput') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page.getByRole('checkbox', { name: 'Select row 0' }).check() + await page.getByTestId('add').click() + await expect(page.getByText('user added to the group')).toBeVisible() + }) + + test('should delete a group', { tag: '@e2e' }, async ({ page }) => { + const groupName = await createGroup(page, groupsToDelete) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .fill(groupName) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page + .getByRole('treeitem', { name: `${groupName} Actions` }) + .getByLabel('Actions') + .click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByTestId('confirm').click() + }) + + test('should delete a user', { tag: '@e2e' }, async ({ page }) => { + await createUser(page, usersToDelete) + await page.getByTestId('action-dropdown').click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByTestId('confirm').click() + }) + + test('cleanup Keycloak test data', { tag: '@integ' }, async ({ request }) => { + const params = new URLSearchParams() + params.append('grant_type', 'password') + params.append('client_id', 'admin-cli') + params.append('username', keycloakConfig.adminUser) + params.append('password', keycloakConfig.adminPass) + + const tokenRes = await request.post( + `${keycloakConfig.url}/realms/master/protocol/openid-connect/token`, + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: params.toString(), + }, + ) + + if (!tokenRes.ok) { + throw new Error(`Failed to get token: ${tokenRes.statusText}`) + } + + const tokenData = await tokenRes.json() + const authHeader = { Authorization: `Bearer ${tokenData.access_token}` } + + for (const username of usersToDelete) { + const usersRes = await request.get( + `${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users?username=${username}`, + { headers: authHeader }, + ) + const users = await usersRes.json() + for (const user of users) { + await request.delete(`${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users/${user.id}`, { + headers: authHeader, + }) + } + } + + for (const groupName of groupsToDelete) { + const groupsRes = await request.get( + `${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/groups?search=${groupName}`, + { headers: authHeader }, + ) + const groups = await groupsRes.json() + for (const group of groups) { + await request.delete(`${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/groups/${group.id}`, { + headers: authHeader, + }) + } + } + }) + }) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/profile.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/profile.spec.ts new file mode 100644 index 0000000000..0794c9c6e5 --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/profile.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test' + +import { + clientURL, + cnolletUser, + signInCloudPiNative, + tcolinUser, +} from '../config/console' + +test.describe('Profile page', () => { + test('Should display name once logged', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + + await expect(page.getByTestId('menuUserList')).toContainText( + `${cnolletUser.firstName} ${cnolletUser.lastName}`, + ) + }) + + test('Should display profile infos', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.goto(`${clientURL}/profile/info`) + + const locator = expect(page.getByTestId('profileInfos')) + await locator.toContainText( + `${tcolinUser.lastName}, ${tcolinUser.firstName}`, + ) + await locator.toContainText(tcolinUser.id) + await locator.toContainText('Admin') + await locator.toContainText(tcolinUser.email) + }) + + test('Should create a PAT', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.goto(`${clientURL}/profile/tokens`) + + // Act + await page.getByTestId('showNewTokenFormBtn').click() + await page.getByTestId('newTokenName').fill('test2') + await page.getByTestId('expirationDateInput').fill('2100-11-22') + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('newTokenPassword')).toBeVisible() + await page.getByTestId('showNewTokenFormBtn').click() + + // Assert + expect(page.getByTestId('newTokenPassword')).toBeDefined() + const locator = page.locator('tr', { hasText: 'test2' }) + expect(locator).toContainText(new Date().getFullYear().toString()) + expect(locator).toContainText('2100') + expect(locator).toContainText('Jamais') + expect(locator).toContainText('Actif') + await locator.getByTitle('Supprimer').click() + await page.getByTestId('confirmDeletionBtn').click() + await expect(page.getByTestId('tokenTable')).toContainText( + 'Aucune clé d\'api existante', + ) + }) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/project-logs.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/project-logs.spec.ts new file mode 100644 index 0000000000..78c0798827 --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/project-logs.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test' +import { + clientURL, + signInCloudPiNative, + tcolinUser, + testUser, +} from '../config/console' +import { createProject } from '../helpers/project' + +test.describe('Project logs page', () => { + test( + 'Should display project logs as owner', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + // Act + await createProject({ page }) + + // Assert + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + }, + ) + + test( + 'Should display additional logs after reprovisionning a project', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + + // Act + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 7', + ) + }, + ) + + test( + 'Should display project logs as project manager as well as a project member', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + // Act + const { name: projectName } = await createProject({ + page, + members: [tcolinUser], + }) + + // Assert - as Project Owner + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + + // Assert - as Project Member + await page.getByRole('link', { name: 'Se Déconnecter' }).click() + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 7', + ) + }, + ) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/project-roles.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/project-roles.spec.ts new file mode 100644 index 0000000000..23e9cf19fc --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/project-roles.spec.ts @@ -0,0 +1,201 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { setCheckbox, unsetCheckbox } from 'helpers/checkbox' +import { clientURL, cnolletUser, signInCloudPiNative, testUser } from '../config/console' +import { createEnvironment } from '../helpers/environment' +import { createProject, deleteProject } from '../helpers/project' +import { createRepository } from '../helpers/repository' + +async function openProjectByName({ page, projectName }: { page: Page, projectName: string }) { + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() +} + +async function openProjectRoleByName({ page, roleName }: { page: Page, roleName: string }) { + await page.getByTestId('test-tab-roles').click() + await page + .locator('[data-testid$="-tab"]') + .filter({ hasText: roleName }) + .click() +} + +async function assignPerms({ + page, + roleName, + perms, +}: { + page: Page + roleName: string + perms: readonly string[] +}) { + await openProjectRoleByName({ page, roleName }) + const setPermChecked = async (key: string, checked: boolean) => { + const input = page.locator(`#${key}-cbx`) + await expect(input).toBeVisible() + if ((await input.isChecked()) === checked) + return + + const label = page.locator(`label[for="${key}-cbx"]`) + if (await label.count()) { + await label.click() + } else { + await setCheckbox(input) + } + + try { + await expect(input).toBeChecked({ checked }) + } catch { + await input.evaluate((el: HTMLInputElement, nextChecked: boolean) => { + el.checked = nextChecked + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, checked) + await expect(input).toBeChecked({ checked }) + } + } + + for (const key of perms) { + await setPermChecked(key, true) + } + await expect(page.getByTestId('saveBtn')).toBeEnabled() + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') +} + +test.describe.serial('Project roles', { tag: '@e2e' }, () => { + const newRoleName = `role-${faker.string.alpha(10).toLowerCase()}` + let projectName = '' + let repositoryName = '' + let environmentName = '' + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + const project = await createProject({ page, members: [cnolletUser] }) + projectName = project.name + + repositoryName = await createRepository({ page }) + environmentName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + await page.getByTestId('test-tab-roles').click() + await page.getByTestId('addRoleBtn').click() + await expect(page.getByTestId('snackbar')).toContainText('Rôle ajouté') + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRoleName) + await page.getByTestId('saveBtn').click() + + await page.getByRole('tab', { name: 'Membres' }).click() + await setCheckbox(page.getByTestId(`input-checkbox-${cnolletUser.id}-cbx`)) + await page.close() + }) + + test.afterAll(async ({ browser }) => { + if (!projectName) + return + const page = await browser.newPage() + try { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await deleteProject({ page, projectName }) + } finally { + await page.close() + } + }) + + test('Should not grant perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + await openProjectByName({ page, projectName }) + + await page.getByTestId('test-tab-team').click() + await expect(page.getByTestId('teamTable').getByText('Retirer du projet')).toBeVisible() + await expect(page.getByTestId('addUserSuggestionInput')).toHaveCount(0) + await expect(page.getByTestId('showTransferProjectBtn')).toHaveCount(0) + await expect(page.getByTestId('test-tab-roles')).toBeVisible() + }) + + test('System roles forbid edits', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + + await openProjectRoleByName({ page, roleName: 'DevOps' }) + + await expect(page.getByTestId('roleNameInput')).toBeDisabled() + await expect(page.locator('#LIST_ENVIRONMENTS-cbx')).toBeDisabled() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('deleteBtn')).toHaveCount(0) + }) + + test('System roles allow member assignment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + await openProjectRoleByName({ page, roleName: 'DevOps' }) + + const membersTab = page.getByRole('tab', { name: 'Membres' }) + await expect(membersTab).toBeVisible() + await membersTab.click() + + const memberCheckbox = page.getByTestId(`input-checkbox-${cnolletUser.id}-cbx`) + await expect(memberCheckbox).toBeVisible() + + await setCheckbox(memberCheckbox) + await expect(memberCheckbox).toBeChecked() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') + + await unsetCheckbox(memberCheckbox) + await expect(memberCheckbox).not.toBeChecked() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') + }) + + test('Should assign view perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + await assignPerms({ + page, + roleName: newRoleName, + perms: ['LIST_ENVIRONMENTS', 'LIST_REPOSITORIES'], + }) + }) + + test('Should grant view perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + await openProjectByName({ page, projectName }) + await page.getByTestId('test-tab-resources').click() + + await expect(page.getByTestId('noReposTr')).toHaveCount(0) + await expect(page.getByTestId('addRepoLink')).toBeHidden() + await page.getByTestId(`repoTr-${repositoryName}`).click() + await expect(page.getByTestId('syncRepoBtn')).toHaveCount(0) + await expect(page.getByTestId('updateRepoBtn')).toHaveCount(0) + await expect(page.getByTestId('showDeleteRepoBtn')).toHaveCount(0) + await page.locator('.fr-modal__header > button.fr-btn--close').click() + + await expect(page.getByTestId('noEnvsTr')).toHaveCount(0) + await expect(page.getByTestId('addEnvironmentLink')).toBeHidden() + await page.getByTestId(`environmentTr-${environmentName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toHaveCount(0) + await expect(page.getByTestId('showDeleteEnvironmentBtn')).toHaveCount(0) + await page.locator('.fr-modal__header > button.fr-btn--close').click() + + await page.getByTestId('test-tab-team').click() + await expect(page.getByTestId('teamTable').getByText('Retirer du projet')).toBeVisible() + await expect(page.getByTestId('addUserSuggestionInput')).toHaveCount(0) + await expect(page.getByTestId('showTransferProjectBtn')).toHaveCount(0) + + await page.getByTestId('test-tab-roles').click() + await expect(page.getByTestId('insuficientPermsRoles')).toBeVisible() + }) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/projects.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/projects.spec.ts new file mode 100644 index 0000000000..0cf0d62384 --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/projects.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + cnolletUser, + signInCloudPiNative, + testUser, +} from '../config/console' +import { deleteValidationInput } from '../helpers/constants' +import { createProject } from '../helpers/project' +import { createRepository, synchronizeBranchOnRepository } from '../helpers/repository' + +test.describe('Projects page', () => { + test( + 'Should display only projects that user is member of', + { tag: '@e2e' }, + async ({ page }) => { + // Create a project as one user + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ page }) + + // Sign off and login as another user + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: cnolletUser }) + + // Previously created project should not appear for this user + await page.getByTestId('menuMyProjects').click() + await expect(page.getByTestId('createProjectLink')).toBeVisible() + await expect( + page.getByRole('link', { name: projectName }), + ).not.toBeVisible() + }, + ) + + test( + 'Should not keep the same default branch name for all repositories of a projects', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + // Adding a project directly puts us on the newly created project page, + // so no need to add navigation steps to go there + const firstRepositoryName = await createRepository({ page }) + const branchName = await synchronizeBranchOnRepository({ + page, + repositoryName: firstRepositoryName, + }) + const secondRepositoryName = await createRepository({ page }) + await page.getByTestId(`repoTr-${secondRepositoryName}`).click() + await expect(page.getByTestId('resource-modal')).toBeVisible() + await expect(page.getByTestId('branchNameInput')).not.toHaveValue( + branchName, + ) + }, + ) + + // @TODO Archiving and Deleting a project is, basically, the same thing. It seems that + // initially we wanted to delete projects but we moved on to archiving, while not updating + // the frontend x) + test( + 'Should archive a project, when logged in as an admin', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const { id: projectId, name: projectName } = await createProject({ page }) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Tous') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('archiveProjectInput')).not.toBeVisible() + await page.getByTestId('showArchiveProjectBtn').click() + await expect(page.getByTestId('confirmDeletionBtn')).toBeDisabled() + await page.getByTestId('archiveProjectInput').fill(deleteValidationInput) + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Archivés') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + // Projects are renamed (suffixed with a timestamp and `_archived`) so @TODO: Do Better… + await expect(page.getByRole('table', { name: 'Liste des projets' })).toContainText(projectName) + }, + ) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/repos.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/repos.spec.ts new file mode 100644 index 0000000000..41362ea716 --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/repos.spec.ts @@ -0,0 +1,742 @@ +import { expect, test } from '@playwright/test' +import { + clientURL, + signInCloudPiNative, + tcolinUser, + testUser, +} from '../config/console' + +import { setCheckbox, unsetCheckbox } from '../helpers/checkbox' +import { + deleteValidationInput, + fakeToken, + invalidGitUrlErrorMessage, + invalidInternalRepoErrorMessage, +} from '../helpers/constants' +import { createProject, projectSlugTextRegexp } from '../helpers/project' + +test.describe('Repositories', () => { + // @TODO: Rework this Cypress-inherited test (use of following-sibling is a + // good hint that something is very very wrong) + test( + 'Should handle repository form validation', + { tag: ['@e2e', '@repos', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + internalRepoName: 'repo00', + externalRepoUrl: 'https://github.com/externalUser01/repo00.git', + externalUserName: 'user', + externalToken: 'videnden88EHEBdldd_T0k9n', + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + + await page.getByTestId('addRepoLink').click() + await page.locator('dialog').waitFor({ state: 'visible' }) + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await setCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await unsetCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('internalRepoNameInput').fill('$%_>') + await expect( + page + .getByTestId('internalRepoNameInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidInternalRepoErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await expect( + page + .getByTestId('internalRepoNameInput') + .locator('//following-sibling::*[1]'), + ).not.toBeVisible() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.slice(0, -4)) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.slice(8)) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.replace('https', 'http')) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).not.toBeVisible() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalUserNameInput') + .fill(repo.externalUserName) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalUserNameInput').clear() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalTokenInput').clear() + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await unsetCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + }, + ) + + test( + 'Should add a standalone public repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + internalRepoName: 'repo00', + isInfra: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await setCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + await page.getByTestId('addRepoBtn').click() + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.locator('h2')).not.toContainText( + 'Synchroniser le dépôt', + ) + }, + ) + + test( + 'Should add an external public repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser01/repo01.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo01', + isInfra: false, + isPrivate: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external private repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser02/repo02.git', + externalToken: 'hoqjC1vXtABzytBIWBXsdyzubmqMYkgA', + externalUserName: '', + internalRepoName: 'repo02', + isInfra: false, + isPrivate: true, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external public infra repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external private infra repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser04/repo04.git', + externalToken: '', + externalUserName: 'externalUser+04', + internalRepoName: 'repo04', + isInfra: true, + isPrivate: true, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page + .getByTestId('externalUserNameInput') + .fill(repo.externalUserName) + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should update a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Update repository + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('internalRepoNameInput')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill('https://github.com/externalUser04/new-repo.git') + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page.getByTestId('externalUserNameInput').fill('newUser') + await page.getByTestId('externalTokenInput').fill('newToken') + await expect( + page.getByTestId('input-checkbox-infraRepoCbx'), + ).toBeEnabled() + await page.getByTestId('updateRepoBtn').click() + await expect(page.getByTestId(`repoTr-repo03`)).toBeVisible() + await page.reload() + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('externalRepoUrlInput')).toHaveValue( + 'https://github.com/externalUser04/new-repo.git', + ) + await expect( + page.getByTestId('input-checkbox-privateRepoCbx'), + ).toBeChecked() + await expect(page.getByTestId('externalUserNameInput')).toHaveValue( + 'newUser', + ) + await expect(page.getByTestId('externalTokenInput')).toHaveValue( + fakeToken, + ) + }, + ) + + test( + 'Should synchronise a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Update repository + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('branchNameInput')).toHaveValue('main') + await expect(page.getByTestId('branchNameInput')).toBeEnabled() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page + .getByTestId('toggleSyncAllBranches') + .locator('input') + .check({ force: true }) + await expect(page.getByTestId('branchNameInput')).not.toBeVisible() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page + .getByTestId('toggleSyncAllBranches') + .locator('input') + .uncheck({ force: true }) + await expect(page.getByTestId('branchNameInput')).toHaveValue('main') + await expect(page.getByTestId('branchNameInput')).toBeEnabled() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page.getByTestId('syncRepoBtn').click() + await expect( + page.getByText( + 'Travail de synchronisation lancé pour le dépôt repo03Fermer le message', + ), + ).toBeVisible() + + await page.getByTestId('branchNameInput').clear() + await expect(page.getByTestId('syncRepoBtn')).toBeDisabled() + await page.getByTestId('branchNameInput').fill('develop') + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page.getByTestId('syncRepoBtn').click() + await expect( + page.getByText( + 'Travail de synchronisation lancé pour le dépôt repo03Fermer le message', + ), + ).toBeVisible() + }, + ) + + test( + 'Should delete a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Delete repository + // + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('deleteRepoInput')).not.toBeVisible() + await expect(page.getByTestId('deleteRepoZone')).toBeVisible() + await page.getByTestId('showDeleteRepoBtn').click() + await expect(page.getByTestId('deleteRepoBtn')).toBeDisabled() + await page.getByTestId('deleteRepoInput').fill(deleteValidationInput) + await page.getByTestId('deleteRepoBtn').click() + await expect(page.getByTestId('repo-form')).not.toBeVisible() + await page.reload() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).not.toBeVisible() + }, + ) + + test( + 'Should not be able to delete a repository if not owner', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { + id: projectId, + name: projectName, + slug: projectSlug, + } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + + // Assert - Attempt to delete repository + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('deleteRepoZone')).not.toBeVisible() + }, + ) + + test( + 'Should not be able to delete a repository if project locked', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ + page, + credentials: + // Only admin users can lock projects + tcolinUser, + }) + const { + id: projectId, + name: projectName, + slug: projectSlug, + } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Act - Lock the project (have to go through admin menu for that) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('handleProjectLockingBtn').click() + + // Assert - Attempt to delete repository + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('showDeleteRepoBtn')).not.toBeVisible() + }, + ) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/roles.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/roles.spec.ts new file mode 100644 index 0000000000..a81d940536 --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/roles.spec.ts @@ -0,0 +1,228 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from 'config/console' +import { setCheckbox } from 'helpers/checkbox' + +test.describe('Administration Roles', () => { + test( + 'Should list admin roles', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + // Assert + // Obviously, we should not use data-testid that are made of ids… + // @TODO: Do better. + await expect( + page.getByTestId('76229c96-4716-45bc-99da-00498ec9018c-tab'), + ).toBeVisible() + await expect( + page.getByTestId('eadf604f-5f54-4744-bdfb-4793d2271e9b-tab'), + ).toBeVisible() + }, + ) + + test('Should add a new OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newOidcRole = { + name: faker.string.alpha(10).toLowerCase(), + oidcGroupName: `/${faker.string.alpha(10).toLowerCase()}`, + user: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newOidcRole.name) + await page.getByTestId('oidcGroupInput').fill(newOidcRole.oidcGroupName) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).toContainText(newOidcRole.name) + await page.getByTestId('test-members').click() + await expect(page.getByTestId('addUserBtn')).toBeDisabled() + await page + .getByTestId('addUserSuggestionInput') + .locator('input') + .fill(`${newOidcRole.user.email}`) + await page.getByTestId('addUserBtn').click() + + // Assert + const membershipCheckbox = page.getByTestId( + `input-checkbox-${newOidcRole.user.id}-cbx`, + ) + await expect(membershipCheckbox).toBeVisible() + await expect(membershipCheckbox).toBeChecked() + }) + + test('Should add a new non-OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + }) + + test('Should add a user to a role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + user: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create non-OIDC role + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + // Add user to role + await page.getByTestId('test-members').click() + await expect(page.getByTestId('addUserBtn')).toBeDisabled() + await page + .getByTestId('addUserSuggestionInput') + .locator('input') + .fill(`${newRole.user.email}`) + await page.getByTestId('addUserBtn').click() + + // Assert + const membershipCheckbox = page.getByTestId( + `input-checkbox-${newRole.user.id}-cbx`, + ) + await expect(membershipCheckbox).toBeVisible() + await expect(membershipCheckbox).toBeChecked() + }) + + test('Should remove OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newOidcRole = { + name: faker.string.alpha(10).toLowerCase(), + oidcGroupName: `/${faker.string.alpha(10).toLowerCase()}`, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create OIDC role + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newOidcRole.name) + await page.getByTestId('oidcGroupInput').fill(newOidcRole.oidcGroupName) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newOidcRole.name) + // Delete role + await page.getByTestId('deleteBtn').click() + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + }) + + test('Should remove non-OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create OIDC role + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + // Delete role + await page.getByTestId('deleteBtn').click() + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + }) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/service-chains.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/service-chains.spec.ts new file mode 100644 index 0000000000..e1dc6def9d --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/service-chains.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' + +test.describe('Service Chains page', () => { + test.describe('Given an Admin-level user', () => { + test( + 'should list service chains', + { tag: ['@e2e', '@service-chains'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationServiceChains').click() + // We take the first service chain available + await expect( + page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1), + ).toBeVisible() + }, + ) + + test( + 'should show a service chain details', + { tag: ['@e2e', '@service-chains'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationServiceChains').click() + // We take the first service chain available + await page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1) + .click() + await expect( + page.getByRole('heading', { name: 'Chaîne de services' }), + ).toBeVisible() + }, + ) + + test( + 'should show a service chain flows', + { tag: ['@e2e', '@service-chains', '@service-chain-flows'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await expect(page.locator('.fr-collapsing')).toBeVisible() + await expect(page.locator('.fr-collapsing')).not.toBeVisible() + + await page.getByTestId('menuAdministrationServiceChains').click() + await expect( + page.getByTitle('Voir les détails de la chaîne de service').first(), + ).toBeVisible() + // We take the first service chain available + await page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1) + .click() + await expect(page.getByTestId('service-chain-flows')).toBeVisible() + }, + ) + }) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/tokens.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/tokens.spec.ts new file mode 100644 index 0000000000..f6aa4b34db --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/tokens.spec.ts @@ -0,0 +1,37 @@ +import { faker } from '@faker-js/faker' + +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +test.describe('Admin Tokens page', () => { + // @TODO The original test did actually test whether the generated token worked. + // Add a dedicated test to do exactly that. + test( + 'Should create tokens, when logged in as an admin', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const tokenName = faker.string.alpha(10).toLowerCase() + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationToken').click() + await page.getByTestId('showNewTokenFormBtn').click() + + await page.getByTestId('newTokenName').fill(tokenName) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('newTokenPassword')).toBeVisible() + // Reinit form should hide newly generated password + await page.getByTestId('showNewTokenFormBtn').click() + await expect(page.getByTestId('newTokenPassword')).not.toBeVisible() + + await expect(page.getByTestId('tokenTable')).toContainText(tokenName) + }, + ) + + // @TODO Add token deletion test (the original one used "nth" which is not reliable) +}) diff --git a/.jjconflict-side-0/playwright/e2e-tests/zones.spec.ts b/.jjconflict-side-0/playwright/e2e-tests/zones.spec.ts new file mode 100644 index 0000000000..04c61a951b --- /dev/null +++ b/.jjconflict-side-0/playwright/e2e-tests/zones.spec.ts @@ -0,0 +1,193 @@ +import { faker } from '@faker-js/faker' + +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' +import { deleteValidationInput } from '../helpers/constants' + +test.describe('Zone page', () => { + // @TODO: Add clusters to this test to ensure this part of the feature + test('Should create a zone', { tag: ['@e2e', '@need-rework'] }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + expect(expect(page.locator('#clusters-select')).not.toBeVisible()) + await page.getByTestId('addZoneBtn').click() + + await page.reload() + + // Assert + await page.locator(`#zoneTile-${zone.slug}`).click() + await expect(page.getByTestId('slugInput')).toHaveValue(zone.slug) + await expect(page.getByTestId('slugInput')).toBeDisabled() + await expect(page.getByTestId('labelInput')).toHaveValue(zone.label) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await expect(page.getByTestId('argocdUrlInput')).toHaveValue( + zone.argocdUrl, + ) + await expect(page.getByTestId('argocdUrlInput')).toBeEnabled() + await expect(page.getByTestId('descriptionInput')).toHaveValue( + zone.description, + ) + await expect(page.getByTestId('descriptionInput')).toBeEnabled() + await expect(page.locator('#clusters-select')).toBeVisible() + await expect(page.locator('#clusters-select')).toHaveAttribute( + 'disabled', + 'true', + ) + await expect(page.getByTestId('updateZoneBtn')).toBeEnabled() + }) + + test('Should update a zone', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + const updatedZone = { + label: 'Zone Mise à Jour', + argocdUrl: 'https://vousnetesplusici.fr', + description: 'Cette zone a été mise à jour.', + } + // Zone creation (for later update) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + + // Act + await page.locator(`#zoneTile-${zone.slug}`).click() + await page.getByTestId('labelInput').fill(updatedZone.label) + await page.getByTestId('argocdUrlInput').fill(updatedZone.argocdUrl) + await page.getByTestId('descriptionInput').fill(updatedZone.description) + await page.getByTestId('updateZoneBtn').click() + + // Assert + await page.locator(`#zoneTile-${zone.slug}`).click() + await expect(page.getByTestId('slugInput')).toHaveValue(zone.slug) + await expect(page.getByTestId('slugInput')).toBeDisabled() + await expect(page.getByTestId('labelInput')).toHaveValue(updatedZone.label) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await expect(page.getByTestId('argocdUrlInput')).toHaveValue( + updatedZone.argocdUrl, + ) + await expect(page.getByTestId('argocdUrlInput')).toBeEnabled() + await expect(page.getByTestId('descriptionInput')).toHaveValue( + updatedZone.description, + ) + await expect(page.getByTestId('descriptionInput')).toBeEnabled() + await expect(page.locator('#clusters-select')).toBeVisible() + await expect(page.locator('#clusters-select')).toHaveAttribute( + 'disabled', + 'true', + ) + await expect(page.getByTestId('updateZoneBtn')).toBeEnabled() + }) + + test('Should not create a zone if slug is already taken', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + // Create the zone + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + + // Act - attempt to create a zone with the same slug + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(`${zone.label}bis`) + await page.getByTestId('argocdUrlInput').fill(`${zone.argocdUrl}bis`) + await page.getByTestId('descriptionInput').fill(`${zone.description}bis`) + await page.getByTestId('addZoneBtn').click() + + // Assert + const snackbar = page + .getByTestId('snackbar') + .filter({ hasText: `Une zone portant le nom ${zone.slug} existe déjà.` }) + await expect(snackbar).toBeVisible() + }) + + test('Should not delete a zone if associated clusters', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + // No data to arrange, a public zone is present by default (I guess ?) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + + // Assert + await page.getByTestId(`zoneTile-publique`).click() + await expect(page.getByTestId('showDeleteZoneBtn')).not.toBeVisible() + await expect(page.getByTestId('associatedClustersAlert')).toBeVisible() + }) + + test('Should delete a zone', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + // Create the zone + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + // @TODO: Add clusters to this test to ensure we can't delete a zone that has clusters + + // Act + await page.getByTestId('menuAdministrationZones').click() + await page.locator(`#zoneTile-${zone.slug}`).click() + await page.getByTestId('showDeleteZoneBtn').click() + await page.getByTestId('deleteZoneInput').fill(deleteValidationInput) + await page.getByTestId('deleteZoneBtn').click() + + // Assert + await page.getByTestId('menuAdministrationZones').click() + await expect(page.locator(`#zoneTile-${zone.slug}`)).not.toBeVisible() + }) +}) diff --git a/.jjconflict-side-0/playwright/eslint.config.mjs b/.jjconflict-side-0/playwright/eslint.config.mjs new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-0/playwright/eslint.config.mjs @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-0/playwright/helpers/checkbox.ts b/.jjconflict-side-0/playwright/helpers/checkbox.ts new file mode 100644 index 0000000000..b304a5c380 --- /dev/null +++ b/.jjconflict-side-0/playwright/helpers/checkbox.ts @@ -0,0 +1,20 @@ +import type { Locator } from '@playwright/test' +import { expect } from '@playwright/test' + +async function setCheckboxValue(locator: Locator, checked: boolean) { + await expect(locator).toBeVisible() + await locator.evaluate((el: HTMLInputElement, nextChecked: boolean) => { + el.checked = nextChecked + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, checked) + await expect(locator).toBeChecked({ checked }) +} + +export async function setCheckbox(locator: Locator) { + await setCheckboxValue(locator, true) +} + +export async function unsetCheckbox(locator: Locator) { + await setCheckboxValue(locator, false) +} diff --git a/.jjconflict-side-0/playwright/helpers/cluster.ts b/.jjconflict-side-0/playwright/helpers/cluster.ts new file mode 100644 index 0000000000..2239dd8904 --- /dev/null +++ b/.jjconflict-side-0/playwright/helpers/cluster.ts @@ -0,0 +1,124 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openClustersAdministration } from './navigation' + +type ClusterZone = string +type ClusterConfidentiality = 'public' | 'dedicated' +type ClusterAssociateStage = 'first' | 'all' | 'none' + +async function selectClusterZone(page: Page, zone: ClusterZone) { + await page.getByLabel('Zone associée').selectOption({ label: zone }) +} + +async function selectClusterConfidentiality(page: Page, confidentiality: ClusterConfidentiality) { + await page + .getByLabel('Confidentialité du cluster') + .selectOption({ label: confidentiality }) + + const projectsSelect = page.locator('#projects-select') + switch (confidentiality) { + case 'public': + await expect(projectsSelect).not.toBeVisible() + break + case 'dedicated': + await expect(projectsSelect).toBeVisible() + break + } +} + +async function addStageToClusterAssociationByName(page: Page, stageName: string) { + await page + .getByTestId('choice-selector-search-stages-select') + .fill(stageName) + await page.locator('#stages-select .fr-tag').first().click() +} + +async function addStagesToClusterAssociationByNames(page: Page, stageNames: string[]) { + for (const stageName of stageNames) + await addStageToClusterAssociationByName(page, stageName) +} + +async function addFirstStageToClusterAssociation(page: Page) { + await page.locator('#stages-select .fr-tag').first().click() +} + +async function addAllStagesToClusterAssociation(page: Page) { + const numberOfStages = (await page.locator('#stages-select .fr-tag').all()).length + for (let i = 0; i < numberOfStages; i++) + await page.locator('#stages-select .fr-tag').first().click() +} + +async function fillClusterInformations(page: Page, informations: string) { + await page.getByTestId('infosInput').fill(informations) +} + +async function searchCluster(page: Page, clusterName: string) { + await page.getByTestId('projectsSearchInput').fill(clusterName) +} + +async function openCluster(page: Page, clusterName: string) { + await page.getByTestId(`clusterLink-${clusterName}`).click() +} + +async function confirmClusterDeletion(page: Page) { + await expect(page.getByTestId('deleteClusterZone')).toBeVisible() + await page.getByTestId('showDeleteClusterBtn').click() + await expect(page.getByTestId('deleteClusterBtn')).toBeVisible() + await expect(page.getByTestId('deleteClusterBtn')).toBeDisabled() + await page.getByTestId('deleteClusterInput').fill(deleteValidationInput) + await expect(page.getByTestId('deleteClusterBtn')).toBeEnabled() + await page.getByTestId('deleteClusterBtn').click() +} + +export async function createCluster({ + page, + zone, + confidentiality, + associateStage, + associateStageNames, + informations, +}: { + page: Page + zone?: ClusterZone + confidentiality: ClusterConfidentiality + associateStage?: ClusterAssociateStage + associateStageNames?: string[] + informations?: string +}): Promise { + const clusterName = faker.string.alpha(10).toLowerCase() + await openClustersAdministration({ page }) + await page.getByTestId('addClusterLink').click() + await page.getByTestId('labelInput').fill(clusterName) + if (zone) + await selectClusterZone(page, zone) + await selectClusterConfidentiality(page, confidentiality) + if (associateStageNames?.length) + await addStagesToClusterAssociationByNames(page, associateStageNames) + + switch (associateStage ?? 'none') { + case 'first': + await addFirstStageToClusterAssociation(page) + break + case 'all': + await addAllStagesToClusterAssociation(page) + break + case 'none': + break + } + + if (informations) + await fillClusterInformations(page, informations) + await page.getByTestId('addClusterBtn').click() + await searchCluster(page, clusterName) + return clusterName +} + +export async function deleteCluster({ page, clusterName }: { page: Page, clusterName: string }) { + await openClustersAdministration({ page }) + await searchCluster(page, clusterName) + await expect(page.getByTestId(`clusterLink-${clusterName}`)).toBeVisible() + await openCluster(page, clusterName) + await confirmClusterDeletion(page) +} diff --git a/.jjconflict-side-0/playwright/helpers/constants.ts b/.jjconflict-side-0/playwright/helpers/constants.ts new file mode 100644 index 0000000000..df68f8179a --- /dev/null +++ b/.jjconflict-side-0/playwright/helpers/constants.ts @@ -0,0 +1,9 @@ +export const invalidGitUrlErrorMessage = 'L\'adresse doit commencer par https et se terminer par .git' +export const invalidInternalRepoErrorMessage = 'Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique' +export const fakeToken = 'fakeToken' +export const deleteValidationInput = 'DELETE' + +export const snackbarTimeoutMs = 20_000 +export const repoSyncTimeoutMs = 60_000 +export const projectReadinessTimeoutMs = 60_000 +export const pollingFastIntervalMs = 2_000 diff --git a/.jjconflict-side-0/playwright/helpers/deployment.ts b/.jjconflict-side-0/playwright/helpers/deployment.ts new file mode 100644 index 0000000000..7a2850f3fc --- /dev/null +++ b/.jjconflict-side-0/playwright/helpers/deployment.ts @@ -0,0 +1,44 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' + +export async function createDeployment({ + page, + deploymentName, + envName, + repoName, + customStageName, +}: { + page: Page + deploymentName?: string + envName: string + repoName: string + customStageName: string +}): Promise { + deploymentName = deploymentName ?? faker.string.alpha(10).toLocaleLowerCase() + await openDeploymentCreateForm(page) + await fillDeploymentName(page, deploymentName) + await selectDeploymentEnvironment(page, envName, customStageName) + await selectDeploymentRepository(page, repoName) + await submitDeploymentForm(page) + return deploymentName +} + +async function openDeploymentCreateForm(page: Page) { + await page.getByRole('button', { name: 'Ajouter un nouveau déploiement' }).click() +} + +async function fillDeploymentName(page: Page, deploymentName: string) { + await page.getByRole('textbox', { name: 'Nom du déploiement * Ne doit' }).fill(deploymentName) +} + +async function selectDeploymentEnvironment(page: Page, envName: string, customStageName: string) { + await page.getByText(`${envName} ${customStageName}`).click() +} + +async function selectDeploymentRepository(page: Page, repoName: string) { + await page.getByLabel('Dépôt *').selectOption({ label: repoName }) +} + +async function submitDeploymentForm(page: Page) { + await page.getByRole('button', { name: 'Enregistrer' }).click() +} diff --git a/.jjconflict-side-0/playwright/helpers/environment.ts b/.jjconflict-side-0/playwright/helpers/environment.ts new file mode 100644 index 0000000000..2eccb82509 --- /dev/null +++ b/.jjconflict-side-0/playwright/helpers/environment.ts @@ -0,0 +1,89 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openMyProjects } from './navigation' + +type EnvironmentZone = 'publique' | 'Zone privée' + +async function openEnvironmentCreateForm(page: Page) { + await page.getByTestId('addEnvironmentLink').click() +} + +async function fillEnvironmentName(page: Page, envName: string) { + await page.getByTestId('environmentNameInput').fill(envName) +} + +async function selectEnvironmentZone(page: Page, zone: EnvironmentZone) { + await page.getByLabel('Choix de la zone cible').selectOption({ label: zone }) +} + +async function selectEnvironmentStage(page: Page, stageName: string) { + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: stageName }) +} + +async function selectEnvironmentCluster(page: Page, clusterName: string) { + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page + .getByLabel('Choix du cluster cible') + .selectOption({ label: clusterName }) +} + +async function fillEnvironmentResources( + page: Page, + { memoryInput, cpuInput, gpuInput }: { memoryInput: string, cpuInput: string, gpuInput: string }, +) { + await page.getByTestId('memoryInput').fill(memoryInput) + await page.getByTestId('cpuInput').fill(cpuInput) + await page.getByTestId('gpuInput').fill(gpuInput) +} + +async function submitEnvironmentCreation(page: Page) { + await page.getByTestId('addEnvironmentBtn').click() +} + +export async function createEnvironment({ + page, + envName, + zone, + customStageName, + customClusterName, + memoryInput = '1', + cpuInput = '1', + gpuInput = '1', + envsToDelete, +}: { + page: Page + envName?: string + zone: EnvironmentZone + customStageName: string + customClusterName: string + memoryInput?: string + cpuInput?: string + gpuInput?: string + envsToDelete?: string[] +}): Promise { + envName = envName ?? faker.string.alpha(10).toLowerCase() + envsToDelete?.push(envName) + await openEnvironmentCreateForm(page) + await fillEnvironmentName(page, envName) + await selectEnvironmentZone(page, zone) + await selectEnvironmentStage(page, customStageName) + await selectEnvironmentCluster(page, customClusterName) + await fillEnvironmentResources(page, { memoryInput, cpuInput, gpuInput }) + await submitEnvironmentCreation(page) + return envName +} + +export async function deleteEnvironment({ page, projectName, envName }: { page: Page, projectName: string, envName: string }) { + await openMyProjects({ page }) + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId(`environmentTr-${envName}`).click() + await page.getByTestId('showDeleteEnvironmentBtn').click() + await expect(page.getByTestId('deleteEnvironmentZone')).toBeVisible() + await page.getByTestId('deleteEnvironmentInput').fill(deleteValidationInput) + await page.getByTestId('deleteEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).not.toBeVisible() +} diff --git a/.jjconflict-side-0/playwright/helpers/global.d.ts b/.jjconflict-side-0/playwright/helpers/global.d.ts new file mode 100644 index 0000000000..20981cf311 --- /dev/null +++ b/.jjconflict-side-0/playwright/helpers/global.d.ts @@ -0,0 +1,5 @@ +declare global { + interface RegExpConstructor { + escape(string: string): string + } +} diff --git a/.jjconflict-side-0/playwright/helpers/navigation.ts b/.jjconflict-side-0/playwright/helpers/navigation.ts new file mode 100644 index 0000000000..3d4dc22a7d --- /dev/null +++ b/.jjconflict-side-0/playwright/helpers/navigation.ts @@ -0,0 +1,26 @@ +import type { Page } from '@playwright/test' + +export async function openMyProjects({ page }: { page: Page }) { + await page.getByTestId('menuMyProjects').click() +} + +async function ensureAdminMenuVisible(page: Page, menuTestId: string) { + const menuItem = page.getByTestId(menuTestId) + if (!(await menuItem.isVisible())) + await page.getByTestId('menuAdministrationBtn').click() +} + +export async function openClustersAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationClusters') + await page.getByTestId('menuAdministrationClusters').click() +} + +export async function openStagesAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationStages') + await page.getByTestId('menuAdministrationStages').click() +} + +export async function openZonesAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationZones') + await page.getByTestId('menuAdministrationZones').click() +} diff --git a/.jjconflict-side-0/playwright/helpers/project.ts b/.jjconflict-side-0/playwright/helpers/project.ts new file mode 100644 index 0000000000..0d5a8d65d1 --- /dev/null +++ b/.jjconflict-side-0/playwright/helpers/project.ts @@ -0,0 +1,124 @@ +import type { Page } from '@playwright/test' +import type { Credentials } from '../config/console' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput, projectReadinessTimeoutMs } from './constants' +import { openMyProjects } from './navigation' + +const projectUrlRegexp = /\/projects\/[^/]+$/ +const projectSlugPrefixRegexp = /^slug\s*:\s*/i + +interface Resources { + cpu: number + gpu: number + memory: number +} + +async function openProjectCreateForm(page: Page) { + await openMyProjects({ page }) + await page.getByTestId('createProjectLink').click() +} + +async function fillProjectName(page: Page, name: string) { + await page.getByTestId('nameInput').click() + await page.getByTestId('nameInput').fill(name) +} + +async function fillProjectResources({ + page, + hprodResources, + prodResources, +}: { + page: Page + hprodResources?: Resources + prodResources?: Resources +}) { + const fillHprod = async (resources: Resources) => { + await page.getByTestId('cpuHprodInput').fill(resources.cpu.toString()) + await page.getByTestId('gpuHprodInput').fill(resources.gpu.toString()) + await page + .getByTestId('memoryHprodInput') + .fill(resources.memory.toString()) + } + const fillProd = async (resources: Resources) => { + await page.getByTestId('cpuProdInput').fill(resources.cpu.toString()) + await page.getByTestId('gpuProdInput').fill(resources.gpu.toString()) + await page + .getByTestId('memoryProdInput') + .fill(resources.memory.toString()) + } + + if (hprodResources) + await fillHprod(hprodResources) + if (prodResources) + await fillProd(prodResources) +} + +async function enableProjectLimitless(page: Page) { + await page.getByTestId('limitlessProjectSwitch').locator('label').click() +} + +async function addMembersToProject(page: Page, members: Credentials[]) { + await page.getByTestId('test-tab-team').click() + for (const member of members) { + await page + .getByRole('combobox', { name: 'Ajouter un utilisateur' }) + .fill(member.email) + await page.getByTestId('addUserBtn').click() + await expect( + page.getByTestId('teamTable').getByText(member.email), + ).toBeVisible() + } + await page.getByTestId('test-tab-resources').click() +} + +async function getProjectSlugAndId(page: Page) { + const rawSlugText = (await page.getByTestId('project-slug').textContent()) ?? 'no-slug' + const slug = rawSlugText?.replace(projectSlugPrefixRegexp, '').trim() + const id = (await page.getByTestId('project-id').getAttribute('title')) ?? 'no-id' + return { slug, id } +} + +export function projectSlugTextRegexp(projectSlug: string): RegExp { + const escapedSlug = RegExp.escape(projectSlug) + return new RegExp(`^slug:\\s*${escapedSlug}$`) +} + +export async function createProject({ + page, + projectName: name, + members, + hprodResources, + prodResources, +}: { + page: Page + projectName?: string + members?: Credentials[] + hprodResources?: Resources + prodResources?: Resources +}): Promise<{ id: string, slug: string, name: string }> { + name = name ?? faker.string.alpha(10).toLowerCase() + await openProjectCreateForm(page) + await fillProjectName(page, name) + await fillProjectResources({ page, hprodResources, prodResources }) + if (!hprodResources && !prodResources) await enableProjectLimitless(page) + + await page.getByTestId('createProjectBtn').click() + await expect(page).toHaveURL(projectUrlRegexp, { timeout: projectReadinessTimeoutMs }) + await expect(page.getByTestId('project-slug')).toBeVisible({ timeout: projectReadinessTimeoutMs }) + if (members?.length) + await addMembersToProject(page, members) + + const { slug, id } = await getProjectSlugAndId(page) + return { name, slug, id } +} + +export async function deleteProject({ page, projectName }: { page: Page, projectName: string }) { + await openMyProjects({ page }) + await page.getByRole('link', { name: projectName }).click() + await page.getByRole('button', { name: 'Supprimer le projet' }).click() + await page.getByTestId('archiveProjectInput').fill(deleteValidationInput) + await page.getByTestId('confirmDeletionBtn').click() + await openMyProjects({ page }) + await expect(page.getByRole('link', { name: projectName })).not.toBeVisible() +} diff --git a/.jjconflict-side-0/playwright/helpers/repository.ts b/.jjconflict-side-0/playwright/helpers/repository.ts new file mode 100644 index 0000000000..82678de00d --- /dev/null +++ b/.jjconflict-side-0/playwright/helpers/repository.ts @@ -0,0 +1,82 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { repoSyncTimeoutMs } from './constants' +import { waitForSnackbar } from './snackbar' + +const repoSyncApiRegex = /\/api\/.*\/repositories\/.*\/sync$/ + +export function waitForRepoSync({ + page, + timeoutMs = repoSyncTimeoutMs, +}: { + page: Page + timeoutMs?: number +}) { + return page.waitForResponse((response) => { + const request = response.request() + const success = response.status() >= 200 && response.status() < 300 + return request.method() === 'POST' && success && repoSyncApiRegex.test(response.url()) + }, { timeout: timeoutMs }) +} + +async function openAddRepositoryForm(page: Page) { + await page.getByTestId('addRepoLink').click() +} + +async function fillRepositoryUrl(page: Page, externalRepoUrlInput?: string) { + const url = externalRepoUrlInput ?? `${faker.internet.url({ appendSlash: true })}myrepository.git` + await page.getByTestId('externalRepoUrlInput').fill(url) +} + +async function closeResourceModal(page: Page) { + await page + .getByTestId('resource-modal') + .getByRole('button', { name: 'Fermer' }) + .click() +} + +export async function createRepository({ + page, + repositoryName, + externalRepoUrlInput, + infraRepo, +}: { + page: Page + repositoryName?: string + externalRepoUrlInput?: string + infraRepo?: boolean +}) { + repositoryName = repositoryName ?? faker.string.alpha(10).toLowerCase() + await openAddRepositoryForm(page) + await page.getByTestId('internalRepoNameInput').fill(repositoryName) + await fillRepositoryUrl(page, externalRepoUrlInput) + if (infraRepo) + await page.getByText('Dépôt contenant du code d\'').click() + await page.getByTestId('addRepoBtn').click() + await expect(page.getByTestId(`repoTr-${repositoryName}`)).toContainText( + repositoryName, + ) + return repositoryName +} + +export async function synchronizeBranchOnRepository({ + page, + repositoryName, + branchName, +}: { + page: Page + repositoryName: string + branchName?: string +}) { + branchName = branchName ?? faker.string.alpha(10).toLowerCase() + await page.getByTestId(`repoTr-${repositoryName}`).click() + await expect(page.getByTestId('resource-modal')).toBeVisible() + await page.getByTestId('branchNameInput').fill(branchName) + const syncRequest = waitForRepoSync({ page, timeoutMs: repoSyncTimeoutMs }) + await page.getByTestId('syncRepoBtn').click() + await syncRequest + await closeResourceModal(page) + await waitForSnackbar({ page, text: 'Travail de synchronisation lancé' }) + return branchName +} diff --git a/.jjconflict-side-0/playwright/helpers/snackbar.ts b/.jjconflict-side-0/playwright/helpers/snackbar.ts new file mode 100644 index 0000000000..7c7d57d685 --- /dev/null +++ b/.jjconflict-side-0/playwright/helpers/snackbar.ts @@ -0,0 +1,17 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' +import { snackbarTimeoutMs } from './constants' + +export async function waitForSnackbar({ + page, + text, + timeoutMs = snackbarTimeoutMs, +}: { + page: Page + text: string | RegExp + timeoutMs?: number +}) { + await expect(page.getByTestId('snackbar')).toContainText(text, { + timeout: timeoutMs, + }) +} diff --git a/.jjconflict-side-0/playwright/helpers/stage.ts b/.jjconflict-side-0/playwright/helpers/stage.ts new file mode 100644 index 0000000000..f99f261b34 --- /dev/null +++ b/.jjconflict-side-0/playwright/helpers/stage.ts @@ -0,0 +1,76 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openStagesAdministration } from './navigation' + +async function assertStageCreateForm(page: Page) { + await expect(page.locator('h1')).toContainText( + 'Informations du type d\'environnement', + ) + await expect(page.getByTestId('addStageBtn')).toBeVisible() + await expect(page.getByTestId('addStageBtn')).toBeDisabled() + await expect(page.getByTestId('updateStageBtn')).not.toBeVisible() +} + +async function addFirstClusterAssociation(page: Page) { + await page.locator('.fr-tag').first().click() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(1) +} + +async function addAllClusterAssociations(page: Page) { + await expect(page.locator('.fr-tag')).not.toHaveCount(0) + const numberOfAvalaibleClusters = (await page.locator('.fr-tag').all()).length + for (let i = 0; i < numberOfAvalaibleClusters; i++) + await page.locator('.fr-tag').first().click() + + await expect(page.locator('.fr-tag--dismiss')).toHaveCount( + numberOfAvalaibleClusters, + ) +} + +export async function createStage({ + page, + associateToCluster, +}: { + page: Page + associateToCluster: 'first' | 'all' | 'none' +}): Promise { + const stageName = faker.string.alpha(10).toLowerCase() + await openStagesAdministration({ page }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await page.getByTestId('addStageLink').click() + await assertStageCreateForm(page) + await page.getByTestId('nameInput').fill(stageName) + await expect(page.getByTestId('addStageBtn')).toBeEnabled() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(0) + + switch (associateToCluster) { + case 'first': + await addFirstClusterAssociation(page) + break + case 'all': + await addAllClusterAssociations(page) + break + case 'none': + break + } + + await page.getByTestId('addStageBtn').click() + return stageName +} + +export async function deleteStage({ page, stageName }: { page: Page, stageName: string }) { + await openStagesAdministration({ page }) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.getByTestId('deleteStageZone')).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsZone'), + ).not.toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable'), + ).not.toBeVisible() + await page.getByTestId('showDeleteStageBtn').click() + await page.getByTestId('deleteStageInput').fill(deleteValidationInput) + await page.getByTestId('deleteStageBtn').click() +} diff --git a/.jjconflict-side-0/playwright/helpers/zone.ts b/.jjconflict-side-0/playwright/helpers/zone.ts new file mode 100644 index 0000000000..c18973e9ef --- /dev/null +++ b/.jjconflict-side-0/playwright/helpers/zone.ts @@ -0,0 +1,34 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { deleteValidationInput } from './constants' +import { openZonesAdministration } from './navigation' + +async function openZoneCreateForm(page: Page) { + await page.getByTestId('createZoneLink').click() +} + +async function fillZoneCreateForm(page: Page, zoneName: string) { + await page.getByTestId('slugInput').fill(zoneName) + await page.getByTestId('labelInput').fill(zoneName) + await page.getByTestId('argocdUrlInput').fill(faker.internet.url()) + await page + .getByTestId('descriptionInput') + .fill(faker.string.alpha(100).toLowerCase()) +} + +export async function createZone({ page }: { page: Page }): Promise { + const zoneName = faker.string.alpha(10).toLowerCase() + await openZonesAdministration({ page }) + await openZoneCreateForm(page) + await fillZoneCreateForm(page, zoneName) + await page.getByTestId('addZoneBtn').click() + return zoneName +} + +export async function deleteZone({ page, zoneName }: { page: Page, zoneName: string }) { + await openZonesAdministration({ page }) + await page.getByRole('link', { name: zoneName }).click() + await page.getByTestId('showDeleteZoneBtn').click() + await page.getByTestId('deleteZoneInput').fill(deleteValidationInput) + await page.getByTestId('deleteZoneBtn').click() +} diff --git a/.jjconflict-side-0/playwright/integration-tests/admin-setup.spec.ts b/.jjconflict-side-0/playwright/integration-tests/admin-setup.spec.ts new file mode 100644 index 0000000000..da53fa6394 --- /dev/null +++ b/.jjconflict-side-0/playwright/integration-tests/admin-setup.spec.ts @@ -0,0 +1,54 @@ +import { test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' + +import { createCluster, deleteCluster } from '../helpers/cluster' +import { createStage, deleteStage } from '../helpers/stage' +import { createZone, deleteZone } from '../helpers/zone' + +const zonesToDelete: string[] = [] +const stagesToDelete: string[] = [] +const clustersToDelete: string[] = [] + +test.describe('Integration tests for admin', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Admin setup', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + const zoneName = await createZone({ page }) + zonesToDelete.push(zoneName) + // we need to attains 7 stages to be able to use associateStageNames argument in createCluster + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName1 = await createStage({ page, associateToCluster: 'none' }) + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName2 = await createStage({ page, associateToCluster: 'none' }) + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName3 = await createStage({ page, associateToCluster: 'none' }) + stagesToDelete.push(customStageName1) + stagesToDelete.push(customStageName2) + stagesToDelete.push(customStageName3) + const clusterName = await createCluster({ + page, + zone: zoneName, + confidentiality: 'public', + associateStageNames: [customStageName1, customStageName2, customStageName3], + }) + clustersToDelete.push(clusterName) + }) + + test('Cleanup admin test data', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + for (const stageName of stagesToDelete) { + await deleteStage({ page, stageName }) + } + for (const clusterName of clustersToDelete) { + await deleteCluster({ page, clusterName }) + } + for (const zoneName of zonesToDelete) { + await deleteZone({ page, zoneName }) + } + }) +}) diff --git a/.jjconflict-side-0/playwright/integration-tests/gitlab.spec.ts b/.jjconflict-side-0/playwright/integration-tests/gitlab.spec.ts new file mode 100644 index 0000000000..19d5424ddd --- /dev/null +++ b/.jjconflict-side-0/playwright/integration-tests/gitlab.spec.ts @@ -0,0 +1,125 @@ +import { expect, test } from '@playwright/test' +import { clientURL, currentUser, secondTestUser, signInCloudPiNative } from '../config/console' +import { projectReadinessTimeoutMs } from '../helpers/constants' +import { createProject, deleteProject } from '../helpers/project' + +const membersLinkRegexp = /^(members|membres)$/i +const manageLinkRegexp = /^(manage|gérer)$/i +const developerRoleRegexp = /developer/i + +test.describe('GitLab', () => { + test( + 'Should create project and have infra-apps + mirror repos in GitLab', + { tag: ['@integ', '@replayable'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: currentUser }) + + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + try { + await page.getByTestId('test-tab-services').click() + + const gitlabPopup = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const gitlabPage = await gitlabPopup + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + await expect( + gitlabPage.getByTestId('group-name').filter({ hasText: 'infra-apps' }), + ).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await expect( + gitlabPage.getByTestId('group-name').filter({ hasText: 'mirror' }), + ).toBeVisible({ timeout: projectReadinessTimeoutMs }) + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const manageLink = gitlabPage + .getByRole('button', { name: manageLinkRegexp }) + .or(gitlabPage.getByRole('link', { name: manageLinkRegexp })) + .first() + await manageLink.click({ timeout: projectReadinessTimeoutMs }) + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const membersLink = gitlabPage.getByRole('link', { name: membersLinkRegexp }).first() + await membersLink.click({ timeout: projectReadinessTimeoutMs }) + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const currentUserGitlabUsername = currentUser.email.replace('@', '.') + const userRow = gitlabPage.getByRole('row').filter({ hasText: `@${currentUserGitlabUsername}` }).first() + await userRow.scrollIntoViewIfNeeded() + await expect(userRow).toBeVisible({ + timeout: projectReadinessTimeoutMs, + }) + + const botRow = gitlabPage.getByRole('row').filter({ hasText: `${projectSlug}-bot` }).first() + await botRow.scrollIntoViewIfNeeded() + await expect(botRow).toBeVisible({ + timeout: projectReadinessTimeoutMs, + }) + } finally { + await deleteProject({ page, projectName }) + } + }, + ) + + test( + 'Should reflect console role assignment in GitLab builtin role', + { tag: ['@e2e', '@integ', '@replayable'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: currentUser }) + + const { name: projectName } = await createProject({ page }) + + try { + await page.getByTestId('test-tab-team').click() + await page.locator('input[data-testid="addUserSuggestionInput"]').fill(secondTestUser.email) + await page.getByTestId('addUserBtn').click() + const teamMemberRow = page.getByTestId('teamTable').getByRole('row').filter({ hasText: secondTestUser.email }).first() + await expect(teamMemberRow).toBeVisible({ timeout: projectReadinessTimeoutMs }) + const teamMemberUserId = ((await teamMemberRow.locator('code').first().textContent()) ?? '').trim() + expect(teamMemberUserId).not.toEqual('') + + await page.reload() + await expect(page.getByTestId('project-slug')).toBeVisible({ timeout: projectReadinessTimeoutMs }) + + await page.getByTestId('test-tab-roles').click() + await page + .locator('[data-testid$="-tab"]') + .filter({ hasText: 'DevOps' }) + .click() + await page.getByRole('tab', { name: 'Membres' }).click() + const roleMemberCheckbox = page.getByTestId(`input-checkbox-${teamMemberUserId}-cbx`) + await expect(roleMemberCheckbox).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await roleMemberCheckbox.check({ force: true }) + + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour', { timeout: projectReadinessTimeoutMs }) + + await page.getByTestId('test-tab-services').click() + + const gitlabPopup = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const gitlabPage = await gitlabPopup + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const manageLink = gitlabPage + .getByRole('button', { name: manageLinkRegexp }) + .or(gitlabPage.getByRole('link', { name: manageLinkRegexp })) + .first() + await manageLink.click({ timeout: projectReadinessTimeoutMs }) + + const membersLink = gitlabPage.getByRole('link', { name: membersLinkRegexp }).first() + await membersLink.click({ timeout: projectReadinessTimeoutMs }) + + const secondTestUserGitlabUsername = secondTestUser.email.replace('@', '.') + const memberRow = gitlabPage.getByRole('row').filter({ hasText: `@${secondTestUserGitlabUsername}` }).first() + await expect(memberRow).toContainText(developerRoleRegexp, { timeout: projectReadinessTimeoutMs }) + } finally { + await deleteProject({ page, projectName }) + } + }, + ) +}) diff --git a/.jjconflict-side-0/playwright/integration-tests/user-flow.spec.ts b/.jjconflict-side-0/playwright/integration-tests/user-flow.spec.ts new file mode 100644 index 0000000000..a27b10139b --- /dev/null +++ b/.jjconflict-side-0/playwright/integration-tests/user-flow.spec.ts @@ -0,0 +1,390 @@ +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, secondTestUser, signInCloudPiNative, testUser } from '../config/console' +import { pollingFastIntervalMs, projectReadinessTimeoutMs, repoSyncTimeoutMs } from '../helpers/constants' +import { createProject, deleteProject } from '../helpers/project' +import { createRepository } from '../helpers/repository' + +const projectsToDelete: string[] = [] +const projectName = 'socleprojecttest' +const repositoryName = 'socle-project-test' +const destinationCluster = process.env.CONSOLE_DESTINATION_CLUSTER || 'cpin-app-hp' +const helmValuesFiles = process.env.CONSOLE_VALUES_FILE || 'values-cpin-hp.yaml' +projectsToDelete.push(projectName) + +test.describe('Integration tests user flow: project creation', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Preliminary checks', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + // Check all services are healthy + await page.getByTestId('menuServicesHealth').click() + await expect(page.getByTestId('ArgoCD-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Gitlab-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Harbor-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Keycloak-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Nexus-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('SonarQube-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Vault-info').getByText('OK')).toBeVisible() + await expect(page.getByText('Tous les services')).toBeVisible() + // Check maintenance mode is disabled + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationSystemSettings').click() + await expect(page.getByText('Le mode Maintenance est')).not.toBeVisible() + await expect(page.getByText('Désactiver le mode maintenance')).not.toBeVisible() + await expect(page.getByText('Activer le mode maintenance')).toBeVisible() + }) + + test('Project creation and configuration', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page, projectName }) + // Enable Nexus Maven plugin + await page.getByTestId('test-tab-services').click() + await page.getByRole('button', { name: 'Nexus' }).click() + await page + .getByRole('group', { name: 'Activer le dépôt Maven' }) + .locator('input[value="enabled"] + label') + .click() + await page.getByTestId('saveBtn').click() + await expect(page.getByText('Paramètres sauvegardés')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Check if project reprovisioning is successful + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + await expect(page.getByText('Le projet a été reprovisionn')).toBeVisible() + // Add repository to project + await page.getByTestId('test-tab-resources').click() + await createRepository({ + page, + repositoryName, + externalRepoUrlInput: 'https://github.com/cloud-pi-native/socle-project-test.git', + infraRepo: true, + }) + await page.getByRole('cell', { name: repositoryName }).click() + await page.getByTestId('deployRevisionInput').fill('main') + await page.getByTestId('deployPathInput').fill('helm/') + await page.getByTestId('helmValuesFilesTextarea').fill(helmValuesFiles) + await page.getByTestId('updateRepoBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: first checks', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Check Vault kv', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Vault' }).click() + const page1 = await page1Promise + const page2Promise = page1.waitForEvent('popup') + await page1.getByRole('button', { name: 'Sign in with OIDC Provider' }).click() + await page2Promise + await expect(page1.getByRole('link', { name: 'Vault home' })).toBeVisible() + // Check that standard user has access to his project kv + await expect(page1.getByText('No secrets yet')).toBeVisible() + await expect(page1.getByRole('link', { name: 'Create secret' })).toBeVisible() + await page1.getByLabel('breadcrumbs').getByRole('link', { name: 'Secrets' }).click() + // Check that forge-dso kv is not accessible for standard user + await expect(page1.getByRole('link', { name: 'forge-dso' })).not.toBeVisible() + }) + + test('Project permissions', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Add user to project + await page.getByTestId('test-tab-team').click() + await page.getByTestId('addUserSuggestionInput').locator('input').fill(secondTestUser.email) + await page.getByTestId('addUserBtn').click() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Create read-only role + await page.getByTestId('test-tab-roles').click() + await page.getByTestId('addRoleBtn').click() + await page.getByTestId('roleNameInput').fill('readOnly') + await page.getByText('Afficher les secrets Permet d').click() + await page.getByText('Voir les environnements').click() + await page.getByText('Voir les dépôts Permet de').click() + await page.getByTestId('saveBtn').click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Add user to read-only role + await page.getByTestId('test-members').click() + await page.getByLabel('Rôles', { exact: true }).getByText(secondTestUser.email).click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Replay project hooks + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + await expect(page.getByText('Le projet a été reprovisionn')).toBeVisible() + }) + + test('Pipelines run', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if mirror pipeline is successful + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const page1 = await page1Promise + await page1.getByTestId('group-name').filter({ hasText: 'mirror' }).click() + await expect(page1.getByTestId('status_success_borderless-icon')).toBeVisible() + // Check if tests are successful + await page1.getByRole('link', { name: projectName }).click() + await page1.getByTestId('group-name').filter({ hasText: repositoryName }).click() + await page1.getByTestId('ci-icon').click() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-vault' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-nexus' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-harbor' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-sonar' }), + ).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: after pipelines checks', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Prepare ArgoCD deployment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Create environment for project to trigger ArgoCD deployment + await page.getByTestId('test-tab-resources').click() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').click() + await page.getByTestId('environmentNameInput').fill('integ') + await page.getByLabel('Zone *Choix de la zone cible').selectOption({ label: 'DSO' }) + await page.getByLabel('Type d\'environnement *Type d\'').selectOption({ label: 'dev' }) + await page.getByLabel('Cluster *Choix du cluster').selectOption({ label: destinationCluster }) + await page.getByTestId('memoryInput').fill('2') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + }) + + test('View Sonar scan report', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if sonar scan is available + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'SonarQube' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'OpenID Connect Log in with' }).click() + await page1.getByPlaceholder('Search for projects...').fill(projectName) + await page1.getByRole('link', { name: `${projectName}-${repositoryName}` }).click() + await expect( + page1.getByTestId('overview__quality-gate-panel').getByText('Passed', { exact: true }), + ).toBeVisible() + }) + + test('Check Harbor repository', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Harbor' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'LOGIN WITH keycloak' }).click() + await expect(page1.getByRole('button', { name: 'Administration' })).not.toBeVisible() + await expect(page1.getByText('Guest')).toBeVisible() + await expect(page1.getByRole('heading', { name: 'Private' })).toBeVisible() + await page1.getByRole('link', { name: `${projectName}/java-demo` }).click() + await expect(page1.getByRole('button', { name: 'main' })).toBeVisible() + await expect(page1.getByText('Policy')).not.toBeVisible() + // Check trivy scan result, hopefully will stay at C + await expect(page1.getByRole('button', { name: 'C', exact: true })).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: deployment and metrics', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('ArgoCD deployment', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if ArgoCD deployment is successful + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await page1.getByText('app.kubernetes.io/managed-by=dso-console, dso/environment=integ, dso/').click() + await expect(page1.getByText('Synced').nth(1)).toBeVisible() + await expect(page1.getByText('Sync OK')).toBeVisible() + await expect(page1.getByText('Healthy').nth(1)).toBeVisible() + await page1.getByRole('button', { name: ' Details' }).click() + const page2Promise = page1.waitForEvent('popup') + await page1.getByRole('link', { name: `http://${repositoryName}.` }).click() + const page2 = await page2Promise + await expect + .poll( + async () => { + await page2.reload({ waitUntil: 'domcontentloaded' }) + return page2.locator('body').textContent() + }, + { + timeout: repoSyncTimeoutMs, + intervals: [pollingFastIntervalMs], + }, + ) + .toContain('Application is running') + }) + + test('Check Grafana', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Grafana' }).click() + const page1 = await page1Promise + const signInLink = page1.getByRole('link', { name: 'Sign in with grafana-projects' }) + await expect(signInLink).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await signInLink.click({ timeout: projectReadinessTimeoutMs }) + await expect(page1.getByRole('link', { name: 'Grafana', exact: true })).toBeVisible() + await page1.getByTestId('data-testid Toggle menu').click() + await page1.getByRole('button', { name: 'Expand section Dashboards' }).click() + await page1.getByRole('link', { name: 'Dashboards', exact: true }).click() + const dsoDashboard = page1.getByRole('link', { name: 'dso-grafana' }) + await expect(dsoDashboard).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await dsoDashboard.click({ timeout: projectReadinessTimeoutMs }) + // Check if we can see some metrics + await page1.getByRole('link', { name: 'Kubernetes / Views /' }).click() + await expect(page1.getByText('0.100')).toBeVisible() // Cpu request + await expect(page1.getByText('0.500')).toBeVisible() // Cpu limit + await expect(page1.getByText('256')).toBeVisible() // Memory request + await expect(page1.getByText('512')).toBeVisible() // Memory limit + // Check if we can see some logs + await page1.getByTestId('data-testid dso-grafana breadcrumb').click() + await page1.getByRole('link', { name: 'Loki Kubernetes Logs' }).click() + await expect(page1.getByTestId('data-testid Panel status error').first()).not.toBeVisible() + await expect(page1.locator('.rc-drawer-mask')).not.toBeVisible() + await page1.getByTestId('data-testid TimePicker Open Button').click() + await page1.getByText('Last 1 hour').click() + await page1.locator('.css-13x53bc-Icon-topVerticalAlign').first().click() + await expect(page1.getByRole('cell', { name: 'app_kubernetes_io_name' }).nth(1)).toBeVisible() + await expect(page1.getByText('demo-java-helm').nth(1)).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: Cleanup', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Remove permissions and user', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Remove role membership + await page.getByTestId('test-tab-roles').click() + await page.getByRole('button', { name: 'readOnly' }).click() + await page.getByTestId('test-members').click() + await page.getByLabel('Rôles', { exact: true }).getByText(secondTestUser.email).click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Remove role + await page.getByTestId('test-general').click() + await page.getByTestId('deleteBtn').click() + await page.getByText('Rôle supprimé').click() + // Remove project membership + await page.getByTestId('test-tab-team').click() + await page.getByTitle(`Retirer ${secondTestUser.email} du`).click() + }) + + test('Remove stage', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByRole('cell', { name: 'integ' }).click() + await page.getByTestId('showDeleteEnvironmentBtn').click() + await page.getByTestId('deleteEnvironmentInput').fill('DELETE') + await page.getByTestId('deleteEnvironmentBtn').click() + await page.getByRole('heading', { name: 'Opération en cours...' }).click() + await expect( + page.getByRole('cell', { name: 'Aucun environnement existant' }), + ).toBeVisible() + // ArgoCD deployment will be deleted when stage is deleted + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await expect( + page1.getByRole('combobox', { name: 'Search applications...' }), + ).toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-integ-socle-` }), + ).not.toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-cpin-app-hp-integ-root` }), + ).not.toBeVisible() + }) + + // This check will forever fail until we fix + // https://github.com/cloud-pi-native/console/issues/1853 + test('Check Argocd root application deletion', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Tous') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await page.getByRole('cell', { name: projectName }).first().click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await expect( + page1.getByRole('combobox', { name: 'Search applications...' }), + ).toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-cpin-app-hp-integ-root` }), + ).not.toBeVisible() + }) + + test('Remove repository from project', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-resources').click() + await page.getByRole('cell', { name: repositoryName }).click() + await page.getByTestId('showDeleteRepoBtn').click() + await page.getByTestId('deleteRepoInput').fill('DELETE') + await page.getByTestId('deleteRepoBtn').click() + await page.getByRole('heading', { name: 'Opération en cours...' }).click() + await expect(page.getByRole('cell', { name: 'Aucun dépôt existant' })).toBeVisible() + for (const projectName of projectsToDelete) { + await deleteProject({ page, projectName }) + } + }) +}) diff --git a/.jjconflict-side-0/playwright/package.json b/.jjconflict-side-0/playwright/package.json new file mode 100644 index 0000000000..d4b0f175ee --- /dev/null +++ b/.jjconflict-side-0/playwright/package.json @@ -0,0 +1,27 @@ +{ + "name": "@cpn-console/playwright", + "version": "2.0.0", + "private": true, + "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26", + "description": "Our Playwright automated test suite", + "keywords": [ + "playwright", + "tests", + "e2e" + ], + "main": "playwright.config.ts", + "scripts": { + "playwright:test": "pnpm exec playwright test", + "playwright:test:ui": "pnpm exec playwright test --ui", + "integ:playwright:test": "INTEGRATION=true pnpm exec playwright test", + "integ:playwright:test:ui": "INTEGRATION=true pnpm exec playwright test --ui", + "format": "eslint ./ --fix" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@playwright/test": "catalog:test", + "@types/node": "catalog:types" + } +} diff --git a/.jjconflict-side-0/playwright/playwright.config.ts b/.jjconflict-side-0/playwright/playwright.config.ts new file mode 100644 index 0000000000..06a9545f6a --- /dev/null +++ b/.jjconflict-side-0/playwright/playwright.config.ts @@ -0,0 +1,58 @@ +import path from 'node:path' +import { defineConfig, devices } from '@playwright/test' + +process.loadEnvFile(path.resolve(__dirname, '..', 'apps/client', '.env.docker')) + +const isIntegration = process.env.INTEGRATION === 'true' + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: isIntegration ? './integration-tests' : './e2e-tests', + + /* Run tests in files in parallel */ + fullyParallel: true, + + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + + retries: 3, + + workers: process.env.CI ? 1 : undefined, // Default is 50% logical cores + + // The maximum number of test failures forthe whole test suite run. + // After reaching this number, testing will stop and exit with an error. + // Setting to zero (default) disables this behavior. + maxFailures: 1, + + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Retain trace on failed tries. See https://playwright.dev/docs/trace-viewer */ + trace: 'retain-on-failure', + }, + + /* All timeouts are in milliseconds */ + // Timeout for each and every `test` block + timeout: Number(process.env.CONSOLE_GLOBAL_TIMEOUT) || 30_000, + + // Timeout for each and every `expect` command + expect: { + timeout: Number(process.env.CONSOLE_EXPECT_TIMEOUT) || 10_000, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + ], +}) diff --git a/.jjconflict-side-0/playwright/tsconfig.json b/.jjconflict-side-0/playwright/tsconfig.json new file mode 100644 index 0000000000..f9908dab99 --- /dev/null +++ b/.jjconflict-side-0/playwright/tsconfig.json @@ -0,0 +1,41 @@ +{ + "extends": ["@cpn-console/ts-config/tsconfig.base.json"], + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + + "jsx": "preserve", + "lib": ["DOM", "ESNext"], + // Base + "baseUrl": "./", + "rootDir": "./", + "module": "Preserve", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "types": ["node"], + "allowImportingTsExtensions": false, + "allowJs": true, + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useUnknownInCatchVariables": true, + "declaration": true, + "declarationDir": "./types", + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "./dist", + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["./playwright.config.ts", "./config/**/*.ts", "./e2e-tests/**/*.ts", "./helpers/**/*.ts", "./integration-tests/**/*.ts"], + "exclude": [] +} diff --git a/.jjconflict-side-0/plugins/argocd/.gitignore b/.jjconflict-side-0/plugins/argocd/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-0/plugins/argocd/README.md b/.jjconflict-side-0/plugins/argocd/README.md new file mode 100644 index 0000000000..2fe1f154d8 --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/README.md @@ -0,0 +1,200 @@ +# Plugin ArgoCD + +Ce document décrit le fonctionnement du plugin ArgoCD. + +## Hooks et Steps supportés + +Le seul step supporté est pour l'instant le step `main`. Ceci est applicable à tous les hooks de ce plugin. + +## upsertProject hook + +Voici le diagramme de séquence du hook le plus important du plugin (`upsert`) : + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + end + + box Project Cluster + participant ArgoCD_EXT as ArgoCD + participant Kubernetes_EXT as Kubernetes + end + + Bob ->> Console: "Please upsert project" + + loop For every zone/environment + Console ->> Gitlab: Reconcile (create/update/delete) values.yaml files + ArgoCD_EXT ->> Gitlab: Detects new/updated values.yaml files + ArgoCD_EXT ->> Kubernetes_EXT: Apply changes + end +``` + +Les clusters sont dits "externes" car ils sont hors du périmètre de DSO, et gérés de manière autonome par les projets clients, ce qui est une contrainte majeur : on ne peut pas interagir avec l'API Kubernetes (le cluster est considéré comme "air gapped", c'est-à-dire injoignable de l'extérieur, *a fortiori* depuis la Console). C'est en particulier le cas pour les clusters qui se trouvent dans une Zone DR ("Diffusion Restreinte"). + +C'est pourquoi la Console fonctionne avec son paradigme dans lequel c'est le cluster externe qui **vient récupérer les informations pour se mettre à jour**. Et la manière la plus efficace de faire ça, c'est de fonctionner en mode "GitOps", un mode dans lequel la Console crée un fichier de configuration (typiquement un `values.yaml`) qui serait récupéré (d'où le nom de "pull", en opposition au "push" traditionnel dans lequel on va utiliser les API Kubernetes) par le cluster externe afin d'être traité par l'ArgoCD qui lui est rattaché. + +## deleteProject hook + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + participant ArgoCD_DSO as ArgoCD + participant Kubernetes_DSO as Kubernetes + end + + box Project Cluster + participant ArgoCD_EXT as ArgoCD + participant Kubernetes_EXT as Kubernetes + end + + Bob ->> Console: "Please delete project" + + loop For every zone/environment + Console ->> Gitlab: Delete values.yaml files + ArgoCD_EXT ->> Gitlab: Detects deleted values.yaml files + ArgoCD_EXT ->> Kubernetes_EXT: Apply relevant changes (delete resources) + end +``` + +## upsertCluster hook + +Ce hook sert à la création/modification d'un cluster Kubernetes. +Il est à noter la distinction des clusters étiquetés `in-cluster` pour lesquels la gestion des secrets est différente (c'est une exception à la règle). + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please upsert cluster" + + alt cluster has "in-cluster" label + Console ->> Kubernetes: Delete cluster Secret + else cluster DOES NOT HAVE "in-cluster" label + Console ->> Kubernetes: Create cluster Secret + end + + Console ->> Vault: Create entry for cluster data +``` + +## deleteCluster hook + +Ce hook sert à la "suppression" d'un cluster Kubernetes. +Il est à noter que le cluster n'est pas vraiment supprimé, mais "oublié" par la Console, qui n'en garde plus trace. + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please delete cluster" + + Console ->> Kubernetes: Delete cluster Secret + Console ->> Vault: Destroy entry for cluster data +``` + +## upsertZone hook + +Ce hook sert à la création/modification d'une Zone (qui contiendra donc un ou plusieurs clusters Kubernetes) + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please upsert zone" + + Console ->> Vault: Get credentials + Console ->> Gitlab: Create infrastructure project for the zone if needed + Console ->> Gitlab: Create argocd-values.yaml file +``` + +## Deployment as-code (declarative mode) + +```mermaid +--- +config: + layout: default + theme: base + flowchart: + curve: linear + defaultRenderer: "elk" +--- +flowchart TD + subgraph "Helm-charts" + H1@{ shape: doc, label: "Chart 'dso-argocd-zone'" } + H2@{ shape: doc, label: "Chart 'dso-env'" } + H3@{ shape: doc, label: "Chart 'dso-ns'" } + end + subgraph "Gitlab DSO" + subgraph "zone repository" + V1@{ shape: doc, label: "/argocd-values.yaml" } + V2@{ shape: docs, label: "**/values.yaml" } + end + subgraph "project repositories" + V3@{ shape: docs, label: "infra sources" } + end + end + subgraph "DSO Cluster" + A1[Application zone-#lt;name>-app] + A1 --Deploy--> A3[ApplicationSet dso-appset] + A1 --Deploy--> S1[Cluster secrets] + V2 --Generator--> A3 + A3 --Generates--> A4[Application *-root] + V2 --> A4 + H2 -.-> A4 + A4 --Deploys--> A7[Application #lt;project>-env] + A4 --Deploys--> A8@{ shape: procs, label: "Application #lt;project>-#lt;env>-#lt;random-id>" } + + end + S1 --> apps + V1 --> A1 + H1 -.-> A1 + V3 ==> A8 + H3 -.-> A7 + V2 --> A7 + A7 --Deploys--> namespace & R1 & R2 & R3 + A8 ==Deploys==> A10 + A8 --Deploys--> A11 + subgraph apps["Apps Clusters"] + subgraph namespace["Namespace"] + A10@{ shape: procs, label: "K8S resources" } + A11@{ shape: procs, label: "VaultStaticSecrets" } + R1[ResourceQuota] + R2[VaultConnection] + R3[VaultStaticSecret registry-pull-secret] + R3 --> R4[Secret registry-pull-secret] + A11 --> R5@{ shape: procs, label: "Secrets" } + end + end + subgraph "Vault DSO" + kv1["KV Infra"] + kv2@{ shape: procs, label: "KV Projets" } + end + kv1 --> R3 + kv2 --> A11 +``` diff --git a/.jjconflict-side-0/plugins/argocd/eslint.config.js b/.jjconflict-side-0/plugins/argocd/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-0/plugins/argocd/package.json b/.jjconflict-side-0/plugins/argocd/package.json new file mode 100644 index 0000000000..042fad6e4d --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/package.json @@ -0,0 +1,53 @@ +{ + "name": "@cpn-console/argocd-plugin", + "type": "module", + "version": "2.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@himenon/argocd-typescript-openapi": "catalog:runtime", + "axios": "catalog:runtime", + "yaml": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/plugins/argocd/src/cluster.ts b/.jjconflict-side-0/plugins/argocd/src/cluster.ts new file mode 100644 index 0000000000..efdb6de462 --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/src/cluster.ts @@ -0,0 +1,69 @@ +import type { ClusterObject, StepCall } from '@cpn-console/hooks' +import { updateZoneValues } from './utils.js' + +export const upsertCluster: StepCall = async (payload) => { + try { + const cluster = payload.args + const { vault } = payload.apis + const clusterData = { + name: cluster.label, + clusterResources: cluster.clusterResources.toString(), + server: cluster.cluster.server, + config: JSON.stringify(convertConfig(cluster)), + } + await vault.upsert() + await vault.write(clusterData, `clusters/cluster-${cluster.label}/argocd-cluster-secret`) + await updateZoneValues(cluster.zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Cluster secret created/updated', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed create/update cluster secret', + }, + } + } +} + +export const deleteCluster: StepCall = async (payload) => { + try { + await payload.apis.vault.destroy(`clusters/cluster-${payload.args.label}/argocd-cluster-secret`) + await updateZoneValues(payload.args.zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Cluster secret deleted', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed to delete cluster secret', + }, + } + } +} + +// ...désolé +function convertConfig(cluster: ClusterObject) { + return { + ...cluster.user?.username && { username: cluster.user?.username }, + ...cluster.user?.password && { password: cluster.user?.password }, + ...cluster.user?.token && { bearerToken: cluster.user?.token }, + tlsClientConfig: { + ...cluster.user?.keyData && { keyData: cluster.user?.keyData }, + ...cluster.user?.certData && { certData: cluster.user?.certData }, + ...(cluster.cluster?.caData && !cluster.cluster?.skipTLSVerify) && { caData: cluster.cluster?.caData }, + ...cluster.cluster?.skipTLSVerify && { insecure: cluster.cluster.skipTLSVerify }, + serverName: cluster.cluster.tlsServerName, + }, + } +} diff --git a/.jjconflict-side-0/plugins/argocd/src/env.d.ts b/.jjconflict-side-0/plugins/argocd/src/env.d.ts new file mode 100644 index 0000000000..58ef50fd4c --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/src/env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/.jjconflict-side-0/plugins/argocd/src/functions.spec.ts b/.jjconflict-side-0/plugins/argocd/src/functions.spec.ts new file mode 100644 index 0000000000..2a036bc0c4 --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/src/functions.spec.ts @@ -0,0 +1,212 @@ +import type { ClusterObject, Environment, Project, Repository } from '@cpn-console/hooks' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { stringify } from 'yaml' +import { deleteProject, upsertProject } from './functions.js' + +vi.mock('./utils.js', () => ({ + generateAppProjectName: vi.fn(() => 'app-project-name'), + getConfig: vi.fn(() => ({ namespace: 'argocd', url: 'https://argocd.example.com' })), +})) + +vi.mock('@cpn-console/shared', async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + generateNamespaceName: vi.fn(() => 'namespace-name'), + } +}) + +describe('argocd functions', () => { + const mockGitlabApi = { + // Used to get the public group URL for sourceRepositories + getPublicGroupUrl: vi.fn(), + // Used to get or create the infra project where values.yaml is stored + getOrCreateInfraProject: vi.fn(), + // Used to get the infra project details (repo URL) + getProjectById: vi.fn(), + // Used to resolve the public URL of a repository + getPublicRepoUrl: vi.fn(), + // Used to commit the values.yaml file + commitCreateOrUpdate: vi.fn(), + // Used to list files in the infra project for cleanup + listFiles: vi.fn(), + // Used to delete files from the infra project + commitDelete: vi.fn(), + } + // Used to get the RO and RW groups for the environment + const mockKeycloakApi = { + getEnvGroup: vi.fn(), + } + // Used to get user vault secrets + const mockVaultApi = { + Project: { + getValues: vi.fn(), + }, + } + + let mockCluster: ClusterObject + let mockEnvironment: Environment + let mockRepo: Repository + let mockProject: Project + + beforeEach(() => { + vi.clearAllMocks() + + mockCluster = { + id: faker.string.uuid(), + label: faker.string.alphanumeric(10), + zone: { slug: faker.string.alphanumeric(5) }, + privacy: 'public', + } as any + + mockEnvironment = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(5), + clusterId: mockCluster.id, + cpu: faker.number.int({ min: 1, max: 10 }), + memory: faker.number.int({ min: 1, max: 32 }), + gpu: 0, + autosync: true, + } as any + + mockRepo = { + id: faker.string.uuid(), + internalRepoName: faker.string.alphanumeric(10), + isInfra: true, + helmValuesFiles: null, + deployRevision: 'main', + deployPath: '/deploy', + } as any + + mockProject = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(10), + slug: faker.string.alphanumeric(10), + repositories: [mockRepo], + environments: [mockEnvironment], + clusters: [mockCluster], + store: {}, + } as any + }) + + it('upsertProject should succeed', async () => { + const infraProjectId = faker.number.int() + const infraProjectUrl = faker.internet.url() + const gitlabGroupUrl = faker.internet.url() + const gitlabRepoUrl = faker.internet.url() + + mockGitlabApi.getPublicGroupUrl.mockResolvedValue(gitlabGroupUrl) + mockGitlabApi.getOrCreateInfraProject.mockResolvedValue({ id: infraProjectId, http_url_to_repo: infraProjectUrl }) + mockKeycloakApi.getEnvGroup.mockResolvedValue({ subgroups: { RO: '/ro', RW: '/rw' } }) + mockVaultApi.Project.getValues.mockResolvedValue({ secret: 'value' }) + mockGitlabApi.getProjectById.mockResolvedValue({ http_url_to_repo: infraProjectUrl }) + mockGitlabApi.listFiles.mockResolvedValue([]) // No files to delete + mockGitlabApi.getPublicRepoUrl.mockResolvedValue(gitlabRepoUrl) + + const payload = { + args: mockProject, + apis: { + gitlab: mockGitlabApi, + keycloak: mockKeycloakApi, + vault: mockVaultApi, + }, + config: {}, + } as any + + const result = await upsertProject(payload) + + expect(result.status.result).toBe('OK') + + const expectedValues = { + common: { + 'dso/project': mockProject.name, + 'dso/project.id': mockProject.id, + 'dso/project.slug': mockProject.slug, + 'dso/environment': mockEnvironment.name, + 'dso/environment.id': mockEnvironment.id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'app-project-name', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: infraProjectUrl, + valueFileRevision: 'HEAD', + valueFilePath: `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, + roGroup: '/ro', + rwGroup: '/rw', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + projectAdminGroup: `/${mockProject.slug}/console/admin`, + projectDevopsGroup: `/${mockProject.slug}/console/devops`, + projectDevelopperGroup: `/${mockProject.slug}/console/developer`, + projectReadonlyGroup: `/${mockProject.slug}/console/readonly`, + }, + application: { + quota: { + cpu: mockEnvironment.cpu, + gpu: mockEnvironment.gpu, + memory: `${mockEnvironment.memory}Gi`, + }, + sourceRepositories: [ + `${gitlabGroupUrl}/**`, + ], + destination: { + namespace: 'namespace-name', + name: mockCluster.label, + }, + autosync: true, + vault: { secret: 'value' }, + repositories: [ + { + id: mockRepo.id, + name: mockRepo.internalRepoName, + repoURL: gitlabRepoUrl, + targetRevision: 'main', + path: '/deploy', + valueFiles: [], + }, + ], + }, + } + + expect(mockGitlabApi.commitCreateOrUpdate).toHaveBeenCalledWith( + infraProjectId, + stringify(expectedValues), + `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, + ) + expect(mockGitlabApi.commitDelete).toHaveBeenCalledWith(infraProjectId, []) + }) + + it('deleteProject should succeed', async () => { + const infraProjectId = faker.number.int() + mockGitlabApi.getOrCreateInfraProject.mockResolvedValue({ id: infraProjectId }) + mockGitlabApi.listFiles.mockResolvedValue([ + { name: 'values.yaml', path: `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, type: 'blob' }, + { name: 'values.yaml', path: `${mockProject.name}/${mockCluster.label}/old-env/values.yaml`, type: 'blob' }, + ]) + + const payload = { + args: mockProject, + apis: { + gitlab: mockGitlabApi, + }, + } as any + + const result = await deleteProject(payload) + + expect(result.status.result).toBe('OK') + expect(mockGitlabApi.listFiles).toHaveBeenCalledWith(infraProjectId, { + path: `${mockProject.name}/`, + recursive: true, + }) + // Only the stale env file should be deleted (not in project.environments) + expect(mockGitlabApi.commitDelete).toHaveBeenCalledWith(infraProjectId, [ + `${mockProject.name}/${mockCluster.label}/old-env/values.yaml`, + ]) + }) +}) diff --git a/.jjconflict-side-0/plugins/argocd/src/functions.ts b/.jjconflict-side-0/plugins/argocd/src/functions.ts new file mode 100644 index 0000000000..7c1ccbc139 --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/src/functions.ts @@ -0,0 +1,281 @@ +import type { GitlabProjectApi } from '@cpn-console/gitlab-plugin/types/class.js' +import type { + ClusterObject, + Config, + Environment, + Project, + Repository, + StepCall, +} from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' +import { stringify } from 'yaml' +import { + DEFAULT_DSO_ENV_CHART_VERSION, + DEFAULT_DSO_NS_CHART_VERSION, + DEFAULT_PLATFORM_ADMIN_GROUP_PATH, + DEFAULT_PLATFORM_READONLY_GROUP_PATH, + DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX, +} from './infos.js' +import { logger } from './logger.js' +import { generateAppProjectName, getConfig } from './utils.js' + +function splitExtraRepositories(repos?: string): string[] { + return repos ? repos.split(',').map(repo => repo.trim()) : [] +} + +function getValueFilePath(p: Project, c: ClusterObject, e: Environment): string { + return `${p.name}/${c.label}/${e.name}/values.yaml` +} + +export const upsertProject: StepCall = async (payload) => { + const project = payload.args + const gitlabApi = payload.apis.gitlab as unknown as GitlabProjectApi + const keycloakApi = payload.apis.keycloak as any + const vaultApi = payload.apis.vault as unknown as VaultProjectApi + + try { + const infraRepositories = project.repositories.filter( + repo => repo.isInfra, + ) + const sourceRepositories = [ + `${await gitlabApi.getPublicGroupUrl()}/**`, + ...splitExtraRepositories(payload.config.argocd?.extraRepositories), + ...splitExtraRepositories(project.store.argocd?.extraRepositories), + ] + + await Promise.all( + project.environments.map(async (environment) => { + const appNamespace = generateNamespaceName(project.id, environment.id) + const cluster = getCluster(project, environment) + const infraProject = await gitlabApi.getOrCreateInfraProject(cluster.zone.slug) + const appProjectName = generateAppProjectName(project.slug, environment.name) + const envGroup = await (keycloakApi as any).getEnvGroup(environment.name) + const roGroup = envGroup.subgroups.RO + const rwGroup = envGroup.subgroups.RW + + await ensureInfraEnvValues( + project, + environment, + appNamespace, + roGroup, + rwGroup, + appProjectName, + infraRepositories, + infraProject.id, + sourceRepositories, + gitlabApi, + vaultApi, + payload.config, + ) + }), + ) + + await removeInfraEnvValues(project, gitlabApi) + + logger.info({ action: 'upsertProject', projectSlug: project.slug }, 'Hook done') + + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +interface ArgoRepoSource { + repoURL: string + targetRevision: string + path: string + valueFiles: string[] +} + +async function ensureInfraEnvValues( + project: Project, + environment: Environment, + appNamespace: string, + roGroup: string, + rwGroup: string, + appProjectName: string, + infraRepositories: Repository[], + repoId: number, + sourceRepositories: string[], + gitlabApi: GitlabProjectApi, + vaultApi: VaultProjectApi, + config: Config, +) { + const platformAdminGroupPath = config.argocd?.platformAdminGroupPath ?? DEFAULT_PLATFORM_ADMIN_GROUP_PATH + const platformReadonlyGroupPath = config.argocd?.platformReadonlyGroupPath ?? DEFAULT_PLATFORM_READONLY_GROUP_PATH + const projectAdminGroupSuffix = config.argocd?.projectAdminGroupPathSuffix ?? DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX + const projectDevopsGroupSuffix = config.argocd?.projectDevopsGroupPathSuffix ?? DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX + const projectDevelopperGroupSuffix = config.argocd?.projectDevelopperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + const projectReadonlyGroupSuffix = config.argocd?.projectReadonlyGroupPathSuffix ?? DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX + const dsoEnvChartVersion = config.argocd?.dsoEnvChartVersion ?? DEFAULT_DSO_ENV_CHART_VERSION + const dsoNsChartVersion = config.argocd?.dsoNsChartVersion ?? DEFAULT_DSO_NS_CHART_VERSION + const cluster = getCluster(project, environment) + const infraProject = await gitlabApi.getProjectById(repoId) + const valueFilePath = getValueFilePath(project, cluster, environment) + const vaultValues = await vaultApi.Project.getValues() + const repositories: ArgoRepoSource[] = await Promise.all( + infraRepositories.map(async (repository) => { + const repoURL = await gitlabApi.getPublicRepoUrl( + repository.internalRepoName, + ) + const valueFiles = repository.helmValuesFiles + ? repository.helmValuesFiles + .replaceAll('', environment.name) + .split(',') + : [] + return { + id: repository.id, + name: repository.internalRepoName, + repoURL, + targetRevision: repository.deployRevision || 'HEAD', + path: repository.deployPath || '.', + valueFiles, + } + }), + ) + + const values = { + common: { + 'dso/project': project.name, + 'dso/project.id': project.id, + 'dso/project.slug': project.slug, + 'dso/environment': environment.name, + 'dso/environment.id': environment.id, + }, + argocd: { + cluster: inClusterLabel, + namespace: getConfig().namespace, + project: appProjectName, + envChartVersion: dsoEnvChartVersion, + nsChartVersion: dsoNsChartVersion, + }, + environment: { + valueFileRepository: infraProject.http_url_to_repo, + valueFileRevision: 'HEAD', + valueFilePath, + roGroup, + rwGroup, + platformAdminGroup: platformAdminGroupPath, + platformReadonlyGroup: platformReadonlyGroupPath, + projectAdminGroup: `/${project.slug}${projectAdminGroupSuffix}`, + projectDevopsGroup: `/${project.slug}${projectDevopsGroupSuffix}`, + projectDevelopperGroup: `/${project.slug}${projectDevelopperGroupSuffix}`, + projectReadonlyGroup: `/${project.slug}${projectReadonlyGroupSuffix}`, + }, + application: { + quota: { + cpu: environment.cpu, + gpu: environment.gpu, + memory: `${environment.memory}Gi`, + }, + sourceRepositories, + destination: { + namespace: appNamespace, + name: cluster.label, + }, + autosync: environment.autosync, + vault: vaultValues, + repositories, + }, + } + + await gitlabApi.commitCreateOrUpdate(repoId, stringify(values), valueFilePath) +} + +function getCluster(p: Project, e: Environment): ClusterObject { + const c = p.clusters.find(c => c.id === e.clusterId) + if (!c) throw new Error(`Unable to find cluster ${e.id} for env ${e.name}`) + return c +} + +async function removeInfraEnvValues( + project: Project, + gitlabApi: GitlabProjectApi, +) { + for (const z of getDistinctZones(project)) { + const infraProject = await gitlabApi.getOrCreateInfraProject(z) + const existingFiles = await gitlabApi.listFiles(infraProject.id, { + path: `${project.name}/`, + recursive: true, + }) + const neededFiles = project.environments.map(env => + getValueFilePath(project, getCluster(project, env), env), + ) + const filesToDelete: string[] = [] + for (const existingFile of existingFiles) { + if ( + existingFile.name === 'values.yaml' + && !neededFiles.includes(existingFile.path) + ) { + filesToDelete.push(existingFile.path) + } + } + await gitlabApi.commitDelete(infraProject.id, filesToDelete) + } +} + +function getDistinctZones(project: Project) { + const zones: Set = new Set() + project.clusters.forEach(c => zones.add(c.zone.slug)) + return zones +} + +async function cleanupProjectInfra( + project: Project, + gitlabApi: GitlabProjectApi, +) { + for (const z of getDistinctZones(project)) { + const infraProject = await gitlabApi.getOrCreateInfraProject(z) + const existingFiles = await gitlabApi.listFiles(infraProject.id, { + path: `${project.name}/`, + recursive: true, + }) + const filesToDelete = existingFiles + .filter(f => f.name === 'values.yaml') + .map(f => f.path) + await gitlabApi.commitDelete(infraProject.id, filesToDelete) + } +} + +export const deleteProject: StepCall = async (payload) => { + const project = payload.args + const gitlabApi = payload.apis.gitlab as unknown as GitlabProjectApi + + try { + await cleanupProjectInfra(project, gitlabApi) + + logger.info({ action: 'deleteProject', projectSlug: project.slug }, 'Hook done') + + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'deleteProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} diff --git a/.jjconflict-side-0/plugins/argocd/src/index.ts b/.jjconflict-side-0/plugins/argocd/src/index.ts new file mode 100644 index 0000000000..5d7acfdda0 --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/src/index.ts @@ -0,0 +1,43 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import { deleteCluster, upsertCluster } from './cluster.js' +import { deleteProject, upsertProject } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' +import { upsertZone } from './zone.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { + steps: { + main: upsertProject, + }, + }, + deleteProject: { + steps: { + main: deleteProject, + }, + }, + upsertCluster: { + steps: { + main: upsertCluster, + }, + }, + deleteCluster: { + steps: { + main: deleteCluster, + }, + }, + upsertZone: { + steps: { + main: upsertZone, + }, + }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-side-0/plugins/argocd/src/infos.ts b/.jjconflict-side-0/plugins/argocd/src/infos.ts new file mode 100644 index 0000000000..71d672c5e1 --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/src/infos.ts @@ -0,0 +1,133 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const extraRepositoriesDesc = 'appproject.spec.sourceRepos supplémentaires, séparés par des virgules (https://a.com/repo.git,https://b.com/' + +export const DEFAULT_PLATFORM_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_PLATFORM_READONLY_GROUP_PATH = '/console/readonly' +export const DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX = '/console/readonly' +export const DEFAULT_DSO_ENV_CHART_VERSION = 'dso-env-1.6.0' +export const DEFAULT_DSO_NS_CHART_VERSION = 'dso-ns-1.1.5' + +const infos = { + name: 'argocd', + to: ({ zones, project }) => zones.map(z => ({ + to: `${z.argocdUrl}/applications?showFavorites=false&proj=&sync=&health=&namespace=&cluster=&labels=&search=${project.slug}`, + title: `ArgoCD ${z.label}`, + })), + title: 'ArgoCD', + imgSrc: '/img/argocd.svg', + description: 'ArgoCD est un outil déclaratif de livraison continue GitOps pour Kubernetes', + config: { + global: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }, { + key: 'platformAdminGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Admin Group Path', + value: DEFAULT_PLATFORM_ADMIN_GROUP_PATH, + description: 'Chemin du groupe administrateur de plateforme', + }, { + key: 'platformReadonlyGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Readonly Group Path', + value: DEFAULT_PLATFORM_READONLY_GROUP_PATH, + description: 'Chemin du groupe lecture seule de plateforme', + }, { + key: 'projectAdminGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Admin Group Path Suffix', + value: DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe administrateur de projet', + }, { + key: 'projectDevopsGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project DevOps Group Path Suffix', + value: DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe devops de projet', + }, { + key: 'projectDevelopperGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Developer Group Path Suffix', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe développeur de projet', + }, { + key: 'projectReadonlyGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Readonly Group Path Suffix', + value: DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe lecture seule de projet', + }, { + key: 'dsoEnvChartVersion', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Env Chart Version', + value: DEFAULT_DSO_ENV_CHART_VERSION, + description: 'Version du chart Helm dso-env', + placeholder: DEFAULT_DSO_ENV_CHART_VERSION, + }, { + key: 'dsoNsChartVersion', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Namespace Chart Version', + value: DEFAULT_DSO_NS_CHART_VERSION, + description: 'Version du chart Helm dso-ns', + placeholder: DEFAULT_DSO_NS_CHART_VERSION, + }], + project: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-side-0/plugins/argocd/src/logger.ts b/.jjconflict-side-0/plugins/argocd/src/logger.ts new file mode 100644 index 0000000000..45416d7025 --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'argocd' }) diff --git a/.jjconflict-side-0/plugins/argocd/src/monitor.ts b/.jjconflict-side-0/plugins/argocd/src/monitor.ts new file mode 100644 index 0000000000..132a2a4441 --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/src/monitor.ts @@ -0,0 +1,27 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(requiredEnv('ARGOCD_INTERNAL_URL'), { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-0/plugins/argocd/src/utils.ts b/.jjconflict-side-0/plugins/argocd/src/utils.ts new file mode 100644 index 0000000000..151f2389ef --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/src/utils.ts @@ -0,0 +1,51 @@ +import type { GitlabZoneApi } from '@cpn-console/gitlab-plugin/types/class.js' +import type { ClusterObject, HookPayloadApis, ZoneObject } from '@cpn-console/hooks' +import { createHmac } from 'node:crypto' +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import { stringify } from 'yaml' + +export function generateAppProjectName(projectSlug: string, env: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${envHash}` +} + +export function generateApplicationName(projectSlug: string, env: string, repo: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${repo}-${envHash}` +} + +const config: { + namespace?: string + url?: string +} = {} + +export function getConfig(): Required { + config.namespace = config.namespace ?? requiredEnv('ARGO_NAMESPACE') + config.url = removeTrailingSlash(requiredEnv('ARGOCD_URL')) + + // @ts-ignore + return config +} + +export async function updateZoneValues(zone: ZoneObject, apis: HookPayloadApis | HookPayloadApis) { + const gitlab = apis.gitlab as unknown as GitlabZoneApi + const vault = apis.vault as any + const values = { + vault: await vault.getValues(), + clusters: zone.clusterNames, + } + const zoneRepo = await gitlab.getOrCreateInfraProject(zone.slug) + await gitlab.commitCreateOrUpdate(zoneRepo.id, stringify(values), 'argocd-values.yaml') + return { + status: { + result: 'OK', + message: 'Zone argocd configuration created/updated', + }, + } +} diff --git a/.jjconflict-side-0/plugins/argocd/src/zone.ts b/.jjconflict-side-0/plugins/argocd/src/zone.ts new file mode 100644 index 0000000000..61e74e765a --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/src/zone.ts @@ -0,0 +1,23 @@ +import type { StepCall, ZoneObject } from '@cpn-console/hooks' +import { updateZoneValues } from './utils.js' + +export const upsertZone: StepCall = async (payload) => { + try { + const zone = payload.args + await updateZoneValues(zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Zone argocd configuration created/updated', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed create/update zone argocd configuration', + }, + } + } +} diff --git a/.jjconflict-side-0/plugins/argocd/tsconfig.eslint.json b/.jjconflict-side-0/plugins/argocd/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-0/plugins/argocd/tsconfig.json b/.jjconflict-side-0/plugins/argocd/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-0/plugins/argocd/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-0/plugins/gitlab/.gitignore b/.jjconflict-side-0/plugins/gitlab/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-0/plugins/gitlab/eslint.config.js b/.jjconflict-side-0/plugins/gitlab/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-0/plugins/gitlab/files/.gitlab-ci.yml b/.jjconflict-side-0/plugins/gitlab/files/.gitlab-ci.yml new file mode 100644 index 0000000000..ca9be29847 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/files/.gitlab-ci.yml @@ -0,0 +1,22 @@ +variables: + PROJECT_NAME: + description: Nom du dépôt (dans ce Gitlab) à synchroniser. + GIT_BRANCH_DEPLOY: + description: Nom de la branche à synchroniser. + value: main + SYNC_ALL: + description: Synchroniser toutes les branches. + value: "false" + +include: + - project: $CATALOG_PATH + file: mirror.yml + ref: main + +repo_pull_sync: + extends: .repo_pull_sync + only: + - api + - triggers + - web + - schedules diff --git a/.jjconflict-side-0/plugins/gitlab/files/mirror.sh b/.jjconflict-side-0/plugins/gitlab/files/mirror.sh new file mode 100644 index 0000000000..8f5fbdfe37 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/files/mirror.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' + +# Console step increment +i=1 + +# Default values +BRANCH_TO_SYNC=main + +print_help() { + TEXT_HELPER="\nThis script aims to send a synchronization request to DSO.\nFollowing flags are available: + -a Api url to send the synchronization request + -b Branch which is wanted to be synchronize for the given repository (default '$BRANCH_TO_SYNC') + -g GitLab token to trigger the pipeline on the gitlab mirror project + -i Gitlab mirror project id + -r Gitlab repository name to mirror + -h Print script help\n" + printf "$TEXT_HELPER" +} + +print_args() { + printf "\nArguments received: + -a API_URL: $API_URL + -b BRANCH_TO_SYNC: $BRANCH_TO_SYNC + -g GITLAB_TRIGGER_TOKEN length: ${#GITLAB_TRIGGER_TOKEN} + -i GITLAB_MIRROR_PROJECT_ID: $GITLAB_MIRROR_PROJECT_ID + -r REPOSITORY_NAME: $REPOSITORY_NAME\n" +} + +# Parse options +while getopts :ha:b:g:i:r: flag +do + case "${flag}" in + a) + API_URL=${OPTARG};; + b) + BRANCH_TO_SYNC=${OPTARG};; + g) + GITLAB_TRIGGER_TOKEN=${OPTARG};; + i) + GITLAB_MIRROR_PROJECT_ID=${OPTARG};; + r) + REPOSITORY_NAME=${OPTARG};; + h) + printf "\nHelp requested.\n" + print_help + printf "\nExiting.\n" + exit 0;; + *) + printf "\nInvalid argument ${OPTARG} (${flag}).\n" + print_help + print_args + exit 1;; + esac +done + +# Test if arguments are missing +if [ -z ${API_URL} ] || [ -z ${BRANCH_TO_SYNC} ] || [ -z ${GITLAB_TRIGGER_TOKEN} ] || [ -z ${GITLAB_MIRROR_PROJECT_ID} ] || [ -z ${REPOSITORY_NAME} ]; then + printf "\nArgument(s) missing!\n" + print_help + print_args + exit 2 +fi + +# Print arguments +print_args + +# Send synchronization request +printf "\n${red}${i}.${no_color} Send request to DSO api.\n\n" + +curl \ + -X POST \ + --fail \ + -F token=${GITLAB_TRIGGER_TOKEN} \ + -F ref=main \ + -F variables[GIT_BRANCH_DEPLOY]=${BRANCH_TO_SYNC} \ + -F variables[PROJECT_NAME]=${REPOSITORY_NAME} \ + "${API_URL}/api/v4/projects/${GITLAB_MIRROR_PROJECT_ID}/trigger/pipeline" diff --git a/.jjconflict-side-0/plugins/gitlab/package.json b/.jjconflict-side-0/plugins/gitlab/package.json new file mode 100644 index 0000000000..5640a675c7 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/package.json @@ -0,0 +1,52 @@ +{ + "name": "@cpn-console/gitlab-plugin", + "type": "module", + "version": "3.3.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "files", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/requester-utils": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/plugins/gitlab/src/class.ts b/.jjconflict-side-0/plugins/gitlab/src/class.ts new file mode 100644 index 0000000000..2ddb702b02 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/class.ts @@ -0,0 +1,686 @@ +import type { Project, ProjectMember, UniqueRepo } from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { AccessTokenScopes, AllRepositoryTreesOptions, CommitAction, CondensedProjectSchema, Gitlab, GroupSchema, MemberSchema, ProjectSchema, ProjectVariableSchema, RepositoryFileExpandedSchema, VariableSchema } from '@gitbeaker/core' +import type { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { createHash } from 'node:crypto' +import { PluginApi } from '@cpn-console/hooks' +import { objectEntries } from '@cpn-console/shared' +import { AccessLevel } from '@gitbeaker/core' +import config from './config.js' +import { + customAttributesFilter, + infraGroupCustomAttributeKey, + managedByConsoleCustomAttributeKey, + projectGroupCustomAttributeKey, + upsertCustomAttribute, +} from './custom-attributes.js' +import { logger } from './logger.js' +import { + find, + getAll, + getApi, + getGroupRootId, + infraAppsRepoName, + internalMirrorRepoName, + MAX_PAGINATION_PER_PAGE, + offsetPaginate, +} from './utils.js' + +type setVariableResult = 'created' | 'updated' | 'already up-to-date' +type AccessLevelAllowed = AccessLevel.NO_ACCESS | AccessLevel.MINIMAL_ACCESS | AccessLevel.GUEST | AccessLevel.REPORTER | AccessLevel.DEVELOPER | AccessLevel.MAINTAINER | AccessLevel.OWNER +const infraGroupName = 'Infra' +const infraGroupPath = 'infra' +export const pluginManagedTopic = 'plugin-managed' + +interface GitlabMirrorSecret { + MIRROR_USER: string + MIRROR_TOKEN: string +} + +interface RepoSelect { + mirror?: CondensedProjectSchema + target?: CondensedProjectSchema +} +type PendingCommits = Record +}> + +interface CreateEmptyRepositoryArgs { + repoName: string + description?: string +} + +export class GitlabApi extends PluginApi { + protected api: Gitlab + private pendingCommits: PendingCommits = {} + + constructor() { + super() + this.api = getApi() + } + + public async createEmptyRepository({ createFirstCommit, groupId, repoName, description, ciConfigPath }: CreateEmptyRepositoryArgs & { + createFirstCommit: boolean + groupId: number + ciConfigPath?: string + }) { + logger.debug({ action: 'createEmptyRepository', repoName, groupId, createFirstCommit, ciConfigPath }, 'Create empty repository') + const project = await this.api.Projects.create({ + name: repoName, + path: repoName, + ciConfigPath, + namespaceId: groupId, + description, + }) + try { + await upsertCustomAttribute('projects', project.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createEmptyRepository', projectId: project.id, err }, 'Failed to upsert project custom attribute') + } + // Dépôt tout juste créé, zéro branche => pas d'erreur (filesTree undefined) + if (createFirstCommit) { + await this.api.Commits.create(project.id, 'main', 'ci: 🌱 First commit', []) + } + return project + } + + public async commitCreateOrUpdate( + repoId: number, + fileContent: string, + filePath: string, + branch: string = 'main', + comment: string = 'ci: :robot_face: Update file content', + ): Promise { + logger.debug({ action: 'commitCreateOrUpdate', repoId, filePath, branch }, 'Schedule commit create/update') + let action: CommitAction['action'] = 'create' + + const existingBranch = await find(offsetPaginate(opts => this.api.Branches.all(repoId, opts), { perPage: MAX_PAGINATION_PER_PAGE }), b => b.name === branch) + if (existingBranch) { + let actualFile: RepositoryFileExpandedSchema | undefined + try { + actualFile = await this.api.RepositoryFiles.show(repoId, filePath, branch) + } catch {} + if (actualFile) { + const newContentDigest = createHash('sha256').update(fileContent).digest('hex') + if (actualFile.content_sha256 === newContentDigest) { + // Already up-to-date + return false + } + // Update needed + action = 'update' + } + } + + const commitAction: CommitAction = { + action, + filePath, + content: fileContent, + } + this.addActions(repoId, branch, comment, [commitAction]) + + return true + } + + /** + * Fonction pour supprimer une liste de fichiers d'un repo + * @param repoId + * @param files + * @param branch + * @param comment + */ + public async commitDelete( + repoId: number, + files: string[], + branch: string = 'main', + comment: string = 'ci: :robot_face: Delete files', + ): Promise { + logger.debug({ action: 'commitDelete', repoId, branch, filesCount: files.length }, 'Schedule commit delete') + if (files.length) { + const commitActions: CommitAction[] = files.map((filePath) => { + return { + action: 'delete', + filePath, + } + }) + this.addActions(repoId, branch, comment, commitActions) + return true + } + return false + } + + private addActions(repoId: number, branch: string, comment: string, commitActions: CommitAction[]) { + if (!this.pendingCommits[repoId]) { + this.pendingCommits[repoId] = { branches: {} } + } + if (this.pendingCommits[repoId].branches[branch]) { + this.pendingCommits[repoId].branches[branch].actions.push(...commitActions) + this.pendingCommits[repoId].branches[branch].messages.push(comment) + } else { + this.pendingCommits[repoId].branches[branch] = { + actions: commitActions, + messages: [comment], + } + } + } + + public async commitFiles() { + let filesUpdated: number = 0 + for (const [id, repo] of objectEntries(this.pendingCommits)) { + for (const [branch, details] of objectEntries(repo.branches)) { + const filesNumber = details.actions.length + if (filesNumber) { + filesUpdated += filesNumber + const message = [`ci: :robot_face: Update ${filesNumber} file${filesNumber > 1 ? 's' : ''}`, ...details.messages.filter(m => m)].join('\n') + logger.debug({ action: 'commitFiles', repoId: id, branch, filesNumber }, 'Commit pending file changes') + await this.api.Commits.create(id, branch, message, details.actions) + } + } + } + return filesUpdated + } + + public async listFiles(repoId: number, options: AllRepositoryTreesOptions = {}) { + options.path = options?.path ?? '/' + options.ref = options?.ref ?? 'main' + options.recursive = options?.recursive ?? false + try { + const files = await this.api.Repositories.allRepositoryTrees(repoId, options) + // if (depth >= 0) { + // for (const file of files) { + // if (file.type !== 'tree') { + // return [] + // } + // const childrenFiles = await this.listFiles(repoId, { depth: depth - 1, ...options, path: file.path }) + // files.push(...childrenFiles) + // } + // } + return files + } catch (error) { + const { cause } = error as GitbeakerRequestError + if (cause?.description.includes('Not Found')) { + // Empty repository, with zero commit ==> Zero files + return [] + } else { + throw error + } + } + } + + public async deleteRepository(repoId: number, fullPath: string) { + logger.info({ action: 'deleteRepository', repoId, fullPath }, 'Delete repository') + await this.api.Projects.remove(repoId) // Marks for deletion + return this.api.Projects.remove(repoId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${repoId}` }) // Effective deletion + } +} + +export class GitlabZoneApi extends GitlabApi { + private infraProjectsByZoneSlug: Map + + constructor() { + super() + this.infraProjectsByZoneSlug = new Map() + } + + // Group Infra + public async getOrCreateInfraGroup(): Promise { + logger.debug({ action: 'getOrCreateInfraGroup', infraGroupName }, 'Get/create infra group') + const rootId = await getGroupRootId() + // Get or create projects_root_dir/infra group + const fast = await find( + offsetPaginate(opts => this.api.Groups.all({ + ...customAttributesFilter(infraGroupCustomAttributeKey, 'true'), + ...opts, + })), + group => group.parent_id === rootId, + ) + + const existingParentGroup = fast ?? await find( + offsetPaginate(opts => this.api.Groups.all({ + search: infraGroupName, + ...opts, + })), + group => group.parent_id === rootId && group.name === infraGroupName, + ) + + const group = existingParentGroup || await this.api.Groups.create(infraGroupName, infraGroupPath, { + parentId: rootId, + projectCreationLevel: 'maintainer', + subgroupCreationLevel: 'owner', + defaultBranchProtection: 0, + description: 'Group that hosts infrastructure-as-code repositories for all zones (ArgoCD pull targets).', + }) + try { + await upsertCustomAttribute('groups', group.id, infraGroupCustomAttributeKey, 'true') + await upsertCustomAttribute('groups', group.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getOrCreateInfraGroup', groupId: group.id, err }, 'Failed to upsert infra group custom attribute') + } + return group + } + + public async getOrCreateInfraProject(zone: string): Promise { + logger.debug({ action: 'getOrCreateInfraProject', zone }, 'Get/create infra project for zone') + if (this.infraProjectsByZoneSlug.has(zone)) { + return this.infraProjectsByZoneSlug.get(zone)! + } + const infraGroup = await this.getOrCreateInfraGroup() + // Get or create projects_root_dir/infra/zone + const fast = await find( + offsetPaginate(opts => this.api.Groups.allProjects(infraGroup.id, { + ...customAttributesFilter(managedByConsoleCustomAttributeKey, 'true'), + search: zone, + simple: true, + ...opts, + })), + repo => repo.name === zone, + ) + const project = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allProjects(infraGroup.id, { + search: zone, + simple: true, + ...opts, + })), + repo => repo.name === zone, + ) ?? await this.createEmptyRepository({ + repoName: zone, + groupId: infraGroup.id, + description: 'Repository hosting deployment files for this zone.', + createFirstCommit: true, + }) + this.infraProjectsByZoneSlug.set(zone, project) + return project + } +} + +export class GitlabProjectApi extends GitlabApi { + private project: Project | UniqueRepo | ProjectMember['project'] + private gitlabGroup: GroupSchema | undefined + private specialRepositories: string[] = [infraAppsRepoName, internalMirrorRepoName] + private zoneApi: GitlabZoneApi + + constructor(project: Project | UniqueRepo | ProjectMember['project']) { + super() + this.project = project + this.api = getApi() + this.zoneApi = new GitlabZoneApi() + } + + // Group Project + private async createProjectGroup(): Promise { + logger.info({ action: 'createProjectGroup', projectSlug: this.project.slug }, 'Create project group') + const parentId = await getGroupRootId() + const existingGroup = await find(offsetPaginate(opts => this.api.Groups.all({ + search: this.project.slug, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), group => group.parent_id === parentId && group.name === this.project.slug) + + if (existingGroup) return existingGroup + + const group = await this.api.Groups.create(this.project.slug, this.project.slug, { + parentId, + projectCreationLevel: 'maintainer', + subgroupCreationLevel: 'owner', + defaultBranchProtection: 0, + }) + try { + await upsertCustomAttribute('groups', group.id, projectGroupCustomAttributeKey, this.project.slug) + await upsertCustomAttribute('groups', group.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createProjectGroup', groupId: group.id, err }, 'Failed to upsert project group custom attribute') + } + return group + } + + public async getProjectGroup(): Promise { + logger.debug({ action: 'getProjectGroup', projectSlug: this.project.slug }, 'Get project group') + if (!this.gitlabGroup) { + logger.debug({ action: 'getProjectGroup', projectSlug: this.project.slug }, 'Search project group') + const parentId = await getGroupRootId() + const fast = await find( + offsetPaginate(opts => this.api.Groups.allSubgroups(parentId, { + ...customAttributesFilter(projectGroupCustomAttributeKey, this.project.slug), + ...opts, + })), + group => group.name === this.project.slug, + ) + + this.gitlabGroup = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allSubgroups(parentId, { + search: this.project.slug, + ...opts, + })), + group => group.name === this.project.slug, + ) + + if (this.gitlabGroup) { + try { + await upsertCustomAttribute('groups', this.gitlabGroup.id, projectGroupCustomAttributeKey, this.project.slug) + await upsertCustomAttribute('groups', this.gitlabGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getProjectGroup', groupId: this.gitlabGroup.id, err }, 'Failed to upsert project group custom attribute') + } + } + } + return this.gitlabGroup + } + + public async getOrCreateProjectGroup(): Promise { + logger.debug({ action: 'getOrCreateProjectGroup', projectSlug: this.project.slug }, 'Get/create project group') + const group = await this.getProjectGroup() + if (group) return group + return this.createProjectGroup() + } + + public async getPublicGroupUrl() { + return new URL(`/${config().projectsRootDir}/${this.project.slug}`, config().publicUrl) + } + + public async getInternalGroupUrl() { + return new URL(`/${config().projectsRootDir}/${this.project.slug}`, config().internalUrl) + } + + // Tokens + public async getProjectMirrorCreds(vaultApi: VaultProjectApi): Promise { + logger.debug({ action: 'getProjectMirrorCreds', projectSlug: this.project.slug }, 'Get/create project mirror credentials') + const tokenName = `${this.project.slug}-bot` + const currentToken = await this.getProjectToken(tokenName) + const creds: GitlabMirrorSecret = { + MIRROR_USER: '', + MIRROR_TOKEN: '', + } + if (currentToken) { + const vaultSecret = await vaultApi.read('tech/GITLAB_MIRROR', { throwIfNoEntry: false }) as { data: GitlabMirrorSecret } + if (vaultSecret) { + try { + const group = await this.getProjectGroup() + if (!group) throw new Error('Group not created yet') + + const res = await fetch(`${config().internalUrl}/api/v4/groups/${group.id}`, { + headers: { 'PRIVATE-TOKEN': vaultSecret.data.MIRROR_TOKEN }, + }) + + if (res.ok) { + return vaultSecret.data // valid token hence early exit + } + + throw new Error('Invalid token') + } catch (error) { + logger.warn({ action: 'getProjectMirrorCreds', err: error }, 'Mirror token invalid, revoking project token') + await this.revokeProjectToken(currentToken.id) + } + } + } + const newToken = await this.createProjectToken(tokenName, ['write_repository', 'read_repository', 'read_api']) + creds.MIRROR_TOKEN = newToken.token + creds.MIRROR_USER = newToken.name + await vaultApi.write(creds, 'tech/GITLAB_MIRROR') + return creds + } + + public async getProjectId(projectName: string) { + logger.debug({ action: 'getProjectId', projectName, projectSlug: this.project.slug }, 'Look up project id') + const projectGroup = await this.getProjectGroup() + if (!projectGroup) throw new Error(`Gitlab inaccessible, impossible de trouver le groupe ${this.project.slug}`) + + const fast = await find( + offsetPaginate(opts => this.api.Groups.allProjects(projectGroup.id, { + ...customAttributesFilter(managedByConsoleCustomAttributeKey, 'true'), + search: projectName, + simple: true, + ...opts, + })), + repo => repo.name === projectName, + ) + + const project = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allProjects(projectGroup.id, { + search: projectName, + simple: true, + ...opts, + })), + repo => repo.name === projectName, + ) + + return project?.id + } + + public async getProjectById(projectId: number) { + return this.api.Projects.show(projectId) + } + + public async getOrCreateInfraProject(zone: string) { + return await this.zoneApi.getOrCreateInfraProject(zone) + } + + public async getProjectToken(tokenName: string) { + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + return find(offsetPaginate(opts => this.api.GroupAccessTokens.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE }), token => token.name === tokenName) + } + + public async createProjectToken(tokenName: string, scopes: AccessTokenScopes[]) { + logger.info({ action: 'createProjectToken', tokenName, projectSlug: this.project.slug }, 'Create project access token') + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + const expiryDate = new Date() + expiryDate.setFullYear(expiryDate.getFullYear() + 1) + return this.api.GroupAccessTokens.create(group.id, tokenName, scopes, expiryDate.toLocaleDateString('en-CA')) + } + + public async revokeProjectToken(tokenId: number) { + logger.info({ action: 'revokeProjectToken', tokenId, projectSlug: this.project.slug }, 'Revoke project access token') + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + return this.api.GroupAccessTokens.revoke(group.id, tokenId) + } + + // Triggers + public async getMirrorProjectTriggerToken(vaultApi: VaultProjectApi) { + logger.debug({ action: 'getMirrorProjectTriggerToken', projectSlug: this.project.slug }, 'Get mirror project trigger token') + const tokenDescription = 'mirroring-from-external-repo' + const gitlabRepositories = await this.listRepositories() + const mirrorRepo = gitlabRepositories.find(repo => repo.name === internalMirrorRepoName) + if (!mirrorRepo) throw new Error('Don\'t know how mirror repo could not exist') + const currentTriggerToken = await find(offsetPaginate(opts => this.api.PipelineTriggerTokens.all(mirrorRepo.id, opts), { perPage: MAX_PAGINATION_PER_PAGE }), token => token.description === tokenDescription) + + const tokenVaultSecret = await vaultApi.read('GITLAB', { throwIfNoEntry: false }) + + if (currentTriggerToken && !tokenVaultSecret?.data?.GIT_MIRROR_TOKEN) { + await this.api.PipelineTriggerTokens.remove(mirrorRepo.id, currentTriggerToken.id) + } + const triggerToken = await this.api.PipelineTriggerTokens.create(mirrorRepo.id, tokenDescription) + return { token: triggerToken.token, repoId: mirrorRepo.id } + } + + // Repositories + public async getPublicRepoUrl(repoName: string) { + return `${await this.getPublicGroupUrl()}/${repoName}.git` + } + + public async getInternalRepoUrl(repoName: string) { + return `${await this.getInternalGroupUrl()}/${repoName}.git` + } + + public async listRepositories() { + const group = await this.getOrCreateProjectGroup() + const projects = await getAll(offsetPaginate(opts => this.api.Groups.allProjects(group.id, { simple: false, ...opts }), { perPage: MAX_PAGINATION_PER_PAGE })) // to refactor with https://github.com/jdalrymple/gitbeaker/pull/3624 + return Promise.all(projects.map(async (project) => { + if (this.specialRepositories.includes(project.name) && (!project.topics || !project.topics.includes(pluginManagedTopic))) { + return this.api.Projects.edit(project.id, { topics: project.topics ? [...project.topics, pluginManagedTopic] : [pluginManagedTopic] }) + } + return project + })) + } + + public async createEmptyProjectRepository({ repoName, description, clone }: CreateEmptyRepositoryArgs & { clone?: boolean }) { + logger.info({ action: 'createEmptyProjectRepository', repoName, projectSlug: this.project.slug, clone }, 'Create empty project repository') + const namespaceId = (await this.getOrCreateProjectGroup()).id + return this.createEmptyRepository({ + repoName, + groupId: namespaceId, + description, + ciConfigPath: clone ? '.gitlab-ci-dso.yml' : undefined, + createFirstCommit: !clone, + }) + } + + // Special Repositories + public async getSpecialRepositories(): Promise { + return this.specialRepositories + } + + public async addSpecialRepositories(name: string) { + logger.debug({ action: 'addSpecialRepositories', name, projectSlug: this.project.slug }, 'Register special repository') + if (!this.specialRepositories.includes(name)) { + this.specialRepositories.push(name) + } + } + + // Group members + public async getGroupMembers() { + const group = await this.getOrCreateProjectGroup() + return getAll(offsetPaginate(opts => this.api.GroupMembers.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async addGroupMember(userId: number, accessLevel: AccessLevelAllowed = AccessLevel.DEVELOPER): Promise { + logger.info({ action: 'addGroupMember', accessLevel, projectSlug: this.project.slug }, 'Add group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.add(group.id, userId, accessLevel) + } + + public async editGroupMember(userId: number, accessLevel: AccessLevelAllowed = AccessLevel.DEVELOPER): Promise { + logger.info({ action: 'editGroupMember', accessLevel, projectSlug: this.project.slug }, 'Edit group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.edit(group.id, userId, accessLevel) + } + + public async removeGroupMember(userId: number) { + logger.info({ action: 'removeGroupMember', projectSlug: this.project.slug }, 'Remove group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.remove(group.id, userId) + } + + // CI Variables + public async getGitlabGroupVariables(): Promise { + const group = await this.getOrCreateProjectGroup() + return await getAll(offsetPaginate(opts => this.api.GroupVariables.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async setGitlabGroupVariable(listVars: VariableSchema[], toSetVariable: VariableSchema): Promise { + const group = await this.getOrCreateProjectGroup() + const currentVariable = listVars.find(v => v.key === toSetVariable.key) + if (currentVariable) { + if ( + currentVariable.masked !== toSetVariable.masked + || currentVariable.value !== toSetVariable.value + || currentVariable.protected !== toSetVariable.protected + || currentVariable.variable_type !== toSetVariable.variable_type + ) { + await this.api.GroupVariables.edit( + group.id, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + filter: { environment_scope: '*' }, + }, + ) + return 'updated' + } + return 'already up-to-date' + } + await this.api.GroupVariables.create( + group.id, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + + }, + ) + return 'created' + } + + public async getGitlabRepoVariables(repoId: number): Promise { + return await getAll(offsetPaginate(opts => this.api.ProjectVariables.all(repoId, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async setGitlabRepoVariable(repoId: number, listVars: VariableSchema[], toSetVariable: ProjectVariableSchema): Promise { + const currentVariable = listVars.find(v => v.key === toSetVariable.key) + if (currentVariable) { + if ( + currentVariable.masked !== toSetVariable.masked + || currentVariable.value !== toSetVariable.value + || currentVariable.protected !== toSetVariable.protected + || currentVariable.variable_type !== toSetVariable.variable_type + ) { + await this.api.ProjectVariables.edit( + repoId, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + filter: { + environment_scope: toSetVariable.environment_scope, + }, + }, + ) + return 'updated' + } + return 'already up-to-date' + } + await this.api.ProjectVariables.create( + repoId, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + }, + ) + return 'created' + } + + // Mirror + public async triggerMirror(targetRepo: string, syncAllBranches: boolean, branchName?: string) { + logger.info({ action: 'triggerMirror', targetRepo, syncAllBranches, branchName, projectSlug: this.project.slug }, 'Trigger repository mirror') + if ((await this.getSpecialRepositories()).includes(targetRepo)) { + throw new Error('User requested for invalid mirroring') + } + const repos = await this.listRepositories() + const { mirror, target }: RepoSelect = repos.reduce((acc, repository) => { + if (repository.name === 'mirror') { + acc.mirror = repository + } + if (repository.name === targetRepo) { + acc.target = repository + } + return acc + }, {} as RepoSelect) + if (!mirror) throw new Error('Unable to find mirror repository') + if (!target) throw new Error('Unable to find target repository') + return this.api.Pipelines.create(mirror.id, 'main', { + variables: [ + { + key: 'SYNC_ALL', + value: syncAllBranches.toString(), + }, + { + key: 'GIT_BRANCH_DEPLOY', + value: branchName ?? '', + }, + { + key: 'PROJECT_NAME', + value: targetRepo, + }, + ], + }) + } +} diff --git a/.jjconflict-side-0/plugins/gitlab/src/config.ts b/.jjconflict-side-0/plugins/gitlab/src/config.ts new file mode 100644 index 0000000000..be14dda99c --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/config.ts @@ -0,0 +1,26 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + token: string + projectsRootDir: string + constructor() { + this.token = requiredEnv('GITLAB_TOKEN') + this.publicUrl = removeTrailingSlash(requiredEnv('GITLAB_URL')) + this.projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') + this.internalUrl = process.env.GITLAB_INTERNAL_URL + ? removeTrailingSlash(process.env.GITLAB_INTERNAL_URL) + : this.publicUrl + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-side-0/plugins/gitlab/src/custom-attributes.ts b/.jjconflict-side-0/plugins/gitlab/src/custom-attributes.ts new file mode 100644 index 0000000000..f9ba81bf56 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/custom-attributes.ts @@ -0,0 +1,31 @@ +import { logger } from './logger.js' +import { getApi } from './utils.js' + +export const groupRootCustomAttributeKey = 'cpn_projects_root_dir' +export const infraGroupCustomAttributeKey = 'cpn_infra_group' +export const projectGroupCustomAttributeKey = 'cpn_project_slug' +export const userIdCustomAttributeKey = 'cpn_user_id' +export const managedByConsoleCustomAttributeKey = 'cpn_managed_by_console' + +export function customAttributesFilter(key: string, value: string) { + return { [`custom_attributes[${key}]`]: value } as Record +} + +export async function upsertCustomAttribute(resource: 'groups' | 'projects' | 'users', id: number, key: string, value: string): Promise { + logger.info({ action: 'upsertCustomAttribute', resource, id, key }, 'Upsert custom attribute') + logger.debug({ action: 'upsertCustomAttribute', resource, id, key }, 'Upsert custom attribute details') + const api = getApi() + try { + if (resource === 'groups') { + await api.GroupCustomAttributes.set(id, key, value) + } else if (resource === 'projects') { + await api.ProjectCustomAttributes.set(id, key, value) + } else { + await api.UserCustomAttributes.set(id, key, value) + } + } catch (err) { + logger.warn({ action: 'upsertCustomAttribute', resource, id, key, err }, 'Failed to upsert custom attribute') + throw err + } + logger.info({ action: 'upsertCustomAttribute', resource, id, key }, 'Custom attribute upserted') +} diff --git a/.jjconflict-side-0/plugins/gitlab/src/env.d.ts b/.jjconflict-side-0/plugins/gitlab/src/env.d.ts new file mode 100644 index 0000000000..46f7f90d7c --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-side-0/plugins/gitlab/src/functions.ts b/.jjconflict-side-0/plugins/gitlab/src/functions.ts new file mode 100644 index 0000000000..392245d2da --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/functions.ts @@ -0,0 +1,374 @@ +import type { AdminRole, ClusterObject, PluginResult, Project, ProjectLite, ProjectMember, StepCall, UniqueRepo, ZoneObject } from '@cpn-console/hooks' +import type { GitlabProjectApi } from './class.js' +import type { VaultSecrets } from './utils.js' +import { okStatus, specificallyDisabled } from '@cpn-console/hooks' +import config from './config.js' +import { deleteGroup } from './group.js' +import { + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, +} from './infos.js' +import { ensureGroup } from './members.js' +import { ensureRepositories } from './repositories.js' +import { createUsername, getUser, upsertUser } from './user.js' +import { cleanGitlabError } from './utils.js' + +// Check +export const checkApi: StepCall = async (payload) => { + try { + const { users } = payload.args + for (const user of users) { + const userInfos = await getUser({ ...user, username: createUsername(user.email) }) + if (userInfos?.id === 1) { + return { + status: { + result: 'KO', + message: 'Gitlab notify: User 1 (root) should not use Console', + }, + } + } + } + + return { + status: { + result: 'OK', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const getDsoProjectSecrets: StepCall = async (payload) => { + try { + if (!specificallyDisabled(payload.config.gitlab?.displayTriggerHint)) { + // TODO déplacer les secrets dans un dossier pour tout lister plutôt que de sélectionner dans le code + const gitlab = (await payload.apis.vault.read('GITLAB')).data as VaultSecrets['GITLAB'] + /* eslint-disable no-template-curly-in-string */ + const curlCommand = [ + 'curl -X POST --fail', + '-F token=\${GIT_MIRROR_TOKEN}', + '-F ref=main', + '-F variables[GIT_BRANCH_DEPLOY]=\${BRANCH_TO_SYNC}', + '-F variables[PROJECT_NAME]=\${REPOSITORY_NAME}', + `"${config().publicUrl}/api/v4/projects/${gitlab.GIT_MIRROR_PROJECT_ID}/trigger/pipeline"`, + ] + /* eslint-enable */ + const secrets: Record = { + GIT_MIRROR_PROJECT_ID: String(gitlab.GIT_MIRROR_PROJECT_ID), + GIT_MIRROR_TOKEN: gitlab.GIT_MIRROR_TOKEN, + 'CURL COMMAND': curlCommand.join(' \\\n '), + } + + return { + status: { + result: 'OK', + message: 'secret retrieved', + }, + secrets, + } + } + return { + status: { + result: 'OK', + message: 'This feature is disabled', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'OK', + message: 'No secrets found for this project', + }, + } + } +} + +export const upsertDsoProject: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + }, + } + try { + const project = payload.args + const { gitlab: gitlabApi, vault: vaultApi } = payload.apis + + await gitlabApi.getOrCreateProjectGroup() + + await Promise.all(project.users.map(user => + ensureGroup(gitlabApi, project, user, payload.config), + )) + + const projectMirrorCreds = await gitlabApi.getProjectMirrorCreds(vaultApi) + await ensureRepositories(gitlabApi, project, vaultApi, { + botAccount: projectMirrorCreds.MIRROR_USER, + token: projectMirrorCreds.MIRROR_TOKEN, + }) + + const destroySecrets = (await vaultApi.list()) + .filter(path => path.endsWith('-mirror')) + .map(path => path.slice(1, path.length - 7)) + .filter(repoName => !project.repositories.some(projectRepo => projectRepo.internalRepoName === repoName)) + + await Promise.all(destroySecrets + .map(repoName => vaultApi.destroy(`${repoName}-mirror`)), + ) + + const mirrorTriggerToken = await gitlabApi.getMirrorProjectTriggerToken(vaultApi) + + const gitlabSecret: VaultSecrets['GITLAB'] = { + PROJECT_SLUG: project.slug, + GIT_MIRROR_PROJECT_ID: mirrorTriggerToken.repoId, + GIT_MIRROR_TOKEN: mirrorTriggerToken.token, + } + + await vaultApi.write(gitlabSecret, 'GITLAB') + + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const deleteDsoProject: StepCall = async (payload) => { + try { + const group = await payload.apis.gitlab.getProjectGroup() + if (group) await deleteGroup(group.id, group.full_path) + + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +export const syncRepository: StepCall = async (payload) => { + const targetRepo = payload.args.repo + const gitlabApi = payload.apis.gitlab + try { + await gitlabApi.triggerMirror(targetRepo.internalRepoName, targetRepo.syncAllBranches, targetRepo.branchName) + return { + status: { + result: 'OK', + message: 'Ci launched', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'Failed to trigger sync', + }, + } + } +} + +export const upsertZone: StepCall = async (payload) => { + const returnResult: PluginResult = okStatus + try { + const gitlabApi = payload.apis.gitlab + await gitlabApi.getOrCreateInfraProject(payload.args.slug) + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const deleteZone: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + message: 'Deleted', + }, + } + try { + const gitlabApi = payload.apis.gitlab + const zoneRepo = await gitlabApi.getOrCreateInfraProject(payload.args.slug) + await gitlabApi.deleteRepository(zoneRepo.id, zoneRepo.path_with_namespace) + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const commitFiles: StepCall = async (payload) => { + const returnResult = payload.results.gitlab + try { + const filesUpdated = await payload.apis.gitlab.commitFiles() + + returnResult.status.message = `${filesUpdated} file${filesUpdated > 1 ? 's' : ''} updated` + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Failed to commit files' + return returnResult + } +} + +export const upsertAdminRole: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH + const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? DEFAULT_AUDITOR_GROUP_PATH + + const isAdmin = role.oidcGroup === adminGroupPath ? true : undefined + const isAuditor = role.oidcGroup === auditorGroupPath ? true : undefined + + if (isAdmin === undefined && isAuditor === undefined) { + return { + status: { + result: 'OK', + message: 'Not a managed role for GitLab plugin', + }, + } + } + + for (const member of role.members) { + await upsertUser(member, isAdmin, isAuditor) + } + + return { + status: { + result: 'OK', + message: 'Members synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error occured while syncing admin role', + }, + } + } +} + +export const deleteAdminRole: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH + const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? DEFAULT_AUDITOR_GROUP_PATH + + const isAdmin = role.oidcGroup === adminGroupPath ? false : undefined + const isAuditor = role.oidcGroup === auditorGroupPath ? false : undefined + + if (isAdmin === undefined && isAuditor === undefined) { + return { + status: { + result: 'OK', + message: 'Not a managed role for GitLab plugin', + }, + } + } + + for (const member of role.members) { + await upsertUser(member, isAdmin, isAuditor) + } + + return { + status: { + result: 'OK', + message: 'Admin role deleted and members synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error occured while deleting admin role', + }, + } + } +} + +export const upsertProjectMember: StepCall = async (payload) => { + const member = payload.args + const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon + + try { + await Promise.all(member.project.users.map(user => + ensureGroup(gitlabApi, member.project, user, payload.config), + )) + + return { + status: { + result: 'OK', + message: 'Member synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error happened while syncing project member', + }, + } + } +} + +export const deleteProjectMember: StepCall = async (payload) => { + const member = payload.args + const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon + + try { + const userInfos = await getUser({ ...member, id: member.userId, username: createUsername(member.email) }) + if (!userInfos) { + return { + status: { + result: 'OK', + message: 'User not found in GitLab', + }, + } + } + + await gitlabApi.removeGroupMember(userInfos.id) + + return { + status: { + result: 'OK', + message: 'Member deleted', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error happened while deleting project member', + }, + } + } +} diff --git a/.jjconflict-side-0/plugins/gitlab/src/group.ts b/.jjconflict-side-0/plugins/gitlab/src/group.ts new file mode 100644 index 0000000000..eabf036561 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/group.ts @@ -0,0 +1,7 @@ +import { getApi } from './utils.js' + +export async function deleteGroup(groupId: number, fullPath: string) { + const api = getApi() + await api.Groups.remove(groupId) // Marks for deletion + return api.Groups.remove(groupId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${groupId}` }) // Effective deletion +} diff --git a/.jjconflict-side-0/plugins/gitlab/src/index.ts b/.jjconflict-side-0/plugins/gitlab/src/index.ts new file mode 100644 index 0000000000..1eb773c6c6 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/index.ts @@ -0,0 +1,116 @@ +import type { DeclareModuleGenerator, DefaultArgs, Plugin, Project, ProjectMember, UniqueRepo, ZoneObject } from '@cpn-console/hooks' +import { GitlabProjectApi, GitlabZoneApi } from './class.js' +import { + checkApi, + commitFiles, + deleteDsoProject, + deleteProjectMember, + deleteZone, + getDsoProjectSecrets, + syncRepository, + upsertAdminRole, + upsertDsoProject, + upsertProjectMember, + upsertZone, +} from './functions.js' +import infos from './infos.js' +import { logger } from './logger.js' +import monitor from './monitor.js' +import { getOrCreateGroupRoot } from './utils.js' + +const onlyApi = { api: (project: Project | UniqueRepo) => new GitlabProjectApi(project) } + +function start() { + getOrCreateGroupRoot().catch((error) => { + logger.error({ action: 'start', err: error }, 'Hook failed') + throw new Error('Error at gitlab plugin start') + }) +} + +export const plugin: Plugin = { + infos, + subscribedHooks: { + deleteProject: { + ...onlyApi, + steps: { + main: deleteDsoProject, + post: commitFiles, + }, + }, + upsertProject: { + ...onlyApi, + steps: { + check: checkApi, + main: upsertDsoProject, + post: commitFiles, + }, + }, + getProjectSecrets: { steps: { main: getDsoProjectSecrets } }, + syncRepository: { + ...onlyApi, + steps: { + main: syncRepository, + post: commitFiles, + }, + }, + upsertCluster: { + api: () => new GitlabZoneApi(), + steps: { + post: commitFiles, + }, + }, + deleteCluster: { + api: () => new GitlabZoneApi(), + steps: { + post: commitFiles, + }, + }, + upsertZone: { + api: () => new GitlabZoneApi(), + steps: { + pre: upsertZone, + post: commitFiles, + }, + }, + deleteZone: { + api: () => new GitlabZoneApi(), + steps: { + main: deleteZone, + }, + }, + upsertAdminRole: { + steps: { + main: upsertAdminRole, + }, + }, + upsertProjectMember: { + api: member => new GitlabProjectApi(member.project), + steps: { + main: upsertProjectMember, + }, + }, + deleteProjectMember: { + api: member => new GitlabProjectApi(member.project), + steps: { + post: deleteProjectMember, + }, + }, + }, + monitor, + start, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + gitlab: Args extends Project | UniqueRepo | ProjectMember['project'] + ? GitlabProjectApi + : Args extends ZoneObject + ? GitlabZoneApi + : never + } + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} + interface PluginResult { + warnReasons?: string[] + } +} diff --git a/.jjconflict-side-0/plugins/gitlab/src/infos.ts b/.jjconflict-side-0/plugins/gitlab/src/infos.ts new file mode 100644 index 0000000000..38f5d10eac --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/infos.ts @@ -0,0 +1,108 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import config from './config.js' + +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer,/console/devops' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly' + +const infos = { + name: 'gitlab', + to: ({ project }) => `${config().publicUrl}/${config().projectsRootDir}/${project.slug}`, + title: 'Gitlab', + imgSrc: '/img/gitlab.svg', + description: 'GitLab est un service d\'hébergement de code source et de pipeline CI/CD', + config: { + global: [ + { + kind: 'switch', + key: 'purge', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Purger les utilisateurs non synchronisés', + value: DISABLED, + description: 'Purger les utilisateurs non synchronisés de GitLab lors de la synchronisation', + }, + { + kind: 'switch', + key: 'displayTriggerHint', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Afficher l\'aide de déclenchement de pipeline', + value: ENABLED, + description: 'Afficher l\'aide de déclenchement de pipeline aux utilisateurs lorsqu\'ils souhaitent afficher les secrets du projet', + }, + { + kind: 'text', + key: 'adminGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Admin', + value: DEFAULT_ADMIN_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'administrateur GitLab', + placeholder: DEFAULT_ADMIN_GROUP_PATH, + }, + { + kind: 'text', + key: 'auditorGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Auditeur', + value: DEFAULT_AUDITOR_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'auditeur GitLab', + placeholder: DEFAULT_AUDITOR_GROUP_PATH, + }, + { + kind: 'text', + key: 'projectMaintainerGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Maintainer', + value: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Maintainer', + placeholder: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectDeveloperGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Developer', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Developer', + placeholder: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectReporterGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Reporter', + value: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Reporter', + placeholder: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + }, + ], + project: [], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-side-0/plugins/gitlab/src/logger.ts b/.jjconflict-side-0/plugins/gitlab/src/logger.ts new file mode 100644 index 0000000000..8d4c5caea7 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'gitlab' }) diff --git a/.jjconflict-side-0/plugins/gitlab/src/members.ts b/.jjconflict-side-0/plugins/gitlab/src/members.ts new file mode 100644 index 0000000000..2e558b18a5 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/members.ts @@ -0,0 +1,69 @@ +import type { Config, Project, Role, UserObject } from '@cpn-console/hooks' +import type { GitlabProjectApi } from './class.js' +import { AccessLevel } from '@gitbeaker/core' +import { + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, +} from './infos.js' +import { createUsername, upsertUser } from './user.js' +import { matchRole } from './utils.js' + +export function getGroupAccessLevelFromProjectRole(project: Project, user: UserObject, config: Config) { + const projectReporterGroupPathSuffixes = (config.gitlab?.projectReporterGroupPathSuffix ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX).split(',') + const projectDeveloperGroupPathSuffixes = (config.gitlab?.projectDeveloperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX).split(',') + const projectMaintainerGroupPathSuffixes = (config.gitlab?.projectMaintainerGroupPathSuffix ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX).split(',') + + const getAccessLevel = (role: Role): number | null => { + // TODO: update to AccessLevel.GUEST once we migrated pre-fine grained + // projects members to developer access level via database migrations + if (!role.oidcGroup) return AccessLevel.DEVELOPER + if (matchRole(project.slug, role.oidcGroup, projectReporterGroupPathSuffixes)) return AccessLevel.REPORTER + if (matchRole(project.slug, role.oidcGroup, projectDeveloperGroupPathSuffixes)) return AccessLevel.DEVELOPER + if (matchRole(project.slug, role.oidcGroup, projectMaintainerGroupPathSuffixes)) return AccessLevel.MAINTAINER + return null + } + + // TODO: update to AccessLevel.GUEST once we migrated pre-fine grained + // projects members to developer access level via database migrations + return project.roles.reduce((highestAccessLevel, role) => { + if (role.users.some(userRole => userRole.id === user.id)) { + const level = getAccessLevel(role) + if (level && level > (highestAccessLevel ?? 0)) return level + } + return highestAccessLevel + }, AccessLevel.DEVELOPER) +} + +export function getGroupAccessLevel(project: Project, user: UserObject, config: Config): number | null { + if (project.owner.id === user.id) return AccessLevel.OWNER + return getGroupAccessLevelFromProjectRole(project, user, config) +} + +export async function ensureGroup( + gitlabApi: GitlabProjectApi, + project: Project, + user: UserObject, + config: Config, +) { + const gitlabUser = await upsertUser({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }) + + const groupMembers = await gitlabApi.getGroupMembers() + const existingMember = groupMembers.find(m => m.username === createUsername(user.email)) + const maxAccessLevel = getGroupAccessLevel(project, user, config) + + if (existingMember && maxAccessLevel) { + if (existingMember.access_level !== maxAccessLevel) { + await gitlabApi.editGroupMember(gitlabUser.id, maxAccessLevel) + } + } else if (maxAccessLevel) { + await gitlabApi.addGroupMember(gitlabUser.id, maxAccessLevel) + } else if (existingMember) { + await gitlabApi.removeGroupMember(gitlabUser.id) + } +} diff --git a/.jjconflict-side-0/plugins/gitlab/src/monitor.ts b/.jjconflict-side-0/plugins/gitlab/src/monitor.ts new file mode 100644 index 0000000000..d190d6ed51 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/monitor.ts @@ -0,0 +1,52 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import config from './config.js' + +enum HealthStatus { + failed = 'failed', + ok = 'ok', +} +type GitlabRes = Record }> +const coreComponents = ['gitaly_check', 'master_check', 'db_check', 'sessions_check'] + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(`${config().internalUrl}/-/readiness?all=1`, { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + const data = res.data as GitlabRes + const failedComponents = Object.entries(data) + .reduce((acc, [name, value]) => { + if (value.status === HealthStatus.failed) return [...acc, name] + return acc + }, [] as string[]) + const failedCoreComponents = failedComponents.filter(name => coreComponents.includes(name)) + + if (failedCoreComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } + if (failedComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.WARNING + instance.lastStatus.message = 'Service dégradé' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Fatal Error' + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-0/plugins/gitlab/src/project.ts b/.jjconflict-side-0/plugins/gitlab/src/project.ts new file mode 100644 index 0000000000..df3dc8e67e --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/project.ts @@ -0,0 +1,52 @@ +import * as fs from 'node:fs/promises' +import path from 'node:path' + +import { getApi } from './utils.js' + +export async function provisionMirror(repoId: number) { + const baseDir = path.resolve(import.meta.url, '../../files/').split(':')[1] + + const gitlabCiYml = ( + await fs.readFile(`${baseDir}/.gitlab-ci.yml`) + ).toString() + const mirrorSh = (await fs.readFile(`${baseDir}/mirror.sh`)).toString() + + const mirrorFirstActions: CommitAction[] = [ + { + action: 'create', + filePath: '.gitlab-ci.yml', + content: gitlabCiYml, + execute_filemode: false, + }, + { + action: 'create', + filePath: 'mirror.sh', + content: mirrorSh, + execute_filemode: true, + }, + ] + const api = getApi() + await api.Commits.create( + repoId, + 'main', + 'ci: :construction_worker: first mirror', + mirrorFirstActions, + ) +} + +interface CommitAction { + /** The action to perform */ + action: 'create' | 'delete' | 'move' | 'update' | 'chmod' + /** Full path to the file. Ex. lib/class.rb */ + filePath: string + /** Original full path to the file being moved.Ex.lib / class1.rb */ + previousPath?: string + /** File content, required for all except delete. Optional for move */ + content?: string + /** text or base64. text is default. */ + encoding?: string + /** Last known file commit id. Will be only considered in update, move and delete actions. */ + lastCommitId?: string + /** When true/false enables/disables the execute flag on the file. Only considered for chmod action. */ + execute_filemode?: boolean +} diff --git a/.jjconflict-side-0/plugins/gitlab/src/repositories.ts b/.jjconflict-side-0/plugins/gitlab/src/repositories.ts new file mode 100644 index 0000000000..00797cf446 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/repositories.ts @@ -0,0 +1,93 @@ +import type { Project, Repository } from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { CondensedProjectSchema, ProjectSchema } from '@gitbeaker/rest' +import type { GitlabProjectApi } from './class.js' +import { shallowEqual } from '@cpn-console/shared' +import { pluginManagedTopic } from './class.js' +import { provisionMirror } from './project.js' +import { infraAppsRepoName, internalMirrorRepoName } from './utils.js' + +interface ProjectMirrorCreds { + botAccount: string + token: string +} + +export async function ensureRepositories(gitlabApi: GitlabProjectApi, project: Project, vaultApi: VaultProjectApi, projectMirrorCreds: ProjectMirrorCreds) { + const specialRepos = await gitlabApi.getSpecialRepositories() + const gitlabRepositories = await gitlabApi.listRepositories() + + const promises: Promise[] = [ + // delete excess repositories + ...gitlabRepositories + .filter(gitlabRepository => ( + !specialRepos.includes(gitlabRepository.name) + && !gitlabRepository.topics?.includes(pluginManagedTopic) + && !project.repositories.some(repo => repo.internalRepoName === gitlabRepository.name))) + .map(gitlabRepository => gitlabApi.deleteRepository(gitlabRepository.id, gitlabRepository.path_with_namespace)), + // create missing repositories + ...project.repositories.map(repo => ensureRepositoryExists(gitlabRepositories, repo, gitlabApi, projectMirrorCreds, vaultApi)), + ] + + if (!gitlabRepositories.some(repo => repo.name === infraAppsRepoName)) { + promises.push( + gitlabApi.createEmptyProjectRepository({ repoName: infraAppsRepoName, clone: false }), + ) + } + if (!gitlabRepositories.some(repo => repo.name === internalMirrorRepoName)) { + promises.push( + gitlabApi.createEmptyProjectRepository({ repoName: internalMirrorRepoName, clone: false }) + .then(mirrorRepo => provisionMirror(mirrorRepo.id)), + ) + } + + await Promise.all(promises) +} + +const urnRegexp = /:\/\/(.*)/s + +async function ensureRepositoryExists(gitlabRepositories: CondensedProjectSchema[], repository: Repository, gitlabApi: GitlabProjectApi, projectMirrorCreds: ProjectMirrorCreds, vaultApi: VaultProjectApi) { + const gitlabRepository: CondensedProjectSchema | ProjectSchema | void = gitlabRepositories.find(gitlabRepository => gitlabRepository.name === repository.internalRepoName) + const externalRepoUrn = repository.externalRepoUrl.split(urnRegexp)[1] + const vaultCredsPath = `${repository.internalRepoName}-mirror` + const currentVaultSecret = await vaultApi.read(vaultCredsPath, { throwIfNoEntry: false }) + + if (!gitlabRepository) { + await gitlabApi.createEmptyProjectRepository({ + repoName: repository.internalRepoName, + description: undefined, + clone: !!repository.externalRepoUrl, + }) + } + + if (!repository.externalRepoUrl) { + return currentVaultSecret && vaultApi.destroy(vaultCredsPath) + } + + let gitInputUser: string | undefined + let gitInputPassword: string | undefined + if (currentVaultSecret?.data) { + gitInputUser = currentVaultSecret.data.GIT_INPUT_USER + gitInputPassword = currentVaultSecret.data.GIT_INPUT_PASSWORD + } + + const internalRepoUrl = await gitlabApi.getInternalRepoUrl(repository.internalRepoName) + + const mirrorSecretData = { + GIT_INPUT_URL: externalRepoUrn, + GIT_INPUT_USER: repository.isPrivate + ? (repository.newCreds?.username || gitInputUser) + : undefined, + GIT_INPUT_PASSWORD: repository.isPrivate + ? (repository.newCreds?.token || gitInputPassword) + : undefined, + GIT_OUTPUT_URL: internalRepoUrl.split(urnRegexp)[1], + GIT_OUTPUT_USER: projectMirrorCreds.botAccount, + GIT_OUTPUT_PASSWORD: projectMirrorCreds.token, + } + if ( + !currentVaultSecret?.data + || !shallowEqual(mirrorSecretData, currentVaultSecret.data) + ) { + await vaultApi.write(mirrorSecretData, vaultCredsPath) + } +} diff --git a/.jjconflict-side-0/plugins/gitlab/src/user.ts b/.jjconflict-side-0/plugins/gitlab/src/user.ts new file mode 100644 index 0000000000..5f638e4bc1 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/user.ts @@ -0,0 +1,107 @@ +import type { UserObject } from '@cpn-console/hooks' +import type { CreateUserOptions, SimpleUserSchema } from '@gitbeaker/rest' +import { upsertCustomAttribute, userIdCustomAttributeKey } from './custom-attributes.js' +import { logger } from './logger.js' +import { find, getApi, offsetPaginate } from './utils.js' + +export function createUsername(email: string) { + const parts = email.split('@') + if (parts.length > 0) { + return parts[0] + } + return email +} + +export async function getUser(user: { email: string, username: string, id: string }): Promise { + const api = getApi() + + const isUser = (gitlabUser: SimpleUserSchema) => + gitlabUser?.externUid === user.id + || gitlabUser?.externUid === user.email + || gitlabUser.email === user.email + || gitlabUser.username === user.username + + const fast = await find( + offsetPaginate(opts => api.Users.all({ + externUid: user.email, + provider: 'openid_connect', + orderBy: 'username', + asAdmin: true, + ...opts, + })), + isUser, + ) + + if (!fast) { + logger.debug({ action: 'getUser', user }, 'User not found in fast search') + } + + return fast ?? await find( + offsetPaginate(opts => api.Users.all({ + search: user.username, + asAdmin: true, + ...opts, + })), + isUser, + ) +} + +export async function upsertUser(user: UserObject, isAdmin?: boolean, isAuditor?: boolean): Promise { + const api = getApi() + const username = createUsername(user.email) + const existingUser = await getUser({ ...user, username }) + + const userDefinitionBase: CreateUserOptions = { + // required options + name: `${user.firstName} ${user.lastName}`, + username, + email: user.email, + // sso options + externUid: user.email, + provider: 'openid_connect', + admin: isAdmin, + auditor: isAuditor, + } + + if (existingUser) { + const incorrectProps = Object.entries(userDefinitionBase).reduce((acc, [key, value]) => { + if (existingUser[key] !== value) { + acc.push({ + key, + curr: existingUser[key], + new: value, + }) + } + return acc + }, [] as { key: string, curr: any, new: any }[]) + + if (incorrectProps.length) { + logger.debug({ action: 'upsertUser', changes: incorrectProps }, 'User properties differ from expected') + try { + await api.Users.edit(existingUser.id, userDefinitionBase) + } catch (err) { + logger.error({ action: 'upsertUser', err }, 'Failed to update user') + } + } + try { + await upsertCustomAttribute('users', existingUser.id, userIdCustomAttributeKey, user.id) + } catch (err) { + logger.debug({ action: 'upsertUser', userId: existingUser.id, err }, 'Failed to upsert user custom attribute') + } + return existingUser + } + + const created = await api.Users.create({ + ...userDefinitionBase, + canCreateGroup: false, + forceRandomPassword: true, + projectsLimit: 0, + skipConfirmation: true, + }) + try { + await upsertCustomAttribute('users', created.id, userIdCustomAttributeKey, user.id) + } catch (err) { + logger.debug({ action: 'upsertUser', userId: created.id, err }, 'Failed to upsert user custom attribute') + } + return created +} diff --git a/.jjconflict-side-0/plugins/gitlab/src/utils.ts b/.jjconflict-side-0/plugins/gitlab/src/utils.ts new file mode 100644 index 0000000000..3dbe69b772 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/src/utils.ts @@ -0,0 +1,198 @@ +import type { BaseRequestOptions, Gitlab as IGitlab, OffsetPagination, PaginationRequestOptions } from '@gitbeaker/core' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { Gitlab } from '@gitbeaker/rest' +import config from './config.js' +import { customAttributesFilter, groupRootCustomAttributeKey, managedByConsoleCustomAttributeKey, upsertCustomAttribute } from './custom-attributes.js' +import { logger } from './logger.js' + +let api: IGitlab | undefined + +let groupRootId: number + +export const MAX_PAGINATION_PER_PAGE = 100 + +export async function getGroupRootId(throwIfNotFound?: true): Promise +export async function getGroupRootId(throwIfNotFound?: false): Promise +export async function getGroupRootId(throwIfNotFound?: boolean): Promise { + const gitlabApi = getApi() + const projectRootDir = config().projectsRootDir + logger.debug({ action: 'getGroupRootId', projectRootDir }, 'Resolve group root id') + if (groupRootId) return groupRootId + const fast = await find( + offsetPaginate(opts => gitlabApi.Groups.all({ + ...customAttributesFilter(groupRootCustomAttributeKey, projectRootDir), + ...opts, + })), + grp => grp.full_path === projectRootDir, + ) + const groupRoot = fast ?? await find( + offsetPaginate(opts => gitlabApi.Groups.all({ + search: projectRootDir, + ...opts, + })), + grp => grp.full_path === projectRootDir, + ) + logger.debug({ action: 'getGroupRootId', groupRootId: groupRoot?.id, groupRootPath: groupRoot?.full_path }, 'Resolved group root') + const searchId = groupRoot?.id + if (typeof searchId === 'undefined') { + if (throwIfNotFound) { + throw new Error(`Gitlab inaccessible, impossible de trouver le groupe RACINE ${projectRootDir}`) + } + return searchId + } + groupRootId = searchId + try { + await upsertCustomAttribute('groups', groupRootId, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', groupRootId, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getGroupRootId', groupRootId, err }, 'Failed to upsert group root custom attribute') + } + return groupRootId +} + +async function createGroupRoot(): Promise { + logger.info({ action: 'createGroupRoot', projectRootDir: config().projectsRootDir }, 'Create group root hierarchy') + const gitlabApi = getApi() + const projectRootDir = config().projectsRootDir + const projectRootDirArray = projectRootDir.split('/') + + const rootGroupPath = projectRootDirArray.shift() + if (!rootGroupPath) { + throw new Error('No projectRootDir available') + } + + let parentGroup = await find(offsetPaginate(opts => gitlabApi.Groups.all({ + search: rootGroupPath, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), grp => grp.full_path === rootGroupPath) ?? await gitlabApi.Groups.create(rootGroupPath, rootGroupPath) + + if (parentGroup.full_path === projectRootDir) { + try { + await upsertCustomAttribute('groups', parentGroup.id, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', parentGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createGroupRoot', groupRootId: parentGroup.id, err }, 'Failed to upsert group root custom attribute') + } + return parentGroup.id + } + + for (const path of projectRootDirArray) { + const futureFullPath = `${parentGroup.full_path}/${path}` + parentGroup = await find(offsetPaginate(opts => gitlabApi.Groups.all({ + search: futureFullPath, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), grp => grp.full_path === futureFullPath) ?? await gitlabApi.Groups.create(path, path, { parentId: parentGroup.id, visibility: 'internal' }) + + if (parentGroup.full_path === projectRootDir) { + try { + await upsertCustomAttribute('groups', parentGroup.id, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', parentGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createGroupRoot', groupRootId: parentGroup.id, err }, 'Failed to upsert group root custom attribute') + } + return parentGroup.id + } + } + throw new Error('No projectRootDir available or is malformed') +} + +export async function getOrCreateGroupRoot(): Promise { + return await getGroupRootId(false) ?? createGroupRoot() +} + +export function getApi(): IGitlab { + api ??= new Gitlab({ token: config().token, host: config().internalUrl }) + return api +} + +export const infraAppsRepoName = 'infra-apps' +export const internalMirrorRepoName = 'mirror' + +export interface VaultSecrets { + GITLAB: { + PROJECT_SLUG: string + GIT_MIRROR_PROJECT_ID: number + GIT_MIRROR_TOKEN: string + } +} + +// eslint-disable-next-line regexp/no-super-linear-backtracking +const keyValueRegExp = /\/\/(.*):(.*)@/g + +export function cleanGitlabError(error: T): T { + if (error instanceof GitbeakerRequestError && error.cause?.description) { + error.cause.description = String(error.cause.description).replaceAll(keyValueRegExp, '//MASKED:MASKED@') + } + return error +} + +export function matchRole(projectSlug: string, roleOidcGroup: string, configuredRolePath: string[]) { + return configuredRolePath.some(path => roleOidcGroup === `/${projectSlug}${path}`) +} + +export interface OffsetPaginateOptions { + startPage?: number + perPage?: number + maxPages?: number +} + +export async function* offsetPaginate( + request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, + options?: OffsetPaginateOptions, +): AsyncGenerator { + let page: number | null = options?.startPage ?? 1 + let pagesFetched = 0 + let total: number = 0 + logger.debug({ action: 'offsetPaginate', page }, 'Pagination start') + while (page !== null) { + if (options?.maxPages && pagesFetched >= options.maxPages) { + page = null + continue + } + try { + const { data, paginationInfo } = await request({ + page, + perPage: options?.perPage, + maxPages: options?.maxPages, + showExpanded: true, + pagination: 'offset', + }) + pagesFetched += 1 + total += data.length + logger.debug( + { action: 'offsetPaginate', page, nextPage: paginationInfo.next, items: data.length, total }, + 'Pagination page fetched', + ) + for (const item of data) { + yield item + } + page = paginationInfo.next + } catch (error) { + logger.error({ action: 'offsetPaginate', page, err: error }, 'Pagination request failed') + throw error + } + } + logger.debug({ action: 'offsetPaginate', total }, 'Pagination done') +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} + +export async function find( + iterable: AsyncIterable, + predicate: (item: T) => boolean, +): Promise { + for await (const item of iterable) { + if (predicate(item)) { + return item + } + } + return undefined +} diff --git a/.jjconflict-side-0/plugins/gitlab/tsconfig.eslint.json b/.jjconflict-side-0/plugins/gitlab/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-0/plugins/gitlab/tsconfig.json b/.jjconflict-side-0/plugins/gitlab/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-0/plugins/gitlab/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-0/plugins/harbor/.gitignore b/.jjconflict-side-0/plugins/harbor/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-0/plugins/harbor/eslint.config.js b/.jjconflict-side-0/plugins/harbor/eslint.config.js new file mode 100644 index 0000000000..3148148bf5 --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/eslint.config.js @@ -0,0 +1,4 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +// Generated by `pnpm run swagger:generate` — never linted or hand-edited. +export default eslintConfigBase.append({ ignores: ['src/api/Api.ts'] }) diff --git a/.jjconflict-side-0/plugins/harbor/package.json b/.jjconflict-side-0/plugins/harbor/package.json new file mode 100644 index 0000000000..ec8a51bb24 --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/package.json @@ -0,0 +1,55 @@ +{ + "name": "@cpn-console/harbor-plugin", + "type": "module", + "version": "2.3.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types", + "utils.d.ts" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./", + "swagger:generate": "node ./scripts/generate-api.mjs" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime", + "bytes": "catalog:runtime", + "cron-validator": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "js-yaml": "catalog:tools", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "swagger-typescript-api": "catalog:build", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/plugins/harbor/scripts/generate-api.mjs b/.jjconflict-side-0/plugins/harbor/scripts/generate-api.mjs new file mode 100644 index 0000000000..2633066ec9 --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/scripts/generate-api.mjs @@ -0,0 +1,37 @@ +/** + * Generates `src/api/Api.ts` from Harbor's OpenAPI/Swagger spec. + * + * The spec is fetched and parsed with `js-yaml` before being handed to + * `swagger-typescript-api`. This is deliberate: swagger-typescript-api v13 + * bundles a strict YAML parser that rejects Harbor's multi-line single-quoted + * `description` scalars, so we parse the YAML ourselves and pass the resulting + * JSON spec object programmatically. + * + * Bump HARBOR_TAG to regenerate against a newer Harbor release. + */ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { generateApi } from 'swagger-typescript-api' + +const HARBOR_TAG = process.env.HARBOR_TAG ?? 'v2.13.5' +const SPEC_URL = `https://raw.githubusercontent.com/goharbor/harbor/${HARBOR_TAG}/api/v2.0/swagger.yaml` + +const outputDir = resolve(dirname(fileURLToPath(import.meta.url)), '../src/api') + +console.log(`Fetching Harbor ${HARBOR_TAG} spec from ${SPEC_URL}`) +const res = await fetch(SPEC_URL) +if (!res.ok) { + throw new Error(`Failed to fetch spec (${res.status} ${res.statusText})`) +} + +const spec = yaml.load(await res.text()) + +await generateApi({ + spec, + output: outputDir, + fileName: 'Api.ts', + httpClientType: 'axios', +}) + +console.log(`Generated ${outputDir}/Api.ts`) diff --git a/.jjconflict-side-0/plugins/harbor/src/api/Api.ts b/.jjconflict-side-0/plugins/harbor/src/api/Api.ts new file mode 100644 index 0000000000..2616919eba --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/src/api/Api.ts @@ -0,0 +1,8030 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +/** The error array that describe the errors got during the handling of request */ +export interface Errors { + errors?: Error[]; +} + +/** a model for all the error response coming from harbor */ +export interface Error { + /** The error code */ + code?: string; + /** The error message */ + message?: string; +} + +export interface Search { + /** Search results of the projects that matched the filter keywords. */ + project?: Project[]; + /** Search results of the repositories that matched the filter keywords. */ + repository?: SearchRepository[]; +} + +export interface SearchRepository { + /** The ID of the project that the repository belongs to */ + project_id?: number; + /** The name of the project that the repository belongs to */ + project_name?: string; + /** The flag to indicate the publicity of the project that the repository belongs to (1 is public, 0 is not) */ + project_public?: boolean; + /** The name of the repository */ + repository_name?: string; + /** The count how many times the repository is pulled */ + pull_count?: number; + /** The count of artifacts in the repository */ + artifact_count?: number; +} + +export interface Repository { + /** + * The ID of the repository + * @format int64 + */ + id?: number; + /** + * The ID of the project that the repository belongs to + * @format int64 + */ + project_id?: number; + /** The name of the repository */ + name?: string; + /** The description of the repository */ + description?: string; + /** + * The count of the artifacts inside the repository + * @format int64 + */ + artifact_count: number; + /** + * The count that the artifact inside the repository pulled + * @format int64 + */ + pull_count: number; + /** + * The creation time of the repository + * @format date-time + */ + creation_time?: string | null; + /** + * The update time of the repository + * @format date-time + */ + update_time?: string; +} + +export interface Artifact { + /** + * The ID of the artifact + * @format int64 + */ + id?: number; + /** The type of the artifact, e.g. image, chart, etc */ + type?: string; + /** The media type of the artifact */ + media_type?: string; + /** The manifest media type of the artifact */ + manifest_media_type?: string; + /** The artifact_type in the manifest of the artifact */ + artifact_type?: string; + /** + * The ID of the project that the artifact belongs to + * @format int64 + */ + project_id?: number; + /** + * The ID of the repository that the artifact belongs to + * @format int64 + */ + repository_id?: number; + /** The name of the repository that the artifact belongs to */ + repository_name?: string; + /** The digest of the artifact */ + digest?: string; + /** + * The size of the artifact + * @format int64 + */ + size?: number; + /** The digest of the icon */ + icon?: string; + /** + * The push time of the artifact + * @format date-time + */ + push_time?: string; + /** + * The latest pull time of the artifact + * @format date-time + */ + pull_time?: string; + extra_attrs?: ExtraAttrs; + annotations?: Annotations; + references?: Reference[]; + tags?: Tag[]; + addition_links?: AdditionLinks; + labels?: Label[]; + /** The overview of the scan result. */ + scan_overview?: ScanOverview; + /** The overview of the generating SBOM progress */ + sbom_overview?: SBOMOverview; + accessories?: Accessory[]; +} + +export interface Tag { + /** + * The ID of the tag + * @format int64 + */ + id?: number; + /** + * The ID of the repository that the tag belongs to + * @format int64 + */ + repository_id?: number; + /** + * The ID of the artifact that the tag attached to + * @format int64 + */ + artifact_id?: number; + /** The name of the tag */ + name?: string; + /** + * The push time of the tag + * @format date-time + */ + push_time?: string; + /** + * The latest pull time of the tag + * @format date-time + */ + pull_time?: string; + /** The immutable status of the tag */ + immutable: boolean; +} + +export type ExtraAttrs = Record; + +export type Annotations = Record; + +export type AdditionLinks = Record; + +export interface AdditionLink { + /** The link of the addition */ + href?: string; + /** Determine whether the link is an absolute URL or not */ + absolute: boolean; +} + +export interface Reference { + /** + * The parent ID of the reference + * @format int64 + */ + parent_id?: number; + /** + * The child ID of the reference + * @format int64 + */ + child_id?: number; + /** The digest of the child artifact */ + child_digest?: string; + platform?: Platform; + annotations?: Annotations; + /** The download URLs */ + urls?: string[]; +} + +export interface Platform { + /** The architecture that the artifact applys to */ + architecture?: string; + /** The OS that the artifact applys to */ + os?: string; + /** The version of the OS that the artifact applys to */ + "'os.version'"?: string; + /** The features of the OS that the artifact applys to */ + "'os.features'"?: string[]; + /** The variant of the CPU */ + variant?: string; +} + +export interface Label { + /** + * The ID of the label + * @format int64 + */ + id?: number; + /** The name the label */ + name?: string; + /** The description the label */ + description?: string; + /** The color the label */ + color?: string; + /** The scope the label */ + scope?: string; + /** + * The ID of project that the label belongs to + * @format int64 + */ + project_id?: number; + /** + * The creation time the label + * @format date-time + */ + creation_time?: string; + /** + * The update time of the label + * @format date-time + */ + update_time?: string; +} + +export interface Scanner { + /** + * Name of the scanner + * @example "Trivy" + */ + name?: string; + /** + * Name of the scanner provider + * @example "Aqua Security" + */ + vendor?: string; + /** + * Version of the scanner adapter + * @example "v0.9.1" + */ + version?: string; +} + +/** The scan overview attached in the metadata of tag */ +export type ScanOverview = Record; + +/** The generate SBOM overview information */ +export interface SBOMOverview { + /** + * The start time of the generating sbom report task + * @format date-time + * @example "2006-01-02T14:04:05Z" + */ + start_time?: string; + /** + * The end time of the generating sbom report task + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + end_time?: string; + /** The status of the generating SBOM task */ + scan_status?: string; + /** The digest of the generated SBOM accessory */ + sbom_digest?: string; + /** + * id of the native scan report + * @example "5f62c830-f996-11e9-957f-0242c0a89008" + */ + report_id?: string; + /** + * Time in seconds required to create the report + * @format int64 + * @example 300 + */ + duration?: number; + scanner?: Scanner; +} + +/** The summary for the native report */ +export interface NativeReportSummary { + /** + * id of the native scan report + * @example "5f62c830-f996-11e9-957f-0242c0a89008" + */ + report_id?: string; + /** + * The status of the report generating process + * @example "Success" + */ + scan_status?: string; + /** + * The overall severity + * @example "High" + */ + severity?: string; + /** + * The seconds spent for generating the report + * @format int64 + * @example 300 + */ + duration?: number; + /** VulnerabilitySummary contains the total number of the foun d vulnerabilities number and numbers of each severity level. */ + summary?: VulnerabilitySummary; + /** + * The start time of the scan process that generating report + * @format date-time + * @example "2006-01-02T14:04:05Z" + */ + start_time?: string; + /** + * The end time of the scan process that generating report + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + end_time?: string; + /** + * The complete percent of the scanning which value is between 0 and 100 + * @example 100 + */ + complete_percent?: number; + scanner?: Scanner; +} + +/** VulnerabilitySummary contains the total number of the foun d vulnerabilities number and numbers of each severity level. */ +export interface VulnerabilitySummary { + /** + * The total number of the found vulnerabilities + * @format int + * @example 500 + */ + total: number; + /** + * The number of the fixable vulnerabilities + * @format int + * @example 100 + */ + fixable: number; + /** + * Numbers of the vulnerabilities with different severity + * @example {"Critical":5,"High":5} + */ + summary: Record; +} + +export interface AuditLog { + /** The ID of the audit log entry. */ + id?: number; + /** Username of the user in this log entry. */ + username?: string; + /** Name of the repository in this log entry. */ + resource?: string; + /** Tag of the repository in this log entry. */ + resource_type?: string; + /** The operation against the repository in this log entry. */ + operation?: string; + /** + * The time when this operation is triggered. + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + op_time?: string; +} + +export interface AuditLogExt { + /** The ID of the audit log entry. */ + id?: number; + /** The username of the operator in this log entry. */ + username?: string; + /** Name of the resource in this log entry. */ + resource?: string; + /** Type of the resource in this log entry. */ + resource_type?: string; + /** The operation against the resource in this log entry. */ + operation?: string; + /** The operation's detail description */ + operation_description?: string; + /** the operation's result, true for success, false for fail */ + operation_result: boolean; + /** + * The time when this operation is triggered. + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + op_time?: string; +} + +export interface AuditLogEventType { + /** + * the event type, such as create_user. + * @example "create_user" + */ + event_type?: string; +} + +export interface Metadata { + /** id */ + id?: string; + /** name */ + name?: string; + /** icon */ + icon?: string; + /** maintainers */ + maintainers?: string[]; + /** version */ + version?: string; + /** source */ + source?: string; +} + +export interface Instance { + /** Unique ID */ + id?: number; + /** Instance name */ + name?: string; + /** Description of instance */ + description?: string; + /** Based on which driver, identified by ID */ + vendor?: string; + /** The service endpoint of this instance */ + endpoint?: string; + /** The authentication way supported */ + auth_mode?: string; + /** The auth credential data if exists */ + auth_info?: Record; + /** The health status */ + status?: string; + /** Whether the instance is activated or not */ + enabled: boolean; + /** Whether the instance is default or not */ + default: boolean; + /** Whether the instance endpoint is insecure or not */ + insecure: boolean; + /** + * The timestamp of instance setting up + * @format int64 + */ + setup_timestamp?: number; +} + +export interface PreheatPolicy { + /** The ID of preheat policy */ + id?: number; + /** The Name of preheat policy */ + name?: string; + /** The Description of preheat policy */ + description?: string; + /** The ID of preheat policy project */ + project_id?: number; + /** The ID of preheat policy provider */ + provider_id?: number; + /** The Name of preheat policy provider */ + provider_name?: string; + /** The Filters of preheat policy */ + filters?: string; + /** The Trigger of preheat policy */ + trigger?: string; + /** Whether the preheat policy enabled */ + enabled: boolean; + /** The extra attributes of preheat policy */ + extra_attrs?: string; + /** + * The Create Time of preheat policy + * @format date-time + */ + creation_time?: string; + /** + * The Update Time of preheat policy + * @format date-time + */ + update_time?: string; +} + +export interface Metrics { + /** The count of task */ + task_count?: number; + /** The count of success task */ + success_task_count?: number; + /** The count of error task */ + error_task_count?: number; + /** The count of pending task */ + pending_task_count?: number; + /** The count of running task */ + running_task_count?: number; + /** The count of scheduled task */ + scheduled_task_count?: number; + /** The count of stopped task */ + stopped_task_count?: number; +} + +export interface Execution { + /** The ID of execution */ + id?: number; + /** The vendor type of execution */ + vendor_type?: string; + /** The vendor id of execution */ + vendor_id?: number; + /** The status of execution */ + status?: string; + /** The status message of execution */ + status_message?: string; + metrics?: Metrics; + /** The trigger of execution */ + trigger?: string; + extra_attrs?: ExtraAttrs; + /** The start time of execution */ + start_time?: string; + /** The end time of execution */ + end_time?: string; +} + +export interface Task { + /** The ID of task */ + id?: number; + /** The ID of task execution */ + execution_id?: number; + /** The status of task */ + status?: string; + /** The status message of task */ + status_message?: string; + /** + * The count of task run + * @format int32 + */ + run_count?: number; + extra_attrs?: ExtraAttrs; + /** The creation time of task */ + creation_time?: string; + /** The update time of task */ + update_time?: string; + /** The start time of task */ + start_time?: string; + /** The end time of task */ + end_time?: string; +} + +export interface ProviderUnderProject { + id?: number; + provider?: string; + enabled?: boolean; + default?: boolean; +} + +export interface Icon { + /** The content type of the icon */ + "content-type"?: string; + /** The base64 encoded content of the icon */ + content?: string; +} + +export interface ProjectReq { + /** + * The name of the project. + * @maxLength 255 + */ + project_name?: string; + /** deprecated, reserved for project creation in replication */ + public?: boolean | null; + /** The metadata of the project. */ + metadata?: ProjectMetadata; + /** The CVE allowlist of the project. */ + cve_allowlist?: CVEAllowlist; + /** + * The storage quota of the project. + * @format int64 + */ + storage_limit?: number | null; + /** + * The ID of referenced registry when creating the proxy cache project + * @format int64 + */ + registry_id?: number | null; +} + +export interface Project { + /** + * Project ID + * @format int32 + */ + project_id?: number; + /** + * The owner ID of the project always means the creator of the project. + * @format int32 + */ + owner_id?: number; + /** The name of the project. */ + name?: string; + /** + * The ID of referenced registry when the project is a proxy cache project. + * @format int64 + */ + registry_id?: number; + /** + * The creation time of the project. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the project. + * @format date-time + */ + update_time?: string; + /** A deletion mark of the project. */ + deleted?: boolean; + /** The owner name of the project. */ + owner_name?: string; + /** Correspond to the UI about whether the project's publicity is updatable (for UI) */ + togglable?: boolean; + /** The role ID with highest permission of the current user who triggered the API (for UI). This attribute is deprecated and will be removed in future versions. */ + current_user_role_id?: number; + /** The list of role ID of the current user who triggered the API (for UI) */ + current_user_role_ids?: number[]; + /** The number of the repositories under this project. */ + repo_count: number; + /** The metadata of the project. */ + metadata?: ProjectMetadata; + /** The CVE allowlist of this project. */ + cve_allowlist?: CVEAllowlist; +} + +export interface ProjectDeletable { + /** Whether the project can be deleted. */ + deletable?: boolean; + /** The detail message when the project can not be deleted. */ + message?: string; +} + +export interface ProjectMetadata { + /** The public status of the project. The valid values are "true", "false". */ + public?: string; + /** Whether content trust is enabled or not. If it is enabled, user can't pull unsigned images from this project. The valid values are "true", "false". */ + enable_content_trust?: string | null; + /** Whether cosign content trust is enabled or not. If it is enabled, user can't pull images without cosign signature from this project. The valid values are "true", "false". */ + enable_content_trust_cosign?: string | null; + /** Whether prevent the vulnerable images from running. The valid values are "true", "false". */ + prevent_vul?: string | null; + /** If the vulnerability is high than severity defined here, the images can't be pulled. The valid values are "none", "low", "medium", "high", "critical". */ + severity?: string | null; + /** Whether scan images automatically when pushing. The valid values are "true", "false". */ + auto_scan?: string | null; + /** Whether generating SBOM automatically when pushing a subject artifact. The valid values are "true", "false". */ + auto_sbom_generation?: string | null; + /** Whether this project reuse the system level CVE allowlist as the allowlist of its own. The valid values are "true", "false". If it is set to "true" the actual allowlist associate with this project, if any, will be ignored. */ + reuse_sys_cve_allowlist?: string | null; + /** The ID of the tag retention policy for the project */ + retention_id?: string | null; + /** The bandwidth limit of proxy cache, in Kbps (kilobits per second). It limits the communication between Harbor and the upstream registry, not the client and the Harbor. */ + proxy_speed_kb?: string | null; +} + +export interface ProjectSummary { + /** The number of the repositories under this project. */ + repo_count: number; + /** The total number of project admin members. */ + project_admin_count?: number; + /** The total number of maintainer members. */ + maintainer_count?: number; + /** The total number of developer members. */ + developer_count?: number; + /** The total number of guest members. */ + guest_count?: number; + /** The total number of limited guest members. */ + limited_guest_count?: number; + quota?: ProjectSummaryQuota; + registry?: Registry; +} + +export interface ProjectSummaryQuota { + /** The hard limits of the quota */ + hard?: ResourceList; + /** The used status of the quota */ + used?: ResourceList; +} + +export interface ProjectScanner { + /** The identifier of the scanner registration */ + uuid: string; +} + +/** The CVE Allowlist for system or project */ +export interface CVEAllowlist { + /** ID of the allowlist */ + id?: number; + /** ID of the project which the allowlist belongs to. For system level allowlist this attribute is zero. */ + project_id?: number; + /** the time for expiration of the allowlist, in the form of seconds since epoch. This is an optional attribute, if it's not set the CVE allowlist does not expire. */ + expires_at?: number | null; + items?: CVEAllowlistItem[]; + /** + * The creation time of the allowlist. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the allowlist. + * @format date-time + */ + update_time?: string; +} + +/** The item in CVE allowlist */ +export interface CVEAllowlistItem { + /** The ID of the CVE, such as "CVE-2019-10164" */ + cve_id?: string; +} + +export interface ReplicationPolicy { + /** + * The policy ID. + * @format int64 + */ + id?: number; + /** The policy name. */ + name?: string; + /** The description of the policy. */ + description?: string; + /** The source registry. */ + src_registry?: Registry; + /** The destination registry. */ + dest_registry?: Registry; + /** The destination namespace. */ + dest_namespace?: string; + /** + * Specify how many path components will be replaced by the provided destination namespace. + * The default value is -1 in which case the legacy mode will be applied. + * @format int8 + */ + dest_namespace_replace_count?: number; + trigger?: ReplicationTrigger; + /** The replication policy filter array. */ + filters?: ReplicationFilter[]; + /** Whether to replicate the deletion operation. */ + replicate_deletion?: boolean; + /** Deprecated, use "replicate_deletion" instead. Whether to replicate the deletion operation. */ + deletion?: boolean; + /** Whether to override the resources on the destination registry. */ + override?: boolean; + /** Whether the policy is enabled or not. */ + enabled?: boolean; + /** + * The create time of the policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the policy. + * @format date-time + */ + update_time?: string; + /** + * speed limit for each task + * @format int32 + */ + speed?: number; + /** Whether to enable copy by chunk. */ + copy_by_chunk?: boolean; +} + +export interface ReplicationTrigger { + /** The replication policy trigger type. The valid values are manual, event_based and scheduled. */ + type?: string; + trigger_settings?: ReplicationTriggerSettings; +} + +export interface ReplicationTriggerSettings { + /** The cron string for scheduled trigger */ + cron?: string; +} + +export interface ReplicationFilter { + /** The replication policy filter type. */ + type?: string; + /** The value of replication policy filter. */ + value?: object; + /** matches or excludes the result */ + decoration?: string; +} + +export interface RegistryCredential { + /** Credential type, such as 'basic', 'oauth'. */ + type?: string; + /** Access key, e.g. user name when credential type is 'basic'. */ + access_key?: string; + /** Access secret, e.g. password when credential type is 'basic'. */ + access_secret?: string; +} + +export interface Registry { + /** + * The registry ID. + * @format int64 + */ + id: number; + /** The registry URL string. */ + url?: string; + /** The registry name. */ + name?: string; + credential?: RegistryCredential; + /** Type of the registry, e.g. 'harbor'. */ + type?: string; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean; + /** Description of the registry. */ + description?: string; + /** Health status of the registry. */ + status?: string; + /** + * The create time of the policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the policy. + * @format date-time + */ + update_time?: string; +} + +export interface RegistryUpdate { + /** The registry name. */ + name?: string | null; + /** Description of the registry. */ + description?: string | null; + /** The registry URL. */ + url?: string | null; + /** Credential type of the registry, e.g. 'basic'. */ + credential_type?: string | null; + /** The registry access key. */ + access_key?: string | null; + /** The registry access secret. */ + access_secret?: string | null; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean | null; +} + +export interface RegistryPing { + /** + * The registry ID. + * @format int64 + */ + id?: number | null; + /** Type of the registry, e.g. 'harbor'. */ + type?: string | null; + /** The registry URL. */ + url?: string | null; + /** Credential type of the registry, e.g. 'basic'. */ + credential_type?: string | null; + /** The registry access key. */ + access_key?: string | null; + /** The registry access secret. */ + access_secret?: string | null; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean | null; +} + +/** The registry info contains the base info and capability declarations of the registry */ +export interface RegistryInfo { + /** The registry type */ + type?: string; + /** The description */ + description?: string; + /** The filters that the registry supports */ + supported_resource_filters?: FilterStyle[]; + /** The triggers that the registry supports */ + supported_triggers?: string[]; + /** The registry whether support copy by chunk. */ + supported_copy_by_chunk?: boolean; +} + +/** The registry provider info contains the base info and capability declarations of the registry provider */ +export interface RegistryProviderInfo { + /** The endpoint pattern */ + endpoint_pattern?: RegistryProviderEndpointPattern; + /** The credential pattern */ + credential_pattern?: RegistryProviderCredentialPattern; +} + +/** The registry endpoint pattern */ +export interface RegistryProviderEndpointPattern { + /** The endpoint type */ + endpoint_type?: string; + /** The endpoint list */ + endpoints?: RegistryEndpoint[]; +} + +/** The registry credential pattern */ +export interface RegistryProviderCredentialPattern { + /** The access key type */ + access_key_type?: string; + /** The access key data */ + access_key_data?: string; + /** The access secret type */ + access_secret_type?: string; + /** The access secret data */ + access_secret_data?: string; +} + +/** The style of the resource filter */ +export interface RegistryEndpoint { + /** The endpoint key */ + key?: string; + /** The endpoint value */ + value?: string; +} + +/** The style of the resource filter */ +export interface FilterStyle { + /** The filter type */ + type?: string; + /** The filter style */ + style?: string; + /** The filter values */ + values?: string[]; +} + +export type ResourceList = Record; + +/** The replication execution */ +export interface ReplicationExecution { + /** The ID of the execution */ + id?: number; + /** The ID if the policy that the execution belongs to */ + policy_id?: number; + /** The status of the execution */ + status?: string; + /** The trigger mode */ + trigger?: string; + /** + * The start time + * @format date-time + */ + start_time?: string; + /** + * The end time + * @format date-time + */ + end_time?: string; + /** The status text */ + status_text: string; + /** The total count of all executions */ + total: number; + /** The count of failed executions */ + failed: number; + /** The count of succeed executions */ + succeed: number; + /** The count of in_progress executions */ + in_progress: number; + /** The count of stopped executions */ + stopped: number; +} + +export interface StartReplicationExecution { + /** + * The ID of policy that the execution belongs to. + * @format int64 + */ + policy_id?: number; +} + +/** The replication task */ +export interface ReplicationTask { + /** The ID of the task */ + id?: number; + /** The ID of the execution that the task belongs to */ + execution_id?: number; + /** The status of the task */ + status?: string; + /** The ID of the underlying job that the task related to */ + job_id?: string; + /** The operation of the task */ + operation?: string; + /** The type of the resource that the task operates */ + resource_type?: string; + /** The source resource that the task operates */ + src_resource?: string; + /** The destination resource that the task operates */ + dst_resource?: string; + /** + * The start time of the task + * @format date-time + */ + start_time?: string; + /** + * The end time of the task + * @format date-time + */ + end_time?: string; +} + +export interface Robot { + /** + * The ID of the robot + * @format int64 + */ + id?: number; + /** The name of the robot */ + name?: string; + /** The description of the robot */ + description?: string; + /** The secret of the robot */ + secret?: string; + /** The level of the robot, project or system */ + level?: string; + /** + * The duration of the robot in days, duration must be either -1(Never) or a positive integer + * @format int64 + */ + duration?: number | null; + /** The editable status of the robot */ + editable: boolean; + /** The disable status of the robot */ + disable: boolean; + /** + * The expiration date of the robot + * @format int64 + */ + expires_at?: number; + permissions?: RobotPermission[]; + /** The type of the robot creator, like local(harbor_user) or robot. */ + creator_type?: string; + /** The reference of the robot creator, like the id of harbor user. */ + creator_ref?: number; + /** + * The creation time of the robot. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the robot. + * @format date-time + */ + update_time?: string; +} + +/** The request for robot account creation. */ +export interface RobotCreate { + /** The name of the robot */ + name?: string; + /** The description of the robot */ + description?: string; + /** The secret of the robot */ + secret?: string; + /** The level of the robot, project or system */ + level?: string; + /** The disable status of the robot */ + disable?: boolean; + /** + * The duration of the robot in days, duration must be either -1(Never) or a positive integer + * @format int64 + */ + duration?: number; + permissions?: RobotPermission[]; +} + +/** The response for robot account creation. */ +export interface RobotCreated { + /** + * The ID of the robot + * @format int64 + */ + id?: number; + /** The name of the robot */ + name?: string; + /** The secret of the robot */ + secret?: string; + /** + * The creation time of the robot. + * @format date-time + */ + creation_time?: string; + /** + * The expiration date of the robot + * @format int64 + */ + expires_at?: number; +} + +/** The response for refresh/update robot account secret. */ +export interface RobotSec { + /** The secret of the robot */ + secret?: string; +} + +export interface RobotPermission { + /** The kind of the permission */ + kind?: string; + /** The namespace of the permission */ + namespace?: string; + access?: Access[]; +} + +export interface Access { + /** The resource of the access. Possible resources are listed here for system and project level https://github.com/goharbor/harbor/blob/main/src/common/rbac/const.go */ + resource?: string; + /** The action of the access. Possible actions are *, pull, push, create, read, update, delete, list, operate, scanner-pull and stop. */ + action?: string; + /** The effect of the access */ + effect?: string; +} + +export interface RobotCreateV1 { + /** The name of robot account */ + name?: string; + /** The description of robot account */ + description?: string; + /** The expiration time on or after which the JWT MUST NOT be accepted for processing. */ + expires_at?: number; + /** The permission of robot account */ + access?: Access[]; +} + +export interface Storage { + /** + * Total volume size. + * @format uint64 + */ + total?: number; + /** + * Free volume size. + * @format uint64 + */ + free?: number; +} + +export interface GeneralInfo { + /** + * The banner message for the UI. It is the stringified result of the banner message object. + * @example "{"closable":true,"message":"your banner message content","type":"warning","fromDate":"06/19/2023","toDate":"06/21/2023"}" + */ + banner_message?: string | null; + /** + * The current time of the server. + * @format date-time + */ + current_time?: string | null; + /** The url of registry against which the docker command should be issued. */ + registry_url?: string | null; + /** The external URL of Harbor, with protocol. */ + external_url?: string | null; + /** The auth mode of current Harbor instance. */ + auth_mode?: string | null; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: boolean | null; + /** Indicate who can create projects, it could be 'adminonly' or 'everyone'. */ + project_creation_restriction?: string | null; + /** Indicate whether the Harbor instance enable user to register himself. */ + self_registration?: boolean | null; + /** Indicate whether there is a ca root cert file ready for download in the file system. */ + has_ca_root?: boolean | null; + /** The build version of Harbor. */ + harbor_version?: string | null; + /** The storage provider's name of Harbor registry */ + registry_storage_provider_name?: string | null; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: boolean | null; + /** The flag to indicate whether notification mechanism is enabled on Harbor instance. */ + notification_enable?: boolean | null; + /** The setting of auth proxy this is only available when Harbor relies on authproxy for authentication. */ + authproxy_settings?: AuthproxySetting | null; + /** The OIDC provider name, empty if current auth is not OIDC_auth or OIDC provider is not configured. */ + oidc_provider_name?: string | null; +} + +export interface AuthproxySetting { + /** The fully qualified URI of login endpoint of authproxy, such as 'https://192.168.1.2:8443/login' */ + endpoint?: string; + /** The fully qualified URI of token review endpoint of authproxy, such as 'https://192.168.1.2:8443/tokenreview' */ + tokenreivew_endpoint?: string; + /** The flag to determine whether Harbor can skip search the user/group when adding him as a member. */ + skip_search?: boolean; + /** The flag to determine whether Harbor should verify the certificate when connecting to the auth proxy. */ + verify_cert?: boolean; + /** The certificate to be pinned when connecting auth proxy. */ + server_certificate?: string; +} + +export interface SystemInfo { + /** The storage of system. */ + storage?: Storage[]; +} + +export interface GCHistory { + /** the id of gc job. */ + id?: number; + /** the job name of gc job. */ + job_name?: string; + /** the job kind of gc job. */ + job_kind?: string; + /** the job parameters of gc job. */ + job_parameters?: string; + schedule?: ScheduleObj; + /** the status of gc job. */ + job_status?: string; + /** if gc job was deleted. */ + deleted?: boolean; + /** + * the creation time of gc job. + * @format date-time + */ + creation_time?: string; + /** + * the update time of gc job. + * @format date-time + */ + update_time?: string; +} + +export interface ExecHistory { + /** the id of purge job. */ + id?: number; + /** the job name of purge job. */ + job_name?: string; + /** the job kind of purge job. */ + job_kind?: string; + /** the job parameters of purge job. */ + job_parameters?: string; + schedule?: ScheduleObj; + /** the status of purge job. */ + job_status?: string; + /** if purge job was deleted. */ + deleted?: boolean; + /** + * the creation time of purge job. + * @format date-time + */ + creation_time?: string; + /** + * the update time of purge job. + * @format date-time + */ + update_time?: string; +} + +export interface Schedule { + /** The id of the schedule. */ + id?: number; + /** The status of the schedule. */ + status?: string; + /** + * the creation time of the schedule. + * @format date-time + */ + creation_time?: string; + /** + * the update time of the schedule. + * @format date-time + */ + update_time?: string; + schedule?: ScheduleObj; + /** The parameters of schedule job */ + parameters?: Record; +} + +export interface ScheduleObj { + /** + * The schedule type. The valid values are 'Hourly', 'Daily', 'Weekly', 'Custom', 'Manual', 'None' and 'Schedule'. + * 'Manual' means to trigger it right away, 'Schedule' means to trigger it by a specified cron schedule and + * 'None' means to cancel the schedule. + */ + type?: + | "Hourly" + | "Daily" + | "Weekly" + | "Custom" + | "Manual" + | "None" + | "Schedule"; + /** A cron expression, a time-based job scheduler. */ + cron?: string; + /** + * The next time to schedule to run the job. + * @format date-time + */ + next_scheduled_time?: string; +} + +/** Stats provides the overall progress of the scan all process. */ +export interface Stats { + /** + * The total number of scan processes triggered by the scan all action + * @format int + * @example 100 + */ + total: number; + /** + * The number of the finished scan processes triggered by the scan all action + * @format int + * @example 90 + */ + completed: number; + /** + * The metrics data for the each status + * @example {"Success":5,"Error":2,"Running":3} + */ + metrics?: Record; + /** A flag indicating job status of scan all. */ + ongoing: boolean; + /** The trigger of the scan all job. */ + trigger?: "Manual" | "Schedule" | "Event"; +} + +/** the tag retention metadata */ +export interface RetentionMetadata { + /** templates */ + templates?: RetentionRuleMetadata[]; + /** supported scope selectors */ + scope_selectors?: RetentionSelectorMetadata[]; + /** supported tag selectors */ + tag_selectors?: RetentionSelectorMetadata[]; +} + +/** the tag retention rule metadata */ +export interface RetentionRuleMetadata { + /** rule id */ + rule_template?: string; + /** rule display text */ + display_text?: string; + /** rule action */ + action?: string; + /** rule params */ + params?: RetentionRuleParamMetadata[]; +} + +/** rule param */ +export interface RetentionRuleParamMetadata { + type?: string; + unit?: string; + required?: boolean; +} + +/** retention selector */ +export interface RetentionSelectorMetadata { + display_text?: string; + kind?: string; + decorations?: string[]; +} + +/** retention policy */ +export interface RetentionPolicy { + /** @format int64 */ + id?: number; + algorithm?: string; + rules?: RetentionRule[]; + trigger?: RetentionRuleTrigger; + scope?: RetentionPolicyScope; +} + +export interface RetentionRuleTrigger { + kind?: string; + settings?: object; + references?: object; +} + +export interface RetentionPolicyScope { + level?: string; + ref?: number; +} + +export interface RetentionRule { + id?: number; + priority?: number; + disabled?: boolean; + action?: string; + template?: string; + params?: Record; + tag_selectors?: RetentionSelector[]; + scope_selectors?: Record; +} + +export interface RetentionSelector { + kind?: string; + decoration?: string; + pattern?: string; + extras?: string; +} + +export interface RetentionExecution { + /** @format int64 */ + id?: number; + /** @format int64 */ + policy_id?: number; + start_time?: string; + end_time?: string; + status?: string; + trigger?: string; + dry_run?: boolean; +} + +export interface RetentionExecutionTask { + /** @format int64 */ + id?: number; + /** @format int64 */ + execution_id?: number; + repository?: string; + job_id?: string; + status?: string; + status_code: number; + /** @format int64 */ + status_revision?: number; + start_time?: string; + end_time?: string; + total: number; + retained: number; +} + +export interface QuotaUpdateReq { + /** The new hard limits for the quota */ + hard?: ResourceList; +} + +export type QuotaRefObject = Record; + +/** The quota object */ +export interface Quota { + /** ID of the quota */ + id?: number; + /** The reference object of the quota */ + ref?: QuotaRefObject; + /** The hard limits of the quota */ + hard: ResourceList; + /** The used status of the quota */ + used: ResourceList; + /** + * the creation time of the quota + * @format date-time + */ + creation_time?: string; + /** + * the update time of the quota + * @format date-time + */ + update_time?: string; +} + +/** Registration represents a named configuration for invoking a scanner via its adapter. */ +export interface ScannerRegistration { + /** The unique identifier of this registration. */ + uuid?: string; + /** + * The name of this registration. + * @example "Trivy" + */ + name?: string; + /** + * An optional description of this registration. + * @example "A free-to-use tool that scans container images for package vulnerabilities. + * " + */ + description: string; + /** + * A base URL of the scanner adapter + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url?: string; + /** + * Indicate whether the registration is enabled or not + * @default false + */ + disabled: boolean; + /** + * Indicate if the registration is set as the system default one + * @default false + */ + is_default: boolean; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @default "" + * @example "Bearer" + */ + auth: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential: string; + /** + * Indicate if skip the certificate verification when sending HTTP requests + * @default false + */ + skip_certVerify: boolean; + /** + * Indicate whether use internal registry addr for the scanner to pull content or not + * @default false + */ + use_internal_addr: boolean; + /** + * The creation time of this registration + * @format date-time + */ + create_time?: string; + /** + * The update time of this registration + * @format date-time + */ + update_time?: string; + /** + * Optional property to describe the name of the scanner registration + * @example "Trivy" + */ + adapter?: string; + /** + * Optional property to describe the vendor of the scanner registration + * @example "CentOS" + */ + vendor?: string; + /** + * Optional property to describe the version of the scanner registration + * @example "1.0.1" + */ + version?: string; + /** + * Indicate the healthy of the registration + * @default "" + * @example "healthy" + */ + health?: string; + /** + * Indicates the capabilities of the scanner, e.g. support_vulnerability or support_sbom. + * @example {"support_vulnerability":true,"support_sbom":true} + */ + capabilities?: Record; +} + +export interface ScannerRegistrationReq { + /** + * The name of this registration + * @example "Trivy" + */ + name: string; + /** + * An optional description of this registration. + * @example "A free-to-use tool that scans container images for package vulnerabilities. + * " + */ + description?: string; + /** + * A base URL of the scanner adapter. + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url: string; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @example "Bearer" + */ + auth?: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential?: string; + /** + * Indicate if skip the certificate verification when sending HTTP requests + * @default false + */ + skip_certVerify?: boolean; + /** + * Indicate whether use internal registry addr for the scanner to pull content or not + * @default false + */ + use_internal_addr?: boolean; + /** + * Indicate whether the registration is enabled or not + * @default false + */ + disabled?: boolean; +} + +export interface ScannerRegistrationSettings { + /** + * The name of this registration + * @example "Trivy" + */ + name: string; + /** + * A base URL of the scanner adapter. + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url: string; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @default "" + */ + auth?: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential?: string; +} + +export interface IsDefault { + /** A flag indicating whether a scanner registration is default. */ + is_default?: boolean; +} + +export interface ScannerCapability { + /** + * Specify the type of scanner capability, like vulnerability or sbom + * @example "sbom" + */ + type: string; + consumes_mime_types?: string[]; + produces_mime_types?: string[]; +} + +/** The metadata info of the scanner adapter */ +export interface ScannerAdapterMetadata { + scanner?: Scanner; + capabilities?: ScannerCapability[]; + /** @example {"harbor.scanner-adapter/registry-authorization-type":"Bearer"} */ + properties?: Record; +} + +export interface ImmutableRule { + id?: number; + priority?: number; + disabled?: boolean; + action?: string; + template?: string; + params?: Record; + tag_selectors?: ImmutableSelector[]; + scope_selectors?: Record; +} + +export interface ImmutableSelector { + kind?: string; + decoration?: string; + pattern?: string; + extras?: string; +} + +/** The ldap configure properties */ +export interface LdapConf { + /** The url of ldap service. */ + ldap_url?: string; + /** The search dn of ldap service. */ + ldap_search_dn?: string; + /** The search password of ldap service. */ + ldap_search_password?: string; + /** The base dn of ldap service. */ + ldap_base_dn?: string; + /** The serach filter of ldap service. */ + ldap_filter?: string; + /** The serach uid from ldap service attributes. */ + ldap_uid?: string; + /** + * The serach scope of ldap service. + * @format int64 + */ + ldap_scope?: number; + /** + * The connect timeout of ldap service(second). + * @format int64 + */ + ldap_connection_timeout?: number; + /** Verify Ldap server certificate. */ + ldap_verify_cert?: boolean; +} + +/** The ldap ping result */ +export interface LdapPingResult { + /** Test success */ + success?: boolean; + /** The ping operation output message. */ + message?: string; +} + +export interface LdapImportUsers { + /** selected uid list */ + ldap_uid_list?: string[]; +} + +export interface LdapFailedImportUser { + /** the uid can't add to system. */ + uid?: string; + /** fail reason. */ + error?: string; +} + +export interface LdapUser { + /** ldap username. */ + username?: string; + /** The user realname from "uid" or "cn" attribute. */ + realname?: string; + /** The user email address from "mail" or "email" attribute. */ + email?: string; +} + +export interface UserGroup { + /** The ID of the user group */ + id?: number; + /** The name of the user group */ + group_name?: string; + /** The group type, 1 for LDAP group, 2 for HTTP group, 3 for OIDC group. */ + group_type?: number; + /** The DN of the LDAP group if group type is 1 (LDAP group). */ + ldap_group_dn?: string; +} + +export interface UserGroupSearchItem { + /** The ID of the user group */ + id?: number; + /** The name of the user group */ + group_name?: string; + /** The group type, 1 for LDAP group, 2 for HTTP group, 3 for OIDC group. */ + group_type?: number; +} + +/** Supported webhook event types and notify types. */ +export interface SupportedWebhookEventTypes { + event_type?: EventType[]; + notify_type?: NotifyType[]; + payload_formats?: PayloadFormat[]; +} + +/** + * Webhook supported event type. + * @example "PULL_ARTIFACT" + */ +export type EventType = string; + +/** + * Webhook supported notify type. + * @example "http" + */ +export type NotifyType = string; + +/** + * The type of webhook paylod format. + * @example "CloudEvents" + */ +export type PayloadFormatType = string; + +/** Webhook supported payload format type collections. */ +export interface PayloadFormat { + /** Webhook supported notify type. */ + notify_type?: NotifyType; + /** The supported payload formats for this notify type. */ + formats?: PayloadFormatType[]; +} + +/** The webhook policy target object. */ +export interface WebhookTargetObject { + /** The webhook target notify type. */ + type?: string; + /** The webhook target address. */ + address?: string; + /** The webhook auth header. */ + auth_header?: string; + /** Whether or not to skip cert verify. */ + skip_cert_verify?: boolean; + /** The payload format of webhook, by default is Default for http type. */ + payload_format?: PayloadFormatType; +} + +/** The webhook policy object */ +export interface WebhookPolicy { + /** + * The webhook policy ID. + * @format int64 + */ + id?: number; + /** The name of webhook policy. */ + name?: string; + /** The description of webhook policy. */ + description?: string; + /** The project ID of webhook policy. */ + project_id?: number; + targets?: WebhookTargetObject[]; + event_types?: string[]; + /** The creator of the webhook policy. */ + creator?: string; + /** + * The create time of the webhook policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the webhook policy. + * @format date-time + */ + update_time?: string; + /** Whether the webhook policy is enabled or not. */ + enabled: boolean; +} + +/** The webhook policy and last trigger time group by event type. */ +export interface WebhookLastTrigger { + /** The webhook policy name. */ + policy_name?: string; + /** The webhook event type. */ + event_type?: string; + /** Whether or not the webhook policy enabled. */ + enabled?: boolean; + /** + * The creation time of webhook policy. + * @format date-time + */ + creation_time?: string; + /** + * The last trigger time of webhook policy. + * @format date-time + */ + last_trigger_time?: string; +} + +/** The webhook job. */ +export interface WebhookJob { + /** + * The webhook job ID. + * @format int64 + */ + id?: number; + /** + * The webhook policy ID. + * @format int64 + */ + policy_id?: number; + /** The webhook job event type. */ + event_type?: string; + /** The webhook job notify type. */ + notify_type?: string; + /** The webhook job status. */ + status?: string; + /** The webhook job notify detailed data. */ + job_detail?: string; + /** + * The webhook job creation time. + * @format date-time + */ + creation_time?: string; + /** + * The webhook job update time. + * @format date-time + */ + update_time?: string; +} + +export type InternalConfigurationsResponse = Record< + string, + InternalConfigurationValue +>; + +export interface InternalConfigurationValue { + /** The value of current config item */ + value?: object; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface ConfigurationsResponse { + /** The auth mode of current system, such as "db_auth", "ldap_auth", "oidc_auth" */ + auth_mode?: StringConfigItem; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: BoolConfigItem; + /** The Base DN for LDAP binding. */ + ldap_base_dn?: StringConfigItem; + /** The filter for LDAP search */ + ldap_filter?: StringConfigItem; + /** The base DN to search LDAP group. */ + ldap_group_base_dn?: StringConfigItem; + /** Specify the ldap group which have the same privilege with Harbor admin */ + ldap_group_admin_dn?: StringConfigItem; + /** The attribute which is used as identity of the LDAP group, default is cn.' */ + ldap_group_attribute_name?: StringConfigItem; + /** The filter to search the ldap group */ + ldap_group_search_filter?: StringConfigItem; + /** The scope to search ldap group. ''0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE'' */ + ldap_group_search_scope?: IntegerConfigItem; + /** Attach LDAP user group information in parallel. */ + ldap_group_attach_parallel?: BoolConfigItem; + /** The scope to search ldap users,'0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE' */ + ldap_scope?: IntegerConfigItem; + /** The DN of the user to do the search. */ + ldap_search_dn?: StringConfigItem; + /** Timeout in seconds for connection to LDAP server */ + ldap_timeout?: IntegerConfigItem; + /** The attribute which is used as identity for the LDAP binding, such as "CN" or "SAMAccountname" */ + ldap_uid?: StringConfigItem; + /** The URL of LDAP server */ + ldap_url?: StringConfigItem; + /** Whether verify your OIDC server certificate, disable it if your OIDC server is hosted via self-hosted certificate. */ + ldap_verify_cert?: BoolConfigItem; + /** The user attribute to identify the group membership */ + ldap_group_membership_attribute?: StringConfigItem; + /** Indicate who can create projects, it could be ''adminonly'' or ''everyone''. */ + project_creation_restriction?: StringConfigItem; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: BoolConfigItem; + /** Whether the Harbor instance supports self-registration. If it''s set to false, admin need to add user to the instance. */ + self_registration?: BoolConfigItem; + /** The expiration time of the token for internal Registry, in minutes. */ + token_expiration?: IntegerConfigItem; + /** The client id of UAA */ + uaa_client_id?: StringConfigItem; + /** The client secret of the UAA */ + uaa_client_secret?: StringConfigItem; + /** The endpoint of the UAA */ + uaa_endpoint?: StringConfigItem; + /** Verify the certificate in UAA server */ + uaa_verify_cert?: BoolConfigItem; + /** The endpoint of the HTTP auth */ + http_authproxy_endpoint?: StringConfigItem; + /** The token review endpoint */ + http_authproxy_tokenreview_endpoint?: StringConfigItem; + /** The group which has the harbor admin privileges */ + http_authproxy_admin_groups?: StringConfigItem; + /** The usernames which has the harbor admin privileges */ + http_authproxy_admin_usernames?: StringConfigItem; + /** Verify the HTTP auth provider's certificate */ + http_authproxy_verify_cert?: BoolConfigItem; + /** Search user before onboard */ + http_authproxy_skip_search?: BoolConfigItem; + /** The certificate of the HTTP auth provider */ + http_authproxy_server_certificate?: StringConfigItem; + /** The OIDC provider name */ + oidc_name?: StringConfigItem; + /** The endpoint of the OIDC provider */ + oidc_endpoint?: StringConfigItem; + /** The client ID of the OIDC provider */ + oidc_client_id?: StringConfigItem; + /** The attribute claims the group name */ + oidc_groups_claim?: StringConfigItem; + /** The OIDC group which has the harbor admin privileges */ + oidc_admin_group?: StringConfigItem; + /** The OIDC group filter which filters out the group doesn't match the regular expression */ + oidc_group_filter?: StringConfigItem; + /** The scope of the OIDC provider */ + oidc_scope?: StringConfigItem; + /** The attribute claims the username */ + oidc_user_claim?: StringConfigItem; + /** Verify the OIDC provider's certificate' */ + oidc_verify_cert?: BoolConfigItem; + /** Auto onboard the OIDC user */ + oidc_auto_onboard?: BoolConfigItem; + /** Extra parameters to add when redirect request to OIDC provider */ + oidc_extra_redirect_parms?: StringConfigItem; + /** Extra parameters to logout user session from the OIDC provider */ + oidc_logout?: BoolConfigItem; + /** The robot account token duration in days */ + robot_token_duration?: IntegerConfigItem; + /** The rebot account name prefix */ + robot_name_prefix?: StringConfigItem; + /** Enable notification */ + notification_enable?: BoolConfigItem; + /** Enable quota per project */ + quota_per_project_enable?: BoolConfigItem; + /** The storage quota per project */ + storage_per_project?: IntegerConfigItem; + /** The endpoint of the audit log forwarder */ + audit_log_forward_endpoint?: StringConfigItem; + /** Whether skip the audit log in database */ + skip_audit_log_database?: BoolConfigItem; + /** Whether or not to skip update the pull time for scanner */ + scanner_skip_update_pulltime?: BoolConfigItem; + scan_all_policy?: { + /** The type of scan all policy, currently the valid values are "none" and "daily" */ + type?: string; + /** The parameters of the policy, the values are dependent on the type of the policy. */ + parameter?: { + /** The offset in seconds of UTC 0 o'clock, only valid when the policy type is "daily" */ + daily_time?: number; + }; + }; + /** The session timeout in minutes */ + session_timeout?: IntegerConfigItem; + /** The banner message for the UI.It is the stringified result of the banner message object */ + banner_message?: StringConfigItem; + /** The audit log event types to skip to log in database */ + disabled_audit_log_event_types?: StringConfigItem; +} + +export interface Configurations { + /** The auth mode of current system, such as "db_auth", "ldap_auth", "oidc_auth" */ + auth_mode?: string; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: boolean | null; + /** The Base DN for LDAP binding. */ + ldap_base_dn?: string; + /** The filter for LDAP search */ + ldap_filter?: string; + /** The base DN to search LDAP group. */ + ldap_group_base_dn?: string; + /** Specify the ldap group which have the same privilege with Harbor admin */ + ldap_group_admin_dn?: string; + /** The attribute which is used as identity of the LDAP group, default is cn.' */ + ldap_group_attribute_name?: string; + /** The filter to search the ldap group */ + ldap_group_search_filter?: string; + /** The scope to search ldap group. ''0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE'' */ + ldap_group_search_scope?: number; + /** Attach LDAP user group information in parallel, the parallel worker count is 5 */ + ldap_group_attach_parallel?: boolean; + /** The scope to search ldap users,'0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE' */ + ldap_scope?: number; + /** The DN of the user to do the search. */ + ldap_search_dn?: string; + /** The password of the ldap search dn */ + ldap_search_password?: string; + /** Timeout in seconds for connection to LDAP server */ + ldap_timeout?: number; + /** The attribute which is used as identity for the LDAP binding, such as "CN" or "SAMAccountname" */ + ldap_uid?: string; + /** The URL of LDAP server */ + ldap_url?: string; + /** Whether verify your OIDC server certificate, disable it if your OIDC server is hosted via self-hosted certificate. */ + ldap_verify_cert?: boolean; + /** The user attribute to identify the group membership */ + ldap_group_membership_attribute?: string; + /** Indicate who can create projects, it could be ''adminonly'' or ''everyone''. */ + project_creation_restriction?: string; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: boolean; + /** Whether the Harbor instance supports self-registration. If it''s set to false, admin need to add user to the instance. */ + self_registration?: boolean; + /** The expiration time of the token for internal Registry, in minutes. */ + token_expiration?: number; + /** The client id of UAA */ + uaa_client_id?: string; + /** The client secret of the UAA */ + uaa_client_secret?: string; + /** The endpoint of the UAA */ + uaa_endpoint?: string; + /** Verify the certificate in UAA server */ + uaa_verify_cert?: boolean; + /** The endpoint of the HTTP auth */ + http_authproxy_endpoint?: string; + /** The token review endpoint */ + http_authproxy_tokenreview_endpoint?: string; + /** The group which has the harbor admin privileges */ + http_authproxy_admin_groups?: string; + /** The username which has the harbor admin privileges */ + http_authproxy_admin_usernames?: string; + /** Verify the HTTP auth provider's certificate */ + http_authproxy_verify_cert?: boolean; + /** Search user before onboard */ + http_authproxy_skip_search?: boolean; + /** The certificate of the HTTP auth provider */ + http_authproxy_server_certificate?: string; + /** The OIDC provider name */ + oidc_name?: string; + /** The endpoint of the OIDC provider */ + oidc_endpoint?: string; + /** The client ID of the OIDC provider */ + oidc_client_id?: string; + /** The OIDC provider secret */ + oidc_client_secret?: string; + /** The attribute claims the group name */ + oidc_groups_claim?: string; + /** The OIDC group which has the harbor admin privileges */ + oidc_admin_group?: string; + /** The OIDC group filter which filters out the group name doesn't match the regular expression */ + oidc_group_filter?: string; + /** The scope of the OIDC provider */ + oidc_scope?: string; + /** The attribute claims the username */ + oidc_user_claim?: string; + /** Verify the OIDC provider's certificate' */ + oidc_verify_cert?: boolean; + /** Auto onboard the OIDC user */ + oidc_auto_onboard?: boolean; + /** Extra parameters to add when redirect request to OIDC provider */ + oidc_extra_redirect_parms?: string; + /** Logout OIDC user session */ + oidc_logout?: boolean; + /** The robot account token duration in days */ + robot_token_duration?: number; + /** The rebot account name prefix */ + robot_name_prefix?: string; + /** Enable notification */ + notification_enable?: boolean; + /** Enable quota per project */ + quota_per_project_enable?: boolean; + /** The storage quota per project */ + storage_per_project?: number; + /** The audit log forward endpoint */ + audit_log_forward_endpoint?: string; + /** Skip audit log database */ + skip_audit_log_database?: boolean; + /** The session timeout for harbor, in minutes. */ + session_timeout?: number; + /** Whether or not to skip update pull time for scanner */ + scanner_skip_update_pulltime?: boolean; + /** The banner message for the UI.It is the stringified result of the banner message object */ + banner_message?: string; + /** the list to disable log audit event types. */ + disabled_audit_log_event_types?: string; +} + +export interface StringConfigItem { + /** The string value of current config item */ + value: string; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface BoolConfigItem { + /** The boolean value of current config item */ + value: boolean; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface IntegerConfigItem { + /** The integer value of current config item */ + value: number; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface ProjectMemberEntity { + /** the project member id */ + id?: number; + /** the project id */ + project_id?: number; + /** the name of the group member. */ + entity_name?: string; + /** the name of the role */ + role_name?: string; + /** the role id */ + role_id?: number; + /** the id of entity, if the member is a user, it is user_id in user table. if the member is a user group, it is the user group's ID in user_group table. */ + entity_id?: number; + /** the entity's type, u for user entity, g for group entity. */ + entity_type?: string; +} + +export interface ProjectMember { + /** The role id 1 for projectAdmin, 2 for developer, 3 for guest, 4 for maintainer */ + role_id?: number; + member_user?: UserEntity; + member_group?: UserGroup; +} + +export interface RoleRequest { + /** The role id 1 for projectAdmin, 2 for developer, 3 for guest, 4 for maintainer */ + role_id?: number; +} + +export interface UserEntity { + /** The ID of the user. */ + user_id?: number; + /** The name of the user. */ + username?: string; +} + +export interface UserProfile { + email?: string; + realname?: string; + comment?: string; +} + +export interface UserCreationReq { + /** @maxLength 255 */ + email?: string; + realname?: string; + comment?: string; + password?: string; + /** @maxLength 255 */ + username?: string; +} + +export interface OIDCUserInfo { + /** + * the ID of the OIDC info record + * @format int + */ + id?: number; + /** + * the ID of the user + * @format int + */ + user_id?: number; + /** the concatenation of sub and issuer in the ID token */ + subiss?: string; + /** the secret of the OIDC user that can be used for CLI to push/pull artifacts */ + secret?: string; + /** + * The creation time of the OIDC user info record. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the OIDC user info record. + * @format date-time + */ + update_time?: string; +} + +export interface UserResp { + email?: string; + realname?: string; + comment?: string; + /** @format int */ + user_id?: number; + username?: string; + sysadmin_flag: boolean; + /** indicate the admin privilege is grant by authenticator (LDAP), is always false unless it is the current login user */ + admin_role_in_auth: boolean; + oidc_user_meta?: OIDCUserInfo; + /** + * The creation time of the user. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the user. + * @format date-time + */ + update_time?: string; +} + +export interface UserSysAdminFlag { + /** true-admin, false-not admin. */ + sysadmin_flag?: boolean; +} + +export interface UserSearch { + /** + * The ID of the user. + * @format int + */ + user_id?: number; + username?: string; +} + +export interface PasswordReq { + /** The user's existing password. */ + old_password?: string; + /** New password for marking as to be updated. */ + new_password?: string; +} + +export interface UserSearchRespItem { + /** + * The ID of the user. + * @format int + */ + user_id?: number; + username?: string; +} + +export interface Permission { + /** The permission resoruce */ + resource?: string; + /** The permission action */ + action?: string; +} + +export interface Permissions { + /** The system level permissions */ + system?: Permission[]; + /** The project level permissions */ + project?: Permission[]; +} + +export interface OIDCCliSecretReq { + /** The new secret */ + secret?: string; +} + +/** The system health status */ +export interface OverallHealthStatus { + /** The overall health status. It is "healthy" only when all the components' status are "healthy" */ + status?: string; + components?: ComponentHealthStatus[]; +} + +/** The health status of component */ +export interface ComponentHealthStatus { + /** The component name */ + name?: string; + /** The health status of component. Is either "healthy" or "unhealthy". */ + status?: string; + /** (optional) The error message when the status is "unhealthy" */ + error?: string; +} + +export interface Statistic { + /** + * The count of the private projects + * @format int64 + */ + private_project_count: number; + /** + * The count of the private repositories + * @format int64 + */ + private_repo_count: number; + /** + * The count of the public projects + * @format int64 + */ + public_project_count: number; + /** + * The count of the public repositories + * @format int64 + */ + public_repo_count: number; + /** + * The count of the total projects, only be seen by the system admin + * @format int64 + */ + total_project_count: number; + /** + * The count of the total repositories, only be seen by the system admin + * @format int64 + */ + total_repo_count: number; + /** + * The total storage consumption of blobs, only be seen by the system admin + * @format int64 + */ + total_storage_consumption: number; +} + +/** The accessory of the artifact */ +export interface Accessory { + /** + * The ID of the accessory + * @format int64 + */ + id?: number; + /** + * The artifact id of the accessory + * @format int64 + */ + artifact_id: number; + /** + * Going to be deprecated, use repo and digest for insteand. The subject artifact id of the accessory. + * @format int64 + */ + subject_artifact_id?: number; + /** The subject artifact digest of the accessory */ + subject_artifact_digest: string; + /** The subject artifact repository name of the accessory */ + subject_artifact_repo: string; + /** + * The artifact size of the accessory + * @format int64 + */ + size: number; + /** The artifact digest of the accessory */ + digest: string; + /** The artifact size of the accessory */ + type: string; + /** The icon of the accessory */ + icon: string; + /** + * The creation time of the accessory + * @format date-time + */ + creation_time?: string; +} + +/** The criteria to select the scan data to export. */ +export interface ScanDataExportRequest { + /** Name of the scan data export job */ + job_name?: string; + /** A list of one or more projects for which to export the scan data, currently only one project is supported due to performance concerns, but define as array for extension in the future. */ + projects?: number[]; + /** A list of one or more labels for which to export the scan data, defaults to all if empty */ + labels?: number[]; + /** A list of repositories for which to export the scan data, defaults to all if empty */ + repositories?: string; + /** CVE-IDs for which to export data. Multiple CVE-IDs can be specified by separating using ',' and enclosed between '{}'. Defaults to all if empty */ + cveIds?: string; + /** A list of tags enclosed within '{}'. Defaults to all if empty */ + tags?: string; +} + +/** The metadata associated with the scan data export job */ +export interface ScanDataExportJob { + /** + * The id of the scan data export job + * @format int64 + */ + id?: number; +} + +/** The replication execution */ +export interface ScanDataExportExecution { + /** The ID of the execution */ + id?: number; + /** The ID if the user triggering the export job */ + user_id?: number; + /** The status of the execution */ + status?: string; + /** The trigger mode */ + trigger?: string; + /** + * The start time + * @format date-time + */ + start_time?: string; + /** + * The end time + * @format date-time + */ + end_time?: string; + /** The status text */ + status_text: string; + /** The name of the user triggering the job */ + user_name: string; + /** Indicates whether the export artifact is present in registry */ + file_present: boolean; +} + +/** The list of scan data export executions */ +export interface ScanDataExportExecutionList { + /** The list of scan data export executions */ + items?: ScanDataExportExecution[]; +} + +/** the worker pool of job service */ +export interface WorkerPool { + /** the process id of jobservice */ + pid?: number; + /** the id of the worker pool */ + worker_pool_id?: string; + /** + * The start time of the work pool + * @format date-time + */ + start_at?: string; + /** + * The heartbeat time of the work pool + * @format date-time + */ + heartbeat_at?: string; + /** The concurrency of the work pool */ + concurrency?: number; + /** The host of the work pool */ + host?: string; +} + +/** worker in the pool */ +export interface Worker { + /** the id of the worker */ + id?: string; + /** the id of the worker pool */ + pool_id?: string; + /** the name of the running job in the worker */ + job_name?: string; + /** the id of the running job in the worker */ + job_id?: string; + /** + * The start time of the worker + * @format date-time + */ + start_at?: string | null; + /** the checkin of the running job in the worker */ + check_in?: string; + /** + * The checkin time of the worker + * @format date-time + */ + checkin_at?: string | null; +} + +/** The request to stop, pause or resume */ +export interface ActionRequest { + /** The action of the request, should be stop, pause or resume */ + action?: "stop" | "pause" | "resume"; +} + +/** the job queue info */ +export interface JobQueue { + /** The type of the job queue */ + job_type?: string; + /** The count of jobs in the job queue */ + count?: number; + /** The latency the job queue (seconds) */ + latency?: number; + /** The paused status of the job queue */ + paused: boolean; +} + +/** the schedule task info */ +export interface ScheduleTask { + /** the id of the Schedule task */ + id?: number; + /** the vendor type of the current schedule task */ + vendor_type?: string; + /** the vendor id of the current task */ + vendor_id?: number; + /** the cron of the current schedule task */ + cron?: string; + /** + * the update time of the schedule task + * @format date-time + */ + update_time?: string; +} + +/** the scheduler status */ +export interface SchedulerStatus { + /** if the scheduler is paused */ + paused: boolean; +} + +/** the security summary */ +export interface SecuritySummary { + /** + * the count of critical vulnerabilities + * @format int64 + */ + critical_cnt: number; + /** + * the count of high vulnerabilities + * @format int64 + */ + high_cnt?: number; + /** + * the count of medium vulnerabilities + * @format int64 + */ + medium_cnt: number; + /** + * the count of low vulnerabilities + * @format int64 + */ + low_cnt: number; + /** + * the count of none vulnerabilities + * @format int64 + */ + none_cnt?: number; + /** + * the count of unknown vulnerabilities + * @format int64 + */ + unknown_cnt?: number; + /** + * the count of total vulnerabilities + * @format int64 + */ + total_vuls: number; + /** + * the count of scanned artifacts + * @format int64 + */ + scanned_cnt: number; + /** + * the total count of artifacts + * @format int64 + */ + total_artifact: number; + /** + * the count of fixable vulnerabilities + * @format int64 + */ + fixable_cnt: number; + /** the list of dangerous CVEs */ + dangerous_cves?: DangerousCVE[]; + /** the list of dangerous artifacts */ + dangerous_artifacts?: DangerousArtifact[]; +} + +/** the dangerous CVE information */ +export interface DangerousCVE { + /** the cve id */ + cve_id?: string; + /** the severity of the CVE */ + severity?: string; + /** + * the cvss score v3 + * @format float64 + */ + cvss_score_v3?: number; + /** the description of the CVE */ + desc?: string; + /** the package of the CVE */ + package?: string; + /** the version of the package */ + version?: string; +} + +/** the dangerous artifact information */ +export interface DangerousArtifact { + /** + * the project id of the artifact + * @format int64 + */ + project_id?: number; + /** the repository name of the artifact */ + repository_name?: string; + /** the digest of the artifact */ + digest?: string; + /** the count of critical vulnerabilities */ + critical_cnt: number; + /** + * the count of high vulnerabilities + * @format int64 + */ + high_cnt: number; + /** the count of medium vulnerabilities */ + medium_cnt: number; +} + +/** the vulnerability item info */ +export interface VulnerabilityItem { + /** + * the project ID of the artifact + * @format int64 + */ + project_id?: number; + /** the repository name of the artifact */ + repository_name?: string; + /** the digest of the artifact */ + digest?: string; + /** the tags of the artifact */ + tags?: string[]; + /** the CVE id of the vulnerability. */ + cve_id?: string; + /** the severity of the vulnerability */ + severity?: string; + /** + * the nvd cvss v3 score of the vulnerability + * @format float + */ + cvss_v3_score?: number; + /** the package of the vulnerability */ + package?: string; + /** the version of the package */ + version?: string; + /** the fixed version of the package */ + fixed_version?: string; + /** The description of the vulnerability */ + desc?: string; + /** Links of the vulnerability */ + links?: string[]; +} + +export interface ScanType { + /** The scan type for the scan request. Two options are currently supported, vulnerability and sbom */ + scan_type?: "vulnerability" | "sbom"; +} + +import type { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + HeadersDefaults, + ResponseType, +} from "axios"; +import axios from "axios"; + +export type QueryParamsType = Record; + +export interface FullRequestParams + extends Omit { + /** set parameter to `true` for call `securityWorker` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseType; + /** request body */ + body?: unknown; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig + extends Omit { + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | AxiosRequestConfig | void; + secure?: boolean; + format?: ResponseType; +} + +export enum ContentType { + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public instance: AxiosInstance; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private secure?: boolean; + private format?: ResponseType; + + constructor({ + securityWorker, + secure, + format, + ...axiosConfig + }: ApiConfig = {}) { + this.instance = axios.create({ + ...axiosConfig, + baseURL: axiosConfig.baseURL || "http://localhost/api/v2.0", + }); + this.secure = secure; + this.format = format; + this.securityWorker = securityWorker; + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected mergeRequestParams( + params1: AxiosRequestConfig, + params2?: AxiosRequestConfig, + ): AxiosRequestConfig { + const method = params1.method || (params2 && params2.method); + + return { + ...this.instance.defaults, + ...params1, + ...(params2 || {}), + headers: { + ...((method && + this.instance.defaults.headers[ + method.toLowerCase() as keyof HeadersDefaults + ]) || + {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected stringifyFormItem(formItem: unknown) { + if (typeof formItem === "object" && formItem !== null) { + return JSON.stringify(formItem); + } else { + return `${formItem}`; + } + } + + protected createFormData(input: Record): FormData { + if (input instanceof FormData) { + return input; + } + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + const isFileType = formItem instanceof Blob || formItem instanceof File; + formData.append( + key, + isFileType ? formItem : this.stringifyFormItem(formItem), + ); + } + + return formData; + }, new FormData()); + } + + public request = async ({ + secure, + path, + type, + query, + format, + body, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const responseFormat = format || this.format || undefined; + + if ( + type === ContentType.FormData && + body && + body !== null && + typeof body === "object" + ) { + body = this.createFormData(body as Record); + } + + if ( + type === ContentType.Text && + body && + body !== null && + typeof body !== "string" + ) { + body = JSON.stringify(body); + } + + return this.instance.request({ + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type ? { "Content-Type": type } : {}), + }, + params: query, + responseType: responseFormat, + data: body, + url: path, + }); + }; +} + +/** + * @title Harbor API + * @version 2.0 + * @baseUrl http://localhost/api/v2.0 + * + * These APIs provide services for manipulating Harbor project. + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + health = { + /** + * @description Check the status of Harbor components. This path does not require authentication. + * + * @tags health + * @name GetHealth + * @summary Check the status of Harbor components + * @request GET:/health + * @secure + */ + getHealth: (params: RequestParams = {}) => + this.request({ + path: `/health`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + search = { + /** + * @description The Search endpoint returns information about the projects and repositories offered at public status or related to the current logged in user. The response includes the project and repository list in a proper display order. + * + * @tags search + * @name Search + * @summary Search for projects and repositories + * @request GET:/search + * @secure + */ + search: ( + query: { + /** Search parameter for project and repository name. */ + q: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + statistics = { + /** + * @description Get the statistic information about the projects and repositories + * + * @tags statistic + * @name GetStatistic + * @summary Get the statistic information about the projects and repositories + * @request GET:/statistics + * @secure + */ + getStatistic: (params: RequestParams = {}) => + this.request({ + path: `/statistics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + ldap = { + /** + * @description This endpoint ping the available ldap service for test related configuration parameters. + * + * @tags Ldap + * @name PingLdap + * @summary Ping available ldap service. + * @request POST:/ldap/ping + * @secure + */ + pingLdap: (ldapconf: LdapConf, params: RequestParams = {}) => + this.request({ + path: `/ldap/ping`, + method: "POST", + body: ldapconf, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint searches the available ldap users based on related configuration parameters. Support searched by input ldap configuration, load configuration from the system and specific filter. + * + * @tags Ldap + * @name SearchLdapUser + * @summary Search available ldap users. + * @request GET:/ldap/users/search + * @secure + */ + searchLdapUser: ( + query?: { + /** Registered user ID */ + username?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/ldap/users/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint adds the selected available ldap users to harbor based on related configuration parameters from the system. System will try to guess the user email address and realname, add to harbor user information. If have errors when import user, will return the list of importing failed uid and the failed reason. + * + * @tags Ldap + * @name ImportLdapUser + * @summary Import selected available ldap users. + * @request POST:/ldap/users/import + * @secure + */ + importLdapUser: (uid_list: LdapImportUsers, params: RequestParams = {}) => + this.request({ + path: `/ldap/users/import`, + method: "POST", + body: uid_list, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint searches the available ldap groups based on related configuration parameters. support to search by groupname or groupdn. + * + * @tags Ldap + * @name SearchLdapGroup + * @summary Search available ldap groups. + * @request GET:/ldap/groups/search + * @secure + */ + searchLdapGroup: ( + query?: { + /** Ldap group name */ + groupname?: string; + /** The LDAP group DN */ + groupdn?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/ldap/groups/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + internalconfig = { + /** + * @description This endpoint is for retrieving system configurations that only provides for internal api call. + * + * @tags configure + * @name GetInternalconfig + * @summary Get internal configurations. + * @request GET:/internalconfig + * @secure + */ + getInternalconfig: (params: RequestParams = {}) => + this.request({ + path: `/internalconfig`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + configurations = { + /** + * @description This endpoint is for retrieving system configurations that only provides for admin user. + * + * @tags configure + * @name GetConfigurations + * @summary Get system configurations. + * @request GET:/configurations + * @secure + */ + getConfigurations: (params: RequestParams = {}) => + this.request({ + path: `/configurations`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for modifying system configurations that only provides for admin user. + * + * @tags configure + * @name UpdateConfigurations + * @summary Modify system configurations. + * @request PUT:/configurations + * @secure + */ + updateConfigurations: ( + configurations: Configurations, + params: RequestParams = {}, + ) => + this.request({ + path: `/configurations`, + method: "PUT", + body: configurations, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + projects = { + /** + * @description This endpoint returns projects created by Harbor. + * + * @tags project + * @name ListProjects + * @summary List projects + * @request GET:/projects + * @secure + */ + listProjects: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** The name of project. */ + name?: string; + /** The project is public or private. */ + public?: boolean; + /** The name of project owner. */ + owner?: string; + /** + * Bool value indicating whether return detailed information of the project + * @default true + */ + with_detail?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is used to check if the project name provided already exist. + * + * @tags project + * @name HeadProject + * @summary Check if the project name user provided already exists. + * @request HEAD:/projects + * @secure + */ + headProject: ( + query: { + /** Project name for checking exists. */ + project_name: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects`, + method: "HEAD", + query: query, + secure: true, + ...params, + }), + + /** + * @description This endpoint is for user to create a new project. + * + * @tags project + * @name CreateProject + * @summary Create a new project. + * @request POST:/projects + * @secure + */ + createProject: (project: ProjectReq, params: RequestParams = {}) => + this.request({ + path: `/projects`, + method: "POST", + body: project, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint returns specific project information by project ID. + * + * @tags project + * @name GetProject + * @summary Return specific project detail information + * @request GET:/projects/{project_name_or_id} + * @secure + */ + getProject: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is aimed to update the properties of a project. + * + * @tags project + * @name UpdateProject + * @summary Update properties for a selected project. + * @request PUT:/projects/{project_name_or_id} + * @secure + */ + updateProject: ( + projectNameOrId: string, + project: ProjectReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "PUT", + body: project, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is aimed to delete project by project ID. + * + * @tags project + * @name DeleteProject + * @summary Delete project by projectID + * @request DELETE:/projects/{project_name_or_id} + * @secure + */ + deleteProject: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Get the deletable status of the project + * + * @tags project + * @name GetProjectDeletable + * @summary Get the deletable status of the project + * @request GET:/projects/{project_name_or_id}/_deletable + * @secure + */ + getProjectDeletable: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/_deletable`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get summary of the project. + * + * @tags project + * @name GetProjectSummary + * @summary Get summary of the project. + * @request GET:/projects/{project_name_or_id}/summary + * @secure + */ + getProjectSummary: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}/summary`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get all project member information + * + * @tags member + * @name ListProjectMembers + * @summary Get all project member information + * @request GET:/projects/{project_name_or_id}/members + * @secure + */ + listProjectMembers: ( + projectNameOrId: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The entity name to search. */ + entityname?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create project member relationship, the member can be one of the user_member and group_member, The user_member need to specify user_id or username. If the user already exist in harbor DB, specify the user_id, If does not exist in harbor DB, it will SearchAndOnBoard the user. The group_member need to specify id or ldap_group_dn. If the group already exist in harbor DB. specify the user group's id, If does not exist, it will SearchAndOnBoard the group. + * + * @tags member + * @name CreateProjectMember + * @summary Create project member + * @request POST:/projects/{project_name_or_id}/members + * @secure + */ + createProjectMember: ( + projectNameOrId: string, + project_member: ProjectMember, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members`, + method: "POST", + body: project_member, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the project member information + * + * @tags member + * @name GetProjectMember + * @summary Get the project member information + * @request GET:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + getProjectMember: ( + projectNameOrId: string, + mid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update project member relationship + * + * @tags member + * @name UpdateProjectMember + * @summary Update project member + * @request PUT:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + updateProjectMember: ( + projectNameOrId: string, + mid: number, + role: RoleRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "PUT", + body: role, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags member + * @name DeleteProjectMember + * @summary Delete project member + * @request DELETE:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + deleteProjectMember: ( + projectNameOrId: string, + mid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Get the metadata of the specific project + * + * @tags projectMetadata + * @name ListProjectMetadatas + * @summary Get the metadata of the specific project + * @request GET:/projects/{project_name_or_id}/metadatas/ + * @secure + */ + listProjectMetadatas: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Add metadata for the specific project + * + * @tags projectMetadata + * @name AddProjectMetadatas + * @summary Add metadata for the specific project + * @request POST:/projects/{project_name_or_id}/metadatas/ + * @secure + */ + addProjectMetadatas: ( + projectNameOrId: string, + metadata: Annotations, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/`, + method: "POST", + body: metadata, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific metadata of the specific project + * + * @tags projectMetadata + * @name GetProjectMetadata + * @summary Get the specific metadata of the specific project + * @request GET:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + getProjectMetadata: ( + projectNameOrId: string, + metaName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update the specific metadata for the specific project + * + * @tags projectMetadata + * @name UpdateProjectMetadata + * @summary Update the specific metadata for the specific project + * @request PUT:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + updateProjectMetadata: ( + projectNameOrId: string, + metaName: string, + metadata: Annotations, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "PUT", + body: metadata, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the specific metadata for the specific project + * + * @tags projectMetadata + * @name DeleteProjectMetadata + * @summary Delete the specific metadata for the specific project + * @request DELETE:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + deleteProjectMetadata: ( + projectNameOrId: string, + metaName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List repositories of the specified project + * + * @tags repository + * @name ListRepositories + * @summary List repositories + * @request GET:/projects/{project_name}/repositories + * @secure + */ + listRepositories: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the repository specified by name + * + * @tags repository + * @name GetRepository + * @summary Get repository + * @request GET:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + getRepository: ( + projectName: string, + repositoryName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update the repository specified by name + * + * @tags repository + * @name UpdateRepository + * @summary Update repository + * @request PUT:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + updateRepository: ( + projectName: string, + repositoryName: string, + repository: Repository, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "PUT", + body: repository, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the repository specified by name + * + * @tags repository + * @name DeleteRepository + * @summary Delete repository + * @request DELETE:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + deleteRepository: ( + projectName: string, + repositoryName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List artifacts under the specific project and repository. Except the basic properties, the other supported queries in "q" includes "tags=*" to list only tagged artifacts, "tags=nil" to list only untagged artifacts, "tags=~v" to list artifacts whose tag fuzzy matches "v", "tags=v" to list artifact whose tag exactly matches "v", "labels=(id1, id2)" to list artifacts that both labels with id1 and id2 are added to + * + * @tags artifact + * @name ListArtifacts + * @summary List artifacts + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts + * @secure + */ + listArtifacts: ( + projectName: string, + repositoryName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are included inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are included inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is included inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifacts, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the immutable status is included inside the tags of the returning artifacts. Only works when setting "with_immutable_status=true" + * @default false + */ + with_immutable_status?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. Only works when setting "with_accessory=true" + * @default false + */ + with_accessory?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Copy the artifact specified in the "from" parameter to the repository. + * + * @tags artifact + * @name CopyArtifact + * @summary Copy artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts + * @secure + */ + copyArtifact: ( + projectName: string, + repositoryName: string, + query: { + /** The artifact from which the new artifact is copied from, the format should be "project/repository:tag" or "project/repository@digest". */ + from: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts`, + method: "POST", + query: query, + secure: true, + ...params, + }), + + /** + * @description Get the artifact specified by the reference under the project and repository. The reference can be digest or tag. + * + * @tags artifact + * @name GetArtifact + * @summary Get the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference} + * @secure + */ + getArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are inclued inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are inclued inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is inclued inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifact, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. + * @default false + */ + with_accessory?: boolean; + /** + * Specify whether the signature is inclued inside the returning artifacts + * @default false + */ + with_signature?: boolean; + /** + * Specify whether the immutable status is inclued inside the tags of the returning artifacts. + * @default false + */ + with_immutable_status?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the artifact specified by the reference under the project and repository. The reference can be digest or tag + * + * @tags artifact + * @name DeleteArtifact + * @summary Delete the specific artifact + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference} + * @secure + */ + deleteArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Scan the specified artifact + * + * @tags scan + * @name ScanArtifact + * @summary Scan the artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan + * @secure + */ + scanArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + scanType: ScanType, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan`, + method: "POST", + body: scanType, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Cancelling a scan job for a particular artifact + * + * @tags scan + * @name StopScanArtifact + * @summary Cancelling a scan job for a particular artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan/stop + * @secure + */ + stopScanArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + scanType: ScanType, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan/stop`, + method: "POST", + body: scanType, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the log of the scan report + * + * @tags scan + * @name GetReportLog + * @summary Get the log of the scan report + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan/{report_id}/log + * @secure + */ + getReportLog: ( + projectName: string, + repositoryName: string, + reference: string, + reportId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan/${reportId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description Create a tag for the specified artifact + * + * @tags artifact + * @name CreateTag + * @summary Create tag + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags + * @secure + */ + createTag: ( + projectName: string, + repositoryName: string, + reference: string, + tag: Tag, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags`, + method: "POST", + body: tag, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List tags of the specific artifact + * + * @tags artifact + * @name ListTags + * @summary List tags + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags + * @secure + */ + listTags: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the immutable status is included inside the returning tags + * @default false + */ + with_immutable_status?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the tag of the specified artifact + * + * @tags artifact + * @name DeleteTag + * @summary Delete tag + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags/{tag_name} + * @secure + */ + deleteTag: ( + projectName: string, + repositoryName: string, + reference: string, + tagName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags/${tagName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List accessories of the specific artifact + * + * @tags artifact + * @name ListAccessories + * @summary List accessories + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/accessories + * @secure + */ + listAccessories: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/accessories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the vulnerabilities addition of the artifact specified by the reference under the project and repository. + * + * @tags artifact + * @name GetVulnerabilitiesAddition + * @summary Get the vulnerabilities addition of the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/additions/vulnerabilities + * @secure + */ + getVulnerabilitiesAddition: ( + projectName: string, + repositoryName: string, + reference: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/additions/vulnerabilities`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the addition of the artifact specified by the reference under the project and repository. + * + * @tags artifact + * @name GetAddition + * @summary Get the addition of the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/additions/{addition} + * @secure + */ + getAddition: ( + projectName: string, + repositoryName: string, + reference: string, + addition: + | "build_history" + | "values.yaml" + | "readme.md" + | "dependencies" + | "sbom" + | "license" + | "files", + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/additions/${addition}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Add label to the specified artiact. + * + * @tags artifact + * @name AddLabel + * @summary Add label to artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/labels + * @secure + */ + addLabel: ( + projectName: string, + repositoryName: string, + reference: string, + label: Label, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/labels`, + method: "POST", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Remove the label from the specified artiact. + * + * @tags artifact + * @name RemoveLabel + * @summary Remove label from artifact + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/labels/{label_id} + * @secure + */ + removeLabel: ( + projectName: string, + repositoryName: string, + reference: string, + labelId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/labels/${labelId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List artifacts of the specified project + * + * @tags project + * @name ListArtifactsOfProject + * @summary List artifacts + * @request GET:/projects/{project_name_or_id}/artifacts + * @secure + */ + listArtifactsOfProject: ( + projectNameOrId: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are included inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are included inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is included inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifacts, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the immutable status is included inside the tags of the returning artifacts. Only works when setting "with_immutable_status=true" + * @default false + */ + with_immutable_status?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. Only works when setting "with_accessory=true" + * @default false + */ + with_accessory?: boolean; + /** + * Specify whether only the latest pushed artifact of each repository is included inside the returning artifacts. Only works when either artifact_type or media_type is included in the query. + * @default false + */ + latest_in_repository?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/artifacts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the scanner registration of the specified project. If no scanner registration is configured for the specified project, the system default scanner registration will be returned. + * + * @tags project + * @name GetScannerOfProject + * @summary Get project level scanner + * @request GET:/projects/{project_name_or_id}/scanner + * @secure + */ + getScannerOfProject: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Set one of the system configured scanner registration as the indepndent scanner of the specified project. + * + * @tags project + * @name SetScannerOfProject + * @summary Configure scanner for the specified project + * @request PUT:/projects/{project_name_or_id}/scanner + * @secure + */ + setScannerOfProject: ( + projectNameOrId: string, + payload: ProjectScanner, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner`, + method: "PUT", + body: payload, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Retrieve the system configured scanner registrations as candidates of setting project level scanner. + * + * @tags project + * @name ListScannerCandidatesOfProject + * @summary Get scanner registration candidates for configurating project level scanner + * @request GET:/projects/{project_name_or_id}/scanner/candidates + * @secure + */ + listScannerCandidatesOfProject: ( + projectNameOrId: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner/candidates`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get recent logs of the projects, it only query the previous version's audit log + * + * @tags project + * @name GetLogs + * @summary Get recent logs of the projects (deprecated) + * @request GET:/projects/{project_name}/logs + * @secure + */ + getLogs: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/logs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get recent logs of the projects + * + * @tags project + * @name GetLogExts + * @summary Get recent logs of the projects + * @request GET:/projects/{project_name}/auditlog-exts + * @secure + */ + getLogExts: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/auditlog-exts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a preheat policy under a project + * + * @tags preheat + * @name CreatePolicy + * @summary Create a preheat policy under a project + * @request POST:/projects/{project_name}/preheat/policies + * @secure + */ + createPolicy: ( + projectName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List preheat policies + * + * @tags preheat + * @name ListPolicies + * @summary List preheat policies + * @request GET:/projects/{project_name}/preheat/policies + * @secure + */ + listPolicies: ( + projectName: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a preheat policy + * + * @tags preheat + * @name GetPolicy + * @summary Get a preheat policy + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + getPolicy: ( + projectName: string, + preheatPolicyName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update preheat policy + * + * @tags preheat + * @name UpdatePolicy + * @summary Update preheat policy + * @request PUT:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + updatePolicy: ( + projectName: string, + preheatPolicyName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Manual preheat + * + * @tags preheat + * @name ManualPreheat + * @summary Manual preheat + * @request POST:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + manualPreheat: ( + projectName: string, + preheatPolicyName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete a preheat policy + * + * @tags preheat + * @name DeletePolicy + * @summary Delete a preheat policy + * @request DELETE:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + deletePolicy: ( + projectName: string, + preheatPolicyName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List executions for the given policy + * + * @tags preheat + * @name ListExecutions + * @summary List executions for the given policy + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions + * @secure + */ + listExecutions: ( + projectName: string, + preheatPolicyName: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a execution detail by id + * + * @tags preheat + * @name GetExecution + * @summary Get a execution detail by id + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id} + * @secure + */ + getExecution: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop a execution + * + * @tags preheat + * @name StopExecution + * @summary Stop a execution + * @request PATCH:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id} + * @secure + */ + stopExecution: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + execution: Execution, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}`, + method: "PATCH", + body: execution, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List all the related tasks for the given execution + * + * @tags preheat + * @name ListTasks + * @summary List all the related tasks for the given execution + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id}/tasks + * @secure + */ + listTasks: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the log text stream of the specified task for the given execution + * + * @tags preheat + * @name GetPreheatLog + * @summary Get the log text stream of the specified task for the given execution + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id}/tasks/{task_id}/logs + * @secure + */ + getPreheatLog: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}/tasks/${taskId}/logs`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description Get all providers at project level + * + * @tags preheat + * @name ListProvidersUnderProject + * @summary Get all providers at project level + * @request GET:/projects/{project_name}/preheat/providers + * @secure + */ + listProvidersUnderProject: ( + projectName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/providers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the immutable tag rules of a project + * + * @tags immutable + * @name ListImmuRules + * @summary List all immutable tag rules of current project + * @request GET:/projects/{project_name_or_id}/immutabletagrules + * @secure + */ + listImmuRules: ( + projectNameOrId: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint add an immutable tag rule to the project + * + * @tags immutable + * @name CreateImmuRule + * @summary Add an immutable tag rule to current project + * @request POST:/projects/{project_name_or_id}/immutabletagrules + * @secure + */ + createImmuRule: ( + projectNameOrId: string, + ImmutableRule: ImmutableRule, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules`, + method: "POST", + body: ImmutableRule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags immutable + * @name UpdateImmuRule + * @summary Update the immutable tag rule or enable or disable the rule + * @request PUT:/projects/{project_name_or_id}/immutabletagrules/{immutable_rule_id} + * @secure + */ + updateImmuRule: ( + projectNameOrId: string, + immutableRuleId: number, + ImmutableRule: ImmutableRule, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules/${immutableRuleId}`, + method: "PUT", + body: ImmutableRule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags immutable + * @name DeleteImmuRule + * @summary Delete the immutable tag rule. + * @request DELETE:/projects/{project_name_or_id}/immutabletagrules/{immutable_rule_id} + * @secure + */ + deleteImmuRule: ( + projectNameOrId: string, + immutableRuleId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules/${immutableRuleId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns webhook policies of a project. + * + * @tags webhook + * @name ListWebhookPoliciesOfProject + * @summary List project webhook policies. + * @request GET:/projects/{project_name_or_id}/webhook/policies + * @secure + */ + listWebhookPoliciesOfProject: ( + projectNameOrId: string, + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint create a webhook policy if the project does not have one. + * + * @tags webhook + * @name CreateWebhookPolicyOfProject + * @summary Create project webhook policy. + * @request POST:/projects/{project_name_or_id}/webhook/policies + * @secure + */ + createWebhookPolicyOfProject: ( + projectNameOrId: string, + policy: WebhookPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint returns specified webhook policy of a project. + * + * @tags webhook + * @name GetWebhookPolicyOfProject + * @summary Get project webhook policy + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + getWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is aimed to update the webhook policy of a project. + * + * @tags webhook + * @name UpdateWebhookPolicyOfProject + * @summary Update webhook policy of a project. + * @request PUT:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + updateWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + policy: WebhookPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is aimed to delete webhookpolicy of a project. + * + * @tags webhook + * @name DeleteWebhookPolicyOfProject + * @summary Delete webhook policy of a project + * @request DELETE:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + deleteWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns the executions of a specific webhook policy. + * + * @tags webhook + * @name ListExecutionsOfWebhookPolicy + * @summary List executions for a specific webhook policy + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions + * @secure + */ + listExecutionsOfWebhookPolicy: ( + projectNameOrId: string, + webhookPolicyId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the tasks of a specific webhook execution. + * + * @tags webhook + * @name ListTasksOfWebhookExecution + * @summary List tasks for a specific webhook execution + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions/{execution_id}/tasks + * @secure + */ + listTasksOfWebhookExecution: ( + projectNameOrId: string, + webhookPolicyId: number, + executionId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions/${executionId}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the logs of a specific webhook task. + * + * @tags webhook + * @name GetLogsOfWebhookTask + * @summary Get logs for a specific webhook task + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions/{execution_id}/tasks/{task_id}/log + * @secure + */ + getLogsOfWebhookTask: ( + projectNameOrId: string, + webhookPolicyId: number, + executionId: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions/${executionId}/tasks/${taskId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns last trigger information of project webhook policy. + * + * @tags webhook + * @name LastTrigger + * @summary Get project webhook policy last trigger info + * @request GET:/projects/{project_name_or_id}/webhook/lasttrigger + * @deprecated + * @secure + */ + lastTrigger: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/lasttrigger`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns webhook jobs of a project. + * + * @tags webhookjob + * @name ListWebhookJobs + * @summary List project webhook jobs + * @request GET:/projects/{project_name_or_id}/webhook/jobs + * @deprecated + * @secure + */ + listWebhookJobs: ( + projectNameOrId: string, + query: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * The policy ID. + * @format int64 + */ + policy_id: number; + /** The status of webhook job. */ + status?: string[]; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/jobs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get supported event types and notify types. + * + * @tags webhook + * @name GetSupportedEventTypes + * @summary Get supported event types and notify types. + * @request GET:/projects/{project_name_or_id}/webhook/events + * @secure + */ + getSupportedEventTypes: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/events`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + repositories = { + /** + * @description List all authorized repositories + * + * @tags repository + * @name ListAllRepositories + * @summary List all authorized repositories + * @request GET:/repositories + * @secure + */ + listAllRepositories: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/repositories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + auditLogs = { + /** + * @description This endpoint let the user see the recent operation logs of projects which the user is a member with project admin role,, or return all audit logs for system admin user, it only query the audit log in previous version. + * + * @tags auditlog + * @name ListAuditLogs + * @summary Get recent logs of projects which the user is a member with project admin role, or return all audit logs for system admin user (deprecated) + * @request GET:/audit-logs + * @secure + */ + listAuditLogs: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/audit-logs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + auditlogExts = { + /** + * @description This endpoint let user see the recent operation logs of the projects which he is member with project_admin role, or return all audit logs for system admin user. + * + * @tags auditlog + * @name ListAuditLogExts + * @summary Get recent logs of the projects which the user is a member with project_admin role, or return all audit logs for system admin user + * @request GET:/auditlog-exts + * @secure + */ + listAuditLogExts: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/auditlog-exts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get all event types of audit log + * + * @tags auditlog + * @name ListAuditLogEventTypes + * @summary Get all event types of audit log + * @request GET:/auditlog-exts/events + * @secure + */ + listAuditLogEventTypes: (params: RequestParams = {}) => + this.request({ + path: `/auditlog-exts/events`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + p2P = { + /** + * @description List P2P providers + * + * @tags preheat + * @name ListProviders + * @summary List P2P providers + * @request GET:/p2p/preheat/providers + * @secure + */ + listProviders: (params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/providers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint checks status of a instance, the instance can be given by ID or Endpoint URL (together with credential) + * + * @tags preheat + * @name PingInstances + * @summary Ping status of a instance. + * @request POST:/p2p/preheat/instances/ping + * @secure + */ + pingInstances: (instance: Instance, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/ping`, + method: "POST", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List P2P provider instances + * + * @tags preheat + * @name ListInstances + * @summary List P2P provider instances + * @request GET:/p2p/preheat/instances + * @secure + */ + listInstances: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/p2p/preheat/instances`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create p2p provider instances + * + * @tags preheat + * @name CreateInstance + * @summary Create p2p provider instances + * @request POST:/p2p/preheat/instances + * @secure + */ + createInstance: (instance: Instance, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances`, + method: "POST", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get a P2P provider instance + * + * @tags preheat + * @name GetInstance + * @summary Get a P2P provider instance + * @request GET:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + getInstance: (preheatInstanceName: string, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specified P2P provider instance + * + * @tags preheat + * @name DeleteInstance + * @summary Delete the specified P2P provider instance + * @request DELETE:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + deleteInstance: (preheatInstanceName: string, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the specified P2P provider instance + * + * @tags preheat + * @name UpdateInstance + * @summary Update the specified P2P provider instance + * @request PUT:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + updateInstance: ( + preheatInstanceName: string, + instance: Instance, + params: RequestParams = {}, + ) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "PUT", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + usergroups = { + /** + * @description Get all user groups information, it is open for system admin + * + * @tags usergroup + * @name ListUserGroups + * @summary Get all user groups information + * @request GET:/usergroups + * @secure + */ + listUserGroups: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** search with ldap group DN */ + ldap_group_dn?: string; + /** group name need to search, fuzzy matches */ + group_name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create user group information + * + * @tags usergroup + * @name CreateUserGroup + * @summary Create user group + * @request POST:/usergroups + * @secure + */ + createUserGroup: (usergroup: UserGroup, params: RequestParams = {}) => + this.request({ + path: `/usergroups`, + method: "POST", + body: usergroup, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is to search groups by group name. It's open for all authenticated requests. + * + * @tags usergroup + * @name SearchUserGroups + * @summary Search groups by groupname + * @request GET:/usergroups/search + * @secure + */ + searchUserGroups: ( + query: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Group name for filtering results. */ + groupname: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get user group information + * + * @tags usergroup + * @name GetUserGroup + * @summary Get user group information + * @request GET:/usergroups/{group_id} + * @secure + */ + getUserGroup: (groupId: number, params: RequestParams = {}) => + this.request({ + path: `/usergroups/${groupId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update user group information + * + * @tags usergroup + * @name UpdateUserGroup + * @summary Update group information + * @request PUT:/usergroups/{group_id} + * @secure + */ + updateUserGroup: ( + groupId: number, + usergroup: UserGroup, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups/${groupId}`, + method: "PUT", + body: usergroup, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete user group + * + * @tags usergroup + * @name DeleteUserGroup + * @summary Delete user group + * @request DELETE:/usergroups/{group_id} + * @secure + */ + deleteUserGroup: (groupId: number, params: RequestParams = {}) => + this.request({ + path: `/usergroups/${groupId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + icons = { + /** + * @description Get the artifact icon with the specified digest. As the original icon image is resized and encoded before returning, the parameter "digest" in the path doesn't match the hash of the returned content + * + * @tags icon + * @name GetIcon + * @summary Get artifact icon + * @request GET:/icons/{digest} + * @secure + */ + getIcon: (digest: string, params: RequestParams = {}) => + this.request({ + path: `/icons/${digest}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + robots = { + /** + * @description List the robot accounts with the specified level and project. + * + * @tags robot + * @name ListRobot + * @summary Get robot account + * @request GET:/robots + * @secure + */ + listRobot: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/robots`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a robot account + * + * @tags robot + * @name CreateRobot + * @summary Create a robot account + * @request POST:/robots + * @secure + */ + createRobot: (robot: RobotCreate, params: RequestParams = {}) => + this.request({ + path: `/robots`, + method: "POST", + body: robot, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns specific robot account information by robot ID. + * + * @tags robot + * @name GetRobotById + * @summary Get a robot account + * @request GET:/robots/{robot_id} + * @secure + */ + getRobotById: (robotId: number, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint updates specific robot account information by robot ID. + * + * @tags robot + * @name UpdateRobot + * @summary Update a robot account + * @request PUT:/robots/{robot_id} + * @secure + */ + updateRobot: (robotId: number, robot: Robot, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "PUT", + body: robot, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Refresh the robot secret + * + * @tags robot + * @name RefreshSec + * @summary Refresh the robot secret + * @request PATCH:/robots/{robot_id} + * @secure + */ + refreshSec: ( + robotId: number, + robotSec: RobotSec, + params: RequestParams = {}, + ) => + this.request({ + path: `/robots/${robotId}`, + method: "PATCH", + body: robotSec, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint deletes specific robot account information by robot ID. + * + * @tags robot + * @name DeleteRobot + * @summary Delete a robot account + * @request DELETE:/robots/{robot_id} + * @secure + */ + deleteRobot: (robotId: number, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + quotas = { + /** + * @description List quotas + * + * @tags quota + * @name ListQuotas + * @summary List quotas + * @request GET:/quotas + * @secure + */ + listQuotas: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The reference type of quota. */ + reference?: string; + /** The reference id of quota. */ + reference_id?: string; + /** + * Sort method, valid values include: + * 'hard.resource_name', '-hard.resource_name', 'used.resource_name', '-used.resource_name'. + * Here '-' stands for descending order, resource_name should be the real resource name of the quota. + */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/quotas`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the specified quota + * + * @tags quota + * @name GetQuota + * @summary Get the specified quota + * @request GET:/quotas/{id} + * @secure + */ + getQuota: (id: number, params: RequestParams = {}) => + this.request({ + path: `/quotas/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update hard limits of the specified quota + * + * @tags quota + * @name UpdateQuota + * @summary Update the specified quota + * @request PUT:/quotas/{id} + * @secure + */ + updateQuota: ( + id: number, + hard: QuotaUpdateReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/quotas/${id}`, + method: "PUT", + body: hard, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + replication = { + /** + * @description List replication policies + * + * @tags replication + * @name ListReplicationPolicies + * @summary List replication policies + * @request GET:/replication/policies + * @secure + */ + listReplicationPolicies: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Deprecated, use "query" instead. The policy name. */ + name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a replication policy + * + * @tags replication + * @name CreateReplicationPolicy + * @summary Create a replication policy + * @request POST:/replication/policies + * @secure + */ + createReplicationPolicy: ( + policy: ReplicationPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific replication policy + * + * @tags replication + * @name GetReplicationPolicy + * @summary Get the specific replication policy + * @request GET:/replication/policies/{id} + * @secure + */ + getReplicationPolicy: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/policies/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specific replication policy + * + * @tags replication + * @name DeleteReplicationPolicy + * @summary Delete the specific replication policy + * @request DELETE:/replication/policies/{id} + * @secure + */ + deleteReplicationPolicy: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/policies/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the replication policy + * + * @tags replication + * @name UpdateReplicationPolicy + * @summary Update the replication policy + * @request PUT:/replication/policies/{id} + * @secure + */ + updateReplicationPolicy: ( + id: number, + policy: ReplicationPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies/${id}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List replication executions + * + * @tags replication + * @name ListReplicationExecutions + * @summary List replication executions + * @request GET:/replication/executions + * @secure + */ + listReplicationExecutions: ( + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The ID of the policy that the executions belong to. */ + policy_id?: number; + /** The execution status. */ + status?: string; + /** The trigger mode. */ + trigger?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Start one replication execution according to the policy + * + * @tags replication + * @name StartReplication + * @summary Start one replication execution + * @request POST:/replication/executions + * @secure + */ + startReplication: ( + execution: StartReplicationExecution, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions`, + method: "POST", + body: execution, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the replication execution specified by ID + * + * @tags replication + * @name GetReplicationExecution + * @summary Get the specific replication execution + * @request GET:/replication/executions/{id} + * @secure + */ + getReplicationExecution: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/executions/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the replication execution specified by ID + * + * @tags replication + * @name StopReplication + * @summary Stop the specific replication execution + * @request PUT:/replication/executions/{id} + * @secure + */ + stopReplication: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/executions/${id}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description List replication tasks for a specific execution + * + * @tags replication + * @name ListReplicationTasks + * @summary List replication tasks for a specific execution + * @request GET:/replication/executions/{id}/tasks + * @secure + */ + listReplicationTasks: ( + id: number, + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The task status. */ + status?: string; + /** The resource type. */ + resource_type?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions/${id}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the log of the specific replication task + * + * @tags replication + * @name GetReplicationLog + * @summary Get the log of the specific replication task + * @request GET:/replication/executions/{id}/tasks/{task_id}/log + * @secure + */ + getReplicationLog: ( + id: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions/${id}/tasks/${taskId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description List registry adapters + * + * @tags registry + * @name ListRegistryProviderTypes + * @summary List registry adapters + * @request GET:/replication/adapters + * @secure + */ + listRegistryProviderTypes: (params: RequestParams = {}) => + this.request({ + path: `/replication/adapters`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description List all registered registry provider information + * + * @tags registry + * @name ListRegistryProviderInfos + * @summary List all registered registry provider information + * @request GET:/replication/adapterinfos + * @secure + */ + listRegistryProviderInfos: (params: RequestParams = {}) => + this.request, Errors>({ + path: `/replication/adapterinfos`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + registries = { + /** + * @description Create a registry + * + * @tags registry + * @name CreateRegistry + * @summary Create a registry + * @request POST:/registries + * @secure + */ + createRegistry: (registry: Registry, params: RequestParams = {}) => + this.request({ + path: `/registries`, + method: "POST", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List the registries + * + * @tags registry + * @name ListRegistries + * @summary List the registries + * @request GET:/registries + * @secure + */ + listRegistries: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Deprecated, use `q` instead. */ + name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/registries`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Check status of a registry + * + * @tags registry + * @name PingRegistry + * @summary Check status of a registry + * @request POST:/registries/ping + * @secure + */ + pingRegistry: (registry: RegistryPing, params: RequestParams = {}) => + this.request({ + path: `/registries/ping`, + method: "POST", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific registry + * + * @tags registry + * @name GetRegistry + * @summary Get the specific registry + * @request GET:/registries/{id} + * @secure + */ + getRegistry: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specific registry + * + * @tags registry + * @name DeleteRegistry + * @summary Delete the specific registry + * @request DELETE:/registries/{id} + * @secure + */ + deleteRegistry: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the registry + * + * @tags registry + * @name UpdateRegistry + * @summary Update the registry + * @request PUT:/registries/{id} + * @secure + */ + updateRegistry: ( + id: number, + registry: RegistryUpdate, + params: RequestParams = {}, + ) => + this.request({ + path: `/registries/${id}`, + method: "PUT", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the registry info + * + * @tags registry + * @name GetRegistryInfo + * @summary Get the registry info + * @request GET:/registries/{id}/info + * @secure + */ + getRegistryInfo: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}/info`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + scans = { + /** + * @description Get the metrics of the latest scan all process + * + * @tags scanAll + * @name GetLatestScanAllMetrics + * @summary Get the metrics of the latest scan all process + * @request GET:/scans/all/metrics + * @secure + */ + getLatestScanAllMetrics: (params: RequestParams = {}) => + this.request({ + path: `/scans/all/metrics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the metrics of the latest scheduled scan all process + * + * @tags scanAll + * @name GetLatestScheduledScanAllMetrics + * @summary Get the metrics of the latest scheduled scan all process + * @request GET:/scans/schedule/metrics + * @deprecated + * @secure + */ + getLatestScheduledScanAllMetrics: (params: RequestParams = {}) => + this.request({ + path: `/scans/schedule/metrics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + systeminfo = { + /** + * @description This API is for retrieving general system info, this can be called by anonymous request. Some attributes will be omitted in the response when this API is called by anonymous request. + * + * @tags systeminfo + * @name GetSystemInfo + * @summary Get general system info + * @request GET:/systeminfo + * @secure + */ + getSystemInfo: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for retrieving system volume info that only provides for admin user. Note that the response only reflects the storage status of local disk. + * + * @tags systeminfo + * @name GetVolumes + * @summary Get system volume info (total/free size). + * @request GET:/systeminfo/volumes + * @secure + */ + getVolumes: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo/volumes`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for downloading a default root certificate. + * + * @tags systeminfo + * @name GetCert + * @summary Get default root certificate. + * @request GET:/systeminfo/getcert + * @secure + */ + getCert: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo/getcert`, + method: "GET", + secure: true, + ...params, + }), + }; + system = { + /** + * @description Test the OIDC endpoint, the setting of the endpoint is provided in the request. This API can only be called by system admin. + * + * @tags oidc + * @name PingOidc + * @summary Test the OIDC endpoint. + * @request POST:/system/oidc/ping + * @secure + */ + pingOidc: ( + endpoint: { + /** The URL of OIDC endpoint to be tested. */ + url?: string; + /** Whether the certificate should be verified */ + verify_cert?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/oidc/ping`, + method: "POST", + body: endpoint, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let user get gc execution history. + * + * @tags gc + * @name GetGcHistory + * @summary Get gc results. + * @request GET:/system/gc + * @secure + */ + getGcHistory: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/gc`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user get gc status filtered by specific ID. + * + * @tags gc + * @name GetGc + * @summary Get gc status. + * @request GET:/system/gc/{gc_id} + * @secure + */ + getGc: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the GC execution specified by ID + * + * @tags gc + * @name StopGc + * @summary Stop the specific GC execution + * @request PUT:/system/gc/{gc_id} + * @secure + */ + stopGc: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description This endpoint let user get gc job logs filtered by specific ID. + * + * @tags gc + * @name GetGcLog + * @summary Get gc job log. + * @request GET:/system/gc/{gc_id}/log + * @secure + */ + getGcLog: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint is for get schedule of gc job. + * + * @tags gc + * @name GetGcSchedule + * @summary Get gc's schedule. + * @request GET:/system/gc/schedule + * @secure + */ + getGcSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for update gc schedule. + * + * @tags gc + * @name CreateGcSchedule + * @summary Create a gc schedule. + * @request POST:/system/gc/schedule + * @secure + */ + createGcSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for update gc schedule. + * + * @tags gc + * @name UpdateGcSchedule + * @summary Update gc's schedule. + * @request PUT:/system/gc/schedule + * @secure + */ + updateGcSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description get purge job execution history. + * + * @tags purge + * @name GetPurgeHistory + * @summary Get purge job results. + * @request GET:/system/purgeaudit + * @secure + */ + getPurgeHistory: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/purgeaudit`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user get purge job status filtered by specific ID. + * + * @tags purge + * @name GetPurgeJob + * @summary Get purge job status. + * @request GET:/system/purgeaudit/{purge_id} + * @secure + */ + getPurgeJob: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the purge audit log execution specified by ID + * + * @tags purge + * @name StopPurge + * @summary Stop the specific purge audit log execution + * @request PUT:/system/purgeaudit/{purge_id} + * @secure + */ + stopPurge: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description This endpoint let user get purge job logs filtered by specific ID. + * + * @tags purge + * @name GetPurgeJobLog + * @summary Get purge job log. + * @request GET:/system/purgeaudit/{purge_id}/log + * @secure + */ + getPurgeJobLog: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint is for get schedule of purge job. + * + * @tags purge + * @name GetPurgeSchedule + * @summary Get purge's schedule. + * @request GET:/system/purgeaudit/schedule + * @secure + */ + getPurgeSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for update purge job schedule. + * + * @tags purge + * @name CreatePurgeSchedule + * @summary Create a purge job schedule. + * @request POST:/system/purgeaudit/schedule + * @secure + */ + createPurgeSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for update purge job schedule. + * + * @tags purge + * @name UpdatePurgeSchedule + * @summary Update purge job's schedule. + * @request PUT:/system/purgeaudit/schedule + * @secure + */ + updatePurgeSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the system level allowlist of CVE. This API can be called by all authenticated users. + * + * @tags SystemCVEAllowlist + * @name GetSystemCveAllowlist + * @summary Get the system level allowlist of CVE. + * @request GET:/system/CVEAllowlist + * @secure + */ + getSystemCveAllowlist: (params: RequestParams = {}) => + this.request({ + path: `/system/CVEAllowlist`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This API overwrites the system level allowlist of CVE with the list in request body. Only system Admin has permission to call this API. + * + * @tags SystemCVEAllowlist + * @name PutSystemCveAllowlist + * @summary Update the system level allowlist of CVE. + * @request PUT:/system/CVEAllowlist + * @secure + */ + putSystemCveAllowlist: ( + allowlist: CVEAllowlist, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/CVEAllowlist`, + method: "PUT", + body: allowlist, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for getting a schedule for the scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name GetScanAllSchedule + * @summary Get scan all's schedule. + * @request GET:/system/scanAll/schedule + * @secure + */ + getScanAllSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for updating the schedule of scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name UpdateScanAllSchedule + * @summary Update scan all's schedule. + * @request PUT:/system/scanAll/schedule + * @secure + */ + updateScanAllSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for creating a schedule or a manual trigger for the scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name CreateScanAllSchedule + * @summary Create a schedule or a manual trigger for the scan all job. + * @request POST:/system/scanAll/schedule + * @secure + */ + createScanAllSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Stop scanAll job execution + * + * @tags scanAll + * @name StopScanAll + * @summary Stop scanAll job execution + * @request POST:/system/scanAll/stop + * @secure + */ + stopScanAll: (params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/stop`, + method: "POST", + secure: true, + ...params, + }), + }; + jobservice = { + /** + * @description Get worker pools + * + * @tags jobservice + * @name GetWorkerPools + * @summary Get worker pools + * @request GET:/jobservice/pools + * @secure + */ + getWorkerPools: (params: RequestParams = {}) => + this.request({ + path: `/jobservice/pools`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get workers in current pool + * + * @tags jobservice + * @name GetWorkers + * @summary Get workers + * @request GET:/jobservice/pools/{pool_id}/workers + * @secure + */ + getWorkers: (poolId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/pools/${poolId}/workers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop running job + * + * @tags jobservice + * @name StopRunningJob + * @summary Stop running job + * @request PUT:/jobservice/jobs/{job_id} + * @secure + */ + stopRunningJob: (jobId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/jobs/${jobId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description Get job log by job id, it is only used by administrator + * + * @tags jobservice + * @name ActionGetJobLog + * @summary Get job log by job id + * @request GET:/jobservice/jobs/{job_id}/log + * @secure + */ + actionGetJobLog: (jobId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/jobs/${jobId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description list job queue + * + * @tags jobservice + * @name ListJobQueues + * @summary list job queues + * @request GET:/jobservice/queues + * @secure + */ + listJobQueues: (params: RequestParams = {}) => + this.request({ + path: `/jobservice/queues`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description stop and clean, pause, resume pending jobs in the queue + * + * @tags jobservice + * @name ActionPendingJobs + * @summary stop and clean, pause, resume pending jobs in the queue + * @request PUT:/jobservice/queues/{job_type} + * @secure + */ + actionPendingJobs: ( + jobType: string, + action_request: ActionRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/jobservice/queues/${jobType}`, + method: "PUT", + body: action_request, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + schedules = { + /** + * @description List schedules + * + * @tags schedule + * @name ListSchedules + * @request GET:/schedules + * @secure + */ + listSchedules: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/schedules`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get scheduler paused status + * + * @tags schedule + * @name GetSchedulePaused + * @request GET:/schedules/{job_type}/paused + * @secure + */ + getSchedulePaused: (jobType: string, params: RequestParams = {}) => + this.request({ + path: `/schedules/${jobType}/paused`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + ping = { + /** + * @description This API simply replies a pong to indicate the process to handle API is up, disregarding the health status of dependent components. This path does not require any authentication. + * + * @tags ping + * @name GetPing + * @summary Ping Harbor to check if the API server is alive. + * @request GET:/ping + * @secure + */ + getPing: (params: RequestParams = {}) => + this.request({ + path: `/ping`, + method: "GET", + secure: true, + ...params, + }), + }; + retentions = { + /** + * @description Get Retention Metadatas. + * + * @tags Retention + * @name GetRentenitionMetadata + * @summary Get Retention Metadatas + * @request GET:/retentions/metadatas + * @secure + */ + getRentenitionMetadata: (params: RequestParams = {}) => + this.request({ + path: `/retentions/metadatas`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when no retention policy binded to project yet. + * + * @tags Retention + * @name CreateRetention + * @summary Create Retention Policy + * @request POST:/retentions + * @secure + */ + createRetention: (policy: RetentionPolicy, params: RequestParams = {}) => + this.request({ + path: `/retentions`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention Policy. + * + * @tags Retention + * @name GetRetention + * @summary Get Retention Policy + * @request GET:/retentions/{id} + * @secure + */ + getRetention: (id: number, params: RequestParams = {}) => + this.request({ + path: `/retentions/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when retention policy has already binded to project. + * + * @tags Retention + * @name UpdateRetention + * @summary Update Retention Policy + * @request PUT:/retentions/{id} + * @secure + */ + updateRetention: ( + id: number, + policy: RetentionPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when retention policy has already binded to project. + * + * @tags Retention + * @name DeleteRetention + * @summary Delete Retention Policy + * @request DELETE:/retentions/{id} + * @secure + */ + deleteRetention: (id: number, params: RequestParams = {}) => + this.request({ + path: `/retentions/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Trigger a Retention Execution, if dry_run is True, nothing would be deleted actually. + * + * @tags Retention + * @name TriggerRetentionExecution + * @summary Trigger a Retention Execution + * @request POST:/retentions/{id}/executions + * @secure + */ + triggerRetentionExecution: ( + id: number, + body: { + dry_run?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions`, + method: "POST", + body: body, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention executions, execution status may be delayed before job service schedule it up. + * + * @tags Retention + * @name ListRetentionExecutions + * @summary Get Retention executions + * @request GET:/retentions/{id}/executions + * @secure + */ + listRetentionExecutions: ( + id: number, + query?: { + /** + * The page number. + * @format int64 + */ + page?: number; + /** + * The size of per page. + * @format int64 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop a Retention execution, only support "stop" action now. + * + * @tags Retention + * @name OperateRetentionExecution + * @summary Stop a Retention execution + * @request PATCH:/retentions/{id}/executions/{eid} + * @secure + */ + operateRetentionExecution: ( + id: number, + eid: number, + body: { + action?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}`, + method: "PATCH", + body: body, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention tasks, each repository as a task. + * + * @tags Retention + * @name ListRetentionTasks + * @summary Get Retention tasks + * @request GET:/retentions/{id}/executions/{eid}/tasks + * @secure + */ + listRetentionTasks: ( + id: number, + eid: number, + query?: { + /** + * The page number. + * @format int64 + */ + page?: number; + /** + * The size of per page. + * @format int64 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get Retention job task log, tags ratain or deletion detail will be shown in a table. + * + * @tags Retention + * @name GetRetentionTaskLog + * @summary Get Retention job task log + * @request GET:/retentions/{id}/executions/{eid}/tasks/{tid} + * @secure + */ + getRetentionTaskLog: ( + id: number, + eid: number, + tid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}/tasks/${tid}`, + method: "GET", + secure: true, + ...params, + }), + }; + scanners = { + /** + * @description Returns a list of currently configured scanner registrations. + * + * @tags scanner + * @name ListScanners + * @summary List scanner registrations + * @request GET:/scanners + * @secure + */ + listScanners: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Creats a new scanner registration with the given data. + * + * @tags scanner + * @name CreateScanner + * @summary Create a scanner registration + * @request POST:/scanners + * @secure + */ + createScanner: ( + registration: ScannerRegistrationReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners`, + method: "POST", + body: registration, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Pings scanner adapter to test endpoint URL and authorization settings. + * + * @tags scanner + * @name PingScanner + * @summary Tests scanner registration settings + * @request POST:/scanners/ping + * @secure + */ + pingScanner: ( + settings: ScannerRegistrationSettings, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/ping`, + method: "POST", + body: settings, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Retruns the details of the specified scanner registration. + * + * @tags scanner + * @name GetScanner + * @summary Get a scanner registration details + * @request GET:/scanners/{registration_id} + * @secure + */ + getScanner: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Updates the specified scanner registration. + * + * @tags scanner + * @name UpdateScanner + * @summary Update a scanner registration + * @request PUT:/scanners/{registration_id} + * @secure + */ + updateScanner: ( + registrationId: string, + registration: ScannerRegistrationReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/${registrationId}`, + method: "PUT", + body: registration, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Deletes the specified scanner registration. + * + * @tags scanner + * @name DeleteScanner + * @summary Delete a scanner registration + * @request DELETE:/scanners/{registration_id} + * @secure + */ + deleteScanner: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}`, + method: "DELETE", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Set the specified scanner registration as the system default one. + * + * @tags scanner + * @name SetScannerAsDefault + * @summary Set system default scanner registration + * @request PATCH:/scanners/{registration_id} + * @secure + */ + setScannerAsDefault: ( + registrationId: string, + payload: IsDefault, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/${registrationId}`, + method: "PATCH", + body: payload, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the metadata of the specified scanner registration, including the capabilities and customized properties. + * + * @tags scanner + * @name GetScannerMetadata + * @summary Get the metadata of the specified scanner registration + * @request GET:/scanners/{registration_id}/metadata + * @secure + */ + getScannerMetadata: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}/metadata`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + users = { + /** + * No description + * + * @tags user + * @name ListUsers + * @summary List users + * @request GET:/users + * @secure + */ + listUsers: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This API can be used only when the authentication mode is for local DB. When self registration is disabled. + * + * @tags user + * @name CreateUser + * @summary Create a local user. + * @request POST:/users + * @secure + */ + createUser: (userReq: UserCreationReq, params: RequestParams = {}) => + this.request({ + path: `/users`, + method: "POST", + body: userReq, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetCurrentUserInfo + * @summary Get current user info. + * @request GET:/users/current + * @secure + */ + getCurrentUserInfo: (params: RequestParams = {}) => + this.request({ + path: `/users/current`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is to search the users by username. It's open for all authenticated requests. + * + * @tags user + * @name SearchUsers + * @summary Search users by username + * @request GET:/users/search + * @secure + */ + searchUsers: ( + query: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Username for filtering results. */ + username: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetUser + * @summary Get a user's profile. + * @request GET:/users/{user_id} + * @secure + */ + getUser: (userId: number, params: RequestParams = {}) => + this.request({ + path: `/users/${userId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags user + * @name UpdateUserProfile + * @summary Update user's profile. + * @request PUT:/users/{user_id} + * @secure + */ + updateUserProfile: ( + userId: number, + profile: UserProfile, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}`, + method: "PUT", + body: profile, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let administrator of Harbor mark a registered user as removed.It actually won't be deleted from DB. + * + * @tags user + * @name DeleteUser + * @summary Mark a registered user as be removed. + * @request DELETE:/users/{user_id} + * @secure + */ + deleteUser: (userId: number, params: RequestParams = {}) => + this.request({ + path: `/users/${userId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * No description + * + * @tags user + * @name SetUserSysAdmin + * @summary Update a registered user to change to be an administrator of Harbor. + * @request PUT:/users/{user_id}/sysadmin + * @secure + */ + setUserSysAdmin: ( + userId: number, + sysadmin_flag: UserSysAdminFlag, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/sysadmin`, + method: "PUT", + body: sysadmin_flag, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for user to update password. Users with the admin role can change any user's password. Regular users can change only their own password. + * + * @tags user + * @name UpdateUserPassword + * @summary Change the password on a user that already exists. + * @request PUT:/users/{user_id}/password + * @secure + */ + updateUserPassword: ( + userId: number, + password: PasswordReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/password`, + method: "PUT", + body: password, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetCurrentUserPermissions + * @summary Get current user permissions. + * @request GET:/users/current/permissions + * @secure + */ + getCurrentUserPermissions: ( + query?: { + /** The scope for the permission */ + scope?: string; + /** + * If true, the resources in the response are relative to the scope, + * eg for resource '/project/1/repository' if relative is 'true' then the resource in response will be 'repository'. + */ + relative?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/current/permissions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user generate a new CLI secret for himself. This API only works when auth mode is set to 'OIDC'. Once this API returns with successful status, the old secret will be invalid, as there will be only one CLI secret for a user. + * + * @tags user + * @name SetCliSecret + * @summary Set CLI secret for a user. + * @request PUT:/users/{user_id}/cli_secret + * @secure + */ + setCliSecret: ( + userId: number, + secret: OIDCCliSecretReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/cli_secret`, + method: "PUT", + body: secret, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + labels = { + /** + * @description This endpoint let user list labels by name, scope and project_id + * + * @tags label + * @name ListLabels + * @summary List labels according to the query strings. + * @request GET:/labels + * @secure + */ + listLabels: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The label name. */ + name?: string; + /** The label scope. Valid values are g and p. g for global labels and p for project labels. */ + scope?: string; + /** + * Relevant project ID, required when scope is p. + * @format int64 + */ + project_id?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/labels`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user creates a label. + * + * @tags label + * @name CreateLabel + * @summary Post creates a label + * @request POST:/labels + * @secure + */ + createLabel: (label: Label, params: RequestParams = {}) => + this.request({ + path: `/labels`, + method: "POST", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let user get the label by specific ID. + * + * @tags label + * @name GetLabelById + * @summary Get the label specified by ID. + * @request GET:/labels/{label_id} + * @secure + */ + getLabelById: (labelId: number, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user update label properties. + * + * @tags label + * @name UpdateLabel + * @summary Update the label properties. + * @request PUT:/labels/{label_id} + * @secure + */ + updateLabel: (labelId: number, label: Label, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "PUT", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the label specified by ID. + * + * @tags label + * @name DeleteLabel + * @summary Delete the label specified by ID. + * @request DELETE:/labels/{label_id} + * @secure + */ + deleteLabel: (labelId: number, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + export = { + /** + * @description Export scan data for selected projects + * + * @tags scan data export + * @name ExportScanData + * @summary Export scan data for selected projects + * @request POST:/export/cve + * @secure + */ + exportScanData: ( + criteria: ScanDataExportRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve`, + method: "POST", + body: criteria, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description Get the scan data export execution specified by ID + * + * @tags scan data export + * @name GetScanDataExportExecution + * @summary Get the specific scan data export execution + * @request GET:/export/cve/execution/{execution_id} + * @secure + */ + getScanDataExportExecution: ( + executionId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve/execution/${executionId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a list of specific scan data export execution jobs for a specified user + * + * @tags scan data export + * @name GetScanDataExportExecutionList + * @summary Get a list of specific scan data export execution jobs for a specified user + * @request GET:/export/cve/executions + * @secure + */ + getScanDataExportExecutionList: (params: RequestParams = {}) => + this.request({ + path: `/export/cve/executions`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Download the scan data report. Default format is CSV + * + * @tags scan data export + * @name DownloadScanData + * @summary Download the scan data export file + * @request GET:/export/cve/download/{execution_id} + * @secure + */ + downloadScanData: ( + executionId: number, + query?: { + /** The format of the data to be exported. e.g. CSV or PDF */ + format?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve/download/${executionId}`, + method: "GET", + query: query, + secure: true, + ...params, + }), + }; + security = { + /** + * @description Retrieve the vulnerability summary of the system + * + * @tags securityhub + * @name GetSecuritySummary + * @summary Get vulnerability system summary + * @request GET:/security/summary + * @secure + */ + getSecuritySummary: ( + query?: { + /** + * Specify whether the dangerous CVEs are included inside summary information + * @default false + */ + with_dangerous_cve?: boolean; + /** + * Specify whether the dangerous Artifact are included inside summary information + * @default false + */ + with_dangerous_artifact?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/security/summary`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the vulnerability list. use q to pass the query condition, supported conditions: cve_id(exact match) cvss_score_v3(range condition) severity(exact match) repository_name(exact match) project_id(exact match) package(exact match) tag(exact match) digest(exact match) + * + * @tags securityhub + * @name ListVulnerabilities + * @summary Get the vulnerability list. + * @request GET:/security/vul + * @secure + */ + listVulnerabilities: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Enable to ignore X-Total-Count when the total count > 1000, if the total count is less than 1000, the real total count is returned, else -1. + * @default false + */ + tune_count?: boolean; + /** + * Specify whether the tag information is included inside vulnerability information + * @default false + */ + with_tag?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/security/vul`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + permissions = { + /** + * @description This endpoint is for retrieving resource and action info that only provides for admin user(system admin and project admin). + * + * @tags permissions + * @name GetPermissions + * @summary Get system or project level permissions info. + * @request GET:/permissions + * @secure + */ + getPermissions: (params: RequestParams = {}) => + this.request({ + path: `/permissions`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; +} diff --git a/.jjconflict-side-0/plugins/harbor/src/env.d.ts b/.jjconflict-side-0/plugins/harbor/src/env.d.ts new file mode 100644 index 0000000000..44377abce9 --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-side-0/plugins/harbor/src/functions.ts b/.jjconflict-side-0/plugins/harbor/src/functions.ts new file mode 100644 index 0000000000..ea902dae27 --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/src/functions.ts @@ -0,0 +1,159 @@ +import type { PluginResult, Project, ProjectLite, StepCall } from '@cpn-console/hooks' +import type { VaultRobotSecret } from './robot.js' +import { + + specificallyDisabled, + specificallyEnabled, +} from '@cpn-console/hooks' +import { DEFAULT, ENABLED } from '@cpn-console/shared' +// @ts-ignore pas de typage disponible pour le paquet bytes +import bytes from 'bytes' +import { logger } from './logger.js' +import { addProjectGroupMember } from './permission.js' +import { addRetentionPolicy } from './policy.js' +import { createProject, deleteProject } from './project.js' +import { deleteRobot, ensureRobot, roAccess, rwAccess } from './robot.js' +import { + getApi, + getConfig, + projectRobotName, + roRobotName, + rwRobotName, +} from './utils.js' + +export const createDsoProject: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + }, + } + const warnReasons: string[] = [] + try { + const project = payload.args + const projectName = project.slug + const { vault: vaultApi, keycloak: keycloakApi } = payload.apis + + const publishRoRobotProject = project.store.registry?.publishProjectRobot + const publishRoRobotConfig = payload.config.registry?.publishProjectRobot + const createProjectRobot + = specificallyEnabled(publishRoRobotProject) + || (specificallyEnabled(publishRoRobotConfig) + && !specificallyDisabled(publishRoRobotProject)) + + const quotaHardLimit + = project.store.registry?.quotaHardLimit + || payload.config.registry?.quotaHardLimit + const quotaHardLimitBytes = quotaHardLimit + ? bytes.parse(quotaHardLimit) + : undefined + + const [projectCreated, oidcGroup] = await Promise.all([ + createProject( + projectName, + quotaHardLimitBytes === 1 ? undefined : quotaHardLimitBytes, + ), + keycloakApi.getProjectGroupPath(), + ]) + const api = getApi() + + if (!projectCreated.project_id) + throw new Error('Unable to retrieve project_id') + const projectId = projectCreated.project_id + + await Promise.all([ + ensureRobot(projectName, projectId, roRobotName, vaultApi, roAccess, api), // cette ligne en premier sinon ça foire au dessus + ensureRobot(projectName, projectId, rwRobotName, vaultApi, rwAccess, api), + addProjectGroupMember(projectName, oidcGroup), + addRetentionPolicy(projectName, projectId), + createProjectRobot + ? ensureRobot(projectName, projectId, projectRobotName, vaultApi, roAccess, api) + : deleteRobot(projectName, projectId, projectRobotName, vaultApi, api), + ]) + returnResult.status.message = `Created${createProjectRobot ? ' , with project robot' : ''}` + returnResult.store = { + projectId: projectCreated.project_id, + } + if (warnReasons.length) { + returnResult.status.result = 'WARNING' + returnResult.status.message = warnReasons.join(', ') + } + logger.info({ action: 'createDsoProject', projectSlug: projectName, result: returnResult.status.result, createProjectRobot }, 'Hook done') + return returnResult + } catch (error) { + logger.error({ action: 'createDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const deleteDsoProject: StepCall = async (payload) => { + try { + const project = payload.args + const projectName = project.slug + + await deleteProject(projectName) + + logger.info({ action: 'deleteDsoProject', projectSlug: projectName, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } catch (error) { + logger.error({ action: 'deleteDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const getProjectSecrets: StepCall = async ({ + args: project, + apis: { vault: vaultApi }, + config, +}) => { + logger.debug({ action: 'getProjectSecrets', projectSlug: project.slug }, 'Hook done') + const publishRoRobotProject = project.store.registry?.publishProjectRobot + const publishRoRobotConfig = config.registry?.publishProjectRobot + const projectRobotEnabled + = publishRoRobotProject === ENABLED + || (publishRoRobotConfig === ENABLED + && (!publishRoRobotProject || publishRoRobotProject === DEFAULT)) + + const VaultRobotSecret = projectRobotEnabled + ? ((await vaultApi.read(`REGISTRY/${projectRobotName}`, { + throwIfNoEntry: false, + })) as { data: VaultRobotSecret } | undefined) + : undefined + let secrets: { [x: string]: string } = { + 'Registry base path': `${getConfig().host}/${project.slug}/`, + } + + if (projectRobotEnabled) { + secrets = VaultRobotSecret?.data + ? { + ...secrets, + ...VaultRobotSecret.data, + } + : { + ...secrets, + '/!\\': 'Vous n\'avez pas de robot de lecture veuillez reprovisionner', + } + } + return { + status: { + result: 'OK', + }, + secrets, + } +} diff --git a/.jjconflict-side-0/plugins/harbor/src/index.ts b/.jjconflict-side-0/plugins/harbor/src/index.ts new file mode 100644 index 0000000000..c005ffd840 --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/src/index.ts @@ -0,0 +1,19 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import { createDsoProject, deleteDsoProject, getProjectSecrets } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { steps: { post: createDsoProject } }, + deleteProject: { steps: { main: deleteDsoProject } }, + getProjectSecrets: { steps: { main: getProjectSecrets } }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-side-0/plugins/harbor/src/infos.ts b/.jjconflict-side-0/plugins/harbor/src/infos.ts new file mode 100644 index 0000000000..97c3de42eb --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/src/infos.ts @@ -0,0 +1,71 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED } from '@cpn-console/shared' +import { getConfig } from './utils.js' + +const quotaDescription = '-1 -> illimité, sinon 100MB / 1.2GB (unités : B, KB, MB, GB, TB)), max 1024TB' +const infos = { + name: 'registry', + to: ({ store }) => store?.registry?.projectId ? `${getConfig().url}/harbor/projects/${store.registry.projectId}` : `${getConfig().url}/`, + title: 'Harbor', + imgSrc: '/img/harbor.svg', + description: 'Harbor stocke, analyse et distribue vos images de conteneurs', + config: { + project: [{ + permissions: { + admin: { read: false, write: false }, + user: { read: false, write: false }, + }, + key: 'projectId', + kind: 'text', + title: 'Num du projet Harbor', + value: '', + }, { + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot projet', + description: 'Activer le robot de projet (read-only) et afficher ses identifiants aux utilisateurs', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota', + value: '', + description: `Stockage limite (vide utilisation du paramètre global, ${quotaDescription}`, + placeholder: '', + }], + global: [{ + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot RO aux projets', + description: 'Définit le comportement en l\'absence de ce paramétrage au niveau projet', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota par défaut', + value: '-1', + description: `Stockage limite par projet (${quotaDescription}`, + placeholder: '-1', + }], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-side-0/plugins/harbor/src/kubeSecret.ts b/.jjconflict-side-0/plugins/harbor/src/kubeSecret.ts new file mode 100644 index 0000000000..acf1ec297b --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/src/kubeSecret.ts @@ -0,0 +1,14 @@ +// Utils +export function getSecretObject({ DOCKER_CONFIG }: { DOCKER_CONFIG: string }) { + const b64dockerConfig = Buffer.from(DOCKER_CONFIG).toString('base64') + return { + kind: 'Secret', + data: { + '.dockerconfigjson': b64dockerConfig, + }, + type: 'kubernetes.io/dockerconfigjson', + metadata: { + name: 'registry-pull-secret', + }, + } +} diff --git a/.jjconflict-side-0/plugins/harbor/src/logger.ts b/.jjconflict-side-0/plugins/harbor/src/logger.ts new file mode 100644 index 0000000000..82f73cecb6 --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'harbor' }) diff --git a/.jjconflict-side-0/plugins/harbor/src/monitor.ts b/.jjconflict-side-0/plugins/harbor/src/monitor.ts new file mode 100644 index 0000000000..eaa73c6ea1 --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/src/monitor.ts @@ -0,0 +1,52 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import { getApi } from './utils.js' + +enum HealthStatus { + healthy = 'healthy', + unhealthy = 'unhealthy', +} +const coreComponents = ['core', 'database', 'portal', 'registry', 'registryctl'] + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await getApi().health.getHealth({ + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + const data = res.data + if (data.status === HealthStatus.healthy) { + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + const failedCoreComponents = data.components + ? data.components + .filter(component => + component.status === HealthStatus.unhealthy + && component.name + && coreComponents.includes(component.name), + ) + : [] + + if (failedCoreComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.WARNING + instance.lastStatus.message = 'Service dégradé' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Fatal Error' + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-0/plugins/harbor/src/permission.ts b/.jjconflict-side-0/plugins/harbor/src/permission.ts new file mode 100644 index 0000000000..05e30d4d6c --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/src/permission.ts @@ -0,0 +1,26 @@ +import { getApi } from './utils.js' + +export async function addProjectGroupMember(projectName: string, groupName: string, accessLevel: number = 3): Promise { + const api = getApi() + const members = await api.projects.listProjectMembers(projectName) + const member = members.data.find(m => m.entity_name === groupName) + if (member?.id) { // member.id is always set pb comes from bad swagger + if (member.role_id !== accessLevel && member.entity_type !== 'g') { + // le membre semble être incorrectement paramétré, suppression pour recréation + await api.projects.deleteProjectMember(projectName, member.id) + } else { + // tout va bien + return + } + } + // Création du membre + await api.projects.createProjectMember(projectName, { + + role_id: accessLevel, + member_group: { + + group_name: groupName, + group_type: 3, + }, + }) +} diff --git a/.jjconflict-side-0/plugins/harbor/src/policy.ts b/.jjconflict-side-0/plugins/harbor/src/policy.ts new file mode 100644 index 0000000000..4945ad51cc --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/src/policy.ts @@ -0,0 +1,135 @@ +import { isValidCron } from 'cron-validator' +import { logger } from './logger.js' +import { getApi } from './utils.js' + +// https://github.com/goharbor/harbor/blob/main/src/server/v2.0/handler/retention.go +export type RuleTemplate + = | 'always' + | 'latestPulledK' + | 'latestPushedK' + | 'nDaysSinceLastPull' + | 'nDaysSinceLastPush' + +const allowed: RuleTemplate[] = [ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +] + +export const harborRuleTemplate: RuleTemplate = allowed.includes(process.env.HARBOR_RULE_TEMPLATE as RuleTemplate) + ? (process.env.HARBOR_RULE_TEMPLATE as RuleTemplate) + : 'latestPushedK' + +const countEnv = Number(process.env.HARBOR_RULE_COUNT) +export const harborRuleCount = !Number.isNaN(countEnv) && countEnv > 0 + ? countEnv + : harborRuleTemplate === 'always' + ? 1 + : 10 + +const defaultCron = '0 22 2 * * *' +const envCron = process.env.HARBOR_RETENTION_CRON?.trim() + +export const harborRetentionCron = envCron && isValidCron(envCron, { seconds: true }) + ? envCron + : defaultCron + +export interface TagSelector { + kind: 'doublestar' | 'label' + decoration: 'matches' | 'excludes' | 'repoMatches' | 'repoExcludes' + pattern: string +} + +export interface Rule { + disabled: boolean + action: 'retain' | 'delete' + template: RuleTemplate + params: Record + tag_selectors: TagSelector[] + scope_selectors: Record +} + +export interface Trigger { + kind: 'Schedule' + settings: { cron: string } + references: unknown[] +} + +export interface Policy { + algorithm: 'or' | 'and' + rules: Rule[] + trigger: Trigger + scope: { level: 'project', ref: number } +} + +export function makeDefaultPolicy(projectId: number): Policy { + return { + algorithm: 'or', + scope: { level: 'project', ref: projectId }, + rules: [ + { + disabled: false, + action: 'retain', + template: harborRuleTemplate, + params: { [harborRuleTemplate]: harborRuleCount }, + tag_selectors: [ + { kind: 'doublestar', decoration: 'matches', pattern: '**' }, + ], + scope_selectors: { + repository: [ + { kind: 'doublestar', decoration: 'repoMatches', pattern: '**' }, + ], + }, + }, + ], + trigger: { + kind: 'Schedule', + settings: { cron: harborRetentionCron }, + references: [], + }, + } +} + +export async function addRetentionPolicy( + projectName: string, + harborProjectId: number, +): Promise { + const api = getApi() + const ref = Number(harborProjectId) + if (Number.isNaN(ref)) throw new Error(`Invalid projectId: ${harborProjectId}`) + + const policy: Policy = makeDefaultPolicy(ref) + const project = await api.projects.getProject(projectName) + const retentionId = Number(project?.data?.metadata?.retention_id) + + try { + if (retentionId && !Number.isNaN(retentionId)) { + await api.retentions.updateRetention(retentionId, policy as unknown as any) + } else { + await api.retentions.createRetention(policy as unknown as any) + } + } catch (err: any) { + const payload = JSON.stringify(policy, null, 2) + const details = err?.response?.data ?? 'Unknown error' + logger.error( + { + action: 'addRetentionPolicy', + projectSlug: projectName, + projectName, + projectId: project?.data?.project_id, + retentionId, + payload, + details, + err, + }, + 'Failed to apply Harbor retention policy', + ) + throw new Error( + `Retention policy failed for project "${projectName}": ${ + typeof details === 'string' ? details : JSON.stringify(details) + }`, + ) + } +} diff --git a/.jjconflict-side-0/plugins/harbor/src/project.ts b/.jjconflict-side-0/plugins/harbor/src/project.ts new file mode 100644 index 0000000000..6b8a7a0e6e --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/src/project.ts @@ -0,0 +1,70 @@ +import type { Project as HarborProject, Quota } from './api/Api.js' +import { logger } from './logger.js' +import { getApi } from './utils.js' + +export async function createProject(projectName: string, storageLimit: number = -1): Promise { + logger.debug({ action: 'createProject', projectSlug: projectName, storageLimit }, 'Creating Harbor project if missing') + const api = getApi() + const existingProject = await api.projects.getProject(projectName, { + headers: { + 'X-Is-Resource-Name': true, + }, + validateStatus: () => true, + }) + + if (existingProject.status === 200) { + const projectId = existingProject.data.project_id as number + const refQuotas = await api.quotas.listQuotas({ + reference_id: String(projectId), + }) + const hardQuota = refQuotas.data.find(quota => quota.ref?.id === projectId) as Quota + + if (hardQuota.hard.storage !== storageLimit) { + await api.quotas.updateQuota(projectId, { + hard: { + storage: storageLimit, + }, + }) + } + return existingProject.data + } + + await api.projects.createProject({ + project_name: projectName, + metadata: { + auto_scan: 'true', + }, + storage_limit: storageLimit, + }, { + validateStatus: () => true, + }) + + const newProject = await api.projects.getProject(projectName) + return newProject.data +} + +async function removeRepositories(projectName: string) { + const api = getApi() + const repositories = await api.projects.listRepositories(projectName, { page_size: 100 }) + + for (const repo of repositories.data) { + // TODO mauvais swagger name existe forcément + const repoName = repo.name?.split('/').slice(1).join('/') as string + await api.projects.deleteRepository(projectName, repoName) + } + if (repositories.headers['x-total-count'] > repositories.data.length) { + await removeRepositories(projectName) + } +} + +export async function deleteProject(projectName: string) { + const api = getApi() + const project = await api.projects.getProject(projectName, { + validateStatus: status => [200, 404].includes(status), + }) + + if (project.status === 200) { + await removeRepositories(projectName) + await api.projects.deleteProject(projectName) + } +} diff --git a/.jjconflict-side-0/plugins/harbor/src/robot.ts b/.jjconflict-side-0/plugins/harbor/src/robot.ts new file mode 100644 index 0000000000..90c2ee23eb --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/src/robot.ts @@ -0,0 +1,133 @@ +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { Access, Robot, RobotCreated } from './api/Api.js' +import type { HarborApi } from './utils.js' +import { logger } from './logger.js' +import { getConfig, toVaultSecret } from './utils.js' + +// A project only ever owns a handful of robots (ro, rw and optionally the +// project robot — 3 in practice), so fetching a single page is enough; we never +// need to paginate the robot listing. +const ROBOT_LIST_PAGE_SIZE = 100 + +export interface VaultRobotSecret { + // {"auths":{"registry-host.com":{"auth":"","email":""}}}, + DOCKER_CONFIG: string + // registry-host.com, + HOST: string + TOKEN: string + // robot$+ + USERNAME: string +} + +export const getRobot = async (projectName: string, projectId: number, robotName: string, api: HarborApi): Promise => getRobotByName(projectId, `robot$${projectName}+${robotName}`, api) + +export async function ensureRobot(projectName: string, projectId: number, robotName: string, vaultApi: VaultProjectApi, access: Access[], api: HarborApi): Promise { + logger.debug({ action: 'ensureRobot', projectSlug: projectName, robotName }, 'Ensuring Harbor robot') + const vaultPath = `REGISTRY/${robotName}` + const robot = await getRobot(projectName, projectId, robotName, api) + const vaultRobotSecret = await vaultApi.read(vaultPath, { throwIfNoEntry: false }) as { data: VaultRobotSecret } | undefined + + let creds: VaultRobotSecret + // only regenerate robot if there is a change with the host + if (vaultRobotSecret?.data && vaultRobotSecret.data.HOST === getConfig().host) { + creds = vaultRobotSecret.data + } else if (robot) { + creds = toVaultSecret(await regenerateRobot(projectName, projectId, robotName, access, api) as Required) + await vaultApi.write(creds, vaultPath) + } else { + creds = toVaultSecret(await createRobot(projectName, robotName, access, api) as Required) + await vaultApi.write(creds, vaultPath) + } + return creds +} + +export async function deleteRobot(projectName: string, projectId: number, robotName: string, vaultApi: VaultProjectApi, api: HarborApi): Promise { + logger.info({ action: 'deleteRobot', projectSlug: projectName, robotName }, 'Deleting Harbor robot') + const vaultPath = `REGISTRY/${robotName}` + const robot = await getRobot(projectName, projectId, robotName, api) + if (robot?.id) { + api.robots.deleteRobot(robot.id) + } + const vaultRobotSecret = await vaultApi.read(vaultPath, { throwIfNoEntry: false }) as { data: VaultRobotSecret } | undefined + + if (vaultRobotSecret) { + await vaultApi.destroy(vaultPath) + } +} + +export async function createRobot(projectName: string, robotName: string, access: Access[], api: HarborApi) { + logger.info({ action: 'createRobot', projectSlug: projectName, robotName }, 'Creating Harbor robot') + return (await api.robots.createRobot(getRobotPermissions(projectName, robotName, access))).data +} + +export async function regenerateRobot(projectName: string, projectId: number, robotName: string, access: Access[], api: HarborApi): Promise { + logger.info({ action: 'regenerateRobot', projectSlug: projectName, robotName }, 'Regenerating Harbor robot') + const robot = await getRobot(projectName, projectId, robotName, api) + if (robot?.id) + await api.robots.deleteRobot(robot.id) + return createRobot(projectName, robotName, access, api) +} + +export async function getRobotByName(projectId: number, robotName: string, api: HarborApi): Promise { + logger.debug({ action: 'getRobotByName', projectId, robotName }, 'Listing Harbor robots to find matching name') + const listRobots = await api.robots.listRobot({ + q: `Level=project,ProjectID=${projectId}`, + page_size: ROBOT_LIST_PAGE_SIZE, + }) + return listRobots.data.find(({ name }) => name === robotName) +} + +function getRobotPermissions(projectName: string, robotName: string, access: Access[]) { + return { + name: robotName, + duration: -1, + description: 'robot for ci builds', + disable: false, + level: 'project', + permissions: [{ + namespace: projectName, + kind: 'project', + access, + }], + } +} + +export const roAccess: Access[] = [{ + resource: 'repository', + action: 'pull', +}, { + resource: 'artifact', + action: 'read', +}] + +export const rwAccess: Access[] = [...roAccess, { + resource: 'repository', + action: 'list', +}, { + resource: 'tag', + action: 'list', +}, { + resource: 'artifact', + action: 'list', +}, { + resource: 'scan', + action: 'create', +}, { + resource: 'scan', + action: 'stop', +}, { + resource: 'repository', + action: 'push', +}, { + resource: 'artifact-label', + action: 'create', +}, { + resource: 'artifact-label', + action: 'delete', +}, { + resource: 'tag', + action: 'create', +}, { + resource: 'tag', + action: 'delete', +}] diff --git a/.jjconflict-side-0/plugins/harbor/src/utils.ts b/.jjconflict-side-0/plugins/harbor/src/utils.ts new file mode 100644 index 0000000000..abb2b471ba --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/src/utils.ts @@ -0,0 +1,61 @@ +import type { RobotCreated } from './api/Api.js' +import type { VaultRobotSecret } from './robot.js' +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import { Api } from './api/Api.js' + +const config: { + url?: string + internalUrl?: string + host?: string +} = {} + +export function getConfig(): Required { + config.url = config.url ?? removeTrailingSlash(requiredEnv('HARBOR_URL')) + config.internalUrl = config.internalUrl ?? removeTrailingSlash(requiredEnv('HARBOR_INTERNAL_URL')) + config.host = config.host ?? config?.url?.split('://')[1] + // @ts-ignore + return config +} + +function getApiConfig() { + return { + auth: { + username: requiredEnv('HARBOR_ADMIN'), + password: requiredEnv('HARBOR_ADMIN_PASSWORD'), + }, + baseURL: `${getConfig().internalUrl}/api/v2.0/`, + } +} + +let api: Api> | undefined + +export type HarborApi = Api> +export function getApi(): HarborApi { + if (!api) { + api = new Api(getApiConfig()) + } + return api +} + +export function toVaultSecret(robot: Required): VaultRobotSecret { + const auth = `${robot.name}:${robot.secret}` + const buff = Buffer.from(auth) + const b64auth = buff.toString('base64') + return { + DOCKER_CONFIG: JSON.stringify({ + auths: { + [getConfig().host]: { + auth: b64auth, + email: '', + }, + }, + }), + HOST: getConfig().host, + TOKEN: robot.secret, + USERNAME: robot.name, + } +} + +export const rwRobotName = 'rw-robot' +export const roRobotName = 'ro-robot' +export const projectRobotName = 'project-robot' diff --git a/.jjconflict-side-0/plugins/harbor/tsconfig.eslint.json b/.jjconflict-side-0/plugins/harbor/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-0/plugins/harbor/tsconfig.json b/.jjconflict-side-0/plugins/harbor/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-0/plugins/harbor/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-0/plugins/keycloak/.gitignore b/.jjconflict-side-0/plugins/keycloak/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-0/plugins/keycloak/eslint.config.js b/.jjconflict-side-0/plugins/keycloak/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-0/plugins/keycloak/package.json b/.jjconflict-side-0/plugins/keycloak/package.json new file mode 100644 index 0000000000..d0cb9e3ece --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/package.json @@ -0,0 +1,49 @@ +{ + "name": "@cpn-console/keycloak-plugin", + "type": "module", + "version": "2.1.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@keycloak/keycloak-admin-client": "catalog:runtime", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/plugins/keycloak/src/class.ts b/.jjconflict-side-0/plugins/keycloak/src/class.ts new file mode 100644 index 0000000000..36f3dc045e --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/src/class.ts @@ -0,0 +1,33 @@ +import { PluginApi } from '@cpn-console/hooks' +import { consoleGroupName } from './group.js' + +interface KeycloakEnv { + path: string + subgroups: { // Key is the group name, value is the full path + RO: string + RW: string + } +} + +export class KeycloakProjectApi extends PluginApi { + private readonly projectSlug: string + + constructor(projectSlug: string) { + super() + this.projectSlug = projectSlug + } + + public async getProjectGroupPath(): Promise { + return `/${this.projectSlug}` + } + + public async getEnvGroup(environment: string): Promise { + return { + path: `/${this.projectSlug}/${consoleGroupName}/${environment}`, + subgroups: { + RO: `/${this.projectSlug}/${consoleGroupName}/${environment}/RO`, + RW: `/${this.projectSlug}/${consoleGroupName}/${environment}/RW`, + }, + } + } +} diff --git a/.jjconflict-side-0/plugins/keycloak/src/client.ts b/.jjconflict-side-0/plugins/keycloak/src/client.ts new file mode 100644 index 0000000000..484dc8789a --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/src/client.ts @@ -0,0 +1,28 @@ +import KcAdminClient from '@keycloak/keycloak-admin-client' +import getConfig from './config.js' +import { logger } from './logger.js' + +export async function getkcClient() { + const kcClient = new KcAdminClient({ + baseUrl: getConfig().url, + }) + + await kcClient.auth({ + clientId: 'admin-cli', + grantType: 'password', + username: process.env.KEYCLOAK_ADMIN, + password: process.env.KEYCLOAK_ADMIN_PASSWORD, + }) + kcClient.setConfig({ realmName: getConfig().realm }) + return kcClient +} + +export function start() { + getkcClient().catch((error) => { + logger.error({ err: error }, 'Failed to start Keycloak plugin') + if (process.env.IGNORE_PLUGINS_START_FAIL?.includes('keycloak')) { + return + } + throw new Error('failed to start keycloak plugin') + }) +} diff --git a/.jjconflict-side-0/plugins/keycloak/src/config.ts b/.jjconflict-side-0/plugins/keycloak/src/config.ts new file mode 100644 index 0000000000..d249e99b5b --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/src/config.ts @@ -0,0 +1,23 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + url: string + realm: string + + constructor() { + this.url = process.env.KEYCLOAK_INTERNAL_URL + ? removeTrailingSlash(process.env.KEYCLOAK_INTERNAL_URL) + : `${requiredEnv('KEYCLOAK_PROTOCOL')}://${requiredEnv('KEYCLOAK_DOMAIN')}` + this.realm = requiredEnv('KEYCLOAK_REALM') + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-side-0/plugins/keycloak/src/env.d.ts b/.jjconflict-side-0/plugins/keycloak/src/env.d.ts new file mode 100644 index 0000000000..81cd034697 --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/.jjconflict-side-0/plugins/keycloak/src/functions.ts b/.jjconflict-side-0/plugins/keycloak/src/functions.ts new file mode 100644 index 0000000000..9bf08d971d --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/src/functions.ts @@ -0,0 +1,478 @@ +import type { AdminRole, Project, ProjectMember, StepCall, UserEmail, ZoneObject } from '@cpn-console/hooks' +import type { ProjectRole } from '@cpn-console/shared' +import type ClientRepresentation from '@keycloak/keycloak-admin-client/lib/defs/clientRepresentation.js' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js' +import type { CustomGroup } from './group.js' +import { generateRandomPassword, PluginResultBuilder } from '@cpn-console/hooks' +import { isExternalRoleType } from '@cpn-console/shared' +import { getkcClient } from './client.js' +import { consoleGroupName, deleteGroup, getAllSubgroups, getGroupByName, getOrCreateChildGroup, getOrCreateGroupByPath, getOrCreateProjectGroup } from './group.js' +import { logger } from './logger.js' + +export const retrieveKeycloakUserByEmail: StepCall = async ({ args: { email } }) => { + const kcClient = await getkcClient() + try { + const user = (await kcClient.users.find({ email }))[0] + logger.debug({ action: 'retrieveKeycloakUserByEmail', found: Boolean(user) }, 'Hook done') + + return { + status: { result: 'OK' }, + user, + } + } catch (error) { + logger.error({ action: 'retrieveKeycloakUserByEmail', err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const deleteProject: StepCall = async ({ args: project }) => { + const projectSlug = project.slug + try { + const kcClient = await getkcClient() + const group = await getGroupByName(kcClient, projectSlug) + if (group?.id) { + await kcClient.groups.del({ id: group.id }) + logger.info({ action: 'deleteProject', projectSlug, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + logger.info({ action: 'deleteProject', projectSlug, outcome: 'already-missing' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already Missing', + }, + } + } catch (error) { + logger.error({ action: 'deleteProject', projectSlug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const upsertProject: StepCall = async ({ args: project }) => { + const projectSlug = project.slug + const pluginResult = new PluginResultBuilder('Up-to-date') + try { + const kcClient = await getkcClient() + const projectGroup = await getOrCreateProjectGroup(kcClient, projectSlug) + + const groupMembers = await kcClient.groups.listMembers({ id: projectGroup.id }) + + await Promise.all([ + ...groupMembers.map((member, index) => { + if (!project.users.some(({ id }) => id === member.id)) { + return kcClient.users.delFromGroup({ + // @ts-ignore id is present on user, bad typing in lib + id: member.id, + groupId: projectGroup.id, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak project group') + pluginResult.addExtra(`remove-${index}`, err) + }) + } + return undefined + }), + ...project.users.map((user, index) => { + if (!groupMembers.some(({ id }) => id === user.id)) { + return kcClient.users.addToGroup({ + id: user.id, + groupId: projectGroup.id, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t add user to keycloak project group') + pluginResult.addExtra(`add-${index}`, err) + }) + } + return undefined + }), + ]) + + // Ensure envs subgroups exists + const projectGroups = await getAllSubgroups(kcClient, projectGroup.id, 0) + + const consoleGroup: Required = projectGroups.find(({ name }) => name === consoleGroupName) as Required + ?? await getOrCreateChildGroup(kcClient, projectGroup.id, consoleGroupName) as Required + + const envGroups = await getAllSubgroups(kcClient, consoleGroup.id, 0) as CustomGroup[] + + const promises: Promise[] = [] + for (const environment of project.environments) { + const envGroup: Required = envGroups.find(group => group.name === environment.name) as Required + ?? await getOrCreateChildGroup(kcClient, consoleGroup.id, environment.name) + + const [roGroup, rwGroup] = await Promise.all([ + getOrCreateChildGroup(kcClient, envGroup.id, 'RO'), + getOrCreateChildGroup(kcClient, envGroup.id, 'RW'), + ]) + + // Ensure envs permissions membership exists + for (const permission of environment.permissions) { + if (permission.permissions.ro) { + promises.push(kcClient.users.addToGroup({ id: permission.userId, groupId: roGroup.id })) + } else { + promises.push(kcClient.users.delFromGroup({ id: permission.userId, groupId: roGroup.id })) + } + if (permission.permissions.rw) { + promises.push(kcClient.users.addToGroup({ id: permission.userId, groupId: rwGroup.id })) + } else { + promises.push(kcClient.users.delFromGroup({ id: permission.userId, groupId: rwGroup.id })) + } + } + } + + await Promise.all(promises) + + const envGroupIdsToDelete = envGroups + .filter(subGroup => !project.environments.some(({ name }) => name === subGroup.name)) + .map(subGroup => subGroup.id) + .filter((id): id is string => !!id) + if (envGroupIdsToDelete.length) { + await Promise.all(envGroupIdsToDelete.map(id => kcClient.groups.del({ id }))) + } + + logger.info({ action: 'upsertProject', projectSlug }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const upsertZone: StepCall = async ({ args: zone, apis }) => { + const zoneSlug = zone.slug + try { + const kcClient = await getkcClient() + const argocdUrl = zone.argocdUrl + const clientId = getClientZoneId(zone) + const client: ClientRepresentation = { + clientId, + clientAuthenticatorType: 'client-secret', + protocol: 'openid-connect', + publicClient: false, + defaultClientScopes: ['generic'], + redirectUris: [`${argocdUrl}/auth/callback`], + webOrigins: [argocdUrl], + rootUrl: argocdUrl, + adminUrl: argocdUrl, + baseUrl: '/applications', + } + const result = await kcClient.clients.find({ clientId, max: 1 }) + let outcome: 'updated' | 'created' + if (result.length > 0 && result[0].id) { + await kcClient.clients.update({ id: result[0].id }, client) + outcome = 'updated' + } else { + const password = generateRandomPassword(30) + await apis.vault.write({ clientSecret: password }, 'keycloak') + await kcClient.clients.create({ + secret: password, + ...client, + }) + outcome = 'created' + } + logger.info({ action: 'upsertZone', zoneSlug, clientId, outcome }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'upsertZone', zoneSlug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +export const deleteZone: StepCall = async ({ args: zone }) => { + const zoneSlug = zone.slug + try { + const kcClient = await getkcClient() + const clientId = getClientZoneId(zone) + const result = await kcClient.clients.find({ clientId, max: 1 }) + if (result.length > 0 && result[0].id) { + await kcClient.clients.del({ id: result[0].id }) + logger.info({ action: 'deleteZone', zoneSlug, clientId, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + logger.info({ action: 'deleteZone', zoneSlug, clientId, outcome: 'already-missing' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already Missing', + }, + } + } catch (error) { + logger.error({ action: 'deleteZone', zoneSlug, clientId: getClientZoneId(zone), err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const upsertAdminRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) return { status: { result: 'OK', message: 'No OIDC Group defined' } } + if (isExternalRoleType(role.type)) return { status: { result: 'OK', message: 'Skipped (external system role)' } } + const pluginResult = new PluginResultBuilder('Up-to-date') + try { + const kcClient = await getkcClient() + const group = await getOrCreateGroupByPath(kcClient, role.oidcGroup) + const groupMembers = await kcClient.groups.listMembers({ id: group.id }) + + await Promise.all([ + ...groupMembers.map((member, index) => { + if (member.id && !role.members.some(({ id }) => id === member.id)) { + return kcClient.users.delFromGroup({ + id: member.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak admin group') + pluginResult.addExtra(`remove-${index}`, err) + }) + } + return undefined + }), + ...role.members.map((user, index) => { + if (!groupMembers.some(({ id }) => id === user.id)) { + return kcClient.users.addToGroup({ + id: user.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t add user to keycloak admin group') + pluginResult.addExtra(`add-${index}`, err) + }) + } + return undefined + }), + ]) + + logger.info({ action: 'upsertAdminRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertAdminRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const deleteAdminRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) return { status: { result: 'OK', message: 'No OIDC Group defined' } } + if (isExternalRoleType(role.type)) return { status: { result: 'OK', message: 'Skipped (external system role)' } } + const pluginResult = new PluginResultBuilder('Deleted') + try { + const kcClient = await getkcClient() + let group: GroupRepresentation | undefined + if (role.oidcGroup.startsWith('/')) { + const name = role.oidcGroup.split('/').pop() || '' + const groups = await kcClient.groups.find({ search: name }) + group = groups.find(g => g.path === role.oidcGroup) + } else { + const groupOrVoid = await getGroupByName(kcClient, role.oidcGroup) + group = groupOrVoid || undefined + } + + if (group?.id) { + await Promise.all(role.members.map((user, index) => { + return kcClient.users.delFromGroup({ + id: user.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak admin group') + pluginResult.addExtra(`remove-${index}`, err) + }) + })) + } + logger.info({ action: 'deleteAdminRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'deleteAdminRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const upsertProjectRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) { + return { + status: { + result: 'OK', + message: 'No OIDC group defined', + }, + } + } + try { + const kcClient = await getkcClient() + await getOrCreateGroupByPath(kcClient, role.oidcGroup) + logger.info({ action: 'upsertProjectRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Synced', + }, + } + } catch (error) { + logger.error({ action: 'upsertProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed to sync role', + }, + } + } +} + +export const deleteProjectRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) { + return { + status: { + result: 'OK', + message: 'No OIDC group defined', + }, + } + } + try { + const kcClient = await getkcClient() + const [projectName, pluginName, roleName] = role.oidcGroup.split('/').slice(1) + if (!projectName || !pluginName || !roleName) throw new Error('Invalid OIDC group format') + const projectGroup = await getGroupByName(kcClient, projectName) + if (projectGroup?.id) { + const pluginGroups = await getAllSubgroups(kcClient, projectGroup.id, 0) + const pluginGroup = pluginGroups.find(({ name }) => name === pluginName) as Required | undefined + if (pluginGroup?.id) { + const roleGroups = await getAllSubgroups(kcClient, pluginGroup.id, 0) + const roleGroup = roleGroups.find(({ name }) => name === roleName) as Required | undefined + if (roleGroup?.id) { + await deleteGroup(kcClient, roleGroup.id) + logger.info({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + } + } + logger.info({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, outcome: 'already-deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already deleted', + }, + } + } catch (error) { + logger.error({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed to delete role', + }, + } + } +} + +export const upsertProjectMember: StepCall = async ({ args: member }) => { + const pluginResult = new PluginResultBuilder('Synced') + try { + const kcClient = await getkcClient() + + const projectGroup = await getOrCreateProjectGroup(kcClient, member.project.slug) + const consoleGroup = await getOrCreateChildGroup(kcClient, projectGroup.id, consoleGroupName) + const allRoleGroups = await getAllSubgroups(kcClient, consoleGroup.id, 0) + const userGroups = await kcClient.users.listGroups({ id: member.userId }) + + const userRolesOidcGroups = member.roles + .map(r => r.oidcGroup) + .filter((g): g is string => !!g) + + // Sync Roles + let groupMembershipChanges = 0 + for (const roleGroup of allRoleGroups) { + if (!roleGroup.id || !roleGroup.path) continue + const isMember = userGroups.some(ug => ug.id === roleGroup.id) + const shouldBeMember = userRolesOidcGroups.includes(roleGroup.path) + + if (shouldBeMember && !isMember) { + groupMembershipChanges += 1 + await kcClient.users.addToGroup({ id: member.userId, groupId: roleGroup.id }) + } else if (!shouldBeMember && isMember) { + groupMembershipChanges += 1 + await kcClient.users.delFromGroup({ id: member.userId, groupId: roleGroup.id }) + } + } + + logger.info({ action: 'upsertProjectMember', projectSlug: member.project.slug, groupMembershipChanges }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertProjectMember', projectSlug: member.project.slug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const deleteProjectMember: StepCall = async ({ args: member }) => { + const pluginResult = new PluginResultBuilder('Deleted') + try { + const kcClient = await getkcClient() + if (!member.userId) return pluginResult.getResultObject() + + const projectGroup = await getGroupByName(kcClient, member.project.slug) + if (!projectGroup?.id) return pluginResult.getResultObject() + + const userGroups = await kcClient.users.listGroups({ id: member.userId }) + const projectGroups = userGroups.filter(g => g.path?.startsWith(projectGroup.path!)) + + let removedCount = 0 + for (const group of projectGroups) { + if (group.id) { + await kcClient.users.delFromGroup({ id: member.userId, groupId: group.id }) + removedCount += 1 + } + } + + logger.info({ action: 'deleteProjectMember', projectSlug: member.project.slug, removedCount }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'deleteProjectMember', projectSlug: member.project.slug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +function getClientZoneId(zone: ZoneObject): string { + return `argocd-${zone.slug}-zone` +} diff --git a/.jjconflict-side-0/plugins/keycloak/src/group.ts b/.jjconflict-side-0/plugins/keycloak/src/group.ts new file mode 100644 index 0000000000..09c4cdc077 --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/src/group.ts @@ -0,0 +1,123 @@ +import type KeycloakAdminClient from '@keycloak/keycloak-admin-client' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js' + +export const consoleGroupName = 'console' + +export type CustomGroup = Required> +export async function getGroupByName(kcClient: KeycloakAdminClient, name: string): Promise { + const groupSearch = await kcClient.groups.find({ search: name }) + return groupSearch.find(grp => grp.name === name) +} + +export async function getAllSubgroups(kcClient: KeycloakAdminClient, parentId: string, first: number, subgroups: GroupRepresentation[] = []): Promise { + const newSubgroups = [ + ...subgroups, + ...await kcClient.groups.listSubGroups({ parentId, briefRepresentation: false, max: 10, first }), + ] + if (newSubgroups.length - subgroups.length === 10) { + return getAllSubgroups(kcClient, parentId, first + 10, newSubgroups) + } + return newSubgroups +} + +export async function getOrCreateChildGroup(kcClient: KeycloakAdminClient, parentId: string, name: string, subGroups: GroupRepresentation[] | undefined = []): Promise { + if (Array.isArray(subGroups) && subGroups.length > 0) { + const matchingGroup = subGroups.find(({ name: groupName }) => groupName === name) as Required | undefined + if (matchingGroup) { + return { + id: matchingGroup.id, + subGroups: matchingGroup.subGroups || [], + subGroupCount: matchingGroup.subGroups?.length || 0, + name, + } + } + } + subGroups = await getAllSubgroups(kcClient, parentId, 0) + + const matchingGroup = subGroups?.find(({ name: groupName }) => groupName === name) as Required | undefined + if (!matchingGroup) { + const newGroup = await kcClient.groups.createChildGroup({ id: parentId }, { name }) + return { + id: newGroup.id, + subGroups: [], + subGroupCount: 0, + name, + } + } + return { + id: matchingGroup.id, + subGroups: matchingGroup.subGroups || [], + subGroupCount: matchingGroup.subGroups?.length || 0, + name, + } +} + +export async function getOrCreateProjectGroup(kcClient: KeycloakAdminClient, name: string): Promise> & { subGroups: Required[] }> { + const existingGroup = await getGroupByName(kcClient, name) as Required + if (!existingGroup) { + const newGroup = await kcClient.groups.create({ name }) + return { + id: newGroup.id, + subGroups: [], + subGroupCount: 0, + name, + } + } + return { + id: existingGroup.id, + // @ts-ignore mauvais typage de librairie les subGroups ont forcément un id + subGroups: existingGroup.subGroups || [], + subGroupCount: existingGroup.subGroups?.length || 0, + name: existingGroup.name, + } +} + +export async function getOrCreateGroupByName(kcClient: KeycloakAdminClient, name: string): Promise> { + const group = await getGroupByName(kcClient, name) + if (group) return group as Required + const newGroup = await kcClient.groups.create({ name }) + const created = await kcClient.groups.findOne({ id: newGroup.id }) + if (!created) throw new Error(`Failed to retrieve created group: ${name}`) + return created as Required +} + +export async function getOrCreateGroupByPath(kcClient: KeycloakAdminClient, path: string): Promise> { + if (!path.startsWith('/')) { + return getOrCreateGroupByName(kcClient, path) + } + + const name = path.split('/').pop() || '' + const groups = await kcClient.groups.find({ search: name }) + const existingGroup = groups.find(g => g.path === path) + + if (existingGroup) return existingGroup as Required + + const groupNames = path.split('/').filter(Boolean) + let parentId: string | undefined + + for (const groupName of groupNames) { + const subGroups = parentId + ? await kcClient.groups.listSubGroups({ parentId }) + : await kcClient.groups.find({ search: groupName }) + const existingSubGroup = subGroups.find(g => g.name === groupName) + + if (existingSubGroup) { + parentId = existingSubGroup.id + } else { + const newGroup = parentId + ? await kcClient.groups.createChildGroup({ id: parentId }, { name: groupName }) + : await kcClient.groups.create({ name: groupName }) + parentId = newGroup.id + } + } + + if (parentId) { + const group = await kcClient.groups.findOne({ id: parentId }) + if (group) return group as Required + } + throw new Error(`Failed to create group path: ${path}`) +} + +export async function deleteGroup(kcClient: KeycloakAdminClient, groupId: string) { + await kcClient.groups.del({ id: groupId }) +} diff --git a/.jjconflict-side-0/plugins/keycloak/src/index.ts b/.jjconflict-side-0/plugins/keycloak/src/index.ts new file mode 100644 index 0000000000..a5fda76e1f --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/src/index.ts @@ -0,0 +1,67 @@ +import type { DefaultArgs, Plugin, Project, ProjectLite } from '@cpn-console/hooks' +import { KeycloakProjectApi } from './class.js' +import { start } from './client.js' +import { + deleteAdminRole, + deleteProject, + deleteProjectMember, + deleteProjectRole, + deleteZone, + retrieveKeycloakUserByEmail, + upsertAdminRole, + upsertProject, + upsertProjectMember, + upsertProjectRole, + upsertZone, +} from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + deleteProject: { + api: project => new KeycloakProjectApi(project.slug), + steps: { post: deleteProject }, + }, + upsertProject: { + api: project => new KeycloakProjectApi(project.slug), + steps: { main: upsertProject }, + }, + upsertProjectRole: { + steps: { main: upsertProjectRole }, + }, + upsertProjectMember: { + steps: { main: upsertProjectMember }, + }, + deleteProjectMember: { + steps: { post: deleteProjectMember }, + }, + upsertZone: { + steps: { main: upsertZone }, + }, + deleteZone: { + steps: { post: deleteZone }, + }, + deleteProjectRole: { + steps: { post: deleteProjectRole }, + }, + retrieveUserByEmail: { steps: { main: retrieveKeycloakUserByEmail } }, + upsertAdminRole: { + steps: { main: upsertAdminRole }, + }, + deleteAdminRole: { + steps: { post: deleteAdminRole }, + }, + }, + monitor, + start, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + keycloak: Args extends (ProjectLite | Project) + ? KeycloakProjectApi + : undefined + } +} diff --git a/.jjconflict-side-0/plugins/keycloak/src/infos.ts b/.jjconflict-side-0/plugins/keycloak/src/infos.ts new file mode 100644 index 0000000000..3b3980194f --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/src/infos.ts @@ -0,0 +1,12 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos: ServiceInfos = { + name: 'keycloak', + title: 'Keycloak', + config: { + global: [], + project: [], + }, +} + +export default infos diff --git a/.jjconflict-side-0/plugins/keycloak/src/logger.ts b/.jjconflict-side-0/plugins/keycloak/src/logger.ts new file mode 100644 index 0000000000..1578c26c49 --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'keycloak' }) diff --git a/.jjconflict-side-0/plugins/keycloak/src/monitor.ts b/.jjconflict-side-0/plugins/keycloak/src/monitor.ts new file mode 100644 index 0000000000..4f1df8d648 --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/src/monitor.ts @@ -0,0 +1,27 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(requiredEnv('KEYCLOAK_URL'), { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-0/plugins/keycloak/tsconfig.eslint.json b/.jjconflict-side-0/plugins/keycloak/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-0/plugins/keycloak/tsconfig.json b/.jjconflict-side-0/plugins/keycloak/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-0/plugins/keycloak/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-0/plugins/nexus/.gitignore b/.jjconflict-side-0/plugins/nexus/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-0/plugins/nexus/README.md b/.jjconflict-side-0/plugins/nexus/README.md new file mode 100644 index 0000000000..a8ee9f797a --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/README.md @@ -0,0 +1,13 @@ +# Introduction + +Ce plugin permet de déployer des dépôts à la demande sur nexus. Les seules technologies prisent en charge actuellement sont Maven et NPM. + +## Configuration + +| Env var | valeur possible | description | +| ---------------------- | --------------- | --------------------------------------------------------------------------------------------- | +| NEXUS_ADMIN | chaine | Nom d'utilisateur admin Nexus | +| NEXUS_ADMIN_PASSWORD | chaine | Mot de passe Nexus | +| NEXUS_URL | *url* | Url public d'accés au Nexus | +| NEXUS_INTERNAL_URL | *url* ou vide | url par laquelle la console interroge le service Nexus, si absent utilisation de l'url public | +| NEXUS__SECRET_EXPOSE_INTERNAL_URL | "true" ou vide | Exposition ou non de l'url interne dans la remontée des secrets du projet | diff --git a/.jjconflict-side-0/plugins/nexus/eslint.config.js b/.jjconflict-side-0/plugins/nexus/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-0/plugins/nexus/package.json b/.jjconflict-side-0/plugins/nexus/package.json new file mode 100644 index 0000000000..0a758fd7d9 --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/package.json @@ -0,0 +1,49 @@ +{ + "name": "@cpn-console/nexus-plugin", + "type": "module", + "version": "2.3.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/plugins/nexus/src/config.ts b/.jjconflict-side-0/plugins/nexus/src/config.ts new file mode 100644 index 0000000000..aecdc37956 --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/src/config.ts @@ -0,0 +1,29 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + secretExposedUrl: string + user: string + password: string + + constructor() { + this.password = requiredEnv('NEXUS_ADMIN_PASSWORD') + this.user = requiredEnv('NEXUS_ADMIN') + this.publicUrl = removeTrailingSlash(requiredEnv('NEXUS_URL')) + this.internalUrl = process.env.NEXUS_INTERNAL_URL + ? removeTrailingSlash(process.env.NEXUS_INTERNAL_URL) + : this.publicUrl + this.secretExposedUrl = process.env.NEXUS__SECRET_EXPOSE_INTERNAL_URL === 'true' ? this.internalUrl : this.publicUrl + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-side-0/plugins/nexus/src/env.d.ts b/.jjconflict-side-0/plugins/nexus/src/env.d.ts new file mode 100644 index 0000000000..b34e01d65a --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-side-0/plugins/nexus/src/functions.ts b/.jjconflict-side-0/plugins/nexus/src/functions.ts new file mode 100644 index 0000000000..5841f1c018 --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/src/functions.ts @@ -0,0 +1,28 @@ +import getConfig from './config.js' + +let axiosOptions: { + baseURL: string + auth: { + username: string + password: string + } + headers: { + Accept: string + } +} + +export function getAxiosOptions(): Required { + if (!axiosOptions) { + axiosOptions = { + baseURL: `${getConfig().internalUrl}/service/rest/v1/`, + auth: { + username: getConfig().user, + password: getConfig().password, + }, + headers: { + Accept: 'application/json', + }, + } + } + return axiosOptions +} diff --git a/.jjconflict-side-0/plugins/nexus/src/index.ts b/.jjconflict-side-0/plugins/nexus/src/index.ts new file mode 100644 index 0000000000..3457e0c021 --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/src/index.ts @@ -0,0 +1,19 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import infos from './infos.js' +import monitor from './monitor.js' +import { createNexusProject, deleteNexusProject, getSecrets } from './project.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { steps: { main: createNexusProject } }, + deleteProject: { steps: { main: deleteNexusProject } }, + getProjectSecrets: { steps: { main: getSecrets } }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-side-0/plugins/nexus/src/infos.ts b/.jjconflict-side-0/plugins/nexus/src/infos.ts new file mode 100644 index 0000000000..83abdb71aa --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/src/infos.ts @@ -0,0 +1,116 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos = { + name: 'nexus', + title: 'Nexus', + imgSrc: '/img/nexus.png', + description: 'Nexus permet de gérer les binaires et artefacts de build à travers la chaîne logistique logicielle', + config: { + project: [ + { + key: 'activateNpmRepo', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt NPM', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'npmWritePolicy', + section: 'NPM', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture', + value: 'allow', + description: 'Politique d\'écriture des dépôts NPM, valeurs possibles: allow / allow_once, deny, replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'activateMavenRepo', + section: 'Maven', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt Maven', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'mavenSnapshotWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Snapshot', + value: 'allow', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'mavenReleaseWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Release', + value: 'allow_once', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + ], + global: [ + { + key: 'activateNpmRepoDefaultValue', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Créer un dépôt NPM privé (comportement par défaut des projets)', + value: 'disabled', + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'activateMavenRepoDefaultValue', + section: 'Maven', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Créer un dépôt MAVEN privé (comportement par défaut des projets)', + value: 'disabled', + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'enablePlugin', + kind: 'switch', + initialValue: 'enabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Activer/Désactiver entièrement le plugin Nexus', + value: 'enabled', + description: 'Défaut: Activé', + }, + ], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-side-0/plugins/nexus/src/logger.ts b/.jjconflict-side-0/plugins/nexus/src/logger.ts new file mode 100644 index 0000000000..952b553155 --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'nexus' }) diff --git a/.jjconflict-side-0/plugins/nexus/src/maven.ts b/.jjconflict-side-0/plugins/nexus/src/maven.ts new file mode 100644 index 0000000000..4fea9e16cc --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/src/maven.ts @@ -0,0 +1,193 @@ +import type { AxiosInstance } from 'axios' +import type { WritePolicy } from './utils.js' +import getConfig from './config.js' +import { deleteIfExists } from './utils.js' + +// Retro-compatibilty, maven is a special case with bad name formats +function getRepoNames(projectName: string) { // Unique function per language cause names are unique per repo + return { + hosted: [ + { + repo: `${projectName}-repository-release`, + privilege: `${projectName}-privilege-release`, + }, + { + repo: `${projectName}-repository-snapshot`, + privilege: `${projectName}-privilege-snapshot`, + }, + ], + group: { + repo: `${projectName}-repository-group`, + privilege: `${projectName}-privilege-group`, + }, + } +} + +async function provisionMavenHosted(axiosInstance: AxiosInstance, repoName: string, writePolicy: WritePolicy) { + const repo = await axiosInstance({ + method: 'GET', + url: `/repositories/maven/hosted/${repoName}`, + validateStatus: code => [200, 404].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/maven/hosted', + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/maven/hosted/${repoName}`, + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [204].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/maven/hosted', + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/maven/hosted/${repoName}`, + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [204].includes(code), + }) + } + } +} +interface MavenOptions { + snapshotWritePolicy: WritePolicy + releaseWritePolicy: WritePolicy +} +export async function createMavenRepo(axiosInstance: AxiosInstance, projectName: string, options: MavenOptions) { + const names = getRepoNames(projectName) + + // create local repo maven + await Promise.all([ + provisionMavenHosted(axiosInstance, names.hosted[0].repo, options.releaseWritePolicy), + provisionMavenHosted(axiosInstance, names.hosted[1].repo, options.snapshotWritePolicy), + ]) + + // create maven group + await axiosInstance({ + method: 'post', + url: '/repositories/maven/group', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + 'maven-public', + ], + }, + }, + validateStatus: code => [201, 400].includes(code), + }) + + // create privileges + for (const name of [...names.hosted, names.group]) { + await axiosInstance({ + method: 'post', + url: '/security/privileges/repository-view', + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'maven2', + repository: name.repo, + }, + validateStatus: code => [201, 400].includes(code), + }) + } + return names +} + +export async function deleteMavenRepo(axiosInstance: AxiosInstance, projectName: string) { + const names = getRepoNames(projectName) + const repoPaths = [names.group, ...names.hosted] + const privileges = [...names.hosted, names.group] + const pathsToDelete = [ + // delete privileges + ...privileges.map(({ privilege }) => `/security/privileges/${privilege}`), + // delete local repo maven snapshot + ...repoPaths.map(repo => `/repositories/${repo.repo}`), + ] + for (const path of pathsToDelete) { + await deleteIfExists(path, axiosInstance) + } +} + +export function getMavenUrls(projectName: string) { + const nexusUrl = getConfig().secretExposedUrl + const names = getRepoNames(projectName) + return { + MAVEN_REPO_RELEASE: `${nexusUrl}/${names.hosted[0].repo}`, + MAVEN_REPO_SNAPSHOT: `${nexusUrl}/${names.hosted[1].repo}`, + } +} diff --git a/.jjconflict-side-0/plugins/nexus/src/monitor.ts b/.jjconflict-side-0/plugins/nexus/src/monitor.ts new file mode 100644 index 0000000000..a79babebeb --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/src/monitor.ts @@ -0,0 +1,79 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import { getAxiosOptions } from './functions.js' + +const coreComponents = [ + 'Blob Stores Ready', + 'File Blob Stores Path', +] +const auxComponents = [ + 'Available CPUs', + 'Blob Stores Quota', + 'Blob Stores Ready', + 'Default Admin Credentials', + 'DefaultRoleRealm', + 'File Blob Stores Path', + 'NuGet V2 repositories', + 'Scheduler', + 'com.codahale.metrics.health.jvm.ThreadDeadlockHealthCheck', + 'Node Health Check', + 'Re-encryption required', + 'Default Admin Credentials', + 'Recent version', +] + +type NexusRes = Record + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const status = await axios.get('/status', { + validateStatus: () => true, + ...getAxiosOptions(), + }) + const statusCheck = await axios.get('/status/check', { + validateStatus: res => [200, 503].includes(res), + ...getAxiosOptions(), + }) + const statusWritable = await axios.get('/status/writable', { + validateStatus: res => [200, 503].includes(res), + ...getAxiosOptions(), + }) + if (status.status === 503 || statusWritable.status === 503) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Nexus semble planté' + return instance.lastStatus + } + if (statusCheck.status === 200) { + const data = statusCheck.data as NexusRes + + const failedCoreComponents = coreComponents.filter(component => !data[component]?.healthy) + const failedAuxComponents = auxComponents.filter(component => !data[component]?.healthy) + if (failedAuxComponents.length || failedCoreComponents.length) { + instance.lastStatus.status = failedCoreComponents.length + ? MonitorStatus.ERROR + : MonitorStatus.WARNING + instance.lastStatus.message = failedCoreComponents.length + ? 'Des composants critiques sont en erreur' + : 'Le service est partiellement dégradé' + + instance.lastStatus.cause = `Les composants suivants sont en erreurs: ${[...failedCoreComponents, ...failedAuxComponents].join(', ')}` + return instance.lastStatus + } + } + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-0/plugins/nexus/src/npm.ts b/.jjconflict-side-0/plugins/nexus/src/npm.ts new file mode 100644 index 0000000000..02bc3573f0 --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/src/npm.ts @@ -0,0 +1,167 @@ +import type { AxiosInstance } from 'axios' +import type { WritePolicy } from './utils.js' +import getConfig from './config.js' +import { deleteIfExists } from './utils.js' + +function getRepoNames(projectName: string) { // Unique function per language cause names are unique per repo + return { + hosted: [ + { + repo: `${projectName}-npm`, + privilege: `${projectName}-npm-privilege`, + }, + ], + group: { + repo: `${projectName}-npm-group`, + privilege: `${projectName}-npm-group-privilege`, + }, + } +} +export async function createNpmRepo(axiosInstance: AxiosInstance, projectName: string, writePolicy: WritePolicy) { + const names = getRepoNames(projectName) + // create local repo maven + for (const repVersion of names.hosted) { + const repo = await axiosInstance({ + method: 'GET', + url: `/repositories/npm/hosted/${repVersion.repo}`, + validateStatus: code => [200, 404].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/npm/hosted', + data: { + name: repVersion.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/npm/hosted/${repVersion.repo}`, + data: { + name: repVersion.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + }, + validateStatus: code => [204].includes(code), + }) + } + } + // create maven group + const group = await axiosInstance({ + method: 'get', + url: `/repositories/npm/group/${names.group.repo}`, + validateStatus: code => [200, 404].includes(code), + }) + if (group.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/npm/group', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + ], + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + url: `/repositories/npm/group/${names.group.repo}`, + method: 'put', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + ], + }, + }, + validateStatus: code => [204].includes(code), + }) + } + + for (const name of [...names.hosted, names.group]) { + const privilege = await axiosInstance({ + method: 'get', + url: `/security/privileges/${name.privilege}`, + validateStatus: code => [200, 404].includes(code), + }) + if (privilege.status === 404) { + await axiosInstance({ + method: 'post', + url: '/security/privileges/repository-view', + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'npm', + repository: name.repo, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/security/privileges/repository-view/${name.privilege}`, + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'npm', + repository: name.repo, + }, + validateStatus: code => [204].includes(code), + }) + } + } + return names +} + +export async function deleteNpmRepo(axiosInstance: AxiosInstance, projectName: string) { + const names = getRepoNames(projectName) + const repoPaths = [names.group, ...names.hosted] + const privileges = [...names.hosted, names.group] + const pathsToDelete = [ + // delete privileges + ...privileges.map(({ privilege }) => `/security/privileges/${privilege}`), + // delete local repo maven snapshot + ...repoPaths.map(repo => `/repositories/${repo.repo}`), + ] + for (const path of pathsToDelete) { + await deleteIfExists(path, axiosInstance) + } +} + +export function getNpmUrls(projectName: string) { + const nexusUrl = getConfig().secretExposedUrl + const names = getRepoNames(projectName) + return { + NPM_REPO: `${nexusUrl}/${names.hosted[0].repo}`, + } +} diff --git a/.jjconflict-side-0/plugins/nexus/src/project.ts b/.jjconflict-side-0/plugins/nexus/src/project.ts new file mode 100644 index 0000000000..2363ca05a1 --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/src/project.ts @@ -0,0 +1,245 @@ +import type { Project, ProjectLite, StepCall } from '@cpn-console/hooks' +import type { WritePolicy } from './utils.js' +import { generateRandomPassword, specificallyDisabled } from '@cpn-console/hooks' +import axios from 'axios' +import { getAxiosOptions } from './functions.js' +import { logger } from './logger.js' +import { createMavenRepo, deleteMavenRepo, getMavenUrls } from './maven.js' +import { createNpmRepo, deleteNpmRepo, getNpmUrls } from './npm.js' +import { deleteIfExists, getTechUsed, parseProjectOptions, updateStore } from './utils.js' + +const getAxiosInstance = () => axios.create(getAxiosOptions()) + +export const deleteNexusProject: StepCall = async ({ args: project }) => { + const axiosInstance = getAxiosInstance() + const projectName = project.slug + try { + await deleteMavenRepo(axiosInstance, projectName) + await deleteNpmRepo(axiosInstance, projectName) + await Promise.all([ + // delete role + deleteIfExists(`/security/roles/${projectName}-ID`, axiosInstance), + // delete user + axiosInstance({ + method: 'delete', + url: `/security/users/${projectName}`, + validateStatus: code => code === 404 || code < 300, + }), + ]) + + logger.info({ action: 'deleteNexusProject', projectSlug: project.slug, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Project deleted from Nexus', + }, + store: { + activateNpmRepo: 'disabled', + activateMavenRepo: 'disabled', + }, + } + } catch (error) { + logger.error({ action: 'deleteNexusProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const createNexusProject: StepCall = async (payload) => { + try { + if (specificallyDisabled(payload.config.nexus?.enablePlugin)) { + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'disabled' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Nexus plugin is disabled', + }, + } + } + if (!payload.apis.vault) throw new Error('no Vault available') + + const axiosInstance = getAxiosInstance() + const projectName = payload.args.slug + const owner = payload.args.owner + const res: any = {} + const options = parseProjectOptions(payload.args.store.nexus) + const failedProvisionning: Partial> = {} + const techUsed = getTechUsed(payload) + + if (options.keysInError.length) { + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'invalid-config', invalidKeyCount: options.keysInError.length }, 'Hook done') + return { + status: { + result: 'WARNING', + message: `${options.keysInError.join(', ')} ${options.keysInError.length > 1 ? 'are' : 'is'} invalid, provisionning canceled, fix it and try again`, + }, + warnReasons: [`invalid key(s): ${options.keysInError.join(', ')}`], + } + } + const privilegesToAccess = [] as string[] + + try { + if (techUsed.maven) { + const names = await createMavenRepo(axiosInstance, projectName, { + releaseWritePolicy: options.mavenReleaseWritePolicy as WritePolicy, + snapshotWritePolicy: options.mavenSnapshotWritePolicy as WritePolicy, + }) + privilegesToAccess.push(names.group.privilege, ...names.hosted.map(({ privilege }) => privilege)) + } else { + await deleteMavenRepo(axiosInstance, projectName) + } + } catch (error) { + failedProvisionning.maven = { error, message: `Maven failed to ${techUsed.maven ? 'provision' : 'delete'} repositories please try again in few minutes` } + } + + try { + if (techUsed.npm) { + const names = await createNpmRepo(axiosInstance, projectName, options.npmWritePolicy as WritePolicy) + privilegesToAccess.push(names.group.privilege, ...names.hosted.map(({ privilege }) => privilege)) + } else { + await deleteNpmRepo(axiosInstance, projectName) + } + } catch (error) { + failedProvisionning.npm = { error, message: `Npm failed to ${techUsed.npm ? 'provision' : 'delete'} repositories please try again in few minutes` } + } + + const roleId = `${projectName}-ID` + // create role + const role = await axiosInstance({ + method: 'GET', + url: `security/roles/${roleId}`, + validateStatus: code => [200, 404].includes(code), + }) + if (role.status === 404) { + await axiosInstance({ + method: 'post', + url: '/security/roles', + data: { + id: `${projectName}-ID`, + name: `${projectName}-role`, + description: 'desc', + privileges: privilegesToAccess, + }, + validateStatus: code => [200].includes(code), + }) + } else if (role.status === 200) { + await axiosInstance({ + method: 'PUT', + url: `security/roles/${roleId}`, + data: { + id: `${projectName}-ID`, + name: `${projectName}-role`, + privileges: privilegesToAccess, + }, + validateStatus: code => [204].includes(code), + }) + } + + const vaultNexusSecret = await payload.apis.vault.read('NEXUS', { throwIfNoEntry: false }) + let currentPwd: string = vaultNexusSecret?.NEXUS_PASSWORD + + const newPwd = generateRandomPassword(30) + const getUser = await axiosInstance({ + url: `/security/users?userId=${projectName}`, + }) as { data: { userId: string }[] } + const user = getUser.data.find(user => user.userId === projectName) + if (user) { + res.user = getUser.data[0] + res.status = { result: 'OK', message: 'User already exists' } + if (!vaultNexusSecret) { + await axiosInstance({ + method: 'put', + url: `/security/users/${projectName}/change-password`, + data: newPwd, + headers: { + 'Content-Type': 'text/plain', + }, + }) + currentPwd = newPwd + } + } else { + // createUser + await axiosInstance({ + method: 'post', + url: '/security/users', + data: { + userId: projectName, + firstName: 'Monkey D.', + lastName: 'Luffy', + emailAddress: owner.email, + password: newPwd, + status: 'active', + roles: [`${projectName}-ID`], + }, + }) + currentPwd = newPwd + } + + if (!getUser.data.length || (getUser.data.length && !vaultNexusSecret)) { // conditions précédentes, si non existent ou si modp a dû être changé + await payload.apis.vault.write({ + NEXUS_PASSWORD: currentPwd, + NEXUS_USERNAME: projectName, + }, 'NEXUS') + } + + if (Object.keys(failedProvisionning).length) { + const failed = Object.values(failedProvisionning) + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'partial-failure', failedProvisionCount: failed.length }, 'Hook done') + return { + status: { + result: 'WARNING', + message: failed.map(({ message }) => message).join('; '), + }, + errors: failed.map(({ error }) => error), + } + } + + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'up-to-date' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + store: updateStore(payload.args.store.nexus), + } + } catch (error) { + logger.error({ action: 'createNexusProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Fail Create repositories', + }, + } + } +} + +export const getSecrets: StepCall = async (payload) => { + if (specificallyDisabled(payload.config.nexus?.enablePlugin)) { + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug, outcome: 'disabled' }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } + const projectName = payload.args.slug + const techUsed = getTechUsed(payload) + + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + secrets: { + ...techUsed.maven && getMavenUrls(projectName), + ...techUsed.npm && getNpmUrls(projectName), + }, + } +} diff --git a/.jjconflict-side-0/plugins/nexus/src/utils.ts b/.jjconflict-side-0/plugins/nexus/src/utils.ts new file mode 100644 index 0000000000..5c52c4435f --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/src/utils.ts @@ -0,0 +1,80 @@ +import type { HookPayload, Project, ProjectLite } from '@cpn-console/hooks' +import type { AxiosInstance } from 'axios' +import { defaultOrNullish, specificallyEnabled } from '@cpn-console/hooks' + +export async function deleteIfExists(url: string, axiosInstance: AxiosInstance) { + const res = await axiosInstance({ + method: 'get', + url, + validateStatus: code => code === 404 || code < 300, + }) + if (res.status !== 404) { + // delete maven group + return axiosInstance({ + method: 'delete', + url, + validateStatus: code => code === 404 || code < 300, + }) + } + return res +} + +export function getTechUsed(payload: HookPayload) { + const projectStore = payload.args.store.nexus + const globalConfig = payload.config.nexus + const techUsed = { + maven: false, + npm: false, + } + if (specificallyEnabled(projectStore?.activateMavenRepo)) { + techUsed.maven = true + } else if (defaultOrNullish(projectStore?.activateMavenRepo) && specificallyEnabled(globalConfig?.activateMavenRepoDefaultValue)) { + techUsed.maven = true + } + if (specificallyEnabled(projectStore?.activateNpmRepo)) { + techUsed.npm = true + } else if (defaultOrNullish(projectStore?.activateMavenRepo) && specificallyEnabled(globalConfig?.activateMavenRepoDefaultValue)) { + techUsed.npm = true + } + return techUsed +} + +export const writePolicyEnum = ['ALLOW', 'ALLOW_ONCE', 'DENY', 'REPLICATION_ONLY'] as const +export type WritePolicy = typeof writePolicyEnum[number] + +export function parseWritePolicy(inputTrial?: string): WritePolicy | 'ERROR' { + const input = inputTrial?.toUpperCase() ?? 'ALLOW' + return writePolicyEnum.find(policy => policy === input) ?? 'ERROR' +} + +export function parseProjectOptions(options: ProjectLite['store']['nexus']) { + // @ts-expect-error + const mavenReleaseWritePolicy = parseWritePolicy(options?.mavenReleaseWritePolicy ?? options?.mavenWritePolicy) + // @ts-expect-error + const mavenSnapshotWritePolicy = parseWritePolicy(options?.mavenSnapshotWritePolicy ?? options?.mavenWritePolicy) + const npmWritePolicy = parseWritePolicy(options?.npmWritePolicy) + const keysInError: (keyof NonNullable>)[] = [] + if (mavenReleaseWritePolicy === 'ERROR') keysInError.push('mavenReleaseWritePolicy') + if (mavenSnapshotWritePolicy === 'ERROR') keysInError.push('mavenSnapshotWritePolicy') + if (npmWritePolicy === 'ERROR') keysInError.push('npmWritePolicy') + return { + keysInError, + mavenReleaseWritePolicy, + mavenSnapshotWritePolicy, + npmWritePolicy, + } +} + +export function updateStore(store?: ProjectLite['store']['nexus']) { + if (!store) return {} + // @ts-expect-error + if (store.mavenWritePolicy) { + // @ts-expect-error + store.mavenReleaseWritePolicy = store.mavenReleaseWritePolicy ?? store.mavenWritePolicy + // @ts-expect-error + store.mavenSnapshotWritePolicy = store.mavenSnapshotWritePolicy ?? store.mavenWritePolicy + // @ts-expect-error + store.mavenWritePolicy = '' + } + return store +} diff --git a/.jjconflict-side-0/plugins/nexus/tsconfig.eslint.json b/.jjconflict-side-0/plugins/nexus/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-0/plugins/nexus/tsconfig.json b/.jjconflict-side-0/plugins/nexus/tsconfig.json new file mode 100644 index 0000000000..fb5ba5c23c --- /dev/null +++ b/.jjconflict-side-0/plugins/nexus/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "useUnknownInCatchVariables": true, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-0/plugins/sonarqube/.gitignore b/.jjconflict-side-0/plugins/sonarqube/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-0/plugins/sonarqube/eslint.config.js b/.jjconflict-side-0/plugins/sonarqube/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-0/plugins/sonarqube/package.json b/.jjconflict-side-0/plugins/sonarqube/package.json new file mode 100644 index 0000000000..703362ca0d --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/package.json @@ -0,0 +1,50 @@ +{ + "name": "@cpn-console/sonarqube-plugin", + "type": "module", + "version": "2.0.5", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/plugins/sonarqube/src/check.ts b/.jjconflict-side-0/plugins/sonarqube/src/check.ts new file mode 100644 index 0000000000..67a1e79cf2 --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/src/check.ts @@ -0,0 +1,45 @@ +import type { PluginResult } from '@cpn-console/hooks' +import { getAxiosInstance } from './tech.js' + +let status: PluginResult + +export async function getStatus() { + if (!status?.result) { + status = await check() + } + return status +} + +export async function check(): Promise { + const axiosInstance = getAxiosInstance() + + try { + const health = await axiosInstance({ + url: 'system/info', + }) + const res: PluginResult = { + status: { + result: 'OK', + }, + } + + if (health.data.Health === 'RED') { + res.status.result = 'KO' + } + if (health.data['Health Causes']) { + res.status.message = health.data['Health Causes'].join('\n') + } + + return res + } catch (error) { + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + updatedAt: Date.now(), + } + } +} diff --git a/.jjconflict-side-0/plugins/sonarqube/src/env.d.ts b/.jjconflict-side-0/plugins/sonarqube/src/env.d.ts new file mode 100644 index 0000000000..219601dae5 --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/src/env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/.jjconflict-side-0/plugins/sonarqube/src/functions.ts b/.jjconflict-side-0/plugins/sonarqube/src/functions.ts new file mode 100644 index 0000000000..2a72978cc7 --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/src/functions.ts @@ -0,0 +1,352 @@ +import type { Project, StepCall } from '@cpn-console/hooks' +import type { KeycloakProjectApi } from '@cpn-console/keycloak-plugin/types/class.js' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { SonarPaging } from './project.js' +import type { VaultSonarSecret } from './tech.js' +import type { SonarUser } from './user.js' +import { generateProjectKey } from '@cpn-console/hooks' +import { adminGroupPath } from '@cpn-console/shared' +import { ensureGroupExists, findGroupByName } from './group.js' +import { logger } from './logger.js' +import { + createDsoRepository, + deleteDsoRepository, + ensureRepositoryConfiguration, + files, + findSonarProjectsForDsoProjects, +} from './project.js' +import { getAxiosInstance } from './tech.js' +import { ensureUserExists } from './user.js' + +const globalPermissions = [ + 'admin', + 'profileadmin', + 'gateadmin', + 'scan', + 'provisioning', +] + +const projectPermissions = [ + 'admin', + 'codeviewer', + 'issueadmin', + 'securityhotspotadmin', + 'scan', + 'user', +] + +export async function initSonar() { + try { + await setTemplatePermisions() + await createAdminGroup() + await setAdminPermisions() + logger.info({ action: 'initSonar' }, 'Hook done') + } catch (error) { + logger.error({ action: 'initSonar', err: error }, 'Hook failed') + throw error + } +} + +async function createAdminGroup() { + const axiosInstance = getAxiosInstance() + const adminGroup = await findGroupByName(adminGroupPath) + if (!adminGroup) { + await axiosInstance({ + method: 'post', + params: { + name: adminGroupPath, + description: 'DSO platform admins', + }, + url: 'user_groups/create', + }) + } +} + +async function setAdminPermisions() { + const axiosInstance = getAxiosInstance() + for (const permission of globalPermissions) { + await axiosInstance({ + method: 'post', + params: { + groupName: adminGroupPath, + permission, + }, + url: 'permissions/add_group', + }) + } +} + +async function setTemplatePermisions() { + const axiosInstance = getAxiosInstance() + await axiosInstance({ + method: 'post', + params: { name: 'Forge Default' }, + url: 'permissions/create_template', + validateStatus: code => [200, 400].includes(code), + }) + for (const permission of projectPermissions) { + await axiosInstance({ + method: 'post', + params: { + templateName: 'Forge Default', + permission, + }, + url: 'permissions/add_project_creator_to_template', + }) + await axiosInstance({ + method: 'post', + params: { + groupName: 'sonar-administrators', + templateName: 'Forge Default', + permission, + }, + url: 'permissions/add_group_to_template', + }) + } + await axiosInstance({ + method: 'post', + params: { + templateName: 'Forge Default', + }, + url: 'permissions/set_default_template', + }) +} + +export const upsertProject: StepCall = async (payload) => { + try { + const project = payload.args + const { + vault: vaultApi, + keycloak: keycloakApi, + }: { + vault: VaultProjectApi + keycloak: KeycloakProjectApi + } = payload.apis + const { slug: projectSlug } = project + const username = project.slug + const keycloakGroupPath = await keycloakApi.getProjectGroupPath() + const sonarRepositories + = await findSonarProjectsForDsoProjects(projectSlug) + + await Promise.all([ + ensureUserAndVault(vaultApi, username, projectSlug), + ensureGroupExists(keycloakGroupPath), + + // Remove excess repositories + ...sonarRepositories + .filter( + sonarRepository => + !project.repositories.some( + repo => repo.internalRepoName === sonarRepository.repository, + ), + ) + .map(sonarRepository => deleteDsoRepository(sonarRepository.key)), + + // Create or configure needed repos + ...project.repositories.map(async (repository) => { + const projectKey = generateProjectKey( + projectSlug, + repository.internalRepoName, + ) + if ( + !sonarRepositories.some( + sonarRepository => + sonarRepository.repository === repository.internalRepoName, + ) + ) { + await createDsoRepository(projectSlug, repository.internalRepoName) + } + await ensureRepositoryConfiguration( + projectKey, + username, + keycloakGroupPath, + ) + }), + ]) + + logger.info({ action: 'upsertProject', projectSlug }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } catch (error) { + logger.error( + { action: 'upsertProject', projectSlug: payload.args.slug, err: error }, + 'Hook failed', + ) + return { + status: { + result: 'WARNING', + message: 'Failed to reconcile', + }, + errors: { + main: error, + }, + } + } +} + +export const setVariables: StepCall = async (payload) => { + const returnResponse = payload.results.sonarqube + try { + const project = payload.args + const { slug: projectSlug } = project + const { gitlab: gitlabApi } = payload.apis + + const sonarSecret = await payload.apis.vault.read('SONAR') + const listGroupVars = await gitlabApi.getGitlabGroupVariables() + await Promise.all([ + // Sonar vars saving in CI (repositories) + ...project.repositories.map(async (repo) => { + const projectKey = generateProjectKey( + projectSlug, + repo.internalRepoName, + ) + const repoId = await payload.apis.gitlab.getProjectId( + repo.internalRepoName, + ) + if (!repoId) return + const listVars = await gitlabApi.getGitlabRepoVariables(repoId) + return [ + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + key: 'PROJECT_KEY', + masked: false, + protected: false, + value: projectKey, + variable_type: 'env_var', + environment_scope: '*', + }), + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + key: 'PROJECT_NAME', + masked: false, + protected: false, + value: `${projectSlug}-${repo.internalRepoName}`, + variable_type: 'env_var', + environment_scope: '*', + }), + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + variable_type: 'file', + key: 'SONAR_PROJECT_PROPERTIES', + masked: false, + protected: false, + value: files['sonar-project.properties'](projectKey), + environment_scope: '*', + }), + ] + }), + // Sonar vars saving in CI (group) + gitlabApi.setGitlabGroupVariable(listGroupVars, { + key: 'SONAR_TOKEN', + masked: true, + protected: false, + value: sonarSecret.data.SONAR_TOKEN, + variable_type: 'env_var', + }), + ]) + + if (payload.results.sonarqube.status.result === 'WARNING') { + returnResponse.status.message = `main message: ${payload.results.sonarqube.status.message}, post message: OK` + } + logger.info({ action: 'setVariables', projectSlug }, 'Hook done') + return returnResponse + } catch (error) { + returnResponse.status.result = 'WARNING' + returnResponse.status.message = `main message: ${payload.results.sonarqube.status.message}, post message: Failed to reconcile` + returnResponse.errors = { + ...returnResponse.errors, + post: error, + } + logger.error( + { action: 'setVariables', projectSlug: payload.args.slug, err: error }, + 'Hook failed', + ) + return returnResponse + } +} + +export const deleteProject: StepCall = async (payload) => { + const axiosInstance = getAxiosInstance() + + const project = payload.args + const { slug: projectSlug } = project + const username = projectSlug + try { + const sonarRepositories + = await findSonarProjectsForDsoProjects(projectSlug) + await Promise.all(sonarRepositories.map(repo => deleteRepo(repo.key))) + const users: { paging: SonarPaging, users: SonarUser[] } = ( + await axiosInstance({ + url: 'users/search', + params: { + q: username, + }, + }) + )?.data + const user = users.users.find(u => u.login === username) + if (!user) { + return { + status: { + result: 'OK', + message: 'Already missing', + }, + } + } + await axiosInstance({ + url: 'users/deactivate', + params: { + login: username, + anonymize: true, + }, + method: 'post', + }) + logger.info( + { action: 'deleteProject', projectSlug, outcome: 'user-anonymized' }, + 'Hook done', + ) + return { + status: { + result: 'OK', + message: 'User anonymized', + }, + } + } catch (error) { + logger.error( + { action: 'deleteProject', projectSlug, err: error }, + 'Hook failed', + ) + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +async function deleteRepo(projectKey: string) { + return getAxiosInstance()({ + url: 'projects/delete', + params: { + project: projectKey, + }, + method: 'post', + }) +} + +async function ensureUserAndVault( + vaultApi: VaultProjectApi, + username: string, + projectSlug: string, +) { + const vaultUserSecret = (await vaultApi.read('SONAR', { + throwIfNoEntry: false, + })) as VaultSonarSecret | undefined + const newUserSecret = await ensureUserExists( + username, + projectSlug, + vaultUserSecret, + ) + if (newUserSecret) await vaultApi.write(newUserSecret, 'SONAR') +} diff --git a/.jjconflict-side-0/plugins/sonarqube/src/group.ts b/.jjconflict-side-0/plugins/sonarqube/src/group.ts new file mode 100644 index 0000000000..08ae823069 --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/src/group.ts @@ -0,0 +1,35 @@ +import type { SonarPaging } from './project.js' +import { getAxiosInstance } from './tech.js' + +export interface SonarGroup { + id: string + name: string + description: string + membersCount: number + default: boolean +} + +export async function findGroupByName(name: string): Promise { + const axiosInstance = getAxiosInstance() + const groupsSearch: { paging: SonarPaging, groups: SonarGroup[] } = (await axiosInstance({ + url: 'user_groups/search', + params: { + q: name, + }, + }))?.data + return groupsSearch.groups.find(g => g.name === name) +} + +export async function ensureGroupExists(groupName: string) { + const axiosInstance = getAxiosInstance() + const group = await findGroupByName(groupName) + if (!group) { + await axiosInstance({ + url: 'user_groups/create', + method: 'post', + params: { + name: groupName, + }, + }) + } +} diff --git a/.jjconflict-side-0/plugins/sonarqube/src/index.ts b/.jjconflict-side-0/plugins/sonarqube/src/index.ts new file mode 100644 index 0000000000..a05d49e210 --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/src/index.ts @@ -0,0 +1,37 @@ +import type { HookStepsNames, Plugin } from '@cpn-console/hooks' +import { getStatus } from './check.js' +import { deleteProject, initSonar, setVariables, upsertProject } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +function start(_options: unknown) { + try { + initSonar() + getStatus() + } catch {} +} + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { + steps: { + main: upsertProject, + post: setVariables, + }, + }, + deleteProject: { + steps: { + main: deleteProject, + }, + }, + }, + start, + monitor, +} + +declare module '@cpn-console/hooks' { + interface PluginResult { + errors?: Partial> + } +} diff --git a/.jjconflict-side-0/plugins/sonarqube/src/infos.ts b/.jjconflict-side-0/plugins/sonarqube/src/infos.ts new file mode 100644 index 0000000000..94ea6616cc --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/src/infos.ts @@ -0,0 +1,12 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { getConfig } from './tech.js' + +const infos: ServiceInfos = { + name: 'sonarqube', + to: () => `${getConfig().url}/projects`, + title: 'SonarQube', + imgSrc: '/img/sonarqube.svg', + description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', +} + +export default infos diff --git a/.jjconflict-side-0/plugins/sonarqube/src/logger.ts b/.jjconflict-side-0/plugins/sonarqube/src/logger.ts new file mode 100644 index 0000000000..7683fa7961 --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'sonarqube' }) diff --git a/.jjconflict-side-0/plugins/sonarqube/src/monitor.ts b/.jjconflict-side-0/plugins/sonarqube/src/monitor.ts new file mode 100644 index 0000000000..d4fe929477 --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/src/monitor.ts @@ -0,0 +1,41 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import { getAxiosOptions } from './tech.js' + +const statusMap = { + GREEN: MonitorStatus.OK, + YELLOW: MonitorStatus.WARNING, + RED: MonitorStatus.ERROR, +} +const messageMap = { + GREEN: MonitorStatus.OK, + YELLOW: 'Service dégradé', + RED: 'Service en panne', +} +interface SonarRes { + health: keyof typeof statusMap + causes: string[] +} + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get('/system/health', { + validateStatus: res => res === 200, + ...getAxiosOptions(), + }) + const data = res.data as SonarRes + + instance.lastStatus.status = statusMap[data.health] + instance.lastStatus.message = messageMap[data.health] + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-0/plugins/sonarqube/src/project.ts b/.jjconflict-side-0/plugins/sonarqube/src/project.ts new file mode 100644 index 0000000000..16082a8d5e --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/src/project.ts @@ -0,0 +1,160 @@ +import { generateProjectKey } from '@cpn-console/hooks' +import { logger } from './logger.js' +import { getAxiosInstance } from './tech.js' + +export interface SonarPaging { + pageIndex: number + pageSize: number + total: number +} + +export type Qualifiers + = 'BRC' // - Sub - projects + | 'DIR' // - Directories + | 'FIL' // - Files + | 'TRK' // - Projects + | 'UTS' // - Test Files + +export interface SonarProject { + key: string // unique key name + name: string + qualifier: Qualifiers + visibility: 'private' | 'public' + lastAnalysisDate?: string + revision?: string +} + +const robotPermissions = [ + 'user', + 'codeviewer', + 'admin', + 'scan', +] +const groupPermissions = [ + 'user', + 'codeviewer', + 'issueadmin', + 'securityhotspotadmin', + 'scan', +] + +export async function createDsoRepository(projectSlug: string, internalRepoName: string, sonarProjectKey?: string) { + const sonarProjectName = `${projectSlug}-${internalRepoName}` + if (!sonarProjectKey) + sonarProjectKey = generateProjectKey(projectSlug, internalRepoName) + return createProject(sonarProjectKey, sonarProjectName) +} + +export async function ensureRepositoryConfiguration(projectKey: string, login: string, groupName: string) { + const axiosInstance = getAxiosInstance() + + for (const permission of robotPermissions) { + await axiosInstance({ + url: 'permissions/add_user', + method: 'post', + params: { + projectKey, + permission, + login, + }, + }) + } + for (const permission of groupPermissions) { + await axiosInstance({ + url: 'permissions/add_group', + method: 'post', + params: { + projectKey, + permission, + groupName, + }, + }) + } +} + +export async function createProject(projectKey: string, projectName: string) { + return getAxiosInstance()({ + url: 'projects/create', + method: 'post', + params: { + project: projectKey, + visibility: 'private', + name: projectName, + mainbranch: 'main', + }, + }) +} + +export async function deleteDsoRepository(projectKey: string) { + const axiosInstance = getAxiosInstance() + + await axiosInstance({ + url: 'projects/delete', + method: 'post', + params: { + project: projectKey, + }, + }) +} + +interface SonarProjectResult { + projectSlug: string + repository: string + key: string +} + +function filterProjectsOwning(repos: { key: string }[], projectSlug: string): SonarProjectResult[] { + return repos.reduce((acc, repo) => { + let isOrphan = true + + const sonarKey = repo.key + const keyElements = sonarKey.split('-') + keyElements.pop() + for (let i = keyElements.length - 1; i > 0; i--) { + const project = keyElements.slice(0, i).join('-') + const repository = keyElements.slice(i).join('-') + const keyComputed = generateProjectKey(project, repository) + if (keyComputed === sonarKey) { + if (project === projectSlug) { + acc.push({ + projectSlug, + repository, + key: sonarKey, + }) + } + isOrphan = false + } + } + if (isOrphan) { + logger.warn({ sonarKey, projectSlug }, 'Orphan SonarQube project key does not match expected pattern') + } + return acc + }, [] as SonarProjectResult[]) +} + +export async function findSonarProjectsForDsoProjects(projectSlug: string) { + const axiosInstance = getAxiosInstance() + let foundProjectKeys: SonarProjectResult[] = [] + + let page = 0 + const pageSize = 100 + let total = 0 + do { + page++ + const similarProjects = await axiosInstance.get('projects/search', { + params: { + q: projectSlug, + p: page, + ps: pageSize, + }, + }) + total = similarProjects.data.paging.total + foundProjectKeys = [...foundProjectKeys, ...filterProjectsOwning(similarProjects.data.components, projectSlug)] + } while (page * pageSize < total) + + return foundProjectKeys +} + +export const files = { + 'sonar-project.properties': (key: string) => `sonar.projectKey=${key}\nsonar.qualitygate.wait=true\n`, +} diff --git a/.jjconflict-side-0/plugins/sonarqube/src/tech.ts b/.jjconflict-side-0/plugins/sonarqube/src/tech.ts new file mode 100644 index 0000000000..e820d8d381 --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/src/tech.ts @@ -0,0 +1,39 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +const config: { + url?: string + internalUrl?: string + user?: string + password?: string +} = {} + +export function getConfig(): Required { + config.url = config.url ?? removeTrailingSlash(requiredEnv('SONARQUBE_URL')) + config.internalUrl = config.internalUrl ?? removeTrailingSlash(requiredEnv('SONARQUBE_INTERNAL_URL')) + config.user = config.user ?? requiredEnv('SONAR_API_TOKEN') + // @ts-ignore + return config +} +export function getAxiosOptions() { + return { + baseURL: `${getConfig().internalUrl}/api/`, + auth: { + username: getConfig().user, + password: '', // Token is used, so password is useless + }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } +} + +export function getAxiosInstance() { + return axios.create(getAxiosOptions()) +} + +export interface VaultSonarSecret { + SONAR_USERNAME: string + SONAR_PASSWORD: string + SONAR_TOKEN: string +} diff --git a/.jjconflict-side-0/plugins/sonarqube/src/user.ts b/.jjconflict-side-0/plugins/sonarqube/src/user.ts new file mode 100644 index 0000000000..0dae43617d --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/src/user.ts @@ -0,0 +1,101 @@ +import type { SonarPaging } from './project.js' +import type { VaultSonarSecret } from './tech.js' +// import type { StepCall, ArchiveProjectExecArgs } from '@cpn-console/hooks' +import { generateRandomPassword } from '@cpn-console/hooks' +import { getAxiosInstance } from './tech.js' + +export interface SonarUser { + login: string + name: string + active: boolean + email: string + groups: string[] + tokensCount: number + local: boolean + externalIdentity: string + externalProvider: string + avatar: string + lastConnectionDate: Date + managed: boolean + sonarLintLastConnectionDate: Date +} + +export async function createUser(username: string, projectSlug: string) { + const axiosInstance = getAxiosInstance() + const fakeEmail = `${projectSlug}@${projectSlug}` + + const newPwd = generateRandomPassword(30) + await axiosInstance({ + url: 'users/create', + method: 'post', + params: { + email: fakeEmail, + local: 'true', + login: username, + name: username, + password: newPwd, + }, + }) + return newPwd +} + +export async function changeToken(username: string) { + const axiosInstance = getAxiosInstance() + await axiosInstance({ + url: 'user_tokens/revoke', + method: 'post', + params: { + login: username, + name: `Sonar Token for ${username}`, + }, + }) + const newToken = await axiosInstance({ + url: 'user_tokens/generate', + method: 'post', + params: { + login: username, + name: `Sonar Token for ${username}`, + }, + }) + return newToken.data.token +} + +export async function getUser(username: string): Promise { + const axiosInstance = getAxiosInstance() + let page = 1 + const pageSize = 100 + while (true) { + const response = await axiosInstance({ + url: 'users/search', + params: { + q: username, + ps: pageSize, + p: page, + }, + }) + const users: { paging: SonarPaging, users: SonarUser[] } = response.data + const found = users.users.find(user => user.login === username) + if (found) return found + if (!users.users.length || users.paging.pageIndex * users.paging.pageSize >= users.paging.total) { + break + } + page += 1 + } +} + +export async function ensureUserExists(username: string, projectSlug: string, vaultUserSecret: VaultSonarSecret | undefined): Promise { + const user = await getUser(username) + if (!user) { + return { + SONAR_PASSWORD: await createUser(username, projectSlug), + SONAR_TOKEN: await changeToken(username), + SONAR_USERNAME: username, + } + } else if (!vaultUserSecret) { + return { + SONAR_PASSWORD: 'not initialized', + SONAR_TOKEN: await changeToken(username), + SONAR_USERNAME: username, + } + } +} diff --git a/.jjconflict-side-0/plugins/sonarqube/tsconfig.eslint.json b/.jjconflict-side-0/plugins/sonarqube/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-0/plugins/sonarqube/tsconfig.json b/.jjconflict-side-0/plugins/sonarqube/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-0/plugins/sonarqube/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-0/plugins/vault/.gitignore b/.jjconflict-side-0/plugins/vault/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-0/plugins/vault/README.md b/.jjconflict-side-0/plugins/vault/README.md new file mode 100644 index 0000000000..bc7c4d1ec9 --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/README.md @@ -0,0 +1,14 @@ +# Introduction + +Plugin de gestion du plugin Vault + +## Configuration + +| Env var | valeur possible | description | +| --------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| VAULT_TOKEN | chaine | Root token ou token ayant les pleins droits sur le Vault | +| VAULT_URL | *url* | Url public d'accès au Vault | +| VAULT_INTERNAL_URL | *url* ou vide | Url par laquelle la console interroge le service Vault, si absent utilisation de l'url public | +| VAULT__HIDE_PROJECT_SERVICE | "true" ou vide | Décide si le plugin masque ou non le service dans l'interface du projet. Ne désactive pas la fonctionnalité du store projet seulement l'affichage. | +| VAULT__DISABLE_VAULT_SECRETS | "true" ou vide | Désactiver le déploiement des ressources nécessaires au Vault Secret Operator dans le namespace cible | +| VAULT__DEPLOY_VAULT_CONNECTION_IN_NS | "true" ou vide | Activer le déploiement du vaultconnections dans le namespace utilisateur, si vide ou faux, s'assure que la ressource n'existe pas | diff --git a/.jjconflict-side-0/plugins/vault/eslint.config.js b/.jjconflict-side-0/plugins/vault/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-0/plugins/vault/package.json b/.jjconflict-side-0/plugins/vault/package.json new file mode 100644 index 0000000000..b8fb478904 --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/package.json @@ -0,0 +1,47 @@ +{ + "name": "@cpn-console/vault-plugin", + "type": "module", + "version": "2.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-0/plugins/vault/src/config.ts b/.jjconflict-side-0/plugins/vault/src/config.ts new file mode 100644 index 0000000000..abce2040b0 --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/src/config.ts @@ -0,0 +1,32 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + token: string + projectsRootDir: string + hideProjectService: boolean + disableVaultSecrets: boolean + deployVaultConnectionInNs: boolean + constructor() { + this.token = requiredEnv('VAULT_TOKEN') + this.publicUrl = removeTrailingSlash(requiredEnv('VAULT_URL')) + this.projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') + this.internalUrl = process.env.VAULT_INTERNAL_URL + ? removeTrailingSlash(process.env.VAULT_INTERNAL_URL) + : this.publicUrl + this.hideProjectService = process.env.VAULT__HIDE_PROJECT_SERVICE === 'true' + this.disableVaultSecrets = process.env.VAULT__DISABLE_VAULT_SECRETS === 'true' + this.deployVaultConnectionInNs = process.env.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS === 'true' + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-side-0/plugins/vault/src/functions.ts b/.jjconflict-side-0/plugins/vault/src/functions.ts new file mode 100644 index 0000000000..2e3d98838b --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/src/functions.ts @@ -0,0 +1,112 @@ +import type { + Project, + ProjectLite, + StepCall, + ZoneObject, +} from '@cpn-console/hooks' +import { okStatus } from '@cpn-console/hooks' +import { logger } from './logger.js' + +export const upsertProject: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.Project.upsert() + logger.info({ action: 'upsertProject', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const archiveDsoProject: StepCall = async (payload) => { + try { + if (!payload.apis.vault) throw new Error('no Vault available') + await payload.apis.vault.Project.delete() + const allSecrets = await payload.apis.vault.list('/') + const promisesDestroy = allSecrets.map((path) => { + return payload.apis.vault.destroy(path) + }) + await Promise.all(promisesDestroy) + + logger.info({ action: 'archiveDsoProject', projectSlug: payload.args.slug, secretsDestroyed: allSecrets.length }, 'Hook done') + return { + status: { + result: 'OK', + }, + secretsDestroyed: allSecrets.length, + } + } catch (error) { + logger.error({ action: 'archiveDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const getSecrets: StepCall = async (payload) => { + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + secrets: { + '.spec.mount': `${payload.args.slug}`, + '.spec.vaultAuthRef': 'vault-auth', + }, + } +} +export const upsertZone: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.upsert() + logger.info({ action: 'upsertZone', zoneSlug: payload.args.slug }, 'Hook done') + return okStatus + } catch (error) { + logger.error({ action: 'upsertZone', zoneSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} +export const deleteZone: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.delete() + logger.info({ action: 'deleteZone', zoneSlug: payload.args.slug }, 'Hook done') + return okStatus + } catch (error) { + logger.error({ action: 'deleteZone', zoneSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} diff --git a/.jjconflict-side-0/plugins/vault/src/index.ts b/.jjconflict-side-0/plugins/vault/src/index.ts new file mode 100644 index 0000000000..e73ba4ab91 --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/src/index.ts @@ -0,0 +1,59 @@ +import type { ClusterObject, DefaultArgs, Plugin, Project, ProjectLite, ZoneObject } from '@cpn-console/hooks' +import { archiveDsoProject, deleteZone, getSecrets, upsertProject, upsertZone } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' +import { VaultProjectApi } from './vault-project-api.js' +import { VaultZoneApi } from './vault-zone-api.js' + +const onlyApi = { api: (project: ProjectLite) => new VaultProjectApi(project) } + +export const plugin: Plugin = { + infos, + subscribedHooks: { + getProjectSecrets: { + ...onlyApi, + steps: { + main: getSecrets, + }, + }, + upsertProject: { + ...onlyApi, + steps: { + main: upsertProject, + }, + }, + deleteProject: { + ...onlyApi, + steps: { post: archiveDsoProject }, // Destroy all secrets for project + }, + upsertZone: { + api: (zone: ZoneObject) => new VaultZoneApi(zone.slug), + steps: { + pre: upsertZone, + }, + }, + deleteZone: { + api: (zone: ZoneObject) => new VaultZoneApi(zone.slug), + steps: { + main: deleteZone, + }, + }, + upsertCluster: { + api: (c: ClusterObject) => new VaultZoneApi(c.zone.slug), + }, + deleteCluster: { + api: (c: ClusterObject) => new VaultZoneApi(c.zone.slug), + }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + vault: Args extends (ProjectLite | Project) + ? VaultProjectApi + : Args extends (ZoneObject | ClusterObject) + ? VaultZoneApi + : never + } +} diff --git a/.jjconflict-side-0/plugins/vault/src/infos.ts b/.jjconflict-side-0/plugins/vault/src/infos.ts new file mode 100644 index 0000000000..976f1f4218 --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/src/infos.ts @@ -0,0 +1,14 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import getConfig from './config.js' + +const infos: ServiceInfos = { + name: 'vault', + to: ({ project }) => getConfig().hideProjectService + ? undefined + : `${getConfig().publicUrl}/ui/vault/secrets/${project.slug}`, + title: 'Vault', + imgSrc: '/img/vault.svg', + description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', +} + +export default infos diff --git a/.jjconflict-side-0/plugins/vault/src/logger.ts b/.jjconflict-side-0/plugins/vault/src/logger.ts new file mode 100644 index 0000000000..f596e24a6f --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'vault' }) diff --git a/.jjconflict-side-0/plugins/vault/src/monitor.ts b/.jjconflict-side-0/plugins/vault/src/monitor.ts new file mode 100644 index 0000000000..091b850649 --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/src/monitor.ts @@ -0,0 +1,45 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import getConfig from './config.js' + +const vaultStatusCode = [200, 429, 472, 473, 501, 503] + +type VaultRes = { + initialized: boolean + sealed: boolean + standby: boolean + performance_standby: boolean + replication_performance_mode: string + replication_dr_mode: string + server_time_utc: number + version: string +} | undefined + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(`${getConfig().internalUrl}/v1/sys/health?standbyok=true`, { + headers: { + 'X-Vault-Token': getConfig().token, + }, + validateStatus: res => vaultStatusCode.includes(res), + }) + if (res.status === 200) { + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + } else { + instance.lastStatus.status = MonitorStatus.ERROR + const data = res.data as VaultRes + instance.lastStatus.message = data?.sealed ? 'Le Vault est scellé' : 'Vault en erreur' + } + } catch (error) { + instance.lastStatus.message = 'Error lors la requete' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + // test + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-0/plugins/vault/src/utils.ts b/.jjconflict-side-0/plugins/vault/src/utils.ts new file mode 100644 index 0000000000..d7036913f4 --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/src/utils.ts @@ -0,0 +1,21 @@ +import type { AxiosInstance } from 'axios' + +export async function getAuthMethod(axiosInstance: AxiosInstance, token: string) { + const response = await axiosInstance({ + method: 'get', + url: '/v1/sys/auth', + headers: { 'X-Vault-Token': token }, + }) + return response.data +} + +export const minimumConfig = { + type: 'kv', + options: { + version: 2, + }, +} +export function generateKVConfigUpdate(config: Record): typeof minimumConfig | void { + if (config?.type !== minimumConfig.type) return minimumConfig + if (config?.options?.version !== minimumConfig.options.version) return minimumConfig +} diff --git a/.jjconflict-side-0/plugins/vault/src/vault-api.ts b/.jjconflict-side-0/plugins/vault/src/vault-api.ts new file mode 100644 index 0000000000..a09cc7c338 --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/src/vault-api.ts @@ -0,0 +1,165 @@ +import type { AxiosInstance } from 'axios' +import { PluginApi } from '@cpn-console/hooks' +import axios from 'axios' +import getConfig from './config.js' +import { + generateKVConfigUpdate, +} from './utils.js' + +export interface AppRoleCredentials { + url: string + coreKvName: string + roleId: string + secretId: string +} + +export abstract class VaultApi extends PluginApi { + protected readonly axios: AxiosInstance + private token: string | undefined = undefined + + constructor() { + super() + this.axios = axios.create({ + baseURL: getConfig().internalUrl, + headers: { + 'X-Vault-Token': getConfig().token, + }, + }) + } + + protected async getToken() { + if (!this.token) { + this.token = (await this.axios.post('/v1/auth/token/create')).data.auth + .client_token as string + } + return this.token + } + + protected async destroy(path: string, kvName: string) { + if (path.startsWith('/')) path = path.slice(1) + return await this.axios({ + method: 'delete', + url: `/v1/${kvName}/metadata/${path}`, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + } + + Kv = { + upsert: async (kvName: string) => { + const token = await this.getToken() + const kvRes = await this.axios({ + method: 'get', + url: `/v1/sys/mounts/${kvName}/tune`, + headers: { 'X-Vault-Token': token }, + validateStatus: code => [400, 200].includes(code), + }) + if (kvRes.status === 400) { + await this.axios({ + method: 'post', + url: `/v1/sys/mounts/${kvName}`, + headers: { + 'X-Vault-Token': token, + }, + data: { + type: 'kv', + config: { + force_no_cache: true, + }, + options: { + version: 2, + }, + }, + }) + } else { + // means 200 status + const configUpdate = generateKVConfigUpdate(kvRes.data) + if (configUpdate) { + await this.axios({ + method: 'put', + url: `/v1/sys/mounts/${kvName}/tune`, + headers: { + 'X-Vault-Token': token, + }, + data: configUpdate, + }) + } + } + }, + delete: async (kvName: string) => { + const token = await this.getToken() + return await this.axios({ + method: 'delete', + url: `/v1/sys/mounts/${kvName}`, + headers: { 'X-Vault-Token': token }, + validateStatus: code => [400, 200, 204].includes(code), + }) + }, + } + + Policy = { + upsert: async (policyName: string, policy: string) => { + await this.axios({ + method: 'put', + url: `/v1/sys/policies/acl/${policyName}`, + data: { policy }, + headers: { + 'X-Vault-Token': await this.getToken(), + 'Content-Type': 'application/json', + }, + }) + }, + delete: async (policyName: string) => { + await this.axios({ + method: 'delete', + url: `/v1/sys/policies/acl/${policyName}`, + headers: { + 'X-Vault-Token': await this.getToken(), + 'Content-Type': 'application/json', + }, + }) + }, + } + + Role = { + upsert: async (roleName: string, policies: string[]) => { + await this.axios({ + method: 'post', + url: `/v1/auth/approle/role/${roleName}`, + data: { + secret_id_num_uses: '0', + secret_id_ttl: '0', + token_max_ttl: '0', + token_num_uses: '0', + token_ttl: '0', + token_type: 'batch', + token_policies: policies, + }, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + delete: async (roleName: string) => { + await this.axios.delete(`/v1/auth/approle/role/${roleName}`, { + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + getCredentials: async (roleName: string) => { + const { data: dataRole } = await this.axios.get( + `/v1/auth/approle/role/${roleName}/role-id`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + const { data: dataSecret } = await this.axios.put( + `/v1/auth/approle/role/${roleName}/secret-id`, + 'null', // Force empty data + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + return { + roleId: dataRole.data?.role_id, + secretId: dataSecret.data?.secret_id, + } + }, + } +} diff --git a/.jjconflict-side-0/plugins/vault/src/vault-project-api.ts b/.jjconflict-side-0/plugins/vault/src/vault-project-api.ts new file mode 100644 index 0000000000..45ec6280c9 --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/src/vault-project-api.ts @@ -0,0 +1,207 @@ +import type { ProjectLite } from '@cpn-console/hooks' +import getConfig from './config.js' +import { + getAuthMethod, +} from './utils.js' +import { VaultApi } from './vault-api.js' + +interface ReadOptions { + throwIfNoEntry: boolean +} +export interface AppRoleCredentials { + url: string + coreKvName: string + roleId: string + secretId: string +} + +interface VaultValuesWithoutCredentials { + /** Slash-separated directory (root node of all Gitlab projects) */ + projectsRootDir: string +} + +type VaultValues = AppRoleCredentials & VaultValuesWithoutCredentials + +export class VaultProjectApi extends VaultApi { + private readonly basePath: string + private readonly roleName: string + private readonly projectRootDir: string + private readonly defaultAppRoleCredentials: AppRoleCredentials + private readonly coreKvName: string = 'forge-dso' + private readonly projectKvName: string + private readonly groupName: string + private readonly policyName: { + techRO: string + appFull: string + } + + constructor(project: ProjectLite) { + super() + this.basePath = project.slug + this.roleName = project.slug + this.projectRootDir = getConfig().projectsRootDir + this.projectKvName = project.slug + this.groupName = project.slug + this.policyName = { + techRO: `tech--${project.slug}--ro`, + appFull: `app--${project.slug}--admin`, + } + this.defaultAppRoleCredentials = { + url: getConfig().deployVaultConnectionInNs ? getConfig().publicUrl : '', + coreKvName: this.coreKvName, + roleId: 'none', + secretId: 'none', + } + } + + public async list(path: string = '/'): Promise { + if (!path.startsWith('/')) path = `/${path}` + + const listSecretPath: string[] = [] + const response = await this.axios({ + url: `/v1/${this.coreKvName}/metadata/${this.projectRootDir}/${this.basePath}${path}/`, + headers: { + 'X-Vault-Token': await this.getToken(), + }, + method: 'list', + validateStatus: code => [200, 404].includes(code), + }) + + if (response.status === 404) return listSecretPath + for (const key of response.data.data.keys) { + if (key.endsWith('/')) { + const subSecrets = await this.list( + `${path.substring(this.basePath.length)}/${key}`, + ) + subSecrets.forEach((secret) => { + listSecretPath.push(`${key}${secret}`) + }) + } else { + listSecretPath.push(`/${key}`) + } + } + return listSecretPath.flat() + } + + public async read( + path: string = '/', + options: ReadOptions = { throwIfNoEntry: true }, + ) { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.get( + `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + validateStatus: status => + (options.throwIfNoEntry ? [200] : [200, 404]).includes(status), + }, + ) + return response.data.data + } + + public async write(body: object, path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.post( + `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + data: body, + }, + ) + return await response.data + } + + public async destroy(path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + return super.destroy( + `${this.projectRootDir}/${this.basePath}/${path}`, + this.coreKvName, + ) + } + + Project = { + upsert: async () => { + await this.Kv.upsert(this.projectKvName) + await this.Policy.upsert( + this.policyName.appFull, + `path "${this.projectKvName}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + ) + await this.Policy.upsert( + this.policyName.techRO, + `path "${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + ) + await this.Group.upsert() + await this.Role.upsert(this.roleName, [ + this.policyName.techRO, + this.policyName.appFull, + ]) + }, + delete: async () => { + await this.Kv.delete(this.projectKvName) + await this.Policy.delete(this.policyName.appFull) + await this.Policy.delete(this.policyName.techRO) + await this.Group.delete() + await this.Role.delete(this.roleName) + }, + getCredentials: async (): Promise => { + const creds = await this.Role.getCredentials(this.roleName) + return { + ...this.defaultAppRoleCredentials, + ...creds, + } + }, + getValues: async (): Promise => { + return { + projectsRootDir: getConfig().projectsRootDir, + ...(await this.Project.getCredentials()), + } + }, + } + + Group = { + upsert: async () => { + // TODO check api responses for POST and GET maybe duplicates + await this.axios({ + method: 'post', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + data: { + name: this.groupName, + type: 'external', + policies: [this.policyName.appFull], + }, + }) + + const response = await this.axios({ + method: 'get', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + validateStatus: code => [404, 200].includes(code), + }) + const group = response.data + const groupAliasName = `/${this.groupName}` + if (group.data.alias?.name === groupAliasName) { + return + } + const methods = await getAuthMethod(this.axios, await this.getToken()) + + await this.axios({ + url: `/v1/identity/group-alias`, + method: 'post', + headers: { 'X-Vault-Token': await this.getToken() }, + data: { + name: groupAliasName, + mount_accessor: methods['oidc/'].accessor, + canonical_id: group.data.id, + }, + }) + }, + delete: async () => { + await this.axios({ + method: 'delete', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + } +} diff --git a/.jjconflict-side-0/plugins/vault/src/vault-zone-api.ts b/.jjconflict-side-0/plugins/vault/src/vault-zone-api.ts new file mode 100644 index 0000000000..378d393083 --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/src/vault-zone-api.ts @@ -0,0 +1,63 @@ +import getConfig from './config.js' +import { VaultApi } from './vault-api.js' + +interface VaultCredentials { + url: string + kvName: string + roleId: string + secretId: string +} +type VaultValues = VaultCredentials + +export class VaultZoneApi extends VaultApi { + private readonly kvName: string + private readonly policyName: string + private readonly roleName: string + constructor(name: string) { + super() + this.kvName = `zone-${name}` + this.policyName = `tech--${this.kvName}--ro` + this.roleName = `zone-${name}` + } + + public async upsert() { + await this.Kv.upsert(this.kvName) + await this.Policy.upsert( + this.policyName, + `path "${this.kvName}/*" { capabilities = ["read"] }`, + ) + await this.Role.upsert(this.roleName, [this.policyName]) + } + + public async delete() { + await this.Kv.delete(this.kvName) + await this.Policy.delete(this.policyName) + await this.Role.delete(this.roleName) + } + + public async write(body: object, path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.post(`/v1/${this.kvName}/data/${path}`, { + headers: { 'X-Vault-Token': await this.getToken() }, + data: body, + }) + return await response.data + } + + public async destroy(path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + return super.destroy(path, this.kvName) + } + + public async getCredentials(): Promise { + return { + url: getConfig().publicUrl, + kvName: this.kvName, + ...(await this.Role.getCredentials(this.roleName)), + } as VaultCredentials + } + + public async getValues(): Promise { + return this.getCredentials() + } +} diff --git a/.jjconflict-side-0/plugins/vault/src/vso.ts b/.jjconflict-side-0/plugins/vault/src/vso.ts new file mode 100644 index 0000000000..579eb8d8ff --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/src/vso.ts @@ -0,0 +1,55 @@ +import type { AppRoleCredentials } from './vault-project-api.js' + +export function generateVsoVaultConnection(creds: AppRoleCredentials) { + return { + apiVersion: 'secrets.hashicorp.com/v1beta1', + kind: 'VaultConnection', + metadata: { + name: 'default', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + spec: { + address: creds.url, + }, + } +} + +export function generateVsoSecret(creds: AppRoleCredentials) { + return { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: 'vso-approle', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + stringData: { + id: creds.secretId, + }, + } +} +export function generateVaultAuth(creds: AppRoleCredentials, vaultConnectionRef: string | null = null) { + return { + apiVersion: 'secrets.hashicorp.com/v1beta1', + kind: 'VaultAuth', + metadata: { + name: 'vault-auth', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + spec: { + vaultConnectionRef, + method: 'appRole', + mount: 'approle', + appRole: { + roleId: creds.roleId, + secretRef: 'vso-approle', + }, + allowedNamespaces: null, + }, + } +} diff --git a/.jjconflict-side-0/plugins/vault/tsconfig.eslint.json b/.jjconflict-side-0/plugins/vault/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-0/plugins/vault/tsconfig.json b/.jjconflict-side-0/plugins/vault/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-0/plugins/vault/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-0/pnpm-lock.yaml b/.jjconflict-side-0/pnpm-lock.yaml new file mode 100644 index 0000000000..4265e63be7 --- /dev/null +++ b/.jjconflict-side-0/pnpm-lock.yaml @@ -0,0 +1,18500 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + injectWorkspacePackages: true + +catalogs: + build: + '@iconify/types': + specifier: ^2.0.0 + version: 2.0.0 + '@nestjs/cli': + specifier: ^11.0.16 + version: 11.0.16 + '@nestjs/schematics': + specifier: ^11.0.9 + version: 11.0.9 + '@nestjs/testing': + specifier: ^11.1.16 + version: 11.1.16 + '@unocss/transformer-directives': + specifier: ^0.65.4 + version: 0.65.4 + '@vitejs/plugin-vue': + specifier: ^6.0.4 + version: 6.0.4 + '@vue/tsconfig': + specifier: ^0.7.0 + version: 0.7.0 + fastify-plugin: + specifier: ^5.1.0 + version: 5.1.0 + swagger-typescript-api: + specifier: ^13.3.1 + version: 13.3.1 + ts-patch: + specifier: ^3.3.0 + version: 3.3.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-transform-paths: + specifier: ^3.5.6 + version: 3.5.6 + unocss: + specifier: ^66.6.6 + version: 66.6.6 + unplugin-auto-import: + specifier: ^0.18.6 + version: 0.18.6 + unplugin-vue-components: + specifier: ^0.27.5 + version: 0.27.5 + vite: + specifier: ^7.3.2 + version: 7.3.3 + vite-node: + specifier: ^6.0.0 + version: 6.0.0 + vite-plugin-pwa: + specifier: ^1.2.0 + version: 1.2.0 + vue-tsc: + specifier: ^2.2.12 + version: 2.2.12 + workbox-window: + specifier: ^7.4.0 + version: 7.4.0 + otel: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@opentelemetry/auto-instrumentations-node': + specifier: ^0.70.1 + version: 0.70.1 + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0 + '@opentelemetry/exporter-trace-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0 + '@opentelemetry/instrumentation-nestjs-core': + specifier: ^0.58.0 + version: 0.58.0 + '@opentelemetry/instrumentation-pino': + specifier: ^0.59.0 + version: 0.59.0 + '@opentelemetry/sdk-metrics': + specifier: ^2.5.1 + version: 2.6.0 + '@opentelemetry/sdk-node': + specifier: ^0.212.0 + version: 0.212.0 + runtime: + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 + '@fastify/helmet': + specifier: ^13.0.2 + version: 13.0.2 + '@fastify/otel': + specifier: ^0.18.1 + version: 0.18.1 + '@fastify/session': + specifier: ^11.1.1 + version: 11.1.1 + '@fastify/swagger': + specifier: ^9.7.0 + version: 9.7.0 + '@fastify/swagger-ui': + specifier: ^5.2.6 + version: 5.2.6 + '@gitbeaker/core': + specifier: ^40.6.0 + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: ^40.6.0 + version: 40.6.0 + '@gitbeaker/rest': + specifier: ^40.6.0 + version: 40.6.0 + '@gouvfr/dsfr': + specifier: ^1.14.4 + version: 1.14.4 + '@gouvminint/vue-dsfr': + specifier: ^8.15.0 + version: 8.15.0 + '@himenon/argocd-typescript-openapi': + specifier: ^1.2.2 + version: 1.2.2 + '@iconify-json/ri': + specifier: ^1.2.10 + version: 1.2.10 + '@iconify/vue': + specifier: ^4.3.0 + version: 4.3.0 + '@keycloak/keycloak-admin-client': + specifier: ^26.5.5 + version: 26.7.0 + '@kubernetes-models/argo-cd': + specifier: ^2.7.2 + version: 2.7.2 + '@nestjs/cache-manager': + specifier: ^3.1.2 + version: 3.1.3 + '@nestjs/common': + specifier: ^11.1.16 + version: 11.1.16 + '@nestjs/config': + specifier: ^4.0.3 + version: 4.0.3 + '@nestjs/core': + specifier: ^11.1.16 + version: 11.1.16 + '@nestjs/event-emitter': + specifier: ^3.0.1 + version: 3.0.1 + '@nestjs/jwt': + specifier: ^11.0.2 + version: 11.0.2 + '@nestjs/platform-fastify': + specifier: ^11.1.16 + version: 11.1.27 + '@nestjs/schedule': + specifier: ^6.1.1 + version: 6.1.1 + '@nestjs/swagger': + specifier: ^11.4.1 + version: 11.4.1 + '@nestjs/terminus': + specifier: ^11.1.1 + version: 11.1.1 + '@prisma/client': + specifier: ^6.19.2 + version: 6.19.2 + '@ts-rest/core': + specifier: ^3.52.1 + version: 3.52.1 + '@ts-rest/fastify': + specifier: ^3.52.1 + version: 3.52.1 + '@ts-rest/open-api': + specifier: ^3.52.1 + version: 3.52.1 + axios: + specifier: ^1.15.1 + version: 1.16.0 + bytes: + specifier: ^3.1.2 + version: 3.1.2 + cache-manager: + specifier: ^7.2.8 + version: 7.2.8 + cron-validator: + specifier: ^1.4.0 + version: 1.4.0 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + fastify: + specifier: ^5.8.5 + version: 5.8.5 + fastify-keycloak-adapter: + specifier: ^3.0.0 + version: 3.0.2 + javascript-time-ago: + specifier: ^2.6.4 + version: 2.6.4 + json-2-csv: + specifier: ^5.5.10 + version: 5.5.10 + json-schema: + specifier: ^0.4.0 + version: 0.4.0 + jszip: + specifier: ^3.10.1 + version: 3.10.1 + keycloak-js: + specifier: ^26.2.3 + version: 26.2.3 + mustache: + specifier: ^4.2.0 + version: 4.2.0 + nanoid: + specifier: 5.0.9 + version: 5.0.9 + nestjs-pino: + specifier: ^4.6.0 + version: 4.6.0 + p-debounce: + specifier: ^4.0.0 + version: 4.0.0 + pinia: + specifier: ^2.3.1 + version: 2.3.1 + prisma: + specifier: ^6.19.2 + version: 6.19.2 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + short-uuid: + specifier: ^5.2.0 + version: 5.2.0 + undici: + specifier: ^7.24.0 + version: 7.24.5 + vitest-mock-extended: + specifier: ^2.0.2 + version: 2.0.2 + vue: + specifier: ^3.5.30 + version: 3.5.30 + vue-router: + specifier: ^4.6.4 + version: 4.6.4 + vue3-json-viewer: + specifier: ^2.4.1 + version: 2.4.1 + xbytes: + specifier: ^1.9.1 + version: 1.9.1 + yaml: + specifier: ^2.7.1 + version: 2.9.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 + zod-validation-error: + specifier: ^3.5.4 + version: 3.5.4 + test: + '@faker-js/faker': + specifier: ^9.9.0 + version: 9.9.0 + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 + '@vitest/coverage-v8': + specifier: ^4.1.5 + version: 4.1.5 + globals: + specifier: ^16.5.0 + version: 16.5.0 + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + msw: + specifier: ^2.12.10 + version: 2.12.10 + vitest: + specifier: ^4.1.5 + version: 4.1.5 + tools: + '@antfu/eslint-config': + specifier: ^8.2.0 + version: 8.2.0 + '@commitlint/cli': + specifier: ^20.5.3 + version: 20.5.3 + '@commitlint/config-conventional': + specifier: ^20.5.3 + version: 20.5.3 + '@eslint/eslintrc': + specifier: ^3.3.5 + version: 3.3.5 + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 + '@vue/eslint-config-typescript': + specifier: ^14.7.0 + version: 14.7.0 + chalk: + specifier: ^5.6.2 + version: 5.6.2 + eslint: + specifier: ^10.5.0 + version: 10.5.0 + eslint-plugin-vue: + specifier: ^10.8.0 + version: 10.8.0 + husky: + specifier: ^9.1.7 + version: 9.1.7 + js-yaml: + specifier: ^4.1.0 + version: 4.2.0 + lint-staged: + specifier: ^16.4.0 + version: 16.4.0 + nodemon: + specifier: ^3.1.14 + version: 3.1.14 + pino: + specifier: ^9.14.0 + version: 9.14.0 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + rimraf: + specifier: ^6.1.3 + version: 6.1.3 + stylelint: + specifier: ^17.11.0 + version: 17.11.0 + stylelint-config-html: + specifier: ^1.1.0 + version: 1.1.0 + stylelint-config-recommended-vue: + specifier: ^1.6.1 + version: 1.6.1 + stylelint-config-standard: + specifier: ^40.0.0 + version: 40.0.0 + typescript-eslint: + specifier: ^8.57.0 + version: 8.57.0 + vue-eslint-parser: + specifier: ^10.4.0 + version: 10.4.0 + types: + '@types/jsdom': + specifier: ^21.1.7 + version: 21.1.7 + '@types/json-schema': + specifier: ^7.0.15 + version: 7.0.15 + '@types/node': + specifier: ^24.12.0 + version: 24.12.0 + undici-types: + specifier: ^7.16.0 + version: 7.16.0 + +importers: + + .: + devDependencies: + '@commitlint/cli': + specifier: catalog:tools + version: 20.5.3(@types/node@24.12.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3) + '@commitlint/config-conventional': + specifier: catalog:tools + version: 20.5.3 + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:packages/eslintconfig + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + husky: + specifier: catalog:tools + version: 9.1.7 + lint-staged: + specifier: catalog:tools + version: 16.4.0 + + apps/client: + dependencies: + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@gouvfr/dsfr': + specifier: catalog:runtime + version: 1.14.4 + '@gouvminint/vue-dsfr': + specifier: catalog:runtime + version: 8.15.0(@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3)))(vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)) + '@iconify-json/ri': + specifier: catalog:runtime + version: 1.2.10 + '@iconify/vue': + specifier: catalog:runtime + version: 4.3.0(vue@3.5.30(typescript@5.9.3)) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@vue/tsconfig': + specifier: catalog:build + version: 0.7.0(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + axios: + specifier: catalog:runtime + version: 1.16.0 + javascript-time-ago: + specifier: catalog:runtime + version: 2.6.4 + jszip: + specifier: catalog:runtime + version: 3.10.1 + keycloak-js: + specifier: catalog:runtime + version: 26.2.3 + nanoid: + specifier: catalog:runtime + version: 5.0.9 + p-debounce: + specifier: catalog:runtime + version: 4.0.0 + pinia: + specifier: catalog:runtime + version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + vue: + specifier: catalog:runtime + version: 3.5.30(typescript@5.9.3) + vue-router: + specifier: catalog:runtime + version: 4.6.4(vue@3.5.30(typescript@5.9.3)) + vue3-json-viewer: + specifier: catalog:runtime + version: 2.4.1(vue@3.5.30(typescript@5.9.3)) + xbytes: + specifier: catalog:runtime + version: 1.9.1 + yaml: + specifier: catalog:runtime + version: 2.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@iconify/types': + specifier: catalog:build + version: 2.0.0 + '@types/jsdom': + specifier: catalog:types + version: 21.1.7 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@unocss/transformer-directives': + specifier: catalog:build + version: 0.65.4 + '@vitejs/plugin-vue': + specifier: catalog:build + version: 6.0.4(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3)) + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + '@vue/eslint-config-typescript': + specifier: catalog:tools + version: 14.7.0(eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + chalk: + specifier: catalog:tools + version: 5.6.2 + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + eslint-plugin-vue: + specifier: catalog:tools + version: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + jsdom: + specifier: catalog:test + version: 25.0.1 + rimraf: + specifier: catalog:tools + version: 6.1.3 + stylelint: + specifier: catalog:tools + version: 17.11.0(typescript@5.9.3) + stylelint-config-html: + specifier: catalog:tools + version: 1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-recommended-vue: + specifier: catalog:tools + version: 1.6.1(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-standard: + specifier: catalog:tools + version: 40.0.0(stylelint@17.11.0(typescript@5.9.3)) + typescript: + specifier: catalog:build + version: 5.9.3 + unocss: + specifier: catalog:build + version: 66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + unplugin-auto-import: + specifier: catalog:build + version: 0.18.6(rollup@2.80.0) + unplugin-vue-components: + specifier: catalog:build + version: 0.27.5(@babel/parser@7.29.7)(rollup@2.80.0)(vue@3.5.30(typescript@5.9.3)) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vite-plugin-pwa: + specifier: catalog:build + version: 1.2.0(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + vue-eslint-parser: + specifier: catalog:tools + version: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + vue-tsc: + specifier: catalog:build + version: 2.2.12(typescript@5.9.3) + workbox-window: + specifier: catalog:build + version: 7.4.0 + + apps/server: + dependencies: + '@cpn-console/argocd-plugin': + specifier: workspace:^ + version: link:../../plugins/argocd + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../../plugins/gitlab + '@cpn-console/harbor-plugin': + specifier: workspace:^ + version: link:../../plugins/harbor + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../../plugins/keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/nexus-plugin': + specifier: workspace:^ + version: link:../../plugins/nexus + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/sonarqube-plugin': + specifier: workspace:^ + version: link:../../plugins/sonarqube + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../../plugins/vault + '@fastify/cookie': + specifier: catalog:runtime + version: 11.0.2 + '@fastify/helmet': + specifier: catalog:runtime + version: 13.0.2 + '@fastify/otel': + specifier: catalog:runtime + version: 0.18.1(@opentelemetry/api@1.9.1) + '@fastify/session': + specifier: catalog:runtime + version: 11.1.1 + '@fastify/swagger': + specifier: catalog:runtime + version: 9.7.0 + '@fastify/swagger-ui': + specifier: catalog:runtime + version: 5.2.6 + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + '@kubernetes-models/argo-cd': + specifier: catalog:runtime + version: 2.7.2 + '@opentelemetry/auto-instrumentations-node': + specifier: catalog:otel + version: 0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': + specifier: catalog:otel + version: 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': + specifier: catalog:otel + version: 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: catalog:otel + version: 0.212.0(@opentelemetry/api@1.9.1) + '@prisma/client': + specifier: catalog:runtime + version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@ts-rest/fastify': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76) + '@ts-rest/open-api': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76) + axios: + specifier: catalog:runtime + version: 1.16.0 + date-fns: + specifier: catalog:runtime + version: 4.1.0 + fastify: + specifier: catalog:runtime + version: 5.8.5 + fastify-keycloak-adapter: + specifier: catalog:runtime + version: 3.0.2 + json-2-csv: + specifier: catalog:runtime + version: 5.5.10 + mustache: + specifier: catalog:runtime + version: 4.2.0 + prisma: + specifier: catalog:runtime + version: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + undici: + specifier: catalog:runtime + version: 7.24.5 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + fastify-plugin: + specifier: catalog:build + version: 5.1.0 + nodemon: + specifier: catalog:tools + version: 3.1.14 + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + rimraf: + specifier: catalog:tools + version: 6.1.3 + ts-patch: + specifier: catalog:build + version: 3.3.0 + typescript: + specifier: catalog:build + version: 5.9.3 + typescript-transform-paths: + specifier: catalog:build + version: 3.5.6(typescript@5.9.3) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + apps/server-nestjs: + dependencies: + '@cpn-console/argocd-plugin': + specifier: workspace:^ + version: link:../../plugins/argocd + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../../plugins/gitlab + '@cpn-console/harbor-plugin': + specifier: workspace:^ + version: link:../../plugins/harbor + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../../plugins/keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/nexus-plugin': + specifier: workspace:^ + version: link:../../plugins/nexus + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/sonarqube-plugin': + specifier: workspace:^ + version: link:../../plugins/sonarqube + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../../plugins/vault + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + '@keycloak/keycloak-admin-client': + specifier: catalog:runtime + version: 26.7.0 + '@kubernetes-models/argo-cd': + specifier: catalog:runtime + version: 2.7.2 + '@nestjs/cache-manager': + specifier: catalog:runtime + version: 3.1.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2) + '@nestjs/common': + specifier: catalog:runtime + version: 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: catalog:runtime + version: 4.0.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: catalog:runtime + version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter': + specifier: catalog:runtime + version: 3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/jwt': + specifier: catalog:runtime + version: 11.0.2(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/platform-fastify': + specifier: catalog:runtime + version: 11.1.27(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/schedule': + specifier: catalog:runtime + version: 6.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/swagger': + specifier: catalog:runtime + version: 11.4.1(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2) + '@nestjs/terminus': + specifier: catalog:runtime + version: 11.1.1(@grpc/grpc-js@1.14.3)(@grpc/proto-loader@0.8.0)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@opentelemetry/api': + specifier: catalog:otel + version: 1.9.1 + '@opentelemetry/auto-instrumentations-node': + specifier: catalog:otel + version: 0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': + specifier: catalog:otel + version: 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': + specifier: catalog:otel + version: 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: catalog:otel + version: 0.212.0(@opentelemetry/api@1.9.1) + '@prisma/client': + specifier: catalog:runtime + version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@ts-rest/fastify': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76) + '@ts-rest/open-api': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76) + cache-manager: + specifier: catalog:runtime + version: 7.2.8 + date-fns: + specifier: catalog:runtime + version: 4.1.0 + fastify: + specifier: catalog:runtime + version: 5.8.5 + fastify-keycloak-adapter: + specifier: catalog:runtime + version: 3.0.2 + json-2-csv: + specifier: catalog:runtime + version: 5.5.10 + mustache: + specifier: catalog:runtime + version: 4.2.0 + nestjs-pino: + specifier: catalog:runtime + version: 4.6.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) + prisma: + specifier: catalog:runtime + version: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + reflect-metadata: + specifier: catalog:runtime + version: 0.2.2 + rxjs: + specifier: catalog:runtime + version: 7.8.2 + undici: + specifier: catalog:runtime + version: 7.24.5 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + yaml: + specifier: catalog:runtime + version: 2.9.0 + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@eslint/eslintrc': + specifier: catalog:tools + version: 3.3.5 + '@eslint/js': + specifier: catalog:tools + version: 9.39.4 + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@nestjs/cli': + specifier: catalog:build + version: 11.0.16(@types/node@24.12.0)(esbuild@0.27.3)(lightningcss@1.32.0) + '@nestjs/schematics': + specifier: catalog:build + version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) + '@nestjs/testing': + specifier: catalog:build + version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16) + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + globals: + specifier: catalog:test + version: 16.5.0 + msw: + specifier: catalog:test + version: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + rimraf: + specifier: catalog:tools + version: 6.1.3 + ts-patch: + specifier: catalog:build + version: 3.3.0 + typescript: + specifier: catalog:build + version: 5.9.3 + typescript-eslint: + specifier: catalog:tools + version: 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + packages/eslintconfig: + devDependencies: + '@antfu/eslint-config': + specifier: catalog:tools + version: 8.2.0(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5) + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + + packages/hooks: + dependencies: + '@cpn-console/logger': + specifier: workspace:^ + version: link:../logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../shared + json-schema: + specifier: catalog:runtime + version: 0.4.0 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/json-schema': + specifier: catalog:types + version: 7.0.15 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + undici-types: + specifier: catalog:types + version: 7.16.0 + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + + packages/logger: + dependencies: + pino: + specifier: catalog:tools + version: 9.14.0 + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + + packages/shared: + dependencies: + '@cpn-console/logger': + specifier: workspace:* + version: link:../logger + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + short-uuid: + specifier: catalog:runtime + version: 5.2.0 + zod: + specifier: catalog:runtime + version: 3.25.76 + zod-validation-error: + specifier: catalog:runtime + version: 3.5.4(zod@3.25.76) + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + packages/test-utils: + dependencies: + '@cpn-console/shared': + specifier: workspace:^ + version: link:../shared + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + + packages/tsconfig: {} + + playwright: + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@playwright/test': + specifier: catalog:test + version: 1.59.1 + '@types/node': + specifier: catalog:types + version: 24.12.0 + + plugins/argocd: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@himenon/argocd-typescript-openapi': + specifier: catalog:runtime + version: 1.2.2 + axios: + specifier: catalog:runtime + version: 1.16.0 + yaml: + specifier: catalog:runtime + version: 2.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/gitlab: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/harbor: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + bytes: + specifier: catalog:runtime + version: 3.1.2 + cron-validator: + specifier: catalog:runtime + version: 1.4.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + js-yaml: + specifier: catalog:tools + version: 4.2.0 + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + swagger-typescript-api: + specifier: catalog:build + version: 13.3.1(magicast@0.5.3) + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/keycloak: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@keycloak/keycloak-admin-client': + specifier: catalog:runtime + version: 26.7.0 + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/nexus: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/sonarqube: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/vault: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + +packages: + + '@anatine/zod-openapi@1.14.2': + resolution: {integrity: sha512-q0qHfnuNYVKu0Swrnnvfj9971AEyW7c8v9jCOZGCl5ZbyGMNG4RPyJkRcMi/JC8CRfdOe0IDfNm1nNsi2avprg==} + peerDependencies: + openapi3-ts: ^2.0.0 || ^3.0.0 + zod: ^3.20.0 + + '@angular-devkit/core@19.2.17': + resolution: {integrity: sha512-Ah008x2RJkd0F+NLKqIpA34/vUGwjlprRCkvddjDopAWRzYn6xCkz1Tqwuhn0nR1Dy47wTLKYD999TYl5ONOAQ==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/core@19.2.19': + resolution: {integrity: sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/schematics-cli@19.2.19': + resolution: {integrity: sha512-7q9UY6HK6sccL9F3cqGRUwKhM7b/XfD2YcVaZ2WD7VMaRlRm85v6mRjSrfKIAwxcQU0UK27kMc79NIIqaHjzxA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + hasBin: true + + '@angular-devkit/schematics@19.2.17': + resolution: {integrity: sha512-ADfbaBsrG8mBF6Mfs+crKA/2ykB8AJI50Cv9tKmZfwcUcyAdmTr+vVvhsBCfvUAEokigSsgqgpYxfkJVxhJYeg==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@angular-devkit/schematics@19.2.19': + resolution: {integrity: sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@antfu/eslint-config@8.2.0': + resolution: {integrity: sha512-spfwYXMNrlkl69riTSBnbC0C2K8EVfVMOK3ceP2EpAAioyfprIW1gTwyLRtd9jZSFeNdX4mFNAIG+o0sOneOfA==} + hasBin: true + peerDependencies: + '@angular-eslint/eslint-plugin': ^21.1.0 + '@angular-eslint/eslint-plugin-template': ^21.1.0 + '@angular-eslint/template-parser': ^21.1.0 + '@eslint-react/eslint-plugin': ^3.0.0 + '@next/eslint-plugin-next': '>=15.0.0' + '@prettier/plugin-xml': ^3.4.1 + '@unocss/eslint-plugin': '>=0.50.0' + astro-eslint-parser: ^1.0.2 + eslint: ^9.10.0 || ^10.0.0 + eslint-plugin-astro: ^1.2.0 + eslint-plugin-format: '>=0.1.0' + eslint-plugin-jsx-a11y: '>=6.10.2' + eslint-plugin-react-refresh: ^0.5.0 + eslint-plugin-solid: ^0.14.3 + eslint-plugin-svelte: '>=2.35.1' + eslint-plugin-vuejs-accessibility: ^2.4.1 + prettier-plugin-astro: ^0.14.0 + prettier-plugin-slidev: ^1.0.5 + svelte-eslint-parser: '>=0.37.0' + peerDependenciesMeta: + '@angular-eslint/eslint-plugin': + optional: true + '@angular-eslint/eslint-plugin-template': + optional: true + '@angular-eslint/template-parser': + optional: true + '@eslint-react/eslint-plugin': + optional: true + '@next/eslint-plugin-next': + optional: true + '@prettier/plugin-xml': + optional: true + '@unocss/eslint-plugin': + optional: true + astro-eslint-parser: + optional: true + eslint-plugin-astro: + optional: true + eslint-plugin-format: + optional: true + eslint-plugin-jsx-a11y: + optional: true + eslint-plugin-react-refresh: + optional: true + eslint-plugin-solid: + optional: true + eslint-plugin-svelte: + optional: true + eslint-plugin-vuejs-accessibility: + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-slidev: + optional: true + svelte-eslint-parser: + optional: true + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@antfu/utils@0.7.10': + resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + + '@apideck/better-ajv-errors@0.3.7': + resolution: {integrity: sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==} + engines: {node: '>=10'} + peerDependencies: + ajv: '>=8' + + '@apidevtools/json-schema-ref-parser@14.0.1': + resolution: {integrity: sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==} + engines: {node: '>= 16'} + + '@apidevtools/openapi-schemas@2.1.0': + resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} + engines: {node: '>=10'} + + '@apidevtools/swagger-methods@3.0.2': + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + + '@apidevtools/swagger-parser@12.1.0': + resolution: {integrity: sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==} + peerDependencies: + openapi-types: '>=7' + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.29.7': + resolution: {integrity: sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.29.7': + resolution: {integrity: sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.29.7': + resolution: {integrity: sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7': + resolution: {integrity: sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7': + resolution: {integrity: sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7': + resolution: {integrity: sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7': + resolution: {integrity: sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7': + resolution: {integrity: sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7': + resolution: {integrity: sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.29.7': + resolution: {integrity: sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-arrow-functions@7.29.7': + resolution: {integrity: sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.7': + resolution: {integrity: sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.29.7': + resolution: {integrity: sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.29.7': + resolution: {integrity: sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.29.7': + resolution: {integrity: sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.29.7': + resolution: {integrity: sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.29.7': + resolution: {integrity: sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.29.7': + resolution: {integrity: sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.29.7': + resolution: {integrity: sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.29.7': + resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.29.7': + resolution: {integrity: sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.29.7': + resolution: {integrity: sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-dynamic-import@7.29.7': + resolution: {integrity: sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.29.7': + resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.29.7': + resolution: {integrity: sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.29.7': + resolution: {integrity: sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.29.7': + resolution: {integrity: sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.29.7': + resolution: {integrity: sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.29.7': + resolution: {integrity: sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.29.7': + resolution: {integrity: sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7': + resolution: {integrity: sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.29.7': + resolution: {integrity: sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.29.7': + resolution: {integrity: sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@7.29.7': + resolution: {integrity: sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-umd@7.29.7': + resolution: {integrity: sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.29.7': + resolution: {integrity: sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7': + resolution: {integrity: sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.29.7': + resolution: {integrity: sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.29.7': + resolution: {integrity: sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.29.7': + resolution: {integrity: sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.29.7': + resolution: {integrity: sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.29.7': + resolution: {integrity: sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.29.7': + resolution: {integrity: sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.29.7': + resolution: {integrity: sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.29.7': + resolution: {integrity: sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.29.7': + resolution: {integrity: sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.7': + resolution: {integrity: sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regexp-modifiers@7.29.7': + resolution: {integrity: sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-reserved-words@7.29.7': + resolution: {integrity: sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.29.7': + resolution: {integrity: sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.29.7': + resolution: {integrity: sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.29.7': + resolution: {integrity: sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.29.7': + resolution: {integrity: sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.29.7': + resolution: {integrity: sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.29.7': + resolution: {integrity: sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.29.7': + resolution: {integrity: sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.29.7': + resolution: {integrity: sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7': + resolution: {integrity: sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.29.7': + resolution: {integrity: sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@biomejs/js-api@4.0.0': + resolution: {integrity: sha512-EOArR/6drRzM1/hwOIz1pZw90FL31Ud4Y7hEHGWVtMNmAwS9SrwZ8hMENGlLVXCeGW/kL46p8kX7eO6x9Nmezg==} + peerDependencies: + '@biomejs/wasm-bundler': ^2.3.0 + '@biomejs/wasm-nodejs': ^2.3.0 + '@biomejs/wasm-web': ^2.3.0 + peerDependenciesMeta: + '@biomejs/wasm-bundler': + optional: true + '@biomejs/wasm-nodejs': + optional: true + '@biomejs/wasm-web': + optional: true + + '@biomejs/wasm-nodejs@2.4.6': + resolution: {integrity: sha512-YRhedzOovXDMs+ZKzi/ZcDTefZIMcrp3z0Ruq+abrNb622aYUh/2m1Ooj8BcG4anGUGR6o+dTCbvAEx9vc74hw==} + + '@borewit/text-codec@0.2.1': + resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + + '@cacheable/memory@2.0.8': + resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} + + '@cacheable/utils@2.4.0': + resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==} + + '@clack/core@1.3.0': + resolution: {integrity: sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.3.0': + resolution: {integrity: sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw==} + engines: {node: '>= 20.12.0'} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@commitlint/cli@20.5.3': + resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@20.5.3': + resolution: {integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@20.5.0': + resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} + engines: {node: '>=v18'} + + '@commitlint/ensure@20.5.3': + resolution: {integrity: sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@20.0.0': + resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} + engines: {node: '>=v18'} + + '@commitlint/format@20.5.0': + resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@20.5.0': + resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} + engines: {node: '>=v18'} + + '@commitlint/lint@20.5.3': + resolution: {integrity: sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==} + engines: {node: '>=v18'} + + '@commitlint/load@20.5.3': + resolution: {integrity: sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==} + engines: {node: '>=v18'} + + '@commitlint/message@20.4.3': + resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} + engines: {node: '>=v18'} + + '@commitlint/parse@20.5.0': + resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} + engines: {node: '>=v18'} + + '@commitlint/read@20.5.0': + resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@20.5.3': + resolution: {integrity: sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==} + engines: {node: '>=v18'} + + '@commitlint/rules@20.5.3': + resolution: {integrity: sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@20.0.0': + resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} + engines: {node: '>=v18'} + + '@commitlint/top-level@20.4.3': + resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} + engines: {node: '>=v18'} + + '@commitlint/types@20.5.0': + resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} + engines: {node: '>=v18'} + + '@conventional-changelog/git-client@2.7.0': + resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} + engines: {node: '>=18'} + peerDependencies: + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.4.0 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@csstools/media-query-list-parser@5.0.0': + resolution: {integrity: sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/selector-resolve-nested@4.0.0': + resolution: {integrity: sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@csstools/selector-specificity@6.0.0': + resolution: {integrity: sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@e18e/eslint-plugin@0.3.0': + resolution: {integrity: sha512-hHgfpxsrZ2UYHcicA+tGZnmk19uJTaye9VH79O+XS8R4ona2Hx3xjhXghclNW58uXMk3xXlbYEOMr8thsoBmWg==} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + oxlint: ^1.55.0 + peerDependenciesMeta: + eslint: + optional: true + oxlint: + optional: true + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@es-joy/jsdoccomment@0.84.0': + resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@es-joy/jsdoccomment@0.86.0': + resolution: {integrity: sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-plugin-eslint-comments@4.7.1': + resolution: {integrity: sha512-Ql2nJFwA8wUGpILYGOQaT1glPsmvEwE0d+a+l7AALLzQvInqdbXJdx7aSu0DpUX9dB1wMVBMhm99/++S3MdEtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/compat@2.0.3': + resolution: {integrity: sha512-SjIJhGigp8hmd1YGIBwh7Ovri7Kisl42GYFjrOyHhtfYGGoLW6teYi/5p8W50KSsawUPpuLOSmsq1bD0NGQLBw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^8.40 || 9 || 10 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/markdown@8.0.1': + resolution: {integrity: sha512-WWKmld/EyNdEB8GMq7JMPX1SDWgyJAM1uhtCi5ySrqYQM4HQjmg11EX/q3ZpnpRXHfdccFtli3NBvvGaYjWyQw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exodus/schemasafe@1.3.0': + resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} + + '@faker-js/faker@9.9.0': + resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/formbody@8.0.2': + resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/helmet@13.0.2': + resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==} + + '@fastify/jwt@9.1.0': + resolution: {integrity: sha512-CiGHCnS5cPMdb004c70sUWhQTfzrJHAeTywt7nVw6dAiI0z1o4WRvU94xfijhkaId4bIxTCOjFgn4sU+Gvk43w==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/otel@0.18.1': + resolution: {integrity: sha512-7TYQXrOBRwCuTiwQm/2qCPO37af011934clxBj6F7KyF9a2a9MB+aSrWv8vMVp5N8rZC1AQ4pUp8uDs5Z40UPQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/session@11.1.1': + resolution: {integrity: sha512-nuKwTHxh3eJsI4NJeXoYVGzXUsg+kH1WfHgS7IofVyVhmjc+A6qGr+29WQy8hYZiNtmCjfG415COpf5xTBkW4Q==} + + '@fastify/static@9.1.3': + resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} + + '@fastify/swagger-ui@5.2.6': + resolution: {integrity: sha512-OMnms0O5s9wb6wis/K5nlrAMLsgUbr1GA8uphM41IasWe3AFdgxz6r/3bA9HTxlDNUYc2FGGKeqMp3ntxmSiNA==} + + '@fastify/swagger@9.7.0': + resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} + + '@gitbeaker/core@40.6.0': + resolution: {integrity: sha512-tVVm8ZPrS9YCHEcuPV8vD1IcEf9POpdygWo+kPvkK7LcC36EERVcXagb8snEaGgGLfUaVQh8qP4iDZgPnP3YBQ==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/requester-utils@40.6.0': + resolution: {integrity: sha512-DQu2l3iXtB+8e1Ye2ekeUHABt4mGMRTLtuVWtFqf74sqJnerHNOxVOjPn19qu/nKdvKR3ZLwSRTtPzEsxgcShg==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/rest@40.6.0': + resolution: {integrity: sha512-sAwYJclU3NlB/gdxqhH6Hnmy5LWzvW7D3W33eShQEnxMhM0VjnFHPHcgJLQCIux3hMiub1uGtTw1hBJTxDc2mQ==} + engines: {node: '>=18.20.0'} + + '@gouvfr/dsfr@1.14.4': + resolution: {integrity: sha512-Ca17apmTyAF2tMFwMcLwQN7fTlbfMFeyYSLUIUmjoSg1XVF4vDDkbvvKBpWJWaoTLDSFZBbvvHzcDyxw9wSkLA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@gouvminint/vue-dsfr@8.15.0': + resolution: {integrity: sha512-e9q7xJeWpGLLFmbe8YCeZ3bgsBM4LWMMiRVmde5DeAfILXewPh3uFKGMphWVKVKnz3tnxrgYM0GoGOn3I9FoYQ==} + engines: {node: '>=22.0.0', pnpm: '>=10.0.0'} + hasBin: true + peerDependencies: + '@iconify/vue': ^4.2.0 + vue: ^3.5.27 + vue-router: ^4.6.4 + + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + + '@himenon/argocd-typescript-openapi@1.2.2': + resolution: {integrity: sha512-xS8HAzhRSQXqMfD779EgbZmPcgkaNW8Hf2anrwvvPYb9Tt0vP9CtzOqj+8GYSOUQqgcRFejmCR9WoAgZJamB1g==} + engines: {pnpm: '>=8'} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@iconify-json/ri@1.2.10': + resolution: {integrity: sha512-WWMhoncVVM+Xmu9T5fgu2lhYRrKTEWhKk3Com0KiM111EeEsRLiASjpsFKnC/SrB6covhUp95r2mH8tGxhgd5Q==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + + '@iconify/vue@4.3.0': + resolution: {integrity: sha512-Xq0h6zMrHBbrW8jXJ9fISi+x8oDQllg5hTDkDuxnWiskJ63rpJu9CvJshj8VniHVTbsxCg9fVoPAaNp3RQI5OQ==} + peerDependencies: + vue: '>=3' + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.3.2': + resolution: {integrity: sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@keycloak/keycloak-admin-client@26.7.0': + resolution: {integrity: sha512-QEsX19sFCvknBzV2zelEkZ7waglCqzayY1zDtoRQinHuUiAe2f7q+xXT6R8XNukn+rShB41TOlG4QR2Bt6hxhw==} + engines: {node: '>=18'} + + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@kubernetes-models/apimachinery@2.2.0': + resolution: {integrity: sha512-7Yj3aMz/nCTdr221wHOMsw5vAMJnLqANMI3BTuCmYjbP+7/tGAaOsiFwT75F4TJH45Dk1P6wcAEqDeCD1ur82Q==} + engines: {node: '>=14'} + + '@kubernetes-models/argo-cd@2.7.2': + resolution: {integrity: sha512-Dge+7Uq54dt5c3pzusOw8i26FHWqJiyiFqHKQKHUNjOq2zb3e7LY2OYBrNP7hDSoM0Ap5K9cJTMgNzsaaKdjOQ==} + engines: {node: '>=14'} + + '@kubernetes-models/base@5.0.1': + resolution: {integrity: sha512-Yfzmh965y/hT8qs9eE+Ms9sO1iT31c/sPoJerBFRGS9KechXXfxHgeqtpOzq+eByQ1INblkcZ09rt2V0FxG0lA==} + engines: {node: '>=14'} + + '@kubernetes-models/validate@4.0.0': + resolution: {integrity: sha512-rsfF5t3sd5c+3ejUgcy8q0cVG2/BxT064L4Xz+CuKEe014u8T4MtFZiWjWZFpfMXSGKzFYEA6DJYm9CqjmIfZg==} + engines: {node: '>=14'} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@microsoft/kiota-abstractions@1.0.0-preview.103': + resolution: {integrity: sha512-om7nKH8nWyEAQ0lgbIdKdrkmznhkdDJdliCnAIo31X/hlUah1aBzvfUx+pbyp67Q040yLK+2J6P7AnvGSoN/qQ==} + + '@microsoft/kiota-http-fetchlibrary@1.0.0-preview.103': + resolution: {integrity: sha512-DbhGL+pdVjtv2AVvxB4fxvNC/GMzKbYyeQsknaXJXwqnPR/oIClWjaXlIxWL5SBK72DOmrZ18leaWMB2vfWvpw==} + + '@microsoft/kiota-serialization-form@1.0.0-preview.103': + resolution: {integrity: sha512-AD5Q59Qu61DfhaLLN7Mvgx4Zw0Zmftqx04XAbrRreC+r9hwT1BqTf0EReCraMzEoC4sdrp/8haI2Arm8dWUWuQ==} + + '@microsoft/kiota-serialization-json@1.0.0-preview.103': + resolution: {integrity: sha512-dTr8q6H42TSe+HaCPBXU4Pv6ypo7MjMGRo/r9GRbnCuy8VCWfe+Vh38rnAZJx4r8cmvOUD7Yw74IJ6q4Hjv3mg==} + + '@microsoft/kiota-serialization-multipart@1.0.0-preview.103': + resolution: {integrity: sha512-b3ePXYgFv/2TQtbzFQ8ZY+hzMtCRkz4Cx5y/Pf8vtoQu56KU0/9rYq5kA9wySDdCx/oZxfubQQRV/8QSTxmejw==} + + '@microsoft/kiota-serialization-text@1.0.0-preview.103': + resolution: {integrity: sha512-FkiboqpNtT2F40xUC9LETHK9ckHd+WuocLJE70ljI4EYvD59MQDsrTERYAwbmkfPIJ6JRE4OfLXFVh510LG1/Q==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} + engines: {node: '>=18'} + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nestjs/cache-manager@3.1.3': + resolution: {integrity: sha512-HMtiOfHz75NZX7mJn1VnZGLSachVI04TnUc5wvEogIaKwk5BDQHgtP5htxreizjv7oxKalJbuTyxtiF6bE+bgQ==} + peerDependencies: + '@nestjs/common': ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 || ^11.0.0 + cache-manager: '>=6' + keyv: '>=5' + rxjs: ^7.8.1 + + '@nestjs/cli@11.0.16': + resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==} + engines: {node: '>= 20.11'} + hasBin: true + peerDependencies: + '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 + '@swc/core': ^1.3.62 + peerDependenciesMeta: + '@swc/cli': + optional: true + '@swc/core': + optional: true + + '@nestjs/common@11.1.16': + resolution: {integrity: sha512-JSIeW+USuMJkkcNbiOdcPkVCeI3TSnXstIVEPpp3HiaKnPRuSbUUKm9TY9o/XpIcPHWUOQItAtC5BiAwFdVITQ==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/config@4.0.3': + resolution: {integrity: sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + '@nestjs/core@11.1.16': + resolution: {integrity: sha512-tXWXyCiqWthelJjrE0KLFjf0O98VEt+WPVx5CrqCf+059kIxJ8y1Vw7Cy7N4fwQafWNrmFL2AfN87DDMbVAY0w==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/event-emitter@3.0.1': + resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/jwt@11.0.2': + resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/platform-express@11.1.16': + resolution: {integrity: sha512-IOegr5+ZfUiMKgk+garsSU4MOkPRhm46e6w8Bp1GcO4vCdl9Piz6FlWAzKVfa/U3Hn/DdzSVJOW3TWcQQFdBDw==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + + '@nestjs/platform-fastify@11.1.27': + resolution: {integrity: sha512-rZMPS0RMP9P2O7Y/shAP0kKwrHElGO9cPTlgtdWpQ6JEX58uhYEJLGsU/X9a9l18DNSxxb3YjrEiVVuI2Iv6MA==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@fastify/view': ^10.0.0 || ^11.0.0 || ^12.0.0 + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + '@fastify/view': + optional: true + + '@nestjs/schedule@6.1.1': + resolution: {integrity: sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/schematics@11.0.9': + resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} + peerDependencies: + typescript: '>=4.8.2' + + '@nestjs/swagger@11.4.1': + resolution: {integrity: sha512-GuGzs8F1Cb3n+eEarmOqB4nt2ai+x4XGOYUXNYplOtDeB59DaFY5E16bsHsBWXiWgD1ywbyKQ5OVv02bQtB1Dw==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/terminus@11.1.1': + resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==} + peerDependencies: + '@grpc/grpc-js': '*' + '@grpc/proto-loader': '*' + '@mikro-orm/core': '*' + '@mikro-orm/nestjs': '*' + '@nestjs/axios': ^2.0.0 || ^3.0.0 || ^4.0.0 + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/microservices': ^10.0.0 || ^11.0.0 + '@nestjs/mongoose': ^11.0.0 + '@nestjs/sequelize': ^10.0.0 || ^11.0.0 + '@nestjs/typeorm': ^10.0.0 || ^11.0.0 + '@prisma/client': '*' + mongoose: '*' + reflect-metadata: 0.1.x || 0.2.x + rxjs: 7.x + sequelize: '*' + typeorm: '*' + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + '@grpc/proto-loader': + optional: true + '@mikro-orm/core': + optional: true + '@mikro-orm/nestjs': + optional: true + '@nestjs/axios': + optional: true + '@nestjs/microservices': + optional: true + '@nestjs/mongoose': + optional: true + '@nestjs/sequelize': + optional: true + '@nestjs/typeorm': + optional: true + '@prisma/client': + optional: true + mongoose: + optional: true + sequelize: + optional: true + typeorm: + optional: true + + '@nestjs/testing@11.1.16': + resolution: {integrity: sha512-E7/aUCxzeMSJV80L5GWGIuiMyR/1ncS7uOIetAImfbS4ATE1/h2GBafk0qpk+vjFtPIbtoh9BWDGICzUEU5jDA==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nuxt/opencollective@0.4.1': + resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} + engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} + hasBin: true + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@opentelemetry/api-logs@0.212.0': + resolution: {integrity: sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.213.0': + resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.214.0': + resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/auto-instrumentations-node@0.70.1': + resolution: {integrity: sha512-r8BKs0rHtBAzZViPIuzSD2eh65fOPau0NqVsca2sACuZ6LFGu6a+QMhqq7skXz+/OqKwFr/7/b6VsaNMS+zZpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.4.1 + '@opentelemetry/core': ^2.0.0 + + '@opentelemetry/configuration@0.212.0': + resolution: {integrity: sha512-D8sAY6RbqMa1W8lCeiaSL2eMCW2MF87QI3y+I6DQE1j+5GrDMwiKPLdzpa/2/+Zl9v1//74LmooCTCJBvWR8Iw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/context-async-hooks@2.5.1': + resolution: {integrity: sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.5.1': + resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.212.0': + resolution: {integrity: sha512-/0bk6fQG+eSFZ4L6NlckGTgUous/ib5+OVdg0x4OdwYeHzV3lTEo3it1HgnPY6UKpmX7ki+hJvxjsOql8rCeZA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.212.0': + resolution: {integrity: sha512-JidJasLwG/7M9RTxV/64xotDKmFAUSBc9SNlxI32QYuUMK5rVKhHNWMPDzC7E0pCAL3cu+FyiKvsTwLi2KqPYw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.212.0': + resolution: {integrity: sha512-RpKB5UVfxc7c6Ta1UaCrxXDTQ0OD7BCGT66a97Q5zR1x3+9fw4dSaiqMXT/6FAWj2HyFbem6Rcu1UzPZikGTWQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0': + resolution: {integrity: sha512-/6Gqf9wpBq22XsomR1i0iPGnbQtCq2Vwnrq5oiDPjYSqveBdK1jtQbhGfmpK2mLLxk4cPDtD1ZEYdIou5K8EaA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.212.0': + resolution: {integrity: sha512-8hgBw3aTTRpSTkU4b9MLf/2YVLnfWp+hfnLq/1Fa2cky+vx6HqTodo+Zv1GTIrAKMOOwgysOjufy0gTxngqeBg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0': + resolution: {integrity: sha512-yw3fTIw4KQIRXC/ZyYQq5gtA3Ogfdfz/g5HVgleobQAcjUUE8Nj3spGMx8iQPp+S+u6/js7BixufRkXhzLmpJA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.212.0': + resolution: {integrity: sha512-C7I4WN+ghn3g7SnxXm2RK3/sRD0k/BYcXaK6lGU3yPjiM7a1M25MLuM6zY3PeVPPzzTZPfuS7+wgn/tHk768Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0': + resolution: {integrity: sha512-geHF+zZaDb0/WRkJTxR8o8dG4fCWT/Wq7HBdNZCxwH5mxhwRi/5f37IDYH7nvU+dwU6IeY4Pg8TPI435JCiNkg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.212.0': + resolution: {integrity: sha512-hJFLhCJba5MW5QHexZMHZdMhBfNqNItxOsN0AZojwD1W2kU9xM+BEICowFGJFo/vNV+I2BJvTtmuKafeDSAo7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.212.0': + resolution: {integrity: sha512-9xTuYWp8ClBhljDGAoa0NSsJcsxJsC9zCFKMSZJp1Osb9pjXCMRdA6fwXtlubyqe7w8FH16EWtQNKx/FWi+Ghw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.212.0': + resolution: {integrity: sha512-v/0wMozNoiEPRolzC4YoPo4rAT0q8r7aqdnRw3Nu7IDN0CGFzNQazkfAlBJ6N5y0FYJkban7Aw5WnN73//6YlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.212.0': + resolution: {integrity: sha512-d1ivqPT0V+i0IVOOdzGaLqonjtlk5jYrW7ItutWzXL/Mk+PiYb59dymy/i2reot9dDnBFWfrsvxyqdutGF5Vig==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0': + resolution: {integrity: sha512-six3vPq3sL+ge1iZOfKEg+RHuFQhGb8ZTdlvD234w/0gi8ty/qKD46qoGpKvM3amy5yYunWBKiFBW47WaVS26w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.5.1': + resolution: {integrity: sha512-Me6JVO7WqXGXsgr4+7o+B7qwKJQbt0c8WamFnxpkR43avgG9k/niTntwCaXiXUTjonWy0+61ZuX6CGzj9nn8CQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-amqplib@0.59.0': + resolution: {integrity: sha512-xscSgOJA+GHphESDZxBHNk/zjNaEgoeufMwmiqYdL+qM27Xw3BbR9vN6Ucbq9dW6Y+oYUPgTTj17qf+Za4+uzg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-lambda@0.64.0': + resolution: {integrity: sha512-vYhM/a8fG34/Dl/Q9gfv5Ih3OFPgqeyn79S8FN+Xs/QZw6h6L8a1lDa3CyigyicOXLCmVIM7Fc9vFD4BGqgGLA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-sdk@0.67.0': + resolution: {integrity: sha512-btpwJnZ2RBXDh/pTpfVpInpBu9Pedi+lbLKbt3naB344SggbbYnIdT7u8EzmGIApWi9EV91vw7hm896I7nESQA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-bunyan@0.57.0': + resolution: {integrity: sha512-W4zLz1Y9ptCsdL+QMXR7xQaBHkJivLBmVlLCjUe23rX4V8E65fGAtlIJSKTKAfz4aEgtWgQAGMdkeqACwG0Caw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cassandra-driver@0.57.0': + resolution: {integrity: sha512-xLwrK+XnN32IB5i6t/a2j+SVdjlq/BIgjpVRkke4HAsKjoSMy1GeSI+ZOiJffRLFb4MojcvH4RG2+nEg1uC6Zg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.55.0': + resolution: {integrity: sha512-UfGw7ubKKZBoTRjxi5KlfeECEaXZinS20RdRNlZE5tVF+O17hJOnrcGwAoQAHp6eYmxI2jW9IQ4t6450gnNF9g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cucumber@0.28.0': + resolution: {integrity: sha512-kim+bRxu4LZqKEyF2SgO01tgG88W+/iYltyP1XjT31FIXzlBjzQpwtSLLM8byayO85mcZIBha54WSNFDLM/7qQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-dataloader@0.29.0': + resolution: {integrity: sha512-220WjRb1G1UiAKbVblSMxwxxFdpyB4wj1XYIO9BJs5r62Azj2dL5fyZiXK3/WO6wB3uLul9R946iKI1bpPxktQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dns@0.55.0': + resolution: {integrity: sha512-cfWLaFi22V+sQrKY7t6QroYzT3kO9m3PpkN1OXYmuCyfwxQaXOVlF8NSAHtua/RQYw0aQl+2fe6JOWyJdEZiwA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.60.0': + resolution: {integrity: sha512-KghHCDqKq0D7iuPIVCuPSXut5WVAI6uwKcPrhwTUJL5VE2LC18id2vKoiAm1V8XvVlgIGAiECtEvbrFwkTCj3g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fastify@0.56.0': + resolution: {integrity: sha512-zotOPoZsWtMF47BjottK23XaaBSmVuwG5D/R3FlGfAAwMNFoDR3IY1OGO9v9KfOU/1/xDVkxsQ22NFfu9lE8aA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.31.0': + resolution: {integrity: sha512-C7tdXGDnkMgLVlE79VSekB+Y+P345zKUigvFMs5M7U0GIYA8ERx3FS0aAcY/ICIq9YwRmH2uuMb++Br5M2vNUg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.55.0': + resolution: {integrity: sha512-7hWiyLbEX/dIS4LZy/h8VaAQPs8oBeEqsrysDWbos0b9PF414L6Rsbi2um/omtxIs+GTvsbuqDscWigeaxyWdA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.60.0': + resolution: {integrity: sha512-XPATrmxAd2tFCsYbJ3eVIXt+gyvMKjc36QQuQxjtssMnAbw006Le9b5lKs7WXik7ItOpM1exATi1aDdOcCjRRg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-grpc@0.212.0': + resolution: {integrity: sha512-r1t7LNKWVhSQMUrBdDJtooFmmLZ93kGuFixqeXPoUP8W+chJCxhey9l0c0+L3xriNdyB7TzvkKHhPXUDevgVEA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.58.0': + resolution: {integrity: sha512-reuRApR2KHm2VsfyDgsrLhNE+IOy4uIU6n3oMjUleReHacEEZmf4vXxdt4/qcmJ6GoUXnRN2AOu3s5N3pMrgYA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.212.0': + resolution: {integrity: sha512-t2nt16Uyv9irgR+tqnX96YeToOStc3X5js7Ljn3EKlI2b4Fe76VhMkTXtsTQ0aId6AsYgefrCRnXSCo/Fn/vww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.60.0': + resolution: {integrity: sha512-R+nnbPD9l2ruzu248qM3YDWzpdmWVaFFFv08lQqsc0EP4pT/B1GGUg06/tHOSo3L5njB2eejwyzpkvJkjaQEMA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.21.0': + resolution: {integrity: sha512-lkLrILnKGO7SHw1xPJnuGx2S4XwbKmQiJyzUGuEImRoU/6Gj0Nka0lkbeRd4ANN20dxr/mLdXIsUsk6DzTrX6A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.56.0': + resolution: {integrity: sha512-pKqtY5lbAQ70MC5K/BJeAA1t2gAUlRBZBAJ5ergRUNs5jw8zbdOXEZOLztiuNvQqD2z4a9N0Tkde9JMFm2pKMQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.60.0': + resolution: {integrity: sha512-UOmu2y2LHgPzKsm9xd0sCQJimr11YP4MKFc190Do1ufd8qds7Zd5BI3f6TudqYhH9dUIhojsQyUaS6K4nv5FsQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.56.0': + resolution: {integrity: sha512-vXtOValhKRgWA9tLAiTU3P37Q31OveRuM2N5iLSVHl4GzkMBQ5p50A9kSKvt5gReL6BzFDXPCM9ItJiAhSS2KQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-memcached@0.55.0': + resolution: {integrity: sha512-kdhW/j5X+vNCAvHVc50PZfvE7diUScg1ZkBaNFRygY3Z6IUjgPLR0luWQMDPSFun6AVo1HaMDPxbUqJrot6qrA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.65.0': + resolution: {integrity: sha512-hOAJRs5vrY7fZolSYUXmf29Y+HFDHWrek0DeLq82uwMPjPSda7h6oumQnqEX5olzw357q/QG39/uJdkclJ/JUg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.58.0': + resolution: {integrity: sha512-3L0Fqo1y2oreISFPWaqdt/bg3NhLgrkn5U/E/9RNG1QaM81drTMBCHseMY1q8SlejjE43ZWOy+0KbmRBlUPJ+g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.58.0': + resolution: {integrity: sha512-EubjV1XZb7XHrENqF7TW2lnah+KN0LddMneKNAB8PjGVKL5lJkVV/vhJ6EIcUNn9nCWmAwZ3GRcFVEDKCnyXfQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.58.0': + resolution: {integrity: sha512-wZDrBCL3WfJclV6KywWVV3/B2ZiUYmDQdgyu3pq4jK/5qSfoDmezHzT/Nayln5MVVWMAGXIMLrCj8BKa6jaKQQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-nestjs-core@0.58.0': + resolution: {integrity: sha512-0lE9oW8j6nmvBHJoOxIQgKzMQQYNfX1nhiWZdXD0sNAMFsWBtvECWS7NAPSroKrEP53I04TcHCyyhcK4I9voXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-net@0.56.0': + resolution: {integrity: sha512-h69x7U6f86mP3gGWWTaMkQZk0K3tBvpVMIU7E0q2kkVw6eZ5TqFm9rkaEy38moQmixiDFQ9j/2/cwxG9P7ZEeA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-openai@0.10.0': + resolution: {integrity: sha512-0lV2zxge2mMaruVCw/bmypWVu+aJ76rc0HBvAVFCPUI3zzJdgBZJZafGIHZ1IB2F6VvrDFL+JstEnle6V8brvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-oracledb@0.37.0': + resolution: {integrity: sha512-OzMghtAEAEkXlkUrZI4QcXSZq0MILeU6WC0/N5+1MSkuIkruIeaRw99/RtyS2of8vlPDa8XbbXl32Q1RM3wSyg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.64.0': + resolution: {integrity: sha512-NbfB/rlfsRI3zpTjnbvJv3qwuoGLsN8FxR/XoI+ZTn1Rs62x1IenO+TSSvk4NO+7FlXpd2MiOe8LT/oNbydHGA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.58.0': + resolution: {integrity: sha512-rgy+tA7cDjuSq6dXAO40OiYP25azIDHMBtxG3RzSmCBVEYdjggl6btyuLVasX6VkOOhP2gf6PBuLMNxVwaIqAw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.59.0': + resolution: {integrity: sha512-IgImVFtWjfMmqxc0NIe3iSjp+J3Asf9lLX8reouUFUk3Aa/qJQO5PEvOtO3sNQtJBkC9bAd1OQdFaFWSFQc03g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.60.0': + resolution: {integrity: sha512-Ea/GffmmzIVHc9geaMjT94IR7poVZzIv4Kk/Lw0tbxGD3cBYcMUsLFVajKxpZsE1NRCECFpidAWeifCIKD0inw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-restify@0.57.0': + resolution: {integrity: sha512-kO6MsZFU+RdXOKhsKw8SOSBYGYCdFSlza+mpBQRl1DQmveZcnidchv4V5JQPtNgHxCGH+1n3hDpLdxdGUbJPNA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-router@0.56.0': + resolution: {integrity: sha512-PHECDGQElLazI/QbHU16C5m9fDC7DGJk+jLIwO5ca6bcp7bXhUPPUTT78l7da2pDsrz4mhv5ytYNZmBbW/Q3rA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-runtime-node@0.25.0': + resolution: {integrity: sha512-XaCmwBSui5KeTn8M6OzaEn1rEsNWtUkjuc1ylg0tqQTLHibNQ0n7f8v4zdF6x/nBV1OnsiYlN8RLHauGemv/TA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-socket.io@0.59.0': + resolution: {integrity: sha512-71DnM/FEqH0PjvU2uZvzWJeaGyVIy3rJKk8rZrxg/aS2QT3qLGb+UPL/B+1vOw4pzDPn4papLTSMpLVF9G8uvw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.31.0': + resolution: {integrity: sha512-HoF2EtcyP3JR4R3jLPHohZ9lFcj1QLJyGmFfLKDTvUUjPiFuK4XZ6L1OV9HhaqvN0xY+tWKfNdCPS3r33rd0Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.22.0': + resolution: {integrity: sha512-yb6vEWUPOrD5i7yR1XceEEqiVHbMgr5YnUPnom5eQVCjvrTkEVswyrf9i+vvJR+28wrNqILIIphWgOOx6BjnTQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation-winston@0.56.0': + resolution: {integrity: sha512-ITIA0Qe61CQ6FQU/bN23pNBvJ+5U0ofoASMOOYrODtXyV9wI267AigNTTwDmv2Myt8dPEFvvVFJZKhiZLIpehA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.212.0': + resolution: {integrity: sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.213.0': + resolution: {integrity: sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.214.0': + resolution: {integrity: sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.212.0': + resolution: {integrity: sha512-HoMv5pQlzbuxiMS0hN7oiUtg8RsJR5T7EhZccumIWxYfNo/f4wFc7LPDfFK6oHdG2JF/+qTocfqIHoom+7kLpw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.213.0': + resolution: {integrity: sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.212.0': + resolution: {integrity: sha512-YidOSlzpsun9uw0iyIWrQp6HxpMtBlECE3tiHGAsnpEqJWbAUWcMnIffvIuvTtTQ1OyRtwwaE79dWSQ8+eiB7g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.212.0': + resolution: {integrity: sha512-bj7zYFOg6Db7NUwsRZQ/WoVXpAf41WY2gsd3kShSfdpZQDRKHWJiRZIg7A8HvWsf97wb05rMFzPbmSHyjEl9tw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.213.0': + resolution: {integrity: sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@2.5.1': + resolution: {integrity: sha512-AU6sZgunZrZv/LTeHP+9IQsSSH5p3PtOfDPe8VTdwYH69nZCfvvvXehhzu+9fMW2mgJMh5RVpiH8M9xuYOu5Dg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.5.1': + resolution: {integrity: sha512-8+SB94/aSIOVGDUPRFSBRHVUm2A8ye1vC6/qcf/D+TF4qat7PC6rbJhRxiUGDXZtMtKEPM/glgv5cBGSJQymSg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.38.2': + resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resource-detector-alibaba-cloud@0.33.3': + resolution: {integrity: sha512-Ep3LDWALU+wCgGzAa1rgFXh3TObahN7HaQZntAeVQnnNhZ3VSXnBniRGeSCTIRvRr7YdZvq+G+rstixtAN5Ugw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-aws@2.13.0': + resolution: {integrity: sha512-ZPCn7gZhGqUYUoD+RCHIlayoHBMaJaEjfqlgz2EPKoXJ4y7Ru7CUm+Tm3yJVMKF92cN9xUQR0j5KALyF0fg9aw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-azure@0.20.0': + resolution: {integrity: sha512-iRy+O2cB6DOlQ/OONaK+L8Cp8nLS89dZVRp6KgnFAfzykXuq9Ws/ygJKcU3CCmjkgY5j2Vk3uVTre/E35bWhYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-container@0.8.4': + resolution: {integrity: sha512-kIvGHkMSacp+kb7btTuXbOAIWLyOCO+P/h/8xxaeLcp5ptmHRZ67uEdLAQo61ApdayFB/uqjJ9gY4x2/i/KsoA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-gcp@0.47.0': + resolution: {integrity: sha512-57T/kRVdU0ch1P4KPEkmU2b5mWNlUs8hHgqrBYVF+fNZMc1jMdL1mANZhEzoLtWKIeoCEy+57Itt7RkXAYNJiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resources@2.5.1': + resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.212.0': + resolution: {integrity: sha512-qglb5cqTf0mOC1sDdZ7nfrPjgmAqs2OxkzOPIf2+Rqx8yKBK0pS7wRtB1xH30rqahBIut9QJDbDePyvtyqvH/Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.213.0': + resolution: {integrity: sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.5.1': + resolution: {integrity: sha512-RKMn3QKi8nE71ULUo0g/MBvq1N4icEBo7cQSKnL3URZT16/YH3nSVgWegOjwx7FRBTrjOIkMJkCUn/ZFIEfn4A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.6.0': + resolution: {integrity: sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.212.0': + resolution: {integrity: sha512-tJzVDk4Lo44MdgJLlP+gdYdMnjxSNsjC/IiTxj5CFSnsjzpHXwifgl3BpUX67Ty3KcdubNVfedeBc/TlqHXwwg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.5.1': + resolution: {integrity: sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.6.0': + resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.5.1': + resolution: {integrity: sha512-9lopQ6ZoElETOEN0csgmtEV5/9C7BMfA7VtF4Jape3i954b6sTY2k3Xw3CxUTKreDck/vpAuJM+EDo4zheUw+A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.41.2': + resolution: {integrity: sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + + '@ota-meshi/ast-token-store@0.3.0': + resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@oxc-parser/binding-android-arm-eabi@0.115.0': + resolution: {integrity: sha512-VoB2rhgoqgYf64d6Qs5emONQW8ASiTc0xp+aUE4JUhxjX+0pE3gblTYDO0upcN5vt9UlBNmUhAwfSifkfre7nw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.115.0': + resolution: {integrity: sha512-lWRX75u+gqfB4TF3pWCHuvhaeneAmRl2b2qNBcl4S6yJ0HtnT4VXOMEZrq747i4Zby1ZTxj6mtOe678Bg8gRLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.115.0': + resolution: {integrity: sha512-ii/oOZjfGY1aszXTy29Z5DRyCEnBOrAXDVCvfdfXFQsOZlbbOa7NMHD7D+06YFe5qdxfmbWAYv4yn6QJi/0d2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.115.0': + resolution: {integrity: sha512-R/sW/p8l77wglbjpMcF+h/3rWbp9zk1mRP3U14mxTYIC2k3m+aLBpXXgk2zksqf9qKk5mcc4GIYsuCn9l8TgDg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.115.0': + resolution: {integrity: sha512-CSJ5ldNm9wIGGkhaIJeGmxRMZbgxThRN+X1ufYQQUNi5jZDV/U3C2QDMywpP93fczNBj961hXtcUPO/oVGq4Pw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.115.0': + resolution: {integrity: sha512-uWFwssE5dHfQ8lH+ktrsD9JA49+Qa0gtxZHUs62z1e91NgGz6O7jefHGI6aygNyKNS45pnnBSDSP/zV977MsOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.115.0': + resolution: {integrity: sha512-fZbqt8y/sKQ+v6bBCuv/mYYFoC0+fZI3mGDDEemmDOhT78+aUs2+4ZMdbd2btlXmnLaScl37r8IRbhnok5Ka9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.115.0': + resolution: {integrity: sha512-1ej/MjuTY9tJEunU/hUPIFmgH5PqgMQoRjNOvOkibtJ3Zqlw/+Lc+HGHDNET8sjbgIkWzdhX+p4J96A5CPdbag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.115.0': + resolution: {integrity: sha512-HjsZbJPH9mMd4swJRywVMsDZsJX0hyKb1iNHo5ijRl5yhtbO3lj7ImSrrL1oZ1VEg0te4iKmDGGz/6YPLd1G8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.115.0': + resolution: {integrity: sha512-zhhePoBrd7kQx3oClX/W6NldsuCbuMqaN9rRsY+6/WoorAb4j490PG/FjqgAXscWp2uSW2WV9L+ksn0wHrvsrg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.115.0': + resolution: {integrity: sha512-t/IRojvUE9XrKu+/H1b8YINug+7Q6FLls5rsm2lxB5mnS8GN/eYAYrPgHkcg9/1SueRDSzGpDYu3lGWTObk1zw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.115.0': + resolution: {integrity: sha512-79jBHSSh/YpQRAmvYoaCfpyToRbJ/HBrdB7hxK2ku2JMehjopTVo+xMJss/RV7/ZYqeezgjvKDQzapJbgcjVZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.115.0': + resolution: {integrity: sha512-nA1TpxkhNTIOMMyiSSsa7XIVJVoOU/SsVrHIz3gHvWweB5PHCQfO7w+Lb2EP0lBWokv7HtA/KbF7aLDoXzmuMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.115.0': + resolution: {integrity: sha512-9iVX789DoC3SaOOG+X6NcF/tVChgLp2vcHffzOC2/Z1JTPlz6bMG2ogvcW6/9s0BG2qvhNQImd+gbWYeQbOwVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.115.0': + resolution: {integrity: sha512-RmQmk+mjCB0nMNfEYhaCxwofLo1Z95ebHw1AGvRiWGCd4zhCNOyskgCbMogIcQzSB3SuEKWgkssyaiQYVAA4hQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.115.0': + resolution: {integrity: sha512-viigraWWQhhDvX5aGq+wrQq58k00Xq3MHz/0R4AFMxGlZ8ogNonpEfNc73Q5Ly87Z6sU9BvxEdG0dnYTfVnmew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.115.0': + resolution: {integrity: sha512-IzGCrMwXhpb4kTXy/8lnqqqwjI7eOvy+r9AhVw+hsr8t1ecBBEHprcNy0aKatFHN6hsX7UMHHQmBAQjVvL/p1A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.115.0': + resolution: {integrity: sha512-/ym+Absk/TLFvbhh3se9XYuI1D7BrUVHw4RaG/2dmWKgBenrZHaJsgnRb7NJtaOyjEOLIPtULx1wDdVL0SX2eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.115.0': + resolution: {integrity: sha512-AQSZjIR+b+Te7uaO/hGTMjT8/oxlYrvKrOTi4KTHF/O6osjHEatUQ3y6ZW2+8+lJxy20zIcGz6iQFmFq/qDKkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.115.0': + resolution: {integrity: sha512-oxUl82N+fIO9jIaXPph8SPPHQXrA08BHokBBJW8ct9F/x6o6bZE6eUAhUtWajbtvFhL8UYcCWRMba+kww6MBlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.115.0': + resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + + '@oxc-project/types@0.128.0': + resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@prisma/client@6.19.2': + resolution: {integrity: sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.2': + resolution: {integrity: sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==} + + '@prisma/debug@6.19.2': + resolution: {integrity: sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines@6.19.2': + resolution: {integrity: sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==} + + '@prisma/fetch-engine@6.19.2': + resolution: {integrity: sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==} + + '@prisma/get-platform@6.19.2': + resolution: {integrity: sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.18': + resolution: {integrity: sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.18': + resolution: {integrity: sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': + resolution: {integrity: sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': + resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': + resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': + resolution: {integrity: sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': + resolution: {integrity: sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': + resolution: {integrity: sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.18': + resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==} + + '@rolldown/pluginutils@1.0.0-rc.2': + resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + + '@rollup/plugin-babel@5.3.1': + resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} + engines: {node: '>= 10.0.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@types/babel__core': ^7.1.9 + rollup: ^1.20.0||^2.0.0 + peerDependenciesMeta: + '@types/babel__core': + optional: true + + '@rollup/plugin-node-resolve@15.3.1': + resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-replace@2.4.2': + resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 + + '@rollup/plugin-terser@0.4.4': + resolution: {integrity: sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@3.1.0': + resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} + engines: {node: '>= 8.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0 + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@simple-libs/child-process-utils@1.0.2': + resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} + engines: {node: '>=18'} + + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} + + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@std-uritemplate/std-uritemplate@2.0.12': + resolution: {integrity: sha512-s/R828vYTrFPgAOFAUiHuP4i1eSZHCO9PKVfzVR+2DFERJ/G71BpZJVObM2yy3xeMjMW3wB61gvanb6/pV1lJw==} + + '@stylistic/eslint-plugin@5.10.0': + resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + '@surma/rollup-plugin-off-main-thread@2.2.3': + resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} + + '@swc/helpers@0.5.19': + resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@ts-rest/core@3.52.1': + resolution: {integrity: sha512-tAjz7Kxq/grJodcTA1Anop4AVRDlD40fkksEV5Mmal88VoZeRKAG8oMHsDwdwPZz+B/zgnz0q2sF+cm5M7Bc7g==} + peerDependencies: + '@types/node': ^18.18.7 || >=20.8.4 + zod: ^3.22.3 + peerDependenciesMeta: + '@types/node': + optional: true + zod: + optional: true + + '@ts-rest/fastify@3.52.1': + resolution: {integrity: sha512-y+w3ENayNI77T0l2gcr9mobF3Nfc4yIb4487mvNMsMUkGLMHmaWzhLy3Mo/mswuoA+tVdOkrD8tJD38bLY7iYQ==} + peerDependencies: + '@ts-rest/core': ~3.52.0 + fastify: ^4.0.0 + zod: ^3.22.3 + peerDependenciesMeta: + zod: + optional: true + + '@ts-rest/open-api@3.52.1': + resolution: {integrity: sha512-VY91g6HFzCe1/fNgX4Y3HzaJbIdztCGHTg34drkh7bKgtNOizLJLXV0wwIkMwLGvjhRHojJxnwYxHjGMIkScwg==} + peerDependencies: + '@ts-rest/core': ~3.52.0 + zod: ^3.22.3 + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/aws-lambda@8.10.161': + resolution: {integrity: sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/bunyan@1.8.11': + resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@0.0.39': + resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/memcached@2.2.10': + resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/mysql@2.15.27': + resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} + + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + + '@types/oracledb@6.5.2': + resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==} + + '@types/pg-pool@2.0.7': + resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@types/swagger-schema-official@2.0.25': + resolution: {integrity: sha512-T92Xav+Gf/Ik1uPW581nA+JftmjWPgskw/WBf4TJzxRG/SJ+DfNnNE+WuZ4mrXuzflQMqMkm1LSYjzYW7MB1Cg==} + + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@typescript-eslint/eslint-plugin@8.57.0': + resolution: {integrity: sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/eslint-plugin@8.59.2': + resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.57.0': + resolution: {integrity: sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.59.2': + resolution: {integrity: sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.57.0': + resolution: {integrity: sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.59.2': + resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/rule-tester@8.57.0': + resolution: {integrity: sha512-qs4OapXmAIj3so85/20lQG1WrBSSvDE/3b42Orl3lpZkaOlNXtbfKzL+9EPaY5wSEgdlhKEpympAMFHPG9i72Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + '@typescript-eslint/scope-manager@8.57.0': + resolution: {integrity: sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.59.2': + resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.57.0': + resolution: {integrity: sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/tsconfig-utils@8.59.2': + resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.57.0': + resolution: {integrity: sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.59.2': + resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.57.0': + resolution: {integrity: sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.59.2': + resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.57.0': + resolution: {integrity: sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/typescript-estree@8.59.2': + resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.57.0': + resolution: {integrity: sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.59.2': + resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.57.0': + resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.59.2': + resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unocss/cli@66.6.6': + resolution: {integrity: sha512-78SY8j4hAVelK+vP/adsDGaSjEITasYLFECJLHWxUJSzK+G9UIc5wtL/u4jA+zKvwVkHcDvbkcO5K6wwwpAixg==} + engines: {node: '>=14'} + hasBin: true + + '@unocss/config@66.6.6': + resolution: {integrity: sha512-menlnkqAFX/4wR2aandY8hSqrt01JE+rOzvtQxWaBt8kf1du62b0sS72FE5Z40n6HlEsEbF91N9FCfhnzG6i6g==} + engines: {node: '>=14'} + + '@unocss/core@0.65.4': + resolution: {integrity: sha512-a2JOoFutrhqd5RgPhIR5FIXrDoHDU3gwCbPrpT6KYTjsqlSc/fv02yZ+JGOZFN3MCFhCmaPTs+idDFtwb3xU8g==} + + '@unocss/core@66.6.6': + resolution: {integrity: sha512-Sbbx0ZQqmV8K2lg8E+z9MJzWb1MgRtJnvqzxDIrNuBjXasKhbcFt5wEMBtEZJOr63Z4ck0xThhZK53HmYT2jmg==} + + '@unocss/extractor-arbitrary-variants@66.6.6': + resolution: {integrity: sha512-uMzekF2miZRUwSZGvy3yYQiBAcSAs9LiXK8e3NjldxEw8xcRDWgTErxgStRoBeAD6UyzDcg/Cvwtf2guMbtR+g==} + + '@unocss/inspector@66.6.6': + resolution: {integrity: sha512-CpXIsqHwxCXJtUjUz6S29diHCIA+EJ1u5WML/6m2YPI4ObgWAVKrExy09inSg2icS52lFkWWdWQSeqc9kl5W6Q==} + + '@unocss/preset-attributify@66.6.6': + resolution: {integrity: sha512-3H12UI1rBt60PQy+S4IEeFYWu1/WQFuc2yhJ5mu/RCvX5/qwlIGanBpuh+xzTPXU1fWBlZN68yyO9uWOQgTqZQ==} + + '@unocss/preset-icons@66.6.6': + resolution: {integrity: sha512-HfIEEqf3jyKexOB2Sux556n0NkPoUftb2H4+Cf7prJvKHopMkZ/OUkXjwvUlxt1e5UpAEaIa0A2Ir7+ApxXoGA==} + + '@unocss/preset-mini@66.6.6': + resolution: {integrity: sha512-k+/95PKMPOK57cJcSmz34VkIFem8BlujRRx6/L0Yusw7vLJMh98k0rPhC5s+NomZ/d9ZPgbNylskLhItJlak3w==} + + '@unocss/preset-tagify@66.6.6': + resolution: {integrity: sha512-KgBXYPYS0g4TVC3NLiIB78YIqUlvDLanz1EHIDo34rOTUfMgY8Uf5VuDJAzMu4Sc0LiwwBJbk6nIG9/Zm7ufWg==} + + '@unocss/preset-typography@66.6.6': + resolution: {integrity: sha512-SM1km5nqt15z4sTabfOobSC633I5Ol5nnme6JFTra4wiyCUNs+Cg31nJ6jnopWDUT4SEAXqfUH7jKSSoCnI6ZA==} + + '@unocss/preset-uno@66.6.6': + resolution: {integrity: sha512-40PcBDtlhW7QP7e/WOxC684IhN5T1dXvj1dgx9ZzK+8lEDGjcX7bN2noW4aSenzSrHymeSsMrL/0ltL4ED/5Zw==} + + '@unocss/preset-web-fonts@66.6.6': + resolution: {integrity: sha512-5ikwgrJB8VPzKd0bqgGNgYUGix90KFnVtKJPjWTP5qsv3+ZtZnea1rRbAFl8i2t52hg35msNBsQo+40IC3xB6A==} + + '@unocss/preset-wind3@66.6.6': + resolution: {integrity: sha512-rk6gPPIQ7z2DVucOqp7XZ4vGpKAuzBV1vtUDvDh5WscxzO/QlqaeTfTALk5YgGpmLaF4+ns6FrTgLjV+wHgHuQ==} + + '@unocss/preset-wind4@66.6.6': + resolution: {integrity: sha512-caTDM9rZSlp4tyPWWAnwMvQr2PXq53LsEYwd3N8zj0ou2hcsqptJvF+mFvyhvGF66x26wWJr/FwuUEhh7qycaw==} + + '@unocss/preset-wind@66.6.6': + resolution: {integrity: sha512-TMy3lZ35FP/4QqDHOLWZmV+RoOGWUDqnDEOTjOKI1CQARGta0ppUmq+IZMuI1ZJLuOa4OZ9V6SfnwMXwRLgXmw==} + + '@unocss/rule-utils@0.65.4': + resolution: {integrity: sha512-+EzdJEWcqGcO6HwbBTe7vEdBRpuKkBiz4MycQeLD6GEio04T45y6VHHO7/WTqxltbO4YwwW9/s2TKRMxKtoG8g==} + engines: {node: '>=14'} + + '@unocss/rule-utils@66.6.6': + resolution: {integrity: sha512-krWtQKGshOaqQMuxeGq1NOA8NL35VdpYlmQEWOe39BY6TACT51bgQFu40MRfsAIMZZtoGS2YYTrnHojgR92omw==} + engines: {node: '>=14'} + + '@unocss/transformer-attributify-jsx@66.6.6': + resolution: {integrity: sha512-NnDchmN2EeFLy4lfVqDgNe9j1+w2RLL2L9zKECXs5g6rDVfeeEK6FNgxSq3XnPcKltjNCy1pF4MaDOROG7r8yA==} + + '@unocss/transformer-compile-class@66.6.6': + resolution: {integrity: sha512-KKssJxU8fZ9x84yznIirbtta2sB0LN/3lm0bp+Wl1298HITaNiVeG2n26iStQ3N7r240xRN2RarxncSVCMFwWw==} + + '@unocss/transformer-directives@0.65.4': + resolution: {integrity: sha512-zkoDEwzPkgXi6ohW7P11gbArwfTRMZ9knYSUYoPEltQz+UZYzeRQ85exiAmdz5MsbCAuhQEr577Kd/CWfhjEuA==} + + '@unocss/transformer-directives@66.6.6': + resolution: {integrity: sha512-CReFTcBfMtKkRvzIqxL20VptWt5C1Om27dwoKzyVFBXv0jzViWysbu0y0AQg3bsgD4cFqndFyAGyeL84j0nbKg==} + + '@unocss/transformer-variant-group@66.6.6': + resolution: {integrity: sha512-j4L/0Tw6AdMVB2dDnuBlDbevyL1/0CAk88a77VF/VjgEIBwB9VXsCCUsxz+2Dohcl7N2GMm7+kpaWA6qt2PSaA==} + + '@unocss/vite@66.6.6': + resolution: {integrity: sha512-DgG7KcUUMtoDhPOlFf2l4dR+66xZ23SdZvTYpikk5nZfLCzZd62vedutD7x0bTR6VpK2YRq39B+F+Z6TktNY/w==} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0 + + '@vitejs/plugin-vue@6.0.4': + resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vue: ^3.2.25 + + '@vitest/coverage-v8@4.1.5': + resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} + peerDependencies: + '@vitest/browser': 4.1.5 + vitest: 4.1.5 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/eslint-plugin@1.6.17': + resolution: {integrity: sha512-sIVY9ZeVcXyPxFCNRkIt8Yw4keKIcUyp9/8qnmuomPwE+ST1htw5sZsbqdUMTiah9SmCg1JYoK9RqdDtPeNYYg==} + engines: {node: '>=18'} + peerDependencies: + '@typescript-eslint/eslint-plugin': '*' + eslint: '>=8.57.0' + typescript: '>=5.0.0' + vitest: '*' + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + typescript: + optional: true + vitest: + optional: true + + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.30': + resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==} + + '@vue/compiler-dom@3.5.30': + resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==} + + '@vue/compiler-sfc@3.5.30': + resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==} + + '@vue/compiler-ssr@3.5.30': + resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/eslint-config-typescript@14.7.0': + resolution: {integrity: sha512-iegbMINVc+seZ/QxtzWiOBozctrHiF2WvGedruu2EbLujg9VuU0FQiNcN2z1ycuaoKKpF4m2qzB5HDEMKbxtIg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.10.0 || ^10.0.0 + eslint-plugin-vue: ^9.28.0 || ^10.0.0 + typescript: '>=4.8.4' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.30': + resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==} + + '@vue/runtime-core@3.5.30': + resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==} + + '@vue/runtime-dom@3.5.30': + resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==} + + '@vue/server-renderer@3.5.30': + resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==} + peerDependencies: + vue: 3.5.30 + + '@vue/shared@3.5.30': + resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==} + + '@vue/tsconfig@0.7.0': + resolution: {integrity: sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==} + peerDependencies: + typescript: 5.x + vue: ^3.4.0 + peerDependenciesMeta: + typescript: + optional: true + vue: + optional: true + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats-draft2019@1.6.1: + resolution: {integrity: sha512-JQPvavpkWDvIsBp2Z33UkYCtXCSpW4HD3tAZ+oL4iEFOk9obQZffx0yANwECt6vzr6ET+7HN5czRyqXbnq/u0Q==} + peerDependencies: + ajv: '*' + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + any-base@1.1.0: + resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + are-docs-informative@0.0.2: + resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + axios-retry@4.5.0: + resolution: {integrity: sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==} + peerDependencies: + axios: 0.x || 1.x + + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.14.2: + resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.38: + resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} + engines: {node: '>=6.0.0'} + hasBin: true + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bn.js@4.12.3: + resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + brorand@1.1.0: + resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builtin-modules@5.0.0: + resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} + engines: {node: '>=18.20'} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + + c12@3.3.3: + resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + cache-manager@7.2.8: + resolution: {integrity: sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==} + + cacheable@2.3.3: + resolution: {integrity: sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + camelize-ts@3.0.0: + resolution: {integrity: sha512-cgRwKKavoDKLTjO4FQTs3dRBePZp/2Y9Xpud0FhuCOTE86M2cniKN4CCXgRnsyXNMmQMifVHcv6SPaMtTx6ofQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + check-disk-space@3.4.0: + resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==} + engines: {node: '>=16'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cidr-regex@3.1.1: + resolution: {integrity: sha512-RBqYd32aDwbCMFJRL6wHOlDNYJsPNTt8vC82ErHF5vKt8QQzxm1FrkW8s/R5pVrXMf17sba09Uoy91PKiddAsw==} + engines: {node: '>=10'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.1: + resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clean-regexp@1.0.0: + resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} + engines: {node: '>=4'} + + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + clipboard@2.0.11: + resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + comment-json@4.4.1: + resolution: {integrity: sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==} + engines: {node: '>= 6'} + + comment-parser@1.4.5: + resolution: {integrity: sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==} + engines: {node: '>= 12.0.0'} + + comment-parser@1.4.6: + resolution: {integrity: sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==} + engines: {node: '>= 12.0.0'} + + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@9.3.1: + resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} + engines: {node: '>=18'} + + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} + hasBin: true + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cron-validator@1.4.0: + resolution: {integrity: sha512-wGcJ9FCy65iaU6egSH8b5dZYJF7GU/3Jh06wzaT9lsa5dbqExjljmu+0cJ8cpKn+vUyZa/EM4WAxeLR6SypJXw==} + + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto-random-string@2.0.0: + resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} + engines: {node: '>=8'} + + css-functions-list@3.3.3: + resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} + engines: {node: '>=12'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + deeks@3.1.0: + resolution: {integrity: sha512-e7oWH1LzIdv/prMQ7pmlDlaVoL64glqzvNgkgQNgyec9ORPHrT2jaOqMtRyqJuwWjtfb6v+2rk9pmaHj+F137A==} + engines: {node: '>= 16'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegate@3.2.0: + resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + + doc-path@4.1.1: + resolution: {integrity: sha512-h1ErTglQAVv2gCnOpD3sFS6uolDbOKHDU1BZq+Kl3npPqroU3dYL42lUgMfd5UimlwtRgp7C9dLGwqQ5D2HYgQ==} + engines: {node: '>=16'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + dompurify@3.3.2: + resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} + engines: {node: '>=20'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + effect@3.18.4: + resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-to-chromium@1.5.376: + resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + + elliptic@6.6.1: + resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.24.0: + resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.1: + resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} + engines: {node: '>= 0.4'} + + es-toolkit@1.48.1: + resolution: {integrity: sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==} + + es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-flat-gitignore@2.3.0: + resolution: {integrity: sha512-bg4ZLGgoARg1naWfsINUUb/52Ksw/K22K+T16D38Y8v+/sGwwIYrGvH/JBjOin+RQtxxC9tzNNiy4shnGtGyyQ==} + peerDependencies: + eslint: ^9.5.0 || ^10.0.0 + + eslint-flat-config-utils@3.2.0: + resolution: {integrity: sha512-PHgo1X5uqIorJONLVD9BIaOSdoYFD3z/AeJljdqDPlWVRpeCYkDbK9k0AXoYVqqNJr6FEYIEr5Rm2TSktLQcHw==} + + eslint-json-compat-utils@0.2.3: + resolution: {integrity: sha512-RbBmDFyu7FqnjE8F0ZxPNzx5UaptdeS9Uu50r7A+D7s/+FCX+ybiyViYEgFUaFIFqSWJgZRTpL5d8Kanxxl2lQ==} + engines: {node: '>=12'} + peerDependencies: + '@eslint/json': '*' + eslint: '*' + jsonc-eslint-parser: ^2.4.0 || ^3.0.0 + peerDependenciesMeta: + '@eslint/json': + optional: true + + eslint-merge-processors@2.0.0: + resolution: {integrity: sha512-sUuhSf3IrJdGooquEUB5TNpGNpBoQccbnaLHsb1XkBLUPPqCNivCpY05ZcpCOiV9uHwO2yxXEWVczVclzMxYlA==} + peerDependencies: + eslint: '*' + + eslint-plugin-antfu@3.2.2: + resolution: {integrity: sha512-Qzixht2Dmd/pMbb5EnKqw2V8TiWHbotPlsORO8a+IzCLFwE0RxK8a9k4DCTFPzBwyxJzH+0m2Mn8IUGeGQkyUw==} + peerDependencies: + eslint: '*' + + eslint-plugin-command@3.5.2: + resolution: {integrity: sha512-PA59QAkQDwvcCMEt5lYLJLI3zDGVKJeC4id/pcRY2XdRYhSGW7iyYT1VC1N3bmpuvu6Qb/9QptiS3GJMjeGTJg==} + peerDependencies: + '@typescript-eslint/rule-tester': '*' + '@typescript-eslint/typescript-estree': '*' + '@typescript-eslint/utils': '*' + eslint: '*' + + eslint-plugin-depend@1.5.0: + resolution: {integrity: sha512-i3UeLYmclf1Icp35+6W7CR4Bp2PIpDgBuf/mpmXK5UeLkZlvYJ21VuQKKHHAIBKRTPivPGX/gZl5JGno1o9Y0A==} + peerDependencies: + eslint: '>=8.40.0' + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-import-lite@0.6.0: + resolution: {integrity: sha512-80vevx2A7i3H7n1/6pqDO8cc5wRz6OwLDvIyVl9UflBV1N1f46e9Ihzi65IOLYoSxM6YykK2fTw1xm0Ixx6aTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + eslint-plugin-jsdoc@62.9.0: + resolution: {integrity: sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-jsonc@3.1.2: + resolution: {integrity: sha512-dopTxdB22iuOkgKyJCupEC5IYBItUT4J/teq1H5ddUObcaYhOURxtJElZczdcYnnKCghNU/vccuyPkliy2Wxsg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-n@17.24.0: + resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=8.23.0' + + eslint-plugin-no-only-tests@3.3.0: + resolution: {integrity: sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==} + engines: {node: '>=5.0.0'} + + eslint-plugin-perfectionist@5.9.0: + resolution: {integrity: sha512-8TWzg02zmnBdZwCkWLi8jhzqXI+fE7Z/RwV8SL6xD45tJ8Bp3wGuYL2XtQgfe/Wd0eBqOUX+s6ey73IyszvKTA==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-pnpm@1.6.0: + resolution: {integrity: sha512-dxmt9r3zvPaft6IugS4i0k16xag3fTbOvm/road5uV9Y8qUCQT0xzheSh3gMlYAlC6vXRpfArBDsTZ7H7JKCbg==} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + eslint-plugin-regexp@3.1.0: + resolution: {integrity: sha512-qGXIC3DIKZHcK1H9A9+Byz9gmndY6TTSRkSMTZpNXdyCw2ObSehRgccJv35n9AdUakEjQp5VFNLas6BMXizCZg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-toml@1.3.1: + resolution: {integrity: sha512-1l00fBP03HIt9IPV7ZxBi7x0y0NMdEZmakL1jBD6N/FoKBvfKxPw5S8XkmzBecOnFBTn5Z8sNJtL5vdf9cpRMQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-unicorn@64.0.0: + resolution: {integrity: sha512-rNZwalHh8i0UfPlhNwg5BTUO1CMdKNmjqe+TgzOTZnpKoi8VBgsW7u9qCHIdpxEzZ1uwrJrPF0uRb7l//K38gA==} + engines: {node: ^20.10.0 || >=21.0.0} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-unused-imports@4.4.1: + resolution: {integrity: sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==} + peerDependencies: + '@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0 + eslint: ^10.0.0 || ^9.0.0 || ^8.0.0 + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + + eslint-plugin-vue@10.8.0: + resolution: {integrity: sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-eslint-parser: ^10.0.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-plugin-yml@3.3.1: + resolution: {integrity: sha512-isntsZchaTqDMNNkD+CakrgA/pdUoJ45USWBKpuqfAW1MCuw731xX/vrXfoJFZU3tTFr24nCbDYmDfT2+g4QtQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + eslint: '>=9.38.0' + + eslint-processor-vue-blocks@2.0.0: + resolution: {integrity: sha512-u4W0CJwGoWY3bjXAuFpc/b6eK3NQEI8MoeW7ritKj3G3z/WtHrKjkqf+wk8mPEy5rlMGS+k6AZYOw2XBoN/02Q==} + peerDependencies: + '@vue/compiler-sfc': ^3.3.0 + eslint: '>=9.0.0' + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.5.0: + resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@1.0.1: + resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eta@3.5.0: + resolution: {integrity: sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==} + engines: {node: '>=6.0.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-copy@4.0.2: + resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + + fast-jwt@5.0.6: + resolution: {integrity: sha512-LPE7OCGUl11q3ZgW681cEU2d0d2JZ37hhJAmetCgNyW8waVaJVZXhyFF6U2so1Iim58Yc7pfxJe2P7MNetQH2g==} + engines: {node: '>=20'} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastfall@1.5.1: + resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==} + engines: {node: '>=0.10.0'} + + fastify-keycloak-adapter@3.0.2: + resolution: {integrity: sha512-Zm5PowNKxgHPztM1wC0hJmnFYPrUoduREEIzxwz2dnTpMm5qZkp7btweTuGW5AzDbjBu3EVM0fNyPxyR3OkwwA==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + + fastparallel@2.4.1: + resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fastseries@1.7.2: + resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==} + + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + file-entry-cache@11.1.2: + resolution: {integrity: sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-type@21.3.0: + resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} + engines: {node: '>=20'} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flat-cache@6.1.20: + resolution: {integrity: sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + focus-trap-vue@4.1.0: + resolution: {integrity: sha512-RvK28mED+4fqoqnJQyadfoiKZU2G/QEez+EnJtdsg83DjiuKetimvDGas9ot4p5bn1J09rDmKLtKFMBkwQc+JA==} + peerDependencies: + focus-trap: ^7.0.0 + vue: ^3.0.0 + + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fork-ts-checker-webpack-plugin@9.1.0: + resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} + engines: {node: '>=14.21.3'} + peerDependencies: + typescript: '>3.6.0' + webpack: ^5.11.0 + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fp-ts@2.16.11: + resolution: {integrity: sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-monkey@1.1.0: + resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-own-enumerable-property-symbols@3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + git-raw-commits@5.0.1: + resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} + engines: {node: '>=18'} + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. + hasBin: true + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + global-prefix@4.0.0: + resolution: {integrity: sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==} + engines: {node: '>=16'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@16.2.0: + resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} + engines: {node: '>=20'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + good-listener@1.2.2: + resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grant@5.4.24: + resolution: {integrity: sha512-PD5AvSI7wgCBDi2mEd6M/TIe+70c/fVc3Ik4B0s4mloWTy9J800eUEcxivOiyqSP9wvBy2QjWq1JR8gOfDMnEg==} + engines: {node: '>=12.0.0'} + + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + + hashery@1.5.0: + resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==} + engines: {node: '>=20'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + hmac-drbg@1.0.1: + resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-tags@5.1.0: + resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==} + engines: {node: '>=20.10'} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http2-client@1.3.5: + resolution: {integrity: sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-in-the-middle@2.0.6: + resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + + import-in-the-middle@3.0.0: + resolution: {integrity: sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==} + engines: {node: '>=18'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + io-ts@2.2.22: + resolution: {integrity: sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA==} + peerDependencies: + fp-ts: ^2.5.0 + + ip-regex@4.3.0: + resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} + engines: {node: '>=8'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-builtin-module@5.0.0: + resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} + engines: {node: '>=18.20'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-cidr@4.0.2: + resolution: {integrity: sha512-z4a1ENUajDbEl/Q6/pVBpTR1nBjjEE1X7qb7bmWYanNnPoKAvUCPFKeXV6Fe4mgTkWKBqiHIcwsI3SndiO5FeA==} + engines: {node: '>=10'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@1.0.1: + resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} + engines: {node: '>=0.10.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-regexp@1.0.0: + resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} + engines: {node: '>=0.10.0'} + + is-retry-allowed@2.2.0: + resolution: {integrity: sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==} + engines: {node: '>=10'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + javascript-time-ago@2.6.4: + resolution: {integrity: sha512-7K/Z37LuwVaxxjutUDd1pXpznufPcox0b1UYu00ksAMMlV6IsxIvduwL3kgfPxuBVF8jVj7nhrKMPDslMq94aQ==} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + jsdoc-type-pratt-parser@7.1.1: + resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} + engines: {node: '>=20.0.0'} + + jsdoc-type-pratt-parser@7.2.0: + resolution: {integrity: sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==} + engines: {node: '>=20.0.0'} + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-2-csv@5.5.10: + resolution: {integrity: sha512-Dep8wO3Fr5wNjQevO2Z8Y7yeee/nYSGRsi7q6zJDKEVHxXkXT+v21vxHmDX923UzmCXXkSo62HaTz6eTWzFLaw==} + engines: {node: '>= 16'} + deprecated: A security vulnerability has been reported with the preventCsvInjection option which has been fixed in version 5.5.11. Please upgrade as soon as possible. + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-eslint-parser@3.1.0: + resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jwk-to-pem@2.0.7: + resolution: {integrity: sha512-cSVphrmWr6reVchuKQZdfSs4U9c5Y4hwZggPoz6cbVnTpAVgGRpEuQng86IyqLeGZlhTh+c4MAreB6KbdQDKHQ==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + katex@0.16.45: + resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} + hasBin: true + + keycloak-js@26.2.3: + resolution: {integrity: sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + + loader-runner@4.3.2: + resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} + engines: {node: '>=6.11.5'} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + magic-regexp@0.10.0: + resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} + + magic-string@0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mathml-tag-names@4.0.0: + resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + memfs@3.5.3: + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} + engines: {node: '>= 4.0.0'} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} + engines: {node: '>=20'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimalistic-crypto-utils@1.0.1: + resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.1: + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} + + mnemonist@0.40.4: + resolution: {integrity: sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==} + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + module-replacements@2.11.0: + resolution: {integrity: sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA==} + + moo@0.5.3: + resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.12.10: + resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + multer@2.1.1: + resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} + engines: {node: '>= 10.16.0'} + + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@5.0.9: + resolution: {integrity: sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==} + engines: {node: ^18 || >=20} + hasBin: true + + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + natural-orderby@5.0.0: + resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} + engines: {node: '>=18'} + + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + nestjs-pino@4.6.0: + resolution: {integrity: sha512-MzSgnOu9MhRT/f7MsvoDnxat11D9JRJYwL1t+tI6J44UrNz9rUVDpceEh9VFsyfiiIJKUri5S+/snMOoaWh7YA==} + engines: {node: '>= 14'} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + pino: ^7.5.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-emoji@1.11.0: + resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + + node-fetch-h2@2.3.0: + resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==} + engines: {node: 4.x || >=6.0.0} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-readfiles@0.2.0: + resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==} + + node-releases@2.0.48: + resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} + engines: {node: '>=18'} + + nodemon@3.1.14: + resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + nypm@0.6.5: + resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} + engines: {node: '>=18'} + hasBin: true + + oas-kit-common@1.0.8: + resolution: {integrity: sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==} + + oas-linter@3.2.2: + resolution: {integrity: sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==} + + oas-resolver@2.5.6: + resolution: {integrity: sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==} + hasBin: true + + oas-schema-walker@1.1.5: + resolution: {integrity: sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==} + + oas-validator@5.0.8: + resolution: {integrity: sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==} + + oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-deep-merge@2.0.0: + resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + + openapi3-ts@2.0.2: + resolution: {integrity: sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + oxc-parser@0.115.0: + resolution: {integrity: sha512-2w7Xn3CbS/zwzSY82S5WLemrRu3CT57uF7Lx8llrE/2bul6iMTcJE4Rbls7GDNbLn3ttATI68PfOz2Pt3KZ2cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-walker@0.7.0: + resolution: {integrity: sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A==} + peerDependencies: + oxc-parser: '>=0.98.0' + + p-debounce@4.0.0: + resolution: {integrity: sha512-4Ispi9I9qYGO4lueiLDhe4q4iK5ERK8reLsuzH6BPaXn53EGaua8H66PXIFGrW897hwjXp+pVLrm/DLxN0RF0A==} + engines: {node: '>=12'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-gitignore@2.0.0: + resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==} + engines: {node: '>=14'} + + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch-browser@2.2.6: + resolution: {integrity: sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==} + engines: {node: '>=8.6'} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-http@11.0.0: + resolution: {integrity: sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + pnpm-workspace-yaml@1.6.0: + resolution: {integrity: sha512-uUy4dK3E11sp7nK+hnT7uAWfkBMe00KaUw8OG3NuNlYQoTk4sc9pcdIy1+XIP85v9Tvr02mK3JPaNNrP0QyRaw==} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-html@1.8.1: + resolution: {integrity: sha512-OLF6P7qctfAWayOhLpcVnTGqVeJzu2W3WpIYelfz2+JV5oGxfkcEvweN9U4XpeqE0P98dcD9ssusGwlF0TK0uQ==} + engines: {node: ^12 || >=14} + + postcss-safe-parser@6.0.0: + resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.3.3 + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + + pretty-bytes@6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} + + prisma@6.19.2: + resolution: {integrity: sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + protobufjs@7.6.4: + resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} + engines: {node: '>=12.0.0'} + + protobufjs@8.0.0: + resolution: {integrity: sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==} + engines: {node: '>=12.0.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qified@0.6.0: + resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} + engines: {node: '>=20'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + rate-limiter-flexible@4.0.1: + resolution: {integrity: sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ==} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + re2-wasm@1.0.2: + resolution: {integrity: sha512-VXUdgSiUrE/WZXn6gUIVVIsg0+Hp6VPZPOaHCay+OuFKy6u/8ktmeNEf+U5qSA8jzGGFsg8jrDNu1BeHpz2pJA==} + engines: {node: '>=10'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + refa@0.12.1: + resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + reftools@1.1.9: + resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regexp-ast-analysis@0.7.1: + resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + + relative-time-format@1.1.12: + resolution: {integrity: sha512-qaZBjmRIuXLfuLnzgqpFdBPa5W0euSX1tMnoMUHGPphLwJmrt8xbNiOIHrlvYOD6oNJ0M5owPCZyPibI8de5pQ==} + + request-compose@2.1.7: + resolution: {integrity: sha512-27amNkWTK4Qq25XEwdmrhb4VLMiQzRSKuDfsy1o1griykcyXk5MxMHmJG+OKTRdO9PgsO7Kkn7GrEkq0UAIIMQ==} + engines: {node: '>=12.0.0'} + + request-oauth@1.0.1: + resolution: {integrity: sha512-85THTg1RgOYtqQw42JON6AqvHLptlj1biw265Tsq4fD4cPdUvhDB2Qh9NTv17yCD322ROuO9aOmpc4GyayGVBA==} + engines: {node: '>=8.0.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + + rolldown@1.0.0-rc.18: + resolution: {integrity: sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@2.80.0: + resolution: {integrity: sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==} + engines: {node: '>=10.0.0'} + hasBin: true + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + schemes@1.4.0: + resolution: {integrity: sha512-ImFy9FbCsQlVgnE3TCWmLPCFnVzx0lHL/l+umHplDqAKd0dzFpnS6lFZIpagBlYhKwzVmlV36ec0Y1XTu8JBAQ==} + + scslre@0.3.0: + resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} + engines: {node: ^14.0.0 || >=16.0.0} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + select@1.1.2: + resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + short-uuid@5.2.0: + resolution: {integrity: sha512-296/Nzi4DmANh93iYBwT4NoYRJuHnKEzefrkSagQbTH/A6NTaB68hSPDjm5IlbI5dx9FXdmtqPcj6N5H+CPm6w==} + engines: {node: '>=14'} + + should-equal@2.0.0: + resolution: {integrity: sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==} + + should-format@3.0.3: + resolution: {integrity: sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==} + + should-type-adaptors@1.1.0: + resolution: {integrity: sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==} + + should-type@1.4.0: + resolution: {integrity: sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==} + + should-util@1.0.1: + resolution: {integrity: sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==} + + should@13.2.3: + resolution: {integrity: sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + + smob@1.6.2: + resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} + engines: {node: '>=20.0.0'} + + smtp-address-parser@1.1.0: + resolution: {integrity: sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==} + engines: {node: '>=0.10'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions + + sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@4.0.0: + resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + steed@1.1.3: + resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-object@3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-comments@2.0.1: + resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} + engines: {node: '>=10'} + + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + strtok3@10.3.4: + resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} + engines: {node: '>=18'} + + stylelint-config-html@1.1.0: + resolution: {integrity: sha512-IZv4IVESjKLumUGi+HWeb7skgO6/g4VMuAYrJdlqQFndgbj6WJAXPhaysvBiXefX79upBdQVumgYcdd17gCpjQ==} + engines: {node: ^12 || >=14} + peerDependencies: + postcss-html: ^1.0.0 + stylelint: '>=14.0.0' + + stylelint-config-recommended-vue@1.6.1: + resolution: {integrity: sha512-lLW7hTIMBiTfjenGuDq2kyHA6fBWd/+Df7MO4/AWOxiFeXP9clbpKgg27kHfwA3H7UNMGC7aeP3mNlZB5LMmEQ==} + engines: {node: ^12 || >=14} + peerDependencies: + postcss-html: ^1.0.0 + stylelint: '>=14.0.0' + + stylelint-config-recommended@18.0.0: + resolution: {integrity: sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint-config-standard@40.0.0: + resolution: {integrity: sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint@17.11.0: + resolution: {integrity: sha512-/3czzmbF9XdGWvReDF3Ex4R23Ajolo7j8RB2bFNEqk6Ht356nlpVV+G5bG2Qt8AW1ofJzXztBRDnAtd7cgowWA==} + engines: {node: '>=20.19.0'} + hasBin: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@4.4.0: + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} + engines: {node: '>=20'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + swagger-schema-official@2.0.0-bab6bed: + resolution: {integrity: sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA==} + + swagger-typescript-api@13.3.1: + resolution: {integrity: sha512-fSYf55kY0nV2TxS131h35sl+HaLNiZabF+A5ffFC7TCMlHTAVCSlmsh3MPXFaWYk8CcMLSFg4q4vDlWkhBDdAA==} + engines: {node: '>=20'} + hasBin: true + + swagger-ui-dist@5.32.4: + resolution: {integrity: sha512-0AADFFQNJzExEN49SrD/34Nn9cxNxVLiydYl2MBwSZFPVXNkVwC/EFAjoezGGqE8oDegiDC+p47t8lKObCinMQ==} + + swagger2openapi@7.0.8: + resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==} + hasBin: true + + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + temp-dir@2.0.0: + resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} + engines: {node: '>=8'} + + tempy@0.6.0: + resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==} + engines: {node: '>=10'} + + terser-webpack-plugin@5.6.1: + resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + engines: {node: '>=10'} + hasBin: true + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tiny-emitter@2.1.0: + resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyduration@3.4.1: + resolution: {integrity: sha512-NemFoamVYn7TmtwZKZ3OiliM9fZkr6EWiTM+wKknco6POSy2gS689xx/pXip0JYp40HXpUw6k65CUYHWYUXdaA==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts-core@7.4.3: + resolution: {integrity: sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tldts@7.4.3: + resolution: {integrity: sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + toml-eslint-parser@1.0.3: + resolution: {integrity: sha512-A5F0cM6+mDleacLIEUkmfpkBbnHJFV1d2rprHU2MXNk7mlxHq2zGojA+SRvQD1RoMo9gqjZPWEaKG4v1BQ48lw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-declaration-location@1.0.7: + resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} + peerDependencies: + typescript: '>=4.0.0' + + ts-deepmerge@6.2.1: + resolution: {integrity: sha512-8CYSLazCyj0DJDpPIxOFzJG46r93uh6EynYjuey+bxcLltBeqZL7DMfaE5ZPzZNFlav7wx+2TDa/mBl8gkTYzw==} + engines: {node: '>=14.13.1'} + + ts-essentials@10.1.1: + resolution: {integrity: sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==} + peerDependencies: + typescript: '>=4.5.0' + peerDependenciesMeta: + typescript: + optional: true + + ts-patch@3.3.0: + resolution: {integrity: sha512-zAOzDnd5qsfEnjd9IGy1IRuvA7ygyyxxdxesbhMdutt8AHFjD8Vw8hU2rMF89HX1BKRWFYqKHrO8Q6lw0NeUZg==} + hasBin: true + + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.16.0: + resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} + engines: {node: '>=10'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + engines: {node: '>=20'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + type-level-regexp@0.1.17: + resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript-eslint@8.57.0: + resolution: {integrity: sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript-transform-paths@3.5.6: + resolution: {integrity: sha512-3eQTG6Ogt+pgPEh45uX2s9OwcfAVjWnyNO+osjYcOqYaWDVMIFUkqW8e0O1cOaVwdMqQFQf6alDT+76xmeS2Ag==} + peerDependencies: + typescript: '>=3.6.5' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + unconfig@7.5.0: + resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@7.24.5: + resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} + engines: {node: '>=20.18.1'} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + + unimport@3.14.6: + resolution: {integrity: sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==} + + unique-string@2.0.0: + resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} + engines: {node: '>=8'} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unocss@66.6.6: + resolution: {integrity: sha512-PRKK945e2oZKHV664MA5Z9CDHbvY/V79IvTOUWKZ514jpl3UsJU3sS+skgxmKJSmwrWvXE5OVcmPthJrD/7vxg==} + engines: {node: '>=14'} + peerDependencies: + '@unocss/astro': 66.6.6 + '@unocss/postcss': 66.6.6 + '@unocss/webpack': 66.6.6 + peerDependenciesMeta: + '@unocss/astro': + optional: true + '@unocss/postcss': + optional: true + '@unocss/webpack': + optional: true + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unplugin-auto-import@0.18.6: + resolution: {integrity: sha512-LMFzX5DtkTj/3wZuyG5bgKBoJ7WSgzqSGJ8ppDRdlvPh45mx6t6w3OcbExQi53n3xF5MYkNGPNR/HYOL95KL2A==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': ^3.2.2 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + + unplugin-vue-components@0.27.5: + resolution: {integrity: sha512-m9j4goBeNwXyNN8oZHHxvIIYiG8FQ9UfmKWeNllpDvhU7btKNNELGPt+o3mckQKuPwrE7e0PvCsx+IWuDSD9Vg==} + engines: {node: '>=14'} + peerDependencies: + '@babel/parser': ^7.15.8 + '@nuxt/kit': ^3.2.2 + vue: 2 || 3 + peerDependenciesMeta: + '@babel/parser': + optional: true + '@nuxt/kit': + optional: true + + unplugin@1.16.1: + resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} + engines: {node: '>=14.0.0'} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + + upath@1.2.0: + resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} + engines: {node: '>=4'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-template@3.1.1: + resolution: {integrity: sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-node@6.0.0: + resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + vite-plugin-pwa@1.2.0: + resolution: {integrity: sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@vite-pwa/assets-generator': ^1.0.0 + vite: ^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + workbox-build: ^7.4.0 + workbox-window: ^7.4.0 + peerDependenciesMeta: + '@vite-pwa/assets-generator': + optional: true + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vite@8.0.11: + resolution: {integrity: sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest-mock-extended@2.0.2: + resolution: {integrity: sha512-n3MBqVITKyclZ0n0y66hkT4UiiEYFQn9tteAnIxT0MPz1Z8nFcPUG3Cf0cZOyoPOj/cq6Ab1XFw2lM/qM5EDWQ==} + peerDependencies: + typescript: 3.x || 4.x || 5.x + vitest: '>=2.0.0' + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-eslint-parser@10.4.0: + resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue3-json-viewer@2.4.1: + resolution: {integrity: sha512-Z1sunvS58lJ3ZcpNhl3jYQapBVw2wjnXbemigfMWm3QnjCeg3CPMq8R6pxHUYahxMfPKLvrbGve6mUXqhWyLaQ==} + peerDependencies: + vue: ^3.5.16 + + vue@3.5.30: + resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + watchpack@2.5.2: + resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + webpack-node-externals@3.0.0: + resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} + engines: {node: '>=6'} + + webpack-sources@3.5.0: + resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} + engines: {node: '>=10.13.0'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + webpack@5.104.1: + resolution: {integrity: sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + wildcard-match@5.1.4: + resolution: {integrity: sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g==} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + workbox-background-sync@7.4.0: + resolution: {integrity: sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==} + + workbox-broadcast-update@7.4.0: + resolution: {integrity: sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==} + + workbox-build@7.4.0: + resolution: {integrity: sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==} + engines: {node: '>=20.0.0'} + + workbox-cacheable-response@7.4.0: + resolution: {integrity: sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==} + + workbox-core@7.4.0: + resolution: {integrity: sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==} + + workbox-expiration@7.4.0: + resolution: {integrity: sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==} + + workbox-google-analytics@7.4.0: + resolution: {integrity: sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==} + + workbox-navigation-preload@7.4.0: + resolution: {integrity: sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==} + + workbox-precaching@7.4.0: + resolution: {integrity: sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==} + + workbox-range-requests@7.4.0: + resolution: {integrity: sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==} + + workbox-recipes@7.4.0: + resolution: {integrity: sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==} + + workbox-routing@7.4.0: + resolution: {integrity: sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==} + + workbox-strategies@7.4.0: + resolution: {integrity: sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==} + + workbox-streams@7.4.0: + resolution: {integrity: sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==} + + workbox-sw@7.4.0: + resolution: {integrity: sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==} + + workbox-window@7.4.0: + resolution: {integrity: sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xbytes@1.9.1: + resolution: {integrity: sha512-29E0ygMFWrM5JW2W1ypmezjyR2FOS5aCszdLe620ymSJBoZzL0/RCLJKCoUFu3DfQGhZ/FWykEBp5dfEy6+hjA==} + engines: {node: '>=1'} + + xcase@2.0.1: + resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml-eslint-parser@2.0.0: + resolution: {integrity: sha512-h0uDm97wvT2bokfwwTmY6kJ1hp6YDFL0nRHwNKz8s/VD1FH/vvZjAKoMUE+un0eaYBSG7/c6h+lJTP+31tjgTw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yummies@7.10.0: + resolution: {integrity: sha512-BXMmEYzFr/DtSvr1VMvctPVxQ/BZnx35oY2AGs6clkqgWnflsjqfuVle+8rH2uiMenmEf4I3OH9bRRW86yYyyg==} + peerDependencies: + mobx: ^6.12.4 + react: ^18 || ^19 + peerDependenciesMeta: + mobx: + optional: true + react: + optional: true + + zod-validation-error@3.5.4: + resolution: {integrity: sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.24.4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@anatine/zod-openapi@1.14.2(openapi3-ts@2.0.2)(zod@3.25.76)': + dependencies: + openapi3-ts: 2.0.2 + ts-deepmerge: 6.2.1 + zod: 3.25.76 + + '@angular-devkit/core@19.2.17(chokidar@4.0.3)': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + jsonc-parser: 3.3.1 + picomatch: 4.0.2 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/core@19.2.19(chokidar@4.0.3)': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + jsonc-parser: 3.3.1 + picomatch: 4.0.2 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/schematics-cli@19.2.19(@types/node@24.12.0)(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) + '@inquirer/prompts': 7.3.2(@types/node@24.12.0) + ansi-colors: 4.1.3 + symbol-observable: 4.0.0 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - '@types/node' + - chokidar + + '@angular-devkit/schematics@19.2.17(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.17(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@19.2.19(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@antfu/eslint-config@8.2.0(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5)': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@clack/prompts': 1.3.0 + '@e18e/eslint-plugin': 0.3.0(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint/markdown': 8.0.1 + '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@vitest/eslint-plugin': 1.6.17(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5) + ansis: 4.2.0 + cac: 7.0.0 + eslint: 10.5.0(jiti@2.6.1) + eslint-config-flat-gitignore: 2.3.0(eslint@10.5.0(jiti@2.6.1)) + eslint-flat-config-utils: 3.2.0 + eslint-merge-processors: 2.0.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-antfu: 3.2.2(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-import-lite: 0.6.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-jsdoc: 62.9.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-jsonc: 3.1.2(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-n: 17.24.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-no-only-tests: 3.3.0 + eslint-plugin-perfectionist: 5.9.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-pnpm: 1.6.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-regexp: 3.1.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-toml: 1.3.1(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-unicorn: 64.0.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + eslint-plugin-yml: 3.3.1(eslint@10.5.0(jiti@2.6.1)) + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1)) + globals: 17.6.0 + local-pkg: 1.1.2 + parse-gitignore: 2.0.0 + toml-eslint-parser: 1.0.3 + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + yaml-eslint-parser: 2.0.0 + transitivePeerDependencies: + - '@eslint/json' + - '@typescript-eslint/rule-tester' + - '@typescript-eslint/typescript-estree' + - '@typescript-eslint/utils' + - '@vue/compiler-sfc' + - oxlint + - supports-color + - typescript + - vitest + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.2.4 + + '@antfu/utils@0.7.10': {} + + '@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)': + dependencies: + ajv: 8.20.0 + jsonpointer: 5.0.1 + leven: 3.1.0 + + '@apidevtools/json-schema-ref-parser@14.0.1': + dependencies: + '@types/json-schema': 7.0.15 + js-yaml: 4.2.0 + + '@apidevtools/openapi-schemas@2.1.0': {} + + '@apidevtools/swagger-methods@3.0.2': {} + + '@apidevtools/swagger-parser@12.1.0(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 14.0.1 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@5.5.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + debug: 4.4.3(supports-color@5.5.0) + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-wrap-function': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helper-wrap-function@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + + '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/template': 7.29.7 + + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-dotall-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-keys@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-dynamic-import@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-exponentiation-operator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-member-expression-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-amd@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-systemjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regenerator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regexp-modifiers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-reserved-words@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-spread@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-typeof-symbol@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-property-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/preset-env@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7) + '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7) + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-systemjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.7) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + core-js-compat: 3.49.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/types': 7.29.7 + esutils: 2.0.3 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@biomejs/js-api@4.0.0(@biomejs/wasm-nodejs@2.4.6)': + optionalDependencies: + '@biomejs/wasm-nodejs': 2.4.6 + + '@biomejs/wasm-nodejs@2.4.6': {} + + '@borewit/text-codec@0.2.1': {} + + '@cacheable/memory@2.0.8': + dependencies: + '@cacheable/utils': 2.4.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.4.0': + dependencies: + hashery: 1.5.0 + keyv: 5.6.0 + + '@clack/core@1.3.0': + dependencies: + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@clack/prompts@1.3.0': + dependencies: + '@clack/core': 1.3.0 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@colors/colors@1.5.0': + optional: true + + '@commitlint/cli@20.5.3(@types/node@24.12.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': + dependencies: + '@commitlint/format': 20.5.0 + '@commitlint/lint': 20.5.3 + '@commitlint/load': 20.5.3(@types/node@24.12.0)(typescript@5.9.3) + '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) + '@commitlint/types': 20.5.0 + tinyexec: 1.2.4 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-conventionalcommits: 9.3.1 + + '@commitlint/config-validator@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + ajv: 8.20.0 + + '@commitlint/ensure@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + es-toolkit: 1.48.1 + + '@commitlint/execute-rule@20.0.0': {} + + '@commitlint/format@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + semver: 7.8.5 + + '@commitlint/lint@20.5.3': + dependencies: + '@commitlint/is-ignored': 20.5.0 + '@commitlint/parse': 20.5.0 + '@commitlint/rules': 20.5.3 + '@commitlint/types': 20.5.0 + + '@commitlint/load@20.5.3(@types/node@24.12.0)(typescript@5.9.3)': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/execute-rule': 20.0.0 + '@commitlint/resolve-extends': 20.5.3 + '@commitlint/types': 20.5.0 + cosmiconfig: 9.0.2(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@24.12.0)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3) + es-toolkit: 1.48.1 + is-plain-obj: 4.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@20.4.3': {} + + '@commitlint/parse@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 + + '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': + dependencies: + '@commitlint/top-level': 20.4.3 + '@commitlint/types': 20.5.0 + git-raw-commits: 5.0.1(conventional-commits-parser@6.4.0) + minimist: 1.2.8 + tinyexec: 1.2.4 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@20.5.3': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/types': 20.5.0 + es-toolkit: 1.48.1 + global-directory: 5.0.0 + import-meta-resolve: 4.2.0 + resolve-from: 5.0.0 + + '@commitlint/rules@20.5.3': + dependencies: + '@commitlint/ensure': 20.5.3 + '@commitlint/message': 20.4.3 + '@commitlint/to-lines': 20.0.0 + '@commitlint/types': 20.5.0 + + '@commitlint/to-lines@20.0.0': {} + + '@commitlint/top-level@20.4.3': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@20.5.0': + dependencies: + conventional-commits-parser: 6.4.0 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@2.7.0(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.8.5 + optionalDependencies: + conventional-commits-parser: 6.4.0 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/css-tokenizer@4.0.0': {} + + '@csstools/media-query-list-parser@5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/selector-resolve-nested@4.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@e18e/eslint-plugin@0.3.0(eslint@10.5.0(jiti@2.6.1))': + dependencies: + eslint-plugin-depend: 1.5.0(eslint@10.5.0(jiti@2.6.1)) + optionalDependencies: + eslint: 10.5.0(jiti@2.6.1) + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@es-joy/jsdoccomment@0.84.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.61.1 + comment-parser: 1.4.5 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.1.1 + + '@es-joy/jsdoccomment@0.86.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.61.1 + comment-parser: 1.4.6 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.2.0 + + '@es-joy/resolve.exports@1.2.0': {} + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@eslint-community/eslint-plugin-eslint-comments@4.7.1(eslint@10.5.0(jiti@2.6.1))': + dependencies: + escape-string-regexp: 4.0.0 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.6.1))': + dependencies: + eslint: 10.5.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/compat@2.0.3(eslint@10.5.0(jiti@2.6.1))': + dependencies: + '@eslint/core': 1.2.1 + optionalDependencies: + eslint: 10.5.0(jiti@2.6.1) + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3(supports-color@5.5.0) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/markdown@8.0.1': + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + github-slugger: 2.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-frontmatter: 2.0.1 + mdast-util-gfm: 3.1.0 + mdast-util-math: 3.0.0 + micromark-extension-frontmatter: 2.0.0 + micromark-extension-gfm: 3.0.0 + micromark-extension-math: 3.1.0 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@exodus/schemasafe@1.3.0': {} + + '@faker-js/faker@9.9.0': {} + + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.1.1 + fastify-plugin: 5.1.0 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/formbody@8.0.2': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/helmet@13.0.2': + dependencies: + fastify-plugin: 5.1.0 + helmet: 8.1.0 + + '@fastify/jwt@9.1.0': + dependencies: + '@fastify/error': 4.2.0 + '@lukeed/ms': 2.0.2 + fast-jwt: 5.0.6 + fastify-plugin: 5.1.0 + steed: 1.1.3 + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/otel@0.18.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/session@11.1.1': + dependencies: + fastify-plugin: 5.1.0 + safe-stable-stringify: 2.5.0 + + '@fastify/static@9.1.3': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@fastify/swagger-ui@5.2.6': + dependencies: + '@fastify/static': 9.1.3 + fastify-plugin: 5.1.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + + '@fastify/swagger@9.7.0': + dependencies: + fastify-plugin: 5.1.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + transitivePeerDependencies: + - supports-color + + '@gitbeaker/core@40.6.0': + dependencies: + '@gitbeaker/requester-utils': 40.6.0 + qs: 6.15.2 + xcase: 2.0.1 + + '@gitbeaker/requester-utils@40.6.0': + dependencies: + picomatch-browser: 2.2.6 + qs: 6.15.2 + rate-limiter-flexible: 4.0.1 + xcase: 2.0.1 + + '@gitbeaker/rest@40.6.0': + dependencies: + '@gitbeaker/core': 40.6.0 + '@gitbeaker/requester-utils': 40.6.0 + + '@gouvfr/dsfr@1.14.4': {} + + '@gouvminint/vue-dsfr@8.15.0(@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3)))(vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@gouvfr/dsfr': 1.14.4 + '@iconify/vue': 4.3.0(vue@3.5.30(typescript@5.9.3)) + focus-trap: 7.8.0 + focus-trap-vue: 4.1.0(focus-trap@7.8.0)(vue@3.5.30(typescript@5.9.3)) + vue: 3.5.30(typescript@5.9.3) + vue-router: 4.6.4(vue@3.5.30(typescript@5.9.3)) + + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.4 + yargs: 17.7.3 + + '@himenon/argocd-typescript-openapi@1.2.2': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@iconify-json/ri@1.2.10': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.1 + + '@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@iconify/types': 2.0.0 + vue: 3.5.30(typescript@5.9.3) + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/confirm@5.1.21(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/core@10.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/editor@4.2.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/external-editor': 1.0.3(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/expand@4.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/external-editor@1.0.3(@types/node@24.12.0)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/number@3.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/password@4.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/prompts@7.10.1(@types/node@24.12.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.12.0) + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@inquirer/editor': 4.2.23(@types/node@24.12.0) + '@inquirer/expand': 4.0.23(@types/node@24.12.0) + '@inquirer/input': 4.3.1(@types/node@24.12.0) + '@inquirer/number': 3.0.23(@types/node@24.12.0) + '@inquirer/password': 4.0.23(@types/node@24.12.0) + '@inquirer/rawlist': 4.1.11(@types/node@24.12.0) + '@inquirer/search': 3.2.2(@types/node@24.12.0) + '@inquirer/select': 4.4.2(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/prompts@7.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.12.0) + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@inquirer/editor': 4.2.23(@types/node@24.12.0) + '@inquirer/expand': 4.0.23(@types/node@24.12.0) + '@inquirer/input': 4.3.1(@types/node@24.12.0) + '@inquirer/number': 3.0.23(@types/node@24.12.0) + '@inquirer/password': 4.0.23(@types/node@24.12.0) + '@inquirer/rawlist': 4.1.11(@types/node@24.12.0) + '@inquirer/search': 3.2.2(@types/node@24.12.0) + '@inquirer/select': 4.4.2(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/rawlist@4.1.11(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/search@3.2.2(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/select@4.4.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/type@3.0.10(@types/node@24.12.0)': + optionalDependencies: + '@types/node': 24.12.0 + + '@isaacs/cliui@9.0.0': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@js-sdsl/ordered-map@4.4.2': {} + + '@keycloak/keycloak-admin-client@26.7.0': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + '@microsoft/kiota-http-fetchlibrary': 1.0.0-preview.103 + '@microsoft/kiota-serialization-form': 1.0.0-preview.103 + '@microsoft/kiota-serialization-json': 1.0.0-preview.103 + '@microsoft/kiota-serialization-multipart': 1.0.0-preview.103 + '@microsoft/kiota-serialization-text': 1.0.0-preview.103 + camelize-ts: 3.0.0 + url-template: 3.1.1 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@kubernetes-models/apimachinery@2.2.0': + dependencies: + '@kubernetes-models/base': 5.0.1 + '@kubernetes-models/validate': 4.0.0 + '@swc/helpers': 0.5.19 + + '@kubernetes-models/argo-cd@2.7.2': + dependencies: + '@kubernetes-models/apimachinery': 2.2.0 + '@kubernetes-models/base': 5.0.1 + '@kubernetes-models/validate': 4.0.0 + '@swc/helpers': 0.5.19 + + '@kubernetes-models/base@5.0.1': + dependencies: + '@kubernetes-models/validate': 4.0.0 + is-plain-object: 5.0.0 + tslib: 2.8.1 + + '@kubernetes-models/validate@4.0.0': + dependencies: + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-formats-draft2019: 1.6.1(ajv@8.20.0) + ajv-i18n: 4.2.0(ajv@8.20.0) + is-cidr: 4.0.2 + tslib: 2.8.1 + optionalDependencies: + re2-wasm: 1.0.2 + + '@lukeed/csprng@1.1.0': {} + + '@lukeed/ms@2.0.2': {} + + '@microsoft/kiota-abstractions@1.0.0-preview.103': + dependencies: + '@opentelemetry/api': 1.9.1 + '@std-uritemplate/std-uritemplate': 2.0.12 + tinyduration: 3.4.1 + tslib: 2.8.1 + + '@microsoft/kiota-http-fetchlibrary@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + '@opentelemetry/api': 1.9.1 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-form@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-json@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-multipart@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-text@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/tsdoc@0.16.0': {} + + '@mswjs/interceptors@0.41.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@nestjs/cache-manager@3.1.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cache-manager: 7.2.8 + keyv: 5.6.0 + rxjs: 7.8.2 + + '@nestjs/cli@11.0.16(@types/node@24.12.0)(esbuild@0.27.3)(lightningcss@1.32.0)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics-cli': 19.2.19(@types/node@24.12.0)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@24.12.0) + '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.9.3) + ansis: 4.2.0 + chokidar: 4.0.3 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)) + glob: 13.0.0 + node-emoji: 1.11.0 + ora: 5.4.1 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.9.3 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/html' + - '@types/node' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + - webpack-cli + + '@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.0 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/config@4.0.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.2.3 + dotenv-expand: 12.0.3 + lodash: 4.17.23 + rxjs: 7.8.2 + + '@nestjs/core@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxt/opencollective': 0.4.1 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.3.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + + '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + eventemitter2: 6.4.9 + + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.6 + express: 5.2.1 + multer: 2.1.1 + path-to-regexp: 8.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@nestjs/platform-fastify@11.1.27(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@fastify/cors': 11.2.0 + '@fastify/formbody': 8.0.2 + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-querystring: 1.1.2 + fastify: 5.8.5 + fastify-plugin: 6.0.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + path-to-regexp: 8.4.2 + reusify: 1.1.0 + tslib: 2.8.1 + optionalDependencies: + '@fastify/static': 9.1.3 + + '@nestjs/schedule@6.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 + + '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': + dependencies: + '@angular-devkit/core': 19.2.17(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.17(chokidar@4.0.3) + comment-json: 4.4.1 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.9.3 + transitivePeerDependencies: + - chokidar + + '@nestjs/swagger@11.4.1(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.4 + optionalDependencies: + '@fastify/static': 9.1.3 + + '@nestjs/terminus@11.1.1(@grpc/grpc-js@1.14.3)(@grpc/proto-loader@0.8.0)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + boxen: 5.1.2 + check-disk-space: 3.4.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + optionalDependencies: + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.8.0 + '@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + + '@nestjs/testing@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nuxt/opencollective@0.4.1': + dependencies: + consola: 3.4.2 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@opentelemetry/api-logs@0.212.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.213.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.214.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/auto-instrumentations-node@0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-amqplib': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-lambda': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-sdk': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-bunyan': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cassandra-driver': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-connect': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cucumber': 0.28.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dataloader': 0.29.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dns': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-express': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fastify': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fs': 0.31.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-generic-pool': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-graphql': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-hapi': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-kafkajs': 0.21.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-knex': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-koa': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-lru-memoizer': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-memcached': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongodb': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongoose': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql2': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-net': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-openai': 0.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-oracledb': 0.37.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-redis': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-restify': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-router': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-runtime-node': 0.25.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-socket.io': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-tedious': 0.31.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': 0.22.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-winston': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-alibaba-cloud': 0.33.3(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-aws': 2.13.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-azure': 0.20.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-container': 0.8.4(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-gcp': 0.47.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/configuration@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + yaml: 2.9.0 + + '@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/exporter-logs-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/exporter-trace-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/instrumentation-amqplib@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-lambda@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/aws-lambda': 8.10.161 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-sdk@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-bunyan@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@types/bunyan': 1.8.11 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cassandra-driver@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cucumber@0.28.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.29.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dns@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-express@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fastify@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.31.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.21.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-memcached@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/memcached': 2.2.10 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/mysql': 2.15.27 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-nestjs-core@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-net@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-openai@0.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-oracledb@0.37.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/oracledb': 6.5.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-restify@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-router@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-runtime-node@0.25.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-socket.io@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-tedious@0.31.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.22.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-winston@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + import-in-the-middle: 3.0.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + import-in-the-middle: 3.0.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + protobufjs: 8.0.0 + + '@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + protobufjs: 7.6.4 + + '@opentelemetry/propagator-b3@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/redis-common@0.38.2': {} + + '@opentelemetry/resource-detector-alibaba-cloud@0.33.3(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resource-detector-aws@2.13.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resource-detector-azure@0.20.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resource-detector-container@0.8.4(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resource-detector-gcp@0.47.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + gcp-metadata: 8.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-logs@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-metrics@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/configuration': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-trace-node@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/semantic-conventions@1.41.1': {} + + '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + + '@ota-meshi/ast-token-store@0.3.0': {} + + '@oxc-parser/binding-android-arm-eabi@0.115.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.115.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.115.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.115.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.115.0': + optional: true + + '@oxc-project/types@0.115.0': {} + + '@oxc-project/types@0.128.0': {} + + '@pinojs/redact@0.4.0': {} + + '@pkgr/core@0.2.9': {} + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + '@polka/url@1.0.0-next.29': {} + + '@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@6.19.2(magicast@0.3.5)': + dependencies: + c12: 3.1.0(magicast@0.3.5) + deepmerge-ts: 7.1.5 + effect: 3.18.4 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.2': {} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.2 + '@prisma/get-platform': 6.19.2 + + '@prisma/fetch-engine@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.2 + + '@prisma/get-platform@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.18': {} + + '@rolldown/pluginutils@1.0.0-rc.2': {} + + '@rollup/plugin-babel@5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.80.0)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@rollup/pluginutils': 3.1.0(rollup@2.80.0) + rollup: 2.80.0 + optionalDependencies: + '@types/babel__core': 7.20.5 + transitivePeerDependencies: + - supports-color + + '@rollup/plugin-node-resolve@15.3.1(rollup@2.80.0)': + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.12 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/plugin-replace@2.4.2(rollup@2.80.0)': + dependencies: + '@rollup/pluginutils': 3.1.0(rollup@2.80.0) + magic-string: 0.25.9 + rollup: 2.80.0 + + '@rollup/plugin-terser@0.4.4(rollup@2.80.0)': + dependencies: + serialize-javascript: 6.0.2 + smob: 1.6.2 + terser: 5.48.0 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/pluginutils@3.1.0(rollup@2.80.0)': + dependencies: + '@types/estree': 0.0.39 + estree-walker: 1.0.1 + picomatch: 2.3.2 + rollup: 2.80.0 + + '@rollup/pluginutils@5.4.0(rollup@2.80.0)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@scarf/scarf@1.4.0': {} + + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 + + '@simple-libs/stream-utils@1.2.0': {} + + '@sindresorhus/base62@1.0.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@std-uritemplate/std-uritemplate@2.0.12': {} + + '@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/types': 8.61.1 + eslint: 10.5.0(jiti@2.6.1) + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + estraverse: 5.3.0 + picomatch: 4.0.4 + + '@surma/rollup-plugin-off-main-thread@2.2.3': + dependencies: + ejs: 3.1.10 + json5: 2.2.3 + magic-string: 0.25.9 + string.prototype.matchall: 4.0.12 + + '@swc/helpers@0.5.19': + dependencies: + tslib: 2.8.1 + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3(supports-color@5.5.0) + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76)': + optionalDependencies: + '@types/node': 24.12.0 + zod: 3.25.76 + + '@ts-rest/fastify@3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76)': + dependencies: + '@ts-rest/core': 3.52.1(@types/node@24.12.0)(zod@3.25.76) + fastify: 5.8.5 + optionalDependencies: + zod: 3.25.76 + + '@ts-rest/open-api@3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76)': + dependencies: + '@anatine/zod-openapi': 1.14.2(openapi3-ts@2.0.2)(zod@3.25.76) + '@ts-rest/core': 3.52.1(@types/node@24.12.0)(zod@3.25.76) + openapi3-ts: 2.0.2 + zod: 3.25.76 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aws-lambda@8.10.161': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + optional: true + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + optional: true + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + optional: true + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + optional: true + + '@types/bunyan@1.8.11': + dependencies: + '@types/node': 24.12.0 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.12.0 + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.9 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + + '@types/esrecurse@4.3.1': {} + + '@types/estree@0.0.39': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/jsdom@21.1.7': + dependencies: + '@types/node': 24.12.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.12.0 + + '@types/katex@0.16.8': {} + + '@types/luxon@3.7.1': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/memcached@2.2.10': + dependencies: + '@types/node': 24.12.0 + + '@types/ms@2.1.0': {} + + '@types/mysql@2.15.27': + dependencies: + '@types/node': 24.12.0 + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + '@types/oracledb@6.5.2': + dependencies: + '@types/node': 24.12.0 + + '@types/pg-pool@2.0.7': + dependencies: + '@types/pg': 8.15.6 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 24.12.0 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + + '@types/resolve@1.20.2': {} + + '@types/statuses@2.0.6': {} + + '@types/swagger-schema-official@2.0.25': {} + + '@types/tedious@4.0.14': + dependencies: + '@types/node': 24.12.0 + + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': {} + + '@types/unist@3.0.3': {} + + '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/type-utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.0 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/type-utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.57.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3(supports-color@5.5.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3(supports-color@5.5.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + ajv: 6.15.0 + eslint: 10.5.0(jiti@2.6.1) + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/scope-manager@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + + '@typescript-eslint/scope-manager@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + + '@typescript-eslint/tsconfig-utils@8.57.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.57.0': {} + + '@typescript-eslint/types@8.59.2': {} + + '@typescript-eslint/types@8.61.1': {} + + '@typescript-eslint/typescript-estree@8.57.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.57.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + eslint-visitor-keys: 5.0.1 + + '@typescript-eslint/visitor-keys@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + eslint-visitor-keys: 5.0.1 + + '@unocss/cli@66.6.6': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + '@unocss/preset-wind4': 66.6.6 + '@unocss/transformer-directives': 66.6.6 + cac: 6.7.14 + chokidar: 5.0.0 + colorette: 2.0.20 + consola: 3.4.2 + magic-string: 0.30.21 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + tinyglobby: 0.2.17 + unplugin-utils: 0.3.1 + + '@unocss/config@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + colorette: 2.0.20 + consola: 3.4.2 + unconfig: 7.5.0 + + '@unocss/core@0.65.4': {} + + '@unocss/core@66.6.6': {} + + '@unocss/extractor-arbitrary-variants@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/inspector@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + colorette: 2.0.20 + gzip-size: 6.0.0 + sirv: 3.0.2 + + '@unocss/preset-attributify@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/preset-icons@66.6.6': + dependencies: + '@iconify/utils': 3.1.0 + '@unocss/core': 66.6.6 + ofetch: 1.5.1 + + '@unocss/preset-mini@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/extractor-arbitrary-variants': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-tagify@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/preset-typography@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-uno@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + + '@unocss/preset-web-fonts@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + ofetch: 1.5.1 + + '@unocss/preset-wind3@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-mini': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-wind4@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/extractor-arbitrary-variants': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-wind@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + + '@unocss/rule-utils@0.65.4': + dependencies: + '@unocss/core': 0.65.4 + magic-string: 0.30.21 + + '@unocss/rule-utils@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + magic-string: 0.30.21 + + '@unocss/transformer-attributify-jsx@66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@unocss/core': 66.6.6 + oxc-parser: 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + oxc-walker: 0.7.0(oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@unocss/transformer-compile-class@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/transformer-directives@0.65.4': + dependencies: + '@unocss/core': 0.65.4 + '@unocss/rule-utils': 0.65.4 + css-tree: 3.2.1 + + '@unocss/transformer-directives@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + css-tree: 3.2.1 + + '@unocss/transformer-variant-group@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/vite@66.6.6(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/inspector': 66.6.6 + chokidar: 5.0.0 + magic-string: 0.30.21 + pathe: 2.0.3 + tinyglobby: 0.2.17 + unplugin-utils: 0.3.1 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + + '@vitejs/plugin-vue@6.0.4(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vue: 3.5.30(typescript@5.9.3) + + '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.5 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + '@vitest/eslint-plugin@1.6.17(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + typescript: 5.9.3 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + + '@vitest/mocker@4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.30': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.30 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.30': + dependencies: + '@vue/compiler-core': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-sfc@3.5.30': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.30 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.30': + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + fast-glob: 3.3.3 + typescript-eslint: 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.30 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.30': + dependencies: + '@vue/shared': 3.5.30 + + '@vue/runtime-core@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/runtime-dom@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/runtime-core': 3.5.30 + '@vue/shared': 3.5.30 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + vue: 3.5.30(typescript@5.9.3) + + '@vue/shared@3.5.30': {} + + '@vue/tsconfig@0.7.0(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))': + optionalDependencies: + typescript: 5.9.3 + vue: 3.5.30(typescript@5.9.3) + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + abstract-logging@2.0.1: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + optional: true + + acorn-import-attributes@1.9.5(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-import-phases@1.0.4(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + agent-base@7.1.4: {} + + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats-draft2019@1.6.1(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + punycode: 2.3.1 + schemes: 1.4.0 + smtp-address-parser: 1.1.0 + uri-js: 4.4.1 + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-i18n@4.2.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv-keywords@3.5.2(ajv@6.15.0): + dependencies: + ajv: 6.15.0 + + ajv-keywords@5.1.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + fast-deep-equal: 3.1.3 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alien-signals@1.0.13: {} + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-colors@4.1.3: {} + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + ansis@4.2.0: {} + + any-base@1.1.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + append-field@1.0.0: + optional: true + + are-docs-informative@0.0.2: {} + + argparse@2.0.1: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-ify@1.0.0: {} + + array-timsort@1.0.3: {} + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + astral-regex@2.0.0: {} + + async-function@1.0.0: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + atomic-sleep@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + axios-retry@4.5.0(axios@1.16.0): + dependencies: + axios: 1.16.0 + is-retry-allowed: 2.2.0 + + axios@1.16.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.38: {} + + bignumber.js@9.3.1: {} + + binary-extensions@2.3.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bn.js@4.12.3: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + optional: true + + boolbase@1.0.0: {} + + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + brorand@1.1.0: + optional: true + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.38 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.376 + node-releases: 2.0.48 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builtin-modules@5.0.0: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + optional: true + + bytes@3.1.2: {} + + c12@3.1.0(magicast@0.3.5): + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.3.5 + + c12@3.3.3(magicast@0.5.3): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 17.3.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.5.3 + + cac@6.7.14: {} + + cac@7.0.0: {} + + cache-manager@7.2.8: + dependencies: + '@cacheable/utils': 2.4.0 + keyv: 5.6.0 + + cacheable@2.3.3: + dependencies: + '@cacheable/memory': 2.0.8 + '@cacheable/utils': 2.4.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.6.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + call-me-maybe@1.0.2: {} + + callsites@3.1.0: {} + + camelcase@6.3.0: {} + + camelize-ts@3.0.0: {} + + caniuse-lite@1.0.30001799: {} + + ccount@2.0.1: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + change-case@5.4.4: {} + + character-entities@2.0.2: {} + + chardet@2.1.1: {} + + check-disk-space@3.4.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chrome-trace-event@1.0.4: {} + + ci-info@4.4.0: {} + + cidr-regex@3.1.1: + dependencies: + ip-regex: 4.3.0 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.1: {} + + cjs-module-lexer@2.2.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clean-regexp@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + + cli-boxes@2.2.1: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.1 + + cli-width@4.1.0: {} + + clipboard@2.0.11: + dependencies: + good-listener: 1.2.2 + select: 1.1.2 + tiny-emitter: 2.1.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@14.0.3: {} + + commander@2.20.3: {} + + commander@4.1.1: {} + + commander@8.3.0: {} + + comment-json@4.4.1: + dependencies: + array-timsort: 1.0.3 + core-util-is: 1.0.3 + esprima: 4.0.1 + + comment-parser@1.4.5: {} + + comment-parser@1.4.6: {} + + common-tags@1.8.2: {} + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + optional: true + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + consola@3.4.2: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: + optional: true + + content-type@2.0.0: + optional: true + + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: + optional: true + + cookie@0.7.2: + optional: true + + cookie@1.1.1: {} + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.2 + + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + optional: true + + cosmiconfig-typescript-loader@6.3.0(@types/node@24.12.0)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3): + dependencies: + '@types/node': 24.12.0 + cosmiconfig: 9.0.2(typescript@5.9.3) + jiti: 2.6.1 + typescript: 5.9.3 + + cosmiconfig@8.3.6(typescript@5.9.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.2.0 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.3 + + cosmiconfig@9.0.2(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + cron-validator@1.4.0: {} + + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypto-random-string@2.0.0: {} + + css-functions-list@3.3.3: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-uri-to-buffer@4.0.1: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + date-fns@4.1.0: {} + + dateformat@4.6.3: {} + + dayjs@1.11.19: {} + + de-indent@1.0.2: {} + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + decimal.js@10.6.0: {} + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + deeks@3.1.0: {} + + deep-is@0.1.4: {} + + deepmerge-ts@7.1.5: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + defu@6.1.4: {} + + delayed-stream@1.0.0: {} + + delegate@3.2.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff-sequences@29.6.3: {} + + discontinuous-range@1.0.0: {} + + doc-path@4.1.1: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + dompurify@3.3.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.2.3: {} + + dotenv@17.3.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer@0.1.2: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: + optional: true + + effect@3.18.4: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-to-chromium@1.5.376: {} + + elliptic@6.6.1: + dependencies: + bn.js: 4.12.3 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + optional: true + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + empathic@2.0.0: {} + + encodeurl@2.0.0: + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.24.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + env-paths@2.2.1: {} + + environment@1.1.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.1 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.22 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.1.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-to-primitive@1.3.1: + dependencies: + es-abstract-get: 1.0.0 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es-toolkit@1.48.1: {} + + es6-promise@3.3.1: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-compat-utils@0.5.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + semver: 7.8.5 + + eslint-config-flat-gitignore@2.3.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/compat': 2.0.3(eslint@10.5.0(jiti@2.6.1)) + eslint: 10.5.0(jiti@2.6.1) + + eslint-flat-config-utils@3.2.0: + dependencies: + '@eslint/config-helpers': 0.5.5 + pathe: 2.0.3 + + eslint-json-compat-utils@0.2.3(eslint@10.5.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + esquery: 1.7.0 + jsonc-eslint-parser: 3.1.0 + + eslint-merge-processors@2.0.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-antfu@3.2.2(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-command@3.5.2(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@es-joy/jsdoccomment': 0.84.0 + '@typescript-eslint/rule-tester': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-depend@1.5.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + empathic: 2.0.0 + eslint: 10.5.0(jiti@2.6.1) + module-replacements: 2.11.0 + semver: 7.8.5 + + eslint-plugin-es-x@7.8.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + eslint: 10.5.0(jiti@2.6.1) + eslint-compat-utils: 0.5.1(eslint@10.5.0(jiti@2.6.1)) + + eslint-plugin-import-lite@0.6.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-jsdoc@62.9.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@es-joy/jsdoccomment': 0.86.0 + '@es-joy/resolve.exports': 1.2.0 + are-docs-informative: 0.0.2 + comment-parser: 1.4.6 + debug: 4.4.3(supports-color@5.5.0) + escape-string-regexp: 4.0.0 + eslint: 10.5.0(jiti@2.6.1) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.0 + parse-imports-exports: 0.2.4 + semver: 7.8.5 + spdx-expression-parse: 4.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color + + eslint-plugin-jsonc@3.1.2(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + diff-sequences: 29.6.3 + eslint: 10.5.0(jiti@2.6.1) + eslint-json-compat-utils: 0.2.3(eslint@10.5.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0) + jsonc-eslint-parser: 3.1.0 + natural-compare: 1.4.0 + synckit: 0.11.12 + transitivePeerDependencies: + - '@eslint/json' + + eslint-plugin-n@17.24.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + enhanced-resolve: 5.24.0 + eslint: 10.5.0(jiti@2.6.1) + eslint-plugin-es-x: 7.8.0(eslint@10.5.0(jiti@2.6.1)) + get-tsconfig: 4.13.6 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.8.5 + ts-declaration-location: 1.0.7(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + eslint-plugin-no-only-tests@3.3.0: {} + + eslint-plugin-perfectionist@5.9.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + natural-orderby: 5.0.0 + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-pnpm@1.6.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + empathic: 2.0.0 + eslint: 10.5.0(jiti@2.6.1) + jsonc-eslint-parser: 3.1.0 + pathe: 2.0.3 + pnpm-workspace-yaml: 1.6.0 + tinyglobby: 0.2.17 + yaml: 2.9.0 + yaml-eslint-parser: 2.0.0 + + eslint-plugin-regexp@3.1.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + comment-parser: 1.4.6 + eslint: 10.5.0(jiti@2.6.1) + jsdoc-type-pratt-parser: 7.2.0 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + scslre: 0.3.0 + + eslint-plugin-toml@1.3.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + toml-eslint-parser: 1.0.3 + transitivePeerDependencies: + - supports-color + + eslint-plugin-unicorn@64.0.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + change-case: 5.4.4 + ci-info: 4.4.0 + clean-regexp: 1.0.0 + core-js-compat: 3.49.0 + eslint: 10.5.0(jiti@2.6.1) + find-up-simple: 1.0.1 + globals: 17.6.0 + indent-string: 5.0.0 + is-builtin-module: 5.0.0 + jsesc: 3.1.0 + pluralize: 8.0.0 + regexp-tree: 0.1.27 + regjsparser: 0.13.2 + semver: 7.8.5 + strip-indent: 4.1.1 + + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + + eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + eslint: 10.5.0(jiti@2.6.1) + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.1 + semver: 7.8.5 + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + xml-name-validator: 4.0.0 + optionalDependencies: + '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + + eslint-plugin-yml@3.3.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + debug: 4.4.3(supports-color@5.5.0) + diff-sequences: 29.6.3 + escape-string-regexp: 5.0.0 + eslint: 10.5.0(jiti@2.6.1) + natural-compare: 1.4.0 + yaml-eslint-parser: 2.0.0 + transitivePeerDependencies: + - supports-color + + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@vue/compiler-sfc': 3.5.30 + eslint: 10.5.0(jiti@2.6.1) + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.5.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@5.5.0) + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-walker@1.0.1: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + eta@3.5.0: {} + + etag@1.8.1: + optional: true + + eventemitter2@6.4.9: {} + + eventemitter3@5.0.4: {} + + events@3.3.0: {} + + expect-type@1.3.0: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + optional: true + + exsolve@1.0.8: {} + + extend@3.0.2: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-copy@4.0.2: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-jwt@5.0.6: + dependencies: + '@lukeed/ms': 2.0.2 + asn1.js: 5.4.1 + ecdsa-sig-formatter: 1.0.11 + mnemonist: 0.40.4 + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.2: {} + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + + fastest-levenshtein@1.0.16: {} + + fastfall@1.5.1: + dependencies: + reusify: 1.1.0 + + fastify-keycloak-adapter@3.0.2: + dependencies: + '@fastify/cookie': 11.0.2 + '@fastify/jwt': 9.1.0 + '@fastify/session': 11.1.1 + axios: 1.16.0 + axios-retry: 4.5.0(axios@1.16.0) + fastify-plugin: 5.1.0 + fp-ts: 2.16.11 + grant: 5.4.24 + io-ts: 2.2.22(fp-ts@2.16.11) + qs: 6.15.2 + wildcard-match: 5.1.4 + transitivePeerDependencies: + - debug + + fastify-plugin@5.1.0: {} + + fastify-plugin@6.0.0: {} + + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 9.14.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.0 + + fastparallel@2.4.1: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fastseries@1.7.2: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + + fault@2.0.1: + dependencies: + format: 0.2.2 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + file-entry-cache@11.1.2: + dependencies: + flat-cache: 6.1.20 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-type@21.3.0: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.4 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + optional: true + + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + find-up-simple@1.0.1: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flat-cache@6.1.20: + dependencies: + cacheable: 2.3.3 + flatted: 3.4.2 + hookified: 1.15.1 + + flatted@3.4.2: {} + + focus-trap-vue@4.1.0(focus-trap@7.8.0)(vue@3.5.30(typescript@5.9.3)): + dependencies: + focus-trap: 7.8.0 + vue: 3.5.30(typescript@5.9.3) + + focus-trap@7.8.0: + dependencies: + tabbable: 6.4.0 + + follow-redirects@1.16.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)): + dependencies: + '@babel/code-frame': 7.29.7 + chalk: 4.1.2 + chokidar: 4.0.3 + cosmiconfig: 8.3.6(typescript@5.9.3) + deepmerge: 4.3.1 + fs-extra: 10.1.0 + memfs: 3.5.3 + minimatch: 3.1.5 + node-abort-controller: 3.1.1 + schema-utils: 3.3.0 + semver: 7.8.5 + tapable: 2.3.3 + typescript: 5.9.3 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + format@0.2.2: {} + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded-parse@2.1.2: {} + + forwarded@0.2.0: + optional: true + + fp-ts@2.16.11: {} + + fresh@2.0.0: + optional: true + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-monkey@1.1.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-own-enumerable-property-symbols@3.0.2: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.5 + pathe: 2.0.3 + + git-raw-commits@5.0.1(conventional-commits-parser@6.4.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + github-slugger@2.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + glob@13.0.0: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + global-directory@5.0.0: + dependencies: + ini: 6.0.0 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + global-prefix@4.0.0: + dependencies: + ini: 4.1.3 + kind-of: 6.0.3 + which: 4.0.0 + + globals@14.0.0: {} + + globals@15.15.0: {} + + globals@16.5.0: {} + + globals@17.6.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@16.2.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + + globjoin@0.1.4: {} + + globrex@0.1.2: {} + + good-listener@1.2.2: + dependencies: + delegate: 3.2.0 + + google-logging-utils@1.1.3: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + grant@5.4.24: + dependencies: + qs: 6.15.2 + request-compose: 2.1.7 + request-oauth: 1.0.1 + optionalDependencies: + cookie: 0.7.2 + cookie-signature: 1.2.2 + jwk-to-pem: 2.0.7 + jws: 4.0.1 + + graphql@16.14.2: {} + + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + + has-bigints@1.1.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-flag@5.0.1: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + optional: true + + hashery@1.5.0: + dependencies: + hookified: 1.15.1 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + headers-polyfill@4.0.3: {} + + helmet@8.1.0: {} + + help-me@5.0.0: {} + + hmac-drbg@1.0.1: + dependencies: + hash.js: 1.1.7 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + optional: true + + hookified@1.15.1: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + html-entities@2.6.0: {} + + html-escaper@2.0.2: {} + + html-tags@5.1.0: {} + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + http2-client@1.3.5: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + husky@9.1.7: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + idb@7.1.1: {} + + ieee754@1.2.1: {} + + ignore-by-default@1.0.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immediate@3.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-in-the-middle@2.0.6: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + import-in-the-middle@3.0.0: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + import-meta-resolve@4.2.0: {} + + imurmurhash@0.1.4: {} + + indent-string@5.0.0: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ini@4.1.3: {} + + ini@6.0.0: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + + io-ts@2.2.22(fp-ts@2.16.11): + dependencies: + fp-ts: 2.16.11 + + ip-regex@4.3.0: {} + + ipaddr.js@1.9.1: + optional: true + + ipaddr.js@2.4.0: {} + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-builtin-module@5.0.0: + dependencies: + builtin-modules: 5.0.0 + + is-callable@1.2.7: {} + + is-cidr@4.0.2: + dependencies: + cidr-regex: 3.1.1 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-map@2.0.3: {} + + is-module@1.0.0: {} + + is-negative-zero@2.0.3: {} + + is-node-process@1.2.0: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-obj@1.0.1: {} + + is-obj@2.0.0: {} + + is-path-inside@4.0.0: {} + + is-plain-obj@4.1.0: {} + + is-plain-object@5.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: + optional: true + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-regexp@1.0.0: {} + + is-retry-allowed@2.2.0: {} + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-unicode-supported@0.1.0: {} + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterare@1.2.1: {} + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + javascript-time-ago@2.6.4: + dependencies: + relative-time-format: 1.1.12 + + jest-worker@27.5.1: + dependencies: + '@types/node': 24.12.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jiti@2.6.1: {} + + joycon@3.1.1: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + jsdoc-type-pratt-parser@7.1.1: {} + + jsdoc-type-pratt-parser@7.2.0: {} + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-2-csv@5.5.10: + dependencies: + deeks: 3.1.0 + doc-path: 4.1.1 + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + fast-uri: 3.1.2 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema@0.4.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-eslint-parser@3.1.0: + dependencies: + acorn: 8.17.0 + eslint-visitor-keys: 5.0.1 + semver: 7.8.5 + + jsonc-parser@3.3.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonpointer@5.0.1: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwk-to-pem@2.0.7: + dependencies: + asn1.js: 5.4.1 + elliptic: 6.6.1 + safe-buffer: 5.2.1 + optional: true + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + katex@0.16.45: + dependencies: + commander: 8.3.0 + + keycloak-js@26.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.2.4 + yaml: 2.9.0 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + load-esm@1.0.3: {} + + loader-runner@4.3.2: {} + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.1 + pkg-types: 1.3.1 + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.1 + pkg-types: 2.3.0 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.debounce@4.0.8: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash.sortby@4.7.0: {} + + lodash.truncate@4.4.2: {} + + lodash@4.17.23: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + long@5.3.2: {} + + longest-streak@3.1.0: {} + + lru-cache@10.4.3: {} + + lru-cache@11.5.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + luxon@3.7.2: {} + + magic-regexp@0.10.0: + dependencies: + estree-walker: 3.0.3 + magic-string: 0.30.21 + mlly: 1.8.1 + regexp-tree: 0.1.27 + type-level-regexp: 0.1.17 + ufo: 1.6.3 + unplugin: 2.3.11 + + magic-string@0.25.9: + dependencies: + sourcemap-codec: 1.4.8 + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + optional: true + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + markdown-table@3.0.4: {} + + math-intrinsics@1.1.0: {} + + mathml-tag-names@4.0.0: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.27.1: {} + + media-typer@0.3.0: + optional: true + + media-typer@1.1.0: + optional: true + + memfs@3.5.3: + dependencies: + fs-monkey: 1.1.0 + + meow@13.2.0: {} + + meow@14.1.0: {} + + merge-descriptors@2.0.0: + optional: true + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.45 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3(supports-color@5.5.0) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: + optional: true + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + optional: true + + mime@3.0.0: {} + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimalistic-assert@1.0.1: {} + + minimalistic-crypto-utils@1.0.1: + optional: true + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.1 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mlly@1.8.1: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + mnemonist@0.40.4: + dependencies: + obliterator: 2.0.5 + + module-details-from-path@1.0.4: {} + + module-replacements@2.11.0: {} + + moo@0.5.3: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.7.0 + until-async: 3.0.2 + yargs: 17.7.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + muggle-string@0.4.1: {} + + multer@2.1.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + optional: true + + mustache@4.2.0: {} + + mute-stream@2.0.0: {} + + nanoid@3.3.15: {} + + nanoid@5.0.9: {} + + nanoid@5.1.6: {} + + natural-compare@1.4.0: {} + + natural-orderby@5.0.0: {} + + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.3 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + + negotiator@1.0.0: + optional: true + + neo-async@2.6.2: {} + + nestjs-pino@4.6.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2): + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + pino: 10.3.1 + pino-http: 11.0.0 + rxjs: 7.8.2 + + node-abort-controller@3.1.1: {} + + node-domexception@1.0.0: {} + + node-emoji@1.11.0: + dependencies: + lodash: 4.18.1 + + node-fetch-h2@2.3.0: + dependencies: + http2-client: 1.3.5 + + node-fetch-native@1.6.7: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-readfiles@0.2.0: + dependencies: + es6-promise: 3.3.1 + + node-releases@2.0.48: {} + + nodemon@3.1.14: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 10.2.5 + pstree.remy: 1.1.8 + semver: 7.8.5 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nwsapi@2.2.24: {} + + nypm@0.6.5: + dependencies: + citty: 0.2.1 + pathe: 2.0.3 + tinyexec: 1.2.4 + + oas-kit-common@1.0.8: + dependencies: + fast-safe-stringify: 2.1.1 + + oas-linter@3.2.2: + dependencies: + '@exodus/schemasafe': 1.3.0 + should: 13.2.3 + yaml: 1.10.3 + + oas-resolver@2.5.6: + dependencies: + node-fetch-h2: 2.3.0 + oas-kit-common: 1.0.8 + reftools: 1.1.9 + yaml: 1.10.3 + yargs: 17.7.3 + + oas-schema-walker@1.1.5: {} + + oas-validator@5.0.8: + dependencies: + call-me-maybe: 1.0.2 + oas-kit-common: 1.0.8 + oas-linter: 3.2.2 + oas-resolver: 2.5.6 + oas-schema-walker: 1.1.5 + reftools: 1.1.9 + should: 13.2.3 + yaml: 1.10.3 + + oauth-sign@0.9.0: {} + + object-assign@4.1.1: + optional: true + + object-deep-merge@2.0.0: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + obliterator@2.0.5: {} + + obug@2.1.3: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.3 + + ohash@2.0.11: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + optional: true + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + openapi-types@12.1.3: {} + + openapi3-ts@2.0.2: + dependencies: + yaml: 1.10.3 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + outvariant@1.4.3: {} + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + dependencies: + '@oxc-project/types': 0.115.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.115.0 + '@oxc-parser/binding-android-arm64': 0.115.0 + '@oxc-parser/binding-darwin-arm64': 0.115.0 + '@oxc-parser/binding-darwin-x64': 0.115.0 + '@oxc-parser/binding-freebsd-x64': 0.115.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.115.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.115.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.115.0 + '@oxc-parser/binding-linux-arm64-musl': 0.115.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.115.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.115.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.115.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.115.0 + '@oxc-parser/binding-linux-x64-gnu': 0.115.0 + '@oxc-parser/binding-linux-x64-musl': 0.115.0 + '@oxc-parser/binding-openharmony-arm64': 0.115.0 + '@oxc-parser/binding-wasm32-wasi': 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@oxc-parser/binding-win32-arm64-msvc': 0.115.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.115.0 + '@oxc-parser/binding-win32-x64-msvc': 0.115.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + oxc-walker@0.7.0(oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)): + dependencies: + magic-regexp: 0.10.0 + oxc-parser: 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + + p-debounce@4.0.0: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + package-manager-detector@1.6.0: {} + + pako@1.0.11: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-gitignore@2.0.0: {} + + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-statements@1.0.11: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: + optional: true + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + + path-to-regexp@6.3.0: {} + + path-to-regexp@8.3.0: {} + + path-to-regexp@8.4.2: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + pg-int8@1.0.1: {} + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + picocolors@1.1.1: {} + + picomatch-browser@2.2.6: {} + + picomatch@2.3.2: {} + + picomatch@4.0.2: {} + + picomatch@4.0.4: {} + + pinia@2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.30(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@vue/composition-api' + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-http@11.0.0: + dependencies: + get-caller-file: 2.0.5 + pino: 10.3.1 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.1.0 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.1 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + + pluralize@8.0.0: {} + + pnpm-workspace-yaml@1.6.0: + dependencies: + yaml: 2.9.0 + + possible-typed-array-names@1.1.0: {} + + postcss-html@1.8.1: + dependencies: + htmlparser2: 8.0.2 + js-tokens: 9.0.1 + postcss: 8.5.15 + postcss-safe-parser: 6.0.0(postcss@8.5.15) + + postcss-safe-parser@6.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-safe-parser@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prelude-ls@1.2.1: {} + + pretty-bytes@5.6.0: {} + + pretty-bytes@6.1.1: {} + + prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.2(magicast@0.3.5) + '@prisma/engines': 6.19.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + process-nextick-args@2.0.1: {} + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + protobufjs@7.6.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 24.12.0 + long: 5.3.2 + + protobufjs@8.0.0: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 24.12.0 + long: 5.3.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + optional: true + + proxy-from-env@2.1.0: {} + + pstree.remy@1.1.8: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + qified@0.6.0: + dependencies: + hookified: 1.15.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + + quansync@0.2.11: {} + + quansync@1.0.0: {} + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + range-parser@1.2.1: + optional: true + + rate-limiter-flexible@4.0.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + optional: true + + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + + re2-wasm@1.0.2: + optional: true + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + readdirp@4.1.2: {} + + readdirp@5.0.0: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + refa@0.12.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + + reflect-metadata@0.2.2: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + reftools@1.1.9: {} + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regexp-ast-analysis@0.7.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + + regexp-tree@0.1.27: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.2 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 + + relative-time-format@1.1.12: {} + + request-compose@2.1.7: {} + + request-oauth@1.0.1: + dependencies: + oauth-sign: 0.9.0 + qs: 6.15.2 + uuid: 8.3.2 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + + reserved-identifiers@1.2.0: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + ret@0.1.15: {} + + ret@0.5.0: {} + + rettime@0.10.1: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + + rolldown@1.0.0-rc.18: + dependencies: + '@oxc-project/types': 0.128.0 + '@rolldown/pluginutils': 1.0.0-rc.18 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.18 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.18 + '@rolldown/binding-darwin-x64': 1.0.0-rc.18 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.18 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.18 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.18 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.18 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.18 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.18 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.18 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.18 + + rollup@2.80.0: + optionalDependencies: + fsevents: 2.3.3 + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + optional: true + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.15.0 + ajv-keywords: 3.5.2(ajv@6.15.0) + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-keywords: 5.1.0(ajv@8.20.0) + + schemes@1.4.0: + dependencies: + extend: 3.0.2 + + scslre@0.3.0: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + + scule@1.3.0: {} + + secure-json-parse@4.1.0: {} + + select@1.1.2: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + optional: true + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + optional: true + + set-cookie-parser@2.7.2: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + short-uuid@5.2.0: + dependencies: + any-base: 1.1.0 + uuid: 9.0.1 + + should-equal@2.0.0: + dependencies: + should-type: 1.4.0 + + should-format@3.0.3: + dependencies: + should-type: 1.4.0 + should-type-adaptors: 1.1.0 + + should-type-adaptors@1.1.0: + dependencies: + should-type: 1.4.0 + should-util: 1.0.1 + + should-type@1.4.0: {} + + should-util@1.0.1: {} + + should@13.2.3: + dependencies: + should-equal: 2.0.0 + should-format: 3.0.3 + should-type: 1.4.0 + should-type-adaptors: 1.1.0 + should-util: 1.0.1 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.5 + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sisteransi@1.0.5: {} + + slash@5.1.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + smob@1.6.2: {} + + smtp-address-parser@1.1.0: + dependencies: + nearley: 2.20.1 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.4: {} + + source-map@0.8.0-beta.0: + dependencies: + whatwg-url: 7.1.0 + + sourcemap-codec@1.4.8: {} + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@4.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.1.0: {} + + steed@1.1.3: + dependencies: + fastfall: 1.5.1 + fastparallel: 2.4.1 + fastq: 1.20.1 + fastseries: 1.7.2 + reusify: 1.1.0 + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + streamsearch@1.1.0: + optional: true + + strict-event-emitter@0.5.1: {} + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-object@3.3.0: + dependencies: + get-own-enumerable-property-symbols: 3.0.2 + is-obj: 1.0.1 + is-regexp: 1.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-comments@2.0.1: {} + + strip-indent@4.1.1: {} + + strip-json-comments@3.1.1: {} + + strip-json-comments@5.0.3: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + strtok3@10.3.4: + dependencies: + '@tokenizer/token': 0.3.0 + + stylelint-config-html@1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + postcss-html: 1.8.1 + stylelint: 17.11.0(typescript@5.9.3) + + stylelint-config-recommended-vue@1.6.1(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + postcss-html: 1.8.1 + semver: 7.8.5 + stylelint: 17.11.0(typescript@5.9.3) + stylelint-config-html: 1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-recommended: 18.0.0(stylelint@17.11.0(typescript@5.9.3)) + + stylelint-config-recommended@18.0.0(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + stylelint: 17.11.0(typescript@5.9.3) + + stylelint-config-standard@40.0.0(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + stylelint: 17.11.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.11.0(typescript@5.9.3)) + + stylelint@17.11.0(typescript@5.9.3): + dependencies: + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) + colord: 2.9.3 + cosmiconfig: 9.0.2(typescript@5.9.3) + css-functions-list: 3.3.3 + css-tree: 3.2.1 + debug: 4.4.3(supports-color@5.5.0) + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.2 + global-modules: 2.0.0 + globby: 16.2.0 + globjoin: 0.1.4 + html-tags: 5.1.0 + ignore: 7.0.5 + import-meta-resolve: 4.2.0 + is-plain-object: 5.0.0 + mathml-tag-names: 4.0.0 + meow: 14.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-safe-parser: 7.0.1(postcss@8.5.15) + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + string-width: 8.2.1 + supports-hyperlinks: 4.4.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 7.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@10.2.2: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@4.4.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-tags@1.0.0: {} + + swagger-schema-official@2.0.0-bab6bed: {} + + swagger-typescript-api@13.3.1(magicast@0.5.3): + dependencies: + '@apidevtools/swagger-parser': 12.1.0(openapi-types@12.1.3) + '@biomejs/js-api': 4.0.0(@biomejs/wasm-nodejs@2.4.6) + '@biomejs/wasm-nodejs': 2.4.6 + '@types/swagger-schema-official': 2.0.25 + c12: 3.3.3(magicast@0.5.3) + citty: 0.2.1 + consola: 3.4.2 + es-toolkit: 1.48.1 + eta: 3.5.0 + nanoid: 5.1.6 + openapi-types: 12.1.3 + swagger-schema-official: 2.0.0-bab6bed + swagger2openapi: 7.0.8 + type-fest: 5.7.0 + typescript: 5.9.3 + yaml: 2.9.0 + yummies: 7.10.0 + transitivePeerDependencies: + - '@biomejs/wasm-bundler' + - '@biomejs/wasm-web' + - encoding + - magicast + - mobx + - react + + swagger-ui-dist@5.32.4: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger2openapi@7.0.8: + dependencies: + call-me-maybe: 1.0.2 + node-fetch: 2.7.0 + node-fetch-h2: 2.3.0 + node-readfiles: 0.2.0 + oas-kit-common: 1.0.8 + oas-resolver: 2.5.6 + oas-schema-walker: 1.1.5 + oas-validator: 5.0.8 + reftools: 1.1.9 + yaml: 1.10.3 + yargs: 17.7.3 + transitivePeerDependencies: + - encoding + + symbol-observable@4.0.0: {} + + symbol-tree@3.2.4: {} + + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + + tabbable@6.4.0: {} + + table@6.9.0: + dependencies: + ajv: 8.20.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tagged-tag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tapable@2.3.3: {} + + temp-dir@2.0.0: {} + + tempy@0.6.0: + dependencies: + is-stream: 2.0.1 + temp-dir: 2.0.0 + type-fest: 0.16.0 + unique-string: 2.0.0 + + terser-webpack-plugin@5.6.1(esbuild@0.27.3)(lightningcss@1.32.0)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.48.0 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + optionalDependencies: + esbuild: 0.27.3 + lightningcss: 1.32.0 + + terser@5.48.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.17.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tiny-emitter@2.1.0: {} + + tinybench@2.9.0: {} + + tinyduration@3.4.1: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tldts-core@6.1.86: {} + + tldts-core@7.4.3: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tldts@7.4.3: + dependencies: + tldts-core: 7.4.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + + toad-cache@3.7.0: {} + + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.1 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + toml-eslint-parser@1.0.3: + dependencies: + eslint-visitor-keys: 5.0.1 + + totalist@3.0.1: {} + + touch@3.1.1: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.3 + + tr46@0.0.3: {} + + tr46@1.0.1: + dependencies: + punycode: 2.3.1 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-declaration-location@1.0.7(typescript@5.9.3): + dependencies: + picomatch: 4.0.4 + typescript: 5.9.3 + + ts-deepmerge@6.2.1: {} + + ts-essentials@10.1.1(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + ts-patch@3.3.0: + dependencies: + chalk: 4.1.2 + global-prefix: 4.0.0 + minimist: 1.2.8 + resolve: 1.22.12 + semver: 7.8.5 + strip-ansi: 6.0.1 + + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.24.0 + tapable: 2.3.3 + tsconfig-paths: 4.2.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.16.0: {} + + type-fest@0.20.2: {} + + type-fest@5.7.0: + dependencies: + tagged-tag: 1.0.0 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + optional: true + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + optional: true + + type-level-regexp@0.1.17: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typedarray@0.0.6: + optional: true + + typescript-eslint@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript-transform-paths@3.5.6(typescript@5.9.3): + dependencies: + minimatch: 9.0.9 + typescript: 5.9.3 + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + unconfig@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + defu: 6.1.4 + jiti: 2.6.1 + quansync: 1.0.0 + unconfig-core: 7.5.0 + + undefsafe@2.0.5: {} + + undici-types@7.16.0: {} + + undici@7.24.5: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + unicorn-magic@0.4.0: {} + + unimport@3.14.6(rollup@2.80.0): + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + acorn: 8.17.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + fast-glob: 3.3.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.1 + pathe: 2.0.3 + picomatch: 4.0.4 + pkg-types: 1.3.1 + scule: 1.3.0 + strip-literal: 2.1.1 + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + + unique-string@2.0.0: + dependencies: + crypto-random-string: 2.0.0 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universalify@2.0.1: {} + + unocss@66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@unocss/cli': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/preset-attributify': 66.6.6 + '@unocss/preset-icons': 66.6.6 + '@unocss/preset-mini': 66.6.6 + '@unocss/preset-tagify': 66.6.6 + '@unocss/preset-typography': 66.6.6 + '@unocss/preset-uno': 66.6.6 + '@unocss/preset-web-fonts': 66.6.6 + '@unocss/preset-wind': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + '@unocss/preset-wind4': 66.6.6 + '@unocss/transformer-attributify-jsx': 66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@unocss/transformer-compile-class': 66.6.6 + '@unocss/transformer-directives': 66.6.6 + '@unocss/transformer-variant-group': 66.6.6 + '@unocss/vite': 66.6.6(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - vite + + unpipe@1.0.0: + optional: true + + unplugin-auto-import@0.18.6(rollup@2.80.0): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + fast-glob: 3.3.3 + local-pkg: 0.5.1 + magic-string: 0.30.21 + minimatch: 9.0.9 + unimport: 3.14.6(rollup@2.80.0) + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + + unplugin-vue-components@0.27.5(@babel/parser@7.29.7)(rollup@2.80.0)(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + fast-glob: 3.3.3 + local-pkg: 0.5.1 + magic-string: 0.30.21 + minimatch: 9.0.9 + mlly: 1.8.1 + unplugin: 1.16.1 + vue: 3.5.30(typescript@5.9.3) + optionalDependencies: + '@babel/parser': 7.29.7 + transitivePeerDependencies: + - rollup + - supports-color + + unplugin@1.16.1: + dependencies: + acorn: 8.17.0 + webpack-virtual-modules: 0.6.2 + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.17.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + until-async@3.0.2: {} + + upath@1.2.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-template@3.1.1: {} + + util-deprecate@1.0.2: {} + + uuid@8.3.2: {} + + uuid@9.0.1: {} + + vary@1.1.2: + optional: true + + vite-node@6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0): + dependencies: + cac: 7.0.0 + es-module-lexer: 2.1.0 + obug: 2.1.3 + pathe: 2.0.3 + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + vite-plugin-pwa@1.2.0(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): + dependencies: + debug: 4.4.3(supports-color@5.5.0) + pretty-bytes: 6.1.1 + tinyglobby: 0.2.17 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + workbox-build: 7.4.0(@types/babel__core@7.20.5) + workbox-window: 7.4.0 + transitivePeerDependencies: + - supports-color + + vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.59.0 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + terser: 5.48.0 + yaml: 2.9.0 + + vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.0-rc.18 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.0 + esbuild: 0.27.3 + fsevents: 2.3.3 + jiti: 2.6.1 + terser: 5.48.0 + yaml: 2.9.0 + + vitest-mock-extended@2.0.2(typescript@5.9.3)(vitest@4.1.5): + dependencies: + ts-essentials: 10.1.1(typescript@5.9.3) + typescript: 5.9.3 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.0 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + jsdom: 25.0.1 + transitivePeerDependencies: + - msw + + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.0 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + jsdom: 25.0.1 + transitivePeerDependencies: + - msw + + vscode-uri@3.1.0: {} + + vue-demi@0.14.10(vue@3.5.30(typescript@5.9.3)): + dependencies: + vue: 3.5.30(typescript@5.9.3) + + vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.30(typescript@5.9.3) + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue3-json-viewer@2.4.1(vue@3.5.30(typescript@5.9.3)): + dependencies: + clipboard: 2.0.11 + vue: 3.5.30(typescript@5.9.3) + + vue@3.5.30(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-sfc': 3.5.30 + '@vue/runtime-dom': 3.5.30 + '@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3)) + '@vue/shared': 3.5.30 + optionalDependencies: + typescript: 5.9.3 + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + watchpack@2.5.2: + dependencies: + graceful-fs: 4.2.11 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@4.0.2: {} + + webidl-conversions@7.0.0: {} + + webpack-node-externals@3.0.0: {} + + webpack-sources@3.5.0: {} + + webpack-virtual-modules@0.6.2: {} + + webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.17.0 + acorn-import-phases: 1.0.4(acorn@8.17.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.24.0 + es-module-lexer: 2.1.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.2 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.1(esbuild@0.27.3)(lightningcss@1.32.0)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)) + watchpack: 2.5.2 + webpack-sources: 3.5.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + whatwg-url@7.1.0: + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.22 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + wildcard-match@5.1.4: {} + + word-wrap@1.2.5: {} + + workbox-background-sync@7.4.0: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.0 + + workbox-broadcast-update@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-build@7.4.0(@types/babel__core@7.20.5): + dependencies: + '@apideck/better-ajv-errors': 0.3.7(ajv@8.20.0) + '@babel/core': 7.29.7 + '@babel/preset-env': 7.29.7(@babel/core@7.29.7) + '@babel/runtime': 7.29.7 + '@rollup/plugin-babel': 5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.80.0) + '@rollup/plugin-node-resolve': 15.3.1(rollup@2.80.0) + '@rollup/plugin-replace': 2.4.2(rollup@2.80.0) + '@rollup/plugin-terser': 0.4.4(rollup@2.80.0) + '@surma/rollup-plugin-off-main-thread': 2.2.3 + ajv: 8.20.0 + common-tags: 1.8.2 + fast-json-stable-stringify: 2.1.0 + fs-extra: 9.1.0 + glob: 11.1.0 + lodash: 4.18.1 + pretty-bytes: 5.6.0 + rollup: 2.80.0 + source-map: 0.8.0-beta.0 + stringify-object: 3.3.0 + strip-comments: 2.0.1 + tempy: 0.6.0 + upath: 1.2.0 + workbox-background-sync: 7.4.0 + workbox-broadcast-update: 7.4.0 + workbox-cacheable-response: 7.4.0 + workbox-core: 7.4.0 + workbox-expiration: 7.4.0 + workbox-google-analytics: 7.4.0 + workbox-navigation-preload: 7.4.0 + workbox-precaching: 7.4.0 + workbox-range-requests: 7.4.0 + workbox-recipes: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + workbox-streams: 7.4.0 + workbox-sw: 7.4.0 + workbox-window: 7.4.0 + transitivePeerDependencies: + - '@types/babel__core' + - supports-color + + workbox-cacheable-response@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-core@7.4.0: {} + + workbox-expiration@7.4.0: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.0 + + workbox-google-analytics@7.4.0: + dependencies: + workbox-background-sync: 7.4.0 + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-navigation-preload@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-precaching@7.4.0: + dependencies: + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-range-requests@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-recipes@7.4.0: + dependencies: + workbox-cacheable-response: 7.4.0 + workbox-core: 7.4.0 + workbox-expiration: 7.4.0 + workbox-precaching: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-routing@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-strategies@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-streams@7.4.0: + dependencies: + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + + workbox-sw@7.4.0: {} + + workbox-window@7.4.0: + dependencies: + '@types/trusted-types': 2.0.7 + workbox-core: 7.4.0 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@7.0.1: + dependencies: + signal-exit: 4.1.0 + + ws@8.21.0: {} + + xbytes@1.9.1: {} + + xcase@2.0.1: {} + + xml-name-validator@4.0.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml-eslint-parser@2.0.0: + dependencies: + eslint-visitor-keys: 5.0.1 + yaml: 2.9.0 + + yaml@1.10.3: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + yummies@7.10.0: + dependencies: + class-variance-authority: 0.7.1 + clsx: 2.1.1 + dayjs: 1.11.19 + dompurify: 3.3.2 + nanoid: 5.1.6 + tailwind-merge: 3.5.0 + + zod-validation-error@3.5.4(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + + zwitch@2.0.4: {} diff --git a/.jjconflict-side-0/pnpm-workspace.yaml b/.jjconflict-side-0/pnpm-workspace.yaml new file mode 100644 index 0000000000..a812ffa983 --- /dev/null +++ b/.jjconflict-side-0/pnpm-workspace.yaml @@ -0,0 +1,162 @@ +packages: + - packages/** + - plugins/** + - apps/** + - "!apps/server/src/plugins/external/**" + - playwright/** +allowBuilds: + "@nestjs/core": true + "@prisma/client": true + "@prisma/engines": true + "@scarf/scarf": true + esbuild: true + msw: true + prisma: true + protobufjs: true + vue-demi: true +allowUnusedPatches: true +autoInstallPeers: true +injectWorkspacePackages: true +# minimum number of minutes that must pass after a version is published before +# pnpm will install it. This applies to all dependencies, including transitive ones. +minimumReleaseAge: 1440 # 1 day +catalogMode: strict +cleanupUnusedCatalogs: true +shellEmulator: true +trustPolicy: no-downgrade +# Check these after each update. This list should be empty ! +trustPolicyExclude: + - rollup@2.80.0 + - semver@6.3.1 + - chokidar@4.0.3 + - pino@9.14.0 + - typescript-transform-paths@3.5.6 + - undici-types@6.21.0 +catalogs: + runtime: + "@gitbeaker/core": ^40.6.0 + "@gitbeaker/requester-utils": ^40.6.0 + "@gitbeaker/rest": ^40.6.0 + "@gouvfr/dsfr": ^1.14.4 + "@gouvminint/vue-dsfr": ^8.15.0 + "@himenon/argocd-typescript-openapi": ^1.2.2 + "@iconify-json/ri": ^1.2.10 + "@iconify/vue": ^4.3.0 + "@keycloak/keycloak-admin-client": ^26.5.5 + "@kubernetes-models/argo-cd": ^2.7.2 + "@nestjs/cache-manager": ^3.1.2 + "@nestjs/common": ^11.1.16 + "@nestjs/config": ^4.0.3 + "@nestjs/core": ^11.1.16 + "@nestjs/event-emitter": ^3.0.1 + "@nestjs/jwt": ^11.0.2 + "@nestjs/platform-fastify": ^11.1.16 + "@nestjs/schedule": ^6.1.1 + "@nestjs/swagger": ^11.4.1 + "@nestjs/terminus": ^11.1.1 + "@prisma/client": ^6.19.2 + "@ts-rest/core": ^3.52.1 + "@ts-rest/fastify": ^3.52.1 + "@ts-rest/open-api": ^3.52.1 + axios: ^1.15.1 + cache-manager: ^7.2.8 + date-fns: ^4.1.0 + fastify: ^5.8.5 + fastify-keycloak-adapter: ^3.0.0 + javascript-time-ago: ^2.6.4 + json-2-csv: ^5.5.10 + json-schema: ^0.4.0 + jszip: ^3.10.1 + keycloak-js: ^26.2.3 + mustache: ^4.2.0 + nanoid: 5.0.9 + nestjs-pino: ^4.6.0 + p-debounce: ^4.0.0 + pinia: ^2.3.1 + prisma: ^6.19.2 + reflect-metadata: ^0.2.2 + rxjs: ^7.8.2 + short-uuid: ^5.2.0 + undici: ^7.24.0 + vitest-mock-extended: ^2.0.2 + vue: ^3.5.30 + vue-router: ^4.6.4 + vue3-json-viewer: ^2.4.1 + xbytes: ^1.9.1 + yaml: ^2.7.1 + zod: ^3.25.76 + zod-validation-error: ^3.5.4 + "@fastify/cookie": ^11.0.2 + "@fastify/helmet": ^13.0.2 + "@fastify/otel": ^0.18.1 + "@fastify/session": ^11.1.1 + "@fastify/swagger": ^9.7.0 + "@fastify/swagger-ui": ^5.2.6 + bytes: ^3.1.2 + cron-validator: ^1.4.0 + build: + "@iconify/types": ^2.0.0 + "@nestjs/cli": ^11.0.16 + "@nestjs/schematics": ^11.0.9 + "@nestjs/testing": ^11.1.16 + "@unocss/transformer-directives": ^0.65.4 + "@vitejs/plugin-vue": ^6.0.4 + "@vue/tsconfig": ^0.7.0 + fastify-plugin: ^5.1.0 + swagger-typescript-api: ^13.3.1 + ts-patch: ^3.3.0 + typescript: ^5.9.3 + typescript-transform-paths: ^3.5.6 + unocss: ^66.6.6 + unplugin-auto-import: ^0.18.6 + unplugin-vue-components: ^0.27.5 + vite: ^7.3.2 + vite-node: ^6.0.0 + vite-plugin-pwa: ^1.2.0 + vue-tsc: ^2.2.12 + workbox-window: ^7.4.0 + test: + "@playwright/test": ^1.59.1 + "@faker-js/faker": ^9.9.0 + "@vitest/coverage-v8": ^4.1.5 + globals: ^16.5.0 + jsdom: ^25.0.1 + msw: ^2.12.10 + vitest: ^4.1.5 + types: + "@types/jsdom": ^21.1.7 + "@types/json-schema": ^7.0.15 + "@types/node": ^24.12.0 + undici-types: ^7.16.0 + otel: + "@opentelemetry/api": ^1.9.0 + "@opentelemetry/auto-instrumentations-node": ^0.70.1 + "@opentelemetry/exporter-metrics-otlp-proto": ^0.213.0 + "@opentelemetry/exporter-trace-otlp-proto": ^0.213.0 + "@opentelemetry/instrumentation-nestjs-core": ^0.58.0 + "@opentelemetry/instrumentation-pino": ^0.59.0 + "@opentelemetry/sdk-metrics": ^2.5.1 + "@opentelemetry/sdk-node": ^0.212.0 + tools: + "@antfu/eslint-config": ^8.2.0 + "@commitlint/cli": ^20.5.3 + "@commitlint/config-conventional": ^20.5.3 + "@eslint/eslintrc": ^3.3.5 + "@eslint/js": ^9.39.4 + "@vue/eslint-config-typescript": ^14.7.0 + chalk: ^5.6.2 + eslint: ^10.5.0 + eslint-plugin-vue: ^10.8.0 + husky: ^9.1.7 + js-yaml: ^4.1.0 + lint-staged: ^16.4.0 + nodemon: ^3.1.14 + pino: ^9.14.0 + pino-pretty: ^13.1.3 + rimraf: ^6.1.3 + stylelint: ^17.11.0 + stylelint-config-html: ^1.1.0 + stylelint-config-recommended-vue: ^1.6.1 + stylelint-config-standard: ^40.0.0 + typescript-eslint: ^8.57.0 + vue-eslint-parser: ^10.4.0 diff --git a/.jjconflict-side-0/release-please-config.json b/.jjconflict-side-0/release-please-config.json new file mode 100644 index 0000000000..473308bd27 --- /dev/null +++ b/.jjconflict-side-0/release-please-config.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "versioning": "always-bump-minor", + "pull-request-title-pattern": "chore${scope}: Release${component} v${version}", + "release-type": "node", + "changelog-type": "default", + "changelog-sections": [ + { + "type": "feat", + "hidden": false, + "section": "Features" + }, + { + "type": "feature", + "hidden": false, + "section": "Features" + }, + { + "type": "fix", + "hidden": false, + "section": "Bug Fixes" + }, + { + "type": "chore", + "hidden": false, + "section": "Miscellaneous Chores" + }, + { + "type": "build", + "hidden": false, + "section": "Miscellaneous Chores" + }, + { + "type": "docs", + "hidden": false, + "section": "Docs" + }, + { + "type": "refactor", + "hidden": false, + "section": "Refactoring" + }, + { + "type": "revert", + "hidden": false, + "section": "Reverted commits" + } + ], + "packages": { + ".": { + "package-name": "console", + "include-component-in-tag": false + } + } +} diff --git a/.jjconflict-side-0/scripts/run-build.sh b/.jjconflict-side-0/scripts/run-build.sh new file mode 100755 index 0000000000..9e53b07cf8 --- /dev/null +++ b/.jjconflict-side-0/scripts/run-build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd $(dirname "$COMPOSE_FILE") \ + && docker buildx bake --file $(basename "$COMPOSE_FILE") --load --allow=fs.read=.. \ + && cd - > /dev/null diff --git a/.jjconflict-side-0/scripts/update-hosts.sh b/.jjconflict-side-0/scripts/update-hosts.sh new file mode 100755 index 0000000000..0de643ec93 --- /dev/null +++ b/.jjconflict-side-0/scripts/update-hosts.sh @@ -0,0 +1,130 @@ +#!/bin/bash + +set -xe +set -o pipefail + +# Colorize terminal +export red='\e[0;31m' +export no_color='\033[0m' + +# Get versions +export DOCKER_VERSION="$(docker --version)" + +# Default +export SCRIPTPATH="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" +export HELM_RELEASE_NAME="dso" +export INTEGRATION_ARG="" +export INTEGRATION_ARGS_UTILS="" +export CI_ARGS="" + + +# Declare script helper +export TEXT_HELPER="\nThis script aims to manage a local kubernetes cluster using Kind also known as Kubernetes in Docker. +Following flags are available: + + -c Command tu run. Multiple commands can be provided as a comma separated list. + Available commands are : + create - Create kind cluster. + clean - Delete images in kind cluster (keep only infra resources and ingress controller). + delete - Delete kind cluster. + build - Build, push and load docker images from compose file into cluster nodes. + load - Load docker images from compose file into cluster nodes. + dev - Run application in development mode. + prod - Run application in production mode. + integ - Run application in integration mode (need to combine with 'dev' or 'prod'). + + -d Domains to add in /etc/hosts for local services resolution. Comma separated list. This will require sudo. + + -f Path to the docker-compose file that will be used with Kind. + + -i Install kind. + + -k Path to the kubeconfig to use. + + -t Tag used to deploy application images. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hc:d:f:ik:t: flag; do + case "${flag}" in + c) + export COMMAND=${OPTARG};; + d) + export DOMAINS=${OPTARG};; + f) + export COMPOSE_FILE=${OPTARG};; + i) + export INSTALL_KIND=true;; + k) + export KUBECONFIG_HOST_PATH=${OPTARG};; + t) + export TAG=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Utils +install_kind() { + printf "\n\n${red}[kind wrapper].${no_color} Install kind...\n\n" + if [ "$(uname)" = "Linux" ]; then + export OS=linux + elif [ "$(uname)" = "Darwin" ]; then + export OS=darwin + else + printf "\n\nNo installation available for your system, plese refer to the installation guide\n\n" + exit 0 + fi + + if [ "$(uname -m)" = "x86_64" ]; then + export ARCH=amd64 + elif [ "$(uname -m)" = "arm64" ] || [ "$(uname -m)" = "aarch64" ]; then + export ARCH=arm64 + fi + + curl -Lo ./kind "https://kind.sigs.k8s.io/dl/v0.20.0/kind-$OS-$ARCH" + chmod +x ./kind + mv ./kind /usr/local/bin/kind + + printf "\n\n$(kind --version) installed\n\n" +} + +if [ "$INSTALL_KIND" = "true" ] && [ -z "$(kind --version)" ]; then + install_kind +fi + + +# Script condition +if [ -z "$(kind --version)" ]; then + echo "\nYou need to install kind to run this script.\n" + print_help + exit 1 +fi + +if [[ "$COMMAND" =~ "build" ]] && [ ! -f "$(readlink -f $COMPOSE_FILE)" ]; then + echo "\nDocker compose file $COMPOSE_FILE does not exist.\n" + print_help + exit 1 +fi + + +# Add local services to /etc/hosts +if [ ! -z "$DOMAINS" ]; then + printf "\n\n${red}[kind wrapper].${no_color} Add services local domains to /etc/hosts\n\n" + + export FORMATED_DOMAINS="$(echo "$DOMAINS" | sed 's/,/\ /g')" + if [ "$(grep -c "$FORMATED_DOMAINS" /etc/hosts)" -ge 1 ]; then + printf "\n\n${red}[kind wrapper].${no_color} Services local domains already added to /etc/hosts\n\n" + else + sudo sh -c "echo $'\n\n# Kind\n127.0.0.1 $FORMATED_DOMAINS' >> /etc/hosts" + + printf "\n\n${red}[kind wrapper].${no_color} Services local domains successfully added to /etc/hosts\n\n" + fi +fi diff --git a/.jjconflict-side-1/.dockerignore b/.jjconflict-side-1/.dockerignore new file mode 100644 index 0000000000..2edecc5c93 --- /dev/null +++ b/.jjconflict-side-1/.dockerignore @@ -0,0 +1,26 @@ +# Npm +node_modules +npm-debug.log + +# Git +.gitkeep + +# Lint +.eslintrc.js +.eslintrc.cjs +.eslintrc.json +.eslintignore +.stylelintrc.js +.stylelintrc.cjs + +# Tests +.turbo/ +coverage/ + +# Docker +**/Dockerfile +.dockerignore +docker/ + +# Env +.env* \ No newline at end of file diff --git a/.jjconflict-side-1/.gitattributes b/.jjconflict-side-1/.gitattributes new file mode 100644 index 0000000000..2e7bbd226d --- /dev/null +++ b/.jjconflict-side-1/.gitattributes @@ -0,0 +1,2 @@ +# Generated API clients — collapse in diffs and exclude from language stats. +plugins/harbor/src/api/Api.ts linguist-generated=true diff --git a/.jjconflict-side-1/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.jjconflict-side-1/.github/ISSUE_TEMPLATE/BUG-REPORT.yml new file mode 100644 index 0000000000..f87368e72d --- /dev/null +++ b/.jjconflict-side-1/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -0,0 +1,98 @@ +name: 🐛 Rapport de bug +description: Créer un ticket pour un bug. +title: "🐛 [BUG] - " +labels: [bug] +body: + - type: textarea + id: description + attributes: + label: Description + description: Veuillez saisir une description explicite de votre problème. + placeholder: Description courte et explicite de votre incident... + validations: + required: true + + - type: textarea + id: reprod + attributes: + label: Etapes de reproduction + description: Veuillez saisir une description explicite pour reproduire votre problème. + value: | + 1. Aller à '...' + 2. Cliquer sur '....' + 3. Scroller jusqu'à '....' + 4. Voir l'erreur + render: bash + validations: + required: true + + - type: textarea + id: screenshot + attributes: + label: "Captures d'écran" + description: Si nécessaire, ajoutez des captures d'écran pour expliquer votre problème. + value: | + ![DESCRIPTION](LINK.png) + render: bash + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Logs + description: Veuillez copier/coller tous les logs pertinents. Ces données seront automatiquement formatées en code. + validations: + required: false + + - type: dropdown + id: browsers + attributes: + label: Navigateurs + description: Quels sont les navigateurs sur lesquels vous constatez le problème ? + multiple: true + options: + - Firefox + - Chrome + - Chromium + - Brave + - Safari + - Microsoft Edge + - Opera + validations: + required: false + + - type: dropdown + id: os + attributes: + label: OS + description: Quels sont les systèmes d'exploitation sur lesquels vous constatez le problème ? + multiple: true + options: + - Windows + - Linux + - Mac + validations: + required: false + + - type: input + id: affected-versions + attributes: + label: Version de la console impactée + description: Quelle version de la console est impactée par le bug ? + placeholder: v1.2.3 + validations: + required: false + + - type: checkboxes + id: dod + attributes: + label: Définition du fini + description: Cochez les différents élément de la Definition Of Done. + options: + - label: Le correctif est terminé + required: false + - label: Les tests liés à ce correctif ont été ajoutés + required: false + - label: La communication avec les autres équipes impliquées par ce correctif a été faite + required: false diff --git a/.jjconflict-side-1/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.jjconflict-side-1/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml new file mode 100644 index 0000000000..2d55aae3ad --- /dev/null +++ b/.jjconflict-side-1/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml @@ -0,0 +1,64 @@ +name: 💡 Demande de fonctionnalité +description: Créer un ticket pour demander une nouvelle fonctionnalité. +title: "💡 [REQUEST] - <title>" +labels: [enhancement] +body: + - type: textarea + id: summary + attributes: + label: Description + description: Veuillez saisir une brève explication de la fonctionnalité. + placeholder: Décrivez en quelques lignes la fonctionnalité désirée. + validations: + required: true + + - type: textarea + id: implementation_pr + attributes: + label: PRs liées + description: Pull request qui répond à la fonctionnalité. + placeholder: "#Pull Request ID" + validations: + required: false + + - type: textarea + id: reference_issues + attributes: + label: Issues liées + description: Issues associée à cette demande de fonctionnalité. + placeholder: "#Issues IDs" + validations: + required: false + + - type: textarea + id: basic_example + attributes: + label: Exemples simples + description: Indiquez ici quelques exemples de base de votre fonctionnalité. + placeholder: Quelques mots sur votre demande de fonctionnalité. + validations: + required: false + + - type: textarea + id: tech + attributes: + label: Spécifications techniques + description: Spcifiez les détails techniques liés à votre demande. + placeholder: Une description technique de la fonctionnalité. + validations: + required: false + + - type: checkboxes + id: dod + attributes: + label: Définition du fini + description: Cochez les différents élément de la Definition Of Done. + options: + - label: La fonctionnalité est terminée + required: false + - label: Les tests liés à cette fonctionnalité ont été ajoutés + required: false + - label: La documentation liée à cette fonctionnalité a été ajoutée (cf. https://github.com/cloud-pi-native/documentation) + required: false + - label: La communication avec les autres équipes impliquées par cette fonctionnalité a été faite + required: false diff --git a/.jjconflict-side-1/.github/ISSUE_TEMPLATE/config.yml b/.jjconflict-side-1/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..b565340e24 --- /dev/null +++ b/.jjconflict-side-1/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: 📚 Documentation + url: https://cloud-pi-native.fr + about: Cet outil de suivi des problèmes n'est pas destiné aux problèmes de documentation. Veuillez ouvrir un ticket sur le dépôt de documentation. + - name: 🚀 Déploiement + url: https://github.com/cloud-pi-native/socle + about: Cet outil de suivi des problèmes n'est pas destiné aux problèmes de déploiement de la plateforme. Veuillez ouvrir un ticket sur le dépôt de socle. diff --git a/.jjconflict-side-1/.github/PULL_REQUEST_TEMPLATE.md b/.jjconflict-side-1/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..1730631ac9 --- /dev/null +++ b/.jjconflict-side-1/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +## Issues liées + +Issues numéro: + +--------- + +<!-- Ne soumettez pas de mises à jour des dépendances à moins qu'elles ne corrigent un problème. --> + +<!-- Veuillez essayer de limiter votre Pull Request à un seul type (correction de bogue, fonctionnalité, etc.). Soumettez plusieurs PRs si nécessaire. --> + +## Quel est le comportement actuel ? +<!-- Veuillez décrire le comportement actuel que vous modifiez. --> + +## Quel est le nouveau comportement ? +<!-- Veuillez décrire le comportement ou les changements apportés par cette PR. --> + +## Cette PR introduit-elle un breaking change ? +<!-- Si un breaking change est introduit, veuillez décrire ci-dessous l'impact et la procédure de migration pour les applications existantes. --> + +## Autres informations +<!-- Toute autre information importante pour la PR, telle que des captures d'écran montrant l'aspect du composant avant et après la modification. --> diff --git a/.jjconflict-side-1/.github/SECURITY.md b/.jjconflict-side-1/.github/SECURITY.md new file mode 100644 index 0000000000..9aaaf8698f --- /dev/null +++ b/.jjconflict-side-1/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +Contact: cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-side-1/.github/labeler/build.yml b/.jjconflict-side-1/.github/labeler/build.yml new file mode 100644 index 0000000000..68a15e21bc --- /dev/null +++ b/.jjconflict-side-1/.github/labeler/build.yml @@ -0,0 +1,3 @@ +built: + - changed-files: + - any-glob-to-any-file: "**" diff --git a/.jjconflict-side-1/.github/workflows/job-build.yml b/.jjconflict-side-1/.github/workflows/job-build.yml new file mode 100644 index 0000000000..f1a1f96e51 --- /dev/null +++ b/.jjconflict-side-1/.github/workflows/job-build.yml @@ -0,0 +1,256 @@ +name: Build + +permissions: + contents: read + packages: write + +on: + workflow_call: + inputs: + REGISTRY: + required: true + type: string + NAMESPACE: + required: true + type: string + BUILD_AMD64: + required: true + type: boolean + BUILD_ARM64: + required: true + type: boolean + TAG: + required: true + type: string + MAJOR_TAG: + required: false + type: string + MINOR_TAG: + required: false + type: string + PATCH_TAG: + required: false + type: string + USE_QEMU: + required: true + type: boolean + PR_NUMBER: + required: false + type: string + secrets: + ARGOCD_TOKEN: + required: false + workflow_dispatch: + inputs: + REGISTRY: + description: Target registry to push images + required: true + type: string + default: ghcr.io + NAMESPACE: + description: Target namespace to the given registry + required: true + type: string + default: cloud-pi-native/console + BUILD_AMD64: + description: Build for amd64 + required: true + type: boolean + default: true + BUILD_ARM64: + description: Build for arm64 + required: true + type: boolean + default: true + USE_QEMU: + description: Use QEMU emulator for arm64 + required: true + type: boolean + default: false + PR_NUMBER: + description: ID number of the pull request to use for deploy preview app + required: false + type: string + +jobs: + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + lower-branch: ${{ steps.lower-branch.outputs.LOWER_BRANCH }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + - name: Get lowercase branch name + id: lower-branch + run: | + echo "LOWER_BRANCH=$(echo '${{ github.head_ref || github.ref_name }}' | sed 's/\//-/g' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + + build: + name: Build application + runs-on: ${{ matrix.runners }} + needs: + - matrix + strategy: + fail-fast: false + matrix: + runners: ${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && !inputs.USE_QEMU && fromJson('["ubuntu-latest", "ARM64"]')) || (inputs.BUILD_ARM64 && !inputs.USE_QEMU && fromJson('["ARM64"]')) || fromJson('["ubuntu-latest"]') }} + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Get short SHA + id: short-sha + run: | + echo "SHORT_SHA=sha-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Cache Docker layers + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: /tmp/.buildx-cache + key: ${{ matrix.images.name == 'server' && format('buildx-{0}-{1}-{2}-{3}', runner.os, runner.arch, matrix.images.name, hashFiles(format('apps/{0}/**', matrix.images.name),'packages/shared/**','plugins/**')) || format('buildx-{0}-{1}-{2}-{3}', runner.os, runner.arch, matrix.images.name, hashFiles(format('apps/{0}/**', matrix.images.name),'packages/shared/**')) }} + restore-keys: | + buildx-${{ runner.os }}-${{ runner.arch }}-${{ matrix.images.name }}- + + - name: Set up QEMU (for multi platform build) + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + if: ${{ inputs.USE_QEMU }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ inputs.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + logout: true + + - name: Build and push docker image + id: build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: ${{ matrix.images.build.context }} + file: ${{ matrix.images.build.dockerfile }} + provenance: false + platforms: ${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && inputs.USE_QEMU && 'linux/amd64,linux/arm64') || (inputs.BUILD_ARM64 && 'linux/arm64') || (inputs.BUILD_AMD64 && 'linux/amd64') }} + outputs: type=image,name=${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }},push-by-digest=true,name-canonical=true,push=true + build-args: | + APP_VERSION=${{ inputs.TAG || steps.short-sha.outputs.SHORT_SHA }} + # cache-from: type=gha + # cache-to: type=gha,mode=max + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new + + # Necessary to avoid forever growing cache + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + + - name: Export digest + run: | + mkdir -p /tmp/digests/${{ matrix.images.name }} + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${{ matrix.images.name }}/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: digests-${{ matrix.images.name }}.${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && inputs.USE_QEMU && 'multiarch') || (inputs.BUILD_ARM64 && 'arm64') || (inputs.BUILD_AMD64 && 'amd64') }}.digest + path: /tmp/digests/${{ matrix.images.name }}/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge digest + runs-on: ubuntu-latest + needs: + - matrix + - build + strategy: + fail-fast: false + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Download artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: digests-${{ matrix.images.name }}.*.digest + path: /tmp/digests/${{ matrix.images.name }} + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Docker meta + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + with: + images: ${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }} + tags: | + type=raw,value=${{ needs.matrix.outputs.lower-branch }},enable=${{ github.head_ref != 'main' }} + type=raw,value=${{ inputs.TAG }},enable=${{ inputs.TAG != '' }} + type=raw,value=${{ inputs.MAJOR_TAG }}.${{ inputs.MINOR_TAG }},enable=${{ inputs.MAJOR_TAG != '' && inputs.MINOR_TAG != '' }} + type=raw,value=${{ inputs.MAJOR_TAG }},enable=${{ inputs.MAJOR_TAG != '' }} + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ inputs.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + logout: true + + - name: Create manifest list and push + working-directory: /tmp/digests/${{ matrix.images.name }} + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ steps.meta.outputs.version }} + + preview: + name: Redeploy preview app + if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'preview') && inputs.PR_NUMBER != '' }} + runs-on: ubuntu-latest + needs: + - merge + steps: + - name: Replace template fields + id: feed-template + run: | + echo "ARGOCD_APP_NAME=$(echo '${{ vars.ARGOCD_APP_NAME_TEMPLATE }}' | sed 's|<pr_number>|${{ inputs.PR_NUMBER }}|g')" >> $GITHUB_OUTPUT + echo "ARGOCD_SYNC_PAYLOAD=$(echo '${{ vars.ARGOCD_SYNC_PAYLOAD_TEMPLATE }}' | sed 's|<pr_number>|${{ inputs.PR_NUMBER }}|g')" >> $GITHUB_OUTPUT + + - name: Deploy new image + run: | + set -e + + RESPONSE_CODE=$(curl -s -w "%{http_code}" -o >(RESPONSE_BODY=$(cat)) -X POST ${{ vars.ARGOCD_URL }}/api/v1/applications/${{ steps.feed-template.outputs.ARGOCD_APP_NAME }}/sync \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${{ secrets.ARGOCD_TOKEN }}" \ + -d '${{ steps.feed-template.outputs.ARGOCD_SYNC_PAYLOAD }}') + + echo "HTTP response code: $RESPONSE_CODE" + + if [ "$RESPONSE_CODE" -ne 200 ]; then + echo "Error: Non-200 HTTP response detected." + echo "Response body: $RESPONSE_BODY" + exit 1 + fi + continue-on-error: true diff --git a/.jjconflict-side-1/.github/workflows/job-label.yml b/.jjconflict-side-1/.github/workflows/job-label.yml new file mode 100644 index 0000000000..1fd944015f --- /dev/null +++ b/.jjconflict-side-1/.github/workflows/job-label.yml @@ -0,0 +1,25 @@ +name: Add labels on PR + +permissions: + contents: read + pull-requests: write + +on: + workflow_call: + inputs: + CONF_PATH: + required: true + type: string + +jobs: + label: + name: Add labels on PR + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Add pull request label + uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + with: + configuration-path: ${{ inputs.CONF_PATH }} diff --git a/.jjconflict-side-1/.github/workflows/job-lint.yml b/.jjconflict-side-1/.github/workflows/job-lint.yml new file mode 100644 index 0000000000..bbbae077d6 --- /dev/null +++ b/.jjconflict-side-1/.github/workflows/job-lint.yml @@ -0,0 +1,79 @@ +name: Lint + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: false + type: string + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + +jobs: + lint: + name: Lint codebase + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check lint error + run: | + ./ci/scripts/run-tests.sh -l + + - name: Validate nginx-strangler config + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends nginx gettext-base + # Préparer un répertoire de test isolé avec la config substituée + mkdir -p /tmp/nginx-test/conf.d /tmp/nginx-test/logs + envsubst '${LEGACY_UPSTREAM} ${NESTJS_UPSTREAM}' \ + < apps/nginx-strangler/conf.d/routing.conf \ + > /tmp/nginx-test/conf.d/routing.conf + # Adapter nginx.conf pour l'environnement CI (user www-data, paths accessibles) + sed \ + -e 's|^user .*|user www-data;|' \ + -e 's|pid .*|pid /tmp/nginx-test/nginx.pid;|' \ + -e 's|error_log .*|error_log /tmp/nginx-test/logs/error.log notice;|' \ + -e 's|access_log .*|access_log /tmp/nginx-test/logs/access.log main;|' \ + -e 's|include /etc/nginx/conf\.d/\*\.conf|include /tmp/nginx-test/conf.d/*.conf|' \ + apps/nginx-strangler/nginx.conf > /tmp/nginx-test/nginx.conf + nginx -t -c /tmp/nginx-test/nginx.conf + env: + LEGACY_UPSTREAM: "127.0.0.1:8080" + NESTJS_UPSTREAM: "127.0.0.1:3001" diff --git a/.jjconflict-side-1/.github/workflows/job-npm.yml b/.jjconflict-side-1/.github/workflows/job-npm.yml new file mode 100644 index 0000000000..51e4f3eedc --- /dev/null +++ b/.jjconflict-side-1/.github/workflows/job-npm.yml @@ -0,0 +1,247 @@ +name: Publish npm + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + PUBLISH_APPS: + required: true + type: boolean + PUBLISH_PACKAGES: + required: true + type: boolean + PUBLISH_PLUGINS: + required: true + type: boolean + CHECK_CHANGES: + required: true + type: boolean + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used + required: true + type: string + PUBLISH_APPS: + description: Publish apps modules + required: true + type: boolean + default: false + PUBLISH_PACKAGES: + description: Publish packages modules + required: true + type: boolean + default: false + PUBLISH_PLUGINS: + description: Publish plugins modules + required: true + type: boolean + default: true + CHECK_CHANGES: + required: true + type: boolean + default: true + +jobs: + path-filter: + if: ${{ inputs.CHECK_CHANGES }} + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.filter.outputs.changes }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + eslint-config: + - 'packages/eslintconfig/src/**' + shared: + - 'packages/shared/src/**' + hooks: + - 'packages/hooks/src/**' + ts-config: + - 'packages/tsconfig/**' + logger: + - 'packages/logger/**' + argocd: + - 'plugins/argocd/src/**' + gitlab: + - 'plugins/gitlab/src/**' + harbor: + - 'plugins/harbor/src/**' + keycloak: + - 'plugins/keycloak/src/**' + nexus: + - 'plugins/nexus/src/**' + sonarqube: + - 'plugins/sonarqube/src/**' + vault: + - 'plugins/vault/src/**' + + check-update: + name: Check for package version update + if: ${{ fromJson(needs.path-filter.outputs.packages).include[0] }} # skip if the matrix is empty! + runs-on: ubuntu-latest + needs: + - path-filter + strategy: + matrix: + packages: ${{ fromJSON(needs.path-filter.outputs.packages) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Check changes + id: check + run: | + PACKAGE_NAME=$(jq -cr --arg p "${{ matrix.packages }}" '[.packages[], .plugins[]][] | select(.name | test($p)?) | .name' < ./ci/matrix-npm.json) + PACKAGE_PATH=$(jq -cr --arg p "${{ matrix.packages }}" '[.packages[], .plugins[]][] | select(.name | test($p)?) | .path' < ./ci/matrix-npm.json) + PACKAGE_VERSION=$(jq -cr '.version' < ${PACKAGE_PATH}/package.json) + if [ "$(curl -s -o /dev/null -I -w %{http_code} https://registry.npmjs.org/${PACKAGE_NAME})" = 404 ]; then + REMOTE=false + else + REMOTE=$(pnpm view ${PACKAGE_NAME} --json | jq --arg v "$PACKAGE_VERSION" 'any(.versions[] == $v; .)') + fi + if [ "$REMOTE" = "true" ]; then + echo "WARNING=true" >> $GITHUB_OUTPUT + echo "PACKAGE_NAME=$PACKAGE_NAME" >> $GITHUB_OUTPUT + echo "PACKAGE_PATH=$PACKAGE_PATH" >> $GITHUB_OUTPUT + echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_OUTPUT + else + echo "WARNING=false" >> $GITHUB_OUTPUT + fi + + matrix: + name: Generate publish matrix + needs: + - path-filter + if: ${{ needs.path-filter.outputs.packages != '' && (inputs.PUBLISH_APPS || inputs.PUBLISH_PACKAGES || inputs.PUBLISH_PLUGINS) }} + runs-on: ubuntu-latest + outputs: + publish-matrix: ${{ steps.packages-matrix.outputs.PUBLISH_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: packages-matrix + run: | + PUBLISH_MATRIX="[]" + APPS_MATRIX="$(jq -c '.apps' < ./ci/matrix-npm.json)" + PACKAGES_MATRIX="$(jq -c '.packages' < ./ci/matrix-npm.json)" + PLUGINS_MATRIX="$(jq -c '.plugins' < ./ci/matrix-npm.json)" + if [ "${{ inputs.PUBLISH_APPS }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$APPS_MATRIX" '$acc + $cur')" + fi + if [ "${{ inputs.PUBLISH_PACKAGES }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$PACKAGES_MATRIX" '$acc + $cur')" + fi + if [ "${{ inputs.PUBLISH_PLUGINS }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$PLUGINS_MATRIX" '$acc + $cur')" + fi + echo "PUBLISH_MATRIX=$PUBLISH_MATRIX" >> $GITHUB_OUTPUT + + publish-npm: + name: Publish on npm + if: ${{ needs.path-filter.outputs.packages != '' && (inputs.PUBLISH_APPS || inputs.PUBLISH_PACKAGES || inputs.PUBLISH_PLUGINS) }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + needs: + - path-filter + - matrix + strategy: + # Allow jobs to be processed independently of their siblings status + fail-fast: false + matrix: + modules: ${{ fromJSON(needs.matrix.outputs.publish-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.ref }} + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Check for new package version + id: check-version + run: | + PACKAGE_VERSION=$(jq -cr '.version' < ${{ matrix.modules.path }}/package.json) + if [ "$(curl -s -o /dev/null -I -w %{http_code} https://registry.npmjs.org/${{ matrix.modules.name }})" = 404 ]; then + REMOTE=false + else + REMOTE=$(pnpm view ${{ matrix.modules.name }} --json | jq --arg v "$PACKAGE_VERSION" 'any(.versions[] == $v; .)') + fi + if [ "$REMOTE" = "true" ]; then + echo "Package ${{ matrix.modules.name }} - version $PACKAGE_VERSION already exists" + else + echo "Package ${{ matrix.modules.name }} - version $PACKAGE_VERSION does not exist" + fi + echo "REMOTE=$REMOTE" >> $GITHUB_OUTPUT + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + with: + node-version: "${{ inputs.NODE_VERSION }}" + registry-url: "https://registry.npmjs.org" + + - name: Get pnpm store directory + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install base dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: pnpm install --frozen-lockfile + + - name: Build internal dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter "./packages/**" run build && pnpm --filter "./plugins/**" run build + + - name: Update PNPM internal links with built internal dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm install --frozen-lockfile + + - name: Build package + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter ${{ matrix.modules.name }} run build + + - name: Publish package + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter ${{ matrix.modules.name }} publish --provenance --no-git-checks --report-summary diff --git a/.jjconflict-side-1/.github/workflows/job-playwright.yml b/.jjconflict-side-1/.github/workflows/job-playwright.yml new file mode 100644 index 0000000000..ec0a3fe695 --- /dev/null +++ b/.jjconflict-side-1/.github/workflows/job-playwright.yml @@ -0,0 +1,114 @@ +name: Playwright Tests + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + TAG: + required: true + type: string + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + TAG: + description: Container images version to use for console client and server/server-nestjs + required: true + type: string + default: latest +jobs: + test: + timeout-minutes: 60 + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache Node.js files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install Node.js dependencies + run: pnpm install --frozen-lockfile + + - name: Build internal dependencies + run: pnpm --filter "./packages/**" build + + - name: Get used Playwright version + id: playwright-version + run: echo "PLAYWRIGHT_VERSION=$(pnpm --dir playwright exec playwright -V | awk '{ print $2 }')" >> $GITHUB_ENV + + - name: Restore cache - Playwright browser binaries + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + id: playwright-cache + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} + + - name: Install Playwright system dependencies + run: pnpm --dir playwright exec playwright install --with-deps + + - name: Save cache - Playwright browser binaries + if: always() && steps.playwright-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} + + - name: Initialize application environment for tests + run: | + ./ci/scripts/init-env.sh + docker pull ghcr.io/cloud-pi-native/console/server:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/server:${{ inputs.TAG }} dso-console/server:ci + docker pull ghcr.io/cloud-pi-native/console/nginx-strangler:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/nginx-strangler:${{ inputs.TAG }} dso-console/nginx-strangler:ci + docker pull ghcr.io/cloud-pi-native/console/server-nestjs:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/server-nestjs:${{ inputs.TAG }} dso-console/server-nestjs:ci + docker pull ghcr.io/cloud-pi-native/console/client:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/client:${{ inputs.TAG }} dso-console/client:ci + docker pull ghcr.io/cloud-pi-native/console/opencds-mockoon:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/opencds-mockoon:${{ inputs.TAG }} dso-console/opencds-mockoon:ci + docker compose -f ./docker/docker-compose.ci.yml up --no-build -d --remove-orphans + + - name: Run Playwright tests + run: pnpm --dir playwright exec playwright test --grep @e2e + + - name: Clean up docker resources (containers, volumes) + run: docker compose -f ./docker/docker-compose.ci.yml down -v --remove-orphans + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright/playwright-report/ + retention-days: 30 diff --git a/.jjconflict-side-1/.github/workflows/job-release-please.yml b/.jjconflict-side-1/.github/workflows/job-release-please.yml new file mode 100644 index 0000000000..b4789c2cbb --- /dev/null +++ b/.jjconflict-side-1/.github/workflows/job-release-please.yml @@ -0,0 +1,84 @@ +name: Release + +permissions: + contents: write + pull-requests: write + +on: + workflow_call: + outputs: + release-created: + description: Has the releease been created + value: ${{ jobs.release.outputs.release-created }} + major-tag: + description: Major version tag + value: ${{ jobs.release.outputs.major-tag }} + minor-tag: + description: Minor version tag + value: ${{ jobs.release.outputs.minor-tag }} + patch-tag: + description: Patch version tag + value: ${{ jobs.release.outputs.patch-tag }} + +jobs: + release: + name: Create new release + runs-on: ubuntu-latest + outputs: + release-created: ${{ steps.release.outputs.release_created }} + major-tag: ${{ steps.release.outputs.major }} + minor-tag: ${{ steps.release.outputs.minor }} + patch-tag: ${{ steps.release.outputs.patch }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Update release-please config for hotfix branches + if: startsWith(github.ref_name, 'hotfix/') + run: | + # Update versioning to always-bump-patch for hotfix branches + jq '.versioning = "always-bump-patch"' release-please-config.json > tmp.json && mv tmp.json release-please-config.json + echo "Updated release-please-config.json versioning to always-bump-patch" + shell: bash + + - name: Create merge request for new release + uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 + id: release + with: + config-file: release-please-config.json + target-branch: ${{ github.ref_name }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Edit packages versions + if: ${{ ! steps.release.outputs.release_created && steps.release.outputs.pr }} + run: | + export BRANCH=release-please--branches--main--components--console + git fetch + git checkout "$BRANCH" + VERSION=$(jq -r .version package.json) + for pkg in $(jq -r '.apps | map(.path + "/package.json") | .[]' < ./ci/matrix-npm.json); do + yq e -i ".version |= \"$VERSION\"" $pkg + yq e -i ".publishConfig.tag |= \"latest\"" $pkg + done + git add . + # Emulate the author of the existing commit (the bot). + git config user.email "$(git log -n 1 --format='%ae')" + git config user.name "$(git log -n 1 --format='%an')" + # Update the PR. + git commit --amend --no-edit + git push -f + + - name: Tag major and minor versions + if: ${{ steps.release.outputs.release_created }} + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git remote add gh-token "https://${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" + git tag -d v${{ steps.release.outputs.major }} || true + git tag -d v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} || true + git push origin :v${{ steps.release.outputs.major }} || true + git push origin :v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} || true + git tag -a v${{ steps.release.outputs.major }} -m "Release v${{ steps.release.outputs.major }}" + git tag -a v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} -m "Release v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}" + git push origin v${{ steps.release.outputs.major }} + git push origin v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} diff --git a/.jjconflict-side-1/.github/workflows/job-scan.yml b/.jjconflict-side-1/.github/workflows/job-scan.yml new file mode 100644 index 0000000000..3efab69fc5 --- /dev/null +++ b/.jjconflict-side-1/.github/workflows/job-scan.yml @@ -0,0 +1,148 @@ +name: Vulnerability scan + +permissions: + contents: read + pull-requests: write + +on: + workflow_call: + inputs: + REGISTRY: + required: true + type: string + NAMESPACE: + required: true + type: string + TAG: + required: true + type: string + workflow_dispatch: + inputs: + REGISTRY: + description: Target registry to push images + required: true + type: string + default: ghcr.io + NAMESPACE: + description: Target namespace to the given registry + required: true + type: string + default: cloud-pi-native/console + TAG: + description: Tag used to scan images + required: true + type: string + default: latest + +jobs: + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + images-scan: + name: Scan images vulnerabilities + runs-on: ubuntu-latest + needs: + - matrix + strategy: + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run Trivy vulnerability scanner on images + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: "${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ inputs.TAG }}" + format: template + template: "@/contrib/sarif.tpl" + vuln-type: "os,library" + ignore-unfixed: true + output: trivy-results.sarif + github-pat: ${{ secrets.GITHUB_TOKEN }} + cache: "false" + continue-on-error: true + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: trivy-results.sarif + continue-on-error: true + + - name: Warn about error + if: failure() + run: | + echo ":warning: The Trivy scan report for image `${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ inputs.TAG }}` was not successfully completed and could not be uploaded to the Github Security Panel." >> $GITHUB_STEP_SUMMARY + + config-scan: + name: Scan config files vulnerabilities + runs-on: ubuntu-latest + needs: + - matrix + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run Trivy vulnerability scanner on config files + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + scan-ref: . + scan-type: config + format: template + template: "@/contrib/sarif.tpl" + skip-dirs: "**/node_modules,ci" + ignore-unfixed: true + output: trivy-results.sarif + github-pat: ${{ secrets.GITHUB_TOKEN }} + cache: "false" + continue-on-error: true + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: trivy-results.sarif + continue-on-error: true + + - name: Warn about error + if: failure() + run: | + echo ":warning: The Trivy scan report for configuration was not successfully completed and could not be uploaded to the Github Security Panel." >> $GITHUB_STEP_SUMMARY + + scan-notif: + name: Notify users + runs-on: ubuntu-latest + needs: + - matrix + - images-scan + - config-scan + if: ${{ github.event_name == 'pull_request' }} + env: + MERGE_REQUEST_ID: ${{ github.event.pull_request.number }} + COMMENT: | + 🤖 Hey ! + + The security scan report for the current pull request is available [here](https://github.com/${{ github.repository }}/security/code-scanning?query=is%3Aopen+branch%3Amain+pr%3A${{ github.event.pull_request.number || github.event.number }}). + steps: + - name: Add comment on Merge Request + id: add-comment-on-merge-request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.MERGE_REQUEST_ID, + body: process.env.COMMENT, + }); diff --git a/.jjconflict-side-1/.github/workflows/job-tests-unit.yml b/.jjconflict-side-1/.github/workflows/job-tests-unit.yml new file mode 100644 index 0000000000..993ff302cf --- /dev/null +++ b/.jjconflict-side-1/.github/workflows/job-tests-unit.yml @@ -0,0 +1,177 @@ +name: Tests - unit + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + secrets: + SONAR_HOST_URL: + required: false + SONAR_TOKEN: + required: false + SONAR_PROJECT_KEY: + required: false + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + +jobs: + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm -r install --frozen-lockfile + + - name: Build internal dependencies (packages) + run: pnpm --filter "./packages/**" build + + - name: Build internal plugins + run: pnpm --filter "./plugins/**" build + + - name: Run unit tests + run: | + ./ci/scripts/init-env.sh + ./ci/scripts/run-tests.sh -u + + - name: Upload vitest coverage artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: unit-tests-coverage + path: | + ./apps/**/coverage/lcov.info + ./packages/**/coverage/lcov.info + retention-days: 1 + + check-secrets: + name: Check if Sonar secrets are set + runs-on: ubuntu-latest + needs: + - unit-tests + outputs: + run-scan: ${{ steps.check-secrets.outputs.run-scan }} + steps: + - name: Check for sonar secrets + id: check-secrets + run: | + if [ "${{ secrets.SONAR_HOST_URL }}" != "" ] && [ "${{ secrets.SONAR_TOKEN }}" != "" ] && [ "${{ secrets.SONAR_PROJECT_KEY }}" != "" ]; then + echo "run-scan=true" >> $GITHUB_OUTPUT + else + echo "run-scan=false" >> $GITHUB_OUTPUT + fi + + code-scan: + name: Run code quality analysis + runs-on: ubuntu-latest + needs: + - unit-tests + - check-secrets + if: ${{ needs.check-secrets.outputs.run-scan == 'true' }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Download artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unit-tests-coverage + path: ./coverage + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set sonarqube args + id: sonar-args + run: | + echo "SONAR_ARGS_PR=-Dsonar.pullrequest.provider=github -Dsonar.pullrequest.key=${{ github.event.number }} -Dsonar.pullrequest.branch=${{ github.head_ref }} -Dsonar.pullrequest.base=${{ github.base_ref }} -Dsonar.pullrequest.github.repository=${{ github.repository }}" >> $GITHUB_OUTPUT + echo "SONAR_ARGS_BRANCH=-Dsonar.branch.name=${{ github.ref_name }}" >> $GITHUB_OUTPUT + + - name: SonarQube Scan + uses: sonarsource/sonarqube-scan-action@299e4b793aaa83bf2aba7c9c14bedbb485688ec4 # v7.1.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + args: > + -Dsonar.projectKey=${{ secrets.SONAR_PROJECT_KEY }} + -Dsonar.sources=apps,packages + -Dsonar.javascript.node.maxspace=4096 + -Dsonar.javascript.lcov.reportPaths=coverage/apps/server/coverage/lcov.info,coverage/apps/client/coverage/lcov.info,coverage/packages/shared/coverage/lcov.info + -Dsonar.coverage.exclusions=**/*.spec.js,**/*.spec.ts,**/*.vue,**/assets/**,**/packages/test-utils/**,apps/server/src/plugins/** + -Dsonar.cpd.exclusions=**/*.spec.js,**/*.spec.ts + -Dsonar.scm.provider=git + ${{ github.event_name == 'pull_request' && steps.sonar-args.outputs.SONAR_ARGS_PR || steps.sonar-args.outputs.SONAR_ARGS_BRANCH }} + continue-on-error: true + + - name: SonarQube Quality Gate check + id: sonarqube-quality-gate-check + uses: sonarsource/sonarqube-quality-gate-action@cf038b0e0cdecfa9e56c198bbb7d21d751d62c3b # v1.2.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + timeout-minutes: 5 + continue-on-error: true diff --git a/.jjconflict-side-1/.github/workflows/workflow-clean-cache.yml b/.jjconflict-side-1/.github/workflows/workflow-clean-cache.yml new file mode 100644 index 0000000000..d1e5b60e95 --- /dev/null +++ b/.jjconflict-side-1/.github/workflows/workflow-clean-cache.yml @@ -0,0 +1,94 @@ +name: Clean cache + +permissions: + contents: read + # For cache cleaning + actions: write + # For artifacts deletion + packages: write + +on: + pull_request: + types: + - closed + workflow_dispatch: + inputs: + PR_NUMBER: + description: Related pull request Id + required: false + type: number + BRANCH_NAME: + description: Related branch name + required: false + type: string + +jobs: + cleanup-cache: + name: Delete github cache + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Clean cache for closed branch + run: | + REPO=${{ github.repository }} + if [ -n "${{ inputs.BRANCH_NAME }}" ]; then + BRANCH="${{ inputs.BRANCH_NAME }}" + else + BRANCH="refs/pull/${{ github.event.pull_request.number || inputs.PR_NUMBER }}/merge" + fi + + echo "Fetching list of cache key" + cacheKeysForPR=$(gh cache list -R $REPO --ref $BRANCH -L 100 | cut -f 1 ) + + ## Setting this to not fail the workflow while deleting cache keys. + set +e + echo "Deleting caches..." + for cacheKey in $cacheKeysForPR; do + gh cache delete $cacheKey -R $REPO + done + echo "Done" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + cleanup-image: + name: Delete image from ghcr.io + runs-on: ubuntu-latest + needs: + - matrix + strategy: + fail-fast: false + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Get repository owner and name + id: image-infos + run: | + echo "ORG_NAME=$(echo '${{ github.repository }}' | cut -d "/" -f 1)" >> $GITHUB_OUTPUT + echo "REPO_NAME=$(echo '${{ github.repository }}' | cut -d "/" -f 2)" >> $GITHUB_OUTPUT + + - name: Delete ${{ matrix.images.name }} image + run: | + ./ci/scripts/delete-image.sh \ + -o "${{ steps.image-infos.outputs.ORG_NAME }}" \ + -i "${{ steps.image-infos.outputs.REPO_NAME }}/${{ matrix.images.name }}" \ + -t "pr-${{ github.event.pull_request.number || github.event.number }}" \ + -g "${{ secrets.GITHUB_TOKEN }}" diff --git a/.jjconflict-side-1/.github/workflows/workflow-continuous-integration.yml b/.jjconflict-side-1/.github/workflows/workflow-continuous-integration.yml new file mode 100644 index 0000000000..1553fddc93 --- /dev/null +++ b/.jjconflict-side-1/.github/workflows/workflow-continuous-integration.yml @@ -0,0 +1,150 @@ +name: Continuous Integration + +permissions: + contents: read + # Needed for build job + packages: write + # Needed for build-label and scan-vuln jobs + pull-requests: write + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + branches: + - "**" + workflow_dispatch: + +env: + NODE_VERSION: 24.13.1 + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: false + USE_QEMU: false + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ steps.filter.outputs.apps }} + packages: ${{ steps.filter.outputs.packages }} + e2e: ${{ steps.filter.outputs.e2e }} + ci: ${{ steps.filter.outputs.ci }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + ci: + - '.github/workflows/**' + e2e: + - 'playwright/**' + + expose-vars: + runs-on: ubuntu-latest + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + lint: + uses: ./.github/workflows/job-lint.yml + needs: + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + + unit-tests: + uses: ./.github/workflows/job-tests-unit.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' }} + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + secrets: + SONAR_HOST_URL: "${{ secrets.SONAR_HOST_URL }}" + SONAR_TOKEN: "${{ secrets.SONAR_TOKEN }}" + SONAR_PROJECT_KEY: "${{ secrets.SONAR_PROJECT_KEY }}" + + build: + uses: ./.github/workflows/job-build.yml + if: ${{ github.event.pull_request.number && (needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true') }} + needs: + - path-filter + - expose-vars + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.pull_request.number || github.event.number }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.number }} + secrets: + ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }} + + build-label: + uses: ./.github/workflows/job-label.yml + needs: + - expose-vars + - build + with: + CONF_PATH: ./.github/labeler/build.yml + + scan-vuln: + uses: ./.github/workflows/job-scan.yml + needs: + - expose-vars + - build + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.pull_request.number || github.event.number }} + + # Workaround for required status check in protection branches (see. https://github.com/orgs/community/discussions/13690) + all-jobs-passed: + name: Check jobs status + runs-on: ubuntu-latest + if: ${{ always() }} + needs: + - path-filter + - expose-vars + - lint + - unit-tests + - build + - scan-vuln + steps: + - name: Check status of all required jobs + run: |- + NEEDS_CONTEXT='${{ toJson(needs) }}' + JOB_IDS=$(echo "$NEEDS_CONTEXT" | jq -r 'keys[]') + for JOB_ID in $JOB_IDS; do + RESULT=$(echo "$NEEDS_CONTEXT" | jq -r ".[\"$JOB_ID\"].result") + echo "$JOB_ID job result: $RESULT" + if [[ $RESULT != "success" && $RESULT != "skipped" ]]; then + echo "***" + echo "Error: The $JOB_ID job did not pass." + exit 1 + fi + done + echo "All jobs passed or were skipped." diff --git a/.jjconflict-side-1/.github/workflows/workflow-create-or-update-release.yml b/.jjconflict-side-1/.github/workflows/workflow-create-or-update-release.yml new file mode 100644 index 0000000000..70d77d68c7 --- /dev/null +++ b/.jjconflict-side-1/.github/workflows/workflow-create-or-update-release.yml @@ -0,0 +1,93 @@ +name: Create/update next release + +permissions: + contents: write + pull-requests: write + # Needed for build job + packages: write + +on: + push: + branches: + - main + - hotfix/* + +env: + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: true + USE_QEMU: false + NODE_VERSION: 24.13.1 + +jobs: + expose-vars: + runs-on: ubuntu-latest + outputs: + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + NODE_VERSION: ${{ env.NODE_VERSION }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars" + + release: + uses: ./.github/workflows/job-release-please.yml + + build-amd64: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.release.outputs.release-created == 'true' }} + needs: + - expose-vars + - release + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: ${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} + MAJOR_TAG: ${{ needs.release.outputs.major-tag }} + MINOR_TAG: ${{ needs.release.outputs.minor-tag }} + PATCH_TAG: ${{ needs.release.outputs.patch-tag }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: false + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + + build-arm64: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.release.outputs.release-created == 'true' }} + needs: + - expose-vars + - release + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: ${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} + MAJOR_TAG: ${{ needs.release.outputs.major-tag }} + MINOR_TAG: ${{ needs.release.outputs.minor-tag }} + PATCH_TAG: ${{ needs.release.outputs.patch-tag }} + BUILD_AMD64: false + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + + update-chart: + runs-on: ubuntu-latest + needs: + - expose-vars + - release + - build-amd64 + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: helm-charts + - name: Trigger helm-charts update + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + run: | + gh workflow --repo cloud-pi-native/helm-charts run update-chart.yaml -f APP_VERSION=${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} -f CHART_NAME=dso-console diff --git a/.jjconflict-side-1/.github/workflows/workflow-create-preview-comment.yml b/.jjconflict-side-1/.github/workflows/workflow-create-preview-comment.yml new file mode 100644 index 0000000000..e91b3731b1 --- /dev/null +++ b/.jjconflict-side-1/.github/workflows/workflow-create-preview-comment.yml @@ -0,0 +1,43 @@ +name: Add preview comment + +permissions: + pull-requests: write + +on: + pull_request: + types: + - labeled + branches: + - "**" + +jobs: + preview-comment: + name: Add comment with preview infos + if: contains(github.event.pull_request.labels.*.name, 'preview') + runs-on: ubuntu-latest + steps: + - name: Generate app url + id: generate-url + run: | + echo "CONSOLE_URL=$(echo "${{ vars.ARGOCD_CONSOLE_URL_TEMPLATE }}" | sed 's|<pr_number>|${{ github.event.number }}|g')" >> $GITHUB_OUTPUT + + - name: Add comment on Merge Request + id: add-comment-on-merge-request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MERGE_REQUEST_ID: ${{ github.event.pull_request.number }} + COMMENT: | + 🤖 Hey ! + + A preview of the application is available at : ${{ steps.generate-url.outputs.CONSOLE_URL }} + + *Please be patient, deployment may take a few minutes.* + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.MERGE_REQUEST_ID, + body: process.env.COMMENT, + }); diff --git a/.jjconflict-side-1/.github/workflows/workflow-merge-queue.yml b/.jjconflict-side-1/.github/workflows/workflow-merge-queue.yml new file mode 100644 index 0000000000..cca23047d1 --- /dev/null +++ b/.jjconflict-side-1/.github/workflows/workflow-merge-queue.yml @@ -0,0 +1,166 @@ +name: Merge Queue + +permissions: + contents: read + # Needed for build job + packages: write + # Needed for build-label job + pull-requests: write + +on: + workflow_dispatch: + inputs: + PR_NUMBER: + description: Pull request number to run checks for + required: true + type: string + merge_group: + types: [checks_requested] + +env: + NODE_VERSION: 24.13.1 + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: false + USE_QEMU: false + MERGE_GROUP_HEAD_SHA: "${{ github.event.merge_group.head_sha || github.sha }}" + MERGE_GROUP_HEAD_REF: "${{ github.event.merge_group.head_ref || github.ref_name }}" + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.apps }} + packages: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.packages }} + e2e: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.e2e }} + ci: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.ci }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + if: ${{ github.event_name != 'workflow_dispatch' }} + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + ci: + - '.github/workflows/**' + e2e: + - 'playwright/**' + + expose-vars: + runs-on: ubuntu-latest + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + MERGE_GROUP_HEAD_REF: ${{ env.MERGE_GROUP_HEAD_REF }} + MERGE_GROUP_HEAD_SHA: ${{ env.MERGE_GROUP_HEAD_SHA }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + lint: + uses: ./.github/workflows/job-lint.yml + needs: + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + + unit-tests: + uses: ./.github/workflows/job-tests-unit.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + secrets: + SONAR_HOST_URL: "${{ secrets.SONAR_HOST_URL }}" + SONAR_TOKEN: "${{ secrets.SONAR_TOKEN }}" + SONAR_PROJECT_KEY: "${{ secrets.SONAR_PROJECT_KEY }}" + + build: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + PR_NUMBER: ${{ github.event.inputs.PR_NUMBER || github.event.number || '' }} + secrets: + ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }} + + build-label: + uses: ./.github/workflows/job-label.yml + needs: + - expose-vars + - build + with: + CONF_PATH: ./.github/labeler/build.yml + + playwright-tests: + uses: ./.github/workflows/job-playwright.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + - build + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + + scan-vuln: + uses: ./.github/workflows/job-scan.yml + needs: + - expose-vars + - build + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + + # Workaround for required status check in protection branches (see. https://github.com/orgs/community/discussions/13690) + all-jobs-passed: + name: Check jobs status + runs-on: ubuntu-latest + if: ${{ always() }} + needs: + - path-filter + - expose-vars + - lint + - unit-tests + - build + - playwright-tests + - scan-vuln + steps: + - name: Check status of all required jobs + run: |- + NEEDS_CONTEXT='${{ toJson(needs) }}' + JOB_IDS=$(echo "$NEEDS_CONTEXT" | jq -r 'keys[]') + for JOB_ID in $JOB_IDS; do + RESULT=$(echo "$NEEDS_CONTEXT" | jq -r ".[\"$JOB_ID\"].result") + echo "$JOB_ID job result: $RESULT" + if [[ $RESULT != "success" && $RESULT != "skipped" ]]; then + echo "***" + echo "Error: The $JOB_ID job did not pass." + exit 1 + fi + done + echo "All jobs passed or were skipped." diff --git a/.jjconflict-side-1/.github/workflows/workflow-post-merge.yml b/.jjconflict-side-1/.github/workflows/workflow-post-merge.yml new file mode 100644 index 0000000000..da61baa341 --- /dev/null +++ b/.jjconflict-side-1/.github/workflows/workflow-post-merge.yml @@ -0,0 +1,56 @@ +name: Post-merge + +on: + push: + branches: + - main + +env: + NODE_VERSION: 24.13.1 + +permissions: + contents: read + id-token: write + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ steps.filter.outputs.apps }} + packages: ${{ steps.filter.outputs.packages }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + + expose-vars: + runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.draft }} + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + npm-publish: + uses: ./.github/workflows/job-npm.yml + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + PUBLISH_PACKAGES: true + PUBLISH_PLUGINS: true + CHECK_CHANGES: true + # Deprecated variable: we never publish the apps on NPM registry + PUBLISH_APPS: false diff --git a/.jjconflict-side-1/.gitignore b/.jjconflict-side-1/.gitignore new file mode 100644 index 0000000000..0d127c9b66 --- /dev/null +++ b/.jjconflict-side-1/.gitignore @@ -0,0 +1,77 @@ +# code +.vscode/* +!.vscode/vue-dsfr.code-snippets +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + + +# modules +node_modules +**/node_modules + +# env +.env +.env.ci +.env.test +.env.prod +.env.int +.env.integ +.env.codespace +.env.docker + +# init-db +apps/server/src/init/db/imports/data + +# act +**/act/artifacts +**/act/docker/registry/auth +**/act/docker/registry/data + +# vitest +coverage +apps/client/vite.config.*.timestamp-*.mjs +apps/server/vite.config.*.timestamp-*.mjs + +# sonarqube +.scannerwork + +# keycloak +keycloak/data + +# turbo +.turbo + +# kube config +.kubeconfig + +# dist +dist +build +components.d.ts +*.tsbuildinfo + +# types +types/ + +# plugins +apps/server/src/plugins/external + +# helm +charts/ + +# integration helm values +**/env/dso-values-integ.yaml +**/env/kind-values.yml + +# tmp dev files +ci/kind/configs/rendered + +external-plugins + +# Playwright +playwright/test-results/ +playwright/playwright-report/ +playwright/blob-report/ +playwright/playwright/.cache/ diff --git a/.jjconflict-side-1/.husky/commit-msg b/.jjconflict-side-1/.husky/commit-msg new file mode 100755 index 0000000000..8761c9bac9 --- /dev/null +++ b/.jjconflict-side-1/.husky/commit-msg @@ -0,0 +1 @@ +pnpx commitlint --edit ${1} diff --git a/.jjconflict-side-1/.lintstagedrc b/.jjconflict-side-1/.lintstagedrc new file mode 100644 index 0000000000..741d5e9ca3 --- /dev/null +++ b/.jjconflict-side-1/.lintstagedrc @@ -0,0 +1,8 @@ +{ + "**/apps/client/**/*.{vue,css}": "pnpm --filter=@cpn-console/client run lint:style --", + "**/apps/client/**/*": "pnpm --filter=@cpn-console/client run lint:ts --", + "**/apps/server/**/*": "pnpm --filter=@cpn-console/server run lint --", + "**/packages/**/*": "pnpm --filter=./packages/* run lint --", + "**/plugins/**/*": "pnpm --filter=./plugins/* run lint --", + "**/*": "pnpm run lint:root --" +} diff --git a/.jjconflict-side-1/.pinact.yaml b/.jjconflict-side-1/.pinact.yaml new file mode 100644 index 0000000000..3b20588ab3 --- /dev/null +++ b/.jjconflict-side-1/.pinact.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/pinact/refs/heads/main/json-schema/pinact.json +# pinact - https://github.com/suzuki-shunsuke/pinact +version: 3 +# files: +# - pattern: action.yaml +# - pattern: */action.yaml + +# separator: " # " + +ignore_actions: +# - name: slsa-framework/slsa-github-generator/\.github/workflows/generator_generic_slsa3\.yml +# ref: v\d+\.\d+\.\d+ +# - name: actions/.* +# ref: main +# - name: suzuki-shunsuke/.* +# ref: release-.* diff --git a/.jjconflict-side-1/.release-please-manifest.json b/.jjconflict-side-1/.release-please-manifest.json new file mode 100644 index 0000000000..18953d7442 --- /dev/null +++ b/.jjconflict-side-1/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "9.22.2" +} diff --git a/.jjconflict-side-1/.vscode/extensions.json b/.jjconflict-side-1/.vscode/extensions.json new file mode 100644 index 0000000000..3795e131e1 --- /dev/null +++ b/.jjconflict-side-1/.vscode/extensions.json @@ -0,0 +1,30 @@ +{ + "recommendations": [ + // Cross-editor config + "editorconfig.editorconfig", + // Git + "mhutchie.git-graph", + "eamodio.gitlens", + // Node / npm + "christian-kohler.npm-intellisense", + // JavaScript + "dbaeumer.vscode-eslint", + // Vue.js + "vue.volar", + "vue.vscode-typescript-vue-plugin", + // Vitejs + "antfu.vite", + "antfu.browse-lite", + // Firefox Debugger + "firefox-devtools.vscode-firefox-debug", + // CSS + "csstools.postcss", + "stylelint.vscode-stylelint", + // YAML + "redhat.vscode-yaml", + // Makdown + "yzhang.markdown-all-in-one", + // Kubernetes + "ms-kubernetes-tools.vscode-kubernetes-tools" + ] +} diff --git a/.jjconflict-side-1/.vscode/launch.json b/.jjconflict-side-1/.vscode/launch.json new file mode 100644 index 0000000000..fbfb2f1013 --- /dev/null +++ b/.jjconflict-side-1/.vscode/launch.json @@ -0,0 +1,326 @@ +{ + // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. + // Pointez pour afficher la description des attributs existants. + // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Server (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server", + "run", + "integ" + ], + "envFile": "${workspaceFolder}/apps/server/.env.integ", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server", + "run", + "dev" + ], + "envFile": "${workspaceFolder}/apps/server/.env", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Infra (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "run", + "integ" + ], + "console": "internalConsole", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Infra (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "run", + "dev" + ], + "console": "internalConsole", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server NestJS (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server-nestjs", + "run", + "start:dev" + ], + "envFile": "${workspaceFolder}/apps/server-nestjs/.env.integ", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server NestJS (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server-nestjs", + "run", + "start:dev" + ], + "envFile": "${workspaceFolder}/apps/server-nestjs/.env", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Attach Server", + "type": "node", + "request": "attach", + "port": 9229, + "address": "127.0.0.1", + "localRoot": "${workspaceFolder}/apps/server", + "remoteRoot": "/app/apps/server", + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ], + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Chrome) (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "integ", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env.integ", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Chrome)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Chrome) (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "dev", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Chrome)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Firefox) (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "integ", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env.integ", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Firefox)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Firefox) (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "dev", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Firefox)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Launch Client (Chrome)", + "type": "chrome", + "request": "launch", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}/apps/client", + "presentation": { + "hidden": true + } + }, + { + "name": "Launch Client (Firefox)", + "type": "firefox", + "request": "launch", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}/apps/client", + "presentation": { + "hidden": true + } + } + ], + "compounds": [ + { + "name": "Launch (Chrome) (Integ)", + "configurations": [ + "Launch Infra (Integ)", + "Launch Server (Integ)", + "Launch Server NestJS (Integ)", + "Serve Client (Chrome) (Integ)" + ] + }, + { + "name": "Launch (Chrome) (Dev)", + "configurations": [ + "Launch Infra (Dev)", + "Launch Server (Dev)", + "Launch Server NestJS (Dev)", + "Serve Client (Chrome) (Dev)" + ] + }, + { + "name": "Launch (Firefox) (Integ)", + "configurations": [ + "Launch Infra (Integ)", + "Launch Server (Integ)", + "Launch Server NestJS (Integ)", + "Serve Client (Firefox) (Integ)" + ] + }, + { + "name": "Launch (Firefox) (Dev)", + "configurations": [ + "Launch Infra (Dev)", + "Launch Server (Dev)", + "Launch Server NestJS (Dev)", + "Serve Client (Firefox) (Dev)" + ] + } + ] +} diff --git a/.jjconflict-side-1/.vscode/settings.json b/.jjconflict-side-1/.vscode/settings.json new file mode 100644 index 0000000000..1b93a34e04 --- /dev/null +++ b/.jjconflict-side-1/.vscode/settings.json @@ -0,0 +1,120 @@ +{ + // visuals + "window.autoDetectColorScheme": true, + "editor.fontLigatures": "'ss01', 'ss02', 'ss03', 'ss06', 'zero'", + "editor.cursorBlinking": "phase", + "editor.suggestSelection": "first", + "editor.wordWrap": "on", + "editor.bracketPairColorization.enabled": true, + "editor.tabSize": 2, + "search.exclude": { + "**/.git": true, + "**/.nuxt": true, + "**/.pnpm": true, + "**/.yarn": true, + "**/.turbo": true, + "**/dist/**": true, + "**/out/**": true, + "**/logs": true, + "**/node_modules": true, + "**/package-lock.json": true, + "**/pnpm-lock.yaml": true, + "**/yarn.lock": true + }, + // general + "editor.unicodeHighlight.invisibleCharacters": false, + "workbench.startupEditor": "none", + // git + "git.autofetch": true, + "git.untrackedChanges": "separate", + // github + "githubPullRequests.pullBranch": "never", + // eslint + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "vue", + "html", + "markdown", + "json", + "jsonc", + "yaml", + "github-actions-workflow", + "toml", + "xml", + "gql", + "graphql", + "astro", + "svelte", + "css", + "less", + "scss", + "pcss", + "postcss" + ], + // stylelint + "css.validate": true, + "less.validate": true, + "scss.validate": true, + "files.associations": { + "*.css": "css" + }, + // languages + "[javascript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "javascript.preferences.quoteStyle": "single", + "javascript.preferences.importModuleSpecifierEnding": "js", + "javascript.suggest.completeJSDocs": true, + "javascript.suggest.jsdoc.generateReturns": true, + "[typescript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "typescript.preferences.quoteStyle": "single", + "typescript.preferences.importModuleSpecifierEnding": "js", + "typescript.suggest.completeJSDocs": true, + "typescript.suggest.jsdoc.generateReturns": true, + "[vue]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[css]": { + "editor.defaultFormatter": "stylelint.vscode-stylelint" + }, + "[html]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[markdown]": { + "editor.quickSuggestions": { + "other": true, + "comments": true, + "strings": true + }, + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.defaultFormatter": "yzhang.markdown-all-in-one" + }, + // conventional commits + "conventionalCommits.showNewVersionNotes": false, + // kubernetes + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "vscode-kubernetes.log-viewer.follow": true, + "vscode-kubernetes.log-viewer.timestamp": false, + "vscode-kubernetes.log-viewer.since": -1, + "vscode-kubernetes.log-viewer.tail": -1, + "vscode-kubernetes.log-viewer.destination": "Webview", + "vscode-kubernetes.log-viewer.wrap": true, + // sonarqube + "sonarlint.connectedMode.project": { + "connectionId": "https-sonarqube-fabrique-numerique-fr", + "projectKey": "cloud-pi-native_console_AYsa46O31ebDtzs-pYyZ" + }, + "prisma-smart-formatter.typescript.defaultFormatter": "dbaeumer.vscode-eslint", + "prisma-smart-formatter.prisma.defaultFormatter": "Prisma.prisma" +} diff --git a/.jjconflict-side-1/.vscode/vue-dsfr.code-snippets b/.jjconflict-side-1/.vscode/vue-dsfr.code-snippets new file mode 100644 index 0000000000..2533f522d8 --- /dev/null +++ b/.jjconflict-side-1/.vscode/vue-dsfr.code-snippets @@ -0,0 +1,823 @@ +{ + "template": { + "scope": "vue", + "prefix": "template", + "body": [ + "<template>", + " $1", + "</template>" + ], + "description": "Create <template> block" + }, + "script": { + "scope": "vue", + "prefix": "script", + "body": [ + "<script>", + "import { defineComponent } from 'vue';", + "", + "export default defineComponent({", + " $2", + "});", + "</script>" + ], + "description": "Create <script> block" + }, + "script setup": { + "scope": "vue", + "prefix": "script setup", + "body": [ + "<script setup>", + "defineProps({", + " $1", + "});", + "</script>" + ], + "description": "Create <script setup> + <script> blocks" + }, + "style": { + "scope": "vue", + "prefix": "scoped-style", + "body": [ + "<style scoped>", + "$1", + "</style>" + ], + "description": "Create <style> block" + }, + "v-for": { + "scope": "html", + "prefix": "v-for", + "body": [ + "<template v-for=\"$1 in $2\">", + " $3", + "</template>" + ], + "description": "Create html tag with v-for" + }, + "v-if": { + "scope": "html", + "prefix": "v-if", + "body": [ + "<template v-if=\"$1\">", + " $2", + "</template>" + ], + "description": "Create html tag with v-if" + }, + "v-else-if": { + "scope": "html", + "prefix": "v-else-if", + "body": [ + "<template v-else-if=\"$1\">", + " $2", + "</template>" + ], + "description": "Create html tag with v-else-if" + }, + "v-else": { + "scope": "html", + "prefix": "v-else", + "body": [ + "<template v-else>", + " $1", + "</template>" + ], + "description": "Create html tag with v-else" + }, + "DsfrAccordion": { + "scope": "html", + "prefix": "DsfrAccordion", + "body": [ + "<DsfrAccordion", + " :expanded-id=\"${1:expandedId}\"", + " :title=\"${2:accordionTitle}\"", + ">", + " $3", + "</DsfrAccordion>", + ], + "description": "Create DsfrAccordion boilerplate" + }, + "DsfrAccordionsGroup": { + "scope": "html", + "prefix": "DsfrAccordionsGroup", + "body": [ + "<DsfrAccordionsGroup>", + " <li>", + " <DsfrAccordion", + " :expanded-id=\"${1:expandedId}\"", + " :title=\"${2:accordionTitle}\"", + " @expand=\"expandedId = $event\"", + " >", + " </li>", + "</DsfrAccordionsGroup>", + ], + "description": "Create DsfrAccordionsGroup boilerplate" + }, + "DsfrAlert": { + "scope": "html", + "prefix": "DsfrAlert", + "body": [ + "<DsfrAlert", + " :title=\"${1:alertTitle}\"", + " :description=\"${2:alertDescription}\"", + " :type=\"${3:alertType}\"", + " :closed=\"${4:!openAlert}\"", + " ${5:closeable}", + " @close=\"${6:closeAlert()}\"", + "/>", + ], + "description": "Create DsfrAlert boilerplate" + }, + "DsfrBadge": { + "scope": "html", + "prefix": "DsfrBadge", + "body": [ + "<DsfrBadge", + " :label=\"${1}\"", + " :type=\"${2:success}\"", + "/>", + ], + "description": "Create DsfrBadge boilerplate" + }, + "DsfrBreadcrumb": { + "scope": "html", + "prefix": "DsfrBreadcrumb", + "body": [ + "<DsfrBreadcrumb", + " :links=\"${1:links}\"", + "/>", + ], + "description": "Create DsfrBreadcrumb boilerplate" + }, + "DsfrButton": { + "scope": "html", + "prefix": "DsfrButton", + "body": [ + "<DsfrButton", + " type=\"${1:buttonType}\"", + " :label=\"${2:buttonLabel}\"", + " ${3:secondary}", + " @click=\"${4:onClick()}\"", + "/>", + ], + "description": "Create DsfrButton boilerplate" + }, + "DsfrButtonGroup": { + "scope": "html", + "prefix": "DsfrButtonGroup", + "body": [ + "<DsfrButtonGroup", + " :buttons=\"${1:buttons}\"", + " :label=\"${2:buttonLabel}\"", + " ${3:secondary}", + " @click=\"${4:onClick()}\"", + "/>", + ], + "description": "Create DsfrButtonGroup boilerplate" + }, + "DsfrCallout": { + "scope": "html", + "prefix": "DsfrCallout", + "body": [ + "<DsfrCallout", + " :title=\"${1:title}\"", + " :content=\"${2:content}\"", + " :button=\"${3:button}\"", + " :icon=\"${4:icon}\"", + "/>", + ], + "description": "Create DsfrCallout boilerplate" + }, + "DsfrCard": { + "scope": "html", + "prefix": "DsfrCard", + "body": [ + "<DsfrCard", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :detail=\"${3:detail}\"", + " :link=\"${4:link}\"", + " :alt-img=\"${5:altImg}\"", + " :img-src=\"${6:imgSrc}\"", + "/>", + ], + "description": "Create DsfrCard boilerplate" + }, + "DsfrCheckbox": { + "scope": "html", + "prefix": "DsfrCheckbox", + "body": [ + "<DsfrCheckbox", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + " :name=\"${3:name}\"", + " :modelValue=\"${4:checked}\"", + " @update:model-value=\"updateChecked($event)\"", + "/>", + ], + "description": "Create DsfrCheckbox boilerplate" + }, + "DsfrCheckboxSet": { + "scope": "html", + "prefix": "DsfrCheckboxSet", + "body": [ + "<DsfrCheckboxSet", + " v-model=\"${1:selectedCheckbox}\"", + " :legend=\"${2:legend}\"", + " :options=\"${3:options}\"", + "/>", + ], + "description": "Create DsfrCheckboxSet boilerplate" + }, + "DsfrConsent": { + "scope": "html", + "prefix": "DsfrConsent", + "body": [ + "<DsfrConsent", + " :url=\"${1:url}\"", + " @accept-all=\"${2:onAcceptAll()}\"", + " @refuse-all=\"${3:onRefuseAll()}\"", + " @customize=\"${3:onCustomize()}\"", + "/>", + ], + "description": "Create DsfrConsent boilerplate" + }, + "DsfrFieldset": { + "scope": "html", + "prefix": "DsfrFieldset", + "body": [ + "<DsfrFieldset", + " :legend=\"${1:legend}\"", + " :hint=\"${2:hint}\"", + " :legend-class=\"${1:legendClass}\"", + " :hint-class=\"${1:hintClass}\"", + " :legend-id=\"${1:legendId}\"", + "/>", + ], + "description": "Create DsfrFieldset boilerplate" + }, + "DsfrFileUpload": { + "scope": "html", + "prefix": "DsfrFileUpload", + "body": [ + "<DsfrFileUpload", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + "/>", + ], + "description": "Create DsfrFileUpload boilerplate" + }, + "DsfrFollow": { + "scope": "html", + "prefix": "DsfrFollow", + "body": [ + "<DsfrFollow", + " :networks=\"${1:networks}\"", + " :newsletter-data=\"${2:newsletterData}\"", + "/>", + ], + "description": "Create DsfrFollow boilerplate" + }, + "DsfrFranceConnect": { + "scope": "html", + "prefix": "DsfrFranceConnect", + "body": [ + "<DsfrFranceConnect ${1:secure} />", + ], + "description": "Create DsfrFranceConnect boilerplate" + }, + "DsfrNewsLetter": { + "scope": "html", + "prefix": "DsfrNewsLetter", + "body": [ + "<DsfrNewsLetter", + " v-model:email=\"${1:email}\"", + " :title=\"${2:title}\"", + " :description=\"${3:description}\"", + " :labelEmail=\"${4:labelEmail}\"", + " :inputTitle=\"${5:inputTitle}\"", + " :placeholder=\"${6:placeholder}\"", + " :hintText=\"${7:hintText}\"", + " :buttonText=\"${8:buttonText}\"", + "/>", + ], + "description": "Create DsfrNewsLetter boilerplate" + }, + "DsfrSocialNetworks": { + "scope": "html", + "prefix": "DsfrSocialNetworks", + "body": [ + "<DsfrSocialNetworks", + " :networks=\"${1:networks}\"", + "/>", + ], + "description": "Create DsfrSocialNetworks boilerplate" + }, + "DsfrFooter": { + "scope": "html", + "prefix": "DsfrFooter", + "body": [ + "<DsfrFooter", + " :a11y-compliance=\"${1:a11yCompliance}\"", + " :logo-text=\"${2:logoText}\"", + " :legal-link=\"${3:legalLink}\"", + " :personal-data-link=\"${4:personalDataLink}\"", + " :cookies-link=\"${5:cookiesLink}\"", + " :a11y-compliance-link=\"${6:a11yComplianceLink}\"", + " :desc-text=\"${7:descText}\"", + " :home-link=\"${8:homeLink}\"", + " :partners=\"${9:partners}\"", + " :ecosystem-links=\"${10:ecosystemLinks}\"", + "/>", + ], + "description": "Create DsfrFooter boilerplate" + }, + "DsfrHeader": { + "scope": "html", + "prefix": "DsfrHeader", + "body": [ + "<DsfrHeader", + " :service-title=\"${1:serviceTitle}\"", + " :service-description=\"${2:serviceDescription}\"", + " :logo-text=\"${3:logoText}\"", + " :quick-links=\"${4:quickLinks}\"", + " ${5:show-search}", + "/>", + ], + "description": "Create DsfrHeader boilerplate" + }, + "DsfrHighlight": { + "scope": "html", + "prefix": "DsfrHighlight", + "body": [ + "<DsfrHighlight", + " :text=\"${1:text}\"", + "/>", + ], + "description": "Create DsfrHighlight boilerplate" + }, + "DsfrInput": { + "scope": "html", + "prefix": "DsfrInput", + "body": [ + "<DsfrInput", + " v-model=\"${1:inputValue}\"", + " type=\"${2:inputType}\"", + " :label=\"${3:inputLabel}\"", + " ${4:label-visible}", + " :placeholder=\"${5:inputPlaceholder}\"", + "/>", + ], + "description": "Create DsfrInput boilerplate" + }, + "DsfrLogo": { + "scope": "html", + "prefix": "DsfrLogo", + "body": [ + "<DsfrLogo", + " logo-text=\"$1\"", + "/>", + ], + "description": "Create DsfrLogo boilerplate" + }, + "DsfrPicture": { + "scope": "html", + "prefix": "DsfrPicture", + "body": [ + "<DsfrPicture", + " :src=\"${1:src}\"", + " :alt=\"${2:alt}\"", + " :title=\"${3:title}\"", + " :legend=\"${4:legend}\"", + "/>", + ], + "description": "Create DsfrPicture boilerplate" + }, + "DsfrVideo": { + "scope": "html", + "prefix": "DsfrVideo", + "body": [ + "<DsfrVideo", + " :src=\"${1:src}\"", + " :legend=\"${2:legend}\"", + "/>", + ], + "description": "Create DsfrVideo boilerplate" + }, + "DsfrModal": { + "scope": "html", + "prefix": "DsfrModal", + "body": [ + "<DsfrButton", + " label=\"${1:Ouvre la modale}\"", + " ref=\"modalOrigin\"", + " @click=\"${2:open()}\"", + "/>", + "<DsfrModal", + " :opened=\"${3:opened}\"", + " :title=\"${4:title}\"", + " :origin=\"$refs.modalOrigin\"", + ">", + " $5", + "</DsfrModal>", + ], + "description": "Create DsfrModal boilerplate" + }, + "DsfrNavigation": { + "scope": "html", + "prefix": "DsfrNavigation", + "body": [ + "<DsfrNavigation", + " :nav-items=\"${1:navItems}\"", + "/>", + ], + "description": "Create DsfrNavigation boilerplate" + }, + "DsfrNavigationItem": { + "scope": "html", + "prefix": "DsfrNavigationItem", + "body": [ + "<DsfrNavigationItem :active=\"${1:active}\">", + " $2", + "</DsfrNavigationItem>", + ], + "description": "Create DsfrNavigationItem boilerplate" + }, + "DsfrNavigationMegaMenu": { + "scope": "html", + "prefix": "DsfrNavigationMegaMenu", + "body": [ + "<DsfrNavigationMegaMenu", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :link=\"${3:link}\"", + " :menus=\"${4:menus}\"", + " :expanded-id=\"${5:expandedId}\"", + " :@click=\"${6:toggle($event)}\"", + ">", + " $7", + "</DsfrNavigationMegaMenu>", + ], + "description": "Create DsfrNavigationMegaMenu boilerplate" + }, + "DsfrNavigationMegaMenuCategory": { + "scope": "html", + "prefix": "DsfrNavigationMegaMenuCategory", + "body": [ + "<DsfrNavigationMegaMenuCategory", + " :title=\"${1:title}\"", + " :links=\"${2:links}\"", + "/>", + ], + "description": "Create DsfrNavigationMegaMenuCategory boilerplate" + }, + "DsfrNavigationMenu": { + "scope": "html", + "prefix": "DsfrNavigationMenu", + "body": [ + "<DsfrNavigationMenu", + " :title=\"${1:title}\"", + " :links=\"${2:links}\"", + " :expanded-id=\"${3:expandedId}\"", + " :@click=\"${4:toggle($event)}\"", + "/>", + ], + "description": "Create DsfrNavigationMenu boilerplate" + }, + "DsfrNavigationMenuItem": { + "scope": "html", + "prefix": "DsfrNavigationMenuItem", + "body": [ + "<DsfrNavigationMenuItem", + " :active=\"${1:active}\"", + ">", + "</DsfrNavigationMenuItem>", + ], + "description": "Create DsfrNavigationMenuItem boilerplate" + }, + "DsfrNavigationMenuLink": { + "scope": "html", + "prefix": "DsfrNavigationMenuLink", + "body": [ + "<DsfrNavigationMenuLink", + " :to=\"${1:to}\"", + " :text=\"${2:text}\"", + "/>", + ], + "description": "Create DsfrNavigationMenuLink boilerplate" + }, + "DsfrPagination": { + "scope": "html", + "prefix": "DsfrPagination", + "body": [ + "<DsfrPagination", + " :pages=\"${1:pages}\"", + " v-model:current-page=\"${2:currentPage}\"", + "/>", + ], + "description": "Create DsfrPagination boilerplate" + }, + "DsfrQuote": { + "scope": "html", + "prefix": "DsfrQuote", + "body": [ + "<DsfrQuote", + " :quote=\"${1:quote}\"", + " :author=\"${2:author}\"", + " :details=\"${3:details}\"", + " :illustration=\"${4:illustration}\"", + " :source=\"${5:source}\"", + " :sourceUrl=\"${6:sourceUrl}\"", + " :quoteImage=\"${7:quoteImage}\"", + "/>", + ], + "description": "Create DsfrQuote boilerplate" + }, + "DsfrRadioButton": { + "scope": "html", + "prefix": "DsfrRadioButton", + "body": [ + "<DsfrRadioButton", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + " :name=\"${3:name}\"", + " :value=\"${4:value}\"", + " :model-value=\"${5:modelValue}\"", + " @update:model-value=\"${6:updateCheckedValue($event)}\"", + "/>", + ], + "description": "Create DsfrRadioButton boilerplate" + }, + "DsfrRadioButtonSet": { + "scope": "html", + "prefix": "DsfrRadioButtonSet", + "body": [ + "<DsfrRadioButtonSet", + " v-model=\"${1:modelValue}\"", + " :legend=\"${2:legend}\"", + " name=\"${2:radio-set}\"", + " :options=\"${4:options}\"", + " @update:model-value=\"${6:updateCheckedValue($event)}\"", + "/>", + ], + "description": "Create DsfrRadioButtonSet boilerplate" + }, + "DsfrSearchBar": { + "scope": "html", + "prefix": "DsfrSearchBar", + "body": [ + "<DsfrSearchBar", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :placeholder=\"${3:placeholder}\"", + " ${4:label-visible}", + " :button-text=\"${5:buttonText}\"", + "/>", + ], + "description": "Create DsfrSearchBar boilerplate" + }, + "DsfrSelect": { + "scope": "html", + "prefix": "DsfrSelect", + "body": [ + "<DsfrSelect", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :options=\"${3:options}\"", + "/>", + ], + "description": "Create DsfrSelect boilerplate" + }, + "DsfrShare": { + "scope": "html", + "prefix": "DsfrShare", + "body": [ + "<DsfrShare", + " :title=\"${1:title}\"", + " :networks=\"${2:networks}\"", + " :mail=\"${3:mail}\"", + " :copy-label=\"${4:copyLabel}\"", + "/>", + ], + "description": "Create DsfrShare boilerplate" + }, + "DsfrSideMenu": { + "scope": "html", + "prefix": "DsfrSideMenu", + "body": [ + "<DsfrSideMenu", + " :heading-title=\"${1:headingTitle}\"", + " :button-label=\"${2:buttonLabel}\"", + " :menu-items=\"${3:menuItems}\"", + " @toggle-expand=\"${4:toggleExpand($event)}\"", + "/>", + ], + "description": "Create DsfrSideMenu boilerplate" + }, + "DsfrSideMenuButton": { + "scope": "html", + "prefix": "DsfrSideMenuButton", + "body": [ + "<DsfrSideMenuButton", + " :expanded=\"${1:expanded}\"", + " :button-label=\"${2:buttonLabel}\"", + " :control-id=\"${3:controlId}\"", + " @toggle-expand=\"${4:toggleExpand($event)}\"", + "/>", + ], + "description": "Create DsfrSideMenuButton boilerplate" + }, + "DsfrSideMenuLink": { + "scope": "html", + "prefix": "DsfrSideMenuLink", + "body": [ + "<DsfrSideMenuLink", + " :active=\"${1:active}\"", + " :to=\"${2:to}\"", + "/>", + ], + "description": "Create DsfrSideMenuLink boilerplate" + }, + "DsfrSideMenuList": { + "scope": "html", + "prefix": "DsfrSideMenuList", + "body": [ + "<DsfrSideMenuList", + " :menu-items=\"${1:menuItems}\"", + " @toggle-expand=\"${2:toggleExpand}\"", + "/>", + ], + "description": "Create DsfrSideMenuList boilerplate" + }, + "DsfrSideMenuListItem": { + "scope": "html", + "prefix": "DsfrSideMenuListItem", + "body": [ + "<DsfrSideMenuListItem", + " :active=\"${1:active}\"", + ">", + " $2", + "</DsfrSideMenuListItem>", + ], + "description": "Create DsfrSideMenuListItem boilerplate" + }, + "DsfrSkipLinks": { + "scope": "html", + "prefix": "DsfrSkipLinks", + "body": [ + "<DsfrSkipLinks", + " :links=\"${1:links}\"", + "/>", + ], + "description": "Create DsfrSkipLinks boilerplate" + }, + "DsfrTable": { + "scope": "html", + "prefix": "DsfrTable", + "body": [ + "<DsfrTable", + " :title=\"${1:title}\"", + " :headers=\"${2:headers}\"", + " :rows=\"${3:rows}\"", + "/>", + ], + "description": "Create DsfrTable boilerplate" + }, + "DsfrTableCell": { + "scope": "html", + "prefix": "DsfrTableCell", + "body": [ + "<DsfrTableCell", + " :field=\"${1:field}\"", + "/>", + ], + "description": "Create DsfrTableCell boilerplate" + }, + "DsfrTableHeader": { + "scope": "html", + "prefix": "DsfrTableHeader", + "body": [ + "<DsfrTableHeader", + " :header=\"${1:header}\"", + " :header-attrs=\"${2:headerAttrs}\"", + "/>", + ], + "description": "Create DsfrTableHeader boilerplate" + }, + "DsfrTableHeaders": { + "scope": "html", + "prefix": "DsfrTableHeaders", + "body": [ + "<DsfrTableHeaders", + " :headers=\"${1:headers}\"", + "/>", + ], + "description": "Create DsfrTableHeaders boilerplate" + }, + "DsfrTableRow": { + "scope": "html", + "prefix": "DsfrTableRow", + "body": [ + "<DsfrTableRow", + " :row-data=\"${1:rowData}\"", + "/>", + ], + "description": "Create DsfrTableRow boilerplate" + }, + "DsfrTabContent": { + "scope": "html", + "prefix": "DsfrTabContent", + "body": [ + "<DsfrTabContent", + " :panel-id=\"${1:panelId}\"", + " :tab-id=\"${2:tabId}\"", + " :selected=\"${3:selected}\"", + " :asc=\"${4:asc}\"", + ">", + " $5", + "</DsfrTabContent>", + ], + "description": "Create DsfrTabContent boilerplate" + }, + "DsfrTabItem": { + "scope": "html", + "prefix": "DsfrTabItem", + "body": [ + "<DsfrTabItem", + " :tab-id=\"${2:tabId}\"", + " :panel-id=\"${1:panelId}\"", + " :selected=\"${3:selected}\"", + " :icon=\"${4:icon}\"", + ">", + " $5", + "</DsfrTabItem>", + ], + "description": "Create DsfrTabItem boilerplate" + }, + "DsfrTabs": { + "scope": "html", + "prefix": "DsfrTabs", + "body": [ + "<DsfrTabs", + " :tab-list-name=\"${1:tabListName}\"", + " :tab-titles=\"${2:tabTitles}\"", + " :tab-contents=\"${3:tabContents}\"", + "/>", + ], + "description": "Create DsfrTabs boilerplate" + }, + "DsfrTag": { + "scope": "html", + "prefix": "DsfrTag", + "body": [ + "<DsfrTag", + " :label=\"${1:label}\"", + "/>", + ], + "description": "Create DsfrTag boilerplate" + }, + "DsfrTags": { + "scope": "html", + "prefix": "DsfrTags", + "body": [ + "<DsfrTags", + " :tags=\"${1:tags}\"", + "/>", + ], + "description": "Create DsfrTags boilerplate" + }, + "DsfrTile": { + "scope": "html", + "prefix": "DsfrTile", + "body": [ + "<DsfrTile", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :to=\"${3:to}\"", + " :rows=\"${4:rows}\"", + "/>", + ], + "description": "Create DsfrTile boilerplate" + }, + "DsfrTiles": { + "scope": "html", + "prefix": "DsfrTiles", + "body": [ + "<DsfrTiles :tiles=\"${1:tiles}\" />", + ], + "description": "Create DsfrTiles boilerplate" + }, + "DsfrToggleSwitch": { + "scope": "html", + "prefix": "DsfrToggleSwitch", + "body": [ + "<DsfrToggleSwitch", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :hint=\"${2:hint}\"", + "/>", + ], + "description": "Create DsfrToggleSwitch boilerplate" + }, +} \ No newline at end of file diff --git a/.jjconflict-side-1/AGENTS.md b/.jjconflict-side-1/AGENTS.md new file mode 100644 index 0000000000..77866c7703 --- /dev/null +++ b/.jjconflict-side-1/AGENTS.md @@ -0,0 +1,85 @@ +# Console Cloud Pi Native + +pnpm monorepo. Node >= 24, pnpm v11.8 + +## Structure + +- `apps/client` : Vue 3 + Vite + vue-dsfr (French gov design system), Pinia, UnoCSS +- `apps/server` : Fastify 4 + Prisma 6 (PostgreSQL), contract-first API via @ts-rest +- `apps/server-nestjs` : NestJS rewrite (in progress) +- `plugins/*` : argocd, gitlab, harbor, keycloak, kubernetes, nexus, sonarqube, vault +- `packages/shared` : API contracts (@ts-rest), types, permissions (BigInt bitmasks) +- `packages/hooks` : plugin hook system (core of plugin architecture) +- `packages/test-utils`, `packages/eslintconfig`, `packages/tsconfig` +- `playwright/` : E2E tests (dedicated workspace) +- `docker/` : docker-compose files (local, dev, integ, prod, ci) +- `ci/scripts/` : init-env.sh, setup.sh, cleanup.sh, run-tests.sh +- `keycloak/` : DSFR theme and dev realm data + +## Server architecture (Fastify) + +Resource-based organization in `apps/server/src/resources/`. Each resource follows a 3-file pattern: +- `router.ts` : route handlers (auth, permissions, delegates to business) +- `business.ts` : business logic, orchestrates queries + hook calls +- `queries.ts` : Prisma database queries + +API contracts defined in `@cpn-console/shared`, shared with client via @ts-rest. +Auth: Keycloak + Fastify session. Permissions: BigInt bitmasks (`ProjectAuthorized`, `AdminAuthorized`). + +## Plugin / Hook system + +Hook lifecycle: `pre` -> `main` -> `post` (sequential steps, parallel plugin execution). On failure: `revert`. +Plugins are statically imported in `apps/server/src/plugins.ts`, then external plugins dynamically loaded from `/plugins`. +Each plugin: `index.ts` (Plugin interface), `infos.ts` (metadata/config), `functions.ts` (hook handlers). +Plugins use TS module augmentation to extend `ProjectStore` and `Config` interfaces. + +## Database (Prisma) + +Multi-file schema in `apps/server/src/prisma/schema/*.prisma` (project, user, token, admin, topography). +Migrations: standard Prisma Migrate. Major version data migrations in `migrations/v9/`. + +## Environment config + +- Files: `.env`, `.env.docker`, `.env.integ` in `apps/client/`, `apps/server/`, `apps/server-nestjs/` +- Templates: `*-example` suffix (git-tracked), active files gitignored +- Override chain (weakest to strongest): `.env` < `.env.docker` (if DOCKER=true) < `.env.integ` (if INTEGRATION=true) < explicit env vars +- Server loading: `apps/server/src/utils/env.ts` | Client: `apps/client/vite.config.ts` + +## Testing + +- **Vitest**: unit tests everywhere (server, client, packages, plugins) — colocated `*.spec.ts` files +- **Playwright**: E2E in `playwright/` (Chromium + Firefox, parallel) +- Commands: `pnpm test` (all unit), `pnpm playwright:test` + +## Code quality + +- ESLint 9 flat config based on `@antfu/eslint-config` (no Prettier, except server-nestjs) +- Stylelint for CSS/Vue in client +- Husky hooks: pre-commit (lint-staged), commit-msg (commitlint), pre-push (unit tests) +- Conventional commits enforced: `feat`, `fix`, `chore`, `docs`, `refactor`, `revert`, `build` + +## TypeScript + +- Shared base: `packages/tsconfig/tsconfig.base.json` — ESNext, NodeNext, strict, `@/* -> src/*` alias +- Server: extends shared base, uses `ts-patch`/`tspc` for path transform in emitted JS +- Client: does NOT extend shared base, uses `Bundler` module resolution +- server-nestjs: standalone config with `emitDecoratorMetadata` + `experimentalDecorators` + +## Main commands + +- `pnpm dev` : local Docker infra (keycloak, postgres, pgadmin) + server/client +- `pnpm docker:dev` : fully containerized with Docker Compose Watch +- `pnpm docker:integ` / `pnpm integ` : integration mode (remote env) +- `pnpm fullsetup` : full install (deps, prisma generate, build, Docker images) +- `pnpm fullclean` : full cleanup (builds, node_modules, Docker images) + +## Git & Release + +- Branches: `main` (protected) + `hotfix/*` +- Release Please for automated versioning, changelogs, npm publish, Docker images, Helm chart updates +- PR template: `.github/PULL_REQUEST_TEMPLATE.md` + +## Conventions + +- Template env files use `-example` suffix (not `.example`) +- `ci/scripts/init-env.sh` copies `*-example` to active equivalents (non-destructive) diff --git a/.jjconflict-side-1/CHANGELOG.md b/.jjconflict-side-1/CHANGELOG.md new file mode 100644 index 0000000000..642437ef86 --- /dev/null +++ b/.jjconflict-side-1/CHANGELOG.md @@ -0,0 +1,3507 @@ +# Changelog + +## [9.22.2](https://github.com/cloud-pi-native/console/compare/v9.22.1...v9.22.2) (2026-07-24) + + +### Bug Fixes + +* ArgoCD internal url for health check ([9429667](https://github.com/cloud-pi-native/console/commit/942966785e23fca986246948033afe60d40d7232)) + +## [9.22.1](https://github.com/cloud-pi-native/console/compare/v9.22.0...v9.22.1) (2026-07-21) + + +### Bug Fixes + +* **plugins/nexus:** remove invalid cleanup policy from repo provisioning to fix Nexus 3.85+ ([8b99cb6](https://github.com/cloud-pi-native/console/commit/8b99cb6a7807dac1f6ba525a6ee3164f3fe13141)) + +## [9.22.0](https://github.com/cloud-pi-native/console/compare/v9.21.0...v9.22.0) (2026-07-21) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** restrict deployment visibility to console admins ([def61e7](https://github.com/cloud-pi-native/console/commit/def61e76b3a867445c5ddb1c08e0f3af59b188ae)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([52c8173](https://github.com/cloud-pi-native/console/commit/52c8173f5610e83836b661d7472c0fe3ff22f70f)) +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* :construction: make all new cluster external by default ([dcd203f](https://github.com/cloud-pi-native/console/commit/dcd203f3569d93a74e0d627f6cf3eb898d6b338d)) +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* add old-server new Nest.js services to CpinModule ([a17c109](https://github.com/cloud-pi-native/console/commit/a17c109616270115af701366ae8014e05cb41e7c)) +* add server-nestjs README to explain what we are doing ([57a4ca5](https://github.com/cloud-pi-native/console/commit/57a4ca51086b7c7af44a1df83cd272b82eb9a9f8)) +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* apply prettier on old-server ([6a781ac](https://github.com/cloud-pi-native/console/commit/6a781ac80c756924c53e28815064197b63508ff4)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* bump turbo to v2.7.1 ([d955e52](https://github.com/cloud-pi-native/console/commit/d955e52e65ed829053fdb120b217cb2744bd02c2)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** trigger Playwright E2E tests before Cypress E2E tests ([2262f37](https://github.com/cloud-pi-native/console/commit/2262f3717101833217d5963580e07fb4f69e9847)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* **client:** commit auto-imports.d.ts ([4252266](https://github.com/cloud-pi-native/console/commit/42522663edf5151ce7e38c36d4eb5d2d13b24a48)) +* **client:** do not install pnpm twice ([43ff3a3](https://github.com/cloud-pi-native/console/commit/43ff3a3e6690c901d4905236b627016ee8b4f043)) +* **client:** ensure build fails if typing system is not sound ([97297b5](https://github.com/cloud-pi-native/console/commit/97297b58a80bee7167171d39b16404f077fef03b)) +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* **client:** fix types ([131b2b6](https://github.com/cloud-pi-native/console/commit/131b2b64926493d94ba1e07c3501e331d0fcac8c)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* convert cypress dashboard e2e tests to playwright ([2badd91](https://github.com/cloud-pi-native/console/commit/2badd91181d4960d40898cfa01fbcfd0b13667aa)) +* convert plugin manager to NestJS service ([2ae46e3](https://github.com/cloud-pi-native/console/commit/2ae46e38787b3521911786898bee881c23fdb08b)) +* disable tests from nest.js for now ([5137c10](https://github.com/cloud-pi-native/console/commit/5137c10ed9296c761c3d31877bf54a411bbee140)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **e2e:** avoid launching Playwright and Cypress E2E tests at the same time ([1f203a8](https://github.com/cloud-pi-native/console/commit/1f203a8d35d6b1e4e69076f289256c7192a356da)) +* **e2e:** migrate Admin Role tests from Cypress to Playwright ([2112398](https://github.com/cloud-pi-native/console/commit/2112398f85b1ab7a5e332b798f68d4f04894d51d)) +* **e2e:** migrate Duplicate Zone test from Cypress to Playwright ([748ec80](https://github.com/cloud-pi-native/console/commit/748ec80917af7ef34b1f7922a13703706e5e41cc)) +* **e2e:** migrate last Role tests from Cypress to Playwright ([176ea34](https://github.com/cloud-pi-native/console/commit/176ea3481fc5b282497ea82a7562d735b11b325f)) +* **e2e:** migrate last Zone tests from Cypress to Playwright ([dcff338](https://github.com/cloud-pi-native/console/commit/dcff3384e695e95c9d2ae2b08486ce4ca47fa769)) +* **e2e:** migrate Project archive test from Cypress to Playwright ([0567931](https://github.com/cloud-pi-native/console/commit/0567931723cccf2fc54cb5560c6dd00043211f58)) +* **e2e:** migrate repository tests from Cypress to Playwright ([8370b8b](https://github.com/cloud-pi-native/console/commit/8370b8b4adbe5c1b65bc5f45bd50c860beeba96c)) +* **e2e:** migrate some Zone tests from Cypress to Playwright ([425e8c9](https://github.com/cloud-pi-native/console/commit/425e8c9672b276507983b6b0685bcf475bdb7c6c)) +* **e2e:** migrate Token tests from Cypress to Playwright ([86cb815](https://github.com/cloud-pi-native/console/commit/86cb8158285be552a1d0e94ebd30600858b9193a)) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* fix all typescript import errors by massively creating NestJs services ([68b7cb7](https://github.com/cloud-pi-native/console/commit/68b7cb75407dbb8d630dd4a50e4832e97796008a)) +* fix any type in AppService ([627e1e9](https://github.com/cloud-pi-native/console/commit/627e1e9f0cac82bcaa9b3b3a6337c3f2b447e1e5)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) +* **gitlab-plugin:** interalize awaited import to avoid top-level-await errors ([6472c74](https://github.com/cloud-pi-native/console/commit/6472c742c9ec74f834da994db697fcfb7d385c7e)) +* **gitlab:** purge useless file ([2637156](https://github.com/cloud-pi-native/console/commit/263715641844bb7d766730a3de9240531c9f26e3)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **hotfix/revert-server-nestjs-offload:** Release v9.20.1 ([8ebeae7](https://github.com/cloud-pi-native/console/commit/8ebeae7717d0aeac93ce14102a137f4aac62c409)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* initialize nestjs dedicated app ([a533963](https://github.com/cloud-pi-native/console/commit/a53396329455c9c856274f3c419e2ff377e12dba)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) +* **main:** release 9.10.0 ([c87ad31](https://github.com/cloud-pi-native/console/commit/c87ad3114149c4af995be42e130d03efd2e301d9)) +* **main:** release 9.10.1 ([c4b4649](https://github.com/cloud-pi-native/console/commit/c4b464985cfafc7d60246621e9beb82760b73200)) +* **main:** release 9.10.2 ([20a233a](https://github.com/cloud-pi-native/console/commit/20a233af09a3ef918a7b1de36057374dd4817726)) +* **main:** release 9.11.0 ([c03e2ef](https://github.com/cloud-pi-native/console/commit/c03e2ef8c4e448784528ae7dee68edff36e48930)) +* **main:** release 9.11.1 ([7580384](https://github.com/cloud-pi-native/console/commit/7580384525c7cdddbd51e7b0b9b4154ca624b058)) +* **main:** release 9.11.2 ([c7de3a1](https://github.com/cloud-pi-native/console/commit/c7de3a15bd54730655e3a75523c29e9f313a18d9)) +* **main:** release 9.11.3 ([9d2df48](https://github.com/cloud-pi-native/console/commit/9d2df48e3588a819fe5a63467dee773c5e1963c3)) +* **main:** release 9.11.4 ([9c873e1](https://github.com/cloud-pi-native/console/commit/9c873e1b5d1ec3d59b86b6ae19b0fe055f3ed669)) +* **main:** release 9.11.5 ([5fc68de](https://github.com/cloud-pi-native/console/commit/5fc68de5cf2a22f33171f03132798df20701df1b)) +* **main:** release 9.8.1 ([bb94301](https://github.com/cloud-pi-native/console/commit/bb9430196e042847931719ec518d5b9685a67585)) +* **main:** release 9.9.0 ([aa755da](https://github.com/cloud-pi-native/console/commit/aa755dac97ffd563bb0684fe669268b95c6d1362)) +* **main:** Release v9.12.0 ([3a14b03](https://github.com/cloud-pi-native/console/commit/3a14b03a7284ee8ee8a5e9d54d7ba842832c4017)) +* **main:** Release v9.12.0 ([4f6e1a8](https://github.com/cloud-pi-native/console/commit/4f6e1a8d8b630335c89a12b4daf4941e1dcdc32a)) +* **main:** Release v9.13.0 ([4daaa1d](https://github.com/cloud-pi-native/console/commit/4daaa1dce01276f83c6870ffe1c13af096547621)) +* **main:** Release v9.13.0 ([613ba30](https://github.com/cloud-pi-native/console/commit/613ba30b9f7f41e6db64fa8957709bca96e53847)) +* **main:** Release v9.13.1 ([0b39ff1](https://github.com/cloud-pi-native/console/commit/0b39ff1747d65fae9d4d4c6409421a30b1c57fc4)) +* **main:** Release v9.13.1 ([96ed6a3](https://github.com/cloud-pi-native/console/commit/96ed6a318a74978a9c2b8713d65100f1c0ca2f3d)) +* **main:** Release v9.13.2 ([400e9a7](https://github.com/cloud-pi-native/console/commit/400e9a71b28879c2bcd382ed4a43f55757d7aa2e)) +* **main:** Release v9.13.2 ([e2d09e3](https://github.com/cloud-pi-native/console/commit/e2d09e3f476fc958967089bc0ac9c5054b7eb5ac)) +* **main:** Release v9.14.0 ([5c62e19](https://github.com/cloud-pi-native/console/commit/5c62e191e0ab426dff4b5c390817a15345e3bdb0)) +* **main:** Release v9.14.1 ([8bccc81](https://github.com/cloud-pi-native/console/commit/8bccc81517f8cf39ddc5e1be972d4efa701b5e41)) +* **main:** Release v9.15.0 ([7b50776](https://github.com/cloud-pi-native/console/commit/7b50776d35fa73d1a28f6fb21010ffad85bafaf1)) +* **main:** Release v9.16.0 ([68abf2e](https://github.com/cloud-pi-native/console/commit/68abf2e5afd1fac52fad95c1319a4b50e8ac6275)) +* **main:** Release v9.16.1 ([3ba4c0b](https://github.com/cloud-pi-native/console/commit/3ba4c0bf08d899b614875cac99af7345a61998c8)) +* **main:** Release v9.17.0 ([a4061a6](https://github.com/cloud-pi-native/console/commit/a4061a61137c40c5b496e6649645b0b017723407)) +* **main:** Release v9.17.1 ([f8f7952](https://github.com/cloud-pi-native/console/commit/f8f795208ce32b5539e4d06c84f8579d1ae7ad2d)) +* **main:** Release v9.18.0 ([775bd1e](https://github.com/cloud-pi-native/console/commit/775bd1e82a598278ae2317bb775054b9e0627a36)) +* **main:** Release v9.18.1 ([261d9a3](https://github.com/cloud-pi-native/console/commit/261d9a367b71e8598c7956e4b76b612550244ffd)) +* **main:** Release v9.18.2 ([0a75369](https://github.com/cloud-pi-native/console/commit/0a753695320428332119401d6de53dc576538d72)) +* **main:** Release v9.18.3 ([f8cadc4](https://github.com/cloud-pi-native/console/commit/f8cadc47d40393c8fd040b0740957cff39ae6cb4)) +* **main:** Release v9.18.4 ([71dc32b](https://github.com/cloud-pi-native/console/commit/71dc32bf1f3d16a87a39f5d6378f3b7876bf2e42)) +* **main:** Release v9.19.0 ([c7d0f41](https://github.com/cloud-pi-native/console/commit/c7d0f41d7ecfd5d4645872de743d83d90b97bdab)) +* **main:** Release v9.20.0 ([9ee41b3](https://github.com/cloud-pi-native/console/commit/9ee41b36597cdd79c632ae3b2d3eb5b032781819)) +* **main:** Release v9.20.2 ([4075691](https://github.com/cloud-pi-native/console/commit/4075691de972a9d181dbed02a9aa2d801dc026f2)) +* **main:** Release v9.21.0 ([27e7f3a](https://github.com/cloud-pi-native/console/commit/27e7f3acff20927d8753d6bc67cebc4ed686f5ab)) +* move pnpm config to pnpm-workspace.yaml to avoid future issues with .npmrc ([00974c9](https://github.com/cloud-pi-native/console/commit/00974c9fcb98d223923c5062ea854ffd5ddeeb95)) +* move the entirety of apps/server in server-nestjs ([8437837](https://github.com/cloud-pi-native/console/commit/8437837fadca7dd79ec76cd3f004a83881891c29)) +* **nest-js:** add basic config and logger modules based on existing implementation ([429e4ba](https://github.com/cloud-pi-native/console/commit/429e4ba046f076e65320c3b4714aea878c99b754)) +* **nest-js:** initialize a bunch of module/services to move old-server code ([935f1d7](https://github.com/cloud-pi-native/console/commit/935f1d7fdef9ea68a404f01be07fa1b62b670dfd)) +* **nest-js:** update old-server code from main ([09f3729](https://github.com/cloud-pi-native/console/commit/09f3729078482abce704a2e885f6230134071f57)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** don't bother with webkit for now ([7d80b33](https://github.com/cloud-pi-native/console/commit/7d80b33f6ed5d364b526475e312679330825bd3c)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **playwright:** migrate E2E Project logs tests from Cypress ([089f0bc](https://github.com/cloud-pi-native/console/commit/089f0bcb6f443bc43c2936d039088b76bab10a01)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* **plugins:** update formatting ([86f8bad](https://github.com/cloud-pi-native/console/commit/86f8badfc617809d6f10e9c869b9fbce201de953)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* remove .js extensions from imports ([c8774f3](https://github.com/cloud-pi-native/console/commit/c8774f323c45ac85269d5c47eecf0813c632f879)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) +* remove configuration for tools that are not explicitely supported on the project ([e1be3d5](https://github.com/cloud-pi-native/console/commit/e1be3d5903a8a85d651785b6ef747040b9f29a41)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* rename nest.js base AppModule to MainModule for clarification with our own AppService ([8a20228](https://github.com/cloud-pi-native/console/commit/8a2022815fe363aa5626b0b4fcc8ab832dca12de)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) +* **server-nestjs:** add code to DatabaseService and thus finalize ApplicationInitializationService ([b12c207](https://github.com/cloud-pi-native/console/commit/b12c2079aaa944282ff5365504baf0095bd14e84)) +* **server-nestjs:** add code to PluginManagementService ([2d3133d](https://github.com/cloud-pi-native/console/commit/2d3133d5203fc94e71d3e4a2dfb5d4fc4e6bd784)) +* **server-nestjs:** add database initialization code in DatabaseInitializationService ([73c27d9](https://github.com/cloud-pi-native/console/commit/73c27d93ab779155a7e2c1c353909f3e5338b818)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) +* **server-nestjs:** convert all router services to NestJS classes ([2dd2c3e](https://github.com/cloud-pi-native/console/commit/2dd2c3ea697c6b508e89e107a689f79a8b39a6a1)) +* **server-nestjs:** delete unneeded code ([bbe07b2](https://github.com/cloud-pi-native/console/commit/bbe07b27bfd3d6c198e17a642cc441c919392d85)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** fix a fastify type issue ([868f1bc](https://github.com/cloud-pi-native/console/commit/868f1bc813f880963d13bccffd7814a54166f17a)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* **server-nestjs:** fix pnpm format command ([133254c](https://github.com/cloud-pi-native/console/commit/133254c1d285f45bb0385fda11d2f5b700f4ab20)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* **server-nestjs:** initialize core module and its AppService ([91d9596](https://github.com/cloud-pi-native/console/commit/91d9596765ced2a941f8e31c514e2b6c3969ef95)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove now useless old-server directory ([47d7536](https://github.com/cloud-pi-native/console/commit/47d7536a66e152b64fd69b0c57f72ab23a27d0e0)) +* **server-nestjs:** remove Router module ([8104360](https://github.com/cloud-pi-native/console/commit/8104360de9236f0f2c61dba2273b6d8cb368a356)) +* **server-nestjs:** remove test scripts (we'll migrate to vitest soon) ([afc4e91](https://github.com/cloud-pi-native/console/commit/afc4e912e88084a073b00e5213b2eed182b6c78b)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rename vite file to avoid compilation issues for now ([bd15699](https://github.com/cloud-pi-native/console/commit/bd15699e39d11ddbb6304366a41d67b8564c425a)) +* **server-nestjs:** retrieve prisma directory from old-server as-is ([a89fd36](https://github.com/cloud-pi-native/console/commit/a89fd368a1bc1c4d3eb1e535b0d4d776322e622f)) +* **server-nestjs:** rework configuration service to properly instanciate logger service ([64a113d](https://github.com/cloud-pi-native/console/commit/64a113d6591739557d459562acc57456fc3f74b9)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **server-nestjs:** update README with a plan for the future ([fc336d4](https://github.com/cloud-pi-native/console/commit/fc336d4b6793898a9190f585b39a9ba63c5e528b)) +* **server-nestjs:** update README with the modularization target ([d98bdfa](https://github.com/cloud-pi-native/console/commit/d98bdfa428290e7a5358a7caf33137a13815239c)) +* **server-nestjs:** use old-server eslint config ([d0f80ee](https://github.com/cloud-pi-native/console/commit/d0f80ee7bf88b7d26407b78a9792af699fb463e8)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* start integration old-server into nestjs ([d83b1cd](https://github.com/cloud-pi-native/console/commit/d83b1cdf47ba140a50a83da02c1c6a8fb024c9b9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) +* update browserlist/caniuse-lite ([23617f9](https://github.com/cloud-pi-native/console/commit/23617f9e051caf7e600360a92bae76ee6c8af575)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* update old-server import aliases ([d89735e](https://github.com/cloud-pi-native/console/commit/d89735e299b541efa4967a835c3cd0ece7d1ffbb)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) +* **vault:** explicit that VaultApi base class is abstract and must not be exported ([3673c4c](https://github.com/cloud-pi-native/console/commit/3673c4cf96bfc40bfac114fe7b67dee425b372da)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Docs + +* :memo: add notes regarding services communication ([ca4a057](https://github.com/cloud-pi-native/console/commit/ca4a0578d3e815f93aa1e57cbb880601354d600d)) +* :memo: Ajout du lien vers le MODOP de gestion des versions de dépendances ([7151293](https://github.com/cloud-pi-native/console/commit/715129312a6150f887298d1dc493ef7876c51e28)) +* :memo: update release documentation ([cea92a9](https://github.com/cloud-pi-native/console/commit/cea92a9aa651af68499ae318e4a7c27e037c706c)) +* :memo: use Nexus internal url for CI jobs ([2193ec3](https://github.com/cloud-pi-native/console/commit/2193ec3041fb0d78cc7a1fbe841fd451d2a7b095)) +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + + +### Refactoring + +* :bookmark: add tracing information on Argo apps ([340299b](https://github.com/cloud-pi-native/console/commit/340299b58e702a4258c5d767e3fdf4fc692f7d73)) +* :fire: remove AppRole enabled check to simplify the code ([95c7159](https://github.com/cloud-pi-native/console/commit/95c71595d7aac32fa211d18880fd5e7ed3bf4c53)) +* add PluginResultBuilder for improved result management in project upsert ([80d5bc4](https://github.com/cloud-pi-native/console/commit/80d5bc498422c1eb79134c54c9facc9c7a15e279)) +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) +* split Gitlab classes in separate files ([0d673a5](https://github.com/cloud-pi-native/console/commit/0d673a5d282e9dc82a816d16ac18c8452f740c1f)) +* split Vault classes in separate files ([15bc0ce](https://github.com/cloud-pi-native/console/commit/15bc0ce32dc74e8719c7a202a2e3e406aee50762)) + + +### Reverted commits + +* "feat(server-nestjs): add autoSync and suspended flags to plugin services" ([2116663](https://github.com/cloud-pi-native/console/commit/2116663621cf1d522ca73a0e76ea6b9ed4f9ff6d)) +* "fix: playwright test expect mock" ([d2a746c](https://github.com/cloud-pi-native/console/commit/d2a746c237af4d1cbfe38e8d090599bf26176525)) +* "refactor(server-nestjs): offload project route to new implementation" ([8a964bc](https://github.com/cloud-pi-native/console/commit/8a964bc5d2d223e9c2f2d975cc703a18be81fc45)) +* **client:** switch environments back from API v2 ([933977c](https://github.com/cloud-pi-native/console/commit/933977c8b3434d3db97c0f4300bb2d4bcc393588)) + +## [9.21.0](https://github.com/cloud-pi-native/console/compare/v9.20.2...v9.21.0) (2026-07-21) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** restrict deployment visibility to console admins ([def61e7](https://github.com/cloud-pi-native/console/commit/def61e76b3a867445c5ddb1c08e0f3af59b188ae)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([52c8173](https://github.com/cloud-pi-native/console/commit/52c8173f5610e83836b661d7472c0fe3ff22f70f)) +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* :construction: make all new cluster external by default ([dcd203f](https://github.com/cloud-pi-native/console/commit/dcd203f3569d93a74e0d627f6cf3eb898d6b338d)) +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* add old-server new Nest.js services to CpinModule ([a17c109](https://github.com/cloud-pi-native/console/commit/a17c109616270115af701366ae8014e05cb41e7c)) +* add server-nestjs README to explain what we are doing ([57a4ca5](https://github.com/cloud-pi-native/console/commit/57a4ca51086b7c7af44a1df83cd272b82eb9a9f8)) +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* apply prettier on old-server ([6a781ac](https://github.com/cloud-pi-native/console/commit/6a781ac80c756924c53e28815064197b63508ff4)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* bump turbo to v2.7.1 ([d955e52](https://github.com/cloud-pi-native/console/commit/d955e52e65ed829053fdb120b217cb2744bd02c2)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** trigger Playwright E2E tests before Cypress E2E tests ([2262f37](https://github.com/cloud-pi-native/console/commit/2262f3717101833217d5963580e07fb4f69e9847)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* **client:** commit auto-imports.d.ts ([4252266](https://github.com/cloud-pi-native/console/commit/42522663edf5151ce7e38c36d4eb5d2d13b24a48)) +* **client:** do not install pnpm twice ([43ff3a3](https://github.com/cloud-pi-native/console/commit/43ff3a3e6690c901d4905236b627016ee8b4f043)) +* **client:** ensure build fails if typing system is not sound ([97297b5](https://github.com/cloud-pi-native/console/commit/97297b58a80bee7167171d39b16404f077fef03b)) +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* **client:** fix types ([131b2b6](https://github.com/cloud-pi-native/console/commit/131b2b64926493d94ba1e07c3501e331d0fcac8c)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* convert cypress dashboard e2e tests to playwright ([2badd91](https://github.com/cloud-pi-native/console/commit/2badd91181d4960d40898cfa01fbcfd0b13667aa)) +* convert plugin manager to NestJS service ([2ae46e3](https://github.com/cloud-pi-native/console/commit/2ae46e38787b3521911786898bee881c23fdb08b)) +* disable tests from nest.js for now ([5137c10](https://github.com/cloud-pi-native/console/commit/5137c10ed9296c761c3d31877bf54a411bbee140)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **e2e:** avoid launching Playwright and Cypress E2E tests at the same time ([1f203a8](https://github.com/cloud-pi-native/console/commit/1f203a8d35d6b1e4e69076f289256c7192a356da)) +* **e2e:** migrate Admin Role tests from Cypress to Playwright ([2112398](https://github.com/cloud-pi-native/console/commit/2112398f85b1ab7a5e332b798f68d4f04894d51d)) +* **e2e:** migrate Duplicate Zone test from Cypress to Playwright ([748ec80](https://github.com/cloud-pi-native/console/commit/748ec80917af7ef34b1f7922a13703706e5e41cc)) +* **e2e:** migrate last Role tests from Cypress to Playwright ([176ea34](https://github.com/cloud-pi-native/console/commit/176ea3481fc5b282497ea82a7562d735b11b325f)) +* **e2e:** migrate last Zone tests from Cypress to Playwright ([dcff338](https://github.com/cloud-pi-native/console/commit/dcff3384e695e95c9d2ae2b08486ce4ca47fa769)) +* **e2e:** migrate profile tests from Cypress to Playwright ([d7201b6](https://github.com/cloud-pi-native/console/commit/d7201b6d253d342f6a1656d179d0a9d322745c33)) +* **e2e:** migrate Project archive test from Cypress to Playwright ([0567931](https://github.com/cloud-pi-native/console/commit/0567931723cccf2fc54cb5560c6dd00043211f58)) +* **e2e:** migrate repository tests from Cypress to Playwright ([8370b8b](https://github.com/cloud-pi-native/console/commit/8370b8b4adbe5c1b65bc5f45bd50c860beeba96c)) +* **e2e:** migrate some Zone tests from Cypress to Playwright ([425e8c9](https://github.com/cloud-pi-native/console/commit/425e8c9672b276507983b6b0685bcf475bdb7c6c)) +* **e2e:** migrate Token tests from Cypress to Playwright ([86cb815](https://github.com/cloud-pi-native/console/commit/86cb8158285be552a1d0e94ebd30600858b9193a)) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* fix all typescript import errors by massively creating NestJs services ([68b7cb7](https://github.com/cloud-pi-native/console/commit/68b7cb75407dbb8d630dd4a50e4832e97796008a)) +* fix any type in AppService ([627e1e9](https://github.com/cloud-pi-native/console/commit/627e1e9f0cac82bcaa9b3b3a6337c3f2b447e1e5)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) +* **gitlab-plugin:** interalize awaited import to avoid top-level-await errors ([6472c74](https://github.com/cloud-pi-native/console/commit/6472c742c9ec74f834da994db697fcfb7d385c7e)) +* **gitlab:** purge useless file ([2637156](https://github.com/cloud-pi-native/console/commit/263715641844bb7d766730a3de9240531c9f26e3)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **hotfix/revert-server-nestjs-offload:** Release v9.20.1 ([8ebeae7](https://github.com/cloud-pi-native/console/commit/8ebeae7717d0aeac93ce14102a137f4aac62c409)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* initialize nestjs dedicated app ([a533963](https://github.com/cloud-pi-native/console/commit/a53396329455c9c856274f3c419e2ff377e12dba)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) +* **main:** release 9.10.0 ([c87ad31](https://github.com/cloud-pi-native/console/commit/c87ad3114149c4af995be42e130d03efd2e301d9)) +* **main:** release 9.10.1 ([c4b4649](https://github.com/cloud-pi-native/console/commit/c4b464985cfafc7d60246621e9beb82760b73200)) +* **main:** release 9.10.2 ([20a233a](https://github.com/cloud-pi-native/console/commit/20a233af09a3ef918a7b1de36057374dd4817726)) +* **main:** release 9.11.0 ([c03e2ef](https://github.com/cloud-pi-native/console/commit/c03e2ef8c4e448784528ae7dee68edff36e48930)) +* **main:** release 9.11.1 ([7580384](https://github.com/cloud-pi-native/console/commit/7580384525c7cdddbd51e7b0b9b4154ca624b058)) +* **main:** release 9.11.2 ([c7de3a1](https://github.com/cloud-pi-native/console/commit/c7de3a15bd54730655e3a75523c29e9f313a18d9)) +* **main:** release 9.11.3 ([9d2df48](https://github.com/cloud-pi-native/console/commit/9d2df48e3588a819fe5a63467dee773c5e1963c3)) +* **main:** release 9.11.4 ([9c873e1](https://github.com/cloud-pi-native/console/commit/9c873e1b5d1ec3d59b86b6ae19b0fe055f3ed669)) +* **main:** release 9.11.5 ([5fc68de](https://github.com/cloud-pi-native/console/commit/5fc68de5cf2a22f33171f03132798df20701df1b)) +* **main:** release 9.8.1 ([bb94301](https://github.com/cloud-pi-native/console/commit/bb9430196e042847931719ec518d5b9685a67585)) +* **main:** release 9.9.0 ([aa755da](https://github.com/cloud-pi-native/console/commit/aa755dac97ffd563bb0684fe669268b95c6d1362)) +* **main:** Release v9.12.0 ([3a14b03](https://github.com/cloud-pi-native/console/commit/3a14b03a7284ee8ee8a5e9d54d7ba842832c4017)) +* **main:** Release v9.12.0 ([4f6e1a8](https://github.com/cloud-pi-native/console/commit/4f6e1a8d8b630335c89a12b4daf4941e1dcdc32a)) +* **main:** Release v9.13.0 ([4daaa1d](https://github.com/cloud-pi-native/console/commit/4daaa1dce01276f83c6870ffe1c13af096547621)) +* **main:** Release v9.13.0 ([613ba30](https://github.com/cloud-pi-native/console/commit/613ba30b9f7f41e6db64fa8957709bca96e53847)) +* **main:** Release v9.13.1 ([0b39ff1](https://github.com/cloud-pi-native/console/commit/0b39ff1747d65fae9d4d4c6409421a30b1c57fc4)) +* **main:** Release v9.13.1 ([96ed6a3](https://github.com/cloud-pi-native/console/commit/96ed6a318a74978a9c2b8713d65100f1c0ca2f3d)) +* **main:** Release v9.13.2 ([400e9a7](https://github.com/cloud-pi-native/console/commit/400e9a71b28879c2bcd382ed4a43f55757d7aa2e)) +* **main:** Release v9.13.2 ([e2d09e3](https://github.com/cloud-pi-native/console/commit/e2d09e3f476fc958967089bc0ac9c5054b7eb5ac)) +* **main:** Release v9.14.0 ([5c62e19](https://github.com/cloud-pi-native/console/commit/5c62e191e0ab426dff4b5c390817a15345e3bdb0)) +* **main:** Release v9.14.1 ([8bccc81](https://github.com/cloud-pi-native/console/commit/8bccc81517f8cf39ddc5e1be972d4efa701b5e41)) +* **main:** Release v9.15.0 ([7b50776](https://github.com/cloud-pi-native/console/commit/7b50776d35fa73d1a28f6fb21010ffad85bafaf1)) +* **main:** Release v9.16.0 ([68abf2e](https://github.com/cloud-pi-native/console/commit/68abf2e5afd1fac52fad95c1319a4b50e8ac6275)) +* **main:** Release v9.16.1 ([3ba4c0b](https://github.com/cloud-pi-native/console/commit/3ba4c0bf08d899b614875cac99af7345a61998c8)) +* **main:** Release v9.17.0 ([a4061a6](https://github.com/cloud-pi-native/console/commit/a4061a61137c40c5b496e6649645b0b017723407)) +* **main:** Release v9.17.1 ([f8f7952](https://github.com/cloud-pi-native/console/commit/f8f795208ce32b5539e4d06c84f8579d1ae7ad2d)) +* **main:** Release v9.18.0 ([775bd1e](https://github.com/cloud-pi-native/console/commit/775bd1e82a598278ae2317bb775054b9e0627a36)) +* **main:** Release v9.18.1 ([261d9a3](https://github.com/cloud-pi-native/console/commit/261d9a367b71e8598c7956e4b76b612550244ffd)) +* **main:** Release v9.18.2 ([0a75369](https://github.com/cloud-pi-native/console/commit/0a753695320428332119401d6de53dc576538d72)) +* **main:** Release v9.18.3 ([f8cadc4](https://github.com/cloud-pi-native/console/commit/f8cadc47d40393c8fd040b0740957cff39ae6cb4)) +* **main:** Release v9.18.4 ([71dc32b](https://github.com/cloud-pi-native/console/commit/71dc32bf1f3d16a87a39f5d6378f3b7876bf2e42)) +* **main:** Release v9.19.0 ([c7d0f41](https://github.com/cloud-pi-native/console/commit/c7d0f41d7ecfd5d4645872de743d83d90b97bdab)) +* **main:** Release v9.20.0 ([9ee41b3](https://github.com/cloud-pi-native/console/commit/9ee41b36597cdd79c632ae3b2d3eb5b032781819)) +* **main:** Release v9.20.2 ([4075691](https://github.com/cloud-pi-native/console/commit/4075691de972a9d181dbed02a9aa2d801dc026f2)) +* move pnpm config to pnpm-workspace.yaml to avoid future issues with .npmrc ([00974c9](https://github.com/cloud-pi-native/console/commit/00974c9fcb98d223923c5062ea854ffd5ddeeb95)) +* move the entirety of apps/server in server-nestjs ([8437837](https://github.com/cloud-pi-native/console/commit/8437837fadca7dd79ec76cd3f004a83881891c29)) +* **nest-js:** add basic config and logger modules based on existing implementation ([429e4ba](https://github.com/cloud-pi-native/console/commit/429e4ba046f076e65320c3b4714aea878c99b754)) +* **nest-js:** initialize a bunch of module/services to move old-server code ([935f1d7](https://github.com/cloud-pi-native/console/commit/935f1d7fdef9ea68a404f01be07fa1b62b670dfd)) +* **nest-js:** update old-server code from main ([09f3729](https://github.com/cloud-pi-native/console/commit/09f3729078482abce704a2e885f6230134071f57)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** don't bother with webkit for now ([7d80b33](https://github.com/cloud-pi-native/console/commit/7d80b33f6ed5d364b526475e312679330825bd3c)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **playwright:** migrate E2E Project logs tests from Cypress ([089f0bc](https://github.com/cloud-pi-native/console/commit/089f0bcb6f443bc43c2936d039088b76bab10a01)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* **plugins:** update formatting ([86f8bad](https://github.com/cloud-pi-native/console/commit/86f8badfc617809d6f10e9c869b9fbce201de953)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* remove .js extensions from imports ([c8774f3](https://github.com/cloud-pi-native/console/commit/c8774f323c45ac85269d5c47eecf0813c632f879)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) +* remove configuration for tools that are not explicitely supported on the project ([e1be3d5](https://github.com/cloud-pi-native/console/commit/e1be3d5903a8a85d651785b6ef747040b9f29a41)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* rename nest.js base AppModule to MainModule for clarification with our own AppService ([8a20228](https://github.com/cloud-pi-native/console/commit/8a2022815fe363aa5626b0b4fcc8ab832dca12de)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) +* **server-nestjs:** add code to DatabaseService and thus finalize ApplicationInitializationService ([b12c207](https://github.com/cloud-pi-native/console/commit/b12c2079aaa944282ff5365504baf0095bd14e84)) +* **server-nestjs:** add code to PluginManagementService ([2d3133d](https://github.com/cloud-pi-native/console/commit/2d3133d5203fc94e71d3e4a2dfb5d4fc4e6bd784)) +* **server-nestjs:** add database initialization code in DatabaseInitializationService ([73c27d9](https://github.com/cloud-pi-native/console/commit/73c27d93ab779155a7e2c1c353909f3e5338b818)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) +* **server-nestjs:** convert all router services to NestJS classes ([2dd2c3e](https://github.com/cloud-pi-native/console/commit/2dd2c3ea697c6b508e89e107a689f79a8b39a6a1)) +* **server-nestjs:** delete unneeded code ([bbe07b2](https://github.com/cloud-pi-native/console/commit/bbe07b27bfd3d6c198e17a642cc441c919392d85)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** fix a fastify type issue ([868f1bc](https://github.com/cloud-pi-native/console/commit/868f1bc813f880963d13bccffd7814a54166f17a)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* **server-nestjs:** fix pnpm format command ([133254c](https://github.com/cloud-pi-native/console/commit/133254c1d285f45bb0385fda11d2f5b700f4ab20)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* **server-nestjs:** initialize core module and its AppService ([91d9596](https://github.com/cloud-pi-native/console/commit/91d9596765ced2a941f8e31c514e2b6c3969ef95)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove now useless old-server directory ([47d7536](https://github.com/cloud-pi-native/console/commit/47d7536a66e152b64fd69b0c57f72ab23a27d0e0)) +* **server-nestjs:** remove Router module ([8104360](https://github.com/cloud-pi-native/console/commit/8104360de9236f0f2c61dba2273b6d8cb368a356)) +* **server-nestjs:** remove test scripts (we'll migrate to vitest soon) ([afc4e91](https://github.com/cloud-pi-native/console/commit/afc4e912e88084a073b00e5213b2eed182b6c78b)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rename vite file to avoid compilation issues for now ([bd15699](https://github.com/cloud-pi-native/console/commit/bd15699e39d11ddbb6304366a41d67b8564c425a)) +* **server-nestjs:** retrieve prisma directory from old-server as-is ([a89fd36](https://github.com/cloud-pi-native/console/commit/a89fd368a1bc1c4d3eb1e535b0d4d776322e622f)) +* **server-nestjs:** rework configuration service to properly instanciate logger service ([64a113d](https://github.com/cloud-pi-native/console/commit/64a113d6591739557d459562acc57456fc3f74b9)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **server-nestjs:** update README with a plan for the future ([fc336d4](https://github.com/cloud-pi-native/console/commit/fc336d4b6793898a9190f585b39a9ba63c5e528b)) +* **server-nestjs:** update README with the modularization target ([d98bdfa](https://github.com/cloud-pi-native/console/commit/d98bdfa428290e7a5358a7caf33137a13815239c)) +* **server-nestjs:** use old-server eslint config ([d0f80ee](https://github.com/cloud-pi-native/console/commit/d0f80ee7bf88b7d26407b78a9792af699fb463e8)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* start integration old-server into nestjs ([d83b1cd](https://github.com/cloud-pi-native/console/commit/d83b1cdf47ba140a50a83da02c1c6a8fb024c9b9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) +* update browserlist/caniuse-lite ([23617f9](https://github.com/cloud-pi-native/console/commit/23617f9e051caf7e600360a92bae76ee6c8af575)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* update old-server import aliases ([d89735e](https://github.com/cloud-pi-native/console/commit/d89735e299b541efa4967a835c3cd0ece7d1ffbb)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) +* **vault:** explicit that VaultApi base class is abstract and must not be exported ([3673c4c](https://github.com/cloud-pi-native/console/commit/3673c4cf96bfc40bfac114fe7b67dee425b372da)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Docs + +* :memo: add notes regarding services communication ([ca4a057](https://github.com/cloud-pi-native/console/commit/ca4a0578d3e815f93aa1e57cbb880601354d600d)) +* :memo: Ajout du lien vers le MODOP de gestion des versions de dépendances ([7151293](https://github.com/cloud-pi-native/console/commit/715129312a6150f887298d1dc493ef7876c51e28)) +* :memo: update release documentation ([cea92a9](https://github.com/cloud-pi-native/console/commit/cea92a9aa651af68499ae318e4a7c27e037c706c)) +* :memo: use Nexus internal url for CI jobs ([2193ec3](https://github.com/cloud-pi-native/console/commit/2193ec3041fb0d78cc7a1fbe841fd451d2a7b095)) +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + + +### Refactoring + +* :bookmark: add tracing information on Argo apps ([340299b](https://github.com/cloud-pi-native/console/commit/340299b58e702a4258c5d767e3fdf4fc692f7d73)) +* :fire: remove AppRole enabled check to simplify the code ([95c7159](https://github.com/cloud-pi-native/console/commit/95c71595d7aac32fa211d18880fd5e7ed3bf4c53)) +* add PluginResultBuilder for improved result management in project upsert ([80d5bc4](https://github.com/cloud-pi-native/console/commit/80d5bc498422c1eb79134c54c9facc9c7a15e279)) +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) +* split Gitlab classes in separate files ([0d673a5](https://github.com/cloud-pi-native/console/commit/0d673a5d282e9dc82a816d16ac18c8452f740c1f)) +* split Vault classes in separate files ([15bc0ce](https://github.com/cloud-pi-native/console/commit/15bc0ce32dc74e8719c7a202a2e3e406aee50762)) + + +### Reverted commits + +* "feat(server-nestjs): add autoSync and suspended flags to plugin services" ([2116663](https://github.com/cloud-pi-native/console/commit/2116663621cf1d522ca73a0e76ea6b9ed4f9ff6d)) +* "fix: playwright test expect mock" ([d2a746c](https://github.com/cloud-pi-native/console/commit/d2a746c237af4d1cbfe38e8d090599bf26176525)) +* "refactor(server-nestjs): offload project route to new implementation" ([8a964bc](https://github.com/cloud-pi-native/console/commit/8a964bc5d2d223e9c2f2d975cc703a18be81fc45)) +* **client:** switch environments back from API v2 ([933977c](https://github.com/cloud-pi-native/console/commit/933977c8b3434d3db97c0f4300bb2d4bcc393588)) + +## [9.20.2](https://github.com/cloud-pi-native/console/compare/v9.20.1...v9.20.2) (2026-07-21) + + +### Bug Fixes + +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) + +## [9.20.1](https://github.com/cloud-pi-native/console/compare/v9.20.0...v9.20.1) (2026-07-17) + + +### Bug Fixes + +* **client:** restrict deployment visibility to console admins ([d836948](https://github.com/cloud-pi-native/console/commit/d836948e2c6689445d473c562a2be189873c75af)) + + +### Reverted commits + +* **client:** switch environments back from API v2 ([9c1245a](https://github.com/cloud-pi-native/console/commit/9c1245a56f650d4819f27f926457810f33b67753)) +* feat(server-nestjs): add autoSync and suspended flags to plugin services ([a48697d](https://github.com/cloud-pi-native/console/commit/a48697d46fad4fbbf54567988d0e99433cb5c4cc)) +* fix: playwright test expect mock ([828db42](https://github.com/cloud-pi-native/console/commit/828db421f6ce4f7eda293a744fda9f22882f7a8c)) +* refactor(server-nestjs): offload project route to new implementation ([99a7f5d](https://github.com/cloud-pi-native/console/commit/99a7f5d29becefcdad24331004263756a6950774)) + +## [9.20.0](https://github.com/cloud-pi-native/console/compare/v9.19.0...v9.20.0) (2026-07-17) + +### Features + +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) + + +### Bug Fixes + +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) + + +### Docs + +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) + + +### Refactoring + +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) + +## [9.19.0](https://github.com/cloud-pi-native/console/compare/v9.18.4...v9.19.0) (2026-06-19) + + +### Features + +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) + + +### Bug Fixes + +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) + + +### Miscellaneous Chores + +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) + + +### Refactoring + +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) + +## [9.18.4](https://github.com/cloud-pi-native/console/compare/v9.18.3...v9.18.4) (2026-06-10) + + +### Bug Fixes + +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) + +## [9.18.3](https://github.com/cloud-pi-native/console/compare/v9.18.2...v9.18.3) (2026-06-01) + + +### Bug Fixes + +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) + + +### Docs + +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) + +## [9.18.2](https://github.com/cloud-pi-native/console/compare/v9.18.1...v9.18.2) (2026-05-22) + + +### Bug Fixes + +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) + + +### Miscellaneous Chores + +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) + + +### Docs + +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) + + +### Refactoring + +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) + +## [9.18.1](https://github.com/cloud-pi-native/console/compare/v9.18.0...v9.18.1) (2026-05-20) + + +### Bug Fixes + +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) + +## [9.18.0](https://github.com/cloud-pi-native/console/compare/v9.17.1...v9.18.0) (2026-05-18) + + +### Features + +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) + + +### Bug Fixes + +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) + + +### Miscellaneous Chores + +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) + +## [9.17.1](https://github.com/cloud-pi-native/console/compare/v9.17.0...v9.17.1) (2026-05-05) + + +### Bug Fixes + +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) + +## [9.17.0](https://github.com/cloud-pi-native/console/compare/v9.16.1...v9.17.0) (2026-04-30) + + +### Features + +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) + + +### Bug Fixes + +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) + + +### Miscellaneous Chores + +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) + + +### Docs + +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) + + +### Refactoring + +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) + +## [9.16.1](https://github.com/cloud-pi-native/console/compare/v9.16.0...v9.16.1) (2026-04-13) + + +### Bug Fixes + +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) + + +### Miscellaneous Chores + +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) + +## [9.16.0](https://github.com/cloud-pi-native/console/compare/v9.15.0...v9.16.0) (2026-04-10) + + +### Features + +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) + + +### Bug Fixes + +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) + + +### Miscellaneous Chores + +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) + +## [9.15.0](https://github.com/cloud-pi-native/console/compare/v9.14.1...v9.15.0) (2026-04-09) + + +### Features + +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) + + +### Bug Fixes + +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) + + +### Miscellaneous Chores + +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Refactoring + +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) + +## [9.14.1](https://github.com/cloud-pi-native/console/compare/v9.14.0...v9.14.1) (2026-03-25) + + +### Bug Fixes + +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) + + +### Miscellaneous Chores + +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) + + +### Docs + +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + +## [9.14.0](https://github.com/cloud-pi-native/console/compare/v9.13.2...v9.14.0) (2026-03-23) + + +### Features + +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) + + +### Miscellaneous Chores + +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) + + +### Docs + +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) + +## [9.13.2](https://github.com/cloud-pi-native/console/compare/v9.13.1...v9.13.2) (2026-02-24) + + +### Bug Fixes + +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) + + +### Miscellaneous Chores + +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) + + +### Docs + +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) + +## [9.13.1](https://github.com/cloud-pi-native/console/compare/v9.13.0...v9.13.1) (2026-02-17) + + +### Miscellaneous Chores + +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) + +## [9.13.0](https://github.com/cloud-pi-native/console/compare/v9.12.0...v9.13.0) (2026-02-16) + + +### Features + +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) + + +### Bug Fixes + +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) + + +### Miscellaneous Chores + +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) + + +### Docs + +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) + +## [9.12.0](https://github.com/cloud-pi-native/console/compare/v9.11.5...v9.12.0) (2026-02-03) + + +### Features + +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) + + +### Miscellaneous Chores + +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) + + +### Docs + +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) + +## [9.11.5](https://github.com/cloud-pi-native/console/compare/v9.11.4...v9.11.5) (2026-01-26) + + +### Bug Fixes + +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) + +## [9.11.4](https://github.com/cloud-pi-native/console/compare/v9.11.3...v9.11.4) (2026-01-20) + + +### Bug Fixes + +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) + +## [9.11.3](https://github.com/cloud-pi-native/console/compare/v9.11.2...v9.11.3) (2026-01-13) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) + +## [9.11.2](https://github.com/cloud-pi-native/console/compare/v9.11.1...v9.11.2) (2026-01-08) + + +### Bug Fixes + +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) + +## [9.11.1](https://github.com/cloud-pi-native/console/compare/v9.11.0...v9.11.1) (2026-01-06) + + +### Bug Fixes + +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) + +## [9.11.0](https://github.com/cloud-pi-native/console/compare/v9.10.2...v9.11.0) (2025-12-22) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) + +## [9.10.2](https://github.com/cloud-pi-native/console/compare/v9.10.1...v9.10.2) (2025-12-18) + + +### Bug Fixes + +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) + +## [9.10.1](https://github.com/cloud-pi-native/console/compare/v9.10.0...v9.10.1) (2025-12-18) + + +### Bug Fixes + +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) + +## [9.10.0](https://github.com/cloud-pi-native/console/compare/v9.9.0...v9.10.0) (2025-12-12) + + +### Features + +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) + + +### Bug Fixes + +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) + +## [9.9.0](https://github.com/cloud-pi-native/console/compare/v9.8.1...v9.9.0) (2025-11-27) + + +### Features + +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) + +## [9.8.1](https://github.com/cloud-pi-native/console/compare/v9.8.0...v9.8.1) (2025-11-21) + + +### Bug Fixes + +* :bug: display associated project on cluster form ([488ff24](https://github.com/cloud-pi-native/console/commit/488ff24dab0d90a6af83e96bdbe815a0f955220e)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* **ci:** remove PNPM_VERSION from Github Actions ([bd1f4af](https://github.com/cloud-pi-native/console/commit/bd1f4af2592658f7edcbcada13f1ea6cafcab3d4)) +* rely on packageManager property to retrieve the right version of pnpm ([7b20b83](https://github.com/cloud-pi-native/console/commit/7b20b832b6f83d367c33573d87c444a8da315703)) +* **shared:** explicitely import Zod type ([2a8a4d8](https://github.com/cloud-pi-native/console/commit/2a8a4d8074e45742769fb2f451672777c4f4afa5)) +* **tests:** ensure Cypress tests are green again ([5b7042b](https://github.com/cloud-pi-native/console/commit/5b7042b0be48697ce9dfa4a69377a04ceae8fb5c)) + +## [9.8.0](https://github.com/cloud-pi-native/console/compare/v9.7.2...v9.8.0) (2025-11-17) + + +### Features + +* :fire: de-duplicates Argo CD links for default zone ([2db26cd](https://github.com/cloud-pi-native/console/commit/2db26cdfce71ffbf330bdbe07a48f1276bad7cd9)) +* :poop: change the naming convention of cluster secret ([a471766](https://github.com/cloud-pi-native/console/commit/a47176643cb9a97f41c1345477da60bce5935f85)) + + +### Bug Fixes + +* :bug: improve number inputs for decimal values ([d69a8ba](https://github.com/cloud-pi-native/console/commit/d69a8ba547174be521ac8cab0e47db2448415a61)) +* **#1640:** :bug: only regenerate robot secret if host changes ([e5d3ba8](https://github.com/cloud-pi-native/console/commit/e5d3ba87f895451795be1d7fa42a389e63521a68)) + + +### Performance Improvements + +* :zap: direct service communication for Gitlab pipelines ([4991451](https://github.com/cloud-pi-native/console/commit/4991451f3d0c7dbac32cf5fed1fc880b29a37a1c)) + +## [9.7.2](https://github.com/cloud-pi-native/console/compare/v9.7.1...v9.7.2) (2025-10-27) + + +### Bug Fixes + +* release please condition ([c293866](https://github.com/cloud-pi-native/console/commit/c29386619ff3624d22a2a72f9753bc443045b5c0)) + +## [9.7.1](https://github.com/cloud-pi-native/console/compare/v9.7.0...v9.7.1) (2025-10-27) + + +### Bug Fixes + +* :bug: use the renamed path of repository/group being deleted ([38b8cff](https://github.com/cloud-pi-native/console/commit/38b8cffaff3484257104d325dd9e034bfa2611fa)) + +## [9.7.0](https://github.com/cloud-pi-native/console/compare/v9.6.0...v9.7.0) (2025-10-27) + + +### Features + +* :sparkles: add support for SonarQube and Harbor internal url ([43ce886](https://github.com/cloud-pi-native/console/commit/43ce88690883d84214c29a4cb910f81609e8cb4d)) + +## [9.6.0](https://github.com/cloud-pi-native/console/compare/v9.5.0...v9.6.0) (2025-10-14) + + +### Features + +* :sparkles: allow cluster name update ([161086d](https://github.com/cloud-pi-native/console/commit/161086dfd7c226eac65b3f5e3c835b7f923cf511)) +* :sparkles: allow cluster name update ([0cbc53e](https://github.com/cloud-pi-native/console/commit/0cbc53eb7f3b0e41f128e1066f584aadb55b9766)), closes [#1582](https://github.com/cloud-pi-native/console/issues/1582) + +## [9.5.0](https://github.com/cloud-pi-native/console/compare/v9.4.0...v9.5.0) (2025-10-10) + + +### Features + +* :sparkles: add project usage on resources tab ([1ee66ce](https://github.com/cloud-pi-native/console/commit/1ee66ce8211c2017b328887b7800a2e1ab537300)) +* :sparkles: add resources occupancy on clusters ([75ce5e9](https://github.com/cloud-pi-native/console/commit/75ce5e9450ca68d45d2635b29e0ff8bd8d8614d7)) +* :sparkles: add tags retention policy rules ([a0e3f98](https://github.com/cloud-pi-native/console/commit/a0e3f9839d18c9459264e452b14cc87359a3f6c8)) +* :sparkles: check cluster resources when adding an environment ([826f1e9](https://github.com/cloud-pi-native/console/commit/826f1e9d8f46bf2aed419c7c395ef336131849dc)) +* :sparkles: save resources constraints for the project ([58e6d7a](https://github.com/cloud-pi-native/console/commit/58e6d7ad2d06fbb9e766fa755ccd7ab44556409e)) +* **client:** add service chain details form ([d56e68c](https://github.com/cloud-pi-native/console/commit/d56e68c626ee854fd12e60694acbc87d178ee270)) +* move environments and stages scenario from cypress to playwright ([c4e703f](https://github.com/cloud-pi-native/console/commit/c4e703f92f964e5850ca16ae7d066dc6e7d692cf)) +* **opencds:** add 'list all service chains' section in admin side menu ([f58de6e](https://github.com/cloud-pi-native/console/commit/f58de6e55ad23ed8f8e81aa769f76f9adc62b7b1)) +* **opencds:** add client-side service chain details page ([478bc35](https://github.com/cloud-pi-native/console/commit/478bc35408c43da95da875b105f1cd8222dfa217)) +* **opencds:** add feature toggling default value ([5dc6071](https://github.com/cloud-pi-native/console/commit/5dc607178179c5ab76dedf0d84ab22d3e08cabf5)) +* **opencds:** add flows retrieval and display as a stepper ([7194218](https://github.com/cloud-pi-native/console/commit/71942182464d6c42da0ab818f28ee60024c2e056)) +* **opencds:** add mockoon docker image for local dev and ci considerations ([0cf8de5](https://github.com/cloud-pi-native/console/commit/0cf8de5cd04860cdcfe2259640abffd48881ac5b)) +* **opencds:** adjust service chain list view ([fa0f5c3](https://github.com/cloud-pi-native/console/commit/fa0f5c3b91d940547ef132ad443f3b0a187238aa)) +* **opencds:** clean up mockoon json ([8bd1f74](https://github.com/cloud-pi-native/console/commit/8bd1f7483d5b772705c317510cded0552cad40c6)) +* **opencds:** handle OpenCDS API token-based security ([409ae6e](https://github.com/cloud-pi-native/console/commit/409ae6e40a2e42b54fd95a05904c35e1efd18371)) +* **opencds:** use a badge for state on service-chain list ([e3c1340](https://github.com/cloud-pi-native/console/commit/e3c134095e0dc7fb8009bfdb8fc14e6e8d62959b)) +* remove quota table ([1561d8d](https://github.com/cloud-pi-native/console/commit/1561d8da82ab1413047a3573269ddc4a133360ca)) +* remove Quota table references to use Environment attributes instead ([03297ec](https://github.com/cloud-pi-native/console/commit/03297ec88fb69834c6d76f61b25309ce1a363f4f)) +* update Cluster model to store resources capability ([6167743](https://github.com/cloud-pi-native/console/commit/6167743743c91f5e109da7297de275ea9d4d23d3)) +* update Environment form to use new resources attributes ([ff58b26](https://github.com/cloud-pi-native/console/commit/ff58b26662db51f385ce49ac14b4073bd5cf71cf)) + + +### Bug Fixes + +* :bug: update zone values when modifying a cluster ([253acb9](https://github.com/cloud-pi-native/console/commit/253acb9d6106727fc11b22c8268cfcd07320001a)), closes [#1642](https://github.com/cloud-pi-native/console/issues/1642) +* :lipstick: show resources in clusters and environment tables ([8494e2a](https://github.com/cloud-pi-native/console/commit/8494e2ad19afc7e0c8afb65ccc2ee216d7399615)) +* config and locator due to keycloak upgrade ([516d9d5](https://github.com/cloud-pi-native/console/commit/516d9d5bc34b5261490170f2c356c050168f54e5)) +* **opencds:** fix validatedBy pseudo-null value conflicting with OpenCDS contract in server ([cde51a2](https://github.com/cloud-pi-native/console/commit/cde51a2f08cfd64e57714f6096a6ec2d6bb8962f)) +* **playwright:** bump playwright to 1.54.2 ([44ea5e3](https://github.com/cloud-pi-native/console/commit/44ea5e3f5093a45cf95266c0691dad60ca33de26)) +* **playwright:** fix flaky test on cluster informations by adding a loader ([ffc7b43](https://github.com/cloud-pi-native/console/commit/ffc7b436efa7af16a1a7d166c52d02317c26ab74)) + +## [9.4.0](https://github.com/cloud-pi-native/console/compare/v9.3.3...v9.4.0) (2025-08-25) + + +### Features + +* :rocket: empty Vault url to use the default VaultConnection ([e2d3b28](https://github.com/cloud-pi-native/console/commit/e2d3b287964952b0f6dd0a00ea97499827cb7198)) +* :sparkles: create contact email environment variable ([779cb4a](https://github.com/cloud-pi-native/console/commit/779cb4a83492d4159a7efa868822adc5640dd6f8)) +* ✨ Add the repo UID display on console ([2ed10e4](https://github.com/cloud-pi-native/console/commit/2ed10e4b612687b9a18441458749d2821bcff43e)) +* ✨ Variabilize clipboard timeout ([2ed10e4](https://github.com/cloud-pi-native/console/commit/2ed10e4b612687b9a18441458749d2821bcff43e)) +* add Playwright E2E tests for Keycloak ([1524bba](https://github.com/cloud-pi-native/console/commit/1524bba8d6adcc8b360d740e85b51fcf8db71964)) +* add README for ArgoCD plugin ([5d66223](https://github.com/cloud-pi-native/console/commit/5d66223c5e6af45fdc02d3f1e062a6424b0e6cdc)) +* allow cluster informations property to be up to 1000 characters long ([9fe3182](https://github.com/cloud-pi-native/console/commit/9fe3182a5c771552f23f1e147475acd606efe67a)) +* configure Playwright Github workflow ([4ac7f55](https://github.com/cloud-pi-native/console/commit/4ac7f55f6fc6d3d747bafad20efa7184e608b146)) +* **playwright:** add Project page tests ([419ea3c](https://github.com/cloud-pi-native/console/commit/419ea3c735dd3ece8e02ce2d77beaa815170f7cd)) + + +### Bug Fixes + +* :bug: adapt status analysis to Nexus 3.83.1 response ([4efa720](https://github.com/cloud-pi-native/console/commit/4efa720c50e766068d3c8773df83e3754d29c9f1)) +* **#1681:** :bug: missing kv creation if not exists ([e862c5c](https://github.com/cloud-pi-native/console/commit/e862c5ca51dee5a0216f2c66040f90d510579df4)) +* **1638:** add validation for repo names in Zod schema ([48621bd](https://github.com/cloud-pi-native/console/commit/48621bdc63446be7cd5e38ca13b98762af322c16)) +* **client:** ensure branch name is reset to default value after synchronization on a repository ([af0ea44](https://github.com/cloud-pi-native/console/commit/af0ea44186dd8c22c370c5c7edd77b4807fb9e80)) +* **client:** fix Teams E2E cypress being broken after keycloak realm update ([9c95fdf](https://github.com/cloud-pi-native/console/commit/9c95fdffbb208cae9b930fc0d2ca9516bb2cbe9b)) +* fix docker build permissions to allow local path import ([c0aaa2f](https://github.com/cloud-pi-native/console/commit/c0aaa2f29017845575c38ef83d18850f6ae741cb)) +* fix docker build permissions to allow local path import ([6b9461d](https://github.com/cloud-pi-native/console/commit/6b9461dd5e481486a5d1e479f4364348e1cf4fda)) +* fix formatting issue ([662a834](https://github.com/cloud-pi-native/console/commit/662a8342bd83242a61eceae900eb328b1eab5cc5)) +* fix typo ([c20bd60](https://github.com/cloud-pi-native/console/commit/c20bd60e462f59222b1d873d65dcb2ec980b6c33)) +* **playwright:** ensure keycloak tests work for all languages ([44e27a3](https://github.com/cloud-pi-native/console/commit/44e27a3a8d666949ad29d26b8cbd77da50b067bb)) +* **playwright:** fix format command not working ([578e4b3](https://github.com/cloud-pi-native/console/commit/578e4b3e5a0538ac41e9410d09b47f45b98f30bb)) +* remove cluster resources to avoid confusion on its usage ([2ecf9d7](https://github.com/cloud-pi-native/console/commit/2ecf9d70330581b9cb757fa5822a85c6464e0bec)) +* **tests:** fix admin id in pgsql import data not being consistent with keycloak ([3ee2583](https://github.com/cloud-pi-native/console/commit/3ee2583c6f1f7761b5586047b6a93d40c3fb5d58)) + +## [9.3.3](https://github.com/cloud-pi-native/console/compare/v9.3.2...v9.3.3) (2025-06-12) + + +### Bug Fixes + +* :bug: fix gitlab deletion API request ([7acd71b](https://github.com/cloud-pi-native/console/commit/7acd71bc9aed370882b41f31c37dcbb8122bdd3d)) + +## [9.3.2](https://github.com/cloud-pi-native/console/compare/v9.3.1...v9.3.2) (2025-06-12) + + +### Bug Fixes + +* :bug: add missing custom source repositories in argocd values (pulling mode) ([5330f7a](https://github.com/cloud-pi-native/console/commit/5330f7ae16565d7f6e0840fab7bb86691c6aeb4a)) +* fix admin roles not being retrieved when a new user logs in for the first time ([e40c00b](https://github.com/cloud-pi-native/console/commit/e40c00b1b25ecb4026612018737a6a0a1abd5aec)) +* fix hiding tile header for all tiles instead of just the projects ones ([156e75a](https://github.com/cloud-pi-native/console/commit/156e75a215bd18a90b9abe6adee5a300ea180bbd)) + +## [9.3.1](https://github.com/cloud-pi-native/console/compare/v9.3.0...v9.3.1) (2025-05-23) + + +### Bug Fixes + +* :bug: adapt status analysis to Nexus 3.78 response ([95001c2](https://github.com/cloud-pi-native/console/commit/95001c2d96afa101e9867da177d5eed8dcc6b40d)) + +## [9.3.0](https://github.com/cloud-pi-native/console/compare/v9.2.1...v9.3.0) (2025-05-19) + + +### Features + +* :zap: optimization of gitlab API calls ([d9b236e](https://github.com/cloud-pi-native/console/commit/d9b236ebe032d4976094fed757467165ac40df25)) + + +### Bug Fixes + +* :bug: add missing scope when creating keycloak client ([ec24df1](https://github.com/cloud-pi-native/console/commit/ec24df116469237ad85e67d183560976283fc1ec)) +* :wrench: duplicate changelog entries ([#1587](https://github.com/cloud-pi-native/console/issues/1587)) ([ea9fdec](https://github.com/cloud-pi-native/console/commit/ea9fdec8e15132d537f888aec0369ccb655a3667)) +* allowed sources repositories ([a5ce7ad](https://github.com/cloud-pi-native/console/commit/a5ce7ad7a6a3cc17fa8726a6be42cdf53828034b)) + +## [9.2.1](https://github.com/cloud-pi-native/console/compare/v9.2.0...v9.2.1) (2025-03-25) + + +### Bug Fixes + +* :bug: update condition to allow private quota modification ([f4c34bb](https://github.com/cloud-pi-native/console/commit/f4c34bb1e462a9d393c4af231a0f524eb20fcad1)) + +## [9.2.0](https://github.com/cloud-pi-native/console/compare/v9.1.0...v9.2.0) (2025-03-21) + + +### Features + +* :zap: limit gitlab api calls to list cicd variables in sonar plugin ([c1b3299](https://github.com/cloud-pi-native/console/commit/c1b32998f4f340c945c657d2dbbb81160fc054f8)) + +## [9.1.0](https://github.com/cloud-pi-native/console/compare/v9.0.0...v9.1.0) (2025-02-19) + + +### Features + +* :sparkles: display last refresh monitor and display cause to admins ([054ea37](https://github.com/cloud-pi-native/console/commit/054ea37d556b1c6e0fabf0e52f4611395aec18b4)) + + +### Bug Fixes + +* :alien: fix nexus monitoring ([8bb7448](https://github.com/cloud-pi-native/console/commit/8bb74480deaa2b88628d2a715496e93aa4d27629)) +* :bug: missing icons ([d2b98c2](https://github.com/cloud-pi-native/console/commit/d2b98c201fb9f4ce3181c2d2b55fdac2a85f11f5)) + +## [9.0.0](https://github.com/cloud-pi-native/console/compare/v8.24.2...v9.0.0) (2025-02-14) + + +### ⚠ BREAKING CHANGES + +* :triangular_flag_on_post: remove organizations final part + +### Features + +* :sparkles: auto create projectRootDir in Gitlab ([b4f1212](https://github.com/cloud-pi-native/console/commit/b4f12120e81d8d689b1d65de12ed06c65a95279b)) +* :triangular_flag_on_post: remove organizations final part ([73ce9a9](https://github.com/cloud-pi-native/console/commit/73ce9a9497f44bb4d831aed8c25f9ee2e8a337f5)) +* search cluster, table display ([d21be53](https://github.com/cloud-pi-native/console/commit/d21be53e168a820f500334d97faa73ae30aeaf6e)) +* unique dashboard ([d21be53](https://github.com/cloud-pi-native/console/commit/d21be53e168a820f500334d97faa73ae30aeaf6e)) + + +### Bug Fixes + +* :ambulance: bad migration ([baa0712](https://github.com/cloud-pi-native/console/commit/baa071203693db68154ffda61f99a66a72a2c1c8)) +* :ambulance: forgot to use slug in gitlab group ([aa22f19](https://github.com/cloud-pi-native/console/commit/aa22f1963d0d87d28d1a9d74fbf13ac207545382)) +* :bug: auto re login when session expiry ([6c16e88](https://github.com/cloud-pi-native/console/commit/6c16e884d505372a8d2142d5870aed635ac4afbd)) + +## [8.24.2](https://github.com/cloud-pi-native/console/compare/v8.24.1...v8.24.2) (2025-02-06) + + +### Bug Fixes + +* :recycle: add projectId to url params ([3986376](https://github.com/cloud-pi-native/console/commit/3986376d418c017f8edc802ad237df4e3a29df25)) + +## [8.24.1](https://github.com/cloud-pi-native/console/compare/v8.24.0...v8.24.1) (2025-02-04) + + +### Bug Fixes + +* :ambulance: owner of a project cannot see attached private quota ([297a4ac](https://github.com/cloud-pi-native/console/commit/297a4ac410c739142d3ef88f1da58492881ddb6f)) + +## [8.24.0](https://github.com/cloud-pi-native/console/compare/v8.23.4...v8.24.0) (2025-01-28) + + +### Features + +* :sparkles: add an 'external' boolean properties on clusters ([4a64abc](https://github.com/cloud-pi-native/console/commit/4a64abccea40d2675f435b6cec21bb4f9aa3aa43)) +* :sparkles: vault plugin, switch to deploy or not vaultconnections ([cc9c4e6](https://github.com/cloud-pi-native/console/commit/cc9c4e6ad40412921a8d7afc888f014d92ef46c8)) + + +### Bug Fixes + +* :alien: gitlab response changed on upgrade ([d595f3a](https://github.com/cloud-pi-native/console/commit/d595f3ac80e84070e27ed257d4b8879ee2611d46)) +* :bug: names displayed twice at project creation ([079a20a](https://github.com/cloud-pi-native/console/commit/079a20a4e5d910e64e43a6df2925acee5ea7368c)) + +## [8.23.4](https://github.com/cloud-pi-native/console/compare/v8.23.3...v8.23.4) (2025-01-24) + + +### Bug Fixes + +* :bug: duplicates name should not be already allowed ([eb2a4db](https://github.com/cloud-pi-native/console/commit/eb2a4db44596ad2f9f6affbbdb2dea84cb28067c)) +* fix gitlab, no first commit on cloned repository ([2d7038f](https://github.com/cloud-pi-native/console/commit/2d7038fd1f5aefb67259666086048d11a6d5537f)) + +## [8.23.3](https://github.com/cloud-pi-native/console/compare/v8.23.2...v8.23.3) (2025-01-21) + + +### Bug Fixes + +* :ambulance: fix gitlab ci config file ([2c2a287](https://github.com/cloud-pi-native/console/commit/2c2a287a76723dfffecd9ae892a8897f03aef161)) + +## [8.23.2](https://github.com/cloud-pi-native/console/compare/v8.23.1...v8.23.2) (2025-01-21) + + +### Bug Fixes + +* :bug: add a better patch capability for kubernetes ([0050394](https://github.com/cloud-pi-native/console/commit/005039450701f9d77a4479487ba872af602ec689)) +* :bug: can't retrieved enc ns name for gitops cluster ([dcf3c1a](https://github.com/cloud-pi-native/console/commit/dcf3c1a147755ae698ba09f7bb7d1d79d13cc1d1)) +* :triangular_flag_on_post: no more cloned repository at gitlab createProject ([211d070](https://github.com/cloud-pi-native/console/commit/211d070d77802d894c70c584fa2c41e86a297295)) + +## [8.23.1](https://github.com/cloud-pi-native/console/compare/v8.23.0...v8.23.1) (2025-01-20) + + +### Bug Fixes + +* :ambulance: bad gitlab url fn ([2ec5332](https://github.com/cloud-pi-native/console/commit/2ec5332ce320e40da131ff537974c169716e3ac5)) + +## [8.23.0](https://github.com/cloud-pi-native/console/compare/v8.22.1...v8.23.0) (2025-01-20) + + +### Features + +* :fire: remove organization feature ([3b331f6](https://github.com/cloud-pi-native/console/commit/3b331f680716880e7ae4e33b6e441452e2816f3c)) +* :sparkles: store cluster secret in Vault ([cea79ef](https://github.com/cloud-pi-native/console/commit/cea79efc6ecb4c33d868d7f5044ddc7825c69ed5)) + + +### Bug Fixes + +* :bug: test if description is string before using replaceAll function ([d5b1234](https://github.com/cloud-pi-native/console/commit/d5b12342c236dca1231d05528f6ccd7746f62309)) + +## [8.22.1](https://github.com/cloud-pi-native/console/compare/v8.22.0...v8.22.1) (2025-01-09) + + +### Bug Fixes + +* :zap: package icons in client ([73fa90d](https://github.com/cloud-pi-native/console/commit/73fa90dd8aa0fe9c7fa8075ada31eea39af808e5)) +* patch vue-dsfr ([02079aa](https://github.com/cloud-pi-native/console/commit/02079aaf569b80f1a6a245504757524826a7ab3c)) + +## [8.22.0](https://github.com/cloud-pi-native/console/compare/v8.21.0...v8.22.0) (2025-01-08) + + +### Features + +* :sparkles: store cluster secret in Vault ([3935ca6](https://github.com/cloud-pi-native/console/commit/3935ca68cb0927bf150e15c3b205baebafa5cb98)) +* track project version provisioning ([227baf6](https://github.com/cloud-pi-native/console/commit/227baf6802687c9555d3931071a7e049d2756db1)) + + +### Bug Fixes + +* :bug: downgrade gitlab lib to avoid upsert user warning ([90faeff](https://github.com/cloud-pi-native/console/commit/90faeff2978e0ee2db7624b895550db278aa9c17)) +* :bug: nexus auto disable repo config on delete ([1295ece](https://github.com/cloud-pi-native/console/commit/1295ece0312a6c7b319c379e88be1ee570d75499)) + +## [8.21.0](https://github.com/cloud-pi-native/console/compare/v8.20.0...v8.21.0) (2024-12-16) + + +### Features + +* :construction: zone kv store management ([628a6e5](https://github.com/cloud-pi-native/console/commit/628a6e57cb439c8b899acdb8bf1995eedbe0a184)) + + +### Bug Fixes + +* :bug: fix not displaying swagger ([6f708a1](https://github.com/cloud-pi-native/console/commit/6f708a150402835c4ef044bdb8e69f20dfe3ee32)) +* :bug: retry delete project is failed ([c5856fa](https://github.com/cloud-pi-native/console/commit/c5856fadc49acd238ee61d289cf8441a5b3a2e18)) + +## [8.20.0](https://github.com/cloud-pi-native/console/compare/v8.19.1...v8.20.0) (2024-11-25) + + +### Features + +* :sparkles: bulk actions on projects ([951fc15](https://github.com/cloud-pi-native/console/commit/951fc152016e00e3bac6cb1d9920998e3cfb4a80)) + +## [8.19.1](https://github.com/cloud-pi-native/console/compare/v8.19.0...v8.19.1) (2024-11-23) + + +### Bug Fixes + +* :ambulance: broken admin token ([a1eff7f](https://github.com/cloud-pi-native/console/commit/a1eff7f96c6793338019eea0676b8f6dea4c0056)) + +## [8.19.0](https://github.com/cloud-pi-native/console/compare/v8.18.2...v8.19.0) (2024-11-22) + + +### Features + +* :art: use upsert zone hook to manage infra repo ([6fe47d1](https://github.com/cloud-pi-native/console/commit/6fe47d1922359c4934b5364fa3d87dd86cec129b)) +* :sparkles: easy in-cluster config ([01435e2](https://github.com/cloud-pi-native/console/commit/01435e2595383b4be00bdafb45970161765da6b9)) +* :sparkles: enable pwa on frontend ([c6c03f3](https://github.com/cloud-pi-native/console/commit/c6c03f3dadf9e954353ad11c29c19d50538d1e2a)) + + +### Bug Fixes + +* :bug: vault forgot to set kv to v2 ([90dca64](https://github.com/cloud-pi-native/console/commit/90dca64aca9d64b8491b332d0151f1be68dcbccc)) + +## [8.18.2](https://github.com/cloud-pi-native/console/compare/v8.18.1...v8.18.2) (2024-11-20) + + +### Bug Fixes + +* :bug: fix vault detect api hashicorp ([86959f5](https://github.com/cloud-pi-native/console/commit/86959f5874f60e898442ba4f734ff1a5581998a6)) + +## [8.18.1](https://github.com/cloud-pi-native/console/compare/v8.18.0...v8.18.1) (2024-11-20) + + +### Bug Fixes + +* :ambulance: fix pat migration ([df3f699](https://github.com/cloud-pi-native/console/commit/df3f699ddc972a2b2001a20d5e31dd5ba9ad7482)) + +## [8.18.0](https://github.com/cloud-pi-native/console/compare/v8.17.2...v8.18.0) (2024-11-20) + + +### Features + +* :sparkles: build project kv with config for projects ([f1c687f](https://github.com/cloud-pi-native/console/commit/f1c687f55ee4c8b8c2f342e431fb3f0982ffd72c)) +* ✨ add PAT ([2c35cd7](https://github.com/cloud-pi-native/console/commit/2c35cd72a1d0fb14f8c1a633b86e7ef00da80bab)) + + +### Bug Fixes + +* :bug: fix weird front behviours ([e06cf11](https://github.com/cloud-pi-native/console/commit/e06cf117c0fef4f49749d80a7ac5b8de813c6ed7)) + +## [8.17.2](https://github.com/cloud-pi-native/console/compare/v8.17.1...v8.17.2) (2024-11-14) + + +### Bug Fixes + +* :zap: disallow recursive list to avoid big latency ([1fed78c](https://github.com/cloud-pi-native/console/commit/1fed78c1742b70918c9bab1ff616f0cf681d66fa)) + +## [8.17.1](https://github.com/cloud-pi-native/console/compare/v8.17.0...v8.17.1) (2024-11-08) + + +### Bug Fixes + +* :ambulance: fix not displayed repositories in admin view ([48c0a92](https://github.com/cloud-pi-native/console/commit/48c0a92339db999c598fe24cdc9302b28cffa14d)) +* :ambulance: internal gitlab url ([16f195e](https://github.com/cloud-pi-native/console/commit/16f195e55222de023046aaabefa9b6d7f79062ee)) + +## [8.17.0](https://github.com/cloud-pi-native/console/compare/v8.16.0...v8.17.0) (2024-11-07) + + +### Features + +* :sparkles: config items section ([7c41eea](https://github.com/cloud-pi-native/console/commit/7c41eeaec2855355c0fa683d4b7ec81210eae6af)) +* :sparkles: hooks returned store accept null ([1255a32](https://github.com/cloud-pi-native/console/commit/1255a32237a6775e465fc0efe4dfe10bc9463816)) +* :sparkles: nexus fine write policy ([14897e6](https://github.com/cloud-pi-native/console/commit/14897e685ff6665da35464d93571a972e2c8dae9)) +* :sparkles: record and display last time a user logged ([1e8323b](https://github.com/cloud-pi-native/console/commit/1e8323bbfcd611891fe741434d4cb5f780f45dec)) +* gitlab optional internal url ([c1643a1](https://github.com/cloud-pi-native/console/commit/c1643a1eaae350220c4ecf114c7fa16bfb1983a1)) + +## [8.16.0](https://github.com/cloud-pi-native/console/compare/v8.15.1...v8.16.0) (2024-11-06) + + +### Features + +* :lipstick: disable cluster selection when creating a zone ([a8bb19c](https://github.com/cloud-pi-native/console/commit/a8bb19cb4ff50b69b892547e05b1541e322b0ecd)) +* :lipstick: user list rework ([5c13b9d](https://github.com/cloud-pi-native/console/commit/5c13b9d756c549a2f12d99e7f7384690f3166553)) +* :sparkles: nexus customizable write policy ([78a004b](https://github.com/cloud-pi-native/console/commit/78a004bc72e97b3a98c963560e8c11db45b34b67)) + + +### Bug Fixes + +* :bug: minors plugins fix and front ([22937c8](https://github.com/cloud-pi-native/console/commit/22937c875480dc0b6bf67f0784311f98300df04d)) + +## [8.15.1](https://github.com/cloud-pi-native/console/compare/v8.15.0...v8.15.1) (2024-10-29) + + +### Bug Fixes + +* :bug: update depracated import assertion type syntax ([d8f2072](https://github.com/cloud-pi-native/console/commit/d8f20728a0d96c6799e5bba7e821ed12356d6393)) + +## [8.15.0](https://github.com/cloud-pi-native/console/compare/v8.14.0...v8.15.0) (2024-10-29) + + +### Features + +* :lipstick: adding the project id in namespace labels ([eace611](https://github.com/cloud-pi-native/console/commit/eace611838cd2a5ce0e457971914c9d3bf29e6d0)) +* 🚸 rework project selection ([1641fc9](https://github.com/cloud-pi-native/console/commit/1641fc974f455734bedeb0cf2fdaf7356c9b850b)) + +## [8.14.0](https://github.com/cloud-pi-native/console/compare/v8.13.2...v8.14.0) (2024-10-17) + + +### Features + +* :lipstick: add argocd urls for each zone ([cf467ae](https://github.com/cloud-pi-native/console/commit/cf467aeaf82c44099f8b964dd4ef2e00a5323a90)) +* :sparkles: add npm support in npm ([60b7752](https://github.com/cloud-pi-native/console/commit/60b7752fd31ca36860e64df9b320d8468163d0d4)) +* :sparkles: introduce warn status on project ([9d69357](https://github.com/cloud-pi-native/console/commit/9d69357fde2014c1fe134803f8361680bac1d3fc)) +* :sparkles: keycloak client management for configuring sso oidc of each argocd zone ([44b3b06](https://github.com/cloud-pi-native/console/commit/44b3b06e3882fe2a60691a8bd29572e023b32146)) +* :sparkles: return warns and errors message from plugins to user ([be4443f](https://github.com/cloud-pi-native/console/commit/be4443fc10db5669578279fbcbac2b4d67adcb52)) +* :sparkles: show short logs about project to users ([4888e64](https://github.com/cloud-pi-native/console/commit/4888e64527d2627fc2e320300a39745e2571b73c)) + +## [8.13.2](https://github.com/cloud-pi-native/console/compare/v8.13.1...v8.13.2) (2024-10-02) + + +### Bug Fixes + +* :ambulance: no check on api response ([cfc56db](https://github.com/cloud-pi-native/console/commit/cfc56db5b4cdab03b3515d2cae1477e7546dfef8)) + +## [8.13.1](https://github.com/cloud-pi-native/console/compare/v8.13.0...v8.13.1) (2024-09-27) + + +### Bug Fixes + +* :loud_sound: change log logic in sonarqube plugin ([5006562](https://github.com/cloud-pi-native/console/commit/500656244f12d0d1edeb995e30be8cfa95ca8a4f)) + +## [8.13.0](https://github.com/cloud-pi-native/console/compare/v8.12.3...v8.13.0) (2024-09-27) + + +### Features + +* :loud_sound: add logger object ([02c6715](https://github.com/cloud-pi-native/console/commit/02c67150d738ae200f8937adb88bf32001ff2b8c)) +* :sparkles: plugins can return warning to not interupt hook ([3e132c3](https://github.com/cloud-pi-native/console/commit/3e132c3238cc98d1d75ec956ce55efebe2c5304a)) + + +### Bug Fixes + +* :bug: change imports exports to not use z.lazy, fix swagger-ui ([64a60fe](https://github.com/cloud-pi-native/console/commit/64a60fe6bc84ea9de51519049252957726963afa)) +* :bug: dsfr tabs require rewrite DsoHome.vue ([ea03623](https://github.com/cloud-pi-native/console/commit/ea0362303757ac239e5b753af6777514cb714b03)) + +## [8.12.3](https://github.com/cloud-pi-native/console/compare/v8.12.2...v8.12.3) (2024-09-19) + + +### Bug Fixes + +* :passport_control: allow more actions to admin token ([81fa606](https://github.com/cloud-pi-native/console/commit/81fa60653e2bfbcb37251bda448ea3d2f9805eb3)) + +## [8.12.2](https://github.com/cloud-pi-native/console/compare/v8.12.1...v8.12.2) (2024-09-19) + + +### Bug Fixes + +* :ambulance: cbx not checked cause vuedsfr breaking change ([c7e607f](https://github.com/cloud-pi-native/console/commit/c7e607fa6a373866489d7e72e7234107d76a3b7e)) + +## [8.12.1](https://github.com/cloud-pi-native/console/compare/v8.12.0...v8.12.1) (2024-09-17) + + +### Bug Fixes + +* :bug: fix behaviour of complex querying on user list ([b63bd9a](https://github.com/cloud-pi-native/console/commit/b63bd9a56c118fe52ab38589a28c90777c5a557e)) + +## [8.12.0](https://github.com/cloud-pi-native/console/compare/v8.11.1...v8.12.0) (2024-09-17) + + +### Features + +* :sparkles: add admins api tokens ([e141642](https://github.com/cloud-pi-native/console/commit/e141642eb2b717c29cf91f482e91761e7ce4bfa8)) +* :sparkles: add users search filter (admin) ([cc1ab56](https://github.com/cloud-pi-native/console/commit/cc1ab56b2ac496ffd01196ee4d97362ab7a08e03)) + +## [8.11.1](https://github.com/cloud-pi-native/console/compare/v8.11.0...v8.11.1) (2024-09-02) + + +### Bug Fixes + +* :lipstick: better repo ui ([084fc91](https://github.com/cloud-pi-native/console/commit/084fc91332fbdd6052cadbde204df77cd772a07d)) + +## [8.11.0](https://github.com/cloud-pi-native/console/compare/v8.10.1...v8.11.0) (2024-08-29) + + +### Features + +* :sparkles: standalone repo ([ea9a2ba](https://github.com/cloud-pi-native/console/commit/ea9a2bac3fc9696fde6f1ae56b916e180ae19039)) + + +### Bug Fixes + +* :ambulance: edit previous migrations ([2a87288](https://github.com/cloud-pi-native/console/commit/2a87288ea99c19df706fe2f68173ba4bea919a17)) + + +### Performance Improvements + +* :zap: avoid unuseful keycloak call and auto-refresh ([8dcb1c2](https://github.com/cloud-pi-native/console/commit/8dcb1c24e9cce760e278222dcb84c62dff4118a1)) + +## [8.10.1](https://github.com/cloud-pi-native/console/compare/v8.10.0...v8.10.1) (2024-08-27) + + +### Bug Fixes + +* :ambulance: missed admin authorized to remove a member ([15eafa9](https://github.com/cloud-pi-native/console/commit/15eafa9996d7e1f827f5eb1cf6aef425f4620a2b)) + +## [8.10.0](https://github.com/cloud-pi-native/console/compare/v8.9.4...v8.10.0) (2024-08-26) + + +### Features + +* :sparkles: add project dedicated approle and policy in vault, to be used by argocd ([e354978](https://github.com/cloud-pi-native/console/commit/e354978392b8c5d7a4c3591388f0f0a9aa3d7865)) +* :sparkles: introduce fine grained perms and and roles ([ab0a7a5](https://github.com/cloud-pi-native/console/commit/ab0a7a55e42ef94a18e529b168d5f299687ca526)) +* :sparkles: maintenance mode ([fdbd5f0](https://github.com/cloud-pi-native/console/commit/fdbd5f0356a16c24ef677f8a06309aac89af3a4c)) +* :sparkles: option to mirror all branches ([655b032](https://github.com/cloud-pi-native/console/commit/655b0329599fe7e0857e487e3b0fd3ecb49b94ac)) + +## [8.9.4](https://github.com/cloud-pi-native/console/compare/v8.9.3...v8.9.4) (2024-08-09) + + +### Bug Fixes + +* :ambulance: handle missing commit in gitlab repo ([2ef9930](https://github.com/cloud-pi-native/console/commit/2ef993001770e76be5bdf2795a0cbe8e302cf71a)) + +## [8.9.3](https://github.com/cloud-pi-native/console/compare/v8.9.2...v8.9.3) (2024-08-08) + + +### Bug Fixes + +* :ambulance: fix error on getArgoRepo when the infra repository is empty (zero commit) ([bf3f3f1](https://github.com/cloud-pi-native/console/commit/bf3f3f10c0e50eaf2f2b788b587c04e27f1eff00)) + +## [8.9.2](https://github.com/cloud-pi-native/console/compare/v8.9.1...v8.9.2) (2024-07-19) + + +### Bug Fixes + +* :ambulance: clone private repositories in gitlab plugin ([d42bbf8](https://github.com/cloud-pi-native/console/commit/d42bbf8a02950a6f9d0d3218bd7bc40cec203843)) + +## [8.9.1](https://github.com/cloud-pi-native/console/compare/v8.9.0...v8.9.1) (2024-07-18) + + +### Bug Fixes + +* :bug: search on HEAD ref instead of main ([ac13b5f](https://github.com/cloud-pi-native/console/commit/ac13b5fca8c952841e162ed300fdce099c74ac9b)) + +## [8.9.0](https://github.com/cloud-pi-native/console/compare/v8.8.2...v8.9.0) (2024-07-17) + + +### Features + +* :rocket: add helm detection and list values files ([006ccba](https://github.com/cloud-pi-native/console/commit/006ccbaa6d4710af8f1495ae8dadccf729b7dcc6)) + +## [8.8.2](https://github.com/cloud-pi-native/console/compare/v8.8.1...v8.8.2) (2024-07-15) + + +### Bug Fixes + +* :art: harmonize hook calling in business ([4effaab](https://github.com/cloud-pi-native/console/commit/4effaab7f9c80561d0fd52e365822d806a5426f5)) +* :bug: don't ignore user token when testing cluster deployment mode ([cc488c3](https://github.com/cloud-pi-native/console/commit/cc488c3053fadffba040f8396eebfa27bf6c1f79)) +* :bug: env name error not displayed ([d2f84e8](https://github.com/cloud-pi-native/console/commit/d2f84e822b94074615df3385f0c088582ceaf3cf)) +* :bug: require tls serverName ([9d4dea2](https://github.com/cloud-pi-native/console/commit/9d4dea22e68aab7ef6ee6b74a16e92a397002709)) + +## [8.8.1](https://github.com/cloud-pi-native/console/compare/v8.8.0...v8.8.1) (2024-07-09) + + +### Bug Fixes + +* :ambulance: not ready for project reponse validation ([c77dd68](https://github.com/cloud-pi-native/console/commit/c77dd6889886b3c867294437e5377573771a7ff0)) +* :ambulance: second try to fix ([1ef6244](https://github.com/cloud-pi-native/console/commit/1ef6244d7536de5704ec6fc9a83b0cfc5d806147)) + +## [8.8.1](https://github.com/cloud-pi-native/console/compare/v8.8.0...v8.8.1) (2024-07-09) + + +### Bug Fixes + +* :ambulance: not ready for project reponse validation ([c77dd68](https://github.com/cloud-pi-native/console/commit/c77dd6889886b3c867294437e5377573771a7ff0)) + +## [8.8.0](https://github.com/cloud-pi-native/console/compare/v8.7.1...v8.8.0) (2024-07-09) + + +### Features + +* :sparkles: query filter admin projects ([feb701d](https://github.com/cloud-pi-native/console/commit/feb701d9d36db0120032f0c1f8888a893c799495)) + +## [8.7.1](https://github.com/cloud-pi-native/console/compare/v8.7.0...v8.7.1) (2024-07-05) + + +### Bug Fixes + +* :bug: insert user when first accessing admin pages ([095b9de](https://github.com/cloud-pi-native/console/commit/095b9de4e282712919e932bf67b15fdaeec96932)) +* :lipstick: repo should be able to change infra, not infra ([1069728](https://github.com/cloud-pi-native/console/commit/10697280499b44b1a1eef28a71d510bfe8916cf3)) + +## [8.7.0](https://github.com/cloud-pi-native/console/compare/v8.6.0...v8.7.0) (2024-07-03) + + +### Features + +* :necktie: hide private quotas for all on environment form ([ef08f29](https://github.com/cloud-pi-native/console/commit/ef08f29080d2be32236839be1e402fe76f44f465)) + + +### Bug Fixes + +* :lock: serialize sevice monitor response ([26ee98f](https://github.com/cloud-pi-native/console/commit/26ee98fa21984e57eaae8251dc14394f0e94c4b9)) + +## [8.6.0](https://github.com/cloud-pi-native/console/compare/v8.5.0...v8.6.0) (2024-06-28) + + +### Features + +* :art: change subpath of deployments ([d7b57ca](https://github.com/cloud-pi-native/console/commit/d7b57ca095bcb1ae8fcde5ead7a533b0f0ce81e5)) +* :sparkles: add user leo poumailloux ([0147e07](https://github.com/cloud-pi-native/console/commit/0147e071bbdfbb8c666d20247bd95938f645f152)) +* :sparkles: give project ownership ([dc6fa79](https://github.com/cloud-pi-native/console/commit/dc6fa798fe2fe37395c521482bbc857eb74b50d1)) +* :sparkles: truncate description ([8191a41](https://github.com/cloud-pi-native/console/commit/8191a41ae808980d400f1e65499e012d0d192c28)) + + +### Bug Fixes + +* :bug: handle vault ha for status monitor ([3692e01](https://github.com/cloud-pi-native/console/commit/3692e010d9d4c0c03e8403b4d5df3591f0312209)) +* :bug: interface suppression & add explication ([4476c9b](https://github.com/cloud-pi-native/console/commit/4476c9bacdcc748ca0d2d1029f8980392a2ea068)) +* :bug: isolation des groupes d'envs dans keycloak ([91b5d55](https://github.com/cloud-pi-native/console/commit/91b5d551a88f5059868a80271be5b37c451839d0)) +* :bug: prevent env deletion if project is locked ([d75a5cf](https://github.com/cloud-pi-native/console/commit/d75a5cfc4e86741a91ba31d0c2d2fc2b21fa5226)) +* :bug: prevent env deletion if project is locked ([a273869](https://github.com/cloud-pi-native/console/commit/a273869ba421ee37fc342c585c38c978aab5d411)) +* :bug: support slashes in syncRepository branchName ([d304427](https://github.com/cloud-pi-native/console/commit/d304427c8bd0af8e7080ec9480143d0abab3bb76)) + +## [8.5.0](https://github.com/cloud-pi-native/console/compare/v8.4.1...v8.5.0) (2024-06-10) + + +### Features + +* :sparkles: add text filter for admin user list ([d44f142](https://github.com/cloud-pi-native/console/commit/d44f142e9f559f7714e3eb71650c1dd3c8a325cd)) +* :sparkles: allow harbor quota limit ([9644d03](https://github.com/cloud-pi-native/console/commit/9644d037ea2642ba9e456e362c18a1eed520b66b)) +* :sparkles: argocd extra repositories ([303f4a2](https://github.com/cloud-pi-native/console/commit/303f4a2fac89ce511aeac9cf87dbf6221908e373)) + + +### Bug Fixes + +* :bug: keycloak crashed when more than 10 environments ([f3d4f5d](https://github.com/cloud-pi-native/console/commit/f3d4f5d3cc5b49946cc4e1ab969895229524f335)) +* :bug: manage if no users in project ([cd23a59](https://github.com/cloud-pi-native/console/commit/cd23a5940eddf1d8509bf7594842db5bcc5fa1ae)) +* :bug: why is it useful ? ([ee2bf5c](https://github.com/cloud-pi-native/console/commit/ee2bf5ce588a92b303b3af8db2bf24ed999822eb)) + +## [8.4.1](https://github.com/cloud-pi-native/console/compare/v8.4.0...v8.4.1) (2024-05-31) + + +### Bug Fixes + +* :bug: add try catch to parsing plugin error ([b958529](https://github.com/cloud-pi-native/console/commit/b95852942863f75eda7458e7593e09579c7811d5)) + +## [8.4.0](https://github.com/cloud-pi-native/console/compare/v8.3.0...v8.4.0) (2024-05-29) + + +### Features + +* :sparkles: create a user usable robot for harbor ([a71dfc9](https://github.com/cloud-pi-native/console/commit/a71dfc992b62a6c3c69dcc6d7de98b8d2d72a79c)) +* :sparkles: customize keycloak theme ([7c21375](https://github.com/cloud-pi-native/console/commit/7c21375d1ad56a03f84c25d04cbe0ada7eac8d5c)) +* :sparkles: option for gitlab display secret ([28d3ebc](https://github.com/cloud-pi-native/console/commit/28d3ebce4d6912904e1f21cd3c126ca342fc3cfb)) + + +### Bug Fixes + +* :art: squash const ([cd1be8c](https://github.com/cloud-pi-native/console/commit/cd1be8c8f282be045ffce473387b21ed95355f5e)) +* :bug: fallback if no owners are found ([629ef40](https://github.com/cloud-pi-native/console/commit/629ef40afb3c6ff0a22b0f17c2c86a343463a8ba)) +* :bug: missing await and ambiguous message ([0530b45](https://github.com/cloud-pi-native/console/commit/0530b4520a5bc05d6a45f9f76029c898f960ee34)) +* :bug: update cluster logic ([65ba7e1](https://github.com/cloud-pi-native/console/commit/65ba7e10f0d7dd9e1675da633c922fff8499ee95)) + +## [8.3.0](https://github.com/cloud-pi-native/console/compare/v8.2.2...v8.3.0) (2024-05-16) + + +### Features + +* :sparkles: handle keycloak admin group membership from console ([4741878](https://github.com/cloud-pi-native/console/commit/4741878fdfa4d784ed2e7695407ebce6b00b4850)) + + +### Bug Fixes + +* :ambulance: possible missed clusters in payload ([47efcc8](https://github.com/cloud-pi-native/console/commit/47efcc893f272823b5d7d7f4b78b9c29ddad9095)) +* :ambulance: unhandled promise gitlab ([592035c](https://github.com/cloud-pi-native/console/commit/592035c95605f004e62cba71faff36ea189d6173)) + +## [8.2.2](https://github.com/cloud-pi-native/console/compare/v8.2.1...v8.2.2) (2024-05-13) + + +### Bug Fixes + +* :bug: fix update cluster ([f02958c](https://github.com/cloud-pi-native/console/commit/f02958ca3888fdb452a545fd94b8d20f9ebb93ed)) +* :bug: stores should have unique names ([6e8032a](https://github.com/cloud-pi-native/console/commit/6e8032a68b71d7ea86b8153d0984013e5bd4c8df)) + +## [8.2.1](https://github.com/cloud-pi-native/console/compare/v8.2.0...v8.2.1) (2024-05-06) + + +### Bug Fixes + +* :fire: remove unjustified refine ([9b1d41e](https://github.com/cloud-pi-native/console/commit/9b1d41ec402446678518bb39c0f3cff543afceb3)) +* :mute: remove problematic log ([639e982](https://github.com/cloud-pi-native/console/commit/639e982df49ccb5888adb26f3a0b1ca62fe53973)) + +## [8.2.0](https://github.com/cloud-pi-native/console/compare/v8.1.1...v8.2.0) (2024-05-03) + + +### Features + +* :sparkles: adding the value file commit ([6ea872b](https://github.com/cloud-pi-native/console/commit/6ea872b498e658113983cdc2b80d9d2ca3cf770d)) +* :sparkles: can configure plugins globally or by project ([15b66c1](https://github.com/cloud-pi-native/console/commit/15b66c1be31311e3b6062c3d771fb3ecc3dcbfa8)) + + +### Bug Fixes + +* :bug: override plugins unzip if already exists ([ff65133](https://github.com/cloud-pi-native/console/commit/ff651332fcee6ae752bdf4533ebbb88f216bfe95)) + +## [8.1.1](https://github.com/cloud-pi-native/console/compare/v8.1.0...v8.1.1) (2024-04-24) + + +### Bug Fixes + +* :ambulance: correct sql query for zoneId ([c47dae4](https://github.com/cloud-pi-native/console/commit/c47dae4a4f74cd51e6c9fe19856d4b8eea08cbb8)) + +## [8.1.0](https://github.com/cloud-pi-native/console/compare/v8.0.3...v8.1.0) (2024-04-24) + + +### Features + +* :safety_vest: allow unlogged users to access services health ([e2fc942](https://github.com/cloud-pi-native/console/commit/e2fc9423b107f1afd9fdbe1b017b7dd1f3d2eab1)) +* :safety_vest: check existing zone slug before creating one ([072fffb](https://github.com/cloud-pi-native/console/commit/072fffbd1d4788329fb76b927242854feb25d31b)) +* :sparkles: add zones ([53d155f](https://github.com/cloud-pi-native/console/commit/53d155f244a846e0cf23d3cf75528e6d937fe0ce)) +* :sparkles: allow repo sync from console ui ([1cd1c93](https://github.com/cloud-pi-native/console/commit/1cd1c93a9e1d95663e51f4de24a5b518fa2bee1a)) +* :sparkles: display project services in admin view ([a5d53fd](https://github.com/cloud-pi-native/console/commit/a5d53fd586dd912b13b72196ed99c2354c022448)) + + +### Bug Fixes + +* :bug: app version in client footer ([ac2f26e](https://github.com/cloud-pi-native/console/commit/ac2f26e88ca3fa593668d4f9641465b09855c663)) +* :bug: get only client app version from env on production build ([3bb3017](https://github.com/cloud-pi-native/console/commit/3bb3017f968e8e93b955b33cb494d51ecce5521a)) +* :bug: handle stage and zone changes in environment form ([5aa0a58](https://github.com/cloud-pi-native/console/commit/5aa0a585e9903e0a29f7bec29e4a08090109542b)) + +## [8.0.3](https://github.com/cloud-pi-native/console/compare/v8.0.2...v8.0.3) (2024-04-16) + + +### Bug Fixes + +* :ambulance: gitlab secret ([0c03a95](https://github.com/cloud-pi-native/console/commit/0c03a9551377705fced2dedf1b9e8063df9d2748)) + +## [8.0.2](https://github.com/cloud-pi-native/console/compare/v8.0.1...v8.0.2) (2024-04-11) + + +### Bug Fixes + +* :lock: remove old robot permission that harbor does not support anymore ([1d988ce](https://github.com/cloud-pi-native/console/commit/1d988ce86b6b338a576d517816f013295b2f18d3)) + +## [8.0.1](https://github.com/cloud-pi-native/console/compare/v8.0.0...v8.0.1) (2024-04-04) + + +### Bug Fixes + +* :ambulance: change kubernetes logic ([3679c22](https://github.com/cloud-pi-native/console/commit/3679c2221c961b4887e1191229edaacd6e34649b)) + +## [8.0.0](https://github.com/cloud-pi-native/console/compare/v7.0.1...v8.0.0) (2024-04-02) + + +### ⚠ BREAKING CHANGES + +* :sparkles: simplify hooks for idempotency + +### Features + +* :safety_vest: allow + sign for repo externalUserName ([4a695ef](https://github.com/cloud-pi-native/console/commit/4a695eff71ea6a03f839d19d2e2526180ea5161b)) +* :sparkles: add replay hooks button for project ([9147231](https://github.com/cloud-pi-native/console/commit/91472317ebd112b4f360d859d25d8793c89eb331)) +* :sparkles: add sonar properties var file to gitlab ([0462a36](https://github.com/cloud-pi-native/console/commit/0462a364b54e66639ade6997f6f4c48e9fe305c6)) +* :sparkles: simplify hooks for idempotency ([aac0a2d](https://github.com/cloud-pi-native/console/commit/aac0a2d331acd4e86531591de99cc944584f7684)) +* 🚧 création du repo infra-apps à la création du projet ([17007f7](https://github.com/cloud-pi-native/console/commit/17007f7eb0bb8c9ba612cf70536236b428b801da)) + + +### Bug Fixes + +* :ambulance: gitlab plugin deleted public repo secret ([46002b0](https://github.com/cloud-pi-native/console/commit/46002b007e9dfd69ce5daeee33cfd61164e0ed8e)) +* :ambulance: gitlab plugin deleted public repo secret ([353f43e](https://github.com/cloud-pi-native/console/commit/353f43eba74d5d0b0a95ace2ee65d9c51c0685b7)) +* :bug: reload user projects after hooks replay ([ae57ed0](https://github.com/cloud-pi-native/console/commit/ae57ed017dac4e5601cdf80a02598967bf75364b)) +* :lipstick: improve logs ui ([ec6ecb2](https://github.com/cloud-pi-native/console/commit/ec6ecb283f0c7c37589c02e194f1b101e9dc10c2)) +* :zap: manage hook concurrency execution ([6af135f](https://github.com/cloud-pi-native/console/commit/6af135f0548d232ef5bd190375057da0968d8f94)) + +## [7.0.1](https://github.com/cloud-pi-native/console/compare/v7.0.0...v7.0.1) (2024-03-18) + + +### Bug Fixes + +* :bug: add a common error parser for logs ([2391439](https://github.com/cloud-pi-native/console/commit/2391439ef6147119863278bd69ac01797b368f76)) +* :bug: add a common error parser for logs ([b6f28d7](https://github.com/cloud-pi-native/console/commit/b6f28d738c03c3c023d16cf658de8cdc384aa6df)) +* :bug: correctly handle external plugins init ([91e5d7f](https://github.com/cloud-pi-native/console/commit/91e5d7f27f90c1e249ed9e295b05400f06565c2d)) +* :green_heart: fix ci-cd build and release ([a036562](https://github.com/cloud-pi-native/console/commit/a036562f005ba0550c4bf25248227aee02357371)) + +## [7.0.0](https://github.com/cloud-pi-native/console/compare/console-v6.5.1...console-v7.0.0) (2024-03-13) + + +### ⚠ BREAKING CHANGES + +* :art: split plugins +* :boom: environment management + +### Features + +* :alien: synch controllers with plugin calls ([d0a271d](https://github.com/cloud-pi-native/console/commit/d0a271d188840d8682b6abe412ee8da4db7992b1)) +* :boom: environment management ([b4f3793](https://github.com/cloud-pi-native/console/commit/b4f379315803aee1264192c16851af049fa661ee)) +* :children_crossing: add confirm box for updating an organization ([5c3cd20](https://github.com/cloud-pi-native/console/commit/5c3cd207221a22ed5d93bdf8638b23c9849897f6)) +* :children_crossing: add default branch to sync in mirror pipeline ([45dbc01](https://github.com/cloud-pi-native/console/commit/45dbc01c4a95d04a201b2c5e198e3b192e1e5fe2)) +* :children_crossing: add filtering on admin projects view ([dc5412c](https://github.com/cloud-pi-native/console/commit/dc5412c1a80ce84cd563376a0b7f72da7926a858)) +* :children_crossing: better ux for forms in tiles ([da68243](https://github.com/cloud-pi-native/console/commit/da68243a91865732db46b9ae610a82eeec5c9bf2)) +* :children_crossing: improve home page and update doc link ([038d982](https://github.com/cloud-pi-native/console/commit/038d982ead7bbfa70debdf902d5c46e8518c1e60)) +* :children_crossing: inform users that clipboard works only with https ([909c3b6](https://github.com/cloud-pi-native/console/commit/909c3b672bcae1401d2133c8fff94cdca91f61bc)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :iphone: more responsive sideMenu and permissionForm ([c840306](https://github.com/cloud-pi-native/console/commit/c8403067224b5ad90e34b8233a1830ceeec8b715)) +* :loud_sound: add payload and full hook response in validation hooks ([adabb02](https://github.com/cloud-pi-native/console/commit/adabb02fd0b8028b1fcdf1113744234a925d72ce)) +* :loud_sound: add reqId in db logs ([2b7f22b](https://github.com/cloud-pi-native/console/commit/2b7f22b7e24748dc37c8623bbda3e914ed7cae00)) +* :necktie: do not fail and lock project for membership reasons ([91038d5](https://github.com/cloud-pi-native/console/commit/91038d5ed3533e4d4eacb8eac3539f29a60ab091)) +* :passport_control: update harbor project member role ([4419dd5](https://github.com/cloud-pi-native/console/commit/4419dd5f395f79a9eb99e8c0f305a7433ad84f7e)) +* :safety_vest: update password character support ([c82f35f](https://github.com/cloud-pi-native/console/commit/c82f35fb7f20ad06c0a63eb518407d17ddb88b51)) +* :sparkles: add backoffice for admins ([fadfdfc](https://github.com/cloud-pi-native/console/commit/fadfdfc43c427261adc3e31dbb1907d6dcefc5cb)) +* :sparkles: add infos key for cluster ([25ab8b8](https://github.com/cloud-pi-native/console/commit/25ab8b8d821c37f4491bd8fea075bdc7fbfcfc93)) +* :sparkles: add or remove a team member via keycloak ([0563d20](https://github.com/cloud-pi-native/console/commit/0563d20ca93a25decc7dbb5d945e2b738b0d575a)) +* :sparkles: add project environments to hook payload ([7aa8117](https://github.com/cloud-pi-native/console/commit/7aa81179ac95afdddf711e1a0a4b3c3a45a35c5a)) +* :sparkles: add quota and stage management for admin ([80ce642](https://github.com/cloud-pi-native/console/commit/80ce642b69a5eaaf1b53c7d884b70889c4e71d9d)) +* :sparkles: add quotas selection for environment ([bb97f5f](https://github.com/cloud-pi-native/console/commit/bb97f5f17c5e7b497eccb655a6eb5da692f7df73)) +* :sparkles: add stage in setPermission payload ([e920805](https://github.com/cloud-pi-native/console/commit/e920805a3181ae98bf98b774d19e02d1e8a2139c)) +* :sparkles: add stage management for admin ([e133853](https://github.com/cloud-pi-native/console/commit/e13385347610014a4edcc0b2ff12252386328cb8)) +* :sparkles: add tlsServerName input in cluster form ([4bdc01e](https://github.com/cloud-pi-native/console/commit/4bdc01ef9dd5b2041a34491e46b9759e5c6652f0)) +* :sparkles: better integration of sonarqube ([c0a0517](https://github.com/cloud-pi-native/console/commit/c0a05173a158b74d752aafaccdff58fc3b29c382)) +* :sparkles: delete cluster if no environment suscribed ([8edc50a](https://github.com/cloud-pi-native/console/commit/8edc50a85f266f1135b340d6df39a48ea7ac746b)) +* :sparkles: disallow hyphen on project, environment and stage ([f9b05ee](https://github.com/cloud-pi-native/console/commit/f9b05eefee41f7d514d749a0ac3fa1c1d063f051)) +* :sparkles: display cluster infos to users on environment form ([4afdf34](https://github.com/cloud-pi-native/console/commit/4afdf34fc40df7789a00fe6d159ee73370b94cb9)) +* :sparkles: download projects data button for admins ([cb38e75](https://github.com/cloud-pi-native/console/commit/cb38e7573314ba0980c4161d1a77a9e83fddb236)) +* :sparkles: enable admin cluster update ([f81008e](https://github.com/cloud-pi-native/console/commit/f81008e97c25bc73466b6257bd4cb2dc0bde9163)) +* :sparkles: handle gitlab membership ([7ff68bd](https://github.com/cloud-pi-native/console/commit/7ff68bdf8f9a16562cfc00fbdb0c322b0f09943d)) +* :sparkles: lock and unlock projects according to their org status ([3b9444a](https://github.com/cloud-pi-native/console/commit/3b9444aeaa2fab76461946e9a4d00e8afe4a62f1)) +* :sparkles: retrieve user from kc if does not exist in db ([3d4461a](https://github.com/cloud-pi-native/console/commit/3d4461ae7d82e6f64a1644921893d89f41e9a2d9)) +* :sparkles: services secrets improvement ([40e3ddd](https://github.com/cloud-pi-native/console/commit/40e3ddd2acd7e000b03f5bfa5663533449d315bf)) +* :sparkles: to fn, more infos, multi tiles ([8c4de83](https://github.com/cloud-pi-native/console/commit/8c4de83cf3b4bc0e335292d437ddf7d22305ff93)) +* :speech_balloon: translate stage in front for better understanding ([5a66479](https://github.com/cloud-pi-native/console/commit/5a6647980319524098a72fcbd2be6affbda3f3d0)) +* :technologist: add images auto-scan to harbor settings ([507c6e6](https://github.com/cloud-pi-native/console/commit/507c6e6a541b19502084276db30cf834e586d955)) +* :technologist: add swagger for server api ([abe15d2](https://github.com/cloud-pi-native/console/commit/abe15d2ceb93039ecd795389156f8730e5327841)) + + +### Bug Fixes + +* :alien: add owner in archiveProject hookpayload ([5c99183](https://github.com/cloud-pi-native/console/commit/5c99183f03afe0c20980c9649ce37788b833a351)) +* :alien: change logo for svg ([aa8315b](https://github.com/cloud-pi-native/console/commit/aa8315b63617c8413a448499e2ab0e68c216faef)) +* :alien: fetch does not support proxy natively ([20d531d](https://github.com/cloud-pi-native/console/commit/20d531d622e6fe9e2869e13db0d205b44ad602ce)) +* :alien: sync controllers require bigger axios timeout ([5116bbd](https://github.com/cloud-pi-native/console/commit/5116bbdb8f49b73c920ac04421ca05ccabef902c)) +* :ambulance: bad logic register plugins ([049040e](https://github.com/cloud-pi-native/console/commit/049040eafda26214afbc2118cb56542b133feca5)) +* :ambulance: preserve status colum in project ([febfaff](https://github.com/cloud-pi-native/console/commit/febfaff702d6a55210723ce213d603ad059ceaed)) +* :ambulance: register cluster destination via kubeconfig ([0e8d310](https://github.com/cloud-pi-native/console/commit/0e8d3102267268c595d172808e30183dbe4b3aad)) +* :ambulance: send internalRepoName to plugins on updateRepository hook ([2bde004](https://github.com/cloud-pi-native/console/commit/2bde0045592f2898975633c0c6f959311234f7f3)) +* :art: can disble plugins by name, and some fixes ([f2bd397](https://github.com/cloud-pi-native/console/commit/f2bd397e0526d76d439ef8f38b5dcebdfbaa8ede)) +* :art: refactor of admin project types and refs ([be0684f](https://github.com/cloud-pi-native/console/commit/be0684f378696a24304d7abecd6b90c0738e1785)) +* :bento: update MultiSelector component ([9a44bb3](https://github.com/cloud-pi-native/console/commit/9a44bb35d8aa0da1aaf8f96a745c28f97f6ea53c)) +* :bug: add conditional chaining to avoid error in dsobadge ([56ad040](https://github.com/cloud-pi-native/console/commit/56ad0403242222f50ef8abe898b46c241d40b66d)) +* :bug: add missing await in gitlab createProjectMirror ([f1bf907](https://github.com/cloud-pi-native/console/commit/f1bf9072b88abec3f08654720f64571aa421db34)) +* :bug: change appproject policy ([e20b6c4](https://github.com/cloud-pi-native/console/commit/e20b6c431348b08bed7ea215a967c51d0e6187f0)) +* :bug: clusters related bugs ([c2ba63e](https://github.com/cloud-pi-native/console/commit/c2ba63e94d87bdf1741e9a51e4522c3f77b354de)) +* :bug: create appropriate folders for server image ([3027fef](https://github.com/cloud-pi-native/console/commit/3027fef08ebc8a58e2e705645febaf40af7314f5)) +* :bug: filter archived project from cluster form ([50913f5](https://github.com/cloud-pi-native/console/commit/50913f57def7d8e3e36f63a5876e476e64884828)) +* :bug: fix endless redirect on login if authenticated ([461c6bd](https://github.com/cloud-pi-native/console/commit/461c6bd385f44a551fe9af1027c6beca1e7fa9ec)) +* :bug: fix gitlab group deletion ([12a35d0](https://github.com/cloud-pi-native/console/commit/12a35d08619fbeb231d4e7931fb7fdb51ef63fde)) +* :bug: fix missing extensions ([4cd4b21](https://github.com/cloud-pi-native/console/commit/4cd4b212e88d30b447d5674cf157ce71862fe14a)) +* :bug: fix not updated argocd cluster and not using insecure in config ([69e7a9a](https://github.com/cloud-pi-native/console/commit/69e7a9ac7a584d76ce047b8330356ce2387218d7)) +* :bug: fix types, add void and handle it ([110cfed](https://github.com/cloud-pi-native/console/commit/110cfedfcb2a58757b5ad47f96f6323cbe034550)) +* :bug: import new shared package ([ff0b0db](https://github.com/cloud-pi-native/console/commit/ff0b0db9dcc96c10b409b51bd4180c03b5b01478)) +* :bug: launch e2e test in open mode without turbo ([d3497d1](https://github.com/cloud-pi-native/console/commit/d3497d13e3862d7701cbbd2da65cb7a571032eb2)) +* :bug: manage cancel emit on update repo form ([16df7b4](https://github.com/cloud-pi-native/console/commit/16df7b43d584fb1edfa5a01938356b04b785429b)) +* :bug: missed limits ([f2ba02d](https://github.com/cloud-pi-native/console/commit/f2ba02d41e923b0add154fc7cb5ae7d8a2c89370)) +* :bug: missing check in search group ([7fb65c1](https://github.com/cloud-pi-native/console/commit/7fb65c12754eea955c7ba942329cf5bfa612f8b7)) +* :bug: missing check nexus find user ([993dd2a](https://github.com/cloud-pi-native/console/commit/993dd2ab30687d0ea23000596f909b77b3be70e5)) +* :bug: missing check nexus find user ([e0f1674](https://github.com/cloud-pi-native/console/commit/e0f167424df8e0c01619a0da38bf3e5723014ee1)) +* :bug: now the admin can chosse skip tls api kube ([e09aa2a](https://github.com/cloud-pi-native/console/commit/e09aa2ac659e1439f59050c56256f530b9f8cae6)) +* :bug: optional paramater to call repeatFn ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: pin pnpm version in dockerfiles ([b494a2f](https://github.com/cloud-pi-native/console/commit/b494a2f0d190e3da81f9765dded76187333e61af)) +* :bug: pnpm lock ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: retrieve kc user bug fix ([aaf61c5](https://github.com/cloud-pi-native/console/commit/aaf61c51a288cd7998a5d3883cf467471b42c3cb)) +* :bug: run.sh js to ts reference ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: send cluster kubeconfig in hook paylaod ([e21c692](https://github.com/cloud-pi-native/console/commit/e21c6923d016bb59a69be457d5d4efc08243a771)) +* :bug: share loadingct display knowledge ([8520a5c](https://github.com/cloud-pi-native/console/commit/8520a5c827d05316e9dedc59dd90a92fb3f513d4)) +* :bug: should not set caData if skipTLSVerify is true ([ccd66f8](https://github.com/cloud-pi-native/console/commit/ccd66f83be75850ffe47e87ff405fcf9cf136e3c)) +* :bug: update compile cmd to build in plugins pkgs ([b9e4424](https://github.com/cloud-pi-native/console/commit/b9e44246c22238c3cdb383515816951ace4f65c2)) +* :bug: use argoCD regex for internalRepoName ([6c2ed3f](https://github.com/cloud-pi-native/console/commit/6c2ed3f682514b947e66547ce136d148b9e2d686)) +* :bug: various fixes around plugins and controllers ([6271c67](https://github.com/cloud-pi-native/console/commit/6271c67f3d8471ee6afc3409c5d753f7ade950f0)) +* :card_file_box: set all project status to failed for v6 release ([b689c35](https://github.com/cloud-pi-native/console/commit/b689c356f27e769b18aabab2f2510dd24354d95f)) +* :construction: fix configs ([ff77d7a](https://github.com/cloud-pi-native/console/commit/ff77d7ab10aecdf92643f9bcece9ef3360bc4c46)) +* :goal_net: add try catch in checkApi func ([baf38af](https://github.com/cloud-pi-native/console/commit/baf38af3f96fe227a934228d7875181e1407c8e9)) +* :goal_net: allow deleting environment if not found ([a2dc7da](https://github.com/cloud-pi-native/console/commit/a2dc7daf96942121e9291063569ff9c6612bd345)) +* :goal_net: throw error if catched in business ([1790ec2](https://github.com/cloud-pi-native/console/commit/1790ec274e965d5d62a0693937eec5ee765602e2)) +* :goal_net: validate schema before creating repo or env ([23535c3](https://github.com/cloud-pi-native/console/commit/23535c3e0c9923df0cc8adb5fb646dc2dd21fb7c)) +* :heavy_plus_sign: fix pnpm-lock file ([93aa64e](https://github.com/cloud-pi-native/console/commit/93aa64e146e11f30c8305f85d7ff03b696b0297b)) +* :label: add missing fastify keycloak adapter patch to improve types ([2d4ca1f](https://github.com/cloud-pi-native/console/commit/2d4ca1fc64c374cd06cef5b27842502db59a0c1c)) +* :lipstick: add icons ([ed1887b](https://github.com/cloud-pi-native/console/commit/ed1887baf6a65d0d58c6e399991a1419351c3883)) +* :lipstick: fix padding ([907d424](https://github.com/cloud-pi-native/console/commit/907d424a94f2dfc7e7110882438130bdb5dc4cd8)) +* :lipstick: restyle logs interface ([fc362cf](https://github.com/cloud-pi-native/console/commit/fc362cf461c65f6e4e8e7b6b2d92c83a41844bdf)) +* :lock: root token is not used in new repo credential when synced ([c92ea8b](https://github.com/cloud-pi-native/console/commit/c92ea8b5ef95097b07c2bbda435ce433f5bba53d)) +* :pushpin: pin axios version to avoid vite error ([88c6d30](https://github.com/cloud-pi-native/console/commit/88c6d30b5ab14cd9ca899fdf7cfcd17faa067903)) +* :rewind: fix bugs introduced during my vacations ([9ee467c](https://github.com/cloud-pi-native/console/commit/9ee467c69b590a4630dae19f42f2fc5c9321953e)) +* :safety_vest: validate user schema before trying to insert it in db ([ab8e6ef](https://github.com/cloud-pi-native/console/commit/ab8e6efdb00ec29c6a9a1e0f89c58ffbc999c966)) +* :technologist: change domains for dev and int ([0de00ab](https://github.com/cloud-pi-native/console/commit/0de00abf78543bc769f80be2f0e59795cfefefa5)) +* :technologist: fix docker volumes for integ mode ([80af072](https://github.com/cloud-pi-native/console/commit/80af072bfc0affa8ab0b415a3f6d63d17ceaf2c2)) +* :technologist: fix import kubeconfig in integration ([51af3dd](https://github.com/cloud-pi-native/console/commit/51af3dd16128a6d4a1e676921937005e295acfc9)) +* :test_tube: trying to fix ct test ([c67366e](https://github.com/cloud-pi-native/console/commit/c67366e0b319de7edd681b9ba9265091be5c9e32)) +* :test_tube: trying to optimize dependancies for failing modules ([ec97602](https://github.com/cloud-pi-native/console/commit/ec9760236a380efdcabc5bc0f0cb7e9b23c07adb)) +* :wrench: handle no values for disabledPlugins ([d0d46a3](https://github.com/cloud-pi-native/console/commit/d0d46a38570db6f626f5879da180829216826fa8)) + + +### Performance Improvements + +* :construction: skip failing test for merge ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) + + +### Reverts + +* :card_file_box: add missing default for projectStatus ([2263988](https://github.com/cloud-pi-native/console/commit/22639888442da48b097f936d0afb62652d262b02)) +* :card_file_box: add missing default for projectStatus ([5653165](https://github.com/cloud-pi-native/console/commit/565316504a1baa90d2c7c999c0ba6810151324f5)) +* :poop: comment harbor projet member role update ([63926ae](https://github.com/cloud-pi-native/console/commit/63926ae9c5a5fe6b9e6e8a2d0007f55d186b0f36)) + + +### Build System + +* :art: split plugins ([f4ac305](https://github.com/cloud-pi-native/console/commit/f4ac30587917b78d6d0f3e58cdda1b8a08123b48)) + +## [6.5.2](https://github.com/cloud-pi-native/console/compare/v6.5.1...v6.5.2) (2024-03-13) + + +### Bug Fixes + +* :bug: missing check in search group ([7fb65c1](https://github.com/cloud-pi-native/console/commit/7fb65c12754eea955c7ba942329cf5bfa612f8b7)) +* :bug: missing check nexus find user ([e0f1674](https://github.com/cloud-pi-native/console/commit/e0f167424df8e0c01619a0da38bf3e5723014ee1)) + +## [6.5.1](https://github.com/cloud-pi-native/console/compare/v6.5.0...v6.5.1) (2024-01-25) + + +### Bug Fixes + +* :bug: share loadingct display knowledge ([8520a5c](https://github.com/cloud-pi-native/console/commit/8520a5c827d05316e9dedc59dd90a92fb3f513d4)) + +## [6.5.0](https://github.com/cloud-pi-native/console/compare/v6.4.0...v6.5.0) (2024-01-25) + + +### Features + +* :children_crossing: better ux for forms in tiles ([da68243](https://github.com/cloud-pi-native/console/commit/da68243a91865732db46b9ae610a82eeec5c9bf2)) +* :loud_sound: add payload and full hook response in validation hooks ([adabb02](https://github.com/cloud-pi-native/console/commit/adabb02fd0b8028b1fcdf1113744234a925d72ce)) +* :loud_sound: add reqId in db logs ([2b7f22b](https://github.com/cloud-pi-native/console/commit/2b7f22b7e24748dc37c8623bbda3e914ed7cae00)) +* :sparkles: add project environments to hook payload ([7aa8117](https://github.com/cloud-pi-native/console/commit/7aa81179ac95afdddf711e1a0a4b3c3a45a35c5a)) +* :sparkles: disallow hyphen on project, environment and stage ([f9b05ee](https://github.com/cloud-pi-native/console/commit/f9b05eefee41f7d514d749a0ac3fa1c1d063f051)) +* :sparkles: download projects data button for admins ([cb38e75](https://github.com/cloud-pi-native/console/commit/cb38e7573314ba0980c4161d1a77a9e83fddb236)) +* :sparkles: to fn, more infos, multi tiles ([8c4de83](https://github.com/cloud-pi-native/console/commit/8c4de83cf3b4bc0e335292d437ddf7d22305ff93)) + + +### Bug Fixes + +* :goal_net: validate schema before creating repo or env ([23535c3](https://github.com/cloud-pi-native/console/commit/23535c3e0c9923df0cc8adb5fb646dc2dd21fb7c)) +* :pushpin: pin axios version to avoid vite error ([88c6d30](https://github.com/cloud-pi-native/console/commit/88c6d30b5ab14cd9ca899fdf7cfcd17faa067903)) + +## [6.4.0](https://github.com/cloud-pi-native/console/compare/v6.3.1...v6.4.0) (2023-12-20) + + +### Features + +* :children_crossing: add filtering on admin projects view ([dc5412c](https://github.com/cloud-pi-native/console/commit/dc5412c1a80ce84cd563376a0b7f72da7926a858)) +* :sparkles: display cluster infos to users on environment form ([4afdf34](https://github.com/cloud-pi-native/console/commit/4afdf34fc40df7789a00fe6d159ee73370b94cb9)) +* :speech_balloon: translate stage in front for better understanding ([5a66479](https://github.com/cloud-pi-native/console/commit/5a6647980319524098a72fcbd2be6affbda3f3d0)) + + +### Bug Fixes + +* :art: refactor of admin project types and refs ([be0684f](https://github.com/cloud-pi-native/console/commit/be0684f378696a24304d7abecd6b90c0738e1785)) + +## [6.3.1](https://github.com/cloud-pi-native/console/compare/v6.3.0...v6.3.1) (2023-12-12) + + +### Bug Fixes + +* :bug: missed limits ([f2ba02d](https://github.com/cloud-pi-native/console/commit/f2ba02d41e923b0add154fc7cb5ae7d8a2c89370)) + +## [6.3.0](https://github.com/cloud-pi-native/console/compare/v6.2.0...v6.3.0) (2023-12-11) + + +### Features + +* :sparkles: add backoffice for admins ([fadfdfc](https://github.com/cloud-pi-native/console/commit/fadfdfc43c427261adc3e31dbb1907d6dcefc5cb)) + + +### Bug Fixes + +* :bug: fix gitlab group deletion ([12a35d0](https://github.com/cloud-pi-native/console/commit/12a35d08619fbeb231d4e7931fb7fdb51ef63fde)) + +## [6.2.0](https://github.com/cloud-pi-native/console/compare/v6.1.0...v6.2.0) (2023-11-20) + + +### Features + +* :sparkles: delete cluster if no environment suscribed ([8edc50a](https://github.com/cloud-pi-native/console/commit/8edc50a85f266f1135b340d6df39a48ea7ac746b)) + + +### Bug Fixes + +* :alien: add owner in archiveProject hookpayload ([5c99183](https://github.com/cloud-pi-native/console/commit/5c99183f03afe0c20980c9649ce37788b833a351)) +* :bug: use argoCD regex for internalRepoName ([6c2ed3f](https://github.com/cloud-pi-native/console/commit/6c2ed3f682514b947e66547ce136d148b9e2d686)) + +## [6.1.0](https://github.com/cloud-pi-native/console/compare/v6.0.0...v6.1.0) (2023-11-13) + + +### Features + +* :sparkles: add quota and stage management for admin ([80ce642](https://github.com/cloud-pi-native/console/commit/80ce642b69a5eaaf1b53c7d884b70889c4e71d9d)) +* :sparkles: add stage management for admin ([e133853](https://github.com/cloud-pi-native/console/commit/e13385347610014a4edcc0b2ff12252386328cb8)) + + +### Bug Fixes + +* :bug: add missing await in gitlab createProjectMirror ([f1bf907](https://github.com/cloud-pi-native/console/commit/f1bf9072b88abec3f08654720f64571aa421db34)) +* :bug: fix endless redirect on login if authenticated ([461c6bd](https://github.com/cloud-pi-native/console/commit/461c6bd385f44a551fe9af1027c6beca1e7fa9ec)) +* :bug: should not set caData if skipTLSVerify is true ([ccd66f8](https://github.com/cloud-pi-native/console/commit/ccd66f83be75850ffe47e87ff405fcf9cf136e3c)) +* :lock: root token is not used in new repo credential when synced ([c92ea8b](https://github.com/cloud-pi-native/console/commit/c92ea8b5ef95097b07c2bbda435ce433f5bba53d)) + +## [6.0.0](https://github.com/cloud-pi-native/console/compare/v5.11.0...v6.0.0) (2023-10-27) + + +### ⚠ BREAKING CHANGES + +* :boom: environment management + +### Features + +* :boom: environment management ([b4f3793](https://github.com/cloud-pi-native/console/commit/b4f379315803aee1264192c16851af049fa661ee)) +* :sparkles: services secrets improvement ([40e3ddd](https://github.com/cloud-pi-native/console/commit/40e3ddd2acd7e000b03f5bfa5663533449d315bf)) + + +### Bug Fixes + +* :alien: change logo for svg ([aa8315b](https://github.com/cloud-pi-native/console/commit/aa8315b63617c8413a448499e2ab0e68c216faef)) +* :bug: add conditional chaining to avoid error in dsobadge ([56ad040](https://github.com/cloud-pi-native/console/commit/56ad0403242222f50ef8abe898b46c241d40b66d)) +* :bug: launch e2e test in open mode without turbo ([d3497d1](https://github.com/cloud-pi-native/console/commit/d3497d13e3862d7701cbbd2da65cb7a571032eb2)) +* :card_file_box: set all project status to failed for v6 release ([b689c35](https://github.com/cloud-pi-native/console/commit/b689c356f27e769b18aabab2f2510dd24354d95f)) + +## [5.11.0](https://github.com/cloud-pi-native/console/compare/v5.10.1...v5.11.0) (2023-10-13) + + +### Features + +* :children_crossing: add default branch to sync in mirror pipeline ([45dbc01](https://github.com/cloud-pi-native/console/commit/45dbc01c4a95d04a201b2c5e198e3b192e1e5fe2)) + + +### Bug Fixes + +* :goal_net: add try catch in checkApi func ([baf38af](https://github.com/cloud-pi-native/console/commit/baf38af3f96fe227a934228d7875181e1407c8e9)) + +## [5.10.1](https://github.com/cloud-pi-native/console/compare/v5.10.0...v5.10.1) (2023-10-10) + +### Bug Fixes + +* :ambulance: send internalRepoName to plugins on updateRepository hook ([2bde004](https://github.com/cloud-pi-native/console/commit/2bde0045592f2898975633c0c6f959311234f7f3)) + +## [5.10.0](https://github.com/cloud-pi-native/console/compare/v5.9.1...v5.10.0) (2023-10-03) + +### Features + +* :iphone: more responsive sideMenu and permissionForm ([c840306](https://github.com/cloud-pi-native/console/commit/c8403067224b5ad90e34b8233a1830ceeec8b715)) +* :sparkles: enable admin cluster update ([f81008e](https://github.com/cloud-pi-native/console/commit/f81008e97c25bc73466b6257bd4cb2dc0bde9163)) + +## [5.9.1](https://github.com/cloud-pi-native/console/compare/v5.9.0...v5.9.1) (2023-09-28) + +### Bug Fixes + +* :bug: fix not updated argocd cluster and not using insecure in config ([69e7a9a](https://github.com/cloud-pi-native/console/commit/69e7a9ac7a584d76ce047b8330356ce2387218d7)) + +## [5.9.0](https://github.com/cloud-pi-native/console/compare/v5.8.2...v5.9.0) (2023-09-25) + +### Features + +* :technologist: add swagger for server api ([abe15d2](https://github.com/cloud-pi-native/console/commit/abe15d2ceb93039ecd795389156f8730e5327841)) + +### Bug Fixes + +* :ambulance: register cluster destination via kubeconfig ([0e8d310](https://github.com/cloud-pi-native/console/commit/0e8d3102267268c595d172808e30183dbe4b3aad)) +* :safety_vest: validate user schema before trying to insert it in db ([ab8e6ef](https://github.com/cloud-pi-native/console/commit/ab8e6efdb00ec29c6a9a1e0f89c58ffbc999c966)) + +## [5.8.2](https://github.com/cloud-pi-native/console/compare/v5.8.1...v5.8.2) (2023-09-20) + +### Bug Fixes + +* :wrench: handle no values for disabledPlugins ([d0d46a3](https://github.com/cloud-pi-native/console/commit/d0d46a38570db6f626f5879da180829216826fa8)) + +## [5.8.1](https://github.com/cloud-pi-native/console/compare/v5.8.0...v5.8.1) (2023-09-18) + +### Bug Fixes + +* :ambulance: bad logic register plugins ([049040e](https://github.com/cloud-pi-native/console/commit/049040eafda26214afbc2118cb56542b133feca5)) + +## [5.8.0](https://github.com/cloud-pi-native/console/compare/v5.7.0...v5.8.0) (2023-09-18) + +### Features + +* :passport_control: update harbor project member role ([4419dd5](https://github.com/cloud-pi-native/console/commit/4419dd5f395f79a9eb99e8c0f305a7433ad84f7e)) +* :sparkles: add infos key for cluster ([25ab8b8](https://github.com/cloud-pi-native/console/commit/25ab8b8d821c37f4491bd8fea075bdc7fbfcfc93)) +* :sparkles: add tlsServerName input in cluster form ([4bdc01e](https://github.com/cloud-pi-native/console/commit/4bdc01ef9dd5b2041a34491e46b9759e5c6652f0)) +* :technologist: add images auto-scan to harbor settings ([507c6e6](https://github.com/cloud-pi-native/console/commit/507c6e6a541b19502084276db30cf834e586d955)) + +### Bug Fixes + +* :alien: fetch does not support proxy natively ([20d531d](https://github.com/cloud-pi-native/console/commit/20d531d622e6fe9e2869e13db0d205b44ad602ce)) +* :art: can disble plugins by name, and some fixes ([f2bd397](https://github.com/cloud-pi-native/console/commit/f2bd397e0526d76d439ef8f38b5dcebdfbaa8ede)) +* :bug: clusters related bugs ([c2ba63e](https://github.com/cloud-pi-native/console/commit/c2ba63e94d87bdf1741e9a51e4522c3f77b354de)) +* :bug: now the admin can chosse skip tls api kube ([e09aa2a](https://github.com/cloud-pi-native/console/commit/e09aa2ac659e1439f59050c56256f530b9f8cae6)) +* :goal_net: throw error if catched in business ([1790ec2](https://github.com/cloud-pi-native/console/commit/1790ec274e965d5d62a0693937eec5ee765602e2)) +* :heavy_plus_sign: fix pnpm-lock file ([93aa64e](https://github.com/cloud-pi-native/console/commit/93aa64e146e11f30c8305f85d7ff03b696b0297b)) + +### Reverts + +* :poop: comment harbor projet member role update ([63926ae](https://github.com/cloud-pi-native/console/commit/63926ae9c5a5fe6b9e6e8a2d0007f55d186b0f36)) + +## [5.7.0](https://github.com/cloud-pi-native/console/compare/v5.6.0...v5.7.0) (2023-09-07) + +### Features + +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :safety_vest: update password character support ([c82f35f](https://github.com/cloud-pi-native/console/commit/c82f35fb7f20ad06c0a63eb518407d17ddb88b51)) +* :sparkles: handle gitlab membership ([7ff68bd](https://github.com/cloud-pi-native/console/commit/7ff68bdf8f9a16562cfc00fbdb0c322b0f09943d)) + +### Bug Fixes + +* :bug: change appproject policy ([e20b6c4](https://github.com/cloud-pi-native/console/commit/e20b6c431348b08bed7ea215a967c51d0e6187f0)) +* :bug: import new shared package ([ff0b0db](https://github.com/cloud-pi-native/console/commit/ff0b0db9dcc96c10b409b51bd4180c03b5b01478)) + +### Reverts + +* :card_file_box: add missing default for projectStatus ([2263988](https://github.com/cloud-pi-native/console/commit/22639888442da48b097f936d0afb62652d262b02)) +* :card_file_box: add missing default for projectStatus ([5653165](https://github.com/cloud-pi-native/console/commit/565316504a1baa90d2c7c999c0ba6810151324f5)) + +## [5.6.0](https://github.com/cloud-pi-native/console/compare/v5.5.0...v5.6.0) (2023-08-28) + +### Features + +* :sparkles: better integration of sonarqube ([c0a0517](https://github.com/cloud-pi-native/console/commit/c0a05173a158b74d752aafaccdff58fc3b29c382)) + +### Bug Fixes + +* :ambulance: preserve status colum in project ([febfaff](https://github.com/cloud-pi-native/console/commit/febfaff702d6a55210723ce213d603ad059ceaed)) +* :bug: optional paramater to call repeatFn ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: pnpm lock ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: run.sh js to ts reference ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :lipstick: fix padding ([907d424](https://github.com/cloud-pi-native/console/commit/907d424a94f2dfc7e7110882438130bdb5dc4cd8)) +* :lipstick: restyle logs interface ([fc362cf](https://github.com/cloud-pi-native/console/commit/fc362cf461c65f6e4e8e7b6b2d92c83a41844bdf)) + +### Performance Improvements + +* :construction: skip failing test for merge ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) + +## [5.5.0](https://github.com/cloud-pi-native/console/compare/v5.4.0...v5.5.0) (2023-08-22) + +### Features + +* :children_crossing: inform users that clipboard works only with https ([909c3b6](https://github.com/cloud-pi-native/console/commit/909c3b672bcae1401d2133c8fff94cdca91f61bc)) +* :sparkles: retrieve user from kc if does not exist in db ([3d4461a](https://github.com/cloud-pi-native/console/commit/3d4461ae7d82e6f64a1644921893d89f41e9a2d9)) + +### Bug Fixes + +* :alien: sync controllers require bigger axios timeout ([5116bbd](https://github.com/cloud-pi-native/console/commit/5116bbdb8f49b73c920ac04421ca05ccabef902c)) +* :bento: update MultiSelector component ([9a44bb3](https://github.com/cloud-pi-native/console/commit/9a44bb35d8aa0da1aaf8f96a745c28f97f6ea53c)) +* :bug: filter archived project from cluster form ([50913f5](https://github.com/cloud-pi-native/console/commit/50913f57def7d8e3e36f63a5876e476e64884828)) +* :bug: fix types, add void and handle it ([110cfed](https://github.com/cloud-pi-native/console/commit/110cfedfcb2a58757b5ad47f96f6323cbe034550)) +* :bug: manage cancel emit on update repo form ([16df7b4](https://github.com/cloud-pi-native/console/commit/16df7b43d584fb1edfa5a01938356b04b785429b)) +* :bug: retrieve kc user bug fix ([aaf61c5](https://github.com/cloud-pi-native/console/commit/aaf61c51a288cd7998a5d3883cf467471b42c3cb)) +* :bug: various fixes around plugins and controllers ([6271c67](https://github.com/cloud-pi-native/console/commit/6271c67f3d8471ee6afc3409c5d753f7ade950f0)) +* :goal_net: allow deleting environment if not found ([a2dc7da](https://github.com/cloud-pi-native/console/commit/a2dc7daf96942121e9291063569ff9c6612bd345)) +* :lipstick: add icons ([ed1887b](https://github.com/cloud-pi-native/console/commit/ed1887baf6a65d0d58c6e399991a1419351c3883)) +* :rewind: fix bugs introduced during my vacations ([9ee467c](https://github.com/cloud-pi-native/console/commit/9ee467c69b590a4630dae19f42f2fc5c9321953e)) +* :technologist: fix import kubeconfig in integration ([51af3dd](https://github.com/cloud-pi-native/console/commit/51af3dd16128a6d4a1e676921937005e295acfc9)) + +## [5.4.0](https://github.com/cloud-pi-native/console/compare/v5.3.0...v5.4.0) (2023-08-02) + +### Features + +* :children_crossing: improve home page and update doc link ([038d982](https://github.com/cloud-pi-native/console/commit/038d982ead7bbfa70debdf902d5c46e8518c1e60)) +* :sparkles: add or remove a team member via keycloak ([0563d20](https://github.com/cloud-pi-native/console/commit/0563d20ca93a25decc7dbb5d945e2b738b0d575a)) + +### Bug Fixes + +* :technologist: change domains for dev and int ([0de00ab](https://github.com/cloud-pi-native/console/commit/0de00abf78543bc769f80be2f0e59795cfefefa5)) + +## [5.3.0](https://github.com/cloud-pi-native/console/compare/v5.2.1...v5.3.0) (2023-08-01) + +### Features + +* :alien: synch controllers with plugin calls ([d0a271d](https://github.com/cloud-pi-native/console/commit/d0a271d188840d8682b6abe412ee8da4db7992b1)) +* :children_crossing: add confirm box for updating an organization ([5c3cd20](https://github.com/cloud-pi-native/console/commit/5c3cd207221a22ed5d93bdf8638b23c9849897f6)) +* :sparkles: lock and unlock projects according to their org status ([3b9444a](https://github.com/cloud-pi-native/console/commit/3b9444aeaa2fab76461946e9a4d00e8afe4a62f1)) + +### Bug Fixes + +* :bug: create appropriate folders for server image ([3027fef](https://github.com/cloud-pi-native/console/commit/3027fef08ebc8a58e2e705645febaf40af7314f5)) +* :bug: fix missing extensions ([4cd4b21](https://github.com/cloud-pi-native/console/commit/4cd4b212e88d30b447d5674cf157ce71862fe14a)) +* :bug: pin pnpm version in dockerfiles ([b494a2f](https://github.com/cloud-pi-native/console/commit/b494a2f0d190e3da81f9765dded76187333e61af)) + +## [5.2.1](https://github.com/cloud-pi-native/console/compare/v5.2.0...v5.2.1) (2023-07-21) + +### Bug Fixes + +* :adhesive_bandage: update kaniko stage name in gitlab ci ([7a71712](https://github.com/cloud-pi-native/console/commit/7a71712075d7aed5aeeb00adb1d2ec51b6d717ab)) + +## [5.2.0](https://github.com/cloud-pi-native/console/compare/v5.1.0...v5.2.0) (2023-07-20) + +### Features + +* :children_crossing: improve gitlab mirroring by using a single mirror repo ([914bbe4](https://github.com/cloud-pi-native/console/commit/914bbe4546a3e876a6cd8847523f18e95d688d44)) +* :sparkles: add id in admin user table ([d1ebcea](https://github.com/cloud-pi-native/console/commit/d1ebcea1768993b6ab31e5f45a6642ac3577cfc5)) + +### Bug Fixes + +* :bug: add missing data to add argo cluster ([fc53f54](https://github.com/cloud-pi-native/console/commit/fc53f54d8f2d211857aa82c43589b9ee26e214b8)) +* :bug: delete kube resources when deleting a project ([5e8cd51](https://github.com/cloud-pi-native/console/commit/5e8cd514edc8a0eb4f5d49208cb17b6da82f4fd7)) +* :bug: fix argo plugin ([b1dc1fb](https://github.com/cloud-pi-native/console/commit/b1dc1fb60ba89b534c3e3cd357b89a4e08b74838)) + +## [5.1.0](https://github.com/cloud-pi-native/console/compare/v5.0.0...v5.1.0) (2023-07-18) + +### Features + +* :alien: add project infos for canel plugin ([a4516a1](https://github.com/cloud-pi-native/console/commit/a4516a14b2439dd3a74540168aee6daa586c7308)) +* :alien: add updateProject hook for description update for example ([7e97505](https://github.com/cloud-pi-native/console/commit/7e97505b5a75c3f6c680d5b1bb6f18a9e45b87b3)) + +### Bug Fixes + +* :bug: fix projectLimit for gitlab plugin ([13fca89](https://github.com/cloud-pi-native/console/commit/13fca89f37a16b980dce7f16e87151510d6f0b63)) +* :bug: missing await before plugins hook and logs recording on archiveProject ([e221341](https://github.com/cloud-pi-native/console/commit/e221341a7db27d67bfa40e4231d47de1e5e8648f)) +* :bug: missing await before plugins hook and logs recording on archiveProject ([2515c62](https://github.com/cloud-pi-native/console/commit/2515c624afd1d89718a98085c5e41c1c996157b8)) + +## [5.0.0](https://github.com/cloud-pi-native/console/compare/v4.1.0...v5.0.0) (2023-07-13) + +### ⚠ BREAKING CHANGES + +* :boom: migrate from sequelize to prisma + +### Features + +* :children_crossing: add organization to differenciate project ([5814d7e](https://github.com/cloud-pi-native/console/commit/5814d7e00b5a6a335a16059b62f7af456d3b4a58)) +* :sparkles: allow updating repo ([27b59cf](https://github.com/cloud-pi-native/console/commit/27b59cfc5cacdf0e96b0fd22b090ce4ab038792d)) +* :sparkles: always show ci form ([753d64b](https://github.com/cloud-pi-native/console/commit/753d64bc011701dcc9297097b1a0aec87cef6c03)) +* :tada: introduce multi-cluster deployment ([0e6c065](https://github.com/cloud-pi-native/console/commit/0e6c065d421c117050e00944ca68114414a05dc7)) +* :technologist: add config for codespace ([1ff7996](https://github.com/cloud-pi-native/console/commit/1ff79966454be7961ba80049be65815cfce1d4cf)) + +### Bug Fixes + +* :alien: change gitlab api calls ([f04dc87](https://github.com/cloud-pi-native/console/commit/f04dc87208ca19ee2b06ff2c35535e51a72f84cd)) +* :art: finish pr ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :art: quote fixes ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :bug: add module on shared ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :bug: fix imports, change dependencies ([41f427f](https://github.com/cloud-pi-native/console/commit/41f427fe17daef7ef1b3c330ec5635338edfda59)) +* :bug: fix logs pagination bug ([7f3a990](https://github.com/cloud-pi-native/console/commit/7f3a99085f701d02367fab04f194dc0c0e85356d)) +* :bug: forgot await in argo plugin ([f4504d4](https://github.com/cloud-pi-native/console/commit/f4504d4b1ccc999274c3267508c943f23fd8690b)) +* :bug: retrieve also inactive orgs on admin project list page ([4b8977e](https://github.com/cloud-pi-native/console/commit/4b8977e01214295f3090ab16a92bde4b7d7a64f9)) +* :construction_worker: fix CI: tsconfig is needed ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :construction: in progress ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :lipstick: more contrasted icon color for archived project ([790d276](https://github.com/cloud-pi-native/console/commit/790d2761d3f77756144172e3ccd80bcb0df4572d)) + +### Code Refactoring + +* :boom: migrate from sequelize to prisma ([37b065d](https://github.com/cloud-pi-native/console/commit/37b065d6cff844dfd1b9d2276831eddefa64a71e)) + +## [4.1.0](https://github.com/cloud-pi-native/console/compare/v4.0.0...v4.1.0) (2023-06-12) + +### Features + +* :sparkles: add admin log page ([41058ee](https://github.com/cloud-pi-native/console/commit/41058ee51899d4ec351ae7adad10eb3fefa99f8b)) + +### Bug Fixes + +* :bug: harbor, fix remove repos from unknown project ([8a18381](https://github.com/cloud-pi-native/console/commit/8a18381c2197634d90649a8520dd068a59e5369c)) +* :bug: test if external dir exists ([3276400](https://github.com/cloud-pi-native/console/commit/3276400a00b3435eb87e225e0e8deadd5a311b0d)) +* :zap: gitlab, stabilize user search and lifecycle ([179c912](https://github.com/cloud-pi-native/console/commit/179c91269ecf04ff6a489a5d92f490b940446e70)) + +## [4.0.0](https://github.com/dnum-mi/dso-console/compare/v3.4.1...v4.0.0) (2023-06-08) + +### ⚠ BREAKING CHANGES + +* :boom: clarify business logic on status and locking + +### Features + +* :alien: add hook to synchronize organizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :alien: add hook to synchronize organizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :alien: add keys to source organizations ([626c031](https://github.com/dnum-mi/dso-console/commit/626c03135571199c3a1f1e72e19132bc651288a3)) +* :boom: if status: failed (aka plugins failed), project remains locked ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) +* :children_crossing: improve user experience on dso-console ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :children_crossing: improve ux for danger zone ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :children_crossing: improve UX on permission form ([fb72076](https://github.com/dnum-mi/dso-console/commit/fb72076f16f79df456392fc7f9a7129ab1655832)) +* :children_crossing: redirect to dashboard when selecting a project ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :necktie: allow only one plugin to register on hook fetchOrganizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :sparkles: add project admin view ([b49ff96](https://github.com/dnum-mi/dso-console/commit/b49ff967853e31ca6cbcd8cd2ff78b299023788e)) + +### Bug Fixes + +* :art: temp fix labels, add check, refacto get ns ([a43f50c](https://github.com/dnum-mi/dso-console/commit/a43f50cb93b2453f025b9d4284f2b65b6134161a)) +* :bug: fix payload, fix check step ([ff76b27](https://github.com/dnum-mi/dso-console/commit/ff76b2750352cbe7b2d49ff5fb6e4d3e424b328f)) +* :bug: remove harbor repos before projects ([7239329](https://github.com/dnum-mi/dso-console/commit/72393297dcfaf44938e2814cccfd8366613ba75c)) +* :necktie: clarify business logic on status and locking ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) + +### Code Refactoring + +* :boom: clarify business logic on status and locking ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) + +## [3.4.1](https://github.com/dnum-mi/dso-console/compare/v3.4.0...v3.4.1) (2023-05-22) + +### Bug Fixes + +* :art: change ns labels ([ae14d67](https://github.com/dnum-mi/dso-console/commit/ae14d6736a3b3276b3406562709cf77e3aab0668)) +* :construction: hooks payload send full owner object, bug getSingleOwnerByProjectId ([0e74895](https://github.com/dnum-mi/dso-console/commit/0e74895695b42d4e13a645ef4005afbffec59b64)) + +## [3.4.0](https://github.com/dnum-mi/dso-console/compare/v3.3.1...v3.4.0) (2023-05-15) + +### Features + +* :alien: owner name is required to create Canel user ([e4aa5e9](https://github.com/dnum-mi/dso-console/commit/e4aa5e9662fb0494ef43b7f52140c9c11702613f)) + +## [3.3.1](https://github.com/dnum-mi/dso-console/compare/v3.3.0...v3.3.1) (2023-05-11) + +### Bug Fixes + +* :ambulance: keycloak now sends group path ([913f402](https://github.com/dnum-mi/dso-console/commit/913f40295f7f0eab17964e72e38e61200523456e)) + +## [3.3.0](https://github.com/dnum-mi/dso-console/compare/v3.2.3...v3.3.0) (2023-05-11) + +### Features + +* :sparkles: add organization page for admins ([5e6b7fa](https://github.com/dnum-mi/dso-console/commit/5e6b7fa252285b432ef5869aedf8881b642eff53)) + +### Bug Fixes + +* :ambulance: handle unocss migration for media queries ([8fdf445](https://github.com/dnum-mi/dso-console/commit/8fdf4455a34a274c3723685928549bb30bb04762)) +* :sparkles: set ingress secretName ([8be3479](https://github.com/dnum-mi/dso-console/commit/8be34796b15219a6b4f77585662b21d8d04c76c8)) + +## [3.2.3](https://github.com/dnum-mi/dso-console/compare/v3.2.2...v3.2.3) (2023-05-09) + +### Bug Fixes + +* :ambulance: keycloak group id, typo ([1aedf77](https://github.com/dnum-mi/dso-console/commit/1aedf77b4a74698bb1d52cbfe6863bd249a7eadb)) + +## [3.2.2](https://github.com/dnum-mi/dso-console/compare/v3.2.1...v3.2.2) (2023-05-09) + +### Bug Fixes + +* :ambulance: missing ownerid at env init ([14696ff](https://github.com/dnum-mi/dso-console/commit/14696ffc24a2bfe5345b135e99f48502ba0d2fae)) + +## [3.2.1](https://github.com/dnum-mi/dso-console/compare/v3.2.0...v3.2.1) (2023-05-09) + +### Bug Fixes + +* :ambulance: habor missing slash on oidc group ([e85b903](https://github.com/dnum-mi/dso-console/commit/e85b9031f77c7feba05f04b9444c5d6862342246)) +* :ambulance: keycloak do not add owner in group env ([f20b3da](https://github.com/dnum-mi/dso-console/commit/f20b3da4a8666d3b2912c3b7147819818a96c8ed)) + +## [3.2.0](https://github.com/dnum-mi/dso-console/compare/v3.1.0...v3.2.0) (2023-05-05) + +### Features + +* :lipstick: better ui for permission form ([56e0739](https://github.com/dnum-mi/dso-console/commit/56e0739d935d96c4eedd55807151f72f8fafcb6b)) +* :sparkles: add description field for a project ([e9d65ed](https://github.com/dnum-mi/dso-console/commit/e9d65eda3084ac46b48f8e7a4a7e78324cf8d351)) +* :sparkles: add download link for includes.zip ([0e067d1](https://github.com/dnum-mi/dso-console/commit/0e067d1965e39084d3f21191e27aa3b82948df17)) +* :sparkles: introduce users admin view ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) + +### Bug Fixes + +* :alien: refacto plugins to check services before save in database ([c58f3da](https://github.com/dnum-mi/dso-console/commit/c58f3dab057fd39f62e20ca3951278a87f6cfad0)) +* :bug: forget default value for log table ([ade449b](https://github.com/dnum-mi/dso-console/commit/ade449b0a834f850d4f64c94cfb489174b946463)) +* :construction: fix e2e test admin (temporary) ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) +* :construction: use ingress ([621d5ee](https://github.com/dnum-mi/dso-console/commit/621d5eee80adfb5f14459b93c7f51b431dd61c31)) +* :fire: remove routes ([65dabcb](https://github.com/dnum-mi/dso-console/commit/65dabcb28f8ede298975a2cec3e7ff5623ee4d8c)) +* :loud_sound: log database sync error ([3e73dac](https://github.com/dnum-mi/dso-console/commit/3e73dac0fc470051c5aaf410ebbbf28cafc9ee0e)) +* :wrench: update helm values ([16d3223](https://github.com/dnum-mi/dso-console/commit/16d32233c84001b8aa206a76b8ac3b772875cb64)) + +### Performance Improvements + +* :art: admin users no custom exception ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) + +## [3.1.0](https://github.com/dnum-mi/dso-console/compare/v3.0.0...v3.1.0) (2023-04-18) + +### Features + +* :children_crossing: add deployment infos below environment tiles ([992b34e](https://github.com/dnum-mi/dso-console/commit/992b34e070401ae330cab239d73bf27b56ba516c)) +* :sparkles: add custom ca to server ([8583577](https://github.com/dnum-mi/dso-console/commit/85835775d1bf21dfadffe5094dc1a513d6782525)) +* :sparkles: introduce environment management page ([3e77a67](https://github.com/dnum-mi/dso-console/commit/3e77a679446853b981d9bf98f1bd62795ce5cf1d)) + +### Bug Fixes + +* :bug: fix git output url ([e3402b4](https://github.com/dnum-mi/dso-console/commit/e3402b44dddbd71805293305df181321af04dafe)) +* :bug: forget delete mirror repository secret ([f00aac7](https://github.com/dnum-mi/dso-console/commit/f00aac7f6315a1f27ee05ebaa6863feb466ac654)) + +## [3.0.0](https://github.com/dnum-mi/dso-console/compare/v2.3.0...v3.0.0) (2023-04-14) + +### ⚠ BREAKING CHANGES + +* **api:** :building_construction: replace ansible with plugins + +### Features + +* :art: load kubeconfig only if if a path is set ([a9d9338](https://github.com/dnum-mi/dso-console/commit/a9d93387cc7ff407ea71edb848e664fb651fc09a)) +* :bento: add Marianne police ([c5af057](https://github.com/dnum-mi/dso-console/commit/c5af05774332d97fc5b669aeaa60f9fca6075998)) +* :boom: change helm deployment ([c1368c6](https://github.com/dnum-mi/dso-console/commit/c1368c69de93357c66709110f3c57d22a62af8ad)) +* :lipstick: add dark theme ([cfc16d8](https://github.com/dnum-mi/dso-console/commit/cfc16d81391c28447d02297a9ebdf22c1f142c6f)) +* :lipstick: add dark theme ([cfc16d8](https://github.com/dnum-mi/dso-console/commit/cfc16d81391c28447d02297a9ebdf22c1f142c6f)) +* :loud_sound: add a short description for database logging ([4146f5a](https://github.com/dnum-mi/dso-console/commit/4146f5ae7048fa280a654b5471a32968af159b2f)) +* :technologist: do not drop database in integration dev ([521d503](https://github.com/dnum-mi/dso-console/commit/521d503cdf5e2c24f9e148bc6435db485e1252e7)) + +### Bug Fixes + +* :ambulance: add kaniko proxy ([1564b6f](https://github.com/dnum-mi/dso-console/commit/1564b6f7a696c13846799b38557b1003d2cb87c8)) +* :ambulance: stabilize gitlab search and vault secret name ([044da15](https://github.com/dnum-mi/dso-console/commit/044da1511ff8a5f16136807c70194d662c08db26)) +* :bug: display hyphen only if service message exists ([ba5ed65](https://github.com/dnum-mi/dso-console/commit/ba5ed659954235f8b00e78df24029f1186d23b7a)) +* :bug: fix controllers logic ([251fba4](https://github.com/dnum-mi/dso-console/commit/251fba44ebd93e645487da63710add4d9382521d)) +* :bug: use proxy on services healthcheck ([85eef14](https://github.com/dnum-mi/dso-console/commit/85eef144f8c87760c61203de7944229af2828047)) +* :lipstick: make snackbar bakground -color adaptive to theme variations ([851a004](https://github.com/dnum-mi/dso-console/commit/851a004837eb901d02bbf65e20697e457ecde6e2)) +* :wrench: rename kubeconfig var ([61fdfbf](https://github.com/dnum-mi/dso-console/commit/61fdfbf3bb47b51225d2cf3c00b167c46ff1bedf)) + +### Code Refactoring + +* **api:** :building_construction: replace ansible with plugins ([59cd5fe](https://github.com/dnum-mi/dso-console/commit/59cd5fef0799ff3dd5df7ec42ef879647a6de78a)) + +## [2.3.0](https://github.com/dnum-mi/dso-console/compare/v2.2.1...v2.3.0) (2023-03-22) + +### Features + +* :alien: fetch services to get their statuses ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) +* :art: review tobi ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) +* :monocle_face: sort projects by ASC name ([ccb7fbb](https://github.com/dnum-mi/dso-console/commit/ccb7fbb5066da6ebc1776a893c86d8d80a292856)) +* :sparkles: build project services url in api ([2173f8a](https://github.com/dnum-mi/dso-console/commit/2173f8abade9848c83cc68e4a68da02982d4a887)) +* :sparkles: new page dedicated to services ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) + +### Bug Fixes + +* :bug: fix container env ([f9fd457](https://github.com/dnum-mi/dso-console/commit/f9fd45715d94789e02d8c88991b02fe9831e4460)) + +## [2.2.1](https://github.com/dnum-mi/dso-console/compare/v2.2.0...v2.2.1) (2023-03-14) + +### Bug Fixes + +* :bug: token should authorize "-" ([277f292](https://github.com/dnum-mi/dso-console/commit/277f29232fd5713c0a6e5c42f29ba257b71cdb75)) + +### Performance Improvements + +* :zap: no need to call /account endpoint ([3b278f7](https://github.com/dnum-mi/dso-console/commit/3b278f7e8a2fc22cf3a9aec8b1319c83a934a7f6)) + +## [2.2.0](https://github.com/dnum-mi/dso-console/compare/v2.1.1...v2.2.0) (2023-03-13) + +### Features + +* :sparkles: display project and repositories statuses in dashboard and repo pages ([ba27167](https://github.com/dnum-mi/dso-console/commit/ba2716798e8d39e5470b6fe0e97acc3a54480612)) + +### Bug Fixes + +* :bug: add joi schema validation in repo form ([8524e50](https://github.com/dnum-mi/dso-console/commit/8524e50cca3a97965b6cc2eb104620aec2d3e5dd)) +* :bug: do nothing when tile is disabled ([e09043c](https://github.com/dnum-mi/dso-console/commit/e09043c34a21315abd8c0c3d1570488057a3d1a6)) +* :bug: wrong key for project archived ([8e6b27b](https://github.com/dnum-mi/dso-console/commit/8e6b27b576e7ad28bde1b8a00b84acf366531993)) + +### Performance Improvements + +* :fire: no external token in database, no crypto needed ([0a3fb8c](https://github.com/dnum-mi/dso-console/commit/0a3fb8c5403215e1ae3d24fb8ea7cfcf43a8060f)) + +## [2.1.1](https://github.com/dnum-mi/dso-console/compare/v2.1.0...v2.1.1) (2023-02-27) + +### Bug Fixes + +* :globe_with_meridians: translate error messages in french ([a2827c3](https://github.com/dnum-mi/dso-console/commit/a2827c3746aad94293de183725bd2e77ac34f525)) + +## [2.1.0](https://github.com/dnum-mi/dso-console/compare/v2.0.0...v2.1.0) (2023-02-27) + +### Features + +* :children_crossing: redirect to services when selecting a project ([3849dad](https://github.com/dnum-mi/dso-console/commit/3849dad734e07ae0250962c8a71c1eb3470614c2)) +* :lipstick: whoami in sidemenu rather than snackbar ([70fcbf5](https://github.com/dnum-mi/dso-console/commit/70fcbf537fa32a04538d10673a1d0b4246fddd65)) +* :memo: ajout d'un schéma d'architecture ([72f61fb](https://github.com/dnum-mi/dso-console/commit/72f61fbf6060455f56d578003e927196c0292a50)) +* :necktie: add constraint for project name length ([f2d3975](https://github.com/dnum-mi/dso-console/commit/f2d3975f8cf5124142edc38a64f219ac9a223985)) +* :sparkles: add snackbar to handle errors ([0b5fa71](https://github.com/dnum-mi/dso-console/commit/0b5fa71b5d993fb7529f4fa2b74d78dacb0b33d3)) +* add active column on organization table, and client get only active: true ([de7b9d0](https://github.com/dnum-mi/dso-console/commit/de7b9d01bf878ee1cf298736e83410905390a5a3)) + +### Bug Fixes + +* :ambulance: bad owner w/ new queries ([5153b62](https://github.com/dnum-mi/dso-console/commit/5153b621c0707a438f191dd2b06d8c801804f014)) +* :ambulance: log more of ansible output ([e2b10d6](https://github.com/dnum-mi/dso-console/commit/e2b10d6875605c836606cb5ba79c963787d4c177)) +* :bug: error in ansible route ([a470677](https://github.com/dnum-mi/dso-console/commit/a470677a5d5c3b2b262434adc3f399f948caccd2)) +* :bug: error in ansible route ([1565411](https://github.com/dnum-mi/dso-console/commit/1565411524a2c17d0e7de79e5965562053a2024b)) +* :goal_net: catch fetch errors and send it to front ([fcca40b](https://github.com/dnum-mi/dso-console/commit/fcca40b2efbc5d38ae8fe0001043671756ebda34)) +* :goal_net: optional chaining to avoid errors on selected project ([6750077](https://github.com/dnum-mi/dso-console/commit/67500771939efb2e2329d8ab94407588aa76fa5b)) +* :goal_net: return if user does not have project ([f2a3d74](https://github.com/dnum-mi/dso-console/commit/f2a3d7449abc729b5506aaaf43a163e600c51bcb)) +* :lipstick: enforce ui on disabled dsfrtiles ([4de5d25](https://github.com/dnum-mi/dso-console/commit/4de5d257d847c4ecff137face3cbb276d543b779)) +* :truck: rename generated gitlab ci file ([d3e637e](https://github.com/dnum-mi/dso-console/commit/d3e637e04ef008c5316b7e720a9fc905c0ab0000)) +* :wrench: update postgres image ([16e5d55](https://github.com/dnum-mi/dso-console/commit/16e5d557c52e149def7f7c216371ba23fc721bbf)) + +### Reverts + +* :rewind: rebase error ([728a704](https://github.com/dnum-mi/dso-console/commit/728a704522a3f5cfcc2dc5879671a83a051a82c6)) + +## [2.0.0](https://github.com/dnum-mi/dso-console/compare/v1.0.0...v2.0.0) (2023-02-22) + +### ⚠ BREAKING CHANGES + +* **database:** :card_file_box: new database model, queries, + +### Features + +* :art: easier generation of files object ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :art: specific routes, add validation schema ([69645b9](https://github.com/dnum-mi/dso-console/commit/69645b9b392f8f56b8d3606780dfa63098fa5240)) +* :art: use only one get route ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: mob review ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :card_file_box: add an association table for users and projects ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: add destroy functions for db ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: wip : working with @ArnaudTa on liveshare ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: add delete repo button ([ad64daf](https://github.com/dnum-mi/dso-console/commit/ad64dafce222ea572e6169c502aaed08562a70e7)) +* :construction: Add download and copy, missing format ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: add status and locked for project ([1bb55b8](https://github.com/dnum-mi/dso-console/commit/1bb55b8758eecced9204aa7b77f404ac6a3d45b0)) +* :construction: adding api implementation ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :construction: all controllers writtend ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: applying review requested changes ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: commit à ecraser - wip fs file generator ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: feature to achieve later, keep asking for project status ([57846f9](https://github.com/dnum-mi/dso-console/commit/57846f923f15771186b8a7816324df42d55d86cb)) +* :construction: first database model ([69f51f2](https://github.com/dnum-mi/dso-console/commit/69f51f2eb16b052d4fddbb909ac7a5f0913b2c94)) +* :construction: new project controller ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: remove done todos ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: uncomment path for delete repo and projects ([8fb7cd9](https://github.com/dnum-mi/dso-console/commit/8fb7cd9b5c67857862cebfde091a3b5bbba54c93)) +* :construction: wip - ci files generated by server ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: wip downloading file ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: wip fix unit tests ([31c3a86](https://github.com/dnum-mi/dso-console/commit/31c3a863104f553adcefb5e78d64613e32f65704)) +* :lipstick: fix snackbar on bottom left ([7823c99](https://github.com/dnum-mi/dso-console/commit/7823c991b4adf62e380193d7f1d9fc33108dc368)) +* :lock: add crypto module to encrypt externalToken or other data if needed ([c5806b3](https://github.com/dnum-mi/dso-console/commit/c5806b392ec2b203f0d54a62527c9d96570f2918)) +* :necktie: cannot update nor delete permission if not permitted - controller side ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :necktie: disable delete permission for owner ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :necktie: permissions should be enabled only for permitted users ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :passport_control: organization controller ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :sparkles: add archiveProject btn ([e412223](https://github.com/dnum-mi/dso-console/commit/e41222346a4e3d9d60100d59b4edf160a2319c08)) +* :sparkles: add backend function to generate random passwords ([5244597](https://github.com/dnum-mi/dso-console/commit/52445972b7be13862d395059f82fae81b7efa495)) +* :sparkles: add delete playbook call in deleteRepo controller ([8abe695](https://github.com/dnum-mi/dso-console/commit/8abe6950e8f26aa01b7b1a95e4fbc4627f26d925)) +* :sparkles: add delete trigger for environment ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :sparkles: add environment and permissions management ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :sparkles: add environment page ([a5a5129](https://github.com/dnum-mi/dso-console/commit/a5a5129e89cb9594204b08c9cc76c3a30bb04eb7)) +* :sparkles: Add GitLab ci generation in repoForm ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: Add gitlabCi form for repo ([839f1e1](https://github.com/dnum-mi/dso-console/commit/839f1e18bf99f1bef53ed3ab39a64ab097c6a0d8)) +* :sparkles: add GitlabCI generated file ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: add helm charts ([e540325](https://github.com/dnum-mi/dso-console/commit/e5403252cd7d888c839be63758c15c1fdce76c71)) +* :sparkles: add log table in model ([642b245](https://github.com/dnum-mi/dso-console/commit/642b245a4a0ffa8877c900fa786345fd484f60f8)) +* :sparkles: add misc routes, healthz/version ([520976d](https://github.com/dnum-mi/dso-console/commit/520976ddfebba2d05385ba66d27af809e8736287)) +* :sparkles: ajout d'un système de role propre à la console avec authentification admin ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :sparkles: ansible-api respond playbook rc, server stores it in db ([7774045](https://github.com/dnum-mi/dso-console/commit/7774045ae6404e1f0de810bb09a7832c0292baba)) +* :sparkles: call delete repo and archive project playbooks ([97a7b13](https://github.com/dnum-mi/dso-console/commit/97a7b1300847fafa5053d58fb05079ef52a9d041)) +* :sparkles: ciForm ok ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: display current user name in closeable alert ([349ade9](https://github.com/dnum-mi/dso-console/commit/349ade9f5b8017bf206aff2c894a56cc675d6873)) +* :sparkles: get user's group from keycloak ([8311622](https://github.com/dnum-mi/dso-console/commit/83116226e7a3e172a94b4f988d037ec1dcc8aaf4)) +* :sparkles: record ansible responses in database ([5753883](https://github.com/dnum-mi/dso-console/commit/5753883ae32f36e28a128f396df3f9669e5a854b)) +* :sparkles: wip components for environment managing ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :test_tube: ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: rewrite random utils for tests ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* **Helm:** :art: add env per container ([48b41cf](https://github.com/dnum-mi/dso-console/commit/48b41cfea037ce90812197990c54d2726fb75ac7)) +* **Helm:** :art: variabilize server imports ([0973646](https://github.com/dnum-mi/dso-console/commit/09736463d3011f96348f94cf3ad8cc69c9c0f07e)) + +### Bug Fixes + +* :alembic: changement du keycloak flow ([1af0d85](https://github.com/dnum-mi/dso-console/commit/1af0d8504766f3f0ebab92feff8f4795e4e1c67e)) +* :art: variabilize pvc name ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: add await on start server functions and fix tests ([c8e66f8](https://github.com/dnum-mi/dso-console/commit/c8e66f87f4ccd4be1775b32729f12d9c0e919f35)) +* :bug: add refresh keycloak token ([b758e44](https://github.com/dnum-mi/dso-console/commit/b758e44241c868a1e8a1546271f1806069370964)) +* :bug: bind do not always works ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: change create project and change message logs ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: controller response before ansible fetch ([8983c45](https://github.com/dnum-mi/dso-console/commit/8983c457cd8de96d117d51881668b37c3b4f9407)) +* :bug: delete default argo repo at deleting ([5fafe99](https://github.com/dnum-mi/dso-console/commit/5fafe9919b7d08447e356eb260795a6cb91f3ba3)) +* :bug: fix projectAddUserController ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: flatMap didn't work as expected ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: forgot to load repositories data ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: import env ([98c1632](https://github.com/dnum-mi/dso-console/commit/98c16329e8fbd3b0bcabf2ac132d53931ef7f9bf)) +* :bug: init db doit être séquentiel pour ne pas violer l'intégrité des tables ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: isInfra & isPrivate false if undefined ([2fa987d](https://github.com/dnum-mi/dso-console/commit/2fa987d0872f6884627d6060c64d27d932a2db5c)) +* :bug: isInfra not send to ansible ([b049746](https://github.com/dnum-mi/dso-console/commit/b049746b97e5529d6f5368e045eb96a266bb432b)) +* :bug: mode dégradé si payload n'est pas bon ([14309cf](https://github.com/dnum-mi/dso-console/commit/14309cf41835f59168c72b6a4840fcf1ae8264d2)) +* :bug: repositories may be empty ([584798f](https://github.com/dnum-mi/dso-console/commit/584798f1ebba5894b2f9181cbe0f9bdbd49cdd6c)) +* :bug: review changes ([5e4bdb7](https://github.com/dnum-mi/dso-console/commit/5e4bdb73414102728e68d56fe30d65a7f718a0f7)) +* :bug: send proper data to ansible ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: send200 if code is OK ([08ca7eb](https://github.com/dnum-mi/dso-console/commit/08ca7ebdf0b24dabc60baa631c4887854780465c)) +* :bug: show permissionForm only when environment is created ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: stabilize playbookDir in different execution execution ([69645b9](https://github.com/dnum-mi/dso-console/commit/69645b9b392f8f56b8d3606780dfa63098fa5240)) +* :bug: update ansible calls ([04ee9ff](https://github.com/dnum-mi/dso-console/commit/04ee9fff7e78bbe4305ad7a1923df842a80a49ad)) +* :bug: update orgName generation to new db schema ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: use flatMap to iterate over two arrays ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: wip bug ansible repositoryInitializing ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: wip working on todo's ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: working on todo's ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: organization should be a FK in projects ([d014387](https://github.com/dnum-mi/dso-console/commit/d014387c8d09182675ddf98d381e27b78bc28545)) +* :card_file_box: turn envList into array of objects ([41f0278](https://github.com/dnum-mi/dso-console/commit/41f027858f108fb056958c118bf664a141e7623c)) +* :construction_worker: Copy test-utils in client docker ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: trying to log details for ansible api call ([ae944ae](https://github.com/dnum-mi/dso-console/commit/ae944aea77d0f13798c63e9bf9690363f75e9bc9)) +* :construction: wip ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :fire: remove bad log and comms ([748c43d](https://github.com/dnum-mi/dso-console/commit/748c43d93212a79b2dd04e527f97da5dd5d585b5)) +* :goal_net: add front error management for createProject ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :goal_net: catch errors in front when calling back ([5331773](https://github.com/dnum-mi/dso-console/commit/53317734961895bf348c758774d23c2505f812a8)) +* :lipstick: update UI for new DSFR version ([62750c4](https://github.com/dnum-mi/dso-console/commit/62750c478ecac453542e5933d1464af2a752e2d4)) +* :lock: filter users sensitive infos ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :lock: improve security using node utility function to compare hashes ([ea0e730](https://github.com/dnum-mi/dso-console/commit/ea0e73084a04c85d69e8d556e73241982df5d40f)) +* :loud_sound: mute logs for health route ([b1eaad5](https://github.com/dnum-mi/dso-console/commit/b1eaad5701ac73b131b0cb8692111ae5fd0a8d84)) +* :mute: remove sequelize logs to preserve data ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :necktie: permission level goes from 0 to 3, 0 by default, 3 for owner ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :pencil2: typo on var name ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :recycle: change where directive to through associations ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :recycle: fix ([8d2c911](https://github.com/dnum-mi/dso-console/commit/8d2c911b855f19b0a6568e2857129c9c62fcfcc9)) +* :recycle: use sequelize querytypes ([d45472a](https://github.com/dnum-mi/dso-console/commit/d45472a89a5be2a74c1af2da14d41ceb5d118284)) +* :rotating_light: Sonar warnings ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :rotating_light: SonarQube warning ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: fix TU ([26bd4f1](https://github.com/dnum-mi/dso-console/commit/26bd4f1477bd3664472ca4d8989630f09c2a7e0b)) +* :test_tube: use dedicated en var for ct tests ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: wip fix repo ansible fetch failed ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :truck: fix generateCIFiles feature ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :truck: rename folder for generating files ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) + +### Performance Improvements + +* :art: use map instead of forEach for array generation ([03c78fa](https://github.com/dnum-mi/dso-console/commit/03c78fa5f28bb86fad7e3e1b142a1c71d1639054)) +* :stethoscope: add health probes in templates ([d645f2e](https://github.com/dnum-mi/dso-console/commit/d645f2e35460ba92a67aeb0ea65ac9356ed36381)) + +### Reverts + +* :egg: hide environments and permissions feature waiting for playbooks ([edebee9](https://github.com/dnum-mi/dso-console/commit/edebee944905f5723518aaca0c37a51deeac80a6)) +* :rewind: hide snackbar for now ([6afe9e8](https://github.com/dnum-mi/dso-console/commit/6afe9e81bc5b32cb544456d7e697be66ea0349be)) + +### Code Refactoring + +* **database:** :card_file_box: new database model, queries, ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) + +## 1.0.0 (2022-12-16) + +### Features + +* :alembic: clone in entrypoint ([f97edb0](https://github.com/dnum-mi/dso-console/commit/f97edb07c31036cb1fc1882610e2f78185106028)) +* :art: baudoin's review ([656aac6](https://github.com/dnum-mi/dso-console/commit/656aac693a4c21a64402dd9bcd8b978f2fb63b55)) +* :art: introduce CLONE_DIR, where to clone ([263ea79](https://github.com/dnum-mi/dso-console/commit/263ea79deef5d9f58e2fc10ffcf98e31a4b41bc2)) +* :art: spread instead of push ([530b366](https://github.com/dnum-mi/dso-console/commit/530b366368b8eb1a2f2597362ecc2c5aa2bc1145)) +* :bento: Add images for services logos in public folder ([6d8e91c](https://github.com/dnum-mi/dso-console/commit/6d8e91ca9837dd60f603d5ed364e11fb3485a5b5)) +* :bento: Definitive logos for services ([e2e6270](https://github.com/dnum-mi/dso-console/commit/e2e627081224433e0d190876a8791aa1d2ea8d6b)) +* :boom: first query successfull ([61a7cd5](https://github.com/dnum-mi/dso-console/commit/61a7cd5857a6e6c648219d20399c19e8672a37db)) +* :boom: first query successfull ([36c973e](https://github.com/dnum-mi/dso-console/commit/36c973ebaa9f3a52669d0f921eedfb8aee6c4a9f)) +* :bricks: change routes ([af30bd8](https://github.com/dnum-mi/dso-console/commit/af30bd895bef8203b3e06facfa04d93be83b86f9)) +* :card_file_box: Add api getProjectById ([98cd231](https://github.com/dnum-mi/dso-console/commit/98cd23165e72add49762bc31b81c54526f42b768)) +* :card_file_box: Add api getProjectById ([98976d0](https://github.com/dnum-mi/dso-console/commit/98976d0a33cf65ee3be4baad629dae107410c6e3)) +* :card_file_box: Add createProject query ([9bcfb05](https://github.com/dnum-mi/dso-console/commit/9bcfb059d2c3c51c64ee223b777da5d999339e44)) +* :card_file_box: Add owner from keycloak in project ([5aa9aad](https://github.com/dnum-mi/dso-console/commit/5aa9aad9f12b5c5060139baebd5b2869ea91b1f4)) +* :card_file_box: Add owner from keycloak in project ([cadb474](https://github.com/dnum-mi/dso-console/commit/cadb4745648ecdfc0ffbbc045ca4a054ee062615)) +* :card_file_box: Add postgres and pgadmin ([76374e6](https://github.com/dnum-mi/dso-console/commit/76374e6acd72ba89ab15bdf4c7e1ff82f00e110d)) +* :card_file_box: Add user id in project queries ([48949df](https://github.com/dnum-mi/dso-console/commit/48949df562179493e2b524b541129ff520913d86)) +* :card_file_box: Create database & table on server start if not exists ([14b9f6b](https://github.com/dnum-mi/dso-console/commit/14b9f6b5359e33bd345eccb1be081698fbda8b59)) +* :card_file_box: Wip add createProject api ([1414bc0](https://github.com/dnum-mi/dso-console/commit/1414bc0ed59f63fd41f21dcc76dcebebd1a5c4cf)) +* :card_file_box: Wip app and server js ([2d4efdf](https://github.com/dnum-mi/dso-console/commit/2d4efdf91f98a82fcd18c3887c1943a283c653c8)) +* :construction: (wip) add project mongoose model ([76e8d77](https://github.com/dnum-mi/dso-console/commit/76e8d77a3342ff58707b47977a8cdd14a721339d)) +* :construction: (wip) trying to connect to keycloak with server ([2401fda](https://github.com/dnum-mi/dso-console/commit/2401fdaa383a38632e95d1097e4a3766ef7f6563)) +* :construction: Add check in addRepo and addUser queries ([c2bad66](https://github.com/dnum-mi/dso-console/commit/c2bad663b9aba71dffd9e023cbd098a38353ee0d)) +* :construction: Add DsfrTable for users ([8b63e84](https://github.com/dnum-mi/dso-console/commit/8b63e84bd6923f5128efbd427e659418794f7eaf)) +* :construction: Add DsoTeam table ([ca3e2c3](https://github.com/dnum-mi/dso-console/commit/ca3e2c3462d9e17d90e10afed4432b24735ded24)) +* :construction: Add final todos ([c224592](https://github.com/dnum-mi/dso-console/commit/c2245927e6a2b61cd3bf4fbef4fddf5708ccd20f)) +* :construction: Add OrderProject ([f4d2962](https://github.com/dnum-mi/dso-console/commit/f4d2962ec7b76a13c7f9849302f3a0279e79a442)) +* :construction: Add postgres and pgadmin ([a7ce8c5](https://github.com/dnum-mi/dso-console/commit/a7ce8c5ff9aee0003f7d2066a6f2e172b9d5bd08)) +* :construction: Add postgres and pgadmin ([6fdf560](https://github.com/dnum-mi/dso-console/commit/6fdf56045839636324015739852ee85d5685d584)) +* :construction: Add removeUserFromProject function ([9430fc6](https://github.com/dnum-mi/dso-console/commit/9430fc697e9761c3e79f0d37bef16dcf52f1e889)) +* :construction: Add routes for projects ([cd14601](https://github.com/dnum-mi/dso-console/commit/cd146017e1a6646777451e0ed711b9d6556e5393)) +* :construction: Add SideMenu to app ([3373f8c](https://github.com/dnum-mi/dso-console/commit/3373f8cf18607293f1081172ed93e8dabd69fd28)) +* :construction: Configure keycloak for frontend ([d8074ac](https://github.com/dnum-mi/dso-console/commit/d8074ac468a562322a8d44ed5d08b26be56cc7e8)) +* :construction: Failing specs projects ([a56cb8c](https://github.com/dnum-mi/dso-console/commit/a56cb8cb0685e3173b867519d09f4ca41b217959)) +* :construction: Func removeUserFromProject ([95fdc7f](https://github.com/dnum-mi/dso-console/commit/95fdc7f8cd8285bf18257364f833b9eb84588cf1)) +* :construction: Handle menu collapse in sm mode ([5237e89](https://github.com/dnum-mi/dso-console/commit/5237e892f86f5c4abd170562867a4745e2121bb9)) +* :construction: trying to access server /api/v-4/version ([a27483c](https://github.com/dnum-mi/dso-console/commit/a27483c6525091eca564b9ad615636edf79c5283)) +* :construction: trying to access server /api/v1/version ([2e43c8f](https://github.com/dnum-mi/dso-console/commit/2e43c8fd0c13f59bd40567912815a605c43bf0dc)) +* :construction: wip add node-postgres ([50ef526](https://github.com/dnum-mi/dso-console/commit/50ef526f2e647d5a16254468e9e83996e71baab1)) +* :construction: wip business rules orderProject ([24f7daa](https://github.com/dnum-mi/dso-console/commit/24f7daaae5cba5d7fdb6ee1ebc700c9aba1a0830)) +* :construction: wip pg ([391bccb](https://github.com/dnum-mi/dso-console/commit/391bccbbf34c9a365eed847824edb718643a8d73)) +* :construction: working on api x keycloak ([183f4d8](https://github.com/dnum-mi/dso-console/commit/183f4d8c9e49407f500444210f2f143dcf1433c3)) +* :hammer: Add turbo ([a3fdc96](https://github.com/dnum-mi/dso-console/commit/a3fdc96e23fbd3c66dea95ce89829dbe873b835a)) +* :hammer: Update docker compose scripts for pnpm ([537ba52](https://github.com/dnum-mi/dso-console/commit/537ba52f8c75659a8204beb5e48fa2ff11572be2)) +* :heavy_plus_sign: Add axios for api management ([591b012](https://github.com/dnum-mi/dso-console/commit/591b012df344e0842ce3848eaae4aa0c3beceb73)) +* :heavy_plus_sign: Add fastify keycloak adapter ([575e8d7](https://github.com/dnum-mi/dso-console/commit/575e8d71aa5d4fe9a0f2da94c484de2867ca5c9a)) +* :lipstick: Resize sidemenu and content ([61670d8](https://github.com/dnum-mi/dso-console/commit/61670d816aa3b70aa164e3d8531b12b3f835fcc6)) +* :lipstick: thiner projects subheader ([4427945](https://github.com/dnum-mi/dso-console/commit/442794546e8977dc3298a5ec92e68a30ef3775eb)) +* :lipstick: Update navigation in frontend ([5751ed3](https://github.com/dnum-mi/dso-console/commit/5751ed36dbdbeceb832c9bcc0c9972fca2abd26c)) +* :lock: Add keycloak token check on ansible api ([afe18f5](https://github.com/dnum-mi/dso-console/commit/afe18f58dc7c041adb40b1dee7617cb6b38ef9f5)) +* :loud_sound: forward request id to ansible api ([b6107bb](https://github.com/dnum-mi/dso-console/commit/b6107bbc322d6740bfbe7345e5865af6df4cb219)) +* :memo: Add documentation inside application ([0d70a0e](https://github.com/dnum-mi/dso-console/commit/0d70a0e8c6b4248e427e63d987dce7498f0c64ee)) +* :necktie: Add removeuser func ([3da7701](https://github.com/dnum-mi/dso-console/commit/3da77019f350b4dee5031ffbf5a97735299b0df9)) +* :necktie: users[].email must be unique ([d74b99d](https://github.com/dnum-mi/dso-console/commit/d74b99d642cdb3dea3fcd18116f644aa34542dbf)) +* :poop: dinindex turbo ([d7e78cb](https://github.com/dnum-mi/dso-console/commit/d7e78cb1e15c7008f761af42961bdc2754b117d0)) +* :poop: Pass current location to redirectUri ([ee65e42](https://github.com/dnum-mi/dso-console/commit/ee65e42daaff2fb20c4a9142a5ba87357e415088)) +* :poop: test 2 ([5c3b1da](https://github.com/dnum-mi/dso-console/commit/5c3b1da4c4d95ce3a9294c175f4826e174ae6436)) +* :poop: try to remove .tubro/ ([01ba3ef](https://github.com/dnum-mi/dso-console/commit/01ba3efaa8efacbb97638c5e89aebd3255149d2b)) +* :poop: turbo unindexation test ([2e7ba79](https://github.com/dnum-mi/dso-console/commit/2e7ba79848aae2fc174e364de867ff59159ed39c)) +* :recycle: Baudoin's review, userProfile in store ([c1a34b4](https://github.com/dnum-mi/dso-console/commit/c1a34b4c2c48455874829966d220b0b3137a010c)) +* :recycle: Harmonize api's names ([71ed577](https://github.com/dnum-mi/dso-console/commit/71ed577474dbe431cc1ebdb83336510ac93c58c8)) +* :recycle: Merge conf and init in a single keycloak file ([e777b02](https://github.com/dnum-mi/dso-console/commit/e777b028ce94df3288ab03bc6bdeea2ba2408fde)) +* :recycle: service url construction ([6b25ab3](https://github.com/dnum-mi/dso-console/commit/6b25ab3d40449da2b50ecd12604c6030aa0b287d)) +* :see_no_evil: Ignore .tubro ([d927f05](https://github.com/dnum-mi/dso-console/commit/d927f0559ecff8d3805709d9eca0487786295b52)) +* :sparkles: Add allTiles for all services, adapt projectTiles to selectedProject ([d207065](https://github.com/dnum-mi/dso-console/commit/d207065a628e26293f441a38ae9dd17950188d7c)) +* :sparkles: Add DsoService content ([024cbd0](https://github.com/dnum-mi/dso-console/commit/024cbd0c50475413a2a41097e301c88c87885363)) +* :sparkles: Add envList to project ([02e5215](https://github.com/dnum-mi/dso-console/commit/02e5215ec25a9dc7decd6c7d4d26c68a3a1b5fa5)) +* :sparkles: Add infra repo checkbox ([b0c0602](https://github.com/dnum-mi/dso-console/commit/b0c06020ea9f2aa1580514aa9498aec38cb3824b)) +* :sparkles: Add sidemenu and tmp routes ([be02a0c](https://github.com/dnum-mi/dso-console/commit/be02a0c2184b8ff92b13745efdfdda5626d91ec6)) +* :sparkles: Add specific endpoints to console api ([a35b9cc](https://github.com/dnum-mi/dso-console/commit/a35b9ccf452db0db438990633eded6b8d5ae0be7)) +* :sparkles: Add user to project ([85d5a1c](https://github.com/dnum-mi/dso-console/commit/85d5a1c98f9374ba9c7fb9b4f179bb8f40a6b0f3)) +* :sparkles: Add users[i].id in query ([35a36b4](https://github.com/dnum-mi/dso-console/commit/35a36b437d224b3d4b490a7c0ff2fdfe42f80410)) +* :sparkles: Add vault url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :sparkles: Add windicss to project ([8db02f5](https://github.com/dnum-mi/dso-console/commit/8db02f531f6f4dbc768ae726597b957cb8cb8e3a)) +* :sparkles: Create projects view ([aa0d833](https://github.com/dnum-mi/dso-console/commit/aa0d83356ab679261893cb563237062cdbbc04e6)) +* :sparkles: Finish v1 orderProject form ([590eb74](https://github.com/dnum-mi/dso-console/commit/590eb744f9daab7b9e34e6e2e9a376257f8290da)) +* :sparkles: Prepare ansible call for project provisioning ([a3d0918](https://github.com/dnum-mi/dso-console/commit/a3d09185837675f7954dad3185651ca300373bb1)) +* :sparkles: Separate ansible wrapper in a new api called ansible-api ([bcd64db](https://github.com/dnum-mi/dso-console/commit/bcd64dbdfa3c043bcf7c77f783ce1d36e43e4d05)) +* :speech_balloon: Correct services url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :speech_balloon: Update app's title ([869e2a3](https://github.com/dnum-mi/dso-console/commit/869e2a34455f1adb80d52e7fc5c4bc4c884368dd)) +* :speech_balloon: Update services url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :technologist: Add husky unit tests for server ([75e3a23](https://github.com/dnum-mi/dso-console/commit/75e3a23199ba15de9229809558a2b2569e38bdd4)) +* :technologist: Add watch src for hot reload ([e4e129d](https://github.com/dnum-mi/dso-console/commit/e4e129db4803f3bc5ed5a1ee3fc759ebcef38d89)) +* :truck: Better images for logos ([8ce9112](https://github.com/dnum-mi/dso-console/commit/8ce911280348e49d9f197dc9947d0d7d5bf5cee4)) +* :truck: Create folder projects in views, create store project ([a1df0f6](https://github.com/dnum-mi/dso-console/commit/a1df0f6bfcdbdba96d9b8c1c77e8500074f59512)) +* login on keycloack TEST ([20ab492](https://github.com/dnum-mi/dso-console/commit/20ab492a78f5e3e81a3295a3416a4422422d4244)) +* page login ([0c3946b](https://github.com/dnum-mi/dso-console/commit/0c3946b0ff04889b8b0fcf7b5160fea4409b3e8d)) +* **projectInit:** :building_construction: Add client folder ([9f3e90d](https://github.com/dnum-mi/dso-console/commit/9f3e90d305aebc6d9508d4570ffc0d1fac9502f2)) +* **projectInit:** :heavy_plus_sign: Add Stylelint ([21f33e0](https://github.com/dnum-mi/dso-console/commit/21f33e0554b04932a35ca723934c24bd93122513)) +* **projectInit:** :tada: Vite vue3 basic installation ([41aeb82](https://github.com/dnum-mi/dso-console/commit/41aeb824d02ba387bc8c842b07351fd55b221782)) + +### Bug Fixes + +* :alembic: edit code to run with new ansible directories ([8822d95](https://github.com/dnum-mi/dso-console/commit/8822d953e93db6f4ddc00383e7ad4a8c01e8cbaf)) +* :alembic: edit server image, kubeconfig fix for ansible ([5e02c5e](https://github.com/dnum-mi/dso-console/commit/5e02c5ef97a5fc67cfd30d296b36bf339fff8251)) +* :alembic: Try to fix keycloak redirect ([954bc51](https://github.com/dnum-mi/dso-console/commit/954bc513be0ff9a1e46b140af334273f710b9ec2)) +* :ambulance: fix a commit written too late in the night ([ee0cc99](https://github.com/dnum-mi/dso-console/commit/ee0cc99fab9a243ffddcc5ed874cbcd449d319f4)) +* :art: add env for keycloak in FRONT ([dea4e38](https://github.com/dnum-mi/dso-console/commit/dea4e38b36d9bb672f4f9e1079e9540d0e93d5af)) +* :art: rename file user in stores ([86be41e](https://github.com/dnum-mi/dso-console/commit/86be41e2ee7d6c8d0a0baad9507fe46fb8a527b4)) +* :bug: Add case sensitivity for init deb script ([699dedd](https://github.com/dnum-mi/dso-console/commit/699dedd5c8b149d8de83b632184fdb28b15784fc)) +* :bug: Add tagName (type) so it's closeable ([8aa65d5](https://github.com/dnum-mi/dso-console/commit/8aa65d53d03641ae30b0f989874f4acb85889b3f)) +* :bug: duplicated line ([2dad819](https://github.com/dnum-mi/dso-console/commit/2dad819540e76852251ef3ac8b7c3618abec0c71)) +* :bug: Finish removeUser feature ([25a4f36](https://github.com/dnum-mi/dso-console/commit/25a4f363ca99b3dd5b9a9adf6cf940021c509a5e)) +* :bug: Fix after dsfr's upgrade ([9273618](https://github.com/dnum-mi/dso-console/commit/92736182d04f6a965b15d0514a8c18e329ebec7a)) +* :bug: Fix bugs revealed by e2e tests ([f9ff1fc](https://github.com/dnum-mi/dso-console/commit/f9ff1fc8c633b894864489cb7b8c51eaf733e012)) +* :bug: Fix db init if not exists ([d3a7b77](https://github.com/dnum-mi/dso-console/commit/d3a7b779fb00399a94e6db570583bed77e4fc355)) +* :bug: Fix endless redirection ([7762a83](https://github.com/dnum-mi/dso-console/commit/7762a8358b6a76879e41ac6ea5a29765ec36a2e1)) +* :bug: Fix init db error ([9b50685](https://github.com/dnum-mi/dso-console/commit/9b5068519d7d26126c9730d32b375cb9e9243e92)) +* :bug: Fix multiples bugs ([fc94d99](https://github.com/dnum-mi/dso-console/commit/fc94d992ee0495113f7e5f4cd165f90b2460dd9d)) +* :bug: Fix potentital bugs ([ec04eee](https://github.com/dnum-mi/dso-console/commit/ec04eeeeb745be5e4a4ab5ad8a1c60e08cb74168)) +* :bug: Handle cases where no users ([47befb9](https://github.com/dnum-mi/dso-console/commit/47befb9e0dcfa07e733596aa5ccd9323e7447ad6)) +* :bug: Handle cases where users key does not exist in project ([98381dc](https://github.com/dnum-mi/dso-console/commit/98381dc3a746993c563b0b817cd6e985c40d4cc6)) +* :bug: Improve backend & fix in adequation of front changes ([a4df195](https://github.com/dnum-mi/dso-console/commit/a4df19533cd2e634149c549e5a717bf7b5bd1280)) +* :bug: kc logout ([c3621b9](https://github.com/dnum-mi/dso-console/commit/c3621b9f4db92b1a73a21b53d3894d79f2bdeb1d)) +* :bug: Last reviews ([4f39f8a](https://github.com/dnum-mi/dso-console/commit/4f39f8aea216aee29ff1128a7c5b60174e98b489)) +* :bug: multiple fixes, increase some log verbosity ([39e543b](https://github.com/dnum-mi/dso-console/commit/39e543bc8071d8323af96e85a6f6c8b7beffce19)) +* :bug: Reinit projects in store before pushing query result ([53d6c3f](https://github.com/dnum-mi/dso-console/commit/53d6c3f94e1211a69034c580abc6bd2731d7e717)) +* :bug: remove extra properties, it failed tests ([98abead](https://github.com/dnum-mi/dso-console/commit/98abeadf4ad5aad0eaab3f706500e27c98444ce3)) +* :bug: Remove unintentionaly added import ([8c6cab6](https://github.com/dnum-mi/dso-console/commit/8c6cab6fa05b57639003ea1e514da973d79fad3b)) +* :bug: Review tobi, an array even empty is always true ([9fe7068](https://github.com/dnum-mi/dso-console/commit/9fe706894c930fa5f2d187c0ce1500d75a797e29)) +* :bug: update for production ready ([4e80d61](https://github.com/dnum-mi/dso-console/commit/4e80d61fd3f9ec0526d99b3f3fec65a4ddfa78b0)) +* :coffin: Remove test class ([38a39ed](https://github.com/dnum-mi/dso-console/commit/38a39ed849fdcc7f62bdb24223f5cd74407e5aa9)) +* :coffin: Remove unused function ([b557bdc](https://github.com/dnum-mi/dso-console/commit/b557bdcb5b8fcb927ffc30cf9e4c0830d35b5363)) +* :construction_worker: Remove prod flag for dev stage in server dockerfile ([3e28a00](https://github.com/dnum-mi/dso-console/commit/3e28a00d1a652d46fcb0072471090ee2ad0fb899)) +* :construction: broken install in test yml ([1121d8b](https://github.com/dnum-mi/dso-console/commit/1121d8b7f90fc301a303c8705a039e1175d55eae)) +* :construction: Fix init keycloak in frontend ([0b41546](https://github.com/dnum-mi/dso-console/commit/0b41546b355e3583dd02fc1793c24c30af860030)) +* :construction: Wip reinit newUser object ([699a0bb](https://github.com/dnum-mi/dso-console/commit/699a0bbf8600efed4d1bf62a07d02f78c0e1c38c)) +* :construction: working on keyclock + pinia ([95d5822](https://github.com/dnum-mi/dso-console/commit/95d5822b23277b24a87f6f6d6c8ca77fc6a49926)) +* :fire: Remove useless silentchecksso & move cypress/vue to optional dep ([3b85f7a](https://github.com/dnum-mi/dso-console/commit/3b85f7a7f792d6fcb0b7f9bf8b94fe82b02ed577)) +* :globe_with_meridians: All english for dso-console ([6d45cba](https://github.com/dnum-mi/dso-console/commit/6d45cba12a6540dd13a933f6940b91701dbdab34)) +* :green_heart: Fix e2e tests in ci & improve code structure ([0857f70](https://github.com/dnum-mi/dso-console/commit/0857f703f0445cd49940d3337c4d94c9af4c70ea)) +* :lock: add binding in sql request to avoid sql injection ([46f5d04](https://github.com/dnum-mi/dso-console/commit/46f5d048e9df384c8f116bfdd8c9b7005160b5bf)) +* :loud_sound: Add logs for investigation ([b8f4867](https://github.com/dnum-mi/dso-console/commit/b8f48679f350ef34e856925fc9ff42ecfa1f281a)) +* :memo: Last review ([f37ad55](https://github.com/dnum-mi/dso-console/commit/f37ad5549d4e863cc78ed970c0525b4ed421b7f1)) +* :package: add jmespath lib ([5fcdd41](https://github.com/dnum-mi/dso-console/commit/5fcdd41c74ca678ce8cdfa79e5626d4eaeaba14c)) +* :pencil2: Typo in js ([ef99847](https://github.com/dnum-mi/dso-console/commit/ef99847908ce21cd28bddc8787101d9448c1976d)) +* :recycle: Refacto brainstorm thibault ([4e24417](https://github.com/dnum-mi/dso-console/commit/4e24417f7ac7a09dd57aa93f19d174ff679dcf74)) +* :rewind: Readd vitest config in shared ([2ec8b14](https://github.com/dnum-mi/dso-console/commit/2ec8b149a8d47136ac8305359688679e1eac1316)) +* :rewind: Rebase bug fix ([dbfaa2b](https://github.com/dnum-mi/dso-console/commit/dbfaa2b4732f51d6e1de0f731c55fa25518cc91c)) +* :rotating_light: Warn in console for missing required id ([db26c58](https://github.com/dnum-mi/dso-console/commit/db26c587106f1be4bf4765a0ea207adde71bb0ff)) +* :technologist: Add env exemple ([64fec78](https://github.com/dnum-mi/dso-console/commit/64fec783624dd4e3a625dbdd16f0bf9736adcc0d)) +* :test_tube: (to remove) failing tests skipped ([aeaded1](https://github.com/dnum-mi/dso-console/commit/aeaded1671f5de18d9e613fa32fda162a6147a27)) +* :white_check_mark: Correct api call in repo e2e test ([64d5196](https://github.com/dnum-mi/dso-console/commit/64d51968bad97f50f36a1619916b7c2b82378078)) +* :white_check_mark: Fix e2e tests on repo ([de0c0a5](https://github.com/dnum-mi/dso-console/commit/de0c0a59e02b639a67066c28b9532a5d38e12e98)) +* :zap: remove ansible legacy from server ([68c9dc9](https://github.com/dnum-mi/dso-console/commit/68c9dc9100aff3e71ccdab18608f61f9420e720f)) +* conf keycloak for cors from localhost:8080 ([1e06ff0](https://github.com/dnum-mi/dso-console/commit/1e06ff033094024d4e1a4f66bab5443b4505247c)) +* login on keycloack TEST ([c983eed](https://github.com/dnum-mi/dso-console/commit/c983eedccdba49d024ba6e5726f69afd33e577a1)) +* **projectInit:** :bricks: Add ports in vite config ([5de6e86](https://github.com/dnum-mi/dso-console/commit/5de6e86e02343ff910023681960e3bf7a788becd)) +* **projectInit:** :bug: Add context in docker-compose ([3ac3a72](https://github.com/dnum-mi/dso-console/commit/3ac3a7297c1b81fdb93ed099605ceeb2044ad617)) +* **projectInit:** :hammer: Fix run test script for client ([272f007](https://github.com/dnum-mi/dso-console/commit/272f007803d35e2113646d57b715e84249fa65bf)) +* remove double code in dockerfile ([2951672](https://github.com/dnum-mi/dso-console/commit/29516725acf793168854296f6f35088ef5b3447c)) +* rename and add volumes for keycloak ([3d0d999](https://github.com/dnum-mi/dso-console/commit/3d0d999630906223e8cb89fa51b31022e04e1659)) +* run cypress in container ([4589169](https://github.com/dnum-mi/dso-console/commit/4589169d0bb0fd1c368c08869e80251d0d5f691a)) +* run with delay 10 second to wait keycloak initialize ([b736991](https://github.com/dnum-mi/dso-console/commit/b736991208b9a5fc0880fba294fa96dc073f4c8a)) +* update gitignore for all node_modules ([3988743](https://github.com/dnum-mi/dso-console/commit/39887438eceefe4b08b80ad056f2992baaf78494)) +* update implicit flow in config keycloak ([06d2f77](https://github.com/dnum-mi/dso-console/commit/06d2f77fcc07db2de780867b0bda1f49294b349e)) +* update pnpm lock ([344261d](https://github.com/dnum-mi/dso-console/commit/344261d2e1b37e8df9eed0cca90ed8e21697d17a)) + +### Reverts + +* :recycle: Move code to another git branch ([1c632c7](https://github.com/dnum-mi/dso-console/commit/1c632c715ffc01100d75c46c1e84a565195a10b3)) diff --git a/.jjconflict-side-1/CONTRIBUTING.md b/.jjconflict-side-1/CONTRIBUTING.md new file mode 100644 index 0000000000..50318b28ee --- /dev/null +++ b/.jjconflict-side-1/CONTRIBUTING.md @@ -0,0 +1,115 @@ +# Contribuer à Console Cloud Pi Native + +Merci de contribuer à la Console Cloud Pi Native. Ce guide s'applique aux +contributions internes et externes, qu'elles concernent du code, de la +configuration, des dépendances ou de la documentation. + +## Principes + +La Console fournit une interface unifiée pour piloter les services de l'offre +Cloud Pi Native, automatiser le cycle de vie des projets et appliquer les +exigences DevSecOps de sécurité, de qualité, de maintenabilité et +d'interopérabilité. + +Toute évolution doit démontrer son alignement avec cette vision avant toute +implémentation. Une contribution qui ne sert pas l'offre, son architecture ou +sa feuille de route ne peut pas être acceptée. + +## Qualifier le sujet avant de développer + +Toute contribution de code doit être adossée à un ticket. Ce ticket décrit : + +- le besoin traité et son alignement avec la vision produit ou la feuille de route ; +- le périmètre fonctionnel et technique de la contribution ; +- les critères d'acceptation ; +- les impacts connus sur la sécurité, les contrats API, les données, l'exploitation et la documentation. + +Pour une correction de bug, une procédure de reproduction, un test automatisé +de non-régression et, pour une interface, des captures d'écran sont fortement +recommandés lorsqu'ils sont pertinents. Leur absence ne bloque pas la +contribution lorsqu'ils ne peuvent pas s'appliquer. Pour une fonctionnalité, +joindre des maquettes ou croquis, même rudimentaires, est encouragé. + +Les corrections strictement documentaires ou typographiques, sans impact +fonctionnel, sont les seules contributions qui ne nécessitent pas de ticket. +Elles restent soumises au contrôle de périmètre et aux exigences de qualité +adaptées. + +## Respecter le périmètre + +Une pull request ou merge request ne contient que les changements nécessaires +au sujet décrit par son ticket. Les commits, fichiers ou changements sans +rapport direct avec ce sujet sont rejetés et doivent être proposés séparément, +avec leur propre ticket. + +Utilisez le [template de pull request](.github/PULL_REQUEST_TEMPLATE.md) pour +présenter le comportement modifié et les informations utiles à la revue. Son +évolution est traitée séparément de ce guide. + +## Obtenir les validations requises + +Une approbation explicite de l'équipe Socle est obligatoire pour toute +contribution de code qui n'est ni une correction de bug ni un changement +trivial. + +L'approbation explicite de l'équipe Socle est obligatoire dans tous les cas +pour : + +- l'ajout, la suppression ou la mise à niveau d'une dépendance Node.js ; +- l'ajout ou la mise à niveau d'un outil installé dans une image Docker ; +- tout autre changement de dépendance ayant un impact sur la chaîne d'approvisionnement, la sécurité ou la maintenance. + +Les corrections de bugs et les changements triviaux ne dispensent ni du +contrôle de périmètre, ni de la revue de code, ni des contrôles qualité. Une +dépendance ajoutée pour corriger un bug doit donc recevoir l'approbation +explicite de l'équipe Socle. + +## Cibler le backend approuvé + +`apps/server` est le backend historique et ne reçoit plus de contribution. +Toute pull request ou merge request qui modifie ce dossier est rejetée, y +compris pour une correction de bug. + +Les évolutions backend ciblent exclusivement `apps/server-nestjs`. Elles +suivent le [plan de modularisation backend vers NestJS](apps/server-nestjs/documentation/Modularisation-de-console-server/README.md), l'[état de migration](apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md), les conventions NestJS et la stratégie de tests associée. Toute dérogation est une décision explicite de l'équipe Socle, hors du flux normal de contribution. + +## Respecter l'architecture et la qualité + +Respectez les frontières et conventions de la zone modifiée : + +- le frontend Vue 3 et le système de design existant ; +- les modules NestJS et Prisma dans `apps/server-nestjs` pour le backend ; +- les contrats partagés, permissions et schémas de données ; +- les plugins et leurs mécanismes de cycle de vie lorsqu'ils sont concernés. + +Ajoutez ou adaptez les tests proportionnés au risque et au périmètre : tests +unitaires ou d'intégration pour la logique et les contrats, et tests Playwright +pour les parcours utilisateurs affectés. Mettez à jour la documentation lorsqu'un +comportement, une API, une configuration, une opération ou une migration est +affectée. + +Avant soumission, exécutez les contrôles adaptés à votre modification : + +```bash +pnpm lint +pnpm test +pnpm playwright:test +``` + +Le test Playwright est requis lorsque la contribution affecte un parcours +utilisateur. Respectez aussi TypeScript strict, ESLint, Stylelint pour le +client, les hooks Husky et la CI. Les messages de commit suivent Conventional +Commits et sont rédigés en anglais. Rebasez votre branche sur `origin/main` +avant la demande de fusion. + +## Documents de référence + +Consultez et citez les références appropriées dans le ticket : + +- la [documentation publique Cloud Pi Native](https://github.com/cloud-pi-native/documentation), notamment l'[introduction de la plateforme](https://github.com/cloud-pi-native/documentation/blob/main/docs/platform/introduction.md) et la [feuille de route](https://github.com/cloud-pi-native/documentation/blob/main/docs/platform/roadmap.md) ; +- l'[architecture et les conventions du dépôt](README.md) ; +- le [plan de modularisation NestJS](apps/server-nestjs/documentation/Modularisation-de-console-server/README.md) ; + +La feuille de route publique est actuellement en cours de rédaction. Son +indisponibilité ne bloque pas la qualification : le ticket doit alors exposer +une justification concise au regard de la vision produit. diff --git a/.jjconflict-side-1/ENVIRONMENTS.md b/.jjconflict-side-1/ENVIRONMENTS.md new file mode 100644 index 0000000000..011939c593 --- /dev/null +++ b/.jjconflict-side-1/ENVIRONMENTS.md @@ -0,0 +1,244 @@ +# Fichiers de configuration d'environnements + +Cette documentation a pour but de détailler tout ce qui concerne la gestion des configuration d'environnements (appelé couramment "fichiers .env"). + +Comme vous aurez pu le constater, il y a beaucoup de choses à configurer pour un projet d'ampleur comme l'est la Console de CPiN, et il y a des cas d'usages très spécifiques qui seront décrits ici. Vous avez également la possibilité de composer votre propre manière de gérer vous variables d'environments en vous basant sur ce qui a été fait pour nous. + +## Contexte : migration progressive vers NestJS (Strangler Fig) + +Le backend de la console est en cours de migration de `apps/server` (Fastify/legacy) vers `apps/server-nestjs` (NestJS). Cette migration est progressive, route par route, selon le [Strangler Fig Pattern](https://martinfowler.com/bliki/StranglerFigApplication.html). + +Pour orchestrer cette migration, un **nginx-strangler** a été introduit (ticket [#1885](https://github.com/cloud-pi-native/console/issues/1885)). Il s'intercale entre le `client` et les backends, et route chaque requête API vers le bon service : + +``` +[client :8080] + │ proxy /api → nginx-strangler:8080 + ▼ +[nginx-strangler :8080] + ├── /api/[routes migrées] ──→ server-nestjs:3001 (NestJS) + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +**Conséquences pratiques pour les développeurs :** + +- `server-nestjs` est désormais présent dans tous les `docker-compose` aux côtés de `server`. +- Le `nginx-strangler` est le nouveau point d'entrée unique pour les appels API — le `client` ne pointe plus directement vers `server`. +- Consultez [`MODULARISATION-STATUT.md`](apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md) avant de développer sur un module backend, pour savoir s'il est en cours de migration (zones en feature freeze). +- Si vous travaillez sur un module déjà migré vers `server-nestjs`, développez dans `apps/server-nestjs`, pas dans `apps/server`. + +Pour plus de détails sur la stratégie de migration : [`apps/nginx-strangler/README.md`](apps/nginx-strangler/README.md). + +--- + +## Cas d'usage supportés + +Avant de décrire dans le détail comment configurer les différents environments, il est important de rappeler les Cas d'Usage qui sont supportés par les scripts de CPiN: + +- **Développement totalement en local** : vous déployez l'entièreté de l'écosystème de `console`, sans considération d'un branchement à l'extérieur, et vous développez directement en mode "serve" sur `client`, `server`, `server-nestjs`, un `plugin` en particulier, ou peut-être même une combinaison des trois. +- **Développement conteneurisé en local** : vous déployez l'entièreté de l'écosystème de `console`, sans considération d'un branchement à l'extérieur, et même les composants fondamentaux de console (comme `client`, `server` et `server-nestjs`) dans des conteneurs à l'aide d'images construites précédemment (en local, ou alors tirée depuis le registre de l'organisation CPiN) +- **Développement conteneurisé hybride** : vous déployez localement seulement une partie de l'écosystème de `console` (`client` et/ou `server`/`server-nestjs`, de manière à utiliser votre code local) et pour le reste (Base de données de `console`, `keycloak`, `gitlab`, les clusters applicatifs, etc.), vous vous branchez à un environnement existant (appelé `integ` pour `INTEGRATION`). Ce cas d'usage est très pratique pour tester votre code avec de "vraies données" d'un environnement fonctionnel (comme notre environnement interne `cpin-hp`) +- Les cas de déploiements finaux du système complet, qui sont eux adressé par un chart Helm stocké dans [le dépôt `helm-charts`](https://github.com/cloud-pi-native/helm-charts) + +Maintenant que ces définitions sont établies, passons à la configuration pour chacun des cas d'usage + +## Considérations communes à tous les cas + +Le mode de fonctionnement de la configuration des environnements est assez classique : les applications `client`, `server` et `server-nestjs` ont besoin d'avoir certaines variables d'environnements définies. + +Le mécanisme de surcharge des différentes configurations fonctionne de cette manière : + +``` +->: «surcharge» + +var d'env settée explicitement -> fichier .env.docker (si contexte docker) -> fichier .env.integ (si INTEGRATION=true) -> fichier .env +``` + +## Prégénération des fichiers .env, .env.docker, et .env.integ + +Un script permet de copier facilement les fichiers `.env*-example` en leur équivalent `.env*`: [`./ci/scripts/init-env.sh`](./ci/scripts/init-env.sh). + +> Il faut ensuite remplir ces fichiers, car ils ne sont là que simplement copiés avec les valeurs par défaut + +## Configuration pour le développement entièrement en local + +Le développement entièrement en local suit cette organisation des composants: + +```mermaid +--- +title: Architecture mode entièrement local +--- +graph LR + + subgraph DEV_LOCAL["DEV LOCAL"] + direction LR + NAV_L["Navigateur"] + NGINX_L(("nginx (client)\n:8080\nLOCAL")) + STRANGLER_L(("nginx-strangler\n:8080\n🐳 exposé :4000")) + SERVER_L(("server\n:4001\nLOCAL")) + NESTJS_L(("server-nestjs\n:3001\nLOCAL")) + + NAV_L --> NGINX_L + NGINX_L -- "/api" --> STRANGLER_L + STRANGLER_L -- "défaut\n(pas migré)" --> SERVER_L + STRANGLER_L -- "migré" --> NESTJS_L + end + + style DEV_LOCAL fill:#fff,stroke:#333,stroke-width:2px + style NAV_L fill:#fff,stroke:#333 +``` + +Docker Compose utilisé : [`docker/docker-compose.local.yml`](docker/docker-compose.local.yml) (infrastructure uniquement : Keycloak, PostgreSQL, pgAdmin, OpenCDS mock, **nginx-strangler, et Jaeger**) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/server/.env` +- `apps/server-nestjs/.env` *(si vous travaillez sur server-nestjs)* + +Les valeurs par défaut, disponibles dans les fichiers `.env-example`, sont suffisantes dans 99% des cas. Cela dit, c'est un cas d'usage assez restreint car la console se reposant sur quelques composants externes comme une base de données PostgreSQL ou un serveur IAM comme `keycloak`, il faut configurer manuellement les `.env` pour pointer sur les bonnes URLs, etc. Pas infaisable, mais pas très pratique au quotidien, hors des cas simple de build des applications. + +**Commandes de lancement :** + +```bash +# Lance l'infrastructure (Keycloak, PostgreSQL, pgAdmin, OpenCDS mock, nginx-strangler, Jaeger) +pnpm dev + +# Puis dans d'autres terminaux, lancer les serveurs et le client manuellement : +pnpm --filter server run dev +pnpm --filter server-nestjs run start:dev # nouveau backend NestJS +pnpm --filter client run dev +``` + +### Faire transiter les appels API par le nginx-strangler en dev local + +Par défaut, le proxy Vite du `client` pointe directement vers `server` (port `4000`). Pour faire transiter les appels via le `nginx-strangler` (utile pour tester le routage de migration ou travailler sur `server-nestjs`), une seule variable suffit dans `apps/client/.env` : + +```bash +# apps/client/.env +SERVER_PORT=8082 # port du nginx-strangler exposé par docker-compose.local.yml + # (au lieu de 4000 qui pointe directement vers server) +``` + +Le `nginx-strangler` est automatiquement lancé par `pnpm dev` via `docker-compose.local.yml`. Il écoute sur `localhost:8082` et redirige vers les deux backends natifs (`server:4000` et `server-nestjs:3001`) selon la configuration de [`apps/nginx-strangler/conf.d/routing.conf`](apps/nginx-strangler/conf.d/routing.conf). + +> **Note :** si vous ne travaillez pas sur la migration NestJS, vous n'avez pas besoin de changer `SERVER_PORT` — le comportement par défaut (proxy direct vers `server:4000`) reste identique. + +### Observabilité : Jaeger + OpenTelemetry (traces) + +Le fichier [`docker/docker-compose.local.yml`](docker/docker-compose.local.yml) démarre un service `jaeger` (image `jaegertracing/all-in-one`) pour collecter et visualiser les traces. + +- UI Jaeger : http://localhost:16686 +- Endpoints de collecte exposés sur la machine hôte : + - OTLP gRPC : `localhost:4317` + - OTLP HTTP (protobuf) : `http://localhost:4318` + +Dans `apps/server-nestjs`, l'instrumentation OpenTelemetry est initialisée au démarrage via [`src/instrumentation.ts`](apps/server-nestjs/src/instrumentation.ts) (appelée depuis `main.ts`) et exporte via OTLP. + +Pour vérifier rapidement : + +1. Démarrer l'infra : `pnpm dev` (Jaeger inclus). +2. Démarrer `server-nestjs` : `pnpm --filter server-nestjs run start:dev`. +3. Exécuter une requête sur une route backend (depuis le client ou un `curl`). +4. Ouvrir http://localhost:16686 et chercher le service `cloud-pi-native-console`. + +## Configuration pour le développement conteneurisé en local + +Docker Compose utilisé : [`docker/docker-compose.dev.yml`](docker/docker-compose.dev.yml) (tout conteneurisé avec Docker Compose Watch) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/client/.env.docker` +- `apps/server/.env` +- `apps/server/.env.docker` +- `apps/server-nestjs/.env` +- `apps/server-nestjs/.env.docker` + +Cette configuration est déjà plus intéressante, car elle s'appuie sur les conteneurs définis dans [ce docker-compose](docker/docker-compose.dev.yml), qui lance notamment une base de données PostreSQL (ainsi qu'un `pgadmin`), et un serveur Keycloak préchargé avec un royaume qui contient un jeu de données. Le docker-compose contient des instructions `develop` qui permettent soit de synchroniser certains fichiers, soit de carrément reconstruire l'image et de relancer le service concerné. De cette manière vous pouvez développer en laissant les conteneurs tourner. C'est un peu moins performant qu'un travail totalement en local, mais ça a le mérite d'être plus proche du déploiement cible. + +Le `nginx-strangler` et `server-nestjs` sont inclus dans ce docker-compose et démarrent automatiquement. Le `client` pointe vers `nginx-strangler` — le routage API est donc toujours actif, même si aucune route n'est encore basculée vers NestJS (tout passe par `server` en fallback). + +**Commande de lancement :** + +```bash +# Lance l'ensemble des conteneurs (client, server, server-nestjs, nginx-strangler, +# keycloak, postgres, pgadmin, opencds mock) avec Docker Compose Watch +pnpm docker:dev +``` + +## Configuration pour le développement hybride avec un environnement d'intégration existant + +Le développement hybride branché sur l'environnement d'intégration suit cette organisation des composants: + +```mermaid +--- +title: Architecture mode hybride branché sur l'environment d'intégration +--- +graph LR + + subgraph DEV_INTEG["DEV INTEG"] + direction LR + NAV_I["Navigateur"] + CURL_I["curl directs\n(tests)"] + INTEG["INTEG\n(keycloak, gitlab, etc.)"] + + subgraph DOCKER_NET["🐳 Réseau Docker"] + direction LR + NGINX_I(("nginx (client)\n:8080\nexposé :8080")) + STRANGLER_I(("nginx-strangler\n:8080\nexposé :4000")) + SERVER_I(("server\n:8080\nexposé :4001")) + NESTJS_I(("server-nestjs\n:3001\nexposé :3001")) + + NGINX_I -- "/api" --> STRANGLER_I + STRANGLER_I -- "pas migré" --> SERVER_I + STRANGLER_I -- "migré" --> NESTJS_I + end + + NAV_I --> NGINX_I + SERVER_I -. "accès externe" .-> INTEG + NESTJS_I -. "accès externe" .-> INTEG + + CURL_I -- ":8080" --> NGINX_I + CURL_I -- ":4000" --> STRANGLER_I + CURL_I -- ":4001" --> SERVER_I + CURL_I -- ":3001" --> NESTJS_I + end + + style DEV_INTEG fill:#fff,stroke:#333,stroke-width:2px + style DOCKER_NET fill:#e6f3ff,stroke:#2196F3,stroke-width:2px + style NAV_I fill:#fff,stroke:#333 + style CURL_I fill:#fff,stroke:#333 + style INTEG fill:#fff,stroke:#333,stroke-dasharray: 5 5 +``` + +Docker Compose utilisé : [`docker/docker-compose.integ.yml`](docker/docker-compose.integ.yml) (sans Keycloak, branché sur l'environnement d'intégration distant) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/client/.env.docker` +- `apps/client/.env.integ` +- `apps/server/.env` +- `apps/server/.env.docker` +- `apps/server/.env.integ` +- `apps/server-nestjs/.env` +- `apps/server-nestjs/.env.docker` +- `apps/server-nestjs/.env.integ` *(si vous travaillez sur server-nestjs en mode integ)* + +Cette configuration est une itération de la précédente. Dans ce cas d'usage le Keycloak n'est pas créé en tant que conteneur, car on est supposé se brancher sur l'environnement d'intégration défini dans les fichiers `.env.integ`. Le contenu de ces fichiers (en particulier celui de `apps/server`) est donc clé. + +Le `nginx-strangler` et `server-nestjs` sont également inclus dans ce docker-compose. + +**Commandes de lancement :** + +```bash +# Option 1 : Tout conteneurisé, branché sur l'environnement d'intégration +pnpm docker:integ + +# Option 2 : Seulement l'infra en Docker (postgres, pgadmin), server et client en local avec mode integ +pnpm integ +# Puis dans d'autres terminaux : +pnpm --filter server run dev +pnpm --filter server-nestjs run start:dev +pnpm --filter client run dev +``` diff --git a/.jjconflict-side-1/LICENSE b/.jjconflict-side-1/LICENSE new file mode 100644 index 0000000000..4b139aa8bc --- /dev/null +++ b/.jjconflict-side-1/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Ministère de l’Intérieur et des Outre-Mer + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/.jjconflict-side-1/README.md b/.jjconflict-side-1/README.md new file mode 100644 index 0000000000..3a89503a2b --- /dev/null +++ b/.jjconflict-side-1/README.md @@ -0,0 +1,409 @@ +# Console Cloud π Native + +La console Cloud π Native est une application web ayant pour but de piloter des services dans un cluster Kubernetes afin de fournir une plateforme cloud qui accompagne les produits numériques lors de toutes les phases de leur cycle de vie. + +Cette console offre une interface unifiée vers un ensemble de services tout en garantissant une cohérence globale du système avec la création automatique d'un certain nombre de ressources comme les comptes d'accès, les robots ou encore des ressources Kubernetes. +En addition du provisionnement automatique, elle garantit aussi le contrôle d'accès aux ressources du projet à l'aide d'une gestion d'équipe, de permissions, de quotas, etc. + +<img src="./misc/images/projects.png" width="30%"></img> <img src="./misc/images/repositories.png" width="30%"></img> <img src="./misc/images/environments.png" width="30%"></img> <img src="./misc/images/services.png" width="30%"></img> <img src="./misc/images/members.png" width="30%"></img> <img src="./misc/images/status.png" width="30%"></img> + +## Démarrage Rapide (OnBoarding) + +Afin de démarrer rapidement l'application pour la découvrir il vous faudra d'abord cloner ce dépôt, puis satisfaire à un certain nombre de prérequis. + +### Cloner le projet + +```bash +git clone https://github.com/cloud-pi-native/console.git + +# Se rendre dans le dossier du projet +cd console +``` + +### Prérequis + +#### Prérequis techniques du projet + +- [Docker >= v27](https://docs.docker.com/get-docker/) Orchestrateur de conteneurs + - [Plugin compose >= v2.35](https://github.com/docker/compose) (attention à ne pas avoir une vieille version qui traînerait dans `~/.docker/cli-plugins/` !). Permet de composer plusieurs conteneurs Docker + - [Plugin buildx](https://github.com/docker/buildx) Permet d'étendre les capacités de Docker à l'aide de BuildKit +- [Node.js >= v24](https://nodejs.org/en/download/) Environnement d'exécution JavaScript +- [PnPM >= v10](https://pnpm.io/installation) Gestionnaire de paquets pour JavaScript + +#### Prérequis de configuration du projet + +Afin de pouvoir démarrer l'application, il vous faudra également récupérer (ou bien créer vous-même) les fichiers : + +- `apps/client/.env.docker` +- `apps/client/.env.integ` +- `apps/server/.env.docker` +- `apps/server/.env.integ` + +Si vous ne les avez pas déjà, référez-vous à [./ENVIRONMENTS.md](./ENVIRONMENTS.md) qui est la documentation consacrée au sujet. + +### Lancement rapide utilisant l'application en local et les services sur un environnement distant + +Maintenant que vous avez les prérequis projets (prérequis techniques et de configuration), vous pouvez construire et lancer l'application localement, en la couplant avec les services (par exemple Keycloak) d'un environnement d'intégration : + +```bash +# Installer toutes les dépendances +$ pnpm install + +# Initialiser la base de données PostgreSQL lancée localement +$ pnpm db:generate + +# Construire les applications client et server +$ pnpm build + +# Construire les images dockers client et server, puis lancer la stack docker compose d'intégration +$ export KUBECONFIG_HOST_PATH="$HOME/.kube/config" && pnpm run docker:integ +``` + +Cette série de commandes va installer les diverses dépendances, construire l'intégralité de l'application Console (client et serveur) dans des images docker locales, et finalement lancer l'application en la connectant dans votre infrastructure d'Intégration existante. + +Si vous voulez une vue plus approfondie du lancement de l'application, vous pouvez vous référer à la [section détaillée à ce sujet](#développement). + +## Architecture + +Ce projet est construit avec [NodeJS](https://nodejs.org/), [VueJS](https://vuejs.org/), [Postgres](https://www.postgresql.org/) et [Keycloak](https://www.keycloak.org/). +Le serveur et le client sont livrés sous forme d'images [Docker](https://www.docker.com/) et sont déployées à l'aide de [Helm](https://helm.sh/) dans [Kubernetes](https://kubernetes.io/). + +### API + +Le serveur est construit selon une architecture __core / plugins__ pour favoriser l'évolutivité et l'ajout de nouvelles fonctionnalités / la gestion de nouveaux services. Pour ce faire, les plugins s'enregistrent auprès de différents `hooks` (qui suivent le cycle de vie d'un projet au sein de l'application), ces derniers seront déclenchés par les contrôleurs de l'application. + +Plusieurs plugins sont nativement enregistrés auprès du serveur pour assurer le bon fonctionnement de la plateforme, à savoir : + +- [Argocd](https://argo-cd.readthedocs.io/en/stable/) +- [Gitlab](https://about.gitlab.com/) +- [Harbor](https://goharbor.io/) +- [Keycloak](https://www.keycloak.org/) +- [Kubernetes](https://kubernetes.io/) +- [Nexus](https://www.sonatype.com/products/sonatype-nexus-repository) +- [Sonarqube](https://www.sonarsource.com/products/sonarqube/) +- [Vault](https://www.vaultproject.io/) + +> Pour plus d'informations sur le développement d'un plugin, voir : +> - [documentation des plugins](./misc/plugins.md). +> - [documentation du module hooks](./packages/hooks/README.md). +> - [exemple de plugin](https://github.com/cloud-pi-native/console-plugin-helloworld). + +### Gestion des rôles + +La console gère le contrôle d'accès via des rôles (administration de la console et rôles au niveau projet) composés d'un nom, d'un ensemble de permissions (bitmask) et d'un type (`type`). + +Chaque rôle est associé à un groupe OIDC/Keycloak (`oidcGroup`) mais la source de vérité peut varier selon le type : + +- `managed` : la console est source de vérité pour les membres et les permissions, puis synchronise l'appartenance avec le groupe `oidcGroup`. +- `external` : le groupe OIDC est la source de vérité pour l'appartenance ; la console s'aligne dessus. +- `global` : rôle d'administration destiné à porter des permissions transverses telle que le role "Tout le monde". + +Le préfixe `system:` est un qualifiant (pas un type à part entière) utilisé pour marquer les rôles fournis/maintenus par la plateforme, par exemple `system:managed`, `system:external`, `system:global`. + +Dans l'interface, un rôle dont le type commence par `system:` est considéré comme non modifiable : ses champs (nom/type/groupe/permissions) sont en lecture seule et la suppression est désactivée. Selon l'écran, la gestion des membres peut rester disponible. + +## Développement + +Le développement s'effectue à l'aide de Docker *(le client et le serveur peuvent tourner en local ou dans Docker)*. + +### Construire l'application + +Une fois le projet cloné, lancez les commandes suivantes dans votre terminal pour installer le projet et construire l'ensemble de l'application `console` : + +```shell +# Installer les dépendances du projet +pnpm install + +# Créer les fichiers d'environnement exemples +./ci/scripts/init-env.sh + +# Générer le client Prisma côté serveur +pnpm --filter @cpn-console/server run db:generate + +# Construire tous les paquets applicatifs +pnpm build +``` + +#### Lancer l'application + +L'application peut se lancer de plusieurs manières, à savoir : + +__Déploiement local du client et du serveur de la console, le reste dans des conteneurs :__ + +```shell +# Lancer keycloak, postgres et pgadmin dans des conteneurs +pnpm run dev + +# Lancer le serveur dans un shell (la commande va afficher les logs) +pnpm --filter @cpn-console/server run dev + +# Lancer le client dans un shell (la commande va afficher les logs) +pnpm --filter @cpn-console/client run dev + +# …une fois que vous terminé d'utiliser l'application: + +# Supprimer les conteneurs keycloak, postgres et pgadmin +pnpm run dev:clean + +# Supprimer les conteneurs keycloak, postgres et pgadmin (supprime les volumes docker) +pnpm run dev:delete +``` + +__Tout dans des conteneurs :__ + +```shell +# Lancer l'application +pnpm run docker:dev + +# Supprimer les conteneurs +pnpm run docker:dev:clean + +# Supprimer les conteneurs (supprime également les volumes !) +pnpm run docker:dev:delete +``` + +> Pour lancer le debugger Node.js sur le serveur, dans les fichiers `docker-compose` remplacer la directive `command: ["dev"]` par `command: ["debug"]`. + +#### Intégration + +L'application peut se lancer de plusieurs manières, à savoir : + +__Déploiement local du client et du serveur de la console, le reste dans des conteneurs :__ + +```shell +# Lancer postgres et pgadmin dans des conteneurs +# (le keycloak utilisé devra être configuré dans les .env.integ) +pnpm run integ + +# Lancer le serveur +pnpm --filter @cpn-console/server run integ + +# Lancer le client +pnpm --filter @cpn-console/client run integ + +# Supprimer les conteneurs postgres et pgadmin +pnpm run integ:clean + +# Supprimer les conteneurs postgres et pgadmin (supprime les volumes docker) +pnpm run integ:delete +``` + +__Tout dans des conteneurs :__ + +```shell +# Lancer l'application +pnpm run docker:integ + +# Supprimer les conteneurs +pnpm run docker:integ:clean + +# Supprimer les conteneurs (supprime les volumes docker) +pnpm run docker:integ:delete +``` + +#### Utilitaires + +Les commandes utilitaires de l'application : + +```shell +# Formattage du code +pnpm run format + +# Lancer la vérification syntaxique +pnpm run lint + +# Lancer les tests unitaires +pnpm run test + +# Lancer les tests de composants +pnpm run test:ct +``` + +L'intégralité des commandes est disponibles dans le fichier [package.json](./package.json) à la racine du projet, vous pouvez lancer ces dernières à l'aide de la commande `pnpm run <le_nom_du_script>`. + +### Tests Playwright + +Se référer à la [documentation concernée](./playwright/README.md). + +### Accès aux services + +Les services sont disponibles via les ports suivants : + +| Service | Url (local/docker) | +| ---------------------------------------------- | ----------------------- | +| Interface graphique *- (client)* | <http://localhost:8080> | +| Serveur *- (api)* | <http://localhost:4000> | +| Interface d'administration de base de données | <http://localhost:8081> | +| Interface d'administration du serveur keycloak | <http://localhost:8090> | + +*__Notes:__ ⚠ Il est possible que le navigateur utilisé (particulièrement Brave ou Firefox) bloque les cookies utilisés entre le frontend et keycloak, il est nécessaire de désactiver les protections de ce type dans votre navigateur (ex: Brave Shield).* + +### Informations de connexion + +Les comptes utilisés pendant le développement sont les suivants : + +| Service | Nom d'utilisateur | Mot de passe | +| ------------------ | ----------------- | ------------ | +| Keycloak *(admin)* | `admin` | `admin` | +| Keycloak *(user)* | `test` | `test` | +| PgAdmin | `admin@dso.fr` | `admin` | +| Postgres | `admin@dso.fr` | `admin` | + +> La liste complète des comptes Keycloak pré-créés est disponible [ici](./keycloak/realms/realm-dev.json). + +> Le nom de la base de données est : `dso-console-db`. + +### Variables d'environnements + +__Local / Docker:__ + +Les variables d'environnements sont gérées localement via des fichiers `.env` (local) / `.env.docker` (docker) dans les dossiers `./apps/server` et `./apps/client`, aux précédents fichiers s'ajoute un fichier `.env.integ` utilisé pour le mode intégration (local et docker). + +### Base de données + +Pour faciliter les opérations de migrations de base de données via [Prisma](https://www.prisma.io/), un script est disponible : + +```shell +# Lancer le script +pnpm --filter @cpn-console/server run db:wrapper + +# Voir l'aide du script +pnpm --filter @cpn-console/server run db:wrapper -h +``` + +### Débogage avec VS Code + +Le projet est configuré pour être débogué directement dans VS Code via le fichier `.vscode/launch.json`. +Rendez-vous dans l'onglet "Run and Debug" (ou `Ctrl+Shift+D`) pour voir les configurations disponibles. + +#### Configurations disponibles + +- **Launch Server** : Lance le serveur en local (hors Docker) et attache le débogueur. + - *Note : Assurez-vous que les bases de données (Postgres) et Keycloak sont accessibles (ex: lancés via `pnpm run dev` dans un autre terminal pour les dépendances).* +- **Attach Server** : S'attache au processus Node.js du serveur. + - Utile lorsque le serveur tourne dans un conteneur Docker. + - Pour que cela fonctionne, le serveur doit être lancé avec le mode debug activé. +- **Serve Client (Chrome/Firefox)** : Lance l'application client en local et ouvre une instance de navigateur dédiée au débogage. +- **Launch (Chrome/Firefox)** : Lance simultanément le serveur et le client pour une session de débogage complète (Compound task). + +#### Déboguer avec d'autres outils (Chrome DevTools, WebStorm, etc.) + +Le serveur Node.js expose le port de débogage `9229` lorsqu'il est lancé en mode debug (ou avec l'option `--inspect`). Vous pouvez utiliser n'importe quel client de débogage compatible avec le protocole Inspector de Node.js. + +**Exemple avec Chrome DevTools :** + +1. Lancez le serveur en mode debug (voir section précédente pour Docker, ou `pnpm run debug` en local). +2. Ouvrez Chrome et naviguez vers `chrome://inspect`. +3. Cliquez sur "Open dedicated DevTools for Node" ou attendez que votre target apparaisse sous "Remote Target". +4. Vous pouvez maintenant mettre des points d'arrêt, inspecter les variables, etc. + +### Construction des images + +Ce dépôt utilise des fichiers docker-compose pour construire les images docker: + +- [docker-compose.local.yml](./docker/docker-compose.local.yml) pour le développement local (`client`/`server`/`server-nestjs` lancés localement, et une "glue" docker pour les joindre) +- [docker-compose.dev.yml](./docker/docker-compose.dev.yml) pour la construction des images docker du mode développement. +- [docker-compose.ci.yml](./docker/docker-compose.ci.yml) pour la construction des images docker utilisées dans nos pipelines d'Intégration Continue. + +## Configuration du Keycloak + +Pour pouvoir gérer les droits utilisateurs des services le pod `server` doit accéder aux groupes des users. Cela signifie modifier le clientScope `profile`: + +- Onglet `Mappers` +- `Add Mappers > By configuration > Group Membership` + - Name: `groups` + - Token Claim Name: `groups` + - Full group path: `off` + - Add to ID token: `on` + - Add to access token: `on` + - Add to userinfo: `off` + +> En environnement de dev l'import par défaut prévoit déjà cette modification. + +Les utilisateurs faisant parti du group `admin` ont également accès à l'interface administrateur de la console une fois connectés via un onglet supplémentaire `Administration` dans le menu latéral de l'application. + +## Tableau des ressources, terminologie + +| Console Cloud Pi | Projet | Environnement | Dépots | Utilisateur / membre | +| ---------------- | ---------------------------- | ------------- | --------------------------------------- | -------------------- | +| __Openshift__ | | Namespace | | | +| __ArgoCD__ | | | (infra) Secret, AppProject, Application | | +| __Gitlab__ | Group | | Repository (Dépôt) | User | +| __Harbor__ | Project | | Repository [1] | | +| __Keycloak__ | | Group | | User / member | +| __Sonar__ | User | | | | +| __Nexus__ | Repositories, role, user ... | | | | + +[1] N'est pas crée par la console mais par le produit de la CI + +## Architecture du dépôt + +La gestion des dépendances est effectuée à l'aide de [pnpm](https://pnpm.io/) selon la structure de dossiers suivante : + +- Les différentes briques applicatives se trouvent dans le dossier `apps/`. +- Les bibliothèques additionnelles se trouvent dans le dossier `packages/`. +- Les plugins core se trouvent dans le dossier `plugins/`. + +*Schema de l'architecture du monorepo :* + +```shell +./ +├── apps +│ ├── client +│ └── server +├── packages +│ ├── eslintconfig +│ ├── hooks +│ ├── shared +│ ├── test-utils +│ └── tsconfig +├── plugins +│ ├── argocd +│ ├── gitlab +│ ├── harbor +│ ├── keycloak +│ ├── kubernetes +│ ├── nexus +│ ├── sonarqube +│ └── vault +├── package.json +├── pnpm-lock.yaml +├── pnpm-workspace.yaml +└── README.md +``` + +## Organisation avec Git + +Une requête de fusion ("merge request") doit être faite avec la branche `main` comme destination. + +La branche de base **doit** être à jour avec `origin/main` (utiliser `git pull origin/main && git rebase origin/main` ou l'option de rebasage dans l'interface Web si besoin) avant la demande de fusion (MR). + +## Conventions de nommage + +Cf. [Conventions - MIOM Fabrique Numérique](https://docs.fabrique-numerique.fr/conventions/nommage.html). + +Les commits doivent suivre la spécification des [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). Cette norme est utilisée pour construire la nouvelle version, voir ci-après. + +Il est possible d'ajouter l'[extension VSCode](https://github.com/vivaxy/vscode-conventional-commits) pour faciliter la création des commits. + +> Les messages de commits sont rédigés en anglais. + +## Gestion des versions + +Se référer à [./RELEASE.md](./RELEASE.md). + +## Gestion des dépendances + +### Mise à jour des dépendances + +À propos du cycle de vie des dépendances, et notamment leur mise à jour, un document spécifique est disponible [ici](https://github.com/cloud-pi-native/documentation-interne-socle/blob/main/Divers/Mode-op%C3%A9ratoire-mont%C3%A9es-version-librairies.md). Ce document détaille la stratégie choisie en interne de mise à jour des dépendances de la Console CPiN. + +Concernant la mise à jour de `browserlist` et de `caniuse-lite`, utiliser la commande suivante sachant qu'on utilise `pnpm` : + +```shell +$ pnpm update caniuse-lite -r +``` + +### Dépendances Helm + +La console embarque une dépendance avec le Helm Chart utilisé par ArgoCD pour consommer le fichier values qu'elle construit. Afin d'assurer la compatibilité, il faut toujours que la version du Helm chart associé ait été publiée au préalable (voir [la documentation dédiée](https://github.com/cloud-pi-native/helm-charts?tab=readme-ov-file#contribution)). diff --git a/.jjconflict-side-1/RELEASE.md b/.jjconflict-side-1/RELEASE.md new file mode 100644 index 0000000000..300f31fd01 --- /dev/null +++ b/.jjconflict-side-1/RELEASE.md @@ -0,0 +1,110 @@ +# Gestion des versions + +Ce document décrit comment est géré le versionnement de `console`, c'est-à-dire : + +- Préparer la création d'une nouvelle version de `console` au fur et à mesure des ajouts +- Créer effectivement la nouvelle version +- Mettre à jour le chart Helm concerné +- Publier les nouvelles versions de modules NPM concernés + +## Incrémentation des versions + +Afin d'éviter une confusion entre les hotfixes, qui sont des versions `PATCH` qui ensuite rétroportées sur la branche principale (`main`), et les versions régulières qui n'ont que des commits `fix:` (et donc produisent par défaut, elles aussi, des versions `PATCH`), nous avons décidé d'adopter le protocole de versionnement suivant : + +- Les versions régulières sur `main` sont par défaut des `MINOR` (et très rarement des `MAJOR`) +- Les versions de hotfix d'une version en particulier sont **forcément** des `PATCH` (ceci est forcé dans le [flux de travail GitHub `Create new release`](https://github.com/cloud-pi-native/console/tree/main/.github/workflows/job-release-please.yml#L39)) + +La structure de versionnement de `console` est donc **`MAJOR`.`MINOR`.`HOTFIX`** + +## Schéma récapitulatif + +Les sections suivantes vont expliciter ce schéma: + +```mermaid +gitGraph + commit id: "…previous commits" + commit id: "Add basic features" + + branch release-please-main + checkout release-please-main + commit id:"bump to v1.2.0" tag: "v1.2.0" + + checkout main + merge release-please-main + checkout release-please-main + branch "hotfix/urgent-fix-for-v1.2.0" + commit id: "Fix stuff" + branch release-please-hotfix_v1.2.1 + commit id:"bump to v1.2.1" tag: "v1.2.1" + checkout "hotfix/urgent-fix-for-v1.2.0" + merge release-please-hotfix_v1.2.1 + + checkout main + commit id: "Add features" + checkout release-please-main + commit id:"bump to v1.3.0" tag: "v1.3.0" + merge main + commit id:"bump to v1.3.0" tag: "v1.3.0" + checkout main + commit id: "More features" + checkout release-please-main + merge main + commit id:"bump to v1.3.0 (recreated)" tag: "v1.3.0 (recreated)" + checkout main + merge release-please-main + + checkout main + merge "hotfix/urgent-fix-for-v1.2.0" + commit id:"backport v1.2.1 fix onto main" + checkout release-please-main + commit id:"bump to v1.4.0" tag: "v1.4.0" + merge main + commit id:"bump to v1.4.0" tag: "v1.4.0" + checkout main + merge release-please-main +``` + +## Versionnement de Console + +Le flux de travail qui créé les nouvelles versions s'intitule [`create-or-update-release`](https://github.com/cloud-pi-native/console/blob/main/.github/workflows/workflow-create-or-update-release.yml) et est déclenché à chaque nouveau commit sur `main` (soit lorsqu'on fusionne une requête de fusion, soit un commit poussé en outrepassant l'interdiction de pousser sur `main`) ou sur une branche `hotfix/*`. + +Le flux de travail utilise [release-please-action](https://github.com/googleapis/release-please-action) pour automatiquement générer les tags Git ainsi que les nouvelles versions sur GitHub. À chaque fois que du code est poussé dans la branche `main` ou un branche `hotfix/*`, une requête de fusion de version est créée en analysant les messages de commits pour déterminer le numéro de version à créer (`PATCH`, `MINOR`, ou `MAJOR`). Si une requête de fusion de nouvelle version existe déjà, elle est mise à jour afin de refléter les nouveaux changements ajoutés à la future nouvelle version. + +Les différent types de commits (`chore:`, `feat:`, `fix:` etc.) vont alimenter différentes sections de la `CHANGELOG`. Ces sections sont décrites dans la configuration de release-please, [`./release-please-config.json`](./release-please-config.json) + +Lorsqu'une requête de fusion de version (sur `main` ou `hotfix/*`) est fusionnée, les images de conteneur des applications (`client`, `server`, etc.) sont alors créées et hébergées dans la [registry Github associée au dépôt](https://github.com/orgs/cloud-pi-native/packages?repo_name=console) avec les tags appropriés (qui reflètent les tags git concernés). + +> Seuls les tags "complets" (`vX.Y.Z`) sont immutables, les tags "partiels" (`vX` et `vX.Y`) sont recréés pour relier la dernière version concernée. C'est pour ça que lorsque vous tirez les changements de `main` il est recommandé de faire un `git pull --tags --force` afin de forcer la recréation de vos tags locaux pour ces tags partiels. + +## Versionnement du chart Helm `dso-console` + +Le déploiement de `console` se fait préférablement à l'aide de son chart Helm, nommé [`dso-console`](https://github.com/cloud-pi-native/helm-charts/tree/main/charts/dso-console). + +La dernière étape du flux de travail de création de nouvelles versions (cf. section ci-dessus) est la création automatique d'une requête de fusion dans le dépôt `helm-charts` pour la mise à jour du chart Helm `dso-console`. Une fusion manuelle sur ce dépôt est alors nécessaire pour déclencher la publication de la nouvelle version du chart Helm (embarquant donc la nouvelle version de `console`). Exemple d'une telle requête de fusion de nouvelle version du chart Helm : https://github.com/cloud-pi-native/helm-charts/pull/204. + +> Les versions "régulières" (`MAJOR` ou `MINOR` depuis `main`) et les versions "hotfixes" (`PATCH` depuis `hotfix/*`) produisent le même type de requête de fusion côté `helm-charts`, car du point de vue de ce dépôt toute mise à jour de l'application est un `PATCH` bump côté chart. + +## Versionnement des modules NPM + +La publication des nouvelles versions de modules npm du dépôt est automatique et est inclus dans le flux de travail de création d'une nouvelle version. Il analyse les numéros de version présents dans les différents fichiers `package.json` pour déterminer si une nouvelle version du module doit être créée et publiée. + +> Il est possible de créer une version de pré-release d'un module npm en modifiant la clé `publishConfig.tag` dans le `package.json` avec par exemple `beta` pour générer une version beta. + +## Hotfixes + +Autant que faire se peut il vaut mieux privilégier le "Fix Forward" avec de nouvelles versions, afin d'éviter la charger de générer/rétroporter un hotfix. + +Ceci étant dit, il arrivera, hélas, qu'un hotfix soit nécessaire sur une version livrée. + +Voici donc le processus compatible avec l'utilisation de `release-please`: + +- Se placer localement sur le tag de la version concernée: `$ git checkout v1.2.0` (`v1.2.0` est ici la version à hotfixer) +- En tirer une branche dédiée au hotfix: `$ git checkout -b hotfix/my-urgent-hotfix-for-v1.2.0` (Note: Il n'est pas nécessaire de spécifier la version dans le nom de la branche, mais ça peut aider à la lecture et ainsi confirmer la version concernée) +- Faire les modifications nécessaires, committer, etc. +- Pousser la nouvelle branche sur le dépôt Github +- ⚠ Si vous voulez faire une "preview" de cette branche il faudra très probablement créer une **autre branche** qui cible `main` et y résoudre les conflits avant de faire une `preview`. En effet il est fortement probable que si vous faites un hotfix, la branche `main` aura déjà une nouvelle version (sinon vous ne feriez pas un hotfix, vous feriez simplement une nouvelle version 😁). Or il faut savoir que [Github ne permet pas l'exécution des workflows en cas de conflits](https://github.com/orgs/community/discussions/26304). Donc si vous voulez une `preview` du hotfix il faudra une MR dédiée, et dont vous aurez résolu les hotfixes (ça devrait être normalement limité aux fichiers contenant la version comme `CHANGELOG.md`). C'est pénible, mais c'est comme ça. +- Une fois la nouvelle branche poussée, `release-please` va être déclenché par le flux de travail Github `create-or-update-release` afin de créer une requête de fusion pour la nouvelle version hotfixée (avec comme cible la branche de hotfix). Il est d'ailleurs à noter que dans le cas d'un hotfix **on ne fait qu'une montée du "PATCH"** (ici on obtiendra donc la version `v1.2.1`, qui est alors le premier hotfix de la version `v1.2.0`) quelque soit les commits (donc même un `feat!` ne fera pas de montée majeure) +- Valider la MR de version hotfixée (créée donc par `release-please`) à l'aide du flux de travail Github `Continuous Integration` +- Une fois la MR de version hotfixée validée et fusionnée, la nouvelle version est créée et, comme pour les versions traditionnelles, une requête de fusion est crée dans le dépôt `helm-charts` pour avoir là aussi une version hotfixée (mais, pour le chart Helm, c'est considéré comme une version classique) +- Il faudra ensuite faire des picorages (`git cherry-pick`) ou une MR de la branche de hotfix vers `main` afin d'intégrer le ou les commits de hotfix dans la prochaine version officielle +- ⚠ Il faut retagger le commit de hotfix quand on a backporté le fix + le commit de bump de la release hotfix sur `main`, car sinon la Release côté GitHub pointe vers un tag dont le commit n'est pas sur `main` (le SHA a logiquement changé lorsqu'il a été backporté). Refaire donc localement un `git tag vX.Y.Z commit-de-release-backporté-sur-main` et pusher avec `git push --tags --force` pour forcer la mise à jour du tag. SI VOUS NE FAITES PAS ÇA LES CHANGELOG VONT TOTALEMENT PARTIR EN VRILLE ⚠ diff --git a/.jjconflict-side-1/apps/client/.env-example b/.jjconflict-side-1/apps/client/.env-example new file mode 100644 index 0000000000..2f23f06f97 --- /dev/null +++ b/.jjconflict-side-1/apps/client/.env-example @@ -0,0 +1,24 @@ +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Hôte du serveur backend vers lequel le client proxifie les appels API +SERVER_HOST=localhost +# Port d'écoute du serveur backend +# En développement local avec le nginx-strangler (pnpm dev +# + docker-compose.local.yml), il vaut mieux mettre SERVER_PORT=4000 pour que +# le proxy Vite passe par nginx-strangler plutôt que directement par server (4001) +# ou server-nestjs (3001). +SERVER_PORT=4000 +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté frontend +KEYCLOAK_CLIENT_ID=dso-console-frontend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# Active/désactive le module OpenCDS (true | false) +OPENCDS_ENABLED=false +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-side-1/apps/client/.env.docker-example b/.jjconflict-side-1/apps/client/.env.docker-example new file mode 100644 index 0000000000..1f05a462c6 --- /dev/null +++ b/.jjconflict-side-1/apps/client/.env.docker-example @@ -0,0 +1,21 @@ +# Spécifique à ce ficher. Permet de dire que la stack est conteneurisée +DOCKER=true +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Hôte du nginx vers lequel le client proxifie les appels API +SERVER_HOST=nginx-strangler +SERVER_PORT=8080 +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté frontend +KEYCLOAK_CLIENT_ID=dso-console-frontend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# Active/désactive le module OpenCDS (true | false) +OPENCDS_ENABLED=false +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-side-1/apps/client/.env.integ-example b/.jjconflict-side-1/apps/client/.env.integ-example new file mode 100644 index 0000000000..4f870dfd2f --- /dev/null +++ b/.jjconflict-side-1/apps/client/.env.integ-example @@ -0,0 +1,11 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Domaine du Keycloak de l'environnement d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak de l'environnement d'intégration +KEYCLOAK_REALM= +# En intégration, Keycloak est généralement exposé en HTTPS +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak frontend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= diff --git a/.jjconflict-side-1/apps/client/.eslintrc-auto-import.json b/.jjconflict-side-1/apps/client/.eslintrc-auto-import.json new file mode 100644 index 0000000000..253b4b4683 --- /dev/null +++ b/.jjconflict-side-1/apps/client/.eslintrc-auto-import.json @@ -0,0 +1,93 @@ +{ + "globals": { + "Component": true, + "ComponentPublicInstance": true, + "ComputedRef": true, + "DirectiveBinding": true, + "EffectScope": true, + "ExtractDefaultPropTypes": true, + "ExtractPropTypes": true, + "ExtractPublicPropTypes": true, + "InjectionKey": true, + "MaybeRef": true, + "MaybeRefOrGetter": true, + "PropType": true, + "Ref": true, + "VNode": true, + "WritableComputedRef": true, + "acceptHMRUpdate": true, + "computed": true, + "createApp": true, + "createPinia": true, + "customRef": true, + "defineAsyncComponent": true, + "defineComponent": true, + "defineStore": true, + "effectScope": true, + "getActivePinia": true, + "getCurrentInstance": true, + "getCurrentScope": true, + "h": true, + "inject": true, + "isProxy": true, + "isReactive": true, + "isReadonly": true, + "isRef": true, + "mapActions": true, + "mapGetters": true, + "mapState": true, + "mapStores": true, + "mapWritableState": true, + "markRaw": true, + "nextTick": true, + "onActivated": true, + "onBeforeMount": true, + "onBeforeRouteLeave": true, + "onBeforeRouteUpdate": true, + "onBeforeUnmount": true, + "onBeforeUpdate": true, + "onDeactivated": true, + "onErrorCaptured": true, + "onMounted": true, + "onRenderTracked": true, + "onRenderTriggered": true, + "onScopeDispose": true, + "onServerPrefetch": true, + "onUnmounted": true, + "onUpdated": true, + "onWatcherCleanup": true, + "provide": true, + "reactive": true, + "readonly": true, + "ref": true, + "resolveComponent": true, + "setActivePinia": true, + "setMapStoreSuffix": true, + "shallowReactive": true, + "shallowReadonly": true, + "shallowRef": true, + "storeToRefs": true, + "toRaw": true, + "toRef": true, + "toRefs": true, + "toValue": true, + "triggerRef": true, + "unref": true, + "useAttrs": true, + "useCssModule": true, + "useCssVars": true, + "useId": true, + "useLink": true, + "useModel": true, + "useRoute": true, + "useRouter": true, + "useScheme": true, + "useSlots": true, + "useTabs": true, + "useTemplateRef": true, + "watch": true, + "watchEffect": true, + "watchPostEffect": true, + "watchSyncEffect": true + } +} diff --git a/.jjconflict-side-1/apps/client/.stylelintrc.js b/.jjconflict-side-1/apps/client/.stylelintrc.js new file mode 100644 index 0000000000..86614a8803 --- /dev/null +++ b/.jjconflict-side-1/apps/client/.stylelintrc.js @@ -0,0 +1,40 @@ +export default { + root: true, + extends: [ + 'stylelint-config-standard', + 'stylelint-config-html/vue', + 'stylelint-config-html', + 'stylelint-config-recommended-vue', + ], + ignoreDisables: false, + rules: { + 'at-rule-no-deprecated': [ + true, + { + ignoreAtRules: [ + 'windi', + 'apply', + 'include', + 'variants', + 'responsive', + 'screen', + ], + }, + ], + 'at-rule-no-unknown': [ + true, + { + ignoreAtRules: [ + 'windi', + 'apply', + 'include', + 'variants', + 'responsive', + 'screen', + ], + }, + ], + 'no-descending-specificity': null, + 'selector-class-pattern': '^((sm|md|lg|xl|2xl):)?[a-z][-_/a-z0-9]*$', + }, +} diff --git a/.jjconflict-side-1/apps/client/Dockerfile b/.jjconflict-side-1/apps/client/Dockerfile new file mode 100644 index 0000000000..b6784f3a82 --- /dev/null +++ b/.jjconflict-side-1/apps/client/Dockerfile @@ -0,0 +1,48 @@ +# Dev stage ------------------------------------------------------------------------ +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Install pnpm version defined in package.json "packageManager" property +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY --chown=node:root apps/client/package.json ./apps/client/package.json +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +RUN pnpm install --ignore-scripts # --no-optional + +COPY --chown=node:root packages/ ./packages/ +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +COPY --chown=node:root apps/client/ ./apps/client/ +RUN pnpm --filter client run icons + +ENTRYPOINT [ "pnpm", "--filter", "client", "run", "dev" ] + + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +ARG APP_VERSION +RUN pnpm --filter @cpn-console/client run build + + +# Prod stage ----------------------------------------------------------------------- +FROM nginxinc/nginx-unprivileged:1.31-alpine AS prod + +COPY --chown=nginx:root --from=build /app/apps/client/dist /etc/nginx/html/ +COPY --chown=nginx:root --chmod=660 ./apps/client/nginx/default.conf /etc/nginx/conf.d/default.conf +COPY --chown=nginx:root --chmod=750 ./apps/client/nginx/entrypoint.sh /docker-entrypoint.d/99-envsubst-js-assets.sh + +RUN chmod -R g=u /etc/nginx/html + +EXPOSE 8080 diff --git a/.jjconflict-side-1/apps/client/README.md b/.jjconflict-side-1/apps/client/README.md new file mode 100644 index 0000000000..00810a3778 --- /dev/null +++ b/.jjconflict-side-1/apps/client/README.md @@ -0,0 +1,56 @@ +# Console Cloud π Native - Client + +## Installation + +```sh +npm install +``` + +## Lancement de l'app + +```sh +npm run dev +``` + +## Lancement des tests unitaires + +```sh +npm run test +``` + +## Formattage du code + +```sh +# Lister les problèmes de formatage +npm run lint + +# Régler automatiquement les problèmes de formatage +npm run format +``` + +## Build de l'app + +```sh +npm run build +``` + +## Prévisualisation du code de production + +```sh +npm run preview +``` + +## Récupérer toutes les icones du projets +`grep -r -oh "ri:[a-z0-9-]*" . | sort | uniq | cut -d ':' -f 2 | awk NF | awk '{print " \047" $1 "\047,"}'` + +## Activation OpenCDS + +Se référer à [la documentation dédiée](../../packages/opencds/README.adoc) + +## Crédits + +- [vue](https://github.com/vuejs/) +- [vue-dsfr](https://github.com/dnum-mi/vue-dsfr) +- [vite](https://github.com/vitejs/vite) +- [stylelint](https://github.com/stylelint/stylelint) +- [eslint](https://github.com/eslint/eslint) diff --git a/.jjconflict-side-1/apps/client/eslint.config.js b/.jjconflict-side-1/apps/client/eslint.config.js new file mode 100644 index 0000000000..88c06179d3 --- /dev/null +++ b/.jjconflict-side-1/apps/client/eslint.config.js @@ -0,0 +1,6 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase.overrideRules({ + 'e18e/prefer-static-regex': 'off', + 'vue/no-required-prop-with-default': 'off', +}) diff --git a/.jjconflict-side-1/apps/client/index.html b/.jjconflict-side-1/apps/client/index.html new file mode 100644 index 0000000000..b57fbfc4ab --- /dev/null +++ b/.jjconflict-side-1/apps/client/index.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html lang="fr"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" href="/favicon.ico" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Console Cloud π Native + + +
+ + + diff --git a/.jjconflict-side-1/apps/client/jsconfig.json b/.jjconflict-side-1/apps/client/jsconfig.json new file mode 100644 index 0000000000..a5cee14f16 --- /dev/null +++ b/.jjconflict-side-1/apps/client/jsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "jsx": "preserve", + "baseUrl": ".", + "module": "ESNext", + "moduleResolution": "node", + "paths": { + "@/*": [ + "./src/*" + ] + }, + "sourceMap": true + }, + "exclude": [ + "node_modules" + ] +} diff --git a/.jjconflict-side-1/apps/client/nginx/README.md b/.jjconflict-side-1/apps/client/nginx/README.md new file mode 100644 index 0000000000..b2e55f8ea1 --- /dev/null +++ b/.jjconflict-side-1/apps/client/nginx/README.md @@ -0,0 +1,5 @@ +# À propos + +Ce nginx est celui utilisé à fin d'exposer les fichiers de l'application "SPA" (Single Page Application) de la Console. + +**À ne pas confondre avec [`nginx-strangler`](../../nginx-strangler) qui est le composant permettant le transfert progressif des routes de `server` vers `server-nestjs`** diff --git a/.jjconflict-side-1/apps/client/nginx/default.conf b/.jjconflict-side-1/apps/client/nginx/default.conf new file mode 100644 index 0000000000..60cfae693b --- /dev/null +++ b/.jjconflict-side-1/apps/client/nginx/default.conf @@ -0,0 +1,16 @@ +server { + listen 8080; + server_name localhost; + root /etc/nginx/html; + index index.html; + + large_client_header_buffers 4 32k; + + location / { + try_files $uri $uri/ @rewrites; + } + + location @rewrites { + rewrite "^(.+)$" /index.html last; + } +} diff --git a/.jjconflict-side-1/apps/client/nginx/default.docker.conf b/.jjconflict-side-1/apps/client/nginx/default.docker.conf new file mode 100644 index 0000000000..74bca2057d --- /dev/null +++ b/.jjconflict-side-1/apps/client/nginx/default.docker.conf @@ -0,0 +1,35 @@ +upstream api { + # Toutes les requêtes API transitent par nginx-strangler qui orchestre + # la bascule progressive vers server-nestjs (Strangler Fig Pattern, ticket #1885) + server nginx-strangler:8080; +} + +server { + listen 8080; + server_name localhost; + root /etc/nginx/html; + index index.html; + + large_client_header_buffers 4 32k; + + location / { + try_files $uri $uri/ @rewrites; + } + + location @rewrites { + rewrite "^(.+)$" /index.html last; + } + + location ~ ^/(api|swagger-ui) { + rewrite "^(.*)$" $1 break; + add_header Access-Control-Allow-Origin '$http_origin'; + proxy_pass http://api; + proxy_redirect off; + proxy_buffering off; + proxy_http_version 1.1; + + # For WebSockets + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/.jjconflict-side-1/apps/client/nginx/entrypoint.sh b/.jjconflict-side-1/apps/client/nginx/entrypoint.sh new file mode 100755 index 0000000000..98dfe3bcd9 --- /dev/null +++ b/.jjconflict-side-1/apps/client/nginx/entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +ASSETS_DIR=/etc/nginx/html/assets + +make_envsubst_vars() { + fmt= + for name do + fmt="${fmt}${fmt:+ }\${$name}" + done + + printf '%s\n' "$fmt" +} + +ENV_VARS=$(make_envsubst_vars \ + SERVER_HOST \ + SERVER_PORT \ + OPENCDS_ENABLED \ + KEYCLOAK_PROTOCOL \ + KEYCLOAK_DOMAIN \ + KEYCLOAK_REALM \ + KEYCLOAK_CLIENT_ID \ + KEYCLOAK_REDIRECT_URI \ + CONTACT_EMAIL \ +) + +echo "Replacing env variables in JavaScript asset files..." +for file in $ASSETS_DIR/*.js; do + echo "Processing $file ..."; + envsubst "${ENV_VARS}" \ + < $file \ + > $file-out && \ + mv $file-out $file +done +echo "Done !" diff --git a/.jjconflict-side-1/apps/client/package.json b/.jjconflict-side-1/apps/client/package.json new file mode 100644 index 0000000000..a897dccf6f --- /dev/null +++ b/.jjconflict-side-1/apps/client/package.json @@ -0,0 +1,88 @@ +{ + "name": "@cpn-console/client", + "type": "module", + "version": "9.22.1", + "private": false, + "description": "cpn-console client", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "scripts": { + "build": "pnpm run icons && pnpm run type-check && pnpm run build-only", + "build-only": "vite --mode=production build", + "build:clean": "rimraf ./dist ./tsconfig.tsbuildinfo", + "dev": "vite --mode=development", + "format": "NODE_OPTIONS='--no-warnings=ExperimentalWarning' eslint ./ --fix", + "format:style": "stylelint ./src/**/*.{css,vue} --fix", + "icons": "vue-dsfr-icons -s scripts/icons.js -t src/icon-collections.ts", + "integ": "vite --mode=integration", + "lint": "pnpm run --parallel \"/^lint:.*/\"", + "lint:style": "stylelint ./src/**/*.{css,vue}", + "lint:ts": "NODE_OPTIONS='--no-warnings=ExperimentalWarning' eslint ./", + "preview": "vite preview --port 8080", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "type-check": "vue-tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@gouvfr/dsfr": "catalog:runtime", + "@gouvminint/vue-dsfr": "catalog:runtime", + "@iconify-json/ri": "catalog:runtime", + "@iconify/vue": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@vue/tsconfig": "catalog:build", + "axios": "catalog:runtime", + "javascript-time-ago": "catalog:runtime", + "jszip": "catalog:runtime", + "keycloak-js": "catalog:runtime", + "nanoid": "catalog:runtime", + "p-debounce": "catalog:runtime", + "pinia": "catalog:runtime", + "vue": "catalog:runtime", + "vue-router": "catalog:runtime", + "vue3-json-viewer": "catalog:runtime", + "xbytes": "catalog:runtime", + "yaml": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@iconify/types": "catalog:build", + "@types/jsdom": "catalog:types", + "@types/node": "catalog:types", + "@unocss/transformer-directives": "catalog:build", + "@vitejs/plugin-vue": "catalog:build", + "@vitest/coverage-v8": "catalog:test", + "@vue/eslint-config-typescript": "catalog:tools", + "chalk": "catalog:tools", + "eslint": "catalog:tools", + "eslint-plugin-vue": "catalog:tools", + "jsdom": "catalog:test", + "rimraf": "catalog:tools", + "stylelint": "catalog:tools", + "stylelint-config-html": "catalog:tools", + "stylelint-config-recommended-vue": "catalog:tools", + "stylelint-config-standard": "catalog:tools", + "typescript": "catalog:build", + "unocss": "catalog:build", + "unplugin-auto-import": "catalog:build", + "unplugin-vue-components": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vite-plugin-pwa": "catalog:build", + "vitest": "catalog:test", + "vue-eslint-parser": "catalog:tools", + "vue-tsc": "catalog:build", + "workbox-window": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/apps/client/public/browserconfig.xml b/.jjconflict-side-1/apps/client/public/browserconfig.xml new file mode 100644 index 0000000000..4826e1357a --- /dev/null +++ b/.jjconflict-side-1/apps/client/public/browserconfig.xml @@ -0,0 +1,2 @@ + +#ffffff diff --git a/.jjconflict-side-1/apps/client/public/favicon.ico b/.jjconflict-side-1/apps/client/public/favicon.ico new file mode 100644 index 0000000000..d5b9b0fcb9 Binary files /dev/null and b/.jjconflict-side-1/apps/client/public/favicon.ico differ diff --git a/.jjconflict-side-1/apps/client/public/img/architecture.png b/.jjconflict-side-1/apps/client/public/img/architecture.png new file mode 100644 index 0000000000..46a52e9c98 Binary files /dev/null and b/.jjconflict-side-1/apps/client/public/img/architecture.png differ diff --git a/.jjconflict-side-1/apps/client/public/img/argocd.svg b/.jjconflict-side-1/apps/client/public/img/argocd.svg new file mode 100644 index 0000000000..fb7bc6fe84 --- /dev/null +++ b/.jjconflict-side-1/apps/client/public/img/argocd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.jjconflict-side-1/apps/client/public/img/gitlab.svg b/.jjconflict-side-1/apps/client/public/img/gitlab.svg new file mode 100644 index 0000000000..602ffcc063 --- /dev/null +++ b/.jjconflict-side-1/apps/client/public/img/gitlab.svg @@ -0,0 +1 @@ +GitLab home page \ No newline at end of file diff --git a/.jjconflict-side-1/apps/client/public/img/harbor.svg b/.jjconflict-side-1/apps/client/public/img/harbor.svg new file mode 100644 index 0000000000..c5d042f6b6 --- /dev/null +++ b/.jjconflict-side-1/apps/client/public/img/harbor.svg @@ -0,0 +1,482 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.jjconflict-side-1/apps/client/public/img/logo-marianne.png b/.jjconflict-side-1/apps/client/public/img/logo-marianne.png new file mode 100644 index 0000000000..786e3d61f4 Binary files /dev/null and b/.jjconflict-side-1/apps/client/public/img/logo-marianne.png differ diff --git a/.jjconflict-side-1/apps/client/public/img/nexus.png b/.jjconflict-side-1/apps/client/public/img/nexus.png new file mode 100644 index 0000000000..809e5fbe1b Binary files /dev/null and b/.jjconflict-side-1/apps/client/public/img/nexus.png differ diff --git a/.jjconflict-side-1/apps/client/public/img/sonarqube.svg b/.jjconflict-side-1/apps/client/public/img/sonarqube.svg new file mode 100644 index 0000000000..c6bb30720f --- /dev/null +++ b/.jjconflict-side-1/apps/client/public/img/sonarqube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.jjconflict-side-1/apps/client/public/img/vault.svg b/.jjconflict-side-1/apps/client/public/img/vault.svg new file mode 100644 index 0000000000..d5be8c74d2 --- /dev/null +++ b/.jjconflict-side-1/apps/client/public/img/vault.svg @@ -0,0 +1 @@ +Vault \ No newline at end of file diff --git a/.jjconflict-side-1/apps/client/public/manifest.json b/.jjconflict-side-1/apps/client/public/manifest.json new file mode 100644 index 0000000000..a99d9409c7 --- /dev/null +++ b/.jjconflict-side-1/apps/client/public/manifest.json @@ -0,0 +1,41 @@ +{ + "name": "VueDsfr App", + "icons": [ + { + "src": "/icons/android-icon-36x36.png", + "sizes": "36x36", + "type": "image/png", + "density": "0.75" + }, + { + "src": "/icons/android-icon-48x48.png", + "sizes": "48x48", + "type": "image/png", + "density": "1.0" + }, + { + "src": "/icons/android-icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "density": "1.5" + }, + { + "src": "/icons/android-icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "density": "2.0" + }, + { + "src": "/icons/android-icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "density": "3.0" + }, + { + "src": "/icons/android-icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "density": "4.0" + } + ] +} diff --git a/.jjconflict-side-1/apps/client/scripts/create-collections.js b/.jjconflict-side-1/apps/client/scripts/create-collections.js new file mode 100644 index 0000000000..da1b4a6ef3 --- /dev/null +++ b/.jjconflict-side-1/apps/client/scripts/create-collections.js @@ -0,0 +1,18 @@ +#!env node +import path from 'node:path' + +import { createCustomCollectionFile } from '@gouvminint/vue-dsfr/meta' +import { Command } from 'commander' + +const program = new Command() + +program + .option('-s, --source ', 'Chemin vers le fichier de tuples [IconifyJSON, string[]]') + .option('-t, --target ', 'Chemin vers le fichier destination (src/icons.ts par défaut)') + .parse(process.argv) + +const options = program.opts() + +if (options.source && options.target) { + createCustomCollectionFile(path.resolve(process.cwd(), options.source), path.resolve(process.cwd(), options.target)) +} diff --git a/.jjconflict-side-1/apps/client/scripts/icons.js b/.jjconflict-side-1/apps/client/scripts/icons.js new file mode 100644 index 0000000000..c275232c2a --- /dev/null +++ b/.jjconflict-side-1/apps/client/scripts/icons.js @@ -0,0 +1,75 @@ +// @ts-check +import { icons as riCollection } from '@iconify-json/ri' + +/** + * @type {string[]} + */ +const riIconNames = [ + 'account-circle-line', + 'add-line', + 'admin-line', + 'arrow-drop-left-line', + 'arrow-drop-right-line', + 'arrow-go-back-line', + 'arrow-left-double-line', + 'arrow-right-double-line', + 'arrow-right-line', + 'arrow-right-s-line', + 'award-line', + 'building-line', + 'checkbox-blank-circle-fill', + 'checkbox-circle-line', + 'clipboard-line', + 'close-line', + 'code-s-slash-line', + 'dashboard-line', + 'delete-bin-7-line', + 'exchange-line', + 'eye-line', + 'eye-off-line', + 'file-download-line', + 'file-info-line', + 'filter-line', + 'filter-off-line', + 'flow-chart', + 'focus-3-line', + 'folder-line', + 'folder-shield-2-line', + 'folders-line', + 'folder-user-line', + 'github-line', + 'git-merge-line', + 'git-repository-private-line', + 'global-line', + 'key-2-line', + 'loader-4-line', + 'lock-line', + 'lock-unlock-line', + 'mail-line', + 'moon-clear-line', + 'newspaper-line', + 'pencil-line', + 'question-line', + 'refresh-line', + 'restart-line', + 'send-plane-line', + 'server-line', + 'settings-3-line', + 'shapes-line', + 'shield-check-line', + 'sound-module-line', + 'stock-line', + 'sun-line', + 'team-line', + 'tools-line', + 'upload-cloud-line', + 'user-add-line', + 'user-shared-2-line', +] + +/** + * @type {[import('@iconify/vue').IconifyJSON, string[]][]} + */ +export const collectionsToFilter = [ + [riCollection, riIconNames], +] diff --git a/.jjconflict-side-1/apps/client/src/App.vue b/.jjconflict-side-1/apps/client/src/App.vue new file mode 100644 index 0000000000..91e0e48742 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/App.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/api/xhr-client.ts b/.jjconflict-side-1/apps/client/src/api/xhr-client.ts new file mode 100644 index 0000000000..427c6b36ba --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/api/xhr-client.ts @@ -0,0 +1,57 @@ +import type { ApiFetcherArgs } from '@ts-rest/core' +import { logger } from '@cpn-console/logger/browser' +import { apiPrefix, getApiClient } from '@cpn-console/shared' +import { tsRestFetchApi } from '@ts-rest/core' +import { getKeycloak } from '@/utils/keycloak/keycloak.js' + +export const apiClient = await getApiClient( + '', + {}, + async (args: ApiFetcherArgs): Promise<{ status: number, body: { status: number, error: string } | unknown, headers: Headers }> => { + // Paths that do not require token + const validPaths = [`${apiPrefix}/version`, '/login', `${apiPrefix}/services`] + if (validPaths.some(validPath => args.path?.startsWith(validPath))) { + return tsRestFetchApi(args) + } + + // Envs that do not require token + if (process.env.NODE_ENV === 'test' && process.env.CT === 'true') { + return tsRestFetchApi(args) + } + + // Generate token + const keycloak = getKeycloak() + const token = keycloak.token + if (token) { + args.headers.Authorization = `Bearer ${token}` + } + + return tsRestFetchApi(args) + }, +) + +export function extractData(response: T, expectedStatus: S): Extract['body'] { + if (response.status === 401) { + const keycloak = getKeycloak() + if (!keycloak.authenticated) { + keycloak.login() + } + } + if (response.status >= 400) { + // `message` d'abord : les erreurs gérées du legacy (ErrorResType) et celles de NestJS + // (HttpException) portent le vrai message dans `message` — chez NestJS, `error` ne contient + // que la raison HTTP générique ("Forbidden", "Bad Request"). `error` reste en repli pour + // les erreurs non gérées du legacy (setErrorHandler), qui n'ont pas de champ `message`. + // @ts-ignore + throw new Error(response.body?.message ?? response.body?.error ?? 'Erreur inconnue') + } + if (response.status === expectedStatus) return response.body + try { + throw new Error(`Erreur lors de la requete, reçu ${response.status}, attendu ${expectedStatus}`) + } catch (error: unknown) { + if (error instanceof Error) { + logger.error({ err: error }, 'Request failed') + } + throw error + } +} diff --git a/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Bold.otf b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Bold.otf new file mode 100755 index 0000000000..4a051bcd7a Binary files /dev/null and b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Bold.otf differ diff --git a/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-BoldItalic.otf b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-BoldItalic.otf new file mode 100755 index 0000000000..d24f18d012 Binary files /dev/null and b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-BoldItalic.otf differ diff --git a/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-ExtraBold.otf b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-ExtraBold.otf new file mode 100755 index 0000000000..77b5d8ad1d Binary files /dev/null and b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-ExtraBold.otf differ diff --git a/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf new file mode 100755 index 0000000000..b5a48d1946 Binary files /dev/null and b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf differ diff --git a/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Light.otf b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Light.otf new file mode 100755 index 0000000000..5a7b8aad90 Binary files /dev/null and b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Light.otf differ diff --git a/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-LightItalic.otf b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-LightItalic.otf new file mode 100755 index 0000000000..15747f0a79 Binary files /dev/null and b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-LightItalic.otf differ diff --git a/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Medium.otf b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Medium.otf new file mode 100755 index 0000000000..cd15454d1b Binary files /dev/null and b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Medium.otf differ diff --git a/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-MediumItalic.otf b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-MediumItalic.otf new file mode 100755 index 0000000000..d7069ddb95 Binary files /dev/null and b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-MediumItalic.otf differ diff --git a/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Regular.otf b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Regular.otf new file mode 100755 index 0000000000..b8aa87d247 Binary files /dev/null and b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Regular.otf differ diff --git a/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-RegularItalic.otf b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-RegularItalic.otf new file mode 100755 index 0000000000..e0b98c8956 Binary files /dev/null and b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-RegularItalic.otf differ diff --git a/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Thin.otf b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Thin.otf new file mode 100755 index 0000000000..055d889cab Binary files /dev/null and b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-Thin.otf differ diff --git a/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-ThinItalic.otf b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-ThinItalic.otf new file mode 100755 index 0000000000..23fe8e8a97 Binary files /dev/null and b/.jjconflict-side-1/apps/client/src/assets/fonts/Marianne-ThinItalic.otf differ diff --git a/.jjconflict-side-1/apps/client/src/auto-imports.d.ts b/.jjconflict-side-1/apps/client/src/auto-imports.d.ts new file mode 100644 index 0000000000..4372345fd5 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/auto-imports.d.ts @@ -0,0 +1,173 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + const EffectScope: typeof import('vue')['EffectScope'] + const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] + const computed: typeof import('vue')['computed'] + const createApp: typeof import('vue')['createApp'] + const createPinia: typeof import('pinia')['createPinia'] + const customRef: typeof import('vue')['customRef'] + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] + const defineComponent: typeof import('vue')['defineComponent'] + const defineStore: typeof import('pinia')['defineStore'] + const effectScope: typeof import('vue')['effectScope'] + const getActivePinia: typeof import('pinia')['getActivePinia'] + const getCurrentInstance: typeof import('vue')['getCurrentInstance'] + const getCurrentScope: typeof import('vue')['getCurrentScope'] + const h: typeof import('vue')['h'] + const inject: typeof import('vue')['inject'] + const isProxy: typeof import('vue')['isProxy'] + const isReactive: typeof import('vue')['isReactive'] + const isReadonly: typeof import('vue')['isReadonly'] + const isRef: typeof import('vue')['isRef'] + const mapActions: typeof import('pinia')['mapActions'] + const mapGetters: typeof import('pinia')['mapGetters'] + const mapState: typeof import('pinia')['mapState'] + const mapStores: typeof import('pinia')['mapStores'] + const mapWritableState: typeof import('pinia')['mapWritableState'] + const markRaw: typeof import('vue')['markRaw'] + const nextTick: typeof import('vue')['nextTick'] + const onActivated: typeof import('vue')['onActivated'] + const onBeforeMount: typeof import('vue')['onBeforeMount'] + const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave'] + const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate'] + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] + const onDeactivated: typeof import('vue')['onDeactivated'] + const onErrorCaptured: typeof import('vue')['onErrorCaptured'] + const onMounted: typeof import('vue')['onMounted'] + const onRenderTracked: typeof import('vue')['onRenderTracked'] + const onRenderTriggered: typeof import('vue')['onRenderTriggered'] + const onScopeDispose: typeof import('vue')['onScopeDispose'] + const onServerPrefetch: typeof import('vue')['onServerPrefetch'] + const onUnmounted: typeof import('vue')['onUnmounted'] + const onUpdated: typeof import('vue')['onUpdated'] + const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] + const provide: typeof import('vue')['provide'] + const reactive: typeof import('vue')['reactive'] + const readonly: typeof import('vue')['readonly'] + const ref: typeof import('vue')['ref'] + const resolveComponent: typeof import('vue')['resolveComponent'] + const setActivePinia: typeof import('pinia')['setActivePinia'] + const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] + const shallowReactive: typeof import('vue')['shallowReactive'] + const shallowReadonly: typeof import('vue')['shallowReadonly'] + const shallowRef: typeof import('vue')['shallowRef'] + const storeToRefs: typeof import('pinia')['storeToRefs'] + const toRaw: typeof import('vue')['toRaw'] + const toRef: typeof import('vue')['toRef'] + const toRefs: typeof import('vue')['toRefs'] + const toValue: typeof import('vue')['toValue'] + const triggerRef: typeof import('vue')['triggerRef'] + const unref: typeof import('vue')['unref'] + const useAttrs: typeof import('vue')['useAttrs'] + const useCssModule: typeof import('vue')['useCssModule'] + const useCssVars: typeof import('vue')['useCssVars'] + const useId: typeof import('vue')['useId'] + const useLink: typeof import('vue-router')['useLink'] + const useModel: typeof import('vue')['useModel'] + const useRoute: typeof import('vue-router')['useRoute'] + const useRouter: typeof import('vue-router')['useRouter'] + const useScheme: typeof import('@gouvminint/vue-dsfr')['useScheme'] + const useSlots: typeof import('vue')['useSlots'] + const useTabs: typeof import('@gouvminint/vue-dsfr')['useTabs'] + const useTemplateRef: typeof import('vue')['useTemplateRef'] + const watch: typeof import('vue')['watch'] + const watchEffect: typeof import('vue')['watchEffect'] + const watchPostEffect: typeof import('vue')['watchPostEffect'] + const watchSyncEffect: typeof import('vue')['watchSyncEffect'] +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') +} + +// for vue template auto import +import { UnwrapRef } from 'vue' +declare module 'vue' { + interface GlobalComponents {} + interface ComponentCustomProperties { + readonly EffectScope: UnwrapRef + readonly acceptHMRUpdate: UnwrapRef + readonly computed: UnwrapRef + readonly createApp: UnwrapRef + readonly createPinia: UnwrapRef + readonly customRef: UnwrapRef + readonly defineAsyncComponent: UnwrapRef + readonly defineComponent: UnwrapRef + readonly defineStore: UnwrapRef + readonly effectScope: UnwrapRef + readonly getActivePinia: UnwrapRef + readonly getCurrentInstance: UnwrapRef + readonly getCurrentScope: UnwrapRef + readonly h: UnwrapRef + readonly inject: UnwrapRef + readonly isProxy: UnwrapRef + readonly isReactive: UnwrapRef + readonly isReadonly: UnwrapRef + readonly isRef: UnwrapRef + readonly mapActions: UnwrapRef + readonly mapGetters: UnwrapRef + readonly mapState: UnwrapRef + readonly mapStores: UnwrapRef + readonly mapWritableState: UnwrapRef + readonly markRaw: UnwrapRef + readonly nextTick: UnwrapRef + readonly onActivated: UnwrapRef + readonly onBeforeMount: UnwrapRef + readonly onBeforeRouteLeave: UnwrapRef + readonly onBeforeRouteUpdate: UnwrapRef + readonly onBeforeUnmount: UnwrapRef + readonly onBeforeUpdate: UnwrapRef + readonly onDeactivated: UnwrapRef + readonly onErrorCaptured: UnwrapRef + readonly onMounted: UnwrapRef + readonly onRenderTracked: UnwrapRef + readonly onRenderTriggered: UnwrapRef + readonly onScopeDispose: UnwrapRef + readonly onServerPrefetch: UnwrapRef + readonly onUnmounted: UnwrapRef + readonly onUpdated: UnwrapRef + readonly onWatcherCleanup: UnwrapRef + readonly provide: UnwrapRef + readonly reactive: UnwrapRef + readonly readonly: UnwrapRef + readonly ref: UnwrapRef + readonly resolveComponent: UnwrapRef + readonly setActivePinia: UnwrapRef + readonly setMapStoreSuffix: UnwrapRef + readonly shallowReactive: UnwrapRef + readonly shallowReadonly: UnwrapRef + readonly shallowRef: UnwrapRef + readonly storeToRefs: UnwrapRef + readonly toRaw: UnwrapRef + readonly toRef: UnwrapRef + readonly toRefs: UnwrapRef + readonly toValue: UnwrapRef + readonly triggerRef: UnwrapRef + readonly unref: UnwrapRef + readonly useAttrs: UnwrapRef + readonly useCssModule: UnwrapRef + readonly useCssVars: UnwrapRef + readonly useId: UnwrapRef + readonly useLink: UnwrapRef + readonly useModel: UnwrapRef + readonly useRoute: UnwrapRef + readonly useRouter: UnwrapRef + readonly useScheme: UnwrapRef + readonly useSlots: UnwrapRef + readonly useTabs: UnwrapRef + readonly useTemplateRef: UnwrapRef + readonly watch: UnwrapRef + readonly watchEffect: UnwrapRef + readonly watchPostEffect: UnwrapRef + readonly watchSyncEffect: UnwrapRef + } +} \ No newline at end of file diff --git a/.jjconflict-side-1/apps/client/src/components/AdminRoleForm.vue b/.jjconflict-side-1/apps/client/src/components/AdminRoleForm.vue new file mode 100644 index 0000000000..bf129c8ad7 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/AdminRoleForm.vue @@ -0,0 +1,304 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/Badge.vue b/.jjconflict-side-1/apps/client/src/components/Badge.vue new file mode 100644 index 0000000000..1dd98c5cda --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/Badge.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ChoiceSelector.vue b/.jjconflict-side-1/apps/client/src/components/ChoiceSelector.vue new file mode 100644 index 0000000000..b615ab6750 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ChoiceSelector.vue @@ -0,0 +1,272 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ClusterForm.vue b/.jjconflict-side-1/apps/client/src/components/ClusterForm.vue new file mode 100644 index 0000000000..81f51ebcaf --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ClusterForm.vue @@ -0,0 +1,532 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ConfigParam.vue b/.jjconflict-side-1/apps/client/src/components/ConfigParam.vue new file mode 100644 index 0000000000..b36d3820c7 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ConfigParam.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/components/DeleteForm.vue b/.jjconflict-side-1/apps/client/src/components/DeleteForm.vue new file mode 100644 index 0000000000..3716065a14 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/DeleteForm.vue @@ -0,0 +1,73 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/DeploymentCard.vue b/.jjconflict-side-1/apps/client/src/components/DeploymentCard.vue new file mode 100644 index 0000000000..cceb650e6f --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/DeploymentCard.vue @@ -0,0 +1,46 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/DeploymentModal.vue b/.jjconflict-side-1/apps/client/src/components/DeploymentModal.vue new file mode 100644 index 0000000000..2323b7cea9 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/DeploymentModal.vue @@ -0,0 +1,182 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/DeploymentRepoOption.vue b/.jjconflict-side-1/apps/client/src/components/DeploymentRepoOption.vue new file mode 100644 index 0000000000..0aba4f3fc3 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/DeploymentRepoOption.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/components/DeploymentRepoSelect.vue b/.jjconflict-side-1/apps/client/src/components/DeploymentRepoSelect.vue new file mode 100644 index 0000000000..9af01fb8c6 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/DeploymentRepoSelect.vue @@ -0,0 +1,65 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/DeploymentResources.vue b/.jjconflict-side-1/apps/client/src/components/DeploymentResources.vue new file mode 100644 index 0000000000..948c34c059 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/DeploymentResources.vue @@ -0,0 +1,96 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/DsoBadge.vue b/.jjconflict-side-1/apps/client/src/components/DsoBadge.vue new file mode 100644 index 0000000000..0f33785b50 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/DsoBadge.vue @@ -0,0 +1,52 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/DsoSnackbar.vue b/.jjconflict-side-1/apps/client/src/components/DsoSnackbar.vue new file mode 100644 index 0000000000..a91ff46ee8 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/DsoSnackbar.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/components/EnvironmentForm.vue b/.jjconflict-side-1/apps/client/src/components/EnvironmentForm.vue new file mode 100644 index 0000000000..1035945aca --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/EnvironmentForm.vue @@ -0,0 +1,396 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ErrorGoBackToProjects.vue b/.jjconflict-side-1/apps/client/src/components/ErrorGoBackToProjects.vue new file mode 100644 index 0000000000..3dc6c155f1 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ErrorGoBackToProjects.vue @@ -0,0 +1,25 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/Loader.vue b/.jjconflict-side-1/apps/client/src/components/Loader.vue new file mode 100644 index 0000000000..5ba76adde9 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/Loader.vue @@ -0,0 +1,21 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/LoadingCt.vue b/.jjconflict-side-1/apps/client/src/components/LoadingCt.vue new file mode 100644 index 0000000000..a36af47cc3 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/LoadingCt.vue @@ -0,0 +1,29 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/LogsViewer.vue b/.jjconflict-side-1/apps/client/src/components/LogsViewer.vue new file mode 100644 index 0000000000..2ce24862f4 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/LogsViewer.vue @@ -0,0 +1,218 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/OperationPanel.vue b/.jjconflict-side-1/apps/client/src/components/OperationPanel.vue new file mode 100644 index 0000000000..e308f41fb7 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/OperationPanel.vue @@ -0,0 +1,36 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/PaginationCt.vue b/.jjconflict-side-1/apps/client/src/components/PaginationCt.vue new file mode 100644 index 0000000000..ef0e1e2c73 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/PaginationCt.vue @@ -0,0 +1,74 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ProjectAction.vue b/.jjconflict-side-1/apps/client/src/components/ProjectAction.vue new file mode 100644 index 0000000000..e05dc0bbd2 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ProjectAction.vue @@ -0,0 +1,51 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ProjectArchiveButton.vue b/.jjconflict-side-1/apps/client/src/components/ProjectArchiveButton.vue new file mode 100644 index 0000000000..1d9fe1b0f5 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ProjectArchiveButton.vue @@ -0,0 +1,74 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ProjectBanner.vue b/.jjconflict-side-1/apps/client/src/components/ProjectBanner.vue new file mode 100644 index 0000000000..05ab11ea11 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ProjectBanner.vue @@ -0,0 +1,185 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ProjectClustersInfos.vue b/.jjconflict-side-1/apps/client/src/components/ProjectClustersInfos.vue new file mode 100644 index 0000000000..d7af0874ed --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ProjectClustersInfos.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ProjectLockButton.vue b/.jjconflict-side-1/apps/client/src/components/ProjectLockButton.vue new file mode 100644 index 0000000000..ac700f5cc4 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ProjectLockButton.vue @@ -0,0 +1,32 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ProjectLogsViewer.vue b/.jjconflict-side-1/apps/client/src/components/ProjectLogsViewer.vue new file mode 100644 index 0000000000..fba2129414 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ProjectLogsViewer.vue @@ -0,0 +1,47 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ProjectReplayButton.vue b/.jjconflict-side-1/apps/client/src/components/ProjectReplayButton.vue new file mode 100644 index 0000000000..efb2a90886 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ProjectReplayButton.vue @@ -0,0 +1,62 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ProjectResources.vue b/.jjconflict-side-1/apps/client/src/components/ProjectResources.vue new file mode 100644 index 0000000000..0c0d25e2a5 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ProjectResources.vue @@ -0,0 +1,478 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ProjectRoleForm.vue b/.jjconflict-side-1/apps/client/src/components/ProjectRoleForm.vue new file mode 100644 index 0000000000..d20f31c5d9 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ProjectRoleForm.vue @@ -0,0 +1,201 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ProjectRoles.vue b/.jjconflict-side-1/apps/client/src/components/ProjectRoles.vue new file mode 100644 index 0000000000..5760069994 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ProjectRoles.vue @@ -0,0 +1,162 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ProjectSecretsButton.vue b/.jjconflict-side-1/apps/client/src/components/ProjectSecretsButton.vue new file mode 100644 index 0000000000..05f1f423e4 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ProjectSecretsButton.vue @@ -0,0 +1,93 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ProjectSettings.vue b/.jjconflict-side-1/apps/client/src/components/ProjectSettings.vue new file mode 100644 index 0000000000..4bcd1dc66a --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ProjectSettings.vue @@ -0,0 +1,164 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ReloadPrompt.vue b/.jjconflict-side-1/apps/client/src/components/ReloadPrompt.vue new file mode 100644 index 0000000000..aa914e8ecb --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ReloadPrompt.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/components/RepoForm.vue b/.jjconflict-side-1/apps/client/src/components/RepoForm.vue new file mode 100644 index 0000000000..0a38b379cd --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/RepoForm.vue @@ -0,0 +1,397 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/SelectProject.vue b/.jjconflict-side-1/apps/client/src/components/SelectProject.vue new file mode 100644 index 0000000000..5c98b13e21 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/SelectProject.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ServiceChainForm.vue b/.jjconflict-side-1/apps/client/src/components/ServiceChainForm.vue new file mode 100644 index 0000000000..eec5f94223 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ServiceChainForm.vue @@ -0,0 +1,423 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ServicesConfig.vue b/.jjconflict-side-1/apps/client/src/components/ServicesConfig.vue new file mode 100644 index 0000000000..1cd4a36f9b --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ServicesConfig.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/components/SideMenu.vue b/.jjconflict-side-1/apps/client/src/components/SideMenu.vue new file mode 100644 index 0000000000..ecde0c9aef --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/SideMenu.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/components/StageForm.vue b/.jjconflict-side-1/apps/client/src/components/StageForm.vue new file mode 100644 index 0000000000..afca19b513 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/StageForm.vue @@ -0,0 +1,212 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/SuggestionInput.vue b/.jjconflict-side-1/apps/client/src/components/SuggestionInput.vue new file mode 100644 index 0000000000..fb704e409a --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/SuggestionInput.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/components/TeamCt.vue b/.jjconflict-side-1/apps/client/src/components/TeamCt.vue new file mode 100644 index 0000000000..d850569e77 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/TeamCt.vue @@ -0,0 +1,259 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/TokenForm.vue b/.jjconflict-side-1/apps/client/src/components/TokenForm.vue new file mode 100644 index 0000000000..2d743348ec --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/TokenForm.vue @@ -0,0 +1,159 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/components/ZoneForm.vue b/.jjconflict-side-1/apps/client/src/components/ZoneForm.vue new file mode 100644 index 0000000000..57c0b8db5d --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/components/ZoneForm.vue @@ -0,0 +1,225 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/env.d.ts b/.jjconflict-side-1/apps/client/src/env.d.ts new file mode 100644 index 0000000000..c725a2cc66 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/env.d.ts @@ -0,0 +1,37 @@ +/// +/// + +declare module 'virtual:pwa-register/vue' { + import type { Ref } from 'vue' + import type { RegisterSWOptions } from 'vite-plugin-pwa/types' + + export type { RegisterSWOptions } + + export function useRegisterSW(options?: RegisterSWOptions): { + needRefresh: Ref + offlineReady: Ref + updateServiceWorker: (reloadPage?: boolean) => Promise + } +} + +declare module 'vue3-json-viewer' { + import { AllowedComponentProps, App, Component, ComponentCustomProps, VNodeProps } from 'vue'; + + interface JsonViewerProps { + value: Record | Array | string | number | boolean; + expanded: boolean; + expandDepth: number; + copyable: boolean | object; + sort: boolean; + boxed: boolean; + theme: string; //"dark" | "light" + previewMode: boolean; + timeformat: (value: any) => string; + } + + type JsonViewerType = JsonViewerProps & VNodeProps & AllowedComponentProps & ComponentCustomProps; + const JsonViewer: Component; + export { JsonViewer }; + const def: { install: (app: App) => void }; + export default def; +} diff --git a/.jjconflict-side-1/apps/client/src/icon-collections.ts b/.jjconflict-side-1/apps/client/src/icon-collections.ts new file mode 100644 index 0000000000..de8e82fe94 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/icon-collections.ts @@ -0,0 +1,5 @@ +import type { IconifyJSON } from '@iconify/vue' + +const collections: IconifyJSON[] = [{ prefix: 'ri', icons: { 'account-circle-line': { body: '' }, 'add-line': { body: '' }, 'admin-line': { body: '' }, 'arrow-drop-left-line': { body: '' }, 'arrow-drop-right-line': { body: '' }, 'arrow-go-back-line': { body: '' }, 'arrow-left-double-line': { body: '' }, 'arrow-right-double-line': { body: '' }, 'arrow-right-line': { body: '' }, 'arrow-right-s-line': { body: '' }, 'award-line': { body: '' }, 'building-line': { body: '' }, 'checkbox-blank-circle-fill': { body: '' }, 'checkbox-circle-line': { body: '' }, 'clipboard-line': { body: '' }, 'close-line': { body: '' }, 'code-s-slash-line': { body: '' }, 'dashboard-line': { body: '' }, 'delete-bin-7-line': { body: '' }, 'exchange-line': { body: '' }, 'eye-line': { body: '' }, 'eye-off-line': { body: '' }, 'file-download-line': { body: '' }, 'file-info-line': { body: '' }, 'filter-line': { body: '' }, 'filter-off-line': { body: '' }, 'flow-chart': { body: '' }, 'focus-3-line': { body: '' }, 'folder-line': { body: '' }, 'folder-shield-2-line': { body: '' }, 'folder-user-line': { body: '' }, 'folders-line': { body: '' }, 'git-merge-line': { body: '' }, 'git-repository-private-line': { body: '' }, 'github-line': { body: '' }, 'global-line': { body: '' }, 'key-2-line': { body: '' }, 'loader-4-line': { body: '' }, 'lock-line': { body: '' }, 'lock-unlock-line': { body: '' }, 'mail-line': { body: '' }, 'moon-clear-line': { body: '' }, 'newspaper-line': { body: '' }, 'pencil-line': { body: '' }, 'question-line': { body: '' }, 'refresh-line': { body: '' }, 'restart-line': { body: '' }, 'send-plane-line': { body: '' }, 'server-line': { body: '' }, 'settings-3-line': { body: '' }, 'shapes-line': { body: '' }, 'shield-check-line': { body: '' }, 'sound-module-line': { body: '' }, 'stock-line': { body: '' }, 'sun-line': { body: '' }, 'team-line': { body: '' }, 'tools-line': { body: '' }, 'upload-cloud-line': { body: '' }, 'user-add-line': { body: '' }, 'user-shared-2-line': { body: '' } }, width: 24, height: 24 }] +export const ri = { accountCircleLine: 'ri:account-circle-line', addLine: 'ri:add-line', adminLine: 'ri:admin-line', arrowDropLeftLine: 'ri:arrow-drop-left-line', arrowDropRightLine: 'ri:arrow-drop-right-line', arrowGoBackLine: 'ri:arrow-go-back-line', arrowLeftDoubleLine: 'ri:arrow-left-double-line', arrowRightDoubleLine: 'ri:arrow-right-double-line', arrowRightLine: 'ri:arrow-right-line', arrowRightSLine: 'ri:arrow-right-s-line', awardLine: 'ri:award-line', buildingLine: 'ri:building-line', checkboxBlankCircleFill: 'ri:checkbox-blank-circle-fill', checkboxCircleLine: 'ri:checkbox-circle-line', clipboardLine: 'ri:clipboard-line', closeLine: 'ri:close-line', codeSSlashLine: 'ri:code-s-slash-line', dashboardLine: 'ri:dashboard-line', deleteBin7Line: 'ri:delete-bin-7-line', exchangeLine: 'ri:exchange-line', eyeLine: 'ri:eye-line', eyeOffLine: 'ri:eye-off-line', fileDownloadLine: 'ri:file-download-line', fileInfoLine: 'ri:file-info-line', filterLine: 'ri:filter-line', filterOffLine: 'ri:filter-off-line', flowChart: 'ri:flow-chart', focus3Line: 'ri:focus-3-line', folderLine: 'ri:folder-line', folderShield2Line: 'ri:folder-shield-2-line', folderUserLine: 'ri:folder-user-line', foldersLine: 'ri:folders-line', gitMergeLine: 'ri:git-merge-line', gitRepositoryPrivateLine: 'ri:git-repository-private-line', githubLine: 'ri:github-line', globalLine: 'ri:global-line', key2Line: 'ri:key-2-line', loader4Line: 'ri:loader-4-line', lockLine: 'ri:lock-line', lockUnlockLine: 'ri:lock-unlock-line', mailLine: 'ri:mail-line', moonClearLine: 'ri:moon-clear-line', newspaperLine: 'ri:newspaper-line', pencilLine: 'ri:pencil-line', questionLine: 'ri:question-line', refreshLine: 'ri:refresh-line', restartLine: 'ri:restart-line', sendPlaneLine: 'ri:send-plane-line', serverLine: 'ri:server-line', settings3Line: 'ri:settings-3-line', shapesLine: 'ri:shapes-line', shieldCheckLine: 'ri:shield-check-line', soundModuleLine: 'ri:sound-module-line', stockLine: 'ri:stock-line', sunLine: 'ri:sun-line', teamLine: 'ri:team-line', toolsLine: 'ri:tools-line', uploadCloudLine: 'ri:upload-cloud-line', userAddLine: 'ri:user-add-line', userShared2Line: 'ri:user-shared-2-line' } as const +export default collections diff --git a/.jjconflict-side-1/apps/client/src/main.css b/.jjconflict-side-1/apps/client/src/main.css new file mode 100644 index 0000000000..58f1adca7f --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/main.css @@ -0,0 +1,194 @@ +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-Regular.otf'); +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-RegularItalic.otf'); + font-style: italic; +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-Bold.otf'); + font-weight: 700; +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-BoldItalic.otf'); + font-weight: 700; + font-style: italic; +} + + +html, +body, +#app { + font-family: Marianne, sans-serif; + height: 100%; +} + +.h-full { + height: 100%; +} + +.disabled { + color: var(--text-disabled-grey); + cursor: not-allowed; +} + +.fr-fieldset { + margin: 1rem 0 0; + align-items: flex-start; + min-width: 0; +} + +.fr-checkbox-group { + margin-bottom: 1rem; +} + +.dso-footer.fr-footer { + box-shadow: inset 0 2px 0 0 var(--border-plain-blue-france); +} + +.dso-footer .fr-footer__bottom { + display: none; +} + +.dso-footer .fr-footer__content-desc { + text-align: right; + margin-right: 25px; +} + +.danger-zone { + border-bottom: solid var(--border-plain-warning); + margin-top: 2rem; + padding: 2rem; + background-color: var(--background-alt-grey); +} + +.fr-alert { + background-color: var(--background-alt-grey); +} + +.fr-alert pre { + white-space: normal; +} + +.log-panel, .log-btn { + background-color: var(--background-default-grey); +} + +.log-panel { + box-shadow: inset 0 0 0 1px var(--border-action-high-blue-france); +} + +.log-box--success{ + border-color: var(--background-contrast-success); +} + +.log-box--warning{ + border-color: var(--background-contrast-warning); +} + +.log-box--error{ + border-color: var(--background-contrast-error); +} + +.danger-zone-btns { + @apply flex justify-between items-center gap-4 { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + describe('detect project slug helper', () => { + let projectStore: ReturnType + let slug: string + let uuid: string + + beforeEach(() => { + projectStore = useProjectStore() + slug = faker.lorem.slug() + uuid = faker.string.uuid() + const recent = faker.date.recent() + const ownerId = faker.string.uuid() + const project: ProjectV2 = { + id: uuid, + clusterIds: [], + description: '', + everyonePerms: '0', + name: slug, + slug, + locked: false, + owner: { + type: 'human', + id: ownerId, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + createdAt: recent.toString(), + updatedAt: recent.toString(), + lastLogin: recent.toString(), + }, + ownerId, + roles: [], + members: [], + createdAt: recent.toString(), + updatedAt: recent.toString(), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + status: 'created', + lastSuccessProvisionningVersion: null, + } + projectStore.updateStore([project]) + }) + + it('it should return project\'slug with uuid passed', () => { + const slugFound = detectProjectslug({ + params: { + slug: uuid, + }, + }) + expect(slugFound).toEqual(slug) + }) + + it('it should return project\'slug with slug passed', () => { + const slugFound = detectProjectslug({ + params: { + slug, + }, + }) + expect(slugFound).toEqual(slug) + }) + }) + + describe('navigation with real router instance', () => { + it('renders home and navigates to projects', async () => { + const router = createAppRouter('') + const userStore = useUserStore() + const systemStore = useSystemSettingsStore() + + // Ensure global guard does not redirect to /login + // by simulating an authenticated user. + userStore.isLoggedIn = true + userStore.setIsLoggedIn = async () => {} + systemStore.listSystemSettings = async () => {} + router.push('/') + await router.isReady() + + expect(router.currentRoute.value.name).toEqual('Home') + + await router.push('/projects') + await router.isReady() + + expect(router.currentRoute.value.matched.some(r => r.name === 'Projects')).toBe(true) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/client/src/router/index.ts b/.jjconflict-side-1/apps/client/src/router/index.ts new file mode 100644 index 0000000000..002e8421a5 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/router/index.ts @@ -0,0 +1,326 @@ +import type { + RouteLocationNormalizedGeneric, + RouteRecordRaw, +} from 'vue-router' +import { logger } from '@cpn-console/logger/browser' +import { AdminAuthorized, swaggerUiPath } from '@cpn-console/shared' +import { + createRouter, + createWebHistory, +} from 'vue-router' +import { useProjectStore } from '@/stores/project.js' +import { useSystemSettingsStore } from '@/stores/system-settings.js' + +import { useUserStore } from '@/stores/user.js' +import { uuid } from '@/utils/regex.js' +import DsoHome from '@/views/DsoHome.vue' +import NotFound from '@/views/NotFound.vue' + +const AdminCluster = () => import('@/views/admin/AdminCluster.vue') +const AdminServiceChain = () => import('@/views/admin/AdminServiceChain.vue') +const ServicesHealth = () => import('@/views/ServicesHealth.vue') +const CreateProject = () => import('@/views/CreateProject.vue') +const ProfileWrapper = () => import('@/views/profile/ProfileWrapper.vue') +const UserInfo = () => import('@/views/profile/UserInfo.vue') +const PersonalAccessTokens = () => import('@/views/profile/PersonalAccessTokens.vue') +const DsoProjects = () => import('@/views/projects/DsoProjects.vue') +const DsoAdmin = () => import('@/views/admin/DsoAdmin.vue') +const ListUser = () => import('@/views/admin/ListUser.vue') +const ProjectDashboard = () => import('@/views/ProjectDashboard.vue') +const ListProjects = () => import('@/views/admin/ListProjects.vue') +const ListLogs = () => import('@/views/admin/ListLogs.vue') +const AdminRoles = () => import('@/views/admin/AdminRoles.vue') +const ListClusters = () => import('@/views/admin/ListClusters.vue') +const ListServiceChains = () => import('@/views/admin/ListServiceChains.vue') +const ListStages = () => import('@/views/admin/ListStages.vue') +const ListZones = () => import('@/views/admin/ListZones.vue') +const ListPlugins = () => import('@/views/admin/ListPlugins.vue') +const SystemSettings = () => import('@/views/admin/SystemSettings.vue') +const Maintenance = () => import('@/views/DsoMaintenance.vue') +const AdminTokens = () => import('@/views/admin/AdminTokens.vue') + +const MAIN_TITLE = 'Console Cloud π Native' + +export function detectProjectslug(to: Pick) { + const slugParam = to.params.slug as string + return uuid.test(slugParam) + ? useProjectStore().projects.find(project => project.id === slugParam)?.slug + : slugParam +} + +export const routes: Readonly = [ + { + path: '/login', + name: 'Login', + beforeEnter: async (_to, _from) => { + const userStore = useUserStore() + await userStore.login() + }, + component: DsoProjects, + }, + { + path: '/logout', + name: 'Logout', + beforeEnter: async (_to, _from) => { + const userStore = useUserStore() + await userStore.logout() + }, + component: DsoHome, + }, + { + path: '/', + name: 'Home', + component: DsoHome, + }, + { + path: '/profile', + name: 'Profile', + component: ProfileWrapper, + children: [ + { + path: 'info', + name: 'UserInfo', + component: UserInfo, + }, + { + path: 'tokens', + name: 'PersonalAccessTokens', + component: PersonalAccessTokens, + }, + ], + }, + { + path: '/maintenance', + name: 'Maintenance', + component: Maintenance, + }, + { + path: '/services-health', + name: 'ServicesHealth', + component: ServicesHealth, + }, + + { + path: '/projects', + children: [ + { + path: '', + name: 'Projects', + component: DsoProjects, + }, + { + path: ':slug', + name: 'Project', + component: ProjectDashboard, + async beforeEnter(to, _from, next) { + if (typeof to.params.slug !== 'string') { + return next({ name: 'Projects' }) + } + if (!(to.params.slug in useProjectStore().projectsBySlug)) { + await useProjectStore().getProject(to.params.slug).catch(async () => { + logger.warn({ slug: to.params.slug }, 'Unable to find project information, redirecting to /projects') + return next({ name: 'Projects' }) + }) + } + useProjectStore().lastSelectedProjectSlug = to.params.slug + return next() + }, + props(to) { + return { + projectSlug: detectProjectslug(to), + parentRoute: 'Projects', + asProfile: 'user', + tab: to.query.tab, + } + }, + }, + ], + }, + { + path: '/create-project', + name: 'CreateProject', + component: CreateProject, + }, + { + name: 'ParentAdmin', + path: '/admin', + component: DsoAdmin, + children: [ + { + path: 'users', + name: 'ListUser', + component: ListUser, + }, + { + path: 'projects', + children: [ + { + path: '', + name: 'ListProjects', + component: ListProjects, + }, + { + path: ':slug', + name: 'AdminProject', + component: ProjectDashboard, + async beforeEnter(to, _from, next) { + if (typeof to.params.slug !== 'string') { + return next('/admin/projects') + } + await useProjectStore().getProject(to.params.slug) + return next() + }, + props(to) { + return { + projectSlug: detectProjectslug(to), + parentRoute: 'ListProjects', + asProfile: 'admin', + } + }, + }, + ], + }, + { + path: 'service-chains', + children: [ + { + path: '', + name: 'ListServiceChains', + component: ListServiceChains, + }, + { + path: ':id', + name: 'AdminServiceChain', + component: AdminServiceChain, + props(to) { + return { + id: to.params.id, + } + }, + }, + ], + }, + { + path: 'logs', + name: 'ListLogs', + component: ListLogs, + }, + { + path: 'clusters', + children: [ + { + path: '', + name: 'ListClusters', + component: ListClusters, + }, + { + path: ':id', + name: 'AdminCluster', + component: AdminCluster, + props(to) { + return { + id: to.params.id, + } + }, + }, + ], + }, + { + path: 'stages', + name: 'ListStages', + component: ListStages, + }, + { + path: 'zones', + name: 'ListZones', + component: ListZones, + }, + { + path: 'plugins', + name: 'ListPlugins', + component: ListPlugins, + }, + { + path: 'system-settings', + name: 'SystemSettings', + component: SystemSettings, + }, + { + path: 'roles', + name: 'AdminRoles', + component: AdminRoles, + }, + { + path: 'tokens', + name: 'AdminTokens', + component: AdminTokens, + }, + ], + }, + { + path: `${swaggerUiPath}-server`, + name: 'Swagger-server', + // no component, swagger plugin + components: {}, + strict: false, + }, + { + path: `${swaggerUiPath}-server-nestjs`, + name: 'Swagger-server-nestjs', + // no component, swagger plugin + components: {}, + strict: false, + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: NotFound, + }, +] + +export function createAppRouter(base?: string) { + const router = createRouter({ + history: createWebHistory(base ?? (import.meta.env?.BASE_URL || '')), + scrollBehavior: (to) => { if (to.hash && !to.hash.startsWith('#state=')) return ({ el: to.hash }) }, + routes, + }) + router.beforeEach((to) => { + const specificTitle = to.meta.title ? `${to.meta.title} - ` : '' + document.title = `${specificTitle}${MAIN_TITLE}` + }) + router.beforeEach(async (to, _from, next) => { + const validPath = new Set(['Login', 'Home', 'Doc', 'NotFound', 'ServicesHealth', 'Maintenance', 'Logout', 'Swagger-server', 'Swagger-server-nestjs']) + const userStore = useUserStore() + const systemStore = useSystemSettingsStore() + await userStore.setIsLoggedIn() + if ( + !validPath.has(to.name?.toString() ?? '') + && !userStore.isLoggedIn + ) { + return next('/login') + } + if (to.name === 'Login' && userStore.isLoggedIn) { + return next('/') + } + if ( + !validPath.has(to.name?.toString() ?? '') + && userStore.isLoggedIn + ) { + await systemStore.listSystemSettings() + if (systemStore.systemSettingsByKey.maintenance?.value === 'on' && !AdminAuthorized.Manage(userStore.adminPerms)) return next('/maintenance') + } + next() + }) + return router +} + +const router = createAppRouter() + +export const isInProject = computed(() => router.currentRoute.value.matched.some(route => route.name === 'Project')) +export const selectedProjectSlug = computed(() => { + if (router.currentRoute.value.matched.some(route => route.name === 'Project')) { + return router.currentRoute.value.params.slug as string + } + return undefined +}) +export default router diff --git a/.jjconflict-side-1/apps/client/src/stores/admin-role.ts b/.jjconflict-side-1/apps/client/src/stores/admin-role.ts new file mode 100644 index 0000000000..fa70004e0f --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/admin-role.ts @@ -0,0 +1,61 @@ +import type { AdminRole, adminRoleContract } from '@cpn-console/shared' +import { AdminAuthorized } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '../api/xhr-client.js' +import { useUserStore } from './user.js' + +export const useAdminRoleStore = defineStore('adminRole', () => { + const userStore = useUserStore() + const roles = ref([]) + const memberCounts = ref>({}) + + const countMembersRoles = async () => { + memberCounts.value + = await apiClient.AdminRoles.adminRoleMemberCounts().then((res: any) => + extractData(res, 200), + ) + return memberCounts.value + } + + const listRoles = async () => { + roles.value = await apiClient.AdminRoles.listAdminRoles().then((response: any) => + extractData(response, 200), + ) + if (AdminAuthorized.ListRoles(userStore.adminPerms)) { + await countMembersRoles() + } + return roles.value + } + + const createRole = async () => { + roles.value = await apiClient.AdminRoles.createAdminRole({ + body: { name: 'Nouveau rôle' }, + }).then((res: any) => extractData(res, 201)) + } + + const deleteRole = async (roleId: AdminRole['id']) => { + await apiClient.AdminRoles.deleteAdminRole({ params: { roleId } }).then( + (res: any) => extractData(res, 204), + ) + await listRoles() + } + + const patchRoles = async ( + body: typeof adminRoleContract.patchAdminRoles.body._type, + ) => { + roles.value = await apiClient.AdminRoles.patchAdminRoles({ + body, + }).then((res: any) => extractData(res, 200)) + } + + return { + roles, + memberCounts, + countMembersRoles, + listRoles, + createRole, + deleteRole, + patchRoles, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/admin-token.spec.ts b/.jjconflict-side-1/apps/client/src/stores/admin-token.spec.ts new file mode 100644 index 0000000000..96a91a1b94 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/admin-token.spec.ts @@ -0,0 +1,57 @@ +import type { ExposedAdminToken } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useAdminTokenStore } from './admin-token.js' + +const apiClientGet = vi.spyOn(apiClient.AdminTokens, 'listAdminTokens') +const apiClientPost = vi.spyOn(apiClient.AdminTokens, 'createAdminToken') +const apiClientDelete = vi.spyOn(apiClient.AdminTokens, 'deleteAdminToken') + +describe('cluster Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get tokens list by api call', async () => { + const data = [] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminTokenStore = useAdminTokenStore() + + await adminTokenStore.listTokens() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should add token by api call', async () => { + const data: ExposedAdminToken = { + name: 'test2', + expirationDate: null, + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + lastUse: null, + password: 'password', + permissions: '2', + } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: data })) + const adminTokenStore = useAdminTokenStore() + + const res = await adminTokenStore.createToken({ name: data.name, permissions: data.permissions }) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should delete token by api call', async () => { + const tokenId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204 })) + const adminTokenStore = useAdminTokenStore() + + await adminTokenStore.deleteToken(tokenId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/admin-token.ts b/.jjconflict-side-1/apps/client/src/stores/admin-token.ts new file mode 100644 index 0000000000..3b76dc2fe8 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/admin-token.ts @@ -0,0 +1,33 @@ +import type { AdminToken, adminTokenContract } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '../api/xhr-client.js' + +export const useAdminTokenStore = defineStore('adminToken', () => { + const listTokens = async ( + query: typeof adminTokenContract.listAdminTokens.query._type = {}, + ) => { + return apiClient.AdminTokens.listAdminTokens({ query }).then( + (response: any) => extractData(response, 200), + ) + } + + const createToken = async ( + body: typeof adminTokenContract.createAdminToken.body._type, + ) => { + return apiClient.AdminTokens.createAdminToken({ body }).then((res: any) => + extractData(res, 201), + ) + } + + const deleteToken = async (tokenId: AdminToken['id']) => { + await apiClient.AdminTokens.deleteAdminToken({ params: { tokenId } }).then( + (res: any) => extractData(res, 204), + ) + } + + return { + listTokens, + createToken, + deleteToken, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/cluster.spec.ts b/.jjconflict-side-1/apps/client/src/stores/cluster.spec.ts new file mode 100644 index 0000000000..903c45450e --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/cluster.spec.ts @@ -0,0 +1,128 @@ +import { ClusterPrivacy } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useClusterStore } from './cluster.js' + +const apiClientGet = vi.spyOn(apiClient.Clusters, 'getClusterDetails') +const apiClientGetClusterEnvironments = vi.spyOn(apiClient.Clusters, 'getClusterEnvironments') +const apiClientPost = vi.spyOn(apiClient.Clusters, 'createCluster') +const apiClientPut = vi.spyOn(apiClient.Clusters, 'updateCluster') +const apiClientDelete = vi.spyOn(apiClient.Clusters, 'deleteCluster') + +describe('cluster Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get clusters list by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b7', + label: 'cluster2', + infos: 'infos', + kubeconfig: { + cluster: { + tlsServerName: '8790044f-8414-569f-9c4a-2df42a879879', + }, + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + }, + clusterResources: true, + privacy: 'public', + } + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + await adminClusterStore.getClusterDetails(data.id) + + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should get cluster\'s associated environments by api call', async () => { + const clusterId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + const data = { + environments: [], + } + + apiClientGetClusterEnvironments.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.getClusterAssociatedEnvironments(clusterId) + + expect(res).toBe(data) + expect(apiClientGetClusterEnvironments).toHaveBeenCalledTimes(1) + }) + + it('should add cluster by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + label: 'cluster1', + projectIds: [ + '22e7044f-8414-435d-9c4a-2df42a65034b', + ], + kubeconfig: { + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + }, + clusterResources: true, + privacy: ClusterPrivacy.DEDICATED, + } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.addCluster(data) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update cluster by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + projectIds: [ + '22e7044f-8414-435d-9c4a-2df42a65034b', + ], + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + clusterResources: true, + privacy: ClusterPrivacy.DEDICATED, + } + apiClientPut.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.updateCluster(data) + + expect(res).toBe(data) + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete cluster by api call', async () => { + const clusterId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204 })) + const adminClusterStore = useClusterStore() + + await adminClusterStore.deleteCluster(clusterId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/cluster.ts b/.jjconflict-side-1/apps/client/src/stores/cluster.ts new file mode 100644 index 0000000000..3f94777f2b --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/cluster.ts @@ -0,0 +1,65 @@ +import type { CleanedCluster, Cluster, CreateClusterBody, UpdateClusterBody } from '@cpn-console/shared' +import { + resourceListToDict, + +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useClusterStore = defineStore('cluster', () => { + const clusters = ref>([]) + const clustersById = computed(() => resourceListToDict(clusters.value)) + + const getClusters = async () => { + clusters.value = await apiClient.Clusters.listClusters().then( + (response: any) => extractData(response, 200), + ) + return clusters.value + } + + const getClusterDetails = async (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterDetails({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const getClusterUsage = async (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterUsage({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const getClusterAssociatedEnvironments = (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterEnvironments({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const addCluster = (cluster: CreateClusterBody) => + apiClient.Clusters.createCluster({ body: cluster }).then((response: any) => + extractData(response, 201), + ) + + const updateCluster = ({ + id, + ...body + }: UpdateClusterBody & { id: Cluster['id'] }) => + apiClient.Clusters.updateCluster({ body, params: { clusterId: id } }).then( + (response: any) => extractData(response, 200), + ) + + const deleteCluster = (clusterId: Cluster['id']) => + apiClient.Clusters.deleteCluster({ params: { clusterId } }).then( + (response: any) => extractData(response, 204), + ) + + return { + clusters, + clustersById, + getClusterAssociatedEnvironments, + addCluster, + updateCluster, + deleteCluster, + getClusterDetails, + getClusterUsage, + getClusters, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/log.spec.ts b/.jjconflict-side-1/apps/client/src/stores/log.spec.ts new file mode 100644 index 0000000000..e9b9d0b796 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/log.spec.ts @@ -0,0 +1,32 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useLogStore } from './log.js' + +const apiClientGet = vi.spyOn(apiClient.Logs, 'getLogs') + +describe('log Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get logs list and total by api call', async () => { + const data = { + total: 1, + logs: [ + { id: 'thisIsAnId', data: {}, action: 'Create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565' }, + ], + } + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminLogStore = useLogStore() + + await adminLogStore.getAllLogs({ offset: 5, limit: 10 }) + + expect(adminLogStore.count).toEqual(data.total) + expect(adminLogStore.logs).toStrictEqual(data.logs) + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/log.ts b/.jjconflict-side-1/apps/client/src/stores/log.ts new file mode 100644 index 0000000000..3b1266cc48 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/log.ts @@ -0,0 +1,30 @@ +import type { GetLogsQuery, Log } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useLogStore = defineStore('log', () => { + const logs = ref([]) + const count = ref(undefined) + const needRefresh = ref(false) + + const getAllLogs = async ({ offset, limit }: GetLogsQuery = { offset: 0, limit: 100 }) => { + const res = await apiClient.Logs.getLogs({ query: { offset, limit, clean: false } }) + .then((response: any) => extractData(response, 200)) + count.value = res.total + logs.value = res.logs as Log[] + } + + const listLogs = async ({ offset, limit, clean, projectId }: GetLogsQuery = { offset: 0, limit: 10 }) => { + return apiClient.Logs.getLogs({ query: { offset, limit, clean, projectId } }) + .then((response: any) => extractData(response, 200)) + } + + return { + logs, + count, + needRefresh, + getAllLogs, + listLogs, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/plugins.ts b/.jjconflict-side-1/apps/client/src/stores/plugins.ts new file mode 100644 index 0000000000..99dd9f7abd --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/plugins.ts @@ -0,0 +1,19 @@ +import type { PluginsUpdateBody, ProjectService } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const usePluginsConfigStore = defineStore('plugins', () => { + const services: ProjectService[] = [] + + const getPluginsConfig = () => apiClient.SystemPlugin.getPluginsConfig() + .then((response: any) => extractData(response, 200)) + + const updatePluginsConfig = (body: PluginsUpdateBody) => apiClient.SystemPlugin.updatePluginsConfig({ body }) + .then((response: any) => extractData(response, 204)) + + return { + services, + getPluginsConfig, + updatePluginsConfig, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/project.spec.ts b/.jjconflict-side-1/apps/client/src/stores/project.spec.ts new file mode 100644 index 0000000000..6e57c0e9c4 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/project.spec.ts @@ -0,0 +1,94 @@ +import { createRandomDbSetup, getRandomMember, getRandomProject } from '@cpn-console/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client' +import { useProjectStore } from './project' + +const getProject = vi.spyOn(apiClient.Projects, 'getProject') +const listProjects = vi.spyOn(apiClient.Projects, 'listProjects') +const listEnvironments = vi.spyOn(apiClient.Environments, 'listEnvironments') +const listRepositories = vi.spyOn(apiClient.Repositories, 'listRepositories') +const apiClientPost = vi.spyOn(apiClient.Projects, 'createProject') + +describe('project Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should retrieve projects by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.environments + delete randomDbSetup.project.repositories + delete randomDbSetup.project.clusters + const projects = [randomDbSetup.project] + + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: projects, headers: {} })) + + await projectStore.listProjects() + + expect(listProjects).toHaveBeenCalledTimes(1) + expect(projectStore.projects).toMatchObject(projects) + }) + + it('should retrieve user\'s projects by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.clusters + const projects = [randomDbSetup.project] + + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: projects, headers: {} })) + listEnvironments.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.environments, headers: {} })) + listRepositories.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.repositories, headers: {} })) + + await projectStore.listMyProjects() + + expect(listProjects).toHaveBeenCalledTimes(1) + }) + + it('should retrieve one project by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.clusters + const project = randomDbSetup.project + + getProject.mockReturnValueOnce(Promise.resolve({ status: 200, body: project, headers: {} })) + listEnvironments.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.environments, headers: {} })) + listRepositories.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.repositories, headers: {} })) + + await projectStore.getProject('foo') + + expect(getProject).toHaveBeenCalledTimes(1) + }) + + it('should create a project by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + const projects = [randomDbSetup.project] + const newProject = getRandomProject() + newProject.members = [getRandomMember(randomDbSetup.users[0].id, 'owner')] + + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: newProject, headers: {} })) + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: [...projects, newProject], headers: {} })) + + await projectStore.createProject(newProject) + + expect(apiClientPost).toHaveBeenCalledTimes(1) + expect(projectStore.myProjects).toHaveLength(1) + }) +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/project.ts b/.jjconflict-side-1/apps/client/src/stores/project.ts new file mode 100644 index 0000000000..44e59d56a8 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/project.ts @@ -0,0 +1,119 @@ +import type { + CreateProjectBody, + projectContract, + ProjectV2, +} from '@cpn-console/shared' +import pDebounce from 'p-debounce' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' +import { Project } from '@/utils/project-utils.js' +import { useUserStore } from './user.js' + +export const useProjectStore = defineStore('project', () => { + const userStore = useUserStore() + + const amIPartOf = (project: ProjectV2) => + project.status !== 'archived' + && (project.ownerId === userStore.userProfile?.id + || project.members.some( + member => member.userId === userStore.userProfile?.id, + )) + + // mostly for admin views + const projectsBySlug = ref>({}) + + const projects = computed(() => + Object.values(projectsBySlug.value).sort((p1, p2) => + p1.slug.localeCompare(p2.slug), + ), + ) + + const myProjects = computed(() => + Object.values(projectsBySlug.value).filter( + project => project.status !== 'archived' && amIPartOf(project), + ), + ) + + const updateStore = async (projectsRecieved: ProjectV2[]) => { + return projectsRecieved.map((project) => { + if (project.slug in projectsBySlug.value) { + return projectsBySlug.value[project.slug].Commands.updateData(project) + } + const newProject = new Project(project) + projectsBySlug.value[project.slug] = newProject + return newProject + }) + } + + const selectFromStore = (slugs: ProjectV2['slug'][]) => { + return slugs + .filter(slug => slug in projectsBySlug.value) + .map(slug => projectsBySlug.value[slug]) + } + + const getProject = async (projectId: ProjectV2['id']) => { + const res = await apiClient.Projects.getProject({ + params: { projectId }, + }).then((response: any) => extractData(response, 200)) + return (await updateStore([res]))[0] + } + + const listProjects = async ( + query: typeof projectContract.listProjects.query._type = { + filter: 'member', + statusNotIn: 'archived', + }, + ) => { + const res = await apiClient.Projects.listProjects({ query }).then( + (response: any) => extractData(response, 200), + ) + await updateStore(res) + return selectFromStore(res.map((project: any) => project.slug)) + } + + const listMyProjects = pDebounce(async () => { + const res = await apiClient.Projects.listProjects({ + query: { filter: 'member', statusNotIn: 'archived' }, + }).then((response: any) => extractData(response, 200)) + for (const storedProject of myProjects.value) { + if ( + !res.some((responseProject: any) => responseProject.id === storedProject.id) + ) { + delete projectsBySlug.value[storedProject.slug] + } + } + await updateStore(res) + return selectFromStore(res.map((project: any) => project.slug)) + }, 200) + + const createProject = async (body: CreateProjectBody) => { + const project = await apiClient.Projects.createProject({ body }).then( + (response: any) => extractData(response, 201), + ) + projectsBySlug.value[project.slug] = new Project(project) + return project + } + + const generateProjectsData = () => + apiClient.Projects.getProjectsData().then((response: any) => + extractData(response, 200), + ) + + // Should only be used for components and vue outside of Project route and its children, consider using the props instead + // Should only be update by beforeEnter() of Project route + const lastSelectedProjectSlug = ref() + + return { + projects, + projectsBySlug, + myProjects, + lastSelectedProjectSlug, + getProject, + listProjects, + listMyProjects, + createProject, + generateProjectsData, + updateStore, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/service-chain.ts b/.jjconflict-side-1/apps/client/src/stores/service-chain.ts new file mode 100644 index 0000000000..802a594e0d --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/service-chain.ts @@ -0,0 +1,53 @@ +import type { ServiceChain, ServiceChainList } from '@cpn-console/shared' +import { + resourceListToDict, + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useServiceChainStore = defineStore('serviceChain', () => { + const serviceChains = ref([]) + const serviceChainsById = computed(() => + resourceListToDict(serviceChains.value), + ) + + const getServiceChainsList = async () => { + serviceChains.value + = await apiClient.ServiceChains.listServiceChains().then((response: any) => + extractData(response, 200), + ) + return serviceChains.value + } + + const getServiceChainDetails = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.getServiceChainDetails({ + params: { serviceChainId }, + }).then((response: any) => + ServiceChainDetailsSchema.parse(extractData(response, 200)), + ) + + const getServiceChainFlows = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.getServiceChainFlows({ + params: { serviceChainId }, + }).then((response: any) => + ServiceChainFlowsSchema.parse(extractData(response, 200)), + ) + + const retryServiceChain = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.retryServiceChain({ + params: { serviceChainId }, + }) + + return { + serviceChains, + serviceChainsById, + getServiceChainDetails, + getServiceChainFlows, + getServiceChainsList, + retryServiceChain, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/services-monitor.spec.ts b/.jjconflict-side-1/apps/client/src/stores/services-monitor.spec.ts new file mode 100644 index 0000000000..a2d72c8417 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/services-monitor.spec.ts @@ -0,0 +1,60 @@ +import type { ServiceBody } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useServiceStore } from './services-monitor.js' + +const apiClientGet = vi.spyOn(apiClient.Services, 'getServiceHealth') + +describe('service Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get services health by api call (healthy)', async () => { + const data: ServiceBody = [ + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'OK', name: 'Keycloak', status: 'OK' }, + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'Service perdu', name: 'Gitlab', status: 'OK' }, + ] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const serviceStore = useServiceStore() + + expect(serviceStore.servicesHealth).toMatchObject({}) + expect(serviceStore.services).toMatchObject([]) + + await serviceStore.checkServicesHealth() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(serviceStore.servicesHealth).toMatchObject({ + message: 'Tous les services fonctionnent normalement', + serviceStatus: 'OK', + status: 'success', + }) + expect(serviceStore.services).toMatchObject(data) + }) + + it('should get services health by api call (unhealthy)', async () => { + const data = [ + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'OK', name: 'Keycloak', status: 'OK' }, + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'Service perdu', name: 'Gitlab', status: 'Inconnu' }, + ] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const serviceStore = useServiceStore() + + expect(serviceStore.servicesHealth).toMatchObject({}) + expect(serviceStore.services).toMatchObject([]) + + await serviceStore.checkServicesHealth() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(serviceStore.servicesHealth).toMatchObject({ + message: 'Échec lors de la dernière vérification', + serviceStatus: 'Inconnu', + status: undefined, + }) + expect(serviceStore.services).toMatchObject(data) + }) +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/services-monitor.ts b/.jjconflict-side-1/apps/client/src/stores/services-monitor.ts new file mode 100644 index 0000000000..d810ef328b --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/services-monitor.ts @@ -0,0 +1,125 @@ +import type { ServiceBody } from '@cpn-console/shared' +// @ts-ignore '@gouvminint/vue-dsfr' missing types +import type { DsfrAlertType } from '@gouvminint/vue-dsfr' +import { MonitorStatus } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export type ServicesHealth = { + message: string + status: DsfrAlertType | undefined + dotColor: 'gray' | 'red' | 'green' | 'orange' | 'blue' +} | Record + +interface ServiceHealthOption { message: string, status: DsfrAlertType | undefined, serviceStatus: MonitorStatus, dotColor: ServicesHealth['dotColor'] } + +export const alertTypeMapper: Record = { + [MonitorStatus.OK]: 'success', + [MonitorStatus.WARNING]: 'warning', + [MonitorStatus.ERROR]: 'error', + [MonitorStatus.UNKNOW]: undefined, +} + +const serviceHealthOptions = { + fetching: { + message: 'Vérification de l\'état des services...', + status: 'info', + serviceStatus: MonitorStatus.UNKNOW, + dotColor: 'blue', + }, + fetchError: { + message: 'Échec lors de la dernière vérification', + status: alertTypeMapper[MonitorStatus.UNKNOW], + serviceStatus: MonitorStatus.UNKNOW, + dotColor: 'gray', + }, + error: { + message: 'Un ou plusieurs services dysfonctionnent', + status: alertTypeMapper[MonitorStatus.ERROR], + serviceStatus: MonitorStatus.ERROR, + dotColor: 'red', + }, + warn: { + message: 'Un ou plusieurs services sont partiellement dégradés', + status: alertTypeMapper[MonitorStatus.WARNING], + serviceStatus: MonitorStatus.WARNING, + dotColor: 'orange', + }, + ok: { + message: 'Tous les services fonctionnent normalement', + status: alertTypeMapper[MonitorStatus.OK], + serviceStatus: MonitorStatus.OK, + dotColor: 'green', + }, +} as const satisfies Record + +export const useServiceStore = defineStore('serviceMonitor', () => { + const callStastus = ref<'ok' | 'fetching' | 'error'>('fetching') + + const displayCause = ref(false) + const services = ref([]) + + const serviceHealthIndex = computed(() => { + if (callStastus.value === 'fetching') { + return 'fetching' + } + if (services.value.some(({ status }) => status === 'Dégradé')) { + return 'warn' + } + if (services.value.some(({ status }) => status === 'En échec')) { + return 'error' + } + if (services.value.some(({ status }) => status === 'Inconnu')) { + return 'fetchError' + } + return 'ok' + }) + + const servicesHealth = computed(() => serviceHealthOptions[serviceHealthIndex.value]) + let interval: NodeJS.Timeout + + const clear = () => interval && clearInterval(interval) + + const checkServicesHealth = async () => { + callStastus.value = 'fetching' + try { + services.value = await (displayCause.value + ? apiClient.Services.getCompleteServiceHealth() + : apiClient.Services.getServiceHealth()) + .then((res: any) => extractData(res, 200)) + callStastus.value = 'ok' + } catch { + callStastus.value = 'error' + } + } + + const refreshServicesHealth = async () => { + await apiClient.Services.refreshServiceHealth() + .then((res: any) => extractData(res, 200)) + return checkServicesHealth() + } + + const startHealthPolling = async () => { + if (!interval) return + + clear() + await checkServicesHealth() + interval = setInterval(checkServicesHealth, 300_000_000) + } + + async function toggleDisplayCause() { + displayCause.value = !displayCause.value + await checkServicesHealth() + } + + return { + displayCause, + servicesHealth, + services, + checkServicesHealth, + refreshServicesHealth, + toggleDisplayCause, + startHealthPolling, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/snackbar.spec.ts b/.jjconflict-side-1/apps/client/src/stores/snackbar.spec.ts new file mode 100644 index 0000000000..4fe623ecb3 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/snackbar.spec.ts @@ -0,0 +1,155 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSnackbarStore } from './snackbar.js' + +describe('snackbar Store', () => { + beforeEach(() => { + vi.clearAllTimers() + vi.useFakeTimers() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should display message in snackbar with default values', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(6000) + + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display message in snackbar with custom timer', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message, 'info', 10000) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(6000) + + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(4000) + + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display message in snackbar with custom type', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + const type = 'warning' + snackbarStore.setMessage(message, type) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + }) + + it('should display message in snackbar and don\'t hide it if type error', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + const type = 'error' + snackbarStore.setMessage(message, type) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type) + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display last message in snackbar', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message1 = 'this is a message' + const type1 = 'warning' + snackbarStore.setMessage(message1, type1) + + expect(snackbarStore.message).toEqual(message1) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type1) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + const message2 = 'this is another message' + const type2 = 'info' + snackbarStore.setMessage(message2, type2) + + expect(snackbarStore.message).toEqual(message2) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type2) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + }) + + it('should hide message in snackbar', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + snackbarStore.hideMessage() + + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/snackbar.ts b/.jjconflict-side-1/apps/client/src/stores/snackbar.ts new file mode 100644 index 0000000000..badfb2e072 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/snackbar.ts @@ -0,0 +1,42 @@ +import type { ErrorTypes } from '@cpn-console/shared' +import type { Ref } from 'vue' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useSnackbarStore = defineStore('snackbar', () => { + const defaultTimeout: number = 6000 + const message: Ref = ref(undefined) + const isOpen: Ref = ref(false) + const type: Ref = ref('info') + const timeoutId: Ref | undefined> = ref(undefined) + const isWaitingForResponse = ref(false) + + const hideMessage = () => { + isOpen.value = false + clearTimeout(timeoutId.value) + timeoutId.value = undefined + } + + const setMessage = (errorMessage: string, errorType: ErrorTypes = 'info', timeout: number = defaultTimeout) => { + if (timeoutId.value) { + clearTimeout(timeoutId.value) + timeoutId.value = undefined + } + if (errorType !== 'error') { + timeoutId.value = setTimeout(hideMessage, timeout) + } + message.value = errorMessage + isOpen.value = true + type.value = errorType + } + + return { + message, + isOpen, + type, + timeoutId, + isWaitingForResponse, + setMessage, + hideMessage, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/stage.spec.ts b/.jjconflict-side-1/apps/client/src/stores/stage.spec.ts new file mode 100644 index 0000000000..542c5c4c1c --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/stage.spec.ts @@ -0,0 +1,96 @@ +import type { Stage } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useStageStore } from './stage.js' + +const apiClientListStages = vi.spyOn(apiClient.Stages, 'listStages') +const apiClientGet = vi.spyOn(apiClient.Stages, 'getStageEnvironments') +const apiClientPost = vi.spyOn(apiClient.Stages, 'createStage') +const apiClientPut = vi.spyOn(apiClient.Stages, 'updateStage') +const apiClientDelete = vi.spyOn(apiClient.Stages, 'deleteStage') + +describe('stage Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get stages list by api call', async () => { + const data = [ + { id: 'id1', name: 'dev' }, + { id: 'id2', name: 'int' }, + { id: 'id3', name: 'prod' }, + ] as Stage[] + apiClientListStages.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + await stageStore.getAllStages() + + expect(stageStore.stages).toEqual(data) + expect(apiClientListStages).toHaveBeenCalledTimes(1) + }) + + it('should get a stage\'s associated environments by api call', async () => { + const stageId = 'stageId' + const data = [ + { name: 'env1' }, + { name: 'env2' }, + { name: 'env3' }, + ] as { name: string, project: string, cluster: string, owner: string }[] + apiClientGet.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.getStageAssociatedEnvironments(stageId) + + expect(res).toBe(data) + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should create a stage by api call', async () => { + const data = { + name: 'int', + } as Stage + + apiClientPost.mockResolvedValueOnce({ status: 201, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.addStage(data) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update a stage\'s quotas and clusters associated by api call', async () => { + const stageId = 'stageId' + const quotaIds = ['stage1'] + const clusterIds = ['cluster1'] + const data = { + name: 'stageA', + quotaIds, + clusterIds, + id: stageId, + } + + apiClientPut.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.updateStage(stageId, { clusterIds, name: 'stageA' }) + + expect(res).toBe(data) + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete a stage by api call', async () => { + const stageId = 'stageId' + + apiClientDelete.mockResolvedValueOnce({ status: 204, body: null, headers: null }) + const stageStore = useStageStore() + + await stageStore.deleteStage(stageId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/stage.ts b/.jjconflict-side-1/apps/client/src/stores/stage.ts new file mode 100644 index 0000000000..de6d3824bf --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/stage.ts @@ -0,0 +1,45 @@ +import type { CreateStageBody, Stage, UpdateStageBody } from '@cpn-console/shared' +import { + + resourceListToDict, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useStageStore = defineStore('stage', () => { + const stages = ref([]) + const stagesById = computed(() => resourceListToDict(stages.value)) + + const getAllStages = async () => { + stages.value = await apiClient.Stages.listStages() + .then((response: any) => extractData(response, 200)) + return stages.value + } + + const getStageAssociatedEnvironments = (stageId: string) => + apiClient.Stages.getStageEnvironments({ params: { stageId } }) + .then((response: any) => extractData(response, 200)) + + const addStage = (body: CreateStageBody) => + apiClient.Stages.createStage({ body }) + .then((response: any) => extractData(response, 201)) + + const updateStage = (stageId: string, body: UpdateStageBody) => + apiClient.Stages.updateStage({ params: { stageId }, body }) + .then((response: any) => extractData(response, 200)) + + const deleteStage = (stageId: string) => + apiClient.Stages.deleteStage({ params: { stageId } }) + .then((response: any) => extractData(response, 204)) + + return { + stages, + stagesById, + getAllStages, + getStageAssociatedEnvironments, + addStage, + updateStage, + deleteStage, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/system-settings.spec.ts b/.jjconflict-side-1/apps/client/src/stores/system-settings.spec.ts new file mode 100644 index 0000000000..0cf6eca2eb --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/system-settings.spec.ts @@ -0,0 +1,55 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useSystemSettingsStore } from './system-settings.js' + +const listSystemSettings = vi.spyOn(apiClient.SystemSettings, 'listSystemSettings') +const upsertSystemSetting = vi.spyOn(apiClient.SystemSettings, 'upsertSystemSetting') + +describe('system Settings Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get system settings list by api call', async () => { + const data = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'miom' }, + ] + listSystemSettings.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const systemSettingsStore = useSystemSettingsStore() + + await systemSettingsStore.listSystemSettings() + + expect(systemSettingsStore.systemSettings).toEqual(data) + expect(listSystemSettings).toHaveBeenCalledTimes(1) + }) + + it('should upsert a system setting by api call', async () => { + const data = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'miom' }, + ] + const newSystemSetting = { key: 'organisation', value: 'mj' } + const newData = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'mj' }, + ] + + upsertSystemSetting.mockReturnValueOnce(Promise.resolve({ status: 201, body: newSystemSetting })) + const systemSettingsStore = useSystemSettingsStore() + systemSettingsStore.systemSettings = data + + const res = await systemSettingsStore.upsertSystemSetting(data) + + expect(res).toEqual(newSystemSetting) + expect(systemSettingsStore.systemSettings).toEqual(newData) + expect(upsertSystemSetting).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/system-settings.ts b/.jjconflict-side-1/apps/client/src/stores/system-settings.ts new file mode 100644 index 0000000000..8eebac262e --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/system-settings.ts @@ -0,0 +1,36 @@ +import type { + SystemSettings, + systemSettingsContract, + UpsertSystemSettingBody, +} from '@cpn-console/shared' +import { + resourceListToDictByKey, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useSystemSettingsStore = defineStore('systemSettings', () => { + const systemSettings = ref([]) + const systemSettingsByKey = computed(() => resourceListToDictByKey(systemSettings.value)) + + const listSystemSettings = async (query: typeof systemSettingsContract.listSystemSettings.query._type = {}) => { + systemSettings.value = await apiClient.SystemSettings.listSystemSettings(query) + .then((response: any) => extractData(response, 200)) + } + + const upsertSystemSetting = async (newSystemSetting: UpsertSystemSettingBody) => { + const res = await apiClient.SystemSettings.upsertSystemSetting({ body: newSystemSetting }) + .then((response: any) => extractData(response, 201)) + systemSettings.value = systemSettings.value + .toSpliced(systemSettings.value + .findIndex(systemSetting => systemSetting.key === res.key), 1, res) + return res + } + + return { + systemSettings, + systemSettingsByKey, + listSystemSettings, + upsertSystemSetting, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/token.ts b/.jjconflict-side-1/apps/client/src/stores/token.ts new file mode 100644 index 0000000000..d19b832333 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/token.ts @@ -0,0 +1,36 @@ +import type { + PersonalAccessToken, + personalAccessTokenContract, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '../api/xhr-client.js' + +export const useTokenStore = defineStore('token', () => { + const listPersonalAccessTokens = async () => { + return apiClient.PersonalAccessTokens.listPersonalAccessTokens().then( + (response: any) => extractData(response, 200), + ) + } + + const createPersonalAccessToken = async ( + body: typeof personalAccessTokenContract.createPersonalAccessToken.body._type, + ) => { + return apiClient.PersonalAccessTokens.createPersonalAccessToken({ + body, + }).then((res: any) => extractData(res, 201)) + } + + const deletePersonalAccessToken = async ( + tokenId: PersonalAccessToken['id'], + ) => { + await apiClient.PersonalAccessTokens.deletePersonalAccessToken({ + params: { tokenId }, + }).then((res: any) => extractData(res, 204)) + } + + return { + listPersonalAccessTokens, + createPersonalAccessToken, + deletePersonalAccessToken, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/user.spec.ts b/.jjconflict-side-1/apps/client/src/stores/user.spec.ts new file mode 100644 index 0000000000..8ca9e7a521 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/user.spec.ts @@ -0,0 +1,80 @@ +import Keycloak from 'keycloak-js' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useUserStore } from './user.js' + +const authMock = vi.spyOn(apiClient.Users, 'auth') +const listAdminRoles = vi.spyOn(apiClient.AdminRoles, 'listAdminRoles') + +authMock.mockResolvedValue(Promise.resolve({ status: 200, body: {} })) +listAdminRoles.mockResolvedValue(Promise.resolve({ status: 200, body: [] })) + +vi.mock('keycloak-js', () => { + const Keycloak = vi.fn() + Keycloak.prototype.authenticated = true + Keycloak.prototype.idTokenParsed = { + email: 'michel.michel@test.com', + sub: 'userId', + given_name: 'Michel', + family_name: 'MICHEL', + groups: ['/admin'], + } + Keycloak.prototype.login = vi.fn() + Keycloak.prototype.logout = vi.fn() + + return { default: Keycloak } +}) +const keycloak = new Keycloak() + +describe('user Store', () => { + beforeEach(() => { + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + afterEach(() => { + vi.clearAllMocks() + }) + + it('should retrieve isLoggedIn from Keycloak (true)', async () => { + const userStore = useUserStore() + + expect(userStore.isLoggedIn).toBeUndefined() + + await userStore.setIsLoggedIn() + + expect(userStore.isLoggedIn).toEqual(true) + }) + + it('should retrieve userProfile from Keycloak', async () => { + const userStore = useUserStore() + + expect(userStore.userProfile).toMatchObject({}) + + userStore.setUserProfile() + + expect(userStore.userProfile).toMatchObject({ + email: 'michel.michel@test.com', + id: 'userId', + firstName: 'Michel', + lastName: 'MICHEL', + }) + }) + + it('should call Keycloak login function', async () => { + const userStore = useUserStore() + + await userStore.login() + + expect(keycloak.login).toBeCalledTimes(1) + }) + + it('should call Keycloak logout function', async () => { + const userStore = useUserStore() + + await userStore.logout() + + expect(keycloak.logout).toBeCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/user.ts b/.jjconflict-side-1/apps/client/src/stores/user.ts new file mode 100644 index 0000000000..f860e6d037 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/user.ts @@ -0,0 +1,58 @@ +import type { AdminRole, User, UserProfile } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' +import { getKeycloak, getUserProfile, keycloakLogin, keycloakLogout } from '@/utils/keycloak/keycloak.js' +import { useAdminRoleStore } from './admin-role.js' +import { useSystemSettingsStore } from './system-settings.js' + +export const useUserStore = defineStore('user', () => { + const adminRoleStore = useAdminRoleStore() + const systemSettingsStore = useSystemSettingsStore() + const isLoggedIn = ref() + const userProfile = ref() + const apiAuthInfos = ref() + + const myAdminRoles = computed(() => adminRoleStore.roles?.filter(adminRole => apiAuthInfos.value?.adminRoleIds.includes(adminRole.id)) ?? []) + + const adminPerms = computed(() => { + if (!apiAuthInfos.value) return null + const globalRoles = adminRoleStore.roles?.filter(role => role.type === 'global') ?? [] + const globalPerms = globalRoles.reduce((acc, role) => acc | BigInt(role.permissions), 0n) + const adminPerms = myAdminRoles.value.reduce((acc, role) => acc | BigInt(role.permissions), 0n) + return globalPerms | adminPerms + }) + + const setUserProfile = async () => { + userProfile.value = getUserProfile() + await systemSettingsStore.listSystemSettings().catch(() => undefined) + await apiClient.Users.auth() + .then((res: any) => apiAuthInfos.value = extractData(res, 200)) + } + + const setIsLoggedIn = async () => { + const keycloak = getKeycloak() + if (keycloak.authenticated !== isLoggedIn.value) { + isLoggedIn.value = keycloak.authenticated + if (isLoggedIn.value) { + await setUserProfile() + } + } + } + + const login = () => keycloakLogin() + + const logout = () => keycloakLogout() + + return { + isLoggedIn, + setIsLoggedIn, + userProfile, + apiAuthInfos, + myAdminRoles, + adminPerms, + setUserProfile, + login, + logout, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/users.ts b/.jjconflict-side-1/apps/client/src/stores/users.ts new file mode 100644 index 0000000000..4bacc644da --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/users.ts @@ -0,0 +1,23 @@ +import type { userContract } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useUsersStore = defineStore('users', () => { + const listUsers = async (query: typeof userContract.getAllUsers.query._input) => + apiClient.Users.getAllUsers({ query }) + .then((res: any) => extractData(res, 200)) + + const listMatchingUsers = async (query: typeof userContract.getMatchingUsers.query._type) => + apiClient.Users.getMatchingUsers({ query }) + .then((res: any) => extractData(res, 200)) + + const patchUsers = async (body: typeof userContract.patchUsers.body._type) => + apiClient.Users.patchUsers({ body }) + .then((res: any) => extractData(res, 200)) + + return { + listUsers, + listMatchingUsers, + patchUsers, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/zone.spec.ts b/.jjconflict-side-1/apps/client/src/stores/zone.spec.ts new file mode 100644 index 0000000000..087ee45034 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/zone.spec.ts @@ -0,0 +1,67 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useZoneStore } from './zone.js' + +const apiClientGet = vi.spyOn(apiClient.Zones, 'listZones') +const apiClientPost = vi.spyOn(apiClient.Zones, 'createZone') +const apiClientPut = vi.spyOn(apiClient.Zones, 'updateZone') +const apiClientDelete = vi.spyOn(apiClient.Zones, 'deleteZone') + +describe('zone Store', () => { + beforeEach(() => { + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + afterEach(() => { + vi.clearAllMocks() + }) + + it('should retrieve all zones', async () => { + const zoneStore = useZoneStore() + + expect(zoneStore.zones).toEqual([]) + + const zones = [{ id: 'zoneId' }, { id: 'anotherZoneId' }] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: zones })) + + await zoneStore.getAllZones() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(zoneStore.zones).toMatchObject(zones) + }) + + it('should create a new zone', async () => { + const adminZoneStore = useZoneStore() + + const zone = { label: 'Zone à défendre', slug: 'zad' } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, data: zone })) + + await adminZoneStore.createZone(zone) + + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update a zone', async () => { + const adminZoneStore = useZoneStore() + + const zone = { id: 'zoneId', label: 'Zod à update' } + apiClientPut.mockReturnValueOnce(Promise.resolve({ status: 200, data: zone })) + + await adminZoneStore.updateZone(zone.id, zone) + + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete a zone', async () => { + const adminZoneStore = useZoneStore() + + const zoneId = 'zoneId' + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204, data: null })) + + await adminZoneStore.deleteZone(zoneId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-1/apps/client/src/stores/zone.ts b/.jjconflict-side-1/apps/client/src/stores/zone.ts new file mode 100644 index 0000000000..0c81a3e0f1 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/stores/zone.ts @@ -0,0 +1,40 @@ +import type { CreateZoneBody, UpdateZoneBody, Zone } from '@cpn-console/shared' +import { + + resourceListToDict, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useZoneStore = defineStore('zone', () => { + const zones = ref([]) + const zonesById = computed(() => resourceListToDict(zones.value)) + + const getAllZones = async () => { + zones.value = await apiClient.Zones.listZones() + .then((response: any) => extractData(response, 200)) + return zones.value + } + + const createZone = (body: CreateZoneBody) => + apiClient.Zones.createZone({ body }) + .then((response: any) => extractData(response, 201)) + + const updateZone = (zoneId: Zone['id'], data: UpdateZoneBody) => + apiClient.Zones.updateZone({ body: data, params: { zoneId } }) + .then((response: any) => extractData(response, 200)) + + const deleteZone = (zoneId: Zone['id']) => + apiClient.Zones.deleteZone({ params: { zoneId } }) + .then((response: any) => extractData(response, 204)) + + return { + zones, + zonesById, + getAllZones, + createZone, + updateZone, + deleteZone, + } +}) diff --git a/.jjconflict-side-1/apps/client/src/utils/env.ts b/.jjconflict-side-1/apps/client/src/utils/env.ts new file mode 100644 index 0000000000..189eb7143e --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/utils/env.ts @@ -0,0 +1,20 @@ +/* eslint-disable no-template-curly-in-string */ +export const serverHost: string = process.env.SERVER_HOST ?? '${SERVER_HOST}' + +export const serverPort: string = process.env.SERVER_PORT ?? '${SERVER_PORT}' + +export const clientPort: string = process.env.CLIENT_PORT ?? '${CLIENT_PORT}' + +export const keycloakProtocol: string = process.env.KEYCLOAK_PROTOCOL ?? '${KEYCLOAK_PROTOCOL}' + +export const keycloakDomain: string = process.env.KEYCLOAK_DOMAIN ?? '${KEYCLOAK_DOMAIN}' + +export const keycloakRealm: string = process.env.KEYCLOAK_REALM ?? '${KEYCLOAK_REALM}' + +export const keycloakClientId: string = process.env.KEYCLOAK_CLIENT_ID ?? '${KEYCLOAK_CLIENT_ID}' + +export const keycloakRedirectUri: string = process.env.KEYCLOAK_REDIRECT_URI ?? '${KEYCLOAK_REDIRECT_URI}' + +export const contactEmail: string = process.env.CONTACT_EMAIL ?? '${CONTACT_EMAIL}' + +export const openCDSEnabled: string = process.env.OPENCDS_ENABLED ?? '${OPENCDS_ENABLED}' diff --git a/.jjconflict-side-1/apps/client/src/utils/func.spec.ts b/.jjconflict-side-1/apps/client/src/utils/func.spec.ts new file mode 100644 index 0000000000..75ef96cead --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/utils/func.spec.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('localeParseFloat EN tests', () => { + let localeParseFloatEN: (s: string) => number + beforeEach(async () => { + vi.resetModules() + vi.resetAllMocks() + Object.defineProperty(navigator, 'language', { + value: 'en-EN', + configurable: true, + }); + ({ localeParseFloat: localeParseFloatEN } = await import('./func.js')) + }) + it('should parse invalid float FR in locale EN', async () => { + const result = localeParseFloatEN('4,25') + expect(result).toBe(425) + }) + + it('should parse valid float EN in locale EN', async () => { + const result = localeParseFloatEN('4.25') + expect(result).toBe(4.25) + }) +}) + +describe('localeParseFloat FR tests', () => { + let localeParseFloatFR: (s: string) => number + beforeEach(async () => { + vi.resetModules() + vi.resetAllMocks() + Object.defineProperty(navigator, 'language', { + value: 'fr-FR', + configurable: true, + }); + ({ localeParseFloat: localeParseFloatFR } = await import('./func.js')) + }) + it('should parse valid float FR in locale FR', async () => { + const result = localeParseFloatFR('4,25') + expect(result).toBe(4.25) + }) + it('should parse valid float EN in locale FR', async () => { + const result = localeParseFloatFR('4.25') + expect(result).toBe(4.25) + }) +}) diff --git a/.jjconflict-side-1/apps/client/src/utils/func.ts b/.jjconflict-side-1/apps/client/src/utils/func.ts new file mode 100644 index 0000000000..71f1a61b5e --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/utils/func.ts @@ -0,0 +1,103 @@ +import type { Ref } from 'vue' +import { useSnackbarStore } from '@/stores/snackbar.js' + +const LOCALE = navigator.language.slice(0, 2) +// Get the thousands and decimal separator characters used in the locale. +const [,THOUSANDS_SEPARATOR,,,,DECIMAL_SEPARATOR] = 1111.1.toLocaleString(LOCALE) +// 0.1 number as local string to use in labels and placeholders +export const ONE_TENTH_STR = 0.1.toLocaleString() + +export async function copyContent(content: string): Promise { + const snackbarStore = useSnackbarStore() + try { + await navigator.clipboard.writeText(content) + snackbarStore.setMessage('Donnée copiée', 'success') + } catch (error: any) { + snackbarStore.setMessage(error?.message, 'error') + } +} + +export function toCodeComponent(value: string) { + return { + component: 'code', + text: value, + title: 'Copier la valeur', + class: 'fr-text-default--info text-xs cursor-pointer', + onClick: () => copyContent(value), + } +} + +const maxDescriptionLength = 60 +export function truncateDescription(description: string) { + let innerHTML: string + + if (description.length <= maxDescriptionLength) { + innerHTML = description + } else { + const lastSpaceIndex = description.slice(0, maxDescriptionLength).lastIndexOf(' ') + innerHTML = `${description.slice(0, lastSpaceIndex > 0 ? lastSpaceIndex : maxDescriptionLength)} ...` + } + + return { + id: 'description', + 'data-testid': 'description', + component: 'span', + open: false, + title: description, + innerHTML, + } +} + +export function clickInDialog(e?: MouseEvent | TouchEvent, fn?: () => void) { + // @ts-ignore + if (e && e.target?.tagName !== 'DIALOG') { + return + } + fn?.() +} + +function randomId() { + String.fromCharCode(97) + return (Array.from({ length: 6 })).map(() => String.fromCharCode(97 + Math.floor(Math.random() * 26))) +} +export function getRandomId(suffix?: string, prefix?: string) { + return (prefix ? (`${prefix}-`) : '') + randomId() + (suffix ? (`-${suffix}`) : '') +} + +export function toKebabCase(value: string) { + return value + .trim() + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +/** + * Replace current locale separators occurences before parsing a floating Number. + * See https://stackoverflow.com/a/59679285 + * @param s String to parse + * @returns Number + */ +export function localeParseFloat(s: string): number { + // Remove thousand separators, and put a point where the decimal separator occurs + const delocalizedInput = s.replaceAll(THOUSANDS_SEPARATOR, '').replaceAll(DECIMAL_SEPARATOR, '.') + // Now it can be parsed + return Number.parseFloat(delocalizedInput) +} + +export async function scrollToFirstError(container: Ref) { + await nextTick() + + if (!container.value) return + + const firstErrorElement = container.value.querySelector('.fr-error-text, .fr-input-group--error') + + if (firstErrorElement) { + firstErrorElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }) + } +} diff --git a/.jjconflict-side-1/apps/client/src/utils/keycloak/keycloak.spec.ts b/.jjconflict-side-1/apps/client/src/utils/keycloak/keycloak.spec.ts new file mode 100644 index 0000000000..63760697cf --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/utils/keycloak/keycloak.spec.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getKeycloak, getUserProfile, keycloakInit, keycloakLogin, keycloakLogout } from './keycloak.js' + +const userToken = { + email: 'test@test.com', + sub: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + given_name: 'Jean', + family_name: 'DUPOND', +} +const userStored = { + email: 'test@test.com', + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', +} +const keycloak = getKeycloak() + +vi.mock('keycloak-js', () => { + class Keycloak { + idTokenParsed: Record + init: () => void + login: () => void + logout: () => void + + constructor() { + this.idTokenParsed = userToken + this.init = vi.fn() + this.login = vi.fn() + this.logout = vi.fn() + } + } + return { default: Keycloak } +}) + +const mockedKeycloakInit = vi.spyOn(keycloak, 'init') +const mockedKeycloakLogin = vi.spyOn(keycloak, 'login') +const mockedKeycloakLogout = vi.spyOn(keycloak, 'logout') + +describe('keycloak-init', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return Keycloak instance', () => { + const keycloak = getKeycloak() + + expect(keycloak).toBeInstanceOf(Object) + }) + + it('should return keycloak user profile', () => { + const keycloakUser = getUserProfile() + + expect(keycloakUser).toBeInstanceOf(Object) + expect(keycloakUser).toMatchObject(userStored) + }) + + it('should init keycloak', async () => { + await keycloakInit() + + expect(mockedKeycloakInit.mock.calls).toHaveLength(1) + }) + + it('should throw an error if keycloak can\'t be initialize', async () => { + const error = new Error('Failed to init keycloak') + try { + mockedKeycloakInit.mockReturnValueOnce(Promise.reject(error)) + await keycloakInit() + } catch (e) { + expect(mockedKeycloakInit.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) + + it('should start login process to keycloak', async () => { + await keycloakLogin() + + expect(mockedKeycloakLogin.mock.calls).toHaveLength(1) + }) + + it('should throw an error if login process to keycloak failed', async () => { + const error = new Error('Failed to start login process keycloak') + try { + mockedKeycloakLogin.mockReturnValueOnce(Promise.reject(error)) + await keycloakLogin() + } catch (e) { + expect(mockedKeycloakLogin.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) + + it('should start logout process to keycloak', async () => { + await keycloakLogout() + + expect(mockedKeycloakLogout.mock.calls).toHaveLength(1) + }) + + it('should throw an error if logout process to keycloak failed', async () => { + const error = new Error('Failed to start logout process keycloak') + try { + mockedKeycloakLogout.mockReturnValueOnce(Promise.reject(error)) + await keycloakLogout() + } catch (e) { + expect(mockedKeycloakLogout.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) +}) diff --git a/.jjconflict-side-1/apps/client/src/utils/keycloak/keycloak.ts b/.jjconflict-side-1/apps/client/src/utils/keycloak/keycloak.ts new file mode 100644 index 0000000000..2d36a097c5 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/utils/keycloak/keycloak.ts @@ -0,0 +1,101 @@ +import type { UserProfile } from '@cpn-console/shared' +import type { KeycloakConfig, KeycloakInitOptions } from 'keycloak-js' +import { logger } from '@cpn-console/logger/browser' +import Keycloak from 'keycloak-js' +import { + keycloakClientId, + keycloakDomain, + keycloakProtocol, + keycloakRealm, + keycloakRedirectUri, +} from '../env.js' + +export const keycloakInitOptions: KeycloakInitOptions = { + onLoad: 'check-sso', + flow: 'standard', + redirectUri: keycloakRedirectUri, +} + +export const keycloakConfig: KeycloakConfig = { + url: `${keycloakProtocol}://${keycloakDomain}`, + realm: keycloakRealm, + clientId: keycloakClientId, +} + +let keycloak: Keycloak + +export function getKeycloak() { + if (!keycloak) { + keycloak = new Keycloak(keycloakConfig) + keycloak.onAuthSuccess = () => { + if (!(keycloak.refreshTokenParsed?.exp && keycloak.tokenParsed?.exp && keycloak.refreshTokenParsed.exp > keycloak.tokenParsed.exp)) { + return + } + logger.warn('Keycloak misconfiguration: refreshToken should not expire before token') + const refreshTokenDelay = (keycloak.tokenParsed.exp * 1000 - Date.now()) / 2 + setTimeout(() => { + keycloak.updateToken() + }, refreshTokenDelay) + } + keycloak.onTokenExpired = () => { + keycloak.updateToken(30) + } + } + return keycloak +} + +export function getUserProfile(): UserProfile { + try { + const keycloak = getKeycloak() + const { email, sub: id, given_name: firstName, family_name: lastName, groups } = keycloak.idTokenParsed as { email: string, sub: string, given_name: string, firstName: string, family_name: string, lastName: string, groups: string[] } + return { + email, + id, + firstName, + lastName, + groups, + } + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de récupération du profil keycloak de l\'utilisateur') + } +} + +export async function keycloakInit() { + const currentUrl = new URL(window.location.href) + const redirectUri = `${window.location.origin}${currentUrl.pathname}${currentUrl.search}` + try { + const { onLoad, flow } = keycloakInitOptions + const keycloak = getKeycloak() + await keycloak.init({ + onLoad, + flow, + redirectUri, + }) + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec d\'initialisation du keycloak') + } +} + +export async function keycloakLogin() { + try { + const keycloak = getKeycloak() + const currentUrl = new URL(window.location.href) + const redirectUri = `${window.location.origin}${currentUrl.pathname}${currentUrl.search}` + await keycloak.login({ redirectUri }) + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de connexion au keycloak') + } +} + +export async function keycloakLogout() { + try { + const keycloak = getKeycloak() + await keycloak.logout() + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de déconnexion du keycloak') + } +} diff --git a/.jjconflict-side-1/apps/client/src/utils/project-utils.ts b/.jjconflict-side-1/apps/client/src/utils/project-utils.ts new file mode 100644 index 0000000000..e45746f660 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/utils/project-utils.ts @@ -0,0 +1,414 @@ +import type { + CreateDeploymentBody, + CreateEnvironmentBody, + CreateRepositoryBody, + Deployment, + Environment, + GetLogsQuery, + PermissionTarget, + PluginsUpdateBody, + projectContract, + projectMemberContract, + projectRoleContract, + ProjectService, + ProjectV2, + Repo, + RepositoryParams, + Role, + UpdateDeploymentBody, + UpdateEnvironmentBody, + UpdateRepositoryBody, + User, +} from '@cpn-console/shared' +import type { Ref } from 'vue' +import { + getPermsByUserRoles, + objectKeys, + PROJECT_PERMS, + resourceListToDict, +} from '@cpn-console/shared' + +import { + apiClient, + extractData, +} from '@/api/xhr-client.js' +import { useLogStore } from '@/stores/log.js' +import { useUserStore } from '@/stores/user.js' +import { getRandomId } from './func.js' + +export type ProjectOperations = 'create' + | 'delete' + | 'deploymentManagement' + | 'envManagement' + | 'repoManagement' + | 'teamManagement' + | 'searchSecret' + | 'replay' + | 'update' + | 'lockHandling' + | 'saveServices' + | 'roleManagement' + +function calculateProjectPerms(project: ProjectV2 | undefined, userId: string | undefined) { + if (!project || !userId) return 0n + if (userId === project?.ownerId) return PROJECT_PERMS.MANAGE + const selfMember = project.members.find(member => member.userId === userId) + if (!selfMember) return 0n + + return getPermsByUserRoles(selfMember.roleIds, resourceListToDict(project.roles), project.everyonePerms) +} + +export class Project implements ProjectV2 { + id: string + clusterIds: string[] + description: string | undefined + everyonePerms: string + name: string + slug: string + locked: boolean + owner: Omit + ownerId: string + roles: { id: string, name: string, permissions: string, position: number, projectId: string, oidcGroup?: string, type?: string }[] + members: ({ userId: string, firstName: string, lastName: string, email: string, roleIds: string[] } | { updatedAt: string, createdAt: string, firstName: string, lastName: string, email: string, userId: string, roleIds: string[] })[] + createdAt: string + updatedAt: string + limitless: boolean + hprodMemory: number + hprodCpu: number + hprodGpu: number + prodMemory: number + prodCpu: number + prodGpu: number + status: ProjectV2['status'] + operationsInProgress: Ref + myPerms: bigint + repositories: Ref + environments: Ref + deployments: Ref + services: ProjectService[] = [] + lastSuccessProvisionningVersion: string | null + needReplay: Ref + + constructor(project: ProjectV2) { + this.id = project.id + this.clusterIds = project.clusterIds + this.description = project.description + this.everyonePerms = project.everyonePerms + this.name = project.name + this.slug = project.slug + this.locked = project.locked + this.owner = project.owner + this.ownerId = project.ownerId + this.roles = project.roles + this.members = project.members + this.createdAt = project.createdAt + this.updatedAt = project.updatedAt + this.limitless = project.limitless + this.hprodMemory = project.hprodMemory + this.hprodCpu = project.hprodCpu + this.hprodGpu = project.hprodGpu + this.prodMemory = project.prodMemory + this.prodCpu = project.prodCpu + this.prodGpu = project.prodGpu + this.lastSuccessProvisionningVersion = project.lastSuccessProvisionningVersion + this.status = project.status + this.myPerms = calculateProjectPerms(this, useUserStore().userProfile?.id) + this.operationsInProgress = ref([]) + this.environments = ref([]) + this.repositories = ref([]) + this.needReplay = ref(false) + this.deployments = ref([]) + } + + private removeOperation(operationName: ProjectOperations) { + setTimeout(() => { + useLogStore().needRefresh = true + }, 100) + this.operationsInProgress.value = this.operationsInProgress.value.filter(op => op !== operationName) + return this.operationsInProgress.value + } + + private addOperation(operationName: ProjectOperations): () => void { + this.needReplay.value = ['teamManagement', 'saveServices', 'roleManagement'].includes(operationName) + if (this.operationsInProgress.value.includes(operationName)) { + operationName += getRandomId() + } + if (this.operationsInProgress.value.length <= 1) { + this.operationsInProgress.value.push(operationName) + } else { + return () => {} + } + return () => { this.removeOperation(operationName) } + } + + private computePerms() { + this.myPerms = calculateProjectPerms(this, useUserStore().userProfile?.id) + } + + Commands = { + update: async (data: typeof projectContract.updateProject.body._type) => { + const callback = this.addOperation('update') + try { + const project = await apiClient.Projects.updateProject({ body: data, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + .finally(() => callback()) + return this.Commands.updateData(project) + } finally { + callback() + } + }, + updateData: (project: Partial) => { + for (const key of objectKeys(project)) { + // @ts-ignore + this[key] = project[key] + } + this.computePerms() + return undefined + }, + refresh: async () => { + const project = await apiClient.Projects.getProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.Commands.updateData(project) + await Promise.all([ + this.Repositories.list(), + this.Environments.list(), + ]) + return undefined + }, + replay: async () => { + const callback = this.addOperation('update') + try { + await apiClient.Projects.replayHooksForProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 204)) + return this.Commands.refresh() + } finally { + callback() + } + }, + delete: async () => { + const callback = this.addOperation('delete') + try { + await apiClient.Projects.archiveProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 204)) + this.status = 'archived' + } catch { + await this.Commands.refresh() + } finally { + callback() + } + }, + } + + Members = { + list: async () => { + this.members = await apiClient.ProjectsMembers.listMembers({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.members + }, + create: async (email: string) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.addMember({ params: { projectId: this.id }, body: { email } }) + .then((response: any) => extractData(response, 201)) + return this.Members.list() + } finally { callback() } + }, + delete: async (userId: User['id']) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.removeMember({ params: { projectId: this.id, userId } }) + .then((response: any) => extractData(response, 200)) + return this.Members.list() + } finally { callback() } + }, + patch: async (body: typeof projectMemberContract.patchMembers.body._type) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.patchMembers({ params: { projectId: this.id }, body }) + .then((response: any) => extractData(response, 200)) + return this.Members.list() + } finally { callback() } + }, + getCandidateUsers: async (letters: string) => { + return apiClient.Users.getMatchingUsers({ query: { letters, notInProjectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + } + + Deployments = { + list: async () => { + this.deployments.value = await apiClient.Deployments.listDeployments({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.deployments.value + }, + create: async (deploymentData: Omit) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.createDeployment({ + params: { projectId: this.id }, + body: { ...deploymentData, projectId: this.id }, + }) + .then((response: any) => extractData(response, 201)) + } finally { callback() } + }, + update: async (id: Deployment['id'], deployment: UpdateDeploymentBody) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.updateDeployment({ + params: { projectId: this.id, deploymentId: id }, + body: deployment, + }) + .then((response: any) => extractData(response, 200)) + } finally { callback() } + }, + delete: async (deploymentId: Deployment['id']) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.deleteDeployment({ + params: { projectId: this.id, deploymentId }, + }) + .then((response: any) => extractData(response, 204)) + } finally { callback() } + }, + } + + Environments = { + list: async () => { + this.environments.value = await apiClient.Environments.listEnvironments({ query: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.environments.value + }, + create: async (envData: Omit) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.createEnvironment({ body: { ...envData, projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + return this.Environments.list() + } finally { callback() } + }, + update: async (id: Environment['id'], environment: UpdateEnvironmentBody) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.updateEnvironment({ body: environment, params: { environmentId: id } }) + .then((response: any) => extractData(response, 200)) + await this.Environments.list() + return this.environments + } finally { callback() } + }, + delete: async (environmentId: Environment['id']) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.deleteEnvironment({ params: { environmentId } }) + .then((response: any) => extractData(response, 204)) + await this.Environments.list() + return this.environments + } finally { callback() } + }, + } + + Repositories = { + list: async () => { + this.repositories.value = await apiClient.Repositories.listRepositories({ query: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.repositories.value + }, + sync: async (repositoryId: Repo['id'], { branchName, syncAllBranches = false }: { branchName?: string, syncAllBranches?: boolean }) => { + const callback = this.addOperation('repoManagement') + try { + return apiClient.Repositories.syncRepository({ + params: { repositoryId }, + body: { branchName, syncAllBranches }, + }) + .then((response: any) => extractData(response, 204)) + } finally { callback() } + }, + create: async (repoData: Omit) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.createRepository({ body: { ...repoData, projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + return this.Repositories.list() + } finally { callback() } + }, + update: async (id: RepositoryParams['repositoryId'], repoData: Omit) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.updateRepository({ body: { ...repoData, projectId: this.id }, params: { repositoryId: id } }) + .then((response: any) => extractData(response, 200)) + return this.Repositories.list() + } finally { callback() } + }, + delete: async (repositoryId: Repo['id']) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.deleteRepository({ params: { repositoryId } }) + .then((response: any) => extractData(response, 204)) + return this.Repositories.list() + } finally { callback() } + }, + } + + Roles = { + countMembers: async () => { + return apiClient.ProjectsRoles.projectRoleMemberCounts({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + list: async () => { + this.roles = await apiClient.ProjectsRoles.listProjectRoles({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.computePerms() + return this.roles + }, + patch: async (body: typeof projectRoleContract.patchProjectRoles.body._type) => { + const callback = this.addOperation('roleManagement') + try { + this.roles = await apiClient.ProjectsRoles.patchProjectRoles({ body, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.computePerms() + return this.roles + } finally { callback() } + }, + create: async (body: typeof projectRoleContract.createProjectRole.body._type) => { + this.roles = await apiClient.ProjectsRoles.createProjectRole({ body, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + this.computePerms() + return this.roles + }, + delete: async (roleId: Role['id']) => { + const callback = this.addOperation('roleManagement') + try { + await apiClient.ProjectsRoles.deleteProjectRole({ params: { projectId: this.id, roleId } }) + .then((response: any) => extractData(response, 204)) + this.computePerms() + return this.Roles.list() + } finally { callback() } + }, + } + + Services = { + getSecrets: async () => { + return apiClient.Projects.getProjectSecrets({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + list: async (permissionTarget: PermissionTarget = 'user') => { + this.services = await apiClient.ProjectServices.getServices({ params: { projectId: this.id }, query: { permissionTarget } }) + .then((response: any) => extractData(response, 200)) + return this.services + }, + update: async (body: PluginsUpdateBody) => { + const callback = this.addOperation('saveServices') + try { + await apiClient.ProjectServices.updateProjectServices({ params: { projectId: this.id }, body }) + .then((response: any) => extractData(response, 204)) + return this.Services.list() + } finally { callback() } + }, + } + + Logs = { + list: async ({ offset, limit, clean }: GetLogsQuery = { offset: 0, limit: 10 }) => { + return apiClient.Logs.getLogs({ query: { offset, limit, clean, projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + } +} diff --git a/.jjconflict-side-1/apps/client/src/utils/regex.spec.ts b/.jjconflict-side-1/apps/client/src/utils/regex.spec.ts new file mode 100644 index 0000000000..353338ee8c --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/utils/regex.spec.ts @@ -0,0 +1,25 @@ +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { email, noSpace, uuid } from '@/utils/regex.js' + +describe('regex', () => { + it('should return false (noSpace)', () => { + expect(noSpace.test('te st')).toStrictEqual(false) + }) + it('should return true (noSpace)', () => { + expect(noSpace.test('test')).toStrictEqual(true) + }) + + it('should return false (email)', () => { + expect(email.test('prenom.nom')).toStrictEqual(false) + }) + it('should return true (email)', () => { + expect(email.test('prenom.nom@interieur.gouv.fr')).toStrictEqual(true) + }) + it('should return true (uuid)', () => { + expect(uuid.test(faker.string.uuid())).toStrictEqual(true) + }) + it('should return false (uuid)', () => { + expect(uuid.test('abc')).toStrictEqual(false) + }) +}) diff --git a/.jjconflict-side-1/apps/client/src/utils/regex.ts b/.jjconflict-side-1/apps/client/src/utils/regex.ts new file mode 100644 index 0000000000..38ded36071 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/utils/regex.ts @@ -0,0 +1,5 @@ +export const noSpace: RegExp = /^\S*$/ + +export const email: RegExp = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/ + +export const uuid: RegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i diff --git a/.jjconflict-side-1/apps/client/src/views/CreateProject.vue b/.jjconflict-side-1/apps/client/src/views/CreateProject.vue new file mode 100644 index 0000000000..797df6602d --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/CreateProject.vue @@ -0,0 +1,148 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/DsoHome.vue b/.jjconflict-side-1/apps/client/src/views/DsoHome.vue new file mode 100644 index 0000000000..6e141e518b --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/DsoHome.vue @@ -0,0 +1,183 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/DsoMaintenance.vue b/.jjconflict-side-1/apps/client/src/views/DsoMaintenance.vue new file mode 100644 index 0000000000..e220e5965c --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/DsoMaintenance.vue @@ -0,0 +1,36 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/NotFound.vue b/.jjconflict-side-1/apps/client/src/views/NotFound.vue new file mode 100644 index 0000000000..a0e59fb9cf --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/NotFound.vue @@ -0,0 +1,22 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/ProjectDashboard.vue b/.jjconflict-side-1/apps/client/src/views/ProjectDashboard.vue new file mode 100644 index 0000000000..d411ed7e78 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/ProjectDashboard.vue @@ -0,0 +1,344 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/ServicesHealth.vue b/.jjconflict-side-1/apps/client/src/views/ServicesHealth.vue new file mode 100644 index 0000000000..ca9f021061 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/ServicesHealth.vue @@ -0,0 +1,107 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/admin/AdminCluster.vue b/.jjconflict-side-1/apps/client/src/views/admin/AdminCluster.vue new file mode 100644 index 0000000000..fe87036105 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/admin/AdminCluster.vue @@ -0,0 +1,101 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/admin/AdminRoles.vue b/.jjconflict-side-1/apps/client/src/views/admin/AdminRoles.vue new file mode 100644 index 0000000000..3c4357c340 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/admin/AdminRoles.vue @@ -0,0 +1,127 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/admin/AdminServiceChain.vue b/.jjconflict-side-1/apps/client/src/views/admin/AdminServiceChain.vue new file mode 100644 index 0000000000..cee1e4baa2 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/admin/AdminServiceChain.vue @@ -0,0 +1,42 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/admin/AdminTokens.vue b/.jjconflict-side-1/apps/client/src/views/admin/AdminTokens.vue new file mode 100644 index 0000000000..e4cb9e9e6c --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/admin/AdminTokens.vue @@ -0,0 +1,147 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/admin/DsoAdmin.vue b/.jjconflict-side-1/apps/client/src/views/admin/DsoAdmin.vue new file mode 100644 index 0000000000..415c8d7276 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/admin/DsoAdmin.vue @@ -0,0 +1,14 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/admin/ListClusters.vue b/.jjconflict-side-1/apps/client/src/views/admin/ListClusters.vue new file mode 100644 index 0000000000..7074ceee8a --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/admin/ListClusters.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/views/admin/ListLogs.vue b/.jjconflict-side-1/apps/client/src/views/admin/ListLogs.vue new file mode 100644 index 0000000000..2fab771995 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/admin/ListLogs.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/views/admin/ListPlugins.vue b/.jjconflict-side-1/apps/client/src/views/admin/ListPlugins.vue new file mode 100644 index 0000000000..250e3938ec --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/admin/ListPlugins.vue @@ -0,0 +1,140 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/admin/ListProjects.vue b/.jjconflict-side-1/apps/client/src/views/admin/ListProjects.vue new file mode 100644 index 0000000000..171f4ea557 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/admin/ListProjects.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/views/admin/ListServiceChains.vue b/.jjconflict-side-1/apps/client/src/views/admin/ListServiceChains.vue new file mode 100644 index 0000000000..2894d83443 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/admin/ListServiceChains.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/src/views/admin/ListStages.vue b/.jjconflict-side-1/apps/client/src/views/admin/ListStages.vue new file mode 100644 index 0000000000..060aec61fb --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/admin/ListStages.vue @@ -0,0 +1,169 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/admin/ListUser.vue b/.jjconflict-side-1/apps/client/src/views/admin/ListUser.vue new file mode 100644 index 0000000000..6fd88db2d3 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/admin/ListUser.vue @@ -0,0 +1,261 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/admin/ListZones.vue b/.jjconflict-side-1/apps/client/src/views/admin/ListZones.vue new file mode 100644 index 0000000000..18698cadfa --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/admin/ListZones.vue @@ -0,0 +1,173 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/admin/SystemSettings.vue b/.jjconflict-side-1/apps/client/src/views/admin/SystemSettings.vue new file mode 100644 index 0000000000..8054ef29f5 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/admin/SystemSettings.vue @@ -0,0 +1,29 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/profile/PersonalAccessTokens.vue b/.jjconflict-side-1/apps/client/src/views/profile/PersonalAccessTokens.vue new file mode 100644 index 0000000000..f97b54cb59 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/profile/PersonalAccessTokens.vue @@ -0,0 +1,128 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/profile/ProfileWrapper.vue b/.jjconflict-side-1/apps/client/src/views/profile/ProfileWrapper.vue new file mode 100644 index 0000000000..d31d761dfd --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/profile/ProfileWrapper.vue @@ -0,0 +1,7 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/profile/UserInfo.vue b/.jjconflict-side-1/apps/client/src/views/profile/UserInfo.vue new file mode 100644 index 0000000000..c3b9e4a1b5 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/profile/UserInfo.vue @@ -0,0 +1,66 @@ + + + diff --git a/.jjconflict-side-1/apps/client/src/views/projects/DsoProjects.vue b/.jjconflict-side-1/apps/client/src/views/projects/DsoProjects.vue new file mode 100644 index 0000000000..d4174d1337 --- /dev/null +++ b/.jjconflict-side-1/apps/client/src/views/projects/DsoProjects.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/.jjconflict-side-1/apps/client/tsconfig.eslint.json b/.jjconflict-side-1/apps/client/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-1/apps/client/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-1/apps/client/tsconfig.json b/.jjconflict-side-1/apps/client/tsconfig.json new file mode 100644 index 0000000000..7f8a3442bf --- /dev/null +++ b/.jjconflict-side-1/apps/client/tsconfig.json @@ -0,0 +1,62 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + + "jsx": "preserve", + "lib": ["DOM", "ESNext"], + // Base + "baseUrl": "./", + "rootDir": "./src", + "module": "Preserve", + "moduleResolution": "Bundler", + "paths": { + "@/*": [ + "src/*" + ] + }, + "resolveJsonModule": true, + "types": [ + "vitest", + "vite/client", + "vite-plugin-pwa/vue", + "vite-plugin-pwa/client" + ], + "allowImportingTsExtensions": false, + "allowJs": true, + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useUnknownInCatchVariables": true, + "declaration": true, + "declarationDir": "./types", + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "./dist", + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "vueCompilerOptions": {}, + "include": [ + "./src/**/*.ts", + "./src/**/*.js", + "./src/**/*.vue", + "./src/**/*.d.ts" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-1/apps/client/uno.config.ts b/.jjconflict-side-1/apps/client/uno.config.ts new file mode 100644 index 0000000000..6035f5b009 --- /dev/null +++ b/.jjconflict-side-1/apps/client/uno.config.ts @@ -0,0 +1,23 @@ +import { + defineConfig, + presetIcons, + presetUno, + transformerDirectives, + transformerVariantGroup, +} from 'unocss' + +export default defineConfig({ + presets: [ + presetUno(), + presetIcons({ + scale: 1.2, + extraProperties: { + display: 'inline-block', + }, + }), + ], + transformers: [ + transformerDirectives(), + transformerVariantGroup(), + ], +}) diff --git a/.jjconflict-side-1/apps/client/vite.config.ts b/.jjconflict-side-1/apps/client/vite.config.ts new file mode 100644 index 0000000000..817f1306c6 --- /dev/null +++ b/.jjconflict-side-1/apps/client/vite.config.ts @@ -0,0 +1,145 @@ +import fs from 'node:fs' +import { fileURLToPath, URL } from 'node:url' +import { + vueDsfrAutoimportPreset, + vueDsfrComponentResolver, +} from '@gouvminint/vue-dsfr/meta' +import vue from '@vitejs/plugin-vue' +import UnoCSS from 'unocss/vite' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { defineConfig } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) + process.loadEnvFile('.env.integ') + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) + process.loadEnvFile('.env') + +const serverHost = process.env.SERVER_HOST ?? 'localhost' +const serverPort = process.env.SERVER_PORT ?? 4000 +const clientPort = process.env.CLIENT_PORT ?? 8080 + +const define = process.env.NODE_ENV === 'production' + ? { 'process.env': { APP_VERSION: process.env.APP_VERSION } } + : { 'process.env': process.env } + +// https://vitejs.dev/config/ +export default defineConfig({ + server: { + host: '0.0.0.0', + port: Number(clientPort) || 8080, + proxy: { + '^/api': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + '^/swagger-ui-server': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + '^/swagger-ui-server-nestjs': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + }, + }, + define, + plugins: [ + vue(), + AutoImport({ + imports: [ + // @ts-ignore + 'vue', + // @ts-ignore + 'vue-router', + // @ts-ignore + 'pinia', + // @ts-ignore + vueDsfrAutoimportPreset, + ], + vueTemplate: true, + dts: './src/auto-imports.d.ts', + eslintrc: { + enabled: true, + filepath: './.eslintrc-auto-import.json', + globalsPropValue: true, + }, + }), + Components({ + extensions: ['vue'], + dirs: [ + './src/components', + './src/views', + ], + include: [/\.vue$/, /\.vue\?vue/], + dts: './src/components.d.ts', + resolvers: [ + vueDsfrComponentResolver, + ], + }), + UnoCSS({ + extendTheme: (theme) => { + return { + ...theme, + breakpoints: { + ...theme.breakpoints, + dsfrmenu: '992px', + }, + } + }, + }), + VitePWA({ + registerType: 'prompt', // autoUpdate + // disable: true, + // selfDestroying: true, + workbox: { + maximumFileSizeToCacheInBytes: 5_000_000, + cleanupOutdatedCaches: true, + navigateFallbackDenylist: [ + /^\/api/, + /^\/swagger-ui-server/, + /^\/swagger-ui-server-nestjs/, + ], + }, + devOptions: { + enabled: false, + }, + manifest: { + name: 'Console Cloud Pi Native', + short_name: 'CPiN', + description: 'Une console web pour les controler tous', + start_url: '/', + display: 'standalone', + background_color: '#ffffff', + theme_color: '#42b883', + icons: [ + { + src: '/favicon.ico', + sizes: '16x16', + type: 'image/png', + }, + ], + }, + }), + ], + base: '/', + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + dedupe: ['vue'], + }, + build: { + target: 'ESNext', + }, + optimizeDeps: { + include: [ + 'jszip', + ], + }, +}) diff --git a/.jjconflict-side-1/apps/client/vitest.config.ts b/.jjconflict-side-1/apps/client/vitest.config.ts new file mode 100644 index 0000000000..7aa0e9fda4 --- /dev/null +++ b/.jjconflict-side-1/apps/client/vitest.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import { mergeConfig } from 'vite' +import { configDefaults, defineConfig } from 'vitest/config' +import viteConfig from './vite.config' + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + reporters: ['default', 'hanging-process'], + environment: 'jsdom', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: [ + '**/types', + '**/mocks', + '**/*.spec.ts', + '**/*.d.ts', + '**/*.vue', + 'src/main.ts', + 'src/icons.ts', + 'src/router/index.ts', + ], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + root: fileURLToPath(new URL('./', import.meta.url)), + pool: 'forks', + }, + }), +) diff --git a/.jjconflict-side-1/apps/nginx-strangler/Dockerfile b/.jjconflict-side-1/apps/nginx-strangler/Dockerfile new file mode 100644 index 0000000000..ec2c6753fd --- /dev/null +++ b/.jjconflict-side-1/apps/nginx-strangler/Dockerfile @@ -0,0 +1,32 @@ +FROM nginxinc/nginx-unprivileged:1.31-alpine AS prod + +USER root + +# On supprime la config par défaut +RUN rm /etc/nginx/conf.d/default.conf + +# Config principale +COPY apps/nginx-strangler/nginx.conf /etc/nginx/nginx.conf + +# Template de routing (sera substitué au démarrage) +COPY apps/nginx-strangler/conf.d/routing.conf /etc/nginx/templates/routing.conf.template + +# Donner à l'utilisateur nginx et au groupe root les droits d'écriture sur conf.d/ (pour subst au démarrage) +# et sur les répertoires de logs/pid nécessaires en mode non-root +# OpenShift exécute les conteneurs avec un UID arbitraire appartenant au groupe root. +RUN chown -R nginx:root \ + /etc/nginx/nginx.conf \ + /etc/nginx/conf.d \ + /etc/nginx/templates \ + /etc/nginx/mime.types \ + /var/cache/nginx \ + /var/log/nginx \ + && touch /var/run/nginx.pid \ + && chown nginx:root /var/run/nginx.pid && chmod g+w /var/run/nginx.pid + +USER nginx + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/health || exit 1 + +EXPOSE 8080 diff --git a/.jjconflict-side-1/apps/nginx-strangler/README.md b/.jjconflict-side-1/apps/nginx-strangler/README.md new file mode 100644 index 0000000000..f7bd5af63e --- /dev/null +++ b/.jjconflict-side-1/apps/nginx-strangler/README.md @@ -0,0 +1,120 @@ +# nginx-strangler + +Reverse proxy dédié à la migration progressive du backend `server` +(Fastify/legacy) vers `server-nestjs` (NestJS), selon le [Strangler FigPattern](https://martinfowler.com/bliki/StranglerFigApplication.html). + +Ticket de référence : [#1885](https://github.com/cloud-pi-native/console/issues/1885) + +--- + +## Architecture + +``` +[Client nginx :8080] + │ proxy_pass → nginx-strangler:8080 + ▼ +[nginx-strangler :8080] + ├── /api/[routes migrées] ──→ server-nestjs:3001 + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +--- + +## Variables d'environnement + +| Variable | Description | Exemple Docker | Exemple local | +|---|---|---|---| +| `LEGACY_UPSTREAM` | Adresse du backend Fastify legacy | `server:8080` | `127.0.0.1:4000` | +| `NESTJS_UPSTREAM` | Adresse du backend NestJS | `server-nestjs:3001` | `127.0.0.1:3001` | + +Ces variables sont substituées dans `conf.d/routing.conf` via `envsubst` au démarrage du conteneur. + +--- + +## Basculer une route vers NestJS + +Éditer `conf.d/routing.conf` et ajouter un bloc `location` **avant** le bloc fallback `/api/`, en respectant le format suivant : + +```nginx +# [Vague/Itération X - nom-du-module] AAAA-MM-JJ +location = /api/v1/ma-route { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Puis recharger nginx sans downtime : + +```bash +docker compose exec nginx-strangler nginx -t # valider la config +docker compose exec nginx-strangler nginx -s reload +``` + +### Modificateurs de location + +| Modificateur | Usage | +|---|---| +| `=` | Route exacte (ex: `GET /api/v1/system/health`) | +| `^~` | Préfixe prioritaire (ex: tout un module `/api/v1/users/`) | +| *(aucun)* | Préfixe standard | + +--- + +## Rollback d'une route + +En cas de problème sur une route basculée : + +```bash +# 1. Dans conf.d/routing.conf, commenter le bloc concerné : +# # [Vague X - module] AAAA-MM-JJ ← ROLLBACK AAAA-MM-JJ +# # location = /api/v1/xxx { ... } + +# 2. Recharger nginx sans downtime +docker compose exec nginx-strangler nginx -s reload + +# 3. Vérifier les logs +docker compose logs -f nginx-strangler | grep "/api/v1/xxx" +``` + +--- + +## Lancer en dev local (pnpm dev) + +Quand `server` et `server-nestjs` tournent nativement (hors Docker), le +`nginx-strangler` reste lui en Docker avec des upstreams pointant vers les +ports "locaux" des deux services: + +```bash +# Dans docker/docker-compose.local.yml, le service nginx-strangler utilise : +# LEGACY_UPSTREAM=127.0.0.1:4000 +# NESTJS_UPSTREAM=127.0.0.1:3001 + +docker compose -f docker/docker-compose.local.yml up nginx-strangler +``` + +--- + +## Vérification de la configuration + +```bash +# Tester la syntaxe (depuis l'hôte) +docker compose exec nginx-strangler nginx -t + +# Voir la config résolue (après substitution envsubst) +docker compose exec nginx-strangler cat /etc/nginx/conf.d/routing.conf + +# Logs en temps réel +docker compose logs -f nginx-strangler +``` + +--- + +## Liens + +- Plan de mise en place : [`apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md`](../server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md) +- Stratégie de modularisation : [`apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md`](../server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md) +- Cartographie des modules : [`apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md`](../server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) diff --git a/.jjconflict-side-1/apps/nginx-strangler/conf.d/routing.conf b/.jjconflict-side-1/apps/nginx-strangler/conf.d/routing.conf new file mode 100644 index 0000000000..25bf6f7192 --- /dev/null +++ b/.jjconflict-side-1/apps/nginx-strangler/conf.d/routing.conf @@ -0,0 +1,106 @@ +# ============================================================================= +# nginx-strangler — Configuration de routage "as-code" +# +# Ce fichier est le cœur du pattern Strangler Fig. +# Il route progressivement les routes de server (legacy) vers server-nestjs. +# +# Variables d'environnement substituées au démarrage (via envsubst) : +# LEGACY_UPSTREAM — adresse du backend Fastify legacy (ex: server:8080) +# NESTJS_UPSTREAM — adresse du backend NestJS (ex: server-nestjs:3001) +# +# Format d'une bascule : +# # [Vague X - module] AAAA-MM-JJ +# location [= | ^~ | ~] /api/v1/xxx { +# proxy_pass http://server-nestjs; +# include /etc/nginx/conf.d/_proxy_headers.conf; +# } +# +# Procédure de rollback d'une route : +# 1. Commenter le bloc location concerné (ajouter "# ROLLBACK AAAA-MM-JJ") +# 2. docker compose exec nginx-strangler nginx -s reload +# ============================================================================= + +upstream server-legacy { + server ${LEGACY_UPSTREAM}; +} + +upstream server-nestjs { + server ${NESTJS_UPSTREAM}; +} + +server { + listen 8080; + + # Taille des headers (nécessaire pour les tokens Keycloak) + large_client_header_buffers 4 32k; + + # Healthcheck de nginx-strangler lui-même + location = /health { + access_log off; + add_header 'Content-Type' 'application/json'; + return 200 '{"status":"UP"}'; + } + + # ── Swagger UI ──────────────────────────────────────────────────────────────── + location /swagger-ui-server-nestjs { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /swagger-ui-server { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── Routes migrées vers NestJS ──────────────────────────────────────────────── + location /api/v1/service-chains { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ~ ^/api/v1/projects/[^/]+/deployments(.*)$ { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Les routes /api/v2 n'existent que sur server-nestjs + location /api/v2/ { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── Routes par défaut (pour l'instant dans le legacy) ───────────────────────── + location /api/ { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_redirect off; + } + +} diff --git a/.jjconflict-side-1/apps/nginx-strangler/conf.d/routing.local.conf.example b/.jjconflict-side-1/apps/nginx-strangler/conf.d/routing.local.conf.example new file mode 100644 index 0000000000..b8f62e2e8b --- /dev/null +++ b/.jjconflict-side-1/apps/nginx-strangler/conf.d/routing.local.conf.example @@ -0,0 +1,64 @@ +# ============================================================================= +# Exemple de configuration nginx-strangler pour le développement local +# (pnpm dev — server et server-nestjs tournent nativement, hors Docker) +# +# Ce fichier est fourni à titre documentaire. +# En pratique, le docker-compose.local.yml injecte directement les variables +# d'environnement LEGACY_UPSTREAM et NESTJS_UPSTREAM dans le conteneur +# nginx-strangler via envsubst, sans qu'il soit nécessaire de modifier ce fichier. +# +# Pour lancer le nginx-strangler en mode local : +# +# docker compose -f docker/docker-compose.local.yml up nginx-strangler +# +# Les valeurs par défaut dans docker-compose.local.yml : +# LEGACY_UPSTREAM=host.docker.internal:4000 (server Fastify natif) +# NESTJS_UPSTREAM=host.docker.internal:3001 (server-nestjs natif) +# +# Si vos servers écoutent sur des ports différents, surcharger les variables : +# +# LEGACY_UPSTREAM=host.docker.internal:4000 \ +# NESTJS_UPSTREAM=host.docker.internal:3001 \ +# docker compose -f docker/docker-compose.local.yml up nginx-strangler +# +# Le nginx-strangler est alors accessible sur http://localhost:8082 +# (le client Vue.js en dev natif doit pointer son proxy /api vers localhost:8082) +# ============================================================================= + +upstream server-legacy { + server host.docker.internal:4000; +} + +upstream server-nestjs { + server host.docker.internal:3001; +} + +server { + listen 8080; + + # ── Routes migrées vers NestJS ──────────────────────────────────────────── + # (vide au démarrage — toutes les routes sont en fallback sur server-legacy) + + # ── Fallback : tout le reste vers le server legacy ──────────────────────── + location /api/ { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_redirect off; + } + + location /swagger-ui { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/.jjconflict-side-1/apps/nginx-strangler/nginx.conf b/.jjconflict-side-1/apps/nginx-strangler/nginx.conf new file mode 100644 index 0000000000..657e2100dd --- /dev/null +++ b/.jjconflict-side-1/apps/nginx-strangler/nginx.conf @@ -0,0 +1,28 @@ +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + 'upstream=$upstream_addr rt=$request_time'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + + # Taille des headers (nécessaire pour les tokens Keycloak) + large_client_header_buffers 4 32k; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/.jjconflict-side-1/apps/server-nestjs/.env-example b/.jjconflict-side-1/apps/server-nestjs/.env-example new file mode 100644 index 0000000000..1da8adba5c --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/.env-example @@ -0,0 +1,61 @@ +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Activation des plugins externes (true | false). Actif par défaut en développement. +USE_PLUGINS=true +# HOME=/home/node +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# Identifiant de l'administrateur Keycloak sur le royaume KEYCLOAK_REALM +KEYCLOAK_ADMIN=admin +# Mot de passe de l'administrateur Keycloak sur le royaume KEYCLOAK_REALM +KEYCLOAK_ADMIN_PASSWORD=admin +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 +# Interface d'écoute du serveur backend +SERVER_HOST=localhost +# Port d'écoute du serveur backend +SERVER_PORT=3001 +# URL de connexion à la base de données PostgreSQL (format Prisma) +DB_URL=postgresql://admin:admin@localhost:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +OPENCDS_URL= +# URL interne de l'API OpenCDS +OPENCDS_INTERNAL_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_GITLAB=false +USE_NEXUS=false +USE_SONARQUBE=false +USE_REGISTRY=false +USE_HARBOR=false +USE_OPENCDS=false +USE_ARGOCD=false diff --git a/.jjconflict-side-1/apps/server-nestjs/.env.docker-example b/.jjconflict-side-1/apps/server-nestjs/.env.docker-example new file mode 100644 index 0000000000..d0cf596b3a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/.env.docker-example @@ -0,0 +1,64 @@ +# Indique que l'application tourne dans un conteneur Docker +DOCKER=true +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Activation des plugins externes (true | false). +USE_PLUGINS=fasle +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine du Keycloak (nom du service Docker Compose + port interne) +KEYCLOAK_DOMAIN=keycloak:8080 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http en Docker local) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# Identifiant de l'administrateur Keycloak +KEYCLOAK_ADMIN=admin +# Mot de passe de l'administrateur Keycloak +KEYCLOAK_ADMIN_PASSWORD=admin +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 +# Interface d'écoute du serveur backend +SERVER_HOST=0.0.0.0 +# Port d'écoute du serveur dans le réseau Docker +SERVER_PORT=3001 +# URL de connexion PostgreSQL (utilise le nom de service Docker 'postgres') +DB_URL=postgresql://admin:admin@postgres:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +# Par défaut configuré sur le conteneur Mockoon (à changer sur envs non-PAX) +OPENCDS_URL= +# URL interne de l'API OpenCDS +OPENCDS_INTERNAL_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_GITLAB=false +USE_VAULT=false +USE_NEXUS=false +USE_SONARQUBE=false +USE_REGISTRY=false +USE_HARBOR=false +USE_OPENCDS=false +USE_ARGOCD=false diff --git a/.jjconflict-side-1/apps/server-nestjs/.env.integ-example b/.jjconflict-side-1/apps/server-nestjs/.env.integ-example new file mode 100644 index 0000000000..f0bf743326 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/.env.integ-example @@ -0,0 +1,130 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Désactive l'initialisation des données de démo (l'intégration a ses propres données) +DEV_SETUP="false" +# Active le mode intégration (charge ce fichier en surcharge) +INTEGRATION=true +# Activation des plugins externes (true | false). Désactivé en mode intégration. +USE_PLUGINS=false + +# --- Keycloak --- +# Protocole (généralement https en intégration) +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= +# Secret du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_SECRET= +# Domaine du Keycloak d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak d'intégration +KEYCLOAK_REALM= + +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 + +# --- ArgoCD --- +# Namespace Kubernetes dans lequel ArgoCD est déployé +ARGO_NAMESPACE= +# URL de l'API ArgoCD +ARGOCD_URL= +# URL interne de l'API ArgoCD +ARGOCD_INTERNAL_URL= + +# --- GitLab --- +# Token d'accès personnel GitLab (scope api) +GITLAB_TOKEN= +# URL de l'instance GitLab +GITLAB_URL= +# URL interne de l'API Gitlab +GITLAB_INTERNAL_URL= + +# --- Harbor --- +# Nom d'utilisateur admin Harbor +HARBOR_ADMIN= +# Mot de passe admin Harbor +HARBOR_ADMIN_PASSWORD= +# URL de l'instance Harbor +HARBOR_URL= +# URL interne de l'API Harbor +HARBOR_INTERNAL_URL= + +# --- Keycloak (admin) --- +# Utilisateur admin Keycloak pour la gestion des royaumes/clients +KEYCLOAK_ADMIN= +# Mot de passe admin Keycloak +KEYCLOAK_ADMIN_PASSWORD= +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL complète de Keycloak (peut différer de KEYCLOAK_DOMAIN) +KEYCLOAK_URL= + +# --- Nexus --- +# Nom d'utilisateur admin Nexus +NEXUS_ADMIN= +# Mot de passe admin Nexus +NEXUS_ADMIN_PASSWORD= +# URL de l'instance Nexus +NEXUS_URL= +# URL interne de l'API Nexus +NEXUS_INTERNAL_URL= + +# --- Divers --- +# Répertoire racine des projets dans Gitlab +PROJECTS_ROOT_DIR= + +# --- SonarQube --- +# Token d'API SonarQube +SONAR_API_TOKEN= +# URL de l'instance SonarQube +SONARQUBE_URL= +# URL interne de l'API SonarQube +SONARQUBE_INTERNAL_URL= + +# --- Vault --- +# Token d'authentification Vault +VAULT_TOKEN= +# URL de l'instance Vault +VAULT_URL= +# URL interne de l'API Vault +VAULT_INTERNAL_URL= + +# --- Kubeconfig --- +# Chemin vers le kubeconfig sur la machine hôte (monté en volume dans Docker) +KUBECONFIG_HOST_PATH= +# Chemin vers le kubeconfig à l'intérieur du conteneur +KUBECONFIG_PATH=$HOME/.kube/config +# Contexte Kubernetes à utiliser +KUBECONFIG_CTX= + +# Répertoire contenant les plugins externes (monté en volume dans Docker) +EXTERNAL_PLUGINS_DIR_HOST_PATH=/path/to/plugins + +# Informations pour le plugin Observabilité +GRAFANA_URL= +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 + +# --- Configuration proxy (si nécessaire pour accéder aux services externes) --- +# HTTP_PROXY= +# HTTPS_PROXY= +# NO_PROXY= + +# Configuration OpenCDS +OPENCDS_URL= +OPENCDS_INTERNAL_URL= +OPENCDS_API_TOKEN=token +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false + +# --- Activation des plugins externes --- +# Désactivés par défaut en intégration : renseignez les variables et passez à "true" pour activer. +USE_KEYCLOAK=true +USE_GITLAB=true +USE_VAULT=true +USE_NEXUS=true +USE_SONARQUBE=true +USE_REGISTRY=true +USE_HARBOR=true +USE_OPENCDS=true +USE_ARGOCD=true diff --git a/.jjconflict-side-1/apps/server-nestjs/.gitignore b/.jjconflict-side-1/apps/server-nestjs/.gitignore new file mode 100644 index 0000000000..4b56acfbeb --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/.gitignore @@ -0,0 +1,56 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/.jjconflict-side-1/apps/server-nestjs/.prettierrc b/.jjconflict-side-1/apps/server-nestjs/.prettierrc new file mode 100644 index 0000000000..8deec5d940 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/.prettierrc @@ -0,0 +1,12 @@ +{ + "plugins": ["@trivago/prettier-plugin-sort-imports"], + "importOrderParserPlugins": ["typescript", "decorators"], + "importOrder": ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true, + "printWidth": 80, + "semi": true, + "singleQuote": true, + "tabWidth": 4, + "trailingComma": "all" +} diff --git a/.jjconflict-side-1/apps/server-nestjs/Dockerfile b/.jjconflict-side-1/apps/server-nestjs/Dockerfile new file mode 100644 index 0000000000..6eb7f5f180 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/Dockerfile @@ -0,0 +1,106 @@ +# Base / dev stage ---------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Activer pnpm via corepack (version définie dans package.json "packageManager") +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +# Copier les manifestes workspace (nécessaires pour que pnpm résolve les dépendances) +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ + +# Manifestes des packages workspace dont server-nestjs dépend +COPY --chown=node:root apps/server-nestjs/package.json ./apps/server-nestjs/package.json + +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +COPY --chown=node:root plugins/argocd/package.json ./plugins/argocd/package.json +COPY --chown=node:root plugins/gitlab/package.json ./plugins/gitlab/package.json +COPY --chown=node:root plugins/harbor/package.json ./plugins/harbor/package.json +COPY --chown=node:root plugins/keycloak/package.json ./plugins/keycloak/package.json +COPY --chown=node:root plugins/nexus/package.json ./plugins/nexus/package.json +COPY --chown=node:root plugins/sonarqube/package.json ./plugins/sonarqube/package.json +COPY --chown=node:root plugins/vault/package.json ./plugins/vault/package.json + +# Installer toutes les dépendances afin de permettre le build des libs partagées +RUN pnpm install --frozen-lockfile + +# Copier les sources des packages workspace +COPY --chown=node:root plugins/ ./plugins/ +COPY --chown=node:root packages/ ./packages/ +COPY --chown=node:root apps/server-nestjs/ ./apps/server-nestjs/ + +# Build des libs partagées (génère dist/ et types/) +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +# Build hooks (génère dist/ et types/ nécessaires aux imports) +RUN pnpm --filter @cpn-console/hooks run build + +# Réinjecter les libs buildées dans node_modules (injectWorkspacePackages copie au moment de l'install) +RUN pnpm --filter @cpn-console/server-nestjs install --frozen-lockfile + +# Générer le client Prisma (schéma multi-fichiers : pointer sur le dossier) +RUN pnpm --filter @cpn-console/server-nestjs exec prisma generate \ + --schema=src/prisma/schema + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/api/v1/healthz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" + +ENTRYPOINT ["pnpm", "--filter", "server-nestjs", "run"] +CMD ["start:dev"] + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +# Build shared (génère dist/ et types/) +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +# Build hooks (génère dist/ et types/ nécessaires aux imports) +RUN pnpm --filter @cpn-console/hooks run build + +# Réinjecter shared buildé dans node_modules (injectWorkspacePackages copie au moment de l'install) +RUN pnpm --filter @cpn-console/server-nestjs install --frozen-lockfile + +# Build server-nestjs +RUN pnpm --filter @cpn-console/server-nestjs run build + +# Export @cpn-console/server-nestjs to target build directory +RUN pnpm --filter @cpn-console/server-nestjs --prod deploy build + +# Prod stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS prod + +ARG APP_VERSION +ENV APP_VERSION=$APP_VERSION + +VOLUME ["/plugins"] +WORKDIR /app + +RUN mkdir -p /home/node/logs && chmod 770 -R /home/node/logs \ + && mkdir -p /home/node/.npm && chmod 770 -R /home/node/.npm \ + && chown node:root /app + +# Copier l'artefact de build et les node_modules de production +COPY --chown=node:root --from=build /app/build . +COPY --chown=node:root --from=build /app/apps/server-nestjs/dist ./dist +# COPY --chown=node:root --from=build /app/build/node_modules ./node_modules + +# Regénérer le client Prisma dans l'image de production +RUN npm run db:generate + +USER node +EXPOSE 3001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/api/v1/healthz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" + +ENTRYPOINT ["node", "dist/src/main"] diff --git a/.jjconflict-side-1/apps/server-nestjs/README.md b/.jjconflict-side-1/apps/server-nestjs/README.md new file mode 100644 index 0000000000..62673164cc --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/README.md @@ -0,0 +1,543 @@ +# À propos + +Ce dossier contient une nouvelle version de `server`, basée sur NestJS. + +## Objectifs + +Rappel : L'objectif principal de cette démarche est de préparer l'extraction de +chaque "plugin" en tant que module NestJS incluant à la fois la partie Front et +la partie Backend pour une meilleure composition de la Console. + +On va profiter de cette vision pour passer, côté `server` d'un "Back For +Front", à un backend qui va gérer à la fois le Front et le Back de différentes +fonctionnalités (appelée "Plugins"), et ce parfois de manière dynamique (mais +déjà en statique, ce sera pas mal 😅). + +## Conséquences pour apps/server + +On va donc passer de ça : + +```mermaid +flowchart TD + + %% --- Top-level --- + NestJS["Nest.js"] + FutursModules["Futurs modules
Nest.js correctement
découpés..."] + MainModule["MainModule"] + + NestJS --> MainModule + MainModule --> CPinModule + MainModule -.-> FutursModules + + CPinModule["CPinModule
(Contient TOUT le code actuel de 'server')"] + + %% --- Core services --- + ConnectionService["ConnectionService"] + LoggerService["LoggerService"] + FastifyService["FastifyService"] + ServerService["ServerService"] + AppService["AppService"] + ResourceService["ResourceService"] + PrepareAppService["PrepareAppService"] + InitDbService["InitDbService"] + PluginService["PluginService"] + + %% --- Router services --- + AdminRoleRouterService["AdminRoleRouterService"] + AdminTokenRouterService["AdminTokenRouterService"] + ClusterRouterService["ClusterRouterService"] + OtherRouterService["...RouterService"] + + %% --- External services --- + Gitlab["Gitlab"] + ArgoCD["ArgoCD"] + Dots["..."] + + %% --- CPinModule connections --- + CPinModule --> ConnectionService + CPinModule --> AppService + CPinModule --> LoggerService + CPinModule --> FastifyService + CPinModule --> ServerService + CPinModule --> ResourceService + CPinModule --> PrepareAppService + + %% --- AppService central connections --- + ConnectionService --> AppService + LoggerService --> AppService + FastifyService --> AppService + ServerService --> AppService + + AppService --> ResourceService + AppService --> PrepareAppService + AppService --> ServerService + AppService --> FastifyService + AppService --> LoggerService + AppService --> ConnectionService + + %% --- ResourceService to routers --- + ResourceService --> AdminRoleRouterService + ResourceService --> AdminTokenRouterService + ResourceService --> ClusterRouterService + ResourceService --> OtherRouterService + + %% --- PrepareAppService --- + PrepareAppService --> InitDbService + PrepareAppService --> PluginService + PrepareAppService --> ServerService + + %% --- PluginService external interactions --- + PluginService --> Gitlab + PluginService --> ArgoCD + PluginService --> Dots + + %% --- Bounding box (visual grouping only) --- + subgraph CPinBlock[" "] + CPinModule + ConnectionService + LoggerService + FastifyService + ServerService + AppService + ResourceService + PrepareAppService + InitDbService + PluginService + AdminRoleRouterService + AdminTokenRouterService + ClusterRouterService + OtherRouterService + end +``` + +à ça : + +```mermaid +flowchart TD + + %% --- Top-level Nest module --- + NestJS["Point d'entrée de NestJS"] + MainModule["MainModule"] + CPinModule["CPinModule"] + FutursModules["Futurs modules
NestJS correctement
découpés..."] + + NestJS --> MainModule + MainModule --> CPinModule + MainModule -.-> FutursModules + + %% --- Layering for clarity --- + subgraph LayerInit["Initialisation de l'application"] + ApplicationInitializationService["ApplicationInitializationService"] + DatabaseInitializationService["DatabaseInitializationService"] + PluginManagementService["PluginManagementService"] + end + + subgraph LayerCore["Coeur de l'application"] + AppService["AppService"] + RouterService["RouterService"] + ServerService["ServerService"] + end + + subgraph LayerInfra["Couche Infrastructure"] + LoggerService["LoggerService"] + ConfigurationService["ConfigurationService"] + DatabaseService["DatabaseService"] + FastifyService["FastifyService"] + HTTPClientService["HTTPClientService"] + end + + OtherAPIService["APIs externes
(par ex. OpenCDS)"] + HTTPClientService --> OtherAPIService + + subgraph LayerBusiness["Modules métiers"] + subgraph AdminRole["Admin Roles"] + AdminRoleRouterService["AdminRoleRouterService"] + AdminRoleBusinessService["AdminRoleBusinessService"] + AdminRoleDTOService["AdminRoleDTOService"] + AdminRoleRouterService --> AdminRoleBusinessService + AdminRoleRouterService --> LoggerService + AdminRoleBusinessService --> AdminRoleDTOService + AdminRoleBusinessService --> LoggerService + AdminRoleDTOService --> LoggerService + AdminRoleDTOService --> DatabaseService + end + subgraph AdminToken["Admin Tokens"] + AdminTokenRouterService["AdminTokenRouterService"] + AdminTokenBusinessService["AdminTokenBusinessService"] + AdminTokenDTOService["AdminTokenDTOService"] + AdminTokenRouterService --> AdminTokenBusinessService + AdminTokenRouterService --> LoggerService + AdminTokenBusinessService --> AdminTokenDTOService + AdminTokenBusinessService --> LoggerService + AdminTokenDTOService --> DatabaseService + AdminTokenDTOService --> LoggerService + end + subgraph ServiceChain["Service chains"] + ServiceChainRouterService["ServiceChainRouterService"] + ServiceChainBusinessService["ServiceChainBusinessService"] + ServiceChainRouterService --> ServiceChainBusinessService + ServiceChainRouterService --> LoggerService + ServiceChainBusinessService --> HTTPClientService + ServiceChainBusinessService --> LoggerService + + end + subgraph Cluster["Clusters"] + ClusterRouterService["ClusterRouterService"] + ClusterBusinessService["ClusterBusinessService"] + ClusterDTOService["ClusterDTOService"] + ClusterRouterService --> ClusterBusinessService + ClusterRouterService --> LoggerService + ClusterBusinessService --> ClusterDTOService + ClusterBusinessService --> LoggerService + ClusterDTOService --> DatabaseService + ClusterDTOService --> LoggerService + end + OtherBusinessModules["...Other Business Modules"] + end + + RouterService --> AdminRoleRouterService + RouterService --> AdminTokenRouterService + RouterService --> ClusterRouterService + RouterService --> ServiceChainRouterService + RouterService --> OtherBusinessModules + RouterService --> LoggerService + + subgraph LayerPlugins["Plugins compatibles CPiN"] + Gitlab["Gitlab"] + ArgoCD["ArgoCD"] + Kubernetes["Kubernetes"] + Dots["..."] + end + + %% --- Module wiring --- + CPinModule --> ApplicationInitializationService + + %% Application initialization + ApplicationInitializationService --> LoggerService + ApplicationInitializationService --> ConfigurationService + ApplicationInitializationService --> FastifyService + ApplicationInitializationService --> AppService + ApplicationInitializationService --> DatabaseInitializationService + DatabaseInitializationService --> DatabaseService + DatabaseInitializationService --> LoggerService + ApplicationInitializationService --> PluginManagementService + ApplicationInitializationService --> LoggerService + + %% App Core internal flow + AppService --> RouterService + AppService --> ServerService + AppService --> LoggerService + ServerService --> LoggerService + + %% Plugin Management + PluginManagementService --> Gitlab + PluginManagementService --> ArgoCD + PluginManagementService --> Kubernetes + PluginManagementService --> Dots + PluginManagementService --> LoggerService + Gitlab --> LoggerService + ArgoCD --> LoggerService + Kubernetes --> LoggerService + Dots --> LoggerService +``` + +et enfin à ça : + +```mermaid +flowchart TD + + %% --- Top-level Nest module --- + NestJS["Point d'entrée de NestJS"] + MainModule["MainModule"] + + NestJS --> MainModule + + subgraph CoreModule["CoreModule"] + ApplicationInitializationService["ApplicationInitializationService"] + end + + subgraph InfrastructureModule["InfrastructureModule"] + LoggerService["LoggerService"] + ConfigurationService["ConfigurationService"] + DatabaseService["DatabaseService"] + HTTPClientService["HTTPClientService"] + end + + OtherAPIService["APIs externes
(par ex. OpenCDS)"] + HTTPClientService --> OtherAPIService + + subgraph MandatoryModules["Modules obligatoires CPiN"] + subgraph GitlabModule["GitlabModule"] + GitlabController["GitlabController"] + GitlabBusinessService["GitlabBusinessService"] + GitlabDTOService["GitlabDTOService"] + GitlabController --> GitlabBusinessService + GitlabController --> LoggerService + GitlabBusinessService --> GitlabDTOService + GitlabBusinessService --> LoggerService + GitlabDTOService --> DatabaseService + GitlabDTOService --> LoggerService + end + + subgraph ArgoCDModule["ArgoCDModule"] + ArgoCDController["ArgoCDController"] + ArgoCDBusinessService["ArgoCDBusinessService"] + ArgoCDDTOService["ArgoCDDTOService"] + ArgoCDController --> ArgoCDBusinessService + ArgoCDController --> LoggerService + ArgoCDBusinessService --> ArgoCDDTOService + ArgoCDBusinessService --> LoggerService + ArgoCDDTOService --> DatabaseService + ArgoCDDTOService --> LoggerService + end + + subgraph KubernetesModule["KubernetesModule"] + KubernetesController["KubernetesController"] + KubernetesBusinessService["KubernetesBusinessService"] + KubernetesDTOService["KubernetesDTOService"] + KubernetesController --> KubernetesBusinessService + KubernetesController --> LoggerService + KubernetesBusinessService --> KubernetesDTOService + KubernetesBusinessService --> LoggerService + KubernetesDTOService --> DatabaseService + KubernetesDTOService --> LoggerService + end + + subgraph AdminRoleModule["AdminRoleModule"] + AdminRoleController["AdminRoleController"] + AdminRoleBusinessService["AdminRoleBusinessService"] + AdminRoleDTOService["AdminRoleDTOService"] + AdminRoleController --> AdminRoleBusinessService + AdminRoleController --> LoggerService + AdminRoleBusinessService --> AdminRoleDTOService + AdminRoleBusinessService --> LoggerService + AdminRoleDTOService --> LoggerService + AdminRoleDTOService --> DatabaseService + end + + subgraph AdminTokenModule["AdminTokenModule"] + AdminTokenController["AdminTokenController"] + AdminTokenBusinessService["AdminTokenBusinessService"] + AdminTokenDTOService["AdminTokenDTOService"] + AdminTokenController --> AdminTokenBusinessService + AdminTokenController --> LoggerService + AdminTokenBusinessService --> AdminTokenDTOService + AdminTokenBusinessService --> LoggerService + AdminTokenDTOService --> DatabaseService + AdminTokenDTOService --> LoggerService + end + + subgraph ClusterModule["ClusterModule"] + ClusterController["ClusterController"] + ClusterBusinessService["ClusterBusinessService"] + ClusterDTOService["ClusterDTOService"] + ClusterController --> ClusterBusinessService + ClusterController --> LoggerService + ClusterBusinessService --> ClusterDTOService + ClusterBusinessService --> LoggerService + ClusterDTOService --> DatabaseService + ClusterDTOService --> LoggerService + end + OtherBusinessModules["...Other Business Modules"] + end + + CoreModule --> GitlabModule + CoreModule --> ArgoCDModule + CoreModule --> KubernetesModule + CoreModule --> AdminRoleModule + CoreModule --> AdminTokenModule + CoreModule --> ClusterModule + + subgraph ThirdPartyModules["Modules optionnels de CPin"] + subgraph ServiceChainModule["ServiceChainModule"] + ServiceChainController["ServiceChainController"] + ServiceChainBusinessService["ServiceChainBusinessService"] + ServiceChainController --> ServiceChainBusinessService + ServiceChainController --> LoggerService + ServiceChainBusinessService --> HTTPClientService + ServiceChainBusinessService --> LoggerService + end + end + + CoreModule --> ServiceChainModule + + %% --- Module wiring --- + MainModule --> ApplicationInitializationService + + %% Application initialization + ApplicationInitializationService --> LoggerService + ApplicationInitializationService --> ConfigurationService + ApplicationInitializationService --> LoggerService +``` + +## Architecture du module ServiceChain (Vague 1) + +Le module ServiceChain est le **premier module métier migré** depuis le legacy +vers server-nestjs via le pattern Strangler Fig. Il sert de proxy HTTP vers +l'API externe OpenCDS (gestion des chaînes de service réseau). + +### Flux de proxying OpenCDS + +``` +┌──────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ Client │────▶│ nginx-strangler │────▶│ server-nestjs │────▶│ API OpenCDS │ +│ (browser/ │ │ │ │ │ │ (externe) │ +│ script) │ │ /api/v1/ │ │ ServiceChain │ │ │ +│ │◀────│ service-chains │◀────│ Controller │◀────│ /requests │ +│ │ │ ──▶ nestjs │ │ ──▶ Service │ │ /validate │ +└──────────┘ │ │ │ ──▶ axios │ │ /flows │ + │ /api/* (reste) │ └──────────────────┘ └──────────────┘ + │ ──▶ legacy │ + └─────────────────┘ +``` + +### Structure du module + +``` +src/cpin-module/ +├── infrastructure/ +│ └── auth/ # Auth transverse (réutilisable) +│ ├── auth.module.ts # Module NestJS +│ ├── auth.service.ts # Lookup token SHA256 → Prisma +│ ├── admin-permission.guard.ts # Guard : vérifie x-dso-token + permissions +│ ├── admin-permission.decorator.ts @RequireAdminPermission('ListSystem') +│ └── admin-permission.guard.spec.ts +│ +└── service-chain/ # Module métier + ├── service-chain.module.ts # Imports: ConfigurationModule, AuthModule + ├── service-chain.controller.ts # 5 endpoints sous /api/v1/service-chains + ├── service-chain.service.ts # Proxy axios → OpenCDS + validation Zod + ├── service-chain.controller.spec.ts + └── service-chain.service.spec.ts +``` + +### Authentification + +Seule l'**auth par token** (`x-dso-token`) est supportée pour l'instant. +L'auth par session Keycloak sera ajoutée lors de la migration globale du +mécanisme de session vers NestJS (les cookies de session Fastify ne sont pas +déchiffrables par Express). + +``` + Requête HTTP + │ + ▼ + ┌──────────────────────┐ + │ AdminPermissionGuard │ + │ │ + │ 1. Lire header │ + │ x-dso-token │──── absent ──▶ 401 Unauthorized + │ │ + │ 2. SHA256(token) │ + │ → Prisma lookup │──── invalide ──▶ 401 Unauthorized + │ (PersonalAccess │ (expiré, révoqué, introuvable) + │ Token ou Admin │ + │ Token) │ + │ │ + │ 3. Vérifier perms │ + │ bitwise via │──── insuffisant ──▶ 403 Forbidden + │ AdminAuthorized │ + │ │ + │ 4. OK → continuer │──────────────────▶ Controller + └──────────────────────┘ +``` + +### Endpoints + +| Méthode | Route | Permission | Description | +|---------|-------|------------|-------------| +| `GET` | `/api/v1/service-chains` | `ListSystem` | Liste toutes les chaînes | +| `GET` | `/api/v1/service-chains/:id` | `ListSystem` | Détails d'une chaîne | +| `GET` | `/api/v1/service-chains/:id/flows` | `ListSystem` | Flux d'une chaîne | +| `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | Relancer une chaîne | +| `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | Valider une chaîne | + +### Différences avec le legacy + +- **403 systématique** : le legacy retournait `[]` sur `GET /` sans permission ; + le NestJS retourne 403 pour tous les endpoints sans permission. +- **Pas d'auth session** : seul `x-dso-token` fonctionne (le legacy supportait + aussi les sessions Keycloak). +- **Validation UUID** : les paramètres d'URL sont validés via `ParseUUIDPipe` + (400 si format invalide). + +### Variables d'environnement + +| Variable | Description | Défaut | +|----------|-------------|--------| +| `OPENCDS_URL` | URL de base de l'API OpenCDS | _(vide = désactivé)_ | +| `OPENCDS_API_TOKEN` | Token d'API OpenCDS (header `X-API-Key`) | — | +| `OPENCDS_API_TLS_REJECT_UNAUTHORIZED` | Vérification TLS (`true`/`false`) | `true` | + +--- + +Pour mettre à jour `old-server` (après avoir rebasé sur `origin/master`, par exemple) : + +```bash +server-nestjs/$ rm -rf src/cpin-module/old-server +server-nestjs/$ cp -r ../server src/cpin-module/old-server +server-nestjs/$ find src/cpin-module/old-server -type f -iname "*.ts" -exec sed -i -e "s#@/#@old-server/#g" {} \; +server-nestjs/$ find src/cpin-module/old-server -type f -iname "*.ts" -exec sed -i -e "s#\.[jt]s'#'#g" {} \; +``` + +## Fichiers à supprimer dans le futur + +Certains fichiers de `old-server` servait de "framework" pour le backend, et +vont donc être réécrits en tant que modules/services NestJS. On va garder la +liste ici, ce qui permettra de ne pas être constamment en conflit sur le code +de `server`. En attendant de pouvoir s'en débarrasser, et afin de s'assurer que +leur code n'est pas utilisé dans d'autres parties du backend, on va commenter +l'intégralité de ces fichiers (comme ça pas d'erreur d'import quand on les +supprimera). + +Voilà donc la liste des fichiers "dépréciés" : + +``` +old-server/src/app.ts -> Réécrit en AppService +old-server/src/connect.ts -> Réécrit en DatabaseService +old-server/src/init/db/* (à part dump.ts) -> Réécrit en DatabaseInitializationService +old-server/src/resources/**/router.ts -> Réécrit en **RouterService +old-server/src/resources/index.ts -> Réécrit en RouterService +old-server/src/server.ts -> Intégré à ApplicationInitializationService +old-server/src/utils/env.ts -> Réécrit en ConfigurationModule +old-server/src/utils/fastify.ts -> Réécrit en FastifyService +old-server/src/utils/keycloak-utils.ts -> Intégré dans AppService +old-server/src/utils/keycloak.ts -> Intégré dans AppService +old-server/src/utils/logger.ts -> Réécrit en LoggerModule +old-server/src/utils/plugin.ts -> Réécrit en PluginManagementService +``` + +## Prochaines itérations sur le sujet + +Tâches à réaliser par la suite dans d'autres itérations/tickets/etc. : + +- Migrer une fonctionnalité "verticale" complète (Route, Contract, Controller, + Business, Queries, Prisma schema) dans son propre module NestJS qui sera + importé dans `MainModule` (et pas dans `CpinModule` qui devra disparaître + à terme). +- Définir la liste de ces fonctionnalités verticales, et planifier l'extraction + de certaines d'entre elles (OpenCDS, typiquement, qui n'a rien à faire dans + le code de base de la Console) +- Migrer la base actuelle de NestJS de Jest vers Vitest et s'assurer que les + tests unitaires passent à nouveaux (attention, certains d'entre eux devront + être adaptés vu qu'on a commencé à réécrire du code au standard NestJS) +- Intérgrer `server-nestjs` dans tous les `docker compose` et les différents + scripts PNPM/Bash qui font tout le sel de nos process de dev +- Revoir les imports de données (le fameux `dump.ts`). C'était déjà une + mauvaise idée à l'époque, ça l'est encore plus aujourd'hui. On ferait mieux + d'utiliser un side-container pour ça +- Être capable de déployer `server-nestjs` en parallèle de `server`, avec + probablement un reverse proxy dédié à la migration des routes de l'ancien + vers le nouveau (à rediscuter plus concrètement) + +Les étapes d'après-après (quand on sera sereins sur la migration de `server` +vers `server-nestjs`) : + +- Tester les capacités de NestJS SSR (Server Side Rendering), notamment + vis-à-vis de nos composants VueJS. Il y aura probablement des sujets autour + de l'isolation du code VueJS des différentes fonctionnalités afin de + faciliter leur extraction de `client` vers `server` +- Implémenter une fonctionnalité du Front en tant que module NestJS SSR + (OpenCDS est un très bon cas d'étude grâce à son côté très isolé dans le + code). Idéalement ce code "Front" ajouté "côté Backend" devra être colocalisé + avec le code "Backend" correspondant. diff --git a/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md b/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md new file mode 100644 index 0000000000..1973831edb --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md @@ -0,0 +1,214 @@ +# Modularisation Backend : Node.js Server → NestJS + +## 🎯 Objectifs de la modularisation + +### Principes directeurs + +- La Console a évolué très rapidement. Depuis sa création jusqu'à récemment, la + priorité a été donnée à l'ajout de fonctionnalités, sans forcément avoir le + temps de consolider le socle technique. La modularisation de sa partie + serveur, à l'aide d'un framework robuste comme NestJS, permettra une + évolution saine de la plate-forme. +- Il y a une volonté de permettre davantage de souplesse dans la configuration + de la console (utilisation d'un plugin en remplacement d'un autre), afin de + permettre aux projets qui voudraient avoir "leur" console de la configurer + comme ils veulent +- Le code du serveur de la console est assez daté, dans ses paradigmes et ses + dépendances. Un nettoyage de ce code par une remise à plat de la cartographie + des interdépendances entre les différents modules permettra d'améliorer les + choses module par module + +### Objectifs techniques +- **Modularisation** : Découpage en modules métier cohérents et maintenables +- **Architecture propre** : Injection de dépendances, séparation des responsabilités +- **Scalabilité** : Base solide pour les évolutions futures +- **Qualité code** : Standards NestJS, TypeScript strict + +### Objectifs organisationnels +- **Continuité produit** : Zéro impact sur les développements en cours +- **Montée en compétence** : Équipe formée progressivement à NestJS +- **Risque maîtrisé** : Modularisation progressive sans interruption de service + +## 📊 Contexte technique + +### Stack actuelle +- **Backend** : Node.js (avec ts-rest/fastify et injection de dépendance manuelle des plugins) +- **ORM** : Prisma avec PostgreSQL +- **Auth** : OIDC avec Keycloak +- **Tests** : Playwright (E2E), Vitest (unitaires) +- **Déploiement** : Docker Compose +- **CI/CD** : Pipeline existante + +### Stack cible +- **Backend** : NestJS (TypeScript) +- **ORM** : Prisma (conservé, partagé) +- **Auth** : OIDC Keycloak +- **Tests** : Playwright + Vitest (conservés) +- **Déploiement** : Docker Compose (phase transition) → NestJS seul +- **CI/CD** : Adaptée pour build et le déploiement des 2 images en parallèle + +### Métriques du projet +- **Routes** : ~100 +- **Équipe** : ~5 développeurs +- **Timeline** : 3 mois (12 sprints) +- **Couverture tests E2E** : 33% → objectif 50% + +## 🏗️ Stratégie : Strangler Fig Pattern + +### Principe +Le nouveau backend `server-nestjs` coexiste avec l'ancien et "étouffe" progressivement `server` en reprenant route par route ses fonctionnalités. + +### Pourquoi cette approche ? +✅ **Zero downtime** : Service continu pendant la modularisation +✅ **Rollback facile** : Retour arrière immédiat via configuration nginx +✅ **Équipe non bloquée** : Développements possibles en parallèle +✅ **Validation progressive** : Tests en prod sur petit périmètre +✅ **Risque maîtrisé** : Pas de big bang + +### Architecture de transition + +``` +┌─────────────┐ +│ Nginx │ ← Point d'entrée unique (port 80) +│ (Reverse │ +│ Proxy) │ +└──────┬──────┘ + │ + ├─────────────┐ + │ │ + Migré Pas encore + │ │ + │ │ +┌──────▼──────┐ ┌────▼───────┐ +│ NestJS API │ │ Server API │ +│ (3001) │ │ (3000) │ +└──────┬──────┘ └────┬───────┘ + │ │ + └─────────────│ + │ + ┌──────▼─────┐ + │ PostgreSQL │ + │ (Prisma) │ + └────────────┘ +``` + +## 📋 Documents de référence + +Cette modularisation est documentée dans plusieurs fichiers : + +1. **[00-OVERVIEW.md](00-OVERVIEW.md)** *(ce document)* - Vue d'ensemble +3. **[01-MODULARISATION-STRATEGY.md](01-MODULARISATION-STRATEGY.md)** - Stratégie et méthodologie +7. **[MODULARISATION_STATUS.md](MODULARISATION_STATUS.md)** - Suivi en temps réel (fichier vivant) +8. **[MODULARISATION_MAP.md](MODULARISATION_MAP.md)** - Cartographie des modules (fichier vivant) + +## 🚀 Démarrage rapide + +### Pour l'équipe de développement +1. Lire ce document (OVERVIEW) +2. Consulter [MODULARISATION\_STATUS.md](MODULARISATION_STATUS.md) pour connaître l'état actuel +3. Suivre le canal Mattermost `#backend-modularisation` +4. Respecter les zones en modularisation (🚧) avant de développer + +### Pour le lead technique de la modularisation +1. Lire tous les documents dans l'ordre +2. Personnaliser [MODULARISATION\_MAP.md](MODULARISATION_MAP.md) avec la cartographie réelle +3. Configurer l'infrastructure +4. Lancer la communication + +## 🎯 Principes directeurs + +### Technique +- **Parité fonctionnelle** : Chaque route migrée doit être strictement équivalent à son prédécesseur +- **Tests de contrat** : Validation automatique de la parité comportementale +- **Monitoring** : Surveillance continue des performances et erreurs +- **Documentation** : Chaque décision architecturale documentée (ADR) + +### Organisationnel +- **Communication proactive** : Annonce claire des zones en modularisation +- **Collaboration** : "Pair programming" encouragé pour montée en compétence +- **Pragmatisme** : Adapter le planning selon les contraintes business +- **Célébration** : Reconnaître les étapes franchies + +## ⚠️ Règles critiques + +### À FAIRE +✅ Annoncer début/fin de modularisation d'un module sur Mattermost +✅ Mettre à jour MODULARISATION\_STATUS.md à chaque changement +✅ Écrire des tests de contrat avant de migrer une route +✅ Déployer progressivement (intégration → staging → prod) +✅ Monitorer 24-48 h après chaque modularisation + +### À NE PAS FAIRE + +❌ Migrer un module pendant qu'une fonctionnalité y est développée +❌ Modifier le schéma Prisma pendant la modularisation (sauf critique) +❌ Déployer sans tests E2E passants +❌ Oublier de mettre à jour la config nginx pour indiquer la bascule d'une route +❌ Développer sur `server` (et pas sur `server-nestjs`) un module déjà migré + +## 📞 Contacts + +### Responsable modularisation +- **Lead technique** : @stephane.trebel +- **Canal Mattermost** : #backend-modularisation +- **Réunions** : Hebdo modularisation (les jeudis à 10h, 30 min) + +### Escalade +- **Problème technique bloquant** : Contacter @lead-dev immédiatement +- **Conflit de développement** : Contacter @lead-dev immédiatement +- **Incident de Production** : Retour en arrière Nginx sur la route concernée + +## 📈 Métriques de succès + +### Fin de modularisation (sprint 12) +- [ ] 100% des routes migrées sur NestJS +- [ ] code de `server` supprimé du dépôt Git de `console` +- [ ] Couverture des Tests E2E ≥ 50% des cas d'usage de `console` +- [ ] Documentation technique complète des modules (cartographie, dépendances inter-modules, etc.) +- [ ] 0 régression fonctionnelle +- [ ] Équipe autonome sur NestJS + +### Indicateurs intermédiaires +- **S4** : 20% migré (Modules transverses (logger, configuration, etc.)) +- **S6** : 40% migré (Modules "verticaux" comme Zone, Cluster, etc.) +- **S8** : 60% migré +- **S10** : 80% migré (Plugins, comme ArgoCD, Gitlab, etc.) +- **S12** : 100% migré + +### Migrations anticipées +- **ServiceChain (OpenCDS)** : Migré le 2026-04-09 (prévu V3/S8). Ce module + isolé (proxy HTTP vers API externe, aucune + dépendance entrante) a servi de premier test de bout en bout du pipeline de + migration : module NestJS + `InfrastructureModule` + `UserGuard` + + nginx-strangler + Docker. + +## 🗓️ Dates clés + +- **S1-S2** : Cartographie et setup +- **S3-S4** : Modularisation des modules transverses +- **S5-S6** : Modularisation des modules métiers "cœur" (Zone, Cluster, etc.) +- **S7-S10** : Modularisation modules plugins (ArgoCD, Gitlab, etc.) +- **S11-S12** : Finalisation et nettoyage du code (suppression reverse proxy, suppression de `server`, etc.) + +*Dates indicatives, ajustables selon les contraintes business* + +## 📚 Ressources + +### Documentation NestJS + +- [Documentation officielle NestJS](https://docs.nestjs.com/) +- [Prisma avec NestJS](https://docs.nestjs.com/recipes/prisma) +- [Authentification OIDC NestJS](https://docs.nestjs.com/recipes/passport) + +### Outils + +- [Madge](https://github.com/pahen/madge) - Analyse de dépendances +- [Compodoc](https://compodoc.app/) - Documentation auto +- [Playwright](https://playwright.dev/) - Tests E2E +- [Vitest](https://vitest.dev/) - Tests unitaires + +--- + +**Version** : 1.2 +**Dernière mise à jour** : 2026-06-16 +**Prochaine revue** : Fin S2 diff --git a/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md b/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md new file mode 100644 index 0000000000..10c05e2525 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md @@ -0,0 +1,313 @@ +# Stratégie de Modularisation - console/server → console/server-nestjs + +## 🎯 Approche globale + +Sachant que nous avons des fonctionalités assez bien identifiées (AdminTokens, Plugin Gitlab, etc.), nous allons utiliser une approche « Modularisation par verticaux ». + +### Principe + +Plutôt que de migrer par couches horizontales (tous les DTOs, puis tous les services, puis tous les controllers, puis tous les plugins), nous migrerons **module complet par module complet**. + +### Pourquoi cette approche ? + +✅ **Valeur métier immédiate** : Chaque itération livre un module fonctionnel +✅ **Tests E2E possibles** : Validation complète de la non-régression dès la fin du module +✅ **Risque isolé** : Un problème n'affecte qu'un seul domaine métier +✅ **Rollback granulaire** : Retour arrière possible module par module +✅ **Équipe non bloquée** : Autres modules restent disponibles pour développement +✅ **Reprise du typage** : Beaucoup de modules ont un typage Typescript lacunaire, voire inexistant (`any`). Migrer un module sera l'opportunité de reprendre ces aspects et définir clairement l'"API" du Module + +### Anatomie d'un vertical + +``` +ZoneModule (exemple) +├── Model (Prisma) ← Déjà existant, partagé +├── Repository/Service ← Modularisation du code métier +├── Controller/Routes ← Modularisation des endpoints +├── DTOs & Validation ← Ajout validation NestJS +├── Tests unitaires ← Nouveaux tests Vitest +└── Tests E2E ← Adaptation tests Playwright si besoin (normalement jamais) +``` + +## Phase 1: 📊 Cartographie et priorisation du code de console/server actuel + +### Objectifs de la cartographie + +1. **Inventaire exhaustif** : Lister tous les endpoints et leurs dépendances +2. **Graphe de dépendances** : Identifier les modules couplés entre eux (typiquement Vault et…tout le reste) +3. **Priorisation** : Définir l'ordre optimal de modularisation (de "bas en haut", donc de ceux qui ont le moins de dépendances à ceux qui dépendent de presque tout le monde) +4. **Estimation** : Évaluer grosse maille la charge de chaque module + +### Outils d'analyse automatique + +Une manière de faire qui combine une approche automatique (on parcourt tout ce qu'on peut) et une approche empirique (on regarde ce que ça donne) peut se faire de la manière suivante : + +```bash +# 1. Installer les outils +npm install -D madge dependency-cruiser + +# 2. Générer le graphe de dépendances +npx madge --image dependency-graph.png --extensions js,ts server/src + +# 3. Détecter les cycles de dépendances +npx madge --circular --extensions js,ts server/src + +# 4. Analyser les violations architecturales +npx depcruise src --output-type err +``` + +### Ordre de modularisation recommandé + +**Stratégie en 3 phases** : + +1. **Phase d'apprentissage (S3-S4)** : Modules simples mais critiques + - Auth : Critique mais bien délimité + - Objectif : Valider l'approche, former l'équipe + +2. **Phase de vélocité (S5-S8)** : Modules métier principaux + - Cluster, Zone, etc. + - Objectif : Migrer le gros du trafic + +3. **Phase de finalisation (S9-S12)** : Modules périphériques + - Plugins qui doivent exister impérativement (Kubernetes, Gitlab, etc. -LISTE À DEFINIR-) + - Objectif : Compléter la modularisation, suppression de `server` et renommage de `server-nestjs` en `server` + +## 🔄 Gestion du passage simple singleton node.js → Injection de dépendances dans des services singleton + +### Le problème des singletons purement "node.js" + +Les singletons actuellement utilisés dans le code sont ceux de Node.js. Ce sont +des instances certes uniques, mais qui reposent intégralement sur une logique +d'import des fichiers. Cette logique n'est pas mauvaise en soi (et fonctionne +encore maintenant), mais elle est très limitée, car elle ne nous permet pas de +configurer les "modules" importés (changer une dépendance par une autre, par +exemple). Par essence les modules node.js ne permettent pas d'injection de +dépendance, en particulier au runtime. + +```javascript +// ❌ Pattern singleton dans le code server +const prisma = require('../db').default; + +async findById(id) { + return prisma.user.findUnique({ where: { id } }); +} + +async create(data) { + return prisma.user.create({ data }); +} + +// Export d'une instance unique (singleton) +module.exports = { findById, create }; +``` + +**Problèmes** : +- État partagé mutable +- Difficile à tester (mocking complexe) +- Pas de contrôle du cycle de vie +- Couplage fort avec les dépendances +- Typage souvent lacunaire + +### Solution : Migrer vers un vrai service singleton de type NestJS + +```typescript +// nest-backend/src/users/users.service.ts +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { User } from '@prisma/client'; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id } }); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + return user; + } + + async create(createUserDto: CreateUserDto): Promise { + return this.prisma.user.create({ + data: createUserDto, + }); + } + + // Ajouter validation, transformation, logique métier +} +``` + +### Checklist de modularisation d'un singleton node.js -> singleton NestJS + +Pour chaque service singleton node.js à migrer : + +- [ ] **Identifier** : Lister tous les singletons node.js (grep `module.exports =` ou `export default`) +- [ ] **Analyser** : Documenter l'interface publique (méthodes exposées) +- [ ] **État mutable** : Identifier les propriétés d'instance partagées +- [ ] **Dépendances** : Lister ce dont le singleton dépend +- [ ] **Créer le service** : Reproduire l'interface du singleton en NestJS +- [ ] **Tests de contrat** : Valider la parité comportementale avant/après +- [ ] **Migrer progressivement** : Remplacer les usages un par un +- [ ] **Nettoyage** : Supprimer le singleton node.js une fois inutilisé + +## 📝 Méthodologie de modularisation d'un module + +### Processus en 7 étapes + +#### 1. Analyse et préparation (Jour 1) + +```bash +# Analyser les dépendances du module +npx madge --depends my-module old-server/src + +# Lister les endpoints concernés +grep -r "router.*mymodule" old-server/src/routes + +# Identifier les tests E2E existants +grep -r "my-module" playwright/tests/e2e +``` + +**Livrables** : +- Liste des routes/endpoints à migrer +- Graphe des dépendances +- Plan de tests + +#### 2. Setup du nouveau module NestJS (Jour 1) + +**Note**: Afin d'accélérer la création des entités NestJS (services, modules, +contrôleurs, etc.), on pourra utiliser [la CLI `nest`](https://docs.nestjs.com/cli/usages#nest-generate) +pour générer des modèles (*templates*). + +```bash +# Générer le module avec NestJS CLI (par ex pour un module UserModule) +cd nest-backend +nest g module users +nest g controller users +nest g service users +``` + +**Structure créée** : +``` +src/users/ +├── users.module.ts +├── users.controller.ts +├── users.service.ts +├── dto/ +│ ├── create-user.dto.ts +│ └── update-user.dto.ts +├── entities/ +│ └── user.entity.ts +└── users.controller.spec.ts +``` + +#### 3. Modularisation de la logique métier (Jours 2-3) + +**Ordre de modularisation** : +1. DTOs et contrat de validation, suivant les besoins (certains services ont + besoin d'un DTO puisqu'ils utilisent la BDD, d'autres non) +2. Service (logique métier du module) +3. Controller (routes du module) +4. Guards et interceptors pour les contrôles qui peuvent être factorisés + +#### 4. Écriture des tests unitaires pour le nouveau service (Jour 3) + +#### 5. Adaptation des tests E2E si nécessaire (Jour 4) + +#### 6. Configuration du nginx étrangleur et déploiement (Jour 5) + +**Mise à jour de `apps/nginx-strangler/routing.conf`** : + +```nginx +# [S5-S6] Users - 2026-02-23 +location /api/xxxxx { + proxy_pass http://server-nest; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location /api/yyyyy { + proxy_pass http://server-nest; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Note: Prévoir un "blue-green" afin de permettre un passage souple et serein +entre les anciens et nouveaux services. + +**Déploiement progressif** : + +```bash +# 1. Backup de la config actuelle +cp apps/nginx-strangler/routing.conf apps/nginx-strangler/routing.conf.backup + +# 2. Mise à jour de la config +# (éditer apps/nginx-strangler/routing.conf) + +# 3. Validation de la config nginx +docker-compose exec nginx nginx -t + +# 4. Rechargement sans downtime +docker-compose exec nginx nginx -s reload + +# 5. Monitoring (surveiller les logs pendant 5-10min) +docker-compose logs -f nginx | grep users +``` + +Note: Prévoir un retour arrière en cas de problème ⚠ + +#### 7. Monitoring et validation (Jours 5-7) + +Note: Faire un point sur la métrologie existante. Envisager si besoin de +fiabiliser la stack d'observabilité, implémenter Loki et remonter des métriques +si besoin). + +**Métriques à surveiller** : + +```bash +# Logs en temps réel +docker-compose logs -f nest-api | grep users + +# Taux d'erreur +docker-compose logs nest-api | grep ERROR | grep users | wc -l + +# Temps de réponse moyen (exemple avec awk) +docker-compose logs nginx | grep "/api/users" | \ + awk '{print $NF}' | awk -F'=' '{sum+=$2; count++} END {print sum/count}' +``` + +**Validation** : +- [ ] Aucune erreur 5xx pendant 24h +- [ ] Temps de réponse comparable au code `server` (±20%) +- [ ] Tests E2E passants +- [ ] Recette utilisateurs (si applicable) + +## 🚨 Gestion des situations complexes + +### Cas 1 : Module avec beaucoup de dépendances + +**Stratégie** : +1. Migrer d'abord les dépendances (Users, Products) +2. Créer des adaptateurs temporaires (des services "passe-plat") pour les modules qui ne sont pas encore migrés +3. Tester intensivement les interactions entre modules (typage fort, tests d'intégration, E2E, etc.) + +## ✅ Checklist de fin de module + +Avant de considérer un module comme "migré" : + +- [ ] Tous les endpoints du module redirigent vers `server-nestjs` +- [ ] Tests de contrat passants (parité avec le code `server`) +- [ ] Tests E2E passants (éventuellement adaptés) +- [ ] Configuration nginx mise à jour et déployée +- [ ] Monitoring actif +- [ ] Aucune régression détectée +- [ ] Documentation technique à jour +- [ ] MODULARISATION_STATUS.md mis à jour +- [ ] Annonce de fin sur Mattermost diff --git a/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md b/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md new file mode 100644 index 0000000000..257b260401 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md @@ -0,0 +1,120 @@ +# Architecture d’un module (pattern `apps/server-nestjs/src/modules/*`) + +Les modules NestJS métier vivent dans `src/modules//` et suivent un découpage “vertical slice” avec des responsabilités explicites : **client**, **service (API publique)**, **controller service (orchestration)**, **datastore**, **utils** et **tests**. + +Exemples concrets : + +- Module GitLab : `src/modules/gitlab/` +- Module Keycloak : `src/modules/keycloak/` + +## Structure type + +```txt +src/modules// +├── .module.ts +├── .constants.ts +├── -client.service.ts +├── .service.ts +├── -datastore.service.ts +├── .utils.ts +├── -testing.utils.ts +└── *.spec.ts +``` + +## Sens des dépendances (flow recommandé) + +Objectif : un flux de dépendances lisible et sans cycles. + +```txt +.service.ts + ↙ ↘ +-client.service.ts -datastore.service.ts +``` + +Règles pratiques : + +- Le `.service.ts` est un entrypoint interne (cron, events, reconcile) et orchestre en appelant directement le `client` (et le `datastore` si nécessaire), sans dépendre du `service`. +- Le `service` contient les règles métier (décisions, transformations, validations) et s’appuie sur le `client` (et le `datastore` quand la lecture/écriture DB fait partie du cas d’usage). +- Le `client` encapsule l’accès à une API externe (initialisation + appels + erreurs bas niveau). +- Le `datastore` encapsule l’accès DB (Prisma) et expose des méthodes de lecture/écriture typées. +- Les `utils` restent “purs” (pas d’IO, pas d’injection Nest). +- Les `testing utils` centralisent les factories/fixtures pour réduire la duplication dans les tests. + +## Composants + +### `.module.ts` + +Rôle : + +- Déclare les providers, imports, exports du module. +- Exporte le `service` du module (`.service.ts`) qui constitue l’API publique. + +### `-client.service.ts` + +Rôle : + +- Adapter vers le système externe (SDK HTTP, client Keycloak, client GitLab, etc.). +- Conserver un contrat stable pour le reste du module. +- Mapper/normaliser les erreurs externes si nécessaire. + +À éviter : + +- Décisions métier (permissions, synchronisation, règles de purge) : elles vont dans `.service.ts` ou le controller service. + +### `.service.ts` + +Rôle : + +- Orchestrateur de workflows : `@Cron`, `@OnEvent`, reconcile périodique, tâches “batch”. +- Coordination entre `client` et `datastore` (sans dépendre du `service`). +- Garde-fous “safety” avant opérations destructrices (ex: suppression de groupes orphelins). + +### `-datastore.service.ts` + +Rôle : + +- Accès DB via Prisma (select/include, transactions, pagination). +- Exposition de types agrégés utiles au domaine (ex: `ProjectWithDetails`). + +À éviter : + +- Appliquer des règles métier (ex: calcul de permissions) : on garde le datastore centré persistence. + +### `.utils.ts` + +Rôle : + +- Fonctions utilitaires pures : mapping, helpers de collections, types partagés. +- Aucune dépendance Nest, aucune lecture/écriture DB, aucun appel réseau. + +### `-testing.utils.ts` + +Rôle : + +- Factories typées pour les structures fréquemment utilisées en tests. +- Support d’`overrides` pour construire rapidement des variantes. +- Centralisation des erreurs/fake responses spécifiques au module (quand utile). + +## Tests (Vitest) + +### `.service.spec.ts` + +Cible : + +- Orchestration : séquences d’appels, side-effects attendus, reconcile. + +Approche : + +- Mock du `service`, du `datastore`, et des appels externes. +- Vérification des appels effectués et des paramètres attendus. + +### `-datastore.service.spec.ts` (si présent) + +Cible : + +- Forme des requêtes Prisma, mapping de résultat, typage de l’agrégat renvoyé. + +Approche : + +- Mock de Prisma/DatabaseService, pas de logique métier. + diff --git a/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md b/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md new file mode 100644 index 0000000000..a5e6b10c1d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md @@ -0,0 +1,1276 @@ +# Cartographie des modules - Modularisation Backend + +> Derniere mise a jour : **2026-06-16** (refonte Infrastructure/Auth/Permission) + +--- + +## Objectif de ce document + +Cartographier l'ensemble des modules de l'application backend actuelle pour : +1. Identifier les dependances entre modules +2. Prioriser l'ordre de modularisation +3. Estimer la charge de chaque module +4. Faciliter la communication sur les zones en modularisation + +--- + +## Decisions structurantes + +| Decision | Choix | +|----------|-------| +| Approche migration | Bottom-up (feuilles d'abord, puis remontée vers les modules couples) | +| Contrats API | Decorateurs NestJS natifs + class-validator (abandon de ts-rest) | +| queries-index.ts | Supprime des le depart : chaque module NestJS possede ses propres queries Prisma | +| Systeme d'evenements | `@nestjs/event-emitter` (remplacement progressif de `@cpn-console/hooks`) | +| Plugins | Deviennent des modules NestJS (encapsulation puis reecriture progressive) | +| Module project | Decoupe en 3 sous-modules : ProjectCore, ProjectSecrets, ProjectBulk | +| Equipe dediee | 2 developpeurs en parallele | + +--- + +## Vue synthetique + +**75 routes metier** reparties en **18 modules metier** (dont project eclate en 3), +plus **5 couches transverses** et **7 plugins** a encapsuler. + +### Legende des statuts + +- FAIT : Deja implemente dans `server-nestjs` +- A CREER : N'existe pas encore +- A MIGRER : Existe dans `server`, a migrer vers `server-nestjs` + +--- + +## Architecture lasagnes - Etat des lieux + +``` + Couche Etat actuel server-nestjs ++--------------------------------------------------------------+ +| Couche 6 : Modules metier (controllers, DTOs) | A MIGRER ++--------------------------------------------------------------+ +| Couche 5 : Plugins (modules NestJS injectables) | A CREER ++--------------------------------------------------------------+ +| Couche 4 : Evenements (EventEmitter, remplacement hooks) | A CREER ++--------------------------------------------------------------+ +| Couche 3 : Securite (AuthService, UserGuard, ProjectGuard, Filters) | PARTIEL ++--------------------------------------------------------------+ +| Couche 2 : Core (AppService, FastifyService) | FAIT ++--------------------------------------------------------------+ +| Couche 1 : Infrastructure (Config, Logger, DB, HTTP) | FAIT ++--------------------------------------------------------------+ +``` + +Les couches 1 et 2 sont en place. La couche 3 est **partiellement** en place : +l'auth par token (`x-dso-token`) et le bearer JWT Keycloak passent désormais +par `AuthService`. La couche de permissions est structurée autour de +`InfrastructureModule -> PermissionModule -> UserModule / ProjectModule` +avec `UserGuard`, `ProjectGuard`, `UserService` et `ProjectService`. +Il reste a homogénéiser les usages des decorateurs de contexte utilisateur +(`@AuthUser()`), et le `GlobalExceptionFilter`. +Les couches 4 et 5 restent a creer. + +--- + +## Couche 0 - Socle transverse (S3, priorite absolue) + +Ces elements ne sont pas dans la matrice de scoring car ce ne sont pas des +modules metier. Ils conditionnent cependant la migration de **tous** les modules +qui suivent. Les creer en premier permet que chaque migration de module soit un +exercice de "remplissage" des couches superieures, sans reinventer +l'infrastructure a chaque fois. + +### 0a. AuthService + decorateur @AuthUser() — PARTIEL + +**Sprint** : S3 (Dev A) +**Remplace** : `authUser()` dans `apps/server/src/utils/controller.ts` + +**Etat actuel (2026-04-09)** : La partie auth par token est implementee dans +`infrastructure/auth/` : +- ✅ `AuthService` : validation token SHA256, lookup Prisma + (PersonalAccessToken + AdminToken), verification status/expiration, calcul + permissions bitwise (y compris roles globaux) +- ✅ `AuthService` : route `x-dso-token` et bearer JWT Keycloak vers + `DsoTokenService` / `KeycloakJwtService` +- ✅ `UserGuard` : applique les requirements de permission et injecte le + contexte utilisateur dans la request +- ✅ `@AuthUser()` : decorateur de parametre pour le contexte utilisateur +- ✅ `@RequireAdminPermission()` / `@RequireProjectPermission()` : metadata + de permissions admin et projet + +**Reste a faire** : +- Harmonisation des controles d'accès entre modules restants +- Chargement optionnel du projet (par slug, id, environmentId, repositoryId) +- Formalisation des types de contexte utilisateur/projet partages + +### 0b. GlobalExceptionFilter + +**Sprint** : S3 (Dev B) +**Remplace** : Les classes de `apps/server/src/utils/errors.ts` + +Mapping des erreurs : + +| Classe actuelle (server) | Equivalent NestJS | +|--------------------------|-------------------| +| `BadRequest400` | `BadRequestException` | +| `Unauthorized401` | `UnauthorizedException` | +| `Forbidden403` | `ForbiddenException` | +| `NotFound404` | `NotFoundException` | +| `Unprocessable422` | `UnprocessableEntityException` | +| `Internal500` | `InternalServerErrorException` | + +Le filtre global intercepte les `HttpException` NestJS et les formate dans le +schema de reponse attendu par le client (`{ status, body: { message } }`). + +### 0c. PermissionModule + decorateurs de permissions + +**Sprint** : S3-S4 (Dev A) +**Remplace** : `getProjectPermissions`, `AdminAuthorized`, `ProjectAuthorized` dans `utils/controller.ts` + +Fonctionnalites : +- Decorateurs `@RequireAdminPermission()` et `@RequireProjectPermission()` sur les methodes de controller +- Guard `UserGuard` qui verifie les bitmasks de permissions admin et projet +- Support des deux niveaux : `AdminRole` (global) et `ProjectRole` (scope projet) + +### 0d. ValidationPipe global + +**Sprint** : S3 (Dev B) +**Remplace** : La validation Zod des contrats ts-rest + +Configuration : +- `class-validator` + `class-transformer` en pipe global +- `whitelist: true` (strip des proprietes non declarees) +- `forbidNonWhitelisted: true` (erreur si proprietes inconnues) +- `transform: true` (transformation automatique des types) + +### 0e. EventEmitterModule + +**Sprint** : S4-S5 (Dev A) +**Remplace** : `@cpn-console/hooks` (premiere iteration) + +Mise en place de `@nestjs/event-emitter` avec : +- Evenements types pour chaque hook actuel (ProjectUpserted, ClusterDeleted, etc.) +- Pattern `@OnEvent('project.upserted')` pour les abonnements +- Pas de steps pre/check/main/post dans cette v1 : orchestration lineaire + +Ce module sera utilise par les modules metier des la Vague 2 (quand les modules +avec hooks commencent a etre migres). + +--- + +## Matrice de priorisation + +### Criteres de scoring + +| Critere | Poids | Description | +|---------|-------|-------------| +| Dependances faibles | 40% | Moins de dependances sortantes = plus facile a extraire (bottom-up) | +| Criticite | 30% | Balance risque/valeur. Un module critique et bien delimite score haut | +| Trafic | 20% | Volume raisonnable pour validation de la migration | +| Difficulte (inversee) | 10% | Complexite technique inversee (facile = score eleve) | + +### Formule + +``` +Score = (Dependances x 0.4) + (Criticite x 0.3) + (Trafic x 0.2) + (Difficulte x 0.1) + +Echelle : 1-10 pour chaque critere. +Plus le score est eleve, plus le module est prioritaire. +``` + +### Resultats ordonnes par score + +| Rang | Module | Type | Score | Vague | Sprint | Statut | +|------|--------|------|-------|-------|--------|--------| +| 1 | vault (encapsulation) | Plugin | 8.5 | V3 | S7-S8 | ✅ MIGRE | +| 2 | system (health/version) | Metier | 7.8 | V1 | S3 | ✅ MIGRE | +| 3 | system/settings | Metier | 7.4 | V1 | S3 | ✅ MIGRE | +| 4 | system/config | Metier | 7.4 | V1 | S3-S4 | | +| 5 | keycloak (encapsulation) | Plugin | 7.4 | V3 | S8 | ✅ MIGRE | +| 6 | admin-token | Metier | 7.1 | V1 | S3-S4 | | +| 7 | user/tokens | Metier | 7.1 | V1 | S3-S4 | | +| 8 | gitlab (encapsulation) | Plugin | 6.7 | V4 | S9 | ✅ MIGRE | +| 9 | service-monitor | Metier | 6.6 | V2 | S5 | | +| 10 | user | Metier | 6.6 | V2 | S5 | | +| 11 | stage | Metier | 6.5 | V2 | S5-S6 | | +| 12 | log | Metier | 6.5 | V1 | S4 | | +| 13 | zone | Metier | 6.4 | V2 | S6 | | +| 14 | environment | Metier | 6.3 | V3 | S7 | | +| 15 | admin-role | Metier | 6.1 | V2 | S5 | | +| 16 | project-core | Metier | 5.8 | V4 | S9 | | +| 17 | service-chain | Metier | 5.9 | V3 | S8 | ✅ MIGRE | +| 18 | repository | Metier | 5.8 | V3 | S7-S8 | | +| 19 | cluster | Metier | 5.7 | V3 | S7 | | +| 20 | harbor (encapsulation) | Plugin | 5.6 | V4 | S9-S10 | ✅ MIGRE | +| 21 | project-service | Metier | 5.6 | V3 | S8 | | +| 22 | argocd (encapsulation) | Plugin | 5.3 | V5 | S10-S11 | ✅ MIGRE | +| 23 | project-role | Metier | 5.2 | V3 | S7-S8 | | +| 24 | nexus (encapsulation) | Plugin | 5.1 | V4 | S10 | ✅ MIGRE | +| 25 | project-member | Metier | 4.7 | V3 | S8 | | +| 26 | project-secrets | Metier | 4.6 | V4 | S9 | | +| 27 | project-bulk | Metier | 4.2 | V4 | S9-S10 | | +| 28 | sonarqube (encapsulation) | Plugin | 4.2 | V5 | S11 | | + +**Note** : Le score brut ne dicte pas directement l'ordre de migration. +L'ordre reel est contraint par le graphe de dependances (bottom-up), les +pre-requis transverses, et la parallelisation a 2 devs. C'est pourquoi `vault` +(score 8.5) est en Vague 3 et non en Vague 1 : son encapsulation NestJS +necessite l'EventEmitter et un pattern valide sur les modules metier simples. + +--- + +## Vague 1 - Quick wins et validation du pattern (S3-S4) + +**Objectif** : Valider le pattern NestJS complet (Controller, Service, Prisma, +AuthGuard, DTOs class-validator, ExceptionFilter). Former l'equipe. Deployer +les premiers modules via Nginx. + +**Livrable fin S4** : 13 routes migrees (~17%) + +### 1. system (health/version) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.8 | +| **Sprint** | S3 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/version` - Version de l'application +- `GET /api/v1/healthz` - Health check + +**Dependances sortantes** : Aucune (lecture de variable d'env) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Module le plus simple du codebase. Ideal pour valider le pipeline complet : + creation controller NestJS, deploiement Docker, bascule Nginx +- Pas d'authentification requise sur ces routes + +**Estimation** : 0.5 jour + +--- + +### 2. system/settings — ✅ MIGRE (2026-04-28) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.4 | +| **Sprint** | S3 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/system/settings` - Liste des parametres systeme +- `PUT /api/v1/system/settings` - Mise a jour d'un parametre + +**Dependances sortantes** : Aucune (queries propres, Prisma direct) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Premiere occasion de valider le pattern CRUD NestJS avec DTOs +- Auth requise (admin uniquement) + +**Estimation** : 1 jour + +--- + +### 3. system/config + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.4 | +| **Sprint** | S3-S4 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/plugins-config` - Configuration des plugins +- `PUT /api/v1/admin/plugins-config` - Mise a jour de la configuration + +**Dependances sortantes** : Aucune (queries propres) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth admin requise +- Gestion de la configuration JSON des plugins (AdminPlugin en BDD) + +**Estimation** : 1 jour + +--- + +### 4. admin-token + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 7.1 | +| **Sprint** | S3-S4 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/admin/tokens` - Liste des tokens de service admin +- `POST /api/v1/admin/tokens` - Creation d'un token +- `DELETE /api/v1/admin/tokens/:tokenId` - Suppression d'un token + +**Dependances sortantes** : Aucune (Prisma direct, pas de queries-index) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth admin requise +- Premier module avec CRUD complet et generation de token (hash bcrypt) +- Pas de hook-wrapper utilise + +**Estimation** : 1 jour + +--- + +### 5. user/tokens + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 7.1 | +| **Sprint** | S3-S4 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/users/me/tokens` - Liste des tokens personnels +- `POST /api/v1/users/me/tokens` - Creation d'un token personnel +- `DELETE /api/v1/users/me/tokens/:tokenId` - Suppression d'un token + +**Dependances sortantes** : Aucune (Prisma direct) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth utilisateur requise (scope personnel : l'utilisateur gere ses propres tokens) +- Similaire a admin-token, bon pour paralleliser +- Hash bcrypt des tokens + +**Estimation** : 1 jour + +--- + +### 6. log + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 1 | +| **Score** | 6.5 | +| **Sprint** | S4 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/logs` - Logs d'audit du projet + +**Dependances sortantes** : `queries-index` (lecture seule via `getAllLogs`) +**Dependances entrantes** : Aucune (beaucoup de modules *ecrivent* des logs +via `addLogs`, mais ce module ne fait que *lire*) + +**Points d'attention** : +- Premiere rencontre avec queries-index. La strategie est de creer les queries + Prisma directement dans le module NestJS (pas de barrel partage) +- Auth utilisateur requise, scope projet +- Read-only : pas de hooks + +**Estimation** : 0.5 jour + +--- + +## Vague 2 - Fondations metier et premiers hooks (S5-S6) + +**Objectif** : Migrer les modules qui sont des dependances pour d'autres. +Introduction de l'EventEmitter dans les modules avec hooks. `stage`, `zone` +et `user` sont des pre-requis pour la Vague 3 (cluster, project-member). + +**Livrable fin S6** : 34 routes migrees (~45%) + +### 7. service-monitor + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 6.6 | +| **Sprint** | S5 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/services` - Sante des services (plugins) +- `GET /api/v1/services/all` - Sante complete de tous les services +- `POST /api/v1/services/refresh` - Rafraichissement des statuts + +**Dependances sortantes** : `@cpn-console/hooks` (appel direct des moniteurs de plugins) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Premier module utilisant le systeme de hooks (via les moniteurs de plugins) +- Bon premier test de l'EventEmitter : les plugins exposent leur moniteur, + le service-monitor les interroge +- Pas de queries Prisma (pas d'interaction BDD) + +**Estimation** : 1 jour + +--- + +### 8. user + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.6 | +| **Sprint** | S5 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/users` - Recherche d'utilisateurs (matching) +- `POST /api/v1/auth` - Authentification / synchronisation session +- `GET /api/v1/admin/users` - Liste complete des utilisateurs (admin) +- `PATCH /api/v1/admin/users` - Modification utilisateurs (admin) + +**Dependances sortantes** : `queries-index` (getMatchingUsers, getUsers), hooks (adminRole.upsert) +**Dependances entrantes** : `project-member` (importe `logViaSession`), `utils/controller.ts` (importe `logViaSession`, `logViaToken`) + +**Points d'attention** : +- Module critique : `logViaSession` et `logViaToken` sont consommes par le + systeme d'auth. Ces fonctions auront ete portees dans l'AuthGuard (Couche 0), + donc la dependance entrante est deja resolue +- Hooks utilises (adminRole.upsert) : necessite l'EventEmitter +- Pre-requis pour `project-member` (Vague 3) + +**Estimation** : 2 jours + +--- + +### 9. admin-role + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 6.1 | +| **Sprint** | S5 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/roles` - Liste des roles admin +- `POST /api/v1/admin/roles` - Creation d'un role +- `PATCH /api/v1/admin/roles` - Modification des roles +- `GET /api/v1/admin/roles/member-counts` - Comptage des membres par role +- `DELETE /api/v1/admin/roles/:roleId` - Suppression d'un role + +**Dependances sortantes** : `queries-index` (getAdminRoleById, listAdminRoles), hooks (adminRole.upsert, adminRole.delete) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Valide le pattern hooks + EventEmitter dans un contexte admin +- Permissions admin (bitmask) : valide `UserGuard` +- Queries a internaliser dans le module + +**Estimation** : 1.5 jours + +--- + +### 10. stage + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 6.5 | +| **Sprint** | S5-S6 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/stages` - Liste des stages (dev, staging, prod, etc.) +- `GET /api/v1/stages/:stageId/environments` - Environnements d'un stage +- `POST /api/v1/stages` - Creation d'un stage +- `PUT /api/v1/stages/:stageId` - Modification d'un stage +- `DELETE /api/v1/stages/:stageId` - Suppression d'un stage + +**Dependances sortantes** : `queries-index` (nombreuses queries stage + cluster) +**Dependances entrantes** : `cluster/business.ts` (importe directement `linkClusterToStages`) + +**Points d'attention** : +- Pre-requis critique pour `cluster` (Vague 3). La fonction `linkClusterToStages` + exportee par stage est consommee directement par cluster. Dans l'architecture + NestJS, ce sera un service injectable du StageModule exporte et importe par + ClusterModule +- Pas de hooks directs +- Beaucoup de queries a internaliser (11 fonctions dans queries-index) + +**Estimation** : 2 jours + +--- + +### 11. zone + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.4 | +| **Sprint** | S6 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/zones` - Liste des zones +- `POST /api/v1/zones` - Creation d'une zone +- `PUT /api/v1/zones/:zoneId` - Modification d'une zone +- `DELETE /api/v1/zones/:zoneId` - Suppression d'une zone + +**Dependances sortantes** : `queries-index` (addLogs), `./queries` (linkZoneToClusters), hooks (zone.upsert, zone.delete) +**Dependances entrantes** : Aucune directe (mais les hooks zone declenchent des actions dans les plugins keycloak, vault, gitlab) + +**Points d'attention** : +- Hooks utilises : zone.upsert et zone.delete declenchent des operations dans + 5 plugins (vault, keycloak, gitlab, argocd). C'est le premier module ou + l'EventEmitter sera teste en profondeur +- Interaction avec clusters via `linkZoneToClusters` +- Gestion des logs d'audit (addLogs) + +**Estimation** : 2 jours + +--- + +## Vague 3 - Modules couples + debut encapsulation plugins (S7-S8) + +**Objectif** : Migrer les modules a dependances croisees directes. En parallele, +debut d'encapsulation des plugins feuilles (vault, keycloak) dans des modules +NestJS injectables. + +**Livrable fin S8** : 66 routes migrees (~88%) + plugins vault et keycloak encapsules + +### 12. cluster + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 7 | +| **Score** | 5.7 | +| **Sprint** | S7 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/clusters` - Liste des clusters +- `GET /api/v1/clusters/:clusterId` - Details d'un cluster +- `GET /api/v1/clusters/:clusterId/usage` - Utilisation d'un cluster +- `POST /api/v1/clusters` - Creation d'un cluster +- `GET /api/v1/clusters/:clusterId/environments` - Environnements d'un cluster +- `PUT /api/v1/clusters/:clusterId` - Modification d'un cluster +- `DELETE /api/v1/clusters/:clusterId` - Suppression d'un cluster + +**Dependances sortantes** : +- `queries-index` (15+ fonctions : addLogs, createCluster, deleteCluster, + getClusterById, getClusterByLabel, getClusterDetails, getClusterEnvironments, + getProjectsByClusterId, linkClusterToProjects, linkZoneToClusters, listClusters, + listStagesByClusterId, removeClusterFromProject, removeClusterFromStage, updateCluster) +- **`stage/business.ts`** (import direct : `linkClusterToStages`) +- hooks (cluster.upsert, cluster.delete) +- `utils/business.ts` (Result monad) + +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Module le plus complexe en termes de dependances sortantes +- La dependance directe vers `stage/business` sera resolue par un import du + StageModule dans ClusterModule (stage migre en Vague 2) +- 15+ queries a internaliser dans le module +- Hooks cluster declenchent des operations dans vault et argocd +- Gestion des kubeconfigs (donnees sensibles) + +**Estimation** : 3 jours + +--- + +### 13. environment + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.3 | +| **Sprint** | S7 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/environments` - Liste des environnements +- `POST /api/v1/projects/:projectId/environments` - Creation d'un environnement +- `PUT /api/v1/environments/:environmentId` - Modification d'un environnement +- `DELETE /api/v1/environments/:environmentId` - Suppression d'un environnement + +**Dependances sortantes** : `queries-index` (addLogs, deleteEnvironment, getEnvironmentsByProjectId, initializeEnvironment, updateEnvironment), hooks +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Utilise le pattern `Result` (monad Success/Failure) de `utils/business.ts`. + A decider si on conserve ce pattern dans NestJS ou si on passe aux exceptions +- Scope projet : necessite `ProjectGuard` avec `ProjectAuthorized` +- Hooks environnement declenchent des operations dans les plugins + +**Estimation** : 2 jours + +--- + +### 14. repository + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.8 | +| **Sprint** | S7-S8 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/repositories` - Liste des depots +- `POST /api/v1/projects/:projectId/repositories/sync` - Synchronisation d'un depot +- `POST /api/v1/projects/:projectId/repositories` - Creation d'un depot +- `PUT /api/v1/repositories/:repositoryId` - Modification d'un depot +- `DELETE /api/v1/repositories/:repositoryId` - Suppression d'un depot + +**Dependances sortantes** : `queries-index` (addLogs, deleteRepository, getProjectInfosAndRepos, getProjectRepositories, initializeRepository, updateRepository), hooks (project.upsert, misc.syncRepository) +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Le hook `misc.syncRepository` est specifique a ce module (declenchement de + pipeline miroir GitLab) +- Scope projet : `ProjectGuard` requis +- Gestion des credentials de miroir (donnees sensibles dans Vault) + +**Estimation** : 2 jours + +--- + +### 15. project-role + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.2 | +| **Sprint** | S7-S8 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects/:projectId/roles` - Liste des roles projet +- `POST /api/v1/projects/:projectId/roles` - Creation d'un role +- `PATCH /api/v1/projects/:projectId/roles` - Modification des roles +- `GET /api/v1/projects/:projectId/roles/member-counts` - Comptage des membres +- `DELETE /api/v1/projects/:projectId/roles/:roleId` - Suppression d'un role + +**Dependances sortantes** : `queries-index` (deleteRole, listMembers, listRoles, updateRole), hooks (projectRole.upsert, projectRole.delete) +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Pattern similaire a admin-role (migre en Vague 2) mais scope projet +- Bitmask de permissions projet +- Hooks projectRole declenchent des operations dans Keycloak + +**Estimation** : 1.5 jours + +--- + +### 16. project-member + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 4.7 | +| **Sprint** | S8 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects/:projectId/members` - Liste des membres +- `POST /api/v1/projects/:projectId/members` - Ajout d'un membre +- `PATCH /api/v1/projects/:projectId/members` - Modification des membres +- `DELETE /api/v1/projects/:projectId/members/:memberId` - Retrait d'un membre + +**Dependances sortantes** : +- **`user/business.ts`** (import direct : `logViaSession`) +- `queries-index` (addLogs, deleteMember, listMembers, upsertMember) +- hooks (user.retrieveUserByEmail, projectMember.upsert, projectMember.delete) + +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- La dependance vers `user/business.ts` (`logViaSession`) aura ete resolue + par l'AuthGuard (Couche 0). Pas de dependance residuelle vers UserModule +- Hooks projectMember declenchent des operations dans Keycloak +- Recherche d'utilisateur par email via Keycloak (hook user.retrieveUserByEmail) + +**Estimation** : 1.5 jours + +--- + +### 17. project-service + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 5.6 | +| **Sprint** | S8 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/services` - Liste des services/plugins du projet +- `PUT /api/v1/projects/:projectId/services` - Mise a jour des services + +**Dependances sortantes** : `queries-index` (getAdminPlugin, getProjectInfosByIdOrThrow, getProjectStore, getPublicClusters, saveProjectStore) +**Dependances entrantes** : `utils/hook-wrapper.ts` (importe `ConfigRecords` type et `dbToObj`) + +**Points d'attention** : +- Ce module exporte des types et fonctions utilitaires consommes par hook-wrapper. + Dans l'architecture NestJS, ces types seront definis dans un module partage + ou dans le ProjectServiceModule exporte +- Gestion de la configuration plugin par projet (JSON store) +- Pas de hooks directs + +**Estimation** : 1 jour + +--- + +### 18. service-chain — ✅ MIGRE (2026-04-09) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.9 | +| **Sprint prevu** | S8 | +| **Migration effective** | 2026-04-09 | +| **Dev** | @stephane.trebel | + +**Routes (implementation reelle)** : +- `GET /api/v1/service-chains` - Liste des chaines de service +- `GET /api/v1/service-chains/:id` - Details d'une chaine +- `GET /api/v1/service-chains/:id/flows` - Flux de la chaine +- `POST /api/v1/service-chains/:id/retry` - Relance d'une chaine +- `POST /api/v1/service-chains/validate/:id` - Validation + +**Dependances sortantes** : API externe OpenCDS (HTTP via axios) +**Dependances entrantes** : Aucune + +**Implementation** : +- Module place dans la catégorie `ThirdPartyModules` (futur module optionnel CPin) comme envisagé +- Auth par token et bearer JWT via `AuthService` et `UserGuard` +- Validation UUID sur les parametres d'URL (`ParseUUIDPipe`) +- Validation des reponses OpenCDS via schemas Zod de `@cpn-console/shared` +- Telemetrie via `@StartActiveSpan()` (OpenTelemetry) +- Utilisation d'un service dédié `OpenCdsClientService` afin de gérer les requêtes vers le service OpenCDS externe + +**Differences avec le legacy** : +- 403 systematique si permissions insuffisantes (le legacy renvoyait `[]` sur GET /) +- Pas d'auth session Keycloak (token uniquement) +- Validation UUID stricte (400 si format invalide) +- Client OpenCDS dédié dans server-nestjs + +**Fichiers** : +- `src/cpin-module/service-chain/service-chain.*.ts` +- `src/cpin-module/service-chain/open-cds-client.*.ts` +- `src/modules/infrastructure/auth/` (AuthModule partage) + +--- + +### Plugin : vault (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 7 | +| **Score** | 8.5 | +| **Sprint** | S7-S8 | +| **Dev** | A (en parallele de cluster) | + +**Encapsulation** : Wrapper les 3 classes existantes (VaultApi, VaultProjectApi, +VaultZoneApi) dans un `VaultModule` NestJS avec des services `@Injectable()`. +La logique interne reste intacte. Le module expose `VaultService`, +`VaultProjectService`, `VaultZoneService` via DI. + +**Dependances sortantes (plugins)** : Aucune (feuille du graphe de plugins) +**Dependances entrantes (plugins)** : gitlab, keycloak, harbor, nexus, sonarqube, argocd (tous) + +**Points d'attention** : +- Plugin fondation de toute la chaine. Doit etre encapsule en premier +- Les autres plugins consomment les API classes de vault via le systeme de hooks. + Dans NestJS, ils importeront VaultModule et injecteront les services +- Configuration par variables d'environnement (a integrer dans ConfigurationService) + +**Estimation encapsulation** : 2 jours +**Estimation reecriture complete** : 5 jours (ulterieur) + +--- + +### Plugin : keycloak (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 11 (le plus grand nombre) | +| **Score** | 7.4 | +| **Sprint** | S8 | +| **Dev** | B (en parallele de project-member/service-chain) | + +**Encapsulation** : Wrapper `KeycloakProjectApi` et les fonctions de `functions.ts` +dans un `KeycloakModule` NestJS. + +**Dependances sortantes (plugins)** : vault (runtime via `payload.apis.vault`) +**Dependances entrantes (plugins)** : argocd, harbor, sonarqube + +**Points d'attention** : +- 11 hooks souscrits couvrant projets, zones, roles, membres, utilisateurs +- Le plus grand nombre de fonctions exportees (12) +- Utilise `@keycloak/keycloak-admin-client` (dependance externe) +- Necessite vault encapsule avant (depend de VaultModule) + +**Estimation encapsulation** : 2 jours +**Estimation reecriture complete** : 5-7 jours (ulterieur) + +--- + +## Vague 4 - Module project (decoupe) + plugins intermediaires (S9-S10) + +**Objectif** : Migrer le coeur metier (project) en 3 sous-modules independants. +Encapsuler les plugins intermediaires. + +**Livrable fin S10** : 75 routes migrees (100% des routes metier) + plugins +vault, keycloak, gitlab, harbor, nexus encapsules + +### 19. project-core + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.8 | +| **Sprint** | S9 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects` - Liste des projets +- `GET /api/v1/projects/:projectId` - Details d'un projet +- `POST /api/v1/projects` - Creation d'un projet +- `PUT /api/v1/projects/:projectId` - Modification d'un projet +- `DELETE /api/v1/projects/:projectId` - Archivage d'un projet + +**Dependances sortantes** : `queries-index` (addLogs, getProjectOrThrow, getSlugs, initializeProject, listProjects, lockProject, updateProject), hooks (project.upsert, project.delete, projectRole.upsert) +**Dependances entrantes** : Presque tous les modules a scope projet + +**Points d'attention** : +- Coeur absolu du systeme. Toute erreur ici impacte tout +- Le CRUD projet declenche des hooks vers TOUS les plugins (upsertProject, + deleteProject). C'est le plus gros consommateur de l'EventEmitter +- Gestion du statut projet (created/failed/warning/archived) via state machine +- Verrouillage projet (`lockProject`) pendant les operations longues +- La creation declenche : Keycloak (groupes), Vault (KV store), GitLab (groupe + + repos), Harbor (registre), Nexus (repos), SonarQube (projets), ArgoCD (config) + +**Estimation** : 3 jours + +--- + +### 20. project-secrets + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 1 | +| **Score** | 4.6 | +| **Sprint** | S9 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/secrets` - Secrets du projet + +**Dependances sortantes** : hooks (project.getSecrets) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Appelle le hook `getProjectSecrets` qui interroge chaque plugin pour ses + secrets (Vault, GitLab mirror token, Harbor robot, Nexus credentials, etc.) +- Donnees sensibles : attention au logging et a la serialisation +- Necessite que les plugins soient encapsules pour fonctionner correctement + +**Estimation** : 1 jour + +--- + +### 21. project-bulk + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 4.2 | +| **Sprint** | S9-S10 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/projects/data` - Export bulk des donnees projets +- `POST /api/v1/admin/projects/bulk` - Action bulk sur les projets +- `POST /api/v1/projects/:projectId/replay-hooks` - Rejeu des hooks d'un projet + +**Dependances sortantes** : `queries-index` (getAllProjectsDataForExport, deleteAllEnvironmentForProject, deleteAllRepositoryForProject), hooks (project.upsert pour replay) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Routes admin uniquement +- Le bulk action itere sur les projets avec une limite de parallelisme + (`parallelBulkLimit` dans la config). Attention aux performances +- Le replay-hooks re-execute toute la chaine de hooks pour un projet +- L'export data peut generer de gros payloads + +**Estimation** : 2 jours + +--- + +### Plugin : gitlab (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 8 | +| **Score** | 6.7 | +| **Sprint** | S9 | +| **Dev** | B | + +**Encapsulation** : Wrapper les 3 classes (GitlabApi, GitlabZoneApi, +GitlabProjectApi) dans un `GitlabModule` NestJS. + +**Dependances sortantes (plugins)** : vault (VaultProjectApi pour secrets) +**Dependances entrantes (plugins)** : argocd, nexus, sonarqube + +**Points d'attention** : +- Plugin le plus complexe (13 fichiers source, classe de 570 lignes) +- Utilise `@gitbeaker/core` et `@gitbeaker/rest` (dependances externes) +- Systeme de "pending commits" (commits bufferises puis envoyes en batch) +- Necessite vault encapsule avant + +**Estimation encapsulation** : 2-3 jours +**Estimation reecriture complete** : 7-10 jours (ulterieur) + +--- + +### Plugin : harbor (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 3 | +| **Score** | 5.6 | +| **Sprint** | S9-S10 | +| **Dev** | A | + +**Encapsulation** : Wrapper dans un `HarborModule` NestJS. + +**Dependances sortantes (plugins)** : keycloak (getProjectGroupPath), vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- API generee (fichier `api/Api.ts`) +- Gestion des quotas (parsing de tailles humaines "100MB", "1.2GB") +- Necessite keycloak et vault encapsules avant + +**Estimation encapsulation** : 1.5 jours + +--- + +### Plugin : nexus (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 3 | +| **Score** | 5.1 | +| **Sprint** | S10 | +| **Dev** | B | + +**Encapsulation** : Wrapper dans un `NexusModule` NestJS. + +**Dependances sortantes (plugins)** : vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Gestion Maven + NPM (repos group/hosted/snapshot) +- Configuration riche (activation par techno) +- Necessite vault encapsule avant + +**Estimation encapsulation** : 1.5 jours + +--- + +## Vague 5 - Plugins terminaux + finalisation (S10-S12) + +**Objectif** : Encapsuler les derniers plugins. Demarrer le nettoyage. + +**Livrable fin S11** : Tous les plugins encapsules en modules NestJS + +### Plugin : argocd (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 5 | +| **Score** | 5.3 | +| **Sprint** | S10-S11 | +| **Dev** | A | + +**Encapsulation** : Wrapper dans un `ArgoCDModule` NestJS. + +**Dependances sortantes (plugins)** : gitlab (GitlabProjectApi), keycloak (KeycloakProjectApi), vault (VaultProjectApi, VaultZoneApi) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Le plugin le plus couple (depend de 3 autres plugins) +- Generation YAML complexe (value files pour Helm) +- Necessite gitlab, keycloak et vault encapsules avant +- Dernier plugin avec dependances, donc naturellement parmi les derniers + +**Estimation encapsulation** : 2 jours + +--- + +### Plugin : sonarqube (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 2 | +| **Score** | 4.2 | +| **Sprint** | S11 | +| **Dev** | B | + +**Encapsulation** : Wrapper dans un `SonarQubeModule` NestJS. + +**Dependances sortantes (plugins)** : gitlab (GitlabProjectApi pour CI vars), keycloak (getProjectGroupPath), vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Depend de 3 autres plugins (comme argocd) +- Interaction avec l'API SonarQube + ecriture de variables CI dans GitLab +- Necessite gitlab, keycloak et vault encapsules avant + +**Estimation encapsulation** : 1.5 jours + +--- + +## Vague 6 - Nettoyage (S11-S12) + +- Suppression du reverse proxy Nginx (tout pointe vers `server-nestjs`) +- Suppression du code `apps/server` +- Renommage `server-nestjs` en `server` +- Suppression de `@cpn-console/hooks` (remplace par EventEmitter NestJS) +- Suppression de `ts-rest` cote server (remplace par decorateurs NestJS) +- Mise a jour de tous les `docker-compose` et scripts PNPM/Bash +- Reecriture progressive des plugins (au-dela de l'encapsulation) -- planifiee + dans des iterations futures + +--- + +## Graphe de dependances + +### Graphe actuel (genere par Madge) + +![Graphe des dependances de `server`](./graph-dependances-server-avant.png) + +Commande : + +``` +npx madge --image dependency-graph.png --extensions js,ts apps/server/src +``` + +### Graphe cible des modules NestJS + +```mermaid +flowchart TD + subgraph Transverse["Couche 0 : Socle transverse"] + AuthGuard["AuthService + @AuthUser()"] + ExceptionFilter["GlobalExceptionFilter"] + PermissionsGuard["UserGuard + ProjectGuard"] + ValidationPipe["ValidationPipe global"] + EventEmitter["EventEmitterModule"] + end + + subgraph Infrastructure["Couche 1-2 : Infrastructure (FAIT)"] + ConfigModule["ConfigurationModule"] + LoggerModule["LoggerModule"] + DatabaseService["DatabaseService (Prisma)"] + AppService["AppService"] + FastifyService["FastifyService"] + end + + subgraph Feuilles["Vague 1 : Modules feuilles"] + SystemModule["SystemModule (health, version)"] + SettingsModule["SystemSettingsModule"] + ConfigPluginModule["SystemConfigModule"] + AdminTokenModule["AdminTokenModule"] + UserTokensModule["UserTokensModule"] + LogModule["LogModule"] + end + + subgraph Fondations["Vague 2 : Fondations metier"] + ServiceMonitorModule["ServiceMonitorModule"] + UserModule["UserModule"] + AdminRoleModule["AdminRoleModule"] + StageModule["StageModule"] + ZoneModule["ZoneModule"] + end + + subgraph Couples["Vague 3 : Modules couples"] + ClusterModule["ClusterModule"] + EnvironmentModule["EnvironmentModule"] + RepositoryModule["RepositoryModule"] + ProjectRoleModule["ProjectRoleModule"] + ProjectMemberModule["ProjectMemberModule"] + ProjectServiceModule["ProjectServiceModule"] + ServiceChainModule["ServiceChainModule"] + end + + subgraph ProjectGroup["Vague 4 : Project (decoupe)"] + ProjectCoreModule["ProjectCoreModule"] + ProjectSecretsModule["ProjectSecretsModule"] + ProjectBulkModule["ProjectBulkModule"] + end + + subgraph Plugins["Vague 3-5 : Plugins NestJS"] + VaultModule["VaultModule"] + KeycloakModule["KeycloakModule"] + GitlabModule["GitlabModule"] + HarborModule["HarborModule"] + NexusModule["NexusModule"] + ArgoCDModule["ArgoCDModule"] + SonarQubeModule["SonarQubeModule"] + end + + %% Dependances infrastructure + Feuilles --> AuthGuard + Feuilles --> ExceptionFilter + Feuilles --> ValidationPipe + Fondations --> EventEmitter + Fondations --> PermissionsGuard + + %% Dependances inter-modules metier + ClusterModule --> StageModule + ProjectMemberModule -.-> UserModule + + %% Dependances plugins + KeycloakModule --> VaultModule + GitlabModule --> VaultModule + HarborModule --> KeycloakModule + HarborModule --> VaultModule + NexusModule --> VaultModule + ArgoCDModule --> GitlabModule + ArgoCDModule --> KeycloakModule + ArgoCDModule --> VaultModule + SonarQubeModule --> GitlabModule + SonarQubeModule --> KeycloakModule + SonarQubeModule --> VaultModule + + %% Evenements + ProjectCoreModule -.->|evenements| Plugins + ZoneModule -.->|evenements| Plugins + ClusterModule -.->|evenements| Plugins +``` + +### Dependances directes inter-modules (code actuel) + +| Module source | Importe depuis | Fonction | Resolution NestJS | +|---------------|----------------|----------|-------------------| +| cluster | stage/business | `linkClusterToStages` | ClusterModule importe StageModule | +| project-member | user/business | `logViaSession` | Resolu par AuthService / AuthUser (Couche 0) | +| utils/controller | user/business | `logViaSession`, `logViaToken` | Resolu par AuthService / AuthUser (Couche 0) | +| utils/hook-wrapper | project-service/business | `ConfigRecords`, `dbToObj` | Types partages ou ProjectServiceModule exporte | + +### Dependances inter-plugins + +``` +vault (feuille -- aucune dependance plugin) + | + +--- keycloak (vault en runtime) + | | + | +--- harbor (keycloak + vault) + | +--- argocd (gitlab + keycloak + vault) + | +--- sonarqube (gitlab + keycloak + vault) + | + +--- gitlab (vault) + | + +--- nexus (vault, gitlab en devDependency) + +--- argocd (gitlab + keycloak + vault) + +--- sonarqube (gitlab + keycloak + vault) +``` + +--- + +## Modules transverses + +### Middlewares globaux + +| Middleware | Statut | Module NestJS | +|-----------|--------|---------------| +| CORS | A configurer | AppService (Fastify) | +| Helmet (securite) | FAIT | AppService | +| Cookie + Session | FAIT | AppService | +| Body parser | FAIT | Fastify (natif) | +| Compression | A evaluer | Fastify compression plugin | +| Logging (requetes) | FAIT | LoggerModule (nestjs-pino) | +| Configuration (.env) | FAIT | ConfigurationModule | +| Validation (DTOs) | A CREER | ValidationPipe global (Couche 0) | + +### Services partages + +| Service | Statut | Module NestJS | +|---------|--------|---------------| +| PrismaService (DB) | FAIT | DatabaseService | +| LoggerService | FAIT | LoggerModule | +| ConfigService (env) | FAIT | ConfigurationModule | +| HttpClientService | STUB (vide) | A implementer dans InfrastructureModule | +| ServerService (ts-rest) | STUB | A supprimer (abandon ts-rest) | + +--- + +## Checklist de cartographie + +### Sprint 1 + +- [x] Installer outils d'analyse (Madge) +- [x] Generer graphe de dependances automatique +- [x] Lister toutes les routes (75 routes identifiees) +- [x] Identifier les modules metier principaux (16 domaines + 7 plugins) +- [x] Premiere version de cette cartographie + +### Sprint 2 + +- [x] Affiner les dependances inter-modules +- [x] Calculer les scores de priorisation (28 unites scorees) +- [x] Definir l'ordre de modularisation (6 vagues, bottom-up) +- [x] Documenter les points d'attention par module +- [x] Finaliser les estimations de charge +- [x] Completer ce fichier +- [ ] Valider l'ordre de modularisation avec l'equipe + +--- + +## Estimations de charge par vague + +| Vague | Sprint | Routes | Modules | Charge estimee (j/p) | Objectif | +|-------|--------|--------|---------|----------------------|----------| +| V0 | S3 | 0 | 5 transverses | 5 j/p | Socle (Guards, Filters, Pipes, EventEmitter) | +| V1 | S3-S4 | 13 | 6 | 5 j/p | Quick wins, validation pattern | +| V2 | S5-S6 | 21 | 5 | 9 j/p | Fondations metier, premiers hooks | +| V3 | S7-S8 | 32 | 7 metier + 2 plugins | 17 j/p | Modules couples, vault + keycloak | +| V4 | S9-S10 | 9 | 3 metier + 3 plugins | 13 j/p | Project decoupe, gitlab + harbor + nexus | +| V5 | S10-S11 | 0 | 2 plugins | 3.5 j/p | argocd + sonarqube | +| V6 | S11-S12 | 0 | 0 | 5 j/p | Nettoyage, suppression server | +| **Total** | **S3-S12** | **75** | **28** | **~57.5 j/p** | | + +Avec 2 developpeurs dedies : **~29 jours calendaires** (soit ~6 sprints d'une +semaine), ce qui s'inscrit dans la fenetre S3-S12 (10 sprints disponibles). +La marge de 4 sprints absorbe les imprevisibles, la montee en competence +NestJS, et les eventuelles sessions de pair programming sur les modules +complexes. + +--- + +## Propagation de la modularisation + +Mettre a jour dans ce document : +- Le chemin parcouru par un module migre (quel environnement, a quel + moment/jalon, et avec quel resultat succes/echec) +- Tracer les manques de tests d'integration ou E2E sur un module suivant les + retours effectues +- Expliciter les regles de suivi dans la metrologie de la migration d'un module + (avant/apres, payload et requetes concernees, etc.) + +--- + +## Contact + +**Responsable cartographie** : @stephane.trebel +**Questions** : #backend-modularisation sur Mattermost + +--- + +**Version** : 2.0 +**Derniere mise a jour** : 2026-02-23 +**Prochaine revision majeure** : Fin S4 (premier bilan post-Vague 1) diff --git a/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md b/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md new file mode 100644 index 0000000000..fe899d0bdf --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md @@ -0,0 +1,214 @@ +# État de la modularisation Backend → NestJS + +> 📋 **Ce fichier est mis à jour en temps réel** +> Dernière mise à jour : **2026-07-23** + +--- + +## 🎯 Progression globale + +![Progress](https://progress-bar.dev/7/?title=modularisation&width=400) + +**~60%** complété (16/18 modules métier migrés, 45/75 routes) + +--- + +## 📊 Vue d'ensemble + +| Statut | Nombre de modules | % du total | +|--------|-------------------|------------| +| ✅ Migré | 16 | ~89% | +| 🚧 En cours | 0 | 0% | +| 📅 Planifié | 2 | ~11% | +| ⏳ En attente de cartographie | 0 | 0% | + +--- + +## ✅ Modules migrés + +### ServiceChain (OpenCDS) — migré le 2026-04-09 + +Module proxy HTTP vers l'API externe OpenCDS (gestion des chaînes de service +réseau). Migré en avance de phase par rapport au planning initial (prévu V3/S8), +profitant de son isolement complet vis-à-vis du reste du codebase. + +- **Routes** : 5 (`/api/v1/service-chains/...`) +- **Auth** : `AuthService` unifie `x-dso-token` et bearer JWT Keycloak +- **Nginx** : Bascule effectuée dans `nginx-strangler/conf.d/routing.conf` +- **Tests** : Controller + Service couverts (Vitest) +- **Différences avec le legacy** : + - 403 systématique si permissions insuffisantes (le legacy renvoyait `[]` sur GET /) + - Validation UUID sur les paramètres d'URL (400 si format invalide) + +| Méthode | Route | Permission | +|---------|-------|------------| +| `GET` | `/api/v1/service-chains` | `ListSystem` | +| `GET` | `/api/v1/service-chains/:id` | `ListSystem` | +| `GET` | `/api/v1/service-chains/:id/flows` | `ListSystem` | +| `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | +| `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | + +### Infrastructure transverse déployée + +En support de cette migration, les éléments d'infrastructure suivants ont été +créés : + +- **AuthModule** (`infrastructure/auth/`) : `AuthService` (validation token + SHA256 via Prisma + bearer JWT Keycloak), `DsoTokenModule`, + `KeycloakJwtModule` et décorateur `@AuthUser()` +- **PermissionModule** (`infrastructure/permission/`) : `UserModule` + (`UserGuard`, `UserService`, `UserPolicy`) + `ProjectModule` + (`ProjectGuard`, `ProjectLoaderService`, `ProjectService`, `ProjectPolicy`) +- **Nginx strangler** : Reverse proxy configuré pour router les routes migrées + vers server-nestjs, le reste vers le legacy +- **Docker** : Build order corrigé (shared avant server-nestjs) + +> **Limitation connue** : l'auth par session Keycloak et le flux bearer JWT ont +> désormais la même entrée publique (`AuthService.authenticate(request, ...)`), +> mais les usages côté contrôleurs restent encore à homogénéiser au fil des +> modules migrés. + +--- + +## 🚧 En cours de modularisation + +### Aucune modularisation en cours + +--- + +## 📅 Modules planifiés + +> Ces informations seront affinées après la cartographie (fin S2) + +### Sprint 3-4 (27 janvier - 9 février) +- **Module** : Auth +- **Responsable** : @stephane.trebel +- **Routes** : ~5 +- **Criticité** : 🔴 Haute +- **Statut** : 📅 Planifié + +### Sprint 5-6 (10-23 février) +- **Module** : Users +- **Responsable** : TBD +- **Routes** : ~10 +- **Criticité** : 🔴 Haute +- **Statut** : 📅 Planifié + +### Sprint 7-8 (24 février - 9 mars) +- **Module** : À définir (selon cartographie) +- **Responsable** : TBD +- **Routes** : TBD +- **Statut** : 📅 Planifié + +### Sprint 9-10 (10-23 mars) +- **Module** : À définir (selon cartographie) +- **Responsable** : TBD +- **Routes** : TBD +- **Statut** : 📅 Planifié + +### Sprint 11-12 (24 mars - 6 avril) +- **Modules** : Finalisation + cleanup +- **Responsable** : Équipe +- **Objectif** : Suppression du legacy + +--- + +## 🚫 Zones en feature freeze + +### Aucune zone gelée actuellement + +**Règle** : Quand un module passe en status 🚧 (En cours), il est automatiquement en feature freeze. + +**Que faire si vous devez travailler sur une zone gelée ?** +1. Vérifier l'urgence réelle (Critique / Importante / Normale) +2. Consulter la [matrice de décision](04-COMMUNICATION-PLAN.md#matrice-de-décision-pour-développeurs) +3. Coordonner avec le responsable de la modularisation +4. Annoncer sur #backend-modularisation + +--- + +## 📈 Métriques de qualité + +### Couverture de tests + +| Type | Initial | Actuel | Objectif | +|------|---------|--------|----------| +| E2E Playwright | 33% | 33% | 50% | +| Unitaires Vitest | ? | ? | 70% | +| Tests de contrat | 0% | 0% | 100% | + +### Routes par statut + +- **Total** : ~75 routes métier +- **Migrés** : 45 (~60%) +- **En cours** : 0 (0%) +- **Restants** : ~30 (~40%) + +--- + +## 🗓️ Dates clés + +| Date | Événement | +|------|-----------| +| 07/01/2026 | Début du projet (S1) | +| 26/01/2026 | Fin de la cartographie (S2) | +| 27/01/2026 | Début modularisation Auth (S3) | +| 09/02/2026 | Fin modularisation Auth (S4) - 20% complété | +| 09/03/2026 | Point mi-parcours - 60% complété | +| 26/03/2026 | Migration ServiceChain (OpenCDS) finalisée — 1er module métier migré | +| 06/04/2026 | Fin de modularisation - 100% complété | + +--- + +## 📞 Contacts & Communication + +### Canaux +- **Slack** : #backend-modularisation +- **Meeting hebdo** : Vendredi 16h (30min) +- **Lead technique** : @stephane.trebel + +### Besoin d'aide ? +- **Question technique** : Poser sur #backend-modularisation +- **Conflit de développement** : Contacter @stephane.trebel +- **Urgence production** : Suivre la procédure de rollback (voir [01-TECHNICAL-SETUP.md](01-TECHNICAL-SETUP.md)) + +--- + +## 📚 Documentation + +- [00-OVERVIEW.md](00-OVERVIEW.md) - Vue d'ensemble du projet +- [01-TECHNICAL-SETUP.md](01-TECHNICAL-SETUP.md) - Configuration technique +- [02-modularisation-STRATEGY.md](02-modularisation-STRATEGY.md) - Stratégie de modularisation +- [03-PLANNING.md](03-PLANNING.md) - Planning détaillé 12 sprints +- [04-COMMUNICATION-PLAN.md](04-COMMUNICATION-PLAN.md) - Plan de communication +- [05-TESTING-STRATEGY.md](05-TESTING-STRATEGY.md) - Stratégie de tests +- [modularisation_MAP.md](modularisation_MAP.md) - Cartographie des modules + +--- + +## 🔄 Historique des changements + +### 2026-04-09 +- ✅ Migration du module **ServiceChain (OpenCDS)** — 5 routes, proxy HTTP vers API externe +- ✅ Création de l'**AuthModule** (infrastructure/auth/) : auth par token `x-dso-token` +- ✅ Configuration **nginx-strangler** pour les routes service-chain +- ✅ Fix Docker : build order shared → server-nestjs +- ✅ Mise à jour de ce fichier de suivi + +### 2026-01-07 (S1) +- ✅ Création du fichier de suivi +- ✅ Initialisation de la documentation +- ✅ Kickoff du projet + +--- + +## 📝 Notes + +> Ce fichier sera mis à jour régulièrement (minimum une fois par sprint, idéalement en temps réel). +> Toute l'équipe peut consulter ce fichier pour connaître l'état actuel de la modularisation. +> Pour proposer des modifications ou signaler des incohérences, utiliser #backend-modularisation. + +--- + +**Version du fichier** : 1.1 +**Responsable de mise à jour** : Lead technique backend diff --git a/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md b/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md new file mode 100644 index 0000000000..811aa04f0c --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md @@ -0,0 +1,120 @@ +# Documentation de Modularisation du Backend de Console (apps/server) vers NestJS + +Ce dossier contient toute la documentation nécessaire pour mener à bien la modularisation du backend Node.js vers NestJS. + +## 📁 Structure de la documentation + +### Documents principaux (à lire dans l'ordre) + +1. **00-OVERVIEW.md** - Vue d'ensemble du projet de modularisation + - Objectifs et contexte + - Stratégie "Strangler Pattern" + - Principes directeurs + - Métriques de succès + +2. **01-TECHNICAL-SETUP.md** - Configuration technique détaillée + - Configuration Nginx (reverse proxy) qui va progressivement "étrangler" `server` au profit de `server-nestjs` + - Gestion Prisma/BDD partagée entre `server` et `server-nestjs` + - Déploiement `docker compose` + +3. **02-MODULARISATION-STRATEGY.md** - Stratégie et méthodologie + - Modularisation par slices verticales + - Cartographie et priorisation + - Gestion singletons → Injection de dépendances + - Méthodologie en 7 étapes par module + - Architecture Decision Records (ADR) + +4. **03-PLANNING.md** - Planning détaillé sur les prochains sprints + - Découpage sprint par sprint + - Sprints 1-2 : Fondations + - Sprints 3-4 : Modules transverses de `server` (logger, etc.) + - Sprints 5-6 : Modules applicatifs (Zones, Clusters, etc.) + - Sprints 7-10 : Modules métier (les fameux plugins) + - Sprints 11-12 : Finalisation + - Métriques et KPIs + +5. **04-COMMUNICATION-PLAN.md** - Plan de communication équipe + - Canaux de communication (Mattermost, fichiers, réunions) + - Gestion des zones en modularisation + - Modèles d'annonces concernant la modularisation + - Matrice de décision pour développeurs + - Procédure en cas de conflit + +6. **05-TESTING-STRATEGY.md** - Stratégie de tests + - Tests de contrat (nouveaux, critiques) + - Tests E2E Playwright + - Tests unitaires Vitest + - Tests d'intégration + - Workflow de tests pendant modularisation + - Intégration CI/CD + +### Fichiers vivants (mis à jour régulièrement) + +7. **MODULARISATION_STATUT.md** - Suivi en temps réel de l'avancement + - Progression globale + - Modules migrés + - Modules en cours + - Zones en "feature freeze" du fait de leur modularisation en cours 🚧 + - Dates clés + +8. **MODULARISATION_CARTOGRAPHIE.md** - Cartographie des modules + - Liste de tous les modules avec métadonnées + - Graphe de dépendances + - Scoring et priorisation des modules + - Estimation de charge + +## 🚀 Par où commencer ? + +### Si vous êtes le lead technique de la modularisation + +1. Lire **00-OVERVIEW.md** pour la vue d'ensemble +2. Consulter **01-TECHNICAL-SETUP.md** pour préparer l'infrastructure +3. Étudier **02-MODULARISATION-STRATEGY.md** pour la méthodologie +4. Planifier avec **03-PLANNING.md** +5. Organiser la communication avec **04-COMMUNICATION-PLAN.md** +6. Mettre en place les tests avec **05-TESTING-STRATEGY.md** +7. Compléter **MODULARISATION_MAP.md** lors de la cartographie (S1-S2) +8. Maintenir **MODULARISATION_STATUT.md** à jour tout au long de la modularisation + +### Si vous êtes développeur dans l'équipe + +1. Lire **00-OVERVIEW.md** pour comprendre le projet +2. Consulter **MODULARISATION_STATUT.md** AVANT de traiter un ticket, pour éviter de toucher à une zone en cours de modularisation +3. Suivre le canal Mattermost #backend-modularisation qui sera créé dans cet objectif +4. Respecter les zones en modularisation (🚧) +5. Consulter **04-COMMUNICATION-PLAN.md** en cas de conflit + +## 📊 Contexte du projet + +- **Durée** : 3 mois (12 semaines) +- **Équipe** : 5 développeurs +- **Endpoints** : ~100 +- **Approche** : Strangler Fig Pattern (modularisation progressive) +- **Stack cible** : NestJS + Prisma + Keycloak OIDC + +## 🎯 Objectifs + +- ✅ 100% des endpoints migrés sur NestJS +- ✅ 0 régression fonctionnelle +- ✅ Couverture tests E2E ≥ 50% +- ✅ Équipe autonome sur NestJS +- ✅ Documentation complète + +## 📞 Contact + +- **Lead technique** : @stephane.trebel +- **Canal Mattermost** : #backend-modularisation +- **Réunion hebdo** : Jeudi 10h (30min) + +## 📚 Ressources externes + +- [NestJS Documentation](https://docs.nestjs.com/) +- [Prisma Documentation](https://www.prisma.io/docs) +- [Playwright Documentation](https://playwright.dev/) +- [Vitest Documentation](https://vitest.dev/) + +--- + +**Bonne modularisation ! 🚀** + +*Version 1.0 - Dernière mise à jour : 2026-01-07* diff --git a/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png b/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png new file mode 100644 index 0000000000..569dd60e19 Binary files /dev/null and b/.jjconflict-side-1/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png differ diff --git a/.jjconflict-side-1/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md b/.jjconflict-side-1/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md new file mode 100644 index 0000000000..b7ba2c4258 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md @@ -0,0 +1,271 @@ +# Plan : Nginx "étrangleur" pour la transition `server` → `server-nestjs` + +> Ticket de référence : [#1885](https://github.com/cloud-pi-native/console/issues/1885) +> PR en cours : [#1951](https://github.com/cloud-pi-native/console/pull/1951) — branche `add-server-nextjs-to-local-dev` +> Dernière mise à jour : 2026-02-26 + +--- + +## Contexte et état des lieux + +| Élément | État | +|---|---| +| `server` (Fastify) | Port `8080` interne, exposé `4001` sur l'hôte | +| Nginx actuel | Embarqué dans l'image `client`, proxifie `/api` + `/swagger-ui` vers `server:8080` — upstream changé vers `nginx-strangler:8080` | +| `server-nestjs` | Dockerisé (port `3001`), ajouté dans tous les docker-compose | +| `nginx-strangler` | Service dédié créé dans `apps/nginx-strangler/`, fallback total sur `server` legacy, exposé sur le port `4000` pour se "substituer" à `server` | +| Envs couverts | Local dev, docker-compose (dev/ci/integ) | + +--- + +## Architecture cible + +``` +[Navigateur / API client] + │ + ▼ +┌────────────────────┐ +│ Client (8080) │ ← nginx embarqué dans l'image client +│ nginx "front" │ SPA + proxy /api → nginx-strangler +└────────┬───────────┘ + │ proxy_pass http://nginx-strangler:8080 + ▼ +┌────────────────────┐ +│ nginx-strangler │ ← service dédié (port 8080 interne, non-root) +│ (routing as-code) │ +└──────┬─────────────┘ + │ + ├── /api/[routes migrées] ──→ server-nestjs:3001 + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +**Principe clé** : le nginx client existant reste inchangé dans son rôle SPA, mais son upstream `api` est redirigé vers `nginx-strangler` plutôt que directement vers `server`. Le `nginx-strangler` devient l'unique point de routage des appels API. + +--- + +## Décisions structurantes + +| Décision | Choix | +|---|---| +| Emplacement du nginx-strangler | Répertoire `apps/nginx-strangler/` à la racine du dépôt | +| Port interne de `nginx-strangler` | `8080` (non-root, Trivy DS002) | +| Port interne de `server-nestjs` | `3001` (distinct du legacy sur `8080`) | +| Envs PAX / MinInt | Couverts via docker-compose uniquement dans ce ticket | +| Config nginx | `envsubst` pour substituer les upstreams selon l'environnement | +| Rollback | Commenter une `location` dans `routing.conf` + `nginx -s reload` | +| Sécurité image nginx | `USER nginx`, `HEALTHCHECK`, `chown` sur les répertoires système nginx | +| Sécurité image server-nestjs | `USER node`, `HEALTHCHECK` sur le stage `prod` (Trivy DS026) | + +--- + +## Découvertes en cours de réalisation + +- Le nginx existant dans l'image `client` proxifiait directement vers `server:8080` — upstream changé vers `nginx-strangler:8080` +- `server-nestjs/src/main.ts` utilisait `process.env.PORT ?? 0` au lieu de `ConfigurationService.port` — corrigé +- Le schéma Prisma de `server-nestjs` est multi-fichiers dans `src/prisma/schema/` — `prisma generate` doit pointer sur le dossier, pas sur `schema.prisma` +- En CI (`job-lint.yml`), `nginx -t` échoue si `apt-get update -qq` n'est pas exécuté avant l'install, et si les chemins `pid`/`error_log`/`access_log` ne sont pas patchés vers `/tmp/` (runner non-root) +- En CI (`job-playwright.yml`), les images `server-nestjs:ci` et `nginx-strangler:ci` doivent être buildées localement avant le `docker compose up --no-build` (même pattern que `opencds-mockoon`) + +--- + +## Phase 1 — Socle nginx-strangler ✅ + +### Tâche 1.1 : Créer `apps/nginx-strangler/` ✅ + +``` +apps/nginx-strangler/ +├── Dockerfile # image nginx:1.27-alpine, USER nginx, HEALTHCHECK, EXPOSE 8080, envsubst entrypoint +├── nginx.conf # directives globales (worker_processes, logs, include conf.d/) +├── conf.d/ +│ ├── routing.conf # fichier "vivant" des routes (commenté par vague de migration) +│ └── routing.local.conf.example # template documentaire pour le mode local natif +└── README.md # procédures opérationnelles (déploiement, rollback, ajout de route, dev local) +``` + +### Tâche 1.2 : Contenu initial de `routing.conf` ✅ + +Le fichier utilise des variables d'environnement substituées via `envsubst` pour permettre la réutilisation de la même image dans tous les contextes (Docker, local natif) : + +```nginx +upstream server-legacy { + server ${LEGACY_UPSTREAM}; +} + +upstream server-nestjs { + server ${NESTJS_UPSTREAM}; +} + +server { + listen 8080; + + # ── Routes migrées vers NestJS ────────────────────────────────────────── + # Format : # [Vague X - Module] AAAA-MM-JJ + # (vide au démarrage — toutes les routes sont en fallback sur server-legacy) + + # ── Fallback : tout le reste vers le server legacy ─────────────────────── + location /api/ { + proxy_pass http://server-legacy; + ... + } + + location /swagger-ui { + proxy_pass http://server-legacy; + ... + } +} +``` + +Exemple de bascule future (à ajouter au fil des vagues) : +```nginx +# [Vague 1 - system/health] 2026-03-XX +location = /api/v1/system/health { + proxy_pass http://server-nestjs; + ... +} +``` + +--- + +## Phase 2 — Dockerisation de `server-nestjs` ✅ + +### Tâche 2.1 : Fix du port dans `main.ts` ✅ + +`apps/server-nestjs/src/main.ts` utilisait `process.env.PORT ?? 0` — câblé sur `ConfigurationService.port`. + +### Tâche 2.2 : Créer `apps/server-nestjs/Dockerfile` ✅ + +Multi-stage : `base` → `deps` → `build` → `prod`. `prisma generate --schema=src/prisma/schema` (dossier multi-fichiers). `USER node`, `HEALTHCHECK`, `EXPOSE 3001`. + +### Tâche 2.3 : `apps/server-nestjs/.env.docker-example` ✅ + +`SERVER_PORT=3001` présent. + +--- + +## Phase 3 — Mise à jour des docker-compose ✅ + +### Tâche 3.1 : `docker/docker-compose.dev.yml` ✅ + +Services `server-nestjs` (avec Docker Compose Watch) et `nginx-strangler` ajoutés. `client` dépend de `nginx-strangler`. + +### Tâche 3.2 : `docker/docker-compose.prod.yml` ✅ + +Idem sans Watch. Bug `depends_on` vide sur `opencds-mockoon` corrigé. + +### Tâche 3.3 : `docker/docker-compose.ci.yml` ✅ + +Idem prod. + +### Tâche 3.4 : `docker/docker-compose.integ.yml` ✅ + +Idem dev (avec Watch) + volumes kubeconfig. + +### Tâche 3.5 : `docker/docker-compose.local.yml` ✅ + +`nginx-strangler` ajouté avec `host.docker.internal`, port `8082:8080`, `extra_hosts: host-gateway`. + +### Tâche 3.6 : `apps/client/nginx/default.docker.conf` ✅ + +Upstream `server:8080` → `nginx-strangler:8080`. + +--- + +## Phase 4 — Dev local (pnpm dev natif) ✅ + +### Tâche 4.1 : Template de config pour le mode local ✅ + +`apps/nginx-strangler/conf.d/routing.local.conf.example` créé avec `host.docker.internal` comme valeurs d'exemple. + +### Tâche 4.2 : Documentation ✅ + +- `ENVIRONMENTS.md` : refonte complète avec section Strangler Fig, `server-nestjs` mentionné partout, sous-section dédiée au geste manuel `SERVER_PORT=8082` +- `apps/client/.env-example` : note sur `SERVER_PORT=8082` pour passer par le nginx-strangler en local + +Variables par contexte : + +| Contexte | `LEGACY_UPSTREAM` | `NESTJS_UPSTREAM` | +|---|---|---| +| Docker (dev/prod/ci/integ) | `server:8080` | `server-nestjs:3001` | +| Local (pnpm dev) | `127.0.0.1:4001` | `127.0.0.1:3001` | +| PAX / MinInt | À adapter selon la topologie réseau | + +--- + +## Phase 5 — Validation et outillage + +### Tâche 5.1 : Validation `nginx -t` dans le CI ✅ + +Étape ajoutée dans `.github/workflows/job-lint.yml` : +- `apt-get update -qq` + install `nginx gettext-base` +- `envsubst` sur `routing.conf` +- `sed` pour patcher `user`, `pid`, `error_log`, `access_log` vers des chemins accessibles en non-root +- `nginx -t -c /tmp/nginx-test/nginx.conf` + +### Tâche 5.2 : Smoke test ❌ (à faire) + +Vérifier dans les tests Playwright que `/api/v1/system/health` répond correctement en passant par le nginx-strangler (et non plus directement par `server`). + +### Tâche 5.3 : Procédure de rollback documentée ✅ + +Documentée dans `apps/nginx-strangler/README.md` : + +```bash +# 1. Commenter la location concernée dans routing.conf +# 2. Recharger nginx sans downtime +docker compose exec nginx-strangler nginx -s reload +# 3. Vérifier les logs +docker compose logs -f nginx-strangler | grep /api/v1/xxx +``` + +### Tâche 5.4 (ajout) : Build des images CI dans job-playwright ✅ + +`docker buildx build -t dso-console/server-nestjs:ci` et `nginx-strangler:ci` ajoutés dans `.github/workflows/job-playwright.yml` avant le `docker compose up --no-build`. + +--- + +## Critères d'acceptation + +- [x] Le service `nginx-strangler` démarre correctement dans tous les docker-compose +- [x] Les appels `/api/*` continuent de fonctionner sans régression (fallback total sur `server`) +- [x] `nginx -t` passe en CI +- [x] La config est documentée et le format de bascule des routes est clairement défini +- [x] La procédure de rollback est documentée +- [x] Le dev local (`pnpm dev`) fonctionne avec le nginx-strangler en Docker +- [ ] Smoke test Playwright sur `/api/v1/system/health` via nginx-strangler + +--- + +## Ce qui reste à faire (post-merge PR #1951) + +1. **Tâche 5.2** : Smoke test Playwright (optionnel pour la PR courante, recommandé avant Vague 1) +2. **Vague 1** : Basculer les premiers modules dans `apps/nginx-strangler/conf.d/routing.conf` : + - `system` (health, config, settings) + - `admin-token` + - `user/tokens` + - `log` + - Référence : [`MODULARISATION-CARTOGRAPHIE.md`](../Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) + +--- + +## Séquençage initial et estimation de charge + +``` +Phase 1 : Créer apps/nginx-strangler/ (structure + routing.conf) Dev A ~0.5j ✅ +Phase 2 : Dockeriser server-nestjs (Dockerfile + port) Dev B ~1j ✅ + + Modifier upstream nginx client Dev A ~0.5h ✅ +Phase 3 : Mettre à jour tous les docker-compose Dev A+B ~1j ✅ +Phase 4 : Config locale (envsubst + doc) Dev A ~0.5j ✅ +Phase 5 : CI + smoke test + rollback doc Dev B ~0.5j 🔄 (5.1 ✅, 5.2 ❌, 5.3 ✅) +───────────────────────────────────────────────────────────────────── +Total estimé ~3.5 j/p +``` + +--- + +## Liens + +- Ticket GitHub : [#1885](https://github.com/cloud-pi-native/console/issues/1885) +- PR : [#1951](https://github.com/cloud-pi-native/console/pull/1951) +- Documentation Nginx strangler dans la stratégie de modularisation : [`01-MODULARISATION-STRATEGIE.md`](../Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md) +- Cartographie des modules (vagues de migration) : [`MODULARISATION-CARTOGRAPHIE.md`](../Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) +- Suivi de l'avancement : [`MODULARISATION-STATUT.md`](../Modularisation-de-console-server/MODULARISATION-STATUT.md) diff --git a/.jjconflict-side-1/apps/server-nestjs/eslint.config.mjs b/.jjconflict-side-1/apps/server-nestjs/eslint.config.mjs new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/eslint.config.mjs @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-1/apps/server-nestjs/nest-cli.json b/.jjconflict-side-1/apps/server-nestjs/nest-cli.json new file mode 100644 index 0000000000..f9aa683b1a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/package.json b/.jjconflict-side-1/apps/server-nestjs/package.json new file mode 100644 index 0000000000..79126d32e2 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/package.json @@ -0,0 +1,111 @@ +{ + "name": "@cpn-console/server-nestjs", + "version": "9.22.1", + "private": true, + "description": "", + "author": "", + "license": "UNLICENSED", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "scripts": { + "prebuild": "pnpm i --frozen-lockfile", + "build": "nest build", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev --name dso", + "db:reset": "prisma migrate reset", + "format": "eslint ./ --fix", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "start": "nest start", + "start:debug": "nest start --debug --watch", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "pretest": "pnpm run db:generate", + "test": "vitest run", + "test:watch": "vitest", + "pretest:cov": "pnpm run db:generate", + "test:cov": "vitest run --coverage", + "test:debug": "vitest --inspect" + }, + "dependencies": { + "@cpn-console/argocd-plugin": "workspace:^", + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/harbor-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/nexus-plugin": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/sonarqube-plugin": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/requester-utils": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "@keycloak/keycloak-admin-client": "catalog:runtime", + "@kubernetes-models/argo-cd": "catalog:runtime", + "@nestjs/cache-manager": "catalog:runtime", + "@nestjs/common": "catalog:runtime", + "@nestjs/config": "catalog:runtime", + "@nestjs/core": "catalog:runtime", + "@nestjs/event-emitter": "catalog:runtime", + "@nestjs/jwt": "catalog:runtime", + "@nestjs/platform-fastify": "catalog:runtime", + "@nestjs/schedule": "catalog:runtime", + "@nestjs/swagger": "catalog:runtime", + "@nestjs/terminus": "catalog:runtime", + "@opentelemetry/api": "catalog:otel", + "@opentelemetry/auto-instrumentations-node": "catalog:otel", + "@opentelemetry/exporter-metrics-otlp-proto": "catalog:otel", + "@opentelemetry/exporter-trace-otlp-proto": "catalog:otel", + "@opentelemetry/instrumentation-nestjs-core": "catalog:otel", + "@opentelemetry/instrumentation-pino": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-node": "catalog:otel", + "@prisma/client": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@ts-rest/fastify": "catalog:runtime", + "@ts-rest/open-api": "catalog:runtime", + "cache-manager": "catalog:runtime", + "date-fns": "catalog:runtime", + "fastify": "catalog:runtime", + "fastify-keycloak-adapter": "catalog:runtime", + "json-2-csv": "catalog:runtime", + "mustache": "catalog:runtime", + "nestjs-pino": "catalog:runtime", + "prisma": "catalog:runtime", + "reflect-metadata": "catalog:runtime", + "rxjs": "catalog:runtime", + "undici": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime", + "yaml": "catalog:runtime", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@eslint/eslintrc": "catalog:tools", + "@eslint/js": "catalog:tools", + "@faker-js/faker": "catalog:test", + "@nestjs/cli": "catalog:build", + "@nestjs/schematics": "catalog:build", + "@nestjs/testing": "catalog:build", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "eslint": "catalog:tools", + "globals": "catalog:test", + "msw": "catalog:test", + "pino-pretty": "catalog:tools", + "rimraf": "catalog:tools", + "ts-patch": "catalog:build", + "typescript": "catalog:build", + "typescript-eslint": "catalog:tools", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/prisma.config.ts b/.jjconflict-side-1/apps/server-nestjs/prisma.config.ts new file mode 100644 index 0000000000..ff5aaf9186 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/prisma.config.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' +import { defineConfig } from 'prisma/config' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +export default defineConfig({ + schema: path.join('src', 'prisma', 'schema'), + migrations: { + path: path.join('src', 'prisma', 'migrations'), + }, +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/__mocks__/prisma.ts b/.jjconflict-side-1/apps/server-nestjs/src/__mocks__/prisma.ts new file mode 100644 index 0000000000..2a871fd476 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/__mocks__/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-side-1/apps/server-nestjs/src/config/argocd.config.ts b/.jjconflict-side-1/apps/server-nestjs/src/config/argocd.config.ts new file mode 100644 index 0000000000..a7f1ecc094 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/config/argocd.config.ts @@ -0,0 +1,25 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const argocdFeatureSchema = z.object({ + ARGO_NAMESPACE: z.string().default('argocd'), + ARGOCD_URL: z.string().url(), + ARGOCD_INTERNAL_URL: z.string().url().optional(), + ARGOCD_EXTRA_REPOSITORIES: z.string().min(1), + DSO_ENV_CHART_VERSION: z.string().default('dso-env-1.6.0'), + DSO_NS_CHART_VERSION: z.string().default('dso-ns-1.1.5'), + VAULT__DEPLOY_VAULT_CONNECTION_IN_NS: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform(raw => ({ + namespace: raw.ARGO_NAMESPACE, + url: raw.ARGOCD_URL, + internalUrl: raw.ARGOCD_INTERNAL_URL, + extraRepositories: raw.ARGOCD_EXTRA_REPOSITORIES, + dsoEnvChartVersion: raw.DSO_ENV_CHART_VERSION, + dsoNsChartVersion: raw.DSO_NS_CHART_VERSION, + vaultDeployVaultConnectionInNs: raw.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS, + internalOrPublicUrl: raw.ARGOCD_INTERNAL_URL ?? raw.ARGOCD_URL, + probeUrl: new URL('/api/version', raw.ARGOCD_URL).toString(), +})) + +export const argocdConfigFactory = registerAs('argocd', () => argocdFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/config/base.config.ts b/.jjconflict-side-1/apps/server-nestjs/src/config/base.config.ts new file mode 100644 index 0000000000..38f05bac01 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/config/base.config.ts @@ -0,0 +1,50 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, truthySchema } from './config.utils' + +const baseFeatureSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).optional(), + INTEGRATION: flag(truthySchema.default('false')), + CI: flag(truthySchema.default('false')), + DEV_SETUP: flag(truthySchema.default('false')), + DOCKER: flag(truthySchema.default('false')), + SERVER_HOST: z.string().default('localhost'), + SERVER_PORT: z.string().transform(Number).default('0'), + APP_VERSION: z.string().optional().default('unknown'), + DB_URL: z.string().url().optional(), + SESSION_SECRET: z.string().min(32).optional(), + CONTACT_EMAIL: z.string().email().default('cloudpinative-relations@interieur.gouv.fr'), + MOCK_PLUGINS: flag(truthySchema.default('false')), + PROJECTS_ROOT_DIR: z.string().min(1, 'PROJECTS_ROOT_DIR is required'), + PLUGINS_DIR: z.string().default('/plugins'), + HTTP_PROXY: z.string().url().optional(), + HTTPS_PROXY: z.string().url().optional(), +}).transform((raw) => { + const nodeEnv = raw.NODE_ENV ?? 'production' + return { + nodeEnv, + isTest: raw.NODE_ENV === 'test', + isDev: raw.NODE_ENV === 'development', + isCI: raw.CI, + isProd: nodeEnv === 'production', + integration: raw.INTEGRATION, + ci: raw.CI, + devSetup: raw.DEV_SETUP, + docker: raw.DOCKER, + serverHost: raw.SERVER_HOST, + serverPort: raw.SERVER_PORT, + appVersion: raw.NODE_ENV === 'production' ? raw.APP_VERSION : 'dev', + dbUrl: raw.DB_URL, + sessionSecret: raw.SESSION_SECRET, + contactEmail: raw.CONTACT_EMAIL, + mockPlugins: raw.MOCK_PLUGINS, + projectsRootDir: raw.PROJECTS_ROOT_DIR, + pluginsDir: raw.PLUGINS_DIR, + httpProxy: raw.HTTP_PROXY, + httpsProxy: raw.HTTPS_PROXY, + } +}) + +export type BaseConfig = z.infer + +export const baseConfigFactory = registerAs('base', () => baseFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/config/config.utils.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/config/config.utils.spec.ts new file mode 100644 index 0000000000..155c025349 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/config/config.utils.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import z from 'zod' +import { csv, flag, truthySchema } from './config.utils' + +describe('config.utils', () => { + describe('flag', () => { + it('coerces true/1 (case-insensitive) to true', () => { + const schema = flag(truthySchema.default('false')) + expect(schema.parse('true')).toBe(true) + expect(schema.parse('1')).toBe(true) + expect(schema.parse('TRUE')).toBe(true) + }) + + it('coerces false/0 to false', () => { + const schema = flag(truthySchema.default('true')) + expect(schema.parse('false')).toBe(false) + expect(schema.parse('0')).toBe(false) + }) + + it('falls back to the schema default when missing', () => { + expect(flag(truthySchema.default('false')).parse(undefined)).toBe(false) + expect(flag(truthySchema.default('true')).parse(undefined)).toBe(true) + }) + }) + + describe('csv', () => { + const schema = csv(z.string()) + + it('splits, trims and drops empty parts', () => { + expect(schema.parse('a, b ,,c')).toEqual(['a', 'b', 'c']) + }) + + it('maps missing/empty to an empty array', () => { + expect(schema.parse(undefined)).toEqual([]) + expect(schema.parse('')).toEqual([]) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/config/config.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/config/config.utils.ts new file mode 100644 index 0000000000..f06cde73f8 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/config/config.utils.ts @@ -0,0 +1,27 @@ +import z from 'zod' + +// Shared truthy enum for flag(): 'true'/'false'/'1'/'0'. +export const truthySchema = z.enum(['true', 'false', '1', '0']) + +// Boolean flag. Strictly takes a z string schema (e.g. truthySchema.default('true')). +// Lowercases, then coerces 'true'/'1' -> true, 'false'/'0' -> false. +// Missing value falls back to the schema's default. +export function flag(schema: z.ZodType) { + return z + .preprocess( + val => (typeof val === 'string' ? val.toLowerCase() : val), + schema, + ) + .transform(val => val === 'true' || val === '1') +} + +// Comma-separated string -> array of schema-validated, trimmed non-empty parts. +// Empty/missing -> []. Strictly takes a z string schema (applied per element). +export function csv>(schema: T) { + return z + .preprocess( + val => (typeof val === 'string' ? val : val ?? ''), + z.string().transform(value => value.split(',').map(part => part.trim()).filter(Boolean)), + ) + .pipe(z.array(schema)) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/config/gitlab.config.ts b/.jjconflict-side-1/apps/server-nestjs/src/config/gitlab.config.ts new file mode 100644 index 0000000000..d376279d4f --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/config/gitlab.config.ts @@ -0,0 +1,28 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const gitlabFeatureSchema = z.object({ + GITLAB_TOKEN: z.string().min(1), + GITLAB_URL: z.string().url(), + GITLAB_INTERNAL_URL: z.string().url().optional(), + GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS: z.coerce.number().int().positive().default(180), + GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + GITLAB__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), + PROJECTS_ROOT_DIR: z.string().min(1), +}).transform((raw) => { + const urlBase = raw.GITLAB_INTERNAL_URL ?? raw.GITLAB_URL + return { + token: raw.GITLAB_TOKEN, + url: raw.GITLAB_URL, + internalUrl: raw.GITLAB_INTERNAL_URL, + secretExposeInternalUrl: raw.GITLAB__SECRET_EXPOSE_INTERNAL_URL, + mirrorTokenExpirationDays: raw.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS, + mirrorTokenRotationThresholdDays: raw.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS, + projectRootDir: raw.PROJECTS_ROOT_DIR, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/-/health', urlBase).toString(), + } +}) + +export const gitlabConfigFactory = registerAs('gitlab', () => gitlabFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/config/harbor.config.ts b/.jjconflict-side-1/apps/server-nestjs/src/config/harbor.config.ts new file mode 100644 index 0000000000..3e4d093272 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/config/harbor.config.ts @@ -0,0 +1,34 @@ +import type { HarborConfig } from '../modules/registry/harbor.module-definition' +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const harborFeatureSchema = z.object({ + HARBOR_URL: z.string().url(), + HARBOR_INTERNAL_URL: z.string().url().optional(), + HARBOR_ADMIN: z.string().min(1), + HARBOR_ADMIN_PASSWORD: z.string().min(1), + HARBOR_RULE_TEMPLATE: z.string().optional(), + HARBOR_RULE_COUNT: z.string().optional(), + HARBOR_RETENTION_CRON: z.string().default('0 22 2 * * *'), + HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + HARBOR_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform((raw) => { + const urlBase = raw.HARBOR_INTERNAL_URL ?? raw.HARBOR_URL + return { + url: raw.HARBOR_URL, + internalUrl: raw.HARBOR_INTERNAL_URL, + admin: raw.HARBOR_ADMIN, + adminPassword: raw.HARBOR_ADMIN_PASSWORD, + ruleTemplate: raw.HARBOR_RULE_TEMPLATE, + ruleCount: raw.HARBOR_RULE_COUNT, + retentionCron: raw.HARBOR_RETENTION_CRON, + robotRotationThresholdDays: raw.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.HARBOR_PROJECT_SLUG_CACHE_TTL_MS, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/api/v2.0/ping', urlBase).toString(), + } +}) + +export type HarborAppConfig = HarborConfig + +export const harborConfigFactory = registerAs('harbor', () => harborFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/config/keycloak.config.ts b/.jjconflict-side-1/apps/server-nestjs/src/config/keycloak.config.ts new file mode 100644 index 0000000000..576cd9c996 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/config/keycloak.config.ts @@ -0,0 +1,50 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const keycloakFeatureSchema = z.object({ + KEYCLOAK_PROTOCOL: z.enum(['http', 'https']).default('https'), + KEYCLOAK_DOMAIN: z.string().min(1), + KEYCLOAK_PUBLIC_PROTOCOL: z.enum(['http', 'https']).default('https'), + KEYCLOAK_PUBLIC_DOMAIN: z.string().min(1), + KEYCLOAK_REALM: z.string().min(1), + KEYCLOAK_CLIENT_ID: z.string().min(1), + KEYCLOAK_CLIENT_SECRET: z.string().min(1), + KEYCLOAK_ADMIN: z.string().min(1), + KEYCLOAK_ADMIN_PASSWORD: z.string().min(1), + KEYCLOAK_ADMIN_CLIENT_ID: z.string().default('admin-cli'), + KEYCLOAK_REDIRECT_URI: z.string().url(), + KEYCLOAK_JWKS_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + KEYCLOAK_JWKS_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), + KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + ADMIN_KC_USER_ID: z.preprocess( + value => (typeof value === 'string' ? value.split(',').map(part => part.trim()).filter(Boolean) : value), + z.array(z.string()).default([]), + ), +}).transform((raw) => { + const keycloakUrl = `${raw.KEYCLOAK_PROTOCOL}://${raw.KEYCLOAK_DOMAIN}` + const keycloakRealmUrl = `${keycloakUrl}/realms/${raw.KEYCLOAK_REALM}` + return { + protocol: raw.KEYCLOAK_PROTOCOL, + domain: raw.KEYCLOAK_DOMAIN, + publicProtocol: raw.KEYCLOAK_PUBLIC_PROTOCOL, + publicDomain: raw.KEYCLOAK_PUBLIC_DOMAIN, + realm: raw.KEYCLOAK_REALM, + clientId: raw.KEYCLOAK_CLIENT_ID, + clientSecret: raw.KEYCLOAK_CLIENT_SECRET, + admin: raw.KEYCLOAK_ADMIN, + adminPassword: raw.KEYCLOAK_ADMIN_PASSWORD, + adminClientId: raw.KEYCLOAK_ADMIN_CLIENT_ID, + redirectUri: raw.KEYCLOAK_REDIRECT_URI, + jwksCacheTtlMs: raw.KEYCLOAK_JWKS_CACHE_TTL_MS, + jwksTimeoutMs: raw.KEYCLOAK_JWKS_TIMEOUT_MS, + openidConfigurationCacheTtlMs: raw.KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS, + adminKcUserId: raw.ADMIN_KC_USER_ID, + url: keycloakUrl, + realmUrl: keycloakRealmUrl, + openidConfigurationUrl: `${keycloakRealmUrl}/.well-known/openid-configuration`, + } +}) + +export type KeycloakConfig = z.infer + +export const keycloakConfigFactory = registerAs('keycloak', () => keycloakFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/config/nexus.config.ts b/.jjconflict-side-1/apps/server-nestjs/src/config/nexus.config.ts new file mode 100644 index 0000000000..cfdc23000b --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/config/nexus.config.ts @@ -0,0 +1,24 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const nexusFeatureSchema = z.object({ + NEXUS_URL: z.string().url(), + NEXUS_INTERNAL_URL: z.string().url().optional(), + NEXUS_ADMIN: z.string().min(1), + NEXUS_ADMIN_PASSWORD: z.string().min(1), + NEXUS__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + const urlBase = raw.NEXUS_INTERNAL_URL ?? raw.NEXUS_URL + return { + url: raw.NEXUS_URL, + internalUrl: raw.NEXUS_INTERNAL_URL, + admin: raw.NEXUS_ADMIN, + adminPassword: raw.NEXUS_ADMIN_PASSWORD, + secretExposeInternalUrl: raw.NEXUS__SECRET_EXPOSE_INTERNAL_URL, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/service/rest/v1/status', urlBase).toString(), + } +}) + +export const nexusConfigFactory = registerAs('nexus', () => nexusFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/config/opencds.config.ts b/.jjconflict-side-1/apps/server-nestjs/src/config/opencds.config.ts new file mode 100644 index 0000000000..fb1da23b96 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/config/opencds.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const opencdsFeatureSchema = z.object({ + OPENCDS_URL: z.string().url(), + OPENCDS_INTERNAL_URL: z.string().url().optional(), + OPENCDS_API_TOKEN: z.string().min(1), + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + const probeBase = raw.OPENCDS_INTERNAL_URL ?? raw.OPENCDS_URL + return { + url: raw.OPENCDS_URL, + internalUrl: raw.OPENCDS_INTERNAL_URL, + probeUrl: new URL('/api/v1/health', probeBase).toString(), + apiToken: raw.OPENCDS_API_TOKEN, + apiTlsRejectUnauthorized: raw.OPENCDS_API_TLS_REJECT_UNAUTHORIZED, + } +}) + +export const openCdsConfigFactory = registerAs('opencds', () => opencdsFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/config/registry.config.ts b/.jjconflict-side-1/apps/server-nestjs/src/config/registry.config.ts new file mode 100644 index 0000000000..c30ef12c50 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/config/registry.config.ts @@ -0,0 +1,30 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const registryFeatureSchema = z.object({ + REGISTRY_URL: z.string().url(), + REGISTRY_INTERNAL_URL: z.string().url().optional(), + REGISTRY_ADMIN: z.string().min(1), + REGISTRY_ADMIN_PASSWORD: z.string().min(1), + REGISTRY_RULE_TEMPLATE: z.string().min(1), + REGISTRY_RULE_COUNT: z.coerce.number().int().nonnegative().optional(), + REGISTRY_RETENTION_CRON: z.string().default('0 22 2 * * *'), + REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + REGISTRY_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform((raw) => { + const urlBase = raw.REGISTRY_INTERNAL_URL ?? raw.REGISTRY_URL + return { + url: raw.REGISTRY_URL, + internalUrl: raw.REGISTRY_INTERNAL_URL, + admin: raw.REGISTRY_ADMIN, + adminPassword: raw.REGISTRY_ADMIN_PASSWORD, + ruleTemplate: raw.REGISTRY_RULE_TEMPLATE, + ruleCount: raw.REGISTRY_RULE_COUNT, + retentionCron: raw.REGISTRY_RETENTION_CRON, + robotRotationThresholdDays: raw.REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.REGISTRY_PROJECT_SLUG_CACHE_TTL_MS, + internalOrPublicUrl: urlBase, + } +}) + +export const registryConfigFactory = registerAs('registry', () => registryFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/config/sonarqube.config.ts b/.jjconflict-side-1/apps/server-nestjs/src/config/sonarqube.config.ts new file mode 100644 index 0000000000..78fdd00939 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/config/sonarqube.config.ts @@ -0,0 +1,19 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const sonarqubeFeatureSchema = z.object({ + SONARQUBE_URL: z.string().url(), + SONARQUBE_INTERNAL_URL: z.string().url().optional(), + SONAR_API_TOKEN: z.string().min(1), +}).transform((raw) => { + const urlBase = raw.SONARQUBE_INTERNAL_URL ?? raw.SONARQUBE_URL + return { + url: raw.SONARQUBE_URL, + internalUrl: raw.SONARQUBE_INTERNAL_URL, + apiToken: raw.SONAR_API_TOKEN, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/api/system/health', urlBase).toString(), + } +}) + +export const sonarqubeConfigFactory = registerAs('sonarqube', () => sonarqubeFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/config/vault.config.ts b/.jjconflict-side-1/apps/server-nestjs/src/config/vault.config.ts new file mode 100644 index 0000000000..83aca3cb62 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/config/vault.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const vaultFeatureSchema = z.object({ + VAULT_TOKEN: z.string().min(1), + VAULT_URL: z.string().url(), + VAULT_INTERNAL_URL: z.string().url().optional(), + VAULT_KV_NAME: z.string().default('forge-dso'), +}).transform((raw) => { + const urlBase = raw.VAULT_INTERNAL_URL ?? raw.VAULT_URL + return { + token: raw.VAULT_TOKEN, + url: raw.VAULT_URL, + internalUrl: raw.VAULT_INTERNAL_URL, + kvName: raw.VAULT_KV_NAME, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/v1/sys/health', urlBase).toString(), + } +}) + +export const vaultConfigFactory = registerAs('vault', () => vaultFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/main.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/main.module.ts new file mode 100644 index 0000000000..87f8cb8180 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/main.module.ts @@ -0,0 +1,53 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { ScheduleModule } from '@nestjs/schedule' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from './config/base.config' +import { DeploymentModule } from './modules/deployment/deployment.module' +import { EnvironmentModule } from './modules/environment/environment.module' +import { HealthzModule } from './modules/healthz/healthz.module' +import { InfrastructureModule } from './modules/infrastructure/infrastructure.module' +import { KeycloakModule } from './modules/keycloak/keycloak.module' +import { LogModule } from './modules/log/log.module' +import { ProjectBulkModule } from './modules/project-bulk/project-bulk.module' +import { ProjectHooksModule } from './modules/project-hooks/project-hooks.module' +import { ProjectMembersModule } from './modules/project-members/project-members.module' +import { ProjectRolesModule } from './modules/project-roles/project-roles.module' +import { ProjectSecretsModule } from './modules/project-secrets/project-secrets.module' +import { ProjectServicesModule } from './modules/project-services/project-services.module' +import { ProjectModule } from './modules/project/project.module' +import { ServiceChainModule } from './modules/service-chain/service-chain.module' +import { SystemSettingsModule } from './modules/system-settings/system-settings.module' +import { VersionModule } from './modules/version/version.module' +import { getDotenvPaths } from './utils/dotenv.utils' + +@Module({ + imports: [ + ConfigModule.forRoot({ + envFilePath: getDotenvPaths(), + isGlobal: true, + load: [baseConfigFactory], + }), + TerminusModule.forRoot(), + InfrastructureModule, + HealthzModule, + KeycloakModule, + ScheduleModule.forRoot(), + SystemSettingsModule, + ServiceChainModule, + ProjectModule, + ProjectHooksModule, + ProjectSecretsModule, + ProjectServicesModule, + ProjectBulkModule, + ProjectMembersModule, + ProjectRolesModule, + LogModule, + DeploymentModule, + EnvironmentModule, + VersionModule, + ], + controllers: [], + providers: [], +}) +export class MainModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/main.ts b/.jjconflict-side-1/apps/server-nestjs/src/main.ts new file mode 100644 index 0000000000..600f7232f7 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/main.ts @@ -0,0 +1,66 @@ +import type { NestFastifyApplication } from '@nestjs/platform-fastify' +import type { BaseConfig } from './modules/infrastructure/config/base.config' +import { NestFactory } from '@nestjs/core' +import { FastifyAdapter } from '@nestjs/platform-fastify' +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' +import { NodeSDK, resources } from '@opentelemetry/sdk-node' +import { Logger } from 'nestjs-pino' +import { MainModule } from './main.module' +import { BASE_CONFIG } from './modules/infrastructure/config/base.module' + +const SERVICE_NAME = 'console-pi-native-console' + +const telemetry = new NodeSDK({ + traceExporter: new OTLPTraceExporter({}), + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + }), + instrumentations: [ + getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-nestjs-core': { enabled: true }, + '@opentelemetry/instrumentation-pino': { enabled: true }, + }), + ], + resource: resources.resourceFromAttributes({ + 'service.name': SERVICE_NAME, + }), +}) + +async function bootstrap() { + telemetry.start() + + const app = await NestFactory.create(MainModule, new FastifyAdapter(), { + bufferLogs: true, + }) + + app.useLogger(app.get(Logger)) + app.flushLogs() + app.enableShutdownHooks() + app.getHttpAdapter().getInstance().addHook('onClose', async () => { + await telemetry.shutdown() + }) + + const config = app.get(BASE_CONFIG) + + // Setup swagger-ui route + const swaggerConfig = new DocumentBuilder() + .setTitle('Console Cloud π Native') + .setDescription('Description de l\' API Cloud π Native') + .setVersion('1.0') + .addTag('console') + .build() + const documentFactory = () => SwaggerModule.createDocument(app, swaggerConfig) + SwaggerModule.setup('swagger-ui-server-nestjs', app, documentFactory) + + await app.listen(config.serverPort, config.serverHost) + + const serverUrl = await app.getUrl() + const logger = app.get(Logger) + logger.log(`NestJS server running on: ${serverUrl}`) +} + +void bootstrap() diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts new file mode 100644 index 0000000000..d3103a2e31 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts @@ -0,0 +1,104 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + helmValuesFiles: true, + deployRevision: true, + deployPath: true, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + deployments: { + select: { + id: true, + name: true, + autosync: true, + environment: { + select: { + id: true, + name: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + deploymentSources: { + select: { + type: true, + path: true, + targetRevision: true, + helmValuesFiles: true, + repository: { + select: { + id: true, + internalRepoName: true, + }, + }, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class ArgoCDDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts new file mode 100644 index 0000000000..f79fc15424 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts @@ -0,0 +1,23 @@ +import type { ArgocdConfig } from './argocd.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ARGOCD_CONFIG } from './argocd.module-definition' + +@Injectable() +export class ArgoCDHealthService { + constructor( + @Inject(ARGOCD_CONFIG) private readonly argocdConfig: ArgocdConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.argocdConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts new file mode 100644 index 0000000000..7388fc6dd8 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts @@ -0,0 +1,169 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Injectable } from '@nestjs/common' +import { + DEFAULT_DSO_ENV_CHART_VERSION, + DEFAULT_DSO_NS_CHART_VERSION, + DSO_ENV_CHART_VERSION_PLUGIN_KEY, + DSO_NS_CHART_VERSION_PLUGIN_KEY, + EXTRA_REPOSITORIES_PLUGIN_KEY, + PLATFORM_ADMIN_GROUP_PATH, + PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + PLATFORM_READONLY_GROUP_PATH, + PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY, + PLATFORM_SECURITY_GROUP_PATH, + PROJECT_ADMIN_GROUP_PATH_SUFFIX, + PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_READONLY_GROUP_PATH_SUFFIX, + PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_GROUP_PATH_SUFFIX, +} from './argocd.constants' + +@Injectable() +export class ArgoCDPluginService { + infos(): ServiceInfos { + const extraRepositoriesDesc = 'appproject.spec.sourceRepos supplémentaires, séparés par des virgules (https://a.com/repo.git,https://b.com/' + + return { + name: 'argocd', + to: ({ zones, project }) => zones.map(z => ({ + to: new URL(`applications?showFavorites=false&proj=&sync=&health=&namespace=&cluster=&labels=&search=${project.slug}`, z.argocdUrl).toString(), + title: `ArgoCD ${z.label}`, + })), + title: 'ArgoCD', + imgSrc: '/img/argocd.svg', + description: 'ArgoCD est un outil déclaratif de livraison continue GitOps pour Kubernetes', + config: { + global: [{ + key: EXTRA_REPOSITORIES_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }, { + key: PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Admin Group Path', + value: PLATFORM_ADMIN_GROUP_PATH, + description: 'Chemin du groupe administrateur de plateforme', + }, { + key: PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Readonly Group Path', + value: PLATFORM_READONLY_GROUP_PATH, + description: 'Chemin du groupe lecture seule de plateforme', + }, { + key: 'platformSecurityGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Security Group Path', + value: PLATFORM_SECURITY_GROUP_PATH, + description: 'Chemin du groupe sécurité de plateforme', + }, { + key: PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Admin Group Path Suffix', + value: PROJECT_ADMIN_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe administrateur de projet', + }, { + key: PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project DevOps Group Path Suffix', + value: PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe devops de projet', + }, { + key: PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Developer Group Path Suffix', + value: PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe développeur de projet', + }, { + key: PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Readonly Group Path Suffix', + value: PROJECT_READONLY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe lecture seule de projet', + }, { + key: 'projectSecurityGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Security Group Path Suffix', + value: PROJECT_SECURITY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe sécurité de projet', + }, { + key: DSO_ENV_CHART_VERSION_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Env Chart Version', + value: DEFAULT_DSO_ENV_CHART_VERSION, + description: 'Version du chart Helm dso-env', + placeholder: DEFAULT_DSO_ENV_CHART_VERSION, + }, { + key: DSO_NS_CHART_VERSION_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Namespace Chart Version', + value: DEFAULT_DSO_NS_CHART_VERSION, + description: 'Version du chart Helm dso-ns', + placeholder: DEFAULT_DSO_NS_CHART_VERSION, + }], + project: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts new file mode 100644 index 0000000000..65d58f8955 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts @@ -0,0 +1,94 @@ +import type { ProjectWithDetails } from './argocd-datastore.service' + +import { faker } from '@faker-js/faker' + +export function makeProjectDeploymentSource( + overrides: Partial = {}, +): ProjectWithDetails['deployments'][number]['deploymentSources'][number] { + return { + type: 'git', + path: '.', + targetRevision: 'HEAD', + helmValuesFiles: '', + repository: makeProjectRepository(), + ...overrides, + } satisfies ProjectWithDetails['deployments'][number]['deploymentSources'][number] +} + +export function makeProjectDeployment( + overrides: Partial = {}, +): ProjectWithDetails['deployments'][number] { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + environment: makeProjectEnvironment(), + autosync: true, + deploymentSources: [ + { + type: 'git', + path: '.', + targetRevision: 'HEAD', + helmValuesFiles: '', + repository: makeProjectRepository(), + }, + ], + ...overrides, + } satisfies ProjectWithDetails['deployments'][number] +} + +export function makeProjectRepository( + overrides: Partial = {}, +): ProjectWithDetails['repositories'][number] { + return { + id: faker.string.uuid(), + internalRepoName: faker.word.noun(), + isInfra: false, + deployRevision: 'HEAD', + deployPath: '.', + helmValuesFiles: '', + ...overrides, + } satisfies ProjectWithDetails['repositories'][number] +} + +export function makeCluster( + overrides: Partial = {}, +): ProjectWithDetails['environments'][number]['cluster'] { + return { + id: faker.string.uuid(), + label: faker.word.noun(), + zone: { + slug: faker.word.noun(), + }, + ...overrides, + } satisfies ProjectWithDetails['environments'][number]['cluster'] +} + +export function makeProjectEnvironment( + overrides: Partial = {}, +): ProjectWithDetails['environments'][number] { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + cluster: makeCluster(), + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + ...overrides, + } satisfies ProjectWithDetails['environments'][number] +} + +export function makeProjectWithDetails( + overrides: Partial = {}, +): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(faker.word.words({ count: 2 })).toLowerCase(), + name: faker.word.noun(), + environments: [makeProjectEnvironment()], + repositories: [makeProjectRepository()], + plugins: [], + deployments: [makeProjectDeployment()], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.constants.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.constants.ts new file mode 100644 index 0000000000..d856cf98bf --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.constants.ts @@ -0,0 +1,30 @@ +// ArgoCD plugin constants +export const PLUGIN_NAME = 'argocd' + +// Platform-level group paths +export const CONSOLE_ADMIN_GROUP_PATH = '/console/admin' +export const PLATFORM_ADMIN_GROUP_PATH = '/console/admin' +export const PLATFORM_READONLY_GROUP_PATH = '/console/readonly' +export const PLATFORM_SECURITY_GROUP_PATH = '/console/security' + +// Suffix for project group path +export const PROJECT_ADMIN_GROUP_PATH_SUFFIX = '/console/admin' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const PROJECT_READONLY_GROUP_PATH_SUFFIX = '/console/readonly' +export const PROJECT_SECURITY_GROUP_PATH_SUFFIX = '/console/security' + +// Default chart versions +export const DEFAULT_DSO_ENV_CHART_VERSION = 'dso-env-1.6.0' +export const DEFAULT_DSO_NS_CHART_VERSION = 'dso-ns-1.1.5' + +// Plugin configuration keys +export const EXTRA_REPOSITORIES_PLUGIN_KEY = 'extraRepositories' +export const PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY = 'platformAdminGroupPath' +export const PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY = 'platformReadonlyGroupPath' +export const PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectAdminGroupPathSuffix' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevopsGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevelopperGroupPathSuffix' +export const PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReadonlyGroupPathSuffix' +export const DSO_ENV_CHART_VERSION_PLUGIN_KEY = 'dsoEnvChartVersion' +export const DSO_NS_CHART_VERSION_PLUGIN_KEY = 'dsoNsChartVersion' diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts new file mode 100644 index 0000000000..a134c72f4d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts @@ -0,0 +1,19 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface ArgocdConfig { + namespace: string + url: string + internalUrl: string + extraRepositories: string + dsoEnvChartVersion: string + dsoNsChartVersion: string + vaultDeployVaultConnectionInNs: boolean + internalOrPublicUrl?: string +} + +export interface ArgoCDModuleOptions extends ArgocdConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('argocd') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as ARGOCD_CONFIG } diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts new file mode 100644 index 0000000000..91447152ec --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts @@ -0,0 +1,26 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { argocdConfigFactory } from '../../config/argocd.config' +import { ArgoCDModule } from './argocd.module' +import { ArgoCDService } from './argocd.service' + +describe('argocdModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ArgoCDService when USE_GITLAB=false', async () => { + vi.stubEnv('USE_GITLAB', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule.forRoot(argocdConfigFactory.asProvider()), 'USE_GITLAB')], + }).compile() + expect(() => module.get(ArgoCDService)).toThrow() + }) + + it('omits ArgoCDService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule.forRoot(argocdConfigFactory.asProvider()), 'USE_VAULT')], + }).compile() + expect(() => module.get(ArgoCDService)).toThrow() + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.module.ts new file mode 100644 index 0000000000..71979a5023 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { argocdConfigFactory } from '../../config/argocd.config' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' +import { GitlabModule } from '../gitlab/gitlab.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { ArgoCDHealthService } from './argocd-health.service' +import { ArgoCDPluginService } from './argocd-plugin.service' +import { ArgoCDService } from './argocd.service' + +@Module({ + imports: [DatabaseModule, ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), TerminusModule, VaultModule, ConfigModule.forFeature(argocdConfigFactory), ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + providers: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService, ArgoCDDatastoreService], + exports: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService], +}) +export class ArgoCDModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts new file mode 100644 index 0000000000..2c0511c4bf --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -0,0 +1,541 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { ArgocdConfig } from './argocd.module-definition' +import { generateNamespaceName } from '@cpn-console/shared' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { stringify } from 'yaml' +import { GitlabClientService } from '../gitlab/gitlab-client.service' +import { makeCommitAction, makeProjectSchema, makeRepositoryTreeSchema } from '../gitlab/gitlab-testing.utils' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { makeProjectDeployment, makeProjectDeploymentSource, makeProjectEnvironment, makeProjectRepository, makeProjectWithDetails } from './argocd-testing.utils' +import { ARGOCD_CONFIG } from './argocd.module-definition' +import { ArgoCDService } from './argocd.service' + +describe('argoCDService', () => { + let service: ArgoCDService + let datastore: DeepMockProxy + let gitlab: DeepMockProxy + let vault: DeepMockProxy + let argocdConfig: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + datastore = mockDeep() + gitlab = mockDeep() + vault = mockDeep() + argocdConfig = mockDeep({ + namespace: 'argocd', + url: 'https://argocd.internal', + internalUrl: undefined, + extraRepositories: 'repo3', + dsoEnvChartVersion: 'dso-env-1.6.0', + dsoNsChartVersion: 'dso-ns-1.1.5', + vaultDeployVaultConnectionInNs: false, + internalOrPublicUrl: undefined, + }) + baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep({ + url: 'https://vault.internal', + kvName: 'kv', + internalUrl: undefined, + }) + + const module = await Test.createTestingModule({ + providers: [ + ArgoCDService, + { provide: ArgoCDDatastoreService, useValue: datastore }, + { provide: ARGOCD_CONFIG, useValue: argocdConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: GitlabClientService, useValue: gitlab }, + { provide: VaultClientService, useValue: vault }, + ], + }).compile() + + service = module.get(ArgoCDService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('should return OK result when ensureProject succeeds', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [], + repositories: [], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) + gitlab.maybeCreateCommit.mockResolvedValue(undefined) + + const result = await service.handleUpsert(mockProject) + + expect(result).toEqual({ + argocd: expect.objectContaining({ + status: 'OK', + message: 'Up to date', + executionTime: expect.any(Number), + }), + }) + }) + + it('should return KO result when ensureProject throws', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const error = new Error('GitLab unreachable') + gitlab.getOrCreateInfraGroupRepo.mockRejectedValue(error) + + const result = await service.handleUpsert(mockProject) + + expect(result).toEqual({ + argocd: expect.objectContaining({ + status: 'KO', + message: 'GitLab unreachable', + executionTime: expect.any(Number), + error, + }), + }) + }) + }) + + it('should sync project environments', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + makeProjectEnvironment({ name: 'prod', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + // Verify Gitlab calls + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'dev', + 'dso/environment.id': mockProject.environments[0].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-dev-6293', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/dev/values.yaml', + roGroup: '/project-1/console/dev/RO', + rwGroup: '/project-1/console/dev/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockProject.environments[0].id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + id: mockProject.repositories[0].id, + name: 'infra-repo', + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: 'HEAD', + path: '.', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/dev/values.yaml', + }, + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'prod', + 'dso/environment.id': mockProject.environments[1].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-prod-c626', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/prod/values.yaml', + roGroup: '/project-1/console/prod/RO', + rwGroup: '/project-1/console/prod/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockProject.environments[1].id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + id: mockProject.repositories[0].id, + name: 'infra-repo', + repoURL: 'https://gitlab.internal/group/project-1/infra-repo.git', + targetRevision: 'HEAD', + path: '.', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/prod/values.yaml', + }, + ]), + ) + + expect(gitlab.listFiles).toHaveBeenCalledWith(infraProject, { + path: 'Project 1/', + recursive: true, + }) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(2) + }) + + it('should delete values file when an environment is removed', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([ + makeRepositoryTreeSchema( + { name: 'values.yaml', path: 'Project 1/cluster-1/dev/values.yaml' }, + ), + makeRepositoryTreeSchema( + { name: 'values.yaml', path: 'Project 1/cluster-1/prod/values.yaml' }, + ), + ]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + expect.objectContaining({ + action: 'create', + filePath: 'Project 1/cluster-1/dev/values.yaml', + }), + { + action: 'delete', + filePath: 'Project 1/cluster-1/prod/values.yaml', + }, + ]), + ) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(1) + }) + + it('should not commit when there is no diff', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + + gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) + + await expect(service.handleCron()).resolves.not.toThrow() + + expect(gitlab.maybeCreateCommit).not.toHaveBeenCalled() + }) + + it('should sync project deployments', async () => { + const mockRepo = makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true }) + const mockDevEnv = makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }) + const mockProdEnv = makeProjectEnvironment({ name: 'prod', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }) + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + mockDevEnv, + mockProdEnv, + ], + repositories: [mockRepo], + plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], + deployments: [ + makeProjectDeployment({ + environment: mockDevEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: 'dev' })], + }), + makeProjectDeployment({ + environment: mockDevEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: '1.0.0', path: 'service-1' })], + }), + makeProjectDeployment({ + environment: mockProdEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: 'prod' })], + }), + ], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + // Verify Gitlab calls + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'dev', + 'dso/environment.id': mockProject.environments[0].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-dev-6293', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/dev/values.yaml', + roGroup: '/project-1/console/dev/RO', + rwGroup: '/project-1/console/dev/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockDevEnv.id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + name: 'infra-repo', + id: mockRepo.id, + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: 'dev', + path: '.', + valueFiles: [], + }, + { + name: 'infra-repo', + id: mockRepo.id, + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: '1.0.0', + path: 'service-1', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/dev/values.yaml', + }, + ]), + ) + + expect(gitlab.listFiles).toHaveBeenCalledWith(infraProject, { + path: 'Project 1/', + recursive: true, + }) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(4) // 2 environments + 2 deployments + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.service.ts new file mode 100644 index 0000000000..cbfa862247 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -0,0 +1,662 @@ +import type { CommitAction, CondensedProjectSchema, ProjectSchema, SimpleProjectSchema } from '@gitbeaker/core' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { ProjectWithDetails } from './argocd-datastore.service' +import type { ArgocdConfig } from './argocd.module-definition' +import { createHmac } from 'node:crypto' +import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { stringify } from 'yaml' +import { GitlabClientService } from '../gitlab/gitlab-client.service' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { + CONSOLE_ADMIN_GROUP_PATH, + PLATFORM_ADMIN_GROUP_PATH, + PLATFORM_READONLY_GROUP_PATH, + PLATFORM_SECURITY_GROUP_PATH, + PROJECT_ADMIN_GROUP_PATH_SUFFIX, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + PROJECT_READONLY_GROUP_PATH_SUFFIX, + PROJECT_SECURITY_GROUP_PATH_SUFFIX, +} from './argocd.constants' +import { ARGOCD_CONFIG } from './argocd.module-definition' + +@Injectable() +export class ArgoCDService { + private readonly logger = new Logger(ArgoCDService.name) + + constructor( + @Inject(ArgoCDDatastoreService) private readonly datastore: ArgoCDDatastoreService, + @Inject(ARGOCD_CONFIG) private readonly argocdConfig: ArgocdConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('ArgoCDService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('argocd', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`ArgoCD sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('argocd', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`ArgoCD sync completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + this.logger.log('Starting ArgoCD reconciliation') + const projects = await this.datastore.getAllProjects() + const span = trace.getActiveSpan() + span?.setAttribute('argocd.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for ArgoCD reconciliation`) + await this.ensureProjects(projects) + this.logger.log(`ArgoCD reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('argocd.projects.count', projects.length) + this.logger.verbose(`Reconciling ArgoCD projects (count=${projects.length})`) + await Promise.all(projects.map(project => this.ensureProject(project))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling ArgoCD project ${project.slug}`) + await this.ensureZones(project) + this.logger.verbose(`ArgoCD project reconciled (${project.slug})`) + } + + @StartActiveSpan() + private async ensureZones( + project: ProjectWithDetails, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const zones = getDistinctZones(project) + span?.setAttribute('argocd.zones.count', zones.length) + this.logger.verbose(`Reconciling ArgoCD zones for project ${project.slug} (count=${zones.length})`) + await Promise.all(zones.map(zoneSlug => this.ensureZone(project, zoneSlug))) + } + + @StartActiveSpan() + private async ensureZone( + project: ProjectWithDetails, + zoneSlug: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const infraProject = await this.gitlab.getOrCreateInfraGroupRepo(zoneSlug) + span?.setAttributes({ + 'argocd.repo.id': infraProject.id, + 'argocd.repo.path': infraProject.path_with_namespace, + 'zone.slug': zoneSlug, + }) + this.logger.verbose(`Reconciling ArgoCD zone for project ${project.slug} in zone ${zoneSlug} (repoId=${infraProject.id})`) + + const actions = await this.generateEnvironmentActions(project, infraProject, zoneSlug) + + span?.setAttribute('argocd.repo.actions.count', actions.length) + if (actions.length === 0) { + this.logger.verbose(`No ArgoCD changes need to be committed for project ${project.slug} in zone ${zoneSlug}`) + return + } + + this.logger.log(`Applying ArgoCD changes for project ${project.slug} in zone ${zoneSlug} (actions=${actions.length})`) + await this.gitlab.maybeCreateCommit(infraProject, `ci: :robot_face: Sync ${project.slug}`, actions) + } + + private async generateEnvironmentActions( + project: ProjectWithDetails, + infraProject: ProjectSchema, + zoneSlug: string, + ) { + const environmentActions = await this.generateEnvironmentsUpdateActions( + project, + project.environments, + infraProject, + zoneSlug, + ) + const deploymentActions = await this.generateDeploymentsUpdateActions( + project, + infraProject, + zoneSlug, + ) + const purgeEnvironmentActions = await this.generatePurgeEnvironmentActions( + project, + infraProject, + zoneSlug, + ) + return [ + ...(deploymentActions.length ? deploymentActions : environmentActions), + ...purgeEnvironmentActions, + ] satisfies CommitAction[] + } + + private async generatePurgeEnvironmentActions( + project: ProjectWithDetails, + infraProject: CondensedProjectSchema, + zoneSlug: string, + ): Promise { + const neededFiles = new Set() + const clusterLabelsInZone = new Set( + project.environments + .filter(e => e.cluster.zone.slug === zoneSlug) + .map(e => e.cluster.label), + ) + + project.environments.forEach((env) => { + if (env.cluster?.zone.slug !== zoneSlug) return + neededFiles.add(formatEnvironmentValuesFilePath(project, env.cluster, env)) + }) + + const existingFiles = await this.gitlab.listFiles(infraProject, { + path: `${project.name}/`, + recursive: true, + }) + + const projectPrefix = `${project.name}/` + const actions = existingFiles + .filter((existingFile) => { + if (existingFile.name !== 'values.yaml') return false + if (!existingFile.path.startsWith(projectPrefix)) return false + + const remaining = existingFile.path.slice(projectPrefix.length) + const clusterLabel = remaining.split('/')[0] + if (!clusterLabel || !clusterLabelsInZone.has(clusterLabel)) return false + + return !neededFiles.has(existingFile.path) + }) + .map(existingFile => ({ action: 'delete', filePath: existingFile.path } satisfies CommitAction)) + + this.logger.verbose(`Computed ArgoCD purge actions for project ${project.slug} in zone ${zoneSlug} (actions=${actions.length})`) + + return actions + } + + private async generateEnvironmentsUpdateActions( + project: ProjectWithDetails, + environments: ProjectWithDetails['environments'], + infraProject: SimpleProjectSchema, + zoneSlug: string, + ): Promise { + this.logger.verbose(`Computing ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug} (environments=${environments.length})`) + const actions = await Promise.all( + environments + .filter(env => env.cluster?.zone.slug === zoneSlug) + .map(env => this.generateEnvironmentUpdateAction(project, env, infraProject)), + ) + const filteredActions: CommitAction[] = actions.filter(a => !!a) + this.logger.verbose(`Computed ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug} (actions=${filteredActions.length})`) + return filteredActions + } + + private async generateEnvironmentUpdateAction( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + infraProject: SimpleProjectSchema, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const vaultValues = await this.generateVaultValues(project.slug) + const cluster = environment.cluster + if (!cluster) { + this.logger.warn(`Cluster not found for environment ${environment.id} in project ${project.slug}`) + throw new Error(`Cluster not found for environment ${environment.id}`) + } + span?.setAttribute('zone.slug', cluster.zone.slug) + + const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment) + + const gitlabPublicProjectUrl = `${(await this.gitlab.getOrCreateProjectGroupPublicUrl())}/${project.slug}` + + const values = formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories: this.argocdConfig.extraRepositories, + infraProject, + valueFilePath, + vaultValues, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, + }) + + return this.gitlab.generateCreateOrUpdateAction( + infraProject, + 'main', + valueFilePath, + stringify(values), + ) + } + + private async generateDeploymentsUpdateActions( + project: ProjectWithDetails, + infraProject: SimpleProjectSchema, + zoneSlug: string, + ): Promise { + this.logger.verbose(`Computing ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug} (environments=${project.deployments.length})`) + const environments = project.deployments.reduce< + Record + >((acc, deployment) => { + const environment = deployment.environment + if (acc[environment.id]) acc[environment.id].deployments.push(deployment) + else acc[environment.id] = { environment, deployments: [deployment] } + return acc + }, {}) + + const actions = await Promise.all( + Object.values(environments) + .filter(({ environment }) => environment.cluster?.zone.slug === zoneSlug) + .map(({ environment, deployments }) => this.generateEnvironmentWithDeploymentsUpdateAction(project, environment, deployments, infraProject)), + ) + + const filteredActions: CommitAction[] = actions.filter(a => !!a) + this.logger.verbose(`Computed ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug} (actions=${filteredActions.length})`) + return filteredActions + } + + private async generateEnvironmentWithDeploymentsUpdateAction( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + deployments: ProjectWithDetails['deployments'][number][], + infraProject: SimpleProjectSchema, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const vaultValues = await this.generateVaultValues(project.slug) + const cluster = environment.cluster + if (!cluster) { + this.logger.warn(`Cluster not found for environment ${environment.id} in project ${project.slug}`) + throw new Error(`Cluster not found for environment ${environment.id}`) + } + span?.setAttribute('zone.slug', cluster.zone.slug) + + const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment) + + const gitlabPublicProjectUrl = `${(await this.gitlab.getOrCreateProjectGroupPublicUrl())}/${project.slug}` + + const values = formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories: this.argocdConfig.extraRepositories, + infraProject, + valueFilePath, + vaultValues, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, + deployments, + }) + + return this.gitlab.generateCreateOrUpdateAction( + infraProject, + 'main', + valueFilePath, + stringify(values), + ) + } + + private async generateVaultValues(projectSlug: string): Promise> { + this.logger.verbose(`Generating Vault project values for projectSlug=${projectSlug}`) + const roleId = await this.vault.getAuthApproleRoleRoleId(projectSlug).catch(() => { + this.logger.warn(`Couldn't find app role (project=${projectSlug})`) + return undefined + }) + const secretId = await this.vault.createAuthApproleRoleSecretId(projectSlug).catch(() => { + this.logger.warn(`Couldn't find secret (project=${projectSlug})`) + return undefined + }) + return { + projectsRootDir: this.baseConfig.projectsRootDir, + url: this.argocdConfig.vaultDeployVaultConnectionInNs ? this.vaultConfig.url : '', + coreKvName: this.vaultConfig.kvName, + roleId: roleId ?? 'none', + secretId: secretId ?? 'none', + } + } +} + +interface ValuesSchema { + common: { + 'dso/project': string + 'dso/project.id': string + 'dso/project.slug': string + 'dso/environment': string + 'dso/environment.id': string + } + argocd: { + cluster: string + namespace: string + project: string + envChartVersion: string + nsChartVersion: string + } + environment: { + valueFileRepository: string + valueFileRevision: string + valueFilePath: string + roGroup: string + rwGroup: string + consoleAdminGroup: string + platformAdminGroup: string + platformReadonlyGroup: string + platformSecurityGroup: string + projectAdminGroup: string + projectDevopsGroup: string + projectDevelopperGroup: string + projectSecurityGroup: string + projectReadonlyGroup: string + } + application: { + quota: { + cpu: number + gpu: number + memory: string + } + sourceRepositories: string[] + destination: { + namespace: string + name: string + } + autosync: boolean + vault: Record + repositories: { + name: string + id: string + repoURL: string + targetRevision: string + path: string + valueFiles: string[] + }[] + } + features: { + fineGrainedRoles: { + enabled: boolean + } + } +} + +function formatReadOnlyGroupName(projectSlug: string, environmentName: string) { + return generateEnvironmentConsoleGroupPath(projectSlug, environmentName, 'RO') +} + +function formatReadWriteGroupName(projectSlug: string, environmentName: string) { + return generateEnvironmentConsoleGroupPath(projectSlug, environmentName, 'RW') +} + +function generateEnvironmentConsoleGroupPath(projectSlug: string, environmentName: string, access: 'RO' | 'RW') { + return `/${projectSlug}/console/${environmentName}/${access}` +} + +function generateProjectConsoleGroupPath(projectSlug: string, suffix: string) { + return `/${projectSlug}${suffix}` +} + +function formatAppProjectName(projectSlug: string, env: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${envHash}` +} + +function formatEnvironmentValuesFilePath(project: { name: string }, cluster: { label: string }, env: { name: string }): string { + return `${project.name}/${cluster.label}/${env.name}/values.yaml` +} + +function getDistinctZones(project: ProjectWithDetails) { + const zones = new Set() + project.environments.forEach(e => zones.add(e.cluster.zone.slug)) + return [...zones] +} + +function splitExtraRepositories(extraRepositories: string | undefined): string[] { + if (!extraRepositories) return [] + return extraRepositories.split(',').map(r => r.trim()).filter(r => r.length > 0) +} + +function formatRepositoriesValues( + repositories: ProjectWithDetails['repositories'], + gitlabPublicProjectUrl: string, + envName: string, +) { + return repositories + .filter(repo => repo.isInfra) + .map((repository) => { + const valueFiles = splitExtraRepositories(repository.helmValuesFiles?.replaceAll('', envName)) + return { + id: repository.id, + name: repository.internalRepoName, + repoURL: `${gitlabPublicProjectUrl}/${repository.internalRepoName}.git`, + targetRevision: repository.deployRevision || 'HEAD', + path: repository.deployPath || '.', + valueFiles, + } satisfies ValuesSchema['application']['repositories'][number] + }) +} + +function formatRepositoriesValuesFromDeployments( + deployments: ProjectWithDetails['deployments'][number][], + gitlabPublicProjectUrl: string, + envName: string, +) { + return deployments.flatMap(deployment => + deployment.deploymentSources + .map((source) => { + const valueFiles = splitExtraRepositories(source.helmValuesFiles?.replaceAll('', envName)) + return { + name: source.repository.internalRepoName, + id: source.repository.id, + repoURL: `${gitlabPublicProjectUrl}/${source.repository.internalRepoName}.git`, + targetRevision: source.targetRevision || 'HEAD', + path: source.path || '.', + valueFiles, + } satisfies ValuesSchema['application']['repositories'][number] + }), + ) +} + +function formatEnvironmentValues( + project: ProjectWithDetails, + infraProject: SimpleProjectSchema, + valueFilePath: string, + roGroup: string, + rwGroup: string, +) { + return { + valueFileRepository: infraProject.http_url_to_repo, + valueFileRevision: 'HEAD', + valueFilePath, + roGroup, + rwGroup, + consoleAdminGroup: CONSOLE_ADMIN_GROUP_PATH, + platformAdminGroup: PLATFORM_ADMIN_GROUP_PATH, + platformReadonlyGroup: PLATFORM_READONLY_GROUP_PATH, + platformSecurityGroup: PLATFORM_SECURITY_GROUP_PATH, + projectAdminGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_ADMIN_GROUP_PATH_SUFFIX), + projectDevopsGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_DEVOPS_GROUP_PATH_SUFFIX), + projectDevelopperGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX), + projectSecurityGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_SECURITY_GROUP_PATH_SUFFIX), + projectReadonlyGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_READONLY_GROUP_PATH_SUFFIX), + } satisfies ValuesSchema['environment'] +} + +interface FormatSourceRepositoriesValuesOptions { + gitlabPublicProjectUrl: string + argocdExtraRepositories?: string + projectPlugins?: ProjectWithDetails['plugins'] +} + +function formatSourceRepositoriesValues( + { gitlabPublicProjectUrl, argocdExtraRepositories, projectPlugins }: FormatSourceRepositoriesValuesOptions, +): string[] { + let projectExtraRepositories = '' + if (projectPlugins) { + const argocdPlugin = projectPlugins.find(p => p.pluginName === 'argocd' && p.key === 'extraRepositories') + if (argocdPlugin) projectExtraRepositories = argocdPlugin.value + } + + return [ + `${gitlabPublicProjectUrl}/**`, + ...splitExtraRepositories(argocdExtraRepositories), + ...splitExtraRepositories(projectExtraRepositories), + ] +} + +interface FormatCommonOptions { + project: ProjectWithDetails + environment: ProjectWithDetails['environments'][number] +} + +function formatCommon({ project, environment }: FormatCommonOptions) { + return { + 'dso/project': project.name, + 'dso/project.id': project.id, + 'dso/project.slug': project.slug, + 'dso/environment': environment.name, + 'dso/environment.id': environment.id, + } satisfies ValuesSchema['common'] +} + +interface FormatArgoCDValuesOptions { + namespace: string + project: string + envChartVersion: string + nsChartVersion: string +} + +function formatArgoCDValues(options: FormatArgoCDValuesOptions) { + const { namespace, project, envChartVersion, nsChartVersion } = options + return { + cluster: inClusterLabel, + namespace, + project, + envChartVersion, + nsChartVersion, + } satisfies ValuesSchema['argocd'] +} + +interface FormatValuesOptions { + project: ProjectWithDetails + environment: ProjectWithDetails['environments'][number] + cluster: ProjectWithDetails['environments'][number]['cluster'] + gitlabPublicProjectUrl: string + argocdExtraRepositories?: string + vaultValues: Record + infraProject: SimpleProjectSchema + valueFilePath: string + argoNamespace: string + envChartVersion: string + nsChartVersion: string + deployments?: ProjectWithDetails['deployments'][number][] +} + +function formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories, + vaultValues, + infraProject, + valueFilePath, + argoNamespace, + envChartVersion, + nsChartVersion, + deployments, +}: FormatValuesOptions) { + return { + common: formatCommon({ project, environment }), + argocd: formatArgoCDValues({ + namespace: argoNamespace, + project: formatAppProjectName(project.slug, environment.name), + envChartVersion, + nsChartVersion, + }), + environment: formatEnvironmentValues( + project, + infraProject, + valueFilePath, + formatReadOnlyGroupName(project.slug, environment.name), + formatReadWriteGroupName(project.slug, environment.name), + ), + application: { + quota: { + cpu: environment.cpu, + gpu: environment.gpu, + memory: `${environment.memory}Gi`, + }, + sourceRepositories: formatSourceRepositoriesValues({ + gitlabPublicProjectUrl, + argocdExtraRepositories, + projectPlugins: project.plugins, + }), + destination: { + namespace: generateNamespaceName(project.id, environment.id), + name: cluster.label, + }, + autosync: environment.autosync, + vault: vaultValues, + repositories: deployments + ? formatRepositoriesValuesFromDeployments(deployments, gitlabPublicProjectUrl, environment.name) + : formatRepositoriesValues( + project.repositories, + gitlabPublicProjectUrl, + environment.name, + ), + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + } satisfies ValuesSchema +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/configurable-feature-module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/configurable-feature-module.ts new file mode 100644 index 0000000000..85b2418316 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/configurable-feature-module.ts @@ -0,0 +1,78 @@ +import type { DynamicModule } from '@nestjs/common' +import { ConfigurableModuleBuilder } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' + +// Minimal shape of the generated configurable class, so consumers (e.g. `XModule.forRoot(...)`) +// see the `forRoot` static method. The arg is `TOptions` (plain object, for tests) OR a +// `ConfigFactory` (e.g. `registerAs('', ...)`), whose `.asProvider()` is wired by +// `forRoot` into this module's options token. +export interface ConfigurableCls { + new(...args: any[]): any + forRoot: (arg: TOptions | any) => DynamicModule +} + +// Builds a configurable module that OWNS its config contract (interface embedded in-module, +// no ../../config / registerAs dependency inside the module). The module is wired from the +// outside via `XModule.forRoot(xxxConfigFactory)`, where `xxxConfigFactory` is the +// `registerAs('', ...)` result from `config/*.config.ts`. `` MUST equal +// `token` so `ConfigModule.forFeature` registers it under `CONFIGURATION()`. +// `isGlobal` makes the module (and its config token) available app-wide via @Global(). +// ponytail: native forRoot only takes a plain options object; we wrap it so it also accepts +// a `ConfigFactory` and wires it (via ConfigModule.forFeature + a provider under the token). +function isConfigFactory(arg: unknown): boolean { + return typeof arg === 'function' && 'asProvider' in (arg as any) + && typeof (arg as any).asProvider === 'function' +} + +function isConfigProvider(arg: unknown): arg is { provide: unknown, useFactory?: unknown, useValue?: unknown } { + return Boolean(arg) && typeof arg === 'object' + && 'provide' in (arg as Record) + && ('useFactory' in (arg as Record) || 'useValue' in (arg as Record)) +} + +export function createConfigurableModule(token: string, isGlobal = false) { + const { ConfigurableModuleClass } = new ConfigurableModuleBuilder({ optionsInjectionToken: token }) + .setClassMethodName('forRoot') + .setFactoryMethodName('useFactory') + .setExtras( + { isGlobal }, + (definition: DynamicModule, extras) => ({ + ...definition, + exports: [...(definition.exports ?? []), token], + global: extras.isGlobal, + }), + ) + .build() + + class WrappedModuleClass extends (ConfigurableModuleClass as any) { + static forRoot(arg: any): DynamicModule { + const def = (super.forRoot as (o: unknown) => DynamicModule)({}) as DynamicModule + if (isConfigFactory(arg)) { + const provider = (arg as any).asProvider() + return { + ...def, + imports: [(ConfigModule as any).forFeature(arg), ...(def.imports ?? [])], + providers: [ + ...(def.providers ?? []), + { provide: token, useFactory: provider.useFactory, inject: provider.inject }, + ], + exports: [...(def.exports ?? []), token], + } + } + if (isConfigProvider(arg)) { + const provider = arg as any + return { + ...def, + providers: [...(def.providers ?? []), provider], + exports: [...(def.exports ?? []), provider.provide], + } + } + return (super.forRoot as (o: unknown) => DynamicModule)(arg) + } + } + + return { + ConfigurableModuleClass: WrappedModuleClass as unknown as ConfigurableCls, + MODULE_OPTIONS_TOKEN: token, + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts new file mode 100644 index 0000000000..01267db740 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts @@ -0,0 +1,155 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' + +const mockDeployment = { + name: 'dep1', + id: 'dep1', + projectId: 'id', + memory: 3, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: 'envId', +} + +describe('deploymentDatastoreService', () => { + let module: TestingModule + let service: DeploymentDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + DeploymentDatastoreService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(DeploymentDatastoreService) + }) + + describe('getDeploymentById', () => { + it('should return a deployment with sources and repository', async () => { + const deployment = { ...mockDeployment } + + prisma.deployment.findUnique.mockResolvedValue(deployment) + + const result = await service.getDeploymentById('dep1') + + expect(prisma.deployment.findUnique).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + }) + expect(result).toEqual(deployment) + }) + }) + + describe('getDeploymentsByProjectId', () => { + it('should return deployments for a project', async () => { + const deployments = [{ ...mockDeployment }] + + prisma.deployment.findMany.mockResolvedValue(deployments) + + const result = await service.getDeploymentsByProjectId('project1') + + expect(prisma.deployment.findMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }) + expect(result).toEqual(deployments) + }) + }) + + describe('createDeployment', () => { + it('should create a deployment', async () => { + const created = { + ...mockDeployment, + name: 'test', + } + + const data = { + ...created, + project: { connect: { id: 'id' } }, + environment: { connect: { id: 'envId' } }, + } + + prisma.deployment.create.mockResolvedValue(data) + + const result = await service.createDeployment({ + ...data, + }) + + expect(prisma.deployment.create).toHaveBeenCalledWith({ data }) + expect(result).toEqual(data) + }) + }) + + describe('updateDeployment', () => { + it('should update a deployment', async () => { + const updated = { ...mockDeployment, name: 'updated' } + + prisma.deployment.update.mockResolvedValue(updated) + + const result = await service.updateDeployment('dep1', { + ...mockDeployment, + name: 'updated', + }) + + expect(prisma.deployment.update).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + data: { ...mockDeployment, name: 'updated' }, + }) + expect(result).toEqual(updated) + }) + }) + + describe('deleteDeployment', () => { + it('should delete a deployment', async () => { + const deleted = { ...mockDeployment } + + prisma.deployment.delete.mockResolvedValue(deleted) + + const result = await service.deleteDeployment('dep1') + + expect(prisma.deployment.delete).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + }) + expect(result).toEqual(deleted) + }) + }) + + describe('deleteAllDeploymentsByProjectId', () => { + it('should delete all deployments for a project', async () => { + const response = { count: 3 } + + prisma.deployment.deleteMany.mockResolvedValue(response) + + const result = await service.deleteAllDeploymentsByProjectId('project1') + + expect(prisma.deployment.deleteMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + }) + expect(result).toEqual(response) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts new file mode 100644 index 0000000000..859a299494 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts @@ -0,0 +1,56 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +@Injectable() +export class DeploymentDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + getDeploymentById(deploymentId: string) { + return this.prisma.deployment.findUnique({ + where: { id: deploymentId }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + }) + } + + getDeploymentsByProjectId(projectId: string) { + return this.prisma.deployment.findMany({ + where: { projectId }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }) + } + + createDeployment(data: Prisma.DeploymentCreateInput) { + return this.prisma.deployment.create({ data }) + } + + updateDeployment(deploymentId: string, data: Prisma.DeploymentUpdateInput) { + return this.prisma.deployment.update({ + where: { id: deploymentId }, + data, + }) + } + + deleteDeployment(deploymentId: string) { + return this.prisma.deployment.delete({ + where: { id: deploymentId }, + }) + } + + deleteAllDeploymentsByProjectId(projectId: string) { + return this.prisma.deployment.deleteMany({ + where: { projectId }, + }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts new file mode 100644 index 0000000000..20f7e249a3 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts @@ -0,0 +1,85 @@ +import type { Deployment, DeploymentSource, Environment, Prisma, Repository } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeDeployment(overrides: Partial = {}): Deployment { + return { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + autosync: true, + environmentId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } +} + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + projectId: faker.string.uuid(), + memory: 2, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export function makeRepository(overrides: Partial = {}): Repository { + return { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: faker.string.alphanumeric(8).toLowerCase(), + externalRepoUrl: '', + externalUserName: '', + isInfra: false, + isPrivate: false, + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } +} + +type DeploymentSourceWithRepository = DeploymentSource & { repository: Repository } + +export function makeDeploymentSource(overrides: Partial = {}): DeploymentSourceWithRepository { + const repositoryId = overrides.repositoryId ?? faker.string.uuid() + return { + id: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + deploymentId: faker.string.uuid(), + repositoryId, + type: 'git', + targetRevision: 'main', + path: '/app', + helmValuesFiles: '', + repository: makeRepository({ id: repositoryId }), + ...overrides, + } +} + +export type DeploymentWithRelations = Prisma.DeploymentGetPayload<{ + include: { + environment: true + deploymentSources: { include: { repository: true } } + } +}> + +export function makeDeploymentWithRelations(overrides: Partial = {}): DeploymentWithRelations { + const base = makeDeployment(overrides) + return { + ...base, + environment: overrides.environment ?? makeEnvironment({ id: base.environmentId, projectId: base.projectId }), + deploymentSources: overrides.deploymentSources ?? [makeDeploymentSource({ deploymentId: base.id })], + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts new file mode 100644 index 0000000000..53f28c259f --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts @@ -0,0 +1,139 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { makeDeployment, makeDeploymentWithRelations } from './deployment-testing.utils' +import { DeploymentController } from './deployment.controller' +import { DeploymentService } from './deployment.service' + +describe('deploymentController', () => { + let module: TestingModule + let controller: DeploymentController + let service: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + + const project: ProjectContext = { id: projectId, slug: 'my-project' } + const user: UserContext = { userId } + const request = { id: requestId } as FastifyRequest + + const validCreateDeployment = { + name: 'dev', + projectId, + environmentId: '22222222-2222-2222-2222-222222222222', + autosync: true, + deploymentSources: [ + { + repositoryId: '33333333-3333-3333-3333-333333333333', + type: 'git', + targetRevision: 'main', + path: '/app', + }, + ], + } satisfies CreateDeployment + + const validUpdateDeployment = { + ...validCreateDeployment, + deploymentSources: [ + { + id: '44444444-4444-4444-4444-444444444444', + repositoryId: '33333333-3333-3333-3333-333333333333', + type: 'git', + targetRevision: 'develop', + path: '/updated-app', + }, + ], + } satisfies UpdateDeployment + + beforeEach(async () => { + service = mockDeep() + + module = await Test.createTestingModule({ + controllers: [DeploymentController], + providers: [ + { provide: DeploymentService, useValue: service }, + ], + }) + .overrideGuard(ProjectGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(DeploymentController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call deploymentService.listByProjectId with projectId', async () => { + const expectedResult = [makeDeploymentWithRelations({ projectId })] + + service.listByProjectId.mockResolvedValue(expectedResult) + + const result = await controller.list(project) + + expect(service.listByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('create', () => { + it('should validate body and call deploymentService.createDeployment', async () => { + const expectedResult = makeDeployment() + + service.createDeployment.mockResolvedValue(expectedResult) + + const result = await controller.create(validCreateDeployment, project, user, request) + + expect(service.createDeployment).toHaveBeenCalledWith( + projectId, + validCreateDeployment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('update', () => { + it('should validate body and call deploymentService.updateDeployment', async () => { + const deploymentId = '55555555-5555-5555-5555-555555555555' + const expectedResult = makeDeployment({ id: deploymentId }) + + service.updateDeployment.mockResolvedValue(expectedResult) + + const result = await controller.update(deploymentId, validUpdateDeployment, project, user, request) + + expect(service.updateDeployment).toHaveBeenCalledWith( + projectId, + deploymentId, + validUpdateDeployment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('delete', () => { + it('should call deploymentService.deleteDeployment with deploymentId', async () => { + const deploymentId = '55555555-5555-5555-5555-555555555555' + + service.deleteDeployment.mockResolvedValue(undefined) + + const result = await controller.delete(deploymentId, project, user, request) + + expect(service.deleteDeployment).toHaveBeenCalledWith(projectId, deploymentId, userId, requestId) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.controller.ts new file mode 100644 index 0000000000..d3a8706e01 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.controller.ts @@ -0,0 +1,63 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { CreateDeploymentSchema, UpdateDeploymentSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { DeploymentService } from './deployment.service' + +@Controller('api/v1/projects/:projectId/deployments') +@UseGuards(ProjectGuard) +export class DeploymentController { + constructor(@Inject(DeploymentService) private readonly deploymentService: DeploymentService) {} + + @Get('') + @RequireAdminPermission('ListProjects') + @RequireProjectPermission('ListDeployments') + list(@Project() project: ProjectContext) { + return this.deploymentService.listByProjectId(project.id) + } + + @Post('') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.CREATED) + create( + @Body(new ZodValidationPipe(CreateDeploymentSchema)) data: CreateDeployment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.createDeployment(project.id, data, user.userId, request.id) + } + + @Put(':deploymentId') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.OK) + update( + @Param('deploymentId') deploymentId: string, @Body(new ZodValidationPipe(UpdateDeploymentSchema)) + data: UpdateDeployment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.updateDeployment(project.id, deploymentId, data, user.userId, request.id) + } + + @Delete(':deploymentId') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.NO_CONTENT) + delete( + @Param('deploymentId') deploymentId: string, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.deleteDeployment(project.id, deploymentId, user.userId, request.id) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.module.ts new file mode 100644 index 0000000000..cc0f6e1a91 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { ProjectModule } from '../project/project.module' +import { DeploymentDatastoreService } from './deployment-datastore.service' +import { DeploymentController } from './deployment.controller' +import { DeploymentService } from './deployment.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, EventsModule, ProjectModule, ProjectPermissionModule], + controllers: [DeploymentController], + providers: [ + DeploymentDatastoreService, + DeploymentService, + ], +}) +export class DeploymentModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts new file mode 100644 index 0000000000..3b9e19c058 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts @@ -0,0 +1,222 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' +import { makeDeployment, makeDeploymentSource, makeDeploymentWithRelations } from './deployment-testing.utils' +import { DeploymentService } from './deployment.service' + +describe('deploymentService', () => { + let module: TestingModule + let service: DeploymentService + let datastore: DeepMockProxy + let appEvents: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + const deploymentId = '22222222-2222-2222-2222-222222222222' + + const okArgoCDResults = { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } } + + const validCreateDeployment = { + name: 'mydeployment', + projectId, + environmentId: '33333333-3333-3333-3333-333333333333', + autosync: true, + deploymentSources: [ + { + repositoryId: '44444444-4444-4444-4444-444444444444', + type: 'git', + targetRevision: 'main', + path: '/app', + helmValuesFiles: 'values.yaml', + }, + ], + } satisfies CreateDeployment + + const validUpdateDeployment = { + ...validCreateDeployment, + deploymentSources: [ + { + id: '55555555-5555-5555-5555-555555555555', + repositoryId: '44444444-4444-4444-4444-444444444444', + type: 'git', + targetRevision: 'develop', + path: '/updated-app', + helmValuesFiles: 'updated-values.yaml', + }, + ], + } satisfies UpdateDeployment + + beforeEach(async () => { + datastore = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + DeploymentService, + { provide: DeploymentDatastoreService, useValue: datastore }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(DeploymentService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('listByProjectId', () => { + it('should return deployments by projectId', async () => { + const deployments = [makeDeploymentWithRelations({ id: deploymentId, projectId })] + datastore.getDeploymentsByProjectId.mockResolvedValue(deployments) + + const result = await service.listByProjectId(projectId) + + expect(datastore.getDeploymentsByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(deployments) + }) + }) + + describe('createDeployment', () => { + it('should create deployment and trigger a project reconciliation', async () => { + const createdDeployment = makeDeployment({ id: deploymentId, projectId }) + + datastore.createDeployment.mockResolvedValue(createdDeployment) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + const result = await service.createDeployment(projectId, validCreateDeployment, userId, requestId) + + expect(datastore.createDeployment).toHaveBeenCalledWith({ + name: validCreateDeployment.name, + project: { connect: { id: projectId } }, + autosync: validCreateDeployment.autosync, + environment: { connect: { id: validCreateDeployment.environmentId } }, + deploymentSources: { + createMany: { + data: validCreateDeployment.deploymentSources.map(source => ({ + type: source.type, + repositoryId: source.repositoryId, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + })), + }, + }, + }) + + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Create Deployment', userId, requestId }) + expect(result).toEqual(createdDeployment) + }) + + it('should return the deployment even when the reconciliation fails', async () => { + const createdDeployment = makeDeployment({ id: deploymentId, projectId }) + datastore.createDeployment.mockResolvedValue(createdDeployment) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + const result = await service.createDeployment(projectId, validCreateDeployment, userId, requestId) + + expect(result).toEqual(createdDeployment) + }) + }) + + describe('updateDeployment', () => { + it('should update deployment and trigger a project reconciliation', async () => { + const existingDeployment = makeDeploymentWithRelations({ + id: deploymentId, + projectId, + deploymentSources: [ + makeDeploymentSource({ id: '55555555-5555-5555-5555-555555555555', deploymentId }), + makeDeploymentSource({ id: '66666666-6666-6666-6666-666666666666', deploymentId }), + ], + }) + + const updatedDeployment = makeDeployment({ id: deploymentId, projectId }) + + datastore.getDeploymentById.mockResolvedValue(existingDeployment) + datastore.updateDeployment.mockResolvedValue(updatedDeployment) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + const result = await service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId) + + expect(datastore.updateDeployment).toHaveBeenCalledWith( + deploymentId, + expect.objectContaining({ + name: validUpdateDeployment.name, + deploymentSources: { + deleteMany: { + id: { in: ['66666666-6666-6666-6666-666666666666'] }, + }, + upsert: expect.any(Array), + }, + }), + ) + + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Update Deployment', userId, requestId }) + expect(result).toEqual(updatedDeployment) + }) + + it('should throw if deployment does not exist', async () => { + datastore.getDeploymentById.mockResolvedValue(null) + + await expect( + service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId), + ).rejects.toThrow(`Deployment with id ${deploymentId} not found`) + + expect(datastore.updateDeployment).not.toHaveBeenCalled() + }) + + it('should return the deployment even when the reconciliation fails', async () => { + const existingDeployment = makeDeploymentWithRelations({ + id: deploymentId, + projectId, + deploymentSources: [ + makeDeploymentSource({ id: '55555555-5555-5555-5555-555555555555', deploymentId }), + ], + }) + const updatedDeployment = makeDeployment({ id: deploymentId, projectId }) + datastore.getDeploymentById.mockResolvedValue(existingDeployment) + datastore.updateDeployment.mockResolvedValue(updatedDeployment) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + const result = await service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId) + + expect(result).toEqual(updatedDeployment) + }) + }) + + describe('deleteDeployment', () => { + it('should delete deployment and trigger a project reconciliation', async () => { + datastore.deleteDeployment.mockResolvedValue(makeDeployment({ id: deploymentId, projectId })) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + await service.deleteDeployment(projectId, deploymentId, userId, requestId) + + expect(datastore.deleteDeployment).toHaveBeenCalledWith(deploymentId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Delete Deployment', userId, requestId }) + }) + + it('should resolve even when the reconciliation fails', async () => { + datastore.deleteDeployment.mockResolvedValue(makeDeployment({ id: deploymentId, projectId })) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + await expect(service.deleteDeployment(projectId, deploymentId, userId, requestId)).resolves.toBeUndefined() + }) + }) + + describe('deleteAllDeploymentsByProjectId', () => { + it('should delete all deployments and upsert project', async () => { + datastore.deleteAllDeploymentsByProjectId.mockResolvedValue({ count: 1 }) + + await service.deleteAllDeploymentsByProjectId(projectId) + + expect(datastore.deleteAllDeploymentsByProjectId).toHaveBeenCalledWith(projectId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Delete all project deployments' }) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.service.ts new file mode 100644 index 0000000000..beec47e562 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/deployment/deployment.service.ts @@ -0,0 +1,108 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { EventLogAction } from '../events/app-events.service' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' + +@Injectable() +export class DeploymentService { + private readonly logger = new Logger(DeploymentService.name) + + constructor( + @Inject(DeploymentDatastoreService) private readonly deploymentDatastoreService: DeploymentDatastoreService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async listByProjectId(projectId: string) { + return this.deploymentDatastoreService.getDeploymentsByProjectId(projectId) + } + + async createDeployment(projectId: string, deploymentToCreate: CreateDeployment, userId: string, requestId: string) { + const deployment = await this.deploymentDatastoreService.createDeployment({ + name: deploymentToCreate.name, + project: { connect: { id: projectId } }, + autosync: deploymentToCreate.autosync, + environment: { connect: { id: deploymentToCreate.environmentId } }, + deploymentSources: { + createMany: { + data: deploymentToCreate.deploymentSources.map(({ type, repositoryId, targetRevision, path, helmValuesFiles }) => ({ + type, + repositoryId, + targetRevision, + path, + helmValuesFiles, + })), + }, + }, + }) + + this.reconcileProject(projectId, 'Create Deployment', userId, requestId) + return deployment + } + + async updateDeployment(projectId: string, deploymentId: string, deploymentToUpdate: UpdateDeployment, userId: string, requestId: string) { + const existing = await this.deploymentDatastoreService.getDeploymentById(deploymentId) + if (!existing) throw new Error(`Deployment with id ${deploymentId} not found`) + + const incomingDeploymentSourceIds = new Set( + deploymentToUpdate.deploymentSources + .filter(s => s.id) + .map(s => s.id), + ) + + const deploymentSourcesToDelete = existing.deploymentSources.filter( + e => !incomingDeploymentSourceIds.has(e.id), + ) + + const deployment = await this.deploymentDatastoreService.updateDeployment(deploymentId, { + name: deploymentToUpdate.name, + autosync: deploymentToUpdate.autosync, + environment: { connect: { id: deploymentToUpdate.environmentId } }, + deploymentSources: { + deleteMany: { + id: { in: deploymentSourcesToDelete.map(s => s.id) }, + }, + upsert: deploymentToUpdate.deploymentSources.map(source => ({ + where: { id: source.id ?? crypto.randomUUID() }, + update: { + repository: { connect: { id: source.repositoryId } }, + type: source.type, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + }, + create: { + repository: { connect: { id: source.repositoryId } }, + type: source.type, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + }, + })), + }, + }) + this.reconcileProject(projectId, 'Update Deployment', userId, requestId) + return deployment + } + + async deleteDeployment(projectId: string, deploymentId: string, userId: string, requestId: string) { + await this.deploymentDatastoreService.deleteDeployment(deploymentId) + this.reconcileProject(projectId, 'Delete Deployment', userId, requestId) + } + + async deleteAllDeploymentsByProjectId(projectId: string) { + await this.deploymentDatastoreService.deleteAllDeploymentsByProjectId(projectId) + await this.appEvents.emitProjectEvent('project.upsert', projectId, { action: 'Delete all project deployments' }) + } + + /** + * Triggers the project reconciliation without blocking the response: listener + * outcomes (including failures) are persisted in the admin log by AppEventsService. + */ + private reconcileProject(projectId: string, action: EventLogAction, userId: string, requestId: string) { + this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) + .catch((error: unknown) => { + this.logger.error(`project.upsert reconciliation failed (projectId=${projectId})`, error instanceof Error ? error.stack : String(error)) + }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts new file mode 100644 index 0000000000..6189f20316 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts @@ -0,0 +1,181 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeCluster, makeEnvironment, makeStage } from './environment-testing.utils' + +describe('environmentDatastoreService', () => { + let module: TestingModule + let service: EnvironmentDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentDatastoreService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(EnvironmentDatastoreService) + }) + + describe('getProjectEnvironment', () => { + it('should return an environment with its cluster scoped to the project', async () => { + const environment = makeEnvironment({ id: 'env1', projectId: 'project1' }) + prisma.environment.findFirst.mockResolvedValue(environment) + + const result = await service.getProjectEnvironment('project1', 'env1') + + expect(prisma.environment.findFirst).toHaveBeenCalledWith({ + where: { id: 'env1', projectId: 'project1' }, + include: { cluster: true }, + }) + expect(result).toEqual(environment) + }) + }) + + describe('getEnvironmentsByProjectId', () => { + it('should return environments for a project with their stage', async () => { + const environments = [makeEnvironment({ projectId: 'project1' })] + prisma.environment.findMany.mockResolvedValue(environments) + + const result = await service.getEnvironmentsByProjectId('project1') + + expect(prisma.environment.findMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + include: { stage: true }, + }) + expect(result).toEqual(environments) + }) + }) + + describe('getEnvironmentByName', () => { + it('should look the environment up by its project unique name', async () => { + prisma.environment.findUnique.mockResolvedValue(null) + + const result = await service.getEnvironmentByName('project1', 'dev') + + expect(prisma.environment.findUnique).toHaveBeenCalledWith({ + where: { projectId_name: { projectId: 'project1', name: 'dev' } }, + }) + expect(result).toBeNull() + }) + }) + + describe('getStageById', () => { + it('should return the stage', async () => { + const stage = makeStage({ id: 'stage1' }) + prisma.stage.findUnique.mockResolvedValue(stage) + + const result = await service.getStageById('stage1') + + expect(prisma.stage.findUnique).toHaveBeenCalledWith({ where: { id: 'stage1' } }) + expect(result).toEqual(stage) + }) + }) + + describe('getProdStageIds', () => { + it('should return the ids of the prod stages', async () => { + prisma.stage.findMany.mockResolvedValue([makeStage({ id: 'stage1', name: 'prod' })]) + + await service.getProdStageIds() + + expect(prisma.stage.findMany).toHaveBeenCalledWith({ + select: { id: true }, + where: { name: 'prod' }, + }) + }) + }) + + describe('getAvailableCluster', () => { + it('should only match public clusters or dedicated clusters of the project', async () => { + const cluster = makeCluster({ id: 'cluster1' }) + prisma.cluster.findFirst.mockResolvedValue(cluster) + + const result = await service.getAvailableCluster('cluster1', 'project1') + + expect(prisma.cluster.findFirst).toHaveBeenCalledWith({ + where: { + OR: [{ + id: 'cluster1', + privacy: 'public', + }, { + id: 'cluster1', + privacy: 'dedicated', + projects: { some: { id: 'project1' } }, + }], + }, + }) + expect(result).toEqual(cluster) + }) + }) + + describe('sumEnvironmentResources', () => { + it('should aggregate cpu, gpu and memory', async () => { + await service.sumEnvironmentResources({ clusterId: 'cluster1' }) + + expect(prisma.environment.aggregate).toHaveBeenCalledWith({ + _sum: { + cpu: true, + gpu: true, + memory: true, + }, + where: { clusterId: 'cluster1' }, + }) + }) + }) + + describe('createEnvironment', () => { + it('should create an environment', async () => { + const environment = makeEnvironment() + prisma.environment.create.mockResolvedValue(environment) + + const result = await service.createEnvironment({ + projectId: environment.projectId, + name: environment.name, + clusterId: environment.clusterId, + stageId: environment.stageId, + cpu: environment.cpu, + gpu: environment.gpu, + memory: environment.memory, + autosync: environment.autosync, + }) + + expect(prisma.environment.create).toHaveBeenCalled() + expect(result).toEqual(environment) + }) + }) + + describe('updateEnvironment', () => { + it('should update the environment resources', async () => { + const environment = makeEnvironment({ id: 'env1' }) + prisma.environment.update.mockResolvedValue(environment) + + const result = await service.updateEnvironment('env1', { cpu: 2, gpu: 0, memory: 4, autosync: false }) + + expect(prisma.environment.update).toHaveBeenCalledWith({ + where: { id: 'env1' }, + data: { cpu: 2, gpu: 0, memory: 4, autosync: false }, + }) + expect(result).toEqual(environment) + }) + }) + + describe('deleteEnvironment', () => { + it('should delete the environment', async () => { + const environment = makeEnvironment({ id: 'env1' }) + prisma.environment.delete.mockResolvedValue(environment) + + const result = await service.deleteEnvironment('env1') + + expect(prisma.environment.delete).toHaveBeenCalledWith({ where: { id: 'env1' } }) + expect(result).toEqual(environment) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts new file mode 100644 index 0000000000..1dab19eb23 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts @@ -0,0 +1,99 @@ +import type { Cluster, Environment, Prisma, Project, Stage } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PROD_STAGE_NAME } from './environment.constants' + +export type EnvironmentWithCluster = Environment & { cluster: Cluster } +export type EnvironmentWithStage = Environment & { stage: Stage } + +export interface EnvironmentResourcesSum { + _sum: { + cpu: number | null + gpu: number | null + memory: number | null + } +} + +@Injectable() +export class EnvironmentDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + getProjectEnvironment(projectId: string, environmentId: string): Promise { + return this.prisma.environment.findFirst({ + where: { id: environmentId, projectId }, + include: { cluster: true }, + }) + } + + getEnvironmentsByProjectId(projectId: string): Promise { + return this.prisma.environment.findMany({ + where: { projectId }, + include: { stage: true }, + }) + } + + getEnvironmentByName(projectId: string, name: string): Promise { + return this.prisma.environment.findUnique({ + where: { projectId_name: { projectId, name } }, + }) + } + + getStageById(stageId: string): Promise { + return this.prisma.stage.findUnique({ where: { id: stageId } }) + } + + getProdStageIds(): Promise<{ id: string }[]> { + return this.prisma.stage.findMany({ + select: { id: true }, + where: { name: PROD_STAGE_NAME }, + }) + } + + /** Only public clusters or dedicated clusters attached to the project are usable. */ + getAvailableCluster(clusterId: string, projectId: string): Promise { + return this.prisma.cluster.findFirst({ + where: { + OR: [{ + id: clusterId, + privacy: 'public', + }, { + id: clusterId, + privacy: 'dedicated', + projects: { some: { id: projectId } }, + }], + }, + }) + } + + getProjectById(projectId: string): Promise { + return this.prisma.project.findUniqueOrThrow({ where: { id: projectId } }) + } + + sumEnvironmentResources(where: Prisma.EnvironmentWhereInput): Promise { + return this.prisma.environment.aggregate({ + _sum: { + cpu: true, + gpu: true, + memory: true, + }, + where, + }) + } + + createEnvironment(data: Prisma.EnvironmentUncheckedCreateInput): Promise { + return this.prisma.environment.create({ data }) + } + + updateEnvironment(environmentId: string, data: Pick): Promise { + return this.prisma.environment.update({ + where: { id: environmentId }, + data, + }) + } + + deleteEnvironment(environmentId: string): Promise { + return this.prisma.environment.delete({ + where: { id: environmentId }, + }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts new file mode 100644 index 0000000000..5f0d0ee58d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts @@ -0,0 +1,87 @@ +import type { Cluster, Environment, Project, Stage } from '@prisma/client' +import type { EnvironmentWithCluster, EnvironmentWithStage } from './environment-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + projectId: faker.string.uuid(), + memory: 2, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export function makeCluster(overrides: Partial = {}): Cluster { + return { + id: faker.string.uuid(), + label: faker.string.alphanumeric(8).toLowerCase(), + privacy: 'public', + secretName: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + infos: null, + memory: 0, + cpu: 0, + gpu: 0, + zoneId: faker.string.uuid(), + ...overrides, + } +} + +export function makeStage(overrides: Partial = {}): Stage { + return { + id: faker.string.uuid(), + name: 'dev', + ...overrides, + } +} + +export function makeProject(overrides: Partial = {}): Project { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + description: '', + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.string.alphanumeric(8).toLowerCase(), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } +} + +export function makeEnvironmentWithCluster(overrides: Partial = {}): EnvironmentWithCluster { + const base = makeEnvironment(overrides) + return { + ...base, + cluster: overrides.cluster ?? makeCluster({ id: base.clusterId }), + } +} + +export function makeEnvironmentWithStage(overrides: Partial = {}): EnvironmentWithStage { + const base = makeEnvironment(overrides) + return { + ...base, + stage: overrides.stage ?? makeStage({ id: base.stageId }), + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts new file mode 100644 index 0000000000..1933528eb8 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts @@ -0,0 +1,222 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { BadRequestException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeCluster, makeEnvironment, makeEnvironmentWithCluster, makeProject, makeStage } from './environment-testing.utils' +import { EnvironmentValidationService } from './environment-validation.service' + +describe('environmentValidationService', () => { + let module: TestingModule + let service: EnvironmentValidationService + let datastore: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const environmentId = '22222222-2222-2222-2222-222222222222' + const clusterId = '33333333-3333-3333-3333-333333333333' + const stageId = '44444444-4444-4444-4444-444444444444' + + const validCreateEnvironment = { + name: 'dev', + clusterId, + stageId, + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + datastore = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentValidationService, + { provide: EnvironmentDatastoreService, useValue: datastore }, + ], + }).compile() + + service = module.get(EnvironmentValidationService) + }) + + describe('validateCreate', () => { + it('should pass when the stage, name and cluster are valid and no limit applies', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + // cpu/memory at 0 means the cluster resources are not configured + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)).resolves.toBeUndefined() + }) + + it('should reject when the environment name is already taken', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(makeEnvironment({ projectId, name: validCreateEnvironment.name })) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow(BadRequestException) + }) + + it('should reject when the stage does not exist', async () => { + datastore.getStageById.mockResolvedValue(null) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Type d\'environnement invalide.') + }) + + it('should reject when the cluster is not available for the project', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(null) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Cluster invalide.') + }) + + it('should reject when the cluster does not have enough resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 4, gpu: 0, memory: 8 })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le cluster ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ clusterId }) + }) + + it('should reject when the project does not have enough prod resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'prod' })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + prodCpu: 2, + prodGpu: 0, + prodMemory: 4, + })) + datastore.getProdStageIds.mockResolvedValue([{ id: stageId }]) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 1, gpu: 0, memory: 2 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le projet ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + + it('should reject when the project does not have enough non-prod resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'dev' })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + hprodCpu: 2, + hprodGpu: 0, + hprodMemory: 4, + })) + datastore.getProdStageIds.mockResolvedValue([{ id: '55555555-5555-5555-5555-555555555555' }]) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 1, gpu: 0, memory: 2 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le projet ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + }) + + describe('validateUpdate', () => { + it('should pass when the project is limitless and the cluster limits are not configured', async () => { + const environment = makeEnvironmentWithCluster({ + projectId, + cluster: makeCluster({ id: clusterId, cpu: 0, memory: 0 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + }) + + it('should reject when the cluster does not have enough resources', async () => { + const environment = makeEnvironmentWithCluster({ + projectId, + cluster: makeCluster({ id: clusterId, cpu: 4, gpu: 0, memory: 8 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)) + .rejects.toThrow('Le cluster ne dispose pas de suffisamment de ressources : CPU, GPU, Mémoire.') + }) + + it('should not count the environment being updated in the cluster consumed resources', async () => { + const environment = makeEnvironmentWithCluster({ + id: environmentId, + projectId, + cluster: makeCluster({ id: clusterId, cpu: 8, gpu: 2, memory: 16 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + // the sum only covers the other environments of the cluster + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ + clusterId, + id: { not: environmentId }, + }) + }) + + it('should not count the environment being updated in the project consumed resources', async () => { + const environment = makeEnvironmentWithCluster({ + id: environmentId, + projectId, + stageId, + cluster: makeCluster({ id: clusterId, cpu: 0, memory: 0 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + prodCpu: 8, + prodGpu: 2, + prodMemory: 16, + })) + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'prod' })) + datastore.getProdStageIds.mockResolvedValue([{ id: stageId }]) + // the sum only covers the other prod environments of the project + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ + projectId, + stageId: { in: [stageId] }, + id: { not: environmentId }, + }) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-validation.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-validation.service.ts new file mode 100644 index 0000000000..f81672410a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment-validation.service.ts @@ -0,0 +1,152 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Cluster, Prisma, Project } from '@prisma/client' +import type { EnvironmentWithCluster } from './environment-datastore.service' +import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { PROD_STAGE_NAME } from './environment.constants' + +interface EnvironmentResources { + cpu: number + gpu: number + memory: number +} + +/** cpu and memory both at 0 mean the limits were never configured, so quota checks do not apply. */ +function areResourceLimitsConfigured(limit: EnvironmentResources): boolean { + return limit.cpu !== 0 || limit.memory !== 0 +} + +/** + * Business rules for environments: name unicity, stage and cluster validity, + * cluster and project resource quotas. Existence/ownership (404) concerns stay + * in EnvironmentService; this service only decides whether a request is allowed (400). + */ +@Injectable() +export class EnvironmentValidationService { + constructor( + @Inject(EnvironmentDatastoreService) private readonly environmentDatastoreService: EnvironmentDatastoreService, + ) {} + + async validateCreate(projectId: string, input: CreateEnvironment): Promise { + const errorMessages: string[] = [] + const [stage, sameNameEnvironment, cluster] = await Promise.all([ + this.environmentDatastoreService.getStageById(input.stageId), + this.environmentDatastoreService.getEnvironmentByName(projectId, input.name), + this.environmentDatastoreService.getAvailableCluster(input.clusterId, projectId), + ]) + + if (sameNameEnvironment) errorMessages.push('Ce nom d\'environnement est déjà pris.') + if (!stage) errorMessages.push('Type d\'environnement invalide.') + if (cluster) { + const clusterError = await this.checkClusterResources(input, cluster) + if (clusterError) errorMessages.push(clusterError) + + const project = await this.environmentDatastoreService.getProjectById(projectId) + const projectError = await this.checkProjectResources(input, input.stageId, project) + if (projectError) errorMessages.push(projectError) + } else { + errorMessages.push('Cluster invalide.') + } + + if (errorMessages.length > 0) { + throw new BadRequestException(errorMessages.join('\n')) + } + } + + async validateUpdate(environment: EnvironmentWithCluster, input: UpdateEnvironment): Promise { + const errorMessages: string[] = [] + const clusterError = await this.checkClusterResources(input, environment.cluster, environment.id) + if (clusterError) errorMessages.push(clusterError) + + const project = await this.environmentDatastoreService.getProjectById(environment.projectId) + const projectError = await this.checkProjectResources(input, environment.stageId, project, environment.id) + if (projectError) errorMessages.push(projectError) + + if (errorMessages.length > 0) { + throw new BadRequestException(errorMessages.join('\n')) + } + } + + private async checkClusterResources(request: EnvironmentResources, cluster: Cluster, excludedEnvironmentId?: string): Promise { + const overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: cluster.cpu, gpu: cluster.gpu, memory: cluster.memory }, + where: { clusterId: cluster.id }, + excludedEnvironmentId, + }) + if (overflowResources.length > 0) { + return `Le cluster ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.` + } + return undefined + } + + private async checkProjectResources(request: EnvironmentResources, stageId: string, project: Project, excludedEnvironmentId?: string): Promise { + if (project.limitless) { + // No limits + return undefined + } + const [stage, prodStages] = await Promise.all([ + this.environmentDatastoreService.getStageById(stageId), + this.environmentDatastoreService.getProdStageIds(), + ]) + const prodStageIds = prodStages.map(s => s.id) + + let overflowResources: string[] + if (stage?.name === PROD_STAGE_NAME) { + overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: project.prodCpu, gpu: project.prodGpu, memory: project.prodMemory }, + where: { + projectId: project.id, + stageId: { in: prodStageIds }, + }, + excludedEnvironmentId, + }) + } else { // hprod + overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: project.hprodCpu, gpu: project.hprodGpu, memory: project.hprodMemory }, + where: { + projectId: project.id, + stageId: { notIn: prodStageIds }, + }, + excludedEnvironmentId, + }) + } + if (overflowResources.length > 0) { + return `Le projet ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.` + } + return undefined + } + + /** + * On update, the environment being updated must be excluded from the consumed-resources + * sum: its requested resources replace its current ones. Counting both would reject + * legitimate updates within the quota (#1911). + */ + private async getOverflowResources({ request, limit, where, excludedEnvironmentId }: { + request: EnvironmentResources + limit: EnvironmentResources + where: Prisma.EnvironmentWhereInput + excludedEnvironmentId?: string + }): Promise { + if (!areResourceLimitsConfigured(limit)) { + return [] + } + const environmentResources = await this.environmentDatastoreService.sumEnvironmentResources( + excludedEnvironmentId === undefined ? where : { ...where, id: { not: excludedEnvironmentId } }, + ) + // A null sum means no environment matched: nothing is consumed yet. + const insufficientResources: string[] = [] + if ((environmentResources._sum.cpu ?? 0) + request.cpu > limit.cpu) { + insufficientResources.push('CPU') + } + if ((environmentResources._sum.gpu ?? 0) + request.gpu > limit.gpu) { + insufficientResources.push('GPU') + } + if ((environmentResources._sum.memory ?? 0) + request.memory > limit.memory) { + insufficientResources.push('Mémoire') + } + return insufficientResources + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.constants.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.constants.ts new file mode 100644 index 0000000000..b55c418847 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.constants.ts @@ -0,0 +1,2 @@ +/** Name of the stage whose environments consume the project's production resource quotas. */ +export const PROD_STAGE_NAME = 'prod' diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts new file mode 100644 index 0000000000..1449e91a3a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts @@ -0,0 +1,128 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { makeEnvironment, makeEnvironmentWithStage } from './environment-testing.utils' +import { EnvironmentController } from './environment.controller' +import { EnvironmentService } from './environment.service' + +describe('environmentController', () => { + let module: TestingModule + let controller: EnvironmentController + let service: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + + const project: ProjectContext = { id: projectId, slug: 'my-project' } + const user: UserContext = { userId } + const request = { id: requestId } as FastifyRequest + + const validCreateEnvironment = { + name: 'dev', + clusterId: '22222222-2222-2222-2222-222222222222', + stageId: '33333333-3333-3333-3333-333333333333', + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + service = mockDeep() + + module = await Test.createTestingModule({ + controllers: [EnvironmentController], + providers: [ + { provide: EnvironmentService, useValue: service }, + ], + }) + .overrideGuard(ProjectGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(EnvironmentController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call environmentService.listByProjectId with projectId', async () => { + const expectedResult = [makeEnvironmentWithStage({ projectId })] + + service.listByProjectId.mockResolvedValue(expectedResult) + + const result = await controller.list(project) + + expect(service.listByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('create', () => { + it('should validate body and call environmentService.createEnvironment', async () => { + const expectedResult = makeEnvironment({ projectId }) + + service.createEnvironment.mockResolvedValue(expectedResult) + + const result = await controller.create(validCreateEnvironment, project, user, request) + + expect(service.createEnvironment).toHaveBeenCalledWith( + projectId, + validCreateEnvironment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('update', () => { + it('should validate body and call environmentService.updateEnvironment', async () => { + const environmentId = '44444444-4444-4444-4444-444444444444' + const expectedResult = makeEnvironment({ id: environmentId, projectId }) + + service.updateEnvironment.mockResolvedValue(expectedResult) + + const result = await controller.update(environmentId, validUpdateEnvironment, project, user, request) + + expect(service.updateEnvironment).toHaveBeenCalledWith( + projectId, + environmentId, + validUpdateEnvironment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('delete', () => { + it('should call environmentService.deleteEnvironment with environmentId', async () => { + const environmentId = '44444444-4444-4444-4444-444444444444' + + service.deleteEnvironment.mockResolvedValue(undefined) + + const result = await controller.delete(environmentId, project, user, request) + + expect(service.deleteEnvironment).toHaveBeenCalledWith(projectId, environmentId, userId, requestId) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.controller.ts new file mode 100644 index 0000000000..7589f241f8 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.controller.ts @@ -0,0 +1,73 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Environment } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { EnvironmentWithStage } from './environment-datastore.service' +import { CreateEnvironmentSchema, UpdateEnvironmentSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { EnvironmentService } from './environment.service' + +@Controller('api/v2/projects/:projectId/environments') +@UseGuards(ProjectGuard) +export class EnvironmentController { + constructor(@Inject(EnvironmentService) private readonly environmentService: EnvironmentService) {} + + @Get('') + @RequireAdminPermission('ListProjects') + @RequireProjectPermission('ListEnvironments') + list(@Project() project: ProjectContext): Promise { + return this.environmentService.listByProjectId(project.id) + } + + @Post('') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.CREATED) + create( + @Body(new ZodValidationPipe(CreateEnvironmentSchema)) data: CreateEnvironment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.createEnvironment(project.id, data, user.userId, request.id) + } + + @Put(':environmentId') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.OK) + update( + @Param('environmentId', ParseUUIDPipe) environmentId: string, + @Body(new ZodValidationPipe(UpdateEnvironmentSchema)) data: UpdateEnvironment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.updateEnvironment(project.id, environmentId, data, user.userId, request.id) + } + + @Delete(':environmentId') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.NO_CONTENT) + delete( + @Param('environmentId', ParseUUIDPipe) environmentId: string, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.deleteEnvironment(project.id, environmentId, user.userId, request.id) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.module.ts new file mode 100644 index 0000000000..a9c4f597d4 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { EnvironmentValidationService } from './environment-validation.service' +import { EnvironmentController } from './environment.controller' +import { EnvironmentService } from './environment.service' + +@Module({ + imports: [AppEventsModule, InfrastructureModule], + controllers: [EnvironmentController], + providers: [ + EnvironmentDatastoreService, + EnvironmentValidationService, + EnvironmentService, + ], +}) +export class EnvironmentModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.service.spec.ts new file mode 100644 index 0000000000..d9baf3db40 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.service.spec.ts @@ -0,0 +1,183 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeEnvironment, makeEnvironmentWithCluster, makeEnvironmentWithStage } from './environment-testing.utils' +import { EnvironmentValidationService } from './environment-validation.service' +import { EnvironmentService } from './environment.service' + +describe('environmentService', () => { + let module: TestingModule + let service: EnvironmentService + let datastore: DeepMockProxy + let validation: DeepMockProxy + let appEvents: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + const environmentId = '22222222-2222-2222-2222-222222222222' + const clusterId = '33333333-3333-3333-3333-333333333333' + const stageId = '44444444-4444-4444-4444-444444444444' + + const validCreateEnvironment = { + name: 'dev', + clusterId, + stageId, + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + datastore = mockDeep() + validation = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentService, + { provide: EnvironmentDatastoreService, useValue: datastore }, + { provide: EnvironmentValidationService, useValue: validation }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(EnvironmentService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('listByProjectId', () => { + it('should return environments by projectId', async () => { + const environments = [makeEnvironmentWithStage({ id: environmentId, projectId })] + datastore.getEnvironmentsByProjectId.mockResolvedValue(environments) + + const result = await service.listByProjectId(projectId) + + expect(datastore.getEnvironmentsByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(environments) + }) + }) + + describe('createEnvironment', () => { + it('should validate, create the environment and trigger the project reconciliation', async () => { + const environment = makeEnvironment({ id: environmentId, projectId, clusterId, stageId }) + validation.validateCreate.mockResolvedValue(undefined) + datastore.createEnvironment.mockResolvedValue(environment) + appEvents.emitProjectEvent.mockResolvedValue({}) + + const result = await service.createEnvironment(projectId, validCreateEnvironment, userId, requestId) + + expect(validation.validateCreate).toHaveBeenCalledWith(projectId, validCreateEnvironment) + expect(datastore.createEnvironment).toHaveBeenCalledWith({ + projectId, + name: validCreateEnvironment.name, + clusterId, + stageId, + cpu: validCreateEnvironment.cpu, + gpu: validCreateEnvironment.gpu, + memory: validCreateEnvironment.memory, + autosync: validCreateEnvironment.autosync, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Create Environment', + userId, + requestId, + }) + expect(result).toEqual(environment) + }) + + it('should not create the environment when the validation fails', async () => { + validation.validateCreate.mockRejectedValue(new BadRequestException('Cluster invalide.')) + + await expect(service.createEnvironment(projectId, validCreateEnvironment, userId, requestId)) + .rejects.toThrow(BadRequestException) + expect(datastore.createEnvironment).not.toHaveBeenCalled() + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) + }) + + describe('updateEnvironment', () => { + it('should validate, update the environment and trigger the project reconciliation', async () => { + const existing = makeEnvironmentWithCluster({ id: environmentId, projectId }) + const updated = makeEnvironment({ id: environmentId, projectId, ...validUpdateEnvironment }) + datastore.getProjectEnvironment.mockResolvedValue(existing) + validation.validateUpdate.mockResolvedValue(undefined) + datastore.updateEnvironment.mockResolvedValue(updated) + appEvents.emitProjectEvent.mockResolvedValue({}) + + const result = await service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId) + + expect(validation.validateUpdate).toHaveBeenCalledWith(existing, validUpdateEnvironment) + expect(datastore.updateEnvironment).toHaveBeenCalledWith(environmentId, validUpdateEnvironment) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Update Environment', + userId, + requestId, + }) + expect(result).toEqual(updated) + }) + + it('should reject when the environment is not found in the project', async () => { + datastore.getProjectEnvironment.mockResolvedValue(null) + + await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) + .rejects.toThrow(NotFoundException) + expect(datastore.getProjectEnvironment).toHaveBeenCalledWith(projectId, environmentId) + expect(validation.validateUpdate).not.toHaveBeenCalled() + expect(datastore.updateEnvironment).not.toHaveBeenCalled() + }) + + it('should not update the environment when the validation fails', async () => { + datastore.getProjectEnvironment.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId, projectId })) + validation.validateUpdate.mockRejectedValue(new BadRequestException('Le cluster ne dispose pas de suffisamment de ressources : CPU.')) + + await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) + .rejects.toThrow(BadRequestException) + expect(datastore.updateEnvironment).not.toHaveBeenCalled() + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) + }) + + describe('deleteEnvironment', () => { + it('should delete the environment and trigger the project reconciliation', async () => { + datastore.getProjectEnvironment.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId, projectId })) + datastore.deleteEnvironment.mockResolvedValue(makeEnvironment({ id: environmentId, projectId })) + appEvents.emitProjectEvent.mockResolvedValue({}) + + await service.deleteEnvironment(projectId, environmentId, userId, requestId) + + expect(datastore.deleteEnvironment).toHaveBeenCalledWith(environmentId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Delete Environment', + userId, + requestId, + }) + }) + + it('should reject when the environment is not found in the project', async () => { + datastore.getProjectEnvironment.mockResolvedValue(null) + + await expect(service.deleteEnvironment(projectId, environmentId, userId, requestId)) + .rejects.toThrow(NotFoundException) + expect(datastore.getProjectEnvironment).toHaveBeenCalledWith(projectId, environmentId) + expect(datastore.deleteEnvironment).not.toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.service.ts new file mode 100644 index 0000000000..d181c84966 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/environment/environment.service.ts @@ -0,0 +1,81 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Environment } from '@prisma/client' +import type { EventLogAction } from '../events/app-events.service' +import type { EnvironmentWithCluster, EnvironmentWithStage } from './environment-datastore.service' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { EnvironmentValidationService } from './environment-validation.service' + +@Injectable() +export class EnvironmentService { + private readonly logger = new Logger(EnvironmentService.name) + + constructor( + @Inject(EnvironmentDatastoreService) private readonly environmentDatastoreService: EnvironmentDatastoreService, + @Inject(EnvironmentValidationService) private readonly environmentValidationService: EnvironmentValidationService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async listByProjectId(projectId: string): Promise { + return this.environmentDatastoreService.getEnvironmentsByProjectId(projectId) + } + + async createEnvironment(projectId: string, environmentToCreate: CreateEnvironment, userId: string, requestId: string): Promise { + await this.environmentValidationService.validateCreate(projectId, environmentToCreate) + + const environment = await this.environmentDatastoreService.createEnvironment({ + projectId, + name: environmentToCreate.name, + clusterId: environmentToCreate.clusterId, + stageId: environmentToCreate.stageId, + cpu: environmentToCreate.cpu, + gpu: environmentToCreate.gpu, + memory: environmentToCreate.memory, + autosync: environmentToCreate.autosync, + }) + + this.reconcileProject(projectId, 'Create Environment', userId, requestId) + return environment + } + + async updateEnvironment(projectId: string, environmentId: string, environmentToUpdate: UpdateEnvironment, userId: string, requestId: string): Promise { + const existingEnvironment = await this.getProjectEnvironmentOrThrow(projectId, environmentId) + await this.environmentValidationService.validateUpdate(existingEnvironment, environmentToUpdate) + + const environment = await this.environmentDatastoreService.updateEnvironment(environmentId, { + cpu: environmentToUpdate.cpu, + gpu: environmentToUpdate.gpu, + memory: environmentToUpdate.memory, + autosync: environmentToUpdate.autosync, + }) + + this.reconcileProject(projectId, 'Update Environment', userId, requestId) + return environment + } + + async deleteEnvironment(projectId: string, environmentId: string, userId: string, requestId: string): Promise { + await this.getProjectEnvironmentOrThrow(projectId, environmentId) + await this.environmentDatastoreService.deleteEnvironment(environmentId) + this.reconcileProject(projectId, 'Delete Environment', userId, requestId) + } + + private async getProjectEnvironmentOrThrow(projectId: string, environmentId: string): Promise { + const environment = await this.environmentDatastoreService.getProjectEnvironment(projectId, environmentId) + if (!environment) { + throw new NotFoundException('Environnement introuvable') + } + return environment + } + + /** + * Triggers the project reconciliation without blocking the response: listener + * outcomes (including failures) are persisted in the admin log by AppEventsService. + */ + private reconcileProject(projectId: string, action: EventLogAction, userId: string, requestId: string): void { + this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) + .catch((error: unknown) => { + this.logger.error(`project.upsert reconciliation failed (projectId=${projectId})`, error instanceof Error ? error.stack : String(error)) + }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.module.ts new file mode 100644 index 0000000000..08130cfc7b --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { LogModule } from '../log/log.module' +import { AppEventsService } from './app-events.service' + +@Module({ + imports: [InfrastructureModule, LogModule], + providers: [AppEventsService], + exports: [AppEventsService], +}) +export class AppEventsModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.service.spec.ts new file mode 100644 index 0000000000..7bd6fa2ea2 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.service.spec.ts @@ -0,0 +1,178 @@ +import type { EventEmitter2 as EventEmitter2Type } from '@nestjs/event-emitter' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { projectSelect } from '../project/project-queries.utils' +import { makeProject } from '../project/project-testing.utils' +import { AppEventsService } from './app-events.service' + +describe('appEventsService', () => { + let module: TestingModule + let service: AppEventsService + let prisma: DeepMockProxy + let eventEmitter: DeepMockProxy + let logs: DeepMockProxy + let config: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + eventEmitter = mockDeep({ emitAsync: vi.fn().mockResolvedValue([]) }) + logs = mockDeep() + config = mockDeep({ appVersion: 'test-version' }) + + module = await Test.createTestingModule({ + providers: [ + AppEventsService, + { provide: PrismaService, useValue: prisma }, + { provide: EventEmitter2, useValue: eventEmitter }, + { provide: LogService, useValue: logs }, + { provide: BASE_CONFIG, useValue: config }, + ], + }).compile() + + service = module.get(AppEventsService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('loads the project by id, emits and logs the merged listener results', async () => { + const project = makeProject() + prisma.project.findUnique.mockResolvedValue(project) + eventEmitter.emitAsync.mockResolvedValue([ + undefined, // listener not yet migrated to capturePluginResult + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + { argocd: { status: 'KO', message: 'Sync failed', executionTime: 20, error: new Error('Sync failed') } }, + ]) + + const results = await service.emitProjectEvent('project.upsert', project.id, { + action: 'Create Project', + userId: 'user-id', + requestId: 'request-id', + }) + + expect(prisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: project.id }, + select: projectSelect, + }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', project) + expect(results).toEqual({ + gitlab: expect.objectContaining({ status: 'OK' }), + argocd: expect.objectContaining({ status: 'KO' }), + }) + expect(logs.addLog).toHaveBeenCalledWith({ + action: 'Create Project', + userId: 'user-id', + requestId: 'request-id', + projectId: project.id, + data: { + args: project, + failed: ['argocd'], + results: { + gitlab: { + status: { result: 'OK', message: 'Up to date' }, + executionTime: { main: 10 }, + }, + argocd: { + status: { result: 'KO', message: 'Sync failed' }, + executionTime: { main: 20 }, + error: expect.stringContaining('"message":"Sync failed"'), + }, + }, + totalExecutionTime: expect.any(Number), + messageResume: 'Errors:\nargocd: Sync failed;', + }, + }) + }) + + it('marks the project as failed when a listener reports a KO result', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { argocd: { status: 'KO', message: 'Sync failed', executionTime: 20, error: new Error('Sync failed') } }, + ]) + + await service.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: project.id }, + data: { status: 'failed' }, + }) + }) + + it('marks the project as created with the provisioning version after a successful upsert', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + ]) + + await service.emitProjectEvent('project.upsert', project, { action: 'Create Project' }) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: project.id }, + data: { status: 'created', lastSuccessProvisionningVersion: 'test-version' }, + }) + }) + + it('does not touch the project status after a successful delete', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + ]) + + await service.emitProjectEvent('project.delete', project, { action: 'Delete all project resources' }) + + expect(prisma.project.update).not.toHaveBeenCalled() + }) + + it('uses a provided project snapshot without fetching it again', async () => { + const project = makeProject() + + await service.emitProjectEvent('project.delete', project, { action: 'Delete all project resources' }) + + expect(prisma.project.findUnique).not.toHaveBeenCalled() + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.delete', project) + }) + + it('defaults the log userId and requestId to null', async () => { + const project = makeProject() + + await service.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Update Project', + userId: null, + requestId: null, + })) + }) + + it('skips emitting and logging when the project cannot be found', async () => { + prisma.project.findUnique.mockResolvedValue(null) + + const results = await service.emitProjectEvent('project.upsert', 'missing-id', { action: 'Update Project' }) + + expect(results).toEqual({}) + expect(eventEmitter.emitAsync).not.toHaveBeenCalled() + expect(logs.addLog).not.toHaveBeenCalled() + }) + + it('emits and logs project member events with their payload as args', async () => { + const payload = { projectId: 'project-id', userId: 'user-id' } + + await service.emitProjectMemberEvent('projectMember.upsert', payload, { action: 'Add Project Member' }) + + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.upsert', payload) + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Add Project Member', + projectId: 'project-id', + data: expect.objectContaining({ args: payload }), + })) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.service.ts new file mode 100644 index 0000000000..7de8bcf562 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -0,0 +1,138 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { PluginResults } from '../plugin/plugin.utils' +import type { ProjectWithDetails } from '../project/project-queries.utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { getFailedPlugins, mergePluginResults } from '../plugin/plugin.utils' +import { getProject } from '../project/project-queries.utils' +import { formatEventLogData, isPluginResults } from './app-events.utils' + +export type ProjectEventName = 'project.upsert' | 'project.delete' +export type ProjectMemberEventName = 'projectMember.upsert' | 'projectMember.delete' + +export interface ProjectMemberEventPayload { + projectId: string + userId: string +} + +/** Admin-log action labels (legacy hooks wording). */ +export type EventLogAction + = | 'Create Project' | 'Update Project' | 'Delete all project resources' + | 'Replay hooks for Project' | 'Upsert Project Role' + | 'Create Deployment' | 'Update Deployment' | 'Delete Deployment' + | 'Delete all project deployments' + | 'Create Environment' | 'Update Environment' | 'Delete Environment' + | 'Add Project Member' | 'Update Project Member' | 'Remove Project Member' + +export interface EventContext { + /** Action label persisted in the admin log. */ + action: EventLogAction + userId?: string | null + requestId?: string | null +} + +/** + * Single entry point to emit domain events: emits through EventEmitter2, merges the + * `PluginResults` returned by every `capturePluginResult` listener and persists one admin + * log per event. Emitters only provide an id and the log context. + */ +@Injectable() +export class AppEventsService { + private readonly logger = new Logger(AppEventsService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, + @Inject(LogService) private readonly logs: LogService, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + /** + * Emits a project event and logs the merged listener results. + * + * Pass a project id in the general case: the project is loaded here, after the + * emitting transaction commits, so every listener sees the same committed state. + * Pass an already-loaded snapshot when the row no longer reflects what listeners + * must act on (e.g. archiving renames the slug before emitting `project.delete`). + */ + async emitProjectEvent( + event: ProjectEventName, + projectOrId: string | ProjectWithDetails, + context: EventContext, + ): Promise { + const project = typeof projectOrId === 'string' + ? await getProject(this.prisma, projectOrId) + : projectOrId + + if (!project) { + this.logger.warn(`${event} skipped: project not found (projectId=${String(projectOrId)})`) + return {} + } + + const results = await this.emitAndLog(event, project, project.id, context) + await this.updateProjectStatus(event, project.id, results) + return results + } + + async emitProjectMemberEvent( + event: ProjectMemberEventName, + payload: ProjectMemberEventPayload, + context: EventContext, + ): Promise { + return this.emitAndLog(event, payload, payload.projectId, context) + } + + private async emitAndLog( + event: string, + payload: unknown, + projectId: string, + context: EventContext, + ): Promise { + const start = process.hrtime.bigint() + const responses = await this.eventEmitter.emitAsync(event, payload) + const totalExecutionTime = Number(process.hrtime.bigint() - start) / 1_000_000 + + const results = mergePluginResults(responses.filter(isPluginResults)) + this.logger.log(`${event} completed (projectId=${projectId}, services=${Object.keys(results).join(',') || 'none'})`) + + await this.logs.addLog({ + action: context.action, + data: formatEventLogData(payload, results, totalExecutionTime), + userId: context.userId ?? null, + requestId: context.requestId ?? null, + projectId, + }) + + return results + } + + /** + * Reflects the listeners' outcome on the project row (legacy hooks behavior): + * any KO result marks the project `failed`; a fully successful upsert marks it + * `created` and records the provisioning version. A successful `project.delete` + * leaves the `archived` status set when the project was archived. + */ + private async updateProjectStatus( + event: ProjectEventName, + projectId: string, + results: PluginResults, + ): Promise { + const failed = getFailedPlugins(results) + + if (failed.length) { + this.logger.warn(`${event} marked project as failed (projectId=${projectId}, failed=${failed.join(',')})`) + await this.prisma.project.update({ where: { id: projectId }, data: { status: 'failed' } }) + return + } + + if (event === 'project.upsert') { + await this.prisma.project.update({ + where: { id: projectId }, + data: { status: 'created', lastSuccessProvisionningVersion: this.baseConfig.appVersion }, + }) + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts new file mode 100644 index 0000000000..3c1ba22a7a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts @@ -0,0 +1,96 @@ +import type { PluginResults } from '../plugin/plugin.utils' +import { NetworkError } from '@keycloak/keycloak-admin-client' +import { describe, expect, it } from 'vitest' +import { formatEventLogData, isPluginResults, serializeError } from './app-events.utils' + +describe('isPluginResults', () => { + it('should accept an object produced by a capturePluginResult handler', () => { + expect(isPluginResults({ gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } })).toBe(true) + expect(isPluginResults({ argocd: { status: 'KO', message: 'boom', executionTime: 10, error: new Error('boom') } })).toBe(true) + }) + + it('should reject non-result responses', () => { + expect(isPluginResults(undefined)).toBe(false) + expect(isPluginResults(null)).toBe(false) + expect(isPluginResults('ok')).toBe(false) + expect(isPluginResults({})).toBe(false) + expect(isPluginResults({ gitlab: { foo: 'bar' } })).toBe(false) + expect(isPluginResults({ gitlab: { status: 'MAYBE' } })).toBe(false) + }) +}) + +describe('serializeError', () => { + it('should serialize an Error with name, message and stack', () => { + const parsed = JSON.parse(serializeError(new Error('boom'))) + expect(parsed).toEqual({ name: 'Error', message: 'boom', stack: expect.any(String) }) + }) + + it('should include HTTP details from errors carrying a fetch Response', () => { + const error = new NetworkError('Network response was not OK.', { + response: new Response('conflict', { status: 409 }), + responseData: { errorMessage: 'Sibling group named \'admin\' already exists.' }, + }) + const parsed = JSON.parse(serializeError(error)) + expect(parsed).toEqual({ + name: 'Error', + message: 'Network response was not OK.', + status: 409, + url: expect.any(String), + responseData: { errorMessage: 'Sibling group named \'admin\' already exists.' }, + stack: expect.any(String), + }) + }) + + it('should serialize non-Error values', () => { + expect(serializeError({ code: 42 })).toBe('{"code":42}') + expect(serializeError('boom')).toBe('"boom"') + }) +}) + +describe('formatEventLogData', () => { + const args = { id: 'project-1', slug: 'project-1' } + + it('should format results in the LogSchema shape', () => { + const results: PluginResults = { + gitlab: { status: 'OK', message: 'Up to date', executionTime: 12.4 }, + argocd: { status: 'KO', message: 'Sync failed', executionTime: 100.9, error: new Error('Sync failed') }, + } + + expect(formatEventLogData(args, results, 120.6)).toEqual({ + args, + failed: ['argocd'], + results: { + gitlab: { + status: { result: 'OK', message: 'Up to date' }, + executionTime: { main: 12 }, + }, + argocd: { + status: { result: 'KO', message: 'Sync failed' }, + executionTime: { main: 101 }, + error: expect.stringContaining('"message":"Sync failed"'), + }, + }, + totalExecutionTime: 121, + messageResume: 'Errors:\nargocd: Sync failed;', + }) + }) + + it('should omit the failed list and produce a success resume when everything is OK', () => { + const results: PluginResults = { + gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 }, + } + + const data = formatEventLogData(args, results, 10) + expect(data).not.toHaveProperty('failed') + expect(data.messageResume).toBe('Success') + }) + + it('should handle events without any listener', () => { + expect(formatEventLogData(args, {}, 1)).toEqual({ + args, + results: {}, + totalExecutionTime: 1, + messageResume: 'Success', + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.utils.ts new file mode 100644 index 0000000000..a9cf62706b --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/events/app-events.utils.ts @@ -0,0 +1,77 @@ +import type { LogData } from '../log/log.service' +import type { PluginName, PluginResult, PluginResults } from '../plugin/plugin.utils' +import { getErrorHttpDetails } from '../../utils/http.utils' +import { getFailedPlugins } from '../plugin/plugin.utils' + +/** Per-service result as persisted in the admin logs (legacy hooks format, parsed by LogSchema). */ +export interface LoggablePluginResult { + status: { + result: 'OK' | 'KO' + message?: string + } + executionTime: { + main: number + } + error?: string +} + +function isPluginResult(value: unknown): value is PluginResult { + return typeof value === 'object' + && value !== null + && 'status' in value + && ((value as PluginResult).status === 'OK' || (value as PluginResult).status === 'KO') +} + +/** Narrows an `emitAsync` response to a `PluginResults` object produced by `capturePluginResult` handlers. */ +export function isPluginResults(response: unknown): response is PluginResults { + if (typeof response !== 'object' || response === null) return false + const values = Object.values(response) + return values.length > 0 && values.every(isPluginResult) +} + +export function serializeError(error: unknown): string { + if (error instanceof Error) { + return JSON.stringify({ + name: error.name, + message: error.message, + ...getErrorHttpDetails(error), + stack: error.stack, + }) + } + try { + return JSON.stringify(error) + } catch { + return String(error) + } +} + +function toLoggableResult(result: PluginResult): LoggablePluginResult { + return { + status: { + result: result.status, + ...(result.message ? { message: result.message } : {}), + }, + executionTime: { main: Math.round(result.executionTime) }, + ...(result.status === 'KO' ? { error: serializeError(result.error) } : {}), + } +} + +function buildMessageResume(results: PluginResults, failed: PluginName[]): string { + if (!failed.length) return 'Success' + const errorLines = failed.map(service => `${service}: ${results[service]?.message};`).join('\n') + return `Errors:\n${errorLines}` +} + +/** Builds the log `data` payload for an event: the emitted args plus every listener's result. */ +export function formatEventLogData(args: unknown, results: PluginResults, totalExecutionTime: number): LogData { + const failed = getFailedPlugins(results) + const entries = Object.entries(results) as [PluginName, PluginResult][] + + return { + args, + ...(failed.length ? { failed } : {}), + results: Object.fromEntries(entries.map(([service, result]) => [service, toLoggableResult(result)])), + totalExecutionTime: Math.round(totalExecutionTime), + messageResume: buildMessageResume(results, failed), + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts new file mode 100644 index 0000000000..0db65f4630 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts @@ -0,0 +1,689 @@ +import type { ExpandedGroupSchema, Gitlab as GitlabApi, ProjectSchema } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { MockedFunction } from 'vitest' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' +import { + makeAccessTokenExposedSchema, + makeAccessTokenSchema, + makeExpandedGroupSchema, + makeExpandedUserSchema, + makeGitbeakerRequestError, + makeGroupSchema, + makeMemberSchema, + makeOffsetPagination, + makePipelineTriggerToken, + makeProjectSchema, + makeRepositoryFileExpandedSchema, + makeRepositoryTreeSchema, +} from './gitlab-testing.utils' +import { + GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, + MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, + PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, + USER_ID_CUSTOM_ATTRIBUTE_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +describe('gitlab-client', () => { + let service: GitlabClientService + let gitlabApi: DeepMockProxy + + beforeEach(async () => { + gitlabApi = mockDeep() + const config = mockDeep({ + url: 'https://gitlab.internal', + token: 'token', + internalUrl: 'https://gitlab.internal', + projectRootDir: 'forge', + mirrorTokenExpirationDays: 30, + internalOrPublicUrl: 'https://gitlab.internal', + }) + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GitlabClientService, + { provide: GITLAB_REST_CLIENT, useValue: gitlabApi }, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + service = module.get(GitlabClientService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('getOrCreateInfraProject', () => { + it('should create infra project if not exists', async () => { + const zoneSlug = 'zone-1' + const rootId = 123 + const infraGroupId = 456 + const projectId = 789 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + + gitlabApi.Groups.show.mockResolvedValueOnce({ id: rootId, full_path: 'forge' } as ExpandedGroupSchema) + + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + gitlabApi.Groups.create.mockResolvedValue({ id: infraGroupId, full_path: 'forge/infra' } as ExpandedGroupSchema) + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.create.mockResolvedValue({ + id: projectId, + path_with_namespace: 'forge/infra/zone-1', + http_url_to_repo: 'https://gitlab.internal/infra/zone-1.git', + } as ProjectSchema) + + const result = await service.getOrCreateInfraGroupRepo(zoneSlug) + + expect(result).toEqual({ + id: projectId, + http_url_to_repo: 'https://gitlab.internal/infra/zone-1.git', + path_with_namespace: 'forge/infra/zone-1', + }) + expect(gitlabApi.Groups.create).toHaveBeenCalledWith('infra', 'infra', expect.any(Object)) + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(rootId, GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(infraGroupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(infraGroupId, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ + name: zoneSlug, + path: zoneSlug, + namespaceId: infraGroupId, + })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + }) + + describe('commitCreateOrUpdate', () => { + it('should create commit if file not exists', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'content' + const filePath = 'file.txt' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + const notFoundError = makeGitbeakerRequestError({ description: '404 File Not Found' }) + gitlabRepositoryFilesShowMock.mockRejectedValue(notFoundError) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + message, + [{ action: 'create', filePath, content }], + ) + }) + + it('should update commit if content differs', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'new content' + const filePath = 'file.txt' + const oldHash = 'oldhash' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + gitlabRepositoryFilesShowMock.mockResolvedValue(makeRepositoryFileExpandedSchema({ content_sha256: oldHash })) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + message, + [{ action: 'update', filePath, content }], + ) + }) + + it('should do nothing if content matches', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'content' + const filePath = 'file.txt' + const hash = 'ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + gitlabRepositoryFilesShowMock.mockResolvedValue(makeRepositoryFileExpandedSchema({ content_sha256: hash })) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).not.toHaveBeenCalled() + }) + }) + + describe('getOrCreateProjectGroup', () => { + it('should create project group if not exists', async () => { + const projectSlug = 'project-1' + const rootId = 123 + const groupId = 456 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce(makeExpandedGroupSchema({ id: rootId, full_path: 'forge' })) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.create.mockResolvedValue(makeExpandedGroupSchema({ id: groupId, name: projectSlug, path: projectSlug, full_path: `forge/${projectSlug}` })) + + const result = await service.getOrCreateProjectSubGroup(projectSlug) + + expect(result).toEqual(expect.objectContaining({ id: groupId, name: projectSlug })) + expect(gitlabApi.Groups.create).toHaveBeenCalledWith(projectSlug, projectSlug, expect.objectContaining({ + parentId: rootId, + })) + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(groupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(groupId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + }) + + it('should return existing group', async () => { + const projectSlug = 'project-1' + const rootId = 123 + const groupId = 456 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce(makeExpandedGroupSchema({ id: rootId, full_path: 'forge' })) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: rootId, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectSubGroup(projectSlug) + + expect(result).toEqual({ id: groupId, name: projectSlug, parent_id: rootId, full_path: 'forge/project-1' }) + expect(gitlabApi.Groups.create).not.toHaveBeenCalled() + }) + }) + + describe('repositories', () => { + it('should return internal repo url', async () => { + const projectSlug = 'project-1' + const repoName = 'repo-1' + const rootId = 123 + const groupId = 1 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectGroupInternalRepoUrl(projectSlug, repoName) + expect(result).toBe('https://gitlab.internal/forge/project-1/repo-1.git') + }) + + it('should upsert mirror repo', async () => { + const projectSlug = 'project-1' + const repoId = 1 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValue({ + data: [{ id: repoId, path_with_namespace: 'forge/project-1/mirror' }], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: 'mirror' } as ProjectSchema) + + const result = await service.upsertProjectMirrorRepo(projectSlug) + + expect(result).toEqual({ id: repoId, name: 'mirror' }) + expect(gitlabApi.Projects.edit).toHaveBeenCalledWith(repoId, expect.objectContaining({ + name: 'mirror', + path: 'mirror', + })) + }) + + it('should set managed custom attribute when upserting a project repo', async () => { + const projectSlug = 'project-1' + const repoName = 'repo-1' + const repoId = 101 + + gitlabApi.Projects.show.mockResolvedValue(makeProjectSchema({ id: repoId })) + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: repoName } as ProjectSchema) + + const result = await service.upsertProjectGroupRepo(projectSlug, repoName, 'desc') + + expect(result).toEqual({ id: repoId, name: repoName }) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(repoId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + + describe('upsertUser', () => { + it('should create user and set custom attribute if not exists', async () => { + const consoleUser = { id: 'u1', email: 'new@example.com', firstName: 'New', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'new', + name: 'New User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([]) + gitlabApi.Users.create.mockResolvedValue(makeExpandedUserSchema({ id: 999, email: consoleUser.email })) + + const result = await service.upsertUser(gitlabUser, { cpnUserId: consoleUser.id }) + + expect(result).toEqual(expect.objectContaining({ id: 999, email: consoleUser.email })) + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'new@example.com', + username: 'new', + name: 'New User', + externUid: 'new@example.com', + provider: 'openid_connect', + skipConfirmation: true, + })) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(999, USER_ID_CUSTOM_ATTRIBUTE_KEY, consoleUser.id) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(999, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + + it('should set custom attribute if user exists', async () => { + const consoleUser = { id: 'u1', email: 'existing@example.com', firstName: 'Existing', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'existing', + name: 'Existing User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([makeExpandedUserSchema({ id: 1000, email: consoleUser.email })]) + + const result = await service.upsertUser(gitlabUser, { cpnUserId: consoleUser.id }) + + expect(result).toEqual(expect.objectContaining({ id: 1000, email: consoleUser.email })) + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.objectContaining({ + email: 'existing@example.com', + username: 'existing', + name: 'Existing User', + externUid: 'existing@example.com', + provider: 'openid_connect', + })) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(1000, USER_ID_CUSTOM_ATTRIBUTE_KEY, consoleUser.id) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(1000, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.Users.create).not.toHaveBeenCalled() + }) + + it('should set admin flag when provided', async () => { + const consoleUser = { id: 'u1', email: 'admin@example.com', firstName: 'Admin', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'admin', + name: 'Admin User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([]) + gitlabApi.Users.create.mockResolvedValue(makeExpandedUserSchema({ id: 999, email: consoleUser.email })) + + await service.upsertUser({ ...gitlabUser, admin: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'admin@example.com', + username: 'admin', + name: 'Admin User', + externUid: 'admin@example.com', + provider: 'openid_connect', + admin: true, + skipConfirmation: true, + })) + }) + + it('should not disable existing admin flag when enabling auditor flag', async () => { + const consoleUser = { id: 'u1', email: 'admin-auditor@example.com', firstName: 'Admin', lastName: 'Auditor' } + const gitlabUser = { + email: consoleUser.email, + username: 'admin-auditor', + name: 'Admin Auditor', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([ + makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_admin: true }), + ]) + + await service.upsertUser({ ...gitlabUser, auditor: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.not.objectContaining({ + admin: true, + })) + }) + + it('should not disable existing auditor flag when enabling admin flag', async () => { + const consoleUser = { id: 'u1', email: 'auditor-admin@example.com', firstName: 'Auditor', lastName: 'Admin' } + const gitlabUser = { + email: consoleUser.email, + username: 'auditor-admin', + name: 'Auditor Admin', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([ + makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_auditor: true }), + ]) + + await service.upsertUser({ ...gitlabUser, admin: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.not.objectContaining({ + auditor: true, + })) + }) + }) + + it('should create pipeline trigger token if not exists', async () => { + const projectSlug = 'project-1' + const repoId = 1 + const tokenDescription = 'mirroring-from-external-repo' + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValue({ + data: [{ id: repoId, path_with_namespace: 'forge/project-1/mirror' }], + paginationInfo: { next: null }, + }) + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: 'mirror' } as ProjectSchema) + + const gitlabPipelineTriggerTokensAllMock = gitlabApi.PipelineTriggerTokens.all as MockedFunction + gitlabPipelineTriggerTokensAllMock.mockResolvedValue({ + data: [], + paginationInfo: makeOffsetPagination({ next: null }), + }) + + gitlabApi.PipelineTriggerTokens.create.mockResolvedValue(makePipelineTriggerToken({ id: 2, description: tokenDescription })) + + const result = await service.getOrCreateMirrorPipelineTriggerToken(projectSlug) + + expect(result).toEqual(expect.objectContaining({ id: 2, description: tokenDescription })) + expect(gitlabApi.PipelineTriggerTokens.create).toHaveBeenCalledWith(repoId, tokenDescription) + }) + }) + + describe('group Members', () => { + it('should get group members', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const members = [makeMemberSchema({ id: 1, name: 'user' })] + const gitlabGroupMembersAllMock = gitlabApi.GroupMembers.all as MockedFunction + gitlabGroupMembersAllMock.mockResolvedValue(members) + + const result = await service.getGroupMembers(group) + expect(result).toEqual(members) + expect(gitlabApi.GroupMembers.all).toHaveBeenCalledWith(groupId) + }) + + it('should add group member', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const userId = 2 + const accessLevel = 30 + gitlabApi.GroupMembers.add.mockResolvedValue(makeMemberSchema({ id: userId })) + + await service.addGroupMember(group, userId, accessLevel) + expect(gitlabApi.GroupMembers.add).toHaveBeenCalledWith(groupId, userId, accessLevel) + }) + + it('should remove group member', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const userId = 2 + gitlabApi.GroupMembers.remove.mockResolvedValue(undefined) + + await service.removeGroupMember(group, userId) + expect(gitlabApi.GroupMembers.remove).toHaveBeenCalledWith(groupId, userId) + }) + }) + + describe('createProjectMirrorAccessToken', () => { + it('should create project access token with correct scopes', async () => { + const projectSlug = 'project-1' + const groupId = 456 + const tokenName = `${projectSlug}-bot` + const token = makeAccessTokenExposedSchema({ id: 1, name: tokenName, token: 'secret-token' }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: 123, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce({ id: 123, full_path: 'forge' } as ExpandedGroupSchema) + + const gitlabGroupsAllSubgroupsMock = gitlabApi.Groups.allSubgroups as MockedFunction + gitlabGroupsAllSubgroupsMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: 123, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + gitlabApi.GroupAccessTokens.create.mockResolvedValue(token) + + const result = await service.createMirrorAccessToken(projectSlug) + + expect(result).toEqual(token) + expect(gitlabApi.GroupAccessTokens.create).toHaveBeenCalledWith( + groupId, + tokenName, + ['write_repository', 'read_repository', 'read_api'], + expect.any(String), + ) + }) + }) + + describe('getOrCreateProjectGroupRepo', () => { + it('should return existing repo', async () => { + const subGroupPath = 'project-1' + const repoName = 'repo-1' + const fullPath = `${subGroupPath}/${repoName}` + const projectId = 789 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [{ id: projectId, path_with_namespace: `forge/${fullPath}` }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectGroupRepo(subGroupPath, fullPath) + + expect(result).toEqual(expect.objectContaining({ id: projectId })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, 'project-1') + }) + + it('should create repo if not exists', async () => { + const subGroupPath = 'project-1' + const repoName = 'repo-1' + const fullPath = `${subGroupPath}/${repoName}` + const projectId = 789 + const groupId = 456 + const rootId = 123 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: subGroupPath, parent_id: rootId, full_path: `forge/${subGroupPath}` }], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.create.mockResolvedValue({ id: projectId, name: repoName } as ProjectSchema) + + const result = await service.getOrCreateProjectGroupRepo(subGroupPath, fullPath) + + expect(result).toEqual(expect.objectContaining({ id: projectId })) + expect(gitlabApi.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ + name: repoName, + path: repoName, + namespaceId: groupId, + })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, 'project-1') + }) + }) + + describe('getFile', () => { + it('should return file content', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const file = makeRepositoryFileExpandedSchema({ content: 'content' }) + + gitlabApi.RepositoryFiles.show.mockResolvedValue(file) + + const result = await service.getFile(repo, filePath, ref) + expect(result).toEqual(file) + }) + + it('should return undefined on 404', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const error = makeGitbeakerRequestError({ description: '404 File Not Found' }) + + gitlabApi.RepositoryFiles.show.mockRejectedValue(error) + + const result = await service.getFile(repo, filePath, ref) + expect(result).toBeUndefined() + }) + + it('should throw on other errors', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const error = new Error('Some other error') + + gitlabApi.RepositoryFiles.show.mockRejectedValue(error) + + await expect(service.getFile(repo, filePath, ref)).rejects.toThrow(error) + }) + }) + + describe('listFiles', () => { + it('should return files', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const files = [makeRepositoryTreeSchema({ path: 'file.txt' })] + + gitlabApi.Repositories.allRepositoryTrees.mockResolvedValue(files) + + const result = await service.listFiles(repo) + expect(result).toEqual(files) + }) + + it('should return empty array on 404', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const error = makeGitbeakerRequestError({ description: '404 Tree Not Found' }) + + gitlabApi.Repositories.allRepositoryTrees.mockRejectedValue(error) + + const result = await service.listFiles(repo) + expect(result).toEqual([]) + }) + }) + + describe('getProjectToken', () => { + it('should return specific token', async () => { + const projectSlug = 'project-1' + const groupId = 456 + const tokenName = `${projectSlug}-bot` + const token = makeAccessTokenSchema({ id: 1, name: tokenName }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: 123, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce({ id: 123, full_path: 'forge' } as ExpandedGroupSchema) + const gitlabGroupsAllSubgroupsMock = gitlabApi.Groups.allSubgroups as MockedFunction + gitlabGroupsAllSubgroupsMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: 123, full_path: `forge/${projectSlug}` }], + paginationInfo: { next: null }, + }) + + const gitlabGroupAccessTokensAllMock = gitlabApi.GroupAccessTokens.all as MockedFunction + gitlabGroupAccessTokensAllMock.mockResolvedValue({ + data: [token], + paginationInfo: makeOffsetPagination({ next: null }), + }) + + const result = await service.getProjectToken(projectSlug) + expect(result).toEqual(token) + }) + }) + + describe('createUser', () => { + it('should create user', async () => { + const email = 'user@example.com' + const username = 'user' + const name = 'User Name' + const user = makeExpandedUserSchema({ id: 1, username }) + + gitlabApi.Users.create.mockResolvedValue(user) + + const result = await service.createUser({ email, username, name }) + + expect(result).toEqual(user) + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email, + username, + name, + skipConfirmation: true, + })) + }) + }) + + describe('commitMirror', () => { + it('should create mirror commit', async () => { + const repoId = 1 + + await service.commitMirror(repoId) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + expect.any(String), + expect.arrayContaining([ + expect.objectContaining({ filePath: '.gitlab-ci.yml', action: 'create' }), + expect.objectContaining({ filePath: 'mirror.sh', action: 'create' }), + ]), + ) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts new file mode 100644 index 0000000000..d1a755fc9a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -0,0 +1,554 @@ +import type { + AccessLevel, + AccessTokenScopes, + BaseRequestOptions, + CommitAction, + CondensedGroupSchema, + CondensedProjectSchema, + EditUserOptions, + ExpandedUserSchema, + Gitlab, + GroupSchema, + OffsetPagination, + PaginationRequestOptions, + PipelineTriggerTokenSchema, + SimpleUserSchema, +} from '@gitbeaker/core' +import type { GitlabConfig } from './gitlab.module-definition' +import { join } from 'node:path' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { find } from '../../utils/iterable.utils' +import { + GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_PATH, + MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, + MIRROR_REPO_NAME, + PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, + TOKEN_DESCRIPTION, + TOPIC_PLUGIN_MANAGED, + USER_ID_CUSTOM_ATTRIBUTE_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { generateGitlabCIConfigContent, generateMirrorScriptContent, hasFileContentChanged } from './gitlab.utils' + +export const GITLAB_REST_CLIENT = Symbol('GITLAB_REST_CLIENT') + +type With = T & Required> +export type CondensedGroupSchemaWith = With +export type CondensedProjectSchemaWith = With +export type EditUserOptionsWith = With +type UserSchema = SimpleUserSchema | ExpandedUserSchema + +export interface OffsetPaginateOptions { + startPage?: number + perPage?: number + maxPages?: number +} + +@Injectable() +export class GitlabClientService { + private readonly logger = new Logger(GitlabClientService.name) + + constructor( + @Inject(GITLAB_CONFIG) readonly config: GitlabConfig, + @Inject(GITLAB_REST_CLIENT) private readonly client: Gitlab, + ) { + } + + async upsertGroupCustomAttribute(groupId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab group custom attribute (groupId=${groupId}, key=${key})`) + try { + await this.client.GroupCustomAttributes.set(groupId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab group custom attribute (groupId=${groupId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + async upsertProjectCustomAttribute(projectId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab project custom attribute (projectId=${projectId}, key=${key})`) + try { + await this.client.ProjectCustomAttributes.set(projectId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab project custom attribute (projectId=${projectId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + async upsertUserCustomAttribute(userId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab user custom attribute (userId=${userId}, key=${key})`) + try { + await this.client.UserCustomAttributes.set(userId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab user custom attribute (userId=${userId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + private async setManagedUserAttributes(userId: number, cpnUserId: string) { + await this.upsertUserCustomAttribute(userId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + await this.upsertUserCustomAttribute(userId, USER_ID_CUSTOM_ATTRIBUTE_KEY, cpnUserId) + } + + private async setManagedInfraProjectAttributes(projectId: number) { + await this.upsertProjectCustomAttribute(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedProjectAttributes(projectId: number, projectSlug: string) { + await this.upsertProjectCustomAttribute(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + await this.upsertProjectCustomAttribute(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + } + + private async setManagedGroupAttributes(groupId: number) { + await this.upsertGroupCustomAttribute(groupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedRootGroupAttributes(groupId: number) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedInfraGroupAttributes(groupId: number) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedProjectGroupAttributes(groupId: number, projectSlug: string) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + } + + async getGroupByPath(path: string) { + this.logger.verbose(`Looking up a GitLab group by path ${path}`) + return find( + this.offsetPaginate(opts => this.client.Groups.all({ search: path, orderBy: 'path', ...opts })), + g => g.full_path === path, + ) + } + + async createGroup(path: string) { + this.logger.log(`Creating a GitLab group at path ${path}`) + const created = await this.client.Groups.create(path, path) + if (this.config.projectRootDir && created.full_path === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(created.id) + } + if (this.config.projectRootDir && created.full_path === `${this.config.projectRootDir}/${INFRA_GROUP_PATH}`) { + await this.setManagedInfraGroupAttributes(created.id) + } + return created + } + + async createSubGroup(parentGroup: CondensedGroupSchemaWith<'id' | 'full_path'>, name: string, fullPath: string) { + this.logger.log(`Creating a GitLab subgroup ${fullPath} (parentId=${parentGroup.id})`) + const created = await this.client.Groups.create(name, name, { parentId: parentGroup.id }) + if (this.config.projectRootDir && fullPath === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(created.id) + } else if (this.config.projectRootDir && fullPath === `${this.config.projectRootDir}/${INFRA_GROUP_PATH}`) { + await this.setManagedInfraGroupAttributes(created.id) + } else if (this.config.projectRootDir && fullPath.startsWith(`${this.config.projectRootDir}/`) && !fullPath.slice(this.config.projectRootDir.length + 1).includes('/')) { + const projectSlug = fullPath.slice(this.config.projectRootDir.length + 1) + if (projectSlug && projectSlug !== INFRA_GROUP_PATH) { + await this.setManagedProjectGroupAttributes(created.id, projectSlug) + } + } + return created + } + + async getOrCreateGroupByPath(path: string) { + const parts = path.split('/') + const rootGroupPath = parts.shift() + if (!rootGroupPath) throw new Error('Invalid projects root dir') + + this.logger.verbose(`Resolving GitLab group path ${path} (depth=${1 + parts.length})`) + let parentGroup = await this.getGroupByPath(rootGroupPath) ?? await this.createGroup(rootGroupPath) + if (this.config.projectRootDir && parentGroup.full_path === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(parentGroup.id) + } + + let currentFullPath: string + for (const part of parts) { + currentFullPath = `${parentGroup.full_path}/${part}` + parentGroup = await this.getGroupByPath(currentFullPath) ?? await this.createSubGroup(parentGroup, part, currentFullPath) + } + + this.logger.verbose(`GitLab group path resolved (path=${path}, groupId=${parentGroup.id})`) + return parentGroup + } + + async getOrCreateProjectGroup() { + if (!this.config.projectRootDir) throw new Error('projectRootDir not configured') + return this.getOrCreateGroupByPath(this.config.projectRootDir) + } + + async getOrCreateProjectSubGroup(subGroupPath: string) { + const fullPath = this.config.projectRootDir + ? `${this.config.projectRootDir}/${subGroupPath}` + : subGroupPath + return this.getOrCreateGroupByPath(fullPath) + } + + async getOrCreateProjectGroupPublicUrl(): Promise { + const projectGroup = await this.getOrCreateProjectGroup() + return new URL(projectGroup.full_path, this.config.url).toString() + } + + async getOrCreateInfraGroupRepoPublicUrl(repoName: string): Promise { + const projectGroup = await this.getOrCreateProjectGroup() + return new URL(`${projectGroup.full_path}/${INFRA_GROUP_PATH}/${repoName}.git`, this.config.url).toString() + } + + async getOrCreateProjectGroupInternalRepoUrl(subGroupPath: string, repoName: string): Promise { + const projectGroup = await this.getOrCreateProjectSubGroup(subGroupPath) + const urlBase = this.config.internalOrPublicUrl + if (!urlBase) throw new Error('GITLAB_URL is required') + return `${urlBase}/${projectGroup.full_path}/${repoName}.git` + } + + private async getOrCreateRepo(subGroupPath: string) { + const fullPath = this.config.projectRootDir + ? `${this.config.projectRootDir}/${subGroupPath}` + : subGroupPath + this.logger.verbose(`Resolving a GitLab project repository by path ${fullPath}`) + try { + const existingRepo = await this.client.Projects.show(fullPath) + if (existingRepo) { + this.logger.verbose(`Found a GitLab project repository (path=${fullPath}, repoId=${existingRepo.id})`) + return existingRepo + } + } catch (error) { + if (!(error instanceof GitbeakerRequestError) || !error.cause?.description?.includes('404')) { + throw error + } + } + const repo = await find( + this.offsetPaginate(opts => this.client.Projects.all({ + search: fullPath, + orderBy: 'path', + ...opts, + })), + p => p.path_with_namespace === fullPath, + ) + if (repo) { + this.logger.verbose(`Found a GitLab project repository via search (path=${fullPath}, repoId=${repo.id})`) + return repo + } + const parts = subGroupPath.split('/') + const repoName = parts.pop() + if (!repoName) throw new Error('Invalid repo path') + const parentGroup = await this.getOrCreateProjectSubGroup(parts.join('/')) + try { + const created = await this.client.Projects.create({ + name: repoName, + path: repoName, + namespaceId: parentGroup.id, + defaultBranch: 'main', + }) + this.logger.log(`Created a GitLab project repository (path=${fullPath}, repoId=${created.id})`) + return created + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('has already been taken')) { + this.logger.warn(`GitLab project repository already exists (race); reloading ${fullPath}`) + const reloaded = await this.client.Projects.show(fullPath) + return reloaded + } + throw error + } + } + + async getOrCreateProjectGroupRepo(projectSlug: string, subGroupPath: string) { + const repo = await this.getOrCreateRepo(subGroupPath) + await this.setManagedProjectAttributes(repo.id, projectSlug) + return repo + } + + async getOrCreateInfraGroupRepo(path: string) { + const fullPath = join(INFRA_GROUP_PATH, path) + const repo = await this.getOrCreateRepo(fullPath) + await this.setManagedInfraProjectAttributes(repo.id) + return repo + } + + async getFile(repo: CondensedProjectSchemaWith<'id'>, filePath: string, ref: string = 'main') { + try { + return await this.client.RepositoryFiles.show(repo.id, filePath, ref) + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('Not Found')) { + this.logger.debug(`GitLab file not found (repoId=${repo.id}, ref=${ref}, filePath=${filePath})`) + return + } + throw error + } + } + + async maybeCreateCommit( + repo: CondensedProjectSchemaWith<'id'>, + message: string, + actions: CommitAction[], + ref: string = 'main', + ): Promise { + if (actions.length === 0) { + this.logger.debug(`No GitLab commit actions to create (repoId=${repo.id}, ref=${ref})`) + return + } + this.logger.log(`Creating a GitLab commit (repoId=${repo.id}, ref=${ref}, actions=${actions.length})`) + await this.client.Commits.create(repo.id, ref, message, actions) + this.logger.verbose(`GitLab commit created (repoId=${repo.id}, ref=${ref}, actions=${actions.length})`) + } + + async generateCreateOrUpdateAction(repo: CondensedProjectSchemaWith<'id'>, ref: string, filePath: string, content: string): Promise { + const file = await this.getFile(repo, filePath, ref) + if (file && !hasFileContentChanged(file, content)) { + this.logger.debug(`GitLab file is up to date; skipping commit action (repoId=${repo.id}, ref=${ref}, filePath=${filePath})`) + return null + } + this.logger.verbose(`Prepared a GitLab commit action (repoId=${repo.id}, ref=${ref}, filePath=${filePath}, action=${file ? 'update' : 'create'})`) + return { + action: file ? 'update' : 'create', + filePath, + content, + } + } + + async listFiles(repo: CondensedProjectSchemaWith<'id'>, options: { path?: string, recursive?: boolean, ref?: string } = {}) { + try { + const path = options.path ?? '/' + const recursive = options.recursive ?? false + const ref = options.ref ?? 'main' + this.logger.verbose(`Listing GitLab repository tree (repoId=${repo.id}, ref=${ref}, path=${path}, recursive=${recursive})`) + const files = await this.client.Repositories.allRepositoryTrees(repo.id, { + path: options.path ?? '/', + recursive: options.recursive ?? false, + ref: options.ref ?? 'main', + }) + this.logger.verbose(`Listed GitLab repository tree (repoId=${repo.id}, ref=${ref}, path=${path}, count=${files.length})`) + return files + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('Not Found')) { + return [] + } + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('404 Tree Not Found')) { + return [] + } + throw error + } + } + + async getProjectGroup(projectSlug: string): Promise { + const parentGroup = await this.getOrCreateProjectGroup() + return find( + this.offsetPaginate(opts => this.client.Groups.allSubgroups(parentGroup.id, opts)), + g => g.name === projectSlug, + ) + } + + async deleteGroup(group: CondensedGroupSchemaWith<'id' | 'full_path'>): Promise { + this.logger.verbose(`Deleting GitLab group ${group.full_path} (groupId=${group.id})`) + await this.client.Groups.remove(group.id) + } + + async getGroupMembers(group: CondensedGroupSchemaWith<'id'>) { + this.logger.verbose(`Loading GitLab group members (groupId=${group.id})`) + return this.client.GroupMembers.all(group.id) + } + + async addGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number, accessLevel: Exclude) { + this.logger.verbose(`Adding a GitLab group member (groupId=${group.id}, userId=${userId}, accessLevel=${accessLevel})`) + return this.client.GroupMembers.add(group.id, userId, accessLevel) + } + + async editGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number, accessLevel: Exclude) { + this.logger.verbose(`Editing a GitLab group member (groupId=${group.id}, userId=${userId}, accessLevel=${accessLevel})`) + return this.client.GroupMembers.edit(group.id, userId, accessLevel) + } + + async removeGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number) { + this.logger.verbose(`Removing a GitLab group member (groupId=${group.id}, userId=${userId})`) + return this.client.GroupMembers.remove(group.id, userId) + } + + async getUserByEmail(email: string) { + const users = await this.client.Users.all({ search: email, orderBy: 'username' }) + if (users.length === 0) return null + return users[0] as UserSchema + } + + async createUser(user: EditUserOptions) { + this.logger.log(`Creating a GitLab user (email=${user.email}, username=${user.username})`) + return await this.client.Users.create({ + ...user, + skipConfirmation: true, + }) as UserSchema + } + + async upsertUser( + user: Omit, 'externUid' | 'provider'>, + options: { cpnUserId: string }, + ) { + const existing = await this.getUserByEmail(user.email) + + const editOptions: EditUserOptions = { + ...user, + externUid: user.email, + provider: 'openid_connect', + } + const gitlabUser = existing ?? await this.createUser(editOptions) + + if (existing) { + const hasDiff = Object.entries(editOptions).some(([key, value]) => { + if (value === undefined) return false + return (existing as Record)[key] !== value + }) + if (hasDiff) { + await this.client.Users.edit(gitlabUser.id, editOptions) + } + } + await this.setManagedUserAttributes(gitlabUser.id, options.cpnUserId) + return gitlabUser + } + + async* getRepos(projectSlug: string) { + const group = await this.getOrCreateProjectSubGroup(projectSlug) + const repos = this.offsetPaginate(opts => this.client.Groups.allProjects(group.id, { simple: false, ...opts })) + for await (const repo of repos) { + yield repo + } + } + + async upsertProjectGroupRepo(projectSlug: string, repoName: string, description?: string) { + const fullPath = `${projectSlug}/${repoName}` + const repo = await this.getOrCreateProjectGroupRepo(projectSlug, fullPath) + const updated = await this.client.Projects.edit(repo.id, { + name: repoName, + path: repoName, + topics: [TOPIC_PLUGIN_MANAGED], + description, + }) + return updated + } + + async deleteProjectGroupRepo(projectSlug: string, repoName: string) { + const fullPath = `${projectSlug}/${repoName}` + const repo = await this.getOrCreateProjectGroupRepo(projectSlug, fullPath) + return this.client.Projects.remove(repo.id) + } + + async commitMirror(repoId: number) { + this.logger.log(`Creating a GitLab mirror bootstrap commit (repoId=${repoId})`) + const actions: CommitAction[] = [ + { + action: 'create', + filePath: '.gitlab-ci.yml', + content: generateGitlabCIConfigContent(), + execute_filemode: false, + }, + { + action: 'create', + filePath: 'mirror.sh', + content: generateMirrorScriptContent(), + execute_filemode: true, + }, + ] + + await this.client.Commits.create( + repoId, + 'main', + 'ci: :construction_worker: first mirror', + actions, + ) + this.logger.verbose(`GitLab mirror bootstrap commit created (repoId=${repoId}, actions=${actions.length})`) + } + + async upsertProjectMirrorRepo(projectSlug: string) { + return this.upsertProjectGroupRepo(projectSlug, MIRROR_REPO_NAME) + } + + async getProjectToken(projectSlug: string) { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + return find( + this.offsetPaginate<{ name: string }>( + opts => this.client.GroupAccessTokens.all(group.id, opts) as unknown as Promise<{ data: { name: string }[], paginationInfo: OffsetPagination }>, + ), + token => token.name === `${projectSlug}-bot`, + ) + } + + async createProjectToken(projectSlug: string, tokenName: string, scopes: AccessTokenScopes[]) { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + const expirationDays = Number(this.config.mirrorTokenExpirationDays) + const effectiveExpirationDays = Number.isFinite(expirationDays) && expirationDays > 0 ? expirationDays : 30 + const expiryDate = new Date(Date.now() + effectiveExpirationDays * 24 * 60 * 60 * 1000) + this.logger.log(`Creating a GitLab group access token (projectSlug=${projectSlug}, tokenName=${tokenName}, expiry=${expiryDate.toISOString().slice(0, 10)})`) + return this.client.GroupAccessTokens.create(group.id, tokenName, scopes, expiryDate.toISOString().slice(0, 10)) + } + + async createMirrorAccessToken(projectSlug: string) { + const tokenName = `${projectSlug}-bot` + return this.createProjectToken(projectSlug, tokenName, ['write_repository', 'read_repository', 'read_api']) + } + + async getOrCreateMirrorPipelineTriggerToken(projectSlug: string): Promise { + const mirrorRepo = await this.upsertProjectMirrorRepo(projectSlug) + this.logger.verbose(`Resolving a GitLab pipeline trigger token (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + const currentTriggerToken = await find( + this.offsetPaginate(opts => this.client.PipelineTriggerTokens.all(mirrorRepo.id, opts)), + token => token.description === TOKEN_DESCRIPTION, + ) + if (currentTriggerToken) { + this.logger.verbose(`GitLab pipeline trigger token found (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + return currentTriggerToken + } + const created = await this.client.PipelineTriggerTokens.create(mirrorRepo.id, TOKEN_DESCRIPTION) + this.logger.log(`GitLab pipeline trigger token created (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + return created + } + + private async* offsetPaginate( + request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, + options?: OffsetPaginateOptions, + ): AsyncGenerator { + let page: number | null = options?.startPage ?? 1 + let pagesFetched = 0 + let total = 0 + + this.logger.debug(`Pagination start (page=${page})`) + + while (page !== null) { + if (options?.maxPages && pagesFetched >= options.maxPages) { + page = null + continue + } + + try { + const { data, paginationInfo } = await request({ + page, + perPage: options?.perPage, + maxPages: options?.maxPages, + showExpanded: true, + pagination: 'offset', + }) + + pagesFetched += 1 + total += data.length + + const nextPage = paginationInfo.next ?? null + this.logger.debug(`Pagination page fetched (page=${page}, nextPage=${nextPage ?? 'null'}, items=${data.length}, total=${total})`) + + for (const item of data) { + yield item + } + + page = nextPage + } catch (error) { + if (error instanceof Error) { + this.logger.error(`Pagination request failed (page=${page}): ${error.message}`, error.stack) + } else { + this.logger.error(`Pagination request failed (page=${page}): ${String(error)}`) + } + throw error + } + } + + this.logger.debug(`Pagination done (total=${total})`) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts new file mode 100644 index 0000000000..5f72414b87 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts @@ -0,0 +1,133 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + owner: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + roles: { + select: { + id: true, + oidcGroup: true, + }, + }, + members: { + select: { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + }, + }, + roleIds: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + isPrivate: true, + externalRepoUrl: true, + externalUserName: true, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class GitlabDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: 'gitlab', + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } + + async getAdminRolesByOidcGroups(oidcGroups: string[]): Promise<{ id: string, oidcGroup: string }[]> { + return this.prisma.adminRole.findMany({ + where: { + oidcGroup: { + in: oidcGroups, + }, + }, + select: { + id: true, + oidcGroup: true, + }, + }) + } + + async getUser(id: string) { + return this.prisma.user.findUnique({ + where: { + id, + }, + }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts new file mode 100644 index 0000000000..0c4bdf8c41 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts @@ -0,0 +1,23 @@ +import type { GitlabConfig } from './gitlab.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +@Injectable() +export class GitlabHealthService { + constructor( + @Inject(GITLAB_CONFIG) private readonly gitlabConfig: GitlabConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.gitlabConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts new file mode 100644 index 0000000000..7f4d85f694 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { GitlabPluginService } from './gitlab-plugin.service' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +describe('gitlabPluginService', () => { + let service: GitlabPluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://gitlab.public', + projectRootDir: 'forge', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + GitlabPluginService, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(GitlabPluginService) + }) + + it('should expose the legacy project url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams({ project: { id: '', name: '', slug: 'dulei' } })) + + expect(url).toBe('https://gitlab.public/forge/dulei') + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts new file mode 100644 index 0000000000..3ce12a442c --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts @@ -0,0 +1,113 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { GitlabConfig } from './gitlab.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { DEFAULT_ADMIN_GROUP_PATH, DEFAULT_AUDITOR_GROUP_PATH, DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, PURGE_PLUGIN_KEY } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +@Injectable() +export class GitlabPluginService { + constructor( + @Inject(GITLAB_CONFIG) + private readonly gitlabConfig: GitlabConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'gitlab', + to: ({ project }) => new URL(`${this.gitlabConfig.projectRootDir}/${project.slug}`, this.gitlabConfig.url).toString(), + title: 'Gitlab', + imgSrc: '/img/gitlab.svg', + description: 'GitLab est un service d\'hébergement de code source et de pipeline CI/CD', + config: { + global: [ + { + kind: 'switch', + key: PURGE_PLUGIN_KEY, + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Purger les utilisateurs non synchronisés', + value: DISABLED, + description: 'Purger les utilisateurs non synchronisés de GitLab lors de la synchronisation', + }, + { + kind: 'switch', + key: 'displayTriggerHint', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Afficher l\'aide de déclenchement de pipeline', + value: ENABLED, + description: 'Afficher l\'aide de déclenchement de pipeline aux utilisateurs lorsqu\'ils souhaitent afficher les secrets du projet', + }, + { + kind: 'text', + key: 'adminGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Admin', + value: DEFAULT_ADMIN_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'administrateur GitLab', + placeholder: DEFAULT_ADMIN_GROUP_PATH, + }, + { + kind: 'text', + key: 'auditorGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Auditeur', + value: DEFAULT_AUDITOR_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'auditeur GitLab', + placeholder: DEFAULT_AUDITOR_GROUP_PATH, + }, + { + kind: 'text', + key: 'projectMaintainerGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Maintainer', + value: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Maintainer', + placeholder: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectDeveloperGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Developer', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Developer', + placeholder: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectReporterGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Reporter', + value: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Reporter', + placeholder: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + }, + ], + project: [], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts new file mode 100644 index 0000000000..678e3e1e36 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts @@ -0,0 +1,432 @@ +import type { + AccessTokenExposedSchema, + AccessTokenSchema, + CommitAction, + ExpandedGroupSchema, + ExpandedUserSchema, + GroupSchema, + MemberSchema, + OffsetPagination, + PipelineTriggerTokenSchema, + ProjectSchema, + RepositoryFileExpandedSchema, + RepositoryTreeSchema, +} from '@gitbeaker/core' +import type { AdminRole, Project, User } from '@prisma/client' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { faker } from '@faker-js/faker' +import { AccessLevel } from '@gitbeaker/core' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { ProjectStatus } from '@prisma/client' +import { PLUGIN_NAME } from './gitlab.constants' + +export function makeExpandedUserSchema(overrides: Partial = {}): ExpandedUserSchema { + const isoDate = faker.date.past().toISOString() + return { + id: 1, + name: 'User', + username: 'user', + state: 'active', + avatar_url: '', + web_url: 'https://gitlab.example/users/user', + created_at: isoDate, + locked: null, + bio: null, + bot: false, + location: null, + public_email: null, + skype: null, + linkedin: null, + twitter: null, + discord: null, + website_url: null, + pronouns: null, + organization: null, + job_title: null, + work_information: null, + followers: null, + following: null, + local_time: null, + is_followed: null, + is_admin: null, + last_sign_in_at: isoDate, + confirmed_at: isoDate, + last_activity_on: isoDate, + email: 'user@example.com', + theme_id: 1, + color_scheme_id: 1, + projects_limit: 0, + current_sign_in_at: null, + note: null, + identities: null, + can_create_group: false, + can_create_project: false, + two_factor_enabled: false, + external: false, + private_profile: null, + namespace_id: null, + created_by: null, + ...overrides, + } satisfies ExpandedUserSchema +} + +export function makeMemberSchema(overrides: Partial = {}) { + return { + id: 1, + username: 'user', + name: 'User', + state: 'active', + avatar_url: '', + web_url: 'https://gitlab.example/users/user', + expires_at: faker.date.future().toISOString(), + access_level: 30, + email: 'user@example.com', + group_saml_identity: { + extern_uid: '', + provider: '', + saml_provider_id: 1, + }, + ...overrides, + } satisfies MemberSchema +} + +export function makeGroupSchema(overrides: Partial = {}) { + return { + id: 123, + web_url: 'https://gitlab.example/groups/forge', + name: 'forge', + avatar_url: '', + full_name: 'forge', + full_path: 'forge', + path: 'forge', + description: '', + visibility: 'private', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: 'maintainer', + subgroup_creation_level: 'maintainer', + lfs_enabled: true, + default_branch_protection: 0, + request_access_enabled: false, + created_at: faker.date.past().toISOString(), + parent_id: 0, + ...overrides, + } satisfies GroupSchema +} + +export function makeExpandedGroupSchema(overrides: Partial = {}): ExpandedGroupSchema { + return { + id: 123, + web_url: 'https://gitlab.example/groups/forge', + name: 'forge', + avatar_url: '', + full_name: 'forge', + full_path: 'forge', + path: 'forge', + description: '', + visibility: 'private', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: 'maintainer', + subgroup_creation_level: 'maintainer', + lfs_enabled: true, + default_branch_protection: 0, + request_access_enabled: false, + created_at: faker.date.past().toISOString(), + parent_id: 0, + runners_token: 'runners-token', + file_template_project_id: 1, + shared_with_groups: null, + ...overrides, + } satisfies ExpandedGroupSchema +} + +export function makeProjectSchema(overrides: Partial = {}) { + return { + id: 1, + web_url: 'https://gitlab.example/projects/1', + name: 'repo', + path: 'repo', + description: '', + name_with_namespace: 'forge / repo', + path_with_namespace: 'forge/repo', + created_at: faker.date.past().toISOString(), + default_branch: 'main', + topics: [], + ssh_url_to_repo: 'ssh://gitlab.example/forge/repo.git', + http_url_to_repo: 'https://gitlab.example/forge/repo.git', + readme_url: '', + forks_count: 0, + avatar_url: null, + star_count: 0, + last_activity_at: faker.date.future().toISOString(), + namespace: { id: 1, name: 'forge', path: 'forge', kind: 'group', full_path: 'forge', avatar_url: '', web_url: 'https://gitlab.example/groups/forge' }, + description_html: '', + visibility: 'private', + empty_repo: false, + owner: { id: 1, name: 'Owner', created_at: faker.date.past().toISOString() }, + issues_enabled: true, + open_issues_count: 0, + merge_requests_enabled: true, + jobs_enabled: true, + wiki_enabled: true, + snippets_enabled: true, + can_create_merge_request_in: true, + resolve_outdated_diff_discussions: false, + container_registry_access_level: 'enabled', + security_and_compliance_access_level: 'enabled', + container_expiration_policy: { + cadence: '1d', + enabled: false, + keep_n: null, + older_than: null, + name_regex_delete: null, + name_regex_keep: null, + next_run_at: faker.date.future().toISOString(), + }, + updated_at: faker.date.past().toISOString(), + creator_id: 1, + import_url: null, + import_type: null, + import_status: 'none', + import_error: null, + permissions: { + project_access: { access_level: 0, notification_level: 0 }, + group_access: { access_level: 0, notification_level: 0 }, + }, + archived: false, + license_url: '', + license: { key: 'mit', name: 'MIT', nickname: 'MIT', html_url: '', source_url: '' }, + shared_runners_enabled: true, + group_runners_enabled: true, + runners_token: '', + ci_default_git_depth: 0, + ci_forward_deployment_enabled: false, + ci_forward_deployment_rollback_allowed: false, + ci_allow_fork_pipelines_to_run_in_parent_project: false, + ci_separated_caches: false, + ci_restrict_pipeline_cancellation_role: '', + public_jobs: false, + shared_with_groups: null, + repository_storage: '', + only_allow_merge_if_pipeline_succeeds: false, + allow_merge_on_skipped_pipeline: false, + restrict_user_defined_variables: false, + only_allow_merge_if_all_discussions_are_resolved: false, + remove_source_branch_after_merge: false, + printing_merge_requests_link_enabled: false, + request_access_enabled: false, + merge_method: '', + squash_option: '', + auto_devops_enabled: false, + auto_devops_deploy_strategy: '', + mirror: false, + mirror_user_id: 1, + mirror_trigger_builds: false, + only_mirror_protected_branches: false, + mirror_overwrites_diverged_branches: false, + external_authorization_classification_label: '', + packages_enabled: false, + service_desk_enabled: false, + service_desk_address: 'service-desk@example.com', + service_desk_reply_to: 'service-desk@example.com', + autoclose_referenced_issues: false, + suggestion_commit_message: 'Add suggestion commit message', + enforce_auth_checks_on_uploads: false, + merge_commit_template: 'Add suggestion commit message', + squash_commit_template: 'Add suggestion commit message', + issue_branch_template: 'Add suggestion commit message', + marked_for_deletion_on: faker.date.future().toISOString(), + compliance_frameworks: [], + warn_about_potentially_unwanted_characters: false, + container_registry_image_prefix: 'registry.gitlab.example/forge/repo', + _links: { + self: 'https://gitlab.example/projects/1', + issues: 'https://gitlab.example/projects/1/issues', + merge_requests: 'https://gitlab.example/projects/1/merge_requests', + repo_branches: 'https://gitlab.example/projects/1/repository/branches', + labels: 'https://gitlab.example/projects/1/labels', + events: 'https://gitlab.example/projects/1/events', + members: 'https://gitlab.example/projects/1/members', + cluster_agents: 'https://gitlab.example/projects/1/cluster_agents', + }, + ...overrides, + } satisfies ProjectSchema +} + +export function makeProject(overrides: Partial = {}) { + return { + id: faker.string.uuid(), + slug: faker.lorem.slug(), + name: faker.company.name(), + description: faker.lorem.sentence(), + status: faker.helpers.enumValue(ProjectStatus), + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + everyonePerms: 0n, + ownerId: faker.string.uuid(), + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } satisfies Project +} + +export function makeProjectWithDetails(overrides: Partial = {}) { + return { + id: faker.string.uuid(), + slug: faker.lorem.slug(), + name: faker.company.name(), + description: faker.lorem.sentence(), + owner: { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + }, + plugins: [], + roles: [], + members: [], + repositories: [], + clusters: [], + ...overrides, + } satisfies ProjectWithDetails +} +export function makePipelineTriggerToken(overrides: Partial = {}) { + return { + id: 1, + description: 'mirroring-from-external-repo', + created_at: faker.date.past().toISOString(), + last_used: null, + token: 'trigger-token', + updated_at: faker.date.past().toISOString(), + owner: null, + repoId: 1, + ...overrides, + } satisfies PipelineTriggerTokenSchema +} + +export function makeOffsetPagination(overrides: Partial = {}) { + return { + total: 1, + next: null, + current: 1, + previous: null, + perPage: 20, + totalPages: 1, + ...overrides, + } satisfies OffsetPagination +} + +export function makeAccessTokenSchema(overrides: Partial = {}) { + const isoDate = faker.date.past().toISOString() + return { + id: 1, + user_id: 1, + name: 'token', + expires_at: isoDate, + active: true, + created_at: isoDate, + revoked: false, + access_level: AccessLevel.DEVELOPER, + ...overrides, + } satisfies AccessTokenSchema +} + +export function makeAccessTokenExposedSchema(overrides: Partial = {}) { + return { + ...makeAccessTokenSchema(), + token: 'secret-token', + ...overrides, + } satisfies AccessTokenExposedSchema +} + +export function makeRepositoryFileExpandedSchema(overrides: Partial = {}) { + return { + file_name: 'file.txt', + file_path: 'file.txt', + size: 7, + encoding: 'base64', + content: 'content', + content_sha256: 'sha256', + ref: 'main', + blob_id: 'blob', + commit_id: 'commit', + last_commit_id: 'last-commit', + ...overrides, + } satisfies RepositoryFileExpandedSchema +} + +export function makeRepositoryTreeSchema(overrides: Partial = {}) { + return { + id: 'id', + name: 'file.txt', + type: 'blob', + path: 'file.txt', + mode: '100644', + ...overrides, + } satisfies RepositoryTreeSchema +} + +export function makeGitbeakerRequestError(params: { message?: string, status?: number, statusText?: string, description: string }) { + const request = new Request('https://gitlab.internal.example/api') + const response = new Response(null, { status: params.status ?? 404, statusText: params.statusText ?? 'Not Found' }) + return new GitbeakerRequestError(params.message ?? params.statusText ?? 'Error', { + cause: { + description: params.description, + request, + response, + }, + }) +} + +export function makeCommitAction(overrides: Partial = {}) { + return { + action: 'create', + filePath: 'file.txt', + content: 'content', + ...overrides, + } satisfies CommitAction +} + +export function makeAdminPlugin(overrides: { value?: string, pluginName?: string, key?: string } = {}) { + return { + pluginName: overrides.pluginName ?? PLUGIN_NAME, + key: overrides.key ?? 'token', + value: overrides.value ?? faker.string.alphanumeric(20), + } +} + +export function makeAdminRole(overrides: { id?: string, oidcGroup?: string } = {}) { + return { + id: overrides.id ?? faker.string.uuid(), + name: faker.word.words(), + permissions: 0n, + position: 0, + oidcGroup: overrides.oidcGroup ?? faker.word.words(), + type: 'managed', + } satisfies AdminRole +} + +export function makeUser(overrides: { id?: string, email?: string, firstName?: string, lastName?: string } = {}) { + return { + id: overrides.id ?? faker.string.uuid(), + email: overrides.email ?? faker.internet.email(), + firstName: overrides.firstName ?? faker.person.firstName(), + lastName: overrides.lastName ?? faker.person.lastName(), + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: null, + adminRoleIds: [], + type: 'human' as const, + } satisfies User +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts new file mode 100644 index 0000000000..d4d331cd2e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts @@ -0,0 +1,34 @@ +// GitLab plugin constants +export const PLUGIN_NAME = 'gitlab' + +// Infrastructure integration +export const INFRA_GROUP_NAME = 'Infra' +export const INFRA_GROUP_PATH = 'infra' +export const INFRA_APPS_REPO_NAME = 'infra-apps' +export const MIRROR_REPO_NAME = 'mirror' + +// Managed resources sentinel +export const TOPIC_PLUGIN_MANAGED = 'plugin-managed' +export const TOKEN_DESCRIPTION = 'mirroring-from-external-repo' + +// Default group paths for console roles +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly,/console/security' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin,/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly,/console/security' + +// Plugin configuration keys +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const AUDITOR_GROUP_PATH_PLUGIN_KEY = 'auditorGroupPath' +export const PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReporterGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDeveloperGroupPathSuffix' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectMaintainerGroupPathSuffix' +export const PURGE_PLUGIN_KEY = 'purge' + +// Custom attribute keys used in GitLab groups +export const GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY = 'cpn_projects_root_dir' +export const INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY = 'cpn_infra_group' +export const PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY = 'cpn_project_slug' +export const USER_ID_CUSTOM_ATTRIBUTE_KEY = 'cpn_user_id' +export const MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY = 'cpn_managed_by_console' diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts new file mode 100644 index 0000000000..3bb17e6b57 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts @@ -0,0 +1,19 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface GitlabConfig { + token?: string + url: string + internalUrl: string + mirrorTokenExpirationDays: number + mirrorTokenRotationThresholdDays: number + projectRootDir: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface GitlabModuleOptions extends GitlabConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('gitlab') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as GITLAB_CONFIG } diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts new file mode 100644 index 0000000000..f08dc6f312 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts @@ -0,0 +1,33 @@ +import type { GitlabConfig } from './gitlab.module-definition' +import { Gitlab } from '@gitbeaker/rest' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { GitlabHealthService } from './gitlab-health.service' +import { GitlabPluginService } from './gitlab-plugin.service' +import { ConfigurableModuleClass, GITLAB_CONFIG } from './gitlab.module-definition' +import { GitlabService } from './gitlab.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule], + providers: [ + { + provide: GITLAB_REST_CLIENT, + inject: [GITLAB_CONFIG], + useFactory: (config: GitlabConfig) => new Gitlab({ + token: config.token, + host: config.internalOrPublicUrl, + }), + }, + GitlabClientService, + GitlabDatastoreService, + GitlabHealthService, + GitlabPluginService, + GitlabService, + ], + exports: [GitlabClientService, GitlabHealthService, GitlabPluginService, GitlabService], +}) +export class GitlabModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts new file mode 100644 index 0000000000..da46c5fe4f --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -0,0 +1,492 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { AccessLevel } from '@gitbeaker/core' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { VaultClientService } from '../vault/vault-client.service' +import { GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { makeAccessTokenExposedSchema, makeExpandedUserSchema, makeGroupSchema, makeMemberSchema, makePipelineTriggerToken, makeProjectSchema, makeProjectWithDetails } from './gitlab-testing.utils' +import { PLUGIN_NAME, TOPIC_PLUGIN_MANAGED } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { GitlabService } from './gitlab.service' + +describe('gitlabService', () => { + let service: GitlabService + let gitlab: DeepMockProxy + let vault: DeepMockProxy + let datastore: DeepMockProxy + + beforeEach(async () => { + gitlab = mockDeep() + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + getAdminRolesByOidcGroups: vi.fn().mockResolvedValue([]), + }) + vault = mockDeep({ + writeGitlabMirrorCreds: vi.fn().mockResolvedValue(undefined), + deleteGitlabMirrorCreds: vi.fn().mockResolvedValue(undefined), + writeTechReadOnlyCreds: vi.fn().mockResolvedValue(undefined), + writeMirrorTriggerToken: vi.fn().mockResolvedValue(undefined), + readTechnReadOnlyCreds: vi.fn().mockResolvedValue(null), + readGitlabMirrorCreds: vi.fn().mockResolvedValue(null), + }) + const config = mockDeep({ projectRootDir: 'forge' }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + GitlabService, + { provide: GitlabClientService, useValue: gitlab }, + { provide: GitlabDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(GitlabService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('should reconcile project members and repositories', async () => { + const project = makeProjectWithDetails() + const group = makeGroupSchema({ + id: 123, + full_path: 'forge/console/project-1', + full_name: 'forge/console/project-1', + name: 'project-1', + path: 'project-1', + parent_id: 1, + }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectGroupRepo.mockResolvedValue(makeProjectSchema({ id: 1 })) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/repo') + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + gitlab.upsertUser.mockResolvedValue(makeExpandedUserSchema({ id: 123, username: 'user' })) + + await service.handleUpsert(project) + + expect(gitlab.getOrCreateProjectSubGroup).toHaveBeenCalledWith(project.slug) + expect(gitlab.getGroupMembers).toHaveBeenCalledWith(group) + expect(gitlab.getRepos).toHaveBeenCalledWith(project.slug) + }) + + it('should remove orphan member if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 999, username: 'orphan' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).toHaveBeenCalledWith(group, 999) + }) + + it('should not remove managed user (bot) even if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 888, username: 'group_123_bot' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).not.toHaveBeenCalled() + }) + + it('should not remove orphan member if purge disabled', async () => { + const project = makeProjectWithDetails() + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 999, username: 'orphan' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).not.toHaveBeenCalled() + }) + + it('should delete orphan repositories if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepo = makeProjectSchema({ name: 'orphan-repo', topics: [TOPIC_PLUGIN_MANAGED] }) + const unmanagedRepo = makeProjectSchema({ name: 'unmanaged-repo', topics: [] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepo + yield unmanagedRepo + })()) + gitlab.deleteProjectGroupRepo.mockResolvedValue(undefined) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).toHaveBeenCalledWith(project.slug, 'orphan-repo') + expect(gitlab.deleteProjectGroupRepo).toHaveBeenCalledTimes(1) + }) + + it('should not delete orphan repositories if purge disabled', async () => { + const project = makeProjectWithDetails({ + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepo = makeProjectSchema({ name: 'orphan-repo', topics: [TOPIC_PLUGIN_MANAGED] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepo + })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).not.toHaveBeenCalled() + }) + + it('should not delete orphan repositories without the correct topic even if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepoWithoutTopic = makeProjectSchema({ name: 'orphan-repo', topics: [] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepoWithoutTopic + })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).not.toHaveBeenCalled() + }) + + it('should create gitlab user if not exists', async () => { + const project = makeProjectWithDetails({ + owner: { id: 'o1', email: 'owner@example.com', firstName: 'Owner', lastName: 'User', adminRoleIds: [] }, + members: [{ user: { id: 'u1', email: 'new@example.com', firstName: 'New', lastName: 'User', adminRoleIds: [] }, roleIds: [] }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'new@example.com' ? 999 : 998, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'new@example.com' }), + expect.objectContaining({ cpnUserId: 'u1' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'owner@example.com' }), + expect.objectContaining({ cpnUserId: 'o1' }), + ) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 999, AccessLevel.GUEST) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 998, AccessLevel.OWNER) + }) + + it('should map roles to access levels and apply highest level', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-reporter', oidcGroup: '/project-1/console/readonly' }, + { id: 'r-developer', oidcGroup: '/project-1/console/developer' }, + { id: 'r-devops', oidcGroup: '/project-1/console/devops' }, + { id: 'r-maintainer', oidcGroup: '/project-1/console/admin' }, + { id: 'r-unknown', oidcGroup: '/other/group' }, + ], + members: [ + { user: { id: 'u1', email: 'reporter@example.com', firstName: 'Rep', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter'] }, + { user: { id: 'u2', email: 'developer@example.com', firstName: 'Dev', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-developer'] }, + { user: { id: 'u3', email: 'devops@example.com', firstName: 'Ops', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-devops'] }, + { user: { id: 'u4', email: 'maintainer@example.com', firstName: 'Main', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-maintainer'] }, + { user: { id: 'u5', email: 'mixed@example.com', firstName: 'Mixed', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter', 'r-developer'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + const idByEmail: Record = { + 'reporter@example.com': 101, + 'developer@example.com': 102, + 'devops@example.com': 103, + 'maintainer@example.com': 104, + 'mixed@example.com': 105, + 'owner@example.com': 100, + } + return makeExpandedUserSchema({ + id: idByEmail[user.email] ?? 999, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 101, AccessLevel.REPORTER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 102, AccessLevel.DEVELOPER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 103, AccessLevel.MAINTAINER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 104, AccessLevel.MAINTAINER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.DEVELOPER) + }) + + it('should prioritize higher access level when oidc group appears in multiple paths', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-devops', oidcGroup: '/project-1/console/devops' }, + ], + members: [ + { user: { id: 'u1', email: 'devops@example.com', firstName: 'Ops', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-devops'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'devops@example.com' ? 101 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 101, AccessLevel.MAINTAINER) + }) + + it('should map security project role to reporter access level', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-security', oidcGroup: '/project-1/console/security' }, + ], + members: [ + { user: { id: 'u1', email: 'security@example.com', firstName: 'Sec', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-security'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'security@example.com' ? 105 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.REPORTER) + }) + + it('should downgrade existing member to guest when no role maps to an access level', async () => { + const project = makeProjectWithDetails({ + roles: [{ id: 'r-unknown', oidcGroup: '/other/group' }], + members: [{ user: { id: 'u1', email: 'no-access@example.com', firstName: 'No', lastName: 'Access', adminRoleIds: [] }, roleIds: ['r-unknown'] }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 105, username: 'no-access', access_level: AccessLevel.REPORTER })]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'no-access@example.com' ? 105 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.editGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.GUEST) + expect(gitlab.removeGroupMember).not.toHaveBeenCalledWith(group, 105) + }) + + it('should bind builtin roles (admin/auditor) when role ids are resolved', async () => { + const project = makeProjectWithDetails({ + owner: { id: 'o1', email: 'owner@example.com', firstName: 'Owner', lastName: 'User', adminRoleIds: ['admin-role-id'] }, + members: [ + { user: { id: 'u1', email: 'admin@example.com', firstName: 'Admin', lastName: 'User', adminRoleIds: ['admin-role-id'] }, roleIds: [] }, + { user: { id: 'u2', email: 'auditor@example.com', firstName: 'Auditor', lastName: 'User', adminRoleIds: ['auditor-role-id'] }, roleIds: [] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + datastore.getAdminPluginConfig.mockImplementation(async (_pluginName: string, key: string) => { + if (key === 'adminGroupPath') return '/console/admin' + if (key === 'auditorGroupPath') return '/console/readonly' + return null + }) + datastore.getAdminRolesByOidcGroups.mockResolvedValue([ + { id: 'admin-role-id', oidcGroup: '/console/admin' }, + { id: 'auditor-role-id', oidcGroup: '/console/readonly' }, + ]) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: faker.number.int(), + email: user.email, + username: user.email.split('@')[0], + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'admin@example.com', admin: true, auditor: false }), + expect.objectContaining({ cpnUserId: 'u1' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'auditor@example.com', admin: false, auditor: true }), + expect.objectContaining({ cpnUserId: 'u2' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'owner@example.com', admin: true, auditor: false }), + expect.objectContaining({ cpnUserId: 'o1' }), + ) + }) + + it('should configure repository mirroring if external url is present', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + repositories: [{ + id: 'r1', + internalRepoName: 'repo-1', + externalRepoUrl: 'https://github.com/org/repo.git', + isPrivate: true, + externalUserName: 'user', + isInfra: false, + }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const gitlabRepo = makeProjectSchema({ id: 101, name: 'repo-1', path: 'repo-1', path_with_namespace: 'forge/console/project-1/repo-1' }) + const accessToken = makeAccessTokenExposedSchema({ + name: 'bot', + scopes: ['read_api'], + access_level: 40, + }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { yield gitlabRepo })()) + gitlab.getOrCreateProjectGroupInternalRepoUrl.mockResolvedValue('https://gitlab.internal/group/repo-1.git') + gitlab.createMirrorAccessToken.mockResolvedValue(accessToken) + vault.readTechnReadOnlyCreds.mockResolvedValue(null) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.createMirrorAccessToken).toHaveBeenCalledWith('project-1') + expect(gitlab.upsertProjectMirrorRepo).toHaveBeenCalledWith('project-1') + + expect(vault.writeGitlabMirrorCreds).toHaveBeenCalledWith( + 'project-1', + 'repo-1', + expect.objectContaining({ + GIT_INPUT_URL: 'github.com/org/repo.git', + GIT_OUTPUT_USER: 'bot', + GIT_OUTPUT_PASSWORD: accessToken.token, + }), + ) + expect(vault.writeTechReadOnlyCreds).toHaveBeenCalledWith('project-1', { + MIRROR_USER: 'bot', + MIRROR_TOKEN: accessToken.token, + }) + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects', async () => { + const projects = [makeProjectWithDetails({ id: 'p1', slug: 'project-1' })] + datastore.getAllProjects.mockResolvedValue(projects) + + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(gitlab.getOrCreateProjectSubGroup).toHaveBeenCalledWith('project-1') + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts new file mode 100644 index 0000000000..fd214154c1 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -0,0 +1,528 @@ +import type { CondensedGroupSchema, MemberSchema, ProjectSchema } from '@gitbeaker/core' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultSecret } from '../vault/vault-client.service' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import type { GitlabConfig } from './gitlab.module-definition' +import { specificallyEnabled } from '@cpn-console/hooks' +import { AccessLevel } from '@gitbeaker/core' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { getAll } from '../../utils/iterable.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + AUDITOR_GROUP_PATH_PLUGIN_KEY, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + INFRA_APPS_REPO_NAME, + PLUGIN_NAME, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PURGE_PLUGIN_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { + adminRoleFlag, + daysAgoFromNow, + generateAccessLevelMapping, + generateAdminRoleMapping, + generateName, + generateProjectRoleGroupPath, + generateUsername, + generateUsernameCandidates, + getProjectPluginConfig, + isOwnedRepo, + isOwnedUser, + isSystemRepo, +} from './gitlab.utils' + +type ProjectAccessLevel = Exclude + +@Injectable() +export class GitlabService { + private readonly logger = new Logger(GitlabService.name) + + constructor( + @Inject(GitlabDatastoreService) private readonly datastore: GitlabDatastoreService, + @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(GITLAB_CONFIG) private readonly gitlabConfig: GitlabConfig, + ) { + this.logger.log('GitLabService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('gitlab', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`GitLab sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('gitlab', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`GitLab sync completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + span?.setAttribute('gitlab.projects.count', 0) + this.logger.log('Starting GitLab reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('gitlab.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for GitLab reconciliation`) + await this.ensureProjectGroups(projects) + this.logger.log(`GitLab reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('gitlab.projects.count', projects.length) + this.logger.verbose(`Reconciling GitLab project groups (${projects.length})`) + await Promise.all(projects.map(p => this.ensureProjectGroup(p))) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling GitLab project group (${project.slug})`) + const group = await this.gitlab.getOrCreateProjectSubGroup(project.slug) + const members = await this.gitlab.getGroupMembers(group) + this.logger.verbose(`Loaded GitLab project group state (${project.slug}): groupId=${group.id} members=${members.length}`) + await this.ensureProjectGroupMembers(project, group, members) + await this.ensureProjectRepos(project) + await this.purgeOrphanRepos(project) + await this.ensureSystemRepos(project) + this.logger.verbose(`GitLab project group reconciled (${project.slug})`) + } + + @StartActiveSpan() + private async ensureProjectGroupMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling GitLab group members for project ${project.slug} (groupId=${group.id}, members=${members.length})`) + const { adminRoleId, auditorRoleId } = await this.getAdminRoleIds(project) + await this.addMissingMembers(project, group, members, adminRoleId, auditorRoleId) + await this.addMissingOwnerMember(project, group, members, adminRoleId, auditorRoleId) + await this.purgeOrphanMembers(project, group, members) + } + + private async addMissingMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + adminRoleId?: string, + auditorRoleId?: string, + ) { + const membersById = new Map(members.map(m => [m.id, m])) + const groupPaths = await this.getProjectRoleGroupPaths(project) + const accessLevelByUserId = generateAccessLevelMapping(project, groupPaths) + + await Promise.all(project.members.map(async ({ user }) => { + const gitlabUser = await this.gitlab.upsertUser({ + email: user.email, + username: generateUsername(user.email), + name: generateName(user.firstName, user.lastName), + admin: adminRoleFlag(user, adminRoleId), + auditor: adminRoleFlag(user, auditorRoleId), + }, { + cpnUserId: user.id, + }) + if (!gitlabUser) { + this.logger.warn(`Unable to resolve a GitLab user for a project member (project=${project.slug}, userId=${user.id}, email=${user.email})`) + return + } + const accessLevel = accessLevelByUserId.get(user.id) ?? AccessLevel.GUEST + await this.ensureGroupMemberAccessLevel(group, gitlabUser.id, accessLevel, membersById) + })) + } + + private async ensureGroupMemberAccessLevel( + group: CondensedGroupSchema, + gitlabUserId: number, + accessLevel: ProjectAccessLevel, + membersById: Map, + ) { + const existingMember = membersById.get(gitlabUserId) + + if (accessLevel === AccessLevel.NO_ACCESS) { + if (existingMember) { + await this.gitlab.removeGroupMember(group, gitlabUserId) + } + return + } + + if (!existingMember) { + await this.gitlab.addGroupMember(group, gitlabUserId, accessLevel) + return + } + + if (existingMember.access_level !== accessLevel) { + await this.gitlab.editGroupMember(group, gitlabUserId, accessLevel) + } + } + + private async addMissingOwnerMember( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + adminRoleId?: string, + auditorRoleId?: string, + ) { + const gitlabUser = await this.gitlab.upsertUser({ + email: project.owner.email, + username: generateUsername(project.owner.email), + name: generateName(project.owner.firstName, project.owner.lastName), + admin: adminRoleFlag(project.owner, adminRoleId), + auditor: adminRoleFlag(project.owner, auditorRoleId), + }, { + cpnUserId: project.owner.id, + }) + if (!gitlabUser) { + this.logger.warn(`Unable to resolve the GitLab owner account (project=${project.slug}, ownerId=${project.owner.id}, email=${project.owner.email})`) + return + } + const membersById = new Map(members.map(m => [m.id, m])) + await this.ensureGroupMemberAccessLevel(group, gitlabUser.id, AccessLevel.OWNER, membersById) + } + + private async getAdminRoleIds(project: ProjectWithDetails): Promise<{ adminRoleId?: string, auditorRoleId?: string }> { + const adminGroupPath = await this.getAdminGroupPath(project) + const auditorGroupPath = await this.getAuditorGroupPath(project) + const roles = await this.datastore.getAdminRolesByOidcGroups([adminGroupPath, auditorGroupPath]) + return generateAdminRoleMapping(roles, adminGroupPath, auditorGroupPath) + } + + private async getAdminGroupPath(project: ProjectWithDetails): Promise { + return await this.getAdminOrProjectPluginConfig(project, ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getAuditorGroupPath(project: ProjectWithDetails): Promise { + return await this.getAdminOrProjectPluginConfig(project, AUDITOR_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_AUDITOR_GROUP_PATH + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + if (!project) return undefined + return getProjectPluginConfig(project, key) ?? undefined + } + + private async getProjectRoleGroupPaths(project: ProjectWithDetails): Promise<{ reporter: string[], developer: string[], maintainer: string[] }> { + const [reporter, developer, maintainer] = await Promise.all([ + this.getProjectReporterGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectMaintainerGroupPaths(project), + ]) + + return { + reporter, + developer, + maintainer, + } + } + + private async getProjectReporterGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectMaintainerGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + @StartActiveSpan() + private async purgeOrphanMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'group.id': group.id, + 'members.total': members.length, + }) + const purgeConfig = getProjectPluginConfig(project, PURGE_PLUGIN_KEY) + const usernames = new Set([ + ...generateUsernameCandidates(project.owner.email), + ...project.members.flatMap(m => generateUsernameCandidates(m.user.email)), + ]) + const emails = new Set([ + project.owner.email.toLowerCase(), + ...project.members.map(m => m.user.email.toLowerCase()), + ]) + + const orphans = members.filter((member) => { + if (isOwnedUser(member)) return false + if (usernames.has(member.username)) return false + if (member.email && emails.has(member.email.toLowerCase())) return false + return true + }) + span?.setAttribute('orphans.count', orphans.length) + + if (specificallyEnabled(purgeConfig)) { + span?.setAttribute('purge.enabled', true) + let removedCount = 0 + await Promise.all(orphans.map(async (orphan) => { + await this.gitlab.removeGroupMember(group, orphan.id) + removedCount++ + this.logger.log(`Removed a user from the GitLab group (groupId=${group.id}, username=${orphan.username})`) + })) + span?.setAttribute('orphans.removed.count', removedCount) + } else { + span?.setAttribute('purge.enabled', false) + let warnedCount = 0 + for (const orphan of orphans) { + warnedCount++ + this.logger.warn(`User is in the GitLab group but not in the project (purge disabled, username=${orphan.username})`) + } + span?.setAttribute('orphans.warned.count', warnedCount) + } + } + + @StartActiveSpan() + private async ensureProjectRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'repositories.count': project.repositories.length, + }) + const gitlabRepositories = await getAll(this.gitlab.getRepos(project.slug)) + span?.setAttribute('gitlab.repositories.count', gitlabRepositories.length) + let mirroringEnabledCount = 0 + let mirroringDisabledCount = 0 + for (const repo of project.repositories) { + const externalHost = this.getExternalRepoHost(repo.externalRepoUrl) + span?.addEvent('gitlab.repo.reconcile', { + 'repository.name': repo.internalRepoName, + 'repository.isPrivate': repo.isPrivate, + ...(externalHost ? { 'repository.external.host': externalHost } : {}), + 'repository.external': !!repo.externalRepoUrl, + }) + await this.ensureRepository(project, repo, gitlabRepositories) + + if (repo.externalRepoUrl) { + span?.setAttribute('repository.mirroring', true) + mirroringEnabledCount++ + await this.configureRepositoryMirroring(project, repo) + } else { + span?.setAttribute('repository.mirroring', false) + mirroringDisabledCount++ + await this.vault.deleteGitlabMirrorCreds(project.slug, repo.internalRepoName) + } + } + span?.setAttribute('repositories.mirroring.enabled.count', mirroringEnabledCount) + span?.setAttribute('repositories.mirroring.disabled.count', mirroringDisabledCount) + } + + @StartActiveSpan() + private async purgeOrphanRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const gitlabRepositories = await getAll(this.gitlab.getRepos(project.slug)) + span?.setAttribute('gitlab.repositories.count', gitlabRepositories.length) + + const orphanRepos = gitlabRepositories.filter(r => isOwnedRepo(r) && !isSystemRepo(project, r)) + span?.setAttribute('orphan.repositories.count', orphanRepos.length) + + if (specificallyEnabled(getProjectPluginConfig(project, PURGE_PLUGIN_KEY))) { + span?.setAttribute('purge.enabled', true) + let removedCount = 0 + await Promise.all(orphanRepos.map(async (orphan) => { + await this.gitlab.deleteProjectGroupRepo(project.slug, orphan.name) + removedCount++ + this.logger.log(`Removed a repository from the GitLab project (project=${project.slug}, repoName=${orphan.name})`) + })) + + span?.setAttribute('orphan.repositories.removed.count', removedCount) + } else { + span?.setAttribute('purge.enabled', false) + let warnedCount = 0 + for (const orphan of orphanRepos) { + warnedCount++ + this.logger.warn(`Repository is in GitLab but not in the project definition (purge disabled, project=${project.slug}, repoName=${orphan.name})`) + } + span?.setAttribute('managed.repositories.warned.count', warnedCount) + } + } + + private async ensureRepository( + project: ProjectWithDetails, + repo: ProjectWithDetails['repositories'][number], + gitlabRepositories: ProjectSchema[], + ) { + return gitlabRepositories.find(r => r.name === repo.internalRepoName) + ?? await this.gitlab.upsertProjectGroupRepo( + project.slug, + repo.internalRepoName, + ) + } + + @StartActiveSpan() + private async configureRepositoryMirroring( + project: ProjectWithDetails, + repo: ProjectWithDetails['repositories'][number], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('repository.internalRepoName', repo.internalRepoName) + span?.setAttribute('repository.isPrivate', repo.isPrivate) + const externalHost = this.getExternalRepoHost(repo.externalRepoUrl) + if (externalHost) span?.setAttribute('repository.external.host', externalHost) + + const currentVaultSecret = await this.vault.readGitlabMirrorCreds(project.slug, repo.internalRepoName) + span?.setAttribute('vault.secret.exists', !!currentVaultSecret) + if (!currentVaultSecret) { + this.logger.warn(`No existing mirror credentials found in Vault; rotating new credentials (project=${project.slug}, repoName=${repo.internalRepoName})`) + } + + const internalRepoUrl = await this.gitlab.getOrCreateProjectGroupInternalRepoUrl(project.slug, repo.internalRepoName) + const externalRepoUrn = repo.externalRepoUrl.split('://')[1] + const internalRepoUrn = internalRepoUrl.split('://')[1] + span?.setAttribute('repository.externalRepoUrn', externalRepoUrn) + span?.setAttribute('repository.internalRepoUrn', internalRepoUrn) + + const projectMirrorCreds = await this.getOrRotateMirrorCreds(project.slug) + + const mirrorSecretData = { + GIT_INPUT_URL: externalRepoUrn, + GIT_INPUT_USER: repo.isPrivate ? repo.externalUserName : undefined, + GIT_INPUT_PASSWORD: currentVaultSecret?.data?.GIT_INPUT_PASSWORD, // Preserve existing password as it's not in DB + GIT_OUTPUT_URL: internalRepoUrn, + GIT_OUTPUT_USER: projectMirrorCreds.MIRROR_USER, + GIT_OUTPUT_PASSWORD: projectMirrorCreds.MIRROR_TOKEN, + } + + // Write to vault if changed + // Using simplified check + await this.vault.writeGitlabMirrorCreds(project.slug, repo.internalRepoName, mirrorSecretData) + span?.setAttribute('vault.secret.written', true) + } + + @StartActiveSpan() + private async ensureSystemRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + await Promise.all([ + this.ensureInfraAppsRepo(project.slug), + this.ensureMirrorRepo(project.slug), + ]) + } + + private async ensureInfraAppsRepo(projectSlug: string) { + await this.gitlab.upsertProjectGroupRepo(projectSlug, INFRA_APPS_REPO_NAME) + } + + private async ensureMirrorRepo(projectSlug: string) { + const mirrorRepo = await this.gitlab.upsertProjectMirrorRepo(projectSlug) + if (mirrorRepo.empty_repo) { + await this.gitlab.commitMirror(mirrorRepo.id) + } + await this.ensureMirrorRepoTriggerToken(projectSlug) + } + + @StartActiveSpan() + private async ensureMirrorRepoTriggerToken(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const triggerToken = await this.gitlab.getOrCreateMirrorPipelineTriggerToken(projectSlug) + const gitlabSecret = { + PROJECT_SLUG: projectSlug, + GIT_MIRROR_PROJECT_ID: triggerToken.repoId, + GIT_MIRROR_TOKEN: triggerToken.token, + } + await this.vault.writeMirrorTriggerToken(gitlabSecret) + span?.setAttribute('vault.secret.written', true) + } + + @StartActiveSpan() + private async getOrRotateMirrorCreds(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const vaultSecret = await this.vault.readTechnReadOnlyCreds(projectSlug) + if (!vaultSecret) return this.createMirrorAccessToken(projectSlug) + + const isExpiring = this.isMirrorCredsExpiring(vaultSecret) + span?.setAttribute('mirror.creds.expiring', isExpiring) + if (!isExpiring) { + span?.setAttribute('mirror.creds.rotated', false) + return vaultSecret.data as { MIRROR_USER: string, MIRROR_TOKEN: string } + } + return this.createMirrorAccessToken(projectSlug) + } + + @StartActiveSpan() + private async createMirrorAccessToken(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('mirror.creds.rotated', true) + const token = await this.gitlab.createMirrorAccessToken(projectSlug) + const creds = { + MIRROR_USER: token.name, + MIRROR_TOKEN: token.token, + } + await this.vault.writeTechReadOnlyCreds(projectSlug, creds) + span?.setAttribute('vault.secret.written', true) + return creds + } + + private isMirrorCredsExpiring(vaultSecret: VaultSecret): boolean { + if (!vaultSecret?.metadata?.created_time) return false + const createdTime = new Date(vaultSecret.metadata.created_time) + return daysAgoFromNow(createdTime) > this.gitlabConfig.mirrorTokenRotationThresholdDays + } + + private getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { + if (!externalRepoUrl) return undefined + try { + return new URL(externalRepoUrl).host + } catch { + return undefined + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts new file mode 100644 index 0000000000..63f574bd3c --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts @@ -0,0 +1,233 @@ +import type { MemberSchema, ProjectSchema } from '@gitbeaker/core' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { createHash } from 'node:crypto' +import { AccessLevel } from '@gitbeaker/core' +import { stringify } from 'yaml' +import { TOPIC_PLUGIN_MANAGED } from './gitlab.constants' + +export type ProjectAccessLevel = Exclude + +export function getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { + if (!externalRepoUrl) return undefined + try { + return new URL(externalRepoUrl).host + } catch { + return undefined + } +} + +export function hasFileContentChanged(file: { content_sha256?: string } | null | undefined, content: string): boolean { + return file?.content_sha256 !== digestContent(content) +} + +export function digestContent(content: string): string { + return createHash('sha256').update(content).digest('hex') +} + +export function generateUsername(email: string): string { + const localPart = email.split('@')[0] + return localPart.replaceAll(/[^\w-]/g, '') +} + +export function generateUsernameCandidates(email: string): string[] { + const username = generateUsername(email) + return [username, `${username}_1`, `${username}_2`, `${username}_3`] +} + +export function generateName(firstName: string | null, lastName: string | null): string { + const parts = [firstName, lastName].filter(Boolean) + return parts.length > 0 ? parts.join(' ') : 'User' +} + +export function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string): string[] { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${projectSlug}${path}`) +} + +export function generateAdminRoleMapping( + roles: ProjectWithDetails['roles'], + adminGroupPath: string, + auditorGroupPath: string, +): { adminRoleId?: string, auditorRoleId?: string } { + const roleIdByOidcGroup = new Map(roles.map(r => [r.oidcGroup, r.id] as [string | null, string])) + return { + adminRoleId: roleIdByOidcGroup.get(adminGroupPath), + auditorRoleId: roleIdByOidcGroup.get(auditorGroupPath), + } +} + +export function generateAccessLevelMapping( + project: ProjectWithDetails, + groupPaths: { reporter: string[], developer: string[], maintainer: string[] }, +): Map { + const getAccessLevelFromOidcGroup = (oidcGroup: string | null): ProjectAccessLevel | null => { + if (!oidcGroup) return null + if (groupPaths.maintainer.includes(oidcGroup)) return AccessLevel.MAINTAINER + if (groupPaths.developer.includes(oidcGroup)) return AccessLevel.DEVELOPER + if (groupPaths.reporter.includes(oidcGroup)) return AccessLevel.REPORTER + return null + } + + const roleAccessLevelById = new Map( + project.roles.map(role => [role.id, getAccessLevelFromOidcGroup(role.oidcGroup)]), + ) + + return project.members.reduce((acc, membership) => { + const highest = membership.roleIds.reduce((highest: ProjectAccessLevel | null, roleId) => { + const level = roleAccessLevelById.get(roleId) + if (level !== null && level !== undefined && (highest === null || level > highest)) return level + return highest + }, null) + acc.set(membership.user.id, highest ?? AccessLevel.GUEST) + return acc + }, new Map()) +} + +export function generateGitlabCIConfigContent() { + return stringify({ + variables: { + PROJECT_NAME: { + description: 'Nom du dépôt (dans ce Gitlab) à synchroniser.', + }, + GIT_BRANCH_DEPLOY: { + description: 'Nom de la branche à synchroniser.', + value: 'main', + }, + SYNC_ALL: { + description: 'Synchroniser toutes les branches.', + value: 'false', + }, + }, + include: [ + { + project: '$CATALOG_PATH', + file: 'mirror.yml', + ref: 'main', + }, + ], + repo_pull_sync: { + extends: '.repo_pull_sync', + only: [ + 'api', + 'triggers', + 'web', + 'schedules', + ], + }, + }) +} + +export function generateMirrorScriptContent() { + return `#!/bin/bash + +set -e + +# Colorize terminal +red='\\e[0;31m' +no_color='\\033[0m' + +# Console step increment +i=1 + +# Default values +BRANCH_TO_SYNC=main + +print_help() { + TEXT_HELPER="\\nThis script aims to send a synchronization request to DSO.\\nFollowing flags are available: + -a Api url to send the synchronization request + -b Branch which is wanted to be synchronize for the given repository (default '$BRANCH_TO_SYNC') + -g GitLab token to trigger the pipeline on the gitlab mirror project + -i Gitlab mirror project id + -r Gitlab repository name to mirror + -h Print script help\\n" + printf "$TEXT_HELPER" +} + +print_args() { + printf "\\nArguments received: + -a API_URL: $API_URL + -b BRANCH_TO_SYNC: $BRANCH_TO_SYNC + -g GITLAB_TRIGGER_TOKEN length: \${#GITLAB_TRIGGER_TOKEN} + -i GITLAB_MIRROR_PROJECT_ID: $GITLAB_MIRROR_PROJECT_ID + -r REPOSITORY_NAME: $REPOSITORY_NAME\\n" +} + +# Parse options +while getopts :ha:b:g:i:r: flag +do + case "\${flag}" in + a) + API_URL=\${OPTARG};; + b) + BRANCH_TO_SYNC=\${OPTARG};; + g) + GITLAB_TRIGGER_TOKEN=\${OPTARG};; + i) + GITLAB_MIRROR_PROJECT_ID=\${OPTARG};; + r) + REPOSITORY_NAME=\${OPTARG};; + h) + printf "\\nHelp requested.\\n" + print_help + printf "\\nExiting.\\n" + exit 0;; + *) + printf "\\nInvalid argument \${OPTARG} (\${flag}).\\n" + print_help + print_args + exit 1;; + esac +done + +# Test if arguments are missing +if [ -z \${API_URL} ] || [ -z \${BRANCH_TO_SYNC} ] || [ -z \${GITLAB_TRIGGER_TOKEN} ] || [ -z \${GITLAB_MIRROR_PROJECT_ID} ] || [ -z \${REPOSITORY_NAME} ]; then + printf "\\nArgument(s) missing!\\n" + print_help + print_args + exit 2 +fi + +# Print arguments +print_args + +# Send synchronization request +printf "\\n\${red}\${i}.\${no_color} Send request to DSO api.\\n\\n" + +curl \\ + -X POST \\ + --fail \\ + -F token=\${GITLAB_TRIGGER_TOKEN} \\ + -F ref=main \\ + -F variables[GIT_BRANCH_DEPLOY]=\${BRANCH_TO_SYNC} \\ + -F variables[PROJECT_NAME]=\${REPOSITORY_NAME} \\ + "\${API_URL}/api/v4/projects/\${GITLAB_MIRROR_PROJECT_ID}/trigger/pipeline" +` +} + +const ownedUserRegex = /group_\d+_bot/u + +export function isOwnedUser(member: MemberSchema) { + return ownedUserRegex.test(member.username) +} + +export function isOwnedRepo(repo: ProjectSchema) { + return repo.topics?.includes(TOPIC_PLUGIN_MANAGED) ?? false +} + +export function isSystemRepo(project: ProjectWithDetails, repo: ProjectSchema) { + return project.repositories.some(r => r.internalRepoName === repo.name) +} + +export function getProjectPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} +export function daysAgoFromNow(date: Date) { + return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) +} + +export function adminRoleFlag(user: ProjectWithDetails['members'][0]['user'], adminRoleId?: string) { + return adminRoleId ? user.adminRoleIds?.includes(adminRoleId) : undefined +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/healthz/healthz.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/healthz/healthz.controller.ts new file mode 100644 index 0000000000..b18ec7aadd --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/healthz/healthz.controller.ts @@ -0,0 +1,14 @@ +import type { HealthzService } from './healthz.service' +import { Controller, Get } from '@nestjs/common' +import { HealthCheck } from '@nestjs/terminus' + +@Controller('api/v1/healthz') +export class HealthzController { + constructor(private readonly healthz: HealthzService) {} + + @Get() + @HealthCheck() + check() { + return this.healthz.check() + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/healthz/healthz.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/healthz/healthz.module.ts new file mode 100644 index 0000000000..2b2cfaa615 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/healthz/healthz.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { ArgoCDModule } from '../argocd/argocd.module' +import { GitlabModule } from '../gitlab/gitlab.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { NexusModule } from '../nexus/nexus.module' +import { OpenCdsModule } from '../opencds/opencds.module' +import { RegistryModule } from '../registry/registry.module' +import { VaultModule } from '../vault/vault.module' +import { HealthzController } from './healthz.controller' +import { HealthzService } from './healthz.service' + +@Module({ + imports: [ + TerminusModule.forRoot(), + ConfigModule.forRoot(), + DatabaseModule, + KeycloakModule, + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + VaultModule, + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(RegistryModule, 'USE_REGISTRY'), + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(OpenCdsModule, 'USE_OPENCDS'), + ], + controllers: [HealthzController], + providers: [HealthzService], +}) +export class HealthzModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts new file mode 100644 index 0000000000..288e80d353 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts @@ -0,0 +1,106 @@ +import { HealthCheckService, TerminusModule } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { OpenCdsHealthService } from '../opencds/opencds-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { VaultHealthService } from '../vault/vault-health.service' +import { HealthzService } from './healthz.service' + +describe('healthzService', () => { + afterEach(() => vi.clearAllMocks()) + + describe('with all optional probes registered', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let keycloak: ReturnType> + let gitlab: ReturnType> + let vault: ReturnType> + let nexus: ReturnType> + let registry: ReturnType> + let argocd: ReturnType> + let opencds: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + keycloak = mockDeep() + gitlab = mockDeep() + vault = mockDeep() + nexus = mockDeep() + registry = mockDeep() + argocd = mockDeep() + opencds = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + { provide: KeycloakHealthService, useValue: keycloak }, + { provide: GitlabHealthService, useValue: gitlab }, + { provide: VaultHealthService, useValue: vault }, + { provide: NexusHealthService, useValue: nexus }, + { provide: RegistryHealthService, useValue: registry }, + { provide: ArgoCDHealthService, useValue: argocd }, + { provide: OpenCdsHealthService, useValue: opencds }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('checks every injected probe, including those wired via @Optional', async () => { + await service.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(keycloak.check).toHaveBeenCalledWith('keycloak') + expect(gitlab.check).toHaveBeenCalledWith('gitlab') + expect(vault.check).toHaveBeenCalledWith('vault') + expect(nexus.check).toHaveBeenCalledWith('nexus') + expect(registry.check).toHaveBeenCalledWith('registry') + expect(argocd.check).toHaveBeenCalledWith('argocd') + expect(opencds.check).toHaveBeenCalledWith('opencds') + }) + }) + + describe('without optional probes registered (@Optional -> undefined)', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('omits probes whose services are not registered', async () => { + await service.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(healthCheck.check).toHaveBeenCalledWith([expect.any(Function)]) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/healthz/healthz.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/healthz/healthz.service.ts new file mode 100644 index 0000000000..67619844e0 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/healthz/healthz.service.ts @@ -0,0 +1,41 @@ +import type { HealthIndicatorFunction } from '@nestjs/terminus' +import { Inject, Injectable, Optional } from '@nestjs/common' +import { HealthCheckService } from '@nestjs/terminus' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { OpenCdsHealthService } from '../opencds/opencds-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { VaultHealthService } from '../vault/vault-health.service' + +@Injectable() +export class HealthzService { + // Optional modules (gated by USE_*) resolve to undefined when not registered. + constructor( + @Inject(HealthCheckService) private readonly health: HealthCheckService, + @Inject(DatabaseHealthService) @Optional() private readonly database?: DatabaseHealthService, + @Inject(KeycloakHealthService) @Optional() private readonly keycloak?: KeycloakHealthService, + @Inject(GitlabHealthService) @Optional() private readonly gitlab?: GitlabHealthService, + @Inject(VaultHealthService) @Optional() private readonly vault?: VaultHealthService, + @Inject(NexusHealthService) @Optional() private readonly nexus?: NexusHealthService, + @Inject(RegistryHealthService) @Optional() private readonly registry?: RegistryHealthService, + @Inject(ArgoCDHealthService) @Optional() private readonly argocd?: ArgoCDHealthService, + @Inject(OpenCdsHealthService) @Optional() private readonly opencds?: OpenCdsHealthService, + ) {} + + check() { + const checks: HealthIndicatorFunction[] = [] + // Each health service's own check() reports 'Not configured' when its URL is absent. + if (this.database) checks.push(() => this.database!.check('database')) + if (this.keycloak) checks.push(() => this.keycloak!.check('keycloak')) + if (this.gitlab) checks.push(() => this.gitlab!.check('gitlab')) + if (this.vault) checks.push(() => this.vault!.check('vault')) + if (this.nexus) checks.push(() => this.nexus!.check('nexus')) + if (this.registry) checks.push(() => this.registry!.check('registry')) + if (this.argocd) checks.push(() => this.argocd!.check('argocd')) + if (this.opencds) checks.push(() => this.opencds!.check('opencds')) + return this.health.check(checks) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts new file mode 100644 index 0000000000..d170ff59ee --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts @@ -0,0 +1,86 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { HttpArgumentsHost } from '@nestjs/common/interfaces' +import type { AdminToken, PersonalAccessToken, User } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from './auth-user.decorator' +import { faker } from '@faker-js/faker' +import { mockDeep } from 'vitest-mock-extended' + +export function makeAuthRequest(headers: FastifyRequest['headers'] = {}): FastifyRequest { + const request = mockDeep() + request.headers = headers + return request +} + +export function makeExecutionContext(headers: FastifyRequest['headers'] = {}): DeepMockProxy { + const context = mockDeep() + const httpArgumentsHost = mockDeep() + const request = mockDeep() + + request.headers = headers + httpArgumentsHost.getRequest.mockReturnValue(request) + context.switchToHttp.mockReturnValue(httpArgumentsHost) + + return context +} + +export function makePersonalAccessToken(overrides: { + id?: string + userId?: string + adminRoleIds?: string[] + type?: User['type'] + status?: PersonalAccessToken['status'] + expirationDate?: PersonalAccessToken['expirationDate'] + lastUse?: PersonalAccessToken['lastUse'] +} = {}): PersonalAccessToken & { owner: { id: string, adminRoleIds: string[], type: string } } { + const id = overrides.id ?? 'pat-id' + const ownerId = overrides.userId ?? 'owner-id' + return { + id, + name: 'test-token', + status: overrides.status ?? 'active', + expirationDate: overrides.expirationDate ?? faker.date.future(), + lastUse: overrides.lastUse ?? null, + createdAt: faker.date.past(), + hash: 'hash', + userId: ownerId, + owner: { + id: ownerId, + adminRoleIds: overrides.adminRoleIds ?? [], + type: overrides.type ?? 'human', + }, + } +} + +export function makeAdminToken(overrides: { + id?: string + userId?: string + adminRoleIds?: string[] + type?: User['type'] + status?: AdminToken['status'] + permissions?: bigint + expirationDate?: AdminToken['expirationDate'] + lastUse?: AdminToken['lastUse'] +} = {}): AdminToken & { owner: { id: string, adminRoleIds: string[], type: string } } { + const id = overrides.id ?? 'admin-token-id' + const ownerId = overrides.userId ?? 'owner-id' + const type = overrides.type ?? 'human' + + return { + id, + name: 'admin-token', + status: overrides.status ?? 'active', + expirationDate: overrides.expirationDate ?? null, + permissions: overrides.permissions ?? 256n, + lastUse: overrides.lastUse ?? null, + createdAt: faker.date.past(), + hash: 'hash', + userId: ownerId, + owner: { + id: ownerId, + adminRoleIds: overrides.adminRoleIds ?? [], + type, + }, + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts new file mode 100644 index 0000000000..f018ac647e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts @@ -0,0 +1,21 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { User } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import { createParamDecorator } from '@nestjs/common' + +export interface UserContext { + userId: string + adminPermissions?: bigint + userType?: User['type'] +} + +export const AuthUser = createParamDecorator( + (_: unknown, ctx: ExecutionContext): UserContext => { + const request = ctx.switchToHttp().getRequest() + return { + userId: request.userId, + adminPermissions: request.adminPermissions, + userType: request.userType, + } + }, +) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts new file mode 100644 index 0000000000..23f7129d2e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../config/keycloak.config' +import { AuthService } from './auth.service' +import { DsoTokenModule } from './dso-token/dso-token.module' +import { KeycloakJwtModule } from './keycloak-jwt/keycloak-jwt.module' + +@Module({ + imports: [ConfigModule.forFeature(keycloakConfigFactory), DsoTokenModule, KeycloakJwtModule], + providers: [ + AuthService, + ], + exports: [ + AuthService, + KeycloakJwtModule, + ], +}) +export class AuthModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts new file mode 100644 index 0000000000..aa0fe99424 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts @@ -0,0 +1,59 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeAuthRequest } from './auth-testing.utils' +import { AuthService } from './auth.service' +import { DsoTokenService } from './dso-token/dso-token.service' +import { KeycloakJwtService } from './keycloak-jwt/keycloak-jwt.service' + +describe('authService', () => { + let module: TestingModule + let service: AuthService + let dsoTokenService: DeepMockProxy + let keycloakJwtService: DeepMockProxy + + beforeEach(async () => { + dsoTokenService = mockDeep() + keycloakJwtService = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: DsoTokenService, useValue: dsoTokenService }, + { provide: KeycloakJwtService, useValue: keycloakJwtService }, + ], + }).compile() + + service = module.get(AuthService) + }) + + it('should authenticate a Fastify request directly', async () => { + dsoTokenService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 0n, userType: 'human' }) + const request = makeAuthRequest({ 'x-dso-token': 'token' }) + + const result = await service.authenticate(request) + + expect(result).toEqual({ userId: 'u1', adminPermissions: 0n, userType: 'human' }) + expect(dsoTokenService.authenticate).toHaveBeenCalledWith(request, undefined) + }) + + it('should authenticate a Keycloak bearer token from the request header', async () => { + keycloakJwtService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 8n, userType: 'human' }) + + const request = makeAuthRequest({ authorization: 'Bearer jwt-token' }) + const result = await service.authenticate(request) + + expect(keycloakJwtService.authenticate).toHaveBeenCalledWith( + request, + undefined, + ) + expect(result).toEqual({ userId: 'u1', adminPermissions: 8n, userType: 'human' }) + }) + + it('should throw 401 when no supported auth header exists', async () => { + await expect(service.authenticate(makeAuthRequest({}))).rejects.toThrow(UnauthorizedException) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts new file mode 100644 index 0000000000..9ee491473c --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts @@ -0,0 +1,37 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from './auth-user.decorator' +import type { AuthRequirements } from './auth.utils' +import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common' +import { DsoTokenService } from './dso-token/dso-token.service' +import { KeycloakJwtService } from './keycloak-jwt/keycloak-jwt.service' + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name) + + constructor( + @Inject(DsoTokenService) private readonly dsoTokenService: DsoTokenService, + @Inject(KeycloakJwtService) private readonly keycloakJwtService: KeycloakJwtService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + const dsoTokenResult = await this.dsoTokenService.authenticate(request, requirements) + if (dsoTokenResult) { + this.logger.debug(`Auth authenticated request (requestId=${request.id}, authSource=dso-token, userId=${dsoTokenResult.userId}, adminPermissions=${dsoTokenResult.adminPermissions?.toString()}, userType=${dsoTokenResult.userType})`) + return dsoTokenResult + } + + const bearerTokenResult = await this.keycloakJwtService.authenticate(request, requirements) + if (bearerTokenResult) { + this.logger.debug(`Auth authenticated request (requestId=${request.id}, authSource=keycloak-jwt, userId=${bearerTokenResult.userId}, adminPermissions=${bearerTokenResult.adminPermissions?.toString()}, userType=${bearerTokenResult.userType})`) + return bearerTokenResult + } + + this.logger.warn(`Auth rejected request (requestId=${request.id}, hasDsoTokenHeader=${typeof request.headers['x-dso-token'] === 'string'}, hasAuthorizationHeader=${typeof request.headers.authorization === 'string'})`) + + throw new UnauthorizedException('Not authenticated') + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts new file mode 100644 index 0000000000..3b4d0c3ed9 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts @@ -0,0 +1,14 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from './auth-user.decorator' + +export interface AuthRequirements { + includeAdminRoleIds?: boolean + includeUserType?: boolean +} + +export interface AuthProvider { + authenticate: ( + request: FastifyRequest, + requirements?: AuthRequirements, + ) => Promise +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts new file mode 100644 index 0000000000..ed96461131 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../../database/database.module' +import { DsoTokenService } from './dso-token.service' + +@Module({ + imports: [DatabaseModule], + providers: [DsoTokenService], + exports: [DsoTokenService], +}) +export class DsoTokenModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts new file mode 100644 index 0000000000..f9ab618442 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts @@ -0,0 +1,118 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { createHash } from 'node:crypto' +import { faker } from '@faker-js/faker' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../../database/prisma.service' +import { makeAdminToken, makePersonalAccessToken } from '../auth-testing.utils' +import { DsoTokenService } from './dso-token.service' + +describe('dsoTokenService', () => { + let module: TestingModule + let service: DsoTokenService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep({ + adminRole: { + findMany: vi.fn().mockResolvedValue([]), + }, + }) + + module = await Test.createTestingModule({ + providers: [ + DsoTokenService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(DsoTokenService) + }) + + it('should validate a PersonalAccessToken and return raw token result', async () => { + const rawToken = faker.string.alphanumeric(32) + const patMock = makePersonalAccessToken({ adminRoleIds: [faker.string.uuid()] }) + prisma.personalAccessToken.findFirst.mockResolvedValue(patMock) + + const result = await service.validateToken(rawToken, { includeAdminRoleIds: true, includeUserType: true }) + + expect(prisma.personalAccessToken.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { hash: createHash('sha256').update(rawToken).digest('hex') } }), + ) + expect(result).toBeDefined() + if (result?.kind !== 'personal') throw new Error('Expected personal token result') + expect(result.ownerAdminRoleIds).toHaveLength(1) + expect(result.userType).toBe('human') + }) + + it('should validate an AdminToken and return raw token result', async () => { + const userId = faker.string.uuid() + prisma.personalAccessToken.findFirst.mockResolvedValue(null) + prisma.adminToken.findFirst.mockResolvedValue( + makeAdminToken({ + id: faker.string.uuid(), + userId, + permissions: 256n, + }), + ) + + const result = await service.validateToken(faker.string.alphanumeric(24), { includeAdminRoleIds: true, includeUserType: true }) + + expect(result).toBeDefined() + if (result?.kind !== 'admin') throw new Error('Expected admin token result') + expect(result.permissions).toBe(256n) + expect(result.userType).toBe('human') + }) + + it('should return undefined when no token found', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue(null) + prisma.adminToken.findFirst.mockResolvedValue(null) + + const result = await service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true }) + + expect(result).toBeUndefined() + }) + + it('should throw 401 when token is inactive', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue( + makePersonalAccessToken({ status: 'revoked', expirationDate: new Date(Date.now() + 86400000) }), + ) + + await expect(service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true })).rejects.toThrow(UnauthorizedException) + }) + + it('should throw 401 when token is expired', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue( + makePersonalAccessToken({ expirationDate: new Date(Date.now() - 1000) }), + ) + + await expect(service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true })).rejects.toThrow(UnauthorizedException) + }) + + it('should update lastUse and lastLogin', async () => { + const patMock = makePersonalAccessToken({}) + prisma.personalAccessToken.findFirst.mockResolvedValue(patMock) + + await service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true }) + + expect(prisma.personalAccessToken.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: patMock.id }, data: { lastUse: expect.any(String) } }), + ) + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: patMock.owner.id }, data: { lastLogin: expect.any(String) } }), + ) + }) + + it('should authenticate a DSO token from the request header', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue(makePersonalAccessToken({})) + + const result = await service.authenticateHeaders({ 'x-dso-token': 'token' }) + + expect(typeof result?.userId).toBe('string') + expect(result?.userType).toBe('human') + expect(result?.adminPermissions).toBe(0n) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts new file mode 100644 index 0000000000..1e27c96d63 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts @@ -0,0 +1,150 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../auth-user.decorator' +import type { AuthProvider, AuthRequirements } from '../auth.utils' +import type { AdminTokenWithOwner, AuthToken, PersonalAccessTokenWithOwner } from './dso-token.utils' +import { createHash } from 'node:crypto' +import { tokenHeaderName } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { PrismaService } from '../../database/prisma.service' +import { makeAdminTokenSelect, makePersonalAccessTokenSelect, validateToken } from './dso-token.utils' + +@Injectable() +export class DsoTokenService implements AuthProvider { + private readonly logger = new Logger(DsoTokenService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + return this.authenticateHeaders(request.headers, requirements) + } + + async authenticateHeaders( + headers: FastifyRequest['headers'], + requirements?: AuthRequirements, + ): Promise { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? true + const includeUserType = requirements?.includeUserType ?? true + + const tokenValue = headers[tokenHeaderName] + if (typeof tokenValue !== 'string') { + return undefined + } + + const tokenResult = await this.validateToken(tokenValue, { includeAdminRoleIds, includeUserType }) + if (!tokenResult) return undefined + + const adminPermissions = includeAdminRoleIds + ? await this.resolveAdminPermissions(tokenResult) + : undefined + + return { + userId: tokenResult.userId, + adminPermissions, + userType: tokenResult.userType, + } + } + + async validateToken( + rawToken: string, + requirements: Required, + ): Promise { + this.logger.debug(`validateToken started`) + const hash = createHash('sha256').update(rawToken).digest('hex') + const result = await this.findAndValidateToken(hash, requirements) + if (!result) { + this.logger.warn(`validateToken token not found`) + return undefined + } + this.logger.debug(`validateToken completed (userId=${result.userId})`) + return result + } + + private async findAndValidateToken(hash: string, requirements: Required): Promise { + const personalAccessTokenResult = await this.findAndValidatePersonalAccessToken(hash, requirements) + if (personalAccessTokenResult) { + return personalAccessTokenResult + } + + const adminTokenResult = await this.findAndValidateAdminToken(hash, requirements) + if (adminTokenResult) { + return adminTokenResult + } + + return undefined + } + + private async findAndValidatePersonalAccessToken(hash: string, requirements: Required): Promise { + const pat = await this.prisma.personalAccessToken.findFirst({ + select: makePersonalAccessTokenSelect(requirements), + where: { hash }, + }) satisfies PersonalAccessTokenWithOwner | null + if (pat) { + validateToken(pat) + await this.updateLastUse('personalAccessToken', pat.id, pat.owner.id) + return { + kind: 'personal' as const, + userId: pat.owner.id, + ownerAdminRoleIds: requirements.includeAdminRoleIds ? (pat.owner.adminRoleIds ?? []) : undefined, + userType: requirements.includeUserType ? (pat.owner.type ?? undefined) : undefined, + } + } + + return undefined + } + + private async findAndValidateAdminToken(hash: string, requirements: Required): Promise { + const adminToken = await this.prisma.adminToken.findFirst({ + select: makeAdminTokenSelect(requirements), + where: { hash }, + }) satisfies AdminTokenWithOwner | null + if (adminToken) { + validateToken(adminToken) + await this.updateLastUse('adminToken', adminToken.id, adminToken.owner.id) + return { + kind: 'admin' as const, + userId: adminToken.owner.id, + permissions: adminToken.permissions, + userType: adminToken.owner.type ?? undefined, + } + } + + return undefined + } + + private async updateLastUse(model: 'personalAccessToken' | 'adminToken', tokenId: string, userId: string) { + const now = new Date().toISOString() + if (model === 'personalAccessToken') { + await this.prisma.personalAccessToken.update({ where: { id: tokenId }, data: { lastUse: now } }) + } else { + await this.prisma.adminToken.update({ where: { id: tokenId }, data: { lastUse: now } }) + } + await this.prisma.user.update({ where: { id: userId }, data: { lastLogin: now } }) + } + + private async resolveAdminPermissions(result: AuthToken): Promise { + const globalRoles = await this.prisma.adminRole.findMany({ + where: { type: 'global' }, + select: { permissions: true }, + }) + const globalPerms = globalRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + + if (result.kind === 'admin') { + return globalPerms | result.permissions + } + const ownerAdminRoleIds = result.ownerAdminRoleIds ?? [] + if (!ownerAdminRoleIds.length) { + return globalPerms + } + const roles = await this.prisma.adminRole.findMany({ + select: { permissions: true }, + where: { id: { in: ownerAdminRoleIds } }, + }) + const tokenPerms = roles.reduce((acc, curr) => acc | curr.permissions, 0n) + return globalPerms | tokenPerms + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts new file mode 100644 index 0000000000..297533b572 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts @@ -0,0 +1,72 @@ +import type { AdminToken, Prisma, User } from '@prisma/client' +import type { AuthRequirements } from '../auth.utils' +import { UnauthorizedException } from '@nestjs/common' + +export type AuthToken + = | { kind: 'admin', userId: string, permissions: bigint, userType?: User['type'] } + | { kind: 'personal', userId: string, ownerAdminRoleIds?: string[], userType?: User['type'] } + +export function makeAdminTokenSelect(requirements?: AuthRequirements): Prisma.AdminTokenSelect { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? false + const includeUserType = requirements?.includeUserType ?? false + + return { + id: true, + name: true, + ...(includeAdminRoleIds ? { owner: { select: { adminRoleIds: true } } } : {}), + ...(includeUserType ? { owner: { select: { type: true } } } : {}), + ...(includeAdminRoleIds && includeUserType ? { owner: { select: { adminRoleIds: true, type: true } } } : {}), + } satisfies Prisma.AdminTokenSelect +} + +export function makePersonalAccessTokenSelect(requirements?: AuthRequirements): Prisma.PersonalAccessTokenSelect { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? false + const includeUserType = requirements?.includeUserType ?? false + + return { + id: true, + name: true, + owner: { + select: { + id: true, + ...(includeAdminRoleIds ? { adminRoleIds: true } : {}), + ...(includeUserType ? { type: true } : {}), + ...(includeAdminRoleIds && includeUserType ? { adminRoleIds: true, type: true } : {}), + }, + }, + } satisfies Prisma.PersonalAccessTokenSelect +} + +export interface PersonalAccessTokenWithOwner { + id: string + status: AdminToken['status'] + expirationDate: Date | null + owner: { + id: string + adminRoleIds?: string[] | null + type?: User['type'] | null + } +} + +export interface AdminTokenWithOwner { + id: string + status: AdminToken['status'] + expirationDate: Date | null + owner: { + id: string + adminRoleIds?: string[] | null + type?: User['type'] | null + } +} + +export type Token = PersonalAccessTokenWithOwner | AdminTokenWithOwner + +export function validateToken(token: Token): void { + if (token.expirationDate && token.expirationDate < new Date()) { + throw new UnauthorizedException(`Token expired: ${token.expirationDate}`) + } + + if (token.status === 'revoked') { + throw new UnauthorizedException('Token revoked') + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts new file mode 100644 index 0000000000..9b29a144f2 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts @@ -0,0 +1,45 @@ +import { faker } from '@faker-js/faker' + +export function makeMockUser(overrides: Partial<{ + id: string + firstName: string + lastName: string + email: string + createdAt: Date + updatedAt: Date + lastLogin: Date | null + adminRoleIds: string[] + type: 'human' | 'bot' | 'ghost' +}> = {}) { + return { + id: faker.string.uuid(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email().toLowerCase(), + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: null, + adminRoleIds: [], + type: 'human' as const, + ...overrides, + } +} + +export function makeMockAdminRole(overrides: Partial<{ + id: string + name: string + permissions: bigint + position: number + oidcGroup: string + type: string +}> = {}) { + return { + id: faker.string.uuid(), + name: faker.lorem.words(2), + permissions: 1n, + position: 0, + oidcGroup: '', + type: 'managed', + ...overrides, + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts new file mode 100644 index 0000000000..6c7851d92d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common' +import { JwtModule } from '@nestjs/jwt' +import { DatabaseModule } from '../../database/database.module' +import { KeycloakSecretProviderModule } from '../keycloak-secret-provider/keycloak-secret-provider.module' +import { KeycloakSecretProviderService } from '../keycloak-secret-provider/keycloak-secret-provider.service' +import { KeycloakJwtService } from './keycloak-jwt.service' + +@Module({ + imports: [ + DatabaseModule, + JwtModule.registerAsync({ + imports: [KeycloakSecretProviderModule], + inject: [KeycloakSecretProviderService], + useFactory: async (client: KeycloakSecretProviderService) => { + // The issuer is fetched from the openid-configuration endpoint + // rather than reconstructed from env vars, as the server may be + // behind a reverse proxy that differs from the public-facing domain. + const issuer = await client.fetchIssuer() + return { + secretOrKeyProvider: (requestType, tokenOrPayload) => client.getSecret(requestType, tokenOrPayload), + // Keycloak tokens currently do not include an audience claim for this app. + verifyOptions: { + algorithms: ['RS256'], + issuer, + }, + } + }, + }), + ], + providers: [KeycloakJwtService], + exports: [KeycloakJwtService, JwtModule], +}) +export class KeycloakJwtModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts new file mode 100644 index 0000000000..ed2c588caf --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts @@ -0,0 +1,170 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { UnauthorizedException } from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../../database/prisma.service' +import { makeAuthRequest } from '../auth-testing.utils' +import { makeMockAdminRole, makeMockUser } from './keycloak-jwt-testing.utils' +import { KeycloakJwtService } from './keycloak-jwt.service' + +describe('keycloakJwtService', () => { + let module: TestingModule + let service: KeycloakJwtService + let prisma: DeepMockProxy + let jwtService: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + jwtService = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + KeycloakJwtService, + { provide: PrismaService, useValue: prisma }, + { provide: JwtService, useValue: jwtService }, + ], + }).compile() + + service = module.get(KeycloakJwtService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('validatePayload', () => { + it('should recompute active admin roles from the current Keycloak group membership', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue( + makeMockUser({ + id: payload.sub, + adminRoleIds: ['stale-oidc-role', 'manual-role'], + }), + ) + prisma.adminRole.findMany.mockResolvedValue([ + makeMockAdminRole({ + id: 'stale-oidc-role', + oidcGroup: '/stale-group', + permissions: 8n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'manual-role', + oidcGroup: '', + permissions: 16n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'current-oidc-role', + oidcGroup: '/current-group', + permissions: 32n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'global-role', + oidcGroup: '', + permissions: 4n, + type: 'global', + }), + ]) + + const result = await service.validatePayload(payload) + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: payload.sub }, + data: { + adminRoleIds: ['manual-role', 'current-oidc-role', 'global-role'], + lastLogin: expect.any(String), + }, + }) + expect(result).toEqual({ + userId: payload.sub, + adminPermissions: 52n, + userType: 'human', + }) + }) + + it('should reject when the local user is missing', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue(null) + + await expect(service.validatePayload(payload)).rejects.toBeInstanceOf(UnauthorizedException) + expect(prisma.adminRole.findMany).not.toHaveBeenCalled() + expect(prisma.user.update).not.toHaveBeenCalled() + }) + + it('should skip admin role resolution when permissions are not required', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue( + makeMockUser({ + id: payload.sub, + type: 'human', + }), + ) + + const result = await service.validatePayload( + payload, + { includeAdminRoleIds: false, includeUserType: true }, + ) + + expect(prisma.adminRole.findMany).not.toHaveBeenCalled() + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: payload.sub }, + data: { + lastLogin: expect.any(String), + }, + }) + expect(result).toEqual({ + userId: payload.sub, + adminPermissions: undefined, + userType: 'human', + }) + }) + }) + + describe('authenticate', () => { + it('should authenticate a bearer token from the request', async () => { + jwtService.verifyAsync.mockResolvedValue({ + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: [], + }) + prisma.user.findUnique.mockResolvedValue(makeMockUser({})) + prisma.adminRole.findMany.mockResolvedValue([]) + + const result = await service.authenticate( + makeAuthRequest({ authorization: 'Bearer jwt-token' }), + ) + + expect(jwtService.verifyAsync).toHaveBeenCalledWith('jwt-token') + expect(result?.userId).toBeDefined() + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts new file mode 100644 index 0000000000..9e1eb00db7 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts @@ -0,0 +1,123 @@ +import type { Prisma } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { IncomingHttpHeaders } from 'node:http' +import type { UserContext } from '../auth-user.decorator' +import type { AuthProvider, AuthRequirements } from '../auth.utils' +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { z } from 'zod' +import { PrismaService } from '../../database/prisma.service' + +const KeycloakPayloadSchema = z.object({ + sub: z.string(), + email: z.string().optional().default(''), + given_name: z.string().optional().default(''), + family_name: z.string().optional().default(''), + groups: z.array(z.string()).optional().default([]), +}) + +type KeycloakPayload = z.infer + +@Injectable() +export class KeycloakJwtService implements AuthProvider { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(JwtService) private readonly jwtService: JwtService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + return this.authenticateHeaders(request.headers, requirements) + } + + async authenticateHeaders( + headers: IncomingHttpHeaders, + requirements?: AuthRequirements, + ): Promise { + const authHeader = headers.authorization + if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) { + return undefined + } + + try { + const jwt = authHeader.slice(7) + const payload = await this.jwtService.verifyAsync(jwt) + const parsedPayload = KeycloakPayloadSchema.parse(payload) + return await this.validatePayload(parsedPayload, requirements) + } catch (error) { + throw new UnauthorizedException( + error instanceof Error ? error.message : 'Authentication failed', + ) + } + } + + async validatePayload( + payload: KeycloakPayload, + requirements?: AuthRequirements, + ): Promise { + const authRequirements = normalizeRequirements(requirements) + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + select: makeUserSelect(authRequirements), + }) + if (!user) { + throw new UnauthorizedException('Not authenticated') + } + + let adminPermissions: bigint | undefined + let mergedRoleIds: string[] | undefined + + if (authRequirements.includeAdminRoleIds) { + const adminRoleIds = 'adminRoleIds' in user ? user.adminRoleIds : [] + const groups = payload.groups + + const matchingAdminRoles = await this.prisma.adminRole.findMany({ + where: { + OR: [ + { oidcGroup: { in: groups } }, + { id: { in: adminRoleIds } }, + { type: 'global' }, + ], + }, + }) + + const activeAdminRoles = matchingAdminRoles.filter(({ oidcGroup, type }) => + type === 'global' || !oidcGroup || groups.includes(oidcGroup), + ) + + mergedRoleIds = [...new Set(activeAdminRoles.map(({ id }) => id))] + adminPermissions = activeAdminRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + } + + await this.prisma.user.update({ + where: { id: payload.sub }, + data: { + ...(mergedRoleIds ? { adminRoleIds: mergedRoleIds } : {}), + lastLogin: new Date().toISOString(), + }, + }) + + return { + userId: payload.sub, + adminPermissions, + userType: 'type' in user ? user.type : undefined, + } + } +} + +function makeUserSelect(requirements: Required) { + return { + id: true, + ...(requirements.includeAdminRoleIds ? { adminRoleIds: true } : {}), + ...(requirements.includeUserType ? { type: true } : {}), + } satisfies Prisma.UserSelect +} + +function normalizeRequirements(requirements: AuthRequirements = {}): Required { + return { + includeAdminRoleIds: requirements.includeAdminRoleIds ?? true, + includeUserType: requirements.includeUserType ?? true, + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts new file mode 100644 index 0000000000..4c5ef71a1b --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts @@ -0,0 +1,22 @@ +import { generateKeyPairSync } from 'node:crypto' + +export function makeJwksResponse(kid: string): Response { + const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const jwk = publicKey.export({ format: 'jwk' }) + return new Response(JSON.stringify({ + keys: [ + { + kid, + kty: 'RSA', + use: 'sig', + n: jwk.n, + e: jwk.e, + }, + ], + }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts new file mode 100644 index 0000000000..42f606a58c --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts @@ -0,0 +1,2 @@ +export const KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX = 'jwks:' +export const KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY = `openid-configuration:` diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts new file mode 100644 index 0000000000..602419cdcf --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts @@ -0,0 +1,15 @@ +import { CacheModule } from '@nestjs/cache-manager' +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../../config/keycloak.config' +import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' + +@Module({ + imports: [ + CacheModule.register(), + ConfigModule.forFeature(keycloakConfigFactory), + ], + providers: [KeycloakSecretProviderService], + exports: [KeycloakSecretProviderService], +}) +export class KeycloakSecretProviderModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts new file mode 100644 index 0000000000..9d7e632122 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts @@ -0,0 +1,211 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { KeycloakConfig } from '../../../keycloak/keycloak.module-definition' +import { faker } from '@faker-js/faker' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { JwtSecretRequestType } from '@nestjs/jwt' +import { Test } from '@nestjs/testing' +import { createCache } from 'cache-manager' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KEYCLOAK_CONFIG } from '../../../keycloak/keycloak.module-definition' +import { makeJwksResponse } from './keycloak-secret-provider-testing.utils' +import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' +import { createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' + +describe('keycloakSecretProviderService', () => { + let module: TestingModule + let service: KeycloakSecretProviderService + let config: DeepMockProxy + let fetchMock: ReturnType + let cache: ReturnType + + beforeEach(async () => { + const keycloakDomain = faker.internet.domainName() + const keycloakRealm = faker.lorem.word() + const openidConfigurationUrl = `https://${keycloakDomain}/realms/${keycloakRealm}/.well-known/openid-configuration` + + config = mockDeep({ + protocol: 'https', + domain: keycloakDomain, + realm: keycloakRealm, + openidConfigurationUrl, + jwksTimeoutMs: 1_000, + jwksCacheTtlMs: 300_000, + openidConfigurationCacheTtlMs: 300_000, + }) + fetchMock = vi.fn() + cache = createCache() + + vi.clearAllMocks() + vi.unstubAllGlobals() + vi.stubGlobal('fetch', fetchMock) + + module = await Test.createTestingModule({ + providers: [ + KeycloakSecretProviderService, + { provide: KEYCLOAK_CONFIG, useValue: config }, + { provide: CACHE_MANAGER, useValue: cache }, + ], + }).compile() + + service = module.get(KeycloakSecretProviderService) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should fetch JWKS from Keycloak and parse the response', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + const internalJwksUri = `${config.protocol}://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-1')) + + const jwks = await service.fetchSigningKeys() + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0]?.[0]).toBe(config.openidConfigurationUrl) + expect(fetchMock.mock.calls[1]?.[0]).toBe(internalJwksUri) + expect(jwks).toEqual({ + keys: [ + { + kid: 'kid-1', + kty: 'RSA', + use: 'sig', + n: expect.any(String), + e: expect.any(String), + }, + ], + }) + }) + + it('should abort and return undefined when the JWKS request exceeds the timeout', async () => { + vi.useFakeTimers() + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockImplementationOnce((_url, init?: RequestInit) => new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + })) + + const promise = service.fetchSigningKeys() + await vi.advanceTimersByTimeAsync(1_000) + + await expect(promise).resolves.toBeUndefined() + }) + + it('should return undefined when Keycloak returns a non-OK response', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(new Response('', { status: 500, statusText: 'Internal Server Error' })) + + await expect(service.fetchSigningKeys()).resolves.toBeUndefined() + }) + + it('should resolve a PEM public key from the JWKS', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-2')) + + const publicKey = await service.fetchPublicKey('kid-2') + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(publicKey).toContain('BEGIN RSA PUBLIC KEY') + expect(await cache.get(createKeycloakSecretProviderPublicKeyCacheKey('kid-2'))).toBe(publicKey) + }) + + it('should reuse the cached PEM public key', async () => { + await cache.set( + createKeycloakSecretProviderPublicKeyCacheKey('cached-kid'), + '-----BEGIN RSA PUBLIC KEY-----\ncached\n-----END RSA PUBLIC KEY-----', + ) + + const publicKey = await service.fetchPublicKey('cached-kid') + + expect(fetchMock).not.toHaveBeenCalled() + expect(publicKey).toBe('-----BEGIN RSA PUBLIC KEY-----\ncached\n-----END RSA PUBLIC KEY-----') + }) + + it('should resolve the secret directly from the JWT token and request type', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-3')) + const header = Buffer.from(JSON.stringify({ kid: 'kid-3' })).toString('base64url') + + const secret = await service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(secret).toContain('BEGIN RSA PUBLIC KEY') + }) + + it('should reject signing requests', async () => { + await expect(service.getSecret(JwtSecretRequestType.SIGN, 'payload')).rejects.toThrow('Signing is not supported') + }) + + it('should reject non-string tokens', async () => { + await expect(service.getSecret(JwtSecretRequestType.VERIFY, Buffer.from('payload'))).rejects.toThrow( + 'Unsupported token type', + ) + }) + + it('should reject malformed JWTs', async () => { + await expect(service.getSecret(JwtSecretRequestType.VERIFY, 'only.two')).rejects.toThrow('Invalid JWT format') + }) + + it('should reject JWTs without a kid header', async () => { + const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url') + + await expect(service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`)).rejects.toThrow( + 'Missing kid', + ) + }) + + it('should reject JWTs when the key cannot be resolved', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ keys: [] }))) + const header = Buffer.from(JSON.stringify({ kid: 'missing-kid' })).toString('base64url') + + await expect(service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`)).rejects.toThrow( + 'Unknown signing key', + ) + }) + + it('should resolve the issuer from openid-configuration', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + + await expect(service.fetchIssuer()).resolves.toBe(issuer) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('should replace the discovered JWKS domain with the configured internal Keycloak domain', async () => { + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + issuer: `https://${config.domain}/realms/${config.realm}`, + jwks_uri: publicJwksUri, + }))) + + await expect(service.fetchJwksUri()).resolves.toBe( + `https://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs`, + ) + }) + + it('should keep the discovered JWKS URI unchanged when no internal Keycloak domain is configured', async () => { + config.domain = undefined + const publicJwksUri = `https://public.example.test/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + issuer: 'https://public.example.test/realms/test', + jwks_uri: publicJwksUri, + }))) + + await expect(service.fetchJwksUri()).resolves.toBe(publicJwksUri) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts new file mode 100644 index 0000000000..69a19bb5fa --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts @@ -0,0 +1,162 @@ +import type { Cache } from 'cache-manager' +import type { KeycloakConfig } from '../../../keycloak/keycloak.module-definition' +import { createPublicKey } from 'node:crypto' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { JwtSecretRequestType } from '@nestjs/jwt' +import { z } from 'zod' +import { KEYCLOAK_CONFIG } from '../../../keycloak/keycloak.module-definition' +import { createKeycloakSecretProviderOpenIdConfigurationCacheKey, createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' + +const OpenidConfigurationSchema = z.object({ + issuer: z.string().url(), + jwks_uri: z.string().url(), +}) + +type OpenidConfiguration = z.infer + +const JwksResponseSchema = z.object({ + keys: z.array(z.object({ + kid: z.string(), + kty: z.string(), + use: z.string(), + n: z.string(), + e: z.string(), + })), +}) + +type JwksResponse = z.infer + +const JwtHeaderSchema = z.object({ + kid: z.string(), +}) + +@Injectable() +export class KeycloakSecretProviderService { + private readonly logger = new Logger(KeycloakSecretProviderService.name) + + constructor( + @Inject(CACHE_MANAGER) private readonly cache: Cache, + @Inject(KEYCLOAK_CONFIG) private readonly keycloakConfig: KeycloakConfig, + ) {} + + async fetchOpenIdConfig(): Promise { + const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(this.keycloakConfig.openidConfigurationUrl) + const cached = await this.cache.get(cacheKey) + if (cached) return cached + + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) + if (!response.ok) { + this.logger.error(`Failed to fetch openid-configuration: ${response.status} ${response.statusText}`) + return undefined + } + + const raw = await response.json() + const config = OpenidConfigurationSchema.safeParse(raw) + if (!config.success) { + this.logger.error('openid-configuration response missing jwks_uri') + return undefined + } + + await this.cache.set(cacheKey, config.data, this.keycloakConfig.openidConfigurationCacheTtlMs) + return config.data + } + + async fetchIssuer(): Promise { + const config = await this.fetchOpenIdConfig() + return config?.issuer + } + + async fetchJwksUri(): Promise { + const config = await this.fetchOpenIdConfig() + return config ? this.replaceJwksUriDomainWithInternalDomain(config.jwks_uri) : undefined + } + + private replaceJwksUriDomainWithInternalDomain(jwksUri: string): string { + if (!this.keycloakConfig.domain) return jwksUri + const url = new URL(jwksUri) + url.protocol = this.keycloakConfig.protocol + url.host = this.keycloakConfig.domain + this.logger.log(`Replacing JWKS URI domain: ${jwksUri} -> ${url.toString()}`) + return url.toString() + } + + async fetchSigningKeys(): Promise { + const jwksUri = await this.fetchJwksUri() + if (!jwksUri) return undefined + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), this.keycloakConfig.jwksTimeoutMs) + + try { + const response = await fetch(jwksUri, { signal: controller.signal }) + if (!response.ok) { + this.logger.error(`Failed to fetch JWKS: ${response.status} ${response.statusText}`) + return undefined + } + + const raw = await response.json() + return JwksResponseSchema.parse(raw) + } catch (error) { + this.logger.error(`Failed to fetch JWKS: ${error instanceof Error ? error.message : String(error)}`) + return undefined + } finally { + clearTimeout(timeout) + } + } + + async fetchPublicKey(kid: string): Promise { + const cacheKey = createKeycloakSecretProviderPublicKeyCacheKey(kid) + const cached = await this.cache.get(cacheKey) + if (cached) return cached + + const jwks = await this.fetchSigningKeys() + if (!jwks) return undefined + + const key = jwks.keys.find(candidate => candidate.kid === kid && candidate.use === 'sig' && candidate.kty === 'RSA') + if (!key) return undefined + + const publicKey = createPublicKey({ + key: { kty: 'RSA', n: key.n, e: key.e }, + format: 'jwk', + }) + + const pem = publicKey.export({ format: 'pem', type: 'pkcs1' }) as string + await this.cache.set(cacheKey, pem, this.keycloakConfig.jwksCacheTtlMs) + return pem + } + + async getSecret( + requestType: JwtSecretRequestType, + tokenOrPayload: string | object | Buffer, + ): Promise { + if (requestType === JwtSecretRequestType.SIGN) { + throw new TypeError('Signing is not supported') + } + + if (typeof tokenOrPayload !== 'string') { + throw new TypeError('Unsupported token type') + } + + const parts = tokenOrPayload.split('.') + if (parts.length !== 3) { + throw new TypeError('Invalid JWT format') + } + + const rawHeader = JSON.parse( + Buffer.from(parts[0].replaceAll('-', '+').replaceAll('_', '/'), 'base64').toString(), + ) + const header = JwtHeaderSchema.safeParse(rawHeader) + if (!header.success) { + throw new TypeError('Missing kid') + } + const { kid } = header.data + + const publicKey = await this.fetchPublicKey(kid) + if (!publicKey) { + throw new Error('Unknown signing key') + } + + return publicKey + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts new file mode 100644 index 0000000000..b13beeed98 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts @@ -0,0 +1,9 @@ +import { KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY, KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX } from './keycloak-secret-provider.constants' + +export function createKeycloakSecretProviderPublicKeyCacheKey(kid: string): string { + return `${KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX}${kid}` +} + +export function createKeycloakSecretProviderOpenIdConfigurationCacheKey(url: string): string { + return `${KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY}${url}` +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts new file mode 100644 index 0000000000..d1853a3219 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts @@ -0,0 +1,27 @@ +export interface BaseConfig { + nodeEnv: 'development' | 'production' | 'test' + isTest: boolean + isDev: boolean + isCI: boolean + isProd: boolean + integration: boolean + ci: boolean + devSetup: boolean + docker: boolean + serverHost: string + serverPort: number + appVersion: string + dbUrl?: string + sessionSecret?: string + contactEmail: string + mockPlugins: boolean + projectsRootDir?: string + pluginsDir: string + httpProxy?: string + httpsProxy?: string +} + +// Runtime config (BASE_CONFIG token) is owned by the global config/base.config.ts +// (which calls registerAs('base', ...)); re-exported here so services keep a single +// stable import surface (the module) while config depends on the module. +export { BASE_CONFIG } from './base.module' diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts new file mode 100644 index 0000000000..5ee8150bd8 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts @@ -0,0 +1,13 @@ +import type { BaseConfig } from './base.config' +import { Global, Module } from '@nestjs/common' +import { createConfigurableModule } from '../../configurable-feature-module' + +export interface BaseModuleOptions extends BaseConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('base', true) + +@Global() +@Module({}) +export class BaseModule extends ConfigurableModuleClass {} + +export const BASE_CONFIG = MODULE_OPTIONS_TOKEN diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts new file mode 100644 index 0000000000..7c42430723 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts @@ -0,0 +1,52 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import { HealthIndicatorService } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { DatabaseHealthService } from './database-health.service' +import { PrismaService } from './prisma.service' + +describe('databaseHealthService', () => { + let service: DatabaseHealthService + let prisma: DeepMockProxy + let indicatorSession: { up: ReturnType, down: ReturnType } + + beforeEach(async () => { + prisma = mockDeep() + const healthIndicatorMock = mockDeep() + indicatorSession = { + up: vi.fn().mockReturnValue({ status: 'up' }), + down: vi.fn().mockReturnValue({ status: 'down' }), + } + vi.mocked(healthIndicatorMock.check).mockReturnValue(indicatorSession as any) + + const module = await Test.createTestingModule({ + providers: [ + DatabaseHealthService, + { provide: PrismaService, useValue: prisma }, + { provide: HealthIndicatorService, useValue: healthIndicatorMock }, + ], + }).compile() + + service = module.get(DatabaseHealthService) + }) + + it('reports up when the query succeeds', async () => { + prisma.$queryRaw.mockResolvedValueOnce(undefined as any) + + const result = await service.check('database') + + expect(prisma.$queryRaw).toHaveBeenCalled() + expect(indicatorSession.up).toHaveBeenCalled() + expect(result).toEqual({ status: 'up' }) + }) + + it('reports down when the query throws', async () => { + prisma.$queryRaw.mockRejectedValueOnce(new Error('boom')) + + const result = await service.check('database') + + expect(indicatorSession.down).toHaveBeenCalledWith({ message: 'boom' }) + expect(result).toEqual({ status: 'down' }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts new file mode 100644 index 0000000000..1e37899c9b --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts @@ -0,0 +1,23 @@ +import { Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { PrismaService } from './prisma.service' + +@Injectable() +export class DatabaseHealthService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + await this.prisma.$queryRaw`SELECT 1` + return indicator.up() + } catch (error) { + return indicator.down({ + message: error instanceof Error ? error.message : String(error), + }) + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts new file mode 100644 index 0000000000..7e909376ec --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseHealthService } from './database-health.service' +import { DatabaseService } from './database.service' +import { PrismaService } from './prisma.service' + +@Module({ + imports: [TerminusModule], + providers: [DatabaseHealthService, DatabaseService, PrismaService], + exports: [DatabaseHealthService, DatabaseService, PrismaService], +}) +export class DatabaseModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts new file mode 100644 index 0000000000..3f373ba1eb --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts @@ -0,0 +1,30 @@ +import type { ConfigType } from '@nestjs/config' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../config/base.config' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../../infrastructure/config/base.config' +import { DatabaseService } from './database.service' +import { PrismaService } from './prisma.service' + +describe('databaseService', () => { + let service: DatabaseService + + beforeEach(async () => { + const baseConfig = mockDeep() + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabaseService, + { provide: PrismaService, useValue: mockDeep() }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(DatabaseService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts new file mode 100644 index 0000000000..5d9d0c32f7 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts @@ -0,0 +1,73 @@ +import type { BaseConfig } from '../../infrastructure/config/base.config' +import { setTimeout } from 'node:timers/promises' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { BASE_CONFIG } from '../../infrastructure/config/base.config' +import { PrismaService } from './prisma.service' + +@Injectable() +export class DatabaseService { + private readonly loggerService = new Logger(DatabaseService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(BASE_CONFIG) private readonly configurationService: BaseConfig, + ) { + this.DELAY_BEFORE_RETRY + = this.configurationService.isTest || this.configurationService.isCI + ? 1000 + : 10000 + } + + DELAY_BEFORE_RETRY!: number + closingConnections = false + + async getConnection(triesLeft = 5): Promise { + if (this.closingConnections || triesLeft <= 0) { + throw new Error('Unable to connect to Postgres server') + } + triesLeft-- + + try { + if ( + this.configurationService.isDev + || this.configurationService.isTest + || this.configurationService.isCI + ) { + this.loggerService.log( + `Trying to connect to Postgres with: ${this.configurationService.dbUrl}`, + ) + } + await this.prisma.$connect() + + this.loggerService.log('Connected to Postgres!') + } catch (error) { + if (triesLeft > 0) { + this.loggerService.error(error) + this.loggerService.log( + `Could not connect to Postgres: ${error.message}`, + ) + this.loggerService.log(`Retrying (${triesLeft} tries left)`) + await setTimeout(this.DELAY_BEFORE_RETRY) + return this.getConnection(triesLeft) + } + + this.loggerService.log( + `Could not connect to Postgres: ${error.message}`, + ) + this.loggerService.log('Out of retries') + error.message = `Out of retries, last error: ${error.message}` + throw error + } + } + + async closeConnections() { + this.closingConnections = true + try { + await this.prisma.$disconnect() + } catch (error) { + this.loggerService.error(error) + } finally { + this.closingConnections = false + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts new file mode 100644 index 0000000000..f10a31e573 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts @@ -0,0 +1,14 @@ +import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common' +import { Injectable } from '@nestjs/common' +import { PrismaClient } from '@prisma/client' + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect() + } + + async onModuleDestroy() { + await this.$disconnect() + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts new file mode 100644 index 0000000000..8eb62bea67 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitterModule } from '@nestjs/event-emitter' +import { baseConfigFactory } from '../../../config/base.config' + +@Module({ + imports: [EventEmitterModule.forRoot(), ConfigModule.forFeature(baseConfigFactory)], + exports: [EventEmitterModule], +}) +export class EventsModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts new file mode 100644 index 0000000000..25b2a4e695 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from './auth/auth.module' +import { DatabaseModule } from './database/database.module' +import { EventsModule } from './events/events.module' +import { LoggerModule } from './logger/logger.module' +import { PermissionModule } from './permission/permission.module' + +@Module({ + providers: [], + imports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + exports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], +}) +export class InfrastructureModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts new file mode 100644 index 0000000000..257ef429f8 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts @@ -0,0 +1,37 @@ +import type { BaseConfig } from '../config/base.config' +import { getLoggerOptions } from '@cpn-console/logger' +import { Module } from '@nestjs/common' +import { LoggerModule as PinoLoggerModule } from 'nestjs-pino' +import { BASE_CONFIG } from '../config/base.config' + +@Module({ + imports: [ + PinoLoggerModule.forRootAsync({ + imports: [], + inject: [BASE_CONFIG], + useFactory: async (baseConfig: BaseConfig) => { + return { + pinoHttp: { + ...getLoggerOptions(baseConfig.isProd ? 'production' : 'development', baseConfig.isTest ? 'info' : 'debug'), + customLogLevel: (req, res, err) => { + if (err || res.statusCode >= 500) { + return 'error' + } + if (res.statusCode >= 400) { + return 'warn' + } + // kube liveness/readiness probes hit healthz constantly, only log it on failure + if (req.url?.split('?')[0] === '/api/v1/healthz') { + return 'silent' + } + return 'info' + }, + }, + } + }, + }), + ], + controllers: [], + providers: [], +}) +export class LoggerModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts new file mode 100644 index 0000000000..c2f926a351 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { ProjectPermissionModule } from './project/project.module' +import { UserPermissionModule } from './user/user.module' + +@Module({ + imports: [UserPermissionModule, ProjectPermissionModule], + exports: [UserPermissionModule, ProjectPermissionModule], +}) +export class PermissionModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts new file mode 100644 index 0000000000..7863f5ad4b --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_ACCESS_KEY = 'project-access' + +export function RequireProjectAccess() { + return SetMetadata(PROJECT_ACCESS_KEY, true) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts new file mode 100644 index 0000000000..ee11a3df91 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts @@ -0,0 +1,52 @@ +import type { FastifyRequest } from 'fastify' +import type { ProjectRequirements } from './project-loader.utils' +import type { ProjectContext } from './project.guard' +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../../database/prisma.service' +import { isUuid, makeProjectSelect, resolveProjectPermissions } from './project-loader.utils' + +type RequestWithProjectParams = FastifyRequest<{ Params?: ProjectParams }> + +interface ProjectParams { + projectId?: string + projectSlug?: string +} + +@Injectable() +export class ProjectPermissionLoaderService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async load(request: RequestWithProjectParams, userId: string, requirements?: ProjectRequirements): Promise { + let where: { id: string } | { slug: string } + if (request.params?.projectId) { + where = isUuid(request.params.projectId) ? { id: request.params.projectId } : { slug: request.params.projectId } + } else if (request.params?.projectSlug) { + where = { slug: request.params.projectSlug } + } else { + throw new BadRequestException('projectId or projectSlug is required') + } + + const raw = await this.prisma.project.findUnique({ + where, + select: makeProjectSelect(requirements), + }) + if (!raw) throw new NotFoundException('Projet introuvable') + + const project: ProjectContext = { + id: raw.id, + slug: raw.slug, + } + + if (requirements?.includeStatus) { + project.status = raw.status + } + if (requirements?.includeLocked) { + project.locked = raw.locked + } + if (requirements?.includePermissions) { + project.projectPermissions = resolveProjectPermissions(raw, userId) + } + + return project + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts new file mode 100644 index 0000000000..16e48b3fac --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts @@ -0,0 +1,53 @@ +import type { Prisma } from '@prisma/client' +import { PROJECT_PERMS } from '@cpn-console/shared' + +export interface ProjectRequirements { + includeStatus?: boolean + includeLocked?: boolean + includePermissions?: boolean +} + +export function makeProjectSelect(requirements?: ProjectRequirements): Prisma.ProjectSelect { + const includePermissions = requirements?.includePermissions ?? true + const includeStatus = requirements?.includeStatus ?? true + const includeLocked = requirements?.includeLocked ?? true + + return { + id: true, + slug: true, + ...(includeStatus ? { status: true } : {}), + ...(includeLocked ? { locked: true } : {}), + ...(includePermissions + ? { + ownerId: true, + everyonePerms: true, + roles: { select: { id: true, permissions: true } }, + members: { select: { userId: true, roleIds: true } }, + } + : {}), + } satisfies Prisma.ProjectSelect +} + +export function resolveProjectPermissions( + raw: { ownerId: string, everyonePerms: bigint, roles: Array<{ id: string, permissions: bigint }>, members: Array<{ userId: string, roleIds: string[] }> }, + userId: string, +): bigint { + if (raw.ownerId === userId) { + return PROJECT_PERMS.MANAGE + } + + const member = raw.members.find(m => m.userId === userId) + if (!member) { + return 0n + } + + const memberRoles = raw.roles.filter(role => member.roleIds.includes(role.id)) + return memberRoles.reduce( + (acc, curr) => acc | curr.permissions, + raw.everyonePerms | PROJECT_PERMS.GUEST, + ) +} + +export function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts new file mode 100644 index 0000000000..ac63012cc3 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_LOCKED_KEY = 'project-locked' + +export function RequireProjectLocked(locked: boolean) { + return SetMetadata(PROJECT_LOCKED_KEY, locked) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts new file mode 100644 index 0000000000..da064283ff --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts @@ -0,0 +1,8 @@ +import type { ProjectAuthorized } from '@cpn-console/shared' +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_PERMISSION_KEY = 'project-permission' + +export function RequireProjectPermission(...permissions: (keyof typeof ProjectAuthorized)[]) { + return SetMetadata(PROJECT_PERMISSION_KEY, permissions) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts new file mode 100644 index 0000000000..f1b2f05f48 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts @@ -0,0 +1,8 @@ +import type { Project } from '@prisma/client' +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_STATUS_KEY = 'project-status' + +export function RequireProjectStatus(...statuses: Project['status'][]) { + return SetMetadata(PROJECT_STATUS_KEY, statuses) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts new file mode 100644 index 0000000000..921cffd845 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts @@ -0,0 +1,14 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { FastifyRequest } from 'fastify' +import type { ProjectContext } from './project.guard' +import { createParamDecorator } from '@nestjs/common' + +export const Project = createParamDecorator( + (_: unknown, ctx: ExecutionContext): ProjectContext => { + const request = ctx.switchToHttp().getRequest() + if (!request.project) { + throw new Error('Project context is missing from the request') + } + return request.project + }, +) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts new file mode 100644 index 0000000000..8d4a5ba4c6 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts @@ -0,0 +1,63 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ProjectContext } from './project.guard' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AuthService } from '../../auth/auth.service' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectGuard } from './project.guard' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' +import { makeExecutionContext, makeProjectContext, makeProjectPolicy } from './project.testing.utils' + +describe('projectGuard', () => { + let module: TestingModule + let guard: ProjectGuard + let authService: DeepMockProxy + let projectService: DeepMockProxy + let projectPolicy: DeepMockProxy + let loader: DeepMockProxy + + beforeEach(async () => { + authService = mockDeep() + projectService = mockDeep() + projectPolicy = mockDeep() + loader = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectGuard, + { provide: AuthService, useValue: authService }, + { provide: ProjectPermissionService, useValue: projectService }, + { provide: ProjectPermissionPolicy, useValue: projectPolicy }, + { provide: ProjectPermissionLoaderService, useValue: loader }, + ], + }).compile() + + guard = module.get(ProjectGuard) + }) + + it('throws 401 when userId is not set on the request', async () => { + projectPolicy.build.mockReturnValue(makeProjectPolicy()) + authService.authenticate.mockRejectedValue(new UnauthorizedException()) + const ctx = makeExecutionContext({}) + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException) + }) + + it('loads project and validates', async () => { + const project: ProjectContext = makeProjectContext({}) + const policy = makeProjectPolicy({ projectPermissions: ['Manage'] }) + projectPolicy.build.mockReturnValue(policy) + authService.authenticate.mockResolvedValue({ userId: 'member1', adminPermissions: 0n }) + loader.load.mockResolvedValue(project) + const request = { userId: 'member1', adminPermissions: 0n, params: { projectId: 'p1' } } + const ctx = makeExecutionContext(request) + + const result = await guard.canActivate(ctx) + + expect(result).toBe(true) + expect(loader.load).toHaveBeenCalledWith(request, 'member1', expect.anything()) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts new file mode 100644 index 0000000000..b5ef931868 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts @@ -0,0 +1,94 @@ +import type { CanActivate, ExecutionContext } from '@nestjs/common' +import type { Project } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../../auth/auth-user.decorator' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { AuthService } from '../../auth/auth.service' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' + +export interface ProjectContext { + id: string + slug: string + locked?: boolean + status?: Project['status'] + projectPermissions?: bigint +} + +export interface RequestWithProjectContext extends FastifyRequest { + project?: ProjectContext +} + +type RequestWithUserContext = FastifyRequest & { + userId?: string + adminPermissions?: bigint + userType?: string +} + +interface ProjectParams { + projectId?: string + projectSlug?: string +} + +@Injectable() +export class ProjectGuard implements CanActivate { + private readonly logger = new Logger(ProjectGuard.name) + + constructor( + @Inject(AuthService) private readonly authService: AuthService, + @Inject(ProjectPermissionService) private readonly projectService: ProjectPermissionService, + @Inject(ProjectPermissionLoaderService) private readonly loader: ProjectPermissionLoaderService, + @Inject(ProjectPermissionPolicy) private readonly projectPolicy: ProjectPermissionPolicy, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const policy = this.projectPolicy.build(context) + const request = this.getRequest(context) + const user = await this.authenticate(request) + const project = await this.loadProject(request, user.userId) + + this.projectService.validate(policy, project, user) + + return true + } + + private getRequest(context: ExecutionContext): RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }> { + return context.switchToHttp().getRequest< + RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }> + >() + } + + private async authenticate( + request: RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }>, + ): Promise { + try { + const user = await this.authService.authenticate(request, { + includeAdminRoleIds: true, + includeUserType: true, + }) + + request.userId = user.userId + if (user.adminPermissions !== undefined) request.adminPermissions = user.adminPermissions + if (user.userType !== undefined) request.userType = user.userType + + return user + } catch (error) { + this.logger.warn(`Project access auth rejected (requestId=${request.id}, error=${error instanceof Error ? error.message : String(error)})`) + throw error + } + } + + private async loadProject( + request: RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }>, + userId: string, + ): Promise { + const project = await this.loader.load(request, userId, { + includeStatus: true, + includeLocked: true, + includePermissions: true, + }) + request.project = project + return project + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts new file mode 100644 index 0000000000..6561dd78dd --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../../auth/auth.module' +import { DatabaseModule } from '../../database/database.module' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectGuard } from './project.guard' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + ], + providers: [ + ProjectGuard, + ProjectPermissionLoaderService, + ProjectPermissionService, + ProjectPermissionPolicy, + ], + exports: [ + ProjectGuard, + ProjectPermissionLoaderService, + ProjectPermissionService, + ProjectPermissionPolicy, + ], +}) +export class ProjectPermissionModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts new file mode 100644 index 0000000000..9d4098d93e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts @@ -0,0 +1,37 @@ +import type { AdminAuthorized, ProjectAuthorized } from '@cpn-console/shared' +import type { ExecutionContext } from '@nestjs/common' +import type { Project, User } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { ADMIN_PERMISSIONS_KEY } from '../user/user-admin-permission.decorator' +import { USER_TYPES_KEY } from '../user/user-type.decorator' +import { PROJECT_ACCESS_KEY } from './project-access.decorator' +import { PROJECT_LOCKED_KEY } from './project-locked.decorator' +import { PROJECT_PERMISSION_KEY } from './project-permission.decorator' +import { PROJECT_STATUS_KEY } from './project-status.decorator' + +export interface ProjectPolicyConfig { + adminPermissions: (keyof typeof AdminAuthorized)[] + userTypes: User['type'][] + projectPermissions: (keyof typeof ProjectAuthorized)[] + projectStatuses: Project['status'][] + projectLocked?: boolean + projectAccess: boolean +} + +@Injectable() +export class ProjectPermissionPolicy { + constructor(@Inject(Reflector) private readonly reflector: Reflector) {} + + build(context: ExecutionContext): ProjectPolicyConfig { + const targets = [context.getHandler(), context.getClass()] + return { + adminPermissions: this.reflector.getAllAndOverride<(keyof typeof AdminAuthorized)[] | undefined>(ADMIN_PERMISSIONS_KEY, targets) ?? [], + userTypes: this.reflector.getAllAndOverride(USER_TYPES_KEY, targets) ?? [], + projectPermissions: this.reflector.getAllAndOverride<(keyof typeof ProjectAuthorized)[] | undefined>(PROJECT_PERMISSION_KEY, targets) ?? [], + projectStatuses: this.reflector.getAllAndOverride(PROJECT_STATUS_KEY, targets) ?? [], + projectLocked: this.reflector.getAllAndOverride(PROJECT_LOCKED_KEY, targets), + projectAccess: this.reflector.getAllAndOverride(PROJECT_ACCESS_KEY, targets) ?? false, + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts new file mode 100644 index 0000000000..f25771dd42 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts @@ -0,0 +1,72 @@ +import type { ProjectStatus, User } from '@prisma/client' +import type { UserContext } from '../../auth/auth-user.decorator' +import type { ProjectContext } from './project.guard' +import type { ProjectPolicyConfig } from './project.policy' +import { AdminAuthorized, ProjectAuthorized } from '@cpn-console/shared' +import { ForbiddenException, Injectable, Logger } from '@nestjs/common' + +@Injectable() +export class ProjectPermissionService { + private readonly logger = new Logger(ProjectPermissionService.name) + + validate(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + this.validateUserType(policy, user.userType) + this.validateProjectStatus(policy, project.status) + this.validateProjectLock(policy, project.locked) + this.validateProjectAccess(policy, project, user) + this.validateProjectPermissions(policy, project, user) + } + + validateProjectStatus(policy: ProjectPolicyConfig, projectStatus: ProjectStatus | undefined): void { + if (policy.projectStatuses.length > 0 && (!projectStatus || !policy.projectStatuses.includes(projectStatus))) { + this.logger.warn(`project auth denied: invalid project status (projectStatus=${projectStatus}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Statut de projet invalide') + } + } + + validateProjectLock(policy: ProjectPolicyConfig, projectLocked: boolean | undefined): void { + if (policy.projectLocked !== undefined && projectLocked !== policy.projectLocked) { + this.logger.warn(`project auth denied: invalid lock status (projectLocked=${projectLocked}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('État de verrouillage invalide') + } + } + + validateUserType(policy: ProjectPolicyConfig, userType: string | undefined): void { + if (!policy.userTypes.length) return + if (typeof userType !== 'string' || !policy.userTypes.includes(userType as User['type'])) { + this.logger.warn(`project auth denied: invalid user type (userType=${userType}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Cannot find requestor in request context') + } + } + + validateProjectAccess(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + if (!policy.projectAccess) return + + const adminPermissions = user.adminPermissions ?? 0n + const hasAccess = AdminAuthorized.Manage(adminPermissions) || Boolean(project.projectPermissions) + if (!hasAccess) { + this.logger.warn( + `project auth denied: missing project access (projectId=${project.id}, projectSlug=${project.slug}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, projectPermissions=${project.projectPermissions?.toString()}, policy=${JSON.stringify(policy)})`, + ) + throw new ForbiddenException('Permissions insuffisantes pour ce projet') + } + } + + validateProjectPermissions(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + if (!policy.projectPermissions.length) return + + const hasPermission = policy.projectPermissions.every( + permName => ProjectAuthorized[permName]({ + adminPermissions: user.adminPermissions, + projectPermissions: project.projectPermissions, + }), + ) + + if (!hasPermission) { + this.logger.warn( + `project auth denied: missing project permissions (projectId=${project.id}, projectSlug=${project.slug}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, projectPermissions=${project.projectPermissions?.toString()}, policy=${JSON.stringify(policy)})`, + ) + throw new ForbiddenException('Permissions insuffisantes pour ce projet') + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts new file mode 100644 index 0000000000..b2480f78cf --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts @@ -0,0 +1,37 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { HttpArgumentsHost } from '@nestjs/common/interfaces' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ProjectContext } from './project.guard' +import type { ProjectPolicyConfig } from './project.policy' +import { mockDeep } from 'vitest-mock-extended' + +export function makeExecutionContext(request: Record): DeepMockProxy { + const ctx = mockDeep() + const httpArgs = mockDeep() + httpArgs.getRequest.mockReturnValue(request) + ctx.switchToHttp.mockReturnValue(httpArgs) + return ctx +} + +export function makeProjectContext(overrides: Partial = {}): ProjectContext { + return { + id: 'p1', + slug: 'project', + locked: false, + status: 'created', + projectPermissions: 6n, + ...overrides, + } +} + +export function makeProjectPolicy(overrides: Partial = {}): ProjectPolicyConfig { + return { + adminPermissions: [], + userTypes: [], + projectPermissions: [], + projectStatuses: [], + projectLocked: undefined, + projectAccess: false, + ...overrides, + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts new file mode 100644 index 0000000000..ab70852172 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts @@ -0,0 +1,8 @@ +import type { AdminAuthorized } from '@cpn-console/shared' +import { SetMetadata } from '@nestjs/common' + +export const ADMIN_PERMISSIONS_KEY = 'admin-permissions' + +export function RequireAdminPermission(...permissions: (keyof typeof AdminAuthorized)[]) { + return SetMetadata(ADMIN_PERMISSIONS_KEY, permissions) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts new file mode 100644 index 0000000000..c2e004cc46 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts @@ -0,0 +1,25 @@ +import type { AdminAuthorized } from '@cpn-console/shared' +import type { ExecutionContext } from '@nestjs/common' +import type { User as PrismaUser } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { ADMIN_PERMISSIONS_KEY } from './user-admin-permission.decorator' +import { USER_TYPES_KEY } from './user-type.decorator' + +export interface UserPolicyConfig { + adminPermissions: (keyof typeof AdminAuthorized)[] + userTypes: PrismaUser['type'][] +} + +@Injectable() +export class UserPermissionPolicy { + constructor(@Inject(Reflector) private readonly reflector: Reflector) {} + + build(context: ExecutionContext): UserPolicyConfig { + const targets = [context.getHandler(), context.getClass()] + return { + adminPermissions: this.reflector.getAllAndOverride<(keyof typeof AdminAuthorized)[] | undefined>(ADMIN_PERMISSIONS_KEY, targets) ?? [], + userTypes: this.reflector.getAllAndOverride(USER_TYPES_KEY, targets) ?? [], + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts new file mode 100644 index 0000000000..dc6b09af03 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts @@ -0,0 +1,17 @@ +import type { UserContext } from '../../auth/auth-user.decorator' +import type { UserPolicyConfig } from './user-policy.service' + +export function makeUserPolicy(overrides: Partial = {}): UserPolicyConfig { + return { + adminPermissions: [], + userTypes: [], + ...overrides, + } +} + +export function makeUserContext(overrides: Partial = {}): UserContext { + return { + userId: 'test-user', + ...overrides, + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts new file mode 100644 index 0000000000..ee14dbbb38 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts @@ -0,0 +1,8 @@ +import type { User } from '@prisma/client' +import { SetMetadata } from '@nestjs/common' + +export const USER_TYPES_KEY = 'user-types' + +export function RequireUserType(...types: User['type'][]) { + return SetMetadata(USER_TYPES_KEY, types) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts new file mode 100644 index 0000000000..447b77477e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts @@ -0,0 +1,105 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserPolicyConfig } from './user-policy.service' +import { ForbiddenException, UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeExecutionContext } from '../../auth/auth-testing.utils' +import { AuthService } from '../../auth/auth.service' +import { UserPermissionPolicy } from './user-policy.service' +import { UserGuard } from './user.guard' +import { UserPermissionService } from './user.service' + +describe('userGuard', () => { + let module: TestingModule + let guard: UserGuard + let authService: DeepMockProxy + let userService: DeepMockProxy + let userPolicy: DeepMockProxy + + beforeEach(async () => { + authService = mockDeep() + userService = mockDeep() + userPolicy = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + UserGuard, + { provide: AuthService, useValue: authService }, + { provide: UserPermissionService, useValue: userService }, + { provide: UserPermissionPolicy, useValue: userPolicy }, + ], + }).compile() + + guard = module.get(UserGuard) + }) + + it('throws 401 when the auth service rejects the request', async () => { + authService.authenticate.mockRejectedValue(new UnauthorizedException()) + userPolicy.build.mockReturnValue({ adminPermissions: [], userTypes: [] }) + const ctx = makeExecutionContext({}) + + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException) + }) + + it('authenticates without loading optional auth data when no policy metadata is set', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1' }) + userPolicy.build.mockReturnValue({ adminPermissions: [], userTypes: [] }) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + const result = await guard.canActivate(ctx) + const request = ctx.switchToHttp().getRequest() + + expect(result).toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(request.userId).toBe('u1') + }) + + it('validates required admin permissions', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 32768n }) + const policy: UserPolicyConfig = { adminPermissions: ['ListSystem'], userTypes: [] } + userPolicy.build.mockReturnValue(policy) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + await expect(guard.canActivate(ctx)).resolves.toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(userService.validate).toHaveBeenCalled() + }) + + it('throws 403 when required admin permissions are missing', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 0n }) + const policy: UserPolicyConfig = { adminPermissions: ['ManageSystem'], userTypes: [] } + userPolicy.build.mockReturnValue(policy) + userService.validate.mockImplementation(() => { throw new ForbiddenException() }) + const ctx = makeExecutionContext({ 'x-dso-token': 'tok' }) + + await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException) + }) + + it('validates required user types', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', userType: 'human' }) + const policy: UserPolicyConfig = { adminPermissions: [], userTypes: ['human'] } + userPolicy.build.mockReturnValue(policy) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + await expect(guard.canActivate(ctx)).resolves.toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(userService.validate).toHaveBeenCalled() + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts new file mode 100644 index 0000000000..28c339accd --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts @@ -0,0 +1,60 @@ +import type { CanActivate, ExecutionContext } from '@nestjs/common' +import type { FastifyRequest } from 'fastify' +import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common' +import { AuthService } from '../../auth/auth.service' +import { UserPermissionPolicy } from './user-policy.service' +import { UserPermissionService } from './user.service' + +type RequestWithAuthContext = FastifyRequest & { + userId?: string + adminPermissions?: bigint + userType?: string +} + +@Injectable() +export class UserGuard implements CanActivate { + private readonly logger = new Logger(UserGuard.name) + + constructor( + @Inject(AuthService) private readonly authService: AuthService, + @Inject(UserPermissionService) private readonly userService: UserPermissionService, + @Inject(UserPermissionPolicy) private readonly userPolicy: UserPermissionPolicy, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const policy = this.userPolicy.build(context) + const request = this.getRequest(context) + const user = await this.authenticate(request) + + this.validate(policy, user) + this.logger.debug(`User auth granted (requestId=${request.id}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, userType=${user.userType})`) + + return true + } + + private getRequest(context: ExecutionContext): RequestWithAuthContext { + return context.switchToHttp().getRequest() + } + + private async authenticate(request: RequestWithAuthContext) { + try { + const user = await this.authService.authenticate(request, { + includeAdminRoleIds: true, + includeUserType: true, + }) + + request.userId = user.userId + if (user.adminPermissions !== undefined) request.adminPermissions = user.adminPermissions + if (user.userType !== undefined) request.userType = user.userType + + return user + } catch (error) { + this.logger.warn(`User auth ${error instanceof UnauthorizedException ? 'rejected' : 'denied'} (requestId=${request.id}, error=${error instanceof Error ? error.message : String(error)})`) + throw error + } + } + + private validate(policy: ReturnType, user: Awaited>) { + this.userService.validate(policy, user) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts new file mode 100644 index 0000000000..613821ed2a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../../auth/auth.module' +import { DatabaseModule } from '../../database/database.module' +import { UserPermissionPolicy } from './user-policy.service' +import { UserGuard } from './user.guard' +import { UserPermissionService } from './user.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + ], + providers: [ + UserGuard, + UserPermissionService, + UserPermissionPolicy, + ], + exports: [ + UserGuard, + UserPermissionService, + UserPermissionPolicy, + ], +}) +export class UserPermissionModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts new file mode 100644 index 0000000000..4ad5b84704 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts @@ -0,0 +1,73 @@ +import type { UserContext } from '../../auth/auth-user.decorator' +import { AdminAuthorized } from '@cpn-console/shared' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeUserContext, makeUserPolicy } from './user-testing.utils' +import { UserPermissionService } from './user.service' + +describe('userService', () => { + let service: UserPermissionService + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [UserPermissionService], + }).compile() + + service = module.get(UserPermissionService) + }) + + describe('validateAdminPermissions', () => { + it('does not throw when no admin permissions are required', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({}), 0n)).not.toThrow() + }) + + it('does not throw when the user has the required admin permission', () => { + const adminPerms = AdminAuthorized.ListSystem(32768n) ? 32768n : 0n + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ListSystem'] }), adminPerms)).not.toThrow() + }) + + it('throws ForbiddenException when the user lacks the required admin permission', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ManageSystem'] }), 0n)).toThrow(ForbiddenException) + }) + + it('validates multiple required admin permissions', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ManageSystem', 'ListSystem'] }), 0n)).toThrow(ForbiddenException) + }) + + it('treats undefined adminPermissions as 0n', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['Manage'] }), undefined)).toThrow(ForbiddenException) + }) + }) + + describe('validateUserType', () => { + it('does not throw when no user types are required', () => { + expect(() => service.validateUserType(makeUserPolicy({}), 'human')).not.toThrow() + }) + + it('does not throw when the user type is in the allowed list', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), 'human')).not.toThrow() + }) + + it('throws ForbiddenException when the user type is not in the allowed list', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), 'service')).toThrow(ForbiddenException) + }) + + it('throws ForbiddenException when userType is undefined', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), undefined)).toThrow(ForbiddenException) + }) + }) + + describe('validate', () => { + it('delegates to validateAdminPermissions and validateUserType', () => { + const spyPerms = vi.spyOn(service, 'validateAdminPermissions') + const spyType = vi.spyOn(service, 'validateUserType') + const user: UserContext = makeUserContext({ adminPermissions: 1n, userType: 'human' }) + + service.validate(makeUserPolicy({}), user) + + expect(spyPerms).toHaveBeenCalled() + expect(spyType).toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts new file mode 100644 index 0000000000..3784e2feb6 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts @@ -0,0 +1,36 @@ +import type { User as PrismaUser } from '@prisma/client' +import type { UserContext } from '../../auth/auth-user.decorator' +import type { UserPolicyConfig } from './user-policy.service' +import { AdminAuthorized } from '@cpn-console/shared' +import { ForbiddenException, Injectable, Logger } from '@nestjs/common' + +@Injectable() +export class UserPermissionService { + private readonly logger = new Logger(UserPermissionService.name) + + validate(policy: UserPolicyConfig, user: UserContext): void { + this.validateAdminPermissions(policy, user.adminPermissions) + this.validateUserType(policy, user.userType) + } + + validateAdminPermissions(policy: UserPolicyConfig, adminPermissions: bigint | undefined): void { + if (!policy.adminPermissions.length) return + + const hasPermission = policy.adminPermissions.every( + permName => AdminAuthorized[permName](adminPermissions ?? 0n), + ) + + if (!hasPermission) { + this.logger.warn(`User auth denied: missing admin permissions (adminPermissions=${adminPermissions?.toString()}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Permissions administrateur insuffisantes') + } + } + + validateUserType(policy: UserPolicyConfig, userType: string | undefined): void { + if (!policy.userTypes.length) return + if (typeof userType !== 'string' || !policy.userTypes.includes(userType as PrismaUser['type'])) { + this.logger.warn(`User auth denied: invalid user type (userType=${userType}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Cannot find requestor in request context') + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts new file mode 100644 index 0000000000..6fd1db8ee6 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts @@ -0,0 +1,100 @@ +import { BadRequestException } from '@nestjs/common' +import { beforeEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { ZodValidationPipe } from './zod-validation.pipe' + +describe('zodValidationPipe', () => { + const schema = z.object({ + name: z.string(), + age: z.number().int().positive(), + }) + + let pipe: ZodValidationPipe + + beforeEach(() => { + pipe = new ZodValidationPipe(schema) + }) + + describe('transform', () => { + it('should return validated data when input is valid', () => { + const validInput = { + name: 'Alice', + age: 30, + } + + const result = pipe.transform(validInput) + + expect(result).toEqual(validInput) + }) + + it('should strip unknown fields if schema does not allow them', () => { + const input = { + name: 'Alice', + age: 30, + extra: 'remove me', + } + + const result = pipe.transform(input) + + expect(result).toEqual({ + name: 'Alice', + age: 30, + }) + }) + + it('should throw BadRequestException when required fields are missing', () => { + const invalidInput = { + name: 'Alice', + } + + expect(() => pipe.transform(invalidInput)).toThrow( + BadRequestException, + ) + }) + + it('should throw BadRequestException when field types are invalid', () => { + const invalidInput = { + name: 'Alice', + age: '30', + } + + expect(() => pipe.transform(invalidInput)).toThrow( + BadRequestException, + ) + }) + + it('should include flattened Zod errors in the exception response', () => { + const invalidInput = { + name: 123, + age: -5, + } + + try { + pipe.transform(invalidInput) + throw new Error('Expected transform to throw') + } catch (error) { + expect(error).toBeInstanceOf(BadRequestException) + + if (error instanceof BadRequestException) { + const response = error.getResponse() as any + + expect(response).toHaveProperty('fieldErrors') + expect(response.fieldErrors).toHaveProperty('name') + expect(response.fieldErrors).toHaveProperty('age') + } + } + }) + + it('should throw when input is null', () => { + expect(() => pipe.transform(null)).toThrow( + BadRequestException, + ) + }) + + it('should throw when input is undefined', () => { + expect(() => pipe.transform(undefined)).toThrow( + BadRequestException, + ) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts new file mode 100644 index 0000000000..066fa255d4 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts @@ -0,0 +1,17 @@ +import type { PipeTransform } from '@nestjs/common' +import type { ZodSchema } from 'zod' +import { BadRequestException } from '@nestjs/common' + +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: ZodSchema) {} + + transform(value: unknown) { + const result = this.schema.safeParse(value) + + if (!result.success) { + throw new BadRequestException(result.error.flatten()) + } + + return result.data + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts new file mode 100644 index 0000000000..3056b5bcb1 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts @@ -0,0 +1,2 @@ +export const TRACER_NAME = '@cpn-console/server-nestjs' +export const SERVICE_NAME = 'console-pi-native-console' diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts new file mode 100644 index 0000000000..950f09d0cc --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts @@ -0,0 +1,74 @@ +import type { Span as OpenTelemetrySpan, SpanOptions } from '@opentelemetry/api' +import { SpanStatusCode, trace } from '@opentelemetry/api' +import { TRACER_NAME } from './telemetry.constants' + +export type TypedMethodDecorator = any>( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, +) => void + +export function StartActiveSpan(options?: SpanOptions): TypedMethodDecorator { + return any>( + _target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ): void => { + const original = descriptor.value + if (!original) return + + descriptor.value = function (this: ThisParameterType, ...args: Parameters): ReturnType { + const tracer = trace.getTracer(TRACER_NAME) + const className = this?.constructor?.name ?? 'Unknown' + const spanName = `${className}.${String(propertyKey)}` + + const runInActiveSpan = (span: OpenTelemetrySpan) => { + try { + const result = original.apply(this, args) + + if (isPromiseLike(result)) { + return handlePromiseResult(span, result) as ReturnType + } + + span.end() + return result + } catch (error) { + recordException(span, error) + span.end() + throw error + } + } + + if (options) { + return tracer.startActiveSpan(spanName, options, runInActiveSpan) as ReturnType + } + return tracer.startActiveSpan(spanName, runInActiveSpan) as ReturnType + } as T + } +} + +function isPromiseLike(value: unknown): value is Promise { + if (!value) return false + return typeof (value as Promise).then === 'function' +} + +async function handlePromiseResult(span: OpenTelemetrySpan, promise: Promise): Promise { + try { + return await promise + } catch (error) { + recordException(span, error) + throw error + } finally { + span.end() + } +} + +function recordException(span: OpenTelemetrySpan, error: unknown): void { + // If it's an actual Error object, OpenTelemetry captures the stack trace automatically + if (error instanceof Error) { + span.recordException(error) + } else { + span.recordException(String(error)) + } + span.setStatus({ code: SpanStatusCode.ERROR }) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts new file mode 100644 index 0000000000..f30d6c6af7 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts @@ -0,0 +1,251 @@ +import type { TestingModule } from '@nestjs/testing' +import type { KeycloakConfig } from './keycloak.module-definition' +import KcAdminClient from '@keycloak/keycloak-admin-client' +import { ScheduleModule } from '@nestjs/schedule' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' +import { ADMIN_TOKEN_REFRESH_INTERVAL_MS } from './keycloak.constants' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +const keycloakUrl = 'https://keycloak.internal' +const projectRealm = 'project-realm' +// The only handled token endpoint is the master realm's: a token request +// against any other realm is unhandled and fails the test, which enforces +// the realm pinning of authenticate() +const tokenUrl = `${keycloakUrl}/realms/master/protocol/openid-connect/token` +const childrenUrl = `${keycloakUrl}/admin/realms/${projectRealm}/groups/:parentId/children` + +const server = setupServer() + +function useTokenEndpoint({ rejectGrant = () => false }: { rejectGrant?: (grantType: string | null) => boolean } = {}) { + const tokenRequests: URLSearchParams[] = [] + let issued = 0 + server.use( + http.post(tokenUrl, async ({ request }) => { + const body = new URLSearchParams(await request.text()) + tokenRequests.push(body) + if (rejectGrant(body.get('grant_type'))) { + return HttpResponse.json({ error: 'invalid_grant' }, { status: 401 }) + } + issued++ + return HttpResponse.json({ access_token: `access-token-${issued}`, refresh_token: `refresh-token-${issued}`, expires_in: 60 }) + }), + ) + return tokenRequests +} + +function createKeycloakClientServiceTestingModule(config: Partial = {}) { + return Test.createTestingModule({ + imports: [ScheduleModule.forRoot()], + providers: [ + KeycloakClientService, + { provide: KEYCLOAK_ADMIN_CLIENT, useValue: new KcAdminClient({ baseUrl: keycloakUrl }) }, + { + provide: KEYCLOAK_CONFIG, + useValue: mockDeep({ + realm: projectRealm, + admin: 'admin', + adminPassword: 'admin-password', + adminClientId: 'admin-cli', + ...config, + }), + }, + ], + }) +} + +describe('keycloakClientService authentication lifecycle', () => { + let module: TestingModule + let client: KcAdminClient + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + vi.useFakeTimers() + module = await createKeycloakClientServiceTestingModule().compile() + client = module.get(KEYCLOAK_ADMIN_CLIENT) + }) + afterEach(async () => { + // close() re-runs init() on a module whose init() already failed; swallow + // that rethrow so the "initial authentication fails" test can clean up + await module.close().catch(() => {}) + server.resetHandlers() + vi.useRealTimers() + }) + afterAll(() => server.close()) + + it('should authenticate with the password grant then switch to the project realm', async () => { + const tokenRequests = useTokenEndpoint() + + await module.init() + + expect(tokenRequests).toHaveLength(1) + expect(Object.fromEntries(tokenRequests[0])).toMatchObject({ + grant_type: 'password', + client_id: 'admin-cli', + username: 'admin', + password: 'admin-password', + }) + expect(client.accessToken).toBe('access-token-1') + expect(client.realmName).toBe(projectRealm) + }) + + it('should refresh the token periodically with the rotated refresh token so it never expires', async () => { + const tokenRequests = useTokenEndpoint() + await module.init() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(2) + expect(Object.fromEntries(tokenRequests[1])).toMatchObject({ + grant_type: 'refresh_token', + client_id: 'admin-cli', + refresh_token: 'refresh-token-1', + }) + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(3) + expect(tokenRequests[2].get('refresh_token')).toBe('refresh-token-2') + expect(client.accessToken).toBe('access-token-3') + expect(client.realmName).toBe(projectRealm) + }) + + it('should fall back to a full re-authentication when the refresh grant fails', async () => { + const tokenRequests = useTokenEndpoint({ rejectGrant: grantType => grantType === 'refresh_token' }) + await module.init() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + + expect(tokenRequests.map(body => body.get('grant_type'))).toEqual(['password', 'refresh_token', 'password']) + expect(client.accessToken).toBe('access-token-2') + expect(client.realmName).toBe(projectRealm) + }) + + it('should keep refreshing and restore the project realm when both grants fail', async () => { + let keycloakIsDown = false + const tokenRequests = useTokenEndpoint({ rejectGrant: () => keycloakIsDown }) + await module.init() + + keycloakIsDown = true + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests.map(body => body.get('grant_type'))).toEqual(['password', 'refresh_token', 'password']) + expect(client.realmName).toBe(projectRealm) + + keycloakIsDown = false + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(4) + expect(tokenRequests[3].get('grant_type')).toBe('refresh_token') + expect(client.accessToken).toBe('access-token-2') + }) + + it('should stop refreshing the token on module destroy', async () => { + const tokenRequests = useTokenEndpoint() + await module.init() + expect(tokenRequests).toHaveLength(1) + + await module.close() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 3) + expect(tokenRequests).toHaveLength(1) + }) + + it('should rethrow when the initial authentication fails', async () => { + const tokenRequests = useTokenEndpoint({ rejectGrant: () => true }) + + await expect(module.init()).rejects.toThrow('invalid_grant') + expect(client.realmName).toBe('master') + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) + expect(tokenRequests).toHaveLength(1) + }) +}) + +describe('getOrCreateSubGroupByName', () => { + let module: TestingModule + let service: KeycloakClientService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + module = await createKeycloakClientServiceTestingModule().compile() + service = module.get(KeycloakClientService) + useTokenEndpoint() + await module.init() + }) + afterEach(async () => { + await module.close() + server.resetHandlers() + }) + afterAll(() => server.close()) + + it('should return the existing subgroup without creating it', async () => { + // No POST handler: a create attempt would fail the test + server.use( + http.get(childrenUrl, ({ params }) => { + expect(params.parentId).toBe('parent-id') + return HttpResponse.json([{ id: 'sub-id', name: 'sub' }]) + }), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toMatchObject({ id: 'sub-id', name: 'sub' }) + }) + + it('should create the subgroup when it does not exist', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([])), + http.post(childrenUrl, async ({ request, params }) => { + expect(params.parentId).toBe('parent-id') + expect(await request.json()).toEqual({ name: 'sub' }) + return new HttpResponse(null, { + status: 201, + headers: { location: `${keycloakUrl}/admin/realms/${projectRealm}/groups/created-id` }, + }) + }), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toEqual({ id: 'created-id', name: 'sub' }) + }) + + it('should re-fetch the subgroup when a concurrent creation causes a 409', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([]), { once: true }), + http.get(childrenUrl, () => HttpResponse.json([{ id: 'concurrent-id', name: 'sub' }])), + http.post(childrenUrl, () => + HttpResponse.json({ errorMessage: 'Sibling group named \'sub\' already exists.' }, { status: 409 })), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toMatchObject({ id: 'concurrent-id', name: 'sub' }) + }) + + it('should rethrow the 409 when the subgroup still cannot be found', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([])), + http.post(childrenUrl, () => + HttpResponse.json({ errorMessage: 'Sibling group named \'sub\' already exists.' }, { status: 409 })), + ) + + await expect(service.getOrCreateSubGroupByName('parent-id', 'sub')).rejects.toThrow('Sibling group named') + }) + + it('should rethrow non-409 errors without re-fetching', async () => { + let listCalls = 0 + server.use( + http.get(childrenUrl, () => { + listCalls++ + return HttpResponse.json([]) + }), + http.post(childrenUrl, () => + HttpResponse.json({ error: 'unauthorized' }, { status: 401 })), + ) + + await expect(service.getOrCreateSubGroupByName('parent-id', 'sub')).rejects.toThrow('unauthorized') + expect(listCalls).toBe(1) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts new file mode 100644 index 0000000000..bb106d64e2 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -0,0 +1,343 @@ +import type KcAdminClient from '@keycloak/keycloak-admin-client' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { Credentials } from '@keycloak/keycloak-admin-client/lib/utils/auth' +import type { OnModuleInit } from '@nestjs/common' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import type { KeycloakConfig } from './keycloak.module-definition' +import type { GroupRepresentationWith } from './keycloak.utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { Interval } from '@nestjs/schedule' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { getErrorResponseStatus } from '../../utils/http.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { ADMIN_AUTH_REALM, ADMIN_TOKEN_REFRESH_INTERVAL_MS, CONSOLE_GROUP_NAME, PASSWORD_GRANT_TYPE, REFRESH_TOKEN_GRANT_TYPE, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +export const KEYCLOAK_ADMIN_CLIENT = Symbol('KEYCLOAK_ADMIN_CLIENT') + +@Injectable() +export class KeycloakClientService implements OnModuleInit { + private readonly logger = new Logger(KeycloakClientService.name) + + private authenticated = false + + constructor( + @Inject(KEYCLOAK_CONFIG) private readonly keycloakConfig: KeycloakConfig, + @Inject(KEYCLOAK_ADMIN_CLIENT) private readonly client: KcAdminClient, + ) { + } + + async* getAllGroups() { + let first = 0 + while (true) { + const fetched = await this.client.groups.find({ first, max: SUBGROUPS_PAGINATE_QUERY_MAX, briefRepresentation: false }) + this.logger.verbose(`Loaded a Keycloak groups page (first=${first}, count=${fetched.length})`) + if (fetched.length === 0) break + for (const group of fetched) { + yield group + } + if (fetched.length < SUBGROUPS_PAGINATE_QUERY_MAX) break + first += SUBGROUPS_PAGINATE_QUERY_MAX + } + } + + @StartActiveSpan() + async getGroupByName(name: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + const groups = await this.client.groups.find({ search: name, briefRepresentation: false }) ?? [] + return groups.find(g => g.name === name) + } + + async getGroupByPath(path: string): Promise { + const parts = path.split('/').filter(Boolean) + this.logger.verbose(`Resolving Keycloak group path ${path} (depth=${parts.length})`) + let current: GroupRepresentationWith<'id'> | undefined + if (parts.length === 0) return undefined + + for (const name of parts) { + current = current + ? await this.getSubGroupByName(current.id, name) + : await this.getRootGroupByName(name) + + if (!current) { + this.logger.verbose(`Keycloak group path segment was not found (path=${path}, missing=${name})`) + return undefined + } + } + this.logger.verbose(`Keycloak group path resolved (path=${path}, groupId=${current?.id})`) + return current + } + + private async getSubGroupByName(parentId: string, name: string): Promise | undefined> { + for await (const subgroup of this.getSubGroups(parentId)) { + if (subgroup.name === name) { + const parsed = z.object({ id: z.string() }).and(z.record(z.string(), z.unknown())).safeParse(subgroup) + return parsed.success ? parsed.data : undefined + } + } + return undefined + } + + private async getRootGroupByName(name: string): Promise | undefined> { + const candidates = await this.client.groups.find({ search: name, briefRepresentation: false }) ?? [] + const match = candidates.find(g => g.path === `/${name}`) ?? candidates.find(g => g.name === name) + const parsed = z.object({ id: z.string() }).and(z.record(z.string(), z.unknown())).safeParse(match) + return parsed.success ? parsed.data : undefined + } + + async deleteGroup(id: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', id) + this.logger.log(`Deleting Keycloak group (groupId=${id})`) + await this.client.groups.del({ id }) + } + + async getGroupMembers(groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + const members = await this.client.groups.listMembers({ id: groupId }) + this.logger.verbose(`Loaded Keycloak group members (groupId=${groupId}, count=${members?.length ?? 0})`) + return members || [] + } + + async getUserByEmail(email: string): Promise { + const users = await this.client.users.find({ + email, + exact: true, + max: 1, + }) + return users[0] + } + + @StartActiveSpan() + async createGroup(name: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + this.logger.debug(`Creating Keycloak group ${name}`) + const result = await this.client.groups.create({ name }) + return { ...result, name } as GroupRepresentation + } + + async addUserToGroup(userId: string, groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + this.logger.verbose(`Adding user to Keycloak group (userId=${userId}, groupId=${groupId})`) + return this.client.users.addToGroup({ id: userId, groupId }) + } + + async removeUserFromGroup(userId: string, groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + this.logger.verbose(`Removing user from Keycloak group (userId=${userId}, groupId=${groupId})`) + return this.client.users.delFromGroup({ id: userId, groupId }) + } + + async* getSubGroups(parentId: string) { + let first = 0 + while (true) { + const page = await this.client.groups.listSubGroups({ + parentId, + briefRepresentation: false, + max: SUBGROUPS_PAGINATE_QUERY_MAX, + first, + }) + this.logger.verbose(`Loaded a Keycloak subgroups page (parentId=${parentId}, first=${first}, count=${page.length})`) + if (page.length === 0) break + for (const subgroup of page) { + yield subgroup + } + if (page.length < SUBGROUPS_PAGINATE_QUERY_MAX) break + first += SUBGROUPS_PAGINATE_QUERY_MAX + } + } + + async getOrCreateGroupByPath(path: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.path.depth', path.split('/').filter(Boolean).length) + this.logger.verbose(`Ensuring Keycloak group path exists: ${path}`) + const existingGroup = await this.getGroupByPath(path) + if (existingGroup) { + this.logger.verbose(`Keycloak group already exists at path ${path}`) + return existingGroup + } + + const parts = path.split('/').filter(Boolean) + let parentId: string | undefined + let current: GroupRepresentationWith<'id' | 'name'> | undefined + + for (const name of parts.values()) { + if (current) { + if (!parentId) parentId = current.id + const next = z.object({ id: z.string(), name: z.string() }).safeParse(await this.getOrCreateSubGroupByName(parentId, name)) + if (next.success) current = next.data + } else { + const next = z.object({ id: z.string(), name: z.string() }).safeParse(await this.getGroupByName(name) ?? await this.createGroup(name)) + if (next.success) current = next.data + } + parentId = current?.id + } + + if (current) { + this.logger.log(`Created Keycloak group path ${path} (groupId=${current.id})`) + } + return { ...current, path } as GroupRepresentation + } + + @StartActiveSpan() + async getOrCreateSubGroupByName(parentId: string, name: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + span?.setAttribute('parent.id', parentId) + const existing = await this.findSubGroupByName(parentId, name) + if (existing) return existing + + this.logger.debug(`Creating Keycloak subgroup ${name} under parentId=${parentId}`) + try { + const createdGroup = await this.client.groups.createChildGroup({ id: parentId }, { name }) + return { id: createdGroup.id, name } satisfies GroupRepresentation + } catch (err) { + // A concurrent reconciliation may have created the subgroup between the + // scan and the create; treat the 409 as "already exists" and re-fetch it + if (getErrorResponseStatus(err) !== 409) throw err + this.logger.verbose(`Keycloak subgroup ${name} was created concurrently under parentId=${parentId}, fetching it`) + const subgroup = await this.findSubGroupByName(parentId, name) + if (!subgroup) throw err + return subgroup + } + } + + private async findSubGroupByName(parentId: string, name: string): Promise { + for await (const subgroup of this.getSubGroups(parentId)) { + if (subgroup.name === name) { + return subgroup + } + } + return undefined + } + + async getOrCreateConsoleGroup(projectGroup: GroupRepresentationWith<'id'>) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', projectGroup.id) + this.logger.verbose(`Ensuring Keycloak console group exists (projectGroupId=${projectGroup.id})`) + return this.getOrCreateSubGroupByName(projectGroup.id, CONSOLE_GROUP_NAME) + } + + async getOrCreateRoleGroup( + consoleGroup: GroupRepresentationWith<'id' | 'name'>, + oidcGroup: string, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.group.id': consoleGroup.id, + 'role.oidc_group.present': !!oidcGroup, + 'role.oidc_group.depth': oidcGroup.split('/').filter(Boolean).length, + }) + const parts = oidcGroup.split('/').filter(Boolean) + if (parts.length === 0) { + throw new Error(`Invalid oidcGroup for project role: "${oidcGroup}"`) + } + + let current = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(consoleGroup.id, parts[0])) + + for (const name of parts.slice(1)) { + current = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(current.id, name)) + } + + return { ...current, path: `/${consoleGroup.name}/${parts.join('/')}` } satisfies GroupRepresentation + } + + async getOrCreateEnvironmentGroups(consoleGroup: GroupRepresentationWith<'id'>, environment: ProjectWithDetails['environments'][number]) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.group.id': consoleGroup.id, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const envGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(consoleGroup.id, environment.name)) + const [roGroup, rwGroup] = await Promise.all([ + this.getOrCreateSubGroupByName(envGroup.id, 'RO'), + this.getOrCreateSubGroupByName(envGroup.id, 'RW'), + ]) + this.logger.verbose(`Resolved Keycloak environment groups (consoleGroupId=${consoleGroup.id}, env=${environment.name}, envGroupId=${envGroup.id})`) + return { roGroup, rwGroup } + } + + async onModuleInit() { + try { + this.logger.log(`Authenticating Keycloak admin client (realm=${this.keycloakConfig.realm})`) + await this.authenticate(this.passwordCredentials()) + } catch (err) { + if (err instanceof Error) { + this.logger.error(`Keycloak Admin Client authentication failed: ${err.message}`, err.stack) + } else { + this.logger.error(`Keycloak Admin Client authentication failed: ${String(err)}`) + } + throw err + } + this.client.setConfig({ realmName: this.keycloakConfig.realm }) + this.authenticated = true + this.logger.log(`Keycloak Admin Client authenticated (realm=${this.keycloakConfig.realm})`) + } + + // The admin client never refreshes its token on its own; without this the + // access token expires (~60s) and every admin call fails with a 401 + @Interval(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + async refreshAdminToken() { + if (!this.authenticated) return + try { + await this.authenticate(this.refreshTokenCredentials()) + } catch (refreshErr) { + // The refresh token itself can expire or be revoked (e.g. Keycloak + // restart); fall back to a full re-authentication to recover + this.logger.warn(`Keycloak Admin Client token refresh failed, re-authenticating: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`) + try { + await this.authenticate(this.passwordCredentials()) + } catch (err) { + this.logger.error(`Keycloak Admin Client re-authentication failed: ${err instanceof Error ? err.message : String(err)}`) + } + } + } + + private passwordCredentials(): Credentials { + return { + clientId: this.keycloakConfig.adminClientId, + grantType: PASSWORD_GRANT_TYPE, + username: this.keycloakConfig.admin, + password: this.keycloakConfig.adminPassword, + } + } + + private refreshTokenCredentials(): Credentials { + return { + clientId: this.keycloakConfig.adminClientId, + grantType: REFRESH_TOKEN_GRANT_TYPE, + refreshToken: this.client.refreshToken, + } + } + + // auth() resolves the token endpoint from client.realmName, which onModuleInit + // switches to the project realm — the admin user lives in the master realm. + // Restore the realm even when auth fails: the still-valid previous token keeps + // serving admin calls, and those must target the project realm, not master + private async authenticate(credentials: Credentials) { + const realmName = this.client.realmName + this.client.setConfig({ realmName: ADMIN_AUTH_REALM }) + try { + await this.client.auth(credentials) + } finally { + this.client.setConfig({ realmName }) + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts new file mode 100644 index 0000000000..0c0a028836 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts @@ -0,0 +1,88 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + slug: true, + ownerId: true, + everyonePerms: true, + members: { + select: { + roleIds: true, + user: { + select: { + id: true, + email: true, + }, + }, + }, + }, + roles: { + select: { + id: true, + permissions: true, + oidcGroup: true, + type: true, + }, + }, + environments: { + select: { + id: true, + name: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export const adminRoleSelect = { + id: true, + oidcGroup: true, + type: true, +} satisfies Prisma.AdminRoleSelect + +export type AdminRoleWithDetails = Prisma.AdminRoleGetPayload<{ + select: typeof adminRoleSelect +}> + +export const userAdminRoleSelect = { + id: true, + adminRoleIds: true, +} satisfies Prisma.UserSelect + +export type UserWithAdminRoles = Prisma.UserGetPayload<{ + select: typeof userAdminRoleSelect +}> + +@Injectable() +export class KeycloakDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getAllAdminRoles(): Promise { + return this.prisma.adminRole.findMany({ + select: adminRoleSelect, + }) + } + + async getAllUsersWithAdminRoleIds(): Promise { + return this.prisma.user.findMany({ + select: userAdminRoleSelect, + }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts new file mode 100644 index 0000000000..6d78945601 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts @@ -0,0 +1,25 @@ +import type { KeycloakConfig } from './keycloak.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +@Injectable() +export class KeycloakHealthService { + constructor( + @Inject(KEYCLOAK_CONFIG) + private readonly keycloakConfig: KeycloakConfig, + @Inject(HealthIndicatorService) + private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts new file mode 100644 index 0000000000..a0b5e2dfac --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts @@ -0,0 +1,16 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Injectable } from '@nestjs/common' + +@Injectable() +export class KeycloakPluginService { + infos(): ServiceInfos { + return { + name: 'keycloak', + title: 'Keycloak', + config: { + global: [], + project: [], + }, + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts new file mode 100644 index 0000000000..17fb5c5f0d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts @@ -0,0 +1,87 @@ +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { ProjectWithDetails } from './keycloak-datastore.service' + +import { faker } from '@faker-js/faker' + +export function makeUserRepresentation( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + username: faker.internet.username(), + enabled: true, + ...overrides, + } satisfies UserRepresentation +} + +export function makeGroupRepresentation( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + path: `/${faker.word.noun()}`, + subGroups: [], + ...overrides, + } satisfies GroupRepresentation +} + +export function makeProjectUser( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + ...overrides, + } satisfies ProjectWithDetails['members'][number]['user'] +} + +export function makeProjectMember( + overrides: Partial = {}, +) { + return { + roleIds: [], + user: makeProjectUser(), + ...overrides, + } satisfies ProjectWithDetails['members'][number] +} + +export function makeProjectRole( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + permissions: 0n, + oidcGroup: '', + type: 'managed', + ...overrides, + } satisfies ProjectWithDetails['roles'][number] +} + +export function makeProjectEnvironment( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + ...overrides, + } satisfies ProjectWithDetails['environments'][number] +} + +export function makeProjectWithDetails( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(faker.word.words({ count: 2 })).toLowerCase(), + ownerId: faker.string.uuid(), + everyonePerms: 0n, + plugins: [], + members: [], + roles: [], + environments: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts new file mode 100644 index 0000000000..e964d9e74c --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts @@ -0,0 +1,20 @@ +// Keycloak plugin constants +export const PLUGIN_NAME = 'keycloak' + +// Name for the console group for admin and project roles +export const CONSOLE_GROUP_NAME = 'console' + +// Realm hosting the admin user used by the admin client (password grant on admin-cli) +export const ADMIN_AUTH_REALM = 'master' + +// OAuth2 grant types (RFC 6749) used to authenticate the admin client +export const PASSWORD_GRANT_TYPE = 'password' +export const REFRESH_TOKEN_GRANT_TYPE = 'refresh_token' + +// Keycloak's default access token lifespan is 60s; refresh well before it +// expires so a slow or delayed tick still lands within the lifespan +export const ADMIN_TOKEN_REFRESH_INTERVAL_MS = 45_000 + +// Maximum number of entities returned in a paginated query +export const GROUPS_PAGINATE_QUERY_MAX = 20 +export const SUBGROUPS_PAGINATE_QUERY_MAX = 20 diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts new file mode 100644 index 0000000000..8245a556b9 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts @@ -0,0 +1,29 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface KeycloakConfig { + protocol: string + domain?: string + publicProtocol: string + publicDomain?: string + realm?: string + clientId?: string + clientSecret?: string + admin?: string + adminPassword?: string + adminClientId: string + redirectUri?: string + jwksCacheTtlMs: number + jwksTimeoutMs: number + openidConfigurationCacheTtlMs: number + adminKcUserId: string[] + url?: string + realmUrl?: string + openidConfigurationUrl?: string +} + +export interface KeycloakModuleOptions extends KeycloakConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('keycloak') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as KEYCLOAK_CONFIG } diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts new file mode 100644 index 0000000000..7186de782a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts @@ -0,0 +1,31 @@ +import type { KeycloakConfig } from './keycloak.module-definition' +import KcAdminClient from '@keycloak/keycloak-admin-client' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { KeycloakHealthService } from './keycloak-health.service' +import { KeycloakPluginService } from './keycloak-plugin.service' +import { ConfigurableModuleClass, KEYCLOAK_CONFIG } from './keycloak.module-definition' +import { KeycloakService } from './keycloak.service' + +@Module({ + imports: [DatabaseModule, TerminusModule], + providers: [ + { + inject: [KEYCLOAK_CONFIG], + provide: KEYCLOAK_ADMIN_CLIENT, + useFactory: (config: KeycloakConfig) => new KcAdminClient({ + baseUrl: config.url, + }), + }, + KeycloakClientService, + KeycloakDatastoreService, + KeycloakHealthService, + KeycloakPluginService, + KeycloakService, + ], + exports: [KeycloakClientService, KeycloakHealthService, KeycloakPluginService, KeycloakService], +}) +export class KeycloakModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts new file mode 100644 index 0000000000..e5b116688a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -0,0 +1,450 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { AdminRoleWithDetails, ProjectWithDetails, UserWithAdminRoles } from './keycloak-datastore.service' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { + makeGroupRepresentation, + makeProjectEnvironment, + makeProjectMember, + makeProjectRole, + makeProjectUser, + makeProjectWithDetails, + makeUserRepresentation, +} from './keycloak-testing.utils' +import { KeycloakService } from './keycloak.service' + +describe('keycloakService', () => { + let service: KeycloakService + let keycloak: DeepMockProxy + let datastore: DeepMockProxy + + beforeEach(async () => { + keycloak = mockDeep({ + getOrCreateConsoleGroup: vi.fn().mockResolvedValue(makeGroupRepresentation({ id: 'console-group-id', name: 'console' })), + getOrCreateEnvironmentGroups: vi.fn().mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'rw-id', name: 'RW' }), + }), + getAllGroups: vi.fn().mockImplementation(async function* () { /* empty by default */ }), + deleteGroup: vi.fn().mockResolvedValue(undefined), + }) + datastore = mockDeep({ + getAllAdminRoles: vi.fn().mockResolvedValue([]), + getAllUsersWithAdminRoleIds: vi.fn().mockResolvedValue([]), + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + KeycloakService, + { provide: KeycloakClientService, useValue: keycloak }, + { provide: KeycloakDatastoreService, useValue: datastore }, + ], + }).compile() + + service = moduleRef.get(KeycloakService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('reconcile', () => { + const mockProject: ProjectWithDetails = makeProjectWithDetails({ + id: 'project-id', + slug: 'test-project', + ownerId: 'owner-id', + everyonePerms: 0n, + }) + + it('should not sync system external admin roles', async () => { + const adminRoles: AdminRoleWithDetails[] = [ + { id: 'admin-role-id', oidcGroup: '/console/system-external', type: 'system:external' }, + ] + const users: UserWithAdminRoles[] = [ + { id: 'user-1', adminRoleIds: ['admin-role-id'] }, + ] + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + + await service.handleCron() + + expect(keycloak.getOrCreateGroupByPath).not.toHaveBeenCalledWith('/console/system-external') + expect(keycloak.getGroupMembers).not.toHaveBeenCalled() + expect(keycloak.addUserToGroup).not.toHaveBeenCalled() + expect(keycloak.removeUserFromGroup).not.toHaveBeenCalled() + }) + + it('should sync admin role groups', async () => { + const adminRoles: AdminRoleWithDetails[] = [ + { id: 'admin-role-id', oidcGroup: '/console/admin', type: 'managed' }, + ] + const users: UserWithAdminRoles[] = [ + { id: 'user-1', adminRoleIds: ['admin-role-id'] }, + { id: 'user-2', adminRoleIds: [] }, + ] + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + + const adminGroup = makeGroupRepresentation({ id: 'kc-group-id', name: 'admin', path: '/console/admin' }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(adminGroup) + + keycloak.getGroupMembers.mockResolvedValue([ + makeUserRepresentation({ id: 'user-2' }), + ]) + + await service.handleCron() + + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/console/admin') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'kc-group-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'kc-group-id') + }) + + it('should purge orphans', async () => { + datastore.getAllProjects.mockResolvedValue([mockProject]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project', subGroups: [] }) + const orphanGroup = makeGroupRepresentation({ + id: 'orphan-id', + name: 'orphan-project', + subGroups: [makeGroupRepresentation({ name: 'console' })], + }) + + keycloak.getAllGroups.mockImplementation(async function* () { + yield projectGroup + yield orphanGroup + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(keycloak.getAllGroups).toHaveBeenCalled() + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/test-project') + expect(keycloak.deleteGroup).toHaveBeenCalledWith('orphan-id') + }) + + it('should sync project members', async () => { + const projectWithMembers = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: [], + }), + ], + }) + datastore.getAllProjects.mockResolvedValue([projectWithMembers]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + + // Current members: user-2 (extra), missing user-1 + keycloak.getGroupMembers.mockResolvedValue([ + makeUserRepresentation({ id: 'user-2', email: 'user2@example.com' }), + ]) + + keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should add missing member + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'group-id') + // Should add owner (missing in group members) + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('owner-id', 'group-id') + // Should remove extra member + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'group-id') + }) + + it('should sync OIDC role groups', async () => { + const roleWithOidc = makeProjectRole({ + id: 'role-oidc', + permissions: 0n, + oidcGroup: '/oidc-group', + type: 'managed', + }) + const projectWithRole = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-oidc'], + }), + ], + roles: [roleWithOidc], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRole]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const roleGroup = makeGroupRepresentation({ id: 'role-group-id', name: 'oidc-group', path: '/console/oidc-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockResolvedValue(roleGroup) + + // Project members: owner + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + // Role group members: user-2 (extra), missing user-1 + if (groupId === 'role-group-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2', email: 'user2@example.com' })]) + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should create/get role group + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/oidc-group') + // Should add user-1 to role group + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'role-group-id') + // Should remove user-2 from role group + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'role-group-id') + }) + + it('should sync environment groups', async () => { + const projectWithEnv = makeProjectWithDetails({ + ...mockProject, + environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], + }) + datastore.getAllProjects.mockResolvedValue([projectWithEnv]) + + const projectGroup = makeGroupRepresentation({ + id: 'group-id', + name: 'test-project', + subGroups: [makeGroupRepresentation({ name: 'console', id: 'console-id' })], + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + + // Mock console group retrieval + keycloak.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' }), + }) + keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + if (name === 'dev') return Promise.resolve(makeGroupRepresentation({ id: 'dev-id', name: 'dev' })) + if (name === 'RO') return Promise.resolve(makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' })) + if (name === 'RW') return Promise.resolve(makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' })) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name })) + }) + + // Mock existing environments: 'staging' (extra) + keycloak.getSubGroups.mockImplementation(async function* (parentId) { + if (parentId === 'console-id') { + yield makeGroupRepresentation({ id: 'staging-id', name: 'staging' }) + } + if (parentId === 'staging-id') { + yield makeGroupRepresentation({ name: 'RO' }) + yield makeGroupRepresentation({ name: 'RW' }) + } + }) + + await service.handleCron() + + // Should create dev group + expect(keycloak.getOrCreateConsoleGroup).toHaveBeenCalledWith({ id: 'group-id', name: 'test-project' }) + // Should create RO/RW groups + expect(keycloak.getOrCreateEnvironmentGroups).toHaveBeenCalledWith({ id: 'console-id', name: 'console' }, projectWithEnv.environments[0]) + // Should delete staging group + expect(keycloak.deleteGroup).toHaveBeenCalledWith('staging-id') + }) + + it('should sync environment permissions', async () => { + const userRo = makeUserRepresentation({ id: 'user-ro', email: 'ro@example.com' }) + const userRw = makeUserRepresentation({ id: 'user-rw', email: 'rw@example.com' }) + const userNone = makeUserRepresentation({ id: 'user-none', email: 'none@example.com' }) + + const projectWithEnvAndMembers = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: userRo.id, email: userRo.email }), + roleIds: ['role-ro'], + }), + makeProjectMember({ + user: makeProjectUser({ id: userRw.id, email: userRw.email }), + roleIds: ['role-rw'], + }), + makeProjectMember({ + user: makeProjectUser({ id: userNone.id, email: userNone.email }), + roleIds: [], + }), + ], + roles: [ + makeProjectRole({ id: 'role-ro', permissions: 256n, oidcGroup: '', type: 'managed' }), + makeProjectRole({ id: 'role-rw', permissions: 8n, oidcGroup: '', type: 'managed' }), + ], + environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], + }) + datastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) + + const projectGroup = makeGroupRepresentation({ + id: 'group-id', + name: 'test-project', + subGroups: [makeGroupRepresentation({ name: 'console', id: 'console-id' })], + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' }), + }) + + // Project group members (assume all are in project group for simplicity) + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([userRo, userRw, userNone]) + // RO group has userNone (extra), missing userRo + if (groupId === 'dev-ro-id') return Promise.resolve([userNone]) + // RW group has userNone (extra), missing userRw + if (groupId === 'dev-rw-id') return Promise.resolve([userNone]) + return Promise.resolve([]) + }) + + keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + if (name === 'dev') return Promise.resolve(makeGroupRepresentation({ id: 'dev-id', name: 'dev' })) + if (name === 'RO') return Promise.resolve(makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' })) + if (name === 'RW') return Promise.resolve(makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' })) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name })) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Sync RO + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-ro', 'dev-ro-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-ro-id') + // Sync RW + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-rw', 'dev-rw-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-rw-id') + }) + + it('should handle different role types (managed, external, global)', async () => { + const roleManaged = makeProjectRole({ id: 'role-managed', permissions: 0n, oidcGroup: '/managed-group', type: 'managed' }) + const roleExternal = makeProjectRole({ id: 'role-external', permissions: 0n, oidcGroup: '/external-group', type: 'external' }) + const roleGlobal = makeProjectRole({ id: 'role-global', permissions: 0n, oidcGroup: '/global-group', type: 'global' }) + + const projectWithRoles = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-managed', 'role-external', 'role-global'], + }), + ], + roles: [roleManaged, roleExternal, roleGlobal], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const managedGroup = makeGroupRepresentation({ id: 'managed-id', name: 'managed-group' }) + const externalGroup = makeGroupRepresentation({ id: 'external-id', name: 'external-group' }) + const globalGroup = makeGroupRepresentation({ id: 'global-id', name: 'global-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockImplementation((_consoleGroup, oidcGroup) => { + if (oidcGroup === '/managed-group') return Promise.resolve({ ...managedGroup, path: '/console/managed-group' }) + if (oidcGroup === '/external-group') return Promise.resolve({ ...externalGroup, path: '/console/external-group' }) + if (oidcGroup === '/global-group') return Promise.resolve({ ...globalGroup, path: '/console/global-group' }) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name: oidcGroup, path: `/console/${oidcGroup}` })) + }) + + // Group members + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + + // Managed: has extra user-2, missing user-1 + if (groupId === 'managed-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + // External: has extra user-2, missing user-1 + if (groupId === 'external-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + // Global: create group if it doesn't exist but no members + if (groupId === 'global-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Managed: should add user-1, remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/managed-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'managed-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'managed-id') + + // External: should add user-1, NOT remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/external-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'external-id') + expect(keycloak.removeUserFromGroup).not.toHaveBeenCalledWith('user-2', 'external-id') + + // Global: should sync group but no members + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/global-group') + }) + + it('should treat system-prefixed role types as their base type', async () => { + const roleSystemManaged = makeProjectRole({ id: 'role-system-managed', permissions: 0n, oidcGroup: '/system-managed-group', type: 'system:managed' }) + + const projectWithRoles = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-system-managed'], + }), + ], + roles: [roleSystemManaged], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const systemManagedGroup = makeGroupRepresentation({ id: 'system-managed-id', name: 'system-managed-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockResolvedValue({ ...systemManagedGroup, path: '/console/system-managed-group' }) + + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + + // has extra user-2, missing user-1 + if (groupId === 'system-managed-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // system:managed behaves like managed: add user-1, remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/system-managed-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'system-managed-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'system-managed-id') + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts new file mode 100644 index 0000000000..f737e77bcd --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -0,0 +1,670 @@ +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { AdminRoleWithDetails, ProjectWithDetails, UserWithAdminRoles } from './keycloak-datastore.service' +import type { GroupRepresentationWith } from './keycloak.utils' +import { getBaseRoleType, getPermsByUserRoles, isExternalRoleType, ProjectAuthorized, resourceListToDict } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { getErrorResponseStatus } from '../../utils/http.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { isMember, isNonEmptyGroupPath, isOwnedProjectGroup, normalizeGroupPath } from './keycloak.utils' + +@Injectable() +export class KeycloakService { + private readonly logger = new Logger(KeycloakService.name) + + constructor( + @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, + @Inject(KeycloakDatastoreService) private readonly datastore: KeycloakDatastoreService, + ) { + this.logger.log('KeycloakService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('keycloak', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroups([project]) + this.logger.log(`Keycloak sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('keycloak', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.purgeOrphanGroups([project]) + this.logger.log(`Keycloak cleanup completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting periodic Keycloak reconciliation') + const [projects, adminRoles, users] = await Promise.all([ + this.datastore.getAllProjects(), + this.datastore.getAllAdminRoles(), + this.datastore.getAllUsersWithAdminRoleIds(), + ]) + span?.setAttributes({ + 'keycloak.projects.count': projects.length, + 'keycloak.admin_roles.count': adminRoles.length, + 'keycloak.users.count': users.length, + }) + this.logger.debug(`Reconciling Keycloak state (projects=${projects.length}, adminRoles=${adminRoles.length}, users=${users.length})`) + await Promise.all([ + this.ensureAdminRoleGroups(adminRoles, users), + this.ensureProjectGroups(projects), + ]) + await this.purgeOrphanGroups(projects) + this.logger.log(`Keycloak reconciliation completed (projects=${projects.length}, adminRoles=${adminRoles.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.projects.count', projects.length) + this.logger.verbose(`Reconciling Keycloak project groups (${projects.length})`) + await Promise.all(projects.map(project => this.ensureProjectGroup(project))) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'project.members.count': project.members.length, + 'project.roles.count': project.roles.length, + 'project.environments.count': project.environments.length, + }) + this.logger.verbose(`Reconciling Keycloak project group (${project.slug}): members=${project.members.length} roles=${project.roles.length}`) + + const projectGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateGroupByPath(`/${project.slug}`)) + + span?.setAttribute('keycloak.project_group.id', projectGroup.id) + + await Promise.all([ + this.ensureProjectGroupMembers(project, projectGroup), + this.ensureConsoleGroup(project, projectGroup), + ]) + this.logger.verbose(`Keycloak project group reconciled (${project.slug}): groupId=${projectGroup.id}`) + } + + @StartActiveSpan() + private async ensureConsoleGroup(project: ProjectWithDetails, group: GroupRepresentationWith<'id'>) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.console_group.id', group.id) + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateConsoleGroup(group)) + this.logger.verbose(`Reconciling Keycloak console group (${project.slug}): projectGroupId=${group.id} consoleGroupId=${consoleGroup.id}`) + await Promise.all([ + this.ensureRoleGroups(project, consoleGroup), + this.ensureEnvironmentGroups(project, consoleGroup), + this.purgeOrphanEnvironmentGroups(project, consoleGroup), + ]) + } + + @StartActiveSpan() + private async ensureAdminRoleGroups( + adminRoles: AdminRoleWithDetails[], + users: UserWithAdminRoles[], + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.admin_roles.count': adminRoles.length, + 'keycloak.users.count': users.length, + }) + const rolesWithOidcGroup = adminRoles.filter(r => isNonEmptyGroupPath(r.oidcGroup) && !isExternalRoleType(r.type)) + span?.setAttribute('keycloak.admin_roles.oidc_group.count', rolesWithOidcGroup.length) + await Promise.all(rolesWithOidcGroup.map(role => this.ensureAdminRoleGroup(role, users))) + } + + @StartActiveSpan() + private async ensureAdminRoleGroup( + role: AdminRoleWithDetails, + users: UserWithAdminRoles[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('admin_role.id', role.id) + span?.setAttribute('admin_role.oidc_group.present', isNonEmptyGroupPath(role.oidcGroup)) + const roleGroupPath = normalizeGroupPath(role.oidcGroup) + if (!roleGroupPath) return + + span?.setAttribute('keycloak.group.path', roleGroupPath) + const roleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateGroupByPath(roleGroupPath)) + span?.setAttribute('keycloak.group.id', roleGroup.id) + + const groupMembers = await this.keycloak.getGroupMembers(roleGroup.id) + + const desiredUserIds = new Set( + users + .filter(u => u.adminRoleIds.includes(role.id)) + .map(u => u.id), + ) + span?.setAttribute('keycloak.group.members.desired', desiredUserIds.size) + + let addedCount = 0 + let removedCount = 0 + + await Promise.all([ + ...Array.from(desiredUserIds, async (userId) => { + if (!groupMembers.some(m => m.id === userId)) { + addedCount++ + await this.maybeAddUserToGroup(userId, roleGroup.id, roleGroup.name) + } + }), + ...groupMembers.map(async (member) => { + if (member.id && !desiredUserIds.has(member.id)) { + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, roleGroup.id, roleGroup.name) + } + }), + ]) + + span?.setAttribute('keycloak.group.members.added', addedCount) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + @StartActiveSpan() + private async purgeOrphanGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + const groups = map(this.keycloak.getAllGroups(), async (group) => { + return z.object({ + id: z.string(), + name: z.string(), + subGroups: z.array(z.object({ name: z.string() })), + }).parse(group) + }) + const projectSlugs = new Set(projects.map(p => p.slug)) + this.logger.verbose(`Scanning Keycloak groups for orphan cleanup (projects=${projects.length})`) + const promises: Promise[] = [] + let purgedCount = 0 + + for await (const group of groups) { + if (!projectSlugs.has(group.name)) { + if (isOwnedProjectGroup(group)) { + this.logger.log(`Deleting an orphan Keycloak group (groupId=${group.id}, groupName=${group.name})`) + purgedCount++ + promises.push(this.keycloak.deleteGroup(group.id)) + } + } + } + span?.setAttribute('purged.count', purgedCount) + await Promise.all(promises) + this.logger.log(`Orphan Keycloak group cleanup completed (purged=${purgedCount})`) + } + + private async maybeAddUserToGroup(userId: string, groupId: string, groupName: string) { + try { + await this.keycloak.addUserToGroup(userId, groupId) + this.logger.log(`Added user to Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } catch (e) { + const status = getErrorResponseStatus(e) + if (status === 404) { + this.logger.warn(`User not found in Keycloak, skipping addition: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else if (status === 409) { + this.logger.verbose(`User already a member of Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else { + throw e + } + } + } + + private async maybeRemoveUserFromGroup(userId: string, groupId: string, groupName: string) { + try { + await this.keycloak.removeUserFromGroup(userId, groupId) + this.logger.log(`Removed user from Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } catch (e) { + if (getErrorResponseStatus(e) === 404) { + this.logger.warn(`User not found in Keycloak, skipping removal: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else { + throw e + } + } + } + + @StartActiveSpan() + private async ensureProjectGroupMembers( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('keycloak.group.id', group.id) + + const groupMembers = await this.keycloak.getGroupMembers(group.id) + const desiredUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + + span?.setAttribute('keycloak.group.members.current', groupMembers.length) + span?.setAttribute('keycloak.group.members.desired', desiredUserIds.size) + + let addedCount = 0 + let removedCount = 0 + + await Promise.all([ + ...Array.from(desiredUserIds, async (userId) => { + if (!groupMembers.some(m => m.id === userId)) { + addedCount++ + await this.maybeAddUserToGroup(userId, group.id, group.name) + } + }), + ...groupMembers.map(async (member) => { + if (member.id && !desiredUserIds.has(member.id)) { + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, group.id, group.name) + } + }), + ]) + + span?.setAttribute('keycloak.group.members.added', addedCount) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + @StartActiveSpan() + private async ensureRoleGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'keycloak.group.id': group.id, + 'project.roles.count': project.roles.length, + }) + + const rolesWithOidcGroup = project.roles.filter(r => isNonEmptyGroupPath(r.oidcGroup)) + span?.setAttribute('project.roles.oidc_group.count', rolesWithOidcGroup.length) + + await Promise.all(rolesWithOidcGroup.map(role => this.ensureRoleGroup(project, role, group))) + } + + @StartActiveSpan() + private async ensureRoleGroup( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('role.oidc_group.present', isNonEmptyGroupPath(role.oidcGroup)) + if (!isNonEmptyGroupPath(role.oidcGroup)) return + + span?.setAttribute('role.oidc_group.depth', role.oidcGroup.split('/').filter(Boolean).length) + + const roleGroup = await this.keycloak.getOrCreateRoleGroup(group, role.oidcGroup) + span?.setAttribute('keycloak.group.id', roleGroup.id) + span?.setAttribute('keycloak.group.path', roleGroup.path) + + const groupMembers = await this.keycloak.getGroupMembers(roleGroup.id) + span?.setAttribute('keycloak.group.members.current', groupMembers.length) + + switch (getBaseRoleType(role.type)) { + case 'managed': + await Promise.all([ + this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers), + this.purgeOrphanRoleGroupMembers(project, role, roleGroup, groupMembers), + ]) + break + case 'external': + await this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers) + break + case 'global': + await this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers) + break + default: + throw new Error(`Unknown role type ${role.type}`) + } + } + + @StartActiveSpan() + private async ensureRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.members.current', members.length) + + const desiredMemberIds = project.members + .filter(m => m.roleIds.includes(role.id)) + .map(m => m.user.id) + + span?.setAttribute('keycloak.group.members.desired', desiredMemberIds.length) + + let addedCount = 0 + await Promise.all(project.members.map(async (member) => { + if (!members.some(m => m.id === member.user.id) && member.roleIds.includes(role.id)) { + addedCount++ + await this.maybeAddUserToGroup(member.user.id, group.id, group.name) + } + })) + + span?.setAttribute('keycloak.group.members.added', addedCount) + } + + @StartActiveSpan() + private async purgeOrphanRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.members.current', members.length) + + let removedCount = 0 + await Promise.all(members.map(async (member) => { + if (!isMember(project, member)) { + if (!member.id) { + throw new Error(`Failed to create or retrieve role group for ${role.oidcGroup}`) + } + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, group.id, group.name) + } + })) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + private async ensureEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id'>, + ) { + await Promise.all(project.environments.map(environment => + this.ensureEnvironmentGroup(project, environment, group))) + } + + @StartActiveSpan() + private async ensureEnvironmentGroup( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + group: GroupRepresentationWith<'id'>, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + 'project.roles.count': project.roles.length, + }) + + const { roGroup, rwGroup } = z.object({ + roGroup: z.object({ + id: z.string(), + name: z.string(), + }), + rwGroup: z.object({ + id: z.string(), + name: z.string(), + }), + }).parse(await this.keycloak.getOrCreateEnvironmentGroups(group, environment)) + + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + + const rolesById = resourceListToDict(project.roles) + + const [roMembers, rwMembers] = await Promise.all([ + this.keycloak.getGroupMembers(roGroup.id), + this.keycloak.getGroupMembers(rwGroup.id), + ]) + + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + + await Promise.all([ + this.ensureEnvironmentGroupMembers( + project, + environment, + rolesById, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + this.purgeOrphanEnvironmentGroupMembers( + project, + environment, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + ]) + } + + @StartActiveSpan() + private async ensureEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + rolesById: Record, + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('environment.id', environment.id) + span?.setAttribute('environment.name', environment.name) + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + this.logger.verbose(`Reconciling Keycloak environment group members: project=${project.slug} env=${environment.name} roMembers=${roMembers.length} rwMembers=${rwMembers.length}`) + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + span?.setAttribute('project.users.count', projectUserIds.size) + + let roAdded = 0 + let roRemoved = 0 + let rwAdded = 0 + let rwRemoved = 0 + + await Promise.all(Array.from(projectUserIds, async (userId) => { + const perms = this.getUserPermissions(project, rolesById, userId) + + const isInRo = roMembers.some(m => m.id === userId) + if (perms.ro && !isInRo) { + roAdded++ + await this.maybeAddUserToGroup(userId, roGroup.id, `RO group for ${environment.name}`) + } else if (!perms.ro && isInRo) { + roRemoved++ + await this.maybeRemoveUserFromGroup(userId, roGroup.id, `RO group for ${environment.name}`) + } + + const isInRw = rwMembers.some(m => m.id === userId) + if (perms.rw && !isInRw) { + rwAdded++ + await this.maybeAddUserToGroup(userId, rwGroup.id, `RW group for ${environment.name}`) + } else if (!perms.rw && isInRw) { + rwRemoved++ + await this.maybeRemoveUserFromGroup(userId, rwGroup.id, `RW group for ${environment.name}`) + } + })) + + span?.setAttribute('keycloak.env_group.ro.members.added', roAdded) + span?.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) + span?.setAttribute('keycloak.env_group.rw.members.added', rwAdded) + span?.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) + this.logger.verbose(`Keycloak environment group members reconciled: project=${project.slug} env=${environment.name} roAdded=${roAdded} roRemoved=${roRemoved} rwAdded=${rwAdded} rwRemoved=${rwRemoved}`) + } + + @StartActiveSpan() + private async purgeOrphanEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('environment.id', environment.id) + span?.setAttribute('environment.name', environment.name) + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + this.logger.verbose(`Purging orphan Keycloak environment group members: project=${project.slug} env=${environment.name} roMembers=${roMembers.length} rwMembers=${rwMembers.length}`) + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + span?.setAttribute('project.users.count', projectUserIds.size) + + let roRemoved = 0 + let rwRemoved = 0 + + await Promise.all([ + ...roMembers.map(async (member) => { + if (!member.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + if (!projectUserIds.has(member.id)) { + roRemoved++ + await this.maybeRemoveUserFromGroup(member.id, roGroup.id, `RO group for ${environment.name}`) + } + }), + ...rwMembers.map(async (member) => { + if (!member.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + if (!projectUserIds.has(member.id)) { + rwRemoved++ + await this.maybeRemoveUserFromGroup(member.id, rwGroup.id, `RW group for ${environment.name}`) + } + }), + ]) + + span?.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) + span?.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) + this.logger.log(`Orphan Keycloak environment group member cleanup completed: project=${project.slug} env=${environment.name} roRemoved=${roRemoved} rwRemoved=${rwRemoved}`) + } + + @StartActiveSpan() + private async purgeOrphanEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.name', group.name) + this.logger.verbose(`Scanning Keycloak environment groups for orphan cleanup: project=${project.slug} groupId=${group.id} groupName=${group.name}`) + + const envGroups = map(this.keycloak.getSubGroups(group.id), envGroup => z.object({ + id: z.string(), + name: z.string(), + }).parse(envGroup)) + + const promises: Promise[] = [] + let orphanCount = 0 + + for await (const envGroup of envGroups) { + span?.setAttribute('keycloak.env_group.id', envGroup.id) + span?.setAttribute('keycloak.env_group.name', envGroup.name) + + const subGroups = await getAll(map(this.keycloak.getSubGroups(envGroup.id), subgroup => z.object({ + name: z.string(), + }).parse(subgroup))) + + if (this.isEnvironmentGroup(subGroups) && !this.isOwnedEnvironmentGroup(project, envGroup)) { + orphanCount++ + this.logger.log(`Deleting orphan Keycloak environment group: project=${project.slug} envGroupId=${envGroup.id} envGroupName=${envGroup.name}`) + promises.push( + this.keycloak.deleteGroup(envGroup.id) + .catch(err => this.logger.warn(`Failed to delete orphan Keycloak environment group: project=${project.slug} envGroupId=${envGroup.id} envGroupName=${envGroup.name} err=${err instanceof Error ? err.message : String(err)}`)), + ) + } + } + + span?.setAttribute('keycloak.env_groups.orphan.count', orphanCount) + await Promise.all(promises) + this.logger.log(`Orphan Keycloak environment group cleanup completed: project=${project.slug} groupId=${group.id} orphanCount=${orphanCount}`) + } + + private isEnvironmentGroup( + subGroups: GroupRepresentationWith<'name'>[], + ) { + return subGroups.some(subgroup => subgroup.name === 'RO' || subgroup.name === 'RW') + } + + private isOwnedEnvironmentGroup( + project: ProjectWithDetails, + group: GroupRepresentationWith<'name'>, + ) { + return project.environments.some(e => e.name === group.name) + } + + private getUserPermissions( + project: ProjectWithDetails, + rolesById: Record, + userId: string, + ) { + if (userId === project.ownerId) return { ro: true, rw: true } + const member = project.members.find(m => m.user.id === userId) + if (!member) return { ro: false, rw: false } + + const projectPermissions = getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) + + return { + ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions }), + rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions }), + } + } +} + +async function* map( + iterable: AsyncIterable, + mapper: (value: T, index: number) => U | Promise, +): AsyncIterable { + let index = 0 + for await (const value of iterable) { + yield await mapper(value, index++) + } +} + +async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts new file mode 100644 index 0000000000..ac56609793 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts @@ -0,0 +1,25 @@ +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import { CONSOLE_GROUP_NAME } from './keycloak.constants' + +type With = T & Required> +export type GroupRepresentationWith = With + +export function isMember(project: ProjectWithDetails, member: UserRepresentation): boolean { + return project.members.some(m => m.user.id === member.id) || project.ownerId === member.id +} + +export function isNonEmptyGroupPath(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +export function normalizeGroupPath(value: unknown): string | undefined { + if (!isNonEmptyGroupPath(value)) return undefined + const trimmed = value.trim() + return trimmed.startsWith('/') ? trimmed : `/${trimmed}` +} + +export function isOwnedProjectGroup(group: GroupRepresentationWith<'subGroups'>): boolean { + return !!group.subGroups.some(sg => sg.name === CONSOLE_GROUP_NAME) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log-queries.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log-queries.utils.ts new file mode 100644 index 0000000000..7c8e6de59f --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log-queries.utils.ts @@ -0,0 +1,30 @@ +import type { Prisma } from '@prisma/client' + +const logSelect = { + id: true, + createdAt: true, + updatedAt: true, + action: true, + userId: true, + requestId: true, + projectId: true, + data: true, +} satisfies Prisma.LogSelect + +export type LogSelect = Prisma.LogGetPayload<{ + select: typeof logSelect +}> + +export function countLogs(tx: Prisma.TransactionClient, projectId: string | null | undefined) { + return tx.log.count({ where: { projectId } }) +} + +export function listLogs(tx: Prisma.TransactionClient, projectId: string | null | undefined, offset: number, limit: number) { + return tx.log.findMany({ + orderBy: { createdAt: 'desc' }, + skip: offset, + take: limit, + where: { projectId }, + select: logSelect, + }) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log-testing.utils.ts new file mode 100644 index 0000000000..1e90dffb17 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log-testing.utils.ts @@ -0,0 +1,16 @@ +import type { Log } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeLog(overrides: Partial = {}): Log { + return { + id: faker.string.uuid(), + data: {}, + action: faker.word.verb(), + userId: null, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + requestId: faker.string.uuid(), + projectId: faker.string.uuid(), + ...overrides, + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.controller.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.controller.spec.ts new file mode 100644 index 0000000000..fa9dfd0118 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.controller.spec.ts @@ -0,0 +1,79 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { ADMIN_PERMS } from '@cpn-console/shared' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectPermissionLoaderService } from '../infrastructure/permission/project/project-loader.service' +import { makeProjectContext } from '../infrastructure/permission/project/project.testing.utils' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { LogController } from './log.controller' +import { LogService } from './log.service' + +describe('logController', () => { + let module: TestingModule + let controller: LogController + let logs: DeepMockProxy + let projectLoader: DeepMockProxy + + beforeEach(async () => { + logs = mockDeep() + projectLoader = mockDeep() + + module = await Test.createTestingModule({ + controllers: [LogController], + providers: [ + { provide: LogService, useValue: logs }, + { provide: ProjectPermissionLoaderService, useValue: projectLoader }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(LogController) + }) + + it('returns logs for system admins without project lookup', async () => { + logs.getLogs.mockResolvedValueOnce({ total: 1, logs: [] }) + + const response = await controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: undefined }, + { userId: 'user-1', adminPermissions: ADMIN_PERMS.LIST_SYSTEM, userType: 'human' }, + ) + + expect(projectLoader.load).not.toHaveBeenCalled() + expect(logs.getLogs).toHaveBeenCalledWith({ limit: 10, offset: 0, clean: false, projectId: undefined }) + expect(response).toEqual({ total: 1, logs: [] }) + }) + + it('forces clean logs for project-scoped non-admin access', async () => { + projectLoader.load.mockResolvedValueOnce(makeProjectContext({ projectPermissions: 1n })) + logs.getLogs.mockResolvedValueOnce({ total: 1, logs: [] }) + + await controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: 'project-1' }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + ) + + expect(projectLoader.load).toHaveBeenCalledTimes(1) + expect(logs.getLogs).toHaveBeenCalledWith({ limit: 10, offset: 0, clean: true, projectId: 'project-1' }) + }) + + it('rejects non-admin access without projectId', async () => { + await expect(controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: undefined }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + )).rejects.toThrow(ForbiddenException) + }) + + it('rejects non-admin access when user has no project permissions', async () => { + projectLoader.load.mockResolvedValueOnce(makeProjectContext({ projectPermissions: 0n })) + + await expect(controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: 'project-1' }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + )).rejects.toThrow(ForbiddenException) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.controller.ts new file mode 100644 index 0000000000..951ee4f472 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.controller.ts @@ -0,0 +1,47 @@ +import type { AdminLogsQuery } from '@cpn-console/shared' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { AdminAuthorized, logContract } from '@cpn-console/shared' +import { Controller, ForbiddenException, Get, Inject, Query, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { ProjectPermissionLoaderService } from '../infrastructure/permission/project/project-loader.service' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { LogService } from './log.service' + +@Controller('api/v1/logs') +@UseGuards(UserGuard) +export class LogController { + constructor( + @Inject(LogService) private readonly logs: LogService, + @Inject(ProjectPermissionLoaderService) private readonly projectLoader: ProjectPermissionLoaderService, + ) {} + + @Get('') + async getLogs( + @Query(new ZodValidationPipe(logContract.getLogs.query)) query: AdminLogsQuery, + @AuthUser() user: UserContext, + ) { + const isSystemAdmin = AdminAuthorized.ListSystem(user.adminPermissions ?? 0n) + let effectiveQuery = query + + if (!isSystemAdmin) { + if (!query.projectId) { + throw new ForbiddenException() + } + + const project = await this.projectLoader.load( + { params: { projectId: query.projectId } } as Parameters[0], + user.userId, + { includePermissions: true, includeLocked: false, includeStatus: false }, + ) + + if (!project.projectPermissions) { + throw new ForbiddenException() + } + + effectiveQuery = { ...query, clean: true } + } + + return this.logs.getLogs(effectiveQuery) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.module.ts new file mode 100644 index 0000000000..f80194cb54 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { LogController } from './log.controller' +import { LogService } from './log.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, UserPermissionModule], + controllers: [LogController], + providers: [LogService], + exports: [LogService], +}) +export class LogModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.service.spec.ts new file mode 100644 index 0000000000..608bafbdbd --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.service.spec.ts @@ -0,0 +1,67 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeLog } from './log-testing.utils' +import { LogService } from './log.service' + +describe('logService', () => { + let module: TestingModule + let service: LogService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + LogService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(LogService) + }) + + it('should map clean logs', async () => { + const dbLogs = [makeLog({ + data: { args: {} }, + action: 'Action', + })] + + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + + const { logs } = await service.getLogs({ + limit: 10, + offset: 10, + clean: true, + projectId: undefined, + }) + + expect(logs[0]).not.toHaveProperty('requestId') + expect(logs[0].data).not.toHaveProperty('results') + expect(logs[0].data).not.toHaveProperty('args') + expect(logs[0].data).not.toHaveProperty('config') + }) + + it('should not filter admin logs', async () => { + const dbLogs = [makeLog({ + data: { args: {} }, + action: 'Action', + })] + + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + + const { logs } = await service.getLogs({ + limit: 10, + offset: 10, + clean: false, + projectId: undefined, + }) + + expect(logs[0].data).toHaveProperty('args') + expect(logs[0].data).not.toHaveProperty('config') + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.service.ts new file mode 100644 index 0000000000..8aa403a41c --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/log/log.service.ts @@ -0,0 +1,74 @@ +import type { AdminLogsQuery } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import { CleanLogSchema, exclude } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { countLogs, listLogs } from './log-queries.utils' + +/** + * Shape of the `data` JSON column, aligned with the LogSchema contract in + * `@cpn-console/shared` so the console can parse and display every entry. + */ +export interface LogData { + /** Payload the action/event was invoked with (e.g. the full project). */ + args?: unknown + /** Names of the services that reported a KO result. */ + failed?: boolean | string[] + /** Per-service outcome, keyed by service name. */ + results?: Record + totalExecutionTime?: number + messageResume?: string + /** Extra keys are persisted as-is, except the sensitive ones stripped by addLog. */ + [key: string]: unknown +} + +interface AddLogArgs { + action: string + data: LogData + userId?: string | null + requestId?: string | null + projectId?: string | null +} + +@Injectable() +export class LogService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + ) {} + + async getLogs(params: AdminLogsQuery) { + const [total, logs] = await this.getLogsWithCount(params.projectId, params.offset, params.limit) + + return { + total, + logs: params.clean + ? logs.map(log => CleanLogSchema.parse(log)) + : logs, + } + } + + async getLogsWithCount(projectId: string | null | undefined, offset: number, limit: number) { + return this.prisma.$transaction([ + countLogs(this.prisma, projectId), + listLogs(this.prisma, projectId, offset, limit), + ]) + } + + async addLog({ + action, + data, + userId = null, + requestId = null, + projectId = null, + }: AddLogArgs) { + return this.prisma.log.create({ + data: { + action, + userId, + requestId, + projectId, + data: exclude(data, ['cluster', 'user', 'newCreds', 'apis', 'config']) as Prisma.InputJsonValue, + }, + }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts new file mode 100644 index 0000000000..5ac2c471b3 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts @@ -0,0 +1,82 @@ +import type { ConfigType } from '@nestjs/config' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { nexusConfigFactory } from '../../config/nexus.config' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { NexusClientService } from './nexus-client.service' +import { NexusHttpClientService } from './nexus-http-client.service' +import { NEXUS_CONFIG } from './nexus.module-definition' + +const nexusUrl = 'https://nexus.internal' + +const server = setupServer() +const nexusAdminPassword = faker.internet.password() +const basicAuth = `Basic ${Buffer.from(`admin:${nexusAdminPassword}`, 'utf8').toString('base64')}` + +describe('nexusClientService', () => { + let service: NexusClientService + let config: DeepMockProxy> + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + beforeEach(async () => { + config = mockDeep>({ + internalUrl: nexusUrl, + admin: 'admin', + adminPassword: nexusAdminPassword, + internalOrPublicUrl: nexusUrl, + }) + + const module = await Test.createTestingModule({ + providers: [ + NexusClientService, + NexusHttpClientService, + { + provide: NEXUS_CONFIG, + useValue: config, + }, + ], + }).compile() + + service = module.get(NexusClientService) + }) + + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should return null on 404 (getRepositoriesMavenHosted)', async () => { + server.use( + http.get(`${nexusUrl}/service/rest/v1/repositories/maven/hosted/:name`, ({ request }) => { + expect(request.headers.get('authorization')).toBe(basicAuth) + return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.getRepositoriesMavenHosted('missing')).resolves.toBeNull() + }) + + it('should send basic auth and plain text body on change-password', async () => { + server.use( + http.put(`${nexusUrl}/service/rest/v1/security/users/:userId/change-password`, async ({ request, params }) => { + expect(request.method).toBe('PUT') + expect(request.url).toBe(`${nexusUrl}/service/rest/v1/security/users/u1/change-password`) + expect(params.userId).toBe('u1') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('content-type')).toContain('text/plain') + expect(await request.text()).toBe('pw123') + return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) + }), + ) + + await service.updateSecurityUsersChangePassword('u1', 'pw123') + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts new file mode 100644 index 0000000000..85f9e81d62 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts @@ -0,0 +1,297 @@ +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { NexusError, NexusHttpClientService } from './nexus-http-client.service' + +interface NexusRepositoryStorage { + blobStoreName: string + strictContentTypeValidation: boolean + writePolicy?: string +} + +interface NexusRepositoryCleanup { + policyNames: string[] +} + +interface NexusRepositoryComponent { + proprietaryComponents: boolean +} + +interface NexusRepositoryGroup { + memberNames: string[] +} + +export interface NexusMavenHostedRepository { + name: string + online: boolean + storage: NexusRepositoryStorage & { writePolicy: string } + cleanup?: NexusRepositoryCleanup + component: NexusRepositoryComponent + maven: { + versionPolicy: string + layoutPolicy: string + contentDisposition: string + } +} + +interface NexusMavenHostedRepositoryUpsertRequest extends NexusMavenHostedRepository {} + +export interface NexusMavenGroupRepository { + name: string + online: boolean + storage: Omit + group: NexusRepositoryGroup +} + +interface NexusMavenGroupRepositoryUpsertRequest extends NexusMavenGroupRepository {} + +export interface NexusNpmHostedRepository { + name: string + online: boolean + storage: NexusRepositoryStorage & { writePolicy: string } + cleanup?: NexusRepositoryCleanup + component: NexusRepositoryComponent +} + +interface NexusNpmHostedRepositoryUpsertRequest extends NexusNpmHostedRepository {} + +export interface NexusNpmGroupRepository { + name: string + online: boolean + storage: Omit + group: NexusRepositoryGroup +} + +interface NexusNpmGroupRepositoryUpsertRequest extends NexusNpmGroupRepository {} + +interface NexusRepositoryViewPrivilege { + name: string + description: string + actions: string[] + format: string + repository: string +} + +interface NexusRepositoryViewPrivilegeUpsertRequest extends NexusRepositoryViewPrivilege {} + +interface NexusRole { + id: string + name: string + privileges: string[] + source?: string + roles?: string[] + description?: string +} + +interface NexusRoleCreateRequest extends NexusRole { + description: string +} + +interface NexusRoleUpdateRequest extends NexusRole {} + +interface NexusUserCreateRequest { + userId: string + firstName: string + lastName: string + emailAddress: string + password: string + status: string + roles: string[] +} + +export interface NexusPrivilege extends NexusRepositoryViewPrivilege { + type: string +} + +@Injectable() +export class NexusClientService { + constructor( + @Inject(NexusHttpClientService) private readonly http: NexusHttpClientService, + ) {} + + @StartActiveSpan() + async getRepositoriesMavenHosted(name: string) { + try { + const res = await this.http.fetch(`repositories/maven/hosted/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createRepositoriesMavenHosted(body: NexusMavenHostedRepositoryUpsertRequest) { + await this.http.fetch('repositories/maven/hosted', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesMavenHosted(name: string, body: NexusMavenHostedRepositoryUpsertRequest) { + await this.http.fetch(`repositories/maven/hosted/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async createRepositoriesMavenGroup(body: NexusMavenGroupRepositoryUpsertRequest) { + await this.http.fetch('repositories/maven/group', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesMavenGroup(name: string, body: NexusMavenGroupRepositoryUpsertRequest) { + await this.http.fetch(`repositories/maven/group/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getRepositoriesMavenGroup(name: string) { + try { + const res = await this.http.fetch(`repositories/maven/group/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async getRepositoriesNpmHosted(name: string) { + try { + const res = await this.http.fetch(`repositories/npm/hosted/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createRepositoriesNpmHosted(body: NexusNpmHostedRepositoryUpsertRequest) { + await this.http.fetch('repositories/npm/hosted', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesNpmHosted(name: string, body: NexusNpmHostedRepositoryUpsertRequest) { + await this.http.fetch(`repositories/npm/hosted/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getRepositoriesNpmGroup(name: string): Promise { + try { + const res = await this.http.fetch(`repositories/npm/group/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async postRepositoriesNpmGroup(body: NexusNpmGroupRepositoryUpsertRequest) { + await this.http.fetch('repositories/npm/group', { method: 'POST', body }) + } + + @StartActiveSpan() + async putRepositoriesNpmGroup(name: string, body: NexusNpmGroupRepositoryUpsertRequest) { + await this.http.fetch(`repositories/npm/group/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getSecurityPrivileges(name: string): Promise { + try { + const res = await this.http.fetch(`security/privileges/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createSecurityPrivilegesRepositoryView(body: NexusRepositoryViewPrivilegeUpsertRequest) { + await this.http.fetch('security/privileges/repository-view', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateSecurityPrivilegesRepositoryView(name: string, body: NexusRepositoryViewPrivilegeUpsertRequest) { + await this.http.fetch(`security/privileges/repository-view/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async deleteSecurityPrivileges(name: string) { + try { + await this.http.fetch(`security/privileges/${name}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async getSecurityRoles(id: string): Promise { + try { + const res = await this.http.fetch(`security/roles/${id}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createSecurityRoles(body: NexusRoleCreateRequest) { + await this.http.fetch('security/roles', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateSecurityRoles(id: string, body: NexusRoleUpdateRequest) { + await this.http.fetch(`security/roles/${id}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async deleteSecurityRoles(id: string) { + try { + await this.http.fetch(`security/roles/${id}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async getSecurityUsers(userId: string): Promise<{ userId: string }[]> { + const query = new URLSearchParams({ userId }).toString() + const res = await this.http.fetch<{ userId: string }[]>(`security/users?${query}`) + return res.data ?? [] + } + + @StartActiveSpan() + async updateSecurityUsersChangePassword(userId: string, password: string) { + await this.http.fetch(`security/users/${userId}/change-password`, { + method: 'PUT', + body: password, + headers: { 'Content-Type': 'text/plain' }, + }) + } + + @StartActiveSpan() + async createSecurityUsers(body: NexusUserCreateRequest) { + await this.http.fetch('security/users', { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSecurityUsers(userId: string) { + try { + await this.http.fetch(`security/users/${userId}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async deleteRepositoriesByName(name: string) { + try { + await this.http.fetch(`repositories/${name}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts new file mode 100644 index 0000000000..ddbb28b966 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts @@ -0,0 +1,66 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './nexus.constants' + +export const projectSelect = { + slug: true, + owner: { + select: { + email: true, + firstName: true, + lastName: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class NexusDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts new file mode 100644 index 0000000000..5914403606 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts @@ -0,0 +1,27 @@ +import type { NexusConfig } from './nexus.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { NEXUS_CONFIG } from './nexus.module-definition' + +@Injectable() +export class NexusHealthService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const credentials = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` + const encoded = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${encoded}` + try { + const response = await fetch(this.nexusConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts new file mode 100644 index 0000000000..8575a4cd18 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts @@ -0,0 +1,129 @@ +import type { NexusConfig } from './nexus.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { NEXUS_CONFIG } from './nexus.module-definition' + +export interface NexusFetchOptions { + method?: string + body?: unknown + headers?: Record +} + +export interface NexusResponse { + status: HttpStatus + data: T | null +} + +export type NexusErrorKind + = | 'NotConfigured' + | 'HttpError' + | 'Unexpected' + +export class NexusError extends Error { + readonly kind: NexusErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + + constructor( + kind: NexusErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string } = {}, + ) { + super(message) + this.name = 'NexusError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + } +} + +@Injectable() +export class NexusHttpClientService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + ) {} + + @StartActiveSpan() + async fetch(path: string, options: NexusFetchOptions = {}): Promise> { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('nexus.method', method) + span?.setAttribute('nexus.path', path) + + const request = this.createRequest(path, method, options.body, options.headers) + const response = await fetch(request).catch((error) => { + throw new NexusError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('nexus.http.status', response.status) + const result = await handleResponse(response) + if (!response.ok) { + throw new NexusError('HttpError', `Request failed: ${method} ${path} responded ${result.status} ${response.statusText}`, { + status: result.status, + method, + path, + statusText: response.statusText, + }) + } + return result + } + + private get baseUrl() { + const url = this.nexusConfig.internalOrPublicUrl + if (!url) { + throw new NexusError('NotConfigured', 'NEXUS_INTERNAL_URL or NEXUS_URL is required') + } + return url + } + + private get apiBaseUrl() { + return new URL('service/rest/v1/', this.baseUrl).toString() + } + + private get basicAuth() { + if (!this.nexusConfig.admin) { + throw new NexusError('NotConfigured', 'NEXUS_ADMIN is required') + } + if (!this.nexusConfig.adminPassword) { + throw new NexusError('NotConfigured', 'NEXUS_ADMIN_PASSWORD is required') + } + const raw = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` + return Buffer.from(raw, 'utf8').toString('base64') + } + + private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record): Request { + const url = new URL(path, this.apiBaseUrl).toString() + const headers: Record = { + Authorization: `Basic ${this.basicAuth}`, + ...extraHeaders, + } + let requestBody: string | undefined + if (body !== undefined) { + if (typeof body === 'string') { + requestBody = body + headers['Content-Type'] = 'text/plain' + } else { + requestBody = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + } + return new Request(url, { method, headers, body: requestBody }) + } +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts new file mode 100644 index 0000000000..eab6e89046 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts @@ -0,0 +1,37 @@ +import type { ConfigType } from '@nestjs/config' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { nexusConfigFactory } from '../../config/nexus.config' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { NexusPluginService } from './nexus-plugin.service' +import { NEXUS_CONFIG } from './nexus.module-definition' + +describe('nexusPluginService', () => { + let service: NexusPluginService + let config: DeepMockProxy> + + beforeEach(async () => { + config = mockDeep>({ + url: 'https://nexus.public/', + internalUrl: 'https://nexus.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + NexusPluginService, + { provide: NEXUS_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(NexusPluginService) + }) + + it('returns the public Nexus url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://nexus.public/') + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts new file mode 100644 index 0000000000..3682cba90e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts @@ -0,0 +1,176 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { NexusConfig } from './nexus.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { NEXUS_CONFIG } from './nexus.module-definition' + +@Injectable() +export class NexusPluginService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'nexus', + to: () => this.nexusConfig.url, + title: 'Nexus', + imgSrc: '/img/nexus.png', + description: 'Nexus permet de gérer les binaires et artefacts de build à travers la chaîne logistique logicielle', + config: { + project: [ + { + key: 'activateNpmRepo', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt NPM', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'npmWritePolicy', + section: 'NPM', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture', + value: 'allow', + description: 'Politique d\'écriture des dépôts NPM, valeurs possibles: allow / allow_once, deny, replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'activateMavenRepo', + section: 'Maven', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt Maven', + value: DISABLED, + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'mavenSnapshotWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Snapshot', + value: 'allow', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'mavenReleaseWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Release', + value: 'allow_once', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + ], + global: [ + { + key: 'platformWriteGroupPaths', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemins des groupes OIDC plateforme en écriture', + value: '/console/admin', + description: 'Liste séparée par des virgules des chemins des groupes OIDC ayant accès en écriture aux dépôts Nexus de la plateforme', + placeholder: '/console/admin', + }, + { + key: 'platformReadGroupPaths', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemins des groupes OIDC plateforme en lecture', + value: '/console/readonly,/console/security', + description: 'Liste séparée par des virgules des chemins des groupes OIDC ayant accès en lecture aux dépôts Nexus de la plateforme', + placeholder: '/console/readonly,/console/security', + }, + { + key: 'projectWriteGroupPathSuffixes', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixes des groupes OIDC projet en écriture', + value: '/console/admin,/console/devops', + description: 'Liste séparée par des virgules des suffixes des chemins des groupes OIDC ayant accès en écriture aux dépôts Nexus du projet', + placeholder: '/console/admin,/console/devops', + }, + { + key: 'projectReadGroupPathSuffixes', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixes des groupes OIDC projet en lecture', + value: '/console/readonly,/console/security,/console/developer', + description: 'Liste séparée par des virgules des suffixes des chemins des groupes OIDC ayant accès en lecture aux dépôts Nexus du projet', + placeholder: '/console/readonly,/console/security,/console/developer', + }, + { + key: 'activateNpmRepoDefaultValue', + section: 'NPM', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Créer un dépôt NPM privé (comportement par défaut des projets)', + value: DISABLED, + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'activateMavenRepoDefaultValue', + section: 'Maven', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Créer un dépôt MAVEN privé (comportement par défaut des projets)', + value: DISABLED, + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'enablePlugin', + kind: 'switch', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Activer/Désactiver entièrement le plugin Nexus', + value: ENABLED, + description: 'Défaut: Activé', + }, + ], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts new file mode 100644 index 0000000000..4cdc734919 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts @@ -0,0 +1,15 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + slug: faker.internet.domainWord(), + owner: { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + }, + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.constants.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.constants.ts new file mode 100644 index 0000000000..151b2e56b1 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.constants.ts @@ -0,0 +1,32 @@ +// Name of the Nexus plugin used throughout the application +export const PLUGIN_NAME = 'nexus' + +// Configuration keys for enabling Maven and NPM repositories +export const NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO = 'activateMavenRepo' +export const NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO = 'activateNpmRepo' + +// Configuration keys for Maven snapshot, release, and NPM write policies +export const NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY = 'mavenSnapshotWritePolicy' +export const NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY = 'mavenReleaseWritePolicy' +export const NEXUS_CONFIG_KEY_NPM_WRITE_POLICY = 'npmWritePolicy' + +// Default write policy values for Maven snapshots, releases, and NPM packages +export const DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY = 'allow' +export const DEFAULT_MAVEN_RELEASE_WRITE_POLICY = 'allow_once' +export const DEFAULT_NPM_WRITE_POLICY = 'allow' + +// Default group paths granting write and read access at the platform level +export const DEFAULT_PLATFORM_WRITE_GROUP_PATHS = '/console/admin' +export const DEFAULT_PLATFORM_READ_GROUP_PATHS = '/console/readonly,/console/security' + +// Default group path suffixes granting write and read access at the project level +export const DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES = '/console/admin,/console/devops' +export const DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES = '/console/readonly,/console/security,/console/developer' + +// Plugin configuration keys for platform-level group paths +export const PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY = 'platformWriteGroupPaths' +export const PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY = 'platformReadGroupPaths' + +// Plugin configuration keys for project-level group path suffixes +export const PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectWriteGroupPathSuffixes' +export const PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectReadGroupPathSuffixes' diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts new file mode 100644 index 0000000000..998c0b3b7a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts @@ -0,0 +1,18 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface NexusConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + secretExposeInternalUrl: boolean + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface NexusModuleOptions extends NexusConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('nexus') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as NEXUS_CONFIG } diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.module.ts new file mode 100644 index 0000000000..25f0e992ce --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from '../../config/base.config' +import { nexusConfigFactory } from '../../config/nexus.config' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { NexusHealthService } from './nexus-health.service' +import { NexusHttpClientService } from './nexus-http-client.service' +import { NexusPluginService } from './nexus-plugin.service' +import { ConfigurableModuleClass } from './nexus.module-definition' +import { NexusService } from './nexus.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule, ConfigModule.forFeature(nexusConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + providers: [ + NexusHealthService, + NexusPluginService, + NexusService, + NexusDatastoreService, + NexusHttpClientService, + NexusClientService, + ], + exports: [NexusClientService, NexusHealthService, NexusPluginService, NexusService], +}) +export class NexusModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts new file mode 100644 index 0000000000..9963b5e4c5 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -0,0 +1,202 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { NexusConfig } from './nexus.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { makeProjectWithDetails } from './nexus-testing.utils' +import { + NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, + NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, + PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, + PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, +} from './nexus.constants' +import { NEXUS_CONFIG } from './nexus.module-definition' +import { NexusService } from './nexus.service' + +describe('nexusService', () => { + let service: NexusService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let config: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + getRepositoriesMavenHosted: vi.fn().mockResolvedValue(null), + getRepositoriesMavenGroup: vi.fn().mockResolvedValue(null), + getRepositoriesNpmHosted: vi.fn().mockResolvedValue(null), + getRepositoriesNpmGroup: vi.fn().mockResolvedValue(null), + getSecurityPrivileges: vi.fn().mockResolvedValue(null), + getSecurityRoles: vi.fn().mockResolvedValue(null), + getSecurityUsers: vi.fn().mockResolvedValue([]), + }) + datastore = mockDeep({ + getAllProjects: vi.fn().mockResolvedValue([]), + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + read: vi.fn().mockRejectedValue(new VaultError('NotFound', 'Not Found')), + }) + config = mockDeep({}) + baseConfig = mockDeep({ projectsRootDir: 'forge' }) + vaultConfig = mockDeep({}) + + const module = await Test.createTestingModule({ + providers: [ + NexusService, + { provide: NexusClientService, useValue: client }, + { provide: NexusDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: NEXUS_CONFIG, useValue: config }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + ], + }).compile() + + service = module.get(NexusService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('handleUpsert should reconcile based on computed flags', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: DISABLED }, + ], + }) + + await service.handleUpsert(project) + + expect(client.createRepositoriesMavenHosted).toHaveBeenCalled() + expect(client.deleteRepositoriesByName).toHaveBeenCalled() + expect(vault.write).toHaveBeenCalledWith( + expect.objectContaining({ + NEXUS_USERNAME: project.slug, + NEXUS_PASSWORD: expect.any(String), + }), + `forge/${project.slug}/tech/NEXUS`, + ) + }) + + it('handleDelete should delete project', async () => { + const project = makeProjectWithDetails() + await service.handleDelete(project) + expect(client.deleteSecurityRoles).toHaveBeenCalledWith(`${project.slug}-ID`) + expect(client.deleteSecurityUsers).toHaveBeenCalledWith(project.slug) + expect(vault.delete).toHaveBeenCalledWith(`forge/${project.slug}/tech/NEXUS`) + }) + + it('handleCron should reconcile all projects', async () => { + const projects = [ + makeProjectWithDetails({ plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }] }), + makeProjectWithDetails({ plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }] }), + ] + + datastore.getAllProjects.mockResolvedValue(projects) + + await service.handleCron() + + expect(client.createSecurityUsers).toHaveBeenCalledTimes(2) + }) + + it('reuses existing vault password and does not rotate Nexus user password', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + }) + + datastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { + if (key === PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) return ' ' + if (key === PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) return ' ' + return null + }) + + vault.read.mockResolvedValue(makeVaultSecret({ data: { NEXUS_PASSWORD: 'existing' } })) + client.getSecurityUsers.mockResolvedValue([{ userId: project.slug }]) + + await service.handleUpsert(project) + + expect(client.updateSecurityUsersChangePassword).not.toHaveBeenCalled() + expect(client.createSecurityUsers).not.toHaveBeenCalled() + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + NEXUS_USERNAME: project.slug, + NEXUS_PASSWORD: 'existing', + }), `forge/${project.slug}/tech/NEXUS`) + }) + + it('deletes group repos before their hosted members', async () => { + const project = makeProjectWithDetails() + + await service.handleDelete(project) + + const deletedRepos = client.deleteRepositoriesByName.mock.calls.map(([name]) => name) + expect(deletedRepos.indexOf(`${project.slug}-repository-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-repository-snapshot`)) + expect(deletedRepos.indexOf(`${project.slug}-repository-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-repository-release`)) + expect(deletedRepos.indexOf(`${project.slug}-npm-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-npm`)) + }) + + it('tolerates platform role failures when ensuring platform roles', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + }) + const staleProject = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }], + }) + + datastore.getAllProjects.mockResolvedValue([project, staleProject]) + client.createSecurityRoles.mockImplementation(async (body) => { + if (body.id.startsWith('console-')) throw new Error('Request failed: POST security/roles responded 400 Bad Request') + }) + + await expect(service.handleUpsert(project)).resolves.not.toThrow() + + expect(client.createSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({ id: 'console-admin' })) + }) + + it('dedupes project group roles by role id and keeps the highest privileges', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, + { pluginName: PLUGIN_NAME, key: PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, + ], + }) + + datastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { + if (key === PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) return ' ' + if (key === PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) return ' ' + return null + }) + + await service.handleUpsert(project) + + expect(client.createSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({ + id: `${project.slug}-console-devops`, + privileges: expect.arrayContaining([`${project.slug}-privilege-group`]), + })) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.service.ts new file mode 100644 index 0000000000..52061ace32 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -0,0 +1,696 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { NexusPrivilege } from './nexus-client.service' +import type { ProjectWithDetails } from './nexus-datastore.service' +import type { NexusConfig } from './nexus.module-definition' +import type { + MavenHostedRepoKind, +} from './nexus.utils' +import { specificallyEnabled } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { + DEFAULT_MAVEN_RELEASE_WRITE_POLICY, + DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY, + DEFAULT_NPM_WRITE_POLICY, + DEFAULT_PLATFORM_READ_GROUP_PATHS, + DEFAULT_PLATFORM_WRITE_GROUP_PATHS, + DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES, + NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, + NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, + NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY, + NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY, + NEXUS_CONFIG_KEY_NPM_WRITE_POLICY, + PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, + PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, +} from './nexus.constants' +import { NEXUS_CONFIG } from './nexus.module-definition' +import { + generateMavenHostedRepoName, + generateNpmHostedRepoName, + generateRandomPassword, + getPluginConfig, + getProjectVaultPath, +} from './nexus.utils' + +export interface EnsureMavenReposOptions { + snapshotWritePolicy: string + releaseWritePolicy: string +} + +@Injectable() +export class NexusService { + private readonly logger = new Logger(NexusService.name) + + constructor( + @Inject(NexusDatastoreService) private readonly datastore: NexusDatastoreService, + @Inject(NexusClientService) private readonly client: NexusClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) { + this.logger.log('NexusService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('nexus', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + await this.ensureProject(project) + const projects = await this.datastore.getAllProjects() + await this.ensurePlatformRoles(projects) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('nexus', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.deleteProject(project) + const projects = await this.datastore.getAllProjects() + await this.ensurePlatformRoles(projects) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Nexus reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('nexus.projects.count', projects.length) + await this.ensureProjects(projects) + await this.ensurePlatformRoles(projects) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('nexus.projects.count', projects.length) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const enableMaven = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO)) ?? false + const enableNpm = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO)) ?? false + + const mavenSnapshotWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY) ?? DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY + const mavenReleaseWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY) ?? DEFAULT_MAVEN_RELEASE_WRITE_POLICY + const npmWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_NPM_WRITE_POLICY) ?? DEFAULT_NPM_WRITE_POLICY + + await Promise.all([ + enableMaven + ? this.ensureMavenRepos(project, { snapshotWritePolicy: mavenSnapshotWritePolicy, releaseWritePolicy: mavenReleaseWritePolicy }) + : this.deleteMavenRepos(project), + enableNpm + ? this.ensureNpmRepos(project, npmWritePolicy) + : this.deleteNpmRepos(project), + ]) + + const privileges = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + ] + : []), + ] + const readOnlyPrivileges = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + : []), + ] + await this.ensureRole(project, privileges) + await this.ensureUser(project) + await this.ensureProjectGroupRoles(project, { readOnlyPrivileges, writePrivileges: privileges }) + } + + private async upsertPrivilege(body: NexusPrivilege) { + const existing = await this.client.getSecurityPrivileges(body.name) + if (!existing) { + await this.client.createSecurityPrivilegesRepositoryView(body) + return + } + await this.client.updateSecurityPrivilegesRepositoryView(body.name, body) + } + + private async ensureMavenHostedRepo(repoName: string, writePolicy: string) { + const existing = await this.client.getRepositoriesMavenHosted(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + } + if (!existing) { + await this.client.createRepositoriesMavenHosted(body) + return + } + await this.client.updateRepositoriesMavenHosted(repoName, body) + } + + private async ensureNpmHostedRepo(repoName: string, writePolicy: string) { + const existing = await this.client.getRepositoriesNpmHosted(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + } + if (!existing) { + await this.client.createRepositoriesNpmHosted(body) + return + } + await this.client.updateRepositoriesNpmHosted(repoName, body) + } + + private async ensureNpmGroupRepo(repoName: string, memberNames: string[]) { + const existing = await this.client.getRepositoriesNpmGroup(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames, + }, + } + if (!existing) { + await this.client.postRepositoriesNpmGroup(body) + return + } + await this.client.putRepositoriesNpmGroup(repoName, body) + } + + private async ensureMavenHostedRepos(args: { + releaseRepoName: string + snapshotRepoName: string + releaseWritePolicy: string + snapshotWritePolicy: string + }) { + await Promise.all([ + this.ensureMavenHostedRepo(args.snapshotRepoName, args.snapshotWritePolicy), + this.ensureMavenHostedRepo(args.releaseRepoName, args.releaseWritePolicy), + ]) + } + + private async ensureMavenRepos(project: ProjectWithDetails, options: EnsureMavenReposOptions) { + const releaseRepoName = generateMavenHostedRepoName(project, 'release') + const snapshotRepoName = generateMavenHostedRepoName(project, 'snapshot') + const groupRepoName = generateMavenGroupRepoName(project) + + const releasePrivilege = generateMavenHostedPrivilegeName(project, 'release') + const snapshotPrivilege = generateMavenHostedPrivilegeName(project, 'snapshot') + const groupPrivilege = generateMavenGroupPrivilegeName(project) + const releasePrivilegeReadonly = generateMavenHostedPrivilegeNameReadonly(project, 'release') + const snapshotPrivilegeReadonly = generateMavenHostedPrivilegeNameReadonly(project, 'snapshot') + const groupPrivilegeReadonly = generateMavenGroupPrivilegeNameReadonly(project) + + await this.ensureMavenHostedRepos({ + releaseRepoName, + snapshotRepoName, + releaseWritePolicy: options.releaseWritePolicy, + snapshotWritePolicy: options.snapshotWritePolicy, + }) + + await this.ensureMavenGroupRepo( + groupRepoName, + [releaseRepoName, snapshotRepoName, 'maven-public'], + ) + + const privilegesToEnsureWrite = [ + { repo: releaseRepoName, privilege: releasePrivilege }, + { repo: snapshotRepoName, privilege: snapshotPrivilege }, + { repo: groupRepoName, privilege: groupPrivilege }, + ] + const privilegesToEnsureReadonly = [ + { repo: releaseRepoName, privilege: releasePrivilegeReadonly }, + { repo: snapshotRepoName, privilege: snapshotPrivilegeReadonly }, + { repo: groupRepoName, privilege: groupPrivilegeReadonly }, + ] + await Promise.all([ + this.ensureRepositoryViewPrivileges({ + project, + type: 'maven', + format: 'maven2', + entries: privilegesToEnsureWrite, + actions: ['all'], + }), + this.ensureRepositoryViewPrivileges({ + project, + type: 'maven', + format: 'maven2', + entries: privilegesToEnsureReadonly, + actions: ['read', 'browse'], + }), + ]) + } + + private async ensureMavenGroupRepo(repoName: string, memberNames: string[]) { + const existing = await this.client.getRepositoriesMavenGroup(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames, + }, + } + if (!existing) { + await this.client.createRepositoriesMavenGroup(body) + return + } + await this.client.updateRepositoriesMavenGroup(repoName, body) + } + + private async ensureRepositoryViewPrivileges(args: { + project: ProjectWithDetails + type: string + format: string + entries: Array<{ repo: string, privilege: string }> + actions: string[] + }) { + for (const entry of args.entries) { + await this.upsertPrivilege({ + type: args.type, + name: entry.privilege, + description: `Privilege for organization ${args.project.slug} for repo ${entry.repo}`, + actions: args.actions, + format: args.format, + repository: entry.repo, + }) + } + } + + private async deleteMavenRepos(project: ProjectWithDetails) { + const hostedRepoNames = [ + generateMavenHostedRepoName(project, 'release'), + generateMavenHostedRepoName(project, 'snapshot'), + ] + const privileges = [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + await Promise.all(privileges.map(privilege => this.client.deleteSecurityPrivileges(privilege))) + // Nexus responds 500 when deleting a hosted repo still referenced by a group, so delete the group first + await this.client.deleteRepositoriesByName(generateMavenGroupRepoName(project)) + await Promise.all(hostedRepoNames.map(repo => this.client.deleteRepositoriesByName(repo))) + } + + private async ensureNpmRepos(project: ProjectWithDetails, writePolicy: string) { + const hostedRepoName = generateNpmHostedRepoName(project) + const groupRepoName = generateNpmGroupRepoName(project) + + const hostedPrivilege = generateNpmHostedPrivilegeName(project) + const groupPrivilege = generateNpmGroupPrivilegeName(project) + const hostedPrivilegeReadonly = generateNpmHostedPrivilegeNameReadonly(project) + const groupPrivilegeReadonly = generateNpmGroupPrivilegeNameReadonly(project) + + await this.ensureNpmHostedRepo(hostedRepoName, writePolicy) + await this.ensureNpmGroupRepo(groupRepoName, [hostedRepoName]) + + const privilegesToEnsureWrite = [ + { repo: hostedRepoName, privilege: hostedPrivilege }, + { repo: groupRepoName, privilege: groupPrivilege }, + ] + const privilegesToEnsureReadonly = [ + { repo: hostedRepoName, privilege: hostedPrivilegeReadonly }, + { repo: groupRepoName, privilege: groupPrivilegeReadonly }, + ] + await Promise.all([ + this.ensureRepositoryViewPrivileges({ + project, + type: 'npm', + format: 'npm', + entries: privilegesToEnsureWrite, + actions: ['all'], + }), + this.ensureRepositoryViewPrivileges({ + project, + type: 'npm', + format: 'npm', + entries: privilegesToEnsureReadonly, + actions: ['read', 'browse'], + }), + ]) + } + + private async deleteNpmRepos(project: ProjectWithDetails) { + const privileges = [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + await Promise.all(privileges.map(privilege => this.client.deleteSecurityPrivileges(privilege))) + // Nexus responds 500 when deleting a hosted repo still referenced by a group, so delete the group first + await this.client.deleteRepositoriesByName(generateNpmGroupRepoName(project)) + await this.client.deleteRepositoriesByName(generateNpmHostedRepoName(project)) + } + + private async ensureRole(project: ProjectWithDetails, privileges: string[]) { + const roleId = `${project.slug}-ID` + const role = await this.client.getSecurityRoles(roleId) + if (!role) { + await this.client.createSecurityRoles({ + id: roleId, + name: `${project.slug}-role`, + description: 'desc', + privileges, + }) + return + } + await this.client.updateSecurityRoles(roleId, { + id: roleId, + name: `${project.slug}-role`, + privileges, + }) + } + + private async ensureUser(project: ProjectWithDetails) { + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') + let existingPassword: string | undefined + try { + existingPassword = await this.vault.read(vaultPath).then(res => res.data?.NEXUS_PASSWORD) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') { + existingPassword = undefined + } else { + throw error + } + } + + const ensuredPassword = existingPassword ?? generateRandomPassword(30) + const users = await this.client.getSecurityUsers(project.slug) + const existing = users.find(u => u.userId === project.slug) + if (existing) { + if (!existingPassword || existingPassword !== ensuredPassword) { + await this.client.updateSecurityUsersChangePassword(project.slug, ensuredPassword) + } + } else { + await this.client.createSecurityUsers({ + userId: project.slug, + firstName: project.owner.firstName, + lastName: project.owner.lastName, + emailAddress: project.owner.email, + password: ensuredPassword, + status: 'active', + roles: [`${project.slug}-ID`], + }) + } + + await this.vault.write({ + NEXUS_PASSWORD: ensuredPassword, + NEXUS_USERNAME: project.slug, + }, vaultPath) + } + + private async ensureSecurityRole(id: string, privileges: string[]) { + const role = await this.client.getSecurityRoles(id) + if (!role) { + await this.client.createSecurityRoles({ + id, + name: id, + description: 'desc', + privileges, + }) + return + } + await this.client.updateSecurityRoles(id, { + id, + name: id, + privileges, + }) + } + + private async getOptionalConfigValue(project: ProjectWithDetails, key: string) { + const projectValue = getPluginConfig(project, key) + if (projectValue) return projectValue + return await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + } + + private async ensureProjectGroupRoles(project: ProjectWithDetails, args: { readOnlyPrivileges: string[], writePrivileges: string[] }) { + const rawWriteSuffixes = await this.getOptionalConfigValue(project, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES + + const rawReadSuffixes = await this.getOptionalConfigValue(project, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES + + const writeGroupPaths = generateProjectRoleGroupPath(project, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES) + const readGroupPaths = generateProjectRoleGroupPath(project, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES) + + const byId = generateRolePrivilegesMapping({ + readGroupPaths, + writeGroupPaths, + readOnlyPrivileges: args.readOnlyPrivileges, + writePrivileges: args.writePrivileges, + }) + + await Promise.all(Array.from(byId.entries(), ([id, privileges]) => this.ensureSecurityRole(id, privileges))) + } + + private async ensurePlatformRoles(projects: ProjectWithDetails[]) { + const rawWriteGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) + ?? DEFAULT_PLATFORM_WRITE_GROUP_PATHS + + const rawReadGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) + ?? DEFAULT_PLATFORM_READ_GROUP_PATHS + + const readonlyPrivileges = new Set() + const writePrivileges = new Set() + for (const project of projects) { + const computed = computeProjectPrivileges(project) + for (const privilege of computed.readOnly) readonlyPrivileges.add(privilege) + for (const privilege of computed.write) writePrivileges.add(privilege) + } + + const byId = generateRolePrivilegesMapping({ + readGroupPaths: parseOidcGroupPaths(rawReadGroupPaths || DEFAULT_PLATFORM_READ_GROUP_PATHS), + writeGroupPaths: parseOidcGroupPaths(rawWriteGroupPaths || DEFAULT_PLATFORM_WRITE_GROUP_PATHS), + readOnlyPrivileges: [...readonlyPrivileges], + writePrivileges: [...writePrivileges], + }) + + // Platform roles aggregate privileges of every Nexus-enabled project; a stale + // project (e.g. privileges missing on this instance) must not block the current one. + const entries = Array.from(byId.entries()) + const results = await Promise.allSettled(entries.map(([id, privileges]) => this.ensureSecurityRole(id, privileges))) + results.forEach((result, index) => { + if (result.status === 'rejected') { + this.logger.warn(`Failed to ensure platform role ${entries[index][0]}: ${result.reason instanceof Error ? result.reason.message : result.reason}`) + } + }) + } + + private async deleteProjectGroupRoles(project: ProjectWithDetails) { + const rawWriteSuffixes = await this.getOptionalConfigValue(project, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES + + const rawReadSuffixes = await this.getOptionalConfigValue(project, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES + + const groupPaths = [ + ...generateProjectRoleGroupPath(project, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES), + ...generateProjectRoleGroupPath(project, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES), + ] + + const ids = [...new Set(groupPaths.map(generateRoleId))] + await Promise.all(ids.map(id => this.client.deleteSecurityRoles(id))) + } + + @StartActiveSpan() + private async deleteProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + await Promise.all([ + this.deleteMavenRepos(project), + this.deleteNpmRepos(project), + ]) + + await Promise.all([ + this.deleteProjectGroupRoles(project), + this.client.deleteSecurityRoles(`${project.slug}-ID`), + this.client.deleteSecurityUsers(project.slug), + ]) + + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') + try { + await this.vault.delete(vaultPath) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + } +} + +function generateMavenHostedPrivilegeName(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${project.slug}-privilege-${kind}` +} + +function generateMavenGroupRepoName(project: ProjectWithDetails) { + return `${project.slug}-repository-group` +} + +function generateMavenGroupPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-privilege-group` +} + +function generateNpmHostedPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-npm-privilege` +} + +function generateNpmGroupRepoName(project: ProjectWithDetails) { + return `${project.slug}-npm-group` +} + +function generateNpmGroupPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-npm-group-privilege` +} + +function generateMavenHostedPrivilegeNameReadonly(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${generateMavenHostedPrivilegeName(project, kind)}-ro` +} + +function generateMavenGroupPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateMavenGroupPrivilegeName(project)}-ro` +} + +function generateNpmHostedPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateNpmHostedPrivilegeName(project)}-ro` +} + +function generateNpmGroupPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateNpmGroupPrivilegeName(project)}-ro` +} + +function generateProjectRoleGroupPath(project: ProjectWithDetails, rawGroupPathSuffixes: string) { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${project.slug}${path}`) +} + +function parseOidcGroupPaths(rawGroupPaths: string) { + return rawGroupPaths + .split(',') + .map(path => path.trim()) + .filter(Boolean) +} + +function generateRolePrivilegesMapping(args: { readGroupPaths: string[], writeGroupPaths: string[], readOnlyPrivileges: string[], writePrivileges: string[] }) { + const byId = new Map() + for (const groupPath of args.readGroupPaths) byId.set(generateRoleId(groupPath), args.readOnlyPrivileges) + for (const groupPath of args.writeGroupPaths) byId.set(generateRoleId(groupPath), args.writePrivileges) + return byId +} + +function generateRoleId(groupPath: string) { + const trimmed = groupPath.trim() + const withoutLeadingSlash = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed + return withoutLeadingSlash.replaceAll('/', '-') +} + +function computeProjectPrivileges(project: ProjectWithDetails) { + const enableMaven = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO)) ?? false + const enableNpm = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO)) ?? false + + const write = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + ] + : []), + ] + + const readOnly = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + : []), + ] + + return { readOnly, write } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.utils.ts new file mode 100644 index 0000000000..6e9e0e7645 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/nexus/nexus.utils.ts @@ -0,0 +1,26 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { randomBytes } from 'node:crypto' + +export function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +export function generateRandomPassword(length: number) { + const raw = randomBytes(Math.ceil(length * 0.75)).toString('base64url') + return raw.slice(0, length) +} + +export function getProjectVaultPath(projectRootDir: string, projectSlug: string, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return `${projectRootDir}/${projectSlug}/${normalized}` +} + +export type MavenHostedRepoKind = 'release' | 'snapshot' + +export function generateMavenHostedRepoName(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${project.slug}-repository-${kind}` +} + +export function generateNpmHostedRepoName(project: ProjectWithDetails) { + return `${project.slug}-npm` +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts new file mode 100644 index 0000000000..5f008fd21e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts @@ -0,0 +1,25 @@ +import type { OpenCdsConfig } from './opencds.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { OPENCDS_CONFIG } from './opencds.module-definition' + +@Injectable() +export class OpenCdsHealthService { + constructor( + @Inject(OPENCDS_CONFIG) private readonly opencdsConfig: OpenCdsConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const headers: Record = {} + headers.Authorization = `Bearer ${this.opencdsConfig.apiToken}` + const response = await fetch(this.opencdsConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts new file mode 100644 index 0000000000..5a31df9912 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts @@ -0,0 +1,16 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface OpenCdsConfig { + url: string + internalUrl: string + probeUrl?: string + apiToken: string + apiTlsRejectUnauthorized: boolean +} + +export interface OpenCdsModuleOptions extends OpenCdsConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('opencds') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as OPENCDS_CONFIG } diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/opencds/opencds.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/opencds/opencds.module.ts new file mode 100644 index 0000000000..edc85a987a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/opencds/opencds.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { OpenCdsHealthService } from './opencds-health.service' +import { ConfigurableModuleClass } from './opencds.module-definition' + +@Module({ + imports: [TerminusModule], + providers: [OpenCdsHealthService], + exports: [OpenCdsHealthService], +}) +export class OpenCdsModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts new file mode 100644 index 0000000000..1ef4cac975 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts @@ -0,0 +1,65 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginModule } from './plugin.module' +import { PluginService } from './plugin.service' + +describe('pluginModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits PluginService when USE_ARGOCD=false', async () => { + vi.stubEnv('USE_ARGOCD', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_ARGOCD')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_GITLAB=false', async () => { + vi.stubEnv('USE_GITLAB', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_GITLAB')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_REGISTRY=false', async () => { + vi.stubEnv('USE_REGISTRY', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_REGISTRY')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_KEYCLOAK=false', async () => { + vi.stubEnv('USE_KEYCLOAK', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_KEYCLOAK')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_NEXUS=false', async () => { + vi.stubEnv('USE_NEXUS', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_NEXUS')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_SONARQUBE=false', async () => { + vi.stubEnv('USE_SONARQUBE', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_SONARQUBE')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_VAULT')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.module.ts new file mode 100644 index 0000000000..d74a259e4e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { ArgoCDModule } from '../argocd/argocd.module' +import { GitlabModule } from '../gitlab/gitlab.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { NexusModule } from '../nexus/nexus.module' +import { RegistryModule } from '../registry/registry.module' +import { SonarqubeModule } from '../sonarqube/sonarqube.module' +import { VaultModule } from '../vault/vault.module' +import { PluginService } from './plugin.service' + +@Module({ + imports: [ + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + ConditionalModule.registerWhen(RegistryModule, 'USE_REGISTRY'), + KeycloakModule, + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(SonarqubeModule, 'USE_SONARQUBE'), + VaultModule, + ], + providers: [PluginService], + exports: [PluginService], +}) +export class PluginModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.service.ts new file mode 100644 index 0000000000..1b9303ee53 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.service.ts @@ -0,0 +1,46 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { ArgoCDPluginService } from '../argocd/argocd-plugin.service' +import { GitlabPluginService } from '../gitlab/gitlab-plugin.service' +import { KeycloakPluginService } from '../keycloak/keycloak-plugin.service' +import { NexusPluginService } from '../nexus/nexus-plugin.service' +import { RegistryPluginService } from '../registry/registry-plugin.service' +import { SonarqubePluginService } from '../sonarqube/sonarqube-plugin.service' +import { VaultPluginService } from '../vault/vault-plugin.service' + +@Injectable() +export class PluginService { + private readonly logger = new Logger(PluginService.name) + + constructor( + @Inject(ArgoCDPluginService) private readonly argoCDPlugin: ArgoCDPluginService, + @Inject(GitlabPluginService) private readonly gitlabPlugin: GitlabPluginService, + @Inject(RegistryPluginService) private readonly registryPlugin: RegistryPluginService, + @Inject(KeycloakPluginService) private readonly keycloakPlugin: KeycloakPluginService, + @Inject(NexusPluginService) private readonly nexusPlugin: NexusPluginService, + @Inject(SonarqubePluginService) private readonly sonarqubePlugin: SonarqubePluginService, + @Inject(VaultPluginService) private readonly vaultPlugin: VaultPluginService, + ) {} + + async infos(projectId: string): Promise { + const plugins = [ + ['argocd', () => this.argoCDPlugin.infos()], + ['gitlab', () => this.gitlabPlugin.infos()], + ['registry', () => this.registryPlugin.infos(projectId)], + ['keycloak', () => this.keycloakPlugin.infos()], + ['nexus', () => this.nexusPlugin.infos()], + ['sonarqube', () => this.sonarqubePlugin.infos()], + ['vault', () => this.vaultPlugin.infos()], + ] as const + + const settled = await Promise.allSettled(plugins.map(([, loadInfos]) => loadInfos())) + return settled.flatMap((result, index) => { + const [pluginName] = plugins[index] + if (result.status === 'fulfilled') { + return [result.value] + } + this.logger.warn(`Skipping project service plugin ${pluginName} because infos() failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`) + return [] + }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts new file mode 100644 index 0000000000..3a2ecd238e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { capturePluginResult, getFailedPlugins, mergePluginResults } from './plugin.utils' + +describe('capturePluginResult', () => { + it('should resolve a successful task into an OK result with the default message, keyed under the plugin name', async () => { + await expect(capturePluginResult('gitlab', async () => {})).resolves.toEqual({ + gitlab: { + status: 'OK', + message: 'Up to date', + executionTime: expect.any(Number), + }, + }) + }) + + it('should use the string returned by the task as message', async () => { + await expect(capturePluginResult('gitlab', async () => 'Everything synced')).resolves.toEqual({ + gitlab: expect.objectContaining({ status: 'OK', message: 'Everything synced' }), + }) + }) + + it('should resolve a throwing task into a KO result instead of rejecting', async () => { + const task = async () => { + throw new Error('GitLab unreachable') + } + await expect(capturePluginResult('gitlab', task)).resolves.toEqual({ + gitlab: { + status: 'KO', + message: 'GitLab unreachable', + executionTime: expect.any(Number), + error: new Error('GitLab unreachable'), + }, + }) + }) + + it('should report a fallback message for non-Error throws', async () => { + const task = async () => { + throw 'boom' // eslint-disable-line no-throw-literal + } + await expect(capturePluginResult('gitlab', task)).resolves.toEqual({ + gitlab: expect.objectContaining({ status: 'KO', message: 'Erreur inconnue', error: 'boom' }), + }) + }) + + it('should report under the given plugin name', async () => { + await expect(capturePluginResult('nexus', async () => 'custom message')).resolves.toEqual({ + nexus: expect.objectContaining({ status: 'OK', message: 'custom message' }), + }) + }) +}) + +describe('mergePluginResults', () => { + it('should return empty object when given empty array', () => { + expect(mergePluginResults([])).toEqual({}) + }) + + it('should return single result as-is', () => { + const result = [{ argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }] + expect(mergePluginResults(result)).toEqual(result[0]) + }) + + it('should merge multiple results into one', () => { + const results = [ + { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }, + { gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 } }, + ] + expect(mergePluginResults(results)).toEqual({ + argocd: { status: 'OK', message: 'Up to date', executionTime: 10 }, + gitlab: { status: 'OK', message: 'Synced', executionTime: 20 }, + }) + }) + + it('should have later entries overwrite earlier ones for the same plugin', () => { + const error = new Error('sync error') + const results = [ + { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }, + { argocd: { status: 'KO' as const, message: 'Failed', executionTime: 20, error } }, + ] + expect(mergePluginResults(results)).toEqual({ + argocd: { status: 'KO', message: 'Failed', executionTime: 20, error }, + }) + }) +}) + +describe('getFailedPlugins', () => { + it('should return empty array when all plugins are OK', () => { + const results = { + argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 }, + gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 }, + } + expect(getFailedPlugins(results)).toEqual([]) + }) + + it('should return names of KO plugins', () => { + const error = new Error('sync error') + const results = { + argocd: { status: 'KO' as const, message: 'Failed', executionTime: 10, error }, + gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 }, + } + expect(getFailedPlugins(results)).toEqual(['argocd']) + }) + + it('should return all plugins when all fail', () => { + const error = new Error('error') + const results = { + argocd: { status: 'KO' as const, message: 'Failed', executionTime: 10, error }, + gitlab: { status: 'KO' as const, message: 'Failed', executionTime: 20, error }, + } + const failed = getFailedPlugins(results) + expect(failed).toHaveLength(2) + expect(failed).toContain('argocd') + expect(failed).toContain('gitlab') + }) + + it('should return empty array for empty result', () => { + expect(getFailedPlugins({})).toEqual([]) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.utils.ts new file mode 100644 index 0000000000..a54447a6a0 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/plugin/plugin.utils.ts @@ -0,0 +1,107 @@ +import type { ToUrlFnParamaters } from '@cpn-console/hooks' +import { Logger } from '@nestjs/common' + +export function makeToUrlParams(overrides: Partial = {}): ToUrlFnParamaters { + return { + store: {}, + clusters: [], + zones: [], + environments: [], + project: { id: '', slug: 'dulei', name: '' }, + ...overrides, + } +} +export type PluginName = 'argocd' + | 'gitlab' + | 'nexus' + | 'vault' + | 'keycloak' + | 'harbor' + | 'sonarqube' + | 'observability' + +export type PluginResult = { + status: 'OK' + message: string + executionTime: number +} | { + status: 'KO' + message: string + executionTime: number + error: unknown +} + +export type PluginResults = Partial> + +export type RequiredPluginResult + = { [K in T]: PluginResult } & PluginResults + +const logger = new Logger('PluginResult') + +/** + * Runs a plugin task and always resolves with the task's outcome (status, + * message, execution time and error, if any) keyed under the given plugin + * name, instead of throwing, so the event emitter can merge and persist the + * results of every listener. + * + * The task keeps its natural shape: do the work, throw on failure, optionally + * return a message string to override the default 'Up to date'. + * + * The `@OnEvent` handler stays a thin, honestly-typed wrapper, while the + * traced work method keeps `@StartActiveSpan` so the span still records the + * exception on failure: + * + * ```ts + * @OnEvent('project.upsert') + * async handleUpsert(project: ProjectWithDetails): Promise> { + * return capturePluginResult('argocd', () => this.syncProject(project)) + * } + * + * @StartActiveSpan() + * private async syncProject(project: ProjectWithDetails) { ... } + * ``` + */ +export async function capturePluginResult

( + plugin: P, + task: () => Promise, +): Promise> { + const start = process.hrtime.bigint() + const elapsedMs = () => Number(process.hrtime.bigint() - start) / 1_000_000 + + try { + const message = await task() + return keyedBy(plugin, { + status: 'OK', + message: typeof message === 'string' ? message : 'Up to date', + executionTime: elapsedMs(), + }) + } catch (error: unknown) { + logger.error(`${plugin} handler failed`, error) + return keyedBy(plugin, { + status: 'KO', + message: error instanceof Error ? error.message : 'Erreur inconnue', + executionTime: elapsedMs(), + error, + }) + } +} + +// TypeScript widens a computed property with a generic key to an index +// signature instead of Record, so the assertion is confined here. +function keyedBy

(plugin: P, result: PluginResult): RequiredPluginResult

{ + return { [plugin]: result } as RequiredPluginResult

+} + +export function mergePluginResults(responses: PluginResults[]): PluginResults { + return responses.reduce((merged, currentResponse) => { + return { ...merged, ...currentResponse } + }, {} as PluginResults) +} + +export function getFailedPlugins(response: PluginResults): PluginName[] { + const entries = Object.entries(response) as [PluginName, PluginResult][] + + return entries + .filter(([_, result]) => result.status === 'KO') + .map(([pluginName]) => pluginName) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts new file mode 100644 index 0000000000..73858d492b --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts @@ -0,0 +1,16 @@ +import type { Prisma } from '@prisma/client' + +export const projectNotArchivedSelect = { + id: true, +} satisfies Prisma.ProjectSelect + +export type ProjectIdNotArchived = Prisma.ProjectGetPayload<{ + select: typeof projectNotArchivedSelect +}> + +export function listProjectIdsNotArchived(tx: Prisma.TransactionClient): Promise { + return tx.project.findMany({ + select: projectNotArchivedSelect, + where: { status: { not: 'archived' } }, + }) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts new file mode 100644 index 0000000000..82874da42d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts @@ -0,0 +1,36 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { projectContract } from '@cpn-console/shared' +import { Body, Controller, HttpCode, HttpStatus, Inject, Logger, Post, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectBulkService } from './project-bulk.service' + +@Controller('api/v1/projects-bulk') +export class ProjectBulkController { + private readonly logger = new Logger(ProjectBulkController.name) + + constructor( + @Inject(ProjectBulkService) private readonly projectBulk: ProjectBulkService, + ) {} + + @Post('') + @HttpCode(HttpStatus.ACCEPTED) + @UseGuards(UserGuard) + @RequireAdminPermission('Manage') + async bulkAction( + @Body(new ZodValidationPipe(projectContract.bulkActionProject.body)) body: typeof projectContract.bulkActionProject.body._type, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + const target = body.projectIds === 'all' + ? 'all' + : `count=${body.projectIds.length}` + + this.logger.log(`project.bulkAction requested (action=${body.action}, target=${target})`) + await this.projectBulk.bulkAction(body, user.userId, request.id) + this.logger.log(`project.bulkAction accepted (action=${body.action})`) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts new file mode 100644 index 0000000000..fbb1d9bf76 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { ProjectHooksModule } from '../project-hooks/project-hooks.module' +import { ProjectModule } from '../project/project.module' +import { ProjectBulkController } from './project-bulk.controller' +import { ProjectBulkService } from './project-bulk.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ProjectModule, + ProjectHooksModule, + ], + controllers: [ProjectBulkController], + providers: [ProjectBulkService], +}) +export class ProjectBulkModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts new file mode 100644 index 0000000000..37ec381d2e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts @@ -0,0 +1,85 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { ProjectHooksService } from '../project-hooks/project-hooks.service' +import { makeProject } from '../project/project-testing.utils' +import { ProjectService } from '../project/project.service' +import { ProjectBulkService } from './project-bulk.service' + +describe('projectBulkService', () => { + let module: TestingModule + let service: ProjectBulkService + let prisma: DeepMockProxy + let project: DeepMockProxy + let projectHooks: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + project = mockDeep() + projectHooks = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectBulkService, + { provide: PrismaService, useValue: prisma }, + { provide: ProjectService, useValue: project }, + { provide: ProjectHooksService, useValue: projectHooks }, + ], + }).compile() + + service = module.get(ProjectBulkService) + }) + + it('processes specific project ids', async () => { + const projectIds = [faker.string.uuid(), faker.string.uuid()] + + await service.bulkAction({ action: 'archive', projectIds }, 'user-id', 'request-id') + + expect(project.archive).toHaveBeenCalledTimes(2) + expect(project.archive).toHaveBeenCalledWith(projectIds[0], 'user-id', 'request-id') + expect(project.archive).toHaveBeenCalledWith(projectIds[1], 'user-id', 'request-id') + }) + + it('resolves "all" to all non-archived project ids', async () => { + const project1Id = faker.string.uuid() + const project2Id = faker.string.uuid() + + prisma.project.findMany.mockResolvedValue([makeProject({ id: project1Id }), makeProject({ id: project2Id })]) + + await service.bulkAction({ action: 'archive', projectIds: 'all' }, 'user-id', 'request-id') + + expect(prisma.project.findMany).toHaveBeenCalledWith({ + select: { id: true }, + where: { status: { not: 'archived' } }, + }) + expect(project.archive).toHaveBeenCalledTimes(2) + }) + + it('lock action updates locked to true via project hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'lock', projectIds: [projectId] }) + + expect(projectHooks.updateProjectLocked).toHaveBeenCalledWith(projectId, true) + }) + + it('unlock action updates locked to false via project hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'unlock', projectIds: [projectId] }) + + expect(projectHooks.updateProjectLocked).toHaveBeenCalledWith(projectId, false) + }) + + it('replay action triggers hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'replay', projectIds: [projectId] }, 'user-id', 'request-id') + + expect(projectHooks.replay).toHaveBeenCalledWith(projectId, 'user-id', 'request-id') + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts new file mode 100644 index 0000000000..5989764d17 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts @@ -0,0 +1,96 @@ +import type { projectContract } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { ProjectHooksService } from '../project-hooks/project-hooks.service' +import { ProjectService } from '../project/project.service' +import { listProjectIdsNotArchived } from './project-bulk-queries.utils' + +@Injectable() +export class ProjectBulkService { + private readonly logger = new Logger(ProjectBulkService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(ProjectService) private readonly project: ProjectService, + @Inject(ProjectHooksService) private readonly projectHooks: ProjectHooksService, + ) {} + + @StartActiveSpan() + async bulkAction( + data: typeof projectContract.bulkActionProject.body._type, + requestorUserId?: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.bulk.action', data.action) + const projectIdsLog = data.projectIds === 'all' ? 'all' : `count=${data.projectIds.length}` + this.logger.log(`project.bulkAction started (action=${data.action}, projectIds=${projectIdsLog})`) + + try { + const projectIds = await this.resolveProjectIds(data.projectIds) + span?.setAttribute('project.bulk.count', projectIds.length) + + const results = await this.runTasks(projectIds, data.action, requestorUserId, requestId) + const summary = this.summarizeTasks(results) + + span?.setAttributes({ + 'project.bulk.fulfilled': summary.fulfilled, + 'project.bulk.rejected': summary.rejected, + }) + this.logger.log(`project.bulkAction completed (action=${data.action}, projectCount=${projectIds.length}, fulfilled=${summary.fulfilled}, rejected=${summary.rejected})`) + } catch (error) { + const projectIdsLabel = data.projectIds === 'all' + ? 'all' + : `count=${data.projectIds.length}` + const errorMessage = error instanceof Error ? error.message : String(error) + + this.logger.error( + `project.bulkAction failed (action=${data.action}, projectIds=${projectIdsLabel}): ${errorMessage}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async resolveProjectIds(projectIds: string[] | 'all'): Promise { + if (projectIds === 'all') { + const result = await listProjectIdsNotArchived(this.prisma) + return result.map(p => p.id) + } + return projectIds + } + + private async runTasks( + projectIds: string[], + action: string, + requestorUserId?: string, + requestId?: string, + ): Promise[]> { + const tasks = projectIds.map((projectId) => { + if (action === 'archive') { + return this.project.archive(projectId, requestorUserId, requestId) + } + if (action === 'lock' || action === 'unlock') { + return this.projectHooks.updateProjectLocked(projectId, action === 'lock') + } + if (action === 'replay') { + return this.projectHooks.replay(projectId, requestorUserId, requestId) + } + return Promise.resolve() + }) + return Promise.allSettled(tasks) + } + + private summarizeTasks(results: PromiseSettledResult[]): { fulfilled: number, rejected: number } { + return results.reduce( + (acc, r) => { + if (r.status === 'fulfilled') acc.fulfilled += 1 + else acc.rejected += 1 + return acc + }, + { fulfilled: 0, rejected: 0 }, + ) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts new file mode 100644 index 0000000000..9dd8e67e34 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts @@ -0,0 +1,32 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Controller, HttpCode, HttpStatus, Inject, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ProjectHooksService } from './project-hooks.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectHooksController { + constructor( + @Inject(ProjectHooksService) private readonly projectHooks: ProjectHooksService, + ) {} + + @Put('/:projectId/hooks') + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ReplayHooks') + async replay( + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + await this.projectHooks.replay(project.id, user.userId, request.id) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts new file mode 100644 index 0000000000..ff83fb82fb --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { LogModule } from '../log/log.module' +import { ProjectHooksController } from './project-hooks.controller' +import { ProjectHooksService } from './project-hooks.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, EventsModule, LogModule, ProjectPermissionModule], + controllers: [ProjectHooksController], + providers: [ProjectHooksService], + exports: [ProjectHooksService], +}) +export class ProjectHooksModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts new file mode 100644 index 0000000000..92d9ddf77e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts @@ -0,0 +1,80 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { projectSelect } from '../project/project-queries.utils' +import { makeProject } from '../project/project-testing.utils' +import { ProjectHooksService } from './project-hooks.service' + +describe('projectHooksService', () => { + let module: TestingModule + let service: ProjectHooksService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectHooksService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(ProjectHooksService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('updateProjectLocked updates the project locked flag', async () => { + const project = makeProject({ locked: true }) + prisma.project.update.mockResolvedValue(project) + + await service.updateProjectLocked('project-id', true) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 'project-id' }, + data: { locked: true }, + select: projectSelect, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', project, { action: 'Update Project' }) + }) + + it('replayHooks loads the project and emits project.upsert', async () => { + const project = makeProject() + prisma.project.findFirst.mockResolvedValue(project) + + const requestId = 'request-id' + const userId = 'user-id' + + await service.replay('project-id', userId, requestId) + + expect(prisma.project.findFirst).toHaveBeenCalledWith({ + where: { id: 'project-id', status: { not: 'archived' } }, + select: projectSelect, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', project, { + action: 'Replay hooks for Project', + userId, + requestId, + }) + }) + + it('replayHooks rejects locked projects', async () => { + const project = makeProject({ locked: true }) + prisma.project.findFirst.mockResolvedValue(project) + + await expect(service.replay('project-id', 'user-id', 'request-id')).rejects.toThrow(ForbiddenException) + + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts new file mode 100644 index 0000000000..b51cd4df76 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts @@ -0,0 +1,46 @@ +import { ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { getProjectNotArchived, projectSelect } from '../project/project-queries.utils' + +@Injectable() +export class ProjectHooksService { + private readonly logger = new Logger(ProjectHooksService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async updateProjectLocked(projectId: string, locked: boolean): Promise { + const project = await this.prisma.project.update({ + where: { id: projectId }, + data: { locked }, + select: projectSelect, + }) + await this.appEvents.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + } + + async replay(projectId: string, requestorUserId?: string, requestId?: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.replayHooks started (projectId=${projectId})`) + const project = await getProjectNotArchived(this.prisma, projectId) + if (!project) { + this.logger.warn(`project.replayHooks notFound (projectId=${projectId})`) + return + } + if (project.locked) { + this.logger.warn(`project.replayHooks locked (projectId=${projectId})`) + throw new ForbiddenException('Veuillez déverrouiller le projet pour rejouer les webhooks') + } + span?.setAttribute('project.slug', project.slug) + await this.appEvents.emitProjectEvent('project.upsert', project, { + action: 'Replay hooks for Project', + userId: requestorUserId, + requestId, + }) + this.logger.log(`project.replayHooks completed (projectId=${projectId})`) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts new file mode 100644 index 0000000000..ad12c2486d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts @@ -0,0 +1,78 @@ +import type { Prisma } from '@prisma/client' + +export const projectMemberWithUser = { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, +} satisfies Prisma.ProjectMembersInclude + +export const projectOwnerIdSelect = { + ownerId: true, +} satisfies Prisma.ProjectSelect + +export type ProjectMemberWithUser = Prisma.ProjectMembersGetPayload<{ + include: typeof projectMemberWithUser +}> + +export type ProjectOwnerId = Prisma.ProjectGetPayload<{ + select: typeof projectOwnerIdSelect +}> + +export type AddMemberInput = { email: string } | { userId: string } +export interface PatchMemberInput { userId: string, roles: string[] } + +export function listProjectMembersWithUser(tx: Prisma.TransactionClient, projectId: string) { + return tx.projectMembers.findMany({ + where: { projectId }, + include: projectMemberWithUser, + }) +} + +export function upsertProjectMember( + tx: Prisma.TransactionClient, + projectId: string, + member: PatchMemberInput, +) { + const { userId, roles } = member + return tx.projectMembers.upsert({ + where: { projectId_userId: { projectId, userId } }, + create: { projectId, userId, roleIds: roles }, + update: { roleIds: roles }, + }) +} + +export function upsertProjectMemberIfMissing(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.upsert({ + where: { projectId_userId: { projectId, userId } }, + create: { projectId, userId, roleIds: [] }, + update: {}, + }) +} + +export function createProjectMember(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.create({ data: { projectId, userId } }) +} + +export function deleteProjectMember(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.delete({ where: { projectId_userId: { projectId, userId } } }) +} + +export function getHumanUser(tx: Prisma.TransactionClient, opts: { userId?: string, email?: string }) { + const { userId, email } = opts + return tx.user.findFirst({ + where: { + type: 'human', + ...(typeof userId === 'string' ? { id: userId } : {}), + ...(typeof email === 'string' ? { email } : {}), + }, + }) +} + +export function getProjectOwnerId(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectOwnerIdSelect }) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts new file mode 100644 index 0000000000..e01e7f37ea --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts @@ -0,0 +1,11 @@ +import type { ProjectMembers } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } satisfies ProjectMembers +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.controller.ts new file mode 100644 index 0000000000..1761096669 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.controller.ts @@ -0,0 +1,73 @@ +import type { Member } from '@cpn-console/shared' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { AddMemberInput, PatchMemberInput } from './project-members-queries.utils' +import { projectMemberContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Logger, Param, Patch, Post, UseGuards } from '@nestjs/common' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectMembersService } from './project-members.service' +import { generateProjectMember } from './project-members.utils' + +@Controller('api/v1/projects/:projectId/members') +@UseGuards(ProjectGuard) +export class ProjectMembersController { + private readonly logger = new Logger(ProjectMembersController.name) + + constructor( + @Inject(ProjectMembersService) private readonly projectMembers: ProjectMembersService, + ) {} + + @Get() + @RequireProjectPermission('ListMembers') + async list( + @Project() project: ProjectContext, + ): Promise { + return (await this.projectMembers.list(project.id)).map(generateProjectMember) + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async add( + @Body(new ZodValidationPipe(projectMemberContract.addMember.body)) body: AddMemberInput, + @Project() project: ProjectContext, + ): Promise { + const members = await this.projectMembers.add(project.id, body) + this.logger.log(`projectMembers.add completed (memberCount=${members.length})`) + return members.map(generateProjectMember) + } + + @Patch() + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async patch( + @Body(new ZodValidationPipe(projectMemberContract.patchMembers.body)) body: PatchMemberInput[], + @Project() project: ProjectContext, + ): Promise { + const members = await this.projectMembers.patch(project.id, body) + this.logger.log(`projectMembers.patchMembers completed (projectId=${project.id}, memberCount=${members.length})`) + return members.map(generateProjectMember) + } + + @Delete('/:userId') + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async remove( + @Project() project: ProjectContext, + @Param('userId') userId: string, + ): Promise { + const members = await this.projectMembers.remove(project.id, userId) + this.logger.log(`projectMembers.remove completed (projectId=${project.id}, userId=${userId}, memberCount=${members.length})`) + return members.map(generateProjectMember) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.module.ts new file mode 100644 index 0000000000..481aeddcef --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { keycloakConfigFactory } from '../../config/keycloak.config' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { ProjectMembersController } from './project-members.controller' +import { ProjectMembersService } from './project-members.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, KeycloakModule.forRoot(keycloakConfigFactory.asProvider()), ProjectPermissionModule], + controllers: [ProjectMembersController], + providers: [ProjectMembersService], +}) +export class ProjectMembersModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts new file mode 100644 index 0000000000..48ca9eeaaa --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts @@ -0,0 +1,268 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { KeycloakClientService } from '../keycloak/keycloak-client.service' +import { + makeProject, + makeProjectMembers, + makeProjectMemberWithUser, + makeUser, +} from '../project/project-testing.utils' +import { ProjectMembersService } from './project-members.service' + +describe('projectMembersService', () => { + let module: TestingModule + let service: ProjectMembersService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + let keycloak: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + keycloak = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectMembersService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + { provide: KeycloakClientService, useValue: keycloak }, + ], + }).compile() + + service = module.get(ProjectMembersService) + }) + + describe('list', () => { + it('returns members', async () => { + const projectId = faker.string.uuid() + const user1 = makeUser() + const user2 = makeUser() + prisma.projectMembers.findMany.mockResolvedValue([ + makeProjectMemberWithUser(user1, { roleIds: [faker.string.uuid()] }), + makeProjectMemberWithUser(user2), + ]) + + const result = await service.list(projectId) + + expect(result).toHaveLength(2) + expect(result[0]).toHaveProperty('roleIds') + expect(result[0]).toHaveProperty('user') + expect(result[0].user.id).toBe(user1.id) + }) + + it('returns empty array when no members', async () => { + const projectId = faker.string.uuid() + prisma.projectMembers.findMany.mockResolvedValue([]) + + const result = await service.list(projectId) + + expect(result).toEqual([]) + }) + }) + + describe('add', () => { + it('adds member by userId and returns updated member list', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const newUserId = faker.string.uuid() + const body = { userId: newUserId } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ id: newUserId })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId, userId: newUserId })) + const memberUser = makeUser() + tx.projectMembers.findMany.mockResolvedValue([makeProjectMemberWithUser(memberUser)]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.add(projectId, body) + + expect(tx.projectMembers.upsert).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId, userId: newUserId } }, + create: { projectId, userId: newUserId, roleIds: [] }, + update: {}, + }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: newUserId, + }, { action: 'Add Project Member' }) + expect(result).toBeDefined() + }) + + it('adds member by email and returns updated member list', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const email = faker.internet.email() + const body = { email } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ email })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId })) + tx.projectMembers.findMany.mockResolvedValue([]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.add(projectId, body) + + expect(tx.user.findFirst).toHaveBeenCalledWith({ + where: { email, type: 'human' }, + }) + expect(result).toBeDefined() + }) + + it('throws when adding owner as member', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const body = { userId: ownerId } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ id: ownerId })) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow('Le owner ne peut pas être ajouté à cette liste') + }) + + it('throws NotFoundException when user not found by userId', async () => { + const projectId = faker.string.uuid() + const body = { userId: faker.string.uuid() } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId: faker.string.uuid() })) + tx.user.findFirst.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow(NotFoundException) + }) + + it('falls back to Keycloak when user not found locally by email', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const email = faker.internet.email() + const keycloakUserId = faker.string.uuid() + const body = { email } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(null) + tx.user.upsert.mockResolvedValue(makeUser({ id: keycloakUserId, email })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId, userId: keycloakUserId })) + tx.projectMembers.findMany.mockResolvedValue([]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + keycloak.getUserByEmail.mockResolvedValue(makeUser({ + id: keycloakUserId, + email, + firstName: 'Keycloak', + lastName: 'User', + })) + + const result = await service.add(projectId, body) + + expect(keycloak.getUserByEmail).toHaveBeenCalledWith(email) + expect(tx.user.upsert).toHaveBeenCalledWith({ + where: { id: keycloakUserId }, + create: { + id: keycloakUserId, + email, + firstName: 'Keycloak', + lastName: 'User', + adminRoleIds: [], + type: 'human', + }, + update: { + email, + firstName: 'Keycloak', + lastName: 'User', + type: 'human', + }, + }) + expect(result).toBeDefined() + }) + + it('throws NotFoundException when user not found by email anywhere', async () => { + const projectId = faker.string.uuid() + const body = { email: faker.internet.email() } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId: faker.string.uuid() })) + tx.user.findFirst.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow(NotFoundException) + }) + + it('throws NotFoundException when project does not exist', async () => { + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.add(faker.string.uuid(), { userId: faker.string.uuid() }), + ).rejects.toThrow(NotFoundException) + }) + }) + + describe('patch', () => { + it('upserts multiple members and emits events', async () => { + const projectId = faker.string.uuid() + const members = [ + { userId: faker.string.uuid(), roles: [faker.string.uuid()] }, + { userId: faker.string.uuid(), roles: [] }, + ] + + const tx = mockDeep() + prisma.$transaction.mockImplementation(async cb => cb(tx)) + tx.projectMembers.findMany.mockResolvedValue([]) + + await service.patch(projectId, members) + + expect(tx.projectMembers.upsert).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: members[0].userId, + }, { action: 'Update Project Member' }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: members[1].userId, + }, { action: 'Update Project Member' }) + }) + }) + + describe('remove', () => { + it('deletes member, emits event, returns updated list', async () => { + const projectId = faker.string.uuid() + const userId = faker.string.uuid() + + const tx = mockDeep() + tx.projectMembers.delete.mockResolvedValue(makeProjectMembers({ projectId, userId })) + const memberUser = makeUser() + tx.projectMembers.findMany.mockResolvedValue([makeProjectMemberWithUser(memberUser)]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.remove(projectId, userId) + + expect(tx.projectMembers.delete).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId, userId } }, + }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.delete', { + projectId, + userId, + }, { action: 'Remove Project Member' }) + expect(result).toBeDefined() + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.service.ts new file mode 100644 index 0000000000..4daa282eaa --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.service.ts @@ -0,0 +1,164 @@ +import type { AddMemberInput, PatchMemberInput } from './project-members-queries.utils' +import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { KeycloakClientService } from '../keycloak/keycloak-client.service' +import { deleteProjectMember, getHumanUser, getProjectOwnerId, listProjectMembersWithUser, upsertProjectMember, upsertProjectMemberIfMissing } from './project-members-queries.utils' + +@Injectable() +export class ProjectMembersService { + private readonly logger = new Logger(ProjectMembersService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, + ) {} + + @StartActiveSpan() + async list(projectId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.debug(`projectMembers.listMembers started (projectId=${projectId})`) + const members = await listProjectMembersWithUser(this.prisma, projectId) + span?.setAttribute('project.members.count', members.length) + this.logger.debug(`projectMembers.listMembers completed (projectId=${projectId}, count=${members.length})`) + return members + } + + @StartActiveSpan() + async add( + projectId: string, + body: AddMemberInput, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + const lookupBy = 'userId' in body ? 'userId' : 'email' + span?.setAttribute('project.member.lookupBy', lookupBy) + const userIdCandidate = 'userId' in body ? body.userId : undefined + this.logger.log(`projectMembers.addMember started (projectId=${projectId}, lookupBy=${lookupBy}, userId=${userIdCandidate})`) + try { + const result = await this.prisma.$transaction(async (tx) => { + const project = await getProjectOwnerId(tx, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + + const userId = 'userId' in body ? body.userId : undefined + const email = 'email' in body ? body.email : undefined + + const userDb = await this.resolveHumanUser(tx, { userId, email }) + if (!userDb) throw new NotFoundException('Utilisateur introuvable') + + if (userDb.id === project.ownerId) { + throw new BadRequestException('Le owner ne peut pas être ajouté à cette liste') + } + + await upsertProjectMemberIfMissing(tx, projectId, userDb.id) + + const members = await listProjectMembersWithUser(tx, projectId) + return { userId: userDb.id, members } + }) + await this.appEvents.emitProjectMemberEvent('projectMember.upsert', { projectId, userId: result.userId }, { action: 'Add Project Member' }) + span?.setAttribute('project.member.userId', result.userId) + span?.setAttribute('project.members.count', result.members.length) + this.logger.log(`projectMembers.addMember completed (projectId=${projectId}, userId=${result.userId}, memberCount=${result.members.length})`) + return result.members + } catch (error) { + this.logger.error( + `projectMembers.addMember failed (projectId=${projectId}, lookupBy=${lookupBy}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async resolveHumanUser( + tx: Parameters[0], + opts: { userId?: string, email?: string }, + ) { + const userDb = await getHumanUser(tx, opts) + if (userDb || !opts.email) { + return userDb + } + + const keycloakUser = await this.keycloak.getUserByEmail(opts.email) + if (!keycloakUser) { + return null + } + + const keycloakUserId = keycloakUser.id ?? opts.email + return tx.user.upsert({ + where: { id: keycloakUserId }, + create: { + id: keycloakUserId, + email: keycloakUser.email ?? opts.email, + firstName: keycloakUser.firstName ?? keycloakUser.email ?? opts.email, + lastName: keycloakUser.lastName ?? '', + adminRoleIds: [], + type: 'human', + }, + update: { + email: keycloakUser.email ?? opts.email, + firstName: keycloakUser.firstName ?? keycloakUser.email ?? opts.email, + lastName: keycloakUser.lastName ?? '', + type: 'human', + }, + }) + } + + @StartActiveSpan() + async patch( + projectId: string, + body: PatchMemberInput[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('project.members.patch.count', body.length) + this.logger.log(`projectMembers.patchMembers started (projectId=${projectId}, patchCount=${body.length})`) + try { + const members = await this.prisma.$transaction(async (tx) => { + for (const member of body) { + await upsertProjectMember(tx, projectId, member) + } + return listProjectMembersWithUser(tx, projectId) + }) + await Promise.all( + body.map(member => this.appEvents.emitProjectMemberEvent('projectMember.upsert', { projectId, userId: member.userId }, { action: 'Update Project Member' })), + ) + span?.setAttribute('project.members.count', members.length) + this.logger.log(`projectMembers.patchMembers completed (projectId=${projectId}, memberCount=${members.length})`) + return members + } catch (error) { + this.logger.error( + `projectMembers.patchMembers failed (projectId=${projectId}, patchCount=${body.length}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async remove(projectId: string, userId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('project.member.userId', userId) + this.logger.log(`projectMembers.removeMember started (projectId=${projectId}, userId=${userId})`) + try { + const members = await this.prisma.$transaction(async (tx) => { + await deleteProjectMember(tx, projectId, userId) + return listProjectMembersWithUser(tx, projectId) + }) + await this.appEvents.emitProjectMemberEvent('projectMember.delete', { projectId, userId }, { action: 'Remove Project Member' }) + span?.setAttribute('project.members.count', members.length) + this.logger.log(`projectMembers.removeMember completed (projectId=${projectId}, userId=${userId}, memberCount=${members.length})`) + return members + } catch (error) { + this.logger.error( + `projectMembers.removeMember failed (projectId=${projectId}, userId=${userId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.utils.ts new file mode 100644 index 0000000000..4131cfbebd --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-members/project-members.utils.ts @@ -0,0 +1,13 @@ +import type { Member } from '@cpn-console/shared' +import type { ProjectMemberWithUser } from './project-members-queries.utils' + +export function generateProjectMember(member: ProjectMemberWithUser): Member { + const { roleIds, user } = member + return { + userId: user.id, + roleIds, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts new file mode 100644 index 0000000000..20a4355031 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts @@ -0,0 +1,47 @@ +import type { Prisma } from '@prisma/client' + +export const projectRoleWithProjectSelect = { + id: true, + name: true, + permissions: true, + position: true, + oidcGroup: true, + type: true, + projectId: true, + project: { + select: { + slug: true, + members: { + distinct: 'userId', + }, + }, + }, +} satisfies Prisma.ProjectRoleSelect + +export const projectRoleForDeleteSelect = { + type: true, + projectId: true, +} satisfies Prisma.ProjectRoleSelect + +export const projectForRoleContextSelect = { + id: true, + slug: true, + status: true, + locked: true, +} satisfies Prisma.ProjectSelect + +export type ProjectRoleWithProject = Prisma.ProjectRoleGetPayload<{ select: typeof projectRoleWithProjectSelect }> + +export type ProjectForRoleContext = Prisma.ProjectRoleGetPayload<{ select: typeof projectForRoleContextSelect }> + +export function getProjectBySlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: { slug: true } }) +} + +export function getProjectRoleForDelete(tx: Prisma.TransactionClient, roleId: string) { + return tx.projectRole.findUnique({ where: { id: roleId }, select: projectRoleForDeleteSelect }) +} + +export function getProjectForUpsert(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectForRoleContextSelect }) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts new file mode 100644 index 0000000000..c9b74e9e28 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts @@ -0,0 +1,71 @@ +import type { Project, ProjectMembers, ProjectRole } from '@prisma/client' +import { faker } from '@faker-js/faker' + +interface ProjectRoleWithMembers extends ProjectRole { + members?: Array +} + +interface ProjectRoleWithProject extends ProjectRole { + project: Pick +} + +export function makeProjectRole(overrides: Partial = {}): ProjectRole { + return { + id: faker.string.uuid(), + name: faker.person.jobTitle(), + permissions: faker.number.bigInt({ min: 0n, max: 64n }), + projectId: faker.string.uuid(), + position: faker.number.int({ min: 0, max: 100 }), + oidcGroup: '', + type: 'managed', + ...overrides, + } +} + +export function makeProjectRoleWithMembers(overrides: Partial = {}): ProjectRoleWithMembers { + return { + ...makeProjectRole(overrides), + members: overrides.members ?? [], + } +} + +export function makeProjectRoleWithProject(overrides: Partial = {}): ProjectRoleWithProject { + const role = makeProjectRole(overrides) + return { + ...role, + project: overrides.project ?? { slug: faker.helpers.slugify(faker.company.name()) }, + } +} + +export function makeProject(overrides: Partial = {}): Project { + return { + id: faker.string.uuid(), + name: faker.company.name(), + description: '', + status: 'initializing', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.helpers.slugify(faker.company.name()), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } +} + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts new file mode 100644 index 0000000000..c7fb6a706d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts @@ -0,0 +1,70 @@ +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { projectRoleContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Patch, Post, UseGuards } from '@nestjs/common' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectRolesService } from './project-roles.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectRolesController { + constructor( + @Inject(ProjectRolesService) private readonly projectRoles: ProjectRolesService, + ) {} + + @Get('/:projectId/roles') + @RequireProjectPermission('ListRoles') + async list( + @Project() project: ProjectContext, + ) { + return this.projectRoles.list(project.id) + } + + @Post('/:projectId/roles') + @HttpCode(HttpStatus.CREATED) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async create( + @Body(new ZodValidationPipe(projectRoleContract.createProjectRole.body)) body: typeof projectRoleContract.createProjectRole.body._type, + @Project() project: ProjectContext, + ) { + return this.projectRoles.create(project.id, body) + } + + @Patch('/:projectId/roles') + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async update( + @Body(new ZodValidationPipe(projectRoleContract.patchProjectRoles.body)) body: typeof projectRoleContract.patchProjectRoles.body._type, + @Project() project: ProjectContext, + ) { + return this.projectRoles.update(project.id, body) + } + + @Get('/:projectId/roles/member-counts') + @RequireProjectPermission('ListRoles') + async countMembers( + @Project() project: ProjectContext, + ) { + return this.projectRoles.countMembers(project.id) + } + + @Delete('/:projectId/roles/:roleId') + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async delete( + @Param('roleId') roleId: string, + @Project() _project: ProjectContext, + ): Promise { + return this.projectRoles.delete(roleId) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts new file mode 100644 index 0000000000..e6bf20ad5f --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { ProjectRolesController } from './project-roles.controller' +import { ProjectRolesService } from './project-roles.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, ProjectPermissionModule], + controllers: [ProjectRolesController], + providers: [ProjectRolesService], + exports: [ProjectRolesService], +}) +export class ProjectRolesModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts new file mode 100644 index 0000000000..ef4a761800 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts @@ -0,0 +1,181 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeProject, makeProjectMembers, makeProjectRole, makeProjectRoleWithProject } from './project-roles-testing.utils' +import { ProjectRolesService } from './project-roles.service' + +describe('projectRolesService', () => { + let module: TestingModule + let service: ProjectRolesService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectRolesService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(ProjectRolesService) + }) + + it('lists roles with permissions as strings and stripped oidcGroup', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + prisma.project.findUnique.mockResolvedValue(makeProject({ id: projectId, slug })) + prisma.projectRole.findMany.mockResolvedValue([makeProjectRoleWithProject({ + name: 'test', + permissions: 4n, + position: 0, + projectId, + oidcGroup: '/slug/console/admin', + project: { slug }, + })]) + + const roles = await service.list(projectId) + + expect(roles[0].permissions).toBe('4') + expect(roles[0].oidcGroup).toBe('/console/admin') + }) + + it('creates a role and emits project update', async () => { + const projectId = faker.string.uuid() + const slug = faker.string.alphanumeric(8).toLowerCase() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + prisma.projectRole.findFirst.mockResolvedValue(makeProjectRole({ position: 0 })) + prisma.projectRole.create.mockResolvedValue(makeProjectRole()) + prisma.projectRole.findMany.mockResolvedValue([]) + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + + await service.create(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + name: 'test', + permissions: 4n, + position: 1, + projectId, + }), + })) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', expect.anything(), { action: 'Upsert Project Role' }) + }) + + it('rejects system role creation', async () => { + const projectId = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug: 'slug' })) + + await expect(service.create(projectId, { name: 'test', permissions: '4', type: 'system:managed' })) + .rejects.toThrow(BadRequestException) + }) + + it('patches roles and validates positions', async () => { + const projectId = faker.string.uuid() + const slug = faker.string.alphanumeric(8).toLowerCase() + const roleId = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + prisma.projectRole.findMany.mockResolvedValue([makeProjectRoleWithProject({ + id: roleId, + name: 'test', + permissions: 4n, + position: 0, + projectId, + oidcGroup: '/slug/console/admin', + project: { slug }, + })]) + prisma.projectRole.update.mockResolvedValue(makeProjectRole({ id: roleId })) + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + + await service.update(projectId, [{ id: roleId, name: 'updated', position: 0 }]) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: roleId }, + data: expect.objectContaining({ + name: 'updated', + position: 0, + }), + })) + }) + + it('rejects incoherent positions', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + const roleId1 = faker.string.uuid() + const roleId2 = faker.string.uuid() + prisma.projectRole.findMany.mockResolvedValue([ + makeProjectRoleWithProject({ id: roleId1, name: 'a', permissions: 1n, position: 0, projectId, oidcGroup: '', project: { slug } }), + makeProjectRoleWithProject({ id: roleId2, name: 'b', permissions: 1n, position: 1, projectId, oidcGroup: '', project: { slug } }), + ]) + + await expect(service.update(projectId, [{ id: roleId1, position: 1 }])) + .rejects.toThrow(BadRequestException) + }) + + it('counts role members', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + const roleId1 = faker.string.uuid() + const roleId2 = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + prisma.projectRole.findMany.mockResolvedValue([ + makeProjectRoleWithProject({ id: roleId1, name: 'a', permissions: 1n, position: 0, projectId, oidcGroup: '', project: { slug } }), + makeProjectRoleWithProject({ id: roleId2, name: 'b', permissions: 1n, position: 1, projectId, oidcGroup: '', project: { slug } }), + ]) + prisma.projectMembers.findMany.mockResolvedValue([ + makeProjectMembers({ roleIds: [roleId1, roleId2] }), + makeProjectMembers({ roleIds: [roleId2] }), + ]) + + await expect(service.countMembers(projectId)).resolves.toEqual({ + [roleId1]: 1, + [roleId2]: 2, + }) + }) + + it('deletes role and cleans member role ids', async () => { + const projectId = faker.string.uuid() + const roleId = faker.string.uuid() + prisma.projectRole.findUnique.mockResolvedValue(makeProjectRole({ type: 'managed', projectId })) + prisma.project.findUnique.mockResolvedValue(makeProject({ id: projectId, slug: 'slug' })) + + const tx = mockDeep() + tx.projectRole.delete.mockResolvedValue(makeProjectRole()) + tx.projectMembers.findMany.mockResolvedValue([ + makeProjectMembers({ userId: faker.string.uuid(), roleIds: [roleId] }), + makeProjectMembers({ userId: faker.string.uuid(), roleIds: [roleId, faker.string.uuid()] }), + ]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.delete(roleId) + + expect(tx.projectMembers.update).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', expect.anything(), { action: 'Upsert Project Role' }) + }) + + it('rejects system role deletion', async () => { + prisma.projectRole.findUnique.mockResolvedValue(makeProjectRole({ type: 'system:managed', projectId: faker.string.uuid() })) + + await expect(service.delete(faker.string.uuid())) + .rejects.toThrow(BadRequestException) + }) + + it('rejects missing role deletion', async () => { + prisma.projectRole.findUnique.mockResolvedValue(null) + + await expect(service.delete(faker.string.uuid())) + .rejects.toThrow(NotFoundException) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts new file mode 100644 index 0000000000..a8c537b5fd --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts @@ -0,0 +1,130 @@ +import type { Project, ProjectRole } from '@prisma/client' +import type { + CreateProjectRoleInput, + PatchProjectRolesInput, +} from './project-roles.utils' +import { isSystemRoleType } from '@cpn-console/shared' +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { getProjectBySlug, getProjectRoleForDelete, projectRoleWithProjectSelect } from './project-roles-queries.utils' +import { + buildUpdatedProjectRoles, + toProjectRoleResponse, + validatePatchedProjectRolePositions, +} from './project-roles.utils' + +@Injectable() +export class ProjectRolesService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async list(projectId: Project['id']) { + const roles = await this.prisma.projectRole.findMany({ + where: { projectId }, + orderBy: { position: 'asc' }, + select: projectRoleWithProjectSelect, + }) + return roles.map(role => toProjectRoleResponse(role)) + } + + async update(projectId: Project['id'], roles: PatchProjectRolesInput) { + const project = await getProjectBySlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + + const dbRoles = await this.prisma.projectRole.findMany({ where: { projectId }, orderBy: { position: 'asc' } }) + const { requestedPositionsCount, updatedRoles } = buildUpdatedProjectRoles(project.slug, dbRoles, roles) + validatePatchedProjectRolePositions(dbRoles.length, requestedPositionsCount) + + for (const { id, ...role } of updatedRoles) { + await this.prisma.projectRole.update({ where: { id }, data: role }) + } + + await this.emitProjectUpsert(projectId) + return this.list(projectId) + } + + async create(projectId: Project['id'], role: CreateProjectRoleInput) { + const project = await getProjectBySlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + if (isSystemRoleType(role.type)) { + throw new BadRequestException('Impossible de créer un rôle système') + } + + const dbMaxPosRole = (await this.prisma.projectRole.findFirst({ + where: { projectId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + if (role.oidcGroup && !role.oidcGroup.startsWith('/')) { + throw new BadRequestException('oidcGroup doit commencer par /') + } + + await this.prisma.projectRole.create({ + data: { + ...role, + projectId, + position: dbMaxPosRole + 1, + permissions: BigInt(role.permissions), + oidcGroup: role.oidcGroup ? `/${project.slug}${role.oidcGroup}` : undefined, + }, + }) + + await this.emitProjectUpsert(projectId) + return this.list(projectId) + } + + async countMembers(projectId: Project['id']) { + const roles = await this.list(projectId) + const members = await this.prisma.projectMembers.findMany({ where: { projectId } }) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) + + for (const { roleIds } of members) { + for (const roleId of roleIds) { + rolesCounts[roleId]++ + } + } + + return rolesCounts + } + + async delete(roleId: ProjectRole['id']) { + const role = await getProjectRoleForDelete(this.prisma, roleId) + if (!role) throw new NotFoundException('Rôle introuvable') + if (isSystemRoleType(role.type)) { + throw new BadRequestException('Ce rôle système ne peut pas être supprimé') + } + + await this.prisma.$transaction(async (tx) => { + await tx.projectRole.delete({ where: { id: roleId } }) + const attachedMembers = await tx.projectMembers.findMany({ + where: { projectId: role.projectId, roleIds: { has: roleId } }, + }) + + for (const member of attachedMembers) { + await tx.projectMembers.update({ + where: { + projectId_userId: { + projectId: role.projectId, + userId: member.userId, + }, + }, + data: { + roleIds: { + set: member.roleIds.filter(existingRoleId => existingRoleId !== roleId), + }, + }, + }) + } + }) + + await this.emitProjectUpsert(role.projectId) + } + + private async emitProjectUpsert(projectId: string): Promise { + await this.appEvents.emitProjectEvent('project.upsert', projectId, { action: 'Upsert Project Role' }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts new file mode 100644 index 0000000000..c83bb2bb54 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts @@ -0,0 +1,108 @@ +import type { ProjectRole as SharedProjectRole } from '@cpn-console/shared' +import type { ProjectRole } from '@prisma/client' +import type { ProjectRoleWithProject } from './project-roles-queries.utils' +import { isSystemRoleType } from '@cpn-console/shared' +import { BadRequestException } from '@nestjs/common' + +export interface ProjectRoleResponse { + id: string + name: string + oidcGroup?: string | null + type?: string | null + permissions: string + userCount?: number +} + +export type CreateProjectRoleInput = Omit +export type PatchProjectRoleInput = Pick & Partial> +export type PatchProjectRolesInput = PatchProjectRoleInput[] + +export function toProjectRoleResponse( + role: ProjectRoleWithProject, +): ProjectRoleResponse { + return { + id: role.id, + name: role.name, + oidcGroup: stripProjectSlugFromOidcGroup(role.oidcGroup, role.project.slug), + type: role.type ?? 'managed', + permissions: role.permissions.toString(), + userCount: role.project.members?.length ?? 0, + } +} + +export function buildUpdatedProjectRoles( + projectSlug: string, + dbRoles: ProjectRole[], + roles: PatchProjectRolesInput, +): { requestedPositionsCount: number, updatedRoles: ProjectRole[] } { + const requestedPositions = new Set() + const updatedRoles: ProjectRole[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (!matchingRole) continue + + validateProjectRolePatch(dbRole, matchingRole) + + if (matchingRole.position !== undefined) { + requestedPositions.add(matchingRole.position) + } + + updatedRoles.push(mergeProjectRolePatch(projectSlug, dbRole, matchingRole)) + } + + return { requestedPositionsCount: requestedPositions.size, updatedRoles } +} + +export function validatePatchedProjectRolePositions(dbRolesCount: number, requestedPositionsCount: number): void { + if (requestedPositionsCount && requestedPositionsCount !== dbRolesCount) { + throw new BadRequestException('Les numéros de position des rôles sont incohérentes') + } +} + +function validateProjectRolePatch(dbRole: ProjectRole, matchingRole: PatchProjectRoleInput): void { + if (isSystemRoleType(dbRole.type)) { + throw new BadRequestException('Ce rôle système ne peut pas être modifié') + } + + if (isSystemRoleType(matchingRole.type)) { + throw new BadRequestException('Impossible de modifier un rôle en rôle système') + } + + if (matchingRole.oidcGroup && !matchingRole.oidcGroup.startsWith('/')) { + throw new BadRequestException('oidcGroup doit commencer par /') + } +} + +function mergeProjectRolePatch( + projectSlug: string, + dbRole: ProjectRole, + matchingRole: PatchProjectRoleInput, +): ProjectRole { + const permissions = typeof matchingRole.permissions === 'string' + ? BigInt(matchingRole.permissions) + : dbRole.permissions + const oidcGroup = typeof matchingRole.oidcGroup === 'string' + ? `/${projectSlug}${matchingRole.oidcGroup}` + : dbRole.oidcGroup + + return { + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup, + type: matchingRole.type ?? dbRole.type, + projectId: dbRole.projectId, + } +} + +const oidcRegexp = /^\/[^/]+/ + +function stripProjectSlugFromOidcGroup(oidcGroup: string, projectSlug: string): string | null { + if (!oidcGroup.startsWith(`/${projectSlug}`)) { + return oidcGroup.replace(oidcRegexp, '') + } + + return oidcGroup.slice(projectSlug.length + 1) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts new file mode 100644 index 0000000000..f6595cbc81 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts @@ -0,0 +1,13 @@ +import type { Prisma } from '@prisma/client' + +export const projectSlugSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export type ProjectSlug = Prisma.ProjectGetPayload<{ + select: typeof projectSlugSelect +}> + +export function getProjectSlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectSlugSelect }) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts new file mode 100644 index 0000000000..89d0174b4a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts @@ -0,0 +1,21 @@ +import type { VaultMetadata, VaultSecret } from '../vault/vault-client.service' +import { faker } from '@faker-js/faker' + +export function makeVaultSecret = Record>(overrides: Partial> = {}): VaultSecret { + return { + data: {} as T, + metadata: makeVaultMetadata(), + ...overrides, + } satisfies VaultSecret +} + +function makeVaultMetadata(overrides: Partial = {}): VaultMetadata { + return { + created_time: faker.date.past().toISOString(), + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + ...overrides, + } satisfies VaultMetadata +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts new file mode 100644 index 0000000000..ae99445888 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, Inject, UseGuards } from '@nestjs/common' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ProjectSecretsService } from './project-secrets.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectSecretsController { + constructor( + @Inject(ProjectSecretsService) private readonly projectSecrets: ProjectSecretsService, + ) {} + + @Get('/:projectId/secrets') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectPermission('SeeSecrets') + async get( + @Project() project: { id: string }, + ): Promise>> { + return this.projectSecrets.get(project.id) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts new file mode 100644 index 0000000000..1ecc4f73e6 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts @@ -0,0 +1,17 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProjectSecretsModule } from './project-secrets.module' +import { ProjectSecretsService } from './project-secrets.service' + +describe('projectSecretsModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ProjectSecretsService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ProjectSecretsModule, 'USE_VAULT')], + }).compile() + expect(() => module.get(ProjectSecretsService)).toThrow() + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts new file mode 100644 index 0000000000..16a6c23b13 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { VaultModule } from '../vault/vault.module' +import { ProjectSecretsController } from './project-secrets.controller' +import { ProjectSecretsService } from './project-secrets.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, VaultModule, ConfigModule.forFeature(baseConfigFactory)], + controllers: [ProjectSecretsController], + providers: [ProjectSecretsService], + exports: [ProjectSecretsService], +}) +export class ProjectSecretsModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts new file mode 100644 index 0000000000..62586bd954 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts @@ -0,0 +1,106 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeProject } from '../project/project-testing.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { VaultService } from '../vault/vault.service' +import { makeVaultSecret } from './project-secrets-testing.utils' +import { ProjectSecretsService } from './project-secrets.service' + +describe('projectSecretsService', () => { + let module: TestingModule + let service: ProjectSecretsService + let prisma: DeepMockProxy + let vault: DeepMockProxy + let vaultClient: DeepMockProxy + let config: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + vault = mockDeep() + vaultClient = mockDeep() + config = mockDeep({ projectsRootDir: '/vault' }) + vaultConfig = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectSecretsService, + { provide: PrismaService, useValue: prisma }, + { provide: BASE_CONFIG, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: VaultService, useValue: vault }, + { provide: VaultClientService, useValue: vaultClient }, + ], + }).compile() + + service = module.get(ProjectSecretsService) + }) + + it('returns parsed secrets from vault', async () => { + const projectId = 'project-id' + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproject' })) + vault.listProjectSecrets.mockResolvedValue(['group1/secret1']) + vaultClient.read.mockResolvedValue(makeVaultSecret({ data: { key1: 'value1', key2: 42, key3: true, key4: null } })) + + const result = await service.get(projectId) + + expect(prisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: projectId }, + select: { slug: true }, + }) + expect(vault.listProjectSecrets).toHaveBeenCalledWith('myproject') + expect(result).toHaveProperty('group1') + expect(result.group1).toHaveProperty('secret1.key1', 'value1') + expect(result.group1).toHaveProperty('secret1.key2', '42') + expect(result.group1).toHaveProperty('secret1.key3', 'true') + expect(result.group1).toHaveProperty('secret1.key4', '') + }) + + it('handles nested secret paths', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue(['group1/sub/path']) + vaultClient.read.mockResolvedValue(makeVaultSecret({ data: { nested: 'value' } })) + + const result = await service.get('project-id') + + expect(result.group1).toHaveProperty('sub/path.nested', 'value') + }) + + it('returns empty object when no secrets exist', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue([]) + + const result = await service.get('project-id') + + expect(result).toEqual({}) + }) + + it('returns empty object when secret listing fails', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockRejectedValue(new Error('vault unavailable')) + + const result = await service.get('project-id') + + expect(result).toEqual({}) + }) + + it('skips secrets that fail to read', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue(['group1/s1', 'group1/s2']) + vaultClient.read + .mockRejectedValueOnce(new Error('vault error')) + .mockResolvedValueOnce(makeVaultSecret({ data: { key: 'val' } })) + + const result = await service.get('project-id') + + expect(result.group1).toEqual({ 's2.key': 'val' }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts new file mode 100644 index 0000000000..2f9525b41f --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts @@ -0,0 +1,88 @@ +import type { ConfigType } from '@nestjs/config' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { z } from 'zod' +import { baseConfigFactory } from '../../config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultService } from '../vault/vault.service' +import { generateProjectPath } from '../vault/vault.utils' +import { getProjectSlug } from './project-secrets-queries.utils' + +const SecretValueSchema = z.union([ + z.string(), + z.undefined().transform(() => ''), + z.number().transform(String), + z.bigint().transform(String), + z.boolean().transform(String), + z.null().transform(() => ''), +]).catch('') + +export function parseSecretValue(value: string): string { + return SecretValueSchema.parse(value) +} + +@Injectable() +export class ProjectSecretsService { + private readonly logger = new Logger(ProjectSecretsService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, + @Inject(VaultService) private readonly vault: VaultService, + @Inject(VaultClientService) private readonly vaultClient: VaultClientService, + ) {} + + @StartActiveSpan() + async get(projectId: string): Promise>> { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.get started (projectId=${projectId})`) + try { + const project = await getProjectSlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + span?.setAttribute('project.slug', project.slug) + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, project.slug) + + const result: Record> = {} + const relativePaths = await this.vault.listProjectSecrets(project.slug).catch((error) => { + this.logger.warn( + `project.get secret listing failed (projectId=${projectId}, slug=${project.slug}): ${error instanceof Error ? error.message : String(error)}; returning an empty result`, + ) + return [] as string[] + }) + span?.setAttribute('vault.secretFiles.count', relativePaths.length) + this.logger.debug(`project.get listed (projectId=${projectId}, slug=${project.slug}, secretFiles=${relativePaths.length})`) + + for (const relativePath of relativePaths) { + const fullPath = `${projectPath}/${relativePath}` + const secret = await this.vaultClient.read>(fullPath).catch(() => null) + if (!secret?.data) continue + + const [group, ...rest] = relativePath.split('/').filter(Boolean) + if (!group) continue + const prefix = rest.length ? `${rest.join('/')}.` : '' + const groupObj = (result[group] ??= {}) + for (const [key, value] of Object.entries(secret.data)) { + groupObj[`${prefix}${key}`] = parseSecretValue(value) + } + } + + const groupCount = Object.keys(result).length + const keyCount = Object.values(result).reduce((acc, group) => acc + Object.keys(group).length, 0) + span?.setAttributes({ + 'vault.secretGroups.count': groupCount, + 'vault.secretKeys.count': keyCount, + }) + this.logger.log(`project.get completed (projectId=${projectId}, slug=${project.slug}, groupCount=${groupCount}, keyCount=${keyCount})`) + return result + } catch (error) { + this.logger.error( + `project.get failed (projectId=${projectId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts new file mode 100644 index 0000000000..ef539dae4a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts @@ -0,0 +1,117 @@ +import type { Prisma } from '@prisma/client' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + clusters: { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + infos: true, + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + createdAt: true, + updatedAt: true, + clusterId: true, + cluster: { + select: { + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectPluginSelect = { + key: true, + pluginName: true, + projectId: true, + value: true, +} satisfies Prisma.ProjectPluginSelect + +export const adminPluginSelect = { + key: true, + pluginName: true, + value: true, +} satisfies Prisma.AdminPluginSelect + +export const publicClusterSelect = { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + infos: true, + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, +} satisfies Prisma.ClusterFindManyArgs + +export type ProjectPlugin = Prisma.ProjectPluginGetPayload<{ + select: typeof projectPluginSelect +}> + +export type AdminPlugin = Prisma.AdminPluginGetPayload<{ + select: typeof adminPluginSelect +}> + +export type PublicCluster = Prisma.ClusterGetPayload<{ + select: typeof publicClusterSelect.select +}> + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export async function getServicesQueryData(tx: Prisma.TransactionClient, projectId: string) { + return Promise.all([ + tx.project.findUnique({ + where: { id: projectId }, + select: projectSelect, + }), + tx.projectPlugin.findMany({ + where: { projectId }, + select: projectPluginSelect, + }), + tx.adminPlugin.findMany({ + select: adminPluginSelect, + }), + tx.cluster.findMany({ + where: { privacy: 'public' }, + select: publicClusterSelect.select, + }), + ]) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts new file mode 100644 index 0000000000..6a52d2b057 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts @@ -0,0 +1,231 @@ +import type { Plugin } from '@cpn-console/hooks' +import type { PluginsUpdateBody } from '@cpn-console/shared' +import type { Cluster, Environment, ProjectPlugin as PrismaProjectPlugin, Project, Zone } from '@prisma/client' +import type { AdminPlugin, ProjectPlugin, ProjectWithDetails, PublicCluster } from './project-services-queries.utils' +import { DEFAULT } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +/** Narrow zone shape the service reads from `PublicCluster.zone` / environment clusters. */ +export type PublicZone = PublicCluster['zone'] + +export function makeServicesPluginName(): string { + return faker.helpers.slugify(faker.company.name()) +} + +export function makePlugin(overrides: Partial = {}): Plugin { + const pluginName = overrides.name ?? makeServicesPluginName() + + return { + infos: { + name: pluginName, + title: faker.commerce.productName(), + description: faker.lorem.sentence(), + imgSrc: `/${faker.helpers.slugify(faker.commerce.product())}.svg`, + config: { + global: [], + project: [ + { + kind: 'switch', + key: 'enabled', + title: 'Enabled', + value: DEFAULT, + initialValue: DEFAULT, + permissions: { + user: { read: true, write: true }, + admin: { read: true, write: true }, + }, + }, + ], + }, + to: ({ project }) => `/projects/${project.slug}`, + ...overrides, + }, + subscribedHooks: {}, + } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + name: faker.company.name(), + slug: faker.helpers.slugify(faker.company.name()), + description: faker.lorem.sentence(), + clusters: [makePublicCluster()], + environments: [], + ...overrides, + } +} + +export function makeProjectPlugin(overrides: Partial = {}): ProjectPlugin { + return { + pluginName: makeServicesPluginName(), + projectId: faker.string.uuid(), + key: 'enabled', + value: DEFAULT, + ...overrides, + } +} + +export function makeServicesAdminPlugin(overrides: Partial = {}): AdminPlugin { + const pluginName = overrides.pluginName ?? makeServicesPluginName() + const adminPlugin = { + pluginName, + key: 'enabled', + value: DEFAULT, + } + return { ...adminPlugin, ...overrides, pluginName } +} + +export function makePublicCluster(overrides: Partial = {}): PublicCluster { + const resolvedZone = overrides.zone ?? makeServicesZone() + return { + id: faker.string.uuid(), + label: faker.company.name(), + privacy: 'public', + clusterResources: false, + infos: null, + zone: resolvedZone, + ...overrides, + } +} + +export function makeServicesZone(overrides: Partial = {}): PublicZone { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`zone-${faker.string.alphanumeric(8)}`), + label: faker.location.city(), + argocdUrl: `https://${faker.helpers.slugify(faker.location.city())}.example.com`, + ...overrides, + } +} + +export function makeServicesEnvironment(overrides: Partial & { cluster?: PublicCluster } = {}): ProjectWithDetails['environments'][number] { + const { cluster: clusterOverride, ...restOverrides } = overrides + const cluster = clusterOverride ?? makePublicCluster() + return { + id: faker.string.uuid(), + name: faker.word.noun(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: cluster.id, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + cluster, + ...restOverrides, + } +} + +export function makeServicesUpdateBody(pluginName: string = makeServicesPluginName()): PluginsUpdateBody { + return { + [pluginName]: { + enabled: DEFAULT, + }, + } +} + +export type ClusterWithZone = Cluster & { zone: Zone } + +export function makeCluster(overrides: Partial = {}): Cluster { + return { + id: faker.string.uuid(), + label: faker.company.name(), + privacy: 'public', + secretName: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + infos: null, + memory: faker.number.float({ min: 0, max: 1024, fractionDigits: 2 }), + cpu: faker.number.float({ min: 0, max: 64, fractionDigits: 2 }), + gpu: faker.number.float({ min: 0, max: 8, fractionDigits: 2 }), + zoneId: faker.string.uuid(), + ...overrides, + } +} + +export function makeClusterWithZone(overrides: Partial = {}): ClusterWithZone { + const resolvedZone = overrides.zone ?? { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`zone-${faker.string.alphanumeric(8)}`), + label: faker.location.city(), + description: faker.lorem.sentence(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + argocdUrl: `https://${faker.helpers.slugify(faker.location.city())}.example.com`, + } + const { zone: _zone, ...clusterOverrides } = overrides + return { + ...makeCluster(clusterOverrides), + zoneId: clusterOverrides.zoneId ?? resolvedZone.id, + zone: resolvedZone, + } +} + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + projectId: faker.string.uuid(), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export type EnvironmentWithCluster = Environment & { cluster: ClusterWithZone } + +export function makeEnvironmentWithCluster(overrides: Partial = {}): EnvironmentWithCluster { + const resolvedCluster = overrides.cluster ?? makeClusterWithZone() + const { cluster: _cluster, ...envOverrides } = overrides + return { + ...makeEnvironment(envOverrides), + clusterId: envOverrides.clusterId ?? resolvedCluster.id, + cluster: resolvedCluster, + } +} + +export type ProjectWithRelations = Project & { + plugins: PrismaProjectPlugin[] + clusters: ClusterWithZone[] + environments: EnvironmentWithCluster[] +} + +export function makeProject(overrides: Partial = {}): ProjectWithRelations { + return { + id: faker.string.uuid(), + name: faker.company.name(), + description: faker.lorem.sentence(), + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.helpers.slugify(faker.company.name()), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + plugins: [], + clusters: [], + environments: [], + ...overrides, + } +} + +export function makeProjectWithRelations(overrides: Partial = {}): ProjectWithRelations { + return makeProject(overrides) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.controller.ts new file mode 100644 index 0000000000..00ff45d9f2 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.controller.ts @@ -0,0 +1,49 @@ +import type { PermissionTarget, PluginsUpdateBody } from '@cpn-console/shared' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { AdminAuthorized, projectServiceContract } from '@cpn-console/shared' +import { Body, Controller, ForbiddenException, Get, HttpCode, HttpStatus, Inject, Post, Query, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectServicesService } from './project-services.service' + +@Controller('api/v1/projects/:projectId/services') +@UseGuards(ProjectGuard) +export class ProjectServicesController { + constructor( + @Inject(ProjectServicesService) private readonly projectServices: ProjectServicesService, + ) {} + + @Get() + @RequireProjectPermission('ListEnvironments') + async get( + @Query(new ZodValidationPipe(projectServiceContract.getServices.query)) query: { permissionTarget: PermissionTarget }, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + ) { + if (query.permissionTarget === 'admin' && !AdminAuthorized.Manage(user.adminPermissions ?? 0n)) { + throw new ForbiddenException('Vous ne pouvez pas demander les paramètres admin') + } + + return this.projectServices.get(project.id, query.permissionTarget) + } + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + async update( + @Body(new ZodValidationPipe(projectServiceContract.updateProjectServices.body)) body: PluginsUpdateBody, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + ): Promise { + const allowedRoles: Array<'user' | 'admin'> = AdminAuthorized.Manage(user.adminPermissions ?? 0n) ? ['user', 'admin'] : ['user'] + await this.projectServices.update(project.id, body, allowedRoles) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts new file mode 100644 index 0000000000..375e371303 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts @@ -0,0 +1,17 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProjectServicesModule } from './project-services.module' +import { ProjectServicesService } from './project-services.service' + +describe('projectServicesModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ProjectServicesService when USE_PLUGINS=false', async () => { + vi.stubEnv('USE_PLUGINS', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ProjectServicesModule, 'USE_PLUGINS')], + }).compile() + expect(() => module.get(ProjectServicesService)).toThrow() + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.module.ts new file mode 100644 index 0000000000..599b8cae0a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { PluginModule } from '../plugin/plugin.module' +import { ProjectServicesController } from './project-services.controller' +import { ProjectServicesService } from './project-services.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, ConditionalModule.registerWhen(PluginModule, 'USE_PLUGINS')], + controllers: [ProjectServicesController], + providers: [ProjectServicesService], + exports: [ProjectServicesService], +}) +export class ProjectServicesModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts new file mode 100644 index 0000000000..f0702f9a97 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts @@ -0,0 +1,175 @@ +import type { Plugin, ServiceInfos } from '@cpn-console/hooks' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ClusterWithZone, ProjectWithRelations } from './project-services-testing.utils' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PluginService } from '../plugin/plugin.service' +import { + makeClusterWithZone, + makeEnvironmentWithCluster, + makePlugin, + makeProjectPlugin, + makeProjectWithRelations, + makeServicesUpdateBody, +} from './project-services-testing.utils' +import { ProjectServicesService } from './project-services.service' + +function makeEmptyServiceInfo(name: string): ServiceInfos { + return { + name, + title: name, + config: { + global: [], + project: [], + }, + } +} + +describe('servicesService', () => { + let module: TestingModule + let service: ProjectServicesService + let prisma: DeepMockProxy + let projectId: string + let pluginName: string + let projectSlug: string + let serviceTitle: string + let serviceDescription: string + let serviceImgSrc: string + let project: ProjectWithRelations + let cluster: ClusterWithZone + let plugin: Plugin + + beforeEach(async () => { + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(faker.company.name()) + serviceTitle = faker.commerce.productName() + serviceDescription = faker.lorem.sentence() + serviceImgSrc = `/${faker.helpers.slugify(faker.commerce.product())}.svg` + + plugin = makePlugin({ + title: serviceTitle, + description: serviceDescription, + imgSrc: serviceImgSrc, + to: ({ project }) => `/projects/${project.slug}`, + }) + pluginName = plugin.infos.name + project = makeProjectWithRelations({ id: projectId, slug: projectSlug }) + cluster = makeClusterWithZone() + + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectServicesService, + { provide: PrismaService, useValue: prisma }, + { provide: PluginService, useValue: { infos: async () => [plugin.infos, makeEmptyServiceInfo('gitlab'), makeEmptyServiceInfo('harbor'), makeEmptyServiceInfo('keycloak'), makeEmptyServiceInfo('nexus'), makeEmptyServiceInfo('sonarqube'), makeEmptyServiceInfo('vault')] } }, + ], + }).compile() + + service = module.get(ProjectServicesService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('get returns the service with actual hooks data', async () => { + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectPlugin.findMany.mockResolvedValue([ + makeProjectPlugin({ projectId, pluginName, value: ENABLED }), + ]) + prisma.adminPlugin.findMany.mockResolvedValue([]) + prisma.cluster.findMany.mockResolvedValue([cluster]) + + const result = await service.get(projectId, 'user') + const e2eService = result.find(entry => entry.name === pluginName) + + expect(e2eService).toMatchObject({ + name: pluginName, + title: serviceTitle, + imgSrc: serviceImgSrc, + urls: [{ to: `/projects/${projectSlug}`, name: '' }], + }) + expect(e2eService?.manifest.project?.[0]).toMatchObject({ + key: 'enabled', + value: ENABLED, + }) + }) + + it('get includes zones coming from project environments', async () => { + const baseZone = makeClusterWithZone().zone + const environmentZone = makeClusterWithZone({ + zone: { + ...baseZone, + argocdUrl: 'https://argocd.example.com', + }, + }) + const projectWithEnvironmentZones = makeProjectWithRelations({ + clusters: [], + environments: [ + makeEnvironmentWithCluster({ + cluster: environmentZone, + }), + ], + }) + const envZonePlugin = makePlugin({ + to: ({ zones }) => zones.map(zone => ({ + to: `${zone.argocdUrl}/applications?search=${zone.slug}`, + title: zone.label, + })), + }) + + prisma.project.findUnique.mockResolvedValue(projectWithEnvironmentZones) + prisma.projectPlugin.findMany.mockResolvedValue([]) + prisma.adminPlugin.findMany.mockResolvedValue([]) + prisma.cluster.findMany.mockResolvedValue([]) + + const envModule = await Test.createTestingModule({ + providers: [ + ProjectServicesService, + { provide: PrismaService, useValue: prisma }, + { provide: PluginService, useValue: { infos: async () => [envZonePlugin.infos] } }, + ], + }).compile() + const envService = envModule.get(ProjectServicesService) + + const result = await envService.get(projectId, 'user') + + expect(result.find(entry => entry.name === envZonePlugin.infos.name)).toMatchObject({ + urls: [{ to: `${environmentZone.zone.argocdUrl}/applications?search=${environmentZone.zone.slug}`, name: environmentZone.zone.label }], + }) + await envModule.close() + }) + + it('update stores project configuration through the real utility', async () => { + const data = makeServicesUpdateBody(pluginName) + + await service.update(projectId, data, ['user']) + + expect(prisma.projectPlugin.upsert).toHaveBeenCalledWith({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key: 'enabled', + }, + }, + create: { + projectId, + pluginName, + key: 'enabled', + value: data[pluginName].enabled, + }, + update: { + pluginName, + key: 'enabled', + value: data[pluginName].enabled, + }, + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.service.ts new file mode 100644 index 0000000000..572e947767 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.service.ts @@ -0,0 +1,105 @@ +import type { ZoneObject } from '@cpn-console/hooks' +import type { PermissionTarget, PluginsUpdateBody, ProjectService } from '@cpn-console/shared' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PluginService } from '../plugin/plugin.service' +import { getServicesQueryData } from './project-services-queries.utils' +import { + buildProjectEditStrippers, + generatePluginsUpdateBody, + normalizeServiceUrls, + parsePluginsUpdateBody, + populateServiceManifest, + saveProjectStore, +} from './project-services.utils' + +@Injectable() +export class ProjectServicesService { + private readonly logger = new Logger(ProjectServicesService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(PluginService) private readonly plugin: PluginService, + ) {} + + async get(projectId: string, permissionTarget: PermissionTarget): Promise { + const [project, projectStore, globalConfig, publicClusters] = await getServicesQueryData(this.prisma, projectId) + + if (!project) { + throw new NotFoundException('Projet introuvable') + } + + const store = generatePluginsUpdateBody([...projectStore, ...globalConfig]) + const clusters = [...project.clusters, ...publicClusters] + const zones = new Map() + for (const cluster of clusters) { + zones.set(cluster.zone.id, cluster.zone) + } + for (const environment of project.environments) { + const zone = environment.cluster?.zone + if (zone) zones.set(zone.id, zone) + } + const serviceInfos = await this.plugin.infos(projectId) + + const services = await Promise.all(serviceInfos.map(async (serviceInfo) => { + const { name, title, to, imgSrc, description } = serviceInfo + let toResponse: unknown = [] + if (to) { + try { + toResponse = to({ + clusters: clusters.map(c => ({ + id: c.id, + label: c.label, + privacy: c.privacy, + clusterResources: c.clusterResources, + infos: c.infos, + zone: c.zone, + })), + zones: [...zones.values()], + environments: project.environments, + project: { + id: project.id, + name: project.name, + slug: project.slug, + }, + store, + }) + } catch (error) { + this.logger.debug({ err: error, service: name }, 'Service URL generation failed, returning no URLs') + } + } + + const urls = normalizeServiceUrls(toResponse) + const manifest = populateServiceManifest({ + service: serviceInfo, + data: { + project: projectStore, + global: globalConfig, + }, + permissionTarget, + select: { + global: true, + project: true, + }, + }) + + return { imgSrc, title, name, urls, manifest, description } + })) + + return services.filter(service => service.urls.length || service.manifest.global?.length || service.manifest.project?.length) + } + + async update(projectId: string, data: PluginsUpdateBody, stripperRoles: Array<'user' | 'admin'>) { + this.logger.log(`Update project services started (projectId=${projectId}, pluginCount=${Object.keys(data).length}, stripperRoles=${stripperRoles.join(',')})`) + const editStrippers = buildProjectEditStrippers(await this.plugin.infos(projectId)) + + for (const role of stripperRoles) { + const parsedData = editStrippers.project[role].safeParse(data) + if (!parsedData.success) continue + + await saveProjectStore(parsePluginsUpdateBody(parsedData.data), projectId, this.prisma) + } + + this.logger.log(`Update project services completed (projectId=${projectId})`) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.utils.ts new file mode 100644 index 0000000000..c5961851e1 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project-services/project-services.utils.ts @@ -0,0 +1,125 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { PermissionTarget, PluginConfig, PluginsUpdateBody, ServiceUrl } from '@cpn-console/shared' +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { editStrippersGenerator } from '@cpn-console/hooks' +import { atomicValidators, DEFAULT } from '@cpn-console/shared' +import { z } from 'zod' + +export interface PluginRecord { + pluginName: string + key: string + value: string +} + +export function generatePluginsUpdateBody(records: PluginRecord[]): PluginsUpdateBody { + const obj: PluginsUpdateBody = {} + for (const record of records) { + if (!obj[record.pluginName]) obj[record.pluginName] = {} + obj[record.pluginName][record.key] = record.value + } + return obj +} + +export function parsePluginsUpdateBody(obj: PluginsUpdateBody): PluginRecord[] { + return Object.entries(obj) + .flatMap(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) +} + +export function buildProjectEditStrippers(services: ServiceInfos[]) { + const project = { + user: z.object({}), + admin: z.object({}), + } + let global = z.object({}) + + for (const service of services) { + if (!service.config) continue + const editZod = editStrippersGenerator.parse(service.config) + global = global.merge(z.object({ [service.name]: editZod.global.default({}) })) + project.admin = project.admin.merge(z.object({ [service.name]: editZod.project.admin.default({}) })) + project.user = project.user.merge(z.object({ [service.name]: editZod.project.user.default({}) })) + } + + return { project, global } +} + +interface ServiceManifestParams { + service: ServiceInfos + data: { project?: PluginRecord[], global?: PluginRecord[] } + permissionTarget: PermissionTarget + select: Partial> +} + +export function populateServiceManifest({ service, data, select, permissionTarget }: ServiceManifestParams): Partial { + const manifest = structuredClone(service.config) + + const selected: Partial = {} + for (const [scope] of Object.entries(select).filter(([_scope, bool]) => bool)) { + if (!manifest?.[scope]) continue + selected[scope] = manifest[scope].filter(item => item.permissions[permissionTarget].read || item.permissions[permissionTarget].write).map((item) => { + const row = data[scope]?.find(candidate => candidate.pluginName === service.name && candidate.key === item.key) + if (item.kind === 'switch') { + const value = atomicValidators.switch.safeParse(row?.value) + item.value = value.success ? value.data : DEFAULT + } else { + item.value = z.coerce.string().parse(row?.value ?? item.value ?? '') + } + return item + }) + } + return selected +} + +export function normalizeServiceUrls(toResponse: unknown): ServiceUrl[] { + if (Array.isArray(toResponse)) { + return toResponse.map(res => ({ name: res.title ?? '', description: res.description ?? '', to: res.to })) + } + + if (typeof toResponse === 'string') { + return [{ to: toResponse, name: '' }] + } + + if (toResponse) { + return [{ name: (toResponse as { title?: string }).title ?? '', to: (toResponse as { to: string }).to }] + } + + return [] +} + +export async function saveProjectStore(records: PluginRecord[], projectId: string, prisma: PrismaService) { + for (const { pluginName, key, value } of records) { + if (value === null) { + await prisma.projectPlugin.delete({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } else { + await prisma.projectPlugin.upsert({ + create: { + pluginName, + projectId, + key, + value: value.toString(), + }, + update: { + key, + value: value.toString(), + pluginName, + }, + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project-queries.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project-queries.utils.ts new file mode 100644 index 0000000000..8469ab9cee --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project-queries.utils.ts @@ -0,0 +1,311 @@ +import type { Prisma } from '@prisma/client' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + status: true, + locked: true, + limitless: true, + hprodCpu: true, + hprodGpu: true, + hprodMemory: true, + prodCpu: true, + prodGpu: true, + prodMemory: true, + everyonePerms: true, + ownerId: true, + createdAt: true, + updatedAt: true, + lastSuccessProvisionningVersion: true, + owner: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + type: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + }, + }, + members: { + select: { + roleIds: true, + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + type: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + }, + }, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + roles: { + select: { + id: true, + name: true, + permissions: true, + position: true, + oidcGroup: true, + type: true, + projectId: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + isPrivate: true, + externalRepoUrl: true, + externalUserName: true, + helmValuesFiles: true, + deployRevision: true, + deployPath: true, + createdAt: true, + updatedAt: true, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + clusterId: true, + stageId: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + deployments: { + select: { + id: true, + name: true, + autosync: true, + createdAt: true, + updatedAt: true, + environment: { + select: { + id: true, + name: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + deploymentSources: { + select: { + id: true, + type: true, + path: true, + targetRevision: true, + helmValuesFiles: true, + repository: { + select: { + id: true, + internalRepoName: true, + }, + }, + }, + }, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectForUpdateSelect = { + id: true, + ownerId: true, + status: true, + locked: true, + members: { + select: { + userId: true, + user: { + select: { + type: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectForDataSelect = { + name: true, + description: true, + createdAt: true, + updatedAt: true, + environments: { + select: { + name: true, + stage: true, + cluster: { + select: { label: true }, + }, + }, + }, + owner: true, +} satisfies Prisma.ProjectSelect + +export const projectIdSelect = { + id: true, +} satisfies Prisma.ProjectSelect + +export const projectSlugSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export const projectContextSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export const projectForUpsertSelect = { + id: true, + slug: true, + status: true, + locked: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export type ProjectUpdateContext = Prisma.ProjectGetPayload<{ + select: typeof projectForUpdateSelect +}> + +export type ProjectDataExport = Prisma.ProjectGetPayload<{ + select: typeof projectForDataSelect +}> + +export type ProjectContext = Prisma.ProjectGetPayload<{ + select: typeof projectContextSelect +}> + +export type ProjectForUpsert = Prisma.ProjectGetPayload<{ + select: typeof projectForUpsertSelect +}> + +export function getProject(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectSelect }) +} + +export function getProjectNotArchived(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findFirst({ where: { id: projectId, status: { not: 'archived' } }, select: projectSelect }) +} + +export function listProjects(tx: Prisma.TransactionClient, whereAnd: Prisma.ProjectWhereInput[]) { + return tx.project.findMany({ + where: { AND: whereAnd }, + select: projectSelect, + }) +} + +export function listProjectSlugsForPrefix(tx: Prisma.TransactionClient, prefix: string) { + return tx.project.findMany({ + where: { slug: { startsWith: prefix } }, + select: { slug: true }, + }) +} + +export function getProjectSlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: { slug: true } }) +} + +export function getProjectContext(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectContextSelect }) +} + +export function listProjectsForDataExport(tx: Prisma.TransactionClient) { + return tx.project.findMany({ + select: projectForDataSelect, + }) +} + +export function createProject(tx: Prisma.TransactionClient, data: Prisma.ProjectCreateInput) { + return tx.project.create({ + data, + select: projectIdSelect, + }) +} + +export function getNotArchivedProjectForUpdate(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findFirst({ + where: { id: projectId, status: { not: 'archived' } }, + select: projectForUpdateSelect, + }) +} + +export function getProjectForUpsert(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectForUpsertSelect }) +} + +export function updateProject(tx: Prisma.TransactionClient, projectId: string, data: Prisma.ProjectUpdateInput) { + return tx.project.update({ where: { id: projectId }, data }) +} + +export function deleteProjectDependencies(tx: Prisma.TransactionClient, projectId: string) { + return Promise.all([ + tx.repository.deleteMany({ where: { projectId } }), + tx.environment.deleteMany({ where: { projectId } }), + tx.deployment.deleteMany({ where: { projectId } }), + ]) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project-testing.utils.ts new file mode 100644 index 0000000000..4205b88470 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project-testing.utils.ts @@ -0,0 +1,161 @@ +import type { projectContract } from '@cpn-console/shared' +import type { Prisma, ProjectMembers, User } from '@prisma/client' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { projectSelect, ProjectWithDetails } from './project-queries.utils' +import { PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +export function makeUser(overrides: Partial = {}): User { + return { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastLogin: null, + ...overrides, + } +} + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } +} + +type ProjectMembersWithUser = ProjectMembers & { user: User } + +export function makeProjectMemberWithUser(user: User, overrides: Partial = {}): ProjectMembersWithUser { + return { + ...makeProjectMembers({ userId: user.id, ...overrides }), + user, + } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + const owner = overrides.owner ?? makeUser() + const id = overrides.id ?? faker.string.uuid() + return { + id, + name: faker.string.alphanumeric(8).toLowerCase(), + slug: faker.string.alphanumeric(8).toLowerCase(), + description: faker.lorem.sentence(), + status: 'created', + locked: false, + limitless: false, + hprodCpu: 1, + hprodGpu: 0, + hprodMemory: 2, + prodCpu: 1, + prodGpu: 0, + prodMemory: 2, + everyonePerms: PROJECT_PERMS.GUEST, + ownerId: owner.id, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastSuccessProvisionningVersion: null, + owner, + members: [], + plugins: [], + roles: [], + repositories: [], + environments: [], + deployments: [], + clusters: [], + ...overrides, + } +} + +export function makeProjectContext(overrides: Partial = {}): ProjectContext { + return { + id: faker.string.uuid(), + slug: faker.string.alphanumeric(8).toLowerCase(), + locked: false, + status: 'created', + ...overrides, + } +} + +type Project = Prisma.ProjectGetPayload<{ select: typeof projectSelect }> + +export function makeProject(overrides: Partial = {}): Project { + const id = overrides.id ?? faker.string.uuid() + return { + id, + name: faker.string.alphanumeric(8).toLowerCase(), + slug: faker.string.alphanumeric(8).toLowerCase(), + description: '', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 896n, + ownerId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastSuccessProvisionningVersion: null, + owner: { + id: faker.string.uuid(), + email: 'owner@example.com', + firstName: 'Owner', + lastName: 'Test', + adminRoleIds: [], + type: 'human', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastLogin: faker.date.past(), + }, + members: [], + plugins: [], + roles: [], + repositories: [], + environments: [], + deployments: [], + clusters: [], + ...overrides, + } +} + +export type ProjectWithMembers = Prisma.ProjectGetPayload<{ + include: { members: { include: { user: true } } } +}> + +export function makeProjectWithMembersResult( + project: ProjectWithDetails, + members: Array> = [], +): ProjectWithMembers { + return { ...project, members } as ProjectWithMembers +} + +export function makeCreateProjectBody(overrides: Partial = {}): typeof projectContract.createProject.body._type { + return { + name: faker.string.alphanumeric({ length: faker.number.int({ min: 2, max: 20 }) }).toLowerCase(), + description: faker.lorem.sentence(), + limitless: true, + hprodCpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + hprodGpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + hprodMemory: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodCpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodGpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodMemory: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + ...overrides, + } +} + +export function makeListProjectsQuery(overrides: Partial = {}): typeof projectContract.listProjects.query._type { + return { + filter: 'member', + ...overrides, + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.controller.ts new file mode 100644 index 0000000000..a10afe449f --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.controller.ts @@ -0,0 +1,91 @@ +import type { ProjectV2 } from '@cpn-console/shared' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { AdminAuthorized, projectContract } from '@cpn-console/shared' +import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, Inject, Post, Put, Query, Req, UseGuards } from '@nestjs/common' +import { json2csv } from 'json-2-csv' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectAccess } from '../infrastructure/permission/project/project-access.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectService } from './project.service' +import { generateProjectV2 } from './project.utils' + +@Controller('api/v1/projects') +export class ProjectController { + constructor( + @Inject(ProjectService) private readonly project: ProjectService, + ) {} + + @Get('/data') + @UseGuards(UserGuard) + @RequireAdminPermission('Manage') + async getProjectsData(): Promise { + return json2csv(await this.project.getProjectsData()) + } + + @Get('') + @UseGuards(UserGuard) + async list( + @Query(new ZodValidationPipe(projectContract.listProjects.query)) query: typeof projectContract.listProjects.query._type, + @AuthUser() user: UserContext, + ): Promise { + if (query.filter === 'all' && !AdminAuthorized.Manage(user.adminPermissions)) { + throw new ForbiddenException('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + return (await this.project.listProjects(query, user)).map(generateProjectV2) + } + + @Post('') + @HttpCode(HttpStatus.CREATED) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageProjects') + async create( + @Body(new ZodValidationPipe(projectContract.createProject.body)) body: typeof projectContract.createProject.body._type, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return generateProjectV2(await this.project.create(body, user.userId, request.id)) + } + + @Get('/:projectId') + @UseGuards(ProjectGuard) + @RequireProjectAccess() + async get( + @Project() project: ProjectContext, + ): Promise { + return generateProjectV2(await this.project.get(project.id)) + } + + @Put('/:projectId') + @HttpCode(HttpStatus.OK) + @UseGuards(ProjectGuard) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectPermission('Manage') + async update( + @Body(new ZodValidationPipe(projectContract.updateProject.body)) data: typeof projectContract.updateProject.body._type, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return generateProjectV2(await this.project.update(data, user, project.id, request.id)) + } + + @Delete('/:projectId') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(ProjectGuard) + @RequireProjectPermission('Manage') + async archive( + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.project.archive(project.id, user.userId, request.id) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.module.ts new file mode 100644 index 0000000000..4e50667c67 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { LogModule } from '../log/log.module' +import { VaultModule } from '../vault/vault.module' +import { ProjectController } from './project.controller' +import { ProjectService } from './project.service' + +@Module({ + imports: [ + AppEventsModule, + AuthModule, + DatabaseModule, + EventsModule, + ProjectPermissionModule, + UserPermissionModule, + LogModule, + VaultModule, + ConfigModule.forFeature(baseConfigFactory), + ], + controllers: [ProjectController], + providers: [ProjectService], + exports: [ProjectService], +}) +export class ProjectModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.service.spec.ts new file mode 100644 index 0000000000..76de94e2fa --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -0,0 +1,521 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import { faker } from '@faker-js/faker' +import { + ForbiddenException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { + makeCreateProjectBody, + makeListProjectsQuery, + makeProject, + makeProjectContext, + makeProjectMemberWithUser, + makeProjectWithDetails, + makeProjectWithMembersResult, + makeUser, +} from './project-testing.utils' +import { ProjectService } from './project.service' +import { generateSlug } from './project.utils' + +describe('projectService', () => { + let module: TestingModule + let service: ProjectService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + let config: DeepMockProxy + let vaultConfig: DeepMockProxy + let logs: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + config = mockDeep({ appVersion: 'dev' }) + vaultConfig = mockDeep() + logs = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + { provide: BASE_CONFIG, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: LogService, useValue: logs }, + ], + }).compile() + + service = module.get(ProjectService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('create', () => { + it('generates slug, creates project, emits event, returns ProjectV2', async () => { + const userId = faker.string.uuid() + const body = makeCreateProjectBody() + const existingSlugs = [body.name, `${body.name}-1`] + const expectedSlug = generateSlug(body.name, existingSlugs) + const tx = mockDeep() + + tx.project.findMany.mockResolvedValue(existingSlugs.map(slug => makeProject({ slug }))) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const pwd = makeProjectWithDetails({ slug: expectedSlug }) + pwd.roles = Array.from({ length: 4 }, (_, index) => ({ + id: faker.string.uuid(), + name: `role-${index}`, + permissions: 0n, + position: index, + oidcGroup: '', + type: 'managed', + projectId: pwd.id, + })) as never + tx.project.create.mockResolvedValue(makeProject({ id: pwd.id })) + tx.project.findUnique.mockResolvedValue(pwd) + + const requestId = faker.string.uuid() + const result = await service.create(body, userId, requestId) + + expect(tx.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { slug: { startsWith: body.name } } }), + ) + expect(tx.project.create).toHaveBeenCalled() + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', pwd.id, { + action: 'Create Project', + userId, + requestId, + }) + expect(logs.addLog).toHaveBeenCalledTimes(4) + expect(result).toBeDefined() + expect(result.slug).toBe(expectedSlug) + }) + + it('throws InternalServerErrorException when project cannot be loaded after creation', async () => { + const userId = faker.string.uuid() + const body = makeCreateProjectBody() + + const tx = mockDeep() + tx.project.findMany.mockResolvedValue([]) + tx.project.create.mockResolvedValue(makeProject({ id: 'test-id' })) + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.create(body, userId, faker.string.uuid())) + .rejects.toThrow(InternalServerErrorException) + }) + }) + + describe('list', () => { + it('returns projects filtered by member for non-admin', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + const projects = [makeProjectWithDetails(), makeProjectWithDetails()] + prisma.project.findMany.mockResolvedValue(projects) + + const result = await service.listProjects( + makeListProjectsQuery(), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalled() + expect(result).toHaveLength(2) + expect(result[0]).toHaveProperty('id') + expect(result[0]).toHaveProperty('slug') + }) + + it('allows admin-only "all" filter for admin users', async () => { + const userId = faker.string.uuid() + const adminPerms = BigInt(2) + const user = { userId, adminPermissions: adminPerms } satisfies UserContext + prisma.project.findMany.mockResolvedValue([makeProjectWithDetails()]) + + const result = await service.listProjects( + makeListProjectsQuery({ filter: 'all' }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalled() + expect(result).toHaveLength(1) + }) + + it('forbids "all" filter for non-admin users', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + + await expect( + service.listProjects(makeListProjectsQuery({ filter: 'all' }), user), + ).rejects.toThrow(ForbiddenException) + }) + + it('filters by status', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + prisma.project.findMany.mockResolvedValue([]) + + await service.listProjects( + makeListProjectsQuery({ status: 'created' }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ status: 'created' }), + ]), + }), + }), + ) + }) + + it('filters by search term', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + const search = 'myproject' + prisma.project.findMany.mockResolvedValue([]) + + await service.listProjects( + makeListProjectsQuery({ search }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ + OR: [ + { name: { contains: search } }, + { owner: { email: { contains: search } } }, + ], + }), + ]), + }), + }), + ) + }) + }) + + describe('get', () => { + it('returns ProjectV2 for non-archived project', async () => { + const ctx = makeProjectContext({ status: 'created' }) + const pwd = makeProjectWithDetails({ id: ctx.id, status: 'created' }) + prisma.project.findFirst.mockResolvedValue(pwd) + + const result = await service.get(ctx.id) + + expect(result).toBeDefined() + expect(result.id).toBe(ctx.id) + expect(result.status).toBe('created') + }) + + it('throws NotFoundException for archived project', async () => { + const ctx = makeProjectContext({ status: 'archived' }) + prisma.project.findFirst.mockResolvedValue(null) + + await expect(service.get(ctx.id)).rejects.toThrow(NotFoundException) + }) + + it('throws NotFoundException when project cannot be loaded', async () => { + const ctx = makeProjectContext({ status: 'created' }) + prisma.project.findFirst.mockResolvedValue(null) + + await expect(service.get(ctx.id)).rejects.toThrow(NotFoundException) + }) + }) + + describe('update', () => { + it('updates description and returns updated project', async () => { + const ctx = makeProjectContext({ status: 'created', locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id }) + const updatedPwd = makeProjectWithDetails({ id: ctx.id, description: 'Updated desc' }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(updatedPwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const requestId = faker.string.uuid() + const result = await service.update( + { description: 'Updated desc' }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + requestId, + ) + + expect(result.description).toBe('Updated desc') + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', ctx.id, { + action: 'Update Project', + userId: requestorId, + requestId, + }) + }) + + it('strips locked field for non-admin', async () => { + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: true }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: {}, + }), + ) + }) + + it('allows admin to update locked field', async () => { + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const adminPerms = BigInt(2) + const pwd = makeProjectWithDetails({ id: ctx.id }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: true }, + { userId: requestorId, adminPermissions: adminPerms }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: expect.objectContaining({ locked: true }), + }), + ) + }) + + it('strips ownerId for non-owner non-admin', async () => { + const ownerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { ownerId: faker.string.uuid() }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: {}, + }), + ) + }) + + it('throws ForbiddenException when project is locked and not unlocking', async () => { + const ctx = makeProjectContext({ locked: true }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id, locked: true }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { description: 'test' }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow(ForbiddenException) + }) + + it('allows admin to unlock a locked project', async () => { + const ctx = makeProjectContext({ locked: true }) + const requestorId = faker.string.uuid() + const adminPerms = BigInt(2) + const pwd = makeProjectWithDetails({ id: ctx.id, locked: true }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: false }, + { userId: requestorId, adminPermissions: adminPerms }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalled() + }) + + it('validates new owner is a member of the project', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow('Le nouveau propriétaire doit faire partie des membres actuels du projet') + }) + + it('validates new owner is a human account', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue( + makeProjectWithMembersResult(pwd, [makeProjectMemberWithUser(makeUser({ id: newOwnerId, type: 'bot' }))]), + ) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow('Seuls les comptes humains peuvent être propriétaire de projets') + }) + + it('transfers ownership correctly', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue( + makeProjectWithMembersResult(pwd, [makeProjectMemberWithUser( + makeUser({ id: newOwnerId, type: 'human' }), + { roleIds: [faker.string.uuid()] }, + )]), + ) + tx.project.findUnique.mockResolvedValue( + makeProjectWithDetails({ id: ctx.id, ownerId: newOwnerId }), + ) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.projectMembers.delete).toHaveBeenCalledWith({ + where: { projectId_userId: { userId: newOwnerId, projectId: ctx.id } }, + }) + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + owner: { connect: { id: newOwnerId } }, + }, + }), + ) + expect(result).toBeDefined() + }) + }) + + describe('archive', () => { + it('deletes related data, emits event, renames and archives project', async () => { + const projectId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: projectId, name: 'myproject', slug: 'myproject' }) + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(pwd) + tx.repository.deleteMany.mockResolvedValue({ count: 2 }) + tx.environment.deleteMany.mockResolvedValue({ count: 3 }) + tx.deployment.deleteMany.mockResolvedValue({ count: 1 }) + tx.project.update.mockResolvedValue(makeProject({ id: projectId })) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const requestId = faker.string.uuid() + const requestorId = faker.string.uuid() + + await service.archive(projectId, requestorId, requestId) + + expect(tx.repository.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.environment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.deployment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.delete', pwd, { + action: 'Delete all project resources', + userId: requestorId, + requestId, + }) + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: projectId }, + data: expect.objectContaining({ + status: 'archived', + locked: true, + clusters: { set: [] }, + }), + }), + ) + }) + + it('throws NotFoundException when project does not exist', async () => { + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.archive(faker.string.uuid())) + .rejects.toThrow(NotFoundException) + }) + }) + + describe('getData', () => { + it('returns CSV data array', async () => { + prisma.project.findMany.mockResolvedValue([makeProjectWithDetails()]) + + const result = await service.getProjectsData() + + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(1) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.service.ts new file mode 100644 index 0000000000..cbb6a8cf1c --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.service.ts @@ -0,0 +1,333 @@ +import type { projectContract } from '@cpn-console/shared' +import type { ConfigType } from '@nestjs/config' +import type { Prisma } from '@prisma/client' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectDataExport, ProjectUpdateContext, ProjectWithDetails } from './project-queries.utils' +import { AdminAuthorized } from '@cpn-console/shared' +import { BadRequestException, ForbiddenException, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { baseConfigFactory } from '../../config/base.config' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { LogService } from '../log/log.service' +import { createProjectMember, deleteProjectMember } from '../project-members/project-members-queries.utils' +import { + createProject, + deleteProjectDependencies, + getNotArchivedProjectForUpdate, + getProject, + listProjectSlugsForPrefix as listProjectSlugsForSlugPrefix, + projectForDataSelect, + projectSelect, + updateProject, +} from './project-queries.utils' +import { generateProjectCreateInput, generateProjectWhereInput, generateSlug, parseProjectUpdateInput } from './project.utils' + +@Injectable() +export class ProjectService { + private readonly logger = new Logger(ProjectService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, + @Inject(LogService) private readonly logs: LogService, + ) {} + + @StartActiveSpan() + async getProjectsData(): Promise { + const span = trace.getActiveSpan() + this.logger.log('project.getData requested') + const data = await this.listProjectsForDataExport() + span?.setAttribute('project.data.count', data.length) + this.logger.log(`project.getData completed (count=${data.length})`) + return data + } + + @StartActiveSpan() + async listProjects( + query: typeof projectContract.listProjects.query._type, + user: UserContext, + ): Promise { + const span = trace.getActiveSpan() + const { filter = 'member' } = query + span?.setAttribute('project.list.filter', filter) + span?.setAttribute('user.id', user.userId) + + if (filter === 'all' && !AdminAuthorized.Manage(user.adminPermissions)) { + this.logger.warn(`project.list forbidden (requestorUserId=${user.userId}, filter=${filter})`) + throw new ForbiddenException('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + + const whereAnd = generateProjectWhereInput({ + query, + requestorUserId: user.userId, + appVersion: this.baseConfig.appVersion, + }) + + this.logger.debug(`project.list started (requestorUserId=${user.userId}, filter=${filter})`) + const projects = await this.fetchProjects(whereAnd) + span?.setAttribute('project.list.count', projects.length) + this.logger.debug(`project.list completed (requestorUserId=${user.userId}, filter=${filter}, count=${projects.length})`) + + return projects + } + + @StartActiveSpan() + async get(projectId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.debug(`project.get started (projectId=${projectId})`) + const project = await this.getProjectNotArchived(projectId) + if (!project) { + this.logger.warn(`project.get notFound (projectId=${projectId})`) + throw new NotFoundException('Projet introuvable') + } + this.logger.debug(`project.get completed (projectId=${projectId})`) + return project + } + + @StartActiveSpan() + async create( + body: typeof projectContract.createProject.body._type, + requestorUserId: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('user.id', requestorUserId) + this.logger.log(`project.create started (requestorUserId=${requestorUserId}, projectName=${body.name})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const existingSlugs = await listProjectSlugsForSlugPrefix(tx, body.name) + const slug = generateSlug(body.name, existingSlugs.map(s => s.slug)) + + const created = await createProject(tx, generateProjectCreateInput(body, requestorUserId, slug)) + const loaded = await getProject(tx, created.id) + if (!loaded) throw new InternalServerErrorException('Project created but cannot be loaded') + return loaded + }) + await this.appEvents.emitProjectEvent('project.upsert', project.id, { + action: 'Create Project', + userId: requestorUserId, + requestId, + }) + await Promise.all(project.roles.map(async role => this.logProjectRoleAction( + 'Upsert Project Role', + project, + role.id, + `Rôle synchronisé: ${role.name}`, + requestorUserId, + requestId, + ))) + span?.setAttribute('project.id', project.id) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.create completed (requestorUserId=${requestorUserId}, projectId=${project.id}, slug=${project.slug})`) + return project + } catch (error) { + this.logger.error( + `project.create failed (requestorUserId=${requestorUserId}, projectName=${body.name}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + async update( + body: typeof projectContract.updateProject.body._type, + user: UserContext, + projectId: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('user.id', user.userId) + this.logger.log(`project.update started (projectId=${projectId}, requestorUserId=${user.userId})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const projectDb = await getNotArchivedProjectForUpdate(tx, projectId) + if (!projectDb) throw new NotFoundException('Projet introuvable') + + const { effectiveData, strippedKeys } = this.stripProjectUpdateBody(body, user, projectDb) + this.logProjectUpdateStrippedFields(span, projectId, user.userId, strippedKeys) + if (projectDb.locked && effectiveData.locked !== false) { + throw new ForbiddenException('Veuillez déverrouiller le projet pour le mettre à jour') + } + + await this.updateProjectOwnerIfNeeded(tx, projectDb, effectiveData, projectId, user.userId) + + const updateData = parseProjectUpdateInput(effectiveData) + const effectiveKeys = Object.keys(effectiveData) + span?.setAttribute('project.update.effectiveKeys.count', effectiveKeys.length) + await updateProject(tx, projectId, updateData) + + const updated = await getProject(tx, projectId) + if (!updated) throw new NotFoundException('Projet introuvable') + this.logger.log(`project.update dbUpdated (projectId=${projectId}, requestorUserId=${user.userId}, effectiveKeys=${effectiveKeys.join(',')})`) + return updated + }) + await this.appEvents.emitProjectEvent('project.upsert', project.id, { + action: 'Update Project', + userId: user.userId, + requestId, + }) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.update completed (projectId=${projectId}, requestorUserId=${user.userId})`) + return project + } catch (error) { + this.logger.error( + `project.update failed (projectId=${projectId}, requestorUserId=${user.userId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async archive(projectId: string, requestorUserId?: string, requestId?: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.archive started (projectId=${projectId})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const loaded = await getProject(tx, projectId) + if (!loaded) throw new NotFoundException('Projet introuvable') + + await deleteProjectDependencies(tx, projectId) + + const archivedSuffix = `${Date.now()}_archived` + await updateProject(tx, projectId, { + name: `${loaded.name}_${archivedSuffix}`, + slug: `${loaded.slug}_${archivedSuffix}`, + status: 'archived', + locked: true, + clusters: { set: [] }, + }) + + return loaded + }) + // pass the pre-archive snapshot: the row was renamed (slug suffixed) in the + // transaction above, listeners must clean up resources named after the old slug + await this.appEvents.emitProjectEvent('project.delete', project, { + action: 'Delete all project resources', + userId: requestorUserId, + requestId, + }) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.archive completed (projectId=${projectId}, slug=${project.slug})`) + } catch (error) { + this.logger.error( + `project.archive failed (projectId=${projectId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async logProjectRoleAction( + action: string, + project: ProjectWithDetails, + roleId: string, + messageResume: string, + userId: string | undefined, + requestId: string | undefined, + ): Promise { + await this.logs.addLog({ + action, + data: { + args: { projectId: project.id, roleId }, + messageResume, + results: { projectId: project.id, roleId }, + }, + userId, + requestId, + projectId: project.id, + }) + } + + private async listProjectsForDataExport(): Promise { + return this.prisma.project.findMany({ + select: projectForDataSelect, + }) + } + + private async fetchProjects(whereAnd: Prisma.ProjectWhereInput[]): Promise { + return this.prisma.project.findMany({ + where: { AND: whereAnd }, + select: projectSelect, + }) + } + + private async getProjectNotArchived(projectId: string): Promise { + return this.prisma.project.findFirst({ + where: { id: projectId, status: { not: 'archived' } }, + select: projectSelect, + }) + } + + private stripProjectUpdateBody( + body: typeof projectContract.updateProject.body._type, + user: UserContext, + project: ProjectUpdateContext, + ): { effectiveData: Record, strippedKeys: string[] } { + const isOwner = project.ownerId === user.userId + const isAdmin = AdminAuthorized.Manage(user.adminPermissions) + const effectiveData: Record = { ...body } + const strippedKeys: string[] = [] + + if (!isAdmin) { + if ('locked' in effectiveData) strippedKeys.push('locked') + delete effectiveData.locked + + if (!isOwner) { + if ('ownerId' in effectiveData) strippedKeys.push('ownerId') + delete effectiveData.ownerId + } + } + + return { effectiveData, strippedKeys } + } + + private logProjectUpdateStrippedFields( + span: ReturnType, + projectId: string, + requestorUserId: string, + strippedKeys: string[], + ): void { + if (!strippedKeys.length) return + + span?.setAttribute('project.update.strippedKeys.count', strippedKeys.length) + this.logger.debug(`project.update strippedFields (projectId=${projectId}, requestorUserId=${requestorUserId}, strippedKeys=${strippedKeys.join(',')})`) + } + + private async updateProjectOwnerIfNeeded( + tx: Prisma.TransactionClient, + project: ProjectUpdateContext, + effectiveData: Record, + projectId: string, + requestorUserId: string, + ): Promise { + const ownerIdCandidate = effectiveData.ownerId as string | undefined + if (!ownerIdCandidate || ownerIdCandidate === project.ownerId) return + + this.logger.log(`project.update ownerChange started (projectId=${projectId}, requestorUserId=${requestorUserId}, previousOwnerId=${project.ownerId}, nextOwnerId=${ownerIdCandidate})`) + + const memberCandidate = project.members.find(member => member.userId === ownerIdCandidate) + if (!memberCandidate) { + throw new BadRequestException('Le nouveau propriétaire doit faire partie des membres actuels du projet') + } + if (memberCandidate.user.type !== 'human') { + throw new BadRequestException('Seuls les comptes humains peuvent être propriétaire de projets') + } + + const oldOwnerIsMember = project.members.some(member => member.userId === project.ownerId) + if (!oldOwnerIsMember) { + await createProjectMember(tx, project.id, project.ownerId) + } + + await deleteProjectMember(tx, project.id, ownerIdCandidate) + await updateProject(tx, project.id, { owner: { connect: { id: ownerIdCandidate } } }) + + this.logger.log(`project.update ownerChange completed (projectId=${projectId}, requestorUserId=${requestorUserId}, previousOwnerId=${project.ownerId}, nextOwnerId=${ownerIdCandidate})`) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.utils.ts new file mode 100644 index 0000000000..bb50d21fdf --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/project/project.utils.ts @@ -0,0 +1,226 @@ +import type { CreateProjectBody, projectContract } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import type { ProjectWithDetails } from './project-queries.utils' +import { PROJECT_PERMS, ProjectSchemaV2, ProjectStatusSchema } from '@cpn-console/shared' +import { ProjectStatus } from '@prisma/client' +import { z } from 'zod' + +export function generateSlug(prefix: string, existingSlugs: string[] = []) { + if (!existingSlugs.includes(prefix)) return prefix + + let suffix = 1 + while (existingSlugs.includes(`${prefix}-${suffix}`)) { + suffix += 1 + } + return `${prefix}-${suffix}` +} + +export function generateProjectCreateInput( + data: CreateProjectBody, + ownerId: string, + slug: string, +): Prisma.ProjectCreateInput { + return { + name: data.name, + slug, + description: data.description ?? '', + status: ProjectStatus.created, + locked: false, + limitless: z.boolean().parse(data.limitless), + hprodCpu: data.hprodCpu, + hprodGpu: data.hprodGpu, + hprodMemory: data.hprodMemory, + prodCpu: data.prodCpu, + prodGpu: data.prodGpu, + prodMemory: data.prodMemory, + owner: { connect: { id: ownerId } }, + roles: { + create: [ + { + name: 'Administrateur', + permissions: PROJECT_PERMS.MANAGE, + position: 0, + oidcGroup: `/${slug}/console/admin`, + type: 'system:managed', + }, + { + name: 'DevOps', + permissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS + | PROJECT_PERMS.MANAGE_REPOSITORIES + | PROJECT_PERMS.REPLAY_HOOKS + | PROJECT_PERMS.SEE_SECRETS + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 1, + oidcGroup: `/${slug}/console/devops`, + type: 'system:managed', + }, + { + name: 'Développeur', + permissions: PROJECT_PERMS.MANAGE_REPOSITORIES + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 2, + oidcGroup: `/${slug}/console/developer`, + type: 'system:managed', + }, + { + name: 'Lecture seule', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 3, + oidcGroup: `/${slug}/console/readonly`, + type: 'system:managed', + }, + ], + }, + } +} + +export const ProjectV2ResponseSchema = ProjectSchemaV2.omit({ name: true }).extend({ + name: z.string(), +}) + +export function generateProjectV2(project: ProjectWithDetails) { + const payload = { + id: project.id, + name: project.name, + slug: project.slug, + description: project.description, + status: project.status, + locked: project.locked, + limitless: project.limitless, + hprodCpu: project.hprodCpu, + hprodGpu: project.hprodGpu, + hprodMemory: project.hprodMemory, + prodCpu: project.prodCpu, + prodGpu: project.prodGpu, + prodMemory: project.prodMemory, + everyonePerms: project.everyonePerms, + ownerId: project.ownerId, + owner: project.owner, + members: project.members.map(m => ({ + userId: m.user.id, + email: m.user.email, + firstName: m.user.firstName, + lastName: m.user.lastName, + roleIds: m.roleIds, + })), + roles: project.roles.map(role => ({ + id: role.id, + name: role.name, + permissions: role.permissions, + position: role.position, + projectId: role.projectId, + oidcGroup: role.oidcGroup ? role.oidcGroup.replace(`/${project.slug}`, '') : '', + type: role.type, + })), + clusterIds: project.clusters.map(c => c.id), + lastSuccessProvisionningVersion: project.lastSuccessProvisionningVersion, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + } + + return ProjectV2ResponseSchema.parse(payload) +} + +export function generateProjectWhereInput(opts: { + query: typeof projectContract.listProjects.query._type + requestorUserId: string + appVersion: string +}): Prisma.ProjectWhereInput[] { + const projectStatus = ProjectStatusSchema.options + const { status, statusIn, statusNotIn, filter = 'member', ...rest } = opts.query + + const whereAnd: Prisma.ProjectWhereInput[] = [] + if (rest.id) whereAnd.push({ id: rest.id }) + if (rest.locked !== undefined) whereAnd.push({ locked: rest.locked }) + if (rest.name) whereAnd.push({ name: rest.name }) + if (rest.description) whereAnd.push({ description: { contains: rest.description } }) + + const statusWhere = parseEnumWhereFilter({ + enumValues: projectStatus, + eqValue: status, + inValues: statusIn, + notInValues: statusNotIn, + }) + if (statusWhere) whereAnd.push({ status: statusWhere }) + + if (rest.lastSuccessProvisionningVersion) { + if (rest.lastSuccessProvisionningVersion === 'outdated') { + whereAnd.push({ lastSuccessProvisionningVersion: { not: opts.appVersion } }) + } else if (rest.lastSuccessProvisionningVersion === 'last') { + whereAnd.push({ lastSuccessProvisionningVersion: { equals: opts.appVersion } }) + } else { + whereAnd.push({ lastSuccessProvisionningVersion: rest.lastSuccessProvisionningVersion }) + } + } + + if (rest.search) { + whereAnd.push({ + OR: [ + { name: { contains: rest.search } }, + { owner: { email: { contains: rest.search } } }, + ], + }) + } + + if (filter === 'owned') { + whereAnd.push({ ownerId: opts.requestorUserId }) + } else if (filter === 'member') { + whereAnd.push({ + OR: [ + { members: { some: { userId: opts.requestorUserId } } }, + { ownerId: opts.requestorUserId }, + ], + }) + } + + return whereAnd +} + +export function parseEnumWhereFilter({ + enumValues, + eqValue, + inValues, + notInValues, +}: { + enumValues: T + eqValue: T[number] | undefined + inValues: string | undefined + notInValues: string | undefined +}): + | T[number] + | { in: T[number][] } + | { notIn: T[number][] } + | undefined { + if (eqValue) { + return eqValue + } + if (inValues) { + return { in: parseCsvEnumList(enumValues, inValues) } + } + if (notInValues) { + return { notIn: parseCsvEnumList(enumValues, notInValues) } + } +} + +const ProjectUpdateDataSchema = z.object({ + description: z.string().optional(), + locked: z.boolean().optional(), + limitless: z.boolean().optional(), + hprodCpu: z.number().optional(), + hprodGpu: z.number().optional(), + hprodMemory: z.number().optional(), + prodCpu: z.number().optional(), + prodGpu: z.number().optional(), + prodMemory: z.number().optional(), + everyonePerms: z.union([z.string(), z.number(), z.bigint()]).transform(BigInt).optional(), +}).passthrough() + +export function parseProjectUpdateInput(effectiveData: Record): Prisma.ProjectUpdateInput { + return ProjectUpdateDataSchema.parse(effectiveData) satisfies Prisma.ProjectUpdateInput +} + +function parseCsvEnumList(toMatch: T, inputs: string): T[number][] { + return inputs.split(',').filter(i => toMatch.includes(i)) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts new file mode 100644 index 0000000000..f9e676753d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts @@ -0,0 +1,22 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface HarborConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + ruleTemplate?: string + ruleCount?: string + retentionCron: string + robotRotationThresholdDays: number + projectSlugCacheTtlMs: number + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface HarborModuleOptions extends HarborConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('harbor') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as HARBOR_CONFIG } diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts new file mode 100644 index 0000000000..057bd83f04 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts @@ -0,0 +1,96 @@ +import type { HarborConfig } from './harbor.module-definition' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { VaultClientService } from '../vault/vault-client.service' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryHttpClientService } from './registry-http-client.service' + +const harborUrl = 'https://harbor.example' +const harborAdminPassword = faker.internet.password() +const basicAuth = `Basic ${Buffer.from(`admin:${harborAdminPassword}`, 'utf8').toString('base64')}` + +const server = setupServer() + +describe('registryService', () => { + let service: RegistryClientService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + beforeEach(async () => { + const harborConfig = mockDeep({ + url: harborUrl, + internalUrl: harborUrl, + internalOrPublicUrl: harborUrl, + admin: 'admin', + adminPassword: harborAdminPassword, + ruleTemplate: 'latestPushedK', + ruleCount: '10', + retentionCron: '0 22 2 * * *', + }) + + const module = await Test.createTestingModule({ + providers: [ + RegistryClientService, + RegistryHttpClientService, + { + provide: VaultClientService, + useValue: {}, + }, + { + provide: HARBOR_CONFIG, + useValue: harborConfig, + }, + ], + }).compile() + service = module.get(RegistryClientService) + }) + + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should send basic auth and JSON body on createProject', async () => { + server.use( + http.post(`${harborUrl}/api/v2.0/projects`, async ({ request }) => { + expect(request.method).toBe('POST') + expect(request.url).toBe(`${harborUrl}/api/v2.0/projects`) + expect(request.headers.get('accept')).toBe('application/json') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('content-type')).toContain('application/json') + expect(await request.json()).toEqual({ + project_name: 'myproj', + metadata: { auto_scan: 'true' }, + storage_limit: -1, + }) + return HttpResponse.json({}, { status: HttpStatus.CREATED }) + }), + ) + + await service.createProject('myproj', -1) + }) + + it('should send X-Is-Resource-Name on getProjectByName', async () => { + server.use( + http.get(`${harborUrl}/api/v2.0/projects/:projectName`, async ({ request, params }) => { + expect(request.method).toBe('GET') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('x-is-resource-name')).toBe('true') + expect(params.projectName).toBe('myproj') + return HttpResponse.json({ project_id: 123, metadata: {} }) + }), + ) + + const res = await service.getProjectByName('myproj') + + expect(res).toMatchObject({ status: HttpStatus.OK, data: { project_id: 123 } }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-client.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-client.service.ts new file mode 100644 index 0000000000..0e85eff9c9 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-client.service.ts @@ -0,0 +1,226 @@ +import type { RegistryQuery, RegistryResponse } from './registry-http-client.service' +import { Inject, Injectable } from '@nestjs/common' +import { RegistryHttpClientService } from './registry-http-client.service' +import { ROBOT_LIST_PAGE_SIZE } from './registry.constants' + +export const roAccess: HarborAccess[] = [ + { resource: 'repository', action: 'pull' }, + { resource: 'artifact', action: 'read' }, +] + +export const rwAccess: HarborAccess[] = [ + ...roAccess, + { resource: 'repository', action: 'list' }, + { resource: 'tag', action: 'list' }, + { resource: 'artifact', action: 'list' }, + { resource: 'scan', action: 'create' }, + { resource: 'scan', action: 'stop' }, + { resource: 'repository', action: 'push' }, + { resource: 'artifact-label', action: 'create' }, + { resource: 'artifact-label', action: 'delete' }, + { resource: 'tag', action: 'create' }, + { resource: 'tag', action: 'delete' }, +] + +export interface HarborAccess { + resource: string + action: string +} + +export interface HarborProject { + project_id?: number + metadata?: { + retention_id?: number | string + } +} + +export interface HarborRobot { + id?: number + name?: string +} + +export interface HarborRobotCreated { + id?: number + name: string + secret: string +} + +export interface HarborMember { + id?: number + entity_name?: string + entity_type?: string + role_id?: number +} + +export interface HarborGroupMemberRequest { + role_id: number + member_group: { + group_name: string + group_type: number + } +} + +export interface HarborProjectQuota { + ref?: { id?: number } + hard?: { storage?: number } +} + +export interface HarborRobotPermission { + namespace: string + kind: 'project' + access: HarborAccess[] +} + +export interface HarborRobotCreateRequest { + name: string + duration: number + description: string + disable: boolean + level: 'project' + permissions: HarborRobotPermission[] +} + +export interface HarborRetentionRule { + disabled: boolean + action: 'retain' + template: string + params: Record + tag_selectors: Array<{ kind: string, decoration: string, pattern: string }> + scope_selectors: { + repository: Array<{ kind: string, decoration: string, pattern: string }> + } +} + +export interface HarborRetentionPolicy { + algorithm: 'or' | 'and' + scope: { level: 'project', ref: number } + rules: HarborRetentionRule[] + trigger: { + kind: 'Schedule' + settings: { cron?: string } + references: unknown[] + } +} + +@Injectable() +export class RegistryClientService { + constructor( + @Inject(RegistryHttpClientService) private readonly http: RegistryHttpClientService, + ) {} + + async getProjectByName(projectName: string): Promise> { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async createProject(projectName: string, storageLimit: number) { + return this.http.fetch('projects', { + method: 'POST', + body: { + project_name: projectName, + metadata: { auto_scan: 'true' }, + storage_limit: storageLimit, + }, + }) + } + + async deleteProjectByName(projectName: string) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async listQuotas(projectId: number) { + return this.http.fetch(`quotas?reference_id=${encodeURIComponent(String(projectId))}`, { + method: 'GET', + }) + } + + async updateQuota(projectId: number, storageLimit: number) { + return this.http.fetch(`quotas/${encodeURIComponent(String(projectId))}`, { + method: 'PUT', + body: { + hard: { + storage: storageLimit, + }, + }, + }) + } + + async getGroupMembers(projectName: string) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async addGroupMember(projectName: string, body: HarborGroupMemberRequest) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members`, { + method: 'POST', + headers: { 'X-Is-Resource-Name': 'true' }, + body, + }) + } + + async removeGroupMember(projectName: string, memberId: number) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members/${encodeURIComponent(String(memberId))}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + getProjectRobots(projectId: number): AsyncGenerator { + return this.paginate('robots', { + q: `Level=project,ProjectID=${projectId}`, + }) + } + + async createRobot(body: HarborRobotCreateRequest) { + return this.http.fetch('robots', { + method: 'POST', + body, + }) + } + + async deleteRobot(robotId: number): Promise { + return this.http.fetch(`robots/${encodeURIComponent(String(robotId))}`, { + method: 'DELETE', + }) + } + + async getRetentionId(projectName: string): Promise { + const project = await this.getProjectByName(projectName) + if (project.status !== 200 || !project.data) return null + const retentionId = Number(project.data?.metadata?.retention_id) + return Number.isFinite(retentionId) ? retentionId : null + } + + async createRetention(body: HarborRetentionPolicy) { + return this.http.fetch('retentions', { + method: 'POST', + body, + }) + } + + async updateRetention(retentionId: number, body: HarborRetentionPolicy) { + return this.http.fetch(`retentions/${encodeURIComponent(String(retentionId))}`, { + method: 'PUT', + body, + }) + } + + private async* paginate(path: string, query: RegistryQuery): AsyncGenerator { + for (let page = 1; ; page++) { + const response = await this.http.fetch(path, { + method: 'GET', + query: { ...query, page, page_size: ROBOT_LIST_PAGE_SIZE }, + }) + if (response.status !== 200 || !response.data?.length) return + yield* response.data + if (response.data.length < ROBOT_LIST_PAGE_SIZE) return + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts new file mode 100644 index 0000000000..9d0a511ef6 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts @@ -0,0 +1,62 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './registry.constants' + +export const projectSelect = { + slug: true, + plugins: { + where: { + pluginName: PLUGIN_NAME, + }, + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class RegistryDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return await this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return await this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-health.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-health.service.ts new file mode 100644 index 0000000000..4ffab35aad --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -0,0 +1,27 @@ +import type { HarborConfig } from './harbor.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { HARBOR_CONFIG } from './harbor.module-definition' + +@Injectable() +export class RegistryHealthService { + constructor( + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const credentials = `${this.harborConfig.admin}:${this.harborConfig.adminPassword}` + const base64 = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${base64}` + try { + const response = await fetch(this.harborConfig.probeUrl, { method: 'GET', headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts new file mode 100644 index 0000000000..bc3848b0cd --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts @@ -0,0 +1,109 @@ +import type { HarborConfig } from './harbor.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { encodeBasicAuth } from './registry.utils' + +export type RegistryQuery = Record + +export interface RegistryFetchOptions { + method?: string + headers?: Record + body?: unknown + query?: RegistryQuery +} + +export interface RegistryResponse { + status: HttpStatus + data: T | null +} + +export type RegistryErrorKind + = | 'NotConfigured' + | 'Unexpected' + +export class RegistryError extends Error { + readonly kind: RegistryErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + + constructor( + kind: RegistryErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string } = {}, + ) { + super(message) + this.name = 'RegistryError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + } +} + +@Injectable() +export class RegistryHttpClientService { + constructor( + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + ) {} + + private get apiBaseUrl() { + return new URL('api/v2.0/', this.harborConfig.internalOrPublicUrl).toString() + } + + private get defaultHeaders() { + return { Accept: 'application/json', Authorization: `Basic ${encodeBasicAuth(this.harborConfig.admin, this.harborConfig.adminPassword)}` } + } + + async fetch( + path: string, + options: RegistryFetchOptions = {}, + ): Promise> { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('registry.method', method) + span?.setAttribute('registry.path', path) + + const request = this.createRequest(path, method, options.body, options.headers, options.query) + const response = await fetch(request).catch((error) => { + throw new RegistryError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('registry.http.status', response.status) + return await handleResponse(response) + } + + private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record, query?: RegistryQuery): Request { + const url = new URL(path, this.apiBaseUrl) + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, String(value)) + } + } + const headers: Record = { + ...this.defaultHeaders, + ...extraHeaders, + } + let requestBody: string | undefined + if (body !== undefined) { + requestBody = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + return new Request(url, { method, headers, body: requestBody }) + } +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts new file mode 100644 index 0000000000..b4d14b009f --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts @@ -0,0 +1,77 @@ +import type { Cache } from 'cache-manager' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { HarborConfig } from './harbor.module-definition' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { RegistryPluginService } from './registry-plugin.service' +import { makeProjectWithDetails } from './registry-testing.utils' + +describe('registryPluginService', () => { + let service: RegistryPluginService + let harborConfig: DeepMockProxy + let datastore: DeepMockProxy + let registryClient: DeepMockProxy + let cache: DeepMockProxy + + beforeEach(async () => { + harborConfig = mockDeep({ + url: 'https://harbor.example/', + projectSlugCacheTtlMs: 300000, + }) + datastore = mockDeep() + registryClient = mockDeep() + cache = mockDeep() + + const moduleRef = await Test.createTestingModule({ + providers: [ + RegistryPluginService, + { provide: HARBOR_CONFIG, useValue: harborConfig }, + { provide: RegistryDatastoreService, useValue: datastore }, + { provide: RegistryClientService, useValue: registryClient }, + { provide: CACHE_MANAGER, useValue: cache }, + ], + }).compile() + + service = moduleRef.get(RegistryPluginService) + }) + + it('uses the stored Harbor project id when available', async () => { + cache.get.mockResolvedValue('dulei') + registryClient.getProjectByName.mockResolvedValue({ + status: HttpStatus.OK, + data: { project_id: 144, metadata: {} }, + }) + const infos = await service.infos('dulei') + const url = infos.to?.(makeToUrlParams({ + project: { id: '', name: '', slug: 'dulei' }, + store: { registry: { projectId: '144' } }, + })) + + expect(url).toBe('https://harbor.example/harbor/projects/144/') + expect(datastore.getProject).not.toHaveBeenCalled() + expect(registryClient.getProjectByName).toHaveBeenCalledWith('dulei') + }) + + it('falls back to Harbor lookup when the store is empty', async () => { + cache.get.mockResolvedValue(undefined) + datastore.getProject.mockResolvedValue(makeProjectWithDetails({ slug: 'dulei' })) + registryClient.getProjectByName.mockResolvedValue({ + status: HttpStatus.OK, + data: { project_id: 144, metadata: {} }, + }) + + const infos = await service.infos('dulei') + const url = infos.to?.(makeToUrlParams()) + + expect(registryClient.getProjectByName).toHaveBeenCalledWith('dulei') + expect(url).toBe('https://harbor.example/harbor/projects/144/') + expect(cache.set).toHaveBeenCalledWith('registry:project-slug:dulei', 'dulei', 300000) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts new file mode 100644 index 0000000000..e502423c5f --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts @@ -0,0 +1,143 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { Cache } from 'cache-manager' +import type { HarborConfig } from './harbor.module-definition' +import { DISABLED } from '@cpn-console/shared' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { createProjectSlugCacheKey } from './registry.utils' + +@Injectable() +export class RegistryPluginService { + private readonly logger = new Logger(RegistryPluginService.name) + + constructor( + @Inject(HARBOR_CONFIG) + private readonly harborConfig: HarborConfig, + @Inject(RegistryDatastoreService) + private readonly datastore: RegistryDatastoreService, + @Inject(RegistryClientService) + private readonly registryClient: RegistryClientService, + @Inject(CACHE_MANAGER) + private readonly cache: Cache, + ) {} + + private async resolveProjectSlug(projectId: string): Promise { + const cacheKey = createProjectSlugCacheKey(projectId) + const cached = await this.cache.get(cacheKey) + if (cached !== undefined) return cached ?? undefined + + const project = await this.datastore.getProject(projectId) + const slug = project?.slug ?? null + await this.cache.set(cacheKey, slug, this.harborConfig.projectSlugCacheTtlMs) + return slug ?? undefined + } + + private async resolveHarborProjectId(projectSlug: string): Promise { + try { + const harborProject = await this.registryClient.getProjectByName(projectSlug) + const harborProjectId = Number(harborProject.data?.project_id) + if (harborProject.status !== 200 || !Number.isFinite(harborProjectId)) { + return undefined + } + this.logger.log(`Successfully resolve harbor project id for project slug ${projectSlug}: ${harborProjectId}`) + return harborProjectId + } catch (error) { + this.logger.error(`Failed to resolve harbor project id for project slug ${projectSlug}: ${error}`) + return undefined + } + } + + private resolveHarborProjectUrl(harborProjectId: number): string { + return new URL(`harbor/projects/${harborProjectId}/`, this.harborConfig.url).toString() + } + + private async resolveProjectUrl(projectId: string): Promise { + const projectSlug = await this.resolveProjectSlug(projectId) + if (!projectSlug) { + return undefined + } + + const harborProjectId = await this.resolveHarborProjectId(projectSlug) + if (harborProjectId === undefined) { + return undefined + } + + return this.resolveHarborProjectUrl(harborProjectId) + } + + async infos(projectId: string): Promise { + const quotaDescription = '-1 -> illimité, sinon 100MB / 1.2GB (unités : B, KB, MB, GB, TB)), max 1024TB' + const projectUrl = await this.resolveProjectUrl(projectId) + if (!projectUrl) { + throw new Error('Project not found') + } + + return { + name: 'registry', + to: () => projectUrl, + title: 'Harbor', + imgSrc: '/img/harbor.svg', + description: 'Harbor stocke, analyse et distribue vos images de conteneurs', + config: { + project: [{ + permissions: { + admin: { read: false, write: false }, + user: { read: false, write: false }, + }, + key: 'projectId', + kind: 'text', + title: 'Num du projet Harbor', + value: '', + }, { + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot projet', + description: 'Activer le robot de projet (read-only) et afficher ses identifiants aux utilisateurs', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota', + value: '', + description: `Stockage limite (vide utilisation du paramètre global, ${quotaDescription}`, + placeholder: '', + }], + global: [{ + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot RO aux projets', + description: 'Définit le comportement en l\'absence de ce paramétrage au niveau projet', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota par défaut', + value: '-1', + description: `Stockage limite par projet (${quotaDescription}`, + placeholder: '-1', + }], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts new file mode 100644 index 0000000000..c04eae0429 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts @@ -0,0 +1,24 @@ +import type { ProjectWithDetails } from './registry-datastore.service' +import type { RegistryResponse } from './registry-http-client.service' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' + +export function makeOkResponse(data: T): RegistryResponse { + return { status: HttpStatus.OK, data } +} + +export function makeCreatedResponse(data: T): RegistryResponse { + return { status: HttpStatus.CREATED, data } +} + +export function makeNoContent(): RegistryResponse { + return { status: HttpStatus.NO_CONTENT, data: null } +} + +export function makeProjectWithDetails(overrides: Partial = {}) { + return { + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.constants.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.constants.ts new file mode 100644 index 0000000000..e4c054daab --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.constants.ts @@ -0,0 +1,52 @@ +// Registry plugin identification +export const PLUGIN_NAME = 'harbor' + +// Registry configuration keys +export const REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT = 'quotaHardLimit' +export const REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT = 'publishProjectRobot' + +// Default platform-level group paths +export const DEFAULT_PLATFORM_ADMIN_GROUP_PATHS = '/console/admin' +export const DEFAULT_PLATFORM_GUEST_GROUP_PATHS = '/console/security,/console/readonly' + +// Default project-level group path suffixes +export const DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES = '/console/admin' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES = '/console/developer' +export const DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES = '/console/security,/console/readonly' + +// Platform group path plugin configuration keys +export const PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY = 'platformAdminGroupPath' +export const PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY = 'platformGuestGroupPaths' + +// Project group path suffixes plugin configuration keys +export const PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectAdminGroupPathSuffixes' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectMaintainerGroupPathSuffixes' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectDeveloperGroupPathSuffixes' +export const PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectGuestGroupPathSuffixes' + +// Registry robot name identifiers +export const ROBOT_NAME_RO = 'ro-robot' +export const ROBOT_NAME_RW = 'rw-robot' +export const ROBOT_NAME_PROJECT = 'project-robot' + +// Page size used when listing robots. A project only ever owns a handful of +// robots (ro, rw and optionally the project robot — 3 in practice), so a single +// page is plenty; this simply bounds each pagination request. +export const ROBOT_LIST_PAGE_SIZE = 100 + +// Allowed Harbor retention rule templates +export const ALLOWED_RETENTION_RULE_TEMPLATES = [ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +] as const + +// Harbor role identifiers +export const HARBOR_ROLE_PROJECT_ADMIN = 1 +export const HARBOR_ROLE_DEVELOPER = 2 +export const HARBOR_ROLE_GUEST = 3 +export const HARBOR_ROLE_MAINTAINER = 4 +export const HARBOR_ROLE_LIMITED_GUEST = 5 diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.module-definition.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.module-definition.ts new file mode 100644 index 0000000000..c994e3c6cc --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.module-definition.ts @@ -0,0 +1,21 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface RegistryConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + ruleTemplate: string + ruleCount?: number + retentionCron: string + robotRotationThresholdDays: number + projectSlugCacheTtlMs: number + internalOrPublicUrl?: string +} + +export interface RegistryModuleOptions extends RegistryConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('registry') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as REGISTRY_CONFIG } diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.module.ts new file mode 100644 index 0000000000..c93e994bce --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.module.ts @@ -0,0 +1,23 @@ +import { CacheModule } from '@nestjs/cache-manager' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { ConfigurableModuleClass as HarborConfigurableModuleClass } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { RegistryHealthService } from './registry-health.service' +import { RegistryHttpClientService } from './registry-http-client.service' +import { RegistryPluginService } from './registry-plugin.service' +import { ConfigurableModuleClass } from './registry.module-definition' +import { RegistryService } from './registry.service' + +@Module({}) +export class HarborModule extends HarborConfigurableModuleClass {} + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule, HarborModule, CacheModule.register()], + providers: [RegistryHealthService, RegistryPluginService, RegistryService, RegistryDatastoreService, RegistryHttpClientService, RegistryClientService], + exports: [RegistryHealthService, RegistryPluginService, RegistryService], +}) +export class RegistryModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.service.spec.ts new file mode 100644 index 0000000000..94cf3b9d05 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -0,0 +1,322 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { HarborConfig } from './harbor.module-definition' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from '../vault/vault-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { makeCreatedResponse, makeNoContent, makeOkResponse, makeProjectWithDetails } from './registry-testing.utils' +import { + PLUGIN_NAME, + REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, + REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, + ROBOT_NAME_PROJECT, +} from './registry.constants' +import { RegistryService } from './registry.service' + +describe('registryService', () => { + let service: RegistryService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let harborConfig: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + getProjectByName: vi.fn().mockResolvedValue(makeOkResponse({ project_id: 123, metadata: {} })), + listQuotas: vi.fn().mockResolvedValue(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])), + getRetentionId: vi.fn().mockResolvedValue(null), + createRetention: vi.fn().mockResolvedValue(makeCreatedResponse(null)), + getGroupMembers: vi.fn().mockResolvedValue(makeOkResponse([])), + getProjectRobots: vi.fn(async function* () {}), + addGroupMember: vi.fn().mockResolvedValue(makeCreatedResponse(null)), + removeGroupMember: vi.fn().mockResolvedValue(makeNoContent()), + deleteProjectByName: vi.fn().mockResolvedValue(makeNoContent()), + }) + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + read: vi.fn().mockResolvedValue(makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: 'robot$myproj+ro-robot', + TOKEN: 'secret', + }, + })), + write: vi.fn().mockResolvedValue(undefined), + }) + harborConfig = mockDeep({ + url: 'https://harbor.example', + internalUrl: 'https://harbor.example', + admin: 'admin', + adminPassword: faker.internet.password(), + ruleTemplate: 'latestPushedK', + ruleCount: '10', + retentionCron: '0 22 2 * * *', + robotRotationThresholdDays: 90, + }) + baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep({}) + + const module = await Test.createTestingModule({ + providers: [ + RegistryService, + { provide: RegistryClientService, useValue: client }, + { provide: RegistryDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: HARBOR_CONFIG, useValue: harborConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + ], + }).compile() + + service = module.get(RegistryService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('adds expected Harbor group memberships based on defaults', async () => { + const project = makeProjectWithDetails() + + await service.handleUpsert(project) + + const expected = [ + { groupName: `/${project.slug}`, roleId: 5 }, + { groupName: '/console/admin', roleId: 1 }, + { groupName: '/console/readonly', roleId: 3 }, + { groupName: '/console/security', roleId: 3 }, + { groupName: `/${project.slug}/console/readonly`, roleId: 3 }, + { groupName: `/${project.slug}/console/security`, roleId: 3 }, + { groupName: `/${project.slug}/console/developer`, roleId: 3 }, + { groupName: `/${project.slug}/console/devops`, roleId: 3 }, + { groupName: `/${project.slug}/console/admin`, roleId: 2 }, + ] + + expect(client.addGroupMember).toHaveBeenCalledTimes(expected.length) + for (const e of expected) { + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: e.roleId, + member_group: { + group_name: e.groupName, + group_type: 3, + }, + }) + } + }) + + it('reconciles an existing group membership when role differs', async () => { + const project = makeProjectWithDetails() + client.getGroupMembers.mockResolvedValueOnce(makeOkResponse([ + { id: 10, entity_name: `/${project.slug}/console/admin`, entity_type: 'g', role_id: 3 }, + ])) + + await service.handleUpsert(project) + + expect(client.removeGroupMember).toHaveBeenCalledWith(project.slug, 10) + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: 2, + member_group: { + group_name: `/${project.slug}/console/admin`, + group_type: 3, + }, + }) + }) + + it('returns a KO result when project admin membership creation fails', async () => { + const project = makeProjectWithDetails() + client.addGroupMember.mockImplementation(async (_projectName, body) => { + if (body.member_group.group_name === `/${project.slug}/console/admin` && body.role_id === 2) { + return { status: HttpStatus.BAD_REQUEST, data: null } + } + return { status: HttpStatus.CREATED, data: null } + }) + + await expect(service.handleUpsert(project)).resolves.toEqual({ + harbor: expect.objectContaining({ + status: 'KO', + message: expect.stringContaining('Harbor create member failed'), + }), + }) + + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: 2, + member_group: { + group_name: `/${project.slug}/console/admin`, + group_type: 3, + }, + }) + }) + + it('updates quota when it differs', async () => { + client.listQuotas.mockResolvedValueOnce(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])) + + await service.handleUpsert(makeProjectWithDetails({ + slug: 'myproj', + plugins: [ + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1024' }, + ], + })) + + expect(client.updateQuota).toHaveBeenCalledWith(123, 1024) + }) + + it('reuses robot secret when vault secret host matches', async () => { + const project = makeProjectWithDetails() + + await service.handleUpsert(project) + + expect(vault.read).toHaveBeenCalledTimes(2) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/ro-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/rw-robot`) + expect(client.getProjectRobots).not.toHaveBeenCalled() + expect(client.createRobot).not.toHaveBeenCalled() + expect(client.deleteRobot).not.toHaveBeenCalled() + expect(vault.write).not.toHaveBeenCalled() + }) + + it('rotates robot and writes secret when vault secret host differs', async () => { + const project = makeProjectWithDetails() + vault.read.mockImplementation(async (path: string) => { + if (path === `forge/${project.slug}/REGISTRY/ro-robot`) { + return makeVaultSecret({ + data: { + HOST: 'other.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'old', + }, + }) + } + return makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+rw-robot`, + TOKEN: 'secret', + }, + }) + }) + + client.getProjectRobots.mockImplementation(async function* () { + yield { id: 11, name: `robot$${project.slug}+ro-robot` } + }) + client.deleteRobot.mockResolvedValue(makeNoContent()) + client.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) + + await service.handleUpsert(project) + + expect(client.deleteRobot).toHaveBeenCalledWith(11) + expect(client.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + HOST: 'harbor.example', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'newsecret', + }), `forge/${project.slug}/REGISTRY/ro-robot`) + }) + + it('rotates robot and writes secret when vault secret is expiring', async () => { + const project = makeProjectWithDetails() + const old = makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'old', + }, + }) + old.metadata.created_time = new Date(Date.now() - 1000 * 60 * 60 * 24 * 120).toISOString() + + vault.read.mockImplementation(async (path: string) => { + if (path === `forge/${project.slug}/REGISTRY/ro-robot`) return old + return makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+rw-robot`, + TOKEN: 'secret', + }, + }) + }) + + client.getProjectRobots.mockImplementation(async function* () { + yield { id: 11, name: `robot$${project.slug}+ro-robot` } + }) + client.deleteRobot.mockResolvedValue(makeNoContent()) + client.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) + + await service.handleUpsert(project) + + expect(client.deleteRobot).toHaveBeenCalledWith(11) + expect(client.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + HOST: 'harbor.example', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'newsecret', + }), `forge/${project.slug}/REGISTRY/ro-robot`) + }) + + it('parses plugin config and enables project robot publishing', async () => { + const project = makeProjectWithDetails({ + plugins: [ + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1gb' }, + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, value: ENABLED }, + ], + }) + client.getProjectByName.mockResolvedValue(makeOkResponse({ project_id: 1, metadata: {} })) + + await service.handleUpsert(project) + + expect(client.updateQuota).toHaveBeenCalledWith(1, 1024 ** 3) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/ro-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/rw-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/${ROBOT_NAME_PROJECT}`) + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects', async () => { + datastore.getAllProjects.mockResolvedValue([ + makeProjectWithDetails({ slug: 'project-1' }), + makeProjectWithDetails({ slug: 'project-2' }), + ]) + + await service.handleCron() + + expect(client.getGroupMembers).toHaveBeenCalledWith('project-1') + expect(client.getGroupMembers).toHaveBeenCalledWith('project-2') + }) + }) + + describe('handleDelete', () => { + it('should delete project when it exists', async () => { + const project = makeProjectWithDetails() + await service.handleDelete(project) + expect(client.deleteProjectByName).toHaveBeenCalledWith(project.slug) + }) + + it('should not delete project when it does not exist', async () => { + client.getProjectByName.mockResolvedValueOnce({ status: HttpStatus.NOT_FOUND, data: null }) + await service.handleDelete(makeProjectWithDetails()) + expect(client.deleteProjectByName).not.toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.service.ts new file mode 100644 index 0000000000..43dc140455 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -0,0 +1,525 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultSecret } from '../vault/vault-client.service' +import type { HarborConfig } from './harbor.module-definition' +import type { + HarborAccess, + HarborGroupMemberRequest, + HarborMember, + HarborProjectQuota, + HarborRetentionPolicy, + HarborRobotCreateRequest, +} from './registry-client.service' +import type { ProjectWithDetails } from './registry-datastore.service' +import type { RuleTemplate, VaultRobotSecret } from './registry.utils' +import { specificallyEnabled } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { find } from '../../utils/iterable.utils' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService, roAccess, rwAccess } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { + ALLOWED_RETENTION_RULE_TEMPLATES, + DEFAULT_PLATFORM_ADMIN_GROUP_PATHS, + DEFAULT_PLATFORM_GUEST_GROUP_PATHS, + DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES, + HARBOR_ROLE_DEVELOPER, + HARBOR_ROLE_GUEST, + HARBOR_ROLE_LIMITED_GUEST, + HARBOR_ROLE_PROJECT_ADMIN, + PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, + REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, + ROBOT_NAME_PROJECT, + ROBOT_NAME_RO, + ROBOT_NAME_RW, +} from './registry.constants' +import { generateVaultRobotSecret, getHostFromUrl, getProjectVaultPath, parseBytes } from './registry.utils' + +@Injectable() +export class RegistryService { + private readonly logger = new Logger(RegistryService.name) + + constructor( + @Inject(RegistryClientService) private readonly client: RegistryClientService, + @Inject(RegistryDatastoreService) private readonly datastore: RegistryDatastoreService, + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('RegistryService initialized') + } + + private get host() { + return getHostFromUrl(this.harborConfig.url) + } + + private async getRobot(project: ProjectWithDetails, harborProjectId: number, robotName: string) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.robot.name': robotName, + }) + const fullName = generateRobotFullName(project, robotName) + return find(this.client.getProjectRobots(harborProjectId), r => r?.name === fullName) + } + + private async createProjectRobot(project: ProjectWithDetails, robotName: string, access: HarborAccess[]) { + const created = await this.client.createRobot( + generateRobotPermissions(project, robotName, access), + ) + if (created.status >= 300 || !created.data) { + throw new Error(`Harbor create robot failed (${created.status})`) + } + return created.data + } + + private async rotateRobot(project: ProjectWithDetails, harborProjectId: number, robotName: string, access: HarborAccess[]) { + const existing = await this.getRobot(project, harborProjectId, robotName) + if (existing?.id) { + await this.client.deleteRobot(existing.id) + } + return this.createProjectRobot(project, robotName, access) + } + + private async ensureRobotSecret(project: ProjectWithDetails, harborProjectId: number, robotName: string, access: HarborAccess[]) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.robot.name': robotName, + }) + const relativeVaultPath = `REGISTRY/${robotName}` + const vaultPath = getProjectVaultPath(project, this.baseConfig.projectsRootDir, relativeVaultPath) + const vaultRobotSecret = await this.vault.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + + const expiring = vaultRobotSecret + ? this.isRobotSecretExpiring(vaultRobotSecret) + : false + + span?.setAttributes({ + 'vault.secret.exists': !!vaultRobotSecret, + 'registry.robot.secret.expiring': expiring, + }) + + if (vaultRobotSecret?.data?.HOST === this.host && !expiring) { + span?.setAttribute('vault.secret.reused', true) + return vaultRobotSecret.data + } + + const existing = await this.getRobot(project, harborProjectId, robotName) + const created = existing + ? await this.rotateRobot(project, harborProjectId, robotName, access) + : await this.createProjectRobot(project, robotName, access) + const fullName = generateRobotFullName(project, robotName) + const secret = generateVaultRobotSecret(this.host, fullName, created.secret) + await this.vault.write(secret, vaultPath) + span?.setAttribute('vault.secret.written', true) + return secret + } + + private isRobotSecretExpiring(vaultSecret: VaultSecret): boolean { + const createdTimeRaw = vaultSecret?.metadata?.created_time + if (!createdTimeRaw) return false + const createdTime = new Date(createdTimeRaw) + return daysAgoFromNow(createdTime) > this.harborConfig.robotRotationThresholdDays + } + + private async ensureProjectGroupMember( + projectSlug: string, + groupName: string, + accessLevel: number, + membersByName: Map, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'registry.group.name': groupName, + 'registry.group.access_level': accessLevel, + }) + const existing = membersByName.get(groupName) + + if (existing?.id) { + if (existing.role_id !== accessLevel || existing.entity_type !== 'g') { + await this.client.removeGroupMember(projectSlug, Number(existing.id)) + membersByName.delete(groupName) + } else { + span?.setAttribute('registry.member.exists', true) + return + } + } + + const body: HarborGroupMemberRequest = { + role_id: accessLevel, + member_group: { + group_name: groupName, + group_type: 3, + }, + } + const created = await this.client.addGroupMember(projectSlug, body) + if (created.status >= 300) { + throw new Error(`Harbor create member failed (${created.status})`) + } + span?.setAttribute('registry.member.created', true) + } + + private async ensureProjectGroupMembers(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const members = await this.client.getGroupMembers(project.slug) + if (members.status !== 200 || !members.data) { + throw new Error(`Harbor list members failed (${members.status})`) + } + + const membersByName = new Map() + for (const member of members.data) { + const name = member?.entity_name + if (name) membersByName.set(name, member) + } + + const byGroupName = await this.generateAccessLevelMapping(project) + + await Promise.all( + Array.from(byGroupName.entries(), ([groupName, accessLevel]) => this.ensureProjectGroupMember( + project.slug, + groupName, + accessLevel, + membersByName, + )), + ) + } + + private async ensureProjectQuota(project: ProjectWithDetails, storageLimit: number) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.storage_limit.bytes': storageLimit, + }) + const existing = await this.client.getProjectByName(project.slug) + if (existing.status === 200 && existing.data) { + const projectId = Number(existing.data.project_id) + if (!Number.isFinite(projectId)) return existing.data + + const quotas = await this.client.listQuotas(projectId) + if (quotas.status === 200 && quotas.data) { + const hardQuota = quotas.data.find((q: HarborProjectQuota) => q?.ref?.id === projectId) + if (hardQuota?.hard?.storage !== storageLimit) { + await this.client.updateQuota(projectId, storageLimit) + span?.setAttribute('registry.quota.updated', true) + } + } + return existing.data + } + + const created = await this.client.createProject(project.slug, storageLimit) + if (created.status >= 300) { + throw new Error(`Harbor create project failed (${created.status})`) + } + span?.setAttribute('registry.project.created', true) + + const fetched = await this.client.getProjectByName(project.slug) + if (fetched.status !== 200 || !fetched.data) { + throw new Error(`Harbor get project failed (${fetched.status})`) + } + return fetched.data + } + + private async ensureRetentionPolicy(project: ProjectWithDetails, harborProjectId: number) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.project.id': harborProjectId, + }) + const policy = generateRetentionPolicy(harborProjectId, { + harborRuleTemplate: this.harborConfig.ruleTemplate, + harborRuleCount: this.harborConfig.ruleCount, + harborRetentionCron: this.harborConfig.retentionCron, + }) + const retentionId = await this.client.getRetentionId(project.slug) + span?.setAttribute('registry.retention.exists', !!retentionId) + const result = retentionId + ? await this.client.updateRetention(retentionId, policy) + : await this.client.createRetention(policy) + if (result.status >= 300) { + throw new Error(`Harbor retention policy failed (${result.status})`) + } + } + + @StartActiveSpan() + async ensureProject(project: ProjectWithDetails, options: { storageLimitBytes?: number, publishProjectRobot?: boolean } = {}) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.publish_project_robot': !!options.publishProjectRobot, + }) + const storageLimit = options.storageLimitBytes ?? -1 + const harborProject = await this.ensureProjectQuota(project, storageLimit) + const harborProjectId = Number(harborProject.project_id) + if (!Number.isFinite(harborProjectId)) + throw new Error('Unable to retrieve Harbor project_id') + + await Promise.all([ + this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_RO, roAccess), + this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_RW, rwAccess), + this.ensureProjectGroupMembers(project), + this.ensureRetentionPolicy(project, harborProjectId), + options.publishProjectRobot + ? this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_PROJECT, roAccess) + : Promise.resolve(), + ]) + + return { + projectId: harborProjectId, + basePath: `${this.host}/${project.slug}/`, + } + } + + @StartActiveSpan() + async deleteProject(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const existing = await this.client.getProjectByName(projectSlug) + if (existing.status === 404) { + span?.setAttribute('registry.project.exists', false) + return + } + const deleted = await this.client.deleteProjectByName(projectSlug) + if (deleted.status >= 300 && deleted.status !== 404) { + throw new Error(`Harbor delete project failed (${deleted.status})`) + } + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('harbor', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + const quotaConfigRaw = getPluginConfig(project, REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT) + const publishConfig = getPluginConfig(project, REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT) + const parsedQuota = quotaConfigRaw ? parseBytes(String(quotaConfigRaw)) : undefined + const storageLimitBytes = parsedQuota ?? -1 + const publishProjectRobot = specificallyEnabled(publishConfig) + await this.ensureProject(project, { storageLimitBytes, publishProjectRobot }) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('harbor', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.deleteProject(project.slug) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Registry reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('registry.projects.count', projects.length) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string) { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return getPluginConfig(project, key) + } + + private async getPlatformAdminGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_PLATFORM_ADMIN_GROUP_PATHS + return parseGroupPaths(raw) + } + + private async getPlatformGuestGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY) ?? DEFAULT_PLATFORM_GUEST_GROUP_PATHS + return parseGroupPaths(raw) + } + + private async getProjectAdminGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectMaintainerGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectGuestGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async generateAccessLevelMapping(project: ProjectWithDetails) { + const [ + platformAdminGroupPaths, + platformGuestGroupPaths, + projectAdminGroupPaths, + projectMaintainerGroupPaths, + projectDeveloperGroupPaths, + projectGuestGroupPaths, + ] = await Promise.all([ + this.getPlatformAdminGroupPaths(project), + this.getPlatformGuestGroupPaths(project), + this.getProjectAdminGroupPaths(project), + this.getProjectMaintainerGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectGuestGroupPaths(project), + ]) + + const roles = generateHarborAccessLevelMapping({ + guest: projectGuestGroupPaths, + developer: projectDeveloperGroupPaths, + maintainer: projectMaintainerGroupPaths, + admin: projectAdminGroupPaths, + platformAdmin: platformAdminGroupPaths, + platformGuest: platformGuestGroupPaths, + }) + return new Map([ + [`/${project.slug}`, HARBOR_ROLE_LIMITED_GUEST], + ...roles, + ]) + } +} + +function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +function parseGroupPaths(raw: string): string[] { + return raw + .split(',') + .map(path => path.trim()) + .filter(Boolean) +} + +function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string) { + return parseGroupPaths(rawGroupPathSuffixes).map(path => `/${projectSlug}${path}`) +} + +function generateHarborAccessLevelMapping(args: { guest: string[], developer: string[], maintainer: string[], admin: string[], platformAdmin: string[], platformGuest: string[] }) { + const byGroupName = new Map() + for (const groupName of args.guest) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.developer) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.maintainer) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.admin) byGroupName.set(groupName, HARBOR_ROLE_DEVELOPER) + for (const groupName of args.platformAdmin) byGroupName.set(groupName, HARBOR_ROLE_PROJECT_ADMIN) + for (const groupName of args.platformGuest) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + return byGroupName +} + +function daysAgoFromNow(date: Date) { + return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) +} + +function generateRobotFullName(project: ProjectWithDetails, robotName: string) { + return `robot$${project.slug}+${robotName}` +} + +function generateRobotPermissions(project: ProjectWithDetails, robotName: string, access: HarborAccess[]): HarborRobotCreateRequest { + return { + name: robotName, + duration: -1, + description: 'robot for ci builds', + disable: false, + level: 'project', + permissions: [{ + namespace: project.slug, + kind: 'project', + access, + }], + } +} + +function generateRetentionPolicy( + projectId: number, + options: { + harborRuleTemplate?: string + harborRuleCount?: string + harborRetentionCron?: string + }, +): HarborRetentionPolicy { + let template: RuleTemplate = 'latestPushedK' + if (isRuleTemplate(options.harborRuleTemplate)) { + template = options.harborRuleTemplate + } + + const rawCount = Number(options.harborRuleCount) + let count: number + if (Number.isFinite(rawCount) && rawCount > 0) { + count = rawCount + } else if (template === 'always') { + count = 1 + } else { + count = 10 + } + + return { + algorithm: 'or', + scope: { level: 'project', ref: projectId }, + rules: [ + { + disabled: false, + action: 'retain', + template, + params: { [template]: count }, + tag_selectors: [ + { kind: 'doublestar', decoration: 'matches', pattern: '**' }, + ], + scope_selectors: { + repository: [ + { kind: 'doublestar', decoration: 'repoMatches', pattern: '**' }, + ], + }, + }, + ], + trigger: { + kind: 'Schedule', + settings: { cron: options.harborRetentionCron }, + references: [], + }, + } +} + +function isRuleTemplate(value: unknown): value is RuleTemplate { + if (typeof value !== 'string') return false + for (const template of ALLOWED_RETENTION_RULE_TEMPLATES) { + if (template === value) return true + } + return false +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.utils.ts new file mode 100644 index 0000000000..2befbdbd12 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -0,0 +1,86 @@ +import type { ProjectWithDetails } from './registry-datastore.service' +import type { ALLOWED_RETENTION_RULE_TEMPLATES } from './registry.constants' +import { removeTrailingSlash } from '@cpn-console/shared' + +export type RuleTemplate = typeof ALLOWED_RETENTION_RULE_TEMPLATES[number] + +export function createProjectSlugCacheKey(projectId: string) { + return `registry:project-slug:${projectId}` +} + +const protocolPrefixRegex = /^https?:\/\//u +const parseBytesRegex = /^(\d+(?:\.\d+)?)(?:\s*(kb|mb|gb|tb|[kmgtb]))?$/u + +export function getHostFromUrl(url: string) { + return removeTrailingSlash(url).replace(protocolPrefixRegex, '').split('/')[0] +} + +export function encodeBasicAuth(username: string, password: string) { + return Buffer.from(`${username}:${password}`).toString('base64') +} + +export interface VaultRobotSecret { + DOCKER_CONFIG: string + HOST: string + TOKEN: string + USERNAME: string +} + +export function generateVaultRobotSecret(host: string, robotName: string, robotSecret: string): VaultRobotSecret { + const auth = `${robotName}:${robotSecret}` + const b64auth = Buffer.from(auth).toString('base64') + return { + DOCKER_CONFIG: JSON.stringify({ + auths: { + [host]: { + auth: b64auth, + email: '', + }, + }, + }), + HOST: host, + TOKEN: robotSecret, + USERNAME: robotName, + } +} + +export function getProjectVaultPath(project: ProjectWithDetails, projectRootDir: string | undefined, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return projectRootDir + ? `${projectRootDir}/${project.slug}/${normalized}` + : `${project.slug}/${normalized}` +} + +export function parseBytes(input: string | number | undefined) { + if (input === undefined || input === null) return undefined + if (typeof input === 'number' && Number.isFinite(input)) return input + const raw = String(input).trim().toLowerCase() + if (!raw) return undefined + const match = parseBytesRegex.exec(raw) + if (!match) { + return Number.isFinite(Number(raw)) ? Number(raw) : undefined + } + const value = Number(match[1]) + const unit = (match[2] ?? 'b').toLowerCase() + const pow = parseUnit(unit) + return Math.round(value * 1024 ** pow) +} + +function parseUnit(unit: string) { + switch (unit) { + case 'kb': + case 'k': + return 1 + case 'mb': + case 'm': + return 2 + case 'gb': + case 'g': + return 3 + case 'tb': + case 't': + return 4 + default: + return 0 + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts new file mode 100644 index 0000000000..51e59317ee --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts @@ -0,0 +1,199 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Dispatcher, RequestInit } from 'undici' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { OpenCdsConfig } from '../opencds/opencds.module-definition' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { Agent, fetch, Headers, ProxyAgent, Response } from 'undici' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { OPENCDS_CONFIG } from '../opencds/opencds.module-definition' +import { OpenCdsClientError, OpenCdsClientService } from './open-cds-client.service' + +vi.mock('undici', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + Agent: vi.fn(), + fetch: vi.fn(), + ProxyAgent: vi.fn(), + } +}) + +function mockFetchResponse(response: Response): void { + vi.mocked(fetch).mockResolvedValue(response) +} + +function getLastFetchCall(): [string, RequestInit] { + const [url, init] = vi.mocked(fetch).mock.lastCall as [string, RequestInit] + return [url, init] +} + +describe('openCdsClientService', () => { + let module: TestingModule + let service: OpenCdsClientService + let openCdsConfig: Partial + let baseConfig: DeepMockProxy + let tlsDispatcher: Pick + let proxyDispatcher: Pick + + beforeEach(async () => { + vi.clearAllMocks() + vi.unstubAllEnvs() + + tlsDispatcher = { dispatch: vi.fn() } + proxyDispatcher = { dispatch: vi.fn() } + + class MockAgent { + dispatch = tlsDispatcher + }; + + class ProxyMockAgent { + dispatch = proxyDispatcher + }; + + vi.mocked(Agent).mockImplementation(MockAgent as any) + vi.mocked(ProxyAgent).mockImplementation(ProxyMockAgent as any) + + openCdsConfig = { + url: 'https://opencds.example.com/root/api/', + apiToken: 'test-token', + apiTlsRejectUnauthorized: true, + } + baseConfig = mockDeep({ httpProxy: undefined }) + + module = await Test.createTestingModule({ + providers: [ + OpenCdsClientService, + { provide: OPENCDS_CONFIG, useValue: openCdsConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(OpenCdsClientService) + }) + + it('builds GET requests with an Axios-compatible URL, API key header and TLS-aware dispatcher', async () => { + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + const result = await service.get<{ ok: boolean }>('/requests') + + expect(Agent).toHaveBeenCalledWith({ + connect: { + rejectUnauthorized: true, + }, + }) + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests') + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('GET') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(result).toEqual({ ok: true }) + }) + + it('uses ProxyAgent when HTTP_PROXY is configured and preserves TLS settings for the upstream request', async () => { + baseConfig.httpProxy = 'http://proxy.internal:3128' + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + await service.get('/requests') + + expect(ProxyAgent).toHaveBeenCalledWith({ + requestTls: { + rejectUnauthorized: true, + }, + uri: 'http://proxy.internal:3128', + }) + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests') + expect(init.dispatcher?.dispatch).toBe(proxyDispatcher) + expect(init.method).toBe('GET') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + }) + + it('applies query parameters and omits undefined values on GET', async () => { + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + await service.get('/requests', { + query: { + page: 2, + active: true, + search: 'alpha', + ignored: undefined, + }, + }) + + const [url] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests?page=2&active=true&search=alpha') + }) + + it('sends POST without body and without forcing JSON content type', async () => { + mockFetchResponse(new Response(null, { status: HttpStatus.NO_CONTENT })) + + await service.post('/validate/id') + + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/validate/id') + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('POST') + expect(init.signal).toBeUndefined() + expect(init.body).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(new Headers(init.headers).has('Content-Type')).toBe(false) + }) + + it('serializes POST bodies as JSON and sets the content type', async () => { + mockFetchResponse(new Response(null, { status: HttpStatus.NO_CONTENT })) + + await service.post('/validate/id', { + requestId: '123', + enabled: true, + }) + + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/validate/id') + expect(init.body).toBe(JSON.stringify({ + requestId: '123', + enabled: true, + })) + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('POST') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(new Headers(init.headers).get('Content-Type')).toBe('application/json') + }) + + it('throws a dedicated error with HTTP status context for non-OK responses', async () => { + mockFetchResponse(new Response('upstream failure', { + status: HttpStatus.BAD_GATEWAY, + statusText: 'Bad Gateway', + })) + + await expect(service.get('/requests')).rejects.toMatchObject({ + body: 'upstream failure', + message: 'OpenCDS request failed with 502 Bad Gateway', + name: OpenCdsClientError.name, + status: HttpStatus.BAD_GATEWAY, + statusText: 'Bad Gateway', + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts new file mode 100644 index 0000000000..c2760046d2 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts @@ -0,0 +1,129 @@ +import type { HttpStatus } from '@nestjs/common' +import type { Dispatcher, HeadersInit } from 'undici' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { OpenCdsConfig } from '../opencds/opencds.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { Agent, fetch, Headers, ProxyAgent } from 'undici' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { OPENCDS_CONFIG } from '../opencds/opencds.module-definition' +import { throwIfNotOk } from './service-chain.utils' + +const URL_REGEX = /^https?:\/\// +const START_SLASHES_REGEX = /^\/+/ +const END_SLASHES_REGEX = /\/+$/ + +export interface OpenCdsRequestOptions { + headers?: HeadersInit + signal?: AbortSignal + query?: Record +} + +export class OpenCdsClientError extends Error { + constructor( + public readonly status: HttpStatus, + public readonly statusText: string, + public readonly body?: string, + ) { + super(`OpenCDS request failed with ${status} ${statusText}`) + } +} + +@Injectable() +export class OpenCdsClientService { + constructor( + @Inject(OPENCDS_CONFIG) private readonly opencdsConfig: OpenCdsConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + private readonly logger = new Logger(OpenCdsClientService.name) + + async get(path: string, options?: OpenCdsRequestOptions): Promise { + const url = this.buildUrl(path, options?.query) + this.logger.debug(`Retrieving data from URL: ${url}`) + + const headers = this.buildHeaders(options?.headers) + + const response = await fetch(url, { + dispatcher: this.buildDispatcher(), + headers, + method: 'GET', + signal: options?.signal, + }) + + await throwIfNotOk(response) + + return (await response.json()) as T + } + + async post( + path: string, + body?: TBody, + options?: OpenCdsRequestOptions, + ): Promise { + const hasBody = body !== undefined + + const response = await fetch(this.buildUrl(path, options?.query), { + body: hasBody ? JSON.stringify(body) : undefined, + dispatcher: this.buildDispatcher(), + headers: this.buildHeaders(options?.headers, hasBody), + method: 'POST', + signal: options?.signal, + }) + + await throwIfNotOk(response) + } + + private buildUrl( + path: string, + query?: OpenCdsRequestOptions['query'], + ): string { + if (!this.opencdsConfig.url) throw new Error('OpenCDS is disabled') + + const resolvedPath = URL_REGEX.test(path) + ? path + : `${this.opencdsConfig.url.replace(END_SLASHES_REGEX, '')}/${path.replace(START_SLASHES_REGEX, '')}` + + const url = new URL(resolvedPath) + + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined) { + url.searchParams.append(key, String(value)) + } + } + + return url.toString() + } + + private buildHeaders( + headers?: OpenCdsRequestOptions['headers'], + hasJsonBody = false, + ): Headers { + const mergedHeaders = new Headers(headers) + mergedHeaders.set('X-API-Key', this.opencdsConfig.apiToken ?? '') + + if (hasJsonBody) { + mergedHeaders.set('Content-Type', 'application/json') + } + + return mergedHeaders + } + + private buildDispatcher(): Dispatcher { + const httpProxy = this.baseConfig.httpProxy + + if (httpProxy) { + return new ProxyAgent({ + requestTls: { + rejectUnauthorized: this.opencdsConfig.apiTlsRejectUnauthorized, + }, + uri: httpProxy, + }) + } + + return new Agent({ + connect: { + rejectUnauthorized: this.opencdsConfig.apiTlsRejectUnauthorized, + }, + }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts new file mode 100644 index 0000000000..2318edb937 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts @@ -0,0 +1,126 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ServiceChainController } from './service-chain.controller' +import { ServiceChainService } from './service-chain.service' + +describe('serviceChainController', () => { + let module: TestingModule + let controller: ServiceChainController + let service: MockProxy + + const uuid = '550e8400-e29b-41d4-a716-446655440000' + const date = new Date('2026-01-01T00:00:00.000Z') + const serviceChain: ServiceChain = { + id: uuid, + state: 'opened', + commonName: 'test.example.com', + pai: 'test-pai', + network: 'RIE', + createdAt: date, + updatedAt: date, + } + const serviceChainDetails: ServiceChainDetails = { + ...serviceChain, + validationId: uuid, + validatedBy: null, + ref: null, + location: 'SIR', + targetAddress: '10.0.0.1', + projectId: uuid, + env: 'INT', + subjectAlternativeName: [], + redirect: false, + antivirus: null, + websocket: false, + ipWhiteList: [], + sslOutgoing: false, + } + const serviceChainFlows: ServiceChainFlows = { + reserve_ip: { state: 'success', input: {}, output: {}, updatedAt: date }, + create_cert: null, + call_exec: { state: 'success', input: {}, output: {}, updatedAt: date }, + activate_ip: { state: 'success', input: {}, output: {}, updatedAt: date }, + dns_request: { state: 'success', input: {}, output: {}, updatedAt: date }, + } + + beforeEach(async () => { + service = mock() + + module = await Test.createTestingModule({ + controllers: [ServiceChainController], + providers: [ + { provide: ServiceChainService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(ServiceChainController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call service.list()', async () => { + const mockResult = [serviceChain] + service.list.mockResolvedValue(mockResult) + + const result = await controller.list() + + expect(service.list).toHaveBeenCalled() + expect(result).toEqual(mockResult) + }) + }) + + describe('getDetails', () => { + it('should call service.getDetails() with id', async () => { + const mockResult = serviceChainDetails + service.getDetails.mockResolvedValue(mockResult) + + const result = await controller.getDetails(uuid) + + expect(service.getDetails).toHaveBeenCalledWith(uuid) + expect(result).toEqual(mockResult) + }) + }) + + describe('retry', () => { + it('should call service.retry() with id', async () => { + service.retry.mockResolvedValue() + + await controller.retry(uuid) + + expect(service.retry).toHaveBeenCalledWith(uuid) + }) + }) + + describe('validate', () => { + it('should call service.validate() with validationId', async () => { + service.validate.mockResolvedValue() + + await controller.validate(uuid) + + expect(service.validate).toHaveBeenCalledWith(uuid) + }) + }) + + describe('getFlows', () => { + it('should call service.getFlows() with id', async () => { + const mockResult = serviceChainFlows + service.getFlows.mockResolvedValue(mockResult) + + const result = await controller.getFlows(uuid) + + expect(service.getFlows).toHaveBeenCalledWith(uuid) + expect(result).toEqual(mockResult) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts new file mode 100644 index 0000000000..1caea91186 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Inject, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ServiceChainService } from './service-chain.service' + +@Controller('api/v1/service-chains') +export class ServiceChainController { + constructor( + @Inject(ServiceChainService) + private readonly serviceChainService: ServiceChainService, + ) {} + + @Post('validate/:validationId') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async validate(@Param('validationId', ParseUUIDPipe) validationId: string) { + await this.serviceChainService.validate(validationId) + } + + @Get() + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async list() { + return this.serviceChainService.list() + } + + @Get(':serviceChainId') + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async getDetails(@Param('serviceChainId', ParseUUIDPipe) id: string) { + return this.serviceChainService.getDetails(id) + } + + @Post(':serviceChainId/retry') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async retry(@Param('serviceChainId', ParseUUIDPipe) id: string) { + await this.serviceChainService.retry(id) + } + + @Get(':serviceChainId/flows') + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async getFlows(@Param('serviceChainId', ParseUUIDPipe) id: string) { + return this.serviceChainService.getFlows(id) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts new file mode 100644 index 0000000000..52964ab4b2 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { openCdsConfigFactory } from '../../config/opencds.config' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { OpenCdsClientService } from './open-cds-client.service' +import { ServiceChainController } from './service-chain.controller' +import { ServiceChainService } from './service-chain.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ConfigModule.forFeature(openCdsConfigFactory), + ConfigModule.forFeature(baseConfigFactory), + ], + controllers: [ServiceChainController], + providers: [OpenCdsClientService, ServiceChainService], + exports: [ServiceChainService], +}) +export class ServiceChainModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts new file mode 100644 index 0000000000..e92351a0b2 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts @@ -0,0 +1,124 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { OpenCdsClientService } from './open-cds-client.service' +import { ServiceChainService } from './service-chain.service' + +describe('serviceChainService', () => { + let module: TestingModule + let service: ServiceChainService + let openCdsClient: MockProxy + + beforeEach(async () => { + openCdsClient = mock() + + module = await Test.createTestingModule({ + providers: [ + ServiceChainService, + { provide: OpenCdsClientService, useValue: openCdsClient }, + ], + }).compile() + + service = module.get(ServiceChainService) + }) + + const uuid = '550e8400-e29b-41d4-a716-446655440000' + + const mockServiceChain: ServiceChain = { + id: uuid, + state: 'opened', + commonName: 'test.example.com', + pai: 'test-pai', + network: 'RIE', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const mockServiceChainDetails: ServiceChainDetails = { + ...mockServiceChain, + validationId: uuid, + validatedBy: null, + ref: null, + location: 'SIR', + targetAddress: '10.0.0.1', + projectId: uuid, + env: 'INT', + subjectAlternativeName: [], + redirect: false, + antivirus: null, + websocket: false, + ipWhiteList: [], + sslOutgoing: false, + } + const flowDetails: NonNullable = { + state: 'success', + input: {}, + output: {}, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const mockServiceChainFlows: ServiceChainFlows = { + reserve_ip: flowDetails, + create_cert: null, + call_exec: flowDetails, + activate_ip: flowDetails, + dns_request: flowDetails, + } + + describe('list', () => { + it('should call GET /requests and parse response', async () => { + openCdsClient.get.mockResolvedValue([mockServiceChain]) + + const result = await service.list() + + expect(openCdsClient.get).toHaveBeenCalledWith('/requests') + expect(result).toHaveLength(1) + expect(result[0].id).toBe(uuid) + }) + }) + + describe('getDetails', () => { + it('should call GET /requests/:id and parse response', async () => { + openCdsClient.get.mockResolvedValue(mockServiceChainDetails) + + const result = await service.getDetails(uuid) + + expect(openCdsClient.get).toHaveBeenCalledWith(`/requests/${uuid}`) + expect(result.id).toBe(uuid) + expect(result.location).toBe('SIR') + }) + }) + + describe('retry', () => { + it('should call POST /requests/:id/retry', async () => { + openCdsClient.post.mockResolvedValue() + + await service.retry(uuid) + + expect(openCdsClient.post).toHaveBeenCalledWith(`/requests/${uuid}/retry`) + }) + }) + + describe('validate', () => { + it('should call POST /validate/:validationId', async () => { + openCdsClient.post.mockResolvedValue() + + await service.validate(uuid) + + expect(openCdsClient.post).toHaveBeenCalledWith(`/validate/${uuid}`) + }) + }) + + describe('getFlows', () => { + it('should call GET /requests/:id/flows and parse response', async () => { + openCdsClient.get.mockResolvedValue(mockServiceChainFlows) + + const result = await service.getFlows(uuid) + + expect(openCdsClient.get).toHaveBeenCalledWith(`/requests/${uuid}/flows`) + expect(result.reserve_ip.state).toBe('success') + expect(result.create_cert).toBeNull() + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts new file mode 100644 index 0000000000..d40a031fdf --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts @@ -0,0 +1,45 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import { + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainListSchema, +} from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { OpenCdsClientService } from './open-cds-client.service' + +@Injectable() +export class ServiceChainService { + constructor(@Inject(OpenCdsClientService) private readonly openCdsClient: OpenCdsClientService) {} + + @StartActiveSpan() + async list(): Promise { + return ServiceChainListSchema.parse( + await this.openCdsClient.get('/requests'), + ) + } + + @StartActiveSpan() + async getDetails(id: string): Promise { + return ServiceChainDetailsSchema.parse( + await this.openCdsClient.get(`/requests/${id}`), + ) + } + + @StartActiveSpan() + async retry(id: string): Promise { + await this.openCdsClient.post(`/requests/${id}/retry`) + } + + @StartActiveSpan() + async validate(validationId: string): Promise { + await this.openCdsClient.post(`/validate/${validationId}`) + } + + @StartActiveSpan() + async getFlows(id: string): Promise { + return ServiceChainFlowsSchema.parse( + await this.openCdsClient.get(`/requests/${id}/flows`), + ) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts new file mode 100644 index 0000000000..3500ad7ec9 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts @@ -0,0 +1,20 @@ +import type { HttpStatus } from '@nestjs/common' +import type { Response } from 'undici' + +export class OpenCdsClientError extends Error { + constructor( + public readonly status: HttpStatus, + public readonly statusText: string, + public readonly body?: string, + ) { + super(`OpenCDS request failed with ${status} ${statusText}`) + this.name = 'OpenCdsClientError' + } +} + +export async function throwIfNotOk(response: T): Promise { + if (!response.ok) { + const body = await response.text() + throw new OpenCdsClientError(response.status, response.statusText, body) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts new file mode 100644 index 0000000000..26a5d114c4 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts @@ -0,0 +1,203 @@ +import type { AddPermissionGroupParams, CreateUserParams, DeactivateUserParams, RevokeUserTokenParams } from './sonarqube-client.service' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' +import { makeSonarqubeGeneratedToken, makeSonarqubeGroup, makeSonarqubePaging, makeSonarqubeProject, makeSonarqubeUser } from './sonarqube-testing.utils' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +const sonarUrl = 'https://sonarqube.internal' +const sonarToken = 'my-token' +const sonarAuthHeader = `Bearer ${sonarToken}` + +const server = setupServer() + +describe('sonarqubeClientService', () => { + let service: SonarqubeClientService + let config: ReturnType> + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + config = mockDeep({ + apiToken: sonarToken, + internalOrPublicUrl: sonarUrl, + }) + + const module = await Test.createTestingModule({ + providers: [ + SonarqubeClientService, + SonarqubeHttpClientService, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = module.get(SonarqubeClientService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('userGroupsSearch', () => { + it('should GET user_groups/search with auth', async () => { + const group = makeSonarqubeGroup({ name: 'my-group' }) + server.use( + http.get(`${sonarUrl}/api/user_groups/search`, ({ request }) => { + expect(request.headers.get('authorization')).toBe(sonarAuthHeader) + expect(new URL(request.url).searchParams.get('q')).toBe('my-group') + return HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), groups: [group] }) + }), + ) + const result = await service.searchUserGroup({ q: 'my-group' }) + expect(result.groups).toEqual([group]) + }) + }) + + describe('userGroupsCreate', () => { + it('should POST user_groups/create', async () => { + server.use( + http.post(`${sonarUrl}/api/user_groups/create`, ({ request }) => { + expect(new URL(request.url).searchParams.get('name')).toBe('new-group') + return HttpResponse.json({}) + }), + ) + await expect(service.createUserGroup({ name: 'new-group' })).resolves.not.toThrow() + }) + }) + + describe('usersSearch', () => { + it('should GET users/search', async () => { + const user = makeSonarqubeUser({ login: 'my-user' }) + server.use( + http.get(`${sonarUrl}/api/users/search`, () => + HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), users: [user] })), + ) + const result = await service.searchUsers({ q: 'my-user' }) + expect(result.users).toEqual([user]) + }) + }) + + describe('usersCreate', () => { + it('should POST users/create with all params as query string', async () => { + const user = { + email: faker.internet.email(), + local: 'true', + login: faker.internet.username(), + name: faker.internet.username(), + password: faker.internet.password(), + } satisfies CreateUserParams + server.use( + http.post(`${sonarUrl}/api/users/create`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('login')).toBe(user.login) + expect(params.get('email')).toBe(user.email) + expect(params.get('local')).toBe(user.local) + return HttpResponse.json({}) + }), + ) + await service.createUser(user) + }) + }) + + describe('usersDeactivate', () => { + it('should POST users/deactivate with anonymize param', async () => { + const user = { + login: faker.internet.username(), + anonymize: true, + } satisfies DeactivateUserParams + server.use( + http.post(`${sonarUrl}/api/users/deactivate`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('login')).toBe(user.login) + expect(params.get('anonymize')).toBe(String(user.anonymize)) + return HttpResponse.json({}) + }), + ) + await service.deactivateUser(user) + }) + }) + + describe('userTokensRevoke / userTokensGenerate', () => { + it('should POST user_tokens/revoke', async () => { + const token = makeSonarqubeGeneratedToken() + const revoke = { + login: token.login, + name: token.name, + } satisfies RevokeUserTokenParams + server.use( + http.post(`${sonarUrl}/api/user_tokens/revoke`, () => HttpResponse.json({})), + ) + await expect(service.revokeUserToken(revoke)).resolves.not.toThrow() + }) + + it('should POST user_tokens/generate and return the token', async () => { + const generated = makeSonarqubeGeneratedToken() + server.use( + http.post(`${sonarUrl}/api/user_tokens/generate`, () => HttpResponse.json(generated)), + ) + const result = await service.generateUserToken({ login: generated.login, name: generated.name }) + expect(result.token).toBe(generated.token) + }) + }) + + describe('projectsSearch', () => { + it('should GET projects/search', async () => { + const project = makeSonarqubeProject() + server.use( + http.get(`${sonarUrl}/api/projects/search`, () => + HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), components: [project] })), + ) + const result = await service.searchProject({ q: project.name }) + expect(result.components).toEqual([project]) + }) + }) + + describe('projectsDelete', () => { + it('should POST projects/delete with project key as query param', async () => { + const project = makeSonarqubeProject() + server.use( + http.post(`${sonarUrl}/api/projects/delete`, ({ request }) => { + expect(new URL(request.url).searchParams.get('project')).toBe(project.key) + return HttpResponse.json({}) + }), + ) + await service.deleteProject({ project: project.key }) + }) + }) + + describe('permissionsAddGroup', () => { + it('should POST permissions/add_group with global params', async () => { + const group = { + groupName: '/admin', + permission: 'admin', + } satisfies AddPermissionGroupParams + server.use( + http.post(`${sonarUrl}/api/permissions/add_group`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('groupName')).toBe(group.groupName) + expect(params.get('permission')).toBe(group.permission) + expect(params.has('projectKey')).toBe(false) + return HttpResponse.json({}) + }), + ) + await service.addPermissionGroup(group) + }) + + it('should POST permissions/add_group with projectKey for project-scoped call', async () => { + server.use( + http.post(`${sonarUrl}/api/permissions/add_group`, ({ request }) => { + expect(new URL(request.url).searchParams.get('projectKey')).toBe('proj-key') + return HttpResponse.json({}) + }), + ) + await service.addPermissionGroup({ groupName: '/proj', permission: 'scan', projectKey: 'proj-key' }) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts new file mode 100644 index 0000000000..7cc63c64a4 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts @@ -0,0 +1,287 @@ +import type { + SONARQUBE_PROJECT_QUALIFIER_APPLICATION, + SONARQUBE_PROJECT_QUALIFIER_BRANCH, + SONARQUBE_PROJECT_QUALIFIER_DIRECTORY, + SONARQUBE_PROJECT_QUALIFIER_FILE, + SONARQUBE_PROJECT_QUALIFIER_LIBRARY, + SONARQUBE_PROJECT_QUALIFIER_PROJECT, + SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW, + SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST, + SONARQUBE_PROJECT_QUALIFIER_VIEW, +} from './sonarqube.constants' +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' + +export interface SonarqubePaging { + pageIndex: number + pageSize: number + total: number +} + +export interface SonarqubeGroup { + id: string + name: string + description: string + membersCount: number + default: boolean +} + +export interface SonarqubeUser { + login: string + name: string + active: boolean + email: string + groups: string[] + tokensCount: number + local: boolean + externalIdentity: string + externalProvider: string + managed: boolean +} + +export type SonarqubeProjectQualifier + = | typeof SONARQUBE_PROJECT_QUALIFIER_APPLICATION + | typeof SONARQUBE_PROJECT_QUALIFIER_BRANCH + | typeof SONARQUBE_PROJECT_QUALIFIER_DIRECTORY + | typeof SONARQUBE_PROJECT_QUALIFIER_FILE + | typeof SONARQUBE_PROJECT_QUALIFIER_LIBRARY + | typeof SONARQUBE_PROJECT_QUALIFIER_PROJECT + | typeof SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST + | typeof SONARQUBE_PROJECT_QUALIFIER_VIEW + | typeof SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW + +export interface SonarqubeProject { + key: string + name: string + qualifier: SonarqubeProjectQualifier + visibility: 'private' | 'public' + lastAnalysisDate?: string + revision?: string +} + +export interface SonarqubeProjectResult { + projectSlug: string + repository: string + key: string +} + +export interface SonarqubeGeneratedToken { + token: string + login: string + name: string +} + +type BaseParams = Record + +export interface SearchUserGroupParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateUserGroupParams extends BaseParams { + name: string + description?: string +} + +export interface CreatePermissionTemplateParams extends BaseParams { + name: string + description?: string + projectKeyPattern?: string +} + +export interface SearchPermissionTemplatesParams extends BaseParams { + q?: string +} + +export interface SetPermissionDefaultTemplateParams extends BaseParams { + templateName: string + projectKeyPattern?: string +} + +export interface AddPermissionProjectCreatorToTemplateParams extends BaseParams { + templateName: string + permission: string +} + +export interface AddPermissionGroupToTemplateParams extends BaseParams { + groupName: string + templateName: string + permission: string +} + +export interface AddPermissionGroupParams extends BaseParams { + groupName: string + permission: string + projectKey?: string +} + +export interface AddPermissionUserParams extends BaseParams { + projectKey: string + permission: string + login: string +} + +export interface SearchUsersParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateUserParams extends BaseParams { + email: string + local: string + login: string + name: string + password: string +} + +export interface DeactivateUserParams extends BaseParams { + login: string + anonymize: boolean +} + +export interface RevokeUserTokenParams extends BaseParams { + login: string + name: string +} + +export interface GenerateUserTokenParams extends BaseParams { + login: string + name: string +} + +export interface SearchProjectParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateProjectParams extends BaseParams { + project: string + visibility: string + name: string + mainbranch: string +} + +export interface DeleteProjectParams extends BaseParams { + project: string +} + +export interface SearchUserGroupResponse { + paging: SonarqubePaging + groups: SonarqubeGroup[] +} + +export interface SonarqubePermissionTemplate { + id: string + name: string + description?: string +} + +export interface SearchPermissionTemplatesResponse { + permissionTemplates: SonarqubePermissionTemplate[] +} + +export interface SearchUsersResponse { + paging: SonarqubePaging + users: SonarqubeUser[] +} + +export interface SearchProjectResponse { + paging: SonarqubePaging + components: SonarqubeProject[] +} + +@Injectable() +export class SonarqubeClientService { + constructor( + @Inject(SonarqubeHttpClientService) private readonly http: SonarqubeHttpClientService, + ) {} + + @StartActiveSpan() + searchUserGroup(params: SearchUserGroupParams) { + return this.http.fetch('user_groups/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createUserGroup(params: CreateUserGroupParams) { + await this.http.fetch('user_groups/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async createPermissionTemplate(params: CreatePermissionTemplateParams) { + await this.http.fetch('permissions/create_template', { method: 'POST', params }) + } + + @StartActiveSpan() + searchPermissionTemplates(params: SearchPermissionTemplatesParams) { + return this.http.fetch('permissions/search_templates', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async setPermissionDefaultTemplate(params: SetPermissionDefaultTemplateParams) { + await this.http.fetch('permissions/set_default_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionProjectCreatorToTemplate(params: AddPermissionProjectCreatorToTemplateParams) { + await this.http.fetch('permissions/add_project_creator_to_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionGroupToTemplate(params: AddPermissionGroupToTemplateParams) { + await this.http.fetch('permissions/add_group_to_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionGroup(params: AddPermissionGroupParams) { + await this.http.fetch('permissions/add_group', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionUser(params: AddPermissionUserParams) { + await this.http.fetch('permissions/add_user', { method: 'POST', params }) + } + + @StartActiveSpan() + searchUsers(params: SearchUsersParams) { + return this.http.fetch('users/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createUser(params: CreateUserParams) { + await this.http.fetch('users/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async deactivateUser(params: DeactivateUserParams) { + await this.http.fetch('users/deactivate', { method: 'POST', params }) + } + + @StartActiveSpan() + async revokeUserToken(params: RevokeUserTokenParams) { + await this.http.fetch('user_tokens/revoke', { method: 'POST', params }) + } + + @StartActiveSpan() + generateUserToken(params: GenerateUserTokenParams) { + return this.http.fetch('user_tokens/generate', { method: 'POST', params }).then(res => res.data!) + } + + @StartActiveSpan() + searchProject(params: SearchProjectParams) { + return this.http.fetch('projects/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createProject(params: CreateProjectParams) { + await this.http.fetch('projects/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async deleteProject(params: DeleteProjectParams) { + await this.http.fetch('projects/delete', { method: 'POST', params }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts new file mode 100644 index 0000000000..bcb3143972 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts @@ -0,0 +1,68 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './sonarqube.constants' + +export const projectSelect = { + id: true, + slug: true, + repositories: { + select: { + internalRepoName: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class SonarqubeDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig( + pluginName: string, + key: string, + ): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts new file mode 100644 index 0000000000..8f77620a8c --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -0,0 +1,26 @@ +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +@Injectable() +export class SonarqubeHealthService { + constructor( + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const bearerToken = Buffer.from(`${this.sonarqubeConfig.apiToken}:`, 'utf-8').toString('base64') + headers.Authorization = `Bearer ${bearerToken}` + try { + const response = await fetch(this.sonarqubeConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts new file mode 100644 index 0000000000..6ee07f8585 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts @@ -0,0 +1,103 @@ +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +export interface SonarqubeFetchOptions { + method?: string + params?: Record +} + +export interface SonarqubeResponse { + status: HttpStatus + data: T | null +} + +export type SonarqubeErrorKind = 'NotConfigured' | 'ClientError' | 'ServerError' | 'Unexpected' + +export class SonarqubeError extends Error { + readonly kind: SonarqubeErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + + constructor( + kind: SonarqubeErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string } = {}, + ) { + super(message) + this.name = 'SonarqubeError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + } +} + +@Injectable() +export class SonarqubeHttpClientService { + constructor( + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + ) {} + + private get apiBaseUrl(): string { + return new URL('api/', this.sonarqubeConfig.internalOrPublicUrl).toString() + } + + private get defaultHeaders(): Record { + if (!this.sonarqubeConfig.apiToken) throw new SonarqubeError('NotConfigured', 'SONAR_API_TOKEN is required') + return { + Authorization: `Bearer ${this.sonarqubeConfig.apiToken}`, + } + } + + async fetch(path: string, options: SonarqubeFetchOptions = {}): Promise> { + const span = trace.getActiveSpan() + const method = (options.method ?? 'GET').toUpperCase() + span?.setAttribute('sonarqube.method', method) + span?.setAttribute('sonarqube.path', path) + + const request = this.createRequest(path, method, options.params) + const response = await fetch(request).catch((error) => { + throw new SonarqubeError('Unexpected', error instanceof Error ? error.message : String(error), { method, path }) + }) + + span?.setAttribute('sonarqube.http.status', response.status) + const result = await handleResponse(response) + if (response.status >= 400) { + const kind = response.status >= 500 ? 'ServerError' : 'ClientError' + throw new SonarqubeError(kind, formatErrorMessage(response.status, result.data), { status: response.status, method, path }) + } + return result + } + + private createRequest(path: string, method: string, params?: Record): Request { + const url = new URL(path, this.apiBaseUrl) + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) url.searchParams.append(key, String(value)) + } + } + return new Request(url.toString(), { method, headers: this.defaultHeaders }) + } +} + +function formatErrorMessage(status: number, data: unknown): string { + const errors = (data as { errors?: { msg?: unknown }[] } | null)?.errors + const details = Array.isArray(errors) + ? errors.map(e => e?.msg).filter((msg): msg is string => typeof msg === 'string').join('; ') + : '' + return details + ? `SonarQube API responded with status ${status}: ${details}` + : `SonarQube API responded with status ${status}` +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts new file mode 100644 index 0000000000..671a19562a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { SonarqubePluginService } from './sonarqube-plugin.service' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +describe('sonarqubePluginService', () => { + let service: SonarqubePluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://sonar.public/', + internalUrl: 'https://sonar.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + SonarqubePluginService, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(SonarqubePluginService) + }) + + it('returns the public SonarQube url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://sonar.public/projects') + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts new file mode 100644 index 0000000000..d1ffd9387e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts @@ -0,0 +1,22 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Inject, Injectable } from '@nestjs/common' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +@Injectable() +export class SonarqubePluginService { + constructor( + @Inject(SONARQUBE_CONFIG) + private readonly sonarqubeConfig: SonarqubeConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'sonarqube', + to: () => new URL('projects', this.sonarqubeConfig.url).toString(), + title: 'SonarQube', + imgSrc: '/img/sonarqube.svg', + description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts new file mode 100644 index 0000000000..0b7e8c0bbc --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts @@ -0,0 +1,90 @@ +import type { SonarqubeGeneratedToken, SonarqubeGroup, SonarqubePaging, SonarqubeProject, SonarqubeUser } from './sonarqube-client.service' +import type { ProjectWithDetails } from './sonarqube-datastore.service' +import { faker } from '@faker-js/faker' +import { SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' + +export function makeUserToken(overrides: Partial = {}) { + return { + token: faker.string.uuid(), + login: faker.internet.username(), + name: faker.person.fullName(), + ...overrides, + } satisfies SonarqubeGeneratedToken +} + +export function makeEmptyGroupsResponse() { + return { paging: makeSonarqubePaging(), groups: [] } +} + +export function makeEmptyUsersResponse() { + return { paging: makeSonarqubePaging(), users: [] } +} + +export function makeEmptyProjectsResponse() { + return { paging: makeSonarqubePaging(), components: [] } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.internet.domainWord(), + repositories: [], + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + +export function makeSonarqubeGroup(overrides: Partial = {}): SonarqubeGroup { + return { + id: faker.string.uuid(), + name: faker.internet.domainWord(), + description: '', + membersCount: 0, + default: false, + ...overrides, + } satisfies SonarqubeGroup +} + +export function makeSonarqubeUser(overrides: Partial = {}): SonarqubeUser { + return { + login: faker.internet.username(), + name: faker.person.fullName(), + active: true, + email: faker.internet.email(), + groups: [], + tokensCount: 0, + local: true, + externalIdentity: '', + externalProvider: '', + managed: false, + ...overrides, + } satisfies SonarqubeUser +} + +export function makeSonarqubeProject(overrides: Partial = {}): SonarqubeProject { + return { + key: faker.string.alphanumeric(20), + name: faker.internet.domainWord(), + qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, + visibility: 'private', + ...overrides, + } satisfies SonarqubeProject +} + +export function makeSonarqubePaging(overrides: Partial = {}): SonarqubePaging { + return { + pageIndex: 1, + pageSize: 100, + total: 0, + ...overrides, + } satisfies SonarqubePaging +} + +export function makeSonarqubeGeneratedToken(overrides: Partial = {}): SonarqubeGeneratedToken { + return { + token: faker.string.alphanumeric(40), + login: faker.internet.username(), + name: `Sonar Token for ${faker.internet.username()}`, + ...overrides, + } satisfies SonarqubeGeneratedToken +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts new file mode 100644 index 0000000000..9618e9341e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts @@ -0,0 +1,51 @@ +export const PLUGIN_NAME = 'sonarqube' +export const DEFAULT_PERMISSION_TEMPLATE_NAME = 'Forge Default' + +// SonarQube global permission names +export const GLOBAL_ADMIN_PERMISSIONS = ['admin', 'profileadmin', 'gateadmin', 'scan', 'provisioning'] as const + +// Permission template — grants to project creator and sonar-administrators on new projects +export const DEFAULT_TEMPLATE_PERMISSIONS = ['admin', 'codeviewer', 'issueadmin', 'securityhotspotadmin', 'scan', 'user'] as const + +// Project-level permission sets per role (SonarQube permission API names) +export const PROJECT_ADMIN_PERMISSIONS = ['admin', 'scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_DEVOPS_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_DEVELOPER_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_SECURITY_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_READONLY_PERMISSIONS = ['user', 'codeviewer'] as const + +// CI robot/service account — needs Execute Analysis + Browse + See Source Code +export const ROBOT_PROJECT_PERMISSIONS = ['scan', 'user', 'codeviewer'] as const + +// Default platform-wide Keycloak group paths +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_READONLY_GROUP_PATH = '/console/readonly' +export const DEFAULT_SECURITY_GROUP_PATH = '/console/security' + +// Default project role group path suffixes (appended to /{projectSlug}) +export const DEFAULT_PROJECT_ADMIN_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVOPS_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_SECURITY_SUFFIX = '/console/security' +export const DEFAULT_PROJECT_READONLY_SUFFIX = '/console/readonly' + +// Admin plugin config keys for overriding defaults +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const READONLY_GROUP_PATH_PLUGIN_KEY = 'readonlyGroupPath' +export const SECURITY_GROUP_PATH_PLUGIN_KEY = 'securityGroupPath' +export const PROJECT_ADMIN_SUFFIX_PLUGIN_KEY = 'projectAdminSuffix' +export const PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY = 'projectDevopsSuffix' +export const PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY = 'projectDeveloperSuffix' +export const PROJECT_SECURITY_SUFFIX_PLUGIN_KEY = 'projectSecuritySuffix' +export const PROJECT_READONLY_SUFFIX_PLUGIN_KEY = 'projectReadonlySuffix' + +// SonarQube project qualifier identifiers +export const SONARQUBE_PROJECT_QUALIFIER_APPLICATION = 'APP' +export const SONARQUBE_PROJECT_QUALIFIER_BRANCH = 'BRC' +export const SONARQUBE_PROJECT_QUALIFIER_DIRECTORY = 'DIR' +export const SONARQUBE_PROJECT_QUALIFIER_FILE = 'FIL' +export const SONARQUBE_PROJECT_QUALIFIER_LIBRARY = 'LIB' +export const SONARQUBE_PROJECT_QUALIFIER_PROJECT = 'TRK' +export const SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW = 'SVW' +export const SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST = 'UTS' +export const SONARQUBE_PROJECT_QUALIFIER_VIEW = 'VW' diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts new file mode 100644 index 0000000000..ef4dac617c --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts @@ -0,0 +1,16 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface SonarqubeConfig { + url: string + internalUrl: string + apiToken: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface SonarqubeModuleOptions extends SonarqubeConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('sonarqube') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as SONARQUBE_CONFIG } diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts new file mode 100644 index 0000000000..f054302ff4 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { SonarqubeHealthService } from './sonarqube-health.service' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' +import { SonarqubePluginService } from './sonarqube-plugin.service' +import { ConfigurableModuleClass } from './sonarqube.module-definition' +import { SonarqubeService } from './sonarqube.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule], + providers: [ + SonarqubeHealthService, + SonarqubeHttpClientService, + SonarqubeClientService, + SonarqubeDatastoreService, + SonarqubePluginService, + SonarqubeService, + ], + exports: [SonarqubeClientService, SonarqubeHealthService, SonarqubePluginService, SonarqubeService], +}) +export class SonarqubeModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts new file mode 100644 index 0000000000..e7d0ef90e1 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -0,0 +1,291 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { generateProjectKey } from '../../utils/crypto.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { + makeEmptyGroupsResponse, + makeEmptyProjectsResponse, + makeEmptyUsersResponse, + makeProjectWithDetails, + makeSonarqubePaging, + makeSonarqubeUser, + makeUserToken, +} from './sonarqube-testing.utils' +import { PLUGIN_NAME, SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' +import { SonarqubeService } from './sonarqube.service' + +describe('sonarqubeService', () => { + let service: SonarqubeService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let config: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + searchUserGroup: vi.fn().mockResolvedValue(makeEmptyGroupsResponse()), + createUserGroup: vi.fn().mockResolvedValue(undefined), + createPermissionTemplate: vi.fn().mockResolvedValue(undefined), + searchPermissionTemplates: vi.fn().mockResolvedValue({ permissionTemplates: [] }), + setPermissionDefaultTemplate: vi.fn().mockResolvedValue(undefined), + addPermissionGroupToTemplate: vi.fn().mockResolvedValue(undefined), + addPermissionGroup: vi.fn().mockResolvedValue(undefined), + addPermissionUser: vi.fn().mockResolvedValue(undefined), + searchUsers: vi.fn().mockResolvedValue(makeEmptyUsersResponse()), + createUser: vi.fn().mockResolvedValue(undefined), + deactivateUser: vi.fn().mockResolvedValue(undefined), + revokeUserToken: vi.fn().mockResolvedValue(undefined), + searchProject: vi.fn().mockResolvedValue(makeEmptyProjectsResponse()), + createProject: vi.fn().mockResolvedValue(undefined), + deleteProject: vi.fn().mockResolvedValue(undefined), + }) + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + readSonarqubeUser: vi.fn().mockResolvedValue(null), + writeSonarqubeUser: vi.fn().mockResolvedValue(undefined), + deleteSonarqubeUser: vi.fn().mockResolvedValue(undefined), + }) + config = mockDeep({ + internalOrPublicUrl: 'https://sonarqube.internal', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + SonarqubeService, + { provide: SonarqubeClientService, useValue: client }, + { provide: SonarqubeDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(SonarqubeService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('init', () => { + it('should set up the permission template', async () => { + await service.init() + expect(client.createPermissionTemplate).toHaveBeenCalledWith({ name: 'Forge Default' }) + expect(client.setPermissionDefaultTemplate).toHaveBeenCalledWith({ templateName: 'Forge Default' }) + }) + + it('should not recreate the permission template when it already exists', async () => { + client.searchPermissionTemplates.mockResolvedValue({ + permissionTemplates: [{ id: '1', name: 'Forge Default' }], + }) + await service.init() + expect(client.createPermissionTemplate).not.toHaveBeenCalled() + expect(client.setPermissionDefaultTemplate).toHaveBeenCalledWith({ templateName: 'Forge Default' }) + }) + + it('should create /console/admin group with global permissions when it does not exist', async () => { + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/admin' })) + }) + + it('should create /console/readonly and /console/security platform groups', async () => { + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/readonly' })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/security' })) + }) + + it('should not create groups that already exist', async () => { + client.searchUserGroup.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 1 }), + groups: [{ id: '1', name: '/console/admin', description: '', membersCount: 1, default: false }], + }) + await service.init() + expect(client.createUserGroup).not.toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) + }) + + it('should use custom group paths from admin plugin config', async () => { + datastore.getAdminPluginConfig.mockImplementation((_plugin, key) => { + if (key === 'adminGroupPath') return Promise.resolve('/custom/admin') + return Promise.resolve(null) + }) + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/custom/admin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/custom/admin' })) + }) + }) + + describe('handleUpsert', () => { + it('should create the 5 project role groups in SonarQube', async () => { + const project = makeProjectWithDetails() + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/admin` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/devops` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/developer` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/security` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/readonly` })) + }) + + it('should create a new user and write vault credentials', async () => { + const project = makeProjectWithDetails() + const userToken = makeUserToken({ login: project.slug }) + client.generateUserToken.mockResolvedValue(userToken) + + await service.handleUpsert(project) + + expect(client.createUser).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(client.generateUserToken).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(vault.writeSonarqubeUser).toHaveBeenCalledWith(project.slug, expect.objectContaining({ SONAR_USERNAME: project.slug, SONAR_TOKEN: userToken.token })) + }) + + it('should set role-based permissions on new repositories', async () => { + const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'repo' }] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.createProject).toHaveBeenCalledWith(expect.objectContaining({ visibility: 'private', name: `${project.slug}-repo` })) + expect(client.addPermissionUser).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/admin` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/devops` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/readonly' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/security' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer`, permission: 'issueadmin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer`, permission: 'securityhotspotadmin' })) + }) + + it('should not recreate user or write vault when both user and secret exist', async () => { + const project = makeProjectWithDetails({ slug: 'existing', repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.createUser).not.toHaveBeenCalled() + expect(client.generateUserToken).not.toHaveBeenCalled() + expect(vault.writeSonarqubeUser).not.toHaveBeenCalled() + }) + + it('should rotate token when user exists but vault secret is missing', async () => { + const project = makeProjectWithDetails({ repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + + await service.handleUpsert(project) + + expect(client.createUser).not.toHaveBeenCalled() + expect(client.generateUserToken).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(vault.writeSonarqubeUser).toHaveBeenCalledWith(project.slug, expect.objectContaining({ SONAR_PASSWORD: 'not initialized' })) + }) + + it('should delete sonarqube projects for removed repositories', async () => { + const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'kept' }] }) + const keptKey = generateProjectKey(project.slug, 'kept') + const removedKey = generateProjectKey(project.slug, 'removed') + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 2 }), + components: [ + { key: keptKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + { key: removedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + ], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.deleteProject).toHaveBeenCalledWith({ project: removedKey }) + expect(client.deleteProject).not.toHaveBeenCalledWith({ project: keptKey }) + }) + + it('should not delete sonarqube projects whose key was not generated by the console', async () => { + const project = makeProjectWithDetails({ slug: 'my', repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 2 }), + components: [ + // manually created project, hash suffix does not match generateProjectKey + { key: 'my-manual-project', name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + // belongs to project "my-app" (repo "x"), not to project "my" + { key: generateProjectKey('my-app', 'x'), name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + ], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.deleteProject).not.toHaveBeenCalled() + }) + + it('should use comma-separated group path suffixes from project plugin config', async () => { + const project = makeProjectWithDetails({ + repositories: [{ internalRepoName: 'repo' }], + plugins: [{ pluginName: PLUGIN_NAME, key: 'projectAdminSuffix', value: '/console/admin,/console/owner' }], + }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/admin` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/owner` })) + }) + }) + + describe('handleDelete', () => { + it('should delete sonarqube projects, anonymize user and remove vault entry', async () => { + const project = makeProjectWithDetails({ slug: 'doomed' }) + const doomedKey = generateProjectKey('doomed', 'repo') + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 1 }), + components: [{ key: doomedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: 'doomed' })] }) + + await service.handleDelete(project) + + expect(client.deleteProject).toHaveBeenCalledWith({ project: doomedKey }) + expect(client.deactivateUser).toHaveBeenCalledWith({ login: 'doomed', anonymize: true }) + expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('doomed') + }) + + it('should skip anonymization when the user does not exist', async () => { + const project = makeProjectWithDetails({ slug: 'no-user' }) + + await service.handleDelete(project) + + expect(client.deactivateUser).not.toHaveBeenCalled() + expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('no-user') + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects and run init', async () => { + const projects = [ + makeProjectWithDetails({ repositories: [] }), + makeProjectWithDetails({ repositories: [] }), + ] + datastore.getAllProjects.mockResolvedValue(projects) + client.generateUserToken.mockImplementation(({ login }) => Promise.resolve(makeUserToken({ login }))) + + await service.handleCron() + + expect(client.searchProject).toHaveBeenCalledTimes(2) + expect(client.createPermissionTemplate).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts new file mode 100644 index 0000000000..a4500dcca9 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -0,0 +1,456 @@ +import type { OnModuleInit } from '@nestjs/common' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { SonarqubeUserSecret } from '../vault/vault-client.service' +import type { SonarqubeProjectResult, SonarqubeUser } from './sonarqube-client.service' +import type { ProjectWithDetails } from './sonarqube-datastore.service' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { generateProjectKey, generateRandomPassword } from '../../utils/crypto.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_PERMISSION_TEMPLATE_NAME, + DEFAULT_PROJECT_ADMIN_SUFFIX, + DEFAULT_PROJECT_DEVELOPER_SUFFIX, + DEFAULT_PROJECT_DEVOPS_SUFFIX, + DEFAULT_PROJECT_READONLY_SUFFIX, + DEFAULT_PROJECT_SECURITY_SUFFIX, + DEFAULT_READONLY_GROUP_PATH, + DEFAULT_SECURITY_GROUP_PATH, + DEFAULT_TEMPLATE_PERMISSIONS, + GLOBAL_ADMIN_PERMISSIONS, + PLUGIN_NAME, + PROJECT_ADMIN_PERMISSIONS, + PROJECT_ADMIN_SUFFIX_PLUGIN_KEY, + PROJECT_DEVELOPER_PERMISSIONS, + PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_PERMISSIONS, + PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY, + PROJECT_READONLY_PERMISSIONS, + PROJECT_READONLY_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_PERMISSIONS, + PROJECT_SECURITY_SUFFIX_PLUGIN_KEY, + READONLY_GROUP_PATH_PLUGIN_KEY, + ROBOT_PROJECT_PERMISSIONS, + SECURITY_GROUP_PATH_PLUGIN_KEY, +} from './sonarqube.constants' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +interface SonarqubeRolePaths { + admin: string[] + devops: string[] + developer: string[] + security: string[] + readonly: string[] +} + +@Injectable() +export class SonarqubeService implements OnModuleInit { + private readonly logger = new Logger(SonarqubeService.name) + + constructor( + @Inject(SonarqubeDatastoreService) private readonly datastore: SonarqubeDatastoreService, + @Inject(SonarqubeClientService) private readonly client: SonarqubeClientService, + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('SonarqubeService initialized') + } + + async onModuleInit() { + await this.init().catch(error => this.logger.error({ error }, 'SonarQube initialization failed')) + } + + @StartActiveSpan() + async init(): Promise { + this.logger.log('Initializing SonarQube platform configuration') + const adminGroupPath = await this.getAdminGroupPath() + const [readonlyGroupPath, securityGroupPath] = await Promise.all([ + this.getReadonlyGroupPath(), + this.getSecurityGroupPath(), + ]) + await this.ensureDefaultPermissionTemplate() + await Promise.all([ + this.ensureGroupWithGlobalPermissions(adminGroupPath, GLOBAL_ADMIN_PERMISSIONS), + this.ensureGroup(readonlyGroupPath), + this.ensureGroup(securityGroupPath), + ]) + this.logger.log('SonarQube platform configuration initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('sonarqube', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`SonarQube sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('sonarqube', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.deleteProjectGroup(project) + this.logger.log(`SonarQube deletion completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting SonarQube reconciliation') + await this.init().catch(error => this.logger.error('SonarQube init during cron failed', error)) + const projects = await this.datastore.getAllProjects() + span?.setAttribute('sonarqube.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for SonarQube reconciliation`) + await this.ensureProjectGroups(projects) + this.logger.log(`SonarQube reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('sonarqube.projects.count', projects.length) + await Promise.all(projects.map(p => + this.ensureProjectGroup(p).catch(error => + this.logger.error(`Failed to reconcile SonarQube project (slug=${p.slug})`, error), + ), + )) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const rolePaths = await this.getProjectRoleGroupPaths(project) + await Promise.all([ + this.ensureUser(project.slug, project.slug), + this.ensureProjectSonarGroups(rolePaths), + this.ensureProjectRepositories(project, rolePaths), + ]) + } + + @StartActiveSpan() + private async deleteProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const sonarProjects = await this.findProjectsForSlug(project.slug) + span?.setAttribute('sonarqube.projects.count', sonarProjects.length) + this.logger.log(`Deleting ${sonarProjects.length} SonarQube repositories for project ${project.slug}`) + + await Promise.all(sonarProjects.map(async (sp) => { + await this.client.deleteProject({ project: sp.key }) + this.logger.verbose(`Deleted SonarQube repository (key=${sp.key})`) + })) + + const user = await this.findUser(project.slug) + if (user) { + await this.client.deactivateUser({ login: project.slug, anonymize: true }) + this.logger.log(`Anonymized SonarQube user (login=${project.slug})`) + } else { + this.logger.verbose(`SonarQube user not found, skipping anonymization (login=${project.slug})`) + } + + await this.vault.deleteSonarqubeUser(project.slug) + this.logger.verbose(`Deleted SonarQube vault credentials (slug=${project.slug})`) + } + + @StartActiveSpan() + private async ensureDefaultPermissionTemplate(): Promise { + this.logger.verbose(`Ensuring SonarQube permission template (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + const { permissionTemplates } = await this.client.searchPermissionTemplates({ q: DEFAULT_PERMISSION_TEMPLATE_NAME }) + + if (permissionTemplates.some(t => t.name.toLowerCase() === DEFAULT_PERMISSION_TEMPLATE_NAME.toLowerCase())) { + this.logger.verbose(`SonarQube permission template already exists (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } else { + await this.client.createPermissionTemplate({ name: DEFAULT_PERMISSION_TEMPLATE_NAME }) + this.logger.log(`Created SonarQube permission template (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } + await Promise.all(DEFAULT_TEMPLATE_PERMISSIONS.map(permission => + this.client.addPermissionProjectCreatorToTemplate({ templateName: DEFAULT_PERMISSION_TEMPLATE_NAME, permission }), + )) + await Promise.all(DEFAULT_TEMPLATE_PERMISSIONS.map(permission => + this.client.addPermissionGroupToTemplate({ groupName: 'sonar-administrators', templateName: DEFAULT_PERMISSION_TEMPLATE_NAME, permission }), + )) + await this.client.setPermissionDefaultTemplate({ templateName: DEFAULT_PERMISSION_TEMPLATE_NAME }) + this.logger.log(`SonarQube permission template ensured (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } + + @StartActiveSpan() + private async ensureUser(username: string, projectSlug: string): Promise { + const existingSecret = await this.vault.readSonarqubeUser(projectSlug) + const user = await this.findUser(username) + let newSecret: SonarqubeUserSecret | undefined + + if (!user) { + this.logger.log(`Creating SonarQube user (login=${username})`) + const password = generateRandomPassword(30) + await this.client.createUser({ email: `${projectSlug}@${projectSlug}`, local: 'true', login: username, name: username, password }) + const token = await this.rotateToken(username) + newSecret = { SONAR_USERNAME: username, SONAR_PASSWORD: password, SONAR_TOKEN: token } + } else if (existingSecret) { + this.logger.verbose(`SonarQube user already exists with vault credentials (login=${username})`) + } else { + this.logger.warn(`SonarQube user exists but vault secret is missing, rotating token (login=${username})`) + const token = await this.rotateToken(username) + newSecret = { SONAR_USERNAME: username, SONAR_PASSWORD: 'not initialized', SONAR_TOKEN: token } + } + + if (newSecret) { + await this.vault.writeSonarqubeUser(projectSlug, newSecret) + this.logger.log(`Stored SonarQube credentials in vault (slug=${projectSlug})`) + } + } + + private async ensureProjectSonarGroups(rolePaths: SonarqubeRolePaths): Promise { + const allGroups = [ + ...rolePaths.admin, + ...rolePaths.devops, + ...rolePaths.developer, + ...rolePaths.security, + ...rolePaths.readonly, + ] + await Promise.all(allGroups.map(group => this.ensureGroup(group))) + } + + @StartActiveSpan() + private async ensureProjectRepositories(project: ProjectWithDetails, rolePaths: SonarqubeRolePaths): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('repositories.count', project.repositories.length) + + const [readonlyGroupPath, securityGroupPath, existingSonarProjects] = await Promise.all([ + this.getReadonlyGroupPath(), + this.getSecurityGroupPath(), + this.findProjectsForSlug(project.slug), + ]) + + const orphans = existingSonarProjects.filter(sp => !project.repositories.some(r => r.internalRepoName === sp.repository)) + if (orphans.length) this.logger.log(`Removing ${orphans.length} orphan SonarQube repositories for project ${project.slug}`) + + await Promise.all([ + ...orphans.map(async (sp) => { + await this.client.deleteProject({ project: sp.key }) + this.logger.verbose(`Deleted orphan SonarQube repository (key=${sp.key})`) + }), + ...project.repositories.map(async (repository) => { + const projectKey = generateProjectKey(project.slug, repository.internalRepoName) + if (!existingSonarProjects.some(sp => sp.repository === repository.internalRepoName)) { + await this.client.createProject({ + project: projectKey, + visibility: 'private', + name: `${project.slug}-${repository.internalRepoName}`, + mainbranch: 'main', + }) + this.logger.log(`Created SonarQube repository (key=${projectKey})`) + } + await this.ensureProjectPermissions(projectKey, project.slug, rolePaths, readonlyGroupPath, securityGroupPath) + this.logger.verbose(`Ensured permissions on SonarQube repository (key=${projectKey})`) + }), + ]) + } + + private async ensureProjectPermissions( + projectKey: string, + login: string, + rolePaths: SonarqubeRolePaths, + readonlyGroupPath: string, + securityGroupPath: string, + ): Promise { + await Promise.all([ + ...ROBOT_PROJECT_PERMISSIONS.map(permission => + this.client.addPermissionUser({ projectKey, permission, login }), + ), + ...buildGroupPermissions(rolePaths, readonlyGroupPath, securityGroupPath).flatMap(({ groupName, permissions }) => + permissions.map(permission => this.client.addPermissionGroup({ projectKey, permission, groupName })), + ), + ]) + } + + private async ensureGroupWithGlobalPermissions(groupName: string, permissions: readonly string[]): Promise { + await this.ensureGroup(groupName) + await Promise.all(permissions.map(permission => + this.client.addPermissionGroup({ groupName, permission }), + )) + } + + private async ensureGroup(groupName: string): Promise { + const result = await this.client.searchUserGroup({ q: groupName }) + if (result.groups.some(g => g.name === groupName)) { + this.logger.verbose(`SonarQube group already exists (name=${groupName})`) + } else { + await this.client.createUserGroup({ name: groupName }) + this.logger.log(`Created SonarQube group (name=${groupName})`) + } + } + + private async rotateToken(login: string): Promise { + const name = `Sonar Token for ${login}` + await this.client.revokeUserToken({ login, name }).catch(() => {}) + const { token } = await this.client.generateUserToken({ login, name }) + this.logger.log(`Rotated SonarQube token (login=${login})`) + return token + } + + private async getAdminGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, ADMIN_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getReadonlyGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, READONLY_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_READONLY_GROUP_PATH + } + + private async getSecurityGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, SECURITY_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_SECURITY_GROUP_PATH + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return getProjectPluginConfig(project, key) ?? undefined + } + + private async getProjectRoleGroupPaths(project: ProjectWithDetails): Promise { + const [admin, devops, developer, security, readonly] = await Promise.all([ + this.getProjectAdminGroupPaths(project), + this.getProjectDevopsGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectSecurityGroupPaths(project), + this.getProjectReadonlyGroupPaths(project), + ]) + return { admin, devops, developer, security, readonly } + } + + private async getProjectAdminGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_ADMIN_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_ADMIN_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_ADMIN_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDevopsGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVOPS_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVELOPER_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectSecurityGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_SECURITY_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_SECURITY_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_SECURITY_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectReadonlyGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_READONLY_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_READONLY_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_READONLY_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async findUser(login: string): Promise { + let page = 1 + const pageSize = 100 + while (true) { + const response = await this.client.searchUsers({ q: login, ps: pageSize, p: page }) + const found = response.users.find(u => u.login === login) + if (found) return found + if (!response.users.length || response.paging.pageIndex * response.paging.pageSize >= response.paging.total) return undefined + page++ + } + } + + private async findProjectsForSlug(projectSlug: string): Promise { + let found: SonarqubeProjectResult[] = [] + let page = 0 + const pageSize = 100 + let total = 0 + do { + page++ + const result = await this.client.searchProject({ q: projectSlug, p: page, ps: pageSize }) + total = result.paging.total + found = [...found, ...filterProjectsOwningSlug(result.components, projectSlug)] + } while (page * pageSize < total) + return found + } +} + +function getProjectPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string): string[] { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(suffix => `/${projectSlug}${suffix}`) +} + +function buildGroupPermissions( + rolePaths: SonarqubeRolePaths, + readonlyGroupPath: string, + securityGroupPath: string, +): { groupName: string, permissions: readonly string[] }[] { + return [ + ...rolePaths.admin.map(groupName => ({ groupName, permissions: PROJECT_ADMIN_PERMISSIONS })), + ...rolePaths.devops.map(groupName => ({ groupName, permissions: PROJECT_DEVOPS_PERMISSIONS })), + ...rolePaths.developer.map(groupName => ({ groupName, permissions: PROJECT_DEVELOPER_PERMISSIONS })), + ...rolePaths.security.map(groupName => ({ groupName, permissions: PROJECT_SECURITY_PERMISSIONS })), + ...rolePaths.readonly.map(groupName => ({ groupName, permissions: PROJECT_READONLY_PERMISSIONS })), + { groupName: securityGroupPath, permissions: PROJECT_SECURITY_PERMISSIONS }, + { groupName: readonlyGroupPath, permissions: PROJECT_READONLY_PERMISSIONS }, + ] +} + +function filterProjectsOwningSlug( + components: { key: string }[], + projectSlug: string, +): SonarqubeProjectResult[] { + return components.reduce((acc, { key: sonarKey }) => { + const parts = sonarKey.split('-') + parts.pop() + for (let i = parts.length - 1; i > 0; i--) { + const project = parts.slice(0, i).join('-') + const repository = parts.slice(i).join('-') + // recompute the key (with its HMAC suffix) so keys not managed by the + // console, or owned by a project whose slug is a prefix, are never claimed + if (generateProjectKey(project, repository) === sonarKey && project === projectSlug) { + acc.push({ projectSlug, repository, key: sonarKey }) + break + } + } + return acc + }, []) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts new file mode 100644 index 0000000000..8b8fc8adff --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts @@ -0,0 +1,6 @@ +export function sonarProjectPropertiesFile(projectKey: string) { + return [ + `sonar.projectKey=${projectKey}`, + 'sonar.qualitygate.wait=true', + ] +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts new file mode 100644 index 0000000000..f4450b21d7 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts @@ -0,0 +1,11 @@ +import type { Prisma } from '@prisma/client' + +export const adminPluginSelect = { + pluginName: true, + key: true, + value: true, +} satisfies Prisma.AdminPluginSelect + +export type AdminPluginSelect = Prisma.AdminPluginGetPayload<{ + select: typeof adminPluginSelect +}> diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config.controller.ts new file mode 100644 index 0000000000..7f74d4d40d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config.controller.ts @@ -0,0 +1,27 @@ +import type { PluginsUpdateBody } from '@cpn-console/shared' +import { pluginUpdateBody } from '@cpn-console/shared' +import { Body, Controller, Get, HttpCode, HttpStatus, Inject, Post, UseGuards } from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { SystemConfigService } from './system-config.service' + +@Controller('api/v1/system/plugins') +export class SystemConfigController { + constructor(@Inject(SystemConfigService) private readonly service: SystemConfigService) {} + + @Get() + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async get() { + return this.service.list() + } + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async update(@Body(new ZodValidationPipe(pluginUpdateBody)) body: PluginsUpdateBody) { + await this.service.update(body) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config.module.ts new file mode 100644 index 0000000000..b0622abf84 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { SystemConfigController } from './system-config.controller' +import { SystemConfigService } from './system-config.service' + +@Module({ + imports: [DatabaseModule, UserPermissionModule], + controllers: [SystemConfigController], + providers: [SystemConfigService], +}) +export class SystemConfigModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts new file mode 100644 index 0000000000..0ba0742991 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts @@ -0,0 +1,41 @@ +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { SystemConfigService } from './system-config.service' + +describe('systemConfigService', () => { + let prisma: ReturnType> + let service: SystemConfigService + + beforeEach(() => { + prisma = mockDeep() + service = new SystemConfigService(prisma) + }) + + it('returns plugin config items', async () => { + prisma.adminPlugin.findMany.mockResolvedValue([ + { pluginName: 'argocd', key: 'url', value: 'https://argocd' }, + ]) + + const result = await service.list() + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + }) + + it('updates plugins config from body via transaction', async () => { + prisma.$transaction.mockImplementation(async (callback: any) => callback(prisma)) + prisma.adminPlugin.upsert.mockResolvedValue({ pluginName: 'argocd', key: 'url', value: 'https://argocd' }) + + await service.update({ argocd: { url: 'https://argocd' } }) + + expect(prisma.$transaction).toHaveBeenCalledTimes(1) + }) + + it('does not call upsert for empty body', async () => { + prisma.$transaction.mockImplementation(async (callback: any) => callback(prisma)) + + await service.update({}) + + expect(prisma.adminPlugin.upsert).toHaveBeenCalledTimes(0) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config.service.ts new file mode 100644 index 0000000000..100cee22bc --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-config/system-config.service.ts @@ -0,0 +1,46 @@ +import type { PluginsUpdateBody } from '@cpn-console/shared' +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { BadRequestException, Injectable } from '@nestjs/common' + +@Injectable() +export class SystemConfigService { + constructor(private readonly prisma: PrismaService) {} + + async list() { + const globalConfig = await this.prisma.adminPlugin.findMany({ + select: { key: true, value: true, pluginName: true }, + }) + + return Object.values(servicesInfos).map(({ name, title, imgSrc, description }) => { + const manifest = populatePluginManifests({ + data: { global: globalConfig }, + permissionTarget: 'admin', + pluginName: name, + select: { global: true, project: false }, + }) + return { imgSrc, title, name, manifest: manifest.global ?? [], description } + }).filter(plugin => plugin.manifest.length > 0) + } + + async update(body: PluginsUpdateBody) { + const parsedData = editStrippers.global.safeParse(body) + if (!parsedData.success) { + throw new BadRequestException(parsedData.error.message) + } + + const records = Object.entries(parsedData.data) + .flatMap(([pluginName, values]: [string, Record]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value: String(value) }))) + + await this.prisma.$transaction(async (tx) => { + for (const { pluginName, key, value } of records) { + await tx.adminPlugin.upsert({ + create: { pluginName, key, value }, + update: { key, value, pluginName }, + where: { pluginName_key: { pluginName, key } }, + }) + } + }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts new file mode 100644 index 0000000000..bdc5e91f25 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts @@ -0,0 +1,15 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +export function makeSystemSetting() { + return { + key: faker.string.alphanumeric(), + value: faker.string.alphanumeric(), + } satisfies SystemSetting +} + +export function makeSystemSettings() { + return faker.helpers.multiple(() => makeSystemSetting(), { + count: faker.number.int({ min: 1, max: 10 }), + }) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts new file mode 100644 index 0000000000..9644dc97f0 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts @@ -0,0 +1,24 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { SystemSettingSchema } from '@cpn-console/shared' +import { Body, Controller, Get, Inject, Put, Query } from '@nestjs/common' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { SystemSettingsService } from './system-settings.service' + +@Controller('api/v1/system/settings') +export class SystemSettingsController { + constructor(@Inject(SystemSettingsService) private readonly service: SystemSettingsService) {} + + @Get() + async list( + @Query() query: string, + ) { + return this.service.list(query) + } + + @Put(':key') + async upsert( + @Body(new ZodValidationPipe(SystemSettingSchema)) data: SystemSetting, + ) { + return this.service.upsert(data) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts new file mode 100644 index 0000000000..704b575e14 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { SystemSettingsController } from './system-settings.controller' +import { SystemSettingsService } from './system-settings.service' + +@Module({ + imports: [DatabaseModule], + controllers: [SystemSettingsController], + providers: [SystemSettingsService], + exports: [SystemSettingsService], +}) +export class SystemSettingsModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts new file mode 100644 index 0000000000..ae9ec6e161 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts @@ -0,0 +1,84 @@ +import type { TestingModule } from '@nestjs/testing' +import type { PrismaClient } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeSystemSetting, makeSystemSettings } from './system-settings-testing.utils' +import { SystemSettingsService } from './system-settings.service' + +describe('systemSettingsService', () => { + let module: TestingModule + let service: SystemSettingsService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + SystemSettingsService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(SystemSettingsService) + }) + + describe('list', () => { + it('should return all settings', async () => { + const systemSettings = makeSystemSettings() + + prisma.systemSetting.findMany.mockResolvedValue(systemSettings) + + const result = await service.list() + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: undefined } }) + expect(result).toEqual(systemSettings) + }) + + it('should return empty array if no settings are found', async () => { + prisma.systemSetting.findMany.mockResolvedValue([]) + + const result = await service.list() + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: undefined } }) + expect(result).toEqual([]) + }) + + it('should return setting by key', async () => { + const systemSettings = makeSystemSettings() + const systemSetting = faker.helpers.arrayElement(systemSettings) + prisma.systemSetting.findMany.mockResolvedValue([systemSetting]) + + const result = await service.list(systemSetting.key) + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: systemSetting.key } }) + expect(result).toEqual([systemSetting]) + }) + + it('should return empty array if key is not found', async () => { + const key = faker.string.alphanumeric(10) + prisma.systemSetting.findMany.mockResolvedValue([]) + + const result = await service.list(key) + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key } }) + expect(result).toEqual([]) + }) + }) + + describe('upsert', () => { + it('should update setting if it exists', async () => { + const systemSetting = makeSystemSetting() + prisma.systemSetting.upsert.mockResolvedValue(systemSetting) + + const result = await service.upsert(systemSetting) + + expect(result.key).toBe(systemSetting.key) + expect(result.value).toBe(systemSetting.value) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts new file mode 100644 index 0000000000..06c5e72603 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts @@ -0,0 +1,20 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +@Injectable() +export class SystemSettingsService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(key?: string): Promise { + return this.prisma.systemSetting.findMany({ where: { key } }) + } + + async upsert(data: SystemSetting): Promise { + return this.prisma.systemSetting.upsert({ + create: data, + update: data, + where: { key: data.key }, + }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts new file mode 100644 index 0000000000..d6cce4272f --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts @@ -0,0 +1,109 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from './vault-client.service' +import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { VAULT_CONFIG } from './vault.module-definition' + +const vaultUrl = 'https://vault.internal' + +const server = setupServer( + http.post(`${vaultUrl}/v1/auth/token/create`, () => { + return HttpResponse.json({ auth: { client_token: 'token' } }) + }), + http.get(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ data: { data: { secret: 'value' }, metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 } } }) + }), + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({}) + }), + http.delete(`${vaultUrl}/v1/kv/metadata/:path`, () => { + return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) + }), +) + +describe('vault', () => { + let service: VaultClientService + + beforeAll(() => server.listen()) + beforeEach(async () => { + const config = mockDeep({ + token: 'token', + url: vaultUrl, + internalUrl: vaultUrl, + kvName: 'kv', + internalOrPublicUrl: vaultUrl, + }) + const baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + + const module = await Test.createTestingModule({ + providers: [ + VaultClientService, + VaultHttpClientService, + { provide: VAULT_CONFIG, useValue: config }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(VaultClientService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + describe('read', () => { + it('should read secret', async () => { + const result = await service.read('path') + expect(result).toEqual({ + data: { secret: 'value' }, + metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 }, + }) + }) + + it('should throw if 404', async () => { + server.use( + http.get(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.read('path')).rejects.toBeInstanceOf(VaultError) + await expect(service.read('path')).rejects.toMatchObject({ kind: 'NotFound', status: HttpStatus.NOT_FOUND }) + }) + }) + + describe('write', () => { + it('should write secret', async () => { + await expect(service.write({ secret: 'value' }, 'path')).resolves.toBeUndefined() + }) + + it('should expose reasons on error', async () => { + server.use( + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ errors: ['No secret engine mount at test-project/'] }, { status: HttpStatus.BAD_REQUEST }) + }), + ) + + await expect(service.write({ secret: 'value' }, 'path')).rejects.toBeInstanceOf(VaultError) + await expect(service.write({ secret: 'value' }, 'path')).rejects.toMatchObject({ + kind: 'HttpError', + status: HttpStatus.BAD_REQUEST, + reasons: ['No secret engine mount at test-project/'], + }) + await expect(service.write({ secret: 'value' }, 'path')).rejects.toThrow('Request failed') + }) + }) + + describe('delete', () => { + it('should delete secret', async () => { + await expect(service.delete('path')).resolves.toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-client.service.ts new file mode 100644 index 0000000000..dd07abebbd --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -0,0 +1,410 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { VAULT_CONFIG } from './vault.module-definition' +import { generateGitlabMirrorCredPath, generateSonarqubeCredPath, generateTechReadOnlyCredPath } from './vault.utils' + +export interface VaultSysPoliciesAclUpsertRequest { + policy: string +} + +export interface VaultSysMountCreateRequest { + type: string + config: { + force_no_cache: boolean + } + options: { + version: number + } +} + +export interface VaultSysMountTuneRequest { + options: { + version: number + } +} + +export interface VaultAuthApproleRoleUpsertRequest { + secret_id_num_uses: string + secret_id_ttl: string + token_max_ttl: string + token_num_uses: string + token_ttl: string + token_type: string + token_policies: string[] +} + +export interface VaultIdentityGroupUpsertRequest { + name: string + type: string + policies: string[] +} + +export interface VaultIdentityGroupAliasCreateRequest { + name: string + mount_accessor: string + canonical_id: string +} + +export interface VaultAuthMethod { + accessor: string + type: string + description?: string +} + +export interface VaultSysAuthResponse { + data: Record +} + +export interface VaultIdentityGroupResponse { + data: { + id: string + name: string + alias?: { + id?: string + name?: string + } + } +} + +export interface SonarqubeUserSecret { + SONAR_USERNAME: string + SONAR_PASSWORD: string + SONAR_TOKEN: string +} + +export interface VaultMetadata { + created_time: string + custom_metadata: Record | null + deletion_time: string + destroyed: boolean + version: number +} + +export interface VaultSecret { + data: T + metadata: VaultMetadata +} + +export interface VaultResponse { + data: VaultSecret +} + +export interface VaultListResponse { + data: { + keys: string[] + } +} + +export interface VaultRoleIdResponse { + data: { + role_id: string + } +} + +export interface VaultSecretIdResponse { + data: { + secret_id: string + } +} + +@Injectable() +export class VaultClientService { + private readonly logger = new Logger(VaultClientService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultHttpClientService) private readonly http: VaultHttpClientService, + ) { + } + + @StartActiveSpan() + async getKvData(kvName: string, path: string): Promise> { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Reading Vault KV data (kvName=${kvName}, path=${path})`) + const response = await this.http.fetch>(`${kvName}/data/${path}`) + if (!response?.data) { + throw new VaultError('InvalidResponse', 'Missing "data" field', { method: 'GET', path: `${kvName}/data/${path}` }) + } + return response.data + } + + @StartActiveSpan() + async upsertKvData(kvName: string, path: string, body: { data: T }): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Writing Vault KV data (kvName=${kvName}, path=${path})`) + await this.http.fetch(`${kvName}/data/${path}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async read(path: string): Promise> { + this.logger.debug(`Reading Vault KV secret at ${path}`) + return await this.getKvData(this.vaultConfig.kvName, path) + } + + @StartActiveSpan() + async write(data: T, path: string): Promise { + this.logger.debug(`Writing Vault KV secret at ${path}`) + await this.upsertKvData(this.vaultConfig.kvName, path, { data }) + } + + @StartActiveSpan() + async delete(path: string): Promise { + this.logger.debug(`Deleting Vault KV secret at ${path}`) + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.path', path) + return await this.deleteKvMetadata(this.vaultConfig.kvName, path) + } + + @StartActiveSpan() + async readGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Reading Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + return await this.read(vaultCredsPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeGitlabMirrorCreds(projectSlug: string, repoName: string, data: Record): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Writing Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + await this.write(data, vaultCredsPath) + } + + @StartActiveSpan() + async deleteGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Deleting Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + await this.delete(vaultCredsPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + }) + } + + @StartActiveSpan() + async readTechnReadOnlyCreds(projectSlug: string): Promise { + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + return await this.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeTechReadOnlyCreds(projectSlug: string, creds: Record): Promise { + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + await this.write(creds, vaultPath) + } + + @StartActiveSpan() + async readSonarqubeUser(projectSlug: string): Promise | null> { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Reading Vault SonarQube user credentials (projectSlug=${projectSlug})`) + return await this.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeSonarqubeUser(projectSlug: string, secret: SonarqubeUserSecret): Promise { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Writing Vault SonarQube user credentials (projectSlug=${projectSlug})`) + await this.write(secret, vaultPath) + } + + @StartActiveSpan() + async deleteSonarqubeUser(projectSlug: string): Promise { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Deleting Vault SonarQube user credentials (projectSlug=${projectSlug})`) + await this.delete(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + }) + } + + @StartActiveSpan() + async writeMirrorTriggerToken(secret: Record): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.path', 'GITLAB') + await this.write(secret, 'GITLAB') + } + + @StartActiveSpan() + async deleteKvMetadata(kvName: string, path: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + try { + await this.http.fetch(`${kvName}/metadata/${path}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + } + + @StartActiveSpan() + async listKvMetadata(kvName: string, path: string): Promise { + try { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Listing Vault KV metadata (kvName=${kvName}, path=${path})`) + const response = await this.http.fetch(`${kvName}/metadata/${path}`, { method: 'LIST' }) + if (!response?.data?.keys) { + throw new VaultError('InvalidResponse', 'Missing "data.keys" field', { method: 'LIST', path: `${kvName}/metadata/${path}` }) + } + return response.data.keys + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return [] + throw error + } + } + + @StartActiveSpan() + async upsertSysPoliciesAcl(policyName: string, body: VaultSysPoliciesAclUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault ACL policy ${policyName}`) + await this.http.fetch(`sys/policies/acl/${policyName}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSysPoliciesAcl(policyName: string): Promise { + this.logger.verbose(`Deleting Vault ACL policy ${policyName}`) + await this.http.fetch(`sys/policies/acl/${policyName}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async createSysMount(name: string, body: VaultSysMountCreateRequest): Promise { + this.logger.verbose(`Creating Vault mount ${name} (version=${body.options.version})`) + await this.http.fetch(`sys/mounts/${name}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async tuneSysMount(name: string, body: VaultSysMountTuneRequest): Promise { + this.logger.verbose(`Tuning Vault mount ${name} (version=${body.options.version})`) + await this.http.fetch(`sys/mounts/${name}/tune`, { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSysMounts(name: string): Promise { + this.logger.verbose(`Deleting Vault mount ${name}`) + await this.http.fetch(`sys/mounts/${name}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async upsertAuthApproleRole(roleName: string, body: VaultAuthApproleRoleUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault AppRole ${roleName} (policies=${body.token_policies.length})`) + await this.http.fetch(`auth/approle/role/${roleName}`, { + method: 'POST', + body, + }) + } + + @StartActiveSpan() + async deleteAuthApproleRole(roleName: string): Promise { + this.logger.verbose(`Deleting Vault AppRole ${roleName}`) + await this.http.fetch(`auth/approle/role/${roleName}`, { method: 'DELETE' }) + } + + async getAuthApproleRoleRoleId(roleName: string) { + const path = `auth/approle/role/${roleName}/role-id` + this.logger.verbose(`Reading Vault AppRole role-id for ${roleName}`) + const response = await this.http.fetch(path) + const roleId = response?.data?.role_id + if (!roleId) { + throw new VaultError('InvalidResponse', `Vault role-id not found for role ${roleName}`, { method: 'GET', path }) + } + return roleId + } + + @StartActiveSpan() + async createAuthApproleRoleSecretId(roleName: string) { + const path = `auth/approle/role/${roleName}/secret-id` + this.logger.verbose(`Creating Vault AppRole secret-id for ${roleName}`) + const response = await this.http.fetch(path, { method: 'POST' }) + const secretId = response?.data?.secret_id + if (!secretId) { + throw new VaultError('InvalidResponse', `Vault secret-id not generated for role ${roleName}`, { method: 'POST', path }) + } + return secretId + } + + async getSysAuth(): Promise> { + this.logger.verbose('Listing Vault auth methods') + const response = await this.http.fetch('sys/auth') + return response?.data ?? {} + } + + @StartActiveSpan() + async upsertIdentityGroupName(groupName: string, body: VaultIdentityGroupUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault identity group ${groupName} (policies=${body.policies.length})`) + await this.http.fetch(`identity/group/name/${groupName}`, { + method: 'POST', + body, + }) + } + + @StartActiveSpan() + async getIdentityGroupName(groupName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.name', groupName) + const path = `identity/group/name/${groupName}` + const response = await this.http.fetch(path) + if (!response) throw new VaultError('InvalidResponse', 'Empty response', { method: 'GET', path }) + return response + } + + @StartActiveSpan() + async deleteIdentityGroupName(groupName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.name', groupName) + this.logger.verbose(`Deleting Vault identity group ${groupName}`) + await this.http.fetch(`identity/group/name/${groupName}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async createIdentityGroupAlias(body: VaultIdentityGroupAliasCreateRequest): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.alias', body.name) + this.logger.verbose(`Creating Vault identity group alias (aliasName=${body.name}, canonicalId=${body.canonical_id})`) + await this.http.fetch('identity/group-alias', { method: 'POST', body }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts new file mode 100644 index 0000000000..dfb48d9a71 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts @@ -0,0 +1,84 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + environments: { + select: { + id: true, + clusterId: true, + cpu: true, + memory: true, + autosync: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export const zoneSelect = { + id: true, + slug: true, + clusters: { + select: { + projects: { + select: { + id: true, + }, + }, + }, + }, +} satisfies Prisma.ZoneSelect + +export type ZoneWithDetails = Prisma.ZoneGetPayload<{ + select: typeof zoneSelect +}> + +@Injectable() +export class VaultDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ where: { id }, select: projectSelect }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } + + async getAllZones(): Promise { + return this.prisma.zone.findMany({ + select: zoneSelect, + }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-health.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-health.service.ts new file mode 100644 index 0000000000..daddabd5b4 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-health.service.ts @@ -0,0 +1,23 @@ +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { VAULT_CONFIG } from './vault.module-definition' + +@Injectable() +export class VaultHealthService { + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.vaultConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts new file mode 100644 index 0000000000..2a80532efe --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts @@ -0,0 +1,135 @@ +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus, Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VAULT_CONFIG } from './vault.module-definition' + +export interface VaultFetchOptions { + method?: string + body?: unknown +} + +export type VaultErrorKind + = | 'NotConfigured' + | 'NotFound' + | 'HttpError' + | 'InvalidResponse' + | 'ParseError' + | 'Unexpected' + +export class VaultError extends Error { + readonly kind: VaultErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + readonly reasons?: string[] + + constructor( + kind: VaultErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string, reasons?: string[] } = {}, + ) { + super(message) + this.name = 'VaultError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + this.reasons = details.reasons + } +} + +@Injectable() +export class VaultHttpClientService { + private readonly logger = new Logger(VaultHttpClientService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + ) {} + + @StartActiveSpan() + async fetch( + path: string, + options: VaultFetchOptions = {}, + ): Promise { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('vault.method', method) + span?.setAttribute('vault.path', path) + + this.logger.verbose(`Starting Vault request: ${method} ${path}`) + this.logger.debug(`Vault request: ${method} ${path}`) + const request = this.createRequest(path, method, options.body) + const response = await fetch(request).catch((error) => { + if (error instanceof Error) { + this.logger.error(`Vault request failed: ${method} ${path}: ${error.message}`, error.stack) + } else { + this.logger.error(`Vault request failed: ${method} ${path}: ${String(error)}`) + } + throw new VaultError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('vault.http.status', response.status) + this.logger.debug(`Vault response: ${method} ${path} status=${response.status}`) + + const parsed = await this.handleResponse(response, method, path) + this.logger.verbose(`Completed Vault request: ${method} ${path} status=${response.status} outcome=${parsed === null ? 'no-content' : 'ok'}`) + return parsed + } + + private get apiBaseUrl() { + return new URL('v1/', this.vaultConfig.internalOrPublicUrl).toString() + } + + private createRequest(path: string, method: string, body?: unknown): Request { + const url = new URL(path, this.apiBaseUrl).toString() + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Vault-Token': this.vaultConfig.token, + } + + return new Request(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }) + } + + private async handleResponse(response: Response, method: string, path: string): Promise { + if (response.status === HttpStatus.NO_CONTENT) return null + + if (!response.ok) { + await this.throwForStatus(response, method, path) + } + + return await response.json() + } + + private async throwForStatus(response: Response, method: string, path: string): Promise { + const responseBody = await response.json() + const vaultErrorBody = z.object({ errors: z.array(z.string()) }).safeParse(responseBody) + const reasons = vaultErrorBody.success ? vaultErrorBody.data.errors : undefined + const reasonsPart = reasons?.length ? ` reasons=${reasons.join('; ')}` : '' + this.logger.warn(`Vault request returned error: ${method} ${path} status=${response.status} statusText=${response.statusText}${reasonsPart}`) + + if (response.status === 404) { + throw new VaultError('NotFound', 'Not Found', { + status: 404, + method, + path, + statusText: response.statusText, + reasons, + }) + } + + throw new VaultError('HttpError', 'Request failed', { + status: response.status, + method, + path, + statusText: response.statusText, + reasons, + }) + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts new file mode 100644 index 0000000000..a1d5e06eea --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { VaultConfig } from './vault.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { VaultPluginService } from './vault-plugin.service' +import { VAULT_CONFIG } from './vault.module-definition' + +describe('vaultPluginService', () => { + let service: VaultPluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://vault.public/', + internalUrl: 'https://vault.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + VaultPluginService, + { provide: VAULT_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(VaultPluginService) + }) + + it('returns the public Vault url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://vault.public/ui/vault/secrets/dulei') + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts new file mode 100644 index 0000000000..0095ac34cf --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts @@ -0,0 +1,21 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable } from '@nestjs/common' +import { VAULT_CONFIG } from './vault.module-definition' + +@Injectable() +export class VaultPluginService { + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'vault', + to: ({ project }) => new URL(`ui/vault/secrets/${project.slug}`, this.vaultConfig.url).toString(), + title: 'Vault', + imgSrc: '/img/vault.svg', + description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', + } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts new file mode 100644 index 0000000000..2397b97bdc --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts @@ -0,0 +1,43 @@ +import type { VaultSecret } from './vault-client.service' +import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + name: faker.company.name(), + description: faker.company.buzzPhrase(), + environments: [], + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + +export function makeZoneWithDetails(overrides: Partial = {}): ZoneWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-zone-${faker.string.uuid()}`), + clusters: [], + ...overrides, + } satisfies ZoneWithDetails +} + +export function makeVaultSecret(overrides: Partial = {}): VaultSecret { + return { + data: {}, + metadata: makeVaultSecretMetadata(), + ...overrides, + } satisfies VaultSecret +} + +export function makeVaultSecretMetadata(overrides: Partial = {}): VaultSecret['metadata'] { + return { + created_time: faker.date.soon().toISOString(), + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + ...overrides, + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.constants.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.constants.ts new file mode 100644 index 0000000000..22adfa3275 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.constants.ts @@ -0,0 +1,34 @@ +// Plugin name used to identify the Vault integration +export const PLUGIN_NAME = 'vault' + +// Platform-level policy names for RBAC +export const PLATFORM_ADMIN_POLICY_NAME = 'platform--admin' +export const PLATFORM_READONLY_POLICY_NAME = 'platform--readonly' +export const PLATFORM_SECURITY_POLICY_NAME = 'platform--security' + +// Console group names for user role assignment +export const CONSOLE_ADMIN_GROUP_NAME = 'console-admin' +export const CONSOLE_READONLY_GROUP_NAME = 'console-readonly' +export const CONSOLE_SECURITY_GROUP_NAME = 'console-security' + +// Default LDAP/AD group paths for console access +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly' +export const DEFAULT_SECURITY_GROUP_PATH = '/console/security' + +// Default suffixes for project-scoped group paths +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly' +export const DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX = '/console/security' + +// Plugin configuration keys for custom group paths +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const AUDITOR_GROUP_PATH_PLUGIN_KEY = 'auditorGroupPath' +export const SECURITY_GROUP_PATH_PLUGIN_KEY = 'securityGroupPath' +export const PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReporterGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDeveloperGroupPathSuffix' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevopsGroupPathSuffix' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectMaintainerGroupPathSuffix' +export const PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectSecurityGroupPathSuffix' diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.module-definition.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.module-definition.ts new file mode 100644 index 0000000000..d0f1b425cf --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.module-definition.ts @@ -0,0 +1,17 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface VaultConfig { + token?: string + url: string + internalUrl: string + kvName: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface VaultModuleOptions extends VaultConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('vault') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as VAULT_CONFIG } diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.module.ts new file mode 100644 index 0000000000..367a1527a2 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { VaultHealthService } from './vault-health.service' +import { VaultHttpClientService } from './vault-http-client.service' +import { VaultPluginService } from './vault-plugin.service' +import { ConfigurableModuleClass } from './vault.module-definition' +import { VaultService } from './vault.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + providers: [ + VaultHealthService, + VaultHttpClientService, + VaultClientService, + VaultPluginService, + VaultService, + VaultDatastoreService, + ], + exports: [VaultClientService, VaultHealthService, VaultPluginService, VaultService], +}) +export class VaultModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.service.spec.ts new file mode 100644 index 0000000000..d46cb7b75c --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -0,0 +1,153 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { makeProjectWithDetails, makeVaultSecret, makeZoneWithDetails } from './vault-testing.utils' +import { VAULT_CONFIG } from './vault.module-definition' +import { VaultService } from './vault.service' + +const projectRoleGroupNameRegex = /^project-(.*)-(admin|devops|developer|readonly|security)$/ + +describe('vaultService', () => { + let service: VaultService + let datastore: DeepMockProxy + let client: DeepMockProxy + + beforeEach(async () => { + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + client = mockDeep({ + createSysMount: vi.fn().mockResolvedValue(undefined), + tuneSysMount: vi.fn().mockResolvedValue(undefined), + deleteSysMounts: vi.fn().mockResolvedValue(undefined), + upsertSysPoliciesAcl: vi.fn().mockResolvedValue(undefined), + deleteSysPoliciesAcl: vi.fn().mockResolvedValue(undefined), + upsertAuthApproleRole: vi.fn().mockResolvedValue(undefined), + deleteAuthApproleRole: vi.fn().mockResolvedValue(undefined), + getIdentityGroupName: vi.fn(async (groupName: string) => makeVaultSecret({ data: { id: 'gid', name: groupName } })), + deleteIdentityGroupName: vi.fn().mockResolvedValue(undefined), + getSysAuth: vi.fn().mockResolvedValue({ 'oidc/': { accessor: 'oidc-accessor', type: 'oidc' } }), + createIdentityGroupAlias: vi.fn().mockResolvedValue(undefined), + listKvMetadata: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + }) + + const vaultConfig = mockDeep({ kvName: 'kv' }) + const baseConfig = mockDeep({ projectsRootDir: 'forge' }) + + const module = await Test.createTestingModule({ + providers: [ + VaultService, + { provide: VaultClientService, useValue: client }, + { provide: VaultDatastoreService, useValue: datastore }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(VaultService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should reconcile on cron', async () => { + const projects = faker.helpers.multiple(() => makeProjectWithDetails()) + const zones = faker.helpers.multiple(() => makeZoneWithDetails()) + + datastore.getAllProjects.mockResolvedValue(projects) + datastore.getAllZones.mockResolvedValue(zones) + + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(datastore.getAllZones).toHaveBeenCalled() + expect(client.createSysMount).toHaveBeenCalledTimes(projects.length + zones.length) + projects.forEach((project) => { + expect(client.createSysMount).toHaveBeenCalledWith(project.slug, expect.any(Object)) + }) + zones.forEach((zone) => { + expect(client.createSysMount).toHaveBeenCalledWith(`zone-${zone.slug}`, expect.any(Object)) + }) + }) + + it('should upsert project on event', async () => { + const project = makeProjectWithDetails() + + client.getIdentityGroupName.mockImplementation(async (groupName: string) => { + const projectRoleMatch = groupName.match(projectRoleGroupNameRegex) + if (projectRoleMatch) { + const projectSlug = projectRoleMatch[1] + const role = projectRoleMatch[2] + return { data: { id: 'gid', name: groupName, alias: { name: `/${projectSlug}/console/${role}` } } } + } + + if (groupName === 'console-admin') return { data: { id: 'gid', name: groupName, alias: { name: '/console/admin' } } } + if (groupName === 'console-readonly') return { data: { id: 'gid', name: groupName, alias: { name: '/console/readonly' } } } + if (groupName === 'console-security') return { data: { id: 'gid', name: groupName, alias: { name: '/console/security' } } } + + return { data: { id: 'gid', name: groupName } } + }) + + await service.handleUpsert(project) + + expect(client.createSysMount).toHaveBeenCalledWith(project.slug, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`app--${project.slug}--admin`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`tech--${project.slug}--ro`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--devops`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--developer`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--readonly`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--security`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--admin', expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--readonly', expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--security', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-admin', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-readonly', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-security', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-admin`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-devops`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-developer`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`, expect.any(Object)) + expect(client.createIdentityGroupAlias).not.toHaveBeenCalled() + + const policyCalls = client.upsertSysPoliciesAcl.mock.calls as Array<[string, { policy: string }]> + expect(policyCalls.find(([name]) => name === `project--${project.slug}--developer`)?.[1].policy) + .toBe(`path "${project.slug}/data/*" { capabilities = ["list"] }`) + expect(policyCalls.find(([name]) => name === `project--${project.slug}--readonly`)?.[1].policy) + .toBe(`path "${project.slug}/data/*" { capabilities = ["list"] }`) + expect(policyCalls.find(([name]) => name === `project--${project.slug}--security`)?.[1].policy) + .toBe([ + `path "${project.slug}/metadata/*" { capabilities = ["list"] }`, + `path "transit/keys/${project.slug}/*" { capabilities = ["list"] }`, + ].join('\n')) + }) + + it('should delete project and destroy secrets on event', async () => { + const project = makeProjectWithDetails() + + await service.handleDelete(project) + + expect(client.deleteSysMounts).toHaveBeenCalledWith(project.slug) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`app--${project.slug}--admin`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`tech--${project.slug}--ro`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--devops`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--developer`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--readonly`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--security`) + expect(client.deleteAuthApproleRole).toHaveBeenCalledWith(project.slug) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-admin`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-devops`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-developer`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.service.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.service.ts new file mode 100644 index 0000000000..7128c24963 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -0,0 +1,594 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { VaultError } from './vault-http-client.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + AUDITOR_GROUP_PATH_PLUGIN_KEY, + CONSOLE_ADMIN_GROUP_NAME, + CONSOLE_READONLY_GROUP_NAME, + CONSOLE_SECURITY_GROUP_NAME, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX, + DEFAULT_SECURITY_GROUP_PATH, + PLATFORM_ADMIN_POLICY_NAME, + PLATFORM_READONLY_POLICY_NAME, + PLATFORM_SECURITY_POLICY_NAME, + PLUGIN_NAME, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + SECURITY_GROUP_PATH_PLUGIN_KEY, +} from './vault.constants' +import { VAULT_CONFIG } from './vault.module-definition' +import { generateProjectPath } from './vault.utils' + +type ProjectScope = 'admin' | 'devops' | 'developer' | 'readonly' | 'security' + +@Injectable() +export class VaultService { + private readonly logger = new Logger(VaultService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultDatastoreService) private readonly datastore: VaultDatastoreService, + @Inject(VaultClientService) private readonly client: VaultClientService, + ) { + this.logger.log('VaultService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('vault', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`Vault project sync completed for ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('vault', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await Promise.all([ + this.deleteProject(project), + this.deleteProjectSecrets(project.slug), + ]) + this.logger.log(`Vault project cleanup completed for ${project.slug}`) + } + + @OnEvent('zone.upsert') + async handleUpsertZone(zone: ZoneWithDetails): Promise> { + return capturePluginResult('vault', () => this.syncZone(zone)) + } + + @StartActiveSpan() + private async syncZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.log(`Handling a zone upsert event for ${zone.slug}`) + await this.ensureZone(zone) + this.logger.log(`Vault zone sync completed for ${zone.slug}`) + } + + @OnEvent('zone.delete') + async handleDeleteZone(zone: ZoneWithDetails): Promise> { + return capturePluginResult('vault', () => this.cleanupZone(zone)) + } + + @StartActiveSpan() + private async cleanupZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.log(`Handling a zone delete event for ${zone.slug}`) + await this.deleteZone(zone.slug) + this.logger.log(`Vault zone cleanup completed for ${zone.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Vault reconciliation') + const [projects, zones] = await Promise.all([ + this.datastore.getAllProjects(), + this.datastore.getAllZones(), + ]) + + span?.setAttributes({ + 'vault.projects.count': projects.length, + 'vault.zones.count': zones.length, + }) + this.logger.log(`Loaded state for Vault reconciliation (projects=${projects.length}, zones=${zones.length})`) + await Promise.all([ + this.ensureProjects(projects), + this.ensureZones(zones), + ]) + this.logger.log(`Vault reconciliation completed (projects=${projects.length} zones=${zones.length})`) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('vault.projects.count', projects.length) + this.logger.verbose(`Reconciling Vault projects (count=${projects.length})`) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling Vault project ${project.slug}`) + await this.upsertProject(project) + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return project.plugins?.find(p => p.pluginName === PLUGIN_NAME && p.key === key)?.value + } + + private async getAdminGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getAuditorGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, AUDITOR_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_AUDITOR_GROUP_PATH + } + + private async getSecurityGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, SECURITY_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_SECURITY_GROUP_PATH + } + + private async getProjectMaintainerGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX + } + + private async getProjectDevopsGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX + } + + private async getProjectDeveloperGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + } + + private async getProjectReporterGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX + } + + private async getProjectSecurityGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX + } + + @StartActiveSpan() + private async ensureZones(zones: ZoneWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('vault.zones.count', zones.length) + this.logger.verbose(`Reconciling Vault zones (count=${zones.length})`) + await Promise.all(zones.map(z => this.ensureZone(z))) + } + + @StartActiveSpan() + private async ensureZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.verbose(`Reconciling Vault zone ${zone.slug}`) + await this.upsertZone(zone.slug) + } + + private async upsertMount(kvName: string): Promise { + const createBody = { + type: 'kv', + config: { + force_no_cache: true, + }, + options: { + version: 2, + }, + } + const tuneBody = { + options: { + version: 2, + }, + } + try { + await this.client.createSysMount(kvName, createBody) + this.logger.log(`Created Vault mount ${kvName}`) + } catch (error) { + if (error instanceof VaultError && error.kind === 'HttpError' && error.status === 400) { + await this.client.tuneSysMount(kvName, tuneBody) + this.logger.log(`Vault mount ${kvName} already existed, so it was tuned to the expected settings`) + return + } + throw error + } + } + + private async deleteMount(kvName: string): Promise { + try { + await this.client.deleteSysMounts(kvName) + this.logger.log(`Deleted Vault mount ${kvName}`) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') { + this.logger.warn(`Vault mount ${kvName} was already missing`) + return + } + throw error + } + } + + @StartActiveSpan() + async upsertZone(zoneName: string): Promise { + const kvName = generateZoneName(zoneName) + const span = trace.getActiveSpan() + span?.setAttribute('zone.name', zoneName) + span?.setAttribute('vault.kv.name', kvName) + const policyName = generateZoneTechReadOnlyPolicyName(zoneName) + + await this.upsertMount(kvName) + await this.client.upsertSysPoliciesAcl(policyName, { + policy: `path "${kvName}/*" { capabilities = ["read"] }`, + }) + await this.client.upsertAuthApproleRole(kvName, generateApproleRoleBody([policyName])) + } + + @StartActiveSpan() + async deleteZone(zoneName: string): Promise { + const kvName = generateZoneName(zoneName) + const span = trace.getActiveSpan() + span?.setAttribute('zone.name', zoneName) + span?.setAttribute('vault.kv.name', kvName) + const policyName = generateZoneTechReadOnlyPolicyName(zoneName) + const roleName = kvName + + await this.deleteMount(kvName) + + const settled = await Promise.allSettled([ + this.client.deleteSysPoliciesAcl(policyName), + this.client.deleteAuthApproleRole(roleName), + ]) + + for (const result of settled) { + if (result.status !== 'rejected') continue + const error = result.reason + if (error instanceof VaultError && error.kind === 'NotFound') continue + throw error + } + } + + @StartActiveSpan() + async upsertProject(project: ProjectWithDetails): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('vault.kv.name', project.slug) + const appPolicyName = generateAppAdminPolicyName(project) + const techPolicyName = generateTechReadOnlyPolicyName(project) + const projectDevopsPolicyName = generateProjectPolicyName(project, 'devops') + const projectDeveloperPolicyName = generateProjectPolicyName(project, 'developer') + const projectReadOnlyPolicyName = generateProjectPolicyName(project, 'readonly') + const projectSecurityPolicyName = generateProjectPolicyName(project, 'security') + await this.upsertMount(project.slug) + + const [ + adminGroupPath, + auditorGroupPath, + securityGroupPath, + maintainerGroupPathSuffix, + devopsGroupPathSuffix, + developerGroupPathSuffix, + reporterGroupPathSuffix, + securityGroupPathSuffix, + ] = await Promise.all([ + this.getAdminGroupPath(project), + this.getAuditorGroupPath(project), + this.getSecurityGroupPath(project), + this.getProjectMaintainerGroupPathSuffix(project), + this.getProjectDevopsGroupPathSuffix(project), + this.getProjectDeveloperGroupPathSuffix(project), + this.getProjectReporterGroupPathSuffix(project), + this.getProjectSecurityGroupPathSuffix(project), + ]) + + const projectAdminGroupPaths = generateProjectRoleGroupPaths(project, maintainerGroupPathSuffix) + const projectDevopsGroupPaths = generateProjectRoleGroupPaths(project, devopsGroupPathSuffix) + const projectDeveloperGroupPaths = generateProjectRoleGroupPaths(project, developerGroupPathSuffix) + const projectReadOnlyGroupPaths = generateProjectRoleGroupPaths(project, reporterGroupPathSuffix) + const projectSecurityGroupPaths = generateProjectRoleGroupPaths(project, securityGroupPathSuffix) + + await Promise.all([ + this.ensureAppAdminPolicy(appPolicyName, project.slug), + this.ensureTechReadOnlyPolicy(techPolicyName, project.slug), + this.ensureProjectDevopsPolicy(projectDevopsPolicyName, project.slug), + this.ensureProjectReadOnlyPolicy(projectDeveloperPolicyName, project.slug), + this.ensureProjectReadOnlyPolicy(projectReadOnlyPolicyName, project.slug), + this.createProjectSecurityPolicy(projectSecurityPolicyName, project.slug), + this.ensurePlatformAdminPolicy(PLATFORM_ADMIN_POLICY_NAME), + this.ensurePlatformReadOnlyPolicy(PLATFORM_READONLY_POLICY_NAME), + this.ensurePlatformSecurityPolicy(PLATFORM_SECURITY_POLICY_NAME), + this.ensureIdentityGroup(CONSOLE_ADMIN_GROUP_NAME, [PLATFORM_ADMIN_POLICY_NAME], adminGroupPath), + this.ensureIdentityGroup(CONSOLE_READONLY_GROUP_NAME, [PLATFORM_READONLY_POLICY_NAME], auditorGroupPath), + this.ensureIdentityGroup(CONSOLE_SECURITY_GROUP_NAME, [PLATFORM_SECURITY_POLICY_NAME], securityGroupPath), + ...projectAdminGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'admin'), [appPolicyName], groupPath)), + ...projectDevopsGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'devops'), [projectDevopsPolicyName], groupPath)), + ...projectDeveloperGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'developer'), [projectDeveloperPolicyName], groupPath)), + ...projectReadOnlyGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'readonly'), [projectReadOnlyPolicyName], groupPath)), + ...projectSecurityGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'security'), [projectSecurityPolicyName], groupPath)), + this.client.upsertAuthApproleRole(project.slug, generateApproleRoleBody([techPolicyName, appPolicyName])), + ]) + } + + @StartActiveSpan() + async deleteProject(project: ProjectWithDetails): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('vault.kv.name', project.slug) + const appPolicyName = generateAppAdminPolicyName(project) + const techPolicyName = generateTechReadOnlyPolicyName(project) + const projectDevopsPolicyName = generateProjectPolicyName(project, 'devops') + const projectDeveloperPolicyName = generateProjectPolicyName(project, 'developer') + const projectReadOnlyPolicyName = generateProjectPolicyName(project, 'readonly') + const projectSecurityPolicyName = generateProjectPolicyName(project, 'security') + + await this.deleteMount(project.slug) + + const settled = await Promise.allSettled([ + this.client.deleteSysPoliciesAcl(appPolicyName), + this.client.deleteSysPoliciesAcl(techPolicyName), + this.client.deleteSysPoliciesAcl(projectDevopsPolicyName), + this.client.deleteSysPoliciesAcl(projectDeveloperPolicyName), + this.client.deleteSysPoliciesAcl(projectReadOnlyPolicyName), + this.client.deleteSysPoliciesAcl(projectSecurityPolicyName), + this.client.deleteAuthApproleRole(project.slug), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'admin')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'devops')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'developer')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'readonly')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'security')), + ]) + for (const result of settled) { + if (result.status !== 'rejected') continue + const error = result.reason + if (error instanceof VaultError && error.kind === 'NotFound') continue + throw error + } + } + + @StartActiveSpan() + private async ensureIdentityGroup(groupName: string, policies: string[], groupAliasName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'vault.group.name': groupName, + 'vault.policies.count': policies.length, + }) + await this.client.upsertIdentityGroupName(groupName, { + name: groupName, + type: 'external', + policies, + }) + + const groupResult = await this.client.getIdentityGroupName(groupName) + if (!groupResult.data?.id) { + throw new VaultError('InvalidResponse', `Vault group not found after upsert: ${groupName}`, { method: 'GET', path: `/v1/identity/group/name/${groupName}` }) + } + + const normalizedAliasName = groupAliasName.startsWith('/') ? groupAliasName : `/${groupAliasName}` + if (groupResult.data.alias?.name === normalizedAliasName) return + + const methods = await this.client.getSysAuth() + const oidc = methods['oidc/'] + if (!oidc?.accessor) { + throw new VaultError('InvalidResponse', 'Vault OIDC auth method not found (expected "oidc/")', { method: 'GET', path: '/v1/sys/auth' }) + } + try { + span?.setAttributes({ + 'vault.group.alias.name': normalizedAliasName, + 'vault.oidc.accessor': oidc.accessor, + }) + await this.client.createIdentityGroupAlias({ + name: normalizedAliasName, + mount_accessor: oidc.accessor, + canonical_id: groupResult.data.id, + }) + } catch (error) { + if (error instanceof VaultError && error.kind === 'HttpError' && error.status === 400) return + throw error + } + } + + async ensureAppAdminPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${projectSlug}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + }) + } + + async ensureProjectDevopsPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "${projectSlug}/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + `path "${projectSlug}/metadata/*" { capabilities = ["read", "list"] }`, + `path "${projectSlug}/delete/*" { capabilities = ["update"] }`, + `path "${projectSlug}/undelete/*" { capabilities = ["update"] }`, + `path "${projectSlug}/destroy/*" { capabilities = ["update"] }`, + ].join('\n'), + }) + } + + async ensureProjectReadOnlyPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${projectSlug}/data/*" { capabilities = ["list"] }`, + }) + } + + async createProjectSecurityPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "${projectSlug}/metadata/*" { capabilities = ["list"] }`, + `path "transit/keys/${projectSlug}/*" { capabilities = ["list"] }`, + ].join('\n'), + }) + } + + async ensurePlatformAdminPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "sys/*" { capabilities = ["create", "read", "update", "delete", "list", "sudo"] }`, + }) + } + + async ensurePlatformReadOnlyPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "sys/health" { capabilities = ["read"] }`, + `path "sys/mounts" { capabilities = ["read"] }`, + `path "sys/mounts/*" { capabilities = ["read"] }`, + `path "sys/auth" { capabilities = ["read"] }`, + `path "sys/auth/*" { capabilities = ["read"] }`, + `path "sys/policies/*" { capabilities = ["read", "list"] }`, + ].join('\n'), + }) + } + + async ensurePlatformSecurityPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "sys/audit" { capabilities = ["read", "list"] }`, + `path "sys/audit/*" { capabilities = ["read", "list"] }`, + `path "sys/policies/*" { capabilities = ["read", "list"] }`, + `path "sys/auth" { capabilities = ["read"] }`, + `path "sys/auth/*" { capabilities = ["read"] }`, + ].join('\n'), + }) + } + + async ensureTechReadOnlyPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${this.vaultConfig.kvName}/data/${projectSlug}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + }) + } + + async listProjectSecrets(projectSlug: string): Promise { + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) + return this.listRecursive(this.vaultConfig.kvName, projectPath, '') + } + + @StartActiveSpan() + async deleteProjectSecrets(projectSlug: string): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'vault.kv.name': this.vaultConfig.kvName, + }) + const secrets = await this.listProjectSecrets(projectSlug) + span?.setAttribute('vault.secrets.count', secrets.length) + + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) + await Promise.allSettled(secrets.map(async (relativePath) => { + const fullPath = `${projectPath}/${relativePath}` + try { + await this.client.delete(fullPath) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + })) + } + + private async listRecursive( + kvName: string, + basePath: string, + relativePath: string, + ): Promise { + const combined = relativePath.length === 0 ? basePath : `${basePath}/${relativePath}` + const keys = await this.client.listKvMetadata(kvName, combined) + if (keys.length === 0) return [] + + const results: string[] = [] + for (const key of keys) { + if (key.endsWith('/')) { + const nestedRel = relativePath.length === 0 ? key.slice(0, -1) : `${relativePath}/${key.slice(0, -1)}` + const nested = await this.listRecursive(kvName, basePath, nestedRel) + results.push(...nested) + } else { + results.push(relativePath.length === 0 ? key : `${relativePath}/${key}`) + } + } + return results + } +} + +function generateTechReadOnlyPolicyName(project: ProjectWithDetails) { + return `tech--${project.slug}--ro` +} + +function generateAppAdminPolicyName(project: ProjectWithDetails) { + return `app--${project.slug}--admin` +} + +function generateProjectPolicyName(project: ProjectWithDetails, scope: ProjectScope) { + return `project--${project.slug}--${scope}` +} + +function generateProjectGroupName(project: ProjectWithDetails, scope: ProjectScope) { + return `project-${project.slug}-${scope}` +} + +function generateProjectRoleGroupPaths(project: ProjectWithDetails, rawGroupPathSuffixes: string) { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${project.slug}${path}`) +} + +function generateZoneName(name: string) { + return `zone-${name}` +} + +function generateZoneTechReadOnlyPolicyName(zoneName: string) { + return `tech--${generateZoneName(zoneName)}--ro` +} + +function generateApproleRoleBody(policies: string[]) { + return { + secret_id_num_uses: '0', + secret_id_ttl: '0', + token_max_ttl: '0', + token_num_uses: '0', + token_ttl: '0', + token_type: 'batch', + token_policies: policies, + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.utils.ts new file mode 100644 index 0000000000..6e170bede2 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/vault/vault.utils.ts @@ -0,0 +1,15 @@ +export function generateProjectPath(projectRootDir: string, projectSlug: string) { + return `${projectRootDir}/${projectSlug}` +} + +export function generateGitlabMirrorCredPath(projectRootDir: string, projectSlug: string, repoName: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/${repoName}-mirror` +} + +export function generateTechReadOnlyCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/tech/GITLAB_MIRROR` +} + +export function generateSonarqubeCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/SONAR` +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/version/version.controller.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/version/version.controller.ts new file mode 100644 index 0000000000..7dc6a91019 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/version/version.controller.ts @@ -0,0 +1,15 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import { Controller, Get, Inject } from '@nestjs/common' +import { BASE_CONFIG } from '../infrastructure/config/base.config' + +@Controller('api/v1/version') +export class VersionController { + constructor( + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + @Get() + getVersion() { + return { version: this.baseConfig.appVersion } + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/modules/version/version.module.ts b/.jjconflict-side-1/apps/server-nestjs/src/modules/version/version.module.ts new file mode 100644 index 0000000000..c537037b5b --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/modules/version/version.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common' +import { VersionController } from './version.controller' + +@Module({ + controllers: [VersionController], +}) +export class VersionModule {} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql new file mode 100644 index 0000000000..f2f4e7b0b7 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql @@ -0,0 +1,151 @@ +-- CreateTable +CREATE TABLE "Environment" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Environment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Log" ( + "id" UUID NOT NULL, + "data" JSONB NOT NULL, + "action" TEXT NOT NULL DEFAULT '', + "userId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Log_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Organization" ( + "id" UUID NOT NULL, + "source" TEXT NOT NULL, + "name" TEXT NOT NULL, + "label" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Permission" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "environmentId" UUID NOT NULL, + "level" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Permission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "organizationId" UUID NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL, + "locked" BOOLEAN NOT NULL DEFAULT false, + "services" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Repository" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "internalRepoName" TEXT NOT NULL, + "externalRepoUrl" TEXT NOT NULL, + "externalUserName" TEXT, + "externalToken" TEXT, + "isInfra" BOOLEAN NOT NULL DEFAULT false, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Repository_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" UUID NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Role" ( + "userId" UUID NOT NULL, + "projectId" UUID NOT NULL, + "role" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Role_pkey" PRIMARY KEY ("userId","projectId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_id_key" ON "Organization"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_name_key" ON "Organization"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_label_key" ON "Organization"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_id_key" ON "Permission"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_userId_environmentId_key" ON "Permission"("userId", "environmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_id_key" ON "Project"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Role_userId_projectId_key" ON "Role"("userId", "projectId"); + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql new file mode 100644 index 0000000000..26e1ade3f2 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql @@ -0,0 +1,85 @@ +-- CreateEnum +CREATE TYPE "ClusterPrivacy" AS ENUM ('public', 'dedicated'); + +-- CreateTable +CREATE TABLE "Cluster" ( + "id" UUID NOT NULL, + "label" VARCHAR(50) NOT NULL, + "privacy" "ClusterPrivacy" NOT NULL DEFAULT 'dedicated', + "secretName" VARCHAR(50) NOT NULL, + "clusterResources" BOOLEAN NOT NULL DEFAULT false, + "kubeConfigId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cluster_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Kubeconfig" ( + "id" UUID NOT NULL, + "user" JSONB NOT NULL, + "cluster" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "parentClusterId" UUID, + + CONSTRAINT "Kubeconfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_ClusterToEnvironment" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateTable +CREATE TABLE "_ClusterToProject" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_id_key" ON "Cluster"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_label_key" ON "Cluster"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_secretName_key" ON "Cluster"("secretName"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_kubeConfigId_key" ON "Cluster"("kubeConfigId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_id_key" ON "Kubeconfig"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_parentClusterId_key" ON "Kubeconfig"("parentClusterId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToEnvironment_AB_unique" ON "_ClusterToEnvironment"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToEnvironment_B_index" ON "_ClusterToEnvironment"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToProject_AB_unique" ON "_ClusterToProject"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToProject_B_index" ON "_ClusterToProject"("B"); + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_kubeConfigId_fkey" FOREIGN KEY ("kubeConfigId") REFERENCES "Kubeconfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_B_fkey" FOREIGN KEY ("B") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_B_fkey" FOREIGN KEY ("B") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql new file mode 100644 index 0000000000..8f3fb5ff9f --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `parentClusterId` on the `Kubeconfig` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Kubeconfig_parentClusterId_key"; + +-- AlterTable +ALTER TABLE "Kubeconfig" DROP COLUMN "parentClusterId"; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql new file mode 100644 index 0000000000..4eb37edc53 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql @@ -0,0 +1,10 @@ +CREATE TYPE "ProjectStatus" AS ENUM ('initializing', 'created', 'failed', 'archived'); + +ALTER TABLE public."Project" ALTER COLUMN status TYPE "ProjectStatus" USING + case + when status = 'created' then 'created'::"ProjectStatus" + when status = 'failed' then 'failed'::"ProjectStatus" + when status = 'archived' then 'archived'::"ProjectStatus" + else 'initializing'::"ProjectStatus" + end; +ALTER TABLE public."Project" ALTER COLUMN status SET DEFAULT 'initializing'; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql new file mode 100644 index 0000000000..f402a0e3d4 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "infos" VARCHAR(200); diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql new file mode 100644 index 0000000000..f553e7880e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `externalToken` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "externalToken"; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql new file mode 100644 index 0000000000..394b650a51 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql @@ -0,0 +1,81 @@ +-- CreateEnum +CREATE TYPE "QuotaStageStatus" AS ENUM +('active', 'pendingDelete'); + +-- Create new tables +-- CreateTable +CREATE TABLE "Quota" +( + "id" UUID NOT NULL, + "memory" VARCHAR NOT NULL, + "cpu" REAL NOT NULL, + "name" VARCHAR NOT NULL, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "Quota_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Stage" +( + "id" UUID NOT NULL, + "name" VARCHAR NOT NULL, + + CONSTRAINT "Stage_pkey" PRIMARY KEY ("id") +); + +-- Associate Quotas and Stages +-- CreateTable +CREATE TABLE "QuotaStage" +( + "id" UUID NOT NULL, + "quotaId" UUID NOT NULL, + "stageId" UUID NOT NULL, + "status" "QuotaStageStatus" NOT NULL DEFAULT 'active', + + CONSTRAINT "QuotaStage_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "Quota_id_key" ON "Quota"("id"); +CREATE UNIQUE INDEX "Quota_name_key" ON "Quota"("name"); +CREATE UNIQUE INDEX "Stage_id_key" ON "Stage"("id"); +CREATE UNIQUE INDEX "Stage_name_key" ON "Stage"("name"); +CREATE UNIQUE INDEX "QuotaStage_id_key" ON "QuotaStage"("id"); +CREATE UNIQUE INDEX "QuotaStage_quotaId_stageId_key" ON "QuotaStage"("quotaId", "stageId"); +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Create default values for Quotas and Stages +-- Quota +INSERT INTO "Quota" + (id, cpu, memory, "name", "isPrivate") +VALUES + ('5a57b62f-2465-4fb6-a853-5a751d099199', 2, '4Gi', 'small', false), + ('08770663-3b76-4af6-8978-9f75eda4faa7', 4, '8Gi', 'medium', false), + ('b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 6, '12Gi', 'large', false), + ('97b851e8-9067-4a3d-a0e8-c3a6820c49be', 8, '16Gi', 'xlarge', false); + +-- Stage +INSERT INTO "Stage" + (id, "name") +VALUES + ('4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', 'dev'), + ('38fa869d-6267-441d-af7f-e0548fd06b7e', 'staging'), + ('d434310e-7850-4d59-b47f-0772edf50582', 'integration'), + ('9b3e9991-896d-4d90-bdc5-a34be8c06b8f', 'prod'); + +-- QuotaStage +INSERT INTO "QuotaStage" + (id, "quotaId", "stageId") +VALUES + ('0cb0c549-560e-4f26-8f4e-832dd722f68a', '5a57b62f-2465-4fb6-a853-5a751d099199', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('0530e9c9-b37d-4dec-93e6-1895f700e61c', '5a57b62f-2465-4fb6-a853-5a751d099199', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('8a99db49-b7b1-44bf-865d-5e709e8aa0fc', '5a57b62f-2465-4fb6-a853-5a751d099199', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('67561f00-d219-4ca6-b94a-3ee83f09d2d6', '5a57b62f-2465-4fb6-a853-5a751d099199', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('8b3c201e-7518-4254-a94a-16c404e46936', '08770663-3b76-4af6-8978-9f75eda4faa7', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('9157ae12-3e39-43f8-a24f-ae5d9c6b69b7', '08770663-3b76-4af6-8978-9f75eda4faa7', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('c733a1dd-c9fd-4def-b29e-df49ef7b6698', '08770663-3b76-4af6-8978-9f75eda4faa7', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('15a51f47-0ab2-4a94-a808-722639d8c092', '08770663-3b76-4af6-8978-9f75eda4faa7', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('cb66e80c-2304-472d-bc19-a411011674ca', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('59fb0e79-3a76-4b96-81d4-63f4caa98cfa', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('4174b22c-2bee-4f4a-9d85-da7b5463f214', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql new file mode 100644 index 0000000000..8c98a7f74e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql @@ -0,0 +1,35 @@ +-- Multiplication des environnements par clusteurs +ALTER TABLE "Environment" ADD COLUMN "clusterId" UUID; + +DO +$$ +DECLARE + perm record; + cte record; + env_uuid UUID; +BEGIN + FOR cte IN SELECT "B" AS environmentId, "A" AS "clusterId", "name", "projectId", status, "updatedAt", "createdAt" + FROM public."_ClusterToEnvironment", public."Environment" + WHERE public."Environment".id = "B" + LOOP + env_uuid := gen_random_uuid(); + INSERT INTO public."Environment" (id, "name", "projectId", "clusterId", status, "createdAt", "updatedAt") VALUES + (env_uuid, cte."name", cte."projectId", cte."clusterId", cte.status, cte."createdAt", cte."updatedAt"); + + FOR perm in SELECT * FROM public."Permission" WHERE "environmentId" = cte.environmentId + LOOP + INSERT INTO public."Permission" (id, "level", "createdAt", "updatedAt", "environmentId", "userId") VALUES + (gen_random_uuid(), perm."level", perm."createdAt", perm."updatedAt", env_uuid, perm."userId"); + END LOOP; + END LOOP; +END; +$$ +; +DELETE FROM public."Environment" WHERE "clusterId" is null; +ALTER TABLE public."Environment" ALTER COLUMN "clusterId" SET NOT NULL; +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_clusterId_fkey" FOREIGN KEY ("clusterId") REFERENCES "Cluster"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Delete old _ClusterToEnvironment +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_A_fkey"; +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_B_fkey"; +DROP TABLE "_ClusterToEnvironment"; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql new file mode 100644 index 0000000000..035cd1c855 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql @@ -0,0 +1,36 @@ +-- Associate cluster to Stages +-- CreateTable +CREATE TABLE "_ClusterToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToStage_AB_unique" ON "_ClusterToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToStage_B_index" ON "_ClusterToStage"("B"); + +DO +$$ +DECLARE + cluster record; + cte record; + env_uuid UUID; +BEGIN + FOR cluster IN SELECT id + FROM public."Cluster" + LOOP + INSERT INTO public."_ClusterToStage" ("A", "B") VALUES + (cluster.id, '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + (cluster.id, '38fa869d-6267-441d-af7f-e0548fd06b7e'), + (cluster.id, 'd434310e-7850-4d59-b47f-0772edf50582'), + (cluster.id, '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); + END LOOP; +END; +$$ diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql new file mode 100644 index 0000000000..43793bdb49 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaStageId" UUID; +UPDATE "Environment" SET "quotaStageId" = '8b3c201e-7518-4254-a94a-16c404e46936' WHERE "name" = 'dev'; +UPDATE "Environment" SET "quotaStageId" = '9157ae12-3e39-43f8-a24f-ae5d9c6b69b7' WHERE "name" = 'staging'; +UPDATE "Environment" SET "quotaStageId" = '4174b22c-2bee-4f4a-9d85-da7b5463f214' WHERE "name" = 'integration'; +UPDATE "Environment" SET "quotaStageId" = 'de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a' WHERE "name" = 'prod'; +ALTER TABLE "Environment" ALTER COLUMN "name" SET DATA TYPE VARCHAR(11); +ALTER TABLE "Environment" ALTER COLUMN "quotaStageId" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaStageId_fkey" FOREIGN KEY ("quotaStageId") REFERENCES "QuotaStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql new file mode 100644 index 0000000000..9af004b6a3 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- lock all projects +UPDATE public."Project" SET "locked"=true \ No newline at end of file diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql new file mode 100644 index 0000000000..d970d3965a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- set all projects to failed to avoid unlock them +UPDATE public."Project" SET "status" = 'failed' WHERE "status" != 'archived' \ No newline at end of file diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql new file mode 100644 index 0000000000..c387d9885c --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ADD COLUMN "requestId" VARCHAR(21); diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql new file mode 100644 index 0000000000..18e20262c8 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `status` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the column `status` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "status"; + +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "status"; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql new file mode 100644 index 0000000000..f784b21567 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql @@ -0,0 +1,46 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "zoneId" UUID; + +-- CreateTable +CREATE TABLE "Zone" +( + "id" UUID NOT NULL, + "slug" VARCHAR(10) NOT NULL, + "label" VARCHAR(50) NOT NULL, + "description" VARCHAR(200), + "createdAt" TIMESTAMP +(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP +(3) NOT NULL, + + CONSTRAINT "Zone_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_id_key" ON "Zone"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_slug_key" ON "Zone"("slug"); + +-- Create default zone +INSERT INTO "Zone" + (id, "slug", "label", "description", "updatedAt") +VALUES + ('a66c4230-eba6-41f1-aae5-bb1e4f90cce0', 'default', 'Zone Défaut', 'Zone par défaut, à changer', CURRENT_TIMESTAMP); + +-- Set default zoneId for current clusters +UPDATE "Cluster" +SET "zoneId" += 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0' +WHERE "zoneId" +IS NULL; + +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "zoneId" +SET +NOT NULL; + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") +ON DELETE RESTRICT ON +UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql new file mode 100644 index 0000000000..cb4f7ad968 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "ProjectPlugin" ( + "pluginName" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "AdminPlugin" ( + "pluginName" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectPlugin_projectId_pluginName_key_key" ON "ProjectPlugin"("projectId", "pluginName", "key"); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminPlugin_pluginName_key_key" ON "AdminPlugin"("pluginName", "key"); + +-- AddForeignKey +ALTER TABLE "ProjectPlugin" ADD CONSTRAINT "ProjectPlugin_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql new file mode 100644 index 0000000000..11672324f8 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql @@ -0,0 +1,19 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id, services FROM public."Project" LOOP + -- Extrait 'registry.id' de la colonne JSON 'services' + registry_id := (SELECT (project_row.services -> 'registry' ->> 'id')::TEXT); + -- Si 'registry.id' existe, insérer dans la table 'config' + IF registry_id IS NOT NULL THEN + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'registry', 'projectId', registry_id::TEXT); + END IF; + END LOOP; +END $$; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "services"; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql new file mode 100644 index 0000000000..9c7d5a8f8f --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "RoleList" AS ENUM ('owner', 'user'); + +-- AlterTable +ALTER TABLE public."Role" ALTER COLUMN "role" TYPE "RoleList" USING + case + when role = 'owner' then 'owner'::"RoleList" + else 'user'::"RoleList" + end; \ No newline at end of file diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql new file mode 100644 index 0000000000..45a4a5d1ea --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "ProjectClusterHistory" ( + "projectId" UUID NOT NULL, + "clusterId" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectClusterHistory_projectId_clusterId_key" ON "ProjectClusterHistory"("projectId", "clusterId"); diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql new file mode 100644 index 0000000000..2b1641a657 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql @@ -0,0 +1,11 @@ +DO $$ +DECLARE + env_row RECORD; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR env_row IN SELECT "projectId", "clusterId" FROM public."Environment" LOOP + INSERT INTO public."ProjectClusterHistory" ("projectId", "clusterId") + VALUES (env_row."projectId", env_row."clusterId") + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql new file mode 100644 index 0000000000..5e7ff03d45 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql @@ -0,0 +1,58 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaId" UUID, +ADD COLUMN "stageId" UUID; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "_QuotaToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_QuotaToStage_AB_unique" ON "_QuotaToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_QuotaToStage_B_index" ON "_QuotaToStage"("B"); + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +DO $$ +DECLARE + quota_stage_row RECORD; +BEGIN + FOR quota_stage_row IN SELECT * FROM public."QuotaStage" loop + UPDATE public."Environment" SET "stageId" = quota_stage_row."stageId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + UPDATE public."Environment" SET "quotaId" = quota_stage_row."quotaId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + insert into public."_QuotaToStage" values (quota_stage_row."quotaId", quota_stage_row."stageId"); + END LOOP; +END $$; + +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaStageId_fkey"; + +-- AlterTable +ALTER TABLE "Environment" ALTER COLUMN "quotaId" SET NOT NULL, +ALTER COLUMN "stageId" SET NOT NULL, +DROP COLUMN "quotaStageId"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_stageId_fkey"; + +-- DropTable +DROP TABLE "QuotaStage"; + +-- DropEnum +DROP TYPE "QuotaStageStatus"; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql new file mode 100644 index 0000000000..0036da8a12 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Made the column `description` on table `Project` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "description" SET NOT NULL, +ALTER COLUMN "description" SET DEFAULT ''; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql new file mode 100644 index 0000000000..ed6ae9b84e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql @@ -0,0 +1,198 @@ +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_environmentId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_environmentId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_userId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_userId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_projectId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_projectId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_userId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_userId_fkey"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectMembers') THEN + CREATE TABLE "ProjectMembers" ( + "projectId" UUID NOT NULL, + "userId" UUID NOT NULL, + "roleIds" TEXT[] + ); + END IF; +END $$; + +-- AlterTable +ALTER TABLE "Log" ADD COLUMN IF NOT EXISTS "projectId" UUID; + +INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt") +VALUES('04ac168a-2c4f-4816-9cce-af6c612e5912'::uuid, 'Anonymous', 'User', 'anon@user', '2023-07-03 14:46:56.770', '2023-07-03 14:46:56.770') +ON CONFLICT (id) DO NOTHING; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "everyonePerms" BIGINT NOT NULL DEFAULT 896, +ADD COLUMN IF NOT EXISTS "ownerId" UUID; + +DO $$ +DECLARE + role_row RECORD; +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR role_row IN SELECT "userId", "projectId", "role" FROM public."Role" LOOP + INSERT INTO public."ProjectMembers" ("userId", "projectId", "roleIds") VALUES (role_row."userId", role_row."projectId", '{}'); + IF role_row."role" = 'owner'::public."RoleList" THEN + UPDATE public."Project" + SET "ownerId"=role_row."userId" + WHERE id=role_row."projectId"::uuid; + END IF; + END LOOP; + END IF; +END $$; + +UPDATE public."Project" +SET "ownerId"='04ac168a-2c4f-4816-9cce-af6c612e5912' +WHERE "ownerId" IS NULL; + +ALTER TABLE public."Project" ALTER COLUMN "ownerId" SET NOT NULL; + +DELETE FROM public."ProjectMembers" pm +USING public."Project" p +WHERE pm."userId" = p."ownerId" +AND pm."projectId" = p."id"; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Permission') THEN + DROP TABLE "Permission"; + END IF; +END $$; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + DROP TABLE "Role"; + END IF; +END $$; + +-- DropEnum if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'RoleList') THEN + DROP TYPE "RoleList"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'AdminRole') THEN + CREATE TABLE "AdminRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "AdminRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectRole') THEN + CREATE TABLE "ProjectRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "projectId" UUID NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "ProjectRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_id_key') THEN + CREATE UNIQUE INDEX "AdminRole_id_key" ON "AdminRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_name_key') THEN + CREATE UNIQUE INDEX "AdminRole_name_key" ON "AdminRole"("name"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectMembers_projectId_userId_key') THEN + CREATE UNIQUE INDEX "ProjectMembers_projectId_userId_key" ON "ProjectMembers"("projectId", "userId"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectRole_id_key') THEN + CREATE UNIQUE INDEX "ProjectRole_id_key" ON "ProjectRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'Environment_projectId_name_key') THEN + CREATE UNIQUE INDEX "Environment_projectId_name_key" ON "Environment"("projectId", "name"); + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Log_projectId_fkey') THEN + ALTER TABLE "Log" ADD CONSTRAINT "Log_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Project_ownerId_fkey') THEN + ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_projectId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_userId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectRole_projectId_fkey') THEN + ALTER TABLE "ProjectRole" ADD CONSTRAINT "ProjectRole_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "adminRoleIds" TEXT[]; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql new file mode 100644 index 0000000000..c9b41827bf --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "AdminRole_name_key"; + +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "oidcGroup" TEXT; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql new file mode 100644 index 0000000000..265f262abb --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `oidcGroup` on table `AdminRole` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable + +UPDATE public."AdminRole" +SET "oidcGroup"='' +WHERE "oidcGroup" IS NULL; + +ALTER TABLE "AdminRole" ALTER COLUMN "oidcGroup" SET NOT NULL, +ALTER COLUMN "oidcGroup" SET DEFAULT ''; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql new file mode 100644 index 0000000000..4fc2768605 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "SystemSetting" +( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "SystemSetting_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemSetting_key_key" ON "SystemSetting"("key"); + +-- Create maintenance setting +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('maintenance', 'off'); diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql new file mode 100644 index 0000000000..95ab54869d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql @@ -0,0 +1,3 @@ +INSERT INTO public."AdminRole" +(id, "name", permissions, "position", "oidcGroup") +VALUES('76229c96-4716-45bc-99da-00498ec9018c'::uuid, 'Admin', 2, 0, '/admin'); \ No newline at end of file diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql new file mode 100644 index 0000000000..c116482186 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Made the column `externalUserName` on table `Repository` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +UPDATE "Repository" SET "externalUserName" = '' WHERE "externalUserName" IS NULL; + +ALTER TABLE "Repository" ALTER COLUMN "externalUserName" SET NOT NULL, +ALTER COLUMN "externalUserName" SET DEFAULT '', +ALTER COLUMN "externalRepoUrl" SET DEFAULT ''; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql new file mode 100644 index 0000000000..b0472cd806 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "TokenStatus" AS ENUM ('active', 'revoked'); + +-- CreateTable +CREATE TABLE "AdminToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "userId" UUID, + "expirationDate" TIMESTAMP(3), + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "AdminToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminToken_id_key" ON "AdminToken"("id"); + +-- AddForeignKey +ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql new file mode 100644 index 0000000000..47488b00c5 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "Log" DROP CONSTRAINT "Log_userId_fkey"; + +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "userId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql new file mode 100644 index 0000000000..18eca3eadf --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "requestId" SET DATA TYPE VARCHAR(36); diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql new file mode 100644 index 0000000000..74e0946f0a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Zone" ADD COLUMN "argocdUrl" TEXT NOT NULL DEFAULT 'https://example.com'; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql new file mode 100644 index 0000000000..41dac75359 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ProjectStatus" ADD VALUE 'warning'; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql new file mode 100644 index 0000000000..ef888d5e50 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql @@ -0,0 +1,12 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id FROM public."Project" WHERE status <> 'archived'::public."ProjectStatus" LOOP + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'nexus', 'activateMavenRepo', 'enabled') + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql new file mode 100644 index 0000000000..a57e5956c3 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql @@ -0,0 +1,12 @@ +-- CreateEnum +CREATE TYPE "UserType" AS ENUM ('human', 'bot', 'ghost'); + +-- AlterEnum +ALTER TYPE "TokenStatus" ADD VALUE 'inactive'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "type" "UserType" NOT NULL DEFAULT 'human'; +UPDATE "User" SET type = 'ghost' WHERE id = '04ac168a-2c4f-4816-9cce-af6c612e5912'; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "type" DROP DEFAULT; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql new file mode 100644 index 0000000000..71e15a312c --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql @@ -0,0 +1,84 @@ +-- CreateTable (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'PersonalAccessToken') THEN + CREATE TABLE "PersonalAccessToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "userId" UUID NOT NULL, + "expirationDate" TIMESTAMP(3) NOT NULL, + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'PersonalAccessToken_id_key') THEN + CREATE UNIQUE INDEX "PersonalAccessToken_id_key" ON "PersonalAccessToken"("id"); + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'PersonalAccessToken_userId_fkey') THEN + ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- Process AdminToken (idempotent) +DO $$ +DECLARE + admin_token record; + user_uuid UUID; +BEGIN + FOR admin_token IN SELECT "name", "id" + FROM public."AdminToken" + LOOP + -- Generate new UUID if user does not exist + user_uuid := COALESCE( + (SELECT id FROM public."User" WHERE email = concat(admin_token.name, '@bot.id')), + gen_random_uuid() + ); + + -- Insert user if not already exists + INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt", "type") + VALUES(user_uuid, 'Bot Admin', admin_token.name, concat(admin_token.name, '@bot.id'), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'bot') + ON CONFLICT (id) DO NOTHING; + + -- Update AdminToken with the new user ID + UPDATE public."AdminToken" SET "userId" = user_uuid WHERE id = admin_token.id; + END LOOP; +END $$; + +-- Alter AdminToken userId column to NOT NULL (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'AdminToken' AND column_name = 'userId' AND is_nullable = 'NO') THEN + ALTER TABLE public."AdminToken" ALTER COLUMN "userId" SET NOT NULL; + END IF; +END $$; + +-- DropForeignKey if exists (idempotent) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" DROP CONSTRAINT "AdminToken_userId_fkey"; + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql new file mode 100644 index 0000000000..521b2b10ac --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastLogin" TIMESTAMP(3); diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql new file mode 100644 index 0000000000..a7500833b9 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "slug" TEXT; + +UPDATE public."Project" p +SET slug = ( + SELECT concat(org.name, '-', subp.name) FROM public."Project" subp + LEFT JOIN public."Organization" org on org."id" = subp."organizationId" + WHERE subp.id = p.id +); + +ALTER TABLE public."Project" ALTER COLUMN "slug" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug"); diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql new file mode 100644 index 0000000000..b143cbeb9b --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "lastSuccessProvisionningVersion" TEXT; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql new file mode 100644 index 0000000000..7a88681901 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToProject_AB_unique"; + +-- AlterTable +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToStage_AB_unique"; + +-- AlterTable +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_QuotaToStage_AB_unique"; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql new file mode 100644 index 0000000000..21ce77b8d5 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "external" BOOLEAN NOT NULL DEFAULT false; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql new file mode 100644 index 0000000000..ac63cd639d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql @@ -0,0 +1,25 @@ +-- Vérifie les versions dans la table Project +DO $$ +DECLARE + project_id TEXT; + project_name TEXT; + last_version TEXT; +BEGIN + -- Boucle sur les projets non archivés + FOR project_id, project_name, last_version IN ( + SELECT id, name, "lastSuccessProvisionningVersion" + FROM "Project" + WHERE "status" != 'archived' + ) + LOOP + -- Vérifie si la version est NULL + IF last_version IS NULL THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version NULL.', project_name, project_id; + END IF; + + -- Vérifie si la version est inférieure à 8.23.0 selon SemVer + IF (string_to_array(last_version, '.')::int[] < ARRAY[8,23,0]) THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version (%), inférieure à 8.23.0.', project_name, project_id, last_version; + END IF; + END LOOP; +END $$; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql new file mode 100644 index 0000000000..54871c9017 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `organizationId` on the `Project` table. All the data in the column will be lost. + - You are about to drop the `Organization` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Project" DROP CONSTRAINT "Project_organizationId_fkey"; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "organizationId"; + +-- DropTable +DROP TABLE "Organization"; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql new file mode 100644 index 0000000000..68ca0df2fa --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "infos" SET DATA TYPE VARCHAR(1000); diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql new file mode 100644 index 0000000000..8364090d8d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql @@ -0,0 +1,44 @@ +-- AlterTable +ALTER TABLE "Environment" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; + +COMMENT ON COLUMN "Environment".cpu IS 'CPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".gpu IS 'GPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".memory IS 'Memory value as GigaBytes (1 and 0.01 are valid values)'; + +-- Use values from Quota. Memory is an extract of q.memory numeric value as it contains a unit (e.g. '2Gi'). +UPDATE "Environment" +SET cpu = q.cpu, memory = COALESCE(NULLIF(regexp_replace(q.memory, '\D', '','g'), ''), '0')::numeric +FROM "Quota" q +WHERE "quotaId" = q."id"; + +/* + Warnings: + + - You are about to drop the column `quotaId` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the `Quota` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_QuotaToStage` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_B_fkey"; + +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "quotaId", +ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- DropTable +DROP TABLE "Quota"; + +-- DropTable +DROP TABLE "_QuotaToStage"; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql new file mode 100644 index 0000000000..77f32b5ab5 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Cluster" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql new file mode 100644 index 0000000000..decca804aa --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Project" +ADD COLUMN "limitless" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "hprodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodMemory" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodMemory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql new file mode 100644 index 0000000000..95f3a689db --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql @@ -0,0 +1,4 @@ +-- Rename default zone +UPDATE "Zone" +SET ("label", "description") = ('DSO', 'Zone par défaut') +WHERE slug = 'default'; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql new file mode 100644 index 0000000000..aadb6cdba9 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Repository" ADD COLUMN "deployRevision" TEXT, +ADD COLUMN "deployPath" TEXT, +ADD COLUMN "helmValuesFiles" TEXT; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql new file mode 100644 index 0000000000..15cf56ab82 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql @@ -0,0 +1,12 @@ +-- Eliminate NULLs +UPDATE "Repository" SET "deployRevision" = '' WHERE "deployRevision" IS NULL; +UPDATE "Repository" SET "deployPath" = '' WHERE "deployPath" IS NULL; +UPDATE "Repository" SET "helmValuesFiles" = '' WHERE "helmValuesFiles" IS NULL; + +-- AlterTable +ALTER TABLE "Repository" ALTER COLUMN "deployRevision" SET NOT NULL, +ALTER COLUMN "deployRevision" SET DEFAULT '', +ALTER COLUMN "deployPath" SET NOT NULL, +ALTER COLUMN "deployPath" SET DEFAULT '', +ALTER COLUMN "helmValuesFiles" SET NOT NULL, +ALTER COLUMN "helmValuesFiles" SET DEFAULT ''; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql new file mode 100644 index 0000000000..1292495121 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "autosync" BOOLEAN NOT NULL DEFAULT true; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql new file mode 100644 index 0000000000..04c98df036 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "oidcGroup" TEXT NOT NULL DEFAULT ''; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql new file mode 100644 index 0000000000..40da41a75d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- Update AdminRole system roles +UPDATE "AdminRole" SET "type" = 'system' WHERE "name" IN ('Admin', 'Admin Locaux'); + +-- Update ProjectRole system roles +UPDATE "ProjectRole" SET "type" = 'system' WHERE "name" IN ('Administrateur', 'DevOps', 'Développeur', 'Lecture seule'); diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql new file mode 100644 index 0000000000..92c200d261 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql @@ -0,0 +1,26 @@ +-- Update existing Admin role to be system role 'Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + "oidcGroup" = '/admin', + "position" = 0 +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Lecture Seule Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '35848aa2-e881-4770-9844-0c5c3693e506'::uuid, + 'Lecture Seule Plateforme', + 1, -- Assuming 1n means bit 0 + 2, + '/readonly', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Lecture Seule Plateforme', + "type" = 'system', + "permissions" = 1, + "oidcGroup" = '/readonly'; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql new file mode 100644 index 0000000000..2a4afa8971 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql @@ -0,0 +1,28 @@ +-- Update existing Admin role to be system role 'Root Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Root Administrateur Plateforme' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Administrateur Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf'::uuid, + 'Administrateur Plateforme', + 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + 0, + '/console/admin', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, + "oidcGroup" = '/console/admin'; + +-- Update 'Lecture Seule Plateforme' system role +UPDATE "AdminRole" +SET + "oidcGroup" = '/console/readonly' +WHERE "id" = '35848aa2-e881-4770-9844-0c5c3693e506'::uuid; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql new file mode 100644 index 0000000000..f0cdb04873 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `external` on the `Cluster` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Cluster" DROP COLUMN "external"; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql new file mode 100644 index 0000000000..e07cad777b --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql @@ -0,0 +1,5 @@ +-- Enable legacy default permissions flag +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('refined-permissions', 'off'); diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql new file mode 100644 index 0000000000..efb6bc67d4 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql @@ -0,0 +1,3 @@ +-- Remove 'refined-permissions' system setting +DELETE FROM "SystemSetting" +WHERE "key" = 'refined-permissions'; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql new file mode 100644 index 0000000000..d019403c44 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql @@ -0,0 +1,15 @@ +-- Add global 'Tout le monde' role +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '034f589f-1750-4b15-bb34-4cd995e7fcaa'::uuid, + 'Tout le monde', + 25608, -- MANAGE_PROJECTS | LIST_PROJECTS | LIST_ZONES | LIST_STAGES + 0, + '', + 'global' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Tout le monde', + "type" = 'global', + "permissions" = 25608; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql new file mode 100644 index 0000000000..4fd0d80eb2 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql @@ -0,0 +1,25 @@ +-- Alter existing column to change the default value from 'custom' to 'managed' for AdminRole type +ALTER TABLE "AdminRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Alter existing column to change the default value from 'custom' to 'managed' for ProjectRole type +ALTER TABLE "ProjectRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Migrate system roles to managed roles +UPDATE "AdminRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate system roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate custom roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'custom'; + +-- Update existing Admin role to be an externally managed role +UPDATE "AdminRole" +SET "type" = 'external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql new file mode 100644 index 0000000000..d2691666bd --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql @@ -0,0 +1,15 @@ +-- Migrate well-known project roles to the new "system:"-prefixed type +UPDATE "ProjectRole" +SET "type" = 'system:managed' +WHERE + "type" = 'managed' + AND "oidcGroup" ~ '^/[^/]+/console/(admin|devops|developer|readonly)$'; + +-- Migrate well-known admin roles to the new "system:"-prefixed type +UPDATE "AdminRole" +SET "type" = 'system:managed' +WHERE id IN ( + '76229c96-4716-45bc-99da-00498ec9018c', -- Root Administrateur Plateforme + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', -- Administrateur Plateforme + '35848aa2-e881-4770-9844-0c5c3693e506' -- Lecture Seule Plateforme +); diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql new file mode 100644 index 0000000000..23593ba4f4 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "hprodCpu" DROP DEFAULT, +ALTER COLUMN "hprodGpu" DROP DEFAULT, +ALTER COLUMN "hprodMemory" DROP DEFAULT, +ALTER COLUMN "prodCpu" DROP DEFAULT, +ALTER COLUMN "prodGpu" DROP DEFAULT, +ALTER COLUMN "prodMemory" DROP DEFAULT; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql new file mode 100644 index 0000000000..7e3eefb25d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql @@ -0,0 +1,4 @@ +-- Set the Root Administrateur Plateforme admin role to external system-managed type +UPDATE "AdminRole" +SET "type" = 'system:external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql new file mode 100644 index 0000000000..ca81594339 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "DeploymentSourceType" AS ENUM ('git', 'oci'); + +-- CreateTable +CREATE TABLE "Deployment" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "name" VARCHAR(11) NOT NULL, + "autosync" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "environmentId" UUID NOT NULL, + + CONSTRAINT "Deployment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeploymentSource" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deploymentId" UUID NOT NULL, + "repositoryId" UUID NOT NULL, + "type" "DeploymentSourceType" NOT NULL, + "targetRevision" TEXT NOT NULL DEFAULT '', + "path" TEXT NOT NULL DEFAULT '', + "helmValuesFiles" TEXT NOT NULL DEFAULT '', + + CONSTRAINT "DeploymentSource_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Deployment_id_key" ON "Deployment"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentSource_id_key" ON "DeploymentSource"("id"); + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "Deployment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql new file mode 100644 index 0000000000..ad5a64d336 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_environmentId_fkey"; + +-- DropForeignKey +ALTER TABLE "DeploymentSource" DROP CONSTRAINT "DeploymentSource_repositoryId_fkey"; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/migration_lock.toml b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000..044d57cdb0 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/admin.prisma b/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/admin.prisma new file mode 100644 index 0000000000..c4ca38ccb1 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/admin.prisma @@ -0,0 +1,21 @@ +model AdminPlugin { + pluginName String + key String + value String + + @@unique([pluginName, key]) +} + +model AdminRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") +} + +model SystemSetting { + key String @id @unique + value String +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/project.prisma b/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/project.prisma new file mode 100644 index 0000000000..6bcc5ce949 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/project.prisma @@ -0,0 +1,144 @@ +model Deployment { + id String @id @unique @default(uuid()) @db.Uuid + projectId String @db.Uuid + name String @db.VarChar(11) + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + environmentId String @db.Uuid + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model DeploymentSource { + id String @id @unique @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deploymentId String @db.Uuid + repositoryId String @db.Uuid + type DeploymentSourceType + targetRevision String @default("") + path String @default("") + helmValuesFiles String @default("") + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) + deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) +} + +model Environment { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(11) + projectId String @db.Uuid + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clusterId String @db.Uuid + stageId String @db.Uuid + cluster Cluster @relation(fields: [clusterId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + stage Stage @relation(fields: [stageId], references: [id]) + deployments Deployment[] + + @@unique([projectId, name]) +} + +model Repository { + id String @id @default(uuid()) @db.Uuid + projectId String @db.Uuid + internalRepoName String + externalRepoUrl String @default("") + externalUserName String @default("") + isInfra Boolean @default(false) + isPrivate Boolean @default(false) + deployRevision String @default("") + deployPath String @default("") + helmValuesFiles String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model ProjectClusterHistory { + projectId String @db.Uuid + clusterId String @db.Uuid + + @@unique([projectId, clusterId]) +} + +model ProjectMembers { + projectId String @db.Uuid + userId String @db.Uuid + roleIds String[] + project Project @relation(fields: [projectId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@unique([projectId, userId]) +} + +model ProjectPlugin { + pluginName String + projectId String @db.Uuid + key String + value String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, pluginName, key]) +} + +model ProjectRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + projectId String @db.Uuid + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") + project Project @relation(fields: [projectId], references: [id]) +} + +model Project { + id String @id @unique @default(uuid()) @db.Uuid + name String + description String @default("") + status ProjectStatus @default(initializing) + locked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + everyonePerms BigInt @default(896) + ownerId String @db.Uuid + environments Environment[] + logs Log[] + owner User @relation(fields: [ownerId], references: [id]) + members ProjectMembers[] + plugins ProjectPlugin[] + roles ProjectRole[] + repositories Repository[] + clusters Cluster[] @relation("ClusterToProject") + slug String @unique + limitless Boolean @default(true) + hprodCpu Float @db.Real + hprodGpu Float @db.Real + hprodMemory Float @db.Real + prodCpu Float @db.Real + prodGpu Float @db.Real + prodMemory Float @db.Real + lastSuccessProvisionningVersion String? + deployments Deployment[] +} + +enum ProjectStatus { + initializing + created + failed + archived + warning +} + +enum DeploymentSourceType { + git + oci +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/schema.prisma b/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/schema.prisma new file mode 100644 index 0000000000..aadf7fea13 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/schema.prisma @@ -0,0 +1,21 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DB_URL") +} + +model Log { + id String @id @default(uuid()) @db.Uuid + data Json + action String @default("") + userId String? @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + requestId String? @db.VarChar(36) + projectId String? @db.Uuid + project Project? @relation(fields: [projectId], references: [id]) + user User? @relation(fields: [userId], references: [id]) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/token.prisma b/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/token.prisma new file mode 100644 index 0000000000..c0c55751c5 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/token.prisma @@ -0,0 +1,30 @@ +model AdminToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + userId String @db.Uuid + expirationDate DateTime? + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +model PersonalAccessToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + userId String @db.Uuid + expirationDate DateTime + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +enum TokenStatus { + active + revoked + inactive +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/topography.prisma b/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/topography.prisma new file mode 100644 index 0000000000..63d90abb8a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/topography.prisma @@ -0,0 +1,52 @@ +model Cluster { + id String @id @unique @default(uuid()) @db.Uuid + label String @unique @db.VarChar(50) + privacy ClusterPrivacy @default(dedicated) + secretName String @unique @default(uuid()) @db.VarChar(50) + clusterResources Boolean @default(false) + kubeConfigId String @unique @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + infos String? @db.VarChar(1000) + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + zoneId String @db.Uuid + kubeconfig Kubeconfig @relation(fields: [kubeConfigId], references: [id], onDelete: Cascade) + zone Zone @relation(fields: [zoneId], references: [id]) + environments Environment[] + projects Project[] @relation("ClusterToProject") + stages Stage[] @relation("ClusterToStage") +} + +model Kubeconfig { + id String @id @unique @default(uuid()) @db.Uuid + user Json + cluster Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + parentCluster Cluster? +} + +model Stage { + id String @id @unique @default(uuid()) @db.Uuid + name String @unique @db.VarChar + environments Environment[] + clusters Cluster[] @relation("ClusterToStage") +} + +model Zone { + id String @id @unique @default(uuid()) @db.Uuid + slug String @unique @db.VarChar(10) + label String @db.VarChar(50) + description String? @db.VarChar(200) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + argocdUrl String @default("https://example.com") + clusters Cluster[] +} + +enum ClusterPrivacy { + public + dedicated +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/user.prisma b/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/user.prisma new file mode 100644 index 0000000000..e90fb69f83 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/prisma/schema/user.prisma @@ -0,0 +1,23 @@ +model User { + id String @id @db.Uuid + firstName String + lastName String + email String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? + adminRoleIds String[] + type UserType + + logs Log[] + projectsOwned Project[] + adminTokens AdminToken[] + projectMembers ProjectMembers[] + personalAccessTokens PersonalAccessToken[] +} + +enum UserType { + human + bot + ghost +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/utils/crypto.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/utils/crypto.spec.ts new file mode 100644 index 0000000000..090c58583a --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/utils/crypto.spec.ts @@ -0,0 +1,33 @@ +import { generateProjectKey as legacyGenerateProjectKey } from '@cpn-console/hooks' +import { describe, expect, it } from 'vitest' +import { generateProjectKey } from './crypto.utils' + +describe('generateProjectKey', () => { + it('matches the legacy @cpn-console/hooks implementation byte-for-byte', () => { + const cases: [string, string][] = [ + ['my-app', 'my-repo'], + ['my-app', 'front'], + ['my-app-2', 'front'], + ['slug-with-many-dashes', 'repo-with-dashes'], + ['a', 'b'], + ] + for (const [slug, repo] of cases) { + expect(generateProjectKey(slug, repo)).toBe(legacyGenerateProjectKey(slug, repo)) + } + }) + + it('produces stable known keys', () => { + // Golden values: existing SonarQube projects were created with these exact keys, + // and reconciliation recomputes them to decide ownership. Any change here means + // the console would stop recognizing (and could delete) existing projects. + expect(generateProjectKey('my-app', 'my-repo')).toBe('my-app-my-repo-923f') + expect(generateProjectKey('my-app', 'front')).toBe('my-app-front-1013') + expect(generateProjectKey('my-app', 'api')).toBe('my-app-api-a814') + }) + + it('disambiguates identical slug-repo concatenations via the hash suffix', () => { + // "my-app" + "front-api" and "my-app-front" + "api" share the prefix + // "my-app-front-api-"; only the repo hash tells them apart. + expect(generateProjectKey('my-app', 'front-api')).not.toBe(generateProjectKey('my-app-front', 'api')) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/utils/crypto.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/utils/crypto.utils.ts new file mode 100644 index 0000000000..f12bebcd13 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/utils/crypto.utils.ts @@ -0,0 +1,18 @@ +import crypto, { createHmac } from 'node:crypto' + +export function generateRandomPassword(length = 24) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*' + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +// Must stay byte-for-byte identical to the legacy @cpn-console/hooks implementation: +// existing SonarQube project keys were generated with it, and ownership matching +// recomputes keys to decide which projects to reconcile or delete. +export function generateProjectKey(projectSlug: string, internalRepoName: string) { + const repoHash = createHmac('sha256', '') + .update(internalRepoName) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${internalRepoName}-${repoHash}` +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/utils/dotenv.utils.spec.ts b/.jjconflict-side-1/apps/server-nestjs/src/utils/dotenv.utils.spec.ts new file mode 100644 index 0000000000..b5c7014c46 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/utils/dotenv.utils.spec.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDotenvPaths, getExistingDotenvPaths } from './dotenv.utils' + +describe('dotenv.utils', () => { + afterEach(() => vi.unstubAllEnvs()) + + describe('getDotenvPaths', () => { + it('returns [".env"] when no env vars set', () => { + expect(getDotenvPaths()).toEqual(['.env']) + }) + + it('returns [".env", ".env.integ"] when INTEGRATION=true', () => { + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env', '.env.integ']) + }) + + it('returns [".env.docker"] when DOCKER=true', () => { + vi.stubEnv('DOCKER', 'true') + expect(getDotenvPaths()).toEqual(['.env.docker']) + }) + + it('returns [".env.integ", ".env.docker"] when DOCKER=true and INTEGRATION=true', () => { + vi.stubEnv('DOCKER', 'true') + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env.integ', '.env.docker']) + }) + }) + + describe('getExistingDotenvPaths', () => { + it('filters to only existing files', () => { + const paths = getExistingDotenvPaths() + expect(paths.every(p => typeof p === 'string')).toBe(true) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/src/utils/dotenv.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/utils/dotenv.utils.ts new file mode 100644 index 0000000000..7d3e0a0501 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/utils/dotenv.utils.ts @@ -0,0 +1,26 @@ +import fs from 'node:fs' + +export function getDotenvPaths(): string[] { + const paths: string[] = [] + + // Load .env unless DOCKER=true + if (process.env.DOCKER !== 'true') { + paths.push('.env') + } + + // Load .env.integ if INTEGRATION=true + if (process.env.INTEGRATION === 'true') { + paths.push('.env.integ') + } + + // Load .env.docker if DOCKER=true + if (process.env.DOCKER === 'true') { + paths.push('.env.docker') + } + + return paths +} + +export function getExistingDotenvPaths(): string[] { + return getDotenvPaths().filter(path => fs.existsSync(path)) +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/utils/http.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/utils/http.utils.ts new file mode 100644 index 0000000000..b85cc2f78b --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/utils/http.utils.ts @@ -0,0 +1,20 @@ +/** HTTP status carried by errors such as keycloak-admin-client's NetworkError. */ +export function getErrorResponseStatus(error: unknown): number | undefined { + if (error instanceof Error && 'response' in error && error.response instanceof Response) { + return error.response.status + } + return undefined +} + +/** HTTP details (status, url, response body) attached to errors carrying a fetch Response. */ +export function getErrorHttpDetails(error: Error): Record { + const details: Record = {} + if ('response' in error && error.response instanceof Response) { + details.status = error.response.status + details.url = error.response.url + } + if ('responseData' in error && error.responseData !== undefined && error.responseData !== '') { + details.responseData = error.responseData + } + return details +} diff --git a/.jjconflict-side-1/apps/server-nestjs/src/utils/iterable.utils.ts b/.jjconflict-side-1/apps/server-nestjs/src/utils/iterable.utils.ts new file mode 100644 index 0000000000..a46ef80632 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/src/utils/iterable.utils.ts @@ -0,0 +1,26 @@ +export async function* map( + iterable: AsyncIterable, + mapper: (value: T, index: number) => U | Promise, +): AsyncIterable { + let index = 0 + for await (const value of iterable) { + yield await mapper(value, index++) + } +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} + +export async function find(generator: AsyncGenerator, predicate: (item: T) => boolean): Promise { + for await (const item of generator) { + if (predicate(item)) return item + } + return undefined +} diff --git a/.jjconflict-side-1/apps/server-nestjs/test/argocd.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/argocd.e2e-spec.ts new file mode 100644 index 0000000000..65e0ea7497 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/argocd.e2e-spec.ts @@ -0,0 +1,314 @@ +import type { CommitAction, Gitlab } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { baseConfigFactory } from '../src/config/base.config' +import { projectSelect } from '../src/modules/argocd/argocd-datastore.service' +import { ArgoCDModule } from '../src/modules/argocd/argocd.module' +import { ArgoCDService } from '../src/modules/argocd/argocd.service' +import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunArgoCDE2E + = Boolean(process.env.E2E) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + && Boolean(process.env.DB_URL) + +const describeWithArgoCD = describe.runIf(canRunArgoCDE2E) + +describeWithArgoCD('ArgoCDController (e2e)', {}, () => { + let moduleRef: TestingModule + let argocdController: ArgoCDService + let gitlab: GitlabClientService + let gitlabClient: Gitlab + let vault: VaultClientService + let prisma: PrismaService + let config: BaseConfig + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + let zoneId: string + let zoneSlug: string + let kubeconfigId: string + let clusterId: string + let clusterLabel: string + let stageId: string + let envDevId: string + let envProdId: string + let envDevName: string + let envProdName: string + + let infraRepoId: number | undefined + let infraRepoPath: string + let vaultProjectValuesPath: string | undefined + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ArgoCDModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + argocdController = moduleRef.get(ArgoCDService) + gitlab = moduleRef.get(GitlabClientService) + gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) + vault = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + config = moduleRef.get(baseConfigFactory) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + zoneId = faker.string.uuid() + zoneSlug = faker.string.alphanumeric({ length: 10 }).toLowerCase() + kubeconfigId = faker.string.uuid() + clusterId = faker.string.uuid() + clusterLabel = faker.helpers.slugify(`cluster-${faker.string.uuid()}`.slice(0, 40)) + stageId = faker.string.uuid() + envDevId = faker.string.uuid() + envProdId = faker.string.uuid() + envDevName = 'dev' + envProdName = 'prod' + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.zone.create({ + data: { + id: zoneId, + slug: zoneSlug, + label: `Zone ${zoneSlug}`, + argocdUrl: 'https://example.com', + }, + }) + + await prisma.kubeconfig.create({ + data: { + id: kubeconfigId, + user: { token: faker.string.alphanumeric({ length: 16 }) }, + cluster: { server: 'https://example.com' }, + }, + }) + + await prisma.cluster.create({ + data: { + id: clusterId, + label: clusterLabel, + secretName: faker.string.uuid(), + kubeConfigId: kubeconfigId, + infos: null, + memory: 100, + cpu: 100, + gpu: 0, + zoneId, + }, + }) + + await prisma.stage.create({ + data: { + id: stageId, + name: faker.helpers.slugify(`stage-${faker.string.uuid()}`), + }, + }) + + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + clusters: { + connect: { id: clusterId }, + }, + repositories: { + create: { + internalRepoName: zoneSlug, + isInfra: true, + deployRevision: 'HEAD', + deployPath: '.', + helmValuesFiles: '', + }, + }, + environments: { + create: [ + { + id: envDevId, + name: envDevName, + clusterId, + stageId, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + }, + { + id: envProdId, + name: envProdName, + clusterId, + stageId, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + }, + ], + }, + }, + }) + + infraRepoPath = `${config.projectsRootDir}/infra/${zoneSlug}` + try { + const existing = await gitlabClient.Projects.show(infraRepoPath) + if (existing.empty_repo || existing.default_branch !== 'main') { + await gitlabClient.Projects.remove(existing.id).catch(() => {}) + throw new Error('Recreate infra repo') + } + infraRepoId = existing.id + } catch (error: any) { + const description = error?.cause?.description ?? '' + if ( + !(typeof description === 'string' && description.includes('404')) + && !(error instanceof Error && error.message === 'Recreate infra repo') + ) { + throw error + } + + const infraGroup = await gitlab.getOrCreateProjectSubGroup('infra') + const created = await gitlabClient.Projects.create({ + name: zoneSlug, + path: zoneSlug, + namespaceId: infraGroup.id, + initializeWithReadme: true, + defaultBranch: 'main', + }) + infraRepoId = created.id + } + + vaultProjectValuesPath = `${config.projectsRootDir}/${testProjectId}` + await vault.write({ e2e: true }, vaultProjectValuesPath) + }) + + afterAll(async () => { + if (vaultProjectValuesPath) { + await vault.delete(vaultProjectValuesPath).catch(() => {}) + } + + if (infraRepoId) { + await gitlabClient.Projects.remove(infraRepoId).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.stage.deleteMany({ where: { id: stageId } }).catch(() => {}) + await prisma.cluster.deleteMany({ where: { id: clusterId } }).catch(() => {}) + await prisma.kubeconfig.deleteMany({ where: { id: kubeconfigId } }).catch(() => {}) + await prisma.zone.deleteMany({ where: { id: zoneId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should commit environment values and cleanup stale values in the zone infra repo', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + const infraProject = await gitlab.getOrCreateInfraGroupRepo(zoneSlug) + infraRepoId = infraProject.id + + const staleFilePath = `${project.name}/${clusterLabel}/stale/values.yaml` + if (!infraRepoId) throw new Error('Missing infra repo id') + const staleAction = await gitlab.generateCreateOrUpdateAction(infraProject, 'main', staleFilePath, 'stale: true\n') + await gitlab.maybeCreateCommit(infraProject, 'ci: :robot_face: Seed stale values', staleAction ? [staleAction] : []) + + await argocdController.handleUpsert(project) + + const expectedFilePath = `${project.name}/${clusterLabel}/${envDevName}/values.yaml` + const file = await gitlabClient.RepositoryFiles.show(infraRepoId, expectedFilePath, 'main') + const raw = Buffer.from(file.content, 'base64').toString('utf8') + const values = parse(raw) + + expect(values?.common?.['dso/project.slug']).toBe(testProjectSlug) + expect(values?.common?.['dso/environment']).toBe(envDevName) + expect(values?.environment?.valueFilePath).toBe(expectedFilePath) + expect(values?.application?.destination?.name).toBe(clusterLabel) + expect(values?.application?.autosync).toBe(true) + + const shouldBeDeleted = await gitlab.getFile(infraProject, staleFilePath, 'main') + expect(shouldBeDeleted).toBeUndefined() + }, 144000) + + it('should update existing values and delete values of a removed environment', async () => { + const before = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + const infraProject = await gitlab.getOrCreateInfraGroupRepo(zoneSlug) + infraRepoId = infraProject.id + + const devFilePath = `${before.name}/${clusterLabel}/${envDevName}/values.yaml` + const prodFilePath = `${before.name}/${clusterLabel}/${envProdName}/values.yaml` + + const seededActions = (await Promise.all([ + gitlab.generateCreateOrUpdateAction(infraProject, 'main', devFilePath, 'old: true\n'), + gitlab.generateCreateOrUpdateAction(infraProject, 'main', prodFilePath, 'old: true\n'), + ])).filter((action): action is NonNullable => action !== null) + await gitlab.maybeCreateCommit(infraProject, 'ci: :robot_face: Seed existing values', seededActions as CommitAction[]) + + await prisma.environment.deleteMany({ where: { id: envProdId } }) + + const after = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await argocdController.handleUpsert(after) + + const updatedDev = await gitlabClient.RepositoryFiles.show(infraRepoId, devFilePath, 'main') + const devRaw = Buffer.from(updatedDev.content, 'base64').toString('utf8') + const devValues = parse(devRaw) + expect(devValues?.common?.['dso/project.slug']).toBe(testProjectSlug) + expect(devValues?.common?.['dso/environment']).toBe(envDevName) + + const prodFile = await gitlab.getFile(infraProject, prodFilePath, 'main') + expect(prodFile).toBeUndefined() + }, 72000) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/test/gitlab.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/gitlab.e2e-spec.ts new file mode 100644 index 0000000000..e8d19c497b --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/gitlab.e2e-spec.ts @@ -0,0 +1,242 @@ +import type { ExpandedUserSchema, Gitlab } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' +import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' +import { projectSelect } from '../src/modules/gitlab/gitlab-datastore.service' +import { GitlabModule } from '../src/modules/gitlab/gitlab.module' +import { GitlabService } from '../src/modules/gitlab/gitlab.service' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunGitlabE2E + = Boolean(process.env.E2E) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + && Boolean(process.env.DB_URL) + +const describeWithGitLab = describe.runIf(canRunGitlabE2E) + +describeWithGitLab('GitlabController (e2e)', {}, () => { + let moduleRef: TestingModule + let gitlabController: GitlabService + let gitlabService: GitlabClientService + let gitlabClient: Gitlab + let vaultService: VaultClientService + let prisma: PrismaService + let config: BaseConfig + + let testProjectId: string + let testProjectSlug: string + let ownerId: string + let ownerUser: ExpandedUserSchema + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [GitlabModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + gitlabController = moduleRef.get(GitlabService) + gitlabService = moduleRef.get(GitlabClientService) + gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) + vaultService = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + config = moduleRef.get(baseConfigFactory) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + const ownerEmail = `test-owner-${ownerId}@example.com` + + // Create owner in GitLab + ownerUser = await gitlabClient.Users.create({ + name: 'Test Owner', + password: faker.internet.password({ length: 24 }), + username: `test-owner-${ownerId}`, + email: ownerEmail, + skipConfirmation: true, + }) + + // Create owner in DB + await prisma.user.create({ + data: { + id: ownerId, + email: ownerUser.email.toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + await prisma.repository.create({ + data: { + projectId: testProjectId, + internalRepoName: 'app', + externalRepoUrl: 'https://example.com/example.git', + isPrivate: false, + }, + }) + }) + + afterAll(async () => { + // Clean GitLab group + if (testProjectSlug && config.projectsRootDir) { + const fullPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = await gitlabService.getGroupByPath(fullPath) + if (group) { + await gitlabService.deleteGroup(group).catch(() => {}) + } + } + + // Clean Vault + if (testProjectSlug && config.projectsRootDir) { + const vaultPath = `${config.projectsRootDir}/${testProjectSlug}` + await vaultService.delete(`${vaultPath}/tech/GITLAB_MIRROR`).catch(() => {}) + await vaultService.delete(`${vaultPath}/app-mirror`).catch(() => {}) + } + + // Clean DB + if (prisma) { + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + if (ownerUser?.id) { + await gitlabClient.Users.remove(ownerUser.id).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should reconcile and create project group in GitLab and Vault secrets', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await gitlabController.handleUpsert(project) + + // Assert + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = z.object({ + id: z.number(), + name: z.string(), + full_path: z.string(), + web_url: z.string(), + }).parse(await gitlabService.getGroupByPath(groupPath)) + expect(group.full_path).toBe(groupPath) + + // Check membership + const members = await gitlabService.getGroupMembers(group) + const isMember = members.some(m => m.id === ownerUser.id) + expect(isMember).toBe(true) + + const repoVaultPath = `${config.projectsRootDir}/${testProjectSlug}/app-mirror` + const repoSecret = await vaultService.read(repoVaultPath) + expect(repoSecret?.data?.GIT_OUTPUT_USER).toBeTruthy() + expect(repoSecret?.data?.GIT_OUTPUT_PASSWORD).toBeTruthy() + }, 72000) + + describe('project members', () => { + let newUserId: string | undefined + let newUserGitlabId: number | undefined + + beforeAll(async () => { + newUserId = faker.string.uuid() + const newUser = await gitlabClient.Users.create({ + email: faker.internet.email().toLowerCase(), + username: faker.internet.username(), + name: `${faker.person.firstName()} ${faker.person.lastName()}`, + password: faker.internet.password({ length: 24 }), + skipConfirmation: true, + }) + newUserGitlabId = newUser.id + + await prisma.user.create({ + data: { + id: newUserId, + email: newUser.email, + firstName: 'Test', + lastName: 'User', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: newUserId, + roleIds: [], + }, + }) + }) + + afterAll(async () => { + if (newUserGitlabId) { + await gitlabClient.Users.remove(newUserGitlabId).catch(() => {}) + } + if (prisma && newUserId) { + await prisma.projectMembers.deleteMany({ where: { userId: newUserId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: newUserId } }).catch(() => {}) + } + }) + + it('should add member to GitLab group when added in DB', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await gitlabController.handleUpsert(project) + + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = z.object({ + id: z.number(), + name: z.string(), + web_url: z.string(), + }).parse(await gitlabService.getGroupByPath(groupPath)) + + const members = await gitlabService.getGroupMembers(group) + const isNewMemberPresent = members.some(m => m.id === newUserGitlabId) + expect(isNewMemberPresent).toBe(true) + }, 72000) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/test/keycloak.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/keycloak.e2e-spec.ts new file mode 100644 index 0000000000..9875253678 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -0,0 +1,537 @@ +import type KcAdminClient from '@keycloak/keycloak-admin-client' +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Logger } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { projectSelect } from '../src/modules/keycloak/keycloak-datastore.service' +import { KeycloakModule } from '../src/modules/keycloak/keycloak.module' +import { KeycloakService } from '../src/modules/keycloak/keycloak.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunKeycloakE2E + = Boolean(process.env.E2E) + && Boolean(process.env.KEYCLOAK_DOMAIN) + && Boolean(process.env.KEYCLOAK_REALM) + && Boolean(process.env.KEYCLOAK_PROTOCOL) + && Boolean(process.env.KEYCLOAK_ADMIN) + && Boolean(process.env.KEYCLOAK_ADMIN_PASSWORD) + +const describeWithKeycloak = describe.runIf(canRunKeycloakE2E) + +describeWithKeycloak('KeycloakController (e2e)', () => { + let moduleRef: TestingModule + let keycloakController: KeycloakService + let keycloak: KeycloakClientService + let keycloakAdminClient: KcAdminClient + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + let testRoleName: string + let testRoleId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [KeycloakModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + keycloakController = moduleRef.get(KeycloakService) + keycloak = moduleRef.get(KeycloakClientService) + keycloakAdminClient = moduleRef.get(KEYCLOAK_ADMIN_CLIENT) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + testRoleName = faker.helpers.slugify(`test-role-${faker.string.uuid()}`) + testRoleId = faker.string.uuid() + + const ownerEmail = faker.internet.email({ firstName: 'test-owner', provider: 'example.com' }) + + // Create owner in Keycloak + const createdUser = await keycloakAdminClient.users.create({ + id: ownerId, + username: `test-owner-${ownerId}`, + email: ownerEmail, + enabled: true, + firstName: 'Test', + lastName: 'Owner', + }) + if (createdUser.id) { + ownerId = createdUser.id + } + + // Create owner in DB + await prisma.user.create({ + data: { + id: ownerId, + email: ownerEmail, + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + try { + // Clean Keycloak + const group = await keycloak.getGroupByPath(`/${testProjectSlug}`) + if (group?.id) { + await keycloak.deleteGroup(group.id) + } + + // Clean owner user + if (ownerId) { + await keycloakAdminClient.users.del({ id: ownerId }).catch(() => {}) + if (prisma) { + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + } + + // Clean DB + if (prisma) { + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId } }) + // Prisma cascade delete should handle roles/envs if configured correctly, but explicit delete is safer + // We catch errors to avoid failing cleanup if tables/relations are different + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + } + } catch (e: any) { + Logger.warn(`Cleanup failed: ${e.message}`) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('should reconcile and create groups in Keycloak', async () => { + // Create Project in DB + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + roles: { + create: { + id: testRoleId, + name: testRoleName, + oidcGroup: `/${testRoleName}`, + permissions: BigInt(0), + position: 0, + }, + }, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await keycloakController.handleUpsert(project) + + // Assert + // Check main project group + const projectGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + expect(projectGroup.name).toBe(testProjectSlug) + + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console`)) + expect(consoleGroup.name).toBe('console') + + // Check role group + const roleGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(roleGroup.name).toBe(testRoleName) + + // Check membership (owner should be added) + const members = await keycloak.getGroupMembers(projectGroup.id) + const isMember = members.some(m => m.id === ownerId) + expect(isMember).toBe(true) + }, 60000) + + it('should add member to project group when added in DB', async () => { + // Create another user in Keycloak and DB + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-${newUserId}@example.com` + + // Create in Keycloak + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'User', + }) + + // Create in DB + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'User', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [testRoleId], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await keycloakController.handleUpsert(project) + + // Assert + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + const members = await keycloak.getGroupMembers(projectGroup.id) + const isMember = members.some(m => m.id === kcUser.id) + expect(isMember).toBe(true) + + // Check role group membership + const roleGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + const roleMembers = await keycloak.getGroupMembers(roleGroup.id) + const isRoleMember = roleMembers.some(m => m.id === kcUser.id) + expect(isRoleMember).toBe(true) + + // Cleanup user + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should remove member from project group when removed in DB', async () => { + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-remove-${newUserId}@example.com` + + // Create in Keycloak + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-remove-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserRemove', + }) + + // Create in DB + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserRemove', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [], // No roles + }, + }) + + let project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync add + await keycloakController.handleUpsert(project) + + // Verify added + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Remove from DB + await prisma.projectMembers.delete({ + where: { + projectId_userId: { + projectId: testProjectId, + userId: kcUser.id, + }, + }, + }) + + project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync remove + await keycloakController.handleUpsert(project) + + // Verify removed + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should handle non-existent users gracefully', async () => { + // Add a member in DB that does not exist in Keycloak + const fakeUserId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: fakeUserId, + email: `fake-${fakeUserId}@example.com`, + firstName: 'Fake', + lastName: 'User', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: fakeUserId, + roleIds: [], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act - should not throw + await expect(keycloakController.handleUpsert(project)).resolves.not.toThrow() + + // Cleanup + await prisma.projectMembers.deleteMany({ where: { userId: fakeUserId } }) + await prisma.user.delete({ where: { id: fakeUserId } }) + }, 60000) + + it('should add user back to Keycloak group if missing but present in DB', async () => { + // Create user and add to project in DB + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-sync-${newUserId}@example.com` + + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-sync-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserSync', + }) + + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserSync', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync to ensure they are added initially + await keycloakController.handleUpsert(project) + + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + + // Manually remove user from Keycloak group + await keycloak.removeUserFromGroup(kcUser.id, projectGroup.id) + + // Verify removal + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Sync again + await keycloakController.handleUpsert(project) + + // Verify added back + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should remove user from Keycloak group if present but missing in DB', async () => { + // Create user + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-orphan-${newUserId}@example.com` + + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-orphan-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserOrphan', + }) + + // We only need them in Keycloak for this test, but the controller checks if user is in DB to define "missing". + // Actually, `deleteExtraProjectMembers` iterates over Keycloak group members. + // So we don't strictly need the user in DB, but to be "clean" we should probably have them in DB but NOT in the project. + + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserOrphan', + type: 'human', + }, + }) + + // Get project from DB (user is NOT a member) + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync to create group + await keycloakController.handleUpsert(project) + + // Manually add user to Keycloak group + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + await keycloak.addUserToGroup(kcUser.id, projectGroup.id) + + // Verify added + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Sync again to remove user + await keycloakController.handleUpsert(project) + + // Verify removed + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should recreate project group if deleted in Keycloak', async () => { + // Ensure project exists and is synced + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await keycloakController.handleUpsert(project) + + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + + // Delete group in Keycloak + await keycloak.deleteGroup(projectGroup.id) + + // Verify deleted + const deletedProjectGroup = await keycloak.getGroupByPath(`/${testProjectSlug}`) + expect(deletedProjectGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedProjectGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + expect(recreatedProjectGroup?.name).toBe(testProjectSlug) + }, 60000) + + it('should recreate role group if deleted in Keycloak', async () => { + // Ensure project exists and is synced + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await keycloakController.handleUpsert(project) + + const roleGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + + // Delete role group in Keycloak + await keycloak.deleteGroup(roleGroup.id) + + // Verify deleted + const deletedRoleGroup = await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`) + expect(deletedRoleGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedRoleGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(recreatedRoleGroup?.name).toBe(testRoleName) + }, 60000) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/test/log.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/log.e2e-spec.ts new file mode 100644 index 0000000000..2b5f509b3f --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/log.e2e-spec.ts @@ -0,0 +1,188 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { LogModule } from '../src/modules/log/log.module' +import { LogService } from '../src/modules/log/log.service' + +const canRunLogE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + +const describeWithLog = describe.runIf(canRunLogE2E) + +describeWithLog('LogService (e2e)', () => { + let moduleRef: TestingModule + let logService: LogService + let prisma: PrismaService + + let ownerId: string + let projectId: string + let projectSlug: string + let projectLogId: string + let globalLogId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [LogModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + logService = moduleRef.get(LogService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + if (projectLogId) { + await prisma.log.deleteMany({ where: { id: projectLogId } }).catch(() => {}) + } + if (globalLogId) { + await prisma.log.deleteMany({ where: { id: globalLogId } }).catch(() => {}) + } + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should persist logs and return clean/project-filtered results', async () => { + const projectLog = await logService.addLog({ + action: 'project-upsert', + userId: ownerId, + projectId, + requestId: faker.string.uuid(), + data: { + args: { slug: projectSlug }, + results: { ok: true }, + config: { hidden: true }, + cluster: { id: 'cluster-1' }, + user: { id: ownerId }, + newCreds: { token: 'secret' }, + apis: ['v1'], + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }, + }) + projectLogId = projectLog.id + + const globalLog = await logService.addLog({ + action: 'global-upsert', + data: { + args: { enabled: true }, + results: { ok: true }, + config: { hidden: true }, + }, + }) + globalLogId = globalLog.id + + const storedLog = await prisma.log.findUniqueOrThrow({ + where: { id: projectLog.id }, + }) + expect(storedLog.data).toMatchObject({ + args: { slug: projectSlug }, + results: { ok: true }, + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }) + expect(storedLog.data).not.toHaveProperty('config') + expect(storedLog.data).not.toHaveProperty('cluster') + expect(storedLog.data).not.toHaveProperty('user') + expect(storedLog.data).not.toHaveProperty('newCreds') + expect(storedLog.data).not.toHaveProperty('apis') + + const { total: projectTotal, logs: projectLogs } = await logService.getLogs({ + offset: 0, + limit: 10, + projectId, + clean: false, + }) + expect(projectTotal).toBe(1) + expect(projectLogs).toHaveLength(1) + expect(projectLogs[0]).toMatchObject({ + id: projectLog.id, + action: 'project-upsert', + userId: ownerId, + projectId, + }) + expect(projectLogs[0].data).toMatchObject({ + args: { slug: projectSlug }, + results: { ok: true }, + warning: ['careful'], + }) + expect(projectLogs[0].data).not.toHaveProperty('config') + + const { total: allTotal, logs: allLogs } = await logService.getLogs({ + offset: 0, + limit: 10, + projectId: undefined, + clean: true, + }) + expect(allTotal).toBeGreaterThan(2) + expect(allLogs).toHaveLength(10) + expect(allLogs[0]).toMatchObject({ + id: globalLog.id, + action: 'global-upsert', + userId: null, + }) + expect(allLogs[0].data).not.toHaveProperty('args') + expect(allLogs[0].data).not.toHaveProperty('results') + expect(allLogs[0].data).not.toHaveProperty('config') + expect(allLogs[1]).toMatchObject({ + id: projectLog.id, + action: 'project-upsert', + userId: ownerId, + }) + expect(allLogs[1].data).toMatchObject({ + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }) + expect(allLogs[1].data).not.toHaveProperty('args') + expect(allLogs[1].data).not.toHaveProperty('results') + expect(allLogs[1].data).not.toHaveProperty('config') + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/test/nexus.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/nexus.e2e-spec.ts new file mode 100644 index 0000000000..0c5b45eb5d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/nexus.e2e-spec.ts @@ -0,0 +1,156 @@ +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { NexusClientService } from '../src/modules/nexus/nexus-client.service' +import { projectSelect } from '../src/modules/nexus/nexus-datastore.service' +import { makeProjectWithDetails } from '../src/modules/nexus/nexus-testing.utils' +import { NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, PLUGIN_NAME } from '../src/modules/nexus/nexus.constants' +import { NexusModule } from '../src/modules/nexus/nexus.module' +import { NexusService } from '../src/modules/nexus/nexus.service' +import { getProjectVaultPath } from '../src/modules/nexus/nexus.utils' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunNexusE2E + = Boolean(process.env.E2E) + && Boolean(process.env.NEXUS_URL) + && Boolean(process.env.NEXUS_ADMIN) + && Boolean(process.env.NEXUS_ADMIN_PASSWORD) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithNexus = describe.runIf(canRunNexusE2E) + +describeWithNexus('NexusController (e2e)', () => { + let moduleRef: TestingModule + let nexusController: NexusService + let nexusClient: NexusClientService + let vaultService: VaultClientService + let config: BaseConfig + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [NexusModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + nexusController = moduleRef.get(NexusService) + nexusClient = moduleRef.get(NexusClientService) + vaultService = moduleRef.get(VaultClientService) + config = moduleRef.get(baseConfigFactory) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (testProjectSlug) { + await nexusController.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should reconcile project in Nexus (repos, role, user, vault secret)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + plugins: { + create: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }, + ], + }, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await nexusController.handleUpsert(project) + + const mavenReleaseRepo = `${testProjectSlug}-repository-release` + const mavenSnapshotRepo = `${testProjectSlug}-repository-snapshot` + const mavenGroupRepo = `${testProjectSlug}-repository-group` + + const npmHostedRepo = `${testProjectSlug}-npm` + const npmGroupRepo = `${testProjectSlug}-npm-group` + + const [releaseRepo, snapshotRepo, groupRepo, npmRepo, npmGroup] = await Promise.all([ + nexusClient.getRepositoriesMavenHosted(mavenReleaseRepo), + nexusClient.getRepositoriesMavenHosted(mavenSnapshotRepo), + nexusClient.getRepositoriesMavenGroup(mavenGroupRepo), + nexusClient.getRepositoriesNpmHosted(npmHostedRepo), + nexusClient.getRepositoriesNpmGroup(npmGroupRepo), + ]) + + expect(releaseRepo).toBeTruthy() + expect(snapshotRepo).toBeTruthy() + expect(groupRepo).toBeTruthy() + expect(npmRepo).toBeTruthy() + expect(npmGroup).toBeTruthy() + + const roleId = `${testProjectSlug}-ID` + const role = await nexusClient.getSecurityRoles(roleId) + expect(role).toBeTruthy() + + const users = await nexusClient.getSecurityUsers(testProjectSlug) + expect(users.some(u => u.userId === testProjectSlug)).toBe(true) + + const vaultPath = getProjectVaultPath(config.projectsRootDir, testProjectSlug, 'tech/NEXUS') + const secret = await vaultService.read(vaultPath) + expect(secret.data?.NEXUS_USERNAME).toBe(testProjectSlug) + expect(secret.data?.NEXUS_PASSWORD).toBeTruthy() + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/test/project-bulk.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/project-bulk.e2e-spec.ts new file mode 100644 index 0000000000..3a1e80f39d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/project-bulk.e2e-spec.ts @@ -0,0 +1,109 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectBulkModule } from '../src/modules/project-bulk/project-bulk.module' +import { ProjectBulkService } from '../src/modules/project-bulk/project-bulk.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectBulkE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectBulk = describe.runIf(canRunProjectBulkE2E) + +describeWithProjectBulk('ProjectBulkService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectBulkService + let eventEmitter: EventEmitter2 + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectBulkModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectBulkService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('locks and unlocks projects', async () => { + await service.bulkAction({ action: 'lock', projectIds: [projectId] }) + const locked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } }) + expect(locked.locked).toBe(true) + + await service.bulkAction({ action: 'unlock', projectIds: [projectId] }) + const unlocked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } }) + expect(unlocked.locked).toBe(false) + }) + + it('replays hooks for projects', async () => { + await service.bulkAction({ action: 'replay', projectIds: [projectId] }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith( + 'project.upsert', + expect.objectContaining({ id: projectId, slug: projectSlug }), + ) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/test/project-hooks.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/project-hooks.e2e-spec.ts new file mode 100644 index 0000000000..ab6e75184e --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/project-hooks.e2e-spec.ts @@ -0,0 +1,119 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectHooksModule } from '../src/modules/project-hooks/project-hooks.module' +import { ProjectHooksService } from '../src/modules/project-hooks/project-hooks.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectHooksE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectHooks = describe.runIf(canRunProjectHooksE2E) + +describeWithProjectHooks('ProjectHooksService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectHooksService + let eventEmitter: DeepMockProxy + let vaultService: DeepMockProxy + let vaultClient: DeepMockProxy + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + vaultService = mockDeep() + vaultService.listProjectSecrets.mockResolvedValue([]) + vaultClient = mockDeep() + + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectHooksModule], + providers: [ + { provide: VaultService, useValue: vaultService }, + { provide: VaultClientService, useValue: vaultClient }, + ], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectHooksService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('replays hooks through the event system', async () => { + await service.replay(projectId) + + expect(eventEmitter.emitAsync).toHaveBeenCalledWith( + 'project.upsert', + expect.objectContaining({ id: projectId, slug: projectSlug }), + ) + }) + + it('rejects replayHooks when the project does not exist', async () => { + const result = await service.replay(faker.string.uuid()) + expect(result).toBeUndefined() + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/test/project-members.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/project-members.e2e-spec.ts new file mode 100644 index 0000000000..10dec97860 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/project-members.e2e-spec.ts @@ -0,0 +1,169 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { ProjectMembersModule } from '../src/modules/project-members/project-members.module' +import { ProjectMembersService } from '../src/modules/project-members/project-members.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectMembersE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectMembers = describe.runIf(canRunProjectMembersE2E) + +describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectMembersService + let eventEmitter: DeepMockProxy + let keycloakClient: DeepMockProxy + + let ownerId: string + let memberId: string + + beforeAll(async () => { + keycloakClient = mockDeep() + keycloakClient.getUserByEmail.mockResolvedValue(undefined) + + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectMembersModule], + providers: [ + { provide: KeycloakClientService, useValue: keycloakClient }, + ], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectMembersService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + memberId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.user.create({ + data: { + id: memberId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Member', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.user.deleteMany({ where: { id: memberId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('rejects addMember when project does not exist', async () => { + await expect(service.add(faker.string.uuid(), { userId: memberId })).rejects.toThrow(NotFoundException) + }) + + it('rejects listMembers when project does not exist', async () => { + await expect(service.list(faker.string.uuid())).resolves.toEqual([]) + }) + + describe('with project', () => { + let projectId: string + + beforeEach(async () => { + eventEmitter.emitAsync.mockClear() + + projectId = faker.string.uuid() + const projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + + eventEmitter.emitAsync.mockClear() + }) + + afterEach(async () => { + await prisma.projectMembers.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + }) + + it('list', async () => { + const members = await service.list(projectId) + expect(members).toHaveLength(0) + }) + + it('add', async () => { + const afterAdd = await service.add(projectId, { userId: memberId }) + expect(afterAdd.some(m => m.userId === memberId)).toBe(true) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.upsert', { projectId, userId: memberId }) + }) + + it('patch', async () => { + await service.add(projectId, { userId: memberId }) + const roleId = faker.string.uuid() + const afterPatch = await service.patch(projectId, [{ userId: memberId, roles: [roleId] }]) + expect(afterPatch.find(m => m.userId === memberId)?.roleIds).toContain(roleId) + }) + + it('remove', async () => { + await service.add(projectId, { userId: memberId }) + const afterRemove = await service.remove(projectId, memberId) + expect(afterRemove.some(m => m.userId === memberId)).toBe(false) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.delete', { projectId, userId: memberId }) + }) + + it('rejects addMember when adding owner', async () => { + await expect(service.add(projectId, { userId: ownerId })).rejects.toThrow(BadRequestException) + }) + + it('rejects addMember when user does not exist', async () => { + await expect(service.add(projectId, { userId: faker.string.uuid() })).rejects.toThrow(NotFoundException) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/test/project-roles.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/project-roles.e2e-spec.ts new file mode 100644 index 0000000000..0f530ebf53 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/project-roles.e2e-spec.ts @@ -0,0 +1,238 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectRolesModule } from '../src/modules/project-roles/project-roles.module' +import { ProjectRolesService } from '../src/modules/project-roles/project-roles.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectRolesE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectRoles = describe.runIf(canRunProjectRolesE2E) + +describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectRolesService + let eventEmitter: DeepMockProxy + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectRolesModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectRolesService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-roles-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E roles test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.projectRole.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('lists roles for a project', async () => { + const role = await prisma.projectRole.create({ + data: { + projectId, + name: 'admin', + permissions: 4n, + position: 0, + type: 'managed', + oidcGroup: `/${projectSlug}/admin`, + }, + }) + + const roles = await service.list(projectId) + + expect(roles).toHaveLength(1) + expect(roles[0].id).toBe(role.id) + expect(roles[0].permissions).toBe('4') + expect(roles[0].oidcGroup).toBe('/admin') + }) + + it('creates a role and emits project upsert', async () => { + const role = await service.create(projectId, { name: 'developer', permissions: '2' }) + + expect(role.length).toBeGreaterThanOrEqual(1) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', expect.objectContaining({ id: projectId })) + + const dbRole = await prisma.projectRole.findFirst({ + where: { projectId, name: 'developer' }, + }) + expect(dbRole).not.toBeNull() + expect(dbRole?.permissions).toBe(2n) + }) + + it('patches roles and updates position', async () => { + const existingRole = await prisma.projectRole.create({ + data: { + projectId, + name: 'tester', + permissions: 1n, + position: 5, + type: 'managed', + oidcGroup: '', + }, + }) + + await prisma.projectRole.create({ + data: { + projectId, + name: 'other', + permissions: 2n, + position: 6, + type: 'managed', + oidcGroup: '', + }, + }) + + const roles = await service.update(projectId, [{ + id: existingRole.id, + name: 'qa', + }]) + + const updated = roles.find(r => r.id === existingRole.id) + expect(updated?.name).toBe('qa') + }) + + it('counts role members', async () => { + const roleId = faker.string.uuid() + await prisma.projectRole.create({ + data: { + id: roleId, + projectId, + name: 'counted', + permissions: 1n, + position: 10, + type: 'managed', + oidcGroup: '', + }, + }) + + const userId = faker.string.uuid() + await prisma.user.create({ + data: { + id: userId, + email: faker.internet.email().toLowerCase(), + firstName: 'Member', + lastName: 'Test', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId, + userId, + roleIds: [roleId], + }, + }) + + const counts = await service.countMembers(projectId) + expect(counts[roleId]).toBe(1) + }) + + it('deletes a role and cleans member role ids', async () => { + const roleId = faker.string.uuid() + const userId = faker.string.uuid() + + await prisma.projectRole.create({ + data: { + id: roleId, + projectId, + name: 'to-delete', + permissions: 1n, + position: 20, + type: 'managed', + oidcGroup: '', + }, + }) + + await prisma.user.create({ + data: { + id: userId, + email: faker.internet.email().toLowerCase(), + firstName: 'Delete', + lastName: 'Test', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId, + userId, + roleIds: [roleId], + }, + }) + + await service.delete(roleId) + + const member = await prisma.projectMembers.findUnique({ + where: { projectId_userId: { projectId, userId } }, + }) + expect(member?.roleIds).not.toContain(roleId) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', expect.objectContaining({ id: projectId })) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/test/project-secrets.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/project-secrets.e2e-spec.ts new file mode 100644 index 0000000000..bb30aff5d7 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/project-secrets.e2e-spec.ts @@ -0,0 +1,145 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectSecretsModule } from '../src/modules/project-secrets/project-secrets.module' +import { ProjectSecretsService } from '../src/modules/project-secrets/project-secrets.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { generateProjectPath } from '../src/modules/vault/vault.utils' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectSecretsE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithProjectSecrets = describe.runIf(canRunProjectSecretsE2E) + +describeWithProjectSecrets('ProjectSecretsService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectSecretsService + let vaultClient: VaultClientService + + let ownerId: string + let projectId: string + let projectSlug: string + let projectPath: string + let secretAPath: string + let secretBPath: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectSecretsModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectSecretsService) + vaultClient = moduleRef.get(VaultClientService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + projectPath = generateProjectPath(process.env.PROJECTS_ROOT_DIR, projectSlug) + secretAPath = `${projectPath}/group1/secret1` + secretBPath = `${projectPath}/group2/secret2` + + await Promise.all([secretAPath, secretBPath].map(path => vaultClient.delete(path).catch(() => {}))) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + await Promise.all([secretAPath, secretBPath].map(path => vaultClient.delete(path).catch(() => {}))) + + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('returns an empty secret map when no Vault secrets exist', async () => { + const secrets = await service.get(projectId) + expect(secrets).toEqual({}) + }) + + describe('when Vault secrets exist', () => { + beforeAll(async () => { + await Promise.all([ + vaultClient.write({ + key1: 'value1', + key2: 42, + }, secretAPath), + vaultClient.write({ + key3: false, + key4: null, + }, secretBPath), + ]) + }) + + afterAll(async () => { + await Promise.all([ + vaultClient.delete(secretAPath).catch(() => {}), + vaultClient.delete(secretBPath).catch(() => {}), + ]) + }) + + it('parses real Vault values into grouped secrets', async () => { + const secrets = await service.get(projectId) + + expect(secrets.group1).toEqual({ + 'secret1.key1': 'value1', + 'secret1.key2': '42', + }) + expect(secrets.group2).toEqual({ + 'secret2.key3': 'false', + 'secret2.key4': '', + }) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/test/project-services.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/project-services.e2e-spec.ts new file mode 100644 index 0000000000..25612a1f99 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/project-services.e2e-spec.ts @@ -0,0 +1,144 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectServicesModule } from '../src/modules/project-services/project-services.module' +import { ProjectServicesService } from '../src/modules/project-services/project-services.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunServicesE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + && Boolean(process.env.ARGOCD_URL) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.HARBOR_URL) + && Boolean(process.env.HARBOR_ADMIN) + && Boolean(process.env.HARBOR_ADMIN_PASSWORD) + && Boolean(process.env.KEYCLOAK_DOMAIN) + && Boolean(process.env.KEYCLOAK_REALM) + && Boolean(process.env.KEYCLOAK_PROTOCOL) + && Boolean(process.env.NEXUS_URL) + && Boolean(process.env.NEXUS_ADMIN) + && Boolean(process.env.NEXUS_ADMIN_PASSWORD) + && Boolean(process.env.SONARQUBE_URL) + && Boolean(process.env.SONAR_API_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithServices = describe.runIf(canRunServicesE2E) + +const PLUGIN_NAME = 'gitlab' + +describeWithServices('ProjectServicesService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectServicesService + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectServicesModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectServicesService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.projectPlugin.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('get returns services for a project member', async () => { + const services = await service.get(projectId, 'user') + const gitlabService = services.find(entry => entry.name === PLUGIN_NAME) + + expect(gitlabService).toBeTruthy() + expect(gitlabService?.name).toBe(PLUGIN_NAME) + }) + + it('rejects get when project does not exist', async () => { + await expect(service.get(faker.string.uuid(), 'user')).rejects.toThrow(NotFoundException) + }) + + it('update stores project configuration', async () => { + await service.update(projectId, { + gitlab: { + enabled: 'enabled', + }, + }, ['user']) + + const stored = await prisma.projectPlugin.findUniqueOrThrow({ + where: { + projectId_pluginName_key: { + projectId, + pluginName: PLUGIN_NAME, + key: 'user.enabled', + }, + }, + select: { + value: true, + }, + }) + + expect(stored.value).toBe('enabled') + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/test/project.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/project.e2e-spec.ts new file mode 100644 index 0000000000..0aa6b9011d --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/project.e2e-spec.ts @@ -0,0 +1,153 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ForbiddenException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectPermissionModule } from '../src/modules/infrastructure/permission/project/project.module' +import { makeCreateProjectBody } from '../src/modules/project/project-testing.utils' +import { ProjectService } from '../src/modules/project/project.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProject = describe.runIf(canRunProjectE2E) + +describeWithProject('ProjectService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectService + let eventEmitter: DeepMockProxy + + let ownerId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectPermissionModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('ProjectService.create', async () => { + const createBody = makeCreateProjectBody({ + name: faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`), + description: 'Initial description', + }) + const created = await service.create(createBody, ownerId) + await prisma.project.deleteMany({ where: { id: created.id } }).catch(() => {}) + expect(created.id).toBeTruthy() + }) + + it('rejects ProjectService.list filter=all for non-admin', async () => { + await expect( + service.listProjects({ filter: 'all' }, { userId: ownerId, adminPermissions: 0n }), + ).rejects.toThrow(ForbiddenException) + }) + + it('rejects ProjectService.get when project does not exist', async () => { + await expect(service.get(faker.string.uuid())).rejects.toThrow(NotFoundException) + }) + + describe('with project', () => { + let projectId: string + let projectName: string + + beforeEach(async () => { + eventEmitter.emitAsync.mockClear() + + const createBody = makeCreateProjectBody({ + name: faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`), + description: 'Initial description', + }) + + const created = await service.create(createBody, ownerId) + projectId = created.id + projectName = created.name + + eventEmitter.emitAsync.mockClear() + }) + + afterEach(async () => { + await prisma.projectMembers.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + }) + + it('ProjectService.get', async () => { + const fetched = await service.get(projectId) + expect(fetched.id).toBe(projectId) + expect(fetched.ownerId).toBe(ownerId) + }) + + it('ProjectService.list', async () => { + const listResult = await service.listProjects({ filter: 'member' }, { userId: ownerId, adminPermissions: 0n }) + expect(listResult.some(p => p.id === projectId)).toBe(true) + }) + + it('ProjectService.getData', async () => { + const dataExport = await service.getProjectsData() + expect(Array.isArray(dataExport)).toBe(true) + expect(dataExport.some(p => p.name === projectName)).toBe(true) + }) + + it('ProjectService.update', async () => { + const updated = await service.update( + { description: 'Updated description' }, + { userId: ownerId, adminPermissions: 0n }, + projectId, + ) + expect(updated.description).toBe('Updated description') + }) + + it('ProjectService.archive', async () => { + await service.archive(projectId) + const archived = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { status: true, locked: true } }) + expect(archived.status).toBe('archived') + expect(archived.locked).toBe(true) + }) + + it('rejects ProjectService.update when project is locked', async () => { + await prisma.project.update({ where: { id: projectId }, data: { locked: true } }) + await expect( + service.update({ description: 'nope' }, { userId: ownerId, adminPermissions: 0n }, projectId), + ).rejects.toThrow(ForbiddenException) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/test/registry.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/registry.e2e-spec.ts new file mode 100644 index 0000000000..2fdf921814 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/registry.e2e-spec.ts @@ -0,0 +1,92 @@ +import type { ConfigType } from '@nestjs/config' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { harborConfigFactory } from '../src/config/harbor.config' +import { RegistryClientService } from '../src/modules/registry/registry-client.service' +import { makeProjectWithDetails } from '../src/modules/registry/registry-testing.utils' +import { ROBOT_NAME_PROJECT, ROBOT_NAME_RO, ROBOT_NAME_RW } from '../src/modules/registry/registry.constants' +import { RegistryModule } from '../src/modules/registry/registry.module' +import { RegistryService } from '../src/modules/registry/registry.service' +import { getHostFromUrl, getProjectVaultPath } from '../src/modules/registry/registry.utils' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' +import { getAll } from '../src/utils/iterable.utils' + +const canRunRegistryE2E + = Boolean(process.env.E2E) + && Boolean(process.env.HARBOR_URL) + && Boolean(process.env.HARBOR_ADMIN) + && Boolean(process.env.HARBOR_ADMIN_PASSWORD) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithRegistry = describe.runIf(canRunRegistryE2E) + +describeWithRegistry('RegistryService (e2e)', () => { + let moduleRef: TestingModule + let registry: RegistryService + let client: RegistryClientService + let vault: VaultClientService + let config: BaseConfig + let harborConfig: ConfigType + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory, harborConfigFactory] }), RegistryModule], + }) + .compile() + + await moduleRef.init() + + registry = moduleRef.get(RegistryService) + client = moduleRef.get(RegistryClientService) + vault = moduleRef.get(VaultClientService) + config = moduleRef.get(baseConfigFactory) + harborConfig = moduleRef.get(harborConfigFactory) + + projectSlug = faker.helpers.slugify(`test-project-${faker.string.alphanumeric({ length: 10 }).toLowerCase()}`).slice(0, 50) + }) + + afterAll(async () => { + if (vault && config && projectSlug) { + const paths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectsRootDir, `REGISTRY/${name}`)) + await Promise.all(paths.map(path => vault.delete(path).catch(() => {}))) + } + + if (registry && projectSlug) { + await registry.deleteProject(projectSlug).catch(() => {}) + } + + await moduleRef?.close() + }) + + it('should provision project in Harbor and write robot secrets to Vault', async () => { + const result = await registry.ensureProject({ slug: projectSlug, plugins: [] }, { publishProjectRobot: true }) + expect(result.basePath).toBe(`${getHostFromUrl(harborConfig.url)}/${projectSlug}/`) + + const project = await client.getProjectByName(projectSlug) + expect(project.status).toBe(200) + + const robots = await getAll(client.getProjectRobots(Number(project.data?.project_id))) + const robotNames = robots.flatMap(r => r.name ? [r.name] : []) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RO}`) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RW}`) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) + + const vaultPaths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectsRootDir, `REGISTRY/${name}`)) + const [roSecret, rwSecret, projectSecret] = await Promise.all(vaultPaths.map(path => vault.read(path))) + expect(roSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(rwSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(projectSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(roSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RO}`) + expect(rwSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RW}`) + expect(projectSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) + }) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/test/sonarqube.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/sonarqube.e2e-spec.ts new file mode 100644 index 0000000000..b8037965ef --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/sonarqube.e2e-spec.ts @@ -0,0 +1,183 @@ +import type { TestingModule } from '@nestjs/testing' +import { generateProjectKey } from '@cpn-console/hooks' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { SonarqubeClientService } from '../src/modules/sonarqube/sonarqube-client.service' +import { projectSelect } from '../src/modules/sonarqube/sonarqube-datastore.service' +import { makeProjectWithDetails } from '../src/modules/sonarqube/sonarqube-testing.utils' +import { SonarqubeModule } from '../src/modules/sonarqube/sonarqube.module' +import { SonarqubeService } from '../src/modules/sonarqube/sonarqube.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunSonarqubeE2E + = Boolean(process.env.E2E) + && Boolean(process.env.SONARQUBE_URL) + && Boolean(process.env.SONAR_API_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithSonarqube = describe.runIf(canRunSonarqubeE2E) + +describeWithSonarqube('SonarqubeService (e2e)', () => { + let moduleRef: TestingModule + let sonarqubeService: SonarqubeService + let sonarqubeClient: SonarqubeClientService + let vaultService: VaultClientService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + let testRepoName: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [SonarqubeModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + sonarqubeService = moduleRef.get(SonarqubeService) + sonarqubeClient = moduleRef.get(SonarqubeClientService) + vaultService = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-sonar-${faker.string.alphanumeric(8).toLowerCase()}`) + testRepoName = faker.helpers.slugify(`test-sonar-${faker.string.alphanumeric(8).toLowerCase()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (sonarqubeService && testProjectSlug) { + await sonarqubeService.handleDelete( + makeProjectWithDetails({ slug: testProjectSlug, repositories: [] }), + ).catch(() => {}) + } + + if (prisma) { + await prisma.repository.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should create platform groups during initialization', async () => { + // init() is triggered by moduleRef.init() via onModuleInit — groups must already exist + const [adminResult, readonlyResult, securityResult] = await Promise.all([ + sonarqubeClient.searchUserGroup({ q: '/console/admin' }), + sonarqubeClient.searchUserGroup({ q: '/console/readonly' }), + sonarqubeClient.searchUserGroup({ q: '/console/security' }), + ]) + + expect(adminResult.groups.some(g => g.name === '/console/admin')).toBe(true) + expect(readonlyResult.groups.some(g => g.name === '/console/readonly')).toBe(true) + expect(securityResult.groups.some(g => g.name === '/console/security')).toBe(true) + }) + + it('should reconcile project in SonarQube (groups, user, repository, vault secret)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E SonarQube Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + await prisma.repository.create({ + data: { + projectId: testProjectId, + internalRepoName: testRepoName, + isPrivate: false, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await sonarqubeService.handleUpsert(project) + + // All 5 project role groups should exist in SonarQube + const projectGroupNames = [ + `/${testProjectSlug}/console/admin`, + `/${testProjectSlug}/console/devops`, + `/${testProjectSlug}/console/developer`, + `/${testProjectSlug}/console/security`, + `/${testProjectSlug}/console/readonly`, + ] + for (const groupName of projectGroupNames) { + const result = await sonarqubeClient.searchUserGroup({ q: groupName }) + expect(result.groups.some(g => g.name === groupName), `group ${groupName} should exist`).toBe(true) + } + + // Robot/CI user should exist + const usersResult = await sonarqubeClient.searchUsers({ q: testProjectSlug }) + expect(usersResult.users.some(u => u.login === testProjectSlug)).toBe(true) + + // SonarQube analysis project for the repository should exist + const projectKey = generateProjectKey(testProjectSlug, testRepoName) + const projectsResult = await sonarqubeClient.searchProject({ q: testProjectSlug }) + expect(projectsResult.components.some(p => p.key === projectKey)).toBe(true) + + // Vault credentials should be written with correct username and token + const vaultSecret = await vaultService.readSonarqubeUser(testProjectSlug) + expect(vaultSecret?.data?.SONAR_USERNAME).toBe(testProjectSlug) + expect(vaultSecret?.data?.SONAR_TOKEN).toBeTruthy() + expect(vaultSecret?.data?.SONAR_PASSWORD).toBeTruthy() + }, 30000) + + it('should delete the project from SonarQube and remove vault credentials', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await sonarqubeService.handleDelete(project) + + // SonarQube analysis project should be removed + const projectKey = generateProjectKey(testProjectSlug, testRepoName) + const projectsResult = await sonarqubeClient.searchProject({ q: testProjectSlug }) + expect(projectsResult.components.some(p => p.key === projectKey)).toBe(false) + + // Vault credentials should be removed + const vaultSecret = await vaultService.readSonarqubeUser(testProjectSlug) + expect(vaultSecret).toBeNull() + }, 30000) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/test/vault.e2e-spec.ts b/.jjconflict-side-1/apps/server-nestjs/test/vault.e2e-spec.ts new file mode 100644 index 0000000000..6947cb9b53 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/test/vault.e2e-spec.ts @@ -0,0 +1,108 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { projectSelect } from '../src/modules/vault/vault-datastore.service' +import { makeProjectWithDetails } from '../src/modules/vault/vault-testing.utils' +import { VaultModule } from '../src/modules/vault/vault.module' +import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunVaultE2E + = Boolean(process.env.E2E) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithVault = describe.runIf(canRunVaultE2E) + +describeWithVault('VaultController (e2e)', () => { + let moduleRef: TestingModule + let vaultController: VaultService + let vaultClient: VaultClientService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + vaultController = moduleRef.get(VaultService) + vaultClient = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (testProjectSlug) { + await vaultController.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('should reconcile project in Vault (mount, group, role)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await vaultController.handleUpsert(project) + + const group = await vaultClient.getIdentityGroupName(testProjectSlug) + expect(group.data?.id).toBeTruthy() + expect(group.data?.name).toBe(testProjectSlug) + expect(group.data?.alias?.name).toBe(`/${testProjectSlug}`) + }, 180000) +}) diff --git a/.jjconflict-side-1/apps/server-nestjs/tsconfig.build.json b/.jjconflict-side-1/apps/server-nestjs/tsconfig.build.json new file mode 100644 index 0000000000..64f86c6bd2 --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/.jjconflict-side-1/apps/server-nestjs/tsconfig.json b/.jjconflict-side-1/apps/server-nestjs/tsconfig.json new file mode 100644 index 0000000000..48b6403d6f --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "incremental": true, + "target": "ES2023", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "baseUrl": "./", + "module": "nodenext", + "moduleResolution": "nodenext", + "resolvePackageJsonExports": true, + "strictBindCallApply": false, + "strictNullChecks": true, + "noFallthroughCasesInSwitch": false, + "noImplicitAny": false, + "declaration": true, + "outDir": "./dist", + "removeComments": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + } +} diff --git a/.jjconflict-side-1/apps/server-nestjs/vitest.config.ts b/.jjconflict-side-1/apps/server-nestjs/vitest.config.ts new file mode 100644 index 0000000000..b0337f6d0b --- /dev/null +++ b/.jjconflict-side-1/apps/server-nestjs/vitest.config.ts @@ -0,0 +1,22 @@ +import path from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: [ + { find: '@cpn-console/logger/hooks', replacement: path.resolve(__dirname, '../../packages/logger/src/hooks.ts') }, + { find: '@cpn-console/logger', replacement: path.resolve(__dirname, '../../packages/logger/src/index.ts') }, + { find: '@cpn-console/shared', replacement: path.resolve(__dirname, '../../packages/shared/src/index.ts') }, + { find: '@cpn-console/hooks', replacement: path.resolve(__dirname, '../../packages/hooks/src/index.ts') }, + ], + }, + test: { + globals: true, + environment: 'node', + include: ['src/**/*.spec.ts', 'test/**/*.e2e-spec.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}) diff --git a/.jjconflict-side-1/apps/server/.env-example b/.jjconflict-side-1/apps/server/.env-example new file mode 100644 index 0000000000..e26aaeea95 --- /dev/null +++ b/.jjconflict-side-1/apps/server/.env-example @@ -0,0 +1,46 @@ +# Niveau de log +LOG_LEVEL=debug +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 + +# Port d'écoute du serveur backend +SERVER_PORT=4001 +# URL de connexion à la base de données PostgreSQL (format Prisma) +DB_URL=postgresql://admin:admin@localhost:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +OPENCDS_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true + +# Dossier de stockage des plugins externes +EXTERNAL_PLUGINS_DIR_HOST_PATH=/home/stephane/projets/clients/cloud-pi/plugins +# URL du dashboard d'observabilité (géré par Grafana) +GRAFANA_URL= +# Version du chart "observability" à déployer pour les projets +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 +# Inutilisé ? +# SONARQUBE_INTERNAL_URL= diff --git a/.jjconflict-side-1/apps/server/.env.docker-example b/.jjconflict-side-1/apps/server/.env.docker-example new file mode 100644 index 0000000000..571be2ff45 --- /dev/null +++ b/.jjconflict-side-1/apps/server/.env.docker-example @@ -0,0 +1,31 @@ +# Niveau de log +LOG_LEVEL=debug +# Indique que l'application tourne dans un conteneur Docker +DOCKER=true +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine du Keycloak (nom du service Docker Compose + port interne) +KEYCLOAK_DOMAIN=keycloak:8080 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http en Docker local) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 + +# Port d'écoute du serveur dans le réseau Docker +SERVER_PORT=8080 +# URL de connexion PostgreSQL (utilise le nom de service Docker 'postgres') +DB_URL=postgresql://admin:admin@postgres:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-side-1/apps/server/.env.integ-example b/.jjconflict-side-1/apps/server/.env.integ-example new file mode 100644 index 0000000000..1bcb5f1565 --- /dev/null +++ b/.jjconflict-side-1/apps/server/.env.integ-example @@ -0,0 +1,108 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Désactive l'initialisation des données de démo (l'intégration a ses propres données) +DEV_SETUP="false" +# Active le mode intégration (charge ce fichier en surcharge) +INTEGRATION=true + +# --- Keycloak --- +# Protocole (généralement https en intégration) +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= +# Secret du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_SECRET= +# Domaine du Keycloak d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak d'intégration +KEYCLOAK_REALM= + +# --- ArgoCD --- +# Namespace Kubernetes dans lequel ArgoCD est déployé +ARGO_NAMESPACE= +# URL de l'API ArgoCD +ARGOCD_URL= +# URL interne de l'API ArgoCD +ARGOCD_INTERNAL_URL= + +# --- GitLab --- +# Token d'accès personnel GitLab (scope api) +GITLAB_TOKEN= +# URL de l'instance GitLab +GITLAB_URL= +# URL interne de l'API Gitlab +GITLAB_INTERNAL_URL= + +# --- Harbor --- +# Nom d'utilisateur admin Harbor +HARBOR_ADMIN= +# Mot de passe admin Harbor +HARBOR_ADMIN_PASSWORD= +# URL de l'instance Harbor +HARBOR_URL= +# URL interne de l'API Harbor +HARBOR_INTERNAL_URL= + +# --- Keycloak (admin) --- +# Utilisateur admin Keycloak pour la gestion des royaumes/clients +KEYCLOAK_ADMIN= +# Mot de passe admin Keycloak +KEYCLOAK_ADMIN_PASSWORD= +# URL complète de Keycloak (peut différer de KEYCLOAK_DOMAIN) +KEYCLOAK_URL= + +# --- Nexus --- +# Nom d'utilisateur admin Nexus +NEXUS_ADMIN= +# Mot de passe admin Nexus +NEXUS_ADMIN_PASSWORD= +# URL de l'instance Nexus +NEXUS_URL= +# URL interne de l'API Nexus +NEXUS_INTERNAL_URL= + +# --- Divers --- +# Répertoire racine des projets dans Gitlab +PROJECTS_ROOT_DIR= + +# --- SonarQube --- +# Token d'API SonarQube +SONAR_API_TOKEN= +# URL de l'instance SonarQube +SONARQUBE_URL= +# URL interne de l'API SonarQube +SONARQUBE_INTERNAL_URL= + +# --- Vault --- +# Token d'authentification Vault +VAULT_TOKEN= +# URL de l'instance Vault +VAULT_URL= +# URL interne de l'API Vault +VAULT_INTERNAL_URL= + +# --- Kubeconfig --- +# Chemin vers le kubeconfig sur la machine hôte (monté en volume dans Docker) +KUBECONFIG_HOST_PATH= +# Chemin vers le kubeconfig à l'intérieur du conteneur +KUBECONFIG_PATH=$HOME/.kube/config +# Contexte Kubernetes à utiliser +KUBECONFIG_CTX= + +# Répertoire contenant les plugins externes (monté en volume dans Docker) +EXTERNAL_PLUGINS_DIR_HOST_PATH=/path/to/plugins + +# Informations pour le plugin Observabilité +GRAFANA_URL= +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 + +# --- Configuration proxy (si nécessaire pour accéder aux services externes) --- +# HTTP_PROXY= +# HTTPS_PROXY= +# NO_PROXY= + +# Configuration OpenCDS +OPENCDS_URL= +OPENCDS_API_TOKEN=token +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false diff --git a/.jjconflict-side-1/apps/server/Dockerfile b/.jjconflict-side-1/apps/server/Dockerfile new file mode 100644 index 0000000000..47ca58e741 --- /dev/null +++ b/.jjconflict-side-1/apps/server/Dockerfile @@ -0,0 +1,66 @@ +# Base stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Install pnpm version defined in package.json "packageManager" property +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY --chown=node:root apps/server/package.json ./apps/server/package.json + +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +COPY --chown=node:root plugins/argocd/package.json ./plugins/argocd/package.json +COPY --chown=node:root plugins/gitlab/package.json ./plugins/gitlab/package.json +COPY --chown=node:root plugins/harbor/package.json ./plugins/harbor/package.json +COPY --chown=node:root plugins/keycloak/package.json ./plugins/keycloak/package.json +COPY --chown=node:root plugins/nexus/package.json ./plugins/nexus/package.json +COPY --chown=node:root plugins/sonarqube/package.json ./plugins/sonarqube/package.json +COPY --chown=node:root plugins/vault/package.json ./plugins/vault/package.json + +RUN pnpm install --frozen-lockfile +COPY --chown=node:root plugins/ ./plugins/ +COPY --chown=node:root packages/ ./packages/ + +COPY --chown=node:root apps/server/ ./apps/server/ +# Generate Prisma client +RUN pnpm --filter server run db:generate +ENTRYPOINT [ "pnpm", "--filter", "server", "run" ] +CMD [ "dev" ] + + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +# Build @cpn-console/server console-related dependencies +RUN pnpm run build +# Build @cpn-console/server +RUN pnpm --filter @cpn-console/server run build +# Export @cpn-console/server to target build directory +RUN pnpm --filter @cpn-console/server --prod deploy build + + +# Prod stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS prod + +ARG APP_VERSION +ENV APP_VERSION=$APP_VERSION + +VOLUME [ "/plugins" ] +WORKDIR /app +RUN mkdir -p /home/node/logs && chmod 770 -R /home/node/logs \ + && mkdir -p /home/node/.npm && chmod 770 -R /home/node/.npm \ + && chown node:root /app +COPY --chown=node:root --from=build /app/build . +RUN npm run db:generate +USER node +EXPOSE 8080 +ENTRYPOINT ["npm", "start"] diff --git a/.jjconflict-side-1/apps/server/README.md b/.jjconflict-side-1/apps/server/README.md new file mode 100644 index 0000000000..37b89ac99b --- /dev/null +++ b/.jjconflict-side-1/apps/server/README.md @@ -0,0 +1,39 @@ +# Console Cloud π Native - Serveur + +## Installation + +```sh +npm install +``` + +## Lancement de l'app + +```sh +npm run dev +``` + +## Lancement des tests unitaires + +```sh +npm run test +``` + +## Formattage du code + +```sh +# Lister les problèmes de formatage +npm run lint + +# Régler automatiquement les problèmes de formatage +npm run format +``` + +## Lancement de l'app en mode production + +```sh +npm start +``` + +## Activation OpenCDS + +Se référer à [la documentation dédiée](../../packages/opencds/README.adoc) diff --git a/.jjconflict-side-1/apps/server/eslint.config.js b/.jjconflict-side-1/apps/server/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-1/apps/server/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-1/apps/server/nodemon.json b/.jjconflict-side-1/apps/server/nodemon.json new file mode 100644 index 0000000000..a83d0540d3 --- /dev/null +++ b/.jjconflict-side-1/apps/server/nodemon.json @@ -0,0 +1,4 @@ +{ + "watch": ["server.ts", "src/"], + "ext": "js, ts" +} diff --git a/.jjconflict-side-1/apps/server/package.json b/.jjconflict-side-1/apps/server/package.json new file mode 100644 index 0000000000..3096b8ea59 --- /dev/null +++ b/.jjconflict-side-1/apps/server/package.json @@ -0,0 +1,109 @@ +{ + "name": "@cpn-console/server", + "type": "module", + "version": "9.22.1", + "description": "cpn-console server", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/bootstrap.js", + "module": "dist/bootstrap.js", + "types": "types/bootstrap.d.ts", + "files": [ + "dist", + "prisma.config.ts", + "src" + ], + "scripts": { + "prebuild": "pnpm i --frozen-lockfile", + "build": "tspc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "db:deploy": "prisma migrate deploy", + "db:diff": "prisma migrate diff", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev --name dso", + "db:reset": "prisma migrate reset", + "predev": "pnpm run db:deploy", + "dev": "nodemon --exec 'vite-node src/bootstrap.ts'", + "predebug": "pnpm run db:migrate", + "debug": "nodemon --exec 'NODE_OPTIONS=\"--inspect-brk=0.0.0.0\" vite-node src/bootstrap.ts'", + "format": "eslint ./ --fix", + "preinteg": "INTEGRATION=true pnpm run db:migrate", + "integ": "INTEGRATION=true nodemon --exec 'vite-node src/bootstrap.ts'", + "lint": "eslint ./", + "prepublishOnly": "pnpm run db:generate", + "prestart": "npm run db:deploy", + "start": "node --enable-source-maps dist/bootstrap.js", + "pretest": "pnpm run db:generate", + "test": "vitest run", + "pretest:cov": "pnpm run db:generate", + "test:cov": "vitest run --coverage", + "test:e2e": "pnpm run dev", + "test:e2e-ci": "pnpm run start" + }, + "dependencies": { + "@cpn-console/argocd-plugin": "workspace:^", + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/harbor-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/nexus-plugin": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/sonarqube-plugin": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@fastify/cookie": "catalog:runtime", + "@fastify/helmet": "catalog:runtime", + "@fastify/otel": "catalog:runtime", + "@fastify/session": "catalog:runtime", + "@fastify/swagger": "catalog:runtime", + "@fastify/swagger-ui": "catalog:runtime", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "@kubernetes-models/argo-cd": "catalog:runtime", + "@opentelemetry/auto-instrumentations-node": "catalog:otel", + "@opentelemetry/exporter-metrics-otlp-proto": "catalog:otel", + "@opentelemetry/exporter-trace-otlp-proto": "catalog:otel", + "@opentelemetry/instrumentation-nestjs-core": "catalog:otel", + "@opentelemetry/instrumentation-pino": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-node": "catalog:otel", + "@prisma/client": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@ts-rest/fastify": "catalog:runtime", + "@ts-rest/open-api": "catalog:runtime", + "axios": "catalog:runtime", + "date-fns": "catalog:runtime", + "fastify": "catalog:runtime", + "fastify-keycloak-adapter": "catalog:runtime", + "json-2-csv": "catalog:runtime", + "mustache": "catalog:runtime", + "prisma": "catalog:runtime", + "undici": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "fastify-plugin": "catalog:build", + "nodemon": "catalog:tools", + "pino-pretty": "catalog:tools", + "rimraf": "catalog:tools", + "ts-patch": "catalog:build", + "typescript": "catalog:build", + "typescript-transform-paths": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/apps/server/prisma.config.ts b/.jjconflict-side-1/apps/server/prisma.config.ts new file mode 100644 index 0000000000..ff5aaf9186 --- /dev/null +++ b/.jjconflict-side-1/apps/server/prisma.config.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' +import { defineConfig } from 'prisma/config' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +export default defineConfig({ + schema: path.join('src', 'prisma', 'schema'), + migrations: { + path: path.join('src', 'prisma', 'migrations'), + }, +}) diff --git a/.jjconflict-side-1/apps/server/src/__mocks__/prisma.ts b/.jjconflict-side-1/apps/server/src/__mocks__/prisma.ts new file mode 100644 index 0000000000..075578c96c --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/__mocks__/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma.js') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-side-1/apps/server/src/__mocks__/utils/hook-wrapper.ts b/.jjconflict-side-1/apps/server/src/__mocks__/utils/hook-wrapper.ts new file mode 100644 index 0000000000..f7fdd811a2 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/__mocks__/utils/hook-wrapper.ts @@ -0,0 +1,42 @@ +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../utils/hook-wrapper.ts') + +export const hook = { + adminRole: { + delete: vi.fn(), + upsert: vi.fn(), + }, + cluster: { + delete: vi.fn(), + upsert: vi.fn(), + }, + misc: { + checkServices: vi.fn(), + syncRepository: vi.fn(), + }, + project: { + upsert: vi.fn(), + delete: vi.fn(), + getSecrets: vi.fn(), + }, + projectRole: { + upsert: vi.fn(), + delete: vi.fn(), + }, + user: { + retrieveUserByEmail: vi.fn(), + }, + zone: { + delete: vi.fn(), + upsert: vi.fn(), + }, +} as const + +const hookMock = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(hookMock) +}) diff --git a/.jjconflict-side-1/apps/server/src/app.spec.ts b/.jjconflict-side-1/apps/server/src/app.spec.ts new file mode 100644 index 0000000000..e540174ed4 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/app.spec.ts @@ -0,0 +1,21 @@ +import { apiPrefix } from '@cpn-console/shared' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import app from './app.js' +import { getRandomRequestor, setRequestor } from './utils/mocks.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) + +describe('app', () => { + beforeEach(() => { + setRequestor(getRandomRequestor()) + }) + afterAll(async () => { + await app.close() + }) + + it('should respond 404 on unknown route', async () => { + const response = await app.inject() + .get(`${apiPrefix}/miss`) + expect(response.statusCode).toBe(404) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/app.ts b/.jjconflict-side-1/apps/server/src/app.ts new file mode 100644 index 0000000000..996db320fa --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/app.ts @@ -0,0 +1,63 @@ +import type { FastifyRequest } from 'fastify' +import { apiPrefix, getContract } from '@cpn-console/shared' +import fastifyCookie from '@fastify/cookie' +import helmet from '@fastify/helmet' +import fastifySession from '@fastify/session' +import fastifySwagger from '@fastify/swagger' +import fastifySwaggerUi from '@fastify/swagger-ui' +import { initServer } from '@ts-rest/fastify' +import { generateOpenApi } from '@ts-rest/open-api' +import fastify from 'fastify' +import keycloak from 'fastify-keycloak-adapter' +import { apiRouter } from './resources/index.js' +import { isDev, isInt, isTest } from './utils/env.js' +import { fastifyConf, swaggerConf, swaggerUiConf } from './utils/fastify.js' +import { keycloakConf, sessionConf } from './utils/keycloak.js' + +export const serverInstance: ReturnType = initServer() + +const openApiDocument = generateOpenApi(await getContract(), swaggerConf, { + setOperationId: true, +}) + +const app = fastify(fastifyConf) + .register(helmet, () => ({ + contentSecurityPolicy: !(isInt || isDev || isTest), + })) + .register(fastifyCookie) + .register(fastifySession, sessionConf) + // @ts-ignore + .register(keycloak, keycloakConf) + .register(fastifySwagger, { transformObject: () => openApiDocument }) + .register(fastifySwaggerUi, swaggerUiConf) + .register(apiRouter()) + .addHook('onRoute', (opts) => { + if (opts.path === `${apiPrefix}/healthz`) { + opts.logLevel = 'silent' + } + }) + .setErrorHandler((err: Error, req: FastifyRequest, reply) => { + const statusCode = 500 + // @ts-ignore vérifier l'objet + const message = err.description || err.message + reply + .status(statusCode) + .send({ status: statusCode, error: message, stack: err.stack }) + req.log.error({ err, reqId: req.id }, 'Unhandled request error') + }) + .addHook('onResponse', (req, res) => { + const user = req.session?.user as { + id: string + } + if (res.statusCode < 400) { + req.log.info({ status: res.statusCode, userId: user?.id }) + } else if (res.statusCode < 500) { + req.log.warn({ status: res.statusCode, userId: user?.id }) + } else { + req.log.error({ status: res.statusCode, userId: user?.id }) + } + }) + +await app.ready() + +export default app diff --git a/.jjconflict-side-1/apps/server/src/bootstrap.ts b/.jjconflict-side-1/apps/server/src/bootstrap.ts new file mode 100644 index 0000000000..83c833f007 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/bootstrap.ts @@ -0,0 +1,3 @@ +import './runtime.js' +import './telemetry.js' +import './server.js' diff --git a/.jjconflict-side-1/apps/server/src/connect.spec.ts b/.jjconflict-side-1/apps/server/src/connect.spec.ts new file mode 100644 index 0000000000..363c89d290 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/connect.spec.ts @@ -0,0 +1,86 @@ +import { logger } from '@cpn-console/logger' +import { PrismaClientInitializationError } from '@prisma/client/runtime/library.js' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from './__mocks__/prisma.js' +import app from './app.js' +import { getConnection } from './connect.js' + +vi.mock( + 'fastify-keycloak-adapter', + (await import('./utils/mocks.js')).mockSessionPlugin, +) +vi.mock('@/resources/queries-index.js') +vi.mock('./models/log.js', () => getModel('getLogModel')) +vi.mock('./models/repository.js', () => getModel('getRepositoryModel')) +vi.mock('./models/permission.js', () => getModel('getPermissionModel')) +vi.mock('./models/environment.js', () => getModel('getEnvironmentModel')) +vi.mock('./models/project.js', () => getModel('getProjectModel')) +vi.mock('./models/user.js', () => getModel('getUserModel')) +vi.mock('./models/users-projects.js', () => getModel('getRolesModel')) +vi.mock('./models/zone.js', () => getModel('getZoneModel')) +vi.mock('./prisma.js') +vi.mock('./utils/env.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isTest: true, + } +}) + +vi.spyOn(app, 'listen') +const infoSpy = vi.spyOn(logger, 'info') +const errorSpy = vi.spyOn(logger, 'error') +const debugSpy = vi.spyOn(logger, 'debug') + +function getModel(modelName: string) { + return { + [modelName]: vi.fn(() => ({ + sync: vi.fn(), + hasMany: vi.fn(), + belongsTo: vi.fn(), + belongsToMany: vi.fn(), + })), + } +} + +describe('connect', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should connect to postgres', async () => { + await getConnection() + + expect(debugSpy.mock.calls).toHaveLength(1) + expect(debugSpy.mock.calls[0]?.[1]).toBe('Connecting to Postgres') + + expect(infoSpy.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toContainEqual(['Connected to Postgres!']) + }) + + it( + 'should fail to connect once, then connect to postgres', + { timeout: 10_000 }, + async () => { + const errorToCatch = new PrismaClientInitializationError( + 'Failed to connect', + '2.19.0', + 'P1001', + ) + + prisma.$connect.mockRejectedValueOnce(errorToCatch) + await getConnection() + + expect(errorSpy.mock.calls).toHaveLength(1) + expect(errorSpy.mock.calls[0]?.[1]).toBe('Could not connect to Postgres') + + expect(infoSpy.mock.calls).toHaveLength(2) + expect(infoSpy.mock.calls[0]?.[1]).toBe('Retrying Postgres connection') + expect(infoSpy.mock.calls).toContainEqual(['Connected to Postgres!']) + + expect(debugSpy.mock.calls).toHaveLength(2) + expect(debugSpy.mock.calls[0]?.[1]).toBe('Connecting to Postgres') + expect(debugSpy.mock.calls[1]?.[1]).toBe('Connecting to Postgres') + }, + ) +}) diff --git a/.jjconflict-side-1/apps/server/src/connect.ts b/.jjconflict-side-1/apps/server/src/connect.ts new file mode 100644 index 0000000000..31d608a66e --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/connect.ts @@ -0,0 +1,63 @@ +import { setTimeout } from 'node:timers/promises' +import { logger } from '@cpn-console/logger' +import prisma from './prisma.js' +import { + dbUrl, + isCI, + isDev, + isTest, +} from './utils/env.js' + +const DELAY_BEFORE_RETRY = isTest ? 0 : isCI ? 1000 : 10000 +let closingConnections = false +const leadingSlashRegExp = /^\// + +function parseDbUrl(url: string | undefined) { + if (!url) return undefined + try { + const parsed = new URL(url) + return { + host: parsed.host, + database: parsed.pathname?.replace(leadingSlashRegExp, '') || undefined, + } + } catch { + return undefined + } +} + +export async function getConnection(triesLeft = 5): Promise { + if (closingConnections || triesLeft <= 0) { + throw new Error('Unable to connect to Postgres server') + } + triesLeft-- + + try { + if (isDev || isTest || isCI) + logger.debug({ db: parseDbUrl(dbUrl) }, 'Connecting to Postgres') + await prisma.$connect() + + logger.info('Connected to Postgres!') + } catch (err) { + if (triesLeft > 0) { + logger.error({ err }, 'Could not connect to Postgres') + logger.info({ triesLeft }, 'Retrying Postgres connection') + await setTimeout(DELAY_BEFORE_RETRY) + return getConnection(triesLeft) + } + + logger.error({ err }, 'Out of retries connecting to Postgres') + err.message = `Out of retries connecting to Postgres, last error: ${err.message}` + throw err + } +} + +export async function closeConnections() { + closingConnections = true + try { + await prisma.$disconnect() + } catch (error) { + logger.error({ err: error }, 'Could not disconnect from Postgres') + } finally { + closingConnections = false + } +} diff --git a/.jjconflict-side-1/apps/server/src/init/db/dump.ts b/.jjconflict-side-1/apps/server/src/init/db/dump.ts new file mode 100644 index 0000000000..6709946605 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/init/db/dump.ts @@ -0,0 +1,28 @@ +// @ts-nocheck + +/** + * How to use ? + * npx vite-node src/init/db/dump.ts + * format ./data.ts with linter + * cut/paste to packages/test-utils/src/imports/data.ts + */ + +import { writeFileSync } from 'node:fs' +import { Prisma } from '@prisma/client' +import prisma from '@/prisma.js' +import { associations, manyToManyRelation, modelKeys, models, resourceListToDict } from './utils.js' + +const Models = resourceListToDict(Prisma.dmmf.datamodel.models) + +for (const modelKey of modelKeys) { + const modelDatas = await prisma[modelKey].findMany() + models[modelKey] = modelDatas +} +for (const [model, targetModel, relationKey] of manyToManyRelation) { + const modelKey = model.slice(0, 1).toLocaleLowerCase() + model.slice(1) + const modelDatas = await prisma[modelKey].findMany({ select: { [Models[model].id]: true, [relationKey]: { select: { [Models[targetModel].id]: true } } } }) + associations.push([modelKey, modelDatas]) +} +const a = JSON.stringify({ ...models, associations }, null, 2) + +writeFileSync('./data.ts', `export const data = ${a}`) diff --git a/.jjconflict-side-1/apps/server/src/init/db/index.ts b/.jjconflict-side-1/apps/server/src/init/db/index.ts new file mode 100644 index 0000000000..fe37b33281 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/init/db/index.ts @@ -0,0 +1,51 @@ +import { logger } from '@cpn-console/logger' +import prisma from '@/prisma.js' +import { modelKeys } from './utils.js' + +type ExtractKeysWithFields = { + [K in keyof T]: T[K] extends { fields: any } ? K : never +}[keyof T] + +type Models = ExtractKeysWithFields + +type Imports = Partial> & { + associations: [Models, any[]] +} + +export async function initDb(data: Imports) { + const dataStringified = JSON.stringify(data) + const dataParsed = JSON.parse(dataStringified, (key, value) => { + try { + if (['permissions', 'everyonePerms'].includes(key)) { + return BigInt(value.slice(0, value.length - 1)) + } + } catch { + return value + } + return value + }) + logger.info('Drop tables') + for (const modelKey of modelKeys.toReversed()) { + // @ts-ignore + await prisma[modelKey].deleteMany() + } + logger.info('Import models') + for (const modelKey of modelKeys) { + // @ts-ignore + await prisma[modelKey].createMany({ data: dataParsed[modelKey] }) + } + logger.info('Import associations') + for (const [modelKey, rows] of dataParsed.associations) { + for (const row of rows) { + const idKey = 'id' + const connectKeys = Object.keys(row).filter(key => key !== idKey) + const dataConnects = connectKeys.reduce((acc, curr) => { + acc[curr] = { connect: row[curr] } + return acc + }, {} as Record) + // @ts-ignore + await prisma[modelKey].update({ where: { id: row.id }, data: dataConnects }) + } + } + logger.info('End import') +} diff --git a/.jjconflict-side-1/apps/server/src/init/db/utils.spec.ts b/.jjconflict-side-1/apps/server/src/init/db/utils.spec.ts new file mode 100644 index 0000000000..9b831ee089 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/init/db/utils.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { modelKeys, moveBefore, resourceListToDict } from './utils.js' + +vi.mock('fs', () => ({ writeFileSync: vi.fn() })) +for (const modelKey of modelKeys) { + prisma[modelKey].findMany.mockResolvedValue([]) +} + +describe('test moveBefore', () => { + it('should be moved', () => { + const arr = ['a', 'b', 'c'] + const arrSorted = moveBefore(arr, 'c', 'b') + expect(arrSorted).toEqual(['a', 'c', 'b']) + + const arrSorted2 = moveBefore(arr, 'c', 'a') + expect(arrSorted2).toEqual(['c', 'a', 'b']) + }) + it('should not be moved', () => { + const arr = ['a', 'b', 'c'] + const arrSorted = moveBefore(arr, 'b', 'c') + expect(arrSorted).toEqual(false) + + const arrSorted2 = moveBefore(arr, 'a', 'c') + expect(arrSorted2).toEqual(false) + + const arrSorted3 = moveBefore(arr, 'c', 'c') + expect(arrSorted3).toEqual(false) + }) +}) + +it('test resourceListToDict (by name)', () => { + const list = [ + { name: 'a', value: 1 }, + { name: 'b', value: 2 }, + { name: 'c', value: 3 }, + ] + const dict = resourceListToDict(list) + expect(dict).toEqual({ + a: { name: 'a', value: 1 }, + b: { name: 'b', value: 2 }, + c: { name: 'c', value: 3 }, + }) +}) + +it('stringify bigint', () => { + const list = { name: 'a', value: 1n } + + const dict = JSON.stringify(list) + + expect(dict).toEqual('{"name":"a","value":"1n"}') +}) diff --git a/.jjconflict-side-1/apps/server/src/init/db/utils.ts b/.jjconflict-side-1/apps/server/src/init/db/utils.ts new file mode 100644 index 0000000000..e4b941089f --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/init/db/utils.ts @@ -0,0 +1,85 @@ +// @ts-nocheck +import { Prisma } from '@prisma/client' + +// eslint-disable-next-line no-extend-native +BigInt.prototype.toJSON = function () { + return `${this.toString()}n` +} + +export type ResourceByName = Record +export function resourceListToDict(resList: Array): ResourceByName { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.name]: curr, + } + }, {} as ResourceByName) +} + +// @ts-ignore +const Models = resourceListToDict(Prisma.dmmf.datamodel.models) +let ModelsNames = Object.keys(Models) +let ModelsOrder = [...ModelsNames] + +export function moveBefore(arr: T, toMove: T[number], ref: T[number]): T | false { + const iref = arr.indexOf(ref) + const moveref = arr.indexOf(toMove) + if (moveref <= iref) return false + return [ + ...arr.slice(0, iref), + arr[moveref], + ...arr.slice(iref, moveref), + ...arr.slice(moveref + 1), + ] as T +} + +export const manyToManyRelation: [string, string, string][] = [] +function sort() { + let hasChanged = false + for (const model of ModelsNames) { + for (const field of Models[model].fields) { + if (field.isId) Models[model].id = field.name + if (field.type in Models) { + const relationField = Models[field.type].fields.find(({ type }) => type === model) + if (!relationField) throw new Error('unable to find matching model') + if ( + (relationField.isRequired && field.isRequired && !relationField.isList) + || (relationField.isRequired && !field.isRequired) + ) { + const moveRes = moveBefore(ModelsOrder, model, field.type) + if (moveRes) { + hasChanged = true + ModelsOrder = moveRes + } + } + if ( + field.isList && relationField.isList + && !manyToManyRelation.some(test => + (test[0] === model && test[1] === field.type) || (test[0] === field.type && test[1] === model)) + ) { + manyToManyRelation.push([model, field.type, field.name]) + } + } + } + } + ModelsNames = ModelsOrder + if (hasChanged) sort() +} + +sort() + +// special case to study +const logUserCase = moveBefore(ModelsOrder, 'User', 'Log') +if (logUserCase) { + ModelsOrder = logUserCase +} +const logProjectCase = moveBefore(ModelsOrder, 'Project', 'Log') +if (logProjectCase) { + ModelsOrder = logProjectCase +} + +export const models: Record = {} +export const associations: Record = [] +export const modelKeys = ModelsOrder.map(model => model.slice(0, 1).toLocaleLowerCase() + model.slice(1)) diff --git a/.jjconflict-side-1/apps/server/src/mocks/prisma.ts b/.jjconflict-side-1/apps/server/src/mocks/prisma.ts new file mode 100644 index 0000000000..075578c96c --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/mocks/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma.js') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-side-1/apps/server/src/mocks/utils.ts b/.jjconflict-side-1/apps/server/src/mocks/utils.ts new file mode 100644 index 0000000000..c57dd0f52b --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/mocks/utils.ts @@ -0,0 +1,24 @@ +import type { User } from '@cpn-console/test-utils' +import fp from 'fastify-plugin' + +let requestor: User + +export function setRequestor(user: User) { + requestor = user +} + +export function getRequestor() { + return requestor +} + +export async function mockSessionPlugin() { + const sessionPlugin = (app, opt, next) => { + app.addHook('onRequest', (req, res, next) => { + req.session = { user: getRequestor() } + next() + }) + next() + } + + return { default: fp(sessionPlugin) } +} diff --git a/.jjconflict-side-1/apps/server/src/plugins.ts b/.jjconflict-side-1/apps/server/src/plugins.ts new file mode 100644 index 0000000000..fcb54775f2 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/plugins.ts @@ -0,0 +1,47 @@ +import type { Plugin } from '@cpn-console/hooks' +import { readdirSync, statSync } from 'node:fs' +import { plugin as argo } from '@cpn-console/argocd-plugin' +import { plugin as gitlab } from '@cpn-console/gitlab-plugin' +import { plugin as harbor } from '@cpn-console/harbor-plugin' +import { pluginManager } from '@cpn-console/hooks' +import { plugin as keycloak } from '@cpn-console/keycloak-plugin' +import { logger as baseLogger } from '@cpn-console/logger' +import { plugin as nexus } from '@cpn-console/nexus-plugin' +import { plugin as sonarqube } from '@cpn-console/sonarqube-plugin' +import { plugin as vault } from '@cpn-console/vault-plugin' +import { pluginsDir } from './utils/env.js' +import { pluginManagerOptions } from './utils/plugins.js' + +const logger = baseLogger.child({ scope: 'plugin-manager' }) + +export async function initPm() { + const pm = pluginManager(pluginManagerOptions) + pm.register(argo) + pm.register(gitlab) + pm.register(harbor) + pm.register(keycloak) + pm.register(nexus) + pm.register(sonarqube) + pm.register(vault) + + if (!statSync(pluginsDir, { + throwIfNoEntry: false, + })) { + return pm + } + for (const dirName of readdirSync(pluginsDir)) { + const moduleAbsPath = `${pluginsDir}/${dirName}` + try { + statSync(`${moduleAbsPath}/package.json`) + const pkg = await import(`${moduleAbsPath}/package.json`, { with: { type: 'json' } }) + const entrypoint = pkg.default.module || pkg.default.main + if (!entrypoint) throw new Error(`No entrypoint found in package.json : ${pkg.default.name}`) + const { plugin } = await import(`${moduleAbsPath}/${entrypoint}`) as { plugin: Plugin } + pm.register(plugin) + } catch (error) { + logger.error({ err: error, moduleAbsPath }, 'Could not import external plugin module') + } + } + + return pm +} diff --git a/.jjconflict-side-1/apps/server/src/prepare-app.spec.ts b/.jjconflict-side-1/apps/server/src/prepare-app.spec.ts new file mode 100644 index 0000000000..5086faa776 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prepare-app.spec.ts @@ -0,0 +1,70 @@ +import { logger } from '@cpn-console/logger' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from './app.js' +import { getConnection } from './connect.js' +import { initDb } from './init/db/index.js' +import { getPreparedApp } from './prepare-app.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) +vi.mock('./connect.js') +vi.mock('./index.js') +vi.mock('./init/db/index.js', () => ({ initDb: vi.fn() })) + +vi.spyOn(app, 'listen') +vi.spyOn(logger, 'warn') +vi.spyOn(logger, 'error') +vi.spyOn(logger, 'debug') + +describe('server', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should getConnection', async () => { + await getPreparedApp() + + expect(getConnection).toHaveBeenCalledTimes(1) + expect(initDb.mock.calls).toHaveLength(1) + }) + + it('should throw an error on connection to DB', async () => { + const error = new Error('This is OK!') + getConnection.mockRejectedValueOnce(error) + + let response + await getPreparedApp() + .catch((err) => { response = err }) + + expect(getConnection.mock.calls).toHaveLength(1) + expect(app.listen.mock.calls).toHaveLength(0) + expect(response).toMatchObject(error) + }) + + it('should throw an error on initDb import if module is not found', async () => { + const infoSpy = vi.spyOn(logger, 'info') + const error = new Error('Failed to load') + initDb.mockRejectedValueOnce(error) + + await getPreparedApp() + + expect(initDb.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toHaveLength(3) + }) + + it('should throw an error on initDb import', async () => { + const infoSpy = vi.spyOn(logger, 'info') + const error = new Error('This is OK!') + initDb.mockRejectedValueOnce(error) + + let response + try { + await getPreparedApp() + } catch (err) { + response = err + } + + expect(initDb.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toHaveLength(2) + expect(response).toMatchObject(error) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/prepare-app.ts b/.jjconflict-side-1/apps/server/src/prepare-app.ts new file mode 100644 index 0000000000..000734714b --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prepare-app.ts @@ -0,0 +1,96 @@ +import { rm } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { logger } from '@cpn-console/logger' +import app from './app.js' +import { getConnection } from './connect.js' +import { initDb } from './init/db/index.js' +import { initPm } from './plugins.js' +import { isCI, isDev, isDevSetup, isInt, isProd, isTest, port } from './utils/env.js' + +async function initializeDB(path: string) { + logger.info('Starting init DB...') + const { data } = await import(path) + await initDb(data) + logger.info('initDb invoked successfully') +} + +export async function startServer(defaultPort: number = (port ? +port : 8080)) { + try { + await getConnection() + } catch (error) { + logger.error({ err: error }, 'Database connection failed') + if (!(error instanceof Error)) return + throw error + } + + initPm() + + logger.info('Reading init database file') + + try { + const dataPath = (isProd || isInt) + ? './init/db/imports/data.js' + : '@cpn-console/test-utils/src/imports/data.ts' + await initializeDB(dataPath) + if (isProd && !isDevSetup) { + logger.info('Cleaning up imported data file...') + const __filename = fileURLToPath(import.meta.url) + const __dirname = dirname(__filename) + await rm(resolve(__dirname, dataPath)) + logger.info(`Successfully deleted '${dataPath}'`) + } + } catch (err) { + if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.message?.includes('Failed to load') || err?.message?.includes('Cannot find module')) { + logger.info('No initDb file, skipping') + } else { + logger.warn({ err }, 'Init DB failed') + throw err + } + } + + try { + await app.listen({ host: '0.0.0.0', port: defaultPort ?? 8080 }) + } catch (error) { + logger.error({ err: error }, 'Failed to start HTTP server') + process.exit(1) + } + logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) +} + +export async function getPreparedApp() { + try { + await getConnection() + } catch (error) { + logger.error({ err: error }, 'Database connection failed') + throw error + } + + initPm() + + logger.info('Reading init database file') + + try { + const dataPath = (isProd || isInt) + ? './init/db/imports/data.js' + : '@cpn-console/test-utils/src/imports/data.ts' + await initializeDB(dataPath) + if (isProd && !isDevSetup) { + logger.info('Cleaning up imported data file...') + const __filename = fileURLToPath(import.meta.url) + const __dirname = dirname(__filename) + await rm(resolve(__dirname, dataPath)) + logger.info(`Successfully deleted '${dataPath}'`) + } + } catch (err) { + if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.message?.includes('Failed to load') || err?.message?.includes('Cannot find module')) { + logger.info('No initDb file, skipping') + } else { + logger.warn({ err }, 'Init DB failed') + throw err + } + } + + logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) + return app +} diff --git a/.jjconflict-side-1/apps/server/src/prisma.ts b/.jjconflict-side-1/apps/server/src/prisma.ts new file mode 100644 index 0000000000..4590932b68 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +export default prisma diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql new file mode 100644 index 0000000000..f2f4e7b0b7 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql @@ -0,0 +1,151 @@ +-- CreateTable +CREATE TABLE "Environment" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Environment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Log" ( + "id" UUID NOT NULL, + "data" JSONB NOT NULL, + "action" TEXT NOT NULL DEFAULT '', + "userId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Log_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Organization" ( + "id" UUID NOT NULL, + "source" TEXT NOT NULL, + "name" TEXT NOT NULL, + "label" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Permission" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "environmentId" UUID NOT NULL, + "level" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Permission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "organizationId" UUID NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL, + "locked" BOOLEAN NOT NULL DEFAULT false, + "services" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Repository" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "internalRepoName" TEXT NOT NULL, + "externalRepoUrl" TEXT NOT NULL, + "externalUserName" TEXT, + "externalToken" TEXT, + "isInfra" BOOLEAN NOT NULL DEFAULT false, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Repository_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" UUID NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Role" ( + "userId" UUID NOT NULL, + "projectId" UUID NOT NULL, + "role" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Role_pkey" PRIMARY KEY ("userId","projectId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_id_key" ON "Organization"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_name_key" ON "Organization"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_label_key" ON "Organization"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_id_key" ON "Permission"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_userId_environmentId_key" ON "Permission"("userId", "environmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_id_key" ON "Project"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Role_userId_projectId_key" ON "Role"("userId", "projectId"); + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql new file mode 100644 index 0000000000..26e1ade3f2 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql @@ -0,0 +1,85 @@ +-- CreateEnum +CREATE TYPE "ClusterPrivacy" AS ENUM ('public', 'dedicated'); + +-- CreateTable +CREATE TABLE "Cluster" ( + "id" UUID NOT NULL, + "label" VARCHAR(50) NOT NULL, + "privacy" "ClusterPrivacy" NOT NULL DEFAULT 'dedicated', + "secretName" VARCHAR(50) NOT NULL, + "clusterResources" BOOLEAN NOT NULL DEFAULT false, + "kubeConfigId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cluster_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Kubeconfig" ( + "id" UUID NOT NULL, + "user" JSONB NOT NULL, + "cluster" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "parentClusterId" UUID, + + CONSTRAINT "Kubeconfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_ClusterToEnvironment" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateTable +CREATE TABLE "_ClusterToProject" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_id_key" ON "Cluster"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_label_key" ON "Cluster"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_secretName_key" ON "Cluster"("secretName"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_kubeConfigId_key" ON "Cluster"("kubeConfigId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_id_key" ON "Kubeconfig"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_parentClusterId_key" ON "Kubeconfig"("parentClusterId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToEnvironment_AB_unique" ON "_ClusterToEnvironment"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToEnvironment_B_index" ON "_ClusterToEnvironment"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToProject_AB_unique" ON "_ClusterToProject"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToProject_B_index" ON "_ClusterToProject"("B"); + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_kubeConfigId_fkey" FOREIGN KEY ("kubeConfigId") REFERENCES "Kubeconfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_B_fkey" FOREIGN KEY ("B") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_B_fkey" FOREIGN KEY ("B") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql new file mode 100644 index 0000000000..8f3fb5ff9f --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `parentClusterId` on the `Kubeconfig` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Kubeconfig_parentClusterId_key"; + +-- AlterTable +ALTER TABLE "Kubeconfig" DROP COLUMN "parentClusterId"; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql new file mode 100644 index 0000000000..4eb37edc53 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql @@ -0,0 +1,10 @@ +CREATE TYPE "ProjectStatus" AS ENUM ('initializing', 'created', 'failed', 'archived'); + +ALTER TABLE public."Project" ALTER COLUMN status TYPE "ProjectStatus" USING + case + when status = 'created' then 'created'::"ProjectStatus" + when status = 'failed' then 'failed'::"ProjectStatus" + when status = 'archived' then 'archived'::"ProjectStatus" + else 'initializing'::"ProjectStatus" + end; +ALTER TABLE public."Project" ALTER COLUMN status SET DEFAULT 'initializing'; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql new file mode 100644 index 0000000000..f402a0e3d4 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "infos" VARCHAR(200); diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql new file mode 100644 index 0000000000..f553e7880e --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `externalToken` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "externalToken"; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql new file mode 100644 index 0000000000..394b650a51 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql @@ -0,0 +1,81 @@ +-- CreateEnum +CREATE TYPE "QuotaStageStatus" AS ENUM +('active', 'pendingDelete'); + +-- Create new tables +-- CreateTable +CREATE TABLE "Quota" +( + "id" UUID NOT NULL, + "memory" VARCHAR NOT NULL, + "cpu" REAL NOT NULL, + "name" VARCHAR NOT NULL, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "Quota_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Stage" +( + "id" UUID NOT NULL, + "name" VARCHAR NOT NULL, + + CONSTRAINT "Stage_pkey" PRIMARY KEY ("id") +); + +-- Associate Quotas and Stages +-- CreateTable +CREATE TABLE "QuotaStage" +( + "id" UUID NOT NULL, + "quotaId" UUID NOT NULL, + "stageId" UUID NOT NULL, + "status" "QuotaStageStatus" NOT NULL DEFAULT 'active', + + CONSTRAINT "QuotaStage_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "Quota_id_key" ON "Quota"("id"); +CREATE UNIQUE INDEX "Quota_name_key" ON "Quota"("name"); +CREATE UNIQUE INDEX "Stage_id_key" ON "Stage"("id"); +CREATE UNIQUE INDEX "Stage_name_key" ON "Stage"("name"); +CREATE UNIQUE INDEX "QuotaStage_id_key" ON "QuotaStage"("id"); +CREATE UNIQUE INDEX "QuotaStage_quotaId_stageId_key" ON "QuotaStage"("quotaId", "stageId"); +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Create default values for Quotas and Stages +-- Quota +INSERT INTO "Quota" + (id, cpu, memory, "name", "isPrivate") +VALUES + ('5a57b62f-2465-4fb6-a853-5a751d099199', 2, '4Gi', 'small', false), + ('08770663-3b76-4af6-8978-9f75eda4faa7', 4, '8Gi', 'medium', false), + ('b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 6, '12Gi', 'large', false), + ('97b851e8-9067-4a3d-a0e8-c3a6820c49be', 8, '16Gi', 'xlarge', false); + +-- Stage +INSERT INTO "Stage" + (id, "name") +VALUES + ('4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', 'dev'), + ('38fa869d-6267-441d-af7f-e0548fd06b7e', 'staging'), + ('d434310e-7850-4d59-b47f-0772edf50582', 'integration'), + ('9b3e9991-896d-4d90-bdc5-a34be8c06b8f', 'prod'); + +-- QuotaStage +INSERT INTO "QuotaStage" + (id, "quotaId", "stageId") +VALUES + ('0cb0c549-560e-4f26-8f4e-832dd722f68a', '5a57b62f-2465-4fb6-a853-5a751d099199', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('0530e9c9-b37d-4dec-93e6-1895f700e61c', '5a57b62f-2465-4fb6-a853-5a751d099199', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('8a99db49-b7b1-44bf-865d-5e709e8aa0fc', '5a57b62f-2465-4fb6-a853-5a751d099199', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('67561f00-d219-4ca6-b94a-3ee83f09d2d6', '5a57b62f-2465-4fb6-a853-5a751d099199', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('8b3c201e-7518-4254-a94a-16c404e46936', '08770663-3b76-4af6-8978-9f75eda4faa7', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('9157ae12-3e39-43f8-a24f-ae5d9c6b69b7', '08770663-3b76-4af6-8978-9f75eda4faa7', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('c733a1dd-c9fd-4def-b29e-df49ef7b6698', '08770663-3b76-4af6-8978-9f75eda4faa7', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('15a51f47-0ab2-4a94-a808-722639d8c092', '08770663-3b76-4af6-8978-9f75eda4faa7', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('cb66e80c-2304-472d-bc19-a411011674ca', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('59fb0e79-3a76-4b96-81d4-63f4caa98cfa', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('4174b22c-2bee-4f4a-9d85-da7b5463f214', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql new file mode 100644 index 0000000000..8c98a7f74e --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql @@ -0,0 +1,35 @@ +-- Multiplication des environnements par clusteurs +ALTER TABLE "Environment" ADD COLUMN "clusterId" UUID; + +DO +$$ +DECLARE + perm record; + cte record; + env_uuid UUID; +BEGIN + FOR cte IN SELECT "B" AS environmentId, "A" AS "clusterId", "name", "projectId", status, "updatedAt", "createdAt" + FROM public."_ClusterToEnvironment", public."Environment" + WHERE public."Environment".id = "B" + LOOP + env_uuid := gen_random_uuid(); + INSERT INTO public."Environment" (id, "name", "projectId", "clusterId", status, "createdAt", "updatedAt") VALUES + (env_uuid, cte."name", cte."projectId", cte."clusterId", cte.status, cte."createdAt", cte."updatedAt"); + + FOR perm in SELECT * FROM public."Permission" WHERE "environmentId" = cte.environmentId + LOOP + INSERT INTO public."Permission" (id, "level", "createdAt", "updatedAt", "environmentId", "userId") VALUES + (gen_random_uuid(), perm."level", perm."createdAt", perm."updatedAt", env_uuid, perm."userId"); + END LOOP; + END LOOP; +END; +$$ +; +DELETE FROM public."Environment" WHERE "clusterId" is null; +ALTER TABLE public."Environment" ALTER COLUMN "clusterId" SET NOT NULL; +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_clusterId_fkey" FOREIGN KEY ("clusterId") REFERENCES "Cluster"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Delete old _ClusterToEnvironment +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_A_fkey"; +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_B_fkey"; +DROP TABLE "_ClusterToEnvironment"; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql new file mode 100644 index 0000000000..035cd1c855 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql @@ -0,0 +1,36 @@ +-- Associate cluster to Stages +-- CreateTable +CREATE TABLE "_ClusterToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToStage_AB_unique" ON "_ClusterToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToStage_B_index" ON "_ClusterToStage"("B"); + +DO +$$ +DECLARE + cluster record; + cte record; + env_uuid UUID; +BEGIN + FOR cluster IN SELECT id + FROM public."Cluster" + LOOP + INSERT INTO public."_ClusterToStage" ("A", "B") VALUES + (cluster.id, '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + (cluster.id, '38fa869d-6267-441d-af7f-e0548fd06b7e'), + (cluster.id, 'd434310e-7850-4d59-b47f-0772edf50582'), + (cluster.id, '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); + END LOOP; +END; +$$ diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql new file mode 100644 index 0000000000..43793bdb49 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaStageId" UUID; +UPDATE "Environment" SET "quotaStageId" = '8b3c201e-7518-4254-a94a-16c404e46936' WHERE "name" = 'dev'; +UPDATE "Environment" SET "quotaStageId" = '9157ae12-3e39-43f8-a24f-ae5d9c6b69b7' WHERE "name" = 'staging'; +UPDATE "Environment" SET "quotaStageId" = '4174b22c-2bee-4f4a-9d85-da7b5463f214' WHERE "name" = 'integration'; +UPDATE "Environment" SET "quotaStageId" = 'de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a' WHERE "name" = 'prod'; +ALTER TABLE "Environment" ALTER COLUMN "name" SET DATA TYPE VARCHAR(11); +ALTER TABLE "Environment" ALTER COLUMN "quotaStageId" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaStageId_fkey" FOREIGN KEY ("quotaStageId") REFERENCES "QuotaStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql new file mode 100644 index 0000000000..9af004b6a3 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- lock all projects +UPDATE public."Project" SET "locked"=true \ No newline at end of file diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql new file mode 100644 index 0000000000..d970d3965a --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- set all projects to failed to avoid unlock them +UPDATE public."Project" SET "status" = 'failed' WHERE "status" != 'archived' \ No newline at end of file diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql new file mode 100644 index 0000000000..c387d9885c --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ADD COLUMN "requestId" VARCHAR(21); diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql new file mode 100644 index 0000000000..18e20262c8 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `status` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the column `status` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "status"; + +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "status"; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql new file mode 100644 index 0000000000..f784b21567 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql @@ -0,0 +1,46 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "zoneId" UUID; + +-- CreateTable +CREATE TABLE "Zone" +( + "id" UUID NOT NULL, + "slug" VARCHAR(10) NOT NULL, + "label" VARCHAR(50) NOT NULL, + "description" VARCHAR(200), + "createdAt" TIMESTAMP +(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP +(3) NOT NULL, + + CONSTRAINT "Zone_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_id_key" ON "Zone"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_slug_key" ON "Zone"("slug"); + +-- Create default zone +INSERT INTO "Zone" + (id, "slug", "label", "description", "updatedAt") +VALUES + ('a66c4230-eba6-41f1-aae5-bb1e4f90cce0', 'default', 'Zone Défaut', 'Zone par défaut, à changer', CURRENT_TIMESTAMP); + +-- Set default zoneId for current clusters +UPDATE "Cluster" +SET "zoneId" += 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0' +WHERE "zoneId" +IS NULL; + +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "zoneId" +SET +NOT NULL; + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") +ON DELETE RESTRICT ON +UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql new file mode 100644 index 0000000000..cb4f7ad968 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "ProjectPlugin" ( + "pluginName" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "AdminPlugin" ( + "pluginName" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectPlugin_projectId_pluginName_key_key" ON "ProjectPlugin"("projectId", "pluginName", "key"); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminPlugin_pluginName_key_key" ON "AdminPlugin"("pluginName", "key"); + +-- AddForeignKey +ALTER TABLE "ProjectPlugin" ADD CONSTRAINT "ProjectPlugin_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql new file mode 100644 index 0000000000..11672324f8 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql @@ -0,0 +1,19 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id, services FROM public."Project" LOOP + -- Extrait 'registry.id' de la colonne JSON 'services' + registry_id := (SELECT (project_row.services -> 'registry' ->> 'id')::TEXT); + -- Si 'registry.id' existe, insérer dans la table 'config' + IF registry_id IS NOT NULL THEN + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'registry', 'projectId', registry_id::TEXT); + END IF; + END LOOP; +END $$; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "services"; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql new file mode 100644 index 0000000000..9c7d5a8f8f --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "RoleList" AS ENUM ('owner', 'user'); + +-- AlterTable +ALTER TABLE public."Role" ALTER COLUMN "role" TYPE "RoleList" USING + case + when role = 'owner' then 'owner'::"RoleList" + else 'user'::"RoleList" + end; \ No newline at end of file diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql new file mode 100644 index 0000000000..45a4a5d1ea --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "ProjectClusterHistory" ( + "projectId" UUID NOT NULL, + "clusterId" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectClusterHistory_projectId_clusterId_key" ON "ProjectClusterHistory"("projectId", "clusterId"); diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql new file mode 100644 index 0000000000..2b1641a657 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql @@ -0,0 +1,11 @@ +DO $$ +DECLARE + env_row RECORD; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR env_row IN SELECT "projectId", "clusterId" FROM public."Environment" LOOP + INSERT INTO public."ProjectClusterHistory" ("projectId", "clusterId") + VALUES (env_row."projectId", env_row."clusterId") + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql new file mode 100644 index 0000000000..5e7ff03d45 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql @@ -0,0 +1,58 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaId" UUID, +ADD COLUMN "stageId" UUID; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "_QuotaToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_QuotaToStage_AB_unique" ON "_QuotaToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_QuotaToStage_B_index" ON "_QuotaToStage"("B"); + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +DO $$ +DECLARE + quota_stage_row RECORD; +BEGIN + FOR quota_stage_row IN SELECT * FROM public."QuotaStage" loop + UPDATE public."Environment" SET "stageId" = quota_stage_row."stageId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + UPDATE public."Environment" SET "quotaId" = quota_stage_row."quotaId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + insert into public."_QuotaToStage" values (quota_stage_row."quotaId", quota_stage_row."stageId"); + END LOOP; +END $$; + +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaStageId_fkey"; + +-- AlterTable +ALTER TABLE "Environment" ALTER COLUMN "quotaId" SET NOT NULL, +ALTER COLUMN "stageId" SET NOT NULL, +DROP COLUMN "quotaStageId"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_stageId_fkey"; + +-- DropTable +DROP TABLE "QuotaStage"; + +-- DropEnum +DROP TYPE "QuotaStageStatus"; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql new file mode 100644 index 0000000000..0036da8a12 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Made the column `description` on table `Project` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "description" SET NOT NULL, +ALTER COLUMN "description" SET DEFAULT ''; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql new file mode 100644 index 0000000000..ed6ae9b84e --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql @@ -0,0 +1,198 @@ +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_environmentId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_environmentId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_userId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_userId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_projectId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_projectId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_userId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_userId_fkey"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectMembers') THEN + CREATE TABLE "ProjectMembers" ( + "projectId" UUID NOT NULL, + "userId" UUID NOT NULL, + "roleIds" TEXT[] + ); + END IF; +END $$; + +-- AlterTable +ALTER TABLE "Log" ADD COLUMN IF NOT EXISTS "projectId" UUID; + +INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt") +VALUES('04ac168a-2c4f-4816-9cce-af6c612e5912'::uuid, 'Anonymous', 'User', 'anon@user', '2023-07-03 14:46:56.770', '2023-07-03 14:46:56.770') +ON CONFLICT (id) DO NOTHING; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "everyonePerms" BIGINT NOT NULL DEFAULT 896, +ADD COLUMN IF NOT EXISTS "ownerId" UUID; + +DO $$ +DECLARE + role_row RECORD; +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR role_row IN SELECT "userId", "projectId", "role" FROM public."Role" LOOP + INSERT INTO public."ProjectMembers" ("userId", "projectId", "roleIds") VALUES (role_row."userId", role_row."projectId", '{}'); + IF role_row."role" = 'owner'::public."RoleList" THEN + UPDATE public."Project" + SET "ownerId"=role_row."userId" + WHERE id=role_row."projectId"::uuid; + END IF; + END LOOP; + END IF; +END $$; + +UPDATE public."Project" +SET "ownerId"='04ac168a-2c4f-4816-9cce-af6c612e5912' +WHERE "ownerId" IS NULL; + +ALTER TABLE public."Project" ALTER COLUMN "ownerId" SET NOT NULL; + +DELETE FROM public."ProjectMembers" pm +USING public."Project" p +WHERE pm."userId" = p."ownerId" +AND pm."projectId" = p."id"; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Permission') THEN + DROP TABLE "Permission"; + END IF; +END $$; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + DROP TABLE "Role"; + END IF; +END $$; + +-- DropEnum if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'RoleList') THEN + DROP TYPE "RoleList"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'AdminRole') THEN + CREATE TABLE "AdminRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "AdminRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectRole') THEN + CREATE TABLE "ProjectRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "projectId" UUID NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "ProjectRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_id_key') THEN + CREATE UNIQUE INDEX "AdminRole_id_key" ON "AdminRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_name_key') THEN + CREATE UNIQUE INDEX "AdminRole_name_key" ON "AdminRole"("name"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectMembers_projectId_userId_key') THEN + CREATE UNIQUE INDEX "ProjectMembers_projectId_userId_key" ON "ProjectMembers"("projectId", "userId"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectRole_id_key') THEN + CREATE UNIQUE INDEX "ProjectRole_id_key" ON "ProjectRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'Environment_projectId_name_key') THEN + CREATE UNIQUE INDEX "Environment_projectId_name_key" ON "Environment"("projectId", "name"); + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Log_projectId_fkey') THEN + ALTER TABLE "Log" ADD CONSTRAINT "Log_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Project_ownerId_fkey') THEN + ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_projectId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_userId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectRole_projectId_fkey') THEN + ALTER TABLE "ProjectRole" ADD CONSTRAINT "ProjectRole_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "adminRoleIds" TEXT[]; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql new file mode 100644 index 0000000000..c9b41827bf --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "AdminRole_name_key"; + +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "oidcGroup" TEXT; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql new file mode 100644 index 0000000000..265f262abb --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `oidcGroup` on table `AdminRole` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable + +UPDATE public."AdminRole" +SET "oidcGroup"='' +WHERE "oidcGroup" IS NULL; + +ALTER TABLE "AdminRole" ALTER COLUMN "oidcGroup" SET NOT NULL, +ALTER COLUMN "oidcGroup" SET DEFAULT ''; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql new file mode 100644 index 0000000000..4fc2768605 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "SystemSetting" +( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "SystemSetting_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemSetting_key_key" ON "SystemSetting"("key"); + +-- Create maintenance setting +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('maintenance', 'off'); diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql new file mode 100644 index 0000000000..95ab54869d --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql @@ -0,0 +1,3 @@ +INSERT INTO public."AdminRole" +(id, "name", permissions, "position", "oidcGroup") +VALUES('76229c96-4716-45bc-99da-00498ec9018c'::uuid, 'Admin', 2, 0, '/admin'); \ No newline at end of file diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql new file mode 100644 index 0000000000..c116482186 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Made the column `externalUserName` on table `Repository` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +UPDATE "Repository" SET "externalUserName" = '' WHERE "externalUserName" IS NULL; + +ALTER TABLE "Repository" ALTER COLUMN "externalUserName" SET NOT NULL, +ALTER COLUMN "externalUserName" SET DEFAULT '', +ALTER COLUMN "externalRepoUrl" SET DEFAULT ''; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240916141253_token/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240916141253_token/migration.sql new file mode 100644 index 0000000000..b0472cd806 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240916141253_token/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "TokenStatus" AS ENUM ('active', 'revoked'); + +-- CreateTable +CREATE TABLE "AdminToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "userId" UUID, + "expirationDate" TIMESTAMP(3), + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "AdminToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminToken_id_key" ON "AdminToken"("id"); + +-- AddForeignKey +ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql new file mode 100644 index 0000000000..47488b00c5 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "Log" DROP CONSTRAINT "Log_userId_fkey"; + +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "userId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql new file mode 100644 index 0000000000..18eca3eadf --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "requestId" SET DATA TYPE VARCHAR(36); diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql new file mode 100644 index 0000000000..74e0946f0a --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Zone" ADD COLUMN "argocdUrl" TEXT NOT NULL DEFAULT 'https://example.com'; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql new file mode 100644 index 0000000000..41dac75359 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ProjectStatus" ADD VALUE 'warning'; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql new file mode 100644 index 0000000000..ef888d5e50 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql @@ -0,0 +1,12 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id FROM public."Project" WHERE status <> 'archived'::public."ProjectStatus" LOOP + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'nexus', 'activateMavenRepo', 'enabled') + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql new file mode 100644 index 0000000000..a57e5956c3 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql @@ -0,0 +1,12 @@ +-- CreateEnum +CREATE TYPE "UserType" AS ENUM ('human', 'bot', 'ghost'); + +-- AlterEnum +ALTER TYPE "TokenStatus" ADD VALUE 'inactive'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "type" "UserType" NOT NULL DEFAULT 'human'; +UPDATE "User" SET type = 'ghost' WHERE id = '04ac168a-2c4f-4816-9cce-af6c612e5912'; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "type" DROP DEFAULT; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql new file mode 100644 index 0000000000..71e15a312c --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql @@ -0,0 +1,84 @@ +-- CreateTable (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'PersonalAccessToken') THEN + CREATE TABLE "PersonalAccessToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "userId" UUID NOT NULL, + "expirationDate" TIMESTAMP(3) NOT NULL, + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'PersonalAccessToken_id_key') THEN + CREATE UNIQUE INDEX "PersonalAccessToken_id_key" ON "PersonalAccessToken"("id"); + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'PersonalAccessToken_userId_fkey') THEN + ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- Process AdminToken (idempotent) +DO $$ +DECLARE + admin_token record; + user_uuid UUID; +BEGIN + FOR admin_token IN SELECT "name", "id" + FROM public."AdminToken" + LOOP + -- Generate new UUID if user does not exist + user_uuid := COALESCE( + (SELECT id FROM public."User" WHERE email = concat(admin_token.name, '@bot.id')), + gen_random_uuid() + ); + + -- Insert user if not already exists + INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt", "type") + VALUES(user_uuid, 'Bot Admin', admin_token.name, concat(admin_token.name, '@bot.id'), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'bot') + ON CONFLICT (id) DO NOTHING; + + -- Update AdminToken with the new user ID + UPDATE public."AdminToken" SET "userId" = user_uuid WHERE id = admin_token.id; + END LOOP; +END $$; + +-- Alter AdminToken userId column to NOT NULL (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'AdminToken' AND column_name = 'userId' AND is_nullable = 'NO') THEN + ALTER TABLE public."AdminToken" ALTER COLUMN "userId" SET NOT NULL; + END IF; +END $$; + +-- DropForeignKey if exists (idempotent) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" DROP CONSTRAINT "AdminToken_userId_fkey"; + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql new file mode 100644 index 0000000000..521b2b10ac --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastLogin" TIMESTAMP(3); diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql new file mode 100644 index 0000000000..a7500833b9 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "slug" TEXT; + +UPDATE public."Project" p +SET slug = ( + SELECT concat(org.name, '-', subp.name) FROM public."Project" subp + LEFT JOIN public."Organization" org on org."id" = subp."organizationId" + WHERE subp.id = p.id +); + +ALTER TABLE public."Project" ALTER COLUMN "slug" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug"); diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql new file mode 100644 index 0000000000..b143cbeb9b --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "lastSuccessProvisionningVersion" TEXT; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql new file mode 100644 index 0000000000..7a88681901 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToProject_AB_unique"; + +-- AlterTable +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToStage_AB_unique"; + +-- AlterTable +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_QuotaToStage_AB_unique"; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql new file mode 100644 index 0000000000..21ce77b8d5 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "external" BOOLEAN NOT NULL DEFAULT false; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql new file mode 100644 index 0000000000..ac63cd639d --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql @@ -0,0 +1,25 @@ +-- Vérifie les versions dans la table Project +DO $$ +DECLARE + project_id TEXT; + project_name TEXT; + last_version TEXT; +BEGIN + -- Boucle sur les projets non archivés + FOR project_id, project_name, last_version IN ( + SELECT id, name, "lastSuccessProvisionningVersion" + FROM "Project" + WHERE "status" != 'archived' + ) + LOOP + -- Vérifie si la version est NULL + IF last_version IS NULL THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version NULL.', project_name, project_id; + END IF; + + -- Vérifie si la version est inférieure à 8.23.0 selon SemVer + IF (string_to_array(last_version, '.')::int[] < ARRAY[8,23,0]) THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version (%), inférieure à 8.23.0.', project_name, project_id, last_version; + END IF; + END LOOP; +END $$; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql new file mode 100644 index 0000000000..54871c9017 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `organizationId` on the `Project` table. All the data in the column will be lost. + - You are about to drop the `Organization` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Project" DROP CONSTRAINT "Project_organizationId_fkey"; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "organizationId"; + +-- DropTable +DROP TABLE "Organization"; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql new file mode 100644 index 0000000000..68ca0df2fa --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "infos" SET DATA TYPE VARCHAR(1000); diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql new file mode 100644 index 0000000000..8364090d8d --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql @@ -0,0 +1,44 @@ +-- AlterTable +ALTER TABLE "Environment" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; + +COMMENT ON COLUMN "Environment".cpu IS 'CPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".gpu IS 'GPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".memory IS 'Memory value as GigaBytes (1 and 0.01 are valid values)'; + +-- Use values from Quota. Memory is an extract of q.memory numeric value as it contains a unit (e.g. '2Gi'). +UPDATE "Environment" +SET cpu = q.cpu, memory = COALESCE(NULLIF(regexp_replace(q.memory, '\D', '','g'), ''), '0')::numeric +FROM "Quota" q +WHERE "quotaId" = q."id"; + +/* + Warnings: + + - You are about to drop the column `quotaId` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the `Quota` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_QuotaToStage` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_B_fkey"; + +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "quotaId", +ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- DropTable +DROP TABLE "Quota"; + +-- DropTable +DROP TABLE "_QuotaToStage"; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql new file mode 100644 index 0000000000..77f32b5ab5 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Cluster" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql new file mode 100644 index 0000000000..decca804aa --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Project" +ADD COLUMN "limitless" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "hprodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodMemory" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodMemory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql new file mode 100644 index 0000000000..95f3a689db --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql @@ -0,0 +1,4 @@ +-- Rename default zone +UPDATE "Zone" +SET ("label", "description") = ('DSO', 'Zone par défaut') +WHERE slug = 'default'; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql new file mode 100644 index 0000000000..aadb6cdba9 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Repository" ADD COLUMN "deployRevision" TEXT, +ADD COLUMN "deployPath" TEXT, +ADD COLUMN "helmValuesFiles" TEXT; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql new file mode 100644 index 0000000000..15cf56ab82 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql @@ -0,0 +1,12 @@ +-- Eliminate NULLs +UPDATE "Repository" SET "deployRevision" = '' WHERE "deployRevision" IS NULL; +UPDATE "Repository" SET "deployPath" = '' WHERE "deployPath" IS NULL; +UPDATE "Repository" SET "helmValuesFiles" = '' WHERE "helmValuesFiles" IS NULL; + +-- AlterTable +ALTER TABLE "Repository" ALTER COLUMN "deployRevision" SET NOT NULL, +ALTER COLUMN "deployRevision" SET DEFAULT '', +ALTER COLUMN "deployPath" SET NOT NULL, +ALTER COLUMN "deployPath" SET DEFAULT '', +ALTER COLUMN "helmValuesFiles" SET NOT NULL, +ALTER COLUMN "helmValuesFiles" SET DEFAULT ''; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql new file mode 100644 index 0000000000..1292495121 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "autosync" BOOLEAN NOT NULL DEFAULT true; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql new file mode 100644 index 0000000000..04c98df036 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "oidcGroup" TEXT NOT NULL DEFAULT ''; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql new file mode 100644 index 0000000000..40da41a75d --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- Update AdminRole system roles +UPDATE "AdminRole" SET "type" = 'system' WHERE "name" IN ('Admin', 'Admin Locaux'); + +-- Update ProjectRole system roles +UPDATE "ProjectRole" SET "type" = 'system' WHERE "name" IN ('Administrateur', 'DevOps', 'Développeur', 'Lecture seule'); diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql new file mode 100644 index 0000000000..92c200d261 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql @@ -0,0 +1,26 @@ +-- Update existing Admin role to be system role 'Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + "oidcGroup" = '/admin', + "position" = 0 +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Lecture Seule Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '35848aa2-e881-4770-9844-0c5c3693e506'::uuid, + 'Lecture Seule Plateforme', + 1, -- Assuming 1n means bit 0 + 2, + '/readonly', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Lecture Seule Plateforme', + "type" = 'system', + "permissions" = 1, + "oidcGroup" = '/readonly'; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql new file mode 100644 index 0000000000..2a4afa8971 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql @@ -0,0 +1,28 @@ +-- Update existing Admin role to be system role 'Root Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Root Administrateur Plateforme' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Administrateur Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf'::uuid, + 'Administrateur Plateforme', + 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + 0, + '/console/admin', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, + "oidcGroup" = '/console/admin'; + +-- Update 'Lecture Seule Plateforme' system role +UPDATE "AdminRole" +SET + "oidcGroup" = '/console/readonly' +WHERE "id" = '35848aa2-e881-4770-9844-0c5c3693e506'::uuid; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql new file mode 100644 index 0000000000..f0cdb04873 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `external` on the `Cluster` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Cluster" DROP COLUMN "external"; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql new file mode 100644 index 0000000000..e07cad777b --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql @@ -0,0 +1,5 @@ +-- Enable legacy default permissions flag +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('refined-permissions', 'off'); diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql new file mode 100644 index 0000000000..efb6bc67d4 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql @@ -0,0 +1,3 @@ +-- Remove 'refined-permissions' system setting +DELETE FROM "SystemSetting" +WHERE "key" = 'refined-permissions'; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql new file mode 100644 index 0000000000..d019403c44 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql @@ -0,0 +1,15 @@ +-- Add global 'Tout le monde' role +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '034f589f-1750-4b15-bb34-4cd995e7fcaa'::uuid, + 'Tout le monde', + 25608, -- MANAGE_PROJECTS | LIST_PROJECTS | LIST_ZONES | LIST_STAGES + 0, + '', + 'global' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Tout le monde', + "type" = 'global', + "permissions" = 25608; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql new file mode 100644 index 0000000000..4fd0d80eb2 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql @@ -0,0 +1,25 @@ +-- Alter existing column to change the default value from 'custom' to 'managed' for AdminRole type +ALTER TABLE "AdminRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Alter existing column to change the default value from 'custom' to 'managed' for ProjectRole type +ALTER TABLE "ProjectRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Migrate system roles to managed roles +UPDATE "AdminRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate system roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate custom roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'custom'; + +-- Update existing Admin role to be an externally managed role +UPDATE "AdminRole" +SET "type" = 'external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql new file mode 100644 index 0000000000..d2691666bd --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql @@ -0,0 +1,15 @@ +-- Migrate well-known project roles to the new "system:"-prefixed type +UPDATE "ProjectRole" +SET "type" = 'system:managed' +WHERE + "type" = 'managed' + AND "oidcGroup" ~ '^/[^/]+/console/(admin|devops|developer|readonly)$'; + +-- Migrate well-known admin roles to the new "system:"-prefixed type +UPDATE "AdminRole" +SET "type" = 'system:managed' +WHERE id IN ( + '76229c96-4716-45bc-99da-00498ec9018c', -- Root Administrateur Plateforme + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', -- Administrateur Plateforme + '35848aa2-e881-4770-9844-0c5c3693e506' -- Lecture Seule Plateforme +); diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql new file mode 100644 index 0000000000..23593ba4f4 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "hprodCpu" DROP DEFAULT, +ALTER COLUMN "hprodGpu" DROP DEFAULT, +ALTER COLUMN "hprodMemory" DROP DEFAULT, +ALTER COLUMN "prodCpu" DROP DEFAULT, +ALTER COLUMN "prodGpu" DROP DEFAULT, +ALTER COLUMN "prodMemory" DROP DEFAULT; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql new file mode 100644 index 0000000000..7e3eefb25d --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql @@ -0,0 +1,4 @@ +-- Set the Root Administrateur Plateforme admin role to external system-managed type +UPDATE "AdminRole" +SET "type" = 'system:external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql new file mode 100644 index 0000000000..ca81594339 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "DeploymentSourceType" AS ENUM ('git', 'oci'); + +-- CreateTable +CREATE TABLE "Deployment" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "name" VARCHAR(11) NOT NULL, + "autosync" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "environmentId" UUID NOT NULL, + + CONSTRAINT "Deployment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeploymentSource" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deploymentId" UUID NOT NULL, + "repositoryId" UUID NOT NULL, + "type" "DeploymentSourceType" NOT NULL, + "targetRevision" TEXT NOT NULL DEFAULT '', + "path" TEXT NOT NULL DEFAULT '', + "helmValuesFiles" TEXT NOT NULL DEFAULT '', + + CONSTRAINT "DeploymentSource_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Deployment_id_key" ON "Deployment"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentSource_id_key" ON "DeploymentSource"("id"); + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "Deployment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql new file mode 100644 index 0000000000..ad5a64d336 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_environmentId_fkey"; + +-- DropForeignKey +ALTER TABLE "DeploymentSource" DROP CONSTRAINT "DeploymentSource_repositoryId_fkey"; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-1/apps/server/src/prisma/migrations/migration_lock.toml b/.jjconflict-side-1/apps/server/src/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000..044d57cdb0 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/.jjconflict-side-1/apps/server/src/prisma/schema/admin.prisma b/.jjconflict-side-1/apps/server/src/prisma/schema/admin.prisma new file mode 100644 index 0000000000..c4ca38ccb1 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/schema/admin.prisma @@ -0,0 +1,21 @@ +model AdminPlugin { + pluginName String + key String + value String + + @@unique([pluginName, key]) +} + +model AdminRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") +} + +model SystemSetting { + key String @id @unique + value String +} diff --git a/.jjconflict-side-1/apps/server/src/prisma/schema/project.prisma b/.jjconflict-side-1/apps/server/src/prisma/schema/project.prisma new file mode 100644 index 0000000000..6bcc5ce949 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/schema/project.prisma @@ -0,0 +1,144 @@ +model Deployment { + id String @id @unique @default(uuid()) @db.Uuid + projectId String @db.Uuid + name String @db.VarChar(11) + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + environmentId String @db.Uuid + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model DeploymentSource { + id String @id @unique @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deploymentId String @db.Uuid + repositoryId String @db.Uuid + type DeploymentSourceType + targetRevision String @default("") + path String @default("") + helmValuesFiles String @default("") + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) + deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) +} + +model Environment { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(11) + projectId String @db.Uuid + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clusterId String @db.Uuid + stageId String @db.Uuid + cluster Cluster @relation(fields: [clusterId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + stage Stage @relation(fields: [stageId], references: [id]) + deployments Deployment[] + + @@unique([projectId, name]) +} + +model Repository { + id String @id @default(uuid()) @db.Uuid + projectId String @db.Uuid + internalRepoName String + externalRepoUrl String @default("") + externalUserName String @default("") + isInfra Boolean @default(false) + isPrivate Boolean @default(false) + deployRevision String @default("") + deployPath String @default("") + helmValuesFiles String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model ProjectClusterHistory { + projectId String @db.Uuid + clusterId String @db.Uuid + + @@unique([projectId, clusterId]) +} + +model ProjectMembers { + projectId String @db.Uuid + userId String @db.Uuid + roleIds String[] + project Project @relation(fields: [projectId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@unique([projectId, userId]) +} + +model ProjectPlugin { + pluginName String + projectId String @db.Uuid + key String + value String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, pluginName, key]) +} + +model ProjectRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + projectId String @db.Uuid + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") + project Project @relation(fields: [projectId], references: [id]) +} + +model Project { + id String @id @unique @default(uuid()) @db.Uuid + name String + description String @default("") + status ProjectStatus @default(initializing) + locked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + everyonePerms BigInt @default(896) + ownerId String @db.Uuid + environments Environment[] + logs Log[] + owner User @relation(fields: [ownerId], references: [id]) + members ProjectMembers[] + plugins ProjectPlugin[] + roles ProjectRole[] + repositories Repository[] + clusters Cluster[] @relation("ClusterToProject") + slug String @unique + limitless Boolean @default(true) + hprodCpu Float @db.Real + hprodGpu Float @db.Real + hprodMemory Float @db.Real + prodCpu Float @db.Real + prodGpu Float @db.Real + prodMemory Float @db.Real + lastSuccessProvisionningVersion String? + deployments Deployment[] +} + +enum ProjectStatus { + initializing + created + failed + archived + warning +} + +enum DeploymentSourceType { + git + oci +} diff --git a/.jjconflict-side-1/apps/server/src/prisma/schema/schema.prisma b/.jjconflict-side-1/apps/server/src/prisma/schema/schema.prisma new file mode 100644 index 0000000000..aadf7fea13 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/schema/schema.prisma @@ -0,0 +1,21 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DB_URL") +} + +model Log { + id String @id @default(uuid()) @db.Uuid + data Json + action String @default("") + userId String? @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + requestId String? @db.VarChar(36) + projectId String? @db.Uuid + project Project? @relation(fields: [projectId], references: [id]) + user User? @relation(fields: [userId], references: [id]) +} diff --git a/.jjconflict-side-1/apps/server/src/prisma/schema/token.prisma b/.jjconflict-side-1/apps/server/src/prisma/schema/token.prisma new file mode 100644 index 0000000000..c0c55751c5 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/schema/token.prisma @@ -0,0 +1,30 @@ +model AdminToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + userId String @db.Uuid + expirationDate DateTime? + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +model PersonalAccessToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + userId String @db.Uuid + expirationDate DateTime + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +enum TokenStatus { + active + revoked + inactive +} diff --git a/.jjconflict-side-1/apps/server/src/prisma/schema/topography.prisma b/.jjconflict-side-1/apps/server/src/prisma/schema/topography.prisma new file mode 100644 index 0000000000..63d90abb8a --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/schema/topography.prisma @@ -0,0 +1,52 @@ +model Cluster { + id String @id @unique @default(uuid()) @db.Uuid + label String @unique @db.VarChar(50) + privacy ClusterPrivacy @default(dedicated) + secretName String @unique @default(uuid()) @db.VarChar(50) + clusterResources Boolean @default(false) + kubeConfigId String @unique @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + infos String? @db.VarChar(1000) + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + zoneId String @db.Uuid + kubeconfig Kubeconfig @relation(fields: [kubeConfigId], references: [id], onDelete: Cascade) + zone Zone @relation(fields: [zoneId], references: [id]) + environments Environment[] + projects Project[] @relation("ClusterToProject") + stages Stage[] @relation("ClusterToStage") +} + +model Kubeconfig { + id String @id @unique @default(uuid()) @db.Uuid + user Json + cluster Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + parentCluster Cluster? +} + +model Stage { + id String @id @unique @default(uuid()) @db.Uuid + name String @unique @db.VarChar + environments Environment[] + clusters Cluster[] @relation("ClusterToStage") +} + +model Zone { + id String @id @unique @default(uuid()) @db.Uuid + slug String @unique @db.VarChar(10) + label String @db.VarChar(50) + description String? @db.VarChar(200) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + argocdUrl String @default("https://example.com") + clusters Cluster[] +} + +enum ClusterPrivacy { + public + dedicated +} diff --git a/.jjconflict-side-1/apps/server/src/prisma/schema/user.prisma b/.jjconflict-side-1/apps/server/src/prisma/schema/user.prisma new file mode 100644 index 0000000000..e90fb69f83 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/prisma/schema/user.prisma @@ -0,0 +1,23 @@ +model User { + id String @id @db.Uuid + firstName String + lastName String + email String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? + adminRoleIds String[] + type UserType + + logs Log[] + projectsOwned Project[] + adminTokens AdminToken[] + projectMembers ProjectMembers[] + personalAccessTokens PersonalAccessToken[] +} + +enum UserType { + human + bot + ghost +} diff --git a/.jjconflict-side-1/apps/server/src/resources/admin-role/business.spec.ts b/.jjconflict-side-1/apps/server/src/resources/admin-role/business.spec.ts new file mode 100644 index 0000000000..9f9a7051ea --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/admin-role/business.spec.ts @@ -0,0 +1,262 @@ +import type { AdminRole, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400 } from '../../utils/errors.ts' +import { countRolesMembers, createRole, deleteRole, listRoles, patchRoles } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +describe('test admin-role business', () => { + describe('listRoles', () => { + it('should stringify bigint', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + const response = await listRoles() + expect(response).toContainEqual(expect.objectContaining({ permissions: '4', type: 'managed' })) + }) + }) + + describe('createRole', () => { + it('should create role with incremented position when position 0 is the highest', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 1 } }) + }) + + it('should create role with incremented position with bigger position', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 51 } }) + }) + + it('should create role with incremented position with no role in db', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(null) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 0 } }) + }) + }) + describe('deleteRole', () => { + const roleId = faker.string.uuid() + it('should delete role and remove id from concerned users', async () => { + const users = [{ + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roleId], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }, { + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roleId, faker.string.uuid()], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }] as const satisfies User[] + + const dbRole: AdminRole = { + name: 'Admin', + id: roleId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.user.findMany.mockResolvedValue(users) + prisma.adminRole.findMany.mockResolvedValueOnce([]) + prisma.adminRole.findUnique.mockResolvedValueOnce(dbRole) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.delete.mockResolvedValue({ args: {}, failed: false, results: {} }) + await deleteRole(roleId, faker.string.uuid()) + + expect(prisma.user.findMany).toHaveBeenCalledTimes(2) + expect(prisma.user.update).toHaveBeenNthCalledWith(1, { where: { id: users[0].id }, data: { adminRoleIds: [] } }) + expect(prisma.user.update).toHaveBeenNthCalledWith(2, { where: { id: users[1].id }, data: { adminRoleIds: [users[1].adminRoleIds[1]] } }) + expect(prisma.adminRole.delete).toHaveBeenCalledWith({ where: { id: roleId } }) + }) + }) + + describe('countRolesMembers', () => { + it('should return aggregated role member counts', async () => { + const roles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + type: 'managed', + }] as const satisfies AdminRole[] + + const users = [{ + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roles[0].id, roles[1].id], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }, { + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roles[1].id], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }] as const satisfies User[] + prisma.adminRole.findMany.mockResolvedValue(roles) + prisma.user.findMany.mockResolvedValue(users) + + const response = await countRolesMembers() + + expect(response).toEqual({ [roles[0].id]: 1, [roles[1].id]: 2 }) + }) + }) + describe('patchRoles', () => { + const dbRoles: AdminRole[] = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + type: 'managed', + }] + + it('should do nothing', async () => { + prisma.adminRole.findMany.mockResolvedValue([]) + await patchRoles([], faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 1 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(updateRoles, faker.string.uuid()) + + expect(response).instanceOf(BadRequest400) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + it('should return 400 if incoherent positions (missing roles)', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[1].id, position: 1 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(updateRoles, faker.string.uuid()) + + expect(response).instanceOf(BadRequest400) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + it('should update positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 0 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await patchRoles(updateRoles, faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(2) + }) + it('should update permissions', async () => { + const updateRoles: (Pick & { permissions?: string })[] = [ + { id: dbRoles[1].id, permissions: '0' }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await patchRoles(updateRoles, faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(1) + expect(prisma.adminRole.update).toHaveBeenCalledWith({ + data: { + name: dbRoles[1].name, + oidcGroup: dbRoles[1].oidcGroup, + permissions: 0n, + position: 1, + type: 'managed', + }, + where: { + id: dbRoles[1].id, + }, + }) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/admin-role/business.ts b/.jjconflict-side-1/apps/server/src/resources/admin-role/business.ts new file mode 100644 index 0000000000..f81fd65b75 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/admin-role/business.ts @@ -0,0 +1,111 @@ +import type { AdminRole, adminRoleContract } from '@cpn-console/shared' +import type { Project, ProjectRole } from '@prisma/client' +import type { ErrorResType } from '@/utils/errors.js' +import prisma from '@/prisma.js' +import { addLogs, getAdminRoleById, listAdminRoles } from '@/resources/queries-index.js' +import { BadRequest400 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function listRoles() { + return listAdminRoles() + .then(roles => roles.map(role => ({ ...role, permissions: role.permissions.toString(), type: role.type ?? 'managed' }))) +} + +export async function patchRoles( + roles: typeof adminRoleContract.patchAdminRoles.body._type, + requestId: string, +): Promise { + const dbRoles = await prisma.adminRole.findMany() + const positionsAvailable: number[] = [] + const updatedRoles: (Omit & { permissions: bigint })[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (matchingRole) { + if (typeof matchingRole.position !== 'undefined' && !positionsAvailable.includes(matchingRole.position)) { + positionsAvailable.push(matchingRole.position) + } + updatedRoles.push({ + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions: matchingRole.permissions ? BigInt(matchingRole.permissions) : dbRole.permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup: matchingRole.oidcGroup ?? dbRole.oidcGroup, + type: matchingRole.type ?? dbRole.type, + }) + } + } + + if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes') + for (const { id, ...role } of updatedRoles) { + await prisma.adminRole.update({ where: { id }, data: role }) + const hookReply = await hook.adminRole.upsert(id) + await addLogs({ action: 'Update Admin Role', data: hookReply, requestId }) + } + + return listRoles() +} + +export async function createRole( + role: typeof adminRoleContract.createAdminRole.body._type, + requestId: string, +) { + const dbMaxPosRole = (await prisma.adminRole.findFirst({ + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + const createdRole = await prisma.adminRole.create({ + data: { + ...role, + position: dbMaxPosRole + 1, + permissions: 0n, + }, + }) + + const hookReply = await hook.adminRole.upsert(createdRole.id) + await addLogs({ action: 'Create Admin Role', data: hookReply, requestId }) + + return listRoles() +} + +export async function countRolesMembers() { + const roles = await prisma.adminRole.findMany({ where: { oidcGroup: { equals: '' } }, select: { id: true } }) + const roleIds = roles.map(role => role.id) + const users = await prisma.user.findMany({ + where: { adminRoleIds: { hasSome: roleIds } }, + select: { adminRoleIds: true }, + }) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) // {role uuid: 0} + for (const { adminRoleIds } of users) { + for (const roleId of adminRoleIds) { + rolesCounts[roleId]++ + } + } + return rolesCounts +} + +export async function deleteRole( + roleId: Project['id'], + requestId: string, +) { + const role = await getAdminRoleById(roleId) + if (role) { + const hookReply = await hook.adminRole.delete(role) + await addLogs({ action: 'Delete Admin Role', data: hookReply, requestId }) + } + + const allUsers = await prisma.user.findMany({ + where: { + adminRoleIds: { has: roleId }, + }, + }) + for (const user of allUsers) { + await prisma.user.update({ + where: { id: user.id }, + data: { adminRoleIds: user.adminRoleIds.filter(adminRoleId => adminRoleId !== roleId) }, + }) + } + await prisma.adminRole.delete({ where: { id: roleId } }) + return null +} diff --git a/.jjconflict-side-1/apps/server/src/resources/admin-role/queries.ts b/.jjconflict-side-1/apps/server/src/resources/admin-role/queries.ts new file mode 100644 index 0000000000..9c29eb0b36 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/admin-role/queries.ts @@ -0,0 +1,40 @@ +import type { + AdminRole, + Prisma, +} from '@prisma/client' +import prisma from '@/prisma.js' + +export const listAdminRoles = () => prisma.adminRole.findMany({ orderBy: { position: 'asc' } }) + +export function createAdminRole(data: Pick) { + return prisma.adminRole.create({ + data: { + name: data.name, + permissions: 0n, + position: data.position, + type: 'managed', + }, + }) +} + +export function updateAdminRole(id: AdminRole['id'], data: Pick) { + return prisma.adminRole.updateMany({ + where: { id }, + data, + }) +} + +export function deleteAdminRole(id: AdminRole['id']) { + return prisma.adminRole.delete({ + where: { + id, + }, + }) +} + +export async function getAdminRoleById(id: string) { + const role = await prisma.adminRole.findUnique({ where: { id } }) + if (!role) return null + const members = await prisma.user.findMany({ where: { adminRoleIds: { has: id } } }) + return { ...role, members } +} diff --git a/.jjconflict-side-1/apps/server/src/resources/admin-role/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/admin-role/router.spec.ts new file mode 100644 index 0000000000..dab0f79268 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/admin-role/router.spec.ts @@ -0,0 +1,184 @@ +import { ADMIN_PERMS, adminRoleContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListRolesMock = vi.spyOn(business, 'listRoles') +const businessCreateRoleMock = vi.spyOn(business, 'createRole') +const businessPatchRolesMock = vi.spyOn(business, 'patchRoles') +const businessCountRolesMembersMock = vi.spyOn(business, 'countRolesMembers') +const businessDeleteRoleMock = vi.spyOn(business, 'deleteRole') + +describe('test adminRoleContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listAdminRoles', () => { + it('should return list of admin roles', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const roles = [{ id: faker.string.uuid(), name: 'Role 1', oidcGroup: '', position: 0, permissions: '1', type: 'managed' }] + businessListRolesMock.mockResolvedValueOnce(roles) + + const response = await app.inject() + .get(adminRoleContract.listAdminRoles.path) + .end() + + expect(businessListRolesMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(roles) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('createAdminRole', () => { + it('should create a role for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + const newRole = { id: 'newRole', name: 'New Role' } + const roleData = { name: 'New Role' } + + authUserMock.mockResolvedValueOnce(user) + businessCreateRoleMock.mockResolvedValueOnce(newRole) + + const response = await app.inject() + .post(adminRoleContract.createAdminRole.path) + .body(roleData) + .end() + + expect(businessCreateRoleMock).toHaveBeenCalledWith(roleData, expect.any(String)) + expect(response.json()).toEqual(newRole) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(adminRoleContract.createAdminRole.path) + .body({ name: 'New Role' }) + .end() + + expect(businessCreateRoleMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('patchAdminRoles', () => { + const updatedRoles = [{ id: faker.string.uuid(), name: 'Role 1', oidcGroup: '', position: 0, permissions: '1', type: 'managed' }] + const rolesData = [{ id: updatedRoles[0].id, name: 'Updated Role', type: 'managed' }] + it('should update roles for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessPatchRolesMock.mockResolvedValueOnce(updatedRoles) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledWith(rolesData, expect.any(String)) + expect(response.json()).toEqual(updatedRoles) + expect(response.statusCode).toEqual(200) + }) + + it('should return error if business logic fails', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessPatchRolesMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledWith(rolesData, expect.any(String)) + expect(response.statusCode).toEqual(400) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('adminRoleMemberCounts', () => { + it('should return counts of role members for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + const counts = { role1: 5, role2: 3 } + + authUserMock.mockResolvedValueOnce(user) + businessCountRolesMembersMock.mockResolvedValueOnce(counts) + + const response = await app.inject() + .get(adminRoleContract.adminRoleMemberCounts.path) + .end() + + expect(businessCountRolesMembersMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(counts) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 if user is not admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(adminRoleContract.adminRoleMemberCounts.path) + .end() + + expect(businessCountRolesMembersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteAdminRole', () => { + const roleId = faker.string.uuid() + it('should delete a role for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessDeleteRoleMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .delete(adminRoleContract.deleteAdminRole.path.replace(':roleId', roleId)) + .end() + + expect(businessDeleteRoleMock).toHaveBeenCalledWith(roleId, expect.any(String)) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(adminRoleContract.deleteAdminRole.path.replace(':roleId', roleId)) + .end() + + expect(businessDeleteRoleMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/admin-role/router.ts b/.jjconflict-side-1/apps/server/src/resources/admin-role/router.ts new file mode 100644 index 0000000000..4141a2353e --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/admin-role/router.ts @@ -0,0 +1,78 @@ +import { AdminAuthorized, adminRoleContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + countRolesMembers, + createRole, + deleteRole, + listRoles, + patchRoles, +} from './business.js' + +export function adminRoleRouter() { + return serverInstance.router(adminRoleContract, { + // Récupérer des projets + listAdminRoles: async () => { + const body = await listRoles() + + return { + status: 200, + body, + } + }, + + createAdminRole: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await createRole(body, req.id) + + return { + status: 201, + body: resBody, + } + }, + + patchAdminRoles: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await patchRoles(body, req.id) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + adminRoleMemberCounts: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await countRolesMembers() + + return { + status: 200, + body: resBody, + } + }, + + deleteAdminRole: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await deleteRole(params.roleId, req.id) + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/admin-token/business.spec.ts b/.jjconflict-side-1/apps/server/src/resources/admin-token/business.spec.ts new file mode 100644 index 0000000000..2a698ac744 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/admin-token/business.spec.ts @@ -0,0 +1,73 @@ +import type { AdminToken } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { createToken, deleteToken, listTokens } from './business.ts' + +describe('test admin-token business', () => { + describe('listTokens', () => { + it('should stringify bigint', async () => { + const partialtoken: Partial = { + permissions: 4n, + } + + prisma.adminToken.findMany.mockResolvedValueOnce([partialtoken]) + const response = await listTokens({}) + expect(response).toEqual([{ permissions: '4' }]) + }) + it('should return revoked', async () => { + const partialtoken: Partial = { + permissions: 4n, + status: 'revoked', + } + + prisma.adminToken.findMany.mockResolvedValueOnce([partialtoken]) + const response = await listTokens({ withRevoked: true }) + expect(response).toEqual([{ ...partialtoken, permissions: '4' }]) + }) + }) + + describe('createToken', () => { + it('should create ', async () => { + const dbToken: Partial = undefined + const userId = faker.string.uuid() + const createdToken: AdminToken = { + expirationDate: null, + id: faker.string.uuid(), + name: 'test', + permissions: '2', + } + prisma.adminToken.findUnique.mockResolvedValueOnce(dbToken) + prisma.adminToken.create.mockResolvedValueOnce(createdToken) + await createToken({ name: 'test', permissions: '2', expirationDate: null }, userId, undefined) + + expect(prisma.adminToken.create).toHaveBeenCalledWith({ + data: { + name: 'test', + hash: expect.any(String), + permissions: 2n, + userId: expect.any(String), + expirationDate: undefined, + }, + omit: expect.any(Object), + include: { + owner: true, + }, + }) + }) + it('should not create cause expiration is too short', async () => { + const expirationDate = new Date() + await createToken({ name: 'test', permissions: '2', expirationDate: expirationDate.toISOString() }) + + expect(prisma.adminToken.create).toHaveBeenCalledTimes(0) + }) + }) + + describe('deleteToken', () => { + it('should delete token', async () => { + prisma.adminToken.delete.mockResolvedValueOnce(undefined) + await deleteToken(faker.string.uuid()) + expect(prisma.adminToken.updateMany).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/admin-token/business.ts b/.jjconflict-side-1/apps/server/src/resources/admin-token/business.ts new file mode 100644 index 0000000000..0702fde2b9 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/admin-token/business.ts @@ -0,0 +1,75 @@ +import type { adminTokenContract } from '@cpn-console/shared' +import type { $Enums, AdminToken, Prisma } from '@prisma/client' +import { createHash, randomUUID } from 'node:crypto' +import { logger as baseLogger } from '@cpn-console/logger' +import { generateRandomPassword, isAtLeastTomorrow } from '@cpn-console/shared' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../prisma.js' + +const logger = baseLogger.child({ scope: 'resource:admin-token' }) + +export async function listTokens(query: typeof adminTokenContract.listAdminTokens.query._type) { + const where = { + status: { + in: ['active'] as $Enums.TokenStatus[], + }, + } as const satisfies Prisma.AdminTokenWhereInput + + if (query?.withRevoked) where.status.in.push('revoked') + + return prisma.adminToken.findMany({ + omit: { hash: true }, + include: { owner: true }, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + where, + }).then(tokens => + tokens.map(({ permissions, ...token }) => ({ permissions: permissions.toString(), ...token })), + ) +} + +export async function createToken(data: typeof adminTokenContract.createAdminToken.body._type) { + if (data.expirationDate && !isAtLeastTomorrow(new Date(data.expirationDate))) { + return new BadRequest400('Date d\'expiration trop courte') + } + logger.info({ tokenName: data.name, expirationDate: data.expirationDate ?? null }, 'Create admin token started') + const password = generateRandomPassword(48, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-') + const hash = createHash('sha256').update(password).digest('hex') + const botUserId = randomUUID() + await prisma.user.create({ + data: { + firstName: 'Bot Admin', + lastName: data.name, + type: 'bot', + id: botUserId, + email: `${botUserId}@bot.io`, + }, + }) + const token = await prisma.adminToken.create({ + data: { + ...data, + hash, + permissions: BigInt(data.permissions), + expirationDate: data.expirationDate ? new Date(data.expirationDate) : undefined, + userId: botUserId, + }, + omit: { hash: true }, + include: { owner: true }, + }) + logger.info({ adminTokenId: token.id, botUserId, expirationDate: token.expirationDate ?? null, status: token.status }, 'Create admin token completed') + return { + ...token, + password, + permissions: token.permissions.toString(), + } +} + +export async function deleteToken(id: AdminToken['id']) { + logger.info({ adminTokenId: id }, 'Revoke admin token started') + return prisma.adminToken.updateMany({ + where: { id }, + data: { + status: 'revoked', + expirationDate: new Date(Date.now()), + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/admin-token/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/admin-token/router.spec.ts new file mode 100644 index 0000000000..61efec35fc --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/admin-token/router.spec.ts @@ -0,0 +1,161 @@ +import type { ExposedAdminToken } from '@cpn-console/shared' +import type { AdminToken } from '@prisma/client' +import { ADMIN_PERMS, adminTokenContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListTokensMock = vi.spyOn(business, 'listTokens') +const businessCreateTokenMock = vi.spyOn(business, 'createToken') +const businessDeleteTokenMock = vi.spyOn(business, 'deleteToken') + +describe('test adminTokenContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listAdminTokens', () => { + it('should return list of admin tokens', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_ADMIN_TOKEN) + authUserMock.mockResolvedValueOnce(user) + + const tokens: AdminToken[] = [{ + id: faker.string.uuid(), + name: 'token1', + permissions: '2', + lastUse: (new Date()).toISOString(), + expirationDate: null, + status: 'active', + createdAt: (new Date(Date.now())).toISOString(), + }] + businessListTokensMock.mockResolvedValueOnce(tokens) + + const response = await app.inject() + .get(adminTokenContract.listAdminTokens.path) + .end() + + expect(businessListTokensMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(tokens) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(adminTokenContract.listAdminTokens.path) + .end() + + expect(businessListTokensMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createAdminToken', () => { + it('should create a token for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + const newToken = { + id: faker.string.uuid(), + name: 'test', + lastUse: null, + expirationDate: null, + password: faker.string.alpha({ casing: 'lower', length: 10 }), + permissions: '2', + createdAt: (new Date(Date.now())).toISOString(), + status: 'active', + } + const tokenData: ExposedAdminToken = { + name: newToken.name, + permissions: newToken.permissions, + expirationDate: null, + } + + authUserMock.mockResolvedValueOnce(user) + businessCreateTokenMock.mockResolvedValueOnce(newToken) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body(tokenData) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledWith(tokenData) + expect(response.json()).toEqual(newToken) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body({ + name: 'new-token', + expirationDate: null, + permissions: '4', + }) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + authUserMock.mockResolvedValueOnce(user) + businessCreateTokenMock.mockResolvedValueOnce(new BadRequest400('Invalid date')) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body({ + name: 'new-token', + expirationDate: null, + permissions: '4', + }) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(400) + }) + }) + + describe('deleteAdminToken', () => { + const tokenId = faker.string.uuid() + it('should delete a token for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + authUserMock.mockResolvedValueOnce(user) + businessDeleteTokenMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .delete(adminTokenContract.deleteAdminToken.path.replace(':tokenId', tokenId)) + .end() + + expect(businessDeleteTokenMock).toHaveBeenCalledWith(tokenId) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(adminTokenContract.deleteAdminToken.path.replace(':tokenId', tokenId)) + .end() + + expect(businessDeleteTokenMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/admin-token/router.ts b/.jjconflict-side-1/apps/server/src/resources/admin-token/router.ts new file mode 100644 index 0000000000..305b6e527a --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/admin-token/router.ts @@ -0,0 +1,46 @@ +import { AdminAuthorized, adminTokenContract } from '@cpn-console/shared' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { serverInstance } from '../../app.js' +import { createToken, deleteToken, listTokens } from './business.js' + +export function adminTokenRouter() { + return serverInstance.router(adminTokenContract, { + listAdminTokens: async ({ request: req, query }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListAdminToken(perms.adminPermissions)) return new Forbidden403() + const body = await listTokens(query) + + return { + status: 200, + body, + } + }, + + createAdminToken: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageAdminToken(perms.adminPermissions)) return new Forbidden403() + const body = await createToken(data) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + deleteAdminToken: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageAdminToken(perms.adminPermissions)) return new Forbidden403() + await deleteToken(params.tokenId) + + return { + status: 204, + body: null, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/cluster/business.spec.ts b/.jjconflict-side-1/apps/server/src/resources/cluster/business.spec.ts new file mode 100644 index 0000000000..c003e66ae1 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/cluster/business.spec.ts @@ -0,0 +1,171 @@ +import type { Cluster, Environment } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400, ErrorResType, NotFound404, Unprocessable422 } from '../../utils/errors.ts' +import { createCluster, deleteCluster, getClusterAssociatedEnvironments, getClusterDetails, getClusterUsage, listClusters, updateCluster } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const userId = faker.string.uuid() +const requestId = faker.string.uuid() +const cluster: Cluster = { + id: faker.string.uuid(), + infos: faker.lorem.lines(2), + privacy: 'public', + createdAt: new Date(), + updatedAt: new Date(), + zoneId: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + label: faker.string.alpha(10), + secretName: faker.string.alpha(10), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), +} +describe('test Cluster business logic', () => { + describe('listClusters', () => { + it('should filter for user', async () => { + prisma.cluster.findMany.mockResolvedValue([]) + await listClusters(userId) + expect(prisma.cluster.findMany).toHaveBeenCalledTimes(1) + expect(prisma.cluster.findMany).toHaveBeenCalledWith({ select: expect.any(Object), where: { OR: [{ privacy: 'public' }, expect.any(Object), expect.any(Object), expect.any(Object)] } }) + }) + it('should not filter', async () => { + const dbStages = [{ id: faker.string.uuid() }] + prisma.cluster.findMany.mockResolvedValue([{ stages: dbStages }] as unknown as Cluster[]) + const response = await listClusters() + expect(prisma.cluster.findMany).toHaveBeenCalledTimes(1) + expect(prisma.cluster.findMany).toHaveBeenCalledWith({ select: expect.any(Object), where: {} }) + expect(response[0].stageIds).toStrictEqual([dbStages[0].id]) + }) + }) + + describe('getClusterAssociatedEnvironments', () => { + it('should list all environments attached to a cluster', async () => { + const envName = faker.string.alpha(8) + const projectName = faker.string.alpha(8) + const ownerEmail = faker.internet.email() + const cpu = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const gpu = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const memory = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const envs = [{ name: envName, cpu, gpu, memory, project: { name: projectName, owner: { email: ownerEmail } } }] as unknown as Environment[] + prisma.environment.findMany.mockResolvedValue(envs) + const response = await getClusterAssociatedEnvironments(cluster.id) + expect(response).toStrictEqual([{ + name: envName, + project: projectName, + owner: ownerEmail, + cpu, + gpu, + memory, + }]) + }) + }) + + describe('getClusterDetails', () => { + it('should return a cluster details', async () => { + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + await getClusterDetails(cluster.id) + }) + it('should return a cluster details, without infos in db', async () => { + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, infos: null, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + const response = await getClusterDetails(cluster.id) + expect(response.infos).toBe('') + }) + }) + + describe('getClusterUsage', () => { + it('should return a cluster usage', async () => { + prisma.environment.aggregate.mockResolvedValue({ _count: {}, _avg: {}, _min: {}, _max: {}, _sum: { + cpu: 10, + gpu: 5, + memory: 20, + } }) + const response = await getClusterUsage(cluster.id) + expect(response).toStrictEqual({ + cpu: 10, + gpu: 5, + memory: 20, + }) + }) + }) + + describe('createCluster', () => { + it('should create cluster', async () => { + hook.cluster.upsert.mockResolvedValue({ failed: false }) + prisma.cluster.findUnique.mockResolvedValue(null) + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + prisma.cluster.create.mockResolvedValue(cluster) + + const response = await createCluster({ + infos: faker.string.alpha(10), + zoneId: faker.string.uuid(), + privacy: 'public', + stageIds: [], + clusterResources: false, + kubeconfig: { cluster: { tlsServerName: faker.internet.domainName() }, user: {} }, + label: faker.string.alpha(10), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + }, userId, requestId) + + expect(response).not.instanceOf(ErrorResType) + expect(prisma.cluster.create).toHaveBeenCalled() + }) + }) + + describe('updateCluster', () => { + it('should update cluster', async () => { + hook.cluster.upsert.mockResolvedValue({ failed: false }) + prisma.cluster.findUnique.mockResolvedValue(cluster) + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + prisma.cluster.update.mockResolvedValue(cluster) + + const response = await updateCluster({ + infos: faker.string.alpha(10), + zoneId: faker.string.uuid(), + privacy: 'public', + stageIds: [], + }, cluster.id, userId, requestId) + + expect(response).not.instanceOf(ErrorResType) + expect(prisma.cluster.update).toHaveBeenCalled() + }) + it('should return 404', async () => { + prisma.cluster.findUnique.mockResolvedValue(null) + const response = await updateCluster({ infos: faker.string.alpha(10) }, cluster.id, userId, requestId) + expect(response).instanceOf(NotFound404) + }) + }) + + describe('deleteCluster', () => { + it('should delete cluster', async () => { + hook.cluster.delete.mockResolvedValue({}) + await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(prisma.cluster.delete).toHaveBeenCalledTimes(1) + }) + it('should return failed hook', async () => { + hook.cluster.delete.mockResolvedValue({ failed: true }) + const response = await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(response).instanceOf(Unprocessable422) + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + }) + it('should not delete cluster, env attached', async () => { + prisma.environment.findFirst.mockResolvedValue({ id: faker.string.uuid() } as Environment) + const response = await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) +}) + +// findUniqueOrThrow diff --git a/.jjconflict-side-1/apps/server/src/resources/cluster/business.ts b/.jjconflict-side-1/apps/server/src/resources/cluster/business.ts new file mode 100644 index 0000000000..ef542ce33a --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/cluster/business.ts @@ -0,0 +1,230 @@ +import type { Cluster, clusterContract, ClusterDetails, Kubeconfig } from '@cpn-console/shared' +import type { Prisma, Project, User } from '@prisma/client' +import type { Resources } from '@/types/index.js' +import { ClusterDetailsSchema, ClusterPrivacy } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + addLogs, + createCluster as createClusterQuery, + deleteCluster as deleteClusterQuery, + getClusterById, + getClusterByLabel, + getClusterDetails as getClusterDetailsQuery, + getClusterEnvironments, + getProjectsByClusterId, + linkClusterToProjects, + linkZoneToClusters, + listClusters as listClustersQuery, + listStagesByClusterId, + removeClusterFromProject, + removeClusterFromStage, + updateCluster as updateClusterQuery, +} from '@/resources/queries-index.js' +import { linkClusterToStages } from '@/resources/stage/business.js' +import { validateSchema } from '@/utils/business.js' +import { BadRequest400, ErrorResType, NotFound404, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function listClusters(userId?: User['id']) { + const where: Prisma.ClusterWhereInput = userId + ? { + OR: [ + // Sélectionne tous les clusters publics + { privacy: 'public' }, + // Sélectionne les clusters associés aux projets dont l'user est membre + { + projects: { some: { members: { some: { userId } } } }, + }, + // Sélectionne les clusters associés aux projets dont l'user est owner + { + projects: { some: { ownerId: userId } }, + }, + // Sélectionne les clusters associés aux environnments appartenant à des projets dont l'user est membre + { + environments: { some: { project: { members: { some: { userId } } } } }, + }, + ], + } + : {} + const clusters = await listClustersQuery(where) + return clusters.map(({ stages, ...cluster }) => ({ + ...cluster, + stageIds: stages.map(({ id }) => id), + })) +} + +export async function getClusterAssociatedEnvironments(clusterId: string) { + const clusterEnvironments = await getClusterEnvironments(clusterId) + + return clusterEnvironments.map((environment) => { + return ({ + project: environment.project?.name, + name: environment.name, + owner: environment.project.owner.email, + cpu: environment.cpu, + gpu: environment.gpu, + memory: environment.memory, + }) + }) +} + +export async function getClusterDetails(clusterId: string): Promise { + const { infos, projects, stages, kubeconfig, ...details } = await getClusterDetailsQuery(clusterId) + return { + ...details, + infos: infos ?? '', + projectIds: projects.map(project => project.id), + stageIds: stages.map(({ id }) => id), + kubeconfig: { + cluster: kubeconfig.cluster as unknown as Kubeconfig['cluster'], + user: kubeconfig.user as unknown as Kubeconfig['user'], + }, + } +} + +export async function getClusterUsage(clusterId: string): Promise { + const clusterUsage = await prisma.environment.aggregate({ + _sum: { + memory: true, + cpu: true, + gpu: true, + }, + where: { + clusterId, + }, + }) + return { + cpu: clusterUsage._sum.cpu ?? 0, + gpu: clusterUsage._sum.gpu ?? 0, + memory: clusterUsage._sum.memory ?? 0, + } +} + +export async function createCluster(data: typeof clusterContract.createCluster.body._type, userId: User['id'], requestId: string) { + const isLabelTaken = await getClusterByLabel(data.label) + if (isLabelTaken) return new BadRequest400('Ce label existe déjà pour un autre cluster') + + data.projectIds = data.privacy === ClusterPrivacy.PUBLIC + ? [] + : data.projectIds ?? [] + + const { + projectIds, + stageIds, + kubeconfig, + zoneId, + ...clusterData + } = data + + const clusterCreated = await createClusterQuery(clusterData, kubeconfig, zoneId) + + if (data.privacy === ClusterPrivacy.DEDICATED && projectIds.length) { + await linkClusterToProjects(clusterCreated.id, projectIds) + } + + if (stageIds?.length) { + await linkClusterToStages(clusterCreated.id, stageIds) + } + + const hookReply = await hook.cluster.upsert(clusterCreated.id, zoneId) + await addLogs({ action: 'Create Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la création du cluster') + } + + return getClusterDetails(clusterCreated.id) +} + +export async function updateCluster(data: typeof clusterContract.updateCluster.body._type, clusterId: Cluster['id'], userId: User['id'], requestId: string): Promise { + if (data?.privacy === ClusterPrivacy.PUBLIC) delete data.projectIds + + const schemaValidation = ClusterDetailsSchema.partial().safeParse({ ...data, id: clusterId }) + const validateResult = validateSchema(schemaValidation) + if (validateResult instanceof ErrorResType) return validateResult + + const dbCluster = await getClusterById(clusterId) + if (!dbCluster) return new NotFound404() + + const { + projectIds, + stageIds, + kubeconfig, + zoneId, + ...clusterData + } = data + + const clusterUpdated = await updateClusterQuery(clusterId, clusterData, + // @ts-ignore + kubeconfig) + + // zone + if (zoneId) { + await linkZoneToClusters(zoneId, [clusterId]) + } + + // projects + const dbProjects = await getProjectsByClusterId(clusterId) + + let projectsToRemove: Project['id'][] = [] + + if (projectIds && clusterUpdated.privacy === ClusterPrivacy.DEDICATED) { + await linkClusterToProjects(clusterId, projectIds) + projectsToRemove = dbProjects?.map(project => project.id)?.filter(dbProjectId => !projectIds.includes(dbProjectId)) ?? [] + } else if (clusterUpdated.privacy === ClusterPrivacy.PUBLIC) { + projectsToRemove = dbProjects?.map(project => project.id) ?? [] + } + + for (const projectId of projectsToRemove) { + await removeClusterFromProject(clusterUpdated.id, projectId) + } + + // stages + if (stageIds) { + await linkClusterToStages(clusterId, stageIds) + + const dbStages = await listStagesByClusterId(clusterId) + if (dbStages) { + for (const stage of dbStages) { + if (!stageIds.includes(stage.id)) { + await removeClusterFromStage(clusterUpdated.id, stage.id) + } + } + } + } + + const hookReply = await hook.cluster.upsert(clusterId, dbCluster.zoneId) + await addLogs({ action: 'Update Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la mise à jour du cluster') + } + + return getClusterDetails(clusterId) +} + +interface DeleteClusterArgs { + clusterId: Cluster['id'] + userId?: User['id'] + requestId: string + force?: boolean +} +export async function deleteCluster({ clusterId, requestId, force, userId }: DeleteClusterArgs) { + let message: string | null = null + if (force) { + const envs = await prisma.environment.deleteMany({ + where: { clusterId }, + }) + message = `${envs.count} environnements supprimés de force, n'oubliez pas de reprovisionner les projets concernés` + } else { + const environment = await prisma.environment.findFirst({ where: { clusterId } }) + if (environment) return new BadRequest400('Impossible de supprimer le cluster, des environnements en activité y sont déployés') + } + + const hookReply = await hook.cluster.delete(clusterId) + await addLogs({ action: 'Delete Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la suppression du cluster') + } + + await deleteClusterQuery(clusterId) + return message +} diff --git a/.jjconflict-side-1/apps/server/src/resources/cluster/queries.ts b/.jjconflict-side-1/apps/server/src/resources/cluster/queries.ts new file mode 100644 index 0000000000..f66e1fc0bc --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/cluster/queries.ts @@ -0,0 +1,309 @@ +import type { Cluster, Environment, Kubeconfig, Prisma, Project, Stage } from '@prisma/client' +import prisma from '@/prisma.js' + +export async function getClustersAssociatedWithProject(projectId: Project['id']) { + const [ + clusterIdsHistory, + clusterIdsEnv, + ] = await Promise.all([ + prisma.projectClusterHistory.findMany({ + select: { + clusterId: true, + }, + where: { + projectId, + }, + }).then(history => history.map(({ clusterId }) => clusterId)), + prisma.cluster.findMany({ + where: { environments: { some: { project: { id: projectId } } } }, + select: { id: true }, + }).then(cluster => cluster.map(({ id }) => id)), + ]) + const clusterIds = [ + ...clusterIdsHistory, + ...clusterIdsEnv.filter(id => !clusterIdsHistory.includes(id)), + ] + return prisma.cluster.findMany({ + where: { id: { in: clusterIds } }, + select: { + id: true, + infos: true, + label: true, + privacy: true, + secretName: true, + kubeconfig: true, + clusterResources: true, + cpu: true, + gpu: true, + memory: true, + zone: { + select: { + id: true, + slug: true, + argocdUrl: true, + label: true, + }, + }, + }, + }) +} + +export async function updateProjectClusterHistory(projectId: Project['id'], clusterIds: Cluster['id'][]) { + return prisma.$transaction([ + prisma.projectClusterHistory.deleteMany({ + where: { + AND: { + projectId, + clusterId: { notIn: clusterIds }, + }, + }, + }), + prisma.projectClusterHistory.createMany({ + data: clusterIds.map(clusterId => ({ clusterId, projectId })), + skipDuplicates: true, + }), + ]) +} + +export function getClusterById(id: Cluster['id']) { + return prisma.cluster.findUnique({ + where: { id }, + include: { kubeconfig: true }, + }) +} + +export function getClusterByIdOrThrow(id: Cluster['id']) { + return prisma.cluster.findUniqueOrThrow({ + where: { id }, + include: { kubeconfig: true, zone: true }, + }) +} + +export function getClusterEnvironments(clusterId: Cluster['id']) { + return prisma.environment.findMany({ + where: { clusterId }, + select: { + name: true, + cpu: true, + gpu: true, + memory: true, + project: { + select: { + slug: true, + name: true, + owner: true, + members: true, + }, + }, + }, + }) +} + +export function getClusterDetails(id: Cluster['id']) { + return prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { + createdAt: true, + projects: { + select: { + id: true, + }, + }, + id: true, + clusterResources: true, + infos: true, + label: true, + privacy: true, + kubeconfig: true, + stages: true, + updatedAt: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export function getClustersByIds(clusterIds: Cluster['id'][]) { + return prisma.cluster.findMany({ + where: { + id: { in: clusterIds }, + }, + include: { kubeconfig: true }, + }) +} + +export function getPublicClusters() { + return prisma.cluster.findMany({ + where: { privacy: 'public' }, + include: { zone: true }, + }) +} + +export async function getClusterNamesByZoneId(zoneId: string) { + const clusterNames = await prisma.cluster.findMany({ + where: { zoneId }, + select: { + label: true, + }, + }) + return clusterNames.map(({ label }) => label) +} + +export function getClusterByLabel(label: Cluster['label']) { + return prisma.cluster.findUnique({ where: { label } }) +} + +export function getClusterByEnvironmentId(id: Environment['id']) { + return prisma.cluster.findMany({ + where: { + environments: { + some: { id }, + }, + }, + include: { kubeconfig: true }, + }) +} + +export function getClustersWithProjectIdAndConfig() { + return prisma.cluster.findMany({ + select: { + id: true, + stages: true, + projects: { + where: { + status: { not: 'archived' }, + }, + select: { + id: true, + name: true, + slug: true, + status: true, + }, + }, + clusterResources: true, + label: true, + infos: true, + privacy: true, + secretName: true, + kubeconfig: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export function listClusters(where: Prisma.ClusterWhereInput) { + return prisma.cluster.findMany({ + where, + select: { + id: true, + label: true, + stages: true, + clusterResources: true, + privacy: true, + infos: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export async function getProjectsByClusterId(id: Cluster['id']) { + return (await prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { projects: true }, + }))?.projects +} + +export async function listStagesByClusterId(id: Cluster['id']) { + return (await prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { stages: true }, + }))?.stages +} + +export function createCluster(data: Omit, kubeconfig: Pick, zoneId: string) { + return prisma.cluster.create({ + data: { + ...data, + // @ts-ignore + kubeconfig: { create: kubeconfig }, + zone: { + connect: { id: zoneId }, + }, + }, + }) +} + +export function updateCluster(id: Cluster['id'], data: Partial>, kubeconfig: Pick) { + return prisma.cluster.update({ + where: { id }, + data: { + ...data, + kubeconfig: { + // @ts-ignore + update: kubeconfig, + }, + }, + }) +} + +export function linkClusterToProjects(id: Cluster['id'], projectIds: Project['id'][]) { + return prisma.cluster.update({ + where: { id }, + data: { + projects: { + connect: projectIds.map(projectId => ({ id: projectId })), + }, + }, + }) +} + +export function linkClusterToStages(id: Cluster['id'], stageIds: Stage['id'][]) { + return prisma.cluster.update({ + where: { id }, + data: { + stages: { + connect: stageIds.map(stageId => ({ id: stageId })), + }, + }, + }) +} + +export function removeClusterFromProject(id: Cluster['id'], projectId: Project['id']) { + return prisma.cluster.update({ + where: { id }, + data: { + projects: { + disconnect: { + id: projectId, + }, + }, + }, + }) +} + +export function removeClusterFromStage(id: Cluster['id'], stageId: Stage['id']) { + return prisma.cluster.update({ + where: { id }, + data: { + stages: { + disconnect: { + id: stageId, + }, + }, + }, + }) +} + +export function deleteCluster(id: Cluster['id']) { + return prisma.cluster.delete({ + where: { id }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/cluster/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/cluster/router.spec.ts new file mode 100644 index 0000000000..528858b052 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/cluster/router.spec.ts @@ -0,0 +1,308 @@ +import type { ClusterDetails, Environment } from '@cpn-console/shared' +import { ADMIN_PERMS, clusterContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listClusters') +const businessGetDetailsMock = vi.spyOn(business, 'getClusterDetails') +const businessGetUsageMock = vi.spyOn(business, 'getClusterUsage') +const businessGetEnvironmentsMock = vi.spyOn(business, 'getClusterAssociatedEnvironments') +const businessCreateMock = vi.spyOn(business, 'createCluster') +const businessUpdateMock = vi.spyOn(business, 'updateCluster') +const businessDeleteMock = vi.spyOn(business, 'deleteCluster') + +describe('test clusterContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listClusters', () => { + it('as non admin', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + businessListMock.mockResolvedValueOnce([]) + const response = await app.inject() + .get(clusterContract.listClusters.path) + .end() + + expect(businessListMock).toHaveBeenCalledWith(user.user.id) + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('as admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + + authUserMock.mockResolvedValueOnce(user) + + businessListMock.mockResolvedValueOnce([]) + const response = await app.inject() + .get(clusterContract.listClusters.path) + .end() + + expect(businessListMock).toHaveBeenCalledWith() + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getClusterDetails', () => { + it('should return cluster details', async () => { + const cluster: ClusterDetails = { + id: faker.string.uuid(), + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetDetailsMock.mockResolvedValueOnce(cluster) + const response = await app.inject() + .get(clusterContract.getClusterDetails.path.replace(':clusterId', cluster.id)) + .end() + + expect(businessGetDetailsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(cluster) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterDetails.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetDetailsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getClusterUsage', () => { + it('should return cluster usage', async () => { + const resources = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetUsageMock.mockResolvedValueOnce(resources) + const response = await app.inject() + .get(clusterContract.getClusterUsage.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetUsageMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(resources) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterUsage.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetUsageMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getClusterEnvironments', () => { + it('should return cluster environments', async () => { + const envs: Environment[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(envs) + const response = await app.inject() + .get(clusterContract.getClusterEnvironments.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterEnvironments.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createCluster', () => { + const cluster: ClusterDetails = { + id: faker.string.uuid(), + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + + it('should return created cluster', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(cluster) + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.json()).toEqual(cluster) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateCluster', () => { + const clusterId = faker.string.uuid() + const cluster: Omit = { + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + + it('should return created cluster', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: clusterId, ...cluster }) + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.json()).toEqual({ id: clusterId, ...cluster }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteCluster', () => { + it('should return empty when delete', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/cluster/router.ts b/.jjconflict-side-1/apps/server/src/resources/cluster/router.ts new file mode 100644 index 0000000000..3924bce6a9 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/cluster/router.ts @@ -0,0 +1,131 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, clusterContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + createCluster, + deleteCluster, + getClusterAssociatedEnvironments, + getClusterDetails as getClusterDetailsBusiness, + getClusterUsage, + listClusters, + updateCluster, +} from './business.js' +import '@/types/index.js' + +export function clusterRouter() { + return serverInstance.router(clusterContract, { + listClusters: async ({ request: req }) => { + const { adminPermissions, user } = await authUser(req) + + let body: AsyncReturnType = [] + if (AdminAuthorized.ListClusters(adminPermissions)) { + body = await listClusters() + } else if (user) { + body = await listClusters(user.id) + } + + return { + status: 200, + body, + } + }, + + getClusterDetails: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const cluster = await getClusterDetailsBusiness(clusterId) + + return { + status: 200, + body: cluster, + } + }, + + getClusterUsage: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const usage = await getClusterUsage(clusterId) + + return { + status: 200, + body: usage, + } + }, + + createCluster: async ({ request: req, body: data }) => { + const { adminPermissions, user } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + + if (!user) return new Unauthorized401('Require to be requested from user not api key') + const body = await createCluster(data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + getClusterEnvironments: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const environments = await getClusterAssociatedEnvironments(clusterId) + + return { + status: 200, + body: environments, + } + }, + + updateCluster: async ({ request: req, params, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const clusterId = params.clusterId + const body = await updateCluster(data, clusterId, user.id, req.id) + + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + deleteCluster: async ({ request: req, params, query: { force } }) => { + const { user, adminPermissions, tokenId } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + if (!user?.id && !tokenId) return new Unauthorized401('Your identity has not been found') + + const clusterId = params.clusterId + const body = await deleteCluster({ + clusterId, + userId: user?.id, + requestId: req.id, + force, + }) + + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/environment/business.spec.ts b/.jjconflict-side-1/apps/server/src/resources/environment/business.spec.ts new file mode 100644 index 0000000000..7bfebb5b2e --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/environment/business.spec.ts @@ -0,0 +1,353 @@ +import type { Cluster, Environment, Project, ProjectMembers, ProjectRole, Stage, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { Result } from '../../utils/business.js' +import { checkClusterResources, checkProjectResources, createEnvironment, deleteEnvironment, getProjectEnvironments, updateEnvironment } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const user: User = { + id: faker.string.uuid(), + createdAt: new Date(), + updatedAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + lastLogin: null, +} +const project: Project & { + clusters: Pick[] + members: ProjectMembers[] + roles: ProjectRole[] + owner: User +} = { + createdAt: new Date(), + updatedAt: new Date(), + description: '', + everyonePerms: 649n, + id: faker.string.uuid(), + locked: false, + name: faker.string.alphanumeric(8), + status: 'created', + ownerId: faker.string.uuid(), + owner: user, + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + clusters: [], + roles: [], + members: [], + slug: faker.string.alphanumeric(8), + lastSuccessProvisionningVersion: faker.string.numeric(), +} + +describe('test environment business', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getProjectEnvironments', () => { + it('should query environment for projectId', async () => { + prisma.environment.findMany.mockResolvedValue([]) + const projectId = faker.string.uuid() + await getProjectEnvironments(projectId) + + expect(prisma.environment.findMany).toHaveBeenCalledTimes(1) + }) + }) + + describe('createEnvironment', () => { + const clusterId = faker.string.uuid() + const stageId = faker.string.uuid() + const env = { name: 'new-env' } + it('should create environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const stageId = faker.string.uuid() + + prisma.environment.create.mockResolvedValue({ clusterId } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await createEnvironment({ + userId: user.id, + projectId: project.id, + name: env.name, + cpu: 0.1, + gpu: 0.5, + memory: 2.0, + clusterId, + stageId, + requestId, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should create environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + + prisma.environment.create.mockResolvedValue({ clusterId } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await createEnvironment({ + userId: user.id, + projectId: project.id, + name: env.name, + cpu: 0.1, + gpu: 0.5, + memory: 2.0, + clusterId, + stageId, + requestId, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('updateEnvironment', () => { + it('should update environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.update.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await updateEnvironment({ + user, + environmentId, + requestId, + cpu: 2.0, + gpu: 4.0, + memory: 12.5, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should update environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.update.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await updateEnvironment({ + user, + environmentId, + requestId, + cpu: 2.0, + gpu: 4.0, + memory: 12.5, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('deleteEnvironment', () => { + it('should delete environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.delete.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await deleteEnvironment({ environmentId, userId: user.id, projectId: project.id, requestId }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.delete).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should delete environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.delete.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await deleteEnvironment({ environmentId, userId: user.id, projectId: project.id, requestId }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.delete).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('checkClusterResources', () => { + it('should authorize cluster not yet configured', async () => { + const cluster: Cluster = { + cpu: 0, + gpu: 0, + memory: 0, + } as Cluster + const result = await checkClusterResources({ cpu: 1, gpu: 0, memory: 1 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should authorize cluster not yet used', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 0, + gpu: 0, + memory: 0, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 7 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should authorize cluster used but not full', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 2, + gpu: 0, + memory: 2, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 6 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should refuse cluster without enough space', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 5, + gpu: 0, + memory: 5, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 6 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le cluster ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + it('should refuse cluster without GPU', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 2, + gpu: 0, + memory: 2, + }, + } as any) + const result = await checkClusterResources({ cpu: 2, gpu: 1, memory: 2 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le cluster ne dispose pas de suffisamment de ressources : GPU.') + }) + }) + + describe('checkProjectResources', () => { + const prodStage: Stage = { + id: faker.string.uuid(), + name: 'prod', + } + const hprodStage: Stage = { + id: faker.string.uuid(), + name: 'hprod', + } + it('should authorize prod deployment for project with hprod resource but no prod resources', async () => { + const project: Project = { + hprodCpu: 10, + hprodGpu: 10, + hprodMemory: 10, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + } as Project + prisma.stage.findUnique.mockResolvedValue(prodStage) + prisma.stage.findMany.mockResolvedValue([prodStage]) + const result = await checkProjectResources({ cpu: 1, gpu: 0, memory: 1, stageId: prodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should refuse hprod deployment for project with hprod resource but no prod resources', async () => { + const project: Project = { + hprodCpu: 10, + hprodGpu: 10, + hprodMemory: 10, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + } as Project + prisma.stage.findUnique.mockResolvedValue(hprodStage) + prisma.stage.findMany.mockResolvedValue([prodStage] as Stage[]) + prisma.environment.aggregate.mockResolvedValue({ + _sum: { cpu: 0, gpu: 0, memory: 0 }, + } as any) + const result = await checkProjectResources({ cpu: 20, gpu: 20, memory: 20, stageId: hprodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le projet ne dispose pas de suffisamment de ressources : CPU, GPU, Mémoire.') + }) + it('should refuse overloading hprod deployment', async () => { + const project: Project = { + hprodCpu: 20, + hprodGpu: 20, + hprodMemory: 20, + prodCpu: 10, + prodGpu: 10, + prodMemory: 10, + } as Project + prisma.stage.findUnique.mockResolvedValue(hprodStage) + prisma.stage.findMany.mockResolvedValue([prodStage] as Stage[]) + prisma.environment.aggregate.mockResolvedValue({ + _sum: { cpu: 15, gpu: 15, memory: 15 }, + } as any) + const result = await checkProjectResources({ cpu: 5, gpu: 6, memory: 5, stageId: hprodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le projet ne dispose pas de suffisamment de ressources : GPU.') + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/environment/business.ts b/.jjconflict-side-1/apps/server/src/resources/environment/business.ts new file mode 100644 index 0000000000..2f068dedf8 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/environment/business.ts @@ -0,0 +1,306 @@ +import type { Cluster, Environment, Project, Stage, User } from '@prisma/client' +import type { Resources, UserDetails } from '@/types/index.js' +import prisma from '@/prisma.js' +import { + addLogs, + deleteEnvironment as deleteEnvironmentQuery, + getEnvironmentsByProjectId, + initializeEnvironment, + updateEnvironment as updateEnvironmentQuery, +} from '@/resources/queries-index.js' +import { Result } from '@/utils/business.js' +import { hook } from '@/utils/hook-wrapper.js' + +export function getProjectEnvironments(projectId: Project['id']) { + return getEnvironmentsByProjectId(projectId) +} + +// Routes logic +interface CreateEnvironmentParam { + userId: User['id'] + projectId: Project['id'] + name: Environment['name'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + clusterId: Environment['clusterId'] + stageId: Stage['id'] + requestId: string +} + +interface CreateEnvironmentResult { + id: Environment['id'] + createdAt: Date + updatedAt: Date + projectId: Project['id'] + name: Environment['name'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + clusterId: Environment['clusterId'] + stageId: Stage['id'] +} + +export async function createEnvironment({ + userId, + projectId, + name, + cpu, + gpu, + memory, + autosync, + clusterId, + stageId, + requestId, +}: CreateEnvironmentParam): Promise> { + const environment = await initializeEnvironment({ projectId, name, cpu, gpu, memory, autosync, clusterId, stageId }) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Create Environment', data: results, userId, requestId, projectId }) + if (results.failed) { + return Result.fail('Echec des services à la création de l\'environnement') + } + + return Result.succeed({ + ...environment, + stageId, + }) +} + +interface UpdateEnvironmentParam { + user: UserDetails + environmentId: Environment['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + requestId: string +} + +export async function updateEnvironment({ + user, + environmentId, + requestId, + cpu, + gpu, + memory, + autosync, +}: UpdateEnvironmentParam) { + const env = await updateEnvironmentQuery({ + id: environmentId, + cpu, + gpu, + memory, + autosync, + }) + const { results } = await hook.project.upsert(env.projectId) + await addLogs({ action: 'Update Environment', data: results, userId: user.id, requestId, projectId: env.projectId }) + if (results.failed) { + return Result.fail('Echec des services à la mise à jour de l\'environnement') + } + + return Result.succeed(env) +} + +interface DeleteEnvironmentParam { + userId?: User['id'] + environmentId: Environment['id'] + projectId: Project['id'] + requestId: string +} + +export async function deleteEnvironment({ + userId, + environmentId, + projectId, + requestId, +}: DeleteEnvironmentParam) { + const env = await deleteEnvironmentQuery(environmentId) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Delete Environment', data: results, userId, requestId, projectId: env.projectId }) + if (results.failed) { + return Result.fail('Echec des services à la suppression de l\'environnement') + } + return Result.succeed(null) +} + +export async function checkEnvironmentCreate(input: { + clusterId: Cluster['id'] + projectId: Project['id'] + name: Environment['name'] + stageId: Stage['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}): Promise> { + const errorMessages: string[] = [] + const [stage, sameNameEnvironment, cluster] = await Promise.all([ + input.stageId + ? prisma.stage.findUnique({ where: { id: input.stageId } }) + : undefined, + input.name + ? prisma.environment.findUnique({ where: { projectId_name: { projectId: input.projectId, name: input.name } } }) + : undefined, + input.clusterId + ? prisma.cluster.findFirst({ + where: { + OR: [{ // un cluster public + id: input.clusterId, + privacy: 'public', + }, { + id: input.clusterId, // un cluster dédié rattaché au projet + privacy: 'dedicated', + projects: { some: { id: input.projectId } }, + }], + }, + }) + : undefined, + ]) + if (sameNameEnvironment) errorMessages.push('Ce nom d\'environnement est déjà pris.') + if (!stage) errorMessages.push('Type d\'environnment invalide.') + if (!cluster) { + errorMessages.push('Cluster invalide.') + } else { + const resourceCheckResult = await checkClusterResources(input, cluster) + if (resourceCheckResult.isError) { + errorMessages.push(resourceCheckResult.error) + } + const project = await prisma.project.findUniqueOrThrow({ where: { id: input.projectId } }) + const projectCheckResult = await checkProjectResources(input, project) + if (projectCheckResult.isError) { + errorMessages.push(projectCheckResult.error) + } + } + if (errorMessages.length > 0) { + return Result.fail(errorMessages.join('\n')) + } + return Result.succeed(true) +} + +export async function checkClusterResources(input: { + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}, cluster: Cluster): Promise> { + if (cluster.cpu === 0 && cluster.memory === 0) { + // Unconfigured cluster + return Result.succeed(true) + } + const unsufficientResource = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: cluster.cpu, gpu: cluster.gpu, memory: cluster.memory }, + where: { + cluster: { + id: cluster.id, + }, + }, + }) + if (unsufficientResource.length > 0) { + return Result.fail(`Le cluster ne dispose pas de suffisamment de ressources : ${unsufficientResource.join(', ')}.`) + } + return Result.succeed(true) +} + +export async function checkProjectResources(input: { + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + stageId: Environment['stageId'] +}, project: Project): Promise> { + if (project.limitless) { + // No limits + return Result.succeed(true) + } + const stage = await prisma.stage.findUnique({ where: { id: input.stageId } }) + const prodStages = await prisma.stage.findMany({ select: { id: true }, where: { name: 'prod' } }) + let overflowResources: string[] + if (stage?.name === 'prod') { + overflowResources = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: project.prodCpu, gpu: project.prodGpu, memory: project.prodMemory }, + where: { + projectId: project.id, + stageId: { + in: prodStages.map(s => s.id), + }, + }, + }) + } else { // hprod + overflowResources = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: project.hprodCpu, gpu: project.hprodGpu, memory: project.hprodMemory }, + where: { + projectId: project.id, + stageId: { + notIn: prodStages.map(s => s.id), + }, + }, + }) + } + if (overflowResources.length > 0) { + return Result.fail(`Le projet ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.`) + } + return Result.succeed(true) +} + +export async function checkEnvironmentUpdate(input: { + environmentId: Environment['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}): Promise> { + const environment = await prisma.environment.findUniqueOrThrow({ + select: { cluster: true, projectId: true, stageId: true }, + where: { id: input.environmentId }, + }) + const cluster = await prisma.cluster.findUniqueOrThrow({ + where: { id: environment.cluster.id }, + }) + const errorMessages: string[] = [] + const resourceCheckResult = await checkClusterResources(input, cluster) + if (resourceCheckResult.isError) { + errorMessages.push(resourceCheckResult.error) + } + const project = await prisma.project.findUniqueOrThrow({ where: { id: environment.projectId } }) + const projectCheckResult = await checkProjectResources({ stageId: environment.stageId, ...input }, project) + if (projectCheckResult.isError) { + errorMessages.push(projectCheckResult.error) + } + if (errorMessages.length > 0) { + return Result.fail(errorMessages.join('\n')) + } + return Result.succeed(true) +} + +export async function getOverflowResources({ request, limit, where }: { + request: Resources + limit: Resources + where: any +}): Promise { + if (limit.cpu === 0 && limit.memory === 0) { + // Unconfigured project prod resources + return [] + } + const environmentResources = await prisma.environment.aggregate({ + _sum: { + memory: true, + cpu: true, + gpu: true, + }, + where, + }) + const unsufficientResource: string[] = [] + if ((environmentResources._sum.cpu ?? 0) + request.cpu > limit.cpu) { + unsufficientResource.push('CPU') + } + if ((environmentResources._sum.gpu ?? 0) + request.gpu > limit.gpu) { + unsufficientResource.push('GPU') + } + if ((environmentResources._sum.memory ?? 0) + request.memory > limit.memory) { + unsufficientResource.push('Mémoire') + } + return unsufficientResource +} diff --git a/.jjconflict-side-1/apps/server/src/resources/environment/queries.ts b/.jjconflict-side-1/apps/server/src/resources/environment/queries.ts new file mode 100644 index 0000000000..6b228e2687 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/environment/queries.ts @@ -0,0 +1,99 @@ +import type { Environment, Prisma, Project } from '@prisma/client' +import prisma from '@/prisma.js' + +// SELECT +export function getEnvironmentByIdOrThrow(id: Environment['id']) { + return prisma.environment.findUniqueOrThrow({ where: { id }, include: { stage: true } }) +} + +export function getEnvironmentInfos(id: Environment['id']) { + return prisma.environment.findUniqueOrThrow({ + where: { id }, + include: { + project: { + select: { + owner: true, + name: true, + id: true, + status: true, + repositories: { + where: { isInfra: true }, + }, + locked: true, + clusters: { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + }, + }, + }, + }, + stage: true, + }, + }) +} + +export async function getEnvironmentsByProjectId(projectId: Project['id']) { + return prisma.environment.findMany({ + where: { projectId }, + include: { + stage: true, + }, + }) +} + +export function getEnvironmentByIdWithCluster(id: Environment['id']) { + return prisma.environment.findUnique({ + where: { id }, + include: { + cluster: { + include: { kubeconfig: true }, + }, + }, + }) +} + +// INSERT +export function initializeEnvironment(data: Prisma.EnvironmentUncheckedCreateInput) { + return prisma.environment.create({ + data, + include: { + project: { + include: { + repositories: { + where: { isInfra: true }, + }, + }, + }, + }, + }) +} + +export function updateEnvironment({ id, cpu, gpu, memory, autosync }: { id: Environment['id'], cpu: Environment['cpu'], gpu: Environment['gpu'], memory: Environment['memory'], autosync: Environment['autosync'] }) { + return prisma.environment.update({ + where: { + id, + }, + data: { + cpu, + gpu, + memory, + autosync, + }, + }) +} + +// DELETE +export function deleteEnvironment(id: Environment['id']) { + return prisma.environment.delete({ + where: { id }, + }) +} + +export function deleteAllEnvironmentForProject(id: Project['id']) { + return prisma.environment.deleteMany({ + where: { projectId: id }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/environment/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/environment/router.spec.ts new file mode 100644 index 0000000000..2fbff26774 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/environment/router.spec.ts @@ -0,0 +1,340 @@ +import type { Environment } from '@cpn-console/shared' +import { environmentContract, PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { atDates, getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetProjectEnvironmentsMock = vi.spyOn(business, 'getProjectEnvironments') +const businessCreateEnvironmentMock = vi.spyOn(business, 'createEnvironment') +const businessUpdateEnvironmentMock = vi.spyOn(business, 'updateEnvironment') +const businessDeleteEnvironmentMock = vi.spyOn(business, 'deleteEnvironment') +const businessCheckEnvironmentCreateMock = vi.spyOn(business, 'checkEnvironmentCreate') +const businessCheckEnvironmentUpdateMock = vi.spyOn(business, 'checkEnvironmentUpdate') + +describe('environmentRouter tests', () => { + let projectId: string + let environmentId: string + let environmentData: Omit + + beforeEach(() => { + vi.resetAllMocks() + projectId = faker.string.uuid() + environmentId = faker.string.uuid() + environmentData = { + projectId, + name: 'envname', + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + } + }) + + describe('listEnvironments', () => { + it('should return environments for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetProjectEnvironmentsMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(environmentContract.listEnvironments.path) + .query({ projectId }) + .end() + + expect(businessGetProjectEnvironmentsMock).toHaveBeenCalledWith(projectId) + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for non member of projectId query ', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(environmentContract.listEnvironments.path) + .query({ projectId }) + .end() + + expect(businessGetProjectEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createEnvironment', () => { + it('should create environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ success: true }) + businessCreateEnvironmentMock.mockResolvedValueOnce({ + success: true, + data: { id: environmentId, ...environmentData, ...atDates }, + }) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.json()).toMatchObject({ id: environmentId, ...environmentData }) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ success: true, message: 'pas d erreur' }) + businessCreateEnvironmentMock.mockResolvedValueOnce({ isError: true, message: 'une erreur' }) + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(500) + }) + it('should pass invalid reason error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ isError: true, message: 'une erreur' }) + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateEnvironment', () => { + let updateData: { cpu: number, gpu: number, memory: number } + beforeEach(() => { + updateData = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + } + }) + + it('should update environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentUpdateMock.mockResolvedValueOnce({ success: true, value: true }) + businessUpdateEnvironmentMock.mockResolvedValueOnce({ success: true, data: { id: environmentId, ...environmentData, ...atDates } }) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.json()).toMatchObject({ id: environmentId, ...environmentData }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateEnvironmentMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(500) + }) + + it('should pass invalid reason error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentUpdateMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('deleteEnvironment', () => { + it('should delete environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteEnvironmentMock.mockResolvedValueOnce({ success: true }) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteEnvironmentMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(500) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/environment/router.ts b/.jjconflict-side-1/apps/server/src/resources/environment/router.ts new file mode 100644 index 0000000000..7ef91c05ff --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/environment/router.ts @@ -0,0 +1,107 @@ +import { environmentContract, ProjectAuthorized } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { BadRequest400, Forbidden403, Internal500, Unauthorized401 } from '@/utils/errors.js' +import { checkEnvironmentCreate, checkEnvironmentUpdate, createEnvironment, deleteEnvironment, getProjectEnvironments, updateEnvironment } from './business.js' + +export function environmentRouter() { + return serverInstance.router(environmentContract, { + listEnvironments: async ({ request: req, query }) => { + const projectId = query.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListEnvironments(perms)) return new Forbidden403() + const body = await getProjectEnvironments(projectId) + + return { + status: 200, + body, + } + }, + + createEnvironment: async ({ request: req, body: requestBody }) => { + const projectId = requestBody.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const checkCreateResult = await checkEnvironmentCreate({ ...requestBody }) + if (checkCreateResult.isError) return new BadRequest400(checkCreateResult.error) + + const result = await createEnvironment({ + userId: perms.user.id, + projectId, + name: requestBody.name, + clusterId: requestBody.clusterId, + cpu: requestBody.cpu, + gpu: requestBody.gpu, + memory: requestBody.memory, + autosync: requestBody.autosync, + stageId: requestBody.stageId, + requestId: req.id, + }) + if (result.isError) { + return new Internal500(result.error) + } + return { + status: 201, + body: result.data, + } + }, + + updateEnvironment: async ({ request: req, body: requestBody, params }) => { + const { environmentId } = params + const perms = await authUser(req, { environmentId }) + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const checkUpdateResult = await checkEnvironmentUpdate({ environmentId, ...requestBody }) + if (checkUpdateResult.isError) return new BadRequest400(checkUpdateResult.error) + + const result = await updateEnvironment({ + user: perms.user, + environmentId, + cpu: requestBody.cpu, + gpu: requestBody.gpu, + memory: requestBody.memory, + autosync: requestBody.autosync, + requestId: req.id, + }) + if (result.isError) { + return new Internal500(result.error) + } + return { + status: 200, + body: result.data, + } + }, + + deleteEnvironment: async ({ request: req, params }) => { + const { environmentId } = params + const perms = await authUser(req, { environmentId }) + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const result = await deleteEnvironment({ + userId: perms.user?.id, + environmentId, + requestId: req.id, + projectId: perms.projectId, + }) + if (result.isError) { + return new Internal500(result.error) + } + + return { + status: 204, + body: result.data, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/index.ts b/.jjconflict-side-1/apps/server/src/resources/index.ts new file mode 100644 index 0000000000..93a285f9ac --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/index.ts @@ -0,0 +1,49 @@ +import type { FastifyInstance } from 'fastify' +import { serverInstance } from '@/app.js' + +import { adminRoleRouter } from './admin-role/router.js' +import { adminTokenRouter } from './admin-token/router.js' +import { clusterRouter } from './cluster/router.js' +import { environmentRouter } from './environment/router.js' +import { logRouter } from './log/router.js' +import { projectMemberRouter } from './project-member/router.js' +import { projectRoleRouter } from './project-role/router.js' +import { projectServiceRouter } from './project-service/router.js' +import { projectRouter } from './project/router.js' +import { repositoryRouter } from './repository/router.js' +import { serviceChainRouter } from './service-chain/router.js' +import { serviceMonitorRouter } from './service-monitor/router.js' +import { stageRouter } from './stage/router.js' +import { pluginConfigRouter } from './system/config/router.js' +import { systemRouter } from './system/router.js' +import { systemSettingsRouter } from './system/settings/router.js' +import { userRouter } from './user/router.js' +import { personalAccessTokenRouter } from './user/tokens/router.js' +import { zoneRouter } from './zone/router.js' + +// relax validation schema if NO_VALIDATION env var is set to true. +// /!\ It can lead to security leaks !!!! +const validateTrue = { responseValidation: process.env.NO_VALIDATION !== 'true' } +export function apiRouter() { + return async (app: FastifyInstance) => { + await app.register(serverInstance.plugin(adminRoleRouter()), validateTrue) + await app.register(serverInstance.plugin(adminTokenRouter()), validateTrue) + await app.register(serverInstance.plugin(clusterRouter()), validateTrue) + await app.register(serverInstance.plugin(serviceChainRouter()), validateTrue) + await app.register(serverInstance.plugin(environmentRouter()), validateTrue) + await app.register(serverInstance.plugin(logRouter()), validateTrue) + await app.register(serverInstance.plugin(personalAccessTokenRouter()), validateTrue) + await app.register(serverInstance.plugin(projectRouter()), validateTrue) + await app.register(serverInstance.plugin(projectMemberRouter()), validateTrue) + await app.register(serverInstance.plugin(projectRoleRouter()), validateTrue) + await app.register(serverInstance.plugin(projectServiceRouter()), validateTrue) + await app.register(serverInstance.plugin(repositoryRouter()), validateTrue) + await app.register(serverInstance.plugin(serviceMonitorRouter()), validateTrue) + await app.register(serverInstance.plugin(pluginConfigRouter()), validateTrue) + await app.register(serverInstance.plugin(stageRouter()), validateTrue) + await app.register(serverInstance.plugin(systemRouter()), validateTrue) + await app.register(serverInstance.plugin(systemSettingsRouter()), validateTrue) + await app.register(serverInstance.plugin(userRouter()), validateTrue) + await app.register(serverInstance.plugin(zoneRouter()), validateTrue) + } +} diff --git a/.jjconflict-side-1/apps/server/src/resources/log/business.spec.ts b/.jjconflict-side-1/apps/server/src/resources/log/business.spec.ts new file mode 100644 index 0000000000..8a4d080e48 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/log/business.spec.ts @@ -0,0 +1,42 @@ +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { getLogs } from './business.ts' + +describe('test log business', () => { + it('should map filter (clean logs)', async () => { + const dbLogs = [{ + data: { args: {} }, + createdAt: new Date(), + updatedAt: new Date(), + userId: null, + action: 'Action', + id: faker.string.uuid(), + }] + const query = { limit: 10, offset: 10, clean: true, projectId: undefined } + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + const [_total, logs] = await getLogs(query) + + expect(logs[0]).not.haveOwnProperty('requestId') + expect(logs[0].data).not.haveOwnProperty('results') + expect(logs[0].data).not.haveOwnProperty('args') + expect(logs[0].data).not.haveOwnProperty('config') + }) + + it('should not filter (admin logs)', async () => { + const dbLogs = [{ + data: { args: {} }, + createdAt: new Date(), + updatedAt: new Date(), + userId: null, + action: 'Action', + id: faker.string.uuid(), + }] + const query = { limit: 10, offset: 10, clean: false, projectId: undefined } + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + const [_total, logs] = await getLogs(query) + + expect(logs[0].data).haveOwnProperty('args') + expect(logs[0].data).not.haveOwnProperty('config') + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/log/business.ts b/.jjconflict-side-1/apps/server/src/resources/log/business.ts new file mode 100644 index 0000000000..9a0182e7e1 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/log/business.ts @@ -0,0 +1,13 @@ +import type { logContract } from '@cpn-console/shared' +import { CleanLogSchema } from '@cpn-console/shared' +import { getAllLogs } from '@/resources/queries-index.js' + +export async function getLogs({ offset, limit, projectId, clean }: typeof logContract.getLogs.query._type) { + const [total, logs] = await getAllLogs({ skip: offset, take: limit, where: { projectId } }) + return [ + total, + clean + ? logs.map(log => CleanLogSchema.parse(log)) + : logs, + ] +} diff --git a/.jjconflict-side-1/apps/server/src/resources/log/queries.ts b/.jjconflict-side-1/apps/server/src/resources/log/queries.ts new file mode 100644 index 0000000000..3851a8f13f --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/log/queries.ts @@ -0,0 +1,57 @@ +import type { Log, Prisma, Project, User } from '@prisma/client' +import { exclude } from '@cpn-console/shared' +import prisma from '@/prisma.js' + +// SELECT +export function getAllLogsForUser(user: User, offset = 0) { + return prisma.log.findMany({ + where: { userId: user.id }, + take: 100, + skip: offset, + }) +} + +export function getAllLogs({ skip = 0, take = 5, where }: Prisma.LogFindManyArgs) { + return prisma.$transaction([ + prisma.log.count({ where }), + prisma.log.findMany({ + orderBy: { + createdAt: 'desc', + }, + skip, + take, + where, + }), + ]) +} + +// CREATE +interface AddLogsArgs { + action: Log['action'] + data: Record + userId?: User['id'] | null + requestId: string + projectId?: Project['id'] +} +export function addLogs({ action, data, requestId, userId = null, projectId }: AddLogsArgs) { + return prisma.log.create({ + data: { + action, + userId, + data: exclude(data, ['cluster', 'user', 'newCreds', 'apis']), + requestId, + projectId, + }, + }) +} + +// TECH +export function _createLog(data: Parameters[0]['create']) { + return prisma.log.upsert({ + where: { + id: data.id, + }, + create: data, + update: data, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/log/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/log/router.spec.ts new file mode 100644 index 0000000000..911f69305d --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/log/router.spec.ts @@ -0,0 +1,93 @@ +import { ADMIN_PERMS, logContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetLogsMock = vi.spyOn(business, 'getLogs') + +describe('test logContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getLogs', () => { + it('should return logs for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const logs = [] + const total = 1 + + authUserMock.mockResolvedValueOnce(user) + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0 }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ total, logs }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin, no projectId', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0 }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return logs for non-admin, with projectId', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 1n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + const projectId = faker.string.uuid() + + const logs = [] + const total = 1 + + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0, projectId, clean: false }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledWith({ clean: true, limit: 10, offset: 0, projectId }) + expect(response.statusCode).toEqual(200) + }) + + it('should not return logs for non-admin, with projectId', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + const projectId = faker.string.uuid() + + const logs = [] + const total = 1 + + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0, projectId, clean: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/log/router.ts b/.jjconflict-side-1/apps/server/src/resources/log/router.ts new file mode 100644 index 0000000000..72e8712f0b --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/log/router.ts @@ -0,0 +1,30 @@ +import type { CleanLog, Log, XOR } from '@cpn-console/shared' +import type { UserProfile, UserProjectProfile } from '@/utils/controller.js' +import { AdminAuthorized, logContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getLogs } from './business.js' + +export function logRouter() { + return serverInstance.router(logContract, { + // Récupérer des logs + getLogs: async ({ request: req, query }) => { + const perms: XOR = query.projectId + ? await authUser(req, { id: query.projectId }) + : await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) { + if (!perms.projectPermissions) return new Forbidden403() + query.clean = true + } + + const [total, logs] = await getLogs(query) as [number, unknown[]] as [number, Array] + + return { + status: 200, + body: { total, logs }, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/project-member/business.ts b/.jjconflict-side-1/apps/server/src/resources/project-member/business.ts new file mode 100644 index 0000000000..4144b81813 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project-member/business.ts @@ -0,0 +1,73 @@ +import type { projectMemberContract, XOR } from '@cpn-console/shared' +import type { Project, User } from '@prisma/client' +import { logger as baseLogger } from '@cpn-console/logger' +import { UserSchema } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + addLogs, + deleteMember, + listMembers as listMembersQuery, + upsertMember, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' +import { logViaSession } from '../user/business.js' + +const logger = baseLogger.child({ scope: 'resource:project-member' }) + +export const listMembers = async (projectId: Project['id']) => listMembersQuery(projectId) + +export async function addMember(projectId: Project['id'], user: XOR<{ userId: string }, { email: string }>, requestorId: User['id'], requestId: string, projectOwnerId: Project['ownerId']) { + let userInDb: User | undefined | null + + logger.info({ requestId, requestorId, projectId, identifierType: user.userId ? 'userId' : 'email' }, 'Add project member started') + if (user.userId) { + userInDb = await prisma.user.findUnique({ where: { id: user.userId, type: 'human' } }) + } else if (user.email) { + userInDb = await prisma.user.findUnique({ where: { email: user.email, type: 'human' } }) + } else { + return new BadRequest400('Veuillez spécifiez au moins un userId ou un email') + } + if (userInDb) { + if (userInDb.id === projectOwnerId) return new BadRequest400('Le owner ne peut pas être ajouté à cette liste') + } else if (user.email) { + const hookReply = await hook.user.retrieveUserByEmail(user.email) + await addLogs({ action: 'Retrieve User By Email', data: hookReply, userId: requestorId, requestId }) + if (hookReply.failed) { + logger.error({ requestId, requestorId, projectId }, 'Add project member failed during user lookup hooks') + throw new BadRequest400('Echec de la recherche auprès des services externes') + } + + const retrievedUser = hookReply.results.keycloak?.user + if (!retrievedUser) return new BadRequest400('Utilisateur introuvable') + const userValidated = UserSchema.pick({ email: true, firstName: true, lastName: true, id: true }).safeParse(retrievedUser) + if (!userValidated.success) return new BadRequest400('L\'utilisateur trouvé ne remplit pas les conditions de vérification') + const logResults = await logViaSession({ ...userValidated.data, groups: [] }) + userInDb = logResults.user + } else { + return new NotFound404() + } + + await upsertMember({ projectId, userId: userInDb.id, roleIds: [] }) + await hook.projectMember.upsert(projectId, userInDb.id) + logger.info({ requestId, requestorId, projectId, userId: userInDb.id }, 'Add project member completed') + return listMembers(projectId) +} + +export async function patchMembers(projectId: Project['id'], members: typeof projectMemberContract.patchMembers.body._type) { + logger.info({ projectId, membersCount: members.length }, 'Patch project members started') + for (const member of members) { + await upsertMember({ projectId, userId: member.userId, roleIds: member.roles }) + await hook.projectMember.upsert(projectId, member.userId) + } + logger.info({ projectId, membersCount: members.length }, 'Patch project members completed') + return listMembers(projectId) +} + +export async function removeMember(projectId: Project['id'], userId: User['id']) { + logger.info({ projectId, userId }, 'Remove project member started') + await hook.projectMember.delete(projectId, userId) + await deleteMember({ projectId, userId }) + logger.info({ projectId, userId }, 'Remove project member completed') + return listMembers(projectId) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/project-member/queries.ts b/.jjconflict-side-1/apps/server/src/resources/project-member/queries.ts new file mode 100644 index 0000000000..a4ceb00df1 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project-member/queries.ts @@ -0,0 +1,33 @@ +import type { + Prisma, + + Project, +} from '@prisma/client' + +import prisma from '@/prisma.js' + +export const listMembers = (projectId: Project['id']) => prisma.projectMembers.findMany({ where: { projectId }, include: { user: true } }) + +export function upsertMember(data: Prisma.ProjectMembersUncheckedCreateInput) { + return prisma.projectMembers.upsert({ + where: { + projectId_userId: { + userId: data.userId, + projectId: data.projectId, + }, + }, + create: data, + update: { + roleIds: data.roleIds, + }, + include: { user: true }, + }) +} + +export function deleteMember(data: Prisma.ProjectMembersWhereUniqueInput['projectId_userId']) { + return prisma.projectMembers.delete({ + where: { + projectId_userId: data, + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/project-member/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/project-member/router.spec.ts new file mode 100644 index 0000000000..6ebb1d50a2 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project-member/router.spec.ts @@ -0,0 +1,294 @@ +import type { Member } from '@cpn-console/shared' +import { PROJECT_PERMS, projectMemberContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMembersMock = vi.spyOn(business, 'listMembers') +const businessAddMemberMock = vi.spyOn(business, 'addMember') +const businessPatchMembersMock = vi.spyOn(business, 'patchMembers') +const businessRemoveMemberMock = vi.spyOn(business, 'removeMember') + +describe('projectMemberRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const userId = faker.string.uuid() + + describe('listMembers', () => { + it('should return members for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessListMembersMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectMemberContract.listMembers.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectMemberContract.listMembers.path.replace(':projectId', projectId)) + .end() + + expect(businessListMembersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('addMember', () => { + const memberData: Partial = { + userId: faker.string.uuid(), + } + + it('should add member for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const newMember = { + ...memberData, + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + roleIds: [], + } + + businessAddMemberMock.mockResolvedValueOnce([newMember]) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.json()).toEqual([newMember]) + expect(response.statusCode).toEqual(201) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + businessAddMemberMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('patchMembers', () => { + const patchData = [{ userId: faker.string.uuid(), roles: [] }] + + it('should patch members for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchMembersMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('removeMember', () => { + it('should remove member for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessRemoveMemberMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should be able leave a project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessRemoveMemberMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/project-member/router.ts b/.jjconflict-side-1/apps/server/src/resources/project-member/router.ts new file mode 100644 index 0000000000..e9755fab57 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project-member/router.ts @@ -0,0 +1,77 @@ +import { ProjectAuthorized, projectMemberContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + addMember, + listMembers, + patchMembers, + removeMember, +} from './business.js' + +export function projectMemberRouter() { + return serverInstance.router(projectMemberContract, { + listMembers: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListMembers(perms)) return new Forbidden403() + const body = await listMembers(projectId) + + return { + status: 200, + body, + } + }, + + addMember: async ({ request: req, params, body }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageMembers(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await addMember(projectId, body, perms.user.id, req.id, perms.projectOwnerId) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 201, + body: resBody, + } + }, + + patchMembers: async ({ request: req, params, body }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageMembers(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await patchMembers(projectId, body) + + return { + status: 200, + body: resBody, + } + }, + + removeMember: async ({ request: req, params }) => { + const { projectId, userId } = params + const perms = await authUser(req, { id: projectId }) + + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (!ProjectAuthorized.ManageMembers(perms) && userId !== perms.user?.id) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await removeMember(projectId, params.userId) + + return { + status: 200, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/project-role/business.spec.ts b/.jjconflict-side-1/apps/server/src/resources/project-role/business.spec.ts new file mode 100644 index 0000000000..89a70147fd --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project-role/business.spec.ts @@ -0,0 +1,357 @@ +import type { Project, ProjectMembers, ProjectRole } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../__mocks__/prisma.js' +import { countRolesMembers, createRole, deleteRole, listRoles, patchRoles } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', () => ({ + hook: { + project: { + upsert: vi.fn(), + delete: vi.fn(), + getSecrets: vi.fn(), + }, + projectRole: { + upsert: vi.fn(), + delete: vi.fn(), + }, + user: { + retrieveUserByEmail: vi.fn(), + }, + }, +})) + +describe('test project-role business', () => { + const project: Project = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + slug: faker.lorem.word({ length: { min: 2, max: 10 } }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + description: faker.lorem.sentence({ min: 2, max: 10 }), + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + everyonePerms: 0n, + ownerId: faker.string.uuid(), + lastSuccessProvisionningVersion: null, + } + const projectId = faker.string.uuid() + + describe('listRoles', () => { + it('should stringify bigint', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + const response = await listRoles(projectId) + expect(response).toContainEqual(expect.objectContaining({ permissions: '4' })) + }) + + it('should strip oidcGroup prefix', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: `/${project.slug}/console/admin`, + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValueOnce(project) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + + const response = await listRoles(projectId) + expect(response[0].oidcGroup).toBe('/console/admin') + }) + }) + + describe('createRole', () => { + it('should create role with incremented position when position 0 is the highest', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 1, projectId } }) + }) + + it('should create role with incremented position with bigger position', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 51, projectId } }) + }) + + it('should create role with incremented position with no role in db', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(null) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.projectRole.findFirst.mockResolvedValueOnce(null) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 0, projectId } }) + }) + + it('should create role with enforced oidcGroup prefix', async () => { + const dbRole: any = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: `/${project.slug}/console/admin`, + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValueOnce(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + + await createRole(projectId, { name: 'test', permissions: '4', oidcGroup: '/console/admin' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + oidcGroup: `/${project.slug}/console/admin`, + }), + })) + }) + }) + + describe('deleteRole', () => { + const roleId = faker.string.uuid() + it('should delete role and remove id from concerned users', async () => { + const dbRole: ProjectRole = { + id: roleId, + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + const members = [{ + userId: faker.string.uuid(), + projectId, + roleIds: [roleId], + }, { + userId: faker.string.uuid(), + projectId, + roleIds: [roleId, faker.string.uuid()], + }] as const satisfies Partial[] + + prisma.projectRole.findUnique.mockResolvedValue(dbRole) + prisma.projectMembers.findMany.mockResolvedValueOnce(members) + prisma.projectRole.findMany.mockResolvedValueOnce([]) + prisma.projectRole.delete.mockResolvedValue(dbRole) + await deleteRole(roleId) + + expect(prisma.projectMembers.update).toHaveBeenNthCalledWith(1, { where: expect.any(Object), data: { roleIds: { set: [] } } }) + expect(prisma.projectMembers.update).toHaveBeenNthCalledWith(2, { where: expect.any(Object), data: { roleIds: { set: [members[1].roleIds[1]] } } }) + expect(prisma.projectRole.delete).toHaveBeenCalledWith({ where: { id: roleId } }) + }) + }) + + describe.skip('countRolesMembers', () => { + it('should return aggregated role member counts', async () => { + const roles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + }] as const satisfies ProjectRole[] + + const members = [{ + userId: faker.string.uuid(), + projectId, + roleIds: [roles[0].id, roles[1].id], + }, { + userId: faker.string.uuid(), + projectId, + roleIds: [roles[1].id], + }] as const satisfies ProjectMembers[] + + prisma.projectRole.findMany.mockResolvedValue(roles) + prisma.projectMembers.findMany.mockResolvedValue(members) + + const response = await countRolesMembers(projectId) + + expect(response).toEqual({ [roles[0].id]: 1, [roles[1].id]: 2 }) + }) + }) + + describe('patchRoles', () => { + const dbRoles: ProjectRole[] = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + projectId, + oidcGroup: 'group1', + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + projectId, + oidcGroup: 'group2', + type: 'managed', + }] + + it('should do nothing', async () => { + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue([]) + await patchRoles(projectId, []) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 1 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(projectId, updateRoles) + + expect(response).instanceOf(BadRequest400) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions (missing)', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[1].id, position: 1 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(projectId, updateRoles) + + expect(response).instanceOf(BadRequest400) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should update positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 0 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledTimes(2) + }) + + it('should update permissions', async () => { + const updateRoles: (Pick & { permissions: string })[] = [ + { id: dbRoles[1].id, permissions: '0' }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + data: { + name: dbRoles[1].name, + permissions: 0n, + position: 1, + oidcGroup: dbRoles[1].oidcGroup, + projectId, + type: 'managed', + }, + where: { + id: dbRoles[1].id, + }, + })) + }) + + it('should update role with enforced oidcGroup prefix', async () => { + const updateRoles: any[] = [ + { id: dbRoles[1].id, oidcGroup: '/console/admin' }, + ] + + prisma.project.findUnique.mockResolvedValue(project) + + const dbRoleWithPrefix = { ...dbRoles[1], oidcGroup: `/${project.slug}/console/group2` } + prisma.projectRole.findMany.mockResolvedValue([dbRoleWithPrefix]) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + oidcGroup: `/${project.slug}/console/admin`, + }), + })) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/project-role/business.ts b/.jjconflict-side-1/apps/server/src/resources/project-role/business.ts new file mode 100644 index 0000000000..52bd8966b3 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project-role/business.ts @@ -0,0 +1,121 @@ +import type { projectRoleContract } from '@cpn-console/shared' +import type { Project, ProjectRole } from '@prisma/client' +import { isSystemRoleType } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + deleteRole as deleteRoleQuery, + listMembers, + listRoles as listRolesQuery, + updateRole, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +const oidcRegexp = /^\/[^/]+/ + +export async function listRoles(projectId: Project['id']) { + const roles = await listRolesQuery(projectId) + return roles.map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ? role.oidcGroup.replace(oidcRegexp, '') : role.oidcGroup, + })) +} + +export async function patchRoles(projectId: Project['id'], roles: typeof projectRoleContract.patchProjectRoles.body._type) { + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } }) + if (!project) throw new NotFound404() + const dbRoles = await listRolesQuery(projectId) + const positionsAvailable: number[] = [] + + const updatedRoles: (Omit & { permissions: bigint })[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (matchingRole) { + if (isSystemRoleType(dbRole.type)) { + return new BadRequest400('Ce rôle système ne peut pas être modifié') + } + if (isSystemRoleType(matchingRole.type)) { + return new BadRequest400('Impossible de modifier un rôle en rôle système') + } + if (typeof matchingRole?.position !== 'undefined' && !positionsAvailable.includes(matchingRole?.position)) { + positionsAvailable.push(matchingRole.position) + } + if (matchingRole.oidcGroup && !matchingRole.oidcGroup.startsWith('/')) { + return new BadRequest400('oidcGroup doit commencer par /') + } + updatedRoles.push({ + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions: matchingRole.permissions ? BigInt(matchingRole.permissions) : dbRole.permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup: matchingRole.oidcGroup ? `/${project.slug}${matchingRole.oidcGroup}` : dbRole.oidcGroup, + type: matchingRole.type ?? dbRole.type, + projectId: dbRole.projectId, + }) + } + } + + if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes') + for (const { id, ...role } of updatedRoles) { + await updateRole(id, role) + await hook.projectRole.upsert(id) + } + + return listRoles(projectId) +} + +export async function createRole(projectId: Project['id'], role: typeof projectRoleContract.createProjectRole.body._type) { + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } }) + if (!project) throw new NotFound404() + if (isSystemRoleType(role.type)) { + throw new BadRequest400('Impossible de créer un rôle système') + } + const dbMaxPosRole = (await prisma.projectRole.findFirst({ + where: { projectId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + if (role.oidcGroup && !role.oidcGroup.startsWith('/')) { + throw new BadRequest400('oidcGroup doit commencer par /') + } + + const createdRole = await prisma.projectRole.create({ + data: { + ...role, + projectId, + position: dbMaxPosRole + 1, + permissions: BigInt(role.permissions), + oidcGroup: role.oidcGroup ? `/${project.slug}${role.oidcGroup}` : undefined, + }, + }) + + await hook.projectRole.upsert(createdRole.id) + + return listRoles(projectId) +} + +export async function countRolesMembers(projectId: Project['id']) { + const roles = await listRoles(projectId) + const members = await listMembers(projectId) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) // {role uuid: 0} + for (const { roleIds } of members) { + for (const roleId of roleIds) { + rolesCounts[roleId]++ + } + } + return rolesCounts +} + +export async function deleteRole(roleId: Project['id']) { + const role = await prisma.projectRole.findUnique({ where: { id: roleId }, select: { type: true } }) + if (!role) throw new NotFound404() + if (isSystemRoleType(role.type)) { + throw new BadRequest400('Ce rôle système ne peut pas être supprimé') + } + await hook.projectRole.delete(roleId) + await deleteRoleQuery(roleId) + return null +} diff --git a/.jjconflict-side-1/apps/server/src/resources/project-role/queries.ts b/.jjconflict-side-1/apps/server/src/resources/project-role/queries.ts new file mode 100644 index 0000000000..3e2aa4a0fa --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project-role/queries.ts @@ -0,0 +1,59 @@ +import type { + Prisma, + Project, + ProjectRole, +} from '@prisma/client' + +import prisma from '@/prisma.js' + +export const getRole = (id: ProjectRole['id']) => prisma.projectRole.findUnique({ where: { id } }) + +export const listRoles = (projectId: Project['id']) => prisma.projectRole.findMany({ where: { projectId }, orderBy: { position: 'asc' } }) + +export function createRole(data: Pick) { + return prisma.projectRole.create({ + data: { + name: data.name, + permissions: 0n, + position: data.position, + projectId: data.projectId, + oidcGroup: data.oidcGroup, + type: 'managed', + }, + }) +} + +export function updateRole(id: ProjectRole['id'], data: Pick) { + return prisma.projectRole.update({ + where: { id }, + data, + }) +} + +export async function deleteRole(id: ProjectRole['id']) { + const role = await prisma.projectRole.delete({ + where: { + id, + }, + }) + const attachedMembers = await prisma.projectMembers.findMany({ + where: { projectId: role.projectId, roleIds: { has: id } }, + }) + for (const member of attachedMembers) { + await prisma.projectMembers.update({ + where: { + projectId_userId: { + projectId: role.projectId, + userId: member.userId, + }, + }, + data: { + roleIds: { + set: member.roleIds.filter(roleId => roleId !== id), + }, + }, + }) + } +} + +export const getProjectRoleById = (id: ProjectRole['id']) => prisma.projectRole.findUnique({ where: { id }, include: { project: true } }) diff --git a/.jjconflict-side-1/apps/server/src/resources/project-role/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/project-role/router.spec.ts new file mode 100644 index 0000000000..dd42fbc7ad --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project-role/router.spec.ts @@ -0,0 +1,299 @@ +import { PROJECT_PERMS, projectRoleContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('./business.js') +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) + +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessCreateRoleMock = vi.spyOn(business, 'createRole') +const businessDeleteRoleMock = vi.spyOn(business, 'deleteRole') +const businessListRolesMock = vi.spyOn(business, 'listRoles') +const businessPatchRolesMock = vi.spyOn(business, 'patchRoles') + +describe('tests projectRoleContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const roleId = faker.string.uuid() + + describe('listProjectRoles', () => { + it('should return 403 for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessListRolesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectRoleContract.listProjectRoles.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectRoleContract.listProjectRoles.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createProjectRole', () => { + it('should create role for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('patchProjectRoles', () => { + it('should patch roles for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchRolesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchRolesMock.mockResolvedValue(new BadRequest400('une erreur')) + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('projectRoleMemberCounts', () => { + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectRoleContract.projectRoleMemberCounts.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteProjectRole', () => { + it('should delete role for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteRoleMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 404 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/project-role/router.ts b/.jjconflict-side-1/apps/server/src/resources/project-role/router.ts new file mode 100644 index 0000000000..f7aa2ef902 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project-role/router.ts @@ -0,0 +1,88 @@ +import { ProjectAuthorized, projectRoleContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + countRolesMembers, + createRole, + deleteRole, + listRoles, + patchRoles, +} from './business.js' + +export function projectRoleRouter() { + return serverInstance.router(projectRoleContract, { + // Récupérer des projets + listProjectRoles: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ListRoles(perms)) return new Forbidden403() + + const body = await listRoles(projectId) + + return { + status: 200, + body, + } + }, + + createProjectRole: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await createRole(projectId, body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 201, + body: resBody, + } + }, + + patchProjectRoles: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await patchRoles(projectId, body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + projectRoleMemberCounts: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ListRoles(perms)) return new Forbidden403() + + const resBody = await countRolesMembers(projectId) + + return { + status: 200, + body: resBody, + } + }, + + deleteProjectRole: async ({ request: req, params: { projectId, roleId } }) => { + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await deleteRole(roleId) + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/project-service/business.ts b/.jjconflict-side-1/apps/server/src/resources/project-service/business.ts new file mode 100644 index 0000000000..bce70064ef --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project-service/business.ts @@ -0,0 +1,103 @@ +import type { ZoneObject } from '@cpn-console/hooks' +import type { + PermissionTarget, + PluginsUpdateBody, + ServiceUrl, +} from '@cpn-console/shared' +import type { Project, ProjectPlugin } from '@prisma/client' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { + getAdminPlugin, + getProjectInfosByIdOrThrow, + getProjectStore, + getPublicClusters, + saveProjectStore, +} from '@/resources/queries-index.js' + +const logger = baseLogger.child({ scope: 'resource:project-service' }) + +export type ConfigRecords = { + key: string + pluginName: string + value: string | number | null +}[] + +export function dbToObj(records: Omit[]): PluginsUpdateBody { + const obj: PluginsUpdateBody = {} + for (const record of records) { + if (!obj[record.pluginName]) obj[record.pluginName] = {} + obj[record.pluginName][record.key] = record.value + } + return obj +} + +export function objToDb(obj: PluginsUpdateBody): ConfigRecords { + return Object.entries(obj) + .map(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) + .flat() +} + +export async function getProjectServices(projectId: Project['id'], permissionTarget: PermissionTarget) { + // Pré-requis + const project = await getProjectInfosByIdOrThrow(projectId) + + const [projectStore, globalConfig] = await Promise.all([ + getProjectStore(projectId), + getAdminPlugin(), + ]) + const store = dbToObj([...projectStore, ...globalConfig]) + + const publicClusters = await getPublicClusters() + project.clusters = [...project.clusters, ...publicClusters] + const zones: Map = new Map() // Pour dédoublonnage des zones + project.clusters.map(c => zones.set(c.zone.id, c.zone)) + + return Object.values(servicesInfos).map(({ name, title, to, imgSrc, description }) => { + let urls: ServiceUrl[] = [] + const toResponse = to + ? to({ + clusters: project.clusters, + zones: [...zones.values()], + environments: project.environments, + project, + store, + }) + : [] + if (Array.isArray(toResponse)) { + urls = toResponse.map(res => ({ name: res.title ?? '', description: res.description ?? '', to: res.to })) + } else if (typeof toResponse === 'string') { + urls = [{ to: toResponse, name: '' }] + } else if (toResponse) { + urls = [{ name: toResponse.title ?? '', to: toResponse.to }] + } + const manifest = populatePluginManifests({ + data: { + project: projectStore, + global: globalConfig, + }, + permissionTarget, + pluginName: name, + select: { + global: true, + project: true, + }, + }) + return { imgSrc, title, name, urls, manifest, description } + }).filter(s => s.urls.length || s.manifest.global?.length || s.manifest.project?.length) +} + +export async function updateProjectServices(projectId: Project['id'], data: PluginsUpdateBody, stripperRoles: Array<'user' | 'admin'>) { + const pluginKeysCount = Object.fromEntries( + Object.entries(data).map(([pluginName, values]) => [pluginName, Object.keys(values).length]), + ) + logger.info({ projectId, pluginCount: Object.keys(data).length, pluginKeysCount, stripperRoles }, 'Update project services started') + for (const role of stripperRoles) { + const parsedData = editStrippers.project[role].safeParse(data) + if (!parsedData.success) continue + await saveProjectStore(objToDb(parsedData.data), projectId) + } + logger.info({ projectId }, 'Update project services completed') + return null +} diff --git a/.jjconflict-side-1/apps/server/src/resources/project-service/queries.ts b/.jjconflict-side-1/apps/server/src/resources/project-service/queries.ts new file mode 100644 index 0000000000..cf353614b2 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project-service/queries.ts @@ -0,0 +1,54 @@ +import type { Project } from '@prisma/client' +import type { ConfigRecords } from './business.js' +import prisma from '@/prisma.js' + +// CONFIG +export function getProjectStore(projectId: Project['id']) { + return prisma.projectPlugin.findMany({ + where: { projectId }, + select: { + key: true, + pluginName: true, + value: true, + }, + }) +} + +export const getAdminPlugin = prisma.adminPlugin.findMany + +export async function saveProjectStore(records: ConfigRecords, projectId: Project['id']) { + for (const { pluginName, key, value } of records) { + if (value === null) { + await prisma.projectPlugin.delete({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } else { + await prisma.projectPlugin.upsert({ + create: { + pluginName, + projectId, + key, + value: value.toString(), + }, + update: { + key, + value: value.toString(), + pluginName, + }, + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } + } +} diff --git a/.jjconflict-side-1/apps/server/src/resources/project-service/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/project-service/router.spec.ts new file mode 100644 index 0000000000..20398a0d08 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project-service/router.spec.ts @@ -0,0 +1,160 @@ +import { ADMIN_PERMS, PROJECT_PERMS, projectServiceContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetServicesMock = vi.spyOn(business, 'getProjectServices') +const businessUpdateServicesMock = vi.spyOn(business, 'updateProjectServices') + +describe('projectServiceRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + + describe('getServices', () => { + it('should return services for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .query({ permissionTarget: 'user' }) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledWith(projectId, 'user') + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should not return admin services for non admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .query({ permissionTarget: 'admin' }) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return services for admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledWith(projectId, 'user') + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateProjectServices', () => { + const updateData = { serviceA: { param1: 'value' } } + + it('should update services for project manager', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateServicesMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(businessUpdateServicesMock).toHaveBeenCalledWith(projectId, updateData, ['user']) + expect(response.statusCode).toEqual(204) + }) + + it('should update services for project admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateServicesMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(businessUpdateServicesMock).toHaveBeenCalledWith(projectId, updateData, ['user', 'admin']) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/project-service/router.ts b/.jjconflict-side-1/apps/server/src/resources/project-service/router.ts new file mode 100644 index 0000000000..e5a1f07159 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project-service/router.ts @@ -0,0 +1,38 @@ +import { AdminAuthorized, ProjectAuthorized, projectServiceContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getProjectServices, updateProjectServices } from './business.js' + +export function projectServiceRouter() { + return serverInstance.router(projectServiceContract, { + // Récupérer les services d'un projet + getServices: async ({ request: req, params: { projectId }, query }) => { + const perms = await authUser(req, { id: projectId }) + if (!perms.projectPermissions && !ProjectAuthorized.Manage(perms)) return new Forbidden403() + if (query.permissionTarget === 'admin' && !AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403('Vous ne pouvez pas demander les paramètres admin') + + const body = await getProjectServices(projectId, query.permissionTarget) + + return { + status: 200, + body, + } + }, + + updateProjectServices: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.Manage(perms) && !perms.projectPermissions) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + + const allowedRoles: Array<'user' | 'admin'> = AdminAuthorized.Manage(perms.adminPermissions) ? ['user', 'admin'] : ['user'] + + const resBody = await updateProjectServices(projectId, body, allowedRoles) + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/project/business.spec.ts b/.jjconflict-side-1/apps/server/src/resources/project/business.spec.ts new file mode 100644 index 0000000000..a9f6c4fa62 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project/business.spec.ts @@ -0,0 +1,365 @@ +import type { Cluster, Project, ProjectMembers, ProjectRole, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { + BadRequest400, + ErrorResType, + Unprocessable422, +} from '../../utils/errors.js' +import { dbToObj } from '../project-service/business.ts' +import * as userBusiness from '../user/business.js' +import { archiveProject, chunk, createProject, generateProjectsData, generateSlug, getProjectSecrets, listProjects, replayHooks, updateProject } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const logViaSessionMock = vi.spyOn(userBusiness, 'logViaSession') + +const projectId = faker.string.uuid() + +const user: User = { + id: faker.string.uuid(), + createdAt: new Date(), + updatedAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + lastLogin: null, +} +const project: Project & { + clusters: Pick[] + members: ProjectMembers[] + roles: ProjectRole[] + owner: User +} = { + createdAt: new Date(), + updatedAt: new Date(), + description: '', + everyonePerms: 649n, + id: faker.string.uuid(), + locked: false, + name: faker.string.alphanumeric(8), + status: 'created', + ownerId: faker.string.uuid(), + clusters: [], + roles: [], + members: [], +} +const reqId = faker.string.uuid() +describe('test project business utils', () => { + it('should transform arrow ', async () => { + const result = dbToObj([{ key: 'test', pluginName: 'test', value: 'test' }]) + expect(result).toEqual({ test: { test: 'test' } }) + }) +}) + +describe('test project business logic', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listProjects', () => { + it('should return stringified perms', async () => { + prisma.project.findMany.mockResolvedValue([{ everyonePerms: 5n, clusters: [], roles: [{ permissions: 28n }] }]) + const response = await listProjects({}, user.id) + expect(response[0].everyonePerms).toBe('5') + expect(response[0].roles[0].permissions).toBe('28') + }) + }) + describe('getProjectSecrets', () => { + const getResultsHook = { + failed: false, + args: {}, + results: { + registry: { + secrets: { + token: 'myToken', + }, + status: { + failed: false, + }, + }, + }, + } + it('should return transform secret', async () => { + hook.project.getSecrets.mockResolvedValue(getResultsHook) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + + // according to src/utils/mocks.ts + expect(JSON.stringify(response)).toContain('myToken') + }) + + it('should return projects secrets', async () => { + hook.project.getSecrets.mockResolvedValue(getResultsHook) + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + prisma.project.findMany.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + // according to src/utils/mocks.ts + expect(JSON.stringify(response)).toContain('myToken') + }) + + it('should return hook error', async () => { + hook.project.getSecrets.mockResolvedValue({ failed: true }) + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + prisma.project.findMany.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + // according to src/utils/mocks.ts + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('createProject', () => { + it('should create project', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.create.mockResolvedValue({ ...project, status: 'initializing' }) + prisma.project.findFirst.mockResolvedValue(undefined) + prisma.project.findMany.mockResolvedValue([]) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const projectRes = await createProject(project, user, reqId) + + expect(projectRes.name).toEqual(project.name) + expect(prisma.project.create).toHaveBeenCalledTimes(1) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should return plugins failed', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.create.mockResolvedValue({ ...project, status: 'initializing' }) + prisma.project.findFirst.mockResolvedValue(undefined) + prisma.project.findMany.mockResolvedValue([]) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const response = await createProject(project, user, reqId) + + expect(prisma.project.create).toHaveBeenCalledTimes(1) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + describe('updateProject', () => { + const updatedProjet = { + description: faker.lorem.lines(2), + everyonePerms: '5', + } + const reqId = faker.string.uuid() + const members: ProjectMembers[] = [{ userId: faker.string.uuid(), projectId: project.id, roleIds: [], user: { type: 'human' } }] + it('should update project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + await updateProject({ ...updatedProjet, ownerId: members[0].userId }, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(2) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should update nothing', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + await updateProject({ }, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(0) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should not update if project archived', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, status: 'archived' }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const response = await updateProject({ }, project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should not update project, cause missing member', async () => { + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members: [] }) + + const response = await updateProject({ ownerId: members[0].userId }, project.id, user, reqId) + + expect(prisma.project.findUniqueOrThrow).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(BadRequest400) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + expect(prisma.log.update).toHaveBeenCalledTimes(0) + }) + + it('should return plugins failed', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const response = await updateProject(updatedProjet, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + describe('replayHooks', () => { + const reqId = faker.string.uuid() + + it('should replay hooks', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + await replayHooks(project.id, user, reqId) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should not replay hooks on archived project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'archived' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should not replay hooks on locked project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: true, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should update nothing and return error', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: true } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('archiveProject', () => { + it('should archive project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeNull() + expect(prisma.project.update).toHaveBeenLastCalledWith({ + where: { id: project.id }, + data: { + clusters: { set: [] }, + }, + }) + }) + + it('should not archive a project already archived', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false, status: 'archived' }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + }) + + it('should not archive a project locked', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: true, status: 'created' }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + }) + + it('should return hook fail', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false }) + hook.project.delete.mockResolvedValue({ results: { failed: true }, project: Promise.resolve({ status: 'failed' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('generateProjectsData', () => { + it('shoud return string, very bad test ...', async () => { + prisma.project.findMany.mockResolvedValue([{ name: 'test' }]) + const response = await generateProjectsData() + expect(response).toBeTypeOf('string') + }) + }) +}) + +describe('chunk function', () => { + it('should return 5 elements', () => { + const letters = ['A', 'B', 'C', 'D', 'E'] + expect(chunk(letters, 5)).toEqual([letters]) + }) + it('should return 3,2 elements', () => { + const letters = ['A', 'B', 'C', 'D', 'E'] + expect(chunk(letters, 3)).toEqual([['A', 'B', 'C'], ['D', 'E']]) + }) + it('should return 4 elements', () => { + const letters = ['A', 'B', 'C', 'D'] + expect(chunk(letters, 5)).toEqual([letters]) + }) +}) + +const slugRegexp = /-[01]$/ +const slugFourRegexp = /-4$/ +const slugEmptySpaceRegexp = /-2$/ + +describe('generateSlug', () => { + it('should return prefix, no array', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix) + expect(generated).toEqual(prefix) + }) + it('should return prefix, empty array', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, []) + expect(generated).toEqual(prefix) + }) + it('should return prefix, no match', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [faker.string.alphanumeric(5), faker.string.alphanumeric(5)]) + expect(generated).toEqual(prefix) + }) + it('should return generated slug at 1 or 0, all matchs', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix]) + expect(generated).match(slugRegexp) + }) + it('should return generated slug at 4, all matchs', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix, `${prefix}-0`, `${prefix}-1`, `${prefix}-2`, `${prefix}-3`]) + expect(generated).match(slugFourRegexp) + }) + it('should fill empty space', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix, `${prefix}-0`, `${prefix}-1`, `${prefix}-3`]) + expect(generated).match(slugEmptySpaceRegexp) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/project/business.ts b/.jjconflict-side-1/apps/server/src/resources/project/business.ts new file mode 100644 index 0000000000..b27f16bf64 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project/business.ts @@ -0,0 +1,306 @@ +import type { projectContract } from '@cpn-console/shared' +import type { Project, User } from '@prisma/client' +import type { UserDetails } from '@/types/index.js' +import type { ErrorResType } from '@/utils/errors.js' +import { servicesInfos } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { ProjectStatusSchema } from '@cpn-console/shared' +import { json2csv } from 'json-2-csv' +import prisma from '@/prisma.js' +import { + addLogs, + deleteAllEnvironmentForProject, + deleteAllRepositoryForProject, + getAllProjectsDataForExport, + getProjectOrThrow, + getSlugs, + initializeProject, + listProjects as listProjectsQuery, + lockProject, + updateProject as updateProjectQuery, +} from '@/resources/queries-index.js' +import { whereBuilder } from '@/utils/controller.js' +import { parallelBulkLimit } from '@/utils/env.js' +import { BadRequest400, Forbidden403, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +const logger = baseLogger.child({ scope: 'resource:project' }) + +export function generateSlug(prefix: string, existingSlugs?: string[]) { + if (!existingSlugs?.includes(prefix)) { + return prefix + } + let idx = 1 + let generated = `${prefix}-${idx}` + while (existingSlugs.includes(generated)) { + idx++ + generated = `${prefix}-${idx}` + } + return generated +} + +const projectStatus = ProjectStatusSchema._def.values +export async function listProjects({ status, statusIn, statusNotIn, filter = 'member', ...query }: typeof projectContract.listProjects.query._type, userId: User['id'] | undefined) { + return listProjectsQuery({ + ...query, + status: whereBuilder({ enumValues: projectStatus, eqValue: status, inValues: statusIn, notInValues: statusNotIn }), + filter, + userId, + }).then(projects => projects.map(({ clusters, ...project }) => ({ + ...project, + clusterIds: clusters.map(({ id }) => id), + roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + everyonePerms: project.everyonePerms.toString(), + }))) +} + +export async function getProjectSecrets(projectId: string) { + const hookReply = await hook.project.getSecrets(projectId) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la récupération des secrets du projet') + } + + return Object.fromEntries( + Object.entries(hookReply.results) + // @ts-ignore + .filter(([_key, value]) => Object.keys(value.secrets).length) + // @ts-ignore + .map(([key, value]) => [servicesInfos[key]?.title, value.secrets]), + ) +} + +export async function createProject(dataDto: typeof projectContract.createProject.body._type, requestor: UserDetails, requestId: string) { + if (requestor.type !== 'human') return new BadRequest400('Seuls les comptes humains peuvent créer des projets') + + let slug = dataDto.name + logger.info({ requestId, userId: requestor.id, slugPrefix: slug }, 'Create project started') + const projectsWithSamePrefix = await getSlugs(slug) + slug = generateSlug(slug, projectsWithSamePrefix?.map(project => project.slug)) + + // Actions + const project = await initializeProject({ ...dataDto, slug, ownerId: requestor.id }) + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Project initialized') + + const { results, project: projectInfos } = await hook.project.upsert(project.id) + await addLogs({ action: 'Create Project', data: results, userId: requestor.id, requestId, projectId: project.id }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project failed during upsert hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project upsert hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services à la création du projet') + } + + for (const role of projectInfos.roles) { + const roleResult = await hook.projectRole.upsert(role.id) + await addLogs({ action: 'Upsert Project Role', data: roleResult.results, userId: requestor.id, requestId, projectId: project.id }) + } + + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project completed') + return { + ...projectInfos, + clusterIds: projectInfos.clusters.map(({ id }) => id), + everyonePerms: projectInfos.everyonePerms.toString(), + roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + } +} + +export async function getProject(projectId: Project['id']) { + return getProjectOrThrow(projectId).then(({ clusters, ...project }) => ({ + ...project, + clusterIds: clusters.map(({ id }) => id), + roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + everyonePerms: project.everyonePerms.toString(), + })) +} + +export async function updateProject( + { description, ownerId: ownerIdCandidate, everyonePerms, locked, ...data }: typeof projectContract.updateProject.body._type, + projectId: Project['id'], + requestor: UserDetails, + requestId: string, +) { + const changedFields = Object.entries({ + ...data, + description, + locked, + ownerId: ownerIdCandidate, + everyonePerms, + }).filter(([, value]) => typeof value !== 'undefined').map(([key]) => key) + logger.info({ requestId, userId: requestor.id, projectId, changedFields }, 'Update project started') + + // Actions + const projectDb = await prisma.project.findUniqueOrThrow({ + where: { id: projectId }, + include: { members: { include: { user: true } } }, + }) + + if (projectDb.status === 'archived') return new Forbidden403('Le projet est archivé') + + if (ownerIdCandidate && ownerIdCandidate !== projectDb.ownerId) { + const memberCandidate = projectDb.members.find(member => member.userId === ownerIdCandidate) + if (!memberCandidate) { + return new BadRequest400('Le nouveau propriétaire doit faire partie des membres actuels du projet') + } + if (memberCandidate.user.type !== 'human') return new BadRequest400('Seuls les comptes humains peuvent être propriétaire de projets') + if (!projectDb.members.some(member => member.userId === projectDb.ownerId)) { + await prisma.projectMembers.create({ + data: { userId: projectDb.ownerId, projectId }, + }) + } + await prisma.$transaction([ + prisma.projectMembers.delete({ + where: { projectId_userId: { userId: ownerIdCandidate, projectId } }, + }), + prisma.project.update({ where: { id: projectId }, data: { ownerId: ownerIdCandidate } }), + ]) + } + + if (typeof description !== 'undefined' || typeof everyonePerms !== 'undefined' || typeof locked !== 'undefined') { + await updateProjectQuery(projectId, { + description, + locked, + ...everyonePerms && { everyonePerms: BigInt(everyonePerms) }, + ...data, + }) + } + + const { results, project: projectInfos } = await hook.project.upsert(projectId) + await addLogs({ action: 'Update Project', data: results, userId: requestor.id, requestId, projectId: projectInfos.id }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId }, 'Update project failed during upsert hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId }, 'Update project upsert hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services à la mise à jour du projet') + } + + logger.info({ requestId, userId: requestor.id, projectId }, 'Update project completed') + return { + ...projectInfos, + clusterIds: projectInfos.clusters.map(({ id }) => id), + everyonePerms: projectInfos.everyonePerms.toString(), + roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${projectInfos.slug}`, '') : role.oidcGroup })), + } +} + +interface ReplayHooksArgs { + projectId: Project['id'] + userId?: User['id'] + requestId: string +} +export async function replayHooks({ projectId, userId, requestId }: ReplayHooksArgs): Promise { + logger.info({ requestId, userId, projectId }, 'Replay project hooks started') + const projectDb = await prisma.project.findUniqueOrThrow({ + where: { id: projectId }, + include: { members: { include: { user: true } } }, + }) + if (projectDb.locked) return new Forbidden403('Le projet est verrouillé') + if (projectDb.status === 'archived') return new Forbidden403('Le projet est archivé') + // Actions + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Replay hooks for Project', data: results, userId, requestId, projectId }) + if (results.failed) { + logger.error({ requestId, userId, projectId }, 'Replay project hooks failed') + } else { + logger.info({ requestId, userId, projectId }, 'Replay project hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services au reprovisionnement du projet') + } + return null +} + +export async function archiveProject(projectId: Project['id'], requestor: UserDetails, requestId: string): Promise { + logger.info({ requestId, userId: requestor.id, projectId }, 'Archive project started') + // Actions + // Fetch project state before hook so plugins see repos and environments + const projectDb = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, include: { members: { include: { user: true } } } }) + + if (projectDb.locked) return new Forbidden403('Le projet est verrouillé') + if (projectDb.status === 'archived') return new BadRequest400('Le projet est archivé') + if (projectDb.locked) { + await lockProject(projectId) + } + + // -- début - Suppression projet -- + const { results, project } = await hook.project.delete(projectId) + await addLogs({ action: 'Delete all project resources', data: results, userId: requestor.id, requestId, projectId }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId }, 'Archive project failed during delete hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId, status: project.status }, 'Archive project delete hooks completed') + } + if (project.status !== 'archived' && !projectDb.locked) { + await prisma.project.update({ where: { id: projectId }, data: { locked: false } }) + } + if (results.failed) { + return new Unprocessable422('Echec des services à la suppression du projet') + } + + // Empty the project after successful hooks so plugins still have access to repos and environments + await Promise.all([ + deleteAllRepositoryForProject(projectId), + deleteAllEnvironmentForProject(projectId), + ]) + + // Retrait clusters -- + await prisma.project.update({ + where: { id: projectId }, + data: { + clusters: { set: [] }, + }, + }) + + // -- fin - Suppression projet -- + logger.info({ requestId, userId: requestor.id, projectId }, 'Archive project completed') + return null +} + +export async function generateProjectsData() { + const projects = await getAllProjectsDataForExport() + + return json2csv(projects, { + emptyFieldValue: '', + }) +} + +export async function bulkActionProject(data: typeof projectContract.bulkActionProject.body._type, requestor: UserDetails, requestId: string) { + if (data.projectIds === 'all') { + data.projectIds = (await prisma.project.findMany({ + select: { id: true }, + where: { status: { not: 'archived' } }, + })).map(({ id }) => id) + } + bulkExector(data.projectIds + .map((projectId) => { + if (data.action === 'archive') { + return () => archiveProject(projectId, requestor, requestId) + } + if (data.action === 'lock') { + return () => updateProject({ locked: true }, projectId, requestor, requestId) + } + if (data.action === 'unlock') { + return () => updateProject({ locked: false }, projectId, requestor, requestId) + } + if (data.action === 'replay') { + return () => replayHooks({ projectId, userId: requestor.id, requestId }) + } + // should never been called + return async () => {} + })) +} + +export function chunk(arr: T[], size: number): T[][] { + return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => + arr.slice(i * size, i * size + size)) +} + +async function bulkExector(toExecute: Array<() => Promise>) { + const toExecuteChunked = chunk(toExecute, parallelBulkLimit) + for (const chunkToExecute of toExecuteChunked) { + await Promise.allSettled(chunkToExecute.map(fn => fn())) + } +} diff --git a/.jjconflict-side-1/apps/server/src/resources/project/queries.ts b/.jjconflict-side-1/apps/server/src/resources/project/queries.ts new file mode 100644 index 0000000000..5957b15478 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project/queries.ts @@ -0,0 +1,374 @@ +import type { projectContract, XOR } from '@cpn-console/shared' +import type { + Prisma, + Project, + User, +} from '@prisma/client' +import { PROJECT_PERMS } from '@cpn-console/shared' +import { + ProjectStatus, +} from '@prisma/client' +import prisma from '@/prisma.js' +import { appVersion } from '@/utils/env.js' +import { uuid } from '@/utils/queries-tools.js' + +type ProjectUpdate = Partial> +export function updateProject(id: Project['id'], data: ProjectUpdate) { + return prisma.project.update({ + where: { id }, + data, + include: { members: true }, + }) +} + +// SELECT +type FilterWhere = XOR<{ + userId?: User['id'] + filter: 'all' +}, { + userId: User['id'] | undefined + filter: 'owned' | 'member' +}> +type ListProjectWhere = Omit<(typeof projectContract.listProjects.query._type), 'status_in' | 'status_not_in' | 'status'> + & Pick + & FilterWhere +export async function listProjects({ + description, + locked, + name, + status, + id, + filter, + userId, + search, + lastSuccessProvisionningVersion, +}: ListProjectWhere) { + const whereAnd: Prisma.ProjectWhereInput[] = [] + if (id) whereAnd.push({ id }) + if (locked != null) whereAnd.push({ locked }) + if (name) whereAnd.push({ name }) + if (status) whereAnd.push({ status }) + if (description) whereAnd.push({ description: { contains: description } }) + if (lastSuccessProvisionningVersion) { + if (lastSuccessProvisionningVersion === 'outdated') whereAnd.push({ lastSuccessProvisionningVersion: { not: appVersion } }) + else if (lastSuccessProvisionningVersion === 'last') whereAnd.push({ lastSuccessProvisionningVersion: { equals: appVersion } }) + else whereAnd.push({ lastSuccessProvisionningVersion }) + } + if (search) { + whereAnd.push({ OR: [{ + name: { contains: search }, + }, { + owner: { email: { contains: search } }, + }] }) + } + + if (filter === 'owned') { + whereAnd.push({ ownerId: userId }) + } else if (filter === 'member') { + whereAnd.push({ OR: [{ + members: { some: { userId } }, + }, { + ownerId: userId, + }] }) + } + + return prisma.project.findMany({ + where: { AND: whereAnd }, + include: { + clusters: { select: { id: true } }, + members: { include: { user: true } }, + roles: true, + owner: true, + }, + }) +} + +export function getProjectOrThrow(id: Project['id'] | Project['slug']) { + return prisma.project.findFirstOrThrow({ + where: uuid.test(id) + ? { id } + : { slug: id }, + include: { + clusters: { select: { id: true } }, + members: { include: { user: true } }, + roles: true, + owner: true, + }, + }) +} + +export function getProjectInfosByIdOrThrow(projectId: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { + id: projectId, + }, + include: { + environments: true, + clusters: { include: { zone: true } }, + }, + }) +} + +export function getProjectMembers(projectId: Project['id']) { + return prisma.projectMembers.findMany({ + where: { + projectId, + }, + include: { user: true }, + }) +} + +export function getProjectById(id: Project['id']) { + return prisma.project.findUnique({ where: { id } }) +} + +export const baseProjectIncludes = { + members: { include: { user: true } }, + clusters: true, + roles: true, + owner: true, +} as const + +export function getProjectInfos(id: Project['id']) { + return prisma.project.findUnique({ + where: { id }, + include: baseProjectIncludes, + }) +} + +export function getProjectInfosOrThrow(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: baseProjectIncludes, + }) +} + +export function getProjectInfosAndRepos(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: { + ...baseProjectIncludes, + repositories: true, + }, + }) +} + +export function getSlugs(slugPrefix: string) { + return prisma.project.findMany({ + where: { + slug: { startsWith: slugPrefix }, + }, + }) +} + +export function getAllProjectsDataForExport() { + return prisma.project.findMany({ + select: { + name: true, + description: true, + createdAt: true, + updatedAt: true, + environments: { + select: { + name: true, + stage: true, + cluster: { + select: { label: true }, + }, + }, + }, + owner: true, + }, + }) +} + +export function getRolesByProjectId(projectId: Project['id']) { + return prisma.projectRole.findMany({ + where: { projectId }, + }) +} + +const clusterInfosSelect = { + id: true, + infos: true, + label: true, + privacy: true, + secretName: true, + kubeconfig: true, + clusterResources: true, + cpu: true, + gpu: true, + memory: true, + zone: { + select: { + id: true, + slug: true, + argocdUrl: true, + label: true, + }, + }, +} +export function getHookProjectInfos(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: { + members: { include: { user: true }, where: { user: { type: 'human' } } }, + clusters: { select: clusterInfosSelect }, + environments: { + include: { + stage: true, + cluster: { + select: clusterInfosSelect, + }, + }, + }, + repositories: true, + plugins: { + select: { + key: true, + pluginName: true, + value: true, + }, + }, + owner: true, + roles: true, + }, + }) +} + +// CREATE +interface CreateProjectParams { + name: Project['name'] + description?: Project['description'] + ownerId: User['id'] + slug: Project['slug'] + limitless: boolean + hprodCpu: number + hprodGpu: number + hprodMemory: number + prodCpu: number + prodGpu: number + prodMemory: number +} + +export function initializeProject(params: CreateProjectParams) { + return prisma.project.create({ + data: { + description: params.description ?? '', + status: ProjectStatus.created, + locked: false, + roles: { + create: [ + { + name: 'Administrateur', + permissions: PROJECT_PERMS.MANAGE, + position: 0, + oidcGroup: `/${params.slug}/console/admin`, + type: 'system:managed', + }, + { + name: 'DevOps', + permissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 1, + oidcGroup: `/${params.slug}/console/devops`, + type: 'system:managed', + }, + { + name: 'Développeur', + permissions: PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 2, + oidcGroup: `/${params.slug}/console/developer`, + type: 'system:managed', + }, + { + name: 'Sécurité', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 3, + oidcGroup: `/${params.slug}/console/security`, + type: 'system:managed', + }, + { + name: 'Lecture seule', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 4, + oidcGroup: `/${params.slug}/console/readonly`, + type: 'system:managed', + }, + ], + }, + ...params, + }, + }) +} + +// UPDATE +export function lockProject(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { locked: true }, + }) +} + +export function updateProjectCreated(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { + status: ProjectStatus.created, + lastSuccessProvisionningVersion: appVersion, + }, + include: baseProjectIncludes, + }) +} + +export function updateProjectFailed(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { status: ProjectStatus.failed }, + include: baseProjectIncludes, + }) +} + +export function updateProjectWarning(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { status: ProjectStatus.warning }, + include: baseProjectIncludes, + }) +} + +export function addUserToProject({ project, user }: { project: Project, user: User }) { + return prisma.projectMembers.create({ + data: { + userId: user.id, + projectId: project.id, + }, + }) +} + +export function removeUserFromProject({ projectId, userId }: { projectId: Project['id'], userId: User['id'] }) { + return prisma.projectMembers.delete({ + where: { + projectId_userId: { + projectId, + userId, + }, + }, + }) +} + +export async function archiveProject(id: Project['id']) { + const project = await prisma.project.findUnique({ + where: { id }, + select: { name: true, slug: true }, + }) + return prisma.project.update({ + where: { id }, + data: { + name: `${project?.name}_${Date.now()}_archived`, + slug: `${project?.slug}_${Date.now()}_archived`, + status: ProjectStatus.archived, + locked: true, + }, + include: baseProjectIncludes, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/project/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/project/router.spec.ts new file mode 100644 index 0000000000..8c5a2ed820 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project/router.spec.ts @@ -0,0 +1,473 @@ +import type { ProjectV2 } from '@cpn-console/shared' +import type { UserDetails } from '../../types/index.js' +import { ADMIN_PERMS, PROJECT_PERMS, projectContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getRandomRequestor, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listProjects') +const businessCreateMock = vi.spyOn(business, 'createProject') +const businessUpdateMock = vi.spyOn(business, 'updateProject') +const businessDeleteMock = vi.spyOn(business, 'archiveProject') +const businessSyncMock = vi.spyOn(business, 'replayHooks') +const bulkActionProjectMock = vi.spyOn(business, 'bulkActionProject') +const businessGetSecretsMock = vi.spyOn(business, 'getProjectSecrets') +const businessGenerateDataMock = vi.spyOn(business, 'generateProjectsData') + +describe('test projectContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + const projectOwner: ProjectV2['owner'] = { + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + id: faker.string.uuid(), + type: 'human', + } + const projectId = faker.string.uuid() + const project: Omit = { + name: faker.string.alpha({ length: 10, casing: 'lower' }), + slug: faker.string.alpha({ length: 5, casing: 'lower' }), + description: faker.string.alpha({ length: 5 }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + clusterIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + locked: false, + status: 'created', + everyonePerms: '0', + members: [], + owner: projectOwner, + ownerId: projectOwner.id, + roles: [], + lastSuccessProvisionningVersion: null, + } + describe('check unauthorized user on project behaviour', () => { + // UPDATE + it('on Update', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + it('on Update without enough perms', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // REPLAY + it('on replay', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(businessSyncMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // SECRETS + it('on see secret', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(businessGetSecretsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // ARCHIVE + it('on archive', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', projectId)) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + }) + describe('listProjects', () => { + it('should return list of projects', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + const projects: any[] = [] + businessListMock.mockResolvedValueOnce(projects) + const response = await app.inject() + .get(projectContract.listProjects.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(projects) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin with "all" filter', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + const projects: any[] = [] + businessListMock.mockResolvedValueOnce(projects) + + const response = await app.inject() + .get(projectContract.listProjects.path) + .query({ filter: 'all' }) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return list of projects for admin', async () => { + const projects: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(projects) + + const response = await app.inject() + .get(projectContract.listProjects.path) + .query({ filter: 'all' }) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createProject', () => { + it('should create and return project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce({ id: projectId, ...project }) + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: projectId, ...project }) + expect(response.statusCode).toEqual(201) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateProject', () => { + const projectUpdated: Partial = { description: faker.string.alpha({ length: 5 }) } + + it('should update and return project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should not update ownerId if not permitted', async () => { + const userDetails = getRandomRequestor() + const projectPerms = getProjectMockInfos({ projectOwnerId: faker.string.uuid(), projectPermissions: PROJECT_PERMS.MANAGE }) + const projectUpdated = { ownerId: faker.string.uuid(), description: faker.lorem.words() } + const user = getUserMockInfos(0n, userDetails as UserDetails, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith({ description: projectUpdated.description }, projectId, user.user, expect.any(String)) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should update ownerId and return project', async () => { + const requestor = getRandomRequestor() + const projectPerms = getProjectMockInfos({ projectOwnerId: requestor.id, projectPermissions: PROJECT_PERMS.MANAGE }) + const projectUpdated = { ownerId: faker.string.uuid(), description: faker.lorem.words() } + const user = getUserMockInfos(0n, requestor as UserDetails, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith(projectUpdated, projectId, user.user, expect.any(String)) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(400) + }) + }) + + describe('archiveProject', () => { + it('should archive project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return projects data for admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getProjectSecrets', () => { + it('should return project secrets for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const secrets = {} + businessGetSecretsMock.mockResolvedValueOnce(secrets) + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(businessGetSecretsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(secrets) + expect(response.statusCode).toEqual(200) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetSecretsMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized access to secrets', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('replayHooksForProject', () => { + it('should replay hooks for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(businessSyncMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized access to replay hooks', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getProjectsData', () => { + it('should return projects data for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + const data = '' + businessGenerateDataMock.mockResolvedValueOnce(data) + const response = await app.inject() + .get(projectContract.getProjectsData.path) + .end() + + expect(businessGenerateDataMock).toHaveBeenCalledTimes(1) + expect(response.body).toEqual(data) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin user', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectsData.path) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('bulkActionProject', () => { + it('should executebulk for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + const response = await app.inject() + .post(projectContract.bulkActionProject.path) + .body({ action: 'lock', projectIds: [projectId] }) + .end() + + expect(response.json()).toBeNull() + expect(bulkActionProjectMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(202) + }) + + it('should return 403 for unauthorized access to bulk update', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const response = await app.inject() + .post(projectContract.bulkActionProject.path) + .body({ action: 'lock', projectIds: [projectId] }) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/project/router.ts b/.jjconflict-side-1/apps/server/src/resources/project/router.ts new file mode 100644 index 0000000000..b969303ed2 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/project/router.ts @@ -0,0 +1,186 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, ProjectAuthorized, projectContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, NotFound404, Unauthorized401 } from '@/utils/errors.js' +import { + archiveProject, + bulkActionProject, + createProject, + generateProjectsData, + getProject, + getProjectSecrets, + listProjects, + replayHooks, + updateProject, +} from './business.js' + +export function projectRouter() { + return serverInstance.router(projectContract, { + + // Récupérer des projets + listProjects: async ({ request: req, query }) => { + const { adminPermissions, user } = await authUser(req) + let body: AsyncReturnType = [] + + if (adminPermissions && !user) { // c'est donc un compte de service + query.filter = 'all' + } + if (query.filter === 'all' && !AdminAuthorized.Manage(adminPermissions)) { + return new Forbidden403('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + + body = await listProjects( + query, + user?.id, + ) + + return { + status: 200, + body, + } + }, + + // Récupérer les secrets d'un projet + getProjectSecrets: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.SeeSecrets(perms)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await getProjectSecrets(projectId) + + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Créer un projet + createProject: async ({ request: req, body: data }) => { + const perms = await authUser(req) + if (perms.user?.type !== 'human') return new Unauthorized401('Cannot find requestor in database') + if (!AdminAuthorized.ManageProjects(perms.adminPermissions)) return new Forbidden403() + + const body = await createProject(data, perms.user, req.id) + + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Récuperer un seul projet + getProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.projectId) return new NotFound404() + if (!perms.projectPermissions && !AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new NotFound404() + + const body = await getProject(projectId) + + return { + status: 200, + body, + } + }, + + // Mettre à jour un projet + updateProject: async ({ request: req, params, body: data }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!ProjectAuthorized.Manage(perms)) return new Forbidden403() + + const isAdmin = AdminAuthorized.Manage(perms.adminPermissions) + const isOwner = perms.projectOwnerId === perms.user.id + if (!isAdmin) { + delete data.locked + if (!isOwner) delete data.ownerId // impossible de toucher à cette clé + } + if (perms.projectLocked) { + if (!isAdmin) return new Forbidden403('Le projet est verrouillé') + if (data.locked !== false) return new Forbidden403('Veuillez déverrouiler le projet pour le mettre à jour') + } + + const body = await updateProject(data, projectId, perms.user, req.id) + + if (body instanceof ErrorResType) return body + return { + status: 200, + body, + } + }, + + // Reprovisionner un projet + replayHooksForProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ReplayHooks(perms)) return new Forbidden403() + + const body = await replayHooks({ + projectId, + userId: perms.user?.id, + requestId: req.id, + }) + + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + + // Archiver un projet + archiveProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!ProjectAuthorized.Manage(perms)) return new Forbidden403() + + const body = await archiveProject(projectId, perms.user, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + // Récupérer les données de tous les projets pour export + getProjectsData: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + const body = await generateProjectsData() + + return { + status: 200, + body, + } + }, + + bulkActionProject: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + + await bulkActionProject(body, perms.user, req.id) + + return { + status: 202, + body: null, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/queries-index.ts b/.jjconflict-side-1/apps/server/src/resources/queries-index.ts new file mode 100644 index 0000000000..a2c9787d2a --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/queries-index.ts @@ -0,0 +1,14 @@ +export * from '@/resources/admin-role/queries.js' +export * from '@/resources/cluster/queries.js' +export * from '@/resources/environment/queries.js' +export * from '@/resources/log/queries.js' +export * from '@/resources/project-member/queries.js' +export * from '@/resources/project-role/queries.js' +export * from '@/resources/project-service/queries.js' +export * from '@/resources/project/queries.js' +export * from '@/resources/repository/queries.js' +export * from '@/resources/service-chain/queries.js' +export * from '@/resources/stage/queries.js' +export * from '@/resources/system/settings/queries.js' +export * from '@/resources/user/queries.js' +export * from '@/resources/zone/queries.js' diff --git a/.jjconflict-side-1/apps/server/src/resources/repository/business.ts b/.jjconflict-side-1/apps/server/src/resources/repository/business.ts new file mode 100644 index 0000000000..473c9c73a7 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/repository/business.ts @@ -0,0 +1,115 @@ +import type { CreateRepositoryBody, UpdateRepositoryBody } from '@cpn-console/shared' +import type { Project, Repository, User } from '@prisma/client' +import { addLogs, deleteRepository as deleteRepositoryQuery, getProjectInfosAndRepos, getProjectRepositories as getProjectRepositoriesQuery, initializeRepository, updateRepository as updateRepositoryQuery } from '@/resources/queries-index.js' +import { BadRequest400, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function getProjectRepositories(projectId: Project['id']) { + return getProjectRepositoriesQuery(projectId) +} + +export async function syncRepository({ + repositoryId, + userId, + syncAllBranches, + branchName, + requestId, +}: { + repositoryId: Repository['id'] + userId: User['id'] + syncAllBranches: boolean + branchName?: string + requestId: string +}) { + const hookReply = await hook.misc.syncRepository(repositoryId, { syncAllBranches, branchName }) + await addLogs({ action: 'Sync Repository', data: hookReply, userId, requestId, projectId: hookReply.args.id }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la synchronisation du dépôt') + } + return null +} + +export async function createRepository({ + data, + userId, + requestId, +}: { + data: CreateRepositoryBody + userId: User['id'] + requestId: string +}) { + const project = await getProjectInfosAndRepos(data.projectId) + + if (project.repositories?.find(repo => repo.internalRepoName === data.internalRepoName)) return new BadRequest400(`Le nom du dépôt interne ${data.internalRepoName} existe déjà en base pour ce projet`) + const dbData = { ...data, isInfra: !!data.isInfra, isPrivate: !!data.isPrivate } + delete dbData.externalToken + + const repo = await initializeRepository(dbData) + const { results } = await hook.project.upsert(project.id, data.isPrivate + ? { + [repo.internalRepoName]: { + token: data.externalToken ?? '', + username: data.externalUserName ?? '', + }, + } + : undefined) + await addLogs({ action: 'Create Repository', data: results, userId, requestId, projectId: repo.projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services lors de la création du dépôt') + } + + if (data.externalRepoUrl) { + await syncRepository({ repositoryId: repo.id, requestId, syncAllBranches: true, userId }) + } + return repo +} + +export async function updateRepository({ + repositoryId, + data, + userId, + requestId, +}: { + repositoryId: Repository['id'] + data: Partial + userId: User['id'] + requestId: string +}) { + const dbData = { ...data } + delete dbData.externalToken + const repo = await updateRepositoryQuery(repositoryId, dbData) + + const { results } = await hook.project.upsert(repo.projectId, { + [repo.internalRepoName]: { + username: repo.externalUserName ?? '', + token: data.externalToken ?? '', + }, + }) + await addLogs({ action: 'Update Repository', data: results, userId, requestId, projectId: repo.projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services à la mise à jour du dépôt') + } + + return repo +} + +export async function deleteRepository({ + repositoryId, + userId, + requestId, + projectId, +}: { + repositoryId: Repository['id'] + userId: User['id'] + requestId: string + projectId: Project['id'] +}) { + await deleteRepositoryQuery(repositoryId) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Delete Repository', data: results, userId, requestId, projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services à la suppression du dépôt') + } + return null +} diff --git a/.jjconflict-side-1/apps/server/src/resources/repository/queries.ts b/.jjconflict-side-1/apps/server/src/resources/repository/queries.ts new file mode 100644 index 0000000000..277e7af602 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/repository/queries.ts @@ -0,0 +1,62 @@ +import type { Project, Repository } from '@prisma/client' +import prisma from '@/prisma.js' + +// SELECT +export function getRepositoryById(id: Repository['id']) { + return prisma.repository.findUniqueOrThrow({ where: { id } }) +} + +export function getProjectRepositories(projectId: Project['id']) { + return prisma.repository.findMany({ where: { projectId } }) +} + +// CREATE +type RepositoryCreate = Pick + & Partial> + +export function initializeRepository({ projectId, internalRepoName, externalRepoUrl, isInfra, isPrivate, externalUserName, deployRevision, deployPath, helmValuesFiles }: RepositoryCreate) { + return prisma.repository.create({ + data: { + projectId, + internalRepoName, + externalRepoUrl, + externalUserName, + isInfra, + isPrivate, + deployRevision, + deployPath, + helmValuesFiles, + }, + }) +} + +export function getHookRepository(id: Repository['id']) { + return prisma.repository.findUniqueOrThrow({ + where: { + id, + }, + include: { + project: true, + }, + }) +} + +// UPDATE +export function updateRepository(id: Repository['id'], infos: Partial) { + return prisma.repository.update({ where: { id }, data: { ...infos } }) +} + +// DELETE +export async function deleteRepository(id: Repository['id']) { + const doesRepoExist = await getRepositoryById(id) + if (!doesRepoExist) throw new Error('Le dépôt interne demandé n\'existe pas en base pour ce projet') + return prisma.repository.delete({ where: { id } }) +} + +export function deleteAllRepositoryForProject(id: Project['id']) { + return prisma.repository.deleteMany({ where: { projectId: id } }) +} + +export function _createRepository(data: Parameters[0]['create']) { + return prisma.repository.upsert({ create: data, update: data, where: { id: data.id } }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/repository/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/repository/router.spec.ts new file mode 100644 index 0000000000..ae08505be0 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/repository/router.spec.ts @@ -0,0 +1,404 @@ +import { PROJECT_PERMS, repositoryContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { atDates, getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessCreateMock = vi.spyOn(business, 'createRepository') +const businessUpdateMock = vi.spyOn(business, 'updateRepository') +const businessDeleteMock = vi.spyOn(business, 'deleteRepository') +const businessSyncMock = vi.spyOn(business, 'syncRepository') +const businessGetProjectRepositoriesMock = vi.spyOn(business, 'getProjectRepositories') + +describe('repositoryRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const repositoryId = faker.string.uuid() + const repositoryData = { + projectId, + externalRepoUrl: `${faker.internet.url()}.git`, + isPrivate: true, + externalToken: faker.string.alpha(), + externalUserName: faker.internet.username(), + isInfra: false, + internalRepoName: faker.string.alpha({ length: 5, casing: 'lower' }), + } + + describe('listRepositories', () => { + it('should return repositories for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetProjectRepositoriesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(repositoryContract.listRepositories.path) + .query({ projectId }) + .end() + + expect(businessGetProjectRepositoriesMock).toHaveBeenCalledWith(projectId) + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.REPLAY_HOOKS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(repositoryContract.listRepositories.path) + .query({ projectId }) + .end() + + expect(businessGetProjectRepositoriesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('syncRepository', () => { + it('should synchronize repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(204) + expect(businessSyncMock).toHaveBeenCalledWith({ repositoryId, userId: user.user.id, branchName: 'main', requestId: expect.any(String), syncAllBranches: false }) + }) + + it('should return 403 for forbidden sync attempt', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('createRepository', () => { + it('should create repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...atDates }) + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(201) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should return 403 for insuficient permissions', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateRepository', () => { + const repoUpdateData = { isInfra: true } + it('should update repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...repoUpdateData, ...atDates }) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(200) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData, ...repoUpdateData }) + }) + + it('should update repository and drop creds if is not private', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const repoUpdateData = { isPrivate: false, externalUserName: 'test' } + businessUpdateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...repoUpdateData, ...atDates }) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith({ data: { isPrivate: false }, repositoryId, requestId: expect.any(String), userId: user.user.id }) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData, ...repoUpdateData }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not enough permissions', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(400) + }) + // TODO add tests about filtering + }) + + describe('deleteRepository', () => { + it('should delete repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not enough privilege', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/repository/router.ts b/.jjconflict-side-1/apps/server/src/resources/repository/router.ts new file mode 100644 index 0000000000..a9e93209ee --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/repository/router.ts @@ -0,0 +1,133 @@ +import { fakeToken, ProjectAuthorized, repositoryContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { filterObjectByKeys } from '@/utils/queries-tools.js' +import { + createRepository, + deleteRepository, + getProjectRepositories, + syncRepository, + updateRepository, +} from './business.js' + +export function repositoryRouter() { + return serverInstance.router(repositoryContract, { + // Récupérer tous les repositories d'un projet + listRepositories: async ({ request: req, query }) => { + const projectId = query.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListRepositories(perms)) return new Forbidden403() + const body = await getProjectRepositories(projectId) + + return { + status: 200, + body, + } + }, + + // Synchroniser un repository + syncRepository: async ({ request: req, params, body }) => { + const { repositoryId } = params + const perms = await authUser(req, { repositoryId }) + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const { syncAllBranches, branchName } = body + + const resBody = await syncRepository({ repositoryId, userId: perms.user.id, branchName, requestId: req.id, syncAllBranches }) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 204, + body: resBody, + } + }, + + // Créer un repository + createRepository: async ({ request: req, body: data }) => { + const projectId = data.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await createRepository({ data, userId: perms.user.id, requestId: req.id }) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Mettre à jour un repository + updateRepository: async ({ request: req, params, body }) => { + const repositoryId = params.repositoryId + const perms = await authUser(req, { repositoryId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const keysAllowedForUpdate = [ + 'externalRepoUrl', + 'isPrivate', + 'externalToken', + 'externalUserName', + 'isInfra', + 'deployRevision', + 'deployPath', + 'helmValuesFiles', + ] + const data = filterObjectByKeys(body, keysAllowedForUpdate) + + if (data.externalToken === fakeToken) { + delete data.externalToken + } + + if (data.isPrivate === false) { + delete data.externalToken + delete data.externalUserName + } + + const resBody = await updateRepository({ repositoryId, data, userId: perms.user.id, requestId: req.id }) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + // Supprimer un repository + deleteRepository: async ({ request: req, params }) => { + const repositoryId = params.repositoryId + const perms = await authUser(req, { repositoryId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await deleteRepository({ + repositoryId, + userId: perms.user.id, + requestId: req.id, + projectId: perms.projectId, + }) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/service-chain/business.spec.ts b/.jjconflict-side-1/apps/server/src/resources/service-chain/business.spec.ts new file mode 100644 index 0000000000..a3903e6eff --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/service-chain/business.spec.ts @@ -0,0 +1,171 @@ +import type { + ServiceChain, + ServiceChainDetails, + ServiceChainFlows, +} from '@cpn-console/shared' +import type { Mock } from 'vitest' +import { + serviceChainEnvironmentEnum, + serviceChainFlowStateEnum, + serviceChainLocationEnum, + serviceChainNetworkEnum, + serviceChainStateEnum, +} from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import axios from 'axios' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + getServiceChainDetails, + getServiceChainFlows, + listServiceChains, + retryServiceChain, + validateServiceChain, +} from './business.ts' + +vi.mock('axios') + +let serviceChain: ServiceChain +let serviceChainDetails: ServiceChainDetails +let serviceChainFlows: ServiceChainFlows + +describe('test ServiceChain business logic', () => { + beforeEach(() => { + serviceChain = { + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + } + + serviceChainDetails = { + ...serviceChain, + validationId: faker.string.uuid(), + validatedBy: faker.helpers.maybe(() => faker.string.uuid()) || null, + ref: faker.string.uuid(), + location: faker.helpers.arrayElement(serviceChainLocationEnum), + targetAddress: faker.internet.ipv4(), + projectId: faker.string.uuid(), + env: faker.helpers.arrayElement(serviceChainEnvironmentEnum), + subjectAlternativeName: faker.helpers.uniqueArray( + faker.internet.domainName, + 3, + ), + redirect: faker.datatype.boolean(), + antivirus: + faker.helpers.maybe(() => ({ + maxFileSize: faker.number.int(), + })) || null, // undefined is not wanted here + websocket: faker.datatype.boolean(), + ipWhiteList: faker.helpers + .uniqueArray(faker.internet.ipv4, 5) + .map(e => `${e}/32`), // We want a CIDR here + sslOutgoing: faker.datatype.boolean(), + } + + serviceChainFlows = { + reserve_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + create_cert: faker.helpers.maybe(() => ({ + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + })) || null, + call_exec: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + activate_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + dns_request: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + } + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('listServiceChains', () => { + it('should return a list of service chains', async () => { + const input = [serviceChain]; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await listServiceChains() + + expect(result).toStrictEqual(input) + }) + }) + + describe('getServiceChainDetails', () => { + it('should return a service chain details', async () => { + const input = serviceChainDetails; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await getServiceChainDetails(faker.string.uuid()) + + expect(result).toStrictEqual(input) + }) + }) + + describe('retryServiceChain', () => { + it('should trigger a service chain retry attempt', async () => { + const input = {}; + (axios.create as Mock).mockReturnValue({ + post: () => ({ data: input }), + }) + + const result = await retryServiceChain(faker.string.uuid()) + + expect(result.data).toStrictEqual(input) + }) + }) + + describe('validateServiceChain', () => { + it('should trigger a service chain validate attempt', async () => { + const input = {}; + (axios.create as Mock).mockReturnValue({ + post: () => ({ data: input }), + }) + + const result = await validateServiceChain(faker.string.uuid()) + + expect(result.data).toStrictEqual(input) + }) + }) + + describe('getServiceChainFlows', () => { + it('should return a service chain flows', async () => { + const input = serviceChainFlows; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await getServiceChainFlows(faker.string.uuid()) + + expect(result).toStrictEqual(input) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/service-chain/business.ts b/.jjconflict-side-1/apps/server/src/resources/service-chain/business.ts new file mode 100644 index 0000000000..43ffb986e6 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/service-chain/business.ts @@ -0,0 +1,27 @@ +import { + getServiceChainDetails as getServiceChainDetailsQuery, + getServiceChainFlows as getServiceChainFlowsQuery, + listServiceChains as listServiceChainsQuery, + retryServiceChain as retryServiceChainQuery, + validateServiceChain as validateServiceChainQuery, +} from '@/resources/queries-index.js' + +export async function listServiceChains() { + return listServiceChainsQuery() +} + +export async function getServiceChainDetails(serviceChainId: string) { + return getServiceChainDetailsQuery(serviceChainId) +} + +export async function retryServiceChain(serviceChainId: string) { + return retryServiceChainQuery(serviceChainId) +} + +export async function validateServiceChain(validationId: string) { + return validateServiceChainQuery(validationId) +} + +export async function getServiceChainFlows(serviceChainId: string) { + return getServiceChainFlowsQuery(serviceChainId) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/service-chain/queries.ts b/.jjconflict-side-1/apps/server/src/resources/service-chain/queries.ts new file mode 100644 index 0000000000..0207266c8a --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/service-chain/queries.ts @@ -0,0 +1,59 @@ +import type { ServiceChain } from '@cpn-console/shared' +import https from 'node:https' +import { + + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainListSchema, +} from '@cpn-console/shared' +import axios from 'axios' + +const openCDSEnvVar = 'OPENCDS_URL' +const openCDSTargetURL = process.env[openCDSEnvVar] +const openCDSDisabledErrorMessage = `OpenCDS is disabled, please set ${openCDSEnvVar} in your relevant .env file. See .env-example` + +function getClient() { + if (!openCDSTargetURL) { + throw new Error(openCDSDisabledErrorMessage) + } + return axios.create({ + baseURL: openCDSTargetURL, + httpsAgent: new https.Agent({ + rejectUnauthorized: + // We want it to be `false` only if it has explicitly + // been stated as "false" in the env vars + process.env.OPENCDS_API_TLS_REJECT_UNAUTHORIZED !== 'false', + }), + headers: { + 'X-API-Key': process.env.OPENCDS_API_TOKEN, + }, + }) +} + +export async function listServiceChains() { + return ServiceChainListSchema.parse( + (await getClient().get(`/requests`)).data, + ) +} + +export async function getServiceChainDetails( + serviceChainId: ServiceChain['id'], +) { + return ServiceChainDetailsSchema.parse( + (await getClient().get(`/requests/${serviceChainId}`)).data, + ) +} + +export async function retryServiceChain(serviceChainId: ServiceChain['id']) { + return await getClient().post(`/requests/${serviceChainId}/retry`) +} + +export async function validateServiceChain(validationId: string) { + return await getClient().post(`/validate/${validationId}`) +} + +export async function getServiceChainFlows(serviceChainId: ServiceChain['id']) { + return ServiceChainFlowsSchema.parse( + (await getClient().get(`/requests/${serviceChainId}/flows`)).data, + ) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/service-chain/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/service-chain/router.spec.ts new file mode 100644 index 0000000000..c69c14edff --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/service-chain/router.spec.ts @@ -0,0 +1,307 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import { + ADMIN_PERMS, + serviceChainContract, + ServiceChainDetailsSchema, + serviceChainEnvironmentEnum, + ServiceChainFlowsSchema, + serviceChainFlowStateEnum, + ServiceChainListSchema, + serviceChainLocationEnum, + serviceChainNetworkEnum, + serviceChainStateEnum, +} from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock( + 'fastify-keycloak-adapter', + (await import('../../utils/mocks.js')).mockSessionPlugin, +) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListServiceChainsMock = vi.spyOn(business, 'listServiceChains') +const businessGetServiceChainDetailsMock = vi.spyOn(business, 'getServiceChainDetails') +const businessRetryServiceChainMock = vi.spyOn(business, 'retryServiceChain') +const businessValidateServiceChainMock = vi.spyOn(business, 'validateServiceChain') +const businessGetServiceChainsFlowsMock = vi.spyOn(business, 'getServiceChainFlows') + +describe('test ServiceChainContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listServiceChains', () => { + it('as non admin', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + businessListServiceChainsMock.mockResolvedValueOnce([]) + const response = await app + .inject() + .get(serviceChainContract.listServiceChains.path) + .end() + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('as admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const serviceChainList = faker.helpers.multiple(() => ({ + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + })) + + authUserMock.mockResolvedValueOnce(user) + + businessListServiceChainsMock.mockResolvedValueOnce(serviceChainList) + const response = await app + .inject() + .get(serviceChainContract.listServiceChains.path) + .end() + + expect(businessListServiceChainsMock).toHaveBeenCalledWith() + + expect(ServiceChainListSchema.parse(response.json())).toStrictEqual( + serviceChainList, + ) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getServiceChainDetails', () => { + it('should return serviceChain details', async () => { + const serviceChainDetails: ServiceChainDetails = { + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + validationId: faker.string.uuid(), + validatedBy: faker.string.uuid(), + ref: faker.string.uuid(), + location: faker.helpers.arrayElement(serviceChainLocationEnum), + targetAddress: faker.internet.ipv4(), + projectId: faker.string.uuid(), + env: faker.helpers.arrayElement(serviceChainEnvironmentEnum), + subjectAlternativeName: faker.helpers.uniqueArray( + faker.internet.domainName, + 3, + ), + redirect: faker.datatype.boolean(), + antivirus: + faker.helpers.maybe(() => ({ + maxFileSize: faker.number.int(), + })) || null, // undefined is not wanted here + websocket: faker.datatype.boolean(), + ipWhiteList: faker.helpers + .uniqueArray(faker.internet.ipv4, 5) + .map(e => `${e}/32`), // We want a CIDR here + sslOutgoing: faker.datatype.boolean(), + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessGetServiceChainDetailsMock.mockResolvedValueOnce(serviceChainDetails) + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainDetails.path.replace( + ':serviceChainId', + serviceChainDetails.id, + ), + ) + .end() + + expect(ServiceChainDetailsSchema.parse(response.json())).toEqual( + serviceChainDetails, + ) + expect(response.statusCode).toEqual(200) + expect(businessGetServiceChainDetailsMock).toHaveBeenCalledTimes(1) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainDetails.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessGetServiceChainDetailsMock).toHaveBeenCalledTimes(0) + }) + }) + + describe('retryServiceChain', () => { + it('should return 204', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessRetryServiceChainMock.mockResolvedValueOnce({ + status: 204, + body: undefined, + }) + const response = await app + .inject() + .post( + serviceChainContract.retryServiceChain.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.body).toEqual('') + expect(businessRetryServiceChainMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(204) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .post( + serviceChainContract.retryServiceChain.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessRetryServiceChainMock).toHaveBeenCalledTimes(0) + }) + }) + + describe('validateServiceChain', () => { + it('should return 204', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessValidateServiceChainMock.mockResolvedValueOnce({ + status: 204, + body: undefined, + }) + const response = await app + .inject() + .post( + serviceChainContract.validateServiceChain.path.replace( + ':validationId', + faker.string.uuid(), + ), + ) + .end() + + expect(businessValidateServiceChainMock).toHaveBeenCalledTimes(1) + expect(response.body).toEqual('') + expect(response.statusCode).toEqual(204) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .post( + serviceChainContract.validateServiceChain.path.replace( + ':validationId', + faker.string.uuid(), + ), + ) + .end() + + expect(businessValidateServiceChainMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getServiceChainFlows', () => { + it('should return serviceChain flows', async () => { + const serviceChainFlows: ServiceChainFlows = { + reserve_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + create_cert: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + call_exec: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + activate_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + dns_request: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessGetServiceChainsFlowsMock.mockResolvedValueOnce(serviceChainFlows) + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainFlows.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(ServiceChainFlowsSchema.parse(response.json())).toEqual( + serviceChainFlows, + ) + expect(response.statusCode).toEqual(200) + expect(businessGetServiceChainsFlowsMock).toHaveBeenCalledTimes(1) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainFlows.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessGetServiceChainsFlowsMock).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/service-chain/router.ts b/.jjconflict-side-1/apps/server/src/resources/service-chain/router.ts new file mode 100644 index 0000000000..5888418cfe --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/service-chain/router.ts @@ -0,0 +1,94 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, serviceChainContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { + getServiceChainDetails as getServiceChainDetailsBusiness, + getServiceChainFlows as getServiceChainFlowsBusiness, + listServiceChains as listServiceChainsBusiness, + retryServiceChain as retryServiceChainBusiness, + validateServiceChain as validateServiceChainBusiness, +} from './business.js' +import '@/types/index.js' + +export function serviceChainRouter() { + return serverInstance.router(serviceChainContract, { + listServiceChains: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + let body: AsyncReturnType = [] + if (AdminAuthorized.ListSystem(adminPermissions)) { + body = await listServiceChainsBusiness() + } + + return { + status: 200, + body, + } + }, + + getServiceChainDetails: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + const serviceChainDetails + = await getServiceChainDetailsBusiness(serviceChainId) + + return { + status: 200, + body: serviceChainDetails, + } + }, + + retryServiceChain: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + await retryServiceChainBusiness(serviceChainId) + + return { + status: 204, + body: null, + } + }, + + validateServiceChain: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.validationId + await validateServiceChainBusiness(serviceChainId) + + return { + status: 204, + body: null, + } + }, + + getServiceChainFlows: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + const serviceChainFlows + = await getServiceChainFlowsBusiness(serviceChainId) + + return { + status: 200, + body: serviceChainFlows, + } + }, + + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/service-monitor/business.ts b/.jjconflict-side-1/apps/server/src/resources/service-monitor/business.ts new file mode 100644 index 0000000000..fa61d5a6de --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/service-monitor/business.ts @@ -0,0 +1,9 @@ +import { services } from '@cpn-console/hooks' + +export function checkServicesHealth() { + return services.getStatus() +} + +export async function refreshServicesHealth() { + return Promise.all(services.refreshStatus()) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/service-monitor/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/service-monitor/router.spec.ts new file mode 100644 index 0000000000..898ed61e5a --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/service-monitor/router.spec.ts @@ -0,0 +1,78 @@ +import type { ServiceStatus } from '@cpn-console/hooks' +import { ADMIN_PERMS, MonitorStatus, serviceContract } from '@cpn-console/shared' +import { describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +const authUserMock = vi.spyOn(utilsController, 'authUser') + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const businessCheckMock = vi.spyOn(business, 'checkServicesHealth') +const businessRefreshMock = vi.spyOn(business, 'refreshServicesHealth') + +describe('test serviceContract', () => { + const services: ServiceStatus[] = [{ interval: 1, lastUpdateTimestamp: 1, message: 'OK', name: 'A service', status: MonitorStatus.OK }] + const servicesComplete: ServiceStatus[] = [{ cause: 'error', interval: 1, lastUpdateTimestamp: 1, message: 'OK', name: 'A service', status: MonitorStatus.OK }] + + it('should return complete services, with cause', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(servicesComplete) + expect(response.statusCode).toEqual(200) + }) + + it('should not return complete services, forbidden', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return services', async () => { + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(services) + expect(response.statusCode).toEqual(200) + }) + + it('should refresh services', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessRefreshMock.mockResolvedValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(servicesComplete) + expect(response.statusCode).toEqual(200) + }) + + it('should refresh services, cause forbidden', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + businessRefreshMock.mockResolvedValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.statusCode).toEqual(403) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/service-monitor/router.ts b/.jjconflict-side-1/apps/server/src/resources/service-monitor/router.ts new file mode 100644 index 0000000000..ace5831c3e --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/service-monitor/router.ts @@ -0,0 +1,44 @@ +import { AdminAuthorized, serviceContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { checkServicesHealth, refreshServicesHealth } from './business.js' + +export function serviceMonitorRouter() { + return serverInstance.router(serviceContract, { + getServiceHealth: async () => { + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + + getCompleteServiceHealth: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ListSystem(adminPermissions)) return new Forbidden403() + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + + refreshServiceHealth: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageSystem(adminPermissions)) return new Forbidden403() + + await refreshServicesHealth() + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/stage/business.spec.ts b/.jjconflict-side-1/apps/server/src/resources/stage/business.spec.ts new file mode 100644 index 0000000000..ba43c2cb75 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/stage/business.spec.ts @@ -0,0 +1,113 @@ +import type { Environment, Stage } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { BadRequest400, NotFound404 } from '../../utils/errors.ts' +import { createStage, deleteStage, getStageAssociatedEnvironments, listStages, updateStage } from './business.ts' + +describe('test stage busines logic', () => { + let stage: Stage + beforeEach(() => { + vi.resetAllMocks() + stage = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + } + }) + describe('createStage', () => { + it('should create a stage', async () => { + prisma.stage.findUnique.mockResolvedValue(null) + prisma.stage.create.mockResolvedValue({ id: stage.id } as Stage) + await createStage({ name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(1) + }) + it('should not create a stage, name conflict', async () => { + prisma.stage.findUnique.mockResolvedValue({ id: stage.id } as Stage) + const response = await createStage({ name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) + + describe('updateStage', () => { + it('should update a stage', async () => { + const dbClusters = [{ id: faker.string.uuid() }] + const newClusters = [faker.string.uuid()] + prisma.stage.findUnique.mockResolvedValue({ ...stage, clusters: dbClusters } as Stage) + prisma.stage.update.mockResolvedValue({ id: stage.id } as Stage) + const response = await updateStage(stage.id, { name: stage.name, clusterIds: newClusters }) + expect(prisma.cluster.update).toHaveBeenCalledTimes(1) + expect(prisma.cluster.update).toHaveBeenCalledWith({ where: { id: dbClusters[0].id }, data: { + stages: { + disconnect: { + id: stage.id, + }, + }, + } }) + expect(prisma.stage.update).toHaveBeenCalledTimes(1) + expect(prisma.stage.update).toHaveBeenCalledWith({ where: { id: stage.id }, data: { + clusters: { + connect: [{ + id: newClusters[0], + }], + }, + } }) + expect(response.clusterIds).toBe(newClusters) + }) + it('should do nothing', async () => { + prisma.stage.findUnique.mockResolvedValue({ ...stage, clusters: [] } as Stage) + await updateStage(stage.id, { clusterIds: [], name: stage.name }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + }) + it('should return not found', async () => { + prisma.stage.findUnique.mockResolvedValue(null) + const response = await updateStage(stage.id, { name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + expect(response).instanceOf(NotFound404) + }) + }) + + describe('deleteStage', () => { + it('should delete a stage', async () => { + prisma.environment.findFirst.mockResolvedValue(null) + prisma.stage.delete.mockResolvedValue({ id: stage.id } as Stage) + await deleteStage(stage.id) + expect(prisma.stage.delete).toHaveBeenCalledTimes(1) + }) + it('should not delete a stage, environment attached', async () => { + prisma.environment.findFirst.mockResolvedValue({ id: faker.string.uuid() } as Environment) + const response = await deleteStage(stage.id) + expect(prisma.stage.delete).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) + + describe('listStages', () => { + const clusterAssociated = [{ id: faker.string.uuid() }] + it('should list all stages (admin, no userId provided)', async () => { + prisma.stage.findMany.mockResolvedValue([{ clusters: clusterAssociated }] as unknown as Stage[]) + const response = await listStages() + expect(response[0].clusterIds).toStrictEqual([clusterAssociated[0].id]) + expect(prisma.stage.findMany).toHaveBeenCalledTimes(1) + expect(prisma.stage.findMany).toHaveBeenCalledWith({ include: { clusters: true } }) + }) + }) + + describe('getStageAssociatedEnvironments', () => { + it('should list all environments attached to a stage stages', async () => { + const envName = faker.string.alpha(8) + const projectSlug = faker.string.alpha(8) + const clusterLabel = faker.string.alpha(8) + const ownerEmail = faker.internet.email() + const envs = [{ name: envName, project: { slug: projectSlug, owner: { email: ownerEmail } }, cluster: { label: clusterLabel } }] + prisma.environment.findMany.mockResolvedValue(envs as unknown as Environment[]) + const response = await getStageAssociatedEnvironments(stage.id) + expect(response).toStrictEqual([{ + name: envName, + project: projectSlug, + owner: ownerEmail, + cluster: clusterLabel, + }]) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/stage/business.ts b/.jjconflict-side-1/apps/server/src/resources/stage/business.ts new file mode 100644 index 0000000000..db6faf686e --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/stage/business.ts @@ -0,0 +1,97 @@ +import type { CreateStageBody, UpdateStageBody } from '@cpn-console/shared' +import type { Cluster, Stage } from '@prisma/client' +import prisma from '@/prisma.js' +import { + createStage as createStageQuery, + deleteStage as deleteStageQuery, + getAllStageIds, + getStageAssociatedEnvironmentById, + getStageById, + getStageByName, + linkClusterToStages as linkClusterToStagesQuery, + linkStageToClusters, + listStages as listStagesQuery, + removeClusterFromStage, + updateStageName, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' + +export async function getStageAssociatedEnvironments(stageId: Stage['id']) { + const environments = await getStageAssociatedEnvironmentById(stageId) + return environments.map(env => ({ + project: env.project.slug, + name: env.name, + cluster: env.cluster.label, + owner: env.project.owner.email, + })) +} + +export async function createStage({ clusterIds = [], name }: CreateStageBody) { + const isNameTaken = await getStageByName(name) + if (isNameTaken) return new BadRequest400('Un type d\'environnement portant ce nom existe déjà') + + const stage = await createStageQuery({ name }) + + if (clusterIds.length) { + await linkStageToClusters(stage.id, clusterIds) + } + + return { + id: stage.id, + name: stage.name, + clusterIds, + } +} + +export async function updateStage(stageId: Stage['id'], { clusterIds, name }: UpdateStageBody) { + const dbStage = await getStageById(stageId) + if (!dbStage) return new NotFound404() + if (name !== dbStage.name) { + await updateStageName(stageId, name) + } + // Remove clusters + const dbClusters = dbStage.clusters + if (dbClusters?.length) { + const clustersToRemove = dbClusters.filter(dbCluster => !clusterIds.includes(dbCluster.id)) + for (const clusterToRemove of clustersToRemove) { + await removeClusterFromStage(clusterToRemove.id, stageId) + } + } + // Add clusters + if (clusterIds.length) { + await linkStageToClusters(stageId, clusterIds) + } + + return { + id: stageId, + name: name ?? dbStage.name, + clusterIds: clusterIds ?? dbStage.clusters.map(({ id }) => id), + } +} + +export async function deleteStage(stageId: Stage['id']) { + const attachedEnvironment = await prisma.environment.findFirst({ where: { stageId }, select: { id: true } }) + if (attachedEnvironment) return new BadRequest400('Impossible de supprimer le stage, des environnements en activité y ont souscrit') + + await deleteStageQuery(stageId) + return null +} + +export async function listStages() { + const stages = await listStagesQuery() + + return stages.map((stage) => { + return { + id: stage.id, + name: stage.name, + clusterIds: stage.clusters.map(({ id }) => id), + } + }) +} + +export async function linkClusterToStages(clusterId: Cluster['id'], stageIds: Stage['id'][], linkToAll: boolean = false) { + if (linkToAll === true) { + stageIds = await getAllStageIds() + } + await linkClusterToStagesQuery(clusterId, stageIds) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/stage/queries.ts b/.jjconflict-side-1/apps/server/src/resources/stage/queries.ts new file mode 100644 index 0000000000..98d526600a --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/stage/queries.ts @@ -0,0 +1,111 @@ +import type { Cluster, Stage } from '@prisma/client' +import prisma from '@/prisma.js' + +export function listStages() { + return prisma.stage.findMany({ + include: { + clusters: true, + }, + }) +} + +export async function getAllStageIds() { + return (await prisma.stage.findMany({ + select: { + id: true, + }, + })).map(({ id }) => id) +} + +export function getStageById(id: Stage['id']) { + return prisma.stage.findUnique({ + where: { id }, + include: { + clusters: true, + }, + }) +} + +export function getStageByIdOrThrow(id: Stage['id']) { + return prisma.stage.findUniqueOrThrow({ + where: { id }, + include: { + clusters: true, + }, + }) +} + +export function getStageAssociatedEnvironmentById(id: Stage['id']) { + return prisma.environment.findMany({ + where: { + stageId: id, + }, + select: { + name: true, + cluster: { + select: { + label: true, + }, + }, + project: { + select: { + name: true, + owner: true, + slug: true, + }, + }, + }, + }) +} + +export function getStageAssociatedEnvironmentLengthById(id: Stage['id']) { + return prisma.environment.count({ + where: { + stageId: id, + }, + }) +} + +export function getStageByName(name: Stage['name']) { + return prisma.stage.findUnique({ + where: { name }, + }) +} + +export function linkStageToClusters(id: Stage['id'], clusterIds: Cluster['id'][]) { + return prisma.stage.update({ + where: { + id, + }, + data: { + clusters: { + connect: clusterIds.map(clusterId => ({ id: clusterId })), + }, + }, + }) +} + +export function createStage({ name }: { name: Stage['name'] }) { + return prisma.stage.create({ + data: { + name, + }, + }) +} + +export function updateStageName(id: Stage['id'], name: Stage['name']) { + return prisma.stage.update({ + where: { + id, + }, + data: { + name, + }, + }) +} + +export function deleteStage(id: Stage['id']) { + return prisma.stage.delete({ + where: { id }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/stage/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/stage/router.spec.ts new file mode 100644 index 0000000000..3e62a482fc --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/stage/router.spec.ts @@ -0,0 +1,204 @@ +import type { Stage } from '@cpn-console/shared' +import { ADMIN_PERMS, stageContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listStages') +const businessGetEnvironmentsMock = vi.spyOn(business, 'getStageAssociatedEnvironments') +const businessCreateMock = vi.spyOn(business, 'createStage') +const businessUpdateMock = vi.spyOn(business, 'updateStage') +const businessDeleteMock = vi.spyOn(business, 'deleteStage') + +describe('test stageContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listStages', () => { + it('should return list of stages', async () => { + const stages: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_STAGES) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(stages) + + const response = await app.inject() + .get(stageContract.listStages.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(stages) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getStageEnvironments', () => { + it('should return stage environments for admin', async () => { + const environments: any = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(environments) + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(environments) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createStage', () => { + const stage: Stage = { id: faker.string.uuid(), name: faker.string.alpha({ length: 5 }), clusterIds: [] } + + it('should create and return stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(stage) + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(stage) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateStage', () => { + const stageId = faker.string.uuid() + const stage = { name: faker.string.alpha({ length: 5 }), clusterIds: [] } + + it('should update and return stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: stageId, ...stage }) + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: stageId, ...stage }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteStage', () => { + it('should delete stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/stage/router.ts b/.jjconflict-side-1/apps/server/src/resources/stage/router.ts new file mode 100644 index 0000000000..73202d4549 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/stage/router.ts @@ -0,0 +1,92 @@ +import { AdminAuthorized, stageContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + createStage, + deleteStage, + getStageAssociatedEnvironments, + listStages, + updateStage, +} from './business.js' + +export function stageRouter() { + return serverInstance.router(stageContract, { + + // Récupérer les types d'environnement disponibles + listStages: async () => { + const body = await listStages() + + return { + status: 200, + body, + } + }, + + // Récupérer les environnements associés au stage + getStageEnvironments: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + const body = await getStageAssociatedEnvironments(stageId) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Créer un stage + createStage: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const body = await createStage(data) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Modifier une association stage / clusters + updateStage: async ({ request: req, params, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + + const body = await updateStage(stageId, data) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Supprimer un stage + deleteStage: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + + const body = await deleteStage(stageId) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/system/config/business.spec.ts b/.jjconflict-side-1/apps/server/src/resources/system/config/business.spec.ts new file mode 100644 index 0000000000..35d4844070 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/system/config/business.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import prisma from '../../../__mocks__/prisma.js' +import { objToDb, updatePluginConfig } from './business.ts' + +describe('test system/config business', () => { + const config = { test: { key1: 'value1' } } + it('should transform object to db row', () => { + const response = objToDb({ test: { key1: 'value1' } }) + expect(response).toEqual([{ pluginName: 'test', key: 'key1', value: 'value1' }]) + }) + describe('updatePluginConfig', () => { + it('should update', async () => { + prisma.adminPlugin.upsert.mockResolvedValue(null) + await updatePluginConfig(config) + }) + it('should update 0 items cause missing manifest', async () => { + // @ts-ignore + await updatePluginConfig({ test: { key: 1 } }) + expect(prisma.adminPlugin.upsert).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/system/config/business.ts b/.jjconflict-side-1/apps/server/src/resources/system/config/business.ts new file mode 100644 index 0000000000..111cb4cd1b --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/system/config/business.ts @@ -0,0 +1,50 @@ +import type { + PluginsUpdateBody, +} from '@cpn-console/shared' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { BadRequest400 } from '@/utils/errors.js' +import { + getAdminPlugin, + savePluginsConfig, +} from './queries.js' + +export type ConfigRecords = { + key: string + pluginName: string + value: string +}[] + +export function objToDb(obj: PluginsUpdateBody): ConfigRecords { + return Object.entries(obj) + .map(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) + .flat() +} + +export async function getPluginsConfig() { + const globalConfig = await getAdminPlugin() + + return Object.values(servicesInfos).map(({ name, title, imgSrc, description }) => { + const manifest = populatePluginManifests({ + data: { + global: globalConfig, + }, + permissionTarget: 'admin', + pluginName: name, + select: { + global: true, + project: false, + }, + }) + return { imgSrc, title, name, manifest: manifest.global ?? [], description } + }).filter(plugin => plugin.manifest.length > 0) +} + +export async function updatePluginConfig(data: PluginsUpdateBody) { + const parsedData = editStrippers.global.safeParse(data) + if (!parsedData.success) return new BadRequest400(parsedData.error.message) + const records = objToDb(parsedData.data) + + await savePluginsConfig(records) + return null +} diff --git a/.jjconflict-side-1/apps/server/src/resources/system/config/queries.ts b/.jjconflict-side-1/apps/server/src/resources/system/config/queries.ts new file mode 100644 index 0000000000..69808e506a --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/system/config/queries.ts @@ -0,0 +1,28 @@ +import type { ConfigRecords } from './business.js' +import prisma from '@/prisma.js' + +// CONFIG +export const getAdminPlugin = prisma.adminPlugin.findMany + +export async function savePluginsConfig(records: ConfigRecords) { + for (const { pluginName, key, value } of records) { + await prisma.adminPlugin.upsert({ + create: { + pluginName, + key, + value: String(value), + }, + update: { + key, + value: String(value), + pluginName, + }, + where: { + pluginName_key: { + pluginName, + key, + }, + }, + }) + } +} diff --git a/.jjconflict-side-1/apps/server/src/resources/system/config/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/system/config/router.spec.ts new file mode 100644 index 0000000000..423a1b7b38 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/system/config/router.spec.ts @@ -0,0 +1,96 @@ +import { ADMIN_PERMS, systemPluginContract } from '@cpn-console/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../../app.js' +import * as utilsController from '../../../utils/controller.js' +import { BadRequest400 } from '../../../utils/errors.js' +import { getUserMockInfos } from '../../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetPluginsConfigMock = vi.spyOn(business, 'getPluginsConfig') +const businessUpdatePluginConfigMock = vi.spyOn(business, 'updatePluginConfig') + +describe('test systemPluginContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getPluginsConfig', () => { + it('should return plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const pluginsConfig: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetPluginsConfigMock.mockResolvedValueOnce(pluginsConfig) + + const response = await app.inject() + .get(systemPluginContract.getPluginsConfig.path) + .end() + + expect(businessGetPluginsConfigMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(pluginsConfig) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(systemPluginContract.getPluginsConfig.path) + .end() + + expect(businessGetPluginsConfigMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updatePluginsConfig', () => { + const newConfig = { plugin1: { keyId: 'value' } } + it('should update plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpdatePluginConfigMock.mockResolvedValueOnce(newConfig) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return error if business logic fails', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpdatePluginConfigMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(400) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/system/config/router.ts b/.jjconflict-side-1/apps/server/src/resources/system/config/router.ts new file mode 100644 index 0000000000..d541dc1778 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/system/config/router.ts @@ -0,0 +1,38 @@ +import { AdminAuthorized, systemPluginContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { getPluginsConfig, updatePluginConfig } from './business.js' + +export function pluginConfigRouter() { + return serverInstance.router(systemPluginContract, { + // Récupérer les configurations plugins + getPluginsConfig: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) return new Forbidden403() + + const services = await getPluginsConfig() + + return { + status: 200, + body: services, + + } + }, + // Mettre à jour les configurations plugins + updatePluginsConfig: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) return new Forbidden403() + + const resBody = await updatePluginConfig(body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/system/index.ts b/.jjconflict-side-1/apps/server/src/resources/system/index.ts new file mode 100644 index 0000000000..a45d7accc7 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/system/index.ts @@ -0,0 +1 @@ +export * from './router.js' diff --git a/.jjconflict-side-1/apps/server/src/resources/system/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/system/router.spec.ts new file mode 100644 index 0000000000..62ad30d7f8 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/system/router.spec.ts @@ -0,0 +1,25 @@ +import { systemContract } from '@cpn-console/shared' +import { describe, expect, it, vi } from 'vitest' +import app from '../../app.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) + +describe('system - router', () => { + it('should send application version', async () => { + const response = await app.inject() + .get(systemContract.getVersion.path) + .end() + + expect(response.statusCode).toBe(200) + expect(response.json()).toStrictEqual({ version: process.env.APP_VERSION || 'dev' }) + }) + + it('should send application health with status OK', async () => { + const response = await app.inject() + .get(systemContract.getHealth.path) + .end() + + expect(response.statusCode).toBe(200) + expect(response.json()).toStrictEqual({ status: 'OK' }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/system/router.ts b/.jjconflict-side-1/apps/server/src/resources/system/router.ts new file mode 100644 index 0000000000..9976884963 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/system/router.ts @@ -0,0 +1,21 @@ +import { systemContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { appVersion } from '@/utils/env.js' + +export function systemRouter() { + return serverInstance.router(systemContract, { + getVersion: async () => ({ + status: 200, + body: { + version: appVersion, + }, + }), + + getHealth: async () => ({ + status: 200, + body: { + status: 'OK', + }, + }), + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/system/settings/business.ts b/.jjconflict-side-1/apps/server/src/resources/system/settings/business.ts new file mode 100644 index 0000000000..5e562353b0 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/system/settings/business.ts @@ -0,0 +1,9 @@ +import type { UpsertSystemSettingBody } from '@cpn-console/shared' +import { + getSystemSettings as getSystemSettingsQuery, + upsertSystemSetting as upsertSystemSettingQuery, +} from './queries.js' + +export const getSystemSettings = (key?: string) => getSystemSettingsQuery({ key }) + +export const upsertSystemSetting = (newSystemSetting: UpsertSystemSettingBody) => upsertSystemSettingQuery(newSystemSetting) diff --git a/.jjconflict-side-1/apps/server/src/resources/system/settings/queries.ts b/.jjconflict-side-1/apps/server/src/resources/system/settings/queries.ts new file mode 100644 index 0000000000..c64cb3b74c --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/system/settings/queries.ts @@ -0,0 +1,18 @@ +import type { Prisma, SystemSetting } from '@prisma/client' +import prisma from '@/prisma.js' + +export function upsertSystemSetting(newSystemSetting: SystemSetting) { + return prisma.systemSetting.upsert({ + create: { + ...newSystemSetting, + }, + update: { + value: newSystemSetting.value, + }, + where: { + key: newSystemSetting.key, + }, + }) +} + +export const getSystemSettings = (where?: Prisma.SystemSettingWhereInput) => prisma.systemSetting.findMany({ where }) diff --git a/.jjconflict-side-1/apps/server/src/resources/system/settings/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/system/settings/router.spec.ts new file mode 100644 index 0000000000..469ee45538 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/system/settings/router.spec.ts @@ -0,0 +1,82 @@ +import { ADMIN_PERMS, systemSettingsContract } from '@cpn-console/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../../app.js' +import * as utilsController from '../../../utils/controller.js' +import { getUserMockInfos } from '../../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetSystemSettingsMock = vi.spyOn(business, 'getSystemSettings') +const businessUpsertSystemSettingMock = vi.spyOn(business, 'upsertSystemSetting') + +describe('test systemSettingsContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listSystemSettings', () => { + it('should return plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const systemSettings: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetSystemSettingsMock.mockResolvedValueOnce(systemSettings) + + const response = await app.inject() + .get(systemSettingsContract.listSystemSettings.path) + .end() + + expect(businessGetSystemSettingsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(systemSettings) + expect(response.statusCode).toEqual(200) + }) + + it('should return 200 for anybody', async () => { + const user = getUserMockInfos(0n) + const systemSettings: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetSystemSettingsMock.mockResolvedValueOnce(systemSettings) + + const response = await app.inject() + .get(systemSettingsContract.listSystemSettings.path) + .end() + + expect(businessGetSystemSettingsMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('upsertSystemSetting', () => { + const newConfig = { key: 'key1', value: 'value1' } + it('should update system setting, authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpsertSystemSettingMock.mockResolvedValueOnce(newConfig) + + const response = await app.inject() + .post(systemSettingsContract.upsertSystemSetting.path) + .body(newConfig) + .end() + + expect(businessUpsertSystemSettingMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(systemSettingsContract.upsertSystemSetting.path) + .body(newConfig) + .end() + + expect(businessUpsertSystemSettingMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/system/settings/router.ts b/.jjconflict-side-1/apps/server/src/resources/system/settings/router.ts new file mode 100644 index 0000000000..98bce89e2d --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/system/settings/router.ts @@ -0,0 +1,31 @@ +import { AdminAuthorized, systemSettingsContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getSystemSettings, upsertSystemSetting } from './business.js' + +export function systemSettingsRouter() { + return serverInstance.router(systemSettingsContract, { + listSystemSettings: async ({ query }) => { + const systemSettings = await getSystemSettings(query.key) + + return { + status: 200, + body: systemSettings, + } + }, + + upsertSystemSetting: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) return new Forbidden403() + + const systemSetting = await upsertSystemSetting(data) + + return { + status: 201, + body: systemSetting, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/user/business.spec.ts b/.jjconflict-side-1/apps/server/src/resources/user/business.spec.ts new file mode 100644 index 0000000000..a86c0535ae --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/user/business.spec.ts @@ -0,0 +1,227 @@ +import type { UserDetails } from '../../types/index.ts' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { getMatchingUsers, getUsers, logViaSession, logViaToken, patchUsers, TokenInvalidReason } from './business.ts' +import * as queries from './queries.js' + +const getUsersQueryMock = vi.spyOn(queries, 'getUsers') +const getMatchingUsersQueryMock = vi.spyOn(queries, 'getMatchingUsers') + +describe('test users business', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const user = { + adminRoleIds: [], + createdAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + id: faker.string.uuid(), + lastName: faker.person.lastName(), + updatedAt: new Date(), + } + const projectId = faker.string.uuid() + const adminRoleId = faker.string.uuid() + describe('patchUsers', () => { + it('should do nothing', async () => { + prisma.user.update.mockResolvedValue(null) + + await patchUsers([]) + + expect(prisma.user.update).toHaveBeenCalledTimes(0) + }) + + it('should update a user adminRoleIds', async () => { + const userUpdated = { id: user.id, adminRoleIds: user.adminRoleIds } + + prisma.user.update.mockResolvedValue(user) + + prisma.user.findMany.mockResolvedValue([]) + + await patchUsers([userUpdated]) + expect(prisma.user.update).toHaveBeenCalledTimes(1) + expect(prisma.user.findMany).toHaveBeenCalledTimes(2) + + await patchUsers([userUpdated, userUpdated]) + expect(prisma.user.update).toHaveBeenCalledTimes(3) + }) + }) + describe('getUsers', () => { + it('should query without where', async () => { + prisma.user.update.mockResolvedValue(null) + + await getUsers({}) + + expect(getUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getUsersQueryMock).toHaveBeenCalledWith({ AND: [] }) + }) + it('should query with filter adminRoleIds', async () => { + prisma.user.update.mockResolvedValue(null) + + await getUsers({ adminRoleIds: [adminRoleId] }) + + expect(getUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getUsersQueryMock).toHaveBeenCalledWith({ AND: [{ adminRoleIds: { hasEvery: [adminRoleId] } }] }) + }) + }) + + describe('getMatchingUsers', () => { + const AND = [ + { + OR: [ + { + email: { + contains: 'abc', + mode: 'insensitive', + }, + }, + { + firstName: { + contains: 'abc', + mode: 'insensitive', + }, + }, + { + lastName: { + contains: 'abc', + mode: 'insensitive', + }, + }, + ], + }, + { + type: 'human', + }, + ] + it('should query only with letters ', async () => { + prisma.user.update.mockResolvedValue(null) + + await getMatchingUsers({ letters: 'abc' }) + + expect(getMatchingUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getMatchingUsersQueryMock).toHaveBeenCalledWith({ AND }) + }) + it('should query with letters and projectId', async () => { + prisma.user.update.mockResolvedValue(null) + + await getMatchingUsers({ letters: 'abc', notInProjectId: projectId }) + + expect(getMatchingUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getMatchingUsersQueryMock).toHaveBeenCalledWith({ AND: [...[{ + projectMembers: { + none: { + projectId, + }, + }, + }, { + projectsOwned: { + none: { + id: projectId, + }, + }, + }], ...AND] }) + }) + }) + describe('logViaSession', () => { + // ça ne teste pas tout mais c'est déjà bien hein + const adminRoles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: 0n, + position: 0, + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '/admin', + permissions: 0n, + position: 0, + }] + const userToLog: UserDetails = { + id: faker.string.uuid(), + email: user.email, + firstName: user.firstName, + groups: [], + lastName: user.lastName, + } + it('should create user and return adminPerms', async () => { + prisma.adminRole.findMany.mockResolvedValue(adminRoles) + prisma.user.findUnique.mockResolvedValue(undefined) + prisma.user.create.mockResolvedValue(user) + prisma.user.update.mockResolvedValue(user) + const response = await logViaSession(userToLog) + expect(response.adminPerms).toBe(0n) + expect(prisma.user.create).toHaveBeenCalledTimes(1) + }) + it('should update user and return adminPerms', async () => { + prisma.adminRole.findMany.mockResolvedValue(adminRoles) + prisma.user.findUnique.mockResolvedValue(user) + prisma.user.update.mockResolvedValue(user) + const response = await logViaSession(userToLog) + expect(response.adminPerms).toEqual(0n) + expect(prisma.user.create).toHaveBeenCalledTimes(0) + }) + }) +}) + +describe('logViaToken', () => { + const nextYear = new Date() + const lastYear = new Date() + nextYear.setFullYear((new Date()).getFullYear() + 1) + lastYear.setFullYear((new Date()).getFullYear() - 1) + const baseToken = { + createdAt: new Date(), + hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + id: faker.string.uuid(), + lastUse: null, + permissions: 2n, + userId: null, + status: 'active', + } as const + + beforeEach(() => { + // Mock the global roles lookup + prisma.adminRole.findMany.mockResolvedValue([]) + }) + + it('should return identity', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken }) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(2n) + }) + + it('should return identity based on pat', async () => { + const pat = structuredClone(baseToken) + delete pat.permissions + pat.owner = { adminRoleIds: null } + prisma.personalAccessToken.findFirst.mockResolvedValueOnce(pat) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(0n) + }) + + it('should return identity, with expirationDate', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, expirationDate: nextYear }) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(2n) + }) + + it('should return cause revoked', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, status: 'revoked' }) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.INACTIVE) + }) + + it('should return cause expired', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, expirationDate: lastYear }) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.EXPIRED) + }) + + it('should return cause not found', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce(undefined) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.NOT_FOUND) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/user/business.ts b/.jjconflict-side-1/apps/server/src/resources/user/business.ts new file mode 100644 index 0000000000..acbf67bb8e --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/user/business.ts @@ -0,0 +1,221 @@ +import type { userContract, XOR } from '@cpn-console/shared' +import type { AdminRole, AdminToken, PersonalAccessToken, Prisma, User } from '@prisma/client' +import type { UserDetails } from '@/types/index.js' +import { createHash } from 'node:crypto' +import prisma from '@/prisma.js' +import { getMatchingUsers as getMatchingUsersQuery, getUsers as getUsersQuery } from '@/resources/queries-index.js' +import { BadRequest400 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function getUsers(query: typeof userContract.getAllUsers.query._type, relationType: 'OR' | 'AND' = 'AND') { + const whereInputs: Prisma.UserWhereInput[] = [] + if (query.adminRoleIds?.length) { + whereInputs.push({ adminRoleIds: { hasEvery: query.adminRoleIds } }) + } + if (query.adminRoles?.length) { + const roles = query.adminRoles + ? await prisma.adminRole.findMany({ where: { name: { in: query.adminRoles } } }) + : [] + + const adminRoleNameNotFound = query.adminRoles?.find(nameQueried => !roles.some(({ name }) => name === nameQueried)) + if (adminRoleNameNotFound) { + return new BadRequest400(`Unable to find adminRole ${adminRoleNameNotFound}`) + } + whereInputs.push({ adminRoleIds: { hasEvery: roles.map(({ id }) => id) } }) + } + if (query.memberOfIds) { + whereInputs.push({ + AND: query.memberOfIds.map(id => ({ + OR: [ + { projectsOwned: { some: { id } } }, + { ProjectMembers: { some: { project: { id } } } }, + ], + })), + }) + } + + return getUsersQuery({ [relationType]: whereInputs }) +} + +export async function getMatchingUsers(query: typeof userContract.getMatchingUsers.query._type) { + const AND: Prisma.UserWhereInput[] = [] + if (query.notInProjectId) { + AND.push({ projectMembers: { none: { projectId: query.notInProjectId } } }) + AND.push({ projectsOwned: { none: { id: query.notInProjectId } } }) + } + const filter = { contains: query.letters, mode: 'insensitive' } as const // Default value: default + if (query.letters) { + AND.push({ + OR: [{ + email: filter, + }, { + firstName: filter, + }, { + lastName: filter, + }], + }) + AND.push({ type: 'human' }) + } + + return getMatchingUsersQuery({ + AND, + }) +} + +export async function patchUsers(users: typeof userContract.patchUsers.body._type) { + const userIds = users.map(u => u.id) + const usersBefore = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, adminRoleIds: true }, + }) + + for (const user of users) { + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + adminRoleIds: user.adminRoleIds, + }, + }) + } + + const impactedRoleIds = new Set() + for (const user of users) { + const before = usersBefore.find(u => u.id === user.id) + if (before) { + before.adminRoleIds.forEach(id => impactedRoleIds.add(id)) + } + user.adminRoleIds?.forEach(id => impactedRoleIds.add(id)) + } + + for (const roleId of impactedRoleIds) { + await hook.adminRole.upsert(roleId) + } + + return prisma.user.findMany({ + where: { + id: { in: users.map(({ id }) => id) }, + }, + }) +} + +export enum TokenInvalidReason { + INACTIVE = 'Not active', + EXPIRED = 'Expired', + NOT_FOUND = 'Not authenticated', +} + +export type UserTrial = Omit +export async function logViaSession({ id, email, groups, ...user }: UserTrial): Promise<{ user: User, adminPerms: bigint }> { + let userDb = await prisma.user.findUnique({ + where: { id }, + }) + + if (!userDb) { + userDb = await prisma.user.create({ data: { email, id, ...user, adminRoleIds: [], type: 'human' } }) + } + + const matchingAdminRoles = await prisma.adminRole.findMany({ + where: { OR: [{ oidcGroup: { in: groups } }, { id: { in: userDb.adminRoleIds } }, { type: 'global' }] }, + }) + + const oidcRoleIds = matchingAdminRoles + .filter(({ oidcGroup }) => oidcGroup && groups.includes(oidcGroup)) + .map(({ id }) => id) + + // On enregistre en bdd uniquement les roles de l'utilisateur + // qui ne viennent pas de keycloak + const updatedUser = await prisma.user.update({ where: { id }, data: { ...user, lastLogin: (new Date()).toISOString() } }) + .then(user => ({ ...user, adminRoleIds: [...new Set([...user.adminRoleIds, ...oidcRoleIds])] })) + return { + user: updatedUser, + adminPerms: sumAdminPerms(matchingAdminRoles), + } +} + +type UserWithTokenId = Omit & { tokenId: string } +export async function logViaToken(pass: string): Promise<({ user: UserWithTokenId, adminPerms: bigint }) | TokenInvalidReason> { + const passHash = createHash('sha256').update(pass).digest('hex') + + let token: (XOR & { owner: User }) | TokenInvalidReason | undefined + const tokenLoginMethods = [findPersonalAccessToken, findAdminToken] + for (const tokenLoginMethod of tokenLoginMethods) { + token = await tokenLoginMethod(passHash) + if (token) { + break + } + } + + if (typeof token === 'string') { + return token + } + if (!token) { + return TokenInvalidReason.NOT_FOUND + } + + const globalRoles = await prisma.adminRole.findMany({ where: { type: 'global' }, select: { permissions: true } }) + const globalPerms = globalRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + const adminPerms = token?.permissions ?? await getAdminRolesAndSum(token.owner.adminRoleIds) + + return { + user: { + ...token.owner, + tokenId: token.id, + }, + adminPerms: globalPerms | adminPerms, + } +} + +function isTokenInvalid(token: AdminToken | PersonalAccessToken): TokenInvalidReason | undefined { + if (token.status !== 'active') { + return TokenInvalidReason.INACTIVE + } + const currentDate = new Date() + if (token.expirationDate && currentDate.getTime() > token.expirationDate?.getTime()) { + return TokenInvalidReason.EXPIRED + } +} + +function sumAdminPerms(roles: AdminRole[]): bigint { + if (!roles.length) { + return 0n + } + return roles.reduce((acc, curr) => acc | curr.permissions, 0n) +} + +async function getAdminRolesAndSum(roles: AdminRole['id'][] | null): Promise { + if (!roles?.length) { + return 0n + } + return sumAdminPerms(await prisma.adminRole.findMany({ + where: { id: { in: roles } }, + })) +} + +// List all token tpe authentication +async function findPersonalAccessToken(digest: string): Promise<(PersonalAccessToken & { owner: User }) | undefined | TokenInvalidReason> { + const token = await prisma.personalAccessToken.findFirst({ where: { hash: digest }, include: { owner: true } }) + if (!token) + return undefined + const invalidReason = isTokenInvalid(token) + if (invalidReason) { + return invalidReason + } + await prisma.personalAccessToken.update({ where: { id: token.id }, data: { lastUse: (new Date()).toISOString() } }) + await prisma.user.update({ where: { id: token.owner.id }, data: { lastLogin: (new Date()).toISOString() } }) + return token +} + +async function findAdminToken(digest: string): Promise<(AdminToken & { owner: User }) | undefined | TokenInvalidReason> { + const token = await prisma.adminToken.findFirst({ where: { hash: digest }, include: { owner: true } }) + if (!token) + return undefined + const invalidReason = isTokenInvalid(token) + if (invalidReason) { + return invalidReason + } + await prisma.adminToken.update({ where: { id: token.id }, data: { lastUse: (new Date()).toISOString() } }) + await prisma.user.update({ where: { id: token.userId }, data: { lastLogin: (new Date()).toISOString() } }) + return token +} diff --git a/.jjconflict-side-1/apps/server/src/resources/user/queries.ts b/.jjconflict-side-1/apps/server/src/resources/user/queries.ts new file mode 100644 index 0000000000..1e33a8128b --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/user/queries.ts @@ -0,0 +1,60 @@ +import type { Prisma, User } from '@prisma/client' +import prisma from '@/prisma.js' + +type UserCreate = Omit + +// SELECT +export const getUsers = (where?: Prisma.UserWhereInput) => prisma.user.findMany({ where }) + +export async function getUserInfos(id: User['id']) { + return prisma.user.findMany({ + where: { id }, + include: { + logs: true, + }, + }) +} + +export function getMatchingUsers(where: Prisma.UserWhereInput) { + return prisma.user.findMany({ + where, + take: 5, + }) +} + +export function getUserById(id: User['id']) { + return prisma.user.findUnique({ where: { id } }) +} + +export function getUserOrThrow(id: User['id']) { + return prisma.user.findUniqueOrThrow({ + where: { id }, + }) +} + +export function getUserByEmail(email: User['email']) { + return prisma.user.findUnique({ where: { email } }) +} + +// CREATE +export async function createUser({ id, email, firstName, lastName, type }: UserCreate) { + const user = await getUserByEmail(email) + if (user) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') + return prisma.user.create({ data: { id, email, firstName, lastName, type } }) +} + +// UPDATE +export async function updateUserById({ id, email, firstName, lastName }: UserCreate) { + const user = await getUserById(id) + const isEmailAlreadyTaken = await getUserByEmail(email) + if (!user) throw new Error('L\'utilisateur demandé n\'existe pas') + if (isEmailAlreadyTaken) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') + if (user && !isEmailAlreadyTaken) { + return prisma.user.update({ where: { id }, data: { email, firstName, lastName } }) + } +} + +// TECH +export function _createUser(data: Prisma.UserCreateInput) { + return prisma.user.upsert({ where: { id: data.id }, create: data, update: data }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/user/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/user/router.spec.ts new file mode 100644 index 0000000000..f47c9aa311 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/user/router.spec.ts @@ -0,0 +1,142 @@ +import { ADMIN_PERMS, userContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos, setRequestor } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetMatchingMock = vi.spyOn(business, 'getMatchingUsers') +const businessLogViaSessionMock = vi.spyOn(business, 'logViaSession') +const businessGetUsersMock = vi.spyOn(business, 'getUsers') +const businessPatchMock = vi.spyOn(business, 'patchUsers') + +describe('test userContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getMatchingUsers', () => { + it('should return matching users', async () => { + const usersMatching: any[] = [] + businessGetMatchingMock.mockResolvedValueOnce(usersMatching) + + const response = await app.inject() + .get(userContract.getMatchingUsers.path) + .query({ letters: faker.person.fullName() }) + .end() + + expect(businessGetMatchingMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(usersMatching) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('auth', () => { + it('should return logged user', async () => { + const user = { + id: faker.string.uuid(), + adminRoleIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + type: 'human' as const, + lastName: faker.person.lastName(), + lastLogin: (new Date()).toISOString(), + } + // @ts-ignore + setRequestor(user) + // @ts-ignore + businessLogViaSessionMock.mockResolvedValueOnce({ user, adminPerms: 0n }) + + const response = await app.inject() + .get(userContract.auth.path) + .end() + + expect(businessLogViaSessionMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(user) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getAllUsers', () => { + it('should return all users for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + const users: any[] = [] + authUserMock.mockResolvedValueOnce(user) + businessGetUsersMock.mockResolvedValueOnce(users) + + const response = await app.inject() + .get(userContract.getAllUsers.path) + .query({ role: 'admin' }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetUsersMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(users) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(userContract.getAllUsers.path) + .query({ role: 'admin' }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetUsersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('patchUsers', () => { + const usersPatchData = [{ + id: faker.string.uuid(), + adminRoleIds: [], + }] + const usersReturn = [{ + id: faker.string.uuid(), + adminRoleIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + type: 'human', + }] + + it('should patch and return users for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + authUserMock.mockResolvedValueOnce(user) + + businessPatchMock.mockResolvedValueOnce(usersReturn) + const response = await app.inject() + .patch(userContract.patchUsers.path) + .body(usersPatchData) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessPatchMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(usersReturn) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(userContract.patchUsers.path) + .body(usersPatchData) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessPatchMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/user/router.ts b/.jjconflict-side-1/apps/server/src/resources/user/router.ts new file mode 100644 index 0000000000..e745dbec43 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/user/router.ts @@ -0,0 +1,72 @@ +import type { + UserTrial, +} from './business.js' +import { AdminAuthorized, userContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + getMatchingUsers, + getUsers, + logViaSession, + patchUsers, +} from './business.js' +import '@/types/index.js' + +export function userRouter() { + return serverInstance.router(userContract, { + getMatchingUsers: async ({ query }) => { + const usersMatching = await getMatchingUsers(query) + + return { + status: 200, + body: usersMatching, + } + }, + + auth: async ({ request: req }) => { + const user = req.session.user + + if (!user) return new Unauthorized401() + + const { user: body } = await logViaSession(user as unknown as UserTrial) + + return { + status: 200, + body, + } + }, + + getAllUsers: async ({ + request: req, + query: { relationType, ...query }, + }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageUsers(perms.adminPermissions)) + return new Forbidden403() + + const body = await getUsers(query, relationType) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + patchUsers: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageUsers(perms.adminPermissions)) + return new Forbidden403() + + const users = await patchUsers(body) + + return { + status: 200, + body: users, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/user/tokens/business.ts b/.jjconflict-side-1/apps/server/src/resources/user/tokens/business.ts new file mode 100644 index 0000000000..e378d3b79b --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/user/tokens/business.ts @@ -0,0 +1,51 @@ +import type { personalAccessTokenContract } from '@cpn-console/shared' +import type { AdminToken, User } from '@prisma/client' +import { createHash } from 'node:crypto' +import { generateRandomPassword, isAtLeastTomorrow } from '@cpn-console/shared' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../../prisma.js' + +export async function listTokens(userId: User['id']) { + return prisma.personalAccessToken.findMany({ + omit: { hash: true }, + include: { owner: true }, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + where: { userId }, + }) +} + +export async function createToken(data: typeof personalAccessTokenContract.createPersonalAccessToken.body._type, userId: User['id']) { + if (data.expirationDate && !isAtLeastTomorrow(new Date(data.expirationDate))) { + return new BadRequest400('Date d\'expiration trop courte') + } + const password = generateRandomPassword(48, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-') + const hash = createHash('sha256').update(password).digest('hex') + const token = await prisma.personalAccessToken.create({ + data: { + ...data, + hash, + expirationDate: new Date(data.expirationDate), + userId, + }, + omit: { hash: true }, + include: { owner: true }, + }) + return { + ...token, + password, + } +} + +export async function deleteToken(id: AdminToken['id'], userId: User['id']) { + const token = await prisma.personalAccessToken.findUnique({ + where: { + id, + userId, + }, + }) + if (token) { + return prisma.personalAccessToken.delete({ + where: { id }, + }) + } +} diff --git a/.jjconflict-side-1/apps/server/src/resources/user/tokens/router.ts b/.jjconflict-side-1/apps/server/src/resources/user/tokens/router.ts new file mode 100644 index 0000000000..369ded2b1a --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/user/tokens/router.ts @@ -0,0 +1,48 @@ +import { personalAccessTokenContract } from '@cpn-console/shared' + +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { createToken, deleteToken, listTokens } from './business.js' +import '@/types/index.js' + +export function personalAccessTokenRouter() { + return serverInstance.router(personalAccessTokenContract, { + listPersonalAccessTokens: async ({ request: req }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + const body = await listTokens(perms.user.id) + + return { + status: 200, + body, + } + }, + + createPersonalAccessToken: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + const body = await createToken(data, perms.user.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + deletePersonalAccessToken: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + await deleteToken(params.tokenId, perms.user.id) + + return { + status: 204, + body: null, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/zone/business.spec.ts b/.jjconflict-side-1/apps/server/src/resources/zone/business.spec.ts new file mode 100644 index 0000000000..2b42464e26 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/zone/business.spec.ts @@ -0,0 +1,133 @@ +import type { Cluster, Zone } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400 } from '../../utils/errors.ts' +import { createZone, deleteZone, listZones, updateZone } from './business.ts' +import * as queries from './queries.js' + +const userId = faker.string.uuid() +const reqId = faker.string.uuid() +const linkZoneToClustersMock = vi.spyOn(queries, 'linkZoneToClusters') +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +describe('test zone business', () => { + const zones: Zone[] = [{ + id: faker.string.uuid(), + label: faker.string.alphanumeric(), + argocdUrl: faker.internet.url(), + createdAt: new Date(), + updatedAt: new Date(), + description: faker.lorem.lines(1), + slug: faker.string.alphanumeric(5), + }, { + id: faker.string.uuid(), + label: faker.string.alphanumeric(), + argocdUrl: faker.internet.url(), + createdAt: new Date(), + updatedAt: new Date(), + description: faker.lorem.lines(1), + slug: faker.string.alphanumeric(6), + }] + + const clusters: Pick[] = [ + { id: faker.string.uuid() }, + { id: faker.string.uuid() }, + ] + + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listZones', () => { + it('should return zones', async () => { + prisma.zone.findMany.mockResolvedValueOnce(zones) + + const response = await listZones() + expect(response).toEqual(zones) + }) + }) + describe('createZone', () => { + it('should create zone without description and clusterIds', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl } + + hook.zone.upsert.mockResolvedValue({}) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).toEqual(zones[0]) + expect(prisma.zone.create).toHaveBeenCalledWith({ + data: { + slug: newZone.slug, + label: newZone.label, + argocdUrl: newZone.argocdUrl, + description: undefined, + }, + }) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(0) + }) + it('should create zone with description and clusterIds', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl, clusterIds: clusters.map(({ id }) => id), description: faker.lorem.lines(2) } + + hook.zone.upsert.mockResolvedValue({}) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).toEqual(zones[0]) + expect(prisma.zone.create).toHaveBeenCalledWith({ + data: { + description: newZone.description, + label: newZone.label, + argocdUrl: newZone.argocdUrl, + slug: newZone.slug, + }, + }) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(1) + }) + it('should not create zone, conflict label', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl } + + prisma.zone.findUnique.mockResolvedValueOnce(zones[0]) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).instanceOf(BadRequest400) + expect(prisma.zone.create).toHaveBeenCalledTimes(0) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(0) + }) + }) + describe('updateZone', () => { + it('should filter keys and update zone', async () => { + prisma.zone.update.mockResolvedValueOnce(zones[0]) + hook.zone.upsert.mockResolvedValue({}) + await updateZone(zones[0].id, { + description: '', + label: zones[0].label, + argocdUrl: zones[0].argocdUrl, + extraKey: 1, + }, userId, reqId) + expect(prisma.zone.update).toHaveBeenCalledWith({ where: { id: zones[0].id }, data: { + description: '', + label: zones[0].label, + argocdUrl: zones[0].argocdUrl, + } }) + }) + }) + describe('deleteZone', () => { + it('should not delete zone, cluster attached', async () => { + prisma.cluster.findFirst.mockResolvedValueOnce(clusters[0]) + const response = await deleteZone(zones[0].id, userId, reqId) + expect(response).instanceOf(BadRequest400) + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + }) + it('should delete zone', async () => { + prisma.cluster.findFirst.mockResolvedValueOnce(undefined) + hook.zone.delete.mockResolvedValue({}) + const response = await deleteZone(zones[0].id, userId, reqId) + expect(response).toEqual(null) + expect(prisma.zone.delete).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/zone/business.ts b/.jjconflict-side-1/apps/server/src/resources/zone/business.ts new file mode 100644 index 0000000000..eb3155ce60 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/zone/business.ts @@ -0,0 +1,78 @@ +import type { User, Zone } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { BadRequest400, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' +import { addLogs } from '../queries-index.js' +import { linkZoneToClusters } from './queries.js' + +export const listZones = prisma.zone.findMany + +export async function createZone( + data: { slug: string, label: string, argocdUrl: string, description?: string | null, clusterIds?: string[] }, + userId: User['id'], + requestId: string, +) { + const { slug, label, argocdUrl, description, clusterIds } = data + + const existingZone = await prisma.zone.findUnique({ + where: { slug }, + }) + + if (existingZone) return new BadRequest400(`Une zone portant le nom ${slug} existe déjà.`) + const zone = await prisma.zone.create({ + data: { + slug, + label, + argocdUrl, + description, + }, + }) + if (clusterIds) { + await linkZoneToClusters(zone.id, clusterIds) + } + const hookReply = await hook.zone.upsert(zone.id) + await addLogs({ action: 'Create zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la création de la zone') + } + return zone +} + +export async function updateZone( + zoneId: Zone['id'], + data: Pick, + userId: User['id'], + requestId: string, +) { + const { label, argocdUrl, description } = data + + const updatedZone = await prisma.zone.update({ + where: { + id: zoneId, + }, + data: { + label, + argocdUrl, + description, + }, + }) + const hookReply = await hook.zone.upsert(updatedZone.id) + await addLogs({ action: 'Update zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la mise à jour de la zone') + } + return updatedZone +} + +export async function deleteZone(zoneId: Zone['id'], userId: User['id'], requestId: string) { + const attachedCluster = await prisma.cluster.findFirst({ where: { zoneId }, select: { id: true } }) + if (attachedCluster) return new BadRequest400('Vous ne pouvez supprimer cette zone, car des clusters y sont associés.') + + const hookReply = await hook.zone.delete(zoneId) + await addLogs({ action: 'Delete zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la suppression de la zone') + } + await prisma.zone.delete({ where: { id: zoneId } }) + return null +} diff --git a/.jjconflict-side-1/apps/server/src/resources/zone/queries.ts b/.jjconflict-side-1/apps/server/src/resources/zone/queries.ts new file mode 100644 index 0000000000..1390bb153a --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/zone/queries.ts @@ -0,0 +1,21 @@ +import type { Cluster, Zone } from '@prisma/client' +import prisma from '@/prisma.js' + +export function getZoneByIdOrThrow(id: Zone['id']) { + return prisma.zone.findUniqueOrThrow({ + where: { id }, + }) +} + +export function linkZoneToClusters(zoneId: Zone['id'], clusterIds: Cluster['id'][]) { + return prisma.zone.update({ + where: { + id: zoneId, + }, + data: { + clusters: { + connect: clusterIds.map(clusterId => ({ id: clusterId })), + }, + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/resources/zone/router.spec.ts b/.jjconflict-side-1/apps/server/src/resources/zone/router.spec.ts new file mode 100644 index 0000000000..0ee9c7f394 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/zone/router.spec.ts @@ -0,0 +1,164 @@ +import type { Zone } from '@cpn-console/shared' +import { ADMIN_PERMS, zoneContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listZones') +const businessCreateMock = vi.spyOn(business, 'createZone') +const businessUpdateMock = vi.spyOn(business, 'updateZone') +const businessDeleteMock = vi.spyOn(business, 'deleteZone') + +describe('test zoneContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listZones', () => { + it('should return list of zones', async () => { + const zones: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_ZONES) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(zones) + + const response = await app.inject() + .get(zoneContract.listZones.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(zones) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('createZone', () => { + const zone = { id: faker.string.uuid(), label: faker.string.alpha({ length: 5 }), argocdUrl: faker.internet.url(), slug: faker.string.alpha({ length: 5, casing: 'lower' }), description: '' } + + it('should create and return zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(zone) + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(zone) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateZone', () => { + const zoneId = faker.string.uuid() + const zone: Omit = { label: faker.string.alpha({ length: 5 }), slug: faker.string.alpha({ length: 5, casing: 'lower' }), argocdUrl: faker.internet.url(), description: '' } + + it('should update and return zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: zoneId, ...zone }) + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: zoneId, ...zone }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteZone', () => { + it('should delete zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/resources/zone/router.ts b/.jjconflict-side-1/apps/server/src/resources/zone/router.ts new file mode 100644 index 0000000000..c682826c3e --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/resources/zone/router.ts @@ -0,0 +1,67 @@ +import { AdminAuthorized, zoneContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { createZone, deleteZone, listZones, updateZone } from './business.js' + +export function zoneRouter() { + return serverInstance.router(zoneContract, { + listZones: async () => { + const zones = await listZones() + + return { + status: 200, + body: zones, + } + }, + + createZone: async ({ request: req, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const body = await createZone(data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + updateZone: async ({ request: req, params, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const zoneId = params.zoneId + + const body = await updateZone(zoneId, data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + deleteZone: async ({ request: req, params }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + const zoneId = params.zoneId + + const body = await deleteZone(zoneId, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-side-1/apps/server/src/runtime.ts b/.jjconflict-side-1/apps/server/src/runtime.ts new file mode 100644 index 0000000000..d5b8822533 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/runtime.ts @@ -0,0 +1,22 @@ +import { Agent, cacheStores, interceptors, ProxyAgent, setGlobalDispatcher } from 'undici' + +const base = process.env.HTTP_PROXY ? new ProxyAgent(process.env.HTTP_PROXY) : new Agent() + +// Undici’s cache interceptor follows RFC 7234: +// 1. Only GET/HEAD are cached (configurable via `methods`). +// 2. Cache-Control directives are obeyed: +// - `no-store` → never cache +// - `max-age`, `s-maxage`, `expires` → freshness lifetime +// - `private` → skip if shared store +// - `no-cache` → revalidate with ETag/Last-Modified +// 3. Heuristic caching (status 200 without explicit freshness) +// uses 10 % of time since Last-Modified if present. +// 4. Stale responses are served while revalidating in background +// when `stale-while-revalidate` is present. +const client = base.compose( + interceptors.cache({ + store: new cacheStores.SqliteCacheStore(), + methods: ['GET', 'HEAD'], + }), +) +setGlobalDispatcher(client) diff --git a/.jjconflict-side-1/apps/server/src/server.spec.ts b/.jjconflict-side-1/apps/server/src/server.spec.ts new file mode 100644 index 0000000000..5dc093888a --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/server.spec.ts @@ -0,0 +1,57 @@ +import { logger } from '@cpn-console/logger' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { closeConnections } from './connect.js' +import { exitGracefully, handleExit } from './server.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) +vi.mock('./init/db/index.js', () => ({ initDb: vi.fn() })) +vi.mock('./connect.js') + +vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + +vi.mock('./prepare-app.js', () => { + const app = { + listen: vi.fn(), + close: vi.fn(async () => {}), + } + return { + getPreparedApp: () => Promise.resolve(app), + } +}) +vi.spyOn(logger, 'warn') +vi.spyOn(logger, 'debug') +const infoSpy = vi.spyOn(logger, 'info') +const errorSpy = vi.spyOn(logger, 'error') +const fatalSpy = vi.spyOn(logger, 'fatal') + +describe('server', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should call closeConnections without parameter', async () => { + await exitGracefully() + + expect(closeConnections).toHaveBeenCalledTimes(1) + expect(vi.mocked(closeConnections).mock.calls[0]).toHaveLength(0) + expect(errorSpy).toHaveBeenCalledTimes(0) + }) + + it('should log an error', async () => { + await exitGracefully(new Error('error')) + + expect(closeConnections).toHaveBeenCalledTimes(1) + expect(vi.mocked(closeConnections).mock.calls[0]).toHaveLength(0) + expect(fatalSpy).toHaveBeenCalledTimes(1) + expect(fatalSpy.mock.calls[0]?.[0]).toBeInstanceOf(Error) + expect(infoSpy).toHaveBeenCalledTimes(2) + }) + + it('should call process.on 4 times', () => { + const processOn = vi.spyOn(process, 'on') + + handleExit() + + expect(processOn).toHaveBeenCalledTimes(5) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/server.ts b/.jjconflict-side-1/apps/server/src/server.ts new file mode 100644 index 0000000000..93425dae79 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/server.ts @@ -0,0 +1,44 @@ +import { logger } from '@cpn-console/logger' +import { closeConnections } from './connect.js' +import { getPreparedApp } from './prepare-app.js' +import { isCI, isDev, isDevSetup, isProd, isTest, port } from './utils/env.js' + +const app = await getPreparedApp() + +try { + await app.listen({ host: '0.0.0.0', port: +(port ?? 8080) }) +} catch (error) { + logger.error({ err: error }, 'Failed to start server') + process.exit(1) +} + +logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) + +export async function exitGracefully(error?: Error) { + if (error instanceof Error) { + logger.fatal(error) + } + await app.close() + logger.info('Closing connections...') + await closeConnections() + logger.info('Exiting...') + process.exit(error instanceof Error ? 1 : 0) +} + +function logExitCode(code: number) { + logger.warn(`received signal: ${code}`) +} + +function logUnhandledRejection(reason: unknown, promise: Promise) { + logger.error({ promise, err: reason }, 'Unhandled Rejection') +} + +export function handleExit() { + process.on('exit', logExitCode) + process.on('SIGINT', exitGracefully) + process.on('SIGTERM', exitGracefully) + process.on('uncaughtException', exitGracefully) + process.on('unhandledRejection', logUnhandledRejection) +} + +handleExit() diff --git a/.jjconflict-side-1/apps/server/src/telemetry.ts b/.jjconflict-side-1/apps/server/src/telemetry.ts new file mode 100644 index 0000000000..65b59dddab --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/telemetry.ts @@ -0,0 +1,26 @@ +import { FastifyOtelInstrumentation } from '@fastify/otel' +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' +import { NodeSDK } from '@opentelemetry/sdk-node' + +const sdk = new NodeSDK({ + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({}), + }), + serviceName: 'console-pi-native-console', + traceExporter: new OTLPTraceExporter({}), + instrumentations: [ + getNodeAutoInstrumentations(), + new FastifyOtelInstrumentation({ + registerOnInitialization: true, + }), + ], +}) + +sdk.start() + +process.once('beforeExit', () => { + sdk.shutdown() +}) diff --git a/.jjconflict-side-1/apps/server/src/types/index.ts b/.jjconflict-side-1/apps/server/src/types/index.ts new file mode 100644 index 0000000000..ed39a8f94b --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/types/index.ts @@ -0,0 +1,22 @@ +import { User } from '@cpn-console/shared' + +export type UserDetails = { + id: string + firstName: string + lastName: string + email: string + groups: string[] + type: User['type'], +} + +export interface Resources { + cpu: number + gpu: number + memory: number +} + +declare module 'fastify' { + interface Session { + user?: UserDetails + } +} diff --git a/.jjconflict-side-1/apps/server/src/utils/business.ts b/.jjconflict-side-1/apps/server/src/utils/business.ts new file mode 100644 index 0000000000..10fe3ced2f --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/business.ts @@ -0,0 +1,42 @@ +import type { SharedSafeParseReturnType } from '@cpn-console/shared' +import { parseZodError } from '@cpn-console/shared' +import { BadRequest400 } from './errors.js' + +export type Success = Result +export type Failure = Result +export class Result { + protected constructor( + readonly success: boolean, + readonly value: T | string, + ) {} + + static succeed(value: T): Success { + return new Result(true, value) as Success + } + + static fail(message: string): Failure { + return new Result(false, message) as Failure + } + + get isSuccess(): boolean { + return this.success + } + + get isError(): boolean { + return !this.success + } + + get data(): T { + if (this.success) return this.value as T + throw new Error('Cannot get data from a Failure') + } + + get error(): string { + if (!this.success) return this.value as string + throw new Error('Cannot get error from a Success') + } +} + +export function validateSchema(schemaValidation: SharedSafeParseReturnType) { + if (!schemaValidation.success) return new BadRequest400(parseZodError(schemaValidation.error)) +} diff --git a/.jjconflict-side-1/apps/server/src/utils/controller.ts b/.jjconflict-side-1/apps/server/src/utils/controller.ts new file mode 100644 index 0000000000..57ab7865af --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/controller.ts @@ -0,0 +1,171 @@ +import type { XOR } from '@cpn-console/shared' +import type { Cluster, Prisma, Project, ProjectMembers, ProjectRole } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserTrial } from '@/resources/user/business.js' +import type { UserDetails } from '@/types/index.js' +import { PROJECT_PERMS as PP, PROJECT_PERMS, projectIsLockedInfo, tokenHeaderName } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { logViaSession, logViaToken } from '@/resources/user/business.js' +import { Unauthorized401 } from './errors.js' +import { uuid } from './queries-tools.js' + +export type RequireOnlyOne + = Pick> + & { + [K in Keys]-?: + Required> + & Partial, undefined>> + }[Keys] + +type ErrorMessagePredicate = () => string | undefined +export function getErrorMessage(...fns: ErrorMessagePredicate[]) { + for (const f of fns) { + const error = f() + if (error) { + return error + } + } +} + +/** + * Renvoie une erreur si le projet est verrouillé + */ +export function checkProjectLocked(project: { locked: boolean }): string { + return project.locked + ? projectIsLockedInfo + : '' +} + +export function checkLocked(project: { locked: Project['locked'] }): string { + return checkProjectLocked(project) +} + +export function checkClusterUnavailable(clusterId: Cluster['id'], authorizedClusterIds: Cluster['id'][]): string { + return authorizedClusterIds.includes(clusterId) + ? '' + : 'Ce cluster n\'est pas disponible pour cette combinaison projet et stage' +} + +export const splitStringsFilterArray = >(toMatch: T, inputs: string): T => inputs.split(',').filter(i => toMatch.includes(i)) as unknown as T + +type StringArray = string[] +interface WhereBuilderParams { + enumValues: T + eqValue: T[number] | undefined + inValues: string | undefined + notInValues: string | undefined +} + +export function whereBuilder({ enumValues, eqValue, inValues, notInValues }: WhereBuilderParams) { + if (eqValue) { + return eqValue + } else if (inValues) { + return { in: splitStringsFilterArray(enumValues, inValues) } + } else if (notInValues) { + return { notIn: splitStringsFilterArray(enumValues, notInValues) } + } +} + +type ProjectMinimalPerms = Pick & { roles: ProjectRole[], members: ProjectMembers[] } +export interface UserProfile { user?: UserDetails, adminPermissions: bigint, tokenId?: string } +export interface ProjectPermState { projectPermissions?: bigint, projectId: Project['id'], projectLocked: boolean, projectStatus: Project['status'], projectOwnerId: Project['ownerId'] } +export type UserProjectProfile = UserProfile & ProjectPermState + +type ProjectUniqueFinder = XOR< + { slug: string }, + XOR<{ environmentId: string }, XOR<{ repositoryId: string }, { id: string }>> +> + +const projectPermsSelect = { roles: true, members: true, everyonePerms: true, ownerId: true, id: true, locked: true, status: true } as const satisfies Prisma.ProjectSelect + +export async function authUser(req: FastifyRequest): Promise +export async function authUser(req: FastifyRequest, projectUnique: ProjectUniqueFinder): Promise +export async function authUser(req: FastifyRequest, projectUnique?: ProjectUniqueFinder): Promise { + let adminPermissions: bigint = 0n + let tokenId: string | undefined + const reqUser: UserTrial = req.session.user as unknown as UserTrial + let user: UserDetails | undefined + + if (req.session.user) { + const loginResult = await logViaSession(reqUser) + user = { + ...loginResult.user, + groups: reqUser.groups, + } + adminPermissions = loginResult.adminPerms + } else { + const tokenHeader = req.headers[tokenHeaderName] + if (typeof tokenHeader === 'string') { + const resultToken = await logViaToken(tokenHeader) + if (typeof resultToken === 'string') { + throw new Unauthorized401(resultToken) + } + adminPermissions = resultToken.adminPerms ?? 0n + tokenId = resultToken.user.tokenId + if (!user && resultToken.user) { + user = { ...resultToken.user, groups: [] } + } + } + } + + const baseReturnInfos = { + user, + adminPermissions, + tokenId, + } + if (!projectUnique || !user) { + return baseReturnInfos + } + let project: ProjectMinimalPerms | null | undefined + + if (projectUnique.repositoryId) { + project = (await prisma.repository.findUnique({ + where: { id: projectUnique.repositoryId }, + select: { project: { select: projectPermsSelect } }, + }))?.project + } else if (projectUnique.environmentId) { + project = (await prisma.environment.findUnique({ + where: { id: projectUnique.environmentId }, + select: { project: { select: projectPermsSelect } }, + }))?.project + } else if (projectUnique.id) { + project = uuid.test(projectUnique.id) + ? await prisma.project.findUnique({ + where: { id: projectUnique.id }, + select: projectPermsSelect, + }) + : await prisma.project.findUnique({ + where: { slug: projectUnique.id }, + select: projectPermsSelect, + }) + } else if (projectUnique.slug) { + project = await prisma.project.findFirstOrThrow({ + where: { slug: projectUnique.slug }, + select: projectPermsSelect, + }) + } + if (!project) { + return baseReturnInfos + } + + const projectPermissions = getProjectPermissions(project, user) + + return { + user, + adminPermissions, + projectPermissions, + projectId: project.id, + projectLocked: project.locked, + projectStatus: project.status, + projectOwnerId: project.ownerId, + } +} + +function getProjectPermissions(project: ProjectMinimalPerms, user: UserDetails): bigint | undefined { + if (project.ownerId === user.id) return PP.MANAGE + const member = project.members.find(member => member.userId === user.id) + if (!member) return + + const memberRoles = project.roles.filter(role => member.roleIds.includes(role.id)) + return memberRoles.reduce((acc, curr) => acc | curr.permissions, project.everyonePerms | PROJECT_PERMS.GUEST) +} diff --git a/.jjconflict-side-1/apps/server/src/utils/date.spec.ts b/.jjconflict-side-1/apps/server/src/utils/date.spec.ts new file mode 100644 index 0000000000..7abcaa1aaa --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/date.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { getJSDateFromUtcIso } from './date.js' + +describe('date-util', () => { + it('should return a native Date object', () => { + const date = '2022-10-11' + + const received = getJSDateFromUtcIso(date) + + expect(received.getMonth()).toBe(9) + expect(received.getFullYear()).toBe(2022) + expect(received.getDate()).toBeGreaterThan(10) + expect(received.getDate()).toBeLessThan(12) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/utils/date.ts b/.jjconflict-side-1/apps/server/src/utils/date.ts new file mode 100644 index 0000000000..87473d2625 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/date.ts @@ -0,0 +1,5 @@ +import { parseISO } from 'date-fns' + +export function getJSDateFromUtcIso(dateUtcIso: string) { + return parseISO(dateUtcIso) +} diff --git a/.jjconflict-side-1/apps/server/src/utils/env.ts b/.jjconflict-side-1/apps/server/src/utils/env.ts new file mode 100644 index 0000000000..2ca618fd19 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/env.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +// application mode +export const isDev = process.env.NODE_ENV === 'development' +export const isTest = process.env.NODE_ENV === 'test' +export const isProd = process.env.NODE_ENV === 'production' +export const isInt = process.env.INTEGRATION === 'true' +export const isCI = process.env.CI === 'true' +export const isDevSetup = process.env.DEV_SETUP === 'true' + +// app +export const port = process.env.SERVER_PORT +export const appVersion = isProd + ? (process.env.APP_VERSION ?? 'unknown') + : 'dev' + +// db +export const dbUrl = process.env.DB_URL + +// keycloak +export const sessionSecret = process.env.SESSION_SECRET +export const keycloakProtocol = process.env.KEYCLOAK_PROTOCOL +export const keycloakDomain = process.env.KEYCLOAK_DOMAIN +export const keycloakRealm = process.env.KEYCLOAK_REALM +export const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID +export const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET +export const keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI +export const adminsUserId = process.env.ADMIN_KC_USER_ID + ? process.env.ADMIN_KC_USER_ID.split(',') + : [] + +export const contactEmail = process.env.CONTACT_EMAIL ?? 'cloudpinative-relations@interieur.gouv.fr' + +// plugins +export const mockPlugins = process.env.MOCK_PLUGINS === 'true' +export const projectRootDir = process.env.PROJECTS_ROOT_DIR +export const pluginsDir = process.env.PLUGINS_DIR ?? '/plugins' +export const NODE_ENV = process.env.NODE_ENV === 'test' + ? 'test' + : process.env.NODE_ENV === 'development' + ? 'development' + : 'production' + +// server tuning +export const parallelBulkLimit = process.env.PARALLEL_BULK_LIMIT ? Number(process.env.PARALLEL_BULK_LIMIT) : 5 diff --git a/.jjconflict-side-1/apps/server/src/utils/errors.ts b/.jjconflict-side-1/apps/server/src/utils/errors.ts new file mode 100644 index 0000000000..0f1dd07fbe --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/errors.ts @@ -0,0 +1,48 @@ +export class ErrorResType { + readonly status: 400 | 401 | 403 | 404 | 422 | 500 + body: { message: string } = { message: '' } + constructor(code: 400 | 401 | 403 | 404 | 422 | 500) { + this.status = code + } +} +export class BadRequest400 extends ErrorResType { + constructor(message: string) { + super(400) + this.body.message = message ?? 'Bad Request' + } +} + +export class Unauthorized401 extends ErrorResType { + constructor(message?: string) { + super(401) + this.body.message = message ?? 'Unauthorized' + } +} + +export class Forbidden403 extends ErrorResType { + constructor(message?: string) { + super(403) + this.body.message = message ?? 'Forbidden' + } +} + +export class NotFound404 extends ErrorResType { + constructor() { + super(404) + this.body.message = 'Not Found' + } +} + +export class Unprocessable422 extends ErrorResType { + constructor(message?: string) { + super(422) + this.body.message = message ?? 'Unprocessable Entity' + } +} + +export class Internal500 extends ErrorResType { + constructor(message: string) { + super(500) + this.body.message = message + } +} diff --git a/.jjconflict-side-1/apps/server/src/utils/fastify.ts b/.jjconflict-side-1/apps/server/src/utils/fastify.ts new file mode 100644 index 0000000000..c51281e3e9 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/fastify.ts @@ -0,0 +1,54 @@ +import type { FastifySwaggerUiOptions } from '@fastify/swagger-ui' +import type { generateOpenApi } from '@ts-rest/open-api' +import type { FastifyServerOptions } from 'fastify' +import { randomUUID } from 'node:crypto' +import { getLoggerOptionsFromEnv } from '@cpn-console/logger' +import { swaggerUiPath } from '@cpn-console/shared' +import { + appVersion, + keycloakClientId, + keycloakClientSecret, + keycloakRealm, + keycloakRedirectUri, +} from './env.js' + +export const fastifyConf: FastifyServerOptions = { + maxParamLength: 5000, + logger: getLoggerOptionsFromEnv(), + genReqId: () => randomUUID(), +} + +const externalDocs = { + description: 'External documentation.', + url: 'https://cloud-pi-native.fr', +} + +export const swaggerConf: Parameters[1] = { + info: { + title: 'Console Cloud Pi Native', + description: 'API de gestion des ressources Cloud Pi Native.', + version: appVersion, + }, + + externalDocs, + servers: [ + { + url: keycloakRedirectUri, + }, + ], +} + +export const swaggerUiConf: FastifySwaggerUiOptions = { + routePrefix: `${swaggerUiPath}-server`, + uiConfig: { + docExpansion: 'list', + deepLinking: false, + }, + initOAuth: { + clientId: keycloakClientId, + clientSecret: keycloakClientSecret, + realm: keycloakRealm, + appName: 'Cloud Pi Native', + scopes: 'openid generic', + }, +} diff --git a/.jjconflict-side-1/apps/server/src/utils/hook-wrapper.spec.ts b/.jjconflict-side-1/apps/server/src/utils/hook-wrapper.spec.ts new file mode 100644 index 0000000000..c2f4e3ebcc --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/hook-wrapper.spec.ts @@ -0,0 +1,239 @@ +import type { KubeCluster, KubeUser, Project as ProjectPayload, Store } from '@cpn-console/hooks' +import type { ProjectInfos, ReposCreds } from './hook-wrapper.ts' +import { describe, expect, it } from 'vitest' +import { transformToHookProject } from './hook-wrapper.ts' + +const associatedCluster = { + id: 'f0e39981-0b6d-4c16-aa96-225062b75767', + infos: '', + label: 'carno', + privacy: 'dedicated', + secretName: '4a38422c-29e1-4b61-b533-edaa1b8a9b60', + kubeconfig: { + id: 'c8ba6db2-9a1d-4d6b-8b5e-2902cecd1437', + user: { + keyData: 'REDACTED', + certData: 'REDACTED', + }, + cluster: { + caData: 'REDACTED', + server: 'https://api-server:6443', + skipTLSVerify: false, + tlsServerName: 'api-server', + }, + createdAt: '2024-05-02T09:17:27.882Z', + updatedAt: '2024-05-02T09:17:27.882Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0', + slug: 'default', + }, +} +const nonAssociatedCluster = { + id: 'f0e39981-0b6d-4c16-aa96-225062b75111', + infos: '', + label: 'carno2', + privacy: 'dedicated', + secretName: '4a38422c-29e1-4b61-b533-edaa1b8a9111', + kubeconfig: { + id: 'c8ba6db2-9a1d-4d6b-8b5e-2902cecd1111', + user: { + keyData: 'REDACTED', + certData: 'REDACTED', + }, + cluster: { + caData: 'REDACTED', + server: 'https://api-server:6443', + skipTLSVerify: false, + tlsServerName: 'api-server', + }, + createdAt: '2024-05-02T09:17:27.882Z', + updatedAt: '2024-05-02T09:17:27.882Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0', + slug: 'default', + }, +} +const project: ProjectInfos = { + id: '011e7860-04d7-461f-912d-334c622d38b3', + name: 'candilib', + description: 'Application de réservation de places à l\'examen du permis B.', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.778Z', + updatedAt: '2023-07-03T14:46:56.783Z', + everyonePerms: 896n, + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + members: [], + clusters: [associatedCluster, nonAssociatedCluster], + environments: [ + { + id: '1b9f1053-fcf5-4053-a7b2-ff8a2c0c1921', + name: 'dev', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.787Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + quota: { + id: '5a57b62f-2465-4fb6-a853-5a751d099199', + memory: '4Gi', + cpu: 2, + name: 'small', + isPrivate: false, + }, + stage: { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + name: 'dev', + }, + cluster: { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + infos: 'Floating IP : 0.0.0.0', + label: 'pas-top-cluster', + privacy: 'dedicated', + secretName: '94d52618-7869-4192-b33e-85dd0959e815', + kubeconfig: { + id: 'b5662039-a62b-483e-ba54-b12c6f966c96', + user: { + token: 'kirikou', + }, + cluster: { + server: 'https://pwned.cluster', + tlsServerName: 'pwned.cluster', + }, + createdAt: '2024-07-24T16:54:14.969Z', + updatedAt: '2024-07-24T16:54:14.969Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + }, + }, + }, + { + id: '1c654f00-4798-4a80-929f-960ddb37885a', + name: 'integration', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + quota: { + id: '5a57b62f-2465-4fb6-a853-5a751d099199', + memory: '4Gi', + cpu: 2, + name: 'small', + isPrivate: false, + }, + stage: { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + name: 'integration', + }, + cluster: { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + infos: null, + label: 'top-secret-cluster', + privacy: 'dedicated', + secretName: '59be2d50-58f9-42f3-95dc-b0c0518e3d8a', + kubeconfig: { + id: '0e88f000-07e6-4781-a69d-0963489387f7', + user: { + token: 'nyan cat', + }, + cluster: { + server: 'https://nothere.cluster', + skipTLSVerify: false, + tlsServerName: 'nothere.cluster', + }, + createdAt: '2024-07-24T16:54:14.966Z', + updatedAt: '2024-07-24T16:54:14.966Z', + }, + clusterResources: true, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + }, + }, + }, + ], + repositories: [ + { + id: '299216bb-2dcc-42b5-ac71-6aa001d2dccf', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/dnum-mi/candilib.git', + externalUserName: 'this-is-a-test', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.802Z', + }, + ], + plugins: [], + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', + email: 'test@test.com', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + }, + roles: [], +} + +describe('transformToHookProject', () => { + // Mock data + const mockStore: Store = {} + const mockReposCreds: ReposCreds = { + console: { + token: 'test', + username: 'test', + }, + } + + it('transforme correctement le projet en objet Payload', () => { + const result: ProjectPayload = transformToHookProject(project, mockStore, mockReposCreds) + + // Asserts pour vérifier la transformation + + // Assert sur la transformation des utilisateurs + expect(result.users).toEqual([project.owner]) + + // Assert sur la transformation des rôles + expect(result.roles).toEqual([{ + name: 'owner', + position: 0, + users: [project.owner], + }]) + + // Assert sur la transformation des clusters + expect(result.clusters).toEqual([associatedCluster, nonAssociatedCluster].map(({ kubeconfig, ...cluster }) => ({ + user: kubeconfig.user as unknown as KubeUser, + cluster: kubeconfig.cluster as unknown as KubeCluster, + ...cluster, + privacy: cluster.privacy, + }))) + + // Assert sur la transformation des environnements + expect(result.environments).toEqual(project.environments.map(({ permissions: _, stage, quota, ...environment }) => ({ + quota, + stage: stage.name, + permissions: [{ permissions: { rw: true, ro: true }, userId: project.ownerId }], + ...environment, + apis: {}, + }))) + + // Assert sur la transformation des repositories + expect(result.repositories).toEqual(project.repositories.map(repo => ({ ...repo, newCreds: mockReposCreds[repo.internalRepoName] }))) + + // Assert sur le store + expect(result.store).toEqual(mockStore) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/utils/hook-wrapper.ts b/.jjconflict-side-1/apps/server/src/utils/hook-wrapper.ts new file mode 100644 index 0000000000..0df3d89a4b --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/hook-wrapper.ts @@ -0,0 +1,419 @@ +import type { ClusterObject, HookResult, KubeCluster, KubeUser, Project as ProjectPayload, RepoCreds, Repository, Store, ZoneObject } from '@cpn-console/hooks' +import type { AsyncReturnType } from '@cpn-console/shared' +import type { Cluster, Kubeconfig, Project, ProjectMembers, ProjectRole, Zone } from '@prisma/client' +import type { ConfigRecords } from '@/resources/project-service/business.js' +import { hooks } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { getPermsByUserRoles, ProjectAuthorized, resourceListToDict } from '@cpn-console/shared' +import { dbToObj } from '@/resources/project-service/business.js' +import { archiveProject, getAdminPlugin, getAdminRoleById, getClusterByIdOrThrow, getClusterNamesByZoneId, getClustersAssociatedWithProject, getHookProjectInfos, getHookRepository, getProjectStore, getRole, getZoneByIdOrThrow, saveProjectStore, updateProjectClusterHistory, updateProjectCreated, updateProjectFailed, updateProjectWarning } from '@/resources/queries-index.js' +import { genericProxy } from './proxy.js' + +export type ReposCreds = Record +export type ProjectInfos = AsyncReturnType + +const logger = baseLogger.child({ scope: 'utils:hook-wrapper' }) + +function summarizeHookResultForLogs(hookResult: HookResult) { + const nonOkResults = Object.fromEntries( + Object.entries(hookResult.results ?? {}) + .filter(([_pluginName, result]) => result?.status?.result !== 'OK') + .map(([pluginName, result]) => [ + pluginName, + { + result: result.status.result, + message: result.status.result === 'OK' ? undefined : result.status.message, + }, + ]), + ) + + return { + failed: hookResult.failed, + warning: hookResult.warning, + messageResume: hookResult.messageResume, + totalExecutionTime: hookResult.totalExecutionTime, + nonOkResults, + } +} + +async function getProjectPayload(projectId: Project['id'], reposCreds?: ReposCreds) { + const [ + project, + store, + clusters, + ] = await Promise.all([ + getHookProjectInfos(projectId), + getProjectStore(projectId), + getClustersAssociatedWithProject(projectId), + ]) + + return transformToHookProject({ + ...project, + clusters, + }, dbToObj(store), reposCreds) +} + +async function upsertProject(projectId: Project['id'], reposCreds?: ReposCreds) { + const [payload, config] = await Promise.all([ + getProjectPayload(projectId, reposCreds), + getAdminPlugin(), + ]) + + const results = await hooks.upsertProject.execute(payload, dbToObj(config)) + + const records: ConfigRecords = Object.entries(results.results).reduce((acc, [pluginName, result]) => { + if (result.store) { + return [...acc, ...Object.entries(result.store).map(([key, value]) => ({ pluginName, key, value: String(value) }))] + } + return acc + }, [] as ConfigRecords) + + await saveProjectStore(records, projectId) + const project = await manageProjectStatus(projectId, results, 'upsert', payload.environments.map(env => env.clusterId)) + return { + results, + project, + } +} +const project = { + upsert: async (projectId: Project['id'], reposCreds?: ReposCreds) => { + const first = await upsertProject(projectId, reposCreds) + if (!first.results.failed) { + return first + } + + logger.warn({ + action: 'upsertProject', + projectId, + attempt: 1, + maxAttempts: 2, + ...summarizeHookResultForLogs(first.results), + }, 'Hook upsertProject failed, retrying once') + + const second = await upsertProject(projectId, reposCreds) + const logPayload = { + action: 'upsertProject', + projectId, + attempt: 2, + maxAttempts: 2, + ...summarizeHookResultForLogs(second.results), + } + if (second.results.failed) { + logger.error(logPayload, 'Hook upsertProject retry failed') + } else { + logger.info(logPayload, 'Hook upsertProject retry succeeded') + } + + return second + }, + delete: async (projectId: Project['id']) => { + const [payload, config] = await Promise.all([ + getProjectPayload(projectId), + getAdminPlugin(), + ]) + const results = await hooks.deleteProject.execute(payload, dbToObj(config)) + return { + results, + project: await manageProjectStatus(projectId, results, 'delete', []), + } + }, + getSecrets: async (projectId: Project['id']) => { + const project = await getHookProjectInfos(projectId) + const store = dbToObj(await getProjectStore(project.id)) + const config = dbToObj(await getAdminPlugin()) + + return hooks.getProjectSecrets.execute({ ...project, store }, config) + }, +} as const + +type ProjectAction = keyof typeof project +async function manageProjectStatus(projectId: Project['id'], hookReply: HookResult, action: ProjectAction, envClusterIds: Cluster['id'][]): Promise> { + if (!hookReply.failed && hookReply.results?.kubernetes) { + await updateProjectClusterHistory(projectId, envClusterIds) + } + if (hookReply.failed) { + return updateProjectFailed(projectId) + } else if (hookReply.warning.length) { + return updateProjectWarning(projectId) + } else if (action === 'upsert') { + return updateProjectCreated(projectId) + } else if (action === 'delete') { + return archiveProject(projectId) + } + throw new Error('unknown action') +} + +const cluster = { + upsert: async (clusterId: Cluster['id'], previousZoneId: Cluster['zoneId']) => { + const cluster = await getClusterByIdOrThrow(clusterId) + const clusterObject = cluster as unknown as ClusterObject + const store = dbToObj(await getAdminPlugin()) + if (cluster.zoneId !== previousZoneId) { + // Upsert on the old zone to remove cluster + const previousClusterObject = { + ...cluster, + } as unknown as ClusterObject + previousClusterObject.zone = await getZoneByIdOrThrow(previousZoneId) + previousClusterObject.zone.clusterNames = await getClusterNamesByZoneId(previousZoneId) + const hookResult = await hooks.upsertCluster.execute({ + ...cluster.kubeconfig as unknown as Pick, + ...previousClusterObject, + }, store) + if (hookResult.failed) { + return hookResult + } + } + clusterObject.zone.clusterNames = await getClusterNamesByZoneId(cluster.zoneId) + return hooks.upsertCluster.execute({ + ...cluster.kubeconfig as unknown as Pick, + ...clusterObject, + }, store) + }, + delete: async (clusterId: Cluster['id']) => { + const cluster = await getClusterByIdOrThrow(clusterId) + const clusterObject = cluster as unknown as ClusterObject + const clusterNames = await getClusterNamesByZoneId(cluster.zoneId) + clusterObject.zone.clusterNames = clusterNames.filter(c => c !== cluster.label) + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteCluster.execute({ + ...cluster.kubeconfig as unknown as ClusterObject, + ...clusterObject, + }, store) + }, +} as const + +const user = { + retrieveUserByEmail: async (email: string) => { + const config = dbToObj(await getAdminPlugin()) + return hooks.retrieveUserByEmail.execute({ email }, config) + }, +} as const + +const projectMember = { + upsert: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { + const project = await getHookProjectInfos(projectId) + const projectStore = dbToObj(await getProjectStore(project.id)) + const hookProject = transformToHookProject(project, projectStore) + const store = dbToObj(await getAdminPlugin()) + + const member = project.members.find(m => m.userId === userId) + if (!member) throw new Error('Member not found') + + const memberRoles = project.roles + .filter(role => member.roleIds.includes(role.id)) + .map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ?? undefined, + project: hookProject, + })) + + const payload = { + userId: member.userId, + roleIds: member.roleIds, + firstName: member.user.firstName, + lastName: member.user.lastName, + email: member.user.email, + type: member.user.type as 'human' | 'bot' | 'ghost', + createdAt: member.user.createdAt.toISOString(), + updatedAt: member.user.updatedAt.toISOString(), + lastLogin: member.user.lastLogin?.toISOString(), + projectId: project.id, + roles: memberRoles, + project: hookProject, + } + + return hooks.upsertProjectMember.execute(payload, store) + }, + delete: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { + const project = await getHookProjectInfos(projectId) + const projectStore = dbToObj(await getProjectStore(project.id)) + const hookProject = transformToHookProject(project, projectStore) + const store = dbToObj(await getAdminPlugin()) + + const member = project.members.find(m => m.userId === userId) + if (!member) throw new Error('Member not found') + + const memberRoles = project.roles + .filter(role => member.roleIds.includes(role.id)) + .map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ?? undefined, + project: hookProject, + })) + + const payload = { + userId: member.userId, + roleIds: member.roleIds, + firstName: member.user.firstName, + lastName: member.user.lastName, + email: member.user.email, + type: member.user.type as 'human' | 'bot' | 'ghost', + createdAt: member.user.createdAt.toISOString(), + updatedAt: member.user.updatedAt.toISOString(), + lastLogin: member.user.lastLogin?.toISOString(), + projectId: project.id, + roles: memberRoles, + project: hookProject, + } + + return hooks.deleteProjectMember.execute(payload, store) + }, +} as const + +const projectRole = { + upsert: async (roleId: ProjectRole['id']) => { + const role = await getRole(roleId) + if (!role) throw new Error('Role not found') + + const project = await getHookProjectInfos(role.projectId) + const projectStore = dbToObj(await getProjectStore(role.projectId)) + const hookProject = transformToHookProject(project, projectStore) + + const rolePayload = { + ...role, + permissions: role.permissions.toString(), + project: hookProject, + } + const store = dbToObj(await getAdminPlugin()) + return hooks.upsertProjectRole.execute(rolePayload, store) + }, + delete: async (roleId: ProjectRole['id']) => { + const role = await getRole(roleId) + if (!role) throw new Error('Role not found') + + const project = await getHookProjectInfos(role.projectId) + const projectStore = dbToObj(await getProjectStore(role.projectId)) + const hookProject = transformToHookProject(project, projectStore) + + const rolePayload = { + ...role, + permissions: role.permissions.toString(), + project: hookProject, + } + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteProjectRole.execute(rolePayload, store) + }, +} as const + +const zone = { + upsert: async (zoneId: Zone['id']) => { + const zone: ZoneObject = await getZoneByIdOrThrow(zoneId) + zone.clusterNames = await getClusterNamesByZoneId(zoneId) + const store = dbToObj(await getAdminPlugin()) + return hooks.upsertZone.execute(zone, store) + }, + delete: async (zoneId: Zone['id']) => { + const zone = await getZoneByIdOrThrow(zoneId) + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteZone.execute(zone, store) + }, +} as const + +const misc = { + checkServices: async () => { + const config = dbToObj(await getAdminPlugin()) + return hooks.checkServices.execute({}, config) + }, + syncRepository: async (repoId: string, { syncAllBranches, branchName }: { syncAllBranches: boolean, branchName?: string }) => { + const { project, ...repoInfos } = await getHookRepository(repoId) + const store = dbToObj(await getProjectStore(project.id)) + const payload = { + repo: { ...repoInfos, syncAllBranches, branchName }, + ...project, + store, + } + const config = dbToObj(await getAdminPlugin()) + return hooks.syncRepository.execute(payload, config) + }, +} as const + +const adminRole = { + upsert: async (roleId: string) => { + const role = await getAdminRoleById(roleId) + if (!role) throw new Error('Role not found') + const config = dbToObj(await getAdminPlugin()) + return hooks.upsertAdminRole.execute(role, config) + }, + delete: async (role: AsyncReturnType) => { + if (!role) throw new Error('Role is required') + const config = dbToObj(await getAdminPlugin()) + return hooks.deleteAdminRole.execute(role, config) + }, +} as const + +export const hook = { + // @ts-ignore TODO voir comment opti la signature de la fonction + misc: genericProxy(misc), + // @ts-ignore TODO voir comment opti la signature de la fonction + project: genericProxy(project, { upsert: ['delete'], delete: ['upsert', 'delete'], getSecrets: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + projectRole: genericProxy(projectRole, { delete: ['upsert', 'delete'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + projectMember: genericProxy(projectMember, { delete: ['upsert'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + cluster: genericProxy(cluster, { delete: ['upsert', 'delete'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + zone: genericProxy(zone, { delete: ['upsert'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + user: genericProxy(user, {}), + // @ts-ignore TODO voir comment opti la signature de la fonction + adminRole: genericProxy(adminRole, { delete: ['upsert'], upsert: ['delete'] }), +} + +function formatClusterInfos({ kubeconfig, ...cluster }: Omit + & { kubeconfig: Kubeconfig, zone: Pick }) { + return { + user: kubeconfig.user as unknown as KubeUser, + cluster: kubeconfig.cluster as unknown as KubeCluster, + ...cluster, + privacy: cluster.privacy, + } +} +export type RolesById = Record + +export function transformToHookProject(project: ProjectInfos, store: Store, reposCreds: ReposCreds = {}): ProjectPayload { + const clusters = project.clusters.map(cluster => formatClusterInfos(cluster)) + const rolesById = resourceListToDict(project.roles) + + return ({ + ...project, + clusters, + environments: project.environments.map(({ stage, ...environment }) => ({ + stage: stage.name, + permissions: [ + { permissions: { rw: true, ro: true }, userId: project.ownerId }, + ...project.members.map(member => ({ + userId: member.userId, + permissions: { + ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions: getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) }), + rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions: getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) }), + }, + })), + ], + ...environment, + apis: {}, + })), + repositories: project.repositories.map(repo => ({ ...repo, newCreds: reposCreds[repo.internalRepoName] })), + store, + users: [project.owner, ...project.members.map(({ user }) => user)], + roles: [ + { + name: 'owner', + position: 0, + users: [project.owner], + }, + ...project.roles.map(role => ({ + name: role.name, + permissions: role.permissions.toString(), + position: role.position, + type: role.type, + oidcGroup: role.oidcGroup, + users: project.members + .filter(member => member.roleIds.includes(role.id)) + .map(member => member.user), + })), + ], + }) +} diff --git a/.jjconflict-side-1/apps/server/src/utils/keycloak-utils.spec.ts b/.jjconflict-side-1/apps/server/src/utils/keycloak-utils.spec.ts new file mode 100644 index 0000000000..42c9cfae04 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/keycloak-utils.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { userPayloadMapper } from './keycloak-utils.js' + +describe('keycloak', () => { + it('should map keycloak user object to DSO user object without groups', () => { + const payload = { + sub: 'thisIsAnId', + email: 'test@test.com', + given_name: 'Jean', + family_name: 'DUPOND', + } + const desired = { + id: 'thisIsAnId', + email: 'test@test.com', + firstName: 'Jean', + lastName: 'DUPOND', + groups: [], + } + + const transformed = userPayloadMapper(payload) + + expect(transformed).toMatchObject(desired) + }) + + it('should map keycloak user object to DSO user object with groups', () => { + const payload = { + sub: 'thisIsAnId', + email: 'test@test.com', + given_name: 'Jean', + family_name: 'DUPOND', + groups: ['group1'], + } + const desired = { + id: 'thisIsAnId', + email: 'test@test.com', + firstName: 'Jean', + lastName: 'DUPOND', + groups: ['group1'], + } + + const transformed = userPayloadMapper(payload) + + expect(transformed).toMatchObject(desired) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/utils/keycloak-utils.ts b/.jjconflict-side-1/apps/server/src/utils/keycloak-utils.ts new file mode 100644 index 0000000000..a86a5f13c3 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/keycloak-utils.ts @@ -0,0 +1,27 @@ +import type { FastifyRequest } from 'fastify' +import { tokenHeaderName } from '@cpn-console/shared' + +interface KeycloakPayload { + sub: string + email: string + given_name: string + family_name: string + groups: string[] +} + +export function userPayloadMapper(userPayload: KeycloakPayload) { + return { + id: userPayload.sub, + email: userPayload.email, + firstName: userPayload.given_name, + lastName: userPayload.family_name, + groups: userPayload.groups || [], + } +} + +export function bypassFn(request: FastifyRequest) { + try { + return !!request.headers[tokenHeaderName] + } catch {} + return false +} diff --git a/.jjconflict-side-1/apps/server/src/utils/keycloak.ts b/.jjconflict-side-1/apps/server/src/utils/keycloak.ts new file mode 100644 index 0000000000..73137efc3b --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/keycloak.ts @@ -0,0 +1,42 @@ +import type { KeycloakOptions } from 'fastify-keycloak-adapter' +import { serviceContract, swaggerUiPath, systemContract } from '@cpn-console/shared' +import { + keycloakClientId, + keycloakClientSecret, + keycloakDomain, + keycloakProtocol, + keycloakRealm, + keycloakRedirectUri, + sessionSecret, +} from './env.js' +import { bypassFn, userPayloadMapper } from './keycloak-utils.js' + +export const keycloakConf = { + appOrigin: keycloakRedirectUri ?? 'http://localhost:8080', + keycloakSubdomain: `${keycloakDomain}/realms/${keycloakRealm}`, + clientId: keycloakClientId ?? '', + clientSecret: keycloakClientSecret ?? '', + useHttps: keycloakProtocol === 'https', + disableCookiePlugin: true, + disableSessionPlugin: true, + // @ts-ignore + userPayloadMapper, + retries: 5, + excludedPatterns: [ + systemContract.getVersion.path, + systemContract.getHealth.path, + serviceContract.getServiceHealth.path, + `${swaggerUiPath}-server/**`, + ], + bypassFn, +} as const satisfies KeycloakOptions + +export const sessionConf = { + cookieName: 'sessionId', + secret: sessionSecret || 'a-very-strong-secret-with-more-than-32-char', + cookie: { + httpOnly: true, + secure: true, + }, + expires: 1_800_000, +} diff --git a/.jjconflict-side-1/apps/server/src/utils/mocks.ts b/.jjconflict-side-1/apps/server/src/utils/mocks.ts new file mode 100644 index 0000000000..16169f4d31 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/mocks.ts @@ -0,0 +1,152 @@ +import type { PluginsManifests, RepoCreds, ServiceInfos } from '@cpn-console/hooks' +import type { Repository } from '@prisma/client' +import type { UserDetails } from '../types/index.js' +import type * as utilsController from '../utils/controller.js' +import { editStrippers, populatePluginManifests } from '@cpn-console/hooks' +import { DEFAULT, DISABLED, PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import fp from 'fastify-plugin' + +let requestor: Requestor + +export function setRequestor(user: Requestor = getRandomRequestor()) { + requestor = user +} + +export function getRequestor() { + return requestor +} + +export async function mockSessionPlugin() { + const sessionPlugin = (app: any, opt: any, next: any) => { + app.addHook('onRequest', (req: any, res: any, next: any) => { + req.session = { user: getRequestor() } + next() + }) + next() + } + + return { default: fp(sessionPlugin) } +} + +export async function mockHooksPackage() { + const hookTemplate = { + execute: () => ({ + args: {}, + failed: false, + }), + validate: () => ({ + failed: false, + }), + } + + return { + editStrippers, + populatePluginManifests, + services: { + getStatus: () => [], + refreshStatus: async () => [], + }, + PluginApi: class { }, + servicesInfos: { + registry: { title: 'Harbor', name: 'registry', to: () => 'test' }, + plugin2: { title: 'Plugin2', name: 'plugin2', to: () => ({ to: 'test', title: 'Test' }) }, + plugin3: { title: 'Plugin3', name: 'plugin3', to: () => [{ to: 'test', title: 'Test' }] }, + plugin4: { title: 'Plugin4', name: 'plugin4', to: () => [{ to: 'test' }] }, + plugin5: { title: 'Plugin5', name: 'plugin5' }, + } as Record, + pluginsManifests: { + registry: { + title: 'Harbor', + global: [{ + kind: 'switch', + initialValue: DEFAULT, + key: 'test2', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Test2', + value: DEFAULT, + description: 'description', + }], + project: [{ + kind: 'switch', + key: 'test2', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Test', + value: DEFAULT, + initialValue: DISABLED, + }], + }, + } as PluginsManifests, + hooks: { + // projects + getProjectSecrets: { + execute: () => ({ + failed: false, + args: {}, + results: { + registry: { + secrets: { + token: 'myToken', + }, + status: { + failed: false, + }, + }, + }, + }), + }, + upsertProject: hookTemplate, + deleteProject: hookTemplate, + // clusters + upsertCluster: hookTemplate, + deleteCluster: hookTemplate, + // user + retrieveUserByEmail: hookTemplate, + }, + } +} + +export type ReposCreds = Record + +type Requestor = Partial +export function getRandomRequestor(user?: Requestor): Partial { + return { + id: user?.id ?? faker.string.uuid(), + email: user?.email ?? faker.internet.email(), + firstName: user?.firstName ?? faker.person.firstName(), + lastName: user?.lastName ?? faker.person.lastName(), + type: 'human', + ...user?.groups !== null && { groups: user?.groups ?? [] }, + } +} + +export function getUserMockInfos(adminPermissions?: bigint, user?: UserDetails): utilsController.UserProfile & utilsController.ProjectPermState +export function getUserMockInfos(adminPermissions?: bigint, user?: UserDetails, project?: utilsController.ProjectPermState): utilsController.UserProjectProfile & utilsController.ProjectPermState +export function getUserMockInfos(adminPermissions: bigint = 0n, user = getRandomRequestor(), project?: utilsController.ProjectPermState): utilsController.UserProfile | utilsController.UserProjectProfile { + return { + adminPermissions, + user, + ...project, + } +} + +export function getProjectMockInfos({ projectId, projectLocked, projectOwnerId, projectPermissions, projectStatus }: Partial): utilsController.ProjectPermState { + return { + projectId: projectId ?? faker.string.uuid(), + projectLocked: projectLocked ?? false, + projectOwnerId: projectOwnerId ?? faker.string.uuid(), + projectStatus: projectStatus ?? 'created', + projectPermissions: projectPermissions ?? PROJECT_PERMS.MANAGE, + } +} + +export const atDates = { + createdAt: new Date(), + updatedAt: new Date(), +} diff --git a/.jjconflict-side-1/apps/server/src/utils/plugins.ts b/.jjconflict-side-1/apps/server/src/utils/plugins.ts new file mode 100644 index 0000000000..9876677766 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/plugins.ts @@ -0,0 +1,9 @@ +import type { PluginManagerOptions } from '@cpn-console/hooks' +import { isCI, isInt, isProd } from './env.js' + +export const pluginManagerOptions: PluginManagerOptions = { + mockHooks: isCI || (!isProd && !isInt), + mockMonitoring: isCI || (!isProd && !isInt), + mockExternalServices: isCI || (!isProd && !isInt), + startPlugins: (!isCI && isProd) || isInt, +} diff --git a/.jjconflict-side-1/apps/server/src/utils/proxy.spec.ts b/.jjconflict-side-1/apps/server/src/utils/proxy.spec.ts new file mode 100644 index 0000000000..7fdc203833 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/proxy.spec.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { genericProxy } from './proxy.js' + +// Création d'une cible de test +const target = { + async fetchData(id: string) { + return { id, data: 'Mocked data' } + }, + async otherMethod(id: string) { + return { id, data: 'Mocked data' } + }, +} + +describe('test calls without ID passed', () => { + // Test d'appel de méthode sans ID + it('calling method without ID', async () => { + const proxied = genericProxy(target) + const result = await proxied.fetchData() + expect(result).toEqual({ id: undefined, data: 'Mocked data' }) + }) + + // Fonction de test asynchrone pour tester le cas où aucune ID n'est fournie + it('test when no ID is provided', async () => { + // Création d'une cible de test + const target = { + async fetchData() { + return 'No ID provided' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Appel à la méthode fetchData sans ID + const result = await proxied.fetchData() + + // Vérification que le résultat est correct + expect(result).toBe('No ID provided') + }) + + // Fonction de test asynchrone pour tester le cas où aucune ID n'est fournie avec une promesse en cours + it('test when no ID is provided with pending promise', async () => { + // Création d'une cible de test + const target = { + async fetchData() { + return new Promise(resolve => setTimeout(resolve, 100, 'Pending result')) + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Appel à la méthode fetchData sans ID + const promise1 = proxied.fetchData() + const promise2 = proxied.fetchData() // Deuxième appel avant la résolution du premier + + // Attendre que la première promesse se résolve + const result1 = await promise1 + + // Vérification que le résultat de la première promesse est correct + expect(result1).toBe('Pending result') + + // Attendre que la deuxième promesse se résolve + const result2 = await promise2 + + // Vérification que le résultat de la deuxième promesse est correct + expect(result2).toBe('Pending result') + }) + // Test pour vérifier que l'erreur est levée lorsque args est fourni sans ID + it('test error when args provided without ID', async () => { + // Création d'une cible de test + const target = { + async fetchData(_id: string, _args: any) { + return 'No ID provided' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + const args = { key: 'value' } + + // Appel de la fonction fetchData avec des arguments mais sans ID + await expect(proxied.fetchData(undefined, args)).rejects.toThrow('ID is required when args are provided') + }) +}) + +describe('test calls with ID passed', () => { + // Test d'appel de méthode avec ID + it('calling method with ID', async () => { + const proxied = genericProxy(target) + const result = await proxied.fetchData('123') + expect(result).toEqual({ id: '123', data: 'Mocked data' }) + }) + + // Test d'appel de méthode avec exclusion en cours + it('calling method with exclusion in progress', async () => { + const proxied = genericProxy(target, { fetchData: ['otherMethod'] }) + // Simuler une exécution en cours pour la méthode exclue + proxied.otherMethod('456') + + // Maintenant, tenter d'appeler fetchData pour le même ID devrait échouer + await expect(proxied.fetchData('456')).rejects.toThrow( + 'otherMethod in progress on 456, can\'t fetchData', + ) + }) + + // Fonction de test asynchrone pour tester le mélange des nextArgs + it('test mixing nextArgs from concurrent promises', async () => { + // Création d'une cible de test + const target = { + async fetchData(id: string, args?: object) { + return { id, args } + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + const promise1 = proxied.fetchData('123', { key1: 'value1' }) + // Appels successifs à fetchData avec différents arguments + const promise2 = proxied.fetchData('123', { key2: 'value2' }) + + // Promesse concurrente avec des nextArgs différents + const promise3 = proxied.fetchData('123', { key3: 'value3' }) + + // Attendre que les promesses se résolvent + const result1 = await promise1 + const result2 = await promise2 + const result3 = await promise3 + + // Vérification que les nextArgs de promise2 et promise3 ont été correctement mélangés + expect(result1.args).toEqual({ key1: 'value1' }) + expect(result2.args).toEqual({ key2: 'value2', key3: 'value3' }) + expect(result3.args).toEqual({ key2: 'value2', key3: 'value3' }) + }) + + it('test rejection of set attempt', () => { + // Création d'une cible de test + const target = { + async fetchData() { + return 'Mocked data' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Tentative de définir une nouvelle propriété sur le proxy + const setAttempt = () => { + proxied.fetchData = () => new Promise(resolve => resolve('illegal')) + } + + // Vérification que la tentative de set est rejetée + expect(setAttempt).toThrow(TypeError) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/utils/proxy.ts b/.jjconflict-side-1/apps/server/src/utils/proxy.ts new file mode 100644 index 0000000000..ef915a7d15 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/proxy.ts @@ -0,0 +1,78 @@ +// @ts-nocheck un enfer à typer, pour plus tard +type Tracker> = Record + nextArgs?: [string] +}>> | Promise + +type Target = Record Promise> +type Excludes = Partial>> | undefined +const toTarget = (target: T) => ({ tracker: {} as Tracker, methods: target }) + +// @ts-ignore +export function genericProxy(proxied: T, excludes: Excludes = {}): T { + return new Proxy(toTarget(proxied), { + get({ methods, tracker }, property: string) { + if (!(property in methods)) return + return async (...args) => { + const id = args[0] as string + + if (!id && args.length > 0) { + throw new Error('ID is required when args are provided') + } + + if (!id) { + if (tracker[property] instanceof Promise) { + return tracker[property] + } + const p = methods[property]() + if (p instanceof Promise) { + tracker[property] = p + p.then(() => { + delete tracker[property] + }) + } + return p + } + if (!tracker[property]) { + tracker[property] = {} + } + + for (const testExclude of excludes[property] ?? []) { + // @ts-ignore + if (tracker?.[testExclude]?.[id]?.currentExec) { + throw new Error(`${String(testExclude)} in progress on ${id}, can't ${String(property)}`) + } + } + + if (id in tracker[property]) { + if (args[1]) { + tracker[property][id].nextArgs = { + ...(tracker[property][id].nextArgs ?? {}), + ...args[1], + } + } + if (tracker[property][id].currentExec) { + return new Promise((resolve) => { + tracker[property][id].currentExec.then(() => { + resolve(tracker[property][id].currentExec ?? methods[property](id, tracker[property][id].nextArgs)) + }) + }) + } + } + const p = methods[property](...args) + tracker[property][id] = { + currentExec: p, + nextArgs: undefined, + } + tracker[property][id].currentExec = p + p.then(() => { + tracker[property][id].currentExec = undefined + }) + return p + } + }, + set() { + return false + }, + }) as T +} diff --git a/.jjconflict-side-1/apps/server/src/utils/queries-tools.spec.ts b/.jjconflict-side-1/apps/server/src/utils/queries-tools.spec.ts new file mode 100644 index 0000000000..e37ed94c25 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/queries-tools.spec.ts @@ -0,0 +1,47 @@ +import { exclude } from '@cpn-console/shared' +import { describe, expect, it } from 'vitest' +import { filterObjectByKeys } from './queries-tools.js' + +describe('queries-tools', () => { + it('should return a filtered object (filterObjectByKeys)', () => { + const initial = { + id: 'thisIsAnId', + name: 'alsoKeepThisKey', + description: 'keepThisKey', + } + const desired = { + name: 'alsoKeepThisKey', + description: 'keepThisKey', + } + + const transformed = filterObjectByKeys(initial, ['name', 'description']) + + expect(transformed).toMatchObject(desired) + }) + + it('should return a filtered object (exclude)', () => { + const initial = { + id: 'thisIsAnId', + name: 'myProjectName', + environment: { + permissions: { + password: 'secret', + id: 'notSecret', + }, + }, + } + const desired = { + id: 'thisIsAnId', + name: 'myProjectName', + environment: { + permissions: { + id: 'notSecret', + }, + }, + } + + const transformed = exclude(initial, ['password']) + + expect(transformed).toMatchObject(desired) + }) +}) diff --git a/.jjconflict-side-1/apps/server/src/utils/queries-tools.ts b/.jjconflict-side-1/apps/server/src/utils/queries-tools.ts new file mode 100644 index 0000000000..856ca277f1 --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/queries-tools.ts @@ -0,0 +1,11 @@ +export const dbKeysExcluded = ['updatedAt', 'createdAt'] + +// TODO +// @ts-ignore supprimer cette fonction et utiliser des schémas zod où elle est utilisé +export function filterObjectByKeys(obj, keys) { + return Object.fromEntries( + Object.entries(obj)?.filter(([key, _value]) => keys.includes(key)), + ) +} + +export const uuid: RegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i diff --git a/.jjconflict-side-1/apps/server/src/utils/random.spec.ts b/.jjconflict-side-1/apps/server/src/utils/random.spec.ts new file mode 100644 index 0000000000..3c33d6a4ee --- /dev/null +++ b/.jjconflict-side-1/apps/server/src/utils/random.spec.ts @@ -0,0 +1,148 @@ +import { createRandomDbSetup } from '@cpn-console/test-utils' +import { describe, expect, it } from 'vitest' + +describe('random utils', () => { + // TODO + it.skip('should create a random db for tests', () => { + const db = createRandomDbSetup({ nbUsers: 3, nbRepo: 1, envs: ['dev', 'prod'] }) + expect(db).toEqual( + expect.objectContaining({ + stages: expect.arrayContaining([ + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + ]), + quotas: expect.arrayContaining([ + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + ]), + project: expect.objectContaining({ + id: expect.any(String), + name: expect.any(String), + clusters: expect.arrayContaining([{ + caData: expect.any(String), + server: expect.any(String), + tlsServername: expect.any(String), + }]), + status: expect.any(String), + locked: expect.any(Boolean), + roles: expect.arrayContaining([ + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + ]), + repositories: expect.any(Array), + environments: expect.arrayContaining([ + { + id: expect.any(String), + stageId: expect.any(String), + projectId: expect.any(String), + quotaId: expect.any(String), + status: expect.any(String), + permissions: expect.any(Array), + clusters: expect.any(Array), + }, + { + id: expect.any(String), + stageId: expect.any(String), + projectId: expect.any(String), + quotaId: expect.any(String), + status: expect.any(String), + permissions: expect.any(Array), + clusters: expect.any(Array), + }, + ]), + }), + users: expect.arrayContaining([ + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + ]), + }), + ) + }) +}) diff --git a/.jjconflict-side-1/apps/server/tsconfig.json b/.jjconflict-side-1/apps/server/tsconfig.json new file mode 100644 index 0000000000..a3397898ca --- /dev/null +++ b/.jjconflict-side-1/apps/server/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "useUnknownInCatchVariables": false, + "declarationDir": "./types", + "outDir": "./dist", + "plugins": [{ "transform": "typescript-transform-paths" }] + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__", + "./src/mocks/utils.ts", + "./src/utils/mocks.ts" + ] +} diff --git a/.jjconflict-side-1/apps/server/vite.config.ts b/.jjconflict-side-1/apps/server/vite.config.ts new file mode 100644 index 0000000000..f9a3e50fae --- /dev/null +++ b/.jjconflict-side-1/apps/server/vite.config.ts @@ -0,0 +1,18 @@ +/// +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + poolMatchGlobs: [ + ['**/resources/**/*.spec.ts', 'forks'], + ], + }, +}) diff --git a/.jjconflict-side-1/apps/server/vitest-init.ts b/.jjconflict-side-1/apps/server/vitest-init.ts new file mode 100644 index 0000000000..596420f7de --- /dev/null +++ b/.jjconflict-side-1/apps/server/vitest-init.ts @@ -0,0 +1,11 @@ +process.env.ARGOCD_URL = 'https://argo-cd.readthedocs.io' +process.env.GITLAB_URL = 'https://gitlab.com' +process.env.HARBOR_URL = 'https://goharbor.io' +process.env.NEXUS_URL = 'https://sonatype.com/products/nexus-repository' +process.env.SONARQUBE_URL = 'https://www.sonarqube.org' +process.env.VAULT_URL = 'https://www.vaultproject.io' +process.env.PROJECTS_ROOT_DIR = 'forge-mi/projects' +process.env.KEYCLOAK_REDIRECT_URI = 'http://console.dso.local' +process.env.CONTACT_EMAIL = 'cloudpinative-relations@interieur.gouv.fr' +process.env.OPENCDS_URL = 'https://opencds.gouv.fr' +process.env.OPENCDS_API_TOKEN = 'test_token' diff --git a/.jjconflict-side-1/apps/server/vitest.config.ts b/.jjconflict-side-1/apps/server/vitest.config.ts new file mode 100644 index 0000000000..9dc913021e --- /dev/null +++ b/.jjconflict-side-1/apps/server/vitest.config.ts @@ -0,0 +1,35 @@ +import { fileURLToPath } from 'node:url' +import { mergeConfig } from 'vite' +import { configDefaults, defineConfig } from 'vitest/config' +import viteConfig from './vite.config' + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + reporters: ['default', 'hanging-process'], + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: [ + '**/types', + '**/mocks', + '**/*.spec.ts', + '**/*.d.ts', + '**/*.vue', + '**/queries.ts', + '**/mocks.ts', + '**/*.sql', + ], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + setupFiles: ['./vitest-init.ts'], + root: fileURLToPath(new URL('./', import.meta.url)), + pool: 'forks', + }, + }), +) diff --git a/.jjconflict-side-1/ci/matrix-docker.json b/.jjconflict-side-1/ci/matrix-docker.json new file mode 100644 index 0000000000..3550832dba --- /dev/null +++ b/.jjconflict-side-1/ci/matrix-docker.json @@ -0,0 +1,42 @@ +[ + { + "name": "server", + "build": { + "context": "./", + "dockerfile": "./apps/server/Dockerfile", + "target": "prod" + } + }, + { + "name": "nginx-strangler", + "build": { + "context": "./", + "dockerfile": "./apps/nginx-strangler/Dockerfile", + "target": "prod" + } + }, + { + "name": "server-nestjs", + "build": { + "context": "./", + "dockerfile": "./apps/server-nestjs/Dockerfile", + "target": "prod" + } + }, + { + "name": "client", + "build": { + "context": "./", + "dockerfile": "./apps/client/Dockerfile", + "target": "prod" + } + }, + { + "name": "opencds-mockoon", + "build": { + "context": "./", + "dockerfile": "./packages/opencds/Dockerfile", + "target": "prod" + } + } +] diff --git a/.jjconflict-side-1/ci/matrix-npm.json b/.jjconflict-side-1/ci/matrix-npm.json new file mode 100644 index 0000000000..b4805a8be6 --- /dev/null +++ b/.jjconflict-side-1/ci/matrix-npm.json @@ -0,0 +1,72 @@ +{ + "apps": [ + { + "name": "@cpn-console/server", + "path": "./apps/server" + }, + { + "name": "@cpn-console/server-nestjs", + "path": "./apps/server-nestjs" + }, + { + "name": "@cpn-console/client", + "path": "./apps/client" + } + ], + "packages": [ + { + "name": "@cpn-console/shared", + "path": "./packages/shared" + }, + { + "name": "@cpn-console/hooks", + "path": "./packages/hooks" + }, + { + "name": "@cpn-console/logger", + "path": "./packages/logger" + }, + { + "name": "@cpn-console/test-utils", + "path": "./packages/test-utils" + }, + { + "name": "@cpn-console/eslint-config", + "path": "./packages/eslintconfig" + }, + { + "name": "@cpn-console/ts-config", + "path": "./packages/tsconfig" + } + ], + "plugins": [ + { + "name": "@cpn-console/argocd-plugin", + "path": "./plugins/argocd" + }, + { + "name": "@cpn-console/gitlab-plugin", + "path": "./plugins/gitlab" + }, + { + "name": "@cpn-console/harbor-plugin", + "path": "./plugins/harbor" + }, + { + "name": "@cpn-console/keycloak-plugin", + "path": "./plugins/keycloak" + }, + { + "name": "@cpn-console/nexus-plugin", + "path": "./plugins/nexus" + }, + { + "name": "@cpn-console/sonarqube-plugin", + "path": "./plugins/sonarqube" + }, + { + "name": "@cpn-console/vault-plugin", + "path": "./plugins/vault" + } + ] +} diff --git a/.jjconflict-side-1/ci/scripts/build-matrix.sh b/.jjconflict-side-1/ci/scripts/build-matrix.sh new file mode 100755 index 0000000000..301a203ea5 --- /dev/null +++ b/.jjconflict-side-1/ci/scripts/build-matrix.sh @@ -0,0 +1,205 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Get versions +DOCKER_VERSION="$(docker --version)" + + +# Default +# REGISTRY="docker.io" +TAGS="latest" +COMMIT_SHA="$(git rev-parse --short HEAD)" +PLATFORMS="linux/amd64" +CSV=false +RECURSIVE=false + +unset MAJOR_VERSION +unset MINOR_VERSION +unset PATCH_VERSION + +# Declare script helper +TEXT_HELPER="\nThis script aims to build matrix for Github CI/CD. It will parse the given docker-compose file and return a json object with images infos (name, tag, context, dockerfile and if it need to be build) +Following flags are available: + + -a Create recursive tags, if it match 'x.x.x' it will create 'x.x' and 'x'. + + -c Use csv list formated output for tags instead of json array. + + -f Docker-compose file used to build matrix. + + -n Namespace used to tag images. e.g 'username/reponame'. + + -p Target platforms used to build matrix (List/CSV format. ex: 'linux/amd64,linux/arm64'). + Default is '$PLATFORMS'. + + -r Registry host used to build matrix. + Default is '$REGISTRY'. + + -t Docker tag used to build matrix. + Default is '$TAGS'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hacf:n:p:r:t: flag; do + case "${flag}" in + a) + RECURSIVE=true;; + c) + CSV=true;; + f) + COMPOSE_FILE=${OPTARG};; + n) + NAMESPACE=${OPTARG};; + p) + PLATFORMS=${OPTARG};; + r) + REGISTRY=${OPTARG};; + t) + TAGS=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ ! -f "$(readlink -f $COMPOSE_FILE)" ]; then + echo "\nDocker compose file $COMPOSE_FILE does not exist." + print_help + exit 1 +fi + + +if [ "$REGISTRY" ] && [[ "$REGISTRY" != */ ]]; then + REGISTRY="$REGISTRY/" +fi + +if [ "$NAMESPACE" ] && [[ "$NAMESPACE" != */ ]]; then + NAMESPACE="$NAMESPACE/" +fi + + +# Build core matrix +MATRIX=$(cat "$COMPOSE_FILE" \ + | docker run -i --rm mikefarah/yq -o=json \ + | jq \ + --arg d "$(dirname $(readlink -f $COMPOSE_FILE))" \ + --arg p "$PLATFORMS" \ + --arg r "$REGISTRY" \ + --arg t "$TAGS" \ + '.services | to_entries | map({ + image: (.value.image), + name: (.value.image | split(":")[0] | split("/")[-1]), + build: ( + if .value.build then { + context: ($d + "/" + .value.build.context), + dockerfile: ($d + "/" + .value.build.context + "/" + .value.build.dockerfile), + target: (.value.build.target), + platforms: [], + tags: [] + } + else + false + end) + })') + +# Add tags in matrix +for t in $(echo $TAGS | tr "," "\n"); do + if [[ "$t" == *"."*"."* ]] && [[ "$RECURSIVE" == "true" ]]; then + MAJOR_VERSION="$(echo $t | cut -d "." -f 1)" + MINOR_VERSION="$(echo $t | cut -d "." -f 2)" + PATCH_VERSION="$(echo $t | cut -d "." -f 3)" + + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg r "$REGISTRY" \ + --arg n "$NAMESPACE" \ + --arg major "$MAJOR_VERSION" \ + --arg minor "$MINOR_VERSION" \ + 'map(. | + if .build != false then + .build.tags += [ + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $major), + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $major + "." + $minor) + ] + else + . + end + )') + fi + + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg t "$t" \ + --arg r "$REGISTRY" \ + --arg n "$NAMESPACE" \ + 'map(. | + if .build != false then + .build.tags += [ + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $t) + ] + else + . + end + )') +done + +# Add platforms in matrix +for p in $(echo $PLATFORMS | tr "," "\n"); do + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg p "$p" \ + 'map(. | + if .build != false then + .build.platforms += [ + ($p) + ] + else + . + end + )') +done + +# Update image key with first tag +MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg t "$TAGS" \ + 'map(. | + if .build != false then + .image = (.image | split(":")[0] | split("/")[-1] + ":" + ($t | split(",")[0])) + else + . + end + )') + +# Convert tags & platforms from json array to csv list (use join instead of @csv to don't get unwanted quotes) +if [ "$CSV" == "true" ]; then + MATRIX=$(echo "$MATRIX" \ + | jq -r \ + 'map(. | + if .build != false then + .build.tags = (.build.tags | join(",")) | + .build.platforms = (.build.platforms | join(",")) + else + . + end + )') +fi + +echo "$MATRIX" | jq . \ No newline at end of file diff --git a/.jjconflict-side-1/ci/scripts/build-tags.sh b/.jjconflict-side-1/ci/scripts/build-tags.sh new file mode 100755 index 0000000000..63f795c710 --- /dev/null +++ b/.jjconflict-side-1/ci/scripts/build-tags.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +set -e + +unset MAJOR_VERSION +unset MINOR_VERSION +unset PATCH_VERSION + +# Declare script helper +TEXT_HELPER="\nThis script aims to build docker tags based on image and set of tags. +Following flags are available: + + -i Image used to create tags. + + -t Docker tag used to build matrix. + Default is '$TAGS'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hi:t: flag; do + case "${flag}" in + i) + IMAGE=${OPTARG};; + t) + TAGS=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ -z "$IMAGE" ] || [ -z "$TAGS" ]; then + echo "\nArguments missing." + print_help + exit 1 +fi + + +for TAG in $(echo $TAGS | tr "," "\n"); do + if [[ "$TAG" == *"."*"."* ]]; then + MAJOR_VERSION="$(echo $TAG | cut -d "." -f 1)" + MINOR_VERSION="$(echo $TAG | cut -d "." -f 1-2)" + if [ -z "$DOCKER_TAGS" ]; then + DOCKER_TAGS="$IMAGE:$MAJOR_VERSION,$IMAGE:$MINOR_VERSION,$IMAGE:$TAG" + else + DOCKER_TAGS="$DOCKER_TAGS,$IMAGE:$TAG,$IMAGE:$MAJOR_VERSION,$IMAGE:$MINOR_VERSION,$IMAGE:$TAG" + fi + else + if [ -z "$DOCKER_TAGS" ]; then + DOCKER_TAGS="$IMAGE:$TAG" + else + DOCKER_TAGS="$DOCKER_TAGS,$IMAGE:$TAG" + fi + fi +done + +echo "$DOCKER_TAGS" diff --git a/.jjconflict-side-1/ci/scripts/cleanup.sh b/.jjconflict-side-1/ci/scripts/cleanup.sh new file mode 100755 index 0000000000..acef1973a0 --- /dev/null +++ b/.jjconflict-side-1/ci/scripts/cleanup.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +cd "$PROJECT_DIR" + + +printf "\n${red}${i}.${no_color} Remove build\n" +i=$(($i + 1)) + +pnpm run build:clean + + +printf "\n${red}${i}.${no_color} Remove node_modules\n" +i=$(($i + 1)) + +while true; do + read -p "Do you want to remove node_modules ('rm -rf $PROJECT_DIR/**/node_modules')? " yn + case $yn in + [Yy]*) + for n in $(find $PROJECT_DIR -depth 3 -type d -name node_modules); do + echo "Delete '$n'" + rm -rf "$n" + done + # find $PROJECT_DIR -type d -name node_modules -delete + break;; + [Nn]*) + echo "\nKeeping node_modules\n" + break;; + *) + echo "\nPlease answer y or n.\n";; + esac +done + + +printf "\n${red}${i}.${no_color} Remove docker stuff\n" +i=$(($i + 1)) + +if docker image inspect dso-console/client:dev > /dev/null 2>&1; then + docker image rm dso-console/client:dev +fi +if docker image inspect dso-console/client:prod > /dev/null 2>&1; then + docker image rm dso-console/client:prod +fi +if docker image inspect dso-console/server:dev > /dev/null 2>&1; then + docker image rm dso-console/server:dev +fi +if docker image inspect dso-console/server:prod > /dev/null 2>&1; then + docker image rm dso-console/server:prod +fi + +if docker volume inspect docker_dso-postgres-data-dev > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-dev +fi +if docker volume inspect docker_dso-postgres-data-integ > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-integ +fi +if docker volume inspect docker_dso-postgres-data-local > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-local +fi diff --git a/.jjconflict-side-1/ci/scripts/delete-image.sh b/.jjconflict-side-1/ci/scripts/delete-image.sh new file mode 100755 index 0000000000..d1e0e70e59 --- /dev/null +++ b/.jjconflict-side-1/ci/scripts/delete-image.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' + +# Get versions +DOCKER_VERSION="$(docker --version)" +DOCKER_BUILDX_VERSION="$(docker buildx version)" + +# Declare script helper +TEXT_HELPER="\nThis script aims to delete an image with all its subsequent images in ghcr. +Following flags are available: + + -g Github token to perform api calls. + + -i Image name used for api calls. + + -o Github organization used for api calls. + + -t Image tage to delete. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hg:i:o:t: flag; do + case "${flag}" in + g) + GITHUB_TOKEN=${OPTARG};; + i) + IMAGE_NAME=${OPTARG};; + o) + ORG=${OPTARG};; + t) + TAG=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +checkDockerRunning () { + if [ ! -x "$(command -v docker)" ]; then + printf "\nThis script uses docker, and it isn't running - please start docker and try again!\n" + exit 1 + fi +} + +checkBuildxPlugin () { + if [ ! "$DOCKER_BUILDX_VERSION" ]; then + printf "\nThis script uses docker buildx plugin, and it isn't installed - please install docker buildx plugin and try again!\n" + exit 1 + fi +} + +# Settings +printf "\nScript settings: + -> docker version: ${DOCKER_VERSION} + -> docker buildx version: ${DOCKER_BUILDX_VERSION}\n" + + +if [ -z "$GITHUB_TOKEN" ] || [ -z "$ORG" ] || [ -z "$IMAGE_NAME" ] || [ -z "$TAG" ]; then + echo "\nYMissing arguments ...\n" + print_help + exit 1 +fi + +IMAGE_NAME_URL_ENCODED="$(jq -rn --arg x ${IMAGE_NAME} '$x | @uri')" +IMAGES=$(curl -s \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions?per_page=100") + +# Delete subsequent images +while read -r SHA; do + IMAGE_ID=$(echo "$IMAGES" | jq -r --arg s "$SHA" '.[] | select(.name==$s) | .id') + + printf "\n${red}[Delete ghcr image].${no_color} Deleting subsequent image '$ORG/$IMAGE_NAME@$SHA'\n" + + curl -s \ + -X DELETE \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions/${IMAGE_ID}" +done <<< "$(docker buildx imagetools inspect ghcr.io/${ORG}/${IMAGE_NAME}:${TAG} --raw | jq -r '.manifests[] | .digest')" + +# Delete main images +while read -r IMAGE_ID; do + + printf "\n${red}[Delete ghcr image].${no_color} Deleting GH package version: ${IMAGE_ID}'\n" + + curl -s \ + -X DELETE \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions/${IMAGE_ID}" +done <<< "$(echo "$IMAGES" | jq -r --arg t "$TAG" '.[] | select(.metadata.container.tags[] | contains($t)) | .id')" diff --git a/.jjconflict-side-1/ci/scripts/init-env.sh b/.jjconflict-side-1/ci/scripts/init-env.sh new file mode 100755 index 0000000000..0c6c5d3feb --- /dev/null +++ b/.jjconflict-side-1/ci/scripts/init-env.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Declare script helper +TEXT_HELPER="\nThis script aims to copy .env-example files into .env files at project initialization. +Following flags are available: + + -h Print script help\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts h flag +do + case "${flag}" in + h | *) + print_help + exit 0;; + esac +done + + +find $PROJECT_DIR -type f -name ".env*-example" -or -name "*-example.yaml" | while read f; do + if [ ! -f "${f/-example/}" ]; then + printf "\n${red}Copy${no_color}: '$f' + ${red}to${no_color}: '${f/-example/}'\n" + cp "$f" "${f/-example/}" + else + printf "\nFile '${f/-example/}' already exists\n" + fi +done diff --git a/.jjconflict-side-1/ci/scripts/run-tests.sh b/.jjconflict-side-1/ci/scripts/run-tests.sh new file mode 100755 index 0000000000..cf18c018fe --- /dev/null +++ b/.jjconflict-side-1/ci/scripts/run-tests.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Get versions +NODE_VERSION="$(node --version)" +NPM_VERSION="$(npm --version)" +DOCKER_VERSION="$(docker --version)" +DOCKER_BUILDX_VERSION="$(docker buildx version)" + +# Default +RUN_LINT="false" +RUN_UNIT_TESTS="false" +RUN_E2E_TESTS="false" +RUN_STATUS_CHECK="false" + +# Declare script helper +TEXT_HELPER="\nThis script aims to run application tests. +Following flags are available: + + -e Run e2e tests + + -l Run lint + + -s Run deployement status check + + -t (Optional) Tag used for docker images in e2e tests + + -u Run unit tests + + -h Print script help\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts heklst:u flag +do + case "${flag}" in + e) + RUN_E2E_TESTS=true;; + l) + RUN_LINT=true;; + s) + RUN_STATUS_CHECK=true;; + t) + TAG=${OPTARG};; + u) + RUN_UNIT_TESTS=true;; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ "$RUN_LINT" == "false" ] && [ "$RUN_UNIT_TESTS" == "false" ] && [ "$RUN_E2E_TESTS" == "false" ] && [ "$RUN_STATUS_CHECK" == "false" ]; then + printf "\nArgument(s) missing, you don't specify any kind of test to run.\n" + print_help + exit 1 +fi + +if [ "$RUN_E2E_TESTS" == "true" ] && [ -z "$TAG" ]; then + printf "\nArgument(s) missing, you don't specify the TAG used to pull docker images for e2e tests.\n" + print_help + exit 1 +fi + +checkDockerRunning () { + if [ ! -x "$(command -v docker)" ]; then + printf "\nThis script uses docker, and it isn't running - please start docker and try again!\n" + exit 1 + fi +} + +checkBuildxPlugin () { + if [ ! "$DOCKER_BUILDX_VERSION" ]; then + printf "\nThis script uses docker buildx plugin, and it isn't installed - please install docker buildx plugin and try again!\n" + exit 1 + fi +} + + +# Settings +printf "\nScript settings: + -> node version: ${NODE_VERSION} + -> npm version: ${NPM_VERSION} + -> docker version: ${DOCKER_VERSION} + -> docker buildx version: ${DOCKER_BUILDX_VERSION} + -> run unit tests: ${RUN_UNIT_TESTS} + -> run e2e tests: ${RUN_E2E_TESTS} + -> run deploy status check: ${RUN_STATUS_CHECK}\n" + + +cd "$PROJECT_DIR" + +# Run lint +if [ "$RUN_LINT" == "true" ]; then + npm run lint +fi + + +# Run unit tests +if [ "$RUN_UNIT_TESTS" == "true" ]; then + npm run test:cov +fi + +# Run e2e tests +if [ "$RUN_E2E_TESTS" == "true" ]; then + checkDockerRunning + + printf "\n${red}${i}.${no_color} Launch e2e tests\n" + i=$(($i + 1)) + + npm --prefix $PROJECT_DIR/packages/shared run build + npm --prefix $PROJECT_DIR/packages/test-utils run build + + if [[ -n "$TAG" ]]; then + docker pull ghcr.io/cloud-pi-native/console/server:$TAG && docker tag ghcr.io/cloud-pi-native/console/server:$TAG dso-console/server:ci + docker pull ghcr.io/cloud-pi-native/console/server-nestjs:$TAG && docker tag ghcr.io/cloud-pi-native/console/server-nestjs:$TAG dso-console/server-nestjs:ci + docker pull ghcr.io/cloud-pi-native/console/client:$TAG && docker tag ghcr.io/cloud-pi-native/console/client:$TAG dso-console/client:ci + docker pull ghcr.io/cloud-pi-native/console/nginx-strangler:$TAG && docker tag ghcr.io/cloud-pi-native/console/client:$TAG dso-console/nginx-strangler:ci + fi + + npm run docker:e2e-ci + + printf "\n${red}${i}.${no_color} Remove resources\n" + i=$(($i + 1)) + + npm run docker:e2e-ci:delete +fi diff --git a/.jjconflict-side-1/ci/scripts/setup.sh b/.jjconflict-side-1/ci/scripts/setup.sh new file mode 100755 index 0000000000..4917b17cf8 --- /dev/null +++ b/.jjconflict-side-1/ci/scripts/setup.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +cd "$PROJECT_DIR" + + +printf "\n${red}${i}.${no_color} Install dependencies\n" +i=$(($i + 1)) + +pnpm install + + +printf "\n${red}${i}.${no_color} Generate Prisma client\n" +i=$(($i + 1)) + +pnpm --filter=server run db:generate + + +printf "\n${red}${i}.${no_color} Build packges\n" +i=$(($i + 1)) + +pnpm run build --force + + +printf "\n${red}${i}.${no_color} Build docker dev images\n" +i=$(($i + 1)) + +pnpm run docker:dev:build + + +printf "\n${red}${i}.${no_color} Build docker prod images\n" +i=$(($i + 1)) + +pnpm run docker:prod:build diff --git a/.jjconflict-side-1/ci/scripts/vuln-report.sh b/.jjconflict-side-1/ci/scripts/vuln-report.sh new file mode 100755 index 0000000000..9bf3f6c169 --- /dev/null +++ b/.jjconflict-side-1/ci/scripts/vuln-report.sh @@ -0,0 +1,244 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + + +# Declare script helper +TEXT_HELPER="\nThis script aims to build a markdown style vulnerability report for the trivy json scan results. It will read the files and then build a markdown summary (compatible with Github issue) summarising the vulnerabilities for each image and configuration file for the given input folder. + +Following flags are available: + + -i Input folder that contain trivy json scan result files into subfolders 'images/' and 'configs/'. + Please provide the path without last slash (i.e not like this 'path/to/folder/'). + + -o Output file for the generated markdown report. + Please provide the path with the markdown file extension (i.e '.md'). + + -p Github project (format as '/'). + It can be set in GitHub Actions as '\${{ github.repository }}'. + + -r Github actions run ID. + It can be set in GitHub Actions as '\${{ github.run_id }}'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hi:o:p:r: flag +do + case "${flag}" in + i) + INPUT="${OPTARG}";; + o) + OUTPUT="${OPTARG}";; + p) + REPO="${OPTARG}";; + r) + RUN_ID="${OPTARG}";; + h | *) + print_help + exit 0;; + esac +done + + +NOW="$(date +'%Y-%m-%d at %H:%M')" +ARTIFACT_DOWNLOAD_URL="https://github.com/${REPO}/actions/runs/${RUN_ID}" + + +if [ -z "$INPUT" ] || [ -z "$OUTPUT" ] || [ -z "$REPO" ] || [ -z "$RUN_ID" ]; then + echo "Argument(s) missing, you should set all paramters." + print_help + exit 0 +fi + + +_jq() { + echo ${1} | base64 --decode | jq -r ${2} +} + + +printf "\n${red}${i}.${no_color} Build vulnerability report\n" +i=$(($i + 1)) + +VULNERABILITY_REPORT_BODY="# Security report + +:robot: This report was generated automatically the $NOW. + +It summarizes the result of a vulnerability scan of Trivy for each docker image and configuration file. +You should take a look at it and possibly modify some dependencies to fix vulnerabilities or update your configuration files." + +#--------------------# +# Images scan report # +#--------------------# +if [[ -n $(find $INPUT/images/*) ]]; then + FORMATED_REPORT=$(jq -s '.' $INPUT/images/* | jq '[ .[] | { + name: .ArtifactName, + type: .ArtifactType, + results: [ + .Results[] | select(.Vulnerabilities != null) | { + class: .Class, + target: .Target, + type: .Type, + length: .Vulnerabilities | (if length == 0 then "-" else length end), + vulnerabilityType: { + critical: [.Vulnerabilities[] | select(.Severity == "CRITICAL")] | (if length == 0 then "-" else length end), + high: [.Vulnerabilities[] | select(.Severity == "HIGH")] | (if length == 0 then "-" else length end), + medium: [.Vulnerabilities[] | select(.Severity == "MEDIUM")] | (if length == 0 then "-" else length end), + low: [.Vulnerabilities[] | select(.Severity == "LOW")] | (if length == 0 then "-" else length end), + unknown: [.Vulnerabilities[] | select(.Severity == "UNKNOWN")] | (if length == 0 then "-" else length end) + } + } + ] + } ]') + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +## Images" + + for row in $(echo "${FORMATED_REPORT}" | jq -r '.[] | @base64'); do + IMAGE_NAME=$(echo "$(_jq "${row}" '.')" | jq -r '.name') + OS_TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .length ] | add') + OS_CRITICAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.critical ] | add') + OS_HIGH_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.high ] | add') + OS_MEDIUM_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.medium ] | add') + OS_LOW_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.low ] | add') + OS_UNKNOWN_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.unknown ] | add') + + if [[ $OS_TOTAL_COUNT = 'null' ]]; then + IMAGE_SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + IMAGE_SCAN_SUMMARY=" +| Critical | High | Medium | Low | Unknown | Total | +|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:| +| $OS_CRITICAL_COUNT | $OS_HIGH_COUNT | $OS_MEDIUM_COUNT | $OS_LOW_COUNT | $OS_UNKNOWN_COUNT | $OS_TOTAL_COUNT| +" + fi + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +### \`$IMAGE_NAME\` + +**OS Packages** + +$IMAGE_SCAN_SUMMARY" + + DEP_TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "lang-pkgs") | .length ] | add') + if [[ $DEP_TOTAL_COUNT = 'null' ]]; then + SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + SCAN_SUMMARY=" +| Target | Critical | High | Medium | Low | Unknown | Total | +|:-----------|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:|" + + for row2 in $(echo "$(_jq ${row} '.')" | jq -r '.results[] | select(.class == "lang-pkgs") | @base64'); do + FULL_NAME=$(echo "$(_jq ${row2} '.')" | jq -r '.target') + TOTAL_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '.length') + CRITICAL_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.critical ] | add') + HIGH_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.high ] | add') + MEDIUM_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.medium ] | add') + LOW_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.low ] | add') + UNKNOWN_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.unknown ] | add') + + SCAN_SUMMARY="$SCAN_SUMMARY +| $FULL_NAME | $CRITICAL_COUNT | $HIGH_COUNT | $MEDIUM_COUNT | $LOW_COUNT | $UNKNOWN_COUNT | $TOTAL_COUNT |" + + done + fi + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY +**Language-specific packages** + +$SCAN_SUMMARY" + done + + SCAN_IMAGES=true +fi + +#--------------------# +# Config scan report # +#--------------------# +if [[ -n $(find $INPUT/configs/*) ]]; then + FORMATED_REPORT=$(jq -s '.' $INPUT/configs/* | jq '.[] | { + name: .ArtifactName, + type: .ArtifactType, + results: [ + .Results[] | select(.Misconfigurations != null) | { + class: .Class, + target: .Target, + type: .Type, + length: .Misconfigurations | (if length == 0 then "-" else length end), + vulnerabilityType: { + critical: [.Misconfigurations[] | select(.Severity == "CRITICAL")] | (if length == 0 then "-" else length end), + high: [.Misconfigurations[] | select(.Severity == "HIGH")] | (if length == 0 then "-" else length end), + medium: [.Misconfigurations[] | select(.Severity == "MEDIUM")] | (if length == 0 then "-" else length end), + low: [.Misconfigurations[] | select(.Severity == "LOW")] | (if length == 0 then "-" else length end), + unknown: [.Misconfigurations[] | select(.Severity == "UNKNOWN")] | (if length == 0 then "-" else length end) + } + } + ] + }') + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +## Configs" + + TOTAL_CONFIG=$(echo $FORMATED_REPORT | jq -r '[ .results[].length ] | add') + if [[ $TOTAL_CONFIG = 'null' ]]; then + SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + SCAN_SUMMARY=" +| Target | Critical | High | Medium | Low | Unknown | Total | +|:-----------|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:|" + fi + + for row in $(echo "${FORMATED_REPORT}" | jq -r '.results[] | @base64'); do + FULL_NAME=$(echo "$(_jq "${row}" '.')" | jq -r '.target') + TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '.length') + CRITICAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.critical ] | add') + HIGH_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.high ] | add') + MEDIUM_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.medium ] | add') + LOW_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.low ] | add') + UNKNOWN_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.unknown ] | add') + + SCAN_SUMMARY="$SCAN_SUMMARY +| $FULL_NAME | $CRITICAL_COUNT | $HIGH_COUNT | $MEDIUM_COUNT | $LOW_COUNT | $UNKNOWN_COUNT | $TOTAL_COUNT|" + + done + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY +$SCAN_SUMMARY" + + SCAN_CONFIG=true +fi + +if [ "$SCAN_DEPENDENCIES" = true ] || [ "$SCAN_CONFIG" = true ] || [ "$SCAN_IMAGES" = true ]; then + printf "\n${red}${i}.${no_color} Add artifacts download url to the vulnerability report body\n" + i=$(($i + 1)) + + VULNERABILITY_REPORT_BODY=$(cat < "$OUTPUT" +fi diff --git a/.jjconflict-side-1/commitlint.config.cjs b/.jjconflict-side-1/commitlint.config.cjs new file mode 100644 index 0000000000..070c7b1c34 --- /dev/null +++ b/.jjconflict-side-1/commitlint.config.cjs @@ -0,0 +1,9 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'body-leading-blank': [ + 2, // error + 'always', + ], + }, +}; diff --git a/.jjconflict-side-1/docker/docker-compose.ci.yml b/.jjconflict-side-1/docker/docker-compose.ci.yml new file mode 100644 index 0000000000..b8358950ea --- /dev/null +++ b/.jjconflict-side-1/docker/docker-compose.ci.yml @@ -0,0 +1,151 @@ +services: + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + ports: + - 8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - /opt/bitnami/keycloak/data/h2 + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + ports: + - 8081:80 + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: prod + image: dso-console/server:ci + container_name: dso-console_server + user: root + depends_on: + postgres: + condition: service_started + keycloak: + condition: service_healthy + volumes: + - ../packages/test-utils/src/imports/data.ts:/app/dist/init/db/imports/data.js + env_file: + - ../apps/server/.env.docker + environment: + NODE_ENV: production + CI: "true" + DEV_SETUP: "true" + networks: + - dso-network + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: prod + image: dso-console/server-nestjs:ci + container_name: dso-console_server-nestjs + depends_on: + postgres: + condition: service_started + keycloak: + condition: service_healthy + opencds-mockoon: + condition: service_healthy + env_file: + - ../apps/server-nestjs/.env.docker + environment: + NODE_ENV: "production" + CI: "true" + DEV_SETUP: "true" + OPENCDS_URL: "http://dso-opencds_mockoon:3100/api/v1" + OPENCDS_API_TOKEN: "token" + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: "false" + networks: + - dso-network + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:ci + container_name: dso-console_nginx-strangler + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + + client: + build: + context: .. + dockerfile: apps/client/Dockerfile + target: prod + image: dso-console/client:ci + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + user: root + volumes: + - ../apps/client/nginx/default.docker.conf:/etc/nginx/conf.d/default.conf + env_file: + - ../apps/client/.env.docker + environment: + OPENCDS_ENABLED: "true" + networks: + - dso-network + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:ci + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + name: dso-network + +volumes: + dso-keycloak-dsfr-theme: diff --git a/.jjconflict-side-1/docker/docker-compose.dev.yml b/.jjconflict-side-1/docker/docker-compose.dev.yml new file mode 100644 index 0000000000..98f2a31933 --- /dev/null +++ b/.jjconflict-side-1/docker/docker-compose.dev.yml @@ -0,0 +1,240 @@ +services: + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + ports: + - 8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - ../keycloak/data/dev:/opt/bitnami/keycloak/data/h2 + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-dev:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + - postgres + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-dev:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: dev + image: dso-console/server:dev + container_name: dso-console_server + command: [dev] + depends_on: + keycloak: + condition: service_healthy + postgres: + condition: service_started + ports: + - 4001:8080 + - 9229:9229 + env_file: + - ../apps/server/.env.docker + develop: + watch: + - action: sync + path: ../apps/server + target: /app/apps/server + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server/package.json + networks: + - dso-network + attach: true + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: dev + image: dso-console/server-nestjs:dev + container_name: dso-console_server-nestjs + command: [start:dev] + ports: + - 3001:3001 + depends_on: + keycloak: + condition: service_healthy + postgres: + condition: service_started + opencds-mockoon: + condition: service_healthy + env_file: + - ../apps/server-nestjs/.env.docker + develop: + watch: + - action: sync + path: ../apps/server-nestjs + target: /app/apps/server-nestjs + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - dist/ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server-nestjs/package.json + networks: + - dso-network + attach: true + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:dev + container_name: dso-console_nginx-strangler + ports: + - 4000:8080 + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + attach: false + + client: + restart: unless-stopped + build: + context: .. + dockerfile: apps/client/Dockerfile + target: dev + image: dso-console/client:dev + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + env_file: + - ../apps/client/.env.docker + develop: + watch: + - action: sync + path: ../apps/client + target: /app/apps/client + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: rebuild + path: ../apps/client/package.json + networks: + - dso-network + attach: true + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:dev + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + driver: bridge + +volumes: + dso-keycloak-dsfr-theme: + dso-pgadmin-data-dev: + dso-postgres-data-dev: diff --git a/.jjconflict-side-1/docker/docker-compose.integ.yml b/.jjconflict-side-1/docker/docker-compose.integ.yml new file mode 100644 index 0000000000..385f5e85e7 --- /dev/null +++ b/.jjconflict-side-1/docker/docker-compose.integ.yml @@ -0,0 +1,220 @@ +services: + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-integ:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + postgres: + condition: service_started + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-integ:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: dev + image: dso-console/server:integ + container_name: dso-console_server + command: [dev] + depends_on: + postgres: + condition: service_started + ports: + - 4001:8080 + - 9229:9229 + volumes: + - ${KUBECONFIG_HOST_PATH}:${KUBECONFIG_PATH:-/home/node/.kube/config} + - ${EXTERNAL_PLUGINS_DIR_HOST_PATH}:/plugins + env_file: + - ../apps/server/.env.docker + - ../apps/server/.env.integ + develop: + watch: + - action: sync + path: ../apps/server + target: /app/apps/server + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server/package.json + networks: + - dso-network + attach: true + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: dev + image: dso-console/server-nestjs:integ + container_name: dso-console_server-nestjs + command: [start:dev] + depends_on: + postgres: + condition: service_started + opencds-mockoon: + condition: service_healthy + ports: + - 3001:3001 + volumes: + - ${KUBECONFIG_HOST_PATH}:${KUBECONFIG_PATH:-/home/node/.kube/config} + - ${EXTERNAL_PLUGINS_DIR_HOST_PATH}:/plugins + env_file: + - ../apps/server-nestjs/.env.docker + - ../apps/server-nestjs/.env.integ + develop: + watch: + - action: sync + path: ../apps/server-nestjs + target: /app/apps/server-nestjs + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - dist/ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server-nestjs/package.json + networks: + - dso-network + attach: true + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:integ + container_name: dso-console_nginx-strangler + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + ports: + - 4000:8080 + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + attach: false + + client: + restart: unless-stopped + build: + context: .. + dockerfile: apps/client/Dockerfile + target: dev + image: dso-console/client:integ + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + env_file: + - ../apps/client/.env.docker + - ../apps/client/.env.integ + develop: + watch: + - action: sync + path: ../apps/client + target: /app/apps/client + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: rebuild + path: ../apps/client/package.json + networks: + - dso-network + attach: true + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:integ + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + driver: bridge + +volumes: + dso-pgadmin-data-integ: + dso-postgres-data-integ: diff --git a/.jjconflict-side-1/docker/docker-compose.local.yml b/.jjconflict-side-1/docker/docker-compose.local.yml new file mode 100644 index 0000000000..80473f1823 --- /dev/null +++ b/.jjconflict-side-1/docker/docker-compose.local.yml @@ -0,0 +1,146 @@ +services: + keycloak-fetch-theme: + restart: no + image: docker.io/curlimages/curl:8.8.0 + container_name: dso-console_keycloak-fetch + volumes: + - dso-keycloak-dsfr-theme:/tmp:rw + command: -LfS -o /tmp/keycloak-theme-dsfr.jar https://github.com/codegouvfr/keycloak-theme-dsfr/releases/download/v2.3.3/keycloak-theme-for-kc-26.0-to-26.1.jar + networks: + - dso-network + attach: false + + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + depends_on: + keycloak-fetch-theme: + condition: service_completed_successfully + ports: + - 127.0.0.1:8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - ../keycloak/data/dev:/opt/bitnami/keycloak/data/h2 + - type: volume + source: dso-keycloak-dsfr-theme + target: /opt/bitnami/keycloak/providers/keycloak-theme-dsfr.jar + volume: + subpath: keycloak-theme-dsfr.jar + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + DSFR_THEME_HOME_URL: http://localhost:8080 + DSFR_THEME_SERVICE_TITLE: Console Cloud π Native + DSFR_THEME_BRAND_TOP: Ministère
de l'Intérieur
et des Outre-Mer + DSFR_THEME_TOS_URL: + DSFR_THEME_CONTACT_EMAIL: cloudpinative-relations@interieur.gouv.fr + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-local:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:local + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + postgres: + condition: service_started + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-local:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + nginx-strangler: + # En mode local (pnpm dev), server et server-nestjs tournent nativement sur la machine. + # Le nginx-strangler reste en Docker et atteint les processes natifs via host.docker.internal. + # (voir .env-example de chaque app). + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:local + container_name: dso-console_nginx-strangler + extra_hosts: + - host.docker.internal:${HOST_GATEWAY_IP:-host-gateway} + ports: + - 4000:8080 + environment: + LEGACY_UPSTREAM: "host.docker.internal:4001" + NESTJS_UPSTREAM: "host.docker.internal:3001" + networks: + - dso-network + attach: false + + jaeger: + restart: unless-stopped + image: jaegertracing/all-in-one:1.76.0 + container_name: dso-console_jaeger + environment: + COLLECTOR_ZIPKIN_HOST_PORT: :9411 + ports: + - 16686:16686 + - 4317:4317 + - 4318:4318 + - 14250:14250 + - 14268:14268 + - 14269:14269 + - 9411:9411 + networks: + - dso-network + attach: false + +networks: + dso-network: + driver: bridge + +volumes: + dso-keycloak-dsfr-theme: + dso-keycloak-data-local: + dso-pgadmin-data-local: + dso-postgres-data-local: diff --git a/.jjconflict-side-1/eslint.config.js b/.jjconflict-side-1/eslint.config.js new file mode 100644 index 0000000000..50992e5703 --- /dev/null +++ b/.jjconflict-side-1/eslint.config.js @@ -0,0 +1,9 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase.append({ + ignores: [ + 'apps/', + 'packages/', + 'plugins/', + ], +}) diff --git a/.jjconflict-side-1/keycloak/mock-sso.md b/.jjconflict-side-1/keycloak/mock-sso.md new file mode 100644 index 0000000000..cd9b5c2982 --- /dev/null +++ b/.jjconflict-side-1/keycloak/mock-sso.md @@ -0,0 +1,34 @@ +# Keycloak + +## Development + +### Start only keycloak + +Go at the root level of the git project : + +```sh +# Go at the root level of the git project +cd `git rev-parse --show-toplevel` + +# Start keycloak +docker compose -f ./docker/docker-compose.dev.yml up keycloak +``` + +Admin page url: + +__*Login:*__ `admin` +__*Password:*__ `admin` + +## Docs + +### Get Started + + + +### Other doc + +* [Keycloak](https://github.com/keycloak/keycloak) - Keycloak Server and Java adapters +* [Keycloak Documentation](https://github.com/keycloak/keycloak-documentation) - Documentation for Keycloak +* [Keycloak QuickStarts](https://github.com/keycloak/keycloak-quickstarts) - QuickStarts for getting started with Keycloak +* [Keycloak Node.js Connect](https://github.com/keycloak/keycloak-nodejs-connect) - Node.js adapter for Keycloak +* [Keycloak Node.js Admin Client](https://github.com/keycloak/keycloak-nodejs-admin-client) - Node.js library for Keycloak Admin REST API diff --git a/.jjconflict-side-1/keycloak/realms/realm-dev.json b/.jjconflict-side-1/keycloak/realms/realm-dev.json new file mode 100644 index 0000000000..f7e932d8c0 --- /dev/null +++ b/.jjconflict-side-1/keycloak/realms/realm-dev.json @@ -0,0 +1,577 @@ +{ + "realm": "dso", + "enabled": true, + "accessTokenLifespan": 300, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "ssoSessionIdleTimeout": 600, + "ssoSessionMaxLifespan": 36000, + "sslRequired": "none", + "registrationAllowed": false, + "privateKey": "MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=", + "publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "requiredCredentials": ["password"], + "attributes": { + "frontendUrl": "http://localhost:8090" + }, + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "groups": [ + { + "id": "07169ee1-d967-48da-a784-1b1ccbdaea7a", + "name": "admin", + "path": "/admin", + "attributes": {}, + "realmRoles": [], + "clientRoles": {}, + "subGroups": [] + } + ], + "users": [ + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6565", + "username": "test", + "enabled": true, + "email": "test@test.com", + "emailVerified": true, + "firstName": "Jean", + "lastName": "DUPOND", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6566", + "username": "tcolin", + "enabled": true, + "email": "thibault.colin@test.com", + "emailVerified": true, + "firstName": "Thibault", + "lastName": "COLIN", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + }, + "groups": ["admin"] + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6567", + "username": "cnollet", + "enabled": true, + "email": "claire.nollet@test.com", + "emailVerified": true, + "firstName": "Claire", + "lastName": "NOLLET", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "58e13168-d533-446b-b6a1-936da0a9d7b7", + "username": "lpoumailloux", + "enabled": true, + "email": "leo.poumailloux@test.com", + "emailVerified": true, + "firstName": "Léo", + "lastName": "POUMAILLOUX", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6568", + "username": "btran", + "enabled": true, + "email": "baudoin.tran@test.com", + "emailVerified": true, + "firstName": "Baudoin", + "lastName": "TRAN", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6569", + "username": "atardif", + "enabled": true, + "email": "arnaud.tardif@test.com", + "emailVerified": true, + "firstName": "Arnaud", + "lastName": "TARDIF", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "387216f1-3b87-4211-9cac-4371125e1175", + "username": "admin", + "enabled": true, + "email": "admin@test.com", + "emailVerified": true, + "firstName": "Admin", + "lastName": "ADMIN", + "credentials": [ + { + "type": "password", + "value": "admin" + } + ], + "realmRoles": ["user", "admin"], + "clientRoles": { + "realm-management": ["realm-admin"], + "account": ["manage-account"] + }, + "groups": ["admin"] + } + ], + "roles": { + "realm": [ + { + "name": "user", + "description": "User privileges" + }, + { + "name": "admin", + "description": "Administrator privileges" + } + ] + }, + "clients": [ + { + "id": "e7d105eb-28d4-4557-a274-eb354a60a33b", + "clientId": "dso-console-frontend", + "name": "dso-console-frontend", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["*"], + "webOrigins": ["*"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "id.token.as.detached.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "require.pushed.authorization.requests": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": ["generic", "basic"], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "access": { + "view": true, + "configure": true, + "manage": true + } + }, + { + "id": "e7d105eb-28d4-4557-a274-eb354a60a33c", + "clientId": "dso-console-backend", + "name": "dso-console-backend", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "client-secret-backend", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "id.token.as.detached.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "require.pushed.authorization.requests": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": ["generic"], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "access": { + "view": true, + "configure": true, + "manage": true + } + } + ], + "clientScopes": [ + { + "id": "41b313bd-c5dc-448f-8056-007d25e42027", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "a067866c-7812-47c6-aa28-9e3421fd673b", + "name": "basic", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "910359ad-0368-44dd-b10c-d1cae6c6dd17", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "1611458d-38ab-4a9f-859e-1812d251c1ac", + "name": "generic", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "e0491f43-3e4c-4649-b16e-3c0e47bbc2c0", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "000f3a6c-bec3-40dd-8397-0c45019df7e0", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "2acd698c-03d2-4d0b-99a4-db00ed08f25d", + "name": "name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "13e8d53a-415f-4f98-a133-8795e0173efd", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "5a8652b6-9a42-48e5-819f-59259c349883", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "9d090281-1d17-4283-8fb7-183e1b536328", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "79bccac1-d0f1-4c28-a040-e0e640889e52", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "5ca2ebe3-ef32-4f62-bcad-e764c70d20e6", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "7d976f67-441d-4491-a1fe-f17343a03120", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "bb8a7b6e-2f1a-46b2-91e1-8d027dabe93d", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "84629e2c-c129-4d49-bb04-a03a5124ed47", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "f5c4b940-47ad-4734-8712-4f53357cff1c", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "e1e82435-1b52-4b41-aa0e-14a3e9779670", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "userinfo.token.claim": "true" + } + }, + { + "id": "c3803e56-b542-45e3-a780-0fb70841dd5c", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + } + ], + "loginWithEmailAllowed": true, + "registrationEmailAsUsername": false, + "loginTheme": "DSFR", + "accountTheme": "DSFR", + "adminTheme": "DSFR", + "emailTheme": "DSFR", + "internationalizationEnabled": true, + "supportedLocales": ["en", "fr"], + "defaultLocale": "fr" +} diff --git a/.jjconflict-side-1/migrations/v9/Dockerfile b/.jjconflict-side-1/migrations/v9/Dockerfile new file mode 100644 index 0000000000..5365c15bc1 --- /dev/null +++ b/.jjconflict-side-1/migrations/v9/Dockerfile @@ -0,0 +1,7 @@ +FROM docker.io/node:22.14.0-bullseye-slim +WORKDIR /app +COPY ./package.json package.json +RUN npm i +COPY ./migrate.js migrate.js +CMD [ "npm", "start" ] +USER node diff --git a/.jjconflict-side-1/migrations/v9/Migrate v9-FR.md b/.jjconflict-side-1/migrations/v9/Migrate v9-FR.md new file mode 100644 index 0000000000..f27cb4619f --- /dev/null +++ b/.jjconflict-side-1/migrations/v9/Migrate v9-FR.md @@ -0,0 +1,42 @@ +# Migration vers la v9 +## Qu'est-ce qui change ? +Nous avons pris la décision d'abandonner la fonctionnalité d'organisation, dès lors le projet ne sera plus rattaché à rien. Cela entraîne quelques problèmes de suivi des ressources, mais si vous suivez les instructions ci-dessous, tout ira bien. + +Les organisations reviendront à l'avenir, mais sous une forme complètement différente. + +## IMPORTANT +Sauvegardez votre instance gitlab et le kv Vault !!! + +## Préparation des ressources +Depuis la version 8.22.0, le serveur api stocke son numéro de version pour chaque projet provisionné avec succès. + +Avant de passer à la version 9, vous DEVEZ provisionner tous les projets actifs au moins à la version 8.23.0. Si vous ne le faites pas, le script de migration SQL lèvera une exception. + +Un bon suivi des ressources est essentiel. Si un projet ne peut pas être provisionné avec succès, vous pouvez toujours changer sa valeur dans la base de données directement, mais assurez-vous que les balises suivantes sont appliquées sur les ressources kubernetes correspondantes : +- dso/project.id : (namespace, applications, appprojects) +- dso/project.slug : (namespace, applications, appprojects) +- dso/environment.id : (namespace, applications, appprojects) +- dso/repository.id : (namespace, applications) + +## Migration des ressources utilisateurs +Vous pouvez lancer cette image (une seule fois) avant ou après la mise à jour vers la v9. + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: migrate-v9 +spec: + containers: + - image: ghcr.io/cloud-pi-native/migration:v9 + name: migrate-v9 + resources: {} + envFrom: + - secretRef: + name: dso-config + restartPolicy: Never +``` +kubectl run --image docker ghcr.io/cloud-pi-native/migration:v9 + +### À propos de Gitlab +Parce que gitlab effectue de nombreuses tâches de manière asynchrone, nous ne pouvons pas supprimer les anciens groupes d'organisation après avoir transféré les groupes de projet. Vous devrez vous connecter manuellement pour les rechercher et les supprimer. Si vous ne le faites pas, ce n'est pas grave... diff --git a/.jjconflict-side-1/migrations/v9/Migrate v9.md b/.jjconflict-side-1/migrations/v9/Migrate v9.md new file mode 100644 index 0000000000..9b7e94bacd --- /dev/null +++ b/.jjconflict-side-1/migrations/v9/Migrate v9.md @@ -0,0 +1,42 @@ +# Migrating to v9 +## What's changing? +We've taken the decision to abandon the organization feature, so the project won't be attached to anything. This is causing some resource tracking problems, but if you follow the instructions below, you'll be fine. + +Organizations will return in future, but in a completely different form. + +## IMPORTANT +Backup your gitlab instance and the vault KV !!! + +## Resource preparation +Since version 8.22.0, the api server stores its version number for each successfully provisioned project. + +Before upgrading to version 9, you MUST provision all active projects to at least version 8.23.0. If you fail to do so, the SQL migration script will throw an exception. + +Good resource tracking is essential. If a project cannot be provisioned successfully, you can always change its value in the database directly, but make sure the following tags are applied on the corresponding kubernetes resources: +- dso/project.id : (namespace, applications, appprojects) +- dso/project.slug : (namespace, applications, appprojects) +- dso/environment.id : (namespace, applications, appprojects) +- dso/repository.id : (namespace, applications) + +## Migrate user resources +You can run this image (once only) before or after upgrading to v9 + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: migrate-v9 +spec: + containers: + - image: ghcr.io/cloud-pi-native/migration:v9 + name: migrate-v9 + resources: {} + envFrom: + - secretRef: + name: dso-config + restartPolicy: Never +``` +kubectl run --image docker ghcr.io/cloud-pi-native/migration:v9 + +### About Gitlab +Because gitlab performs many tasks asynchronously, we can't delete the old organization groups after transferring the project groups. You'll have to log in manually to search for and delete them. If you don't, it's not a big deal... diff --git a/.jjconflict-side-1/migrations/v9/migrate.js b/.jjconflict-side-1/migrations/v9/migrate.js new file mode 100644 index 0000000000..ed8d048084 --- /dev/null +++ b/.jjconflict-side-1/migrations/v9/migrate.js @@ -0,0 +1,142 @@ +import { logger as baseLogger } from '@cpn-console/logger/hooks' +import { Gitlab } from '@gitbeaker/rest' +import axios from 'axios' + +const logger = baseLogger.child({ scope: 'migration:v9' }) + +export function removeTrailingSlash(url) { + return url?.endsWith('/') + ? url.slice(0, -1) + : url +} + +export function requiredEnv(envName) { + const envValue = process.env[envName] + if (envValue) return envValue + + throw new Error(`env: ${envName} is not defined !`) +} + +const gitlabToken = requiredEnv('GITLAB_TOKEN') +const gitlabPublicUrl = removeTrailingSlash(requiredEnv('GITLAB_URL')) +const projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') +const gitlabInternalUrl = process.env.GITLAB_INTERNAL_URL + ? removeTrailingSlash(process.env.GITLAB_INTERNAL_URL) + : gitlabPublicUrl + +const vaultPublicUrl = removeTrailingSlash(requiredEnv('VAULT_URL')) + +const axiosInstance = axios.create({ + baseURL: vaultPublicUrl, + headers: { + 'X-Vault-Token': requiredEnv('VAULT_TOKEN'), + }, +}) + +const api = new Gitlab({ token: gitlabToken, host: gitlabInternalUrl }) + +const groupRootSearch = await api.Groups.search(projectsRootDir) +const groupRootId = (groupRootSearch.find(grp => grp.full_path === projectsRootDir))?.id + +const organizationGroups = await api.Groups.allDescendantGroups(groupRootId, { perPage: 300 }) + +logger.info({ organizationGroupsCount: organizationGroups.length }, 'Loaded organization groups') +if (organizationGroups.length > 300) { + throw new Error('increase perPage, you could miss some results') +} + +for (const organizationGroup of organizationGroups) { + if (organizationGroup.name === 'Infra') continue + logger.info({ organizationGroupId: organizationGroup.id, organizationGroupName: organizationGroup.name }, 'Processing organization group') + const projectGroups = await api.Groups.allDescendantGroups(organizationGroup.id, { perPage: 300 }) + if (projectGroups.length > 300) { + throw new Error('increase perPage, you could miss some projects group results') + } + + for (const projectGroup of projectGroups) { + const newName = `${organizationGroup.name}-${projectGroup.name}` + logger.info({ projectGroupId: projectGroup.id, newName }, 'Renaming and transferring project group') + + try { + const renamedGroup = await api.Groups.edit(projectGroup.id, { name: newName, path: newName }) + await api.Groups.transfer(renamedGroup.id, { groupId: groupRootId }) + } catch (error) { + logger.warn({ err: error, projectGroupId: projectGroup.id, organizationGroupId: organizationGroup.id }, 'Could not rename/transfer project group') + } + } +} + +const coreKvName = 'forge-dso' + +const vaultToken = (await axiosInstance.post('/v1/auth/token/create')) + .data.auth.client_token + +function transformPath(path) { + if (!path.startsWith('/')) { + path = `/${path}` + } + const parts = path.split('/') + return `/${parts[1]}-${parts[2]}/${parts.slice(3).join('/')}` +} +const secretsMapper = {} + +async function list(path = '/') { + if (!path.startsWith('/')) + path = `/${path}` + const response = await axiosInstance({ + url: `/v1/${coreKvName}/metadata/${projectsRootDir}${path}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + method: 'list', + validateStatus: code => [200, 404].includes(code), + }) + + if (response.status === 404) return [] + for (const key of response.data.data.keys) { + if (key.endsWith('/')) { + await list(`${path}${key}`) + } else { + secretsMapper[`${path}${key}`] = transformPath(`${path}${key}`) + } + } +} + +try { + await list() + logger.info({ secretsCount: Object.keys(secretsMapper).length }, 'Collected secrets mapping') +} catch (error) { + logger.error({ err: error }, 'Failed while collecting secrets mapping') +} + +for (const [source, destination] of Object.entries(secretsMapper)) { + const secretContent = await axiosInstance({ + url: `/v1/${coreKvName}/data/${projectsRootDir}${source}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + method: 'get', + validateStatus: code => [200, 404].includes(code), + }) + const data = secretContent.data.data.data + + try { + await axiosInstance({ + method: 'POST', + url: `/v1/${coreKvName}/data/${projectsRootDir}${destination}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + data: { data }, + }) + await axiosInstance({ + method: 'delete', + url: `/v1/${coreKvName}/metadata/${projectsRootDir}${source}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + }) + } catch (error) { + logger.error({ err: error, source, destination }, 'Failed while moving secret') + } +} diff --git a/.jjconflict-side-1/migrations/v9/package.json b/.jjconflict-side-1/migrations/v9/package.json new file mode 100644 index 0000000000..c49c17280c --- /dev/null +++ b/.jjconflict-side-1/migrations/v9/package.json @@ -0,0 +1,15 @@ +{ + "name": "dso-v9-migration", + "version": "1.0.0", + "description": "", + "author": "Cloud Pi Native", + "license": "ISC", + "main": "index.js", + "scripts": { + "start": "node migrate.js" + }, + "dependencies": { + "@gitbeaker/rest": "catalog:runtime", + "axios": "catalog:runtime" + } +} \ No newline at end of file diff --git a/.jjconflict-side-1/misc/business-rules.md b/.jjconflict-side-1/misc/business-rules.md new file mode 100644 index 0000000000..04f1a4fee9 --- /dev/null +++ b/.jjconflict-side-1/misc/business-rules.md @@ -0,0 +1,63 @@ +# Console Cloud π Native + +## Règles métier + +### Formulaire de commande d'un espace projet + +| Champ | Type | Description | +| --------------------- | ------ | ------------------------------------- | +| Nom de l'organisation | string | obligatoire | +| Nom du projet | string | obligatoire, `regex([a-z0-9-]{2,53})` | + +### Formulaire de synchronisation d'un dépôt + +| Champ | Type | Description | +| ----------------------------------------------- | ------- | ----------------------------------------- | +| Nom du dépôt Git interne | string | obligatoire, `regex([a-z0-9-]{2,53})` | +| Url du dépôt Git externe | string | obligatoire, `regex(^https://)` | +| Dépôt externe privé | boolean | obligatoire | +| Nom d'utilisateur lié au token du dépôt externe | string | obligatoire si le dépôt externe est privé | +| Token d'accès au dépôt externe | string | obligatoire si le dépôt externe est privé | +| Dépôt d'infrastructure | boolean | obligatoire | + +### Status et verrouillage + +#### Schema + +- un `project` peut avoir pour `status: Enum('initializing', 'created', 'failed', 'archived')` +- un `repository` et un `environnement` peuvent avoir pour `status: Enum('initializing', 'created', 'failed', 'deleting')` +- un `project` peut être `locked (boolean)` + +#### Principe + +- `initializing` : la ressource est en cours de création, des opérations sont en cours côté `plugins` - le `project` est `locked: true` +- `created` : la ressource est créée, les opérations des `plugins` se sont bien déroulées - le `project` est `locked: false` +- `failed` : la ressource est créée, les opérations des `plugins` ont échoué - le `project` est `locked: true` +- `deleting` : la ressource est en cours de suppression, des opérations sont en cours côté `plugins` - le `project` est `locked: true` +- `archived` : les opérations des `plugins` se sont bien déroulées, le projet et son équipe sont toujours présents en base de données pour archive, mais invisibles des utilisateurs hors `admin` - le `project` est `locked: true` +- `project.locked: true` : toute opération de Create / Update sur le projet et ses ressource est interdite. + +### Permissions d'environnement + +Trois niveaux de permissions différents, +| Valeur en base | Description dans le front | Niveaux | +| -------------- | ------------------------- | ------------- | +| 0 | r | Lecture | +| 1 | rw | + Ecriture | +| 2 | rwd | + Suppression | + +## Points d'API + +voir swagger : + +--- + +## TODO: Idées + +### Admin + +- Lister chacune des tables +- Mise en place d'une table d'historisation des status des projets +- Mettre en place un point d'api pour gérer l'équipe d'admin (création/suppression) +- Possibilité de réexecuter dans keycloak les droits présents dans la DB (synchro DB - keycloak) +- Route qui récapitule l'ensemble des ressources argo et supprime toutes les autres diff --git a/.jjconflict-side-1/misc/controller-plugins-communication.png b/.jjconflict-side-1/misc/controller-plugins-communication.png new file mode 100644 index 0000000000..b446155dc5 Binary files /dev/null and b/.jjconflict-side-1/misc/controller-plugins-communication.png differ diff --git a/.jjconflict-side-1/misc/controllers.md b/.jjconflict-side-1/misc/controllers.md new file mode 100644 index 0000000000..d194fec2fd --- /dev/null +++ b/.jjconflict-side-1/misc/controllers.md @@ -0,0 +1,29 @@ +# Logique controllers + +```mermaid +flowchart TD + A[(db: find prérequis)] + A -->|KO| B>send code 400, 401, 403, 404] + A -->|try| D(db: update projectLocked = true + status = initializing + upsert/delete + hook: plugins) + D -->|catch| E[(db: update status = failed)] + E --> B + D -->|OK| G[(db: update projectLocked = false + status = created)] + G -->|OK| H>send code 200, 201] + G -->|catch| E +``` + +## Principe + +### Controller + +- reçoit la requête +- appelle la couche business +- renvoie la réponse + +### Business + +- est appelé par le controller +- appelle l'ORM pour les opérations en bdd +- appelle éventuellement les plugins +- gère les erreurs (try catch) +- renvoie un résultat ou une erreur au controller diff --git a/.jjconflict-side-1/misc/images/environments.png b/.jjconflict-side-1/misc/images/environments.png new file mode 100644 index 0000000000..2c9f47a130 Binary files /dev/null and b/.jjconflict-side-1/misc/images/environments.png differ diff --git a/.jjconflict-side-1/misc/images/members.png b/.jjconflict-side-1/misc/images/members.png new file mode 100644 index 0000000000..bcd9955ba6 Binary files /dev/null and b/.jjconflict-side-1/misc/images/members.png differ diff --git a/.jjconflict-side-1/misc/images/projects.png b/.jjconflict-side-1/misc/images/projects.png new file mode 100644 index 0000000000..de48c1d125 Binary files /dev/null and b/.jjconflict-side-1/misc/images/projects.png differ diff --git a/.jjconflict-side-1/misc/images/repositories.png b/.jjconflict-side-1/misc/images/repositories.png new file mode 100644 index 0000000000..b1029c947d Binary files /dev/null and b/.jjconflict-side-1/misc/images/repositories.png differ diff --git a/.jjconflict-side-1/misc/images/services.png b/.jjconflict-side-1/misc/images/services.png new file mode 100644 index 0000000000..4b346ebf31 Binary files /dev/null and b/.jjconflict-side-1/misc/images/services.png differ diff --git a/.jjconflict-side-1/misc/images/status.png b/.jjconflict-side-1/misc/images/status.png new file mode 100644 index 0000000000..472e57d330 Binary files /dev/null and b/.jjconflict-side-1/misc/images/status.png differ diff --git a/.jjconflict-side-1/misc/plugins.md b/.jjconflict-side-1/misc/plugins.md new file mode 100644 index 0000000000..b062c3285d --- /dev/null +++ b/.jjconflict-side-1/misc/plugins.md @@ -0,0 +1,67 @@ +# Plugins + +## Lexique + +Un plugin `core` est un service de base associé à la chaîne DSO. +Un plugin `external` est un service supplémentaire facultatif. +Un `hook` est un point de déclenchement dont le cycle de vie comprend plusieurs étapes (`step`) : `pre`, `main`, `post`, etc. +Tout `plugin` peut s'enregistrer sur un `hook`, à une étape donnée. + +## Principe + +Exemple : + +1. Le plugin `gitlab` s'enregistre sur le hook `createProject`, à l'étape `main`. +2. Le controller `createProjectController` déclenche le hook `createProject` en lui associant un `payload`. +3. Le plugin `gitlab` réagit au déclenchement du hook `createProject` et reçoit le payload associé du controller. +4. Le plugin `gitlab` effectue ses opérations (ex: création d'un groupe GitLab pour le projet). +5. Le plugin `gitlab` renvoie un objet `result` au controller `createProjectController`, contenant un `status` indiquant si tout s'est déroulé sans erreur ou non. +6. Le controller `createProjectController` enregistre ce `result` en base de donnée et s'arrête si le `status` est en erreur. + +## Modèles + +### Entries + +```js +{ + // args come from controllers + args: { + projectName: 'toto', + }, + failed: true || undefined + /* + [pluginName]: + [pluginName]: + ... + */ +} +``` + +### Result + +Note: Si un module réutilise son propre payload, il doit penser à renvoyer le précédent s'il ne veut pas l'écraser. Charge aussi au plugin de gérer les erreurs des étapes précédentes. + +```js +{ + status: { + result: string('KO' || 'OK'), + message: string(), + }, + vault: [ + { + name: string(), + data: { + // secret data + } + } + ] || undefined + // otherKey: {}, + // anotherKey: {}, +} +``` + +## Notes + +La communication des services associés à la chaîne DSO se fait au maximum en direct par les services Kubernetes via les url internes. On note toutefois les exceptions suivantes : +- Argocd communique avec l'url publique de Gitlab peu importe s'ils sont sur le même cluster. +- L'url publique de Harbor est utilisée dans les jobs de CI : il faudrait sinon une `imagePullSecret` avec url interne pour les jobs de CI et une avec url publique pour les pods des clusters applicatifs. diff --git a/.jjconflict-side-1/package.json b/.jjconflict-side-1/package.json new file mode 100644 index 0000000000..70b33dd885 --- /dev/null +++ b/.jjconflict-side-1/package.json @@ -0,0 +1,66 @@ +{ + "name": "@cpn-console/root", + "type": "module", + "version": "9.22.2", + "private": true, + "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "engines": { + "node": ">=24.0.0 <25.0.0", + "npm": ">=11.0.0" + }, + "scripts": { + "build": "pnpm -r run build", + "build:clean": "pnpm -r run build:clean", + "db:generate": "pnpm -r run db:generate", + "dev": "docker compose -f ./docker/docker-compose.local.yml up -d --remove-orphans && echo '\nStart applications with commands:\n\n pnpm --filter @cpn-console/server run dev\n pnpm --filter @cpn-console/client run dev\n pnpm --filter @cpn-console/server-nestjs run dev\n\nMake sure you provide env variables in `.env` files.'", + "dev:clean": "docker compose -f ./docker/docker-compose.local.yml down --remove-orphans", + "dev:delete": "docker compose -f ./docker/docker-compose.local.yml down -v --remove-orphans", + "docker:dev": "docker compose -f ./docker/docker-compose.dev.yml up --menu", + "docker:dev:build": "export COMPOSE_FILE=./docker/docker-compose.dev.yml && ./scripts/run-build.sh $COMPOSE_FILE", + "docker:dev:clean": "docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans", + "docker:dev:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:dev' -q) ; true", + "docker:e2e": "docker compose -f ./docker/docker-compose.dev.yml up ${TAG:+--no-build} -d --remove-orphans; pnpm playwright:test; docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans", + "docker:e2e-ci": "docker compose -f ./docker/docker-compose.ci.yml up ${TAG:+--no-build} -d --remove-orphans; pnpm playwright:test", + "docker:e2e-ci:delete": "docker compose -f ./docker/docker-compose.ci.yml down -v --remove-orphans", + "docker:integ": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.integ.yml up --menu", + "docker:integ:build": "export $(cat apps/server/.env.integ | grep -v '#' | xargs) && export COMPOSE_FILE=./docker/docker-compose.integ.yml && cd $(dirname $COMPOSE_FILE) && docker buildx bake --allow=fs.read=.. --file $(basename $COMPOSE_FILE) --load && cd - > /dev/null", + "docker:integ:clean": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.integ.yml down --remove-orphans", + "docker:integ:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:integ' -q) ; true", + "docker:ci": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.ci.yml up --menu", + "docker:ci:build": "export $(cat apps/server/.env.integ | grep -v '#' | xargs) && export COMPOSE_FILE=./docker/docker-compose.ci.yml && cd $(dirname $COMPOSE_FILE) && docker buildx bake --allow=fs.read=.. --file $(basename $COMPOSE_FILE) --load && cd - > /dev/null", + "docker:ci:clean": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.ci.yml down --remove-orphans", + "docker:ci:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:ci' -q) ; true", + "format": "pnpm -r run format", + "format:root": "eslint . --fix", + "format:style": "pnpm -r run format:style", + "fullclean": "sh ./ci/scripts/cleanup.sh", + "fullsetup": "sh ./ci/scripts/setup.sh", + "integ": "docker compose -f ./docker/docker-compose.local.yml up -d --remove-orphans postgres pgadmin && echo '\nStart server and client with commands:\n\n pnpm --filter @cpn-console/server run integ\n pnpm --filter @cpn-console/client run integ\n pnpm --filter @cpn-console/server-nestjs run integ\n\nMake sure you provide env variables in `.env.integ` files.'", + "integ:clean": "docker compose -f ./docker/docker-compose.local.yml down --remove-orphans", + "integ:delete": "docker compose -f ./docker/docker-compose.local.yml down -v --remove-orphans", + "lint": "pnpm -r run lint", + "lint:root": "eslint .", + "lint:style": "pnpm -r run lint:style", + "lint:ts": "pnpm -r run lint:ts", + "playwright:test": "pnpm --dir playwright run playwright:test", + "playwright:test:ui": "pnpm --dir playwright run playwright:test:ui", + "playwright:test:integration": "pnpm --dir playwright run playwright:test:integration", + "prepare": "husky", + "test": "pnpm -r --no-bail run test", + "test:cov": "pnpm -r --no-bail run test:cov", + "test:e2e": "pnpm kube:e2e", + "test:e2e-ci": "pnpm kube:prod; pnpm kube:e2e-ci" + }, + "devDependencies": { + "@commitlint/cli": "catalog:tools", + "@commitlint/config-conventional": "catalog:tools", + "@cpn-console/eslint-config": "workspace:^", + "eslint": "catalog:tools", + "husky": "catalog:tools", + "lint-staged": "catalog:tools" + } +} diff --git a/.jjconflict-side-1/packages/eslintconfig/eslint.config.js b/.jjconflict-side-1/packages/eslintconfig/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-1/packages/eslintconfig/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-1/packages/eslintconfig/package.json b/.jjconflict-side-1/packages/eslintconfig/package.json new file mode 100644 index 0000000000..e81e294cbe --- /dev/null +++ b/.jjconflict-side-1/packages/eslintconfig/package.json @@ -0,0 +1,21 @@ +{ + "name": "@cpn-console/eslint-config", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "type": "module", + "version": "1.0.2", + "license": "ISC", + "main": "src/index.js", + "types": "types/index.d.ts", + "devDependencies": { + "@antfu/eslint-config": "catalog:tools", + "eslint": "catalog:tools" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/packages/eslintconfig/src/index.js b/.jjconflict-side-1/packages/eslintconfig/src/index.js new file mode 100644 index 0000000000..79867556e6 --- /dev/null +++ b/.jjconflict-side-1/packages/eslintconfig/src/index.js @@ -0,0 +1,41 @@ +import antfu from '@antfu/eslint-config' + +export default antfu( + { + stylistic: { + overrides: { + 'antfu/consistent-chaining': 'off', + 'antfu/if-newline': 'off', + 'antfu/no-top-level-await': 'off', + 'node/prefer-global/process': ['error', 'always'], + 'node/prefer-global/buffer': ['error', 'always'], + 'style/comma-dangle': ['error', 'always-multiline'], + 'style/quote-props': ['error', 'as-needed', { keywords: false, unnecessary: true }], + 'style/brace-style': ['error', '1tbs', { allowSingleLine: true }], + 'style/max-statements-per-line': ['error', { max: 2 }], + 'ts/ban-ts-comment': 'off', + }, + }, + typescript: true, + vue: true, + yaml: { + overrides: { + 'yaml/quotes': ['error', { prefer: 'double' }], + 'yaml/indent': ['error', 2, { indentBlockSequences: true, indicatorValueIndent: 2 }], + }, + }, + ignores: [ + '**/node_modules', + '**/prisma/migrations', + '**/pnpm-lock.yaml', + '**/dist/', + '**/types/', + '**/coverage/', + '**/templates/*.{yaml,yml}', + '**/Chart.yaml', + '**/*.d.ts', + '**/*.md/*.js', + '**/*.md/*.ts', + ], + }, +) diff --git a/.jjconflict-side-1/packages/hooks/.gitignore b/.jjconflict-side-1/packages/hooks/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-1/packages/hooks/README.md b/.jjconflict-side-1/packages/hooks/README.md new file mode 100644 index 0000000000..e0149e15c8 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/README.md @@ -0,0 +1,245 @@ +# Plugin manager + +## Présentation + +Le plugin manager ou communément appelés "hooks" est une interface permettant au serveur d'api (`apps/server`) de déclencher des hooks "déclencheurs / événements" pour que les plugins puissent piloter et configurer des outils. + +Les plugins obligatoires de l'application sont souvent appelés `corePlugins`. + +L'objectif est que le server soit totalement agnostique des actions des plugins et même de leur existence. De plus le serveur d'API essayera au démarrage de charger des plugins dans un dossier spécifique, on les distingue en les appelant `externalPlugins`. + +## Développement de plugin + +Cette section est dédiée à tous ceux voulant améliorer les corePlugins ou en créer de nouveaux. + +Pour commencer je vous conseille d'aller regarder le [plugin vault](../../plugins/vault) + +### Interface + +Tout paquet existant devrait avoir un export nommé `plugin` et de type `Plugin`. + +Ce typage vous assurera de bien fournir toutes les clés nécessaires au bon fonctionnement du plugin : + +```ts +// index.ts +import type { Plugin } from '@cpn-console/hooks' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: {}, + monitor, +} +``` + +### Infos + +Ce sont les infos de bases de votre application : + +```ts +// infos.ts +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos: ServiceInfos = { + name: 'my_plugin', // il serait bien que ça ne change jamais, imaginez que c'est un identifiant unique. + to: ({ projectSlug }) => `${process.env.MON_URL}/${projectSlug}`, + title: 'Mon super plugin', + imgSrc: 'https://un_lien_vers/image_externe.svg/', // préférez le svg + description: 'La description générale de mon plugin', +} + +export default infos +``` + +La fonction `to` peux renvoyer une `String` ou un objet contenant une clé `to` et potentiellement d'autres informations ou encore un tableau de cet objet : + +```ts +// Valid +const to1 = () => 'une url' +const to2 = () => ({ to: 'url', title: 'Un titre', description: 'description', imgSrc: 'url' }) +function to3() { + return [ + { to: 'url générale', title: 'Service générale', description: 'description générale' }, + { to: 'url1', title: 'Service 1', description: 'description 1' }, + { to: 'url2', title: 'Service 2', description: 'description 2' }, + ] +} +// Invalid +const to3 = () => ['url', 'url1', 'url2'] +``` + +### Monitoring + +Pour offrir un (et un seul) service de monitoring sommaire de l'outil que le plugin manipule, vous pouvez créer une instance de la classe `Monitor` et l'initialiser avec une fonction dont vous aurez seul la maîtrise. +Cette fonction sera éxécutée par un `setInterval` toutes les 5 min ou selon le temps en miliseconds que vous aurez fourni : + +```ts +// monitor.ts +import { Monitor, type MonitorInfos, MonitorStatus } from '@cpn-console/shared' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = (new Date()).getTime() + // Votre fonction ne devrait jamais lever d'exception + try { + // faites des trucs + // mettez à jour les clés en fonction de votre résultat: + // instance.lastStatus.message = 'Tout va bien' + // instance.lastStatus.status = MonitorStatus.OK + } catch (error) { + instance.lastStatus.message = 'Error lors la requete' + instance.lastStatus.status = MonitorStatus.UNKNOW + // la clé cause n'est pour l'instant jamais retourné à l'utilisateur ni stocké, ça sera pour une prochaine PR + instance.lastStatus.cause = error + } + // c'est bien de le retourner mais on s'en fiche un peu + return instance.lastStatus +} + +export default new Monitor(monitor) +``` + +### SubscribedHooks + +Pour informer le plugin manager sur quels hooks vous voulez exécuter une fonction, vous devez fournir un objet qui aura cette structure : + +```ts + subscribedHooks: { + createProject: { + steps: { + pre: createDsoProjectFirst, + post: createDsoProjectLast, + }, + }, + archiveProject: { + steps: { post: archiveDsoProject }, + }, + }, +``` + +### Fonctions (StepCalls) + +Pensez à typer vos fonctions comme suit pour que TS soit capable de vous notifier si vous avez associé un mauvais payload à un hook. + +**Vos fonctions ne doivent pas non plus lever d'exceptions non gérées.** +**Et elles doivent toutes retourner un statut !** + +Vous pouvez aussi retourner des clés supplémentaires. Ces clés sont accessibles par tous les plugins dans `payload.results[nom_du_plugin]`. + +Attention, chaque fonction écrase le résultat de la step d'avant : + +```ts +export const createDsoProjectFirst: StepCall = async (payload) => { + try { + // fais des trucs + // oh j'ai un payload typé ! + return { + status: { + result: 'OK', + message: 'ça s\'est bien passé' // optionnel si OK + }, + foo: { + bar: 'complétement facultatif' + } + } + } catch (error) { + return { + status: { + result: 'KO', + message: 'Ouille !' // Obligatoire pour explique ce qui n'a pas réussi' + }, + } + } +} + +export const createDsoProjectLast: StepCall = async (payload) => { + try { + // fais des trucs + // oh j'ai un payload typé ! + return { + status: { + result: 'OK', + message: `${payload.results.my_plugin.foo.bar} a vraiment bien été crée` // optionnel si OK + }, + une_clé: { + newProjectName: 'complétement facultatif' + } + } + } catch (error) { + return { + status: { + result: 'KO', + message: 'Ouille !' // Obligatoire pour explique ce qui n'a pas réussi' + }, + } + } +} +``` + +### Apis + +Il n'existe pour l'instant qu'un seul plugin qui exporte une API pour les autres plugins, c'est Vault ! +D'autres viendront aider à la séparation des responsabiltés plus tard, stay tuned ! + +Vous pouvez, comme lui, déclarer des apis sur des hooks. Pour l'uniformité, déclarez que c'est un `extends` de `PluginApi` (qui est vide) : + +```ts +// api.ts +import { PluginApi } from '@cpn-console/hooks' + +export class ClusterApi extends PluginApi {} +``` + +```ts +// index.ts + subscribedHooks: { + createCluster: { + api: (args) => new ClusterApi(args.label, args.privacy), + steps: { post: createCluster }, + }, + deleteCluster: { + api: (args) => new ClusterApi(args.label), + }, + }, +``` + +### Dépendances entre les plugins + +La section précédente est bien sympathique mais en l'état, le plugin n'a aucune conscience des apis des autres plugins. + +> Disclaimer: Les plugins peuvent se parler entre eux mais attention ils ne s'importent jamais les uns les autres. C'est le Plugin Manager qui est en charge de fournir tous les objets nécessaires et de faire passe plat. + +Pour y arriver il va falloir deux étapes + +1) Le plugin qui expose l'api doit faire un `declare module` : + +```ts +// index.ts +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + vault: Args extends CreateClusterExecArgs | DeleteClusterExecArgs + ? ClusterApi + : undefined + } +} +``` + +1) Le module l'utilisant doit importer les types : + +```json +// package.json +{ + "peerDependencies": { + "my_plugin": "1.2.3" + } +} +``` + +```ts +// src/env.d.ts +/// +``` + +## Conclusion + +N'hésitez pas à ouvrir des issues si ce n'est pas clair et bon développement ! diff --git a/.jjconflict-side-1/packages/hooks/eslint.config.js b/.jjconflict-side-1/packages/hooks/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-1/packages/hooks/package.json b/.jjconflict-side-1/packages/hooks/package.json new file mode 100644 index 0000000000..e13bdf29ad --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/package.json @@ -0,0 +1,57 @@ +{ + "name": "@cpn-console/hooks", + "type": "module", + "version": "4.1.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'vite-node src/config.ts'", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "test:e2e-ci": "echo 'check cache'", + "test:watch": "vitest" + }, + "dependencies": { + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "json-schema": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/json-schema": "catalog:types", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "undici-types": "catalog:types", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/packages/hooks/src/config.ts b/.jjconflict-side-1/packages/hooks/src/config.ts new file mode 100644 index 0000000000..ab1a702bf9 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/config.ts @@ -0,0 +1,76 @@ +import type { PermissionTarget, PluginConfig } from '@cpn-console/shared' +import { atomicValidators, DEFAULT, pluginConfig } from '@cpn-console/shared' +import { z } from 'zod' +import { objectEntries } from './utils/utils.js' + +export type PluginsManifests = Record +export const pluginsManifests: PluginsManifests = {} + +export const editStrippersGenerator = pluginConfig.transform((arg) => { + const project = { + user: z.object({}), + admin: z.object({}), + } + let global = z.object({}) + + for (const item of arg.project) { + const zAny = atomicValidators[item.kind].optional() + if (item.permissions.user.write) { + project.user = project.user.merge(z.object({ [item.key]: zAny })) + } + if (item.permissions.admin.write) { + project.admin = project.admin.merge(z.object({ [item.key]: zAny })) + } + } + + for (const item of arg.global || []) { + if (item.permissions.admin.write) { + const zAny = atomicValidators[item.kind].optional() + global = global.merge(z.object({ [item.key]: zAny })) + } + } + return { project, global } +}) + +export const editStrippers = { + project: { + admin: z.object({}), + user: z.object({}), + }, + global: z.object({}), +} + +export function addPlugin(pluginName: string, config: PluginConfig, editStrippersObject: typeof editStrippers) { + const editZod = editStrippersGenerator.parse(config) + pluginsManifests[pluginName] = config + editStrippersObject.global = editStrippersObject.global.merge(z.object({ [pluginName]: editZod.global.default({}) })) + editStrippersObject.project.admin = editStrippersObject.project.admin.merge(z.object({ [pluginName]: editZod.project.admin.default({}) })) + editStrippersObject.project.user = editStrippersObject.project.user.merge(z.object({ [pluginName]: editZod.project.user.default({}) })) +} + +interface Row { pluginName: string, key: string, value: string } +interface PopulateManifestsParams { + data: { project?: Row[], global?: Row[] } + permissionTarget: PermissionTarget + select: Partial> + pluginName: string +} +export function populatePluginManifests({ data, select, permissionTarget, pluginName }: PopulateManifestsParams): Partial { + const manifest = structuredClone(pluginsManifests[pluginName]) + + const selected: Partial = {} + for (const [scope] of objectEntries(select).filter(([_scope, bool]) => bool)) { + if (!manifest?.[scope]) continue + selected[scope] = manifest[scope].filter(item => item.permissions[permissionTarget].read || item.permissions[permissionTarget].write).map((item) => { + const row = data[scope]?.find(candidate => candidate.pluginName === pluginName && candidate.key === item.key) + if (item.kind === 'switch') { + const value = atomicValidators.switch.safeParse(row?.value) + item.value = value.success ? value.data : DEFAULT + } else { + item.value = z.coerce.string().parse(row?.value ?? item.value ?? '') + } + return item + }) + } + return selected +} diff --git a/.jjconflict-side-1/packages/hooks/src/hooks/hook-admin-role.ts b/.jjconflict-side-1/packages/hooks/src/hooks/hook-admin-role.ts new file mode 100644 index 0000000000..ff9b55292d --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/hooks/hook-admin-role.ts @@ -0,0 +1,16 @@ +import type { Hook } from './hook.js' +import type { UserObject } from './index.js' +import { createHook } from './hook.js' + +export interface AdminRole { + id: string + name: string + permissions: bigint + position: number + oidcGroup: string + type?: string + members: UserObject[] +} + +export const upsertAdminRole: Hook = createHook() +export const deleteAdminRole: Hook = createHook() diff --git a/.jjconflict-side-1/packages/hooks/src/hooks/hook-cluster.ts b/.jjconflict-side-1/packages/hooks/src/hooks/hook-cluster.ts new file mode 100644 index 0000000000..faeef0d8b7 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/hooks/hook-cluster.ts @@ -0,0 +1,6 @@ +import type { Hook } from './hook.js' +import type { ClusterObject } from './index.js' +import { createHook } from './hook.js' + +export const upsertCluster: Hook = createHook() +export const deleteCluster: Hook = createHook() diff --git a/.jjconflict-side-1/packages/hooks/src/hooks/hook-misc.ts b/.jjconflict-side-1/packages/hooks/src/hooks/hook-misc.ts new file mode 100644 index 0000000000..faf6705c12 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/hooks/hook-misc.ts @@ -0,0 +1,16 @@ +import type { Project, Repository } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +// misc hooks +export type EmptyPayload = Record + +export const checkServices: Hook = createHook() + +// misc project related hooks +export type ProjectLite = Pick + +export const getProjectSecrets: Hook = createHook() + +export type UniqueRepo = ProjectLite & { repo: Omit & { syncAllBranches: boolean, branchName?: string } } +export const syncRepository: Hook = createHook() diff --git a/.jjconflict-side-1/packages/hooks/src/hooks/hook-project-member.ts b/.jjconflict-side-1/packages/hooks/src/hooks/hook-project-member.ts new file mode 100644 index 0000000000..c1d726f599 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/hooks/hook-project-member.ts @@ -0,0 +1,16 @@ +import type { ProjectRole } from './hook-project-role.js' +import type { Project } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +export interface ProjectMember { + userId: string + email: string + firstName: string + lastName: string + roles: ProjectRole[] + project: Project +} + +export const upsertProjectMember: Hook = createHook() +export const deleteProjectMember: Hook = createHook() diff --git a/.jjconflict-side-1/packages/hooks/src/hooks/hook-project-role.ts b/.jjconflict-side-1/packages/hooks/src/hooks/hook-project-role.ts new file mode 100644 index 0000000000..8d047f4d79 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/hooks/hook-project-role.ts @@ -0,0 +1,17 @@ +import type { Project } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +export interface ProjectRole { + id: string + name: string + permissions: string + projectId: string + position: number + type?: string + oidcGroup?: string + project: Project +} + +export const upsertProjectRole: Hook = createHook() +export const deleteProjectRole: Hook = createHook() diff --git a/.jjconflict-side-1/packages/hooks/src/hooks/hook-project.ts b/.jjconflict-side-1/packages/hooks/src/hooks/hook-project.ts new file mode 100644 index 0000000000..86d65eff97 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/hooks/hook-project.ts @@ -0,0 +1,74 @@ +import type { PluginApi } from '../utils/utils.js' +import type { Hook } from './hook.js' +import type { ClusterObject, ExternalRepoUrl, InternalRepoName, IsInfra, IsPrivate, UserObject } from './index.js' +import { createHook } from './hook.js' + +export interface RepoCreds { + username: string + token: string +} + +export interface Role { + name: string + permissions?: string + position: number + type?: string + oidcGroup?: string + users: UserObject[] +} + +export interface EnvironmentApis { + [x: string]: PluginApi +} +export interface Environment { + id: string + name: string + clusterId: ClusterObject['id'] + cpu: number + gpu: number + memory: number + stage: string + autosync: boolean + permissions: { + userId: UserObject['id'] + permissions: { + ro: boolean + rw: boolean + } + }[] + apis: EnvironmentApis +} + +export interface Repository { + id: string + internalRepoName: InternalRepoName + newCreds?: RepoCreds + externalRepoUrl: ExternalRepoUrl + isPrivate: IsPrivate + isInfra: IsInfra + deployRevision: string | null + deployPath: string | null + helmValuesFiles: string | null +} + +export interface ProjectStore { + [x: string]: { [x: string]: string } +} + +export interface Project { + id: string + description: string | null + name: string + status: string + clusters: ClusterObject[] + slug: string + environments: Environment[] + repositories: Repository[] + users: UserObject[] + roles: Role[] + store: ProjectStore + owner: UserObject +} + +export const upsertProject: Hook = createHook() +export const deleteProject: Hook = createHook() diff --git a/.jjconflict-side-1/packages/hooks/src/hooks/hook-user.ts b/.jjconflict-side-1/packages/hooks/src/hooks/hook-user.ts new file mode 100644 index 0000000000..ffb9a9cb97 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/hooks/hook-user.ts @@ -0,0 +1,7 @@ +import type { Hook } from './hook.js' +import type { UserObject } from './index.js' +import { createHook } from './hook.js' + +export type UserEmail = Pick +export type UserAdmin = Pick & { isAdmin: boolean } +export const retrieveUserByEmail: Hook = createHook() diff --git a/.jjconflict-side-1/packages/hooks/src/hooks/hook-zone.ts b/.jjconflict-side-1/packages/hooks/src/hooks/hook-zone.ts new file mode 100644 index 0000000000..dd2ffc7710 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/hooks/hook-zone.ts @@ -0,0 +1,6 @@ +import type { Hook } from './hook.js' +import type { ZoneObject } from './index.js' +import { createHook } from './hook.js' + +export const upsertZone: Hook = createHook() +export const deleteZone: Hook = createHook() diff --git a/.jjconflict-side-1/packages/hooks/src/hooks/hook.spec.ts b/.jjconflict-side-1/packages/hooks/src/hooks/hook.spec.ts new file mode 100644 index 0000000000..3dcfe36fd9 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/hooks/hook.spec.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest' +import { okStatus, PluginApi } from '../utils/utils.ts' +import { createHook, executeStep } from './hook.ts' + +const koStatus = { status: { result: 'KO', message: 'Failed' } } as const +const warningStatus = { status: { result: 'WARNING', message: 'Failed' } } as const + +async function simpleOkHookCall() { + return okStatus +} +async function simpleFailedHookCall() { + return koStatus +} +async function simpleWarningHookCall() { + return warningStatus +} + +describe('test executeStep mechanism', () => { + it('test payload results, everything ok', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleOkHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.failed).toBe(false) + expect(results.warning).toEqual([]) + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...okStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, everything ko', async () => { + const results = await executeStep({ + plugin1: simpleFailedHookCall, + plugin2: simpleFailedHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual([]) + expect(results.failed).contain('plugin1') + expect(results.failed).contain('plugin2') + expect(results.results).toEqual({ + plugin1: { ...koStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...koStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, partial ko', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleFailedHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual([]) + expect(results.failed).not.contain('plugin1') + expect(results.failed).contain('plugin2') + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...koStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, partial warning', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleWarningHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual(['plugin2']) + expect(results.failed).toEqual(false) + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...warningStatus, executionTime: { main: expect.any(Number) } }, + }) + }) +}) + +describe('createHook', () => { + it('test empty hookStructure', async () => { + const hook = createHook(false) + expect(hook).toEqual({ + apis: {}, + steps: { + check: {}, + pre: {}, + main: {}, + post: {}, + revert: {}, + }, + execute: expect.any(Function), + }) + + const hookUnique = createHook(true) + expect(hookUnique.uniquePlugin).toBe('') + }) + + it('test hook execution, simple ok', async () => { + const hook = createHook(false) + hook.steps.main.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, multistep ok', async () => { + const hook = createHook(false) + hook.steps.pre.plugin1 = simpleOkHookCall + hook.steps.main.plugin1 = simpleOkHookCall + hook.steps.post.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.warning).toEqual([]) + expect(hookResult.results).toEqual({ plugin1: { ...okStatus, executionTime: { + pre: expect.any(Number), + main: expect.any(Number), + post: expect.any(Number), + } } }) + }) + + it('test payload results, multistep with warning', async () => { + const hook = createHook(false) + hook.steps.pre.plugin1 = simpleWarningHookCall + hook.steps.main.plugin2 = simpleOkHookCall + hook.steps.post.plugin2 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.warning).toEqual(['plugin1']) + expect(hookResult.results).toEqual({ + plugin1: { + ...warningStatus, + executionTime: { + pre: expect.any(Number), + }, + }, + plugin2: { + ...okStatus, + executionTime: { + main: expect.any(Number), + post: expect.any(Number), + }, + }, + }) + }) + + it('test payload results, main fails', async () => { + const hook = createHook(false) + hook.apis.plugin1 = () => new PluginApi() // à tester ailleurs + hook.steps.pre.plugin1 = simpleOkHookCall + hook.steps.main.plugin1 = simpleFailedHookCall + hook.steps.post.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(['plugin1']) + expect(hookResult.results).toEqual({ + plugin1: { + ...koStatus, + executionTime: { + pre: expect.any(Number), + main: expect.any(Number), + }, + }, + }) + }) +}) diff --git a/.jjconflict-side-1/packages/hooks/src/hooks/hook.ts b/.jjconflict-side-1/packages/hooks/src/hooks/hook.ts new file mode 100644 index 0000000000..1377d9abcc --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/hooks/hook.ts @@ -0,0 +1,192 @@ +import type { PluginApi } from '../utils/utils.js' +import type * as hooks from './index.js' +import { logger } from '@cpn-console/logger/hooks' + +export type DefaultArgs = Record +export type PluginResultStoreValue = string | number | null +export type PluginResultStore = Record +export interface PluginResult { + status: { result: 'OK', message?: string } | { result: 'KO' | 'WARNING', message: string } + store?: PluginResultStore + [key: string]: any +} + +export interface HookPayloadResults { + [x: string]: PluginResult +} +// @ts-ignore +// eslint-disable-next-line unused-imports/no-unused-vars +export interface HookPayloadApis { + [x: string]: PluginApi +} +export type Store = Record> // TO DEPRECIATE USE ONFIG + +export interface Config { + [x: string]: { [x: string]: string } +} + +export interface HookPayload { + args: Args + failed: boolean | string[] + warning: string[] + results: HookPayloadResults + apis: HookPayloadApis + config: Config +} + +export type HookResult = Omit, 'apis'> & { totalExecutionTime: number, messageResume?: string } + +export type StepCall = (payload: HookPayload) => Promise +type HookStep = Record> +export type HookStepsNames = 'check' | 'pre' | 'main' | 'post' | 'revert' +export interface Hook { + uniquePlugin?: string // if plugin register on it no other one can register on it + execute: (args: E, store: Config) => Promise> + apis: Record PluginApi> + steps: Record +} +export type HookList = Record> + +function generateMessageResume(payload: HookPayload): string | undefined { + let messageResume = '' + if (Array.isArray(payload.failed)) { + for (const pluginName of payload.failed) { + messageResume += 'Errors:' + messageResume += `\n${pluginName}: ${payload.results[pluginName].status.message};` + } + } + if (payload.warning.length) { + for (const pluginName of payload.warning) { + messageResume += 'Warnings:' + messageResume += `\n${pluginName}: ${payload.results[pluginName].status.message};` + } + } + return messageResume || undefined +} + +function handleRejectedStepResult( + reason: any, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + logger.error({ plugin: name, step: stepName, err: reason }, 'Hook step failed') + const result: PluginResult = { + status: { + result: 'KO', + message: reason instanceof Error ? reason.message : String(reason), + }, + } + payload.failed = Array.isArray(payload.failed) + ? [...payload.failed, name] + : [name] + return result +} + +function handleFulfilledStepResult( + value: PluginResult, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + if (value.status.result === 'KO') { + payload.failed = Array.isArray(payload.failed) + ? [...payload.failed, name] + : [name] + logger.error({ plugin: name, step: stepName, err: value.status.message, status: value.status.result, message: value.status.message }, 'Hook step failed') + } else if (value.status.result === 'WARNING' && !payload.warning.includes(name)) { + payload.warning.push(name) + logger.warn({ plugin: name, step: stepName, err: value.status.message, status: value.status.result, message: value.status.message }, 'Hook step warning') + } else { + logger.trace({ plugin: name, step: stepName, status: value.status.result }, 'Hook step fulfilled') + } + return value +} + +function handleStepResult( + settled: PromiseSettledResult, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + const result = settled.status === 'fulfilled' + ? handleFulfilledStepResult(settled.value, name, stepName, payload) + : handleRejectedStepResult(settled.reason, name, stepName, payload) + return { ...result, executionTime: payload.results[name].executionTime } +} + +export async function executeStep(step: HookStep, payload: HookPayload, stepName: string) { + const names = Object.keys(step) + const fns = names.map(async (name) => { + if (payload.results[name]?.executionTime) { + payload.results[name].executionTime[stepName] = Date.now() + } else { + payload.results[name] = { + status: { result: 'OK' }, + executionTime: { [stepName]: Date.now() }, + } + } + const fnResult = await step[name](payload) + payload.results[name].executionTime[stepName] = Date.now() - payload.results[name].executionTime[stepName] + return fnResult + }) + const results = await Promise.allSettled(fns) + names.forEach((name, index) => { + payload.results[name] = handleStepResult(results[index], name, stepName, payload) + }) + return payload +} +export function createHook(unique = false) { + const steps: Record = { + check: {}, + pre: {}, + main: {}, + post: {}, + revert: {}, + } + const apis: Record PluginApi> = { + } + const execute = async (args: E, config: Config): Promise> => { + const startTime = Date.now() + const payloadApis: HookPayloadApis = {} + Object.entries(apis).forEach(([pluginName, apiFn]) => { + payloadApis[pluginName] = apiFn(args) + }) + let payload: HookPayload = { + failed: false, + args, + results: {}, + apis: payloadApis, + config, + warning: [], + } + + const executeSteps = ['pre', 'main', 'post'] as const + for (const step of executeSteps) { + payload = await executeStep(steps[step], payload, step) + if (payload.failed) { + payload = await executeStep(steps.revert, payload, 'revert') + break + } + } + return { + args: payload.args, + results: payload.results, + failed: payload.failed, + warning: payload.warning, + totalExecutionTime: Date.now() - startTime, + config, + messageResume: generateMessageResume(payload), + } + } + + const hook: Hook = { + apis, + steps, + execute, + } + if (unique) { + hook.uniquePlugin = '' + } + return hook +} diff --git a/.jjconflict-side-1/packages/hooks/src/hooks/index.ts b/.jjconflict-side-1/packages/hooks/src/hooks/index.ts new file mode 100644 index 0000000000..a783454745 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/hooks/index.ts @@ -0,0 +1,68 @@ +export * from './hook-admin-role.js' +export * from './hook-cluster.js' +export * from './hook-misc.js' +export * from './hook-project-member.js' +export * from './hook-project-role.js' +export * from './hook-project.js' +export * from './hook-user.js' +export * from './hook-zone.js' + +export interface PartialEnvironment { environment: string, stage: string, clusterLabel: string } +export type InternalRepoName = string +export type ExternalRepoUrl = string +export type ExternalUserName = string +export type ExternalToken = string +export type IsPrivate = boolean +export type IsInfra = boolean +export type InternalUrl = string +export interface UserObject { + firstName: string + lastName: string + id: string + email: string +} +export interface EnvironmentObject { + id: string + name: string + createdAt: Date + updatedAt: Date + clusterId: string +} + +export interface KubeUser { + readonly certData?: string + readonly keyData?: string + readonly token?: string + readonly username?: string + readonly password?: string +} + +export interface KubeCluster { + readonly caData?: string + readonly server: string + readonly skipTLSVerify?: boolean + readonly tlsServerName?: string +} + +export interface ZoneObject { + id: string + slug: string + label: string + argocdUrl: string + clusterNames?: string[] +} + +export interface ClusterObject { + id: string + label: string + privacy: 'public' | 'dedicated' + /** + * @deprecated Le nom aléatoire stocké ici n'est plus utilisé, au profit d'un nom caculé et déterministe. + */ + secretName: string + clusterResources: boolean + infos: string | null + cluster: KubeCluster + user: KubeUser + zone: ZoneObject +} diff --git a/.jjconflict-side-1/packages/hooks/src/index.ts b/.jjconflict-side-1/packages/hooks/src/index.ts new file mode 100644 index 0000000000..ad4af3bca8 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/index.ts @@ -0,0 +1,135 @@ +import type { Monitor } from '@cpn-console/shared' +import type { HookStepsNames, StepCall } from './hooks/hook.js' +import type { ServiceInfos } from './services.js' +import type { PluginApi } from './utils/utils.js' +import { logger } from '@cpn-console/logger/hooks' +import { addPlugin, editStrippers } from './config.js' +import * as hooks from './hooks/index.js' +import { servicesInfos } from './services.js' +import { objectEntries } from './utils/utils.js' + +export * from './config.js' + +export type HookChoice = keyof typeof hooks + +type ExecArgs = Parameters[0] + +export type PluginsFunctions = Partial<{ + [C in HookChoice]: { + steps?: Partial<{ + [S in HookStepsNames]: StepCall> + }> + api?: (args: ExecArgs) => PluginApi + } +}> + +export interface Plugin { + infos: ServiceInfos + subscribedHooks: PluginsFunctions + monitor?: Monitor + start?: (options: unknown) => void +} + +export type RegisterFn = (plugin: Plugin) => void +export type UnregisterFn = (name: Plugin['infos']['name']) => void +export interface PluginManager { + servicesInfos: Record + register: RegisterFn + unregister: UnregisterFn +} + +export interface PluginManagerOptions { + startPlugins?: boolean + mockMonitoring?: boolean + mockHooks?: boolean + mockExternalServices?: boolean + externalDir?: string +} + +let config: PluginManagerOptions +function pluginManager(options: PluginManagerOptions): PluginManager { + config = options + const register: RegisterFn = (plugin: Plugin) => { + if (plugin.infos.config) { + addPlugin(plugin.infos.name, plugin.infos.config, editStrippers) + } + + if (plugin.infos.to && config.mockExternalServices) + plugin.infos.to = () => [{ name: 'Lien', to: 'https://theuselessweb.com/' }] + if (plugin.start && options.startPlugins) + plugin.start({}) + const message: string[] = [] + if (plugin.monitor && config.mockMonitoring) { + plugin.monitor.monitorFn = async (instance: Monitor) => instance.lastStatus + } + if (plugin.monitor) + plugin.monitor.refresh() + servicesInfos[plugin.infos.name] = { + ...plugin.infos, + monitor: plugin.monitor, + } + const subscribedHooks = plugin.subscribedHooks + const name = plugin.infos.name + + if (!config.mockHooks) { + for (const [hook, functions] of objectEntries(subscribedHooks)) { + if (!(hook in hooks)) { + logger.warn({ plugin: name, hook }, 'Tried to register on an unknown hook') + continue + } + if (functions?.api) { + hooks[hook].apis[name] = functions.api + } + for (const [step, fn] of objectEntries(functions?.steps ?? {})) { + if (fn === undefined) + continue + if (hook === 'checkServices' && step !== 'check') { + logger.warn({ plugin: name, hook, step }, 'Tried to register an invalid step for checkServices hook') + continue + } + + if ('uniquePlugin' in hooks[hook] && hooks[hook]?.uniquePlugin !== '' && hooks[hook]?.uniquePlugin !== name) { + logger.warn({ plugin: name, hook, registeredBy: hooks[hook].uniquePlugin }, 'Hook is already registered by another plugin') + continue + } + // @ts-ignore + hooks[hook].steps[step][name] = fn + message.push(`${hook}:${step}`) + } + } + } + if (process.env.NODE_ENV !== 'test') { + logger.info({ plugin: name, registrations: message }, 'Plugin registered') + } + } + + const unregister: UnregisterFn = (name) => { + delete servicesInfos[name] + + Object.values(hooks).forEach((hook) => { + delete hook.steps.pre[name] + delete hook.steps.main[name] + delete hook.steps.post[name] + delete hook.steps.revert[name] + delete hook.apis[name] + }) + } + + return { + servicesInfos, + register, + unregister, + } +} + +export { + hooks, + pluginManager, +} + +export * from './hooks/hook.js' +export * from './hooks/index.js' +export * from './services.js' +export * from './utils/crypto.js' +export * from './utils/plugin-result-handler.js' +export * from './utils/utils.js' diff --git a/.jjconflict-side-1/packages/hooks/src/services.ts b/.jjconflict-side-1/packages/hooks/src/services.ts new file mode 100644 index 0000000000..dce8693564 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/services.ts @@ -0,0 +1,40 @@ +import type { Monitor, MonitorInfos, PluginConfig, PluginsUpdateBody } from '@cpn-console/shared' +import type { ClusterObject, EnvironmentObject, ProjectLite, ZoneObject } from './hooks/index.js' + +interface ToUrlObject { to: string, title?: string, description?: string, imgSrc?: string } +export interface ToUrlFnParamaters { + store: PluginsUpdateBody + clusters: Omit[] + zones: ZoneObject[] + environments: EnvironmentObject[] + project: Omit +} +type ToUrlFnResponse = ToUrlObject | ToUrlObject[] | string | void + +export interface ServiceInfos { + name: string + to?: ({ store, clusters, zones, environments, project }: ToUrlFnParamaters) => ToUrlFnResponse + monitor?: Monitor + title: string + imgSrc?: string + description?: string + config?: PluginConfig +} + +export const servicesInfos: Record = {} + +export type ServiceStatus = MonitorInfos & { name: string } +export const services = { + getStatus: (): Array => { + return Object.values(servicesInfos).reduce((acc, serviceInfos) => { + if (!serviceInfos?.monitor?.lastStatus) return acc + return [...acc, { ...serviceInfos.monitor.lastStatus, name: serviceInfos.title }] + }, [] as Array) + }, + refreshStatus: (): Array> => { + // @ts-ignore obligé d'ignore TS ne comprend pas l'interet du filter + return Object.values(servicesInfos) + .filter(servicesInfos => servicesInfos.monitor) + .map(serviceInfos => serviceInfos.monitor?.refresh()) + }, +} diff --git a/.jjconflict-side-1/packages/hooks/src/utils/crypto.ts b/.jjconflict-side-1/packages/hooks/src/utils/crypto.ts new file mode 100644 index 0000000000..5517e2b906 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/utils/crypto.ts @@ -0,0 +1,16 @@ +import type { Project } from '../hooks/index.js' +import crypto, { createHmac } from 'node:crypto' + +export function generateRandomPassword(length = 24) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*' + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +export function generateProjectKey(projectSlug: Project['slug'], repo: Project['repositories'][0]['internalRepoName']) { + const repoHash = createHmac('sha256', '') + .update(repo) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${repo}-${repoHash}` +} diff --git a/.jjconflict-side-1/packages/hooks/src/utils/index.ts b/.jjconflict-side-1/packages/hooks/src/utils/index.ts new file mode 100644 index 0000000000..ffb3bb4ec5 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './crypto.js' +export * from './utils.js' diff --git a/.jjconflict-side-1/packages/hooks/src/utils/plugin-result-handler.ts b/.jjconflict-side-1/packages/hooks/src/utils/plugin-result-handler.ts new file mode 100644 index 0000000000..a90261aede --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/utils/plugin-result-handler.ts @@ -0,0 +1,92 @@ +import type { PluginResult, PluginResultStore, PluginResultStoreValue } from '../hooks/hook.js' + +export class PluginResultBuilder { + private okMessages = [] as string[] + private warnMessages = [] as string[] + private koMessages = [] as string[] + private extras: Record = {} + public store: PluginResultStore = {} + + constructor(okMessage: string | undefined) { + if (okMessage) { + this.okMessages.push(okMessage) + } + } + + addExtra(key: string, value: any) { + this.extras[key] = value + return this + } + + addOkMessage(...messages: string[]) { + this.okMessages.push(...messages) + return this + } + + addKoMessage(...messages: string[]) { + this.koMessages.push(...messages) + return this + } + + addWarnMessage(...messages: string[]) { + this.warnMessages.push(...messages) + return this + } + + setOkMessage(...messages: string[]) { + this.okMessages = messages + return this + } + + setKoMessage(...messages: string[]) { + this.koMessages = messages + return this + } + + setWarnMessage(...messages: string[]) { + this.warnMessages = messages + return this + } + + setToStore(key: string, value: PluginResultStoreValue) { + this.store[key] = value + } + + deleteFromStore(key: string) { + delete this.store[key] + } + + returnUnexpectedError(error: unknown): PluginResult { + this.addKoMessage('UnexpectedError') + this.addExtra('error', error) + return this.getResultObject() + } + + getResultObject(): PluginResult { + const result: PluginResult = { + status: { + result: 'OK', + message: this.okMessages.join('\n') || undefined, + }, + ...this.extras, + } + + if (this.koMessages.length) { + result.status = { + result: 'KO', + message: this.koMessages.join('\n'), + } + } else if (this.warnMessages.length) { + result.status = { + result: 'WARNING', + message: this.warnMessages.join('\n'), + } + } + + if (Object.keys(this.store).length) { + result.store = this.store + } + + return result + } +} diff --git a/.jjconflict-side-1/packages/hooks/src/utils/utils.spec.ts b/.jjconflict-side-1/packages/hooks/src/utils/utils.spec.ts new file mode 100644 index 0000000000..a463bc5fb2 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/utils/utils.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { defaultOrNullish, disabledOrDefaultOrNullish, enabledOrDefaultOrNullish, objectEntries, objectKeys, objectValues, specificallyDisabled, specificallyEnabled } from './utils.ts' + +const object = { test1: 1, test2: 2, 3: 'test3' } + +it('should return object keys', () => { + const keys = objectKeys(object) + + // cannot gaurantee order in keys + expect(keys[0]).contain('3') + expect(keys[1]).contain('test1') + expect(keys[2]).contain('test2') +}) + +it('should return object entries', () => { + const keys = objectEntries(object) + + expect(keys[0]).toEqual(['3', 'test3']) + expect(keys[1]).toEqual(['test1', 1]) + expect(keys[2]).toEqual(['test2', 2]) +}) + +it('should return object values', () => { + const keys = objectValues(object) + + // cannot gaurantee order in values + expect(keys).contain('test3') + expect(keys).contain(1) + expect(keys).contain(2) +}) + +const values = [ + '', + 'nimp', + 'enabled', + 'default', + 'disabled', +] as const +describe('test config parsing', () => { + it('enabledOrDefaultOrNullish', () => { + expect(enabledOrDefaultOrNullish(values[0])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[1])).toBeFalsy() + expect(enabledOrDefaultOrNullish(values[2])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[3])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[4])).toBeFalsy() + }) + it('specificallyDisabled', () => { + expect(specificallyDisabled(values[0])).toBeFalsy() + expect(specificallyDisabled(values[1])).toBeFalsy() + expect(specificallyDisabled(values[2])).toBeFalsy() + expect(specificallyDisabled(values[3])).toBeFalsy() + expect(specificallyDisabled(values[4])).toBeTruthy() + }) + it('specificallyEnabled', () => { + expect(specificallyEnabled(values[0])).toBeFalsy() + expect(specificallyEnabled(values[1])).toBeFalsy() + expect(specificallyEnabled(values[2])).toBeTruthy() + expect(specificallyEnabled(values[3])).toBeFalsy() + expect(specificallyEnabled(values[4])).toBeFalsy() + }) + it('defaultOrNullish', () => { + expect(defaultOrNullish(values[0])).toBeTruthy() + expect(defaultOrNullish(values[1])).toBeFalsy() + expect(defaultOrNullish(values[2])).toBeFalsy() + expect(defaultOrNullish(values[3])).toBeTruthy() + expect(defaultOrNullish(values[4])).toBeFalsy() + }) + it('disabledOrDefault', () => { + expect(disabledOrDefaultOrNullish(values[0])).toBeTruthy() + expect(disabledOrDefaultOrNullish(values[1])).toBeFalsy() + expect(disabledOrDefaultOrNullish(values[2])).toBeFalsy() + expect(disabledOrDefaultOrNullish(values[3])).toBeTruthy() + expect(disabledOrDefaultOrNullish(values[4])).toBeTruthy() + }) +}) diff --git a/.jjconflict-side-1/packages/hooks/src/utils/utils.ts b/.jjconflict-side-1/packages/hooks/src/utils/utils.ts new file mode 100644 index 0000000000..65d03954c2 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/src/utils/utils.ts @@ -0,0 +1,88 @@ +import type { ServiceInfos } from '../services.js' +import { DEFAULT, DISABLED, ENABLED } from '@cpn-console/shared' + +export class PluginApi { } + +export function objectEntries>(obj: Obj): ([keyof Obj, Obj[keyof Obj]])[] { + return Object.entries(obj) as ([keyof Obj, Obj[keyof Obj]])[] +} +export function objectKeys>(obj: Obj): (keyof Obj)[] { + return Object.keys(obj) as (keyof Obj)[] +} +export function objectValues>(obj: Obj): (Obj[keyof Obj])[] { + return Object.values(obj) as (Obj[keyof Obj])[] +} + +type ConfigScope = keyof Required['config'] + +export type DeclareModuleGenerator, 'config'>, Scope extends ConfigScope> = { + [K in Infos['name']]?: { + [P in Infos['config'][Scope][number]['key']]?: string + } +} + +export const enabledOrDefaultOrNullish = (value?: string): boolean | undefined => value ? [ENABLED, DEFAULT].includes(value) : true +export const disabledOrDefaultOrNullish = (value?: string): boolean | undefined => value ? [DISABLED, DEFAULT].includes(value) : true +export const specificallyDisabled = (value?: string): boolean | undefined => value ? value === DISABLED : undefined +export const specificallyEnabled = (value?: string): boolean | undefined => value ? value === ENABLED : undefined +export const defaultOrNullish = (value?: string): boolean | undefined => value ? DEFAULT === value : true + +export const okStatus = { status: { result: 'OK' } } as const + +/** + * Take a list of list of kubernetes resources and ditch duplicates by name + * + * @remarks Kind is ignored. You can pass as many arguments as you want + * + * @example + * ```ts + * const foo = [{ metadata: { name: 'One' }}, { metadata: { name: 'Two' }}] + * const bar = [{ metadata: { name: 'One' }}, { metadata: { name: 'Three' }}] + * const xyz = [{ metadata: { name: 'Four' }}, { metadata: { name: 'Two' }}] + * uniqueResource(foo, bar, xyz) + * // [ + * // { metadata: { name: 'One' }}, + * // { metadata: { name: 'Two' }}, + * // { metadata: { name: 'Three' }}, + * // { metadata: { name: 'Four' }} + * // ] + * ``` + * + */ +export function uniqueResource(...lists: T[][]): T[] { + return lists + .flat() + .reduce((acc, cur) => (acc.some(item => item.metadata?.name === cur.metadata?.name) + ? acc + : [...acc, cur] + ), [] as T[]) +} + +export interface BaseResources { + kind?: string + apiVersion?: string + metadata: { + name?: string + namespace?: string + labels: { + [x: string]: string + } + [x: string]: any + } + [x: string]: any +} + +export interface BareMinimumResource { + metadata: { + name: string + labels: { + [x: string]: string + } + } +} + +export interface ListMinimumResources { + body: { + items: BareMinimumResource[] + } +} diff --git a/.jjconflict-side-1/packages/hooks/tsconfig.eslint.json b/.jjconflict-side-1/packages/hooks/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-1/packages/hooks/tsconfig.json b/.jjconflict-side-1/packages/hooks/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-1/packages/hooks/vitest.config.ts b/.jjconflict-side-1/packages/hooks/vitest.config.ts new file mode 100644 index 0000000000..58225abfa0 --- /dev/null +++ b/.jjconflict-side-1/packages/hooks/vitest.config.ts @@ -0,0 +1,16 @@ +import { configDefaults, defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: ['**/types', '**/mocks', '**/*.spec.ts', '**/*.d.ts'], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude], + }, +}) diff --git a/.jjconflict-side-1/packages/logger/eslint.config.js b/.jjconflict-side-1/packages/logger/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-1/packages/logger/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-1/packages/logger/package.json b/.jjconflict-side-1/packages/logger/package.json new file mode 100644 index 0000000000..2b14869190 --- /dev/null +++ b/.jjconflict-side-1/packages/logger/package.json @@ -0,0 +1,53 @@ +{ + "name": "@cpn-console/logger", + "type": "module", + "version": "1.0.1", + "private": false, + "description": "Cloud PI Native internal logger for legacy code", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "browser": "./dist/browser.js", + "default": "./dist/index.js" + }, + "./browser": { + "types": "./types/browser.d.ts", + "default": "./dist/browser.js" + }, + "./hooks": { + "types": "./types/hooks.d.ts", + "default": "./dist/hooks.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "pino": "catalog:tools", + "pino-pretty": "catalog:tools", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "rimraf": "catalog:tools", + "typescript": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/packages/logger/src/browser.ts b/.jjconflict-side-1/packages/logger/src/browser.ts new file mode 100644 index 0000000000..f1ed417404 --- /dev/null +++ b/.jjconflict-side-1/packages/logger/src/browser.ts @@ -0,0 +1,16 @@ +import { pino, stdSerializers } from 'pino' + +export const logger = pino({ + customLevels: { + audit: 25, + }, + level: 'info', + browser: { + asObject: true, + }, + serializers: { + err: stdSerializers.errWithCause, + }, +}) + +export type Logger = typeof logger diff --git a/.jjconflict-side-1/packages/logger/src/hooks.ts b/.jjconflict-side-1/packages/logger/src/hooks.ts new file mode 100644 index 0000000000..2ee8ad0f1a --- /dev/null +++ b/.jjconflict-side-1/packages/logger/src/hooks.ts @@ -0,0 +1,22 @@ +import type { Logger } from './index.js' +import { logger as baseLogger } from './index.js' + +export const logger: Logger = baseLogger.child( + { scope: 'hooks' }, + { + redact: { + paths: [ + 'err.config.auth.username', + 'err.config.auth.password', + 'err.config.headers', + 'err.config.data', + 'err.config.params.client_secret', + 'err.config.params.clientSecret', + 'err.request.headers', + 'err.response.data', + 'err.response.headers', + ], + remove: true, + }, + }, +) diff --git a/.jjconflict-side-1/packages/logger/src/index.ts b/.jjconflict-side-1/packages/logger/src/index.ts new file mode 100644 index 0000000000..cf2a6e2397 --- /dev/null +++ b/.jjconflict-side-1/packages/logger/src/index.ts @@ -0,0 +1,87 @@ +import type { LoggerOptions } from 'pino' +import { pino, stdSerializers } from 'pino' +import z from 'zod' + +const envSchema = z.object({ + LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error', 'audit']).default('debug'), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), +}) + +export type Env = z.infer['NODE_ENV'] +export type LogLevel = z.infer['LOG_LEVEL'] +const customLevels = { + audit: 25, +} + +const redact: LoggerOptions['redact'] = { + paths: [ + 'password', + '*.password', + 'passwd', + '*.passwd', + 'secret', + '*.secret', + 'token', + '*.token', + 'access_token', + '*.access_token', + 'refresh_token', + '*.refresh_token', + 'client_secret', + '*.client_secret', + 'authorization', + '*.authorization', + 'headers.authorization', + 'headers.cookie', + 'req.headers.authorization', + 'req.headers.cookie', + 'request.headers.authorization', + 'request.headers.cookie', + ], + remove: true, +} + +export function getLoggerOptions(env: Env, level: LogLevel): LoggerOptions { + switch (env) { + case 'development': + return { + transport: { + target: 'pino-pretty', + options: { + translateTime: 'dd/mm/yyyy - HH:MM:ss Z', + ignore: 'pid,hostname', + colorize: true, + singleLine: true, + }, + }, + customLevels, + level, + redact, + serializers: { + err: stdSerializers.errWithCause, + }, + } + case 'production': + return { + customLevels, + level, + redact, + serializers: { + err: stdSerializers.errWithCause, + }, + } + default: + return { + level: 'silent', + } + } +} + +export function getLoggerOptionsFromEnv(): LoggerOptions { + const env = envSchema.parse(process.env) + return getLoggerOptions(env.NODE_ENV, env.LOG_LEVEL) +} + +export const logger = pino(getLoggerOptionsFromEnv()) + +export type Logger = typeof logger diff --git a/.jjconflict-side-1/packages/logger/tsconfig.eslint.json b/.jjconflict-side-1/packages/logger/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-1/packages/logger/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-1/packages/logger/tsconfig.json b/.jjconflict-side-1/packages/logger/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-1/packages/logger/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-1/packages/opencds/Dockerfile b/.jjconflict-side-1/packages/opencds/Dockerfile new file mode 100644 index 0000000000..734496c4fb --- /dev/null +++ b/.jjconflict-side-1/packages/opencds/Dockerfile @@ -0,0 +1,21 @@ +FROM node:24-alpine AS prod + +RUN npm install -g @mockoon/cli@9.3.0 + +# Install curl for healthcheck and tzdata for timezone support. +RUN apk --no-cache add curl tzdata + +# Do not run as root. +RUN adduser --shell /bin/sh --disabled-password --gecos "" mockoon + +COPY packages/opencds/opencds_api.json ./opencds_api.json +RUN chown -R mockoon ./opencds_api.json + +USER mockoon + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3100/api/v1/health || exit 1 + +EXPOSE 3100 + +ENTRYPOINT ["mockoon-cli","start","--disable-log-to-file","--data","./opencds_api.json","--port","3100"] diff --git a/.jjconflict-side-1/packages/opencds/README.adoc b/.jjconflict-side-1/packages/opencds/README.adoc new file mode 100644 index 0000000000..bc9a736541 --- /dev/null +++ b/.jjconflict-side-1/packages/opencds/README.adoc @@ -0,0 +1,58 @@ += À propos + +Ce document va servir de fil conducteur pour toutes les idées autour de l'implémentation de la Console OpenCDS. Il permettra à l'équipe Socle d'échanger (au travers, par exemple, des commentaires de MR) sur les différentes problématiques entourant les travaux. C'est un document vivant, dynamique, qui sera remplacé par un "vrai" document d'architecture par la suite. + +== Cadrage effectué + +- SSO à prévoir avec Keycloak (déjà intégré dans la Console, d'où l'intérêt de l'utiliser pour intégrer le front OpenCDS) +- Rôles à prédéfinir dans *Keycloak* : + - `admin` (a accès à toute l'application) + - `cds` (n'a accès qu'à la partie qui concerne ses projets) + +Flux GUI définis : +- Pour tous les utilisateurs : + - Login/Logout à travers le SSO Keycloak comme la Console +- Utilisateurs `admin`: + - Liste de toutes les CDS + - Relancer une CDS en particulier + - Afficher les "flows" (étapes) d'un CDS +- Utilisateurs `cds`: + - Pouvoir lister les CDS de leur équipe + - Afficher les "flows" (étapes) d'une CDS qu'ils ont le droit de voir + - Avoir un écran de confirmation (on parlera plutôt de "validation" étant donné que la route côté OpenCDS s'appelle `/validate`) de création d'une CDS. Actuellement un mail leur est envoyé qui contient un lien pour validation. Ce lien valide la création de la CDS. Il faudra donc crééer un point d'entrée dans l'application pour plutôt ouvrir un écran de confirmation de la création. + +Lotissement des évolutions: +- [x] Création en tant que fonctionnalité "feature togglée" de Console (on verra plus tard si on en fait quelque chose à part) +- [x] Admin - Liste des CDS +- [x] Admin - Liste des Flow +- [x] Admin - Relancer un CDS en statut "échoué" (`/retry`) +- [ ] CDS - Liste des CDS/Flow de leur périmètre +- [ ] CDS - Écran de confirmation de la création d'un CDS (`/validate`) + += Architecture + +L'architecture retenue est celle consistant à utiliser le serveur ("Back for Front") de la console comme d'un Proxy pour OpenCDS. Ceci permet de réutiliser toute la mécanique d'authentification en SSO par Keycloak, la sérialisation/désérialisation entre `client` et `server-nestjs` (le Frontend et son BFF), ainsi que la génération des types TypeScript (à travers l'utilisation de schémas et de contrats `zod`). De cette manière, l'implémentation des ressources OpenCDS (`service-chains` et `flows`) peut se faire de la même manière que les autres ressources existantes (`cluster`, `projet`, etc.), avec le même niveau de qualité (tests unitaires avec vitest, et E2E avec Playwright). + += Contraintes + +- Console étant un projet OpenSource, et OpenCDS étant un sujet exclusivement "MI", le couplage entre les deux doit être le plus faible possible. Idéalement OpenCDS devrait être une "extension" de la Console de manière à ne jamais être directement référencé dans l'application. +=> Dans un premier temps on va fonctionner avec un "Feature Toggling" rudimentaire (à l'aide de variables d'environnement), pour voir où on va, et ensuite on verra comment éventuellement établir une séparation claire entre la console et sa partie OpenCDS, par exemple dans un projet Git totalement à part. + += Jeu de données + +Afin de pouvoir avancer sans forcément dépendre du backend OpenCDS, un https://mockoon.com/[Mockoon] sera utilisé (./opencds_api.json) pour simuler cette partie. Lors de la convergence entre les développements de la console et ceux côté OpenCDS, une version d'"intégration" de celle-ci sera déployée sur nos environnements PAX (`cpin-hp`, `formation`, et `pax-prod`) afin d'effectuer une recette dédiée. + += Mise en place + +Utilisation de varables d'environnements qui vont nous servir de moyen d'activer la fonctionnalité: + - Côté `apps/client`: + - `OPENCDS_ENABLED`: active ou non la fonctionnalité côté Front (cette activation est indépendante de `apps/server-nestjs`, donc il faut que cette variable d'environnement soit activée pour que la console affiche les écrans concernés par OpenCDS) + - Côté `apps/server-nestjs`: + - `OPENCDS_URL`: Chemin complet (URL + path) du service OpenCDS distant + - `OPENCDS_API_TOKEN`: Définit le token d'authentification au service OpenCDS qui sera utilisé pour chaque requête effectuée + - `OPENCDS_API_TLS_REJECT_UNAUTHORIZED`: Indique si on accepte les certificats non-validés (par exemple les certificats auto-signés, ou signés par une Autorité de Certification inconnue). Cette fonctionnalié permet d'intéragir facilement avec des services OpenCDS de "test" + += Notes + +- Sachant qu'il n'est pas trivial de lancer des services docker-compose de manière conditionnelle, le service `opencds-mockoon` est systématiquement lancé dans les docker-compose concernés (développement local, tests en CI, etc.) +- Les documentations de link:../../apps/client/README.md[la console] et link:../../apps/server-nestjs/README.md[son backend] ont été amendées pour référencer cette documentation, de manière à centraliser ici tous les aspects concernant OpenCDS. diff --git a/.jjconflict-side-1/packages/opencds/opencds_api.json b/.jjconflict-side-1/packages/opencds/opencds_api.json new file mode 100644 index 0000000000..6c92672f03 --- /dev/null +++ b/.jjconflict-side-1/packages/opencds/opencds_api.json @@ -0,0 +1,546 @@ +{ + "uuid": "b263d1c1-3103-4a17-940c-1c92215bb3b8", + "lastMigration": 33, + "name": "Opencds api", + "endpointPrefix": "api/v1/", + "latency": 0, + "port": 3003, + "hostname": "", + "folders": [], + "routes": [ + { + "uuid": "210c5fa6-22dc-4c79-a89e-4289fd2b84bb", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests", + "responses": [ + { + "uuid": "28bd3efb-d903-44c3-b728-66db3af526bc", + "body": "{{setVar 'networks' (array 'RIE' 'INTERNET')}}\n{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n[\n {{#repeat 50 100 comma=true}}\n {{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n {{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n {\n \"id\": \"{{faker 'string.uuid'}}\",\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"commonName\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\",\n \"pai\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"upper\\\" }'}}\",\n \"network\": \"{{oneOf (getVar 'networks')}}\",\n \"createdAt\": \"{{getVar 'createdAt'}}\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n }\n {{/repeat}}\n]", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "0d39381e-e815-40a9-9d47-cc739a8b582c", + "body": "", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "8f00d8d7-7df4-4401-b751-6440ee0e1658", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests/:request_id", + "responses": [ + { + "uuid": "410bd286-4377-47e5-ae3f-e4131fcb0b68", + "body": "{{setVar 'networks' (array 'RIE' 'INTERNET')}}\n{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n{{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'location' (array 'SIR' 'SIL')}}\n{{setVar 'environment' (array 'INT' 'PROD' 'BAS')}}\n{\n \"id\": \"{{urlParam 'request_id'}}\",\n \n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"commonName\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\",\n \"pai\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"upper\\\" }'}}\",\n \"network\": \"{{oneOf (getVar 'networks')}}\",\n \"createdAt\": \"{{getVar 'createdAt'}}\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\",\n\n \"validationId\": \"{{faker 'string.uuid'}}\",\n \"validatedBy\": \"{{faker 'string.uuid'}}\",\n \"ref\": \"{{faker 'string.uuid'}}\",\n \"location\": \"{{oneOf (getVar 'location')}}\",\n \"targetAddress\": \"{{faker 'internet.ipv4'}}\",\n \"projectId\": \"{{faker 'string.uuid'}}\",\n \"env\": \"{{oneOf (getVar 'environment')}}\",\n \"subjectAlternativeName\": [{{#repeat 0 50 comma=true}}\"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\"{{/repeat}}],\n \"redirect\": {{boolean}},\n \"antivirus\": {{#if (eq (boolean) true)}}{\n \"maxFileSize\": {{int 1 100}}\n }{{else}}null{{/if}},\n \"websocket\": {{boolean}},\n \"ipWhiteList\": [{{#repeat 0 10 comma=true}}\"{{faker 'internet.ipv4'}}/{{int 8 32}}\"{{/repeat}}],\n \"sslOutgoing\": {{boolean}}\n}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "41f0a982-3b94-44d7-8177-e120d4741dee", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "904ddbb0-480b-442d-b251-d008d10fa832", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "8ff2f39f-a82f-4364-90ed-135b5ec8f19b", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests/:request_id/flows", + "responses": [ + { + "uuid": "b24f3f54-b45c-4749-a134-dc1fa003a417", + "body": "{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n{{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'json' '' }}\n{\n \"reserve_ip\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"create_cert\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"call_exec\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"activate_ip\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"dns_request\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "e392c78f-a345-4b96-bf0d-74c8094c4097", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "c926d20b-b30f-4293-b583-3eb169943967", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "9cc0eae4-eef1-44ec-92cd-47819501695f", + "type": "http", + "documentation": "", + "method": "post", + "endpoint": "validate/:validation_id", + "responses": [ + { + "uuid": "69bc6592-1208-40a9-a3c0-f8b445fc9f4c", + "body": "", + "latency": 0, + "statusCode": 204, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "179726cf-4c32-4d07-aabf-ead6d4646e17", + "body": "", + "latency": 0, + "statusCode": 429, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "fce2e6b6-0894-4f05-873d-77d8965c4c67", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "79f5ce5f-bf70-4995-bad4-6e82fc72f55f", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "bb61c12f-8185-43fe-9498-22b6661e998c", + "type": "http", + "documentation": "", + "method": "post", + "endpoint": "requests/:request_id/retry", + "responses": [ + { + "uuid": "b703c7f4-7325-448a-ab57-5b122896c3cc", + "body": "", + "latency": 0, + "statusCode": 204, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "e6abdf74-3191-4140-b6ba-3922c52e4c15", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "7b5d5713-0b5c-49d3-b3a2-af6cd366654d", + "body": "", + "latency": 0, + "statusCode": 409, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "5d168446-71d6-456b-9de8-6b6100bd2f8a", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "1055fe07-2f29-4dec-8412-2066fb330b3c", + "type": "http", + "documentation": "OpenCDS Mockoon healthcheck", + "method": "get", + "endpoint": "health", + "responses": [ + { + "uuid": "0d7f2acf-8006-4995-a5e5-39d8ba1858ef", + "body": "{\"status\":\"OK\"}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + } + ], + "rootChildren": [ + { + "type": "route", + "uuid": "210c5fa6-22dc-4c79-a89e-4289fd2b84bb" + }, + { + "type": "route", + "uuid": "8f00d8d7-7df4-4401-b751-6440ee0e1658" + }, + { + "type": "route", + "uuid": "8ff2f39f-a82f-4364-90ed-135b5ec8f19b" + }, + { + "type": "route", + "uuid": "9cc0eae4-eef1-44ec-92cd-47819501695f" + }, + { + "type": "route", + "uuid": "bb61c12f-8185-43fe-9498-22b6661e998c" + }, + { + "type": "route", + "uuid": "1055fe07-2f29-4dec-8412-2066fb330b3c" + } + ], + "proxyMode": false, + "proxyHost": "", + "proxyRemovePrefix": false, + "tlsOptions": { + "enabled": false, + "type": "CERT", + "pfxPath": "", + "certPath": "", + "keyPath": "", + "caPath": "", + "passphrase": "" + }, + "cors": true, + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "proxyReqHeaders": [ + { + "key": "", + "value": "" + } + ], + "proxyResHeaders": [ + { + "key": "", + "value": "" + } + ], + "data": [], + "callbacks": [] +} \ No newline at end of file diff --git a/.jjconflict-side-1/packages/shared/.gitignore b/.jjconflict-side-1/packages/shared/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-1/packages/shared/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-1/packages/shared/eslint.config.js b/.jjconflict-side-1/packages/shared/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-1/packages/shared/package.json b/.jjconflict-side-1/packages/shared/package.json new file mode 100644 index 0000000000..5d3700491b --- /dev/null +++ b/.jjconflict-side-1/packages/shared/package.json @@ -0,0 +1,58 @@ +{ + "name": "@cpn-console/shared", + "type": "module", + "version": "1.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "test:e2e-ci": "echo 'check cache'", + "test:watch": "vitest" + }, + "dependencies": { + "@cpn-console/logger": "workspace:*", + "@ts-rest/core": "catalog:runtime", + "short-uuid": "catalog:runtime", + "zod": "catalog:runtime", + "zod-validation-error": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/packages/shared/src/api-client.ts b/.jjconflict-side-1/packages/shared/src/api-client.ts new file mode 100644 index 0000000000..c842cb9e99 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/api-client.ts @@ -0,0 +1,50 @@ +import type { ApiFetcher } from '@ts-rest/core' +import { initClient, initContract } from '@ts-rest/core' + +export const apiPrefix: string = '/api/v1' +export const apiPrefixV2: string = '/api/v2' + +export const contractInstance: ReturnType = initContract() + +export async function getContract() { + return contractInstance.router({ + AdminTokens: (await import('./contracts/index.js')).adminTokenContract, + AdminRoles: (await import('./contracts/index.js')).adminRoleContract, + Clusters: (await import('./contracts/index.js')).clusterContract, + ServiceChains: (await import('./contracts/index.js')).serviceChainContract, + Deployments: (await import('./contracts/index.js')).deploymentContract, + Environments: (await import('./contracts/index.js')).environmentContract, + EnvironmentsV2: (await import('./contracts/index.js')).environmentContractV2, + Logs: (await import('./contracts/index.js')).logContract, + PersonalAccessTokens: (await import('./contracts/index.js')) + .personalAccessTokenContract, + Projects: (await import('./contracts/index.js')).projectContract, + ProjectsMembers: (await import('./contracts/index.js')) + .projectMemberContract, + ProjectsRoles: (await import('./contracts/index.js')).projectRoleContract, + ProjectServices: (await import('./contracts/index.js')) + .projectServiceContract, + Repositories: (await import('./contracts/index.js')).repositoryContract, + Stages: (await import('./contracts/index.js')).stageContract, + Services: (await import('./contracts/index.js')).serviceContract, + Users: (await import('./contracts/index.js')).userContract, + Zones: (await import('./contracts/index.js')).zoneContract, + System: (await import('./contracts/index.js')).systemContract, + SystemPlugin: (await import('./contracts/index.js')).systemPluginContract, + SystemSettings: (await import('./contracts/index.js')) + .systemSettingsContract, + }) +} + +export async function getApiClient( + baseUrl: string, + baseHeaders: Record, + api: ApiFetcher | undefined, +): Promise { + return initClient(await getContract(), { + baseUrl, + baseHeaders, + api, + validateResponse: false, + }) +} diff --git a/.jjconflict-side-1/packages/shared/src/contracts/_utils.ts b/.jjconflict-side-1/packages/shared/src/contracts/_utils.ts new file mode 100644 index 0000000000..ea2f4ee5ff --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/_utils.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' +import { tokenHeaderName } from '../utils/const.js' + +export const EmptySchema = z.object({}) + +export const ErrorSchema = z.lazy(() => z.object({ + message: z.string() + .optional(), + error: z.unknown().optional(), + stack: z.unknown().optional(), +})) + +export const baseHeaders = z.object({ + [tokenHeaderName]: z.string().min(1).optional(), +}) diff --git a/.jjconflict-side-1/packages/shared/src/contracts/admin-role.ts b/.jjconflict-side-1/packages/shared/src/contracts/admin-role.ts new file mode 100644 index 0000000000..dc6696bee3 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/admin-role.ts @@ -0,0 +1,76 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { AdminRoleSchema } from '../schemas/role.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminRoleContract = contractInstance.router({ + listAdminRoles: { + method: 'GET', + path: '', + responses: { + 200: AdminRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createAdminRole: { + method: 'POST', + path: '', + body: AdminRoleSchema.pick({ name: true }), + responses: { + 200: AdminRoleSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + patchAdminRoles: { + method: 'PATCH', + path: '', + body: AdminRoleSchema.partial({ name: true, permissions: true, position: true, oidcGroup: true, type: true }).array(), + responses: { + 200: AdminRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + adminRoleMemberCounts: { + method: 'GET', + path: `/member-counts`, + responses: { + 200: z.record(z.number().min(0)), // Record + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deleteAdminRole: { + method: 'DELETE', + path: `/:roleId`, + pathParams: z.object({ + roleId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/admin/roles`, +}) diff --git a/.jjconflict-side-1/packages/shared/src/contracts/admin-token.ts b/.jjconflict-side-1/packages/shared/src/contracts/admin-token.ts new file mode 100644 index 0000000000..8df06a7db4 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/admin-token.ts @@ -0,0 +1,54 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { AdminTokenSchema, ExposedAdminTokenSchema } from '../schemas/token.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminTokenContract = contractInstance.router({ + listAdminTokens: { + method: 'GET', + path: '', + query: z.object({ + withRevoked: CoerceBooleanSchema + .optional(), + }), + responses: { + 200: AdminTokenSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createAdminToken: { + method: 'POST', + path: '', + body: AdminTokenSchema.pick({ name: true, permissions: true, expirationDate: true }).required(), + responses: { + 201: ExposedAdminTokenSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deleteAdminToken: { + method: 'DELETE', + path: `/:tokenId`, + pathParams: z.object({ tokenId: z.string().uuid() }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/admin/tokens`, +}) diff --git a/.jjconflict-side-1/packages/shared/src/contracts/cluster.ts b/.jjconflict-side-1/packages/shared/src/contracts/cluster.ts new file mode 100644 index 0000000000..96ada83d77 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/cluster.ts @@ -0,0 +1,138 @@ +import type { ClientInferResponseBody } from '@ts-rest/core' +import type Zod from 'zod' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { + CleanedClusterSchema, + ClusterDetailsSchema, + ClusterUsageSchema, +} from '../schemas/cluster.js' +import { EnvironmentSchema } from '../schemas/environment.js' +import { UserSchema } from '../schemas/user.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const ClusterParams = z.object({ + clusterId: CleanedClusterSchema.shape.id, +}) + +export const clusterContract = contractInstance.router({ + listClusters: { + method: 'GET', + path: '', + summary: 'Get clusters', + description: 'Retrieve clusters authorized for user', + responses: { + 200: z.array(CleanedClusterSchema), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + createCluster: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create cluster', + description: 'Create new cluster.', + body: ClusterDetailsSchema.omit({ id: true }), + responses: { + 201: ClusterDetailsSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterDetails: { + method: 'GET', + path: `/:clusterId`, + summary: 'Get cluster details', + description: 'Retrieved details of a cluster.', + pathParams: ClusterParams, + responses: { + 200: ClusterDetailsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterUsage: { + method: 'GET', + path: `/usage/:clusterId`, + summary: 'Get cluster usage', + description: 'Retrieved resources usage of a cluster.', + pathParams: ClusterParams, + responses: { + 200: ClusterUsageSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterEnvironments: { + method: 'GET', + path: `/:clusterId/environments`, + summary: 'Get cluster envs', + description: 'Retrieved environments linked to a cluster.', + pathParams: ClusterParams, + responses: { + 200: z.array(z.object({ + // TODO: Remettre `ProjectSchemaV2.shape.name` mais attention aux projets non compatibles + project: z.string(), + name: EnvironmentSchema.shape.name, + owner: UserSchema.shape.email.optional(), + cpu: EnvironmentSchema.shape.cpu, + gpu: EnvironmentSchema.shape.gpu, + memory: EnvironmentSchema.shape.memory, + })), + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateCluster: { + method: 'PUT', + path: `/:clusterId`, + summary: 'Update cluster', + description: 'Update a cluster by its ID.', + pathParams: ClusterParams, + body: ClusterDetailsSchema.omit({ id: true }).partial(), + responses: { + 200: ClusterDetailsSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteCluster: { + method: 'DELETE', + path: `/:clusterId`, + summary: 'Delete cluster', + description: 'Delete a cluster by its ID.', + query: z.object({ force: CoerceBooleanSchema.optional() }), + pathParams: ClusterParams, + body: ContractNoBody, + responses: { + 204: z.string() + .nullable(), + 400: ErrorSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/clusters`, +}) + +export type ClusterAssociatedEnvironments = ClientInferResponseBody +export type CreateClusterBody = Zod.infer +export type UpdateClusterBody = Zod.infer diff --git a/.jjconflict-side-1/packages/shared/src/contracts/deployment.ts b/.jjconflict-side-1/packages/shared/src/contracts/deployment.ts new file mode 100644 index 0000000000..968e65e940 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/deployment.ts @@ -0,0 +1,91 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + CreateDeploymentSchema, + DeploymentSchema, + UpdateDeploymentSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const deploymentContract = contractInstance.router({ + createDeployment: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create deployment', + description: 'Create new deployment.', + body: CreateDeploymentSchema, + responses: { + 201: DeploymentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listDeployments: { + method: 'GET', + path: '', + summary: 'Get deployments', + description: 'Retrieved project deployments.', + responses: { + 200: DeploymentSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateDeployment: { + method: 'PUT', + path: `/:deploymentId`, + summary: 'Update deployment', + description: 'Update a deployment by its ID.', + pathParams: z.object({ + deploymentId: z.string() + .uuid(), + }), + body: UpdateDeploymentSchema, + responses: { + 200: DeploymentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteDeployment: { + method: 'DELETE', + path: `/:deploymentId`, + summary: 'Delete deployment', + description: 'Delete a deployment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + deploymentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/deployments`, + pathParams: z.object({ + projectId: z.string().uuid(), + }), +}) + +export type CreateDeploymentBody = ClientInferRequest['body'] +export type UpdateDeploymentBody = ClientInferRequest['body'] diff --git a/.jjconflict-side-1/packages/shared/src/contracts/environment.ts b/.jjconflict-side-1/packages/shared/src/contracts/environment.ts new file mode 100644 index 0000000000..8d9320733e --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/environment.ts @@ -0,0 +1,175 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, apiPrefixV2, contractInstance } from '../api-client.js' +import { + CreateEnvironmentSchema, + EnvironmentSchema, + UpdateEnvironmentSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const environmentContract = contractInstance.router({ + createEnvironment: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create environment', + description: 'Create new environment.', + body: EnvironmentSchema.omit({ id: true, createdAt: true, updatedAt: true }), + responses: { + 201: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listEnvironments: { + method: 'GET', + path: '', + summary: 'Get environments', + description: 'Retrieved project environments.', + query: z.object({ + projectId: z.string() + .uuid(), + }), + responses: { + 200: EnvironmentSchema.omit({ name: true }) + .extend({ name: z.string() }).array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateEnvironment: { + method: 'PUT', + path: `/:environmentId`, + summary: 'Update environment', + description: 'Update a environment by its ID.', + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + body: EnvironmentSchema.pick({ cpu: true, gpu: true, memory: true, autosync: true }), + responses: { + 200: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteEnvironment: { + method: 'DELETE', + path: `/:environmentId`, + summary: 'Delete environment', + description: 'Delete a environment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/environments`, +}) + +export type CreateEnvironmentBody = ClientInferRequest['body'] +export type UpdateEnvironmentBody = ClientInferRequest['body'] + +// NB: les clés de routes servent d'operationId OpenAPI (setOperationId: true) et +// doivent être uniques sur l'ensemble du contrat, d'où le suffixe V2. +export const environmentContractV2 = contractInstance.router({ + createEnvironmentV2: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create environment', + description: 'Create new environment.', + body: CreateEnvironmentSchema, + responses: { + 201: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listEnvironmentsV2: { + method: 'GET', + path: '', + summary: 'Get environments', + description: 'Retrieved project environments.', + responses: { + 200: EnvironmentSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateEnvironmentV2: { + method: 'PUT', + path: '/:environmentId', + summary: 'Update environment', + description: 'Update an environment by its ID.', + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + body: UpdateEnvironmentSchema, + responses: { + 200: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteEnvironmentV2: { + method: 'DELETE', + path: '/:environmentId', + summary: 'Delete environment', + description: 'Delete an environment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefixV2}/projects/:projectId/environments`, + pathParams: z.object({ + projectId: z.string().uuid(), + }), +}) diff --git a/.jjconflict-side-1/packages/shared/src/contracts/index.ts b/.jjconflict-side-1/packages/shared/src/contracts/index.ts new file mode 100644 index 0000000000..c5c88fab16 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/index.ts @@ -0,0 +1,18 @@ +export * from './admin-role.js' +export * from './admin-token.js' +export * from './cluster.js' +export * from './deployment.js' +export * from './environment.js' +export * from './log.js' +export * from './personal-access-token.js' +export * from './project-member.js' +export * from './project-role.js' +export * from './project-service.js' +export * from './project.js' +export * from './repository.js' +export * from './service-chain.js' +export * from './service-monitor.js' +export * from './stage.js' +export * from './system.js' +export * from './user.js' +export * from './zone.js' diff --git a/.jjconflict-side-1/packages/shared/src/contracts/log.ts b/.jjconflict-side-1/packages/shared/src/contracts/log.ts new file mode 100644 index 0000000000..062f2b3adc --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/log.ts @@ -0,0 +1,43 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import type Zod from 'zod' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { CleanLogSchema, LogSchema } from '../schemas/log.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminLogsQuery = z.object({ + offset: z.coerce.number(), + limit: z.coerce.number(), + projectId: z.string().optional(), + clean: CoerceBooleanSchema.default(true), +}) +export type AdminLogsQuery = Zod.infer + +export const logContract = contractInstance.router({ + getLogs: { + method: 'GET', + path: '', + query: adminLogsQuery, + summary: 'Get logs', + description: 'Retrieved all logs.', + responses: { + 200: z.object({ + total: z.number(), + logs: LogSchema.array().or(CleanLogSchema.array()), + }), + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/logs`, +}) + +export type GetLogsQuery = ClientInferRequest['query'] + +export type Log = Zod.infer +export type CleanLog = Zod.infer diff --git a/.jjconflict-side-1/packages/shared/src/contracts/personal-access-token.ts b/.jjconflict-side-1/packages/shared/src/contracts/personal-access-token.ts new file mode 100644 index 0000000000..72e9012596 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/personal-access-token.ts @@ -0,0 +1,49 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ExposedPersonalAccessTokenSchema, PersonalAccessTokenSchema } from '../schemas/token.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const personalAccessTokenContract = contractInstance.router({ + listPersonalAccessTokens: { + method: 'GET', + path: '', + responses: { + 200: PersonalAccessTokenSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createPersonalAccessToken: { + method: 'POST', + path: '', + body: PersonalAccessTokenSchema.pick({ name: true, expirationDate: true }).required(), + responses: { + 201: ExposedPersonalAccessTokenSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deletePersonalAccessToken: { + method: 'DELETE', + path: `/:tokenId`, + pathParams: z.object({ tokenId: z.string().uuid() }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/user/tokens`, +}) diff --git a/.jjconflict-side-1/packages/shared/src/contracts/project-member.ts b/.jjconflict-side-1/packages/shared/src/contracts/project-member.ts new file mode 100644 index 0000000000..ac9797495f --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/project-member.ts @@ -0,0 +1,68 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { MemberSchema } from '../schemas/user.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const projectMemberContract = contractInstance.router({ + listMembers: { + method: 'GET', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + addMember: { + method: 'POST', + path: '', + body: z.object({ email: z.string() }).or(z.object({ userId: z.string() })), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 201: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + patchMembers: { + method: 'PATCH', + path: '', + body: z.object({ + userId: z.string().uuid(), + roles: z.string().uuid().array(), + }).array(), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + removeMember: { + method: 'DELETE', + path: `/:userId`, + pathParams: z.object({ + projectId: z.string().uuid(), + userId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/members`, +}) diff --git a/.jjconflict-side-1/packages/shared/src/contracts/project-role.ts b/.jjconflict-side-1/packages/shared/src/contracts/project-role.ts new file mode 100644 index 0000000000..b909a994db --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/project-role.ts @@ -0,0 +1,80 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ProjectRoleSchema } from '../schemas/role.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const projectRoleContract = contractInstance.router({ + listProjectRoles: { + method: 'GET', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + createProjectRole: { + method: 'POST', + path: '', + body: ProjectRoleSchema.omit({ position: true, id: true, projectId: true }), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + // 200: z.any(), + 201: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + patchProjectRoles: { + method: 'PATCH', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + // body: z.any(), + body: ProjectRoleSchema.pick({ id: true }).merge(ProjectRoleSchema.omit({ id: true, projectId: true }).partial()).array(), + responses: { + // 200: z.any(), + 200: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + projectRoleMemberCounts: { + method: 'GET', + path: `/member-counts`, + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: z.record(z.number().min(0)), // Record + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + deleteProjectRole: { + method: 'DELETE', + path: `/:roleId`, + pathParams: z.object({ + projectId: z.string().uuid(), + roleId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/roles`, +}) diff --git a/.jjconflict-side-1/packages/shared/src/contracts/project-service.ts b/.jjconflict-side-1/packages/shared/src/contracts/project-service.ts new file mode 100644 index 0000000000..a0545d06b2 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/project-service.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { pluginUpdateBody } from '../schemas/config.js' +import { permissionTarget, ServiceSchema } from '../schemas/services.js' +import { baseHeaders, ErrorSchema } from './_utils.js' +import { ProjectParams } from './project.js' + +export const projectServiceContract = contractInstance.router({ + getServices: { + method: 'GET', + path: '', + summary: 'Get Project\'s services', + description: 'Get all informations about services related to a project.', + query: z.object({ permissionTarget }), + pathParams: ProjectParams, + responses: { + 200: ServiceSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateProjectServices: { + method: 'POST', + path: '', + summary: 'Update project service configuration', + description: 'Update project service configuration', + pathParams: ProjectParams, + body: pluginUpdateBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/services`, +}) diff --git a/.jjconflict-side-1/packages/shared/src/contracts/project.ts b/.jjconflict-side-1/packages/shared/src/contracts/project.ts new file mode 100644 index 0000000000..8532849b4e --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/project.ts @@ -0,0 +1,184 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ProjectSchemaV2 } from '../schemas/project.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const ProjectParams = z.object({ + projectId: z.string().regex(/[a-z0-9-]*/), // uuid or slug like +}) + +export const projectContract = contractInstance.router({ + createProject: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create project', + description: 'Create a new project.', + body: ProjectSchemaV2.pick({ + name: true, + description: true, + limitless: true, + hprodCpu: true, + hprodGpu: true, + hprodMemory: true, + prodCpu: true, + prodGpu: true, + prodMemory: true, + }), + responses: { + 201: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + bulkActionProject: { + method: 'POST', + path: '-bulk', + contentType: 'application/json', + summary: 'Perform bulk action on projects', + description: 'Perform bulk action on projects.', + body: z.object({ + action: z.enum(['archive', 'lock', 'unlock', 'replay']), + projectIds: z.string().uuid().array().or(z.literal('all')), + }), + responses: { + 202: null, + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getProject: { + method: 'GET', + path: '/:projectId', + pathParams: ProjectParams, + summary: 'Get a project', + description: 'Get a project', + responses: { + 200: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listProjects: { + method: 'GET', + path: '', + query: ProjectSchemaV2 + .pick({ + id: true, + name: true, + status: true, + locked: true, + description: true, + lastSuccessProvisionningVersion: true, + }) + .extend({ + statusIn: z.string(), + statusNotIn: z.string(), + filter: z.enum(['owned', 'member', 'all']), + search: z.string(), + }) + .partial(), + summary: 'Get projects', + description: 'Get projects with filters', + responses: { + 200: ProjectSchemaV2.omit({ + name: true, + }).extend({ name: z.string() }).array(), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getProjectSecrets: { + method: 'GET', + path: `/:projectId/secrets`, + summary: 'Get project secrets', + description: 'Retrieved a project secrets.', + pathParams: ProjectParams, + responses: { + 200: z.record(z.record(z.string())), + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateProject: { + method: 'PUT', + path: `/:projectId`, + summary: 'Update project', + description: 'Update a project.', + pathParams: ProjectParams, + body: ProjectSchemaV2 + .pick({ + description: true, + everyonePerms: true, + locked: true, + ownerId: true, + limitless: true, + hprodMemory: true, + hprodCpu: true, + hprodGpu: true, + prodMemory: true, + prodCpu: true, + prodGpu: true, + }) + .partial(), + responses: { + 200: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 500: ErrorSchema, + }, + }, + + replayHooksForProject: { + method: 'PUT', + path: `/:projectId/hooks`, + summary: 'Replay hooks for project', + description: 'Replay hooks for a project.', + body: ContractNoBody, + pathParams: ProjectParams, + responses: { + 204: null, + 500: ErrorSchema, + }, + }, + + archiveProject: { + method: 'DELETE', + path: `/:projectId`, + summary: 'Delete project', + description: 'Delete a project.', + pathParams: ProjectParams, + body: ContractNoBody, + responses: { + 204: null, + 500: ErrorSchema, + }, + }, + + getProjectsData: { + method: 'GET', + path: `/data`, + summary: 'Download projects csv report', + description: 'Retrieve all projects data for download as CSV file.', + responses: { + 200: z.string(), + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects`, +}) + +export type CreateProjectBody = ClientInferRequest['body'] diff --git a/.jjconflict-side-1/packages/shared/src/contracts/repository.ts b/.jjconflict-side-1/packages/shared/src/contracts/repository.ts new file mode 100644 index 0000000000..131deee00e --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/repository.ts @@ -0,0 +1,107 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { RepoSchema } from '../schemas/repository.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const repositoryContract = contractInstance.router({ + createRepository: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create repo', + description: 'Create new repo.', + body: RepoSchema.omit({ id: true, createdAt: true, updatedAt: true }), + responses: { + 201: RepoSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listRepositories: { + method: 'GET', + path: '', + summary: 'Get repos', + description: 'Retrieved all repos.', + query: z.object({ + projectId: z.string() + .uuid(), + }), + responses: { + 200: z.array(RepoSchema + .omit({ internalRepoName: true }) + .extend({ internalRepoName: z.string() }), + ), + 500: ErrorSchema, + }, + }, + + syncRepository: { + method: 'POST', + path: `/:repositoryId/sync`, + summary: 'application/json', + description: 'Trigger a gitlab synchronization for a repository', + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + body: z.object({ + syncAllBranches: z.boolean(), + branchName: z.string().optional(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateRepository: { + method: 'PUT', + path: `/:repositoryId`, + summary: 'Update repo', + description: 'Update a repo by its ID.', + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + body: RepoSchema.omit({ createdAt: true, updatedAt: true }).partial(), + responses: { + 200: RepoSchema, + 500: ErrorSchema, + }, + }, + + deleteRepository: { + method: 'DELETE', + path: `/:repositoryId`, + summary: 'Delete repo', + description: 'Delete a repo by its ID.', + body: ContractNoBody, + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/repositories`, +}) + +export type CreateRepositoryBody = ClientInferRequest['body'] + +export type UpdateRepositoryBody = ClientInferRequest['body'] + +export type SyncRepositoryParams = ClientInferRequest['params'] + +export type RepositoryParams = ClientInferRequest['params'] diff --git a/.jjconflict-side-1/packages/shared/src/contracts/service-chain.ts b/.jjconflict-side-1/packages/shared/src/contracts/service-chain.ts new file mode 100644 index 0000000000..e98f864db8 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/service-chain.ts @@ -0,0 +1,99 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainSchema, +} from '../schemas/index.js' +import { baseHeaders, EmptySchema, ErrorSchema } from './_utils.js' + +export const ServiceChainParams = z.object({ + serviceChainId: ServiceChainSchema.shape.id, +}) + +export const ServiceChainValidationParams = z.object({ + validationId: ServiceChainDetailsSchema.shape.validationId, +}) + +export const serviceChainContract = contractInstance.router( + { + listServiceChains: { + method: 'GET', + path: '', + summary: 'Get Service Chains', + description: 'Retrieve Service Chains authorized for user', + responses: { + 200: z.array(ServiceChainSchema), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getServiceChainDetails: { + method: 'GET', + path: `/:serviceChainId`, + summary: 'Get Service Chain details', + description: 'Retrieved details of a Service Chain.', + pathParams: ServiceChainParams, + responses: { + 200: ServiceChainDetailsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + retryServiceChain: { + method: 'POST', + path: `/:serviceChainId/retry`, + summary: 'Retry Service Chain creation', + description: + 'Retry the whole service chain creation from the point it failed.', + pathParams: ServiceChainParams, + body: ContractNoBody, + responses: { + 204: null, + 401: ErrorSchema, + 404: ErrorSchema, + 429: EmptySchema, // Already successful, no retry needed + 500: ErrorSchema, + }, + }, + + validateServiceChain: { + method: 'POST', + path: `/validate/:validationId`, + summary: 'Validate Service Chain creation', + description: 'Trigger the whole service chain creation.', + pathParams: ServiceChainValidationParams, + body: ContractNoBody, + responses: { + 204: null, + 401: ErrorSchema, + 404: ErrorSchema, + 429: EmptySchema, // Validation already done + 500: ErrorSchema, + }, + }, + + getServiceChainFlows: { + method: 'GET', + path: `/:serviceChainId/flows`, + summary: 'Get Service Chain flow details', + description: 'Retrieved flow details of a Service Chain.', + pathParams: ServiceChainParams, + responses: { + 200: ServiceChainFlowsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + }, + { + baseHeaders, + pathPrefix: `${apiPrefix}/service-chains`, + }, +) diff --git a/.jjconflict-side-1/packages/shared/src/contracts/service-monitor.ts b/.jjconflict-side-1/packages/shared/src/contracts/service-monitor.ts new file mode 100644 index 0000000000..c2b02e304b --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/service-monitor.ts @@ -0,0 +1,51 @@ +import type { ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ServiceHealthSchema } from '../schemas/services.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const serviceContract = contractInstance.router({ + getServiceHealth: { + method: 'GET', + path: '/health-services', + summary: 'Get services health', + description: 'Retrieve services health.', + responses: { + 200: ServiceHealthSchema.array(), + 500: ErrorSchema, + }, + }, + + getCompleteServiceHealth: { + method: 'GET', + path: '/complete-services', + summary: 'Get services health with cause', + description: 'Retrieve services health with cause.', + responses: { + 200: ServiceHealthSchema.extend({ + cause: z.any().optional(), + }).array(), + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + refreshServiceHealth: { + method: 'GET', + path: '/refresh-services', + summary: 'Force services health', + description: 'Retrieved services health.', + responses: { + 200: ServiceHealthSchema.array(), + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}`, +}) + +export type ServiceBody = ClientInferResponseBody diff --git a/.jjconflict-side-1/packages/shared/src/contracts/stage.ts b/.jjconflict-side-1/packages/shared/src/contracts/stage.ts new file mode 100644 index 0000000000..a8f20f62cf --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/stage.ts @@ -0,0 +1,95 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { StageSchema } from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const stageContract = contractInstance.router({ + listStages: { + method: 'GET', + path: '', + summary: 'Get stages', + description: 'Retrieved all stages.', + responses: { + 200: StageSchema.array(), + 500: ErrorSchema, + }, + }, + + createStage: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create stage', + description: 'Create new stage.', + body: StageSchema.omit({ id: true }).partial({ clusterIds: true }), + responses: { + 201: StageSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getStageEnvironments: { + method: 'GET', + path: `/:stageId/environments`, + summary: 'Get stages', + description: 'Retrieved all stages.', + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + responses: { + 200: z.array(z.object({ + project: z.string(), + name: z.string(), + cluster: z.string(), + owner: z.string().optional(), + })), + 500: ErrorSchema, + }, + }, + + updateStage: { + method: 'PUT', + path: `/:stageId`, + summary: 'Update stage', + description: 'Update a stage by its ID.', + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + body: StageSchema.pick({ clusterIds: true, name: true }), + responses: { + 200: StageSchema, + 500: ErrorSchema, + }, + }, + + deleteStage: { + method: 'DELETE', + path: `/:stageId`, + summary: 'Delete stage', + description: 'Delete a stage by its ID.', + body: ContractNoBody, + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/stages`, +}) + +export type CreateStageBody = ClientInferRequest['body'] + +export type UpdateStageBody = ClientInferRequest['body'] + +export type StageAssociatedEnvironments = ClientInferResponseBody diff --git a/.jjconflict-side-1/packages/shared/src/contracts/system.ts b/.jjconflict-side-1/packages/shared/src/contracts/system.ts new file mode 100644 index 0000000000..b5c77864fe --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/system.ts @@ -0,0 +1,111 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + pluginSchema, + pluginUpdateBody, + SystemSettingSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const systemContract = contractInstance.router({ + getVersion: { + method: 'GET', + path: `/version`, + summary: 'Get version', + description: 'Retrieve api version.', + responses: { + 200: z.object({ + version: z.string(), + }), + 500: ErrorSchema, + }, + }, + + getHealth: { + method: 'GET', + path: `/healthz`, + summary: 'Get health', + description: 'Retrieve api health infos.', + responses: { + 200: z.object({ + status: z.enum(['OK', 'KO']), + }), + 500: ErrorSchema, + }, + }, +}, { + pathPrefix: `${apiPrefix}`, +}) + +export const systemPluginContract = contractInstance.router({ + getPluginsConfig: { + method: 'GET', + path: '', + summary: 'Get plugins configuration', + description: 'Get plugins configuration', + responses: { + 200: pluginSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updatePluginsConfig: { + method: 'POST', + path: '', + summary: 'Update project service configuration', + description: 'Update project service configuration', + body: pluginUpdateBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/system/plugins`, +}) + +export const systemSettingsContract = contractInstance.router({ + listSystemSettings: { + method: 'GET', + path: '', + summary: 'Get System Settings state', + description: 'Get System Settings state', + query: SystemSettingSchema.pick({ key: true }) + .partial(), + responses: { + 200: SystemSettingSchema.array(), + 500: ErrorSchema, + }, + }, + + upsertSystemSetting: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Update System Settings state', + description: 'Update System Settings state', + body: SystemSettingSchema, + responses: { + 201: SystemSettingSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/system/settings`, +}) + +export type SystemSettings = ClientInferResponseBody + +export type SystemSetting = SystemSettings[number] + +export type UpsertSystemSettingBody = ClientInferRequest['body'] diff --git a/.jjconflict-side-1/packages/shared/src/contracts/user.ts b/.jjconflict-side-1/packages/shared/src/contracts/user.ts new file mode 100644 index 0000000000..508ef91171 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/user.ts @@ -0,0 +1,83 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { UuidOrCsvUuidSchema } from '../schemas/_utils.js' +import { + RoleNameCsvSchema, + UserSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const userContract = contractInstance.router({ + getMatchingUsers: { + method: 'GET', + path: `${apiPrefix}/users/matching`, + query: z.object({ + letters: z.string(), + notInProjectId: z.string().uuid().optional(), + }), + summary: 'Get users by letters matching', + description: 'Retrieved users by letters matching.', + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + auth: { + method: 'GET', + path: `${apiPrefix}/auth`, + summary: 'Login', + description: 'OIDC callback to signin or signup', + responses: { + 200: UserSchema, + 307: null, + 500: ErrorSchema, + }, + }, + + getAllUsers: { + method: 'GET', + path: `${apiPrefix}/users`, + summary: 'Get all users', + description: 'Get all users.', + query: z.object({ + adminRoles: RoleNameCsvSchema + .optional(), + adminRoleIds: UuidOrCsvUuidSchema + .optional(), + memberOfIds: UuidOrCsvUuidSchema + .optional(), + relationType: z.enum(['OR', 'AND']) + .optional(), + }), + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + patchUsers: { + method: 'PATCH', + path: `${apiPrefix}/users`, + summary: 'Patch users', + body: UserSchema.pick({ adminRoleIds: true, id: true }).array(), + description: 'Update user admin role.', + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, +}) + +export type LettersQuery = ClientInferRequest['query'] + +export type AllUsers = ClientInferResponseBody diff --git a/.jjconflict-side-1/packages/shared/src/contracts/zone.ts b/.jjconflict-side-1/packages/shared/src/contracts/zone.ts new file mode 100644 index 0000000000..90a300ba5f --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/contracts/zone.ts @@ -0,0 +1,83 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + ZoneSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const zoneContract = contractInstance.router({ + listZones: { + method: 'GET', + path: '', + summary: 'Get zones', + description: 'Get all zones.', + responses: { + 200: ZoneSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + createZone: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create zone', + description: 'Create new zone.', + body: ZoneSchema + .omit({ id: true }) + .extend({ clusterIds: z.string().uuid().array().optional() }), + responses: { + 201: ZoneSchema, + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateZone: { + method: 'PUT', + path: `/:zoneId`, + summary: 'Update zone', + description: 'Update a zone by its ID.', + pathParams: z.object({ + zoneId: z.string() + .uuid(), + }), + body: ZoneSchema.omit({ id: true, slug: true }), + responses: { + 201: ZoneSchema, + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteZone: { + method: 'DELETE', + path: `/:zoneId`, + summary: 'Delete zone', + description: 'Delete a zone by its ID.', + body: ContractNoBody, + pathParams: z.object({ + zoneId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/zones`, +}) + +export type CreateZoneBody = ClientInferRequest['body'] +export type UpdateZoneBody = ClientInferRequest['body'] diff --git a/.jjconflict-side-1/packages/shared/src/index.ts b/.jjconflict-side-1/packages/shared/src/index.ts new file mode 100644 index 0000000000..8cc07ea318 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/index.ts @@ -0,0 +1,4 @@ +export * from './api-client.js' +export * from './contracts/index.js' +export * from './schemas/index.js' +export * from './utils/index.js' diff --git a/.jjconflict-side-1/packages/shared/src/schemas/_utils.ts b/.jjconflict-side-1/packages/shared/src/schemas/_utils.ts new file mode 100644 index 0000000000..91cea1b16a --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/_utils.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +export const CoerceBooleanSchema = z.boolean() + .or(z.enum(['true', 'false']) + .transform(value => value === 'true')) + +export const dateToString = z.string().or(z.date().transform(date => date.toISOString())) + +export const AtDatesToStringExtend = { + updatedAt: dateToString, + createdAt: dateToString, +} +export const permissionLevelSchema = z.coerce.string() + +export const UuidOrCsvUuidSchema = z.string() + .refine((value) => { + return !value + .split(',') + .some(uuid => !z.string().uuid().safeParse(uuid).success) + }) + .transform(value => value.split(',')) diff --git a/.jjconflict-side-1/packages/shared/src/schemas/cluster.ts b/.jjconflict-side-1/packages/shared/src/schemas/cluster.ts new file mode 100644 index 0000000000..bac22d02c0 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/cluster.ts @@ -0,0 +1,71 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const ClusterPrivacySchema = z.enum(['public', 'dedicated']) + +export const CleanedClusterSchema = z.object({ + id: z.string() + .uuid(), + label: z.string() + .regex(/^[a-z0-9-]+$/i) + .max(50), + infos: z.string() + .max(1000) + .optional() + .nullable() + .transform(value => value ?? ''), + clusterResources: z.boolean(), + privacy: ClusterPrivacySchema, + zoneId: z.string() + .uuid(), + stageIds: z.string() + .uuid() + .array(), + cpu: z.coerce.number().gte(0), + gpu: z.coerce.number().gte(0), + memory: z.coerce.number().gte(0), +}) + +export const KubeconfigSchema = z.object({ + user: z.object({ + username: z.string() + .optional(), + password: z.string() + .optional(), + keyData: z.string() + .optional(), + certData: z.string() + .optional(), + token: z.string() + .optional(), + }), + cluster: z.object({ + server: z.string() + .optional(), + tlsServerName: z.string(), + skipTLSVerify: z.boolean() + .optional(), + caData: z.string() + .optional(), + }), +}) + +export const ClusterDetailsSchema = CleanedClusterSchema.merge(z.object({ + projectIds: z.string() + .uuid() + .array() + .optional(), + kubeconfig: KubeconfigSchema, +})) + +export const ClusterUsageSchema = z.object({ + cpu: z.number(), + gpu: z.number(), + memory: z.number(), +}) + +export type Cluster = Zod.infer +export type ClusterDetails = Zod.infer +export type Kubeconfig = Zod.infer + +export type CleanedCluster = Zod.infer diff --git a/.jjconflict-side-1/packages/shared/src/schemas/config.ts b/.jjconflict-side-1/packages/shared/src/schemas/config.ts new file mode 100644 index 0000000000..cca95debd8 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/config.ts @@ -0,0 +1,84 @@ +import type Zod from 'zod' +import { z } from 'zod' + +const configItemBase = z.object({ + key: z.string().min(2).regex(/[a-z-]/), + description: z.string().optional(), + title: z.string(), + section: z.string().optional(), +}) + +const projectVisibilty = z.object({ + permissions: z.object({ + user: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + admin: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + }), +}) + +const globalVisibilty = z.object({ + permissions: z.object({ + user: z.object({ + read: z.boolean(), + write: z.boolean().refine(value => !value, { message: 'global params can\'t be user writable' }), + }), + admin: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + }), +}) + +export const ENABLED = 'enabled' +export const DISABLED = 'disabled' +export const DEFAULT = 'default' + +export const atomicValidators = { + switch: z.enum([DISABLED, DEFAULT, ENABLED]), + // eslint-disable-next-line regexp/prefer-w, regexp/use-ignore-case + text: z.string().trim().regex(/[a-zA-Z-_0-9 ]*/), +} + +export type SwitchParam = Zod.infer + +const configItemSwitch = z.object({ + kind: z.literal('switch'), + value: atomicValidators.switch, + initialValue: atomicValidators.switch, +}).merge(configItemBase) + +const configItemInput = z.object({ + kind: z.literal('text'), + value: atomicValidators.text, + placeholder: z.string().optional(), +}).merge(configItemBase) + +const configGlobalItemDeclaration = z.discriminatedUnion('kind', [ + configItemSwitch.merge(globalVisibilty), + configItemInput.merge(globalVisibilty), +]) + +export type PluginConfigItem = Zod.infer + +export const configProjectItemDeclaration = z.discriminatedUnion('kind', [ + configItemSwitch.merge(projectVisibilty), + configItemInput.merge(projectVisibilty), +]) + +export const pluginConfig = z.object({ + global: configGlobalItemDeclaration.array(), + project: configProjectItemDeclaration.array(), +}) + +export type PluginConfig = Zod.infer + +export const pluginsPopulatedManifests = pluginConfig.partial() +export type PluginsPopulatedManifests = Zod.infer + +export const pluginUpdateBody = z.record(z.record(z.string())) +export type PluginsUpdateBody = Zod.infer diff --git a/.jjconflict-side-1/packages/shared/src/schemas/deployment.ts b/.jjconflict-side-1/packages/shared/src/schemas/deployment.ts new file mode 100644 index 0000000000..b1e0f8ee47 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/deployment.ts @@ -0,0 +1,70 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestDeploymentName } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' +import { EnvironmentSchema } from './environment.js' +import { RepoSchema } from './repository.js' + +const DeploymentSourceType = z.enum(['git', 'oci']) + +export const DeploymentSourceSchema = z.object({ + id: z.string() + .uuid(), + deploymentId: z.string() + .uuid(), + repositoryId: z.string() + .uuid(), + type: DeploymentSourceType, + repository: RepoSchema, + // Optional deployment settings + targetRevision: z.string().optional(), + path: z.string().optional(), + helmValuesFiles: z.string().optional(), +}).extend(AtDatesToStringExtend) + +export const DeploymentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestDeploymentName), + projectId: z.string() + .uuid(), + environmentId: z.string() + .uuid(), + autosync: z.boolean(), + environment: EnvironmentSchema, + deploymentSources: DeploymentSourceSchema.array(), +}).extend(AtDatesToStringExtend) + +export const CreateDeploymentSchema = DeploymentSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + environment: true, +}).extend({ + deploymentSources: DeploymentSourceSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deploymentId: true, + repository: true, + }).array().min(1, 'Au moins une source de déploiement est requise'), +}) + +export const UpdateDeploymentSchema = CreateDeploymentSchema.extend({ + deploymentSources: DeploymentSourceSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deploymentId: true, + repository: true, + }) + .extend({ id: z.string().uuid().optional() }) + .array().min(1, 'Au moins une source de déploiement est requise'), +}) + +export type Deployment = Zod.infer +export type CreateDeployment = Zod.infer +export type UpdateDeployment = Zod.infer diff --git a/.jjconflict-side-1/packages/shared/src/schemas/environment.ts b/.jjconflict-side-1/packages/shared/src/schemas/environment.ts new file mode 100644 index 0000000000..f3c9bd2d8c --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/environment.ts @@ -0,0 +1,40 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestEnvironmentName } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' + +export const EnvironmentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestEnvironmentName), + projectId: z.string() + .uuid(), + stageId: z.string().uuid(), + clusterId: z.string() + .uuid(), + cpu: z.coerce.number().gte(0), + gpu: z.coerce.number().gte(0), + memory: z.coerce.number().gte(0), + autosync: z.boolean(), +}).extend(AtDatesToStringExtend) + +export const CreateEnvironmentSchema = EnvironmentSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, +}) + +export const UpdateEnvironmentSchema = EnvironmentSchema.pick({ + cpu: true, + gpu: true, + memory: true, + autosync: true, +}) + +export type Environment = Zod.infer +export type CreateEnvironment = Zod.infer +export type UpdateEnvironment = Zod.infer diff --git a/.jjconflict-side-1/packages/shared/src/schemas/index.ts b/.jjconflict-side-1/packages/shared/src/schemas/index.ts new file mode 100644 index 0000000000..236c4124b2 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/index.ts @@ -0,0 +1,15 @@ +export * from './cluster.js' +export * from './config.js' +export * from './deployment.js' +export * from './environment.js' +export * from './log.js' +export * from './project.js' +export * from './repository.js' +export * from './role.js' +export * from './service-chain.js' +export * from './services.js' +export * from './stage.js' +export * from './system.js' +export * from './token.js' +export * from './user.js' +export * from './zone.js' diff --git a/.jjconflict-side-1/packages/shared/src/schemas/log.ts b/.jjconflict-side-1/packages/shared/src/schemas/log.ts new file mode 100644 index 0000000000..a58f2777f2 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/log.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import { AtDatesToStringExtend } from './_utils.js' + +export const CleanLogSchema = z.object({ + id: z.string(), + data: z.object({ + failed: z.boolean().or(z.array(z.string())).optional(), + warning: z.string().array().optional(), + totalExecutionTime: z.number().optional(), + messageResume: z.string().optional(), + }).strip(), + action: z.string(), + userId: z.string().nullable(), +}) + .extend(AtDatesToStringExtend) + +export const LogSchema = z.object({ + id: z.string(), + data: z.object({ + args: z.any(), + failed: z.boolean().or(z.array(z.string())).optional(), + warning: z.string().array().optional(), + results: z.any(), + totalExecutionTime: z.number().optional(), + messageResume: z.string().optional(), + }) + .passthrough() + .transform((data) => { + delete data.config + return data + }), + action: z.string(), + userId: z.string().nullable(), + requestId: z.string().nullable(), + projectId: z.string().nullable(), +}) + .extend(AtDatesToStringExtend) diff --git a/.jjconflict-side-1/packages/shared/src/schemas/project.ts b/.jjconflict-side-1/packages/shared/src/schemas/project.ts new file mode 100644 index 0000000000..6f4e549f78 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/project.ts @@ -0,0 +1,87 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestEnvironmentName, projectStatus } from '../utils/const.js' +import { AtDatesToStringExtend, CoerceBooleanSchema, permissionLevelSchema } from './_utils.js' +import { RepoSchema } from './repository.js' +import { ProjectRoleSchema } from './role.js' +import { MemberSchema, UserSchema } from './user.js' + +export const descriptionMaxLength = 280 +export const projectNameMaxLength = 20 +export const ProjectStatusSchema = z.enum(projectStatus) + +const ProjectEnvironmentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestEnvironmentName), + slug: z.string() + .regex(/^[a-z0-9]+$/), + projectId: z.string() + .uuid(), + stageId: z.string().uuid(), + clusterId: z.string() + .uuid(), + permissions: z.array(z.object({ + id: z.string() + .uuid(), + userId: z.string() + .uuid(), + environmentId: z.string() + .uuid(), + level: z.union([ + z.string(), + z.number() + .int() + .nonnegative() + .max(2), + ]), + })), +}) +export const ProjectSchema = z.object({ + // ProjectInfos + repositories: RepoSchema.array(), + environments: ProjectEnvironmentSchema.array(), +}) + +export type Project = Zod.infer + +export const ProjectSchemaV2 = z.object({ + clusterIds: z.string().uuid().array(), + name: z.string() + .min(2) + .max(projectNameMaxLength) + .regex(/^[a-z0-9]+$/), + slug: z.string(), + description: z.string() + .max(descriptionMaxLength) + .optional(), + status: ProjectStatusSchema, + locked: CoerceBooleanSchema, + limitless: CoerceBooleanSchema, + hprodCpu: z.coerce.number().gte(0), + hprodGpu: z.coerce.number().gte(0), + hprodMemory: z.coerce.number().gte(0), + prodCpu: z.coerce.number().gte(0), + prodGpu: z.coerce.number().gte(0), + prodMemory: z.coerce.number().gte(0), + + id: z.string() + .uuid(), + members: MemberSchema + .array(), + ownerId: z.string() + .uuid(), + owner: UserSchema + .omit({ adminRoleIds: true }), + roles: ProjectRoleSchema + .array(), + everyonePerms: permissionLevelSchema, + lastSuccessProvisionningVersion: z.string() + .nullable(), +}) + .extend(AtDatesToStringExtend) + +export type ProjectV2 = Zod.infer diff --git a/.jjconflict-side-1/packages/shared/src/schemas/repository.ts b/.jjconflict-side-1/packages/shared/src/schemas/repository.ts new file mode 100644 index 0000000000..85675b9123 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/repository.ts @@ -0,0 +1,74 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { forbiddenRepoNames, invalidGitUrl, invalidInternalRepoName, missingCredentials } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' + +export const RepoSchema = z.object({ + id: z.string() + .uuid(), + internalRepoName: z.string() + .min(2, { message: 'Longueur minimum 2 caractères' }) + .max(20, { message: 'Longueur maximum 20 caractères' }) + .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, { message: invalidInternalRepoName }) + .refine(name => !forbiddenRepoNames.includes(name.toLowerCase()), { + message: `Le nom du dépôt choisi est inclus dans la liste d'exclusion: ${forbiddenRepoNames.join(', ')}`, + }), + externalRepoUrl: z.string() + .regex(/^https:\/\/.*\.git$/, { message: invalidGitUrl }) + .url({ message: 'Url invalide' }) + .or(z.literal('')) + .optional(), + isPrivate: z.boolean(), + isInfra: z.boolean(), + externalUserName: z.string() + .optional(), + externalToken: z.string() + .optional(), + projectId: z.string() + .uuid(), + // Optional deployment settings for infra repositories + deployRevision: z.string().optional(), + deployPath: z.string().optional(), + helmValuesFiles: z.string().optional(), +}).extend(AtDatesToStringExtend) + +// To only use in frontend form +export const RepoFormSchema = RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .extend({ isStandalone: z.boolean() }) + +export const UpdateRepoFormSchema = RepoFormSchema + .refine( + ({ isPrivate, externalToken, externalUserName }) => { + if (isPrivate) { + if (!externalToken && !externalUserName) return false + return true + } + return true + }, + { message: missingCredentials, path: ['credentials'] }, + ) + .refine(({ isStandalone, externalRepoUrl }) => { + if (!isStandalone && !externalRepoUrl) { + return false + } + return true + }, { message: 'Veuillez renseignez l\'url du dépôt externe', path: ['externalRepoUrl'] }) + +export const CreateRepoFormSchema = RepoFormSchema + .omit({ id: true, projectId: true }) + .refine(({ isPrivate, externalToken, externalUserName }) => { + if (isPrivate) { + if (!externalToken && !externalUserName) return false + return true + } + return true + }, { message: missingCredentials, path: ['credentials'] }) + .refine(({ isStandalone, externalRepoUrl }) => { + if (!isStandalone && !externalRepoUrl) { + return false + } + return true + }, { message: 'Veuillez renseignez l\'url du dépôt externe', path: ['externalRepoUrl'] }) + +export type Repo = Zod.infer diff --git a/.jjconflict-side-1/packages/shared/src/schemas/role.ts b/.jjconflict-side-1/packages/shared/src/schemas/role.ts new file mode 100644 index 0000000000..0e0a1d226a --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/role.ts @@ -0,0 +1,35 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { permissionLevelSchema } from './_utils.js' + +export const RoleNameSchema = z.string().max(30) + +export const RoleSchema = z.object({ + id: z.string().uuid(), + name: RoleNameSchema, + permissions: permissionLevelSchema, + position: z.number().min(0), + type: z.string().optional(), +}) + +export const ProjectRoleSchema = RoleSchema.extend({ + projectId: z.string().uuid(), + oidcGroup: z.string().optional(), +}) + +export const AdminRoleSchema = RoleSchema.extend({ + oidcGroup: z.string(), + type: z.string().optional().default('managed'), +}) + +export const RoleNameCsvSchema = z.string() + .refine((value) => { + return !value.split(',').some(name => !RoleNameSchema.safeParse(name).success) + }) + .transform(value => value.split(',')) + +export type Role = Zod.infer +export type RoleBigint = Omit, 'permissions'> & { permissions: bigint } +export type AdminRole = Zod.infer +export type ProjectRole = Zod.infer +export type ProjectRoleBigint = Omit, 'permissions'> & { permissions: bigint } diff --git a/.jjconflict-side-1/packages/shared/src/schemas/service-chain.ts b/.jjconflict-side-1/packages/shared/src/schemas/service-chain.ts new file mode 100644 index 0000000000..c364fcb8d0 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/service-chain.ts @@ -0,0 +1,103 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const serviceChainStateEnum = ['opened', 'pending', 'success', 'failed'] as const +export const ServiceChainStateZodEnum = z.enum(serviceChainStateEnum) +export type ServiceChainState = Zod.infer + +export const serviceChainNetworkEnum = ['RIE', 'INTERNET'] as const +export const ServiceChainNetworkZodEnum = z.enum(serviceChainNetworkEnum) +export type ServiceChainNetwork = Zod.infer + +export const serviceChainLocationEnum = ['SIR', 'SIL'] as const +export const ServiceChainLocationZodEnum = z.enum(serviceChainLocationEnum) +export type ServiceChainLocation = Zod.infer + +export const serviceChainEnvironmentEnum = ['INT', 'PROD', 'BAS'] as const +export const ServiceChainEnvironmentZodEnum = z.enum(serviceChainEnvironmentEnum) +export type ServiceChainEnvironment = Zod.infer< + typeof ServiceChainEnvironmentZodEnum +> + +export const ServiceChainSchema = z.object({ + id: z.string().uuid(), + state: ServiceChainStateZodEnum, + commonName: + // @TODO: Replace with z.hostname when upgraded to Zod v4 + z.string(), + pai: z.string(), + network: ServiceChainNetworkZodEnum, + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}) +export type ServiceChain = Zod.infer + +export const ServiceChainListSchema = z.array(ServiceChainSchema) +export type ServiceChainList = Zod.infer + +export const ServiceChainDetailsSchema = ServiceChainSchema.extend({ + validationId: z.string().uuid(), + validatedBy: z.nullable(z.string().uuid()), + ref: z.nullable(z.string().uuid()), + location: ServiceChainLocationZodEnum, + targetAddress: z.string().ip(), + projectId: z.string().uuid(), + env: ServiceChainEnvironmentZodEnum, + subjectAlternativeName: z.array( + // @TODO: Replace with z.hostname when upgraded to Zod v4 + z.string(), + ), + redirect: z.boolean(), + antivirus: z.nullable(z.object({ + maxFileSize: z.number(), + })), + websocket: z.boolean(), + ipWhiteList: z.array(z.string().cidr()), + sslOutgoing: z.boolean(), +}) +export type ServiceChainDetails = Zod.infer + +// JSON as String validation through Zod. Instead of adding yet-another-dependency, +// I merely copied the code as-is. +// Many thanks to JacobWeisenburger/zod_utilz ! +// +// Usage: use stringToJSON() as you would use z.string() to validate strings that must +// contain JSON stringified content +const literalSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), +]) +type Literal = z.infer +type Json = Literal | { [ key: string ]: Json } | Json[] +const jsonSchema: z.ZodType = z.lazy(() => + z.union([ + literalSchema, + z.array(jsonSchema), + z.record(jsonSchema), + ]), +) +export const json = () => jsonSchema + +export const serviceChainFlowStateEnum = ['opened', 'pending', 'success', 'failed'] as const +export const ServiceChainFlowStateZodEnum = z.enum(serviceChainFlowStateEnum) +export type ServiceChainFlowState = Zod.infer + +export const ServiceChainFlowDetailsSchema = z.object({ + state: ServiceChainFlowStateZodEnum, + input: json(), + output: json(), + updatedAt: z.coerce.date(), +}) +export type ServiceChainFlowDetails = Zod.infer + +export const ServiceChainFlowsSchema = z.object({ + reserve_ip: ServiceChainFlowDetailsSchema, + create_cert: z.nullable(ServiceChainFlowDetailsSchema), + call_exec: ServiceChainFlowDetailsSchema, + activate_ip: ServiceChainFlowDetailsSchema, + dns_request: ServiceChainFlowDetailsSchema, +}) +// Flows is on object, so always used in plural sense +export type ServiceChainFlows = Zod.infer diff --git a/.jjconflict-side-1/packages/shared/src/schemas/services.ts b/.jjconflict-side-1/packages/shared/src/schemas/services.ts new file mode 100644 index 0000000000..22c8299f1f --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/services.ts @@ -0,0 +1,36 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { pluginsPopulatedManifests } from './config.js' + +export const serviceUrl = z.object({ + to: z.string(), + name: z.string(), + description: z.string() + .optional(), +}) + +export type ServiceUrl = Zod.infer + +export const ServiceSchema = z.object({ + description: z.string() + .optional(), + title: z.string(), + name: z.string(), + imgSrc: z.string() + .optional(), + urls: serviceUrl.array(), + manifest: pluginsPopulatedManifests, +}) + +export const permissionTarget = z.enum(['user', 'admin']).default('user') + +export type PermissionTarget = Zod.infer +export type ProjectService = Zod.infer + +export const ServiceHealthSchema = z.object({ + name: z.string(), + status: z.enum(['OK', 'Dégradé', 'En échec', 'Inconnu']), + interval: z.number(), + lastUpdateTimestamp: z.number(), + message: z.string(), +}) diff --git a/.jjconflict-side-1/packages/shared/src/schemas/stage.ts b/.jjconflict-side-1/packages/shared/src/schemas/stage.ts new file mode 100644 index 0000000000..51cb72d120 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/stage.ts @@ -0,0 +1,14 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const StageSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/i) + .min(2, { message: 'must be at least 2 character long' }) + .max(20, { message: 'must not exceed 20 characters' }), + clusterIds: z.string().uuid().array(), +}) + +export type Stage = Zod.infer diff --git a/.jjconflict-side-1/packages/shared/src/schemas/system.ts b/.jjconflict-side-1/packages/shared/src/schemas/system.ts new file mode 100644 index 0000000000..d941a5b9d2 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/system.ts @@ -0,0 +1,20 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { configProjectItemDeclaration } from './config.js' + +export const pluginSchema = z.object({ + description: z.string() + .optional(), + title: z.string(), + name: z.string(), + imgSrc: z.string() + .optional(), + manifest: configProjectItemDeclaration.array(), +}) + +export type PluginSchema = Zod.infer + +export const SystemSettingSchema = z.object({ + key: z.string(), + value: z.string(), +}) diff --git a/.jjconflict-side-1/packages/shared/src/schemas/token.ts b/.jjconflict-side-1/packages/shared/src/schemas/token.ts new file mode 100644 index 0000000000..36cd518ac5 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/token.ts @@ -0,0 +1,44 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { dateToString, permissionLevelSchema } from './_utils.js' +import { UserSchema } from './user.js' + +export const TokenSchema = z.object({ + id: z.string().uuid(), + name: z.string() + .max(32, { message: 'Ne peut dépasser 32 caractères' }) + .min(2, { message: 'Ne peut faire moins de 2 caractères' }) + .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, { message: 'Le nom ne peut être constitué que de caractères minuscules, de chiffres et de tirets (-)' }), + lastUse: dateToString.nullable(), + createdAt: dateToString, + owner: UserSchema + .pick({ email: true, firstName: true, lastName: true, id: true, type: true }) + .optional() + .nullable(), + status: z.enum(['active', 'revoked', 'inactive']), +}) + +// Admin Token section +export const AdminTokenSchema = TokenSchema + .extend({ + expirationDate: dateToString.nullable(), + permissions: permissionLevelSchema, + }) + +export const ExposedAdminTokenSchema = AdminTokenSchema.extend({ + password: z.string(), +}) + +export type AdminToken = Zod.infer +export type ExposedAdminToken = Zod.infer + +// PAT section +export const PersonalAccessTokenSchema = TokenSchema.extend({ + expirationDate: dateToString, +}) +export const ExposedPersonalAccessTokenSchema = TokenSchema.extend({ + password: z.string(), +}) + +export type PersonalAccessToken = Zod.infer +export type ExposedPersonalAccessToken = Zod.infer diff --git a/.jjconflict-side-1/packages/shared/src/schemas/user.ts b/.jjconflict-side-1/packages/shared/src/schemas/user.ts new file mode 100644 index 0000000000..2fd89bc285 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/user.ts @@ -0,0 +1,39 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { AtDatesToStringExtend, dateToString } from './_utils.js' + +export const UserSchema = z.object({ + id: z.string() + .uuid(), + firstName: z.string(), + lastName: z.string(), + email: z.string(), + adminRoleIds: z.string().uuid().array(), + type: z.enum(['human', 'ghost', 'bot']), + lastLogin: dateToString.optional().nullable(), +}) + .extend(AtDatesToStringExtend) + +export const MemberSchema = z.object({ + userId: z.string() + .uuid(), + firstName: z.string(), + lastName: z.string(), + email: z.string() + .email(), + roleIds: z.string().uuid().array(), +}) + .or( + z.object({ + user: UserSchema, + roleIds: z.string().uuid().array(), + }).transform(({ user: { adminRoleIds: _, id: userId, ...user }, roleIds }) => ({ userId, roleIds, ...user })), + ) + +export const ProjectMemberSchema = MemberSchema.and(z.object({ + projectId: z.string().uuid(), +})) + +export type User = Zod.infer +export type Member = Zod.infer +export type ProjectMember = Zod.infer diff --git a/.jjconflict-side-1/packages/shared/src/schemas/zone.ts b/.jjconflict-side-1/packages/shared/src/schemas/zone.ts new file mode 100644 index 0000000000..e402a73a6c --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/schemas/zone.ts @@ -0,0 +1,24 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const ZoneSchema = z.object({ + id: z.string() + .uuid(), + slug: z.string() + .min(1) + .max(10) + .regex(/^[a-z0-9-]+$/), + label: z.string() + .min(1) + .max(50), + argocdUrl: z.string() + .min(5, { message: 'Longueur minimum 5 caractères' }) + .url({ message: 'Url invalide' }), + description: z.string() + .max(200) + .optional() + .nullable() + .transform(value => value ?? ''), +}) + +export type Zone = Zod.infer diff --git a/.jjconflict-side-1/packages/shared/src/utils/const.ts b/.jjconflict-side-1/packages/shared/src/utils/const.ts new file mode 100644 index 0000000000..9c80f33998 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/utils/const.ts @@ -0,0 +1,147 @@ +export const adminGroupPath = '/admin' +export const deleteValidationInput = 'DELETE' +export const forbiddenRepoNames = ['mirror', 'infra-apps', 'infra-observability'] + +export const inClusterLabel = 'in-cluster' as const +export const projectIsLockedInfo = 'Le projet est verrouillé, pas d\'action possible' +export const missingCredentials = 'Si le dépôt est privé, vous devez renseigner au moins le nom d\'utilisateur ou le token' +export const invalidGitUrl = 'L\'adresse doit commencer par https et se terminer par .git' +export const invalidInternalRepoName = 'Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique' + +export const fakeToken = 'fakeToken' +export const tokenHeaderName = 'x-dso-token' +export const swaggerUiPath = '/swagger-ui' + +export const levels = [ + 'r', + 'rw', + 'rwd', +] as const +export type PermissionLevels = typeof levels[number] + +export const projectRoles = [ + 'owner', + 'user', +] as const +export type ProjectRoles = typeof projectRoles[number] + +export const longestEnvironmentName = 11 as const +export const longestDeploymentName = 11 as const + +export const allStatus = [ + 'initializing', + 'created', + 'failed', + 'deleting', +] as const + +export const projectStatus = [ + 'initializing', + 'created', + 'failed', + 'archived', + 'warning', +] as const + +export const achievedStatus = [ + 'created', + 'failed', +] as const + +export type AchievedStatus = typeof achievedStatus[number] + +export enum ClusterPrivacy { + PUBLIC = 'public', + DEDICATED = 'dedicated', +} + +export const privacyWording: Record = { + dedicated: { text: 'dédié', icon: 'ri:shield-keyhole-line' }, + public: { text: 'public', icon: 'ri:door-open-line' }, +} + +export enum AllStatus { + CREATED = 'created', + FAILED = 'failed', + DELETING = 'deleting', +} + +export const logActions = [ + 'Create Project', + 'Create Repository', + 'Create Environment', + 'Delete Project', + 'Delete Repository', + 'Delete Environment', +] + +export const statusDict = { + locked: { + false: { + testId: 'unlocked-badge', + type: 'success', + icon: 'ri:lock-unlock-line', + wording: 'déverrouillé', + animation: undefined, + color: 'var(--success-425-625)', + }, + true: { + testId: 'locked-badge', + type: 'warning', + icon: 'ri:lock-line', + wording: 'verrouillé', + animation: undefined, + color: 'var(--warning-425-625)', + }, + }, + status: { + created: { + testId: 'created-badge', + type: 'success', + icon: 'ri:check-line', + wording: 'succès', + animation: undefined, + color: 'var(--success-425-625)', + }, + failed: { + testId: 'failed-badge', + type: 'error', + icon: 'ri:close-line', + wording: 'opérations échouées', + animation: undefined, + color: 'var(--error-425-625)', + }, + initializing: { + testId: 'initializing-badge', + type: 'info', + icon: 'ri:loader-4-line', + animation: 'spin', + wording: 'opérations en cours', + color: 'var(--info-425-625)', + }, + archived: { + testId: 'archived-badge', + type: 'info', + icon: 'ri:archive-line', + wording: 'archivé', + animation: undefined, + color: 'var(--text-mention-grey)', + }, + warning: { + testId: 'warning-badge', + type: 'warning', + icon: 'ri:alert-line', + wording: 'partiellement dégradé', + animation: undefined, + color: 'var(--warning-425-625)', + }, + }, +} as const + +export const servicePluginOrder = [ + 'argocd', + 'gitlab', + 'registry', + 'sonarqube', + 'vault', +] diff --git a/.jjconflict-side-1/packages/shared/src/utils/date.ts b/.jjconflict-side-1/packages/shared/src/utils/date.ts new file mode 100644 index 0000000000..1bc7428288 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/utils/date.ts @@ -0,0 +1,9 @@ +export function formatDate(dateString: string) { + const date = new Date(dateString) + return new Intl.DateTimeFormat('default', { dateStyle: 'long' }).format(date) +} + +export function formatDateTime(dateTimeString: string) { + const date = new Date(dateTimeString) + return new Intl.DateTimeFormat('default', { dateStyle: 'short', timeStyle: 'short' }).format(date) +} diff --git a/.jjconflict-side-1/packages/shared/src/utils/functions.spec.ts b/.jjconflict-side-1/packages/shared/src/utils/functions.spec.ts new file mode 100644 index 0000000000..deecd24656 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/utils/functions.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { calcProjectNameMaxLength, exclude, identity, removeTrailingSlash, shallowEqual, shallowMatch } from './functions.js' + +describe('function utils: identity', () => { + it('should return identity', () => { + expect(identity('Test')).toStrictEqual('Test') + }) +}) + +describe('function utils: exclude', () => { + it('should exclude keys', () => { + const emptyInput = {} + const simpleInput = { + hello: 'world', + foo: 'bar', + } + const simpleExpectedInput = { + foo: 'bar', + } + const complexInput = { + empty: emptyInput, + simple: simpleInput, + array: [emptyInput, simpleInput, simpleInput, emptyInput], + number: 1, + } + const complexExpectedOutput = { + empty: emptyInput, + simple: simpleExpectedInput, + array: [emptyInput, simpleExpectedInput, simpleExpectedInput, emptyInput], + number: 1, + } + + expect(exclude(emptyInput, ['absentKey'])).toStrictEqual({}) + expect(exclude(simpleInput, ['hello'])).toStrictEqual(simpleExpectedInput) + expect(exclude(complexInput, ['hello'])).toStrictEqual(complexExpectedOutput) + }) + + it('should not mutate input', () => { + const simpleInput = { + hello: 'world', + foo: 'bar', + } + exclude(simpleInput, ['hello']) + expect(simpleInput).toStrictEqual(simpleInput) + }) + + it('should stringify Error and not traverse non-serializable objects', () => { + class NotSerializable { + public value = 'secret' + } + + const input = { + err: new Error('boom'), + big: 896n, + date: new Date('2026-04-08T14:32:26.773Z'), + instance: new NotSerializable(), + } + + const transformed = exclude(input, []) + expect(transformed).toMatchObject({ + big: '896', + date: '2026-04-08T14:32:26.773Z', + instance: '[NotSerializable]', + }) + expect(typeof (transformed as any).err).toBe('string') + expect((transformed as any).err).toContain('boom') + }) +}) + +describe('function utils: calcProjectNameMaxLength', () => { + it('should return max length', () => { + expect(calcProjectNameMaxLength()).toStrictEqual(50) + }) +}) + +describe('function utils: removeTrailingSlash', () => { + it('should return string without ending slash', () => { + expect(removeTrailingSlash('mtest')).toStrictEqual('mtest') + expect(removeTrailingSlash('mtest/')).toStrictEqual('mtest') + }) +}) + +describe('function utils: shallowEqual', () => { + it('should return false if value differs', () => { + expect(shallowEqual({ a: 1 }, { a: 2 })).toEqual(false) + }) + it('should return false if missing key', () => { + expect(shallowEqual({ a: 1, b: 2 }, { a: 1 })).toEqual(false) + }) + it('should return true if equal', () => { + expect(shallowEqual({ a: 1 }, { a: 1 })).toEqual(true) + }) + it('should ignore undefined values in object1', () => { + expect(shallowEqual({ a: 1, b: undefined }, { a: 1 })).toEqual(true) + }) + it('should ignore undefined values in object2', () => { + expect(shallowEqual({ a: 1 }, { a: 1, b: undefined })).toEqual(true) + }) + it('should return true even if keys are in different order', () => { + expect(shallowEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toEqual(true) + }) + it('should treat empty objects as equal', () => { + expect(shallowEqual({}, {})).toEqual(true) + }) + it('should treat objects with only undefined keys as equal', () => { + expect(shallowEqual({ a: undefined }, { b: undefined })).toEqual(true) + }) +}) + +describe('function utils: shallowMatch', () => { + it('should return false if key is not equal', () => { + expect(shallowMatch({ a: 1 }, { a: 2 })).toEqual(false) + }) + it('should return false if missing key', () => { + expect(shallowMatch({ a: 1, b: 2 }, { a: 1 })).toEqual(false) + }) + it('should return true if is equal', () => { + expect(shallowMatch({ a: 1 }, { a: 1 })).toEqual(true) + }) +}) diff --git a/.jjconflict-side-1/packages/shared/src/utils/functions.ts b/.jjconflict-side-1/packages/shared/src/utils/functions.ts new file mode 100644 index 0000000000..f38778f36d --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/utils/functions.ts @@ -0,0 +1,290 @@ +import type { ResourceById, ResourceByKey } from './types.js' +import shortUUID from 'short-uuid' +import { longestEnvironmentName } from './const.js' + +/** + * @param {*} value Value wanted to be return as is + * @returns {*} Value returned as is + */ +export const identity = (value: unknown) => value + +export const getLongestStringOfArray = (array: Array) => array.reduce((acc, curr) => acc.length < curr.length ? curr : acc, '') + +export function calcProjectNameMaxLength() { + return 61 - longestEnvironmentName +} + +export const getUniqueListBy = (arr: Array>, key: string) => [...new Map(arr.map(item => [item[key], item])).values()] + +export const isString = (value: any): value is string => typeof value === 'string' + +type ObjToSort = Record + +export function sortArrByObjKeyAsc(arr: T, key: string): T { + return arr.toSorted((a: ObjToSort, b: ObjToSort) => { + const aValue = a[key] + const bValue = b[key] + if (isString(aValue) && isString(bValue)) { + return aValue.localeCompare(bValue.toString(), 'fr', { sensitivity: 'base' }) + } + if (typeof aValue === 'number' && typeof bValue === 'number') { + return aValue - bValue + } + if (typeof aValue === 'boolean' && typeof bValue === 'boolean') { + return Number(bValue) - Number(aValue) + } + return -1 + }) as T +} + +export function removeTrailingSlash(url: string) { + return url?.endsWith('/') + ? url.slice(0, -1) + : url +} + +function excludeCircular(value: unknown, keys: string[], inPath: WeakSet, inArray: boolean): unknown { + if (value === null) return null + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value + if (typeof value === 'bigint') return value.toString() + if (typeof value === 'undefined') return inArray ? null : undefined + if (typeof value === 'function') return inArray ? null : undefined + if (typeof value === 'symbol') return value.toString() + if (typeof value === 'object') { + if (inPath.has(value)) return '[object Circular]' + inPath.add(value) + + if (Array.isArray(value)) return value.map(v => excludeCircular(v, keys, inPath, true)) + + if (value instanceof Date) { + inPath.delete(value) + return value.toISOString() + } + + if (value instanceof Error) { + const payload = { + name: value.name, + message: value.message, + stack: value.stack, + } + inPath.delete(value) + try { + return JSON.stringify(payload) + } catch { + return `${value.name}: ${value.message}` + } + } + + if (value instanceof Map) { + const obj: Record = {} + for (const [k, v] of value.entries()) { + const key = typeof k === 'string' ? k : String(k) + obj[key] = excludeCircular(v, keys, inPath, false) + } + inPath.delete(value) + return obj + } + + if (value instanceof Set) { + const out = Array.from(value.values(), v => excludeCircular(v, keys, inPath, true)) + inPath.delete(value) + return out + } + + if (value instanceof RegExp) { + inPath.delete(value) + return value.toString() + } + + if (value instanceof URL) { + inPath.delete(value) + return value.toString() + } + + if (value instanceof URLSearchParams) { + inPath.delete(value) + return value.toString() + } + + if ('toJSON' in value && typeof value.toJSON === 'function') { + try { + const serialized = value.toJSON() + const out = excludeCircular(serialized, keys, inPath, inArray) + inPath.delete(value) + return out + } catch { + inPath.delete(value) + return '[object Unserializable]' + } + } + + const proto = Object.getPrototypeOf(value) + if (proto !== Object.prototype && proto !== null) { + const ctorName = (value as any)?.constructor?.name + const tag = typeof ctorName === 'string' && ctorName.length ? ctorName : 'Object' + try { + const s = String(value) + const out = s === '[object Object]' ? `[${tag}]` : s + inPath.delete(value) + return out + } catch { + inPath.delete(value) + return `[object ${tag}]` + } + } + + const obj = value as Record + const newObj: Record = {} + for (const [k, v] of Object.entries(obj)) { + if (keys.includes(k)) continue + const next = excludeCircular(v, keys, inPath, false) + if (typeof next === 'undefined') continue + newObj[k] = next + } + inPath.delete(value) + return newObj + } + return String(value) +} + +// Exclude keys from an object +export function exclude(result: T, keys: string[]): T { + const inPath = new WeakSet() + return excludeCircular(result, keys, inPath, false) as T +} + +export type AsyncReturnType Promise> + = T extends (...args: any) => Promise ? R : any + +export function objectEntries>(obj: Obj): ([keyof Obj, Obj[keyof Obj]])[] { + return Object.entries(obj) as ([keyof Obj, Obj[keyof Obj]])[] +} +export function objectKeys>(obj: Obj): (keyof Obj)[] { + return Object.keys(obj) as (keyof Obj)[] +} +export function objectValues>(obj: Obj): (Obj[keyof Obj])[] { + return Object.values(obj) as (Obj[keyof Obj])[] +} + +export function requiredEnv(envName: string): string { + const envValue = process.env[envName] + if (envValue) return envValue + + throw new Error(`env: ${envName} is not defined !`) +} + +export function resourceListToDict(resList: Array): ResourceById { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.id]: curr, + } + }, {} as ResourceById) +} + +export function resourceListToDictByKey(resList: Array): ResourceByKey { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.key]: curr, + } + }, {} as ResourceByKey) +} + +export function shallowEqual(object1: Record, object2: Record) { + const definedObject1 = Object.fromEntries( + Object.entries(object1).filter(([_, v]) => v !== undefined), + ) + const definedObject2 = Object.fromEntries( + Object.entries(object2).filter(([_, v]) => v !== undefined), + ) + const keys1 = Object.keys(definedObject1) + const keys2 = Object.keys(definedObject2) + + if (keys1.length !== keys2.length) { + return false + } + + for (const key of keys1) { + if (definedObject1[key] !== definedObject2[key]) { + return false + } + } + + return true +} + +/** + * Check if all the keys / values in first parameter is present in the the second parameter, the second one can have additional property + * + * @example + * ```ts + * shallowMatch({ a: 'a' }, { a: 'c' }) // false, invalid value + * shallowMatch({ a: 'a' }, { }) // fals, missing property + * shallowMatch({ a: 'a' }, { a: 'a', b: 'b' }) // true, everything is retrieved and equivalent in the second argument + * ``` + * + */ +export function shallowMatch(objectController: Record | undefined, objectToCheck: Record | undefined) { + if (!objectController || !objectToCheck) { + return false + } + + const keys1 = Object.keys(objectController) + const keys2 = Object.keys(objectToCheck) + + if (keys1.length > keys2.length) { + return false + } + + for (const key of keys1) { + if (objectController[key] !== objectToCheck[key]) { + return false + } + } + + return true +} + +export function generateRandomPassword(length = 24, chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*') { + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +export function isAtLeastTomorrow(actualTime: Date) { + const tomorrow = new Date(Date.now()) + tomorrow.setUTCHours(23, 59, 59, 999) + + return actualTime.getTime() > tomorrow.getTime() +} + +export function insert(pseudoArray: T[] | undefined, element: T): T[] { + if (!pseudoArray) { + return [element] + } else if (Array.isArray(pseudoArray)) { + return [...pseudoArray, element] + } else { + throw new TypeError('item is not an ArrayLike') + } +} + +export type ArrayElement + = ArrayType extends readonly (infer ElementType)[] ? ElementType : never + +export const bts = (v: boolean) => v ? 'true' : 'false' +export function stb(v?: string | undefined) { + return v === 'true' + ? true + : v === 'false' ? false : undefined +} + +const uuidTranslator = shortUUID(shortUUID.constants.uuid25Base36, { + consistentLength: false, +}) + +export const compressUUID = uuidTranslator.fromUUID +export const expandUUID = uuidTranslator.toUUID + +export function generateNamespaceName(projectId: string, envId: string) { + return `${compressUUID(envId)}--${compressUUID(projectId)}` +} diff --git a/.jjconflict-side-1/packages/shared/src/utils/index.ts b/.jjconflict-side-1/packages/shared/src/utils/index.ts new file mode 100644 index 0000000000..0600385dce --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/utils/index.ts @@ -0,0 +1,8 @@ +export * from './const.js' +export * from './date.js' +export * from './functions.js' +export * from './permissions.js' +export * from './plugins.js' +export * from './roles.js' +export * from './schemas.js' +export * from './types.js' diff --git a/.jjconflict-side-1/packages/shared/src/utils/permissions.ts b/.jjconflict-side-1/packages/shared/src/utils/permissions.ts new file mode 100644 index 0000000000..040fbf55d1 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/utils/permissions.ts @@ -0,0 +1,345 @@ +/** + * [FR] ATTENTION ! Ce fichier est la base du système de permissions de l'application. + * Ces permissions sont basés sur le Bitwise Permissions System. Les modifier à posteriori pourrait être catastrophique niveau sécurité. + * Veuillez bien étudier le système et lire la documentation. + * + * [EN] This file is the basis of the application's permissions system. + * These permissions are based on the Bitwise Permissions System. Modifying them after the fact could be catastrophic in terms of security. + * Please study the system carefully and read the documentation. + * https://en.wikipedia.org/wiki/Bitwise_operation + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_AND + * https://www.alexhyett.com/bitwise-operators/ + * + * Voici des sources d'inspirations + * https://discordapi.com/permissions.html#32 + * https://discord.com/developers/docs/topics/permissions#permissions + */ +import type { ResourceById } from './types.js' +import { logger as baseLogger } from '@cpn-console/logger' + +const logger = baseLogger.child({ scope: 'utils:permissions' }) + +export function getPermsByUserRoles(userRoles: string[] | undefined, rolesById: ResourceById<{ id: string, permissions: bigint | string }>, basePerms?: bigint | string) { + if (!userRoles) { + return basePerms ? BigInt(basePerms) : 0n + } + return userRoles.reduce((acc, curr) => { + if (!rolesById[curr]) { + logger.warn(`Role ${curr} not found in rolesById`) + return acc + } + return acc | BigInt(rolesById[curr].permissions) + }, basePerms ? BigInt(basePerms) : 0n) +} + +function permissionsParser(a: Record) { + const valuesRegistered = [] as bigint[] + for (const [k, v] of Object.entries(a)) { + if (typeof v !== 'bigint') + throw new Error(`${k} has a invalid value: ${v}, which is not a bigint`) + if (valuesRegistered.includes(v)) + throw new Error(`${k} has a duplicated value: ${v}`) + valuesRegistered.push(v) + } +} + +const bit = (position: bigint) => 1n << position + +// Be very careful and think to apply corresponding updates in database if you modify these values, You'll have to do binary updates in SQL, good luck ! +export const PROJECT_PERMS = { // project permissions + GUEST: bit(0n), + MANAGE: bit(1n), + MANAGE_MEMBERS: bit(2n), + MANAGE_ENVIRONMENTS: bit(3n), + MANAGE_REPOSITORIES: bit(4n), + MANAGE_ROLES: bit(5n), + SEE_SECRETS: bit(6n), + REPLAY_HOOKS: bit(7n), + LIST_ENVIRONMENTS: bit(8n), + LIST_REPOSITORIES: bit(9n), + LIST_MEMBERS: bit(10n), + LIST_ROLES: bit(11n), + MANAGE_DEPLOYMENTS: bit(12n), + LIST_DEPLOYMENTS: bit(13n), +} + +// Be very careful and think to apply corresponding updates in database if you modify these values, You'll have to do binary updates in SQL, good luck ! +export const ADMIN_PERMS = { // admin permissions + LIST: bit(0n), + MANAGE: bit(1n), + MANAGE_USERS: bit(2n), + MANAGE_PROJECTS: bit(3n), + MANAGE_ROLES: bit(4n), + MANAGE_CLUSTERS: bit(5n), + MANAGE_ZONES: bit(6n), + MANAGE_STAGES: bit(7n), + MANAGE_SYSTEM: bit(8n), + LIST_USERS: bit(9n), + LIST_PROJECTS: bit(10n), + LIST_ROLES: bit(11n), + LIST_CLUSTERS: bit(12n), + LIST_ZONES: bit(13n), + LIST_STAGES: bit(14n), + LIST_SYSTEM: bit(15n), + MANAGE_ADMIN_TOKEN: bit(16n), + LIST_ADMIN_TOKEN: bit(17n), +} + +export type ProjectPermsKeys = keyof typeof PROJECT_PERMS +export type AdminPermsKeys = keyof typeof ADMIN_PERMS + +permissionsParser(ADMIN_PERMS) +permissionsParser(PROJECT_PERMS) + +interface ProjectAuthorizedParams { adminPermissions?: bigint | string | null, projectPermissions?: bigint | string } + +export const toBigInt = (value?: bigint | number | string | undefined | null) => value ? BigInt(value) : 0n + +export const AdminAuthorized = { + Manage: (perms?: bigint | string | null) => !!(toBigInt(perms) & ADMIN_PERMS.MANAGE), + ManageUsers: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_USERS | ADMIN_PERMS.MANAGE)), + ManageProjects: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_PROJECTS | ADMIN_PERMS.MANAGE)), + ManageRoles: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ROLES | ADMIN_PERMS.MANAGE)), + ManageClusters: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_CLUSTERS | ADMIN_PERMS.MANAGE)), + ManageZones: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ZONES | ADMIN_PERMS.MANAGE)), + ManageStages: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_STAGES | ADMIN_PERMS.MANAGE)), + ManageSystem: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_SYSTEM | ADMIN_PERMS.MANAGE)), + ManageAdminToken: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ADMIN_TOKEN | ADMIN_PERMS.MANAGE)), + ListUsers: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_USERS | ADMIN_PERMS.MANAGE_USERS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListProjects: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_PROJECTS | ADMIN_PERMS.MANAGE_PROJECTS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListRoles: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ROLES | ADMIN_PERMS.MANAGE_ROLES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListClusters: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_CLUSTERS | ADMIN_PERMS.MANAGE_CLUSTERS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListZones: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ZONES | ADMIN_PERMS.MANAGE_ZONES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListStages: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_STAGES | ADMIN_PERMS.MANAGE_STAGES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListSystem: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_SYSTEM | ADMIN_PERMS.MANAGE_SYSTEM | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListAdminToken: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ADMIN_TOKEN | ADMIN_PERMS.MANAGE_ADMIN_TOKEN | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), +} as const + +export const ProjectAuthorized = { + Manage: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & PROJECT_PERMS.MANAGE), + + ListEnvironments: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE)), + ListRepositories: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_REPOSITORIES | PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.MANAGE)), + ListMembers: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_MEMBERS | PROJECT_PERMS.MANAGE_MEMBERS | PROJECT_PERMS.MANAGE)), + ListRoles: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_ROLES | PROJECT_PERMS.MANAGE_ROLES | PROJECT_PERMS.MANAGE)), + + ManageEnvironments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE)), + ManageRepositories: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.MANAGE)), + + ManageMembers: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_MEMBERS | PROJECT_PERMS.MANAGE)), + + ManageRoles: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_ROLES | PROJECT_PERMS.MANAGE)), + + ReplayHooks: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.MANAGE)), + + SeeSecrets: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.MANAGE)), + ManageDeployments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_DEPLOYMENTS | PROJECT_PERMS.MANAGE)), + ListDeployments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_DEPLOYMENTS | PROJECT_PERMS.MANAGE)), +} as const + +interface ScopePerm { + name: string + perms: Array<{ key: T, label: string, hint?: string }> +} +type PermDetails = Array> + +export const projectPermsDetails: PermDetails = [{ + name: 'Projet', + perms: [{ + key: 'MANAGE', + label: 'Gérer le projet', + hint: 'Permet de gérer tout le projet et ses ressources associées', + }, { + key: 'MANAGE_ROLES', + label: 'Gérer les rôles du projet', + hint: 'ATTENTION : Ce rôle inclut une élévation de privilège ! Permet de gérer les rôles du projet et les membres associés', + }, { + key: 'MANAGE_MEMBERS', + label: 'Gérer les membres du projet', + hint: 'Permet d\'inviter des utilisateurs et de les retirer', + }, { + key: 'LIST_ROLES', + label: 'Voir les rôles du projet', + hint: 'Permet de visualiser les rôles du projet', + }, { + key: 'LIST_MEMBERS', + label: 'Voir les membres du projet', + hint: 'Permet de visualiser les membres du projet', + }, { + key: 'SEE_SECRETS', + label: 'Afficher les secrets', + hint: 'Permet d\'afficher les secrets générés par les services', + }, { + key: 'REPLAY_HOOKS', + label: 'Reprovisionner le projet', + hint: 'Permet de lancer un reprovisionnage du projet', + }], +}, { + name: 'Environnement', + perms: [ + { + key: 'MANAGE_ENVIRONMENTS', + label: 'Gérer les environnements', + hint: 'Permet de créer, éditer, supprimer des environnements', + }, + { + key: 'LIST_ENVIRONMENTS', + label: 'Voir les environnements', + hint: 'Permet de visualiser tous les environnements et leurs configurations', + }, + ], +}, { + name: 'Dépôt', + perms: [ + { + key: 'MANAGE_REPOSITORIES', + label: 'Gérer les dépots', + hint: 'Permet de créer, éditer, supprimer des dépôts', + }, + { + key: 'LIST_REPOSITORIES', + label: 'Voir les dépôts', + hint: 'Permet de visualiser tous les dépôts et leurs configurations', + }, + ], +}, { + name: 'Déploiements', + perms: [ + { + key: 'MANAGE_DEPLOYMENTS', + label: 'Gérer les déploiements', + hint: 'Permet de créer, éditer, supprimer des déploiements', + }, + { + key: 'LIST_DEPLOYMENTS', + label: 'Voir les déploiements', + hint: 'Permet de visualiser tous les déploiements et leurs configurations', + }, + ], +}] as const + +export const adminPermsDetails: PermDetails = [{ + name: 'Global', + perms: [{ + key: 'MANAGE', + label: 'Administration globale', + hint: 'Administration globale de toute la console et de ses ressources', + }, { + key: 'LIST', + label: 'Lecture seule globale', + hint: 'Accès en lecture seule à toute la console et ses ressources', + }], +}, { + name: 'Gestion des utilisateurs', + perms: [{ + key: 'MANAGE_USERS', + label: 'Gérer les utilisateurs', + hint: 'Permet de gérer les utilisateurs de la console', + }, { + key: 'LIST_USERS', + label: 'Voir les utilisateurs', + hint: 'Permet de voir les utilisateurs de la console', + }], +}, { + name: 'Gestion des projets', + perms: [{ + key: 'MANAGE_PROJECTS', + label: 'Gérer les projets', + hint: 'Permet de gérer les projets de la console', + }, { + key: 'LIST_PROJECTS', + label: 'Voir les projets', + hint: 'Permet de voir les projets de la console', + }], +}, { + name: 'Gestion des rôles', + perms: [{ + key: 'MANAGE_ROLES', + label: 'Gérer les rôles', + hint: 'Permet de gérer les rôles de la console', + }, { + key: 'LIST_ROLES', + label: 'Voir les rôles', + hint: 'Permet de voir les rôles de la console', + }], +}, { + name: 'Infrastructure', + perms: [{ + key: 'MANAGE_CLUSTERS', + label: 'Gérer les clusters', + hint: 'Permet de gérer les clusters de la console', + }, { + key: 'LIST_CLUSTERS', + label: 'Voir les clusters', + hint: 'Permet de voir les clusters de la console', + }, { + key: 'MANAGE_ZONES', + label: 'Gérer les zones', + hint: 'Permet de gérer les zones de la console', + }, { + key: 'LIST_ZONES', + label: 'Voir les zones', + hint: 'Permet de voir les zones de la console', + }, { + key: 'MANAGE_STAGES', + label: 'Gérer les types d\'environnement', + hint: 'Permet de gérer les types d\'environnement de la console', + }, { + key: 'LIST_STAGES', + label: 'Voir les types d\'environnement', + hint: 'Permet de voir les types d\'environnement de la console', + }], +}, { + name: 'Système', + perms: [{ + key: 'MANAGE_SYSTEM', + label: 'Gérer le système', + hint: 'Permet de gérer les configurations et logs du système', + }, { + key: 'LIST_SYSTEM', + label: 'Voir le système', + hint: 'Permet de voir les configurations et logs du système', + }], +}, { + name: 'Jetons d’API', + perms: [{ + key: 'MANAGE_ADMIN_TOKEN', + label: 'Gérer les jetons d’API', + hint: 'Permet de créer et révoquer des jetons d’API admin', + }, { + key: 'LIST_ADMIN_TOKEN', + label: 'Voir les jetons d’API', + hint: 'Permet de lister les jetons d’API admin', + }], +}] as const + +export function getAdminPermLabelsByValue(value: bigint | string) { + value = typeof value === 'bigint' ? value : BigInt(value) + return adminPermsDetails + .map(section => section.perms) + .flat() + .filter(permDetail => ADMIN_PERMS[permDetail.key] & value) + .map(permDetail => permDetail.label) +} + +export function getProjectPermLabelsByValue(value: bigint | string) { + value = typeof value === 'bigint' ? value : BigInt(value) + return projectPermsDetails + .map(section => section.perms) + .flat() + .filter(permDetail => PROJECT_PERMS[permDetail.key] & value) + .map(permDetail => permDetail.label) +} diff --git a/.jjconflict-side-1/packages/shared/src/utils/plugins.ts b/.jjconflict-side-1/packages/shared/src/utils/plugins.ts new file mode 100644 index 0000000000..fd907ad899 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/utils/plugins.ts @@ -0,0 +1,42 @@ +export enum MonitorStatus { + OK = 'OK', + WARNING = 'Dégradé', + ERROR = 'En échec', + UNKNOW = 'Inconnu', +} + +export interface MonitorInfos { + lastUpdateTimestamp: number + interval: number + message: string + status: MonitorStatus + cause?: any // should be deserved to admins only +} +export class Monitor { + private intervalTime: number + public monitorFn: (instance: any) => Promise + private intervalID: NodeJS.Timeout | undefined + public lastStatus: MonitorInfos + + constructor(callback: (instance: Monitor) => Promise, interval: number = 5 * 60 * 1000) { + this.intervalTime = interval + this.monitorFn = () => { + this.lastStatus.cause = undefined + return callback(this) + } + this.lastStatus = { + interval: this.intervalTime, + lastUpdateTimestamp: Date.now(), + message: 'En attente d\'une première vérification', + status: MonitorStatus.UNKNOW, + cause: 'App just started', + } + } + + async refresh() { + if (this.intervalID) + clearInterval(this.intervalID) + this.intervalID = setInterval(() => this.monitorFn(this), this.intervalTime) + return this.monitorFn(this) + } +} diff --git a/.jjconflict-side-1/packages/shared/src/utils/roles.spec.ts b/.jjconflict-side-1/packages/shared/src/utils/roles.spec.ts new file mode 100644 index 0000000000..1124f52e69 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/utils/roles.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { + generateSystemRoleType, + getBaseRoleType, + isExternalRoleType, + isGlobalRoleType, + isManagedRoleType, + isSystemRoleType, +} from './roles.js' + +describe('roles', () => { + describe('generateSystemRoleType', () => { + it('prefixes a base type with system:', () => { + expect(generateSystemRoleType('external')).toBe('system:external') + }) + + it('returns system:undefined for undefined input', () => { + expect(generateSystemRoleType(undefined)).toBe('system:undefined') + }) + + it('returns system:null for null input', () => { + expect(generateSystemRoleType(null)).toBe('system:null') + }) + }) + + describe('isSystemRoleType', () => { + it('returns true for system: prefixed values', () => { + expect(isSystemRoleType('system:external')).toBe(true) + expect(isSystemRoleType('system:managed')).toBe(true) + }) + + it('returns false for non-system values', () => { + expect(isSystemRoleType('external')).toBe(false) + expect(isSystemRoleType('managed')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isSystemRoleType(null)).toBe(false) + expect(isSystemRoleType(undefined)).toBe(false) + }) + }) + + describe('getBaseRoleType', () => { + it('strips system: prefix', () => { + expect(getBaseRoleType('system:external')).toBe('external') + expect(getBaseRoleType('system:managed')).toBe('managed') + }) + + it('returns non-system values as-is', () => { + expect(getBaseRoleType('external')).toBe('external') + expect(getBaseRoleType('managed')).toBe('managed') + }) + + it('returns undefined for null or undefined', () => { + expect(getBaseRoleType(null)).toBeUndefined() + expect(getBaseRoleType(undefined)).toBeUndefined() + }) + }) + + describe('isExternalRoleType', () => { + it('returns true for system:external', () => { + expect(isExternalRoleType('system:external')).toBe(true) + }) + + it('returns true for bare external', () => { + expect(isExternalRoleType('external')).toBe(true) + }) + + it('returns false for other system types', () => { + expect(isExternalRoleType('system:managed')).toBe(false) + expect(isExternalRoleType('system:global')).toBe(false) + }) + + it('returns false for non-external types', () => { + expect(isExternalRoleType('managed')).toBe(false) + expect(isExternalRoleType('global')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isExternalRoleType(null)).toBe(false) + expect(isExternalRoleType(undefined)).toBe(false) + }) + }) + + describe('isManagedRoleType', () => { + it('returns true for system:managed', () => { + expect(isManagedRoleType('system:managed')).toBe(true) + }) + + it('returns true for bare managed', () => { + expect(isManagedRoleType('managed')).toBe(true) + }) + + it('returns false for other types', () => { + expect(isManagedRoleType('external')).toBe(false) + expect(isManagedRoleType('global')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isManagedRoleType(null)).toBe(false) + expect(isManagedRoleType(undefined)).toBe(false) + }) + }) + + describe('isGlobalRoleType', () => { + it('returns true for system:global', () => { + expect(isGlobalRoleType('system:global')).toBe(true) + }) + + it('returns true for bare global', () => { + expect(isGlobalRoleType('global')).toBe(true) + }) + + it('returns false for other types', () => { + expect(isGlobalRoleType('external')).toBe(false) + expect(isGlobalRoleType('managed')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isGlobalRoleType(null)).toBe(false) + expect(isGlobalRoleType(undefined)).toBe(false) + }) + }) +}) diff --git a/.jjconflict-side-1/packages/shared/src/utils/roles.ts b/.jjconflict-side-1/packages/shared/src/utils/roles.ts new file mode 100644 index 0000000000..3512475e8e --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/utils/roles.ts @@ -0,0 +1,26 @@ +export const SYSTEM_ROLE_TYPE = 'system' as const + +export function generateSystemRoleType(type: string | null | undefined) { + return `${SYSTEM_ROLE_TYPE}:${type}` +} + +export function isSystemRoleType(type: string | null | undefined) { + return !!type?.startsWith(`${SYSTEM_ROLE_TYPE}:`) +} + +export function getBaseRoleType(type: string | null | undefined) { + if (!type) return undefined + return isSystemRoleType(type) ? type.slice(`${SYSTEM_ROLE_TYPE}:`.length) : type +} + +export function isManagedRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'managed' +} + +export function isGlobalRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'global' +} + +export function isExternalRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'external' +} diff --git a/.jjconflict-side-1/packages/shared/src/utils/schemas.spec.ts b/.jjconflict-side-1/packages/shared/src/utils/schemas.spec.ts new file mode 100644 index 0000000000..5f0c422a13 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/utils/schemas.spec.ts @@ -0,0 +1,349 @@ +import type { Log, ProjectV2 } from '../index.js' +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { ZodError } from 'zod' +import { ClusterDetailsSchema, ClusterPrivacy, ClusterUsageSchema, descriptionMaxLength, EnvironmentSchema, instanciateSchema, LogSchema, parseZodError, ProjectSchemaV2, RepoSchema, StageSchema, UserSchema } from '../index.js' + +describe('schemas utils', () => { + it('should delete config in log', () => { + const toParse: Log = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + action: 'Create a log', + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + data: { + args: {}, + failed: false, + config: {}, + }, + requestId: faker.string.uuid(), + userId: faker.string.uuid(), + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + // @ts-ignore + delete parsed.data.config + expect(LogSchema + .safeParse(toParse)) + .toStrictEqual({ data: parsed, success: true }) + }) + + it('should not validate an undefined object', () => { + // @ts-ignore + expect(RepoSchema.safeParse(undefined).error).toBeInstanceOf(ZodError) + }) + + it('should validate a correct repository schema', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + externalToken: 'eddddsqsq-_', + isPrivate: true, + isInfra: false, + externalUserName: 'clai+re-nlet_', + } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a correct environment schema', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + projectId: faker.string.uuid(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + } + + expect(EnvironmentSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a correct project schema', () => { + const toParse: ProjectV2 = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + slug: faker.lorem.word({ length: { min: 2, max: 10 } }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + description: '', + status: 'created', + locked: false, + clusterIds: [], + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + members: [], + owner: { + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + email: 'invalid-email@mais-pas-grave', + firstName: faker.person.firstName(), + id: faker.string.uuid(), + lastName: faker.person.lastName(), + type: 'human', + }, + everyonePerms: '1', + ownerId: faker.string.uuid(), + roles: [], + lastSuccessProvisionningVersion: null, + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + // @ts-ignore + parsed.owner.updatedAt = parsed.owner.updatedAt.toISOString() + // @ts-ignore + parsed.owner.createdAt = parsed.owner.createdAt.toISOString() + expect(ProjectSchemaV2.safeParse(toParse)).toStrictEqual({ data: parsed, success: true }) + }) + + it('should validate a correct user schema', () => { + const toParse = { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + updatedAt: new Date(), + createdAt: new Date(), + type: 'human', + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + expect(UserSchema.safeParse(toParse)).toStrictEqual({ data: parsed, success: true }) + }) + + it('should validate a correct stage schema', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + clusterIds: [], + } + + expect(StageSchema.safeParse(toParse)).toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a repo business schema', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + externalUserName: 'clairenlet', + externalToken: 'myToken', + isPrivate: true, + isInfra: false, + } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a public cluster details schema', () => { + const toParse = { + id: faker.string.uuid(), + label: 'cluster', + clusterResources: true, + privacy: ClusterPrivacy.PUBLIC, + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + zoneId: faker.string.uuid(), + stageIds: [faker.string.uuid(), faker.string.uuid()], + kubeconfig: { + user: {}, + cluster: { + tlsServerName: 'blabla', + }, + }, + } + + expect(ClusterDetailsSchema + .safeParse(toParse)) + .toStrictEqual({ data: { ...toParse, infos: '' }, success: true }) + }) + + it('should validate a cluster usage schema', () => { + const toParse = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + } + + expect(ClusterUsageSchema + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a repository schema with wrong internal repo name', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: '-candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + isPrivate: false, + isInfra: false, + } + + // @ts-ignore + expect(parseZodError(RepoSchema + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candilib-' + + // @ts-ignore + expect(parseZodError(RepoSchema + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candiLib' + + // @ts-ignore + expect(parseZodError(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candi-lib' + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a too short project name', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.string.alpha({ casing: 'lower' }), + status: 'created', + locked: false, + description: '', + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + // @ts-ignore + expect(parseZodError(ProjectSchemaV2 + .safeParse(toParse) + .error)) + .toMatch('Validation error: String must contain at least 2 character(s) at "name"') + }) + + it('should not validate a too long project name', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.string.alpha({ length: 24, casing: 'lower' }), + status: 'created', + locked: false, + description: '', + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + // @ts-ignore + expect(parseZodError(ProjectSchemaV2 + .safeParse(toParse) + .error)) + .toMatch('Validation error: String must contain at most 20 character(s) at "name"') + }) + + it('should not validate a too long project description', () => { + const toParse = { + id: faker.string.uuid(), + name: 'candilib', + description: faker.string.alpha(descriptionMaxLength + 1), + status: 'created', + locked: false, + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + expect(ProjectSchemaV2 + .safeParse(toParse) + .error).toBeInstanceOf(ZodError) + }) + + it('should validate a single key with given schema', () => { + const toParse = { internalRepoName: 'candilib' } + + expect(RepoSchema + .pick({ internalRepoName: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a single key with given schema', () => { + const toParse = { internalRepoName: 'candi lib' } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .pick({ internalRepoName: true }) + .safeParse(toParse) + // @ts-ignore + .error).toBeInstanceOf(ZodError) + }) + + it('should return truthy schema', () => { + expect(instanciateSchema(RepoSchema.omit({ id: true }), true)).toMatchObject({ + internalRepoName: true, + externalRepoUrl: true, + externalToken: true, + isPrivate: true, + isInfra: true, + externalUserName: true, + projectId: true, + }) + }) + + it('should return true schema', () => { + expect(instanciateSchema(RepoSchema.omit({ id: true }), true)).toMatchObject({ + internalRepoName: true, + externalRepoUrl: true, + externalToken: true, + isPrivate: true, + isInfra: true, + externalUserName: true, + projectId: true, + }) + }) +}) diff --git a/.jjconflict-side-1/packages/shared/src/utils/schemas.ts b/.jjconflict-side-1/packages/shared/src/utils/schemas.ts new file mode 100644 index 0000000000..7330fa943e --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/utils/schemas.ts @@ -0,0 +1,20 @@ +// import Joi from 'joi' +import type { SafeParseReturnType, ZodError, ZodObject } from 'zod' +import { fromZodError } from 'zod-validation-error' + +export type SharedZodError = ZodError +export type SharedSafeParseReturnType = SafeParseReturnType +export const parseZodError = (zodError: ZodError) => fromZodError(zodError).toString() + +export function instanciateSchema, V extends boolean>(schema: T, value: V): Record { + const keys = schema.keyof()._def.values + // @ts-ignore + if (keys.length) { + // @ts-ignore + const entries = schema.keyof()._def.values?.map(key => [key, value]) + // @ts-ignore + return Object.fromEntries(entries) + } + // @ts-ignore + return {} +} diff --git a/.jjconflict-side-1/packages/shared/src/utils/types.ts b/.jjconflict-side-1/packages/shared/src/utils/types.ts new file mode 100644 index 0000000000..babed97bf2 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/src/utils/types.ts @@ -0,0 +1,21 @@ +export type ErrorTypes = 'info' | 'warning' | 'error' | 'success' + +export interface UserProfile { + email: string + id: string + firstName: string + lastName: string + groups: string[] +} + +export type ResourceById = Record + +export type ResourceByKey = Record + +export type Without = { [P in Exclude]?: never } + +export type XOR + = T extends object + ? U extends object + ? (Without & U) | (Without & T) + : U : T diff --git a/.jjconflict-side-1/packages/shared/tsconfig.eslint.json b/.jjconflict-side-1/packages/shared/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-1/packages/shared/tsconfig.json b/.jjconflict-side-1/packages/shared/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-1/packages/shared/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-1/packages/shared/vitest.config.ts b/.jjconflict-side-1/packages/shared/vitest.config.ts new file mode 100644 index 0000000000..fda276425b --- /dev/null +++ b/.jjconflict-side-1/packages/shared/vitest.config.ts @@ -0,0 +1,16 @@ +import { configDefaults, defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: ['**/types', '**/mocks', '**/*.spec.ts', '**/*.d.ts'], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + }, +}) diff --git a/.jjconflict-side-1/packages/test-utils/.gitignore b/.jjconflict-side-1/packages/test-utils/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-1/packages/test-utils/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-1/packages/test-utils/eslint.config.js b/.jjconflict-side-1/packages/test-utils/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-1/packages/test-utils/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-1/packages/test-utils/package.json b/.jjconflict-side-1/packages/test-utils/package.json new file mode 100644 index 0000000000..10d292744f --- /dev/null +++ b/.jjconflict-side-1/packages/test-utils/package.json @@ -0,0 +1,42 @@ +{ + "name": "@cpn-console/test-utils", + "type": "module", + "version": "1.0.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test:e2e-ci": "echo 'check cache'" + }, + "dependencies": { + "@cpn-console/shared": "workspace:^", + "@faker-js/faker": "catalog:test" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "rimraf": "catalog:tools", + "typescript": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/packages/test-utils/src/imports/data.ts b/.jjconflict-side-1/packages/test-utils/src/imports/data.ts new file mode 100644 index 0000000000..e7bc172877 --- /dev/null +++ b/.jjconflict-side-1/packages/test-utils/src/imports/data.ts @@ -0,0 +1,2305 @@ +export const data = { + personalAccessToken: [], + adminToken: [ + { + id: 'e66a5ce2-440d-4a60-ac0d-d439bbaba040', + name: 'test', + hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', // test + permissions: '2n', + userId: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + }, + { + id: 'e66a5ce2-440d-4a60-ac0d-d439bbaba041', + name: 'test', + hash: '4bb47f186df233e48b09d241ee4defb821add0c35ac8311469fe1522c6813dd5', // revoked + permissions: '2n', + userId: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + status: 'revoked', + }, + ], + adminPlugin: [], + adminRole: [ + { + id: '76229c96-4716-45bc-99da-00498ec9018c', + permissions: '3n', + position: 0, + oidcGroup: '/admin', + name: 'Root Administrateur Plateforme', + type: 'system:external', + }, + { + id: '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', + permissions: '3n', + position: 0, + oidcGroup: '/console/admin', + name: 'Administrateur Plateforme', + type: 'system:managed', + }, + { + id: 'eadf604f-5f54-4744-bdfb-4793d2271e9b', + permissions: '2n', + position: 1, + oidcGroup: '', + name: 'Admin Locaux', + type: 'external', + }, + { + id: '35848aa2-e881-4770-9844-0c5c3693e506', + permissions: '1n', + position: 2, + oidcGroup: '/console/readonly', + name: 'Lecture Seule Plateforme', + type: 'system:managed', + }, + { + id: '034f589f-1750-4b15-bb34-4cd995e7fcaa', + permissions: '25608n', + position: 0, + oidcGroup: '', + name: 'Tout le monde', + type: 'global', + }, + ], + kubeconfig: [ + { + id: '2a88634a-0a60-459c-bf68-c4ffb12430a2', + user: { + keyData: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=', + certData: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + }, + cluster: { + server: 'https://public.server:6443', + skipTLSVerify: true, + tlsServerName: 'public.server', + }, + createdAt: '2024-07-24T16:54:14.961Z', + updatedAt: '2024-07-24T16:54:14.961Z', + }, + { + id: '2a88634a-0a60-459c-bf68-c4ffb12430a3', + user: { + keyData: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=', + certData: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + }, + cluster: { + server: 'https://nowhere.server:6443', + skipTLSVerify: true, + tlsServerName: 'nowhere.server', + }, + createdAt: '2024-07-24T16:54:14.964Z', + updatedAt: '2024-07-24T16:54:14.964Z', + }, + { + id: '0e88f000-07e6-4781-a69d-0963489387f7', + user: { + token: 'nyan cat', + }, + cluster: { + server: 'https://nothere.cluster', + skipTLSVerify: false, + tlsServerName: 'nothere.cluster', + }, + createdAt: '2024-07-24T16:54:14.966Z', + updatedAt: '2024-07-24T16:54:14.966Z', + }, + { + id: 'b5662039-a62b-483e-ba54-b12c6f966c96', + user: { + token: 'kirikou', + }, + cluster: { + server: 'https://pwned.cluster', + tlsServerName: 'pwned.cluster', + }, + createdAt: '2024-07-24T16:54:14.969Z', + updatedAt: '2024-07-24T16:54:14.969Z', + }, + ], + zone: [ + { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce1', + slug: 'pr', + label: 'Zone privée', + argocdUrl: 'https://argocd.private-zone.fr', + description: 'Cette zone de déploiement est privée.', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + }, + { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + label: 'publique', + argocdUrl: 'https://argocd.public-zone.fr', + description: 'Zone de diffusion ouverte.', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + }, + ], + cluster: [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + label: 'unused-cluster', + privacy: 'public', + secretName: '3972ac09-6abc-4e49-83b6-d046da5260ed', + clusterResources: false, + memory: 8, + cpu: 10, + gpu: 0, + kubeConfigId: '2a88634a-0a60-459c-bf68-c4ffb12430a3', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + infos: 'Cluster public non utilisé', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + label: 'top-secret-cluster', + privacy: 'dedicated', + secretName: '59be2d50-58f9-42f3-95dc-b0c0518e3d8a', + clusterResources: true, + memory: 8, + cpu: 10, + gpu: 0, + kubeConfigId: '0e88f000-07e6-4781-a69d-0963489387f7', + createdAt: '2023-07-10T19:49:31.691Z', + updatedAt: '2024-07-24T16:54:15.234Z', + infos: null, + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + label: 'pas-top-cluster', + privacy: 'dedicated', + secretName: '94d52618-7869-4192-b33e-85dd0959e815', + clusterResources: false, + memory: 30, + cpu: 20, + gpu: 0, + kubeConfigId: 'b5662039-a62b-483e-ba54-b12c6f966c96', + createdAt: '2023-07-10T19:49:31.697Z', + updatedAt: '2024-07-24T16:54:15.249Z', + infos: 'Floating IP : 0.0.0.0', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + label: 'public1', + privacy: 'public', + secretName: '3972ac09-6abc-4e49-83b6-d046da5260ec', + clusterResources: false, + memory: 100, + cpu: 100, + gpu: 100, + kubeConfigId: '2a88634a-0a60-459c-bf68-c4ffb12430a2', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2024-07-24T16:54:15.261Z', + infos: 'Cluster public proposé par DSO', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + ], + user: [ + { + id: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + firstName: 'Bot Admin', + lastName: 'test', + email: 'c7712841-c0fd-40ff-abbb-9d914bcc907d@bot.io', + createdAt: '2023-11-16T15:30:01.140Z', + updatedAt: '2023-11-16T15:30:01.140Z', + adminRoleIds: [], + type: 'bot', + lastLogin: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + { + id: '387216f1-3b87-4211-9cac-4371125e1175', + firstName: 'Admin', + lastName: 'ADMIN', + email: 'admin@test.com', + createdAt: '2023-07-03T18:01:52.884Z', + updatedAt: '2023-07-06T12:53:39.183Z', + adminRoleIds: [ + '76229c96-4716-45bc-99da-00498ec9018c', + ], + type: 'human', + }, + { + id: '04ac168a-2c4f-4816-9cce-af6c612e5912', + firstName: 'Anonymous', + lastName: 'User', + email: 'anon@user', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + type: 'ghost', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', + firstName: 'Arnaud', + lastName: 'TARDIF', + email: 'arnaud.tardif@test.com', + createdAt: '2023-07-03T14:46:56.773Z', + updatedAt: '2023-07-03T14:46:56.773Z', + adminRoleIds: [], + type: 'human', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', + email: 'test@test.com', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + type: 'human', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + firstName: 'Thibault', + lastName: 'COLIN', + email: 'thibault.colin@test.com', + createdAt: '2023-07-03T14:46:56.772Z', + updatedAt: '2024-07-25T16:18:11.372Z', + type: 'human', + adminRoleIds: [ + 'eadf604f-5f54-4744-bdfb-4793d2271e9b', + ], + }, + ], + log: [ + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1220', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + environment: 'staging', + registryHost: 'blabla.com', + repositories: [], + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'Create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.809Z', + requestId: null, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1221', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + environment: 'staging', + registryHost: 'blabla.com', + repositories: [], + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.819Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1222', + data: { + argo: { + status: { + result: 'OK', + message: 'Not an infra repository', + }, + }, + args: { + id: 'bd934af0-6de2-41b2-a111-6b0c45b82384', + status: 'initializing', + isInfra: false, + project: 'int-2', + createdAt: '2023-06-08T15:15:56.692Z', + isPrivate: false, + projectId: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + updatedAt: '2023-06-08T15:15:56.692Z', + environment: [], + internalUrl: 'https://blabla.com/bla/projects/mi/int-2/repo.git', + externalRepoUrl: 'https://github.com/dnum-mi/dso-console.git', + externalUserName: '', + internalRepoName: 'repo', + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 1, + }, + gitlab: { + vault: [ + { + data: { + GIT_INPUT_URL: 'github.com/dnum-mi/dso-console.git', + GIT_INPUT_USER: '', + GIT_OUTPUT_URL: 'blabla.com/bla/projects/mi/int-2/repo.git', + GIT_OUTPUT_USER: 'root', + GIT_INPUT_PASSWORD: '', + GIT_OUTPUT_PASSWORD: 'password', + }, + name: 'repo-mirror', + }, + ], + status: { + result: 'OK', + message: 'Created', + }, + }, + }, + action: 'Create Repository', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.819Z', + requestId: null, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1223', + data: { + argo: { + status: { + result: 'OK', + message: 'Not an infra repository', + }, + }, + args: { + id: 'bd934af0-6de2-41b2-a111-6b0c45b82384', + status: 'initializing', + isInfra: false, + project: 'int-2', + createdAt: '2023-06-08T15:15:56.692Z', + isPrivate: false, + projectId: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + updatedAt: '2023-06-08T15:15:56.692Z', + environment: [], + internalUrl: 'https://blabla.com/bla/projects/mi/int-2/repo.git', + externalRepoUrl: 'https://github.com/dnum-mi/dso-console.git', + externalUserName: '', + internalRepoName: 'repo', + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 1, + }, + gitlab: { + vault: [ + { + data: { + GIT_INPUT_URL: 'github.com/dnum-mi/dso-console.git', + GIT_INPUT_USER: '', + GIT_OUTPUT_URL: 'blabla.com/bla/projects/mi/int-2/repo.git', + GIT_OUTPUT_USER: 'root', + GIT_INPUT_PASSWORD: '', + GIT_OUTPUT_PASSWORD: 'password', + }, + name: 'repo-mirror', + }, + ], + status: { + result: 'OK', + message: 'Created', + }, + }, + }, + action: 'Create Repository', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1224', + data: { + args: { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + locked: true, + status: 'initializing', + project: 'int-2', + createdAt: '2023-06-08T15:14:53.517Z', + updatedAt: '2023-06-08T15:14:53.517Z', + description: '', + }, + nexus: { + user: { + roles: [ + 'mi-int-2-ID', + ], + source: 'default', + status: 'active', + userId: 'mi-int-2', + lastName: 'Luffy', + readOnly: false, + firstName: 'Monkey D.', + emailAddress: 'claire.nollet@interieur.gouv.fr', + externalRoles: [], + }, + vault: [ + { + data: { + NEXUS_PASSWORD: 'password', + NEXUS_USERNAME: 'mi-int-2', + }, + name: 'NEXUS', + }, + ], + status: { + result: 'OK', + message: 'User Created', + }, + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 4, + }, + keycloak: { + group: { + id: '4a767b5c-1bf4-43b9-8164-5da76ded49a0', + }, + status: { + result: 'OK', + }, + }, + registry: { + vault: [ + { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + name: 'REGISTRY', + }, + ], + result: { + robot: { + id: 70, + name: 'robot$mi-int-2+ci', + secret: 'token', + expires_at: -1, + creation_time: '2023-06-08T15:14:55.813Z', + }, + project: { + name: 'mi-int-2', + metadata: { + public: 'false', + }, + owner_id: 1, + owner_name: 'admin', + project_id: 63, + repo_count: 0, + update_time: '2023-06-08T15:14:55.363Z', + creation_time: '2023-06-08T15:14:55.363Z', + cve_allowlist: { + id: 65, + items: [], + project_id: 63, + update_time: '0001-01-01T00:00:00.000Z', + creation_time: '0001-01-01T00:00:00.000Z', + }, + current_user_role_id: 1, + current_user_role_ids: [ + 1, + ], + }, + }, + status: { + result: 'OK', + message: 'Created', + }, + }, + sonarqube: { + user: { + user: { + name: 'mi-int-2', + email: 'claire.nollet@interieur.gouv.fr', + local: true, + login: 'mi-int-2', + active: true, + scmAccounts: [], + }, + }, + vault: [ + { + data: { + SONAR_TOKEN: 'token', + SONAR_PASSWORD: 'password', + SONAR_USERNAME: 'mi-int-2', + }, + name: 'SONAR', + }, + ], + result: {}, + status: { + result: 'OK', + message: 'User Created', + }, + }, + }, + action: 'Create Environment', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1225', + data: { + args: { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + locked: false, + status: 'created', + project: 'int-2', + createdAt: '2023-06-08T15:14:53.517Z', + updatedAt: '2023-06-08T15:16:49.011Z', + description: '', + }, + nexus: { + status: { + result: 'OK', + message: 'User deleted', + }, + }, + vault: { + status: { + result: 'OK', + }, + secretsDestroyed: 5, + }, + gitlab: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + keycloak: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + registry: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + sonarqube: { + status: { + result: 'OK', + message: 'User anonymized', + }, + }, + }, + action: 'Delete Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1226', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1227', + data: { + argo: { + status: { + result: 'KO', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1228', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1229', + data: { + argo: { + status: { + result: 'KO', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1230', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1231', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1232', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1233', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1234', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1235', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1236', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1237', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1238', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1239', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1240', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1241', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + ], + project: [ + { + id: '22e7044f-8414-435d-9c4a-2df42a65034b', + name: 'betaapp', + slug: 'betaapp', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.814Z', + updatedAt: '2023-07-03T14:46:56.817Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '9dabf3f9-6c86-4358-8598-65007d78df65', + name: 'projecttoarchive', + slug: 'projecttoarchive', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.824Z', + updatedAt: '2023-07-03T14:46:56.830Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + name: 'candilib', + slug: 'candilib', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application de réservation de places à l\'examen du permis B.', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.778Z', + updatedAt: '2023-07-03T14:46:56.783Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '011e7860-04d7-461f-912d-334c622d38c5', + name: 'basegun', + slug: 'basegun', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application d\'aide à la catégorisation d\'armes à feu.', + status: 'created', + locked: false, + createdAt: '2023-07-10T14:46:56.778Z', + updatedAt: '2023-07-10T14:46:56.783Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + name: 'psijfailed', + slug: 'psijfailed', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application de transmission d\'informations entre agents de la PS et de l\'IJ.', + status: 'failed', + locked: true, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '8bd21636-e8ce-4098-8ed8-16cbd60af340', + name: 'projet dégradé', + slug: 'projet dégradé', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Projet testé en état dégradé', + status: 'warning', + locked: true, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '554d9150-9a07-42c1-8207-1163f2f0addd', + name: 'pissenlit', + slug: 'pissenlit', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + name: 'rolistes', + slug: 'rolistes', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '0n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + ], + stage: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + name: 'dev', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + name: 'staging', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + name: 'integration', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + name: 'prod', + }, + ], + environment: [ + { + id: 'bc06ace5-ddf6-4f00-97fa-872922baf078', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.826Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '95ef0d9b-945e-4af6-851c-4c6685ceff20', + name: 'staging', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.829Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: '8d4503eb-64c7-407e-89db-6ab80865071f', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.855Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '3b0cf6c1-251b-4ec6-926f-b54ce1f82560', + name: 'staging', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.859Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: '1b9f1053-fcf5-4053-a7b2-ff8a2c0c1921', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.787Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '1c654f00-4798-4a80-929f-960ddb37885a', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '1c654f00-4798-4a80-929f-960ddb36774b', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38c5', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '2805a1f5-0ca4-46a4-b3d7-5b649aee4a91', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + createdAt: '2023-07-03T14:46:56.808Z', + updatedAt: '2023-07-03T14:46:56.815Z', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '2805a1f5-0ca4-46a4-b3d7-5b649aee4a92', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + createdAt: '2023-07-03T15:56:56.808Z', + updatedAt: '2023-07-03T15:56:56.815Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + ], + projectClusterHistory: [ + { + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38c5', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + }, + { + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + }, + ], + projectMembers: [ + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f9599', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + roleIds: [], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f95b5', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f9595', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + roleIds: [], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + roleIds: [], + }, + ], + projectPlugin: [], + projectRole: [ + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f95b5', + name: 'bg75', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + permissions: '2n', + position: 0, + }, + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f9595', + name: 'bg78', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + permissions: '1n', + position: 0, + }, + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f9599', + name: 'level up', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + permissions: '0n', + position: 0, + }, + ], + repository: [ + { + id: '53891549-e628-4893-8bd3-92abcb71068a', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + internalRepoName: 'beta-front', + externalRepoUrl: 'https://github.com/dnum-mi/beta-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.825Z', + }, + { + id: '26ad0fee-c4bd-462c-a4f7-0f7a713b56f7', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + internalRepoName: 'beta-back', + externalRepoUrl: 'https://github.com/dnum-mi/beta-back.git', + externalUserName: '', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.825Z', + }, + { + id: '83aa3c2a-cdae-4638-b95f-a02b3f31eace', + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + internalRepoName: 'archive-front', + externalRepoUrl: 'https://github.com/dnum-mi/archive-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.857Z', + }, + { + id: 'ad77aa10-a708-4fce-8d0c-15c1b4704309', + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + internalRepoName: 'archive-back', + externalRepoUrl: 'https://github.com/dnum-mi/archive-back.git', + externalUserName: '', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.854Z', + }, + { + id: '299216bb-2dcc-42b5-ac71-6aa001d2dccf', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/dnum-mi/candilib.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.802Z', + }, + { + id: 'c26df1a3-2e03-420d-9894-cd9ce4f98b60', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-back', + externalRepoUrl: 'https://github.com/dnum-mi/psij-back.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.813Z', + }, + { + id: '78f2b122-13cc-406f-bac8-9f0947a23172', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-front', + externalRepoUrl: 'https://github.com/dnum-mi/psij-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '78f2b122-13cc-406f-bac8-9f0947b23172', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-infra', + externalRepoUrl: 'https://github.com/dnum-mi/psij-infra.git', + externalUserName: 'this-is-a-test', + deployRevision: 'main', + deployPath: 'helm/', + helmValuesFiles: 'values.yaml,values.prod.yaml', + isInfra: true, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '9d3e7791-ee67-4282-be95-613c491c2e75', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + internalRepoName: 'pissenlit', + externalRepoUrl: 'https://github.com/dnum-mi/pissenlit.git', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '9d3e7791-ee67-4282-be95-613c491c2e76', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + internalRepoName: 'warhammer', + externalRepoUrl: 'https://github.com/dnum-mi/warhammer.git', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T16:46:56.809Z', + updatedAt: '2023-07-03T16:46:56.816Z', + }, + ], + systemSetting: [ + { + key: 'maintenance', + value: 'off', + }, + + ], + associations: [ + [ + 'cluster', + [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + projects: [], + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + projects: [ + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + }, + ], + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + projects: [ + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + }, + { + id: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: '9dabf3f9-6c86-4358-8598-65007d78df65', + }, + ], + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + projects: [], + }, + ], + ], + [ + 'cluster', + [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + stages: [], + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + ], + ], + ], + deployment: [], + deploymentSource: [], +} diff --git a/.jjconflict-side-1/packages/test-utils/src/index.ts b/.jjconflict-side-1/packages/test-utils/src/index.ts new file mode 100644 index 0000000000..51d39c09b7 --- /dev/null +++ b/.jjconflict-side-1/packages/test-utils/src/index.ts @@ -0,0 +1,5 @@ +export * from './imports/data.js' +export * from './utils/func-utils.js' +export * from './utils/random-project.js' +export * from './utils/random-utils.js' +export * from './utils/types.js' diff --git a/.jjconflict-side-1/packages/test-utils/src/utils/func-utils.ts b/.jjconflict-side-1/packages/test-utils/src/utils/func-utils.ts new file mode 100644 index 0000000000..da5c99a8b4 --- /dev/null +++ b/.jjconflict-side-1/packages/test-utils/src/utils/func-utils.ts @@ -0,0 +1,6 @@ +import type { User } from '@cpn-console/shared' + +export const repeatFn = (nb: number) => (fn: (optionalParam?: any) => any, optionalParam?: any) => Array.from({ length: nb }).map(() => fn(optionalParam)) + +// transform an array of UserModel in object-like usersStore +export const toUsersStore = (users: Array>): Record> => users.reduce((store, user) => ({ ...store, [user.id]: user }), {}) diff --git a/.jjconflict-side-1/packages/test-utils/src/utils/random-project.ts b/.jjconflict-side-1/packages/test-utils/src/utils/random-project.ts new file mode 100644 index 0000000000..73dcdf40bb --- /dev/null +++ b/.jjconflict-side-1/packages/test-utils/src/utils/random-project.ts @@ -0,0 +1,77 @@ +import type { User } from './types.js' +import { projectRoles } from '@cpn-console/shared' +import { repeatFn } from './func-utils.js' +import { + getRandomCluster, + getRandomEnv, + getRandomPerm, + getRandomProject, + getRandomRepo, + getRandomRole, + getRandomStage, + getRandomUser, + getRandomZone, +} from './random-utils.js' + +const basicStages = ['dev', 'staging', 'integration', 'prod'] + +export function createRandomDbSetup({ nbUsers = 1, nbRepo = 3, envs = basicStages }) { + // Create users + const users: User[] = repeatFn(nbUsers)(getRandomUser) + + // Create project + const project = getRandomProject() + + // Create Roles association table + project.roles = users.map(user => ({ + ...getRandomRole(user.id, project.id), + user, + })) + project.roles[0].role = projectRoles[0] + const ownerId = project.roles.find(role => role.role === 'owner')?.userId + + // @ts-ignore + project.members = project.roles.map(({ userId, user: { id: _, ...user }, role }) => { + return { + userId, + role, + ...user, + } + }) + + // Create zone + const zones = [getRandomZone()] + + // Create cluster + const clusters = [getRandomCluster({ projectIds: [project.id], zoneId: zones[0].id })] + // @ts-ignore + project.clusters = clusters + + // Create stages + const stages = basicStages.map(baseEnvironment => getRandomStage(baseEnvironment)) + stages.forEach((stage) => { + // @ts-ignore + stage.clusters = project.clusters + }) + + // Create repositories + project.repositories = repeatFn(nbRepo)(getRandomRepo, project.id) + + // Create environment + project.environments = envs + .map(env => getRandomEnv(env, project.id, stages[0].id, clusters[0].id)) + + // Create permissions + project.environments.forEach((env) => { + env.permissions = users.map(user => + user.id === ownerId ? getRandomPerm(env.id, user, 2) : getRandomPerm(env.id, user), + ) + }) + + return { + users, + stages, + zones, + project, + } +} diff --git a/.jjconflict-side-1/packages/test-utils/src/utils/random-utils.ts b/.jjconflict-side-1/packages/test-utils/src/utils/random-utils.ts new file mode 100644 index 0000000000..3f78ccf346 --- /dev/null +++ b/.jjconflict-side-1/packages/test-utils/src/utils/random-utils.ts @@ -0,0 +1,158 @@ +import type { AchievedStatus, ClusterDetails, ProjectRoles, Stage, Zone } from '@cpn-console/shared' +import type { Cluster, Environment, Log, Member, Permission, Project, Repository, Role, User } from './types.js' +import { achievedStatus, ClusterPrivacy, logActions, projectRoles } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { repeatFn } from './func-utils.js' + +export function getRandomProjectName() { + return faker.lorem.word() +} + +export function getRandomGitUrl() { + const url = `${faker.internet.url().split('.')[0]}.git` + return url.startsWith('https://') ? url : `https://${url.split('://')[1]}` +} + +export function getRandomProject(): Project & { status: AchievedStatus, members?: Member[] } { + const name = getRandomProjectName() + return { + id: faker.string.uuid(), + name, + slug: `${name}-1`, + description: faker.lorem.sentence(8), + status: faker.helpers.arrayElement(achievedStatus), + locked: false, + updatedAt: (new Date()).toISOString(), + createdAt: (new Date()).toISOString(), + } +} + +export function getRandomZone(): Zone { + return { + id: faker.string.uuid(), + slug: faker.lorem.word({ length: { min: 1, max: 10 } }), + label: faker.lorem.word({ length: { min: 1, max: 50 } }), + argocdUrl: faker.internet.url(), + description: faker.lorem.sentence(8), + } +} + +export function getRandomCluster({ projectIds = repeatFn(2)(faker.string.uuid), stageIds = repeatFn(2)(faker.string.uuid), privacy = faker.helpers.arrayElement(Object.values(ClusterPrivacy)), zoneId = faker.string.uuid() }: +{ projectIds?: string[], stageIds?: string[], privacy?: ClusterPrivacy, zoneId?: string }): ClusterDetails { + return { + id: faker.string.uuid(), + label: faker.lorem.word(), + infos: faker.lorem.sentence(8), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + zoneId, + projectIds: privacy === ClusterPrivacy.DEDICATED ? projectIds : [], + stageIds, + kubeconfig: { + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + }, + privacy, + clusterResources: faker.datatype.boolean(), + } +} + +export function getRandomUser(id = faker.string.uuid()): User { + return { + id, + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + } +} + +export function getRandomRole(userId = faker.string.uuid(), projectId = faker.string.uuid(), role: ProjectRoles = projectRoles[1]): Role { + return { + userId, + role, + projectId, + } +} + +export function getRandomMember(userId = faker.string.uuid(), role: ProjectRoles = projectRoles[1]): Member { + return { + userId, + role, + } +} + +export function getRandomRepo(projectId = faker.string.uuid()): Repository { + const repo: Repository = { + id: faker.string.uuid(), + projectId, + internalRepoName: faker.lorem.word(), + externalRepoUrl: getRandomGitUrl(), + isPrivate: faker.datatype.boolean(), + isInfra: faker.datatype.boolean(), + } + if (repo.isPrivate) { + repo.externalUserName = faker.person.firstName() + repo.externalToken = faker.internet.password({ length: 25 }) + } + + return repo +} + +export function getRandomStage(name: string = faker.lorem.word({ length: { min: 2, max: 20 } }), links?: { + clusterIds?: string[] + clusters?: Cluster[] +}): Stage { + return { + id: faker.string.uuid(), + name, + clusterIds: links?.clusterIds ?? links?.clusters?.map(({ id }) => id) ?? [] as string[], + } +} + +export function getRandomEnv(name = faker.lorem.slug(1), projectId = faker.string.uuid(), stageId = faker.string.uuid(), clusterId = faker.string.uuid()): Environment { + return { + id: faker.string.uuid(), + name, + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + projectId, + stageId, + clusterId, + } +} + +export function getRandomPerm(environmentId = faker.string.uuid(), user = getRandomUser(), level = faker.number.int({ min: 0, max: 2 })): Permission { + return { + id: faker.string.uuid(), + environmentId, + userId: user.id, + level, + user, + } +} + +export function getRandomLog(action = faker.helpers.arrayElement(logActions), userId = faker.string.uuid()): Log { + return { + id: faker.string.uuid(), + action, + userId, + updatedAt: (new Date()).toISOString(), + createdAt: (new Date()).toISOString(), + data: { + args: {}, + failed: faker.datatype.boolean(), + results: {}, + totalExecutionTime: 1, + }, + requestId: null, + } +} diff --git a/.jjconflict-side-1/packages/test-utils/src/utils/types.ts b/.jjconflict-side-1/packages/test-utils/src/utils/types.ts new file mode 100644 index 0000000000..14eaf73bb8 --- /dev/null +++ b/.jjconflict-side-1/packages/test-utils/src/utils/types.ts @@ -0,0 +1,112 @@ +export interface Repository { + id: string + projectId: string + internalRepoName: string + externalRepoUrl: string + isPrivate: boolean + isInfra: boolean + createdAt?: Date + updatedAt?: Date + externalUserName?: string + externalToken?: string +} + +export interface User { + id: string + email: string + firstName: string + lastName: string + updatedAt?: Date + createdAt?: Date +} + +export interface Role { + userId: string + projectId: string + role: string + updatedAt?: Date + createdAt?: Date + user?: User +} + +export interface Member { + userId: User['id'] + role: string +} + +export interface Permission { + id: string + environmentId: string + userId: string + level: number + user: User + updatedAt?: Date + createdAt?: Date +} + +export interface Cluster { + id: string + label: string + infos?: string + projectIds?: string[] + stageIds?: string[] + zoneId?: string + user: { + certData: string + keyData: string + } + cluster: { + caData: string + server: string + tlsServerName: string + } + privacy: string + clusterResources: boolean + updatedAt?: Date + createdAt?: Date +} + +export interface Environment { + id: string + name: string + cpu: number + gpu: number + memory: number + projectId: string + clusterId: string + stageId: string + permissions?: Permission[] + cluster?: Cluster + updatedAt?: Date + createdAt?: Date +} + +export interface Log { + id: string + action: string + userId: string + createdAt: Date | string + updatedAt: Date | string + data: { + args: any + failed: boolean + results: any + totalExecutionTime: number + } + requestId: null +} + +export interface Project { + id: string + name: string + slug: string + description: string + status: string + locked: boolean + roles?: Role[] + clusters?: Cluster[] + repositories?: Repository[] + environments?: Environment[] + updatedAt?: Date | string + createdAt?: Date | string +} diff --git a/.jjconflict-side-1/packages/test-utils/tsconfig.eslint.json b/.jjconflict-side-1/packages/test-utils/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-1/packages/test-utils/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-1/packages/test-utils/tsconfig.json b/.jjconflict-side-1/packages/test-utils/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-1/packages/test-utils/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-1/packages/tsconfig/package.json b/.jjconflict-side-1/packages/tsconfig/package.json new file mode 100644 index 0000000000..f90b7b4aee --- /dev/null +++ b/.jjconflict-side-1/packages/tsconfig/package.json @@ -0,0 +1,16 @@ +{ + "name": "@cpn-console/ts-config", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "version": "1.1.0", + "private": false, + "description": "", + "main": "index.js", + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} \ No newline at end of file diff --git a/.jjconflict-side-1/packages/tsconfig/tsconfig.base.json b/.jjconflict-side-1/packages/tsconfig/tsconfig.base.json new file mode 100644 index 0000000000..6a08a00241 --- /dev/null +++ b/.jjconflict-side-1/packages/tsconfig/tsconfig.base.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + "jsx": "preserve", + "lib": ["ESNext"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + + // Base + "paths": { + "@/*": [ + "src/*" + ] + }, + "resolveJsonModule": true, + + "allowImportingTsExtensions": false, + "allowJs": true, + // Best practices + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + // Some stricter flags + "useUnknownInCatchVariables": false, + "declaration": true, + "emitDeclarationOnly": false, + // Output + "noEmit": false, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + } +} diff --git a/.jjconflict-side-1/pgadmin/servers-docker.json b/.jjconflict-side-1/pgadmin/servers-docker.json new file mode 100644 index 0000000000..d1710b3648 --- /dev/null +++ b/.jjconflict-side-1/pgadmin/servers-docker.json @@ -0,0 +1,22 @@ +{ + "Servers": { + "1": { + "Name": "dso-console-db", + "Group": "Servers", + "Host": "postgres", + "Port": 5432, + "MaintenanceDB": "dso-console-db", + "Username": "admin", + "SSLMode": "prefer", + "SSLCert": "/.postgresql/postgresql.crt", + "SSLKey": "/.postgresql/postgresql.key", + "SSLCompression": 0, + "BGColor": "#EEEDE7", + "FGColor": "#05445E", + "Timeout": 10, + "UseSSHTunnel": 0, + "TunnelPort": "22", + "TunnelAuthentication": 0 + } + } +} diff --git a/.jjconflict-side-1/pgadmin/servers-kube.json b/.jjconflict-side-1/pgadmin/servers-kube.json new file mode 100644 index 0000000000..4f16a15fae --- /dev/null +++ b/.jjconflict-side-1/pgadmin/servers-kube.json @@ -0,0 +1,22 @@ +{ + "Servers": { + "1": { + "Name": "dso-console-db", + "Group": "Servers", + "Host": "dso-cpn-console-db-hl", + "Port": 5432, + "MaintenanceDB": "dso-console-db", + "Username": "admin", + "SSLMode": "prefer", + "SSLCert": "/.postgresql/postgresql.crt", + "SSLKey": "/.postgresql/postgresql.key", + "SSLCompression": 0, + "BGColor": "#EEEDE7", + "FGColor": "#05445E", + "Timeout": 10, + "UseSSHTunnel": 0, + "TunnelPort": "22", + "TunnelAuthentication": 0 + } + } +} diff --git a/.jjconflict-side-1/playwright/README.md b/.jjconflict-side-1/playwright/README.md new file mode 100644 index 0000000000..d47e6602c9 --- /dev/null +++ b/.jjconflict-side-1/playwright/README.md @@ -0,0 +1,78 @@ +# À propos + +Ce dossier contient tout ce qui nous est nécessaire pour lancer les tests automatisés avec Playwright. + +Il peut s'agir de tests E2E, d'une brique en particulier, bref tout ce qui est actionnable à partir de l'interface utilisateur. + +## Installer Playwright + +Tout est déjà prévu dans le `pnpm install` que vous avez normalement déjà exécuté à la racine du MonoRepo. Si ce n'est pas le cas vous pouvez relancer la commande dans ce dossier. + +## Installer les dépendances (navigateurs, etc.) + +Afin de pouvoir lancer les tests sur différents navigateurs, Playwright a besoin d'un certain nombre de binaires. +Pour les installer localement il faut lancer la commande suivante : + +```shell +$ pnpm exec playwright install --with-deps +``` + +Note: Il est possible qu'il soit nécessaire que vous fassiez un `sudo` + +## Lancer les tests + +Il faut d'abord lancer une stack, par ex, celle de Dev: + +```shell +$ pnpm run docker:dev +``` +On peut lancer ensuite les tests : + +```shell +$ pnpm run playwright:test --grep @e2e +``` + +OU (autre possibilité), lancer la "console" Playwright pour parcourir/exécuter les tests manuellement : + +```shell +$ pnpm run playwright:test:ui --grep @e2e +``` + +Et pour les tests d'intégration, il faut au préalable exporter des variables d'environnement : + +```shell +$ export KEYCLOAK_DOMAIN=keycloak.example.com +$ export KEYCLOAK_REDIRECT_URI=https://console.example.com +$ export KEYCLOAK_PROTOCOL=https +$ export KEYCLOAK_ADMIN_USERNAME= +$ export KEYCLOAK_ADMIN_PASSWORD= +$ export KEYCLOAK_REALM=dso +$ export CONSOLE_ADMIN_USERNAME= +$ export CONSOLE_ADMIN_PASSWORD= +$ export CONSOLE_ADMIN_EMAIL= +$ export CONSOLE_TEST_USERNAME= +$ export CONSOLE_TEST_PASSWORD= +$ export CONSOLE_TEST_EMAIL= +$ export CONSOLE_SECOND_TEST_USERNAME= +$ export CONSOLE_SECOND_TEST_PASSWORD= +$ export CONSOLE_SECOND_TEST_EMAIL= +$ export CONSOLE_GLOBAL_TIMEOUT='900000' +$ export CONSOLE_EXPECT_TIMEOUT='900000' +$ export CONSOLE_DESTINATION_CLUSTER= +$ export CONSOLE_VALUES_FILE= +``` + +On peut lancer ensuite les tests : + +```shell +$ pnpm run playwright:test:integration --grep @integ +``` + +## Conventions de code + +Afin d'assurer la lisibilité et la maintenabilité des tests Playwright, quelques décisions ont été prises concernant l'écriture du code : +- Pas d'imbrication de if : les conditions sont autorisées dans les fonctions, mais ne doivent pas être imbriquées. +- Arguments explicites : certains arguments initialement optionnels doivent être rendus obligatoires afin de rendre le comportement de la fonction plus clair (par exemple pour les zones publiques, ou les contextes spécifiques). +- Visibilité des tests : il doit être évident, à la lecture du test, de voir ce qui est réellement vérifié. Par conséquent, évitez de masquer les expect à l'intérieur de fonctions utilitaires. Les assertions doivent apparaître directement dans le corps du test lorsqu'elles participent à la vérification d'un comportement. + +Ces conventions ont pour objectif de rendre les tests plus explicites, plus faciles à relire, et de réduire les effets de bord cachés. diff --git a/.jjconflict-side-1/playwright/config/console.ts b/.jjconflict-side-1/playwright/config/console.ts new file mode 100644 index 0000000000..35ea8a13c9 --- /dev/null +++ b/.jjconflict-side-1/playwright/config/console.ts @@ -0,0 +1,83 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +// Retrieve frontend URL from environment variables (see playwright.config.ts) +export const clientURL + = process.env.KEYCLOAK_REDIRECT_URI || 'http://change-me' + +export interface Credentials { + id: string + username: string + password: string + firstName: string + lastName: string + email: string +} + +// Users referenced in Keycloak dev realm (../keycloak/realms/realm-dev.json) +export const adminUser: Credentials = { + id: '387216f1-3b87-4211-9cac-4371125e1175', + username: process.env.CONSOLE_ADMIN_USERNAME?.trim() || 'admin', + password: process.env.CONSOLE_ADMIN_PASSWORD?.trim() || 'admin', + firstName: 'Admin', + lastName: 'ADMIN', + email: process.env.CONSOLE_ADMIN_EMAIL?.trim() || 'admin@test.com', +} +export const testUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + username: process.env.CONSOLE_TEST_USERNAME?.trim() || 'test', + password: process.env.CONSOLE_TEST_PASSWORD?.trim() || 'test', + firstName: 'Jean', + lastName: 'DUPOND', + email: process.env.CONSOLE_TEST_EMAIL?.trim() || 'test@test.com', +} +export const cnolletUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + username: 'cnollet', + password: 'test', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', +} +export const tcolinUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + username: 'tcolin', + password: 'test', + firstName: 'Thibault', + lastName: 'COLIN', + email: 'thibault.colin@test.com', +} + +// User for integration test +export const secondTestUser: Credentials = { + id: 'test', + username: process.env.CONSOLE_SECOND_TEST_USERNAME?.trim() || 'secondtestuser', + password: process.env.CONSOLE_SECOND_TEST_PASSWORD?.trim() || 'secondtestuser', + firstName: 'test', + lastName: 'test', + email: process.env.CONSOLE_SECOND_TEST_EMAIL?.trim() || 'secondtestuser@example.com', +} + +export const currentUser: Credentials = { + id: 'current', + username: process.env.CONSOLE_CURRENT_TEST_USERNAME?.trim() || 'currentuser', + password: process.env.CONSOLE_CURRENT_TEST_PASSWORD?.trim() || 'currentuser', + firstName: 'test', + lastName: 'test', + email: process.env.CONSOLE_CURRENT_TEST_EMAIL?.trim() || 'currentuser@example.com', +} + +export async function signInCloudPiNative({ + page, + credentials, +}: { + page: Page + credentials: Credentials +}) { + const { username, password } = credentials + await page.getByRole('link', { name: 'Se connecter' }).click() + await page.getByRole('textbox', { name: 'Username or email' }).fill(username) + await page.getByRole('textbox', { name: 'Password' }).fill(password) + await page.getByRole('button', { name: 'Sign In' }).click() + await expect(page.locator('#top')).toContainText('Cloud π Native') +} diff --git a/.jjconflict-side-1/playwright/config/keycloak.ts b/.jjconflict-side-1/playwright/config/keycloak.ts new file mode 100644 index 0000000000..e93138829a --- /dev/null +++ b/.jjconflict-side-1/playwright/config/keycloak.ts @@ -0,0 +1,38 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +export interface KeycloakConfig { + url: string + realm: string + adminUser: string + adminPass: string + clientFrontend: string + clientBackend: string +} + +export function loadKeycloakConfig(): KeycloakConfig { + const protocol = process.env.KEYCLOAK_PROTOCOL || 'http' + const domain = process.env.KEYCLOAK_DOMAIN || 'localhost' + const port = process.env.KEYCLOAK_PORT ? `:${process.env.KEYCLOAK_PORT}` : '' + const url = `${protocol}://${domain}${port}` + + return { + url, + realm: process.env.KEYCLOAK_REALM?.trim() || 'dso', + adminUser: process.env.KEYCLOAK_ADMIN_USERNAME?.trim() || 'admin', + adminPass: process.env.KEYCLOAK_ADMIN_PASSWORD?.trim() || 'admin', + clientFrontend: process.env.KEYCLOAK_CLIENT_FRONTEND || 'dso-console-frontend', + clientBackend: process.env.KEYCLOAK_CLIENT_BACKEND || 'dso-console-backend', + } +} + +export async function signInKeycloak( + page: Page, +) { + const keycloakConfig = loadKeycloakConfig() + await page.goto(keycloakConfig.url) + await page.locator('#username').fill(keycloakConfig.adminUser) + await page.locator('#password').fill(keycloakConfig.adminPass) + await page.locator('#kc-login').click() + await expect(page.getByRole('link', { name: 'Logo' })).toBeVisible() +} diff --git a/.jjconflict-side-1/playwright/e2e-tests/admin-logs.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/admin-logs.spec.ts new file mode 100644 index 0000000000..c368f1d939 --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/admin-logs.spec.ts @@ -0,0 +1,75 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +const adminLogsUrlRe = /\/admin\/logs/ +const logCountRe = /Total\s*:\s*(\d+)/ + +async function goToAdminLogs(page: Page) { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationLogs').click() + await expect(page).toHaveURL(adminLogsUrlRe) + await expect(page.getByTestId('logCountInfo')).toBeVisible() +} + +test.describe('Administration logs', { tag: '@e2e' }, () => { + test('Should display logs list, logged in as admin', async ({ page }) => { + await goToAdminLogs(page) + + const logCountText = await page.getByTestId('logCountInfo').textContent() + expect(logCountText).toBeTruthy() + const total = Number((logCountText || '').match(logCountRe)?.[1] || 0) + + const positionInfo = page.getByTestId('positionInfo').first() + const seePreviousPageBtn = page.getByTestId('seePreviousPageBtn').first() + const seeFirstPageBtn = page.getByTestId('seeFirstPageBtn').first() + const seeNextPageBtn = page.getByTestId('seeNextPageBtn').first() + const seeLastPageBtn = page.getByTestId('seeLastPageBtn').first() + + await expect(positionInfo).toContainText('sur') + await expect(seePreviousPageBtn).toBeDisabled() + await expect(seeFirstPageBtn).toBeDisabled() + + const logs = page.locator('[data-testid$="-json"]') + await expect(logs).toHaveCount(Math.min(10, total)) + + if (total > 10) { + await expect(seeNextPageBtn).toBeEnabled() + await expect(seeLastPageBtn).toBeEnabled() + + const firstLogText = await logs.first().textContent() + await seeNextPageBtn.click() + + await expect(seePreviousPageBtn).toBeEnabled() + await expect(seeFirstPageBtn).toBeEnabled() + await expect(positionInfo).toContainText('sur') + await expect(logs.first()).not.toHaveText(firstLogText || '') + + await seeFirstPageBtn.click() + await expect(seePreviousPageBtn).toBeDisabled() + await expect(seeFirstPageBtn).toBeDisabled() + } else { + await expect(seeNextPageBtn).toBeDisabled() + await expect(seeLastPageBtn).toBeDisabled() + } + }) + + test('Should toggle compact logs display, logged in as admin', async ({ page }) => { + await goToAdminLogs(page) + + await expect(page.locator('h1')).toContainText( + 'Journaux des services associés à la chaîne DSO', + ) + + const jsonBoxes = page.locator('.json-box') + await expect(jsonBoxes.first()).toBeVisible() + + await page.getByTestId('showLogsBtn').click() + await expect(jsonBoxes).toHaveCount(0) + + await page.getByTestId('showLogsBtn').click() + await expect(jsonBoxes.first()).toBeVisible() + }) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/admin-stages.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/admin-stages.spec.ts new file mode 100644 index 0000000000..0964f9f100 --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/admin-stages.spec.ts @@ -0,0 +1,131 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative, testUser } from '../config/console' + +import { createProject } from '../helpers/project' +import { createStage, deleteStage } from '../helpers/stage' + +test.describe('Stages administration page', { tag: '@e2e' }, () => { + test('should display default stages list', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + for (const stageName of ['dev', 'integration', 'prod', 'staging']) { + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + } + }) + + test('should create a custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'all' }) + // Validation de la création + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Check stage availability in environment form + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = faker.string.alpha(10).toLowerCase() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page.getByLabel('Choix de la zone cible').selectOption({ label: 'publique' }) + await page.getByLabel('Type d\'environnement').selectOption({ label: stageName }) + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page.getByLabel('Choix du cluster cible').selectOption({ label: 'public1' }) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + }) + + test('should update a custom stage', async ({ page }) => { + // Create stage (1 cluster) + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'first' }) + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + // Update stage (adding 1 extra cluster) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.locator('h1')).toContainText(`Informations du type d\'environnement ${stageName}`) + await expect(page.getByTestId('addStageBtn')).not.toBeVisible() + await expect(page.getByTestId('updateStageBtn')).toBeVisible() + await expect(page.getByTestId('updateStageBtn')).toBeEnabled() + await expect(page.getByTestId('nameInput')).toBeDisabled() + await expect(page.getByTestId('nameInput')).toHaveValue(stageName) + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(1) + await page.locator('.fr-tag').first().click() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(2) + await page.getByTestId('updateStageBtn').click() + // Check stage (2 clusters) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.locator('h1')).toContainText(`Informations du type d\'environnement ${stageName}`) + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(2) + }) + + test('should not be able to create a stage with an existing name', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + // Try to create a stage "dev" + await expect(page.getByTestId('addStageLink')).toBeVisible() + await page.getByTestId('addStageLink').click() + await page.getByTestId('nameInput').fill('dev') + await page.getByTestId('addStageBtn').click() + await expect(page.getByTestId('snackbar').getByText('Un type d\'environnement portant ce nom existe déjà')).toBeVisible() + }) + + test('should delete a custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'first' }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Delete custom stage + await deleteStage({ page, stageName }) + await expect(page.getByTestId(`stageTile-${stageName}`)).not.toBeVisible() + }) + + test('should not delete a used custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'all' }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Use custom stage in an environment + await createProject({ page }) + const envName = faker.string.alpha(10).toLowerCase() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page.getByLabel('Choix de la zone cible').selectOption({ label: 'publique' }) + await page.getByLabel('Type d\'environnement').selectOption({ label: stageName }) + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page.getByLabel('Choix du cluster cible').selectOption({ label: 'public1' }) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Check custom stage in admin view + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.getByTestId('deleteStageZone')).not.toBeVisible() + await expect(page.getByTestId('associatedEnvironmentsZone')).toBeVisible() + await expect(page.getByTestId('associatedEnvironmentsZone')).toContainText('Le type d\'environnement ne peut être supprimé') + await expect(page.getByTestId('associatedEnvironmentsTable')).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable').getByText(envName), + ).toBeVisible() + }) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/admin-users.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/admin-users.spec.ts new file mode 100644 index 0000000000..82e3a15cb4 --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/admin-users.spec.ts @@ -0,0 +1,71 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { setCheckbox, unsetCheckbox } from 'helpers/checkbox' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +interface ApiUser { + id: string + email: string + firstName: string + lastName: string + type?: string +} + +const adminUsersUrlRe = /\/admin\/users/ +const listUsersUrlRe = /\/api\/v1\/users(?:\?|$)/ + +async function goToAdminUsers(page: Page) { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationUsers').click() + await expect(page).toHaveURL(adminUsersUrlRe) + await expect(page.getByTestId('tableAdministrationUsers')).toBeVisible() +} + +test.describe('Administration users', { tag: '@e2e' }, () => { + test('Should display admin users, logged in as admin', async ({ page }) => { + const usersResponse = page.waitForResponse((res) => { + return ( + res.request().method() === 'GET' + && listUsersUrlRe.test(res.url()) + && res.status() >= 200 + && res.status() < 300 + ) + }) + + await goToAdminUsers(page) + + const users = (await (await usersResponse).json()) as ApiUser[] + expect(users.length).toBeGreaterThan(0) + + for (const user of users) { + const row = page.getByTestId(`user-${user.id}`) + await expect(row).toContainText(user.email) + await expect(row).toContainText(user.lastName) + await expect(row).toContainText(user.firstName) + await expect(row).toContainText('202') + } + + await setCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersDisplayId')) + for (const user of users) { + await expect(page.getByTestId(`user-${user.id}`)).toContainText(user.id) + } + + const botUser = users.find(u => u.email.includes('anon@user')) ?? users.find(u => u.type && u.type !== 'human') + if (botUser) { + await expect(page.getByTestId(`user-${botUser.id}`)).toBeVisible() + await setCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersHideBots')) + await expect(page.getByTestId(`user-${botUser.id}`)).toHaveCount(0) + await unsetCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersHideBots')) + } + + const rows = page.getByTestId('tableAdministrationUsers').locator('tbody tr') + expect(await rows.count()).toBeGreaterThanOrEqual(users.length) + + const search = page.getByTestId('tableAdministrationUsersSearch') + await expect(search).toBeVisible() + await search.fill(botUser?.email ?? users[0].email) + await expect(page.getByTestId('tableAdministrationUsers').locator('tbody tr')).toHaveCount(1) + }) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/clusters.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/clusters.spec.ts new file mode 100644 index 0000000000..e7917af0d2 --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/clusters.spec.ts @@ -0,0 +1,235 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' +import { createCluster, deleteCluster } from '../helpers/cluster' +import { createEnvironment } from '../helpers/environment' +import { openClustersAdministration } from '../helpers/navigation' +import { createProject } from '../helpers/project' + +test.describe('Clusters page', () => { + test('should create a public cluster', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'first', + }) + // Validate + await expect(page.getByTestId(`clusterZone-${clusterName}`)).toContainText( + 'publique', + ) + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Public', + ) + }) + + test('should update a public cluster', { tag: '@e2e' }, async ({ page }) => { + const clusterName2 = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'public', + }) + // Update + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('labelInput')).toHaveValue(clusterName) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await page.getByTestId('labelInput').fill(clusterName2) + await expect(page.getByTestId('updateClusterBtn')).toBeVisible() + await expect(page.locator('#projects-select')).not.toBeVisible() + await expect(page.locator('#privacy-select option[selected]')).toHaveText( + 'public', + ) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('1') + await page.getByTestId('updateClusterBtn').click() + // Validate + await expect(page.getByTestId(`clusterResources-${clusterName2}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + }) + + test( + 'should create a dedicated cluster', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Validate + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Dédié', + ) + }, + ) + + test( + 'should associate a project to a dedicated cluster', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a dedicated project + const { name: projectName } = await createProject({ page }) + // Create the cluster + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Validate + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('updateClusterBtn')).toBeVisible() + await expect(page.locator('#projects-select')).toBeVisible() + await expect(page.locator('#privacy-select option[selected]')).toHaveText( + 'dedicated', + ) + // Associating specifically created project + await page.locator('#projects-select').click() + await expect( + page.locator('#projects-select .fr-tag--dismiss'), + ).toHaveCount(0) + await page + .getByTestId('choice-selector-search-projects-select') + .fill(projectName) + await page.locator('#projects-select .fr-tag').click() + await expect( + page.locator('#projects-select .fr-tag--dismiss'), + ).toHaveCount(1) + await page.getByTestId('updateClusterBtn').click() + // Validate + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Dédié', + ) + }, + ) + + test('should create a cluster even if given informations is longer than 200 chars but shorter than 1001 chars', async ({ + page, + }) => { + const informations = faker.string.alpha(1000).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + informations, + }) + // Validate + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('infosInput')).toHaveValue(informations) + }) + + test('should NOT create a cluster when given informations is longer than 1000 chars', async ({ + page, + }) => { + const clusterName = faker.string.alpha(10).toLowerCase() + const informations = faker.string.alpha(1001).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationClusters').click() + await page.getByTestId('addClusterLink').click() + await page.getByTestId('labelInput').fill(clusterName) + await page.getByTestId('infosInput').fill(informations) + await expect(page.getByTestId('addClusterBtn')).toBeDisabled() + }) + + test('should delete a cluster', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Delete + await deleteCluster({ page, clusterName }) + // Validate + await page.getByTestId('projectsSearchInput').fill(clusterName) + await expect(page.getByTestId('noClusterMsg')).toBeVisible() + await expect(page.getByTestId('noClusterMsg')).toHaveText( + 'Aucun cluster trouvé', + ) + }) + + test( + 'should NOT delete a cluster with associated environment', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a public cluster + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'all', + }) + // Create a project and an environment using this cluster + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: clusterName, + cpuInput: '2', + gpuInput: '0', + }) + // Try to delete the cluster + await expect(page.getByText('Opération en cours...')).toHaveCount(0) + await openClustersAdministration({ page }) + await page.getByTestId('projectsSearchInput').fill(clusterName) + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('deleteClusterZone')).not.toBeVisible() + await expect( + page.getByText('Le cluster ne peut être supprimé'), + ).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable'), + ).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable').getByText(envName), + ).toBeVisible() + }, + ) + + test('should NOT update the cluster label if it has associated environment', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a public cluster + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'all', + }) + // Create a project and an environment using this cluster + await createProject({ page }) + await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: clusterName, + cpuInput: '2', + gpuInput: '0', + }) + // Verify that cluster label is disabled + await expect(page.getByText('Opération en cours...')).toHaveCount(0) + await openClustersAdministration({ page }) + await page.getByTestId('projectsSearchInput').fill(clusterName) + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('labelInput')).toHaveValue(clusterName) + await expect(page.getByTestId('labelInput')).toBeDisabled() + }) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/dashboard.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/dashboard.spec.ts new file mode 100644 index 0000000000..cb72d47d0d --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/dashboard.spec.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + signInCloudPiNative, + testUser, +} from '../config/console' +import { createProject, projectSlugTextRegexp } from '../helpers/project' + +test.describe('Dashboard page', () => { + test( + 'Should display a project statuses', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { + name: projectName, + id: projectId, + slug: projectSlug, + } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + expect(await page.getByTestId('project-id').getAttribute('title')).toBe( + projectId, + ) + await expect(page.locator('h1')).toContainText(projectName) + }, + ) + + test( + 'Should add, display and edit description', + { tag: '@e2e' }, + async ({ page, browserName }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const description1 = 'Application de prise de rendez-vous en préfécture.' + const description2 + = 'Application d\'organisation de tournois de pétanque interministériels.' + + // Act 1 + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('setDescriptionBtn').click() + // Yep, we need that for now... + // @TODO Ensure setDescriptionBtn is properly chained to descriptionInput + if (browserName === 'chromium') { + await page.waitForTimeout(1000) + } + await page.getByTestId('descriptionInput').fill(description1) + await page.getByTestId('saveDescriptionBtn').click() + // Assert 1 + await expect(page.getByTestId('descriptionP')).toHaveText(description1) + + // Act 2 + await page.getByTestId('setDescriptionBtn').click() + if (browserName === 'chromium') { + await page.waitForTimeout(1000) + } + await page.getByTestId('descriptionInput').fill(description2) + await page.getByTestId('saveDescriptionBtn').click() + // Assert 2 + await expect(page.getByTestId('descriptionP')).toHaveText(description2) + }, + ) + + test('Should show project secrets', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await expect(page.getByTestId('projectSecretsZone')).not.toBeVisible() + await page.getByTestId('showSecretsBtn').click() + + // Assert + await expect(page.getByTestId('projectSecretsZone')).toBeVisible() + await expect(page.getByTestId('noProjectSecretsP')).toHaveText( + 'Aucun secret à afficher', + ) + }) + + test('Should replay hooks for project', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('snackbar')).toContainText( + 'Le projet a été reprovisionné avec succès', + ) + }) + + test( + 'Should not be able to access project secrets if not owner', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName, slug: projectSlug } = await createProject({ + page, + }) + + // Act + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('projectSecretsZone')).not.toBeVisible() + await expect(page.getByTestId('showSecretsBtn')).not.toBeVisible() + }, + ) + + // @TODO: This test's expectation was reversed in Cypress E2E tests ("you should not be able + // to archive someone else's project"), but the reality is that you actually can do that, + // and Playwright detects it as well. So, for the time being, and in order to make the test + // pass, we reversed this test's expectation: You indeed can (and therefore should be able to) + // archive someone else's project ! + test( + 'Should be able to archive a project if not owner', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName, slug: projectSlug } = await createProject({ + page, + }) + + // Act + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('showArchiveProjectBtn')).toBeVisible() + }, + ) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/deployment.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/deployment.spec.ts new file mode 100644 index 0000000000..77df873337 --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/deployment.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, testUser } from 'config/console' +import { createDeployment } from 'helpers/deployment' +import { createEnvironment } from 'helpers/environment' +import { createProject, deleteProject } from 'helpers/project' +import { createRepository } from 'helpers/repository' + +test.describe('Déploiement', { tag: '@e2e' }, () => { + let projectName: string + + test.beforeEach(async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name } = await createProject({ page }) + projectName = name + }) + + // Not working on CI (need to mock plugins....) + test.skip('should not be able to create a deployment without an environment or a repository', async ({ page }) => { + await page.getByRole('button', { name: 'Ajouter un nouveau déploiement' }).click() + await expect(page.getByText('Pour créer un déploiement, vous devez d\'abord créer un environnement et un dépôt.')).toBeVisible() + }) + + // Not working on CI (need to mock plugins....) + test.skip('should create a deployment', async ({ page }) => { + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + cpuInput: '2', + gpuInput: '0', + }) + const repoName = await createRepository({ page }) + + const deploymentName = await createDeployment({ page, envName, repoName, customStageName: 'dev' }) + await expect(page.getByText(deploymentName)).toBeVisible() + }) + + test.afterEach(async ({ page }) => { + if (!projectName) return + await deleteProject({ page, projectName }) + }) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/environments.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/environments.spec.ts new file mode 100644 index 0000000000..5035f890d1 --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/environments.spec.ts @@ -0,0 +1,335 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + cnolletUser, + signInCloudPiNative, + testUser, +} from '../config/console' + +import { unsetCheckbox } from '../helpers/checkbox' +import { createEnvironment, deleteEnvironment } from '../helpers/environment' +import { createProject } from '../helpers/project' + +test.describe('Environments page', { tag: '@e2e' }, () => { + test('should add environments to an existing project', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + cpuInput: '2', + gpuInput: '0', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + }) + + test('should not add environments to a project without enough hprod GPU', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ + page, + hprodResources: { + cpu: 10, + memory: 10, + gpu: 1, + }, + }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + memoryInput: '2', + gpuInput: '2', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).not.toBeVisible() + await expect( + page + .getByTestId('snackbar') + .getByText( + 'Le projet ne dispose pas de suffisamment de ressources : GPU.', + ), + ).toBeVisible() + }) + + test('should not add incorrect environments', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + + // Incorrect input + await page.getByTestId('environmentNameInput').click() + await page.getByTestId('environmentNameInput').fill(`${envName}-yolo`) + await expect( + page.getByRole('alert').getByText('Le nom de l\'environnment ne doit pas'), + ).toBeVisible() + + // Valid input + await page.getByTestId('cancelEnvironmentBtn').click() + await createEnvironment({ + page, + envName, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + // Second try with same name (invalid) + await createEnvironment({ + page, + envName, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect( + page + .getByTestId('snackbar') + .getByText('Ce nom d\'environnement est déjà pris'), + ).toBeVisible() + }) + + test('should alert cluster unavailability', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'Zone privée' }) + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: 'dev' }) + await expect(page.getByTestId('noClusterOptionAlert')).toBeVisible() + await expect(page.getByLabel('Choix du cluster cible')).not.toBeVisible() + + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'publique' }) + await expect(page.getByTestId('noClusterOptionAlert')).not.toBeVisible() + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + }) + + test('should display zone infos and cluster infos', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'publique' }) + await expect(page.getByTestId('chosenZoneDescription')).toBeVisible() + + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: 'dev' }) + await page + .getByLabel('Choix du cluster cible') + .selectOption({ label: 'public1' }) + await expect(page.getByTestId('clusterInfos')).toBeVisible() + }) + + test('should update environment resources', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toBeVisible() + await expect(page.locator('#zone-select option[selected]')).toHaveText( + 'publique', + ) + await expect(page.locator('#stage-select option[selected]')).toHaveText( + 'dev', + ) + await expect(page.locator('#cluster-select option[selected]')).toHaveText( + 'public1', + ) + await expect(page.getByTestId('memoryInput')).toHaveValue('1') + await expect(page.getByTestId('cpuInput')).toHaveValue('1') + await expect(page.getByTestId('gpuInput')).toHaveValue('1') + await page.getByTestId('memoryInput').fill('1.5') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('10') + await page.getByTestId('putEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1.5GiB 2CPU 10GPU', + ) + }) + + test('should not update environment resources when cluster is too small', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toBeVisible() + await expect(page.locator('#zone-select option[selected]')).toHaveText( + 'publique', + ) + await expect(page.locator('#stage-select option[selected]')).toHaveText( + 'dev', + ) + await expect(page.locator('#cluster-select option[selected]')).toHaveText( + 'public1', + ) + await expect(page.getByTestId('memoryInput')).toHaveValue('1') + await expect(page.getByTestId('cpuInput')).toHaveValue('1') + await expect(page.getByTestId('gpuInput')).toHaveValue('1') + await page.getByTestId('memoryInput').fill('120') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('1') + await page.getByTestId('putEnvironmentBtn').click() + await expect( + page + .getByTestId('snackbar') + .getByText( + 'Le cluster ne dispose pas de suffisamment de ressources : Mémoire.', + ), + ).toBeVisible() + }) + + test('should delete an environment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + await deleteEnvironment({ page, projectName, envName }) + }) + + test('should not be able to delete an environment if not owner', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ + page, + members: [cnolletUser], + }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Sign off and login as another user (project member) + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: cnolletUser }) + // Select previously created project + await page.getByTestId('menuMyProjects').click() + await expect(page.getByTestId('createProjectLink')).toBeVisible() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + // Verify absence of delete button + await page.getByTestId(`environmentTr-${envName}`).click() + await expect( + page.getByTestId('showDeleteEnvironmentBtn'), + ).not.toBeVisible() + }) + + test('should not be able to delete an environment if project locked', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName } = await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Sign off and login as admin to lock the project + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await page.getByTestId('handleProjectLockingBtn').click() + + // Sign off and login back as user + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: testUser }) + // Verify absence of delete button + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId(`environmentTr-${envName}`).click() + await expect( + page.getByTestId('showDeleteEnvironmentBtn'), + ).not.toBeVisible() + }) + + test('should show a warning if autosync is deactivated', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Verify warning message + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('input-checkbox-autosyncCbx')).toBeVisible() + await expect(page.getByTestId('input-checkbox-autosyncCbx')).toBeChecked() + await expect(page.getByTestId('noAutosyncAlert')).not.toBeVisible() + + // Act - Uncheck auto-sync to trigger warning message + await unsetCheckbox(page.getByTestId('input-checkbox-autosyncCbx')) + await expect(page.getByTestId('noAutosyncAlert')).toBeVisible() + await expect(page.getByTestId('noAutosyncAlert')) + .toHaveText('La synchronisation automatique est désactivée. Les déploiements devront être synchronisés manuellement.') + }) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/keycloak.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/keycloak.spec.ts new file mode 100644 index 0000000000..17c87fb6d4 --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/keycloak.spec.ts @@ -0,0 +1,185 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { loadKeycloakConfig, signInKeycloak } from '../config/keycloak' + +const keycloakConfig = loadKeycloakConfig() +const usersToDelete: string[] = [] +const groupsToDelete: string[] = [] + +export async function createUser(page: Page, usersToDelete: string[]) { + await page.getByRole('link', { name: 'Users' }).click() + await page.getByTestId('add-user').click() + const username = faker.internet.username() + usersToDelete.push(username) + await page + .getByRole('textbox', { name: 'Username' }) + .fill(username) + await page.getByTestId('email').fill(faker.internet.email()) + await page.getByTestId('user-creation-save').click() + await expect(page.getByLabel('The user has been created')).toBeVisible() + return username +} + +export async function createGroup(page: Page, groupsToDelete: string[]) { + await page.getByRole('link', { name: 'Groups' }).click() + await page.getByRole('button', { name: 'Create group' }).click() + const groupName = faker.string.alpha(10) + groupsToDelete.push(groupName) + await page.getByTestId('name').fill(groupName) + await page.getByTestId('createGroup').click() + await expect(page.getByLabel('Group created')).toBeVisible() + return groupName +} + +test.describe('Keycloak', () => { + test.beforeEach(async ({ page }) => { + await signInKeycloak(page) + }) + + test('should sign in to master realm', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + // expect also exists in sign in fuction but we check it explicitely here + // for documentation purposes + await expect(page.getByRole('link', { name: 'Logo' })).toBeVisible() + await page.getByTestId('realmSelector').click() + await expect(page.getByRole('menuitem', { name: keycloakConfig.realm })).toBeVisible() + }) + + test('should have a CπN realm', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByTestId('realmSelector').click() + await expect( + page.getByRole('menuitem', { name: keycloakConfig.realm }), + ).toBeVisible() + }) + + test.describe('CπN realm', () => { + test.beforeEach(async ({ page }) => { + await page.getByTestId('realmSelector').click() + await page.getByRole('menuitem', { name: keycloakConfig.realm }).click() + }) + + test('should have required Clients', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByTestId('realmSelector').click() + await page.getByRole('menuitem', { name: keycloakConfig.realm }).click() + await page.getByRole('link', { name: 'Clients' }).click() + await expect(page.getByRole('link', { name: keycloakConfig.clientFrontend })).toBeVisible() + await expect(page.getByRole('link', { name: keycloakConfig.clientBackend })).toBeVisible() + await page.getByRole('link', { name: 'Client scopes' }).click() + await expect(page.getByRole('link', { name: 'generic' })).toBeVisible() + }) + + test('should have required Clients scopes', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByRole('link', { name: 'Client scopes' }).click() + await expect(page.getByRole('link', { name: 'generic' })).toBeVisible() + }) + + test('should create a user', { tag: '@e2e' }, async ({ page }) => { + await createUser(page, usersToDelete) + }) + + test('should create a group', { tag: '@e2e' }, async ({ page }) => { + await createGroup(page, groupsToDelete) + }) + + test('should link a user to a group', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + const username = await createUser(page, usersToDelete) + const groupName = await createGroup(page, groupsToDelete) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .fill(groupName) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page.getByRole('button', { name: groupName }).click() + await page.getByTestId('members').click() + await page.getByRole('button', { name: 'Add member' }).click() + await page + .getByTestId('titleUsersinput') + .getByRole('textbox', { name: 'Search' }) + .fill(username) + await page + .getByTestId('titleUsersinput') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page.getByRole('checkbox', { name: 'Select row 0' }).check() + await page.getByTestId('add').click() + await expect(page.getByText('user added to the group')).toBeVisible() + }) + + test('should delete a group', { tag: '@e2e' }, async ({ page }) => { + const groupName = await createGroup(page, groupsToDelete) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .fill(groupName) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page + .getByRole('treeitem', { name: `${groupName} Actions` }) + .getByLabel('Actions') + .click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByTestId('confirm').click() + }) + + test('should delete a user', { tag: '@e2e' }, async ({ page }) => { + await createUser(page, usersToDelete) + await page.getByTestId('action-dropdown').click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByTestId('confirm').click() + }) + + test('cleanup Keycloak test data', { tag: '@integ' }, async ({ request }) => { + const params = new URLSearchParams() + params.append('grant_type', 'password') + params.append('client_id', 'admin-cli') + params.append('username', keycloakConfig.adminUser) + params.append('password', keycloakConfig.adminPass) + + const tokenRes = await request.post( + `${keycloakConfig.url}/realms/master/protocol/openid-connect/token`, + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: params.toString(), + }, + ) + + if (!tokenRes.ok) { + throw new Error(`Failed to get token: ${tokenRes.statusText}`) + } + + const tokenData = await tokenRes.json() + const authHeader = { Authorization: `Bearer ${tokenData.access_token}` } + + for (const username of usersToDelete) { + const usersRes = await request.get( + `${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users?username=${username}`, + { headers: authHeader }, + ) + const users = await usersRes.json() + for (const user of users) { + await request.delete(`${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users/${user.id}`, { + headers: authHeader, + }) + } + } + + for (const groupName of groupsToDelete) { + const groupsRes = await request.get( + `${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/groups?search=${groupName}`, + { headers: authHeader }, + ) + const groups = await groupsRes.json() + for (const group of groups) { + await request.delete(`${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/groups/${group.id}`, { + headers: authHeader, + }) + } + } + }) + }) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/profile.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/profile.spec.ts new file mode 100644 index 0000000000..0794c9c6e5 --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/profile.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test' + +import { + clientURL, + cnolletUser, + signInCloudPiNative, + tcolinUser, +} from '../config/console' + +test.describe('Profile page', () => { + test('Should display name once logged', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + + await expect(page.getByTestId('menuUserList')).toContainText( + `${cnolletUser.firstName} ${cnolletUser.lastName}`, + ) + }) + + test('Should display profile infos', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.goto(`${clientURL}/profile/info`) + + const locator = expect(page.getByTestId('profileInfos')) + await locator.toContainText( + `${tcolinUser.lastName}, ${tcolinUser.firstName}`, + ) + await locator.toContainText(tcolinUser.id) + await locator.toContainText('Admin') + await locator.toContainText(tcolinUser.email) + }) + + test('Should create a PAT', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.goto(`${clientURL}/profile/tokens`) + + // Act + await page.getByTestId('showNewTokenFormBtn').click() + await page.getByTestId('newTokenName').fill('test2') + await page.getByTestId('expirationDateInput').fill('2100-11-22') + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('newTokenPassword')).toBeVisible() + await page.getByTestId('showNewTokenFormBtn').click() + + // Assert + expect(page.getByTestId('newTokenPassword')).toBeDefined() + const locator = page.locator('tr', { hasText: 'test2' }) + expect(locator).toContainText(new Date().getFullYear().toString()) + expect(locator).toContainText('2100') + expect(locator).toContainText('Jamais') + expect(locator).toContainText('Actif') + await locator.getByTitle('Supprimer').click() + await page.getByTestId('confirmDeletionBtn').click() + await expect(page.getByTestId('tokenTable')).toContainText( + 'Aucune clé d\'api existante', + ) + }) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/project-logs.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/project-logs.spec.ts new file mode 100644 index 0000000000..78c0798827 --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/project-logs.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test' +import { + clientURL, + signInCloudPiNative, + tcolinUser, + testUser, +} from '../config/console' +import { createProject } from '../helpers/project' + +test.describe('Project logs page', () => { + test( + 'Should display project logs as owner', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + // Act + await createProject({ page }) + + // Assert + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + }, + ) + + test( + 'Should display additional logs after reprovisionning a project', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + + // Act + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 7', + ) + }, + ) + + test( + 'Should display project logs as project manager as well as a project member', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + // Act + const { name: projectName } = await createProject({ + page, + members: [tcolinUser], + }) + + // Assert - as Project Owner + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + + // Assert - as Project Member + await page.getByRole('link', { name: 'Se Déconnecter' }).click() + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 7', + ) + }, + ) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/project-roles.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/project-roles.spec.ts new file mode 100644 index 0000000000..23e9cf19fc --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/project-roles.spec.ts @@ -0,0 +1,201 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { setCheckbox, unsetCheckbox } from 'helpers/checkbox' +import { clientURL, cnolletUser, signInCloudPiNative, testUser } from '../config/console' +import { createEnvironment } from '../helpers/environment' +import { createProject, deleteProject } from '../helpers/project' +import { createRepository } from '../helpers/repository' + +async function openProjectByName({ page, projectName }: { page: Page, projectName: string }) { + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() +} + +async function openProjectRoleByName({ page, roleName }: { page: Page, roleName: string }) { + await page.getByTestId('test-tab-roles').click() + await page + .locator('[data-testid$="-tab"]') + .filter({ hasText: roleName }) + .click() +} + +async function assignPerms({ + page, + roleName, + perms, +}: { + page: Page + roleName: string + perms: readonly string[] +}) { + await openProjectRoleByName({ page, roleName }) + const setPermChecked = async (key: string, checked: boolean) => { + const input = page.locator(`#${key}-cbx`) + await expect(input).toBeVisible() + if ((await input.isChecked()) === checked) + return + + const label = page.locator(`label[for="${key}-cbx"]`) + if (await label.count()) { + await label.click() + } else { + await setCheckbox(input) + } + + try { + await expect(input).toBeChecked({ checked }) + } catch { + await input.evaluate((el: HTMLInputElement, nextChecked: boolean) => { + el.checked = nextChecked + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, checked) + await expect(input).toBeChecked({ checked }) + } + } + + for (const key of perms) { + await setPermChecked(key, true) + } + await expect(page.getByTestId('saveBtn')).toBeEnabled() + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') +} + +test.describe.serial('Project roles', { tag: '@e2e' }, () => { + const newRoleName = `role-${faker.string.alpha(10).toLowerCase()}` + let projectName = '' + let repositoryName = '' + let environmentName = '' + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + const project = await createProject({ page, members: [cnolletUser] }) + projectName = project.name + + repositoryName = await createRepository({ page }) + environmentName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + await page.getByTestId('test-tab-roles').click() + await page.getByTestId('addRoleBtn').click() + await expect(page.getByTestId('snackbar')).toContainText('Rôle ajouté') + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRoleName) + await page.getByTestId('saveBtn').click() + + await page.getByRole('tab', { name: 'Membres' }).click() + await setCheckbox(page.getByTestId(`input-checkbox-${cnolletUser.id}-cbx`)) + await page.close() + }) + + test.afterAll(async ({ browser }) => { + if (!projectName) + return + const page = await browser.newPage() + try { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await deleteProject({ page, projectName }) + } finally { + await page.close() + } + }) + + test('Should not grant perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + await openProjectByName({ page, projectName }) + + await page.getByTestId('test-tab-team').click() + await expect(page.getByTestId('teamTable').getByText('Retirer du projet')).toBeVisible() + await expect(page.getByTestId('addUserSuggestionInput')).toHaveCount(0) + await expect(page.getByTestId('showTransferProjectBtn')).toHaveCount(0) + await expect(page.getByTestId('test-tab-roles')).toBeVisible() + }) + + test('System roles forbid edits', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + + await openProjectRoleByName({ page, roleName: 'DevOps' }) + + await expect(page.getByTestId('roleNameInput')).toBeDisabled() + await expect(page.locator('#LIST_ENVIRONMENTS-cbx')).toBeDisabled() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('deleteBtn')).toHaveCount(0) + }) + + test('System roles allow member assignment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + await openProjectRoleByName({ page, roleName: 'DevOps' }) + + const membersTab = page.getByRole('tab', { name: 'Membres' }) + await expect(membersTab).toBeVisible() + await membersTab.click() + + const memberCheckbox = page.getByTestId(`input-checkbox-${cnolletUser.id}-cbx`) + await expect(memberCheckbox).toBeVisible() + + await setCheckbox(memberCheckbox) + await expect(memberCheckbox).toBeChecked() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') + + await unsetCheckbox(memberCheckbox) + await expect(memberCheckbox).not.toBeChecked() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') + }) + + test('Should assign view perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + await assignPerms({ + page, + roleName: newRoleName, + perms: ['LIST_ENVIRONMENTS', 'LIST_REPOSITORIES'], + }) + }) + + test('Should grant view perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + await openProjectByName({ page, projectName }) + await page.getByTestId('test-tab-resources').click() + + await expect(page.getByTestId('noReposTr')).toHaveCount(0) + await expect(page.getByTestId('addRepoLink')).toBeHidden() + await page.getByTestId(`repoTr-${repositoryName}`).click() + await expect(page.getByTestId('syncRepoBtn')).toHaveCount(0) + await expect(page.getByTestId('updateRepoBtn')).toHaveCount(0) + await expect(page.getByTestId('showDeleteRepoBtn')).toHaveCount(0) + await page.locator('.fr-modal__header > button.fr-btn--close').click() + + await expect(page.getByTestId('noEnvsTr')).toHaveCount(0) + await expect(page.getByTestId('addEnvironmentLink')).toBeHidden() + await page.getByTestId(`environmentTr-${environmentName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toHaveCount(0) + await expect(page.getByTestId('showDeleteEnvironmentBtn')).toHaveCount(0) + await page.locator('.fr-modal__header > button.fr-btn--close').click() + + await page.getByTestId('test-tab-team').click() + await expect(page.getByTestId('teamTable').getByText('Retirer du projet')).toBeVisible() + await expect(page.getByTestId('addUserSuggestionInput')).toHaveCount(0) + await expect(page.getByTestId('showTransferProjectBtn')).toHaveCount(0) + + await page.getByTestId('test-tab-roles').click() + await expect(page.getByTestId('insuficientPermsRoles')).toBeVisible() + }) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/projects.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/projects.spec.ts new file mode 100644 index 0000000000..0cf0d62384 --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/projects.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + cnolletUser, + signInCloudPiNative, + testUser, +} from '../config/console' +import { deleteValidationInput } from '../helpers/constants' +import { createProject } from '../helpers/project' +import { createRepository, synchronizeBranchOnRepository } from '../helpers/repository' + +test.describe('Projects page', () => { + test( + 'Should display only projects that user is member of', + { tag: '@e2e' }, + async ({ page }) => { + // Create a project as one user + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ page }) + + // Sign off and login as another user + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: cnolletUser }) + + // Previously created project should not appear for this user + await page.getByTestId('menuMyProjects').click() + await expect(page.getByTestId('createProjectLink')).toBeVisible() + await expect( + page.getByRole('link', { name: projectName }), + ).not.toBeVisible() + }, + ) + + test( + 'Should not keep the same default branch name for all repositories of a projects', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + // Adding a project directly puts us on the newly created project page, + // so no need to add navigation steps to go there + const firstRepositoryName = await createRepository({ page }) + const branchName = await synchronizeBranchOnRepository({ + page, + repositoryName: firstRepositoryName, + }) + const secondRepositoryName = await createRepository({ page }) + await page.getByTestId(`repoTr-${secondRepositoryName}`).click() + await expect(page.getByTestId('resource-modal')).toBeVisible() + await expect(page.getByTestId('branchNameInput')).not.toHaveValue( + branchName, + ) + }, + ) + + // @TODO Archiving and Deleting a project is, basically, the same thing. It seems that + // initially we wanted to delete projects but we moved on to archiving, while not updating + // the frontend x) + test( + 'Should archive a project, when logged in as an admin', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const { id: projectId, name: projectName } = await createProject({ page }) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Tous') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('archiveProjectInput')).not.toBeVisible() + await page.getByTestId('showArchiveProjectBtn').click() + await expect(page.getByTestId('confirmDeletionBtn')).toBeDisabled() + await page.getByTestId('archiveProjectInput').fill(deleteValidationInput) + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Archivés') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + // Projects are renamed (suffixed with a timestamp and `_archived`) so @TODO: Do Better… + await expect(page.getByRole('table', { name: 'Liste des projets' })).toContainText(projectName) + }, + ) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/repos.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/repos.spec.ts new file mode 100644 index 0000000000..41362ea716 --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/repos.spec.ts @@ -0,0 +1,742 @@ +import { expect, test } from '@playwright/test' +import { + clientURL, + signInCloudPiNative, + tcolinUser, + testUser, +} from '../config/console' + +import { setCheckbox, unsetCheckbox } from '../helpers/checkbox' +import { + deleteValidationInput, + fakeToken, + invalidGitUrlErrorMessage, + invalidInternalRepoErrorMessage, +} from '../helpers/constants' +import { createProject, projectSlugTextRegexp } from '../helpers/project' + +test.describe('Repositories', () => { + // @TODO: Rework this Cypress-inherited test (use of following-sibling is a + // good hint that something is very very wrong) + test( + 'Should handle repository form validation', + { tag: ['@e2e', '@repos', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + internalRepoName: 'repo00', + externalRepoUrl: 'https://github.com/externalUser01/repo00.git', + externalUserName: 'user', + externalToken: 'videnden88EHEBdldd_T0k9n', + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + + await page.getByTestId('addRepoLink').click() + await page.locator('dialog').waitFor({ state: 'visible' }) + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await setCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await unsetCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('internalRepoNameInput').fill('$%_>') + await expect( + page + .getByTestId('internalRepoNameInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidInternalRepoErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await expect( + page + .getByTestId('internalRepoNameInput') + .locator('//following-sibling::*[1]'), + ).not.toBeVisible() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.slice(0, -4)) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.slice(8)) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.replace('https', 'http')) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).not.toBeVisible() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalUserNameInput') + .fill(repo.externalUserName) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalUserNameInput').clear() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalTokenInput').clear() + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await unsetCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + }, + ) + + test( + 'Should add a standalone public repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + internalRepoName: 'repo00', + isInfra: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await setCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + await page.getByTestId('addRepoBtn').click() + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.locator('h2')).not.toContainText( + 'Synchroniser le dépôt', + ) + }, + ) + + test( + 'Should add an external public repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser01/repo01.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo01', + isInfra: false, + isPrivate: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external private repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser02/repo02.git', + externalToken: 'hoqjC1vXtABzytBIWBXsdyzubmqMYkgA', + externalUserName: '', + internalRepoName: 'repo02', + isInfra: false, + isPrivate: true, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external public infra repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external private infra repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser04/repo04.git', + externalToken: '', + externalUserName: 'externalUser+04', + internalRepoName: 'repo04', + isInfra: true, + isPrivate: true, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page + .getByTestId('externalUserNameInput') + .fill(repo.externalUserName) + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should update a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Update repository + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('internalRepoNameInput')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill('https://github.com/externalUser04/new-repo.git') + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page.getByTestId('externalUserNameInput').fill('newUser') + await page.getByTestId('externalTokenInput').fill('newToken') + await expect( + page.getByTestId('input-checkbox-infraRepoCbx'), + ).toBeEnabled() + await page.getByTestId('updateRepoBtn').click() + await expect(page.getByTestId(`repoTr-repo03`)).toBeVisible() + await page.reload() + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('externalRepoUrlInput')).toHaveValue( + 'https://github.com/externalUser04/new-repo.git', + ) + await expect( + page.getByTestId('input-checkbox-privateRepoCbx'), + ).toBeChecked() + await expect(page.getByTestId('externalUserNameInput')).toHaveValue( + 'newUser', + ) + await expect(page.getByTestId('externalTokenInput')).toHaveValue( + fakeToken, + ) + }, + ) + + test( + 'Should synchronise a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Update repository + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('branchNameInput')).toHaveValue('main') + await expect(page.getByTestId('branchNameInput')).toBeEnabled() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page + .getByTestId('toggleSyncAllBranches') + .locator('input') + .check({ force: true }) + await expect(page.getByTestId('branchNameInput')).not.toBeVisible() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page + .getByTestId('toggleSyncAllBranches') + .locator('input') + .uncheck({ force: true }) + await expect(page.getByTestId('branchNameInput')).toHaveValue('main') + await expect(page.getByTestId('branchNameInput')).toBeEnabled() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page.getByTestId('syncRepoBtn').click() + await expect( + page.getByText( + 'Travail de synchronisation lancé pour le dépôt repo03Fermer le message', + ), + ).toBeVisible() + + await page.getByTestId('branchNameInput').clear() + await expect(page.getByTestId('syncRepoBtn')).toBeDisabled() + await page.getByTestId('branchNameInput').fill('develop') + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page.getByTestId('syncRepoBtn').click() + await expect( + page.getByText( + 'Travail de synchronisation lancé pour le dépôt repo03Fermer le message', + ), + ).toBeVisible() + }, + ) + + test( + 'Should delete a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Delete repository + // + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('deleteRepoInput')).not.toBeVisible() + await expect(page.getByTestId('deleteRepoZone')).toBeVisible() + await page.getByTestId('showDeleteRepoBtn').click() + await expect(page.getByTestId('deleteRepoBtn')).toBeDisabled() + await page.getByTestId('deleteRepoInput').fill(deleteValidationInput) + await page.getByTestId('deleteRepoBtn').click() + await expect(page.getByTestId('repo-form')).not.toBeVisible() + await page.reload() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).not.toBeVisible() + }, + ) + + test( + 'Should not be able to delete a repository if not owner', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { + id: projectId, + name: projectName, + slug: projectSlug, + } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + + // Assert - Attempt to delete repository + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('deleteRepoZone')).not.toBeVisible() + }, + ) + + test( + 'Should not be able to delete a repository if project locked', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ + page, + credentials: + // Only admin users can lock projects + tcolinUser, + }) + const { + id: projectId, + name: projectName, + slug: projectSlug, + } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Act - Lock the project (have to go through admin menu for that) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('handleProjectLockingBtn').click() + + // Assert - Attempt to delete repository + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('showDeleteRepoBtn')).not.toBeVisible() + }, + ) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/roles.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/roles.spec.ts new file mode 100644 index 0000000000..a81d940536 --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/roles.spec.ts @@ -0,0 +1,228 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from 'config/console' +import { setCheckbox } from 'helpers/checkbox' + +test.describe('Administration Roles', () => { + test( + 'Should list admin roles', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + // Assert + // Obviously, we should not use data-testid that are made of ids… + // @TODO: Do better. + await expect( + page.getByTestId('76229c96-4716-45bc-99da-00498ec9018c-tab'), + ).toBeVisible() + await expect( + page.getByTestId('eadf604f-5f54-4744-bdfb-4793d2271e9b-tab'), + ).toBeVisible() + }, + ) + + test('Should add a new OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newOidcRole = { + name: faker.string.alpha(10).toLowerCase(), + oidcGroupName: `/${faker.string.alpha(10).toLowerCase()}`, + user: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newOidcRole.name) + await page.getByTestId('oidcGroupInput').fill(newOidcRole.oidcGroupName) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).toContainText(newOidcRole.name) + await page.getByTestId('test-members').click() + await expect(page.getByTestId('addUserBtn')).toBeDisabled() + await page + .getByTestId('addUserSuggestionInput') + .locator('input') + .fill(`${newOidcRole.user.email}`) + await page.getByTestId('addUserBtn').click() + + // Assert + const membershipCheckbox = page.getByTestId( + `input-checkbox-${newOidcRole.user.id}-cbx`, + ) + await expect(membershipCheckbox).toBeVisible() + await expect(membershipCheckbox).toBeChecked() + }) + + test('Should add a new non-OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + }) + + test('Should add a user to a role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + user: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create non-OIDC role + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + // Add user to role + await page.getByTestId('test-members').click() + await expect(page.getByTestId('addUserBtn')).toBeDisabled() + await page + .getByTestId('addUserSuggestionInput') + .locator('input') + .fill(`${newRole.user.email}`) + await page.getByTestId('addUserBtn').click() + + // Assert + const membershipCheckbox = page.getByTestId( + `input-checkbox-${newRole.user.id}-cbx`, + ) + await expect(membershipCheckbox).toBeVisible() + await expect(membershipCheckbox).toBeChecked() + }) + + test('Should remove OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newOidcRole = { + name: faker.string.alpha(10).toLowerCase(), + oidcGroupName: `/${faker.string.alpha(10).toLowerCase()}`, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create OIDC role + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newOidcRole.name) + await page.getByTestId('oidcGroupInput').fill(newOidcRole.oidcGroupName) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newOidcRole.name) + // Delete role + await page.getByTestId('deleteBtn').click() + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + }) + + test('Should remove non-OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create OIDC role + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + // Delete role + await page.getByTestId('deleteBtn').click() + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + }) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/service-chains.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/service-chains.spec.ts new file mode 100644 index 0000000000..e1dc6def9d --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/service-chains.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' + +test.describe('Service Chains page', () => { + test.describe('Given an Admin-level user', () => { + test( + 'should list service chains', + { tag: ['@e2e', '@service-chains'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationServiceChains').click() + // We take the first service chain available + await expect( + page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1), + ).toBeVisible() + }, + ) + + test( + 'should show a service chain details', + { tag: ['@e2e', '@service-chains'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationServiceChains').click() + // We take the first service chain available + await page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1) + .click() + await expect( + page.getByRole('heading', { name: 'Chaîne de services' }), + ).toBeVisible() + }, + ) + + test( + 'should show a service chain flows', + { tag: ['@e2e', '@service-chains', '@service-chain-flows'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await expect(page.locator('.fr-collapsing')).toBeVisible() + await expect(page.locator('.fr-collapsing')).not.toBeVisible() + + await page.getByTestId('menuAdministrationServiceChains').click() + await expect( + page.getByTitle('Voir les détails de la chaîne de service').first(), + ).toBeVisible() + // We take the first service chain available + await page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1) + .click() + await expect(page.getByTestId('service-chain-flows')).toBeVisible() + }, + ) + }) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/tokens.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/tokens.spec.ts new file mode 100644 index 0000000000..f6aa4b34db --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/tokens.spec.ts @@ -0,0 +1,37 @@ +import { faker } from '@faker-js/faker' + +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +test.describe('Admin Tokens page', () => { + // @TODO The original test did actually test whether the generated token worked. + // Add a dedicated test to do exactly that. + test( + 'Should create tokens, when logged in as an admin', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const tokenName = faker.string.alpha(10).toLowerCase() + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationToken').click() + await page.getByTestId('showNewTokenFormBtn').click() + + await page.getByTestId('newTokenName').fill(tokenName) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('newTokenPassword')).toBeVisible() + // Reinit form should hide newly generated password + await page.getByTestId('showNewTokenFormBtn').click() + await expect(page.getByTestId('newTokenPassword')).not.toBeVisible() + + await expect(page.getByTestId('tokenTable')).toContainText(tokenName) + }, + ) + + // @TODO Add token deletion test (the original one used "nth" which is not reliable) +}) diff --git a/.jjconflict-side-1/playwright/e2e-tests/zones.spec.ts b/.jjconflict-side-1/playwright/e2e-tests/zones.spec.ts new file mode 100644 index 0000000000..04c61a951b --- /dev/null +++ b/.jjconflict-side-1/playwright/e2e-tests/zones.spec.ts @@ -0,0 +1,193 @@ +import { faker } from '@faker-js/faker' + +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' +import { deleteValidationInput } from '../helpers/constants' + +test.describe('Zone page', () => { + // @TODO: Add clusters to this test to ensure this part of the feature + test('Should create a zone', { tag: ['@e2e', '@need-rework'] }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + expect(expect(page.locator('#clusters-select')).not.toBeVisible()) + await page.getByTestId('addZoneBtn').click() + + await page.reload() + + // Assert + await page.locator(`#zoneTile-${zone.slug}`).click() + await expect(page.getByTestId('slugInput')).toHaveValue(zone.slug) + await expect(page.getByTestId('slugInput')).toBeDisabled() + await expect(page.getByTestId('labelInput')).toHaveValue(zone.label) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await expect(page.getByTestId('argocdUrlInput')).toHaveValue( + zone.argocdUrl, + ) + await expect(page.getByTestId('argocdUrlInput')).toBeEnabled() + await expect(page.getByTestId('descriptionInput')).toHaveValue( + zone.description, + ) + await expect(page.getByTestId('descriptionInput')).toBeEnabled() + await expect(page.locator('#clusters-select')).toBeVisible() + await expect(page.locator('#clusters-select')).toHaveAttribute( + 'disabled', + 'true', + ) + await expect(page.getByTestId('updateZoneBtn')).toBeEnabled() + }) + + test('Should update a zone', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + const updatedZone = { + label: 'Zone Mise à Jour', + argocdUrl: 'https://vousnetesplusici.fr', + description: 'Cette zone a été mise à jour.', + } + // Zone creation (for later update) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + + // Act + await page.locator(`#zoneTile-${zone.slug}`).click() + await page.getByTestId('labelInput').fill(updatedZone.label) + await page.getByTestId('argocdUrlInput').fill(updatedZone.argocdUrl) + await page.getByTestId('descriptionInput').fill(updatedZone.description) + await page.getByTestId('updateZoneBtn').click() + + // Assert + await page.locator(`#zoneTile-${zone.slug}`).click() + await expect(page.getByTestId('slugInput')).toHaveValue(zone.slug) + await expect(page.getByTestId('slugInput')).toBeDisabled() + await expect(page.getByTestId('labelInput')).toHaveValue(updatedZone.label) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await expect(page.getByTestId('argocdUrlInput')).toHaveValue( + updatedZone.argocdUrl, + ) + await expect(page.getByTestId('argocdUrlInput')).toBeEnabled() + await expect(page.getByTestId('descriptionInput')).toHaveValue( + updatedZone.description, + ) + await expect(page.getByTestId('descriptionInput')).toBeEnabled() + await expect(page.locator('#clusters-select')).toBeVisible() + await expect(page.locator('#clusters-select')).toHaveAttribute( + 'disabled', + 'true', + ) + await expect(page.getByTestId('updateZoneBtn')).toBeEnabled() + }) + + test('Should not create a zone if slug is already taken', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + // Create the zone + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + + // Act - attempt to create a zone with the same slug + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(`${zone.label}bis`) + await page.getByTestId('argocdUrlInput').fill(`${zone.argocdUrl}bis`) + await page.getByTestId('descriptionInput').fill(`${zone.description}bis`) + await page.getByTestId('addZoneBtn').click() + + // Assert + const snackbar = page + .getByTestId('snackbar') + .filter({ hasText: `Une zone portant le nom ${zone.slug} existe déjà.` }) + await expect(snackbar).toBeVisible() + }) + + test('Should not delete a zone if associated clusters', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + // No data to arrange, a public zone is present by default (I guess ?) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + + // Assert + await page.getByTestId(`zoneTile-publique`).click() + await expect(page.getByTestId('showDeleteZoneBtn')).not.toBeVisible() + await expect(page.getByTestId('associatedClustersAlert')).toBeVisible() + }) + + test('Should delete a zone', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + // Create the zone + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + // @TODO: Add clusters to this test to ensure we can't delete a zone that has clusters + + // Act + await page.getByTestId('menuAdministrationZones').click() + await page.locator(`#zoneTile-${zone.slug}`).click() + await page.getByTestId('showDeleteZoneBtn').click() + await page.getByTestId('deleteZoneInput').fill(deleteValidationInput) + await page.getByTestId('deleteZoneBtn').click() + + // Assert + await page.getByTestId('menuAdministrationZones').click() + await expect(page.locator(`#zoneTile-${zone.slug}`)).not.toBeVisible() + }) +}) diff --git a/.jjconflict-side-1/playwright/eslint.config.mjs b/.jjconflict-side-1/playwright/eslint.config.mjs new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-1/playwright/eslint.config.mjs @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-1/playwright/helpers/checkbox.ts b/.jjconflict-side-1/playwright/helpers/checkbox.ts new file mode 100644 index 0000000000..b304a5c380 --- /dev/null +++ b/.jjconflict-side-1/playwright/helpers/checkbox.ts @@ -0,0 +1,20 @@ +import type { Locator } from '@playwright/test' +import { expect } from '@playwright/test' + +async function setCheckboxValue(locator: Locator, checked: boolean) { + await expect(locator).toBeVisible() + await locator.evaluate((el: HTMLInputElement, nextChecked: boolean) => { + el.checked = nextChecked + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, checked) + await expect(locator).toBeChecked({ checked }) +} + +export async function setCheckbox(locator: Locator) { + await setCheckboxValue(locator, true) +} + +export async function unsetCheckbox(locator: Locator) { + await setCheckboxValue(locator, false) +} diff --git a/.jjconflict-side-1/playwright/helpers/cluster.ts b/.jjconflict-side-1/playwright/helpers/cluster.ts new file mode 100644 index 0000000000..2239dd8904 --- /dev/null +++ b/.jjconflict-side-1/playwright/helpers/cluster.ts @@ -0,0 +1,124 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openClustersAdministration } from './navigation' + +type ClusterZone = string +type ClusterConfidentiality = 'public' | 'dedicated' +type ClusterAssociateStage = 'first' | 'all' | 'none' + +async function selectClusterZone(page: Page, zone: ClusterZone) { + await page.getByLabel('Zone associée').selectOption({ label: zone }) +} + +async function selectClusterConfidentiality(page: Page, confidentiality: ClusterConfidentiality) { + await page + .getByLabel('Confidentialité du cluster') + .selectOption({ label: confidentiality }) + + const projectsSelect = page.locator('#projects-select') + switch (confidentiality) { + case 'public': + await expect(projectsSelect).not.toBeVisible() + break + case 'dedicated': + await expect(projectsSelect).toBeVisible() + break + } +} + +async function addStageToClusterAssociationByName(page: Page, stageName: string) { + await page + .getByTestId('choice-selector-search-stages-select') + .fill(stageName) + await page.locator('#stages-select .fr-tag').first().click() +} + +async function addStagesToClusterAssociationByNames(page: Page, stageNames: string[]) { + for (const stageName of stageNames) + await addStageToClusterAssociationByName(page, stageName) +} + +async function addFirstStageToClusterAssociation(page: Page) { + await page.locator('#stages-select .fr-tag').first().click() +} + +async function addAllStagesToClusterAssociation(page: Page) { + const numberOfStages = (await page.locator('#stages-select .fr-tag').all()).length + for (let i = 0; i < numberOfStages; i++) + await page.locator('#stages-select .fr-tag').first().click() +} + +async function fillClusterInformations(page: Page, informations: string) { + await page.getByTestId('infosInput').fill(informations) +} + +async function searchCluster(page: Page, clusterName: string) { + await page.getByTestId('projectsSearchInput').fill(clusterName) +} + +async function openCluster(page: Page, clusterName: string) { + await page.getByTestId(`clusterLink-${clusterName}`).click() +} + +async function confirmClusterDeletion(page: Page) { + await expect(page.getByTestId('deleteClusterZone')).toBeVisible() + await page.getByTestId('showDeleteClusterBtn').click() + await expect(page.getByTestId('deleteClusterBtn')).toBeVisible() + await expect(page.getByTestId('deleteClusterBtn')).toBeDisabled() + await page.getByTestId('deleteClusterInput').fill(deleteValidationInput) + await expect(page.getByTestId('deleteClusterBtn')).toBeEnabled() + await page.getByTestId('deleteClusterBtn').click() +} + +export async function createCluster({ + page, + zone, + confidentiality, + associateStage, + associateStageNames, + informations, +}: { + page: Page + zone?: ClusterZone + confidentiality: ClusterConfidentiality + associateStage?: ClusterAssociateStage + associateStageNames?: string[] + informations?: string +}): Promise { + const clusterName = faker.string.alpha(10).toLowerCase() + await openClustersAdministration({ page }) + await page.getByTestId('addClusterLink').click() + await page.getByTestId('labelInput').fill(clusterName) + if (zone) + await selectClusterZone(page, zone) + await selectClusterConfidentiality(page, confidentiality) + if (associateStageNames?.length) + await addStagesToClusterAssociationByNames(page, associateStageNames) + + switch (associateStage ?? 'none') { + case 'first': + await addFirstStageToClusterAssociation(page) + break + case 'all': + await addAllStagesToClusterAssociation(page) + break + case 'none': + break + } + + if (informations) + await fillClusterInformations(page, informations) + await page.getByTestId('addClusterBtn').click() + await searchCluster(page, clusterName) + return clusterName +} + +export async function deleteCluster({ page, clusterName }: { page: Page, clusterName: string }) { + await openClustersAdministration({ page }) + await searchCluster(page, clusterName) + await expect(page.getByTestId(`clusterLink-${clusterName}`)).toBeVisible() + await openCluster(page, clusterName) + await confirmClusterDeletion(page) +} diff --git a/.jjconflict-side-1/playwright/helpers/constants.ts b/.jjconflict-side-1/playwright/helpers/constants.ts new file mode 100644 index 0000000000..df68f8179a --- /dev/null +++ b/.jjconflict-side-1/playwright/helpers/constants.ts @@ -0,0 +1,9 @@ +export const invalidGitUrlErrorMessage = 'L\'adresse doit commencer par https et se terminer par .git' +export const invalidInternalRepoErrorMessage = 'Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique' +export const fakeToken = 'fakeToken' +export const deleteValidationInput = 'DELETE' + +export const snackbarTimeoutMs = 20_000 +export const repoSyncTimeoutMs = 60_000 +export const projectReadinessTimeoutMs = 60_000 +export const pollingFastIntervalMs = 2_000 diff --git a/.jjconflict-side-1/playwright/helpers/deployment.ts b/.jjconflict-side-1/playwright/helpers/deployment.ts new file mode 100644 index 0000000000..7a2850f3fc --- /dev/null +++ b/.jjconflict-side-1/playwright/helpers/deployment.ts @@ -0,0 +1,44 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' + +export async function createDeployment({ + page, + deploymentName, + envName, + repoName, + customStageName, +}: { + page: Page + deploymentName?: string + envName: string + repoName: string + customStageName: string +}): Promise { + deploymentName = deploymentName ?? faker.string.alpha(10).toLocaleLowerCase() + await openDeploymentCreateForm(page) + await fillDeploymentName(page, deploymentName) + await selectDeploymentEnvironment(page, envName, customStageName) + await selectDeploymentRepository(page, repoName) + await submitDeploymentForm(page) + return deploymentName +} + +async function openDeploymentCreateForm(page: Page) { + await page.getByRole('button', { name: 'Ajouter un nouveau déploiement' }).click() +} + +async function fillDeploymentName(page: Page, deploymentName: string) { + await page.getByRole('textbox', { name: 'Nom du déploiement * Ne doit' }).fill(deploymentName) +} + +async function selectDeploymentEnvironment(page: Page, envName: string, customStageName: string) { + await page.getByText(`${envName} ${customStageName}`).click() +} + +async function selectDeploymentRepository(page: Page, repoName: string) { + await page.getByLabel('Dépôt *').selectOption({ label: repoName }) +} + +async function submitDeploymentForm(page: Page) { + await page.getByRole('button', { name: 'Enregistrer' }).click() +} diff --git a/.jjconflict-side-1/playwright/helpers/environment.ts b/.jjconflict-side-1/playwright/helpers/environment.ts new file mode 100644 index 0000000000..2eccb82509 --- /dev/null +++ b/.jjconflict-side-1/playwright/helpers/environment.ts @@ -0,0 +1,89 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openMyProjects } from './navigation' + +type EnvironmentZone = 'publique' | 'Zone privée' + +async function openEnvironmentCreateForm(page: Page) { + await page.getByTestId('addEnvironmentLink').click() +} + +async function fillEnvironmentName(page: Page, envName: string) { + await page.getByTestId('environmentNameInput').fill(envName) +} + +async function selectEnvironmentZone(page: Page, zone: EnvironmentZone) { + await page.getByLabel('Choix de la zone cible').selectOption({ label: zone }) +} + +async function selectEnvironmentStage(page: Page, stageName: string) { + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: stageName }) +} + +async function selectEnvironmentCluster(page: Page, clusterName: string) { + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page + .getByLabel('Choix du cluster cible') + .selectOption({ label: clusterName }) +} + +async function fillEnvironmentResources( + page: Page, + { memoryInput, cpuInput, gpuInput }: { memoryInput: string, cpuInput: string, gpuInput: string }, +) { + await page.getByTestId('memoryInput').fill(memoryInput) + await page.getByTestId('cpuInput').fill(cpuInput) + await page.getByTestId('gpuInput').fill(gpuInput) +} + +async function submitEnvironmentCreation(page: Page) { + await page.getByTestId('addEnvironmentBtn').click() +} + +export async function createEnvironment({ + page, + envName, + zone, + customStageName, + customClusterName, + memoryInput = '1', + cpuInput = '1', + gpuInput = '1', + envsToDelete, +}: { + page: Page + envName?: string + zone: EnvironmentZone + customStageName: string + customClusterName: string + memoryInput?: string + cpuInput?: string + gpuInput?: string + envsToDelete?: string[] +}): Promise { + envName = envName ?? faker.string.alpha(10).toLowerCase() + envsToDelete?.push(envName) + await openEnvironmentCreateForm(page) + await fillEnvironmentName(page, envName) + await selectEnvironmentZone(page, zone) + await selectEnvironmentStage(page, customStageName) + await selectEnvironmentCluster(page, customClusterName) + await fillEnvironmentResources(page, { memoryInput, cpuInput, gpuInput }) + await submitEnvironmentCreation(page) + return envName +} + +export async function deleteEnvironment({ page, projectName, envName }: { page: Page, projectName: string, envName: string }) { + await openMyProjects({ page }) + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId(`environmentTr-${envName}`).click() + await page.getByTestId('showDeleteEnvironmentBtn').click() + await expect(page.getByTestId('deleteEnvironmentZone')).toBeVisible() + await page.getByTestId('deleteEnvironmentInput').fill(deleteValidationInput) + await page.getByTestId('deleteEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).not.toBeVisible() +} diff --git a/.jjconflict-side-1/playwright/helpers/global.d.ts b/.jjconflict-side-1/playwright/helpers/global.d.ts new file mode 100644 index 0000000000..20981cf311 --- /dev/null +++ b/.jjconflict-side-1/playwright/helpers/global.d.ts @@ -0,0 +1,5 @@ +declare global { + interface RegExpConstructor { + escape(string: string): string + } +} diff --git a/.jjconflict-side-1/playwright/helpers/navigation.ts b/.jjconflict-side-1/playwright/helpers/navigation.ts new file mode 100644 index 0000000000..3d4dc22a7d --- /dev/null +++ b/.jjconflict-side-1/playwright/helpers/navigation.ts @@ -0,0 +1,26 @@ +import type { Page } from '@playwright/test' + +export async function openMyProjects({ page }: { page: Page }) { + await page.getByTestId('menuMyProjects').click() +} + +async function ensureAdminMenuVisible(page: Page, menuTestId: string) { + const menuItem = page.getByTestId(menuTestId) + if (!(await menuItem.isVisible())) + await page.getByTestId('menuAdministrationBtn').click() +} + +export async function openClustersAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationClusters') + await page.getByTestId('menuAdministrationClusters').click() +} + +export async function openStagesAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationStages') + await page.getByTestId('menuAdministrationStages').click() +} + +export async function openZonesAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationZones') + await page.getByTestId('menuAdministrationZones').click() +} diff --git a/.jjconflict-side-1/playwright/helpers/project.ts b/.jjconflict-side-1/playwright/helpers/project.ts new file mode 100644 index 0000000000..0d5a8d65d1 --- /dev/null +++ b/.jjconflict-side-1/playwright/helpers/project.ts @@ -0,0 +1,124 @@ +import type { Page } from '@playwright/test' +import type { Credentials } from '../config/console' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput, projectReadinessTimeoutMs } from './constants' +import { openMyProjects } from './navigation' + +const projectUrlRegexp = /\/projects\/[^/]+$/ +const projectSlugPrefixRegexp = /^slug\s*:\s*/i + +interface Resources { + cpu: number + gpu: number + memory: number +} + +async function openProjectCreateForm(page: Page) { + await openMyProjects({ page }) + await page.getByTestId('createProjectLink').click() +} + +async function fillProjectName(page: Page, name: string) { + await page.getByTestId('nameInput').click() + await page.getByTestId('nameInput').fill(name) +} + +async function fillProjectResources({ + page, + hprodResources, + prodResources, +}: { + page: Page + hprodResources?: Resources + prodResources?: Resources +}) { + const fillHprod = async (resources: Resources) => { + await page.getByTestId('cpuHprodInput').fill(resources.cpu.toString()) + await page.getByTestId('gpuHprodInput').fill(resources.gpu.toString()) + await page + .getByTestId('memoryHprodInput') + .fill(resources.memory.toString()) + } + const fillProd = async (resources: Resources) => { + await page.getByTestId('cpuProdInput').fill(resources.cpu.toString()) + await page.getByTestId('gpuProdInput').fill(resources.gpu.toString()) + await page + .getByTestId('memoryProdInput') + .fill(resources.memory.toString()) + } + + if (hprodResources) + await fillHprod(hprodResources) + if (prodResources) + await fillProd(prodResources) +} + +async function enableProjectLimitless(page: Page) { + await page.getByTestId('limitlessProjectSwitch').locator('label').click() +} + +async function addMembersToProject(page: Page, members: Credentials[]) { + await page.getByTestId('test-tab-team').click() + for (const member of members) { + await page + .getByRole('combobox', { name: 'Ajouter un utilisateur' }) + .fill(member.email) + await page.getByTestId('addUserBtn').click() + await expect( + page.getByTestId('teamTable').getByText(member.email), + ).toBeVisible() + } + await page.getByTestId('test-tab-resources').click() +} + +async function getProjectSlugAndId(page: Page) { + const rawSlugText = (await page.getByTestId('project-slug').textContent()) ?? 'no-slug' + const slug = rawSlugText?.replace(projectSlugPrefixRegexp, '').trim() + const id = (await page.getByTestId('project-id').getAttribute('title')) ?? 'no-id' + return { slug, id } +} + +export function projectSlugTextRegexp(projectSlug: string): RegExp { + const escapedSlug = RegExp.escape(projectSlug) + return new RegExp(`^slug:\\s*${escapedSlug}$`) +} + +export async function createProject({ + page, + projectName: name, + members, + hprodResources, + prodResources, +}: { + page: Page + projectName?: string + members?: Credentials[] + hprodResources?: Resources + prodResources?: Resources +}): Promise<{ id: string, slug: string, name: string }> { + name = name ?? faker.string.alpha(10).toLowerCase() + await openProjectCreateForm(page) + await fillProjectName(page, name) + await fillProjectResources({ page, hprodResources, prodResources }) + if (!hprodResources && !prodResources) await enableProjectLimitless(page) + + await page.getByTestId('createProjectBtn').click() + await expect(page).toHaveURL(projectUrlRegexp, { timeout: projectReadinessTimeoutMs }) + await expect(page.getByTestId('project-slug')).toBeVisible({ timeout: projectReadinessTimeoutMs }) + if (members?.length) + await addMembersToProject(page, members) + + const { slug, id } = await getProjectSlugAndId(page) + return { name, slug, id } +} + +export async function deleteProject({ page, projectName }: { page: Page, projectName: string }) { + await openMyProjects({ page }) + await page.getByRole('link', { name: projectName }).click() + await page.getByRole('button', { name: 'Supprimer le projet' }).click() + await page.getByTestId('archiveProjectInput').fill(deleteValidationInput) + await page.getByTestId('confirmDeletionBtn').click() + await openMyProjects({ page }) + await expect(page.getByRole('link', { name: projectName })).not.toBeVisible() +} diff --git a/.jjconflict-side-1/playwright/helpers/repository.ts b/.jjconflict-side-1/playwright/helpers/repository.ts new file mode 100644 index 0000000000..82678de00d --- /dev/null +++ b/.jjconflict-side-1/playwright/helpers/repository.ts @@ -0,0 +1,82 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { repoSyncTimeoutMs } from './constants' +import { waitForSnackbar } from './snackbar' + +const repoSyncApiRegex = /\/api\/.*\/repositories\/.*\/sync$/ + +export function waitForRepoSync({ + page, + timeoutMs = repoSyncTimeoutMs, +}: { + page: Page + timeoutMs?: number +}) { + return page.waitForResponse((response) => { + const request = response.request() + const success = response.status() >= 200 && response.status() < 300 + return request.method() === 'POST' && success && repoSyncApiRegex.test(response.url()) + }, { timeout: timeoutMs }) +} + +async function openAddRepositoryForm(page: Page) { + await page.getByTestId('addRepoLink').click() +} + +async function fillRepositoryUrl(page: Page, externalRepoUrlInput?: string) { + const url = externalRepoUrlInput ?? `${faker.internet.url({ appendSlash: true })}myrepository.git` + await page.getByTestId('externalRepoUrlInput').fill(url) +} + +async function closeResourceModal(page: Page) { + await page + .getByTestId('resource-modal') + .getByRole('button', { name: 'Fermer' }) + .click() +} + +export async function createRepository({ + page, + repositoryName, + externalRepoUrlInput, + infraRepo, +}: { + page: Page + repositoryName?: string + externalRepoUrlInput?: string + infraRepo?: boolean +}) { + repositoryName = repositoryName ?? faker.string.alpha(10).toLowerCase() + await openAddRepositoryForm(page) + await page.getByTestId('internalRepoNameInput').fill(repositoryName) + await fillRepositoryUrl(page, externalRepoUrlInput) + if (infraRepo) + await page.getByText('Dépôt contenant du code d\'').click() + await page.getByTestId('addRepoBtn').click() + await expect(page.getByTestId(`repoTr-${repositoryName}`)).toContainText( + repositoryName, + ) + return repositoryName +} + +export async function synchronizeBranchOnRepository({ + page, + repositoryName, + branchName, +}: { + page: Page + repositoryName: string + branchName?: string +}) { + branchName = branchName ?? faker.string.alpha(10).toLowerCase() + await page.getByTestId(`repoTr-${repositoryName}`).click() + await expect(page.getByTestId('resource-modal')).toBeVisible() + await page.getByTestId('branchNameInput').fill(branchName) + const syncRequest = waitForRepoSync({ page, timeoutMs: repoSyncTimeoutMs }) + await page.getByTestId('syncRepoBtn').click() + await syncRequest + await closeResourceModal(page) + await waitForSnackbar({ page, text: 'Travail de synchronisation lancé' }) + return branchName +} diff --git a/.jjconflict-side-1/playwright/helpers/snackbar.ts b/.jjconflict-side-1/playwright/helpers/snackbar.ts new file mode 100644 index 0000000000..7c7d57d685 --- /dev/null +++ b/.jjconflict-side-1/playwright/helpers/snackbar.ts @@ -0,0 +1,17 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' +import { snackbarTimeoutMs } from './constants' + +export async function waitForSnackbar({ + page, + text, + timeoutMs = snackbarTimeoutMs, +}: { + page: Page + text: string | RegExp + timeoutMs?: number +}) { + await expect(page.getByTestId('snackbar')).toContainText(text, { + timeout: timeoutMs, + }) +} diff --git a/.jjconflict-side-1/playwright/helpers/stage.ts b/.jjconflict-side-1/playwright/helpers/stage.ts new file mode 100644 index 0000000000..f99f261b34 --- /dev/null +++ b/.jjconflict-side-1/playwright/helpers/stage.ts @@ -0,0 +1,76 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openStagesAdministration } from './navigation' + +async function assertStageCreateForm(page: Page) { + await expect(page.locator('h1')).toContainText( + 'Informations du type d\'environnement', + ) + await expect(page.getByTestId('addStageBtn')).toBeVisible() + await expect(page.getByTestId('addStageBtn')).toBeDisabled() + await expect(page.getByTestId('updateStageBtn')).not.toBeVisible() +} + +async function addFirstClusterAssociation(page: Page) { + await page.locator('.fr-tag').first().click() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(1) +} + +async function addAllClusterAssociations(page: Page) { + await expect(page.locator('.fr-tag')).not.toHaveCount(0) + const numberOfAvalaibleClusters = (await page.locator('.fr-tag').all()).length + for (let i = 0; i < numberOfAvalaibleClusters; i++) + await page.locator('.fr-tag').first().click() + + await expect(page.locator('.fr-tag--dismiss')).toHaveCount( + numberOfAvalaibleClusters, + ) +} + +export async function createStage({ + page, + associateToCluster, +}: { + page: Page + associateToCluster: 'first' | 'all' | 'none' +}): Promise { + const stageName = faker.string.alpha(10).toLowerCase() + await openStagesAdministration({ page }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await page.getByTestId('addStageLink').click() + await assertStageCreateForm(page) + await page.getByTestId('nameInput').fill(stageName) + await expect(page.getByTestId('addStageBtn')).toBeEnabled() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(0) + + switch (associateToCluster) { + case 'first': + await addFirstClusterAssociation(page) + break + case 'all': + await addAllClusterAssociations(page) + break + case 'none': + break + } + + await page.getByTestId('addStageBtn').click() + return stageName +} + +export async function deleteStage({ page, stageName }: { page: Page, stageName: string }) { + await openStagesAdministration({ page }) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.getByTestId('deleteStageZone')).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsZone'), + ).not.toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable'), + ).not.toBeVisible() + await page.getByTestId('showDeleteStageBtn').click() + await page.getByTestId('deleteStageInput').fill(deleteValidationInput) + await page.getByTestId('deleteStageBtn').click() +} diff --git a/.jjconflict-side-1/playwright/helpers/zone.ts b/.jjconflict-side-1/playwright/helpers/zone.ts new file mode 100644 index 0000000000..c18973e9ef --- /dev/null +++ b/.jjconflict-side-1/playwright/helpers/zone.ts @@ -0,0 +1,34 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { deleteValidationInput } from './constants' +import { openZonesAdministration } from './navigation' + +async function openZoneCreateForm(page: Page) { + await page.getByTestId('createZoneLink').click() +} + +async function fillZoneCreateForm(page: Page, zoneName: string) { + await page.getByTestId('slugInput').fill(zoneName) + await page.getByTestId('labelInput').fill(zoneName) + await page.getByTestId('argocdUrlInput').fill(faker.internet.url()) + await page + .getByTestId('descriptionInput') + .fill(faker.string.alpha(100).toLowerCase()) +} + +export async function createZone({ page }: { page: Page }): Promise { + const zoneName = faker.string.alpha(10).toLowerCase() + await openZonesAdministration({ page }) + await openZoneCreateForm(page) + await fillZoneCreateForm(page, zoneName) + await page.getByTestId('addZoneBtn').click() + return zoneName +} + +export async function deleteZone({ page, zoneName }: { page: Page, zoneName: string }) { + await openZonesAdministration({ page }) + await page.getByRole('link', { name: zoneName }).click() + await page.getByTestId('showDeleteZoneBtn').click() + await page.getByTestId('deleteZoneInput').fill(deleteValidationInput) + await page.getByTestId('deleteZoneBtn').click() +} diff --git a/.jjconflict-side-1/playwright/integration-tests/admin-setup.spec.ts b/.jjconflict-side-1/playwright/integration-tests/admin-setup.spec.ts new file mode 100644 index 0000000000..da53fa6394 --- /dev/null +++ b/.jjconflict-side-1/playwright/integration-tests/admin-setup.spec.ts @@ -0,0 +1,54 @@ +import { test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' + +import { createCluster, deleteCluster } from '../helpers/cluster' +import { createStage, deleteStage } from '../helpers/stage' +import { createZone, deleteZone } from '../helpers/zone' + +const zonesToDelete: string[] = [] +const stagesToDelete: string[] = [] +const clustersToDelete: string[] = [] + +test.describe('Integration tests for admin', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Admin setup', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + const zoneName = await createZone({ page }) + zonesToDelete.push(zoneName) + // we need to attains 7 stages to be able to use associateStageNames argument in createCluster + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName1 = await createStage({ page, associateToCluster: 'none' }) + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName2 = await createStage({ page, associateToCluster: 'none' }) + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName3 = await createStage({ page, associateToCluster: 'none' }) + stagesToDelete.push(customStageName1) + stagesToDelete.push(customStageName2) + stagesToDelete.push(customStageName3) + const clusterName = await createCluster({ + page, + zone: zoneName, + confidentiality: 'public', + associateStageNames: [customStageName1, customStageName2, customStageName3], + }) + clustersToDelete.push(clusterName) + }) + + test('Cleanup admin test data', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + for (const stageName of stagesToDelete) { + await deleteStage({ page, stageName }) + } + for (const clusterName of clustersToDelete) { + await deleteCluster({ page, clusterName }) + } + for (const zoneName of zonesToDelete) { + await deleteZone({ page, zoneName }) + } + }) +}) diff --git a/.jjconflict-side-1/playwright/integration-tests/gitlab.spec.ts b/.jjconflict-side-1/playwright/integration-tests/gitlab.spec.ts new file mode 100644 index 0000000000..19d5424ddd --- /dev/null +++ b/.jjconflict-side-1/playwright/integration-tests/gitlab.spec.ts @@ -0,0 +1,125 @@ +import { expect, test } from '@playwright/test' +import { clientURL, currentUser, secondTestUser, signInCloudPiNative } from '../config/console' +import { projectReadinessTimeoutMs } from '../helpers/constants' +import { createProject, deleteProject } from '../helpers/project' + +const membersLinkRegexp = /^(members|membres)$/i +const manageLinkRegexp = /^(manage|gérer)$/i +const developerRoleRegexp = /developer/i + +test.describe('GitLab', () => { + test( + 'Should create project and have infra-apps + mirror repos in GitLab', + { tag: ['@integ', '@replayable'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: currentUser }) + + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + try { + await page.getByTestId('test-tab-services').click() + + const gitlabPopup = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const gitlabPage = await gitlabPopup + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + await expect( + gitlabPage.getByTestId('group-name').filter({ hasText: 'infra-apps' }), + ).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await expect( + gitlabPage.getByTestId('group-name').filter({ hasText: 'mirror' }), + ).toBeVisible({ timeout: projectReadinessTimeoutMs }) + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const manageLink = gitlabPage + .getByRole('button', { name: manageLinkRegexp }) + .or(gitlabPage.getByRole('link', { name: manageLinkRegexp })) + .first() + await manageLink.click({ timeout: projectReadinessTimeoutMs }) + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const membersLink = gitlabPage.getByRole('link', { name: membersLinkRegexp }).first() + await membersLink.click({ timeout: projectReadinessTimeoutMs }) + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const currentUserGitlabUsername = currentUser.email.replace('@', '.') + const userRow = gitlabPage.getByRole('row').filter({ hasText: `@${currentUserGitlabUsername}` }).first() + await userRow.scrollIntoViewIfNeeded() + await expect(userRow).toBeVisible({ + timeout: projectReadinessTimeoutMs, + }) + + const botRow = gitlabPage.getByRole('row').filter({ hasText: `${projectSlug}-bot` }).first() + await botRow.scrollIntoViewIfNeeded() + await expect(botRow).toBeVisible({ + timeout: projectReadinessTimeoutMs, + }) + } finally { + await deleteProject({ page, projectName }) + } + }, + ) + + test( + 'Should reflect console role assignment in GitLab builtin role', + { tag: ['@e2e', '@integ', '@replayable'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: currentUser }) + + const { name: projectName } = await createProject({ page }) + + try { + await page.getByTestId('test-tab-team').click() + await page.locator('input[data-testid="addUserSuggestionInput"]').fill(secondTestUser.email) + await page.getByTestId('addUserBtn').click() + const teamMemberRow = page.getByTestId('teamTable').getByRole('row').filter({ hasText: secondTestUser.email }).first() + await expect(teamMemberRow).toBeVisible({ timeout: projectReadinessTimeoutMs }) + const teamMemberUserId = ((await teamMemberRow.locator('code').first().textContent()) ?? '').trim() + expect(teamMemberUserId).not.toEqual('') + + await page.reload() + await expect(page.getByTestId('project-slug')).toBeVisible({ timeout: projectReadinessTimeoutMs }) + + await page.getByTestId('test-tab-roles').click() + await page + .locator('[data-testid$="-tab"]') + .filter({ hasText: 'DevOps' }) + .click() + await page.getByRole('tab', { name: 'Membres' }).click() + const roleMemberCheckbox = page.getByTestId(`input-checkbox-${teamMemberUserId}-cbx`) + await expect(roleMemberCheckbox).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await roleMemberCheckbox.check({ force: true }) + + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour', { timeout: projectReadinessTimeoutMs }) + + await page.getByTestId('test-tab-services').click() + + const gitlabPopup = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const gitlabPage = await gitlabPopup + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const manageLink = gitlabPage + .getByRole('button', { name: manageLinkRegexp }) + .or(gitlabPage.getByRole('link', { name: manageLinkRegexp })) + .first() + await manageLink.click({ timeout: projectReadinessTimeoutMs }) + + const membersLink = gitlabPage.getByRole('link', { name: membersLinkRegexp }).first() + await membersLink.click({ timeout: projectReadinessTimeoutMs }) + + const secondTestUserGitlabUsername = secondTestUser.email.replace('@', '.') + const memberRow = gitlabPage.getByRole('row').filter({ hasText: `@${secondTestUserGitlabUsername}` }).first() + await expect(memberRow).toContainText(developerRoleRegexp, { timeout: projectReadinessTimeoutMs }) + } finally { + await deleteProject({ page, projectName }) + } + }, + ) +}) diff --git a/.jjconflict-side-1/playwright/integration-tests/user-flow.spec.ts b/.jjconflict-side-1/playwright/integration-tests/user-flow.spec.ts new file mode 100644 index 0000000000..a27b10139b --- /dev/null +++ b/.jjconflict-side-1/playwright/integration-tests/user-flow.spec.ts @@ -0,0 +1,390 @@ +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, secondTestUser, signInCloudPiNative, testUser } from '../config/console' +import { pollingFastIntervalMs, projectReadinessTimeoutMs, repoSyncTimeoutMs } from '../helpers/constants' +import { createProject, deleteProject } from '../helpers/project' +import { createRepository } from '../helpers/repository' + +const projectsToDelete: string[] = [] +const projectName = 'socleprojecttest' +const repositoryName = 'socle-project-test' +const destinationCluster = process.env.CONSOLE_DESTINATION_CLUSTER || 'cpin-app-hp' +const helmValuesFiles = process.env.CONSOLE_VALUES_FILE || 'values-cpin-hp.yaml' +projectsToDelete.push(projectName) + +test.describe('Integration tests user flow: project creation', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Preliminary checks', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + // Check all services are healthy + await page.getByTestId('menuServicesHealth').click() + await expect(page.getByTestId('ArgoCD-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Gitlab-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Harbor-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Keycloak-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Nexus-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('SonarQube-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Vault-info').getByText('OK')).toBeVisible() + await expect(page.getByText('Tous les services')).toBeVisible() + // Check maintenance mode is disabled + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationSystemSettings').click() + await expect(page.getByText('Le mode Maintenance est')).not.toBeVisible() + await expect(page.getByText('Désactiver le mode maintenance')).not.toBeVisible() + await expect(page.getByText('Activer le mode maintenance')).toBeVisible() + }) + + test('Project creation and configuration', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page, projectName }) + // Enable Nexus Maven plugin + await page.getByTestId('test-tab-services').click() + await page.getByRole('button', { name: 'Nexus' }).click() + await page + .getByRole('group', { name: 'Activer le dépôt Maven' }) + .locator('input[value="enabled"] + label') + .click() + await page.getByTestId('saveBtn').click() + await expect(page.getByText('Paramètres sauvegardés')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Check if project reprovisioning is successful + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + await expect(page.getByText('Le projet a été reprovisionn')).toBeVisible() + // Add repository to project + await page.getByTestId('test-tab-resources').click() + await createRepository({ + page, + repositoryName, + externalRepoUrlInput: 'https://github.com/cloud-pi-native/socle-project-test.git', + infraRepo: true, + }) + await page.getByRole('cell', { name: repositoryName }).click() + await page.getByTestId('deployRevisionInput').fill('main') + await page.getByTestId('deployPathInput').fill('helm/') + await page.getByTestId('helmValuesFilesTextarea').fill(helmValuesFiles) + await page.getByTestId('updateRepoBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: first checks', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Check Vault kv', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Vault' }).click() + const page1 = await page1Promise + const page2Promise = page1.waitForEvent('popup') + await page1.getByRole('button', { name: 'Sign in with OIDC Provider' }).click() + await page2Promise + await expect(page1.getByRole('link', { name: 'Vault home' })).toBeVisible() + // Check that standard user has access to his project kv + await expect(page1.getByText('No secrets yet')).toBeVisible() + await expect(page1.getByRole('link', { name: 'Create secret' })).toBeVisible() + await page1.getByLabel('breadcrumbs').getByRole('link', { name: 'Secrets' }).click() + // Check that forge-dso kv is not accessible for standard user + await expect(page1.getByRole('link', { name: 'forge-dso' })).not.toBeVisible() + }) + + test('Project permissions', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Add user to project + await page.getByTestId('test-tab-team').click() + await page.getByTestId('addUserSuggestionInput').locator('input').fill(secondTestUser.email) + await page.getByTestId('addUserBtn').click() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Create read-only role + await page.getByTestId('test-tab-roles').click() + await page.getByTestId('addRoleBtn').click() + await page.getByTestId('roleNameInput').fill('readOnly') + await page.getByText('Afficher les secrets Permet d').click() + await page.getByText('Voir les environnements').click() + await page.getByText('Voir les dépôts Permet de').click() + await page.getByTestId('saveBtn').click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Add user to read-only role + await page.getByTestId('test-members').click() + await page.getByLabel('Rôles', { exact: true }).getByText(secondTestUser.email).click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Replay project hooks + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + await expect(page.getByText('Le projet a été reprovisionn')).toBeVisible() + }) + + test('Pipelines run', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if mirror pipeline is successful + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const page1 = await page1Promise + await page1.getByTestId('group-name').filter({ hasText: 'mirror' }).click() + await expect(page1.getByTestId('status_success_borderless-icon')).toBeVisible() + // Check if tests are successful + await page1.getByRole('link', { name: projectName }).click() + await page1.getByTestId('group-name').filter({ hasText: repositoryName }).click() + await page1.getByTestId('ci-icon').click() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-vault' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-nexus' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-harbor' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-sonar' }), + ).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: after pipelines checks', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Prepare ArgoCD deployment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Create environment for project to trigger ArgoCD deployment + await page.getByTestId('test-tab-resources').click() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').click() + await page.getByTestId('environmentNameInput').fill('integ') + await page.getByLabel('Zone *Choix de la zone cible').selectOption({ label: 'DSO' }) + await page.getByLabel('Type d\'environnement *Type d\'').selectOption({ label: 'dev' }) + await page.getByLabel('Cluster *Choix du cluster').selectOption({ label: destinationCluster }) + await page.getByTestId('memoryInput').fill('2') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + }) + + test('View Sonar scan report', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if sonar scan is available + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'SonarQube' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'OpenID Connect Log in with' }).click() + await page1.getByPlaceholder('Search for projects...').fill(projectName) + await page1.getByRole('link', { name: `${projectName}-${repositoryName}` }).click() + await expect( + page1.getByTestId('overview__quality-gate-panel').getByText('Passed', { exact: true }), + ).toBeVisible() + }) + + test('Check Harbor repository', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Harbor' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'LOGIN WITH keycloak' }).click() + await expect(page1.getByRole('button', { name: 'Administration' })).not.toBeVisible() + await expect(page1.getByText('Guest')).toBeVisible() + await expect(page1.getByRole('heading', { name: 'Private' })).toBeVisible() + await page1.getByRole('link', { name: `${projectName}/java-demo` }).click() + await expect(page1.getByRole('button', { name: 'main' })).toBeVisible() + await expect(page1.getByText('Policy')).not.toBeVisible() + // Check trivy scan result, hopefully will stay at C + await expect(page1.getByRole('button', { name: 'C', exact: true })).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: deployment and metrics', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('ArgoCD deployment', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if ArgoCD deployment is successful + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await page1.getByText('app.kubernetes.io/managed-by=dso-console, dso/environment=integ, dso/').click() + await expect(page1.getByText('Synced').nth(1)).toBeVisible() + await expect(page1.getByText('Sync OK')).toBeVisible() + await expect(page1.getByText('Healthy').nth(1)).toBeVisible() + await page1.getByRole('button', { name: ' Details' }).click() + const page2Promise = page1.waitForEvent('popup') + await page1.getByRole('link', { name: `http://${repositoryName}.` }).click() + const page2 = await page2Promise + await expect + .poll( + async () => { + await page2.reload({ waitUntil: 'domcontentloaded' }) + return page2.locator('body').textContent() + }, + { + timeout: repoSyncTimeoutMs, + intervals: [pollingFastIntervalMs], + }, + ) + .toContain('Application is running') + }) + + test('Check Grafana', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Grafana' }).click() + const page1 = await page1Promise + const signInLink = page1.getByRole('link', { name: 'Sign in with grafana-projects' }) + await expect(signInLink).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await signInLink.click({ timeout: projectReadinessTimeoutMs }) + await expect(page1.getByRole('link', { name: 'Grafana', exact: true })).toBeVisible() + await page1.getByTestId('data-testid Toggle menu').click() + await page1.getByRole('button', { name: 'Expand section Dashboards' }).click() + await page1.getByRole('link', { name: 'Dashboards', exact: true }).click() + const dsoDashboard = page1.getByRole('link', { name: 'dso-grafana' }) + await expect(dsoDashboard).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await dsoDashboard.click({ timeout: projectReadinessTimeoutMs }) + // Check if we can see some metrics + await page1.getByRole('link', { name: 'Kubernetes / Views /' }).click() + await expect(page1.getByText('0.100')).toBeVisible() // Cpu request + await expect(page1.getByText('0.500')).toBeVisible() // Cpu limit + await expect(page1.getByText('256')).toBeVisible() // Memory request + await expect(page1.getByText('512')).toBeVisible() // Memory limit + // Check if we can see some logs + await page1.getByTestId('data-testid dso-grafana breadcrumb').click() + await page1.getByRole('link', { name: 'Loki Kubernetes Logs' }).click() + await expect(page1.getByTestId('data-testid Panel status error').first()).not.toBeVisible() + await expect(page1.locator('.rc-drawer-mask')).not.toBeVisible() + await page1.getByTestId('data-testid TimePicker Open Button').click() + await page1.getByText('Last 1 hour').click() + await page1.locator('.css-13x53bc-Icon-topVerticalAlign').first().click() + await expect(page1.getByRole('cell', { name: 'app_kubernetes_io_name' }).nth(1)).toBeVisible() + await expect(page1.getByText('demo-java-helm').nth(1)).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: Cleanup', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Remove permissions and user', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Remove role membership + await page.getByTestId('test-tab-roles').click() + await page.getByRole('button', { name: 'readOnly' }).click() + await page.getByTestId('test-members').click() + await page.getByLabel('Rôles', { exact: true }).getByText(secondTestUser.email).click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Remove role + await page.getByTestId('test-general').click() + await page.getByTestId('deleteBtn').click() + await page.getByText('Rôle supprimé').click() + // Remove project membership + await page.getByTestId('test-tab-team').click() + await page.getByTitle(`Retirer ${secondTestUser.email} du`).click() + }) + + test('Remove stage', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByRole('cell', { name: 'integ' }).click() + await page.getByTestId('showDeleteEnvironmentBtn').click() + await page.getByTestId('deleteEnvironmentInput').fill('DELETE') + await page.getByTestId('deleteEnvironmentBtn').click() + await page.getByRole('heading', { name: 'Opération en cours...' }).click() + await expect( + page.getByRole('cell', { name: 'Aucun environnement existant' }), + ).toBeVisible() + // ArgoCD deployment will be deleted when stage is deleted + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await expect( + page1.getByRole('combobox', { name: 'Search applications...' }), + ).toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-integ-socle-` }), + ).not.toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-cpin-app-hp-integ-root` }), + ).not.toBeVisible() + }) + + // This check will forever fail until we fix + // https://github.com/cloud-pi-native/console/issues/1853 + test('Check Argocd root application deletion', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Tous') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await page.getByRole('cell', { name: projectName }).first().click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await expect( + page1.getByRole('combobox', { name: 'Search applications...' }), + ).toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-cpin-app-hp-integ-root` }), + ).not.toBeVisible() + }) + + test('Remove repository from project', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-resources').click() + await page.getByRole('cell', { name: repositoryName }).click() + await page.getByTestId('showDeleteRepoBtn').click() + await page.getByTestId('deleteRepoInput').fill('DELETE') + await page.getByTestId('deleteRepoBtn').click() + await page.getByRole('heading', { name: 'Opération en cours...' }).click() + await expect(page.getByRole('cell', { name: 'Aucun dépôt existant' })).toBeVisible() + for (const projectName of projectsToDelete) { + await deleteProject({ page, projectName }) + } + }) +}) diff --git a/.jjconflict-side-1/playwright/package.json b/.jjconflict-side-1/playwright/package.json new file mode 100644 index 0000000000..d4b0f175ee --- /dev/null +++ b/.jjconflict-side-1/playwright/package.json @@ -0,0 +1,27 @@ +{ + "name": "@cpn-console/playwright", + "version": "2.0.0", + "private": true, + "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26", + "description": "Our Playwright automated test suite", + "keywords": [ + "playwright", + "tests", + "e2e" + ], + "main": "playwright.config.ts", + "scripts": { + "playwright:test": "pnpm exec playwright test", + "playwright:test:ui": "pnpm exec playwright test --ui", + "integ:playwright:test": "INTEGRATION=true pnpm exec playwright test", + "integ:playwright:test:ui": "INTEGRATION=true pnpm exec playwright test --ui", + "format": "eslint ./ --fix" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@playwright/test": "catalog:test", + "@types/node": "catalog:types" + } +} diff --git a/.jjconflict-side-1/playwright/playwright.config.ts b/.jjconflict-side-1/playwright/playwright.config.ts new file mode 100644 index 0000000000..06a9545f6a --- /dev/null +++ b/.jjconflict-side-1/playwright/playwright.config.ts @@ -0,0 +1,58 @@ +import path from 'node:path' +import { defineConfig, devices } from '@playwright/test' + +process.loadEnvFile(path.resolve(__dirname, '..', 'apps/client', '.env.docker')) + +const isIntegration = process.env.INTEGRATION === 'true' + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: isIntegration ? './integration-tests' : './e2e-tests', + + /* Run tests in files in parallel */ + fullyParallel: true, + + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + + retries: 3, + + workers: process.env.CI ? 1 : undefined, // Default is 50% logical cores + + // The maximum number of test failures forthe whole test suite run. + // After reaching this number, testing will stop and exit with an error. + // Setting to zero (default) disables this behavior. + maxFailures: 1, + + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Retain trace on failed tries. See https://playwright.dev/docs/trace-viewer */ + trace: 'retain-on-failure', + }, + + /* All timeouts are in milliseconds */ + // Timeout for each and every `test` block + timeout: Number(process.env.CONSOLE_GLOBAL_TIMEOUT) || 30_000, + + // Timeout for each and every `expect` command + expect: { + timeout: Number(process.env.CONSOLE_EXPECT_TIMEOUT) || 10_000, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + ], +}) diff --git a/.jjconflict-side-1/playwright/tsconfig.json b/.jjconflict-side-1/playwright/tsconfig.json new file mode 100644 index 0000000000..f9908dab99 --- /dev/null +++ b/.jjconflict-side-1/playwright/tsconfig.json @@ -0,0 +1,41 @@ +{ + "extends": ["@cpn-console/ts-config/tsconfig.base.json"], + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + + "jsx": "preserve", + "lib": ["DOM", "ESNext"], + // Base + "baseUrl": "./", + "rootDir": "./", + "module": "Preserve", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "types": ["node"], + "allowImportingTsExtensions": false, + "allowJs": true, + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useUnknownInCatchVariables": true, + "declaration": true, + "declarationDir": "./types", + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "./dist", + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["./playwright.config.ts", "./config/**/*.ts", "./e2e-tests/**/*.ts", "./helpers/**/*.ts", "./integration-tests/**/*.ts"], + "exclude": [] +} diff --git a/.jjconflict-side-1/plugins/argocd/.gitignore b/.jjconflict-side-1/plugins/argocd/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-1/plugins/argocd/README.md b/.jjconflict-side-1/plugins/argocd/README.md new file mode 100644 index 0000000000..2fe1f154d8 --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/README.md @@ -0,0 +1,200 @@ +# Plugin ArgoCD + +Ce document décrit le fonctionnement du plugin ArgoCD. + +## Hooks et Steps supportés + +Le seul step supporté est pour l'instant le step `main`. Ceci est applicable à tous les hooks de ce plugin. + +## upsertProject hook + +Voici le diagramme de séquence du hook le plus important du plugin (`upsert`) : + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + end + + box Project Cluster + participant ArgoCD_EXT as ArgoCD + participant Kubernetes_EXT as Kubernetes + end + + Bob ->> Console: "Please upsert project" + + loop For every zone/environment + Console ->> Gitlab: Reconcile (create/update/delete) values.yaml files + ArgoCD_EXT ->> Gitlab: Detects new/updated values.yaml files + ArgoCD_EXT ->> Kubernetes_EXT: Apply changes + end +``` + +Les clusters sont dits "externes" car ils sont hors du périmètre de DSO, et gérés de manière autonome par les projets clients, ce qui est une contrainte majeur : on ne peut pas interagir avec l'API Kubernetes (le cluster est considéré comme "air gapped", c'est-à-dire injoignable de l'extérieur, *a fortiori* depuis la Console). C'est en particulier le cas pour les clusters qui se trouvent dans une Zone DR ("Diffusion Restreinte"). + +C'est pourquoi la Console fonctionne avec son paradigme dans lequel c'est le cluster externe qui **vient récupérer les informations pour se mettre à jour**. Et la manière la plus efficace de faire ça, c'est de fonctionner en mode "GitOps", un mode dans lequel la Console crée un fichier de configuration (typiquement un `values.yaml`) qui serait récupéré (d'où le nom de "pull", en opposition au "push" traditionnel dans lequel on va utiliser les API Kubernetes) par le cluster externe afin d'être traité par l'ArgoCD qui lui est rattaché. + +## deleteProject hook + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + participant ArgoCD_DSO as ArgoCD + participant Kubernetes_DSO as Kubernetes + end + + box Project Cluster + participant ArgoCD_EXT as ArgoCD + participant Kubernetes_EXT as Kubernetes + end + + Bob ->> Console: "Please delete project" + + loop For every zone/environment + Console ->> Gitlab: Delete values.yaml files + ArgoCD_EXT ->> Gitlab: Detects deleted values.yaml files + ArgoCD_EXT ->> Kubernetes_EXT: Apply relevant changes (delete resources) + end +``` + +## upsertCluster hook + +Ce hook sert à la création/modification d'un cluster Kubernetes. +Il est à noter la distinction des clusters étiquetés `in-cluster` pour lesquels la gestion des secrets est différente (c'est une exception à la règle). + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please upsert cluster" + + alt cluster has "in-cluster" label + Console ->> Kubernetes: Delete cluster Secret + else cluster DOES NOT HAVE "in-cluster" label + Console ->> Kubernetes: Create cluster Secret + end + + Console ->> Vault: Create entry for cluster data +``` + +## deleteCluster hook + +Ce hook sert à la "suppression" d'un cluster Kubernetes. +Il est à noter que le cluster n'est pas vraiment supprimé, mais "oublié" par la Console, qui n'en garde plus trace. + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please delete cluster" + + Console ->> Kubernetes: Delete cluster Secret + Console ->> Vault: Destroy entry for cluster data +``` + +## upsertZone hook + +Ce hook sert à la création/modification d'une Zone (qui contiendra donc un ou plusieurs clusters Kubernetes) + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please upsert zone" + + Console ->> Vault: Get credentials + Console ->> Gitlab: Create infrastructure project for the zone if needed + Console ->> Gitlab: Create argocd-values.yaml file +``` + +## Deployment as-code (declarative mode) + +```mermaid +--- +config: + layout: default + theme: base + flowchart: + curve: linear + defaultRenderer: "elk" +--- +flowchart TD + subgraph "Helm-charts" + H1@{ shape: doc, label: "Chart 'dso-argocd-zone'" } + H2@{ shape: doc, label: "Chart 'dso-env'" } + H3@{ shape: doc, label: "Chart 'dso-ns'" } + end + subgraph "Gitlab DSO" + subgraph "zone repository" + V1@{ shape: doc, label: "/argocd-values.yaml" } + V2@{ shape: docs, label: "**/values.yaml" } + end + subgraph "project repositories" + V3@{ shape: docs, label: "infra sources" } + end + end + subgraph "DSO Cluster" + A1[Application zone-#lt;name>-app] + A1 --Deploy--> A3[ApplicationSet dso-appset] + A1 --Deploy--> S1[Cluster secrets] + V2 --Generator--> A3 + A3 --Generates--> A4[Application *-root] + V2 --> A4 + H2 -.-> A4 + A4 --Deploys--> A7[Application #lt;project>-env] + A4 --Deploys--> A8@{ shape: procs, label: "Application #lt;project>-#lt;env>-#lt;random-id>" } + + end + S1 --> apps + V1 --> A1 + H1 -.-> A1 + V3 ==> A8 + H3 -.-> A7 + V2 --> A7 + A7 --Deploys--> namespace & R1 & R2 & R3 + A8 ==Deploys==> A10 + A8 --Deploys--> A11 + subgraph apps["Apps Clusters"] + subgraph namespace["Namespace"] + A10@{ shape: procs, label: "K8S resources" } + A11@{ shape: procs, label: "VaultStaticSecrets" } + R1[ResourceQuota] + R2[VaultConnection] + R3[VaultStaticSecret registry-pull-secret] + R3 --> R4[Secret registry-pull-secret] + A11 --> R5@{ shape: procs, label: "Secrets" } + end + end + subgraph "Vault DSO" + kv1["KV Infra"] + kv2@{ shape: procs, label: "KV Projets" } + end + kv1 --> R3 + kv2 --> A11 +``` diff --git a/.jjconflict-side-1/plugins/argocd/eslint.config.js b/.jjconflict-side-1/plugins/argocd/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-1/plugins/argocd/package.json b/.jjconflict-side-1/plugins/argocd/package.json new file mode 100644 index 0000000000..042fad6e4d --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/package.json @@ -0,0 +1,53 @@ +{ + "name": "@cpn-console/argocd-plugin", + "type": "module", + "version": "2.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@himenon/argocd-typescript-openapi": "catalog:runtime", + "axios": "catalog:runtime", + "yaml": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/plugins/argocd/src/cluster.ts b/.jjconflict-side-1/plugins/argocd/src/cluster.ts new file mode 100644 index 0000000000..efdb6de462 --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/src/cluster.ts @@ -0,0 +1,69 @@ +import type { ClusterObject, StepCall } from '@cpn-console/hooks' +import { updateZoneValues } from './utils.js' + +export const upsertCluster: StepCall = async (payload) => { + try { + const cluster = payload.args + const { vault } = payload.apis + const clusterData = { + name: cluster.label, + clusterResources: cluster.clusterResources.toString(), + server: cluster.cluster.server, + config: JSON.stringify(convertConfig(cluster)), + } + await vault.upsert() + await vault.write(clusterData, `clusters/cluster-${cluster.label}/argocd-cluster-secret`) + await updateZoneValues(cluster.zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Cluster secret created/updated', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed create/update cluster secret', + }, + } + } +} + +export const deleteCluster: StepCall = async (payload) => { + try { + await payload.apis.vault.destroy(`clusters/cluster-${payload.args.label}/argocd-cluster-secret`) + await updateZoneValues(payload.args.zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Cluster secret deleted', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed to delete cluster secret', + }, + } + } +} + +// ...désolé +function convertConfig(cluster: ClusterObject) { + return { + ...cluster.user?.username && { username: cluster.user?.username }, + ...cluster.user?.password && { password: cluster.user?.password }, + ...cluster.user?.token && { bearerToken: cluster.user?.token }, + tlsClientConfig: { + ...cluster.user?.keyData && { keyData: cluster.user?.keyData }, + ...cluster.user?.certData && { certData: cluster.user?.certData }, + ...(cluster.cluster?.caData && !cluster.cluster?.skipTLSVerify) && { caData: cluster.cluster?.caData }, + ...cluster.cluster?.skipTLSVerify && { insecure: cluster.cluster.skipTLSVerify }, + serverName: cluster.cluster.tlsServerName, + }, + } +} diff --git a/.jjconflict-side-1/plugins/argocd/src/env.d.ts b/.jjconflict-side-1/plugins/argocd/src/env.d.ts new file mode 100644 index 0000000000..58ef50fd4c --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/src/env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/.jjconflict-side-1/plugins/argocd/src/functions.spec.ts b/.jjconflict-side-1/plugins/argocd/src/functions.spec.ts new file mode 100644 index 0000000000..2a036bc0c4 --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/src/functions.spec.ts @@ -0,0 +1,212 @@ +import type { ClusterObject, Environment, Project, Repository } from '@cpn-console/hooks' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { stringify } from 'yaml' +import { deleteProject, upsertProject } from './functions.js' + +vi.mock('./utils.js', () => ({ + generateAppProjectName: vi.fn(() => 'app-project-name'), + getConfig: vi.fn(() => ({ namespace: 'argocd', url: 'https://argocd.example.com' })), +})) + +vi.mock('@cpn-console/shared', async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + generateNamespaceName: vi.fn(() => 'namespace-name'), + } +}) + +describe('argocd functions', () => { + const mockGitlabApi = { + // Used to get the public group URL for sourceRepositories + getPublicGroupUrl: vi.fn(), + // Used to get or create the infra project where values.yaml is stored + getOrCreateInfraProject: vi.fn(), + // Used to get the infra project details (repo URL) + getProjectById: vi.fn(), + // Used to resolve the public URL of a repository + getPublicRepoUrl: vi.fn(), + // Used to commit the values.yaml file + commitCreateOrUpdate: vi.fn(), + // Used to list files in the infra project for cleanup + listFiles: vi.fn(), + // Used to delete files from the infra project + commitDelete: vi.fn(), + } + // Used to get the RO and RW groups for the environment + const mockKeycloakApi = { + getEnvGroup: vi.fn(), + } + // Used to get user vault secrets + const mockVaultApi = { + Project: { + getValues: vi.fn(), + }, + } + + let mockCluster: ClusterObject + let mockEnvironment: Environment + let mockRepo: Repository + let mockProject: Project + + beforeEach(() => { + vi.clearAllMocks() + + mockCluster = { + id: faker.string.uuid(), + label: faker.string.alphanumeric(10), + zone: { slug: faker.string.alphanumeric(5) }, + privacy: 'public', + } as any + + mockEnvironment = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(5), + clusterId: mockCluster.id, + cpu: faker.number.int({ min: 1, max: 10 }), + memory: faker.number.int({ min: 1, max: 32 }), + gpu: 0, + autosync: true, + } as any + + mockRepo = { + id: faker.string.uuid(), + internalRepoName: faker.string.alphanumeric(10), + isInfra: true, + helmValuesFiles: null, + deployRevision: 'main', + deployPath: '/deploy', + } as any + + mockProject = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(10), + slug: faker.string.alphanumeric(10), + repositories: [mockRepo], + environments: [mockEnvironment], + clusters: [mockCluster], + store: {}, + } as any + }) + + it('upsertProject should succeed', async () => { + const infraProjectId = faker.number.int() + const infraProjectUrl = faker.internet.url() + const gitlabGroupUrl = faker.internet.url() + const gitlabRepoUrl = faker.internet.url() + + mockGitlabApi.getPublicGroupUrl.mockResolvedValue(gitlabGroupUrl) + mockGitlabApi.getOrCreateInfraProject.mockResolvedValue({ id: infraProjectId, http_url_to_repo: infraProjectUrl }) + mockKeycloakApi.getEnvGroup.mockResolvedValue({ subgroups: { RO: '/ro', RW: '/rw' } }) + mockVaultApi.Project.getValues.mockResolvedValue({ secret: 'value' }) + mockGitlabApi.getProjectById.mockResolvedValue({ http_url_to_repo: infraProjectUrl }) + mockGitlabApi.listFiles.mockResolvedValue([]) // No files to delete + mockGitlabApi.getPublicRepoUrl.mockResolvedValue(gitlabRepoUrl) + + const payload = { + args: mockProject, + apis: { + gitlab: mockGitlabApi, + keycloak: mockKeycloakApi, + vault: mockVaultApi, + }, + config: {}, + } as any + + const result = await upsertProject(payload) + + expect(result.status.result).toBe('OK') + + const expectedValues = { + common: { + 'dso/project': mockProject.name, + 'dso/project.id': mockProject.id, + 'dso/project.slug': mockProject.slug, + 'dso/environment': mockEnvironment.name, + 'dso/environment.id': mockEnvironment.id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'app-project-name', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: infraProjectUrl, + valueFileRevision: 'HEAD', + valueFilePath: `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, + roGroup: '/ro', + rwGroup: '/rw', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + projectAdminGroup: `/${mockProject.slug}/console/admin`, + projectDevopsGroup: `/${mockProject.slug}/console/devops`, + projectDevelopperGroup: `/${mockProject.slug}/console/developer`, + projectReadonlyGroup: `/${mockProject.slug}/console/readonly`, + }, + application: { + quota: { + cpu: mockEnvironment.cpu, + gpu: mockEnvironment.gpu, + memory: `${mockEnvironment.memory}Gi`, + }, + sourceRepositories: [ + `${gitlabGroupUrl}/**`, + ], + destination: { + namespace: 'namespace-name', + name: mockCluster.label, + }, + autosync: true, + vault: { secret: 'value' }, + repositories: [ + { + id: mockRepo.id, + name: mockRepo.internalRepoName, + repoURL: gitlabRepoUrl, + targetRevision: 'main', + path: '/deploy', + valueFiles: [], + }, + ], + }, + } + + expect(mockGitlabApi.commitCreateOrUpdate).toHaveBeenCalledWith( + infraProjectId, + stringify(expectedValues), + `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, + ) + expect(mockGitlabApi.commitDelete).toHaveBeenCalledWith(infraProjectId, []) + }) + + it('deleteProject should succeed', async () => { + const infraProjectId = faker.number.int() + mockGitlabApi.getOrCreateInfraProject.mockResolvedValue({ id: infraProjectId }) + mockGitlabApi.listFiles.mockResolvedValue([ + { name: 'values.yaml', path: `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, type: 'blob' }, + { name: 'values.yaml', path: `${mockProject.name}/${mockCluster.label}/old-env/values.yaml`, type: 'blob' }, + ]) + + const payload = { + args: mockProject, + apis: { + gitlab: mockGitlabApi, + }, + } as any + + const result = await deleteProject(payload) + + expect(result.status.result).toBe('OK') + expect(mockGitlabApi.listFiles).toHaveBeenCalledWith(infraProjectId, { + path: `${mockProject.name}/`, + recursive: true, + }) + // Only the stale env file should be deleted (not in project.environments) + expect(mockGitlabApi.commitDelete).toHaveBeenCalledWith(infraProjectId, [ + `${mockProject.name}/${mockCluster.label}/old-env/values.yaml`, + ]) + }) +}) diff --git a/.jjconflict-side-1/plugins/argocd/src/functions.ts b/.jjconflict-side-1/plugins/argocd/src/functions.ts new file mode 100644 index 0000000000..7c1ccbc139 --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/src/functions.ts @@ -0,0 +1,281 @@ +import type { GitlabProjectApi } from '@cpn-console/gitlab-plugin/types/class.js' +import type { + ClusterObject, + Config, + Environment, + Project, + Repository, + StepCall, +} from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' +import { stringify } from 'yaml' +import { + DEFAULT_DSO_ENV_CHART_VERSION, + DEFAULT_DSO_NS_CHART_VERSION, + DEFAULT_PLATFORM_ADMIN_GROUP_PATH, + DEFAULT_PLATFORM_READONLY_GROUP_PATH, + DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX, +} from './infos.js' +import { logger } from './logger.js' +import { generateAppProjectName, getConfig } from './utils.js' + +function splitExtraRepositories(repos?: string): string[] { + return repos ? repos.split(',').map(repo => repo.trim()) : [] +} + +function getValueFilePath(p: Project, c: ClusterObject, e: Environment): string { + return `${p.name}/${c.label}/${e.name}/values.yaml` +} + +export const upsertProject: StepCall = async (payload) => { + const project = payload.args + const gitlabApi = payload.apis.gitlab as unknown as GitlabProjectApi + const keycloakApi = payload.apis.keycloak as any + const vaultApi = payload.apis.vault as unknown as VaultProjectApi + + try { + const infraRepositories = project.repositories.filter( + repo => repo.isInfra, + ) + const sourceRepositories = [ + `${await gitlabApi.getPublicGroupUrl()}/**`, + ...splitExtraRepositories(payload.config.argocd?.extraRepositories), + ...splitExtraRepositories(project.store.argocd?.extraRepositories), + ] + + await Promise.all( + project.environments.map(async (environment) => { + const appNamespace = generateNamespaceName(project.id, environment.id) + const cluster = getCluster(project, environment) + const infraProject = await gitlabApi.getOrCreateInfraProject(cluster.zone.slug) + const appProjectName = generateAppProjectName(project.slug, environment.name) + const envGroup = await (keycloakApi as any).getEnvGroup(environment.name) + const roGroup = envGroup.subgroups.RO + const rwGroup = envGroup.subgroups.RW + + await ensureInfraEnvValues( + project, + environment, + appNamespace, + roGroup, + rwGroup, + appProjectName, + infraRepositories, + infraProject.id, + sourceRepositories, + gitlabApi, + vaultApi, + payload.config, + ) + }), + ) + + await removeInfraEnvValues(project, gitlabApi) + + logger.info({ action: 'upsertProject', projectSlug: project.slug }, 'Hook done') + + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +interface ArgoRepoSource { + repoURL: string + targetRevision: string + path: string + valueFiles: string[] +} + +async function ensureInfraEnvValues( + project: Project, + environment: Environment, + appNamespace: string, + roGroup: string, + rwGroup: string, + appProjectName: string, + infraRepositories: Repository[], + repoId: number, + sourceRepositories: string[], + gitlabApi: GitlabProjectApi, + vaultApi: VaultProjectApi, + config: Config, +) { + const platformAdminGroupPath = config.argocd?.platformAdminGroupPath ?? DEFAULT_PLATFORM_ADMIN_GROUP_PATH + const platformReadonlyGroupPath = config.argocd?.platformReadonlyGroupPath ?? DEFAULT_PLATFORM_READONLY_GROUP_PATH + const projectAdminGroupSuffix = config.argocd?.projectAdminGroupPathSuffix ?? DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX + const projectDevopsGroupSuffix = config.argocd?.projectDevopsGroupPathSuffix ?? DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX + const projectDevelopperGroupSuffix = config.argocd?.projectDevelopperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + const projectReadonlyGroupSuffix = config.argocd?.projectReadonlyGroupPathSuffix ?? DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX + const dsoEnvChartVersion = config.argocd?.dsoEnvChartVersion ?? DEFAULT_DSO_ENV_CHART_VERSION + const dsoNsChartVersion = config.argocd?.dsoNsChartVersion ?? DEFAULT_DSO_NS_CHART_VERSION + const cluster = getCluster(project, environment) + const infraProject = await gitlabApi.getProjectById(repoId) + const valueFilePath = getValueFilePath(project, cluster, environment) + const vaultValues = await vaultApi.Project.getValues() + const repositories: ArgoRepoSource[] = await Promise.all( + infraRepositories.map(async (repository) => { + const repoURL = await gitlabApi.getPublicRepoUrl( + repository.internalRepoName, + ) + const valueFiles = repository.helmValuesFiles + ? repository.helmValuesFiles + .replaceAll('', environment.name) + .split(',') + : [] + return { + id: repository.id, + name: repository.internalRepoName, + repoURL, + targetRevision: repository.deployRevision || 'HEAD', + path: repository.deployPath || '.', + valueFiles, + } + }), + ) + + const values = { + common: { + 'dso/project': project.name, + 'dso/project.id': project.id, + 'dso/project.slug': project.slug, + 'dso/environment': environment.name, + 'dso/environment.id': environment.id, + }, + argocd: { + cluster: inClusterLabel, + namespace: getConfig().namespace, + project: appProjectName, + envChartVersion: dsoEnvChartVersion, + nsChartVersion: dsoNsChartVersion, + }, + environment: { + valueFileRepository: infraProject.http_url_to_repo, + valueFileRevision: 'HEAD', + valueFilePath, + roGroup, + rwGroup, + platformAdminGroup: platformAdminGroupPath, + platformReadonlyGroup: platformReadonlyGroupPath, + projectAdminGroup: `/${project.slug}${projectAdminGroupSuffix}`, + projectDevopsGroup: `/${project.slug}${projectDevopsGroupSuffix}`, + projectDevelopperGroup: `/${project.slug}${projectDevelopperGroupSuffix}`, + projectReadonlyGroup: `/${project.slug}${projectReadonlyGroupSuffix}`, + }, + application: { + quota: { + cpu: environment.cpu, + gpu: environment.gpu, + memory: `${environment.memory}Gi`, + }, + sourceRepositories, + destination: { + namespace: appNamespace, + name: cluster.label, + }, + autosync: environment.autosync, + vault: vaultValues, + repositories, + }, + } + + await gitlabApi.commitCreateOrUpdate(repoId, stringify(values), valueFilePath) +} + +function getCluster(p: Project, e: Environment): ClusterObject { + const c = p.clusters.find(c => c.id === e.clusterId) + if (!c) throw new Error(`Unable to find cluster ${e.id} for env ${e.name}`) + return c +} + +async function removeInfraEnvValues( + project: Project, + gitlabApi: GitlabProjectApi, +) { + for (const z of getDistinctZones(project)) { + const infraProject = await gitlabApi.getOrCreateInfraProject(z) + const existingFiles = await gitlabApi.listFiles(infraProject.id, { + path: `${project.name}/`, + recursive: true, + }) + const neededFiles = project.environments.map(env => + getValueFilePath(project, getCluster(project, env), env), + ) + const filesToDelete: string[] = [] + for (const existingFile of existingFiles) { + if ( + existingFile.name === 'values.yaml' + && !neededFiles.includes(existingFile.path) + ) { + filesToDelete.push(existingFile.path) + } + } + await gitlabApi.commitDelete(infraProject.id, filesToDelete) + } +} + +function getDistinctZones(project: Project) { + const zones: Set = new Set() + project.clusters.forEach(c => zones.add(c.zone.slug)) + return zones +} + +async function cleanupProjectInfra( + project: Project, + gitlabApi: GitlabProjectApi, +) { + for (const z of getDistinctZones(project)) { + const infraProject = await gitlabApi.getOrCreateInfraProject(z) + const existingFiles = await gitlabApi.listFiles(infraProject.id, { + path: `${project.name}/`, + recursive: true, + }) + const filesToDelete = existingFiles + .filter(f => f.name === 'values.yaml') + .map(f => f.path) + await gitlabApi.commitDelete(infraProject.id, filesToDelete) + } +} + +export const deleteProject: StepCall = async (payload) => { + const project = payload.args + const gitlabApi = payload.apis.gitlab as unknown as GitlabProjectApi + + try { + await cleanupProjectInfra(project, gitlabApi) + + logger.info({ action: 'deleteProject', projectSlug: project.slug }, 'Hook done') + + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'deleteProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} diff --git a/.jjconflict-side-1/plugins/argocd/src/index.ts b/.jjconflict-side-1/plugins/argocd/src/index.ts new file mode 100644 index 0000000000..5d7acfdda0 --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/src/index.ts @@ -0,0 +1,43 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import { deleteCluster, upsertCluster } from './cluster.js' +import { deleteProject, upsertProject } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' +import { upsertZone } from './zone.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { + steps: { + main: upsertProject, + }, + }, + deleteProject: { + steps: { + main: deleteProject, + }, + }, + upsertCluster: { + steps: { + main: upsertCluster, + }, + }, + deleteCluster: { + steps: { + main: deleteCluster, + }, + }, + upsertZone: { + steps: { + main: upsertZone, + }, + }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-side-1/plugins/argocd/src/infos.ts b/.jjconflict-side-1/plugins/argocd/src/infos.ts new file mode 100644 index 0000000000..71d672c5e1 --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/src/infos.ts @@ -0,0 +1,133 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const extraRepositoriesDesc = 'appproject.spec.sourceRepos supplémentaires, séparés par des virgules (https://a.com/repo.git,https://b.com/' + +export const DEFAULT_PLATFORM_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_PLATFORM_READONLY_GROUP_PATH = '/console/readonly' +export const DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX = '/console/readonly' +export const DEFAULT_DSO_ENV_CHART_VERSION = 'dso-env-1.6.0' +export const DEFAULT_DSO_NS_CHART_VERSION = 'dso-ns-1.1.5' + +const infos = { + name: 'argocd', + to: ({ zones, project }) => zones.map(z => ({ + to: `${z.argocdUrl}/applications?showFavorites=false&proj=&sync=&health=&namespace=&cluster=&labels=&search=${project.slug}`, + title: `ArgoCD ${z.label}`, + })), + title: 'ArgoCD', + imgSrc: '/img/argocd.svg', + description: 'ArgoCD est un outil déclaratif de livraison continue GitOps pour Kubernetes', + config: { + global: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }, { + key: 'platformAdminGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Admin Group Path', + value: DEFAULT_PLATFORM_ADMIN_GROUP_PATH, + description: 'Chemin du groupe administrateur de plateforme', + }, { + key: 'platformReadonlyGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Readonly Group Path', + value: DEFAULT_PLATFORM_READONLY_GROUP_PATH, + description: 'Chemin du groupe lecture seule de plateforme', + }, { + key: 'projectAdminGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Admin Group Path Suffix', + value: DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe administrateur de projet', + }, { + key: 'projectDevopsGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project DevOps Group Path Suffix', + value: DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe devops de projet', + }, { + key: 'projectDevelopperGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Developer Group Path Suffix', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe développeur de projet', + }, { + key: 'projectReadonlyGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Readonly Group Path Suffix', + value: DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe lecture seule de projet', + }, { + key: 'dsoEnvChartVersion', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Env Chart Version', + value: DEFAULT_DSO_ENV_CHART_VERSION, + description: 'Version du chart Helm dso-env', + placeholder: DEFAULT_DSO_ENV_CHART_VERSION, + }, { + key: 'dsoNsChartVersion', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Namespace Chart Version', + value: DEFAULT_DSO_NS_CHART_VERSION, + description: 'Version du chart Helm dso-ns', + placeholder: DEFAULT_DSO_NS_CHART_VERSION, + }], + project: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-side-1/plugins/argocd/src/logger.ts b/.jjconflict-side-1/plugins/argocd/src/logger.ts new file mode 100644 index 0000000000..45416d7025 --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'argocd' }) diff --git a/.jjconflict-side-1/plugins/argocd/src/monitor.ts b/.jjconflict-side-1/plugins/argocd/src/monitor.ts new file mode 100644 index 0000000000..132a2a4441 --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/src/monitor.ts @@ -0,0 +1,27 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(requiredEnv('ARGOCD_INTERNAL_URL'), { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-1/plugins/argocd/src/utils.ts b/.jjconflict-side-1/plugins/argocd/src/utils.ts new file mode 100644 index 0000000000..151f2389ef --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/src/utils.ts @@ -0,0 +1,51 @@ +import type { GitlabZoneApi } from '@cpn-console/gitlab-plugin/types/class.js' +import type { ClusterObject, HookPayloadApis, ZoneObject } from '@cpn-console/hooks' +import { createHmac } from 'node:crypto' +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import { stringify } from 'yaml' + +export function generateAppProjectName(projectSlug: string, env: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${envHash}` +} + +export function generateApplicationName(projectSlug: string, env: string, repo: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${repo}-${envHash}` +} + +const config: { + namespace?: string + url?: string +} = {} + +export function getConfig(): Required { + config.namespace = config.namespace ?? requiredEnv('ARGO_NAMESPACE') + config.url = removeTrailingSlash(requiredEnv('ARGOCD_URL')) + + // @ts-ignore + return config +} + +export async function updateZoneValues(zone: ZoneObject, apis: HookPayloadApis | HookPayloadApis) { + const gitlab = apis.gitlab as unknown as GitlabZoneApi + const vault = apis.vault as any + const values = { + vault: await vault.getValues(), + clusters: zone.clusterNames, + } + const zoneRepo = await gitlab.getOrCreateInfraProject(zone.slug) + await gitlab.commitCreateOrUpdate(zoneRepo.id, stringify(values), 'argocd-values.yaml') + return { + status: { + result: 'OK', + message: 'Zone argocd configuration created/updated', + }, + } +} diff --git a/.jjconflict-side-1/plugins/argocd/src/zone.ts b/.jjconflict-side-1/plugins/argocd/src/zone.ts new file mode 100644 index 0000000000..61e74e765a --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/src/zone.ts @@ -0,0 +1,23 @@ +import type { StepCall, ZoneObject } from '@cpn-console/hooks' +import { updateZoneValues } from './utils.js' + +export const upsertZone: StepCall = async (payload) => { + try { + const zone = payload.args + await updateZoneValues(zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Zone argocd configuration created/updated', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed create/update zone argocd configuration', + }, + } + } +} diff --git a/.jjconflict-side-1/plugins/argocd/tsconfig.eslint.json b/.jjconflict-side-1/plugins/argocd/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-1/plugins/argocd/tsconfig.json b/.jjconflict-side-1/plugins/argocd/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-1/plugins/argocd/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-1/plugins/gitlab/.gitignore b/.jjconflict-side-1/plugins/gitlab/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-1/plugins/gitlab/eslint.config.js b/.jjconflict-side-1/plugins/gitlab/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-1/plugins/gitlab/files/.gitlab-ci.yml b/.jjconflict-side-1/plugins/gitlab/files/.gitlab-ci.yml new file mode 100644 index 0000000000..ca9be29847 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/files/.gitlab-ci.yml @@ -0,0 +1,22 @@ +variables: + PROJECT_NAME: + description: Nom du dépôt (dans ce Gitlab) à synchroniser. + GIT_BRANCH_DEPLOY: + description: Nom de la branche à synchroniser. + value: main + SYNC_ALL: + description: Synchroniser toutes les branches. + value: "false" + +include: + - project: $CATALOG_PATH + file: mirror.yml + ref: main + +repo_pull_sync: + extends: .repo_pull_sync + only: + - api + - triggers + - web + - schedules diff --git a/.jjconflict-side-1/plugins/gitlab/files/mirror.sh b/.jjconflict-side-1/plugins/gitlab/files/mirror.sh new file mode 100644 index 0000000000..8f5fbdfe37 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/files/mirror.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' + +# Console step increment +i=1 + +# Default values +BRANCH_TO_SYNC=main + +print_help() { + TEXT_HELPER="\nThis script aims to send a synchronization request to DSO.\nFollowing flags are available: + -a Api url to send the synchronization request + -b Branch which is wanted to be synchronize for the given repository (default '$BRANCH_TO_SYNC') + -g GitLab token to trigger the pipeline on the gitlab mirror project + -i Gitlab mirror project id + -r Gitlab repository name to mirror + -h Print script help\n" + printf "$TEXT_HELPER" +} + +print_args() { + printf "\nArguments received: + -a API_URL: $API_URL + -b BRANCH_TO_SYNC: $BRANCH_TO_SYNC + -g GITLAB_TRIGGER_TOKEN length: ${#GITLAB_TRIGGER_TOKEN} + -i GITLAB_MIRROR_PROJECT_ID: $GITLAB_MIRROR_PROJECT_ID + -r REPOSITORY_NAME: $REPOSITORY_NAME\n" +} + +# Parse options +while getopts :ha:b:g:i:r: flag +do + case "${flag}" in + a) + API_URL=${OPTARG};; + b) + BRANCH_TO_SYNC=${OPTARG};; + g) + GITLAB_TRIGGER_TOKEN=${OPTARG};; + i) + GITLAB_MIRROR_PROJECT_ID=${OPTARG};; + r) + REPOSITORY_NAME=${OPTARG};; + h) + printf "\nHelp requested.\n" + print_help + printf "\nExiting.\n" + exit 0;; + *) + printf "\nInvalid argument ${OPTARG} (${flag}).\n" + print_help + print_args + exit 1;; + esac +done + +# Test if arguments are missing +if [ -z ${API_URL} ] || [ -z ${BRANCH_TO_SYNC} ] || [ -z ${GITLAB_TRIGGER_TOKEN} ] || [ -z ${GITLAB_MIRROR_PROJECT_ID} ] || [ -z ${REPOSITORY_NAME} ]; then + printf "\nArgument(s) missing!\n" + print_help + print_args + exit 2 +fi + +# Print arguments +print_args + +# Send synchronization request +printf "\n${red}${i}.${no_color} Send request to DSO api.\n\n" + +curl \ + -X POST \ + --fail \ + -F token=${GITLAB_TRIGGER_TOKEN} \ + -F ref=main \ + -F variables[GIT_BRANCH_DEPLOY]=${BRANCH_TO_SYNC} \ + -F variables[PROJECT_NAME]=${REPOSITORY_NAME} \ + "${API_URL}/api/v4/projects/${GITLAB_MIRROR_PROJECT_ID}/trigger/pipeline" diff --git a/.jjconflict-side-1/plugins/gitlab/package.json b/.jjconflict-side-1/plugins/gitlab/package.json new file mode 100644 index 0000000000..5640a675c7 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/package.json @@ -0,0 +1,52 @@ +{ + "name": "@cpn-console/gitlab-plugin", + "type": "module", + "version": "3.3.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "files", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/requester-utils": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/plugins/gitlab/src/class.ts b/.jjconflict-side-1/plugins/gitlab/src/class.ts new file mode 100644 index 0000000000..2ddb702b02 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/class.ts @@ -0,0 +1,686 @@ +import type { Project, ProjectMember, UniqueRepo } from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { AccessTokenScopes, AllRepositoryTreesOptions, CommitAction, CondensedProjectSchema, Gitlab, GroupSchema, MemberSchema, ProjectSchema, ProjectVariableSchema, RepositoryFileExpandedSchema, VariableSchema } from '@gitbeaker/core' +import type { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { createHash } from 'node:crypto' +import { PluginApi } from '@cpn-console/hooks' +import { objectEntries } from '@cpn-console/shared' +import { AccessLevel } from '@gitbeaker/core' +import config from './config.js' +import { + customAttributesFilter, + infraGroupCustomAttributeKey, + managedByConsoleCustomAttributeKey, + projectGroupCustomAttributeKey, + upsertCustomAttribute, +} from './custom-attributes.js' +import { logger } from './logger.js' +import { + find, + getAll, + getApi, + getGroupRootId, + infraAppsRepoName, + internalMirrorRepoName, + MAX_PAGINATION_PER_PAGE, + offsetPaginate, +} from './utils.js' + +type setVariableResult = 'created' | 'updated' | 'already up-to-date' +type AccessLevelAllowed = AccessLevel.NO_ACCESS | AccessLevel.MINIMAL_ACCESS | AccessLevel.GUEST | AccessLevel.REPORTER | AccessLevel.DEVELOPER | AccessLevel.MAINTAINER | AccessLevel.OWNER +const infraGroupName = 'Infra' +const infraGroupPath = 'infra' +export const pluginManagedTopic = 'plugin-managed' + +interface GitlabMirrorSecret { + MIRROR_USER: string + MIRROR_TOKEN: string +} + +interface RepoSelect { + mirror?: CondensedProjectSchema + target?: CondensedProjectSchema +} +type PendingCommits = Record +}> + +interface CreateEmptyRepositoryArgs { + repoName: string + description?: string +} + +export class GitlabApi extends PluginApi { + protected api: Gitlab + private pendingCommits: PendingCommits = {} + + constructor() { + super() + this.api = getApi() + } + + public async createEmptyRepository({ createFirstCommit, groupId, repoName, description, ciConfigPath }: CreateEmptyRepositoryArgs & { + createFirstCommit: boolean + groupId: number + ciConfigPath?: string + }) { + logger.debug({ action: 'createEmptyRepository', repoName, groupId, createFirstCommit, ciConfigPath }, 'Create empty repository') + const project = await this.api.Projects.create({ + name: repoName, + path: repoName, + ciConfigPath, + namespaceId: groupId, + description, + }) + try { + await upsertCustomAttribute('projects', project.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createEmptyRepository', projectId: project.id, err }, 'Failed to upsert project custom attribute') + } + // Dépôt tout juste créé, zéro branche => pas d'erreur (filesTree undefined) + if (createFirstCommit) { + await this.api.Commits.create(project.id, 'main', 'ci: 🌱 First commit', []) + } + return project + } + + public async commitCreateOrUpdate( + repoId: number, + fileContent: string, + filePath: string, + branch: string = 'main', + comment: string = 'ci: :robot_face: Update file content', + ): Promise { + logger.debug({ action: 'commitCreateOrUpdate', repoId, filePath, branch }, 'Schedule commit create/update') + let action: CommitAction['action'] = 'create' + + const existingBranch = await find(offsetPaginate(opts => this.api.Branches.all(repoId, opts), { perPage: MAX_PAGINATION_PER_PAGE }), b => b.name === branch) + if (existingBranch) { + let actualFile: RepositoryFileExpandedSchema | undefined + try { + actualFile = await this.api.RepositoryFiles.show(repoId, filePath, branch) + } catch {} + if (actualFile) { + const newContentDigest = createHash('sha256').update(fileContent).digest('hex') + if (actualFile.content_sha256 === newContentDigest) { + // Already up-to-date + return false + } + // Update needed + action = 'update' + } + } + + const commitAction: CommitAction = { + action, + filePath, + content: fileContent, + } + this.addActions(repoId, branch, comment, [commitAction]) + + return true + } + + /** + * Fonction pour supprimer une liste de fichiers d'un repo + * @param repoId + * @param files + * @param branch + * @param comment + */ + public async commitDelete( + repoId: number, + files: string[], + branch: string = 'main', + comment: string = 'ci: :robot_face: Delete files', + ): Promise { + logger.debug({ action: 'commitDelete', repoId, branch, filesCount: files.length }, 'Schedule commit delete') + if (files.length) { + const commitActions: CommitAction[] = files.map((filePath) => { + return { + action: 'delete', + filePath, + } + }) + this.addActions(repoId, branch, comment, commitActions) + return true + } + return false + } + + private addActions(repoId: number, branch: string, comment: string, commitActions: CommitAction[]) { + if (!this.pendingCommits[repoId]) { + this.pendingCommits[repoId] = { branches: {} } + } + if (this.pendingCommits[repoId].branches[branch]) { + this.pendingCommits[repoId].branches[branch].actions.push(...commitActions) + this.pendingCommits[repoId].branches[branch].messages.push(comment) + } else { + this.pendingCommits[repoId].branches[branch] = { + actions: commitActions, + messages: [comment], + } + } + } + + public async commitFiles() { + let filesUpdated: number = 0 + for (const [id, repo] of objectEntries(this.pendingCommits)) { + for (const [branch, details] of objectEntries(repo.branches)) { + const filesNumber = details.actions.length + if (filesNumber) { + filesUpdated += filesNumber + const message = [`ci: :robot_face: Update ${filesNumber} file${filesNumber > 1 ? 's' : ''}`, ...details.messages.filter(m => m)].join('\n') + logger.debug({ action: 'commitFiles', repoId: id, branch, filesNumber }, 'Commit pending file changes') + await this.api.Commits.create(id, branch, message, details.actions) + } + } + } + return filesUpdated + } + + public async listFiles(repoId: number, options: AllRepositoryTreesOptions = {}) { + options.path = options?.path ?? '/' + options.ref = options?.ref ?? 'main' + options.recursive = options?.recursive ?? false + try { + const files = await this.api.Repositories.allRepositoryTrees(repoId, options) + // if (depth >= 0) { + // for (const file of files) { + // if (file.type !== 'tree') { + // return [] + // } + // const childrenFiles = await this.listFiles(repoId, { depth: depth - 1, ...options, path: file.path }) + // files.push(...childrenFiles) + // } + // } + return files + } catch (error) { + const { cause } = error as GitbeakerRequestError + if (cause?.description.includes('Not Found')) { + // Empty repository, with zero commit ==> Zero files + return [] + } else { + throw error + } + } + } + + public async deleteRepository(repoId: number, fullPath: string) { + logger.info({ action: 'deleteRepository', repoId, fullPath }, 'Delete repository') + await this.api.Projects.remove(repoId) // Marks for deletion + return this.api.Projects.remove(repoId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${repoId}` }) // Effective deletion + } +} + +export class GitlabZoneApi extends GitlabApi { + private infraProjectsByZoneSlug: Map + + constructor() { + super() + this.infraProjectsByZoneSlug = new Map() + } + + // Group Infra + public async getOrCreateInfraGroup(): Promise { + logger.debug({ action: 'getOrCreateInfraGroup', infraGroupName }, 'Get/create infra group') + const rootId = await getGroupRootId() + // Get or create projects_root_dir/infra group + const fast = await find( + offsetPaginate(opts => this.api.Groups.all({ + ...customAttributesFilter(infraGroupCustomAttributeKey, 'true'), + ...opts, + })), + group => group.parent_id === rootId, + ) + + const existingParentGroup = fast ?? await find( + offsetPaginate(opts => this.api.Groups.all({ + search: infraGroupName, + ...opts, + })), + group => group.parent_id === rootId && group.name === infraGroupName, + ) + + const group = existingParentGroup || await this.api.Groups.create(infraGroupName, infraGroupPath, { + parentId: rootId, + projectCreationLevel: 'maintainer', + subgroupCreationLevel: 'owner', + defaultBranchProtection: 0, + description: 'Group that hosts infrastructure-as-code repositories for all zones (ArgoCD pull targets).', + }) + try { + await upsertCustomAttribute('groups', group.id, infraGroupCustomAttributeKey, 'true') + await upsertCustomAttribute('groups', group.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getOrCreateInfraGroup', groupId: group.id, err }, 'Failed to upsert infra group custom attribute') + } + return group + } + + public async getOrCreateInfraProject(zone: string): Promise { + logger.debug({ action: 'getOrCreateInfraProject', zone }, 'Get/create infra project for zone') + if (this.infraProjectsByZoneSlug.has(zone)) { + return this.infraProjectsByZoneSlug.get(zone)! + } + const infraGroup = await this.getOrCreateInfraGroup() + // Get or create projects_root_dir/infra/zone + const fast = await find( + offsetPaginate(opts => this.api.Groups.allProjects(infraGroup.id, { + ...customAttributesFilter(managedByConsoleCustomAttributeKey, 'true'), + search: zone, + simple: true, + ...opts, + })), + repo => repo.name === zone, + ) + const project = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allProjects(infraGroup.id, { + search: zone, + simple: true, + ...opts, + })), + repo => repo.name === zone, + ) ?? await this.createEmptyRepository({ + repoName: zone, + groupId: infraGroup.id, + description: 'Repository hosting deployment files for this zone.', + createFirstCommit: true, + }) + this.infraProjectsByZoneSlug.set(zone, project) + return project + } +} + +export class GitlabProjectApi extends GitlabApi { + private project: Project | UniqueRepo | ProjectMember['project'] + private gitlabGroup: GroupSchema | undefined + private specialRepositories: string[] = [infraAppsRepoName, internalMirrorRepoName] + private zoneApi: GitlabZoneApi + + constructor(project: Project | UniqueRepo | ProjectMember['project']) { + super() + this.project = project + this.api = getApi() + this.zoneApi = new GitlabZoneApi() + } + + // Group Project + private async createProjectGroup(): Promise { + logger.info({ action: 'createProjectGroup', projectSlug: this.project.slug }, 'Create project group') + const parentId = await getGroupRootId() + const existingGroup = await find(offsetPaginate(opts => this.api.Groups.all({ + search: this.project.slug, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), group => group.parent_id === parentId && group.name === this.project.slug) + + if (existingGroup) return existingGroup + + const group = await this.api.Groups.create(this.project.slug, this.project.slug, { + parentId, + projectCreationLevel: 'maintainer', + subgroupCreationLevel: 'owner', + defaultBranchProtection: 0, + }) + try { + await upsertCustomAttribute('groups', group.id, projectGroupCustomAttributeKey, this.project.slug) + await upsertCustomAttribute('groups', group.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createProjectGroup', groupId: group.id, err }, 'Failed to upsert project group custom attribute') + } + return group + } + + public async getProjectGroup(): Promise { + logger.debug({ action: 'getProjectGroup', projectSlug: this.project.slug }, 'Get project group') + if (!this.gitlabGroup) { + logger.debug({ action: 'getProjectGroup', projectSlug: this.project.slug }, 'Search project group') + const parentId = await getGroupRootId() + const fast = await find( + offsetPaginate(opts => this.api.Groups.allSubgroups(parentId, { + ...customAttributesFilter(projectGroupCustomAttributeKey, this.project.slug), + ...opts, + })), + group => group.name === this.project.slug, + ) + + this.gitlabGroup = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allSubgroups(parentId, { + search: this.project.slug, + ...opts, + })), + group => group.name === this.project.slug, + ) + + if (this.gitlabGroup) { + try { + await upsertCustomAttribute('groups', this.gitlabGroup.id, projectGroupCustomAttributeKey, this.project.slug) + await upsertCustomAttribute('groups', this.gitlabGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getProjectGroup', groupId: this.gitlabGroup.id, err }, 'Failed to upsert project group custom attribute') + } + } + } + return this.gitlabGroup + } + + public async getOrCreateProjectGroup(): Promise { + logger.debug({ action: 'getOrCreateProjectGroup', projectSlug: this.project.slug }, 'Get/create project group') + const group = await this.getProjectGroup() + if (group) return group + return this.createProjectGroup() + } + + public async getPublicGroupUrl() { + return new URL(`/${config().projectsRootDir}/${this.project.slug}`, config().publicUrl) + } + + public async getInternalGroupUrl() { + return new URL(`/${config().projectsRootDir}/${this.project.slug}`, config().internalUrl) + } + + // Tokens + public async getProjectMirrorCreds(vaultApi: VaultProjectApi): Promise { + logger.debug({ action: 'getProjectMirrorCreds', projectSlug: this.project.slug }, 'Get/create project mirror credentials') + const tokenName = `${this.project.slug}-bot` + const currentToken = await this.getProjectToken(tokenName) + const creds: GitlabMirrorSecret = { + MIRROR_USER: '', + MIRROR_TOKEN: '', + } + if (currentToken) { + const vaultSecret = await vaultApi.read('tech/GITLAB_MIRROR', { throwIfNoEntry: false }) as { data: GitlabMirrorSecret } + if (vaultSecret) { + try { + const group = await this.getProjectGroup() + if (!group) throw new Error('Group not created yet') + + const res = await fetch(`${config().internalUrl}/api/v4/groups/${group.id}`, { + headers: { 'PRIVATE-TOKEN': vaultSecret.data.MIRROR_TOKEN }, + }) + + if (res.ok) { + return vaultSecret.data // valid token hence early exit + } + + throw new Error('Invalid token') + } catch (error) { + logger.warn({ action: 'getProjectMirrorCreds', err: error }, 'Mirror token invalid, revoking project token') + await this.revokeProjectToken(currentToken.id) + } + } + } + const newToken = await this.createProjectToken(tokenName, ['write_repository', 'read_repository', 'read_api']) + creds.MIRROR_TOKEN = newToken.token + creds.MIRROR_USER = newToken.name + await vaultApi.write(creds, 'tech/GITLAB_MIRROR') + return creds + } + + public async getProjectId(projectName: string) { + logger.debug({ action: 'getProjectId', projectName, projectSlug: this.project.slug }, 'Look up project id') + const projectGroup = await this.getProjectGroup() + if (!projectGroup) throw new Error(`Gitlab inaccessible, impossible de trouver le groupe ${this.project.slug}`) + + const fast = await find( + offsetPaginate(opts => this.api.Groups.allProjects(projectGroup.id, { + ...customAttributesFilter(managedByConsoleCustomAttributeKey, 'true'), + search: projectName, + simple: true, + ...opts, + })), + repo => repo.name === projectName, + ) + + const project = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allProjects(projectGroup.id, { + search: projectName, + simple: true, + ...opts, + })), + repo => repo.name === projectName, + ) + + return project?.id + } + + public async getProjectById(projectId: number) { + return this.api.Projects.show(projectId) + } + + public async getOrCreateInfraProject(zone: string) { + return await this.zoneApi.getOrCreateInfraProject(zone) + } + + public async getProjectToken(tokenName: string) { + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + return find(offsetPaginate(opts => this.api.GroupAccessTokens.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE }), token => token.name === tokenName) + } + + public async createProjectToken(tokenName: string, scopes: AccessTokenScopes[]) { + logger.info({ action: 'createProjectToken', tokenName, projectSlug: this.project.slug }, 'Create project access token') + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + const expiryDate = new Date() + expiryDate.setFullYear(expiryDate.getFullYear() + 1) + return this.api.GroupAccessTokens.create(group.id, tokenName, scopes, expiryDate.toLocaleDateString('en-CA')) + } + + public async revokeProjectToken(tokenId: number) { + logger.info({ action: 'revokeProjectToken', tokenId, projectSlug: this.project.slug }, 'Revoke project access token') + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + return this.api.GroupAccessTokens.revoke(group.id, tokenId) + } + + // Triggers + public async getMirrorProjectTriggerToken(vaultApi: VaultProjectApi) { + logger.debug({ action: 'getMirrorProjectTriggerToken', projectSlug: this.project.slug }, 'Get mirror project trigger token') + const tokenDescription = 'mirroring-from-external-repo' + const gitlabRepositories = await this.listRepositories() + const mirrorRepo = gitlabRepositories.find(repo => repo.name === internalMirrorRepoName) + if (!mirrorRepo) throw new Error('Don\'t know how mirror repo could not exist') + const currentTriggerToken = await find(offsetPaginate(opts => this.api.PipelineTriggerTokens.all(mirrorRepo.id, opts), { perPage: MAX_PAGINATION_PER_PAGE }), token => token.description === tokenDescription) + + const tokenVaultSecret = await vaultApi.read('GITLAB', { throwIfNoEntry: false }) + + if (currentTriggerToken && !tokenVaultSecret?.data?.GIT_MIRROR_TOKEN) { + await this.api.PipelineTriggerTokens.remove(mirrorRepo.id, currentTriggerToken.id) + } + const triggerToken = await this.api.PipelineTriggerTokens.create(mirrorRepo.id, tokenDescription) + return { token: triggerToken.token, repoId: mirrorRepo.id } + } + + // Repositories + public async getPublicRepoUrl(repoName: string) { + return `${await this.getPublicGroupUrl()}/${repoName}.git` + } + + public async getInternalRepoUrl(repoName: string) { + return `${await this.getInternalGroupUrl()}/${repoName}.git` + } + + public async listRepositories() { + const group = await this.getOrCreateProjectGroup() + const projects = await getAll(offsetPaginate(opts => this.api.Groups.allProjects(group.id, { simple: false, ...opts }), { perPage: MAX_PAGINATION_PER_PAGE })) // to refactor with https://github.com/jdalrymple/gitbeaker/pull/3624 + return Promise.all(projects.map(async (project) => { + if (this.specialRepositories.includes(project.name) && (!project.topics || !project.topics.includes(pluginManagedTopic))) { + return this.api.Projects.edit(project.id, { topics: project.topics ? [...project.topics, pluginManagedTopic] : [pluginManagedTopic] }) + } + return project + })) + } + + public async createEmptyProjectRepository({ repoName, description, clone }: CreateEmptyRepositoryArgs & { clone?: boolean }) { + logger.info({ action: 'createEmptyProjectRepository', repoName, projectSlug: this.project.slug, clone }, 'Create empty project repository') + const namespaceId = (await this.getOrCreateProjectGroup()).id + return this.createEmptyRepository({ + repoName, + groupId: namespaceId, + description, + ciConfigPath: clone ? '.gitlab-ci-dso.yml' : undefined, + createFirstCommit: !clone, + }) + } + + // Special Repositories + public async getSpecialRepositories(): Promise { + return this.specialRepositories + } + + public async addSpecialRepositories(name: string) { + logger.debug({ action: 'addSpecialRepositories', name, projectSlug: this.project.slug }, 'Register special repository') + if (!this.specialRepositories.includes(name)) { + this.specialRepositories.push(name) + } + } + + // Group members + public async getGroupMembers() { + const group = await this.getOrCreateProjectGroup() + return getAll(offsetPaginate(opts => this.api.GroupMembers.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async addGroupMember(userId: number, accessLevel: AccessLevelAllowed = AccessLevel.DEVELOPER): Promise { + logger.info({ action: 'addGroupMember', accessLevel, projectSlug: this.project.slug }, 'Add group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.add(group.id, userId, accessLevel) + } + + public async editGroupMember(userId: number, accessLevel: AccessLevelAllowed = AccessLevel.DEVELOPER): Promise { + logger.info({ action: 'editGroupMember', accessLevel, projectSlug: this.project.slug }, 'Edit group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.edit(group.id, userId, accessLevel) + } + + public async removeGroupMember(userId: number) { + logger.info({ action: 'removeGroupMember', projectSlug: this.project.slug }, 'Remove group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.remove(group.id, userId) + } + + // CI Variables + public async getGitlabGroupVariables(): Promise { + const group = await this.getOrCreateProjectGroup() + return await getAll(offsetPaginate(opts => this.api.GroupVariables.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async setGitlabGroupVariable(listVars: VariableSchema[], toSetVariable: VariableSchema): Promise { + const group = await this.getOrCreateProjectGroup() + const currentVariable = listVars.find(v => v.key === toSetVariable.key) + if (currentVariable) { + if ( + currentVariable.masked !== toSetVariable.masked + || currentVariable.value !== toSetVariable.value + || currentVariable.protected !== toSetVariable.protected + || currentVariable.variable_type !== toSetVariable.variable_type + ) { + await this.api.GroupVariables.edit( + group.id, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + filter: { environment_scope: '*' }, + }, + ) + return 'updated' + } + return 'already up-to-date' + } + await this.api.GroupVariables.create( + group.id, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + + }, + ) + return 'created' + } + + public async getGitlabRepoVariables(repoId: number): Promise { + return await getAll(offsetPaginate(opts => this.api.ProjectVariables.all(repoId, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async setGitlabRepoVariable(repoId: number, listVars: VariableSchema[], toSetVariable: ProjectVariableSchema): Promise { + const currentVariable = listVars.find(v => v.key === toSetVariable.key) + if (currentVariable) { + if ( + currentVariable.masked !== toSetVariable.masked + || currentVariable.value !== toSetVariable.value + || currentVariable.protected !== toSetVariable.protected + || currentVariable.variable_type !== toSetVariable.variable_type + ) { + await this.api.ProjectVariables.edit( + repoId, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + filter: { + environment_scope: toSetVariable.environment_scope, + }, + }, + ) + return 'updated' + } + return 'already up-to-date' + } + await this.api.ProjectVariables.create( + repoId, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + }, + ) + return 'created' + } + + // Mirror + public async triggerMirror(targetRepo: string, syncAllBranches: boolean, branchName?: string) { + logger.info({ action: 'triggerMirror', targetRepo, syncAllBranches, branchName, projectSlug: this.project.slug }, 'Trigger repository mirror') + if ((await this.getSpecialRepositories()).includes(targetRepo)) { + throw new Error('User requested for invalid mirroring') + } + const repos = await this.listRepositories() + const { mirror, target }: RepoSelect = repos.reduce((acc, repository) => { + if (repository.name === 'mirror') { + acc.mirror = repository + } + if (repository.name === targetRepo) { + acc.target = repository + } + return acc + }, {} as RepoSelect) + if (!mirror) throw new Error('Unable to find mirror repository') + if (!target) throw new Error('Unable to find target repository') + return this.api.Pipelines.create(mirror.id, 'main', { + variables: [ + { + key: 'SYNC_ALL', + value: syncAllBranches.toString(), + }, + { + key: 'GIT_BRANCH_DEPLOY', + value: branchName ?? '', + }, + { + key: 'PROJECT_NAME', + value: targetRepo, + }, + ], + }) + } +} diff --git a/.jjconflict-side-1/plugins/gitlab/src/config.ts b/.jjconflict-side-1/plugins/gitlab/src/config.ts new file mode 100644 index 0000000000..be14dda99c --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/config.ts @@ -0,0 +1,26 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + token: string + projectsRootDir: string + constructor() { + this.token = requiredEnv('GITLAB_TOKEN') + this.publicUrl = removeTrailingSlash(requiredEnv('GITLAB_URL')) + this.projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') + this.internalUrl = process.env.GITLAB_INTERNAL_URL + ? removeTrailingSlash(process.env.GITLAB_INTERNAL_URL) + : this.publicUrl + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-side-1/plugins/gitlab/src/custom-attributes.ts b/.jjconflict-side-1/plugins/gitlab/src/custom-attributes.ts new file mode 100644 index 0000000000..f9ba81bf56 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/custom-attributes.ts @@ -0,0 +1,31 @@ +import { logger } from './logger.js' +import { getApi } from './utils.js' + +export const groupRootCustomAttributeKey = 'cpn_projects_root_dir' +export const infraGroupCustomAttributeKey = 'cpn_infra_group' +export const projectGroupCustomAttributeKey = 'cpn_project_slug' +export const userIdCustomAttributeKey = 'cpn_user_id' +export const managedByConsoleCustomAttributeKey = 'cpn_managed_by_console' + +export function customAttributesFilter(key: string, value: string) { + return { [`custom_attributes[${key}]`]: value } as Record +} + +export async function upsertCustomAttribute(resource: 'groups' | 'projects' | 'users', id: number, key: string, value: string): Promise { + logger.info({ action: 'upsertCustomAttribute', resource, id, key }, 'Upsert custom attribute') + logger.debug({ action: 'upsertCustomAttribute', resource, id, key }, 'Upsert custom attribute details') + const api = getApi() + try { + if (resource === 'groups') { + await api.GroupCustomAttributes.set(id, key, value) + } else if (resource === 'projects') { + await api.ProjectCustomAttributes.set(id, key, value) + } else { + await api.UserCustomAttributes.set(id, key, value) + } + } catch (err) { + logger.warn({ action: 'upsertCustomAttribute', resource, id, key, err }, 'Failed to upsert custom attribute') + throw err + } + logger.info({ action: 'upsertCustomAttribute', resource, id, key }, 'Custom attribute upserted') +} diff --git a/.jjconflict-side-1/plugins/gitlab/src/env.d.ts b/.jjconflict-side-1/plugins/gitlab/src/env.d.ts new file mode 100644 index 0000000000..46f7f90d7c --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-side-1/plugins/gitlab/src/functions.ts b/.jjconflict-side-1/plugins/gitlab/src/functions.ts new file mode 100644 index 0000000000..392245d2da --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/functions.ts @@ -0,0 +1,374 @@ +import type { AdminRole, ClusterObject, PluginResult, Project, ProjectLite, ProjectMember, StepCall, UniqueRepo, ZoneObject } from '@cpn-console/hooks' +import type { GitlabProjectApi } from './class.js' +import type { VaultSecrets } from './utils.js' +import { okStatus, specificallyDisabled } from '@cpn-console/hooks' +import config from './config.js' +import { deleteGroup } from './group.js' +import { + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, +} from './infos.js' +import { ensureGroup } from './members.js' +import { ensureRepositories } from './repositories.js' +import { createUsername, getUser, upsertUser } from './user.js' +import { cleanGitlabError } from './utils.js' + +// Check +export const checkApi: StepCall = async (payload) => { + try { + const { users } = payload.args + for (const user of users) { + const userInfos = await getUser({ ...user, username: createUsername(user.email) }) + if (userInfos?.id === 1) { + return { + status: { + result: 'KO', + message: 'Gitlab notify: User 1 (root) should not use Console', + }, + } + } + } + + return { + status: { + result: 'OK', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const getDsoProjectSecrets: StepCall = async (payload) => { + try { + if (!specificallyDisabled(payload.config.gitlab?.displayTriggerHint)) { + // TODO déplacer les secrets dans un dossier pour tout lister plutôt que de sélectionner dans le code + const gitlab = (await payload.apis.vault.read('GITLAB')).data as VaultSecrets['GITLAB'] + /* eslint-disable no-template-curly-in-string */ + const curlCommand = [ + 'curl -X POST --fail', + '-F token=\${GIT_MIRROR_TOKEN}', + '-F ref=main', + '-F variables[GIT_BRANCH_DEPLOY]=\${BRANCH_TO_SYNC}', + '-F variables[PROJECT_NAME]=\${REPOSITORY_NAME}', + `"${config().publicUrl}/api/v4/projects/${gitlab.GIT_MIRROR_PROJECT_ID}/trigger/pipeline"`, + ] + /* eslint-enable */ + const secrets: Record = { + GIT_MIRROR_PROJECT_ID: String(gitlab.GIT_MIRROR_PROJECT_ID), + GIT_MIRROR_TOKEN: gitlab.GIT_MIRROR_TOKEN, + 'CURL COMMAND': curlCommand.join(' \\\n '), + } + + return { + status: { + result: 'OK', + message: 'secret retrieved', + }, + secrets, + } + } + return { + status: { + result: 'OK', + message: 'This feature is disabled', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'OK', + message: 'No secrets found for this project', + }, + } + } +} + +export const upsertDsoProject: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + }, + } + try { + const project = payload.args + const { gitlab: gitlabApi, vault: vaultApi } = payload.apis + + await gitlabApi.getOrCreateProjectGroup() + + await Promise.all(project.users.map(user => + ensureGroup(gitlabApi, project, user, payload.config), + )) + + const projectMirrorCreds = await gitlabApi.getProjectMirrorCreds(vaultApi) + await ensureRepositories(gitlabApi, project, vaultApi, { + botAccount: projectMirrorCreds.MIRROR_USER, + token: projectMirrorCreds.MIRROR_TOKEN, + }) + + const destroySecrets = (await vaultApi.list()) + .filter(path => path.endsWith('-mirror')) + .map(path => path.slice(1, path.length - 7)) + .filter(repoName => !project.repositories.some(projectRepo => projectRepo.internalRepoName === repoName)) + + await Promise.all(destroySecrets + .map(repoName => vaultApi.destroy(`${repoName}-mirror`)), + ) + + const mirrorTriggerToken = await gitlabApi.getMirrorProjectTriggerToken(vaultApi) + + const gitlabSecret: VaultSecrets['GITLAB'] = { + PROJECT_SLUG: project.slug, + GIT_MIRROR_PROJECT_ID: mirrorTriggerToken.repoId, + GIT_MIRROR_TOKEN: mirrorTriggerToken.token, + } + + await vaultApi.write(gitlabSecret, 'GITLAB') + + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const deleteDsoProject: StepCall = async (payload) => { + try { + const group = await payload.apis.gitlab.getProjectGroup() + if (group) await deleteGroup(group.id, group.full_path) + + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +export const syncRepository: StepCall = async (payload) => { + const targetRepo = payload.args.repo + const gitlabApi = payload.apis.gitlab + try { + await gitlabApi.triggerMirror(targetRepo.internalRepoName, targetRepo.syncAllBranches, targetRepo.branchName) + return { + status: { + result: 'OK', + message: 'Ci launched', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'Failed to trigger sync', + }, + } + } +} + +export const upsertZone: StepCall = async (payload) => { + const returnResult: PluginResult = okStatus + try { + const gitlabApi = payload.apis.gitlab + await gitlabApi.getOrCreateInfraProject(payload.args.slug) + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const deleteZone: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + message: 'Deleted', + }, + } + try { + const gitlabApi = payload.apis.gitlab + const zoneRepo = await gitlabApi.getOrCreateInfraProject(payload.args.slug) + await gitlabApi.deleteRepository(zoneRepo.id, zoneRepo.path_with_namespace) + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const commitFiles: StepCall = async (payload) => { + const returnResult = payload.results.gitlab + try { + const filesUpdated = await payload.apis.gitlab.commitFiles() + + returnResult.status.message = `${filesUpdated} file${filesUpdated > 1 ? 's' : ''} updated` + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Failed to commit files' + return returnResult + } +} + +export const upsertAdminRole: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH + const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? DEFAULT_AUDITOR_GROUP_PATH + + const isAdmin = role.oidcGroup === adminGroupPath ? true : undefined + const isAuditor = role.oidcGroup === auditorGroupPath ? true : undefined + + if (isAdmin === undefined && isAuditor === undefined) { + return { + status: { + result: 'OK', + message: 'Not a managed role for GitLab plugin', + }, + } + } + + for (const member of role.members) { + await upsertUser(member, isAdmin, isAuditor) + } + + return { + status: { + result: 'OK', + message: 'Members synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error occured while syncing admin role', + }, + } + } +} + +export const deleteAdminRole: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH + const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? DEFAULT_AUDITOR_GROUP_PATH + + const isAdmin = role.oidcGroup === adminGroupPath ? false : undefined + const isAuditor = role.oidcGroup === auditorGroupPath ? false : undefined + + if (isAdmin === undefined && isAuditor === undefined) { + return { + status: { + result: 'OK', + message: 'Not a managed role for GitLab plugin', + }, + } + } + + for (const member of role.members) { + await upsertUser(member, isAdmin, isAuditor) + } + + return { + status: { + result: 'OK', + message: 'Admin role deleted and members synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error occured while deleting admin role', + }, + } + } +} + +export const upsertProjectMember: StepCall = async (payload) => { + const member = payload.args + const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon + + try { + await Promise.all(member.project.users.map(user => + ensureGroup(gitlabApi, member.project, user, payload.config), + )) + + return { + status: { + result: 'OK', + message: 'Member synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error happened while syncing project member', + }, + } + } +} + +export const deleteProjectMember: StepCall = async (payload) => { + const member = payload.args + const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon + + try { + const userInfos = await getUser({ ...member, id: member.userId, username: createUsername(member.email) }) + if (!userInfos) { + return { + status: { + result: 'OK', + message: 'User not found in GitLab', + }, + } + } + + await gitlabApi.removeGroupMember(userInfos.id) + + return { + status: { + result: 'OK', + message: 'Member deleted', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error happened while deleting project member', + }, + } + } +} diff --git a/.jjconflict-side-1/plugins/gitlab/src/group.ts b/.jjconflict-side-1/plugins/gitlab/src/group.ts new file mode 100644 index 0000000000..eabf036561 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/group.ts @@ -0,0 +1,7 @@ +import { getApi } from './utils.js' + +export async function deleteGroup(groupId: number, fullPath: string) { + const api = getApi() + await api.Groups.remove(groupId) // Marks for deletion + return api.Groups.remove(groupId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${groupId}` }) // Effective deletion +} diff --git a/.jjconflict-side-1/plugins/gitlab/src/index.ts b/.jjconflict-side-1/plugins/gitlab/src/index.ts new file mode 100644 index 0000000000..1eb773c6c6 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/index.ts @@ -0,0 +1,116 @@ +import type { DeclareModuleGenerator, DefaultArgs, Plugin, Project, ProjectMember, UniqueRepo, ZoneObject } from '@cpn-console/hooks' +import { GitlabProjectApi, GitlabZoneApi } from './class.js' +import { + checkApi, + commitFiles, + deleteDsoProject, + deleteProjectMember, + deleteZone, + getDsoProjectSecrets, + syncRepository, + upsertAdminRole, + upsertDsoProject, + upsertProjectMember, + upsertZone, +} from './functions.js' +import infos from './infos.js' +import { logger } from './logger.js' +import monitor from './monitor.js' +import { getOrCreateGroupRoot } from './utils.js' + +const onlyApi = { api: (project: Project | UniqueRepo) => new GitlabProjectApi(project) } + +function start() { + getOrCreateGroupRoot().catch((error) => { + logger.error({ action: 'start', err: error }, 'Hook failed') + throw new Error('Error at gitlab plugin start') + }) +} + +export const plugin: Plugin = { + infos, + subscribedHooks: { + deleteProject: { + ...onlyApi, + steps: { + main: deleteDsoProject, + post: commitFiles, + }, + }, + upsertProject: { + ...onlyApi, + steps: { + check: checkApi, + main: upsertDsoProject, + post: commitFiles, + }, + }, + getProjectSecrets: { steps: { main: getDsoProjectSecrets } }, + syncRepository: { + ...onlyApi, + steps: { + main: syncRepository, + post: commitFiles, + }, + }, + upsertCluster: { + api: () => new GitlabZoneApi(), + steps: { + post: commitFiles, + }, + }, + deleteCluster: { + api: () => new GitlabZoneApi(), + steps: { + post: commitFiles, + }, + }, + upsertZone: { + api: () => new GitlabZoneApi(), + steps: { + pre: upsertZone, + post: commitFiles, + }, + }, + deleteZone: { + api: () => new GitlabZoneApi(), + steps: { + main: deleteZone, + }, + }, + upsertAdminRole: { + steps: { + main: upsertAdminRole, + }, + }, + upsertProjectMember: { + api: member => new GitlabProjectApi(member.project), + steps: { + main: upsertProjectMember, + }, + }, + deleteProjectMember: { + api: member => new GitlabProjectApi(member.project), + steps: { + post: deleteProjectMember, + }, + }, + }, + monitor, + start, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + gitlab: Args extends Project | UniqueRepo | ProjectMember['project'] + ? GitlabProjectApi + : Args extends ZoneObject + ? GitlabZoneApi + : never + } + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} + interface PluginResult { + warnReasons?: string[] + } +} diff --git a/.jjconflict-side-1/plugins/gitlab/src/infos.ts b/.jjconflict-side-1/plugins/gitlab/src/infos.ts new file mode 100644 index 0000000000..38f5d10eac --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/infos.ts @@ -0,0 +1,108 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import config from './config.js' + +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer,/console/devops' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly' + +const infos = { + name: 'gitlab', + to: ({ project }) => `${config().publicUrl}/${config().projectsRootDir}/${project.slug}`, + title: 'Gitlab', + imgSrc: '/img/gitlab.svg', + description: 'GitLab est un service d\'hébergement de code source et de pipeline CI/CD', + config: { + global: [ + { + kind: 'switch', + key: 'purge', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Purger les utilisateurs non synchronisés', + value: DISABLED, + description: 'Purger les utilisateurs non synchronisés de GitLab lors de la synchronisation', + }, + { + kind: 'switch', + key: 'displayTriggerHint', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Afficher l\'aide de déclenchement de pipeline', + value: ENABLED, + description: 'Afficher l\'aide de déclenchement de pipeline aux utilisateurs lorsqu\'ils souhaitent afficher les secrets du projet', + }, + { + kind: 'text', + key: 'adminGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Admin', + value: DEFAULT_ADMIN_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'administrateur GitLab', + placeholder: DEFAULT_ADMIN_GROUP_PATH, + }, + { + kind: 'text', + key: 'auditorGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Auditeur', + value: DEFAULT_AUDITOR_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'auditeur GitLab', + placeholder: DEFAULT_AUDITOR_GROUP_PATH, + }, + { + kind: 'text', + key: 'projectMaintainerGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Maintainer', + value: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Maintainer', + placeholder: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectDeveloperGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Developer', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Developer', + placeholder: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectReporterGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Reporter', + value: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Reporter', + placeholder: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + }, + ], + project: [], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-side-1/plugins/gitlab/src/logger.ts b/.jjconflict-side-1/plugins/gitlab/src/logger.ts new file mode 100644 index 0000000000..8d4c5caea7 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'gitlab' }) diff --git a/.jjconflict-side-1/plugins/gitlab/src/members.ts b/.jjconflict-side-1/plugins/gitlab/src/members.ts new file mode 100644 index 0000000000..2e558b18a5 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/members.ts @@ -0,0 +1,69 @@ +import type { Config, Project, Role, UserObject } from '@cpn-console/hooks' +import type { GitlabProjectApi } from './class.js' +import { AccessLevel } from '@gitbeaker/core' +import { + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, +} from './infos.js' +import { createUsername, upsertUser } from './user.js' +import { matchRole } from './utils.js' + +export function getGroupAccessLevelFromProjectRole(project: Project, user: UserObject, config: Config) { + const projectReporterGroupPathSuffixes = (config.gitlab?.projectReporterGroupPathSuffix ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX).split(',') + const projectDeveloperGroupPathSuffixes = (config.gitlab?.projectDeveloperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX).split(',') + const projectMaintainerGroupPathSuffixes = (config.gitlab?.projectMaintainerGroupPathSuffix ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX).split(',') + + const getAccessLevel = (role: Role): number | null => { + // TODO: update to AccessLevel.GUEST once we migrated pre-fine grained + // projects members to developer access level via database migrations + if (!role.oidcGroup) return AccessLevel.DEVELOPER + if (matchRole(project.slug, role.oidcGroup, projectReporterGroupPathSuffixes)) return AccessLevel.REPORTER + if (matchRole(project.slug, role.oidcGroup, projectDeveloperGroupPathSuffixes)) return AccessLevel.DEVELOPER + if (matchRole(project.slug, role.oidcGroup, projectMaintainerGroupPathSuffixes)) return AccessLevel.MAINTAINER + return null + } + + // TODO: update to AccessLevel.GUEST once we migrated pre-fine grained + // projects members to developer access level via database migrations + return project.roles.reduce((highestAccessLevel, role) => { + if (role.users.some(userRole => userRole.id === user.id)) { + const level = getAccessLevel(role) + if (level && level > (highestAccessLevel ?? 0)) return level + } + return highestAccessLevel + }, AccessLevel.DEVELOPER) +} + +export function getGroupAccessLevel(project: Project, user: UserObject, config: Config): number | null { + if (project.owner.id === user.id) return AccessLevel.OWNER + return getGroupAccessLevelFromProjectRole(project, user, config) +} + +export async function ensureGroup( + gitlabApi: GitlabProjectApi, + project: Project, + user: UserObject, + config: Config, +) { + const gitlabUser = await upsertUser({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }) + + const groupMembers = await gitlabApi.getGroupMembers() + const existingMember = groupMembers.find(m => m.username === createUsername(user.email)) + const maxAccessLevel = getGroupAccessLevel(project, user, config) + + if (existingMember && maxAccessLevel) { + if (existingMember.access_level !== maxAccessLevel) { + await gitlabApi.editGroupMember(gitlabUser.id, maxAccessLevel) + } + } else if (maxAccessLevel) { + await gitlabApi.addGroupMember(gitlabUser.id, maxAccessLevel) + } else if (existingMember) { + await gitlabApi.removeGroupMember(gitlabUser.id) + } +} diff --git a/.jjconflict-side-1/plugins/gitlab/src/monitor.ts b/.jjconflict-side-1/plugins/gitlab/src/monitor.ts new file mode 100644 index 0000000000..d190d6ed51 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/monitor.ts @@ -0,0 +1,52 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import config from './config.js' + +enum HealthStatus { + failed = 'failed', + ok = 'ok', +} +type GitlabRes = Record }> +const coreComponents = ['gitaly_check', 'master_check', 'db_check', 'sessions_check'] + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(`${config().internalUrl}/-/readiness?all=1`, { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + const data = res.data as GitlabRes + const failedComponents = Object.entries(data) + .reduce((acc, [name, value]) => { + if (value.status === HealthStatus.failed) return [...acc, name] + return acc + }, [] as string[]) + const failedCoreComponents = failedComponents.filter(name => coreComponents.includes(name)) + + if (failedCoreComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } + if (failedComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.WARNING + instance.lastStatus.message = 'Service dégradé' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Fatal Error' + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-1/plugins/gitlab/src/project.ts b/.jjconflict-side-1/plugins/gitlab/src/project.ts new file mode 100644 index 0000000000..df3dc8e67e --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/project.ts @@ -0,0 +1,52 @@ +import * as fs from 'node:fs/promises' +import path from 'node:path' + +import { getApi } from './utils.js' + +export async function provisionMirror(repoId: number) { + const baseDir = path.resolve(import.meta.url, '../../files/').split(':')[1] + + const gitlabCiYml = ( + await fs.readFile(`${baseDir}/.gitlab-ci.yml`) + ).toString() + const mirrorSh = (await fs.readFile(`${baseDir}/mirror.sh`)).toString() + + const mirrorFirstActions: CommitAction[] = [ + { + action: 'create', + filePath: '.gitlab-ci.yml', + content: gitlabCiYml, + execute_filemode: false, + }, + { + action: 'create', + filePath: 'mirror.sh', + content: mirrorSh, + execute_filemode: true, + }, + ] + const api = getApi() + await api.Commits.create( + repoId, + 'main', + 'ci: :construction_worker: first mirror', + mirrorFirstActions, + ) +} + +interface CommitAction { + /** The action to perform */ + action: 'create' | 'delete' | 'move' | 'update' | 'chmod' + /** Full path to the file. Ex. lib/class.rb */ + filePath: string + /** Original full path to the file being moved.Ex.lib / class1.rb */ + previousPath?: string + /** File content, required for all except delete. Optional for move */ + content?: string + /** text or base64. text is default. */ + encoding?: string + /** Last known file commit id. Will be only considered in update, move and delete actions. */ + lastCommitId?: string + /** When true/false enables/disables the execute flag on the file. Only considered for chmod action. */ + execute_filemode?: boolean +} diff --git a/.jjconflict-side-1/plugins/gitlab/src/repositories.ts b/.jjconflict-side-1/plugins/gitlab/src/repositories.ts new file mode 100644 index 0000000000..00797cf446 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/repositories.ts @@ -0,0 +1,93 @@ +import type { Project, Repository } from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { CondensedProjectSchema, ProjectSchema } from '@gitbeaker/rest' +import type { GitlabProjectApi } from './class.js' +import { shallowEqual } from '@cpn-console/shared' +import { pluginManagedTopic } from './class.js' +import { provisionMirror } from './project.js' +import { infraAppsRepoName, internalMirrorRepoName } from './utils.js' + +interface ProjectMirrorCreds { + botAccount: string + token: string +} + +export async function ensureRepositories(gitlabApi: GitlabProjectApi, project: Project, vaultApi: VaultProjectApi, projectMirrorCreds: ProjectMirrorCreds) { + const specialRepos = await gitlabApi.getSpecialRepositories() + const gitlabRepositories = await gitlabApi.listRepositories() + + const promises: Promise[] = [ + // delete excess repositories + ...gitlabRepositories + .filter(gitlabRepository => ( + !specialRepos.includes(gitlabRepository.name) + && !gitlabRepository.topics?.includes(pluginManagedTopic) + && !project.repositories.some(repo => repo.internalRepoName === gitlabRepository.name))) + .map(gitlabRepository => gitlabApi.deleteRepository(gitlabRepository.id, gitlabRepository.path_with_namespace)), + // create missing repositories + ...project.repositories.map(repo => ensureRepositoryExists(gitlabRepositories, repo, gitlabApi, projectMirrorCreds, vaultApi)), + ] + + if (!gitlabRepositories.some(repo => repo.name === infraAppsRepoName)) { + promises.push( + gitlabApi.createEmptyProjectRepository({ repoName: infraAppsRepoName, clone: false }), + ) + } + if (!gitlabRepositories.some(repo => repo.name === internalMirrorRepoName)) { + promises.push( + gitlabApi.createEmptyProjectRepository({ repoName: internalMirrorRepoName, clone: false }) + .then(mirrorRepo => provisionMirror(mirrorRepo.id)), + ) + } + + await Promise.all(promises) +} + +const urnRegexp = /:\/\/(.*)/s + +async function ensureRepositoryExists(gitlabRepositories: CondensedProjectSchema[], repository: Repository, gitlabApi: GitlabProjectApi, projectMirrorCreds: ProjectMirrorCreds, vaultApi: VaultProjectApi) { + const gitlabRepository: CondensedProjectSchema | ProjectSchema | void = gitlabRepositories.find(gitlabRepository => gitlabRepository.name === repository.internalRepoName) + const externalRepoUrn = repository.externalRepoUrl.split(urnRegexp)[1] + const vaultCredsPath = `${repository.internalRepoName}-mirror` + const currentVaultSecret = await vaultApi.read(vaultCredsPath, { throwIfNoEntry: false }) + + if (!gitlabRepository) { + await gitlabApi.createEmptyProjectRepository({ + repoName: repository.internalRepoName, + description: undefined, + clone: !!repository.externalRepoUrl, + }) + } + + if (!repository.externalRepoUrl) { + return currentVaultSecret && vaultApi.destroy(vaultCredsPath) + } + + let gitInputUser: string | undefined + let gitInputPassword: string | undefined + if (currentVaultSecret?.data) { + gitInputUser = currentVaultSecret.data.GIT_INPUT_USER + gitInputPassword = currentVaultSecret.data.GIT_INPUT_PASSWORD + } + + const internalRepoUrl = await gitlabApi.getInternalRepoUrl(repository.internalRepoName) + + const mirrorSecretData = { + GIT_INPUT_URL: externalRepoUrn, + GIT_INPUT_USER: repository.isPrivate + ? (repository.newCreds?.username || gitInputUser) + : undefined, + GIT_INPUT_PASSWORD: repository.isPrivate + ? (repository.newCreds?.token || gitInputPassword) + : undefined, + GIT_OUTPUT_URL: internalRepoUrl.split(urnRegexp)[1], + GIT_OUTPUT_USER: projectMirrorCreds.botAccount, + GIT_OUTPUT_PASSWORD: projectMirrorCreds.token, + } + if ( + !currentVaultSecret?.data + || !shallowEqual(mirrorSecretData, currentVaultSecret.data) + ) { + await vaultApi.write(mirrorSecretData, vaultCredsPath) + } +} diff --git a/.jjconflict-side-1/plugins/gitlab/src/user.ts b/.jjconflict-side-1/plugins/gitlab/src/user.ts new file mode 100644 index 0000000000..5f638e4bc1 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/user.ts @@ -0,0 +1,107 @@ +import type { UserObject } from '@cpn-console/hooks' +import type { CreateUserOptions, SimpleUserSchema } from '@gitbeaker/rest' +import { upsertCustomAttribute, userIdCustomAttributeKey } from './custom-attributes.js' +import { logger } from './logger.js' +import { find, getApi, offsetPaginate } from './utils.js' + +export function createUsername(email: string) { + const parts = email.split('@') + if (parts.length > 0) { + return parts[0] + } + return email +} + +export async function getUser(user: { email: string, username: string, id: string }): Promise { + const api = getApi() + + const isUser = (gitlabUser: SimpleUserSchema) => + gitlabUser?.externUid === user.id + || gitlabUser?.externUid === user.email + || gitlabUser.email === user.email + || gitlabUser.username === user.username + + const fast = await find( + offsetPaginate(opts => api.Users.all({ + externUid: user.email, + provider: 'openid_connect', + orderBy: 'username', + asAdmin: true, + ...opts, + })), + isUser, + ) + + if (!fast) { + logger.debug({ action: 'getUser', user }, 'User not found in fast search') + } + + return fast ?? await find( + offsetPaginate(opts => api.Users.all({ + search: user.username, + asAdmin: true, + ...opts, + })), + isUser, + ) +} + +export async function upsertUser(user: UserObject, isAdmin?: boolean, isAuditor?: boolean): Promise { + const api = getApi() + const username = createUsername(user.email) + const existingUser = await getUser({ ...user, username }) + + const userDefinitionBase: CreateUserOptions = { + // required options + name: `${user.firstName} ${user.lastName}`, + username, + email: user.email, + // sso options + externUid: user.email, + provider: 'openid_connect', + admin: isAdmin, + auditor: isAuditor, + } + + if (existingUser) { + const incorrectProps = Object.entries(userDefinitionBase).reduce((acc, [key, value]) => { + if (existingUser[key] !== value) { + acc.push({ + key, + curr: existingUser[key], + new: value, + }) + } + return acc + }, [] as { key: string, curr: any, new: any }[]) + + if (incorrectProps.length) { + logger.debug({ action: 'upsertUser', changes: incorrectProps }, 'User properties differ from expected') + try { + await api.Users.edit(existingUser.id, userDefinitionBase) + } catch (err) { + logger.error({ action: 'upsertUser', err }, 'Failed to update user') + } + } + try { + await upsertCustomAttribute('users', existingUser.id, userIdCustomAttributeKey, user.id) + } catch (err) { + logger.debug({ action: 'upsertUser', userId: existingUser.id, err }, 'Failed to upsert user custom attribute') + } + return existingUser + } + + const created = await api.Users.create({ + ...userDefinitionBase, + canCreateGroup: false, + forceRandomPassword: true, + projectsLimit: 0, + skipConfirmation: true, + }) + try { + await upsertCustomAttribute('users', created.id, userIdCustomAttributeKey, user.id) + } catch (err) { + logger.debug({ action: 'upsertUser', userId: created.id, err }, 'Failed to upsert user custom attribute') + } + return created +} diff --git a/.jjconflict-side-1/plugins/gitlab/src/utils.ts b/.jjconflict-side-1/plugins/gitlab/src/utils.ts new file mode 100644 index 0000000000..3dbe69b772 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/src/utils.ts @@ -0,0 +1,198 @@ +import type { BaseRequestOptions, Gitlab as IGitlab, OffsetPagination, PaginationRequestOptions } from '@gitbeaker/core' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { Gitlab } from '@gitbeaker/rest' +import config from './config.js' +import { customAttributesFilter, groupRootCustomAttributeKey, managedByConsoleCustomAttributeKey, upsertCustomAttribute } from './custom-attributes.js' +import { logger } from './logger.js' + +let api: IGitlab | undefined + +let groupRootId: number + +export const MAX_PAGINATION_PER_PAGE = 100 + +export async function getGroupRootId(throwIfNotFound?: true): Promise +export async function getGroupRootId(throwIfNotFound?: false): Promise +export async function getGroupRootId(throwIfNotFound?: boolean): Promise { + const gitlabApi = getApi() + const projectRootDir = config().projectsRootDir + logger.debug({ action: 'getGroupRootId', projectRootDir }, 'Resolve group root id') + if (groupRootId) return groupRootId + const fast = await find( + offsetPaginate(opts => gitlabApi.Groups.all({ + ...customAttributesFilter(groupRootCustomAttributeKey, projectRootDir), + ...opts, + })), + grp => grp.full_path === projectRootDir, + ) + const groupRoot = fast ?? await find( + offsetPaginate(opts => gitlabApi.Groups.all({ + search: projectRootDir, + ...opts, + })), + grp => grp.full_path === projectRootDir, + ) + logger.debug({ action: 'getGroupRootId', groupRootId: groupRoot?.id, groupRootPath: groupRoot?.full_path }, 'Resolved group root') + const searchId = groupRoot?.id + if (typeof searchId === 'undefined') { + if (throwIfNotFound) { + throw new Error(`Gitlab inaccessible, impossible de trouver le groupe RACINE ${projectRootDir}`) + } + return searchId + } + groupRootId = searchId + try { + await upsertCustomAttribute('groups', groupRootId, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', groupRootId, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getGroupRootId', groupRootId, err }, 'Failed to upsert group root custom attribute') + } + return groupRootId +} + +async function createGroupRoot(): Promise { + logger.info({ action: 'createGroupRoot', projectRootDir: config().projectsRootDir }, 'Create group root hierarchy') + const gitlabApi = getApi() + const projectRootDir = config().projectsRootDir + const projectRootDirArray = projectRootDir.split('/') + + const rootGroupPath = projectRootDirArray.shift() + if (!rootGroupPath) { + throw new Error('No projectRootDir available') + } + + let parentGroup = await find(offsetPaginate(opts => gitlabApi.Groups.all({ + search: rootGroupPath, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), grp => grp.full_path === rootGroupPath) ?? await gitlabApi.Groups.create(rootGroupPath, rootGroupPath) + + if (parentGroup.full_path === projectRootDir) { + try { + await upsertCustomAttribute('groups', parentGroup.id, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', parentGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createGroupRoot', groupRootId: parentGroup.id, err }, 'Failed to upsert group root custom attribute') + } + return parentGroup.id + } + + for (const path of projectRootDirArray) { + const futureFullPath = `${parentGroup.full_path}/${path}` + parentGroup = await find(offsetPaginate(opts => gitlabApi.Groups.all({ + search: futureFullPath, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), grp => grp.full_path === futureFullPath) ?? await gitlabApi.Groups.create(path, path, { parentId: parentGroup.id, visibility: 'internal' }) + + if (parentGroup.full_path === projectRootDir) { + try { + await upsertCustomAttribute('groups', parentGroup.id, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', parentGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createGroupRoot', groupRootId: parentGroup.id, err }, 'Failed to upsert group root custom attribute') + } + return parentGroup.id + } + } + throw new Error('No projectRootDir available or is malformed') +} + +export async function getOrCreateGroupRoot(): Promise { + return await getGroupRootId(false) ?? createGroupRoot() +} + +export function getApi(): IGitlab { + api ??= new Gitlab({ token: config().token, host: config().internalUrl }) + return api +} + +export const infraAppsRepoName = 'infra-apps' +export const internalMirrorRepoName = 'mirror' + +export interface VaultSecrets { + GITLAB: { + PROJECT_SLUG: string + GIT_MIRROR_PROJECT_ID: number + GIT_MIRROR_TOKEN: string + } +} + +// eslint-disable-next-line regexp/no-super-linear-backtracking +const keyValueRegExp = /\/\/(.*):(.*)@/g + +export function cleanGitlabError(error: T): T { + if (error instanceof GitbeakerRequestError && error.cause?.description) { + error.cause.description = String(error.cause.description).replaceAll(keyValueRegExp, '//MASKED:MASKED@') + } + return error +} + +export function matchRole(projectSlug: string, roleOidcGroup: string, configuredRolePath: string[]) { + return configuredRolePath.some(path => roleOidcGroup === `/${projectSlug}${path}`) +} + +export interface OffsetPaginateOptions { + startPage?: number + perPage?: number + maxPages?: number +} + +export async function* offsetPaginate( + request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, + options?: OffsetPaginateOptions, +): AsyncGenerator { + let page: number | null = options?.startPage ?? 1 + let pagesFetched = 0 + let total: number = 0 + logger.debug({ action: 'offsetPaginate', page }, 'Pagination start') + while (page !== null) { + if (options?.maxPages && pagesFetched >= options.maxPages) { + page = null + continue + } + try { + const { data, paginationInfo } = await request({ + page, + perPage: options?.perPage, + maxPages: options?.maxPages, + showExpanded: true, + pagination: 'offset', + }) + pagesFetched += 1 + total += data.length + logger.debug( + { action: 'offsetPaginate', page, nextPage: paginationInfo.next, items: data.length, total }, + 'Pagination page fetched', + ) + for (const item of data) { + yield item + } + page = paginationInfo.next + } catch (error) { + logger.error({ action: 'offsetPaginate', page, err: error }, 'Pagination request failed') + throw error + } + } + logger.debug({ action: 'offsetPaginate', total }, 'Pagination done') +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} + +export async function find( + iterable: AsyncIterable, + predicate: (item: T) => boolean, +): Promise { + for await (const item of iterable) { + if (predicate(item)) { + return item + } + } + return undefined +} diff --git a/.jjconflict-side-1/plugins/gitlab/tsconfig.eslint.json b/.jjconflict-side-1/plugins/gitlab/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-1/plugins/gitlab/tsconfig.json b/.jjconflict-side-1/plugins/gitlab/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-1/plugins/gitlab/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-1/plugins/harbor/.gitignore b/.jjconflict-side-1/plugins/harbor/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-1/plugins/harbor/eslint.config.js b/.jjconflict-side-1/plugins/harbor/eslint.config.js new file mode 100644 index 0000000000..3148148bf5 --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/eslint.config.js @@ -0,0 +1,4 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +// Generated by `pnpm run swagger:generate` — never linted or hand-edited. +export default eslintConfigBase.append({ ignores: ['src/api/Api.ts'] }) diff --git a/.jjconflict-side-1/plugins/harbor/package.json b/.jjconflict-side-1/plugins/harbor/package.json new file mode 100644 index 0000000000..ec8a51bb24 --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/package.json @@ -0,0 +1,55 @@ +{ + "name": "@cpn-console/harbor-plugin", + "type": "module", + "version": "2.3.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types", + "utils.d.ts" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./", + "swagger:generate": "node ./scripts/generate-api.mjs" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime", + "bytes": "catalog:runtime", + "cron-validator": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "js-yaml": "catalog:tools", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "swagger-typescript-api": "catalog:build", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/plugins/harbor/scripts/generate-api.mjs b/.jjconflict-side-1/plugins/harbor/scripts/generate-api.mjs new file mode 100644 index 0000000000..2633066ec9 --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/scripts/generate-api.mjs @@ -0,0 +1,37 @@ +/** + * Generates `src/api/Api.ts` from Harbor's OpenAPI/Swagger spec. + * + * The spec is fetched and parsed with `js-yaml` before being handed to + * `swagger-typescript-api`. This is deliberate: swagger-typescript-api v13 + * bundles a strict YAML parser that rejects Harbor's multi-line single-quoted + * `description` scalars, so we parse the YAML ourselves and pass the resulting + * JSON spec object programmatically. + * + * Bump HARBOR_TAG to regenerate against a newer Harbor release. + */ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { generateApi } from 'swagger-typescript-api' + +const HARBOR_TAG = process.env.HARBOR_TAG ?? 'v2.13.5' +const SPEC_URL = `https://raw.githubusercontent.com/goharbor/harbor/${HARBOR_TAG}/api/v2.0/swagger.yaml` + +const outputDir = resolve(dirname(fileURLToPath(import.meta.url)), '../src/api') + +console.log(`Fetching Harbor ${HARBOR_TAG} spec from ${SPEC_URL}`) +const res = await fetch(SPEC_URL) +if (!res.ok) { + throw new Error(`Failed to fetch spec (${res.status} ${res.statusText})`) +} + +const spec = yaml.load(await res.text()) + +await generateApi({ + spec, + output: outputDir, + fileName: 'Api.ts', + httpClientType: 'axios', +}) + +console.log(`Generated ${outputDir}/Api.ts`) diff --git a/.jjconflict-side-1/plugins/harbor/src/api/Api.ts b/.jjconflict-side-1/plugins/harbor/src/api/Api.ts new file mode 100644 index 0000000000..2616919eba --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/src/api/Api.ts @@ -0,0 +1,8030 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +/** The error array that describe the errors got during the handling of request */ +export interface Errors { + errors?: Error[]; +} + +/** a model for all the error response coming from harbor */ +export interface Error { + /** The error code */ + code?: string; + /** The error message */ + message?: string; +} + +export interface Search { + /** Search results of the projects that matched the filter keywords. */ + project?: Project[]; + /** Search results of the repositories that matched the filter keywords. */ + repository?: SearchRepository[]; +} + +export interface SearchRepository { + /** The ID of the project that the repository belongs to */ + project_id?: number; + /** The name of the project that the repository belongs to */ + project_name?: string; + /** The flag to indicate the publicity of the project that the repository belongs to (1 is public, 0 is not) */ + project_public?: boolean; + /** The name of the repository */ + repository_name?: string; + /** The count how many times the repository is pulled */ + pull_count?: number; + /** The count of artifacts in the repository */ + artifact_count?: number; +} + +export interface Repository { + /** + * The ID of the repository + * @format int64 + */ + id?: number; + /** + * The ID of the project that the repository belongs to + * @format int64 + */ + project_id?: number; + /** The name of the repository */ + name?: string; + /** The description of the repository */ + description?: string; + /** + * The count of the artifacts inside the repository + * @format int64 + */ + artifact_count: number; + /** + * The count that the artifact inside the repository pulled + * @format int64 + */ + pull_count: number; + /** + * The creation time of the repository + * @format date-time + */ + creation_time?: string | null; + /** + * The update time of the repository + * @format date-time + */ + update_time?: string; +} + +export interface Artifact { + /** + * The ID of the artifact + * @format int64 + */ + id?: number; + /** The type of the artifact, e.g. image, chart, etc */ + type?: string; + /** The media type of the artifact */ + media_type?: string; + /** The manifest media type of the artifact */ + manifest_media_type?: string; + /** The artifact_type in the manifest of the artifact */ + artifact_type?: string; + /** + * The ID of the project that the artifact belongs to + * @format int64 + */ + project_id?: number; + /** + * The ID of the repository that the artifact belongs to + * @format int64 + */ + repository_id?: number; + /** The name of the repository that the artifact belongs to */ + repository_name?: string; + /** The digest of the artifact */ + digest?: string; + /** + * The size of the artifact + * @format int64 + */ + size?: number; + /** The digest of the icon */ + icon?: string; + /** + * The push time of the artifact + * @format date-time + */ + push_time?: string; + /** + * The latest pull time of the artifact + * @format date-time + */ + pull_time?: string; + extra_attrs?: ExtraAttrs; + annotations?: Annotations; + references?: Reference[]; + tags?: Tag[]; + addition_links?: AdditionLinks; + labels?: Label[]; + /** The overview of the scan result. */ + scan_overview?: ScanOverview; + /** The overview of the generating SBOM progress */ + sbom_overview?: SBOMOverview; + accessories?: Accessory[]; +} + +export interface Tag { + /** + * The ID of the tag + * @format int64 + */ + id?: number; + /** + * The ID of the repository that the tag belongs to + * @format int64 + */ + repository_id?: number; + /** + * The ID of the artifact that the tag attached to + * @format int64 + */ + artifact_id?: number; + /** The name of the tag */ + name?: string; + /** + * The push time of the tag + * @format date-time + */ + push_time?: string; + /** + * The latest pull time of the tag + * @format date-time + */ + pull_time?: string; + /** The immutable status of the tag */ + immutable: boolean; +} + +export type ExtraAttrs = Record; + +export type Annotations = Record; + +export type AdditionLinks = Record; + +export interface AdditionLink { + /** The link of the addition */ + href?: string; + /** Determine whether the link is an absolute URL or not */ + absolute: boolean; +} + +export interface Reference { + /** + * The parent ID of the reference + * @format int64 + */ + parent_id?: number; + /** + * The child ID of the reference + * @format int64 + */ + child_id?: number; + /** The digest of the child artifact */ + child_digest?: string; + platform?: Platform; + annotations?: Annotations; + /** The download URLs */ + urls?: string[]; +} + +export interface Platform { + /** The architecture that the artifact applys to */ + architecture?: string; + /** The OS that the artifact applys to */ + os?: string; + /** The version of the OS that the artifact applys to */ + "'os.version'"?: string; + /** The features of the OS that the artifact applys to */ + "'os.features'"?: string[]; + /** The variant of the CPU */ + variant?: string; +} + +export interface Label { + /** + * The ID of the label + * @format int64 + */ + id?: number; + /** The name the label */ + name?: string; + /** The description the label */ + description?: string; + /** The color the label */ + color?: string; + /** The scope the label */ + scope?: string; + /** + * The ID of project that the label belongs to + * @format int64 + */ + project_id?: number; + /** + * The creation time the label + * @format date-time + */ + creation_time?: string; + /** + * The update time of the label + * @format date-time + */ + update_time?: string; +} + +export interface Scanner { + /** + * Name of the scanner + * @example "Trivy" + */ + name?: string; + /** + * Name of the scanner provider + * @example "Aqua Security" + */ + vendor?: string; + /** + * Version of the scanner adapter + * @example "v0.9.1" + */ + version?: string; +} + +/** The scan overview attached in the metadata of tag */ +export type ScanOverview = Record; + +/** The generate SBOM overview information */ +export interface SBOMOverview { + /** + * The start time of the generating sbom report task + * @format date-time + * @example "2006-01-02T14:04:05Z" + */ + start_time?: string; + /** + * The end time of the generating sbom report task + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + end_time?: string; + /** The status of the generating SBOM task */ + scan_status?: string; + /** The digest of the generated SBOM accessory */ + sbom_digest?: string; + /** + * id of the native scan report + * @example "5f62c830-f996-11e9-957f-0242c0a89008" + */ + report_id?: string; + /** + * Time in seconds required to create the report + * @format int64 + * @example 300 + */ + duration?: number; + scanner?: Scanner; +} + +/** The summary for the native report */ +export interface NativeReportSummary { + /** + * id of the native scan report + * @example "5f62c830-f996-11e9-957f-0242c0a89008" + */ + report_id?: string; + /** + * The status of the report generating process + * @example "Success" + */ + scan_status?: string; + /** + * The overall severity + * @example "High" + */ + severity?: string; + /** + * The seconds spent for generating the report + * @format int64 + * @example 300 + */ + duration?: number; + /** VulnerabilitySummary contains the total number of the foun d vulnerabilities number and numbers of each severity level. */ + summary?: VulnerabilitySummary; + /** + * The start time of the scan process that generating report + * @format date-time + * @example "2006-01-02T14:04:05Z" + */ + start_time?: string; + /** + * The end time of the scan process that generating report + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + end_time?: string; + /** + * The complete percent of the scanning which value is between 0 and 100 + * @example 100 + */ + complete_percent?: number; + scanner?: Scanner; +} + +/** VulnerabilitySummary contains the total number of the foun d vulnerabilities number and numbers of each severity level. */ +export interface VulnerabilitySummary { + /** + * The total number of the found vulnerabilities + * @format int + * @example 500 + */ + total: number; + /** + * The number of the fixable vulnerabilities + * @format int + * @example 100 + */ + fixable: number; + /** + * Numbers of the vulnerabilities with different severity + * @example {"Critical":5,"High":5} + */ + summary: Record; +} + +export interface AuditLog { + /** The ID of the audit log entry. */ + id?: number; + /** Username of the user in this log entry. */ + username?: string; + /** Name of the repository in this log entry. */ + resource?: string; + /** Tag of the repository in this log entry. */ + resource_type?: string; + /** The operation against the repository in this log entry. */ + operation?: string; + /** + * The time when this operation is triggered. + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + op_time?: string; +} + +export interface AuditLogExt { + /** The ID of the audit log entry. */ + id?: number; + /** The username of the operator in this log entry. */ + username?: string; + /** Name of the resource in this log entry. */ + resource?: string; + /** Type of the resource in this log entry. */ + resource_type?: string; + /** The operation against the resource in this log entry. */ + operation?: string; + /** The operation's detail description */ + operation_description?: string; + /** the operation's result, true for success, false for fail */ + operation_result: boolean; + /** + * The time when this operation is triggered. + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + op_time?: string; +} + +export interface AuditLogEventType { + /** + * the event type, such as create_user. + * @example "create_user" + */ + event_type?: string; +} + +export interface Metadata { + /** id */ + id?: string; + /** name */ + name?: string; + /** icon */ + icon?: string; + /** maintainers */ + maintainers?: string[]; + /** version */ + version?: string; + /** source */ + source?: string; +} + +export interface Instance { + /** Unique ID */ + id?: number; + /** Instance name */ + name?: string; + /** Description of instance */ + description?: string; + /** Based on which driver, identified by ID */ + vendor?: string; + /** The service endpoint of this instance */ + endpoint?: string; + /** The authentication way supported */ + auth_mode?: string; + /** The auth credential data if exists */ + auth_info?: Record; + /** The health status */ + status?: string; + /** Whether the instance is activated or not */ + enabled: boolean; + /** Whether the instance is default or not */ + default: boolean; + /** Whether the instance endpoint is insecure or not */ + insecure: boolean; + /** + * The timestamp of instance setting up + * @format int64 + */ + setup_timestamp?: number; +} + +export interface PreheatPolicy { + /** The ID of preheat policy */ + id?: number; + /** The Name of preheat policy */ + name?: string; + /** The Description of preheat policy */ + description?: string; + /** The ID of preheat policy project */ + project_id?: number; + /** The ID of preheat policy provider */ + provider_id?: number; + /** The Name of preheat policy provider */ + provider_name?: string; + /** The Filters of preheat policy */ + filters?: string; + /** The Trigger of preheat policy */ + trigger?: string; + /** Whether the preheat policy enabled */ + enabled: boolean; + /** The extra attributes of preheat policy */ + extra_attrs?: string; + /** + * The Create Time of preheat policy + * @format date-time + */ + creation_time?: string; + /** + * The Update Time of preheat policy + * @format date-time + */ + update_time?: string; +} + +export interface Metrics { + /** The count of task */ + task_count?: number; + /** The count of success task */ + success_task_count?: number; + /** The count of error task */ + error_task_count?: number; + /** The count of pending task */ + pending_task_count?: number; + /** The count of running task */ + running_task_count?: number; + /** The count of scheduled task */ + scheduled_task_count?: number; + /** The count of stopped task */ + stopped_task_count?: number; +} + +export interface Execution { + /** The ID of execution */ + id?: number; + /** The vendor type of execution */ + vendor_type?: string; + /** The vendor id of execution */ + vendor_id?: number; + /** The status of execution */ + status?: string; + /** The status message of execution */ + status_message?: string; + metrics?: Metrics; + /** The trigger of execution */ + trigger?: string; + extra_attrs?: ExtraAttrs; + /** The start time of execution */ + start_time?: string; + /** The end time of execution */ + end_time?: string; +} + +export interface Task { + /** The ID of task */ + id?: number; + /** The ID of task execution */ + execution_id?: number; + /** The status of task */ + status?: string; + /** The status message of task */ + status_message?: string; + /** + * The count of task run + * @format int32 + */ + run_count?: number; + extra_attrs?: ExtraAttrs; + /** The creation time of task */ + creation_time?: string; + /** The update time of task */ + update_time?: string; + /** The start time of task */ + start_time?: string; + /** The end time of task */ + end_time?: string; +} + +export interface ProviderUnderProject { + id?: number; + provider?: string; + enabled?: boolean; + default?: boolean; +} + +export interface Icon { + /** The content type of the icon */ + "content-type"?: string; + /** The base64 encoded content of the icon */ + content?: string; +} + +export interface ProjectReq { + /** + * The name of the project. + * @maxLength 255 + */ + project_name?: string; + /** deprecated, reserved for project creation in replication */ + public?: boolean | null; + /** The metadata of the project. */ + metadata?: ProjectMetadata; + /** The CVE allowlist of the project. */ + cve_allowlist?: CVEAllowlist; + /** + * The storage quota of the project. + * @format int64 + */ + storage_limit?: number | null; + /** + * The ID of referenced registry when creating the proxy cache project + * @format int64 + */ + registry_id?: number | null; +} + +export interface Project { + /** + * Project ID + * @format int32 + */ + project_id?: number; + /** + * The owner ID of the project always means the creator of the project. + * @format int32 + */ + owner_id?: number; + /** The name of the project. */ + name?: string; + /** + * The ID of referenced registry when the project is a proxy cache project. + * @format int64 + */ + registry_id?: number; + /** + * The creation time of the project. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the project. + * @format date-time + */ + update_time?: string; + /** A deletion mark of the project. */ + deleted?: boolean; + /** The owner name of the project. */ + owner_name?: string; + /** Correspond to the UI about whether the project's publicity is updatable (for UI) */ + togglable?: boolean; + /** The role ID with highest permission of the current user who triggered the API (for UI). This attribute is deprecated and will be removed in future versions. */ + current_user_role_id?: number; + /** The list of role ID of the current user who triggered the API (for UI) */ + current_user_role_ids?: number[]; + /** The number of the repositories under this project. */ + repo_count: number; + /** The metadata of the project. */ + metadata?: ProjectMetadata; + /** The CVE allowlist of this project. */ + cve_allowlist?: CVEAllowlist; +} + +export interface ProjectDeletable { + /** Whether the project can be deleted. */ + deletable?: boolean; + /** The detail message when the project can not be deleted. */ + message?: string; +} + +export interface ProjectMetadata { + /** The public status of the project. The valid values are "true", "false". */ + public?: string; + /** Whether content trust is enabled or not. If it is enabled, user can't pull unsigned images from this project. The valid values are "true", "false". */ + enable_content_trust?: string | null; + /** Whether cosign content trust is enabled or not. If it is enabled, user can't pull images without cosign signature from this project. The valid values are "true", "false". */ + enable_content_trust_cosign?: string | null; + /** Whether prevent the vulnerable images from running. The valid values are "true", "false". */ + prevent_vul?: string | null; + /** If the vulnerability is high than severity defined here, the images can't be pulled. The valid values are "none", "low", "medium", "high", "critical". */ + severity?: string | null; + /** Whether scan images automatically when pushing. The valid values are "true", "false". */ + auto_scan?: string | null; + /** Whether generating SBOM automatically when pushing a subject artifact. The valid values are "true", "false". */ + auto_sbom_generation?: string | null; + /** Whether this project reuse the system level CVE allowlist as the allowlist of its own. The valid values are "true", "false". If it is set to "true" the actual allowlist associate with this project, if any, will be ignored. */ + reuse_sys_cve_allowlist?: string | null; + /** The ID of the tag retention policy for the project */ + retention_id?: string | null; + /** The bandwidth limit of proxy cache, in Kbps (kilobits per second). It limits the communication between Harbor and the upstream registry, not the client and the Harbor. */ + proxy_speed_kb?: string | null; +} + +export interface ProjectSummary { + /** The number of the repositories under this project. */ + repo_count: number; + /** The total number of project admin members. */ + project_admin_count?: number; + /** The total number of maintainer members. */ + maintainer_count?: number; + /** The total number of developer members. */ + developer_count?: number; + /** The total number of guest members. */ + guest_count?: number; + /** The total number of limited guest members. */ + limited_guest_count?: number; + quota?: ProjectSummaryQuota; + registry?: Registry; +} + +export interface ProjectSummaryQuota { + /** The hard limits of the quota */ + hard?: ResourceList; + /** The used status of the quota */ + used?: ResourceList; +} + +export interface ProjectScanner { + /** The identifier of the scanner registration */ + uuid: string; +} + +/** The CVE Allowlist for system or project */ +export interface CVEAllowlist { + /** ID of the allowlist */ + id?: number; + /** ID of the project which the allowlist belongs to. For system level allowlist this attribute is zero. */ + project_id?: number; + /** the time for expiration of the allowlist, in the form of seconds since epoch. This is an optional attribute, if it's not set the CVE allowlist does not expire. */ + expires_at?: number | null; + items?: CVEAllowlistItem[]; + /** + * The creation time of the allowlist. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the allowlist. + * @format date-time + */ + update_time?: string; +} + +/** The item in CVE allowlist */ +export interface CVEAllowlistItem { + /** The ID of the CVE, such as "CVE-2019-10164" */ + cve_id?: string; +} + +export interface ReplicationPolicy { + /** + * The policy ID. + * @format int64 + */ + id?: number; + /** The policy name. */ + name?: string; + /** The description of the policy. */ + description?: string; + /** The source registry. */ + src_registry?: Registry; + /** The destination registry. */ + dest_registry?: Registry; + /** The destination namespace. */ + dest_namespace?: string; + /** + * Specify how many path components will be replaced by the provided destination namespace. + * The default value is -1 in which case the legacy mode will be applied. + * @format int8 + */ + dest_namespace_replace_count?: number; + trigger?: ReplicationTrigger; + /** The replication policy filter array. */ + filters?: ReplicationFilter[]; + /** Whether to replicate the deletion operation. */ + replicate_deletion?: boolean; + /** Deprecated, use "replicate_deletion" instead. Whether to replicate the deletion operation. */ + deletion?: boolean; + /** Whether to override the resources on the destination registry. */ + override?: boolean; + /** Whether the policy is enabled or not. */ + enabled?: boolean; + /** + * The create time of the policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the policy. + * @format date-time + */ + update_time?: string; + /** + * speed limit for each task + * @format int32 + */ + speed?: number; + /** Whether to enable copy by chunk. */ + copy_by_chunk?: boolean; +} + +export interface ReplicationTrigger { + /** The replication policy trigger type. The valid values are manual, event_based and scheduled. */ + type?: string; + trigger_settings?: ReplicationTriggerSettings; +} + +export interface ReplicationTriggerSettings { + /** The cron string for scheduled trigger */ + cron?: string; +} + +export interface ReplicationFilter { + /** The replication policy filter type. */ + type?: string; + /** The value of replication policy filter. */ + value?: object; + /** matches or excludes the result */ + decoration?: string; +} + +export interface RegistryCredential { + /** Credential type, such as 'basic', 'oauth'. */ + type?: string; + /** Access key, e.g. user name when credential type is 'basic'. */ + access_key?: string; + /** Access secret, e.g. password when credential type is 'basic'. */ + access_secret?: string; +} + +export interface Registry { + /** + * The registry ID. + * @format int64 + */ + id: number; + /** The registry URL string. */ + url?: string; + /** The registry name. */ + name?: string; + credential?: RegistryCredential; + /** Type of the registry, e.g. 'harbor'. */ + type?: string; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean; + /** Description of the registry. */ + description?: string; + /** Health status of the registry. */ + status?: string; + /** + * The create time of the policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the policy. + * @format date-time + */ + update_time?: string; +} + +export interface RegistryUpdate { + /** The registry name. */ + name?: string | null; + /** Description of the registry. */ + description?: string | null; + /** The registry URL. */ + url?: string | null; + /** Credential type of the registry, e.g. 'basic'. */ + credential_type?: string | null; + /** The registry access key. */ + access_key?: string | null; + /** The registry access secret. */ + access_secret?: string | null; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean | null; +} + +export interface RegistryPing { + /** + * The registry ID. + * @format int64 + */ + id?: number | null; + /** Type of the registry, e.g. 'harbor'. */ + type?: string | null; + /** The registry URL. */ + url?: string | null; + /** Credential type of the registry, e.g. 'basic'. */ + credential_type?: string | null; + /** The registry access key. */ + access_key?: string | null; + /** The registry access secret. */ + access_secret?: string | null; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean | null; +} + +/** The registry info contains the base info and capability declarations of the registry */ +export interface RegistryInfo { + /** The registry type */ + type?: string; + /** The description */ + description?: string; + /** The filters that the registry supports */ + supported_resource_filters?: FilterStyle[]; + /** The triggers that the registry supports */ + supported_triggers?: string[]; + /** The registry whether support copy by chunk. */ + supported_copy_by_chunk?: boolean; +} + +/** The registry provider info contains the base info and capability declarations of the registry provider */ +export interface RegistryProviderInfo { + /** The endpoint pattern */ + endpoint_pattern?: RegistryProviderEndpointPattern; + /** The credential pattern */ + credential_pattern?: RegistryProviderCredentialPattern; +} + +/** The registry endpoint pattern */ +export interface RegistryProviderEndpointPattern { + /** The endpoint type */ + endpoint_type?: string; + /** The endpoint list */ + endpoints?: RegistryEndpoint[]; +} + +/** The registry credential pattern */ +export interface RegistryProviderCredentialPattern { + /** The access key type */ + access_key_type?: string; + /** The access key data */ + access_key_data?: string; + /** The access secret type */ + access_secret_type?: string; + /** The access secret data */ + access_secret_data?: string; +} + +/** The style of the resource filter */ +export interface RegistryEndpoint { + /** The endpoint key */ + key?: string; + /** The endpoint value */ + value?: string; +} + +/** The style of the resource filter */ +export interface FilterStyle { + /** The filter type */ + type?: string; + /** The filter style */ + style?: string; + /** The filter values */ + values?: string[]; +} + +export type ResourceList = Record; + +/** The replication execution */ +export interface ReplicationExecution { + /** The ID of the execution */ + id?: number; + /** The ID if the policy that the execution belongs to */ + policy_id?: number; + /** The status of the execution */ + status?: string; + /** The trigger mode */ + trigger?: string; + /** + * The start time + * @format date-time + */ + start_time?: string; + /** + * The end time + * @format date-time + */ + end_time?: string; + /** The status text */ + status_text: string; + /** The total count of all executions */ + total: number; + /** The count of failed executions */ + failed: number; + /** The count of succeed executions */ + succeed: number; + /** The count of in_progress executions */ + in_progress: number; + /** The count of stopped executions */ + stopped: number; +} + +export interface StartReplicationExecution { + /** + * The ID of policy that the execution belongs to. + * @format int64 + */ + policy_id?: number; +} + +/** The replication task */ +export interface ReplicationTask { + /** The ID of the task */ + id?: number; + /** The ID of the execution that the task belongs to */ + execution_id?: number; + /** The status of the task */ + status?: string; + /** The ID of the underlying job that the task related to */ + job_id?: string; + /** The operation of the task */ + operation?: string; + /** The type of the resource that the task operates */ + resource_type?: string; + /** The source resource that the task operates */ + src_resource?: string; + /** The destination resource that the task operates */ + dst_resource?: string; + /** + * The start time of the task + * @format date-time + */ + start_time?: string; + /** + * The end time of the task + * @format date-time + */ + end_time?: string; +} + +export interface Robot { + /** + * The ID of the robot + * @format int64 + */ + id?: number; + /** The name of the robot */ + name?: string; + /** The description of the robot */ + description?: string; + /** The secret of the robot */ + secret?: string; + /** The level of the robot, project or system */ + level?: string; + /** + * The duration of the robot in days, duration must be either -1(Never) or a positive integer + * @format int64 + */ + duration?: number | null; + /** The editable status of the robot */ + editable: boolean; + /** The disable status of the robot */ + disable: boolean; + /** + * The expiration date of the robot + * @format int64 + */ + expires_at?: number; + permissions?: RobotPermission[]; + /** The type of the robot creator, like local(harbor_user) or robot. */ + creator_type?: string; + /** The reference of the robot creator, like the id of harbor user. */ + creator_ref?: number; + /** + * The creation time of the robot. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the robot. + * @format date-time + */ + update_time?: string; +} + +/** The request for robot account creation. */ +export interface RobotCreate { + /** The name of the robot */ + name?: string; + /** The description of the robot */ + description?: string; + /** The secret of the robot */ + secret?: string; + /** The level of the robot, project or system */ + level?: string; + /** The disable status of the robot */ + disable?: boolean; + /** + * The duration of the robot in days, duration must be either -1(Never) or a positive integer + * @format int64 + */ + duration?: number; + permissions?: RobotPermission[]; +} + +/** The response for robot account creation. */ +export interface RobotCreated { + /** + * The ID of the robot + * @format int64 + */ + id?: number; + /** The name of the robot */ + name?: string; + /** The secret of the robot */ + secret?: string; + /** + * The creation time of the robot. + * @format date-time + */ + creation_time?: string; + /** + * The expiration date of the robot + * @format int64 + */ + expires_at?: number; +} + +/** The response for refresh/update robot account secret. */ +export interface RobotSec { + /** The secret of the robot */ + secret?: string; +} + +export interface RobotPermission { + /** The kind of the permission */ + kind?: string; + /** The namespace of the permission */ + namespace?: string; + access?: Access[]; +} + +export interface Access { + /** The resource of the access. Possible resources are listed here for system and project level https://github.com/goharbor/harbor/blob/main/src/common/rbac/const.go */ + resource?: string; + /** The action of the access. Possible actions are *, pull, push, create, read, update, delete, list, operate, scanner-pull and stop. */ + action?: string; + /** The effect of the access */ + effect?: string; +} + +export interface RobotCreateV1 { + /** The name of robot account */ + name?: string; + /** The description of robot account */ + description?: string; + /** The expiration time on or after which the JWT MUST NOT be accepted for processing. */ + expires_at?: number; + /** The permission of robot account */ + access?: Access[]; +} + +export interface Storage { + /** + * Total volume size. + * @format uint64 + */ + total?: number; + /** + * Free volume size. + * @format uint64 + */ + free?: number; +} + +export interface GeneralInfo { + /** + * The banner message for the UI. It is the stringified result of the banner message object. + * @example "{"closable":true,"message":"your banner message content","type":"warning","fromDate":"06/19/2023","toDate":"06/21/2023"}" + */ + banner_message?: string | null; + /** + * The current time of the server. + * @format date-time + */ + current_time?: string | null; + /** The url of registry against which the docker command should be issued. */ + registry_url?: string | null; + /** The external URL of Harbor, with protocol. */ + external_url?: string | null; + /** The auth mode of current Harbor instance. */ + auth_mode?: string | null; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: boolean | null; + /** Indicate who can create projects, it could be 'adminonly' or 'everyone'. */ + project_creation_restriction?: string | null; + /** Indicate whether the Harbor instance enable user to register himself. */ + self_registration?: boolean | null; + /** Indicate whether there is a ca root cert file ready for download in the file system. */ + has_ca_root?: boolean | null; + /** The build version of Harbor. */ + harbor_version?: string | null; + /** The storage provider's name of Harbor registry */ + registry_storage_provider_name?: string | null; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: boolean | null; + /** The flag to indicate whether notification mechanism is enabled on Harbor instance. */ + notification_enable?: boolean | null; + /** The setting of auth proxy this is only available when Harbor relies on authproxy for authentication. */ + authproxy_settings?: AuthproxySetting | null; + /** The OIDC provider name, empty if current auth is not OIDC_auth or OIDC provider is not configured. */ + oidc_provider_name?: string | null; +} + +export interface AuthproxySetting { + /** The fully qualified URI of login endpoint of authproxy, such as 'https://192.168.1.2:8443/login' */ + endpoint?: string; + /** The fully qualified URI of token review endpoint of authproxy, such as 'https://192.168.1.2:8443/tokenreview' */ + tokenreivew_endpoint?: string; + /** The flag to determine whether Harbor can skip search the user/group when adding him as a member. */ + skip_search?: boolean; + /** The flag to determine whether Harbor should verify the certificate when connecting to the auth proxy. */ + verify_cert?: boolean; + /** The certificate to be pinned when connecting auth proxy. */ + server_certificate?: string; +} + +export interface SystemInfo { + /** The storage of system. */ + storage?: Storage[]; +} + +export interface GCHistory { + /** the id of gc job. */ + id?: number; + /** the job name of gc job. */ + job_name?: string; + /** the job kind of gc job. */ + job_kind?: string; + /** the job parameters of gc job. */ + job_parameters?: string; + schedule?: ScheduleObj; + /** the status of gc job. */ + job_status?: string; + /** if gc job was deleted. */ + deleted?: boolean; + /** + * the creation time of gc job. + * @format date-time + */ + creation_time?: string; + /** + * the update time of gc job. + * @format date-time + */ + update_time?: string; +} + +export interface ExecHistory { + /** the id of purge job. */ + id?: number; + /** the job name of purge job. */ + job_name?: string; + /** the job kind of purge job. */ + job_kind?: string; + /** the job parameters of purge job. */ + job_parameters?: string; + schedule?: ScheduleObj; + /** the status of purge job. */ + job_status?: string; + /** if purge job was deleted. */ + deleted?: boolean; + /** + * the creation time of purge job. + * @format date-time + */ + creation_time?: string; + /** + * the update time of purge job. + * @format date-time + */ + update_time?: string; +} + +export interface Schedule { + /** The id of the schedule. */ + id?: number; + /** The status of the schedule. */ + status?: string; + /** + * the creation time of the schedule. + * @format date-time + */ + creation_time?: string; + /** + * the update time of the schedule. + * @format date-time + */ + update_time?: string; + schedule?: ScheduleObj; + /** The parameters of schedule job */ + parameters?: Record; +} + +export interface ScheduleObj { + /** + * The schedule type. The valid values are 'Hourly', 'Daily', 'Weekly', 'Custom', 'Manual', 'None' and 'Schedule'. + * 'Manual' means to trigger it right away, 'Schedule' means to trigger it by a specified cron schedule and + * 'None' means to cancel the schedule. + */ + type?: + | "Hourly" + | "Daily" + | "Weekly" + | "Custom" + | "Manual" + | "None" + | "Schedule"; + /** A cron expression, a time-based job scheduler. */ + cron?: string; + /** + * The next time to schedule to run the job. + * @format date-time + */ + next_scheduled_time?: string; +} + +/** Stats provides the overall progress of the scan all process. */ +export interface Stats { + /** + * The total number of scan processes triggered by the scan all action + * @format int + * @example 100 + */ + total: number; + /** + * The number of the finished scan processes triggered by the scan all action + * @format int + * @example 90 + */ + completed: number; + /** + * The metrics data for the each status + * @example {"Success":5,"Error":2,"Running":3} + */ + metrics?: Record; + /** A flag indicating job status of scan all. */ + ongoing: boolean; + /** The trigger of the scan all job. */ + trigger?: "Manual" | "Schedule" | "Event"; +} + +/** the tag retention metadata */ +export interface RetentionMetadata { + /** templates */ + templates?: RetentionRuleMetadata[]; + /** supported scope selectors */ + scope_selectors?: RetentionSelectorMetadata[]; + /** supported tag selectors */ + tag_selectors?: RetentionSelectorMetadata[]; +} + +/** the tag retention rule metadata */ +export interface RetentionRuleMetadata { + /** rule id */ + rule_template?: string; + /** rule display text */ + display_text?: string; + /** rule action */ + action?: string; + /** rule params */ + params?: RetentionRuleParamMetadata[]; +} + +/** rule param */ +export interface RetentionRuleParamMetadata { + type?: string; + unit?: string; + required?: boolean; +} + +/** retention selector */ +export interface RetentionSelectorMetadata { + display_text?: string; + kind?: string; + decorations?: string[]; +} + +/** retention policy */ +export interface RetentionPolicy { + /** @format int64 */ + id?: number; + algorithm?: string; + rules?: RetentionRule[]; + trigger?: RetentionRuleTrigger; + scope?: RetentionPolicyScope; +} + +export interface RetentionRuleTrigger { + kind?: string; + settings?: object; + references?: object; +} + +export interface RetentionPolicyScope { + level?: string; + ref?: number; +} + +export interface RetentionRule { + id?: number; + priority?: number; + disabled?: boolean; + action?: string; + template?: string; + params?: Record; + tag_selectors?: RetentionSelector[]; + scope_selectors?: Record; +} + +export interface RetentionSelector { + kind?: string; + decoration?: string; + pattern?: string; + extras?: string; +} + +export interface RetentionExecution { + /** @format int64 */ + id?: number; + /** @format int64 */ + policy_id?: number; + start_time?: string; + end_time?: string; + status?: string; + trigger?: string; + dry_run?: boolean; +} + +export interface RetentionExecutionTask { + /** @format int64 */ + id?: number; + /** @format int64 */ + execution_id?: number; + repository?: string; + job_id?: string; + status?: string; + status_code: number; + /** @format int64 */ + status_revision?: number; + start_time?: string; + end_time?: string; + total: number; + retained: number; +} + +export interface QuotaUpdateReq { + /** The new hard limits for the quota */ + hard?: ResourceList; +} + +export type QuotaRefObject = Record; + +/** The quota object */ +export interface Quota { + /** ID of the quota */ + id?: number; + /** The reference object of the quota */ + ref?: QuotaRefObject; + /** The hard limits of the quota */ + hard: ResourceList; + /** The used status of the quota */ + used: ResourceList; + /** + * the creation time of the quota + * @format date-time + */ + creation_time?: string; + /** + * the update time of the quota + * @format date-time + */ + update_time?: string; +} + +/** Registration represents a named configuration for invoking a scanner via its adapter. */ +export interface ScannerRegistration { + /** The unique identifier of this registration. */ + uuid?: string; + /** + * The name of this registration. + * @example "Trivy" + */ + name?: string; + /** + * An optional description of this registration. + * @example "A free-to-use tool that scans container images for package vulnerabilities. + * " + */ + description: string; + /** + * A base URL of the scanner adapter + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url?: string; + /** + * Indicate whether the registration is enabled or not + * @default false + */ + disabled: boolean; + /** + * Indicate if the registration is set as the system default one + * @default false + */ + is_default: boolean; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @default "" + * @example "Bearer" + */ + auth: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential: string; + /** + * Indicate if skip the certificate verification when sending HTTP requests + * @default false + */ + skip_certVerify: boolean; + /** + * Indicate whether use internal registry addr for the scanner to pull content or not + * @default false + */ + use_internal_addr: boolean; + /** + * The creation time of this registration + * @format date-time + */ + create_time?: string; + /** + * The update time of this registration + * @format date-time + */ + update_time?: string; + /** + * Optional property to describe the name of the scanner registration + * @example "Trivy" + */ + adapter?: string; + /** + * Optional property to describe the vendor of the scanner registration + * @example "CentOS" + */ + vendor?: string; + /** + * Optional property to describe the version of the scanner registration + * @example "1.0.1" + */ + version?: string; + /** + * Indicate the healthy of the registration + * @default "" + * @example "healthy" + */ + health?: string; + /** + * Indicates the capabilities of the scanner, e.g. support_vulnerability or support_sbom. + * @example {"support_vulnerability":true,"support_sbom":true} + */ + capabilities?: Record; +} + +export interface ScannerRegistrationReq { + /** + * The name of this registration + * @example "Trivy" + */ + name: string; + /** + * An optional description of this registration. + * @example "A free-to-use tool that scans container images for package vulnerabilities. + * " + */ + description?: string; + /** + * A base URL of the scanner adapter. + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url: string; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @example "Bearer" + */ + auth?: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential?: string; + /** + * Indicate if skip the certificate verification when sending HTTP requests + * @default false + */ + skip_certVerify?: boolean; + /** + * Indicate whether use internal registry addr for the scanner to pull content or not + * @default false + */ + use_internal_addr?: boolean; + /** + * Indicate whether the registration is enabled or not + * @default false + */ + disabled?: boolean; +} + +export interface ScannerRegistrationSettings { + /** + * The name of this registration + * @example "Trivy" + */ + name: string; + /** + * A base URL of the scanner adapter. + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url: string; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @default "" + */ + auth?: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential?: string; +} + +export interface IsDefault { + /** A flag indicating whether a scanner registration is default. */ + is_default?: boolean; +} + +export interface ScannerCapability { + /** + * Specify the type of scanner capability, like vulnerability or sbom + * @example "sbom" + */ + type: string; + consumes_mime_types?: string[]; + produces_mime_types?: string[]; +} + +/** The metadata info of the scanner adapter */ +export interface ScannerAdapterMetadata { + scanner?: Scanner; + capabilities?: ScannerCapability[]; + /** @example {"harbor.scanner-adapter/registry-authorization-type":"Bearer"} */ + properties?: Record; +} + +export interface ImmutableRule { + id?: number; + priority?: number; + disabled?: boolean; + action?: string; + template?: string; + params?: Record; + tag_selectors?: ImmutableSelector[]; + scope_selectors?: Record; +} + +export interface ImmutableSelector { + kind?: string; + decoration?: string; + pattern?: string; + extras?: string; +} + +/** The ldap configure properties */ +export interface LdapConf { + /** The url of ldap service. */ + ldap_url?: string; + /** The search dn of ldap service. */ + ldap_search_dn?: string; + /** The search password of ldap service. */ + ldap_search_password?: string; + /** The base dn of ldap service. */ + ldap_base_dn?: string; + /** The serach filter of ldap service. */ + ldap_filter?: string; + /** The serach uid from ldap service attributes. */ + ldap_uid?: string; + /** + * The serach scope of ldap service. + * @format int64 + */ + ldap_scope?: number; + /** + * The connect timeout of ldap service(second). + * @format int64 + */ + ldap_connection_timeout?: number; + /** Verify Ldap server certificate. */ + ldap_verify_cert?: boolean; +} + +/** The ldap ping result */ +export interface LdapPingResult { + /** Test success */ + success?: boolean; + /** The ping operation output message. */ + message?: string; +} + +export interface LdapImportUsers { + /** selected uid list */ + ldap_uid_list?: string[]; +} + +export interface LdapFailedImportUser { + /** the uid can't add to system. */ + uid?: string; + /** fail reason. */ + error?: string; +} + +export interface LdapUser { + /** ldap username. */ + username?: string; + /** The user realname from "uid" or "cn" attribute. */ + realname?: string; + /** The user email address from "mail" or "email" attribute. */ + email?: string; +} + +export interface UserGroup { + /** The ID of the user group */ + id?: number; + /** The name of the user group */ + group_name?: string; + /** The group type, 1 for LDAP group, 2 for HTTP group, 3 for OIDC group. */ + group_type?: number; + /** The DN of the LDAP group if group type is 1 (LDAP group). */ + ldap_group_dn?: string; +} + +export interface UserGroupSearchItem { + /** The ID of the user group */ + id?: number; + /** The name of the user group */ + group_name?: string; + /** The group type, 1 for LDAP group, 2 for HTTP group, 3 for OIDC group. */ + group_type?: number; +} + +/** Supported webhook event types and notify types. */ +export interface SupportedWebhookEventTypes { + event_type?: EventType[]; + notify_type?: NotifyType[]; + payload_formats?: PayloadFormat[]; +} + +/** + * Webhook supported event type. + * @example "PULL_ARTIFACT" + */ +export type EventType = string; + +/** + * Webhook supported notify type. + * @example "http" + */ +export type NotifyType = string; + +/** + * The type of webhook paylod format. + * @example "CloudEvents" + */ +export type PayloadFormatType = string; + +/** Webhook supported payload format type collections. */ +export interface PayloadFormat { + /** Webhook supported notify type. */ + notify_type?: NotifyType; + /** The supported payload formats for this notify type. */ + formats?: PayloadFormatType[]; +} + +/** The webhook policy target object. */ +export interface WebhookTargetObject { + /** The webhook target notify type. */ + type?: string; + /** The webhook target address. */ + address?: string; + /** The webhook auth header. */ + auth_header?: string; + /** Whether or not to skip cert verify. */ + skip_cert_verify?: boolean; + /** The payload format of webhook, by default is Default for http type. */ + payload_format?: PayloadFormatType; +} + +/** The webhook policy object */ +export interface WebhookPolicy { + /** + * The webhook policy ID. + * @format int64 + */ + id?: number; + /** The name of webhook policy. */ + name?: string; + /** The description of webhook policy. */ + description?: string; + /** The project ID of webhook policy. */ + project_id?: number; + targets?: WebhookTargetObject[]; + event_types?: string[]; + /** The creator of the webhook policy. */ + creator?: string; + /** + * The create time of the webhook policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the webhook policy. + * @format date-time + */ + update_time?: string; + /** Whether the webhook policy is enabled or not. */ + enabled: boolean; +} + +/** The webhook policy and last trigger time group by event type. */ +export interface WebhookLastTrigger { + /** The webhook policy name. */ + policy_name?: string; + /** The webhook event type. */ + event_type?: string; + /** Whether or not the webhook policy enabled. */ + enabled?: boolean; + /** + * The creation time of webhook policy. + * @format date-time + */ + creation_time?: string; + /** + * The last trigger time of webhook policy. + * @format date-time + */ + last_trigger_time?: string; +} + +/** The webhook job. */ +export interface WebhookJob { + /** + * The webhook job ID. + * @format int64 + */ + id?: number; + /** + * The webhook policy ID. + * @format int64 + */ + policy_id?: number; + /** The webhook job event type. */ + event_type?: string; + /** The webhook job notify type. */ + notify_type?: string; + /** The webhook job status. */ + status?: string; + /** The webhook job notify detailed data. */ + job_detail?: string; + /** + * The webhook job creation time. + * @format date-time + */ + creation_time?: string; + /** + * The webhook job update time. + * @format date-time + */ + update_time?: string; +} + +export type InternalConfigurationsResponse = Record< + string, + InternalConfigurationValue +>; + +export interface InternalConfigurationValue { + /** The value of current config item */ + value?: object; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface ConfigurationsResponse { + /** The auth mode of current system, such as "db_auth", "ldap_auth", "oidc_auth" */ + auth_mode?: StringConfigItem; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: BoolConfigItem; + /** The Base DN for LDAP binding. */ + ldap_base_dn?: StringConfigItem; + /** The filter for LDAP search */ + ldap_filter?: StringConfigItem; + /** The base DN to search LDAP group. */ + ldap_group_base_dn?: StringConfigItem; + /** Specify the ldap group which have the same privilege with Harbor admin */ + ldap_group_admin_dn?: StringConfigItem; + /** The attribute which is used as identity of the LDAP group, default is cn.' */ + ldap_group_attribute_name?: StringConfigItem; + /** The filter to search the ldap group */ + ldap_group_search_filter?: StringConfigItem; + /** The scope to search ldap group. ''0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE'' */ + ldap_group_search_scope?: IntegerConfigItem; + /** Attach LDAP user group information in parallel. */ + ldap_group_attach_parallel?: BoolConfigItem; + /** The scope to search ldap users,'0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE' */ + ldap_scope?: IntegerConfigItem; + /** The DN of the user to do the search. */ + ldap_search_dn?: StringConfigItem; + /** Timeout in seconds for connection to LDAP server */ + ldap_timeout?: IntegerConfigItem; + /** The attribute which is used as identity for the LDAP binding, such as "CN" or "SAMAccountname" */ + ldap_uid?: StringConfigItem; + /** The URL of LDAP server */ + ldap_url?: StringConfigItem; + /** Whether verify your OIDC server certificate, disable it if your OIDC server is hosted via self-hosted certificate. */ + ldap_verify_cert?: BoolConfigItem; + /** The user attribute to identify the group membership */ + ldap_group_membership_attribute?: StringConfigItem; + /** Indicate who can create projects, it could be ''adminonly'' or ''everyone''. */ + project_creation_restriction?: StringConfigItem; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: BoolConfigItem; + /** Whether the Harbor instance supports self-registration. If it''s set to false, admin need to add user to the instance. */ + self_registration?: BoolConfigItem; + /** The expiration time of the token for internal Registry, in minutes. */ + token_expiration?: IntegerConfigItem; + /** The client id of UAA */ + uaa_client_id?: StringConfigItem; + /** The client secret of the UAA */ + uaa_client_secret?: StringConfigItem; + /** The endpoint of the UAA */ + uaa_endpoint?: StringConfigItem; + /** Verify the certificate in UAA server */ + uaa_verify_cert?: BoolConfigItem; + /** The endpoint of the HTTP auth */ + http_authproxy_endpoint?: StringConfigItem; + /** The token review endpoint */ + http_authproxy_tokenreview_endpoint?: StringConfigItem; + /** The group which has the harbor admin privileges */ + http_authproxy_admin_groups?: StringConfigItem; + /** The usernames which has the harbor admin privileges */ + http_authproxy_admin_usernames?: StringConfigItem; + /** Verify the HTTP auth provider's certificate */ + http_authproxy_verify_cert?: BoolConfigItem; + /** Search user before onboard */ + http_authproxy_skip_search?: BoolConfigItem; + /** The certificate of the HTTP auth provider */ + http_authproxy_server_certificate?: StringConfigItem; + /** The OIDC provider name */ + oidc_name?: StringConfigItem; + /** The endpoint of the OIDC provider */ + oidc_endpoint?: StringConfigItem; + /** The client ID of the OIDC provider */ + oidc_client_id?: StringConfigItem; + /** The attribute claims the group name */ + oidc_groups_claim?: StringConfigItem; + /** The OIDC group which has the harbor admin privileges */ + oidc_admin_group?: StringConfigItem; + /** The OIDC group filter which filters out the group doesn't match the regular expression */ + oidc_group_filter?: StringConfigItem; + /** The scope of the OIDC provider */ + oidc_scope?: StringConfigItem; + /** The attribute claims the username */ + oidc_user_claim?: StringConfigItem; + /** Verify the OIDC provider's certificate' */ + oidc_verify_cert?: BoolConfigItem; + /** Auto onboard the OIDC user */ + oidc_auto_onboard?: BoolConfigItem; + /** Extra parameters to add when redirect request to OIDC provider */ + oidc_extra_redirect_parms?: StringConfigItem; + /** Extra parameters to logout user session from the OIDC provider */ + oidc_logout?: BoolConfigItem; + /** The robot account token duration in days */ + robot_token_duration?: IntegerConfigItem; + /** The rebot account name prefix */ + robot_name_prefix?: StringConfigItem; + /** Enable notification */ + notification_enable?: BoolConfigItem; + /** Enable quota per project */ + quota_per_project_enable?: BoolConfigItem; + /** The storage quota per project */ + storage_per_project?: IntegerConfigItem; + /** The endpoint of the audit log forwarder */ + audit_log_forward_endpoint?: StringConfigItem; + /** Whether skip the audit log in database */ + skip_audit_log_database?: BoolConfigItem; + /** Whether or not to skip update the pull time for scanner */ + scanner_skip_update_pulltime?: BoolConfigItem; + scan_all_policy?: { + /** The type of scan all policy, currently the valid values are "none" and "daily" */ + type?: string; + /** The parameters of the policy, the values are dependent on the type of the policy. */ + parameter?: { + /** The offset in seconds of UTC 0 o'clock, only valid when the policy type is "daily" */ + daily_time?: number; + }; + }; + /** The session timeout in minutes */ + session_timeout?: IntegerConfigItem; + /** The banner message for the UI.It is the stringified result of the banner message object */ + banner_message?: StringConfigItem; + /** The audit log event types to skip to log in database */ + disabled_audit_log_event_types?: StringConfigItem; +} + +export interface Configurations { + /** The auth mode of current system, such as "db_auth", "ldap_auth", "oidc_auth" */ + auth_mode?: string; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: boolean | null; + /** The Base DN for LDAP binding. */ + ldap_base_dn?: string; + /** The filter for LDAP search */ + ldap_filter?: string; + /** The base DN to search LDAP group. */ + ldap_group_base_dn?: string; + /** Specify the ldap group which have the same privilege with Harbor admin */ + ldap_group_admin_dn?: string; + /** The attribute which is used as identity of the LDAP group, default is cn.' */ + ldap_group_attribute_name?: string; + /** The filter to search the ldap group */ + ldap_group_search_filter?: string; + /** The scope to search ldap group. ''0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE'' */ + ldap_group_search_scope?: number; + /** Attach LDAP user group information in parallel, the parallel worker count is 5 */ + ldap_group_attach_parallel?: boolean; + /** The scope to search ldap users,'0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE' */ + ldap_scope?: number; + /** The DN of the user to do the search. */ + ldap_search_dn?: string; + /** The password of the ldap search dn */ + ldap_search_password?: string; + /** Timeout in seconds for connection to LDAP server */ + ldap_timeout?: number; + /** The attribute which is used as identity for the LDAP binding, such as "CN" or "SAMAccountname" */ + ldap_uid?: string; + /** The URL of LDAP server */ + ldap_url?: string; + /** Whether verify your OIDC server certificate, disable it if your OIDC server is hosted via self-hosted certificate. */ + ldap_verify_cert?: boolean; + /** The user attribute to identify the group membership */ + ldap_group_membership_attribute?: string; + /** Indicate who can create projects, it could be ''adminonly'' or ''everyone''. */ + project_creation_restriction?: string; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: boolean; + /** Whether the Harbor instance supports self-registration. If it''s set to false, admin need to add user to the instance. */ + self_registration?: boolean; + /** The expiration time of the token for internal Registry, in minutes. */ + token_expiration?: number; + /** The client id of UAA */ + uaa_client_id?: string; + /** The client secret of the UAA */ + uaa_client_secret?: string; + /** The endpoint of the UAA */ + uaa_endpoint?: string; + /** Verify the certificate in UAA server */ + uaa_verify_cert?: boolean; + /** The endpoint of the HTTP auth */ + http_authproxy_endpoint?: string; + /** The token review endpoint */ + http_authproxy_tokenreview_endpoint?: string; + /** The group which has the harbor admin privileges */ + http_authproxy_admin_groups?: string; + /** The username which has the harbor admin privileges */ + http_authproxy_admin_usernames?: string; + /** Verify the HTTP auth provider's certificate */ + http_authproxy_verify_cert?: boolean; + /** Search user before onboard */ + http_authproxy_skip_search?: boolean; + /** The certificate of the HTTP auth provider */ + http_authproxy_server_certificate?: string; + /** The OIDC provider name */ + oidc_name?: string; + /** The endpoint of the OIDC provider */ + oidc_endpoint?: string; + /** The client ID of the OIDC provider */ + oidc_client_id?: string; + /** The OIDC provider secret */ + oidc_client_secret?: string; + /** The attribute claims the group name */ + oidc_groups_claim?: string; + /** The OIDC group which has the harbor admin privileges */ + oidc_admin_group?: string; + /** The OIDC group filter which filters out the group name doesn't match the regular expression */ + oidc_group_filter?: string; + /** The scope of the OIDC provider */ + oidc_scope?: string; + /** The attribute claims the username */ + oidc_user_claim?: string; + /** Verify the OIDC provider's certificate' */ + oidc_verify_cert?: boolean; + /** Auto onboard the OIDC user */ + oidc_auto_onboard?: boolean; + /** Extra parameters to add when redirect request to OIDC provider */ + oidc_extra_redirect_parms?: string; + /** Logout OIDC user session */ + oidc_logout?: boolean; + /** The robot account token duration in days */ + robot_token_duration?: number; + /** The rebot account name prefix */ + robot_name_prefix?: string; + /** Enable notification */ + notification_enable?: boolean; + /** Enable quota per project */ + quota_per_project_enable?: boolean; + /** The storage quota per project */ + storage_per_project?: number; + /** The audit log forward endpoint */ + audit_log_forward_endpoint?: string; + /** Skip audit log database */ + skip_audit_log_database?: boolean; + /** The session timeout for harbor, in minutes. */ + session_timeout?: number; + /** Whether or not to skip update pull time for scanner */ + scanner_skip_update_pulltime?: boolean; + /** The banner message for the UI.It is the stringified result of the banner message object */ + banner_message?: string; + /** the list to disable log audit event types. */ + disabled_audit_log_event_types?: string; +} + +export interface StringConfigItem { + /** The string value of current config item */ + value: string; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface BoolConfigItem { + /** The boolean value of current config item */ + value: boolean; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface IntegerConfigItem { + /** The integer value of current config item */ + value: number; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface ProjectMemberEntity { + /** the project member id */ + id?: number; + /** the project id */ + project_id?: number; + /** the name of the group member. */ + entity_name?: string; + /** the name of the role */ + role_name?: string; + /** the role id */ + role_id?: number; + /** the id of entity, if the member is a user, it is user_id in user table. if the member is a user group, it is the user group's ID in user_group table. */ + entity_id?: number; + /** the entity's type, u for user entity, g for group entity. */ + entity_type?: string; +} + +export interface ProjectMember { + /** The role id 1 for projectAdmin, 2 for developer, 3 for guest, 4 for maintainer */ + role_id?: number; + member_user?: UserEntity; + member_group?: UserGroup; +} + +export interface RoleRequest { + /** The role id 1 for projectAdmin, 2 for developer, 3 for guest, 4 for maintainer */ + role_id?: number; +} + +export interface UserEntity { + /** The ID of the user. */ + user_id?: number; + /** The name of the user. */ + username?: string; +} + +export interface UserProfile { + email?: string; + realname?: string; + comment?: string; +} + +export interface UserCreationReq { + /** @maxLength 255 */ + email?: string; + realname?: string; + comment?: string; + password?: string; + /** @maxLength 255 */ + username?: string; +} + +export interface OIDCUserInfo { + /** + * the ID of the OIDC info record + * @format int + */ + id?: number; + /** + * the ID of the user + * @format int + */ + user_id?: number; + /** the concatenation of sub and issuer in the ID token */ + subiss?: string; + /** the secret of the OIDC user that can be used for CLI to push/pull artifacts */ + secret?: string; + /** + * The creation time of the OIDC user info record. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the OIDC user info record. + * @format date-time + */ + update_time?: string; +} + +export interface UserResp { + email?: string; + realname?: string; + comment?: string; + /** @format int */ + user_id?: number; + username?: string; + sysadmin_flag: boolean; + /** indicate the admin privilege is grant by authenticator (LDAP), is always false unless it is the current login user */ + admin_role_in_auth: boolean; + oidc_user_meta?: OIDCUserInfo; + /** + * The creation time of the user. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the user. + * @format date-time + */ + update_time?: string; +} + +export interface UserSysAdminFlag { + /** true-admin, false-not admin. */ + sysadmin_flag?: boolean; +} + +export interface UserSearch { + /** + * The ID of the user. + * @format int + */ + user_id?: number; + username?: string; +} + +export interface PasswordReq { + /** The user's existing password. */ + old_password?: string; + /** New password for marking as to be updated. */ + new_password?: string; +} + +export interface UserSearchRespItem { + /** + * The ID of the user. + * @format int + */ + user_id?: number; + username?: string; +} + +export interface Permission { + /** The permission resoruce */ + resource?: string; + /** The permission action */ + action?: string; +} + +export interface Permissions { + /** The system level permissions */ + system?: Permission[]; + /** The project level permissions */ + project?: Permission[]; +} + +export interface OIDCCliSecretReq { + /** The new secret */ + secret?: string; +} + +/** The system health status */ +export interface OverallHealthStatus { + /** The overall health status. It is "healthy" only when all the components' status are "healthy" */ + status?: string; + components?: ComponentHealthStatus[]; +} + +/** The health status of component */ +export interface ComponentHealthStatus { + /** The component name */ + name?: string; + /** The health status of component. Is either "healthy" or "unhealthy". */ + status?: string; + /** (optional) The error message when the status is "unhealthy" */ + error?: string; +} + +export interface Statistic { + /** + * The count of the private projects + * @format int64 + */ + private_project_count: number; + /** + * The count of the private repositories + * @format int64 + */ + private_repo_count: number; + /** + * The count of the public projects + * @format int64 + */ + public_project_count: number; + /** + * The count of the public repositories + * @format int64 + */ + public_repo_count: number; + /** + * The count of the total projects, only be seen by the system admin + * @format int64 + */ + total_project_count: number; + /** + * The count of the total repositories, only be seen by the system admin + * @format int64 + */ + total_repo_count: number; + /** + * The total storage consumption of blobs, only be seen by the system admin + * @format int64 + */ + total_storage_consumption: number; +} + +/** The accessory of the artifact */ +export interface Accessory { + /** + * The ID of the accessory + * @format int64 + */ + id?: number; + /** + * The artifact id of the accessory + * @format int64 + */ + artifact_id: number; + /** + * Going to be deprecated, use repo and digest for insteand. The subject artifact id of the accessory. + * @format int64 + */ + subject_artifact_id?: number; + /** The subject artifact digest of the accessory */ + subject_artifact_digest: string; + /** The subject artifact repository name of the accessory */ + subject_artifact_repo: string; + /** + * The artifact size of the accessory + * @format int64 + */ + size: number; + /** The artifact digest of the accessory */ + digest: string; + /** The artifact size of the accessory */ + type: string; + /** The icon of the accessory */ + icon: string; + /** + * The creation time of the accessory + * @format date-time + */ + creation_time?: string; +} + +/** The criteria to select the scan data to export. */ +export interface ScanDataExportRequest { + /** Name of the scan data export job */ + job_name?: string; + /** A list of one or more projects for which to export the scan data, currently only one project is supported due to performance concerns, but define as array for extension in the future. */ + projects?: number[]; + /** A list of one or more labels for which to export the scan data, defaults to all if empty */ + labels?: number[]; + /** A list of repositories for which to export the scan data, defaults to all if empty */ + repositories?: string; + /** CVE-IDs for which to export data. Multiple CVE-IDs can be specified by separating using ',' and enclosed between '{}'. Defaults to all if empty */ + cveIds?: string; + /** A list of tags enclosed within '{}'. Defaults to all if empty */ + tags?: string; +} + +/** The metadata associated with the scan data export job */ +export interface ScanDataExportJob { + /** + * The id of the scan data export job + * @format int64 + */ + id?: number; +} + +/** The replication execution */ +export interface ScanDataExportExecution { + /** The ID of the execution */ + id?: number; + /** The ID if the user triggering the export job */ + user_id?: number; + /** The status of the execution */ + status?: string; + /** The trigger mode */ + trigger?: string; + /** + * The start time + * @format date-time + */ + start_time?: string; + /** + * The end time + * @format date-time + */ + end_time?: string; + /** The status text */ + status_text: string; + /** The name of the user triggering the job */ + user_name: string; + /** Indicates whether the export artifact is present in registry */ + file_present: boolean; +} + +/** The list of scan data export executions */ +export interface ScanDataExportExecutionList { + /** The list of scan data export executions */ + items?: ScanDataExportExecution[]; +} + +/** the worker pool of job service */ +export interface WorkerPool { + /** the process id of jobservice */ + pid?: number; + /** the id of the worker pool */ + worker_pool_id?: string; + /** + * The start time of the work pool + * @format date-time + */ + start_at?: string; + /** + * The heartbeat time of the work pool + * @format date-time + */ + heartbeat_at?: string; + /** The concurrency of the work pool */ + concurrency?: number; + /** The host of the work pool */ + host?: string; +} + +/** worker in the pool */ +export interface Worker { + /** the id of the worker */ + id?: string; + /** the id of the worker pool */ + pool_id?: string; + /** the name of the running job in the worker */ + job_name?: string; + /** the id of the running job in the worker */ + job_id?: string; + /** + * The start time of the worker + * @format date-time + */ + start_at?: string | null; + /** the checkin of the running job in the worker */ + check_in?: string; + /** + * The checkin time of the worker + * @format date-time + */ + checkin_at?: string | null; +} + +/** The request to stop, pause or resume */ +export interface ActionRequest { + /** The action of the request, should be stop, pause or resume */ + action?: "stop" | "pause" | "resume"; +} + +/** the job queue info */ +export interface JobQueue { + /** The type of the job queue */ + job_type?: string; + /** The count of jobs in the job queue */ + count?: number; + /** The latency the job queue (seconds) */ + latency?: number; + /** The paused status of the job queue */ + paused: boolean; +} + +/** the schedule task info */ +export interface ScheduleTask { + /** the id of the Schedule task */ + id?: number; + /** the vendor type of the current schedule task */ + vendor_type?: string; + /** the vendor id of the current task */ + vendor_id?: number; + /** the cron of the current schedule task */ + cron?: string; + /** + * the update time of the schedule task + * @format date-time + */ + update_time?: string; +} + +/** the scheduler status */ +export interface SchedulerStatus { + /** if the scheduler is paused */ + paused: boolean; +} + +/** the security summary */ +export interface SecuritySummary { + /** + * the count of critical vulnerabilities + * @format int64 + */ + critical_cnt: number; + /** + * the count of high vulnerabilities + * @format int64 + */ + high_cnt?: number; + /** + * the count of medium vulnerabilities + * @format int64 + */ + medium_cnt: number; + /** + * the count of low vulnerabilities + * @format int64 + */ + low_cnt: number; + /** + * the count of none vulnerabilities + * @format int64 + */ + none_cnt?: number; + /** + * the count of unknown vulnerabilities + * @format int64 + */ + unknown_cnt?: number; + /** + * the count of total vulnerabilities + * @format int64 + */ + total_vuls: number; + /** + * the count of scanned artifacts + * @format int64 + */ + scanned_cnt: number; + /** + * the total count of artifacts + * @format int64 + */ + total_artifact: number; + /** + * the count of fixable vulnerabilities + * @format int64 + */ + fixable_cnt: number; + /** the list of dangerous CVEs */ + dangerous_cves?: DangerousCVE[]; + /** the list of dangerous artifacts */ + dangerous_artifacts?: DangerousArtifact[]; +} + +/** the dangerous CVE information */ +export interface DangerousCVE { + /** the cve id */ + cve_id?: string; + /** the severity of the CVE */ + severity?: string; + /** + * the cvss score v3 + * @format float64 + */ + cvss_score_v3?: number; + /** the description of the CVE */ + desc?: string; + /** the package of the CVE */ + package?: string; + /** the version of the package */ + version?: string; +} + +/** the dangerous artifact information */ +export interface DangerousArtifact { + /** + * the project id of the artifact + * @format int64 + */ + project_id?: number; + /** the repository name of the artifact */ + repository_name?: string; + /** the digest of the artifact */ + digest?: string; + /** the count of critical vulnerabilities */ + critical_cnt: number; + /** + * the count of high vulnerabilities + * @format int64 + */ + high_cnt: number; + /** the count of medium vulnerabilities */ + medium_cnt: number; +} + +/** the vulnerability item info */ +export interface VulnerabilityItem { + /** + * the project ID of the artifact + * @format int64 + */ + project_id?: number; + /** the repository name of the artifact */ + repository_name?: string; + /** the digest of the artifact */ + digest?: string; + /** the tags of the artifact */ + tags?: string[]; + /** the CVE id of the vulnerability. */ + cve_id?: string; + /** the severity of the vulnerability */ + severity?: string; + /** + * the nvd cvss v3 score of the vulnerability + * @format float + */ + cvss_v3_score?: number; + /** the package of the vulnerability */ + package?: string; + /** the version of the package */ + version?: string; + /** the fixed version of the package */ + fixed_version?: string; + /** The description of the vulnerability */ + desc?: string; + /** Links of the vulnerability */ + links?: string[]; +} + +export interface ScanType { + /** The scan type for the scan request. Two options are currently supported, vulnerability and sbom */ + scan_type?: "vulnerability" | "sbom"; +} + +import type { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + HeadersDefaults, + ResponseType, +} from "axios"; +import axios from "axios"; + +export type QueryParamsType = Record; + +export interface FullRequestParams + extends Omit { + /** set parameter to `true` for call `securityWorker` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseType; + /** request body */ + body?: unknown; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig + extends Omit { + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | AxiosRequestConfig | void; + secure?: boolean; + format?: ResponseType; +} + +export enum ContentType { + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public instance: AxiosInstance; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private secure?: boolean; + private format?: ResponseType; + + constructor({ + securityWorker, + secure, + format, + ...axiosConfig + }: ApiConfig = {}) { + this.instance = axios.create({ + ...axiosConfig, + baseURL: axiosConfig.baseURL || "http://localhost/api/v2.0", + }); + this.secure = secure; + this.format = format; + this.securityWorker = securityWorker; + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected mergeRequestParams( + params1: AxiosRequestConfig, + params2?: AxiosRequestConfig, + ): AxiosRequestConfig { + const method = params1.method || (params2 && params2.method); + + return { + ...this.instance.defaults, + ...params1, + ...(params2 || {}), + headers: { + ...((method && + this.instance.defaults.headers[ + method.toLowerCase() as keyof HeadersDefaults + ]) || + {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected stringifyFormItem(formItem: unknown) { + if (typeof formItem === "object" && formItem !== null) { + return JSON.stringify(formItem); + } else { + return `${formItem}`; + } + } + + protected createFormData(input: Record): FormData { + if (input instanceof FormData) { + return input; + } + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + const isFileType = formItem instanceof Blob || formItem instanceof File; + formData.append( + key, + isFileType ? formItem : this.stringifyFormItem(formItem), + ); + } + + return formData; + }, new FormData()); + } + + public request = async ({ + secure, + path, + type, + query, + format, + body, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const responseFormat = format || this.format || undefined; + + if ( + type === ContentType.FormData && + body && + body !== null && + typeof body === "object" + ) { + body = this.createFormData(body as Record); + } + + if ( + type === ContentType.Text && + body && + body !== null && + typeof body !== "string" + ) { + body = JSON.stringify(body); + } + + return this.instance.request({ + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type ? { "Content-Type": type } : {}), + }, + params: query, + responseType: responseFormat, + data: body, + url: path, + }); + }; +} + +/** + * @title Harbor API + * @version 2.0 + * @baseUrl http://localhost/api/v2.0 + * + * These APIs provide services for manipulating Harbor project. + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + health = { + /** + * @description Check the status of Harbor components. This path does not require authentication. + * + * @tags health + * @name GetHealth + * @summary Check the status of Harbor components + * @request GET:/health + * @secure + */ + getHealth: (params: RequestParams = {}) => + this.request({ + path: `/health`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + search = { + /** + * @description The Search endpoint returns information about the projects and repositories offered at public status or related to the current logged in user. The response includes the project and repository list in a proper display order. + * + * @tags search + * @name Search + * @summary Search for projects and repositories + * @request GET:/search + * @secure + */ + search: ( + query: { + /** Search parameter for project and repository name. */ + q: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + statistics = { + /** + * @description Get the statistic information about the projects and repositories + * + * @tags statistic + * @name GetStatistic + * @summary Get the statistic information about the projects and repositories + * @request GET:/statistics + * @secure + */ + getStatistic: (params: RequestParams = {}) => + this.request({ + path: `/statistics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + ldap = { + /** + * @description This endpoint ping the available ldap service for test related configuration parameters. + * + * @tags Ldap + * @name PingLdap + * @summary Ping available ldap service. + * @request POST:/ldap/ping + * @secure + */ + pingLdap: (ldapconf: LdapConf, params: RequestParams = {}) => + this.request({ + path: `/ldap/ping`, + method: "POST", + body: ldapconf, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint searches the available ldap users based on related configuration parameters. Support searched by input ldap configuration, load configuration from the system and specific filter. + * + * @tags Ldap + * @name SearchLdapUser + * @summary Search available ldap users. + * @request GET:/ldap/users/search + * @secure + */ + searchLdapUser: ( + query?: { + /** Registered user ID */ + username?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/ldap/users/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint adds the selected available ldap users to harbor based on related configuration parameters from the system. System will try to guess the user email address and realname, add to harbor user information. If have errors when import user, will return the list of importing failed uid and the failed reason. + * + * @tags Ldap + * @name ImportLdapUser + * @summary Import selected available ldap users. + * @request POST:/ldap/users/import + * @secure + */ + importLdapUser: (uid_list: LdapImportUsers, params: RequestParams = {}) => + this.request({ + path: `/ldap/users/import`, + method: "POST", + body: uid_list, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint searches the available ldap groups based on related configuration parameters. support to search by groupname or groupdn. + * + * @tags Ldap + * @name SearchLdapGroup + * @summary Search available ldap groups. + * @request GET:/ldap/groups/search + * @secure + */ + searchLdapGroup: ( + query?: { + /** Ldap group name */ + groupname?: string; + /** The LDAP group DN */ + groupdn?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/ldap/groups/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + internalconfig = { + /** + * @description This endpoint is for retrieving system configurations that only provides for internal api call. + * + * @tags configure + * @name GetInternalconfig + * @summary Get internal configurations. + * @request GET:/internalconfig + * @secure + */ + getInternalconfig: (params: RequestParams = {}) => + this.request({ + path: `/internalconfig`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + configurations = { + /** + * @description This endpoint is for retrieving system configurations that only provides for admin user. + * + * @tags configure + * @name GetConfigurations + * @summary Get system configurations. + * @request GET:/configurations + * @secure + */ + getConfigurations: (params: RequestParams = {}) => + this.request({ + path: `/configurations`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for modifying system configurations that only provides for admin user. + * + * @tags configure + * @name UpdateConfigurations + * @summary Modify system configurations. + * @request PUT:/configurations + * @secure + */ + updateConfigurations: ( + configurations: Configurations, + params: RequestParams = {}, + ) => + this.request({ + path: `/configurations`, + method: "PUT", + body: configurations, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + projects = { + /** + * @description This endpoint returns projects created by Harbor. + * + * @tags project + * @name ListProjects + * @summary List projects + * @request GET:/projects + * @secure + */ + listProjects: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** The name of project. */ + name?: string; + /** The project is public or private. */ + public?: boolean; + /** The name of project owner. */ + owner?: string; + /** + * Bool value indicating whether return detailed information of the project + * @default true + */ + with_detail?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is used to check if the project name provided already exist. + * + * @tags project + * @name HeadProject + * @summary Check if the project name user provided already exists. + * @request HEAD:/projects + * @secure + */ + headProject: ( + query: { + /** Project name for checking exists. */ + project_name: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects`, + method: "HEAD", + query: query, + secure: true, + ...params, + }), + + /** + * @description This endpoint is for user to create a new project. + * + * @tags project + * @name CreateProject + * @summary Create a new project. + * @request POST:/projects + * @secure + */ + createProject: (project: ProjectReq, params: RequestParams = {}) => + this.request({ + path: `/projects`, + method: "POST", + body: project, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint returns specific project information by project ID. + * + * @tags project + * @name GetProject + * @summary Return specific project detail information + * @request GET:/projects/{project_name_or_id} + * @secure + */ + getProject: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is aimed to update the properties of a project. + * + * @tags project + * @name UpdateProject + * @summary Update properties for a selected project. + * @request PUT:/projects/{project_name_or_id} + * @secure + */ + updateProject: ( + projectNameOrId: string, + project: ProjectReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "PUT", + body: project, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is aimed to delete project by project ID. + * + * @tags project + * @name DeleteProject + * @summary Delete project by projectID + * @request DELETE:/projects/{project_name_or_id} + * @secure + */ + deleteProject: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Get the deletable status of the project + * + * @tags project + * @name GetProjectDeletable + * @summary Get the deletable status of the project + * @request GET:/projects/{project_name_or_id}/_deletable + * @secure + */ + getProjectDeletable: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/_deletable`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get summary of the project. + * + * @tags project + * @name GetProjectSummary + * @summary Get summary of the project. + * @request GET:/projects/{project_name_or_id}/summary + * @secure + */ + getProjectSummary: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}/summary`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get all project member information + * + * @tags member + * @name ListProjectMembers + * @summary Get all project member information + * @request GET:/projects/{project_name_or_id}/members + * @secure + */ + listProjectMembers: ( + projectNameOrId: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The entity name to search. */ + entityname?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create project member relationship, the member can be one of the user_member and group_member, The user_member need to specify user_id or username. If the user already exist in harbor DB, specify the user_id, If does not exist in harbor DB, it will SearchAndOnBoard the user. The group_member need to specify id or ldap_group_dn. If the group already exist in harbor DB. specify the user group's id, If does not exist, it will SearchAndOnBoard the group. + * + * @tags member + * @name CreateProjectMember + * @summary Create project member + * @request POST:/projects/{project_name_or_id}/members + * @secure + */ + createProjectMember: ( + projectNameOrId: string, + project_member: ProjectMember, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members`, + method: "POST", + body: project_member, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the project member information + * + * @tags member + * @name GetProjectMember + * @summary Get the project member information + * @request GET:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + getProjectMember: ( + projectNameOrId: string, + mid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update project member relationship + * + * @tags member + * @name UpdateProjectMember + * @summary Update project member + * @request PUT:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + updateProjectMember: ( + projectNameOrId: string, + mid: number, + role: RoleRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "PUT", + body: role, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags member + * @name DeleteProjectMember + * @summary Delete project member + * @request DELETE:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + deleteProjectMember: ( + projectNameOrId: string, + mid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Get the metadata of the specific project + * + * @tags projectMetadata + * @name ListProjectMetadatas + * @summary Get the metadata of the specific project + * @request GET:/projects/{project_name_or_id}/metadatas/ + * @secure + */ + listProjectMetadatas: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Add metadata for the specific project + * + * @tags projectMetadata + * @name AddProjectMetadatas + * @summary Add metadata for the specific project + * @request POST:/projects/{project_name_or_id}/metadatas/ + * @secure + */ + addProjectMetadatas: ( + projectNameOrId: string, + metadata: Annotations, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/`, + method: "POST", + body: metadata, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific metadata of the specific project + * + * @tags projectMetadata + * @name GetProjectMetadata + * @summary Get the specific metadata of the specific project + * @request GET:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + getProjectMetadata: ( + projectNameOrId: string, + metaName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update the specific metadata for the specific project + * + * @tags projectMetadata + * @name UpdateProjectMetadata + * @summary Update the specific metadata for the specific project + * @request PUT:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + updateProjectMetadata: ( + projectNameOrId: string, + metaName: string, + metadata: Annotations, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "PUT", + body: metadata, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the specific metadata for the specific project + * + * @tags projectMetadata + * @name DeleteProjectMetadata + * @summary Delete the specific metadata for the specific project + * @request DELETE:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + deleteProjectMetadata: ( + projectNameOrId: string, + metaName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List repositories of the specified project + * + * @tags repository + * @name ListRepositories + * @summary List repositories + * @request GET:/projects/{project_name}/repositories + * @secure + */ + listRepositories: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the repository specified by name + * + * @tags repository + * @name GetRepository + * @summary Get repository + * @request GET:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + getRepository: ( + projectName: string, + repositoryName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update the repository specified by name + * + * @tags repository + * @name UpdateRepository + * @summary Update repository + * @request PUT:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + updateRepository: ( + projectName: string, + repositoryName: string, + repository: Repository, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "PUT", + body: repository, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the repository specified by name + * + * @tags repository + * @name DeleteRepository + * @summary Delete repository + * @request DELETE:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + deleteRepository: ( + projectName: string, + repositoryName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List artifacts under the specific project and repository. Except the basic properties, the other supported queries in "q" includes "tags=*" to list only tagged artifacts, "tags=nil" to list only untagged artifacts, "tags=~v" to list artifacts whose tag fuzzy matches "v", "tags=v" to list artifact whose tag exactly matches "v", "labels=(id1, id2)" to list artifacts that both labels with id1 and id2 are added to + * + * @tags artifact + * @name ListArtifacts + * @summary List artifacts + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts + * @secure + */ + listArtifacts: ( + projectName: string, + repositoryName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are included inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are included inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is included inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifacts, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the immutable status is included inside the tags of the returning artifacts. Only works when setting "with_immutable_status=true" + * @default false + */ + with_immutable_status?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. Only works when setting "with_accessory=true" + * @default false + */ + with_accessory?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Copy the artifact specified in the "from" parameter to the repository. + * + * @tags artifact + * @name CopyArtifact + * @summary Copy artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts + * @secure + */ + copyArtifact: ( + projectName: string, + repositoryName: string, + query: { + /** The artifact from which the new artifact is copied from, the format should be "project/repository:tag" or "project/repository@digest". */ + from: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts`, + method: "POST", + query: query, + secure: true, + ...params, + }), + + /** + * @description Get the artifact specified by the reference under the project and repository. The reference can be digest or tag. + * + * @tags artifact + * @name GetArtifact + * @summary Get the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference} + * @secure + */ + getArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are inclued inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are inclued inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is inclued inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifact, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. + * @default false + */ + with_accessory?: boolean; + /** + * Specify whether the signature is inclued inside the returning artifacts + * @default false + */ + with_signature?: boolean; + /** + * Specify whether the immutable status is inclued inside the tags of the returning artifacts. + * @default false + */ + with_immutable_status?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the artifact specified by the reference under the project and repository. The reference can be digest or tag + * + * @tags artifact + * @name DeleteArtifact + * @summary Delete the specific artifact + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference} + * @secure + */ + deleteArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Scan the specified artifact + * + * @tags scan + * @name ScanArtifact + * @summary Scan the artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan + * @secure + */ + scanArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + scanType: ScanType, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan`, + method: "POST", + body: scanType, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Cancelling a scan job for a particular artifact + * + * @tags scan + * @name StopScanArtifact + * @summary Cancelling a scan job for a particular artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan/stop + * @secure + */ + stopScanArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + scanType: ScanType, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan/stop`, + method: "POST", + body: scanType, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the log of the scan report + * + * @tags scan + * @name GetReportLog + * @summary Get the log of the scan report + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan/{report_id}/log + * @secure + */ + getReportLog: ( + projectName: string, + repositoryName: string, + reference: string, + reportId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan/${reportId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description Create a tag for the specified artifact + * + * @tags artifact + * @name CreateTag + * @summary Create tag + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags + * @secure + */ + createTag: ( + projectName: string, + repositoryName: string, + reference: string, + tag: Tag, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags`, + method: "POST", + body: tag, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List tags of the specific artifact + * + * @tags artifact + * @name ListTags + * @summary List tags + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags + * @secure + */ + listTags: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the immutable status is included inside the returning tags + * @default false + */ + with_immutable_status?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the tag of the specified artifact + * + * @tags artifact + * @name DeleteTag + * @summary Delete tag + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags/{tag_name} + * @secure + */ + deleteTag: ( + projectName: string, + repositoryName: string, + reference: string, + tagName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags/${tagName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List accessories of the specific artifact + * + * @tags artifact + * @name ListAccessories + * @summary List accessories + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/accessories + * @secure + */ + listAccessories: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/accessories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the vulnerabilities addition of the artifact specified by the reference under the project and repository. + * + * @tags artifact + * @name GetVulnerabilitiesAddition + * @summary Get the vulnerabilities addition of the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/additions/vulnerabilities + * @secure + */ + getVulnerabilitiesAddition: ( + projectName: string, + repositoryName: string, + reference: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/additions/vulnerabilities`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the addition of the artifact specified by the reference under the project and repository. + * + * @tags artifact + * @name GetAddition + * @summary Get the addition of the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/additions/{addition} + * @secure + */ + getAddition: ( + projectName: string, + repositoryName: string, + reference: string, + addition: + | "build_history" + | "values.yaml" + | "readme.md" + | "dependencies" + | "sbom" + | "license" + | "files", + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/additions/${addition}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Add label to the specified artiact. + * + * @tags artifact + * @name AddLabel + * @summary Add label to artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/labels + * @secure + */ + addLabel: ( + projectName: string, + repositoryName: string, + reference: string, + label: Label, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/labels`, + method: "POST", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Remove the label from the specified artiact. + * + * @tags artifact + * @name RemoveLabel + * @summary Remove label from artifact + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/labels/{label_id} + * @secure + */ + removeLabel: ( + projectName: string, + repositoryName: string, + reference: string, + labelId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/labels/${labelId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List artifacts of the specified project + * + * @tags project + * @name ListArtifactsOfProject + * @summary List artifacts + * @request GET:/projects/{project_name_or_id}/artifacts + * @secure + */ + listArtifactsOfProject: ( + projectNameOrId: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are included inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are included inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is included inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifacts, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the immutable status is included inside the tags of the returning artifacts. Only works when setting "with_immutable_status=true" + * @default false + */ + with_immutable_status?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. Only works when setting "with_accessory=true" + * @default false + */ + with_accessory?: boolean; + /** + * Specify whether only the latest pushed artifact of each repository is included inside the returning artifacts. Only works when either artifact_type or media_type is included in the query. + * @default false + */ + latest_in_repository?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/artifacts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the scanner registration of the specified project. If no scanner registration is configured for the specified project, the system default scanner registration will be returned. + * + * @tags project + * @name GetScannerOfProject + * @summary Get project level scanner + * @request GET:/projects/{project_name_or_id}/scanner + * @secure + */ + getScannerOfProject: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Set one of the system configured scanner registration as the indepndent scanner of the specified project. + * + * @tags project + * @name SetScannerOfProject + * @summary Configure scanner for the specified project + * @request PUT:/projects/{project_name_or_id}/scanner + * @secure + */ + setScannerOfProject: ( + projectNameOrId: string, + payload: ProjectScanner, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner`, + method: "PUT", + body: payload, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Retrieve the system configured scanner registrations as candidates of setting project level scanner. + * + * @tags project + * @name ListScannerCandidatesOfProject + * @summary Get scanner registration candidates for configurating project level scanner + * @request GET:/projects/{project_name_or_id}/scanner/candidates + * @secure + */ + listScannerCandidatesOfProject: ( + projectNameOrId: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner/candidates`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get recent logs of the projects, it only query the previous version's audit log + * + * @tags project + * @name GetLogs + * @summary Get recent logs of the projects (deprecated) + * @request GET:/projects/{project_name}/logs + * @secure + */ + getLogs: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/logs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get recent logs of the projects + * + * @tags project + * @name GetLogExts + * @summary Get recent logs of the projects + * @request GET:/projects/{project_name}/auditlog-exts + * @secure + */ + getLogExts: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/auditlog-exts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a preheat policy under a project + * + * @tags preheat + * @name CreatePolicy + * @summary Create a preheat policy under a project + * @request POST:/projects/{project_name}/preheat/policies + * @secure + */ + createPolicy: ( + projectName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List preheat policies + * + * @tags preheat + * @name ListPolicies + * @summary List preheat policies + * @request GET:/projects/{project_name}/preheat/policies + * @secure + */ + listPolicies: ( + projectName: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a preheat policy + * + * @tags preheat + * @name GetPolicy + * @summary Get a preheat policy + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + getPolicy: ( + projectName: string, + preheatPolicyName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update preheat policy + * + * @tags preheat + * @name UpdatePolicy + * @summary Update preheat policy + * @request PUT:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + updatePolicy: ( + projectName: string, + preheatPolicyName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Manual preheat + * + * @tags preheat + * @name ManualPreheat + * @summary Manual preheat + * @request POST:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + manualPreheat: ( + projectName: string, + preheatPolicyName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete a preheat policy + * + * @tags preheat + * @name DeletePolicy + * @summary Delete a preheat policy + * @request DELETE:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + deletePolicy: ( + projectName: string, + preheatPolicyName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List executions for the given policy + * + * @tags preheat + * @name ListExecutions + * @summary List executions for the given policy + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions + * @secure + */ + listExecutions: ( + projectName: string, + preheatPolicyName: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a execution detail by id + * + * @tags preheat + * @name GetExecution + * @summary Get a execution detail by id + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id} + * @secure + */ + getExecution: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop a execution + * + * @tags preheat + * @name StopExecution + * @summary Stop a execution + * @request PATCH:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id} + * @secure + */ + stopExecution: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + execution: Execution, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}`, + method: "PATCH", + body: execution, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List all the related tasks for the given execution + * + * @tags preheat + * @name ListTasks + * @summary List all the related tasks for the given execution + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id}/tasks + * @secure + */ + listTasks: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the log text stream of the specified task for the given execution + * + * @tags preheat + * @name GetPreheatLog + * @summary Get the log text stream of the specified task for the given execution + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id}/tasks/{task_id}/logs + * @secure + */ + getPreheatLog: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}/tasks/${taskId}/logs`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description Get all providers at project level + * + * @tags preheat + * @name ListProvidersUnderProject + * @summary Get all providers at project level + * @request GET:/projects/{project_name}/preheat/providers + * @secure + */ + listProvidersUnderProject: ( + projectName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/providers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the immutable tag rules of a project + * + * @tags immutable + * @name ListImmuRules + * @summary List all immutable tag rules of current project + * @request GET:/projects/{project_name_or_id}/immutabletagrules + * @secure + */ + listImmuRules: ( + projectNameOrId: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint add an immutable tag rule to the project + * + * @tags immutable + * @name CreateImmuRule + * @summary Add an immutable tag rule to current project + * @request POST:/projects/{project_name_or_id}/immutabletagrules + * @secure + */ + createImmuRule: ( + projectNameOrId: string, + ImmutableRule: ImmutableRule, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules`, + method: "POST", + body: ImmutableRule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags immutable + * @name UpdateImmuRule + * @summary Update the immutable tag rule or enable or disable the rule + * @request PUT:/projects/{project_name_or_id}/immutabletagrules/{immutable_rule_id} + * @secure + */ + updateImmuRule: ( + projectNameOrId: string, + immutableRuleId: number, + ImmutableRule: ImmutableRule, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules/${immutableRuleId}`, + method: "PUT", + body: ImmutableRule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags immutable + * @name DeleteImmuRule + * @summary Delete the immutable tag rule. + * @request DELETE:/projects/{project_name_or_id}/immutabletagrules/{immutable_rule_id} + * @secure + */ + deleteImmuRule: ( + projectNameOrId: string, + immutableRuleId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules/${immutableRuleId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns webhook policies of a project. + * + * @tags webhook + * @name ListWebhookPoliciesOfProject + * @summary List project webhook policies. + * @request GET:/projects/{project_name_or_id}/webhook/policies + * @secure + */ + listWebhookPoliciesOfProject: ( + projectNameOrId: string, + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint create a webhook policy if the project does not have one. + * + * @tags webhook + * @name CreateWebhookPolicyOfProject + * @summary Create project webhook policy. + * @request POST:/projects/{project_name_or_id}/webhook/policies + * @secure + */ + createWebhookPolicyOfProject: ( + projectNameOrId: string, + policy: WebhookPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint returns specified webhook policy of a project. + * + * @tags webhook + * @name GetWebhookPolicyOfProject + * @summary Get project webhook policy + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + getWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is aimed to update the webhook policy of a project. + * + * @tags webhook + * @name UpdateWebhookPolicyOfProject + * @summary Update webhook policy of a project. + * @request PUT:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + updateWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + policy: WebhookPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is aimed to delete webhookpolicy of a project. + * + * @tags webhook + * @name DeleteWebhookPolicyOfProject + * @summary Delete webhook policy of a project + * @request DELETE:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + deleteWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns the executions of a specific webhook policy. + * + * @tags webhook + * @name ListExecutionsOfWebhookPolicy + * @summary List executions for a specific webhook policy + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions + * @secure + */ + listExecutionsOfWebhookPolicy: ( + projectNameOrId: string, + webhookPolicyId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the tasks of a specific webhook execution. + * + * @tags webhook + * @name ListTasksOfWebhookExecution + * @summary List tasks for a specific webhook execution + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions/{execution_id}/tasks + * @secure + */ + listTasksOfWebhookExecution: ( + projectNameOrId: string, + webhookPolicyId: number, + executionId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions/${executionId}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the logs of a specific webhook task. + * + * @tags webhook + * @name GetLogsOfWebhookTask + * @summary Get logs for a specific webhook task + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions/{execution_id}/tasks/{task_id}/log + * @secure + */ + getLogsOfWebhookTask: ( + projectNameOrId: string, + webhookPolicyId: number, + executionId: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions/${executionId}/tasks/${taskId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns last trigger information of project webhook policy. + * + * @tags webhook + * @name LastTrigger + * @summary Get project webhook policy last trigger info + * @request GET:/projects/{project_name_or_id}/webhook/lasttrigger + * @deprecated + * @secure + */ + lastTrigger: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/lasttrigger`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns webhook jobs of a project. + * + * @tags webhookjob + * @name ListWebhookJobs + * @summary List project webhook jobs + * @request GET:/projects/{project_name_or_id}/webhook/jobs + * @deprecated + * @secure + */ + listWebhookJobs: ( + projectNameOrId: string, + query: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * The policy ID. + * @format int64 + */ + policy_id: number; + /** The status of webhook job. */ + status?: string[]; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/jobs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get supported event types and notify types. + * + * @tags webhook + * @name GetSupportedEventTypes + * @summary Get supported event types and notify types. + * @request GET:/projects/{project_name_or_id}/webhook/events + * @secure + */ + getSupportedEventTypes: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/events`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + repositories = { + /** + * @description List all authorized repositories + * + * @tags repository + * @name ListAllRepositories + * @summary List all authorized repositories + * @request GET:/repositories + * @secure + */ + listAllRepositories: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/repositories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + auditLogs = { + /** + * @description This endpoint let the user see the recent operation logs of projects which the user is a member with project admin role,, or return all audit logs for system admin user, it only query the audit log in previous version. + * + * @tags auditlog + * @name ListAuditLogs + * @summary Get recent logs of projects which the user is a member with project admin role, or return all audit logs for system admin user (deprecated) + * @request GET:/audit-logs + * @secure + */ + listAuditLogs: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/audit-logs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + auditlogExts = { + /** + * @description This endpoint let user see the recent operation logs of the projects which he is member with project_admin role, or return all audit logs for system admin user. + * + * @tags auditlog + * @name ListAuditLogExts + * @summary Get recent logs of the projects which the user is a member with project_admin role, or return all audit logs for system admin user + * @request GET:/auditlog-exts + * @secure + */ + listAuditLogExts: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/auditlog-exts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get all event types of audit log + * + * @tags auditlog + * @name ListAuditLogEventTypes + * @summary Get all event types of audit log + * @request GET:/auditlog-exts/events + * @secure + */ + listAuditLogEventTypes: (params: RequestParams = {}) => + this.request({ + path: `/auditlog-exts/events`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + p2P = { + /** + * @description List P2P providers + * + * @tags preheat + * @name ListProviders + * @summary List P2P providers + * @request GET:/p2p/preheat/providers + * @secure + */ + listProviders: (params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/providers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint checks status of a instance, the instance can be given by ID or Endpoint URL (together with credential) + * + * @tags preheat + * @name PingInstances + * @summary Ping status of a instance. + * @request POST:/p2p/preheat/instances/ping + * @secure + */ + pingInstances: (instance: Instance, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/ping`, + method: "POST", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List P2P provider instances + * + * @tags preheat + * @name ListInstances + * @summary List P2P provider instances + * @request GET:/p2p/preheat/instances + * @secure + */ + listInstances: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/p2p/preheat/instances`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create p2p provider instances + * + * @tags preheat + * @name CreateInstance + * @summary Create p2p provider instances + * @request POST:/p2p/preheat/instances + * @secure + */ + createInstance: (instance: Instance, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances`, + method: "POST", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get a P2P provider instance + * + * @tags preheat + * @name GetInstance + * @summary Get a P2P provider instance + * @request GET:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + getInstance: (preheatInstanceName: string, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specified P2P provider instance + * + * @tags preheat + * @name DeleteInstance + * @summary Delete the specified P2P provider instance + * @request DELETE:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + deleteInstance: (preheatInstanceName: string, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the specified P2P provider instance + * + * @tags preheat + * @name UpdateInstance + * @summary Update the specified P2P provider instance + * @request PUT:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + updateInstance: ( + preheatInstanceName: string, + instance: Instance, + params: RequestParams = {}, + ) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "PUT", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + usergroups = { + /** + * @description Get all user groups information, it is open for system admin + * + * @tags usergroup + * @name ListUserGroups + * @summary Get all user groups information + * @request GET:/usergroups + * @secure + */ + listUserGroups: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** search with ldap group DN */ + ldap_group_dn?: string; + /** group name need to search, fuzzy matches */ + group_name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create user group information + * + * @tags usergroup + * @name CreateUserGroup + * @summary Create user group + * @request POST:/usergroups + * @secure + */ + createUserGroup: (usergroup: UserGroup, params: RequestParams = {}) => + this.request({ + path: `/usergroups`, + method: "POST", + body: usergroup, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is to search groups by group name. It's open for all authenticated requests. + * + * @tags usergroup + * @name SearchUserGroups + * @summary Search groups by groupname + * @request GET:/usergroups/search + * @secure + */ + searchUserGroups: ( + query: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Group name for filtering results. */ + groupname: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get user group information + * + * @tags usergroup + * @name GetUserGroup + * @summary Get user group information + * @request GET:/usergroups/{group_id} + * @secure + */ + getUserGroup: (groupId: number, params: RequestParams = {}) => + this.request({ + path: `/usergroups/${groupId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update user group information + * + * @tags usergroup + * @name UpdateUserGroup + * @summary Update group information + * @request PUT:/usergroups/{group_id} + * @secure + */ + updateUserGroup: ( + groupId: number, + usergroup: UserGroup, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups/${groupId}`, + method: "PUT", + body: usergroup, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete user group + * + * @tags usergroup + * @name DeleteUserGroup + * @summary Delete user group + * @request DELETE:/usergroups/{group_id} + * @secure + */ + deleteUserGroup: (groupId: number, params: RequestParams = {}) => + this.request({ + path: `/usergroups/${groupId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + icons = { + /** + * @description Get the artifact icon with the specified digest. As the original icon image is resized and encoded before returning, the parameter "digest" in the path doesn't match the hash of the returned content + * + * @tags icon + * @name GetIcon + * @summary Get artifact icon + * @request GET:/icons/{digest} + * @secure + */ + getIcon: (digest: string, params: RequestParams = {}) => + this.request({ + path: `/icons/${digest}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + robots = { + /** + * @description List the robot accounts with the specified level and project. + * + * @tags robot + * @name ListRobot + * @summary Get robot account + * @request GET:/robots + * @secure + */ + listRobot: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/robots`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a robot account + * + * @tags robot + * @name CreateRobot + * @summary Create a robot account + * @request POST:/robots + * @secure + */ + createRobot: (robot: RobotCreate, params: RequestParams = {}) => + this.request({ + path: `/robots`, + method: "POST", + body: robot, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns specific robot account information by robot ID. + * + * @tags robot + * @name GetRobotById + * @summary Get a robot account + * @request GET:/robots/{robot_id} + * @secure + */ + getRobotById: (robotId: number, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint updates specific robot account information by robot ID. + * + * @tags robot + * @name UpdateRobot + * @summary Update a robot account + * @request PUT:/robots/{robot_id} + * @secure + */ + updateRobot: (robotId: number, robot: Robot, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "PUT", + body: robot, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Refresh the robot secret + * + * @tags robot + * @name RefreshSec + * @summary Refresh the robot secret + * @request PATCH:/robots/{robot_id} + * @secure + */ + refreshSec: ( + robotId: number, + robotSec: RobotSec, + params: RequestParams = {}, + ) => + this.request({ + path: `/robots/${robotId}`, + method: "PATCH", + body: robotSec, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint deletes specific robot account information by robot ID. + * + * @tags robot + * @name DeleteRobot + * @summary Delete a robot account + * @request DELETE:/robots/{robot_id} + * @secure + */ + deleteRobot: (robotId: number, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + quotas = { + /** + * @description List quotas + * + * @tags quota + * @name ListQuotas + * @summary List quotas + * @request GET:/quotas + * @secure + */ + listQuotas: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The reference type of quota. */ + reference?: string; + /** The reference id of quota. */ + reference_id?: string; + /** + * Sort method, valid values include: + * 'hard.resource_name', '-hard.resource_name', 'used.resource_name', '-used.resource_name'. + * Here '-' stands for descending order, resource_name should be the real resource name of the quota. + */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/quotas`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the specified quota + * + * @tags quota + * @name GetQuota + * @summary Get the specified quota + * @request GET:/quotas/{id} + * @secure + */ + getQuota: (id: number, params: RequestParams = {}) => + this.request({ + path: `/quotas/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update hard limits of the specified quota + * + * @tags quota + * @name UpdateQuota + * @summary Update the specified quota + * @request PUT:/quotas/{id} + * @secure + */ + updateQuota: ( + id: number, + hard: QuotaUpdateReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/quotas/${id}`, + method: "PUT", + body: hard, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + replication = { + /** + * @description List replication policies + * + * @tags replication + * @name ListReplicationPolicies + * @summary List replication policies + * @request GET:/replication/policies + * @secure + */ + listReplicationPolicies: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Deprecated, use "query" instead. The policy name. */ + name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a replication policy + * + * @tags replication + * @name CreateReplicationPolicy + * @summary Create a replication policy + * @request POST:/replication/policies + * @secure + */ + createReplicationPolicy: ( + policy: ReplicationPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific replication policy + * + * @tags replication + * @name GetReplicationPolicy + * @summary Get the specific replication policy + * @request GET:/replication/policies/{id} + * @secure + */ + getReplicationPolicy: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/policies/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specific replication policy + * + * @tags replication + * @name DeleteReplicationPolicy + * @summary Delete the specific replication policy + * @request DELETE:/replication/policies/{id} + * @secure + */ + deleteReplicationPolicy: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/policies/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the replication policy + * + * @tags replication + * @name UpdateReplicationPolicy + * @summary Update the replication policy + * @request PUT:/replication/policies/{id} + * @secure + */ + updateReplicationPolicy: ( + id: number, + policy: ReplicationPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies/${id}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List replication executions + * + * @tags replication + * @name ListReplicationExecutions + * @summary List replication executions + * @request GET:/replication/executions + * @secure + */ + listReplicationExecutions: ( + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The ID of the policy that the executions belong to. */ + policy_id?: number; + /** The execution status. */ + status?: string; + /** The trigger mode. */ + trigger?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Start one replication execution according to the policy + * + * @tags replication + * @name StartReplication + * @summary Start one replication execution + * @request POST:/replication/executions + * @secure + */ + startReplication: ( + execution: StartReplicationExecution, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions`, + method: "POST", + body: execution, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the replication execution specified by ID + * + * @tags replication + * @name GetReplicationExecution + * @summary Get the specific replication execution + * @request GET:/replication/executions/{id} + * @secure + */ + getReplicationExecution: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/executions/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the replication execution specified by ID + * + * @tags replication + * @name StopReplication + * @summary Stop the specific replication execution + * @request PUT:/replication/executions/{id} + * @secure + */ + stopReplication: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/executions/${id}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description List replication tasks for a specific execution + * + * @tags replication + * @name ListReplicationTasks + * @summary List replication tasks for a specific execution + * @request GET:/replication/executions/{id}/tasks + * @secure + */ + listReplicationTasks: ( + id: number, + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The task status. */ + status?: string; + /** The resource type. */ + resource_type?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions/${id}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the log of the specific replication task + * + * @tags replication + * @name GetReplicationLog + * @summary Get the log of the specific replication task + * @request GET:/replication/executions/{id}/tasks/{task_id}/log + * @secure + */ + getReplicationLog: ( + id: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions/${id}/tasks/${taskId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description List registry adapters + * + * @tags registry + * @name ListRegistryProviderTypes + * @summary List registry adapters + * @request GET:/replication/adapters + * @secure + */ + listRegistryProviderTypes: (params: RequestParams = {}) => + this.request({ + path: `/replication/adapters`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description List all registered registry provider information + * + * @tags registry + * @name ListRegistryProviderInfos + * @summary List all registered registry provider information + * @request GET:/replication/adapterinfos + * @secure + */ + listRegistryProviderInfos: (params: RequestParams = {}) => + this.request, Errors>({ + path: `/replication/adapterinfos`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + registries = { + /** + * @description Create a registry + * + * @tags registry + * @name CreateRegistry + * @summary Create a registry + * @request POST:/registries + * @secure + */ + createRegistry: (registry: Registry, params: RequestParams = {}) => + this.request({ + path: `/registries`, + method: "POST", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List the registries + * + * @tags registry + * @name ListRegistries + * @summary List the registries + * @request GET:/registries + * @secure + */ + listRegistries: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Deprecated, use `q` instead. */ + name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/registries`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Check status of a registry + * + * @tags registry + * @name PingRegistry + * @summary Check status of a registry + * @request POST:/registries/ping + * @secure + */ + pingRegistry: (registry: RegistryPing, params: RequestParams = {}) => + this.request({ + path: `/registries/ping`, + method: "POST", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific registry + * + * @tags registry + * @name GetRegistry + * @summary Get the specific registry + * @request GET:/registries/{id} + * @secure + */ + getRegistry: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specific registry + * + * @tags registry + * @name DeleteRegistry + * @summary Delete the specific registry + * @request DELETE:/registries/{id} + * @secure + */ + deleteRegistry: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the registry + * + * @tags registry + * @name UpdateRegistry + * @summary Update the registry + * @request PUT:/registries/{id} + * @secure + */ + updateRegistry: ( + id: number, + registry: RegistryUpdate, + params: RequestParams = {}, + ) => + this.request({ + path: `/registries/${id}`, + method: "PUT", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the registry info + * + * @tags registry + * @name GetRegistryInfo + * @summary Get the registry info + * @request GET:/registries/{id}/info + * @secure + */ + getRegistryInfo: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}/info`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + scans = { + /** + * @description Get the metrics of the latest scan all process + * + * @tags scanAll + * @name GetLatestScanAllMetrics + * @summary Get the metrics of the latest scan all process + * @request GET:/scans/all/metrics + * @secure + */ + getLatestScanAllMetrics: (params: RequestParams = {}) => + this.request({ + path: `/scans/all/metrics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the metrics of the latest scheduled scan all process + * + * @tags scanAll + * @name GetLatestScheduledScanAllMetrics + * @summary Get the metrics of the latest scheduled scan all process + * @request GET:/scans/schedule/metrics + * @deprecated + * @secure + */ + getLatestScheduledScanAllMetrics: (params: RequestParams = {}) => + this.request({ + path: `/scans/schedule/metrics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + systeminfo = { + /** + * @description This API is for retrieving general system info, this can be called by anonymous request. Some attributes will be omitted in the response when this API is called by anonymous request. + * + * @tags systeminfo + * @name GetSystemInfo + * @summary Get general system info + * @request GET:/systeminfo + * @secure + */ + getSystemInfo: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for retrieving system volume info that only provides for admin user. Note that the response only reflects the storage status of local disk. + * + * @tags systeminfo + * @name GetVolumes + * @summary Get system volume info (total/free size). + * @request GET:/systeminfo/volumes + * @secure + */ + getVolumes: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo/volumes`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for downloading a default root certificate. + * + * @tags systeminfo + * @name GetCert + * @summary Get default root certificate. + * @request GET:/systeminfo/getcert + * @secure + */ + getCert: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo/getcert`, + method: "GET", + secure: true, + ...params, + }), + }; + system = { + /** + * @description Test the OIDC endpoint, the setting of the endpoint is provided in the request. This API can only be called by system admin. + * + * @tags oidc + * @name PingOidc + * @summary Test the OIDC endpoint. + * @request POST:/system/oidc/ping + * @secure + */ + pingOidc: ( + endpoint: { + /** The URL of OIDC endpoint to be tested. */ + url?: string; + /** Whether the certificate should be verified */ + verify_cert?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/oidc/ping`, + method: "POST", + body: endpoint, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let user get gc execution history. + * + * @tags gc + * @name GetGcHistory + * @summary Get gc results. + * @request GET:/system/gc + * @secure + */ + getGcHistory: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/gc`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user get gc status filtered by specific ID. + * + * @tags gc + * @name GetGc + * @summary Get gc status. + * @request GET:/system/gc/{gc_id} + * @secure + */ + getGc: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the GC execution specified by ID + * + * @tags gc + * @name StopGc + * @summary Stop the specific GC execution + * @request PUT:/system/gc/{gc_id} + * @secure + */ + stopGc: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description This endpoint let user get gc job logs filtered by specific ID. + * + * @tags gc + * @name GetGcLog + * @summary Get gc job log. + * @request GET:/system/gc/{gc_id}/log + * @secure + */ + getGcLog: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint is for get schedule of gc job. + * + * @tags gc + * @name GetGcSchedule + * @summary Get gc's schedule. + * @request GET:/system/gc/schedule + * @secure + */ + getGcSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for update gc schedule. + * + * @tags gc + * @name CreateGcSchedule + * @summary Create a gc schedule. + * @request POST:/system/gc/schedule + * @secure + */ + createGcSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for update gc schedule. + * + * @tags gc + * @name UpdateGcSchedule + * @summary Update gc's schedule. + * @request PUT:/system/gc/schedule + * @secure + */ + updateGcSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description get purge job execution history. + * + * @tags purge + * @name GetPurgeHistory + * @summary Get purge job results. + * @request GET:/system/purgeaudit + * @secure + */ + getPurgeHistory: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/purgeaudit`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user get purge job status filtered by specific ID. + * + * @tags purge + * @name GetPurgeJob + * @summary Get purge job status. + * @request GET:/system/purgeaudit/{purge_id} + * @secure + */ + getPurgeJob: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the purge audit log execution specified by ID + * + * @tags purge + * @name StopPurge + * @summary Stop the specific purge audit log execution + * @request PUT:/system/purgeaudit/{purge_id} + * @secure + */ + stopPurge: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description This endpoint let user get purge job logs filtered by specific ID. + * + * @tags purge + * @name GetPurgeJobLog + * @summary Get purge job log. + * @request GET:/system/purgeaudit/{purge_id}/log + * @secure + */ + getPurgeJobLog: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint is for get schedule of purge job. + * + * @tags purge + * @name GetPurgeSchedule + * @summary Get purge's schedule. + * @request GET:/system/purgeaudit/schedule + * @secure + */ + getPurgeSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for update purge job schedule. + * + * @tags purge + * @name CreatePurgeSchedule + * @summary Create a purge job schedule. + * @request POST:/system/purgeaudit/schedule + * @secure + */ + createPurgeSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for update purge job schedule. + * + * @tags purge + * @name UpdatePurgeSchedule + * @summary Update purge job's schedule. + * @request PUT:/system/purgeaudit/schedule + * @secure + */ + updatePurgeSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the system level allowlist of CVE. This API can be called by all authenticated users. + * + * @tags SystemCVEAllowlist + * @name GetSystemCveAllowlist + * @summary Get the system level allowlist of CVE. + * @request GET:/system/CVEAllowlist + * @secure + */ + getSystemCveAllowlist: (params: RequestParams = {}) => + this.request({ + path: `/system/CVEAllowlist`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This API overwrites the system level allowlist of CVE with the list in request body. Only system Admin has permission to call this API. + * + * @tags SystemCVEAllowlist + * @name PutSystemCveAllowlist + * @summary Update the system level allowlist of CVE. + * @request PUT:/system/CVEAllowlist + * @secure + */ + putSystemCveAllowlist: ( + allowlist: CVEAllowlist, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/CVEAllowlist`, + method: "PUT", + body: allowlist, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for getting a schedule for the scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name GetScanAllSchedule + * @summary Get scan all's schedule. + * @request GET:/system/scanAll/schedule + * @secure + */ + getScanAllSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for updating the schedule of scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name UpdateScanAllSchedule + * @summary Update scan all's schedule. + * @request PUT:/system/scanAll/schedule + * @secure + */ + updateScanAllSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for creating a schedule or a manual trigger for the scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name CreateScanAllSchedule + * @summary Create a schedule or a manual trigger for the scan all job. + * @request POST:/system/scanAll/schedule + * @secure + */ + createScanAllSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Stop scanAll job execution + * + * @tags scanAll + * @name StopScanAll + * @summary Stop scanAll job execution + * @request POST:/system/scanAll/stop + * @secure + */ + stopScanAll: (params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/stop`, + method: "POST", + secure: true, + ...params, + }), + }; + jobservice = { + /** + * @description Get worker pools + * + * @tags jobservice + * @name GetWorkerPools + * @summary Get worker pools + * @request GET:/jobservice/pools + * @secure + */ + getWorkerPools: (params: RequestParams = {}) => + this.request({ + path: `/jobservice/pools`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get workers in current pool + * + * @tags jobservice + * @name GetWorkers + * @summary Get workers + * @request GET:/jobservice/pools/{pool_id}/workers + * @secure + */ + getWorkers: (poolId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/pools/${poolId}/workers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop running job + * + * @tags jobservice + * @name StopRunningJob + * @summary Stop running job + * @request PUT:/jobservice/jobs/{job_id} + * @secure + */ + stopRunningJob: (jobId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/jobs/${jobId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description Get job log by job id, it is only used by administrator + * + * @tags jobservice + * @name ActionGetJobLog + * @summary Get job log by job id + * @request GET:/jobservice/jobs/{job_id}/log + * @secure + */ + actionGetJobLog: (jobId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/jobs/${jobId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description list job queue + * + * @tags jobservice + * @name ListJobQueues + * @summary list job queues + * @request GET:/jobservice/queues + * @secure + */ + listJobQueues: (params: RequestParams = {}) => + this.request({ + path: `/jobservice/queues`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description stop and clean, pause, resume pending jobs in the queue + * + * @tags jobservice + * @name ActionPendingJobs + * @summary stop and clean, pause, resume pending jobs in the queue + * @request PUT:/jobservice/queues/{job_type} + * @secure + */ + actionPendingJobs: ( + jobType: string, + action_request: ActionRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/jobservice/queues/${jobType}`, + method: "PUT", + body: action_request, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + schedules = { + /** + * @description List schedules + * + * @tags schedule + * @name ListSchedules + * @request GET:/schedules + * @secure + */ + listSchedules: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/schedules`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get scheduler paused status + * + * @tags schedule + * @name GetSchedulePaused + * @request GET:/schedules/{job_type}/paused + * @secure + */ + getSchedulePaused: (jobType: string, params: RequestParams = {}) => + this.request({ + path: `/schedules/${jobType}/paused`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + ping = { + /** + * @description This API simply replies a pong to indicate the process to handle API is up, disregarding the health status of dependent components. This path does not require any authentication. + * + * @tags ping + * @name GetPing + * @summary Ping Harbor to check if the API server is alive. + * @request GET:/ping + * @secure + */ + getPing: (params: RequestParams = {}) => + this.request({ + path: `/ping`, + method: "GET", + secure: true, + ...params, + }), + }; + retentions = { + /** + * @description Get Retention Metadatas. + * + * @tags Retention + * @name GetRentenitionMetadata + * @summary Get Retention Metadatas + * @request GET:/retentions/metadatas + * @secure + */ + getRentenitionMetadata: (params: RequestParams = {}) => + this.request({ + path: `/retentions/metadatas`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when no retention policy binded to project yet. + * + * @tags Retention + * @name CreateRetention + * @summary Create Retention Policy + * @request POST:/retentions + * @secure + */ + createRetention: (policy: RetentionPolicy, params: RequestParams = {}) => + this.request({ + path: `/retentions`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention Policy. + * + * @tags Retention + * @name GetRetention + * @summary Get Retention Policy + * @request GET:/retentions/{id} + * @secure + */ + getRetention: (id: number, params: RequestParams = {}) => + this.request({ + path: `/retentions/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when retention policy has already binded to project. + * + * @tags Retention + * @name UpdateRetention + * @summary Update Retention Policy + * @request PUT:/retentions/{id} + * @secure + */ + updateRetention: ( + id: number, + policy: RetentionPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when retention policy has already binded to project. + * + * @tags Retention + * @name DeleteRetention + * @summary Delete Retention Policy + * @request DELETE:/retentions/{id} + * @secure + */ + deleteRetention: (id: number, params: RequestParams = {}) => + this.request({ + path: `/retentions/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Trigger a Retention Execution, if dry_run is True, nothing would be deleted actually. + * + * @tags Retention + * @name TriggerRetentionExecution + * @summary Trigger a Retention Execution + * @request POST:/retentions/{id}/executions + * @secure + */ + triggerRetentionExecution: ( + id: number, + body: { + dry_run?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions`, + method: "POST", + body: body, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention executions, execution status may be delayed before job service schedule it up. + * + * @tags Retention + * @name ListRetentionExecutions + * @summary Get Retention executions + * @request GET:/retentions/{id}/executions + * @secure + */ + listRetentionExecutions: ( + id: number, + query?: { + /** + * The page number. + * @format int64 + */ + page?: number; + /** + * The size of per page. + * @format int64 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop a Retention execution, only support "stop" action now. + * + * @tags Retention + * @name OperateRetentionExecution + * @summary Stop a Retention execution + * @request PATCH:/retentions/{id}/executions/{eid} + * @secure + */ + operateRetentionExecution: ( + id: number, + eid: number, + body: { + action?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}`, + method: "PATCH", + body: body, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention tasks, each repository as a task. + * + * @tags Retention + * @name ListRetentionTasks + * @summary Get Retention tasks + * @request GET:/retentions/{id}/executions/{eid}/tasks + * @secure + */ + listRetentionTasks: ( + id: number, + eid: number, + query?: { + /** + * The page number. + * @format int64 + */ + page?: number; + /** + * The size of per page. + * @format int64 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get Retention job task log, tags ratain or deletion detail will be shown in a table. + * + * @tags Retention + * @name GetRetentionTaskLog + * @summary Get Retention job task log + * @request GET:/retentions/{id}/executions/{eid}/tasks/{tid} + * @secure + */ + getRetentionTaskLog: ( + id: number, + eid: number, + tid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}/tasks/${tid}`, + method: "GET", + secure: true, + ...params, + }), + }; + scanners = { + /** + * @description Returns a list of currently configured scanner registrations. + * + * @tags scanner + * @name ListScanners + * @summary List scanner registrations + * @request GET:/scanners + * @secure + */ + listScanners: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Creats a new scanner registration with the given data. + * + * @tags scanner + * @name CreateScanner + * @summary Create a scanner registration + * @request POST:/scanners + * @secure + */ + createScanner: ( + registration: ScannerRegistrationReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners`, + method: "POST", + body: registration, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Pings scanner adapter to test endpoint URL and authorization settings. + * + * @tags scanner + * @name PingScanner + * @summary Tests scanner registration settings + * @request POST:/scanners/ping + * @secure + */ + pingScanner: ( + settings: ScannerRegistrationSettings, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/ping`, + method: "POST", + body: settings, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Retruns the details of the specified scanner registration. + * + * @tags scanner + * @name GetScanner + * @summary Get a scanner registration details + * @request GET:/scanners/{registration_id} + * @secure + */ + getScanner: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Updates the specified scanner registration. + * + * @tags scanner + * @name UpdateScanner + * @summary Update a scanner registration + * @request PUT:/scanners/{registration_id} + * @secure + */ + updateScanner: ( + registrationId: string, + registration: ScannerRegistrationReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/${registrationId}`, + method: "PUT", + body: registration, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Deletes the specified scanner registration. + * + * @tags scanner + * @name DeleteScanner + * @summary Delete a scanner registration + * @request DELETE:/scanners/{registration_id} + * @secure + */ + deleteScanner: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}`, + method: "DELETE", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Set the specified scanner registration as the system default one. + * + * @tags scanner + * @name SetScannerAsDefault + * @summary Set system default scanner registration + * @request PATCH:/scanners/{registration_id} + * @secure + */ + setScannerAsDefault: ( + registrationId: string, + payload: IsDefault, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/${registrationId}`, + method: "PATCH", + body: payload, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the metadata of the specified scanner registration, including the capabilities and customized properties. + * + * @tags scanner + * @name GetScannerMetadata + * @summary Get the metadata of the specified scanner registration + * @request GET:/scanners/{registration_id}/metadata + * @secure + */ + getScannerMetadata: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}/metadata`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + users = { + /** + * No description + * + * @tags user + * @name ListUsers + * @summary List users + * @request GET:/users + * @secure + */ + listUsers: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This API can be used only when the authentication mode is for local DB. When self registration is disabled. + * + * @tags user + * @name CreateUser + * @summary Create a local user. + * @request POST:/users + * @secure + */ + createUser: (userReq: UserCreationReq, params: RequestParams = {}) => + this.request({ + path: `/users`, + method: "POST", + body: userReq, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetCurrentUserInfo + * @summary Get current user info. + * @request GET:/users/current + * @secure + */ + getCurrentUserInfo: (params: RequestParams = {}) => + this.request({ + path: `/users/current`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is to search the users by username. It's open for all authenticated requests. + * + * @tags user + * @name SearchUsers + * @summary Search users by username + * @request GET:/users/search + * @secure + */ + searchUsers: ( + query: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Username for filtering results. */ + username: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetUser + * @summary Get a user's profile. + * @request GET:/users/{user_id} + * @secure + */ + getUser: (userId: number, params: RequestParams = {}) => + this.request({ + path: `/users/${userId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags user + * @name UpdateUserProfile + * @summary Update user's profile. + * @request PUT:/users/{user_id} + * @secure + */ + updateUserProfile: ( + userId: number, + profile: UserProfile, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}`, + method: "PUT", + body: profile, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let administrator of Harbor mark a registered user as removed.It actually won't be deleted from DB. + * + * @tags user + * @name DeleteUser + * @summary Mark a registered user as be removed. + * @request DELETE:/users/{user_id} + * @secure + */ + deleteUser: (userId: number, params: RequestParams = {}) => + this.request({ + path: `/users/${userId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * No description + * + * @tags user + * @name SetUserSysAdmin + * @summary Update a registered user to change to be an administrator of Harbor. + * @request PUT:/users/{user_id}/sysadmin + * @secure + */ + setUserSysAdmin: ( + userId: number, + sysadmin_flag: UserSysAdminFlag, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/sysadmin`, + method: "PUT", + body: sysadmin_flag, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for user to update password. Users with the admin role can change any user's password. Regular users can change only their own password. + * + * @tags user + * @name UpdateUserPassword + * @summary Change the password on a user that already exists. + * @request PUT:/users/{user_id}/password + * @secure + */ + updateUserPassword: ( + userId: number, + password: PasswordReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/password`, + method: "PUT", + body: password, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetCurrentUserPermissions + * @summary Get current user permissions. + * @request GET:/users/current/permissions + * @secure + */ + getCurrentUserPermissions: ( + query?: { + /** The scope for the permission */ + scope?: string; + /** + * If true, the resources in the response are relative to the scope, + * eg for resource '/project/1/repository' if relative is 'true' then the resource in response will be 'repository'. + */ + relative?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/current/permissions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user generate a new CLI secret for himself. This API only works when auth mode is set to 'OIDC'. Once this API returns with successful status, the old secret will be invalid, as there will be only one CLI secret for a user. + * + * @tags user + * @name SetCliSecret + * @summary Set CLI secret for a user. + * @request PUT:/users/{user_id}/cli_secret + * @secure + */ + setCliSecret: ( + userId: number, + secret: OIDCCliSecretReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/cli_secret`, + method: "PUT", + body: secret, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + labels = { + /** + * @description This endpoint let user list labels by name, scope and project_id + * + * @tags label + * @name ListLabels + * @summary List labels according to the query strings. + * @request GET:/labels + * @secure + */ + listLabels: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The label name. */ + name?: string; + /** The label scope. Valid values are g and p. g for global labels and p for project labels. */ + scope?: string; + /** + * Relevant project ID, required when scope is p. + * @format int64 + */ + project_id?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/labels`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user creates a label. + * + * @tags label + * @name CreateLabel + * @summary Post creates a label + * @request POST:/labels + * @secure + */ + createLabel: (label: Label, params: RequestParams = {}) => + this.request({ + path: `/labels`, + method: "POST", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let user get the label by specific ID. + * + * @tags label + * @name GetLabelById + * @summary Get the label specified by ID. + * @request GET:/labels/{label_id} + * @secure + */ + getLabelById: (labelId: number, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user update label properties. + * + * @tags label + * @name UpdateLabel + * @summary Update the label properties. + * @request PUT:/labels/{label_id} + * @secure + */ + updateLabel: (labelId: number, label: Label, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "PUT", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the label specified by ID. + * + * @tags label + * @name DeleteLabel + * @summary Delete the label specified by ID. + * @request DELETE:/labels/{label_id} + * @secure + */ + deleteLabel: (labelId: number, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + export = { + /** + * @description Export scan data for selected projects + * + * @tags scan data export + * @name ExportScanData + * @summary Export scan data for selected projects + * @request POST:/export/cve + * @secure + */ + exportScanData: ( + criteria: ScanDataExportRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve`, + method: "POST", + body: criteria, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description Get the scan data export execution specified by ID + * + * @tags scan data export + * @name GetScanDataExportExecution + * @summary Get the specific scan data export execution + * @request GET:/export/cve/execution/{execution_id} + * @secure + */ + getScanDataExportExecution: ( + executionId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve/execution/${executionId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a list of specific scan data export execution jobs for a specified user + * + * @tags scan data export + * @name GetScanDataExportExecutionList + * @summary Get a list of specific scan data export execution jobs for a specified user + * @request GET:/export/cve/executions + * @secure + */ + getScanDataExportExecutionList: (params: RequestParams = {}) => + this.request({ + path: `/export/cve/executions`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Download the scan data report. Default format is CSV + * + * @tags scan data export + * @name DownloadScanData + * @summary Download the scan data export file + * @request GET:/export/cve/download/{execution_id} + * @secure + */ + downloadScanData: ( + executionId: number, + query?: { + /** The format of the data to be exported. e.g. CSV or PDF */ + format?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve/download/${executionId}`, + method: "GET", + query: query, + secure: true, + ...params, + }), + }; + security = { + /** + * @description Retrieve the vulnerability summary of the system + * + * @tags securityhub + * @name GetSecuritySummary + * @summary Get vulnerability system summary + * @request GET:/security/summary + * @secure + */ + getSecuritySummary: ( + query?: { + /** + * Specify whether the dangerous CVEs are included inside summary information + * @default false + */ + with_dangerous_cve?: boolean; + /** + * Specify whether the dangerous Artifact are included inside summary information + * @default false + */ + with_dangerous_artifact?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/security/summary`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the vulnerability list. use q to pass the query condition, supported conditions: cve_id(exact match) cvss_score_v3(range condition) severity(exact match) repository_name(exact match) project_id(exact match) package(exact match) tag(exact match) digest(exact match) + * + * @tags securityhub + * @name ListVulnerabilities + * @summary Get the vulnerability list. + * @request GET:/security/vul + * @secure + */ + listVulnerabilities: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Enable to ignore X-Total-Count when the total count > 1000, if the total count is less than 1000, the real total count is returned, else -1. + * @default false + */ + tune_count?: boolean; + /** + * Specify whether the tag information is included inside vulnerability information + * @default false + */ + with_tag?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/security/vul`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + permissions = { + /** + * @description This endpoint is for retrieving resource and action info that only provides for admin user(system admin and project admin). + * + * @tags permissions + * @name GetPermissions + * @summary Get system or project level permissions info. + * @request GET:/permissions + * @secure + */ + getPermissions: (params: RequestParams = {}) => + this.request({ + path: `/permissions`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; +} diff --git a/.jjconflict-side-1/plugins/harbor/src/env.d.ts b/.jjconflict-side-1/plugins/harbor/src/env.d.ts new file mode 100644 index 0000000000..44377abce9 --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-side-1/plugins/harbor/src/functions.ts b/.jjconflict-side-1/plugins/harbor/src/functions.ts new file mode 100644 index 0000000000..ea902dae27 --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/src/functions.ts @@ -0,0 +1,159 @@ +import type { PluginResult, Project, ProjectLite, StepCall } from '@cpn-console/hooks' +import type { VaultRobotSecret } from './robot.js' +import { + + specificallyDisabled, + specificallyEnabled, +} from '@cpn-console/hooks' +import { DEFAULT, ENABLED } from '@cpn-console/shared' +// @ts-ignore pas de typage disponible pour le paquet bytes +import bytes from 'bytes' +import { logger } from './logger.js' +import { addProjectGroupMember } from './permission.js' +import { addRetentionPolicy } from './policy.js' +import { createProject, deleteProject } from './project.js' +import { deleteRobot, ensureRobot, roAccess, rwAccess } from './robot.js' +import { + getApi, + getConfig, + projectRobotName, + roRobotName, + rwRobotName, +} from './utils.js' + +export const createDsoProject: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + }, + } + const warnReasons: string[] = [] + try { + const project = payload.args + const projectName = project.slug + const { vault: vaultApi, keycloak: keycloakApi } = payload.apis + + const publishRoRobotProject = project.store.registry?.publishProjectRobot + const publishRoRobotConfig = payload.config.registry?.publishProjectRobot + const createProjectRobot + = specificallyEnabled(publishRoRobotProject) + || (specificallyEnabled(publishRoRobotConfig) + && !specificallyDisabled(publishRoRobotProject)) + + const quotaHardLimit + = project.store.registry?.quotaHardLimit + || payload.config.registry?.quotaHardLimit + const quotaHardLimitBytes = quotaHardLimit + ? bytes.parse(quotaHardLimit) + : undefined + + const [projectCreated, oidcGroup] = await Promise.all([ + createProject( + projectName, + quotaHardLimitBytes === 1 ? undefined : quotaHardLimitBytes, + ), + keycloakApi.getProjectGroupPath(), + ]) + const api = getApi() + + if (!projectCreated.project_id) + throw new Error('Unable to retrieve project_id') + const projectId = projectCreated.project_id + + await Promise.all([ + ensureRobot(projectName, projectId, roRobotName, vaultApi, roAccess, api), // cette ligne en premier sinon ça foire au dessus + ensureRobot(projectName, projectId, rwRobotName, vaultApi, rwAccess, api), + addProjectGroupMember(projectName, oidcGroup), + addRetentionPolicy(projectName, projectId), + createProjectRobot + ? ensureRobot(projectName, projectId, projectRobotName, vaultApi, roAccess, api) + : deleteRobot(projectName, projectId, projectRobotName, vaultApi, api), + ]) + returnResult.status.message = `Created${createProjectRobot ? ' , with project robot' : ''}` + returnResult.store = { + projectId: projectCreated.project_id, + } + if (warnReasons.length) { + returnResult.status.result = 'WARNING' + returnResult.status.message = warnReasons.join(', ') + } + logger.info({ action: 'createDsoProject', projectSlug: projectName, result: returnResult.status.result, createProjectRobot }, 'Hook done') + return returnResult + } catch (error) { + logger.error({ action: 'createDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const deleteDsoProject: StepCall = async (payload) => { + try { + const project = payload.args + const projectName = project.slug + + await deleteProject(projectName) + + logger.info({ action: 'deleteDsoProject', projectSlug: projectName, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } catch (error) { + logger.error({ action: 'deleteDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const getProjectSecrets: StepCall = async ({ + args: project, + apis: { vault: vaultApi }, + config, +}) => { + logger.debug({ action: 'getProjectSecrets', projectSlug: project.slug }, 'Hook done') + const publishRoRobotProject = project.store.registry?.publishProjectRobot + const publishRoRobotConfig = config.registry?.publishProjectRobot + const projectRobotEnabled + = publishRoRobotProject === ENABLED + || (publishRoRobotConfig === ENABLED + && (!publishRoRobotProject || publishRoRobotProject === DEFAULT)) + + const VaultRobotSecret = projectRobotEnabled + ? ((await vaultApi.read(`REGISTRY/${projectRobotName}`, { + throwIfNoEntry: false, + })) as { data: VaultRobotSecret } | undefined) + : undefined + let secrets: { [x: string]: string } = { + 'Registry base path': `${getConfig().host}/${project.slug}/`, + } + + if (projectRobotEnabled) { + secrets = VaultRobotSecret?.data + ? { + ...secrets, + ...VaultRobotSecret.data, + } + : { + ...secrets, + '/!\\': 'Vous n\'avez pas de robot de lecture veuillez reprovisionner', + } + } + return { + status: { + result: 'OK', + }, + secrets, + } +} diff --git a/.jjconflict-side-1/plugins/harbor/src/index.ts b/.jjconflict-side-1/plugins/harbor/src/index.ts new file mode 100644 index 0000000000..c005ffd840 --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/src/index.ts @@ -0,0 +1,19 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import { createDsoProject, deleteDsoProject, getProjectSecrets } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { steps: { post: createDsoProject } }, + deleteProject: { steps: { main: deleteDsoProject } }, + getProjectSecrets: { steps: { main: getProjectSecrets } }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-side-1/plugins/harbor/src/infos.ts b/.jjconflict-side-1/plugins/harbor/src/infos.ts new file mode 100644 index 0000000000..97c3de42eb --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/src/infos.ts @@ -0,0 +1,71 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED } from '@cpn-console/shared' +import { getConfig } from './utils.js' + +const quotaDescription = '-1 -> illimité, sinon 100MB / 1.2GB (unités : B, KB, MB, GB, TB)), max 1024TB' +const infos = { + name: 'registry', + to: ({ store }) => store?.registry?.projectId ? `${getConfig().url}/harbor/projects/${store.registry.projectId}` : `${getConfig().url}/`, + title: 'Harbor', + imgSrc: '/img/harbor.svg', + description: 'Harbor stocke, analyse et distribue vos images de conteneurs', + config: { + project: [{ + permissions: { + admin: { read: false, write: false }, + user: { read: false, write: false }, + }, + key: 'projectId', + kind: 'text', + title: 'Num du projet Harbor', + value: '', + }, { + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot projet', + description: 'Activer le robot de projet (read-only) et afficher ses identifiants aux utilisateurs', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota', + value: '', + description: `Stockage limite (vide utilisation du paramètre global, ${quotaDescription}`, + placeholder: '', + }], + global: [{ + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot RO aux projets', + description: 'Définit le comportement en l\'absence de ce paramétrage au niveau projet', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota par défaut', + value: '-1', + description: `Stockage limite par projet (${quotaDescription}`, + placeholder: '-1', + }], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-side-1/plugins/harbor/src/kubeSecret.ts b/.jjconflict-side-1/plugins/harbor/src/kubeSecret.ts new file mode 100644 index 0000000000..acf1ec297b --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/src/kubeSecret.ts @@ -0,0 +1,14 @@ +// Utils +export function getSecretObject({ DOCKER_CONFIG }: { DOCKER_CONFIG: string }) { + const b64dockerConfig = Buffer.from(DOCKER_CONFIG).toString('base64') + return { + kind: 'Secret', + data: { + '.dockerconfigjson': b64dockerConfig, + }, + type: 'kubernetes.io/dockerconfigjson', + metadata: { + name: 'registry-pull-secret', + }, + } +} diff --git a/.jjconflict-side-1/plugins/harbor/src/logger.ts b/.jjconflict-side-1/plugins/harbor/src/logger.ts new file mode 100644 index 0000000000..82f73cecb6 --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'harbor' }) diff --git a/.jjconflict-side-1/plugins/harbor/src/monitor.ts b/.jjconflict-side-1/plugins/harbor/src/monitor.ts new file mode 100644 index 0000000000..eaa73c6ea1 --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/src/monitor.ts @@ -0,0 +1,52 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import { getApi } from './utils.js' + +enum HealthStatus { + healthy = 'healthy', + unhealthy = 'unhealthy', +} +const coreComponents = ['core', 'database', 'portal', 'registry', 'registryctl'] + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await getApi().health.getHealth({ + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + const data = res.data + if (data.status === HealthStatus.healthy) { + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + const failedCoreComponents = data.components + ? data.components + .filter(component => + component.status === HealthStatus.unhealthy + && component.name + && coreComponents.includes(component.name), + ) + : [] + + if (failedCoreComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.WARNING + instance.lastStatus.message = 'Service dégradé' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Fatal Error' + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-1/plugins/harbor/src/permission.ts b/.jjconflict-side-1/plugins/harbor/src/permission.ts new file mode 100644 index 0000000000..05e30d4d6c --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/src/permission.ts @@ -0,0 +1,26 @@ +import { getApi } from './utils.js' + +export async function addProjectGroupMember(projectName: string, groupName: string, accessLevel: number = 3): Promise { + const api = getApi() + const members = await api.projects.listProjectMembers(projectName) + const member = members.data.find(m => m.entity_name === groupName) + if (member?.id) { // member.id is always set pb comes from bad swagger + if (member.role_id !== accessLevel && member.entity_type !== 'g') { + // le membre semble être incorrectement paramétré, suppression pour recréation + await api.projects.deleteProjectMember(projectName, member.id) + } else { + // tout va bien + return + } + } + // Création du membre + await api.projects.createProjectMember(projectName, { + + role_id: accessLevel, + member_group: { + + group_name: groupName, + group_type: 3, + }, + }) +} diff --git a/.jjconflict-side-1/plugins/harbor/src/policy.ts b/.jjconflict-side-1/plugins/harbor/src/policy.ts new file mode 100644 index 0000000000..4945ad51cc --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/src/policy.ts @@ -0,0 +1,135 @@ +import { isValidCron } from 'cron-validator' +import { logger } from './logger.js' +import { getApi } from './utils.js' + +// https://github.com/goharbor/harbor/blob/main/src/server/v2.0/handler/retention.go +export type RuleTemplate + = | 'always' + | 'latestPulledK' + | 'latestPushedK' + | 'nDaysSinceLastPull' + | 'nDaysSinceLastPush' + +const allowed: RuleTemplate[] = [ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +] + +export const harborRuleTemplate: RuleTemplate = allowed.includes(process.env.HARBOR_RULE_TEMPLATE as RuleTemplate) + ? (process.env.HARBOR_RULE_TEMPLATE as RuleTemplate) + : 'latestPushedK' + +const countEnv = Number(process.env.HARBOR_RULE_COUNT) +export const harborRuleCount = !Number.isNaN(countEnv) && countEnv > 0 + ? countEnv + : harborRuleTemplate === 'always' + ? 1 + : 10 + +const defaultCron = '0 22 2 * * *' +const envCron = process.env.HARBOR_RETENTION_CRON?.trim() + +export const harborRetentionCron = envCron && isValidCron(envCron, { seconds: true }) + ? envCron + : defaultCron + +export interface TagSelector { + kind: 'doublestar' | 'label' + decoration: 'matches' | 'excludes' | 'repoMatches' | 'repoExcludes' + pattern: string +} + +export interface Rule { + disabled: boolean + action: 'retain' | 'delete' + template: RuleTemplate + params: Record + tag_selectors: TagSelector[] + scope_selectors: Record +} + +export interface Trigger { + kind: 'Schedule' + settings: { cron: string } + references: unknown[] +} + +export interface Policy { + algorithm: 'or' | 'and' + rules: Rule[] + trigger: Trigger + scope: { level: 'project', ref: number } +} + +export function makeDefaultPolicy(projectId: number): Policy { + return { + algorithm: 'or', + scope: { level: 'project', ref: projectId }, + rules: [ + { + disabled: false, + action: 'retain', + template: harborRuleTemplate, + params: { [harborRuleTemplate]: harborRuleCount }, + tag_selectors: [ + { kind: 'doublestar', decoration: 'matches', pattern: '**' }, + ], + scope_selectors: { + repository: [ + { kind: 'doublestar', decoration: 'repoMatches', pattern: '**' }, + ], + }, + }, + ], + trigger: { + kind: 'Schedule', + settings: { cron: harborRetentionCron }, + references: [], + }, + } +} + +export async function addRetentionPolicy( + projectName: string, + harborProjectId: number, +): Promise { + const api = getApi() + const ref = Number(harborProjectId) + if (Number.isNaN(ref)) throw new Error(`Invalid projectId: ${harborProjectId}`) + + const policy: Policy = makeDefaultPolicy(ref) + const project = await api.projects.getProject(projectName) + const retentionId = Number(project?.data?.metadata?.retention_id) + + try { + if (retentionId && !Number.isNaN(retentionId)) { + await api.retentions.updateRetention(retentionId, policy as unknown as any) + } else { + await api.retentions.createRetention(policy as unknown as any) + } + } catch (err: any) { + const payload = JSON.stringify(policy, null, 2) + const details = err?.response?.data ?? 'Unknown error' + logger.error( + { + action: 'addRetentionPolicy', + projectSlug: projectName, + projectName, + projectId: project?.data?.project_id, + retentionId, + payload, + details, + err, + }, + 'Failed to apply Harbor retention policy', + ) + throw new Error( + `Retention policy failed for project "${projectName}": ${ + typeof details === 'string' ? details : JSON.stringify(details) + }`, + ) + } +} diff --git a/.jjconflict-side-1/plugins/harbor/src/project.ts b/.jjconflict-side-1/plugins/harbor/src/project.ts new file mode 100644 index 0000000000..6b8a7a0e6e --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/src/project.ts @@ -0,0 +1,70 @@ +import type { Project as HarborProject, Quota } from './api/Api.js' +import { logger } from './logger.js' +import { getApi } from './utils.js' + +export async function createProject(projectName: string, storageLimit: number = -1): Promise { + logger.debug({ action: 'createProject', projectSlug: projectName, storageLimit }, 'Creating Harbor project if missing') + const api = getApi() + const existingProject = await api.projects.getProject(projectName, { + headers: { + 'X-Is-Resource-Name': true, + }, + validateStatus: () => true, + }) + + if (existingProject.status === 200) { + const projectId = existingProject.data.project_id as number + const refQuotas = await api.quotas.listQuotas({ + reference_id: String(projectId), + }) + const hardQuota = refQuotas.data.find(quota => quota.ref?.id === projectId) as Quota + + if (hardQuota.hard.storage !== storageLimit) { + await api.quotas.updateQuota(projectId, { + hard: { + storage: storageLimit, + }, + }) + } + return existingProject.data + } + + await api.projects.createProject({ + project_name: projectName, + metadata: { + auto_scan: 'true', + }, + storage_limit: storageLimit, + }, { + validateStatus: () => true, + }) + + const newProject = await api.projects.getProject(projectName) + return newProject.data +} + +async function removeRepositories(projectName: string) { + const api = getApi() + const repositories = await api.projects.listRepositories(projectName, { page_size: 100 }) + + for (const repo of repositories.data) { + // TODO mauvais swagger name existe forcément + const repoName = repo.name?.split('/').slice(1).join('/') as string + await api.projects.deleteRepository(projectName, repoName) + } + if (repositories.headers['x-total-count'] > repositories.data.length) { + await removeRepositories(projectName) + } +} + +export async function deleteProject(projectName: string) { + const api = getApi() + const project = await api.projects.getProject(projectName, { + validateStatus: status => [200, 404].includes(status), + }) + + if (project.status === 200) { + await removeRepositories(projectName) + await api.projects.deleteProject(projectName) + } +} diff --git a/.jjconflict-side-1/plugins/harbor/src/robot.ts b/.jjconflict-side-1/plugins/harbor/src/robot.ts new file mode 100644 index 0000000000..90c2ee23eb --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/src/robot.ts @@ -0,0 +1,133 @@ +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { Access, Robot, RobotCreated } from './api/Api.js' +import type { HarborApi } from './utils.js' +import { logger } from './logger.js' +import { getConfig, toVaultSecret } from './utils.js' + +// A project only ever owns a handful of robots (ro, rw and optionally the +// project robot — 3 in practice), so fetching a single page is enough; we never +// need to paginate the robot listing. +const ROBOT_LIST_PAGE_SIZE = 100 + +export interface VaultRobotSecret { + // {"auths":{"registry-host.com":{"auth":"","email":""}}}, + DOCKER_CONFIG: string + // registry-host.com, + HOST: string + TOKEN: string + // robot$+ + USERNAME: string +} + +export const getRobot = async (projectName: string, projectId: number, robotName: string, api: HarborApi): Promise => getRobotByName(projectId, `robot$${projectName}+${robotName}`, api) + +export async function ensureRobot(projectName: string, projectId: number, robotName: string, vaultApi: VaultProjectApi, access: Access[], api: HarborApi): Promise { + logger.debug({ action: 'ensureRobot', projectSlug: projectName, robotName }, 'Ensuring Harbor robot') + const vaultPath = `REGISTRY/${robotName}` + const robot = await getRobot(projectName, projectId, robotName, api) + const vaultRobotSecret = await vaultApi.read(vaultPath, { throwIfNoEntry: false }) as { data: VaultRobotSecret } | undefined + + let creds: VaultRobotSecret + // only regenerate robot if there is a change with the host + if (vaultRobotSecret?.data && vaultRobotSecret.data.HOST === getConfig().host) { + creds = vaultRobotSecret.data + } else if (robot) { + creds = toVaultSecret(await regenerateRobot(projectName, projectId, robotName, access, api) as Required) + await vaultApi.write(creds, vaultPath) + } else { + creds = toVaultSecret(await createRobot(projectName, robotName, access, api) as Required) + await vaultApi.write(creds, vaultPath) + } + return creds +} + +export async function deleteRobot(projectName: string, projectId: number, robotName: string, vaultApi: VaultProjectApi, api: HarborApi): Promise { + logger.info({ action: 'deleteRobot', projectSlug: projectName, robotName }, 'Deleting Harbor robot') + const vaultPath = `REGISTRY/${robotName}` + const robot = await getRobot(projectName, projectId, robotName, api) + if (robot?.id) { + api.robots.deleteRobot(robot.id) + } + const vaultRobotSecret = await vaultApi.read(vaultPath, { throwIfNoEntry: false }) as { data: VaultRobotSecret } | undefined + + if (vaultRobotSecret) { + await vaultApi.destroy(vaultPath) + } +} + +export async function createRobot(projectName: string, robotName: string, access: Access[], api: HarborApi) { + logger.info({ action: 'createRobot', projectSlug: projectName, robotName }, 'Creating Harbor robot') + return (await api.robots.createRobot(getRobotPermissions(projectName, robotName, access))).data +} + +export async function regenerateRobot(projectName: string, projectId: number, robotName: string, access: Access[], api: HarborApi): Promise { + logger.info({ action: 'regenerateRobot', projectSlug: projectName, robotName }, 'Regenerating Harbor robot') + const robot = await getRobot(projectName, projectId, robotName, api) + if (robot?.id) + await api.robots.deleteRobot(robot.id) + return createRobot(projectName, robotName, access, api) +} + +export async function getRobotByName(projectId: number, robotName: string, api: HarborApi): Promise { + logger.debug({ action: 'getRobotByName', projectId, robotName }, 'Listing Harbor robots to find matching name') + const listRobots = await api.robots.listRobot({ + q: `Level=project,ProjectID=${projectId}`, + page_size: ROBOT_LIST_PAGE_SIZE, + }) + return listRobots.data.find(({ name }) => name === robotName) +} + +function getRobotPermissions(projectName: string, robotName: string, access: Access[]) { + return { + name: robotName, + duration: -1, + description: 'robot for ci builds', + disable: false, + level: 'project', + permissions: [{ + namespace: projectName, + kind: 'project', + access, + }], + } +} + +export const roAccess: Access[] = [{ + resource: 'repository', + action: 'pull', +}, { + resource: 'artifact', + action: 'read', +}] + +export const rwAccess: Access[] = [...roAccess, { + resource: 'repository', + action: 'list', +}, { + resource: 'tag', + action: 'list', +}, { + resource: 'artifact', + action: 'list', +}, { + resource: 'scan', + action: 'create', +}, { + resource: 'scan', + action: 'stop', +}, { + resource: 'repository', + action: 'push', +}, { + resource: 'artifact-label', + action: 'create', +}, { + resource: 'artifact-label', + action: 'delete', +}, { + resource: 'tag', + action: 'create', +}, { + resource: 'tag', + action: 'delete', +}] diff --git a/.jjconflict-side-1/plugins/harbor/src/utils.ts b/.jjconflict-side-1/plugins/harbor/src/utils.ts new file mode 100644 index 0000000000..abb2b471ba --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/src/utils.ts @@ -0,0 +1,61 @@ +import type { RobotCreated } from './api/Api.js' +import type { VaultRobotSecret } from './robot.js' +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import { Api } from './api/Api.js' + +const config: { + url?: string + internalUrl?: string + host?: string +} = {} + +export function getConfig(): Required { + config.url = config.url ?? removeTrailingSlash(requiredEnv('HARBOR_URL')) + config.internalUrl = config.internalUrl ?? removeTrailingSlash(requiredEnv('HARBOR_INTERNAL_URL')) + config.host = config.host ?? config?.url?.split('://')[1] + // @ts-ignore + return config +} + +function getApiConfig() { + return { + auth: { + username: requiredEnv('HARBOR_ADMIN'), + password: requiredEnv('HARBOR_ADMIN_PASSWORD'), + }, + baseURL: `${getConfig().internalUrl}/api/v2.0/`, + } +} + +let api: Api> | undefined + +export type HarborApi = Api> +export function getApi(): HarborApi { + if (!api) { + api = new Api(getApiConfig()) + } + return api +} + +export function toVaultSecret(robot: Required): VaultRobotSecret { + const auth = `${robot.name}:${robot.secret}` + const buff = Buffer.from(auth) + const b64auth = buff.toString('base64') + return { + DOCKER_CONFIG: JSON.stringify({ + auths: { + [getConfig().host]: { + auth: b64auth, + email: '', + }, + }, + }), + HOST: getConfig().host, + TOKEN: robot.secret, + USERNAME: robot.name, + } +} + +export const rwRobotName = 'rw-robot' +export const roRobotName = 'ro-robot' +export const projectRobotName = 'project-robot' diff --git a/.jjconflict-side-1/plugins/harbor/tsconfig.eslint.json b/.jjconflict-side-1/plugins/harbor/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-1/plugins/harbor/tsconfig.json b/.jjconflict-side-1/plugins/harbor/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-1/plugins/harbor/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-1/plugins/keycloak/.gitignore b/.jjconflict-side-1/plugins/keycloak/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-1/plugins/keycloak/eslint.config.js b/.jjconflict-side-1/plugins/keycloak/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-1/plugins/keycloak/package.json b/.jjconflict-side-1/plugins/keycloak/package.json new file mode 100644 index 0000000000..d0cb9e3ece --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/package.json @@ -0,0 +1,49 @@ +{ + "name": "@cpn-console/keycloak-plugin", + "type": "module", + "version": "2.1.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@keycloak/keycloak-admin-client": "catalog:runtime", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/plugins/keycloak/src/class.ts b/.jjconflict-side-1/plugins/keycloak/src/class.ts new file mode 100644 index 0000000000..36f3dc045e --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/src/class.ts @@ -0,0 +1,33 @@ +import { PluginApi } from '@cpn-console/hooks' +import { consoleGroupName } from './group.js' + +interface KeycloakEnv { + path: string + subgroups: { // Key is the group name, value is the full path + RO: string + RW: string + } +} + +export class KeycloakProjectApi extends PluginApi { + private readonly projectSlug: string + + constructor(projectSlug: string) { + super() + this.projectSlug = projectSlug + } + + public async getProjectGroupPath(): Promise { + return `/${this.projectSlug}` + } + + public async getEnvGroup(environment: string): Promise { + return { + path: `/${this.projectSlug}/${consoleGroupName}/${environment}`, + subgroups: { + RO: `/${this.projectSlug}/${consoleGroupName}/${environment}/RO`, + RW: `/${this.projectSlug}/${consoleGroupName}/${environment}/RW`, + }, + } + } +} diff --git a/.jjconflict-side-1/plugins/keycloak/src/client.ts b/.jjconflict-side-1/plugins/keycloak/src/client.ts new file mode 100644 index 0000000000..484dc8789a --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/src/client.ts @@ -0,0 +1,28 @@ +import KcAdminClient from '@keycloak/keycloak-admin-client' +import getConfig from './config.js' +import { logger } from './logger.js' + +export async function getkcClient() { + const kcClient = new KcAdminClient({ + baseUrl: getConfig().url, + }) + + await kcClient.auth({ + clientId: 'admin-cli', + grantType: 'password', + username: process.env.KEYCLOAK_ADMIN, + password: process.env.KEYCLOAK_ADMIN_PASSWORD, + }) + kcClient.setConfig({ realmName: getConfig().realm }) + return kcClient +} + +export function start() { + getkcClient().catch((error) => { + logger.error({ err: error }, 'Failed to start Keycloak plugin') + if (process.env.IGNORE_PLUGINS_START_FAIL?.includes('keycloak')) { + return + } + throw new Error('failed to start keycloak plugin') + }) +} diff --git a/.jjconflict-side-1/plugins/keycloak/src/config.ts b/.jjconflict-side-1/plugins/keycloak/src/config.ts new file mode 100644 index 0000000000..d249e99b5b --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/src/config.ts @@ -0,0 +1,23 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + url: string + realm: string + + constructor() { + this.url = process.env.KEYCLOAK_INTERNAL_URL + ? removeTrailingSlash(process.env.KEYCLOAK_INTERNAL_URL) + : `${requiredEnv('KEYCLOAK_PROTOCOL')}://${requiredEnv('KEYCLOAK_DOMAIN')}` + this.realm = requiredEnv('KEYCLOAK_REALM') + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-side-1/plugins/keycloak/src/env.d.ts b/.jjconflict-side-1/plugins/keycloak/src/env.d.ts new file mode 100644 index 0000000000..81cd034697 --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/.jjconflict-side-1/plugins/keycloak/src/functions.ts b/.jjconflict-side-1/plugins/keycloak/src/functions.ts new file mode 100644 index 0000000000..9bf08d971d --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/src/functions.ts @@ -0,0 +1,478 @@ +import type { AdminRole, Project, ProjectMember, StepCall, UserEmail, ZoneObject } from '@cpn-console/hooks' +import type { ProjectRole } from '@cpn-console/shared' +import type ClientRepresentation from '@keycloak/keycloak-admin-client/lib/defs/clientRepresentation.js' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js' +import type { CustomGroup } from './group.js' +import { generateRandomPassword, PluginResultBuilder } from '@cpn-console/hooks' +import { isExternalRoleType } from '@cpn-console/shared' +import { getkcClient } from './client.js' +import { consoleGroupName, deleteGroup, getAllSubgroups, getGroupByName, getOrCreateChildGroup, getOrCreateGroupByPath, getOrCreateProjectGroup } from './group.js' +import { logger } from './logger.js' + +export const retrieveKeycloakUserByEmail: StepCall = async ({ args: { email } }) => { + const kcClient = await getkcClient() + try { + const user = (await kcClient.users.find({ email }))[0] + logger.debug({ action: 'retrieveKeycloakUserByEmail', found: Boolean(user) }, 'Hook done') + + return { + status: { result: 'OK' }, + user, + } + } catch (error) { + logger.error({ action: 'retrieveKeycloakUserByEmail', err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const deleteProject: StepCall = async ({ args: project }) => { + const projectSlug = project.slug + try { + const kcClient = await getkcClient() + const group = await getGroupByName(kcClient, projectSlug) + if (group?.id) { + await kcClient.groups.del({ id: group.id }) + logger.info({ action: 'deleteProject', projectSlug, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + logger.info({ action: 'deleteProject', projectSlug, outcome: 'already-missing' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already Missing', + }, + } + } catch (error) { + logger.error({ action: 'deleteProject', projectSlug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const upsertProject: StepCall = async ({ args: project }) => { + const projectSlug = project.slug + const pluginResult = new PluginResultBuilder('Up-to-date') + try { + const kcClient = await getkcClient() + const projectGroup = await getOrCreateProjectGroup(kcClient, projectSlug) + + const groupMembers = await kcClient.groups.listMembers({ id: projectGroup.id }) + + await Promise.all([ + ...groupMembers.map((member, index) => { + if (!project.users.some(({ id }) => id === member.id)) { + return kcClient.users.delFromGroup({ + // @ts-ignore id is present on user, bad typing in lib + id: member.id, + groupId: projectGroup.id, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak project group') + pluginResult.addExtra(`remove-${index}`, err) + }) + } + return undefined + }), + ...project.users.map((user, index) => { + if (!groupMembers.some(({ id }) => id === user.id)) { + return kcClient.users.addToGroup({ + id: user.id, + groupId: projectGroup.id, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t add user to keycloak project group') + pluginResult.addExtra(`add-${index}`, err) + }) + } + return undefined + }), + ]) + + // Ensure envs subgroups exists + const projectGroups = await getAllSubgroups(kcClient, projectGroup.id, 0) + + const consoleGroup: Required = projectGroups.find(({ name }) => name === consoleGroupName) as Required + ?? await getOrCreateChildGroup(kcClient, projectGroup.id, consoleGroupName) as Required + + const envGroups = await getAllSubgroups(kcClient, consoleGroup.id, 0) as CustomGroup[] + + const promises: Promise[] = [] + for (const environment of project.environments) { + const envGroup: Required = envGroups.find(group => group.name === environment.name) as Required + ?? await getOrCreateChildGroup(kcClient, consoleGroup.id, environment.name) + + const [roGroup, rwGroup] = await Promise.all([ + getOrCreateChildGroup(kcClient, envGroup.id, 'RO'), + getOrCreateChildGroup(kcClient, envGroup.id, 'RW'), + ]) + + // Ensure envs permissions membership exists + for (const permission of environment.permissions) { + if (permission.permissions.ro) { + promises.push(kcClient.users.addToGroup({ id: permission.userId, groupId: roGroup.id })) + } else { + promises.push(kcClient.users.delFromGroup({ id: permission.userId, groupId: roGroup.id })) + } + if (permission.permissions.rw) { + promises.push(kcClient.users.addToGroup({ id: permission.userId, groupId: rwGroup.id })) + } else { + promises.push(kcClient.users.delFromGroup({ id: permission.userId, groupId: rwGroup.id })) + } + } + } + + await Promise.all(promises) + + const envGroupIdsToDelete = envGroups + .filter(subGroup => !project.environments.some(({ name }) => name === subGroup.name)) + .map(subGroup => subGroup.id) + .filter((id): id is string => !!id) + if (envGroupIdsToDelete.length) { + await Promise.all(envGroupIdsToDelete.map(id => kcClient.groups.del({ id }))) + } + + logger.info({ action: 'upsertProject', projectSlug }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const upsertZone: StepCall = async ({ args: zone, apis }) => { + const zoneSlug = zone.slug + try { + const kcClient = await getkcClient() + const argocdUrl = zone.argocdUrl + const clientId = getClientZoneId(zone) + const client: ClientRepresentation = { + clientId, + clientAuthenticatorType: 'client-secret', + protocol: 'openid-connect', + publicClient: false, + defaultClientScopes: ['generic'], + redirectUris: [`${argocdUrl}/auth/callback`], + webOrigins: [argocdUrl], + rootUrl: argocdUrl, + adminUrl: argocdUrl, + baseUrl: '/applications', + } + const result = await kcClient.clients.find({ clientId, max: 1 }) + let outcome: 'updated' | 'created' + if (result.length > 0 && result[0].id) { + await kcClient.clients.update({ id: result[0].id }, client) + outcome = 'updated' + } else { + const password = generateRandomPassword(30) + await apis.vault.write({ clientSecret: password }, 'keycloak') + await kcClient.clients.create({ + secret: password, + ...client, + }) + outcome = 'created' + } + logger.info({ action: 'upsertZone', zoneSlug, clientId, outcome }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'upsertZone', zoneSlug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +export const deleteZone: StepCall = async ({ args: zone }) => { + const zoneSlug = zone.slug + try { + const kcClient = await getkcClient() + const clientId = getClientZoneId(zone) + const result = await kcClient.clients.find({ clientId, max: 1 }) + if (result.length > 0 && result[0].id) { + await kcClient.clients.del({ id: result[0].id }) + logger.info({ action: 'deleteZone', zoneSlug, clientId, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + logger.info({ action: 'deleteZone', zoneSlug, clientId, outcome: 'already-missing' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already Missing', + }, + } + } catch (error) { + logger.error({ action: 'deleteZone', zoneSlug, clientId: getClientZoneId(zone), err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const upsertAdminRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) return { status: { result: 'OK', message: 'No OIDC Group defined' } } + if (isExternalRoleType(role.type)) return { status: { result: 'OK', message: 'Skipped (external system role)' } } + const pluginResult = new PluginResultBuilder('Up-to-date') + try { + const kcClient = await getkcClient() + const group = await getOrCreateGroupByPath(kcClient, role.oidcGroup) + const groupMembers = await kcClient.groups.listMembers({ id: group.id }) + + await Promise.all([ + ...groupMembers.map((member, index) => { + if (member.id && !role.members.some(({ id }) => id === member.id)) { + return kcClient.users.delFromGroup({ + id: member.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak admin group') + pluginResult.addExtra(`remove-${index}`, err) + }) + } + return undefined + }), + ...role.members.map((user, index) => { + if (!groupMembers.some(({ id }) => id === user.id)) { + return kcClient.users.addToGroup({ + id: user.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t add user to keycloak admin group') + pluginResult.addExtra(`add-${index}`, err) + }) + } + return undefined + }), + ]) + + logger.info({ action: 'upsertAdminRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertAdminRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const deleteAdminRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) return { status: { result: 'OK', message: 'No OIDC Group defined' } } + if (isExternalRoleType(role.type)) return { status: { result: 'OK', message: 'Skipped (external system role)' } } + const pluginResult = new PluginResultBuilder('Deleted') + try { + const kcClient = await getkcClient() + let group: GroupRepresentation | undefined + if (role.oidcGroup.startsWith('/')) { + const name = role.oidcGroup.split('/').pop() || '' + const groups = await kcClient.groups.find({ search: name }) + group = groups.find(g => g.path === role.oidcGroup) + } else { + const groupOrVoid = await getGroupByName(kcClient, role.oidcGroup) + group = groupOrVoid || undefined + } + + if (group?.id) { + await Promise.all(role.members.map((user, index) => { + return kcClient.users.delFromGroup({ + id: user.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak admin group') + pluginResult.addExtra(`remove-${index}`, err) + }) + })) + } + logger.info({ action: 'deleteAdminRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'deleteAdminRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const upsertProjectRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) { + return { + status: { + result: 'OK', + message: 'No OIDC group defined', + }, + } + } + try { + const kcClient = await getkcClient() + await getOrCreateGroupByPath(kcClient, role.oidcGroup) + logger.info({ action: 'upsertProjectRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Synced', + }, + } + } catch (error) { + logger.error({ action: 'upsertProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed to sync role', + }, + } + } +} + +export const deleteProjectRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) { + return { + status: { + result: 'OK', + message: 'No OIDC group defined', + }, + } + } + try { + const kcClient = await getkcClient() + const [projectName, pluginName, roleName] = role.oidcGroup.split('/').slice(1) + if (!projectName || !pluginName || !roleName) throw new Error('Invalid OIDC group format') + const projectGroup = await getGroupByName(kcClient, projectName) + if (projectGroup?.id) { + const pluginGroups = await getAllSubgroups(kcClient, projectGroup.id, 0) + const pluginGroup = pluginGroups.find(({ name }) => name === pluginName) as Required | undefined + if (pluginGroup?.id) { + const roleGroups = await getAllSubgroups(kcClient, pluginGroup.id, 0) + const roleGroup = roleGroups.find(({ name }) => name === roleName) as Required | undefined + if (roleGroup?.id) { + await deleteGroup(kcClient, roleGroup.id) + logger.info({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + } + } + logger.info({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, outcome: 'already-deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already deleted', + }, + } + } catch (error) { + logger.error({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed to delete role', + }, + } + } +} + +export const upsertProjectMember: StepCall = async ({ args: member }) => { + const pluginResult = new PluginResultBuilder('Synced') + try { + const kcClient = await getkcClient() + + const projectGroup = await getOrCreateProjectGroup(kcClient, member.project.slug) + const consoleGroup = await getOrCreateChildGroup(kcClient, projectGroup.id, consoleGroupName) + const allRoleGroups = await getAllSubgroups(kcClient, consoleGroup.id, 0) + const userGroups = await kcClient.users.listGroups({ id: member.userId }) + + const userRolesOidcGroups = member.roles + .map(r => r.oidcGroup) + .filter((g): g is string => !!g) + + // Sync Roles + let groupMembershipChanges = 0 + for (const roleGroup of allRoleGroups) { + if (!roleGroup.id || !roleGroup.path) continue + const isMember = userGroups.some(ug => ug.id === roleGroup.id) + const shouldBeMember = userRolesOidcGroups.includes(roleGroup.path) + + if (shouldBeMember && !isMember) { + groupMembershipChanges += 1 + await kcClient.users.addToGroup({ id: member.userId, groupId: roleGroup.id }) + } else if (!shouldBeMember && isMember) { + groupMembershipChanges += 1 + await kcClient.users.delFromGroup({ id: member.userId, groupId: roleGroup.id }) + } + } + + logger.info({ action: 'upsertProjectMember', projectSlug: member.project.slug, groupMembershipChanges }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertProjectMember', projectSlug: member.project.slug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const deleteProjectMember: StepCall = async ({ args: member }) => { + const pluginResult = new PluginResultBuilder('Deleted') + try { + const kcClient = await getkcClient() + if (!member.userId) return pluginResult.getResultObject() + + const projectGroup = await getGroupByName(kcClient, member.project.slug) + if (!projectGroup?.id) return pluginResult.getResultObject() + + const userGroups = await kcClient.users.listGroups({ id: member.userId }) + const projectGroups = userGroups.filter(g => g.path?.startsWith(projectGroup.path!)) + + let removedCount = 0 + for (const group of projectGroups) { + if (group.id) { + await kcClient.users.delFromGroup({ id: member.userId, groupId: group.id }) + removedCount += 1 + } + } + + logger.info({ action: 'deleteProjectMember', projectSlug: member.project.slug, removedCount }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'deleteProjectMember', projectSlug: member.project.slug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +function getClientZoneId(zone: ZoneObject): string { + return `argocd-${zone.slug}-zone` +} diff --git a/.jjconflict-side-1/plugins/keycloak/src/group.ts b/.jjconflict-side-1/plugins/keycloak/src/group.ts new file mode 100644 index 0000000000..09c4cdc077 --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/src/group.ts @@ -0,0 +1,123 @@ +import type KeycloakAdminClient from '@keycloak/keycloak-admin-client' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js' + +export const consoleGroupName = 'console' + +export type CustomGroup = Required> +export async function getGroupByName(kcClient: KeycloakAdminClient, name: string): Promise { + const groupSearch = await kcClient.groups.find({ search: name }) + return groupSearch.find(grp => grp.name === name) +} + +export async function getAllSubgroups(kcClient: KeycloakAdminClient, parentId: string, first: number, subgroups: GroupRepresentation[] = []): Promise { + const newSubgroups = [ + ...subgroups, + ...await kcClient.groups.listSubGroups({ parentId, briefRepresentation: false, max: 10, first }), + ] + if (newSubgroups.length - subgroups.length === 10) { + return getAllSubgroups(kcClient, parentId, first + 10, newSubgroups) + } + return newSubgroups +} + +export async function getOrCreateChildGroup(kcClient: KeycloakAdminClient, parentId: string, name: string, subGroups: GroupRepresentation[] | undefined = []): Promise { + if (Array.isArray(subGroups) && subGroups.length > 0) { + const matchingGroup = subGroups.find(({ name: groupName }) => groupName === name) as Required | undefined + if (matchingGroup) { + return { + id: matchingGroup.id, + subGroups: matchingGroup.subGroups || [], + subGroupCount: matchingGroup.subGroups?.length || 0, + name, + } + } + } + subGroups = await getAllSubgroups(kcClient, parentId, 0) + + const matchingGroup = subGroups?.find(({ name: groupName }) => groupName === name) as Required | undefined + if (!matchingGroup) { + const newGroup = await kcClient.groups.createChildGroup({ id: parentId }, { name }) + return { + id: newGroup.id, + subGroups: [], + subGroupCount: 0, + name, + } + } + return { + id: matchingGroup.id, + subGroups: matchingGroup.subGroups || [], + subGroupCount: matchingGroup.subGroups?.length || 0, + name, + } +} + +export async function getOrCreateProjectGroup(kcClient: KeycloakAdminClient, name: string): Promise> & { subGroups: Required[] }> { + const existingGroup = await getGroupByName(kcClient, name) as Required + if (!existingGroup) { + const newGroup = await kcClient.groups.create({ name }) + return { + id: newGroup.id, + subGroups: [], + subGroupCount: 0, + name, + } + } + return { + id: existingGroup.id, + // @ts-ignore mauvais typage de librairie les subGroups ont forcément un id + subGroups: existingGroup.subGroups || [], + subGroupCount: existingGroup.subGroups?.length || 0, + name: existingGroup.name, + } +} + +export async function getOrCreateGroupByName(kcClient: KeycloakAdminClient, name: string): Promise> { + const group = await getGroupByName(kcClient, name) + if (group) return group as Required + const newGroup = await kcClient.groups.create({ name }) + const created = await kcClient.groups.findOne({ id: newGroup.id }) + if (!created) throw new Error(`Failed to retrieve created group: ${name}`) + return created as Required +} + +export async function getOrCreateGroupByPath(kcClient: KeycloakAdminClient, path: string): Promise> { + if (!path.startsWith('/')) { + return getOrCreateGroupByName(kcClient, path) + } + + const name = path.split('/').pop() || '' + const groups = await kcClient.groups.find({ search: name }) + const existingGroup = groups.find(g => g.path === path) + + if (existingGroup) return existingGroup as Required + + const groupNames = path.split('/').filter(Boolean) + let parentId: string | undefined + + for (const groupName of groupNames) { + const subGroups = parentId + ? await kcClient.groups.listSubGroups({ parentId }) + : await kcClient.groups.find({ search: groupName }) + const existingSubGroup = subGroups.find(g => g.name === groupName) + + if (existingSubGroup) { + parentId = existingSubGroup.id + } else { + const newGroup = parentId + ? await kcClient.groups.createChildGroup({ id: parentId }, { name: groupName }) + : await kcClient.groups.create({ name: groupName }) + parentId = newGroup.id + } + } + + if (parentId) { + const group = await kcClient.groups.findOne({ id: parentId }) + if (group) return group as Required + } + throw new Error(`Failed to create group path: ${path}`) +} + +export async function deleteGroup(kcClient: KeycloakAdminClient, groupId: string) { + await kcClient.groups.del({ id: groupId }) +} diff --git a/.jjconflict-side-1/plugins/keycloak/src/index.ts b/.jjconflict-side-1/plugins/keycloak/src/index.ts new file mode 100644 index 0000000000..a5fda76e1f --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/src/index.ts @@ -0,0 +1,67 @@ +import type { DefaultArgs, Plugin, Project, ProjectLite } from '@cpn-console/hooks' +import { KeycloakProjectApi } from './class.js' +import { start } from './client.js' +import { + deleteAdminRole, + deleteProject, + deleteProjectMember, + deleteProjectRole, + deleteZone, + retrieveKeycloakUserByEmail, + upsertAdminRole, + upsertProject, + upsertProjectMember, + upsertProjectRole, + upsertZone, +} from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + deleteProject: { + api: project => new KeycloakProjectApi(project.slug), + steps: { post: deleteProject }, + }, + upsertProject: { + api: project => new KeycloakProjectApi(project.slug), + steps: { main: upsertProject }, + }, + upsertProjectRole: { + steps: { main: upsertProjectRole }, + }, + upsertProjectMember: { + steps: { main: upsertProjectMember }, + }, + deleteProjectMember: { + steps: { post: deleteProjectMember }, + }, + upsertZone: { + steps: { main: upsertZone }, + }, + deleteZone: { + steps: { post: deleteZone }, + }, + deleteProjectRole: { + steps: { post: deleteProjectRole }, + }, + retrieveUserByEmail: { steps: { main: retrieveKeycloakUserByEmail } }, + upsertAdminRole: { + steps: { main: upsertAdminRole }, + }, + deleteAdminRole: { + steps: { post: deleteAdminRole }, + }, + }, + monitor, + start, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + keycloak: Args extends (ProjectLite | Project) + ? KeycloakProjectApi + : undefined + } +} diff --git a/.jjconflict-side-1/plugins/keycloak/src/infos.ts b/.jjconflict-side-1/plugins/keycloak/src/infos.ts new file mode 100644 index 0000000000..3b3980194f --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/src/infos.ts @@ -0,0 +1,12 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos: ServiceInfos = { + name: 'keycloak', + title: 'Keycloak', + config: { + global: [], + project: [], + }, +} + +export default infos diff --git a/.jjconflict-side-1/plugins/keycloak/src/logger.ts b/.jjconflict-side-1/plugins/keycloak/src/logger.ts new file mode 100644 index 0000000000..1578c26c49 --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'keycloak' }) diff --git a/.jjconflict-side-1/plugins/keycloak/src/monitor.ts b/.jjconflict-side-1/plugins/keycloak/src/monitor.ts new file mode 100644 index 0000000000..4f1df8d648 --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/src/monitor.ts @@ -0,0 +1,27 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(requiredEnv('KEYCLOAK_URL'), { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-1/plugins/keycloak/tsconfig.eslint.json b/.jjconflict-side-1/plugins/keycloak/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-1/plugins/keycloak/tsconfig.json b/.jjconflict-side-1/plugins/keycloak/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-1/plugins/keycloak/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-1/plugins/nexus/.gitignore b/.jjconflict-side-1/plugins/nexus/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-1/plugins/nexus/README.md b/.jjconflict-side-1/plugins/nexus/README.md new file mode 100644 index 0000000000..a8ee9f797a --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/README.md @@ -0,0 +1,13 @@ +# Introduction + +Ce plugin permet de déployer des dépôts à la demande sur nexus. Les seules technologies prisent en charge actuellement sont Maven et NPM. + +## Configuration + +| Env var | valeur possible | description | +| ---------------------- | --------------- | --------------------------------------------------------------------------------------------- | +| NEXUS_ADMIN | chaine | Nom d'utilisateur admin Nexus | +| NEXUS_ADMIN_PASSWORD | chaine | Mot de passe Nexus | +| NEXUS_URL | *url* | Url public d'accés au Nexus | +| NEXUS_INTERNAL_URL | *url* ou vide | url par laquelle la console interroge le service Nexus, si absent utilisation de l'url public | +| NEXUS__SECRET_EXPOSE_INTERNAL_URL | "true" ou vide | Exposition ou non de l'url interne dans la remontée des secrets du projet | diff --git a/.jjconflict-side-1/plugins/nexus/eslint.config.js b/.jjconflict-side-1/plugins/nexus/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-1/plugins/nexus/package.json b/.jjconflict-side-1/plugins/nexus/package.json new file mode 100644 index 0000000000..0a758fd7d9 --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/package.json @@ -0,0 +1,49 @@ +{ + "name": "@cpn-console/nexus-plugin", + "type": "module", + "version": "2.3.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/plugins/nexus/src/config.ts b/.jjconflict-side-1/plugins/nexus/src/config.ts new file mode 100644 index 0000000000..aecdc37956 --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/src/config.ts @@ -0,0 +1,29 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + secretExposedUrl: string + user: string + password: string + + constructor() { + this.password = requiredEnv('NEXUS_ADMIN_PASSWORD') + this.user = requiredEnv('NEXUS_ADMIN') + this.publicUrl = removeTrailingSlash(requiredEnv('NEXUS_URL')) + this.internalUrl = process.env.NEXUS_INTERNAL_URL + ? removeTrailingSlash(process.env.NEXUS_INTERNAL_URL) + : this.publicUrl + this.secretExposedUrl = process.env.NEXUS__SECRET_EXPOSE_INTERNAL_URL === 'true' ? this.internalUrl : this.publicUrl + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-side-1/plugins/nexus/src/env.d.ts b/.jjconflict-side-1/plugins/nexus/src/env.d.ts new file mode 100644 index 0000000000..b34e01d65a --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-side-1/plugins/nexus/src/functions.ts b/.jjconflict-side-1/plugins/nexus/src/functions.ts new file mode 100644 index 0000000000..5841f1c018 --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/src/functions.ts @@ -0,0 +1,28 @@ +import getConfig from './config.js' + +let axiosOptions: { + baseURL: string + auth: { + username: string + password: string + } + headers: { + Accept: string + } +} + +export function getAxiosOptions(): Required { + if (!axiosOptions) { + axiosOptions = { + baseURL: `${getConfig().internalUrl}/service/rest/v1/`, + auth: { + username: getConfig().user, + password: getConfig().password, + }, + headers: { + Accept: 'application/json', + }, + } + } + return axiosOptions +} diff --git a/.jjconflict-side-1/plugins/nexus/src/index.ts b/.jjconflict-side-1/plugins/nexus/src/index.ts new file mode 100644 index 0000000000..3457e0c021 --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/src/index.ts @@ -0,0 +1,19 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import infos from './infos.js' +import monitor from './monitor.js' +import { createNexusProject, deleteNexusProject, getSecrets } from './project.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { steps: { main: createNexusProject } }, + deleteProject: { steps: { main: deleteNexusProject } }, + getProjectSecrets: { steps: { main: getSecrets } }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-side-1/plugins/nexus/src/infos.ts b/.jjconflict-side-1/plugins/nexus/src/infos.ts new file mode 100644 index 0000000000..83abdb71aa --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/src/infos.ts @@ -0,0 +1,116 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos = { + name: 'nexus', + title: 'Nexus', + imgSrc: '/img/nexus.png', + description: 'Nexus permet de gérer les binaires et artefacts de build à travers la chaîne logistique logicielle', + config: { + project: [ + { + key: 'activateNpmRepo', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt NPM', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'npmWritePolicy', + section: 'NPM', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture', + value: 'allow', + description: 'Politique d\'écriture des dépôts NPM, valeurs possibles: allow / allow_once, deny, replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'activateMavenRepo', + section: 'Maven', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt Maven', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'mavenSnapshotWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Snapshot', + value: 'allow', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'mavenReleaseWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Release', + value: 'allow_once', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + ], + global: [ + { + key: 'activateNpmRepoDefaultValue', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Créer un dépôt NPM privé (comportement par défaut des projets)', + value: 'disabled', + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'activateMavenRepoDefaultValue', + section: 'Maven', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Créer un dépôt MAVEN privé (comportement par défaut des projets)', + value: 'disabled', + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'enablePlugin', + kind: 'switch', + initialValue: 'enabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Activer/Désactiver entièrement le plugin Nexus', + value: 'enabled', + description: 'Défaut: Activé', + }, + ], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-side-1/plugins/nexus/src/logger.ts b/.jjconflict-side-1/plugins/nexus/src/logger.ts new file mode 100644 index 0000000000..952b553155 --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'nexus' }) diff --git a/.jjconflict-side-1/plugins/nexus/src/maven.ts b/.jjconflict-side-1/plugins/nexus/src/maven.ts new file mode 100644 index 0000000000..4fea9e16cc --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/src/maven.ts @@ -0,0 +1,193 @@ +import type { AxiosInstance } from 'axios' +import type { WritePolicy } from './utils.js' +import getConfig from './config.js' +import { deleteIfExists } from './utils.js' + +// Retro-compatibilty, maven is a special case with bad name formats +function getRepoNames(projectName: string) { // Unique function per language cause names are unique per repo + return { + hosted: [ + { + repo: `${projectName}-repository-release`, + privilege: `${projectName}-privilege-release`, + }, + { + repo: `${projectName}-repository-snapshot`, + privilege: `${projectName}-privilege-snapshot`, + }, + ], + group: { + repo: `${projectName}-repository-group`, + privilege: `${projectName}-privilege-group`, + }, + } +} + +async function provisionMavenHosted(axiosInstance: AxiosInstance, repoName: string, writePolicy: WritePolicy) { + const repo = await axiosInstance({ + method: 'GET', + url: `/repositories/maven/hosted/${repoName}`, + validateStatus: code => [200, 404].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/maven/hosted', + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/maven/hosted/${repoName}`, + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [204].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/maven/hosted', + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/maven/hosted/${repoName}`, + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [204].includes(code), + }) + } + } +} +interface MavenOptions { + snapshotWritePolicy: WritePolicy + releaseWritePolicy: WritePolicy +} +export async function createMavenRepo(axiosInstance: AxiosInstance, projectName: string, options: MavenOptions) { + const names = getRepoNames(projectName) + + // create local repo maven + await Promise.all([ + provisionMavenHosted(axiosInstance, names.hosted[0].repo, options.releaseWritePolicy), + provisionMavenHosted(axiosInstance, names.hosted[1].repo, options.snapshotWritePolicy), + ]) + + // create maven group + await axiosInstance({ + method: 'post', + url: '/repositories/maven/group', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + 'maven-public', + ], + }, + }, + validateStatus: code => [201, 400].includes(code), + }) + + // create privileges + for (const name of [...names.hosted, names.group]) { + await axiosInstance({ + method: 'post', + url: '/security/privileges/repository-view', + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'maven2', + repository: name.repo, + }, + validateStatus: code => [201, 400].includes(code), + }) + } + return names +} + +export async function deleteMavenRepo(axiosInstance: AxiosInstance, projectName: string) { + const names = getRepoNames(projectName) + const repoPaths = [names.group, ...names.hosted] + const privileges = [...names.hosted, names.group] + const pathsToDelete = [ + // delete privileges + ...privileges.map(({ privilege }) => `/security/privileges/${privilege}`), + // delete local repo maven snapshot + ...repoPaths.map(repo => `/repositories/${repo.repo}`), + ] + for (const path of pathsToDelete) { + await deleteIfExists(path, axiosInstance) + } +} + +export function getMavenUrls(projectName: string) { + const nexusUrl = getConfig().secretExposedUrl + const names = getRepoNames(projectName) + return { + MAVEN_REPO_RELEASE: `${nexusUrl}/${names.hosted[0].repo}`, + MAVEN_REPO_SNAPSHOT: `${nexusUrl}/${names.hosted[1].repo}`, + } +} diff --git a/.jjconflict-side-1/plugins/nexus/src/monitor.ts b/.jjconflict-side-1/plugins/nexus/src/monitor.ts new file mode 100644 index 0000000000..a79babebeb --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/src/monitor.ts @@ -0,0 +1,79 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import { getAxiosOptions } from './functions.js' + +const coreComponents = [ + 'Blob Stores Ready', + 'File Blob Stores Path', +] +const auxComponents = [ + 'Available CPUs', + 'Blob Stores Quota', + 'Blob Stores Ready', + 'Default Admin Credentials', + 'DefaultRoleRealm', + 'File Blob Stores Path', + 'NuGet V2 repositories', + 'Scheduler', + 'com.codahale.metrics.health.jvm.ThreadDeadlockHealthCheck', + 'Node Health Check', + 'Re-encryption required', + 'Default Admin Credentials', + 'Recent version', +] + +type NexusRes = Record + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const status = await axios.get('/status', { + validateStatus: () => true, + ...getAxiosOptions(), + }) + const statusCheck = await axios.get('/status/check', { + validateStatus: res => [200, 503].includes(res), + ...getAxiosOptions(), + }) + const statusWritable = await axios.get('/status/writable', { + validateStatus: res => [200, 503].includes(res), + ...getAxiosOptions(), + }) + if (status.status === 503 || statusWritable.status === 503) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Nexus semble planté' + return instance.lastStatus + } + if (statusCheck.status === 200) { + const data = statusCheck.data as NexusRes + + const failedCoreComponents = coreComponents.filter(component => !data[component]?.healthy) + const failedAuxComponents = auxComponents.filter(component => !data[component]?.healthy) + if (failedAuxComponents.length || failedCoreComponents.length) { + instance.lastStatus.status = failedCoreComponents.length + ? MonitorStatus.ERROR + : MonitorStatus.WARNING + instance.lastStatus.message = failedCoreComponents.length + ? 'Des composants critiques sont en erreur' + : 'Le service est partiellement dégradé' + + instance.lastStatus.cause = `Les composants suivants sont en erreurs: ${[...failedCoreComponents, ...failedAuxComponents].join(', ')}` + return instance.lastStatus + } + } + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-1/plugins/nexus/src/npm.ts b/.jjconflict-side-1/plugins/nexus/src/npm.ts new file mode 100644 index 0000000000..02bc3573f0 --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/src/npm.ts @@ -0,0 +1,167 @@ +import type { AxiosInstance } from 'axios' +import type { WritePolicy } from './utils.js' +import getConfig from './config.js' +import { deleteIfExists } from './utils.js' + +function getRepoNames(projectName: string) { // Unique function per language cause names are unique per repo + return { + hosted: [ + { + repo: `${projectName}-npm`, + privilege: `${projectName}-npm-privilege`, + }, + ], + group: { + repo: `${projectName}-npm-group`, + privilege: `${projectName}-npm-group-privilege`, + }, + } +} +export async function createNpmRepo(axiosInstance: AxiosInstance, projectName: string, writePolicy: WritePolicy) { + const names = getRepoNames(projectName) + // create local repo maven + for (const repVersion of names.hosted) { + const repo = await axiosInstance({ + method: 'GET', + url: `/repositories/npm/hosted/${repVersion.repo}`, + validateStatus: code => [200, 404].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/npm/hosted', + data: { + name: repVersion.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/npm/hosted/${repVersion.repo}`, + data: { + name: repVersion.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + }, + validateStatus: code => [204].includes(code), + }) + } + } + // create maven group + const group = await axiosInstance({ + method: 'get', + url: `/repositories/npm/group/${names.group.repo}`, + validateStatus: code => [200, 404].includes(code), + }) + if (group.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/npm/group', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + ], + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + url: `/repositories/npm/group/${names.group.repo}`, + method: 'put', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + ], + }, + }, + validateStatus: code => [204].includes(code), + }) + } + + for (const name of [...names.hosted, names.group]) { + const privilege = await axiosInstance({ + method: 'get', + url: `/security/privileges/${name.privilege}`, + validateStatus: code => [200, 404].includes(code), + }) + if (privilege.status === 404) { + await axiosInstance({ + method: 'post', + url: '/security/privileges/repository-view', + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'npm', + repository: name.repo, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/security/privileges/repository-view/${name.privilege}`, + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'npm', + repository: name.repo, + }, + validateStatus: code => [204].includes(code), + }) + } + } + return names +} + +export async function deleteNpmRepo(axiosInstance: AxiosInstance, projectName: string) { + const names = getRepoNames(projectName) + const repoPaths = [names.group, ...names.hosted] + const privileges = [...names.hosted, names.group] + const pathsToDelete = [ + // delete privileges + ...privileges.map(({ privilege }) => `/security/privileges/${privilege}`), + // delete local repo maven snapshot + ...repoPaths.map(repo => `/repositories/${repo.repo}`), + ] + for (const path of pathsToDelete) { + await deleteIfExists(path, axiosInstance) + } +} + +export function getNpmUrls(projectName: string) { + const nexusUrl = getConfig().secretExposedUrl + const names = getRepoNames(projectName) + return { + NPM_REPO: `${nexusUrl}/${names.hosted[0].repo}`, + } +} diff --git a/.jjconflict-side-1/plugins/nexus/src/project.ts b/.jjconflict-side-1/plugins/nexus/src/project.ts new file mode 100644 index 0000000000..2363ca05a1 --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/src/project.ts @@ -0,0 +1,245 @@ +import type { Project, ProjectLite, StepCall } from '@cpn-console/hooks' +import type { WritePolicy } from './utils.js' +import { generateRandomPassword, specificallyDisabled } from '@cpn-console/hooks' +import axios from 'axios' +import { getAxiosOptions } from './functions.js' +import { logger } from './logger.js' +import { createMavenRepo, deleteMavenRepo, getMavenUrls } from './maven.js' +import { createNpmRepo, deleteNpmRepo, getNpmUrls } from './npm.js' +import { deleteIfExists, getTechUsed, parseProjectOptions, updateStore } from './utils.js' + +const getAxiosInstance = () => axios.create(getAxiosOptions()) + +export const deleteNexusProject: StepCall = async ({ args: project }) => { + const axiosInstance = getAxiosInstance() + const projectName = project.slug + try { + await deleteMavenRepo(axiosInstance, projectName) + await deleteNpmRepo(axiosInstance, projectName) + await Promise.all([ + // delete role + deleteIfExists(`/security/roles/${projectName}-ID`, axiosInstance), + // delete user + axiosInstance({ + method: 'delete', + url: `/security/users/${projectName}`, + validateStatus: code => code === 404 || code < 300, + }), + ]) + + logger.info({ action: 'deleteNexusProject', projectSlug: project.slug, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Project deleted from Nexus', + }, + store: { + activateNpmRepo: 'disabled', + activateMavenRepo: 'disabled', + }, + } + } catch (error) { + logger.error({ action: 'deleteNexusProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const createNexusProject: StepCall = async (payload) => { + try { + if (specificallyDisabled(payload.config.nexus?.enablePlugin)) { + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'disabled' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Nexus plugin is disabled', + }, + } + } + if (!payload.apis.vault) throw new Error('no Vault available') + + const axiosInstance = getAxiosInstance() + const projectName = payload.args.slug + const owner = payload.args.owner + const res: any = {} + const options = parseProjectOptions(payload.args.store.nexus) + const failedProvisionning: Partial> = {} + const techUsed = getTechUsed(payload) + + if (options.keysInError.length) { + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'invalid-config', invalidKeyCount: options.keysInError.length }, 'Hook done') + return { + status: { + result: 'WARNING', + message: `${options.keysInError.join(', ')} ${options.keysInError.length > 1 ? 'are' : 'is'} invalid, provisionning canceled, fix it and try again`, + }, + warnReasons: [`invalid key(s): ${options.keysInError.join(', ')}`], + } + } + const privilegesToAccess = [] as string[] + + try { + if (techUsed.maven) { + const names = await createMavenRepo(axiosInstance, projectName, { + releaseWritePolicy: options.mavenReleaseWritePolicy as WritePolicy, + snapshotWritePolicy: options.mavenSnapshotWritePolicy as WritePolicy, + }) + privilegesToAccess.push(names.group.privilege, ...names.hosted.map(({ privilege }) => privilege)) + } else { + await deleteMavenRepo(axiosInstance, projectName) + } + } catch (error) { + failedProvisionning.maven = { error, message: `Maven failed to ${techUsed.maven ? 'provision' : 'delete'} repositories please try again in few minutes` } + } + + try { + if (techUsed.npm) { + const names = await createNpmRepo(axiosInstance, projectName, options.npmWritePolicy as WritePolicy) + privilegesToAccess.push(names.group.privilege, ...names.hosted.map(({ privilege }) => privilege)) + } else { + await deleteNpmRepo(axiosInstance, projectName) + } + } catch (error) { + failedProvisionning.npm = { error, message: `Npm failed to ${techUsed.npm ? 'provision' : 'delete'} repositories please try again in few minutes` } + } + + const roleId = `${projectName}-ID` + // create role + const role = await axiosInstance({ + method: 'GET', + url: `security/roles/${roleId}`, + validateStatus: code => [200, 404].includes(code), + }) + if (role.status === 404) { + await axiosInstance({ + method: 'post', + url: '/security/roles', + data: { + id: `${projectName}-ID`, + name: `${projectName}-role`, + description: 'desc', + privileges: privilegesToAccess, + }, + validateStatus: code => [200].includes(code), + }) + } else if (role.status === 200) { + await axiosInstance({ + method: 'PUT', + url: `security/roles/${roleId}`, + data: { + id: `${projectName}-ID`, + name: `${projectName}-role`, + privileges: privilegesToAccess, + }, + validateStatus: code => [204].includes(code), + }) + } + + const vaultNexusSecret = await payload.apis.vault.read('NEXUS', { throwIfNoEntry: false }) + let currentPwd: string = vaultNexusSecret?.NEXUS_PASSWORD + + const newPwd = generateRandomPassword(30) + const getUser = await axiosInstance({ + url: `/security/users?userId=${projectName}`, + }) as { data: { userId: string }[] } + const user = getUser.data.find(user => user.userId === projectName) + if (user) { + res.user = getUser.data[0] + res.status = { result: 'OK', message: 'User already exists' } + if (!vaultNexusSecret) { + await axiosInstance({ + method: 'put', + url: `/security/users/${projectName}/change-password`, + data: newPwd, + headers: { + 'Content-Type': 'text/plain', + }, + }) + currentPwd = newPwd + } + } else { + // createUser + await axiosInstance({ + method: 'post', + url: '/security/users', + data: { + userId: projectName, + firstName: 'Monkey D.', + lastName: 'Luffy', + emailAddress: owner.email, + password: newPwd, + status: 'active', + roles: [`${projectName}-ID`], + }, + }) + currentPwd = newPwd + } + + if (!getUser.data.length || (getUser.data.length && !vaultNexusSecret)) { // conditions précédentes, si non existent ou si modp a dû être changé + await payload.apis.vault.write({ + NEXUS_PASSWORD: currentPwd, + NEXUS_USERNAME: projectName, + }, 'NEXUS') + } + + if (Object.keys(failedProvisionning).length) { + const failed = Object.values(failedProvisionning) + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'partial-failure', failedProvisionCount: failed.length }, 'Hook done') + return { + status: { + result: 'WARNING', + message: failed.map(({ message }) => message).join('; '), + }, + errors: failed.map(({ error }) => error), + } + } + + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'up-to-date' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + store: updateStore(payload.args.store.nexus), + } + } catch (error) { + logger.error({ action: 'createNexusProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Fail Create repositories', + }, + } + } +} + +export const getSecrets: StepCall = async (payload) => { + if (specificallyDisabled(payload.config.nexus?.enablePlugin)) { + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug, outcome: 'disabled' }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } + const projectName = payload.args.slug + const techUsed = getTechUsed(payload) + + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + secrets: { + ...techUsed.maven && getMavenUrls(projectName), + ...techUsed.npm && getNpmUrls(projectName), + }, + } +} diff --git a/.jjconflict-side-1/plugins/nexus/src/utils.ts b/.jjconflict-side-1/plugins/nexus/src/utils.ts new file mode 100644 index 0000000000..5c52c4435f --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/src/utils.ts @@ -0,0 +1,80 @@ +import type { HookPayload, Project, ProjectLite } from '@cpn-console/hooks' +import type { AxiosInstance } from 'axios' +import { defaultOrNullish, specificallyEnabled } from '@cpn-console/hooks' + +export async function deleteIfExists(url: string, axiosInstance: AxiosInstance) { + const res = await axiosInstance({ + method: 'get', + url, + validateStatus: code => code === 404 || code < 300, + }) + if (res.status !== 404) { + // delete maven group + return axiosInstance({ + method: 'delete', + url, + validateStatus: code => code === 404 || code < 300, + }) + } + return res +} + +export function getTechUsed(payload: HookPayload) { + const projectStore = payload.args.store.nexus + const globalConfig = payload.config.nexus + const techUsed = { + maven: false, + npm: false, + } + if (specificallyEnabled(projectStore?.activateMavenRepo)) { + techUsed.maven = true + } else if (defaultOrNullish(projectStore?.activateMavenRepo) && specificallyEnabled(globalConfig?.activateMavenRepoDefaultValue)) { + techUsed.maven = true + } + if (specificallyEnabled(projectStore?.activateNpmRepo)) { + techUsed.npm = true + } else if (defaultOrNullish(projectStore?.activateMavenRepo) && specificallyEnabled(globalConfig?.activateMavenRepoDefaultValue)) { + techUsed.npm = true + } + return techUsed +} + +export const writePolicyEnum = ['ALLOW', 'ALLOW_ONCE', 'DENY', 'REPLICATION_ONLY'] as const +export type WritePolicy = typeof writePolicyEnum[number] + +export function parseWritePolicy(inputTrial?: string): WritePolicy | 'ERROR' { + const input = inputTrial?.toUpperCase() ?? 'ALLOW' + return writePolicyEnum.find(policy => policy === input) ?? 'ERROR' +} + +export function parseProjectOptions(options: ProjectLite['store']['nexus']) { + // @ts-expect-error + const mavenReleaseWritePolicy = parseWritePolicy(options?.mavenReleaseWritePolicy ?? options?.mavenWritePolicy) + // @ts-expect-error + const mavenSnapshotWritePolicy = parseWritePolicy(options?.mavenSnapshotWritePolicy ?? options?.mavenWritePolicy) + const npmWritePolicy = parseWritePolicy(options?.npmWritePolicy) + const keysInError: (keyof NonNullable>)[] = [] + if (mavenReleaseWritePolicy === 'ERROR') keysInError.push('mavenReleaseWritePolicy') + if (mavenSnapshotWritePolicy === 'ERROR') keysInError.push('mavenSnapshotWritePolicy') + if (npmWritePolicy === 'ERROR') keysInError.push('npmWritePolicy') + return { + keysInError, + mavenReleaseWritePolicy, + mavenSnapshotWritePolicy, + npmWritePolicy, + } +} + +export function updateStore(store?: ProjectLite['store']['nexus']) { + if (!store) return {} + // @ts-expect-error + if (store.mavenWritePolicy) { + // @ts-expect-error + store.mavenReleaseWritePolicy = store.mavenReleaseWritePolicy ?? store.mavenWritePolicy + // @ts-expect-error + store.mavenSnapshotWritePolicy = store.mavenSnapshotWritePolicy ?? store.mavenWritePolicy + // @ts-expect-error + store.mavenWritePolicy = '' + } + return store +} diff --git a/.jjconflict-side-1/plugins/nexus/tsconfig.eslint.json b/.jjconflict-side-1/plugins/nexus/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-1/plugins/nexus/tsconfig.json b/.jjconflict-side-1/plugins/nexus/tsconfig.json new file mode 100644 index 0000000000..fb5ba5c23c --- /dev/null +++ b/.jjconflict-side-1/plugins/nexus/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "useUnknownInCatchVariables": true, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-1/plugins/sonarqube/.gitignore b/.jjconflict-side-1/plugins/sonarqube/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-1/plugins/sonarqube/eslint.config.js b/.jjconflict-side-1/plugins/sonarqube/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-1/plugins/sonarqube/package.json b/.jjconflict-side-1/plugins/sonarqube/package.json new file mode 100644 index 0000000000..703362ca0d --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/package.json @@ -0,0 +1,50 @@ +{ + "name": "@cpn-console/sonarqube-plugin", + "type": "module", + "version": "2.0.5", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/plugins/sonarqube/src/check.ts b/.jjconflict-side-1/plugins/sonarqube/src/check.ts new file mode 100644 index 0000000000..67a1e79cf2 --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/src/check.ts @@ -0,0 +1,45 @@ +import type { PluginResult } from '@cpn-console/hooks' +import { getAxiosInstance } from './tech.js' + +let status: PluginResult + +export async function getStatus() { + if (!status?.result) { + status = await check() + } + return status +} + +export async function check(): Promise { + const axiosInstance = getAxiosInstance() + + try { + const health = await axiosInstance({ + url: 'system/info', + }) + const res: PluginResult = { + status: { + result: 'OK', + }, + } + + if (health.data.Health === 'RED') { + res.status.result = 'KO' + } + if (health.data['Health Causes']) { + res.status.message = health.data['Health Causes'].join('\n') + } + + return res + } catch (error) { + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + updatedAt: Date.now(), + } + } +} diff --git a/.jjconflict-side-1/plugins/sonarqube/src/env.d.ts b/.jjconflict-side-1/plugins/sonarqube/src/env.d.ts new file mode 100644 index 0000000000..219601dae5 --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/src/env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/.jjconflict-side-1/plugins/sonarqube/src/functions.ts b/.jjconflict-side-1/plugins/sonarqube/src/functions.ts new file mode 100644 index 0000000000..2a72978cc7 --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/src/functions.ts @@ -0,0 +1,352 @@ +import type { Project, StepCall } from '@cpn-console/hooks' +import type { KeycloakProjectApi } from '@cpn-console/keycloak-plugin/types/class.js' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { SonarPaging } from './project.js' +import type { VaultSonarSecret } from './tech.js' +import type { SonarUser } from './user.js' +import { generateProjectKey } from '@cpn-console/hooks' +import { adminGroupPath } from '@cpn-console/shared' +import { ensureGroupExists, findGroupByName } from './group.js' +import { logger } from './logger.js' +import { + createDsoRepository, + deleteDsoRepository, + ensureRepositoryConfiguration, + files, + findSonarProjectsForDsoProjects, +} from './project.js' +import { getAxiosInstance } from './tech.js' +import { ensureUserExists } from './user.js' + +const globalPermissions = [ + 'admin', + 'profileadmin', + 'gateadmin', + 'scan', + 'provisioning', +] + +const projectPermissions = [ + 'admin', + 'codeviewer', + 'issueadmin', + 'securityhotspotadmin', + 'scan', + 'user', +] + +export async function initSonar() { + try { + await setTemplatePermisions() + await createAdminGroup() + await setAdminPermisions() + logger.info({ action: 'initSonar' }, 'Hook done') + } catch (error) { + logger.error({ action: 'initSonar', err: error }, 'Hook failed') + throw error + } +} + +async function createAdminGroup() { + const axiosInstance = getAxiosInstance() + const adminGroup = await findGroupByName(adminGroupPath) + if (!adminGroup) { + await axiosInstance({ + method: 'post', + params: { + name: adminGroupPath, + description: 'DSO platform admins', + }, + url: 'user_groups/create', + }) + } +} + +async function setAdminPermisions() { + const axiosInstance = getAxiosInstance() + for (const permission of globalPermissions) { + await axiosInstance({ + method: 'post', + params: { + groupName: adminGroupPath, + permission, + }, + url: 'permissions/add_group', + }) + } +} + +async function setTemplatePermisions() { + const axiosInstance = getAxiosInstance() + await axiosInstance({ + method: 'post', + params: { name: 'Forge Default' }, + url: 'permissions/create_template', + validateStatus: code => [200, 400].includes(code), + }) + for (const permission of projectPermissions) { + await axiosInstance({ + method: 'post', + params: { + templateName: 'Forge Default', + permission, + }, + url: 'permissions/add_project_creator_to_template', + }) + await axiosInstance({ + method: 'post', + params: { + groupName: 'sonar-administrators', + templateName: 'Forge Default', + permission, + }, + url: 'permissions/add_group_to_template', + }) + } + await axiosInstance({ + method: 'post', + params: { + templateName: 'Forge Default', + }, + url: 'permissions/set_default_template', + }) +} + +export const upsertProject: StepCall = async (payload) => { + try { + const project = payload.args + const { + vault: vaultApi, + keycloak: keycloakApi, + }: { + vault: VaultProjectApi + keycloak: KeycloakProjectApi + } = payload.apis + const { slug: projectSlug } = project + const username = project.slug + const keycloakGroupPath = await keycloakApi.getProjectGroupPath() + const sonarRepositories + = await findSonarProjectsForDsoProjects(projectSlug) + + await Promise.all([ + ensureUserAndVault(vaultApi, username, projectSlug), + ensureGroupExists(keycloakGroupPath), + + // Remove excess repositories + ...sonarRepositories + .filter( + sonarRepository => + !project.repositories.some( + repo => repo.internalRepoName === sonarRepository.repository, + ), + ) + .map(sonarRepository => deleteDsoRepository(sonarRepository.key)), + + // Create or configure needed repos + ...project.repositories.map(async (repository) => { + const projectKey = generateProjectKey( + projectSlug, + repository.internalRepoName, + ) + if ( + !sonarRepositories.some( + sonarRepository => + sonarRepository.repository === repository.internalRepoName, + ) + ) { + await createDsoRepository(projectSlug, repository.internalRepoName) + } + await ensureRepositoryConfiguration( + projectKey, + username, + keycloakGroupPath, + ) + }), + ]) + + logger.info({ action: 'upsertProject', projectSlug }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } catch (error) { + logger.error( + { action: 'upsertProject', projectSlug: payload.args.slug, err: error }, + 'Hook failed', + ) + return { + status: { + result: 'WARNING', + message: 'Failed to reconcile', + }, + errors: { + main: error, + }, + } + } +} + +export const setVariables: StepCall = async (payload) => { + const returnResponse = payload.results.sonarqube + try { + const project = payload.args + const { slug: projectSlug } = project + const { gitlab: gitlabApi } = payload.apis + + const sonarSecret = await payload.apis.vault.read('SONAR') + const listGroupVars = await gitlabApi.getGitlabGroupVariables() + await Promise.all([ + // Sonar vars saving in CI (repositories) + ...project.repositories.map(async (repo) => { + const projectKey = generateProjectKey( + projectSlug, + repo.internalRepoName, + ) + const repoId = await payload.apis.gitlab.getProjectId( + repo.internalRepoName, + ) + if (!repoId) return + const listVars = await gitlabApi.getGitlabRepoVariables(repoId) + return [ + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + key: 'PROJECT_KEY', + masked: false, + protected: false, + value: projectKey, + variable_type: 'env_var', + environment_scope: '*', + }), + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + key: 'PROJECT_NAME', + masked: false, + protected: false, + value: `${projectSlug}-${repo.internalRepoName}`, + variable_type: 'env_var', + environment_scope: '*', + }), + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + variable_type: 'file', + key: 'SONAR_PROJECT_PROPERTIES', + masked: false, + protected: false, + value: files['sonar-project.properties'](projectKey), + environment_scope: '*', + }), + ] + }), + // Sonar vars saving in CI (group) + gitlabApi.setGitlabGroupVariable(listGroupVars, { + key: 'SONAR_TOKEN', + masked: true, + protected: false, + value: sonarSecret.data.SONAR_TOKEN, + variable_type: 'env_var', + }), + ]) + + if (payload.results.sonarqube.status.result === 'WARNING') { + returnResponse.status.message = `main message: ${payload.results.sonarqube.status.message}, post message: OK` + } + logger.info({ action: 'setVariables', projectSlug }, 'Hook done') + return returnResponse + } catch (error) { + returnResponse.status.result = 'WARNING' + returnResponse.status.message = `main message: ${payload.results.sonarqube.status.message}, post message: Failed to reconcile` + returnResponse.errors = { + ...returnResponse.errors, + post: error, + } + logger.error( + { action: 'setVariables', projectSlug: payload.args.slug, err: error }, + 'Hook failed', + ) + return returnResponse + } +} + +export const deleteProject: StepCall = async (payload) => { + const axiosInstance = getAxiosInstance() + + const project = payload.args + const { slug: projectSlug } = project + const username = projectSlug + try { + const sonarRepositories + = await findSonarProjectsForDsoProjects(projectSlug) + await Promise.all(sonarRepositories.map(repo => deleteRepo(repo.key))) + const users: { paging: SonarPaging, users: SonarUser[] } = ( + await axiosInstance({ + url: 'users/search', + params: { + q: username, + }, + }) + )?.data + const user = users.users.find(u => u.login === username) + if (!user) { + return { + status: { + result: 'OK', + message: 'Already missing', + }, + } + } + await axiosInstance({ + url: 'users/deactivate', + params: { + login: username, + anonymize: true, + }, + method: 'post', + }) + logger.info( + { action: 'deleteProject', projectSlug, outcome: 'user-anonymized' }, + 'Hook done', + ) + return { + status: { + result: 'OK', + message: 'User anonymized', + }, + } + } catch (error) { + logger.error( + { action: 'deleteProject', projectSlug, err: error }, + 'Hook failed', + ) + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +async function deleteRepo(projectKey: string) { + return getAxiosInstance()({ + url: 'projects/delete', + params: { + project: projectKey, + }, + method: 'post', + }) +} + +async function ensureUserAndVault( + vaultApi: VaultProjectApi, + username: string, + projectSlug: string, +) { + const vaultUserSecret = (await vaultApi.read('SONAR', { + throwIfNoEntry: false, + })) as VaultSonarSecret | undefined + const newUserSecret = await ensureUserExists( + username, + projectSlug, + vaultUserSecret, + ) + if (newUserSecret) await vaultApi.write(newUserSecret, 'SONAR') +} diff --git a/.jjconflict-side-1/plugins/sonarqube/src/group.ts b/.jjconflict-side-1/plugins/sonarqube/src/group.ts new file mode 100644 index 0000000000..08ae823069 --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/src/group.ts @@ -0,0 +1,35 @@ +import type { SonarPaging } from './project.js' +import { getAxiosInstance } from './tech.js' + +export interface SonarGroup { + id: string + name: string + description: string + membersCount: number + default: boolean +} + +export async function findGroupByName(name: string): Promise { + const axiosInstance = getAxiosInstance() + const groupsSearch: { paging: SonarPaging, groups: SonarGroup[] } = (await axiosInstance({ + url: 'user_groups/search', + params: { + q: name, + }, + }))?.data + return groupsSearch.groups.find(g => g.name === name) +} + +export async function ensureGroupExists(groupName: string) { + const axiosInstance = getAxiosInstance() + const group = await findGroupByName(groupName) + if (!group) { + await axiosInstance({ + url: 'user_groups/create', + method: 'post', + params: { + name: groupName, + }, + }) + } +} diff --git a/.jjconflict-side-1/plugins/sonarqube/src/index.ts b/.jjconflict-side-1/plugins/sonarqube/src/index.ts new file mode 100644 index 0000000000..a05d49e210 --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/src/index.ts @@ -0,0 +1,37 @@ +import type { HookStepsNames, Plugin } from '@cpn-console/hooks' +import { getStatus } from './check.js' +import { deleteProject, initSonar, setVariables, upsertProject } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +function start(_options: unknown) { + try { + initSonar() + getStatus() + } catch {} +} + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { + steps: { + main: upsertProject, + post: setVariables, + }, + }, + deleteProject: { + steps: { + main: deleteProject, + }, + }, + }, + start, + monitor, +} + +declare module '@cpn-console/hooks' { + interface PluginResult { + errors?: Partial> + } +} diff --git a/.jjconflict-side-1/plugins/sonarqube/src/infos.ts b/.jjconflict-side-1/plugins/sonarqube/src/infos.ts new file mode 100644 index 0000000000..94ea6616cc --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/src/infos.ts @@ -0,0 +1,12 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { getConfig } from './tech.js' + +const infos: ServiceInfos = { + name: 'sonarqube', + to: () => `${getConfig().url}/projects`, + title: 'SonarQube', + imgSrc: '/img/sonarqube.svg', + description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', +} + +export default infos diff --git a/.jjconflict-side-1/plugins/sonarqube/src/logger.ts b/.jjconflict-side-1/plugins/sonarqube/src/logger.ts new file mode 100644 index 0000000000..7683fa7961 --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'sonarqube' }) diff --git a/.jjconflict-side-1/plugins/sonarqube/src/monitor.ts b/.jjconflict-side-1/plugins/sonarqube/src/monitor.ts new file mode 100644 index 0000000000..d4fe929477 --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/src/monitor.ts @@ -0,0 +1,41 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import { getAxiosOptions } from './tech.js' + +const statusMap = { + GREEN: MonitorStatus.OK, + YELLOW: MonitorStatus.WARNING, + RED: MonitorStatus.ERROR, +} +const messageMap = { + GREEN: MonitorStatus.OK, + YELLOW: 'Service dégradé', + RED: 'Service en panne', +} +interface SonarRes { + health: keyof typeof statusMap + causes: string[] +} + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get('/system/health', { + validateStatus: res => res === 200, + ...getAxiosOptions(), + }) + const data = res.data as SonarRes + + instance.lastStatus.status = statusMap[data.health] + instance.lastStatus.message = messageMap[data.health] + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-1/plugins/sonarqube/src/project.ts b/.jjconflict-side-1/plugins/sonarqube/src/project.ts new file mode 100644 index 0000000000..16082a8d5e --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/src/project.ts @@ -0,0 +1,160 @@ +import { generateProjectKey } from '@cpn-console/hooks' +import { logger } from './logger.js' +import { getAxiosInstance } from './tech.js' + +export interface SonarPaging { + pageIndex: number + pageSize: number + total: number +} + +export type Qualifiers + = 'BRC' // - Sub - projects + | 'DIR' // - Directories + | 'FIL' // - Files + | 'TRK' // - Projects + | 'UTS' // - Test Files + +export interface SonarProject { + key: string // unique key name + name: string + qualifier: Qualifiers + visibility: 'private' | 'public' + lastAnalysisDate?: string + revision?: string +} + +const robotPermissions = [ + 'user', + 'codeviewer', + 'admin', + 'scan', +] +const groupPermissions = [ + 'user', + 'codeviewer', + 'issueadmin', + 'securityhotspotadmin', + 'scan', +] + +export async function createDsoRepository(projectSlug: string, internalRepoName: string, sonarProjectKey?: string) { + const sonarProjectName = `${projectSlug}-${internalRepoName}` + if (!sonarProjectKey) + sonarProjectKey = generateProjectKey(projectSlug, internalRepoName) + return createProject(sonarProjectKey, sonarProjectName) +} + +export async function ensureRepositoryConfiguration(projectKey: string, login: string, groupName: string) { + const axiosInstance = getAxiosInstance() + + for (const permission of robotPermissions) { + await axiosInstance({ + url: 'permissions/add_user', + method: 'post', + params: { + projectKey, + permission, + login, + }, + }) + } + for (const permission of groupPermissions) { + await axiosInstance({ + url: 'permissions/add_group', + method: 'post', + params: { + projectKey, + permission, + groupName, + }, + }) + } +} + +export async function createProject(projectKey: string, projectName: string) { + return getAxiosInstance()({ + url: 'projects/create', + method: 'post', + params: { + project: projectKey, + visibility: 'private', + name: projectName, + mainbranch: 'main', + }, + }) +} + +export async function deleteDsoRepository(projectKey: string) { + const axiosInstance = getAxiosInstance() + + await axiosInstance({ + url: 'projects/delete', + method: 'post', + params: { + project: projectKey, + }, + }) +} + +interface SonarProjectResult { + projectSlug: string + repository: string + key: string +} + +function filterProjectsOwning(repos: { key: string }[], projectSlug: string): SonarProjectResult[] { + return repos.reduce((acc, repo) => { + let isOrphan = true + + const sonarKey = repo.key + const keyElements = sonarKey.split('-') + keyElements.pop() + for (let i = keyElements.length - 1; i > 0; i--) { + const project = keyElements.slice(0, i).join('-') + const repository = keyElements.slice(i).join('-') + const keyComputed = generateProjectKey(project, repository) + if (keyComputed === sonarKey) { + if (project === projectSlug) { + acc.push({ + projectSlug, + repository, + key: sonarKey, + }) + } + isOrphan = false + } + } + if (isOrphan) { + logger.warn({ sonarKey, projectSlug }, 'Orphan SonarQube project key does not match expected pattern') + } + return acc + }, [] as SonarProjectResult[]) +} + +export async function findSonarProjectsForDsoProjects(projectSlug: string) { + const axiosInstance = getAxiosInstance() + let foundProjectKeys: SonarProjectResult[] = [] + + let page = 0 + const pageSize = 100 + let total = 0 + do { + page++ + const similarProjects = await axiosInstance.get('projects/search', { + params: { + q: projectSlug, + p: page, + ps: pageSize, + }, + }) + total = similarProjects.data.paging.total + foundProjectKeys = [...foundProjectKeys, ...filterProjectsOwning(similarProjects.data.components, projectSlug)] + } while (page * pageSize < total) + + return foundProjectKeys +} + +export const files = { + 'sonar-project.properties': (key: string) => `sonar.projectKey=${key}\nsonar.qualitygate.wait=true\n`, +} diff --git a/.jjconflict-side-1/plugins/sonarqube/src/tech.ts b/.jjconflict-side-1/plugins/sonarqube/src/tech.ts new file mode 100644 index 0000000000..e820d8d381 --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/src/tech.ts @@ -0,0 +1,39 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +const config: { + url?: string + internalUrl?: string + user?: string + password?: string +} = {} + +export function getConfig(): Required { + config.url = config.url ?? removeTrailingSlash(requiredEnv('SONARQUBE_URL')) + config.internalUrl = config.internalUrl ?? removeTrailingSlash(requiredEnv('SONARQUBE_INTERNAL_URL')) + config.user = config.user ?? requiredEnv('SONAR_API_TOKEN') + // @ts-ignore + return config +} +export function getAxiosOptions() { + return { + baseURL: `${getConfig().internalUrl}/api/`, + auth: { + username: getConfig().user, + password: '', // Token is used, so password is useless + }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } +} + +export function getAxiosInstance() { + return axios.create(getAxiosOptions()) +} + +export interface VaultSonarSecret { + SONAR_USERNAME: string + SONAR_PASSWORD: string + SONAR_TOKEN: string +} diff --git a/.jjconflict-side-1/plugins/sonarqube/src/user.ts b/.jjconflict-side-1/plugins/sonarqube/src/user.ts new file mode 100644 index 0000000000..0dae43617d --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/src/user.ts @@ -0,0 +1,101 @@ +import type { SonarPaging } from './project.js' +import type { VaultSonarSecret } from './tech.js' +// import type { StepCall, ArchiveProjectExecArgs } from '@cpn-console/hooks' +import { generateRandomPassword } from '@cpn-console/hooks' +import { getAxiosInstance } from './tech.js' + +export interface SonarUser { + login: string + name: string + active: boolean + email: string + groups: string[] + tokensCount: number + local: boolean + externalIdentity: string + externalProvider: string + avatar: string + lastConnectionDate: Date + managed: boolean + sonarLintLastConnectionDate: Date +} + +export async function createUser(username: string, projectSlug: string) { + const axiosInstance = getAxiosInstance() + const fakeEmail = `${projectSlug}@${projectSlug}` + + const newPwd = generateRandomPassword(30) + await axiosInstance({ + url: 'users/create', + method: 'post', + params: { + email: fakeEmail, + local: 'true', + login: username, + name: username, + password: newPwd, + }, + }) + return newPwd +} + +export async function changeToken(username: string) { + const axiosInstance = getAxiosInstance() + await axiosInstance({ + url: 'user_tokens/revoke', + method: 'post', + params: { + login: username, + name: `Sonar Token for ${username}`, + }, + }) + const newToken = await axiosInstance({ + url: 'user_tokens/generate', + method: 'post', + params: { + login: username, + name: `Sonar Token for ${username}`, + }, + }) + return newToken.data.token +} + +export async function getUser(username: string): Promise { + const axiosInstance = getAxiosInstance() + let page = 1 + const pageSize = 100 + while (true) { + const response = await axiosInstance({ + url: 'users/search', + params: { + q: username, + ps: pageSize, + p: page, + }, + }) + const users: { paging: SonarPaging, users: SonarUser[] } = response.data + const found = users.users.find(user => user.login === username) + if (found) return found + if (!users.users.length || users.paging.pageIndex * users.paging.pageSize >= users.paging.total) { + break + } + page += 1 + } +} + +export async function ensureUserExists(username: string, projectSlug: string, vaultUserSecret: VaultSonarSecret | undefined): Promise { + const user = await getUser(username) + if (!user) { + return { + SONAR_PASSWORD: await createUser(username, projectSlug), + SONAR_TOKEN: await changeToken(username), + SONAR_USERNAME: username, + } + } else if (!vaultUserSecret) { + return { + SONAR_PASSWORD: 'not initialized', + SONAR_TOKEN: await changeToken(username), + SONAR_USERNAME: username, + } + } +} diff --git a/.jjconflict-side-1/plugins/sonarqube/tsconfig.eslint.json b/.jjconflict-side-1/plugins/sonarqube/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-1/plugins/sonarqube/tsconfig.json b/.jjconflict-side-1/plugins/sonarqube/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-1/plugins/sonarqube/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-1/plugins/vault/.gitignore b/.jjconflict-side-1/plugins/vault/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-1/plugins/vault/README.md b/.jjconflict-side-1/plugins/vault/README.md new file mode 100644 index 0000000000..bc7c4d1ec9 --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/README.md @@ -0,0 +1,14 @@ +# Introduction + +Plugin de gestion du plugin Vault + +## Configuration + +| Env var | valeur possible | description | +| --------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| VAULT_TOKEN | chaine | Root token ou token ayant les pleins droits sur le Vault | +| VAULT_URL | *url* | Url public d'accès au Vault | +| VAULT_INTERNAL_URL | *url* ou vide | Url par laquelle la console interroge le service Vault, si absent utilisation de l'url public | +| VAULT__HIDE_PROJECT_SERVICE | "true" ou vide | Décide si le plugin masque ou non le service dans l'interface du projet. Ne désactive pas la fonctionnalité du store projet seulement l'affichage. | +| VAULT__DISABLE_VAULT_SECRETS | "true" ou vide | Désactiver le déploiement des ressources nécessaires au Vault Secret Operator dans le namespace cible | +| VAULT__DEPLOY_VAULT_CONNECTION_IN_NS | "true" ou vide | Activer le déploiement du vaultconnections dans le namespace utilisateur, si vide ou faux, s'assure que la ressource n'existe pas | diff --git a/.jjconflict-side-1/plugins/vault/eslint.config.js b/.jjconflict-side-1/plugins/vault/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-1/plugins/vault/package.json b/.jjconflict-side-1/plugins/vault/package.json new file mode 100644 index 0000000000..b8fb478904 --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/package.json @@ -0,0 +1,47 @@ +{ + "name": "@cpn-console/vault-plugin", + "type": "module", + "version": "2.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-1/plugins/vault/src/config.ts b/.jjconflict-side-1/plugins/vault/src/config.ts new file mode 100644 index 0000000000..abce2040b0 --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/src/config.ts @@ -0,0 +1,32 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + token: string + projectsRootDir: string + hideProjectService: boolean + disableVaultSecrets: boolean + deployVaultConnectionInNs: boolean + constructor() { + this.token = requiredEnv('VAULT_TOKEN') + this.publicUrl = removeTrailingSlash(requiredEnv('VAULT_URL')) + this.projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') + this.internalUrl = process.env.VAULT_INTERNAL_URL + ? removeTrailingSlash(process.env.VAULT_INTERNAL_URL) + : this.publicUrl + this.hideProjectService = process.env.VAULT__HIDE_PROJECT_SERVICE === 'true' + this.disableVaultSecrets = process.env.VAULT__DISABLE_VAULT_SECRETS === 'true' + this.deployVaultConnectionInNs = process.env.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS === 'true' + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-side-1/plugins/vault/src/functions.ts b/.jjconflict-side-1/plugins/vault/src/functions.ts new file mode 100644 index 0000000000..2e3d98838b --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/src/functions.ts @@ -0,0 +1,112 @@ +import type { + Project, + ProjectLite, + StepCall, + ZoneObject, +} from '@cpn-console/hooks' +import { okStatus } from '@cpn-console/hooks' +import { logger } from './logger.js' + +export const upsertProject: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.Project.upsert() + logger.info({ action: 'upsertProject', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const archiveDsoProject: StepCall = async (payload) => { + try { + if (!payload.apis.vault) throw new Error('no Vault available') + await payload.apis.vault.Project.delete() + const allSecrets = await payload.apis.vault.list('/') + const promisesDestroy = allSecrets.map((path) => { + return payload.apis.vault.destroy(path) + }) + await Promise.all(promisesDestroy) + + logger.info({ action: 'archiveDsoProject', projectSlug: payload.args.slug, secretsDestroyed: allSecrets.length }, 'Hook done') + return { + status: { + result: 'OK', + }, + secretsDestroyed: allSecrets.length, + } + } catch (error) { + logger.error({ action: 'archiveDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const getSecrets: StepCall = async (payload) => { + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + secrets: { + '.spec.mount': `${payload.args.slug}`, + '.spec.vaultAuthRef': 'vault-auth', + }, + } +} +export const upsertZone: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.upsert() + logger.info({ action: 'upsertZone', zoneSlug: payload.args.slug }, 'Hook done') + return okStatus + } catch (error) { + logger.error({ action: 'upsertZone', zoneSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} +export const deleteZone: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.delete() + logger.info({ action: 'deleteZone', zoneSlug: payload.args.slug }, 'Hook done') + return okStatus + } catch (error) { + logger.error({ action: 'deleteZone', zoneSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} diff --git a/.jjconflict-side-1/plugins/vault/src/index.ts b/.jjconflict-side-1/plugins/vault/src/index.ts new file mode 100644 index 0000000000..e73ba4ab91 --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/src/index.ts @@ -0,0 +1,59 @@ +import type { ClusterObject, DefaultArgs, Plugin, Project, ProjectLite, ZoneObject } from '@cpn-console/hooks' +import { archiveDsoProject, deleteZone, getSecrets, upsertProject, upsertZone } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' +import { VaultProjectApi } from './vault-project-api.js' +import { VaultZoneApi } from './vault-zone-api.js' + +const onlyApi = { api: (project: ProjectLite) => new VaultProjectApi(project) } + +export const plugin: Plugin = { + infos, + subscribedHooks: { + getProjectSecrets: { + ...onlyApi, + steps: { + main: getSecrets, + }, + }, + upsertProject: { + ...onlyApi, + steps: { + main: upsertProject, + }, + }, + deleteProject: { + ...onlyApi, + steps: { post: archiveDsoProject }, // Destroy all secrets for project + }, + upsertZone: { + api: (zone: ZoneObject) => new VaultZoneApi(zone.slug), + steps: { + pre: upsertZone, + }, + }, + deleteZone: { + api: (zone: ZoneObject) => new VaultZoneApi(zone.slug), + steps: { + main: deleteZone, + }, + }, + upsertCluster: { + api: (c: ClusterObject) => new VaultZoneApi(c.zone.slug), + }, + deleteCluster: { + api: (c: ClusterObject) => new VaultZoneApi(c.zone.slug), + }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + vault: Args extends (ProjectLite | Project) + ? VaultProjectApi + : Args extends (ZoneObject | ClusterObject) + ? VaultZoneApi + : never + } +} diff --git a/.jjconflict-side-1/plugins/vault/src/infos.ts b/.jjconflict-side-1/plugins/vault/src/infos.ts new file mode 100644 index 0000000000..976f1f4218 --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/src/infos.ts @@ -0,0 +1,14 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import getConfig from './config.js' + +const infos: ServiceInfos = { + name: 'vault', + to: ({ project }) => getConfig().hideProjectService + ? undefined + : `${getConfig().publicUrl}/ui/vault/secrets/${project.slug}`, + title: 'Vault', + imgSrc: '/img/vault.svg', + description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', +} + +export default infos diff --git a/.jjconflict-side-1/plugins/vault/src/logger.ts b/.jjconflict-side-1/plugins/vault/src/logger.ts new file mode 100644 index 0000000000..f596e24a6f --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'vault' }) diff --git a/.jjconflict-side-1/plugins/vault/src/monitor.ts b/.jjconflict-side-1/plugins/vault/src/monitor.ts new file mode 100644 index 0000000000..091b850649 --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/src/monitor.ts @@ -0,0 +1,45 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import getConfig from './config.js' + +const vaultStatusCode = [200, 429, 472, 473, 501, 503] + +type VaultRes = { + initialized: boolean + sealed: boolean + standby: boolean + performance_standby: boolean + replication_performance_mode: string + replication_dr_mode: string + server_time_utc: number + version: string +} | undefined + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(`${getConfig().internalUrl}/v1/sys/health?standbyok=true`, { + headers: { + 'X-Vault-Token': getConfig().token, + }, + validateStatus: res => vaultStatusCode.includes(res), + }) + if (res.status === 200) { + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + } else { + instance.lastStatus.status = MonitorStatus.ERROR + const data = res.data as VaultRes + instance.lastStatus.message = data?.sealed ? 'Le Vault est scellé' : 'Vault en erreur' + } + } catch (error) { + instance.lastStatus.message = 'Error lors la requete' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + // test + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-1/plugins/vault/src/utils.ts b/.jjconflict-side-1/plugins/vault/src/utils.ts new file mode 100644 index 0000000000..d7036913f4 --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/src/utils.ts @@ -0,0 +1,21 @@ +import type { AxiosInstance } from 'axios' + +export async function getAuthMethod(axiosInstance: AxiosInstance, token: string) { + const response = await axiosInstance({ + method: 'get', + url: '/v1/sys/auth', + headers: { 'X-Vault-Token': token }, + }) + return response.data +} + +export const minimumConfig = { + type: 'kv', + options: { + version: 2, + }, +} +export function generateKVConfigUpdate(config: Record): typeof minimumConfig | void { + if (config?.type !== minimumConfig.type) return minimumConfig + if (config?.options?.version !== minimumConfig.options.version) return minimumConfig +} diff --git a/.jjconflict-side-1/plugins/vault/src/vault-api.ts b/.jjconflict-side-1/plugins/vault/src/vault-api.ts new file mode 100644 index 0000000000..a09cc7c338 --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/src/vault-api.ts @@ -0,0 +1,165 @@ +import type { AxiosInstance } from 'axios' +import { PluginApi } from '@cpn-console/hooks' +import axios from 'axios' +import getConfig from './config.js' +import { + generateKVConfigUpdate, +} from './utils.js' + +export interface AppRoleCredentials { + url: string + coreKvName: string + roleId: string + secretId: string +} + +export abstract class VaultApi extends PluginApi { + protected readonly axios: AxiosInstance + private token: string | undefined = undefined + + constructor() { + super() + this.axios = axios.create({ + baseURL: getConfig().internalUrl, + headers: { + 'X-Vault-Token': getConfig().token, + }, + }) + } + + protected async getToken() { + if (!this.token) { + this.token = (await this.axios.post('/v1/auth/token/create')).data.auth + .client_token as string + } + return this.token + } + + protected async destroy(path: string, kvName: string) { + if (path.startsWith('/')) path = path.slice(1) + return await this.axios({ + method: 'delete', + url: `/v1/${kvName}/metadata/${path}`, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + } + + Kv = { + upsert: async (kvName: string) => { + const token = await this.getToken() + const kvRes = await this.axios({ + method: 'get', + url: `/v1/sys/mounts/${kvName}/tune`, + headers: { 'X-Vault-Token': token }, + validateStatus: code => [400, 200].includes(code), + }) + if (kvRes.status === 400) { + await this.axios({ + method: 'post', + url: `/v1/sys/mounts/${kvName}`, + headers: { + 'X-Vault-Token': token, + }, + data: { + type: 'kv', + config: { + force_no_cache: true, + }, + options: { + version: 2, + }, + }, + }) + } else { + // means 200 status + const configUpdate = generateKVConfigUpdate(kvRes.data) + if (configUpdate) { + await this.axios({ + method: 'put', + url: `/v1/sys/mounts/${kvName}/tune`, + headers: { + 'X-Vault-Token': token, + }, + data: configUpdate, + }) + } + } + }, + delete: async (kvName: string) => { + const token = await this.getToken() + return await this.axios({ + method: 'delete', + url: `/v1/sys/mounts/${kvName}`, + headers: { 'X-Vault-Token': token }, + validateStatus: code => [400, 200, 204].includes(code), + }) + }, + } + + Policy = { + upsert: async (policyName: string, policy: string) => { + await this.axios({ + method: 'put', + url: `/v1/sys/policies/acl/${policyName}`, + data: { policy }, + headers: { + 'X-Vault-Token': await this.getToken(), + 'Content-Type': 'application/json', + }, + }) + }, + delete: async (policyName: string) => { + await this.axios({ + method: 'delete', + url: `/v1/sys/policies/acl/${policyName}`, + headers: { + 'X-Vault-Token': await this.getToken(), + 'Content-Type': 'application/json', + }, + }) + }, + } + + Role = { + upsert: async (roleName: string, policies: string[]) => { + await this.axios({ + method: 'post', + url: `/v1/auth/approle/role/${roleName}`, + data: { + secret_id_num_uses: '0', + secret_id_ttl: '0', + token_max_ttl: '0', + token_num_uses: '0', + token_ttl: '0', + token_type: 'batch', + token_policies: policies, + }, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + delete: async (roleName: string) => { + await this.axios.delete(`/v1/auth/approle/role/${roleName}`, { + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + getCredentials: async (roleName: string) => { + const { data: dataRole } = await this.axios.get( + `/v1/auth/approle/role/${roleName}/role-id`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + const { data: dataSecret } = await this.axios.put( + `/v1/auth/approle/role/${roleName}/secret-id`, + 'null', // Force empty data + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + return { + roleId: dataRole.data?.role_id, + secretId: dataSecret.data?.secret_id, + } + }, + } +} diff --git a/.jjconflict-side-1/plugins/vault/src/vault-project-api.ts b/.jjconflict-side-1/plugins/vault/src/vault-project-api.ts new file mode 100644 index 0000000000..45ec6280c9 --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/src/vault-project-api.ts @@ -0,0 +1,207 @@ +import type { ProjectLite } from '@cpn-console/hooks' +import getConfig from './config.js' +import { + getAuthMethod, +} from './utils.js' +import { VaultApi } from './vault-api.js' + +interface ReadOptions { + throwIfNoEntry: boolean +} +export interface AppRoleCredentials { + url: string + coreKvName: string + roleId: string + secretId: string +} + +interface VaultValuesWithoutCredentials { + /** Slash-separated directory (root node of all Gitlab projects) */ + projectsRootDir: string +} + +type VaultValues = AppRoleCredentials & VaultValuesWithoutCredentials + +export class VaultProjectApi extends VaultApi { + private readonly basePath: string + private readonly roleName: string + private readonly projectRootDir: string + private readonly defaultAppRoleCredentials: AppRoleCredentials + private readonly coreKvName: string = 'forge-dso' + private readonly projectKvName: string + private readonly groupName: string + private readonly policyName: { + techRO: string + appFull: string + } + + constructor(project: ProjectLite) { + super() + this.basePath = project.slug + this.roleName = project.slug + this.projectRootDir = getConfig().projectsRootDir + this.projectKvName = project.slug + this.groupName = project.slug + this.policyName = { + techRO: `tech--${project.slug}--ro`, + appFull: `app--${project.slug}--admin`, + } + this.defaultAppRoleCredentials = { + url: getConfig().deployVaultConnectionInNs ? getConfig().publicUrl : '', + coreKvName: this.coreKvName, + roleId: 'none', + secretId: 'none', + } + } + + public async list(path: string = '/'): Promise { + if (!path.startsWith('/')) path = `/${path}` + + const listSecretPath: string[] = [] + const response = await this.axios({ + url: `/v1/${this.coreKvName}/metadata/${this.projectRootDir}/${this.basePath}${path}/`, + headers: { + 'X-Vault-Token': await this.getToken(), + }, + method: 'list', + validateStatus: code => [200, 404].includes(code), + }) + + if (response.status === 404) return listSecretPath + for (const key of response.data.data.keys) { + if (key.endsWith('/')) { + const subSecrets = await this.list( + `${path.substring(this.basePath.length)}/${key}`, + ) + subSecrets.forEach((secret) => { + listSecretPath.push(`${key}${secret}`) + }) + } else { + listSecretPath.push(`/${key}`) + } + } + return listSecretPath.flat() + } + + public async read( + path: string = '/', + options: ReadOptions = { throwIfNoEntry: true }, + ) { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.get( + `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + validateStatus: status => + (options.throwIfNoEntry ? [200] : [200, 404]).includes(status), + }, + ) + return response.data.data + } + + public async write(body: object, path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.post( + `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + data: body, + }, + ) + return await response.data + } + + public async destroy(path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + return super.destroy( + `${this.projectRootDir}/${this.basePath}/${path}`, + this.coreKvName, + ) + } + + Project = { + upsert: async () => { + await this.Kv.upsert(this.projectKvName) + await this.Policy.upsert( + this.policyName.appFull, + `path "${this.projectKvName}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + ) + await this.Policy.upsert( + this.policyName.techRO, + `path "${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + ) + await this.Group.upsert() + await this.Role.upsert(this.roleName, [ + this.policyName.techRO, + this.policyName.appFull, + ]) + }, + delete: async () => { + await this.Kv.delete(this.projectKvName) + await this.Policy.delete(this.policyName.appFull) + await this.Policy.delete(this.policyName.techRO) + await this.Group.delete() + await this.Role.delete(this.roleName) + }, + getCredentials: async (): Promise => { + const creds = await this.Role.getCredentials(this.roleName) + return { + ...this.defaultAppRoleCredentials, + ...creds, + } + }, + getValues: async (): Promise => { + return { + projectsRootDir: getConfig().projectsRootDir, + ...(await this.Project.getCredentials()), + } + }, + } + + Group = { + upsert: async () => { + // TODO check api responses for POST and GET maybe duplicates + await this.axios({ + method: 'post', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + data: { + name: this.groupName, + type: 'external', + policies: [this.policyName.appFull], + }, + }) + + const response = await this.axios({ + method: 'get', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + validateStatus: code => [404, 200].includes(code), + }) + const group = response.data + const groupAliasName = `/${this.groupName}` + if (group.data.alias?.name === groupAliasName) { + return + } + const methods = await getAuthMethod(this.axios, await this.getToken()) + + await this.axios({ + url: `/v1/identity/group-alias`, + method: 'post', + headers: { 'X-Vault-Token': await this.getToken() }, + data: { + name: groupAliasName, + mount_accessor: methods['oidc/'].accessor, + canonical_id: group.data.id, + }, + }) + }, + delete: async () => { + await this.axios({ + method: 'delete', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + } +} diff --git a/.jjconflict-side-1/plugins/vault/src/vault-zone-api.ts b/.jjconflict-side-1/plugins/vault/src/vault-zone-api.ts new file mode 100644 index 0000000000..378d393083 --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/src/vault-zone-api.ts @@ -0,0 +1,63 @@ +import getConfig from './config.js' +import { VaultApi } from './vault-api.js' + +interface VaultCredentials { + url: string + kvName: string + roleId: string + secretId: string +} +type VaultValues = VaultCredentials + +export class VaultZoneApi extends VaultApi { + private readonly kvName: string + private readonly policyName: string + private readonly roleName: string + constructor(name: string) { + super() + this.kvName = `zone-${name}` + this.policyName = `tech--${this.kvName}--ro` + this.roleName = `zone-${name}` + } + + public async upsert() { + await this.Kv.upsert(this.kvName) + await this.Policy.upsert( + this.policyName, + `path "${this.kvName}/*" { capabilities = ["read"] }`, + ) + await this.Role.upsert(this.roleName, [this.policyName]) + } + + public async delete() { + await this.Kv.delete(this.kvName) + await this.Policy.delete(this.policyName) + await this.Role.delete(this.roleName) + } + + public async write(body: object, path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.post(`/v1/${this.kvName}/data/${path}`, { + headers: { 'X-Vault-Token': await this.getToken() }, + data: body, + }) + return await response.data + } + + public async destroy(path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + return super.destroy(path, this.kvName) + } + + public async getCredentials(): Promise { + return { + url: getConfig().publicUrl, + kvName: this.kvName, + ...(await this.Role.getCredentials(this.roleName)), + } as VaultCredentials + } + + public async getValues(): Promise { + return this.getCredentials() + } +} diff --git a/.jjconflict-side-1/plugins/vault/src/vso.ts b/.jjconflict-side-1/plugins/vault/src/vso.ts new file mode 100644 index 0000000000..579eb8d8ff --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/src/vso.ts @@ -0,0 +1,55 @@ +import type { AppRoleCredentials } from './vault-project-api.js' + +export function generateVsoVaultConnection(creds: AppRoleCredentials) { + return { + apiVersion: 'secrets.hashicorp.com/v1beta1', + kind: 'VaultConnection', + metadata: { + name: 'default', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + spec: { + address: creds.url, + }, + } +} + +export function generateVsoSecret(creds: AppRoleCredentials) { + return { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: 'vso-approle', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + stringData: { + id: creds.secretId, + }, + } +} +export function generateVaultAuth(creds: AppRoleCredentials, vaultConnectionRef: string | null = null) { + return { + apiVersion: 'secrets.hashicorp.com/v1beta1', + kind: 'VaultAuth', + metadata: { + name: 'vault-auth', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + spec: { + vaultConnectionRef, + method: 'appRole', + mount: 'approle', + appRole: { + roleId: creds.roleId, + secretRef: 'vso-approle', + }, + allowedNamespaces: null, + }, + } +} diff --git a/.jjconflict-side-1/plugins/vault/tsconfig.eslint.json b/.jjconflict-side-1/plugins/vault/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-1/plugins/vault/tsconfig.json b/.jjconflict-side-1/plugins/vault/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-1/plugins/vault/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-1/pnpm-lock.yaml b/.jjconflict-side-1/pnpm-lock.yaml new file mode 100644 index 0000000000..4265e63be7 --- /dev/null +++ b/.jjconflict-side-1/pnpm-lock.yaml @@ -0,0 +1,18500 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + injectWorkspacePackages: true + +catalogs: + build: + '@iconify/types': + specifier: ^2.0.0 + version: 2.0.0 + '@nestjs/cli': + specifier: ^11.0.16 + version: 11.0.16 + '@nestjs/schematics': + specifier: ^11.0.9 + version: 11.0.9 + '@nestjs/testing': + specifier: ^11.1.16 + version: 11.1.16 + '@unocss/transformer-directives': + specifier: ^0.65.4 + version: 0.65.4 + '@vitejs/plugin-vue': + specifier: ^6.0.4 + version: 6.0.4 + '@vue/tsconfig': + specifier: ^0.7.0 + version: 0.7.0 + fastify-plugin: + specifier: ^5.1.0 + version: 5.1.0 + swagger-typescript-api: + specifier: ^13.3.1 + version: 13.3.1 + ts-patch: + specifier: ^3.3.0 + version: 3.3.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-transform-paths: + specifier: ^3.5.6 + version: 3.5.6 + unocss: + specifier: ^66.6.6 + version: 66.6.6 + unplugin-auto-import: + specifier: ^0.18.6 + version: 0.18.6 + unplugin-vue-components: + specifier: ^0.27.5 + version: 0.27.5 + vite: + specifier: ^7.3.2 + version: 7.3.3 + vite-node: + specifier: ^6.0.0 + version: 6.0.0 + vite-plugin-pwa: + specifier: ^1.2.0 + version: 1.2.0 + vue-tsc: + specifier: ^2.2.12 + version: 2.2.12 + workbox-window: + specifier: ^7.4.0 + version: 7.4.0 + otel: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@opentelemetry/auto-instrumentations-node': + specifier: ^0.70.1 + version: 0.70.1 + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0 + '@opentelemetry/exporter-trace-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0 + '@opentelemetry/instrumentation-nestjs-core': + specifier: ^0.58.0 + version: 0.58.0 + '@opentelemetry/instrumentation-pino': + specifier: ^0.59.0 + version: 0.59.0 + '@opentelemetry/sdk-metrics': + specifier: ^2.5.1 + version: 2.6.0 + '@opentelemetry/sdk-node': + specifier: ^0.212.0 + version: 0.212.0 + runtime: + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 + '@fastify/helmet': + specifier: ^13.0.2 + version: 13.0.2 + '@fastify/otel': + specifier: ^0.18.1 + version: 0.18.1 + '@fastify/session': + specifier: ^11.1.1 + version: 11.1.1 + '@fastify/swagger': + specifier: ^9.7.0 + version: 9.7.0 + '@fastify/swagger-ui': + specifier: ^5.2.6 + version: 5.2.6 + '@gitbeaker/core': + specifier: ^40.6.0 + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: ^40.6.0 + version: 40.6.0 + '@gitbeaker/rest': + specifier: ^40.6.0 + version: 40.6.0 + '@gouvfr/dsfr': + specifier: ^1.14.4 + version: 1.14.4 + '@gouvminint/vue-dsfr': + specifier: ^8.15.0 + version: 8.15.0 + '@himenon/argocd-typescript-openapi': + specifier: ^1.2.2 + version: 1.2.2 + '@iconify-json/ri': + specifier: ^1.2.10 + version: 1.2.10 + '@iconify/vue': + specifier: ^4.3.0 + version: 4.3.0 + '@keycloak/keycloak-admin-client': + specifier: ^26.5.5 + version: 26.7.0 + '@kubernetes-models/argo-cd': + specifier: ^2.7.2 + version: 2.7.2 + '@nestjs/cache-manager': + specifier: ^3.1.2 + version: 3.1.3 + '@nestjs/common': + specifier: ^11.1.16 + version: 11.1.16 + '@nestjs/config': + specifier: ^4.0.3 + version: 4.0.3 + '@nestjs/core': + specifier: ^11.1.16 + version: 11.1.16 + '@nestjs/event-emitter': + specifier: ^3.0.1 + version: 3.0.1 + '@nestjs/jwt': + specifier: ^11.0.2 + version: 11.0.2 + '@nestjs/platform-fastify': + specifier: ^11.1.16 + version: 11.1.27 + '@nestjs/schedule': + specifier: ^6.1.1 + version: 6.1.1 + '@nestjs/swagger': + specifier: ^11.4.1 + version: 11.4.1 + '@nestjs/terminus': + specifier: ^11.1.1 + version: 11.1.1 + '@prisma/client': + specifier: ^6.19.2 + version: 6.19.2 + '@ts-rest/core': + specifier: ^3.52.1 + version: 3.52.1 + '@ts-rest/fastify': + specifier: ^3.52.1 + version: 3.52.1 + '@ts-rest/open-api': + specifier: ^3.52.1 + version: 3.52.1 + axios: + specifier: ^1.15.1 + version: 1.16.0 + bytes: + specifier: ^3.1.2 + version: 3.1.2 + cache-manager: + specifier: ^7.2.8 + version: 7.2.8 + cron-validator: + specifier: ^1.4.0 + version: 1.4.0 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + fastify: + specifier: ^5.8.5 + version: 5.8.5 + fastify-keycloak-adapter: + specifier: ^3.0.0 + version: 3.0.2 + javascript-time-ago: + specifier: ^2.6.4 + version: 2.6.4 + json-2-csv: + specifier: ^5.5.10 + version: 5.5.10 + json-schema: + specifier: ^0.4.0 + version: 0.4.0 + jszip: + specifier: ^3.10.1 + version: 3.10.1 + keycloak-js: + specifier: ^26.2.3 + version: 26.2.3 + mustache: + specifier: ^4.2.0 + version: 4.2.0 + nanoid: + specifier: 5.0.9 + version: 5.0.9 + nestjs-pino: + specifier: ^4.6.0 + version: 4.6.0 + p-debounce: + specifier: ^4.0.0 + version: 4.0.0 + pinia: + specifier: ^2.3.1 + version: 2.3.1 + prisma: + specifier: ^6.19.2 + version: 6.19.2 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + short-uuid: + specifier: ^5.2.0 + version: 5.2.0 + undici: + specifier: ^7.24.0 + version: 7.24.5 + vitest-mock-extended: + specifier: ^2.0.2 + version: 2.0.2 + vue: + specifier: ^3.5.30 + version: 3.5.30 + vue-router: + specifier: ^4.6.4 + version: 4.6.4 + vue3-json-viewer: + specifier: ^2.4.1 + version: 2.4.1 + xbytes: + specifier: ^1.9.1 + version: 1.9.1 + yaml: + specifier: ^2.7.1 + version: 2.9.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 + zod-validation-error: + specifier: ^3.5.4 + version: 3.5.4 + test: + '@faker-js/faker': + specifier: ^9.9.0 + version: 9.9.0 + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 + '@vitest/coverage-v8': + specifier: ^4.1.5 + version: 4.1.5 + globals: + specifier: ^16.5.0 + version: 16.5.0 + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + msw: + specifier: ^2.12.10 + version: 2.12.10 + vitest: + specifier: ^4.1.5 + version: 4.1.5 + tools: + '@antfu/eslint-config': + specifier: ^8.2.0 + version: 8.2.0 + '@commitlint/cli': + specifier: ^20.5.3 + version: 20.5.3 + '@commitlint/config-conventional': + specifier: ^20.5.3 + version: 20.5.3 + '@eslint/eslintrc': + specifier: ^3.3.5 + version: 3.3.5 + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 + '@vue/eslint-config-typescript': + specifier: ^14.7.0 + version: 14.7.0 + chalk: + specifier: ^5.6.2 + version: 5.6.2 + eslint: + specifier: ^10.5.0 + version: 10.5.0 + eslint-plugin-vue: + specifier: ^10.8.0 + version: 10.8.0 + husky: + specifier: ^9.1.7 + version: 9.1.7 + js-yaml: + specifier: ^4.1.0 + version: 4.2.0 + lint-staged: + specifier: ^16.4.0 + version: 16.4.0 + nodemon: + specifier: ^3.1.14 + version: 3.1.14 + pino: + specifier: ^9.14.0 + version: 9.14.0 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + rimraf: + specifier: ^6.1.3 + version: 6.1.3 + stylelint: + specifier: ^17.11.0 + version: 17.11.0 + stylelint-config-html: + specifier: ^1.1.0 + version: 1.1.0 + stylelint-config-recommended-vue: + specifier: ^1.6.1 + version: 1.6.1 + stylelint-config-standard: + specifier: ^40.0.0 + version: 40.0.0 + typescript-eslint: + specifier: ^8.57.0 + version: 8.57.0 + vue-eslint-parser: + specifier: ^10.4.0 + version: 10.4.0 + types: + '@types/jsdom': + specifier: ^21.1.7 + version: 21.1.7 + '@types/json-schema': + specifier: ^7.0.15 + version: 7.0.15 + '@types/node': + specifier: ^24.12.0 + version: 24.12.0 + undici-types: + specifier: ^7.16.0 + version: 7.16.0 + +importers: + + .: + devDependencies: + '@commitlint/cli': + specifier: catalog:tools + version: 20.5.3(@types/node@24.12.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3) + '@commitlint/config-conventional': + specifier: catalog:tools + version: 20.5.3 + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:packages/eslintconfig + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + husky: + specifier: catalog:tools + version: 9.1.7 + lint-staged: + specifier: catalog:tools + version: 16.4.0 + + apps/client: + dependencies: + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@gouvfr/dsfr': + specifier: catalog:runtime + version: 1.14.4 + '@gouvminint/vue-dsfr': + specifier: catalog:runtime + version: 8.15.0(@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3)))(vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)) + '@iconify-json/ri': + specifier: catalog:runtime + version: 1.2.10 + '@iconify/vue': + specifier: catalog:runtime + version: 4.3.0(vue@3.5.30(typescript@5.9.3)) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@vue/tsconfig': + specifier: catalog:build + version: 0.7.0(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + axios: + specifier: catalog:runtime + version: 1.16.0 + javascript-time-ago: + specifier: catalog:runtime + version: 2.6.4 + jszip: + specifier: catalog:runtime + version: 3.10.1 + keycloak-js: + specifier: catalog:runtime + version: 26.2.3 + nanoid: + specifier: catalog:runtime + version: 5.0.9 + p-debounce: + specifier: catalog:runtime + version: 4.0.0 + pinia: + specifier: catalog:runtime + version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + vue: + specifier: catalog:runtime + version: 3.5.30(typescript@5.9.3) + vue-router: + specifier: catalog:runtime + version: 4.6.4(vue@3.5.30(typescript@5.9.3)) + vue3-json-viewer: + specifier: catalog:runtime + version: 2.4.1(vue@3.5.30(typescript@5.9.3)) + xbytes: + specifier: catalog:runtime + version: 1.9.1 + yaml: + specifier: catalog:runtime + version: 2.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@iconify/types': + specifier: catalog:build + version: 2.0.0 + '@types/jsdom': + specifier: catalog:types + version: 21.1.7 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@unocss/transformer-directives': + specifier: catalog:build + version: 0.65.4 + '@vitejs/plugin-vue': + specifier: catalog:build + version: 6.0.4(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3)) + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + '@vue/eslint-config-typescript': + specifier: catalog:tools + version: 14.7.0(eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + chalk: + specifier: catalog:tools + version: 5.6.2 + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + eslint-plugin-vue: + specifier: catalog:tools + version: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + jsdom: + specifier: catalog:test + version: 25.0.1 + rimraf: + specifier: catalog:tools + version: 6.1.3 + stylelint: + specifier: catalog:tools + version: 17.11.0(typescript@5.9.3) + stylelint-config-html: + specifier: catalog:tools + version: 1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-recommended-vue: + specifier: catalog:tools + version: 1.6.1(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-standard: + specifier: catalog:tools + version: 40.0.0(stylelint@17.11.0(typescript@5.9.3)) + typescript: + specifier: catalog:build + version: 5.9.3 + unocss: + specifier: catalog:build + version: 66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + unplugin-auto-import: + specifier: catalog:build + version: 0.18.6(rollup@2.80.0) + unplugin-vue-components: + specifier: catalog:build + version: 0.27.5(@babel/parser@7.29.7)(rollup@2.80.0)(vue@3.5.30(typescript@5.9.3)) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vite-plugin-pwa: + specifier: catalog:build + version: 1.2.0(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + vue-eslint-parser: + specifier: catalog:tools + version: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + vue-tsc: + specifier: catalog:build + version: 2.2.12(typescript@5.9.3) + workbox-window: + specifier: catalog:build + version: 7.4.0 + + apps/server: + dependencies: + '@cpn-console/argocd-plugin': + specifier: workspace:^ + version: link:../../plugins/argocd + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../../plugins/gitlab + '@cpn-console/harbor-plugin': + specifier: workspace:^ + version: link:../../plugins/harbor + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../../plugins/keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/nexus-plugin': + specifier: workspace:^ + version: link:../../plugins/nexus + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/sonarqube-plugin': + specifier: workspace:^ + version: link:../../plugins/sonarqube + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../../plugins/vault + '@fastify/cookie': + specifier: catalog:runtime + version: 11.0.2 + '@fastify/helmet': + specifier: catalog:runtime + version: 13.0.2 + '@fastify/otel': + specifier: catalog:runtime + version: 0.18.1(@opentelemetry/api@1.9.1) + '@fastify/session': + specifier: catalog:runtime + version: 11.1.1 + '@fastify/swagger': + specifier: catalog:runtime + version: 9.7.0 + '@fastify/swagger-ui': + specifier: catalog:runtime + version: 5.2.6 + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + '@kubernetes-models/argo-cd': + specifier: catalog:runtime + version: 2.7.2 + '@opentelemetry/auto-instrumentations-node': + specifier: catalog:otel + version: 0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': + specifier: catalog:otel + version: 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': + specifier: catalog:otel + version: 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: catalog:otel + version: 0.212.0(@opentelemetry/api@1.9.1) + '@prisma/client': + specifier: catalog:runtime + version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@ts-rest/fastify': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76) + '@ts-rest/open-api': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76) + axios: + specifier: catalog:runtime + version: 1.16.0 + date-fns: + specifier: catalog:runtime + version: 4.1.0 + fastify: + specifier: catalog:runtime + version: 5.8.5 + fastify-keycloak-adapter: + specifier: catalog:runtime + version: 3.0.2 + json-2-csv: + specifier: catalog:runtime + version: 5.5.10 + mustache: + specifier: catalog:runtime + version: 4.2.0 + prisma: + specifier: catalog:runtime + version: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + undici: + specifier: catalog:runtime + version: 7.24.5 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + fastify-plugin: + specifier: catalog:build + version: 5.1.0 + nodemon: + specifier: catalog:tools + version: 3.1.14 + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + rimraf: + specifier: catalog:tools + version: 6.1.3 + ts-patch: + specifier: catalog:build + version: 3.3.0 + typescript: + specifier: catalog:build + version: 5.9.3 + typescript-transform-paths: + specifier: catalog:build + version: 3.5.6(typescript@5.9.3) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + apps/server-nestjs: + dependencies: + '@cpn-console/argocd-plugin': + specifier: workspace:^ + version: link:../../plugins/argocd + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../../plugins/gitlab + '@cpn-console/harbor-plugin': + specifier: workspace:^ + version: link:../../plugins/harbor + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../../plugins/keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/nexus-plugin': + specifier: workspace:^ + version: link:../../plugins/nexus + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/sonarqube-plugin': + specifier: workspace:^ + version: link:../../plugins/sonarqube + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../../plugins/vault + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + '@keycloak/keycloak-admin-client': + specifier: catalog:runtime + version: 26.7.0 + '@kubernetes-models/argo-cd': + specifier: catalog:runtime + version: 2.7.2 + '@nestjs/cache-manager': + specifier: catalog:runtime + version: 3.1.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2) + '@nestjs/common': + specifier: catalog:runtime + version: 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: catalog:runtime + version: 4.0.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: catalog:runtime + version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter': + specifier: catalog:runtime + version: 3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/jwt': + specifier: catalog:runtime + version: 11.0.2(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/platform-fastify': + specifier: catalog:runtime + version: 11.1.27(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/schedule': + specifier: catalog:runtime + version: 6.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/swagger': + specifier: catalog:runtime + version: 11.4.1(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2) + '@nestjs/terminus': + specifier: catalog:runtime + version: 11.1.1(@grpc/grpc-js@1.14.3)(@grpc/proto-loader@0.8.0)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@opentelemetry/api': + specifier: catalog:otel + version: 1.9.1 + '@opentelemetry/auto-instrumentations-node': + specifier: catalog:otel + version: 0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': + specifier: catalog:otel + version: 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': + specifier: catalog:otel + version: 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: catalog:otel + version: 0.212.0(@opentelemetry/api@1.9.1) + '@prisma/client': + specifier: catalog:runtime + version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@ts-rest/fastify': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76) + '@ts-rest/open-api': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76) + cache-manager: + specifier: catalog:runtime + version: 7.2.8 + date-fns: + specifier: catalog:runtime + version: 4.1.0 + fastify: + specifier: catalog:runtime + version: 5.8.5 + fastify-keycloak-adapter: + specifier: catalog:runtime + version: 3.0.2 + json-2-csv: + specifier: catalog:runtime + version: 5.5.10 + mustache: + specifier: catalog:runtime + version: 4.2.0 + nestjs-pino: + specifier: catalog:runtime + version: 4.6.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) + prisma: + specifier: catalog:runtime + version: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + reflect-metadata: + specifier: catalog:runtime + version: 0.2.2 + rxjs: + specifier: catalog:runtime + version: 7.8.2 + undici: + specifier: catalog:runtime + version: 7.24.5 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + yaml: + specifier: catalog:runtime + version: 2.9.0 + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@eslint/eslintrc': + specifier: catalog:tools + version: 3.3.5 + '@eslint/js': + specifier: catalog:tools + version: 9.39.4 + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@nestjs/cli': + specifier: catalog:build + version: 11.0.16(@types/node@24.12.0)(esbuild@0.27.3)(lightningcss@1.32.0) + '@nestjs/schematics': + specifier: catalog:build + version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) + '@nestjs/testing': + specifier: catalog:build + version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16) + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + globals: + specifier: catalog:test + version: 16.5.0 + msw: + specifier: catalog:test + version: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + rimraf: + specifier: catalog:tools + version: 6.1.3 + ts-patch: + specifier: catalog:build + version: 3.3.0 + typescript: + specifier: catalog:build + version: 5.9.3 + typescript-eslint: + specifier: catalog:tools + version: 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + packages/eslintconfig: + devDependencies: + '@antfu/eslint-config': + specifier: catalog:tools + version: 8.2.0(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5) + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + + packages/hooks: + dependencies: + '@cpn-console/logger': + specifier: workspace:^ + version: link:../logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../shared + json-schema: + specifier: catalog:runtime + version: 0.4.0 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/json-schema': + specifier: catalog:types + version: 7.0.15 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + undici-types: + specifier: catalog:types + version: 7.16.0 + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + + packages/logger: + dependencies: + pino: + specifier: catalog:tools + version: 9.14.0 + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + + packages/shared: + dependencies: + '@cpn-console/logger': + specifier: workspace:* + version: link:../logger + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + short-uuid: + specifier: catalog:runtime + version: 5.2.0 + zod: + specifier: catalog:runtime + version: 3.25.76 + zod-validation-error: + specifier: catalog:runtime + version: 3.5.4(zod@3.25.76) + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + packages/test-utils: + dependencies: + '@cpn-console/shared': + specifier: workspace:^ + version: link:../shared + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + + packages/tsconfig: {} + + playwright: + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@playwright/test': + specifier: catalog:test + version: 1.59.1 + '@types/node': + specifier: catalog:types + version: 24.12.0 + + plugins/argocd: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@himenon/argocd-typescript-openapi': + specifier: catalog:runtime + version: 1.2.2 + axios: + specifier: catalog:runtime + version: 1.16.0 + yaml: + specifier: catalog:runtime + version: 2.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/gitlab: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/harbor: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + bytes: + specifier: catalog:runtime + version: 3.1.2 + cron-validator: + specifier: catalog:runtime + version: 1.4.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + js-yaml: + specifier: catalog:tools + version: 4.2.0 + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + swagger-typescript-api: + specifier: catalog:build + version: 13.3.1(magicast@0.5.3) + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/keycloak: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@keycloak/keycloak-admin-client': + specifier: catalog:runtime + version: 26.7.0 + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/nexus: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/sonarqube: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/vault: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + +packages: + + '@anatine/zod-openapi@1.14.2': + resolution: {integrity: sha512-q0qHfnuNYVKu0Swrnnvfj9971AEyW7c8v9jCOZGCl5ZbyGMNG4RPyJkRcMi/JC8CRfdOe0IDfNm1nNsi2avprg==} + peerDependencies: + openapi3-ts: ^2.0.0 || ^3.0.0 + zod: ^3.20.0 + + '@angular-devkit/core@19.2.17': + resolution: {integrity: sha512-Ah008x2RJkd0F+NLKqIpA34/vUGwjlprRCkvddjDopAWRzYn6xCkz1Tqwuhn0nR1Dy47wTLKYD999TYl5ONOAQ==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/core@19.2.19': + resolution: {integrity: sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/schematics-cli@19.2.19': + resolution: {integrity: sha512-7q9UY6HK6sccL9F3cqGRUwKhM7b/XfD2YcVaZ2WD7VMaRlRm85v6mRjSrfKIAwxcQU0UK27kMc79NIIqaHjzxA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + hasBin: true + + '@angular-devkit/schematics@19.2.17': + resolution: {integrity: sha512-ADfbaBsrG8mBF6Mfs+crKA/2ykB8AJI50Cv9tKmZfwcUcyAdmTr+vVvhsBCfvUAEokigSsgqgpYxfkJVxhJYeg==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@angular-devkit/schematics@19.2.19': + resolution: {integrity: sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@antfu/eslint-config@8.2.0': + resolution: {integrity: sha512-spfwYXMNrlkl69riTSBnbC0C2K8EVfVMOK3ceP2EpAAioyfprIW1gTwyLRtd9jZSFeNdX4mFNAIG+o0sOneOfA==} + hasBin: true + peerDependencies: + '@angular-eslint/eslint-plugin': ^21.1.0 + '@angular-eslint/eslint-plugin-template': ^21.1.0 + '@angular-eslint/template-parser': ^21.1.0 + '@eslint-react/eslint-plugin': ^3.0.0 + '@next/eslint-plugin-next': '>=15.0.0' + '@prettier/plugin-xml': ^3.4.1 + '@unocss/eslint-plugin': '>=0.50.0' + astro-eslint-parser: ^1.0.2 + eslint: ^9.10.0 || ^10.0.0 + eslint-plugin-astro: ^1.2.0 + eslint-plugin-format: '>=0.1.0' + eslint-plugin-jsx-a11y: '>=6.10.2' + eslint-plugin-react-refresh: ^0.5.0 + eslint-plugin-solid: ^0.14.3 + eslint-plugin-svelte: '>=2.35.1' + eslint-plugin-vuejs-accessibility: ^2.4.1 + prettier-plugin-astro: ^0.14.0 + prettier-plugin-slidev: ^1.0.5 + svelte-eslint-parser: '>=0.37.0' + peerDependenciesMeta: + '@angular-eslint/eslint-plugin': + optional: true + '@angular-eslint/eslint-plugin-template': + optional: true + '@angular-eslint/template-parser': + optional: true + '@eslint-react/eslint-plugin': + optional: true + '@next/eslint-plugin-next': + optional: true + '@prettier/plugin-xml': + optional: true + '@unocss/eslint-plugin': + optional: true + astro-eslint-parser: + optional: true + eslint-plugin-astro: + optional: true + eslint-plugin-format: + optional: true + eslint-plugin-jsx-a11y: + optional: true + eslint-plugin-react-refresh: + optional: true + eslint-plugin-solid: + optional: true + eslint-plugin-svelte: + optional: true + eslint-plugin-vuejs-accessibility: + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-slidev: + optional: true + svelte-eslint-parser: + optional: true + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@antfu/utils@0.7.10': + resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + + '@apideck/better-ajv-errors@0.3.7': + resolution: {integrity: sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==} + engines: {node: '>=10'} + peerDependencies: + ajv: '>=8' + + '@apidevtools/json-schema-ref-parser@14.0.1': + resolution: {integrity: sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==} + engines: {node: '>= 16'} + + '@apidevtools/openapi-schemas@2.1.0': + resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} + engines: {node: '>=10'} + + '@apidevtools/swagger-methods@3.0.2': + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + + '@apidevtools/swagger-parser@12.1.0': + resolution: {integrity: sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==} + peerDependencies: + openapi-types: '>=7' + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.29.7': + resolution: {integrity: sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.29.7': + resolution: {integrity: sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.29.7': + resolution: {integrity: sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7': + resolution: {integrity: sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7': + resolution: {integrity: sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7': + resolution: {integrity: sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7': + resolution: {integrity: sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7': + resolution: {integrity: sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7': + resolution: {integrity: sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.29.7': + resolution: {integrity: sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-arrow-functions@7.29.7': + resolution: {integrity: sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.7': + resolution: {integrity: sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.29.7': + resolution: {integrity: sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.29.7': + resolution: {integrity: sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.29.7': + resolution: {integrity: sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.29.7': + resolution: {integrity: sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.29.7': + resolution: {integrity: sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.29.7': + resolution: {integrity: sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.29.7': + resolution: {integrity: sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.29.7': + resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.29.7': + resolution: {integrity: sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.29.7': + resolution: {integrity: sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-dynamic-import@7.29.7': + resolution: {integrity: sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.29.7': + resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.29.7': + resolution: {integrity: sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.29.7': + resolution: {integrity: sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.29.7': + resolution: {integrity: sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.29.7': + resolution: {integrity: sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.29.7': + resolution: {integrity: sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.29.7': + resolution: {integrity: sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7': + resolution: {integrity: sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.29.7': + resolution: {integrity: sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.29.7': + resolution: {integrity: sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@7.29.7': + resolution: {integrity: sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-umd@7.29.7': + resolution: {integrity: sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.29.7': + resolution: {integrity: sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7': + resolution: {integrity: sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.29.7': + resolution: {integrity: sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.29.7': + resolution: {integrity: sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.29.7': + resolution: {integrity: sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.29.7': + resolution: {integrity: sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.29.7': + resolution: {integrity: sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.29.7': + resolution: {integrity: sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.29.7': + resolution: {integrity: sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.29.7': + resolution: {integrity: sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.29.7': + resolution: {integrity: sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.7': + resolution: {integrity: sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regexp-modifiers@7.29.7': + resolution: {integrity: sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-reserved-words@7.29.7': + resolution: {integrity: sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.29.7': + resolution: {integrity: sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.29.7': + resolution: {integrity: sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.29.7': + resolution: {integrity: sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.29.7': + resolution: {integrity: sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.29.7': + resolution: {integrity: sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.29.7': + resolution: {integrity: sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.29.7': + resolution: {integrity: sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.29.7': + resolution: {integrity: sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7': + resolution: {integrity: sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.29.7': + resolution: {integrity: sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@biomejs/js-api@4.0.0': + resolution: {integrity: sha512-EOArR/6drRzM1/hwOIz1pZw90FL31Ud4Y7hEHGWVtMNmAwS9SrwZ8hMENGlLVXCeGW/kL46p8kX7eO6x9Nmezg==} + peerDependencies: + '@biomejs/wasm-bundler': ^2.3.0 + '@biomejs/wasm-nodejs': ^2.3.0 + '@biomejs/wasm-web': ^2.3.0 + peerDependenciesMeta: + '@biomejs/wasm-bundler': + optional: true + '@biomejs/wasm-nodejs': + optional: true + '@biomejs/wasm-web': + optional: true + + '@biomejs/wasm-nodejs@2.4.6': + resolution: {integrity: sha512-YRhedzOovXDMs+ZKzi/ZcDTefZIMcrp3z0Ruq+abrNb622aYUh/2m1Ooj8BcG4anGUGR6o+dTCbvAEx9vc74hw==} + + '@borewit/text-codec@0.2.1': + resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + + '@cacheable/memory@2.0.8': + resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} + + '@cacheable/utils@2.4.0': + resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==} + + '@clack/core@1.3.0': + resolution: {integrity: sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.3.0': + resolution: {integrity: sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw==} + engines: {node: '>= 20.12.0'} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@commitlint/cli@20.5.3': + resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@20.5.3': + resolution: {integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@20.5.0': + resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} + engines: {node: '>=v18'} + + '@commitlint/ensure@20.5.3': + resolution: {integrity: sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@20.0.0': + resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} + engines: {node: '>=v18'} + + '@commitlint/format@20.5.0': + resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@20.5.0': + resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} + engines: {node: '>=v18'} + + '@commitlint/lint@20.5.3': + resolution: {integrity: sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==} + engines: {node: '>=v18'} + + '@commitlint/load@20.5.3': + resolution: {integrity: sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==} + engines: {node: '>=v18'} + + '@commitlint/message@20.4.3': + resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} + engines: {node: '>=v18'} + + '@commitlint/parse@20.5.0': + resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} + engines: {node: '>=v18'} + + '@commitlint/read@20.5.0': + resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@20.5.3': + resolution: {integrity: sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==} + engines: {node: '>=v18'} + + '@commitlint/rules@20.5.3': + resolution: {integrity: sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@20.0.0': + resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} + engines: {node: '>=v18'} + + '@commitlint/top-level@20.4.3': + resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} + engines: {node: '>=v18'} + + '@commitlint/types@20.5.0': + resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} + engines: {node: '>=v18'} + + '@conventional-changelog/git-client@2.7.0': + resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} + engines: {node: '>=18'} + peerDependencies: + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.4.0 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@csstools/media-query-list-parser@5.0.0': + resolution: {integrity: sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/selector-resolve-nested@4.0.0': + resolution: {integrity: sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@csstools/selector-specificity@6.0.0': + resolution: {integrity: sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@e18e/eslint-plugin@0.3.0': + resolution: {integrity: sha512-hHgfpxsrZ2UYHcicA+tGZnmk19uJTaye9VH79O+XS8R4ona2Hx3xjhXghclNW58uXMk3xXlbYEOMr8thsoBmWg==} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + oxlint: ^1.55.0 + peerDependenciesMeta: + eslint: + optional: true + oxlint: + optional: true + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@es-joy/jsdoccomment@0.84.0': + resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@es-joy/jsdoccomment@0.86.0': + resolution: {integrity: sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-plugin-eslint-comments@4.7.1': + resolution: {integrity: sha512-Ql2nJFwA8wUGpILYGOQaT1glPsmvEwE0d+a+l7AALLzQvInqdbXJdx7aSu0DpUX9dB1wMVBMhm99/++S3MdEtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/compat@2.0.3': + resolution: {integrity: sha512-SjIJhGigp8hmd1YGIBwh7Ovri7Kisl42GYFjrOyHhtfYGGoLW6teYi/5p8W50KSsawUPpuLOSmsq1bD0NGQLBw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^8.40 || 9 || 10 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/markdown@8.0.1': + resolution: {integrity: sha512-WWKmld/EyNdEB8GMq7JMPX1SDWgyJAM1uhtCi5ySrqYQM4HQjmg11EX/q3ZpnpRXHfdccFtli3NBvvGaYjWyQw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exodus/schemasafe@1.3.0': + resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} + + '@faker-js/faker@9.9.0': + resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/formbody@8.0.2': + resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/helmet@13.0.2': + resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==} + + '@fastify/jwt@9.1.0': + resolution: {integrity: sha512-CiGHCnS5cPMdb004c70sUWhQTfzrJHAeTywt7nVw6dAiI0z1o4WRvU94xfijhkaId4bIxTCOjFgn4sU+Gvk43w==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/otel@0.18.1': + resolution: {integrity: sha512-7TYQXrOBRwCuTiwQm/2qCPO37af011934clxBj6F7KyF9a2a9MB+aSrWv8vMVp5N8rZC1AQ4pUp8uDs5Z40UPQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/session@11.1.1': + resolution: {integrity: sha512-nuKwTHxh3eJsI4NJeXoYVGzXUsg+kH1WfHgS7IofVyVhmjc+A6qGr+29WQy8hYZiNtmCjfG415COpf5xTBkW4Q==} + + '@fastify/static@9.1.3': + resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} + + '@fastify/swagger-ui@5.2.6': + resolution: {integrity: sha512-OMnms0O5s9wb6wis/K5nlrAMLsgUbr1GA8uphM41IasWe3AFdgxz6r/3bA9HTxlDNUYc2FGGKeqMp3ntxmSiNA==} + + '@fastify/swagger@9.7.0': + resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} + + '@gitbeaker/core@40.6.0': + resolution: {integrity: sha512-tVVm8ZPrS9YCHEcuPV8vD1IcEf9POpdygWo+kPvkK7LcC36EERVcXagb8snEaGgGLfUaVQh8qP4iDZgPnP3YBQ==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/requester-utils@40.6.0': + resolution: {integrity: sha512-DQu2l3iXtB+8e1Ye2ekeUHABt4mGMRTLtuVWtFqf74sqJnerHNOxVOjPn19qu/nKdvKR3ZLwSRTtPzEsxgcShg==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/rest@40.6.0': + resolution: {integrity: sha512-sAwYJclU3NlB/gdxqhH6Hnmy5LWzvW7D3W33eShQEnxMhM0VjnFHPHcgJLQCIux3hMiub1uGtTw1hBJTxDc2mQ==} + engines: {node: '>=18.20.0'} + + '@gouvfr/dsfr@1.14.4': + resolution: {integrity: sha512-Ca17apmTyAF2tMFwMcLwQN7fTlbfMFeyYSLUIUmjoSg1XVF4vDDkbvvKBpWJWaoTLDSFZBbvvHzcDyxw9wSkLA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@gouvminint/vue-dsfr@8.15.0': + resolution: {integrity: sha512-e9q7xJeWpGLLFmbe8YCeZ3bgsBM4LWMMiRVmde5DeAfILXewPh3uFKGMphWVKVKnz3tnxrgYM0GoGOn3I9FoYQ==} + engines: {node: '>=22.0.0', pnpm: '>=10.0.0'} + hasBin: true + peerDependencies: + '@iconify/vue': ^4.2.0 + vue: ^3.5.27 + vue-router: ^4.6.4 + + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + + '@himenon/argocd-typescript-openapi@1.2.2': + resolution: {integrity: sha512-xS8HAzhRSQXqMfD779EgbZmPcgkaNW8Hf2anrwvvPYb9Tt0vP9CtzOqj+8GYSOUQqgcRFejmCR9WoAgZJamB1g==} + engines: {pnpm: '>=8'} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@iconify-json/ri@1.2.10': + resolution: {integrity: sha512-WWMhoncVVM+Xmu9T5fgu2lhYRrKTEWhKk3Com0KiM111EeEsRLiASjpsFKnC/SrB6covhUp95r2mH8tGxhgd5Q==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + + '@iconify/vue@4.3.0': + resolution: {integrity: sha512-Xq0h6zMrHBbrW8jXJ9fISi+x8oDQllg5hTDkDuxnWiskJ63rpJu9CvJshj8VniHVTbsxCg9fVoPAaNp3RQI5OQ==} + peerDependencies: + vue: '>=3' + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.3.2': + resolution: {integrity: sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@keycloak/keycloak-admin-client@26.7.0': + resolution: {integrity: sha512-QEsX19sFCvknBzV2zelEkZ7waglCqzayY1zDtoRQinHuUiAe2f7q+xXT6R8XNukn+rShB41TOlG4QR2Bt6hxhw==} + engines: {node: '>=18'} + + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@kubernetes-models/apimachinery@2.2.0': + resolution: {integrity: sha512-7Yj3aMz/nCTdr221wHOMsw5vAMJnLqANMI3BTuCmYjbP+7/tGAaOsiFwT75F4TJH45Dk1P6wcAEqDeCD1ur82Q==} + engines: {node: '>=14'} + + '@kubernetes-models/argo-cd@2.7.2': + resolution: {integrity: sha512-Dge+7Uq54dt5c3pzusOw8i26FHWqJiyiFqHKQKHUNjOq2zb3e7LY2OYBrNP7hDSoM0Ap5K9cJTMgNzsaaKdjOQ==} + engines: {node: '>=14'} + + '@kubernetes-models/base@5.0.1': + resolution: {integrity: sha512-Yfzmh965y/hT8qs9eE+Ms9sO1iT31c/sPoJerBFRGS9KechXXfxHgeqtpOzq+eByQ1INblkcZ09rt2V0FxG0lA==} + engines: {node: '>=14'} + + '@kubernetes-models/validate@4.0.0': + resolution: {integrity: sha512-rsfF5t3sd5c+3ejUgcy8q0cVG2/BxT064L4Xz+CuKEe014u8T4MtFZiWjWZFpfMXSGKzFYEA6DJYm9CqjmIfZg==} + engines: {node: '>=14'} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@microsoft/kiota-abstractions@1.0.0-preview.103': + resolution: {integrity: sha512-om7nKH8nWyEAQ0lgbIdKdrkmznhkdDJdliCnAIo31X/hlUah1aBzvfUx+pbyp67Q040yLK+2J6P7AnvGSoN/qQ==} + + '@microsoft/kiota-http-fetchlibrary@1.0.0-preview.103': + resolution: {integrity: sha512-DbhGL+pdVjtv2AVvxB4fxvNC/GMzKbYyeQsknaXJXwqnPR/oIClWjaXlIxWL5SBK72DOmrZ18leaWMB2vfWvpw==} + + '@microsoft/kiota-serialization-form@1.0.0-preview.103': + resolution: {integrity: sha512-AD5Q59Qu61DfhaLLN7Mvgx4Zw0Zmftqx04XAbrRreC+r9hwT1BqTf0EReCraMzEoC4sdrp/8haI2Arm8dWUWuQ==} + + '@microsoft/kiota-serialization-json@1.0.0-preview.103': + resolution: {integrity: sha512-dTr8q6H42TSe+HaCPBXU4Pv6ypo7MjMGRo/r9GRbnCuy8VCWfe+Vh38rnAZJx4r8cmvOUD7Yw74IJ6q4Hjv3mg==} + + '@microsoft/kiota-serialization-multipart@1.0.0-preview.103': + resolution: {integrity: sha512-b3ePXYgFv/2TQtbzFQ8ZY+hzMtCRkz4Cx5y/Pf8vtoQu56KU0/9rYq5kA9wySDdCx/oZxfubQQRV/8QSTxmejw==} + + '@microsoft/kiota-serialization-text@1.0.0-preview.103': + resolution: {integrity: sha512-FkiboqpNtT2F40xUC9LETHK9ckHd+WuocLJE70ljI4EYvD59MQDsrTERYAwbmkfPIJ6JRE4OfLXFVh510LG1/Q==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} + engines: {node: '>=18'} + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nestjs/cache-manager@3.1.3': + resolution: {integrity: sha512-HMtiOfHz75NZX7mJn1VnZGLSachVI04TnUc5wvEogIaKwk5BDQHgtP5htxreizjv7oxKalJbuTyxtiF6bE+bgQ==} + peerDependencies: + '@nestjs/common': ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 || ^11.0.0 + cache-manager: '>=6' + keyv: '>=5' + rxjs: ^7.8.1 + + '@nestjs/cli@11.0.16': + resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==} + engines: {node: '>= 20.11'} + hasBin: true + peerDependencies: + '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 + '@swc/core': ^1.3.62 + peerDependenciesMeta: + '@swc/cli': + optional: true + '@swc/core': + optional: true + + '@nestjs/common@11.1.16': + resolution: {integrity: sha512-JSIeW+USuMJkkcNbiOdcPkVCeI3TSnXstIVEPpp3HiaKnPRuSbUUKm9TY9o/XpIcPHWUOQItAtC5BiAwFdVITQ==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/config@4.0.3': + resolution: {integrity: sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + '@nestjs/core@11.1.16': + resolution: {integrity: sha512-tXWXyCiqWthelJjrE0KLFjf0O98VEt+WPVx5CrqCf+059kIxJ8y1Vw7Cy7N4fwQafWNrmFL2AfN87DDMbVAY0w==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/event-emitter@3.0.1': + resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/jwt@11.0.2': + resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/platform-express@11.1.16': + resolution: {integrity: sha512-IOegr5+ZfUiMKgk+garsSU4MOkPRhm46e6w8Bp1GcO4vCdl9Piz6FlWAzKVfa/U3Hn/DdzSVJOW3TWcQQFdBDw==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + + '@nestjs/platform-fastify@11.1.27': + resolution: {integrity: sha512-rZMPS0RMP9P2O7Y/shAP0kKwrHElGO9cPTlgtdWpQ6JEX58uhYEJLGsU/X9a9l18DNSxxb3YjrEiVVuI2Iv6MA==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@fastify/view': ^10.0.0 || ^11.0.0 || ^12.0.0 + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + '@fastify/view': + optional: true + + '@nestjs/schedule@6.1.1': + resolution: {integrity: sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/schematics@11.0.9': + resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} + peerDependencies: + typescript: '>=4.8.2' + + '@nestjs/swagger@11.4.1': + resolution: {integrity: sha512-GuGzs8F1Cb3n+eEarmOqB4nt2ai+x4XGOYUXNYplOtDeB59DaFY5E16bsHsBWXiWgD1ywbyKQ5OVv02bQtB1Dw==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/terminus@11.1.1': + resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==} + peerDependencies: + '@grpc/grpc-js': '*' + '@grpc/proto-loader': '*' + '@mikro-orm/core': '*' + '@mikro-orm/nestjs': '*' + '@nestjs/axios': ^2.0.0 || ^3.0.0 || ^4.0.0 + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/microservices': ^10.0.0 || ^11.0.0 + '@nestjs/mongoose': ^11.0.0 + '@nestjs/sequelize': ^10.0.0 || ^11.0.0 + '@nestjs/typeorm': ^10.0.0 || ^11.0.0 + '@prisma/client': '*' + mongoose: '*' + reflect-metadata: 0.1.x || 0.2.x + rxjs: 7.x + sequelize: '*' + typeorm: '*' + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + '@grpc/proto-loader': + optional: true + '@mikro-orm/core': + optional: true + '@mikro-orm/nestjs': + optional: true + '@nestjs/axios': + optional: true + '@nestjs/microservices': + optional: true + '@nestjs/mongoose': + optional: true + '@nestjs/sequelize': + optional: true + '@nestjs/typeorm': + optional: true + '@prisma/client': + optional: true + mongoose: + optional: true + sequelize: + optional: true + typeorm: + optional: true + + '@nestjs/testing@11.1.16': + resolution: {integrity: sha512-E7/aUCxzeMSJV80L5GWGIuiMyR/1ncS7uOIetAImfbS4ATE1/h2GBafk0qpk+vjFtPIbtoh9BWDGICzUEU5jDA==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nuxt/opencollective@0.4.1': + resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} + engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} + hasBin: true + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@opentelemetry/api-logs@0.212.0': + resolution: {integrity: sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.213.0': + resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.214.0': + resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/auto-instrumentations-node@0.70.1': + resolution: {integrity: sha512-r8BKs0rHtBAzZViPIuzSD2eh65fOPau0NqVsca2sACuZ6LFGu6a+QMhqq7skXz+/OqKwFr/7/b6VsaNMS+zZpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.4.1 + '@opentelemetry/core': ^2.0.0 + + '@opentelemetry/configuration@0.212.0': + resolution: {integrity: sha512-D8sAY6RbqMa1W8lCeiaSL2eMCW2MF87QI3y+I6DQE1j+5GrDMwiKPLdzpa/2/+Zl9v1//74LmooCTCJBvWR8Iw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/context-async-hooks@2.5.1': + resolution: {integrity: sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.5.1': + resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.212.0': + resolution: {integrity: sha512-/0bk6fQG+eSFZ4L6NlckGTgUous/ib5+OVdg0x4OdwYeHzV3lTEo3it1HgnPY6UKpmX7ki+hJvxjsOql8rCeZA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.212.0': + resolution: {integrity: sha512-JidJasLwG/7M9RTxV/64xotDKmFAUSBc9SNlxI32QYuUMK5rVKhHNWMPDzC7E0pCAL3cu+FyiKvsTwLi2KqPYw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.212.0': + resolution: {integrity: sha512-RpKB5UVfxc7c6Ta1UaCrxXDTQ0OD7BCGT66a97Q5zR1x3+9fw4dSaiqMXT/6FAWj2HyFbem6Rcu1UzPZikGTWQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0': + resolution: {integrity: sha512-/6Gqf9wpBq22XsomR1i0iPGnbQtCq2Vwnrq5oiDPjYSqveBdK1jtQbhGfmpK2mLLxk4cPDtD1ZEYdIou5K8EaA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.212.0': + resolution: {integrity: sha512-8hgBw3aTTRpSTkU4b9MLf/2YVLnfWp+hfnLq/1Fa2cky+vx6HqTodo+Zv1GTIrAKMOOwgysOjufy0gTxngqeBg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0': + resolution: {integrity: sha512-yw3fTIw4KQIRXC/ZyYQq5gtA3Ogfdfz/g5HVgleobQAcjUUE8Nj3spGMx8iQPp+S+u6/js7BixufRkXhzLmpJA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.212.0': + resolution: {integrity: sha512-C7I4WN+ghn3g7SnxXm2RK3/sRD0k/BYcXaK6lGU3yPjiM7a1M25MLuM6zY3PeVPPzzTZPfuS7+wgn/tHk768Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0': + resolution: {integrity: sha512-geHF+zZaDb0/WRkJTxR8o8dG4fCWT/Wq7HBdNZCxwH5mxhwRi/5f37IDYH7nvU+dwU6IeY4Pg8TPI435JCiNkg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.212.0': + resolution: {integrity: sha512-hJFLhCJba5MW5QHexZMHZdMhBfNqNItxOsN0AZojwD1W2kU9xM+BEICowFGJFo/vNV+I2BJvTtmuKafeDSAo7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.212.0': + resolution: {integrity: sha512-9xTuYWp8ClBhljDGAoa0NSsJcsxJsC9zCFKMSZJp1Osb9pjXCMRdA6fwXtlubyqe7w8FH16EWtQNKx/FWi+Ghw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.212.0': + resolution: {integrity: sha512-v/0wMozNoiEPRolzC4YoPo4rAT0q8r7aqdnRw3Nu7IDN0CGFzNQazkfAlBJ6N5y0FYJkban7Aw5WnN73//6YlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.212.0': + resolution: {integrity: sha512-d1ivqPT0V+i0IVOOdzGaLqonjtlk5jYrW7ItutWzXL/Mk+PiYb59dymy/i2reot9dDnBFWfrsvxyqdutGF5Vig==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0': + resolution: {integrity: sha512-six3vPq3sL+ge1iZOfKEg+RHuFQhGb8ZTdlvD234w/0gi8ty/qKD46qoGpKvM3amy5yYunWBKiFBW47WaVS26w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.5.1': + resolution: {integrity: sha512-Me6JVO7WqXGXsgr4+7o+B7qwKJQbt0c8WamFnxpkR43avgG9k/niTntwCaXiXUTjonWy0+61ZuX6CGzj9nn8CQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-amqplib@0.59.0': + resolution: {integrity: sha512-xscSgOJA+GHphESDZxBHNk/zjNaEgoeufMwmiqYdL+qM27Xw3BbR9vN6Ucbq9dW6Y+oYUPgTTj17qf+Za4+uzg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-lambda@0.64.0': + resolution: {integrity: sha512-vYhM/a8fG34/Dl/Q9gfv5Ih3OFPgqeyn79S8FN+Xs/QZw6h6L8a1lDa3CyigyicOXLCmVIM7Fc9vFD4BGqgGLA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-sdk@0.67.0': + resolution: {integrity: sha512-btpwJnZ2RBXDh/pTpfVpInpBu9Pedi+lbLKbt3naB344SggbbYnIdT7u8EzmGIApWi9EV91vw7hm896I7nESQA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-bunyan@0.57.0': + resolution: {integrity: sha512-W4zLz1Y9ptCsdL+QMXR7xQaBHkJivLBmVlLCjUe23rX4V8E65fGAtlIJSKTKAfz4aEgtWgQAGMdkeqACwG0Caw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cassandra-driver@0.57.0': + resolution: {integrity: sha512-xLwrK+XnN32IB5i6t/a2j+SVdjlq/BIgjpVRkke4HAsKjoSMy1GeSI+ZOiJffRLFb4MojcvH4RG2+nEg1uC6Zg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.55.0': + resolution: {integrity: sha512-UfGw7ubKKZBoTRjxi5KlfeECEaXZinS20RdRNlZE5tVF+O17hJOnrcGwAoQAHp6eYmxI2jW9IQ4t6450gnNF9g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cucumber@0.28.0': + resolution: {integrity: sha512-kim+bRxu4LZqKEyF2SgO01tgG88W+/iYltyP1XjT31FIXzlBjzQpwtSLLM8byayO85mcZIBha54WSNFDLM/7qQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-dataloader@0.29.0': + resolution: {integrity: sha512-220WjRb1G1UiAKbVblSMxwxxFdpyB4wj1XYIO9BJs5r62Azj2dL5fyZiXK3/WO6wB3uLul9R946iKI1bpPxktQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dns@0.55.0': + resolution: {integrity: sha512-cfWLaFi22V+sQrKY7t6QroYzT3kO9m3PpkN1OXYmuCyfwxQaXOVlF8NSAHtua/RQYw0aQl+2fe6JOWyJdEZiwA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.60.0': + resolution: {integrity: sha512-KghHCDqKq0D7iuPIVCuPSXut5WVAI6uwKcPrhwTUJL5VE2LC18id2vKoiAm1V8XvVlgIGAiECtEvbrFwkTCj3g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fastify@0.56.0': + resolution: {integrity: sha512-zotOPoZsWtMF47BjottK23XaaBSmVuwG5D/R3FlGfAAwMNFoDR3IY1OGO9v9KfOU/1/xDVkxsQ22NFfu9lE8aA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.31.0': + resolution: {integrity: sha512-C7tdXGDnkMgLVlE79VSekB+Y+P345zKUigvFMs5M7U0GIYA8ERx3FS0aAcY/ICIq9YwRmH2uuMb++Br5M2vNUg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.55.0': + resolution: {integrity: sha512-7hWiyLbEX/dIS4LZy/h8VaAQPs8oBeEqsrysDWbos0b9PF414L6Rsbi2um/omtxIs+GTvsbuqDscWigeaxyWdA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.60.0': + resolution: {integrity: sha512-XPATrmxAd2tFCsYbJ3eVIXt+gyvMKjc36QQuQxjtssMnAbw006Le9b5lKs7WXik7ItOpM1exATi1aDdOcCjRRg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-grpc@0.212.0': + resolution: {integrity: sha512-r1t7LNKWVhSQMUrBdDJtooFmmLZ93kGuFixqeXPoUP8W+chJCxhey9l0c0+L3xriNdyB7TzvkKHhPXUDevgVEA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.58.0': + resolution: {integrity: sha512-reuRApR2KHm2VsfyDgsrLhNE+IOy4uIU6n3oMjUleReHacEEZmf4vXxdt4/qcmJ6GoUXnRN2AOu3s5N3pMrgYA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.212.0': + resolution: {integrity: sha512-t2nt16Uyv9irgR+tqnX96YeToOStc3X5js7Ljn3EKlI2b4Fe76VhMkTXtsTQ0aId6AsYgefrCRnXSCo/Fn/vww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.60.0': + resolution: {integrity: sha512-R+nnbPD9l2ruzu248qM3YDWzpdmWVaFFFv08lQqsc0EP4pT/B1GGUg06/tHOSo3L5njB2eejwyzpkvJkjaQEMA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.21.0': + resolution: {integrity: sha512-lkLrILnKGO7SHw1xPJnuGx2S4XwbKmQiJyzUGuEImRoU/6Gj0Nka0lkbeRd4ANN20dxr/mLdXIsUsk6DzTrX6A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.56.0': + resolution: {integrity: sha512-pKqtY5lbAQ70MC5K/BJeAA1t2gAUlRBZBAJ5ergRUNs5jw8zbdOXEZOLztiuNvQqD2z4a9N0Tkde9JMFm2pKMQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.60.0': + resolution: {integrity: sha512-UOmu2y2LHgPzKsm9xd0sCQJimr11YP4MKFc190Do1ufd8qds7Zd5BI3f6TudqYhH9dUIhojsQyUaS6K4nv5FsQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.56.0': + resolution: {integrity: sha512-vXtOValhKRgWA9tLAiTU3P37Q31OveRuM2N5iLSVHl4GzkMBQ5p50A9kSKvt5gReL6BzFDXPCM9ItJiAhSS2KQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-memcached@0.55.0': + resolution: {integrity: sha512-kdhW/j5X+vNCAvHVc50PZfvE7diUScg1ZkBaNFRygY3Z6IUjgPLR0luWQMDPSFun6AVo1HaMDPxbUqJrot6qrA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.65.0': + resolution: {integrity: sha512-hOAJRs5vrY7fZolSYUXmf29Y+HFDHWrek0DeLq82uwMPjPSda7h6oumQnqEX5olzw357q/QG39/uJdkclJ/JUg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.58.0': + resolution: {integrity: sha512-3L0Fqo1y2oreISFPWaqdt/bg3NhLgrkn5U/E/9RNG1QaM81drTMBCHseMY1q8SlejjE43ZWOy+0KbmRBlUPJ+g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.58.0': + resolution: {integrity: sha512-EubjV1XZb7XHrENqF7TW2lnah+KN0LddMneKNAB8PjGVKL5lJkVV/vhJ6EIcUNn9nCWmAwZ3GRcFVEDKCnyXfQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.58.0': + resolution: {integrity: sha512-wZDrBCL3WfJclV6KywWVV3/B2ZiUYmDQdgyu3pq4jK/5qSfoDmezHzT/Nayln5MVVWMAGXIMLrCj8BKa6jaKQQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-nestjs-core@0.58.0': + resolution: {integrity: sha512-0lE9oW8j6nmvBHJoOxIQgKzMQQYNfX1nhiWZdXD0sNAMFsWBtvECWS7NAPSroKrEP53I04TcHCyyhcK4I9voXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-net@0.56.0': + resolution: {integrity: sha512-h69x7U6f86mP3gGWWTaMkQZk0K3tBvpVMIU7E0q2kkVw6eZ5TqFm9rkaEy38moQmixiDFQ9j/2/cwxG9P7ZEeA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-openai@0.10.0': + resolution: {integrity: sha512-0lV2zxge2mMaruVCw/bmypWVu+aJ76rc0HBvAVFCPUI3zzJdgBZJZafGIHZ1IB2F6VvrDFL+JstEnle6V8brvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-oracledb@0.37.0': + resolution: {integrity: sha512-OzMghtAEAEkXlkUrZI4QcXSZq0MILeU6WC0/N5+1MSkuIkruIeaRw99/RtyS2of8vlPDa8XbbXl32Q1RM3wSyg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.64.0': + resolution: {integrity: sha512-NbfB/rlfsRI3zpTjnbvJv3qwuoGLsN8FxR/XoI+ZTn1Rs62x1IenO+TSSvk4NO+7FlXpd2MiOe8LT/oNbydHGA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.58.0': + resolution: {integrity: sha512-rgy+tA7cDjuSq6dXAO40OiYP25azIDHMBtxG3RzSmCBVEYdjggl6btyuLVasX6VkOOhP2gf6PBuLMNxVwaIqAw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.59.0': + resolution: {integrity: sha512-IgImVFtWjfMmqxc0NIe3iSjp+J3Asf9lLX8reouUFUk3Aa/qJQO5PEvOtO3sNQtJBkC9bAd1OQdFaFWSFQc03g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.60.0': + resolution: {integrity: sha512-Ea/GffmmzIVHc9geaMjT94IR7poVZzIv4Kk/Lw0tbxGD3cBYcMUsLFVajKxpZsE1NRCECFpidAWeifCIKD0inw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-restify@0.57.0': + resolution: {integrity: sha512-kO6MsZFU+RdXOKhsKw8SOSBYGYCdFSlza+mpBQRl1DQmveZcnidchv4V5JQPtNgHxCGH+1n3hDpLdxdGUbJPNA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-router@0.56.0': + resolution: {integrity: sha512-PHECDGQElLazI/QbHU16C5m9fDC7DGJk+jLIwO5ca6bcp7bXhUPPUTT78l7da2pDsrz4mhv5ytYNZmBbW/Q3rA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-runtime-node@0.25.0': + resolution: {integrity: sha512-XaCmwBSui5KeTn8M6OzaEn1rEsNWtUkjuc1ylg0tqQTLHibNQ0n7f8v4zdF6x/nBV1OnsiYlN8RLHauGemv/TA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-socket.io@0.59.0': + resolution: {integrity: sha512-71DnM/FEqH0PjvU2uZvzWJeaGyVIy3rJKk8rZrxg/aS2QT3qLGb+UPL/B+1vOw4pzDPn4papLTSMpLVF9G8uvw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.31.0': + resolution: {integrity: sha512-HoF2EtcyP3JR4R3jLPHohZ9lFcj1QLJyGmFfLKDTvUUjPiFuK4XZ6L1OV9HhaqvN0xY+tWKfNdCPS3r33rd0Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.22.0': + resolution: {integrity: sha512-yb6vEWUPOrD5i7yR1XceEEqiVHbMgr5YnUPnom5eQVCjvrTkEVswyrf9i+vvJR+28wrNqILIIphWgOOx6BjnTQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation-winston@0.56.0': + resolution: {integrity: sha512-ITIA0Qe61CQ6FQU/bN23pNBvJ+5U0ofoASMOOYrODtXyV9wI267AigNTTwDmv2Myt8dPEFvvVFJZKhiZLIpehA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.212.0': + resolution: {integrity: sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.213.0': + resolution: {integrity: sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.214.0': + resolution: {integrity: sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.212.0': + resolution: {integrity: sha512-HoMv5pQlzbuxiMS0hN7oiUtg8RsJR5T7EhZccumIWxYfNo/f4wFc7LPDfFK6oHdG2JF/+qTocfqIHoom+7kLpw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.213.0': + resolution: {integrity: sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.212.0': + resolution: {integrity: sha512-YidOSlzpsun9uw0iyIWrQp6HxpMtBlECE3tiHGAsnpEqJWbAUWcMnIffvIuvTtTQ1OyRtwwaE79dWSQ8+eiB7g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.212.0': + resolution: {integrity: sha512-bj7zYFOg6Db7NUwsRZQ/WoVXpAf41WY2gsd3kShSfdpZQDRKHWJiRZIg7A8HvWsf97wb05rMFzPbmSHyjEl9tw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.213.0': + resolution: {integrity: sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@2.5.1': + resolution: {integrity: sha512-AU6sZgunZrZv/LTeHP+9IQsSSH5p3PtOfDPe8VTdwYH69nZCfvvvXehhzu+9fMW2mgJMh5RVpiH8M9xuYOu5Dg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.5.1': + resolution: {integrity: sha512-8+SB94/aSIOVGDUPRFSBRHVUm2A8ye1vC6/qcf/D+TF4qat7PC6rbJhRxiUGDXZtMtKEPM/glgv5cBGSJQymSg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.38.2': + resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resource-detector-alibaba-cloud@0.33.3': + resolution: {integrity: sha512-Ep3LDWALU+wCgGzAa1rgFXh3TObahN7HaQZntAeVQnnNhZ3VSXnBniRGeSCTIRvRr7YdZvq+G+rstixtAN5Ugw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-aws@2.13.0': + resolution: {integrity: sha512-ZPCn7gZhGqUYUoD+RCHIlayoHBMaJaEjfqlgz2EPKoXJ4y7Ru7CUm+Tm3yJVMKF92cN9xUQR0j5KALyF0fg9aw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-azure@0.20.0': + resolution: {integrity: sha512-iRy+O2cB6DOlQ/OONaK+L8Cp8nLS89dZVRp6KgnFAfzykXuq9Ws/ygJKcU3CCmjkgY5j2Vk3uVTre/E35bWhYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-container@0.8.4': + resolution: {integrity: sha512-kIvGHkMSacp+kb7btTuXbOAIWLyOCO+P/h/8xxaeLcp5ptmHRZ67uEdLAQo61ApdayFB/uqjJ9gY4x2/i/KsoA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-gcp@0.47.0': + resolution: {integrity: sha512-57T/kRVdU0ch1P4KPEkmU2b5mWNlUs8hHgqrBYVF+fNZMc1jMdL1mANZhEzoLtWKIeoCEy+57Itt7RkXAYNJiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resources@2.5.1': + resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.212.0': + resolution: {integrity: sha512-qglb5cqTf0mOC1sDdZ7nfrPjgmAqs2OxkzOPIf2+Rqx8yKBK0pS7wRtB1xH30rqahBIut9QJDbDePyvtyqvH/Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.213.0': + resolution: {integrity: sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.5.1': + resolution: {integrity: sha512-RKMn3QKi8nE71ULUo0g/MBvq1N4icEBo7cQSKnL3URZT16/YH3nSVgWegOjwx7FRBTrjOIkMJkCUn/ZFIEfn4A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.6.0': + resolution: {integrity: sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.212.0': + resolution: {integrity: sha512-tJzVDk4Lo44MdgJLlP+gdYdMnjxSNsjC/IiTxj5CFSnsjzpHXwifgl3BpUX67Ty3KcdubNVfedeBc/TlqHXwwg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.5.1': + resolution: {integrity: sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.6.0': + resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.5.1': + resolution: {integrity: sha512-9lopQ6ZoElETOEN0csgmtEV5/9C7BMfA7VtF4Jape3i954b6sTY2k3Xw3CxUTKreDck/vpAuJM+EDo4zheUw+A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.41.2': + resolution: {integrity: sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + + '@ota-meshi/ast-token-store@0.3.0': + resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@oxc-parser/binding-android-arm-eabi@0.115.0': + resolution: {integrity: sha512-VoB2rhgoqgYf64d6Qs5emONQW8ASiTc0xp+aUE4JUhxjX+0pE3gblTYDO0upcN5vt9UlBNmUhAwfSifkfre7nw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.115.0': + resolution: {integrity: sha512-lWRX75u+gqfB4TF3pWCHuvhaeneAmRl2b2qNBcl4S6yJ0HtnT4VXOMEZrq747i4Zby1ZTxj6mtOe678Bg8gRLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.115.0': + resolution: {integrity: sha512-ii/oOZjfGY1aszXTy29Z5DRyCEnBOrAXDVCvfdfXFQsOZlbbOa7NMHD7D+06YFe5qdxfmbWAYv4yn6QJi/0d2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.115.0': + resolution: {integrity: sha512-R/sW/p8l77wglbjpMcF+h/3rWbp9zk1mRP3U14mxTYIC2k3m+aLBpXXgk2zksqf9qKk5mcc4GIYsuCn9l8TgDg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.115.0': + resolution: {integrity: sha512-CSJ5ldNm9wIGGkhaIJeGmxRMZbgxThRN+X1ufYQQUNi5jZDV/U3C2QDMywpP93fczNBj961hXtcUPO/oVGq4Pw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.115.0': + resolution: {integrity: sha512-uWFwssE5dHfQ8lH+ktrsD9JA49+Qa0gtxZHUs62z1e91NgGz6O7jefHGI6aygNyKNS45pnnBSDSP/zV977MsOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.115.0': + resolution: {integrity: sha512-fZbqt8y/sKQ+v6bBCuv/mYYFoC0+fZI3mGDDEemmDOhT78+aUs2+4ZMdbd2btlXmnLaScl37r8IRbhnok5Ka9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.115.0': + resolution: {integrity: sha512-1ej/MjuTY9tJEunU/hUPIFmgH5PqgMQoRjNOvOkibtJ3Zqlw/+Lc+HGHDNET8sjbgIkWzdhX+p4J96A5CPdbag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.115.0': + resolution: {integrity: sha512-HjsZbJPH9mMd4swJRywVMsDZsJX0hyKb1iNHo5ijRl5yhtbO3lj7ImSrrL1oZ1VEg0te4iKmDGGz/6YPLd1G8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.115.0': + resolution: {integrity: sha512-zhhePoBrd7kQx3oClX/W6NldsuCbuMqaN9rRsY+6/WoorAb4j490PG/FjqgAXscWp2uSW2WV9L+ksn0wHrvsrg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.115.0': + resolution: {integrity: sha512-t/IRojvUE9XrKu+/H1b8YINug+7Q6FLls5rsm2lxB5mnS8GN/eYAYrPgHkcg9/1SueRDSzGpDYu3lGWTObk1zw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.115.0': + resolution: {integrity: sha512-79jBHSSh/YpQRAmvYoaCfpyToRbJ/HBrdB7hxK2ku2JMehjopTVo+xMJss/RV7/ZYqeezgjvKDQzapJbgcjVZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.115.0': + resolution: {integrity: sha512-nA1TpxkhNTIOMMyiSSsa7XIVJVoOU/SsVrHIz3gHvWweB5PHCQfO7w+Lb2EP0lBWokv7HtA/KbF7aLDoXzmuMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.115.0': + resolution: {integrity: sha512-9iVX789DoC3SaOOG+X6NcF/tVChgLp2vcHffzOC2/Z1JTPlz6bMG2ogvcW6/9s0BG2qvhNQImd+gbWYeQbOwVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.115.0': + resolution: {integrity: sha512-RmQmk+mjCB0nMNfEYhaCxwofLo1Z95ebHw1AGvRiWGCd4zhCNOyskgCbMogIcQzSB3SuEKWgkssyaiQYVAA4hQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.115.0': + resolution: {integrity: sha512-viigraWWQhhDvX5aGq+wrQq58k00Xq3MHz/0R4AFMxGlZ8ogNonpEfNc73Q5Ly87Z6sU9BvxEdG0dnYTfVnmew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.115.0': + resolution: {integrity: sha512-IzGCrMwXhpb4kTXy/8lnqqqwjI7eOvy+r9AhVw+hsr8t1ecBBEHprcNy0aKatFHN6hsX7UMHHQmBAQjVvL/p1A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.115.0': + resolution: {integrity: sha512-/ym+Absk/TLFvbhh3se9XYuI1D7BrUVHw4RaG/2dmWKgBenrZHaJsgnRb7NJtaOyjEOLIPtULx1wDdVL0SX2eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.115.0': + resolution: {integrity: sha512-AQSZjIR+b+Te7uaO/hGTMjT8/oxlYrvKrOTi4KTHF/O6osjHEatUQ3y6ZW2+8+lJxy20zIcGz6iQFmFq/qDKkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.115.0': + resolution: {integrity: sha512-oxUl82N+fIO9jIaXPph8SPPHQXrA08BHokBBJW8ct9F/x6o6bZE6eUAhUtWajbtvFhL8UYcCWRMba+kww6MBlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.115.0': + resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + + '@oxc-project/types@0.128.0': + resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@prisma/client@6.19.2': + resolution: {integrity: sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.2': + resolution: {integrity: sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==} + + '@prisma/debug@6.19.2': + resolution: {integrity: sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines@6.19.2': + resolution: {integrity: sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==} + + '@prisma/fetch-engine@6.19.2': + resolution: {integrity: sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==} + + '@prisma/get-platform@6.19.2': + resolution: {integrity: sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.18': + resolution: {integrity: sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.18': + resolution: {integrity: sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': + resolution: {integrity: sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': + resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': + resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': + resolution: {integrity: sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': + resolution: {integrity: sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': + resolution: {integrity: sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.18': + resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==} + + '@rolldown/pluginutils@1.0.0-rc.2': + resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + + '@rollup/plugin-babel@5.3.1': + resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} + engines: {node: '>= 10.0.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@types/babel__core': ^7.1.9 + rollup: ^1.20.0||^2.0.0 + peerDependenciesMeta: + '@types/babel__core': + optional: true + + '@rollup/plugin-node-resolve@15.3.1': + resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-replace@2.4.2': + resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 + + '@rollup/plugin-terser@0.4.4': + resolution: {integrity: sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@3.1.0': + resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} + engines: {node: '>= 8.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0 + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@simple-libs/child-process-utils@1.0.2': + resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} + engines: {node: '>=18'} + + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} + + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@std-uritemplate/std-uritemplate@2.0.12': + resolution: {integrity: sha512-s/R828vYTrFPgAOFAUiHuP4i1eSZHCO9PKVfzVR+2DFERJ/G71BpZJVObM2yy3xeMjMW3wB61gvanb6/pV1lJw==} + + '@stylistic/eslint-plugin@5.10.0': + resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + '@surma/rollup-plugin-off-main-thread@2.2.3': + resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} + + '@swc/helpers@0.5.19': + resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@ts-rest/core@3.52.1': + resolution: {integrity: sha512-tAjz7Kxq/grJodcTA1Anop4AVRDlD40fkksEV5Mmal88VoZeRKAG8oMHsDwdwPZz+B/zgnz0q2sF+cm5M7Bc7g==} + peerDependencies: + '@types/node': ^18.18.7 || >=20.8.4 + zod: ^3.22.3 + peerDependenciesMeta: + '@types/node': + optional: true + zod: + optional: true + + '@ts-rest/fastify@3.52.1': + resolution: {integrity: sha512-y+w3ENayNI77T0l2gcr9mobF3Nfc4yIb4487mvNMsMUkGLMHmaWzhLy3Mo/mswuoA+tVdOkrD8tJD38bLY7iYQ==} + peerDependencies: + '@ts-rest/core': ~3.52.0 + fastify: ^4.0.0 + zod: ^3.22.3 + peerDependenciesMeta: + zod: + optional: true + + '@ts-rest/open-api@3.52.1': + resolution: {integrity: sha512-VY91g6HFzCe1/fNgX4Y3HzaJbIdztCGHTg34drkh7bKgtNOizLJLXV0wwIkMwLGvjhRHojJxnwYxHjGMIkScwg==} + peerDependencies: + '@ts-rest/core': ~3.52.0 + zod: ^3.22.3 + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/aws-lambda@8.10.161': + resolution: {integrity: sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/bunyan@1.8.11': + resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@0.0.39': + resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/memcached@2.2.10': + resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/mysql@2.15.27': + resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} + + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + + '@types/oracledb@6.5.2': + resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==} + + '@types/pg-pool@2.0.7': + resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@types/swagger-schema-official@2.0.25': + resolution: {integrity: sha512-T92Xav+Gf/Ik1uPW581nA+JftmjWPgskw/WBf4TJzxRG/SJ+DfNnNE+WuZ4mrXuzflQMqMkm1LSYjzYW7MB1Cg==} + + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@typescript-eslint/eslint-plugin@8.57.0': + resolution: {integrity: sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/eslint-plugin@8.59.2': + resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.57.0': + resolution: {integrity: sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.59.2': + resolution: {integrity: sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.57.0': + resolution: {integrity: sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.59.2': + resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/rule-tester@8.57.0': + resolution: {integrity: sha512-qs4OapXmAIj3so85/20lQG1WrBSSvDE/3b42Orl3lpZkaOlNXtbfKzL+9EPaY5wSEgdlhKEpympAMFHPG9i72Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + '@typescript-eslint/scope-manager@8.57.0': + resolution: {integrity: sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.59.2': + resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.57.0': + resolution: {integrity: sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/tsconfig-utils@8.59.2': + resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.57.0': + resolution: {integrity: sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.59.2': + resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.57.0': + resolution: {integrity: sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.59.2': + resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.57.0': + resolution: {integrity: sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/typescript-estree@8.59.2': + resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.57.0': + resolution: {integrity: sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.59.2': + resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.57.0': + resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.59.2': + resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unocss/cli@66.6.6': + resolution: {integrity: sha512-78SY8j4hAVelK+vP/adsDGaSjEITasYLFECJLHWxUJSzK+G9UIc5wtL/u4jA+zKvwVkHcDvbkcO5K6wwwpAixg==} + engines: {node: '>=14'} + hasBin: true + + '@unocss/config@66.6.6': + resolution: {integrity: sha512-menlnkqAFX/4wR2aandY8hSqrt01JE+rOzvtQxWaBt8kf1du62b0sS72FE5Z40n6HlEsEbF91N9FCfhnzG6i6g==} + engines: {node: '>=14'} + + '@unocss/core@0.65.4': + resolution: {integrity: sha512-a2JOoFutrhqd5RgPhIR5FIXrDoHDU3gwCbPrpT6KYTjsqlSc/fv02yZ+JGOZFN3MCFhCmaPTs+idDFtwb3xU8g==} + + '@unocss/core@66.6.6': + resolution: {integrity: sha512-Sbbx0ZQqmV8K2lg8E+z9MJzWb1MgRtJnvqzxDIrNuBjXasKhbcFt5wEMBtEZJOr63Z4ck0xThhZK53HmYT2jmg==} + + '@unocss/extractor-arbitrary-variants@66.6.6': + resolution: {integrity: sha512-uMzekF2miZRUwSZGvy3yYQiBAcSAs9LiXK8e3NjldxEw8xcRDWgTErxgStRoBeAD6UyzDcg/Cvwtf2guMbtR+g==} + + '@unocss/inspector@66.6.6': + resolution: {integrity: sha512-CpXIsqHwxCXJtUjUz6S29diHCIA+EJ1u5WML/6m2YPI4ObgWAVKrExy09inSg2icS52lFkWWdWQSeqc9kl5W6Q==} + + '@unocss/preset-attributify@66.6.6': + resolution: {integrity: sha512-3H12UI1rBt60PQy+S4IEeFYWu1/WQFuc2yhJ5mu/RCvX5/qwlIGanBpuh+xzTPXU1fWBlZN68yyO9uWOQgTqZQ==} + + '@unocss/preset-icons@66.6.6': + resolution: {integrity: sha512-HfIEEqf3jyKexOB2Sux556n0NkPoUftb2H4+Cf7prJvKHopMkZ/OUkXjwvUlxt1e5UpAEaIa0A2Ir7+ApxXoGA==} + + '@unocss/preset-mini@66.6.6': + resolution: {integrity: sha512-k+/95PKMPOK57cJcSmz34VkIFem8BlujRRx6/L0Yusw7vLJMh98k0rPhC5s+NomZ/d9ZPgbNylskLhItJlak3w==} + + '@unocss/preset-tagify@66.6.6': + resolution: {integrity: sha512-KgBXYPYS0g4TVC3NLiIB78YIqUlvDLanz1EHIDo34rOTUfMgY8Uf5VuDJAzMu4Sc0LiwwBJbk6nIG9/Zm7ufWg==} + + '@unocss/preset-typography@66.6.6': + resolution: {integrity: sha512-SM1km5nqt15z4sTabfOobSC633I5Ol5nnme6JFTra4wiyCUNs+Cg31nJ6jnopWDUT4SEAXqfUH7jKSSoCnI6ZA==} + + '@unocss/preset-uno@66.6.6': + resolution: {integrity: sha512-40PcBDtlhW7QP7e/WOxC684IhN5T1dXvj1dgx9ZzK+8lEDGjcX7bN2noW4aSenzSrHymeSsMrL/0ltL4ED/5Zw==} + + '@unocss/preset-web-fonts@66.6.6': + resolution: {integrity: sha512-5ikwgrJB8VPzKd0bqgGNgYUGix90KFnVtKJPjWTP5qsv3+ZtZnea1rRbAFl8i2t52hg35msNBsQo+40IC3xB6A==} + + '@unocss/preset-wind3@66.6.6': + resolution: {integrity: sha512-rk6gPPIQ7z2DVucOqp7XZ4vGpKAuzBV1vtUDvDh5WscxzO/QlqaeTfTALk5YgGpmLaF4+ns6FrTgLjV+wHgHuQ==} + + '@unocss/preset-wind4@66.6.6': + resolution: {integrity: sha512-caTDM9rZSlp4tyPWWAnwMvQr2PXq53LsEYwd3N8zj0ou2hcsqptJvF+mFvyhvGF66x26wWJr/FwuUEhh7qycaw==} + + '@unocss/preset-wind@66.6.6': + resolution: {integrity: sha512-TMy3lZ35FP/4QqDHOLWZmV+RoOGWUDqnDEOTjOKI1CQARGta0ppUmq+IZMuI1ZJLuOa4OZ9V6SfnwMXwRLgXmw==} + + '@unocss/rule-utils@0.65.4': + resolution: {integrity: sha512-+EzdJEWcqGcO6HwbBTe7vEdBRpuKkBiz4MycQeLD6GEio04T45y6VHHO7/WTqxltbO4YwwW9/s2TKRMxKtoG8g==} + engines: {node: '>=14'} + + '@unocss/rule-utils@66.6.6': + resolution: {integrity: sha512-krWtQKGshOaqQMuxeGq1NOA8NL35VdpYlmQEWOe39BY6TACT51bgQFu40MRfsAIMZZtoGS2YYTrnHojgR92omw==} + engines: {node: '>=14'} + + '@unocss/transformer-attributify-jsx@66.6.6': + resolution: {integrity: sha512-NnDchmN2EeFLy4lfVqDgNe9j1+w2RLL2L9zKECXs5g6rDVfeeEK6FNgxSq3XnPcKltjNCy1pF4MaDOROG7r8yA==} + + '@unocss/transformer-compile-class@66.6.6': + resolution: {integrity: sha512-KKssJxU8fZ9x84yznIirbtta2sB0LN/3lm0bp+Wl1298HITaNiVeG2n26iStQ3N7r240xRN2RarxncSVCMFwWw==} + + '@unocss/transformer-directives@0.65.4': + resolution: {integrity: sha512-zkoDEwzPkgXi6ohW7P11gbArwfTRMZ9knYSUYoPEltQz+UZYzeRQ85exiAmdz5MsbCAuhQEr577Kd/CWfhjEuA==} + + '@unocss/transformer-directives@66.6.6': + resolution: {integrity: sha512-CReFTcBfMtKkRvzIqxL20VptWt5C1Om27dwoKzyVFBXv0jzViWysbu0y0AQg3bsgD4cFqndFyAGyeL84j0nbKg==} + + '@unocss/transformer-variant-group@66.6.6': + resolution: {integrity: sha512-j4L/0Tw6AdMVB2dDnuBlDbevyL1/0CAk88a77VF/VjgEIBwB9VXsCCUsxz+2Dohcl7N2GMm7+kpaWA6qt2PSaA==} + + '@unocss/vite@66.6.6': + resolution: {integrity: sha512-DgG7KcUUMtoDhPOlFf2l4dR+66xZ23SdZvTYpikk5nZfLCzZd62vedutD7x0bTR6VpK2YRq39B+F+Z6TktNY/w==} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0 + + '@vitejs/plugin-vue@6.0.4': + resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vue: ^3.2.25 + + '@vitest/coverage-v8@4.1.5': + resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} + peerDependencies: + '@vitest/browser': 4.1.5 + vitest: 4.1.5 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/eslint-plugin@1.6.17': + resolution: {integrity: sha512-sIVY9ZeVcXyPxFCNRkIt8Yw4keKIcUyp9/8qnmuomPwE+ST1htw5sZsbqdUMTiah9SmCg1JYoK9RqdDtPeNYYg==} + engines: {node: '>=18'} + peerDependencies: + '@typescript-eslint/eslint-plugin': '*' + eslint: '>=8.57.0' + typescript: '>=5.0.0' + vitest: '*' + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + typescript: + optional: true + vitest: + optional: true + + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.30': + resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==} + + '@vue/compiler-dom@3.5.30': + resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==} + + '@vue/compiler-sfc@3.5.30': + resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==} + + '@vue/compiler-ssr@3.5.30': + resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/eslint-config-typescript@14.7.0': + resolution: {integrity: sha512-iegbMINVc+seZ/QxtzWiOBozctrHiF2WvGedruu2EbLujg9VuU0FQiNcN2z1ycuaoKKpF4m2qzB5HDEMKbxtIg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.10.0 || ^10.0.0 + eslint-plugin-vue: ^9.28.0 || ^10.0.0 + typescript: '>=4.8.4' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.30': + resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==} + + '@vue/runtime-core@3.5.30': + resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==} + + '@vue/runtime-dom@3.5.30': + resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==} + + '@vue/server-renderer@3.5.30': + resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==} + peerDependencies: + vue: 3.5.30 + + '@vue/shared@3.5.30': + resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==} + + '@vue/tsconfig@0.7.0': + resolution: {integrity: sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==} + peerDependencies: + typescript: 5.x + vue: ^3.4.0 + peerDependenciesMeta: + typescript: + optional: true + vue: + optional: true + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats-draft2019@1.6.1: + resolution: {integrity: sha512-JQPvavpkWDvIsBp2Z33UkYCtXCSpW4HD3tAZ+oL4iEFOk9obQZffx0yANwECt6vzr6ET+7HN5czRyqXbnq/u0Q==} + peerDependencies: + ajv: '*' + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + any-base@1.1.0: + resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + are-docs-informative@0.0.2: + resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + axios-retry@4.5.0: + resolution: {integrity: sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==} + peerDependencies: + axios: 0.x || 1.x + + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.14.2: + resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.38: + resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} + engines: {node: '>=6.0.0'} + hasBin: true + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bn.js@4.12.3: + resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + brorand@1.1.0: + resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builtin-modules@5.0.0: + resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} + engines: {node: '>=18.20'} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + + c12@3.3.3: + resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + cache-manager@7.2.8: + resolution: {integrity: sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==} + + cacheable@2.3.3: + resolution: {integrity: sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + camelize-ts@3.0.0: + resolution: {integrity: sha512-cgRwKKavoDKLTjO4FQTs3dRBePZp/2Y9Xpud0FhuCOTE86M2cniKN4CCXgRnsyXNMmQMifVHcv6SPaMtTx6ofQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + check-disk-space@3.4.0: + resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==} + engines: {node: '>=16'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cidr-regex@3.1.1: + resolution: {integrity: sha512-RBqYd32aDwbCMFJRL6wHOlDNYJsPNTt8vC82ErHF5vKt8QQzxm1FrkW8s/R5pVrXMf17sba09Uoy91PKiddAsw==} + engines: {node: '>=10'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.1: + resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clean-regexp@1.0.0: + resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} + engines: {node: '>=4'} + + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + clipboard@2.0.11: + resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + comment-json@4.4.1: + resolution: {integrity: sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==} + engines: {node: '>= 6'} + + comment-parser@1.4.5: + resolution: {integrity: sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==} + engines: {node: '>= 12.0.0'} + + comment-parser@1.4.6: + resolution: {integrity: sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==} + engines: {node: '>= 12.0.0'} + + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@9.3.1: + resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} + engines: {node: '>=18'} + + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} + hasBin: true + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cron-validator@1.4.0: + resolution: {integrity: sha512-wGcJ9FCy65iaU6egSH8b5dZYJF7GU/3Jh06wzaT9lsa5dbqExjljmu+0cJ8cpKn+vUyZa/EM4WAxeLR6SypJXw==} + + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto-random-string@2.0.0: + resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} + engines: {node: '>=8'} + + css-functions-list@3.3.3: + resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} + engines: {node: '>=12'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + deeks@3.1.0: + resolution: {integrity: sha512-e7oWH1LzIdv/prMQ7pmlDlaVoL64glqzvNgkgQNgyec9ORPHrT2jaOqMtRyqJuwWjtfb6v+2rk9pmaHj+F137A==} + engines: {node: '>= 16'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegate@3.2.0: + resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + + doc-path@4.1.1: + resolution: {integrity: sha512-h1ErTglQAVv2gCnOpD3sFS6uolDbOKHDU1BZq+Kl3npPqroU3dYL42lUgMfd5UimlwtRgp7C9dLGwqQ5D2HYgQ==} + engines: {node: '>=16'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + dompurify@3.3.2: + resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} + engines: {node: '>=20'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + effect@3.18.4: + resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-to-chromium@1.5.376: + resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + + elliptic@6.6.1: + resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.24.0: + resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.1: + resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} + engines: {node: '>= 0.4'} + + es-toolkit@1.48.1: + resolution: {integrity: sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==} + + es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-flat-gitignore@2.3.0: + resolution: {integrity: sha512-bg4ZLGgoARg1naWfsINUUb/52Ksw/K22K+T16D38Y8v+/sGwwIYrGvH/JBjOin+RQtxxC9tzNNiy4shnGtGyyQ==} + peerDependencies: + eslint: ^9.5.0 || ^10.0.0 + + eslint-flat-config-utils@3.2.0: + resolution: {integrity: sha512-PHgo1X5uqIorJONLVD9BIaOSdoYFD3z/AeJljdqDPlWVRpeCYkDbK9k0AXoYVqqNJr6FEYIEr5Rm2TSktLQcHw==} + + eslint-json-compat-utils@0.2.3: + resolution: {integrity: sha512-RbBmDFyu7FqnjE8F0ZxPNzx5UaptdeS9Uu50r7A+D7s/+FCX+ybiyViYEgFUaFIFqSWJgZRTpL5d8Kanxxl2lQ==} + engines: {node: '>=12'} + peerDependencies: + '@eslint/json': '*' + eslint: '*' + jsonc-eslint-parser: ^2.4.0 || ^3.0.0 + peerDependenciesMeta: + '@eslint/json': + optional: true + + eslint-merge-processors@2.0.0: + resolution: {integrity: sha512-sUuhSf3IrJdGooquEUB5TNpGNpBoQccbnaLHsb1XkBLUPPqCNivCpY05ZcpCOiV9uHwO2yxXEWVczVclzMxYlA==} + peerDependencies: + eslint: '*' + + eslint-plugin-antfu@3.2.2: + resolution: {integrity: sha512-Qzixht2Dmd/pMbb5EnKqw2V8TiWHbotPlsORO8a+IzCLFwE0RxK8a9k4DCTFPzBwyxJzH+0m2Mn8IUGeGQkyUw==} + peerDependencies: + eslint: '*' + + eslint-plugin-command@3.5.2: + resolution: {integrity: sha512-PA59QAkQDwvcCMEt5lYLJLI3zDGVKJeC4id/pcRY2XdRYhSGW7iyYT1VC1N3bmpuvu6Qb/9QptiS3GJMjeGTJg==} + peerDependencies: + '@typescript-eslint/rule-tester': '*' + '@typescript-eslint/typescript-estree': '*' + '@typescript-eslint/utils': '*' + eslint: '*' + + eslint-plugin-depend@1.5.0: + resolution: {integrity: sha512-i3UeLYmclf1Icp35+6W7CR4Bp2PIpDgBuf/mpmXK5UeLkZlvYJ21VuQKKHHAIBKRTPivPGX/gZl5JGno1o9Y0A==} + peerDependencies: + eslint: '>=8.40.0' + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-import-lite@0.6.0: + resolution: {integrity: sha512-80vevx2A7i3H7n1/6pqDO8cc5wRz6OwLDvIyVl9UflBV1N1f46e9Ihzi65IOLYoSxM6YykK2fTw1xm0Ixx6aTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + eslint-plugin-jsdoc@62.9.0: + resolution: {integrity: sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-jsonc@3.1.2: + resolution: {integrity: sha512-dopTxdB22iuOkgKyJCupEC5IYBItUT4J/teq1H5ddUObcaYhOURxtJElZczdcYnnKCghNU/vccuyPkliy2Wxsg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-n@17.24.0: + resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=8.23.0' + + eslint-plugin-no-only-tests@3.3.0: + resolution: {integrity: sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==} + engines: {node: '>=5.0.0'} + + eslint-plugin-perfectionist@5.9.0: + resolution: {integrity: sha512-8TWzg02zmnBdZwCkWLi8jhzqXI+fE7Z/RwV8SL6xD45tJ8Bp3wGuYL2XtQgfe/Wd0eBqOUX+s6ey73IyszvKTA==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-pnpm@1.6.0: + resolution: {integrity: sha512-dxmt9r3zvPaft6IugS4i0k16xag3fTbOvm/road5uV9Y8qUCQT0xzheSh3gMlYAlC6vXRpfArBDsTZ7H7JKCbg==} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + eslint-plugin-regexp@3.1.0: + resolution: {integrity: sha512-qGXIC3DIKZHcK1H9A9+Byz9gmndY6TTSRkSMTZpNXdyCw2ObSehRgccJv35n9AdUakEjQp5VFNLas6BMXizCZg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-toml@1.3.1: + resolution: {integrity: sha512-1l00fBP03HIt9IPV7ZxBi7x0y0NMdEZmakL1jBD6N/FoKBvfKxPw5S8XkmzBecOnFBTn5Z8sNJtL5vdf9cpRMQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-unicorn@64.0.0: + resolution: {integrity: sha512-rNZwalHh8i0UfPlhNwg5BTUO1CMdKNmjqe+TgzOTZnpKoi8VBgsW7u9qCHIdpxEzZ1uwrJrPF0uRb7l//K38gA==} + engines: {node: ^20.10.0 || >=21.0.0} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-unused-imports@4.4.1: + resolution: {integrity: sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==} + peerDependencies: + '@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0 + eslint: ^10.0.0 || ^9.0.0 || ^8.0.0 + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + + eslint-plugin-vue@10.8.0: + resolution: {integrity: sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-eslint-parser: ^10.0.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-plugin-yml@3.3.1: + resolution: {integrity: sha512-isntsZchaTqDMNNkD+CakrgA/pdUoJ45USWBKpuqfAW1MCuw731xX/vrXfoJFZU3tTFr24nCbDYmDfT2+g4QtQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + eslint: '>=9.38.0' + + eslint-processor-vue-blocks@2.0.0: + resolution: {integrity: sha512-u4W0CJwGoWY3bjXAuFpc/b6eK3NQEI8MoeW7ritKj3G3z/WtHrKjkqf+wk8mPEy5rlMGS+k6AZYOw2XBoN/02Q==} + peerDependencies: + '@vue/compiler-sfc': ^3.3.0 + eslint: '>=9.0.0' + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.5.0: + resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@1.0.1: + resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eta@3.5.0: + resolution: {integrity: sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==} + engines: {node: '>=6.0.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-copy@4.0.2: + resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + + fast-jwt@5.0.6: + resolution: {integrity: sha512-LPE7OCGUl11q3ZgW681cEU2d0d2JZ37hhJAmetCgNyW8waVaJVZXhyFF6U2so1Iim58Yc7pfxJe2P7MNetQH2g==} + engines: {node: '>=20'} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastfall@1.5.1: + resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==} + engines: {node: '>=0.10.0'} + + fastify-keycloak-adapter@3.0.2: + resolution: {integrity: sha512-Zm5PowNKxgHPztM1wC0hJmnFYPrUoduREEIzxwz2dnTpMm5qZkp7btweTuGW5AzDbjBu3EVM0fNyPxyR3OkwwA==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + + fastparallel@2.4.1: + resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fastseries@1.7.2: + resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==} + + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + file-entry-cache@11.1.2: + resolution: {integrity: sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-type@21.3.0: + resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} + engines: {node: '>=20'} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flat-cache@6.1.20: + resolution: {integrity: sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + focus-trap-vue@4.1.0: + resolution: {integrity: sha512-RvK28mED+4fqoqnJQyadfoiKZU2G/QEez+EnJtdsg83DjiuKetimvDGas9ot4p5bn1J09rDmKLtKFMBkwQc+JA==} + peerDependencies: + focus-trap: ^7.0.0 + vue: ^3.0.0 + + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fork-ts-checker-webpack-plugin@9.1.0: + resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} + engines: {node: '>=14.21.3'} + peerDependencies: + typescript: '>3.6.0' + webpack: ^5.11.0 + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fp-ts@2.16.11: + resolution: {integrity: sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-monkey@1.1.0: + resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-own-enumerable-property-symbols@3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + git-raw-commits@5.0.1: + resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} + engines: {node: '>=18'} + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. + hasBin: true + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + global-prefix@4.0.0: + resolution: {integrity: sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==} + engines: {node: '>=16'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@16.2.0: + resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} + engines: {node: '>=20'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + good-listener@1.2.2: + resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grant@5.4.24: + resolution: {integrity: sha512-PD5AvSI7wgCBDi2mEd6M/TIe+70c/fVc3Ik4B0s4mloWTy9J800eUEcxivOiyqSP9wvBy2QjWq1JR8gOfDMnEg==} + engines: {node: '>=12.0.0'} + + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + + hashery@1.5.0: + resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==} + engines: {node: '>=20'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + hmac-drbg@1.0.1: + resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-tags@5.1.0: + resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==} + engines: {node: '>=20.10'} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http2-client@1.3.5: + resolution: {integrity: sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-in-the-middle@2.0.6: + resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + + import-in-the-middle@3.0.0: + resolution: {integrity: sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==} + engines: {node: '>=18'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + io-ts@2.2.22: + resolution: {integrity: sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA==} + peerDependencies: + fp-ts: ^2.5.0 + + ip-regex@4.3.0: + resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} + engines: {node: '>=8'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-builtin-module@5.0.0: + resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} + engines: {node: '>=18.20'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-cidr@4.0.2: + resolution: {integrity: sha512-z4a1ENUajDbEl/Q6/pVBpTR1nBjjEE1X7qb7bmWYanNnPoKAvUCPFKeXV6Fe4mgTkWKBqiHIcwsI3SndiO5FeA==} + engines: {node: '>=10'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@1.0.1: + resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} + engines: {node: '>=0.10.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-regexp@1.0.0: + resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} + engines: {node: '>=0.10.0'} + + is-retry-allowed@2.2.0: + resolution: {integrity: sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==} + engines: {node: '>=10'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + javascript-time-ago@2.6.4: + resolution: {integrity: sha512-7K/Z37LuwVaxxjutUDd1pXpznufPcox0b1UYu00ksAMMlV6IsxIvduwL3kgfPxuBVF8jVj7nhrKMPDslMq94aQ==} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + jsdoc-type-pratt-parser@7.1.1: + resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} + engines: {node: '>=20.0.0'} + + jsdoc-type-pratt-parser@7.2.0: + resolution: {integrity: sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==} + engines: {node: '>=20.0.0'} + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-2-csv@5.5.10: + resolution: {integrity: sha512-Dep8wO3Fr5wNjQevO2Z8Y7yeee/nYSGRsi7q6zJDKEVHxXkXT+v21vxHmDX923UzmCXXkSo62HaTz6eTWzFLaw==} + engines: {node: '>= 16'} + deprecated: A security vulnerability has been reported with the preventCsvInjection option which has been fixed in version 5.5.11. Please upgrade as soon as possible. + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-eslint-parser@3.1.0: + resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jwk-to-pem@2.0.7: + resolution: {integrity: sha512-cSVphrmWr6reVchuKQZdfSs4U9c5Y4hwZggPoz6cbVnTpAVgGRpEuQng86IyqLeGZlhTh+c4MAreB6KbdQDKHQ==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + katex@0.16.45: + resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} + hasBin: true + + keycloak-js@26.2.3: + resolution: {integrity: sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + + loader-runner@4.3.2: + resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} + engines: {node: '>=6.11.5'} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + magic-regexp@0.10.0: + resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} + + magic-string@0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mathml-tag-names@4.0.0: + resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + memfs@3.5.3: + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} + engines: {node: '>= 4.0.0'} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} + engines: {node: '>=20'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimalistic-crypto-utils@1.0.1: + resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.1: + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} + + mnemonist@0.40.4: + resolution: {integrity: sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==} + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + module-replacements@2.11.0: + resolution: {integrity: sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA==} + + moo@0.5.3: + resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.12.10: + resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + multer@2.1.1: + resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} + engines: {node: '>= 10.16.0'} + + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@5.0.9: + resolution: {integrity: sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==} + engines: {node: ^18 || >=20} + hasBin: true + + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + natural-orderby@5.0.0: + resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} + engines: {node: '>=18'} + + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + nestjs-pino@4.6.0: + resolution: {integrity: sha512-MzSgnOu9MhRT/f7MsvoDnxat11D9JRJYwL1t+tI6J44UrNz9rUVDpceEh9VFsyfiiIJKUri5S+/snMOoaWh7YA==} + engines: {node: '>= 14'} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + pino: ^7.5.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-emoji@1.11.0: + resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + + node-fetch-h2@2.3.0: + resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==} + engines: {node: 4.x || >=6.0.0} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-readfiles@0.2.0: + resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==} + + node-releases@2.0.48: + resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} + engines: {node: '>=18'} + + nodemon@3.1.14: + resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + nypm@0.6.5: + resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} + engines: {node: '>=18'} + hasBin: true + + oas-kit-common@1.0.8: + resolution: {integrity: sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==} + + oas-linter@3.2.2: + resolution: {integrity: sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==} + + oas-resolver@2.5.6: + resolution: {integrity: sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==} + hasBin: true + + oas-schema-walker@1.1.5: + resolution: {integrity: sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==} + + oas-validator@5.0.8: + resolution: {integrity: sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==} + + oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-deep-merge@2.0.0: + resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + + openapi3-ts@2.0.2: + resolution: {integrity: sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + oxc-parser@0.115.0: + resolution: {integrity: sha512-2w7Xn3CbS/zwzSY82S5WLemrRu3CT57uF7Lx8llrE/2bul6iMTcJE4Rbls7GDNbLn3ttATI68PfOz2Pt3KZ2cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-walker@0.7.0: + resolution: {integrity: sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A==} + peerDependencies: + oxc-parser: '>=0.98.0' + + p-debounce@4.0.0: + resolution: {integrity: sha512-4Ispi9I9qYGO4lueiLDhe4q4iK5ERK8reLsuzH6BPaXn53EGaua8H66PXIFGrW897hwjXp+pVLrm/DLxN0RF0A==} + engines: {node: '>=12'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-gitignore@2.0.0: + resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==} + engines: {node: '>=14'} + + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch-browser@2.2.6: + resolution: {integrity: sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==} + engines: {node: '>=8.6'} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-http@11.0.0: + resolution: {integrity: sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + pnpm-workspace-yaml@1.6.0: + resolution: {integrity: sha512-uUy4dK3E11sp7nK+hnT7uAWfkBMe00KaUw8OG3NuNlYQoTk4sc9pcdIy1+XIP85v9Tvr02mK3JPaNNrP0QyRaw==} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-html@1.8.1: + resolution: {integrity: sha512-OLF6P7qctfAWayOhLpcVnTGqVeJzu2W3WpIYelfz2+JV5oGxfkcEvweN9U4XpeqE0P98dcD9ssusGwlF0TK0uQ==} + engines: {node: ^12 || >=14} + + postcss-safe-parser@6.0.0: + resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.3.3 + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + + pretty-bytes@6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} + + prisma@6.19.2: + resolution: {integrity: sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + protobufjs@7.6.4: + resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} + engines: {node: '>=12.0.0'} + + protobufjs@8.0.0: + resolution: {integrity: sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==} + engines: {node: '>=12.0.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qified@0.6.0: + resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} + engines: {node: '>=20'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + rate-limiter-flexible@4.0.1: + resolution: {integrity: sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ==} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + re2-wasm@1.0.2: + resolution: {integrity: sha512-VXUdgSiUrE/WZXn6gUIVVIsg0+Hp6VPZPOaHCay+OuFKy6u/8ktmeNEf+U5qSA8jzGGFsg8jrDNu1BeHpz2pJA==} + engines: {node: '>=10'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + refa@0.12.1: + resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + reftools@1.1.9: + resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regexp-ast-analysis@0.7.1: + resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + + relative-time-format@1.1.12: + resolution: {integrity: sha512-qaZBjmRIuXLfuLnzgqpFdBPa5W0euSX1tMnoMUHGPphLwJmrt8xbNiOIHrlvYOD6oNJ0M5owPCZyPibI8de5pQ==} + + request-compose@2.1.7: + resolution: {integrity: sha512-27amNkWTK4Qq25XEwdmrhb4VLMiQzRSKuDfsy1o1griykcyXk5MxMHmJG+OKTRdO9PgsO7Kkn7GrEkq0UAIIMQ==} + engines: {node: '>=12.0.0'} + + request-oauth@1.0.1: + resolution: {integrity: sha512-85THTg1RgOYtqQw42JON6AqvHLptlj1biw265Tsq4fD4cPdUvhDB2Qh9NTv17yCD322ROuO9aOmpc4GyayGVBA==} + engines: {node: '>=8.0.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + + rolldown@1.0.0-rc.18: + resolution: {integrity: sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@2.80.0: + resolution: {integrity: sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==} + engines: {node: '>=10.0.0'} + hasBin: true + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + schemes@1.4.0: + resolution: {integrity: sha512-ImFy9FbCsQlVgnE3TCWmLPCFnVzx0lHL/l+umHplDqAKd0dzFpnS6lFZIpagBlYhKwzVmlV36ec0Y1XTu8JBAQ==} + + scslre@0.3.0: + resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} + engines: {node: ^14.0.0 || >=16.0.0} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + select@1.1.2: + resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + short-uuid@5.2.0: + resolution: {integrity: sha512-296/Nzi4DmANh93iYBwT4NoYRJuHnKEzefrkSagQbTH/A6NTaB68hSPDjm5IlbI5dx9FXdmtqPcj6N5H+CPm6w==} + engines: {node: '>=14'} + + should-equal@2.0.0: + resolution: {integrity: sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==} + + should-format@3.0.3: + resolution: {integrity: sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==} + + should-type-adaptors@1.1.0: + resolution: {integrity: sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==} + + should-type@1.4.0: + resolution: {integrity: sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==} + + should-util@1.0.1: + resolution: {integrity: sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==} + + should@13.2.3: + resolution: {integrity: sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + + smob@1.6.2: + resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} + engines: {node: '>=20.0.0'} + + smtp-address-parser@1.1.0: + resolution: {integrity: sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==} + engines: {node: '>=0.10'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions + + sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@4.0.0: + resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + steed@1.1.3: + resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-object@3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-comments@2.0.1: + resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} + engines: {node: '>=10'} + + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + strtok3@10.3.4: + resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} + engines: {node: '>=18'} + + stylelint-config-html@1.1.0: + resolution: {integrity: sha512-IZv4IVESjKLumUGi+HWeb7skgO6/g4VMuAYrJdlqQFndgbj6WJAXPhaysvBiXefX79upBdQVumgYcdd17gCpjQ==} + engines: {node: ^12 || >=14} + peerDependencies: + postcss-html: ^1.0.0 + stylelint: '>=14.0.0' + + stylelint-config-recommended-vue@1.6.1: + resolution: {integrity: sha512-lLW7hTIMBiTfjenGuDq2kyHA6fBWd/+Df7MO4/AWOxiFeXP9clbpKgg27kHfwA3H7UNMGC7aeP3mNlZB5LMmEQ==} + engines: {node: ^12 || >=14} + peerDependencies: + postcss-html: ^1.0.0 + stylelint: '>=14.0.0' + + stylelint-config-recommended@18.0.0: + resolution: {integrity: sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint-config-standard@40.0.0: + resolution: {integrity: sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint@17.11.0: + resolution: {integrity: sha512-/3czzmbF9XdGWvReDF3Ex4R23Ajolo7j8RB2bFNEqk6Ht356nlpVV+G5bG2Qt8AW1ofJzXztBRDnAtd7cgowWA==} + engines: {node: '>=20.19.0'} + hasBin: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@4.4.0: + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} + engines: {node: '>=20'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + swagger-schema-official@2.0.0-bab6bed: + resolution: {integrity: sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA==} + + swagger-typescript-api@13.3.1: + resolution: {integrity: sha512-fSYf55kY0nV2TxS131h35sl+HaLNiZabF+A5ffFC7TCMlHTAVCSlmsh3MPXFaWYk8CcMLSFg4q4vDlWkhBDdAA==} + engines: {node: '>=20'} + hasBin: true + + swagger-ui-dist@5.32.4: + resolution: {integrity: sha512-0AADFFQNJzExEN49SrD/34Nn9cxNxVLiydYl2MBwSZFPVXNkVwC/EFAjoezGGqE8oDegiDC+p47t8lKObCinMQ==} + + swagger2openapi@7.0.8: + resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==} + hasBin: true + + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + temp-dir@2.0.0: + resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} + engines: {node: '>=8'} + + tempy@0.6.0: + resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==} + engines: {node: '>=10'} + + terser-webpack-plugin@5.6.1: + resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + engines: {node: '>=10'} + hasBin: true + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tiny-emitter@2.1.0: + resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyduration@3.4.1: + resolution: {integrity: sha512-NemFoamVYn7TmtwZKZ3OiliM9fZkr6EWiTM+wKknco6POSy2gS689xx/pXip0JYp40HXpUw6k65CUYHWYUXdaA==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts-core@7.4.3: + resolution: {integrity: sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tldts@7.4.3: + resolution: {integrity: sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + toml-eslint-parser@1.0.3: + resolution: {integrity: sha512-A5F0cM6+mDleacLIEUkmfpkBbnHJFV1d2rprHU2MXNk7mlxHq2zGojA+SRvQD1RoMo9gqjZPWEaKG4v1BQ48lw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-declaration-location@1.0.7: + resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} + peerDependencies: + typescript: '>=4.0.0' + + ts-deepmerge@6.2.1: + resolution: {integrity: sha512-8CYSLazCyj0DJDpPIxOFzJG46r93uh6EynYjuey+bxcLltBeqZL7DMfaE5ZPzZNFlav7wx+2TDa/mBl8gkTYzw==} + engines: {node: '>=14.13.1'} + + ts-essentials@10.1.1: + resolution: {integrity: sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==} + peerDependencies: + typescript: '>=4.5.0' + peerDependenciesMeta: + typescript: + optional: true + + ts-patch@3.3.0: + resolution: {integrity: sha512-zAOzDnd5qsfEnjd9IGy1IRuvA7ygyyxxdxesbhMdutt8AHFjD8Vw8hU2rMF89HX1BKRWFYqKHrO8Q6lw0NeUZg==} + hasBin: true + + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.16.0: + resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} + engines: {node: '>=10'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + engines: {node: '>=20'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + type-level-regexp@0.1.17: + resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript-eslint@8.57.0: + resolution: {integrity: sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript-transform-paths@3.5.6: + resolution: {integrity: sha512-3eQTG6Ogt+pgPEh45uX2s9OwcfAVjWnyNO+osjYcOqYaWDVMIFUkqW8e0O1cOaVwdMqQFQf6alDT+76xmeS2Ag==} + peerDependencies: + typescript: '>=3.6.5' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + unconfig@7.5.0: + resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@7.24.5: + resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} + engines: {node: '>=20.18.1'} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + + unimport@3.14.6: + resolution: {integrity: sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==} + + unique-string@2.0.0: + resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} + engines: {node: '>=8'} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unocss@66.6.6: + resolution: {integrity: sha512-PRKK945e2oZKHV664MA5Z9CDHbvY/V79IvTOUWKZ514jpl3UsJU3sS+skgxmKJSmwrWvXE5OVcmPthJrD/7vxg==} + engines: {node: '>=14'} + peerDependencies: + '@unocss/astro': 66.6.6 + '@unocss/postcss': 66.6.6 + '@unocss/webpack': 66.6.6 + peerDependenciesMeta: + '@unocss/astro': + optional: true + '@unocss/postcss': + optional: true + '@unocss/webpack': + optional: true + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unplugin-auto-import@0.18.6: + resolution: {integrity: sha512-LMFzX5DtkTj/3wZuyG5bgKBoJ7WSgzqSGJ8ppDRdlvPh45mx6t6w3OcbExQi53n3xF5MYkNGPNR/HYOL95KL2A==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': ^3.2.2 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + + unplugin-vue-components@0.27.5: + resolution: {integrity: sha512-m9j4goBeNwXyNN8oZHHxvIIYiG8FQ9UfmKWeNllpDvhU7btKNNELGPt+o3mckQKuPwrE7e0PvCsx+IWuDSD9Vg==} + engines: {node: '>=14'} + peerDependencies: + '@babel/parser': ^7.15.8 + '@nuxt/kit': ^3.2.2 + vue: 2 || 3 + peerDependenciesMeta: + '@babel/parser': + optional: true + '@nuxt/kit': + optional: true + + unplugin@1.16.1: + resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} + engines: {node: '>=14.0.0'} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + + upath@1.2.0: + resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} + engines: {node: '>=4'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-template@3.1.1: + resolution: {integrity: sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-node@6.0.0: + resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + vite-plugin-pwa@1.2.0: + resolution: {integrity: sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@vite-pwa/assets-generator': ^1.0.0 + vite: ^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + workbox-build: ^7.4.0 + workbox-window: ^7.4.0 + peerDependenciesMeta: + '@vite-pwa/assets-generator': + optional: true + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vite@8.0.11: + resolution: {integrity: sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest-mock-extended@2.0.2: + resolution: {integrity: sha512-n3MBqVITKyclZ0n0y66hkT4UiiEYFQn9tteAnIxT0MPz1Z8nFcPUG3Cf0cZOyoPOj/cq6Ab1XFw2lM/qM5EDWQ==} + peerDependencies: + typescript: 3.x || 4.x || 5.x + vitest: '>=2.0.0' + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-eslint-parser@10.4.0: + resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue3-json-viewer@2.4.1: + resolution: {integrity: sha512-Z1sunvS58lJ3ZcpNhl3jYQapBVw2wjnXbemigfMWm3QnjCeg3CPMq8R6pxHUYahxMfPKLvrbGve6mUXqhWyLaQ==} + peerDependencies: + vue: ^3.5.16 + + vue@3.5.30: + resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + watchpack@2.5.2: + resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + webpack-node-externals@3.0.0: + resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} + engines: {node: '>=6'} + + webpack-sources@3.5.0: + resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} + engines: {node: '>=10.13.0'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + webpack@5.104.1: + resolution: {integrity: sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + wildcard-match@5.1.4: + resolution: {integrity: sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g==} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + workbox-background-sync@7.4.0: + resolution: {integrity: sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==} + + workbox-broadcast-update@7.4.0: + resolution: {integrity: sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==} + + workbox-build@7.4.0: + resolution: {integrity: sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==} + engines: {node: '>=20.0.0'} + + workbox-cacheable-response@7.4.0: + resolution: {integrity: sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==} + + workbox-core@7.4.0: + resolution: {integrity: sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==} + + workbox-expiration@7.4.0: + resolution: {integrity: sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==} + + workbox-google-analytics@7.4.0: + resolution: {integrity: sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==} + + workbox-navigation-preload@7.4.0: + resolution: {integrity: sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==} + + workbox-precaching@7.4.0: + resolution: {integrity: sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==} + + workbox-range-requests@7.4.0: + resolution: {integrity: sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==} + + workbox-recipes@7.4.0: + resolution: {integrity: sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==} + + workbox-routing@7.4.0: + resolution: {integrity: sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==} + + workbox-strategies@7.4.0: + resolution: {integrity: sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==} + + workbox-streams@7.4.0: + resolution: {integrity: sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==} + + workbox-sw@7.4.0: + resolution: {integrity: sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==} + + workbox-window@7.4.0: + resolution: {integrity: sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xbytes@1.9.1: + resolution: {integrity: sha512-29E0ygMFWrM5JW2W1ypmezjyR2FOS5aCszdLe620ymSJBoZzL0/RCLJKCoUFu3DfQGhZ/FWykEBp5dfEy6+hjA==} + engines: {node: '>=1'} + + xcase@2.0.1: + resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml-eslint-parser@2.0.0: + resolution: {integrity: sha512-h0uDm97wvT2bokfwwTmY6kJ1hp6YDFL0nRHwNKz8s/VD1FH/vvZjAKoMUE+un0eaYBSG7/c6h+lJTP+31tjgTw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yummies@7.10.0: + resolution: {integrity: sha512-BXMmEYzFr/DtSvr1VMvctPVxQ/BZnx35oY2AGs6clkqgWnflsjqfuVle+8rH2uiMenmEf4I3OH9bRRW86yYyyg==} + peerDependencies: + mobx: ^6.12.4 + react: ^18 || ^19 + peerDependenciesMeta: + mobx: + optional: true + react: + optional: true + + zod-validation-error@3.5.4: + resolution: {integrity: sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.24.4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@anatine/zod-openapi@1.14.2(openapi3-ts@2.0.2)(zod@3.25.76)': + dependencies: + openapi3-ts: 2.0.2 + ts-deepmerge: 6.2.1 + zod: 3.25.76 + + '@angular-devkit/core@19.2.17(chokidar@4.0.3)': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + jsonc-parser: 3.3.1 + picomatch: 4.0.2 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/core@19.2.19(chokidar@4.0.3)': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + jsonc-parser: 3.3.1 + picomatch: 4.0.2 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/schematics-cli@19.2.19(@types/node@24.12.0)(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) + '@inquirer/prompts': 7.3.2(@types/node@24.12.0) + ansi-colors: 4.1.3 + symbol-observable: 4.0.0 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - '@types/node' + - chokidar + + '@angular-devkit/schematics@19.2.17(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.17(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@19.2.19(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@antfu/eslint-config@8.2.0(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5)': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@clack/prompts': 1.3.0 + '@e18e/eslint-plugin': 0.3.0(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint/markdown': 8.0.1 + '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@vitest/eslint-plugin': 1.6.17(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5) + ansis: 4.2.0 + cac: 7.0.0 + eslint: 10.5.0(jiti@2.6.1) + eslint-config-flat-gitignore: 2.3.0(eslint@10.5.0(jiti@2.6.1)) + eslint-flat-config-utils: 3.2.0 + eslint-merge-processors: 2.0.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-antfu: 3.2.2(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-import-lite: 0.6.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-jsdoc: 62.9.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-jsonc: 3.1.2(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-n: 17.24.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-no-only-tests: 3.3.0 + eslint-plugin-perfectionist: 5.9.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-pnpm: 1.6.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-regexp: 3.1.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-toml: 1.3.1(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-unicorn: 64.0.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + eslint-plugin-yml: 3.3.1(eslint@10.5.0(jiti@2.6.1)) + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1)) + globals: 17.6.0 + local-pkg: 1.1.2 + parse-gitignore: 2.0.0 + toml-eslint-parser: 1.0.3 + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + yaml-eslint-parser: 2.0.0 + transitivePeerDependencies: + - '@eslint/json' + - '@typescript-eslint/rule-tester' + - '@typescript-eslint/typescript-estree' + - '@typescript-eslint/utils' + - '@vue/compiler-sfc' + - oxlint + - supports-color + - typescript + - vitest + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.2.4 + + '@antfu/utils@0.7.10': {} + + '@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)': + dependencies: + ajv: 8.20.0 + jsonpointer: 5.0.1 + leven: 3.1.0 + + '@apidevtools/json-schema-ref-parser@14.0.1': + dependencies: + '@types/json-schema': 7.0.15 + js-yaml: 4.2.0 + + '@apidevtools/openapi-schemas@2.1.0': {} + + '@apidevtools/swagger-methods@3.0.2': {} + + '@apidevtools/swagger-parser@12.1.0(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 14.0.1 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@5.5.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + debug: 4.4.3(supports-color@5.5.0) + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-wrap-function': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helper-wrap-function@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + + '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/template': 7.29.7 + + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-dotall-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-keys@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-dynamic-import@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-exponentiation-operator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-member-expression-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-amd@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-systemjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regenerator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regexp-modifiers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-reserved-words@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-spread@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-typeof-symbol@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-property-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/preset-env@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7) + '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7) + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-systemjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.7) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + core-js-compat: 3.49.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/types': 7.29.7 + esutils: 2.0.3 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@biomejs/js-api@4.0.0(@biomejs/wasm-nodejs@2.4.6)': + optionalDependencies: + '@biomejs/wasm-nodejs': 2.4.6 + + '@biomejs/wasm-nodejs@2.4.6': {} + + '@borewit/text-codec@0.2.1': {} + + '@cacheable/memory@2.0.8': + dependencies: + '@cacheable/utils': 2.4.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.4.0': + dependencies: + hashery: 1.5.0 + keyv: 5.6.0 + + '@clack/core@1.3.0': + dependencies: + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@clack/prompts@1.3.0': + dependencies: + '@clack/core': 1.3.0 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@colors/colors@1.5.0': + optional: true + + '@commitlint/cli@20.5.3(@types/node@24.12.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': + dependencies: + '@commitlint/format': 20.5.0 + '@commitlint/lint': 20.5.3 + '@commitlint/load': 20.5.3(@types/node@24.12.0)(typescript@5.9.3) + '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) + '@commitlint/types': 20.5.0 + tinyexec: 1.2.4 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-conventionalcommits: 9.3.1 + + '@commitlint/config-validator@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + ajv: 8.20.0 + + '@commitlint/ensure@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + es-toolkit: 1.48.1 + + '@commitlint/execute-rule@20.0.0': {} + + '@commitlint/format@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + semver: 7.8.5 + + '@commitlint/lint@20.5.3': + dependencies: + '@commitlint/is-ignored': 20.5.0 + '@commitlint/parse': 20.5.0 + '@commitlint/rules': 20.5.3 + '@commitlint/types': 20.5.0 + + '@commitlint/load@20.5.3(@types/node@24.12.0)(typescript@5.9.3)': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/execute-rule': 20.0.0 + '@commitlint/resolve-extends': 20.5.3 + '@commitlint/types': 20.5.0 + cosmiconfig: 9.0.2(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@24.12.0)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3) + es-toolkit: 1.48.1 + is-plain-obj: 4.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@20.4.3': {} + + '@commitlint/parse@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 + + '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': + dependencies: + '@commitlint/top-level': 20.4.3 + '@commitlint/types': 20.5.0 + git-raw-commits: 5.0.1(conventional-commits-parser@6.4.0) + minimist: 1.2.8 + tinyexec: 1.2.4 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@20.5.3': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/types': 20.5.0 + es-toolkit: 1.48.1 + global-directory: 5.0.0 + import-meta-resolve: 4.2.0 + resolve-from: 5.0.0 + + '@commitlint/rules@20.5.3': + dependencies: + '@commitlint/ensure': 20.5.3 + '@commitlint/message': 20.4.3 + '@commitlint/to-lines': 20.0.0 + '@commitlint/types': 20.5.0 + + '@commitlint/to-lines@20.0.0': {} + + '@commitlint/top-level@20.4.3': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@20.5.0': + dependencies: + conventional-commits-parser: 6.4.0 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@2.7.0(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.8.5 + optionalDependencies: + conventional-commits-parser: 6.4.0 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/css-tokenizer@4.0.0': {} + + '@csstools/media-query-list-parser@5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/selector-resolve-nested@4.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@e18e/eslint-plugin@0.3.0(eslint@10.5.0(jiti@2.6.1))': + dependencies: + eslint-plugin-depend: 1.5.0(eslint@10.5.0(jiti@2.6.1)) + optionalDependencies: + eslint: 10.5.0(jiti@2.6.1) + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@es-joy/jsdoccomment@0.84.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.61.1 + comment-parser: 1.4.5 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.1.1 + + '@es-joy/jsdoccomment@0.86.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.61.1 + comment-parser: 1.4.6 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.2.0 + + '@es-joy/resolve.exports@1.2.0': {} + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@eslint-community/eslint-plugin-eslint-comments@4.7.1(eslint@10.5.0(jiti@2.6.1))': + dependencies: + escape-string-regexp: 4.0.0 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.6.1))': + dependencies: + eslint: 10.5.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/compat@2.0.3(eslint@10.5.0(jiti@2.6.1))': + dependencies: + '@eslint/core': 1.2.1 + optionalDependencies: + eslint: 10.5.0(jiti@2.6.1) + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3(supports-color@5.5.0) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/markdown@8.0.1': + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + github-slugger: 2.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-frontmatter: 2.0.1 + mdast-util-gfm: 3.1.0 + mdast-util-math: 3.0.0 + micromark-extension-frontmatter: 2.0.0 + micromark-extension-gfm: 3.0.0 + micromark-extension-math: 3.1.0 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@exodus/schemasafe@1.3.0': {} + + '@faker-js/faker@9.9.0': {} + + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.1.1 + fastify-plugin: 5.1.0 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/formbody@8.0.2': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/helmet@13.0.2': + dependencies: + fastify-plugin: 5.1.0 + helmet: 8.1.0 + + '@fastify/jwt@9.1.0': + dependencies: + '@fastify/error': 4.2.0 + '@lukeed/ms': 2.0.2 + fast-jwt: 5.0.6 + fastify-plugin: 5.1.0 + steed: 1.1.3 + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/otel@0.18.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/session@11.1.1': + dependencies: + fastify-plugin: 5.1.0 + safe-stable-stringify: 2.5.0 + + '@fastify/static@9.1.3': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@fastify/swagger-ui@5.2.6': + dependencies: + '@fastify/static': 9.1.3 + fastify-plugin: 5.1.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + + '@fastify/swagger@9.7.0': + dependencies: + fastify-plugin: 5.1.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + transitivePeerDependencies: + - supports-color + + '@gitbeaker/core@40.6.0': + dependencies: + '@gitbeaker/requester-utils': 40.6.0 + qs: 6.15.2 + xcase: 2.0.1 + + '@gitbeaker/requester-utils@40.6.0': + dependencies: + picomatch-browser: 2.2.6 + qs: 6.15.2 + rate-limiter-flexible: 4.0.1 + xcase: 2.0.1 + + '@gitbeaker/rest@40.6.0': + dependencies: + '@gitbeaker/core': 40.6.0 + '@gitbeaker/requester-utils': 40.6.0 + + '@gouvfr/dsfr@1.14.4': {} + + '@gouvminint/vue-dsfr@8.15.0(@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3)))(vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@gouvfr/dsfr': 1.14.4 + '@iconify/vue': 4.3.0(vue@3.5.30(typescript@5.9.3)) + focus-trap: 7.8.0 + focus-trap-vue: 4.1.0(focus-trap@7.8.0)(vue@3.5.30(typescript@5.9.3)) + vue: 3.5.30(typescript@5.9.3) + vue-router: 4.6.4(vue@3.5.30(typescript@5.9.3)) + + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.4 + yargs: 17.7.3 + + '@himenon/argocd-typescript-openapi@1.2.2': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@iconify-json/ri@1.2.10': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.1 + + '@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@iconify/types': 2.0.0 + vue: 3.5.30(typescript@5.9.3) + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/confirm@5.1.21(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/core@10.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/editor@4.2.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/external-editor': 1.0.3(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/expand@4.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/external-editor@1.0.3(@types/node@24.12.0)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/number@3.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/password@4.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/prompts@7.10.1(@types/node@24.12.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.12.0) + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@inquirer/editor': 4.2.23(@types/node@24.12.0) + '@inquirer/expand': 4.0.23(@types/node@24.12.0) + '@inquirer/input': 4.3.1(@types/node@24.12.0) + '@inquirer/number': 3.0.23(@types/node@24.12.0) + '@inquirer/password': 4.0.23(@types/node@24.12.0) + '@inquirer/rawlist': 4.1.11(@types/node@24.12.0) + '@inquirer/search': 3.2.2(@types/node@24.12.0) + '@inquirer/select': 4.4.2(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/prompts@7.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.12.0) + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@inquirer/editor': 4.2.23(@types/node@24.12.0) + '@inquirer/expand': 4.0.23(@types/node@24.12.0) + '@inquirer/input': 4.3.1(@types/node@24.12.0) + '@inquirer/number': 3.0.23(@types/node@24.12.0) + '@inquirer/password': 4.0.23(@types/node@24.12.0) + '@inquirer/rawlist': 4.1.11(@types/node@24.12.0) + '@inquirer/search': 3.2.2(@types/node@24.12.0) + '@inquirer/select': 4.4.2(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/rawlist@4.1.11(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/search@3.2.2(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/select@4.4.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/type@3.0.10(@types/node@24.12.0)': + optionalDependencies: + '@types/node': 24.12.0 + + '@isaacs/cliui@9.0.0': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@js-sdsl/ordered-map@4.4.2': {} + + '@keycloak/keycloak-admin-client@26.7.0': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + '@microsoft/kiota-http-fetchlibrary': 1.0.0-preview.103 + '@microsoft/kiota-serialization-form': 1.0.0-preview.103 + '@microsoft/kiota-serialization-json': 1.0.0-preview.103 + '@microsoft/kiota-serialization-multipart': 1.0.0-preview.103 + '@microsoft/kiota-serialization-text': 1.0.0-preview.103 + camelize-ts: 3.0.0 + url-template: 3.1.1 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@kubernetes-models/apimachinery@2.2.0': + dependencies: + '@kubernetes-models/base': 5.0.1 + '@kubernetes-models/validate': 4.0.0 + '@swc/helpers': 0.5.19 + + '@kubernetes-models/argo-cd@2.7.2': + dependencies: + '@kubernetes-models/apimachinery': 2.2.0 + '@kubernetes-models/base': 5.0.1 + '@kubernetes-models/validate': 4.0.0 + '@swc/helpers': 0.5.19 + + '@kubernetes-models/base@5.0.1': + dependencies: + '@kubernetes-models/validate': 4.0.0 + is-plain-object: 5.0.0 + tslib: 2.8.1 + + '@kubernetes-models/validate@4.0.0': + dependencies: + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-formats-draft2019: 1.6.1(ajv@8.20.0) + ajv-i18n: 4.2.0(ajv@8.20.0) + is-cidr: 4.0.2 + tslib: 2.8.1 + optionalDependencies: + re2-wasm: 1.0.2 + + '@lukeed/csprng@1.1.0': {} + + '@lukeed/ms@2.0.2': {} + + '@microsoft/kiota-abstractions@1.0.0-preview.103': + dependencies: + '@opentelemetry/api': 1.9.1 + '@std-uritemplate/std-uritemplate': 2.0.12 + tinyduration: 3.4.1 + tslib: 2.8.1 + + '@microsoft/kiota-http-fetchlibrary@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + '@opentelemetry/api': 1.9.1 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-form@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-json@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-multipart@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-text@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/tsdoc@0.16.0': {} + + '@mswjs/interceptors@0.41.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@nestjs/cache-manager@3.1.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cache-manager: 7.2.8 + keyv: 5.6.0 + rxjs: 7.8.2 + + '@nestjs/cli@11.0.16(@types/node@24.12.0)(esbuild@0.27.3)(lightningcss@1.32.0)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics-cli': 19.2.19(@types/node@24.12.0)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@24.12.0) + '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.9.3) + ansis: 4.2.0 + chokidar: 4.0.3 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)) + glob: 13.0.0 + node-emoji: 1.11.0 + ora: 5.4.1 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.9.3 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/html' + - '@types/node' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + - webpack-cli + + '@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.0 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/config@4.0.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.2.3 + dotenv-expand: 12.0.3 + lodash: 4.17.23 + rxjs: 7.8.2 + + '@nestjs/core@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxt/opencollective': 0.4.1 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.3.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + + '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + eventemitter2: 6.4.9 + + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.6 + express: 5.2.1 + multer: 2.1.1 + path-to-regexp: 8.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@nestjs/platform-fastify@11.1.27(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@fastify/cors': 11.2.0 + '@fastify/formbody': 8.0.2 + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-querystring: 1.1.2 + fastify: 5.8.5 + fastify-plugin: 6.0.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + path-to-regexp: 8.4.2 + reusify: 1.1.0 + tslib: 2.8.1 + optionalDependencies: + '@fastify/static': 9.1.3 + + '@nestjs/schedule@6.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 + + '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': + dependencies: + '@angular-devkit/core': 19.2.17(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.17(chokidar@4.0.3) + comment-json: 4.4.1 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.9.3 + transitivePeerDependencies: + - chokidar + + '@nestjs/swagger@11.4.1(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.4 + optionalDependencies: + '@fastify/static': 9.1.3 + + '@nestjs/terminus@11.1.1(@grpc/grpc-js@1.14.3)(@grpc/proto-loader@0.8.0)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + boxen: 5.1.2 + check-disk-space: 3.4.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + optionalDependencies: + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.8.0 + '@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + + '@nestjs/testing@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nuxt/opencollective@0.4.1': + dependencies: + consola: 3.4.2 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@opentelemetry/api-logs@0.212.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.213.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.214.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/auto-instrumentations-node@0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-amqplib': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-lambda': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-sdk': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-bunyan': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cassandra-driver': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-connect': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cucumber': 0.28.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dataloader': 0.29.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dns': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-express': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fastify': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fs': 0.31.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-generic-pool': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-graphql': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-hapi': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-kafkajs': 0.21.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-knex': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-koa': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-lru-memoizer': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-memcached': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongodb': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongoose': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql2': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-net': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-openai': 0.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-oracledb': 0.37.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-redis': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-restify': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-router': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-runtime-node': 0.25.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-socket.io': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-tedious': 0.31.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': 0.22.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-winston': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-alibaba-cloud': 0.33.3(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-aws': 2.13.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-azure': 0.20.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-container': 0.8.4(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-gcp': 0.47.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/configuration@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + yaml: 2.9.0 + + '@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/exporter-logs-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/exporter-trace-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/instrumentation-amqplib@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-lambda@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/aws-lambda': 8.10.161 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-sdk@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-bunyan@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@types/bunyan': 1.8.11 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cassandra-driver@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cucumber@0.28.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.29.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dns@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-express@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fastify@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.31.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.21.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-memcached@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/memcached': 2.2.10 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/mysql': 2.15.27 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-nestjs-core@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-net@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-openai@0.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-oracledb@0.37.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/oracledb': 6.5.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-restify@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-router@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-runtime-node@0.25.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-socket.io@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-tedious@0.31.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.22.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-winston@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + import-in-the-middle: 3.0.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + import-in-the-middle: 3.0.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + protobufjs: 8.0.0 + + '@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + protobufjs: 7.6.4 + + '@opentelemetry/propagator-b3@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/redis-common@0.38.2': {} + + '@opentelemetry/resource-detector-alibaba-cloud@0.33.3(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resource-detector-aws@2.13.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resource-detector-azure@0.20.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resource-detector-container@0.8.4(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resource-detector-gcp@0.47.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + gcp-metadata: 8.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-logs@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-metrics@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/configuration': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-trace-node@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/semantic-conventions@1.41.1': {} + + '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + + '@ota-meshi/ast-token-store@0.3.0': {} + + '@oxc-parser/binding-android-arm-eabi@0.115.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.115.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.115.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.115.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.115.0': + optional: true + + '@oxc-project/types@0.115.0': {} + + '@oxc-project/types@0.128.0': {} + + '@pinojs/redact@0.4.0': {} + + '@pkgr/core@0.2.9': {} + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + '@polka/url@1.0.0-next.29': {} + + '@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@6.19.2(magicast@0.3.5)': + dependencies: + c12: 3.1.0(magicast@0.3.5) + deepmerge-ts: 7.1.5 + effect: 3.18.4 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.2': {} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.2 + '@prisma/get-platform': 6.19.2 + + '@prisma/fetch-engine@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.2 + + '@prisma/get-platform@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.18': {} + + '@rolldown/pluginutils@1.0.0-rc.2': {} + + '@rollup/plugin-babel@5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.80.0)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@rollup/pluginutils': 3.1.0(rollup@2.80.0) + rollup: 2.80.0 + optionalDependencies: + '@types/babel__core': 7.20.5 + transitivePeerDependencies: + - supports-color + + '@rollup/plugin-node-resolve@15.3.1(rollup@2.80.0)': + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.12 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/plugin-replace@2.4.2(rollup@2.80.0)': + dependencies: + '@rollup/pluginutils': 3.1.0(rollup@2.80.0) + magic-string: 0.25.9 + rollup: 2.80.0 + + '@rollup/plugin-terser@0.4.4(rollup@2.80.0)': + dependencies: + serialize-javascript: 6.0.2 + smob: 1.6.2 + terser: 5.48.0 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/pluginutils@3.1.0(rollup@2.80.0)': + dependencies: + '@types/estree': 0.0.39 + estree-walker: 1.0.1 + picomatch: 2.3.2 + rollup: 2.80.0 + + '@rollup/pluginutils@5.4.0(rollup@2.80.0)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@scarf/scarf@1.4.0': {} + + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 + + '@simple-libs/stream-utils@1.2.0': {} + + '@sindresorhus/base62@1.0.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@std-uritemplate/std-uritemplate@2.0.12': {} + + '@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/types': 8.61.1 + eslint: 10.5.0(jiti@2.6.1) + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + estraverse: 5.3.0 + picomatch: 4.0.4 + + '@surma/rollup-plugin-off-main-thread@2.2.3': + dependencies: + ejs: 3.1.10 + json5: 2.2.3 + magic-string: 0.25.9 + string.prototype.matchall: 4.0.12 + + '@swc/helpers@0.5.19': + dependencies: + tslib: 2.8.1 + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3(supports-color@5.5.0) + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76)': + optionalDependencies: + '@types/node': 24.12.0 + zod: 3.25.76 + + '@ts-rest/fastify@3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76)': + dependencies: + '@ts-rest/core': 3.52.1(@types/node@24.12.0)(zod@3.25.76) + fastify: 5.8.5 + optionalDependencies: + zod: 3.25.76 + + '@ts-rest/open-api@3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76)': + dependencies: + '@anatine/zod-openapi': 1.14.2(openapi3-ts@2.0.2)(zod@3.25.76) + '@ts-rest/core': 3.52.1(@types/node@24.12.0)(zod@3.25.76) + openapi3-ts: 2.0.2 + zod: 3.25.76 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aws-lambda@8.10.161': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + optional: true + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + optional: true + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + optional: true + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + optional: true + + '@types/bunyan@1.8.11': + dependencies: + '@types/node': 24.12.0 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.12.0 + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.9 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + + '@types/esrecurse@4.3.1': {} + + '@types/estree@0.0.39': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/jsdom@21.1.7': + dependencies: + '@types/node': 24.12.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.12.0 + + '@types/katex@0.16.8': {} + + '@types/luxon@3.7.1': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/memcached@2.2.10': + dependencies: + '@types/node': 24.12.0 + + '@types/ms@2.1.0': {} + + '@types/mysql@2.15.27': + dependencies: + '@types/node': 24.12.0 + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + '@types/oracledb@6.5.2': + dependencies: + '@types/node': 24.12.0 + + '@types/pg-pool@2.0.7': + dependencies: + '@types/pg': 8.15.6 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 24.12.0 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + + '@types/resolve@1.20.2': {} + + '@types/statuses@2.0.6': {} + + '@types/swagger-schema-official@2.0.25': {} + + '@types/tedious@4.0.14': + dependencies: + '@types/node': 24.12.0 + + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': {} + + '@types/unist@3.0.3': {} + + '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/type-utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.0 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/type-utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.57.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3(supports-color@5.5.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3(supports-color@5.5.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + ajv: 6.15.0 + eslint: 10.5.0(jiti@2.6.1) + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/scope-manager@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + + '@typescript-eslint/scope-manager@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + + '@typescript-eslint/tsconfig-utils@8.57.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.57.0': {} + + '@typescript-eslint/types@8.59.2': {} + + '@typescript-eslint/types@8.61.1': {} + + '@typescript-eslint/typescript-estree@8.57.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.57.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + eslint-visitor-keys: 5.0.1 + + '@typescript-eslint/visitor-keys@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + eslint-visitor-keys: 5.0.1 + + '@unocss/cli@66.6.6': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + '@unocss/preset-wind4': 66.6.6 + '@unocss/transformer-directives': 66.6.6 + cac: 6.7.14 + chokidar: 5.0.0 + colorette: 2.0.20 + consola: 3.4.2 + magic-string: 0.30.21 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + tinyglobby: 0.2.17 + unplugin-utils: 0.3.1 + + '@unocss/config@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + colorette: 2.0.20 + consola: 3.4.2 + unconfig: 7.5.0 + + '@unocss/core@0.65.4': {} + + '@unocss/core@66.6.6': {} + + '@unocss/extractor-arbitrary-variants@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/inspector@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + colorette: 2.0.20 + gzip-size: 6.0.0 + sirv: 3.0.2 + + '@unocss/preset-attributify@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/preset-icons@66.6.6': + dependencies: + '@iconify/utils': 3.1.0 + '@unocss/core': 66.6.6 + ofetch: 1.5.1 + + '@unocss/preset-mini@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/extractor-arbitrary-variants': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-tagify@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/preset-typography@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-uno@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + + '@unocss/preset-web-fonts@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + ofetch: 1.5.1 + + '@unocss/preset-wind3@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-mini': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-wind4@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/extractor-arbitrary-variants': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-wind@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + + '@unocss/rule-utils@0.65.4': + dependencies: + '@unocss/core': 0.65.4 + magic-string: 0.30.21 + + '@unocss/rule-utils@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + magic-string: 0.30.21 + + '@unocss/transformer-attributify-jsx@66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@unocss/core': 66.6.6 + oxc-parser: 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + oxc-walker: 0.7.0(oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@unocss/transformer-compile-class@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/transformer-directives@0.65.4': + dependencies: + '@unocss/core': 0.65.4 + '@unocss/rule-utils': 0.65.4 + css-tree: 3.2.1 + + '@unocss/transformer-directives@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + css-tree: 3.2.1 + + '@unocss/transformer-variant-group@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/vite@66.6.6(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/inspector': 66.6.6 + chokidar: 5.0.0 + magic-string: 0.30.21 + pathe: 2.0.3 + tinyglobby: 0.2.17 + unplugin-utils: 0.3.1 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + + '@vitejs/plugin-vue@6.0.4(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vue: 3.5.30(typescript@5.9.3) + + '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.5 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + '@vitest/eslint-plugin@1.6.17(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + typescript: 5.9.3 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + + '@vitest/mocker@4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.30': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.30 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.30': + dependencies: + '@vue/compiler-core': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-sfc@3.5.30': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.30 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.30': + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + fast-glob: 3.3.3 + typescript-eslint: 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.30 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.30': + dependencies: + '@vue/shared': 3.5.30 + + '@vue/runtime-core@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/runtime-dom@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/runtime-core': 3.5.30 + '@vue/shared': 3.5.30 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + vue: 3.5.30(typescript@5.9.3) + + '@vue/shared@3.5.30': {} + + '@vue/tsconfig@0.7.0(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))': + optionalDependencies: + typescript: 5.9.3 + vue: 3.5.30(typescript@5.9.3) + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + abstract-logging@2.0.1: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + optional: true + + acorn-import-attributes@1.9.5(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-import-phases@1.0.4(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + agent-base@7.1.4: {} + + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats-draft2019@1.6.1(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + punycode: 2.3.1 + schemes: 1.4.0 + smtp-address-parser: 1.1.0 + uri-js: 4.4.1 + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-i18n@4.2.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv-keywords@3.5.2(ajv@6.15.0): + dependencies: + ajv: 6.15.0 + + ajv-keywords@5.1.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + fast-deep-equal: 3.1.3 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alien-signals@1.0.13: {} + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-colors@4.1.3: {} + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + ansis@4.2.0: {} + + any-base@1.1.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + append-field@1.0.0: + optional: true + + are-docs-informative@0.0.2: {} + + argparse@2.0.1: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-ify@1.0.0: {} + + array-timsort@1.0.3: {} + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + astral-regex@2.0.0: {} + + async-function@1.0.0: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + atomic-sleep@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + axios-retry@4.5.0(axios@1.16.0): + dependencies: + axios: 1.16.0 + is-retry-allowed: 2.2.0 + + axios@1.16.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.38: {} + + bignumber.js@9.3.1: {} + + binary-extensions@2.3.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bn.js@4.12.3: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + optional: true + + boolbase@1.0.0: {} + + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + brorand@1.1.0: + optional: true + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.38 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.376 + node-releases: 2.0.48 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builtin-modules@5.0.0: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + optional: true + + bytes@3.1.2: {} + + c12@3.1.0(magicast@0.3.5): + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.3.5 + + c12@3.3.3(magicast@0.5.3): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 17.3.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.5.3 + + cac@6.7.14: {} + + cac@7.0.0: {} + + cache-manager@7.2.8: + dependencies: + '@cacheable/utils': 2.4.0 + keyv: 5.6.0 + + cacheable@2.3.3: + dependencies: + '@cacheable/memory': 2.0.8 + '@cacheable/utils': 2.4.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.6.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + call-me-maybe@1.0.2: {} + + callsites@3.1.0: {} + + camelcase@6.3.0: {} + + camelize-ts@3.0.0: {} + + caniuse-lite@1.0.30001799: {} + + ccount@2.0.1: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + change-case@5.4.4: {} + + character-entities@2.0.2: {} + + chardet@2.1.1: {} + + check-disk-space@3.4.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chrome-trace-event@1.0.4: {} + + ci-info@4.4.0: {} + + cidr-regex@3.1.1: + dependencies: + ip-regex: 4.3.0 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.1: {} + + cjs-module-lexer@2.2.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clean-regexp@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + + cli-boxes@2.2.1: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.1 + + cli-width@4.1.0: {} + + clipboard@2.0.11: + dependencies: + good-listener: 1.2.2 + select: 1.1.2 + tiny-emitter: 2.1.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@14.0.3: {} + + commander@2.20.3: {} + + commander@4.1.1: {} + + commander@8.3.0: {} + + comment-json@4.4.1: + dependencies: + array-timsort: 1.0.3 + core-util-is: 1.0.3 + esprima: 4.0.1 + + comment-parser@1.4.5: {} + + comment-parser@1.4.6: {} + + common-tags@1.8.2: {} + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + optional: true + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + consola@3.4.2: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: + optional: true + + content-type@2.0.0: + optional: true + + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: + optional: true + + cookie@0.7.2: + optional: true + + cookie@1.1.1: {} + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.2 + + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + optional: true + + cosmiconfig-typescript-loader@6.3.0(@types/node@24.12.0)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3): + dependencies: + '@types/node': 24.12.0 + cosmiconfig: 9.0.2(typescript@5.9.3) + jiti: 2.6.1 + typescript: 5.9.3 + + cosmiconfig@8.3.6(typescript@5.9.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.2.0 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.3 + + cosmiconfig@9.0.2(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + cron-validator@1.4.0: {} + + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypto-random-string@2.0.0: {} + + css-functions-list@3.3.3: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-uri-to-buffer@4.0.1: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + date-fns@4.1.0: {} + + dateformat@4.6.3: {} + + dayjs@1.11.19: {} + + de-indent@1.0.2: {} + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + decimal.js@10.6.0: {} + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + deeks@3.1.0: {} + + deep-is@0.1.4: {} + + deepmerge-ts@7.1.5: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + defu@6.1.4: {} + + delayed-stream@1.0.0: {} + + delegate@3.2.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff-sequences@29.6.3: {} + + discontinuous-range@1.0.0: {} + + doc-path@4.1.1: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + dompurify@3.3.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.2.3: {} + + dotenv@17.3.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer@0.1.2: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: + optional: true + + effect@3.18.4: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-to-chromium@1.5.376: {} + + elliptic@6.6.1: + dependencies: + bn.js: 4.12.3 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + optional: true + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + empathic@2.0.0: {} + + encodeurl@2.0.0: + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.24.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + env-paths@2.2.1: {} + + environment@1.1.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.1 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.22 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.1.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-to-primitive@1.3.1: + dependencies: + es-abstract-get: 1.0.0 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es-toolkit@1.48.1: {} + + es6-promise@3.3.1: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-compat-utils@0.5.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + semver: 7.8.5 + + eslint-config-flat-gitignore@2.3.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/compat': 2.0.3(eslint@10.5.0(jiti@2.6.1)) + eslint: 10.5.0(jiti@2.6.1) + + eslint-flat-config-utils@3.2.0: + dependencies: + '@eslint/config-helpers': 0.5.5 + pathe: 2.0.3 + + eslint-json-compat-utils@0.2.3(eslint@10.5.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + esquery: 1.7.0 + jsonc-eslint-parser: 3.1.0 + + eslint-merge-processors@2.0.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-antfu@3.2.2(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-command@3.5.2(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@es-joy/jsdoccomment': 0.84.0 + '@typescript-eslint/rule-tester': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-depend@1.5.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + empathic: 2.0.0 + eslint: 10.5.0(jiti@2.6.1) + module-replacements: 2.11.0 + semver: 7.8.5 + + eslint-plugin-es-x@7.8.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + eslint: 10.5.0(jiti@2.6.1) + eslint-compat-utils: 0.5.1(eslint@10.5.0(jiti@2.6.1)) + + eslint-plugin-import-lite@0.6.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-jsdoc@62.9.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@es-joy/jsdoccomment': 0.86.0 + '@es-joy/resolve.exports': 1.2.0 + are-docs-informative: 0.0.2 + comment-parser: 1.4.6 + debug: 4.4.3(supports-color@5.5.0) + escape-string-regexp: 4.0.0 + eslint: 10.5.0(jiti@2.6.1) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.0 + parse-imports-exports: 0.2.4 + semver: 7.8.5 + spdx-expression-parse: 4.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color + + eslint-plugin-jsonc@3.1.2(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + diff-sequences: 29.6.3 + eslint: 10.5.0(jiti@2.6.1) + eslint-json-compat-utils: 0.2.3(eslint@10.5.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0) + jsonc-eslint-parser: 3.1.0 + natural-compare: 1.4.0 + synckit: 0.11.12 + transitivePeerDependencies: + - '@eslint/json' + + eslint-plugin-n@17.24.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + enhanced-resolve: 5.24.0 + eslint: 10.5.0(jiti@2.6.1) + eslint-plugin-es-x: 7.8.0(eslint@10.5.0(jiti@2.6.1)) + get-tsconfig: 4.13.6 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.8.5 + ts-declaration-location: 1.0.7(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + eslint-plugin-no-only-tests@3.3.0: {} + + eslint-plugin-perfectionist@5.9.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + natural-orderby: 5.0.0 + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-pnpm@1.6.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + empathic: 2.0.0 + eslint: 10.5.0(jiti@2.6.1) + jsonc-eslint-parser: 3.1.0 + pathe: 2.0.3 + pnpm-workspace-yaml: 1.6.0 + tinyglobby: 0.2.17 + yaml: 2.9.0 + yaml-eslint-parser: 2.0.0 + + eslint-plugin-regexp@3.1.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + comment-parser: 1.4.6 + eslint: 10.5.0(jiti@2.6.1) + jsdoc-type-pratt-parser: 7.2.0 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + scslre: 0.3.0 + + eslint-plugin-toml@1.3.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + toml-eslint-parser: 1.0.3 + transitivePeerDependencies: + - supports-color + + eslint-plugin-unicorn@64.0.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + change-case: 5.4.4 + ci-info: 4.4.0 + clean-regexp: 1.0.0 + core-js-compat: 3.49.0 + eslint: 10.5.0(jiti@2.6.1) + find-up-simple: 1.0.1 + globals: 17.6.0 + indent-string: 5.0.0 + is-builtin-module: 5.0.0 + jsesc: 3.1.0 + pluralize: 8.0.0 + regexp-tree: 0.1.27 + regjsparser: 0.13.2 + semver: 7.8.5 + strip-indent: 4.1.1 + + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + + eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + eslint: 10.5.0(jiti@2.6.1) + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.1 + semver: 7.8.5 + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + xml-name-validator: 4.0.0 + optionalDependencies: + '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + + eslint-plugin-yml@3.3.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + debug: 4.4.3(supports-color@5.5.0) + diff-sequences: 29.6.3 + escape-string-regexp: 5.0.0 + eslint: 10.5.0(jiti@2.6.1) + natural-compare: 1.4.0 + yaml-eslint-parser: 2.0.0 + transitivePeerDependencies: + - supports-color + + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@vue/compiler-sfc': 3.5.30 + eslint: 10.5.0(jiti@2.6.1) + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.5.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@5.5.0) + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-walker@1.0.1: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + eta@3.5.0: {} + + etag@1.8.1: + optional: true + + eventemitter2@6.4.9: {} + + eventemitter3@5.0.4: {} + + events@3.3.0: {} + + expect-type@1.3.0: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + optional: true + + exsolve@1.0.8: {} + + extend@3.0.2: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-copy@4.0.2: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-jwt@5.0.6: + dependencies: + '@lukeed/ms': 2.0.2 + asn1.js: 5.4.1 + ecdsa-sig-formatter: 1.0.11 + mnemonist: 0.40.4 + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.2: {} + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + + fastest-levenshtein@1.0.16: {} + + fastfall@1.5.1: + dependencies: + reusify: 1.1.0 + + fastify-keycloak-adapter@3.0.2: + dependencies: + '@fastify/cookie': 11.0.2 + '@fastify/jwt': 9.1.0 + '@fastify/session': 11.1.1 + axios: 1.16.0 + axios-retry: 4.5.0(axios@1.16.0) + fastify-plugin: 5.1.0 + fp-ts: 2.16.11 + grant: 5.4.24 + io-ts: 2.2.22(fp-ts@2.16.11) + qs: 6.15.2 + wildcard-match: 5.1.4 + transitivePeerDependencies: + - debug + + fastify-plugin@5.1.0: {} + + fastify-plugin@6.0.0: {} + + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 9.14.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.0 + + fastparallel@2.4.1: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fastseries@1.7.2: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + + fault@2.0.1: + dependencies: + format: 0.2.2 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + file-entry-cache@11.1.2: + dependencies: + flat-cache: 6.1.20 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-type@21.3.0: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.4 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + optional: true + + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + find-up-simple@1.0.1: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flat-cache@6.1.20: + dependencies: + cacheable: 2.3.3 + flatted: 3.4.2 + hookified: 1.15.1 + + flatted@3.4.2: {} + + focus-trap-vue@4.1.0(focus-trap@7.8.0)(vue@3.5.30(typescript@5.9.3)): + dependencies: + focus-trap: 7.8.0 + vue: 3.5.30(typescript@5.9.3) + + focus-trap@7.8.0: + dependencies: + tabbable: 6.4.0 + + follow-redirects@1.16.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)): + dependencies: + '@babel/code-frame': 7.29.7 + chalk: 4.1.2 + chokidar: 4.0.3 + cosmiconfig: 8.3.6(typescript@5.9.3) + deepmerge: 4.3.1 + fs-extra: 10.1.0 + memfs: 3.5.3 + minimatch: 3.1.5 + node-abort-controller: 3.1.1 + schema-utils: 3.3.0 + semver: 7.8.5 + tapable: 2.3.3 + typescript: 5.9.3 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + format@0.2.2: {} + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded-parse@2.1.2: {} + + forwarded@0.2.0: + optional: true + + fp-ts@2.16.11: {} + + fresh@2.0.0: + optional: true + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-monkey@1.1.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-own-enumerable-property-symbols@3.0.2: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.5 + pathe: 2.0.3 + + git-raw-commits@5.0.1(conventional-commits-parser@6.4.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + github-slugger@2.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + glob@13.0.0: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + global-directory@5.0.0: + dependencies: + ini: 6.0.0 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + global-prefix@4.0.0: + dependencies: + ini: 4.1.3 + kind-of: 6.0.3 + which: 4.0.0 + + globals@14.0.0: {} + + globals@15.15.0: {} + + globals@16.5.0: {} + + globals@17.6.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@16.2.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + + globjoin@0.1.4: {} + + globrex@0.1.2: {} + + good-listener@1.2.2: + dependencies: + delegate: 3.2.0 + + google-logging-utils@1.1.3: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + grant@5.4.24: + dependencies: + qs: 6.15.2 + request-compose: 2.1.7 + request-oauth: 1.0.1 + optionalDependencies: + cookie: 0.7.2 + cookie-signature: 1.2.2 + jwk-to-pem: 2.0.7 + jws: 4.0.1 + + graphql@16.14.2: {} + + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + + has-bigints@1.1.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-flag@5.0.1: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + optional: true + + hashery@1.5.0: + dependencies: + hookified: 1.15.1 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + headers-polyfill@4.0.3: {} + + helmet@8.1.0: {} + + help-me@5.0.0: {} + + hmac-drbg@1.0.1: + dependencies: + hash.js: 1.1.7 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + optional: true + + hookified@1.15.1: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + html-entities@2.6.0: {} + + html-escaper@2.0.2: {} + + html-tags@5.1.0: {} + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + http2-client@1.3.5: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + husky@9.1.7: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + idb@7.1.1: {} + + ieee754@1.2.1: {} + + ignore-by-default@1.0.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immediate@3.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-in-the-middle@2.0.6: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + import-in-the-middle@3.0.0: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + import-meta-resolve@4.2.0: {} + + imurmurhash@0.1.4: {} + + indent-string@5.0.0: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ini@4.1.3: {} + + ini@6.0.0: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + + io-ts@2.2.22(fp-ts@2.16.11): + dependencies: + fp-ts: 2.16.11 + + ip-regex@4.3.0: {} + + ipaddr.js@1.9.1: + optional: true + + ipaddr.js@2.4.0: {} + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-builtin-module@5.0.0: + dependencies: + builtin-modules: 5.0.0 + + is-callable@1.2.7: {} + + is-cidr@4.0.2: + dependencies: + cidr-regex: 3.1.1 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-map@2.0.3: {} + + is-module@1.0.0: {} + + is-negative-zero@2.0.3: {} + + is-node-process@1.2.0: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-obj@1.0.1: {} + + is-obj@2.0.0: {} + + is-path-inside@4.0.0: {} + + is-plain-obj@4.1.0: {} + + is-plain-object@5.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: + optional: true + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-regexp@1.0.0: {} + + is-retry-allowed@2.2.0: {} + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-unicode-supported@0.1.0: {} + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterare@1.2.1: {} + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + javascript-time-ago@2.6.4: + dependencies: + relative-time-format: 1.1.12 + + jest-worker@27.5.1: + dependencies: + '@types/node': 24.12.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jiti@2.6.1: {} + + joycon@3.1.1: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + jsdoc-type-pratt-parser@7.1.1: {} + + jsdoc-type-pratt-parser@7.2.0: {} + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-2-csv@5.5.10: + dependencies: + deeks: 3.1.0 + doc-path: 4.1.1 + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + fast-uri: 3.1.2 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema@0.4.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-eslint-parser@3.1.0: + dependencies: + acorn: 8.17.0 + eslint-visitor-keys: 5.0.1 + semver: 7.8.5 + + jsonc-parser@3.3.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonpointer@5.0.1: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwk-to-pem@2.0.7: + dependencies: + asn1.js: 5.4.1 + elliptic: 6.6.1 + safe-buffer: 5.2.1 + optional: true + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + katex@0.16.45: + dependencies: + commander: 8.3.0 + + keycloak-js@26.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.2.4 + yaml: 2.9.0 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + load-esm@1.0.3: {} + + loader-runner@4.3.2: {} + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.1 + pkg-types: 1.3.1 + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.1 + pkg-types: 2.3.0 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.debounce@4.0.8: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash.sortby@4.7.0: {} + + lodash.truncate@4.4.2: {} + + lodash@4.17.23: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + long@5.3.2: {} + + longest-streak@3.1.0: {} + + lru-cache@10.4.3: {} + + lru-cache@11.5.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + luxon@3.7.2: {} + + magic-regexp@0.10.0: + dependencies: + estree-walker: 3.0.3 + magic-string: 0.30.21 + mlly: 1.8.1 + regexp-tree: 0.1.27 + type-level-regexp: 0.1.17 + ufo: 1.6.3 + unplugin: 2.3.11 + + magic-string@0.25.9: + dependencies: + sourcemap-codec: 1.4.8 + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + optional: true + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + markdown-table@3.0.4: {} + + math-intrinsics@1.1.0: {} + + mathml-tag-names@4.0.0: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.27.1: {} + + media-typer@0.3.0: + optional: true + + media-typer@1.1.0: + optional: true + + memfs@3.5.3: + dependencies: + fs-monkey: 1.1.0 + + meow@13.2.0: {} + + meow@14.1.0: {} + + merge-descriptors@2.0.0: + optional: true + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.45 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3(supports-color@5.5.0) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: + optional: true + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + optional: true + + mime@3.0.0: {} + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimalistic-assert@1.0.1: {} + + minimalistic-crypto-utils@1.0.1: + optional: true + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.1 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mlly@1.8.1: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + mnemonist@0.40.4: + dependencies: + obliterator: 2.0.5 + + module-details-from-path@1.0.4: {} + + module-replacements@2.11.0: {} + + moo@0.5.3: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.7.0 + until-async: 3.0.2 + yargs: 17.7.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + muggle-string@0.4.1: {} + + multer@2.1.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + optional: true + + mustache@4.2.0: {} + + mute-stream@2.0.0: {} + + nanoid@3.3.15: {} + + nanoid@5.0.9: {} + + nanoid@5.1.6: {} + + natural-compare@1.4.0: {} + + natural-orderby@5.0.0: {} + + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.3 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + + negotiator@1.0.0: + optional: true + + neo-async@2.6.2: {} + + nestjs-pino@4.6.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2): + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + pino: 10.3.1 + pino-http: 11.0.0 + rxjs: 7.8.2 + + node-abort-controller@3.1.1: {} + + node-domexception@1.0.0: {} + + node-emoji@1.11.0: + dependencies: + lodash: 4.18.1 + + node-fetch-h2@2.3.0: + dependencies: + http2-client: 1.3.5 + + node-fetch-native@1.6.7: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-readfiles@0.2.0: + dependencies: + es6-promise: 3.3.1 + + node-releases@2.0.48: {} + + nodemon@3.1.14: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 10.2.5 + pstree.remy: 1.1.8 + semver: 7.8.5 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nwsapi@2.2.24: {} + + nypm@0.6.5: + dependencies: + citty: 0.2.1 + pathe: 2.0.3 + tinyexec: 1.2.4 + + oas-kit-common@1.0.8: + dependencies: + fast-safe-stringify: 2.1.1 + + oas-linter@3.2.2: + dependencies: + '@exodus/schemasafe': 1.3.0 + should: 13.2.3 + yaml: 1.10.3 + + oas-resolver@2.5.6: + dependencies: + node-fetch-h2: 2.3.0 + oas-kit-common: 1.0.8 + reftools: 1.1.9 + yaml: 1.10.3 + yargs: 17.7.3 + + oas-schema-walker@1.1.5: {} + + oas-validator@5.0.8: + dependencies: + call-me-maybe: 1.0.2 + oas-kit-common: 1.0.8 + oas-linter: 3.2.2 + oas-resolver: 2.5.6 + oas-schema-walker: 1.1.5 + reftools: 1.1.9 + should: 13.2.3 + yaml: 1.10.3 + + oauth-sign@0.9.0: {} + + object-assign@4.1.1: + optional: true + + object-deep-merge@2.0.0: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + obliterator@2.0.5: {} + + obug@2.1.3: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.3 + + ohash@2.0.11: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + optional: true + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + openapi-types@12.1.3: {} + + openapi3-ts@2.0.2: + dependencies: + yaml: 1.10.3 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + outvariant@1.4.3: {} + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + dependencies: + '@oxc-project/types': 0.115.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.115.0 + '@oxc-parser/binding-android-arm64': 0.115.0 + '@oxc-parser/binding-darwin-arm64': 0.115.0 + '@oxc-parser/binding-darwin-x64': 0.115.0 + '@oxc-parser/binding-freebsd-x64': 0.115.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.115.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.115.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.115.0 + '@oxc-parser/binding-linux-arm64-musl': 0.115.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.115.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.115.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.115.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.115.0 + '@oxc-parser/binding-linux-x64-gnu': 0.115.0 + '@oxc-parser/binding-linux-x64-musl': 0.115.0 + '@oxc-parser/binding-openharmony-arm64': 0.115.0 + '@oxc-parser/binding-wasm32-wasi': 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@oxc-parser/binding-win32-arm64-msvc': 0.115.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.115.0 + '@oxc-parser/binding-win32-x64-msvc': 0.115.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + oxc-walker@0.7.0(oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)): + dependencies: + magic-regexp: 0.10.0 + oxc-parser: 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + + p-debounce@4.0.0: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + package-manager-detector@1.6.0: {} + + pako@1.0.11: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-gitignore@2.0.0: {} + + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-statements@1.0.11: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: + optional: true + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + + path-to-regexp@6.3.0: {} + + path-to-regexp@8.3.0: {} + + path-to-regexp@8.4.2: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + pg-int8@1.0.1: {} + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + picocolors@1.1.1: {} + + picomatch-browser@2.2.6: {} + + picomatch@2.3.2: {} + + picomatch@4.0.2: {} + + picomatch@4.0.4: {} + + pinia@2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.30(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@vue/composition-api' + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-http@11.0.0: + dependencies: + get-caller-file: 2.0.5 + pino: 10.3.1 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.1.0 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.1 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + + pluralize@8.0.0: {} + + pnpm-workspace-yaml@1.6.0: + dependencies: + yaml: 2.9.0 + + possible-typed-array-names@1.1.0: {} + + postcss-html@1.8.1: + dependencies: + htmlparser2: 8.0.2 + js-tokens: 9.0.1 + postcss: 8.5.15 + postcss-safe-parser: 6.0.0(postcss@8.5.15) + + postcss-safe-parser@6.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-safe-parser@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prelude-ls@1.2.1: {} + + pretty-bytes@5.6.0: {} + + pretty-bytes@6.1.1: {} + + prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.2(magicast@0.3.5) + '@prisma/engines': 6.19.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + process-nextick-args@2.0.1: {} + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + protobufjs@7.6.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 24.12.0 + long: 5.3.2 + + protobufjs@8.0.0: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 24.12.0 + long: 5.3.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + optional: true + + proxy-from-env@2.1.0: {} + + pstree.remy@1.1.8: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + qified@0.6.0: + dependencies: + hookified: 1.15.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + + quansync@0.2.11: {} + + quansync@1.0.0: {} + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + range-parser@1.2.1: + optional: true + + rate-limiter-flexible@4.0.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + optional: true + + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + + re2-wasm@1.0.2: + optional: true + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + readdirp@4.1.2: {} + + readdirp@5.0.0: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + refa@0.12.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + + reflect-metadata@0.2.2: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + reftools@1.1.9: {} + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regexp-ast-analysis@0.7.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + + regexp-tree@0.1.27: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.2 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 + + relative-time-format@1.1.12: {} + + request-compose@2.1.7: {} + + request-oauth@1.0.1: + dependencies: + oauth-sign: 0.9.0 + qs: 6.15.2 + uuid: 8.3.2 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + + reserved-identifiers@1.2.0: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + ret@0.1.15: {} + + ret@0.5.0: {} + + rettime@0.10.1: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + + rolldown@1.0.0-rc.18: + dependencies: + '@oxc-project/types': 0.128.0 + '@rolldown/pluginutils': 1.0.0-rc.18 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.18 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.18 + '@rolldown/binding-darwin-x64': 1.0.0-rc.18 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.18 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.18 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.18 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.18 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.18 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.18 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.18 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.18 + + rollup@2.80.0: + optionalDependencies: + fsevents: 2.3.3 + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + optional: true + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.15.0 + ajv-keywords: 3.5.2(ajv@6.15.0) + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-keywords: 5.1.0(ajv@8.20.0) + + schemes@1.4.0: + dependencies: + extend: 3.0.2 + + scslre@0.3.0: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + + scule@1.3.0: {} + + secure-json-parse@4.1.0: {} + + select@1.1.2: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + optional: true + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + optional: true + + set-cookie-parser@2.7.2: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + short-uuid@5.2.0: + dependencies: + any-base: 1.1.0 + uuid: 9.0.1 + + should-equal@2.0.0: + dependencies: + should-type: 1.4.0 + + should-format@3.0.3: + dependencies: + should-type: 1.4.0 + should-type-adaptors: 1.1.0 + + should-type-adaptors@1.1.0: + dependencies: + should-type: 1.4.0 + should-util: 1.0.1 + + should-type@1.4.0: {} + + should-util@1.0.1: {} + + should@13.2.3: + dependencies: + should-equal: 2.0.0 + should-format: 3.0.3 + should-type: 1.4.0 + should-type-adaptors: 1.1.0 + should-util: 1.0.1 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.5 + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sisteransi@1.0.5: {} + + slash@5.1.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + smob@1.6.2: {} + + smtp-address-parser@1.1.0: + dependencies: + nearley: 2.20.1 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.4: {} + + source-map@0.8.0-beta.0: + dependencies: + whatwg-url: 7.1.0 + + sourcemap-codec@1.4.8: {} + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@4.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.1.0: {} + + steed@1.1.3: + dependencies: + fastfall: 1.5.1 + fastparallel: 2.4.1 + fastq: 1.20.1 + fastseries: 1.7.2 + reusify: 1.1.0 + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + streamsearch@1.1.0: + optional: true + + strict-event-emitter@0.5.1: {} + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-object@3.3.0: + dependencies: + get-own-enumerable-property-symbols: 3.0.2 + is-obj: 1.0.1 + is-regexp: 1.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-comments@2.0.1: {} + + strip-indent@4.1.1: {} + + strip-json-comments@3.1.1: {} + + strip-json-comments@5.0.3: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + strtok3@10.3.4: + dependencies: + '@tokenizer/token': 0.3.0 + + stylelint-config-html@1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + postcss-html: 1.8.1 + stylelint: 17.11.0(typescript@5.9.3) + + stylelint-config-recommended-vue@1.6.1(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + postcss-html: 1.8.1 + semver: 7.8.5 + stylelint: 17.11.0(typescript@5.9.3) + stylelint-config-html: 1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-recommended: 18.0.0(stylelint@17.11.0(typescript@5.9.3)) + + stylelint-config-recommended@18.0.0(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + stylelint: 17.11.0(typescript@5.9.3) + + stylelint-config-standard@40.0.0(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + stylelint: 17.11.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.11.0(typescript@5.9.3)) + + stylelint@17.11.0(typescript@5.9.3): + dependencies: + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) + colord: 2.9.3 + cosmiconfig: 9.0.2(typescript@5.9.3) + css-functions-list: 3.3.3 + css-tree: 3.2.1 + debug: 4.4.3(supports-color@5.5.0) + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.2 + global-modules: 2.0.0 + globby: 16.2.0 + globjoin: 0.1.4 + html-tags: 5.1.0 + ignore: 7.0.5 + import-meta-resolve: 4.2.0 + is-plain-object: 5.0.0 + mathml-tag-names: 4.0.0 + meow: 14.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-safe-parser: 7.0.1(postcss@8.5.15) + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + string-width: 8.2.1 + supports-hyperlinks: 4.4.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 7.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@10.2.2: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@4.4.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-tags@1.0.0: {} + + swagger-schema-official@2.0.0-bab6bed: {} + + swagger-typescript-api@13.3.1(magicast@0.5.3): + dependencies: + '@apidevtools/swagger-parser': 12.1.0(openapi-types@12.1.3) + '@biomejs/js-api': 4.0.0(@biomejs/wasm-nodejs@2.4.6) + '@biomejs/wasm-nodejs': 2.4.6 + '@types/swagger-schema-official': 2.0.25 + c12: 3.3.3(magicast@0.5.3) + citty: 0.2.1 + consola: 3.4.2 + es-toolkit: 1.48.1 + eta: 3.5.0 + nanoid: 5.1.6 + openapi-types: 12.1.3 + swagger-schema-official: 2.0.0-bab6bed + swagger2openapi: 7.0.8 + type-fest: 5.7.0 + typescript: 5.9.3 + yaml: 2.9.0 + yummies: 7.10.0 + transitivePeerDependencies: + - '@biomejs/wasm-bundler' + - '@biomejs/wasm-web' + - encoding + - magicast + - mobx + - react + + swagger-ui-dist@5.32.4: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger2openapi@7.0.8: + dependencies: + call-me-maybe: 1.0.2 + node-fetch: 2.7.0 + node-fetch-h2: 2.3.0 + node-readfiles: 0.2.0 + oas-kit-common: 1.0.8 + oas-resolver: 2.5.6 + oas-schema-walker: 1.1.5 + oas-validator: 5.0.8 + reftools: 1.1.9 + yaml: 1.10.3 + yargs: 17.7.3 + transitivePeerDependencies: + - encoding + + symbol-observable@4.0.0: {} + + symbol-tree@3.2.4: {} + + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + + tabbable@6.4.0: {} + + table@6.9.0: + dependencies: + ajv: 8.20.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tagged-tag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tapable@2.3.3: {} + + temp-dir@2.0.0: {} + + tempy@0.6.0: + dependencies: + is-stream: 2.0.1 + temp-dir: 2.0.0 + type-fest: 0.16.0 + unique-string: 2.0.0 + + terser-webpack-plugin@5.6.1(esbuild@0.27.3)(lightningcss@1.32.0)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.48.0 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + optionalDependencies: + esbuild: 0.27.3 + lightningcss: 1.32.0 + + terser@5.48.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.17.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tiny-emitter@2.1.0: {} + + tinybench@2.9.0: {} + + tinyduration@3.4.1: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tldts-core@6.1.86: {} + + tldts-core@7.4.3: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tldts@7.4.3: + dependencies: + tldts-core: 7.4.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + + toad-cache@3.7.0: {} + + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.1 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + toml-eslint-parser@1.0.3: + dependencies: + eslint-visitor-keys: 5.0.1 + + totalist@3.0.1: {} + + touch@3.1.1: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.3 + + tr46@0.0.3: {} + + tr46@1.0.1: + dependencies: + punycode: 2.3.1 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-declaration-location@1.0.7(typescript@5.9.3): + dependencies: + picomatch: 4.0.4 + typescript: 5.9.3 + + ts-deepmerge@6.2.1: {} + + ts-essentials@10.1.1(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + ts-patch@3.3.0: + dependencies: + chalk: 4.1.2 + global-prefix: 4.0.0 + minimist: 1.2.8 + resolve: 1.22.12 + semver: 7.8.5 + strip-ansi: 6.0.1 + + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.24.0 + tapable: 2.3.3 + tsconfig-paths: 4.2.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.16.0: {} + + type-fest@0.20.2: {} + + type-fest@5.7.0: + dependencies: + tagged-tag: 1.0.0 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + optional: true + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + optional: true + + type-level-regexp@0.1.17: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typedarray@0.0.6: + optional: true + + typescript-eslint@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript-transform-paths@3.5.6(typescript@5.9.3): + dependencies: + minimatch: 9.0.9 + typescript: 5.9.3 + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + unconfig@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + defu: 6.1.4 + jiti: 2.6.1 + quansync: 1.0.0 + unconfig-core: 7.5.0 + + undefsafe@2.0.5: {} + + undici-types@7.16.0: {} + + undici@7.24.5: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + unicorn-magic@0.4.0: {} + + unimport@3.14.6(rollup@2.80.0): + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + acorn: 8.17.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + fast-glob: 3.3.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.1 + pathe: 2.0.3 + picomatch: 4.0.4 + pkg-types: 1.3.1 + scule: 1.3.0 + strip-literal: 2.1.1 + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + + unique-string@2.0.0: + dependencies: + crypto-random-string: 2.0.0 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universalify@2.0.1: {} + + unocss@66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@unocss/cli': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/preset-attributify': 66.6.6 + '@unocss/preset-icons': 66.6.6 + '@unocss/preset-mini': 66.6.6 + '@unocss/preset-tagify': 66.6.6 + '@unocss/preset-typography': 66.6.6 + '@unocss/preset-uno': 66.6.6 + '@unocss/preset-web-fonts': 66.6.6 + '@unocss/preset-wind': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + '@unocss/preset-wind4': 66.6.6 + '@unocss/transformer-attributify-jsx': 66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@unocss/transformer-compile-class': 66.6.6 + '@unocss/transformer-directives': 66.6.6 + '@unocss/transformer-variant-group': 66.6.6 + '@unocss/vite': 66.6.6(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - vite + + unpipe@1.0.0: + optional: true + + unplugin-auto-import@0.18.6(rollup@2.80.0): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + fast-glob: 3.3.3 + local-pkg: 0.5.1 + magic-string: 0.30.21 + minimatch: 9.0.9 + unimport: 3.14.6(rollup@2.80.0) + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + + unplugin-vue-components@0.27.5(@babel/parser@7.29.7)(rollup@2.80.0)(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + fast-glob: 3.3.3 + local-pkg: 0.5.1 + magic-string: 0.30.21 + minimatch: 9.0.9 + mlly: 1.8.1 + unplugin: 1.16.1 + vue: 3.5.30(typescript@5.9.3) + optionalDependencies: + '@babel/parser': 7.29.7 + transitivePeerDependencies: + - rollup + - supports-color + + unplugin@1.16.1: + dependencies: + acorn: 8.17.0 + webpack-virtual-modules: 0.6.2 + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.17.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + until-async@3.0.2: {} + + upath@1.2.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-template@3.1.1: {} + + util-deprecate@1.0.2: {} + + uuid@8.3.2: {} + + uuid@9.0.1: {} + + vary@1.1.2: + optional: true + + vite-node@6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0): + dependencies: + cac: 7.0.0 + es-module-lexer: 2.1.0 + obug: 2.1.3 + pathe: 2.0.3 + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + vite-plugin-pwa@1.2.0(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): + dependencies: + debug: 4.4.3(supports-color@5.5.0) + pretty-bytes: 6.1.1 + tinyglobby: 0.2.17 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + workbox-build: 7.4.0(@types/babel__core@7.20.5) + workbox-window: 7.4.0 + transitivePeerDependencies: + - supports-color + + vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.59.0 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + terser: 5.48.0 + yaml: 2.9.0 + + vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.0-rc.18 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.0 + esbuild: 0.27.3 + fsevents: 2.3.3 + jiti: 2.6.1 + terser: 5.48.0 + yaml: 2.9.0 + + vitest-mock-extended@2.0.2(typescript@5.9.3)(vitest@4.1.5): + dependencies: + ts-essentials: 10.1.1(typescript@5.9.3) + typescript: 5.9.3 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.0 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + jsdom: 25.0.1 + transitivePeerDependencies: + - msw + + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.0 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + jsdom: 25.0.1 + transitivePeerDependencies: + - msw + + vscode-uri@3.1.0: {} + + vue-demi@0.14.10(vue@3.5.30(typescript@5.9.3)): + dependencies: + vue: 3.5.30(typescript@5.9.3) + + vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.30(typescript@5.9.3) + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue3-json-viewer@2.4.1(vue@3.5.30(typescript@5.9.3)): + dependencies: + clipboard: 2.0.11 + vue: 3.5.30(typescript@5.9.3) + + vue@3.5.30(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-sfc': 3.5.30 + '@vue/runtime-dom': 3.5.30 + '@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3)) + '@vue/shared': 3.5.30 + optionalDependencies: + typescript: 5.9.3 + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + watchpack@2.5.2: + dependencies: + graceful-fs: 4.2.11 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@4.0.2: {} + + webidl-conversions@7.0.0: {} + + webpack-node-externals@3.0.0: {} + + webpack-sources@3.5.0: {} + + webpack-virtual-modules@0.6.2: {} + + webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.17.0 + acorn-import-phases: 1.0.4(acorn@8.17.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.24.0 + es-module-lexer: 2.1.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.2 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.1(esbuild@0.27.3)(lightningcss@1.32.0)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)) + watchpack: 2.5.2 + webpack-sources: 3.5.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + whatwg-url@7.1.0: + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.22 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + wildcard-match@5.1.4: {} + + word-wrap@1.2.5: {} + + workbox-background-sync@7.4.0: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.0 + + workbox-broadcast-update@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-build@7.4.0(@types/babel__core@7.20.5): + dependencies: + '@apideck/better-ajv-errors': 0.3.7(ajv@8.20.0) + '@babel/core': 7.29.7 + '@babel/preset-env': 7.29.7(@babel/core@7.29.7) + '@babel/runtime': 7.29.7 + '@rollup/plugin-babel': 5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.80.0) + '@rollup/plugin-node-resolve': 15.3.1(rollup@2.80.0) + '@rollup/plugin-replace': 2.4.2(rollup@2.80.0) + '@rollup/plugin-terser': 0.4.4(rollup@2.80.0) + '@surma/rollup-plugin-off-main-thread': 2.2.3 + ajv: 8.20.0 + common-tags: 1.8.2 + fast-json-stable-stringify: 2.1.0 + fs-extra: 9.1.0 + glob: 11.1.0 + lodash: 4.18.1 + pretty-bytes: 5.6.0 + rollup: 2.80.0 + source-map: 0.8.0-beta.0 + stringify-object: 3.3.0 + strip-comments: 2.0.1 + tempy: 0.6.0 + upath: 1.2.0 + workbox-background-sync: 7.4.0 + workbox-broadcast-update: 7.4.0 + workbox-cacheable-response: 7.4.0 + workbox-core: 7.4.0 + workbox-expiration: 7.4.0 + workbox-google-analytics: 7.4.0 + workbox-navigation-preload: 7.4.0 + workbox-precaching: 7.4.0 + workbox-range-requests: 7.4.0 + workbox-recipes: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + workbox-streams: 7.4.0 + workbox-sw: 7.4.0 + workbox-window: 7.4.0 + transitivePeerDependencies: + - '@types/babel__core' + - supports-color + + workbox-cacheable-response@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-core@7.4.0: {} + + workbox-expiration@7.4.0: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.0 + + workbox-google-analytics@7.4.0: + dependencies: + workbox-background-sync: 7.4.0 + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-navigation-preload@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-precaching@7.4.0: + dependencies: + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-range-requests@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-recipes@7.4.0: + dependencies: + workbox-cacheable-response: 7.4.0 + workbox-core: 7.4.0 + workbox-expiration: 7.4.0 + workbox-precaching: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-routing@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-strategies@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-streams@7.4.0: + dependencies: + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + + workbox-sw@7.4.0: {} + + workbox-window@7.4.0: + dependencies: + '@types/trusted-types': 2.0.7 + workbox-core: 7.4.0 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@7.0.1: + dependencies: + signal-exit: 4.1.0 + + ws@8.21.0: {} + + xbytes@1.9.1: {} + + xcase@2.0.1: {} + + xml-name-validator@4.0.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml-eslint-parser@2.0.0: + dependencies: + eslint-visitor-keys: 5.0.1 + yaml: 2.9.0 + + yaml@1.10.3: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + yummies@7.10.0: + dependencies: + class-variance-authority: 0.7.1 + clsx: 2.1.1 + dayjs: 1.11.19 + dompurify: 3.3.2 + nanoid: 5.1.6 + tailwind-merge: 3.5.0 + + zod-validation-error@3.5.4(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + + zwitch@2.0.4: {} diff --git a/.jjconflict-side-1/pnpm-workspace.yaml b/.jjconflict-side-1/pnpm-workspace.yaml new file mode 100644 index 0000000000..a812ffa983 --- /dev/null +++ b/.jjconflict-side-1/pnpm-workspace.yaml @@ -0,0 +1,162 @@ +packages: + - packages/** + - plugins/** + - apps/** + - "!apps/server/src/plugins/external/**" + - playwright/** +allowBuilds: + "@nestjs/core": true + "@prisma/client": true + "@prisma/engines": true + "@scarf/scarf": true + esbuild: true + msw: true + prisma: true + protobufjs: true + vue-demi: true +allowUnusedPatches: true +autoInstallPeers: true +injectWorkspacePackages: true +# minimum number of minutes that must pass after a version is published before +# pnpm will install it. This applies to all dependencies, including transitive ones. +minimumReleaseAge: 1440 # 1 day +catalogMode: strict +cleanupUnusedCatalogs: true +shellEmulator: true +trustPolicy: no-downgrade +# Check these after each update. This list should be empty ! +trustPolicyExclude: + - rollup@2.80.0 + - semver@6.3.1 + - chokidar@4.0.3 + - pino@9.14.0 + - typescript-transform-paths@3.5.6 + - undici-types@6.21.0 +catalogs: + runtime: + "@gitbeaker/core": ^40.6.0 + "@gitbeaker/requester-utils": ^40.6.0 + "@gitbeaker/rest": ^40.6.0 + "@gouvfr/dsfr": ^1.14.4 + "@gouvminint/vue-dsfr": ^8.15.0 + "@himenon/argocd-typescript-openapi": ^1.2.2 + "@iconify-json/ri": ^1.2.10 + "@iconify/vue": ^4.3.0 + "@keycloak/keycloak-admin-client": ^26.5.5 + "@kubernetes-models/argo-cd": ^2.7.2 + "@nestjs/cache-manager": ^3.1.2 + "@nestjs/common": ^11.1.16 + "@nestjs/config": ^4.0.3 + "@nestjs/core": ^11.1.16 + "@nestjs/event-emitter": ^3.0.1 + "@nestjs/jwt": ^11.0.2 + "@nestjs/platform-fastify": ^11.1.16 + "@nestjs/schedule": ^6.1.1 + "@nestjs/swagger": ^11.4.1 + "@nestjs/terminus": ^11.1.1 + "@prisma/client": ^6.19.2 + "@ts-rest/core": ^3.52.1 + "@ts-rest/fastify": ^3.52.1 + "@ts-rest/open-api": ^3.52.1 + axios: ^1.15.1 + cache-manager: ^7.2.8 + date-fns: ^4.1.0 + fastify: ^5.8.5 + fastify-keycloak-adapter: ^3.0.0 + javascript-time-ago: ^2.6.4 + json-2-csv: ^5.5.10 + json-schema: ^0.4.0 + jszip: ^3.10.1 + keycloak-js: ^26.2.3 + mustache: ^4.2.0 + nanoid: 5.0.9 + nestjs-pino: ^4.6.0 + p-debounce: ^4.0.0 + pinia: ^2.3.1 + prisma: ^6.19.2 + reflect-metadata: ^0.2.2 + rxjs: ^7.8.2 + short-uuid: ^5.2.0 + undici: ^7.24.0 + vitest-mock-extended: ^2.0.2 + vue: ^3.5.30 + vue-router: ^4.6.4 + vue3-json-viewer: ^2.4.1 + xbytes: ^1.9.1 + yaml: ^2.7.1 + zod: ^3.25.76 + zod-validation-error: ^3.5.4 + "@fastify/cookie": ^11.0.2 + "@fastify/helmet": ^13.0.2 + "@fastify/otel": ^0.18.1 + "@fastify/session": ^11.1.1 + "@fastify/swagger": ^9.7.0 + "@fastify/swagger-ui": ^5.2.6 + bytes: ^3.1.2 + cron-validator: ^1.4.0 + build: + "@iconify/types": ^2.0.0 + "@nestjs/cli": ^11.0.16 + "@nestjs/schematics": ^11.0.9 + "@nestjs/testing": ^11.1.16 + "@unocss/transformer-directives": ^0.65.4 + "@vitejs/plugin-vue": ^6.0.4 + "@vue/tsconfig": ^0.7.0 + fastify-plugin: ^5.1.0 + swagger-typescript-api: ^13.3.1 + ts-patch: ^3.3.0 + typescript: ^5.9.3 + typescript-transform-paths: ^3.5.6 + unocss: ^66.6.6 + unplugin-auto-import: ^0.18.6 + unplugin-vue-components: ^0.27.5 + vite: ^7.3.2 + vite-node: ^6.0.0 + vite-plugin-pwa: ^1.2.0 + vue-tsc: ^2.2.12 + workbox-window: ^7.4.0 + test: + "@playwright/test": ^1.59.1 + "@faker-js/faker": ^9.9.0 + "@vitest/coverage-v8": ^4.1.5 + globals: ^16.5.0 + jsdom: ^25.0.1 + msw: ^2.12.10 + vitest: ^4.1.5 + types: + "@types/jsdom": ^21.1.7 + "@types/json-schema": ^7.0.15 + "@types/node": ^24.12.0 + undici-types: ^7.16.0 + otel: + "@opentelemetry/api": ^1.9.0 + "@opentelemetry/auto-instrumentations-node": ^0.70.1 + "@opentelemetry/exporter-metrics-otlp-proto": ^0.213.0 + "@opentelemetry/exporter-trace-otlp-proto": ^0.213.0 + "@opentelemetry/instrumentation-nestjs-core": ^0.58.0 + "@opentelemetry/instrumentation-pino": ^0.59.0 + "@opentelemetry/sdk-metrics": ^2.5.1 + "@opentelemetry/sdk-node": ^0.212.0 + tools: + "@antfu/eslint-config": ^8.2.0 + "@commitlint/cli": ^20.5.3 + "@commitlint/config-conventional": ^20.5.3 + "@eslint/eslintrc": ^3.3.5 + "@eslint/js": ^9.39.4 + "@vue/eslint-config-typescript": ^14.7.0 + chalk: ^5.6.2 + eslint: ^10.5.0 + eslint-plugin-vue: ^10.8.0 + husky: ^9.1.7 + js-yaml: ^4.1.0 + lint-staged: ^16.4.0 + nodemon: ^3.1.14 + pino: ^9.14.0 + pino-pretty: ^13.1.3 + rimraf: ^6.1.3 + stylelint: ^17.11.0 + stylelint-config-html: ^1.1.0 + stylelint-config-recommended-vue: ^1.6.1 + stylelint-config-standard: ^40.0.0 + typescript-eslint: ^8.57.0 + vue-eslint-parser: ^10.4.0 diff --git a/.jjconflict-side-1/release-please-config.json b/.jjconflict-side-1/release-please-config.json new file mode 100644 index 0000000000..473308bd27 --- /dev/null +++ b/.jjconflict-side-1/release-please-config.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "versioning": "always-bump-minor", + "pull-request-title-pattern": "chore${scope}: Release${component} v${version}", + "release-type": "node", + "changelog-type": "default", + "changelog-sections": [ + { + "type": "feat", + "hidden": false, + "section": "Features" + }, + { + "type": "feature", + "hidden": false, + "section": "Features" + }, + { + "type": "fix", + "hidden": false, + "section": "Bug Fixes" + }, + { + "type": "chore", + "hidden": false, + "section": "Miscellaneous Chores" + }, + { + "type": "build", + "hidden": false, + "section": "Miscellaneous Chores" + }, + { + "type": "docs", + "hidden": false, + "section": "Docs" + }, + { + "type": "refactor", + "hidden": false, + "section": "Refactoring" + }, + { + "type": "revert", + "hidden": false, + "section": "Reverted commits" + } + ], + "packages": { + ".": { + "package-name": "console", + "include-component-in-tag": false + } + } +} diff --git a/.jjconflict-side-1/scripts/run-build.sh b/.jjconflict-side-1/scripts/run-build.sh new file mode 100755 index 0000000000..9e53b07cf8 --- /dev/null +++ b/.jjconflict-side-1/scripts/run-build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd $(dirname "$COMPOSE_FILE") \ + && docker buildx bake --file $(basename "$COMPOSE_FILE") --load --allow=fs.read=.. \ + && cd - > /dev/null diff --git a/.jjconflict-side-1/scripts/update-hosts.sh b/.jjconflict-side-1/scripts/update-hosts.sh new file mode 100755 index 0000000000..0de643ec93 --- /dev/null +++ b/.jjconflict-side-1/scripts/update-hosts.sh @@ -0,0 +1,130 @@ +#!/bin/bash + +set -xe +set -o pipefail + +# Colorize terminal +export red='\e[0;31m' +export no_color='\033[0m' + +# Get versions +export DOCKER_VERSION="$(docker --version)" + +# Default +export SCRIPTPATH="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" +export HELM_RELEASE_NAME="dso" +export INTEGRATION_ARG="" +export INTEGRATION_ARGS_UTILS="" +export CI_ARGS="" + + +# Declare script helper +export TEXT_HELPER="\nThis script aims to manage a local kubernetes cluster using Kind also known as Kubernetes in Docker. +Following flags are available: + + -c Command tu run. Multiple commands can be provided as a comma separated list. + Available commands are : + create - Create kind cluster. + clean - Delete images in kind cluster (keep only infra resources and ingress controller). + delete - Delete kind cluster. + build - Build, push and load docker images from compose file into cluster nodes. + load - Load docker images from compose file into cluster nodes. + dev - Run application in development mode. + prod - Run application in production mode. + integ - Run application in integration mode (need to combine with 'dev' or 'prod'). + + -d Domains to add in /etc/hosts for local services resolution. Comma separated list. This will require sudo. + + -f Path to the docker-compose file that will be used with Kind. + + -i Install kind. + + -k Path to the kubeconfig to use. + + -t Tag used to deploy application images. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hc:d:f:ik:t: flag; do + case "${flag}" in + c) + export COMMAND=${OPTARG};; + d) + export DOMAINS=${OPTARG};; + f) + export COMPOSE_FILE=${OPTARG};; + i) + export INSTALL_KIND=true;; + k) + export KUBECONFIG_HOST_PATH=${OPTARG};; + t) + export TAG=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Utils +install_kind() { + printf "\n\n${red}[kind wrapper].${no_color} Install kind...\n\n" + if [ "$(uname)" = "Linux" ]; then + export OS=linux + elif [ "$(uname)" = "Darwin" ]; then + export OS=darwin + else + printf "\n\nNo installation available for your system, plese refer to the installation guide\n\n" + exit 0 + fi + + if [ "$(uname -m)" = "x86_64" ]; then + export ARCH=amd64 + elif [ "$(uname -m)" = "arm64" ] || [ "$(uname -m)" = "aarch64" ]; then + export ARCH=arm64 + fi + + curl -Lo ./kind "https://kind.sigs.k8s.io/dl/v0.20.0/kind-$OS-$ARCH" + chmod +x ./kind + mv ./kind /usr/local/bin/kind + + printf "\n\n$(kind --version) installed\n\n" +} + +if [ "$INSTALL_KIND" = "true" ] && [ -z "$(kind --version)" ]; then + install_kind +fi + + +# Script condition +if [ -z "$(kind --version)" ]; then + echo "\nYou need to install kind to run this script.\n" + print_help + exit 1 +fi + +if [[ "$COMMAND" =~ "build" ]] && [ ! -f "$(readlink -f $COMPOSE_FILE)" ]; then + echo "\nDocker compose file $COMPOSE_FILE does not exist.\n" + print_help + exit 1 +fi + + +# Add local services to /etc/hosts +if [ ! -z "$DOMAINS" ]; then + printf "\n\n${red}[kind wrapper].${no_color} Add services local domains to /etc/hosts\n\n" + + export FORMATED_DOMAINS="$(echo "$DOMAINS" | sed 's/,/\ /g')" + if [ "$(grep -c "$FORMATED_DOMAINS" /etc/hosts)" -ge 1 ]; then + printf "\n\n${red}[kind wrapper].${no_color} Services local domains already added to /etc/hosts\n\n" + else + sudo sh -c "echo $'\n\n# Kind\n127.0.0.1 $FORMATED_DOMAINS' >> /etc/hosts" + + printf "\n\n${red}[kind wrapper].${no_color} Services local domains successfully added to /etc/hosts\n\n" + fi +fi diff --git a/.jjconflict-side-2/.dockerignore b/.jjconflict-side-2/.dockerignore new file mode 100644 index 0000000000..2edecc5c93 --- /dev/null +++ b/.jjconflict-side-2/.dockerignore @@ -0,0 +1,26 @@ +# Npm +node_modules +npm-debug.log + +# Git +.gitkeep + +# Lint +.eslintrc.js +.eslintrc.cjs +.eslintrc.json +.eslintignore +.stylelintrc.js +.stylelintrc.cjs + +# Tests +.turbo/ +coverage/ + +# Docker +**/Dockerfile +.dockerignore +docker/ + +# Env +.env* \ No newline at end of file diff --git a/.jjconflict-side-2/.gitattributes b/.jjconflict-side-2/.gitattributes new file mode 100644 index 0000000000..2e7bbd226d --- /dev/null +++ b/.jjconflict-side-2/.gitattributes @@ -0,0 +1,2 @@ +# Generated API clients — collapse in diffs and exclude from language stats. +plugins/harbor/src/api/Api.ts linguist-generated=true diff --git a/.jjconflict-side-2/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.jjconflict-side-2/.github/ISSUE_TEMPLATE/BUG-REPORT.yml new file mode 100644 index 0000000000..f87368e72d --- /dev/null +++ b/.jjconflict-side-2/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -0,0 +1,98 @@ +name: 🐛 Rapport de bug +description: Créer un ticket pour un bug. +title: "🐛 [BUG] - " +labels: [bug] +body: + - type: textarea + id: description + attributes: + label: Description + description: Veuillez saisir une description explicite de votre problème. + placeholder: Description courte et explicite de votre incident... + validations: + required: true + + - type: textarea + id: reprod + attributes: + label: Etapes de reproduction + description: Veuillez saisir une description explicite pour reproduire votre problème. + value: | + 1. Aller à '...' + 2. Cliquer sur '....' + 3. Scroller jusqu'à '....' + 4. Voir l'erreur + render: bash + validations: + required: true + + - type: textarea + id: screenshot + attributes: + label: "Captures d'écran" + description: Si nécessaire, ajoutez des captures d'écran pour expliquer votre problème. + value: | + ![DESCRIPTION](LINK.png) + render: bash + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Logs + description: Veuillez copier/coller tous les logs pertinents. Ces données seront automatiquement formatées en code. + validations: + required: false + + - type: dropdown + id: browsers + attributes: + label: Navigateurs + description: Quels sont les navigateurs sur lesquels vous constatez le problème ? + multiple: true + options: + - Firefox + - Chrome + - Chromium + - Brave + - Safari + - Microsoft Edge + - Opera + validations: + required: false + + - type: dropdown + id: os + attributes: + label: OS + description: Quels sont les systèmes d'exploitation sur lesquels vous constatez le problème ? + multiple: true + options: + - Windows + - Linux + - Mac + validations: + required: false + + - type: input + id: affected-versions + attributes: + label: Version de la console impactée + description: Quelle version de la console est impactée par le bug ? + placeholder: v1.2.3 + validations: + required: false + + - type: checkboxes + id: dod + attributes: + label: Définition du fini + description: Cochez les différents élément de la Definition Of Done. + options: + - label: Le correctif est terminé + required: false + - label: Les tests liés à ce correctif ont été ajoutés + required: false + - label: La communication avec les autres équipes impliquées par ce correctif a été faite + required: false diff --git a/.jjconflict-side-2/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.jjconflict-side-2/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml new file mode 100644 index 0000000000..2d55aae3ad --- /dev/null +++ b/.jjconflict-side-2/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml @@ -0,0 +1,64 @@ +name: 💡 Demande de fonctionnalité +description: Créer un ticket pour demander une nouvelle fonctionnalité. +title: "💡 [REQUEST] - <title>" +labels: [enhancement] +body: + - type: textarea + id: summary + attributes: + label: Description + description: Veuillez saisir une brève explication de la fonctionnalité. + placeholder: Décrivez en quelques lignes la fonctionnalité désirée. + validations: + required: true + + - type: textarea + id: implementation_pr + attributes: + label: PRs liées + description: Pull request qui répond à la fonctionnalité. + placeholder: "#Pull Request ID" + validations: + required: false + + - type: textarea + id: reference_issues + attributes: + label: Issues liées + description: Issues associée à cette demande de fonctionnalité. + placeholder: "#Issues IDs" + validations: + required: false + + - type: textarea + id: basic_example + attributes: + label: Exemples simples + description: Indiquez ici quelques exemples de base de votre fonctionnalité. + placeholder: Quelques mots sur votre demande de fonctionnalité. + validations: + required: false + + - type: textarea + id: tech + attributes: + label: Spécifications techniques + description: Spcifiez les détails techniques liés à votre demande. + placeholder: Une description technique de la fonctionnalité. + validations: + required: false + + - type: checkboxes + id: dod + attributes: + label: Définition du fini + description: Cochez les différents élément de la Definition Of Done. + options: + - label: La fonctionnalité est terminée + required: false + - label: Les tests liés à cette fonctionnalité ont été ajoutés + required: false + - label: La documentation liée à cette fonctionnalité a été ajoutée (cf. https://github.com/cloud-pi-native/documentation) + required: false + - label: La communication avec les autres équipes impliquées par cette fonctionnalité a été faite + required: false diff --git a/.jjconflict-side-2/.github/ISSUE_TEMPLATE/config.yml b/.jjconflict-side-2/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..b565340e24 --- /dev/null +++ b/.jjconflict-side-2/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: 📚 Documentation + url: https://cloud-pi-native.fr + about: Cet outil de suivi des problèmes n'est pas destiné aux problèmes de documentation. Veuillez ouvrir un ticket sur le dépôt de documentation. + - name: 🚀 Déploiement + url: https://github.com/cloud-pi-native/socle + about: Cet outil de suivi des problèmes n'est pas destiné aux problèmes de déploiement de la plateforme. Veuillez ouvrir un ticket sur le dépôt de socle. diff --git a/.jjconflict-side-2/.github/PULL_REQUEST_TEMPLATE.md b/.jjconflict-side-2/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000..1730631ac9 --- /dev/null +++ b/.jjconflict-side-2/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +## Issues liées + +Issues numéro: + +--------- + +<!-- Ne soumettez pas de mises à jour des dépendances à moins qu'elles ne corrigent un problème. --> + +<!-- Veuillez essayer de limiter votre Pull Request à un seul type (correction de bogue, fonctionnalité, etc.). Soumettez plusieurs PRs si nécessaire. --> + +## Quel est le comportement actuel ? +<!-- Veuillez décrire le comportement actuel que vous modifiez. --> + +## Quel est le nouveau comportement ? +<!-- Veuillez décrire le comportement ou les changements apportés par cette PR. --> + +## Cette PR introduit-elle un breaking change ? +<!-- Si un breaking change est introduit, veuillez décrire ci-dessous l'impact et la procédure de migration pour les applications existantes. --> + +## Autres informations +<!-- Toute autre information importante pour la PR, telle que des captures d'écran montrant l'aspect du composant avant et après la modification. --> diff --git a/.jjconflict-side-2/.github/SECURITY.md b/.jjconflict-side-2/.github/SECURITY.md new file mode 100644 index 0000000000..9aaaf8698f --- /dev/null +++ b/.jjconflict-side-2/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +Contact: cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-side-2/.github/labeler/build.yml b/.jjconflict-side-2/.github/labeler/build.yml new file mode 100644 index 0000000000..68a15e21bc --- /dev/null +++ b/.jjconflict-side-2/.github/labeler/build.yml @@ -0,0 +1,3 @@ +built: + - changed-files: + - any-glob-to-any-file: "**" diff --git a/.jjconflict-side-2/.github/workflows/job-build.yml b/.jjconflict-side-2/.github/workflows/job-build.yml new file mode 100644 index 0000000000..f1a1f96e51 --- /dev/null +++ b/.jjconflict-side-2/.github/workflows/job-build.yml @@ -0,0 +1,256 @@ +name: Build + +permissions: + contents: read + packages: write + +on: + workflow_call: + inputs: + REGISTRY: + required: true + type: string + NAMESPACE: + required: true + type: string + BUILD_AMD64: + required: true + type: boolean + BUILD_ARM64: + required: true + type: boolean + TAG: + required: true + type: string + MAJOR_TAG: + required: false + type: string + MINOR_TAG: + required: false + type: string + PATCH_TAG: + required: false + type: string + USE_QEMU: + required: true + type: boolean + PR_NUMBER: + required: false + type: string + secrets: + ARGOCD_TOKEN: + required: false + workflow_dispatch: + inputs: + REGISTRY: + description: Target registry to push images + required: true + type: string + default: ghcr.io + NAMESPACE: + description: Target namespace to the given registry + required: true + type: string + default: cloud-pi-native/console + BUILD_AMD64: + description: Build for amd64 + required: true + type: boolean + default: true + BUILD_ARM64: + description: Build for arm64 + required: true + type: boolean + default: true + USE_QEMU: + description: Use QEMU emulator for arm64 + required: true + type: boolean + default: false + PR_NUMBER: + description: ID number of the pull request to use for deploy preview app + required: false + type: string + +jobs: + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + lower-branch: ${{ steps.lower-branch.outputs.LOWER_BRANCH }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + - name: Get lowercase branch name + id: lower-branch + run: | + echo "LOWER_BRANCH=$(echo '${{ github.head_ref || github.ref_name }}' | sed 's/\//-/g' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT + + build: + name: Build application + runs-on: ${{ matrix.runners }} + needs: + - matrix + strategy: + fail-fast: false + matrix: + runners: ${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && !inputs.USE_QEMU && fromJson('["ubuntu-latest", "ARM64"]')) || (inputs.BUILD_ARM64 && !inputs.USE_QEMU && fromJson('["ARM64"]')) || fromJson('["ubuntu-latest"]') }} + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Get short SHA + id: short-sha + run: | + echo "SHORT_SHA=sha-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Cache Docker layers + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: /tmp/.buildx-cache + key: ${{ matrix.images.name == 'server' && format('buildx-{0}-{1}-{2}-{3}', runner.os, runner.arch, matrix.images.name, hashFiles(format('apps/{0}/**', matrix.images.name),'packages/shared/**','plugins/**')) || format('buildx-{0}-{1}-{2}-{3}', runner.os, runner.arch, matrix.images.name, hashFiles(format('apps/{0}/**', matrix.images.name),'packages/shared/**')) }} + restore-keys: | + buildx-${{ runner.os }}-${{ runner.arch }}-${{ matrix.images.name }}- + + - name: Set up QEMU (for multi platform build) + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + if: ${{ inputs.USE_QEMU }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ inputs.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + logout: true + + - name: Build and push docker image + id: build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: ${{ matrix.images.build.context }} + file: ${{ matrix.images.build.dockerfile }} + provenance: false + platforms: ${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && inputs.USE_QEMU && 'linux/amd64,linux/arm64') || (inputs.BUILD_ARM64 && 'linux/arm64') || (inputs.BUILD_AMD64 && 'linux/amd64') }} + outputs: type=image,name=${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }},push-by-digest=true,name-canonical=true,push=true + build-args: | + APP_VERSION=${{ inputs.TAG || steps.short-sha.outputs.SHORT_SHA }} + # cache-from: type=gha + # cache-to: type=gha,mode=max + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new + + # Necessary to avoid forever growing cache + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + + - name: Export digest + run: | + mkdir -p /tmp/digests/${{ matrix.images.name }} + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${{ matrix.images.name }}/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: digests-${{ matrix.images.name }}.${{ (inputs.BUILD_AMD64 && inputs.BUILD_ARM64 && inputs.USE_QEMU && 'multiarch') || (inputs.BUILD_ARM64 && 'arm64') || (inputs.BUILD_AMD64 && 'amd64') }}.digest + path: /tmp/digests/${{ matrix.images.name }}/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge digest + runs-on: ubuntu-latest + needs: + - matrix + - build + strategy: + fail-fast: false + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Download artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: digests-${{ matrix.images.name }}.*.digest + path: /tmp/digests/${{ matrix.images.name }} + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Docker meta + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + with: + images: ${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }} + tags: | + type=raw,value=${{ needs.matrix.outputs.lower-branch }},enable=${{ github.head_ref != 'main' }} + type=raw,value=${{ inputs.TAG }},enable=${{ inputs.TAG != '' }} + type=raw,value=${{ inputs.MAJOR_TAG }}.${{ inputs.MINOR_TAG }},enable=${{ inputs.MAJOR_TAG != '' && inputs.MINOR_TAG != '' }} + type=raw,value=${{ inputs.MAJOR_TAG }},enable=${{ inputs.MAJOR_TAG != '' }} + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ inputs.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + logout: true + + - name: Create manifest list and push + working-directory: /tmp/digests/${{ matrix.images.name }} + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}@sha256:%s ' *) + + - name: Inspect image + run: | + docker buildx imagetools inspect ${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ steps.meta.outputs.version }} + + preview: + name: Redeploy preview app + if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'preview') && inputs.PR_NUMBER != '' }} + runs-on: ubuntu-latest + needs: + - merge + steps: + - name: Replace template fields + id: feed-template + run: | + echo "ARGOCD_APP_NAME=$(echo '${{ vars.ARGOCD_APP_NAME_TEMPLATE }}' | sed 's|<pr_number>|${{ inputs.PR_NUMBER }}|g')" >> $GITHUB_OUTPUT + echo "ARGOCD_SYNC_PAYLOAD=$(echo '${{ vars.ARGOCD_SYNC_PAYLOAD_TEMPLATE }}' | sed 's|<pr_number>|${{ inputs.PR_NUMBER }}|g')" >> $GITHUB_OUTPUT + + - name: Deploy new image + run: | + set -e + + RESPONSE_CODE=$(curl -s -w "%{http_code}" -o >(RESPONSE_BODY=$(cat)) -X POST ${{ vars.ARGOCD_URL }}/api/v1/applications/${{ steps.feed-template.outputs.ARGOCD_APP_NAME }}/sync \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${{ secrets.ARGOCD_TOKEN }}" \ + -d '${{ steps.feed-template.outputs.ARGOCD_SYNC_PAYLOAD }}') + + echo "HTTP response code: $RESPONSE_CODE" + + if [ "$RESPONSE_CODE" -ne 200 ]; then + echo "Error: Non-200 HTTP response detected." + echo "Response body: $RESPONSE_BODY" + exit 1 + fi + continue-on-error: true diff --git a/.jjconflict-side-2/.github/workflows/job-label.yml b/.jjconflict-side-2/.github/workflows/job-label.yml new file mode 100644 index 0000000000..1fd944015f --- /dev/null +++ b/.jjconflict-side-2/.github/workflows/job-label.yml @@ -0,0 +1,25 @@ +name: Add labels on PR + +permissions: + contents: read + pull-requests: write + +on: + workflow_call: + inputs: + CONF_PATH: + required: true + type: string + +jobs: + label: + name: Add labels on PR + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Add pull request label + uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 + with: + configuration-path: ${{ inputs.CONF_PATH }} diff --git a/.jjconflict-side-2/.github/workflows/job-lint.yml b/.jjconflict-side-2/.github/workflows/job-lint.yml new file mode 100644 index 0000000000..bbbae077d6 --- /dev/null +++ b/.jjconflict-side-2/.github/workflows/job-lint.yml @@ -0,0 +1,79 @@ +name: Lint + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: false + type: string + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + +jobs: + lint: + name: Lint codebase + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check lint error + run: | + ./ci/scripts/run-tests.sh -l + + - name: Validate nginx-strangler config + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends nginx gettext-base + # Préparer un répertoire de test isolé avec la config substituée + mkdir -p /tmp/nginx-test/conf.d /tmp/nginx-test/logs + envsubst '${LEGACY_UPSTREAM} ${NESTJS_UPSTREAM}' \ + < apps/nginx-strangler/conf.d/routing.conf \ + > /tmp/nginx-test/conf.d/routing.conf + # Adapter nginx.conf pour l'environnement CI (user www-data, paths accessibles) + sed \ + -e 's|^user .*|user www-data;|' \ + -e 's|pid .*|pid /tmp/nginx-test/nginx.pid;|' \ + -e 's|error_log .*|error_log /tmp/nginx-test/logs/error.log notice;|' \ + -e 's|access_log .*|access_log /tmp/nginx-test/logs/access.log main;|' \ + -e 's|include /etc/nginx/conf\.d/\*\.conf|include /tmp/nginx-test/conf.d/*.conf|' \ + apps/nginx-strangler/nginx.conf > /tmp/nginx-test/nginx.conf + nginx -t -c /tmp/nginx-test/nginx.conf + env: + LEGACY_UPSTREAM: "127.0.0.1:8080" + NESTJS_UPSTREAM: "127.0.0.1:3001" diff --git a/.jjconflict-side-2/.github/workflows/job-npm.yml b/.jjconflict-side-2/.github/workflows/job-npm.yml new file mode 100644 index 0000000000..51e4f3eedc --- /dev/null +++ b/.jjconflict-side-2/.github/workflows/job-npm.yml @@ -0,0 +1,247 @@ +name: Publish npm + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + PUBLISH_APPS: + required: true + type: boolean + PUBLISH_PACKAGES: + required: true + type: boolean + PUBLISH_PLUGINS: + required: true + type: boolean + CHECK_CHANGES: + required: true + type: boolean + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used + required: true + type: string + PUBLISH_APPS: + description: Publish apps modules + required: true + type: boolean + default: false + PUBLISH_PACKAGES: + description: Publish packages modules + required: true + type: boolean + default: false + PUBLISH_PLUGINS: + description: Publish plugins modules + required: true + type: boolean + default: true + CHECK_CHANGES: + required: true + type: boolean + default: true + +jobs: + path-filter: + if: ${{ inputs.CHECK_CHANGES }} + runs-on: ubuntu-latest + outputs: + packages: ${{ steps.filter.outputs.changes }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + eslint-config: + - 'packages/eslintconfig/src/**' + shared: + - 'packages/shared/src/**' + hooks: + - 'packages/hooks/src/**' + ts-config: + - 'packages/tsconfig/**' + logger: + - 'packages/logger/**' + argocd: + - 'plugins/argocd/src/**' + gitlab: + - 'plugins/gitlab/src/**' + harbor: + - 'plugins/harbor/src/**' + keycloak: + - 'plugins/keycloak/src/**' + nexus: + - 'plugins/nexus/src/**' + sonarqube: + - 'plugins/sonarqube/src/**' + vault: + - 'plugins/vault/src/**' + + check-update: + name: Check for package version update + if: ${{ fromJson(needs.path-filter.outputs.packages).include[0] }} # skip if the matrix is empty! + runs-on: ubuntu-latest + needs: + - path-filter + strategy: + matrix: + packages: ${{ fromJSON(needs.path-filter.outputs.packages) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Check changes + id: check + run: | + PACKAGE_NAME=$(jq -cr --arg p "${{ matrix.packages }}" '[.packages[], .plugins[]][] | select(.name | test($p)?) | .name' < ./ci/matrix-npm.json) + PACKAGE_PATH=$(jq -cr --arg p "${{ matrix.packages }}" '[.packages[], .plugins[]][] | select(.name | test($p)?) | .path' < ./ci/matrix-npm.json) + PACKAGE_VERSION=$(jq -cr '.version' < ${PACKAGE_PATH}/package.json) + if [ "$(curl -s -o /dev/null -I -w %{http_code} https://registry.npmjs.org/${PACKAGE_NAME})" = 404 ]; then + REMOTE=false + else + REMOTE=$(pnpm view ${PACKAGE_NAME} --json | jq --arg v "$PACKAGE_VERSION" 'any(.versions[] == $v; .)') + fi + if [ "$REMOTE" = "true" ]; then + echo "WARNING=true" >> $GITHUB_OUTPUT + echo "PACKAGE_NAME=$PACKAGE_NAME" >> $GITHUB_OUTPUT + echo "PACKAGE_PATH=$PACKAGE_PATH" >> $GITHUB_OUTPUT + echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_OUTPUT + else + echo "WARNING=false" >> $GITHUB_OUTPUT + fi + + matrix: + name: Generate publish matrix + needs: + - path-filter + if: ${{ needs.path-filter.outputs.packages != '' && (inputs.PUBLISH_APPS || inputs.PUBLISH_PACKAGES || inputs.PUBLISH_PLUGINS) }} + runs-on: ubuntu-latest + outputs: + publish-matrix: ${{ steps.packages-matrix.outputs.PUBLISH_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: packages-matrix + run: | + PUBLISH_MATRIX="[]" + APPS_MATRIX="$(jq -c '.apps' < ./ci/matrix-npm.json)" + PACKAGES_MATRIX="$(jq -c '.packages' < ./ci/matrix-npm.json)" + PLUGINS_MATRIX="$(jq -c '.plugins' < ./ci/matrix-npm.json)" + if [ "${{ inputs.PUBLISH_APPS }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$APPS_MATRIX" '$acc + $cur')" + fi + if [ "${{ inputs.PUBLISH_PACKAGES }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$PACKAGES_MATRIX" '$acc + $cur')" + fi + if [ "${{ inputs.PUBLISH_PLUGINS }}" = "true" ]; then + PUBLISH_MATRIX="$(jq -n -c --argjson acc "$PUBLISH_MATRIX" --argjson cur "$PLUGINS_MATRIX" '$acc + $cur')" + fi + echo "PUBLISH_MATRIX=$PUBLISH_MATRIX" >> $GITHUB_OUTPUT + + publish-npm: + name: Publish on npm + if: ${{ needs.path-filter.outputs.packages != '' && (inputs.PUBLISH_APPS || inputs.PUBLISH_PACKAGES || inputs.PUBLISH_PLUGINS) }} + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + needs: + - path-filter + - matrix + strategy: + # Allow jobs to be processed independently of their siblings status + fail-fast: false + matrix: + modules: ${{ fromJSON(needs.matrix.outputs.publish-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.ref }} + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Check for new package version + id: check-version + run: | + PACKAGE_VERSION=$(jq -cr '.version' < ${{ matrix.modules.path }}/package.json) + if [ "$(curl -s -o /dev/null -I -w %{http_code} https://registry.npmjs.org/${{ matrix.modules.name }})" = 404 ]; then + REMOTE=false + else + REMOTE=$(pnpm view ${{ matrix.modules.name }} --json | jq --arg v "$PACKAGE_VERSION" 'any(.versions[] == $v; .)') + fi + if [ "$REMOTE" = "true" ]; then + echo "Package ${{ matrix.modules.name }} - version $PACKAGE_VERSION already exists" + else + echo "Package ${{ matrix.modules.name }} - version $PACKAGE_VERSION does not exist" + fi + echo "REMOTE=$REMOTE" >> $GITHUB_OUTPUT + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + with: + node-version: "${{ inputs.NODE_VERSION }}" + registry-url: "https://registry.npmjs.org" + + - name: Get pnpm store directory + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install base dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: pnpm install --frozen-lockfile + + - name: Build internal dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter "./packages/**" run build && pnpm --filter "./plugins/**" run build + + - name: Update PNPM internal links with built internal dependencies + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm install --frozen-lockfile + + - name: Build package + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter ${{ matrix.modules.name }} run build + + - name: Publish package + if: ${{ steps.check-version.outputs.REMOTE == 'false' }} + run: | + pnpm --filter ${{ matrix.modules.name }} publish --provenance --no-git-checks --report-summary diff --git a/.jjconflict-side-2/.github/workflows/job-playwright.yml b/.jjconflict-side-2/.github/workflows/job-playwright.yml new file mode 100644 index 0000000000..ec0a3fe695 --- /dev/null +++ b/.jjconflict-side-2/.github/workflows/job-playwright.yml @@ -0,0 +1,114 @@ +name: Playwright Tests + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + TAG: + required: true + type: string + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + TAG: + description: Container images version to use for console client and server/server-nestjs + required: true + type: string + default: latest +jobs: + test: + timeout-minutes: 60 + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache Node.js files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install Node.js dependencies + run: pnpm install --frozen-lockfile + + - name: Build internal dependencies + run: pnpm --filter "./packages/**" build + + - name: Get used Playwright version + id: playwright-version + run: echo "PLAYWRIGHT_VERSION=$(pnpm --dir playwright exec playwright -V | awk '{ print $2 }')" >> $GITHUB_ENV + + - name: Restore cache - Playwright browser binaries + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + id: playwright-cache + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} + + - name: Install Playwright system dependencies + run: pnpm --dir playwright exec playwright install --with-deps + + - name: Save cache - Playwright browser binaries + if: always() && steps.playwright-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }} + + - name: Initialize application environment for tests + run: | + ./ci/scripts/init-env.sh + docker pull ghcr.io/cloud-pi-native/console/server:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/server:${{ inputs.TAG }} dso-console/server:ci + docker pull ghcr.io/cloud-pi-native/console/nginx-strangler:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/nginx-strangler:${{ inputs.TAG }} dso-console/nginx-strangler:ci + docker pull ghcr.io/cloud-pi-native/console/server-nestjs:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/server-nestjs:${{ inputs.TAG }} dso-console/server-nestjs:ci + docker pull ghcr.io/cloud-pi-native/console/client:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/client:${{ inputs.TAG }} dso-console/client:ci + docker pull ghcr.io/cloud-pi-native/console/opencds-mockoon:${{ inputs.TAG }} + docker tag ghcr.io/cloud-pi-native/console/opencds-mockoon:${{ inputs.TAG }} dso-console/opencds-mockoon:ci + docker compose -f ./docker/docker-compose.ci.yml up --no-build -d --remove-orphans + + - name: Run Playwright tests + run: pnpm --dir playwright exec playwright test --grep @e2e + + - name: Clean up docker resources (containers, volumes) + run: docker compose -f ./docker/docker-compose.ci.yml down -v --remove-orphans + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright/playwright-report/ + retention-days: 30 diff --git a/.jjconflict-side-2/.github/workflows/job-release-please.yml b/.jjconflict-side-2/.github/workflows/job-release-please.yml new file mode 100644 index 0000000000..b4789c2cbb --- /dev/null +++ b/.jjconflict-side-2/.github/workflows/job-release-please.yml @@ -0,0 +1,84 @@ +name: Release + +permissions: + contents: write + pull-requests: write + +on: + workflow_call: + outputs: + release-created: + description: Has the releease been created + value: ${{ jobs.release.outputs.release-created }} + major-tag: + description: Major version tag + value: ${{ jobs.release.outputs.major-tag }} + minor-tag: + description: Minor version tag + value: ${{ jobs.release.outputs.minor-tag }} + patch-tag: + description: Patch version tag + value: ${{ jobs.release.outputs.patch-tag }} + +jobs: + release: + name: Create new release + runs-on: ubuntu-latest + outputs: + release-created: ${{ steps.release.outputs.release_created }} + major-tag: ${{ steps.release.outputs.major }} + minor-tag: ${{ steps.release.outputs.minor }} + patch-tag: ${{ steps.release.outputs.patch }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Update release-please config for hotfix branches + if: startsWith(github.ref_name, 'hotfix/') + run: | + # Update versioning to always-bump-patch for hotfix branches + jq '.versioning = "always-bump-patch"' release-please-config.json > tmp.json && mv tmp.json release-please-config.json + echo "Updated release-please-config.json versioning to always-bump-patch" + shell: bash + + - name: Create merge request for new release + uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4.4.0 + id: release + with: + config-file: release-please-config.json + target-branch: ${{ github.ref_name }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Edit packages versions + if: ${{ ! steps.release.outputs.release_created && steps.release.outputs.pr }} + run: | + export BRANCH=release-please--branches--main--components--console + git fetch + git checkout "$BRANCH" + VERSION=$(jq -r .version package.json) + for pkg in $(jq -r '.apps | map(.path + "/package.json") | .[]' < ./ci/matrix-npm.json); do + yq e -i ".version |= \"$VERSION\"" $pkg + yq e -i ".publishConfig.tag |= \"latest\"" $pkg + done + git add . + # Emulate the author of the existing commit (the bot). + git config user.email "$(git log -n 1 --format='%ae')" + git config user.name "$(git log -n 1 --format='%an')" + # Update the PR. + git commit --amend --no-edit + git push -f + + - name: Tag major and minor versions + if: ${{ steps.release.outputs.release_created }} + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git remote add gh-token "https://${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" + git tag -d v${{ steps.release.outputs.major }} || true + git tag -d v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} || true + git push origin :v${{ steps.release.outputs.major }} || true + git push origin :v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} || true + git tag -a v${{ steps.release.outputs.major }} -m "Release v${{ steps.release.outputs.major }}" + git tag -a v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} -m "Release v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}" + git push origin v${{ steps.release.outputs.major }} + git push origin v${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }} diff --git a/.jjconflict-side-2/.github/workflows/job-scan.yml b/.jjconflict-side-2/.github/workflows/job-scan.yml new file mode 100644 index 0000000000..3efab69fc5 --- /dev/null +++ b/.jjconflict-side-2/.github/workflows/job-scan.yml @@ -0,0 +1,148 @@ +name: Vulnerability scan + +permissions: + contents: read + pull-requests: write + +on: + workflow_call: + inputs: + REGISTRY: + required: true + type: string + NAMESPACE: + required: true + type: string + TAG: + required: true + type: string + workflow_dispatch: + inputs: + REGISTRY: + description: Target registry to push images + required: true + type: string + default: ghcr.io + NAMESPACE: + description: Target namespace to the given registry + required: true + type: string + default: cloud-pi-native/console + TAG: + description: Tag used to scan images + required: true + type: string + default: latest + +jobs: + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + images-scan: + name: Scan images vulnerabilities + runs-on: ubuntu-latest + needs: + - matrix + strategy: + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run Trivy vulnerability scanner on images + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: "${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ inputs.TAG }}" + format: template + template: "@/contrib/sarif.tpl" + vuln-type: "os,library" + ignore-unfixed: true + output: trivy-results.sarif + github-pat: ${{ secrets.GITHUB_TOKEN }} + cache: "false" + continue-on-error: true + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: trivy-results.sarif + continue-on-error: true + + - name: Warn about error + if: failure() + run: | + echo ":warning: The Trivy scan report for image `${{ inputs.REGISTRY }}/${{ inputs.NAMESPACE }}/${{ matrix.images.name }}:${{ inputs.TAG }}` was not successfully completed and could not be uploaded to the Github Security Panel." >> $GITHUB_STEP_SUMMARY + + config-scan: + name: Scan config files vulnerabilities + runs-on: ubuntu-latest + needs: + - matrix + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run Trivy vulnerability scanner on config files + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + scan-ref: . + scan-type: config + format: template + template: "@/contrib/sarif.tpl" + skip-dirs: "**/node_modules,ci" + ignore-unfixed: true + output: trivy-results.sarif + github-pat: ${{ secrets.GITHUB_TOKEN }} + cache: "false" + continue-on-error: true + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 + with: + sarif_file: trivy-results.sarif + continue-on-error: true + + - name: Warn about error + if: failure() + run: | + echo ":warning: The Trivy scan report for configuration was not successfully completed and could not be uploaded to the Github Security Panel." >> $GITHUB_STEP_SUMMARY + + scan-notif: + name: Notify users + runs-on: ubuntu-latest + needs: + - matrix + - images-scan + - config-scan + if: ${{ github.event_name == 'pull_request' }} + env: + MERGE_REQUEST_ID: ${{ github.event.pull_request.number }} + COMMENT: | + 🤖 Hey ! + + The security scan report for the current pull request is available [here](https://github.com/${{ github.repository }}/security/code-scanning?query=is%3Aopen+branch%3Amain+pr%3A${{ github.event.pull_request.number || github.event.number }}). + steps: + - name: Add comment on Merge Request + id: add-comment-on-merge-request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.MERGE_REQUEST_ID, + body: process.env.COMMENT, + }); diff --git a/.jjconflict-side-2/.github/workflows/job-tests-unit.yml b/.jjconflict-side-2/.github/workflows/job-tests-unit.yml new file mode 100644 index 0000000000..993ff302cf --- /dev/null +++ b/.jjconflict-side-2/.github/workflows/job-tests-unit.yml @@ -0,0 +1,177 @@ +name: Tests - unit + +permissions: + contents: read + +on: + workflow_call: + inputs: + NODE_VERSION: + required: true + type: string + secrets: + SONAR_HOST_URL: + required: false + SONAR_TOKEN: + required: false + SONAR_PROJECT_KEY: + required: false + workflow_dispatch: + inputs: + NODE_VERSION: + description: Nodejs version used to run tests + required: true + type: string + +jobs: + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm -r install --frozen-lockfile + + - name: Build internal dependencies (packages) + run: pnpm --filter "./packages/**" build + + - name: Build internal plugins + run: pnpm --filter "./plugins/**" build + + - name: Run unit tests + run: | + ./ci/scripts/init-env.sh + ./ci/scripts/run-tests.sh -u + + - name: Upload vitest coverage artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: unit-tests-coverage + path: | + ./apps/**/coverage/lcov.info + ./packages/**/coverage/lcov.info + retention-days: 1 + + check-secrets: + name: Check if Sonar secrets are set + runs-on: ubuntu-latest + needs: + - unit-tests + outputs: + run-scan: ${{ steps.check-secrets.outputs.run-scan }} + steps: + - name: Check for sonar secrets + id: check-secrets + run: | + if [ "${{ secrets.SONAR_HOST_URL }}" != "" ] && [ "${{ secrets.SONAR_TOKEN }}" != "" ] && [ "${{ secrets.SONAR_PROJECT_KEY }}" != "" ]; then + echo "run-scan=true" >> $GITHUB_OUTPUT + else + echo "run-scan=false" >> $GITHUB_OUTPUT + fi + + code-scan: + name: Run code quality analysis + runs-on: ubuntu-latest + needs: + - unit-tests + - check-secrets + if: ${{ needs.check-secrets.outputs.run-scan == 'true' }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Download artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: unit-tests-coverage + path: ./coverage + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + id: pnpm-install + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: "${{ inputs.NODE_VERSION }}" + + - name: Get pnpm store directory + id: pnpm-store + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Cache node files + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: node-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + node-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set sonarqube args + id: sonar-args + run: | + echo "SONAR_ARGS_PR=-Dsonar.pullrequest.provider=github -Dsonar.pullrequest.key=${{ github.event.number }} -Dsonar.pullrequest.branch=${{ github.head_ref }} -Dsonar.pullrequest.base=${{ github.base_ref }} -Dsonar.pullrequest.github.repository=${{ github.repository }}" >> $GITHUB_OUTPUT + echo "SONAR_ARGS_BRANCH=-Dsonar.branch.name=${{ github.ref_name }}" >> $GITHUB_OUTPUT + + - name: SonarQube Scan + uses: sonarsource/sonarqube-scan-action@299e4b793aaa83bf2aba7c9c14bedbb485688ec4 # v7.1.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + args: > + -Dsonar.projectKey=${{ secrets.SONAR_PROJECT_KEY }} + -Dsonar.sources=apps,packages + -Dsonar.javascript.node.maxspace=4096 + -Dsonar.javascript.lcov.reportPaths=coverage/apps/server/coverage/lcov.info,coverage/apps/client/coverage/lcov.info,coverage/packages/shared/coverage/lcov.info + -Dsonar.coverage.exclusions=**/*.spec.js,**/*.spec.ts,**/*.vue,**/assets/**,**/packages/test-utils/**,apps/server/src/plugins/** + -Dsonar.cpd.exclusions=**/*.spec.js,**/*.spec.ts + -Dsonar.scm.provider=git + ${{ github.event_name == 'pull_request' && steps.sonar-args.outputs.SONAR_ARGS_PR || steps.sonar-args.outputs.SONAR_ARGS_BRANCH }} + continue-on-error: true + + - name: SonarQube Quality Gate check + id: sonarqube-quality-gate-check + uses: sonarsource/sonarqube-quality-gate-action@cf038b0e0cdecfa9e56c198bbb7d21d751d62c3b # v1.2.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + timeout-minutes: 5 + continue-on-error: true diff --git a/.jjconflict-side-2/.github/workflows/workflow-clean-cache.yml b/.jjconflict-side-2/.github/workflows/workflow-clean-cache.yml new file mode 100644 index 0000000000..d1e5b60e95 --- /dev/null +++ b/.jjconflict-side-2/.github/workflows/workflow-clean-cache.yml @@ -0,0 +1,94 @@ +name: Clean cache + +permissions: + contents: read + # For cache cleaning + actions: write + # For artifacts deletion + packages: write + +on: + pull_request: + types: + - closed + workflow_dispatch: + inputs: + PR_NUMBER: + description: Related pull request Id + required: false + type: number + BRANCH_NAME: + description: Related branch name + required: false + type: string + +jobs: + cleanup-cache: + name: Delete github cache + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Clean cache for closed branch + run: | + REPO=${{ github.repository }} + if [ -n "${{ inputs.BRANCH_NAME }}" ]; then + BRANCH="${{ inputs.BRANCH_NAME }}" + else + BRANCH="refs/pull/${{ github.event.pull_request.number || inputs.PR_NUMBER }}/merge" + fi + + echo "Fetching list of cache key" + cacheKeysForPR=$(gh cache list -R $REPO --ref $BRANCH -L 100 | cut -f 1 ) + + ## Setting this to not fail the workflow while deleting cache keys. + set +e + echo "Deleting caches..." + for cacheKey in $cacheKeysForPR; do + gh cache delete $cacheKey -R $REPO + done + echo "Done" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + matrix: + name: Generate matrix + runs-on: ubuntu-latest + outputs: + build-matrix: ${{ steps.build-matrix.outputs.BUILD_MATRIX }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Generate matrix + id: build-matrix + run: | + echo "BUILD_MATRIX=$(jq -c . < ./ci/matrix-docker.json)" >> $GITHUB_OUTPUT + + cleanup-image: + name: Delete image from ghcr.io + runs-on: ubuntu-latest + needs: + - matrix + strategy: + fail-fast: false + matrix: + images: ${{ fromJSON(needs.matrix.outputs.build-matrix) }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Get repository owner and name + id: image-infos + run: | + echo "ORG_NAME=$(echo '${{ github.repository }}' | cut -d "/" -f 1)" >> $GITHUB_OUTPUT + echo "REPO_NAME=$(echo '${{ github.repository }}' | cut -d "/" -f 2)" >> $GITHUB_OUTPUT + + - name: Delete ${{ matrix.images.name }} image + run: | + ./ci/scripts/delete-image.sh \ + -o "${{ steps.image-infos.outputs.ORG_NAME }}" \ + -i "${{ steps.image-infos.outputs.REPO_NAME }}/${{ matrix.images.name }}" \ + -t "pr-${{ github.event.pull_request.number || github.event.number }}" \ + -g "${{ secrets.GITHUB_TOKEN }}" diff --git a/.jjconflict-side-2/.github/workflows/workflow-continuous-integration.yml b/.jjconflict-side-2/.github/workflows/workflow-continuous-integration.yml new file mode 100644 index 0000000000..1553fddc93 --- /dev/null +++ b/.jjconflict-side-2/.github/workflows/workflow-continuous-integration.yml @@ -0,0 +1,150 @@ +name: Continuous Integration + +permissions: + contents: read + # Needed for build job + packages: write + # Needed for build-label and scan-vuln jobs + pull-requests: write + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + branches: + - "**" + workflow_dispatch: + +env: + NODE_VERSION: 24.13.1 + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: false + USE_QEMU: false + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ steps.filter.outputs.apps }} + packages: ${{ steps.filter.outputs.packages }} + e2e: ${{ steps.filter.outputs.e2e }} + ci: ${{ steps.filter.outputs.ci }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + ci: + - '.github/workflows/**' + e2e: + - 'playwright/**' + + expose-vars: + runs-on: ubuntu-latest + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + lint: + uses: ./.github/workflows/job-lint.yml + needs: + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + + unit-tests: + uses: ./.github/workflows/job-tests-unit.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' }} + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + secrets: + SONAR_HOST_URL: "${{ secrets.SONAR_HOST_URL }}" + SONAR_TOKEN: "${{ secrets.SONAR_TOKEN }}" + SONAR_PROJECT_KEY: "${{ secrets.SONAR_PROJECT_KEY }}" + + build: + uses: ./.github/workflows/job-build.yml + if: ${{ github.event.pull_request.number && (needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true') }} + needs: + - path-filter + - expose-vars + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.pull_request.number || github.event.number }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.number }} + secrets: + ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }} + + build-label: + uses: ./.github/workflows/job-label.yml + needs: + - expose-vars + - build + with: + CONF_PATH: ./.github/labeler/build.yml + + scan-vuln: + uses: ./.github/workflows/job-scan.yml + needs: + - expose-vars + - build + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.pull_request.number || github.event.number }} + + # Workaround for required status check in protection branches (see. https://github.com/orgs/community/discussions/13690) + all-jobs-passed: + name: Check jobs status + runs-on: ubuntu-latest + if: ${{ always() }} + needs: + - path-filter + - expose-vars + - lint + - unit-tests + - build + - scan-vuln + steps: + - name: Check status of all required jobs + run: |- + NEEDS_CONTEXT='${{ toJson(needs) }}' + JOB_IDS=$(echo "$NEEDS_CONTEXT" | jq -r 'keys[]') + for JOB_ID in $JOB_IDS; do + RESULT=$(echo "$NEEDS_CONTEXT" | jq -r ".[\"$JOB_ID\"].result") + echo "$JOB_ID job result: $RESULT" + if [[ $RESULT != "success" && $RESULT != "skipped" ]]; then + echo "***" + echo "Error: The $JOB_ID job did not pass." + exit 1 + fi + done + echo "All jobs passed or were skipped." diff --git a/.jjconflict-side-2/.github/workflows/workflow-create-or-update-release.yml b/.jjconflict-side-2/.github/workflows/workflow-create-or-update-release.yml new file mode 100644 index 0000000000..70d77d68c7 --- /dev/null +++ b/.jjconflict-side-2/.github/workflows/workflow-create-or-update-release.yml @@ -0,0 +1,93 @@ +name: Create/update next release + +permissions: + contents: write + pull-requests: write + # Needed for build job + packages: write + +on: + push: + branches: + - main + - hotfix/* + +env: + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: true + USE_QEMU: false + NODE_VERSION: 24.13.1 + +jobs: + expose-vars: + runs-on: ubuntu-latest + outputs: + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + NODE_VERSION: ${{ env.NODE_VERSION }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars" + + release: + uses: ./.github/workflows/job-release-please.yml + + build-amd64: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.release.outputs.release-created == 'true' }} + needs: + - expose-vars + - release + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: ${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} + MAJOR_TAG: ${{ needs.release.outputs.major-tag }} + MINOR_TAG: ${{ needs.release.outputs.minor-tag }} + PATCH_TAG: ${{ needs.release.outputs.patch-tag }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: false + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + + build-arm64: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.release.outputs.release-created == 'true' }} + needs: + - expose-vars + - release + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: ${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} + MAJOR_TAG: ${{ needs.release.outputs.major-tag }} + MINOR_TAG: ${{ needs.release.outputs.minor-tag }} + PATCH_TAG: ${{ needs.release.outputs.patch-tag }} + BUILD_AMD64: false + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + + update-chart: + runs-on: ubuntu-latest + needs: + - expose-vars + - release + - build-amd64 + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: helm-charts + - name: Trigger helm-charts update + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + run: | + gh workflow --repo cloud-pi-native/helm-charts run update-chart.yaml -f APP_VERSION=${{ needs.release.outputs.major-tag }}.${{ needs.release.outputs.minor-tag }}.${{ needs.release.outputs.patch-tag }} -f CHART_NAME=dso-console diff --git a/.jjconflict-side-2/.github/workflows/workflow-create-preview-comment.yml b/.jjconflict-side-2/.github/workflows/workflow-create-preview-comment.yml new file mode 100644 index 0000000000..e91b3731b1 --- /dev/null +++ b/.jjconflict-side-2/.github/workflows/workflow-create-preview-comment.yml @@ -0,0 +1,43 @@ +name: Add preview comment + +permissions: + pull-requests: write + +on: + pull_request: + types: + - labeled + branches: + - "**" + +jobs: + preview-comment: + name: Add comment with preview infos + if: contains(github.event.pull_request.labels.*.name, 'preview') + runs-on: ubuntu-latest + steps: + - name: Generate app url + id: generate-url + run: | + echo "CONSOLE_URL=$(echo "${{ vars.ARGOCD_CONSOLE_URL_TEMPLATE }}" | sed 's|<pr_number>|${{ github.event.number }}|g')" >> $GITHUB_OUTPUT + + - name: Add comment on Merge Request + id: add-comment-on-merge-request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MERGE_REQUEST_ID: ${{ github.event.pull_request.number }} + COMMENT: | + 🤖 Hey ! + + A preview of the application is available at : ${{ steps.generate-url.outputs.CONSOLE_URL }} + + *Please be patient, deployment may take a few minutes.* + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: process.env.MERGE_REQUEST_ID, + body: process.env.COMMENT, + }); diff --git a/.jjconflict-side-2/.github/workflows/workflow-merge-queue.yml b/.jjconflict-side-2/.github/workflows/workflow-merge-queue.yml new file mode 100644 index 0000000000..cca23047d1 --- /dev/null +++ b/.jjconflict-side-2/.github/workflows/workflow-merge-queue.yml @@ -0,0 +1,166 @@ +name: Merge Queue + +permissions: + contents: read + # Needed for build job + packages: write + # Needed for build-label job + pull-requests: write + +on: + workflow_dispatch: + inputs: + PR_NUMBER: + description: Pull request number to run checks for + required: true + type: string + merge_group: + types: [checks_requested] + +env: + NODE_VERSION: 24.13.1 + REGISTRY: ghcr.io + NAMESPACE: "${{ github.repository }}" + BUILD_AMD64: true + BUILD_ARM64: false + USE_QEMU: false + MERGE_GROUP_HEAD_SHA: "${{ github.event.merge_group.head_sha || github.sha }}" + MERGE_GROUP_HEAD_REF: "${{ github.event.merge_group.head_ref || github.ref_name }}" + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.apps }} + packages: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.packages }} + e2e: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.e2e }} + ci: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.filter.outputs.ci }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + if: ${{ github.event_name != 'workflow_dispatch' }} + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + ci: + - '.github/workflows/**' + e2e: + - 'playwright/**' + + expose-vars: + runs-on: ubuntu-latest + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + REGISTRY: ${{ env.REGISTRY }} + NAMESPACE: ${{ env.NAMESPACE }} + BUILD_AMD64: ${{ env.BUILD_AMD64 }} + BUILD_ARM64: ${{ env.BUILD_ARM64 }} + USE_QEMU: ${{ env.USE_QEMU }} + MERGE_GROUP_HEAD_REF: ${{ env.MERGE_GROUP_HEAD_REF }} + MERGE_GROUP_HEAD_SHA: ${{ env.MERGE_GROUP_HEAD_SHA }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + lint: + uses: ./.github/workflows/job-lint.yml + needs: + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + + unit-tests: + uses: ./.github/workflows/job-tests-unit.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + secrets: + SONAR_HOST_URL: "${{ secrets.SONAR_HOST_URL }}" + SONAR_TOKEN: "${{ secrets.SONAR_TOKEN }}" + SONAR_PROJECT_KEY: "${{ secrets.SONAR_PROJECT_KEY }}" + + build: + uses: ./.github/workflows/job-build.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + BUILD_AMD64: ${{ needs.expose-vars.outputs.BUILD_AMD64 == 'true' }} + BUILD_ARM64: ${{ needs.expose-vars.outputs.BUILD_ARM64 == 'true' }} + USE_QEMU: ${{ needs.expose-vars.outputs.USE_QEMU == 'true' }} + PR_NUMBER: ${{ github.event.inputs.PR_NUMBER || github.event.number || '' }} + secrets: + ARGOCD_TOKEN: ${{ secrets.ARGOCD_TOKEN }} + + build-label: + uses: ./.github/workflows/job-label.yml + needs: + - expose-vars + - build + with: + CONF_PATH: ./.github/labeler/build.yml + + playwright-tests: + uses: ./.github/workflows/job-playwright.yml + if: ${{ needs.path-filter.outputs.apps == 'true' || needs.path-filter.outputs.packages == 'true' || needs.path-filter.outputs.ci == 'true' || needs.path-filter.outputs.e2e == 'true' }} + needs: + - path-filter + - expose-vars + - build + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + + scan-vuln: + uses: ./.github/workflows/job-scan.yml + needs: + - expose-vars + - build + with: + REGISTRY: ${{ needs.expose-vars.outputs.REGISTRY }} + NAMESPACE: ${{ needs.expose-vars.outputs.NAMESPACE }} + TAG: pr-${{ github.event.inputs.PR_NUMBER || github.event.number || needs.expose-vars.outputs.MERGE_GROUP_HEAD_SHA }} + + # Workaround for required status check in protection branches (see. https://github.com/orgs/community/discussions/13690) + all-jobs-passed: + name: Check jobs status + runs-on: ubuntu-latest + if: ${{ always() }} + needs: + - path-filter + - expose-vars + - lint + - unit-tests + - build + - playwright-tests + - scan-vuln + steps: + - name: Check status of all required jobs + run: |- + NEEDS_CONTEXT='${{ toJson(needs) }}' + JOB_IDS=$(echo "$NEEDS_CONTEXT" | jq -r 'keys[]') + for JOB_ID in $JOB_IDS; do + RESULT=$(echo "$NEEDS_CONTEXT" | jq -r ".[\"$JOB_ID\"].result") + echo "$JOB_ID job result: $RESULT" + if [[ $RESULT != "success" && $RESULT != "skipped" ]]; then + echo "***" + echo "Error: The $JOB_ID job did not pass." + exit 1 + fi + done + echo "All jobs passed or were skipped." diff --git a/.jjconflict-side-2/.github/workflows/workflow-post-merge.yml b/.jjconflict-side-2/.github/workflows/workflow-post-merge.yml new file mode 100644 index 0000000000..da61baa341 --- /dev/null +++ b/.jjconflict-side-2/.github/workflows/workflow-post-merge.yml @@ -0,0 +1,56 @@ +name: Post-merge + +on: + push: + branches: + - main + +env: + NODE_VERSION: 24.13.1 + +permissions: + contents: read + id-token: write + +jobs: + path-filter: + runs-on: ubuntu-latest + outputs: + apps: ${{ steps.filter.outputs.apps }} + packages: ${{ steps.filter.outputs.packages }} + steps: + - name: Checks-out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check updated files paths + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + apps: + - 'apps/**' + packages: + - 'packages/**' + - 'plugins/**' + + expose-vars: + runs-on: ubuntu-latest + if: ${{ !github.event.pull_request.draft }} + outputs: + NODE_VERSION: ${{ env.NODE_VERSION }} + steps: + - name: Exposing env vars + run: echo "Exposing env vars." + + npm-publish: + uses: ./.github/workflows/job-npm.yml + needs: + - path-filter + - expose-vars + with: + NODE_VERSION: ${{ needs.expose-vars.outputs.NODE_VERSION }} + PUBLISH_PACKAGES: true + PUBLISH_PLUGINS: true + CHECK_CHANGES: true + # Deprecated variable: we never publish the apps on NPM registry + PUBLISH_APPS: false diff --git a/.jjconflict-side-2/.gitignore b/.jjconflict-side-2/.gitignore new file mode 100644 index 0000000000..0d127c9b66 --- /dev/null +++ b/.jjconflict-side-2/.gitignore @@ -0,0 +1,77 @@ +# code +.vscode/* +!.vscode/vue-dsfr.code-snippets +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + + +# modules +node_modules +**/node_modules + +# env +.env +.env.ci +.env.test +.env.prod +.env.int +.env.integ +.env.codespace +.env.docker + +# init-db +apps/server/src/init/db/imports/data + +# act +**/act/artifacts +**/act/docker/registry/auth +**/act/docker/registry/data + +# vitest +coverage +apps/client/vite.config.*.timestamp-*.mjs +apps/server/vite.config.*.timestamp-*.mjs + +# sonarqube +.scannerwork + +# keycloak +keycloak/data + +# turbo +.turbo + +# kube config +.kubeconfig + +# dist +dist +build +components.d.ts +*.tsbuildinfo + +# types +types/ + +# plugins +apps/server/src/plugins/external + +# helm +charts/ + +# integration helm values +**/env/dso-values-integ.yaml +**/env/kind-values.yml + +# tmp dev files +ci/kind/configs/rendered + +external-plugins + +# Playwright +playwright/test-results/ +playwright/playwright-report/ +playwright/blob-report/ +playwright/playwright/.cache/ diff --git a/.jjconflict-side-2/.husky/commit-msg b/.jjconflict-side-2/.husky/commit-msg new file mode 100755 index 0000000000..8761c9bac9 --- /dev/null +++ b/.jjconflict-side-2/.husky/commit-msg @@ -0,0 +1 @@ +pnpx commitlint --edit ${1} diff --git a/.jjconflict-side-2/.lintstagedrc b/.jjconflict-side-2/.lintstagedrc new file mode 100644 index 0000000000..741d5e9ca3 --- /dev/null +++ b/.jjconflict-side-2/.lintstagedrc @@ -0,0 +1,8 @@ +{ + "**/apps/client/**/*.{vue,css}": "pnpm --filter=@cpn-console/client run lint:style --", + "**/apps/client/**/*": "pnpm --filter=@cpn-console/client run lint:ts --", + "**/apps/server/**/*": "pnpm --filter=@cpn-console/server run lint --", + "**/packages/**/*": "pnpm --filter=./packages/* run lint --", + "**/plugins/**/*": "pnpm --filter=./plugins/* run lint --", + "**/*": "pnpm run lint:root --" +} diff --git a/.jjconflict-side-2/.pinact.yaml b/.jjconflict-side-2/.pinact.yaml new file mode 100644 index 0000000000..3b20588ab3 --- /dev/null +++ b/.jjconflict-side-2/.pinact.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/suzuki-shunsuke/pinact/refs/heads/main/json-schema/pinact.json +# pinact - https://github.com/suzuki-shunsuke/pinact +version: 3 +# files: +# - pattern: action.yaml +# - pattern: */action.yaml + +# separator: " # " + +ignore_actions: +# - name: slsa-framework/slsa-github-generator/\.github/workflows/generator_generic_slsa3\.yml +# ref: v\d+\.\d+\.\d+ +# - name: actions/.* +# ref: main +# - name: suzuki-shunsuke/.* +# ref: release-.* diff --git a/.jjconflict-side-2/.release-please-manifest.json b/.jjconflict-side-2/.release-please-manifest.json new file mode 100644 index 0000000000..18953d7442 --- /dev/null +++ b/.jjconflict-side-2/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "9.22.2" +} diff --git a/.jjconflict-side-2/.vscode/extensions.json b/.jjconflict-side-2/.vscode/extensions.json new file mode 100644 index 0000000000..3795e131e1 --- /dev/null +++ b/.jjconflict-side-2/.vscode/extensions.json @@ -0,0 +1,30 @@ +{ + "recommendations": [ + // Cross-editor config + "editorconfig.editorconfig", + // Git + "mhutchie.git-graph", + "eamodio.gitlens", + // Node / npm + "christian-kohler.npm-intellisense", + // JavaScript + "dbaeumer.vscode-eslint", + // Vue.js + "vue.volar", + "vue.vscode-typescript-vue-plugin", + // Vitejs + "antfu.vite", + "antfu.browse-lite", + // Firefox Debugger + "firefox-devtools.vscode-firefox-debug", + // CSS + "csstools.postcss", + "stylelint.vscode-stylelint", + // YAML + "redhat.vscode-yaml", + // Makdown + "yzhang.markdown-all-in-one", + // Kubernetes + "ms-kubernetes-tools.vscode-kubernetes-tools" + ] +} diff --git a/.jjconflict-side-2/.vscode/launch.json b/.jjconflict-side-2/.vscode/launch.json new file mode 100644 index 0000000000..fbfb2f1013 --- /dev/null +++ b/.jjconflict-side-2/.vscode/launch.json @@ -0,0 +1,326 @@ +{ + // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. + // Pointez pour afficher la description des attributs existants. + // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch Server (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server", + "run", + "integ" + ], + "envFile": "${workspaceFolder}/apps/server/.env.integ", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server", + "run", + "dev" + ], + "envFile": "${workspaceFolder}/apps/server/.env", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Infra (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "run", + "integ" + ], + "console": "internalConsole", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Infra (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "run", + "dev" + ], + "console": "internalConsole", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server NestJS (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server-nestjs", + "run", + "start:dev" + ], + "envFile": "${workspaceFolder}/apps/server-nestjs/.env.integ", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Launch Server NestJS (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/server-nestjs", + "run", + "start:dev" + ], + "envFile": "${workspaceFolder}/apps/server-nestjs/.env", + "console": "integratedTerminal", + "restart": true, + "presentation": { + "hidden": true + }, + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ] + }, + { + "name": "Attach Server", + "type": "node", + "request": "attach", + "port": 9229, + "address": "127.0.0.1", + "localRoot": "${workspaceFolder}/apps/server", + "remoteRoot": "/app/apps/server", + "skipFiles": [ + "<node_internals>/**", + "${workspaceFolder}/node_modules/**/*.js" + ], + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Chrome) (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "integ", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env.integ", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Chrome)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Chrome) (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "dev", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Chrome)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Firefox) (Integ)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "integ", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env.integ", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Firefox)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Serve Client (Firefox) (Dev)", + "type": "node", + "request": "launch", + "runtimeExecutable": "corepack", + "runtimeArgs": [ + "pnpm", + "--filter", + "@cpn-console/client", + "run", + "dev", + "--", + "--port", + "8080", + "--strictPort" + ], + "envFile": "${workspaceFolder}/apps/client/.env", + "console": "integratedTerminal", + "restart": true, + "serverReadyAction": { + "pattern": "Local:.*(http://localhost:[0-9]+)", + "action": "startDebugging", + "name": "Launch Client (Firefox)" + }, + "presentation": { + "hidden": true + } + }, + { + "name": "Launch Client (Chrome)", + "type": "chrome", + "request": "launch", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}/apps/client", + "presentation": { + "hidden": true + } + }, + { + "name": "Launch Client (Firefox)", + "type": "firefox", + "request": "launch", + "url": "http://localhost:8080", + "webRoot": "${workspaceFolder}/apps/client", + "presentation": { + "hidden": true + } + } + ], + "compounds": [ + { + "name": "Launch (Chrome) (Integ)", + "configurations": [ + "Launch Infra (Integ)", + "Launch Server (Integ)", + "Launch Server NestJS (Integ)", + "Serve Client (Chrome) (Integ)" + ] + }, + { + "name": "Launch (Chrome) (Dev)", + "configurations": [ + "Launch Infra (Dev)", + "Launch Server (Dev)", + "Launch Server NestJS (Dev)", + "Serve Client (Chrome) (Dev)" + ] + }, + { + "name": "Launch (Firefox) (Integ)", + "configurations": [ + "Launch Infra (Integ)", + "Launch Server (Integ)", + "Launch Server NestJS (Integ)", + "Serve Client (Firefox) (Integ)" + ] + }, + { + "name": "Launch (Firefox) (Dev)", + "configurations": [ + "Launch Infra (Dev)", + "Launch Server (Dev)", + "Launch Server NestJS (Dev)", + "Serve Client (Firefox) (Dev)" + ] + } + ] +} diff --git a/.jjconflict-side-2/.vscode/settings.json b/.jjconflict-side-2/.vscode/settings.json new file mode 100644 index 0000000000..1b93a34e04 --- /dev/null +++ b/.jjconflict-side-2/.vscode/settings.json @@ -0,0 +1,120 @@ +{ + // visuals + "window.autoDetectColorScheme": true, + "editor.fontLigatures": "'ss01', 'ss02', 'ss03', 'ss06', 'zero'", + "editor.cursorBlinking": "phase", + "editor.suggestSelection": "first", + "editor.wordWrap": "on", + "editor.bracketPairColorization.enabled": true, + "editor.tabSize": 2, + "search.exclude": { + "**/.git": true, + "**/.nuxt": true, + "**/.pnpm": true, + "**/.yarn": true, + "**/.turbo": true, + "**/dist/**": true, + "**/out/**": true, + "**/logs": true, + "**/node_modules": true, + "**/package-lock.json": true, + "**/pnpm-lock.yaml": true, + "**/yarn.lock": true + }, + // general + "editor.unicodeHighlight.invisibleCharacters": false, + "workbench.startupEditor": "none", + // git + "git.autofetch": true, + "git.untrackedChanges": "separate", + // github + "githubPullRequests.pullBranch": "never", + // eslint + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "vue", + "html", + "markdown", + "json", + "jsonc", + "yaml", + "github-actions-workflow", + "toml", + "xml", + "gql", + "graphql", + "astro", + "svelte", + "css", + "less", + "scss", + "pcss", + "postcss" + ], + // stylelint + "css.validate": true, + "less.validate": true, + "scss.validate": true, + "files.associations": { + "*.css": "css" + }, + // languages + "[javascript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "javascript.preferences.quoteStyle": "single", + "javascript.preferences.importModuleSpecifierEnding": "js", + "javascript.suggest.completeJSDocs": true, + "javascript.suggest.jsdoc.generateReturns": true, + "[typescript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "typescript.preferences.quoteStyle": "single", + "typescript.preferences.importModuleSpecifierEnding": "js", + "typescript.suggest.completeJSDocs": true, + "typescript.suggest.jsdoc.generateReturns": true, + "[vue]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[css]": { + "editor.defaultFormatter": "stylelint.vscode-stylelint" + }, + "[html]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[markdown]": { + "editor.quickSuggestions": { + "other": true, + "comments": true, + "strings": true + }, + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.defaultFormatter": "yzhang.markdown-all-in-one" + }, + // conventional commits + "conventionalCommits.showNewVersionNotes": false, + // kubernetes + "vs-kubernetes": { + "vs-kubernetes.crd-code-completion": "enabled" + }, + "vscode-kubernetes.log-viewer.follow": true, + "vscode-kubernetes.log-viewer.timestamp": false, + "vscode-kubernetes.log-viewer.since": -1, + "vscode-kubernetes.log-viewer.tail": -1, + "vscode-kubernetes.log-viewer.destination": "Webview", + "vscode-kubernetes.log-viewer.wrap": true, + // sonarqube + "sonarlint.connectedMode.project": { + "connectionId": "https-sonarqube-fabrique-numerique-fr", + "projectKey": "cloud-pi-native_console_AYsa46O31ebDtzs-pYyZ" + }, + "prisma-smart-formatter.typescript.defaultFormatter": "dbaeumer.vscode-eslint", + "prisma-smart-formatter.prisma.defaultFormatter": "Prisma.prisma" +} diff --git a/.jjconflict-side-2/.vscode/vue-dsfr.code-snippets b/.jjconflict-side-2/.vscode/vue-dsfr.code-snippets new file mode 100644 index 0000000000..2533f522d8 --- /dev/null +++ b/.jjconflict-side-2/.vscode/vue-dsfr.code-snippets @@ -0,0 +1,823 @@ +{ + "template": { + "scope": "vue", + "prefix": "template", + "body": [ + "<template>", + " $1", + "</template>" + ], + "description": "Create <template> block" + }, + "script": { + "scope": "vue", + "prefix": "script", + "body": [ + "<script>", + "import { defineComponent } from 'vue';", + "", + "export default defineComponent({", + " $2", + "});", + "</script>" + ], + "description": "Create <script> block" + }, + "script setup": { + "scope": "vue", + "prefix": "script setup", + "body": [ + "<script setup>", + "defineProps({", + " $1", + "});", + "</script>" + ], + "description": "Create <script setup> + <script> blocks" + }, + "style": { + "scope": "vue", + "prefix": "scoped-style", + "body": [ + "<style scoped>", + "$1", + "</style>" + ], + "description": "Create <style> block" + }, + "v-for": { + "scope": "html", + "prefix": "v-for", + "body": [ + "<template v-for=\"$1 in $2\">", + " $3", + "</template>" + ], + "description": "Create html tag with v-for" + }, + "v-if": { + "scope": "html", + "prefix": "v-if", + "body": [ + "<template v-if=\"$1\">", + " $2", + "</template>" + ], + "description": "Create html tag with v-if" + }, + "v-else-if": { + "scope": "html", + "prefix": "v-else-if", + "body": [ + "<template v-else-if=\"$1\">", + " $2", + "</template>" + ], + "description": "Create html tag with v-else-if" + }, + "v-else": { + "scope": "html", + "prefix": "v-else", + "body": [ + "<template v-else>", + " $1", + "</template>" + ], + "description": "Create html tag with v-else" + }, + "DsfrAccordion": { + "scope": "html", + "prefix": "DsfrAccordion", + "body": [ + "<DsfrAccordion", + " :expanded-id=\"${1:expandedId}\"", + " :title=\"${2:accordionTitle}\"", + ">", + " $3", + "</DsfrAccordion>", + ], + "description": "Create DsfrAccordion boilerplate" + }, + "DsfrAccordionsGroup": { + "scope": "html", + "prefix": "DsfrAccordionsGroup", + "body": [ + "<DsfrAccordionsGroup>", + " <li>", + " <DsfrAccordion", + " :expanded-id=\"${1:expandedId}\"", + " :title=\"${2:accordionTitle}\"", + " @expand=\"expandedId = $event\"", + " >", + " </li>", + "</DsfrAccordionsGroup>", + ], + "description": "Create DsfrAccordionsGroup boilerplate" + }, + "DsfrAlert": { + "scope": "html", + "prefix": "DsfrAlert", + "body": [ + "<DsfrAlert", + " :title=\"${1:alertTitle}\"", + " :description=\"${2:alertDescription}\"", + " :type=\"${3:alertType}\"", + " :closed=\"${4:!openAlert}\"", + " ${5:closeable}", + " @close=\"${6:closeAlert()}\"", + "/>", + ], + "description": "Create DsfrAlert boilerplate" + }, + "DsfrBadge": { + "scope": "html", + "prefix": "DsfrBadge", + "body": [ + "<DsfrBadge", + " :label=\"${1}\"", + " :type=\"${2:success}\"", + "/>", + ], + "description": "Create DsfrBadge boilerplate" + }, + "DsfrBreadcrumb": { + "scope": "html", + "prefix": "DsfrBreadcrumb", + "body": [ + "<DsfrBreadcrumb", + " :links=\"${1:links}\"", + "/>", + ], + "description": "Create DsfrBreadcrumb boilerplate" + }, + "DsfrButton": { + "scope": "html", + "prefix": "DsfrButton", + "body": [ + "<DsfrButton", + " type=\"${1:buttonType}\"", + " :label=\"${2:buttonLabel}\"", + " ${3:secondary}", + " @click=\"${4:onClick()}\"", + "/>", + ], + "description": "Create DsfrButton boilerplate" + }, + "DsfrButtonGroup": { + "scope": "html", + "prefix": "DsfrButtonGroup", + "body": [ + "<DsfrButtonGroup", + " :buttons=\"${1:buttons}\"", + " :label=\"${2:buttonLabel}\"", + " ${3:secondary}", + " @click=\"${4:onClick()}\"", + "/>", + ], + "description": "Create DsfrButtonGroup boilerplate" + }, + "DsfrCallout": { + "scope": "html", + "prefix": "DsfrCallout", + "body": [ + "<DsfrCallout", + " :title=\"${1:title}\"", + " :content=\"${2:content}\"", + " :button=\"${3:button}\"", + " :icon=\"${4:icon}\"", + "/>", + ], + "description": "Create DsfrCallout boilerplate" + }, + "DsfrCard": { + "scope": "html", + "prefix": "DsfrCard", + "body": [ + "<DsfrCard", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :detail=\"${3:detail}\"", + " :link=\"${4:link}\"", + " :alt-img=\"${5:altImg}\"", + " :img-src=\"${6:imgSrc}\"", + "/>", + ], + "description": "Create DsfrCard boilerplate" + }, + "DsfrCheckbox": { + "scope": "html", + "prefix": "DsfrCheckbox", + "body": [ + "<DsfrCheckbox", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + " :name=\"${3:name}\"", + " :modelValue=\"${4:checked}\"", + " @update:model-value=\"updateChecked($event)\"", + "/>", + ], + "description": "Create DsfrCheckbox boilerplate" + }, + "DsfrCheckboxSet": { + "scope": "html", + "prefix": "DsfrCheckboxSet", + "body": [ + "<DsfrCheckboxSet", + " v-model=\"${1:selectedCheckbox}\"", + " :legend=\"${2:legend}\"", + " :options=\"${3:options}\"", + "/>", + ], + "description": "Create DsfrCheckboxSet boilerplate" + }, + "DsfrConsent": { + "scope": "html", + "prefix": "DsfrConsent", + "body": [ + "<DsfrConsent", + " :url=\"${1:url}\"", + " @accept-all=\"${2:onAcceptAll()}\"", + " @refuse-all=\"${3:onRefuseAll()}\"", + " @customize=\"${3:onCustomize()}\"", + "/>", + ], + "description": "Create DsfrConsent boilerplate" + }, + "DsfrFieldset": { + "scope": "html", + "prefix": "DsfrFieldset", + "body": [ + "<DsfrFieldset", + " :legend=\"${1:legend}\"", + " :hint=\"${2:hint}\"", + " :legend-class=\"${1:legendClass}\"", + " :hint-class=\"${1:hintClass}\"", + " :legend-id=\"${1:legendId}\"", + "/>", + ], + "description": "Create DsfrFieldset boilerplate" + }, + "DsfrFileUpload": { + "scope": "html", + "prefix": "DsfrFileUpload", + "body": [ + "<DsfrFileUpload", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + "/>", + ], + "description": "Create DsfrFileUpload boilerplate" + }, + "DsfrFollow": { + "scope": "html", + "prefix": "DsfrFollow", + "body": [ + "<DsfrFollow", + " :networks=\"${1:networks}\"", + " :newsletter-data=\"${2:newsletterData}\"", + "/>", + ], + "description": "Create DsfrFollow boilerplate" + }, + "DsfrFranceConnect": { + "scope": "html", + "prefix": "DsfrFranceConnect", + "body": [ + "<DsfrFranceConnect ${1:secure} />", + ], + "description": "Create DsfrFranceConnect boilerplate" + }, + "DsfrNewsLetter": { + "scope": "html", + "prefix": "DsfrNewsLetter", + "body": [ + "<DsfrNewsLetter", + " v-model:email=\"${1:email}\"", + " :title=\"${2:title}\"", + " :description=\"${3:description}\"", + " :labelEmail=\"${4:labelEmail}\"", + " :inputTitle=\"${5:inputTitle}\"", + " :placeholder=\"${6:placeholder}\"", + " :hintText=\"${7:hintText}\"", + " :buttonText=\"${8:buttonText}\"", + "/>", + ], + "description": "Create DsfrNewsLetter boilerplate" + }, + "DsfrSocialNetworks": { + "scope": "html", + "prefix": "DsfrSocialNetworks", + "body": [ + "<DsfrSocialNetworks", + " :networks=\"${1:networks}\"", + "/>", + ], + "description": "Create DsfrSocialNetworks boilerplate" + }, + "DsfrFooter": { + "scope": "html", + "prefix": "DsfrFooter", + "body": [ + "<DsfrFooter", + " :a11y-compliance=\"${1:a11yCompliance}\"", + " :logo-text=\"${2:logoText}\"", + " :legal-link=\"${3:legalLink}\"", + " :personal-data-link=\"${4:personalDataLink}\"", + " :cookies-link=\"${5:cookiesLink}\"", + " :a11y-compliance-link=\"${6:a11yComplianceLink}\"", + " :desc-text=\"${7:descText}\"", + " :home-link=\"${8:homeLink}\"", + " :partners=\"${9:partners}\"", + " :ecosystem-links=\"${10:ecosystemLinks}\"", + "/>", + ], + "description": "Create DsfrFooter boilerplate" + }, + "DsfrHeader": { + "scope": "html", + "prefix": "DsfrHeader", + "body": [ + "<DsfrHeader", + " :service-title=\"${1:serviceTitle}\"", + " :service-description=\"${2:serviceDescription}\"", + " :logo-text=\"${3:logoText}\"", + " :quick-links=\"${4:quickLinks}\"", + " ${5:show-search}", + "/>", + ], + "description": "Create DsfrHeader boilerplate" + }, + "DsfrHighlight": { + "scope": "html", + "prefix": "DsfrHighlight", + "body": [ + "<DsfrHighlight", + " :text=\"${1:text}\"", + "/>", + ], + "description": "Create DsfrHighlight boilerplate" + }, + "DsfrInput": { + "scope": "html", + "prefix": "DsfrInput", + "body": [ + "<DsfrInput", + " v-model=\"${1:inputValue}\"", + " type=\"${2:inputType}\"", + " :label=\"${3:inputLabel}\"", + " ${4:label-visible}", + " :placeholder=\"${5:inputPlaceholder}\"", + "/>", + ], + "description": "Create DsfrInput boilerplate" + }, + "DsfrLogo": { + "scope": "html", + "prefix": "DsfrLogo", + "body": [ + "<DsfrLogo", + " logo-text=\"$1\"", + "/>", + ], + "description": "Create DsfrLogo boilerplate" + }, + "DsfrPicture": { + "scope": "html", + "prefix": "DsfrPicture", + "body": [ + "<DsfrPicture", + " :src=\"${1:src}\"", + " :alt=\"${2:alt}\"", + " :title=\"${3:title}\"", + " :legend=\"${4:legend}\"", + "/>", + ], + "description": "Create DsfrPicture boilerplate" + }, + "DsfrVideo": { + "scope": "html", + "prefix": "DsfrVideo", + "body": [ + "<DsfrVideo", + " :src=\"${1:src}\"", + " :legend=\"${2:legend}\"", + "/>", + ], + "description": "Create DsfrVideo boilerplate" + }, + "DsfrModal": { + "scope": "html", + "prefix": "DsfrModal", + "body": [ + "<DsfrButton", + " label=\"${1:Ouvre la modale}\"", + " ref=\"modalOrigin\"", + " @click=\"${2:open()}\"", + "/>", + "<DsfrModal", + " :opened=\"${3:opened}\"", + " :title=\"${4:title}\"", + " :origin=\"$refs.modalOrigin\"", + ">", + " $5", + "</DsfrModal>", + ], + "description": "Create DsfrModal boilerplate" + }, + "DsfrNavigation": { + "scope": "html", + "prefix": "DsfrNavigation", + "body": [ + "<DsfrNavigation", + " :nav-items=\"${1:navItems}\"", + "/>", + ], + "description": "Create DsfrNavigation boilerplate" + }, + "DsfrNavigationItem": { + "scope": "html", + "prefix": "DsfrNavigationItem", + "body": [ + "<DsfrNavigationItem :active=\"${1:active}\">", + " $2", + "</DsfrNavigationItem>", + ], + "description": "Create DsfrNavigationItem boilerplate" + }, + "DsfrNavigationMegaMenu": { + "scope": "html", + "prefix": "DsfrNavigationMegaMenu", + "body": [ + "<DsfrNavigationMegaMenu", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :link=\"${3:link}\"", + " :menus=\"${4:menus}\"", + " :expanded-id=\"${5:expandedId}\"", + " :@click=\"${6:toggle($event)}\"", + ">", + " $7", + "</DsfrNavigationMegaMenu>", + ], + "description": "Create DsfrNavigationMegaMenu boilerplate" + }, + "DsfrNavigationMegaMenuCategory": { + "scope": "html", + "prefix": "DsfrNavigationMegaMenuCategory", + "body": [ + "<DsfrNavigationMegaMenuCategory", + " :title=\"${1:title}\"", + " :links=\"${2:links}\"", + "/>", + ], + "description": "Create DsfrNavigationMegaMenuCategory boilerplate" + }, + "DsfrNavigationMenu": { + "scope": "html", + "prefix": "DsfrNavigationMenu", + "body": [ + "<DsfrNavigationMenu", + " :title=\"${1:title}\"", + " :links=\"${2:links}\"", + " :expanded-id=\"${3:expandedId}\"", + " :@click=\"${4:toggle($event)}\"", + "/>", + ], + "description": "Create DsfrNavigationMenu boilerplate" + }, + "DsfrNavigationMenuItem": { + "scope": "html", + "prefix": "DsfrNavigationMenuItem", + "body": [ + "<DsfrNavigationMenuItem", + " :active=\"${1:active}\"", + ">", + "</DsfrNavigationMenuItem>", + ], + "description": "Create DsfrNavigationMenuItem boilerplate" + }, + "DsfrNavigationMenuLink": { + "scope": "html", + "prefix": "DsfrNavigationMenuLink", + "body": [ + "<DsfrNavigationMenuLink", + " :to=\"${1:to}\"", + " :text=\"${2:text}\"", + "/>", + ], + "description": "Create DsfrNavigationMenuLink boilerplate" + }, + "DsfrPagination": { + "scope": "html", + "prefix": "DsfrPagination", + "body": [ + "<DsfrPagination", + " :pages=\"${1:pages}\"", + " v-model:current-page=\"${2:currentPage}\"", + "/>", + ], + "description": "Create DsfrPagination boilerplate" + }, + "DsfrQuote": { + "scope": "html", + "prefix": "DsfrQuote", + "body": [ + "<DsfrQuote", + " :quote=\"${1:quote}\"", + " :author=\"${2:author}\"", + " :details=\"${3:details}\"", + " :illustration=\"${4:illustration}\"", + " :source=\"${5:source}\"", + " :sourceUrl=\"${6:sourceUrl}\"", + " :quoteImage=\"${7:quoteImage}\"", + "/>", + ], + "description": "Create DsfrQuote boilerplate" + }, + "DsfrRadioButton": { + "scope": "html", + "prefix": "DsfrRadioButton", + "body": [ + "<DsfrRadioButton", + " :label=\"${1:label}\"", + " :hint=\"${2:hint}\"", + " :name=\"${3:name}\"", + " :value=\"${4:value}\"", + " :model-value=\"${5:modelValue}\"", + " @update:model-value=\"${6:updateCheckedValue($event)}\"", + "/>", + ], + "description": "Create DsfrRadioButton boilerplate" + }, + "DsfrRadioButtonSet": { + "scope": "html", + "prefix": "DsfrRadioButtonSet", + "body": [ + "<DsfrRadioButtonSet", + " v-model=\"${1:modelValue}\"", + " :legend=\"${2:legend}\"", + " name=\"${2:radio-set}\"", + " :options=\"${4:options}\"", + " @update:model-value=\"${6:updateCheckedValue($event)}\"", + "/>", + ], + "description": "Create DsfrRadioButtonSet boilerplate" + }, + "DsfrSearchBar": { + "scope": "html", + "prefix": "DsfrSearchBar", + "body": [ + "<DsfrSearchBar", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :placeholder=\"${3:placeholder}\"", + " ${4:label-visible}", + " :button-text=\"${5:buttonText}\"", + "/>", + ], + "description": "Create DsfrSearchBar boilerplate" + }, + "DsfrSelect": { + "scope": "html", + "prefix": "DsfrSelect", + "body": [ + "<DsfrSelect", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :options=\"${3:options}\"", + "/>", + ], + "description": "Create DsfrSelect boilerplate" + }, + "DsfrShare": { + "scope": "html", + "prefix": "DsfrShare", + "body": [ + "<DsfrShare", + " :title=\"${1:title}\"", + " :networks=\"${2:networks}\"", + " :mail=\"${3:mail}\"", + " :copy-label=\"${4:copyLabel}\"", + "/>", + ], + "description": "Create DsfrShare boilerplate" + }, + "DsfrSideMenu": { + "scope": "html", + "prefix": "DsfrSideMenu", + "body": [ + "<DsfrSideMenu", + " :heading-title=\"${1:headingTitle}\"", + " :button-label=\"${2:buttonLabel}\"", + " :menu-items=\"${3:menuItems}\"", + " @toggle-expand=\"${4:toggleExpand($event)}\"", + "/>", + ], + "description": "Create DsfrSideMenu boilerplate" + }, + "DsfrSideMenuButton": { + "scope": "html", + "prefix": "DsfrSideMenuButton", + "body": [ + "<DsfrSideMenuButton", + " :expanded=\"${1:expanded}\"", + " :button-label=\"${2:buttonLabel}\"", + " :control-id=\"${3:controlId}\"", + " @toggle-expand=\"${4:toggleExpand($event)}\"", + "/>", + ], + "description": "Create DsfrSideMenuButton boilerplate" + }, + "DsfrSideMenuLink": { + "scope": "html", + "prefix": "DsfrSideMenuLink", + "body": [ + "<DsfrSideMenuLink", + " :active=\"${1:active}\"", + " :to=\"${2:to}\"", + "/>", + ], + "description": "Create DsfrSideMenuLink boilerplate" + }, + "DsfrSideMenuList": { + "scope": "html", + "prefix": "DsfrSideMenuList", + "body": [ + "<DsfrSideMenuList", + " :menu-items=\"${1:menuItems}\"", + " @toggle-expand=\"${2:toggleExpand}\"", + "/>", + ], + "description": "Create DsfrSideMenuList boilerplate" + }, + "DsfrSideMenuListItem": { + "scope": "html", + "prefix": "DsfrSideMenuListItem", + "body": [ + "<DsfrSideMenuListItem", + " :active=\"${1:active}\"", + ">", + " $2", + "</DsfrSideMenuListItem>", + ], + "description": "Create DsfrSideMenuListItem boilerplate" + }, + "DsfrSkipLinks": { + "scope": "html", + "prefix": "DsfrSkipLinks", + "body": [ + "<DsfrSkipLinks", + " :links=\"${1:links}\"", + "/>", + ], + "description": "Create DsfrSkipLinks boilerplate" + }, + "DsfrTable": { + "scope": "html", + "prefix": "DsfrTable", + "body": [ + "<DsfrTable", + " :title=\"${1:title}\"", + " :headers=\"${2:headers}\"", + " :rows=\"${3:rows}\"", + "/>", + ], + "description": "Create DsfrTable boilerplate" + }, + "DsfrTableCell": { + "scope": "html", + "prefix": "DsfrTableCell", + "body": [ + "<DsfrTableCell", + " :field=\"${1:field}\"", + "/>", + ], + "description": "Create DsfrTableCell boilerplate" + }, + "DsfrTableHeader": { + "scope": "html", + "prefix": "DsfrTableHeader", + "body": [ + "<DsfrTableHeader", + " :header=\"${1:header}\"", + " :header-attrs=\"${2:headerAttrs}\"", + "/>", + ], + "description": "Create DsfrTableHeader boilerplate" + }, + "DsfrTableHeaders": { + "scope": "html", + "prefix": "DsfrTableHeaders", + "body": [ + "<DsfrTableHeaders", + " :headers=\"${1:headers}\"", + "/>", + ], + "description": "Create DsfrTableHeaders boilerplate" + }, + "DsfrTableRow": { + "scope": "html", + "prefix": "DsfrTableRow", + "body": [ + "<DsfrTableRow", + " :row-data=\"${1:rowData}\"", + "/>", + ], + "description": "Create DsfrTableRow boilerplate" + }, + "DsfrTabContent": { + "scope": "html", + "prefix": "DsfrTabContent", + "body": [ + "<DsfrTabContent", + " :panel-id=\"${1:panelId}\"", + " :tab-id=\"${2:tabId}\"", + " :selected=\"${3:selected}\"", + " :asc=\"${4:asc}\"", + ">", + " $5", + "</DsfrTabContent>", + ], + "description": "Create DsfrTabContent boilerplate" + }, + "DsfrTabItem": { + "scope": "html", + "prefix": "DsfrTabItem", + "body": [ + "<DsfrTabItem", + " :tab-id=\"${2:tabId}\"", + " :panel-id=\"${1:panelId}\"", + " :selected=\"${3:selected}\"", + " :icon=\"${4:icon}\"", + ">", + " $5", + "</DsfrTabItem>", + ], + "description": "Create DsfrTabItem boilerplate" + }, + "DsfrTabs": { + "scope": "html", + "prefix": "DsfrTabs", + "body": [ + "<DsfrTabs", + " :tab-list-name=\"${1:tabListName}\"", + " :tab-titles=\"${2:tabTitles}\"", + " :tab-contents=\"${3:tabContents}\"", + "/>", + ], + "description": "Create DsfrTabs boilerplate" + }, + "DsfrTag": { + "scope": "html", + "prefix": "DsfrTag", + "body": [ + "<DsfrTag", + " :label=\"${1:label}\"", + "/>", + ], + "description": "Create DsfrTag boilerplate" + }, + "DsfrTags": { + "scope": "html", + "prefix": "DsfrTags", + "body": [ + "<DsfrTags", + " :tags=\"${1:tags}\"", + "/>", + ], + "description": "Create DsfrTags boilerplate" + }, + "DsfrTile": { + "scope": "html", + "prefix": "DsfrTile", + "body": [ + "<DsfrTile", + " :title=\"${1:title}\"", + " :description=\"${2:description}\"", + " :to=\"${3:to}\"", + " :rows=\"${4:rows}\"", + "/>", + ], + "description": "Create DsfrTile boilerplate" + }, + "DsfrTiles": { + "scope": "html", + "prefix": "DsfrTiles", + "body": [ + "<DsfrTiles :tiles=\"${1:tiles}\" />", + ], + "description": "Create DsfrTiles boilerplate" + }, + "DsfrToggleSwitch": { + "scope": "html", + "prefix": "DsfrToggleSwitch", + "body": [ + "<DsfrToggleSwitch", + " v-model=\"${1:modelValue}\"", + " :label=\"${2:label}\"", + " :hint=\"${2:hint}\"", + "/>", + ], + "description": "Create DsfrToggleSwitch boilerplate" + }, +} \ No newline at end of file diff --git a/.jjconflict-side-2/AGENTS.md b/.jjconflict-side-2/AGENTS.md new file mode 100644 index 0000000000..77866c7703 --- /dev/null +++ b/.jjconflict-side-2/AGENTS.md @@ -0,0 +1,85 @@ +# Console Cloud Pi Native + +pnpm monorepo. Node >= 24, pnpm v11.8 + +## Structure + +- `apps/client` : Vue 3 + Vite + vue-dsfr (French gov design system), Pinia, UnoCSS +- `apps/server` : Fastify 4 + Prisma 6 (PostgreSQL), contract-first API via @ts-rest +- `apps/server-nestjs` : NestJS rewrite (in progress) +- `plugins/*` : argocd, gitlab, harbor, keycloak, kubernetes, nexus, sonarqube, vault +- `packages/shared` : API contracts (@ts-rest), types, permissions (BigInt bitmasks) +- `packages/hooks` : plugin hook system (core of plugin architecture) +- `packages/test-utils`, `packages/eslintconfig`, `packages/tsconfig` +- `playwright/` : E2E tests (dedicated workspace) +- `docker/` : docker-compose files (local, dev, integ, prod, ci) +- `ci/scripts/` : init-env.sh, setup.sh, cleanup.sh, run-tests.sh +- `keycloak/` : DSFR theme and dev realm data + +## Server architecture (Fastify) + +Resource-based organization in `apps/server/src/resources/`. Each resource follows a 3-file pattern: +- `router.ts` : route handlers (auth, permissions, delegates to business) +- `business.ts` : business logic, orchestrates queries + hook calls +- `queries.ts` : Prisma database queries + +API contracts defined in `@cpn-console/shared`, shared with client via @ts-rest. +Auth: Keycloak + Fastify session. Permissions: BigInt bitmasks (`ProjectAuthorized`, `AdminAuthorized`). + +## Plugin / Hook system + +Hook lifecycle: `pre` -> `main` -> `post` (sequential steps, parallel plugin execution). On failure: `revert`. +Plugins are statically imported in `apps/server/src/plugins.ts`, then external plugins dynamically loaded from `/plugins`. +Each plugin: `index.ts` (Plugin interface), `infos.ts` (metadata/config), `functions.ts` (hook handlers). +Plugins use TS module augmentation to extend `ProjectStore` and `Config` interfaces. + +## Database (Prisma) + +Multi-file schema in `apps/server/src/prisma/schema/*.prisma` (project, user, token, admin, topography). +Migrations: standard Prisma Migrate. Major version data migrations in `migrations/v9/`. + +## Environment config + +- Files: `.env`, `.env.docker`, `.env.integ` in `apps/client/`, `apps/server/`, `apps/server-nestjs/` +- Templates: `*-example` suffix (git-tracked), active files gitignored +- Override chain (weakest to strongest): `.env` < `.env.docker` (if DOCKER=true) < `.env.integ` (if INTEGRATION=true) < explicit env vars +- Server loading: `apps/server/src/utils/env.ts` | Client: `apps/client/vite.config.ts` + +## Testing + +- **Vitest**: unit tests everywhere (server, client, packages, plugins) — colocated `*.spec.ts` files +- **Playwright**: E2E in `playwright/` (Chromium + Firefox, parallel) +- Commands: `pnpm test` (all unit), `pnpm playwright:test` + +## Code quality + +- ESLint 9 flat config based on `@antfu/eslint-config` (no Prettier, except server-nestjs) +- Stylelint for CSS/Vue in client +- Husky hooks: pre-commit (lint-staged), commit-msg (commitlint), pre-push (unit tests) +- Conventional commits enforced: `feat`, `fix`, `chore`, `docs`, `refactor`, `revert`, `build` + +## TypeScript + +- Shared base: `packages/tsconfig/tsconfig.base.json` — ESNext, NodeNext, strict, `@/* -> src/*` alias +- Server: extends shared base, uses `ts-patch`/`tspc` for path transform in emitted JS +- Client: does NOT extend shared base, uses `Bundler` module resolution +- server-nestjs: standalone config with `emitDecoratorMetadata` + `experimentalDecorators` + +## Main commands + +- `pnpm dev` : local Docker infra (keycloak, postgres, pgadmin) + server/client +- `pnpm docker:dev` : fully containerized with Docker Compose Watch +- `pnpm docker:integ` / `pnpm integ` : integration mode (remote env) +- `pnpm fullsetup` : full install (deps, prisma generate, build, Docker images) +- `pnpm fullclean` : full cleanup (builds, node_modules, Docker images) + +## Git & Release + +- Branches: `main` (protected) + `hotfix/*` +- Release Please for automated versioning, changelogs, npm publish, Docker images, Helm chart updates +- PR template: `.github/PULL_REQUEST_TEMPLATE.md` + +## Conventions + +- Template env files use `-example` suffix (not `.example`) +- `ci/scripts/init-env.sh` copies `*-example` to active equivalents (non-destructive) diff --git a/.jjconflict-side-2/CHANGELOG.md b/.jjconflict-side-2/CHANGELOG.md new file mode 100644 index 0000000000..642437ef86 --- /dev/null +++ b/.jjconflict-side-2/CHANGELOG.md @@ -0,0 +1,3507 @@ +# Changelog + +## [9.22.2](https://github.com/cloud-pi-native/console/compare/v9.22.1...v9.22.2) (2026-07-24) + + +### Bug Fixes + +* ArgoCD internal url for health check ([9429667](https://github.com/cloud-pi-native/console/commit/942966785e23fca986246948033afe60d40d7232)) + +## [9.22.1](https://github.com/cloud-pi-native/console/compare/v9.22.0...v9.22.1) (2026-07-21) + + +### Bug Fixes + +* **plugins/nexus:** remove invalid cleanup policy from repo provisioning to fix Nexus 3.85+ ([8b99cb6](https://github.com/cloud-pi-native/console/commit/8b99cb6a7807dac1f6ba525a6ee3164f3fe13141)) + +## [9.22.0](https://github.com/cloud-pi-native/console/compare/v9.21.0...v9.22.0) (2026-07-21) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** restrict deployment visibility to console admins ([def61e7](https://github.com/cloud-pi-native/console/commit/def61e76b3a867445c5ddb1c08e0f3af59b188ae)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([52c8173](https://github.com/cloud-pi-native/console/commit/52c8173f5610e83836b661d7472c0fe3ff22f70f)) +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* :construction: make all new cluster external by default ([dcd203f](https://github.com/cloud-pi-native/console/commit/dcd203f3569d93a74e0d627f6cf3eb898d6b338d)) +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* add old-server new Nest.js services to CpinModule ([a17c109](https://github.com/cloud-pi-native/console/commit/a17c109616270115af701366ae8014e05cb41e7c)) +* add server-nestjs README to explain what we are doing ([57a4ca5](https://github.com/cloud-pi-native/console/commit/57a4ca51086b7c7af44a1df83cd272b82eb9a9f8)) +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* apply prettier on old-server ([6a781ac](https://github.com/cloud-pi-native/console/commit/6a781ac80c756924c53e28815064197b63508ff4)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* bump turbo to v2.7.1 ([d955e52](https://github.com/cloud-pi-native/console/commit/d955e52e65ed829053fdb120b217cb2744bd02c2)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** trigger Playwright E2E tests before Cypress E2E tests ([2262f37](https://github.com/cloud-pi-native/console/commit/2262f3717101833217d5963580e07fb4f69e9847)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* **client:** commit auto-imports.d.ts ([4252266](https://github.com/cloud-pi-native/console/commit/42522663edf5151ce7e38c36d4eb5d2d13b24a48)) +* **client:** do not install pnpm twice ([43ff3a3](https://github.com/cloud-pi-native/console/commit/43ff3a3e6690c901d4905236b627016ee8b4f043)) +* **client:** ensure build fails if typing system is not sound ([97297b5](https://github.com/cloud-pi-native/console/commit/97297b58a80bee7167171d39b16404f077fef03b)) +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* **client:** fix types ([131b2b6](https://github.com/cloud-pi-native/console/commit/131b2b64926493d94ba1e07c3501e331d0fcac8c)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* convert cypress dashboard e2e tests to playwright ([2badd91](https://github.com/cloud-pi-native/console/commit/2badd91181d4960d40898cfa01fbcfd0b13667aa)) +* convert plugin manager to NestJS service ([2ae46e3](https://github.com/cloud-pi-native/console/commit/2ae46e38787b3521911786898bee881c23fdb08b)) +* disable tests from nest.js for now ([5137c10](https://github.com/cloud-pi-native/console/commit/5137c10ed9296c761c3d31877bf54a411bbee140)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **e2e:** avoid launching Playwright and Cypress E2E tests at the same time ([1f203a8](https://github.com/cloud-pi-native/console/commit/1f203a8d35d6b1e4e69076f289256c7192a356da)) +* **e2e:** migrate Admin Role tests from Cypress to Playwright ([2112398](https://github.com/cloud-pi-native/console/commit/2112398f85b1ab7a5e332b798f68d4f04894d51d)) +* **e2e:** migrate Duplicate Zone test from Cypress to Playwright ([748ec80](https://github.com/cloud-pi-native/console/commit/748ec80917af7ef34b1f7922a13703706e5e41cc)) +* **e2e:** migrate last Role tests from Cypress to Playwright ([176ea34](https://github.com/cloud-pi-native/console/commit/176ea3481fc5b282497ea82a7562d735b11b325f)) +* **e2e:** migrate last Zone tests from Cypress to Playwright ([dcff338](https://github.com/cloud-pi-native/console/commit/dcff3384e695e95c9d2ae2b08486ce4ca47fa769)) +* **e2e:** migrate Project archive test from Cypress to Playwright ([0567931](https://github.com/cloud-pi-native/console/commit/0567931723cccf2fc54cb5560c6dd00043211f58)) +* **e2e:** migrate repository tests from Cypress to Playwright ([8370b8b](https://github.com/cloud-pi-native/console/commit/8370b8b4adbe5c1b65bc5f45bd50c860beeba96c)) +* **e2e:** migrate some Zone tests from Cypress to Playwright ([425e8c9](https://github.com/cloud-pi-native/console/commit/425e8c9672b276507983b6b0685bcf475bdb7c6c)) +* **e2e:** migrate Token tests from Cypress to Playwright ([86cb815](https://github.com/cloud-pi-native/console/commit/86cb8158285be552a1d0e94ebd30600858b9193a)) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* fix all typescript import errors by massively creating NestJs services ([68b7cb7](https://github.com/cloud-pi-native/console/commit/68b7cb75407dbb8d630dd4a50e4832e97796008a)) +* fix any type in AppService ([627e1e9](https://github.com/cloud-pi-native/console/commit/627e1e9f0cac82bcaa9b3b3a6337c3f2b447e1e5)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) +* **gitlab-plugin:** interalize awaited import to avoid top-level-await errors ([6472c74](https://github.com/cloud-pi-native/console/commit/6472c742c9ec74f834da994db697fcfb7d385c7e)) +* **gitlab:** purge useless file ([2637156](https://github.com/cloud-pi-native/console/commit/263715641844bb7d766730a3de9240531c9f26e3)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **hotfix/revert-server-nestjs-offload:** Release v9.20.1 ([8ebeae7](https://github.com/cloud-pi-native/console/commit/8ebeae7717d0aeac93ce14102a137f4aac62c409)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* initialize nestjs dedicated app ([a533963](https://github.com/cloud-pi-native/console/commit/a53396329455c9c856274f3c419e2ff377e12dba)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) +* **main:** release 9.10.0 ([c87ad31](https://github.com/cloud-pi-native/console/commit/c87ad3114149c4af995be42e130d03efd2e301d9)) +* **main:** release 9.10.1 ([c4b4649](https://github.com/cloud-pi-native/console/commit/c4b464985cfafc7d60246621e9beb82760b73200)) +* **main:** release 9.10.2 ([20a233a](https://github.com/cloud-pi-native/console/commit/20a233af09a3ef918a7b1de36057374dd4817726)) +* **main:** release 9.11.0 ([c03e2ef](https://github.com/cloud-pi-native/console/commit/c03e2ef8c4e448784528ae7dee68edff36e48930)) +* **main:** release 9.11.1 ([7580384](https://github.com/cloud-pi-native/console/commit/7580384525c7cdddbd51e7b0b9b4154ca624b058)) +* **main:** release 9.11.2 ([c7de3a1](https://github.com/cloud-pi-native/console/commit/c7de3a15bd54730655e3a75523c29e9f313a18d9)) +* **main:** release 9.11.3 ([9d2df48](https://github.com/cloud-pi-native/console/commit/9d2df48e3588a819fe5a63467dee773c5e1963c3)) +* **main:** release 9.11.4 ([9c873e1](https://github.com/cloud-pi-native/console/commit/9c873e1b5d1ec3d59b86b6ae19b0fe055f3ed669)) +* **main:** release 9.11.5 ([5fc68de](https://github.com/cloud-pi-native/console/commit/5fc68de5cf2a22f33171f03132798df20701df1b)) +* **main:** release 9.8.1 ([bb94301](https://github.com/cloud-pi-native/console/commit/bb9430196e042847931719ec518d5b9685a67585)) +* **main:** release 9.9.0 ([aa755da](https://github.com/cloud-pi-native/console/commit/aa755dac97ffd563bb0684fe669268b95c6d1362)) +* **main:** Release v9.12.0 ([3a14b03](https://github.com/cloud-pi-native/console/commit/3a14b03a7284ee8ee8a5e9d54d7ba842832c4017)) +* **main:** Release v9.12.0 ([4f6e1a8](https://github.com/cloud-pi-native/console/commit/4f6e1a8d8b630335c89a12b4daf4941e1dcdc32a)) +* **main:** Release v9.13.0 ([4daaa1d](https://github.com/cloud-pi-native/console/commit/4daaa1dce01276f83c6870ffe1c13af096547621)) +* **main:** Release v9.13.0 ([613ba30](https://github.com/cloud-pi-native/console/commit/613ba30b9f7f41e6db64fa8957709bca96e53847)) +* **main:** Release v9.13.1 ([0b39ff1](https://github.com/cloud-pi-native/console/commit/0b39ff1747d65fae9d4d4c6409421a30b1c57fc4)) +* **main:** Release v9.13.1 ([96ed6a3](https://github.com/cloud-pi-native/console/commit/96ed6a318a74978a9c2b8713d65100f1c0ca2f3d)) +* **main:** Release v9.13.2 ([400e9a7](https://github.com/cloud-pi-native/console/commit/400e9a71b28879c2bcd382ed4a43f55757d7aa2e)) +* **main:** Release v9.13.2 ([e2d09e3](https://github.com/cloud-pi-native/console/commit/e2d09e3f476fc958967089bc0ac9c5054b7eb5ac)) +* **main:** Release v9.14.0 ([5c62e19](https://github.com/cloud-pi-native/console/commit/5c62e191e0ab426dff4b5c390817a15345e3bdb0)) +* **main:** Release v9.14.1 ([8bccc81](https://github.com/cloud-pi-native/console/commit/8bccc81517f8cf39ddc5e1be972d4efa701b5e41)) +* **main:** Release v9.15.0 ([7b50776](https://github.com/cloud-pi-native/console/commit/7b50776d35fa73d1a28f6fb21010ffad85bafaf1)) +* **main:** Release v9.16.0 ([68abf2e](https://github.com/cloud-pi-native/console/commit/68abf2e5afd1fac52fad95c1319a4b50e8ac6275)) +* **main:** Release v9.16.1 ([3ba4c0b](https://github.com/cloud-pi-native/console/commit/3ba4c0bf08d899b614875cac99af7345a61998c8)) +* **main:** Release v9.17.0 ([a4061a6](https://github.com/cloud-pi-native/console/commit/a4061a61137c40c5b496e6649645b0b017723407)) +* **main:** Release v9.17.1 ([f8f7952](https://github.com/cloud-pi-native/console/commit/f8f795208ce32b5539e4d06c84f8579d1ae7ad2d)) +* **main:** Release v9.18.0 ([775bd1e](https://github.com/cloud-pi-native/console/commit/775bd1e82a598278ae2317bb775054b9e0627a36)) +* **main:** Release v9.18.1 ([261d9a3](https://github.com/cloud-pi-native/console/commit/261d9a367b71e8598c7956e4b76b612550244ffd)) +* **main:** Release v9.18.2 ([0a75369](https://github.com/cloud-pi-native/console/commit/0a753695320428332119401d6de53dc576538d72)) +* **main:** Release v9.18.3 ([f8cadc4](https://github.com/cloud-pi-native/console/commit/f8cadc47d40393c8fd040b0740957cff39ae6cb4)) +* **main:** Release v9.18.4 ([71dc32b](https://github.com/cloud-pi-native/console/commit/71dc32bf1f3d16a87a39f5d6378f3b7876bf2e42)) +* **main:** Release v9.19.0 ([c7d0f41](https://github.com/cloud-pi-native/console/commit/c7d0f41d7ecfd5d4645872de743d83d90b97bdab)) +* **main:** Release v9.20.0 ([9ee41b3](https://github.com/cloud-pi-native/console/commit/9ee41b36597cdd79c632ae3b2d3eb5b032781819)) +* **main:** Release v9.20.2 ([4075691](https://github.com/cloud-pi-native/console/commit/4075691de972a9d181dbed02a9aa2d801dc026f2)) +* **main:** Release v9.21.0 ([27e7f3a](https://github.com/cloud-pi-native/console/commit/27e7f3acff20927d8753d6bc67cebc4ed686f5ab)) +* move pnpm config to pnpm-workspace.yaml to avoid future issues with .npmrc ([00974c9](https://github.com/cloud-pi-native/console/commit/00974c9fcb98d223923c5062ea854ffd5ddeeb95)) +* move the entirety of apps/server in server-nestjs ([8437837](https://github.com/cloud-pi-native/console/commit/8437837fadca7dd79ec76cd3f004a83881891c29)) +* **nest-js:** add basic config and logger modules based on existing implementation ([429e4ba](https://github.com/cloud-pi-native/console/commit/429e4ba046f076e65320c3b4714aea878c99b754)) +* **nest-js:** initialize a bunch of module/services to move old-server code ([935f1d7](https://github.com/cloud-pi-native/console/commit/935f1d7fdef9ea68a404f01be07fa1b62b670dfd)) +* **nest-js:** update old-server code from main ([09f3729](https://github.com/cloud-pi-native/console/commit/09f3729078482abce704a2e885f6230134071f57)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** don't bother with webkit for now ([7d80b33](https://github.com/cloud-pi-native/console/commit/7d80b33f6ed5d364b526475e312679330825bd3c)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **playwright:** migrate E2E Project logs tests from Cypress ([089f0bc](https://github.com/cloud-pi-native/console/commit/089f0bcb6f443bc43c2936d039088b76bab10a01)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* **plugins:** update formatting ([86f8bad](https://github.com/cloud-pi-native/console/commit/86f8badfc617809d6f10e9c869b9fbce201de953)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* remove .js extensions from imports ([c8774f3](https://github.com/cloud-pi-native/console/commit/c8774f323c45ac85269d5c47eecf0813c632f879)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) +* remove configuration for tools that are not explicitely supported on the project ([e1be3d5](https://github.com/cloud-pi-native/console/commit/e1be3d5903a8a85d651785b6ef747040b9f29a41)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* rename nest.js base AppModule to MainModule for clarification with our own AppService ([8a20228](https://github.com/cloud-pi-native/console/commit/8a2022815fe363aa5626b0b4fcc8ab832dca12de)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) +* **server-nestjs:** add code to DatabaseService and thus finalize ApplicationInitializationService ([b12c207](https://github.com/cloud-pi-native/console/commit/b12c2079aaa944282ff5365504baf0095bd14e84)) +* **server-nestjs:** add code to PluginManagementService ([2d3133d](https://github.com/cloud-pi-native/console/commit/2d3133d5203fc94e71d3e4a2dfb5d4fc4e6bd784)) +* **server-nestjs:** add database initialization code in DatabaseInitializationService ([73c27d9](https://github.com/cloud-pi-native/console/commit/73c27d93ab779155a7e2c1c353909f3e5338b818)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) +* **server-nestjs:** convert all router services to NestJS classes ([2dd2c3e](https://github.com/cloud-pi-native/console/commit/2dd2c3ea697c6b508e89e107a689f79a8b39a6a1)) +* **server-nestjs:** delete unneeded code ([bbe07b2](https://github.com/cloud-pi-native/console/commit/bbe07b27bfd3d6c198e17a642cc441c919392d85)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** fix a fastify type issue ([868f1bc](https://github.com/cloud-pi-native/console/commit/868f1bc813f880963d13bccffd7814a54166f17a)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* **server-nestjs:** fix pnpm format command ([133254c](https://github.com/cloud-pi-native/console/commit/133254c1d285f45bb0385fda11d2f5b700f4ab20)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* **server-nestjs:** initialize core module and its AppService ([91d9596](https://github.com/cloud-pi-native/console/commit/91d9596765ced2a941f8e31c514e2b6c3969ef95)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove now useless old-server directory ([47d7536](https://github.com/cloud-pi-native/console/commit/47d7536a66e152b64fd69b0c57f72ab23a27d0e0)) +* **server-nestjs:** remove Router module ([8104360](https://github.com/cloud-pi-native/console/commit/8104360de9236f0f2c61dba2273b6d8cb368a356)) +* **server-nestjs:** remove test scripts (we'll migrate to vitest soon) ([afc4e91](https://github.com/cloud-pi-native/console/commit/afc4e912e88084a073b00e5213b2eed182b6c78b)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rename vite file to avoid compilation issues for now ([bd15699](https://github.com/cloud-pi-native/console/commit/bd15699e39d11ddbb6304366a41d67b8564c425a)) +* **server-nestjs:** retrieve prisma directory from old-server as-is ([a89fd36](https://github.com/cloud-pi-native/console/commit/a89fd368a1bc1c4d3eb1e535b0d4d776322e622f)) +* **server-nestjs:** rework configuration service to properly instanciate logger service ([64a113d](https://github.com/cloud-pi-native/console/commit/64a113d6591739557d459562acc57456fc3f74b9)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **server-nestjs:** update README with a plan for the future ([fc336d4](https://github.com/cloud-pi-native/console/commit/fc336d4b6793898a9190f585b39a9ba63c5e528b)) +* **server-nestjs:** update README with the modularization target ([d98bdfa](https://github.com/cloud-pi-native/console/commit/d98bdfa428290e7a5358a7caf33137a13815239c)) +* **server-nestjs:** use old-server eslint config ([d0f80ee](https://github.com/cloud-pi-native/console/commit/d0f80ee7bf88b7d26407b78a9792af699fb463e8)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* start integration old-server into nestjs ([d83b1cd](https://github.com/cloud-pi-native/console/commit/d83b1cdf47ba140a50a83da02c1c6a8fb024c9b9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) +* update browserlist/caniuse-lite ([23617f9](https://github.com/cloud-pi-native/console/commit/23617f9e051caf7e600360a92bae76ee6c8af575)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* update old-server import aliases ([d89735e](https://github.com/cloud-pi-native/console/commit/d89735e299b541efa4967a835c3cd0ece7d1ffbb)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) +* **vault:** explicit that VaultApi base class is abstract and must not be exported ([3673c4c](https://github.com/cloud-pi-native/console/commit/3673c4cf96bfc40bfac114fe7b67dee425b372da)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Docs + +* :memo: add notes regarding services communication ([ca4a057](https://github.com/cloud-pi-native/console/commit/ca4a0578d3e815f93aa1e57cbb880601354d600d)) +* :memo: Ajout du lien vers le MODOP de gestion des versions de dépendances ([7151293](https://github.com/cloud-pi-native/console/commit/715129312a6150f887298d1dc493ef7876c51e28)) +* :memo: update release documentation ([cea92a9](https://github.com/cloud-pi-native/console/commit/cea92a9aa651af68499ae318e4a7c27e037c706c)) +* :memo: use Nexus internal url for CI jobs ([2193ec3](https://github.com/cloud-pi-native/console/commit/2193ec3041fb0d78cc7a1fbe841fd451d2a7b095)) +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + + +### Refactoring + +* :bookmark: add tracing information on Argo apps ([340299b](https://github.com/cloud-pi-native/console/commit/340299b58e702a4258c5d767e3fdf4fc692f7d73)) +* :fire: remove AppRole enabled check to simplify the code ([95c7159](https://github.com/cloud-pi-native/console/commit/95c71595d7aac32fa211d18880fd5e7ed3bf4c53)) +* add PluginResultBuilder for improved result management in project upsert ([80d5bc4](https://github.com/cloud-pi-native/console/commit/80d5bc498422c1eb79134c54c9facc9c7a15e279)) +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) +* split Gitlab classes in separate files ([0d673a5](https://github.com/cloud-pi-native/console/commit/0d673a5d282e9dc82a816d16ac18c8452f740c1f)) +* split Vault classes in separate files ([15bc0ce](https://github.com/cloud-pi-native/console/commit/15bc0ce32dc74e8719c7a202a2e3e406aee50762)) + + +### Reverted commits + +* "feat(server-nestjs): add autoSync and suspended flags to plugin services" ([2116663](https://github.com/cloud-pi-native/console/commit/2116663621cf1d522ca73a0e76ea6b9ed4f9ff6d)) +* "fix: playwright test expect mock" ([d2a746c](https://github.com/cloud-pi-native/console/commit/d2a746c237af4d1cbfe38e8d090599bf26176525)) +* "refactor(server-nestjs): offload project route to new implementation" ([8a964bc](https://github.com/cloud-pi-native/console/commit/8a964bc5d2d223e9c2f2d975cc703a18be81fc45)) +* **client:** switch environments back from API v2 ([933977c](https://github.com/cloud-pi-native/console/commit/933977c8b3434d3db97c0f4300bb2d4bcc393588)) + +## [9.21.0](https://github.com/cloud-pi-native/console/compare/v9.20.2...v9.21.0) (2026-07-21) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** restrict deployment visibility to console admins ([def61e7](https://github.com/cloud-pi-native/console/commit/def61e76b3a867445c5ddb1c08e0f3af59b188ae)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([52c8173](https://github.com/cloud-pi-native/console/commit/52c8173f5610e83836b661d7472c0fe3ff22f70f)) +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* :construction: make all new cluster external by default ([dcd203f](https://github.com/cloud-pi-native/console/commit/dcd203f3569d93a74e0d627f6cf3eb898d6b338d)) +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* add old-server new Nest.js services to CpinModule ([a17c109](https://github.com/cloud-pi-native/console/commit/a17c109616270115af701366ae8014e05cb41e7c)) +* add server-nestjs README to explain what we are doing ([57a4ca5](https://github.com/cloud-pi-native/console/commit/57a4ca51086b7c7af44a1df83cd272b82eb9a9f8)) +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* apply prettier on old-server ([6a781ac](https://github.com/cloud-pi-native/console/commit/6a781ac80c756924c53e28815064197b63508ff4)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* bump turbo to v2.7.1 ([d955e52](https://github.com/cloud-pi-native/console/commit/d955e52e65ed829053fdb120b217cb2744bd02c2)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** trigger Playwright E2E tests before Cypress E2E tests ([2262f37](https://github.com/cloud-pi-native/console/commit/2262f3717101833217d5963580e07fb4f69e9847)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* **client:** commit auto-imports.d.ts ([4252266](https://github.com/cloud-pi-native/console/commit/42522663edf5151ce7e38c36d4eb5d2d13b24a48)) +* **client:** do not install pnpm twice ([43ff3a3](https://github.com/cloud-pi-native/console/commit/43ff3a3e6690c901d4905236b627016ee8b4f043)) +* **client:** ensure build fails if typing system is not sound ([97297b5](https://github.com/cloud-pi-native/console/commit/97297b58a80bee7167171d39b16404f077fef03b)) +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* **client:** fix types ([131b2b6](https://github.com/cloud-pi-native/console/commit/131b2b64926493d94ba1e07c3501e331d0fcac8c)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* convert cypress dashboard e2e tests to playwright ([2badd91](https://github.com/cloud-pi-native/console/commit/2badd91181d4960d40898cfa01fbcfd0b13667aa)) +* convert plugin manager to NestJS service ([2ae46e3](https://github.com/cloud-pi-native/console/commit/2ae46e38787b3521911786898bee881c23fdb08b)) +* disable tests from nest.js for now ([5137c10](https://github.com/cloud-pi-native/console/commit/5137c10ed9296c761c3d31877bf54a411bbee140)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **e2e:** avoid launching Playwright and Cypress E2E tests at the same time ([1f203a8](https://github.com/cloud-pi-native/console/commit/1f203a8d35d6b1e4e69076f289256c7192a356da)) +* **e2e:** migrate Admin Role tests from Cypress to Playwright ([2112398](https://github.com/cloud-pi-native/console/commit/2112398f85b1ab7a5e332b798f68d4f04894d51d)) +* **e2e:** migrate Duplicate Zone test from Cypress to Playwright ([748ec80](https://github.com/cloud-pi-native/console/commit/748ec80917af7ef34b1f7922a13703706e5e41cc)) +* **e2e:** migrate last Role tests from Cypress to Playwright ([176ea34](https://github.com/cloud-pi-native/console/commit/176ea3481fc5b282497ea82a7562d735b11b325f)) +* **e2e:** migrate last Zone tests from Cypress to Playwright ([dcff338](https://github.com/cloud-pi-native/console/commit/dcff3384e695e95c9d2ae2b08486ce4ca47fa769)) +* **e2e:** migrate profile tests from Cypress to Playwright ([d7201b6](https://github.com/cloud-pi-native/console/commit/d7201b6d253d342f6a1656d179d0a9d322745c33)) +* **e2e:** migrate Project archive test from Cypress to Playwright ([0567931](https://github.com/cloud-pi-native/console/commit/0567931723cccf2fc54cb5560c6dd00043211f58)) +* **e2e:** migrate repository tests from Cypress to Playwright ([8370b8b](https://github.com/cloud-pi-native/console/commit/8370b8b4adbe5c1b65bc5f45bd50c860beeba96c)) +* **e2e:** migrate some Zone tests from Cypress to Playwright ([425e8c9](https://github.com/cloud-pi-native/console/commit/425e8c9672b276507983b6b0685bcf475bdb7c6c)) +* **e2e:** migrate Token tests from Cypress to Playwright ([86cb815](https://github.com/cloud-pi-native/console/commit/86cb8158285be552a1d0e94ebd30600858b9193a)) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* fix all typescript import errors by massively creating NestJs services ([68b7cb7](https://github.com/cloud-pi-native/console/commit/68b7cb75407dbb8d630dd4a50e4832e97796008a)) +* fix any type in AppService ([627e1e9](https://github.com/cloud-pi-native/console/commit/627e1e9f0cac82bcaa9b3b3a6337c3f2b447e1e5)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) +* **gitlab-plugin:** interalize awaited import to avoid top-level-await errors ([6472c74](https://github.com/cloud-pi-native/console/commit/6472c742c9ec74f834da994db697fcfb7d385c7e)) +* **gitlab:** purge useless file ([2637156](https://github.com/cloud-pi-native/console/commit/263715641844bb7d766730a3de9240531c9f26e3)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **hotfix/revert-server-nestjs-offload:** Release v9.20.1 ([8ebeae7](https://github.com/cloud-pi-native/console/commit/8ebeae7717d0aeac93ce14102a137f4aac62c409)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* initialize nestjs dedicated app ([a533963](https://github.com/cloud-pi-native/console/commit/a53396329455c9c856274f3c419e2ff377e12dba)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) +* **main:** release 9.10.0 ([c87ad31](https://github.com/cloud-pi-native/console/commit/c87ad3114149c4af995be42e130d03efd2e301d9)) +* **main:** release 9.10.1 ([c4b4649](https://github.com/cloud-pi-native/console/commit/c4b464985cfafc7d60246621e9beb82760b73200)) +* **main:** release 9.10.2 ([20a233a](https://github.com/cloud-pi-native/console/commit/20a233af09a3ef918a7b1de36057374dd4817726)) +* **main:** release 9.11.0 ([c03e2ef](https://github.com/cloud-pi-native/console/commit/c03e2ef8c4e448784528ae7dee68edff36e48930)) +* **main:** release 9.11.1 ([7580384](https://github.com/cloud-pi-native/console/commit/7580384525c7cdddbd51e7b0b9b4154ca624b058)) +* **main:** release 9.11.2 ([c7de3a1](https://github.com/cloud-pi-native/console/commit/c7de3a15bd54730655e3a75523c29e9f313a18d9)) +* **main:** release 9.11.3 ([9d2df48](https://github.com/cloud-pi-native/console/commit/9d2df48e3588a819fe5a63467dee773c5e1963c3)) +* **main:** release 9.11.4 ([9c873e1](https://github.com/cloud-pi-native/console/commit/9c873e1b5d1ec3d59b86b6ae19b0fe055f3ed669)) +* **main:** release 9.11.5 ([5fc68de](https://github.com/cloud-pi-native/console/commit/5fc68de5cf2a22f33171f03132798df20701df1b)) +* **main:** release 9.8.1 ([bb94301](https://github.com/cloud-pi-native/console/commit/bb9430196e042847931719ec518d5b9685a67585)) +* **main:** release 9.9.0 ([aa755da](https://github.com/cloud-pi-native/console/commit/aa755dac97ffd563bb0684fe669268b95c6d1362)) +* **main:** Release v9.12.0 ([3a14b03](https://github.com/cloud-pi-native/console/commit/3a14b03a7284ee8ee8a5e9d54d7ba842832c4017)) +* **main:** Release v9.12.0 ([4f6e1a8](https://github.com/cloud-pi-native/console/commit/4f6e1a8d8b630335c89a12b4daf4941e1dcdc32a)) +* **main:** Release v9.13.0 ([4daaa1d](https://github.com/cloud-pi-native/console/commit/4daaa1dce01276f83c6870ffe1c13af096547621)) +* **main:** Release v9.13.0 ([613ba30](https://github.com/cloud-pi-native/console/commit/613ba30b9f7f41e6db64fa8957709bca96e53847)) +* **main:** Release v9.13.1 ([0b39ff1](https://github.com/cloud-pi-native/console/commit/0b39ff1747d65fae9d4d4c6409421a30b1c57fc4)) +* **main:** Release v9.13.1 ([96ed6a3](https://github.com/cloud-pi-native/console/commit/96ed6a318a74978a9c2b8713d65100f1c0ca2f3d)) +* **main:** Release v9.13.2 ([400e9a7](https://github.com/cloud-pi-native/console/commit/400e9a71b28879c2bcd382ed4a43f55757d7aa2e)) +* **main:** Release v9.13.2 ([e2d09e3](https://github.com/cloud-pi-native/console/commit/e2d09e3f476fc958967089bc0ac9c5054b7eb5ac)) +* **main:** Release v9.14.0 ([5c62e19](https://github.com/cloud-pi-native/console/commit/5c62e191e0ab426dff4b5c390817a15345e3bdb0)) +* **main:** Release v9.14.1 ([8bccc81](https://github.com/cloud-pi-native/console/commit/8bccc81517f8cf39ddc5e1be972d4efa701b5e41)) +* **main:** Release v9.15.0 ([7b50776](https://github.com/cloud-pi-native/console/commit/7b50776d35fa73d1a28f6fb21010ffad85bafaf1)) +* **main:** Release v9.16.0 ([68abf2e](https://github.com/cloud-pi-native/console/commit/68abf2e5afd1fac52fad95c1319a4b50e8ac6275)) +* **main:** Release v9.16.1 ([3ba4c0b](https://github.com/cloud-pi-native/console/commit/3ba4c0bf08d899b614875cac99af7345a61998c8)) +* **main:** Release v9.17.0 ([a4061a6](https://github.com/cloud-pi-native/console/commit/a4061a61137c40c5b496e6649645b0b017723407)) +* **main:** Release v9.17.1 ([f8f7952](https://github.com/cloud-pi-native/console/commit/f8f795208ce32b5539e4d06c84f8579d1ae7ad2d)) +* **main:** Release v9.18.0 ([775bd1e](https://github.com/cloud-pi-native/console/commit/775bd1e82a598278ae2317bb775054b9e0627a36)) +* **main:** Release v9.18.1 ([261d9a3](https://github.com/cloud-pi-native/console/commit/261d9a367b71e8598c7956e4b76b612550244ffd)) +* **main:** Release v9.18.2 ([0a75369](https://github.com/cloud-pi-native/console/commit/0a753695320428332119401d6de53dc576538d72)) +* **main:** Release v9.18.3 ([f8cadc4](https://github.com/cloud-pi-native/console/commit/f8cadc47d40393c8fd040b0740957cff39ae6cb4)) +* **main:** Release v9.18.4 ([71dc32b](https://github.com/cloud-pi-native/console/commit/71dc32bf1f3d16a87a39f5d6378f3b7876bf2e42)) +* **main:** Release v9.19.0 ([c7d0f41](https://github.com/cloud-pi-native/console/commit/c7d0f41d7ecfd5d4645872de743d83d90b97bdab)) +* **main:** Release v9.20.0 ([9ee41b3](https://github.com/cloud-pi-native/console/commit/9ee41b36597cdd79c632ae3b2d3eb5b032781819)) +* **main:** Release v9.20.2 ([4075691](https://github.com/cloud-pi-native/console/commit/4075691de972a9d181dbed02a9aa2d801dc026f2)) +* move pnpm config to pnpm-workspace.yaml to avoid future issues with .npmrc ([00974c9](https://github.com/cloud-pi-native/console/commit/00974c9fcb98d223923c5062ea854ffd5ddeeb95)) +* move the entirety of apps/server in server-nestjs ([8437837](https://github.com/cloud-pi-native/console/commit/8437837fadca7dd79ec76cd3f004a83881891c29)) +* **nest-js:** add basic config and logger modules based on existing implementation ([429e4ba](https://github.com/cloud-pi-native/console/commit/429e4ba046f076e65320c3b4714aea878c99b754)) +* **nest-js:** initialize a bunch of module/services to move old-server code ([935f1d7](https://github.com/cloud-pi-native/console/commit/935f1d7fdef9ea68a404f01be07fa1b62b670dfd)) +* **nest-js:** update old-server code from main ([09f3729](https://github.com/cloud-pi-native/console/commit/09f3729078482abce704a2e885f6230134071f57)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** don't bother with webkit for now ([7d80b33](https://github.com/cloud-pi-native/console/commit/7d80b33f6ed5d364b526475e312679330825bd3c)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **playwright:** migrate E2E Project logs tests from Cypress ([089f0bc](https://github.com/cloud-pi-native/console/commit/089f0bcb6f443bc43c2936d039088b76bab10a01)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* **plugins:** update formatting ([86f8bad](https://github.com/cloud-pi-native/console/commit/86f8badfc617809d6f10e9c869b9fbce201de953)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* remove .js extensions from imports ([c8774f3](https://github.com/cloud-pi-native/console/commit/c8774f323c45ac85269d5c47eecf0813c632f879)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) +* remove configuration for tools that are not explicitely supported on the project ([e1be3d5](https://github.com/cloud-pi-native/console/commit/e1be3d5903a8a85d651785b6ef747040b9f29a41)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* rename nest.js base AppModule to MainModule for clarification with our own AppService ([8a20228](https://github.com/cloud-pi-native/console/commit/8a2022815fe363aa5626b0b4fcc8ab832dca12de)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) +* **server-nestjs:** add code to DatabaseService and thus finalize ApplicationInitializationService ([b12c207](https://github.com/cloud-pi-native/console/commit/b12c2079aaa944282ff5365504baf0095bd14e84)) +* **server-nestjs:** add code to PluginManagementService ([2d3133d](https://github.com/cloud-pi-native/console/commit/2d3133d5203fc94e71d3e4a2dfb5d4fc4e6bd784)) +* **server-nestjs:** add database initialization code in DatabaseInitializationService ([73c27d9](https://github.com/cloud-pi-native/console/commit/73c27d93ab779155a7e2c1c353909f3e5338b818)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) +* **server-nestjs:** convert all router services to NestJS classes ([2dd2c3e](https://github.com/cloud-pi-native/console/commit/2dd2c3ea697c6b508e89e107a689f79a8b39a6a1)) +* **server-nestjs:** delete unneeded code ([bbe07b2](https://github.com/cloud-pi-native/console/commit/bbe07b27bfd3d6c198e17a642cc441c919392d85)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** fix a fastify type issue ([868f1bc](https://github.com/cloud-pi-native/console/commit/868f1bc813f880963d13bccffd7814a54166f17a)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* **server-nestjs:** fix pnpm format command ([133254c](https://github.com/cloud-pi-native/console/commit/133254c1d285f45bb0385fda11d2f5b700f4ab20)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* **server-nestjs:** initialize core module and its AppService ([91d9596](https://github.com/cloud-pi-native/console/commit/91d9596765ced2a941f8e31c514e2b6c3969ef95)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove now useless old-server directory ([47d7536](https://github.com/cloud-pi-native/console/commit/47d7536a66e152b64fd69b0c57f72ab23a27d0e0)) +* **server-nestjs:** remove Router module ([8104360](https://github.com/cloud-pi-native/console/commit/8104360de9236f0f2c61dba2273b6d8cb368a356)) +* **server-nestjs:** remove test scripts (we'll migrate to vitest soon) ([afc4e91](https://github.com/cloud-pi-native/console/commit/afc4e912e88084a073b00e5213b2eed182b6c78b)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rename vite file to avoid compilation issues for now ([bd15699](https://github.com/cloud-pi-native/console/commit/bd15699e39d11ddbb6304366a41d67b8564c425a)) +* **server-nestjs:** retrieve prisma directory from old-server as-is ([a89fd36](https://github.com/cloud-pi-native/console/commit/a89fd368a1bc1c4d3eb1e535b0d4d776322e622f)) +* **server-nestjs:** rework configuration service to properly instanciate logger service ([64a113d](https://github.com/cloud-pi-native/console/commit/64a113d6591739557d459562acc57456fc3f74b9)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **server-nestjs:** update README with a plan for the future ([fc336d4](https://github.com/cloud-pi-native/console/commit/fc336d4b6793898a9190f585b39a9ba63c5e528b)) +* **server-nestjs:** update README with the modularization target ([d98bdfa](https://github.com/cloud-pi-native/console/commit/d98bdfa428290e7a5358a7caf33137a13815239c)) +* **server-nestjs:** use old-server eslint config ([d0f80ee](https://github.com/cloud-pi-native/console/commit/d0f80ee7bf88b7d26407b78a9792af699fb463e8)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* start integration old-server into nestjs ([d83b1cd](https://github.com/cloud-pi-native/console/commit/d83b1cdf47ba140a50a83da02c1c6a8fb024c9b9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) +* update browserlist/caniuse-lite ([23617f9](https://github.com/cloud-pi-native/console/commit/23617f9e051caf7e600360a92bae76ee6c8af575)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* update old-server import aliases ([d89735e](https://github.com/cloud-pi-native/console/commit/d89735e299b541efa4967a835c3cd0ece7d1ffbb)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) +* **vault:** explicit that VaultApi base class is abstract and must not be exported ([3673c4c](https://github.com/cloud-pi-native/console/commit/3673c4cf96bfc40bfac114fe7b67dee425b372da)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Docs + +* :memo: add notes regarding services communication ([ca4a057](https://github.com/cloud-pi-native/console/commit/ca4a0578d3e815f93aa1e57cbb880601354d600d)) +* :memo: Ajout du lien vers le MODOP de gestion des versions de dépendances ([7151293](https://github.com/cloud-pi-native/console/commit/715129312a6150f887298d1dc493ef7876c51e28)) +* :memo: update release documentation ([cea92a9](https://github.com/cloud-pi-native/console/commit/cea92a9aa651af68499ae318e4a7c27e037c706c)) +* :memo: use Nexus internal url for CI jobs ([2193ec3](https://github.com/cloud-pi-native/console/commit/2193ec3041fb0d78cc7a1fbe841fd451d2a7b095)) +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + + +### Refactoring + +* :bookmark: add tracing information on Argo apps ([340299b](https://github.com/cloud-pi-native/console/commit/340299b58e702a4258c5d767e3fdf4fc692f7d73)) +* :fire: remove AppRole enabled check to simplify the code ([95c7159](https://github.com/cloud-pi-native/console/commit/95c71595d7aac32fa211d18880fd5e7ed3bf4c53)) +* add PluginResultBuilder for improved result management in project upsert ([80d5bc4](https://github.com/cloud-pi-native/console/commit/80d5bc498422c1eb79134c54c9facc9c7a15e279)) +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) +* split Gitlab classes in separate files ([0d673a5](https://github.com/cloud-pi-native/console/commit/0d673a5d282e9dc82a816d16ac18c8452f740c1f)) +* split Vault classes in separate files ([15bc0ce](https://github.com/cloud-pi-native/console/commit/15bc0ce32dc74e8719c7a202a2e3e406aee50762)) + + +### Reverted commits + +* "feat(server-nestjs): add autoSync and suspended flags to plugin services" ([2116663](https://github.com/cloud-pi-native/console/commit/2116663621cf1d522ca73a0e76ea6b9ed4f9ff6d)) +* "fix: playwright test expect mock" ([d2a746c](https://github.com/cloud-pi-native/console/commit/d2a746c237af4d1cbfe38e8d090599bf26176525)) +* "refactor(server-nestjs): offload project route to new implementation" ([8a964bc](https://github.com/cloud-pi-native/console/commit/8a964bc5d2d223e9c2f2d975cc703a18be81fc45)) +* **client:** switch environments back from API v2 ([933977c](https://github.com/cloud-pi-native/console/commit/933977c8b3434d3db97c0f4300bb2d4bcc393588)) + +## [9.20.2](https://github.com/cloud-pi-native/console/compare/v9.20.1...v9.20.2) (2026-07-21) + + +### Bug Fixes + +* **harbor:** migrate robot management off removed robotv1 API for Harbor 2.13+ ([424b1c2](https://github.com/cloud-pi-native/console/commit/424b1c2c3c0bf085b33dfea25a2ae01a79182ad9)) +* **server-nestjs:** align @types/node to v24 to keep workspace libs linked ([2ba4fd5](https://github.com/cloud-pi-native/console/commit/2ba4fd5966bbcb712c793f07c3915ebc58880144)) + +## [9.20.1](https://github.com/cloud-pi-native/console/compare/v9.20.0...v9.20.1) (2026-07-17) + + +### Bug Fixes + +* **client:** restrict deployment visibility to console admins ([d836948](https://github.com/cloud-pi-native/console/commit/d836948e2c6689445d473c562a2be189873c75af)) + + +### Reverted commits + +* **client:** switch environments back from API v2 ([9c1245a](https://github.com/cloud-pi-native/console/commit/9c1245a56f650d4819f27f926457810f33b67753)) +* feat(server-nestjs): add autoSync and suspended flags to plugin services ([a48697d](https://github.com/cloud-pi-native/console/commit/a48697d46fad4fbbf54567988d0e99433cb5c4cc)) +* fix: playwright test expect mock ([828db42](https://github.com/cloud-pi-native/console/commit/828db421f6ce4f7eda293a744fda9f22882f7a8c)) +* refactor(server-nestjs): offload project route to new implementation ([99a7f5d](https://github.com/cloud-pi-native/console/commit/99a7f5d29becefcdad24331004263756a6950774)) + +## [9.20.0](https://github.com/cloud-pi-native/console/compare/v9.19.0...v9.20.0) (2026-07-17) + +### Features + +* allow ci run on draft PR ([d3771a2](https://github.com/cloud-pi-native/console/commit/d3771a2665930560a42cbdd6619728d58a071ac4)) +* **client:** add deployment management UI ([08e212d](https://github.com/cloud-pi-native/console/commit/08e212d010d74206b15ce050e0708a558a60af0a)) +* **client:** switch environments to API v2 ([20d97dd](https://github.com/cloud-pi-native/console/commit/20d97dd46022c2bb3aa01ac896c5cfd1a7d8d1b1)) +* fix the permission policy engine from AND to OR between admin and projectpermissions ([4147cc9](https://github.com/cloud-pi-native/console/commit/4147cc94906a4ec2ea2fb9de0b22f3951e0ef49b)) +* migrate project to PNPM v11.8.0 ([6143d5c](https://github.com/cloud-pi-native/console/commit/6143d5c0db1ece4a9ecd694c89cd4fca9bbc725c)) +* migrate service infos descriptor from server plugin manager ([20e725c](https://github.com/cloud-pi-native/console/commit/20e725c25ad1a4be919bdef18aa1ea0761fc424b)) +* **server-nestjs:** add autoSync and suspended flags to plugin services ([4142e01](https://github.com/cloud-pi-native/console/commit/4142e0147d581884248f8a2fd8e3653fab09fae3)) +* **server-nestjs:** add environment API v2 ([9634d83](https://github.com/cloud-pi-native/console/commit/9634d8376d447c94dd9f1c9514a6c99c5c3b96b7)) +* **server-nestjs:** capture plugin handler outcomes as structured results ([e635708](https://github.com/cloud-pi-native/console/commit/e635708f8a021f49a3ea801df8d02975d8e24f75)) +* **server-nestjs:** introduce AppEventsService for project events ([1cf6dfa](https://github.com/cloud-pi-native/console/commit/1cf6dfa10fe200ea540c27c3d7afc4159656fff7)) +* **server-nestjs:** secure deployment API with project permission guards ([1ea4d32](https://github.com/cloud-pi-native/console/commit/1ea4d3293841a6ba2269e3f25f6dbc1bc96240db)) + + +### Bug Fixes + +* configuration may be flaky depending on runner env vars ([ff5c62a](https://github.com/cloud-pi-native/console/commit/ff5c62a0e347eeab399db4aa38900c1b5ba52f03)) +* correctly type JWT non standard payload ([efdd446](https://github.com/cloud-pi-native/console/commit/efdd446597d6454eda9a28e7adaacbe757e465a3)) +* disable NestJS reconciler loop to avoid conflicts ([51b5488](https://github.com/cloud-pi-native/console/commit/51b5488eab96edb92dd444a929e6aaaadd627f1c)) +* fetch JWKS URI using internal domain ([590b92d](https://github.com/cloud-pi-native/console/commit/590b92d0030b824b60d1bd92caef1716fb018bfe)) +* GitLab can't find the template files in dist ([a878bba](https://github.com/cloud-pi-native/console/commit/a878bba7adf39b07d90d4b57fd2cfe39f58d3ba2)) +* **jj:** resolve conflict in registry.service.spec.ts test ([2c65fb0](https://github.com/cloud-pi-native/console/commit/2c65fb09ba03b730b1ea39c1b31cd65664da9f93)) +* JWKS fetching rule are flaky ([7faee14](https://github.com/cloud-pi-native/console/commit/7faee1419c526c73ad4fb81c9b097fc50513ee08)) +* **keycloak:** skip group sync for system:external admin roles ([9000139](https://github.com/cloud-pi-native/console/commit/900013993cc0aa44d7737b9db1bb86f6230be6ee)) +* **keycloak:** skip group sync for system:external admin roles ([4254929](https://github.com/cloud-pi-native/console/commit/4254929fbdde7b0e5d60c3b5b35c04dcc09cfc77)) +* **keyloak-secret-provider:** url is not routed to the correct protocol ([1e79d01](https://github.com/cloud-pi-native/console/commit/1e79d017d0dfbb8ba58be806ef5cd9b786b1d9d1)) +* launch.json to read envfiles ([5a56ad5](https://github.com/cloud-pi-native/console/commit/5a56ad5e102aee053284ceb97fb673cd832d7d3d)) +* **nestjs/gitlab:** build project group public URLs with URL constructor to avoid double slashes ([425b16b](https://github.com/cloud-pi-native/console/commit/425b16b6f1bff343e1eed86bcc65f63a1716fa41)) +* **nexus:** remove missing components in new versions ([58b894a](https://github.com/cloud-pi-native/console/commit/58b894a2b45eac0c968856ee8f76562125525ab9)) +* **nexus:** remove unsupported aux component from new Nexus ([bcbfcd2](https://github.com/cloud-pi-native/console/commit/bcbfcd21a6bef53cae1132fef8eb8b6c78342011)) +* nginx strangler is failling to resolve host in local mode ([78d5c6c](https://github.com/cloud-pi-native/console/commit/78d5c6c3d602a7fcc41817d42a26ad1c8a44941e)) +* playwright test expect mock ([1f6c124](https://github.com/cloud-pi-native/console/commit/1f6c124fd1923c6754719ba032a4883cb7d38d8a)) +* **server-nestjs/environment:** exclude updated environment from quota sums ([2b15ed1](https://github.com/cloud-pi-native/console/commit/2b15ed106843f361073468723946761589ce4402)) +* **server-nestjs/events:** update project status after events ([7515912](https://github.com/cloud-pi-native/console/commit/7515912b30b7af11a7969e6c1f708af76467a245)) +* **server-nestjs/keycloak:** refresh admin client token before it expires ([cf26176](https://github.com/cloud-pi-native/console/commit/cf26176bc1b8a3add7324b152ab53ee38edb86cc)) +* **server-nestjs/keycloak:** resolve base role type for system-prefixed roles ([4c8cf98](https://github.com/cloud-pi-native/console/commit/4c8cf98efc644af780f892de00d944e0a2c43e1c)) +* **server-nestjs/keycloak:** tolerate concurrent subgroup creation conflicts ([87cd136](https://github.com/cloud-pi-native/console/commit/87cd1361a213f9357bcb3f376704408a7fbcecf1)) +* **server-nestjs/nexus:** delete group repos before their hosted members ([c505db0](https://github.com/cloud-pi-native/console/commit/c505db0860d7c0113f096f4a37c7f89f701daebf)) +* **server-nestjs/nexus:** fall back to public Nexus URL when internal URL is unset ([be41606](https://github.com/cloud-pi-native/console/commit/be416069ae6bc1ad2408a8ecef4da2df787f5c02)) +* **server-nestjs/nexus:** tolerate platform role failures when syncing platform roles ([caef46d](https://github.com/cloud-pi-native/console/commit/caef46d7009e20b59643315373a202fff7803696)) +* **server-nestjs/vault:** build ArgoCD Vault values from AppRole creds and config ([9ce5916](https://github.com/cloud-pi-native/console/commit/9ce59161a8925a04c7af2f229843cd7e18201767)) +* **server-nestjs:** add default env var for admin client ID ([4b23d9c](https://github.com/cloud-pi-native/console/commit/4b23d9cfb4357d197b8f0baad7336e71185d5488)) +* **server-nestjs:** add missing healthcheck for local dev ([61de70c](https://github.com/cloud-pi-native/console/commit/61de70c77d0eb4b8496c820b5f42a248f34fb135)) +* **server-nestjs:** harden sonarqube reconciliation with strict key matching and idempotent init ([2686a59](https://github.com/cloud-pi-native/console/commit/2686a595bdcc184d6e185e041ea7b60d5057eb67)) +* **server-nestjs:** migrate system-config route ([20ae55e](https://github.com/cloud-pi-native/console/commit/20ae55ea295573f4abb64d48fe901c55bde15108)) +* **server-nestjs:** satisfy Prisma payload types in deployment spec mocks ([e7e9eaa](https://github.com/cloud-pi-native/console/commit/e7e9eaad4fec8953155331a6435590a66aa62668)) +* **server-nestjs:** silence successful healthz request logs ([7d2514b](https://github.com/cloud-pi-native/console/commit/7d2514b5f3388492ffc930e2457bd66aaa92f7f1)) +* vscode launch dotenv path ([26d9419](https://github.com/cloud-pi-native/console/commit/26d9419d2e03f99171c9b7ed33aaa793a8db1f58)) + + +### Miscellaneous Chores + +* align local Keycloak realm name ([5711c1b](https://github.com/cloud-pi-native/console/commit/5711c1b8d209dd93c827ed6b1b7ece709fb4fcce)) +* backport server-nestjs missing migrations ([8c2288c](https://github.com/cloud-pi-native/console/commit/8c2288cb0becb3d0bb06b347a8a1e8f0c5edefb3)) +* improve auth logging ([b7393d2](https://github.com/cloud-pi-native/console/commit/b7393d2e44294ae1ec622ab5ed7e83eaf69676f3)) +* **keycloak:** update local realm to use publicly facing KC URL ([2fbac4e](https://github.com/cloud-pi-native/console/commit/2fbac4ebde19a9ac32e655c6cd5f45ee6c1be92a)) +* **nginx-strangler:** allow custom (Rancher Desktop) host config in local dev context ([908c252](https://github.com/cloud-pi-native/console/commit/908c25220624d6206bfaef6c7b88878757c3e997)) +* package.json docker delete commands should actually delete the related images ([b32d71c](https://github.com/cloud-pi-native/console/commit/b32d71c047b32f9140794a0aa4809bd0b909dae7)) +* **playwright:** generalize checkbox helper to avoid inconsistencies ([6c839ef](https://github.com/cloud-pi-native/console/commit/6c839ef0a1fa87193fdc4131f4273968ec7084e6)) +* **playwright:** improve tests resilience ([ad95b2c](https://github.com/cloud-pi-native/console/commit/ad95b2cdd19201d8595265e73c8e51670abae1a1)) +* tag integ container images with integ instead of dev ([41c51ad](https://github.com/cloud-pi-native/console/commit/41c51adac249d96f62613f8534baf3f5821e871c)) + + +### Docs + +* add contribution guide ([1438231](https://github.com/cloud-pi-native/console/commit/1438231bb58fbb9558b57984b1fd84abfcaa008e)) + + +### Refactoring + +* Keycloak server provider should use openid-configuration URLs instead of rebuilding ([bc2199f](https://github.com/cloud-pi-native/console/commit/bc2199ff8380c243f38cf8d19fde4660adf7e51e)) +* **server-nestjs:** cleanup constants, tests using mockDeep and datastore tighter select ([1b6c21d](https://github.com/cloud-pi-native/console/commit/1b6c21d345d1d3366e641cff641198a6dbcc5039)) +* **server-nestjs:** migrate health modules from HealthIndicatorService to TerminusModule ([5f09649](https://github.com/cloud-pi-native/console/commit/5f09649588ca4e4c1d6d34fd1966ebd24fe8cd31)) +* **server-nestjs:** migrate logs ([f05e04f](https://github.com/cloud-pi-native/console/commit/f05e04fd3f90164e5211dbe7244cf25a45c6e320)) +* **server-nestjs:** migrate project bulk route ([e9fd130](https://github.com/cloud-pi-native/console/commit/e9fd1304c4d5f06d0dfbd3b41c47a37ffc9cf693)) +* **server-nestjs:** migrate project hook ([459c60a](https://github.com/cloud-pi-native/console/commit/459c60a743366cc39d0200bcaecc38ef5c722cb7)) +* **server-nestjs:** migrate project members ([796f2a9](https://github.com/cloud-pi-native/console/commit/796f2a987614e4c327153f3150c2c14e54d98827)) +* **server-nestjs:** migrate project role ([0764a2f](https://github.com/cloud-pi-native/console/commit/0764a2fb6efda7e5f101730e7d8e630647208478)) +* **server-nestjs:** migrate project route ([1740bc8](https://github.com/cloud-pi-native/console/commit/1740bc8d6eaeb89a28083cfea7940a03ad02e5e9)) +* **server-nestjs:** migrate project secrets routes ([0626157](https://github.com/cloud-pi-native/console/commit/0626157cd84a58299a765ab5e034922afa013f93)) +* **server-nestjs:** migrate project services route ([bf9d09a](https://github.com/cloud-pi-native/console/commit/bf9d09a11687efff0078a4d36f09dc08ca1e0b2f)) +* **server-nestjs:** offload project route to new implementation ([7c81349](https://github.com/cloud-pi-native/console/commit/7c813498770bf374acdcdacca68b0892cb2cc5b4)) +* **server-nestjs:** refactor deployment to use strong typing using mockDeep for refactoring resiliency ([934d9a0](https://github.com/cloud-pi-native/console/commit/934d9a0b2542ec8658e079c4dc177f27cfd00495)) +* **server-nestjs:** rename permission services to disambiguate ([0de7c34](https://github.com/cloud-pi-native/console/commit/0de7c3492f0ff8737d31be7c64ef9d81edc74a0c)) +* **server-nestjs:** replace InfrastructureModule with explicit module imports ([8a95834](https://github.com/cloud-pi-native/console/commit/8a95834f7280efc6f0ab74a8e35179f3369ede9a)) +* **shared:** add RoleType constant and use it across keycloak services ([3933b15](https://github.com/cloud-pi-native/console/commit/3933b15208636f65bc47d9449113b2df33579219)) + +## [9.19.0](https://github.com/cloud-pi-native/console/compare/v9.18.4...v9.19.0) (2026-06-19) + + +### Features + +* add support for Keycloak based token ([959dc9f](https://github.com/cloud-pi-native/console/commit/959dc9fb07fc3838e7f3a6f6f4dfd95ad01a4b22)) +* **server-nestjs:** add project permission guards ([04f7204](https://github.com/cloud-pi-native/console/commit/04f72045ada2c6249412664fe95ae13b678a8897)) + + +### Bug Fixes + +* correct NestJS healthcheck and add opencds health service ([b7633b1](https://github.com/cloud-pi-native/console/commit/b7633b102cfe87969106dad5fdc2785896ee01de)) +* default NestJS port ([76aca60](https://github.com/cloud-pi-native/console/commit/76aca604bfa2b46649cd432bfbb0d12b8d325537)) +* defaulting to 0.0.0.0 is a well known security issue ([5e2ab3b](https://github.com/cloud-pi-native/console/commit/5e2ab3b4e0ed4402944560e9589e52454f07fd92)) +* fix .env examples files having outdated default values ([b18e2ea](https://github.com/cloud-pi-native/console/commit/b18e2ea438ffb3a28d0b95b7a9db8363260a0380)) +* Keycloak JWT validation against public URL ([6e2879d](https://github.com/cloud-pi-native/console/commit/6e2879d5777e2785e84c9c15653050ef5629eb00)) +* **playwright:** correct heading matcher and checkbox helper in e2e tests ([4c2bd24](https://github.com/cloud-pi-native/console/commit/4c2bd2447806519238f6ba622374cfdadd29c10f)) +* **playwright:** increase timeout for service chain details heading ([bf90987](https://github.com/cloud-pi-native/console/commit/bf90987f73e9c2fdac3482b20d16fd566e49ae90)) +* **release-please:** restore default versioning strategy ([5872ebc](https://github.com/cloud-pi-native/console/commit/5872ebc4adf82d3a20e2dd5da19ef45188b13779)) +* remove opencds-mockoon depends_on (optional service) ([181c75b](https://github.com/cloud-pi-native/console/commit/181c75b8753d8e3a921fa18ffb3665a71901a773)) +* replace magic port constant and add startup URL logging ([5cfaf94](https://github.com/cloud-pi-native/console/commit/5cfaf94de6f8d7ac5260b1f1d12d675ffc635297)) + + +### Miscellaneous Chores + +* add docker:ci commands for targer=prod images tests ([9c8f25e](https://github.com/cloud-pi-native/console/commit/9c8f25e59c91dccf24fe26963a2fda77dd05f30a)) +* **client:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([58e0f13](https://github.com/cloud-pi-native/console/commit/58e0f136c78d0afc7d6fe8599cf6bffa4b03cb2d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* enforce port config ([e2bde42](https://github.com/cloud-pi-native/console/commit/e2bde427f79d8bcfefcc98d3e90e0a7a9041ba69)) +* **nginx-strangler:** use official nginx unprivileged image instead of unmaintained bitnamilegacy ([6f33cc2](https://github.com/cloud-pi-native/console/commit/6f33cc21bb96c496bde324401c43a6399b87294d)), closes [#2148](https://github.com/cloud-pi-native/console/issues/2148) +* remove unused packages and fix otel configuration ([242ce68](https://github.com/cloud-pi-native/console/commit/242ce6843c644282c12a20a1c6f2cea23ebc308d)) + + +### Refactoring + +* extract secret provider code from JWT module ([a69f4ef](https://github.com/cloud-pi-native/console/commit/a69f4ef4fb048ed3e32ba039c908e7ada8b37063)) + +## [9.18.4](https://github.com/cloud-pi-native/console/compare/v9.18.3...v9.18.4) (2026-06-10) + + +### Bug Fixes + +* ArgoCD not cleaning up environment on project deletion ([e2026a2](https://github.com/cloud-pi-native/console/commit/e2026a232823e89fb0ca2090ff51ab46495865f1)) + +## [9.18.3](https://github.com/cloud-pi-native/console/compare/v9.18.2...v9.18.3) (2026-06-01) + + +### Bug Fixes + +* repo and source url trailing slash ([71f9b98](https://github.com/cloud-pi-native/console/commit/71f9b98aa214e1d3165d70b9ddbbb4ff88916ee6)) +* revert GitLab default role to developper as backward compat ([d67f96a](https://github.com/cloud-pi-native/console/commit/d67f96a0e01d313611881e166f038b70f41dea3e)) +* role always return null ([1f1fabd](https://github.com/cloud-pi-native/console/commit/1f1fabdebe602ae74d694771cefbe5c99cd1b9d4)) + + +### Docs + +* **opencds:** fix doc mentionning the wrong backend ([36befb2](https://github.com/cloud-pi-native/console/commit/36befb279d45269e0bb3b9a2714cd4477b54a58a)) + +## [9.18.2](https://github.com/cloud-pi-native/console/compare/v9.18.1...v9.18.2) (2026-05-22) + + +### Bug Fixes + +* **client:** fix admin user list table headers not being aligned with its content ([c929ce4](https://github.com/cloud-pi-native/console/commit/c929ce42fa1c88023c2acdd9795b3e196eef2036)) + + +### Miscellaneous Chores + +* **client:** explicitely add eslint as a devDep ([b6b2240](https://github.com/cloud-pi-native/console/commit/b6b2240bee5385410cd8fcdecb8d19ff4428fd8e)) +* **client:** fix custom role management ([c6da4b8](https://github.com/cloud-pi-native/console/commit/c6da4b89c8b71691e10948ffdc08a2840de9ec12)) +* forbid commit message body not leading with a blank line ([5376e78](https://github.com/cloud-pi-native/console/commit/5376e784c6a835fd6b17473282aa5f9cb6dd83f5)) + + +### Docs + +* document why a preview on a hotfix needs a dedicated branch/MR ([14fd5c8](https://github.com/cloud-pi-native/console/commit/14fd5c864ceac51b5f50691371df20bd2997c514)) + + +### Refactoring + +* **server-nestjs:** migrate Sonarqube to NestJS ([b3784df](https://github.com/cloud-pi-native/console/commit/b3784df94f3f8f085dd0fd1315b3ca3f97f08ab7)) + +## [9.18.1](https://github.com/cloud-pi-native/console/compare/v9.18.0...v9.18.1) (2026-05-20) + + +### Bug Fixes + +* use bitnami image for strangler ([6ed431b](https://github.com/cloud-pi-native/console/commit/6ed431bd218fad529d7fdd5b051ea53462e8a024)) + +## [9.18.0](https://github.com/cloud-pi-native/console/compare/v9.17.1...v9.18.0) (2026-05-18) + + +### Features + +* add deployment model ([38f768b](https://github.com/cloud-pi-native/console/commit/38f768bc68e2ea8a8ebeabf80eb35844bdca9aba)) + + +### Bug Fixes + +* **sonarqube:** fix a weird type error ([fe22da3](https://github.com/cloud-pi-native/console/commit/fe22da3775a8d8a4796c9b1171dcb125c52521c2)) + + +### Miscellaneous Chores + +* bump dependencies to avoid dependabot security issues ([50bc6f5](https://github.com/cloud-pi-native/console/commit/50bc6f504eb98c3ba4e1217d1717c817eed14830)) +* **ci:** improve build for unit tests ([7cd49fb](https://github.com/cloud-pi-native/console/commit/7cd49fbcedf0f6afa712e9eb62abe2aaa8163a33)) +* do no bail when launching all tests ([d3a3182](https://github.com/cloud-pi-native/console/commit/d3a3182d8338bf2e1d9b6030686c7ea2d60cd7b0)) +* ensure dependencies are correctly linked for server builds ([b582c95](https://github.com/cloud-pi-native/console/commit/b582c959b11547dd98f7c4573feee81c7eddedba)) +* fix linting errors ([1a3917e](https://github.com/cloud-pi-native/console/commit/1a3917e866063ad55bb512c04156d6ccec23a261)) +* **playwright:** limit time wasted in merge queues by failing fast ([a0176d3](https://github.com/cloud-pi-native/console/commit/a0176d339f8f1644366a6382fc35abccfe08e393)) +* **server:** fix too many dependency issues ([3aee443](https://github.com/cloud-pi-native/console/commit/3aee4434ed1d8d48bab449e50e73f94510503218)) + +## [9.17.1](https://github.com/cloud-pi-native/console/compare/v9.17.0...v9.17.1) (2026-05-05) + + +### Bug Fixes + +* **client:** fix permissions in nginx docker image ([0807c2a](https://github.com/cloud-pi-native/console/commit/0807c2a7ca6939b519efdfbef80da2b83d436950)) + +## [9.17.0](https://github.com/cloud-pi-native/console/compare/v9.16.1...v9.17.0) (2026-04-30) + + +### Features + +* add ArgCD support for fined grained permissions ([db253d7](https://github.com/cloud-pi-native/console/commit/db253d79a0b5d15ad677060704c68f669b4d221a)) +* add Keycloak support for fined grained permissions ([0b9efdb](https://github.com/cloud-pi-native/console/commit/0b9efdb5e88651834472e6d7133198f60b3d59cd)) +* add Vault support for fined grained permissions ([15ba94e](https://github.com/cloud-pi-native/console/commit/15ba94e00f042f6711ee60ed56869338b72d15aa)) +* **gitlab:** upstream support for role binding on NestJS ([2b1d524](https://github.com/cloud-pi-native/console/commit/2b1d524b50b996f791b5e9f6df1af6271086a190)) +* **opencds:** add opencds-mockoon to the list of published images ([86981a8](https://github.com/cloud-pi-native/console/commit/86981a8a8568b551cccaef37961f991f98216c7b)) +* **server-nestjs:** add new ServiceChain (OpenCDS) module ([8903ed7](https://github.com/cloud-pi-native/console/commit/8903ed79b0c9076776d2627c944bcfb61b5b42f0)) +* **server-nestjs:** expose swagger-ui on client UI ([3dc75b0](https://github.com/cloud-pi-native/console/commit/3dc75b023ff0374b56071d82829bbde321dd6fc4)) + + +### Bug Fixes + +* dotenv doesn't overload existing env var ([7d4e0f0](https://github.com/cloud-pi-native/console/commit/7d4e0f006bd7c3c42fff5761dd59589f9ff3abcd)) +* GitLab integration test leaking undeleted users ([3a6644f](https://github.com/cloud-pi-native/console/commit/3a6644ff976507e08c3886002b7119a9e90e6925)) +* root admin role should be externally managed ([4dbd4ed](https://github.com/cloud-pi-native/console/commit/4dbd4ed6caf74016577a4f3f40457e5f1b06071e)) +* **server-nestjs:** migrate opencds client from Axios to undici ([ae0aa31](https://github.com/cloud-pi-native/console/commit/ae0aa312336430490e717b61bc3b8b82febe1ed2)) + + +### Miscellaneous Chores + +* add GitLab e2e test ([d0edff4](https://github.com/cloud-pi-native/console/commit/d0edff4f8b7562cecf546f450a940dc1e1a176dd)) +* add hook wrapper second retry logging ([1a99fb2](https://github.com/cloud-pi-native/console/commit/1a99fb255dad2e3d15fa15739fe476eaab4f18ad)) +* **client:** migrate from bitnamylegacy docker image ([927c838](https://github.com/cloud-pi-native/console/commit/927c83895ebedb58d7cab2bbbb9542e913987917)) +* document constant usage ([95307d7](https://github.com/cloud-pi-native/console/commit/95307d7911888ffb41fb0c6ee16be9736f9ac20f)) +* enfore stricter testing type checking ([8482472](https://github.com/cloud-pi-native/console/commit/84824727fbdc5eb8ab087e90763761efe6198568)) +* fix Sonarlint keep trying to change connectionId ([6738c84](https://github.com/cloud-pi-native/console/commit/6738c84374d55e3bd24cd0471695559d5c05bc39)) +* **nginx-strangler:** change service-chain routing from server to server-nestjs ([75d03bc](https://github.com/cloud-pi-native/console/commit/75d03bce16f8acfad63d6b18a60ac60687b60295)) +* **opencds:** use 3100 instead of 6000 for exposed port ([8272c76](https://github.com/cloud-pi-native/console/commit/8272c76828f2ee02066e9fc39a84e4d48138b9ee)) +* **playwright:** bump to 1.59.1 ([a83d246](https://github.com/cloud-pi-native/console/commit/a83d246e0daba8440f8ca4399b0182e505b2463f)) +* **playwright:** switch back to only one worker in CI ([29a659f](https://github.com/cloud-pi-native/console/commit/29a659f6bc6e6aec18975cf4dd6d28b8ee8b7810)) +* remove dotenv dep ([0383fa7](https://github.com/cloud-pi-native/console/commit/0383fa7bc2be1db645779ed5dda716e689830cce)) +* remove duplicate admin config in example ([66a4036](https://github.com/cloud-pi-native/console/commit/66a403622b20472d9b963a5f63e0e8951501aa89)) +* remove js-yaml e18e warning ([bc7fc12](https://github.com/cloud-pi-native/console/commit/bc7fc12550270dce6d3d38f031307178dd9da58e)) +* rework docker compose commands to use --menu option ([ef10ec4](https://github.com/cloud-pi-native/console/commit/ef10ec40579a116077f6afb4c833eb74026959be)) +* **server-nestjs:** disable auth guard on service-chain (waiting on keycloak module implem) ([28ce0d7](https://github.com/cloud-pi-native/console/commit/28ce0d7b82a2e812dd87bc7ce3b3d394300e1b86)) +* **server-nestjs:** move infrastructure modules from cpin-module to modules directory ([275df7c](https://github.com/cloud-pi-native/console/commit/275df7c6194281abf06885223e0cfcaf4754cc8d)) +* **server-nestjs:** move module to final modules directory ([2191ef7](https://github.com/cloud-pi-native/console/commit/2191ef7fe93e33d970bd85df64a2519165fffae5)) +* **server-nestjs:** remove useless ApplicationInitializationService ([f47e6ae](https://github.com/cloud-pi-native/console/commit/f47e6ae195662841ff84d57b8887393a469d8e98)) +* **server-nestjs:** remove useless CpinModule core modules ([ec6c582](https://github.com/cloud-pi-native/console/commit/ec6c582a95868edafd4f9cb45e115bf9da804036)) +* **server-nestjs:** remove useless HttpClientService ([8bf627b](https://github.com/cloud-pi-native/console/commit/8bf627baf2b17263ce8a8e8544be89c07933c400)) +* **server-nestjs:** remove useless ServerService service ([f50f0e5](https://github.com/cloud-pi-native/console/commit/f50f0e5b59a661d065d448196af14955b9fde9a0)) +* **server-nestjs:** rework Dockerfile for dev mode ([8c634fc](https://github.com/cloud-pi-native/console/commit/8c634fc4dde0051e989750deafb85d230ba689f7)) +* **service-chain:** fix env vars not being coherent between legacy and nestjs backends ([fe796a2](https://github.com/cloud-pi-native/console/commit/fe796a25f2b7b76c5cd797a1f3c6cb5c1f346751)) +* **system-settings:** migrate to NestJS ([f6a13bd](https://github.com/cloud-pi-native/console/commit/f6a13bd5ecb1ab0ad93853e4c304072705b7365e)) +* update dev commands in package.json to account for server-nestjs ([7314511](https://github.com/cloud-pi-native/console/commit/731451137a16feb12fd064acaad092e6274d9d9b)) +* update modularisation status ([7b226a3](https://github.com/cloud-pi-native/console/commit/7b226a3512746459bce7278fa8c45227da647307)) +* upstream static env var to infos ([f2c3e6c](https://github.com/cloud-pi-native/console/commit/f2c3e6c21ad4ab5fed76e1572ebb65019f0c54ad)) + + +### Docs + +* **server-nestjs:** update modularization documents ([d97c54b](https://github.com/cloud-pi-native/console/commit/d97c54bd8477a2e9ff7c8a0a276c2010b62991b1)) + + +### Refactoring + +* **argocd:** migrate ArgoCD to NestJS ([2926bb9](https://github.com/cloud-pi-native/console/commit/2926bb92abe3474dbe356ba388fbdfa5f1f8765d)) +* **server-nestjs:** migrate package managers to NestJS ([e73e254](https://github.com/cloud-pi-native/console/commit/e73e2544b073d21e6602b0212f5b8cf42ff18a7c)) + +## [9.16.1](https://github.com/cloud-pi-native/console/compare/v9.16.0...v9.16.1) (2026-04-13) + + +### Bug Fixes + +* **ci:** fix merge request comment job ([16cc398](https://github.com/cloud-pi-native/console/commit/16cc398f1b8b8388604ac3bd8997f4fbdbd41217)) +* **ci:** fix preview comment workflow not having correct env vars ([48169af](https://github.com/cloud-pi-native/console/commit/48169afdcc3dea972938d53fd7072be533f3ec81)) +* missmatch between direct login and console username generation ([5e51770](https://github.com/cloud-pi-native/console/commit/5e5177017b1f9c5ae8f253e991ea43dd16da0e82)) + + +### Miscellaneous Chores + +* **ci:** add a dedicated workflow for commenting on merge requests ([f49a2e8](https://github.com/cloud-pi-native/console/commit/f49a2e83b335f0e61f29861ab56c48aa85d0f434)) +* **ci:** add pinact configuration to pin GHAs to their SHA ([490b308](https://github.com/cloud-pi-native/console/commit/490b308720763813bb9f7c41191326046bfeb33f)) +* **ci:** disable trivy cache to avoid using action-cache version that is in Node.js v20 ([050abbb](https://github.com/cloud-pi-native/console/commit/050abbbe7d24e3d04c0515fd0010289c39b73e3d)) +* **ci:** pin EVERY GitHub action to their SHA ([f201192](https://github.com/cloud-pi-native/console/commit/f2011921ea3e04e14dec6b5b97caa06df47292e3)) +* **ci:** replace old and unmaintained GHA job for MR comments with our own job ([e66d8ab](https://github.com/cloud-pi-native/console/commit/e66d8ab5202e8476b86a782cc53c3b367982719f)) +* **ci:** update merge request comment action to use appropriate property following the GHA upgrade ([46727c0](https://github.com/cloud-pi-native/console/commit/46727c090ce2f88d50af0ffdbf52b9deb7358d8a)) +* **ci:** upgrade EVERY GitHub action to their latest SHA version ([c50ccda](https://github.com/cloud-pi-native/console/commit/c50ccda531b42548b75cda770bd3baa93977f7d6)) +* remove unused migrate local database scripts ([98093ad](https://github.com/cloud-pi-native/console/commit/98093ad0bc4cf3cca0fae36ccf8d36f8c7810ec5)) + +## [9.16.0](https://github.com/cloud-pi-native/console/compare/v9.15.0...v9.16.0) (2026-04-10) + + +### Features + +* **ci:** add @cpn-console/logger to NPM publish step ([b3ccdf8](https://github.com/cloud-pi-native/console/commit/b3ccdf871c89642f5a8d7912ab3c4725ab38debb)) + + +### Bug Fixes + +* admin auth on master realm ([f3d323e](https://github.com/cloud-pi-native/console/commit/f3d323efa0f447ed3a8ddfb4b021ab49478d6e49)) +* **ci:** add missing needs property to NPM publish workflow ([62a76f9](https://github.com/cloud-pi-native/console/commit/62a76f9f43a4b50504497409fd765cd4350d92cf)) + + +### Miscellaneous Chores + +* **ci:** add missing parameter to NPM publish job ([3b4907a](https://github.com/cloud-pi-native/console/commit/3b4907a6b547a06c14ee160fc181b4e4db028e8c)) +* **logger:** add package to NPM publish matrix ([f186b12](https://github.com/cloud-pi-native/console/commit/f186b1245f5b47aa0634bee8c3301e1e623aaf8a)) +* **logger:** bump version to 1.0.1 to test GHA NPM publish ([8109fe8](https://github.com/cloud-pi-native/console/commit/8109fe8a71a80d868703f6ac4e3bc8090fcfdfb9)) +* **logger:** update description to trigger NPM publish ([6419b83](https://github.com/cloud-pi-native/console/commit/6419b8376f48c653be6f28280b01f865f8fe3895)) +* **logger:** update description to trigger NPM publish ([f43ef92](https://github.com/cloud-pi-native/console/commit/f43ef92256942f300ea3fca747a31dbaa867eb22)) +* **logger:** update description to trigger NPM publish ([c400376](https://github.com/cloud-pi-native/console/commit/c400376842d5ec97a193efee9bf6be34a6ccb118)) + +## [9.15.0](https://github.com/cloud-pi-native/console/compare/v9.14.1...v9.15.0) (2026-04-09) + + +### Features + +* add otel ([9f6e14e](https://github.com/cloud-pi-native/console/commit/9f6e14e34489476a4fa41ca20e5941f6b7bfe591)) +* **github:** fix workflow dispatch for running merge queue workflow for testing purpose ([b1a3993](https://github.com/cloud-pi-native/console/commit/b1a39933ebcc54f7e8fa9f18e1379e49271b487a)) +* improve transverse logging ([3e3b7ff](https://github.com/cloud-pi-native/console/commit/3e3b7ffc83e1945775c6889ce47ad4579c38d49a)) + + +### Bug Fixes + +* **argocd-plugin:** fix a weird type error that blocks server build ([822dea9](https://github.com/cloud-pi-native/console/commit/822dea9bec4af0a712533f110ff802a9a3cfa222)) +* **client:** writable permissions on managed roles ([2f83256](https://github.com/cloud-pi-native/console/commit/2f83256c780dab665c7f4fb973f82862df9af7b6)) +* create project button position ([bcf95c5](https://github.com/cloud-pi-native/console/commit/bcf95c5042fafbc694a51d16444141009103ad60)) +* header width fix ([ea9a588](https://github.com/cloud-pi-native/console/commit/ea9a588deb9c2e6d70abfce2b7d29b03561f07c2)) +* improve pagination search ([55153d5](https://github.com/cloud-pi-native/console/commit/55153d545128d232cae893d56853a1c49f0e8b9b)) +* missing nestjs server on vscode coumpounds launch ([28eeefb](https://github.com/cloud-pi-native/console/commit/28eeefb4f91cb7d40a2324392a0a89bb768601c4)) +* nginx fails on non-bash style shell ([ca37314](https://github.com/cloud-pi-native/console/commit/ca373141986110be2215e319474d5bb4003294ba)) + + +### Miscellaneous Chores + +* bump dependencies after changing pnpm-workspace configuration ([0f7aca8](https://github.com/cloud-pi-native/console/commit/0f7aca82155efcaffd2880b7f540fdd756265e32)) +* ensure we don't pull dependencies that are less than one day old ([c16b237](https://github.com/cloud-pi-native/console/commit/c16b237f233b84dea568e4f50e4883c7121e2550)) +* hide noisy vscode launch configs ([eb7283c](https://github.com/cloud-pi-native/console/commit/eb7283cf429c62e78a3012777b5c9c0118ac1373)) +* **pnpm:** bump to 10.33 ([33ad275](https://github.com/cloud-pi-native/console/commit/33ad275253db3bb9e4b03f3cb02d0ff7101e1f1c)) +* refactor launch to use corepack ([57159c0](https://github.com/cloud-pi-native/console/commit/57159c0664e8cd67d07e6d235bcd39908180b0b3)) +* **server-nestjs:** fix pnpm-lock internal deps ([4967d51](https://github.com/cloud-pi-native/console/commit/4967d5141a60176181df37ffa6d9f2755feaef0c)) +* whitelist some false positives for pnpm trust policy ([10bb5e3](https://github.com/cloud-pi-native/console/commit/10bb5e3239b6372fb303b9dbf27d785d75fe4433)) + + +### Refactoring + +* **keycloak:** migrate Keycloak plugin to NestJS ([76fe58a](https://github.com/cloud-pi-native/console/commit/76fe58aee4b4de35a379ced300f11f493eebaa51)) + +## [9.14.1](https://github.com/cloud-pi-native/console/compare/v9.14.0...v9.14.1) (2026-03-25) + + +### Bug Fixes + +* **docker:** fix docker compose exposed ports ([25ab1e5](https://github.com/cloud-pi-native/console/commit/25ab1e5f34d95d6d92ec44cf0ee87856c65772f5)) +* **nginx-strangler:** fix docker-compose.local having multiple ports ([1b89d4e](https://github.com/cloud-pi-native/console/commit/1b89d4e9d27f9ae6393d3db216491971a65c6163)) + + +### Miscellaneous Chores + +* add vitest ([4308f3f](https://github.com/cloud-pi-native/console/commit/4308f3fc46b075537c87d4d5edaea6d30eefcd35)) +* bump undici to 7.24.5 ([6e7c065](https://github.com/cloud-pi-native/console/commit/6e7c065075287f133e8b3be9639e560a3ce816c7)) +* drop usage of path alias ([d2fa0d4](https://github.com/cloud-pi-native/console/commit/d2fa0d4dd03727ef53c2315e57cffa6750cefd49)) +* **nginx-strangler:** fix Dockerfile casing ([2086a09](https://github.com/cloud-pi-native/console/commit/2086a092f749ce4f8c9142e4f74135206de6a968)) +* **nginx-strangler:** fix healthcheck endpoint ([696bf43](https://github.com/cloud-pi-native/console/commit/696bf434220a37a64dd70002a609f95f209eedaa)) +* **nginx-strangler:** remove useless user directive ([9a5e770](https://github.com/cloud-pi-native/console/commit/9a5e7701c14796042876fb210df26f7087bfd32c)) +* remove old duplicate health module ([2ed91fc](https://github.com/cloud-pi-native/console/commit/2ed91fcad9d45cf0d7146eff846ac5d497747e58)) +* remove useless docker-compose.prod.yml ([e74980b](https://github.com/cloud-pi-native/console/commit/e74980b0d8a4583e42b621b86a5b67f468edb2a8)) +* **version:** migrate to NestJS ([b2cf4e4](https://github.com/cloud-pi-native/console/commit/b2cf4e47d59903ede6a9dc18990a5fefe8b0c3d1)) + + +### Docs + +* update port documentation for every dev mode ([ad9b71c](https://github.com/cloud-pi-native/console/commit/ad9b71c58817f799e0b9cea00140cdbf4266386d)) + +## [9.14.0](https://github.com/cloud-pi-native/console/compare/v9.13.2...v9.14.0) (2026-03-23) + + +### Features + +* **admin-role:** migrate role type to exposure scope ([#1956](https://github.com/cloud-pi-native/console/issues/1956)) ([747189f](https://github.com/cloud-pi-native/console/commit/747189f02135f7b0a7906abd1778f7273fc74f3d)) +* **argocd:** add well known OIDC groups from ADR14 ([#1946](https://github.com/cloud-pi-native/console/issues/1946)) ([4282772](https://github.com/cloud-pi-native/console/commit/4282772e3b6a0649558cdacfcff6aca54bcbc2b4)) +* **gitlab:** add builtin roles to GitLab ([#1910](https://github.com/cloud-pi-native/console/issues/1910)) ([25a72da](https://github.com/cloud-pi-native/console/commit/25a72daa98eaf10dd4a96ca579e5d6da7d9fd5b1)) +* **shared:** add admin role fine grained permissions ([#1896](https://github.com/cloud-pi-native/console/issues/1896)) ([58c53af](https://github.com/cloud-pi-native/console/commit/58c53afb567498e520fba9fa7ceb40fd9792c4b1)) + + +### Bug Fixes + +* add create-or-update workflow package:write permissions for build job ([72c4d27](https://github.com/cloud-pi-native/console/commit/72c4d2747240ccb2fecfb03a3343126c1a945d58)) +* attempt a better matrix check on job-npm ([fb2204d](https://github.com/cloud-pi-native/console/commit/fb2204da305a3dc190d1c5595304c7c6efaa261e)) +* avoid server-nestjs build error preventing npm publish process ([7d55944](https://github.com/cloud-pi-native/console/commit/7d55944aeb3b4162fe9e366fbb0b16b9b881174f)) +* bind "this" context in process hook ([#1968](https://github.com/cloud-pi-native/console/issues/1968)) ([0c7d8b1](https://github.com/cloud-pi-native/console/commit/0c7d8b118b67d86e657eebc7e7f87840105ebdd9)) +* buffered logs not flushed on init error ([#1975](https://github.com/cloud-pi-native/console/issues/1975)) ([67bbecb](https://github.com/cloud-pi-native/console/commit/67bbecb1e96b8dec93acf979d50b86f24673d8ad)) +* **ci:** add missing hooks build in npm publish workflow ([a357ad2](https://github.com/cloud-pi-native/console/commit/a357ad26cb564bfc5adcd091dcf1e1ad0ad403fe)) +* **ci:** avoid making PR number mandatory (issue with create-release job) ([6a7cf04](https://github.com/cloud-pi-native/console/commit/6a7cf049c9262e5eff4949340425703f0d7163c9)) +* **ci:** ensure build job is ran both from PRs and MQs ([0772105](https://github.com/cloud-pi-native/console/commit/0772105d1554cb123810d4254619e8f5baaeb721)) +* **ci:** ensure npm publish job run independently ([0ca3691](https://github.com/cloud-pi-native/console/commit/0ca36915f39afc92b839a41e58c5db0d6e866f23)) +* **ci:** fix id-token permission setup ([6d0cab4](https://github.com/cloud-pi-native/console/commit/6d0cab4a8ab4be76a938f596e8d4633f2e925d7a)) +* **ci:** fix NPM 404 error when publishing ([5e8e5af](https://github.com/cloud-pi-native/console/commit/5e8e5afbacdd11f2413c1136471768f5d7b6168c)) +* **ci:** fix NPM 404 error when publishing (attempt no. 2) ([32f67d7](https://github.com/cloud-pi-native/console/commit/32f67d76a77333230774914c711890e921e324bf)) +* **ci:** fix NPM 404 error when publishing (attempt no. 3) ([dacf45b](https://github.com/cloud-pi-native/console/commit/dacf45bba5b5ce7ad54d25acc4f4f0807bccdcbf)) +* **ci:** fix package.json files not having property required for publishing ([acbf328](https://github.com/cloud-pi-native/console/commit/acbf32832c01fa55a6d61a26dc29b0706f1a959e)) +* **ci:** remove NPM_TOKEN from required parameters ([a0221d0](https://github.com/cloud-pi-native/console/commit/a0221d00162a8abaf719e0603f3ff5b680420e88)) +* **clean-cache:** fix workflow crashing on non-built images ([c24a121](https://github.com/cloud-pi-native/console/commit/c24a121b57d54b427e73013f67f64f84b6492d7e)) +* combine create-or-update job and workflow permissions ([7271468](https://github.com/cloud-pi-native/console/commit/72714680703fd6971b0f2654a3c5f88038e9a5a1)) +* **create-release:** fix pnpm command when publishing to NPM registry ([#1941](https://github.com/cloud-pi-native/console/issues/1941)) ([641f01f](https://github.com/cloud-pi-native/console/commit/641f01fd6170c0825ee530a1622b1375a72ffb1a)) +* **cypress:** fix cypress e2e tests missing nginx-strangler and server-nestjs images ([34c5d8a](https://github.com/cloud-pi-native/console/commit/34c5d8add47350e027ac098b47f3c77040a0a76f)) +* ensure all packages are properly built before pushing to NPM registry ([1ac0264](https://github.com/cloud-pi-native/console/commit/1ac02644f3cc59454ac02cfc918b499afb177046)) +* ensure NPM publish workflow does not crash (single quote variant) ([2a885c9](https://github.com/cloud-pi-native/console/commit/2a885c9eac4996c73f8889bb0547f27587210d48)) +* ensure NPM publishing workflow does not crash when there is nothing to publish ([f927563](https://github.com/cloud-pi-native/console/commit/f92756313f96de74cd1aa56281d6c68d766a9f6a)) +* fix build job not being triggered by workflows ([31e4cd3](https://github.com/cloud-pi-native/console/commit/31e4cd3cb89e9006a73bd87306acda8cdbc151b6)) +* fix build-label job permissions ([42fa1aa](https://github.com/cloud-pi-native/console/commit/42fa1aa421adf4b2a4bcd96ca7bb101c818fb431)) +* fix create-or-update job permissions ([0624395](https://github.com/cloud-pi-native/console/commit/0624395c9daab4fb05aec84e6d003793f966294b)) +* fix create-or-update workflow permissions ([56f1da9](https://github.com/cloud-pi-native/console/commit/56f1da99cc8462c72df97661f6ab1755a675e358)) +* fix packages crashing when building during NPM publish process ([f713e6a](https://github.com/cloud-pi-native/console/commit/f713e6a878d6d53dc484dc1025a2af4600d22951)) +* fix permissions on preview workflow ([df3d342](https://github.com/cloud-pi-native/console/commit/df3d342944c8506d2259c044f0df0e18c0888671)) +* fix scan-vuln job permissions ([4eb2c06](https://github.com/cloud-pi-native/console/commit/4eb2c06ba08d981d5bd29f3120f1e79cc0372770)) +* **gitlab:** iterate over pagination ([#1918](https://github.com/cloud-pi-native/console/issues/1918)) ([9ebce61](https://github.com/cloud-pi-native/console/commit/9ebce618fe7eba1f7188b4c05a644e2cda8f3f11)) +* **hooks:** make hooks should not be fail fast ([#1998](https://github.com/cloud-pi-native/console/issues/1998)) ([1904fc7](https://github.com/cloud-pi-native/console/commit/1904fc7990efb78723af239f11c64aee22304ed7)) +* import missing health module ([1c0a82b](https://github.com/cloud-pi-native/console/commit/1c0a82b0eb5253bcb1bbd21113465d6141332612)) +* import missing Prisma mocks ([#1976](https://github.com/cloud-pi-native/console/issues/1976)) ([568cd85](https://github.com/cloud-pi-native/console/commit/568cd852d40bb8ceee6b4a418754f182f0eb579c)) +* missing opentelemetry api dep ([6593f7a](https://github.com/cloud-pi-native/console/commit/6593f7ac66291f1fe4e260ce73afc142806e7685)) +* module DI conflict with eslint ([#1966](https://github.com/cloud-pi-native/console/issues/1966)) ([460e49d](https://github.com/cloud-pi-native/console/commit/460e49d9523f5b79ee3e4fc2d295d6816f790aaa)) +* NestJS CJS and module ([#2003](https://github.com/cloud-pi-native/console/issues/2003)) ([86f0a34](https://github.com/cloud-pi-native/console/commit/86f0a3425763260e522acf05a8be21bdc174c796)) +* **nginx-strangler:** dockerfile path ([b94a99d](https://github.com/cloud-pi-native/console/commit/b94a99d7a26099eb5bbb69d233c111bc2f1fa88c)) +* **nginx-strangler:** fix context not being root directory in docker-compose files ([e74d7e4](https://github.com/cloud-pi-native/console/commit/e74d7e4ebf207ac70d9b78dc304cab6acc1d9ccc)) +* **nginx-strangler:** fix Dockerfile missing local paths ([ff61830](https://github.com/cloud-pi-native/console/commit/ff61830608ec6ab91444369f05e24d2039c91711)) +* **playwright:** fix nginx-strangler image being built instead of pulled ([7e2c12f](https://github.com/cloud-pi-native/console/commit/7e2c12f5bcffc7d1cf21cf3409bf552972d79ad9)) +* **server-nestjs:** explicit test utils import ([#1991](https://github.com/cloud-pi-native/console/issues/1991)) ([30aca3f](https://github.com/cloud-pi-native/console/commit/30aca3f3b945a89a9c8de26a11a311c0a7beb79b)) +* shared package for nodenext module resolution ([#1974](https://github.com/cloud-pi-native/console/issues/1974)) ([c3c0581](https://github.com/cloud-pi-native/console/commit/c3c0581b3552a7ba527ad0dfc26ba6f7b0ff5e87)) + + +### Miscellaneous Chores + +* add instrumentation ([#1994](https://github.com/cloud-pi-native/console/issues/1994)) ([18ff003](https://github.com/cloud-pi-native/console/commit/18ff003211c0265a488b7295f38d4fc039d1cbef)) +* **admin-role:** add logs ([#1928](https://github.com/cloud-pi-native/console/issues/1928)) ([708870b](https://github.com/cloud-pi-native/console/commit/708870bdbd08c0a3041477fea0ccb93652963e5f)) +* **argocd:** remove kubernetes ressource generation dead code ([#1945](https://github.com/cloud-pi-native/console/issues/1945)) ([95cc280](https://github.com/cloud-pi-native/console/commit/95cc28035f73517ff91811154fc72be56978dd0c)) +* bump axios to 1.13.x ([#1988](https://github.com/cloud-pi-native/console/issues/1988)) ([9f8c8cb](https://github.com/cloud-pi-native/console/commit/9f8c8cb439249fc2304f76ed5332be161f8393db)) +* **ci:** add a dedicated Merge Queue workflow ([0618d39](https://github.com/cloud-pi-native/console/commit/0618d39d35b2873ecd25a7a323557e5cacac9cf3)) +* **ci:** add debug for merge queues ([40132d2](https://github.com/cloud-pi-native/console/commit/40132d2e4ed6fdcc6330603f965a32037800a09d)) +* **ci:** add merge_group to CI worklow again ([674d8cf](https://github.com/cloud-pi-native/console/commit/674d8cfa14ea685b6520b8ec44a160676e6a20cb)) +* **ci:** enable merge queues by leveraging main CI workflow ([62c9214](https://github.com/cloud-pi-native/console/commit/62c9214d80d1bb56fae0223df4018130a9b5be25)) +* **ci:** fix sonarqube-quality-gate-action version ([21c94f9](https://github.com/cloud-pi-native/console/commit/21c94f9ac9bd4386f71e0d7b5580cc22cf007eef)) +* **ci:** fix trivy action version to avoid suppy-chain compromision ([6d153b8](https://github.com/cloud-pi-native/console/commit/6d153b83c209cf0dcc7f99ecebd082bcd30e3265)) +* **ci:** improve debug for merge queues ([1ed3eae](https://github.com/cloud-pi-native/console/commit/1ed3eae17eb88b341c947216a98f9bae7e02b012)) +* **ci:** improve debug for merge queues (add vars as reusable env vars) ([55cf78c](https://github.com/cloud-pi-native/console/commit/55cf78c39bb3e045b7c8009769789d817911b80e)) +* **ci:** improve debug for merge queues (fix event path) ([e3eba9a](https://github.com/cloud-pi-native/console/commit/e3eba9a231e863ace8af29e2c93b039bca2883a1)) +* **ci:** improve debug for merge queues (test stuff on lint and build jobs) ([ca277e0](https://github.com/cloud-pi-native/console/commit/ca277e0863407f5e0fa70ca40b475409a56ae42c)) +* **ci:** lighten non-merge queue load ([e31f2f7](https://github.com/cloud-pi-native/console/commit/e31f2f76df794fa06c7e0be264a240f50ea8694f)) +* **ci:** limit permissions by default ([d189624](https://github.com/cloud-pi-native/console/commit/d18962406aecc118219f596526bf4d3f6156a399)) +* **ci:** remove merge-group check in main workflow ([ccd4acf](https://github.com/cloud-pi-native/console/commit/ccd4acf36faa56a699b8cba23e7c69c90641ecfa)) +* **ci:** remove npm publish from create-or-update-release workflow ([006ffc4](https://github.com/cloud-pi-native/console/commit/006ffc4e2668b61ba83889b3d19a8469fb4dba33)) +* **ci:** remove unneeded token access privilege for npm publish ([0b9da31](https://github.com/cloud-pi-native/console/commit/0b9da31976482d2ff7dbcf53e7e22fab4ebd08c8)) +* **ci:** use merge-group SHA as tag to ensure the workflow can run ([1d555ee](https://github.com/cloud-pi-native/console/commit/1d555ee550e4715bc3ff15cfdcf57c3159ae1df8)) +* **ci:** use NPM Trusted Publishing OIDC instead of the old token process ([#1960](https://github.com/cloud-pi-native/console/issues/1960)) ([2842824](https://github.com/cloud-pi-native/console/commit/284282435eb67dab737df1b2f9d9f27a807ebaa6)) +* **clean-cache:** allow manual execution ([e39cf0c](https://github.com/cloud-pi-native/console/commit/e39cf0ce05f89a780a6f6d3aec4cdeb777731cb7)) +* **clean-cache:** assume there can be more than one image to delete ([24337d9](https://github.com/cloud-pi-native/console/commit/24337d9e4a6d93c002507c7b64eab2c065146d7d)) +* **clean-cache:** remove now useless flag (cache id is enough) ([35b88e1](https://github.com/cloud-pi-native/console/commit/35b88e122bc303e5e6bb47f6e2184ceaa99bb92b)) +* **clean-cache:** remove unknown flag ([7615d57](https://github.com/cloud-pi-native/console/commit/7615d5720bd7d34b9c6af7d80242ae5a82da4607)) +* fix clean cache input descriptions ([f8daa92](https://github.com/cloud-pi-native/console/commit/f8daa92a2d4003cece87fcf57801c224e2e1f858)) +* **keycloak:** migrating admin roles from disabled purge to enabled by default ([#1955](https://github.com/cloud-pi-native/console/issues/1955)) ([a2e5772](https://github.com/cloud-pi-native/console/commit/a2e5772170ab65bd92034877bf0de32a30873439)) +* prevent apps from being published on NPM registry (useless) ([173d503](https://github.com/cloud-pi-native/console/commit/173d503599723f64eb968a191355644285c2e8f9)) +* remove jest in profit to vitest ([#1986](https://github.com/cloud-pi-native/console/issues/1986)) ([f65e2e0](https://github.com/cloud-pi-native/console/commit/f65e2e07c27437b6204b18eb2f72aaafaf76ca0e)) +* remove lint rules that does nothing ([c4c5510](https://github.com/cloud-pi-native/console/commit/c4c55106681fd73cd96bc38efd3f1f6ad14aedcf)) +* remove precommit and prepush ([#1982](https://github.com/cloud-pi-native/console/issues/1982)) ([529257a](https://github.com/cloud-pi-native/console/commit/529257a997f0af7344f2f72bde45442debf5aba4)) +* remove purge option from Keycloack ([#1979](https://github.com/cloud-pi-native/console/issues/1979)) ([169fbc4](https://github.com/cloud-pi-native/console/commit/169fbc4f42acfb4dc84de6ae9f1da83751562e85)) +* remove unneeded job check in NPM publish workflow ([88011b8](https://github.com/cloud-pi-native/console/commit/88011b8a2fefb55c675726d480b6842df3b9c7d1)) +* remove useless failing tests ([#1967](https://github.com/cloud-pi-native/console/issues/1967)) ([9f7612a](https://github.com/cloud-pi-native/console/commit/9f7612a63239dcab12ec7b53ca17f8a390978b97)) +* **server-nestjs:** fix doc formatting ([0ddf0ad](https://github.com/cloud-pi-native/console/commit/0ddf0ad6730f35a73016957e91cba2cfdf2d27c9)) +* stricter imports lints ([#2007](https://github.com/cloud-pi-native/console/issues/2007)) ([9fb3f3a](https://github.com/cloud-pi-native/console/commit/9fb3f3a1a0c9875c2ec4999cda5cd86b68ddcada)) +* stricter unused args ([#2008](https://github.com/cloud-pi-native/console/issues/2008)) ([eef6dba](https://github.com/cloud-pi-native/console/commit/eef6dba9fe17e52c4984014375ffd9cd1c37eb09)) +* translate role type in French ([#1978](https://github.com/cloud-pi-native/console/issues/1978)) ([d86539a](https://github.com/cloud-pi-native/console/commit/d86539a43d2f508b9da20294e1bb1be5024e8c28)) + + +### Docs + +* **server-nestjs:** add modularization documentation ([#1936](https://github.com/cloud-pi-native/console/issues/1936)) ([aa88099](https://github.com/cloud-pi-native/console/commit/aa8809982ee15b6a68a86f82da6810ff8ecfa318)) + +## [9.13.2](https://github.com/cloud-pi-native/console/compare/v9.13.1...v9.13.2) (2026-02-24) + + +### Bug Fixes + +* add schedule trigger on mirror ([0ae54da](https://github.com/cloud-pi-native/console/commit/0ae54daf158122d3f81a13dc8fb9342c093a1beb)) + + +### Miscellaneous Chores + +* **ci:** ensure internal dependencies are built before starting tests ([effedd7](https://github.com/cloud-pi-native/console/commit/effedd75ae38f3acd4cedb770d069ce7a7cae4ec)) +* **ci:** remove component tests from CI ([24faa75](https://github.com/cloud-pi-native/console/commit/24faa756038f5c6bfcbb5905a7d482aa4100caf8)) +* **ci:** remove component tests from CI ([928320d](https://github.com/cloud-pi-native/console/commit/928320d24c0cc794dfa0c466ba0433a89264d9ad)) +* **ci:** remove kubernetes execution context from scripts ([76de420](https://github.com/cloud-pi-native/console/commit/76de4204ea1ded0f137b0a39c3176195aa5c25ef)) +* **ci:** remove kubernetes execution context from scripts ([3cbf816](https://github.com/cloud-pi-native/console/commit/3cbf816e9cdca94a0d4ed6030b6afee0e1c14554)) +* **ci:** use node.js v24 ([2fce2b8](https://github.com/cloud-pi-native/console/commit/2fce2b81db787b36836b08b3fa1b3bc3c2eb2e47)) +* **ci:** use node.js v24 ([2e8947c](https://github.com/cloud-pi-native/console/commit/2e8947c86832636d367e54bfeb776f2567f2f4ac)) +* remove turbo ([05af332](https://github.com/cloud-pi-native/console/commit/05af332261b2dc7028004141a0a6054d70d595b8)) +* **server-nestjs:** align formatting rules on other packages ([f19091b](https://github.com/cloud-pi-native/console/commit/f19091bed9f78ff4eca3d3337c0c8b7d9cd87dce)) +* **server-nestjs:** align formatting rules on other packages ([c9eb1de](https://github.com/cloud-pi-native/console/commit/c9eb1de8b99a349fbf1701fd7317583d71873c18)) + + +### Docs + +* add AGENTS.md ([9e319fe](https://github.com/cloud-pi-native/console/commit/9e319fe37563a5161df4a25cb71fc8b1ac23f088)) +* add AGENTS.md ([e0e10f2](https://github.com/cloud-pi-native/console/commit/e0e10f2daf2798b3279e5c0e8109ec117fa02d62)) + +## [9.13.1](https://github.com/cloud-pi-native/console/compare/v9.13.0...v9.13.1) (2026-02-17) + + +### Miscellaneous Chores + +* **argocd:** remove now useless code ([23571c2](https://github.com/cloud-pi-native/console/commit/23571c255884bcf252f8a9a674934cf74c957730)) +* bump node docker image base ([ccc9411](https://github.com/cloud-pi-native/console/commit/ccc9411c78057851677e2786a6987d4ebb021b61)) +* bump packages ([4f188ab](https://github.com/cloud-pi-native/console/commit/4f188aba67a29c613a450d47c7a1982400b05739)) +* **harbor:** remove deprecated code ([f562c41](https://github.com/cloud-pi-native/console/commit/f562c41b49ff4811e41590b3f92529bff888b088)) +* **playwright:** optimize configuration for execution duration and trace retention ([200fd10](https://github.com/cloud-pi-native/console/commit/200fd10cba850b0ed5f0a6db3b70e4004a75fa8d)) +* remove 'external' cluster property ([46d7ed8](https://github.com/cloud-pi-native/console/commit/46d7ed857024ddb39c864168fcb20496fb24759a)) +* remove every reference to kubernetes client-node dependency ([0fad63c](https://github.com/cloud-pi-native/console/commit/0fad63c184927af85a4e2aecbc3f2dd9f67cf81b)) +* remove kubernetes plugin ([9a2d928](https://github.com/cloud-pi-native/console/commit/9a2d92833b76fec92479fc609047b11701362a16)) + +## [9.13.0](https://github.com/cloud-pi-native/console/compare/v9.12.0...v9.13.0) (2026-02-16) + + +### Features + +* add OIDC group to project groups ([c57674b](https://github.com/cloud-pi-native/console/commit/c57674b441094ca0503b8298b618e3a13b202ab6)) +* add system roles on project creation ([4401519](https://github.com/cloud-pi-native/console/commit/4401519c41160befaa49d3721b475223b537f834)) +* **keycloack:** add configurable purge at upsert ([f8fb77d](https://github.com/cloud-pi-native/console/commit/f8fb77dd717529f10a2d2fdfd8b5212e009de4b4)) +* **keycloak:** add project role creation hook ([83c5194](https://github.com/cloud-pi-native/console/commit/83c5194a19a7f35cbc3d0692319f32a304612afb)) +* **keycloak:** propagate admin roles to Keycloack ([7d3c9b2](https://github.com/cloud-pi-native/console/commit/7d3c9b22b314aa298c0083326771a40c68fa5190)) + + +### Bug Fixes + +* **admin-roles:** admin conflicting with existing admin group in Keycloack ([ed321de](https://github.com/cloud-pi-native/console/commit/ed321def7a37bb612678ad329b664092508e41fd)) +* hooks config is always undefined ([dd2f405](https://github.com/cloud-pi-native/console/commit/dd2f40520c44d2ec3cd6e9309382e7d0a835e579)) +* **keycloak:** project members sync to Keycloak ([0b4383f](https://github.com/cloud-pi-native/console/commit/0b4383ffee8125e9903049b9f2981815fe312aa7)) +* **project-roles:** remove /console prefix strip ([19fbd02](https://github.com/cloud-pi-native/console/commit/19fbd02c367b452046d9d7e9e88601681a1bef29)) +* upgrade to dso-ns chart version 1.1.5 to use project slug in Vault secret path ([0fb9a4d](https://github.com/cloud-pi-native/console/commit/0fb9a4dfb96bb426b71f4f94470a1fde5ac21aa4)) + + +### Miscellaneous Chores + +* add VSCode debugging configurations ([2f96665](https://github.com/cloud-pi-native/console/commit/2f96665871ca5cec6fa302946923cc1f649ac84d)) +* **hooks:** change documentation to a sane pluging dependencies management ([41225c3](https://github.com/cloud-pi-native/console/commit/41225c3709f62788d111167084ddbe79f69f0f95)) +* **keycloak:** remove per project config ([0bf2a2a](https://github.com/cloud-pi-native/console/commit/0bf2a2a880dcc0256e525c48b7bb404dfe9459e0)) +* remove all local kubernetes cluster code ([e199ac4](https://github.com/cloud-pi-native/console/commit/e199ac4db2336d780b190a3f6b6188617c694f32)) + + +### Docs + +* add comments to .env files to clarify usage ([38a728e](https://github.com/cloud-pi-native/console/commit/38a728eb00efc936256f93b3231ef835b8c0b596)) +* split environment configuration from main README into its own ENVIRONMENTS doc ([79030e2](https://github.com/cloud-pi-native/console/commit/79030e277da5dde734e52bbc817cdc1d1748244b)) + +## [9.12.0](https://github.com/cloud-pi-native/console/compare/v9.11.5...v9.12.0) (2026-02-03) + + +### Features + +* **ci:** allow hotfixes with release-please ([52f6751](https://github.com/cloud-pi-native/console/commit/52f6751cece9d17207a60ca5355534be9e15cfbc)) +* **ci:** change release-please config and job to allow for hotfixes ([dd18790](https://github.com/cloud-pi-native/console/commit/dd187904f3a3309858bfd161fda54dddee943108)) +* **release-please:** allow chore, docs, and other commit types to trigger new releases ([cd717b8](https://github.com/cloud-pi-native/console/commit/cd717b8e47e8a4b74c25abcf9debee66f0a937bd)) + + +### Miscellaneous Chores + +* **ci:** avoid checking out the repository for create-preview-comment ([e8bb444](https://github.com/cloud-pi-native/console/commit/e8bb44428b7b2948f269e8e753289f7c57630586)) +* **ci:** move all jobs (not workflows) to dedicated directory ([7f23372](https://github.com/cloud-pi-native/console/commit/7f23372770b4481fc0bbe9bfe3dc40e2fa084e02)) +* **ci:** move release-please job file to dedicated jobs directory ([51a8621](https://github.com/cloud-pi-native/console/commit/51a86217b741de1b01a8e0840434298f32880e51)) +* **ci:** rename cd workflow to create-or-update-release ([36923f5](https://github.com/cloud-pi-native/console/commit/36923f5368f91cf082d218bbffebcbfc28a08099)) +* **ci:** rename ci to explicit continuous-integration workflow ([9841386](https://github.com/cloud-pi-native/console/commit/9841386c150c59eb6bf22fe4372b2dd8ba663a18)) +* **ci:** rename create-preview-comment to better explicit its purpose ([34c5de1](https://github.com/cloud-pi-native/console/commit/34c5de1274a91100ee7236e10969b972b04693ea)) +* **ci:** rename preview to explicit create-preview-release.yml workflow ([44c6c7d](https://github.com/cloud-pi-native/console/commit/44c6c7dcab4a66a35e42c4737ae282181828c680)) +* **ci:** rename workflows ([e15c6e5](https://github.com/cloud-pi-native/console/commit/e15c6e56ba476b2f401e55b3d9151c96749a6f12)) +* **doc:** update main README to fix/update how releases are performed ([4f4f130](https://github.com/cloud-pi-native/console/commit/4f4f130c019280535100e813255a6f8af84fa37d)) +* **server-nestjs:** add application to the version-bumpable ones ([a6b5e78](https://github.com/cloud-pi-native/console/commit/a6b5e78f9ac8bc12e809a9b11949fb2e258e4ded)) + + +### Docs + +* **README:** split release section into its own RELEASE.md file ([59a6b2f](https://github.com/cloud-pi-native/console/commit/59a6b2f2dbe31efeee16a7f79bcf57a201c6eac8)) +* **RELEASE:** add mermaidjs schema ([0b761d8](https://github.com/cloud-pi-native/console/commit/0b761d808a6cd092fa7cbd40ec10fb65718a0aaf)) +* **RELEASE:** document hotfixes creation workflow ([921de7c](https://github.com/cloud-pi-native/console/commit/921de7ca544424995e77a158b4f87290e172b24f)) + +## [9.11.5](https://github.com/cloud-pi-native/console/compare/v9.11.4...v9.11.5) (2026-01-26) + + +### Bug Fixes + +* avoid deleting new Application (and subresources) ([fdbead3](https://github.com/cloud-pi-native/console/commit/fdbead32aa8eb41dddb6b4bdb15b40cd4a9a3f64)) +* keycloak HTTPS required error on dev with Docker Desktop Mac ([2cc25af](https://github.com/cloud-pi-native/console/commit/2cc25af3a08d77303c41753feb67b614db35538e)) +* prisma not loading dotenv ([026b9e9](https://github.com/cloud-pi-native/console/commit/026b9e979513ea418e04fafb1b5bc412153eac04)) +* theme broke Keycloak startup ([4c6ce50](https://github.com/cloud-pi-native/console/commit/4c6ce5095ab3392a00c694d5878ae3da858b6b1b)) + +## [9.11.4](https://github.com/cloud-pi-native/console/compare/v9.11.3...v9.11.4) (2026-01-20) + + +### Bug Fixes + +* **gitlab:** restore gitlab-ci-dso file configuration ([8cc6d2c](https://github.com/cloud-pi-native/console/commit/8cc6d2ce7716260ef470ee0a7374f7491ffa68e6)) + +## [9.11.3](https://github.com/cloud-pi-native/console/compare/v9.11.2...v9.11.3) (2026-01-13) + + +### Bug Fixes + +* :bug: add projectsRootDir value in the vault values of each application ([26f4aec](https://github.com/cloud-pi-native/console/commit/26f4aec66e033336843eb722afaaecbf63b39bc7)) + +## [9.11.2](https://github.com/cloud-pi-native/console/compare/v9.11.1...v9.11.2) (2026-01-08) + + +### Bug Fixes + +* **vault/argocd:** rework how Vault values are passed to ArgoCD ([adbf9c3](https://github.com/cloud-pi-native/console/commit/adbf9c370a45412a54d4422abbac57e42282244d)) + +## [9.11.1](https://github.com/cloud-pi-native/console/compare/v9.11.0...v9.11.1) (2026-01-06) + + +### Bug Fixes + +* improve Keycloak error reporting in upsertProject function ([84b3773](https://github.com/cloud-pi-native/console/commit/84b37731f5b4498f925393408941d1c163e67d29)) +* **shared:** fix a rare pnpm vs typescript bug ([54ccd10](https://github.com/cloud-pi-native/console/commit/54ccd108694ed45e0fa48b1225582db4bc0ecc4d)) + +## [9.11.0](https://github.com/cloud-pi-native/console/compare/v9.10.2...v9.11.0) (2025-12-22) + + +### Features + +* :sparkles: add autosync management on environment ([df06f99](https://github.com/cloud-pi-native/console/commit/df06f995976cf396e344db990e506ec2ce8236c5)) + +## [9.10.2](https://github.com/cloud-pi-native/console/compare/v9.10.1...v9.10.2) (2025-12-18) + + +### Bug Fixes + +* :bug: upgrade dso-ns chart to fix pull secret path ([13b9648](https://github.com/cloud-pi-native/console/commit/13b96488406284c7efd7ca3cfa7ed168ca8b1fa2)) + +## [9.10.1](https://github.com/cloud-pi-native/console/compare/v9.10.0...v9.10.1) (2025-12-18) + + +### Bug Fixes + +* :bug: set non nullable and default constraints on Repository ([500e497](https://github.com/cloud-pi-native/console/commit/500e4978fe8fb7a23976bd8adb39f9650ad4dd32)) + +## [9.10.0](https://github.com/cloud-pi-native/console/compare/v9.9.0...v9.10.0) (2025-12-12) + + +### Features + +* :sparkles: add environment name tag in helm values ([1668604](https://github.com/cloud-pi-native/console/commit/1668604f436f4ae51beb3eef7b018b8f5e42a3f7)) +* :sparkles: Ajout des champs de saisie pour choisir les spécificités du dépôt d'infra ([5d2a156](https://github.com/cloud-pi-native/console/commit/5d2a156e691a680995031cac54d558ebc3ee3bff)) + + +### Bug Fixes + +* :bug: gitlab mirror access token generation ([7045582](https://github.com/cloud-pi-native/console/commit/70455823614a7684f1e3af7d220c5f99506208d8)) +* :bug: internal url for status monitor ([ba48bd6](https://github.com/cloud-pi-native/console/commit/ba48bd6602cbbebf2b41061e748de251686d19d1)) +* :bug: only regenerate mirror secret if there is user or token change ([a4a3e0e](https://github.com/cloud-pi-native/console/commit/a4a3e0e8b3156d1a3de76d42663d70dcd6ec0a94)) +* :bug: switch to sequential deletion ([30f7e47](https://github.com/cloud-pi-native/console/commit/30f7e478feb4661050222f28e0abd7d32e51c82b)) + +## [9.9.0](https://github.com/cloud-pi-native/console/compare/v9.8.1...v9.9.0) (2025-11-27) + + +### Features + +* :sparkles: distinguish between public and internal gitlab url ([616c01c](https://github.com/cloud-pi-native/console/commit/616c01c0d1b4607a48a52701a6b101bde8c50f22)) + +## [9.8.1](https://github.com/cloud-pi-native/console/compare/v9.8.0...v9.8.1) (2025-11-21) + + +### Bug Fixes + +* :bug: display associated project on cluster form ([488ff24](https://github.com/cloud-pi-native/console/commit/488ff24dab0d90a6af83e96bdbe815a0f955220e)) +* :bug: public url for Harbor image pull secret ([16cd209](https://github.com/cloud-pi-native/console/commit/16cd209a0274d045deeb51f7fabf432448c0066b)) +* **ci:** remove PNPM_VERSION from Github Actions ([bd1f4af](https://github.com/cloud-pi-native/console/commit/bd1f4af2592658f7edcbcada13f1ea6cafcab3d4)) +* rely on packageManager property to retrieve the right version of pnpm ([7b20b83](https://github.com/cloud-pi-native/console/commit/7b20b832b6f83d367c33573d87c444a8da315703)) +* **shared:** explicitely import Zod type ([2a8a4d8](https://github.com/cloud-pi-native/console/commit/2a8a4d8074e45742769fb2f451672777c4f4afa5)) +* **tests:** ensure Cypress tests are green again ([5b7042b](https://github.com/cloud-pi-native/console/commit/5b7042b0be48697ce9dfa4a69377a04ceae8fb5c)) + +## [9.8.0](https://github.com/cloud-pi-native/console/compare/v9.7.2...v9.8.0) (2025-11-17) + + +### Features + +* :fire: de-duplicates Argo CD links for default zone ([2db26cd](https://github.com/cloud-pi-native/console/commit/2db26cdfce71ffbf330bdbe07a48f1276bad7cd9)) +* :poop: change the naming convention of cluster secret ([a471766](https://github.com/cloud-pi-native/console/commit/a47176643cb9a97f41c1345477da60bce5935f85)) + + +### Bug Fixes + +* :bug: improve number inputs for decimal values ([d69a8ba](https://github.com/cloud-pi-native/console/commit/d69a8ba547174be521ac8cab0e47db2448415a61)) +* **#1640:** :bug: only regenerate robot secret if host changes ([e5d3ba8](https://github.com/cloud-pi-native/console/commit/e5d3ba87f895451795be1d7fa42a389e63521a68)) + + +### Performance Improvements + +* :zap: direct service communication for Gitlab pipelines ([4991451](https://github.com/cloud-pi-native/console/commit/4991451f3d0c7dbac32cf5fed1fc880b29a37a1c)) + +## [9.7.2](https://github.com/cloud-pi-native/console/compare/v9.7.1...v9.7.2) (2025-10-27) + + +### Bug Fixes + +* release please condition ([c293866](https://github.com/cloud-pi-native/console/commit/c29386619ff3624d22a2a72f9753bc443045b5c0)) + +## [9.7.1](https://github.com/cloud-pi-native/console/compare/v9.7.0...v9.7.1) (2025-10-27) + + +### Bug Fixes + +* :bug: use the renamed path of repository/group being deleted ([38b8cff](https://github.com/cloud-pi-native/console/commit/38b8cffaff3484257104d325dd9e034bfa2611fa)) + +## [9.7.0](https://github.com/cloud-pi-native/console/compare/v9.6.0...v9.7.0) (2025-10-27) + + +### Features + +* :sparkles: add support for SonarQube and Harbor internal url ([43ce886](https://github.com/cloud-pi-native/console/commit/43ce88690883d84214c29a4cb910f81609e8cb4d)) + +## [9.6.0](https://github.com/cloud-pi-native/console/compare/v9.5.0...v9.6.0) (2025-10-14) + + +### Features + +* :sparkles: allow cluster name update ([161086d](https://github.com/cloud-pi-native/console/commit/161086dfd7c226eac65b3f5e3c835b7f923cf511)) +* :sparkles: allow cluster name update ([0cbc53e](https://github.com/cloud-pi-native/console/commit/0cbc53eb7f3b0e41f128e1066f584aadb55b9766)), closes [#1582](https://github.com/cloud-pi-native/console/issues/1582) + +## [9.5.0](https://github.com/cloud-pi-native/console/compare/v9.4.0...v9.5.0) (2025-10-10) + + +### Features + +* :sparkles: add project usage on resources tab ([1ee66ce](https://github.com/cloud-pi-native/console/commit/1ee66ce8211c2017b328887b7800a2e1ab537300)) +* :sparkles: add resources occupancy on clusters ([75ce5e9](https://github.com/cloud-pi-native/console/commit/75ce5e9450ca68d45d2635b29e0ff8bd8d8614d7)) +* :sparkles: add tags retention policy rules ([a0e3f98](https://github.com/cloud-pi-native/console/commit/a0e3f9839d18c9459264e452b14cc87359a3f6c8)) +* :sparkles: check cluster resources when adding an environment ([826f1e9](https://github.com/cloud-pi-native/console/commit/826f1e9d8f46bf2aed419c7c395ef336131849dc)) +* :sparkles: save resources constraints for the project ([58e6d7a](https://github.com/cloud-pi-native/console/commit/58e6d7ad2d06fbb9e766fa755ccd7ab44556409e)) +* **client:** add service chain details form ([d56e68c](https://github.com/cloud-pi-native/console/commit/d56e68c626ee854fd12e60694acbc87d178ee270)) +* move environments and stages scenario from cypress to playwright ([c4e703f](https://github.com/cloud-pi-native/console/commit/c4e703f92f964e5850ca16ae7d066dc6e7d692cf)) +* **opencds:** add 'list all service chains' section in admin side menu ([f58de6e](https://github.com/cloud-pi-native/console/commit/f58de6e55ad23ed8f8e81aa769f76f9adc62b7b1)) +* **opencds:** add client-side service chain details page ([478bc35](https://github.com/cloud-pi-native/console/commit/478bc35408c43da95da875b105f1cd8222dfa217)) +* **opencds:** add feature toggling default value ([5dc6071](https://github.com/cloud-pi-native/console/commit/5dc607178179c5ab76dedf0d84ab22d3e08cabf5)) +* **opencds:** add flows retrieval and display as a stepper ([7194218](https://github.com/cloud-pi-native/console/commit/71942182464d6c42da0ab818f28ee60024c2e056)) +* **opencds:** add mockoon docker image for local dev and ci considerations ([0cf8de5](https://github.com/cloud-pi-native/console/commit/0cf8de5cd04860cdcfe2259640abffd48881ac5b)) +* **opencds:** adjust service chain list view ([fa0f5c3](https://github.com/cloud-pi-native/console/commit/fa0f5c3b91d940547ef132ad443f3b0a187238aa)) +* **opencds:** clean up mockoon json ([8bd1f74](https://github.com/cloud-pi-native/console/commit/8bd1f7483d5b772705c317510cded0552cad40c6)) +* **opencds:** handle OpenCDS API token-based security ([409ae6e](https://github.com/cloud-pi-native/console/commit/409ae6e40a2e42b54fd95a05904c35e1efd18371)) +* **opencds:** use a badge for state on service-chain list ([e3c1340](https://github.com/cloud-pi-native/console/commit/e3c134095e0dc7fb8009bfdb8fc14e6e8d62959b)) +* remove quota table ([1561d8d](https://github.com/cloud-pi-native/console/commit/1561d8da82ab1413047a3573269ddc4a133360ca)) +* remove Quota table references to use Environment attributes instead ([03297ec](https://github.com/cloud-pi-native/console/commit/03297ec88fb69834c6d76f61b25309ce1a363f4f)) +* update Cluster model to store resources capability ([6167743](https://github.com/cloud-pi-native/console/commit/6167743743c91f5e109da7297de275ea9d4d23d3)) +* update Environment form to use new resources attributes ([ff58b26](https://github.com/cloud-pi-native/console/commit/ff58b26662db51f385ce49ac14b4073bd5cf71cf)) + + +### Bug Fixes + +* :bug: update zone values when modifying a cluster ([253acb9](https://github.com/cloud-pi-native/console/commit/253acb9d6106727fc11b22c8268cfcd07320001a)), closes [#1642](https://github.com/cloud-pi-native/console/issues/1642) +* :lipstick: show resources in clusters and environment tables ([8494e2a](https://github.com/cloud-pi-native/console/commit/8494e2ad19afc7e0c8afb65ccc2ee216d7399615)) +* config and locator due to keycloak upgrade ([516d9d5](https://github.com/cloud-pi-native/console/commit/516d9d5bc34b5261490170f2c356c050168f54e5)) +* **opencds:** fix validatedBy pseudo-null value conflicting with OpenCDS contract in server ([cde51a2](https://github.com/cloud-pi-native/console/commit/cde51a2f08cfd64e57714f6096a6ec2d6bb8962f)) +* **playwright:** bump playwright to 1.54.2 ([44ea5e3](https://github.com/cloud-pi-native/console/commit/44ea5e3f5093a45cf95266c0691dad60ca33de26)) +* **playwright:** fix flaky test on cluster informations by adding a loader ([ffc7b43](https://github.com/cloud-pi-native/console/commit/ffc7b436efa7af16a1a7d166c52d02317c26ab74)) + +## [9.4.0](https://github.com/cloud-pi-native/console/compare/v9.3.3...v9.4.0) (2025-08-25) + + +### Features + +* :rocket: empty Vault url to use the default VaultConnection ([e2d3b28](https://github.com/cloud-pi-native/console/commit/e2d3b287964952b0f6dd0a00ea97499827cb7198)) +* :sparkles: create contact email environment variable ([779cb4a](https://github.com/cloud-pi-native/console/commit/779cb4a83492d4159a7efa868822adc5640dd6f8)) +* ✨ Add the repo UID display on console ([2ed10e4](https://github.com/cloud-pi-native/console/commit/2ed10e4b612687b9a18441458749d2821bcff43e)) +* ✨ Variabilize clipboard timeout ([2ed10e4](https://github.com/cloud-pi-native/console/commit/2ed10e4b612687b9a18441458749d2821bcff43e)) +* add Playwright E2E tests for Keycloak ([1524bba](https://github.com/cloud-pi-native/console/commit/1524bba8d6adcc8b360d740e85b51fcf8db71964)) +* add README for ArgoCD plugin ([5d66223](https://github.com/cloud-pi-native/console/commit/5d66223c5e6af45fdc02d3f1e062a6424b0e6cdc)) +* allow cluster informations property to be up to 1000 characters long ([9fe3182](https://github.com/cloud-pi-native/console/commit/9fe3182a5c771552f23f1e147475acd606efe67a)) +* configure Playwright Github workflow ([4ac7f55](https://github.com/cloud-pi-native/console/commit/4ac7f55f6fc6d3d747bafad20efa7184e608b146)) +* **playwright:** add Project page tests ([419ea3c](https://github.com/cloud-pi-native/console/commit/419ea3c735dd3ece8e02ce2d77beaa815170f7cd)) + + +### Bug Fixes + +* :bug: adapt status analysis to Nexus 3.83.1 response ([4efa720](https://github.com/cloud-pi-native/console/commit/4efa720c50e766068d3c8773df83e3754d29c9f1)) +* **#1681:** :bug: missing kv creation if not exists ([e862c5c](https://github.com/cloud-pi-native/console/commit/e862c5ca51dee5a0216f2c66040f90d510579df4)) +* **1638:** add validation for repo names in Zod schema ([48621bd](https://github.com/cloud-pi-native/console/commit/48621bdc63446be7cd5e38ca13b98762af322c16)) +* **client:** ensure branch name is reset to default value after synchronization on a repository ([af0ea44](https://github.com/cloud-pi-native/console/commit/af0ea44186dd8c22c370c5c7edd77b4807fb9e80)) +* **client:** fix Teams E2E cypress being broken after keycloak realm update ([9c95fdf](https://github.com/cloud-pi-native/console/commit/9c95fdffbb208cae9b930fc0d2ca9516bb2cbe9b)) +* fix docker build permissions to allow local path import ([c0aaa2f](https://github.com/cloud-pi-native/console/commit/c0aaa2f29017845575c38ef83d18850f6ae741cb)) +* fix docker build permissions to allow local path import ([6b9461d](https://github.com/cloud-pi-native/console/commit/6b9461dd5e481486a5d1e479f4364348e1cf4fda)) +* fix formatting issue ([662a834](https://github.com/cloud-pi-native/console/commit/662a8342bd83242a61eceae900eb328b1eab5cc5)) +* fix typo ([c20bd60](https://github.com/cloud-pi-native/console/commit/c20bd60e462f59222b1d873d65dcb2ec980b6c33)) +* **playwright:** ensure keycloak tests work for all languages ([44e27a3](https://github.com/cloud-pi-native/console/commit/44e27a3a8d666949ad29d26b8cbd77da50b067bb)) +* **playwright:** fix format command not working ([578e4b3](https://github.com/cloud-pi-native/console/commit/578e4b3e5a0538ac41e9410d09b47f45b98f30bb)) +* remove cluster resources to avoid confusion on its usage ([2ecf9d7](https://github.com/cloud-pi-native/console/commit/2ecf9d70330581b9cb757fa5822a85c6464e0bec)) +* **tests:** fix admin id in pgsql import data not being consistent with keycloak ([3ee2583](https://github.com/cloud-pi-native/console/commit/3ee2583c6f1f7761b5586047b6a93d40c3fb5d58)) + +## [9.3.3](https://github.com/cloud-pi-native/console/compare/v9.3.2...v9.3.3) (2025-06-12) + + +### Bug Fixes + +* :bug: fix gitlab deletion API request ([7acd71b](https://github.com/cloud-pi-native/console/commit/7acd71bc9aed370882b41f31c37dcbb8122bdd3d)) + +## [9.3.2](https://github.com/cloud-pi-native/console/compare/v9.3.1...v9.3.2) (2025-06-12) + + +### Bug Fixes + +* :bug: add missing custom source repositories in argocd values (pulling mode) ([5330f7a](https://github.com/cloud-pi-native/console/commit/5330f7ae16565d7f6e0840fab7bb86691c6aeb4a)) +* fix admin roles not being retrieved when a new user logs in for the first time ([e40c00b](https://github.com/cloud-pi-native/console/commit/e40c00b1b25ecb4026612018737a6a0a1abd5aec)) +* fix hiding tile header for all tiles instead of just the projects ones ([156e75a](https://github.com/cloud-pi-native/console/commit/156e75a215bd18a90b9abe6adee5a300ea180bbd)) + +## [9.3.1](https://github.com/cloud-pi-native/console/compare/v9.3.0...v9.3.1) (2025-05-23) + + +### Bug Fixes + +* :bug: adapt status analysis to Nexus 3.78 response ([95001c2](https://github.com/cloud-pi-native/console/commit/95001c2d96afa101e9867da177d5eed8dcc6b40d)) + +## [9.3.0](https://github.com/cloud-pi-native/console/compare/v9.2.1...v9.3.0) (2025-05-19) + + +### Features + +* :zap: optimization of gitlab API calls ([d9b236e](https://github.com/cloud-pi-native/console/commit/d9b236ebe032d4976094fed757467165ac40df25)) + + +### Bug Fixes + +* :bug: add missing scope when creating keycloak client ([ec24df1](https://github.com/cloud-pi-native/console/commit/ec24df116469237ad85e67d183560976283fc1ec)) +* :wrench: duplicate changelog entries ([#1587](https://github.com/cloud-pi-native/console/issues/1587)) ([ea9fdec](https://github.com/cloud-pi-native/console/commit/ea9fdec8e15132d537f888aec0369ccb655a3667)) +* allowed sources repositories ([a5ce7ad](https://github.com/cloud-pi-native/console/commit/a5ce7ad7a6a3cc17fa8726a6be42cdf53828034b)) + +## [9.2.1](https://github.com/cloud-pi-native/console/compare/v9.2.0...v9.2.1) (2025-03-25) + + +### Bug Fixes + +* :bug: update condition to allow private quota modification ([f4c34bb](https://github.com/cloud-pi-native/console/commit/f4c34bb1e462a9d393c4af231a0f524eb20fcad1)) + +## [9.2.0](https://github.com/cloud-pi-native/console/compare/v9.1.0...v9.2.0) (2025-03-21) + + +### Features + +* :zap: limit gitlab api calls to list cicd variables in sonar plugin ([c1b3299](https://github.com/cloud-pi-native/console/commit/c1b32998f4f340c945c657d2dbbb81160fc054f8)) + +## [9.1.0](https://github.com/cloud-pi-native/console/compare/v9.0.0...v9.1.0) (2025-02-19) + + +### Features + +* :sparkles: display last refresh monitor and display cause to admins ([054ea37](https://github.com/cloud-pi-native/console/commit/054ea37d556b1c6e0fabf0e52f4611395aec18b4)) + + +### Bug Fixes + +* :alien: fix nexus monitoring ([8bb7448](https://github.com/cloud-pi-native/console/commit/8bb74480deaa2b88628d2a715496e93aa4d27629)) +* :bug: missing icons ([d2b98c2](https://github.com/cloud-pi-native/console/commit/d2b98c201fb9f4ce3181c2d2b55fdac2a85f11f5)) + +## [9.0.0](https://github.com/cloud-pi-native/console/compare/v8.24.2...v9.0.0) (2025-02-14) + + +### ⚠ BREAKING CHANGES + +* :triangular_flag_on_post: remove organizations final part + +### Features + +* :sparkles: auto create projectRootDir in Gitlab ([b4f1212](https://github.com/cloud-pi-native/console/commit/b4f12120e81d8d689b1d65de12ed06c65a95279b)) +* :triangular_flag_on_post: remove organizations final part ([73ce9a9](https://github.com/cloud-pi-native/console/commit/73ce9a9497f44bb4d831aed8c25f9ee2e8a337f5)) +* search cluster, table display ([d21be53](https://github.com/cloud-pi-native/console/commit/d21be53e168a820f500334d97faa73ae30aeaf6e)) +* unique dashboard ([d21be53](https://github.com/cloud-pi-native/console/commit/d21be53e168a820f500334d97faa73ae30aeaf6e)) + + +### Bug Fixes + +* :ambulance: bad migration ([baa0712](https://github.com/cloud-pi-native/console/commit/baa071203693db68154ffda61f99a66a72a2c1c8)) +* :ambulance: forgot to use slug in gitlab group ([aa22f19](https://github.com/cloud-pi-native/console/commit/aa22f1963d0d87d28d1a9d74fbf13ac207545382)) +* :bug: auto re login when session expiry ([6c16e88](https://github.com/cloud-pi-native/console/commit/6c16e884d505372a8d2142d5870aed635ac4afbd)) + +## [8.24.2](https://github.com/cloud-pi-native/console/compare/v8.24.1...v8.24.2) (2025-02-06) + + +### Bug Fixes + +* :recycle: add projectId to url params ([3986376](https://github.com/cloud-pi-native/console/commit/3986376d418c017f8edc802ad237df4e3a29df25)) + +## [8.24.1](https://github.com/cloud-pi-native/console/compare/v8.24.0...v8.24.1) (2025-02-04) + + +### Bug Fixes + +* :ambulance: owner of a project cannot see attached private quota ([297a4ac](https://github.com/cloud-pi-native/console/commit/297a4ac410c739142d3ef88f1da58492881ddb6f)) + +## [8.24.0](https://github.com/cloud-pi-native/console/compare/v8.23.4...v8.24.0) (2025-01-28) + + +### Features + +* :sparkles: add an 'external' boolean properties on clusters ([4a64abc](https://github.com/cloud-pi-native/console/commit/4a64abccea40d2675f435b6cec21bb4f9aa3aa43)) +* :sparkles: vault plugin, switch to deploy or not vaultconnections ([cc9c4e6](https://github.com/cloud-pi-native/console/commit/cc9c4e6ad40412921a8d7afc888f014d92ef46c8)) + + +### Bug Fixes + +* :alien: gitlab response changed on upgrade ([d595f3a](https://github.com/cloud-pi-native/console/commit/d595f3ac80e84070e27ed257d4b8879ee2611d46)) +* :bug: names displayed twice at project creation ([079a20a](https://github.com/cloud-pi-native/console/commit/079a20a4e5d910e64e43a6df2925acee5ea7368c)) + +## [8.23.4](https://github.com/cloud-pi-native/console/compare/v8.23.3...v8.23.4) (2025-01-24) + + +### Bug Fixes + +* :bug: duplicates name should not be already allowed ([eb2a4db](https://github.com/cloud-pi-native/console/commit/eb2a4db44596ad2f9f6affbbdb2dea84cb28067c)) +* fix gitlab, no first commit on cloned repository ([2d7038f](https://github.com/cloud-pi-native/console/commit/2d7038fd1f5aefb67259666086048d11a6d5537f)) + +## [8.23.3](https://github.com/cloud-pi-native/console/compare/v8.23.2...v8.23.3) (2025-01-21) + + +### Bug Fixes + +* :ambulance: fix gitlab ci config file ([2c2a287](https://github.com/cloud-pi-native/console/commit/2c2a287a76723dfffecd9ae892a8897f03aef161)) + +## [8.23.2](https://github.com/cloud-pi-native/console/compare/v8.23.1...v8.23.2) (2025-01-21) + + +### Bug Fixes + +* :bug: add a better patch capability for kubernetes ([0050394](https://github.com/cloud-pi-native/console/commit/005039450701f9d77a4479487ba872af602ec689)) +* :bug: can't retrieved enc ns name for gitops cluster ([dcf3c1a](https://github.com/cloud-pi-native/console/commit/dcf3c1a147755ae698ba09f7bb7d1d79d13cc1d1)) +* :triangular_flag_on_post: no more cloned repository at gitlab createProject ([211d070](https://github.com/cloud-pi-native/console/commit/211d070d77802d894c70c584fa2c41e86a297295)) + +## [8.23.1](https://github.com/cloud-pi-native/console/compare/v8.23.0...v8.23.1) (2025-01-20) + + +### Bug Fixes + +* :ambulance: bad gitlab url fn ([2ec5332](https://github.com/cloud-pi-native/console/commit/2ec5332ce320e40da131ff537974c169716e3ac5)) + +## [8.23.0](https://github.com/cloud-pi-native/console/compare/v8.22.1...v8.23.0) (2025-01-20) + + +### Features + +* :fire: remove organization feature ([3b331f6](https://github.com/cloud-pi-native/console/commit/3b331f680716880e7ae4e33b6e441452e2816f3c)) +* :sparkles: store cluster secret in Vault ([cea79ef](https://github.com/cloud-pi-native/console/commit/cea79efc6ecb4c33d868d7f5044ddc7825c69ed5)) + + +### Bug Fixes + +* :bug: test if description is string before using replaceAll function ([d5b1234](https://github.com/cloud-pi-native/console/commit/d5b12342c236dca1231d05528f6ccd7746f62309)) + +## [8.22.1](https://github.com/cloud-pi-native/console/compare/v8.22.0...v8.22.1) (2025-01-09) + + +### Bug Fixes + +* :zap: package icons in client ([73fa90d](https://github.com/cloud-pi-native/console/commit/73fa90dd8aa0fe9c7fa8075ada31eea39af808e5)) +* patch vue-dsfr ([02079aa](https://github.com/cloud-pi-native/console/commit/02079aaf569b80f1a6a245504757524826a7ab3c)) + +## [8.22.0](https://github.com/cloud-pi-native/console/compare/v8.21.0...v8.22.0) (2025-01-08) + + +### Features + +* :sparkles: store cluster secret in Vault ([3935ca6](https://github.com/cloud-pi-native/console/commit/3935ca68cb0927bf150e15c3b205baebafa5cb98)) +* track project version provisioning ([227baf6](https://github.com/cloud-pi-native/console/commit/227baf6802687c9555d3931071a7e049d2756db1)) + + +### Bug Fixes + +* :bug: downgrade gitlab lib to avoid upsert user warning ([90faeff](https://github.com/cloud-pi-native/console/commit/90faeff2978e0ee2db7624b895550db278aa9c17)) +* :bug: nexus auto disable repo config on delete ([1295ece](https://github.com/cloud-pi-native/console/commit/1295ece0312a6c7b319c379e88be1ee570d75499)) + +## [8.21.0](https://github.com/cloud-pi-native/console/compare/v8.20.0...v8.21.0) (2024-12-16) + + +### Features + +* :construction: zone kv store management ([628a6e5](https://github.com/cloud-pi-native/console/commit/628a6e57cb439c8b899acdb8bf1995eedbe0a184)) + + +### Bug Fixes + +* :bug: fix not displaying swagger ([6f708a1](https://github.com/cloud-pi-native/console/commit/6f708a150402835c4ef044bdb8e69f20dfe3ee32)) +* :bug: retry delete project is failed ([c5856fa](https://github.com/cloud-pi-native/console/commit/c5856fadc49acd238ee61d289cf8441a5b3a2e18)) + +## [8.20.0](https://github.com/cloud-pi-native/console/compare/v8.19.1...v8.20.0) (2024-11-25) + + +### Features + +* :sparkles: bulk actions on projects ([951fc15](https://github.com/cloud-pi-native/console/commit/951fc152016e00e3bac6cb1d9920998e3cfb4a80)) + +## [8.19.1](https://github.com/cloud-pi-native/console/compare/v8.19.0...v8.19.1) (2024-11-23) + + +### Bug Fixes + +* :ambulance: broken admin token ([a1eff7f](https://github.com/cloud-pi-native/console/commit/a1eff7f96c6793338019eea0676b8f6dea4c0056)) + +## [8.19.0](https://github.com/cloud-pi-native/console/compare/v8.18.2...v8.19.0) (2024-11-22) + + +### Features + +* :art: use upsert zone hook to manage infra repo ([6fe47d1](https://github.com/cloud-pi-native/console/commit/6fe47d1922359c4934b5364fa3d87dd86cec129b)) +* :sparkles: easy in-cluster config ([01435e2](https://github.com/cloud-pi-native/console/commit/01435e2595383b4be00bdafb45970161765da6b9)) +* :sparkles: enable pwa on frontend ([c6c03f3](https://github.com/cloud-pi-native/console/commit/c6c03f3dadf9e954353ad11c29c19d50538d1e2a)) + + +### Bug Fixes + +* :bug: vault forgot to set kv to v2 ([90dca64](https://github.com/cloud-pi-native/console/commit/90dca64aca9d64b8491b332d0151f1be68dcbccc)) + +## [8.18.2](https://github.com/cloud-pi-native/console/compare/v8.18.1...v8.18.2) (2024-11-20) + + +### Bug Fixes + +* :bug: fix vault detect api hashicorp ([86959f5](https://github.com/cloud-pi-native/console/commit/86959f5874f60e898442ba4f734ff1a5581998a6)) + +## [8.18.1](https://github.com/cloud-pi-native/console/compare/v8.18.0...v8.18.1) (2024-11-20) + + +### Bug Fixes + +* :ambulance: fix pat migration ([df3f699](https://github.com/cloud-pi-native/console/commit/df3f699ddc972a2b2001a20d5e31dd5ba9ad7482)) + +## [8.18.0](https://github.com/cloud-pi-native/console/compare/v8.17.2...v8.18.0) (2024-11-20) + + +### Features + +* :sparkles: build project kv with config for projects ([f1c687f](https://github.com/cloud-pi-native/console/commit/f1c687f55ee4c8b8c2f342e431fb3f0982ffd72c)) +* ✨ add PAT ([2c35cd7](https://github.com/cloud-pi-native/console/commit/2c35cd72a1d0fb14f8c1a633b86e7ef00da80bab)) + + +### Bug Fixes + +* :bug: fix weird front behviours ([e06cf11](https://github.com/cloud-pi-native/console/commit/e06cf117c0fef4f49749d80a7ac5b8de813c6ed7)) + +## [8.17.2](https://github.com/cloud-pi-native/console/compare/v8.17.1...v8.17.2) (2024-11-14) + + +### Bug Fixes + +* :zap: disallow recursive list to avoid big latency ([1fed78c](https://github.com/cloud-pi-native/console/commit/1fed78c1742b70918c9bab1ff616f0cf681d66fa)) + +## [8.17.1](https://github.com/cloud-pi-native/console/compare/v8.17.0...v8.17.1) (2024-11-08) + + +### Bug Fixes + +* :ambulance: fix not displayed repositories in admin view ([48c0a92](https://github.com/cloud-pi-native/console/commit/48c0a92339db999c598fe24cdc9302b28cffa14d)) +* :ambulance: internal gitlab url ([16f195e](https://github.com/cloud-pi-native/console/commit/16f195e55222de023046aaabefa9b6d7f79062ee)) + +## [8.17.0](https://github.com/cloud-pi-native/console/compare/v8.16.0...v8.17.0) (2024-11-07) + + +### Features + +* :sparkles: config items section ([7c41eea](https://github.com/cloud-pi-native/console/commit/7c41eeaec2855355c0fa683d4b7ec81210eae6af)) +* :sparkles: hooks returned store accept null ([1255a32](https://github.com/cloud-pi-native/console/commit/1255a32237a6775e465fc0efe4dfe10bc9463816)) +* :sparkles: nexus fine write policy ([14897e6](https://github.com/cloud-pi-native/console/commit/14897e685ff6665da35464d93571a972e2c8dae9)) +* :sparkles: record and display last time a user logged ([1e8323b](https://github.com/cloud-pi-native/console/commit/1e8323bbfcd611891fe741434d4cb5f780f45dec)) +* gitlab optional internal url ([c1643a1](https://github.com/cloud-pi-native/console/commit/c1643a1eaae350220c4ecf114c7fa16bfb1983a1)) + +## [8.16.0](https://github.com/cloud-pi-native/console/compare/v8.15.1...v8.16.0) (2024-11-06) + + +### Features + +* :lipstick: disable cluster selection when creating a zone ([a8bb19c](https://github.com/cloud-pi-native/console/commit/a8bb19cb4ff50b69b892547e05b1541e322b0ecd)) +* :lipstick: user list rework ([5c13b9d](https://github.com/cloud-pi-native/console/commit/5c13b9d756c549a2f12d99e7f7384690f3166553)) +* :sparkles: nexus customizable write policy ([78a004b](https://github.com/cloud-pi-native/console/commit/78a004bc72e97b3a98c963560e8c11db45b34b67)) + + +### Bug Fixes + +* :bug: minors plugins fix and front ([22937c8](https://github.com/cloud-pi-native/console/commit/22937c875480dc0b6bf67f0784311f98300df04d)) + +## [8.15.1](https://github.com/cloud-pi-native/console/compare/v8.15.0...v8.15.1) (2024-10-29) + + +### Bug Fixes + +* :bug: update depracated import assertion type syntax ([d8f2072](https://github.com/cloud-pi-native/console/commit/d8f20728a0d96c6799e5bba7e821ed12356d6393)) + +## [8.15.0](https://github.com/cloud-pi-native/console/compare/v8.14.0...v8.15.0) (2024-10-29) + + +### Features + +* :lipstick: adding the project id in namespace labels ([eace611](https://github.com/cloud-pi-native/console/commit/eace611838cd2a5ce0e457971914c9d3bf29e6d0)) +* 🚸 rework project selection ([1641fc9](https://github.com/cloud-pi-native/console/commit/1641fc974f455734bedeb0cf2fdaf7356c9b850b)) + +## [8.14.0](https://github.com/cloud-pi-native/console/compare/v8.13.2...v8.14.0) (2024-10-17) + + +### Features + +* :lipstick: add argocd urls for each zone ([cf467ae](https://github.com/cloud-pi-native/console/commit/cf467aeaf82c44099f8b964dd4ef2e00a5323a90)) +* :sparkles: add npm support in npm ([60b7752](https://github.com/cloud-pi-native/console/commit/60b7752fd31ca36860e64df9b320d8468163d0d4)) +* :sparkles: introduce warn status on project ([9d69357](https://github.com/cloud-pi-native/console/commit/9d69357fde2014c1fe134803f8361680bac1d3fc)) +* :sparkles: keycloak client management for configuring sso oidc of each argocd zone ([44b3b06](https://github.com/cloud-pi-native/console/commit/44b3b06e3882fe2a60691a8bd29572e023b32146)) +* :sparkles: return warns and errors message from plugins to user ([be4443f](https://github.com/cloud-pi-native/console/commit/be4443fc10db5669578279fbcbac2b4d67adcb52)) +* :sparkles: show short logs about project to users ([4888e64](https://github.com/cloud-pi-native/console/commit/4888e64527d2627fc2e320300a39745e2571b73c)) + +## [8.13.2](https://github.com/cloud-pi-native/console/compare/v8.13.1...v8.13.2) (2024-10-02) + + +### Bug Fixes + +* :ambulance: no check on api response ([cfc56db](https://github.com/cloud-pi-native/console/commit/cfc56db5b4cdab03b3515d2cae1477e7546dfef8)) + +## [8.13.1](https://github.com/cloud-pi-native/console/compare/v8.13.0...v8.13.1) (2024-09-27) + + +### Bug Fixes + +* :loud_sound: change log logic in sonarqube plugin ([5006562](https://github.com/cloud-pi-native/console/commit/500656244f12d0d1edeb995e30be8cfa95ca8a4f)) + +## [8.13.0](https://github.com/cloud-pi-native/console/compare/v8.12.3...v8.13.0) (2024-09-27) + + +### Features + +* :loud_sound: add logger object ([02c6715](https://github.com/cloud-pi-native/console/commit/02c67150d738ae200f8937adb88bf32001ff2b8c)) +* :sparkles: plugins can return warning to not interupt hook ([3e132c3](https://github.com/cloud-pi-native/console/commit/3e132c3238cc98d1d75ec956ce55efebe2c5304a)) + + +### Bug Fixes + +* :bug: change imports exports to not use z.lazy, fix swagger-ui ([64a60fe](https://github.com/cloud-pi-native/console/commit/64a60fe6bc84ea9de51519049252957726963afa)) +* :bug: dsfr tabs require rewrite DsoHome.vue ([ea03623](https://github.com/cloud-pi-native/console/commit/ea0362303757ac239e5b753af6777514cb714b03)) + +## [8.12.3](https://github.com/cloud-pi-native/console/compare/v8.12.2...v8.12.3) (2024-09-19) + + +### Bug Fixes + +* :passport_control: allow more actions to admin token ([81fa606](https://github.com/cloud-pi-native/console/commit/81fa60653e2bfbcb37251bda448ea3d2f9805eb3)) + +## [8.12.2](https://github.com/cloud-pi-native/console/compare/v8.12.1...v8.12.2) (2024-09-19) + + +### Bug Fixes + +* :ambulance: cbx not checked cause vuedsfr breaking change ([c7e607f](https://github.com/cloud-pi-native/console/commit/c7e607fa6a373866489d7e72e7234107d76a3b7e)) + +## [8.12.1](https://github.com/cloud-pi-native/console/compare/v8.12.0...v8.12.1) (2024-09-17) + + +### Bug Fixes + +* :bug: fix behaviour of complex querying on user list ([b63bd9a](https://github.com/cloud-pi-native/console/commit/b63bd9a56c118fe52ab38589a28c90777c5a557e)) + +## [8.12.0](https://github.com/cloud-pi-native/console/compare/v8.11.1...v8.12.0) (2024-09-17) + + +### Features + +* :sparkles: add admins api tokens ([e141642](https://github.com/cloud-pi-native/console/commit/e141642eb2b717c29cf91f482e91761e7ce4bfa8)) +* :sparkles: add users search filter (admin) ([cc1ab56](https://github.com/cloud-pi-native/console/commit/cc1ab56b2ac496ffd01196ee4d97362ab7a08e03)) + +## [8.11.1](https://github.com/cloud-pi-native/console/compare/v8.11.0...v8.11.1) (2024-09-02) + + +### Bug Fixes + +* :lipstick: better repo ui ([084fc91](https://github.com/cloud-pi-native/console/commit/084fc91332fbdd6052cadbde204df77cd772a07d)) + +## [8.11.0](https://github.com/cloud-pi-native/console/compare/v8.10.1...v8.11.0) (2024-08-29) + + +### Features + +* :sparkles: standalone repo ([ea9a2ba](https://github.com/cloud-pi-native/console/commit/ea9a2bac3fc9696fde6f1ae56b916e180ae19039)) + + +### Bug Fixes + +* :ambulance: edit previous migrations ([2a87288](https://github.com/cloud-pi-native/console/commit/2a87288ea99c19df706fe2f68173ba4bea919a17)) + + +### Performance Improvements + +* :zap: avoid unuseful keycloak call and auto-refresh ([8dcb1c2](https://github.com/cloud-pi-native/console/commit/8dcb1c24e9cce760e278222dcb84c62dff4118a1)) + +## [8.10.1](https://github.com/cloud-pi-native/console/compare/v8.10.0...v8.10.1) (2024-08-27) + + +### Bug Fixes + +* :ambulance: missed admin authorized to remove a member ([15eafa9](https://github.com/cloud-pi-native/console/commit/15eafa9996d7e1f827f5eb1cf6aef425f4620a2b)) + +## [8.10.0](https://github.com/cloud-pi-native/console/compare/v8.9.4...v8.10.0) (2024-08-26) + + +### Features + +* :sparkles: add project dedicated approle and policy in vault, to be used by argocd ([e354978](https://github.com/cloud-pi-native/console/commit/e354978392b8c5d7a4c3591388f0f0a9aa3d7865)) +* :sparkles: introduce fine grained perms and and roles ([ab0a7a5](https://github.com/cloud-pi-native/console/commit/ab0a7a55e42ef94a18e529b168d5f299687ca526)) +* :sparkles: maintenance mode ([fdbd5f0](https://github.com/cloud-pi-native/console/commit/fdbd5f0356a16c24ef677f8a06309aac89af3a4c)) +* :sparkles: option to mirror all branches ([655b032](https://github.com/cloud-pi-native/console/commit/655b0329599fe7e0857e487e3b0fd3ecb49b94ac)) + +## [8.9.4](https://github.com/cloud-pi-native/console/compare/v8.9.3...v8.9.4) (2024-08-09) + + +### Bug Fixes + +* :ambulance: handle missing commit in gitlab repo ([2ef9930](https://github.com/cloud-pi-native/console/commit/2ef993001770e76be5bdf2795a0cbe8e302cf71a)) + +## [8.9.3](https://github.com/cloud-pi-native/console/compare/v8.9.2...v8.9.3) (2024-08-08) + + +### Bug Fixes + +* :ambulance: fix error on getArgoRepo when the infra repository is empty (zero commit) ([bf3f3f1](https://github.com/cloud-pi-native/console/commit/bf3f3f10c0e50eaf2f2b788b587c04e27f1eff00)) + +## [8.9.2](https://github.com/cloud-pi-native/console/compare/v8.9.1...v8.9.2) (2024-07-19) + + +### Bug Fixes + +* :ambulance: clone private repositories in gitlab plugin ([d42bbf8](https://github.com/cloud-pi-native/console/commit/d42bbf8a02950a6f9d0d3218bd7bc40cec203843)) + +## [8.9.1](https://github.com/cloud-pi-native/console/compare/v8.9.0...v8.9.1) (2024-07-18) + + +### Bug Fixes + +* :bug: search on HEAD ref instead of main ([ac13b5f](https://github.com/cloud-pi-native/console/commit/ac13b5fca8c952841e162ed300fdce099c74ac9b)) + +## [8.9.0](https://github.com/cloud-pi-native/console/compare/v8.8.2...v8.9.0) (2024-07-17) + + +### Features + +* :rocket: add helm detection and list values files ([006ccba](https://github.com/cloud-pi-native/console/commit/006ccbaa6d4710af8f1495ae8dadccf729b7dcc6)) + +## [8.8.2](https://github.com/cloud-pi-native/console/compare/v8.8.1...v8.8.2) (2024-07-15) + + +### Bug Fixes + +* :art: harmonize hook calling in business ([4effaab](https://github.com/cloud-pi-native/console/commit/4effaab7f9c80561d0fd52e365822d806a5426f5)) +* :bug: don't ignore user token when testing cluster deployment mode ([cc488c3](https://github.com/cloud-pi-native/console/commit/cc488c3053fadffba040f8396eebfa27bf6c1f79)) +* :bug: env name error not displayed ([d2f84e8](https://github.com/cloud-pi-native/console/commit/d2f84e822b94074615df3385f0c088582ceaf3cf)) +* :bug: require tls serverName ([9d4dea2](https://github.com/cloud-pi-native/console/commit/9d4dea22e68aab7ef6ee6b74a16e92a397002709)) + +## [8.8.1](https://github.com/cloud-pi-native/console/compare/v8.8.0...v8.8.1) (2024-07-09) + + +### Bug Fixes + +* :ambulance: not ready for project reponse validation ([c77dd68](https://github.com/cloud-pi-native/console/commit/c77dd6889886b3c867294437e5377573771a7ff0)) +* :ambulance: second try to fix ([1ef6244](https://github.com/cloud-pi-native/console/commit/1ef6244d7536de5704ec6fc9a83b0cfc5d806147)) + +## [8.8.1](https://github.com/cloud-pi-native/console/compare/v8.8.0...v8.8.1) (2024-07-09) + + +### Bug Fixes + +* :ambulance: not ready for project reponse validation ([c77dd68](https://github.com/cloud-pi-native/console/commit/c77dd6889886b3c867294437e5377573771a7ff0)) + +## [8.8.0](https://github.com/cloud-pi-native/console/compare/v8.7.1...v8.8.0) (2024-07-09) + + +### Features + +* :sparkles: query filter admin projects ([feb701d](https://github.com/cloud-pi-native/console/commit/feb701d9d36db0120032f0c1f8888a893c799495)) + +## [8.7.1](https://github.com/cloud-pi-native/console/compare/v8.7.0...v8.7.1) (2024-07-05) + + +### Bug Fixes + +* :bug: insert user when first accessing admin pages ([095b9de](https://github.com/cloud-pi-native/console/commit/095b9de4e282712919e932bf67b15fdaeec96932)) +* :lipstick: repo should be able to change infra, not infra ([1069728](https://github.com/cloud-pi-native/console/commit/10697280499b44b1a1eef28a71d510bfe8916cf3)) + +## [8.7.0](https://github.com/cloud-pi-native/console/compare/v8.6.0...v8.7.0) (2024-07-03) + + +### Features + +* :necktie: hide private quotas for all on environment form ([ef08f29](https://github.com/cloud-pi-native/console/commit/ef08f29080d2be32236839be1e402fe76f44f465)) + + +### Bug Fixes + +* :lock: serialize sevice monitor response ([26ee98f](https://github.com/cloud-pi-native/console/commit/26ee98fa21984e57eaae8251dc14394f0e94c4b9)) + +## [8.6.0](https://github.com/cloud-pi-native/console/compare/v8.5.0...v8.6.0) (2024-06-28) + + +### Features + +* :art: change subpath of deployments ([d7b57ca](https://github.com/cloud-pi-native/console/commit/d7b57ca095bcb1ae8fcde5ead7a533b0f0ce81e5)) +* :sparkles: add user leo poumailloux ([0147e07](https://github.com/cloud-pi-native/console/commit/0147e071bbdfbb8c666d20247bd95938f645f152)) +* :sparkles: give project ownership ([dc6fa79](https://github.com/cloud-pi-native/console/commit/dc6fa798fe2fe37395c521482bbc857eb74b50d1)) +* :sparkles: truncate description ([8191a41](https://github.com/cloud-pi-native/console/commit/8191a41ae808980d400f1e65499e012d0d192c28)) + + +### Bug Fixes + +* :bug: handle vault ha for status monitor ([3692e01](https://github.com/cloud-pi-native/console/commit/3692e010d9d4c0c03e8403b4d5df3591f0312209)) +* :bug: interface suppression & add explication ([4476c9b](https://github.com/cloud-pi-native/console/commit/4476c9bacdcc748ca0d2d1029f8980392a2ea068)) +* :bug: isolation des groupes d'envs dans keycloak ([91b5d55](https://github.com/cloud-pi-native/console/commit/91b5d551a88f5059868a80271be5b37c451839d0)) +* :bug: prevent env deletion if project is locked ([d75a5cf](https://github.com/cloud-pi-native/console/commit/d75a5cfc4e86741a91ba31d0c2d2fc2b21fa5226)) +* :bug: prevent env deletion if project is locked ([a273869](https://github.com/cloud-pi-native/console/commit/a273869ba421ee37fc342c585c38c978aab5d411)) +* :bug: support slashes in syncRepository branchName ([d304427](https://github.com/cloud-pi-native/console/commit/d304427c8bd0af8e7080ec9480143d0abab3bb76)) + +## [8.5.0](https://github.com/cloud-pi-native/console/compare/v8.4.1...v8.5.0) (2024-06-10) + + +### Features + +* :sparkles: add text filter for admin user list ([d44f142](https://github.com/cloud-pi-native/console/commit/d44f142e9f559f7714e3eb71650c1dd3c8a325cd)) +* :sparkles: allow harbor quota limit ([9644d03](https://github.com/cloud-pi-native/console/commit/9644d037ea2642ba9e456e362c18a1eed520b66b)) +* :sparkles: argocd extra repositories ([303f4a2](https://github.com/cloud-pi-native/console/commit/303f4a2fac89ce511aeac9cf87dbf6221908e373)) + + +### Bug Fixes + +* :bug: keycloak crashed when more than 10 environments ([f3d4f5d](https://github.com/cloud-pi-native/console/commit/f3d4f5d3cc5b49946cc4e1ab969895229524f335)) +* :bug: manage if no users in project ([cd23a59](https://github.com/cloud-pi-native/console/commit/cd23a5940eddf1d8509bf7594842db5bcc5fa1ae)) +* :bug: why is it useful ? ([ee2bf5c](https://github.com/cloud-pi-native/console/commit/ee2bf5ce588a92b303b3af8db2bf24ed999822eb)) + +## [8.4.1](https://github.com/cloud-pi-native/console/compare/v8.4.0...v8.4.1) (2024-05-31) + + +### Bug Fixes + +* :bug: add try catch to parsing plugin error ([b958529](https://github.com/cloud-pi-native/console/commit/b95852942863f75eda7458e7593e09579c7811d5)) + +## [8.4.0](https://github.com/cloud-pi-native/console/compare/v8.3.0...v8.4.0) (2024-05-29) + + +### Features + +* :sparkles: create a user usable robot for harbor ([a71dfc9](https://github.com/cloud-pi-native/console/commit/a71dfc992b62a6c3c69dcc6d7de98b8d2d72a79c)) +* :sparkles: customize keycloak theme ([7c21375](https://github.com/cloud-pi-native/console/commit/7c21375d1ad56a03f84c25d04cbe0ada7eac8d5c)) +* :sparkles: option for gitlab display secret ([28d3ebc](https://github.com/cloud-pi-native/console/commit/28d3ebce4d6912904e1f21cd3c126ca342fc3cfb)) + + +### Bug Fixes + +* :art: squash const ([cd1be8c](https://github.com/cloud-pi-native/console/commit/cd1be8c8f282be045ffce473387b21ed95355f5e)) +* :bug: fallback if no owners are found ([629ef40](https://github.com/cloud-pi-native/console/commit/629ef40afb3c6ff0a22b0f17c2c86a343463a8ba)) +* :bug: missing await and ambiguous message ([0530b45](https://github.com/cloud-pi-native/console/commit/0530b4520a5bc05d6a45f9f76029c898f960ee34)) +* :bug: update cluster logic ([65ba7e1](https://github.com/cloud-pi-native/console/commit/65ba7e10f0d7dd9e1675da633c922fff8499ee95)) + +## [8.3.0](https://github.com/cloud-pi-native/console/compare/v8.2.2...v8.3.0) (2024-05-16) + + +### Features + +* :sparkles: handle keycloak admin group membership from console ([4741878](https://github.com/cloud-pi-native/console/commit/4741878fdfa4d784ed2e7695407ebce6b00b4850)) + + +### Bug Fixes + +* :ambulance: possible missed clusters in payload ([47efcc8](https://github.com/cloud-pi-native/console/commit/47efcc893f272823b5d7d7f4b78b9c29ddad9095)) +* :ambulance: unhandled promise gitlab ([592035c](https://github.com/cloud-pi-native/console/commit/592035c95605f004e62cba71faff36ea189d6173)) + +## [8.2.2](https://github.com/cloud-pi-native/console/compare/v8.2.1...v8.2.2) (2024-05-13) + + +### Bug Fixes + +* :bug: fix update cluster ([f02958c](https://github.com/cloud-pi-native/console/commit/f02958ca3888fdb452a545fd94b8d20f9ebb93ed)) +* :bug: stores should have unique names ([6e8032a](https://github.com/cloud-pi-native/console/commit/6e8032a68b71d7ea86b8153d0984013e5bd4c8df)) + +## [8.2.1](https://github.com/cloud-pi-native/console/compare/v8.2.0...v8.2.1) (2024-05-06) + + +### Bug Fixes + +* :fire: remove unjustified refine ([9b1d41e](https://github.com/cloud-pi-native/console/commit/9b1d41ec402446678518bb39c0f3cff543afceb3)) +* :mute: remove problematic log ([639e982](https://github.com/cloud-pi-native/console/commit/639e982df49ccb5888adb26f3a0b1ca62fe53973)) + +## [8.2.0](https://github.com/cloud-pi-native/console/compare/v8.1.1...v8.2.0) (2024-05-03) + + +### Features + +* :sparkles: adding the value file commit ([6ea872b](https://github.com/cloud-pi-native/console/commit/6ea872b498e658113983cdc2b80d9d2ca3cf770d)) +* :sparkles: can configure plugins globally or by project ([15b66c1](https://github.com/cloud-pi-native/console/commit/15b66c1be31311e3b6062c3d771fb3ecc3dcbfa8)) + + +### Bug Fixes + +* :bug: override plugins unzip if already exists ([ff65133](https://github.com/cloud-pi-native/console/commit/ff651332fcee6ae752bdf4533ebbb88f216bfe95)) + +## [8.1.1](https://github.com/cloud-pi-native/console/compare/v8.1.0...v8.1.1) (2024-04-24) + + +### Bug Fixes + +* :ambulance: correct sql query for zoneId ([c47dae4](https://github.com/cloud-pi-native/console/commit/c47dae4a4f74cd51e6c9fe19856d4b8eea08cbb8)) + +## [8.1.0](https://github.com/cloud-pi-native/console/compare/v8.0.3...v8.1.0) (2024-04-24) + + +### Features + +* :safety_vest: allow unlogged users to access services health ([e2fc942](https://github.com/cloud-pi-native/console/commit/e2fc9423b107f1afd9fdbe1b017b7dd1f3d2eab1)) +* :safety_vest: check existing zone slug before creating one ([072fffb](https://github.com/cloud-pi-native/console/commit/072fffbd1d4788329fb76b927242854feb25d31b)) +* :sparkles: add zones ([53d155f](https://github.com/cloud-pi-native/console/commit/53d155f244a846e0cf23d3cf75528e6d937fe0ce)) +* :sparkles: allow repo sync from console ui ([1cd1c93](https://github.com/cloud-pi-native/console/commit/1cd1c93a9e1d95663e51f4de24a5b518fa2bee1a)) +* :sparkles: display project services in admin view ([a5d53fd](https://github.com/cloud-pi-native/console/commit/a5d53fd586dd912b13b72196ed99c2354c022448)) + + +### Bug Fixes + +* :bug: app version in client footer ([ac2f26e](https://github.com/cloud-pi-native/console/commit/ac2f26e88ca3fa593668d4f9641465b09855c663)) +* :bug: get only client app version from env on production build ([3bb3017](https://github.com/cloud-pi-native/console/commit/3bb3017f968e8e93b955b33cb494d51ecce5521a)) +* :bug: handle stage and zone changes in environment form ([5aa0a58](https://github.com/cloud-pi-native/console/commit/5aa0a585e9903e0a29f7bec29e4a08090109542b)) + +## [8.0.3](https://github.com/cloud-pi-native/console/compare/v8.0.2...v8.0.3) (2024-04-16) + + +### Bug Fixes + +* :ambulance: gitlab secret ([0c03a95](https://github.com/cloud-pi-native/console/commit/0c03a9551377705fced2dedf1b9e8063df9d2748)) + +## [8.0.2](https://github.com/cloud-pi-native/console/compare/v8.0.1...v8.0.2) (2024-04-11) + + +### Bug Fixes + +* :lock: remove old robot permission that harbor does not support anymore ([1d988ce](https://github.com/cloud-pi-native/console/commit/1d988ce86b6b338a576d517816f013295b2f18d3)) + +## [8.0.1](https://github.com/cloud-pi-native/console/compare/v8.0.0...v8.0.1) (2024-04-04) + + +### Bug Fixes + +* :ambulance: change kubernetes logic ([3679c22](https://github.com/cloud-pi-native/console/commit/3679c2221c961b4887e1191229edaacd6e34649b)) + +## [8.0.0](https://github.com/cloud-pi-native/console/compare/v7.0.1...v8.0.0) (2024-04-02) + + +### ⚠ BREAKING CHANGES + +* :sparkles: simplify hooks for idempotency + +### Features + +* :safety_vest: allow + sign for repo externalUserName ([4a695ef](https://github.com/cloud-pi-native/console/commit/4a695eff71ea6a03f839d19d2e2526180ea5161b)) +* :sparkles: add replay hooks button for project ([9147231](https://github.com/cloud-pi-native/console/commit/91472317ebd112b4f360d859d25d8793c89eb331)) +* :sparkles: add sonar properties var file to gitlab ([0462a36](https://github.com/cloud-pi-native/console/commit/0462a364b54e66639ade6997f6f4c48e9fe305c6)) +* :sparkles: simplify hooks for idempotency ([aac0a2d](https://github.com/cloud-pi-native/console/commit/aac0a2d331acd4e86531591de99cc944584f7684)) +* 🚧 création du repo infra-apps à la création du projet ([17007f7](https://github.com/cloud-pi-native/console/commit/17007f7eb0bb8c9ba612cf70536236b428b801da)) + + +### Bug Fixes + +* :ambulance: gitlab plugin deleted public repo secret ([46002b0](https://github.com/cloud-pi-native/console/commit/46002b007e9dfd69ce5daeee33cfd61164e0ed8e)) +* :ambulance: gitlab plugin deleted public repo secret ([353f43e](https://github.com/cloud-pi-native/console/commit/353f43eba74d5d0b0a95ace2ee65d9c51c0685b7)) +* :bug: reload user projects after hooks replay ([ae57ed0](https://github.com/cloud-pi-native/console/commit/ae57ed017dac4e5601cdf80a02598967bf75364b)) +* :lipstick: improve logs ui ([ec6ecb2](https://github.com/cloud-pi-native/console/commit/ec6ecb283f0c7c37589c02e194f1b101e9dc10c2)) +* :zap: manage hook concurrency execution ([6af135f](https://github.com/cloud-pi-native/console/commit/6af135f0548d232ef5bd190375057da0968d8f94)) + +## [7.0.1](https://github.com/cloud-pi-native/console/compare/v7.0.0...v7.0.1) (2024-03-18) + + +### Bug Fixes + +* :bug: add a common error parser for logs ([2391439](https://github.com/cloud-pi-native/console/commit/2391439ef6147119863278bd69ac01797b368f76)) +* :bug: add a common error parser for logs ([b6f28d7](https://github.com/cloud-pi-native/console/commit/b6f28d738c03c3c023d16cf658de8cdc384aa6df)) +* :bug: correctly handle external plugins init ([91e5d7f](https://github.com/cloud-pi-native/console/commit/91e5d7f27f90c1e249ed9e295b05400f06565c2d)) +* :green_heart: fix ci-cd build and release ([a036562](https://github.com/cloud-pi-native/console/commit/a036562f005ba0550c4bf25248227aee02357371)) + +## [7.0.0](https://github.com/cloud-pi-native/console/compare/console-v6.5.1...console-v7.0.0) (2024-03-13) + + +### ⚠ BREAKING CHANGES + +* :art: split plugins +* :boom: environment management + +### Features + +* :alien: synch controllers with plugin calls ([d0a271d](https://github.com/cloud-pi-native/console/commit/d0a271d188840d8682b6abe412ee8da4db7992b1)) +* :boom: environment management ([b4f3793](https://github.com/cloud-pi-native/console/commit/b4f379315803aee1264192c16851af049fa661ee)) +* :children_crossing: add confirm box for updating an organization ([5c3cd20](https://github.com/cloud-pi-native/console/commit/5c3cd207221a22ed5d93bdf8638b23c9849897f6)) +* :children_crossing: add default branch to sync in mirror pipeline ([45dbc01](https://github.com/cloud-pi-native/console/commit/45dbc01c4a95d04a201b2c5e198e3b192e1e5fe2)) +* :children_crossing: add filtering on admin projects view ([dc5412c](https://github.com/cloud-pi-native/console/commit/dc5412c1a80ce84cd563376a0b7f72da7926a858)) +* :children_crossing: better ux for forms in tiles ([da68243](https://github.com/cloud-pi-native/console/commit/da68243a91865732db46b9ae610a82eeec5c9bf2)) +* :children_crossing: improve home page and update doc link ([038d982](https://github.com/cloud-pi-native/console/commit/038d982ead7bbfa70debdf902d5c46e8518c1e60)) +* :children_crossing: inform users that clipboard works only with https ([909c3b6](https://github.com/cloud-pi-native/console/commit/909c3b672bcae1401d2133c8fff94cdca91f61bc)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :iphone: more responsive sideMenu and permissionForm ([c840306](https://github.com/cloud-pi-native/console/commit/c8403067224b5ad90e34b8233a1830ceeec8b715)) +* :loud_sound: add payload and full hook response in validation hooks ([adabb02](https://github.com/cloud-pi-native/console/commit/adabb02fd0b8028b1fcdf1113744234a925d72ce)) +* :loud_sound: add reqId in db logs ([2b7f22b](https://github.com/cloud-pi-native/console/commit/2b7f22b7e24748dc37c8623bbda3e914ed7cae00)) +* :necktie: do not fail and lock project for membership reasons ([91038d5](https://github.com/cloud-pi-native/console/commit/91038d5ed3533e4d4eacb8eac3539f29a60ab091)) +* :passport_control: update harbor project member role ([4419dd5](https://github.com/cloud-pi-native/console/commit/4419dd5f395f79a9eb99e8c0f305a7433ad84f7e)) +* :safety_vest: update password character support ([c82f35f](https://github.com/cloud-pi-native/console/commit/c82f35fb7f20ad06c0a63eb518407d17ddb88b51)) +* :sparkles: add backoffice for admins ([fadfdfc](https://github.com/cloud-pi-native/console/commit/fadfdfc43c427261adc3e31dbb1907d6dcefc5cb)) +* :sparkles: add infos key for cluster ([25ab8b8](https://github.com/cloud-pi-native/console/commit/25ab8b8d821c37f4491bd8fea075bdc7fbfcfc93)) +* :sparkles: add or remove a team member via keycloak ([0563d20](https://github.com/cloud-pi-native/console/commit/0563d20ca93a25decc7dbb5d945e2b738b0d575a)) +* :sparkles: add project environments to hook payload ([7aa8117](https://github.com/cloud-pi-native/console/commit/7aa81179ac95afdddf711e1a0a4b3c3a45a35c5a)) +* :sparkles: add quota and stage management for admin ([80ce642](https://github.com/cloud-pi-native/console/commit/80ce642b69a5eaaf1b53c7d884b70889c4e71d9d)) +* :sparkles: add quotas selection for environment ([bb97f5f](https://github.com/cloud-pi-native/console/commit/bb97f5f17c5e7b497eccb655a6eb5da692f7df73)) +* :sparkles: add stage in setPermission payload ([e920805](https://github.com/cloud-pi-native/console/commit/e920805a3181ae98bf98b774d19e02d1e8a2139c)) +* :sparkles: add stage management for admin ([e133853](https://github.com/cloud-pi-native/console/commit/e13385347610014a4edcc0b2ff12252386328cb8)) +* :sparkles: add tlsServerName input in cluster form ([4bdc01e](https://github.com/cloud-pi-native/console/commit/4bdc01ef9dd5b2041a34491e46b9759e5c6652f0)) +* :sparkles: better integration of sonarqube ([c0a0517](https://github.com/cloud-pi-native/console/commit/c0a05173a158b74d752aafaccdff58fc3b29c382)) +* :sparkles: delete cluster if no environment suscribed ([8edc50a](https://github.com/cloud-pi-native/console/commit/8edc50a85f266f1135b340d6df39a48ea7ac746b)) +* :sparkles: disallow hyphen on project, environment and stage ([f9b05ee](https://github.com/cloud-pi-native/console/commit/f9b05eefee41f7d514d749a0ac3fa1c1d063f051)) +* :sparkles: display cluster infos to users on environment form ([4afdf34](https://github.com/cloud-pi-native/console/commit/4afdf34fc40df7789a00fe6d159ee73370b94cb9)) +* :sparkles: download projects data button for admins ([cb38e75](https://github.com/cloud-pi-native/console/commit/cb38e7573314ba0980c4161d1a77a9e83fddb236)) +* :sparkles: enable admin cluster update ([f81008e](https://github.com/cloud-pi-native/console/commit/f81008e97c25bc73466b6257bd4cb2dc0bde9163)) +* :sparkles: handle gitlab membership ([7ff68bd](https://github.com/cloud-pi-native/console/commit/7ff68bdf8f9a16562cfc00fbdb0c322b0f09943d)) +* :sparkles: lock and unlock projects according to their org status ([3b9444a](https://github.com/cloud-pi-native/console/commit/3b9444aeaa2fab76461946e9a4d00e8afe4a62f1)) +* :sparkles: retrieve user from kc if does not exist in db ([3d4461a](https://github.com/cloud-pi-native/console/commit/3d4461ae7d82e6f64a1644921893d89f41e9a2d9)) +* :sparkles: services secrets improvement ([40e3ddd](https://github.com/cloud-pi-native/console/commit/40e3ddd2acd7e000b03f5bfa5663533449d315bf)) +* :sparkles: to fn, more infos, multi tiles ([8c4de83](https://github.com/cloud-pi-native/console/commit/8c4de83cf3b4bc0e335292d437ddf7d22305ff93)) +* :speech_balloon: translate stage in front for better understanding ([5a66479](https://github.com/cloud-pi-native/console/commit/5a6647980319524098a72fcbd2be6affbda3f3d0)) +* :technologist: add images auto-scan to harbor settings ([507c6e6](https://github.com/cloud-pi-native/console/commit/507c6e6a541b19502084276db30cf834e586d955)) +* :technologist: add swagger for server api ([abe15d2](https://github.com/cloud-pi-native/console/commit/abe15d2ceb93039ecd795389156f8730e5327841)) + + +### Bug Fixes + +* :alien: add owner in archiveProject hookpayload ([5c99183](https://github.com/cloud-pi-native/console/commit/5c99183f03afe0c20980c9649ce37788b833a351)) +* :alien: change logo for svg ([aa8315b](https://github.com/cloud-pi-native/console/commit/aa8315b63617c8413a448499e2ab0e68c216faef)) +* :alien: fetch does not support proxy natively ([20d531d](https://github.com/cloud-pi-native/console/commit/20d531d622e6fe9e2869e13db0d205b44ad602ce)) +* :alien: sync controllers require bigger axios timeout ([5116bbd](https://github.com/cloud-pi-native/console/commit/5116bbdb8f49b73c920ac04421ca05ccabef902c)) +* :ambulance: bad logic register plugins ([049040e](https://github.com/cloud-pi-native/console/commit/049040eafda26214afbc2118cb56542b133feca5)) +* :ambulance: preserve status colum in project ([febfaff](https://github.com/cloud-pi-native/console/commit/febfaff702d6a55210723ce213d603ad059ceaed)) +* :ambulance: register cluster destination via kubeconfig ([0e8d310](https://github.com/cloud-pi-native/console/commit/0e8d3102267268c595d172808e30183dbe4b3aad)) +* :ambulance: send internalRepoName to plugins on updateRepository hook ([2bde004](https://github.com/cloud-pi-native/console/commit/2bde0045592f2898975633c0c6f959311234f7f3)) +* :art: can disble plugins by name, and some fixes ([f2bd397](https://github.com/cloud-pi-native/console/commit/f2bd397e0526d76d439ef8f38b5dcebdfbaa8ede)) +* :art: refactor of admin project types and refs ([be0684f](https://github.com/cloud-pi-native/console/commit/be0684f378696a24304d7abecd6b90c0738e1785)) +* :bento: update MultiSelector component ([9a44bb3](https://github.com/cloud-pi-native/console/commit/9a44bb35d8aa0da1aaf8f96a745c28f97f6ea53c)) +* :bug: add conditional chaining to avoid error in dsobadge ([56ad040](https://github.com/cloud-pi-native/console/commit/56ad0403242222f50ef8abe898b46c241d40b66d)) +* :bug: add missing await in gitlab createProjectMirror ([f1bf907](https://github.com/cloud-pi-native/console/commit/f1bf9072b88abec3f08654720f64571aa421db34)) +* :bug: change appproject policy ([e20b6c4](https://github.com/cloud-pi-native/console/commit/e20b6c431348b08bed7ea215a967c51d0e6187f0)) +* :bug: clusters related bugs ([c2ba63e](https://github.com/cloud-pi-native/console/commit/c2ba63e94d87bdf1741e9a51e4522c3f77b354de)) +* :bug: create appropriate folders for server image ([3027fef](https://github.com/cloud-pi-native/console/commit/3027fef08ebc8a58e2e705645febaf40af7314f5)) +* :bug: filter archived project from cluster form ([50913f5](https://github.com/cloud-pi-native/console/commit/50913f57def7d8e3e36f63a5876e476e64884828)) +* :bug: fix endless redirect on login if authenticated ([461c6bd](https://github.com/cloud-pi-native/console/commit/461c6bd385f44a551fe9af1027c6beca1e7fa9ec)) +* :bug: fix gitlab group deletion ([12a35d0](https://github.com/cloud-pi-native/console/commit/12a35d08619fbeb231d4e7931fb7fdb51ef63fde)) +* :bug: fix missing extensions ([4cd4b21](https://github.com/cloud-pi-native/console/commit/4cd4b212e88d30b447d5674cf157ce71862fe14a)) +* :bug: fix not updated argocd cluster and not using insecure in config ([69e7a9a](https://github.com/cloud-pi-native/console/commit/69e7a9ac7a584d76ce047b8330356ce2387218d7)) +* :bug: fix types, add void and handle it ([110cfed](https://github.com/cloud-pi-native/console/commit/110cfedfcb2a58757b5ad47f96f6323cbe034550)) +* :bug: import new shared package ([ff0b0db](https://github.com/cloud-pi-native/console/commit/ff0b0db9dcc96c10b409b51bd4180c03b5b01478)) +* :bug: launch e2e test in open mode without turbo ([d3497d1](https://github.com/cloud-pi-native/console/commit/d3497d13e3862d7701cbbd2da65cb7a571032eb2)) +* :bug: manage cancel emit on update repo form ([16df7b4](https://github.com/cloud-pi-native/console/commit/16df7b43d584fb1edfa5a01938356b04b785429b)) +* :bug: missed limits ([f2ba02d](https://github.com/cloud-pi-native/console/commit/f2ba02d41e923b0add154fc7cb5ae7d8a2c89370)) +* :bug: missing check in search group ([7fb65c1](https://github.com/cloud-pi-native/console/commit/7fb65c12754eea955c7ba942329cf5bfa612f8b7)) +* :bug: missing check nexus find user ([993dd2a](https://github.com/cloud-pi-native/console/commit/993dd2ab30687d0ea23000596f909b77b3be70e5)) +* :bug: missing check nexus find user ([e0f1674](https://github.com/cloud-pi-native/console/commit/e0f167424df8e0c01619a0da38bf3e5723014ee1)) +* :bug: now the admin can chosse skip tls api kube ([e09aa2a](https://github.com/cloud-pi-native/console/commit/e09aa2ac659e1439f59050c56256f530b9f8cae6)) +* :bug: optional paramater to call repeatFn ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: pin pnpm version in dockerfiles ([b494a2f](https://github.com/cloud-pi-native/console/commit/b494a2f0d190e3da81f9765dded76187333e61af)) +* :bug: pnpm lock ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: retrieve kc user bug fix ([aaf61c5](https://github.com/cloud-pi-native/console/commit/aaf61c51a288cd7998a5d3883cf467471b42c3cb)) +* :bug: run.sh js to ts reference ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: send cluster kubeconfig in hook paylaod ([e21c692](https://github.com/cloud-pi-native/console/commit/e21c6923d016bb59a69be457d5d4efc08243a771)) +* :bug: share loadingct display knowledge ([8520a5c](https://github.com/cloud-pi-native/console/commit/8520a5c827d05316e9dedc59dd90a92fb3f513d4)) +* :bug: should not set caData if skipTLSVerify is true ([ccd66f8](https://github.com/cloud-pi-native/console/commit/ccd66f83be75850ffe47e87ff405fcf9cf136e3c)) +* :bug: update compile cmd to build in plugins pkgs ([b9e4424](https://github.com/cloud-pi-native/console/commit/b9e44246c22238c3cdb383515816951ace4f65c2)) +* :bug: use argoCD regex for internalRepoName ([6c2ed3f](https://github.com/cloud-pi-native/console/commit/6c2ed3f682514b947e66547ce136d148b9e2d686)) +* :bug: various fixes around plugins and controllers ([6271c67](https://github.com/cloud-pi-native/console/commit/6271c67f3d8471ee6afc3409c5d753f7ade950f0)) +* :card_file_box: set all project status to failed for v6 release ([b689c35](https://github.com/cloud-pi-native/console/commit/b689c356f27e769b18aabab2f2510dd24354d95f)) +* :construction: fix configs ([ff77d7a](https://github.com/cloud-pi-native/console/commit/ff77d7ab10aecdf92643f9bcece9ef3360bc4c46)) +* :goal_net: add try catch in checkApi func ([baf38af](https://github.com/cloud-pi-native/console/commit/baf38af3f96fe227a934228d7875181e1407c8e9)) +* :goal_net: allow deleting environment if not found ([a2dc7da](https://github.com/cloud-pi-native/console/commit/a2dc7daf96942121e9291063569ff9c6612bd345)) +* :goal_net: throw error if catched in business ([1790ec2](https://github.com/cloud-pi-native/console/commit/1790ec274e965d5d62a0693937eec5ee765602e2)) +* :goal_net: validate schema before creating repo or env ([23535c3](https://github.com/cloud-pi-native/console/commit/23535c3e0c9923df0cc8adb5fb646dc2dd21fb7c)) +* :heavy_plus_sign: fix pnpm-lock file ([93aa64e](https://github.com/cloud-pi-native/console/commit/93aa64e146e11f30c8305f85d7ff03b696b0297b)) +* :label: add missing fastify keycloak adapter patch to improve types ([2d4ca1f](https://github.com/cloud-pi-native/console/commit/2d4ca1fc64c374cd06cef5b27842502db59a0c1c)) +* :lipstick: add icons ([ed1887b](https://github.com/cloud-pi-native/console/commit/ed1887baf6a65d0d58c6e399991a1419351c3883)) +* :lipstick: fix padding ([907d424](https://github.com/cloud-pi-native/console/commit/907d424a94f2dfc7e7110882438130bdb5dc4cd8)) +* :lipstick: restyle logs interface ([fc362cf](https://github.com/cloud-pi-native/console/commit/fc362cf461c65f6e4e8e7b6b2d92c83a41844bdf)) +* :lock: root token is not used in new repo credential when synced ([c92ea8b](https://github.com/cloud-pi-native/console/commit/c92ea8b5ef95097b07c2bbda435ce433f5bba53d)) +* :pushpin: pin axios version to avoid vite error ([88c6d30](https://github.com/cloud-pi-native/console/commit/88c6d30b5ab14cd9ca899fdf7cfcd17faa067903)) +* :rewind: fix bugs introduced during my vacations ([9ee467c](https://github.com/cloud-pi-native/console/commit/9ee467c69b590a4630dae19f42f2fc5c9321953e)) +* :safety_vest: validate user schema before trying to insert it in db ([ab8e6ef](https://github.com/cloud-pi-native/console/commit/ab8e6efdb00ec29c6a9a1e0f89c58ffbc999c966)) +* :technologist: change domains for dev and int ([0de00ab](https://github.com/cloud-pi-native/console/commit/0de00abf78543bc769f80be2f0e59795cfefefa5)) +* :technologist: fix docker volumes for integ mode ([80af072](https://github.com/cloud-pi-native/console/commit/80af072bfc0affa8ab0b415a3f6d63d17ceaf2c2)) +* :technologist: fix import kubeconfig in integration ([51af3dd](https://github.com/cloud-pi-native/console/commit/51af3dd16128a6d4a1e676921937005e295acfc9)) +* :test_tube: trying to fix ct test ([c67366e](https://github.com/cloud-pi-native/console/commit/c67366e0b319de7edd681b9ba9265091be5c9e32)) +* :test_tube: trying to optimize dependancies for failing modules ([ec97602](https://github.com/cloud-pi-native/console/commit/ec9760236a380efdcabc5bc0f0cb7e9b23c07adb)) +* :wrench: handle no values for disabledPlugins ([d0d46a3](https://github.com/cloud-pi-native/console/commit/d0d46a38570db6f626f5879da180829216826fa8)) + + +### Performance Improvements + +* :construction: skip failing test for merge ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) + + +### Reverts + +* :card_file_box: add missing default for projectStatus ([2263988](https://github.com/cloud-pi-native/console/commit/22639888442da48b097f936d0afb62652d262b02)) +* :card_file_box: add missing default for projectStatus ([5653165](https://github.com/cloud-pi-native/console/commit/565316504a1baa90d2c7c999c0ba6810151324f5)) +* :poop: comment harbor projet member role update ([63926ae](https://github.com/cloud-pi-native/console/commit/63926ae9c5a5fe6b9e6e8a2d0007f55d186b0f36)) + + +### Build System + +* :art: split plugins ([f4ac305](https://github.com/cloud-pi-native/console/commit/f4ac30587917b78d6d0f3e58cdda1b8a08123b48)) + +## [6.5.2](https://github.com/cloud-pi-native/console/compare/v6.5.1...v6.5.2) (2024-03-13) + + +### Bug Fixes + +* :bug: missing check in search group ([7fb65c1](https://github.com/cloud-pi-native/console/commit/7fb65c12754eea955c7ba942329cf5bfa612f8b7)) +* :bug: missing check nexus find user ([e0f1674](https://github.com/cloud-pi-native/console/commit/e0f167424df8e0c01619a0da38bf3e5723014ee1)) + +## [6.5.1](https://github.com/cloud-pi-native/console/compare/v6.5.0...v6.5.1) (2024-01-25) + + +### Bug Fixes + +* :bug: share loadingct display knowledge ([8520a5c](https://github.com/cloud-pi-native/console/commit/8520a5c827d05316e9dedc59dd90a92fb3f513d4)) + +## [6.5.0](https://github.com/cloud-pi-native/console/compare/v6.4.0...v6.5.0) (2024-01-25) + + +### Features + +* :children_crossing: better ux for forms in tiles ([da68243](https://github.com/cloud-pi-native/console/commit/da68243a91865732db46b9ae610a82eeec5c9bf2)) +* :loud_sound: add payload and full hook response in validation hooks ([adabb02](https://github.com/cloud-pi-native/console/commit/adabb02fd0b8028b1fcdf1113744234a925d72ce)) +* :loud_sound: add reqId in db logs ([2b7f22b](https://github.com/cloud-pi-native/console/commit/2b7f22b7e24748dc37c8623bbda3e914ed7cae00)) +* :sparkles: add project environments to hook payload ([7aa8117](https://github.com/cloud-pi-native/console/commit/7aa81179ac95afdddf711e1a0a4b3c3a45a35c5a)) +* :sparkles: disallow hyphen on project, environment and stage ([f9b05ee](https://github.com/cloud-pi-native/console/commit/f9b05eefee41f7d514d749a0ac3fa1c1d063f051)) +* :sparkles: download projects data button for admins ([cb38e75](https://github.com/cloud-pi-native/console/commit/cb38e7573314ba0980c4161d1a77a9e83fddb236)) +* :sparkles: to fn, more infos, multi tiles ([8c4de83](https://github.com/cloud-pi-native/console/commit/8c4de83cf3b4bc0e335292d437ddf7d22305ff93)) + + +### Bug Fixes + +* :goal_net: validate schema before creating repo or env ([23535c3](https://github.com/cloud-pi-native/console/commit/23535c3e0c9923df0cc8adb5fb646dc2dd21fb7c)) +* :pushpin: pin axios version to avoid vite error ([88c6d30](https://github.com/cloud-pi-native/console/commit/88c6d30b5ab14cd9ca899fdf7cfcd17faa067903)) + +## [6.4.0](https://github.com/cloud-pi-native/console/compare/v6.3.1...v6.4.0) (2023-12-20) + + +### Features + +* :children_crossing: add filtering on admin projects view ([dc5412c](https://github.com/cloud-pi-native/console/commit/dc5412c1a80ce84cd563376a0b7f72da7926a858)) +* :sparkles: display cluster infos to users on environment form ([4afdf34](https://github.com/cloud-pi-native/console/commit/4afdf34fc40df7789a00fe6d159ee73370b94cb9)) +* :speech_balloon: translate stage in front for better understanding ([5a66479](https://github.com/cloud-pi-native/console/commit/5a6647980319524098a72fcbd2be6affbda3f3d0)) + + +### Bug Fixes + +* :art: refactor of admin project types and refs ([be0684f](https://github.com/cloud-pi-native/console/commit/be0684f378696a24304d7abecd6b90c0738e1785)) + +## [6.3.1](https://github.com/cloud-pi-native/console/compare/v6.3.0...v6.3.1) (2023-12-12) + + +### Bug Fixes + +* :bug: missed limits ([f2ba02d](https://github.com/cloud-pi-native/console/commit/f2ba02d41e923b0add154fc7cb5ae7d8a2c89370)) + +## [6.3.0](https://github.com/cloud-pi-native/console/compare/v6.2.0...v6.3.0) (2023-12-11) + + +### Features + +* :sparkles: add backoffice for admins ([fadfdfc](https://github.com/cloud-pi-native/console/commit/fadfdfc43c427261adc3e31dbb1907d6dcefc5cb)) + + +### Bug Fixes + +* :bug: fix gitlab group deletion ([12a35d0](https://github.com/cloud-pi-native/console/commit/12a35d08619fbeb231d4e7931fb7fdb51ef63fde)) + +## [6.2.0](https://github.com/cloud-pi-native/console/compare/v6.1.0...v6.2.0) (2023-11-20) + + +### Features + +* :sparkles: delete cluster if no environment suscribed ([8edc50a](https://github.com/cloud-pi-native/console/commit/8edc50a85f266f1135b340d6df39a48ea7ac746b)) + + +### Bug Fixes + +* :alien: add owner in archiveProject hookpayload ([5c99183](https://github.com/cloud-pi-native/console/commit/5c99183f03afe0c20980c9649ce37788b833a351)) +* :bug: use argoCD regex for internalRepoName ([6c2ed3f](https://github.com/cloud-pi-native/console/commit/6c2ed3f682514b947e66547ce136d148b9e2d686)) + +## [6.1.0](https://github.com/cloud-pi-native/console/compare/v6.0.0...v6.1.0) (2023-11-13) + + +### Features + +* :sparkles: add quota and stage management for admin ([80ce642](https://github.com/cloud-pi-native/console/commit/80ce642b69a5eaaf1b53c7d884b70889c4e71d9d)) +* :sparkles: add stage management for admin ([e133853](https://github.com/cloud-pi-native/console/commit/e13385347610014a4edcc0b2ff12252386328cb8)) + + +### Bug Fixes + +* :bug: add missing await in gitlab createProjectMirror ([f1bf907](https://github.com/cloud-pi-native/console/commit/f1bf9072b88abec3f08654720f64571aa421db34)) +* :bug: fix endless redirect on login if authenticated ([461c6bd](https://github.com/cloud-pi-native/console/commit/461c6bd385f44a551fe9af1027c6beca1e7fa9ec)) +* :bug: should not set caData if skipTLSVerify is true ([ccd66f8](https://github.com/cloud-pi-native/console/commit/ccd66f83be75850ffe47e87ff405fcf9cf136e3c)) +* :lock: root token is not used in new repo credential when synced ([c92ea8b](https://github.com/cloud-pi-native/console/commit/c92ea8b5ef95097b07c2bbda435ce433f5bba53d)) + +## [6.0.0](https://github.com/cloud-pi-native/console/compare/v5.11.0...v6.0.0) (2023-10-27) + + +### ⚠ BREAKING CHANGES + +* :boom: environment management + +### Features + +* :boom: environment management ([b4f3793](https://github.com/cloud-pi-native/console/commit/b4f379315803aee1264192c16851af049fa661ee)) +* :sparkles: services secrets improvement ([40e3ddd](https://github.com/cloud-pi-native/console/commit/40e3ddd2acd7e000b03f5bfa5663533449d315bf)) + + +### Bug Fixes + +* :alien: change logo for svg ([aa8315b](https://github.com/cloud-pi-native/console/commit/aa8315b63617c8413a448499e2ab0e68c216faef)) +* :bug: add conditional chaining to avoid error in dsobadge ([56ad040](https://github.com/cloud-pi-native/console/commit/56ad0403242222f50ef8abe898b46c241d40b66d)) +* :bug: launch e2e test in open mode without turbo ([d3497d1](https://github.com/cloud-pi-native/console/commit/d3497d13e3862d7701cbbd2da65cb7a571032eb2)) +* :card_file_box: set all project status to failed for v6 release ([b689c35](https://github.com/cloud-pi-native/console/commit/b689c356f27e769b18aabab2f2510dd24354d95f)) + +## [5.11.0](https://github.com/cloud-pi-native/console/compare/v5.10.1...v5.11.0) (2023-10-13) + + +### Features + +* :children_crossing: add default branch to sync in mirror pipeline ([45dbc01](https://github.com/cloud-pi-native/console/commit/45dbc01c4a95d04a201b2c5e198e3b192e1e5fe2)) + + +### Bug Fixes + +* :goal_net: add try catch in checkApi func ([baf38af](https://github.com/cloud-pi-native/console/commit/baf38af3f96fe227a934228d7875181e1407c8e9)) + +## [5.10.1](https://github.com/cloud-pi-native/console/compare/v5.10.0...v5.10.1) (2023-10-10) + +### Bug Fixes + +* :ambulance: send internalRepoName to plugins on updateRepository hook ([2bde004](https://github.com/cloud-pi-native/console/commit/2bde0045592f2898975633c0c6f959311234f7f3)) + +## [5.10.0](https://github.com/cloud-pi-native/console/compare/v5.9.1...v5.10.0) (2023-10-03) + +### Features + +* :iphone: more responsive sideMenu and permissionForm ([c840306](https://github.com/cloud-pi-native/console/commit/c8403067224b5ad90e34b8233a1830ceeec8b715)) +* :sparkles: enable admin cluster update ([f81008e](https://github.com/cloud-pi-native/console/commit/f81008e97c25bc73466b6257bd4cb2dc0bde9163)) + +## [5.9.1](https://github.com/cloud-pi-native/console/compare/v5.9.0...v5.9.1) (2023-09-28) + +### Bug Fixes + +* :bug: fix not updated argocd cluster and not using insecure in config ([69e7a9a](https://github.com/cloud-pi-native/console/commit/69e7a9ac7a584d76ce047b8330356ce2387218d7)) + +## [5.9.0](https://github.com/cloud-pi-native/console/compare/v5.8.2...v5.9.0) (2023-09-25) + +### Features + +* :technologist: add swagger for server api ([abe15d2](https://github.com/cloud-pi-native/console/commit/abe15d2ceb93039ecd795389156f8730e5327841)) + +### Bug Fixes + +* :ambulance: register cluster destination via kubeconfig ([0e8d310](https://github.com/cloud-pi-native/console/commit/0e8d3102267268c595d172808e30183dbe4b3aad)) +* :safety_vest: validate user schema before trying to insert it in db ([ab8e6ef](https://github.com/cloud-pi-native/console/commit/ab8e6efdb00ec29c6a9a1e0f89c58ffbc999c966)) + +## [5.8.2](https://github.com/cloud-pi-native/console/compare/v5.8.1...v5.8.2) (2023-09-20) + +### Bug Fixes + +* :wrench: handle no values for disabledPlugins ([d0d46a3](https://github.com/cloud-pi-native/console/commit/d0d46a38570db6f626f5879da180829216826fa8)) + +## [5.8.1](https://github.com/cloud-pi-native/console/compare/v5.8.0...v5.8.1) (2023-09-18) + +### Bug Fixes + +* :ambulance: bad logic register plugins ([049040e](https://github.com/cloud-pi-native/console/commit/049040eafda26214afbc2118cb56542b133feca5)) + +## [5.8.0](https://github.com/cloud-pi-native/console/compare/v5.7.0...v5.8.0) (2023-09-18) + +### Features + +* :passport_control: update harbor project member role ([4419dd5](https://github.com/cloud-pi-native/console/commit/4419dd5f395f79a9eb99e8c0f305a7433ad84f7e)) +* :sparkles: add infos key for cluster ([25ab8b8](https://github.com/cloud-pi-native/console/commit/25ab8b8d821c37f4491bd8fea075bdc7fbfcfc93)) +* :sparkles: add tlsServerName input in cluster form ([4bdc01e](https://github.com/cloud-pi-native/console/commit/4bdc01ef9dd5b2041a34491e46b9759e5c6652f0)) +* :technologist: add images auto-scan to harbor settings ([507c6e6](https://github.com/cloud-pi-native/console/commit/507c6e6a541b19502084276db30cf834e586d955)) + +### Bug Fixes + +* :alien: fetch does not support proxy natively ([20d531d](https://github.com/cloud-pi-native/console/commit/20d531d622e6fe9e2869e13db0d205b44ad602ce)) +* :art: can disble plugins by name, and some fixes ([f2bd397](https://github.com/cloud-pi-native/console/commit/f2bd397e0526d76d439ef8f38b5dcebdfbaa8ede)) +* :bug: clusters related bugs ([c2ba63e](https://github.com/cloud-pi-native/console/commit/c2ba63e94d87bdf1741e9a51e4522c3f77b354de)) +* :bug: now the admin can chosse skip tls api kube ([e09aa2a](https://github.com/cloud-pi-native/console/commit/e09aa2ac659e1439f59050c56256f530b9f8cae6)) +* :goal_net: throw error if catched in business ([1790ec2](https://github.com/cloud-pi-native/console/commit/1790ec274e965d5d62a0693937eec5ee765602e2)) +* :heavy_plus_sign: fix pnpm-lock file ([93aa64e](https://github.com/cloud-pi-native/console/commit/93aa64e146e11f30c8305f85d7ff03b696b0297b)) + +### Reverts + +* :poop: comment harbor projet member role update ([63926ae](https://github.com/cloud-pi-native/console/commit/63926ae9c5a5fe6b9e6e8a2d0007f55d186b0f36)) + +## [5.7.0](https://github.com/cloud-pi-native/console/compare/v5.6.0...v5.7.0) (2023-09-07) + +### Features + +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :closed_lock_with_key: retrieve project secrets from vault and display it in front ([201435d](https://github.com/cloud-pi-native/console/commit/201435d18047400ec9fe4252b6be2c11dbd57068)) +* :safety_vest: update password character support ([c82f35f](https://github.com/cloud-pi-native/console/commit/c82f35fb7f20ad06c0a63eb518407d17ddb88b51)) +* :sparkles: handle gitlab membership ([7ff68bd](https://github.com/cloud-pi-native/console/commit/7ff68bdf8f9a16562cfc00fbdb0c322b0f09943d)) + +### Bug Fixes + +* :bug: change appproject policy ([e20b6c4](https://github.com/cloud-pi-native/console/commit/e20b6c431348b08bed7ea215a967c51d0e6187f0)) +* :bug: import new shared package ([ff0b0db](https://github.com/cloud-pi-native/console/commit/ff0b0db9dcc96c10b409b51bd4180c03b5b01478)) + +### Reverts + +* :card_file_box: add missing default for projectStatus ([2263988](https://github.com/cloud-pi-native/console/commit/22639888442da48b097f936d0afb62652d262b02)) +* :card_file_box: add missing default for projectStatus ([5653165](https://github.com/cloud-pi-native/console/commit/565316504a1baa90d2c7c999c0ba6810151324f5)) + +## [5.6.0](https://github.com/cloud-pi-native/console/compare/v5.5.0...v5.6.0) (2023-08-28) + +### Features + +* :sparkles: better integration of sonarqube ([c0a0517](https://github.com/cloud-pi-native/console/commit/c0a05173a158b74d752aafaccdff58fc3b29c382)) + +### Bug Fixes + +* :ambulance: preserve status colum in project ([febfaff](https://github.com/cloud-pi-native/console/commit/febfaff702d6a55210723ce213d603ad059ceaed)) +* :bug: optional paramater to call repeatFn ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: pnpm lock ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :bug: run.sh js to ts reference ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) +* :lipstick: fix padding ([907d424](https://github.com/cloud-pi-native/console/commit/907d424a94f2dfc7e7110882438130bdb5dc4cd8)) +* :lipstick: restyle logs interface ([fc362cf](https://github.com/cloud-pi-native/console/commit/fc362cf461c65f6e4e8e7b6b2d92c83a41844bdf)) + +### Performance Improvements + +* :construction: skip failing test for merge ([a7925dc](https://github.com/cloud-pi-native/console/commit/a7925dcfbaaf1b1727e8cdede06bf9c24d89cad8)) + +## [5.5.0](https://github.com/cloud-pi-native/console/compare/v5.4.0...v5.5.0) (2023-08-22) + +### Features + +* :children_crossing: inform users that clipboard works only with https ([909c3b6](https://github.com/cloud-pi-native/console/commit/909c3b672bcae1401d2133c8fff94cdca91f61bc)) +* :sparkles: retrieve user from kc if does not exist in db ([3d4461a](https://github.com/cloud-pi-native/console/commit/3d4461ae7d82e6f64a1644921893d89f41e9a2d9)) + +### Bug Fixes + +* :alien: sync controllers require bigger axios timeout ([5116bbd](https://github.com/cloud-pi-native/console/commit/5116bbdb8f49b73c920ac04421ca05ccabef902c)) +* :bento: update MultiSelector component ([9a44bb3](https://github.com/cloud-pi-native/console/commit/9a44bb35d8aa0da1aaf8f96a745c28f97f6ea53c)) +* :bug: filter archived project from cluster form ([50913f5](https://github.com/cloud-pi-native/console/commit/50913f57def7d8e3e36f63a5876e476e64884828)) +* :bug: fix types, add void and handle it ([110cfed](https://github.com/cloud-pi-native/console/commit/110cfedfcb2a58757b5ad47f96f6323cbe034550)) +* :bug: manage cancel emit on update repo form ([16df7b4](https://github.com/cloud-pi-native/console/commit/16df7b43d584fb1edfa5a01938356b04b785429b)) +* :bug: retrieve kc user bug fix ([aaf61c5](https://github.com/cloud-pi-native/console/commit/aaf61c51a288cd7998a5d3883cf467471b42c3cb)) +* :bug: various fixes around plugins and controllers ([6271c67](https://github.com/cloud-pi-native/console/commit/6271c67f3d8471ee6afc3409c5d753f7ade950f0)) +* :goal_net: allow deleting environment if not found ([a2dc7da](https://github.com/cloud-pi-native/console/commit/a2dc7daf96942121e9291063569ff9c6612bd345)) +* :lipstick: add icons ([ed1887b](https://github.com/cloud-pi-native/console/commit/ed1887baf6a65d0d58c6e399991a1419351c3883)) +* :rewind: fix bugs introduced during my vacations ([9ee467c](https://github.com/cloud-pi-native/console/commit/9ee467c69b590a4630dae19f42f2fc5c9321953e)) +* :technologist: fix import kubeconfig in integration ([51af3dd](https://github.com/cloud-pi-native/console/commit/51af3dd16128a6d4a1e676921937005e295acfc9)) + +## [5.4.0](https://github.com/cloud-pi-native/console/compare/v5.3.0...v5.4.0) (2023-08-02) + +### Features + +* :children_crossing: improve home page and update doc link ([038d982](https://github.com/cloud-pi-native/console/commit/038d982ead7bbfa70debdf902d5c46e8518c1e60)) +* :sparkles: add or remove a team member via keycloak ([0563d20](https://github.com/cloud-pi-native/console/commit/0563d20ca93a25decc7dbb5d945e2b738b0d575a)) + +### Bug Fixes + +* :technologist: change domains for dev and int ([0de00ab](https://github.com/cloud-pi-native/console/commit/0de00abf78543bc769f80be2f0e59795cfefefa5)) + +## [5.3.0](https://github.com/cloud-pi-native/console/compare/v5.2.1...v5.3.0) (2023-08-01) + +### Features + +* :alien: synch controllers with plugin calls ([d0a271d](https://github.com/cloud-pi-native/console/commit/d0a271d188840d8682b6abe412ee8da4db7992b1)) +* :children_crossing: add confirm box for updating an organization ([5c3cd20](https://github.com/cloud-pi-native/console/commit/5c3cd207221a22ed5d93bdf8638b23c9849897f6)) +* :sparkles: lock and unlock projects according to their org status ([3b9444a](https://github.com/cloud-pi-native/console/commit/3b9444aeaa2fab76461946e9a4d00e8afe4a62f1)) + +### Bug Fixes + +* :bug: create appropriate folders for server image ([3027fef](https://github.com/cloud-pi-native/console/commit/3027fef08ebc8a58e2e705645febaf40af7314f5)) +* :bug: fix missing extensions ([4cd4b21](https://github.com/cloud-pi-native/console/commit/4cd4b212e88d30b447d5674cf157ce71862fe14a)) +* :bug: pin pnpm version in dockerfiles ([b494a2f](https://github.com/cloud-pi-native/console/commit/b494a2f0d190e3da81f9765dded76187333e61af)) + +## [5.2.1](https://github.com/cloud-pi-native/console/compare/v5.2.0...v5.2.1) (2023-07-21) + +### Bug Fixes + +* :adhesive_bandage: update kaniko stage name in gitlab ci ([7a71712](https://github.com/cloud-pi-native/console/commit/7a71712075d7aed5aeeb00adb1d2ec51b6d717ab)) + +## [5.2.0](https://github.com/cloud-pi-native/console/compare/v5.1.0...v5.2.0) (2023-07-20) + +### Features + +* :children_crossing: improve gitlab mirroring by using a single mirror repo ([914bbe4](https://github.com/cloud-pi-native/console/commit/914bbe4546a3e876a6cd8847523f18e95d688d44)) +* :sparkles: add id in admin user table ([d1ebcea](https://github.com/cloud-pi-native/console/commit/d1ebcea1768993b6ab31e5f45a6642ac3577cfc5)) + +### Bug Fixes + +* :bug: add missing data to add argo cluster ([fc53f54](https://github.com/cloud-pi-native/console/commit/fc53f54d8f2d211857aa82c43589b9ee26e214b8)) +* :bug: delete kube resources when deleting a project ([5e8cd51](https://github.com/cloud-pi-native/console/commit/5e8cd514edc8a0eb4f5d49208cb17b6da82f4fd7)) +* :bug: fix argo plugin ([b1dc1fb](https://github.com/cloud-pi-native/console/commit/b1dc1fb60ba89b534c3e3cd357b89a4e08b74838)) + +## [5.1.0](https://github.com/cloud-pi-native/console/compare/v5.0.0...v5.1.0) (2023-07-18) + +### Features + +* :alien: add project infos for canel plugin ([a4516a1](https://github.com/cloud-pi-native/console/commit/a4516a14b2439dd3a74540168aee6daa586c7308)) +* :alien: add updateProject hook for description update for example ([7e97505](https://github.com/cloud-pi-native/console/commit/7e97505b5a75c3f6c680d5b1bb6f18a9e45b87b3)) + +### Bug Fixes + +* :bug: fix projectLimit for gitlab plugin ([13fca89](https://github.com/cloud-pi-native/console/commit/13fca89f37a16b980dce7f16e87151510d6f0b63)) +* :bug: missing await before plugins hook and logs recording on archiveProject ([e221341](https://github.com/cloud-pi-native/console/commit/e221341a7db27d67bfa40e4231d47de1e5e8648f)) +* :bug: missing await before plugins hook and logs recording on archiveProject ([2515c62](https://github.com/cloud-pi-native/console/commit/2515c624afd1d89718a98085c5e41c1c996157b8)) + +## [5.0.0](https://github.com/cloud-pi-native/console/compare/v4.1.0...v5.0.0) (2023-07-13) + +### ⚠ BREAKING CHANGES + +* :boom: migrate from sequelize to prisma + +### Features + +* :children_crossing: add organization to differenciate project ([5814d7e](https://github.com/cloud-pi-native/console/commit/5814d7e00b5a6a335a16059b62f7af456d3b4a58)) +* :sparkles: allow updating repo ([27b59cf](https://github.com/cloud-pi-native/console/commit/27b59cfc5cacdf0e96b0fd22b090ce4ab038792d)) +* :sparkles: always show ci form ([753d64b](https://github.com/cloud-pi-native/console/commit/753d64bc011701dcc9297097b1a0aec87cef6c03)) +* :tada: introduce multi-cluster deployment ([0e6c065](https://github.com/cloud-pi-native/console/commit/0e6c065d421c117050e00944ca68114414a05dc7)) +* :technologist: add config for codespace ([1ff7996](https://github.com/cloud-pi-native/console/commit/1ff79966454be7961ba80049be65815cfce1d4cf)) + +### Bug Fixes + +* :alien: change gitlab api calls ([f04dc87](https://github.com/cloud-pi-native/console/commit/f04dc87208ca19ee2b06ff2c35535e51a72f84cd)) +* :art: finish pr ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :art: quote fixes ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :bug: add module on shared ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :bug: fix imports, change dependencies ([41f427f](https://github.com/cloud-pi-native/console/commit/41f427fe17daef7ef1b3c330ec5635338edfda59)) +* :bug: fix logs pagination bug ([7f3a990](https://github.com/cloud-pi-native/console/commit/7f3a99085f701d02367fab04f194dc0c0e85356d)) +* :bug: forgot await in argo plugin ([f4504d4](https://github.com/cloud-pi-native/console/commit/f4504d4b1ccc999274c3267508c943f23fd8690b)) +* :bug: retrieve also inactive orgs on admin project list page ([4b8977e](https://github.com/cloud-pi-native/console/commit/4b8977e01214295f3090ab16a92bde4b7d7a64f9)) +* :construction_worker: fix CI: tsconfig is needed ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :construction: in progress ([2150961](https://github.com/cloud-pi-native/console/commit/2150961433d0b097100ea62cf1fd7be49fbb4571)) +* :lipstick: more contrasted icon color for archived project ([790d276](https://github.com/cloud-pi-native/console/commit/790d2761d3f77756144172e3ccd80bcb0df4572d)) + +### Code Refactoring + +* :boom: migrate from sequelize to prisma ([37b065d](https://github.com/cloud-pi-native/console/commit/37b065d6cff844dfd1b9d2276831eddefa64a71e)) + +## [4.1.0](https://github.com/cloud-pi-native/console/compare/v4.0.0...v4.1.0) (2023-06-12) + +### Features + +* :sparkles: add admin log page ([41058ee](https://github.com/cloud-pi-native/console/commit/41058ee51899d4ec351ae7adad10eb3fefa99f8b)) + +### Bug Fixes + +* :bug: harbor, fix remove repos from unknown project ([8a18381](https://github.com/cloud-pi-native/console/commit/8a18381c2197634d90649a8520dd068a59e5369c)) +* :bug: test if external dir exists ([3276400](https://github.com/cloud-pi-native/console/commit/3276400a00b3435eb87e225e0e8deadd5a311b0d)) +* :zap: gitlab, stabilize user search and lifecycle ([179c912](https://github.com/cloud-pi-native/console/commit/179c91269ecf04ff6a489a5d92f490b940446e70)) + +## [4.0.0](https://github.com/dnum-mi/dso-console/compare/v3.4.1...v4.0.0) (2023-06-08) + +### ⚠ BREAKING CHANGES + +* :boom: clarify business logic on status and locking + +### Features + +* :alien: add hook to synchronize organizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :alien: add hook to synchronize organizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :alien: add keys to source organizations ([626c031](https://github.com/dnum-mi/dso-console/commit/626c03135571199c3a1f1e72e19132bc651288a3)) +* :boom: if status: failed (aka plugins failed), project remains locked ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) +* :children_crossing: improve user experience on dso-console ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :children_crossing: improve ux for danger zone ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :children_crossing: improve UX on permission form ([fb72076](https://github.com/dnum-mi/dso-console/commit/fb72076f16f79df456392fc7f9a7129ab1655832)) +* :children_crossing: redirect to dashboard when selecting a project ([720b3c9](https://github.com/dnum-mi/dso-console/commit/720b3c9570238453edeb9ceff11ba21f37c50c41)) +* :necktie: allow only one plugin to register on hook fetchOrganizations ([6ccd985](https://github.com/dnum-mi/dso-console/commit/6ccd9855fc8713043a9f24687cfe07d105540c91)) +* :sparkles: add project admin view ([b49ff96](https://github.com/dnum-mi/dso-console/commit/b49ff967853e31ca6cbcd8cd2ff78b299023788e)) + +### Bug Fixes + +* :art: temp fix labels, add check, refacto get ns ([a43f50c](https://github.com/dnum-mi/dso-console/commit/a43f50cb93b2453f025b9d4284f2b65b6134161a)) +* :bug: fix payload, fix check step ([ff76b27](https://github.com/dnum-mi/dso-console/commit/ff76b2750352cbe7b2d49ff5fb6e4d3e424b328f)) +* :bug: remove harbor repos before projects ([7239329](https://github.com/dnum-mi/dso-console/commit/72393297dcfaf44938e2814cccfd8366613ba75c)) +* :necktie: clarify business logic on status and locking ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) + +### Code Refactoring + +* :boom: clarify business logic on status and locking ([8548d6e](https://github.com/dnum-mi/dso-console/commit/8548d6e0d654673789bdebba31237fa98a86788a)) + +## [3.4.1](https://github.com/dnum-mi/dso-console/compare/v3.4.0...v3.4.1) (2023-05-22) + +### Bug Fixes + +* :art: change ns labels ([ae14d67](https://github.com/dnum-mi/dso-console/commit/ae14d6736a3b3276b3406562709cf77e3aab0668)) +* :construction: hooks payload send full owner object, bug getSingleOwnerByProjectId ([0e74895](https://github.com/dnum-mi/dso-console/commit/0e74895695b42d4e13a645ef4005afbffec59b64)) + +## [3.4.0](https://github.com/dnum-mi/dso-console/compare/v3.3.1...v3.4.0) (2023-05-15) + +### Features + +* :alien: owner name is required to create Canel user ([e4aa5e9](https://github.com/dnum-mi/dso-console/commit/e4aa5e9662fb0494ef43b7f52140c9c11702613f)) + +## [3.3.1](https://github.com/dnum-mi/dso-console/compare/v3.3.0...v3.3.1) (2023-05-11) + +### Bug Fixes + +* :ambulance: keycloak now sends group path ([913f402](https://github.com/dnum-mi/dso-console/commit/913f40295f7f0eab17964e72e38e61200523456e)) + +## [3.3.0](https://github.com/dnum-mi/dso-console/compare/v3.2.3...v3.3.0) (2023-05-11) + +### Features + +* :sparkles: add organization page for admins ([5e6b7fa](https://github.com/dnum-mi/dso-console/commit/5e6b7fa252285b432ef5869aedf8881b642eff53)) + +### Bug Fixes + +* :ambulance: handle unocss migration for media queries ([8fdf445](https://github.com/dnum-mi/dso-console/commit/8fdf4455a34a274c3723685928549bb30bb04762)) +* :sparkles: set ingress secretName ([8be3479](https://github.com/dnum-mi/dso-console/commit/8be34796b15219a6b4f77585662b21d8d04c76c8)) + +## [3.2.3](https://github.com/dnum-mi/dso-console/compare/v3.2.2...v3.2.3) (2023-05-09) + +### Bug Fixes + +* :ambulance: keycloak group id, typo ([1aedf77](https://github.com/dnum-mi/dso-console/commit/1aedf77b4a74698bb1d52cbfe6863bd249a7eadb)) + +## [3.2.2](https://github.com/dnum-mi/dso-console/compare/v3.2.1...v3.2.2) (2023-05-09) + +### Bug Fixes + +* :ambulance: missing ownerid at env init ([14696ff](https://github.com/dnum-mi/dso-console/commit/14696ffc24a2bfe5345b135e99f48502ba0d2fae)) + +## [3.2.1](https://github.com/dnum-mi/dso-console/compare/v3.2.0...v3.2.1) (2023-05-09) + +### Bug Fixes + +* :ambulance: habor missing slash on oidc group ([e85b903](https://github.com/dnum-mi/dso-console/commit/e85b9031f77c7feba05f04b9444c5d6862342246)) +* :ambulance: keycloak do not add owner in group env ([f20b3da](https://github.com/dnum-mi/dso-console/commit/f20b3da4a8666d3b2912c3b7147819818a96c8ed)) + +## [3.2.0](https://github.com/dnum-mi/dso-console/compare/v3.1.0...v3.2.0) (2023-05-05) + +### Features + +* :lipstick: better ui for permission form ([56e0739](https://github.com/dnum-mi/dso-console/commit/56e0739d935d96c4eedd55807151f72f8fafcb6b)) +* :sparkles: add description field for a project ([e9d65ed](https://github.com/dnum-mi/dso-console/commit/e9d65eda3084ac46b48f8e7a4a7e78324cf8d351)) +* :sparkles: add download link for includes.zip ([0e067d1](https://github.com/dnum-mi/dso-console/commit/0e067d1965e39084d3f21191e27aa3b82948df17)) +* :sparkles: introduce users admin view ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) + +### Bug Fixes + +* :alien: refacto plugins to check services before save in database ([c58f3da](https://github.com/dnum-mi/dso-console/commit/c58f3dab057fd39f62e20ca3951278a87f6cfad0)) +* :bug: forget default value for log table ([ade449b](https://github.com/dnum-mi/dso-console/commit/ade449b0a834f850d4f64c94cfb489174b946463)) +* :construction: fix e2e test admin (temporary) ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) +* :construction: use ingress ([621d5ee](https://github.com/dnum-mi/dso-console/commit/621d5eee80adfb5f14459b93c7f51b431dd61c31)) +* :fire: remove routes ([65dabcb](https://github.com/dnum-mi/dso-console/commit/65dabcb28f8ede298975a2cec3e7ff5623ee4d8c)) +* :loud_sound: log database sync error ([3e73dac](https://github.com/dnum-mi/dso-console/commit/3e73dac0fc470051c5aaf410ebbbf28cafc9ee0e)) +* :wrench: update helm values ([16d3223](https://github.com/dnum-mi/dso-console/commit/16d32233c84001b8aa206a76b8ac3b772875cb64)) + +### Performance Improvements + +* :art: admin users no custom exception ([34ffbf7](https://github.com/dnum-mi/dso-console/commit/34ffbf735c6d5e103a3814a0cd739242efc77240)) + +## [3.1.0](https://github.com/dnum-mi/dso-console/compare/v3.0.0...v3.1.0) (2023-04-18) + +### Features + +* :children_crossing: add deployment infos below environment tiles ([992b34e](https://github.com/dnum-mi/dso-console/commit/992b34e070401ae330cab239d73bf27b56ba516c)) +* :sparkles: add custom ca to server ([8583577](https://github.com/dnum-mi/dso-console/commit/85835775d1bf21dfadffe5094dc1a513d6782525)) +* :sparkles: introduce environment management page ([3e77a67](https://github.com/dnum-mi/dso-console/commit/3e77a679446853b981d9bf98f1bd62795ce5cf1d)) + +### Bug Fixes + +* :bug: fix git output url ([e3402b4](https://github.com/dnum-mi/dso-console/commit/e3402b44dddbd71805293305df181321af04dafe)) +* :bug: forget delete mirror repository secret ([f00aac7](https://github.com/dnum-mi/dso-console/commit/f00aac7f6315a1f27ee05ebaa6863feb466ac654)) + +## [3.0.0](https://github.com/dnum-mi/dso-console/compare/v2.3.0...v3.0.0) (2023-04-14) + +### ⚠ BREAKING CHANGES + +* **api:** :building_construction: replace ansible with plugins + +### Features + +* :art: load kubeconfig only if if a path is set ([a9d9338](https://github.com/dnum-mi/dso-console/commit/a9d93387cc7ff407ea71edb848e664fb651fc09a)) +* :bento: add Marianne police ([c5af057](https://github.com/dnum-mi/dso-console/commit/c5af05774332d97fc5b669aeaa60f9fca6075998)) +* :boom: change helm deployment ([c1368c6](https://github.com/dnum-mi/dso-console/commit/c1368c69de93357c66709110f3c57d22a62af8ad)) +* :lipstick: add dark theme ([cfc16d8](https://github.com/dnum-mi/dso-console/commit/cfc16d81391c28447d02297a9ebdf22c1f142c6f)) +* :lipstick: add dark theme ([cfc16d8](https://github.com/dnum-mi/dso-console/commit/cfc16d81391c28447d02297a9ebdf22c1f142c6f)) +* :loud_sound: add a short description for database logging ([4146f5a](https://github.com/dnum-mi/dso-console/commit/4146f5ae7048fa280a654b5471a32968af159b2f)) +* :technologist: do not drop database in integration dev ([521d503](https://github.com/dnum-mi/dso-console/commit/521d503cdf5e2c24f9e148bc6435db485e1252e7)) + +### Bug Fixes + +* :ambulance: add kaniko proxy ([1564b6f](https://github.com/dnum-mi/dso-console/commit/1564b6f7a696c13846799b38557b1003d2cb87c8)) +* :ambulance: stabilize gitlab search and vault secret name ([044da15](https://github.com/dnum-mi/dso-console/commit/044da1511ff8a5f16136807c70194d662c08db26)) +* :bug: display hyphen only if service message exists ([ba5ed65](https://github.com/dnum-mi/dso-console/commit/ba5ed659954235f8b00e78df24029f1186d23b7a)) +* :bug: fix controllers logic ([251fba4](https://github.com/dnum-mi/dso-console/commit/251fba44ebd93e645487da63710add4d9382521d)) +* :bug: use proxy on services healthcheck ([85eef14](https://github.com/dnum-mi/dso-console/commit/85eef144f8c87760c61203de7944229af2828047)) +* :lipstick: make snackbar bakground -color adaptive to theme variations ([851a004](https://github.com/dnum-mi/dso-console/commit/851a004837eb901d02bbf65e20697e457ecde6e2)) +* :wrench: rename kubeconfig var ([61fdfbf](https://github.com/dnum-mi/dso-console/commit/61fdfbf3bb47b51225d2cf3c00b167c46ff1bedf)) + +### Code Refactoring + +* **api:** :building_construction: replace ansible with plugins ([59cd5fe](https://github.com/dnum-mi/dso-console/commit/59cd5fef0799ff3dd5df7ec42ef879647a6de78a)) + +## [2.3.0](https://github.com/dnum-mi/dso-console/compare/v2.2.1...v2.3.0) (2023-03-22) + +### Features + +* :alien: fetch services to get their statuses ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) +* :art: review tobi ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) +* :monocle_face: sort projects by ASC name ([ccb7fbb](https://github.com/dnum-mi/dso-console/commit/ccb7fbb5066da6ebc1776a893c86d8d80a292856)) +* :sparkles: build project services url in api ([2173f8a](https://github.com/dnum-mi/dso-console/commit/2173f8abade9848c83cc68e4a68da02982d4a887)) +* :sparkles: new page dedicated to services ([2febba2](https://github.com/dnum-mi/dso-console/commit/2febba22bbed8355d2ea516cd232f6c47ca1a212)) + +### Bug Fixes + +* :bug: fix container env ([f9fd457](https://github.com/dnum-mi/dso-console/commit/f9fd45715d94789e02d8c88991b02fe9831e4460)) + +## [2.2.1](https://github.com/dnum-mi/dso-console/compare/v2.2.0...v2.2.1) (2023-03-14) + +### Bug Fixes + +* :bug: token should authorize "-" ([277f292](https://github.com/dnum-mi/dso-console/commit/277f29232fd5713c0a6e5c42f29ba257b71cdb75)) + +### Performance Improvements + +* :zap: no need to call /account endpoint ([3b278f7](https://github.com/dnum-mi/dso-console/commit/3b278f7e8a2fc22cf3a9aec8b1319c83a934a7f6)) + +## [2.2.0](https://github.com/dnum-mi/dso-console/compare/v2.1.1...v2.2.0) (2023-03-13) + +### Features + +* :sparkles: display project and repositories statuses in dashboard and repo pages ([ba27167](https://github.com/dnum-mi/dso-console/commit/ba2716798e8d39e5470b6fe0e97acc3a54480612)) + +### Bug Fixes + +* :bug: add joi schema validation in repo form ([8524e50](https://github.com/dnum-mi/dso-console/commit/8524e50cca3a97965b6cc2eb104620aec2d3e5dd)) +* :bug: do nothing when tile is disabled ([e09043c](https://github.com/dnum-mi/dso-console/commit/e09043c34a21315abd8c0c3d1570488057a3d1a6)) +* :bug: wrong key for project archived ([8e6b27b](https://github.com/dnum-mi/dso-console/commit/8e6b27b576e7ad28bde1b8a00b84acf366531993)) + +### Performance Improvements + +* :fire: no external token in database, no crypto needed ([0a3fb8c](https://github.com/dnum-mi/dso-console/commit/0a3fb8c5403215e1ae3d24fb8ea7cfcf43a8060f)) + +## [2.1.1](https://github.com/dnum-mi/dso-console/compare/v2.1.0...v2.1.1) (2023-02-27) + +### Bug Fixes + +* :globe_with_meridians: translate error messages in french ([a2827c3](https://github.com/dnum-mi/dso-console/commit/a2827c3746aad94293de183725bd2e77ac34f525)) + +## [2.1.0](https://github.com/dnum-mi/dso-console/compare/v2.0.0...v2.1.0) (2023-02-27) + +### Features + +* :children_crossing: redirect to services when selecting a project ([3849dad](https://github.com/dnum-mi/dso-console/commit/3849dad734e07ae0250962c8a71c1eb3470614c2)) +* :lipstick: whoami in sidemenu rather than snackbar ([70fcbf5](https://github.com/dnum-mi/dso-console/commit/70fcbf537fa32a04538d10673a1d0b4246fddd65)) +* :memo: ajout d'un schéma d'architecture ([72f61fb](https://github.com/dnum-mi/dso-console/commit/72f61fbf6060455f56d578003e927196c0292a50)) +* :necktie: add constraint for project name length ([f2d3975](https://github.com/dnum-mi/dso-console/commit/f2d3975f8cf5124142edc38a64f219ac9a223985)) +* :sparkles: add snackbar to handle errors ([0b5fa71](https://github.com/dnum-mi/dso-console/commit/0b5fa71b5d993fb7529f4fa2b74d78dacb0b33d3)) +* add active column on organization table, and client get only active: true ([de7b9d0](https://github.com/dnum-mi/dso-console/commit/de7b9d01bf878ee1cf298736e83410905390a5a3)) + +### Bug Fixes + +* :ambulance: bad owner w/ new queries ([5153b62](https://github.com/dnum-mi/dso-console/commit/5153b621c0707a438f191dd2b06d8c801804f014)) +* :ambulance: log more of ansible output ([e2b10d6](https://github.com/dnum-mi/dso-console/commit/e2b10d6875605c836606cb5ba79c963787d4c177)) +* :bug: error in ansible route ([a470677](https://github.com/dnum-mi/dso-console/commit/a470677a5d5c3b2b262434adc3f399f948caccd2)) +* :bug: error in ansible route ([1565411](https://github.com/dnum-mi/dso-console/commit/1565411524a2c17d0e7de79e5965562053a2024b)) +* :goal_net: catch fetch errors and send it to front ([fcca40b](https://github.com/dnum-mi/dso-console/commit/fcca40b2efbc5d38ae8fe0001043671756ebda34)) +* :goal_net: optional chaining to avoid errors on selected project ([6750077](https://github.com/dnum-mi/dso-console/commit/67500771939efb2e2329d8ab94407588aa76fa5b)) +* :goal_net: return if user does not have project ([f2a3d74](https://github.com/dnum-mi/dso-console/commit/f2a3d7449abc729b5506aaaf43a163e600c51bcb)) +* :lipstick: enforce ui on disabled dsfrtiles ([4de5d25](https://github.com/dnum-mi/dso-console/commit/4de5d257d847c4ecff137face3cbb276d543b779)) +* :truck: rename generated gitlab ci file ([d3e637e](https://github.com/dnum-mi/dso-console/commit/d3e637e04ef008c5316b7e720a9fc905c0ab0000)) +* :wrench: update postgres image ([16e5d55](https://github.com/dnum-mi/dso-console/commit/16e5d557c52e149def7f7c216371ba23fc721bbf)) + +### Reverts + +* :rewind: rebase error ([728a704](https://github.com/dnum-mi/dso-console/commit/728a704522a3f5cfcc2dc5879671a83a051a82c6)) + +## [2.0.0](https://github.com/dnum-mi/dso-console/compare/v1.0.0...v2.0.0) (2023-02-22) + +### ⚠ BREAKING CHANGES + +* **database:** :card_file_box: new database model, queries, + +### Features + +* :art: easier generation of files object ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :art: specific routes, add validation schema ([69645b9](https://github.com/dnum-mi/dso-console/commit/69645b9b392f8f56b8d3606780dfa63098fa5240)) +* :art: use only one get route ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: mob review ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :card_file_box: add an association table for users and projects ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: add destroy functions for db ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: wip : working with @ArnaudTa on liveshare ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: add delete repo button ([ad64daf](https://github.com/dnum-mi/dso-console/commit/ad64dafce222ea572e6169c502aaed08562a70e7)) +* :construction: Add download and copy, missing format ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: add status and locked for project ([1bb55b8](https://github.com/dnum-mi/dso-console/commit/1bb55b8758eecced9204aa7b77f404ac6a3d45b0)) +* :construction: adding api implementation ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :construction: all controllers writtend ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: applying review requested changes ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: commit à ecraser - wip fs file generator ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: feature to achieve later, keep asking for project status ([57846f9](https://github.com/dnum-mi/dso-console/commit/57846f923f15771186b8a7816324df42d55d86cb)) +* :construction: first database model ([69f51f2](https://github.com/dnum-mi/dso-console/commit/69f51f2eb16b052d4fddbb909ac7a5f0913b2c94)) +* :construction: new project controller ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: remove done todos ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :construction: uncomment path for delete repo and projects ([8fb7cd9](https://github.com/dnum-mi/dso-console/commit/8fb7cd9b5c67857862cebfde091a3b5bbba54c93)) +* :construction: wip - ci files generated by server ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: wip downloading file ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: wip fix unit tests ([31c3a86](https://github.com/dnum-mi/dso-console/commit/31c3a863104f553adcefb5e78d64613e32f65704)) +* :lipstick: fix snackbar on bottom left ([7823c99](https://github.com/dnum-mi/dso-console/commit/7823c991b4adf62e380193d7f1d9fc33108dc368)) +* :lock: add crypto module to encrypt externalToken or other data if needed ([c5806b3](https://github.com/dnum-mi/dso-console/commit/c5806b392ec2b203f0d54a62527c9d96570f2918)) +* :necktie: cannot update nor delete permission if not permitted - controller side ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :necktie: disable delete permission for owner ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :necktie: permissions should be enabled only for permitted users ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :passport_control: organization controller ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :sparkles: add archiveProject btn ([e412223](https://github.com/dnum-mi/dso-console/commit/e41222346a4e3d9d60100d59b4edf160a2319c08)) +* :sparkles: add backend function to generate random passwords ([5244597](https://github.com/dnum-mi/dso-console/commit/52445972b7be13862d395059f82fae81b7efa495)) +* :sparkles: add delete playbook call in deleteRepo controller ([8abe695](https://github.com/dnum-mi/dso-console/commit/8abe6950e8f26aa01b7b1a95e4fbc4627f26d925)) +* :sparkles: add delete trigger for environment ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :sparkles: add environment and permissions management ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :sparkles: add environment page ([a5a5129](https://github.com/dnum-mi/dso-console/commit/a5a5129e89cb9594204b08c9cc76c3a30bb04eb7)) +* :sparkles: Add GitLab ci generation in repoForm ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: Add gitlabCi form for repo ([839f1e1](https://github.com/dnum-mi/dso-console/commit/839f1e18bf99f1bef53ed3ab39a64ab097c6a0d8)) +* :sparkles: add GitlabCI generated file ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: add helm charts ([e540325](https://github.com/dnum-mi/dso-console/commit/e5403252cd7d888c839be63758c15c1fdce76c71)) +* :sparkles: add log table in model ([642b245](https://github.com/dnum-mi/dso-console/commit/642b245a4a0ffa8877c900fa786345fd484f60f8)) +* :sparkles: add misc routes, healthz/version ([520976d](https://github.com/dnum-mi/dso-console/commit/520976ddfebba2d05385ba66d27af809e8736287)) +* :sparkles: ajout d'un système de role propre à la console avec authentification admin ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :sparkles: ansible-api respond playbook rc, server stores it in db ([7774045](https://github.com/dnum-mi/dso-console/commit/7774045ae6404e1f0de810bb09a7832c0292baba)) +* :sparkles: call delete repo and archive project playbooks ([97a7b13](https://github.com/dnum-mi/dso-console/commit/97a7b1300847fafa5053d58fb05079ef52a9d041)) +* :sparkles: ciForm ok ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :sparkles: display current user name in closeable alert ([349ade9](https://github.com/dnum-mi/dso-console/commit/349ade9f5b8017bf206aff2c894a56cc675d6873)) +* :sparkles: get user's group from keycloak ([8311622](https://github.com/dnum-mi/dso-console/commit/83116226e7a3e172a94b4f988d037ec1dcc8aaf4)) +* :sparkles: record ansible responses in database ([5753883](https://github.com/dnum-mi/dso-console/commit/5753883ae32f36e28a128f396df3f9669e5a854b)) +* :sparkles: wip components for environment managing ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :test_tube: ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: rewrite random utils for tests ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* **Helm:** :art: add env per container ([48b41cf](https://github.com/dnum-mi/dso-console/commit/48b41cfea037ce90812197990c54d2726fb75ac7)) +* **Helm:** :art: variabilize server imports ([0973646](https://github.com/dnum-mi/dso-console/commit/09736463d3011f96348f94cf3ad8cc69c9c0f07e)) + +### Bug Fixes + +* :alembic: changement du keycloak flow ([1af0d85](https://github.com/dnum-mi/dso-console/commit/1af0d8504766f3f0ebab92feff8f4795e4e1c67e)) +* :art: variabilize pvc name ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: add await on start server functions and fix tests ([c8e66f8](https://github.com/dnum-mi/dso-console/commit/c8e66f87f4ccd4be1775b32729f12d9c0e919f35)) +* :bug: add refresh keycloak token ([b758e44](https://github.com/dnum-mi/dso-console/commit/b758e44241c868a1e8a1546271f1806069370964)) +* :bug: bind do not always works ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: change create project and change message logs ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: controller response before ansible fetch ([8983c45](https://github.com/dnum-mi/dso-console/commit/8983c457cd8de96d117d51881668b37c3b4f9407)) +* :bug: delete default argo repo at deleting ([5fafe99](https://github.com/dnum-mi/dso-console/commit/5fafe9919b7d08447e356eb260795a6cb91f3ba3)) +* :bug: fix projectAddUserController ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: flatMap didn't work as expected ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: forgot to load repositories data ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: import env ([98c1632](https://github.com/dnum-mi/dso-console/commit/98c16329e8fbd3b0bcabf2ac132d53931ef7f9bf)) +* :bug: init db doit être séquentiel pour ne pas violer l'intégrité des tables ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: isInfra & isPrivate false if undefined ([2fa987d](https://github.com/dnum-mi/dso-console/commit/2fa987d0872f6884627d6060c64d27d932a2db5c)) +* :bug: isInfra not send to ansible ([b049746](https://github.com/dnum-mi/dso-console/commit/b049746b97e5529d6f5368e045eb96a266bb432b)) +* :bug: mode dégradé si payload n'est pas bon ([14309cf](https://github.com/dnum-mi/dso-console/commit/14309cf41835f59168c72b6a4840fcf1ae8264d2)) +* :bug: repositories may be empty ([584798f](https://github.com/dnum-mi/dso-console/commit/584798f1ebba5894b2f9181cbe0f9bdbd49cdd6c)) +* :bug: review changes ([5e4bdb7](https://github.com/dnum-mi/dso-console/commit/5e4bdb73414102728e68d56fe30d65a7f718a0f7)) +* :bug: send proper data to ansible ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: send200 if code is OK ([08ca7eb](https://github.com/dnum-mi/dso-console/commit/08ca7ebdf0b24dabc60baa631c4887854780465c)) +* :bug: show permissionForm only when environment is created ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: stabilize playbookDir in different execution execution ([69645b9](https://github.com/dnum-mi/dso-console/commit/69645b9b392f8f56b8d3606780dfa63098fa5240)) +* :bug: update ansible calls ([04ee9ff](https://github.com/dnum-mi/dso-console/commit/04ee9fff7e78bbe4305ad7a1923df842a80a49ad)) +* :bug: update orgName generation to new db schema ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: use flatMap to iterate over two arrays ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :bug: wip bug ansible repositoryInitializing ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: wip working on todo's ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :bug: working on todo's ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :card_file_box: organization should be a FK in projects ([d014387](https://github.com/dnum-mi/dso-console/commit/d014387c8d09182675ddf98d381e27b78bc28545)) +* :card_file_box: turn envList into array of objects ([41f0278](https://github.com/dnum-mi/dso-console/commit/41f027858f108fb056958c118bf664a141e7623c)) +* :construction_worker: Copy test-utils in client docker ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :construction: trying to log details for ansible api call ([ae944ae](https://github.com/dnum-mi/dso-console/commit/ae944aea77d0f13798c63e9bf9690363f75e9bc9)) +* :construction: wip ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :fire: remove bad log and comms ([748c43d](https://github.com/dnum-mi/dso-console/commit/748c43d93212a79b2dd04e527f97da5dd5d585b5)) +* :goal_net: add front error management for createProject ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :goal_net: catch errors in front when calling back ([5331773](https://github.com/dnum-mi/dso-console/commit/53317734961895bf348c758774d23c2505f812a8)) +* :lipstick: update UI for new DSFR version ([62750c4](https://github.com/dnum-mi/dso-console/commit/62750c478ecac453542e5933d1464af2a752e2d4)) +* :lock: filter users sensitive infos ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :lock: improve security using node utility function to compare hashes ([ea0e730](https://github.com/dnum-mi/dso-console/commit/ea0e73084a04c85d69e8d556e73241982df5d40f)) +* :loud_sound: mute logs for health route ([b1eaad5](https://github.com/dnum-mi/dso-console/commit/b1eaad5701ac73b131b0cb8692111ae5fd0a8d84)) +* :mute: remove sequelize logs to preserve data ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :necktie: permission level goes from 0 to 3, 0 by default, 3 for owner ([3c76eac](https://github.com/dnum-mi/dso-console/commit/3c76eacfa47bfa4f2d8133a09b60788c0527ce9e)) +* :pencil2: typo on var name ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :recycle: change where directive to through associations ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :recycle: fix ([8d2c911](https://github.com/dnum-mi/dso-console/commit/8d2c911b855f19b0a6568e2857129c9c62fcfcc9)) +* :recycle: use sequelize querytypes ([d45472a](https://github.com/dnum-mi/dso-console/commit/d45472a89a5be2a74c1af2da14d41ceb5d118284)) +* :rotating_light: Sonar warnings ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :rotating_light: SonarQube warning ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: fix TU ([26bd4f1](https://github.com/dnum-mi/dso-console/commit/26bd4f1477bd3664472ca4d8989630f09c2a7e0b)) +* :test_tube: use dedicated en var for ct tests ([529a188](https://github.com/dnum-mi/dso-console/commit/529a188505d7ec9bd53e7ca2909b8a8d94e31897)) +* :test_tube: wip fix repo ansible fetch failed ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :truck: fix generateCIFiles feature ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) +* :truck: rename folder for generating files ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) + +### Performance Improvements + +* :art: use map instead of forEach for array generation ([03c78fa](https://github.com/dnum-mi/dso-console/commit/03c78fa5f28bb86fad7e3e1b142a1c71d1639054)) +* :stethoscope: add health probes in templates ([d645f2e](https://github.com/dnum-mi/dso-console/commit/d645f2e35460ba92a67aeb0ea65ac9356ed36381)) + +### Reverts + +* :egg: hide environments and permissions feature waiting for playbooks ([edebee9](https://github.com/dnum-mi/dso-console/commit/edebee944905f5723518aaca0c37a51deeac80a6)) +* :rewind: hide snackbar for now ([6afe9e8](https://github.com/dnum-mi/dso-console/commit/6afe9e81bc5b32cb544456d7e697be66ea0349be)) + +### Code Refactoring + +* **database:** :card_file_box: new database model, queries, ([b547548](https://github.com/dnum-mi/dso-console/commit/b547548fbe70955c2131901509be0b929eb9b549)) + +## 1.0.0 (2022-12-16) + +### Features + +* :alembic: clone in entrypoint ([f97edb0](https://github.com/dnum-mi/dso-console/commit/f97edb07c31036cb1fc1882610e2f78185106028)) +* :art: baudoin's review ([656aac6](https://github.com/dnum-mi/dso-console/commit/656aac693a4c21a64402dd9bcd8b978f2fb63b55)) +* :art: introduce CLONE_DIR, where to clone ([263ea79](https://github.com/dnum-mi/dso-console/commit/263ea79deef5d9f58e2fc10ffcf98e31a4b41bc2)) +* :art: spread instead of push ([530b366](https://github.com/dnum-mi/dso-console/commit/530b366368b8eb1a2f2597362ecc2c5aa2bc1145)) +* :bento: Add images for services logos in public folder ([6d8e91c](https://github.com/dnum-mi/dso-console/commit/6d8e91ca9837dd60f603d5ed364e11fb3485a5b5)) +* :bento: Definitive logos for services ([e2e6270](https://github.com/dnum-mi/dso-console/commit/e2e627081224433e0d190876a8791aa1d2ea8d6b)) +* :boom: first query successfull ([61a7cd5](https://github.com/dnum-mi/dso-console/commit/61a7cd5857a6e6c648219d20399c19e8672a37db)) +* :boom: first query successfull ([36c973e](https://github.com/dnum-mi/dso-console/commit/36c973ebaa9f3a52669d0f921eedfb8aee6c4a9f)) +* :bricks: change routes ([af30bd8](https://github.com/dnum-mi/dso-console/commit/af30bd895bef8203b3e06facfa04d93be83b86f9)) +* :card_file_box: Add api getProjectById ([98cd231](https://github.com/dnum-mi/dso-console/commit/98cd23165e72add49762bc31b81c54526f42b768)) +* :card_file_box: Add api getProjectById ([98976d0](https://github.com/dnum-mi/dso-console/commit/98976d0a33cf65ee3be4baad629dae107410c6e3)) +* :card_file_box: Add createProject query ([9bcfb05](https://github.com/dnum-mi/dso-console/commit/9bcfb059d2c3c51c64ee223b777da5d999339e44)) +* :card_file_box: Add owner from keycloak in project ([5aa9aad](https://github.com/dnum-mi/dso-console/commit/5aa9aad9f12b5c5060139baebd5b2869ea91b1f4)) +* :card_file_box: Add owner from keycloak in project ([cadb474](https://github.com/dnum-mi/dso-console/commit/cadb4745648ecdfc0ffbbc045ca4a054ee062615)) +* :card_file_box: Add postgres and pgadmin ([76374e6](https://github.com/dnum-mi/dso-console/commit/76374e6acd72ba89ab15bdf4c7e1ff82f00e110d)) +* :card_file_box: Add user id in project queries ([48949df](https://github.com/dnum-mi/dso-console/commit/48949df562179493e2b524b541129ff520913d86)) +* :card_file_box: Create database & table on server start if not exists ([14b9f6b](https://github.com/dnum-mi/dso-console/commit/14b9f6b5359e33bd345eccb1be081698fbda8b59)) +* :card_file_box: Wip add createProject api ([1414bc0](https://github.com/dnum-mi/dso-console/commit/1414bc0ed59f63fd41f21dcc76dcebebd1a5c4cf)) +* :card_file_box: Wip app and server js ([2d4efdf](https://github.com/dnum-mi/dso-console/commit/2d4efdf91f98a82fcd18c3887c1943a283c653c8)) +* :construction: (wip) add project mongoose model ([76e8d77](https://github.com/dnum-mi/dso-console/commit/76e8d77a3342ff58707b47977a8cdd14a721339d)) +* :construction: (wip) trying to connect to keycloak with server ([2401fda](https://github.com/dnum-mi/dso-console/commit/2401fdaa383a38632e95d1097e4a3766ef7f6563)) +* :construction: Add check in addRepo and addUser queries ([c2bad66](https://github.com/dnum-mi/dso-console/commit/c2bad663b9aba71dffd9e023cbd098a38353ee0d)) +* :construction: Add DsfrTable for users ([8b63e84](https://github.com/dnum-mi/dso-console/commit/8b63e84bd6923f5128efbd427e659418794f7eaf)) +* :construction: Add DsoTeam table ([ca3e2c3](https://github.com/dnum-mi/dso-console/commit/ca3e2c3462d9e17d90e10afed4432b24735ded24)) +* :construction: Add final todos ([c224592](https://github.com/dnum-mi/dso-console/commit/c2245927e6a2b61cd3bf4fbef4fddf5708ccd20f)) +* :construction: Add OrderProject ([f4d2962](https://github.com/dnum-mi/dso-console/commit/f4d2962ec7b76a13c7f9849302f3a0279e79a442)) +* :construction: Add postgres and pgadmin ([a7ce8c5](https://github.com/dnum-mi/dso-console/commit/a7ce8c5ff9aee0003f7d2066a6f2e172b9d5bd08)) +* :construction: Add postgres and pgadmin ([6fdf560](https://github.com/dnum-mi/dso-console/commit/6fdf56045839636324015739852ee85d5685d584)) +* :construction: Add removeUserFromProject function ([9430fc6](https://github.com/dnum-mi/dso-console/commit/9430fc697e9761c3e79f0d37bef16dcf52f1e889)) +* :construction: Add routes for projects ([cd14601](https://github.com/dnum-mi/dso-console/commit/cd146017e1a6646777451e0ed711b9d6556e5393)) +* :construction: Add SideMenu to app ([3373f8c](https://github.com/dnum-mi/dso-console/commit/3373f8cf18607293f1081172ed93e8dabd69fd28)) +* :construction: Configure keycloak for frontend ([d8074ac](https://github.com/dnum-mi/dso-console/commit/d8074ac468a562322a8d44ed5d08b26be56cc7e8)) +* :construction: Failing specs projects ([a56cb8c](https://github.com/dnum-mi/dso-console/commit/a56cb8cb0685e3173b867519d09f4ca41b217959)) +* :construction: Func removeUserFromProject ([95fdc7f](https://github.com/dnum-mi/dso-console/commit/95fdc7f8cd8285bf18257364f833b9eb84588cf1)) +* :construction: Handle menu collapse in sm mode ([5237e89](https://github.com/dnum-mi/dso-console/commit/5237e892f86f5c4abd170562867a4745e2121bb9)) +* :construction: trying to access server /api/v-4/version ([a27483c](https://github.com/dnum-mi/dso-console/commit/a27483c6525091eca564b9ad615636edf79c5283)) +* :construction: trying to access server /api/v1/version ([2e43c8f](https://github.com/dnum-mi/dso-console/commit/2e43c8fd0c13f59bd40567912815a605c43bf0dc)) +* :construction: wip add node-postgres ([50ef526](https://github.com/dnum-mi/dso-console/commit/50ef526f2e647d5a16254468e9e83996e71baab1)) +* :construction: wip business rules orderProject ([24f7daa](https://github.com/dnum-mi/dso-console/commit/24f7daaae5cba5d7fdb6ee1ebc700c9aba1a0830)) +* :construction: wip pg ([391bccb](https://github.com/dnum-mi/dso-console/commit/391bccbbf34c9a365eed847824edb718643a8d73)) +* :construction: working on api x keycloak ([183f4d8](https://github.com/dnum-mi/dso-console/commit/183f4d8c9e49407f500444210f2f143dcf1433c3)) +* :hammer: Add turbo ([a3fdc96](https://github.com/dnum-mi/dso-console/commit/a3fdc96e23fbd3c66dea95ce89829dbe873b835a)) +* :hammer: Update docker compose scripts for pnpm ([537ba52](https://github.com/dnum-mi/dso-console/commit/537ba52f8c75659a8204beb5e48fa2ff11572be2)) +* :heavy_plus_sign: Add axios for api management ([591b012](https://github.com/dnum-mi/dso-console/commit/591b012df344e0842ce3848eaae4aa0c3beceb73)) +* :heavy_plus_sign: Add fastify keycloak adapter ([575e8d7](https://github.com/dnum-mi/dso-console/commit/575e8d71aa5d4fe9a0f2da94c484de2867ca5c9a)) +* :lipstick: Resize sidemenu and content ([61670d8](https://github.com/dnum-mi/dso-console/commit/61670d816aa3b70aa164e3d8531b12b3f835fcc6)) +* :lipstick: thiner projects subheader ([4427945](https://github.com/dnum-mi/dso-console/commit/442794546e8977dc3298a5ec92e68a30ef3775eb)) +* :lipstick: Update navigation in frontend ([5751ed3](https://github.com/dnum-mi/dso-console/commit/5751ed36dbdbeceb832c9bcc0c9972fca2abd26c)) +* :lock: Add keycloak token check on ansible api ([afe18f5](https://github.com/dnum-mi/dso-console/commit/afe18f58dc7c041adb40b1dee7617cb6b38ef9f5)) +* :loud_sound: forward request id to ansible api ([b6107bb](https://github.com/dnum-mi/dso-console/commit/b6107bbc322d6740bfbe7345e5865af6df4cb219)) +* :memo: Add documentation inside application ([0d70a0e](https://github.com/dnum-mi/dso-console/commit/0d70a0e8c6b4248e427e63d987dce7498f0c64ee)) +* :necktie: Add removeuser func ([3da7701](https://github.com/dnum-mi/dso-console/commit/3da77019f350b4dee5031ffbf5a97735299b0df9)) +* :necktie: users[].email must be unique ([d74b99d](https://github.com/dnum-mi/dso-console/commit/d74b99d642cdb3dea3fcd18116f644aa34542dbf)) +* :poop: dinindex turbo ([d7e78cb](https://github.com/dnum-mi/dso-console/commit/d7e78cb1e15c7008f761af42961bdc2754b117d0)) +* :poop: Pass current location to redirectUri ([ee65e42](https://github.com/dnum-mi/dso-console/commit/ee65e42daaff2fb20c4a9142a5ba87357e415088)) +* :poop: test 2 ([5c3b1da](https://github.com/dnum-mi/dso-console/commit/5c3b1da4c4d95ce3a9294c175f4826e174ae6436)) +* :poop: try to remove .tubro/ ([01ba3ef](https://github.com/dnum-mi/dso-console/commit/01ba3efaa8efacbb97638c5e89aebd3255149d2b)) +* :poop: turbo unindexation test ([2e7ba79](https://github.com/dnum-mi/dso-console/commit/2e7ba79848aae2fc174e364de867ff59159ed39c)) +* :recycle: Baudoin's review, userProfile in store ([c1a34b4](https://github.com/dnum-mi/dso-console/commit/c1a34b4c2c48455874829966d220b0b3137a010c)) +* :recycle: Harmonize api's names ([71ed577](https://github.com/dnum-mi/dso-console/commit/71ed577474dbe431cc1ebdb83336510ac93c58c8)) +* :recycle: Merge conf and init in a single keycloak file ([e777b02](https://github.com/dnum-mi/dso-console/commit/e777b028ce94df3288ab03bc6bdeea2ba2408fde)) +* :recycle: service url construction ([6b25ab3](https://github.com/dnum-mi/dso-console/commit/6b25ab3d40449da2b50ecd12604c6030aa0b287d)) +* :see_no_evil: Ignore .tubro ([d927f05](https://github.com/dnum-mi/dso-console/commit/d927f0559ecff8d3805709d9eca0487786295b52)) +* :sparkles: Add allTiles for all services, adapt projectTiles to selectedProject ([d207065](https://github.com/dnum-mi/dso-console/commit/d207065a628e26293f441a38ae9dd17950188d7c)) +* :sparkles: Add DsoService content ([024cbd0](https://github.com/dnum-mi/dso-console/commit/024cbd0c50475413a2a41097e301c88c87885363)) +* :sparkles: Add envList to project ([02e5215](https://github.com/dnum-mi/dso-console/commit/02e5215ec25a9dc7decd6c7d4d26c68a3a1b5fa5)) +* :sparkles: Add infra repo checkbox ([b0c0602](https://github.com/dnum-mi/dso-console/commit/b0c06020ea9f2aa1580514aa9498aec38cb3824b)) +* :sparkles: Add sidemenu and tmp routes ([be02a0c](https://github.com/dnum-mi/dso-console/commit/be02a0c2184b8ff92b13745efdfdda5626d91ec6)) +* :sparkles: Add specific endpoints to console api ([a35b9cc](https://github.com/dnum-mi/dso-console/commit/a35b9ccf452db0db438990633eded6b8d5ae0be7)) +* :sparkles: Add user to project ([85d5a1c](https://github.com/dnum-mi/dso-console/commit/85d5a1c98f9374ba9c7fb9b4f179bb8f40a6b0f3)) +* :sparkles: Add users[i].id in query ([35a36b4](https://github.com/dnum-mi/dso-console/commit/35a36b437d224b3d4b490a7c0ff2fdfe42f80410)) +* :sparkles: Add vault url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :sparkles: Add windicss to project ([8db02f5](https://github.com/dnum-mi/dso-console/commit/8db02f531f6f4dbc768ae726597b957cb8cb8e3a)) +* :sparkles: Create projects view ([aa0d833](https://github.com/dnum-mi/dso-console/commit/aa0d83356ab679261893cb563237062cdbbc04e6)) +* :sparkles: Finish v1 orderProject form ([590eb74](https://github.com/dnum-mi/dso-console/commit/590eb744f9daab7b9e34e6e2e9a376257f8290da)) +* :sparkles: Prepare ansible call for project provisioning ([a3d0918](https://github.com/dnum-mi/dso-console/commit/a3d09185837675f7954dad3185651ca300373bb1)) +* :sparkles: Separate ansible wrapper in a new api called ansible-api ([bcd64db](https://github.com/dnum-mi/dso-console/commit/bcd64dbdfa3c043bcf7c77f783ce1d36e43e4d05)) +* :speech_balloon: Correct services url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :speech_balloon: Update app's title ([869e2a3](https://github.com/dnum-mi/dso-console/commit/869e2a34455f1adb80d52e7fc5c4bc4c884368dd)) +* :speech_balloon: Update services url ([264d132](https://github.com/dnum-mi/dso-console/commit/264d132a839d93a6677b4f1520eadb3b52497f47)) +* :technologist: Add husky unit tests for server ([75e3a23](https://github.com/dnum-mi/dso-console/commit/75e3a23199ba15de9229809558a2b2569e38bdd4)) +* :technologist: Add watch src for hot reload ([e4e129d](https://github.com/dnum-mi/dso-console/commit/e4e129db4803f3bc5ed5a1ee3fc759ebcef38d89)) +* :truck: Better images for logos ([8ce9112](https://github.com/dnum-mi/dso-console/commit/8ce911280348e49d9f197dc9947d0d7d5bf5cee4)) +* :truck: Create folder projects in views, create store project ([a1df0f6](https://github.com/dnum-mi/dso-console/commit/a1df0f6bfcdbdba96d9b8c1c77e8500074f59512)) +* login on keycloack TEST ([20ab492](https://github.com/dnum-mi/dso-console/commit/20ab492a78f5e3e81a3295a3416a4422422d4244)) +* page login ([0c3946b](https://github.com/dnum-mi/dso-console/commit/0c3946b0ff04889b8b0fcf7b5160fea4409b3e8d)) +* **projectInit:** :building_construction: Add client folder ([9f3e90d](https://github.com/dnum-mi/dso-console/commit/9f3e90d305aebc6d9508d4570ffc0d1fac9502f2)) +* **projectInit:** :heavy_plus_sign: Add Stylelint ([21f33e0](https://github.com/dnum-mi/dso-console/commit/21f33e0554b04932a35ca723934c24bd93122513)) +* **projectInit:** :tada: Vite vue3 basic installation ([41aeb82](https://github.com/dnum-mi/dso-console/commit/41aeb824d02ba387bc8c842b07351fd55b221782)) + +### Bug Fixes + +* :alembic: edit code to run with new ansible directories ([8822d95](https://github.com/dnum-mi/dso-console/commit/8822d953e93db6f4ddc00383e7ad4a8c01e8cbaf)) +* :alembic: edit server image, kubeconfig fix for ansible ([5e02c5e](https://github.com/dnum-mi/dso-console/commit/5e02c5ef97a5fc67cfd30d296b36bf339fff8251)) +* :alembic: Try to fix keycloak redirect ([954bc51](https://github.com/dnum-mi/dso-console/commit/954bc513be0ff9a1e46b140af334273f710b9ec2)) +* :ambulance: fix a commit written too late in the night ([ee0cc99](https://github.com/dnum-mi/dso-console/commit/ee0cc99fab9a243ffddcc5ed874cbcd449d319f4)) +* :art: add env for keycloak in FRONT ([dea4e38](https://github.com/dnum-mi/dso-console/commit/dea4e38b36d9bb672f4f9e1079e9540d0e93d5af)) +* :art: rename file user in stores ([86be41e](https://github.com/dnum-mi/dso-console/commit/86be41e2ee7d6c8d0a0baad9507fe46fb8a527b4)) +* :bug: Add case sensitivity for init deb script ([699dedd](https://github.com/dnum-mi/dso-console/commit/699dedd5c8b149d8de83b632184fdb28b15784fc)) +* :bug: Add tagName (type) so it's closeable ([8aa65d5](https://github.com/dnum-mi/dso-console/commit/8aa65d53d03641ae30b0f989874f4acb85889b3f)) +* :bug: duplicated line ([2dad819](https://github.com/dnum-mi/dso-console/commit/2dad819540e76852251ef3ac8b7c3618abec0c71)) +* :bug: Finish removeUser feature ([25a4f36](https://github.com/dnum-mi/dso-console/commit/25a4f363ca99b3dd5b9a9adf6cf940021c509a5e)) +* :bug: Fix after dsfr's upgrade ([9273618](https://github.com/dnum-mi/dso-console/commit/92736182d04f6a965b15d0514a8c18e329ebec7a)) +* :bug: Fix bugs revealed by e2e tests ([f9ff1fc](https://github.com/dnum-mi/dso-console/commit/f9ff1fc8c633b894864489cb7b8c51eaf733e012)) +* :bug: Fix db init if not exists ([d3a7b77](https://github.com/dnum-mi/dso-console/commit/d3a7b779fb00399a94e6db570583bed77e4fc355)) +* :bug: Fix endless redirection ([7762a83](https://github.com/dnum-mi/dso-console/commit/7762a8358b6a76879e41ac6ea5a29765ec36a2e1)) +* :bug: Fix init db error ([9b50685](https://github.com/dnum-mi/dso-console/commit/9b5068519d7d26126c9730d32b375cb9e9243e92)) +* :bug: Fix multiples bugs ([fc94d99](https://github.com/dnum-mi/dso-console/commit/fc94d992ee0495113f7e5f4cd165f90b2460dd9d)) +* :bug: Fix potentital bugs ([ec04eee](https://github.com/dnum-mi/dso-console/commit/ec04eeeeb745be5e4a4ab5ad8a1c60e08cb74168)) +* :bug: Handle cases where no users ([47befb9](https://github.com/dnum-mi/dso-console/commit/47befb9e0dcfa07e733596aa5ccd9323e7447ad6)) +* :bug: Handle cases where users key does not exist in project ([98381dc](https://github.com/dnum-mi/dso-console/commit/98381dc3a746993c563b0b817cd6e985c40d4cc6)) +* :bug: Improve backend & fix in adequation of front changes ([a4df195](https://github.com/dnum-mi/dso-console/commit/a4df19533cd2e634149c549e5a717bf7b5bd1280)) +* :bug: kc logout ([c3621b9](https://github.com/dnum-mi/dso-console/commit/c3621b9f4db92b1a73a21b53d3894d79f2bdeb1d)) +* :bug: Last reviews ([4f39f8a](https://github.com/dnum-mi/dso-console/commit/4f39f8aea216aee29ff1128a7c5b60174e98b489)) +* :bug: multiple fixes, increase some log verbosity ([39e543b](https://github.com/dnum-mi/dso-console/commit/39e543bc8071d8323af96e85a6f6c8b7beffce19)) +* :bug: Reinit projects in store before pushing query result ([53d6c3f](https://github.com/dnum-mi/dso-console/commit/53d6c3f94e1211a69034c580abc6bd2731d7e717)) +* :bug: remove extra properties, it failed tests ([98abead](https://github.com/dnum-mi/dso-console/commit/98abeadf4ad5aad0eaab3f706500e27c98444ce3)) +* :bug: Remove unintentionaly added import ([8c6cab6](https://github.com/dnum-mi/dso-console/commit/8c6cab6fa05b57639003ea1e514da973d79fad3b)) +* :bug: Review tobi, an array even empty is always true ([9fe7068](https://github.com/dnum-mi/dso-console/commit/9fe706894c930fa5f2d187c0ce1500d75a797e29)) +* :bug: update for production ready ([4e80d61](https://github.com/dnum-mi/dso-console/commit/4e80d61fd3f9ec0526d99b3f3fec65a4ddfa78b0)) +* :coffin: Remove test class ([38a39ed](https://github.com/dnum-mi/dso-console/commit/38a39ed849fdcc7f62bdb24223f5cd74407e5aa9)) +* :coffin: Remove unused function ([b557bdc](https://github.com/dnum-mi/dso-console/commit/b557bdcb5b8fcb927ffc30cf9e4c0830d35b5363)) +* :construction_worker: Remove prod flag for dev stage in server dockerfile ([3e28a00](https://github.com/dnum-mi/dso-console/commit/3e28a00d1a652d46fcb0072471090ee2ad0fb899)) +* :construction: broken install in test yml ([1121d8b](https://github.com/dnum-mi/dso-console/commit/1121d8b7f90fc301a303c8705a039e1175d55eae)) +* :construction: Fix init keycloak in frontend ([0b41546](https://github.com/dnum-mi/dso-console/commit/0b41546b355e3583dd02fc1793c24c30af860030)) +* :construction: Wip reinit newUser object ([699a0bb](https://github.com/dnum-mi/dso-console/commit/699a0bbf8600efed4d1bf62a07d02f78c0e1c38c)) +* :construction: working on keyclock + pinia ([95d5822](https://github.com/dnum-mi/dso-console/commit/95d5822b23277b24a87f6f6d6c8ca77fc6a49926)) +* :fire: Remove useless silentchecksso & move cypress/vue to optional dep ([3b85f7a](https://github.com/dnum-mi/dso-console/commit/3b85f7a7f792d6fcb0b7f9bf8b94fe82b02ed577)) +* :globe_with_meridians: All english for dso-console ([6d45cba](https://github.com/dnum-mi/dso-console/commit/6d45cba12a6540dd13a933f6940b91701dbdab34)) +* :green_heart: Fix e2e tests in ci & improve code structure ([0857f70](https://github.com/dnum-mi/dso-console/commit/0857f703f0445cd49940d3337c4d94c9af4c70ea)) +* :lock: add binding in sql request to avoid sql injection ([46f5d04](https://github.com/dnum-mi/dso-console/commit/46f5d048e9df384c8f116bfdd8c9b7005160b5bf)) +* :loud_sound: Add logs for investigation ([b8f4867](https://github.com/dnum-mi/dso-console/commit/b8f48679f350ef34e856925fc9ff42ecfa1f281a)) +* :memo: Last review ([f37ad55](https://github.com/dnum-mi/dso-console/commit/f37ad5549d4e863cc78ed970c0525b4ed421b7f1)) +* :package: add jmespath lib ([5fcdd41](https://github.com/dnum-mi/dso-console/commit/5fcdd41c74ca678ce8cdfa79e5626d4eaeaba14c)) +* :pencil2: Typo in js ([ef99847](https://github.com/dnum-mi/dso-console/commit/ef99847908ce21cd28bddc8787101d9448c1976d)) +* :recycle: Refacto brainstorm thibault ([4e24417](https://github.com/dnum-mi/dso-console/commit/4e24417f7ac7a09dd57aa93f19d174ff679dcf74)) +* :rewind: Readd vitest config in shared ([2ec8b14](https://github.com/dnum-mi/dso-console/commit/2ec8b149a8d47136ac8305359688679e1eac1316)) +* :rewind: Rebase bug fix ([dbfaa2b](https://github.com/dnum-mi/dso-console/commit/dbfaa2b4732f51d6e1de0f731c55fa25518cc91c)) +* :rotating_light: Warn in console for missing required id ([db26c58](https://github.com/dnum-mi/dso-console/commit/db26c587106f1be4bf4765a0ea207adde71bb0ff)) +* :technologist: Add env exemple ([64fec78](https://github.com/dnum-mi/dso-console/commit/64fec783624dd4e3a625dbdd16f0bf9736adcc0d)) +* :test_tube: (to remove) failing tests skipped ([aeaded1](https://github.com/dnum-mi/dso-console/commit/aeaded1671f5de18d9e613fa32fda162a6147a27)) +* :white_check_mark: Correct api call in repo e2e test ([64d5196](https://github.com/dnum-mi/dso-console/commit/64d51968bad97f50f36a1619916b7c2b82378078)) +* :white_check_mark: Fix e2e tests on repo ([de0c0a5](https://github.com/dnum-mi/dso-console/commit/de0c0a59e02b639a67066c28b9532a5d38e12e98)) +* :zap: remove ansible legacy from server ([68c9dc9](https://github.com/dnum-mi/dso-console/commit/68c9dc9100aff3e71ccdab18608f61f9420e720f)) +* conf keycloak for cors from localhost:8080 ([1e06ff0](https://github.com/dnum-mi/dso-console/commit/1e06ff033094024d4e1a4f66bab5443b4505247c)) +* login on keycloack TEST ([c983eed](https://github.com/dnum-mi/dso-console/commit/c983eedccdba49d024ba6e5726f69afd33e577a1)) +* **projectInit:** :bricks: Add ports in vite config ([5de6e86](https://github.com/dnum-mi/dso-console/commit/5de6e86e02343ff910023681960e3bf7a788becd)) +* **projectInit:** :bug: Add context in docker-compose ([3ac3a72](https://github.com/dnum-mi/dso-console/commit/3ac3a7297c1b81fdb93ed099605ceeb2044ad617)) +* **projectInit:** :hammer: Fix run test script for client ([272f007](https://github.com/dnum-mi/dso-console/commit/272f007803d35e2113646d57b715e84249fa65bf)) +* remove double code in dockerfile ([2951672](https://github.com/dnum-mi/dso-console/commit/29516725acf793168854296f6f35088ef5b3447c)) +* rename and add volumes for keycloak ([3d0d999](https://github.com/dnum-mi/dso-console/commit/3d0d999630906223e8cb89fa51b31022e04e1659)) +* run cypress in container ([4589169](https://github.com/dnum-mi/dso-console/commit/4589169d0bb0fd1c368c08869e80251d0d5f691a)) +* run with delay 10 second to wait keycloak initialize ([b736991](https://github.com/dnum-mi/dso-console/commit/b736991208b9a5fc0880fba294fa96dc073f4c8a)) +* update gitignore for all node_modules ([3988743](https://github.com/dnum-mi/dso-console/commit/39887438eceefe4b08b80ad056f2992baaf78494)) +* update implicit flow in config keycloak ([06d2f77](https://github.com/dnum-mi/dso-console/commit/06d2f77fcc07db2de780867b0bda1f49294b349e)) +* update pnpm lock ([344261d](https://github.com/dnum-mi/dso-console/commit/344261d2e1b37e8df9eed0cca90ed8e21697d17a)) + +### Reverts + +* :recycle: Move code to another git branch ([1c632c7](https://github.com/dnum-mi/dso-console/commit/1c632c715ffc01100d75c46c1e84a565195a10b3)) diff --git a/.jjconflict-side-2/CONTRIBUTING.md b/.jjconflict-side-2/CONTRIBUTING.md new file mode 100644 index 0000000000..50318b28ee --- /dev/null +++ b/.jjconflict-side-2/CONTRIBUTING.md @@ -0,0 +1,115 @@ +# Contribuer à Console Cloud Pi Native + +Merci de contribuer à la Console Cloud Pi Native. Ce guide s'applique aux +contributions internes et externes, qu'elles concernent du code, de la +configuration, des dépendances ou de la documentation. + +## Principes + +La Console fournit une interface unifiée pour piloter les services de l'offre +Cloud Pi Native, automatiser le cycle de vie des projets et appliquer les +exigences DevSecOps de sécurité, de qualité, de maintenabilité et +d'interopérabilité. + +Toute évolution doit démontrer son alignement avec cette vision avant toute +implémentation. Une contribution qui ne sert pas l'offre, son architecture ou +sa feuille de route ne peut pas être acceptée. + +## Qualifier le sujet avant de développer + +Toute contribution de code doit être adossée à un ticket. Ce ticket décrit : + +- le besoin traité et son alignement avec la vision produit ou la feuille de route ; +- le périmètre fonctionnel et technique de la contribution ; +- les critères d'acceptation ; +- les impacts connus sur la sécurité, les contrats API, les données, l'exploitation et la documentation. + +Pour une correction de bug, une procédure de reproduction, un test automatisé +de non-régression et, pour une interface, des captures d'écran sont fortement +recommandés lorsqu'ils sont pertinents. Leur absence ne bloque pas la +contribution lorsqu'ils ne peuvent pas s'appliquer. Pour une fonctionnalité, +joindre des maquettes ou croquis, même rudimentaires, est encouragé. + +Les corrections strictement documentaires ou typographiques, sans impact +fonctionnel, sont les seules contributions qui ne nécessitent pas de ticket. +Elles restent soumises au contrôle de périmètre et aux exigences de qualité +adaptées. + +## Respecter le périmètre + +Une pull request ou merge request ne contient que les changements nécessaires +au sujet décrit par son ticket. Les commits, fichiers ou changements sans +rapport direct avec ce sujet sont rejetés et doivent être proposés séparément, +avec leur propre ticket. + +Utilisez le [template de pull request](.github/PULL_REQUEST_TEMPLATE.md) pour +présenter le comportement modifié et les informations utiles à la revue. Son +évolution est traitée séparément de ce guide. + +## Obtenir les validations requises + +Une approbation explicite de l'équipe Socle est obligatoire pour toute +contribution de code qui n'est ni une correction de bug ni un changement +trivial. + +L'approbation explicite de l'équipe Socle est obligatoire dans tous les cas +pour : + +- l'ajout, la suppression ou la mise à niveau d'une dépendance Node.js ; +- l'ajout ou la mise à niveau d'un outil installé dans une image Docker ; +- tout autre changement de dépendance ayant un impact sur la chaîne d'approvisionnement, la sécurité ou la maintenance. + +Les corrections de bugs et les changements triviaux ne dispensent ni du +contrôle de périmètre, ni de la revue de code, ni des contrôles qualité. Une +dépendance ajoutée pour corriger un bug doit donc recevoir l'approbation +explicite de l'équipe Socle. + +## Cibler le backend approuvé + +`apps/server` est le backend historique et ne reçoit plus de contribution. +Toute pull request ou merge request qui modifie ce dossier est rejetée, y +compris pour une correction de bug. + +Les évolutions backend ciblent exclusivement `apps/server-nestjs`. Elles +suivent le [plan de modularisation backend vers NestJS](apps/server-nestjs/documentation/Modularisation-de-console-server/README.md), l'[état de migration](apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md), les conventions NestJS et la stratégie de tests associée. Toute dérogation est une décision explicite de l'équipe Socle, hors du flux normal de contribution. + +## Respecter l'architecture et la qualité + +Respectez les frontières et conventions de la zone modifiée : + +- le frontend Vue 3 et le système de design existant ; +- les modules NestJS et Prisma dans `apps/server-nestjs` pour le backend ; +- les contrats partagés, permissions et schémas de données ; +- les plugins et leurs mécanismes de cycle de vie lorsqu'ils sont concernés. + +Ajoutez ou adaptez les tests proportionnés au risque et au périmètre : tests +unitaires ou d'intégration pour la logique et les contrats, et tests Playwright +pour les parcours utilisateurs affectés. Mettez à jour la documentation lorsqu'un +comportement, une API, une configuration, une opération ou une migration est +affectée. + +Avant soumission, exécutez les contrôles adaptés à votre modification : + +```bash +pnpm lint +pnpm test +pnpm playwright:test +``` + +Le test Playwright est requis lorsque la contribution affecte un parcours +utilisateur. Respectez aussi TypeScript strict, ESLint, Stylelint pour le +client, les hooks Husky et la CI. Les messages de commit suivent Conventional +Commits et sont rédigés en anglais. Rebasez votre branche sur `origin/main` +avant la demande de fusion. + +## Documents de référence + +Consultez et citez les références appropriées dans le ticket : + +- la [documentation publique Cloud Pi Native](https://github.com/cloud-pi-native/documentation), notamment l'[introduction de la plateforme](https://github.com/cloud-pi-native/documentation/blob/main/docs/platform/introduction.md) et la [feuille de route](https://github.com/cloud-pi-native/documentation/blob/main/docs/platform/roadmap.md) ; +- l'[architecture et les conventions du dépôt](README.md) ; +- le [plan de modularisation NestJS](apps/server-nestjs/documentation/Modularisation-de-console-server/README.md) ; + +La feuille de route publique est actuellement en cours de rédaction. Son +indisponibilité ne bloque pas la qualification : le ticket doit alors exposer +une justification concise au regard de la vision produit. diff --git a/.jjconflict-side-2/ENVIRONMENTS.md b/.jjconflict-side-2/ENVIRONMENTS.md new file mode 100644 index 0000000000..011939c593 --- /dev/null +++ b/.jjconflict-side-2/ENVIRONMENTS.md @@ -0,0 +1,244 @@ +# Fichiers de configuration d'environnements + +Cette documentation a pour but de détailler tout ce qui concerne la gestion des configuration d'environnements (appelé couramment "fichiers .env"). + +Comme vous aurez pu le constater, il y a beaucoup de choses à configurer pour un projet d'ampleur comme l'est la Console de CPiN, et il y a des cas d'usages très spécifiques qui seront décrits ici. Vous avez également la possibilité de composer votre propre manière de gérer vous variables d'environments en vous basant sur ce qui a été fait pour nous. + +## Contexte : migration progressive vers NestJS (Strangler Fig) + +Le backend de la console est en cours de migration de `apps/server` (Fastify/legacy) vers `apps/server-nestjs` (NestJS). Cette migration est progressive, route par route, selon le [Strangler Fig Pattern](https://martinfowler.com/bliki/StranglerFigApplication.html). + +Pour orchestrer cette migration, un **nginx-strangler** a été introduit (ticket [#1885](https://github.com/cloud-pi-native/console/issues/1885)). Il s'intercale entre le `client` et les backends, et route chaque requête API vers le bon service : + +``` +[client :8080] + │ proxy /api → nginx-strangler:8080 + ▼ +[nginx-strangler :8080] + ├── /api/[routes migrées] ──→ server-nestjs:3001 (NestJS) + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +**Conséquences pratiques pour les développeurs :** + +- `server-nestjs` est désormais présent dans tous les `docker-compose` aux côtés de `server`. +- Le `nginx-strangler` est le nouveau point d'entrée unique pour les appels API — le `client` ne pointe plus directement vers `server`. +- Consultez [`MODULARISATION-STATUT.md`](apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md) avant de développer sur un module backend, pour savoir s'il est en cours de migration (zones en feature freeze). +- Si vous travaillez sur un module déjà migré vers `server-nestjs`, développez dans `apps/server-nestjs`, pas dans `apps/server`. + +Pour plus de détails sur la stratégie de migration : [`apps/nginx-strangler/README.md`](apps/nginx-strangler/README.md). + +--- + +## Cas d'usage supportés + +Avant de décrire dans le détail comment configurer les différents environments, il est important de rappeler les Cas d'Usage qui sont supportés par les scripts de CPiN: + +- **Développement totalement en local** : vous déployez l'entièreté de l'écosystème de `console`, sans considération d'un branchement à l'extérieur, et vous développez directement en mode "serve" sur `client`, `server`, `server-nestjs`, un `plugin` en particulier, ou peut-être même une combinaison des trois. +- **Développement conteneurisé en local** : vous déployez l'entièreté de l'écosystème de `console`, sans considération d'un branchement à l'extérieur, et même les composants fondamentaux de console (comme `client`, `server` et `server-nestjs`) dans des conteneurs à l'aide d'images construites précédemment (en local, ou alors tirée depuis le registre de l'organisation CPiN) +- **Développement conteneurisé hybride** : vous déployez localement seulement une partie de l'écosystème de `console` (`client` et/ou `server`/`server-nestjs`, de manière à utiliser votre code local) et pour le reste (Base de données de `console`, `keycloak`, `gitlab`, les clusters applicatifs, etc.), vous vous branchez à un environnement existant (appelé `integ` pour `INTEGRATION`). Ce cas d'usage est très pratique pour tester votre code avec de "vraies données" d'un environnement fonctionnel (comme notre environnement interne `cpin-hp`) +- Les cas de déploiements finaux du système complet, qui sont eux adressé par un chart Helm stocké dans [le dépôt `helm-charts`](https://github.com/cloud-pi-native/helm-charts) + +Maintenant que ces définitions sont établies, passons à la configuration pour chacun des cas d'usage + +## Considérations communes à tous les cas + +Le mode de fonctionnement de la configuration des environnements est assez classique : les applications `client`, `server` et `server-nestjs` ont besoin d'avoir certaines variables d'environnements définies. + +Le mécanisme de surcharge des différentes configurations fonctionne de cette manière : + +``` +->: «surcharge» + +var d'env settée explicitement -> fichier .env.docker (si contexte docker) -> fichier .env.integ (si INTEGRATION=true) -> fichier .env +``` + +## Prégénération des fichiers .env, .env.docker, et .env.integ + +Un script permet de copier facilement les fichiers `.env*-example` en leur équivalent `.env*`: [`./ci/scripts/init-env.sh`](./ci/scripts/init-env.sh). + +> Il faut ensuite remplir ces fichiers, car ils ne sont là que simplement copiés avec les valeurs par défaut + +## Configuration pour le développement entièrement en local + +Le développement entièrement en local suit cette organisation des composants: + +```mermaid +--- +title: Architecture mode entièrement local +--- +graph LR + + subgraph DEV_LOCAL["DEV LOCAL"] + direction LR + NAV_L["Navigateur"] + NGINX_L(("nginx (client)\n:8080\nLOCAL")) + STRANGLER_L(("nginx-strangler\n:8080\n🐳 exposé :4000")) + SERVER_L(("server\n:4001\nLOCAL")) + NESTJS_L(("server-nestjs\n:3001\nLOCAL")) + + NAV_L --> NGINX_L + NGINX_L -- "/api" --> STRANGLER_L + STRANGLER_L -- "défaut\n(pas migré)" --> SERVER_L + STRANGLER_L -- "migré" --> NESTJS_L + end + + style DEV_LOCAL fill:#fff,stroke:#333,stroke-width:2px + style NAV_L fill:#fff,stroke:#333 +``` + +Docker Compose utilisé : [`docker/docker-compose.local.yml`](docker/docker-compose.local.yml) (infrastructure uniquement : Keycloak, PostgreSQL, pgAdmin, OpenCDS mock, **nginx-strangler, et Jaeger**) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/server/.env` +- `apps/server-nestjs/.env` *(si vous travaillez sur server-nestjs)* + +Les valeurs par défaut, disponibles dans les fichiers `.env-example`, sont suffisantes dans 99% des cas. Cela dit, c'est un cas d'usage assez restreint car la console se reposant sur quelques composants externes comme une base de données PostgreSQL ou un serveur IAM comme `keycloak`, il faut configurer manuellement les `.env` pour pointer sur les bonnes URLs, etc. Pas infaisable, mais pas très pratique au quotidien, hors des cas simple de build des applications. + +**Commandes de lancement :** + +```bash +# Lance l'infrastructure (Keycloak, PostgreSQL, pgAdmin, OpenCDS mock, nginx-strangler, Jaeger) +pnpm dev + +# Puis dans d'autres terminaux, lancer les serveurs et le client manuellement : +pnpm --filter server run dev +pnpm --filter server-nestjs run start:dev # nouveau backend NestJS +pnpm --filter client run dev +``` + +### Faire transiter les appels API par le nginx-strangler en dev local + +Par défaut, le proxy Vite du `client` pointe directement vers `server` (port `4000`). Pour faire transiter les appels via le `nginx-strangler` (utile pour tester le routage de migration ou travailler sur `server-nestjs`), une seule variable suffit dans `apps/client/.env` : + +```bash +# apps/client/.env +SERVER_PORT=8082 # port du nginx-strangler exposé par docker-compose.local.yml + # (au lieu de 4000 qui pointe directement vers server) +``` + +Le `nginx-strangler` est automatiquement lancé par `pnpm dev` via `docker-compose.local.yml`. Il écoute sur `localhost:8082` et redirige vers les deux backends natifs (`server:4000` et `server-nestjs:3001`) selon la configuration de [`apps/nginx-strangler/conf.d/routing.conf`](apps/nginx-strangler/conf.d/routing.conf). + +> **Note :** si vous ne travaillez pas sur la migration NestJS, vous n'avez pas besoin de changer `SERVER_PORT` — le comportement par défaut (proxy direct vers `server:4000`) reste identique. + +### Observabilité : Jaeger + OpenTelemetry (traces) + +Le fichier [`docker/docker-compose.local.yml`](docker/docker-compose.local.yml) démarre un service `jaeger` (image `jaegertracing/all-in-one`) pour collecter et visualiser les traces. + +- UI Jaeger : http://localhost:16686 +- Endpoints de collecte exposés sur la machine hôte : + - OTLP gRPC : `localhost:4317` + - OTLP HTTP (protobuf) : `http://localhost:4318` + +Dans `apps/server-nestjs`, l'instrumentation OpenTelemetry est initialisée au démarrage via [`src/instrumentation.ts`](apps/server-nestjs/src/instrumentation.ts) (appelée depuis `main.ts`) et exporte via OTLP. + +Pour vérifier rapidement : + +1. Démarrer l'infra : `pnpm dev` (Jaeger inclus). +2. Démarrer `server-nestjs` : `pnpm --filter server-nestjs run start:dev`. +3. Exécuter une requête sur une route backend (depuis le client ou un `curl`). +4. Ouvrir http://localhost:16686 et chercher le service `cloud-pi-native-console`. + +## Configuration pour le développement conteneurisé en local + +Docker Compose utilisé : [`docker/docker-compose.dev.yml`](docker/docker-compose.dev.yml) (tout conteneurisé avec Docker Compose Watch) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/client/.env.docker` +- `apps/server/.env` +- `apps/server/.env.docker` +- `apps/server-nestjs/.env` +- `apps/server-nestjs/.env.docker` + +Cette configuration est déjà plus intéressante, car elle s'appuie sur les conteneurs définis dans [ce docker-compose](docker/docker-compose.dev.yml), qui lance notamment une base de données PostreSQL (ainsi qu'un `pgadmin`), et un serveur Keycloak préchargé avec un royaume qui contient un jeu de données. Le docker-compose contient des instructions `develop` qui permettent soit de synchroniser certains fichiers, soit de carrément reconstruire l'image et de relancer le service concerné. De cette manière vous pouvez développer en laissant les conteneurs tourner. C'est un peu moins performant qu'un travail totalement en local, mais ça a le mérite d'être plus proche du déploiement cible. + +Le `nginx-strangler` et `server-nestjs` sont inclus dans ce docker-compose et démarrent automatiquement. Le `client` pointe vers `nginx-strangler` — le routage API est donc toujours actif, même si aucune route n'est encore basculée vers NestJS (tout passe par `server` en fallback). + +**Commande de lancement :** + +```bash +# Lance l'ensemble des conteneurs (client, server, server-nestjs, nginx-strangler, +# keycloak, postgres, pgadmin, opencds mock) avec Docker Compose Watch +pnpm docker:dev +``` + +## Configuration pour le développement hybride avec un environnement d'intégration existant + +Le développement hybride branché sur l'environnement d'intégration suit cette organisation des composants: + +```mermaid +--- +title: Architecture mode hybride branché sur l'environment d'intégration +--- +graph LR + + subgraph DEV_INTEG["DEV INTEG"] + direction LR + NAV_I["Navigateur"] + CURL_I["curl directs\n(tests)"] + INTEG["INTEG\n(keycloak, gitlab, etc.)"] + + subgraph DOCKER_NET["🐳 Réseau Docker"] + direction LR + NGINX_I(("nginx (client)\n:8080\nexposé :8080")) + STRANGLER_I(("nginx-strangler\n:8080\nexposé :4000")) + SERVER_I(("server\n:8080\nexposé :4001")) + NESTJS_I(("server-nestjs\n:3001\nexposé :3001")) + + NGINX_I -- "/api" --> STRANGLER_I + STRANGLER_I -- "pas migré" --> SERVER_I + STRANGLER_I -- "migré" --> NESTJS_I + end + + NAV_I --> NGINX_I + SERVER_I -. "accès externe" .-> INTEG + NESTJS_I -. "accès externe" .-> INTEG + + CURL_I -- ":8080" --> NGINX_I + CURL_I -- ":4000" --> STRANGLER_I + CURL_I -- ":4001" --> SERVER_I + CURL_I -- ":3001" --> NESTJS_I + end + + style DEV_INTEG fill:#fff,stroke:#333,stroke-width:2px + style DOCKER_NET fill:#e6f3ff,stroke:#2196F3,stroke-width:2px + style NAV_I fill:#fff,stroke:#333 + style CURL_I fill:#fff,stroke:#333 + style INTEG fill:#fff,stroke:#333,stroke-dasharray: 5 5 +``` + +Docker Compose utilisé : [`docker/docker-compose.integ.yml`](docker/docker-compose.integ.yml) (sans Keycloak, branché sur l'environnement d'intégration distant) + +Fichiers utilisés : + +- `apps/client/.env` +- `apps/client/.env.docker` +- `apps/client/.env.integ` +- `apps/server/.env` +- `apps/server/.env.docker` +- `apps/server/.env.integ` +- `apps/server-nestjs/.env` +- `apps/server-nestjs/.env.docker` +- `apps/server-nestjs/.env.integ` *(si vous travaillez sur server-nestjs en mode integ)* + +Cette configuration est une itération de la précédente. Dans ce cas d'usage le Keycloak n'est pas créé en tant que conteneur, car on est supposé se brancher sur l'environnement d'intégration défini dans les fichiers `.env.integ`. Le contenu de ces fichiers (en particulier celui de `apps/server`) est donc clé. + +Le `nginx-strangler` et `server-nestjs` sont également inclus dans ce docker-compose. + +**Commandes de lancement :** + +```bash +# Option 1 : Tout conteneurisé, branché sur l'environnement d'intégration +pnpm docker:integ + +# Option 2 : Seulement l'infra en Docker (postgres, pgadmin), server et client en local avec mode integ +pnpm integ +# Puis dans d'autres terminaux : +pnpm --filter server run dev +pnpm --filter server-nestjs run start:dev +pnpm --filter client run dev +``` diff --git a/.jjconflict-side-2/LICENSE b/.jjconflict-side-2/LICENSE new file mode 100644 index 0000000000..4b139aa8bc --- /dev/null +++ b/.jjconflict-side-2/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Ministère de l’Intérieur et des Outre-Mer + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/.jjconflict-side-2/README.md b/.jjconflict-side-2/README.md new file mode 100644 index 0000000000..3a89503a2b --- /dev/null +++ b/.jjconflict-side-2/README.md @@ -0,0 +1,409 @@ +# Console Cloud π Native + +La console Cloud π Native est une application web ayant pour but de piloter des services dans un cluster Kubernetes afin de fournir une plateforme cloud qui accompagne les produits numériques lors de toutes les phases de leur cycle de vie. + +Cette console offre une interface unifiée vers un ensemble de services tout en garantissant une cohérence globale du système avec la création automatique d'un certain nombre de ressources comme les comptes d'accès, les robots ou encore des ressources Kubernetes. +En addition du provisionnement automatique, elle garantit aussi le contrôle d'accès aux ressources du projet à l'aide d'une gestion d'équipe, de permissions, de quotas, etc. + +<img src="./misc/images/projects.png" width="30%"></img> <img src="./misc/images/repositories.png" width="30%"></img> <img src="./misc/images/environments.png" width="30%"></img> <img src="./misc/images/services.png" width="30%"></img> <img src="./misc/images/members.png" width="30%"></img> <img src="./misc/images/status.png" width="30%"></img> + +## Démarrage Rapide (OnBoarding) + +Afin de démarrer rapidement l'application pour la découvrir il vous faudra d'abord cloner ce dépôt, puis satisfaire à un certain nombre de prérequis. + +### Cloner le projet + +```bash +git clone https://github.com/cloud-pi-native/console.git + +# Se rendre dans le dossier du projet +cd console +``` + +### Prérequis + +#### Prérequis techniques du projet + +- [Docker >= v27](https://docs.docker.com/get-docker/) Orchestrateur de conteneurs + - [Plugin compose >= v2.35](https://github.com/docker/compose) (attention à ne pas avoir une vieille version qui traînerait dans `~/.docker/cli-plugins/` !). Permet de composer plusieurs conteneurs Docker + - [Plugin buildx](https://github.com/docker/buildx) Permet d'étendre les capacités de Docker à l'aide de BuildKit +- [Node.js >= v24](https://nodejs.org/en/download/) Environnement d'exécution JavaScript +- [PnPM >= v10](https://pnpm.io/installation) Gestionnaire de paquets pour JavaScript + +#### Prérequis de configuration du projet + +Afin de pouvoir démarrer l'application, il vous faudra également récupérer (ou bien créer vous-même) les fichiers : + +- `apps/client/.env.docker` +- `apps/client/.env.integ` +- `apps/server/.env.docker` +- `apps/server/.env.integ` + +Si vous ne les avez pas déjà, référez-vous à [./ENVIRONMENTS.md](./ENVIRONMENTS.md) qui est la documentation consacrée au sujet. + +### Lancement rapide utilisant l'application en local et les services sur un environnement distant + +Maintenant que vous avez les prérequis projets (prérequis techniques et de configuration), vous pouvez construire et lancer l'application localement, en la couplant avec les services (par exemple Keycloak) d'un environnement d'intégration : + +```bash +# Installer toutes les dépendances +$ pnpm install + +# Initialiser la base de données PostgreSQL lancée localement +$ pnpm db:generate + +# Construire les applications client et server +$ pnpm build + +# Construire les images dockers client et server, puis lancer la stack docker compose d'intégration +$ export KUBECONFIG_HOST_PATH="$HOME/.kube/config" && pnpm run docker:integ +``` + +Cette série de commandes va installer les diverses dépendances, construire l'intégralité de l'application Console (client et serveur) dans des images docker locales, et finalement lancer l'application en la connectant dans votre infrastructure d'Intégration existante. + +Si vous voulez une vue plus approfondie du lancement de l'application, vous pouvez vous référer à la [section détaillée à ce sujet](#développement). + +## Architecture + +Ce projet est construit avec [NodeJS](https://nodejs.org/), [VueJS](https://vuejs.org/), [Postgres](https://www.postgresql.org/) et [Keycloak](https://www.keycloak.org/). +Le serveur et le client sont livrés sous forme d'images [Docker](https://www.docker.com/) et sont déployées à l'aide de [Helm](https://helm.sh/) dans [Kubernetes](https://kubernetes.io/). + +### API + +Le serveur est construit selon une architecture __core / plugins__ pour favoriser l'évolutivité et l'ajout de nouvelles fonctionnalités / la gestion de nouveaux services. Pour ce faire, les plugins s'enregistrent auprès de différents `hooks` (qui suivent le cycle de vie d'un projet au sein de l'application), ces derniers seront déclenchés par les contrôleurs de l'application. + +Plusieurs plugins sont nativement enregistrés auprès du serveur pour assurer le bon fonctionnement de la plateforme, à savoir : + +- [Argocd](https://argo-cd.readthedocs.io/en/stable/) +- [Gitlab](https://about.gitlab.com/) +- [Harbor](https://goharbor.io/) +- [Keycloak](https://www.keycloak.org/) +- [Kubernetes](https://kubernetes.io/) +- [Nexus](https://www.sonatype.com/products/sonatype-nexus-repository) +- [Sonarqube](https://www.sonarsource.com/products/sonarqube/) +- [Vault](https://www.vaultproject.io/) + +> Pour plus d'informations sur le développement d'un plugin, voir : +> - [documentation des plugins](./misc/plugins.md). +> - [documentation du module hooks](./packages/hooks/README.md). +> - [exemple de plugin](https://github.com/cloud-pi-native/console-plugin-helloworld). + +### Gestion des rôles + +La console gère le contrôle d'accès via des rôles (administration de la console et rôles au niveau projet) composés d'un nom, d'un ensemble de permissions (bitmask) et d'un type (`type`). + +Chaque rôle est associé à un groupe OIDC/Keycloak (`oidcGroup`) mais la source de vérité peut varier selon le type : + +- `managed` : la console est source de vérité pour les membres et les permissions, puis synchronise l'appartenance avec le groupe `oidcGroup`. +- `external` : le groupe OIDC est la source de vérité pour l'appartenance ; la console s'aligne dessus. +- `global` : rôle d'administration destiné à porter des permissions transverses telle que le role "Tout le monde". + +Le préfixe `system:` est un qualifiant (pas un type à part entière) utilisé pour marquer les rôles fournis/maintenus par la plateforme, par exemple `system:managed`, `system:external`, `system:global`. + +Dans l'interface, un rôle dont le type commence par `system:` est considéré comme non modifiable : ses champs (nom/type/groupe/permissions) sont en lecture seule et la suppression est désactivée. Selon l'écran, la gestion des membres peut rester disponible. + +## Développement + +Le développement s'effectue à l'aide de Docker *(le client et le serveur peuvent tourner en local ou dans Docker)*. + +### Construire l'application + +Une fois le projet cloné, lancez les commandes suivantes dans votre terminal pour installer le projet et construire l'ensemble de l'application `console` : + +```shell +# Installer les dépendances du projet +pnpm install + +# Créer les fichiers d'environnement exemples +./ci/scripts/init-env.sh + +# Générer le client Prisma côté serveur +pnpm --filter @cpn-console/server run db:generate + +# Construire tous les paquets applicatifs +pnpm build +``` + +#### Lancer l'application + +L'application peut se lancer de plusieurs manières, à savoir : + +__Déploiement local du client et du serveur de la console, le reste dans des conteneurs :__ + +```shell +# Lancer keycloak, postgres et pgadmin dans des conteneurs +pnpm run dev + +# Lancer le serveur dans un shell (la commande va afficher les logs) +pnpm --filter @cpn-console/server run dev + +# Lancer le client dans un shell (la commande va afficher les logs) +pnpm --filter @cpn-console/client run dev + +# …une fois que vous terminé d'utiliser l'application: + +# Supprimer les conteneurs keycloak, postgres et pgadmin +pnpm run dev:clean + +# Supprimer les conteneurs keycloak, postgres et pgadmin (supprime les volumes docker) +pnpm run dev:delete +``` + +__Tout dans des conteneurs :__ + +```shell +# Lancer l'application +pnpm run docker:dev + +# Supprimer les conteneurs +pnpm run docker:dev:clean + +# Supprimer les conteneurs (supprime également les volumes !) +pnpm run docker:dev:delete +``` + +> Pour lancer le debugger Node.js sur le serveur, dans les fichiers `docker-compose` remplacer la directive `command: ["dev"]` par `command: ["debug"]`. + +#### Intégration + +L'application peut se lancer de plusieurs manières, à savoir : + +__Déploiement local du client et du serveur de la console, le reste dans des conteneurs :__ + +```shell +# Lancer postgres et pgadmin dans des conteneurs +# (le keycloak utilisé devra être configuré dans les .env.integ) +pnpm run integ + +# Lancer le serveur +pnpm --filter @cpn-console/server run integ + +# Lancer le client +pnpm --filter @cpn-console/client run integ + +# Supprimer les conteneurs postgres et pgadmin +pnpm run integ:clean + +# Supprimer les conteneurs postgres et pgadmin (supprime les volumes docker) +pnpm run integ:delete +``` + +__Tout dans des conteneurs :__ + +```shell +# Lancer l'application +pnpm run docker:integ + +# Supprimer les conteneurs +pnpm run docker:integ:clean + +# Supprimer les conteneurs (supprime les volumes docker) +pnpm run docker:integ:delete +``` + +#### Utilitaires + +Les commandes utilitaires de l'application : + +```shell +# Formattage du code +pnpm run format + +# Lancer la vérification syntaxique +pnpm run lint + +# Lancer les tests unitaires +pnpm run test + +# Lancer les tests de composants +pnpm run test:ct +``` + +L'intégralité des commandes est disponibles dans le fichier [package.json](./package.json) à la racine du projet, vous pouvez lancer ces dernières à l'aide de la commande `pnpm run <le_nom_du_script>`. + +### Tests Playwright + +Se référer à la [documentation concernée](./playwright/README.md). + +### Accès aux services + +Les services sont disponibles via les ports suivants : + +| Service | Url (local/docker) | +| ---------------------------------------------- | ----------------------- | +| Interface graphique *- (client)* | <http://localhost:8080> | +| Serveur *- (api)* | <http://localhost:4000> | +| Interface d'administration de base de données | <http://localhost:8081> | +| Interface d'administration du serveur keycloak | <http://localhost:8090> | + +*__Notes:__ ⚠ Il est possible que le navigateur utilisé (particulièrement Brave ou Firefox) bloque les cookies utilisés entre le frontend et keycloak, il est nécessaire de désactiver les protections de ce type dans votre navigateur (ex: Brave Shield).* + +### Informations de connexion + +Les comptes utilisés pendant le développement sont les suivants : + +| Service | Nom d'utilisateur | Mot de passe | +| ------------------ | ----------------- | ------------ | +| Keycloak *(admin)* | `admin` | `admin` | +| Keycloak *(user)* | `test` | `test` | +| PgAdmin | `admin@dso.fr` | `admin` | +| Postgres | `admin@dso.fr` | `admin` | + +> La liste complète des comptes Keycloak pré-créés est disponible [ici](./keycloak/realms/realm-dev.json). + +> Le nom de la base de données est : `dso-console-db`. + +### Variables d'environnements + +__Local / Docker:__ + +Les variables d'environnements sont gérées localement via des fichiers `.env` (local) / `.env.docker` (docker) dans les dossiers `./apps/server` et `./apps/client`, aux précédents fichiers s'ajoute un fichier `.env.integ` utilisé pour le mode intégration (local et docker). + +### Base de données + +Pour faciliter les opérations de migrations de base de données via [Prisma](https://www.prisma.io/), un script est disponible : + +```shell +# Lancer le script +pnpm --filter @cpn-console/server run db:wrapper + +# Voir l'aide du script +pnpm --filter @cpn-console/server run db:wrapper -h +``` + +### Débogage avec VS Code + +Le projet est configuré pour être débogué directement dans VS Code via le fichier `.vscode/launch.json`. +Rendez-vous dans l'onglet "Run and Debug" (ou `Ctrl+Shift+D`) pour voir les configurations disponibles. + +#### Configurations disponibles + +- **Launch Server** : Lance le serveur en local (hors Docker) et attache le débogueur. + - *Note : Assurez-vous que les bases de données (Postgres) et Keycloak sont accessibles (ex: lancés via `pnpm run dev` dans un autre terminal pour les dépendances).* +- **Attach Server** : S'attache au processus Node.js du serveur. + - Utile lorsque le serveur tourne dans un conteneur Docker. + - Pour que cela fonctionne, le serveur doit être lancé avec le mode debug activé. +- **Serve Client (Chrome/Firefox)** : Lance l'application client en local et ouvre une instance de navigateur dédiée au débogage. +- **Launch (Chrome/Firefox)** : Lance simultanément le serveur et le client pour une session de débogage complète (Compound task). + +#### Déboguer avec d'autres outils (Chrome DevTools, WebStorm, etc.) + +Le serveur Node.js expose le port de débogage `9229` lorsqu'il est lancé en mode debug (ou avec l'option `--inspect`). Vous pouvez utiliser n'importe quel client de débogage compatible avec le protocole Inspector de Node.js. + +**Exemple avec Chrome DevTools :** + +1. Lancez le serveur en mode debug (voir section précédente pour Docker, ou `pnpm run debug` en local). +2. Ouvrez Chrome et naviguez vers `chrome://inspect`. +3. Cliquez sur "Open dedicated DevTools for Node" ou attendez que votre target apparaisse sous "Remote Target". +4. Vous pouvez maintenant mettre des points d'arrêt, inspecter les variables, etc. + +### Construction des images + +Ce dépôt utilise des fichiers docker-compose pour construire les images docker: + +- [docker-compose.local.yml](./docker/docker-compose.local.yml) pour le développement local (`client`/`server`/`server-nestjs` lancés localement, et une "glue" docker pour les joindre) +- [docker-compose.dev.yml](./docker/docker-compose.dev.yml) pour la construction des images docker du mode développement. +- [docker-compose.ci.yml](./docker/docker-compose.ci.yml) pour la construction des images docker utilisées dans nos pipelines d'Intégration Continue. + +## Configuration du Keycloak + +Pour pouvoir gérer les droits utilisateurs des services le pod `server` doit accéder aux groupes des users. Cela signifie modifier le clientScope `profile`: + +- Onglet `Mappers` +- `Add Mappers > By configuration > Group Membership` + - Name: `groups` + - Token Claim Name: `groups` + - Full group path: `off` + - Add to ID token: `on` + - Add to access token: `on` + - Add to userinfo: `off` + +> En environnement de dev l'import par défaut prévoit déjà cette modification. + +Les utilisateurs faisant parti du group `admin` ont également accès à l'interface administrateur de la console une fois connectés via un onglet supplémentaire `Administration` dans le menu latéral de l'application. + +## Tableau des ressources, terminologie + +| Console Cloud Pi | Projet | Environnement | Dépots | Utilisateur / membre | +| ---------------- | ---------------------------- | ------------- | --------------------------------------- | -------------------- | +| __Openshift__ | | Namespace | | | +| __ArgoCD__ | | | (infra) Secret, AppProject, Application | | +| __Gitlab__ | Group | | Repository (Dépôt) | User | +| __Harbor__ | Project | | Repository [1] | | +| __Keycloak__ | | Group | | User / member | +| __Sonar__ | User | | | | +| __Nexus__ | Repositories, role, user ... | | | | + +[1] N'est pas crée par la console mais par le produit de la CI + +## Architecture du dépôt + +La gestion des dépendances est effectuée à l'aide de [pnpm](https://pnpm.io/) selon la structure de dossiers suivante : + +- Les différentes briques applicatives se trouvent dans le dossier `apps/`. +- Les bibliothèques additionnelles se trouvent dans le dossier `packages/`. +- Les plugins core se trouvent dans le dossier `plugins/`. + +*Schema de l'architecture du monorepo :* + +```shell +./ +├── apps +│ ├── client +│ └── server +├── packages +│ ├── eslintconfig +│ ├── hooks +│ ├── shared +│ ├── test-utils +│ └── tsconfig +├── plugins +│ ├── argocd +│ ├── gitlab +│ ├── harbor +│ ├── keycloak +│ ├── kubernetes +│ ├── nexus +│ ├── sonarqube +│ └── vault +├── package.json +├── pnpm-lock.yaml +├── pnpm-workspace.yaml +└── README.md +``` + +## Organisation avec Git + +Une requête de fusion ("merge request") doit être faite avec la branche `main` comme destination. + +La branche de base **doit** être à jour avec `origin/main` (utiliser `git pull origin/main && git rebase origin/main` ou l'option de rebasage dans l'interface Web si besoin) avant la demande de fusion (MR). + +## Conventions de nommage + +Cf. [Conventions - MIOM Fabrique Numérique](https://docs.fabrique-numerique.fr/conventions/nommage.html). + +Les commits doivent suivre la spécification des [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). Cette norme est utilisée pour construire la nouvelle version, voir ci-après. + +Il est possible d'ajouter l'[extension VSCode](https://github.com/vivaxy/vscode-conventional-commits) pour faciliter la création des commits. + +> Les messages de commits sont rédigés en anglais. + +## Gestion des versions + +Se référer à [./RELEASE.md](./RELEASE.md). + +## Gestion des dépendances + +### Mise à jour des dépendances + +À propos du cycle de vie des dépendances, et notamment leur mise à jour, un document spécifique est disponible [ici](https://github.com/cloud-pi-native/documentation-interne-socle/blob/main/Divers/Mode-op%C3%A9ratoire-mont%C3%A9es-version-librairies.md). Ce document détaille la stratégie choisie en interne de mise à jour des dépendances de la Console CPiN. + +Concernant la mise à jour de `browserlist` et de `caniuse-lite`, utiliser la commande suivante sachant qu'on utilise `pnpm` : + +```shell +$ pnpm update caniuse-lite -r +``` + +### Dépendances Helm + +La console embarque une dépendance avec le Helm Chart utilisé par ArgoCD pour consommer le fichier values qu'elle construit. Afin d'assurer la compatibilité, il faut toujours que la version du Helm chart associé ait été publiée au préalable (voir [la documentation dédiée](https://github.com/cloud-pi-native/helm-charts?tab=readme-ov-file#contribution)). diff --git a/.jjconflict-side-2/RELEASE.md b/.jjconflict-side-2/RELEASE.md new file mode 100644 index 0000000000..300f31fd01 --- /dev/null +++ b/.jjconflict-side-2/RELEASE.md @@ -0,0 +1,110 @@ +# Gestion des versions + +Ce document décrit comment est géré le versionnement de `console`, c'est-à-dire : + +- Préparer la création d'une nouvelle version de `console` au fur et à mesure des ajouts +- Créer effectivement la nouvelle version +- Mettre à jour le chart Helm concerné +- Publier les nouvelles versions de modules NPM concernés + +## Incrémentation des versions + +Afin d'éviter une confusion entre les hotfixes, qui sont des versions `PATCH` qui ensuite rétroportées sur la branche principale (`main`), et les versions régulières qui n'ont que des commits `fix:` (et donc produisent par défaut, elles aussi, des versions `PATCH`), nous avons décidé d'adopter le protocole de versionnement suivant : + +- Les versions régulières sur `main` sont par défaut des `MINOR` (et très rarement des `MAJOR`) +- Les versions de hotfix d'une version en particulier sont **forcément** des `PATCH` (ceci est forcé dans le [flux de travail GitHub `Create new release`](https://github.com/cloud-pi-native/console/tree/main/.github/workflows/job-release-please.yml#L39)) + +La structure de versionnement de `console` est donc **`MAJOR`.`MINOR`.`HOTFIX`** + +## Schéma récapitulatif + +Les sections suivantes vont expliciter ce schéma: + +```mermaid +gitGraph + commit id: "…previous commits" + commit id: "Add basic features" + + branch release-please-main + checkout release-please-main + commit id:"bump to v1.2.0" tag: "v1.2.0" + + checkout main + merge release-please-main + checkout release-please-main + branch "hotfix/urgent-fix-for-v1.2.0" + commit id: "Fix stuff" + branch release-please-hotfix_v1.2.1 + commit id:"bump to v1.2.1" tag: "v1.2.1" + checkout "hotfix/urgent-fix-for-v1.2.0" + merge release-please-hotfix_v1.2.1 + + checkout main + commit id: "Add features" + checkout release-please-main + commit id:"bump to v1.3.0" tag: "v1.3.0" + merge main + commit id:"bump to v1.3.0" tag: "v1.3.0" + checkout main + commit id: "More features" + checkout release-please-main + merge main + commit id:"bump to v1.3.0 (recreated)" tag: "v1.3.0 (recreated)" + checkout main + merge release-please-main + + checkout main + merge "hotfix/urgent-fix-for-v1.2.0" + commit id:"backport v1.2.1 fix onto main" + checkout release-please-main + commit id:"bump to v1.4.0" tag: "v1.4.0" + merge main + commit id:"bump to v1.4.0" tag: "v1.4.0" + checkout main + merge release-please-main +``` + +## Versionnement de Console + +Le flux de travail qui créé les nouvelles versions s'intitule [`create-or-update-release`](https://github.com/cloud-pi-native/console/blob/main/.github/workflows/workflow-create-or-update-release.yml) et est déclenché à chaque nouveau commit sur `main` (soit lorsqu'on fusionne une requête de fusion, soit un commit poussé en outrepassant l'interdiction de pousser sur `main`) ou sur une branche `hotfix/*`. + +Le flux de travail utilise [release-please-action](https://github.com/googleapis/release-please-action) pour automatiquement générer les tags Git ainsi que les nouvelles versions sur GitHub. À chaque fois que du code est poussé dans la branche `main` ou un branche `hotfix/*`, une requête de fusion de version est créée en analysant les messages de commits pour déterminer le numéro de version à créer (`PATCH`, `MINOR`, ou `MAJOR`). Si une requête de fusion de nouvelle version existe déjà, elle est mise à jour afin de refléter les nouveaux changements ajoutés à la future nouvelle version. + +Les différent types de commits (`chore:`, `feat:`, `fix:` etc.) vont alimenter différentes sections de la `CHANGELOG`. Ces sections sont décrites dans la configuration de release-please, [`./release-please-config.json`](./release-please-config.json) + +Lorsqu'une requête de fusion de version (sur `main` ou `hotfix/*`) est fusionnée, les images de conteneur des applications (`client`, `server`, etc.) sont alors créées et hébergées dans la [registry Github associée au dépôt](https://github.com/orgs/cloud-pi-native/packages?repo_name=console) avec les tags appropriés (qui reflètent les tags git concernés). + +> Seuls les tags "complets" (`vX.Y.Z`) sont immutables, les tags "partiels" (`vX` et `vX.Y`) sont recréés pour relier la dernière version concernée. C'est pour ça que lorsque vous tirez les changements de `main` il est recommandé de faire un `git pull --tags --force` afin de forcer la recréation de vos tags locaux pour ces tags partiels. + +## Versionnement du chart Helm `dso-console` + +Le déploiement de `console` se fait préférablement à l'aide de son chart Helm, nommé [`dso-console`](https://github.com/cloud-pi-native/helm-charts/tree/main/charts/dso-console). + +La dernière étape du flux de travail de création de nouvelles versions (cf. section ci-dessus) est la création automatique d'une requête de fusion dans le dépôt `helm-charts` pour la mise à jour du chart Helm `dso-console`. Une fusion manuelle sur ce dépôt est alors nécessaire pour déclencher la publication de la nouvelle version du chart Helm (embarquant donc la nouvelle version de `console`). Exemple d'une telle requête de fusion de nouvelle version du chart Helm : https://github.com/cloud-pi-native/helm-charts/pull/204. + +> Les versions "régulières" (`MAJOR` ou `MINOR` depuis `main`) et les versions "hotfixes" (`PATCH` depuis `hotfix/*`) produisent le même type de requête de fusion côté `helm-charts`, car du point de vue de ce dépôt toute mise à jour de l'application est un `PATCH` bump côté chart. + +## Versionnement des modules NPM + +La publication des nouvelles versions de modules npm du dépôt est automatique et est inclus dans le flux de travail de création d'une nouvelle version. Il analyse les numéros de version présents dans les différents fichiers `package.json` pour déterminer si une nouvelle version du module doit être créée et publiée. + +> Il est possible de créer une version de pré-release d'un module npm en modifiant la clé `publishConfig.tag` dans le `package.json` avec par exemple `beta` pour générer une version beta. + +## Hotfixes + +Autant que faire se peut il vaut mieux privilégier le "Fix Forward" avec de nouvelles versions, afin d'éviter la charger de générer/rétroporter un hotfix. + +Ceci étant dit, il arrivera, hélas, qu'un hotfix soit nécessaire sur une version livrée. + +Voici donc le processus compatible avec l'utilisation de `release-please`: + +- Se placer localement sur le tag de la version concernée: `$ git checkout v1.2.0` (`v1.2.0` est ici la version à hotfixer) +- En tirer une branche dédiée au hotfix: `$ git checkout -b hotfix/my-urgent-hotfix-for-v1.2.0` (Note: Il n'est pas nécessaire de spécifier la version dans le nom de la branche, mais ça peut aider à la lecture et ainsi confirmer la version concernée) +- Faire les modifications nécessaires, committer, etc. +- Pousser la nouvelle branche sur le dépôt Github +- ⚠ Si vous voulez faire une "preview" de cette branche il faudra très probablement créer une **autre branche** qui cible `main` et y résoudre les conflits avant de faire une `preview`. En effet il est fortement probable que si vous faites un hotfix, la branche `main` aura déjà une nouvelle version (sinon vous ne feriez pas un hotfix, vous feriez simplement une nouvelle version 😁). Or il faut savoir que [Github ne permet pas l'exécution des workflows en cas de conflits](https://github.com/orgs/community/discussions/26304). Donc si vous voulez une `preview` du hotfix il faudra une MR dédiée, et dont vous aurez résolu les hotfixes (ça devrait être normalement limité aux fichiers contenant la version comme `CHANGELOG.md`). C'est pénible, mais c'est comme ça. +- Une fois la nouvelle branche poussée, `release-please` va être déclenché par le flux de travail Github `create-or-update-release` afin de créer une requête de fusion pour la nouvelle version hotfixée (avec comme cible la branche de hotfix). Il est d'ailleurs à noter que dans le cas d'un hotfix **on ne fait qu'une montée du "PATCH"** (ici on obtiendra donc la version `v1.2.1`, qui est alors le premier hotfix de la version `v1.2.0`) quelque soit les commits (donc même un `feat!` ne fera pas de montée majeure) +- Valider la MR de version hotfixée (créée donc par `release-please`) à l'aide du flux de travail Github `Continuous Integration` +- Une fois la MR de version hotfixée validée et fusionnée, la nouvelle version est créée et, comme pour les versions traditionnelles, une requête de fusion est crée dans le dépôt `helm-charts` pour avoir là aussi une version hotfixée (mais, pour le chart Helm, c'est considéré comme une version classique) +- Il faudra ensuite faire des picorages (`git cherry-pick`) ou une MR de la branche de hotfix vers `main` afin d'intégrer le ou les commits de hotfix dans la prochaine version officielle +- ⚠ Il faut retagger le commit de hotfix quand on a backporté le fix + le commit de bump de la release hotfix sur `main`, car sinon la Release côté GitHub pointe vers un tag dont le commit n'est pas sur `main` (le SHA a logiquement changé lorsqu'il a été backporté). Refaire donc localement un `git tag vX.Y.Z commit-de-release-backporté-sur-main` et pusher avec `git push --tags --force` pour forcer la mise à jour du tag. SI VOUS NE FAITES PAS ÇA LES CHANGELOG VONT TOTALEMENT PARTIR EN VRILLE ⚠ diff --git a/.jjconflict-side-2/apps/client/.env-example b/.jjconflict-side-2/apps/client/.env-example new file mode 100644 index 0000000000..2f23f06f97 --- /dev/null +++ b/.jjconflict-side-2/apps/client/.env-example @@ -0,0 +1,24 @@ +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Hôte du serveur backend vers lequel le client proxifie les appels API +SERVER_HOST=localhost +# Port d'écoute du serveur backend +# En développement local avec le nginx-strangler (pnpm dev +# + docker-compose.local.yml), il vaut mieux mettre SERVER_PORT=4000 pour que +# le proxy Vite passe par nginx-strangler plutôt que directement par server (4001) +# ou server-nestjs (3001). +SERVER_PORT=4000 +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté frontend +KEYCLOAK_CLIENT_ID=dso-console-frontend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# Active/désactive le module OpenCDS (true | false) +OPENCDS_ENABLED=false +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-side-2/apps/client/.env.docker-example b/.jjconflict-side-2/apps/client/.env.docker-example new file mode 100644 index 0000000000..1f05a462c6 --- /dev/null +++ b/.jjconflict-side-2/apps/client/.env.docker-example @@ -0,0 +1,21 @@ +# Spécifique à ce ficher. Permet de dire que la stack est conteneurisée +DOCKER=true +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Hôte du nginx vers lequel le client proxifie les appels API +SERVER_HOST=nginx-strangler +SERVER_PORT=8080 +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté frontend +KEYCLOAK_CLIENT_ID=dso-console-frontend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# Active/désactive le module OpenCDS (true | false) +OPENCDS_ENABLED=false +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-side-2/apps/client/.env.integ-example b/.jjconflict-side-2/apps/client/.env.integ-example new file mode 100644 index 0000000000..4f870dfd2f --- /dev/null +++ b/.jjconflict-side-2/apps/client/.env.integ-example @@ -0,0 +1,11 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Domaine du Keycloak de l'environnement d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak de l'environnement d'intégration +KEYCLOAK_REALM= +# En intégration, Keycloak est généralement exposé en HTTPS +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak frontend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= diff --git a/.jjconflict-side-2/apps/client/.eslintrc-auto-import.json b/.jjconflict-side-2/apps/client/.eslintrc-auto-import.json new file mode 100644 index 0000000000..253b4b4683 --- /dev/null +++ b/.jjconflict-side-2/apps/client/.eslintrc-auto-import.json @@ -0,0 +1,93 @@ +{ + "globals": { + "Component": true, + "ComponentPublicInstance": true, + "ComputedRef": true, + "DirectiveBinding": true, + "EffectScope": true, + "ExtractDefaultPropTypes": true, + "ExtractPropTypes": true, + "ExtractPublicPropTypes": true, + "InjectionKey": true, + "MaybeRef": true, + "MaybeRefOrGetter": true, + "PropType": true, + "Ref": true, + "VNode": true, + "WritableComputedRef": true, + "acceptHMRUpdate": true, + "computed": true, + "createApp": true, + "createPinia": true, + "customRef": true, + "defineAsyncComponent": true, + "defineComponent": true, + "defineStore": true, + "effectScope": true, + "getActivePinia": true, + "getCurrentInstance": true, + "getCurrentScope": true, + "h": true, + "inject": true, + "isProxy": true, + "isReactive": true, + "isReadonly": true, + "isRef": true, + "mapActions": true, + "mapGetters": true, + "mapState": true, + "mapStores": true, + "mapWritableState": true, + "markRaw": true, + "nextTick": true, + "onActivated": true, + "onBeforeMount": true, + "onBeforeRouteLeave": true, + "onBeforeRouteUpdate": true, + "onBeforeUnmount": true, + "onBeforeUpdate": true, + "onDeactivated": true, + "onErrorCaptured": true, + "onMounted": true, + "onRenderTracked": true, + "onRenderTriggered": true, + "onScopeDispose": true, + "onServerPrefetch": true, + "onUnmounted": true, + "onUpdated": true, + "onWatcherCleanup": true, + "provide": true, + "reactive": true, + "readonly": true, + "ref": true, + "resolveComponent": true, + "setActivePinia": true, + "setMapStoreSuffix": true, + "shallowReactive": true, + "shallowReadonly": true, + "shallowRef": true, + "storeToRefs": true, + "toRaw": true, + "toRef": true, + "toRefs": true, + "toValue": true, + "triggerRef": true, + "unref": true, + "useAttrs": true, + "useCssModule": true, + "useCssVars": true, + "useId": true, + "useLink": true, + "useModel": true, + "useRoute": true, + "useRouter": true, + "useScheme": true, + "useSlots": true, + "useTabs": true, + "useTemplateRef": true, + "watch": true, + "watchEffect": true, + "watchPostEffect": true, + "watchSyncEffect": true + } +} diff --git a/.jjconflict-side-2/apps/client/.stylelintrc.js b/.jjconflict-side-2/apps/client/.stylelintrc.js new file mode 100644 index 0000000000..86614a8803 --- /dev/null +++ b/.jjconflict-side-2/apps/client/.stylelintrc.js @@ -0,0 +1,40 @@ +export default { + root: true, + extends: [ + 'stylelint-config-standard', + 'stylelint-config-html/vue', + 'stylelint-config-html', + 'stylelint-config-recommended-vue', + ], + ignoreDisables: false, + rules: { + 'at-rule-no-deprecated': [ + true, + { + ignoreAtRules: [ + 'windi', + 'apply', + 'include', + 'variants', + 'responsive', + 'screen', + ], + }, + ], + 'at-rule-no-unknown': [ + true, + { + ignoreAtRules: [ + 'windi', + 'apply', + 'include', + 'variants', + 'responsive', + 'screen', + ], + }, + ], + 'no-descending-specificity': null, + 'selector-class-pattern': '^((sm|md|lg|xl|2xl):)?[a-z][-_/a-z0-9]*$', + }, +} diff --git a/.jjconflict-side-2/apps/client/Dockerfile b/.jjconflict-side-2/apps/client/Dockerfile new file mode 100644 index 0000000000..b6784f3a82 --- /dev/null +++ b/.jjconflict-side-2/apps/client/Dockerfile @@ -0,0 +1,48 @@ +# Dev stage ------------------------------------------------------------------------ +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Install pnpm version defined in package.json "packageManager" property +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY --chown=node:root apps/client/package.json ./apps/client/package.json +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +RUN pnpm install --ignore-scripts # --no-optional + +COPY --chown=node:root packages/ ./packages/ +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +COPY --chown=node:root apps/client/ ./apps/client/ +RUN pnpm --filter client run icons + +ENTRYPOINT [ "pnpm", "--filter", "client", "run", "dev" ] + + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +ARG APP_VERSION +RUN pnpm --filter @cpn-console/client run build + + +# Prod stage ----------------------------------------------------------------------- +FROM nginxinc/nginx-unprivileged:1.31-alpine AS prod + +COPY --chown=nginx:root --from=build /app/apps/client/dist /etc/nginx/html/ +COPY --chown=nginx:root --chmod=660 ./apps/client/nginx/default.conf /etc/nginx/conf.d/default.conf +COPY --chown=nginx:root --chmod=750 ./apps/client/nginx/entrypoint.sh /docker-entrypoint.d/99-envsubst-js-assets.sh + +RUN chmod -R g=u /etc/nginx/html + +EXPOSE 8080 diff --git a/.jjconflict-side-2/apps/client/README.md b/.jjconflict-side-2/apps/client/README.md new file mode 100644 index 0000000000..00810a3778 --- /dev/null +++ b/.jjconflict-side-2/apps/client/README.md @@ -0,0 +1,56 @@ +# Console Cloud π Native - Client + +## Installation + +```sh +npm install +``` + +## Lancement de l'app + +```sh +npm run dev +``` + +## Lancement des tests unitaires + +```sh +npm run test +``` + +## Formattage du code + +```sh +# Lister les problèmes de formatage +npm run lint + +# Régler automatiquement les problèmes de formatage +npm run format +``` + +## Build de l'app + +```sh +npm run build +``` + +## Prévisualisation du code de production + +```sh +npm run preview +``` + +## Récupérer toutes les icones du projets +`grep -r -oh "ri:[a-z0-9-]*" . | sort | uniq | cut -d ':' -f 2 | awk NF | awk '{print " \047" $1 "\047,"}'` + +## Activation OpenCDS + +Se référer à [la documentation dédiée](../../packages/opencds/README.adoc) + +## Crédits + +- [vue](https://github.com/vuejs/) +- [vue-dsfr](https://github.com/dnum-mi/vue-dsfr) +- [vite](https://github.com/vitejs/vite) +- [stylelint](https://github.com/stylelint/stylelint) +- [eslint](https://github.com/eslint/eslint) diff --git a/.jjconflict-side-2/apps/client/eslint.config.js b/.jjconflict-side-2/apps/client/eslint.config.js new file mode 100644 index 0000000000..88c06179d3 --- /dev/null +++ b/.jjconflict-side-2/apps/client/eslint.config.js @@ -0,0 +1,6 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase.overrideRules({ + 'e18e/prefer-static-regex': 'off', + 'vue/no-required-prop-with-default': 'off', +}) diff --git a/.jjconflict-side-2/apps/client/index.html b/.jjconflict-side-2/apps/client/index.html new file mode 100644 index 0000000000..b57fbfc4ab --- /dev/null +++ b/.jjconflict-side-2/apps/client/index.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html lang="fr"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" href="/favicon.ico" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Console Cloud π Native + + +
+ + + diff --git a/.jjconflict-side-2/apps/client/jsconfig.json b/.jjconflict-side-2/apps/client/jsconfig.json new file mode 100644 index 0000000000..a5cee14f16 --- /dev/null +++ b/.jjconflict-side-2/apps/client/jsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "jsx": "preserve", + "baseUrl": ".", + "module": "ESNext", + "moduleResolution": "node", + "paths": { + "@/*": [ + "./src/*" + ] + }, + "sourceMap": true + }, + "exclude": [ + "node_modules" + ] +} diff --git a/.jjconflict-side-2/apps/client/nginx/README.md b/.jjconflict-side-2/apps/client/nginx/README.md new file mode 100644 index 0000000000..b2e55f8ea1 --- /dev/null +++ b/.jjconflict-side-2/apps/client/nginx/README.md @@ -0,0 +1,5 @@ +# À propos + +Ce nginx est celui utilisé à fin d'exposer les fichiers de l'application "SPA" (Single Page Application) de la Console. + +**À ne pas confondre avec [`nginx-strangler`](../../nginx-strangler) qui est le composant permettant le transfert progressif des routes de `server` vers `server-nestjs`** diff --git a/.jjconflict-side-2/apps/client/nginx/default.conf b/.jjconflict-side-2/apps/client/nginx/default.conf new file mode 100644 index 0000000000..60cfae693b --- /dev/null +++ b/.jjconflict-side-2/apps/client/nginx/default.conf @@ -0,0 +1,16 @@ +server { + listen 8080; + server_name localhost; + root /etc/nginx/html; + index index.html; + + large_client_header_buffers 4 32k; + + location / { + try_files $uri $uri/ @rewrites; + } + + location @rewrites { + rewrite "^(.+)$" /index.html last; + } +} diff --git a/.jjconflict-side-2/apps/client/nginx/default.docker.conf b/.jjconflict-side-2/apps/client/nginx/default.docker.conf new file mode 100644 index 0000000000..74bca2057d --- /dev/null +++ b/.jjconflict-side-2/apps/client/nginx/default.docker.conf @@ -0,0 +1,35 @@ +upstream api { + # Toutes les requêtes API transitent par nginx-strangler qui orchestre + # la bascule progressive vers server-nestjs (Strangler Fig Pattern, ticket #1885) + server nginx-strangler:8080; +} + +server { + listen 8080; + server_name localhost; + root /etc/nginx/html; + index index.html; + + large_client_header_buffers 4 32k; + + location / { + try_files $uri $uri/ @rewrites; + } + + location @rewrites { + rewrite "^(.+)$" /index.html last; + } + + location ~ ^/(api|swagger-ui) { + rewrite "^(.*)$" $1 break; + add_header Access-Control-Allow-Origin '$http_origin'; + proxy_pass http://api; + proxy_redirect off; + proxy_buffering off; + proxy_http_version 1.1; + + # For WebSockets + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/.jjconflict-side-2/apps/client/nginx/entrypoint.sh b/.jjconflict-side-2/apps/client/nginx/entrypoint.sh new file mode 100755 index 0000000000..98dfe3bcd9 --- /dev/null +++ b/.jjconflict-side-2/apps/client/nginx/entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +ASSETS_DIR=/etc/nginx/html/assets + +make_envsubst_vars() { + fmt= + for name do + fmt="${fmt}${fmt:+ }\${$name}" + done + + printf '%s\n' "$fmt" +} + +ENV_VARS=$(make_envsubst_vars \ + SERVER_HOST \ + SERVER_PORT \ + OPENCDS_ENABLED \ + KEYCLOAK_PROTOCOL \ + KEYCLOAK_DOMAIN \ + KEYCLOAK_REALM \ + KEYCLOAK_CLIENT_ID \ + KEYCLOAK_REDIRECT_URI \ + CONTACT_EMAIL \ +) + +echo "Replacing env variables in JavaScript asset files..." +for file in $ASSETS_DIR/*.js; do + echo "Processing $file ..."; + envsubst "${ENV_VARS}" \ + < $file \ + > $file-out && \ + mv $file-out $file +done +echo "Done !" diff --git a/.jjconflict-side-2/apps/client/package.json b/.jjconflict-side-2/apps/client/package.json new file mode 100644 index 0000000000..a897dccf6f --- /dev/null +++ b/.jjconflict-side-2/apps/client/package.json @@ -0,0 +1,88 @@ +{ + "name": "@cpn-console/client", + "type": "module", + "version": "9.22.1", + "private": false, + "description": "cpn-console client", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "scripts": { + "build": "pnpm run icons && pnpm run type-check && pnpm run build-only", + "build-only": "vite --mode=production build", + "build:clean": "rimraf ./dist ./tsconfig.tsbuildinfo", + "dev": "vite --mode=development", + "format": "NODE_OPTIONS='--no-warnings=ExperimentalWarning' eslint ./ --fix", + "format:style": "stylelint ./src/**/*.{css,vue} --fix", + "icons": "vue-dsfr-icons -s scripts/icons.js -t src/icon-collections.ts", + "integ": "vite --mode=integration", + "lint": "pnpm run --parallel \"/^lint:.*/\"", + "lint:style": "stylelint ./src/**/*.{css,vue}", + "lint:ts": "NODE_OPTIONS='--no-warnings=ExperimentalWarning' eslint ./", + "preview": "vite preview --port 8080", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "type-check": "vue-tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@gouvfr/dsfr": "catalog:runtime", + "@gouvminint/vue-dsfr": "catalog:runtime", + "@iconify-json/ri": "catalog:runtime", + "@iconify/vue": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@vue/tsconfig": "catalog:build", + "axios": "catalog:runtime", + "javascript-time-ago": "catalog:runtime", + "jszip": "catalog:runtime", + "keycloak-js": "catalog:runtime", + "nanoid": "catalog:runtime", + "p-debounce": "catalog:runtime", + "pinia": "catalog:runtime", + "vue": "catalog:runtime", + "vue-router": "catalog:runtime", + "vue3-json-viewer": "catalog:runtime", + "xbytes": "catalog:runtime", + "yaml": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@iconify/types": "catalog:build", + "@types/jsdom": "catalog:types", + "@types/node": "catalog:types", + "@unocss/transformer-directives": "catalog:build", + "@vitejs/plugin-vue": "catalog:build", + "@vitest/coverage-v8": "catalog:test", + "@vue/eslint-config-typescript": "catalog:tools", + "chalk": "catalog:tools", + "eslint": "catalog:tools", + "eslint-plugin-vue": "catalog:tools", + "jsdom": "catalog:test", + "rimraf": "catalog:tools", + "stylelint": "catalog:tools", + "stylelint-config-html": "catalog:tools", + "stylelint-config-recommended-vue": "catalog:tools", + "stylelint-config-standard": "catalog:tools", + "typescript": "catalog:build", + "unocss": "catalog:build", + "unplugin-auto-import": "catalog:build", + "unplugin-vue-components": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vite-plugin-pwa": "catalog:build", + "vitest": "catalog:test", + "vue-eslint-parser": "catalog:tools", + "vue-tsc": "catalog:build", + "workbox-window": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/apps/client/public/browserconfig.xml b/.jjconflict-side-2/apps/client/public/browserconfig.xml new file mode 100644 index 0000000000..4826e1357a --- /dev/null +++ b/.jjconflict-side-2/apps/client/public/browserconfig.xml @@ -0,0 +1,2 @@ + +#ffffff diff --git a/.jjconflict-side-2/apps/client/public/favicon.ico b/.jjconflict-side-2/apps/client/public/favicon.ico new file mode 100644 index 0000000000..d5b9b0fcb9 Binary files /dev/null and b/.jjconflict-side-2/apps/client/public/favicon.ico differ diff --git a/.jjconflict-side-2/apps/client/public/img/architecture.png b/.jjconflict-side-2/apps/client/public/img/architecture.png new file mode 100644 index 0000000000..46a52e9c98 Binary files /dev/null and b/.jjconflict-side-2/apps/client/public/img/architecture.png differ diff --git a/.jjconflict-side-2/apps/client/public/img/argocd.svg b/.jjconflict-side-2/apps/client/public/img/argocd.svg new file mode 100644 index 0000000000..fb7bc6fe84 --- /dev/null +++ b/.jjconflict-side-2/apps/client/public/img/argocd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.jjconflict-side-2/apps/client/public/img/gitlab.svg b/.jjconflict-side-2/apps/client/public/img/gitlab.svg new file mode 100644 index 0000000000..602ffcc063 --- /dev/null +++ b/.jjconflict-side-2/apps/client/public/img/gitlab.svg @@ -0,0 +1 @@ +GitLab home page \ No newline at end of file diff --git a/.jjconflict-side-2/apps/client/public/img/harbor.svg b/.jjconflict-side-2/apps/client/public/img/harbor.svg new file mode 100644 index 0000000000..c5d042f6b6 --- /dev/null +++ b/.jjconflict-side-2/apps/client/public/img/harbor.svg @@ -0,0 +1,482 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.jjconflict-side-2/apps/client/public/img/logo-marianne.png b/.jjconflict-side-2/apps/client/public/img/logo-marianne.png new file mode 100644 index 0000000000..786e3d61f4 Binary files /dev/null and b/.jjconflict-side-2/apps/client/public/img/logo-marianne.png differ diff --git a/.jjconflict-side-2/apps/client/public/img/nexus.png b/.jjconflict-side-2/apps/client/public/img/nexus.png new file mode 100644 index 0000000000..809e5fbe1b Binary files /dev/null and b/.jjconflict-side-2/apps/client/public/img/nexus.png differ diff --git a/.jjconflict-side-2/apps/client/public/img/sonarqube.svg b/.jjconflict-side-2/apps/client/public/img/sonarqube.svg new file mode 100644 index 0000000000..c6bb30720f --- /dev/null +++ b/.jjconflict-side-2/apps/client/public/img/sonarqube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.jjconflict-side-2/apps/client/public/img/vault.svg b/.jjconflict-side-2/apps/client/public/img/vault.svg new file mode 100644 index 0000000000..d5be8c74d2 --- /dev/null +++ b/.jjconflict-side-2/apps/client/public/img/vault.svg @@ -0,0 +1 @@ +Vault \ No newline at end of file diff --git a/.jjconflict-side-2/apps/client/public/manifest.json b/.jjconflict-side-2/apps/client/public/manifest.json new file mode 100644 index 0000000000..a99d9409c7 --- /dev/null +++ b/.jjconflict-side-2/apps/client/public/manifest.json @@ -0,0 +1,41 @@ +{ + "name": "VueDsfr App", + "icons": [ + { + "src": "/icons/android-icon-36x36.png", + "sizes": "36x36", + "type": "image/png", + "density": "0.75" + }, + { + "src": "/icons/android-icon-48x48.png", + "sizes": "48x48", + "type": "image/png", + "density": "1.0" + }, + { + "src": "/icons/android-icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "density": "1.5" + }, + { + "src": "/icons/android-icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "density": "2.0" + }, + { + "src": "/icons/android-icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "density": "3.0" + }, + { + "src": "/icons/android-icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "density": "4.0" + } + ] +} diff --git a/.jjconflict-side-2/apps/client/scripts/create-collections.js b/.jjconflict-side-2/apps/client/scripts/create-collections.js new file mode 100644 index 0000000000..da1b4a6ef3 --- /dev/null +++ b/.jjconflict-side-2/apps/client/scripts/create-collections.js @@ -0,0 +1,18 @@ +#!env node +import path from 'node:path' + +import { createCustomCollectionFile } from '@gouvminint/vue-dsfr/meta' +import { Command } from 'commander' + +const program = new Command() + +program + .option('-s, --source ', 'Chemin vers le fichier de tuples [IconifyJSON, string[]]') + .option('-t, --target ', 'Chemin vers le fichier destination (src/icons.ts par défaut)') + .parse(process.argv) + +const options = program.opts() + +if (options.source && options.target) { + createCustomCollectionFile(path.resolve(process.cwd(), options.source), path.resolve(process.cwd(), options.target)) +} diff --git a/.jjconflict-side-2/apps/client/scripts/icons.js b/.jjconflict-side-2/apps/client/scripts/icons.js new file mode 100644 index 0000000000..c275232c2a --- /dev/null +++ b/.jjconflict-side-2/apps/client/scripts/icons.js @@ -0,0 +1,75 @@ +// @ts-check +import { icons as riCollection } from '@iconify-json/ri' + +/** + * @type {string[]} + */ +const riIconNames = [ + 'account-circle-line', + 'add-line', + 'admin-line', + 'arrow-drop-left-line', + 'arrow-drop-right-line', + 'arrow-go-back-line', + 'arrow-left-double-line', + 'arrow-right-double-line', + 'arrow-right-line', + 'arrow-right-s-line', + 'award-line', + 'building-line', + 'checkbox-blank-circle-fill', + 'checkbox-circle-line', + 'clipboard-line', + 'close-line', + 'code-s-slash-line', + 'dashboard-line', + 'delete-bin-7-line', + 'exchange-line', + 'eye-line', + 'eye-off-line', + 'file-download-line', + 'file-info-line', + 'filter-line', + 'filter-off-line', + 'flow-chart', + 'focus-3-line', + 'folder-line', + 'folder-shield-2-line', + 'folders-line', + 'folder-user-line', + 'github-line', + 'git-merge-line', + 'git-repository-private-line', + 'global-line', + 'key-2-line', + 'loader-4-line', + 'lock-line', + 'lock-unlock-line', + 'mail-line', + 'moon-clear-line', + 'newspaper-line', + 'pencil-line', + 'question-line', + 'refresh-line', + 'restart-line', + 'send-plane-line', + 'server-line', + 'settings-3-line', + 'shapes-line', + 'shield-check-line', + 'sound-module-line', + 'stock-line', + 'sun-line', + 'team-line', + 'tools-line', + 'upload-cloud-line', + 'user-add-line', + 'user-shared-2-line', +] + +/** + * @type {[import('@iconify/vue').IconifyJSON, string[]][]} + */ +export const collectionsToFilter = [ + [riCollection, riIconNames], +] diff --git a/.jjconflict-side-2/apps/client/src/App.vue b/.jjconflict-side-2/apps/client/src/App.vue new file mode 100644 index 0000000000..91e0e48742 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/App.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/api/xhr-client.ts b/.jjconflict-side-2/apps/client/src/api/xhr-client.ts new file mode 100644 index 0000000000..427c6b36ba --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/api/xhr-client.ts @@ -0,0 +1,57 @@ +import type { ApiFetcherArgs } from '@ts-rest/core' +import { logger } from '@cpn-console/logger/browser' +import { apiPrefix, getApiClient } from '@cpn-console/shared' +import { tsRestFetchApi } from '@ts-rest/core' +import { getKeycloak } from '@/utils/keycloak/keycloak.js' + +export const apiClient = await getApiClient( + '', + {}, + async (args: ApiFetcherArgs): Promise<{ status: number, body: { status: number, error: string } | unknown, headers: Headers }> => { + // Paths that do not require token + const validPaths = [`${apiPrefix}/version`, '/login', `${apiPrefix}/services`] + if (validPaths.some(validPath => args.path?.startsWith(validPath))) { + return tsRestFetchApi(args) + } + + // Envs that do not require token + if (process.env.NODE_ENV === 'test' && process.env.CT === 'true') { + return tsRestFetchApi(args) + } + + // Generate token + const keycloak = getKeycloak() + const token = keycloak.token + if (token) { + args.headers.Authorization = `Bearer ${token}` + } + + return tsRestFetchApi(args) + }, +) + +export function extractData(response: T, expectedStatus: S): Extract['body'] { + if (response.status === 401) { + const keycloak = getKeycloak() + if (!keycloak.authenticated) { + keycloak.login() + } + } + if (response.status >= 400) { + // `message` d'abord : les erreurs gérées du legacy (ErrorResType) et celles de NestJS + // (HttpException) portent le vrai message dans `message` — chez NestJS, `error` ne contient + // que la raison HTTP générique ("Forbidden", "Bad Request"). `error` reste en repli pour + // les erreurs non gérées du legacy (setErrorHandler), qui n'ont pas de champ `message`. + // @ts-ignore + throw new Error(response.body?.message ?? response.body?.error ?? 'Erreur inconnue') + } + if (response.status === expectedStatus) return response.body + try { + throw new Error(`Erreur lors de la requete, reçu ${response.status}, attendu ${expectedStatus}`) + } catch (error: unknown) { + if (error instanceof Error) { + logger.error({ err: error }, 'Request failed') + } + throw error + } +} diff --git a/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Bold.otf b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Bold.otf new file mode 100755 index 0000000000..4a051bcd7a Binary files /dev/null and b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Bold.otf differ diff --git a/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-BoldItalic.otf b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-BoldItalic.otf new file mode 100755 index 0000000000..d24f18d012 Binary files /dev/null and b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-BoldItalic.otf differ diff --git a/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-ExtraBold.otf b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-ExtraBold.otf new file mode 100755 index 0000000000..77b5d8ad1d Binary files /dev/null and b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-ExtraBold.otf differ diff --git a/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf new file mode 100755 index 0000000000..b5a48d1946 Binary files /dev/null and b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-ExtraBoldItalic.otf differ diff --git a/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Light.otf b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Light.otf new file mode 100755 index 0000000000..5a7b8aad90 Binary files /dev/null and b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Light.otf differ diff --git a/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-LightItalic.otf b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-LightItalic.otf new file mode 100755 index 0000000000..15747f0a79 Binary files /dev/null and b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-LightItalic.otf differ diff --git a/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Medium.otf b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Medium.otf new file mode 100755 index 0000000000..cd15454d1b Binary files /dev/null and b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Medium.otf differ diff --git a/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-MediumItalic.otf b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-MediumItalic.otf new file mode 100755 index 0000000000..d7069ddb95 Binary files /dev/null and b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-MediumItalic.otf differ diff --git a/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Regular.otf b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Regular.otf new file mode 100755 index 0000000000..b8aa87d247 Binary files /dev/null and b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Regular.otf differ diff --git a/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-RegularItalic.otf b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-RegularItalic.otf new file mode 100755 index 0000000000..e0b98c8956 Binary files /dev/null and b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-RegularItalic.otf differ diff --git a/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Thin.otf b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Thin.otf new file mode 100755 index 0000000000..055d889cab Binary files /dev/null and b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-Thin.otf differ diff --git a/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-ThinItalic.otf b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-ThinItalic.otf new file mode 100755 index 0000000000..23fe8e8a97 Binary files /dev/null and b/.jjconflict-side-2/apps/client/src/assets/fonts/Marianne-ThinItalic.otf differ diff --git a/.jjconflict-side-2/apps/client/src/auto-imports.d.ts b/.jjconflict-side-2/apps/client/src/auto-imports.d.ts new file mode 100644 index 0000000000..4372345fd5 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/auto-imports.d.ts @@ -0,0 +1,173 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + const EffectScope: typeof import('vue')['EffectScope'] + const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] + const computed: typeof import('vue')['computed'] + const createApp: typeof import('vue')['createApp'] + const createPinia: typeof import('pinia')['createPinia'] + const customRef: typeof import('vue')['customRef'] + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] + const defineComponent: typeof import('vue')['defineComponent'] + const defineStore: typeof import('pinia')['defineStore'] + const effectScope: typeof import('vue')['effectScope'] + const getActivePinia: typeof import('pinia')['getActivePinia'] + const getCurrentInstance: typeof import('vue')['getCurrentInstance'] + const getCurrentScope: typeof import('vue')['getCurrentScope'] + const h: typeof import('vue')['h'] + const inject: typeof import('vue')['inject'] + const isProxy: typeof import('vue')['isProxy'] + const isReactive: typeof import('vue')['isReactive'] + const isReadonly: typeof import('vue')['isReadonly'] + const isRef: typeof import('vue')['isRef'] + const mapActions: typeof import('pinia')['mapActions'] + const mapGetters: typeof import('pinia')['mapGetters'] + const mapState: typeof import('pinia')['mapState'] + const mapStores: typeof import('pinia')['mapStores'] + const mapWritableState: typeof import('pinia')['mapWritableState'] + const markRaw: typeof import('vue')['markRaw'] + const nextTick: typeof import('vue')['nextTick'] + const onActivated: typeof import('vue')['onActivated'] + const onBeforeMount: typeof import('vue')['onBeforeMount'] + const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave'] + const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate'] + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] + const onDeactivated: typeof import('vue')['onDeactivated'] + const onErrorCaptured: typeof import('vue')['onErrorCaptured'] + const onMounted: typeof import('vue')['onMounted'] + const onRenderTracked: typeof import('vue')['onRenderTracked'] + const onRenderTriggered: typeof import('vue')['onRenderTriggered'] + const onScopeDispose: typeof import('vue')['onScopeDispose'] + const onServerPrefetch: typeof import('vue')['onServerPrefetch'] + const onUnmounted: typeof import('vue')['onUnmounted'] + const onUpdated: typeof import('vue')['onUpdated'] + const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] + const provide: typeof import('vue')['provide'] + const reactive: typeof import('vue')['reactive'] + const readonly: typeof import('vue')['readonly'] + const ref: typeof import('vue')['ref'] + const resolveComponent: typeof import('vue')['resolveComponent'] + const setActivePinia: typeof import('pinia')['setActivePinia'] + const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] + const shallowReactive: typeof import('vue')['shallowReactive'] + const shallowReadonly: typeof import('vue')['shallowReadonly'] + const shallowRef: typeof import('vue')['shallowRef'] + const storeToRefs: typeof import('pinia')['storeToRefs'] + const toRaw: typeof import('vue')['toRaw'] + const toRef: typeof import('vue')['toRef'] + const toRefs: typeof import('vue')['toRefs'] + const toValue: typeof import('vue')['toValue'] + const triggerRef: typeof import('vue')['triggerRef'] + const unref: typeof import('vue')['unref'] + const useAttrs: typeof import('vue')['useAttrs'] + const useCssModule: typeof import('vue')['useCssModule'] + const useCssVars: typeof import('vue')['useCssVars'] + const useId: typeof import('vue')['useId'] + const useLink: typeof import('vue-router')['useLink'] + const useModel: typeof import('vue')['useModel'] + const useRoute: typeof import('vue-router')['useRoute'] + const useRouter: typeof import('vue-router')['useRouter'] + const useScheme: typeof import('@gouvminint/vue-dsfr')['useScheme'] + const useSlots: typeof import('vue')['useSlots'] + const useTabs: typeof import('@gouvminint/vue-dsfr')['useTabs'] + const useTemplateRef: typeof import('vue')['useTemplateRef'] + const watch: typeof import('vue')['watch'] + const watchEffect: typeof import('vue')['watchEffect'] + const watchPostEffect: typeof import('vue')['watchPostEffect'] + const watchSyncEffect: typeof import('vue')['watchSyncEffect'] +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') +} + +// for vue template auto import +import { UnwrapRef } from 'vue' +declare module 'vue' { + interface GlobalComponents {} + interface ComponentCustomProperties { + readonly EffectScope: UnwrapRef + readonly acceptHMRUpdate: UnwrapRef + readonly computed: UnwrapRef + readonly createApp: UnwrapRef + readonly createPinia: UnwrapRef + readonly customRef: UnwrapRef + readonly defineAsyncComponent: UnwrapRef + readonly defineComponent: UnwrapRef + readonly defineStore: UnwrapRef + readonly effectScope: UnwrapRef + readonly getActivePinia: UnwrapRef + readonly getCurrentInstance: UnwrapRef + readonly getCurrentScope: UnwrapRef + readonly h: UnwrapRef + readonly inject: UnwrapRef + readonly isProxy: UnwrapRef + readonly isReactive: UnwrapRef + readonly isReadonly: UnwrapRef + readonly isRef: UnwrapRef + readonly mapActions: UnwrapRef + readonly mapGetters: UnwrapRef + readonly mapState: UnwrapRef + readonly mapStores: UnwrapRef + readonly mapWritableState: UnwrapRef + readonly markRaw: UnwrapRef + readonly nextTick: UnwrapRef + readonly onActivated: UnwrapRef + readonly onBeforeMount: UnwrapRef + readonly onBeforeRouteLeave: UnwrapRef + readonly onBeforeRouteUpdate: UnwrapRef + readonly onBeforeUnmount: UnwrapRef + readonly onBeforeUpdate: UnwrapRef + readonly onDeactivated: UnwrapRef + readonly onErrorCaptured: UnwrapRef + readonly onMounted: UnwrapRef + readonly onRenderTracked: UnwrapRef + readonly onRenderTriggered: UnwrapRef + readonly onScopeDispose: UnwrapRef + readonly onServerPrefetch: UnwrapRef + readonly onUnmounted: UnwrapRef + readonly onUpdated: UnwrapRef + readonly onWatcherCleanup: UnwrapRef + readonly provide: UnwrapRef + readonly reactive: UnwrapRef + readonly readonly: UnwrapRef + readonly ref: UnwrapRef + readonly resolveComponent: UnwrapRef + readonly setActivePinia: UnwrapRef + readonly setMapStoreSuffix: UnwrapRef + readonly shallowReactive: UnwrapRef + readonly shallowReadonly: UnwrapRef + readonly shallowRef: UnwrapRef + readonly storeToRefs: UnwrapRef + readonly toRaw: UnwrapRef + readonly toRef: UnwrapRef + readonly toRefs: UnwrapRef + readonly toValue: UnwrapRef + readonly triggerRef: UnwrapRef + readonly unref: UnwrapRef + readonly useAttrs: UnwrapRef + readonly useCssModule: UnwrapRef + readonly useCssVars: UnwrapRef + readonly useId: UnwrapRef + readonly useLink: UnwrapRef + readonly useModel: UnwrapRef + readonly useRoute: UnwrapRef + readonly useRouter: UnwrapRef + readonly useScheme: UnwrapRef + readonly useSlots: UnwrapRef + readonly useTabs: UnwrapRef + readonly useTemplateRef: UnwrapRef + readonly watch: UnwrapRef + readonly watchEffect: UnwrapRef + readonly watchPostEffect: UnwrapRef + readonly watchSyncEffect: UnwrapRef + } +} \ No newline at end of file diff --git a/.jjconflict-side-2/apps/client/src/components/AdminRoleForm.vue b/.jjconflict-side-2/apps/client/src/components/AdminRoleForm.vue new file mode 100644 index 0000000000..bf129c8ad7 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/AdminRoleForm.vue @@ -0,0 +1,304 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/Badge.vue b/.jjconflict-side-2/apps/client/src/components/Badge.vue new file mode 100644 index 0000000000..1dd98c5cda --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/Badge.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ChoiceSelector.vue b/.jjconflict-side-2/apps/client/src/components/ChoiceSelector.vue new file mode 100644 index 0000000000..b615ab6750 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ChoiceSelector.vue @@ -0,0 +1,272 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ClusterForm.vue b/.jjconflict-side-2/apps/client/src/components/ClusterForm.vue new file mode 100644 index 0000000000..81f51ebcaf --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ClusterForm.vue @@ -0,0 +1,532 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ConfigParam.vue b/.jjconflict-side-2/apps/client/src/components/ConfigParam.vue new file mode 100644 index 0000000000..b36d3820c7 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ConfigParam.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/components/DeleteForm.vue b/.jjconflict-side-2/apps/client/src/components/DeleteForm.vue new file mode 100644 index 0000000000..3716065a14 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/DeleteForm.vue @@ -0,0 +1,73 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/DeploymentCard.vue b/.jjconflict-side-2/apps/client/src/components/DeploymentCard.vue new file mode 100644 index 0000000000..cceb650e6f --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/DeploymentCard.vue @@ -0,0 +1,46 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/DeploymentModal.vue b/.jjconflict-side-2/apps/client/src/components/DeploymentModal.vue new file mode 100644 index 0000000000..2323b7cea9 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/DeploymentModal.vue @@ -0,0 +1,182 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/DeploymentRepoOption.vue b/.jjconflict-side-2/apps/client/src/components/DeploymentRepoOption.vue new file mode 100644 index 0000000000..0aba4f3fc3 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/DeploymentRepoOption.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/components/DeploymentRepoSelect.vue b/.jjconflict-side-2/apps/client/src/components/DeploymentRepoSelect.vue new file mode 100644 index 0000000000..9af01fb8c6 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/DeploymentRepoSelect.vue @@ -0,0 +1,65 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/DeploymentResources.vue b/.jjconflict-side-2/apps/client/src/components/DeploymentResources.vue new file mode 100644 index 0000000000..948c34c059 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/DeploymentResources.vue @@ -0,0 +1,96 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/DsoBadge.vue b/.jjconflict-side-2/apps/client/src/components/DsoBadge.vue new file mode 100644 index 0000000000..0f33785b50 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/DsoBadge.vue @@ -0,0 +1,52 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/DsoSnackbar.vue b/.jjconflict-side-2/apps/client/src/components/DsoSnackbar.vue new file mode 100644 index 0000000000..a91ff46ee8 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/DsoSnackbar.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/components/EnvironmentForm.vue b/.jjconflict-side-2/apps/client/src/components/EnvironmentForm.vue new file mode 100644 index 0000000000..1035945aca --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/EnvironmentForm.vue @@ -0,0 +1,396 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ErrorGoBackToProjects.vue b/.jjconflict-side-2/apps/client/src/components/ErrorGoBackToProjects.vue new file mode 100644 index 0000000000..3dc6c155f1 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ErrorGoBackToProjects.vue @@ -0,0 +1,25 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/Loader.vue b/.jjconflict-side-2/apps/client/src/components/Loader.vue new file mode 100644 index 0000000000..5ba76adde9 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/Loader.vue @@ -0,0 +1,21 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/LoadingCt.vue b/.jjconflict-side-2/apps/client/src/components/LoadingCt.vue new file mode 100644 index 0000000000..a36af47cc3 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/LoadingCt.vue @@ -0,0 +1,29 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/LogsViewer.vue b/.jjconflict-side-2/apps/client/src/components/LogsViewer.vue new file mode 100644 index 0000000000..2ce24862f4 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/LogsViewer.vue @@ -0,0 +1,218 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/OperationPanel.vue b/.jjconflict-side-2/apps/client/src/components/OperationPanel.vue new file mode 100644 index 0000000000..e308f41fb7 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/OperationPanel.vue @@ -0,0 +1,36 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/PaginationCt.vue b/.jjconflict-side-2/apps/client/src/components/PaginationCt.vue new file mode 100644 index 0000000000..ef0e1e2c73 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/PaginationCt.vue @@ -0,0 +1,74 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ProjectAction.vue b/.jjconflict-side-2/apps/client/src/components/ProjectAction.vue new file mode 100644 index 0000000000..e05dc0bbd2 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ProjectAction.vue @@ -0,0 +1,51 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ProjectArchiveButton.vue b/.jjconflict-side-2/apps/client/src/components/ProjectArchiveButton.vue new file mode 100644 index 0000000000..1d9fe1b0f5 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ProjectArchiveButton.vue @@ -0,0 +1,74 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ProjectBanner.vue b/.jjconflict-side-2/apps/client/src/components/ProjectBanner.vue new file mode 100644 index 0000000000..05ab11ea11 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ProjectBanner.vue @@ -0,0 +1,185 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ProjectClustersInfos.vue b/.jjconflict-side-2/apps/client/src/components/ProjectClustersInfos.vue new file mode 100644 index 0000000000..d7af0874ed --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ProjectClustersInfos.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ProjectLockButton.vue b/.jjconflict-side-2/apps/client/src/components/ProjectLockButton.vue new file mode 100644 index 0000000000..ac700f5cc4 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ProjectLockButton.vue @@ -0,0 +1,32 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ProjectLogsViewer.vue b/.jjconflict-side-2/apps/client/src/components/ProjectLogsViewer.vue new file mode 100644 index 0000000000..fba2129414 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ProjectLogsViewer.vue @@ -0,0 +1,47 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ProjectReplayButton.vue b/.jjconflict-side-2/apps/client/src/components/ProjectReplayButton.vue new file mode 100644 index 0000000000..efb2a90886 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ProjectReplayButton.vue @@ -0,0 +1,62 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ProjectResources.vue b/.jjconflict-side-2/apps/client/src/components/ProjectResources.vue new file mode 100644 index 0000000000..0c0d25e2a5 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ProjectResources.vue @@ -0,0 +1,478 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ProjectRoleForm.vue b/.jjconflict-side-2/apps/client/src/components/ProjectRoleForm.vue new file mode 100644 index 0000000000..d20f31c5d9 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ProjectRoleForm.vue @@ -0,0 +1,201 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ProjectRoles.vue b/.jjconflict-side-2/apps/client/src/components/ProjectRoles.vue new file mode 100644 index 0000000000..5760069994 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ProjectRoles.vue @@ -0,0 +1,162 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ProjectSecretsButton.vue b/.jjconflict-side-2/apps/client/src/components/ProjectSecretsButton.vue new file mode 100644 index 0000000000..05f1f423e4 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ProjectSecretsButton.vue @@ -0,0 +1,93 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ProjectSettings.vue b/.jjconflict-side-2/apps/client/src/components/ProjectSettings.vue new file mode 100644 index 0000000000..4bcd1dc66a --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ProjectSettings.vue @@ -0,0 +1,164 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ReloadPrompt.vue b/.jjconflict-side-2/apps/client/src/components/ReloadPrompt.vue new file mode 100644 index 0000000000..aa914e8ecb --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ReloadPrompt.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/components/RepoForm.vue b/.jjconflict-side-2/apps/client/src/components/RepoForm.vue new file mode 100644 index 0000000000..0a38b379cd --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/RepoForm.vue @@ -0,0 +1,397 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/SelectProject.vue b/.jjconflict-side-2/apps/client/src/components/SelectProject.vue new file mode 100644 index 0000000000..5c98b13e21 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/SelectProject.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ServiceChainForm.vue b/.jjconflict-side-2/apps/client/src/components/ServiceChainForm.vue new file mode 100644 index 0000000000..eec5f94223 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ServiceChainForm.vue @@ -0,0 +1,423 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ServicesConfig.vue b/.jjconflict-side-2/apps/client/src/components/ServicesConfig.vue new file mode 100644 index 0000000000..1cd4a36f9b --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ServicesConfig.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/components/SideMenu.vue b/.jjconflict-side-2/apps/client/src/components/SideMenu.vue new file mode 100644 index 0000000000..ecde0c9aef --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/SideMenu.vue @@ -0,0 +1,340 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/components/StageForm.vue b/.jjconflict-side-2/apps/client/src/components/StageForm.vue new file mode 100644 index 0000000000..afca19b513 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/StageForm.vue @@ -0,0 +1,212 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/SuggestionInput.vue b/.jjconflict-side-2/apps/client/src/components/SuggestionInput.vue new file mode 100644 index 0000000000..fb704e409a --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/SuggestionInput.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/components/TeamCt.vue b/.jjconflict-side-2/apps/client/src/components/TeamCt.vue new file mode 100644 index 0000000000..d850569e77 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/TeamCt.vue @@ -0,0 +1,259 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/TokenForm.vue b/.jjconflict-side-2/apps/client/src/components/TokenForm.vue new file mode 100644 index 0000000000..2d743348ec --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/TokenForm.vue @@ -0,0 +1,159 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/components/ZoneForm.vue b/.jjconflict-side-2/apps/client/src/components/ZoneForm.vue new file mode 100644 index 0000000000..57c0b8db5d --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/components/ZoneForm.vue @@ -0,0 +1,225 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/env.d.ts b/.jjconflict-side-2/apps/client/src/env.d.ts new file mode 100644 index 0000000000..c725a2cc66 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/env.d.ts @@ -0,0 +1,37 @@ +/// +/// + +declare module 'virtual:pwa-register/vue' { + import type { Ref } from 'vue' + import type { RegisterSWOptions } from 'vite-plugin-pwa/types' + + export type { RegisterSWOptions } + + export function useRegisterSW(options?: RegisterSWOptions): { + needRefresh: Ref + offlineReady: Ref + updateServiceWorker: (reloadPage?: boolean) => Promise + } +} + +declare module 'vue3-json-viewer' { + import { AllowedComponentProps, App, Component, ComponentCustomProps, VNodeProps } from 'vue'; + + interface JsonViewerProps { + value: Record | Array | string | number | boolean; + expanded: boolean; + expandDepth: number; + copyable: boolean | object; + sort: boolean; + boxed: boolean; + theme: string; //"dark" | "light" + previewMode: boolean; + timeformat: (value: any) => string; + } + + type JsonViewerType = JsonViewerProps & VNodeProps & AllowedComponentProps & ComponentCustomProps; + const JsonViewer: Component; + export { JsonViewer }; + const def: { install: (app: App) => void }; + export default def; +} diff --git a/.jjconflict-side-2/apps/client/src/icon-collections.ts b/.jjconflict-side-2/apps/client/src/icon-collections.ts new file mode 100644 index 0000000000..de8e82fe94 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/icon-collections.ts @@ -0,0 +1,5 @@ +import type { IconifyJSON } from '@iconify/vue' + +const collections: IconifyJSON[] = [{ prefix: 'ri', icons: { 'account-circle-line': { body: '' }, 'add-line': { body: '' }, 'admin-line': { body: '' }, 'arrow-drop-left-line': { body: '' }, 'arrow-drop-right-line': { body: '' }, 'arrow-go-back-line': { body: '' }, 'arrow-left-double-line': { body: '' }, 'arrow-right-double-line': { body: '' }, 'arrow-right-line': { body: '' }, 'arrow-right-s-line': { body: '' }, 'award-line': { body: '' }, 'building-line': { body: '' }, 'checkbox-blank-circle-fill': { body: '' }, 'checkbox-circle-line': { body: '' }, 'clipboard-line': { body: '' }, 'close-line': { body: '' }, 'code-s-slash-line': { body: '' }, 'dashboard-line': { body: '' }, 'delete-bin-7-line': { body: '' }, 'exchange-line': { body: '' }, 'eye-line': { body: '' }, 'eye-off-line': { body: '' }, 'file-download-line': { body: '' }, 'file-info-line': { body: '' }, 'filter-line': { body: '' }, 'filter-off-line': { body: '' }, 'flow-chart': { body: '' }, 'focus-3-line': { body: '' }, 'folder-line': { body: '' }, 'folder-shield-2-line': { body: '' }, 'folder-user-line': { body: '' }, 'folders-line': { body: '' }, 'git-merge-line': { body: '' }, 'git-repository-private-line': { body: '' }, 'github-line': { body: '' }, 'global-line': { body: '' }, 'key-2-line': { body: '' }, 'loader-4-line': { body: '' }, 'lock-line': { body: '' }, 'lock-unlock-line': { body: '' }, 'mail-line': { body: '' }, 'moon-clear-line': { body: '' }, 'newspaper-line': { body: '' }, 'pencil-line': { body: '' }, 'question-line': { body: '' }, 'refresh-line': { body: '' }, 'restart-line': { body: '' }, 'send-plane-line': { body: '' }, 'server-line': { body: '' }, 'settings-3-line': { body: '' }, 'shapes-line': { body: '' }, 'shield-check-line': { body: '' }, 'sound-module-line': { body: '' }, 'stock-line': { body: '' }, 'sun-line': { body: '' }, 'team-line': { body: '' }, 'tools-line': { body: '' }, 'upload-cloud-line': { body: '' }, 'user-add-line': { body: '' }, 'user-shared-2-line': { body: '' } }, width: 24, height: 24 }] +export const ri = { accountCircleLine: 'ri:account-circle-line', addLine: 'ri:add-line', adminLine: 'ri:admin-line', arrowDropLeftLine: 'ri:arrow-drop-left-line', arrowDropRightLine: 'ri:arrow-drop-right-line', arrowGoBackLine: 'ri:arrow-go-back-line', arrowLeftDoubleLine: 'ri:arrow-left-double-line', arrowRightDoubleLine: 'ri:arrow-right-double-line', arrowRightLine: 'ri:arrow-right-line', arrowRightSLine: 'ri:arrow-right-s-line', awardLine: 'ri:award-line', buildingLine: 'ri:building-line', checkboxBlankCircleFill: 'ri:checkbox-blank-circle-fill', checkboxCircleLine: 'ri:checkbox-circle-line', clipboardLine: 'ri:clipboard-line', closeLine: 'ri:close-line', codeSSlashLine: 'ri:code-s-slash-line', dashboardLine: 'ri:dashboard-line', deleteBin7Line: 'ri:delete-bin-7-line', exchangeLine: 'ri:exchange-line', eyeLine: 'ri:eye-line', eyeOffLine: 'ri:eye-off-line', fileDownloadLine: 'ri:file-download-line', fileInfoLine: 'ri:file-info-line', filterLine: 'ri:filter-line', filterOffLine: 'ri:filter-off-line', flowChart: 'ri:flow-chart', focus3Line: 'ri:focus-3-line', folderLine: 'ri:folder-line', folderShield2Line: 'ri:folder-shield-2-line', folderUserLine: 'ri:folder-user-line', foldersLine: 'ri:folders-line', gitMergeLine: 'ri:git-merge-line', gitRepositoryPrivateLine: 'ri:git-repository-private-line', githubLine: 'ri:github-line', globalLine: 'ri:global-line', key2Line: 'ri:key-2-line', loader4Line: 'ri:loader-4-line', lockLine: 'ri:lock-line', lockUnlockLine: 'ri:lock-unlock-line', mailLine: 'ri:mail-line', moonClearLine: 'ri:moon-clear-line', newspaperLine: 'ri:newspaper-line', pencilLine: 'ri:pencil-line', questionLine: 'ri:question-line', refreshLine: 'ri:refresh-line', restartLine: 'ri:restart-line', sendPlaneLine: 'ri:send-plane-line', serverLine: 'ri:server-line', settings3Line: 'ri:settings-3-line', shapesLine: 'ri:shapes-line', shieldCheckLine: 'ri:shield-check-line', soundModuleLine: 'ri:sound-module-line', stockLine: 'ri:stock-line', sunLine: 'ri:sun-line', teamLine: 'ri:team-line', toolsLine: 'ri:tools-line', uploadCloudLine: 'ri:upload-cloud-line', userAddLine: 'ri:user-add-line', userShared2Line: 'ri:user-shared-2-line' } as const +export default collections diff --git a/.jjconflict-side-2/apps/client/src/main.css b/.jjconflict-side-2/apps/client/src/main.css new file mode 100644 index 0000000000..58f1adca7f --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/main.css @@ -0,0 +1,194 @@ +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-Regular.otf'); +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-RegularItalic.otf'); + font-style: italic; +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-Bold.otf'); + font-weight: 700; +} + +@font-face { + font-family: Marianne; + src: url('@/assets/fonts/Marianne-BoldItalic.otf'); + font-weight: 700; + font-style: italic; +} + + +html, +body, +#app { + font-family: Marianne, sans-serif; + height: 100%; +} + +.h-full { + height: 100%; +} + +.disabled { + color: var(--text-disabled-grey); + cursor: not-allowed; +} + +.fr-fieldset { + margin: 1rem 0 0; + align-items: flex-start; + min-width: 0; +} + +.fr-checkbox-group { + margin-bottom: 1rem; +} + +.dso-footer.fr-footer { + box-shadow: inset 0 2px 0 0 var(--border-plain-blue-france); +} + +.dso-footer .fr-footer__bottom { + display: none; +} + +.dso-footer .fr-footer__content-desc { + text-align: right; + margin-right: 25px; +} + +.danger-zone { + border-bottom: solid var(--border-plain-warning); + margin-top: 2rem; + padding: 2rem; + background-color: var(--background-alt-grey); +} + +.fr-alert { + background-color: var(--background-alt-grey); +} + +.fr-alert pre { + white-space: normal; +} + +.log-panel, .log-btn { + background-color: var(--background-default-grey); +} + +.log-panel { + box-shadow: inset 0 0 0 1px var(--border-action-high-blue-france); +} + +.log-box--success{ + border-color: var(--background-contrast-success); +} + +.log-box--warning{ + border-color: var(--background-contrast-warning); +} + +.log-box--error{ + border-color: var(--background-contrast-error); +} + +.danger-zone-btns { + @apply flex justify-between items-center gap-4 { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + describe('detect project slug helper', () => { + let projectStore: ReturnType + let slug: string + let uuid: string + + beforeEach(() => { + projectStore = useProjectStore() + slug = faker.lorem.slug() + uuid = faker.string.uuid() + const recent = faker.date.recent() + const ownerId = faker.string.uuid() + const project: ProjectV2 = { + id: uuid, + clusterIds: [], + description: '', + everyonePerms: '0', + name: slug, + slug, + locked: false, + owner: { + type: 'human', + id: ownerId, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + createdAt: recent.toString(), + updatedAt: recent.toString(), + lastLogin: recent.toString(), + }, + ownerId, + roles: [], + members: [], + createdAt: recent.toString(), + updatedAt: recent.toString(), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + status: 'created', + lastSuccessProvisionningVersion: null, + } + projectStore.updateStore([project]) + }) + + it('it should return project\'slug with uuid passed', () => { + const slugFound = detectProjectslug({ + params: { + slug: uuid, + }, + }) + expect(slugFound).toEqual(slug) + }) + + it('it should return project\'slug with slug passed', () => { + const slugFound = detectProjectslug({ + params: { + slug, + }, + }) + expect(slugFound).toEqual(slug) + }) + }) + + describe('navigation with real router instance', () => { + it('renders home and navigates to projects', async () => { + const router = createAppRouter('') + const userStore = useUserStore() + const systemStore = useSystemSettingsStore() + + // Ensure global guard does not redirect to /login + // by simulating an authenticated user. + userStore.isLoggedIn = true + userStore.setIsLoggedIn = async () => {} + systemStore.listSystemSettings = async () => {} + router.push('/') + await router.isReady() + + expect(router.currentRoute.value.name).toEqual('Home') + + await router.push('/projects') + await router.isReady() + + expect(router.currentRoute.value.matched.some(r => r.name === 'Projects')).toBe(true) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/client/src/router/index.ts b/.jjconflict-side-2/apps/client/src/router/index.ts new file mode 100644 index 0000000000..002e8421a5 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/router/index.ts @@ -0,0 +1,326 @@ +import type { + RouteLocationNormalizedGeneric, + RouteRecordRaw, +} from 'vue-router' +import { logger } from '@cpn-console/logger/browser' +import { AdminAuthorized, swaggerUiPath } from '@cpn-console/shared' +import { + createRouter, + createWebHistory, +} from 'vue-router' +import { useProjectStore } from '@/stores/project.js' +import { useSystemSettingsStore } from '@/stores/system-settings.js' + +import { useUserStore } from '@/stores/user.js' +import { uuid } from '@/utils/regex.js' +import DsoHome from '@/views/DsoHome.vue' +import NotFound from '@/views/NotFound.vue' + +const AdminCluster = () => import('@/views/admin/AdminCluster.vue') +const AdminServiceChain = () => import('@/views/admin/AdminServiceChain.vue') +const ServicesHealth = () => import('@/views/ServicesHealth.vue') +const CreateProject = () => import('@/views/CreateProject.vue') +const ProfileWrapper = () => import('@/views/profile/ProfileWrapper.vue') +const UserInfo = () => import('@/views/profile/UserInfo.vue') +const PersonalAccessTokens = () => import('@/views/profile/PersonalAccessTokens.vue') +const DsoProjects = () => import('@/views/projects/DsoProjects.vue') +const DsoAdmin = () => import('@/views/admin/DsoAdmin.vue') +const ListUser = () => import('@/views/admin/ListUser.vue') +const ProjectDashboard = () => import('@/views/ProjectDashboard.vue') +const ListProjects = () => import('@/views/admin/ListProjects.vue') +const ListLogs = () => import('@/views/admin/ListLogs.vue') +const AdminRoles = () => import('@/views/admin/AdminRoles.vue') +const ListClusters = () => import('@/views/admin/ListClusters.vue') +const ListServiceChains = () => import('@/views/admin/ListServiceChains.vue') +const ListStages = () => import('@/views/admin/ListStages.vue') +const ListZones = () => import('@/views/admin/ListZones.vue') +const ListPlugins = () => import('@/views/admin/ListPlugins.vue') +const SystemSettings = () => import('@/views/admin/SystemSettings.vue') +const Maintenance = () => import('@/views/DsoMaintenance.vue') +const AdminTokens = () => import('@/views/admin/AdminTokens.vue') + +const MAIN_TITLE = 'Console Cloud π Native' + +export function detectProjectslug(to: Pick) { + const slugParam = to.params.slug as string + return uuid.test(slugParam) + ? useProjectStore().projects.find(project => project.id === slugParam)?.slug + : slugParam +} + +export const routes: Readonly = [ + { + path: '/login', + name: 'Login', + beforeEnter: async (_to, _from) => { + const userStore = useUserStore() + await userStore.login() + }, + component: DsoProjects, + }, + { + path: '/logout', + name: 'Logout', + beforeEnter: async (_to, _from) => { + const userStore = useUserStore() + await userStore.logout() + }, + component: DsoHome, + }, + { + path: '/', + name: 'Home', + component: DsoHome, + }, + { + path: '/profile', + name: 'Profile', + component: ProfileWrapper, + children: [ + { + path: 'info', + name: 'UserInfo', + component: UserInfo, + }, + { + path: 'tokens', + name: 'PersonalAccessTokens', + component: PersonalAccessTokens, + }, + ], + }, + { + path: '/maintenance', + name: 'Maintenance', + component: Maintenance, + }, + { + path: '/services-health', + name: 'ServicesHealth', + component: ServicesHealth, + }, + + { + path: '/projects', + children: [ + { + path: '', + name: 'Projects', + component: DsoProjects, + }, + { + path: ':slug', + name: 'Project', + component: ProjectDashboard, + async beforeEnter(to, _from, next) { + if (typeof to.params.slug !== 'string') { + return next({ name: 'Projects' }) + } + if (!(to.params.slug in useProjectStore().projectsBySlug)) { + await useProjectStore().getProject(to.params.slug).catch(async () => { + logger.warn({ slug: to.params.slug }, 'Unable to find project information, redirecting to /projects') + return next({ name: 'Projects' }) + }) + } + useProjectStore().lastSelectedProjectSlug = to.params.slug + return next() + }, + props(to) { + return { + projectSlug: detectProjectslug(to), + parentRoute: 'Projects', + asProfile: 'user', + tab: to.query.tab, + } + }, + }, + ], + }, + { + path: '/create-project', + name: 'CreateProject', + component: CreateProject, + }, + { + name: 'ParentAdmin', + path: '/admin', + component: DsoAdmin, + children: [ + { + path: 'users', + name: 'ListUser', + component: ListUser, + }, + { + path: 'projects', + children: [ + { + path: '', + name: 'ListProjects', + component: ListProjects, + }, + { + path: ':slug', + name: 'AdminProject', + component: ProjectDashboard, + async beforeEnter(to, _from, next) { + if (typeof to.params.slug !== 'string') { + return next('/admin/projects') + } + await useProjectStore().getProject(to.params.slug) + return next() + }, + props(to) { + return { + projectSlug: detectProjectslug(to), + parentRoute: 'ListProjects', + asProfile: 'admin', + } + }, + }, + ], + }, + { + path: 'service-chains', + children: [ + { + path: '', + name: 'ListServiceChains', + component: ListServiceChains, + }, + { + path: ':id', + name: 'AdminServiceChain', + component: AdminServiceChain, + props(to) { + return { + id: to.params.id, + } + }, + }, + ], + }, + { + path: 'logs', + name: 'ListLogs', + component: ListLogs, + }, + { + path: 'clusters', + children: [ + { + path: '', + name: 'ListClusters', + component: ListClusters, + }, + { + path: ':id', + name: 'AdminCluster', + component: AdminCluster, + props(to) { + return { + id: to.params.id, + } + }, + }, + ], + }, + { + path: 'stages', + name: 'ListStages', + component: ListStages, + }, + { + path: 'zones', + name: 'ListZones', + component: ListZones, + }, + { + path: 'plugins', + name: 'ListPlugins', + component: ListPlugins, + }, + { + path: 'system-settings', + name: 'SystemSettings', + component: SystemSettings, + }, + { + path: 'roles', + name: 'AdminRoles', + component: AdminRoles, + }, + { + path: 'tokens', + name: 'AdminTokens', + component: AdminTokens, + }, + ], + }, + { + path: `${swaggerUiPath}-server`, + name: 'Swagger-server', + // no component, swagger plugin + components: {}, + strict: false, + }, + { + path: `${swaggerUiPath}-server-nestjs`, + name: 'Swagger-server-nestjs', + // no component, swagger plugin + components: {}, + strict: false, + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: NotFound, + }, +] + +export function createAppRouter(base?: string) { + const router = createRouter({ + history: createWebHistory(base ?? (import.meta.env?.BASE_URL || '')), + scrollBehavior: (to) => { if (to.hash && !to.hash.startsWith('#state=')) return ({ el: to.hash }) }, + routes, + }) + router.beforeEach((to) => { + const specificTitle = to.meta.title ? `${to.meta.title} - ` : '' + document.title = `${specificTitle}${MAIN_TITLE}` + }) + router.beforeEach(async (to, _from, next) => { + const validPath = new Set(['Login', 'Home', 'Doc', 'NotFound', 'ServicesHealth', 'Maintenance', 'Logout', 'Swagger-server', 'Swagger-server-nestjs']) + const userStore = useUserStore() + const systemStore = useSystemSettingsStore() + await userStore.setIsLoggedIn() + if ( + !validPath.has(to.name?.toString() ?? '') + && !userStore.isLoggedIn + ) { + return next('/login') + } + if (to.name === 'Login' && userStore.isLoggedIn) { + return next('/') + } + if ( + !validPath.has(to.name?.toString() ?? '') + && userStore.isLoggedIn + ) { + await systemStore.listSystemSettings() + if (systemStore.systemSettingsByKey.maintenance?.value === 'on' && !AdminAuthorized.Manage(userStore.adminPerms)) return next('/maintenance') + } + next() + }) + return router +} + +const router = createAppRouter() + +export const isInProject = computed(() => router.currentRoute.value.matched.some(route => route.name === 'Project')) +export const selectedProjectSlug = computed(() => { + if (router.currentRoute.value.matched.some(route => route.name === 'Project')) { + return router.currentRoute.value.params.slug as string + } + return undefined +}) +export default router diff --git a/.jjconflict-side-2/apps/client/src/stores/admin-role.ts b/.jjconflict-side-2/apps/client/src/stores/admin-role.ts new file mode 100644 index 0000000000..fa70004e0f --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/admin-role.ts @@ -0,0 +1,61 @@ +import type { AdminRole, adminRoleContract } from '@cpn-console/shared' +import { AdminAuthorized } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '../api/xhr-client.js' +import { useUserStore } from './user.js' + +export const useAdminRoleStore = defineStore('adminRole', () => { + const userStore = useUserStore() + const roles = ref([]) + const memberCounts = ref>({}) + + const countMembersRoles = async () => { + memberCounts.value + = await apiClient.AdminRoles.adminRoleMemberCounts().then((res: any) => + extractData(res, 200), + ) + return memberCounts.value + } + + const listRoles = async () => { + roles.value = await apiClient.AdminRoles.listAdminRoles().then((response: any) => + extractData(response, 200), + ) + if (AdminAuthorized.ListRoles(userStore.adminPerms)) { + await countMembersRoles() + } + return roles.value + } + + const createRole = async () => { + roles.value = await apiClient.AdminRoles.createAdminRole({ + body: { name: 'Nouveau rôle' }, + }).then((res: any) => extractData(res, 201)) + } + + const deleteRole = async (roleId: AdminRole['id']) => { + await apiClient.AdminRoles.deleteAdminRole({ params: { roleId } }).then( + (res: any) => extractData(res, 204), + ) + await listRoles() + } + + const patchRoles = async ( + body: typeof adminRoleContract.patchAdminRoles.body._type, + ) => { + roles.value = await apiClient.AdminRoles.patchAdminRoles({ + body, + }).then((res: any) => extractData(res, 200)) + } + + return { + roles, + memberCounts, + countMembersRoles, + listRoles, + createRole, + deleteRole, + patchRoles, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/admin-token.spec.ts b/.jjconflict-side-2/apps/client/src/stores/admin-token.spec.ts new file mode 100644 index 0000000000..96a91a1b94 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/admin-token.spec.ts @@ -0,0 +1,57 @@ +import type { ExposedAdminToken } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useAdminTokenStore } from './admin-token.js' + +const apiClientGet = vi.spyOn(apiClient.AdminTokens, 'listAdminTokens') +const apiClientPost = vi.spyOn(apiClient.AdminTokens, 'createAdminToken') +const apiClientDelete = vi.spyOn(apiClient.AdminTokens, 'deleteAdminToken') + +describe('cluster Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get tokens list by api call', async () => { + const data = [] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminTokenStore = useAdminTokenStore() + + await adminTokenStore.listTokens() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should add token by api call', async () => { + const data: ExposedAdminToken = { + name: 'test2', + expirationDate: null, + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + lastUse: null, + password: 'password', + permissions: '2', + } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: data })) + const adminTokenStore = useAdminTokenStore() + + const res = await adminTokenStore.createToken({ name: data.name, permissions: data.permissions }) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should delete token by api call', async () => { + const tokenId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204 })) + const adminTokenStore = useAdminTokenStore() + + await adminTokenStore.deleteToken(tokenId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/admin-token.ts b/.jjconflict-side-2/apps/client/src/stores/admin-token.ts new file mode 100644 index 0000000000..3b76dc2fe8 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/admin-token.ts @@ -0,0 +1,33 @@ +import type { AdminToken, adminTokenContract } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '../api/xhr-client.js' + +export const useAdminTokenStore = defineStore('adminToken', () => { + const listTokens = async ( + query: typeof adminTokenContract.listAdminTokens.query._type = {}, + ) => { + return apiClient.AdminTokens.listAdminTokens({ query }).then( + (response: any) => extractData(response, 200), + ) + } + + const createToken = async ( + body: typeof adminTokenContract.createAdminToken.body._type, + ) => { + return apiClient.AdminTokens.createAdminToken({ body }).then((res: any) => + extractData(res, 201), + ) + } + + const deleteToken = async (tokenId: AdminToken['id']) => { + await apiClient.AdminTokens.deleteAdminToken({ params: { tokenId } }).then( + (res: any) => extractData(res, 204), + ) + } + + return { + listTokens, + createToken, + deleteToken, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/cluster.spec.ts b/.jjconflict-side-2/apps/client/src/stores/cluster.spec.ts new file mode 100644 index 0000000000..903c45450e --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/cluster.spec.ts @@ -0,0 +1,128 @@ +import { ClusterPrivacy } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useClusterStore } from './cluster.js' + +const apiClientGet = vi.spyOn(apiClient.Clusters, 'getClusterDetails') +const apiClientGetClusterEnvironments = vi.spyOn(apiClient.Clusters, 'getClusterEnvironments') +const apiClientPost = vi.spyOn(apiClient.Clusters, 'createCluster') +const apiClientPut = vi.spyOn(apiClient.Clusters, 'updateCluster') +const apiClientDelete = vi.spyOn(apiClient.Clusters, 'deleteCluster') + +describe('cluster Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get clusters list by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b7', + label: 'cluster2', + infos: 'infos', + kubeconfig: { + cluster: { + tlsServerName: '8790044f-8414-569f-9c4a-2df42a879879', + }, + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + }, + clusterResources: true, + privacy: 'public', + } + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + await adminClusterStore.getClusterDetails(data.id) + + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should get cluster\'s associated environments by api call', async () => { + const clusterId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + const data = { + environments: [], + } + + apiClientGetClusterEnvironments.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.getClusterAssociatedEnvironments(clusterId) + + expect(res).toBe(data) + expect(apiClientGetClusterEnvironments).toHaveBeenCalledTimes(1) + }) + + it('should add cluster by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + label: 'cluster1', + projectIds: [ + '22e7044f-8414-435d-9c4a-2df42a65034b', + ], + kubeconfig: { + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + }, + clusterResources: true, + privacy: ClusterPrivacy.DEDICATED, + } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.addCluster(data) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update cluster by api call', async () => { + const data = { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + projectIds: [ + '22e7044f-8414-435d-9c4a-2df42a65034b', + ], + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + clusterResources: true, + privacy: ClusterPrivacy.DEDICATED, + } + apiClientPut.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminClusterStore = useClusterStore() + + const res = await adminClusterStore.updateCluster(data) + + expect(res).toBe(data) + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete cluster by api call', async () => { + const clusterId = '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6' + + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204 })) + const adminClusterStore = useClusterStore() + + await adminClusterStore.deleteCluster(clusterId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/cluster.ts b/.jjconflict-side-2/apps/client/src/stores/cluster.ts new file mode 100644 index 0000000000..3f94777f2b --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/cluster.ts @@ -0,0 +1,65 @@ +import type { CleanedCluster, Cluster, CreateClusterBody, UpdateClusterBody } from '@cpn-console/shared' +import { + resourceListToDict, + +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useClusterStore = defineStore('cluster', () => { + const clusters = ref>([]) + const clustersById = computed(() => resourceListToDict(clusters.value)) + + const getClusters = async () => { + clusters.value = await apiClient.Clusters.listClusters().then( + (response: any) => extractData(response, 200), + ) + return clusters.value + } + + const getClusterDetails = async (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterDetails({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const getClusterUsage = async (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterUsage({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const getClusterAssociatedEnvironments = (clusterId: Cluster['id']) => + apiClient.Clusters.getClusterEnvironments({ params: { clusterId } }).then( + (response: any) => extractData(response, 200), + ) + + const addCluster = (cluster: CreateClusterBody) => + apiClient.Clusters.createCluster({ body: cluster }).then((response: any) => + extractData(response, 201), + ) + + const updateCluster = ({ + id, + ...body + }: UpdateClusterBody & { id: Cluster['id'] }) => + apiClient.Clusters.updateCluster({ body, params: { clusterId: id } }).then( + (response: any) => extractData(response, 200), + ) + + const deleteCluster = (clusterId: Cluster['id']) => + apiClient.Clusters.deleteCluster({ params: { clusterId } }).then( + (response: any) => extractData(response, 204), + ) + + return { + clusters, + clustersById, + getClusterAssociatedEnvironments, + addCluster, + updateCluster, + deleteCluster, + getClusterDetails, + getClusterUsage, + getClusters, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/log.spec.ts b/.jjconflict-side-2/apps/client/src/stores/log.spec.ts new file mode 100644 index 0000000000..e9b9d0b796 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/log.spec.ts @@ -0,0 +1,32 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useLogStore } from './log.js' + +const apiClientGet = vi.spyOn(apiClient.Logs, 'getLogs') + +describe('log Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get logs list and total by api call', async () => { + const data = { + total: 1, + logs: [ + { id: 'thisIsAnId', data: {}, action: 'Create Project', userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565' }, + ], + } + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const adminLogStore = useLogStore() + + await adminLogStore.getAllLogs({ offset: 5, limit: 10 }) + + expect(adminLogStore.count).toEqual(data.total) + expect(adminLogStore.logs).toStrictEqual(data.logs) + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/log.ts b/.jjconflict-side-2/apps/client/src/stores/log.ts new file mode 100644 index 0000000000..3b1266cc48 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/log.ts @@ -0,0 +1,30 @@ +import type { GetLogsQuery, Log } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useLogStore = defineStore('log', () => { + const logs = ref([]) + const count = ref(undefined) + const needRefresh = ref(false) + + const getAllLogs = async ({ offset, limit }: GetLogsQuery = { offset: 0, limit: 100 }) => { + const res = await apiClient.Logs.getLogs({ query: { offset, limit, clean: false } }) + .then((response: any) => extractData(response, 200)) + count.value = res.total + logs.value = res.logs as Log[] + } + + const listLogs = async ({ offset, limit, clean, projectId }: GetLogsQuery = { offset: 0, limit: 10 }) => { + return apiClient.Logs.getLogs({ query: { offset, limit, clean, projectId } }) + .then((response: any) => extractData(response, 200)) + } + + return { + logs, + count, + needRefresh, + getAllLogs, + listLogs, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/plugins.ts b/.jjconflict-side-2/apps/client/src/stores/plugins.ts new file mode 100644 index 0000000000..99dd9f7abd --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/plugins.ts @@ -0,0 +1,19 @@ +import type { PluginsUpdateBody, ProjectService } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const usePluginsConfigStore = defineStore('plugins', () => { + const services: ProjectService[] = [] + + const getPluginsConfig = () => apiClient.SystemPlugin.getPluginsConfig() + .then((response: any) => extractData(response, 200)) + + const updatePluginsConfig = (body: PluginsUpdateBody) => apiClient.SystemPlugin.updatePluginsConfig({ body }) + .then((response: any) => extractData(response, 204)) + + return { + services, + getPluginsConfig, + updatePluginsConfig, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/project.spec.ts b/.jjconflict-side-2/apps/client/src/stores/project.spec.ts new file mode 100644 index 0000000000..6e57c0e9c4 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/project.spec.ts @@ -0,0 +1,94 @@ +import { createRandomDbSetup, getRandomMember, getRandomProject } from '@cpn-console/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client' +import { useProjectStore } from './project' + +const getProject = vi.spyOn(apiClient.Projects, 'getProject') +const listProjects = vi.spyOn(apiClient.Projects, 'listProjects') +const listEnvironments = vi.spyOn(apiClient.Environments, 'listEnvironments') +const listRepositories = vi.spyOn(apiClient.Repositories, 'listRepositories') +const apiClientPost = vi.spyOn(apiClient.Projects, 'createProject') + +describe('project Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should retrieve projects by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.environments + delete randomDbSetup.project.repositories + delete randomDbSetup.project.clusters + const projects = [randomDbSetup.project] + + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: projects, headers: {} })) + + await projectStore.listProjects() + + expect(listProjects).toHaveBeenCalledTimes(1) + expect(projectStore.projects).toMatchObject(projects) + }) + + it('should retrieve user\'s projects by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.clusters + const projects = [randomDbSetup.project] + + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: projects, headers: {} })) + listEnvironments.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.environments, headers: {} })) + listRepositories.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.repositories, headers: {} })) + + await projectStore.listMyProjects() + + expect(listProjects).toHaveBeenCalledTimes(1) + }) + + it('should retrieve one project by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + delete randomDbSetup.project.clusters + const project = randomDbSetup.project + + getProject.mockReturnValueOnce(Promise.resolve({ status: 200, body: project, headers: {} })) + listEnvironments.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.environments, headers: {} })) + listRepositories.mockReturnValue(Promise.resolve({ status: 200, body: randomDbSetup.project.repositories, headers: {} })) + + await projectStore.getProject('foo') + + expect(getProject).toHaveBeenCalledTimes(1) + }) + + it('should create a project by api call', async () => { + const projectStore = useProjectStore() + const randomDbSetup = createRandomDbSetup({}) + + expect(projectStore.projects).toEqual([]) + + const projects = [randomDbSetup.project] + const newProject = getRandomProject() + newProject.members = [getRandomMember(randomDbSetup.users[0].id, 'owner')] + + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, body: newProject, headers: {} })) + listProjects.mockReturnValueOnce(Promise.resolve({ status: 200, body: [...projects, newProject], headers: {} })) + + await projectStore.createProject(newProject) + + expect(apiClientPost).toHaveBeenCalledTimes(1) + expect(projectStore.myProjects).toHaveLength(1) + }) +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/project.ts b/.jjconflict-side-2/apps/client/src/stores/project.ts new file mode 100644 index 0000000000..44e59d56a8 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/project.ts @@ -0,0 +1,119 @@ +import type { + CreateProjectBody, + projectContract, + ProjectV2, +} from '@cpn-console/shared' +import pDebounce from 'p-debounce' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' +import { Project } from '@/utils/project-utils.js' +import { useUserStore } from './user.js' + +export const useProjectStore = defineStore('project', () => { + const userStore = useUserStore() + + const amIPartOf = (project: ProjectV2) => + project.status !== 'archived' + && (project.ownerId === userStore.userProfile?.id + || project.members.some( + member => member.userId === userStore.userProfile?.id, + )) + + // mostly for admin views + const projectsBySlug = ref>({}) + + const projects = computed(() => + Object.values(projectsBySlug.value).sort((p1, p2) => + p1.slug.localeCompare(p2.slug), + ), + ) + + const myProjects = computed(() => + Object.values(projectsBySlug.value).filter( + project => project.status !== 'archived' && amIPartOf(project), + ), + ) + + const updateStore = async (projectsRecieved: ProjectV2[]) => { + return projectsRecieved.map((project) => { + if (project.slug in projectsBySlug.value) { + return projectsBySlug.value[project.slug].Commands.updateData(project) + } + const newProject = new Project(project) + projectsBySlug.value[project.slug] = newProject + return newProject + }) + } + + const selectFromStore = (slugs: ProjectV2['slug'][]) => { + return slugs + .filter(slug => slug in projectsBySlug.value) + .map(slug => projectsBySlug.value[slug]) + } + + const getProject = async (projectId: ProjectV2['id']) => { + const res = await apiClient.Projects.getProject({ + params: { projectId }, + }).then((response: any) => extractData(response, 200)) + return (await updateStore([res]))[0] + } + + const listProjects = async ( + query: typeof projectContract.listProjects.query._type = { + filter: 'member', + statusNotIn: 'archived', + }, + ) => { + const res = await apiClient.Projects.listProjects({ query }).then( + (response: any) => extractData(response, 200), + ) + await updateStore(res) + return selectFromStore(res.map((project: any) => project.slug)) + } + + const listMyProjects = pDebounce(async () => { + const res = await apiClient.Projects.listProjects({ + query: { filter: 'member', statusNotIn: 'archived' }, + }).then((response: any) => extractData(response, 200)) + for (const storedProject of myProjects.value) { + if ( + !res.some((responseProject: any) => responseProject.id === storedProject.id) + ) { + delete projectsBySlug.value[storedProject.slug] + } + } + await updateStore(res) + return selectFromStore(res.map((project: any) => project.slug)) + }, 200) + + const createProject = async (body: CreateProjectBody) => { + const project = await apiClient.Projects.createProject({ body }).then( + (response: any) => extractData(response, 201), + ) + projectsBySlug.value[project.slug] = new Project(project) + return project + } + + const generateProjectsData = () => + apiClient.Projects.getProjectsData().then((response: any) => + extractData(response, 200), + ) + + // Should only be used for components and vue outside of Project route and its children, consider using the props instead + // Should only be update by beforeEnter() of Project route + const lastSelectedProjectSlug = ref() + + return { + projects, + projectsBySlug, + myProjects, + lastSelectedProjectSlug, + getProject, + listProjects, + listMyProjects, + createProject, + generateProjectsData, + updateStore, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/service-chain.ts b/.jjconflict-side-2/apps/client/src/stores/service-chain.ts new file mode 100644 index 0000000000..802a594e0d --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/service-chain.ts @@ -0,0 +1,53 @@ +import type { ServiceChain, ServiceChainList } from '@cpn-console/shared' +import { + resourceListToDict, + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useServiceChainStore = defineStore('serviceChain', () => { + const serviceChains = ref([]) + const serviceChainsById = computed(() => + resourceListToDict(serviceChains.value), + ) + + const getServiceChainsList = async () => { + serviceChains.value + = await apiClient.ServiceChains.listServiceChains().then((response: any) => + extractData(response, 200), + ) + return serviceChains.value + } + + const getServiceChainDetails = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.getServiceChainDetails({ + params: { serviceChainId }, + }).then((response: any) => + ServiceChainDetailsSchema.parse(extractData(response, 200)), + ) + + const getServiceChainFlows = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.getServiceChainFlows({ + params: { serviceChainId }, + }).then((response: any) => + ServiceChainFlowsSchema.parse(extractData(response, 200)), + ) + + const retryServiceChain = async (serviceChainId: ServiceChain['id']) => + apiClient.ServiceChains.retryServiceChain({ + params: { serviceChainId }, + }) + + return { + serviceChains, + serviceChainsById, + getServiceChainDetails, + getServiceChainFlows, + getServiceChainsList, + retryServiceChain, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/services-monitor.spec.ts b/.jjconflict-side-2/apps/client/src/stores/services-monitor.spec.ts new file mode 100644 index 0000000000..a2d72c8417 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/services-monitor.spec.ts @@ -0,0 +1,60 @@ +import type { ServiceBody } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useServiceStore } from './services-monitor.js' + +const apiClientGet = vi.spyOn(apiClient.Services, 'getServiceHealth') + +describe('service Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get services health by api call (healthy)', async () => { + const data: ServiceBody = [ + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'OK', name: 'Keycloak', status: 'OK' }, + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'Service perdu', name: 'Gitlab', status: 'OK' }, + ] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const serviceStore = useServiceStore() + + expect(serviceStore.servicesHealth).toMatchObject({}) + expect(serviceStore.services).toMatchObject([]) + + await serviceStore.checkServicesHealth() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(serviceStore.servicesHealth).toMatchObject({ + message: 'Tous les services fonctionnent normalement', + serviceStatus: 'OK', + status: 'success', + }) + expect(serviceStore.services).toMatchObject(data) + }) + + it('should get services health by api call (unhealthy)', async () => { + const data = [ + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'OK', name: 'Keycloak', status: 'OK' }, + { interval: 300000, lastUpdateTimestamp: Date.now(), message: 'Service perdu', name: 'Gitlab', status: 'Inconnu' }, + ] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const serviceStore = useServiceStore() + + expect(serviceStore.servicesHealth).toMatchObject({}) + expect(serviceStore.services).toMatchObject([]) + + await serviceStore.checkServicesHealth() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(serviceStore.servicesHealth).toMatchObject({ + message: 'Échec lors de la dernière vérification', + serviceStatus: 'Inconnu', + status: undefined, + }) + expect(serviceStore.services).toMatchObject(data) + }) +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/services-monitor.ts b/.jjconflict-side-2/apps/client/src/stores/services-monitor.ts new file mode 100644 index 0000000000..d810ef328b --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/services-monitor.ts @@ -0,0 +1,125 @@ +import type { ServiceBody } from '@cpn-console/shared' +// @ts-ignore '@gouvminint/vue-dsfr' missing types +import type { DsfrAlertType } from '@gouvminint/vue-dsfr' +import { MonitorStatus } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export type ServicesHealth = { + message: string + status: DsfrAlertType | undefined + dotColor: 'gray' | 'red' | 'green' | 'orange' | 'blue' +} | Record + +interface ServiceHealthOption { message: string, status: DsfrAlertType | undefined, serviceStatus: MonitorStatus, dotColor: ServicesHealth['dotColor'] } + +export const alertTypeMapper: Record = { + [MonitorStatus.OK]: 'success', + [MonitorStatus.WARNING]: 'warning', + [MonitorStatus.ERROR]: 'error', + [MonitorStatus.UNKNOW]: undefined, +} + +const serviceHealthOptions = { + fetching: { + message: 'Vérification de l\'état des services...', + status: 'info', + serviceStatus: MonitorStatus.UNKNOW, + dotColor: 'blue', + }, + fetchError: { + message: 'Échec lors de la dernière vérification', + status: alertTypeMapper[MonitorStatus.UNKNOW], + serviceStatus: MonitorStatus.UNKNOW, + dotColor: 'gray', + }, + error: { + message: 'Un ou plusieurs services dysfonctionnent', + status: alertTypeMapper[MonitorStatus.ERROR], + serviceStatus: MonitorStatus.ERROR, + dotColor: 'red', + }, + warn: { + message: 'Un ou plusieurs services sont partiellement dégradés', + status: alertTypeMapper[MonitorStatus.WARNING], + serviceStatus: MonitorStatus.WARNING, + dotColor: 'orange', + }, + ok: { + message: 'Tous les services fonctionnent normalement', + status: alertTypeMapper[MonitorStatus.OK], + serviceStatus: MonitorStatus.OK, + dotColor: 'green', + }, +} as const satisfies Record + +export const useServiceStore = defineStore('serviceMonitor', () => { + const callStastus = ref<'ok' | 'fetching' | 'error'>('fetching') + + const displayCause = ref(false) + const services = ref([]) + + const serviceHealthIndex = computed(() => { + if (callStastus.value === 'fetching') { + return 'fetching' + } + if (services.value.some(({ status }) => status === 'Dégradé')) { + return 'warn' + } + if (services.value.some(({ status }) => status === 'En échec')) { + return 'error' + } + if (services.value.some(({ status }) => status === 'Inconnu')) { + return 'fetchError' + } + return 'ok' + }) + + const servicesHealth = computed(() => serviceHealthOptions[serviceHealthIndex.value]) + let interval: NodeJS.Timeout + + const clear = () => interval && clearInterval(interval) + + const checkServicesHealth = async () => { + callStastus.value = 'fetching' + try { + services.value = await (displayCause.value + ? apiClient.Services.getCompleteServiceHealth() + : apiClient.Services.getServiceHealth()) + .then((res: any) => extractData(res, 200)) + callStastus.value = 'ok' + } catch { + callStastus.value = 'error' + } + } + + const refreshServicesHealth = async () => { + await apiClient.Services.refreshServiceHealth() + .then((res: any) => extractData(res, 200)) + return checkServicesHealth() + } + + const startHealthPolling = async () => { + if (!interval) return + + clear() + await checkServicesHealth() + interval = setInterval(checkServicesHealth, 300_000_000) + } + + async function toggleDisplayCause() { + displayCause.value = !displayCause.value + await checkServicesHealth() + } + + return { + displayCause, + servicesHealth, + services, + checkServicesHealth, + refreshServicesHealth, + toggleDisplayCause, + startHealthPolling, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/snackbar.spec.ts b/.jjconflict-side-2/apps/client/src/stores/snackbar.spec.ts new file mode 100644 index 0000000000..4fe623ecb3 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/snackbar.spec.ts @@ -0,0 +1,155 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useSnackbarStore } from './snackbar.js' + +describe('snackbar Store', () => { + beforeEach(() => { + vi.clearAllTimers() + vi.useFakeTimers() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should display message in snackbar with default values', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(6000) + + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display message in snackbar with custom timer', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message, 'info', 10000) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(6000) + + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + vi.advanceTimersByTime(4000) + + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display message in snackbar with custom type', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + const type = 'warning' + snackbarStore.setMessage(message, type) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + }) + + it('should display message in snackbar and don\'t hide it if type error', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + const type = 'error' + snackbarStore.setMessage(message, type) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type) + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) + + it('should display last message in snackbar', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message1 = 'this is a message' + const type1 = 'warning' + snackbarStore.setMessage(message1, type1) + + expect(snackbarStore.message).toEqual(message1) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type1) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + const message2 = 'this is another message' + const type2 = 'info' + snackbarStore.setMessage(message2, type2) + + expect(snackbarStore.message).toEqual(message2) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual(type2) + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + }) + + it('should hide message in snackbar', () => { + const snackbarStore = useSnackbarStore() + + expect(snackbarStore.message).toBeUndefined() + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeUndefined() + + const message = 'this is a message' + snackbarStore.setMessage(message) + + expect(snackbarStore.message).toEqual(message) + expect(snackbarStore.isOpen).toEqual(true) + expect(snackbarStore.type).toEqual('info') + expect(snackbarStore.timeoutId).toBeDefined() + expect(vi.getTimerCount()).toEqual(1) + + snackbarStore.hideMessage() + + expect(snackbarStore.isOpen).toEqual(false) + expect(snackbarStore.timeoutId).toBeUndefined() + expect(vi.getTimerCount()).toEqual(0) + }) +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/snackbar.ts b/.jjconflict-side-2/apps/client/src/stores/snackbar.ts new file mode 100644 index 0000000000..badfb2e072 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/snackbar.ts @@ -0,0 +1,42 @@ +import type { ErrorTypes } from '@cpn-console/shared' +import type { Ref } from 'vue' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useSnackbarStore = defineStore('snackbar', () => { + const defaultTimeout: number = 6000 + const message: Ref = ref(undefined) + const isOpen: Ref = ref(false) + const type: Ref = ref('info') + const timeoutId: Ref | undefined> = ref(undefined) + const isWaitingForResponse = ref(false) + + const hideMessage = () => { + isOpen.value = false + clearTimeout(timeoutId.value) + timeoutId.value = undefined + } + + const setMessage = (errorMessage: string, errorType: ErrorTypes = 'info', timeout: number = defaultTimeout) => { + if (timeoutId.value) { + clearTimeout(timeoutId.value) + timeoutId.value = undefined + } + if (errorType !== 'error') { + timeoutId.value = setTimeout(hideMessage, timeout) + } + message.value = errorMessage + isOpen.value = true + type.value = errorType + } + + return { + message, + isOpen, + type, + timeoutId, + isWaitingForResponse, + setMessage, + hideMessage, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/stage.spec.ts b/.jjconflict-side-2/apps/client/src/stores/stage.spec.ts new file mode 100644 index 0000000000..542c5c4c1c --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/stage.spec.ts @@ -0,0 +1,96 @@ +import type { Stage } from '@cpn-console/shared' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useStageStore } from './stage.js' + +const apiClientListStages = vi.spyOn(apiClient.Stages, 'listStages') +const apiClientGet = vi.spyOn(apiClient.Stages, 'getStageEnvironments') +const apiClientPost = vi.spyOn(apiClient.Stages, 'createStage') +const apiClientPut = vi.spyOn(apiClient.Stages, 'updateStage') +const apiClientDelete = vi.spyOn(apiClient.Stages, 'deleteStage') + +describe('stage Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get stages list by api call', async () => { + const data = [ + { id: 'id1', name: 'dev' }, + { id: 'id2', name: 'int' }, + { id: 'id3', name: 'prod' }, + ] as Stage[] + apiClientListStages.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + await stageStore.getAllStages() + + expect(stageStore.stages).toEqual(data) + expect(apiClientListStages).toHaveBeenCalledTimes(1) + }) + + it('should get a stage\'s associated environments by api call', async () => { + const stageId = 'stageId' + const data = [ + { name: 'env1' }, + { name: 'env2' }, + { name: 'env3' }, + ] as { name: string, project: string, cluster: string, owner: string }[] + apiClientGet.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.getStageAssociatedEnvironments(stageId) + + expect(res).toBe(data) + expect(apiClientGet).toHaveBeenCalledTimes(1) + }) + + it('should create a stage by api call', async () => { + const data = { + name: 'int', + } as Stage + + apiClientPost.mockResolvedValueOnce({ status: 201, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.addStage(data) + + expect(res).toBe(data) + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update a stage\'s quotas and clusters associated by api call', async () => { + const stageId = 'stageId' + const quotaIds = ['stage1'] + const clusterIds = ['cluster1'] + const data = { + name: 'stageA', + quotaIds, + clusterIds, + id: stageId, + } + + apiClientPut.mockResolvedValueOnce({ status: 200, body: data, headers: null }) + const stageStore = useStageStore() + + const res = await stageStore.updateStage(stageId, { clusterIds, name: 'stageA' }) + + expect(res).toBe(data) + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete a stage by api call', async () => { + const stageId = 'stageId' + + apiClientDelete.mockResolvedValueOnce({ status: 204, body: null, headers: null }) + const stageStore = useStageStore() + + await stageStore.deleteStage(stageId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/stage.ts b/.jjconflict-side-2/apps/client/src/stores/stage.ts new file mode 100644 index 0000000000..de6d3824bf --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/stage.ts @@ -0,0 +1,45 @@ +import type { CreateStageBody, Stage, UpdateStageBody } from '@cpn-console/shared' +import { + + resourceListToDict, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useStageStore = defineStore('stage', () => { + const stages = ref([]) + const stagesById = computed(() => resourceListToDict(stages.value)) + + const getAllStages = async () => { + stages.value = await apiClient.Stages.listStages() + .then((response: any) => extractData(response, 200)) + return stages.value + } + + const getStageAssociatedEnvironments = (stageId: string) => + apiClient.Stages.getStageEnvironments({ params: { stageId } }) + .then((response: any) => extractData(response, 200)) + + const addStage = (body: CreateStageBody) => + apiClient.Stages.createStage({ body }) + .then((response: any) => extractData(response, 201)) + + const updateStage = (stageId: string, body: UpdateStageBody) => + apiClient.Stages.updateStage({ params: { stageId }, body }) + .then((response: any) => extractData(response, 200)) + + const deleteStage = (stageId: string) => + apiClient.Stages.deleteStage({ params: { stageId } }) + .then((response: any) => extractData(response, 204)) + + return { + stages, + stagesById, + getAllStages, + getStageAssociatedEnvironments, + addStage, + updateStage, + deleteStage, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/system-settings.spec.ts b/.jjconflict-side-2/apps/client/src/stores/system-settings.spec.ts new file mode 100644 index 0000000000..0cf6eca2eb --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/system-settings.spec.ts @@ -0,0 +1,55 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useSystemSettingsStore } from './system-settings.js' + +const listSystemSettings = vi.spyOn(apiClient.SystemSettings, 'listSystemSettings') +const upsertSystemSetting = vi.spyOn(apiClient.SystemSettings, 'upsertSystemSetting') + +describe('system Settings Store', () => { + beforeEach(() => { + vi.resetAllMocks() + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + + it('should get system settings list by api call', async () => { + const data = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'miom' }, + ] + listSystemSettings.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const systemSettingsStore = useSystemSettingsStore() + + await systemSettingsStore.listSystemSettings() + + expect(systemSettingsStore.systemSettings).toEqual(data) + expect(listSystemSettings).toHaveBeenCalledTimes(1) + }) + + it('should upsert a system setting by api call', async () => { + const data = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'miom' }, + ] + const newSystemSetting = { key: 'organisation', value: 'mj' } + const newData = [ + { key: 'maintenace', value: 'on' }, + { key: 'theme', value: 'dsfr' }, + { key: 'organisation', value: 'mj' }, + ] + + upsertSystemSetting.mockReturnValueOnce(Promise.resolve({ status: 201, body: newSystemSetting })) + const systemSettingsStore = useSystemSettingsStore() + systemSettingsStore.systemSettings = data + + const res = await systemSettingsStore.upsertSystemSetting(data) + + expect(res).toEqual(newSystemSetting) + expect(systemSettingsStore.systemSettings).toEqual(newData) + expect(upsertSystemSetting).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/system-settings.ts b/.jjconflict-side-2/apps/client/src/stores/system-settings.ts new file mode 100644 index 0000000000..8eebac262e --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/system-settings.ts @@ -0,0 +1,36 @@ +import type { + SystemSettings, + systemSettingsContract, + UpsertSystemSettingBody, +} from '@cpn-console/shared' +import { + resourceListToDictByKey, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useSystemSettingsStore = defineStore('systemSettings', () => { + const systemSettings = ref([]) + const systemSettingsByKey = computed(() => resourceListToDictByKey(systemSettings.value)) + + const listSystemSettings = async (query: typeof systemSettingsContract.listSystemSettings.query._type = {}) => { + systemSettings.value = await apiClient.SystemSettings.listSystemSettings(query) + .then((response: any) => extractData(response, 200)) + } + + const upsertSystemSetting = async (newSystemSetting: UpsertSystemSettingBody) => { + const res = await apiClient.SystemSettings.upsertSystemSetting({ body: newSystemSetting }) + .then((response: any) => extractData(response, 201)) + systemSettings.value = systemSettings.value + .toSpliced(systemSettings.value + .findIndex(systemSetting => systemSetting.key === res.key), 1, res) + return res + } + + return { + systemSettings, + systemSettingsByKey, + listSystemSettings, + upsertSystemSetting, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/token.ts b/.jjconflict-side-2/apps/client/src/stores/token.ts new file mode 100644 index 0000000000..d19b832333 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/token.ts @@ -0,0 +1,36 @@ +import type { + PersonalAccessToken, + personalAccessTokenContract, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '../api/xhr-client.js' + +export const useTokenStore = defineStore('token', () => { + const listPersonalAccessTokens = async () => { + return apiClient.PersonalAccessTokens.listPersonalAccessTokens().then( + (response: any) => extractData(response, 200), + ) + } + + const createPersonalAccessToken = async ( + body: typeof personalAccessTokenContract.createPersonalAccessToken.body._type, + ) => { + return apiClient.PersonalAccessTokens.createPersonalAccessToken({ + body, + }).then((res: any) => extractData(res, 201)) + } + + const deletePersonalAccessToken = async ( + tokenId: PersonalAccessToken['id'], + ) => { + await apiClient.PersonalAccessTokens.deletePersonalAccessToken({ + params: { tokenId }, + }).then((res: any) => extractData(res, 204)) + } + + return { + listPersonalAccessTokens, + createPersonalAccessToken, + deletePersonalAccessToken, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/user.spec.ts b/.jjconflict-side-2/apps/client/src/stores/user.spec.ts new file mode 100644 index 0000000000..8ca9e7a521 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/user.spec.ts @@ -0,0 +1,80 @@ +import Keycloak from 'keycloak-js' +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useUserStore } from './user.js' + +const authMock = vi.spyOn(apiClient.Users, 'auth') +const listAdminRoles = vi.spyOn(apiClient.AdminRoles, 'listAdminRoles') + +authMock.mockResolvedValue(Promise.resolve({ status: 200, body: {} })) +listAdminRoles.mockResolvedValue(Promise.resolve({ status: 200, body: [] })) + +vi.mock('keycloak-js', () => { + const Keycloak = vi.fn() + Keycloak.prototype.authenticated = true + Keycloak.prototype.idTokenParsed = { + email: 'michel.michel@test.com', + sub: 'userId', + given_name: 'Michel', + family_name: 'MICHEL', + groups: ['/admin'], + } + Keycloak.prototype.login = vi.fn() + Keycloak.prototype.logout = vi.fn() + + return { default: Keycloak } +}) +const keycloak = new Keycloak() + +describe('user Store', () => { + beforeEach(() => { + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + afterEach(() => { + vi.clearAllMocks() + }) + + it('should retrieve isLoggedIn from Keycloak (true)', async () => { + const userStore = useUserStore() + + expect(userStore.isLoggedIn).toBeUndefined() + + await userStore.setIsLoggedIn() + + expect(userStore.isLoggedIn).toEqual(true) + }) + + it('should retrieve userProfile from Keycloak', async () => { + const userStore = useUserStore() + + expect(userStore.userProfile).toMatchObject({}) + + userStore.setUserProfile() + + expect(userStore.userProfile).toMatchObject({ + email: 'michel.michel@test.com', + id: 'userId', + firstName: 'Michel', + lastName: 'MICHEL', + }) + }) + + it('should call Keycloak login function', async () => { + const userStore = useUserStore() + + await userStore.login() + + expect(keycloak.login).toBeCalledTimes(1) + }) + + it('should call Keycloak logout function', async () => { + const userStore = useUserStore() + + await userStore.logout() + + expect(keycloak.logout).toBeCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/user.ts b/.jjconflict-side-2/apps/client/src/stores/user.ts new file mode 100644 index 0000000000..f860e6d037 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/user.ts @@ -0,0 +1,58 @@ +import type { AdminRole, User, UserProfile } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' +import { getKeycloak, getUserProfile, keycloakLogin, keycloakLogout } from '@/utils/keycloak/keycloak.js' +import { useAdminRoleStore } from './admin-role.js' +import { useSystemSettingsStore } from './system-settings.js' + +export const useUserStore = defineStore('user', () => { + const adminRoleStore = useAdminRoleStore() + const systemSettingsStore = useSystemSettingsStore() + const isLoggedIn = ref() + const userProfile = ref() + const apiAuthInfos = ref() + + const myAdminRoles = computed(() => adminRoleStore.roles?.filter(adminRole => apiAuthInfos.value?.adminRoleIds.includes(adminRole.id)) ?? []) + + const adminPerms = computed(() => { + if (!apiAuthInfos.value) return null + const globalRoles = adminRoleStore.roles?.filter(role => role.type === 'global') ?? [] + const globalPerms = globalRoles.reduce((acc, role) => acc | BigInt(role.permissions), 0n) + const adminPerms = myAdminRoles.value.reduce((acc, role) => acc | BigInt(role.permissions), 0n) + return globalPerms | adminPerms + }) + + const setUserProfile = async () => { + userProfile.value = getUserProfile() + await systemSettingsStore.listSystemSettings().catch(() => undefined) + await apiClient.Users.auth() + .then((res: any) => apiAuthInfos.value = extractData(res, 200)) + } + + const setIsLoggedIn = async () => { + const keycloak = getKeycloak() + if (keycloak.authenticated !== isLoggedIn.value) { + isLoggedIn.value = keycloak.authenticated + if (isLoggedIn.value) { + await setUserProfile() + } + } + } + + const login = () => keycloakLogin() + + const logout = () => keycloakLogout() + + return { + isLoggedIn, + setIsLoggedIn, + userProfile, + apiAuthInfos, + myAdminRoles, + adminPerms, + setUserProfile, + login, + logout, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/users.ts b/.jjconflict-side-2/apps/client/src/stores/users.ts new file mode 100644 index 0000000000..4bacc644da --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/users.ts @@ -0,0 +1,23 @@ +import type { userContract } from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useUsersStore = defineStore('users', () => { + const listUsers = async (query: typeof userContract.getAllUsers.query._input) => + apiClient.Users.getAllUsers({ query }) + .then((res: any) => extractData(res, 200)) + + const listMatchingUsers = async (query: typeof userContract.getMatchingUsers.query._type) => + apiClient.Users.getMatchingUsers({ query }) + .then((res: any) => extractData(res, 200)) + + const patchUsers = async (body: typeof userContract.patchUsers.body._type) => + apiClient.Users.patchUsers({ body }) + .then((res: any) => extractData(res, 200)) + + return { + listUsers, + listMatchingUsers, + patchUsers, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/zone.spec.ts b/.jjconflict-side-2/apps/client/src/stores/zone.spec.ts new file mode 100644 index 0000000000..087ee45034 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/zone.spec.ts @@ -0,0 +1,67 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../api/xhr-client.js' +import { useZoneStore } from './zone.js' + +const apiClientGet = vi.spyOn(apiClient.Zones, 'listZones') +const apiClientPost = vi.spyOn(apiClient.Zones, 'createZone') +const apiClientPut = vi.spyOn(apiClient.Zones, 'updateZone') +const apiClientDelete = vi.spyOn(apiClient.Zones, 'deleteZone') + +describe('zone Store', () => { + beforeEach(() => { + // creates a fresh pinia and make it active so it's automatically picked + // up by any useStore() call without having to pass it to it: `useStore(pinia)` + setActivePinia(createPinia()) + }) + afterEach(() => { + vi.clearAllMocks() + }) + + it('should retrieve all zones', async () => { + const zoneStore = useZoneStore() + + expect(zoneStore.zones).toEqual([]) + + const zones = [{ id: 'zoneId' }, { id: 'anotherZoneId' }] + apiClientGet.mockReturnValueOnce(Promise.resolve({ status: 200, body: zones })) + + await zoneStore.getAllZones() + + expect(apiClientGet).toHaveBeenCalledTimes(1) + expect(zoneStore.zones).toMatchObject(zones) + }) + + it('should create a new zone', async () => { + const adminZoneStore = useZoneStore() + + const zone = { label: 'Zone à défendre', slug: 'zad' } + apiClientPost.mockReturnValueOnce(Promise.resolve({ status: 201, data: zone })) + + await adminZoneStore.createZone(zone) + + expect(apiClientPost).toHaveBeenCalledTimes(1) + }) + + it('should update a zone', async () => { + const adminZoneStore = useZoneStore() + + const zone = { id: 'zoneId', label: 'Zod à update' } + apiClientPut.mockReturnValueOnce(Promise.resolve({ status: 200, data: zone })) + + await adminZoneStore.updateZone(zone.id, zone) + + expect(apiClientPut).toHaveBeenCalledTimes(1) + }) + + it('should delete a zone', async () => { + const adminZoneStore = useZoneStore() + + const zoneId = 'zoneId' + apiClientDelete.mockReturnValueOnce(Promise.resolve({ status: 204, data: null })) + + await adminZoneStore.deleteZone(zoneId) + + expect(apiClientDelete).toHaveBeenCalledTimes(1) + }) +}) diff --git a/.jjconflict-side-2/apps/client/src/stores/zone.ts b/.jjconflict-side-2/apps/client/src/stores/zone.ts new file mode 100644 index 0000000000..0c81a3e0f1 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/stores/zone.ts @@ -0,0 +1,40 @@ +import type { CreateZoneBody, UpdateZoneBody, Zone } from '@cpn-console/shared' +import { + + resourceListToDict, +} from '@cpn-console/shared' +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { apiClient, extractData } from '@/api/xhr-client.js' + +export const useZoneStore = defineStore('zone', () => { + const zones = ref([]) + const zonesById = computed(() => resourceListToDict(zones.value)) + + const getAllZones = async () => { + zones.value = await apiClient.Zones.listZones() + .then((response: any) => extractData(response, 200)) + return zones.value + } + + const createZone = (body: CreateZoneBody) => + apiClient.Zones.createZone({ body }) + .then((response: any) => extractData(response, 201)) + + const updateZone = (zoneId: Zone['id'], data: UpdateZoneBody) => + apiClient.Zones.updateZone({ body: data, params: { zoneId } }) + .then((response: any) => extractData(response, 200)) + + const deleteZone = (zoneId: Zone['id']) => + apiClient.Zones.deleteZone({ params: { zoneId } }) + .then((response: any) => extractData(response, 204)) + + return { + zones, + zonesById, + getAllZones, + createZone, + updateZone, + deleteZone, + } +}) diff --git a/.jjconflict-side-2/apps/client/src/utils/env.ts b/.jjconflict-side-2/apps/client/src/utils/env.ts new file mode 100644 index 0000000000..189eb7143e --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/utils/env.ts @@ -0,0 +1,20 @@ +/* eslint-disable no-template-curly-in-string */ +export const serverHost: string = process.env.SERVER_HOST ?? '${SERVER_HOST}' + +export const serverPort: string = process.env.SERVER_PORT ?? '${SERVER_PORT}' + +export const clientPort: string = process.env.CLIENT_PORT ?? '${CLIENT_PORT}' + +export const keycloakProtocol: string = process.env.KEYCLOAK_PROTOCOL ?? '${KEYCLOAK_PROTOCOL}' + +export const keycloakDomain: string = process.env.KEYCLOAK_DOMAIN ?? '${KEYCLOAK_DOMAIN}' + +export const keycloakRealm: string = process.env.KEYCLOAK_REALM ?? '${KEYCLOAK_REALM}' + +export const keycloakClientId: string = process.env.KEYCLOAK_CLIENT_ID ?? '${KEYCLOAK_CLIENT_ID}' + +export const keycloakRedirectUri: string = process.env.KEYCLOAK_REDIRECT_URI ?? '${KEYCLOAK_REDIRECT_URI}' + +export const contactEmail: string = process.env.CONTACT_EMAIL ?? '${CONTACT_EMAIL}' + +export const openCDSEnabled: string = process.env.OPENCDS_ENABLED ?? '${OPENCDS_ENABLED}' diff --git a/.jjconflict-side-2/apps/client/src/utils/func.spec.ts b/.jjconflict-side-2/apps/client/src/utils/func.spec.ts new file mode 100644 index 0000000000..75ef96cead --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/utils/func.spec.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('localeParseFloat EN tests', () => { + let localeParseFloatEN: (s: string) => number + beforeEach(async () => { + vi.resetModules() + vi.resetAllMocks() + Object.defineProperty(navigator, 'language', { + value: 'en-EN', + configurable: true, + }); + ({ localeParseFloat: localeParseFloatEN } = await import('./func.js')) + }) + it('should parse invalid float FR in locale EN', async () => { + const result = localeParseFloatEN('4,25') + expect(result).toBe(425) + }) + + it('should parse valid float EN in locale EN', async () => { + const result = localeParseFloatEN('4.25') + expect(result).toBe(4.25) + }) +}) + +describe('localeParseFloat FR tests', () => { + let localeParseFloatFR: (s: string) => number + beforeEach(async () => { + vi.resetModules() + vi.resetAllMocks() + Object.defineProperty(navigator, 'language', { + value: 'fr-FR', + configurable: true, + }); + ({ localeParseFloat: localeParseFloatFR } = await import('./func.js')) + }) + it('should parse valid float FR in locale FR', async () => { + const result = localeParseFloatFR('4,25') + expect(result).toBe(4.25) + }) + it('should parse valid float EN in locale FR', async () => { + const result = localeParseFloatFR('4.25') + expect(result).toBe(4.25) + }) +}) diff --git a/.jjconflict-side-2/apps/client/src/utils/func.ts b/.jjconflict-side-2/apps/client/src/utils/func.ts new file mode 100644 index 0000000000..71f1a61b5e --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/utils/func.ts @@ -0,0 +1,103 @@ +import type { Ref } from 'vue' +import { useSnackbarStore } from '@/stores/snackbar.js' + +const LOCALE = navigator.language.slice(0, 2) +// Get the thousands and decimal separator characters used in the locale. +const [,THOUSANDS_SEPARATOR,,,,DECIMAL_SEPARATOR] = 1111.1.toLocaleString(LOCALE) +// 0.1 number as local string to use in labels and placeholders +export const ONE_TENTH_STR = 0.1.toLocaleString() + +export async function copyContent(content: string): Promise { + const snackbarStore = useSnackbarStore() + try { + await navigator.clipboard.writeText(content) + snackbarStore.setMessage('Donnée copiée', 'success') + } catch (error: any) { + snackbarStore.setMessage(error?.message, 'error') + } +} + +export function toCodeComponent(value: string) { + return { + component: 'code', + text: value, + title: 'Copier la valeur', + class: 'fr-text-default--info text-xs cursor-pointer', + onClick: () => copyContent(value), + } +} + +const maxDescriptionLength = 60 +export function truncateDescription(description: string) { + let innerHTML: string + + if (description.length <= maxDescriptionLength) { + innerHTML = description + } else { + const lastSpaceIndex = description.slice(0, maxDescriptionLength).lastIndexOf(' ') + innerHTML = `${description.slice(0, lastSpaceIndex > 0 ? lastSpaceIndex : maxDescriptionLength)} ...` + } + + return { + id: 'description', + 'data-testid': 'description', + component: 'span', + open: false, + title: description, + innerHTML, + } +} + +export function clickInDialog(e?: MouseEvent | TouchEvent, fn?: () => void) { + // @ts-ignore + if (e && e.target?.tagName !== 'DIALOG') { + return + } + fn?.() +} + +function randomId() { + String.fromCharCode(97) + return (Array.from({ length: 6 })).map(() => String.fromCharCode(97 + Math.floor(Math.random() * 26))) +} +export function getRandomId(suffix?: string, prefix?: string) { + return (prefix ? (`${prefix}-`) : '') + randomId() + (suffix ? (`-${suffix}`) : '') +} + +export function toKebabCase(value: string) { + return value + .trim() + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +/** + * Replace current locale separators occurences before parsing a floating Number. + * See https://stackoverflow.com/a/59679285 + * @param s String to parse + * @returns Number + */ +export function localeParseFloat(s: string): number { + // Remove thousand separators, and put a point where the decimal separator occurs + const delocalizedInput = s.replaceAll(THOUSANDS_SEPARATOR, '').replaceAll(DECIMAL_SEPARATOR, '.') + // Now it can be parsed + return Number.parseFloat(delocalizedInput) +} + +export async function scrollToFirstError(container: Ref) { + await nextTick() + + if (!container.value) return + + const firstErrorElement = container.value.querySelector('.fr-error-text, .fr-input-group--error') + + if (firstErrorElement) { + firstErrorElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }) + } +} diff --git a/.jjconflict-side-2/apps/client/src/utils/keycloak/keycloak.spec.ts b/.jjconflict-side-2/apps/client/src/utils/keycloak/keycloak.spec.ts new file mode 100644 index 0000000000..63760697cf --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/utils/keycloak/keycloak.spec.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getKeycloak, getUserProfile, keycloakInit, keycloakLogin, keycloakLogout } from './keycloak.js' + +const userToken = { + email: 'test@test.com', + sub: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + given_name: 'Jean', + family_name: 'DUPOND', +} +const userStored = { + email: 'test@test.com', + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', +} +const keycloak = getKeycloak() + +vi.mock('keycloak-js', () => { + class Keycloak { + idTokenParsed: Record + init: () => void + login: () => void + logout: () => void + + constructor() { + this.idTokenParsed = userToken + this.init = vi.fn() + this.login = vi.fn() + this.logout = vi.fn() + } + } + return { default: Keycloak } +}) + +const mockedKeycloakInit = vi.spyOn(keycloak, 'init') +const mockedKeycloakLogin = vi.spyOn(keycloak, 'login') +const mockedKeycloakLogout = vi.spyOn(keycloak, 'logout') + +describe('keycloak-init', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return Keycloak instance', () => { + const keycloak = getKeycloak() + + expect(keycloak).toBeInstanceOf(Object) + }) + + it('should return keycloak user profile', () => { + const keycloakUser = getUserProfile() + + expect(keycloakUser).toBeInstanceOf(Object) + expect(keycloakUser).toMatchObject(userStored) + }) + + it('should init keycloak', async () => { + await keycloakInit() + + expect(mockedKeycloakInit.mock.calls).toHaveLength(1) + }) + + it('should throw an error if keycloak can\'t be initialize', async () => { + const error = new Error('Failed to init keycloak') + try { + mockedKeycloakInit.mockReturnValueOnce(Promise.reject(error)) + await keycloakInit() + } catch (e) { + expect(mockedKeycloakInit.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) + + it('should start login process to keycloak', async () => { + await keycloakLogin() + + expect(mockedKeycloakLogin.mock.calls).toHaveLength(1) + }) + + it('should throw an error if login process to keycloak failed', async () => { + const error = new Error('Failed to start login process keycloak') + try { + mockedKeycloakLogin.mockReturnValueOnce(Promise.reject(error)) + await keycloakLogin() + } catch (e) { + expect(mockedKeycloakLogin.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) + + it('should start logout process to keycloak', async () => { + await keycloakLogout() + + expect(mockedKeycloakLogout.mock.calls).toHaveLength(1) + }) + + it('should throw an error if logout process to keycloak failed', async () => { + const error = new Error('Failed to start logout process keycloak') + try { + mockedKeycloakLogout.mockReturnValueOnce(Promise.reject(error)) + await keycloakLogout() + } catch (e) { + expect(mockedKeycloakLogout.mock.calls).toHaveLength(1) + expect(e).toEqual(error) + } + }) +}) diff --git a/.jjconflict-side-2/apps/client/src/utils/keycloak/keycloak.ts b/.jjconflict-side-2/apps/client/src/utils/keycloak/keycloak.ts new file mode 100644 index 0000000000..2d36a097c5 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/utils/keycloak/keycloak.ts @@ -0,0 +1,101 @@ +import type { UserProfile } from '@cpn-console/shared' +import type { KeycloakConfig, KeycloakInitOptions } from 'keycloak-js' +import { logger } from '@cpn-console/logger/browser' +import Keycloak from 'keycloak-js' +import { + keycloakClientId, + keycloakDomain, + keycloakProtocol, + keycloakRealm, + keycloakRedirectUri, +} from '../env.js' + +export const keycloakInitOptions: KeycloakInitOptions = { + onLoad: 'check-sso', + flow: 'standard', + redirectUri: keycloakRedirectUri, +} + +export const keycloakConfig: KeycloakConfig = { + url: `${keycloakProtocol}://${keycloakDomain}`, + realm: keycloakRealm, + clientId: keycloakClientId, +} + +let keycloak: Keycloak + +export function getKeycloak() { + if (!keycloak) { + keycloak = new Keycloak(keycloakConfig) + keycloak.onAuthSuccess = () => { + if (!(keycloak.refreshTokenParsed?.exp && keycloak.tokenParsed?.exp && keycloak.refreshTokenParsed.exp > keycloak.tokenParsed.exp)) { + return + } + logger.warn('Keycloak misconfiguration: refreshToken should not expire before token') + const refreshTokenDelay = (keycloak.tokenParsed.exp * 1000 - Date.now()) / 2 + setTimeout(() => { + keycloak.updateToken() + }, refreshTokenDelay) + } + keycloak.onTokenExpired = () => { + keycloak.updateToken(30) + } + } + return keycloak +} + +export function getUserProfile(): UserProfile { + try { + const keycloak = getKeycloak() + const { email, sub: id, given_name: firstName, family_name: lastName, groups } = keycloak.idTokenParsed as { email: string, sub: string, given_name: string, firstName: string, family_name: string, lastName: string, groups: string[] } + return { + email, + id, + firstName, + lastName, + groups, + } + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de récupération du profil keycloak de l\'utilisateur') + } +} + +export async function keycloakInit() { + const currentUrl = new URL(window.location.href) + const redirectUri = `${window.location.origin}${currentUrl.pathname}${currentUrl.search}` + try { + const { onLoad, flow } = keycloakInitOptions + const keycloak = getKeycloak() + await keycloak.init({ + onLoad, + flow, + redirectUri, + }) + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec d\'initialisation du keycloak') + } +} + +export async function keycloakLogin() { + try { + const keycloak = getKeycloak() + const currentUrl = new URL(window.location.href) + const redirectUri = `${window.location.origin}${currentUrl.pathname}${currentUrl.search}` + await keycloak.login({ redirectUri }) + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de connexion au keycloak') + } +} + +export async function keycloakLogout() { + try { + const keycloak = getKeycloak() + await keycloak.logout() + } catch (error) { + if (error instanceof Error) throw new Error(error.message) + throw new Error('échec de déconnexion du keycloak') + } +} diff --git a/.jjconflict-side-2/apps/client/src/utils/project-utils.ts b/.jjconflict-side-2/apps/client/src/utils/project-utils.ts new file mode 100644 index 0000000000..e45746f660 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/utils/project-utils.ts @@ -0,0 +1,414 @@ +import type { + CreateDeploymentBody, + CreateEnvironmentBody, + CreateRepositoryBody, + Deployment, + Environment, + GetLogsQuery, + PermissionTarget, + PluginsUpdateBody, + projectContract, + projectMemberContract, + projectRoleContract, + ProjectService, + ProjectV2, + Repo, + RepositoryParams, + Role, + UpdateDeploymentBody, + UpdateEnvironmentBody, + UpdateRepositoryBody, + User, +} from '@cpn-console/shared' +import type { Ref } from 'vue' +import { + getPermsByUserRoles, + objectKeys, + PROJECT_PERMS, + resourceListToDict, +} from '@cpn-console/shared' + +import { + apiClient, + extractData, +} from '@/api/xhr-client.js' +import { useLogStore } from '@/stores/log.js' +import { useUserStore } from '@/stores/user.js' +import { getRandomId } from './func.js' + +export type ProjectOperations = 'create' + | 'delete' + | 'deploymentManagement' + | 'envManagement' + | 'repoManagement' + | 'teamManagement' + | 'searchSecret' + | 'replay' + | 'update' + | 'lockHandling' + | 'saveServices' + | 'roleManagement' + +function calculateProjectPerms(project: ProjectV2 | undefined, userId: string | undefined) { + if (!project || !userId) return 0n + if (userId === project?.ownerId) return PROJECT_PERMS.MANAGE + const selfMember = project.members.find(member => member.userId === userId) + if (!selfMember) return 0n + + return getPermsByUserRoles(selfMember.roleIds, resourceListToDict(project.roles), project.everyonePerms) +} + +export class Project implements ProjectV2 { + id: string + clusterIds: string[] + description: string | undefined + everyonePerms: string + name: string + slug: string + locked: boolean + owner: Omit + ownerId: string + roles: { id: string, name: string, permissions: string, position: number, projectId: string, oidcGroup?: string, type?: string }[] + members: ({ userId: string, firstName: string, lastName: string, email: string, roleIds: string[] } | { updatedAt: string, createdAt: string, firstName: string, lastName: string, email: string, userId: string, roleIds: string[] })[] + createdAt: string + updatedAt: string + limitless: boolean + hprodMemory: number + hprodCpu: number + hprodGpu: number + prodMemory: number + prodCpu: number + prodGpu: number + status: ProjectV2['status'] + operationsInProgress: Ref + myPerms: bigint + repositories: Ref + environments: Ref + deployments: Ref + services: ProjectService[] = [] + lastSuccessProvisionningVersion: string | null + needReplay: Ref + + constructor(project: ProjectV2) { + this.id = project.id + this.clusterIds = project.clusterIds + this.description = project.description + this.everyonePerms = project.everyonePerms + this.name = project.name + this.slug = project.slug + this.locked = project.locked + this.owner = project.owner + this.ownerId = project.ownerId + this.roles = project.roles + this.members = project.members + this.createdAt = project.createdAt + this.updatedAt = project.updatedAt + this.limitless = project.limitless + this.hprodMemory = project.hprodMemory + this.hprodCpu = project.hprodCpu + this.hprodGpu = project.hprodGpu + this.prodMemory = project.prodMemory + this.prodCpu = project.prodCpu + this.prodGpu = project.prodGpu + this.lastSuccessProvisionningVersion = project.lastSuccessProvisionningVersion + this.status = project.status + this.myPerms = calculateProjectPerms(this, useUserStore().userProfile?.id) + this.operationsInProgress = ref([]) + this.environments = ref([]) + this.repositories = ref([]) + this.needReplay = ref(false) + this.deployments = ref([]) + } + + private removeOperation(operationName: ProjectOperations) { + setTimeout(() => { + useLogStore().needRefresh = true + }, 100) + this.operationsInProgress.value = this.operationsInProgress.value.filter(op => op !== operationName) + return this.operationsInProgress.value + } + + private addOperation(operationName: ProjectOperations): () => void { + this.needReplay.value = ['teamManagement', 'saveServices', 'roleManagement'].includes(operationName) + if (this.operationsInProgress.value.includes(operationName)) { + operationName += getRandomId() + } + if (this.operationsInProgress.value.length <= 1) { + this.operationsInProgress.value.push(operationName) + } else { + return () => {} + } + return () => { this.removeOperation(operationName) } + } + + private computePerms() { + this.myPerms = calculateProjectPerms(this, useUserStore().userProfile?.id) + } + + Commands = { + update: async (data: typeof projectContract.updateProject.body._type) => { + const callback = this.addOperation('update') + try { + const project = await apiClient.Projects.updateProject({ body: data, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + .finally(() => callback()) + return this.Commands.updateData(project) + } finally { + callback() + } + }, + updateData: (project: Partial) => { + for (const key of objectKeys(project)) { + // @ts-ignore + this[key] = project[key] + } + this.computePerms() + return undefined + }, + refresh: async () => { + const project = await apiClient.Projects.getProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.Commands.updateData(project) + await Promise.all([ + this.Repositories.list(), + this.Environments.list(), + ]) + return undefined + }, + replay: async () => { + const callback = this.addOperation('update') + try { + await apiClient.Projects.replayHooksForProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 204)) + return this.Commands.refresh() + } finally { + callback() + } + }, + delete: async () => { + const callback = this.addOperation('delete') + try { + await apiClient.Projects.archiveProject({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 204)) + this.status = 'archived' + } catch { + await this.Commands.refresh() + } finally { + callback() + } + }, + } + + Members = { + list: async () => { + this.members = await apiClient.ProjectsMembers.listMembers({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.members + }, + create: async (email: string) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.addMember({ params: { projectId: this.id }, body: { email } }) + .then((response: any) => extractData(response, 201)) + return this.Members.list() + } finally { callback() } + }, + delete: async (userId: User['id']) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.removeMember({ params: { projectId: this.id, userId } }) + .then((response: any) => extractData(response, 200)) + return this.Members.list() + } finally { callback() } + }, + patch: async (body: typeof projectMemberContract.patchMembers.body._type) => { + const callback = this.addOperation('teamManagement') + try { + await apiClient.ProjectsMembers.patchMembers({ params: { projectId: this.id }, body }) + .then((response: any) => extractData(response, 200)) + return this.Members.list() + } finally { callback() } + }, + getCandidateUsers: async (letters: string) => { + return apiClient.Users.getMatchingUsers({ query: { letters, notInProjectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + } + + Deployments = { + list: async () => { + this.deployments.value = await apiClient.Deployments.listDeployments({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.deployments.value + }, + create: async (deploymentData: Omit) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.createDeployment({ + params: { projectId: this.id }, + body: { ...deploymentData, projectId: this.id }, + }) + .then((response: any) => extractData(response, 201)) + } finally { callback() } + }, + update: async (id: Deployment['id'], deployment: UpdateDeploymentBody) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.updateDeployment({ + params: { projectId: this.id, deploymentId: id }, + body: deployment, + }) + .then((response: any) => extractData(response, 200)) + } finally { callback() } + }, + delete: async (deploymentId: Deployment['id']) => { + const callback = this.addOperation('deploymentManagement') + try { + await apiClient.Deployments.deleteDeployment({ + params: { projectId: this.id, deploymentId }, + }) + .then((response: any) => extractData(response, 204)) + } finally { callback() } + }, + } + + Environments = { + list: async () => { + this.environments.value = await apiClient.Environments.listEnvironments({ query: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.environments.value + }, + create: async (envData: Omit) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.createEnvironment({ body: { ...envData, projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + return this.Environments.list() + } finally { callback() } + }, + update: async (id: Environment['id'], environment: UpdateEnvironmentBody) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.updateEnvironment({ body: environment, params: { environmentId: id } }) + .then((response: any) => extractData(response, 200)) + await this.Environments.list() + return this.environments + } finally { callback() } + }, + delete: async (environmentId: Environment['id']) => { + const callback = this.addOperation('envManagement') + try { + await apiClient.Environments.deleteEnvironment({ params: { environmentId } }) + .then((response: any) => extractData(response, 204)) + await this.Environments.list() + return this.environments + } finally { callback() } + }, + } + + Repositories = { + list: async () => { + this.repositories.value = await apiClient.Repositories.listRepositories({ query: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + return this.repositories.value + }, + sync: async (repositoryId: Repo['id'], { branchName, syncAllBranches = false }: { branchName?: string, syncAllBranches?: boolean }) => { + const callback = this.addOperation('repoManagement') + try { + return apiClient.Repositories.syncRepository({ + params: { repositoryId }, + body: { branchName, syncAllBranches }, + }) + .then((response: any) => extractData(response, 204)) + } finally { callback() } + }, + create: async (repoData: Omit) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.createRepository({ body: { ...repoData, projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + return this.Repositories.list() + } finally { callback() } + }, + update: async (id: RepositoryParams['repositoryId'], repoData: Omit) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.updateRepository({ body: { ...repoData, projectId: this.id }, params: { repositoryId: id } }) + .then((response: any) => extractData(response, 200)) + return this.Repositories.list() + } finally { callback() } + }, + delete: async (repositoryId: Repo['id']) => { + const callback = this.addOperation('repoManagement') + try { + await apiClient.Repositories.deleteRepository({ params: { repositoryId } }) + .then((response: any) => extractData(response, 204)) + return this.Repositories.list() + } finally { callback() } + }, + } + + Roles = { + countMembers: async () => { + return apiClient.ProjectsRoles.projectRoleMemberCounts({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + list: async () => { + this.roles = await apiClient.ProjectsRoles.listProjectRoles({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.computePerms() + return this.roles + }, + patch: async (body: typeof projectRoleContract.patchProjectRoles.body._type) => { + const callback = this.addOperation('roleManagement') + try { + this.roles = await apiClient.ProjectsRoles.patchProjectRoles({ body, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + this.computePerms() + return this.roles + } finally { callback() } + }, + create: async (body: typeof projectRoleContract.createProjectRole.body._type) => { + this.roles = await apiClient.ProjectsRoles.createProjectRole({ body, params: { projectId: this.id } }) + .then((response: any) => extractData(response, 201)) + this.computePerms() + return this.roles + }, + delete: async (roleId: Role['id']) => { + const callback = this.addOperation('roleManagement') + try { + await apiClient.ProjectsRoles.deleteProjectRole({ params: { projectId: this.id, roleId } }) + .then((response: any) => extractData(response, 204)) + this.computePerms() + return this.Roles.list() + } finally { callback() } + }, + } + + Services = { + getSecrets: async () => { + return apiClient.Projects.getProjectSecrets({ params: { projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + list: async (permissionTarget: PermissionTarget = 'user') => { + this.services = await apiClient.ProjectServices.getServices({ params: { projectId: this.id }, query: { permissionTarget } }) + .then((response: any) => extractData(response, 200)) + return this.services + }, + update: async (body: PluginsUpdateBody) => { + const callback = this.addOperation('saveServices') + try { + await apiClient.ProjectServices.updateProjectServices({ params: { projectId: this.id }, body }) + .then((response: any) => extractData(response, 204)) + return this.Services.list() + } finally { callback() } + }, + } + + Logs = { + list: async ({ offset, limit, clean }: GetLogsQuery = { offset: 0, limit: 10 }) => { + return apiClient.Logs.getLogs({ query: { offset, limit, clean, projectId: this.id } }) + .then((response: any) => extractData(response, 200)) + }, + } +} diff --git a/.jjconflict-side-2/apps/client/src/utils/regex.spec.ts b/.jjconflict-side-2/apps/client/src/utils/regex.spec.ts new file mode 100644 index 0000000000..353338ee8c --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/utils/regex.spec.ts @@ -0,0 +1,25 @@ +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { email, noSpace, uuid } from '@/utils/regex.js' + +describe('regex', () => { + it('should return false (noSpace)', () => { + expect(noSpace.test('te st')).toStrictEqual(false) + }) + it('should return true (noSpace)', () => { + expect(noSpace.test('test')).toStrictEqual(true) + }) + + it('should return false (email)', () => { + expect(email.test('prenom.nom')).toStrictEqual(false) + }) + it('should return true (email)', () => { + expect(email.test('prenom.nom@interieur.gouv.fr')).toStrictEqual(true) + }) + it('should return true (uuid)', () => { + expect(uuid.test(faker.string.uuid())).toStrictEqual(true) + }) + it('should return false (uuid)', () => { + expect(uuid.test('abc')).toStrictEqual(false) + }) +}) diff --git a/.jjconflict-side-2/apps/client/src/utils/regex.ts b/.jjconflict-side-2/apps/client/src/utils/regex.ts new file mode 100644 index 0000000000..38ded36071 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/utils/regex.ts @@ -0,0 +1,5 @@ +export const noSpace: RegExp = /^\S*$/ + +export const email: RegExp = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$/ + +export const uuid: RegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i diff --git a/.jjconflict-side-2/apps/client/src/views/CreateProject.vue b/.jjconflict-side-2/apps/client/src/views/CreateProject.vue new file mode 100644 index 0000000000..797df6602d --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/CreateProject.vue @@ -0,0 +1,148 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/DsoHome.vue b/.jjconflict-side-2/apps/client/src/views/DsoHome.vue new file mode 100644 index 0000000000..6e141e518b --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/DsoHome.vue @@ -0,0 +1,183 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/DsoMaintenance.vue b/.jjconflict-side-2/apps/client/src/views/DsoMaintenance.vue new file mode 100644 index 0000000000..e220e5965c --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/DsoMaintenance.vue @@ -0,0 +1,36 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/NotFound.vue b/.jjconflict-side-2/apps/client/src/views/NotFound.vue new file mode 100644 index 0000000000..a0e59fb9cf --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/NotFound.vue @@ -0,0 +1,22 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/ProjectDashboard.vue b/.jjconflict-side-2/apps/client/src/views/ProjectDashboard.vue new file mode 100644 index 0000000000..d411ed7e78 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/ProjectDashboard.vue @@ -0,0 +1,344 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/ServicesHealth.vue b/.jjconflict-side-2/apps/client/src/views/ServicesHealth.vue new file mode 100644 index 0000000000..ca9f021061 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/ServicesHealth.vue @@ -0,0 +1,107 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/admin/AdminCluster.vue b/.jjconflict-side-2/apps/client/src/views/admin/AdminCluster.vue new file mode 100644 index 0000000000..fe87036105 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/admin/AdminCluster.vue @@ -0,0 +1,101 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/admin/AdminRoles.vue b/.jjconflict-side-2/apps/client/src/views/admin/AdminRoles.vue new file mode 100644 index 0000000000..3c4357c340 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/admin/AdminRoles.vue @@ -0,0 +1,127 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/admin/AdminServiceChain.vue b/.jjconflict-side-2/apps/client/src/views/admin/AdminServiceChain.vue new file mode 100644 index 0000000000..cee1e4baa2 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/admin/AdminServiceChain.vue @@ -0,0 +1,42 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/admin/AdminTokens.vue b/.jjconflict-side-2/apps/client/src/views/admin/AdminTokens.vue new file mode 100644 index 0000000000..e4cb9e9e6c --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/admin/AdminTokens.vue @@ -0,0 +1,147 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/admin/DsoAdmin.vue b/.jjconflict-side-2/apps/client/src/views/admin/DsoAdmin.vue new file mode 100644 index 0000000000..415c8d7276 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/admin/DsoAdmin.vue @@ -0,0 +1,14 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/admin/ListClusters.vue b/.jjconflict-side-2/apps/client/src/views/admin/ListClusters.vue new file mode 100644 index 0000000000..7074ceee8a --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/admin/ListClusters.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/views/admin/ListLogs.vue b/.jjconflict-side-2/apps/client/src/views/admin/ListLogs.vue new file mode 100644 index 0000000000..2fab771995 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/admin/ListLogs.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/views/admin/ListPlugins.vue b/.jjconflict-side-2/apps/client/src/views/admin/ListPlugins.vue new file mode 100644 index 0000000000..250e3938ec --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/admin/ListPlugins.vue @@ -0,0 +1,140 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/admin/ListProjects.vue b/.jjconflict-side-2/apps/client/src/views/admin/ListProjects.vue new file mode 100644 index 0000000000..171f4ea557 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/admin/ListProjects.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/views/admin/ListServiceChains.vue b/.jjconflict-side-2/apps/client/src/views/admin/ListServiceChains.vue new file mode 100644 index 0000000000..2894d83443 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/admin/ListServiceChains.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/src/views/admin/ListStages.vue b/.jjconflict-side-2/apps/client/src/views/admin/ListStages.vue new file mode 100644 index 0000000000..060aec61fb --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/admin/ListStages.vue @@ -0,0 +1,169 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/admin/ListUser.vue b/.jjconflict-side-2/apps/client/src/views/admin/ListUser.vue new file mode 100644 index 0000000000..6fd88db2d3 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/admin/ListUser.vue @@ -0,0 +1,261 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/admin/ListZones.vue b/.jjconflict-side-2/apps/client/src/views/admin/ListZones.vue new file mode 100644 index 0000000000..18698cadfa --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/admin/ListZones.vue @@ -0,0 +1,173 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/admin/SystemSettings.vue b/.jjconflict-side-2/apps/client/src/views/admin/SystemSettings.vue new file mode 100644 index 0000000000..8054ef29f5 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/admin/SystemSettings.vue @@ -0,0 +1,29 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/profile/PersonalAccessTokens.vue b/.jjconflict-side-2/apps/client/src/views/profile/PersonalAccessTokens.vue new file mode 100644 index 0000000000..f97b54cb59 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/profile/PersonalAccessTokens.vue @@ -0,0 +1,128 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/profile/ProfileWrapper.vue b/.jjconflict-side-2/apps/client/src/views/profile/ProfileWrapper.vue new file mode 100644 index 0000000000..d31d761dfd --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/profile/ProfileWrapper.vue @@ -0,0 +1,7 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/profile/UserInfo.vue b/.jjconflict-side-2/apps/client/src/views/profile/UserInfo.vue new file mode 100644 index 0000000000..c3b9e4a1b5 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/profile/UserInfo.vue @@ -0,0 +1,66 @@ + + + diff --git a/.jjconflict-side-2/apps/client/src/views/projects/DsoProjects.vue b/.jjconflict-side-2/apps/client/src/views/projects/DsoProjects.vue new file mode 100644 index 0000000000..d4174d1337 --- /dev/null +++ b/.jjconflict-side-2/apps/client/src/views/projects/DsoProjects.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/.jjconflict-side-2/apps/client/tsconfig.eslint.json b/.jjconflict-side-2/apps/client/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-2/apps/client/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-2/apps/client/tsconfig.json b/.jjconflict-side-2/apps/client/tsconfig.json new file mode 100644 index 0000000000..7f8a3442bf --- /dev/null +++ b/.jjconflict-side-2/apps/client/tsconfig.json @@ -0,0 +1,62 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + + "jsx": "preserve", + "lib": ["DOM", "ESNext"], + // Base + "baseUrl": "./", + "rootDir": "./src", + "module": "Preserve", + "moduleResolution": "Bundler", + "paths": { + "@/*": [ + "src/*" + ] + }, + "resolveJsonModule": true, + "types": [ + "vitest", + "vite/client", + "vite-plugin-pwa/vue", + "vite-plugin-pwa/client" + ], + "allowImportingTsExtensions": false, + "allowJs": true, + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useUnknownInCatchVariables": true, + "declaration": true, + "declarationDir": "./types", + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "./dist", + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "vueCompilerOptions": {}, + "include": [ + "./src/**/*.ts", + "./src/**/*.js", + "./src/**/*.vue", + "./src/**/*.d.ts" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-2/apps/client/uno.config.ts b/.jjconflict-side-2/apps/client/uno.config.ts new file mode 100644 index 0000000000..6035f5b009 --- /dev/null +++ b/.jjconflict-side-2/apps/client/uno.config.ts @@ -0,0 +1,23 @@ +import { + defineConfig, + presetIcons, + presetUno, + transformerDirectives, + transformerVariantGroup, +} from 'unocss' + +export default defineConfig({ + presets: [ + presetUno(), + presetIcons({ + scale: 1.2, + extraProperties: { + display: 'inline-block', + }, + }), + ], + transformers: [ + transformerDirectives(), + transformerVariantGroup(), + ], +}) diff --git a/.jjconflict-side-2/apps/client/vite.config.ts b/.jjconflict-side-2/apps/client/vite.config.ts new file mode 100644 index 0000000000..817f1306c6 --- /dev/null +++ b/.jjconflict-side-2/apps/client/vite.config.ts @@ -0,0 +1,145 @@ +import fs from 'node:fs' +import { fileURLToPath, URL } from 'node:url' +import { + vueDsfrAutoimportPreset, + vueDsfrComponentResolver, +} from '@gouvminint/vue-dsfr/meta' +import vue from '@vitejs/plugin-vue' +import UnoCSS from 'unocss/vite' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { defineConfig } from 'vite' +import { VitePWA } from 'vite-plugin-pwa' + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) + process.loadEnvFile('.env.integ') + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) + process.loadEnvFile('.env') + +const serverHost = process.env.SERVER_HOST ?? 'localhost' +const serverPort = process.env.SERVER_PORT ?? 4000 +const clientPort = process.env.CLIENT_PORT ?? 8080 + +const define = process.env.NODE_ENV === 'production' + ? { 'process.env': { APP_VERSION: process.env.APP_VERSION } } + : { 'process.env': process.env } + +// https://vitejs.dev/config/ +export default defineConfig({ + server: { + host: '0.0.0.0', + port: Number(clientPort) || 8080, + proxy: { + '^/api': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + '^/swagger-ui-server': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + '^/swagger-ui-server-nestjs': { + target: `http://${serverHost}:${serverPort}`, + changeOrigin: true, + ws: true, + }, + }, + }, + define, + plugins: [ + vue(), + AutoImport({ + imports: [ + // @ts-ignore + 'vue', + // @ts-ignore + 'vue-router', + // @ts-ignore + 'pinia', + // @ts-ignore + vueDsfrAutoimportPreset, + ], + vueTemplate: true, + dts: './src/auto-imports.d.ts', + eslintrc: { + enabled: true, + filepath: './.eslintrc-auto-import.json', + globalsPropValue: true, + }, + }), + Components({ + extensions: ['vue'], + dirs: [ + './src/components', + './src/views', + ], + include: [/\.vue$/, /\.vue\?vue/], + dts: './src/components.d.ts', + resolvers: [ + vueDsfrComponentResolver, + ], + }), + UnoCSS({ + extendTheme: (theme) => { + return { + ...theme, + breakpoints: { + ...theme.breakpoints, + dsfrmenu: '992px', + }, + } + }, + }), + VitePWA({ + registerType: 'prompt', // autoUpdate + // disable: true, + // selfDestroying: true, + workbox: { + maximumFileSizeToCacheInBytes: 5_000_000, + cleanupOutdatedCaches: true, + navigateFallbackDenylist: [ + /^\/api/, + /^\/swagger-ui-server/, + /^\/swagger-ui-server-nestjs/, + ], + }, + devOptions: { + enabled: false, + }, + manifest: { + name: 'Console Cloud Pi Native', + short_name: 'CPiN', + description: 'Une console web pour les controler tous', + start_url: '/', + display: 'standalone', + background_color: '#ffffff', + theme_color: '#42b883', + icons: [ + { + src: '/favicon.ico', + sizes: '16x16', + type: 'image/png', + }, + ], + }, + }), + ], + base: '/', + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + dedupe: ['vue'], + }, + build: { + target: 'ESNext', + }, + optimizeDeps: { + include: [ + 'jszip', + ], + }, +}) diff --git a/.jjconflict-side-2/apps/client/vitest.config.ts b/.jjconflict-side-2/apps/client/vitest.config.ts new file mode 100644 index 0000000000..7aa0e9fda4 --- /dev/null +++ b/.jjconflict-side-2/apps/client/vitest.config.ts @@ -0,0 +1,34 @@ +import { fileURLToPath } from 'node:url' +import { mergeConfig } from 'vite' +import { configDefaults, defineConfig } from 'vitest/config' +import viteConfig from './vite.config' + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + reporters: ['default', 'hanging-process'], + environment: 'jsdom', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: [ + '**/types', + '**/mocks', + '**/*.spec.ts', + '**/*.d.ts', + '**/*.vue', + 'src/main.ts', + 'src/icons.ts', + 'src/router/index.ts', + ], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + root: fileURLToPath(new URL('./', import.meta.url)), + pool: 'forks', + }, + }), +) diff --git a/.jjconflict-side-2/apps/nginx-strangler/Dockerfile b/.jjconflict-side-2/apps/nginx-strangler/Dockerfile new file mode 100644 index 0000000000..ec2c6753fd --- /dev/null +++ b/.jjconflict-side-2/apps/nginx-strangler/Dockerfile @@ -0,0 +1,32 @@ +FROM nginxinc/nginx-unprivileged:1.31-alpine AS prod + +USER root + +# On supprime la config par défaut +RUN rm /etc/nginx/conf.d/default.conf + +# Config principale +COPY apps/nginx-strangler/nginx.conf /etc/nginx/nginx.conf + +# Template de routing (sera substitué au démarrage) +COPY apps/nginx-strangler/conf.d/routing.conf /etc/nginx/templates/routing.conf.template + +# Donner à l'utilisateur nginx et au groupe root les droits d'écriture sur conf.d/ (pour subst au démarrage) +# et sur les répertoires de logs/pid nécessaires en mode non-root +# OpenShift exécute les conteneurs avec un UID arbitraire appartenant au groupe root. +RUN chown -R nginx:root \ + /etc/nginx/nginx.conf \ + /etc/nginx/conf.d \ + /etc/nginx/templates \ + /etc/nginx/mime.types \ + /var/cache/nginx \ + /var/log/nginx \ + && touch /var/run/nginx.pid \ + && chown nginx:root /var/run/nginx.pid && chmod g+w /var/run/nginx.pid + +USER nginx + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/health || exit 1 + +EXPOSE 8080 diff --git a/.jjconflict-side-2/apps/nginx-strangler/README.md b/.jjconflict-side-2/apps/nginx-strangler/README.md new file mode 100644 index 0000000000..f7bd5af63e --- /dev/null +++ b/.jjconflict-side-2/apps/nginx-strangler/README.md @@ -0,0 +1,120 @@ +# nginx-strangler + +Reverse proxy dédié à la migration progressive du backend `server` +(Fastify/legacy) vers `server-nestjs` (NestJS), selon le [Strangler FigPattern](https://martinfowler.com/bliki/StranglerFigApplication.html). + +Ticket de référence : [#1885](https://github.com/cloud-pi-native/console/issues/1885) + +--- + +## Architecture + +``` +[Client nginx :8080] + │ proxy_pass → nginx-strangler:8080 + ▼ +[nginx-strangler :8080] + ├── /api/[routes migrées] ──→ server-nestjs:3001 + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +--- + +## Variables d'environnement + +| Variable | Description | Exemple Docker | Exemple local | +|---|---|---|---| +| `LEGACY_UPSTREAM` | Adresse du backend Fastify legacy | `server:8080` | `127.0.0.1:4000` | +| `NESTJS_UPSTREAM` | Adresse du backend NestJS | `server-nestjs:3001` | `127.0.0.1:3001` | + +Ces variables sont substituées dans `conf.d/routing.conf` via `envsubst` au démarrage du conteneur. + +--- + +## Basculer une route vers NestJS + +Éditer `conf.d/routing.conf` et ajouter un bloc `location` **avant** le bloc fallback `/api/`, en respectant le format suivant : + +```nginx +# [Vague/Itération X - nom-du-module] AAAA-MM-JJ +location = /api/v1/ma-route { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Puis recharger nginx sans downtime : + +```bash +docker compose exec nginx-strangler nginx -t # valider la config +docker compose exec nginx-strangler nginx -s reload +``` + +### Modificateurs de location + +| Modificateur | Usage | +|---|---| +| `=` | Route exacte (ex: `GET /api/v1/system/health`) | +| `^~` | Préfixe prioritaire (ex: tout un module `/api/v1/users/`) | +| *(aucun)* | Préfixe standard | + +--- + +## Rollback d'une route + +En cas de problème sur une route basculée : + +```bash +# 1. Dans conf.d/routing.conf, commenter le bloc concerné : +# # [Vague X - module] AAAA-MM-JJ ← ROLLBACK AAAA-MM-JJ +# # location = /api/v1/xxx { ... } + +# 2. Recharger nginx sans downtime +docker compose exec nginx-strangler nginx -s reload + +# 3. Vérifier les logs +docker compose logs -f nginx-strangler | grep "/api/v1/xxx" +``` + +--- + +## Lancer en dev local (pnpm dev) + +Quand `server` et `server-nestjs` tournent nativement (hors Docker), le +`nginx-strangler` reste lui en Docker avec des upstreams pointant vers les +ports "locaux" des deux services: + +```bash +# Dans docker/docker-compose.local.yml, le service nginx-strangler utilise : +# LEGACY_UPSTREAM=127.0.0.1:4000 +# NESTJS_UPSTREAM=127.0.0.1:3001 + +docker compose -f docker/docker-compose.local.yml up nginx-strangler +``` + +--- + +## Vérification de la configuration + +```bash +# Tester la syntaxe (depuis l'hôte) +docker compose exec nginx-strangler nginx -t + +# Voir la config résolue (après substitution envsubst) +docker compose exec nginx-strangler cat /etc/nginx/conf.d/routing.conf + +# Logs en temps réel +docker compose logs -f nginx-strangler +``` + +--- + +## Liens + +- Plan de mise en place : [`apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md`](../server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md) +- Stratégie de modularisation : [`apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md`](../server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md) +- Cartographie des modules : [`apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md`](../server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) diff --git a/.jjconflict-side-2/apps/nginx-strangler/conf.d/routing.conf b/.jjconflict-side-2/apps/nginx-strangler/conf.d/routing.conf new file mode 100644 index 0000000000..25bf6f7192 --- /dev/null +++ b/.jjconflict-side-2/apps/nginx-strangler/conf.d/routing.conf @@ -0,0 +1,106 @@ +# ============================================================================= +# nginx-strangler — Configuration de routage "as-code" +# +# Ce fichier est le cœur du pattern Strangler Fig. +# Il route progressivement les routes de server (legacy) vers server-nestjs. +# +# Variables d'environnement substituées au démarrage (via envsubst) : +# LEGACY_UPSTREAM — adresse du backend Fastify legacy (ex: server:8080) +# NESTJS_UPSTREAM — adresse du backend NestJS (ex: server-nestjs:3001) +# +# Format d'une bascule : +# # [Vague X - module] AAAA-MM-JJ +# location [= | ^~ | ~] /api/v1/xxx { +# proxy_pass http://server-nestjs; +# include /etc/nginx/conf.d/_proxy_headers.conf; +# } +# +# Procédure de rollback d'une route : +# 1. Commenter le bloc location concerné (ajouter "# ROLLBACK AAAA-MM-JJ") +# 2. docker compose exec nginx-strangler nginx -s reload +# ============================================================================= + +upstream server-legacy { + server ${LEGACY_UPSTREAM}; +} + +upstream server-nestjs { + server ${NESTJS_UPSTREAM}; +} + +server { + listen 8080; + + # Taille des headers (nécessaire pour les tokens Keycloak) + large_client_header_buffers 4 32k; + + # Healthcheck de nginx-strangler lui-même + location = /health { + access_log off; + add_header 'Content-Type' 'application/json'; + return 200 '{"status":"UP"}'; + } + + # ── Swagger UI ──────────────────────────────────────────────────────────────── + location /swagger-ui-server-nestjs { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /swagger-ui-server { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── Routes migrées vers NestJS ──────────────────────────────────────────────── + location /api/v1/service-chains { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ~ ^/api/v1/projects/[^/]+/deployments(.*)$ { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Les routes /api/v2 n'existent que sur server-nestjs + location /api/v2/ { + proxy_pass http://server-nestjs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ── Routes par défaut (pour l'instant dans le legacy) ───────────────────────── + location /api/ { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_redirect off; + } + +} diff --git a/.jjconflict-side-2/apps/nginx-strangler/conf.d/routing.local.conf.example b/.jjconflict-side-2/apps/nginx-strangler/conf.d/routing.local.conf.example new file mode 100644 index 0000000000..b8f62e2e8b --- /dev/null +++ b/.jjconflict-side-2/apps/nginx-strangler/conf.d/routing.local.conf.example @@ -0,0 +1,64 @@ +# ============================================================================= +# Exemple de configuration nginx-strangler pour le développement local +# (pnpm dev — server et server-nestjs tournent nativement, hors Docker) +# +# Ce fichier est fourni à titre documentaire. +# En pratique, le docker-compose.local.yml injecte directement les variables +# d'environnement LEGACY_UPSTREAM et NESTJS_UPSTREAM dans le conteneur +# nginx-strangler via envsubst, sans qu'il soit nécessaire de modifier ce fichier. +# +# Pour lancer le nginx-strangler en mode local : +# +# docker compose -f docker/docker-compose.local.yml up nginx-strangler +# +# Les valeurs par défaut dans docker-compose.local.yml : +# LEGACY_UPSTREAM=host.docker.internal:4000 (server Fastify natif) +# NESTJS_UPSTREAM=host.docker.internal:3001 (server-nestjs natif) +# +# Si vos servers écoutent sur des ports différents, surcharger les variables : +# +# LEGACY_UPSTREAM=host.docker.internal:4000 \ +# NESTJS_UPSTREAM=host.docker.internal:3001 \ +# docker compose -f docker/docker-compose.local.yml up nginx-strangler +# +# Le nginx-strangler est alors accessible sur http://localhost:8082 +# (le client Vue.js en dev natif doit pointer son proxy /api vers localhost:8082) +# ============================================================================= + +upstream server-legacy { + server host.docker.internal:4000; +} + +upstream server-nestjs { + server host.docker.internal:3001; +} + +server { + listen 8080; + + # ── Routes migrées vers NestJS ──────────────────────────────────────────── + # (vide au démarrage — toutes les routes sont en fallback sur server-legacy) + + # ── Fallback : tout le reste vers le server legacy ──────────────────────── + location /api/ { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_redirect off; + } + + location /swagger-ui { + proxy_pass http://server-legacy; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/.jjconflict-side-2/apps/nginx-strangler/nginx.conf b/.jjconflict-side-2/apps/nginx-strangler/nginx.conf new file mode 100644 index 0000000000..657e2100dd --- /dev/null +++ b/.jjconflict-side-2/apps/nginx-strangler/nginx.conf @@ -0,0 +1,28 @@ +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + 'upstream=$upstream_addr rt=$request_time'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + + # Taille des headers (nécessaire pour les tokens Keycloak) + large_client_header_buffers 4 32k; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/.jjconflict-side-2/apps/server-nestjs/.env-example b/.jjconflict-side-2/apps/server-nestjs/.env-example new file mode 100644 index 0000000000..1da8adba5c --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/.env-example @@ -0,0 +1,61 @@ +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Activation des plugins externes (true | false). Actif par défaut en développement. +USE_PLUGINS=true +# HOME=/home/node +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# Identifiant de l'administrateur Keycloak sur le royaume KEYCLOAK_REALM +KEYCLOAK_ADMIN=admin +# Mot de passe de l'administrateur Keycloak sur le royaume KEYCLOAK_REALM +KEYCLOAK_ADMIN_PASSWORD=admin +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 +# Interface d'écoute du serveur backend +SERVER_HOST=localhost +# Port d'écoute du serveur backend +SERVER_PORT=3001 +# URL de connexion à la base de données PostgreSQL (format Prisma) +DB_URL=postgresql://admin:admin@localhost:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +OPENCDS_URL= +# URL interne de l'API OpenCDS +OPENCDS_INTERNAL_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_GITLAB=false +USE_NEXUS=false +USE_SONARQUBE=false +USE_REGISTRY=false +USE_HARBOR=false +USE_OPENCDS=false +USE_ARGOCD=false diff --git a/.jjconflict-side-2/apps/server-nestjs/.env.docker-example b/.jjconflict-side-2/apps/server-nestjs/.env.docker-example new file mode 100644 index 0000000000..d0cf596b3a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/.env.docker-example @@ -0,0 +1,64 @@ +# Indique que l'application tourne dans un conteneur Docker +DOCKER=true +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Activation des plugins externes (true | false). +USE_PLUGINS=fasle +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine du Keycloak (nom du service Docker Compose + port interne) +KEYCLOAK_DOMAIN=keycloak:8080 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http en Docker local) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# Identifiant de l'administrateur Keycloak +KEYCLOAK_ADMIN=admin +# Mot de passe de l'administrateur Keycloak +KEYCLOAK_ADMIN_PASSWORD=admin +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 +# Interface d'écoute du serveur backend +SERVER_HOST=0.0.0.0 +# Port d'écoute du serveur dans le réseau Docker +SERVER_PORT=3001 +# URL de connexion PostgreSQL (utilise le nom de service Docker 'postgres') +DB_URL=postgresql://admin:admin@postgres:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +# Par défaut configuré sur le conteneur Mockoon (à changer sur envs non-PAX) +OPENCDS_URL= +# URL interne de l'API OpenCDS +OPENCDS_INTERNAL_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_GITLAB=false +USE_VAULT=false +USE_NEXUS=false +USE_SONARQUBE=false +USE_REGISTRY=false +USE_HARBOR=false +USE_OPENCDS=false +USE_ARGOCD=false diff --git a/.jjconflict-side-2/apps/server-nestjs/.env.integ-example b/.jjconflict-side-2/apps/server-nestjs/.env.integ-example new file mode 100644 index 0000000000..f0bf743326 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/.env.integ-example @@ -0,0 +1,130 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Désactive l'initialisation des données de démo (l'intégration a ses propres données) +DEV_SETUP="false" +# Active le mode intégration (charge ce fichier en surcharge) +INTEGRATION=true +# Activation des plugins externes (true | false). Désactivé en mode intégration. +USE_PLUGINS=false + +# --- Keycloak --- +# Protocole (généralement https en intégration) +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= +# Secret du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_SECRET= +# Domaine du Keycloak d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak d'intégration +KEYCLOAK_REALM= + +# JWKS cache TTL in milliseconds (default 300000 = 5 min) +KEYCLOAK_JWKS_CACHE_TTL_MS=300000 +# JWKS fetch timeout in milliseconds (default 1000 = 1 s) +KEYCLOAK_JWKS_TIMEOUT_MS=1000 + +# --- ArgoCD --- +# Namespace Kubernetes dans lequel ArgoCD est déployé +ARGO_NAMESPACE= +# URL de l'API ArgoCD +ARGOCD_URL= +# URL interne de l'API ArgoCD +ARGOCD_INTERNAL_URL= + +# --- GitLab --- +# Token d'accès personnel GitLab (scope api) +GITLAB_TOKEN= +# URL de l'instance GitLab +GITLAB_URL= +# URL interne de l'API Gitlab +GITLAB_INTERNAL_URL= + +# --- Harbor --- +# Nom d'utilisateur admin Harbor +HARBOR_ADMIN= +# Mot de passe admin Harbor +HARBOR_ADMIN_PASSWORD= +# URL de l'instance Harbor +HARBOR_URL= +# URL interne de l'API Harbor +HARBOR_INTERNAL_URL= + +# --- Keycloak (admin) --- +# Utilisateur admin Keycloak pour la gestion des royaumes/clients +KEYCLOAK_ADMIN= +# Mot de passe admin Keycloak +KEYCLOAK_ADMIN_PASSWORD= +# Client utilisé par le client admin pour s'authentifier sur le royaume master (requis) +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +# URL complète de Keycloak (peut différer de KEYCLOAK_DOMAIN) +KEYCLOAK_URL= + +# --- Nexus --- +# Nom d'utilisateur admin Nexus +NEXUS_ADMIN= +# Mot de passe admin Nexus +NEXUS_ADMIN_PASSWORD= +# URL de l'instance Nexus +NEXUS_URL= +# URL interne de l'API Nexus +NEXUS_INTERNAL_URL= + +# --- Divers --- +# Répertoire racine des projets dans Gitlab +PROJECTS_ROOT_DIR= + +# --- SonarQube --- +# Token d'API SonarQube +SONAR_API_TOKEN= +# URL de l'instance SonarQube +SONARQUBE_URL= +# URL interne de l'API SonarQube +SONARQUBE_INTERNAL_URL= + +# --- Vault --- +# Token d'authentification Vault +VAULT_TOKEN= +# URL de l'instance Vault +VAULT_URL= +# URL interne de l'API Vault +VAULT_INTERNAL_URL= + +# --- Kubeconfig --- +# Chemin vers le kubeconfig sur la machine hôte (monté en volume dans Docker) +KUBECONFIG_HOST_PATH= +# Chemin vers le kubeconfig à l'intérieur du conteneur +KUBECONFIG_PATH=$HOME/.kube/config +# Contexte Kubernetes à utiliser +KUBECONFIG_CTX= + +# Répertoire contenant les plugins externes (monté en volume dans Docker) +EXTERNAL_PLUGINS_DIR_HOST_PATH=/path/to/plugins + +# Informations pour le plugin Observabilité +GRAFANA_URL= +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 + +# --- Configuration proxy (si nécessaire pour accéder aux services externes) --- +# HTTP_PROXY= +# HTTPS_PROXY= +# NO_PROXY= + +# Configuration OpenCDS +OPENCDS_URL= +OPENCDS_INTERNAL_URL= +OPENCDS_API_TOKEN=token +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false + +# --- Activation des plugins externes --- +# Désactivés par défaut en intégration : renseignez les variables et passez à "true" pour activer. +USE_KEYCLOAK=true +USE_GITLAB=true +USE_VAULT=true +USE_NEXUS=true +USE_SONARQUBE=true +USE_REGISTRY=true +USE_HARBOR=true +USE_OPENCDS=true +USE_ARGOCD=true diff --git a/.jjconflict-side-2/apps/server-nestjs/.gitignore b/.jjconflict-side-2/apps/server-nestjs/.gitignore new file mode 100644 index 0000000000..4b56acfbeb --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/.gitignore @@ -0,0 +1,56 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/.jjconflict-side-2/apps/server-nestjs/.prettierrc b/.jjconflict-side-2/apps/server-nestjs/.prettierrc new file mode 100644 index 0000000000..8deec5d940 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/.prettierrc @@ -0,0 +1,12 @@ +{ + "plugins": ["@trivago/prettier-plugin-sort-imports"], + "importOrderParserPlugins": ["typescript", "decorators"], + "importOrder": ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true, + "printWidth": 80, + "semi": true, + "singleQuote": true, + "tabWidth": 4, + "trailingComma": "all" +} diff --git a/.jjconflict-side-2/apps/server-nestjs/Dockerfile b/.jjconflict-side-2/apps/server-nestjs/Dockerfile new file mode 100644 index 0000000000..6eb7f5f180 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/Dockerfile @@ -0,0 +1,106 @@ +# Base / dev stage ---------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Activer pnpm via corepack (version définie dans package.json "packageManager") +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +# Copier les manifestes workspace (nécessaires pour que pnpm résolve les dépendances) +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ + +# Manifestes des packages workspace dont server-nestjs dépend +COPY --chown=node:root apps/server-nestjs/package.json ./apps/server-nestjs/package.json + +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +COPY --chown=node:root plugins/argocd/package.json ./plugins/argocd/package.json +COPY --chown=node:root plugins/gitlab/package.json ./plugins/gitlab/package.json +COPY --chown=node:root plugins/harbor/package.json ./plugins/harbor/package.json +COPY --chown=node:root plugins/keycloak/package.json ./plugins/keycloak/package.json +COPY --chown=node:root plugins/nexus/package.json ./plugins/nexus/package.json +COPY --chown=node:root plugins/sonarqube/package.json ./plugins/sonarqube/package.json +COPY --chown=node:root plugins/vault/package.json ./plugins/vault/package.json + +# Installer toutes les dépendances afin de permettre le build des libs partagées +RUN pnpm install --frozen-lockfile + +# Copier les sources des packages workspace +COPY --chown=node:root plugins/ ./plugins/ +COPY --chown=node:root packages/ ./packages/ +COPY --chown=node:root apps/server-nestjs/ ./apps/server-nestjs/ + +# Build des libs partagées (génère dist/ et types/) +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +# Build hooks (génère dist/ et types/ nécessaires aux imports) +RUN pnpm --filter @cpn-console/hooks run build + +# Réinjecter les libs buildées dans node_modules (injectWorkspacePackages copie au moment de l'install) +RUN pnpm --filter @cpn-console/server-nestjs install --frozen-lockfile + +# Générer le client Prisma (schéma multi-fichiers : pointer sur le dossier) +RUN pnpm --filter @cpn-console/server-nestjs exec prisma generate \ + --schema=src/prisma/schema + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/api/v1/healthz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" + +ENTRYPOINT ["pnpm", "--filter", "server-nestjs", "run"] +CMD ["start:dev"] + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +# Build shared (génère dist/ et types/) +RUN pnpm --filter @cpn-console/logger run build +RUN pnpm --filter @cpn-console/shared run build + +# Build hooks (génère dist/ et types/ nécessaires aux imports) +RUN pnpm --filter @cpn-console/hooks run build + +# Réinjecter shared buildé dans node_modules (injectWorkspacePackages copie au moment de l'install) +RUN pnpm --filter @cpn-console/server-nestjs install --frozen-lockfile + +# Build server-nestjs +RUN pnpm --filter @cpn-console/server-nestjs run build + +# Export @cpn-console/server-nestjs to target build directory +RUN pnpm --filter @cpn-console/server-nestjs --prod deploy build + +# Prod stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS prod + +ARG APP_VERSION +ENV APP_VERSION=$APP_VERSION + +VOLUME ["/plugins"] +WORKDIR /app + +RUN mkdir -p /home/node/logs && chmod 770 -R /home/node/logs \ + && mkdir -p /home/node/.npm && chmod 770 -R /home/node/.npm \ + && chown node:root /app + +# Copier l'artefact de build et les node_modules de production +COPY --chown=node:root --from=build /app/build . +COPY --chown=node:root --from=build /app/apps/server-nestjs/dist ./dist +# COPY --chown=node:root --from=build /app/build/node_modules ./node_modules + +# Regénérer le client Prisma dans l'image de production +RUN npm run db:generate + +USER node +EXPOSE 3001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/api/v1/healthz', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))" + +ENTRYPOINT ["node", "dist/src/main"] diff --git a/.jjconflict-side-2/apps/server-nestjs/README.md b/.jjconflict-side-2/apps/server-nestjs/README.md new file mode 100644 index 0000000000..62673164cc --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/README.md @@ -0,0 +1,543 @@ +# À propos + +Ce dossier contient une nouvelle version de `server`, basée sur NestJS. + +## Objectifs + +Rappel : L'objectif principal de cette démarche est de préparer l'extraction de +chaque "plugin" en tant que module NestJS incluant à la fois la partie Front et +la partie Backend pour une meilleure composition de la Console. + +On va profiter de cette vision pour passer, côté `server` d'un "Back For +Front", à un backend qui va gérer à la fois le Front et le Back de différentes +fonctionnalités (appelée "Plugins"), et ce parfois de manière dynamique (mais +déjà en statique, ce sera pas mal 😅). + +## Conséquences pour apps/server + +On va donc passer de ça : + +```mermaid +flowchart TD + + %% --- Top-level --- + NestJS["Nest.js"] + FutursModules["Futurs modules
Nest.js correctement
découpés..."] + MainModule["MainModule"] + + NestJS --> MainModule + MainModule --> CPinModule + MainModule -.-> FutursModules + + CPinModule["CPinModule
(Contient TOUT le code actuel de 'server')"] + + %% --- Core services --- + ConnectionService["ConnectionService"] + LoggerService["LoggerService"] + FastifyService["FastifyService"] + ServerService["ServerService"] + AppService["AppService"] + ResourceService["ResourceService"] + PrepareAppService["PrepareAppService"] + InitDbService["InitDbService"] + PluginService["PluginService"] + + %% --- Router services --- + AdminRoleRouterService["AdminRoleRouterService"] + AdminTokenRouterService["AdminTokenRouterService"] + ClusterRouterService["ClusterRouterService"] + OtherRouterService["...RouterService"] + + %% --- External services --- + Gitlab["Gitlab"] + ArgoCD["ArgoCD"] + Dots["..."] + + %% --- CPinModule connections --- + CPinModule --> ConnectionService + CPinModule --> AppService + CPinModule --> LoggerService + CPinModule --> FastifyService + CPinModule --> ServerService + CPinModule --> ResourceService + CPinModule --> PrepareAppService + + %% --- AppService central connections --- + ConnectionService --> AppService + LoggerService --> AppService + FastifyService --> AppService + ServerService --> AppService + + AppService --> ResourceService + AppService --> PrepareAppService + AppService --> ServerService + AppService --> FastifyService + AppService --> LoggerService + AppService --> ConnectionService + + %% --- ResourceService to routers --- + ResourceService --> AdminRoleRouterService + ResourceService --> AdminTokenRouterService + ResourceService --> ClusterRouterService + ResourceService --> OtherRouterService + + %% --- PrepareAppService --- + PrepareAppService --> InitDbService + PrepareAppService --> PluginService + PrepareAppService --> ServerService + + %% --- PluginService external interactions --- + PluginService --> Gitlab + PluginService --> ArgoCD + PluginService --> Dots + + %% --- Bounding box (visual grouping only) --- + subgraph CPinBlock[" "] + CPinModule + ConnectionService + LoggerService + FastifyService + ServerService + AppService + ResourceService + PrepareAppService + InitDbService + PluginService + AdminRoleRouterService + AdminTokenRouterService + ClusterRouterService + OtherRouterService + end +``` + +à ça : + +```mermaid +flowchart TD + + %% --- Top-level Nest module --- + NestJS["Point d'entrée de NestJS"] + MainModule["MainModule"] + CPinModule["CPinModule"] + FutursModules["Futurs modules
NestJS correctement
découpés..."] + + NestJS --> MainModule + MainModule --> CPinModule + MainModule -.-> FutursModules + + %% --- Layering for clarity --- + subgraph LayerInit["Initialisation de l'application"] + ApplicationInitializationService["ApplicationInitializationService"] + DatabaseInitializationService["DatabaseInitializationService"] + PluginManagementService["PluginManagementService"] + end + + subgraph LayerCore["Coeur de l'application"] + AppService["AppService"] + RouterService["RouterService"] + ServerService["ServerService"] + end + + subgraph LayerInfra["Couche Infrastructure"] + LoggerService["LoggerService"] + ConfigurationService["ConfigurationService"] + DatabaseService["DatabaseService"] + FastifyService["FastifyService"] + HTTPClientService["HTTPClientService"] + end + + OtherAPIService["APIs externes
(par ex. OpenCDS)"] + HTTPClientService --> OtherAPIService + + subgraph LayerBusiness["Modules métiers"] + subgraph AdminRole["Admin Roles"] + AdminRoleRouterService["AdminRoleRouterService"] + AdminRoleBusinessService["AdminRoleBusinessService"] + AdminRoleDTOService["AdminRoleDTOService"] + AdminRoleRouterService --> AdminRoleBusinessService + AdminRoleRouterService --> LoggerService + AdminRoleBusinessService --> AdminRoleDTOService + AdminRoleBusinessService --> LoggerService + AdminRoleDTOService --> LoggerService + AdminRoleDTOService --> DatabaseService + end + subgraph AdminToken["Admin Tokens"] + AdminTokenRouterService["AdminTokenRouterService"] + AdminTokenBusinessService["AdminTokenBusinessService"] + AdminTokenDTOService["AdminTokenDTOService"] + AdminTokenRouterService --> AdminTokenBusinessService + AdminTokenRouterService --> LoggerService + AdminTokenBusinessService --> AdminTokenDTOService + AdminTokenBusinessService --> LoggerService + AdminTokenDTOService --> DatabaseService + AdminTokenDTOService --> LoggerService + end + subgraph ServiceChain["Service chains"] + ServiceChainRouterService["ServiceChainRouterService"] + ServiceChainBusinessService["ServiceChainBusinessService"] + ServiceChainRouterService --> ServiceChainBusinessService + ServiceChainRouterService --> LoggerService + ServiceChainBusinessService --> HTTPClientService + ServiceChainBusinessService --> LoggerService + + end + subgraph Cluster["Clusters"] + ClusterRouterService["ClusterRouterService"] + ClusterBusinessService["ClusterBusinessService"] + ClusterDTOService["ClusterDTOService"] + ClusterRouterService --> ClusterBusinessService + ClusterRouterService --> LoggerService + ClusterBusinessService --> ClusterDTOService + ClusterBusinessService --> LoggerService + ClusterDTOService --> DatabaseService + ClusterDTOService --> LoggerService + end + OtherBusinessModules["...Other Business Modules"] + end + + RouterService --> AdminRoleRouterService + RouterService --> AdminTokenRouterService + RouterService --> ClusterRouterService + RouterService --> ServiceChainRouterService + RouterService --> OtherBusinessModules + RouterService --> LoggerService + + subgraph LayerPlugins["Plugins compatibles CPiN"] + Gitlab["Gitlab"] + ArgoCD["ArgoCD"] + Kubernetes["Kubernetes"] + Dots["..."] + end + + %% --- Module wiring --- + CPinModule --> ApplicationInitializationService + + %% Application initialization + ApplicationInitializationService --> LoggerService + ApplicationInitializationService --> ConfigurationService + ApplicationInitializationService --> FastifyService + ApplicationInitializationService --> AppService + ApplicationInitializationService --> DatabaseInitializationService + DatabaseInitializationService --> DatabaseService + DatabaseInitializationService --> LoggerService + ApplicationInitializationService --> PluginManagementService + ApplicationInitializationService --> LoggerService + + %% App Core internal flow + AppService --> RouterService + AppService --> ServerService + AppService --> LoggerService + ServerService --> LoggerService + + %% Plugin Management + PluginManagementService --> Gitlab + PluginManagementService --> ArgoCD + PluginManagementService --> Kubernetes + PluginManagementService --> Dots + PluginManagementService --> LoggerService + Gitlab --> LoggerService + ArgoCD --> LoggerService + Kubernetes --> LoggerService + Dots --> LoggerService +``` + +et enfin à ça : + +```mermaid +flowchart TD + + %% --- Top-level Nest module --- + NestJS["Point d'entrée de NestJS"] + MainModule["MainModule"] + + NestJS --> MainModule + + subgraph CoreModule["CoreModule"] + ApplicationInitializationService["ApplicationInitializationService"] + end + + subgraph InfrastructureModule["InfrastructureModule"] + LoggerService["LoggerService"] + ConfigurationService["ConfigurationService"] + DatabaseService["DatabaseService"] + HTTPClientService["HTTPClientService"] + end + + OtherAPIService["APIs externes
(par ex. OpenCDS)"] + HTTPClientService --> OtherAPIService + + subgraph MandatoryModules["Modules obligatoires CPiN"] + subgraph GitlabModule["GitlabModule"] + GitlabController["GitlabController"] + GitlabBusinessService["GitlabBusinessService"] + GitlabDTOService["GitlabDTOService"] + GitlabController --> GitlabBusinessService + GitlabController --> LoggerService + GitlabBusinessService --> GitlabDTOService + GitlabBusinessService --> LoggerService + GitlabDTOService --> DatabaseService + GitlabDTOService --> LoggerService + end + + subgraph ArgoCDModule["ArgoCDModule"] + ArgoCDController["ArgoCDController"] + ArgoCDBusinessService["ArgoCDBusinessService"] + ArgoCDDTOService["ArgoCDDTOService"] + ArgoCDController --> ArgoCDBusinessService + ArgoCDController --> LoggerService + ArgoCDBusinessService --> ArgoCDDTOService + ArgoCDBusinessService --> LoggerService + ArgoCDDTOService --> DatabaseService + ArgoCDDTOService --> LoggerService + end + + subgraph KubernetesModule["KubernetesModule"] + KubernetesController["KubernetesController"] + KubernetesBusinessService["KubernetesBusinessService"] + KubernetesDTOService["KubernetesDTOService"] + KubernetesController --> KubernetesBusinessService + KubernetesController --> LoggerService + KubernetesBusinessService --> KubernetesDTOService + KubernetesBusinessService --> LoggerService + KubernetesDTOService --> DatabaseService + KubernetesDTOService --> LoggerService + end + + subgraph AdminRoleModule["AdminRoleModule"] + AdminRoleController["AdminRoleController"] + AdminRoleBusinessService["AdminRoleBusinessService"] + AdminRoleDTOService["AdminRoleDTOService"] + AdminRoleController --> AdminRoleBusinessService + AdminRoleController --> LoggerService + AdminRoleBusinessService --> AdminRoleDTOService + AdminRoleBusinessService --> LoggerService + AdminRoleDTOService --> LoggerService + AdminRoleDTOService --> DatabaseService + end + + subgraph AdminTokenModule["AdminTokenModule"] + AdminTokenController["AdminTokenController"] + AdminTokenBusinessService["AdminTokenBusinessService"] + AdminTokenDTOService["AdminTokenDTOService"] + AdminTokenController --> AdminTokenBusinessService + AdminTokenController --> LoggerService + AdminTokenBusinessService --> AdminTokenDTOService + AdminTokenBusinessService --> LoggerService + AdminTokenDTOService --> DatabaseService + AdminTokenDTOService --> LoggerService + end + + subgraph ClusterModule["ClusterModule"] + ClusterController["ClusterController"] + ClusterBusinessService["ClusterBusinessService"] + ClusterDTOService["ClusterDTOService"] + ClusterController --> ClusterBusinessService + ClusterController --> LoggerService + ClusterBusinessService --> ClusterDTOService + ClusterBusinessService --> LoggerService + ClusterDTOService --> DatabaseService + ClusterDTOService --> LoggerService + end + OtherBusinessModules["...Other Business Modules"] + end + + CoreModule --> GitlabModule + CoreModule --> ArgoCDModule + CoreModule --> KubernetesModule + CoreModule --> AdminRoleModule + CoreModule --> AdminTokenModule + CoreModule --> ClusterModule + + subgraph ThirdPartyModules["Modules optionnels de CPin"] + subgraph ServiceChainModule["ServiceChainModule"] + ServiceChainController["ServiceChainController"] + ServiceChainBusinessService["ServiceChainBusinessService"] + ServiceChainController --> ServiceChainBusinessService + ServiceChainController --> LoggerService + ServiceChainBusinessService --> HTTPClientService + ServiceChainBusinessService --> LoggerService + end + end + + CoreModule --> ServiceChainModule + + %% --- Module wiring --- + MainModule --> ApplicationInitializationService + + %% Application initialization + ApplicationInitializationService --> LoggerService + ApplicationInitializationService --> ConfigurationService + ApplicationInitializationService --> LoggerService +``` + +## Architecture du module ServiceChain (Vague 1) + +Le module ServiceChain est le **premier module métier migré** depuis le legacy +vers server-nestjs via le pattern Strangler Fig. Il sert de proxy HTTP vers +l'API externe OpenCDS (gestion des chaînes de service réseau). + +### Flux de proxying OpenCDS + +``` +┌──────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────┐ +│ Client │────▶│ nginx-strangler │────▶│ server-nestjs │────▶│ API OpenCDS │ +│ (browser/ │ │ │ │ │ │ (externe) │ +│ script) │ │ /api/v1/ │ │ ServiceChain │ │ │ +│ │◀────│ service-chains │◀────│ Controller │◀────│ /requests │ +│ │ │ ──▶ nestjs │ │ ──▶ Service │ │ /validate │ +└──────────┘ │ │ │ ──▶ axios │ │ /flows │ + │ /api/* (reste) │ └──────────────────┘ └──────────────┘ + │ ──▶ legacy │ + └─────────────────┘ +``` + +### Structure du module + +``` +src/cpin-module/ +├── infrastructure/ +│ └── auth/ # Auth transverse (réutilisable) +│ ├── auth.module.ts # Module NestJS +│ ├── auth.service.ts # Lookup token SHA256 → Prisma +│ ├── admin-permission.guard.ts # Guard : vérifie x-dso-token + permissions +│ ├── admin-permission.decorator.ts @RequireAdminPermission('ListSystem') +│ └── admin-permission.guard.spec.ts +│ +└── service-chain/ # Module métier + ├── service-chain.module.ts # Imports: ConfigurationModule, AuthModule + ├── service-chain.controller.ts # 5 endpoints sous /api/v1/service-chains + ├── service-chain.service.ts # Proxy axios → OpenCDS + validation Zod + ├── service-chain.controller.spec.ts + └── service-chain.service.spec.ts +``` + +### Authentification + +Seule l'**auth par token** (`x-dso-token`) est supportée pour l'instant. +L'auth par session Keycloak sera ajoutée lors de la migration globale du +mécanisme de session vers NestJS (les cookies de session Fastify ne sont pas +déchiffrables par Express). + +``` + Requête HTTP + │ + ▼ + ┌──────────────────────┐ + │ AdminPermissionGuard │ + │ │ + │ 1. Lire header │ + │ x-dso-token │──── absent ──▶ 401 Unauthorized + │ │ + │ 2. SHA256(token) │ + │ → Prisma lookup │──── invalide ──▶ 401 Unauthorized + │ (PersonalAccess │ (expiré, révoqué, introuvable) + │ Token ou Admin │ + │ Token) │ + │ │ + │ 3. Vérifier perms │ + │ bitwise via │──── insuffisant ──▶ 403 Forbidden + │ AdminAuthorized │ + │ │ + │ 4. OK → continuer │──────────────────▶ Controller + └──────────────────────┘ +``` + +### Endpoints + +| Méthode | Route | Permission | Description | +|---------|-------|------------|-------------| +| `GET` | `/api/v1/service-chains` | `ListSystem` | Liste toutes les chaînes | +| `GET` | `/api/v1/service-chains/:id` | `ListSystem` | Détails d'une chaîne | +| `GET` | `/api/v1/service-chains/:id/flows` | `ListSystem` | Flux d'une chaîne | +| `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | Relancer une chaîne | +| `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | Valider une chaîne | + +### Différences avec le legacy + +- **403 systématique** : le legacy retournait `[]` sur `GET /` sans permission ; + le NestJS retourne 403 pour tous les endpoints sans permission. +- **Pas d'auth session** : seul `x-dso-token` fonctionne (le legacy supportait + aussi les sessions Keycloak). +- **Validation UUID** : les paramètres d'URL sont validés via `ParseUUIDPipe` + (400 si format invalide). + +### Variables d'environnement + +| Variable | Description | Défaut | +|----------|-------------|--------| +| `OPENCDS_URL` | URL de base de l'API OpenCDS | _(vide = désactivé)_ | +| `OPENCDS_API_TOKEN` | Token d'API OpenCDS (header `X-API-Key`) | — | +| `OPENCDS_API_TLS_REJECT_UNAUTHORIZED` | Vérification TLS (`true`/`false`) | `true` | + +--- + +Pour mettre à jour `old-server` (après avoir rebasé sur `origin/master`, par exemple) : + +```bash +server-nestjs/$ rm -rf src/cpin-module/old-server +server-nestjs/$ cp -r ../server src/cpin-module/old-server +server-nestjs/$ find src/cpin-module/old-server -type f -iname "*.ts" -exec sed -i -e "s#@/#@old-server/#g" {} \; +server-nestjs/$ find src/cpin-module/old-server -type f -iname "*.ts" -exec sed -i -e "s#\.[jt]s'#'#g" {} \; +``` + +## Fichiers à supprimer dans le futur + +Certains fichiers de `old-server` servait de "framework" pour le backend, et +vont donc être réécrits en tant que modules/services NestJS. On va garder la +liste ici, ce qui permettra de ne pas être constamment en conflit sur le code +de `server`. En attendant de pouvoir s'en débarrasser, et afin de s'assurer que +leur code n'est pas utilisé dans d'autres parties du backend, on va commenter +l'intégralité de ces fichiers (comme ça pas d'erreur d'import quand on les +supprimera). + +Voilà donc la liste des fichiers "dépréciés" : + +``` +old-server/src/app.ts -> Réécrit en AppService +old-server/src/connect.ts -> Réécrit en DatabaseService +old-server/src/init/db/* (à part dump.ts) -> Réécrit en DatabaseInitializationService +old-server/src/resources/**/router.ts -> Réécrit en **RouterService +old-server/src/resources/index.ts -> Réécrit en RouterService +old-server/src/server.ts -> Intégré à ApplicationInitializationService +old-server/src/utils/env.ts -> Réécrit en ConfigurationModule +old-server/src/utils/fastify.ts -> Réécrit en FastifyService +old-server/src/utils/keycloak-utils.ts -> Intégré dans AppService +old-server/src/utils/keycloak.ts -> Intégré dans AppService +old-server/src/utils/logger.ts -> Réécrit en LoggerModule +old-server/src/utils/plugin.ts -> Réécrit en PluginManagementService +``` + +## Prochaines itérations sur le sujet + +Tâches à réaliser par la suite dans d'autres itérations/tickets/etc. : + +- Migrer une fonctionnalité "verticale" complète (Route, Contract, Controller, + Business, Queries, Prisma schema) dans son propre module NestJS qui sera + importé dans `MainModule` (et pas dans `CpinModule` qui devra disparaître + à terme). +- Définir la liste de ces fonctionnalités verticales, et planifier l'extraction + de certaines d'entre elles (OpenCDS, typiquement, qui n'a rien à faire dans + le code de base de la Console) +- Migrer la base actuelle de NestJS de Jest vers Vitest et s'assurer que les + tests unitaires passent à nouveaux (attention, certains d'entre eux devront + être adaptés vu qu'on a commencé à réécrire du code au standard NestJS) +- Intérgrer `server-nestjs` dans tous les `docker compose` et les différents + scripts PNPM/Bash qui font tout le sel de nos process de dev +- Revoir les imports de données (le fameux `dump.ts`). C'était déjà une + mauvaise idée à l'époque, ça l'est encore plus aujourd'hui. On ferait mieux + d'utiliser un side-container pour ça +- Être capable de déployer `server-nestjs` en parallèle de `server`, avec + probablement un reverse proxy dédié à la migration des routes de l'ancien + vers le nouveau (à rediscuter plus concrètement) + +Les étapes d'après-après (quand on sera sereins sur la migration de `server` +vers `server-nestjs`) : + +- Tester les capacités de NestJS SSR (Server Side Rendering), notamment + vis-à-vis de nos composants VueJS. Il y aura probablement des sujets autour + de l'isolation du code VueJS des différentes fonctionnalités afin de + faciliter leur extraction de `client` vers `server` +- Implémenter une fonctionnalité du Front en tant que module NestJS SSR + (OpenCDS est un très bon cas d'étude grâce à son côté très isolé dans le + code). Idéalement ce code "Front" ajouté "côté Backend" devra être colocalisé + avec le code "Backend" correspondant. diff --git a/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md b/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md new file mode 100644 index 0000000000..1973831edb --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/00-OVERVIEW.md @@ -0,0 +1,214 @@ +# Modularisation Backend : Node.js Server → NestJS + +## 🎯 Objectifs de la modularisation + +### Principes directeurs + +- La Console a évolué très rapidement. Depuis sa création jusqu'à récemment, la + priorité a été donnée à l'ajout de fonctionnalités, sans forcément avoir le + temps de consolider le socle technique. La modularisation de sa partie + serveur, à l'aide d'un framework robuste comme NestJS, permettra une + évolution saine de la plate-forme. +- Il y a une volonté de permettre davantage de souplesse dans la configuration + de la console (utilisation d'un plugin en remplacement d'un autre), afin de + permettre aux projets qui voudraient avoir "leur" console de la configurer + comme ils veulent +- Le code du serveur de la console est assez daté, dans ses paradigmes et ses + dépendances. Un nettoyage de ce code par une remise à plat de la cartographie + des interdépendances entre les différents modules permettra d'améliorer les + choses module par module + +### Objectifs techniques +- **Modularisation** : Découpage en modules métier cohérents et maintenables +- **Architecture propre** : Injection de dépendances, séparation des responsabilités +- **Scalabilité** : Base solide pour les évolutions futures +- **Qualité code** : Standards NestJS, TypeScript strict + +### Objectifs organisationnels +- **Continuité produit** : Zéro impact sur les développements en cours +- **Montée en compétence** : Équipe formée progressivement à NestJS +- **Risque maîtrisé** : Modularisation progressive sans interruption de service + +## 📊 Contexte technique + +### Stack actuelle +- **Backend** : Node.js (avec ts-rest/fastify et injection de dépendance manuelle des plugins) +- **ORM** : Prisma avec PostgreSQL +- **Auth** : OIDC avec Keycloak +- **Tests** : Playwright (E2E), Vitest (unitaires) +- **Déploiement** : Docker Compose +- **CI/CD** : Pipeline existante + +### Stack cible +- **Backend** : NestJS (TypeScript) +- **ORM** : Prisma (conservé, partagé) +- **Auth** : OIDC Keycloak +- **Tests** : Playwright + Vitest (conservés) +- **Déploiement** : Docker Compose (phase transition) → NestJS seul +- **CI/CD** : Adaptée pour build et le déploiement des 2 images en parallèle + +### Métriques du projet +- **Routes** : ~100 +- **Équipe** : ~5 développeurs +- **Timeline** : 3 mois (12 sprints) +- **Couverture tests E2E** : 33% → objectif 50% + +## 🏗️ Stratégie : Strangler Fig Pattern + +### Principe +Le nouveau backend `server-nestjs` coexiste avec l'ancien et "étouffe" progressivement `server` en reprenant route par route ses fonctionnalités. + +### Pourquoi cette approche ? +✅ **Zero downtime** : Service continu pendant la modularisation +✅ **Rollback facile** : Retour arrière immédiat via configuration nginx +✅ **Équipe non bloquée** : Développements possibles en parallèle +✅ **Validation progressive** : Tests en prod sur petit périmètre +✅ **Risque maîtrisé** : Pas de big bang + +### Architecture de transition + +``` +┌─────────────┐ +│ Nginx │ ← Point d'entrée unique (port 80) +│ (Reverse │ +│ Proxy) │ +└──────┬──────┘ + │ + ├─────────────┐ + │ │ + Migré Pas encore + │ │ + │ │ +┌──────▼──────┐ ┌────▼───────┐ +│ NestJS API │ │ Server API │ +│ (3001) │ │ (3000) │ +└──────┬──────┘ └────┬───────┘ + │ │ + └─────────────│ + │ + ┌──────▼─────┐ + │ PostgreSQL │ + │ (Prisma) │ + └────────────┘ +``` + +## 📋 Documents de référence + +Cette modularisation est documentée dans plusieurs fichiers : + +1. **[00-OVERVIEW.md](00-OVERVIEW.md)** *(ce document)* - Vue d'ensemble +3. **[01-MODULARISATION-STRATEGY.md](01-MODULARISATION-STRATEGY.md)** - Stratégie et méthodologie +7. **[MODULARISATION_STATUS.md](MODULARISATION_STATUS.md)** - Suivi en temps réel (fichier vivant) +8. **[MODULARISATION_MAP.md](MODULARISATION_MAP.md)** - Cartographie des modules (fichier vivant) + +## 🚀 Démarrage rapide + +### Pour l'équipe de développement +1. Lire ce document (OVERVIEW) +2. Consulter [MODULARISATION\_STATUS.md](MODULARISATION_STATUS.md) pour connaître l'état actuel +3. Suivre le canal Mattermost `#backend-modularisation` +4. Respecter les zones en modularisation (🚧) avant de développer + +### Pour le lead technique de la modularisation +1. Lire tous les documents dans l'ordre +2. Personnaliser [MODULARISATION\_MAP.md](MODULARISATION_MAP.md) avec la cartographie réelle +3. Configurer l'infrastructure +4. Lancer la communication + +## 🎯 Principes directeurs + +### Technique +- **Parité fonctionnelle** : Chaque route migrée doit être strictement équivalent à son prédécesseur +- **Tests de contrat** : Validation automatique de la parité comportementale +- **Monitoring** : Surveillance continue des performances et erreurs +- **Documentation** : Chaque décision architecturale documentée (ADR) + +### Organisationnel +- **Communication proactive** : Annonce claire des zones en modularisation +- **Collaboration** : "Pair programming" encouragé pour montée en compétence +- **Pragmatisme** : Adapter le planning selon les contraintes business +- **Célébration** : Reconnaître les étapes franchies + +## ⚠️ Règles critiques + +### À FAIRE +✅ Annoncer début/fin de modularisation d'un module sur Mattermost +✅ Mettre à jour MODULARISATION\_STATUS.md à chaque changement +✅ Écrire des tests de contrat avant de migrer une route +✅ Déployer progressivement (intégration → staging → prod) +✅ Monitorer 24-48 h après chaque modularisation + +### À NE PAS FAIRE + +❌ Migrer un module pendant qu'une fonctionnalité y est développée +❌ Modifier le schéma Prisma pendant la modularisation (sauf critique) +❌ Déployer sans tests E2E passants +❌ Oublier de mettre à jour la config nginx pour indiquer la bascule d'une route +❌ Développer sur `server` (et pas sur `server-nestjs`) un module déjà migré + +## 📞 Contacts + +### Responsable modularisation +- **Lead technique** : @stephane.trebel +- **Canal Mattermost** : #backend-modularisation +- **Réunions** : Hebdo modularisation (les jeudis à 10h, 30 min) + +### Escalade +- **Problème technique bloquant** : Contacter @lead-dev immédiatement +- **Conflit de développement** : Contacter @lead-dev immédiatement +- **Incident de Production** : Retour en arrière Nginx sur la route concernée + +## 📈 Métriques de succès + +### Fin de modularisation (sprint 12) +- [ ] 100% des routes migrées sur NestJS +- [ ] code de `server` supprimé du dépôt Git de `console` +- [ ] Couverture des Tests E2E ≥ 50% des cas d'usage de `console` +- [ ] Documentation technique complète des modules (cartographie, dépendances inter-modules, etc.) +- [ ] 0 régression fonctionnelle +- [ ] Équipe autonome sur NestJS + +### Indicateurs intermédiaires +- **S4** : 20% migré (Modules transverses (logger, configuration, etc.)) +- **S6** : 40% migré (Modules "verticaux" comme Zone, Cluster, etc.) +- **S8** : 60% migré +- **S10** : 80% migré (Plugins, comme ArgoCD, Gitlab, etc.) +- **S12** : 100% migré + +### Migrations anticipées +- **ServiceChain (OpenCDS)** : Migré le 2026-04-09 (prévu V3/S8). Ce module + isolé (proxy HTTP vers API externe, aucune + dépendance entrante) a servi de premier test de bout en bout du pipeline de + migration : module NestJS + `InfrastructureModule` + `UserGuard` + + nginx-strangler + Docker. + +## 🗓️ Dates clés + +- **S1-S2** : Cartographie et setup +- **S3-S4** : Modularisation des modules transverses +- **S5-S6** : Modularisation des modules métiers "cœur" (Zone, Cluster, etc.) +- **S7-S10** : Modularisation modules plugins (ArgoCD, Gitlab, etc.) +- **S11-S12** : Finalisation et nettoyage du code (suppression reverse proxy, suppression de `server`, etc.) + +*Dates indicatives, ajustables selon les contraintes business* + +## 📚 Ressources + +### Documentation NestJS + +- [Documentation officielle NestJS](https://docs.nestjs.com/) +- [Prisma avec NestJS](https://docs.nestjs.com/recipes/prisma) +- [Authentification OIDC NestJS](https://docs.nestjs.com/recipes/passport) + +### Outils + +- [Madge](https://github.com/pahen/madge) - Analyse de dépendances +- [Compodoc](https://compodoc.app/) - Documentation auto +- [Playwright](https://playwright.dev/) - Tests E2E +- [Vitest](https://vitest.dev/) - Tests unitaires + +--- + +**Version** : 1.2 +**Dernière mise à jour** : 2026-06-16 +**Prochaine revue** : Fin S2 diff --git a/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md b/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md new file mode 100644 index 0000000000..10c05e2525 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md @@ -0,0 +1,313 @@ +# Stratégie de Modularisation - console/server → console/server-nestjs + +## 🎯 Approche globale + +Sachant que nous avons des fonctionalités assez bien identifiées (AdminTokens, Plugin Gitlab, etc.), nous allons utiliser une approche « Modularisation par verticaux ». + +### Principe + +Plutôt que de migrer par couches horizontales (tous les DTOs, puis tous les services, puis tous les controllers, puis tous les plugins), nous migrerons **module complet par module complet**. + +### Pourquoi cette approche ? + +✅ **Valeur métier immédiate** : Chaque itération livre un module fonctionnel +✅ **Tests E2E possibles** : Validation complète de la non-régression dès la fin du module +✅ **Risque isolé** : Un problème n'affecte qu'un seul domaine métier +✅ **Rollback granulaire** : Retour arrière possible module par module +✅ **Équipe non bloquée** : Autres modules restent disponibles pour développement +✅ **Reprise du typage** : Beaucoup de modules ont un typage Typescript lacunaire, voire inexistant (`any`). Migrer un module sera l'opportunité de reprendre ces aspects et définir clairement l'"API" du Module + +### Anatomie d'un vertical + +``` +ZoneModule (exemple) +├── Model (Prisma) ← Déjà existant, partagé +├── Repository/Service ← Modularisation du code métier +├── Controller/Routes ← Modularisation des endpoints +├── DTOs & Validation ← Ajout validation NestJS +├── Tests unitaires ← Nouveaux tests Vitest +└── Tests E2E ← Adaptation tests Playwright si besoin (normalement jamais) +``` + +## Phase 1: 📊 Cartographie et priorisation du code de console/server actuel + +### Objectifs de la cartographie + +1. **Inventaire exhaustif** : Lister tous les endpoints et leurs dépendances +2. **Graphe de dépendances** : Identifier les modules couplés entre eux (typiquement Vault et…tout le reste) +3. **Priorisation** : Définir l'ordre optimal de modularisation (de "bas en haut", donc de ceux qui ont le moins de dépendances à ceux qui dépendent de presque tout le monde) +4. **Estimation** : Évaluer grosse maille la charge de chaque module + +### Outils d'analyse automatique + +Une manière de faire qui combine une approche automatique (on parcourt tout ce qu'on peut) et une approche empirique (on regarde ce que ça donne) peut se faire de la manière suivante : + +```bash +# 1. Installer les outils +npm install -D madge dependency-cruiser + +# 2. Générer le graphe de dépendances +npx madge --image dependency-graph.png --extensions js,ts server/src + +# 3. Détecter les cycles de dépendances +npx madge --circular --extensions js,ts server/src + +# 4. Analyser les violations architecturales +npx depcruise src --output-type err +``` + +### Ordre de modularisation recommandé + +**Stratégie en 3 phases** : + +1. **Phase d'apprentissage (S3-S4)** : Modules simples mais critiques + - Auth : Critique mais bien délimité + - Objectif : Valider l'approche, former l'équipe + +2. **Phase de vélocité (S5-S8)** : Modules métier principaux + - Cluster, Zone, etc. + - Objectif : Migrer le gros du trafic + +3. **Phase de finalisation (S9-S12)** : Modules périphériques + - Plugins qui doivent exister impérativement (Kubernetes, Gitlab, etc. -LISTE À DEFINIR-) + - Objectif : Compléter la modularisation, suppression de `server` et renommage de `server-nestjs` en `server` + +## 🔄 Gestion du passage simple singleton node.js → Injection de dépendances dans des services singleton + +### Le problème des singletons purement "node.js" + +Les singletons actuellement utilisés dans le code sont ceux de Node.js. Ce sont +des instances certes uniques, mais qui reposent intégralement sur une logique +d'import des fichiers. Cette logique n'est pas mauvaise en soi (et fonctionne +encore maintenant), mais elle est très limitée, car elle ne nous permet pas de +configurer les "modules" importés (changer une dépendance par une autre, par +exemple). Par essence les modules node.js ne permettent pas d'injection de +dépendance, en particulier au runtime. + +```javascript +// ❌ Pattern singleton dans le code server +const prisma = require('../db').default; + +async findById(id) { + return prisma.user.findUnique({ where: { id } }); +} + +async create(data) { + return prisma.user.create({ data }); +} + +// Export d'une instance unique (singleton) +module.exports = { findById, create }; +``` + +**Problèmes** : +- État partagé mutable +- Difficile à tester (mocking complexe) +- Pas de contrôle du cycle de vie +- Couplage fort avec les dépendances +- Typage souvent lacunaire + +### Solution : Migrer vers un vrai service singleton de type NestJS + +```typescript +// nest-backend/src/users/users.service.ts +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { User } from '@prisma/client'; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id } }); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + return user; + } + + async create(createUserDto: CreateUserDto): Promise { + return this.prisma.user.create({ + data: createUserDto, + }); + } + + // Ajouter validation, transformation, logique métier +} +``` + +### Checklist de modularisation d'un singleton node.js -> singleton NestJS + +Pour chaque service singleton node.js à migrer : + +- [ ] **Identifier** : Lister tous les singletons node.js (grep `module.exports =` ou `export default`) +- [ ] **Analyser** : Documenter l'interface publique (méthodes exposées) +- [ ] **État mutable** : Identifier les propriétés d'instance partagées +- [ ] **Dépendances** : Lister ce dont le singleton dépend +- [ ] **Créer le service** : Reproduire l'interface du singleton en NestJS +- [ ] **Tests de contrat** : Valider la parité comportementale avant/après +- [ ] **Migrer progressivement** : Remplacer les usages un par un +- [ ] **Nettoyage** : Supprimer le singleton node.js une fois inutilisé + +## 📝 Méthodologie de modularisation d'un module + +### Processus en 7 étapes + +#### 1. Analyse et préparation (Jour 1) + +```bash +# Analyser les dépendances du module +npx madge --depends my-module old-server/src + +# Lister les endpoints concernés +grep -r "router.*mymodule" old-server/src/routes + +# Identifier les tests E2E existants +grep -r "my-module" playwright/tests/e2e +``` + +**Livrables** : +- Liste des routes/endpoints à migrer +- Graphe des dépendances +- Plan de tests + +#### 2. Setup du nouveau module NestJS (Jour 1) + +**Note**: Afin d'accélérer la création des entités NestJS (services, modules, +contrôleurs, etc.), on pourra utiliser [la CLI `nest`](https://docs.nestjs.com/cli/usages#nest-generate) +pour générer des modèles (*templates*). + +```bash +# Générer le module avec NestJS CLI (par ex pour un module UserModule) +cd nest-backend +nest g module users +nest g controller users +nest g service users +``` + +**Structure créée** : +``` +src/users/ +├── users.module.ts +├── users.controller.ts +├── users.service.ts +├── dto/ +│ ├── create-user.dto.ts +│ └── update-user.dto.ts +├── entities/ +│ └── user.entity.ts +└── users.controller.spec.ts +``` + +#### 3. Modularisation de la logique métier (Jours 2-3) + +**Ordre de modularisation** : +1. DTOs et contrat de validation, suivant les besoins (certains services ont + besoin d'un DTO puisqu'ils utilisent la BDD, d'autres non) +2. Service (logique métier du module) +3. Controller (routes du module) +4. Guards et interceptors pour les contrôles qui peuvent être factorisés + +#### 4. Écriture des tests unitaires pour le nouveau service (Jour 3) + +#### 5. Adaptation des tests E2E si nécessaire (Jour 4) + +#### 6. Configuration du nginx étrangleur et déploiement (Jour 5) + +**Mise à jour de `apps/nginx-strangler/routing.conf`** : + +```nginx +# [S5-S6] Users - 2026-02-23 +location /api/xxxxx { + proxy_pass http://server-nest; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location /api/yyyyy { + proxy_pass http://server-nest; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Note: Prévoir un "blue-green" afin de permettre un passage souple et serein +entre les anciens et nouveaux services. + +**Déploiement progressif** : + +```bash +# 1. Backup de la config actuelle +cp apps/nginx-strangler/routing.conf apps/nginx-strangler/routing.conf.backup + +# 2. Mise à jour de la config +# (éditer apps/nginx-strangler/routing.conf) + +# 3. Validation de la config nginx +docker-compose exec nginx nginx -t + +# 4. Rechargement sans downtime +docker-compose exec nginx nginx -s reload + +# 5. Monitoring (surveiller les logs pendant 5-10min) +docker-compose logs -f nginx | grep users +``` + +Note: Prévoir un retour arrière en cas de problème ⚠ + +#### 7. Monitoring et validation (Jours 5-7) + +Note: Faire un point sur la métrologie existante. Envisager si besoin de +fiabiliser la stack d'observabilité, implémenter Loki et remonter des métriques +si besoin). + +**Métriques à surveiller** : + +```bash +# Logs en temps réel +docker-compose logs -f nest-api | grep users + +# Taux d'erreur +docker-compose logs nest-api | grep ERROR | grep users | wc -l + +# Temps de réponse moyen (exemple avec awk) +docker-compose logs nginx | grep "/api/users" | \ + awk '{print $NF}' | awk -F'=' '{sum+=$2; count++} END {print sum/count}' +``` + +**Validation** : +- [ ] Aucune erreur 5xx pendant 24h +- [ ] Temps de réponse comparable au code `server` (±20%) +- [ ] Tests E2E passants +- [ ] Recette utilisateurs (si applicable) + +## 🚨 Gestion des situations complexes + +### Cas 1 : Module avec beaucoup de dépendances + +**Stratégie** : +1. Migrer d'abord les dépendances (Users, Products) +2. Créer des adaptateurs temporaires (des services "passe-plat") pour les modules qui ne sont pas encore migrés +3. Tester intensivement les interactions entre modules (typage fort, tests d'intégration, E2E, etc.) + +## ✅ Checklist de fin de module + +Avant de considérer un module comme "migré" : + +- [ ] Tous les endpoints du module redirigent vers `server-nestjs` +- [ ] Tests de contrat passants (parité avec le code `server`) +- [ ] Tests E2E passants (éventuellement adaptés) +- [ ] Configuration nginx mise à jour et déployée +- [ ] Monitoring actif +- [ ] Aucune régression détectée +- [ ] Documentation technique à jour +- [ ] MODULARISATION_STATUS.md mis à jour +- [ ] Annonce de fin sur Mattermost diff --git a/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md b/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md new file mode 100644 index 0000000000..257b260401 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/02-ARCHITECTURE-MODULES.md @@ -0,0 +1,120 @@ +# Architecture d’un module (pattern `apps/server-nestjs/src/modules/*`) + +Les modules NestJS métier vivent dans `src/modules//` et suivent un découpage “vertical slice” avec des responsabilités explicites : **client**, **service (API publique)**, **controller service (orchestration)**, **datastore**, **utils** et **tests**. + +Exemples concrets : + +- Module GitLab : `src/modules/gitlab/` +- Module Keycloak : `src/modules/keycloak/` + +## Structure type + +```txt +src/modules// +├── .module.ts +├── .constants.ts +├── -client.service.ts +├── .service.ts +├── -datastore.service.ts +├── .utils.ts +├── -testing.utils.ts +└── *.spec.ts +``` + +## Sens des dépendances (flow recommandé) + +Objectif : un flux de dépendances lisible et sans cycles. + +```txt +.service.ts + ↙ ↘ +-client.service.ts -datastore.service.ts +``` + +Règles pratiques : + +- Le `.service.ts` est un entrypoint interne (cron, events, reconcile) et orchestre en appelant directement le `client` (et le `datastore` si nécessaire), sans dépendre du `service`. +- Le `service` contient les règles métier (décisions, transformations, validations) et s’appuie sur le `client` (et le `datastore` quand la lecture/écriture DB fait partie du cas d’usage). +- Le `client` encapsule l’accès à une API externe (initialisation + appels + erreurs bas niveau). +- Le `datastore` encapsule l’accès DB (Prisma) et expose des méthodes de lecture/écriture typées. +- Les `utils` restent “purs” (pas d’IO, pas d’injection Nest). +- Les `testing utils` centralisent les factories/fixtures pour réduire la duplication dans les tests. + +## Composants + +### `.module.ts` + +Rôle : + +- Déclare les providers, imports, exports du module. +- Exporte le `service` du module (`.service.ts`) qui constitue l’API publique. + +### `-client.service.ts` + +Rôle : + +- Adapter vers le système externe (SDK HTTP, client Keycloak, client GitLab, etc.). +- Conserver un contrat stable pour le reste du module. +- Mapper/normaliser les erreurs externes si nécessaire. + +À éviter : + +- Décisions métier (permissions, synchronisation, règles de purge) : elles vont dans `.service.ts` ou le controller service. + +### `.service.ts` + +Rôle : + +- Orchestrateur de workflows : `@Cron`, `@OnEvent`, reconcile périodique, tâches “batch”. +- Coordination entre `client` et `datastore` (sans dépendre du `service`). +- Garde-fous “safety” avant opérations destructrices (ex: suppression de groupes orphelins). + +### `-datastore.service.ts` + +Rôle : + +- Accès DB via Prisma (select/include, transactions, pagination). +- Exposition de types agrégés utiles au domaine (ex: `ProjectWithDetails`). + +À éviter : + +- Appliquer des règles métier (ex: calcul de permissions) : on garde le datastore centré persistence. + +### `.utils.ts` + +Rôle : + +- Fonctions utilitaires pures : mapping, helpers de collections, types partagés. +- Aucune dépendance Nest, aucune lecture/écriture DB, aucun appel réseau. + +### `-testing.utils.ts` + +Rôle : + +- Factories typées pour les structures fréquemment utilisées en tests. +- Support d’`overrides` pour construire rapidement des variantes. +- Centralisation des erreurs/fake responses spécifiques au module (quand utile). + +## Tests (Vitest) + +### `.service.spec.ts` + +Cible : + +- Orchestration : séquences d’appels, side-effects attendus, reconcile. + +Approche : + +- Mock du `service`, du `datastore`, et des appels externes. +- Vérification des appels effectués et des paramètres attendus. + +### `-datastore.service.spec.ts` (si présent) + +Cible : + +- Forme des requêtes Prisma, mapping de résultat, typage de l’agrégat renvoyé. + +Approche : + +- Mock de Prisma/DatabaseService, pas de logique métier. + diff --git a/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md b/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md new file mode 100644 index 0000000000..a5e6b10c1d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md @@ -0,0 +1,1276 @@ +# Cartographie des modules - Modularisation Backend + +> Derniere mise a jour : **2026-06-16** (refonte Infrastructure/Auth/Permission) + +--- + +## Objectif de ce document + +Cartographier l'ensemble des modules de l'application backend actuelle pour : +1. Identifier les dependances entre modules +2. Prioriser l'ordre de modularisation +3. Estimer la charge de chaque module +4. Faciliter la communication sur les zones en modularisation + +--- + +## Decisions structurantes + +| Decision | Choix | +|----------|-------| +| Approche migration | Bottom-up (feuilles d'abord, puis remontée vers les modules couples) | +| Contrats API | Decorateurs NestJS natifs + class-validator (abandon de ts-rest) | +| queries-index.ts | Supprime des le depart : chaque module NestJS possede ses propres queries Prisma | +| Systeme d'evenements | `@nestjs/event-emitter` (remplacement progressif de `@cpn-console/hooks`) | +| Plugins | Deviennent des modules NestJS (encapsulation puis reecriture progressive) | +| Module project | Decoupe en 3 sous-modules : ProjectCore, ProjectSecrets, ProjectBulk | +| Equipe dediee | 2 developpeurs en parallele | + +--- + +## Vue synthetique + +**75 routes metier** reparties en **18 modules metier** (dont project eclate en 3), +plus **5 couches transverses** et **7 plugins** a encapsuler. + +### Legende des statuts + +- FAIT : Deja implemente dans `server-nestjs` +- A CREER : N'existe pas encore +- A MIGRER : Existe dans `server`, a migrer vers `server-nestjs` + +--- + +## Architecture lasagnes - Etat des lieux + +``` + Couche Etat actuel server-nestjs ++--------------------------------------------------------------+ +| Couche 6 : Modules metier (controllers, DTOs) | A MIGRER ++--------------------------------------------------------------+ +| Couche 5 : Plugins (modules NestJS injectables) | A CREER ++--------------------------------------------------------------+ +| Couche 4 : Evenements (EventEmitter, remplacement hooks) | A CREER ++--------------------------------------------------------------+ +| Couche 3 : Securite (AuthService, UserGuard, ProjectGuard, Filters) | PARTIEL ++--------------------------------------------------------------+ +| Couche 2 : Core (AppService, FastifyService) | FAIT ++--------------------------------------------------------------+ +| Couche 1 : Infrastructure (Config, Logger, DB, HTTP) | FAIT ++--------------------------------------------------------------+ +``` + +Les couches 1 et 2 sont en place. La couche 3 est **partiellement** en place : +l'auth par token (`x-dso-token`) et le bearer JWT Keycloak passent désormais +par `AuthService`. La couche de permissions est structurée autour de +`InfrastructureModule -> PermissionModule -> UserModule / ProjectModule` +avec `UserGuard`, `ProjectGuard`, `UserService` et `ProjectService`. +Il reste a homogénéiser les usages des decorateurs de contexte utilisateur +(`@AuthUser()`), et le `GlobalExceptionFilter`. +Les couches 4 et 5 restent a creer. + +--- + +## Couche 0 - Socle transverse (S3, priorite absolue) + +Ces elements ne sont pas dans la matrice de scoring car ce ne sont pas des +modules metier. Ils conditionnent cependant la migration de **tous** les modules +qui suivent. Les creer en premier permet que chaque migration de module soit un +exercice de "remplissage" des couches superieures, sans reinventer +l'infrastructure a chaque fois. + +### 0a. AuthService + decorateur @AuthUser() — PARTIEL + +**Sprint** : S3 (Dev A) +**Remplace** : `authUser()` dans `apps/server/src/utils/controller.ts` + +**Etat actuel (2026-04-09)** : La partie auth par token est implementee dans +`infrastructure/auth/` : +- ✅ `AuthService` : validation token SHA256, lookup Prisma + (PersonalAccessToken + AdminToken), verification status/expiration, calcul + permissions bitwise (y compris roles globaux) +- ✅ `AuthService` : route `x-dso-token` et bearer JWT Keycloak vers + `DsoTokenService` / `KeycloakJwtService` +- ✅ `UserGuard` : applique les requirements de permission et injecte le + contexte utilisateur dans la request +- ✅ `@AuthUser()` : decorateur de parametre pour le contexte utilisateur +- ✅ `@RequireAdminPermission()` / `@RequireProjectPermission()` : metadata + de permissions admin et projet + +**Reste a faire** : +- Harmonisation des controles d'accès entre modules restants +- Chargement optionnel du projet (par slug, id, environmentId, repositoryId) +- Formalisation des types de contexte utilisateur/projet partages + +### 0b. GlobalExceptionFilter + +**Sprint** : S3 (Dev B) +**Remplace** : Les classes de `apps/server/src/utils/errors.ts` + +Mapping des erreurs : + +| Classe actuelle (server) | Equivalent NestJS | +|--------------------------|-------------------| +| `BadRequest400` | `BadRequestException` | +| `Unauthorized401` | `UnauthorizedException` | +| `Forbidden403` | `ForbiddenException` | +| `NotFound404` | `NotFoundException` | +| `Unprocessable422` | `UnprocessableEntityException` | +| `Internal500` | `InternalServerErrorException` | + +Le filtre global intercepte les `HttpException` NestJS et les formate dans le +schema de reponse attendu par le client (`{ status, body: { message } }`). + +### 0c. PermissionModule + decorateurs de permissions + +**Sprint** : S3-S4 (Dev A) +**Remplace** : `getProjectPermissions`, `AdminAuthorized`, `ProjectAuthorized` dans `utils/controller.ts` + +Fonctionnalites : +- Decorateurs `@RequireAdminPermission()` et `@RequireProjectPermission()` sur les methodes de controller +- Guard `UserGuard` qui verifie les bitmasks de permissions admin et projet +- Support des deux niveaux : `AdminRole` (global) et `ProjectRole` (scope projet) + +### 0d. ValidationPipe global + +**Sprint** : S3 (Dev B) +**Remplace** : La validation Zod des contrats ts-rest + +Configuration : +- `class-validator` + `class-transformer` en pipe global +- `whitelist: true` (strip des proprietes non declarees) +- `forbidNonWhitelisted: true` (erreur si proprietes inconnues) +- `transform: true` (transformation automatique des types) + +### 0e. EventEmitterModule + +**Sprint** : S4-S5 (Dev A) +**Remplace** : `@cpn-console/hooks` (premiere iteration) + +Mise en place de `@nestjs/event-emitter` avec : +- Evenements types pour chaque hook actuel (ProjectUpserted, ClusterDeleted, etc.) +- Pattern `@OnEvent('project.upserted')` pour les abonnements +- Pas de steps pre/check/main/post dans cette v1 : orchestration lineaire + +Ce module sera utilise par les modules metier des la Vague 2 (quand les modules +avec hooks commencent a etre migres). + +--- + +## Matrice de priorisation + +### Criteres de scoring + +| Critere | Poids | Description | +|---------|-------|-------------| +| Dependances faibles | 40% | Moins de dependances sortantes = plus facile a extraire (bottom-up) | +| Criticite | 30% | Balance risque/valeur. Un module critique et bien delimite score haut | +| Trafic | 20% | Volume raisonnable pour validation de la migration | +| Difficulte (inversee) | 10% | Complexite technique inversee (facile = score eleve) | + +### Formule + +``` +Score = (Dependances x 0.4) + (Criticite x 0.3) + (Trafic x 0.2) + (Difficulte x 0.1) + +Echelle : 1-10 pour chaque critere. +Plus le score est eleve, plus le module est prioritaire. +``` + +### Resultats ordonnes par score + +| Rang | Module | Type | Score | Vague | Sprint | Statut | +|------|--------|------|-------|-------|--------|--------| +| 1 | vault (encapsulation) | Plugin | 8.5 | V3 | S7-S8 | ✅ MIGRE | +| 2 | system (health/version) | Metier | 7.8 | V1 | S3 | ✅ MIGRE | +| 3 | system/settings | Metier | 7.4 | V1 | S3 | ✅ MIGRE | +| 4 | system/config | Metier | 7.4 | V1 | S3-S4 | | +| 5 | keycloak (encapsulation) | Plugin | 7.4 | V3 | S8 | ✅ MIGRE | +| 6 | admin-token | Metier | 7.1 | V1 | S3-S4 | | +| 7 | user/tokens | Metier | 7.1 | V1 | S3-S4 | | +| 8 | gitlab (encapsulation) | Plugin | 6.7 | V4 | S9 | ✅ MIGRE | +| 9 | service-monitor | Metier | 6.6 | V2 | S5 | | +| 10 | user | Metier | 6.6 | V2 | S5 | | +| 11 | stage | Metier | 6.5 | V2 | S5-S6 | | +| 12 | log | Metier | 6.5 | V1 | S4 | | +| 13 | zone | Metier | 6.4 | V2 | S6 | | +| 14 | environment | Metier | 6.3 | V3 | S7 | | +| 15 | admin-role | Metier | 6.1 | V2 | S5 | | +| 16 | project-core | Metier | 5.8 | V4 | S9 | | +| 17 | service-chain | Metier | 5.9 | V3 | S8 | ✅ MIGRE | +| 18 | repository | Metier | 5.8 | V3 | S7-S8 | | +| 19 | cluster | Metier | 5.7 | V3 | S7 | | +| 20 | harbor (encapsulation) | Plugin | 5.6 | V4 | S9-S10 | ✅ MIGRE | +| 21 | project-service | Metier | 5.6 | V3 | S8 | | +| 22 | argocd (encapsulation) | Plugin | 5.3 | V5 | S10-S11 | ✅ MIGRE | +| 23 | project-role | Metier | 5.2 | V3 | S7-S8 | | +| 24 | nexus (encapsulation) | Plugin | 5.1 | V4 | S10 | ✅ MIGRE | +| 25 | project-member | Metier | 4.7 | V3 | S8 | | +| 26 | project-secrets | Metier | 4.6 | V4 | S9 | | +| 27 | project-bulk | Metier | 4.2 | V4 | S9-S10 | | +| 28 | sonarqube (encapsulation) | Plugin | 4.2 | V5 | S11 | | + +**Note** : Le score brut ne dicte pas directement l'ordre de migration. +L'ordre reel est contraint par le graphe de dependances (bottom-up), les +pre-requis transverses, et la parallelisation a 2 devs. C'est pourquoi `vault` +(score 8.5) est en Vague 3 et non en Vague 1 : son encapsulation NestJS +necessite l'EventEmitter et un pattern valide sur les modules metier simples. + +--- + +## Vague 1 - Quick wins et validation du pattern (S3-S4) + +**Objectif** : Valider le pattern NestJS complet (Controller, Service, Prisma, +AuthGuard, DTOs class-validator, ExceptionFilter). Former l'equipe. Deployer +les premiers modules via Nginx. + +**Livrable fin S4** : 13 routes migrees (~17%) + +### 1. system (health/version) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.8 | +| **Sprint** | S3 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/version` - Version de l'application +- `GET /api/v1/healthz` - Health check + +**Dependances sortantes** : Aucune (lecture de variable d'env) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Module le plus simple du codebase. Ideal pour valider le pipeline complet : + creation controller NestJS, deploiement Docker, bascule Nginx +- Pas d'authentification requise sur ces routes + +**Estimation** : 0.5 jour + +--- + +### 2. system/settings — ✅ MIGRE (2026-04-28) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.4 | +| **Sprint** | S3 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/system/settings` - Liste des parametres systeme +- `PUT /api/v1/system/settings` - Mise a jour d'un parametre + +**Dependances sortantes** : Aucune (queries propres, Prisma direct) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Premiere occasion de valider le pattern CRUD NestJS avec DTOs +- Auth requise (admin uniquement) + +**Estimation** : 1 jour + +--- + +### 3. system/config + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 7.4 | +| **Sprint** | S3-S4 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/plugins-config` - Configuration des plugins +- `PUT /api/v1/admin/plugins-config` - Mise a jour de la configuration + +**Dependances sortantes** : Aucune (queries propres) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth admin requise +- Gestion de la configuration JSON des plugins (AdminPlugin en BDD) + +**Estimation** : 1 jour + +--- + +### 4. admin-token + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 7.1 | +| **Sprint** | S3-S4 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/admin/tokens` - Liste des tokens de service admin +- `POST /api/v1/admin/tokens` - Creation d'un token +- `DELETE /api/v1/admin/tokens/:tokenId` - Suppression d'un token + +**Dependances sortantes** : Aucune (Prisma direct, pas de queries-index) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth admin requise +- Premier module avec CRUD complet et generation de token (hash bcrypt) +- Pas de hook-wrapper utilise + +**Estimation** : 1 jour + +--- + +### 5. user/tokens + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 7.1 | +| **Sprint** | S3-S4 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/users/me/tokens` - Liste des tokens personnels +- `POST /api/v1/users/me/tokens` - Creation d'un token personnel +- `DELETE /api/v1/users/me/tokens/:tokenId` - Suppression d'un token + +**Dependances sortantes** : Aucune (Prisma direct) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Auth utilisateur requise (scope personnel : l'utilisateur gere ses propres tokens) +- Similaire a admin-token, bon pour paralleliser +- Hash bcrypt des tokens + +**Estimation** : 1 jour + +--- + +### 6. log + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 1 | +| **Score** | 6.5 | +| **Sprint** | S4 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/logs` - Logs d'audit du projet + +**Dependances sortantes** : `queries-index` (lecture seule via `getAllLogs`) +**Dependances entrantes** : Aucune (beaucoup de modules *ecrivent* des logs +via `addLogs`, mais ce module ne fait que *lire*) + +**Points d'attention** : +- Premiere rencontre avec queries-index. La strategie est de creer les queries + Prisma directement dans le module NestJS (pas de barrel partage) +- Auth utilisateur requise, scope projet +- Read-only : pas de hooks + +**Estimation** : 0.5 jour + +--- + +## Vague 2 - Fondations metier et premiers hooks (S5-S6) + +**Objectif** : Migrer les modules qui sont des dependances pour d'autres. +Introduction de l'EventEmitter dans les modules avec hooks. `stage`, `zone` +et `user` sont des pre-requis pour la Vague 3 (cluster, project-member). + +**Livrable fin S6** : 34 routes migrees (~45%) + +### 7. service-monitor + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 6.6 | +| **Sprint** | S5 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/services` - Sante des services (plugins) +- `GET /api/v1/services/all` - Sante complete de tous les services +- `POST /api/v1/services/refresh` - Rafraichissement des statuts + +**Dependances sortantes** : `@cpn-console/hooks` (appel direct des moniteurs de plugins) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Premier module utilisant le systeme de hooks (via les moniteurs de plugins) +- Bon premier test de l'EventEmitter : les plugins exposent leur moniteur, + le service-monitor les interroge +- Pas de queries Prisma (pas d'interaction BDD) + +**Estimation** : 1 jour + +--- + +### 8. user + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.6 | +| **Sprint** | S5 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/users` - Recherche d'utilisateurs (matching) +- `POST /api/v1/auth` - Authentification / synchronisation session +- `GET /api/v1/admin/users` - Liste complete des utilisateurs (admin) +- `PATCH /api/v1/admin/users` - Modification utilisateurs (admin) + +**Dependances sortantes** : `queries-index` (getMatchingUsers, getUsers), hooks (adminRole.upsert) +**Dependances entrantes** : `project-member` (importe `logViaSession`), `utils/controller.ts` (importe `logViaSession`, `logViaToken`) + +**Points d'attention** : +- Module critique : `logViaSession` et `logViaToken` sont consommes par le + systeme d'auth. Ces fonctions auront ete portees dans l'AuthGuard (Couche 0), + donc la dependance entrante est deja resolue +- Hooks utilises (adminRole.upsert) : necessite l'EventEmitter +- Pre-requis pour `project-member` (Vague 3) + +**Estimation** : 2 jours + +--- + +### 9. admin-role + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 6.1 | +| **Sprint** | S5 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/roles` - Liste des roles admin +- `POST /api/v1/admin/roles` - Creation d'un role +- `PATCH /api/v1/admin/roles` - Modification des roles +- `GET /api/v1/admin/roles/member-counts` - Comptage des membres par role +- `DELETE /api/v1/admin/roles/:roleId` - Suppression d'un role + +**Dependances sortantes** : `queries-index` (getAdminRoleById, listAdminRoles), hooks (adminRole.upsert, adminRole.delete) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Valide le pattern hooks + EventEmitter dans un contexte admin +- Permissions admin (bitmask) : valide `UserGuard` +- Queries a internaliser dans le module + +**Estimation** : 1.5 jours + +--- + +### 10. stage + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 6.5 | +| **Sprint** | S5-S6 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/stages` - Liste des stages (dev, staging, prod, etc.) +- `GET /api/v1/stages/:stageId/environments` - Environnements d'un stage +- `POST /api/v1/stages` - Creation d'un stage +- `PUT /api/v1/stages/:stageId` - Modification d'un stage +- `DELETE /api/v1/stages/:stageId` - Suppression d'un stage + +**Dependances sortantes** : `queries-index` (nombreuses queries stage + cluster) +**Dependances entrantes** : `cluster/business.ts` (importe directement `linkClusterToStages`) + +**Points d'attention** : +- Pre-requis critique pour `cluster` (Vague 3). La fonction `linkClusterToStages` + exportee par stage est consommee directement par cluster. Dans l'architecture + NestJS, ce sera un service injectable du StageModule exporte et importe par + ClusterModule +- Pas de hooks directs +- Beaucoup de queries a internaliser (11 fonctions dans queries-index) + +**Estimation** : 2 jours + +--- + +### 11. zone + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.4 | +| **Sprint** | S6 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/zones` - Liste des zones +- `POST /api/v1/zones` - Creation d'une zone +- `PUT /api/v1/zones/:zoneId` - Modification d'une zone +- `DELETE /api/v1/zones/:zoneId` - Suppression d'une zone + +**Dependances sortantes** : `queries-index` (addLogs), `./queries` (linkZoneToClusters), hooks (zone.upsert, zone.delete) +**Dependances entrantes** : Aucune directe (mais les hooks zone declenchent des actions dans les plugins keycloak, vault, gitlab) + +**Points d'attention** : +- Hooks utilises : zone.upsert et zone.delete declenchent des operations dans + 5 plugins (vault, keycloak, gitlab, argocd). C'est le premier module ou + l'EventEmitter sera teste en profondeur +- Interaction avec clusters via `linkZoneToClusters` +- Gestion des logs d'audit (addLogs) + +**Estimation** : 2 jours + +--- + +## Vague 3 - Modules couples + debut encapsulation plugins (S7-S8) + +**Objectif** : Migrer les modules a dependances croisees directes. En parallele, +debut d'encapsulation des plugins feuilles (vault, keycloak) dans des modules +NestJS injectables. + +**Livrable fin S8** : 66 routes migrees (~88%) + plugins vault et keycloak encapsules + +### 12. cluster + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 7 | +| **Score** | 5.7 | +| **Sprint** | S7 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/clusters` - Liste des clusters +- `GET /api/v1/clusters/:clusterId` - Details d'un cluster +- `GET /api/v1/clusters/:clusterId/usage` - Utilisation d'un cluster +- `POST /api/v1/clusters` - Creation d'un cluster +- `GET /api/v1/clusters/:clusterId/environments` - Environnements d'un cluster +- `PUT /api/v1/clusters/:clusterId` - Modification d'un cluster +- `DELETE /api/v1/clusters/:clusterId` - Suppression d'un cluster + +**Dependances sortantes** : +- `queries-index` (15+ fonctions : addLogs, createCluster, deleteCluster, + getClusterById, getClusterByLabel, getClusterDetails, getClusterEnvironments, + getProjectsByClusterId, linkClusterToProjects, linkZoneToClusters, listClusters, + listStagesByClusterId, removeClusterFromProject, removeClusterFromStage, updateCluster) +- **`stage/business.ts`** (import direct : `linkClusterToStages`) +- hooks (cluster.upsert, cluster.delete) +- `utils/business.ts` (Result monad) + +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Module le plus complexe en termes de dependances sortantes +- La dependance directe vers `stage/business` sera resolue par un import du + StageModule dans ClusterModule (stage migre en Vague 2) +- 15+ queries a internaliser dans le module +- Hooks cluster declenchent des operations dans vault et argocd +- Gestion des kubeconfigs (donnees sensibles) + +**Estimation** : 3 jours + +--- + +### 13. environment + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 6.3 | +| **Sprint** | S7 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/environments` - Liste des environnements +- `POST /api/v1/projects/:projectId/environments` - Creation d'un environnement +- `PUT /api/v1/environments/:environmentId` - Modification d'un environnement +- `DELETE /api/v1/environments/:environmentId` - Suppression d'un environnement + +**Dependances sortantes** : `queries-index` (addLogs, deleteEnvironment, getEnvironmentsByProjectId, initializeEnvironment, updateEnvironment), hooks +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Utilise le pattern `Result` (monad Success/Failure) de `utils/business.ts`. + A decider si on conserve ce pattern dans NestJS ou si on passe aux exceptions +- Scope projet : necessite `ProjectGuard` avec `ProjectAuthorized` +- Hooks environnement declenchent des operations dans les plugins + +**Estimation** : 2 jours + +--- + +### 14. repository + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.8 | +| **Sprint** | S7-S8 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/repositories` - Liste des depots +- `POST /api/v1/projects/:projectId/repositories/sync` - Synchronisation d'un depot +- `POST /api/v1/projects/:projectId/repositories` - Creation d'un depot +- `PUT /api/v1/repositories/:repositoryId` - Modification d'un depot +- `DELETE /api/v1/repositories/:repositoryId` - Suppression d'un depot + +**Dependances sortantes** : `queries-index` (addLogs, deleteRepository, getProjectInfosAndRepos, getProjectRepositories, initializeRepository, updateRepository), hooks (project.upsert, misc.syncRepository) +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Le hook `misc.syncRepository` est specifique a ce module (declenchement de + pipeline miroir GitLab) +- Scope projet : `ProjectGuard` requis +- Gestion des credentials de miroir (donnees sensibles dans Vault) + +**Estimation** : 2 jours + +--- + +### 15. project-role + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.2 | +| **Sprint** | S7-S8 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects/:projectId/roles` - Liste des roles projet +- `POST /api/v1/projects/:projectId/roles` - Creation d'un role +- `PATCH /api/v1/projects/:projectId/roles` - Modification des roles +- `GET /api/v1/projects/:projectId/roles/member-counts` - Comptage des membres +- `DELETE /api/v1/projects/:projectId/roles/:roleId` - Suppression d'un role + +**Dependances sortantes** : `queries-index` (deleteRole, listMembers, listRoles, updateRole), hooks (projectRole.upsert, projectRole.delete) +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- Pattern similaire a admin-role (migre en Vague 2) mais scope projet +- Bitmask de permissions projet +- Hooks projectRole declenchent des operations dans Keycloak + +**Estimation** : 1.5 jours + +--- + +### 16. project-member + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 4 | +| **Score** | 4.7 | +| **Sprint** | S8 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects/:projectId/members` - Liste des membres +- `POST /api/v1/projects/:projectId/members` - Ajout d'un membre +- `PATCH /api/v1/projects/:projectId/members` - Modification des membres +- `DELETE /api/v1/projects/:projectId/members/:memberId` - Retrait d'un membre + +**Dependances sortantes** : +- **`user/business.ts`** (import direct : `logViaSession`) +- `queries-index` (addLogs, deleteMember, listMembers, upsertMember) +- hooks (user.retrieveUserByEmail, projectMember.upsert, projectMember.delete) + +**Dependances entrantes** : Aucune directe + +**Points d'attention** : +- La dependance vers `user/business.ts` (`logViaSession`) aura ete resolue + par l'AuthGuard (Couche 0). Pas de dependance residuelle vers UserModule +- Hooks projectMember declenchent des operations dans Keycloak +- Recherche d'utilisateur par email via Keycloak (hook user.retrieveUserByEmail) + +**Estimation** : 1.5 jours + +--- + +### 17. project-service + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 2 | +| **Score** | 5.6 | +| **Sprint** | S8 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/services` - Liste des services/plugins du projet +- `PUT /api/v1/projects/:projectId/services` - Mise a jour des services + +**Dependances sortantes** : `queries-index` (getAdminPlugin, getProjectInfosByIdOrThrow, getProjectStore, getPublicClusters, saveProjectStore) +**Dependances entrantes** : `utils/hook-wrapper.ts` (importe `ConfigRecords` type et `dbToObj`) + +**Points d'attention** : +- Ce module exporte des types et fonctions utilitaires consommes par hook-wrapper. + Dans l'architecture NestJS, ces types seront definis dans un module partage + ou dans le ProjectServiceModule exporte +- Gestion de la configuration plugin par projet (JSON store) +- Pas de hooks directs + +**Estimation** : 1 jour + +--- + +### 18. service-chain — ✅ MIGRE (2026-04-09) + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.9 | +| **Sprint prevu** | S8 | +| **Migration effective** | 2026-04-09 | +| **Dev** | @stephane.trebel | + +**Routes (implementation reelle)** : +- `GET /api/v1/service-chains` - Liste des chaines de service +- `GET /api/v1/service-chains/:id` - Details d'une chaine +- `GET /api/v1/service-chains/:id/flows` - Flux de la chaine +- `POST /api/v1/service-chains/:id/retry` - Relance d'une chaine +- `POST /api/v1/service-chains/validate/:id` - Validation + +**Dependances sortantes** : API externe OpenCDS (HTTP via axios) +**Dependances entrantes** : Aucune + +**Implementation** : +- Module place dans la catégorie `ThirdPartyModules` (futur module optionnel CPin) comme envisagé +- Auth par token et bearer JWT via `AuthService` et `UserGuard` +- Validation UUID sur les parametres d'URL (`ParseUUIDPipe`) +- Validation des reponses OpenCDS via schemas Zod de `@cpn-console/shared` +- Telemetrie via `@StartActiveSpan()` (OpenTelemetry) +- Utilisation d'un service dédié `OpenCdsClientService` afin de gérer les requêtes vers le service OpenCDS externe + +**Differences avec le legacy** : +- 403 systematique si permissions insuffisantes (le legacy renvoyait `[]` sur GET /) +- Pas d'auth session Keycloak (token uniquement) +- Validation UUID stricte (400 si format invalide) +- Client OpenCDS dédié dans server-nestjs + +**Fichiers** : +- `src/cpin-module/service-chain/service-chain.*.ts` +- `src/cpin-module/service-chain/open-cds-client.*.ts` +- `src/modules/infrastructure/auth/` (AuthModule partage) + +--- + +### Plugin : vault (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 7 | +| **Score** | 8.5 | +| **Sprint** | S7-S8 | +| **Dev** | A (en parallele de cluster) | + +**Encapsulation** : Wrapper les 3 classes existantes (VaultApi, VaultProjectApi, +VaultZoneApi) dans un `VaultModule` NestJS avec des services `@Injectable()`. +La logique interne reste intacte. Le module expose `VaultService`, +`VaultProjectService`, `VaultZoneService` via DI. + +**Dependances sortantes (plugins)** : Aucune (feuille du graphe de plugins) +**Dependances entrantes (plugins)** : gitlab, keycloak, harbor, nexus, sonarqube, argocd (tous) + +**Points d'attention** : +- Plugin fondation de toute la chaine. Doit etre encapsule en premier +- Les autres plugins consomment les API classes de vault via le systeme de hooks. + Dans NestJS, ils importeront VaultModule et injecteront les services +- Configuration par variables d'environnement (a integrer dans ConfigurationService) + +**Estimation encapsulation** : 2 jours +**Estimation reecriture complete** : 5 jours (ulterieur) + +--- + +### Plugin : keycloak (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 11 (le plus grand nombre) | +| **Score** | 7.4 | +| **Sprint** | S8 | +| **Dev** | B (en parallele de project-member/service-chain) | + +**Encapsulation** : Wrapper `KeycloakProjectApi` et les fonctions de `functions.ts` +dans un `KeycloakModule` NestJS. + +**Dependances sortantes (plugins)** : vault (runtime via `payload.apis.vault`) +**Dependances entrantes (plugins)** : argocd, harbor, sonarqube + +**Points d'attention** : +- 11 hooks souscrits couvrant projets, zones, roles, membres, utilisateurs +- Le plus grand nombre de fonctions exportees (12) +- Utilise `@keycloak/keycloak-admin-client` (dependance externe) +- Necessite vault encapsule avant (depend de VaultModule) + +**Estimation encapsulation** : 2 jours +**Estimation reecriture complete** : 5-7 jours (ulterieur) + +--- + +## Vague 4 - Module project (decoupe) + plugins intermediaires (S9-S10) + +**Objectif** : Migrer le coeur metier (project) en 3 sous-modules independants. +Encapsuler les plugins intermediaires. + +**Livrable fin S10** : 75 routes migrees (100% des routes metier) + plugins +vault, keycloak, gitlab, harbor, nexus encapsules + +### 19. project-core + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 5 | +| **Score** | 5.8 | +| **Sprint** | S9 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/projects` - Liste des projets +- `GET /api/v1/projects/:projectId` - Details d'un projet +- `POST /api/v1/projects` - Creation d'un projet +- `PUT /api/v1/projects/:projectId` - Modification d'un projet +- `DELETE /api/v1/projects/:projectId` - Archivage d'un projet + +**Dependances sortantes** : `queries-index` (addLogs, getProjectOrThrow, getSlugs, initializeProject, listProjects, lockProject, updateProject), hooks (project.upsert, project.delete, projectRole.upsert) +**Dependances entrantes** : Presque tous les modules a scope projet + +**Points d'attention** : +- Coeur absolu du systeme. Toute erreur ici impacte tout +- Le CRUD projet declenche des hooks vers TOUS les plugins (upsertProject, + deleteProject). C'est le plus gros consommateur de l'EventEmitter +- Gestion du statut projet (created/failed/warning/archived) via state machine +- Verrouillage projet (`lockProject`) pendant les operations longues +- La creation declenche : Keycloak (groupes), Vault (KV store), GitLab (groupe + + repos), Harbor (registre), Nexus (repos), SonarQube (projets), ArgoCD (config) + +**Estimation** : 3 jours + +--- + +### 20. project-secrets + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 1 | +| **Score** | 4.6 | +| **Sprint** | S9 | +| **Dev** | B | + +**Routes** : +- `GET /api/v1/projects/:projectId/secrets` - Secrets du projet + +**Dependances sortantes** : hooks (project.getSecrets) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Appelle le hook `getProjectSecrets` qui interroge chaque plugin pour ses + secrets (Vault, GitLab mirror token, Harbor robot, Nexus credentials, etc.) +- Donnees sensibles : attention au logging et a la serialisation +- Necessite que les plugins soient encapsules pour fonctionner correctement + +**Estimation** : 1 jour + +--- + +### 21. project-bulk + +| Attribut | Valeur | +|----------|--------| +| **Routes** | 3 | +| **Score** | 4.2 | +| **Sprint** | S9-S10 | +| **Dev** | A | + +**Routes** : +- `GET /api/v1/admin/projects/data` - Export bulk des donnees projets +- `POST /api/v1/admin/projects/bulk` - Action bulk sur les projets +- `POST /api/v1/projects/:projectId/replay-hooks` - Rejeu des hooks d'un projet + +**Dependances sortantes** : `queries-index` (getAllProjectsDataForExport, deleteAllEnvironmentForProject, deleteAllRepositoryForProject), hooks (project.upsert pour replay) +**Dependances entrantes** : Aucune + +**Points d'attention** : +- Routes admin uniquement +- Le bulk action itere sur les projets avec une limite de parallelisme + (`parallelBulkLimit` dans la config). Attention aux performances +- Le replay-hooks re-execute toute la chaine de hooks pour un projet +- L'export data peut generer de gros payloads + +**Estimation** : 2 jours + +--- + +### Plugin : gitlab (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 8 | +| **Score** | 6.7 | +| **Sprint** | S9 | +| **Dev** | B | + +**Encapsulation** : Wrapper les 3 classes (GitlabApi, GitlabZoneApi, +GitlabProjectApi) dans un `GitlabModule` NestJS. + +**Dependances sortantes (plugins)** : vault (VaultProjectApi pour secrets) +**Dependances entrantes (plugins)** : argocd, nexus, sonarqube + +**Points d'attention** : +- Plugin le plus complexe (13 fichiers source, classe de 570 lignes) +- Utilise `@gitbeaker/core` et `@gitbeaker/rest` (dependances externes) +- Systeme de "pending commits" (commits bufferises puis envoyes en batch) +- Necessite vault encapsule avant + +**Estimation encapsulation** : 2-3 jours +**Estimation reecriture complete** : 7-10 jours (ulterieur) + +--- + +### Plugin : harbor (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 3 | +| **Score** | 5.6 | +| **Sprint** | S9-S10 | +| **Dev** | A | + +**Encapsulation** : Wrapper dans un `HarborModule` NestJS. + +**Dependances sortantes (plugins)** : keycloak (getProjectGroupPath), vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- API generee (fichier `api/Api.ts`) +- Gestion des quotas (parsing de tailles humaines "100MB", "1.2GB") +- Necessite keycloak et vault encapsules avant + +**Estimation encapsulation** : 1.5 jours + +--- + +### Plugin : nexus (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 3 | +| **Score** | 5.1 | +| **Sprint** | S10 | +| **Dev** | B | + +**Encapsulation** : Wrapper dans un `NexusModule` NestJS. + +**Dependances sortantes (plugins)** : vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Gestion Maven + NPM (repos group/hosted/snapshot) +- Configuration riche (activation par techno) +- Necessite vault encapsule avant + +**Estimation encapsulation** : 1.5 jours + +--- + +## Vague 5 - Plugins terminaux + finalisation (S10-S12) + +**Objectif** : Encapsuler les derniers plugins. Demarrer le nettoyage. + +**Livrable fin S11** : Tous les plugins encapsules en modules NestJS + +### Plugin : argocd (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 5 | +| **Score** | 5.3 | +| **Sprint** | S10-S11 | +| **Dev** | A | + +**Encapsulation** : Wrapper dans un `ArgoCDModule` NestJS. + +**Dependances sortantes (plugins)** : gitlab (GitlabProjectApi), keycloak (KeycloakProjectApi), vault (VaultProjectApi, VaultZoneApi) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Le plugin le plus couple (depend de 3 autres plugins) +- Generation YAML complexe (value files pour Helm) +- Necessite gitlab, keycloak et vault encapsules avant +- Dernier plugin avec dependances, donc naturellement parmi les derniers + +**Estimation encapsulation** : 2 jours + +--- + +### Plugin : sonarqube (encapsulation) + +| Attribut | Valeur | +|----------|--------| +| **Hooks souscrits** | 2 | +| **Score** | 4.2 | +| **Sprint** | S11 | +| **Dev** | B | + +**Encapsulation** : Wrapper dans un `SonarQubeModule` NestJS. + +**Dependances sortantes (plugins)** : gitlab (GitlabProjectApi pour CI vars), keycloak (getProjectGroupPath), vault (secrets) +**Dependances entrantes (plugins)** : Aucune + +**Points d'attention** : +- Depend de 3 autres plugins (comme argocd) +- Interaction avec l'API SonarQube + ecriture de variables CI dans GitLab +- Necessite gitlab, keycloak et vault encapsules avant + +**Estimation encapsulation** : 1.5 jours + +--- + +## Vague 6 - Nettoyage (S11-S12) + +- Suppression du reverse proxy Nginx (tout pointe vers `server-nestjs`) +- Suppression du code `apps/server` +- Renommage `server-nestjs` en `server` +- Suppression de `@cpn-console/hooks` (remplace par EventEmitter NestJS) +- Suppression de `ts-rest` cote server (remplace par decorateurs NestJS) +- Mise a jour de tous les `docker-compose` et scripts PNPM/Bash +- Reecriture progressive des plugins (au-dela de l'encapsulation) -- planifiee + dans des iterations futures + +--- + +## Graphe de dependances + +### Graphe actuel (genere par Madge) + +![Graphe des dependances de `server`](./graph-dependances-server-avant.png) + +Commande : + +``` +npx madge --image dependency-graph.png --extensions js,ts apps/server/src +``` + +### Graphe cible des modules NestJS + +```mermaid +flowchart TD + subgraph Transverse["Couche 0 : Socle transverse"] + AuthGuard["AuthService + @AuthUser()"] + ExceptionFilter["GlobalExceptionFilter"] + PermissionsGuard["UserGuard + ProjectGuard"] + ValidationPipe["ValidationPipe global"] + EventEmitter["EventEmitterModule"] + end + + subgraph Infrastructure["Couche 1-2 : Infrastructure (FAIT)"] + ConfigModule["ConfigurationModule"] + LoggerModule["LoggerModule"] + DatabaseService["DatabaseService (Prisma)"] + AppService["AppService"] + FastifyService["FastifyService"] + end + + subgraph Feuilles["Vague 1 : Modules feuilles"] + SystemModule["SystemModule (health, version)"] + SettingsModule["SystemSettingsModule"] + ConfigPluginModule["SystemConfigModule"] + AdminTokenModule["AdminTokenModule"] + UserTokensModule["UserTokensModule"] + LogModule["LogModule"] + end + + subgraph Fondations["Vague 2 : Fondations metier"] + ServiceMonitorModule["ServiceMonitorModule"] + UserModule["UserModule"] + AdminRoleModule["AdminRoleModule"] + StageModule["StageModule"] + ZoneModule["ZoneModule"] + end + + subgraph Couples["Vague 3 : Modules couples"] + ClusterModule["ClusterModule"] + EnvironmentModule["EnvironmentModule"] + RepositoryModule["RepositoryModule"] + ProjectRoleModule["ProjectRoleModule"] + ProjectMemberModule["ProjectMemberModule"] + ProjectServiceModule["ProjectServiceModule"] + ServiceChainModule["ServiceChainModule"] + end + + subgraph ProjectGroup["Vague 4 : Project (decoupe)"] + ProjectCoreModule["ProjectCoreModule"] + ProjectSecretsModule["ProjectSecretsModule"] + ProjectBulkModule["ProjectBulkModule"] + end + + subgraph Plugins["Vague 3-5 : Plugins NestJS"] + VaultModule["VaultModule"] + KeycloakModule["KeycloakModule"] + GitlabModule["GitlabModule"] + HarborModule["HarborModule"] + NexusModule["NexusModule"] + ArgoCDModule["ArgoCDModule"] + SonarQubeModule["SonarQubeModule"] + end + + %% Dependances infrastructure + Feuilles --> AuthGuard + Feuilles --> ExceptionFilter + Feuilles --> ValidationPipe + Fondations --> EventEmitter + Fondations --> PermissionsGuard + + %% Dependances inter-modules metier + ClusterModule --> StageModule + ProjectMemberModule -.-> UserModule + + %% Dependances plugins + KeycloakModule --> VaultModule + GitlabModule --> VaultModule + HarborModule --> KeycloakModule + HarborModule --> VaultModule + NexusModule --> VaultModule + ArgoCDModule --> GitlabModule + ArgoCDModule --> KeycloakModule + ArgoCDModule --> VaultModule + SonarQubeModule --> GitlabModule + SonarQubeModule --> KeycloakModule + SonarQubeModule --> VaultModule + + %% Evenements + ProjectCoreModule -.->|evenements| Plugins + ZoneModule -.->|evenements| Plugins + ClusterModule -.->|evenements| Plugins +``` + +### Dependances directes inter-modules (code actuel) + +| Module source | Importe depuis | Fonction | Resolution NestJS | +|---------------|----------------|----------|-------------------| +| cluster | stage/business | `linkClusterToStages` | ClusterModule importe StageModule | +| project-member | user/business | `logViaSession` | Resolu par AuthService / AuthUser (Couche 0) | +| utils/controller | user/business | `logViaSession`, `logViaToken` | Resolu par AuthService / AuthUser (Couche 0) | +| utils/hook-wrapper | project-service/business | `ConfigRecords`, `dbToObj` | Types partages ou ProjectServiceModule exporte | + +### Dependances inter-plugins + +``` +vault (feuille -- aucune dependance plugin) + | + +--- keycloak (vault en runtime) + | | + | +--- harbor (keycloak + vault) + | +--- argocd (gitlab + keycloak + vault) + | +--- sonarqube (gitlab + keycloak + vault) + | + +--- gitlab (vault) + | + +--- nexus (vault, gitlab en devDependency) + +--- argocd (gitlab + keycloak + vault) + +--- sonarqube (gitlab + keycloak + vault) +``` + +--- + +## Modules transverses + +### Middlewares globaux + +| Middleware | Statut | Module NestJS | +|-----------|--------|---------------| +| CORS | A configurer | AppService (Fastify) | +| Helmet (securite) | FAIT | AppService | +| Cookie + Session | FAIT | AppService | +| Body parser | FAIT | Fastify (natif) | +| Compression | A evaluer | Fastify compression plugin | +| Logging (requetes) | FAIT | LoggerModule (nestjs-pino) | +| Configuration (.env) | FAIT | ConfigurationModule | +| Validation (DTOs) | A CREER | ValidationPipe global (Couche 0) | + +### Services partages + +| Service | Statut | Module NestJS | +|---------|--------|---------------| +| PrismaService (DB) | FAIT | DatabaseService | +| LoggerService | FAIT | LoggerModule | +| ConfigService (env) | FAIT | ConfigurationModule | +| HttpClientService | STUB (vide) | A implementer dans InfrastructureModule | +| ServerService (ts-rest) | STUB | A supprimer (abandon ts-rest) | + +--- + +## Checklist de cartographie + +### Sprint 1 + +- [x] Installer outils d'analyse (Madge) +- [x] Generer graphe de dependances automatique +- [x] Lister toutes les routes (75 routes identifiees) +- [x] Identifier les modules metier principaux (16 domaines + 7 plugins) +- [x] Premiere version de cette cartographie + +### Sprint 2 + +- [x] Affiner les dependances inter-modules +- [x] Calculer les scores de priorisation (28 unites scorees) +- [x] Definir l'ordre de modularisation (6 vagues, bottom-up) +- [x] Documenter les points d'attention par module +- [x] Finaliser les estimations de charge +- [x] Completer ce fichier +- [ ] Valider l'ordre de modularisation avec l'equipe + +--- + +## Estimations de charge par vague + +| Vague | Sprint | Routes | Modules | Charge estimee (j/p) | Objectif | +|-------|--------|--------|---------|----------------------|----------| +| V0 | S3 | 0 | 5 transverses | 5 j/p | Socle (Guards, Filters, Pipes, EventEmitter) | +| V1 | S3-S4 | 13 | 6 | 5 j/p | Quick wins, validation pattern | +| V2 | S5-S6 | 21 | 5 | 9 j/p | Fondations metier, premiers hooks | +| V3 | S7-S8 | 32 | 7 metier + 2 plugins | 17 j/p | Modules couples, vault + keycloak | +| V4 | S9-S10 | 9 | 3 metier + 3 plugins | 13 j/p | Project decoupe, gitlab + harbor + nexus | +| V5 | S10-S11 | 0 | 2 plugins | 3.5 j/p | argocd + sonarqube | +| V6 | S11-S12 | 0 | 0 | 5 j/p | Nettoyage, suppression server | +| **Total** | **S3-S12** | **75** | **28** | **~57.5 j/p** | | + +Avec 2 developpeurs dedies : **~29 jours calendaires** (soit ~6 sprints d'une +semaine), ce qui s'inscrit dans la fenetre S3-S12 (10 sprints disponibles). +La marge de 4 sprints absorbe les imprevisibles, la montee en competence +NestJS, et les eventuelles sessions de pair programming sur les modules +complexes. + +--- + +## Propagation de la modularisation + +Mettre a jour dans ce document : +- Le chemin parcouru par un module migre (quel environnement, a quel + moment/jalon, et avec quel resultat succes/echec) +- Tracer les manques de tests d'integration ou E2E sur un module suivant les + retours effectues +- Expliciter les regles de suivi dans la metrologie de la migration d'un module + (avant/apres, payload et requetes concernees, etc.) + +--- + +## Contact + +**Responsable cartographie** : @stephane.trebel +**Questions** : #backend-modularisation sur Mattermost + +--- + +**Version** : 2.0 +**Derniere mise a jour** : 2026-02-23 +**Prochaine revision majeure** : Fin S4 (premier bilan post-Vague 1) diff --git a/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md b/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md new file mode 100644 index 0000000000..fe899d0bdf --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/MODULARISATION-STATUT.md @@ -0,0 +1,214 @@ +# État de la modularisation Backend → NestJS + +> 📋 **Ce fichier est mis à jour en temps réel** +> Dernière mise à jour : **2026-07-23** + +--- + +## 🎯 Progression globale + +![Progress](https://progress-bar.dev/7/?title=modularisation&width=400) + +**~60%** complété (16/18 modules métier migrés, 45/75 routes) + +--- + +## 📊 Vue d'ensemble + +| Statut | Nombre de modules | % du total | +|--------|-------------------|------------| +| ✅ Migré | 16 | ~89% | +| 🚧 En cours | 0 | 0% | +| 📅 Planifié | 2 | ~11% | +| ⏳ En attente de cartographie | 0 | 0% | + +--- + +## ✅ Modules migrés + +### ServiceChain (OpenCDS) — migré le 2026-04-09 + +Module proxy HTTP vers l'API externe OpenCDS (gestion des chaînes de service +réseau). Migré en avance de phase par rapport au planning initial (prévu V3/S8), +profitant de son isolement complet vis-à-vis du reste du codebase. + +- **Routes** : 5 (`/api/v1/service-chains/...`) +- **Auth** : `AuthService` unifie `x-dso-token` et bearer JWT Keycloak +- **Nginx** : Bascule effectuée dans `nginx-strangler/conf.d/routing.conf` +- **Tests** : Controller + Service couverts (Vitest) +- **Différences avec le legacy** : + - 403 systématique si permissions insuffisantes (le legacy renvoyait `[]` sur GET /) + - Validation UUID sur les paramètres d'URL (400 si format invalide) + +| Méthode | Route | Permission | +|---------|-------|------------| +| `GET` | `/api/v1/service-chains` | `ListSystem` | +| `GET` | `/api/v1/service-chains/:id` | `ListSystem` | +| `GET` | `/api/v1/service-chains/:id/flows` | `ListSystem` | +| `POST` | `/api/v1/service-chains/:id/retry` | `ManageSystem` | +| `POST` | `/api/v1/service-chains/validate/:id` | `ManageSystem` | + +### Infrastructure transverse déployée + +En support de cette migration, les éléments d'infrastructure suivants ont été +créés : + +- **AuthModule** (`infrastructure/auth/`) : `AuthService` (validation token + SHA256 via Prisma + bearer JWT Keycloak), `DsoTokenModule`, + `KeycloakJwtModule` et décorateur `@AuthUser()` +- **PermissionModule** (`infrastructure/permission/`) : `UserModule` + (`UserGuard`, `UserService`, `UserPolicy`) + `ProjectModule` + (`ProjectGuard`, `ProjectLoaderService`, `ProjectService`, `ProjectPolicy`) +- **Nginx strangler** : Reverse proxy configuré pour router les routes migrées + vers server-nestjs, le reste vers le legacy +- **Docker** : Build order corrigé (shared avant server-nestjs) + +> **Limitation connue** : l'auth par session Keycloak et le flux bearer JWT ont +> désormais la même entrée publique (`AuthService.authenticate(request, ...)`), +> mais les usages côté contrôleurs restent encore à homogénéiser au fil des +> modules migrés. + +--- + +## 🚧 En cours de modularisation + +### Aucune modularisation en cours + +--- + +## 📅 Modules planifiés + +> Ces informations seront affinées après la cartographie (fin S2) + +### Sprint 3-4 (27 janvier - 9 février) +- **Module** : Auth +- **Responsable** : @stephane.trebel +- **Routes** : ~5 +- **Criticité** : 🔴 Haute +- **Statut** : 📅 Planifié + +### Sprint 5-6 (10-23 février) +- **Module** : Users +- **Responsable** : TBD +- **Routes** : ~10 +- **Criticité** : 🔴 Haute +- **Statut** : 📅 Planifié + +### Sprint 7-8 (24 février - 9 mars) +- **Module** : À définir (selon cartographie) +- **Responsable** : TBD +- **Routes** : TBD +- **Statut** : 📅 Planifié + +### Sprint 9-10 (10-23 mars) +- **Module** : À définir (selon cartographie) +- **Responsable** : TBD +- **Routes** : TBD +- **Statut** : 📅 Planifié + +### Sprint 11-12 (24 mars - 6 avril) +- **Modules** : Finalisation + cleanup +- **Responsable** : Équipe +- **Objectif** : Suppression du legacy + +--- + +## 🚫 Zones en feature freeze + +### Aucune zone gelée actuellement + +**Règle** : Quand un module passe en status 🚧 (En cours), il est automatiquement en feature freeze. + +**Que faire si vous devez travailler sur une zone gelée ?** +1. Vérifier l'urgence réelle (Critique / Importante / Normale) +2. Consulter la [matrice de décision](04-COMMUNICATION-PLAN.md#matrice-de-décision-pour-développeurs) +3. Coordonner avec le responsable de la modularisation +4. Annoncer sur #backend-modularisation + +--- + +## 📈 Métriques de qualité + +### Couverture de tests + +| Type | Initial | Actuel | Objectif | +|------|---------|--------|----------| +| E2E Playwright | 33% | 33% | 50% | +| Unitaires Vitest | ? | ? | 70% | +| Tests de contrat | 0% | 0% | 100% | + +### Routes par statut + +- **Total** : ~75 routes métier +- **Migrés** : 45 (~60%) +- **En cours** : 0 (0%) +- **Restants** : ~30 (~40%) + +--- + +## 🗓️ Dates clés + +| Date | Événement | +|------|-----------| +| 07/01/2026 | Début du projet (S1) | +| 26/01/2026 | Fin de la cartographie (S2) | +| 27/01/2026 | Début modularisation Auth (S3) | +| 09/02/2026 | Fin modularisation Auth (S4) - 20% complété | +| 09/03/2026 | Point mi-parcours - 60% complété | +| 26/03/2026 | Migration ServiceChain (OpenCDS) finalisée — 1er module métier migré | +| 06/04/2026 | Fin de modularisation - 100% complété | + +--- + +## 📞 Contacts & Communication + +### Canaux +- **Slack** : #backend-modularisation +- **Meeting hebdo** : Vendredi 16h (30min) +- **Lead technique** : @stephane.trebel + +### Besoin d'aide ? +- **Question technique** : Poser sur #backend-modularisation +- **Conflit de développement** : Contacter @stephane.trebel +- **Urgence production** : Suivre la procédure de rollback (voir [01-TECHNICAL-SETUP.md](01-TECHNICAL-SETUP.md)) + +--- + +## 📚 Documentation + +- [00-OVERVIEW.md](00-OVERVIEW.md) - Vue d'ensemble du projet +- [01-TECHNICAL-SETUP.md](01-TECHNICAL-SETUP.md) - Configuration technique +- [02-modularisation-STRATEGY.md](02-modularisation-STRATEGY.md) - Stratégie de modularisation +- [03-PLANNING.md](03-PLANNING.md) - Planning détaillé 12 sprints +- [04-COMMUNICATION-PLAN.md](04-COMMUNICATION-PLAN.md) - Plan de communication +- [05-TESTING-STRATEGY.md](05-TESTING-STRATEGY.md) - Stratégie de tests +- [modularisation_MAP.md](modularisation_MAP.md) - Cartographie des modules + +--- + +## 🔄 Historique des changements + +### 2026-04-09 +- ✅ Migration du module **ServiceChain (OpenCDS)** — 5 routes, proxy HTTP vers API externe +- ✅ Création de l'**AuthModule** (infrastructure/auth/) : auth par token `x-dso-token` +- ✅ Configuration **nginx-strangler** pour les routes service-chain +- ✅ Fix Docker : build order shared → server-nestjs +- ✅ Mise à jour de ce fichier de suivi + +### 2026-01-07 (S1) +- ✅ Création du fichier de suivi +- ✅ Initialisation de la documentation +- ✅ Kickoff du projet + +--- + +## 📝 Notes + +> Ce fichier sera mis à jour régulièrement (minimum une fois par sprint, idéalement en temps réel). +> Toute l'équipe peut consulter ce fichier pour connaître l'état actuel de la modularisation. +> Pour proposer des modifications ou signaler des incohérences, utiliser #backend-modularisation. + +--- + +**Version du fichier** : 1.1 +**Responsable de mise à jour** : Lead technique backend diff --git a/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md b/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md new file mode 100644 index 0000000000..811aa04f0c --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/README.md @@ -0,0 +1,120 @@ +# Documentation de Modularisation du Backend de Console (apps/server) vers NestJS + +Ce dossier contient toute la documentation nécessaire pour mener à bien la modularisation du backend Node.js vers NestJS. + +## 📁 Structure de la documentation + +### Documents principaux (à lire dans l'ordre) + +1. **00-OVERVIEW.md** - Vue d'ensemble du projet de modularisation + - Objectifs et contexte + - Stratégie "Strangler Pattern" + - Principes directeurs + - Métriques de succès + +2. **01-TECHNICAL-SETUP.md** - Configuration technique détaillée + - Configuration Nginx (reverse proxy) qui va progressivement "étrangler" `server` au profit de `server-nestjs` + - Gestion Prisma/BDD partagée entre `server` et `server-nestjs` + - Déploiement `docker compose` + +3. **02-MODULARISATION-STRATEGY.md** - Stratégie et méthodologie + - Modularisation par slices verticales + - Cartographie et priorisation + - Gestion singletons → Injection de dépendances + - Méthodologie en 7 étapes par module + - Architecture Decision Records (ADR) + +4. **03-PLANNING.md** - Planning détaillé sur les prochains sprints + - Découpage sprint par sprint + - Sprints 1-2 : Fondations + - Sprints 3-4 : Modules transverses de `server` (logger, etc.) + - Sprints 5-6 : Modules applicatifs (Zones, Clusters, etc.) + - Sprints 7-10 : Modules métier (les fameux plugins) + - Sprints 11-12 : Finalisation + - Métriques et KPIs + +5. **04-COMMUNICATION-PLAN.md** - Plan de communication équipe + - Canaux de communication (Mattermost, fichiers, réunions) + - Gestion des zones en modularisation + - Modèles d'annonces concernant la modularisation + - Matrice de décision pour développeurs + - Procédure en cas de conflit + +6. **05-TESTING-STRATEGY.md** - Stratégie de tests + - Tests de contrat (nouveaux, critiques) + - Tests E2E Playwright + - Tests unitaires Vitest + - Tests d'intégration + - Workflow de tests pendant modularisation + - Intégration CI/CD + +### Fichiers vivants (mis à jour régulièrement) + +7. **MODULARISATION_STATUT.md** - Suivi en temps réel de l'avancement + - Progression globale + - Modules migrés + - Modules en cours + - Zones en "feature freeze" du fait de leur modularisation en cours 🚧 + - Dates clés + +8. **MODULARISATION_CARTOGRAPHIE.md** - Cartographie des modules + - Liste de tous les modules avec métadonnées + - Graphe de dépendances + - Scoring et priorisation des modules + - Estimation de charge + +## 🚀 Par où commencer ? + +### Si vous êtes le lead technique de la modularisation + +1. Lire **00-OVERVIEW.md** pour la vue d'ensemble +2. Consulter **01-TECHNICAL-SETUP.md** pour préparer l'infrastructure +3. Étudier **02-MODULARISATION-STRATEGY.md** pour la méthodologie +4. Planifier avec **03-PLANNING.md** +5. Organiser la communication avec **04-COMMUNICATION-PLAN.md** +6. Mettre en place les tests avec **05-TESTING-STRATEGY.md** +7. Compléter **MODULARISATION_MAP.md** lors de la cartographie (S1-S2) +8. Maintenir **MODULARISATION_STATUT.md** à jour tout au long de la modularisation + +### Si vous êtes développeur dans l'équipe + +1. Lire **00-OVERVIEW.md** pour comprendre le projet +2. Consulter **MODULARISATION_STATUT.md** AVANT de traiter un ticket, pour éviter de toucher à une zone en cours de modularisation +3. Suivre le canal Mattermost #backend-modularisation qui sera créé dans cet objectif +4. Respecter les zones en modularisation (🚧) +5. Consulter **04-COMMUNICATION-PLAN.md** en cas de conflit + +## 📊 Contexte du projet + +- **Durée** : 3 mois (12 semaines) +- **Équipe** : 5 développeurs +- **Endpoints** : ~100 +- **Approche** : Strangler Fig Pattern (modularisation progressive) +- **Stack cible** : NestJS + Prisma + Keycloak OIDC + +## 🎯 Objectifs + +- ✅ 100% des endpoints migrés sur NestJS +- ✅ 0 régression fonctionnelle +- ✅ Couverture tests E2E ≥ 50% +- ✅ Équipe autonome sur NestJS +- ✅ Documentation complète + +## 📞 Contact + +- **Lead technique** : @stephane.trebel +- **Canal Mattermost** : #backend-modularisation +- **Réunion hebdo** : Jeudi 10h (30min) + +## 📚 Ressources externes + +- [NestJS Documentation](https://docs.nestjs.com/) +- [Prisma Documentation](https://www.prisma.io/docs) +- [Playwright Documentation](https://playwright.dev/) +- [Vitest Documentation](https://vitest.dev/) + +--- + +**Bonne modularisation ! 🚀** + +*Version 1.0 - Dernière mise à jour : 2026-01-07* diff --git a/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png b/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png new file mode 100644 index 0000000000..569dd60e19 Binary files /dev/null and b/.jjconflict-side-2/apps/server-nestjs/documentation/Modularisation-de-console-server/graph-dependances-server-avant.png differ diff --git a/.jjconflict-side-2/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md b/.jjconflict-side-2/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md new file mode 100644 index 0000000000..b7ba2c4258 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/documentation/mise-en-place-nginx-etrangleur/PLAN.md @@ -0,0 +1,271 @@ +# Plan : Nginx "étrangleur" pour la transition `server` → `server-nestjs` + +> Ticket de référence : [#1885](https://github.com/cloud-pi-native/console/issues/1885) +> PR en cours : [#1951](https://github.com/cloud-pi-native/console/pull/1951) — branche `add-server-nextjs-to-local-dev` +> Dernière mise à jour : 2026-02-26 + +--- + +## Contexte et état des lieux + +| Élément | État | +|---|---| +| `server` (Fastify) | Port `8080` interne, exposé `4001` sur l'hôte | +| Nginx actuel | Embarqué dans l'image `client`, proxifie `/api` + `/swagger-ui` vers `server:8080` — upstream changé vers `nginx-strangler:8080` | +| `server-nestjs` | Dockerisé (port `3001`), ajouté dans tous les docker-compose | +| `nginx-strangler` | Service dédié créé dans `apps/nginx-strangler/`, fallback total sur `server` legacy, exposé sur le port `4000` pour se "substituer" à `server` | +| Envs couverts | Local dev, docker-compose (dev/ci/integ) | + +--- + +## Architecture cible + +``` +[Navigateur / API client] + │ + ▼ +┌────────────────────┐ +│ Client (8080) │ ← nginx embarqué dans l'image client +│ nginx "front" │ SPA + proxy /api → nginx-strangler +└────────┬───────────┘ + │ proxy_pass http://nginx-strangler:8080 + ▼ +┌────────────────────┐ +│ nginx-strangler │ ← service dédié (port 8080 interne, non-root) +│ (routing as-code) │ +└──────┬─────────────┘ + │ + ├── /api/[routes migrées] ──→ server-nestjs:3001 + └── /api/[tout le reste] ──→ server:8080 (legacy) +``` + +**Principe clé** : le nginx client existant reste inchangé dans son rôle SPA, mais son upstream `api` est redirigé vers `nginx-strangler` plutôt que directement vers `server`. Le `nginx-strangler` devient l'unique point de routage des appels API. + +--- + +## Décisions structurantes + +| Décision | Choix | +|---|---| +| Emplacement du nginx-strangler | Répertoire `apps/nginx-strangler/` à la racine du dépôt | +| Port interne de `nginx-strangler` | `8080` (non-root, Trivy DS002) | +| Port interne de `server-nestjs` | `3001` (distinct du legacy sur `8080`) | +| Envs PAX / MinInt | Couverts via docker-compose uniquement dans ce ticket | +| Config nginx | `envsubst` pour substituer les upstreams selon l'environnement | +| Rollback | Commenter une `location` dans `routing.conf` + `nginx -s reload` | +| Sécurité image nginx | `USER nginx`, `HEALTHCHECK`, `chown` sur les répertoires système nginx | +| Sécurité image server-nestjs | `USER node`, `HEALTHCHECK` sur le stage `prod` (Trivy DS026) | + +--- + +## Découvertes en cours de réalisation + +- Le nginx existant dans l'image `client` proxifiait directement vers `server:8080` — upstream changé vers `nginx-strangler:8080` +- `server-nestjs/src/main.ts` utilisait `process.env.PORT ?? 0` au lieu de `ConfigurationService.port` — corrigé +- Le schéma Prisma de `server-nestjs` est multi-fichiers dans `src/prisma/schema/` — `prisma generate` doit pointer sur le dossier, pas sur `schema.prisma` +- En CI (`job-lint.yml`), `nginx -t` échoue si `apt-get update -qq` n'est pas exécuté avant l'install, et si les chemins `pid`/`error_log`/`access_log` ne sont pas patchés vers `/tmp/` (runner non-root) +- En CI (`job-playwright.yml`), les images `server-nestjs:ci` et `nginx-strangler:ci` doivent être buildées localement avant le `docker compose up --no-build` (même pattern que `opencds-mockoon`) + +--- + +## Phase 1 — Socle nginx-strangler ✅ + +### Tâche 1.1 : Créer `apps/nginx-strangler/` ✅ + +``` +apps/nginx-strangler/ +├── Dockerfile # image nginx:1.27-alpine, USER nginx, HEALTHCHECK, EXPOSE 8080, envsubst entrypoint +├── nginx.conf # directives globales (worker_processes, logs, include conf.d/) +├── conf.d/ +│ ├── routing.conf # fichier "vivant" des routes (commenté par vague de migration) +│ └── routing.local.conf.example # template documentaire pour le mode local natif +└── README.md # procédures opérationnelles (déploiement, rollback, ajout de route, dev local) +``` + +### Tâche 1.2 : Contenu initial de `routing.conf` ✅ + +Le fichier utilise des variables d'environnement substituées via `envsubst` pour permettre la réutilisation de la même image dans tous les contextes (Docker, local natif) : + +```nginx +upstream server-legacy { + server ${LEGACY_UPSTREAM}; +} + +upstream server-nestjs { + server ${NESTJS_UPSTREAM}; +} + +server { + listen 8080; + + # ── Routes migrées vers NestJS ────────────────────────────────────────── + # Format : # [Vague X - Module] AAAA-MM-JJ + # (vide au démarrage — toutes les routes sont en fallback sur server-legacy) + + # ── Fallback : tout le reste vers le server legacy ─────────────────────── + location /api/ { + proxy_pass http://server-legacy; + ... + } + + location /swagger-ui { + proxy_pass http://server-legacy; + ... + } +} +``` + +Exemple de bascule future (à ajouter au fil des vagues) : +```nginx +# [Vague 1 - system/health] 2026-03-XX +location = /api/v1/system/health { + proxy_pass http://server-nestjs; + ... +} +``` + +--- + +## Phase 2 — Dockerisation de `server-nestjs` ✅ + +### Tâche 2.1 : Fix du port dans `main.ts` ✅ + +`apps/server-nestjs/src/main.ts` utilisait `process.env.PORT ?? 0` — câblé sur `ConfigurationService.port`. + +### Tâche 2.2 : Créer `apps/server-nestjs/Dockerfile` ✅ + +Multi-stage : `base` → `deps` → `build` → `prod`. `prisma generate --schema=src/prisma/schema` (dossier multi-fichiers). `USER node`, `HEALTHCHECK`, `EXPOSE 3001`. + +### Tâche 2.3 : `apps/server-nestjs/.env.docker-example` ✅ + +`SERVER_PORT=3001` présent. + +--- + +## Phase 3 — Mise à jour des docker-compose ✅ + +### Tâche 3.1 : `docker/docker-compose.dev.yml` ✅ + +Services `server-nestjs` (avec Docker Compose Watch) et `nginx-strangler` ajoutés. `client` dépend de `nginx-strangler`. + +### Tâche 3.2 : `docker/docker-compose.prod.yml` ✅ + +Idem sans Watch. Bug `depends_on` vide sur `opencds-mockoon` corrigé. + +### Tâche 3.3 : `docker/docker-compose.ci.yml` ✅ + +Idem prod. + +### Tâche 3.4 : `docker/docker-compose.integ.yml` ✅ + +Idem dev (avec Watch) + volumes kubeconfig. + +### Tâche 3.5 : `docker/docker-compose.local.yml` ✅ + +`nginx-strangler` ajouté avec `host.docker.internal`, port `8082:8080`, `extra_hosts: host-gateway`. + +### Tâche 3.6 : `apps/client/nginx/default.docker.conf` ✅ + +Upstream `server:8080` → `nginx-strangler:8080`. + +--- + +## Phase 4 — Dev local (pnpm dev natif) ✅ + +### Tâche 4.1 : Template de config pour le mode local ✅ + +`apps/nginx-strangler/conf.d/routing.local.conf.example` créé avec `host.docker.internal` comme valeurs d'exemple. + +### Tâche 4.2 : Documentation ✅ + +- `ENVIRONMENTS.md` : refonte complète avec section Strangler Fig, `server-nestjs` mentionné partout, sous-section dédiée au geste manuel `SERVER_PORT=8082` +- `apps/client/.env-example` : note sur `SERVER_PORT=8082` pour passer par le nginx-strangler en local + +Variables par contexte : + +| Contexte | `LEGACY_UPSTREAM` | `NESTJS_UPSTREAM` | +|---|---|---| +| Docker (dev/prod/ci/integ) | `server:8080` | `server-nestjs:3001` | +| Local (pnpm dev) | `127.0.0.1:4001` | `127.0.0.1:3001` | +| PAX / MinInt | À adapter selon la topologie réseau | + +--- + +## Phase 5 — Validation et outillage + +### Tâche 5.1 : Validation `nginx -t` dans le CI ✅ + +Étape ajoutée dans `.github/workflows/job-lint.yml` : +- `apt-get update -qq` + install `nginx gettext-base` +- `envsubst` sur `routing.conf` +- `sed` pour patcher `user`, `pid`, `error_log`, `access_log` vers des chemins accessibles en non-root +- `nginx -t -c /tmp/nginx-test/nginx.conf` + +### Tâche 5.2 : Smoke test ❌ (à faire) + +Vérifier dans les tests Playwright que `/api/v1/system/health` répond correctement en passant par le nginx-strangler (et non plus directement par `server`). + +### Tâche 5.3 : Procédure de rollback documentée ✅ + +Documentée dans `apps/nginx-strangler/README.md` : + +```bash +# 1. Commenter la location concernée dans routing.conf +# 2. Recharger nginx sans downtime +docker compose exec nginx-strangler nginx -s reload +# 3. Vérifier les logs +docker compose logs -f nginx-strangler | grep /api/v1/xxx +``` + +### Tâche 5.4 (ajout) : Build des images CI dans job-playwright ✅ + +`docker buildx build -t dso-console/server-nestjs:ci` et `nginx-strangler:ci` ajoutés dans `.github/workflows/job-playwright.yml` avant le `docker compose up --no-build`. + +--- + +## Critères d'acceptation + +- [x] Le service `nginx-strangler` démarre correctement dans tous les docker-compose +- [x] Les appels `/api/*` continuent de fonctionner sans régression (fallback total sur `server`) +- [x] `nginx -t` passe en CI +- [x] La config est documentée et le format de bascule des routes est clairement défini +- [x] La procédure de rollback est documentée +- [x] Le dev local (`pnpm dev`) fonctionne avec le nginx-strangler en Docker +- [ ] Smoke test Playwright sur `/api/v1/system/health` via nginx-strangler + +--- + +## Ce qui reste à faire (post-merge PR #1951) + +1. **Tâche 5.2** : Smoke test Playwright (optionnel pour la PR courante, recommandé avant Vague 1) +2. **Vague 1** : Basculer les premiers modules dans `apps/nginx-strangler/conf.d/routing.conf` : + - `system` (health, config, settings) + - `admin-token` + - `user/tokens` + - `log` + - Référence : [`MODULARISATION-CARTOGRAPHIE.md`](../Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) + +--- + +## Séquençage initial et estimation de charge + +``` +Phase 1 : Créer apps/nginx-strangler/ (structure + routing.conf) Dev A ~0.5j ✅ +Phase 2 : Dockeriser server-nestjs (Dockerfile + port) Dev B ~1j ✅ + + Modifier upstream nginx client Dev A ~0.5h ✅ +Phase 3 : Mettre à jour tous les docker-compose Dev A+B ~1j ✅ +Phase 4 : Config locale (envsubst + doc) Dev A ~0.5j ✅ +Phase 5 : CI + smoke test + rollback doc Dev B ~0.5j 🔄 (5.1 ✅, 5.2 ❌, 5.3 ✅) +───────────────────────────────────────────────────────────────────── +Total estimé ~3.5 j/p +``` + +--- + +## Liens + +- Ticket GitHub : [#1885](https://github.com/cloud-pi-native/console/issues/1885) +- PR : [#1951](https://github.com/cloud-pi-native/console/pull/1951) +- Documentation Nginx strangler dans la stratégie de modularisation : [`01-MODULARISATION-STRATEGIE.md`](../Modularisation-de-console-server/01-MODULARISATION-STRATEGIE.md) +- Cartographie des modules (vagues de migration) : [`MODULARISATION-CARTOGRAPHIE.md`](../Modularisation-de-console-server/MODULARISATION-CARTOGRAPHIE.md) +- Suivi de l'avancement : [`MODULARISATION-STATUT.md`](../Modularisation-de-console-server/MODULARISATION-STATUT.md) diff --git a/.jjconflict-side-2/apps/server-nestjs/eslint.config.mjs b/.jjconflict-side-2/apps/server-nestjs/eslint.config.mjs new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/eslint.config.mjs @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-2/apps/server-nestjs/nest-cli.json b/.jjconflict-side-2/apps/server-nestjs/nest-cli.json new file mode 100644 index 0000000000..f9aa683b1a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/package.json b/.jjconflict-side-2/apps/server-nestjs/package.json new file mode 100644 index 0000000000..79126d32e2 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/package.json @@ -0,0 +1,111 @@ +{ + "name": "@cpn-console/server-nestjs", + "version": "9.22.1", + "private": true, + "description": "", + "author": "", + "license": "UNLICENSED", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "scripts": { + "prebuild": "pnpm i --frozen-lockfile", + "build": "nest build", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev --name dso", + "db:reset": "prisma migrate reset", + "format": "eslint ./ --fix", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "start": "nest start", + "start:debug": "nest start --debug --watch", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "pretest": "pnpm run db:generate", + "test": "vitest run", + "test:watch": "vitest", + "pretest:cov": "pnpm run db:generate", + "test:cov": "vitest run --coverage", + "test:debug": "vitest --inspect" + }, + "dependencies": { + "@cpn-console/argocd-plugin": "workspace:^", + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/harbor-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/nexus-plugin": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/sonarqube-plugin": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/requester-utils": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "@keycloak/keycloak-admin-client": "catalog:runtime", + "@kubernetes-models/argo-cd": "catalog:runtime", + "@nestjs/cache-manager": "catalog:runtime", + "@nestjs/common": "catalog:runtime", + "@nestjs/config": "catalog:runtime", + "@nestjs/core": "catalog:runtime", + "@nestjs/event-emitter": "catalog:runtime", + "@nestjs/jwt": "catalog:runtime", + "@nestjs/platform-fastify": "catalog:runtime", + "@nestjs/schedule": "catalog:runtime", + "@nestjs/swagger": "catalog:runtime", + "@nestjs/terminus": "catalog:runtime", + "@opentelemetry/api": "catalog:otel", + "@opentelemetry/auto-instrumentations-node": "catalog:otel", + "@opentelemetry/exporter-metrics-otlp-proto": "catalog:otel", + "@opentelemetry/exporter-trace-otlp-proto": "catalog:otel", + "@opentelemetry/instrumentation-nestjs-core": "catalog:otel", + "@opentelemetry/instrumentation-pino": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-node": "catalog:otel", + "@prisma/client": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@ts-rest/fastify": "catalog:runtime", + "@ts-rest/open-api": "catalog:runtime", + "cache-manager": "catalog:runtime", + "date-fns": "catalog:runtime", + "fastify": "catalog:runtime", + "fastify-keycloak-adapter": "catalog:runtime", + "json-2-csv": "catalog:runtime", + "mustache": "catalog:runtime", + "nestjs-pino": "catalog:runtime", + "prisma": "catalog:runtime", + "reflect-metadata": "catalog:runtime", + "rxjs": "catalog:runtime", + "undici": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime", + "yaml": "catalog:runtime", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@eslint/eslintrc": "catalog:tools", + "@eslint/js": "catalog:tools", + "@faker-js/faker": "catalog:test", + "@nestjs/cli": "catalog:build", + "@nestjs/schematics": "catalog:build", + "@nestjs/testing": "catalog:build", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "eslint": "catalog:tools", + "globals": "catalog:test", + "msw": "catalog:test", + "pino-pretty": "catalog:tools", + "rimraf": "catalog:tools", + "ts-patch": "catalog:build", + "typescript": "catalog:build", + "typescript-eslint": "catalog:tools", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/prisma.config.ts b/.jjconflict-side-2/apps/server-nestjs/prisma.config.ts new file mode 100644 index 0000000000..ff5aaf9186 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/prisma.config.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' +import { defineConfig } from 'prisma/config' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +export default defineConfig({ + schema: path.join('src', 'prisma', 'schema'), + migrations: { + path: path.join('src', 'prisma', 'migrations'), + }, +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/__mocks__/prisma.ts b/.jjconflict-side-2/apps/server-nestjs/src/__mocks__/prisma.ts new file mode 100644 index 0000000000..2a871fd476 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/__mocks__/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-side-2/apps/server-nestjs/src/config/argocd.config.ts b/.jjconflict-side-2/apps/server-nestjs/src/config/argocd.config.ts new file mode 100644 index 0000000000..a7f1ecc094 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/config/argocd.config.ts @@ -0,0 +1,25 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const argocdFeatureSchema = z.object({ + ARGO_NAMESPACE: z.string().default('argocd'), + ARGOCD_URL: z.string().url(), + ARGOCD_INTERNAL_URL: z.string().url().optional(), + ARGOCD_EXTRA_REPOSITORIES: z.string().min(1), + DSO_ENV_CHART_VERSION: z.string().default('dso-env-1.6.0'), + DSO_NS_CHART_VERSION: z.string().default('dso-ns-1.1.5'), + VAULT__DEPLOY_VAULT_CONNECTION_IN_NS: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform(raw => ({ + namespace: raw.ARGO_NAMESPACE, + url: raw.ARGOCD_URL, + internalUrl: raw.ARGOCD_INTERNAL_URL, + extraRepositories: raw.ARGOCD_EXTRA_REPOSITORIES, + dsoEnvChartVersion: raw.DSO_ENV_CHART_VERSION, + dsoNsChartVersion: raw.DSO_NS_CHART_VERSION, + vaultDeployVaultConnectionInNs: raw.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS, + internalOrPublicUrl: raw.ARGOCD_INTERNAL_URL ?? raw.ARGOCD_URL, + probeUrl: new URL('/api/version', raw.ARGOCD_URL).toString(), +})) + +export const argocdConfigFactory = registerAs('argocd', () => argocdFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/config/base.config.ts b/.jjconflict-side-2/apps/server-nestjs/src/config/base.config.ts new file mode 100644 index 0000000000..2b8e7af3e8 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/config/base.config.ts @@ -0,0 +1,48 @@ +import type { BaseConfig } from '../modules/infrastructure/config/base.config' +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, nonEmpty, truthySchema } from './config.utils' + +const baseFeatureSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).optional(), + INTEGRATION: flag(truthySchema.default('false')), + CI: flag(truthySchema.default('false')), + DEV_SETUP: flag(truthySchema.default('false')), + DOCKER: flag(truthySchema.default('false')), + SERVER_HOST: z.string().default('localhost'), + SERVER_PORT: z.string().transform(Number).default('0'), + APP_VERSION: z.string().optional().default('unknown'), + DB_URL: z.string().url().optional(), + SESSION_SECRET: z.string().min(32).optional(), + CONTACT_EMAIL: z.string().email().default('cloudpinative-relations@interieur.gouv.fr'), + MOCK_PLUGINS: flag(truthySchema.default('false')), + PROJECTS_ROOT_DIR: nonEmpty(z.string()), + PLUGINS_DIR: z.string().default('/plugins'), + HTTP_PROXY: nonEmpty(z.string().url()), + HTTPS_PROXY: nonEmpty(z.string().url()), +}).transform((raw): BaseConfig => ({ + nodeEnv: raw.NODE_ENV === 'test' ? 'test' : raw.NODE_ENV === 'development' ? 'development' : 'production', + isTest: raw.NODE_ENV === 'test', + isDev: raw.NODE_ENV === 'development', + isCI: raw.CI, + isProd: raw.NODE_ENV === 'production', + integration: raw.INTEGRATION, + ci: raw.CI, + devSetup: raw.DEV_SETUP, + docker: raw.DOCKER, + serverHost: raw.SERVER_HOST, + serverPort: raw.SERVER_PORT, + appVersion: raw.NODE_ENV === 'production' ? raw.APP_VERSION : 'dev', + dbUrl: raw.DB_URL, + sessionSecret: raw.SESSION_SECRET, + contactEmail: raw.CONTACT_EMAIL, + mockPlugins: raw.MOCK_PLUGINS, + projectsRootDir: raw.PROJECTS_ROOT_DIR, + pluginsDir: raw.PLUGINS_DIR, + httpProxy: raw.HTTP_PROXY, + httpsProxy: raw.HTTPS_PROXY, +})) + +export const baseConfigFactory = registerAs('base', () => baseFeatureSchema.parse(process.env)) + +export type { BaseConfig } from '../modules/infrastructure/config/base.config' diff --git a/.jjconflict-side-2/apps/server-nestjs/src/config/config.utils.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/config/config.utils.spec.ts new file mode 100644 index 0000000000..155c025349 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/config/config.utils.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import z from 'zod' +import { csv, flag, truthySchema } from './config.utils' + +describe('config.utils', () => { + describe('flag', () => { + it('coerces true/1 (case-insensitive) to true', () => { + const schema = flag(truthySchema.default('false')) + expect(schema.parse('true')).toBe(true) + expect(schema.parse('1')).toBe(true) + expect(schema.parse('TRUE')).toBe(true) + }) + + it('coerces false/0 to false', () => { + const schema = flag(truthySchema.default('true')) + expect(schema.parse('false')).toBe(false) + expect(schema.parse('0')).toBe(false) + }) + + it('falls back to the schema default when missing', () => { + expect(flag(truthySchema.default('false')).parse(undefined)).toBe(false) + expect(flag(truthySchema.default('true')).parse(undefined)).toBe(true) + }) + }) + + describe('csv', () => { + const schema = csv(z.string()) + + it('splits, trims and drops empty parts', () => { + expect(schema.parse('a, b ,,c')).toEqual(['a', 'b', 'c']) + }) + + it('maps missing/empty to an empty array', () => { + expect(schema.parse(undefined)).toEqual([]) + expect(schema.parse('')).toEqual([]) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/config/config.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/config/config.utils.ts new file mode 100644 index 0000000000..f06cde73f8 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/config/config.utils.ts @@ -0,0 +1,27 @@ +import z from 'zod' + +// Shared truthy enum for flag(): 'true'/'false'/'1'/'0'. +export const truthySchema = z.enum(['true', 'false', '1', '0']) + +// Boolean flag. Strictly takes a z string schema (e.g. truthySchema.default('true')). +// Lowercases, then coerces 'true'/'1' -> true, 'false'/'0' -> false. +// Missing value falls back to the schema's default. +export function flag(schema: z.ZodType) { + return z + .preprocess( + val => (typeof val === 'string' ? val.toLowerCase() : val), + schema, + ) + .transform(val => val === 'true' || val === '1') +} + +// Comma-separated string -> array of schema-validated, trimmed non-empty parts. +// Empty/missing -> []. Strictly takes a z string schema (applied per element). +export function csv>(schema: T) { + return z + .preprocess( + val => (typeof val === 'string' ? val : val ?? ''), + z.string().transform(value => value.split(',').map(part => part.trim()).filter(Boolean)), + ) + .pipe(z.array(schema)) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/config/gitlab.config.ts b/.jjconflict-side-2/apps/server-nestjs/src/config/gitlab.config.ts new file mode 100644 index 0000000000..d376279d4f --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/config/gitlab.config.ts @@ -0,0 +1,28 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const gitlabFeatureSchema = z.object({ + GITLAB_TOKEN: z.string().min(1), + GITLAB_URL: z.string().url(), + GITLAB_INTERNAL_URL: z.string().url().optional(), + GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS: z.coerce.number().int().positive().default(180), + GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + GITLAB__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), + PROJECTS_ROOT_DIR: z.string().min(1), +}).transform((raw) => { + const urlBase = raw.GITLAB_INTERNAL_URL ?? raw.GITLAB_URL + return { + token: raw.GITLAB_TOKEN, + url: raw.GITLAB_URL, + internalUrl: raw.GITLAB_INTERNAL_URL, + secretExposeInternalUrl: raw.GITLAB__SECRET_EXPOSE_INTERNAL_URL, + mirrorTokenExpirationDays: raw.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS, + mirrorTokenRotationThresholdDays: raw.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS, + projectRootDir: raw.PROJECTS_ROOT_DIR, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/-/health', urlBase).toString(), + } +}) + +export const gitlabConfigFactory = registerAs('gitlab', () => gitlabFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/config/harbor.config.ts b/.jjconflict-side-2/apps/server-nestjs/src/config/harbor.config.ts new file mode 100644 index 0000000000..3e4d093272 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/config/harbor.config.ts @@ -0,0 +1,34 @@ +import type { HarborConfig } from '../modules/registry/harbor.module-definition' +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const harborFeatureSchema = z.object({ + HARBOR_URL: z.string().url(), + HARBOR_INTERNAL_URL: z.string().url().optional(), + HARBOR_ADMIN: z.string().min(1), + HARBOR_ADMIN_PASSWORD: z.string().min(1), + HARBOR_RULE_TEMPLATE: z.string().optional(), + HARBOR_RULE_COUNT: z.string().optional(), + HARBOR_RETENTION_CRON: z.string().default('0 22 2 * * *'), + HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + HARBOR_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform((raw) => { + const urlBase = raw.HARBOR_INTERNAL_URL ?? raw.HARBOR_URL + return { + url: raw.HARBOR_URL, + internalUrl: raw.HARBOR_INTERNAL_URL, + admin: raw.HARBOR_ADMIN, + adminPassword: raw.HARBOR_ADMIN_PASSWORD, + ruleTemplate: raw.HARBOR_RULE_TEMPLATE, + ruleCount: raw.HARBOR_RULE_COUNT, + retentionCron: raw.HARBOR_RETENTION_CRON, + robotRotationThresholdDays: raw.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.HARBOR_PROJECT_SLUG_CACHE_TTL_MS, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/api/v2.0/ping', urlBase).toString(), + } +}) + +export type HarborAppConfig = HarborConfig + +export const harborConfigFactory = registerAs('harbor', () => harborFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/config/keycloak.config.ts b/.jjconflict-side-2/apps/server-nestjs/src/config/keycloak.config.ts new file mode 100644 index 0000000000..24f7284666 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/config/keycloak.config.ts @@ -0,0 +1,55 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, nonEmpty, truthySchema } from './config.utils' + +const keycloakRawSchema = z.object({ + USE_KEYCLOAK: flag(truthySchema.default('true')), + KEYCLOAK_PROTOCOL: z.string().default('https'), + KEYCLOAK_DOMAIN: nonEmpty(z.string()), + KEYCLOAK_PUBLIC_PROTOCOL: z.string().default('https'), + KEYCLOAK_PUBLIC_DOMAIN: nonEmpty(z.string()), + KEYCLOAK_REALM: nonEmpty(z.string()), + KEYCLOAK_CLIENT_ID: nonEmpty(z.string()), + KEYCLOAK_CLIENT_SECRET: nonEmpty(z.string()), + KEYCLOAK_ADMIN: nonEmpty(z.string()), + KEYCLOAK_ADMIN_PASSWORD: nonEmpty(z.string()), + KEYCLOAK_ADMIN_CLIENT_ID: z.string().default('admin-cli'), + KEYCLOAK_REDIRECT_URI: nonEmpty(z.string().url()), + KEYCLOAK_JWKS_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + KEYCLOAK_JWKS_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), + KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + ADMIN_KC_USER_ID: z.preprocess( + value => (typeof value === 'string' ? value.split(',').map(part => part.trim()).filter(Boolean) : value), + z.array(z.string()).default([]), + ), +}) + +function mapKeycloakConfig(raw: z.infer) { + const keycloakUrl = `${raw.KEYCLOAK_PROTOCOL}://${raw.KEYCLOAK_DOMAIN}` + const keycloakRealmUrl = `${keycloakUrl}/realms/${raw.KEYCLOAK_REALM}` + return { + enabled: raw.USE_KEYCLOAK, + protocol: raw.KEYCLOAK_PROTOCOL, + domain: raw.KEYCLOAK_DOMAIN, + publicProtocol: raw.KEYCLOAK_PUBLIC_PROTOCOL, + publicDomain: raw.KEYCLOAK_PUBLIC_DOMAIN, + realm: raw.KEYCLOAK_REALM, + clientId: raw.KEYCLOAK_CLIENT_ID, + clientSecret: raw.KEYCLOAK_CLIENT_SECRET, + admin: raw.KEYCLOAK_ADMIN, + adminPassword: raw.KEYCLOAK_ADMIN_PASSWORD, + adminClientId: raw.KEYCLOAK_ADMIN_CLIENT_ID, + redirectUri: raw.KEYCLOAK_REDIRECT_URI, + jwksCacheTtlMs: raw.KEYCLOAK_JWKS_CACHE_TTL_MS, + jwksTimeoutMs: raw.KEYCLOAK_JWKS_TIMEOUT_MS, + openidConfigurationCacheTtlMs: raw.KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS, + adminKcUserId: raw.ADMIN_KC_USER_ID, + url: keycloakUrl, + realmUrl: keycloakRealmUrl, + openidConfigurationUrl: `${keycloakRealmUrl}/.well-known/openid-configuration`, + } +} + +const keycloakConfigSchema = keycloakRawSchema.transform(mapKeycloakConfig) + +export const keycloakConfigFactory = registerAs('keycloak', () => keycloakConfigSchema.parse(process.env)) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/config/nexus.config.ts b/.jjconflict-side-2/apps/server-nestjs/src/config/nexus.config.ts new file mode 100644 index 0000000000..cfdc23000b --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/config/nexus.config.ts @@ -0,0 +1,24 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const nexusFeatureSchema = z.object({ + NEXUS_URL: z.string().url(), + NEXUS_INTERNAL_URL: z.string().url().optional(), + NEXUS_ADMIN: z.string().min(1), + NEXUS_ADMIN_PASSWORD: z.string().min(1), + NEXUS__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + const urlBase = raw.NEXUS_INTERNAL_URL ?? raw.NEXUS_URL + return { + url: raw.NEXUS_URL, + internalUrl: raw.NEXUS_INTERNAL_URL, + admin: raw.NEXUS_ADMIN, + adminPassword: raw.NEXUS_ADMIN_PASSWORD, + secretExposeInternalUrl: raw.NEXUS__SECRET_EXPOSE_INTERNAL_URL, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/service/rest/v1/status', urlBase).toString(), + } +}) + +export const nexusConfigFactory = registerAs('nexus', () => nexusFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/config/opencds.config.ts b/.jjconflict-side-2/apps/server-nestjs/src/config/opencds.config.ts new file mode 100644 index 0000000000..fb1da23b96 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/config/opencds.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const opencdsFeatureSchema = z.object({ + OPENCDS_URL: z.string().url(), + OPENCDS_INTERNAL_URL: z.string().url().optional(), + OPENCDS_API_TOKEN: z.string().min(1), + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + const probeBase = raw.OPENCDS_INTERNAL_URL ?? raw.OPENCDS_URL + return { + url: raw.OPENCDS_URL, + internalUrl: raw.OPENCDS_INTERNAL_URL, + probeUrl: new URL('/api/v1/health', probeBase).toString(), + apiToken: raw.OPENCDS_API_TOKEN, + apiTlsRejectUnauthorized: raw.OPENCDS_API_TLS_REJECT_UNAUTHORIZED, + } +}) + +export const openCdsConfigFactory = registerAs('opencds', () => opencdsFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/config/registry.config.ts b/.jjconflict-side-2/apps/server-nestjs/src/config/registry.config.ts new file mode 100644 index 0000000000..c30ef12c50 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/config/registry.config.ts @@ -0,0 +1,30 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const registryFeatureSchema = z.object({ + REGISTRY_URL: z.string().url(), + REGISTRY_INTERNAL_URL: z.string().url().optional(), + REGISTRY_ADMIN: z.string().min(1), + REGISTRY_ADMIN_PASSWORD: z.string().min(1), + REGISTRY_RULE_TEMPLATE: z.string().min(1), + REGISTRY_RULE_COUNT: z.coerce.number().int().nonnegative().optional(), + REGISTRY_RETENTION_CRON: z.string().default('0 22 2 * * *'), + REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + REGISTRY_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform((raw) => { + const urlBase = raw.REGISTRY_INTERNAL_URL ?? raw.REGISTRY_URL + return { + url: raw.REGISTRY_URL, + internalUrl: raw.REGISTRY_INTERNAL_URL, + admin: raw.REGISTRY_ADMIN, + adminPassword: raw.REGISTRY_ADMIN_PASSWORD, + ruleTemplate: raw.REGISTRY_RULE_TEMPLATE, + ruleCount: raw.REGISTRY_RULE_COUNT, + retentionCron: raw.REGISTRY_RETENTION_CRON, + robotRotationThresholdDays: raw.REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.REGISTRY_PROJECT_SLUG_CACHE_TTL_MS, + internalOrPublicUrl: urlBase, + } +}) + +export const registryConfigFactory = registerAs('registry', () => registryFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/config/sonarqube.config.ts b/.jjconflict-side-2/apps/server-nestjs/src/config/sonarqube.config.ts new file mode 100644 index 0000000000..78fdd00939 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/config/sonarqube.config.ts @@ -0,0 +1,19 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const sonarqubeFeatureSchema = z.object({ + SONARQUBE_URL: z.string().url(), + SONARQUBE_INTERNAL_URL: z.string().url().optional(), + SONAR_API_TOKEN: z.string().min(1), +}).transform((raw) => { + const urlBase = raw.SONARQUBE_INTERNAL_URL ?? raw.SONARQUBE_URL + return { + url: raw.SONARQUBE_URL, + internalUrl: raw.SONARQUBE_INTERNAL_URL, + apiToken: raw.SONAR_API_TOKEN, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/api/system/health', urlBase).toString(), + } +}) + +export const sonarqubeConfigFactory = registerAs('sonarqube', () => sonarqubeFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/config/vault.config.ts b/.jjconflict-side-2/apps/server-nestjs/src/config/vault.config.ts new file mode 100644 index 0000000000..83aca3cb62 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/config/vault.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const vaultFeatureSchema = z.object({ + VAULT_TOKEN: z.string().min(1), + VAULT_URL: z.string().url(), + VAULT_INTERNAL_URL: z.string().url().optional(), + VAULT_KV_NAME: z.string().default('forge-dso'), +}).transform((raw) => { + const urlBase = raw.VAULT_INTERNAL_URL ?? raw.VAULT_URL + return { + token: raw.VAULT_TOKEN, + url: raw.VAULT_URL, + internalUrl: raw.VAULT_INTERNAL_URL, + kvName: raw.VAULT_KV_NAME, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/v1/sys/health', urlBase).toString(), + } +}) + +export const vaultConfigFactory = registerAs('vault', () => vaultFeatureSchema.parse(process.env)) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/main.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/main.module.ts new file mode 100644 index 0000000000..498ae55e1e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/main.module.ts @@ -0,0 +1,51 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { ScheduleModule } from '@nestjs/schedule' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from './config/base.config' +import { keycloakConfigFactory } from './config/keycloak.config' +import { DeploymentModule } from './modules/deployment/deployment.module' +import { EnvironmentModule } from './modules/environment/environment.module' +import { HealthzModule } from './modules/healthz/healthz.module' +import { BaseModule } from './modules/infrastructure/config/base.module' +import { InfrastructureModule } from './modules/infrastructure/infrastructure.module' +import { KeycloakModule } from './modules/keycloak/keycloak.module' +import { LogModule } from './modules/log/log.module' +import { ProjectBulkModule } from './modules/project-bulk/project-bulk.module' +import { ProjectHooksModule } from './modules/project-hooks/project-hooks.module' +import { ProjectMembersModule } from './modules/project-members/project-members.module' +import { ProjectRolesModule } from './modules/project-roles/project-roles.module' +import { ProjectSecretsModule } from './modules/project-secrets/project-secrets.module' +import { ProjectServicesModule } from './modules/project-services/project-services.module' +import { ProjectModule } from './modules/project/project.module' +import { ServiceChainModule } from './modules/service-chain/service-chain.module' +import { SystemSettingsModule } from './modules/system-settings/system-settings.module' +import { VersionModule } from './modules/version/version.module' + +@Module({ + imports: [ + ConfigModule.forRoot(), + BaseModule.forRoot(baseConfigFactory.asProvider()), + TerminusModule.forRoot(), + InfrastructureModule, + HealthzModule, + ConditionalModule.registerWhen(KeycloakModule.forRoot(keycloakConfigFactory.asProvider()), 'USE_KEYCLOAK'), + ScheduleModule.forRoot(), + SystemSettingsModule, + ServiceChainModule, + ProjectModule, + ProjectHooksModule, + ProjectSecretsModule, + ProjectServicesModule, + ProjectBulkModule, + ProjectMembersModule, + ProjectRolesModule, + LogModule, + DeploymentModule, + EnvironmentModule, + VersionModule, + ], + controllers: [], + providers: [], +}) +export class MainModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/main.ts b/.jjconflict-side-2/apps/server-nestjs/src/main.ts new file mode 100644 index 0000000000..600f7232f7 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/main.ts @@ -0,0 +1,66 @@ +import type { NestFastifyApplication } from '@nestjs/platform-fastify' +import type { BaseConfig } from './modules/infrastructure/config/base.config' +import { NestFactory } from '@nestjs/core' +import { FastifyAdapter } from '@nestjs/platform-fastify' +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' +import { NodeSDK, resources } from '@opentelemetry/sdk-node' +import { Logger } from 'nestjs-pino' +import { MainModule } from './main.module' +import { BASE_CONFIG } from './modules/infrastructure/config/base.module' + +const SERVICE_NAME = 'console-pi-native-console' + +const telemetry = new NodeSDK({ + traceExporter: new OTLPTraceExporter({}), + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + }), + instrumentations: [ + getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-nestjs-core': { enabled: true }, + '@opentelemetry/instrumentation-pino': { enabled: true }, + }), + ], + resource: resources.resourceFromAttributes({ + 'service.name': SERVICE_NAME, + }), +}) + +async function bootstrap() { + telemetry.start() + + const app = await NestFactory.create(MainModule, new FastifyAdapter(), { + bufferLogs: true, + }) + + app.useLogger(app.get(Logger)) + app.flushLogs() + app.enableShutdownHooks() + app.getHttpAdapter().getInstance().addHook('onClose', async () => { + await telemetry.shutdown() + }) + + const config = app.get(BASE_CONFIG) + + // Setup swagger-ui route + const swaggerConfig = new DocumentBuilder() + .setTitle('Console Cloud π Native') + .setDescription('Description de l\' API Cloud π Native') + .setVersion('1.0') + .addTag('console') + .build() + const documentFactory = () => SwaggerModule.createDocument(app, swaggerConfig) + SwaggerModule.setup('swagger-ui-server-nestjs', app, documentFactory) + + await app.listen(config.serverPort, config.serverHost) + + const serverUrl = await app.getUrl() + const logger = app.get(Logger) + logger.log(`NestJS server running on: ${serverUrl}`) +} + +void bootstrap() diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts new file mode 100644 index 0000000000..d3103a2e31 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts @@ -0,0 +1,104 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + helmValuesFiles: true, + deployRevision: true, + deployPath: true, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + deployments: { + select: { + id: true, + name: true, + autosync: true, + environment: { + select: { + id: true, + name: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + deploymentSources: { + select: { + type: true, + path: true, + targetRevision: true, + helmValuesFiles: true, + repository: { + select: { + id: true, + internalRepoName: true, + }, + }, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class ArgoCDDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts new file mode 100644 index 0000000000..f79fc15424 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts @@ -0,0 +1,23 @@ +import type { ArgocdConfig } from './argocd.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { ARGOCD_CONFIG } from './argocd.module-definition' + +@Injectable() +export class ArgoCDHealthService { + constructor( + @Inject(ARGOCD_CONFIG) private readonly argocdConfig: ArgocdConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.argocdConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts new file mode 100644 index 0000000000..7388fc6dd8 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd-plugin.service.ts @@ -0,0 +1,169 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Injectable } from '@nestjs/common' +import { + DEFAULT_DSO_ENV_CHART_VERSION, + DEFAULT_DSO_NS_CHART_VERSION, + DSO_ENV_CHART_VERSION_PLUGIN_KEY, + DSO_NS_CHART_VERSION_PLUGIN_KEY, + EXTRA_REPOSITORIES_PLUGIN_KEY, + PLATFORM_ADMIN_GROUP_PATH, + PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + PLATFORM_READONLY_GROUP_PATH, + PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY, + PLATFORM_SECURITY_GROUP_PATH, + PROJECT_ADMIN_GROUP_PATH_SUFFIX, + PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_READONLY_GROUP_PATH_SUFFIX, + PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_GROUP_PATH_SUFFIX, +} from './argocd.constants' + +@Injectable() +export class ArgoCDPluginService { + infos(): ServiceInfos { + const extraRepositoriesDesc = 'appproject.spec.sourceRepos supplémentaires, séparés par des virgules (https://a.com/repo.git,https://b.com/' + + return { + name: 'argocd', + to: ({ zones, project }) => zones.map(z => ({ + to: new URL(`applications?showFavorites=false&proj=&sync=&health=&namespace=&cluster=&labels=&search=${project.slug}`, z.argocdUrl).toString(), + title: `ArgoCD ${z.label}`, + })), + title: 'ArgoCD', + imgSrc: '/img/argocd.svg', + description: 'ArgoCD est un outil déclaratif de livraison continue GitOps pour Kubernetes', + config: { + global: [{ + key: EXTRA_REPOSITORIES_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }, { + key: PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Admin Group Path', + value: PLATFORM_ADMIN_GROUP_PATH, + description: 'Chemin du groupe administrateur de plateforme', + }, { + key: PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Readonly Group Path', + value: PLATFORM_READONLY_GROUP_PATH, + description: 'Chemin du groupe lecture seule de plateforme', + }, { + key: 'platformSecurityGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Security Group Path', + value: PLATFORM_SECURITY_GROUP_PATH, + description: 'Chemin du groupe sécurité de plateforme', + }, { + key: PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Admin Group Path Suffix', + value: PROJECT_ADMIN_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe administrateur de projet', + }, { + key: PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project DevOps Group Path Suffix', + value: PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe devops de projet', + }, { + key: PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Developer Group Path Suffix', + value: PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe développeur de projet', + }, { + key: PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Readonly Group Path Suffix', + value: PROJECT_READONLY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe lecture seule de projet', + }, { + key: 'projectSecurityGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Security Group Path Suffix', + value: PROJECT_SECURITY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe sécurité de projet', + }, { + key: DSO_ENV_CHART_VERSION_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Env Chart Version', + value: DEFAULT_DSO_ENV_CHART_VERSION, + description: 'Version du chart Helm dso-env', + placeholder: DEFAULT_DSO_ENV_CHART_VERSION, + }, { + key: DSO_NS_CHART_VERSION_PLUGIN_KEY, + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Namespace Chart Version', + value: DEFAULT_DSO_NS_CHART_VERSION, + description: 'Version du chart Helm dso-ns', + placeholder: DEFAULT_DSO_NS_CHART_VERSION, + }], + project: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts new file mode 100644 index 0000000000..65d58f8955 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd-testing.utils.ts @@ -0,0 +1,94 @@ +import type { ProjectWithDetails } from './argocd-datastore.service' + +import { faker } from '@faker-js/faker' + +export function makeProjectDeploymentSource( + overrides: Partial = {}, +): ProjectWithDetails['deployments'][number]['deploymentSources'][number] { + return { + type: 'git', + path: '.', + targetRevision: 'HEAD', + helmValuesFiles: '', + repository: makeProjectRepository(), + ...overrides, + } satisfies ProjectWithDetails['deployments'][number]['deploymentSources'][number] +} + +export function makeProjectDeployment( + overrides: Partial = {}, +): ProjectWithDetails['deployments'][number] { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + environment: makeProjectEnvironment(), + autosync: true, + deploymentSources: [ + { + type: 'git', + path: '.', + targetRevision: 'HEAD', + helmValuesFiles: '', + repository: makeProjectRepository(), + }, + ], + ...overrides, + } satisfies ProjectWithDetails['deployments'][number] +} + +export function makeProjectRepository( + overrides: Partial = {}, +): ProjectWithDetails['repositories'][number] { + return { + id: faker.string.uuid(), + internalRepoName: faker.word.noun(), + isInfra: false, + deployRevision: 'HEAD', + deployPath: '.', + helmValuesFiles: '', + ...overrides, + } satisfies ProjectWithDetails['repositories'][number] +} + +export function makeCluster( + overrides: Partial = {}, +): ProjectWithDetails['environments'][number]['cluster'] { + return { + id: faker.string.uuid(), + label: faker.word.noun(), + zone: { + slug: faker.word.noun(), + }, + ...overrides, + } satisfies ProjectWithDetails['environments'][number]['cluster'] +} + +export function makeProjectEnvironment( + overrides: Partial = {}, +): ProjectWithDetails['environments'][number] { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + cluster: makeCluster(), + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + ...overrides, + } satisfies ProjectWithDetails['environments'][number] +} + +export function makeProjectWithDetails( + overrides: Partial = {}, +): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(faker.word.words({ count: 2 })).toLowerCase(), + name: faker.word.noun(), + environments: [makeProjectEnvironment()], + repositories: [makeProjectRepository()], + plugins: [], + deployments: [makeProjectDeployment()], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.constants.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.constants.ts new file mode 100644 index 0000000000..d856cf98bf --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.constants.ts @@ -0,0 +1,30 @@ +// ArgoCD plugin constants +export const PLUGIN_NAME = 'argocd' + +// Platform-level group paths +export const CONSOLE_ADMIN_GROUP_PATH = '/console/admin' +export const PLATFORM_ADMIN_GROUP_PATH = '/console/admin' +export const PLATFORM_READONLY_GROUP_PATH = '/console/readonly' +export const PLATFORM_SECURITY_GROUP_PATH = '/console/security' + +// Suffix for project group path +export const PROJECT_ADMIN_GROUP_PATH_SUFFIX = '/console/admin' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const PROJECT_READONLY_GROUP_PATH_SUFFIX = '/console/readonly' +export const PROJECT_SECURITY_GROUP_PATH_SUFFIX = '/console/security' + +// Default chart versions +export const DEFAULT_DSO_ENV_CHART_VERSION = 'dso-env-1.6.0' +export const DEFAULT_DSO_NS_CHART_VERSION = 'dso-ns-1.1.5' + +// Plugin configuration keys +export const EXTRA_REPOSITORIES_PLUGIN_KEY = 'extraRepositories' +export const PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY = 'platformAdminGroupPath' +export const PLATFORM_READONLY_GROUP_PATH_PLUGIN_KEY = 'platformReadonlyGroupPath' +export const PROJECT_ADMIN_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectAdminGroupPathSuffix' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevopsGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevelopperGroupPathSuffix' +export const PROJECT_READONLY_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReadonlyGroupPathSuffix' +export const DSO_ENV_CHART_VERSION_PLUGIN_KEY = 'dsoEnvChartVersion' +export const DSO_NS_CHART_VERSION_PLUGIN_KEY = 'dsoNsChartVersion' diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts new file mode 100644 index 0000000000..a134c72f4d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts @@ -0,0 +1,19 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface ArgocdConfig { + namespace: string + url: string + internalUrl: string + extraRepositories: string + dsoEnvChartVersion: string + dsoNsChartVersion: string + vaultDeployVaultConnectionInNs: boolean + internalOrPublicUrl?: string +} + +export interface ArgoCDModuleOptions extends ArgocdConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('argocd') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as ARGOCD_CONFIG } diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts new file mode 100644 index 0000000000..91447152ec --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts @@ -0,0 +1,26 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { argocdConfigFactory } from '../../config/argocd.config' +import { ArgoCDModule } from './argocd.module' +import { ArgoCDService } from './argocd.service' + +describe('argocdModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ArgoCDService when USE_GITLAB=false', async () => { + vi.stubEnv('USE_GITLAB', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule.forRoot(argocdConfigFactory.asProvider()), 'USE_GITLAB')], + }).compile() + expect(() => module.get(ArgoCDService)).toThrow() + }) + + it('omits ArgoCDService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule.forRoot(argocdConfigFactory.asProvider()), 'USE_VAULT')], + }).compile() + expect(() => module.get(ArgoCDService)).toThrow() + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.module.ts new file mode 100644 index 0000000000..82f639940a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { GitlabModule } from '../gitlab/gitlab.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { ArgoCDHealthService } from './argocd-health.service' +import { ArgoCDPluginService } from './argocd-plugin.service' +import { ConfigurableModuleClass } from './argocd.module-definition' +import { ArgoCDService } from './argocd.service' + +@Module({ + imports: [ + DatabaseModule, + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + TerminusModule, + ConditionalModule.registerWhen(VaultModule, 'USE_VAULT'), + ], + providers: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService, ArgoCDDatastoreService], + exports: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService], +}) +export class ArgoCDModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts new file mode 100644 index 0000000000..2c0511c4bf --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -0,0 +1,541 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { ArgocdConfig } from './argocd.module-definition' +import { generateNamespaceName } from '@cpn-console/shared' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { stringify } from 'yaml' +import { GitlabClientService } from '../gitlab/gitlab-client.service' +import { makeCommitAction, makeProjectSchema, makeRepositoryTreeSchema } from '../gitlab/gitlab-testing.utils' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { makeProjectDeployment, makeProjectDeploymentSource, makeProjectEnvironment, makeProjectRepository, makeProjectWithDetails } from './argocd-testing.utils' +import { ARGOCD_CONFIG } from './argocd.module-definition' +import { ArgoCDService } from './argocd.service' + +describe('argoCDService', () => { + let service: ArgoCDService + let datastore: DeepMockProxy + let gitlab: DeepMockProxy + let vault: DeepMockProxy + let argocdConfig: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + datastore = mockDeep() + gitlab = mockDeep() + vault = mockDeep() + argocdConfig = mockDeep({ + namespace: 'argocd', + url: 'https://argocd.internal', + internalUrl: undefined, + extraRepositories: 'repo3', + dsoEnvChartVersion: 'dso-env-1.6.0', + dsoNsChartVersion: 'dso-ns-1.1.5', + vaultDeployVaultConnectionInNs: false, + internalOrPublicUrl: undefined, + }) + baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep({ + url: 'https://vault.internal', + kvName: 'kv', + internalUrl: undefined, + }) + + const module = await Test.createTestingModule({ + providers: [ + ArgoCDService, + { provide: ArgoCDDatastoreService, useValue: datastore }, + { provide: ARGOCD_CONFIG, useValue: argocdConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: GitlabClientService, useValue: gitlab }, + { provide: VaultClientService, useValue: vault }, + ], + }).compile() + + service = module.get(ArgoCDService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('should return OK result when ensureProject succeeds', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [], + repositories: [], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) + gitlab.maybeCreateCommit.mockResolvedValue(undefined) + + const result = await service.handleUpsert(mockProject) + + expect(result).toEqual({ + argocd: expect.objectContaining({ + status: 'OK', + message: 'Up to date', + executionTime: expect.any(Number), + }), + }) + }) + + it('should return KO result when ensureProject throws', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const error = new Error('GitLab unreachable') + gitlab.getOrCreateInfraGroupRepo.mockRejectedValue(error) + + const result = await service.handleUpsert(mockProject) + + expect(result).toEqual({ + argocd: expect.objectContaining({ + status: 'KO', + message: 'GitLab unreachable', + executionTime: expect.any(Number), + error, + }), + }) + }) + }) + + it('should sync project environments', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + makeProjectEnvironment({ name: 'prod', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + // Verify Gitlab calls + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'dev', + 'dso/environment.id': mockProject.environments[0].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-dev-6293', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/dev/values.yaml', + roGroup: '/project-1/console/dev/RO', + rwGroup: '/project-1/console/dev/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockProject.environments[0].id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + id: mockProject.repositories[0].id, + name: 'infra-repo', + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: 'HEAD', + path: '.', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/dev/values.yaml', + }, + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'prod', + 'dso/environment.id': mockProject.environments[1].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-prod-c626', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/prod/values.yaml', + roGroup: '/project-1/console/prod/RO', + rwGroup: '/project-1/console/prod/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockProject.environments[1].id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + id: mockProject.repositories[0].id, + name: 'infra-repo', + repoURL: 'https://gitlab.internal/group/project-1/infra-repo.git', + targetRevision: 'HEAD', + path: '.', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/prod/values.yaml', + }, + ]), + ) + + expect(gitlab.listFiles).toHaveBeenCalledWith(infraProject, { + path: 'Project 1/', + recursive: true, + }) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(2) + }) + + it('should delete values file when an environment is removed', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([ + makeRepositoryTreeSchema( + { name: 'values.yaml', path: 'Project 1/cluster-1/dev/values.yaml' }, + ), + makeRepositoryTreeSchema( + { name: 'values.yaml', path: 'Project 1/cluster-1/prod/values.yaml' }, + ), + ]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + expect.objectContaining({ + action: 'create', + filePath: 'Project 1/cluster-1/dev/values.yaml', + }), + { + action: 'delete', + filePath: 'Project 1/cluster-1/prod/values.yaml', + }, + ]), + ) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(1) + }) + + it('should not commit when there is no diff', async () => { + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }), + ], + repositories: [makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true })], + deployments: [], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + + gitlab.generateCreateOrUpdateAction.mockResolvedValue(null) + + await expect(service.handleCron()).resolves.not.toThrow() + + expect(gitlab.maybeCreateCommit).not.toHaveBeenCalled() + }) + + it('should sync project deployments', async () => { + const mockRepo = makeProjectRepository({ internalRepoName: 'infra-repo', isInfra: true }) + const mockDevEnv = makeProjectEnvironment({ name: 'dev', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }) + const mockProdEnv = makeProjectEnvironment({ name: 'prod', cluster: { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } } }) + const mockProject = makeProjectWithDetails({ + slug: 'project-1', + name: 'Project 1', + environments: [ + mockDevEnv, + mockProdEnv, + ], + repositories: [mockRepo], + plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], + deployments: [ + makeProjectDeployment({ + environment: mockDevEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: 'dev' })], + }), + makeProjectDeployment({ + environment: mockDevEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: '1.0.0', path: 'service-1' })], + }), + makeProjectDeployment({ + environment: mockProdEnv, + deploymentSources: [makeProjectDeploymentSource({ repository: mockRepo, targetRevision: 'prod' })], + }), + ], + }) + + const infraProject = makeProjectSchema({ id: 100, http_url_to_repo: 'https://gitlab.internal/infra' }) + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue(infraProject) + gitlab.getOrCreateProjectGroupPublicUrl.mockResolvedValue('https://gitlab.internal/group') + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.getAuthApproleRoleRoleId.mockResolvedValue('role-id') + vault.createAuthApproleRoleSecretId.mockResolvedValue('secret-id') + gitlab.generateCreateOrUpdateAction.mockImplementation(async (_repoId, _ref, filePath: string, content: string) => { + return makeCommitAction({ filePath, content }) + }) + + await expect(service.handleCron()).resolves.not.toThrow() + + // Verify Gitlab calls + expect(gitlab.maybeCreateCommit).toHaveBeenCalledTimes(1) + expect(gitlab.maybeCreateCommit).toHaveBeenCalledWith( + infraProject, + 'ci: :robot_face: Sync project-1', + expect.arrayContaining([ + { + action: 'create', + content: stringify({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': mockProject.id, + 'dso/project.slug': 'project-1', + 'dso/environment': 'dev', + 'dso/environment.id': mockProject.environments[0].id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-dev-6293', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'https://gitlab.internal/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/dev/values.yaml', + roGroup: '/project-1/console/dev/RO', + rwGroup: '/project-1/console/dev/RW', + consoleAdminGroup: '/console/admin', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + platformSecurityGroup: '/console/security', + projectAdminGroup: '/project-1/console/admin', + projectDevopsGroup: '/project-1/console/devops', + projectDevelopperGroup: '/project-1/console/developer', + projectSecurityGroup: '/project-1/console/security', + projectReadonlyGroup: '/project-1/console/readonly', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'https://gitlab.internal/group/project-1/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockDevEnv.id), + name: 'cluster-1', + }, + autosync: true, + vault: { + projectsRootDir: 'forge', + url: '', + coreKvName: 'kv', + roleId: 'role-id', + secretId: 'secret-id', + }, + repositories: [ + { + name: 'infra-repo', + id: mockRepo.id, + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: 'dev', + path: '.', + valueFiles: [], + }, + { + name: 'infra-repo', + id: mockRepo.id, + repoURL: `https://gitlab.internal/group/project-1/infra-repo.git`, + targetRevision: '1.0.0', + path: 'service-1', + valueFiles: [], + }, + ], + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + }), + filePath: 'Project 1/cluster-1/dev/values.yaml', + }, + ]), + ) + + expect(gitlab.listFiles).toHaveBeenCalledWith(infraProject, { + path: 'Project 1/', + recursive: true, + }) + + expect(gitlab.generateCreateOrUpdateAction).toHaveBeenCalledTimes(4) // 2 environments + 2 deployments + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.service.ts new file mode 100644 index 0000000000..cbfa862247 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -0,0 +1,662 @@ +import type { CommitAction, CondensedProjectSchema, ProjectSchema, SimpleProjectSchema } from '@gitbeaker/core' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { ProjectWithDetails } from './argocd-datastore.service' +import type { ArgocdConfig } from './argocd.module-definition' +import { createHmac } from 'node:crypto' +import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { stringify } from 'yaml' +import { GitlabClientService } from '../gitlab/gitlab-client.service' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { + CONSOLE_ADMIN_GROUP_PATH, + PLATFORM_ADMIN_GROUP_PATH, + PLATFORM_READONLY_GROUP_PATH, + PLATFORM_SECURITY_GROUP_PATH, + PROJECT_ADMIN_GROUP_PATH_SUFFIX, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + PROJECT_READONLY_GROUP_PATH_SUFFIX, + PROJECT_SECURITY_GROUP_PATH_SUFFIX, +} from './argocd.constants' +import { ARGOCD_CONFIG } from './argocd.module-definition' + +@Injectable() +export class ArgoCDService { + private readonly logger = new Logger(ArgoCDService.name) + + constructor( + @Inject(ArgoCDDatastoreService) private readonly datastore: ArgoCDDatastoreService, + @Inject(ARGOCD_CONFIG) private readonly argocdConfig: ArgocdConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('ArgoCDService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('argocd', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`ArgoCD sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('argocd', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`ArgoCD sync completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + this.logger.log('Starting ArgoCD reconciliation') + const projects = await this.datastore.getAllProjects() + const span = trace.getActiveSpan() + span?.setAttribute('argocd.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for ArgoCD reconciliation`) + await this.ensureProjects(projects) + this.logger.log(`ArgoCD reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('argocd.projects.count', projects.length) + this.logger.verbose(`Reconciling ArgoCD projects (count=${projects.length})`) + await Promise.all(projects.map(project => this.ensureProject(project))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling ArgoCD project ${project.slug}`) + await this.ensureZones(project) + this.logger.verbose(`ArgoCD project reconciled (${project.slug})`) + } + + @StartActiveSpan() + private async ensureZones( + project: ProjectWithDetails, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const zones = getDistinctZones(project) + span?.setAttribute('argocd.zones.count', zones.length) + this.logger.verbose(`Reconciling ArgoCD zones for project ${project.slug} (count=${zones.length})`) + await Promise.all(zones.map(zoneSlug => this.ensureZone(project, zoneSlug))) + } + + @StartActiveSpan() + private async ensureZone( + project: ProjectWithDetails, + zoneSlug: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const infraProject = await this.gitlab.getOrCreateInfraGroupRepo(zoneSlug) + span?.setAttributes({ + 'argocd.repo.id': infraProject.id, + 'argocd.repo.path': infraProject.path_with_namespace, + 'zone.slug': zoneSlug, + }) + this.logger.verbose(`Reconciling ArgoCD zone for project ${project.slug} in zone ${zoneSlug} (repoId=${infraProject.id})`) + + const actions = await this.generateEnvironmentActions(project, infraProject, zoneSlug) + + span?.setAttribute('argocd.repo.actions.count', actions.length) + if (actions.length === 0) { + this.logger.verbose(`No ArgoCD changes need to be committed for project ${project.slug} in zone ${zoneSlug}`) + return + } + + this.logger.log(`Applying ArgoCD changes for project ${project.slug} in zone ${zoneSlug} (actions=${actions.length})`) + await this.gitlab.maybeCreateCommit(infraProject, `ci: :robot_face: Sync ${project.slug}`, actions) + } + + private async generateEnvironmentActions( + project: ProjectWithDetails, + infraProject: ProjectSchema, + zoneSlug: string, + ) { + const environmentActions = await this.generateEnvironmentsUpdateActions( + project, + project.environments, + infraProject, + zoneSlug, + ) + const deploymentActions = await this.generateDeploymentsUpdateActions( + project, + infraProject, + zoneSlug, + ) + const purgeEnvironmentActions = await this.generatePurgeEnvironmentActions( + project, + infraProject, + zoneSlug, + ) + return [ + ...(deploymentActions.length ? deploymentActions : environmentActions), + ...purgeEnvironmentActions, + ] satisfies CommitAction[] + } + + private async generatePurgeEnvironmentActions( + project: ProjectWithDetails, + infraProject: CondensedProjectSchema, + zoneSlug: string, + ): Promise { + const neededFiles = new Set() + const clusterLabelsInZone = new Set( + project.environments + .filter(e => e.cluster.zone.slug === zoneSlug) + .map(e => e.cluster.label), + ) + + project.environments.forEach((env) => { + if (env.cluster?.zone.slug !== zoneSlug) return + neededFiles.add(formatEnvironmentValuesFilePath(project, env.cluster, env)) + }) + + const existingFiles = await this.gitlab.listFiles(infraProject, { + path: `${project.name}/`, + recursive: true, + }) + + const projectPrefix = `${project.name}/` + const actions = existingFiles + .filter((existingFile) => { + if (existingFile.name !== 'values.yaml') return false + if (!existingFile.path.startsWith(projectPrefix)) return false + + const remaining = existingFile.path.slice(projectPrefix.length) + const clusterLabel = remaining.split('/')[0] + if (!clusterLabel || !clusterLabelsInZone.has(clusterLabel)) return false + + return !neededFiles.has(existingFile.path) + }) + .map(existingFile => ({ action: 'delete', filePath: existingFile.path } satisfies CommitAction)) + + this.logger.verbose(`Computed ArgoCD purge actions for project ${project.slug} in zone ${zoneSlug} (actions=${actions.length})`) + + return actions + } + + private async generateEnvironmentsUpdateActions( + project: ProjectWithDetails, + environments: ProjectWithDetails['environments'], + infraProject: SimpleProjectSchema, + zoneSlug: string, + ): Promise { + this.logger.verbose(`Computing ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug} (environments=${environments.length})`) + const actions = await Promise.all( + environments + .filter(env => env.cluster?.zone.slug === zoneSlug) + .map(env => this.generateEnvironmentUpdateAction(project, env, infraProject)), + ) + const filteredActions: CommitAction[] = actions.filter(a => !!a) + this.logger.verbose(`Computed ArgoCD environment actions for project ${project.slug} in zone ${zoneSlug} (actions=${filteredActions.length})`) + return filteredActions + } + + private async generateEnvironmentUpdateAction( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + infraProject: SimpleProjectSchema, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const vaultValues = await this.generateVaultValues(project.slug) + const cluster = environment.cluster + if (!cluster) { + this.logger.warn(`Cluster not found for environment ${environment.id} in project ${project.slug}`) + throw new Error(`Cluster not found for environment ${environment.id}`) + } + span?.setAttribute('zone.slug', cluster.zone.slug) + + const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment) + + const gitlabPublicProjectUrl = `${(await this.gitlab.getOrCreateProjectGroupPublicUrl())}/${project.slug}` + + const values = formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories: this.argocdConfig.extraRepositories, + infraProject, + valueFilePath, + vaultValues, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, + }) + + return this.gitlab.generateCreateOrUpdateAction( + infraProject, + 'main', + valueFilePath, + stringify(values), + ) + } + + private async generateDeploymentsUpdateActions( + project: ProjectWithDetails, + infraProject: SimpleProjectSchema, + zoneSlug: string, + ): Promise { + this.logger.verbose(`Computing ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug} (environments=${project.deployments.length})`) + const environments = project.deployments.reduce< + Record + >((acc, deployment) => { + const environment = deployment.environment + if (acc[environment.id]) acc[environment.id].deployments.push(deployment) + else acc[environment.id] = { environment, deployments: [deployment] } + return acc + }, {}) + + const actions = await Promise.all( + Object.values(environments) + .filter(({ environment }) => environment.cluster?.zone.slug === zoneSlug) + .map(({ environment, deployments }) => this.generateEnvironmentWithDeploymentsUpdateAction(project, environment, deployments, infraProject)), + ) + + const filteredActions: CommitAction[] = actions.filter(a => !!a) + this.logger.verbose(`Computed ArgoCD deployment actions for project ${project.slug} in zone ${zoneSlug} (actions=${filteredActions.length})`) + return filteredActions + } + + private async generateEnvironmentWithDeploymentsUpdateAction( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + deployments: ProjectWithDetails['deployments'][number][], + infraProject: SimpleProjectSchema, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const vaultValues = await this.generateVaultValues(project.slug) + const cluster = environment.cluster + if (!cluster) { + this.logger.warn(`Cluster not found for environment ${environment.id} in project ${project.slug}`) + throw new Error(`Cluster not found for environment ${environment.id}`) + } + span?.setAttribute('zone.slug', cluster.zone.slug) + + const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment) + + const gitlabPublicProjectUrl = `${(await this.gitlab.getOrCreateProjectGroupPublicUrl())}/${project.slug}` + + const values = formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories: this.argocdConfig.extraRepositories, + infraProject, + valueFilePath, + vaultValues, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, + deployments, + }) + + return this.gitlab.generateCreateOrUpdateAction( + infraProject, + 'main', + valueFilePath, + stringify(values), + ) + } + + private async generateVaultValues(projectSlug: string): Promise> { + this.logger.verbose(`Generating Vault project values for projectSlug=${projectSlug}`) + const roleId = await this.vault.getAuthApproleRoleRoleId(projectSlug).catch(() => { + this.logger.warn(`Couldn't find app role (project=${projectSlug})`) + return undefined + }) + const secretId = await this.vault.createAuthApproleRoleSecretId(projectSlug).catch(() => { + this.logger.warn(`Couldn't find secret (project=${projectSlug})`) + return undefined + }) + return { + projectsRootDir: this.baseConfig.projectsRootDir, + url: this.argocdConfig.vaultDeployVaultConnectionInNs ? this.vaultConfig.url : '', + coreKvName: this.vaultConfig.kvName, + roleId: roleId ?? 'none', + secretId: secretId ?? 'none', + } + } +} + +interface ValuesSchema { + common: { + 'dso/project': string + 'dso/project.id': string + 'dso/project.slug': string + 'dso/environment': string + 'dso/environment.id': string + } + argocd: { + cluster: string + namespace: string + project: string + envChartVersion: string + nsChartVersion: string + } + environment: { + valueFileRepository: string + valueFileRevision: string + valueFilePath: string + roGroup: string + rwGroup: string + consoleAdminGroup: string + platformAdminGroup: string + platformReadonlyGroup: string + platformSecurityGroup: string + projectAdminGroup: string + projectDevopsGroup: string + projectDevelopperGroup: string + projectSecurityGroup: string + projectReadonlyGroup: string + } + application: { + quota: { + cpu: number + gpu: number + memory: string + } + sourceRepositories: string[] + destination: { + namespace: string + name: string + } + autosync: boolean + vault: Record + repositories: { + name: string + id: string + repoURL: string + targetRevision: string + path: string + valueFiles: string[] + }[] + } + features: { + fineGrainedRoles: { + enabled: boolean + } + } +} + +function formatReadOnlyGroupName(projectSlug: string, environmentName: string) { + return generateEnvironmentConsoleGroupPath(projectSlug, environmentName, 'RO') +} + +function formatReadWriteGroupName(projectSlug: string, environmentName: string) { + return generateEnvironmentConsoleGroupPath(projectSlug, environmentName, 'RW') +} + +function generateEnvironmentConsoleGroupPath(projectSlug: string, environmentName: string, access: 'RO' | 'RW') { + return `/${projectSlug}/console/${environmentName}/${access}` +} + +function generateProjectConsoleGroupPath(projectSlug: string, suffix: string) { + return `/${projectSlug}${suffix}` +} + +function formatAppProjectName(projectSlug: string, env: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${envHash}` +} + +function formatEnvironmentValuesFilePath(project: { name: string }, cluster: { label: string }, env: { name: string }): string { + return `${project.name}/${cluster.label}/${env.name}/values.yaml` +} + +function getDistinctZones(project: ProjectWithDetails) { + const zones = new Set() + project.environments.forEach(e => zones.add(e.cluster.zone.slug)) + return [...zones] +} + +function splitExtraRepositories(extraRepositories: string | undefined): string[] { + if (!extraRepositories) return [] + return extraRepositories.split(',').map(r => r.trim()).filter(r => r.length > 0) +} + +function formatRepositoriesValues( + repositories: ProjectWithDetails['repositories'], + gitlabPublicProjectUrl: string, + envName: string, +) { + return repositories + .filter(repo => repo.isInfra) + .map((repository) => { + const valueFiles = splitExtraRepositories(repository.helmValuesFiles?.replaceAll('', envName)) + return { + id: repository.id, + name: repository.internalRepoName, + repoURL: `${gitlabPublicProjectUrl}/${repository.internalRepoName}.git`, + targetRevision: repository.deployRevision || 'HEAD', + path: repository.deployPath || '.', + valueFiles, + } satisfies ValuesSchema['application']['repositories'][number] + }) +} + +function formatRepositoriesValuesFromDeployments( + deployments: ProjectWithDetails['deployments'][number][], + gitlabPublicProjectUrl: string, + envName: string, +) { + return deployments.flatMap(deployment => + deployment.deploymentSources + .map((source) => { + const valueFiles = splitExtraRepositories(source.helmValuesFiles?.replaceAll('', envName)) + return { + name: source.repository.internalRepoName, + id: source.repository.id, + repoURL: `${gitlabPublicProjectUrl}/${source.repository.internalRepoName}.git`, + targetRevision: source.targetRevision || 'HEAD', + path: source.path || '.', + valueFiles, + } satisfies ValuesSchema['application']['repositories'][number] + }), + ) +} + +function formatEnvironmentValues( + project: ProjectWithDetails, + infraProject: SimpleProjectSchema, + valueFilePath: string, + roGroup: string, + rwGroup: string, +) { + return { + valueFileRepository: infraProject.http_url_to_repo, + valueFileRevision: 'HEAD', + valueFilePath, + roGroup, + rwGroup, + consoleAdminGroup: CONSOLE_ADMIN_GROUP_PATH, + platformAdminGroup: PLATFORM_ADMIN_GROUP_PATH, + platformReadonlyGroup: PLATFORM_READONLY_GROUP_PATH, + platformSecurityGroup: PLATFORM_SECURITY_GROUP_PATH, + projectAdminGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_ADMIN_GROUP_PATH_SUFFIX), + projectDevopsGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_DEVOPS_GROUP_PATH_SUFFIX), + projectDevelopperGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX), + projectSecurityGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_SECURITY_GROUP_PATH_SUFFIX), + projectReadonlyGroup: generateProjectConsoleGroupPath(project.slug, PROJECT_READONLY_GROUP_PATH_SUFFIX), + } satisfies ValuesSchema['environment'] +} + +interface FormatSourceRepositoriesValuesOptions { + gitlabPublicProjectUrl: string + argocdExtraRepositories?: string + projectPlugins?: ProjectWithDetails['plugins'] +} + +function formatSourceRepositoriesValues( + { gitlabPublicProjectUrl, argocdExtraRepositories, projectPlugins }: FormatSourceRepositoriesValuesOptions, +): string[] { + let projectExtraRepositories = '' + if (projectPlugins) { + const argocdPlugin = projectPlugins.find(p => p.pluginName === 'argocd' && p.key === 'extraRepositories') + if (argocdPlugin) projectExtraRepositories = argocdPlugin.value + } + + return [ + `${gitlabPublicProjectUrl}/**`, + ...splitExtraRepositories(argocdExtraRepositories), + ...splitExtraRepositories(projectExtraRepositories), + ] +} + +interface FormatCommonOptions { + project: ProjectWithDetails + environment: ProjectWithDetails['environments'][number] +} + +function formatCommon({ project, environment }: FormatCommonOptions) { + return { + 'dso/project': project.name, + 'dso/project.id': project.id, + 'dso/project.slug': project.slug, + 'dso/environment': environment.name, + 'dso/environment.id': environment.id, + } satisfies ValuesSchema['common'] +} + +interface FormatArgoCDValuesOptions { + namespace: string + project: string + envChartVersion: string + nsChartVersion: string +} + +function formatArgoCDValues(options: FormatArgoCDValuesOptions) { + const { namespace, project, envChartVersion, nsChartVersion } = options + return { + cluster: inClusterLabel, + namespace, + project, + envChartVersion, + nsChartVersion, + } satisfies ValuesSchema['argocd'] +} + +interface FormatValuesOptions { + project: ProjectWithDetails + environment: ProjectWithDetails['environments'][number] + cluster: ProjectWithDetails['environments'][number]['cluster'] + gitlabPublicProjectUrl: string + argocdExtraRepositories?: string + vaultValues: Record + infraProject: SimpleProjectSchema + valueFilePath: string + argoNamespace: string + envChartVersion: string + nsChartVersion: string + deployments?: ProjectWithDetails['deployments'][number][] +} + +function formatValues({ + project, + environment, + cluster, + gitlabPublicProjectUrl, + argocdExtraRepositories, + vaultValues, + infraProject, + valueFilePath, + argoNamespace, + envChartVersion, + nsChartVersion, + deployments, +}: FormatValuesOptions) { + return { + common: formatCommon({ project, environment }), + argocd: formatArgoCDValues({ + namespace: argoNamespace, + project: formatAppProjectName(project.slug, environment.name), + envChartVersion, + nsChartVersion, + }), + environment: formatEnvironmentValues( + project, + infraProject, + valueFilePath, + formatReadOnlyGroupName(project.slug, environment.name), + formatReadWriteGroupName(project.slug, environment.name), + ), + application: { + quota: { + cpu: environment.cpu, + gpu: environment.gpu, + memory: `${environment.memory}Gi`, + }, + sourceRepositories: formatSourceRepositoriesValues({ + gitlabPublicProjectUrl, + argocdExtraRepositories, + projectPlugins: project.plugins, + }), + destination: { + namespace: generateNamespaceName(project.id, environment.id), + name: cluster.label, + }, + autosync: environment.autosync, + vault: vaultValues, + repositories: deployments + ? formatRepositoriesValuesFromDeployments(deployments, gitlabPublicProjectUrl, environment.name) + : formatRepositoriesValues( + project.repositories, + gitlabPublicProjectUrl, + environment.name, + ), + }, + features: { + fineGrainedRoles: { + enabled: true, + }, + }, + } satisfies ValuesSchema +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/configurable-feature-module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/configurable-feature-module.ts new file mode 100644 index 0000000000..85b2418316 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/configurable-feature-module.ts @@ -0,0 +1,78 @@ +import type { DynamicModule } from '@nestjs/common' +import { ConfigurableModuleBuilder } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' + +// Minimal shape of the generated configurable class, so consumers (e.g. `XModule.forRoot(...)`) +// see the `forRoot` static method. The arg is `TOptions` (plain object, for tests) OR a +// `ConfigFactory` (e.g. `registerAs('', ...)`), whose `.asProvider()` is wired by +// `forRoot` into this module's options token. +export interface ConfigurableCls { + new(...args: any[]): any + forRoot: (arg: TOptions | any) => DynamicModule +} + +// Builds a configurable module that OWNS its config contract (interface embedded in-module, +// no ../../config / registerAs dependency inside the module). The module is wired from the +// outside via `XModule.forRoot(xxxConfigFactory)`, where `xxxConfigFactory` is the +// `registerAs('', ...)` result from `config/*.config.ts`. `` MUST equal +// `token` so `ConfigModule.forFeature` registers it under `CONFIGURATION()`. +// `isGlobal` makes the module (and its config token) available app-wide via @Global(). +// ponytail: native forRoot only takes a plain options object; we wrap it so it also accepts +// a `ConfigFactory` and wires it (via ConfigModule.forFeature + a provider under the token). +function isConfigFactory(arg: unknown): boolean { + return typeof arg === 'function' && 'asProvider' in (arg as any) + && typeof (arg as any).asProvider === 'function' +} + +function isConfigProvider(arg: unknown): arg is { provide: unknown, useFactory?: unknown, useValue?: unknown } { + return Boolean(arg) && typeof arg === 'object' + && 'provide' in (arg as Record) + && ('useFactory' in (arg as Record) || 'useValue' in (arg as Record)) +} + +export function createConfigurableModule(token: string, isGlobal = false) { + const { ConfigurableModuleClass } = new ConfigurableModuleBuilder({ optionsInjectionToken: token }) + .setClassMethodName('forRoot') + .setFactoryMethodName('useFactory') + .setExtras( + { isGlobal }, + (definition: DynamicModule, extras) => ({ + ...definition, + exports: [...(definition.exports ?? []), token], + global: extras.isGlobal, + }), + ) + .build() + + class WrappedModuleClass extends (ConfigurableModuleClass as any) { + static forRoot(arg: any): DynamicModule { + const def = (super.forRoot as (o: unknown) => DynamicModule)({}) as DynamicModule + if (isConfigFactory(arg)) { + const provider = (arg as any).asProvider() + return { + ...def, + imports: [(ConfigModule as any).forFeature(arg), ...(def.imports ?? [])], + providers: [ + ...(def.providers ?? []), + { provide: token, useFactory: provider.useFactory, inject: provider.inject }, + ], + exports: [...(def.exports ?? []), token], + } + } + if (isConfigProvider(arg)) { + const provider = arg as any + return { + ...def, + providers: [...(def.providers ?? []), provider], + exports: [...(def.exports ?? []), provider.provide], + } + } + return (super.forRoot as (o: unknown) => DynamicModule)(arg) + } + } + + return { + ConfigurableModuleClass: WrappedModuleClass as unknown as ConfigurableCls, + MODULE_OPTIONS_TOKEN: token, + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts new file mode 100644 index 0000000000..01267db740 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.spec.ts @@ -0,0 +1,155 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' + +const mockDeployment = { + name: 'dep1', + id: 'dep1', + projectId: 'id', + memory: 3, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: new Date(), + updatedAt: new Date(), + environmentId: 'envId', +} + +describe('deploymentDatastoreService', () => { + let module: TestingModule + let service: DeploymentDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + DeploymentDatastoreService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(DeploymentDatastoreService) + }) + + describe('getDeploymentById', () => { + it('should return a deployment with sources and repository', async () => { + const deployment = { ...mockDeployment } + + prisma.deployment.findUnique.mockResolvedValue(deployment) + + const result = await service.getDeploymentById('dep1') + + expect(prisma.deployment.findUnique).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + }) + expect(result).toEqual(deployment) + }) + }) + + describe('getDeploymentsByProjectId', () => { + it('should return deployments for a project', async () => { + const deployments = [{ ...mockDeployment }] + + prisma.deployment.findMany.mockResolvedValue(deployments) + + const result = await service.getDeploymentsByProjectId('project1') + + expect(prisma.deployment.findMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }) + expect(result).toEqual(deployments) + }) + }) + + describe('createDeployment', () => { + it('should create a deployment', async () => { + const created = { + ...mockDeployment, + name: 'test', + } + + const data = { + ...created, + project: { connect: { id: 'id' } }, + environment: { connect: { id: 'envId' } }, + } + + prisma.deployment.create.mockResolvedValue(data) + + const result = await service.createDeployment({ + ...data, + }) + + expect(prisma.deployment.create).toHaveBeenCalledWith({ data }) + expect(result).toEqual(data) + }) + }) + + describe('updateDeployment', () => { + it('should update a deployment', async () => { + const updated = { ...mockDeployment, name: 'updated' } + + prisma.deployment.update.mockResolvedValue(updated) + + const result = await service.updateDeployment('dep1', { + ...mockDeployment, + name: 'updated', + }) + + expect(prisma.deployment.update).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + data: { ...mockDeployment, name: 'updated' }, + }) + expect(result).toEqual(updated) + }) + }) + + describe('deleteDeployment', () => { + it('should delete a deployment', async () => { + const deleted = { ...mockDeployment } + + prisma.deployment.delete.mockResolvedValue(deleted) + + const result = await service.deleteDeployment('dep1') + + expect(prisma.deployment.delete).toHaveBeenCalledWith({ + where: { id: 'dep1' }, + }) + expect(result).toEqual(deleted) + }) + }) + + describe('deleteAllDeploymentsByProjectId', () => { + it('should delete all deployments for a project', async () => { + const response = { count: 3 } + + prisma.deployment.deleteMany.mockResolvedValue(response) + + const result = await service.deleteAllDeploymentsByProjectId('project1') + + expect(prisma.deployment.deleteMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + }) + expect(result).toEqual(response) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts new file mode 100644 index 0000000000..859a299494 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment-datastore.service.ts @@ -0,0 +1,56 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +@Injectable() +export class DeploymentDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + getDeploymentById(deploymentId: string) { + return this.prisma.deployment.findUnique({ + where: { id: deploymentId }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + }) + } + + getDeploymentsByProjectId(projectId: string) { + return this.prisma.deployment.findMany({ + where: { projectId }, + include: { + environment: true, + deploymentSources: { + include: { repository: true }, + }, + }, + orderBy: { createdAt: 'asc' }, + }) + } + + createDeployment(data: Prisma.DeploymentCreateInput) { + return this.prisma.deployment.create({ data }) + } + + updateDeployment(deploymentId: string, data: Prisma.DeploymentUpdateInput) { + return this.prisma.deployment.update({ + where: { id: deploymentId }, + data, + }) + } + + deleteDeployment(deploymentId: string) { + return this.prisma.deployment.delete({ + where: { id: deploymentId }, + }) + } + + deleteAllDeploymentsByProjectId(projectId: string) { + return this.prisma.deployment.deleteMany({ + where: { projectId }, + }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts new file mode 100644 index 0000000000..20f7e249a3 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment-testing.utils.ts @@ -0,0 +1,85 @@ +import type { Deployment, DeploymentSource, Environment, Prisma, Repository } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeDeployment(overrides: Partial = {}): Deployment { + return { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + autosync: true, + environmentId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } +} + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + projectId: faker.string.uuid(), + memory: 2, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export function makeRepository(overrides: Partial = {}): Repository { + return { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: faker.string.alphanumeric(8).toLowerCase(), + externalRepoUrl: '', + externalUserName: '', + isInfra: false, + isPrivate: false, + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + ...overrides, + } +} + +type DeploymentSourceWithRepository = DeploymentSource & { repository: Repository } + +export function makeDeploymentSource(overrides: Partial = {}): DeploymentSourceWithRepository { + const repositoryId = overrides.repositoryId ?? faker.string.uuid() + return { + id: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + deploymentId: faker.string.uuid(), + repositoryId, + type: 'git', + targetRevision: 'main', + path: '/app', + helmValuesFiles: '', + repository: makeRepository({ id: repositoryId }), + ...overrides, + } +} + +export type DeploymentWithRelations = Prisma.DeploymentGetPayload<{ + include: { + environment: true + deploymentSources: { include: { repository: true } } + } +}> + +export function makeDeploymentWithRelations(overrides: Partial = {}): DeploymentWithRelations { + const base = makeDeployment(overrides) + return { + ...base, + environment: overrides.environment ?? makeEnvironment({ id: base.environmentId, projectId: base.projectId }), + deploymentSources: overrides.deploymentSources ?? [makeDeploymentSource({ deploymentId: base.id })], + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts new file mode 100644 index 0000000000..53f28c259f --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.controller.spec.ts @@ -0,0 +1,139 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { makeDeployment, makeDeploymentWithRelations } from './deployment-testing.utils' +import { DeploymentController } from './deployment.controller' +import { DeploymentService } from './deployment.service' + +describe('deploymentController', () => { + let module: TestingModule + let controller: DeploymentController + let service: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + + const project: ProjectContext = { id: projectId, slug: 'my-project' } + const user: UserContext = { userId } + const request = { id: requestId } as FastifyRequest + + const validCreateDeployment = { + name: 'dev', + projectId, + environmentId: '22222222-2222-2222-2222-222222222222', + autosync: true, + deploymentSources: [ + { + repositoryId: '33333333-3333-3333-3333-333333333333', + type: 'git', + targetRevision: 'main', + path: '/app', + }, + ], + } satisfies CreateDeployment + + const validUpdateDeployment = { + ...validCreateDeployment, + deploymentSources: [ + { + id: '44444444-4444-4444-4444-444444444444', + repositoryId: '33333333-3333-3333-3333-333333333333', + type: 'git', + targetRevision: 'develop', + path: '/updated-app', + }, + ], + } satisfies UpdateDeployment + + beforeEach(async () => { + service = mockDeep() + + module = await Test.createTestingModule({ + controllers: [DeploymentController], + providers: [ + { provide: DeploymentService, useValue: service }, + ], + }) + .overrideGuard(ProjectGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(DeploymentController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call deploymentService.listByProjectId with projectId', async () => { + const expectedResult = [makeDeploymentWithRelations({ projectId })] + + service.listByProjectId.mockResolvedValue(expectedResult) + + const result = await controller.list(project) + + expect(service.listByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('create', () => { + it('should validate body and call deploymentService.createDeployment', async () => { + const expectedResult = makeDeployment() + + service.createDeployment.mockResolvedValue(expectedResult) + + const result = await controller.create(validCreateDeployment, project, user, request) + + expect(service.createDeployment).toHaveBeenCalledWith( + projectId, + validCreateDeployment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('update', () => { + it('should validate body and call deploymentService.updateDeployment', async () => { + const deploymentId = '55555555-5555-5555-5555-555555555555' + const expectedResult = makeDeployment({ id: deploymentId }) + + service.updateDeployment.mockResolvedValue(expectedResult) + + const result = await controller.update(deploymentId, validUpdateDeployment, project, user, request) + + expect(service.updateDeployment).toHaveBeenCalledWith( + projectId, + deploymentId, + validUpdateDeployment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('delete', () => { + it('should call deploymentService.deleteDeployment with deploymentId', async () => { + const deploymentId = '55555555-5555-5555-5555-555555555555' + + service.deleteDeployment.mockResolvedValue(undefined) + + const result = await controller.delete(deploymentId, project, user, request) + + expect(service.deleteDeployment).toHaveBeenCalledWith(projectId, deploymentId, userId, requestId) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.controller.ts new file mode 100644 index 0000000000..d3a8706e01 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.controller.ts @@ -0,0 +1,63 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { CreateDeploymentSchema, UpdateDeploymentSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Post, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { DeploymentService } from './deployment.service' + +@Controller('api/v1/projects/:projectId/deployments') +@UseGuards(ProjectGuard) +export class DeploymentController { + constructor(@Inject(DeploymentService) private readonly deploymentService: DeploymentService) {} + + @Get('') + @RequireAdminPermission('ListProjects') + @RequireProjectPermission('ListDeployments') + list(@Project() project: ProjectContext) { + return this.deploymentService.listByProjectId(project.id) + } + + @Post('') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.CREATED) + create( + @Body(new ZodValidationPipe(CreateDeploymentSchema)) data: CreateDeployment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.createDeployment(project.id, data, user.userId, request.id) + } + + @Put(':deploymentId') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.OK) + update( + @Param('deploymentId') deploymentId: string, @Body(new ZodValidationPipe(UpdateDeploymentSchema)) + data: UpdateDeployment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.updateDeployment(project.id, deploymentId, data, user.userId, request.id) + } + + @Delete(':deploymentId') + @RequireProjectPermission('ManageDeployments') + @HttpCode(HttpStatus.NO_CONTENT) + delete( + @Param('deploymentId') deploymentId: string, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ) { + return this.deploymentService.deleteDeployment(project.id, deploymentId, user.userId, request.id) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.module.ts new file mode 100644 index 0000000000..cc0f6e1a91 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { ProjectModule } from '../project/project.module' +import { DeploymentDatastoreService } from './deployment-datastore.service' +import { DeploymentController } from './deployment.controller' +import { DeploymentService } from './deployment.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, EventsModule, ProjectModule, ProjectPermissionModule], + controllers: [DeploymentController], + providers: [ + DeploymentDatastoreService, + DeploymentService, + ], +}) +export class DeploymentModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts new file mode 100644 index 0000000000..3b9e19c058 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.service.spec.ts @@ -0,0 +1,222 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' +import { makeDeployment, makeDeploymentSource, makeDeploymentWithRelations } from './deployment-testing.utils' +import { DeploymentService } from './deployment.service' + +describe('deploymentService', () => { + let module: TestingModule + let service: DeploymentService + let datastore: DeepMockProxy + let appEvents: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + const deploymentId = '22222222-2222-2222-2222-222222222222' + + const okArgoCDResults = { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } } + + const validCreateDeployment = { + name: 'mydeployment', + projectId, + environmentId: '33333333-3333-3333-3333-333333333333', + autosync: true, + deploymentSources: [ + { + repositoryId: '44444444-4444-4444-4444-444444444444', + type: 'git', + targetRevision: 'main', + path: '/app', + helmValuesFiles: 'values.yaml', + }, + ], + } satisfies CreateDeployment + + const validUpdateDeployment = { + ...validCreateDeployment, + deploymentSources: [ + { + id: '55555555-5555-5555-5555-555555555555', + repositoryId: '44444444-4444-4444-4444-444444444444', + type: 'git', + targetRevision: 'develop', + path: '/updated-app', + helmValuesFiles: 'updated-values.yaml', + }, + ], + } satisfies UpdateDeployment + + beforeEach(async () => { + datastore = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + DeploymentService, + { provide: DeploymentDatastoreService, useValue: datastore }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(DeploymentService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('listByProjectId', () => { + it('should return deployments by projectId', async () => { + const deployments = [makeDeploymentWithRelations({ id: deploymentId, projectId })] + datastore.getDeploymentsByProjectId.mockResolvedValue(deployments) + + const result = await service.listByProjectId(projectId) + + expect(datastore.getDeploymentsByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(deployments) + }) + }) + + describe('createDeployment', () => { + it('should create deployment and trigger a project reconciliation', async () => { + const createdDeployment = makeDeployment({ id: deploymentId, projectId }) + + datastore.createDeployment.mockResolvedValue(createdDeployment) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + const result = await service.createDeployment(projectId, validCreateDeployment, userId, requestId) + + expect(datastore.createDeployment).toHaveBeenCalledWith({ + name: validCreateDeployment.name, + project: { connect: { id: projectId } }, + autosync: validCreateDeployment.autosync, + environment: { connect: { id: validCreateDeployment.environmentId } }, + deploymentSources: { + createMany: { + data: validCreateDeployment.deploymentSources.map(source => ({ + type: source.type, + repositoryId: source.repositoryId, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + })), + }, + }, + }) + + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Create Deployment', userId, requestId }) + expect(result).toEqual(createdDeployment) + }) + + it('should return the deployment even when the reconciliation fails', async () => { + const createdDeployment = makeDeployment({ id: deploymentId, projectId }) + datastore.createDeployment.mockResolvedValue(createdDeployment) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + const result = await service.createDeployment(projectId, validCreateDeployment, userId, requestId) + + expect(result).toEqual(createdDeployment) + }) + }) + + describe('updateDeployment', () => { + it('should update deployment and trigger a project reconciliation', async () => { + const existingDeployment = makeDeploymentWithRelations({ + id: deploymentId, + projectId, + deploymentSources: [ + makeDeploymentSource({ id: '55555555-5555-5555-5555-555555555555', deploymentId }), + makeDeploymentSource({ id: '66666666-6666-6666-6666-666666666666', deploymentId }), + ], + }) + + const updatedDeployment = makeDeployment({ id: deploymentId, projectId }) + + datastore.getDeploymentById.mockResolvedValue(existingDeployment) + datastore.updateDeployment.mockResolvedValue(updatedDeployment) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + const result = await service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId) + + expect(datastore.updateDeployment).toHaveBeenCalledWith( + deploymentId, + expect.objectContaining({ + name: validUpdateDeployment.name, + deploymentSources: { + deleteMany: { + id: { in: ['66666666-6666-6666-6666-666666666666'] }, + }, + upsert: expect.any(Array), + }, + }), + ) + + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Update Deployment', userId, requestId }) + expect(result).toEqual(updatedDeployment) + }) + + it('should throw if deployment does not exist', async () => { + datastore.getDeploymentById.mockResolvedValue(null) + + await expect( + service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId), + ).rejects.toThrow(`Deployment with id ${deploymentId} not found`) + + expect(datastore.updateDeployment).not.toHaveBeenCalled() + }) + + it('should return the deployment even when the reconciliation fails', async () => { + const existingDeployment = makeDeploymentWithRelations({ + id: deploymentId, + projectId, + deploymentSources: [ + makeDeploymentSource({ id: '55555555-5555-5555-5555-555555555555', deploymentId }), + ], + }) + const updatedDeployment = makeDeployment({ id: deploymentId, projectId }) + datastore.getDeploymentById.mockResolvedValue(existingDeployment) + datastore.updateDeployment.mockResolvedValue(updatedDeployment) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + const result = await service.updateDeployment(projectId, deploymentId, validUpdateDeployment, userId, requestId) + + expect(result).toEqual(updatedDeployment) + }) + }) + + describe('deleteDeployment', () => { + it('should delete deployment and trigger a project reconciliation', async () => { + datastore.deleteDeployment.mockResolvedValue(makeDeployment({ id: deploymentId, projectId })) + appEvents.emitProjectEvent.mockResolvedValue(okArgoCDResults) + + await service.deleteDeployment(projectId, deploymentId, userId, requestId) + + expect(datastore.deleteDeployment).toHaveBeenCalledWith(deploymentId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Delete Deployment', userId, requestId }) + }) + + it('should resolve even when the reconciliation fails', async () => { + datastore.deleteDeployment.mockResolvedValue(makeDeployment({ id: deploymentId, projectId })) + appEvents.emitProjectEvent.mockRejectedValue(new Error('sync error')) + + await expect(service.deleteDeployment(projectId, deploymentId, userId, requestId)).resolves.toBeUndefined() + }) + }) + + describe('deleteAllDeploymentsByProjectId', () => { + it('should delete all deployments and upsert project', async () => { + datastore.deleteAllDeploymentsByProjectId.mockResolvedValue({ count: 1 }) + + await service.deleteAllDeploymentsByProjectId(projectId) + + expect(datastore.deleteAllDeploymentsByProjectId).toHaveBeenCalledWith(projectId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { action: 'Delete all project deployments' }) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.service.ts new file mode 100644 index 0000000000..beec47e562 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/deployment/deployment.service.ts @@ -0,0 +1,108 @@ +import type { CreateDeployment, UpdateDeployment } from '@cpn-console/shared' +import type { EventLogAction } from '../events/app-events.service' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { DeploymentDatastoreService } from './deployment-datastore.service' + +@Injectable() +export class DeploymentService { + private readonly logger = new Logger(DeploymentService.name) + + constructor( + @Inject(DeploymentDatastoreService) private readonly deploymentDatastoreService: DeploymentDatastoreService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async listByProjectId(projectId: string) { + return this.deploymentDatastoreService.getDeploymentsByProjectId(projectId) + } + + async createDeployment(projectId: string, deploymentToCreate: CreateDeployment, userId: string, requestId: string) { + const deployment = await this.deploymentDatastoreService.createDeployment({ + name: deploymentToCreate.name, + project: { connect: { id: projectId } }, + autosync: deploymentToCreate.autosync, + environment: { connect: { id: deploymentToCreate.environmentId } }, + deploymentSources: { + createMany: { + data: deploymentToCreate.deploymentSources.map(({ type, repositoryId, targetRevision, path, helmValuesFiles }) => ({ + type, + repositoryId, + targetRevision, + path, + helmValuesFiles, + })), + }, + }, + }) + + this.reconcileProject(projectId, 'Create Deployment', userId, requestId) + return deployment + } + + async updateDeployment(projectId: string, deploymentId: string, deploymentToUpdate: UpdateDeployment, userId: string, requestId: string) { + const existing = await this.deploymentDatastoreService.getDeploymentById(deploymentId) + if (!existing) throw new Error(`Deployment with id ${deploymentId} not found`) + + const incomingDeploymentSourceIds = new Set( + deploymentToUpdate.deploymentSources + .filter(s => s.id) + .map(s => s.id), + ) + + const deploymentSourcesToDelete = existing.deploymentSources.filter( + e => !incomingDeploymentSourceIds.has(e.id), + ) + + const deployment = await this.deploymentDatastoreService.updateDeployment(deploymentId, { + name: deploymentToUpdate.name, + autosync: deploymentToUpdate.autosync, + environment: { connect: { id: deploymentToUpdate.environmentId } }, + deploymentSources: { + deleteMany: { + id: { in: deploymentSourcesToDelete.map(s => s.id) }, + }, + upsert: deploymentToUpdate.deploymentSources.map(source => ({ + where: { id: source.id ?? crypto.randomUUID() }, + update: { + repository: { connect: { id: source.repositoryId } }, + type: source.type, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + }, + create: { + repository: { connect: { id: source.repositoryId } }, + type: source.type, + targetRevision: source.targetRevision, + path: source.path, + helmValuesFiles: source.helmValuesFiles, + }, + })), + }, + }) + this.reconcileProject(projectId, 'Update Deployment', userId, requestId) + return deployment + } + + async deleteDeployment(projectId: string, deploymentId: string, userId: string, requestId: string) { + await this.deploymentDatastoreService.deleteDeployment(deploymentId) + this.reconcileProject(projectId, 'Delete Deployment', userId, requestId) + } + + async deleteAllDeploymentsByProjectId(projectId: string) { + await this.deploymentDatastoreService.deleteAllDeploymentsByProjectId(projectId) + await this.appEvents.emitProjectEvent('project.upsert', projectId, { action: 'Delete all project deployments' }) + } + + /** + * Triggers the project reconciliation without blocking the response: listener + * outcomes (including failures) are persisted in the admin log by AppEventsService. + */ + private reconcileProject(projectId: string, action: EventLogAction, userId: string, requestId: string) { + this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) + .catch((error: unknown) => { + this.logger.error(`project.upsert reconciliation failed (projectId=${projectId})`, error instanceof Error ? error.stack : String(error)) + }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts new file mode 100644 index 0000000000..6189f20316 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts @@ -0,0 +1,181 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeCluster, makeEnvironment, makeStage } from './environment-testing.utils' + +describe('environmentDatastoreService', () => { + let module: TestingModule + let service: EnvironmentDatastoreService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentDatastoreService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(EnvironmentDatastoreService) + }) + + describe('getProjectEnvironment', () => { + it('should return an environment with its cluster scoped to the project', async () => { + const environment = makeEnvironment({ id: 'env1', projectId: 'project1' }) + prisma.environment.findFirst.mockResolvedValue(environment) + + const result = await service.getProjectEnvironment('project1', 'env1') + + expect(prisma.environment.findFirst).toHaveBeenCalledWith({ + where: { id: 'env1', projectId: 'project1' }, + include: { cluster: true }, + }) + expect(result).toEqual(environment) + }) + }) + + describe('getEnvironmentsByProjectId', () => { + it('should return environments for a project with their stage', async () => { + const environments = [makeEnvironment({ projectId: 'project1' })] + prisma.environment.findMany.mockResolvedValue(environments) + + const result = await service.getEnvironmentsByProjectId('project1') + + expect(prisma.environment.findMany).toHaveBeenCalledWith({ + where: { projectId: 'project1' }, + include: { stage: true }, + }) + expect(result).toEqual(environments) + }) + }) + + describe('getEnvironmentByName', () => { + it('should look the environment up by its project unique name', async () => { + prisma.environment.findUnique.mockResolvedValue(null) + + const result = await service.getEnvironmentByName('project1', 'dev') + + expect(prisma.environment.findUnique).toHaveBeenCalledWith({ + where: { projectId_name: { projectId: 'project1', name: 'dev' } }, + }) + expect(result).toBeNull() + }) + }) + + describe('getStageById', () => { + it('should return the stage', async () => { + const stage = makeStage({ id: 'stage1' }) + prisma.stage.findUnique.mockResolvedValue(stage) + + const result = await service.getStageById('stage1') + + expect(prisma.stage.findUnique).toHaveBeenCalledWith({ where: { id: 'stage1' } }) + expect(result).toEqual(stage) + }) + }) + + describe('getProdStageIds', () => { + it('should return the ids of the prod stages', async () => { + prisma.stage.findMany.mockResolvedValue([makeStage({ id: 'stage1', name: 'prod' })]) + + await service.getProdStageIds() + + expect(prisma.stage.findMany).toHaveBeenCalledWith({ + select: { id: true }, + where: { name: 'prod' }, + }) + }) + }) + + describe('getAvailableCluster', () => { + it('should only match public clusters or dedicated clusters of the project', async () => { + const cluster = makeCluster({ id: 'cluster1' }) + prisma.cluster.findFirst.mockResolvedValue(cluster) + + const result = await service.getAvailableCluster('cluster1', 'project1') + + expect(prisma.cluster.findFirst).toHaveBeenCalledWith({ + where: { + OR: [{ + id: 'cluster1', + privacy: 'public', + }, { + id: 'cluster1', + privacy: 'dedicated', + projects: { some: { id: 'project1' } }, + }], + }, + }) + expect(result).toEqual(cluster) + }) + }) + + describe('sumEnvironmentResources', () => { + it('should aggregate cpu, gpu and memory', async () => { + await service.sumEnvironmentResources({ clusterId: 'cluster1' }) + + expect(prisma.environment.aggregate).toHaveBeenCalledWith({ + _sum: { + cpu: true, + gpu: true, + memory: true, + }, + where: { clusterId: 'cluster1' }, + }) + }) + }) + + describe('createEnvironment', () => { + it('should create an environment', async () => { + const environment = makeEnvironment() + prisma.environment.create.mockResolvedValue(environment) + + const result = await service.createEnvironment({ + projectId: environment.projectId, + name: environment.name, + clusterId: environment.clusterId, + stageId: environment.stageId, + cpu: environment.cpu, + gpu: environment.gpu, + memory: environment.memory, + autosync: environment.autosync, + }) + + expect(prisma.environment.create).toHaveBeenCalled() + expect(result).toEqual(environment) + }) + }) + + describe('updateEnvironment', () => { + it('should update the environment resources', async () => { + const environment = makeEnvironment({ id: 'env1' }) + prisma.environment.update.mockResolvedValue(environment) + + const result = await service.updateEnvironment('env1', { cpu: 2, gpu: 0, memory: 4, autosync: false }) + + expect(prisma.environment.update).toHaveBeenCalledWith({ + where: { id: 'env1' }, + data: { cpu: 2, gpu: 0, memory: 4, autosync: false }, + }) + expect(result).toEqual(environment) + }) + }) + + describe('deleteEnvironment', () => { + it('should delete the environment', async () => { + const environment = makeEnvironment({ id: 'env1' }) + prisma.environment.delete.mockResolvedValue(environment) + + const result = await service.deleteEnvironment('env1') + + expect(prisma.environment.delete).toHaveBeenCalledWith({ where: { id: 'env1' } }) + expect(result).toEqual(environment) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts new file mode 100644 index 0000000000..1dab19eb23 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts @@ -0,0 +1,99 @@ +import type { Cluster, Environment, Prisma, Project, Stage } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PROD_STAGE_NAME } from './environment.constants' + +export type EnvironmentWithCluster = Environment & { cluster: Cluster } +export type EnvironmentWithStage = Environment & { stage: Stage } + +export interface EnvironmentResourcesSum { + _sum: { + cpu: number | null + gpu: number | null + memory: number | null + } +} + +@Injectable() +export class EnvironmentDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + getProjectEnvironment(projectId: string, environmentId: string): Promise { + return this.prisma.environment.findFirst({ + where: { id: environmentId, projectId }, + include: { cluster: true }, + }) + } + + getEnvironmentsByProjectId(projectId: string): Promise { + return this.prisma.environment.findMany({ + where: { projectId }, + include: { stage: true }, + }) + } + + getEnvironmentByName(projectId: string, name: string): Promise { + return this.prisma.environment.findUnique({ + where: { projectId_name: { projectId, name } }, + }) + } + + getStageById(stageId: string): Promise { + return this.prisma.stage.findUnique({ where: { id: stageId } }) + } + + getProdStageIds(): Promise<{ id: string }[]> { + return this.prisma.stage.findMany({ + select: { id: true }, + where: { name: PROD_STAGE_NAME }, + }) + } + + /** Only public clusters or dedicated clusters attached to the project are usable. */ + getAvailableCluster(clusterId: string, projectId: string): Promise { + return this.prisma.cluster.findFirst({ + where: { + OR: [{ + id: clusterId, + privacy: 'public', + }, { + id: clusterId, + privacy: 'dedicated', + projects: { some: { id: projectId } }, + }], + }, + }) + } + + getProjectById(projectId: string): Promise { + return this.prisma.project.findUniqueOrThrow({ where: { id: projectId } }) + } + + sumEnvironmentResources(where: Prisma.EnvironmentWhereInput): Promise { + return this.prisma.environment.aggregate({ + _sum: { + cpu: true, + gpu: true, + memory: true, + }, + where, + }) + } + + createEnvironment(data: Prisma.EnvironmentUncheckedCreateInput): Promise { + return this.prisma.environment.create({ data }) + } + + updateEnvironment(environmentId: string, data: Pick): Promise { + return this.prisma.environment.update({ + where: { id: environmentId }, + data, + }) + } + + deleteEnvironment(environmentId: string): Promise { + return this.prisma.environment.delete({ + where: { id: environmentId }, + }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts new file mode 100644 index 0000000000..5f0d0ee58d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts @@ -0,0 +1,87 @@ +import type { Cluster, Environment, Project, Stage } from '@prisma/client' +import type { EnvironmentWithCluster, EnvironmentWithStage } from './environment-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + projectId: faker.string.uuid(), + memory: 2, + cpu: 1, + gpu: 0, + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export function makeCluster(overrides: Partial = {}): Cluster { + return { + id: faker.string.uuid(), + label: faker.string.alphanumeric(8).toLowerCase(), + privacy: 'public', + secretName: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + infos: null, + memory: 0, + cpu: 0, + gpu: 0, + zoneId: faker.string.uuid(), + ...overrides, + } +} + +export function makeStage(overrides: Partial = {}): Stage { + return { + id: faker.string.uuid(), + name: 'dev', + ...overrides, + } +} + +export function makeProject(overrides: Partial = {}): Project { + return { + id: faker.string.uuid(), + name: faker.string.alphanumeric(8).toLowerCase(), + description: '', + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.string.alphanumeric(8).toLowerCase(), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } +} + +export function makeEnvironmentWithCluster(overrides: Partial = {}): EnvironmentWithCluster { + const base = makeEnvironment(overrides) + return { + ...base, + cluster: overrides.cluster ?? makeCluster({ id: base.clusterId }), + } +} + +export function makeEnvironmentWithStage(overrides: Partial = {}): EnvironmentWithStage { + const base = makeEnvironment(overrides) + return { + ...base, + stage: overrides.stage ?? makeStage({ id: base.stageId }), + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts new file mode 100644 index 0000000000..1933528eb8 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-validation.service.spec.ts @@ -0,0 +1,222 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { BadRequestException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeCluster, makeEnvironment, makeEnvironmentWithCluster, makeProject, makeStage } from './environment-testing.utils' +import { EnvironmentValidationService } from './environment-validation.service' + +describe('environmentValidationService', () => { + let module: TestingModule + let service: EnvironmentValidationService + let datastore: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const environmentId = '22222222-2222-2222-2222-222222222222' + const clusterId = '33333333-3333-3333-3333-333333333333' + const stageId = '44444444-4444-4444-4444-444444444444' + + const validCreateEnvironment = { + name: 'dev', + clusterId, + stageId, + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + datastore = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentValidationService, + { provide: EnvironmentDatastoreService, useValue: datastore }, + ], + }).compile() + + service = module.get(EnvironmentValidationService) + }) + + describe('validateCreate', () => { + it('should pass when the stage, name and cluster are valid and no limit applies', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + // cpu/memory at 0 means the cluster resources are not configured + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)).resolves.toBeUndefined() + }) + + it('should reject when the environment name is already taken', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(makeEnvironment({ projectId, name: validCreateEnvironment.name })) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow(BadRequestException) + }) + + it('should reject when the stage does not exist', async () => { + datastore.getStageById.mockResolvedValue(null) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Type d\'environnement invalide.') + }) + + it('should reject when the cluster is not available for the project', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(null) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Cluster invalide.') + }) + + it('should reject when the cluster does not have enough resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 4, gpu: 0, memory: 8 })) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le cluster ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ clusterId }) + }) + + it('should reject when the project does not have enough prod resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'prod' })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + prodCpu: 2, + prodGpu: 0, + prodMemory: 4, + })) + datastore.getProdStageIds.mockResolvedValue([{ id: stageId }]) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 1, gpu: 0, memory: 2 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le projet ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + + it('should reject when the project does not have enough non-prod resources', async () => { + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'dev' })) + datastore.getEnvironmentByName.mockResolvedValue(null) + datastore.getAvailableCluster.mockResolvedValue(makeCluster({ id: clusterId, cpu: 0, memory: 0 })) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + hprodCpu: 2, + hprodGpu: 0, + hprodMemory: 4, + })) + datastore.getProdStageIds.mockResolvedValue([{ id: '55555555-5555-5555-5555-555555555555' }]) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 1, gpu: 0, memory: 2 }, + }) + + await expect(service.validateCreate(projectId, validCreateEnvironment)) + .rejects.toThrow('Le projet ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + }) + + describe('validateUpdate', () => { + it('should pass when the project is limitless and the cluster limits are not configured', async () => { + const environment = makeEnvironmentWithCluster({ + projectId, + cluster: makeCluster({ id: clusterId, cpu: 0, memory: 0 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + }) + + it('should reject when the cluster does not have enough resources', async () => { + const environment = makeEnvironmentWithCluster({ + projectId, + cluster: makeCluster({ id: clusterId, cpu: 4, gpu: 0, memory: 8 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)) + .rejects.toThrow('Le cluster ne dispose pas de suffisamment de ressources : CPU, GPU, Mémoire.') + }) + + it('should not count the environment being updated in the cluster consumed resources', async () => { + const environment = makeEnvironmentWithCluster({ + id: environmentId, + projectId, + cluster: makeCluster({ id: clusterId, cpu: 8, gpu: 2, memory: 16 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ id: projectId, limitless: true })) + // the sum only covers the other environments of the cluster + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ + clusterId, + id: { not: environmentId }, + }) + }) + + it('should not count the environment being updated in the project consumed resources', async () => { + const environment = makeEnvironmentWithCluster({ + id: environmentId, + projectId, + stageId, + cluster: makeCluster({ id: clusterId, cpu: 0, memory: 0 }), + }) + datastore.getProjectById.mockResolvedValue(makeProject({ + id: projectId, + limitless: false, + prodCpu: 8, + prodGpu: 2, + prodMemory: 16, + })) + datastore.getStageById.mockResolvedValue(makeStage({ id: stageId, name: 'prod' })) + datastore.getProdStageIds.mockResolvedValue([{ id: stageId }]) + // the sum only covers the other prod environments of the project + datastore.sumEnvironmentResources.mockResolvedValue({ + _sum: { cpu: 3, gpu: 0, memory: 6 }, + }) + + await expect(service.validateUpdate(environment, validUpdateEnvironment)).resolves.toBeUndefined() + + expect(datastore.sumEnvironmentResources).toHaveBeenCalledWith({ + projectId, + stageId: { in: [stageId] }, + id: { not: environmentId }, + }) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-validation.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-validation.service.ts new file mode 100644 index 0000000000..f81672410a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment-validation.service.ts @@ -0,0 +1,152 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Cluster, Prisma, Project } from '@prisma/client' +import type { EnvironmentWithCluster } from './environment-datastore.service' +import { BadRequestException, Inject, Injectable } from '@nestjs/common' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { PROD_STAGE_NAME } from './environment.constants' + +interface EnvironmentResources { + cpu: number + gpu: number + memory: number +} + +/** cpu and memory both at 0 mean the limits were never configured, so quota checks do not apply. */ +function areResourceLimitsConfigured(limit: EnvironmentResources): boolean { + return limit.cpu !== 0 || limit.memory !== 0 +} + +/** + * Business rules for environments: name unicity, stage and cluster validity, + * cluster and project resource quotas. Existence/ownership (404) concerns stay + * in EnvironmentService; this service only decides whether a request is allowed (400). + */ +@Injectable() +export class EnvironmentValidationService { + constructor( + @Inject(EnvironmentDatastoreService) private readonly environmentDatastoreService: EnvironmentDatastoreService, + ) {} + + async validateCreate(projectId: string, input: CreateEnvironment): Promise { + const errorMessages: string[] = [] + const [stage, sameNameEnvironment, cluster] = await Promise.all([ + this.environmentDatastoreService.getStageById(input.stageId), + this.environmentDatastoreService.getEnvironmentByName(projectId, input.name), + this.environmentDatastoreService.getAvailableCluster(input.clusterId, projectId), + ]) + + if (sameNameEnvironment) errorMessages.push('Ce nom d\'environnement est déjà pris.') + if (!stage) errorMessages.push('Type d\'environnement invalide.') + if (cluster) { + const clusterError = await this.checkClusterResources(input, cluster) + if (clusterError) errorMessages.push(clusterError) + + const project = await this.environmentDatastoreService.getProjectById(projectId) + const projectError = await this.checkProjectResources(input, input.stageId, project) + if (projectError) errorMessages.push(projectError) + } else { + errorMessages.push('Cluster invalide.') + } + + if (errorMessages.length > 0) { + throw new BadRequestException(errorMessages.join('\n')) + } + } + + async validateUpdate(environment: EnvironmentWithCluster, input: UpdateEnvironment): Promise { + const errorMessages: string[] = [] + const clusterError = await this.checkClusterResources(input, environment.cluster, environment.id) + if (clusterError) errorMessages.push(clusterError) + + const project = await this.environmentDatastoreService.getProjectById(environment.projectId) + const projectError = await this.checkProjectResources(input, environment.stageId, project, environment.id) + if (projectError) errorMessages.push(projectError) + + if (errorMessages.length > 0) { + throw new BadRequestException(errorMessages.join('\n')) + } + } + + private async checkClusterResources(request: EnvironmentResources, cluster: Cluster, excludedEnvironmentId?: string): Promise { + const overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: cluster.cpu, gpu: cluster.gpu, memory: cluster.memory }, + where: { clusterId: cluster.id }, + excludedEnvironmentId, + }) + if (overflowResources.length > 0) { + return `Le cluster ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.` + } + return undefined + } + + private async checkProjectResources(request: EnvironmentResources, stageId: string, project: Project, excludedEnvironmentId?: string): Promise { + if (project.limitless) { + // No limits + return undefined + } + const [stage, prodStages] = await Promise.all([ + this.environmentDatastoreService.getStageById(stageId), + this.environmentDatastoreService.getProdStageIds(), + ]) + const prodStageIds = prodStages.map(s => s.id) + + let overflowResources: string[] + if (stage?.name === PROD_STAGE_NAME) { + overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: project.prodCpu, gpu: project.prodGpu, memory: project.prodMemory }, + where: { + projectId: project.id, + stageId: { in: prodStageIds }, + }, + excludedEnvironmentId, + }) + } else { // hprod + overflowResources = await this.getOverflowResources({ + request, + limit: { cpu: project.hprodCpu, gpu: project.hprodGpu, memory: project.hprodMemory }, + where: { + projectId: project.id, + stageId: { notIn: prodStageIds }, + }, + excludedEnvironmentId, + }) + } + if (overflowResources.length > 0) { + return `Le projet ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.` + } + return undefined + } + + /** + * On update, the environment being updated must be excluded from the consumed-resources + * sum: its requested resources replace its current ones. Counting both would reject + * legitimate updates within the quota (#1911). + */ + private async getOverflowResources({ request, limit, where, excludedEnvironmentId }: { + request: EnvironmentResources + limit: EnvironmentResources + where: Prisma.EnvironmentWhereInput + excludedEnvironmentId?: string + }): Promise { + if (!areResourceLimitsConfigured(limit)) { + return [] + } + const environmentResources = await this.environmentDatastoreService.sumEnvironmentResources( + excludedEnvironmentId === undefined ? where : { ...where, id: { not: excludedEnvironmentId } }, + ) + // A null sum means no environment matched: nothing is consumed yet. + const insufficientResources: string[] = [] + if ((environmentResources._sum.cpu ?? 0) + request.cpu > limit.cpu) { + insufficientResources.push('CPU') + } + if ((environmentResources._sum.gpu ?? 0) + request.gpu > limit.gpu) { + insufficientResources.push('GPU') + } + if ((environmentResources._sum.memory ?? 0) + request.memory > limit.memory) { + insufficientResources.push('Mémoire') + } + return insufficientResources + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.constants.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.constants.ts new file mode 100644 index 0000000000..b55c418847 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.constants.ts @@ -0,0 +1,2 @@ +/** Name of the stage whose environments consume the project's production resource quotas. */ +export const PROD_STAGE_NAME = 'prod' diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts new file mode 100644 index 0000000000..1449e91a3a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts @@ -0,0 +1,128 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { makeEnvironment, makeEnvironmentWithStage } from './environment-testing.utils' +import { EnvironmentController } from './environment.controller' +import { EnvironmentService } from './environment.service' + +describe('environmentController', () => { + let module: TestingModule + let controller: EnvironmentController + let service: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + + const project: ProjectContext = { id: projectId, slug: 'my-project' } + const user: UserContext = { userId } + const request = { id: requestId } as FastifyRequest + + const validCreateEnvironment = { + name: 'dev', + clusterId: '22222222-2222-2222-2222-222222222222', + stageId: '33333333-3333-3333-3333-333333333333', + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + service = mockDeep() + + module = await Test.createTestingModule({ + controllers: [EnvironmentController], + providers: [ + { provide: EnvironmentService, useValue: service }, + ], + }) + .overrideGuard(ProjectGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(EnvironmentController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call environmentService.listByProjectId with projectId', async () => { + const expectedResult = [makeEnvironmentWithStage({ projectId })] + + service.listByProjectId.mockResolvedValue(expectedResult) + + const result = await controller.list(project) + + expect(service.listByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(expectedResult) + }) + }) + + describe('create', () => { + it('should validate body and call environmentService.createEnvironment', async () => { + const expectedResult = makeEnvironment({ projectId }) + + service.createEnvironment.mockResolvedValue(expectedResult) + + const result = await controller.create(validCreateEnvironment, project, user, request) + + expect(service.createEnvironment).toHaveBeenCalledWith( + projectId, + validCreateEnvironment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('update', () => { + it('should validate body and call environmentService.updateEnvironment', async () => { + const environmentId = '44444444-4444-4444-4444-444444444444' + const expectedResult = makeEnvironment({ id: environmentId, projectId }) + + service.updateEnvironment.mockResolvedValue(expectedResult) + + const result = await controller.update(environmentId, validUpdateEnvironment, project, user, request) + + expect(service.updateEnvironment).toHaveBeenCalledWith( + projectId, + environmentId, + validUpdateEnvironment, + userId, + requestId, + ) + expect(result).toEqual(expectedResult) + }) + }) + + describe('delete', () => { + it('should call environmentService.deleteEnvironment with environmentId', async () => { + const environmentId = '44444444-4444-4444-4444-444444444444' + + service.deleteEnvironment.mockResolvedValue(undefined) + + const result = await controller.delete(environmentId, project, user, request) + + expect(service.deleteEnvironment).toHaveBeenCalledWith(projectId, environmentId, userId, requestId) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.controller.ts new file mode 100644 index 0000000000..7589f241f8 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.controller.ts @@ -0,0 +1,73 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Environment } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { EnvironmentWithStage } from './environment-datastore.service' +import { CreateEnvironmentSchema, UpdateEnvironmentSchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { EnvironmentService } from './environment.service' + +@Controller('api/v2/projects/:projectId/environments') +@UseGuards(ProjectGuard) +export class EnvironmentController { + constructor(@Inject(EnvironmentService) private readonly environmentService: EnvironmentService) {} + + @Get('') + @RequireAdminPermission('ListProjects') + @RequireProjectPermission('ListEnvironments') + list(@Project() project: ProjectContext): Promise { + return this.environmentService.listByProjectId(project.id) + } + + @Post('') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.CREATED) + create( + @Body(new ZodValidationPipe(CreateEnvironmentSchema)) data: CreateEnvironment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.createEnvironment(project.id, data, user.userId, request.id) + } + + @Put(':environmentId') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.OK) + update( + @Param('environmentId', ParseUUIDPipe) environmentId: string, + @Body(new ZodValidationPipe(UpdateEnvironmentSchema)) data: UpdateEnvironment, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.updateEnvironment(project.id, environmentId, data, user.userId, request.id) + } + + @Delete(':environmentId') + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @HttpCode(HttpStatus.NO_CONTENT) + delete( + @Param('environmentId', ParseUUIDPipe) environmentId: string, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.environmentService.deleteEnvironment(project.id, environmentId, user.userId, request.id) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.module.ts new file mode 100644 index 0000000000..a9c4f597d4 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { EnvironmentValidationService } from './environment-validation.service' +import { EnvironmentController } from './environment.controller' +import { EnvironmentService } from './environment.service' + +@Module({ + imports: [AppEventsModule, InfrastructureModule], + controllers: [EnvironmentController], + providers: [ + EnvironmentDatastoreService, + EnvironmentValidationService, + EnvironmentService, + ], +}) +export class EnvironmentModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.service.spec.ts new file mode 100644 index 0000000000..d9baf3db40 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.service.spec.ts @@ -0,0 +1,183 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { makeEnvironment, makeEnvironmentWithCluster, makeEnvironmentWithStage } from './environment-testing.utils' +import { EnvironmentValidationService } from './environment-validation.service' +import { EnvironmentService } from './environment.service' + +describe('environmentService', () => { + let module: TestingModule + let service: EnvironmentService + let datastore: DeepMockProxy + let validation: DeepMockProxy + let appEvents: DeepMockProxy + + const projectId = '11111111-1111-1111-1111-111111111111' + const userId = 'user-uuid-1234' + const requestId = 'request-uuid-5678' + const environmentId = '22222222-2222-2222-2222-222222222222' + const clusterId = '33333333-3333-3333-3333-333333333333' + const stageId = '44444444-4444-4444-4444-444444444444' + + const validCreateEnvironment = { + name: 'dev', + clusterId, + stageId, + cpu: 2, + gpu: 0, + memory: 4, + autosync: true, + } satisfies CreateEnvironment + + const validUpdateEnvironment = { + cpu: 4, + gpu: 1, + memory: 8, + autosync: false, + } satisfies UpdateEnvironment + + beforeEach(async () => { + datastore = mockDeep() + validation = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + EnvironmentService, + { provide: EnvironmentDatastoreService, useValue: datastore }, + { provide: EnvironmentValidationService, useValue: validation }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(EnvironmentService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('listByProjectId', () => { + it('should return environments by projectId', async () => { + const environments = [makeEnvironmentWithStage({ id: environmentId, projectId })] + datastore.getEnvironmentsByProjectId.mockResolvedValue(environments) + + const result = await service.listByProjectId(projectId) + + expect(datastore.getEnvironmentsByProjectId).toHaveBeenCalledWith(projectId) + expect(result).toEqual(environments) + }) + }) + + describe('createEnvironment', () => { + it('should validate, create the environment and trigger the project reconciliation', async () => { + const environment = makeEnvironment({ id: environmentId, projectId, clusterId, stageId }) + validation.validateCreate.mockResolvedValue(undefined) + datastore.createEnvironment.mockResolvedValue(environment) + appEvents.emitProjectEvent.mockResolvedValue({}) + + const result = await service.createEnvironment(projectId, validCreateEnvironment, userId, requestId) + + expect(validation.validateCreate).toHaveBeenCalledWith(projectId, validCreateEnvironment) + expect(datastore.createEnvironment).toHaveBeenCalledWith({ + projectId, + name: validCreateEnvironment.name, + clusterId, + stageId, + cpu: validCreateEnvironment.cpu, + gpu: validCreateEnvironment.gpu, + memory: validCreateEnvironment.memory, + autosync: validCreateEnvironment.autosync, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Create Environment', + userId, + requestId, + }) + expect(result).toEqual(environment) + }) + + it('should not create the environment when the validation fails', async () => { + validation.validateCreate.mockRejectedValue(new BadRequestException('Cluster invalide.')) + + await expect(service.createEnvironment(projectId, validCreateEnvironment, userId, requestId)) + .rejects.toThrow(BadRequestException) + expect(datastore.createEnvironment).not.toHaveBeenCalled() + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) + }) + + describe('updateEnvironment', () => { + it('should validate, update the environment and trigger the project reconciliation', async () => { + const existing = makeEnvironmentWithCluster({ id: environmentId, projectId }) + const updated = makeEnvironment({ id: environmentId, projectId, ...validUpdateEnvironment }) + datastore.getProjectEnvironment.mockResolvedValue(existing) + validation.validateUpdate.mockResolvedValue(undefined) + datastore.updateEnvironment.mockResolvedValue(updated) + appEvents.emitProjectEvent.mockResolvedValue({}) + + const result = await service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId) + + expect(validation.validateUpdate).toHaveBeenCalledWith(existing, validUpdateEnvironment) + expect(datastore.updateEnvironment).toHaveBeenCalledWith(environmentId, validUpdateEnvironment) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Update Environment', + userId, + requestId, + }) + expect(result).toEqual(updated) + }) + + it('should reject when the environment is not found in the project', async () => { + datastore.getProjectEnvironment.mockResolvedValue(null) + + await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) + .rejects.toThrow(NotFoundException) + expect(datastore.getProjectEnvironment).toHaveBeenCalledWith(projectId, environmentId) + expect(validation.validateUpdate).not.toHaveBeenCalled() + expect(datastore.updateEnvironment).not.toHaveBeenCalled() + }) + + it('should not update the environment when the validation fails', async () => { + datastore.getProjectEnvironment.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId, projectId })) + validation.validateUpdate.mockRejectedValue(new BadRequestException('Le cluster ne dispose pas de suffisamment de ressources : CPU.')) + + await expect(service.updateEnvironment(projectId, environmentId, validUpdateEnvironment, userId, requestId)) + .rejects.toThrow(BadRequestException) + expect(datastore.updateEnvironment).not.toHaveBeenCalled() + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) + }) + + describe('deleteEnvironment', () => { + it('should delete the environment and trigger the project reconciliation', async () => { + datastore.getProjectEnvironment.mockResolvedValue(makeEnvironmentWithCluster({ id: environmentId, projectId })) + datastore.deleteEnvironment.mockResolvedValue(makeEnvironment({ id: environmentId, projectId })) + appEvents.emitProjectEvent.mockResolvedValue({}) + + await service.deleteEnvironment(projectId, environmentId, userId, requestId) + + expect(datastore.deleteEnvironment).toHaveBeenCalledWith(environmentId) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', projectId, { + action: 'Delete Environment', + userId, + requestId, + }) + }) + + it('should reject when the environment is not found in the project', async () => { + datastore.getProjectEnvironment.mockResolvedValue(null) + + await expect(service.deleteEnvironment(projectId, environmentId, userId, requestId)) + .rejects.toThrow(NotFoundException) + expect(datastore.getProjectEnvironment).toHaveBeenCalledWith(projectId, environmentId) + expect(datastore.deleteEnvironment).not.toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.service.ts new file mode 100644 index 0000000000..d181c84966 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/environment/environment.service.ts @@ -0,0 +1,81 @@ +import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' +import type { Environment } from '@prisma/client' +import type { EventLogAction } from '../events/app-events.service' +import type { EnvironmentWithCluster, EnvironmentWithStage } from './environment-datastore.service' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { EnvironmentDatastoreService } from './environment-datastore.service' +import { EnvironmentValidationService } from './environment-validation.service' + +@Injectable() +export class EnvironmentService { + private readonly logger = new Logger(EnvironmentService.name) + + constructor( + @Inject(EnvironmentDatastoreService) private readonly environmentDatastoreService: EnvironmentDatastoreService, + @Inject(EnvironmentValidationService) private readonly environmentValidationService: EnvironmentValidationService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async listByProjectId(projectId: string): Promise { + return this.environmentDatastoreService.getEnvironmentsByProjectId(projectId) + } + + async createEnvironment(projectId: string, environmentToCreate: CreateEnvironment, userId: string, requestId: string): Promise { + await this.environmentValidationService.validateCreate(projectId, environmentToCreate) + + const environment = await this.environmentDatastoreService.createEnvironment({ + projectId, + name: environmentToCreate.name, + clusterId: environmentToCreate.clusterId, + stageId: environmentToCreate.stageId, + cpu: environmentToCreate.cpu, + gpu: environmentToCreate.gpu, + memory: environmentToCreate.memory, + autosync: environmentToCreate.autosync, + }) + + this.reconcileProject(projectId, 'Create Environment', userId, requestId) + return environment + } + + async updateEnvironment(projectId: string, environmentId: string, environmentToUpdate: UpdateEnvironment, userId: string, requestId: string): Promise { + const existingEnvironment = await this.getProjectEnvironmentOrThrow(projectId, environmentId) + await this.environmentValidationService.validateUpdate(existingEnvironment, environmentToUpdate) + + const environment = await this.environmentDatastoreService.updateEnvironment(environmentId, { + cpu: environmentToUpdate.cpu, + gpu: environmentToUpdate.gpu, + memory: environmentToUpdate.memory, + autosync: environmentToUpdate.autosync, + }) + + this.reconcileProject(projectId, 'Update Environment', userId, requestId) + return environment + } + + async deleteEnvironment(projectId: string, environmentId: string, userId: string, requestId: string): Promise { + await this.getProjectEnvironmentOrThrow(projectId, environmentId) + await this.environmentDatastoreService.deleteEnvironment(environmentId) + this.reconcileProject(projectId, 'Delete Environment', userId, requestId) + } + + private async getProjectEnvironmentOrThrow(projectId: string, environmentId: string): Promise { + const environment = await this.environmentDatastoreService.getProjectEnvironment(projectId, environmentId) + if (!environment) { + throw new NotFoundException('Environnement introuvable') + } + return environment + } + + /** + * Triggers the project reconciliation without blocking the response: listener + * outcomes (including failures) are persisted in the admin log by AppEventsService. + */ + private reconcileProject(projectId: string, action: EventLogAction, userId: string, requestId: string): void { + this.appEvents.emitProjectEvent('project.upsert', projectId, { action, userId, requestId }) + .catch((error: unknown) => { + this.logger.error(`project.upsert reconciliation failed (projectId=${projectId})`, error instanceof Error ? error.stack : String(error)) + }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.module.ts new file mode 100644 index 0000000000..08130cfc7b --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { LogModule } from '../log/log.module' +import { AppEventsService } from './app-events.service' + +@Module({ + imports: [InfrastructureModule, LogModule], + providers: [AppEventsService], + exports: [AppEventsService], +}) +export class AppEventsModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.service.spec.ts new file mode 100644 index 0000000000..7bd6fa2ea2 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.service.spec.ts @@ -0,0 +1,178 @@ +import type { EventEmitter2 as EventEmitter2Type } from '@nestjs/event-emitter' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { projectSelect } from '../project/project-queries.utils' +import { makeProject } from '../project/project-testing.utils' +import { AppEventsService } from './app-events.service' + +describe('appEventsService', () => { + let module: TestingModule + let service: AppEventsService + let prisma: DeepMockProxy + let eventEmitter: DeepMockProxy + let logs: DeepMockProxy + let config: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + eventEmitter = mockDeep({ emitAsync: vi.fn().mockResolvedValue([]) }) + logs = mockDeep() + config = mockDeep({ appVersion: 'test-version' }) + + module = await Test.createTestingModule({ + providers: [ + AppEventsService, + { provide: PrismaService, useValue: prisma }, + { provide: EventEmitter2, useValue: eventEmitter }, + { provide: LogService, useValue: logs }, + { provide: BASE_CONFIG, useValue: config }, + ], + }).compile() + + service = module.get(AppEventsService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('loads the project by id, emits and logs the merged listener results', async () => { + const project = makeProject() + prisma.project.findUnique.mockResolvedValue(project) + eventEmitter.emitAsync.mockResolvedValue([ + undefined, // listener not yet migrated to capturePluginResult + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + { argocd: { status: 'KO', message: 'Sync failed', executionTime: 20, error: new Error('Sync failed') } }, + ]) + + const results = await service.emitProjectEvent('project.upsert', project.id, { + action: 'Create Project', + userId: 'user-id', + requestId: 'request-id', + }) + + expect(prisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: project.id }, + select: projectSelect, + }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', project) + expect(results).toEqual({ + gitlab: expect.objectContaining({ status: 'OK' }), + argocd: expect.objectContaining({ status: 'KO' }), + }) + expect(logs.addLog).toHaveBeenCalledWith({ + action: 'Create Project', + userId: 'user-id', + requestId: 'request-id', + projectId: project.id, + data: { + args: project, + failed: ['argocd'], + results: { + gitlab: { + status: { result: 'OK', message: 'Up to date' }, + executionTime: { main: 10 }, + }, + argocd: { + status: { result: 'KO', message: 'Sync failed' }, + executionTime: { main: 20 }, + error: expect.stringContaining('"message":"Sync failed"'), + }, + }, + totalExecutionTime: expect.any(Number), + messageResume: 'Errors:\nargocd: Sync failed;', + }, + }) + }) + + it('marks the project as failed when a listener reports a KO result', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { argocd: { status: 'KO', message: 'Sync failed', executionTime: 20, error: new Error('Sync failed') } }, + ]) + + await service.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: project.id }, + data: { status: 'failed' }, + }) + }) + + it('marks the project as created with the provisioning version after a successful upsert', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + ]) + + await service.emitProjectEvent('project.upsert', project, { action: 'Create Project' }) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: project.id }, + data: { status: 'created', lastSuccessProvisionningVersion: 'test-version' }, + }) + }) + + it('does not touch the project status after a successful delete', async () => { + const project = makeProject() + eventEmitter.emitAsync.mockResolvedValue([ + { gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } }, + ]) + + await service.emitProjectEvent('project.delete', project, { action: 'Delete all project resources' }) + + expect(prisma.project.update).not.toHaveBeenCalled() + }) + + it('uses a provided project snapshot without fetching it again', async () => { + const project = makeProject() + + await service.emitProjectEvent('project.delete', project, { action: 'Delete all project resources' }) + + expect(prisma.project.findUnique).not.toHaveBeenCalled() + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.delete', project) + }) + + it('defaults the log userId and requestId to null', async () => { + const project = makeProject() + + await service.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Update Project', + userId: null, + requestId: null, + })) + }) + + it('skips emitting and logging when the project cannot be found', async () => { + prisma.project.findUnique.mockResolvedValue(null) + + const results = await service.emitProjectEvent('project.upsert', 'missing-id', { action: 'Update Project' }) + + expect(results).toEqual({}) + expect(eventEmitter.emitAsync).not.toHaveBeenCalled() + expect(logs.addLog).not.toHaveBeenCalled() + }) + + it('emits and logs project member events with their payload as args', async () => { + const payload = { projectId: 'project-id', userId: 'user-id' } + + await service.emitProjectMemberEvent('projectMember.upsert', payload, { action: 'Add Project Member' }) + + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.upsert', payload) + expect(logs.addLog).toHaveBeenCalledWith(expect.objectContaining({ + action: 'Add Project Member', + projectId: 'project-id', + data: expect.objectContaining({ args: payload }), + })) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.service.ts new file mode 100644 index 0000000000..7de8bcf562 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -0,0 +1,138 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { PluginResults } from '../plugin/plugin.utils' +import type { ProjectWithDetails } from '../project/project-queries.utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { getFailedPlugins, mergePluginResults } from '../plugin/plugin.utils' +import { getProject } from '../project/project-queries.utils' +import { formatEventLogData, isPluginResults } from './app-events.utils' + +export type ProjectEventName = 'project.upsert' | 'project.delete' +export type ProjectMemberEventName = 'projectMember.upsert' | 'projectMember.delete' + +export interface ProjectMemberEventPayload { + projectId: string + userId: string +} + +/** Admin-log action labels (legacy hooks wording). */ +export type EventLogAction + = | 'Create Project' | 'Update Project' | 'Delete all project resources' + | 'Replay hooks for Project' | 'Upsert Project Role' + | 'Create Deployment' | 'Update Deployment' | 'Delete Deployment' + | 'Delete all project deployments' + | 'Create Environment' | 'Update Environment' | 'Delete Environment' + | 'Add Project Member' | 'Update Project Member' | 'Remove Project Member' + +export interface EventContext { + /** Action label persisted in the admin log. */ + action: EventLogAction + userId?: string | null + requestId?: string | null +} + +/** + * Single entry point to emit domain events: emits through EventEmitter2, merges the + * `PluginResults` returned by every `capturePluginResult` listener and persists one admin + * log per event. Emitters only provide an id and the log context. + */ +@Injectable() +export class AppEventsService { + private readonly logger = new Logger(AppEventsService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, + @Inject(LogService) private readonly logs: LogService, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + /** + * Emits a project event and logs the merged listener results. + * + * Pass a project id in the general case: the project is loaded here, after the + * emitting transaction commits, so every listener sees the same committed state. + * Pass an already-loaded snapshot when the row no longer reflects what listeners + * must act on (e.g. archiving renames the slug before emitting `project.delete`). + */ + async emitProjectEvent( + event: ProjectEventName, + projectOrId: string | ProjectWithDetails, + context: EventContext, + ): Promise { + const project = typeof projectOrId === 'string' + ? await getProject(this.prisma, projectOrId) + : projectOrId + + if (!project) { + this.logger.warn(`${event} skipped: project not found (projectId=${String(projectOrId)})`) + return {} + } + + const results = await this.emitAndLog(event, project, project.id, context) + await this.updateProjectStatus(event, project.id, results) + return results + } + + async emitProjectMemberEvent( + event: ProjectMemberEventName, + payload: ProjectMemberEventPayload, + context: EventContext, + ): Promise { + return this.emitAndLog(event, payload, payload.projectId, context) + } + + private async emitAndLog( + event: string, + payload: unknown, + projectId: string, + context: EventContext, + ): Promise { + const start = process.hrtime.bigint() + const responses = await this.eventEmitter.emitAsync(event, payload) + const totalExecutionTime = Number(process.hrtime.bigint() - start) / 1_000_000 + + const results = mergePluginResults(responses.filter(isPluginResults)) + this.logger.log(`${event} completed (projectId=${projectId}, services=${Object.keys(results).join(',') || 'none'})`) + + await this.logs.addLog({ + action: context.action, + data: formatEventLogData(payload, results, totalExecutionTime), + userId: context.userId ?? null, + requestId: context.requestId ?? null, + projectId, + }) + + return results + } + + /** + * Reflects the listeners' outcome on the project row (legacy hooks behavior): + * any KO result marks the project `failed`; a fully successful upsert marks it + * `created` and records the provisioning version. A successful `project.delete` + * leaves the `archived` status set when the project was archived. + */ + private async updateProjectStatus( + event: ProjectEventName, + projectId: string, + results: PluginResults, + ): Promise { + const failed = getFailedPlugins(results) + + if (failed.length) { + this.logger.warn(`${event} marked project as failed (projectId=${projectId}, failed=${failed.join(',')})`) + await this.prisma.project.update({ where: { id: projectId }, data: { status: 'failed' } }) + return + } + + if (event === 'project.upsert') { + await this.prisma.project.update({ + where: { id: projectId }, + data: { status: 'created', lastSuccessProvisionningVersion: this.baseConfig.appVersion }, + }) + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts new file mode 100644 index 0000000000..3c1ba22a7a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.utils.spec.ts @@ -0,0 +1,96 @@ +import type { PluginResults } from '../plugin/plugin.utils' +import { NetworkError } from '@keycloak/keycloak-admin-client' +import { describe, expect, it } from 'vitest' +import { formatEventLogData, isPluginResults, serializeError } from './app-events.utils' + +describe('isPluginResults', () => { + it('should accept an object produced by a capturePluginResult handler', () => { + expect(isPluginResults({ gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 } })).toBe(true) + expect(isPluginResults({ argocd: { status: 'KO', message: 'boom', executionTime: 10, error: new Error('boom') } })).toBe(true) + }) + + it('should reject non-result responses', () => { + expect(isPluginResults(undefined)).toBe(false) + expect(isPluginResults(null)).toBe(false) + expect(isPluginResults('ok')).toBe(false) + expect(isPluginResults({})).toBe(false) + expect(isPluginResults({ gitlab: { foo: 'bar' } })).toBe(false) + expect(isPluginResults({ gitlab: { status: 'MAYBE' } })).toBe(false) + }) +}) + +describe('serializeError', () => { + it('should serialize an Error with name, message and stack', () => { + const parsed = JSON.parse(serializeError(new Error('boom'))) + expect(parsed).toEqual({ name: 'Error', message: 'boom', stack: expect.any(String) }) + }) + + it('should include HTTP details from errors carrying a fetch Response', () => { + const error = new NetworkError('Network response was not OK.', { + response: new Response('conflict', { status: 409 }), + responseData: { errorMessage: 'Sibling group named \'admin\' already exists.' }, + }) + const parsed = JSON.parse(serializeError(error)) + expect(parsed).toEqual({ + name: 'Error', + message: 'Network response was not OK.', + status: 409, + url: expect.any(String), + responseData: { errorMessage: 'Sibling group named \'admin\' already exists.' }, + stack: expect.any(String), + }) + }) + + it('should serialize non-Error values', () => { + expect(serializeError({ code: 42 })).toBe('{"code":42}') + expect(serializeError('boom')).toBe('"boom"') + }) +}) + +describe('formatEventLogData', () => { + const args = { id: 'project-1', slug: 'project-1' } + + it('should format results in the LogSchema shape', () => { + const results: PluginResults = { + gitlab: { status: 'OK', message: 'Up to date', executionTime: 12.4 }, + argocd: { status: 'KO', message: 'Sync failed', executionTime: 100.9, error: new Error('Sync failed') }, + } + + expect(formatEventLogData(args, results, 120.6)).toEqual({ + args, + failed: ['argocd'], + results: { + gitlab: { + status: { result: 'OK', message: 'Up to date' }, + executionTime: { main: 12 }, + }, + argocd: { + status: { result: 'KO', message: 'Sync failed' }, + executionTime: { main: 101 }, + error: expect.stringContaining('"message":"Sync failed"'), + }, + }, + totalExecutionTime: 121, + messageResume: 'Errors:\nargocd: Sync failed;', + }) + }) + + it('should omit the failed list and produce a success resume when everything is OK', () => { + const results: PluginResults = { + gitlab: { status: 'OK', message: 'Up to date', executionTime: 10 }, + } + + const data = formatEventLogData(args, results, 10) + expect(data).not.toHaveProperty('failed') + expect(data.messageResume).toBe('Success') + }) + + it('should handle events without any listener', () => { + expect(formatEventLogData(args, {}, 1)).toEqual({ + args, + results: {}, + totalExecutionTime: 1, + messageResume: 'Success', + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.utils.ts new file mode 100644 index 0000000000..a9cf62706b --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/events/app-events.utils.ts @@ -0,0 +1,77 @@ +import type { LogData } from '../log/log.service' +import type { PluginName, PluginResult, PluginResults } from '../plugin/plugin.utils' +import { getErrorHttpDetails } from '../../utils/http.utils' +import { getFailedPlugins } from '../plugin/plugin.utils' + +/** Per-service result as persisted in the admin logs (legacy hooks format, parsed by LogSchema). */ +export interface LoggablePluginResult { + status: { + result: 'OK' | 'KO' + message?: string + } + executionTime: { + main: number + } + error?: string +} + +function isPluginResult(value: unknown): value is PluginResult { + return typeof value === 'object' + && value !== null + && 'status' in value + && ((value as PluginResult).status === 'OK' || (value as PluginResult).status === 'KO') +} + +/** Narrows an `emitAsync` response to a `PluginResults` object produced by `capturePluginResult` handlers. */ +export function isPluginResults(response: unknown): response is PluginResults { + if (typeof response !== 'object' || response === null) return false + const values = Object.values(response) + return values.length > 0 && values.every(isPluginResult) +} + +export function serializeError(error: unknown): string { + if (error instanceof Error) { + return JSON.stringify({ + name: error.name, + message: error.message, + ...getErrorHttpDetails(error), + stack: error.stack, + }) + } + try { + return JSON.stringify(error) + } catch { + return String(error) + } +} + +function toLoggableResult(result: PluginResult): LoggablePluginResult { + return { + status: { + result: result.status, + ...(result.message ? { message: result.message } : {}), + }, + executionTime: { main: Math.round(result.executionTime) }, + ...(result.status === 'KO' ? { error: serializeError(result.error) } : {}), + } +} + +function buildMessageResume(results: PluginResults, failed: PluginName[]): string { + if (!failed.length) return 'Success' + const errorLines = failed.map(service => `${service}: ${results[service]?.message};`).join('\n') + return `Errors:\n${errorLines}` +} + +/** Builds the log `data` payload for an event: the emitted args plus every listener's result. */ +export function formatEventLogData(args: unknown, results: PluginResults, totalExecutionTime: number): LogData { + const failed = getFailedPlugins(results) + const entries = Object.entries(results) as [PluginName, PluginResult][] + + return { + args, + ...(failed.length ? { failed } : {}), + results: Object.fromEntries(entries.map(([service, result]) => [service, toLoggableResult(result)])), + totalExecutionTime: Math.round(totalExecutionTime), + messageResume: buildMessageResume(results, failed), + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts new file mode 100644 index 0000000000..0db65f4630 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts @@ -0,0 +1,689 @@ +import type { ExpandedGroupSchema, Gitlab as GitlabApi, ProjectSchema } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { MockedFunction } from 'vitest' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' +import { + makeAccessTokenExposedSchema, + makeAccessTokenSchema, + makeExpandedGroupSchema, + makeExpandedUserSchema, + makeGitbeakerRequestError, + makeGroupSchema, + makeMemberSchema, + makeOffsetPagination, + makePipelineTriggerToken, + makeProjectSchema, + makeRepositoryFileExpandedSchema, + makeRepositoryTreeSchema, +} from './gitlab-testing.utils' +import { + GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, + MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, + PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, + USER_ID_CUSTOM_ATTRIBUTE_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +describe('gitlab-client', () => { + let service: GitlabClientService + let gitlabApi: DeepMockProxy + + beforeEach(async () => { + gitlabApi = mockDeep() + const config = mockDeep({ + url: 'https://gitlab.internal', + token: 'token', + internalUrl: 'https://gitlab.internal', + projectRootDir: 'forge', + mirrorTokenExpirationDays: 30, + internalOrPublicUrl: 'https://gitlab.internal', + }) + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GitlabClientService, + { provide: GITLAB_REST_CLIENT, useValue: gitlabApi }, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + service = module.get(GitlabClientService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('getOrCreateInfraProject', () => { + it('should create infra project if not exists', async () => { + const zoneSlug = 'zone-1' + const rootId = 123 + const infraGroupId = 456 + const projectId = 789 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + + gitlabApi.Groups.show.mockResolvedValueOnce({ id: rootId, full_path: 'forge' } as ExpandedGroupSchema) + + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + gitlabApi.Groups.create.mockResolvedValue({ id: infraGroupId, full_path: 'forge/infra' } as ExpandedGroupSchema) + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.create.mockResolvedValue({ + id: projectId, + path_with_namespace: 'forge/infra/zone-1', + http_url_to_repo: 'https://gitlab.internal/infra/zone-1.git', + } as ProjectSchema) + + const result = await service.getOrCreateInfraGroupRepo(zoneSlug) + + expect(result).toEqual({ + id: projectId, + http_url_to_repo: 'https://gitlab.internal/infra/zone-1.git', + path_with_namespace: 'forge/infra/zone-1', + }) + expect(gitlabApi.Groups.create).toHaveBeenCalledWith('infra', 'infra', expect.any(Object)) + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(rootId, GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(infraGroupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(infraGroupId, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ + name: zoneSlug, + path: zoneSlug, + namespaceId: infraGroupId, + })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + }) + + describe('commitCreateOrUpdate', () => { + it('should create commit if file not exists', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'content' + const filePath = 'file.txt' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + const notFoundError = makeGitbeakerRequestError({ description: '404 File Not Found' }) + gitlabRepositoryFilesShowMock.mockRejectedValue(notFoundError) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + message, + [{ action: 'create', filePath, content }], + ) + }) + + it('should update commit if content differs', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'new content' + const filePath = 'file.txt' + const oldHash = 'oldhash' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + gitlabRepositoryFilesShowMock.mockResolvedValue(makeRepositoryFileExpandedSchema({ content_sha256: oldHash })) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + message, + [{ action: 'update', filePath, content }], + ) + }) + + it('should do nothing if content matches', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const content = 'content' + const filePath = 'file.txt' + const hash = 'ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73' + const message = 'ci: :robot_face: Update file content' + + const gitlabRepositoryFilesShowMock = gitlabApi.RepositoryFiles.show as MockedFunction + gitlabRepositoryFilesShowMock.mockResolvedValue(makeRepositoryFileExpandedSchema({ content_sha256: hash })) + + const action = await service.generateCreateOrUpdateAction(repo, 'main', filePath, content) + await service.maybeCreateCommit(repo, message, action ? [action] : []) + + expect(gitlabApi.Commits.create).not.toHaveBeenCalled() + }) + }) + + describe('getOrCreateProjectGroup', () => { + it('should create project group if not exists', async () => { + const projectSlug = 'project-1' + const rootId = 123 + const groupId = 456 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce(makeExpandedGroupSchema({ id: rootId, full_path: 'forge' })) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.create.mockResolvedValue(makeExpandedGroupSchema({ id: groupId, name: projectSlug, path: projectSlug, full_path: `forge/${projectSlug}` })) + + const result = await service.getOrCreateProjectSubGroup(projectSlug) + + expect(result).toEqual(expect.objectContaining({ id: groupId, name: projectSlug })) + expect(gitlabApi.Groups.create).toHaveBeenCalledWith(projectSlug, projectSlug, expect.objectContaining({ + parentId: rootId, + })) + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(groupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.GroupCustomAttributes.set).toHaveBeenCalledWith(groupId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + }) + + it('should return existing group', async () => { + const projectSlug = 'project-1' + const rootId = 123 + const groupId = 456 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce(makeExpandedGroupSchema({ id: rootId, full_path: 'forge' })) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: rootId, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectSubGroup(projectSlug) + + expect(result).toEqual({ id: groupId, name: projectSlug, parent_id: rootId, full_path: 'forge/project-1' }) + expect(gitlabApi.Groups.create).not.toHaveBeenCalled() + }) + }) + + describe('repositories', () => { + it('should return internal repo url', async () => { + const projectSlug = 'project-1' + const repoName = 'repo-1' + const rootId = 123 + const groupId = 1 + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectGroupInternalRepoUrl(projectSlug, repoName) + expect(result).toBe('https://gitlab.internal/forge/project-1/repo-1.git') + }) + + it('should upsert mirror repo', async () => { + const projectSlug = 'project-1' + const repoId = 1 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValue({ + data: [{ id: repoId, path_with_namespace: 'forge/project-1/mirror' }], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: 'mirror' } as ProjectSchema) + + const result = await service.upsertProjectMirrorRepo(projectSlug) + + expect(result).toEqual({ id: repoId, name: 'mirror' }) + expect(gitlabApi.Projects.edit).toHaveBeenCalledWith(repoId, expect.objectContaining({ + name: 'mirror', + path: 'mirror', + })) + }) + + it('should set managed custom attribute when upserting a project repo', async () => { + const projectSlug = 'project-1' + const repoName = 'repo-1' + const repoId = 101 + + gitlabApi.Projects.show.mockResolvedValue(makeProjectSchema({ id: repoId })) + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: repoName } as ProjectSchema) + + const result = await service.upsertProjectGroupRepo(projectSlug, repoName, 'desc') + + expect(result).toEqual({ id: repoId, name: repoName }) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(repoId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + + describe('upsertUser', () => { + it('should create user and set custom attribute if not exists', async () => { + const consoleUser = { id: 'u1', email: 'new@example.com', firstName: 'New', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'new', + name: 'New User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([]) + gitlabApi.Users.create.mockResolvedValue(makeExpandedUserSchema({ id: 999, email: consoleUser.email })) + + const result = await service.upsertUser(gitlabUser, { cpnUserId: consoleUser.id }) + + expect(result).toEqual(expect.objectContaining({ id: 999, email: consoleUser.email })) + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'new@example.com', + username: 'new', + name: 'New User', + externUid: 'new@example.com', + provider: 'openid_connect', + skipConfirmation: true, + })) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(999, USER_ID_CUSTOM_ATTRIBUTE_KEY, consoleUser.id) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(999, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + }) + + it('should set custom attribute if user exists', async () => { + const consoleUser = { id: 'u1', email: 'existing@example.com', firstName: 'Existing', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'existing', + name: 'Existing User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([makeExpandedUserSchema({ id: 1000, email: consoleUser.email })]) + + const result = await service.upsertUser(gitlabUser, { cpnUserId: consoleUser.id }) + + expect(result).toEqual(expect.objectContaining({ id: 1000, email: consoleUser.email })) + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.objectContaining({ + email: 'existing@example.com', + username: 'existing', + name: 'Existing User', + externUid: 'existing@example.com', + provider: 'openid_connect', + })) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(1000, USER_ID_CUSTOM_ATTRIBUTE_KEY, consoleUser.id) + expect(gitlabApi.UserCustomAttributes.set).toHaveBeenCalledWith(1000, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.Users.create).not.toHaveBeenCalled() + }) + + it('should set admin flag when provided', async () => { + const consoleUser = { id: 'u1', email: 'admin@example.com', firstName: 'Admin', lastName: 'User' } + const gitlabUser = { + email: consoleUser.email, + username: 'admin', + name: 'Admin User', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([]) + gitlabApi.Users.create.mockResolvedValue(makeExpandedUserSchema({ id: 999, email: consoleUser.email })) + + await service.upsertUser({ ...gitlabUser, admin: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'admin@example.com', + username: 'admin', + name: 'Admin User', + externUid: 'admin@example.com', + provider: 'openid_connect', + admin: true, + skipConfirmation: true, + })) + }) + + it('should not disable existing admin flag when enabling auditor flag', async () => { + const consoleUser = { id: 'u1', email: 'admin-auditor@example.com', firstName: 'Admin', lastName: 'Auditor' } + const gitlabUser = { + email: consoleUser.email, + username: 'admin-auditor', + name: 'Admin Auditor', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([ + makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_admin: true }), + ]) + + await service.upsertUser({ ...gitlabUser, auditor: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.not.objectContaining({ + admin: true, + })) + }) + + it('should not disable existing auditor flag when enabling admin flag', async () => { + const consoleUser = { id: 'u1', email: 'auditor-admin@example.com', firstName: 'Auditor', lastName: 'Admin' } + const gitlabUser = { + email: consoleUser.email, + username: 'auditor-admin', + name: 'Auditor Admin', + } + const gitlabUsersAllMock = gitlabApi.Users.all as MockedFunction + gitlabUsersAllMock.mockResolvedValue([ + makeExpandedUserSchema({ id: 1000, email: consoleUser.email, is_auditor: true }), + ]) + + await service.upsertUser({ ...gitlabUser, admin: true }, { cpnUserId: consoleUser.id }) + + expect(gitlabApi.Users.edit).toHaveBeenCalledWith(1000, expect.not.objectContaining({ + auditor: true, + })) + }) + }) + + it('should create pipeline trigger token if not exists', async () => { + const projectSlug = 'project-1' + const repoId = 1 + const tokenDescription = 'mirroring-from-external-repo' + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValue({ + data: [{ id: repoId, path_with_namespace: 'forge/project-1/mirror' }], + paginationInfo: { next: null }, + }) + gitlabApi.Projects.edit.mockResolvedValue({ id: repoId, name: 'mirror' } as ProjectSchema) + + const gitlabPipelineTriggerTokensAllMock = gitlabApi.PipelineTriggerTokens.all as MockedFunction + gitlabPipelineTriggerTokensAllMock.mockResolvedValue({ + data: [], + paginationInfo: makeOffsetPagination({ next: null }), + }) + + gitlabApi.PipelineTriggerTokens.create.mockResolvedValue(makePipelineTriggerToken({ id: 2, description: tokenDescription })) + + const result = await service.getOrCreateMirrorPipelineTriggerToken(projectSlug) + + expect(result).toEqual(expect.objectContaining({ id: 2, description: tokenDescription })) + expect(gitlabApi.PipelineTriggerTokens.create).toHaveBeenCalledWith(repoId, tokenDescription) + }) + }) + + describe('group Members', () => { + it('should get group members', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const members = [makeMemberSchema({ id: 1, name: 'user' })] + const gitlabGroupMembersAllMock = gitlabApi.GroupMembers.all as MockedFunction + gitlabGroupMembersAllMock.mockResolvedValue(members) + + const result = await service.getGroupMembers(group) + expect(result).toEqual(members) + expect(gitlabApi.GroupMembers.all).toHaveBeenCalledWith(groupId) + }) + + it('should add group member', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const userId = 2 + const accessLevel = 30 + gitlabApi.GroupMembers.add.mockResolvedValue(makeMemberSchema({ id: userId })) + + await service.addGroupMember(group, userId, accessLevel) + expect(gitlabApi.GroupMembers.add).toHaveBeenCalledWith(groupId, userId, accessLevel) + }) + + it('should remove group member', async () => { + const groupId = 1 + const group = makeGroupSchema({ id: groupId }) + const userId = 2 + gitlabApi.GroupMembers.remove.mockResolvedValue(undefined) + + await service.removeGroupMember(group, userId) + expect(gitlabApi.GroupMembers.remove).toHaveBeenCalledWith(groupId, userId) + }) + }) + + describe('createProjectMirrorAccessToken', () => { + it('should create project access token with correct scopes', async () => { + const projectSlug = 'project-1' + const groupId = 456 + const tokenName = `${projectSlug}-bot` + const token = makeAccessTokenExposedSchema({ id: 1, name: tokenName, token: 'secret-token' }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: 123, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce({ id: 123, full_path: 'forge' } as ExpandedGroupSchema) + + const gitlabGroupsAllSubgroupsMock = gitlabApi.Groups.allSubgroups as MockedFunction + gitlabGroupsAllSubgroupsMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: 123, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + gitlabApi.GroupAccessTokens.create.mockResolvedValue(token) + + const result = await service.createMirrorAccessToken(projectSlug) + + expect(result).toEqual(token) + expect(gitlabApi.GroupAccessTokens.create).toHaveBeenCalledWith( + groupId, + tokenName, + ['write_repository', 'read_repository', 'read_api'], + expect.any(String), + ) + }) + }) + + describe('getOrCreateProjectGroupRepo', () => { + it('should return existing repo', async () => { + const subGroupPath = 'project-1' + const repoName = 'repo-1' + const fullPath = `${subGroupPath}/${repoName}` + const projectId = 789 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [{ id: projectId, path_with_namespace: `forge/${fullPath}` }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectGroupRepo(subGroupPath, fullPath) + + expect(result).toEqual(expect.objectContaining({ id: projectId })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, 'project-1') + }) + + it('should create repo if not exists', async () => { + const subGroupPath = 'project-1' + const repoName = 'repo-1' + const fullPath = `${subGroupPath}/${repoName}` + const projectId = 789 + const groupId = 456 + const rootId = 123 + + const gitlabProjectsAllMock = gitlabApi.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: subGroupPath, parent_id: rootId, full_path: `forge/${subGroupPath}` }], + paginationInfo: { next: null }, + }) + + gitlabApi.Projects.create.mockResolvedValue({ id: projectId, name: repoName } as ProjectSchema) + + const result = await service.getOrCreateProjectGroupRepo(subGroupPath, fullPath) + + expect(result).toEqual(expect.objectContaining({ id: projectId })) + expect(gitlabApi.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ + name: repoName, + path: repoName, + namespaceId: groupId, + })) + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + expect(gitlabApi.ProjectCustomAttributes.set).toHaveBeenCalledWith(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, 'project-1') + }) + }) + + describe('getFile', () => { + it('should return file content', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const file = makeRepositoryFileExpandedSchema({ content: 'content' }) + + gitlabApi.RepositoryFiles.show.mockResolvedValue(file) + + const result = await service.getFile(repo, filePath, ref) + expect(result).toEqual(file) + }) + + it('should return undefined on 404', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const error = makeGitbeakerRequestError({ description: '404 File Not Found' }) + + gitlabApi.RepositoryFiles.show.mockRejectedValue(error) + + const result = await service.getFile(repo, filePath, ref) + expect(result).toBeUndefined() + }) + + it('should throw on other errors', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const filePath = 'file.txt' + const ref = 'main' + const error = new Error('Some other error') + + gitlabApi.RepositoryFiles.show.mockRejectedValue(error) + + await expect(service.getFile(repo, filePath, ref)).rejects.toThrow(error) + }) + }) + + describe('listFiles', () => { + it('should return files', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const files = [makeRepositoryTreeSchema({ path: 'file.txt' })] + + gitlabApi.Repositories.allRepositoryTrees.mockResolvedValue(files) + + const result = await service.listFiles(repo) + expect(result).toEqual(files) + }) + + it('should return empty array on 404', async () => { + const repoId = 1 + const repo = makeProjectSchema({ id: repoId }) + const error = makeGitbeakerRequestError({ description: '404 Tree Not Found' }) + + gitlabApi.Repositories.allRepositoryTrees.mockRejectedValue(error) + + const result = await service.listFiles(repo) + expect(result).toEqual([]) + }) + }) + + describe('getProjectToken', () => { + it('should return specific token', async () => { + const projectSlug = 'project-1' + const groupId = 456 + const tokenName = `${projectSlug}-bot` + const token = makeAccessTokenSchema({ id: 1, name: tokenName }) + + const gitlabGroupsAllMock = gitlabApi.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: 123, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabApi.Groups.show.mockResolvedValueOnce({ id: 123, full_path: 'forge' } as ExpandedGroupSchema) + const gitlabGroupsAllSubgroupsMock = gitlabApi.Groups.allSubgroups as MockedFunction + gitlabGroupsAllSubgroupsMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: 123, full_path: `forge/${projectSlug}` }], + paginationInfo: { next: null }, + }) + + const gitlabGroupAccessTokensAllMock = gitlabApi.GroupAccessTokens.all as MockedFunction + gitlabGroupAccessTokensAllMock.mockResolvedValue({ + data: [token], + paginationInfo: makeOffsetPagination({ next: null }), + }) + + const result = await service.getProjectToken(projectSlug) + expect(result).toEqual(token) + }) + }) + + describe('createUser', () => { + it('should create user', async () => { + const email = 'user@example.com' + const username = 'user' + const name = 'User Name' + const user = makeExpandedUserSchema({ id: 1, username }) + + gitlabApi.Users.create.mockResolvedValue(user) + + const result = await service.createUser({ email, username, name }) + + expect(result).toEqual(user) + expect(gitlabApi.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email, + username, + name, + skipConfirmation: true, + })) + }) + }) + + describe('commitMirror', () => { + it('should create mirror commit', async () => { + const repoId = 1 + + await service.commitMirror(repoId) + + expect(gitlabApi.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + expect.any(String), + expect.arrayContaining([ + expect.objectContaining({ filePath: '.gitlab-ci.yml', action: 'create' }), + expect.objectContaining({ filePath: 'mirror.sh', action: 'create' }), + ]), + ) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts new file mode 100644 index 0000000000..d1a755fc9a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -0,0 +1,554 @@ +import type { + AccessLevel, + AccessTokenScopes, + BaseRequestOptions, + CommitAction, + CondensedGroupSchema, + CondensedProjectSchema, + EditUserOptions, + ExpandedUserSchema, + Gitlab, + GroupSchema, + OffsetPagination, + PaginationRequestOptions, + PipelineTriggerTokenSchema, + SimpleUserSchema, +} from '@gitbeaker/core' +import type { GitlabConfig } from './gitlab.module-definition' +import { join } from 'node:path' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { find } from '../../utils/iterable.utils' +import { + GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, + INFRA_GROUP_PATH, + MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, + MIRROR_REPO_NAME, + PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, + TOKEN_DESCRIPTION, + TOPIC_PLUGIN_MANAGED, + USER_ID_CUSTOM_ATTRIBUTE_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { generateGitlabCIConfigContent, generateMirrorScriptContent, hasFileContentChanged } from './gitlab.utils' + +export const GITLAB_REST_CLIENT = Symbol('GITLAB_REST_CLIENT') + +type With = T & Required> +export type CondensedGroupSchemaWith = With +export type CondensedProjectSchemaWith = With +export type EditUserOptionsWith = With +type UserSchema = SimpleUserSchema | ExpandedUserSchema + +export interface OffsetPaginateOptions { + startPage?: number + perPage?: number + maxPages?: number +} + +@Injectable() +export class GitlabClientService { + private readonly logger = new Logger(GitlabClientService.name) + + constructor( + @Inject(GITLAB_CONFIG) readonly config: GitlabConfig, + @Inject(GITLAB_REST_CLIENT) private readonly client: Gitlab, + ) { + } + + async upsertGroupCustomAttribute(groupId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab group custom attribute (groupId=${groupId}, key=${key})`) + try { + await this.client.GroupCustomAttributes.set(groupId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab group custom attribute (groupId=${groupId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + async upsertProjectCustomAttribute(projectId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab project custom attribute (projectId=${projectId}, key=${key})`) + try { + await this.client.ProjectCustomAttributes.set(projectId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab project custom attribute (projectId=${projectId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + async upsertUserCustomAttribute(userId: number, key: string, value: string): Promise { + this.logger.verbose(`Upserting a GitLab user custom attribute (userId=${userId}, key=${key})`) + try { + await this.client.UserCustomAttributes.set(userId, key, value) + } catch (error) { + this.logger.debug(`Failed to upsert a GitLab user custom attribute (userId=${userId}, key=${key}): ${error instanceof Error ? error.message : String(error)}`) + } + } + + private async setManagedUserAttributes(userId: number, cpnUserId: string) { + await this.upsertUserCustomAttribute(userId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + await this.upsertUserCustomAttribute(userId, USER_ID_CUSTOM_ATTRIBUTE_KEY, cpnUserId) + } + + private async setManagedInfraProjectAttributes(projectId: number) { + await this.upsertProjectCustomAttribute(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedProjectAttributes(projectId: number, projectSlug: string) { + await this.upsertProjectCustomAttribute(projectId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + await this.upsertProjectCustomAttribute(projectId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + } + + private async setManagedGroupAttributes(groupId: number) { + await this.upsertGroupCustomAttribute(groupId, MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedRootGroupAttributes(groupId: number) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedInfraGroupAttributes(groupId: number) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, 'true') + } + + private async setManagedProjectGroupAttributes(groupId: number, projectSlug: string) { + await this.setManagedGroupAttributes(groupId) + await this.upsertGroupCustomAttribute(groupId, PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, projectSlug) + } + + async getGroupByPath(path: string) { + this.logger.verbose(`Looking up a GitLab group by path ${path}`) + return find( + this.offsetPaginate(opts => this.client.Groups.all({ search: path, orderBy: 'path', ...opts })), + g => g.full_path === path, + ) + } + + async createGroup(path: string) { + this.logger.log(`Creating a GitLab group at path ${path}`) + const created = await this.client.Groups.create(path, path) + if (this.config.projectRootDir && created.full_path === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(created.id) + } + if (this.config.projectRootDir && created.full_path === `${this.config.projectRootDir}/${INFRA_GROUP_PATH}`) { + await this.setManagedInfraGroupAttributes(created.id) + } + return created + } + + async createSubGroup(parentGroup: CondensedGroupSchemaWith<'id' | 'full_path'>, name: string, fullPath: string) { + this.logger.log(`Creating a GitLab subgroup ${fullPath} (parentId=${parentGroup.id})`) + const created = await this.client.Groups.create(name, name, { parentId: parentGroup.id }) + if (this.config.projectRootDir && fullPath === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(created.id) + } else if (this.config.projectRootDir && fullPath === `${this.config.projectRootDir}/${INFRA_GROUP_PATH}`) { + await this.setManagedInfraGroupAttributes(created.id) + } else if (this.config.projectRootDir && fullPath.startsWith(`${this.config.projectRootDir}/`) && !fullPath.slice(this.config.projectRootDir.length + 1).includes('/')) { + const projectSlug = fullPath.slice(this.config.projectRootDir.length + 1) + if (projectSlug && projectSlug !== INFRA_GROUP_PATH) { + await this.setManagedProjectGroupAttributes(created.id, projectSlug) + } + } + return created + } + + async getOrCreateGroupByPath(path: string) { + const parts = path.split('/') + const rootGroupPath = parts.shift() + if (!rootGroupPath) throw new Error('Invalid projects root dir') + + this.logger.verbose(`Resolving GitLab group path ${path} (depth=${1 + parts.length})`) + let parentGroup = await this.getGroupByPath(rootGroupPath) ?? await this.createGroup(rootGroupPath) + if (this.config.projectRootDir && parentGroup.full_path === this.config.projectRootDir) { + await this.setManagedRootGroupAttributes(parentGroup.id) + } + + let currentFullPath: string + for (const part of parts) { + currentFullPath = `${parentGroup.full_path}/${part}` + parentGroup = await this.getGroupByPath(currentFullPath) ?? await this.createSubGroup(parentGroup, part, currentFullPath) + } + + this.logger.verbose(`GitLab group path resolved (path=${path}, groupId=${parentGroup.id})`) + return parentGroup + } + + async getOrCreateProjectGroup() { + if (!this.config.projectRootDir) throw new Error('projectRootDir not configured') + return this.getOrCreateGroupByPath(this.config.projectRootDir) + } + + async getOrCreateProjectSubGroup(subGroupPath: string) { + const fullPath = this.config.projectRootDir + ? `${this.config.projectRootDir}/${subGroupPath}` + : subGroupPath + return this.getOrCreateGroupByPath(fullPath) + } + + async getOrCreateProjectGroupPublicUrl(): Promise { + const projectGroup = await this.getOrCreateProjectGroup() + return new URL(projectGroup.full_path, this.config.url).toString() + } + + async getOrCreateInfraGroupRepoPublicUrl(repoName: string): Promise { + const projectGroup = await this.getOrCreateProjectGroup() + return new URL(`${projectGroup.full_path}/${INFRA_GROUP_PATH}/${repoName}.git`, this.config.url).toString() + } + + async getOrCreateProjectGroupInternalRepoUrl(subGroupPath: string, repoName: string): Promise { + const projectGroup = await this.getOrCreateProjectSubGroup(subGroupPath) + const urlBase = this.config.internalOrPublicUrl + if (!urlBase) throw new Error('GITLAB_URL is required') + return `${urlBase}/${projectGroup.full_path}/${repoName}.git` + } + + private async getOrCreateRepo(subGroupPath: string) { + const fullPath = this.config.projectRootDir + ? `${this.config.projectRootDir}/${subGroupPath}` + : subGroupPath + this.logger.verbose(`Resolving a GitLab project repository by path ${fullPath}`) + try { + const existingRepo = await this.client.Projects.show(fullPath) + if (existingRepo) { + this.logger.verbose(`Found a GitLab project repository (path=${fullPath}, repoId=${existingRepo.id})`) + return existingRepo + } + } catch (error) { + if (!(error instanceof GitbeakerRequestError) || !error.cause?.description?.includes('404')) { + throw error + } + } + const repo = await find( + this.offsetPaginate(opts => this.client.Projects.all({ + search: fullPath, + orderBy: 'path', + ...opts, + })), + p => p.path_with_namespace === fullPath, + ) + if (repo) { + this.logger.verbose(`Found a GitLab project repository via search (path=${fullPath}, repoId=${repo.id})`) + return repo + } + const parts = subGroupPath.split('/') + const repoName = parts.pop() + if (!repoName) throw new Error('Invalid repo path') + const parentGroup = await this.getOrCreateProjectSubGroup(parts.join('/')) + try { + const created = await this.client.Projects.create({ + name: repoName, + path: repoName, + namespaceId: parentGroup.id, + defaultBranch: 'main', + }) + this.logger.log(`Created a GitLab project repository (path=${fullPath}, repoId=${created.id})`) + return created + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('has already been taken')) { + this.logger.warn(`GitLab project repository already exists (race); reloading ${fullPath}`) + const reloaded = await this.client.Projects.show(fullPath) + return reloaded + } + throw error + } + } + + async getOrCreateProjectGroupRepo(projectSlug: string, subGroupPath: string) { + const repo = await this.getOrCreateRepo(subGroupPath) + await this.setManagedProjectAttributes(repo.id, projectSlug) + return repo + } + + async getOrCreateInfraGroupRepo(path: string) { + const fullPath = join(INFRA_GROUP_PATH, path) + const repo = await this.getOrCreateRepo(fullPath) + await this.setManagedInfraProjectAttributes(repo.id) + return repo + } + + async getFile(repo: CondensedProjectSchemaWith<'id'>, filePath: string, ref: string = 'main') { + try { + return await this.client.RepositoryFiles.show(repo.id, filePath, ref) + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('Not Found')) { + this.logger.debug(`GitLab file not found (repoId=${repo.id}, ref=${ref}, filePath=${filePath})`) + return + } + throw error + } + } + + async maybeCreateCommit( + repo: CondensedProjectSchemaWith<'id'>, + message: string, + actions: CommitAction[], + ref: string = 'main', + ): Promise { + if (actions.length === 0) { + this.logger.debug(`No GitLab commit actions to create (repoId=${repo.id}, ref=${ref})`) + return + } + this.logger.log(`Creating a GitLab commit (repoId=${repo.id}, ref=${ref}, actions=${actions.length})`) + await this.client.Commits.create(repo.id, ref, message, actions) + this.logger.verbose(`GitLab commit created (repoId=${repo.id}, ref=${ref}, actions=${actions.length})`) + } + + async generateCreateOrUpdateAction(repo: CondensedProjectSchemaWith<'id'>, ref: string, filePath: string, content: string): Promise { + const file = await this.getFile(repo, filePath, ref) + if (file && !hasFileContentChanged(file, content)) { + this.logger.debug(`GitLab file is up to date; skipping commit action (repoId=${repo.id}, ref=${ref}, filePath=${filePath})`) + return null + } + this.logger.verbose(`Prepared a GitLab commit action (repoId=${repo.id}, ref=${ref}, filePath=${filePath}, action=${file ? 'update' : 'create'})`) + return { + action: file ? 'update' : 'create', + filePath, + content, + } + } + + async listFiles(repo: CondensedProjectSchemaWith<'id'>, options: { path?: string, recursive?: boolean, ref?: string } = {}) { + try { + const path = options.path ?? '/' + const recursive = options.recursive ?? false + const ref = options.ref ?? 'main' + this.logger.verbose(`Listing GitLab repository tree (repoId=${repo.id}, ref=${ref}, path=${path}, recursive=${recursive})`) + const files = await this.client.Repositories.allRepositoryTrees(repo.id, { + path: options.path ?? '/', + recursive: options.recursive ?? false, + ref: options.ref ?? 'main', + }) + this.logger.verbose(`Listed GitLab repository tree (repoId=${repo.id}, ref=${ref}, path=${path}, count=${files.length})`) + return files + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('Not Found')) { + return [] + } + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('404 Tree Not Found')) { + return [] + } + throw error + } + } + + async getProjectGroup(projectSlug: string): Promise { + const parentGroup = await this.getOrCreateProjectGroup() + return find( + this.offsetPaginate(opts => this.client.Groups.allSubgroups(parentGroup.id, opts)), + g => g.name === projectSlug, + ) + } + + async deleteGroup(group: CondensedGroupSchemaWith<'id' | 'full_path'>): Promise { + this.logger.verbose(`Deleting GitLab group ${group.full_path} (groupId=${group.id})`) + await this.client.Groups.remove(group.id) + } + + async getGroupMembers(group: CondensedGroupSchemaWith<'id'>) { + this.logger.verbose(`Loading GitLab group members (groupId=${group.id})`) + return this.client.GroupMembers.all(group.id) + } + + async addGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number, accessLevel: Exclude) { + this.logger.verbose(`Adding a GitLab group member (groupId=${group.id}, userId=${userId}, accessLevel=${accessLevel})`) + return this.client.GroupMembers.add(group.id, userId, accessLevel) + } + + async editGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number, accessLevel: Exclude) { + this.logger.verbose(`Editing a GitLab group member (groupId=${group.id}, userId=${userId}, accessLevel=${accessLevel})`) + return this.client.GroupMembers.edit(group.id, userId, accessLevel) + } + + async removeGroupMember(group: CondensedGroupSchemaWith<'id'>, userId: number) { + this.logger.verbose(`Removing a GitLab group member (groupId=${group.id}, userId=${userId})`) + return this.client.GroupMembers.remove(group.id, userId) + } + + async getUserByEmail(email: string) { + const users = await this.client.Users.all({ search: email, orderBy: 'username' }) + if (users.length === 0) return null + return users[0] as UserSchema + } + + async createUser(user: EditUserOptions) { + this.logger.log(`Creating a GitLab user (email=${user.email}, username=${user.username})`) + return await this.client.Users.create({ + ...user, + skipConfirmation: true, + }) as UserSchema + } + + async upsertUser( + user: Omit, 'externUid' | 'provider'>, + options: { cpnUserId: string }, + ) { + const existing = await this.getUserByEmail(user.email) + + const editOptions: EditUserOptions = { + ...user, + externUid: user.email, + provider: 'openid_connect', + } + const gitlabUser = existing ?? await this.createUser(editOptions) + + if (existing) { + const hasDiff = Object.entries(editOptions).some(([key, value]) => { + if (value === undefined) return false + return (existing as Record)[key] !== value + }) + if (hasDiff) { + await this.client.Users.edit(gitlabUser.id, editOptions) + } + } + await this.setManagedUserAttributes(gitlabUser.id, options.cpnUserId) + return gitlabUser + } + + async* getRepos(projectSlug: string) { + const group = await this.getOrCreateProjectSubGroup(projectSlug) + const repos = this.offsetPaginate(opts => this.client.Groups.allProjects(group.id, { simple: false, ...opts })) + for await (const repo of repos) { + yield repo + } + } + + async upsertProjectGroupRepo(projectSlug: string, repoName: string, description?: string) { + const fullPath = `${projectSlug}/${repoName}` + const repo = await this.getOrCreateProjectGroupRepo(projectSlug, fullPath) + const updated = await this.client.Projects.edit(repo.id, { + name: repoName, + path: repoName, + topics: [TOPIC_PLUGIN_MANAGED], + description, + }) + return updated + } + + async deleteProjectGroupRepo(projectSlug: string, repoName: string) { + const fullPath = `${projectSlug}/${repoName}` + const repo = await this.getOrCreateProjectGroupRepo(projectSlug, fullPath) + return this.client.Projects.remove(repo.id) + } + + async commitMirror(repoId: number) { + this.logger.log(`Creating a GitLab mirror bootstrap commit (repoId=${repoId})`) + const actions: CommitAction[] = [ + { + action: 'create', + filePath: '.gitlab-ci.yml', + content: generateGitlabCIConfigContent(), + execute_filemode: false, + }, + { + action: 'create', + filePath: 'mirror.sh', + content: generateMirrorScriptContent(), + execute_filemode: true, + }, + ] + + await this.client.Commits.create( + repoId, + 'main', + 'ci: :construction_worker: first mirror', + actions, + ) + this.logger.verbose(`GitLab mirror bootstrap commit created (repoId=${repoId}, actions=${actions.length})`) + } + + async upsertProjectMirrorRepo(projectSlug: string) { + return this.upsertProjectGroupRepo(projectSlug, MIRROR_REPO_NAME) + } + + async getProjectToken(projectSlug: string) { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + return find( + this.offsetPaginate<{ name: string }>( + opts => this.client.GroupAccessTokens.all(group.id, opts) as unknown as Promise<{ data: { name: string }[], paginationInfo: OffsetPagination }>, + ), + token => token.name === `${projectSlug}-bot`, + ) + } + + async createProjectToken(projectSlug: string, tokenName: string, scopes: AccessTokenScopes[]) { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + const expirationDays = Number(this.config.mirrorTokenExpirationDays) + const effectiveExpirationDays = Number.isFinite(expirationDays) && expirationDays > 0 ? expirationDays : 30 + const expiryDate = new Date(Date.now() + effectiveExpirationDays * 24 * 60 * 60 * 1000) + this.logger.log(`Creating a GitLab group access token (projectSlug=${projectSlug}, tokenName=${tokenName}, expiry=${expiryDate.toISOString().slice(0, 10)})`) + return this.client.GroupAccessTokens.create(group.id, tokenName, scopes, expiryDate.toISOString().slice(0, 10)) + } + + async createMirrorAccessToken(projectSlug: string) { + const tokenName = `${projectSlug}-bot` + return this.createProjectToken(projectSlug, tokenName, ['write_repository', 'read_repository', 'read_api']) + } + + async getOrCreateMirrorPipelineTriggerToken(projectSlug: string): Promise { + const mirrorRepo = await this.upsertProjectMirrorRepo(projectSlug) + this.logger.verbose(`Resolving a GitLab pipeline trigger token (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + const currentTriggerToken = await find( + this.offsetPaginate(opts => this.client.PipelineTriggerTokens.all(mirrorRepo.id, opts)), + token => token.description === TOKEN_DESCRIPTION, + ) + if (currentTriggerToken) { + this.logger.verbose(`GitLab pipeline trigger token found (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + return currentTriggerToken + } + const created = await this.client.PipelineTriggerTokens.create(mirrorRepo.id, TOKEN_DESCRIPTION) + this.logger.log(`GitLab pipeline trigger token created (projectSlug=${projectSlug}, repoId=${mirrorRepo.id})`) + return created + } + + private async* offsetPaginate( + request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, + options?: OffsetPaginateOptions, + ): AsyncGenerator { + let page: number | null = options?.startPage ?? 1 + let pagesFetched = 0 + let total = 0 + + this.logger.debug(`Pagination start (page=${page})`) + + while (page !== null) { + if (options?.maxPages && pagesFetched >= options.maxPages) { + page = null + continue + } + + try { + const { data, paginationInfo } = await request({ + page, + perPage: options?.perPage, + maxPages: options?.maxPages, + showExpanded: true, + pagination: 'offset', + }) + + pagesFetched += 1 + total += data.length + + const nextPage = paginationInfo.next ?? null + this.logger.debug(`Pagination page fetched (page=${page}, nextPage=${nextPage ?? 'null'}, items=${data.length}, total=${total})`) + + for (const item of data) { + yield item + } + + page = nextPage + } catch (error) { + if (error instanceof Error) { + this.logger.error(`Pagination request failed (page=${page}): ${error.message}`, error.stack) + } else { + this.logger.error(`Pagination request failed (page=${page}): ${String(error)}`) + } + throw error + } + } + + this.logger.debug(`Pagination done (total=${total})`) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts new file mode 100644 index 0000000000..5f72414b87 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts @@ -0,0 +1,133 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + owner: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + roles: { + select: { + id: true, + oidcGroup: true, + }, + }, + members: { + select: { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + }, + }, + roleIds: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + isPrivate: true, + externalRepoUrl: true, + externalUserName: true, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class GitlabDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: 'gitlab', + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } + + async getAdminRolesByOidcGroups(oidcGroups: string[]): Promise<{ id: string, oidcGroup: string }[]> { + return this.prisma.adminRole.findMany({ + where: { + oidcGroup: { + in: oidcGroups, + }, + }, + select: { + id: true, + oidcGroup: true, + }, + }) + } + + async getUser(id: string) { + return this.prisma.user.findUnique({ + where: { + id, + }, + }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts new file mode 100644 index 0000000000..0c4bdf8c41 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts @@ -0,0 +1,23 @@ +import type { GitlabConfig } from './gitlab.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +@Injectable() +export class GitlabHealthService { + constructor( + @Inject(GITLAB_CONFIG) private readonly gitlabConfig: GitlabConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.gitlabConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts new file mode 100644 index 0000000000..7f4d85f694 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { GitlabPluginService } from './gitlab-plugin.service' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +describe('gitlabPluginService', () => { + let service: GitlabPluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://gitlab.public', + projectRootDir: 'forge', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + GitlabPluginService, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(GitlabPluginService) + }) + + it('should expose the legacy project url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams({ project: { id: '', name: '', slug: 'dulei' } })) + + expect(url).toBe('https://gitlab.public/forge/dulei') + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts new file mode 100644 index 0000000000..3ce12a442c --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts @@ -0,0 +1,113 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { GitlabConfig } from './gitlab.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { DEFAULT_ADMIN_GROUP_PATH, DEFAULT_AUDITOR_GROUP_PATH, DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, PURGE_PLUGIN_KEY } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' + +@Injectable() +export class GitlabPluginService { + constructor( + @Inject(GITLAB_CONFIG) + private readonly gitlabConfig: GitlabConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'gitlab', + to: ({ project }) => new URL(`${this.gitlabConfig.projectRootDir}/${project.slug}`, this.gitlabConfig.url).toString(), + title: 'Gitlab', + imgSrc: '/img/gitlab.svg', + description: 'GitLab est un service d\'hébergement de code source et de pipeline CI/CD', + config: { + global: [ + { + kind: 'switch', + key: PURGE_PLUGIN_KEY, + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Purger les utilisateurs non synchronisés', + value: DISABLED, + description: 'Purger les utilisateurs non synchronisés de GitLab lors de la synchronisation', + }, + { + kind: 'switch', + key: 'displayTriggerHint', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Afficher l\'aide de déclenchement de pipeline', + value: ENABLED, + description: 'Afficher l\'aide de déclenchement de pipeline aux utilisateurs lorsqu\'ils souhaitent afficher les secrets du projet', + }, + { + kind: 'text', + key: 'adminGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Admin', + value: DEFAULT_ADMIN_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'administrateur GitLab', + placeholder: DEFAULT_ADMIN_GROUP_PATH, + }, + { + kind: 'text', + key: 'auditorGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Auditeur', + value: DEFAULT_AUDITOR_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'auditeur GitLab', + placeholder: DEFAULT_AUDITOR_GROUP_PATH, + }, + { + kind: 'text', + key: 'projectMaintainerGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Maintainer', + value: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Maintainer', + placeholder: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectDeveloperGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Developer', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Developer', + placeholder: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectReporterGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Reporter', + value: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Reporter', + placeholder: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + }, + ], + project: [], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts new file mode 100644 index 0000000000..678e3e1e36 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab-testing.utils.ts @@ -0,0 +1,432 @@ +import type { + AccessTokenExposedSchema, + AccessTokenSchema, + CommitAction, + ExpandedGroupSchema, + ExpandedUserSchema, + GroupSchema, + MemberSchema, + OffsetPagination, + PipelineTriggerTokenSchema, + ProjectSchema, + RepositoryFileExpandedSchema, + RepositoryTreeSchema, +} from '@gitbeaker/core' +import type { AdminRole, Project, User } from '@prisma/client' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { faker } from '@faker-js/faker' +import { AccessLevel } from '@gitbeaker/core' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { ProjectStatus } from '@prisma/client' +import { PLUGIN_NAME } from './gitlab.constants' + +export function makeExpandedUserSchema(overrides: Partial = {}): ExpandedUserSchema { + const isoDate = faker.date.past().toISOString() + return { + id: 1, + name: 'User', + username: 'user', + state: 'active', + avatar_url: '', + web_url: 'https://gitlab.example/users/user', + created_at: isoDate, + locked: null, + bio: null, + bot: false, + location: null, + public_email: null, + skype: null, + linkedin: null, + twitter: null, + discord: null, + website_url: null, + pronouns: null, + organization: null, + job_title: null, + work_information: null, + followers: null, + following: null, + local_time: null, + is_followed: null, + is_admin: null, + last_sign_in_at: isoDate, + confirmed_at: isoDate, + last_activity_on: isoDate, + email: 'user@example.com', + theme_id: 1, + color_scheme_id: 1, + projects_limit: 0, + current_sign_in_at: null, + note: null, + identities: null, + can_create_group: false, + can_create_project: false, + two_factor_enabled: false, + external: false, + private_profile: null, + namespace_id: null, + created_by: null, + ...overrides, + } satisfies ExpandedUserSchema +} + +export function makeMemberSchema(overrides: Partial = {}) { + return { + id: 1, + username: 'user', + name: 'User', + state: 'active', + avatar_url: '', + web_url: 'https://gitlab.example/users/user', + expires_at: faker.date.future().toISOString(), + access_level: 30, + email: 'user@example.com', + group_saml_identity: { + extern_uid: '', + provider: '', + saml_provider_id: 1, + }, + ...overrides, + } satisfies MemberSchema +} + +export function makeGroupSchema(overrides: Partial = {}) { + return { + id: 123, + web_url: 'https://gitlab.example/groups/forge', + name: 'forge', + avatar_url: '', + full_name: 'forge', + full_path: 'forge', + path: 'forge', + description: '', + visibility: 'private', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: 'maintainer', + subgroup_creation_level: 'maintainer', + lfs_enabled: true, + default_branch_protection: 0, + request_access_enabled: false, + created_at: faker.date.past().toISOString(), + parent_id: 0, + ...overrides, + } satisfies GroupSchema +} + +export function makeExpandedGroupSchema(overrides: Partial = {}): ExpandedGroupSchema { + return { + id: 123, + web_url: 'https://gitlab.example/groups/forge', + name: 'forge', + avatar_url: '', + full_name: 'forge', + full_path: 'forge', + path: 'forge', + description: '', + visibility: 'private', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: 'maintainer', + subgroup_creation_level: 'maintainer', + lfs_enabled: true, + default_branch_protection: 0, + request_access_enabled: false, + created_at: faker.date.past().toISOString(), + parent_id: 0, + runners_token: 'runners-token', + file_template_project_id: 1, + shared_with_groups: null, + ...overrides, + } satisfies ExpandedGroupSchema +} + +export function makeProjectSchema(overrides: Partial = {}) { + return { + id: 1, + web_url: 'https://gitlab.example/projects/1', + name: 'repo', + path: 'repo', + description: '', + name_with_namespace: 'forge / repo', + path_with_namespace: 'forge/repo', + created_at: faker.date.past().toISOString(), + default_branch: 'main', + topics: [], + ssh_url_to_repo: 'ssh://gitlab.example/forge/repo.git', + http_url_to_repo: 'https://gitlab.example/forge/repo.git', + readme_url: '', + forks_count: 0, + avatar_url: null, + star_count: 0, + last_activity_at: faker.date.future().toISOString(), + namespace: { id: 1, name: 'forge', path: 'forge', kind: 'group', full_path: 'forge', avatar_url: '', web_url: 'https://gitlab.example/groups/forge' }, + description_html: '', + visibility: 'private', + empty_repo: false, + owner: { id: 1, name: 'Owner', created_at: faker.date.past().toISOString() }, + issues_enabled: true, + open_issues_count: 0, + merge_requests_enabled: true, + jobs_enabled: true, + wiki_enabled: true, + snippets_enabled: true, + can_create_merge_request_in: true, + resolve_outdated_diff_discussions: false, + container_registry_access_level: 'enabled', + security_and_compliance_access_level: 'enabled', + container_expiration_policy: { + cadence: '1d', + enabled: false, + keep_n: null, + older_than: null, + name_regex_delete: null, + name_regex_keep: null, + next_run_at: faker.date.future().toISOString(), + }, + updated_at: faker.date.past().toISOString(), + creator_id: 1, + import_url: null, + import_type: null, + import_status: 'none', + import_error: null, + permissions: { + project_access: { access_level: 0, notification_level: 0 }, + group_access: { access_level: 0, notification_level: 0 }, + }, + archived: false, + license_url: '', + license: { key: 'mit', name: 'MIT', nickname: 'MIT', html_url: '', source_url: '' }, + shared_runners_enabled: true, + group_runners_enabled: true, + runners_token: '', + ci_default_git_depth: 0, + ci_forward_deployment_enabled: false, + ci_forward_deployment_rollback_allowed: false, + ci_allow_fork_pipelines_to_run_in_parent_project: false, + ci_separated_caches: false, + ci_restrict_pipeline_cancellation_role: '', + public_jobs: false, + shared_with_groups: null, + repository_storage: '', + only_allow_merge_if_pipeline_succeeds: false, + allow_merge_on_skipped_pipeline: false, + restrict_user_defined_variables: false, + only_allow_merge_if_all_discussions_are_resolved: false, + remove_source_branch_after_merge: false, + printing_merge_requests_link_enabled: false, + request_access_enabled: false, + merge_method: '', + squash_option: '', + auto_devops_enabled: false, + auto_devops_deploy_strategy: '', + mirror: false, + mirror_user_id: 1, + mirror_trigger_builds: false, + only_mirror_protected_branches: false, + mirror_overwrites_diverged_branches: false, + external_authorization_classification_label: '', + packages_enabled: false, + service_desk_enabled: false, + service_desk_address: 'service-desk@example.com', + service_desk_reply_to: 'service-desk@example.com', + autoclose_referenced_issues: false, + suggestion_commit_message: 'Add suggestion commit message', + enforce_auth_checks_on_uploads: false, + merge_commit_template: 'Add suggestion commit message', + squash_commit_template: 'Add suggestion commit message', + issue_branch_template: 'Add suggestion commit message', + marked_for_deletion_on: faker.date.future().toISOString(), + compliance_frameworks: [], + warn_about_potentially_unwanted_characters: false, + container_registry_image_prefix: 'registry.gitlab.example/forge/repo', + _links: { + self: 'https://gitlab.example/projects/1', + issues: 'https://gitlab.example/projects/1/issues', + merge_requests: 'https://gitlab.example/projects/1/merge_requests', + repo_branches: 'https://gitlab.example/projects/1/repository/branches', + labels: 'https://gitlab.example/projects/1/labels', + events: 'https://gitlab.example/projects/1/events', + members: 'https://gitlab.example/projects/1/members', + cluster_agents: 'https://gitlab.example/projects/1/cluster_agents', + }, + ...overrides, + } satisfies ProjectSchema +} + +export function makeProject(overrides: Partial = {}) { + return { + id: faker.string.uuid(), + slug: faker.lorem.slug(), + name: faker.company.name(), + description: faker.lorem.sentence(), + status: faker.helpers.enumValue(ProjectStatus), + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + everyonePerms: 0n, + ownerId: faker.string.uuid(), + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } satisfies Project +} + +export function makeProjectWithDetails(overrides: Partial = {}) { + return { + id: faker.string.uuid(), + slug: faker.lorem.slug(), + name: faker.company.name(), + description: faker.lorem.sentence(), + owner: { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + }, + plugins: [], + roles: [], + members: [], + repositories: [], + clusters: [], + ...overrides, + } satisfies ProjectWithDetails +} +export function makePipelineTriggerToken(overrides: Partial = {}) { + return { + id: 1, + description: 'mirroring-from-external-repo', + created_at: faker.date.past().toISOString(), + last_used: null, + token: 'trigger-token', + updated_at: faker.date.past().toISOString(), + owner: null, + repoId: 1, + ...overrides, + } satisfies PipelineTriggerTokenSchema +} + +export function makeOffsetPagination(overrides: Partial = {}) { + return { + total: 1, + next: null, + current: 1, + previous: null, + perPage: 20, + totalPages: 1, + ...overrides, + } satisfies OffsetPagination +} + +export function makeAccessTokenSchema(overrides: Partial = {}) { + const isoDate = faker.date.past().toISOString() + return { + id: 1, + user_id: 1, + name: 'token', + expires_at: isoDate, + active: true, + created_at: isoDate, + revoked: false, + access_level: AccessLevel.DEVELOPER, + ...overrides, + } satisfies AccessTokenSchema +} + +export function makeAccessTokenExposedSchema(overrides: Partial = {}) { + return { + ...makeAccessTokenSchema(), + token: 'secret-token', + ...overrides, + } satisfies AccessTokenExposedSchema +} + +export function makeRepositoryFileExpandedSchema(overrides: Partial = {}) { + return { + file_name: 'file.txt', + file_path: 'file.txt', + size: 7, + encoding: 'base64', + content: 'content', + content_sha256: 'sha256', + ref: 'main', + blob_id: 'blob', + commit_id: 'commit', + last_commit_id: 'last-commit', + ...overrides, + } satisfies RepositoryFileExpandedSchema +} + +export function makeRepositoryTreeSchema(overrides: Partial = {}) { + return { + id: 'id', + name: 'file.txt', + type: 'blob', + path: 'file.txt', + mode: '100644', + ...overrides, + } satisfies RepositoryTreeSchema +} + +export function makeGitbeakerRequestError(params: { message?: string, status?: number, statusText?: string, description: string }) { + const request = new Request('https://gitlab.internal.example/api') + const response = new Response(null, { status: params.status ?? 404, statusText: params.statusText ?? 'Not Found' }) + return new GitbeakerRequestError(params.message ?? params.statusText ?? 'Error', { + cause: { + description: params.description, + request, + response, + }, + }) +} + +export function makeCommitAction(overrides: Partial = {}) { + return { + action: 'create', + filePath: 'file.txt', + content: 'content', + ...overrides, + } satisfies CommitAction +} + +export function makeAdminPlugin(overrides: { value?: string, pluginName?: string, key?: string } = {}) { + return { + pluginName: overrides.pluginName ?? PLUGIN_NAME, + key: overrides.key ?? 'token', + value: overrides.value ?? faker.string.alphanumeric(20), + } +} + +export function makeAdminRole(overrides: { id?: string, oidcGroup?: string } = {}) { + return { + id: overrides.id ?? faker.string.uuid(), + name: faker.word.words(), + permissions: 0n, + position: 0, + oidcGroup: overrides.oidcGroup ?? faker.word.words(), + type: 'managed', + } satisfies AdminRole +} + +export function makeUser(overrides: { id?: string, email?: string, firstName?: string, lastName?: string } = {}) { + return { + id: overrides.id ?? faker.string.uuid(), + email: overrides.email ?? faker.internet.email(), + firstName: overrides.firstName ?? faker.person.firstName(), + lastName: overrides.lastName ?? faker.person.lastName(), + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: null, + adminRoleIds: [], + type: 'human' as const, + } satisfies User +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts new file mode 100644 index 0000000000..d4d331cd2e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.constants.ts @@ -0,0 +1,34 @@ +// GitLab plugin constants +export const PLUGIN_NAME = 'gitlab' + +// Infrastructure integration +export const INFRA_GROUP_NAME = 'Infra' +export const INFRA_GROUP_PATH = 'infra' +export const INFRA_APPS_REPO_NAME = 'infra-apps' +export const MIRROR_REPO_NAME = 'mirror' + +// Managed resources sentinel +export const TOPIC_PLUGIN_MANAGED = 'plugin-managed' +export const TOKEN_DESCRIPTION = 'mirroring-from-external-repo' + +// Default group paths for console roles +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly,/console/security' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin,/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly,/console/security' + +// Plugin configuration keys +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const AUDITOR_GROUP_PATH_PLUGIN_KEY = 'auditorGroupPath' +export const PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReporterGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDeveloperGroupPathSuffix' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectMaintainerGroupPathSuffix' +export const PURGE_PLUGIN_KEY = 'purge' + +// Custom attribute keys used in GitLab groups +export const GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY = 'cpn_projects_root_dir' +export const INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY = 'cpn_infra_group' +export const PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY = 'cpn_project_slug' +export const USER_ID_CUSTOM_ATTRIBUTE_KEY = 'cpn_user_id' +export const MANAGED_BY_CONSOLE_CUSTOM_ATTRIBUTE_KEY = 'cpn_managed_by_console' diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts new file mode 100644 index 0000000000..3bb17e6b57 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts @@ -0,0 +1,19 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface GitlabConfig { + token?: string + url: string + internalUrl: string + mirrorTokenExpirationDays: number + mirrorTokenRotationThresholdDays: number + projectRootDir: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface GitlabModuleOptions extends GitlabConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('gitlab') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as GITLAB_CONFIG } diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts new file mode 100644 index 0000000000..f08dc6f312 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts @@ -0,0 +1,33 @@ +import type { GitlabConfig } from './gitlab.module-definition' +import { Gitlab } from '@gitbeaker/rest' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { GitlabHealthService } from './gitlab-health.service' +import { GitlabPluginService } from './gitlab-plugin.service' +import { ConfigurableModuleClass, GITLAB_CONFIG } from './gitlab.module-definition' +import { GitlabService } from './gitlab.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule], + providers: [ + { + provide: GITLAB_REST_CLIENT, + inject: [GITLAB_CONFIG], + useFactory: (config: GitlabConfig) => new Gitlab({ + token: config.token, + host: config.internalOrPublicUrl, + }), + }, + GitlabClientService, + GitlabDatastoreService, + GitlabHealthService, + GitlabPluginService, + GitlabService, + ], + exports: [GitlabClientService, GitlabHealthService, GitlabPluginService, GitlabService], +}) +export class GitlabModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts new file mode 100644 index 0000000000..da46c5fe4f --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -0,0 +1,492 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { AccessLevel } from '@gitbeaker/core' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { VaultClientService } from '../vault/vault-client.service' +import { GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { makeAccessTokenExposedSchema, makeExpandedUserSchema, makeGroupSchema, makeMemberSchema, makePipelineTriggerToken, makeProjectSchema, makeProjectWithDetails } from './gitlab-testing.utils' +import { PLUGIN_NAME, TOPIC_PLUGIN_MANAGED } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { GitlabService } from './gitlab.service' + +describe('gitlabService', () => { + let service: GitlabService + let gitlab: DeepMockProxy + let vault: DeepMockProxy + let datastore: DeepMockProxy + + beforeEach(async () => { + gitlab = mockDeep() + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + getAdminRolesByOidcGroups: vi.fn().mockResolvedValue([]), + }) + vault = mockDeep({ + writeGitlabMirrorCreds: vi.fn().mockResolvedValue(undefined), + deleteGitlabMirrorCreds: vi.fn().mockResolvedValue(undefined), + writeTechReadOnlyCreds: vi.fn().mockResolvedValue(undefined), + writeMirrorTriggerToken: vi.fn().mockResolvedValue(undefined), + readTechnReadOnlyCreds: vi.fn().mockResolvedValue(null), + readGitlabMirrorCreds: vi.fn().mockResolvedValue(null), + }) + const config = mockDeep({ projectRootDir: 'forge' }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + GitlabService, + { provide: GitlabClientService, useValue: gitlab }, + { provide: GitlabDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: GITLAB_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(GitlabService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('should reconcile project members and repositories', async () => { + const project = makeProjectWithDetails() + const group = makeGroupSchema({ + id: 123, + full_path: 'forge/console/project-1', + full_name: 'forge/console/project-1', + name: 'project-1', + path: 'project-1', + parent_id: 1, + }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectGroupRepo.mockResolvedValue(makeProjectSchema({ id: 1 })) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateInfraGroupRepoPublicUrl.mockResolvedValue('https://gitlab.internal/repo') + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + gitlab.upsertUser.mockResolvedValue(makeExpandedUserSchema({ id: 123, username: 'user' })) + + await service.handleUpsert(project) + + expect(gitlab.getOrCreateProjectSubGroup).toHaveBeenCalledWith(project.slug) + expect(gitlab.getGroupMembers).toHaveBeenCalledWith(group) + expect(gitlab.getRepos).toHaveBeenCalledWith(project.slug) + }) + + it('should remove orphan member if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 999, username: 'orphan' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).toHaveBeenCalledWith(group, 999) + }) + + it('should not remove managed user (bot) even if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 888, username: 'group_123_bot' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).not.toHaveBeenCalled() + }) + + it('should not remove orphan member if purge disabled', async () => { + const project = makeProjectWithDetails() + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 999, username: 'orphan' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).not.toHaveBeenCalled() + }) + + it('should delete orphan repositories if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepo = makeProjectSchema({ name: 'orphan-repo', topics: [TOPIC_PLUGIN_MANAGED] }) + const unmanagedRepo = makeProjectSchema({ name: 'unmanaged-repo', topics: [] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepo + yield unmanagedRepo + })()) + gitlab.deleteProjectGroupRepo.mockResolvedValue(undefined) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).toHaveBeenCalledWith(project.slug, 'orphan-repo') + expect(gitlab.deleteProjectGroupRepo).toHaveBeenCalledTimes(1) + }) + + it('should not delete orphan repositories if purge disabled', async () => { + const project = makeProjectWithDetails({ + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepo = makeProjectSchema({ name: 'orphan-repo', topics: [TOPIC_PLUGIN_MANAGED] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepo + })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).not.toHaveBeenCalled() + }) + + it('should not delete orphan repositories without the correct topic even if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: 'purge', value: ENABLED }], + repositories: [], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const orphanRepoWithoutTopic = makeProjectSchema({ name: 'orphan-repo', topics: [] }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockImplementation(() => (async function* () { + yield orphanRepoWithoutTopic + })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.deleteProjectGroupRepo).not.toHaveBeenCalled() + }) + + it('should create gitlab user if not exists', async () => { + const project = makeProjectWithDetails({ + owner: { id: 'o1', email: 'owner@example.com', firstName: 'Owner', lastName: 'User', adminRoleIds: [] }, + members: [{ user: { id: 'u1', email: 'new@example.com', firstName: 'New', lastName: 'User', adminRoleIds: [] }, roleIds: [] }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'new@example.com' ? 999 : 998, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'new@example.com' }), + expect.objectContaining({ cpnUserId: 'u1' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'owner@example.com' }), + expect.objectContaining({ cpnUserId: 'o1' }), + ) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 999, AccessLevel.GUEST) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 998, AccessLevel.OWNER) + }) + + it('should map roles to access levels and apply highest level', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-reporter', oidcGroup: '/project-1/console/readonly' }, + { id: 'r-developer', oidcGroup: '/project-1/console/developer' }, + { id: 'r-devops', oidcGroup: '/project-1/console/devops' }, + { id: 'r-maintainer', oidcGroup: '/project-1/console/admin' }, + { id: 'r-unknown', oidcGroup: '/other/group' }, + ], + members: [ + { user: { id: 'u1', email: 'reporter@example.com', firstName: 'Rep', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter'] }, + { user: { id: 'u2', email: 'developer@example.com', firstName: 'Dev', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-developer'] }, + { user: { id: 'u3', email: 'devops@example.com', firstName: 'Ops', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-devops'] }, + { user: { id: 'u4', email: 'maintainer@example.com', firstName: 'Main', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-maintainer'] }, + { user: { id: 'u5', email: 'mixed@example.com', firstName: 'Mixed', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-reporter', 'r-developer'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + const idByEmail: Record = { + 'reporter@example.com': 101, + 'developer@example.com': 102, + 'devops@example.com': 103, + 'maintainer@example.com': 104, + 'mixed@example.com': 105, + 'owner@example.com': 100, + } + return makeExpandedUserSchema({ + id: idByEmail[user.email] ?? 999, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 101, AccessLevel.REPORTER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 102, AccessLevel.DEVELOPER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 103, AccessLevel.MAINTAINER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 104, AccessLevel.MAINTAINER) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.DEVELOPER) + }) + + it('should prioritize higher access level when oidc group appears in multiple paths', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-devops', oidcGroup: '/project-1/console/devops' }, + ], + members: [ + { user: { id: 'u1', email: 'devops@example.com', firstName: 'Ops', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-devops'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'devops@example.com' ? 101 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 101, AccessLevel.MAINTAINER) + }) + + it('should map security project role to reporter access level', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + roles: [ + { id: 'r-security', oidcGroup: '/project-1/console/security' }, + ], + members: [ + { user: { id: 'u1', email: 'security@example.com', firstName: 'Sec', lastName: 'User', adminRoleIds: [] }, roleIds: ['r-security'] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'security@example.com' ? 105 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.REPORTER) + }) + + it('should downgrade existing member to guest when no role maps to an access level', async () => { + const project = makeProjectWithDetails({ + roles: [{ id: 'r-unknown', oidcGroup: '/other/group' }], + members: [{ user: { id: 'u1', email: 'no-access@example.com', firstName: 'No', lastName: 'Access', adminRoleIds: [] }, roleIds: ['r-unknown'] }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 105, username: 'no-access', access_level: AccessLevel.REPORTER })]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: user.email === 'no-access@example.com' ? 105 : 100, + email: user.email, + username: user.email.split('@')[0] ?? user.email, + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.editGroupMember).toHaveBeenCalledWith(group, 105, AccessLevel.GUEST) + expect(gitlab.removeGroupMember).not.toHaveBeenCalledWith(group, 105) + }) + + it('should bind builtin roles (admin/auditor) when role ids are resolved', async () => { + const project = makeProjectWithDetails({ + owner: { id: 'o1', email: 'owner@example.com', firstName: 'Owner', lastName: 'User', adminRoleIds: ['admin-role-id'] }, + members: [ + { user: { id: 'u1', email: 'admin@example.com', firstName: 'Admin', lastName: 'User', adminRoleIds: ['admin-role-id'] }, roleIds: [] }, + { user: { id: 'u2', email: 'auditor@example.com', firstName: 'Auditor', lastName: 'User', adminRoleIds: ['auditor-role-id'] }, roleIds: [] }, + ], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + datastore.getAdminPluginConfig.mockImplementation(async (_pluginName: string, key: string) => { + if (key === 'adminGroupPath') return '/console/admin' + if (key === 'auditorGroupPath') return '/console/readonly' + return null + }) + datastore.getAdminRolesByOidcGroups.mockResolvedValue([ + { id: 'admin-role-id', oidcGroup: '/console/admin' }, + { id: 'auditor-role-id', oidcGroup: '/console/readonly' }, + ]) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.upsertUser.mockImplementation(async (user) => { + return makeExpandedUserSchema({ + id: faker.number.int(), + email: user.email, + username: user.email.split('@')[0], + name: user.name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'admin@example.com', admin: true, auditor: false }), + expect.objectContaining({ cpnUserId: 'u1' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'auditor@example.com', admin: false, auditor: true }), + expect.objectContaining({ cpnUserId: 'u2' }), + ) + expect(gitlab.upsertUser).toHaveBeenCalledWith( + expect.objectContaining({ email: 'owner@example.com', admin: true, auditor: false }), + expect.objectContaining({ cpnUserId: 'o1' }), + ) + }) + + it('should configure repository mirroring if external url is present', async () => { + const project = makeProjectWithDetails({ + slug: 'project-1', + repositories: [{ + id: 'r1', + internalRepoName: 'repo-1', + externalRepoUrl: 'https://github.com/org/repo.git', + isPrivate: true, + externalUserName: 'user', + isInfra: false, + }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const gitlabRepo = makeProjectSchema({ id: 101, name: 'repo-1', path: 'repo-1', path_with_namespace: 'forge/console/project-1/repo-1' }) + const accessToken = makeAccessTokenExposedSchema({ + name: 'bot', + scopes: ['read_api'], + access_level: 40, + }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { yield gitlabRepo })()) + gitlab.getOrCreateProjectGroupInternalRepoUrl.mockResolvedValue('https://gitlab.internal/group/repo-1.git') + gitlab.createMirrorAccessToken.mockResolvedValue(accessToken) + vault.readTechnReadOnlyCreds.mockResolvedValue(null) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.createMirrorAccessToken).toHaveBeenCalledWith('project-1') + expect(gitlab.upsertProjectMirrorRepo).toHaveBeenCalledWith('project-1') + + expect(vault.writeGitlabMirrorCreds).toHaveBeenCalledWith( + 'project-1', + 'repo-1', + expect.objectContaining({ + GIT_INPUT_URL: 'github.com/org/repo.git', + GIT_OUTPUT_USER: 'bot', + GIT_OUTPUT_PASSWORD: accessToken.token, + }), + ) + expect(vault.writeTechReadOnlyCreds).toHaveBeenCalledWith('project-1', { + MIRROR_USER: 'bot', + MIRROR_TOKEN: accessToken.token, + }) + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects', async () => { + const projects = [makeProjectWithDetails({ id: 'p1', slug: 'project-1' })] + datastore.getAllProjects.mockResolvedValue(projects) + + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(gitlab.getOrCreateProjectSubGroup).toHaveBeenCalledWith('project-1') + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts new file mode 100644 index 0000000000..fd214154c1 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -0,0 +1,528 @@ +import type { CondensedGroupSchema, MemberSchema, ProjectSchema } from '@gitbeaker/core' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultSecret } from '../vault/vault-client.service' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import type { GitlabConfig } from './gitlab.module-definition' +import { specificallyEnabled } from '@cpn-console/hooks' +import { AccessLevel } from '@gitbeaker/core' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { getAll } from '../../utils/iterable.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { GitlabClientService } from './gitlab-client.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + AUDITOR_GROUP_PATH_PLUGIN_KEY, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + INFRA_APPS_REPO_NAME, + PLUGIN_NAME, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PURGE_PLUGIN_KEY, +} from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' +import { + adminRoleFlag, + daysAgoFromNow, + generateAccessLevelMapping, + generateAdminRoleMapping, + generateName, + generateProjectRoleGroupPath, + generateUsername, + generateUsernameCandidates, + getProjectPluginConfig, + isOwnedRepo, + isOwnedUser, + isSystemRepo, +} from './gitlab.utils' + +type ProjectAccessLevel = Exclude + +@Injectable() +export class GitlabService { + private readonly logger = new Logger(GitlabService.name) + + constructor( + @Inject(GitlabDatastoreService) private readonly datastore: GitlabDatastoreService, + @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(GITLAB_CONFIG) private readonly gitlabConfig: GitlabConfig, + ) { + this.logger.log('GitLabService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('gitlab', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`GitLab sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('gitlab', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`GitLab sync completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + span?.setAttribute('gitlab.projects.count', 0) + this.logger.log('Starting GitLab reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('gitlab.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for GitLab reconciliation`) + await this.ensureProjectGroups(projects) + this.logger.log(`GitLab reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('gitlab.projects.count', projects.length) + this.logger.verbose(`Reconciling GitLab project groups (${projects.length})`) + await Promise.all(projects.map(p => this.ensureProjectGroup(p))) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling GitLab project group (${project.slug})`) + const group = await this.gitlab.getOrCreateProjectSubGroup(project.slug) + const members = await this.gitlab.getGroupMembers(group) + this.logger.verbose(`Loaded GitLab project group state (${project.slug}): groupId=${group.id} members=${members.length}`) + await this.ensureProjectGroupMembers(project, group, members) + await this.ensureProjectRepos(project) + await this.purgeOrphanRepos(project) + await this.ensureSystemRepos(project) + this.logger.verbose(`GitLab project group reconciled (${project.slug})`) + } + + @StartActiveSpan() + private async ensureProjectGroupMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling GitLab group members for project ${project.slug} (groupId=${group.id}, members=${members.length})`) + const { adminRoleId, auditorRoleId } = await this.getAdminRoleIds(project) + await this.addMissingMembers(project, group, members, adminRoleId, auditorRoleId) + await this.addMissingOwnerMember(project, group, members, adminRoleId, auditorRoleId) + await this.purgeOrphanMembers(project, group, members) + } + + private async addMissingMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + adminRoleId?: string, + auditorRoleId?: string, + ) { + const membersById = new Map(members.map(m => [m.id, m])) + const groupPaths = await this.getProjectRoleGroupPaths(project) + const accessLevelByUserId = generateAccessLevelMapping(project, groupPaths) + + await Promise.all(project.members.map(async ({ user }) => { + const gitlabUser = await this.gitlab.upsertUser({ + email: user.email, + username: generateUsername(user.email), + name: generateName(user.firstName, user.lastName), + admin: adminRoleFlag(user, adminRoleId), + auditor: adminRoleFlag(user, auditorRoleId), + }, { + cpnUserId: user.id, + }) + if (!gitlabUser) { + this.logger.warn(`Unable to resolve a GitLab user for a project member (project=${project.slug}, userId=${user.id}, email=${user.email})`) + return + } + const accessLevel = accessLevelByUserId.get(user.id) ?? AccessLevel.GUEST + await this.ensureGroupMemberAccessLevel(group, gitlabUser.id, accessLevel, membersById) + })) + } + + private async ensureGroupMemberAccessLevel( + group: CondensedGroupSchema, + gitlabUserId: number, + accessLevel: ProjectAccessLevel, + membersById: Map, + ) { + const existingMember = membersById.get(gitlabUserId) + + if (accessLevel === AccessLevel.NO_ACCESS) { + if (existingMember) { + await this.gitlab.removeGroupMember(group, gitlabUserId) + } + return + } + + if (!existingMember) { + await this.gitlab.addGroupMember(group, gitlabUserId, accessLevel) + return + } + + if (existingMember.access_level !== accessLevel) { + await this.gitlab.editGroupMember(group, gitlabUserId, accessLevel) + } + } + + private async addMissingOwnerMember( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + adminRoleId?: string, + auditorRoleId?: string, + ) { + const gitlabUser = await this.gitlab.upsertUser({ + email: project.owner.email, + username: generateUsername(project.owner.email), + name: generateName(project.owner.firstName, project.owner.lastName), + admin: adminRoleFlag(project.owner, adminRoleId), + auditor: adminRoleFlag(project.owner, auditorRoleId), + }, { + cpnUserId: project.owner.id, + }) + if (!gitlabUser) { + this.logger.warn(`Unable to resolve the GitLab owner account (project=${project.slug}, ownerId=${project.owner.id}, email=${project.owner.email})`) + return + } + const membersById = new Map(members.map(m => [m.id, m])) + await this.ensureGroupMemberAccessLevel(group, gitlabUser.id, AccessLevel.OWNER, membersById) + } + + private async getAdminRoleIds(project: ProjectWithDetails): Promise<{ adminRoleId?: string, auditorRoleId?: string }> { + const adminGroupPath = await this.getAdminGroupPath(project) + const auditorGroupPath = await this.getAuditorGroupPath(project) + const roles = await this.datastore.getAdminRolesByOidcGroups([adminGroupPath, auditorGroupPath]) + return generateAdminRoleMapping(roles, adminGroupPath, auditorGroupPath) + } + + private async getAdminGroupPath(project: ProjectWithDetails): Promise { + return await this.getAdminOrProjectPluginConfig(project, ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getAuditorGroupPath(project: ProjectWithDetails): Promise { + return await this.getAdminOrProjectPluginConfig(project, AUDITOR_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_AUDITOR_GROUP_PATH + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + if (!project) return undefined + return getProjectPluginConfig(project, key) ?? undefined + } + + private async getProjectRoleGroupPaths(project: ProjectWithDetails): Promise<{ reporter: string[], developer: string[], maintainer: string[] }> { + const [reporter, developer, maintainer] = await Promise.all([ + this.getProjectReporterGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectMaintainerGroupPaths(project), + ]) + + return { + reporter, + developer, + maintainer, + } + } + + private async getProjectReporterGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectMaintainerGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + @StartActiveSpan() + private async purgeOrphanMembers( + project: ProjectWithDetails, + group: CondensedGroupSchema, + members: MemberSchema[], + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'group.id': group.id, + 'members.total': members.length, + }) + const purgeConfig = getProjectPluginConfig(project, PURGE_PLUGIN_KEY) + const usernames = new Set([ + ...generateUsernameCandidates(project.owner.email), + ...project.members.flatMap(m => generateUsernameCandidates(m.user.email)), + ]) + const emails = new Set([ + project.owner.email.toLowerCase(), + ...project.members.map(m => m.user.email.toLowerCase()), + ]) + + const orphans = members.filter((member) => { + if (isOwnedUser(member)) return false + if (usernames.has(member.username)) return false + if (member.email && emails.has(member.email.toLowerCase())) return false + return true + }) + span?.setAttribute('orphans.count', orphans.length) + + if (specificallyEnabled(purgeConfig)) { + span?.setAttribute('purge.enabled', true) + let removedCount = 0 + await Promise.all(orphans.map(async (orphan) => { + await this.gitlab.removeGroupMember(group, orphan.id) + removedCount++ + this.logger.log(`Removed a user from the GitLab group (groupId=${group.id}, username=${orphan.username})`) + })) + span?.setAttribute('orphans.removed.count', removedCount) + } else { + span?.setAttribute('purge.enabled', false) + let warnedCount = 0 + for (const orphan of orphans) { + warnedCount++ + this.logger.warn(`User is in the GitLab group but not in the project (purge disabled, username=${orphan.username})`) + } + span?.setAttribute('orphans.warned.count', warnedCount) + } + } + + @StartActiveSpan() + private async ensureProjectRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'repositories.count': project.repositories.length, + }) + const gitlabRepositories = await getAll(this.gitlab.getRepos(project.slug)) + span?.setAttribute('gitlab.repositories.count', gitlabRepositories.length) + let mirroringEnabledCount = 0 + let mirroringDisabledCount = 0 + for (const repo of project.repositories) { + const externalHost = this.getExternalRepoHost(repo.externalRepoUrl) + span?.addEvent('gitlab.repo.reconcile', { + 'repository.name': repo.internalRepoName, + 'repository.isPrivate': repo.isPrivate, + ...(externalHost ? { 'repository.external.host': externalHost } : {}), + 'repository.external': !!repo.externalRepoUrl, + }) + await this.ensureRepository(project, repo, gitlabRepositories) + + if (repo.externalRepoUrl) { + span?.setAttribute('repository.mirroring', true) + mirroringEnabledCount++ + await this.configureRepositoryMirroring(project, repo) + } else { + span?.setAttribute('repository.mirroring', false) + mirroringDisabledCount++ + await this.vault.deleteGitlabMirrorCreds(project.slug, repo.internalRepoName) + } + } + span?.setAttribute('repositories.mirroring.enabled.count', mirroringEnabledCount) + span?.setAttribute('repositories.mirroring.disabled.count', mirroringDisabledCount) + } + + @StartActiveSpan() + private async purgeOrphanRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const gitlabRepositories = await getAll(this.gitlab.getRepos(project.slug)) + span?.setAttribute('gitlab.repositories.count', gitlabRepositories.length) + + const orphanRepos = gitlabRepositories.filter(r => isOwnedRepo(r) && !isSystemRepo(project, r)) + span?.setAttribute('orphan.repositories.count', orphanRepos.length) + + if (specificallyEnabled(getProjectPluginConfig(project, PURGE_PLUGIN_KEY))) { + span?.setAttribute('purge.enabled', true) + let removedCount = 0 + await Promise.all(orphanRepos.map(async (orphan) => { + await this.gitlab.deleteProjectGroupRepo(project.slug, orphan.name) + removedCount++ + this.logger.log(`Removed a repository from the GitLab project (project=${project.slug}, repoName=${orphan.name})`) + })) + + span?.setAttribute('orphan.repositories.removed.count', removedCount) + } else { + span?.setAttribute('purge.enabled', false) + let warnedCount = 0 + for (const orphan of orphanRepos) { + warnedCount++ + this.logger.warn(`Repository is in GitLab but not in the project definition (purge disabled, project=${project.slug}, repoName=${orphan.name})`) + } + span?.setAttribute('managed.repositories.warned.count', warnedCount) + } + } + + private async ensureRepository( + project: ProjectWithDetails, + repo: ProjectWithDetails['repositories'][number], + gitlabRepositories: ProjectSchema[], + ) { + return gitlabRepositories.find(r => r.name === repo.internalRepoName) + ?? await this.gitlab.upsertProjectGroupRepo( + project.slug, + repo.internalRepoName, + ) + } + + @StartActiveSpan() + private async configureRepositoryMirroring( + project: ProjectWithDetails, + repo: ProjectWithDetails['repositories'][number], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('repository.internalRepoName', repo.internalRepoName) + span?.setAttribute('repository.isPrivate', repo.isPrivate) + const externalHost = this.getExternalRepoHost(repo.externalRepoUrl) + if (externalHost) span?.setAttribute('repository.external.host', externalHost) + + const currentVaultSecret = await this.vault.readGitlabMirrorCreds(project.slug, repo.internalRepoName) + span?.setAttribute('vault.secret.exists', !!currentVaultSecret) + if (!currentVaultSecret) { + this.logger.warn(`No existing mirror credentials found in Vault; rotating new credentials (project=${project.slug}, repoName=${repo.internalRepoName})`) + } + + const internalRepoUrl = await this.gitlab.getOrCreateProjectGroupInternalRepoUrl(project.slug, repo.internalRepoName) + const externalRepoUrn = repo.externalRepoUrl.split('://')[1] + const internalRepoUrn = internalRepoUrl.split('://')[1] + span?.setAttribute('repository.externalRepoUrn', externalRepoUrn) + span?.setAttribute('repository.internalRepoUrn', internalRepoUrn) + + const projectMirrorCreds = await this.getOrRotateMirrorCreds(project.slug) + + const mirrorSecretData = { + GIT_INPUT_URL: externalRepoUrn, + GIT_INPUT_USER: repo.isPrivate ? repo.externalUserName : undefined, + GIT_INPUT_PASSWORD: currentVaultSecret?.data?.GIT_INPUT_PASSWORD, // Preserve existing password as it's not in DB + GIT_OUTPUT_URL: internalRepoUrn, + GIT_OUTPUT_USER: projectMirrorCreds.MIRROR_USER, + GIT_OUTPUT_PASSWORD: projectMirrorCreds.MIRROR_TOKEN, + } + + // Write to vault if changed + // Using simplified check + await this.vault.writeGitlabMirrorCreds(project.slug, repo.internalRepoName, mirrorSecretData) + span?.setAttribute('vault.secret.written', true) + } + + @StartActiveSpan() + private async ensureSystemRepos(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + await Promise.all([ + this.ensureInfraAppsRepo(project.slug), + this.ensureMirrorRepo(project.slug), + ]) + } + + private async ensureInfraAppsRepo(projectSlug: string) { + await this.gitlab.upsertProjectGroupRepo(projectSlug, INFRA_APPS_REPO_NAME) + } + + private async ensureMirrorRepo(projectSlug: string) { + const mirrorRepo = await this.gitlab.upsertProjectMirrorRepo(projectSlug) + if (mirrorRepo.empty_repo) { + await this.gitlab.commitMirror(mirrorRepo.id) + } + await this.ensureMirrorRepoTriggerToken(projectSlug) + } + + @StartActiveSpan() + private async ensureMirrorRepoTriggerToken(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const triggerToken = await this.gitlab.getOrCreateMirrorPipelineTriggerToken(projectSlug) + const gitlabSecret = { + PROJECT_SLUG: projectSlug, + GIT_MIRROR_PROJECT_ID: triggerToken.repoId, + GIT_MIRROR_TOKEN: triggerToken.token, + } + await this.vault.writeMirrorTriggerToken(gitlabSecret) + span?.setAttribute('vault.secret.written', true) + } + + @StartActiveSpan() + private async getOrRotateMirrorCreds(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const vaultSecret = await this.vault.readTechnReadOnlyCreds(projectSlug) + if (!vaultSecret) return this.createMirrorAccessToken(projectSlug) + + const isExpiring = this.isMirrorCredsExpiring(vaultSecret) + span?.setAttribute('mirror.creds.expiring', isExpiring) + if (!isExpiring) { + span?.setAttribute('mirror.creds.rotated', false) + return vaultSecret.data as { MIRROR_USER: string, MIRROR_TOKEN: string } + } + return this.createMirrorAccessToken(projectSlug) + } + + @StartActiveSpan() + private async createMirrorAccessToken(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('mirror.creds.rotated', true) + const token = await this.gitlab.createMirrorAccessToken(projectSlug) + const creds = { + MIRROR_USER: token.name, + MIRROR_TOKEN: token.token, + } + await this.vault.writeTechReadOnlyCreds(projectSlug, creds) + span?.setAttribute('vault.secret.written', true) + return creds + } + + private isMirrorCredsExpiring(vaultSecret: VaultSecret): boolean { + if (!vaultSecret?.metadata?.created_time) return false + const createdTime = new Date(vaultSecret.metadata.created_time) + return daysAgoFromNow(createdTime) > this.gitlabConfig.mirrorTokenRotationThresholdDays + } + + private getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { + if (!externalRepoUrl) return undefined + try { + return new URL(externalRepoUrl).host + } catch { + return undefined + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts new file mode 100644 index 0000000000..63f574bd3c --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts @@ -0,0 +1,233 @@ +import type { MemberSchema, ProjectSchema } from '@gitbeaker/core' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { createHash } from 'node:crypto' +import { AccessLevel } from '@gitbeaker/core' +import { stringify } from 'yaml' +import { TOPIC_PLUGIN_MANAGED } from './gitlab.constants' + +export type ProjectAccessLevel = Exclude + +export function getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { + if (!externalRepoUrl) return undefined + try { + return new URL(externalRepoUrl).host + } catch { + return undefined + } +} + +export function hasFileContentChanged(file: { content_sha256?: string } | null | undefined, content: string): boolean { + return file?.content_sha256 !== digestContent(content) +} + +export function digestContent(content: string): string { + return createHash('sha256').update(content).digest('hex') +} + +export function generateUsername(email: string): string { + const localPart = email.split('@')[0] + return localPart.replaceAll(/[^\w-]/g, '') +} + +export function generateUsernameCandidates(email: string): string[] { + const username = generateUsername(email) + return [username, `${username}_1`, `${username}_2`, `${username}_3`] +} + +export function generateName(firstName: string | null, lastName: string | null): string { + const parts = [firstName, lastName].filter(Boolean) + return parts.length > 0 ? parts.join(' ') : 'User' +} + +export function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string): string[] { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${projectSlug}${path}`) +} + +export function generateAdminRoleMapping( + roles: ProjectWithDetails['roles'], + adminGroupPath: string, + auditorGroupPath: string, +): { adminRoleId?: string, auditorRoleId?: string } { + const roleIdByOidcGroup = new Map(roles.map(r => [r.oidcGroup, r.id] as [string | null, string])) + return { + adminRoleId: roleIdByOidcGroup.get(adminGroupPath), + auditorRoleId: roleIdByOidcGroup.get(auditorGroupPath), + } +} + +export function generateAccessLevelMapping( + project: ProjectWithDetails, + groupPaths: { reporter: string[], developer: string[], maintainer: string[] }, +): Map { + const getAccessLevelFromOidcGroup = (oidcGroup: string | null): ProjectAccessLevel | null => { + if (!oidcGroup) return null + if (groupPaths.maintainer.includes(oidcGroup)) return AccessLevel.MAINTAINER + if (groupPaths.developer.includes(oidcGroup)) return AccessLevel.DEVELOPER + if (groupPaths.reporter.includes(oidcGroup)) return AccessLevel.REPORTER + return null + } + + const roleAccessLevelById = new Map( + project.roles.map(role => [role.id, getAccessLevelFromOidcGroup(role.oidcGroup)]), + ) + + return project.members.reduce((acc, membership) => { + const highest = membership.roleIds.reduce((highest: ProjectAccessLevel | null, roleId) => { + const level = roleAccessLevelById.get(roleId) + if (level !== null && level !== undefined && (highest === null || level > highest)) return level + return highest + }, null) + acc.set(membership.user.id, highest ?? AccessLevel.GUEST) + return acc + }, new Map()) +} + +export function generateGitlabCIConfigContent() { + return stringify({ + variables: { + PROJECT_NAME: { + description: 'Nom du dépôt (dans ce Gitlab) à synchroniser.', + }, + GIT_BRANCH_DEPLOY: { + description: 'Nom de la branche à synchroniser.', + value: 'main', + }, + SYNC_ALL: { + description: 'Synchroniser toutes les branches.', + value: 'false', + }, + }, + include: [ + { + project: '$CATALOG_PATH', + file: 'mirror.yml', + ref: 'main', + }, + ], + repo_pull_sync: { + extends: '.repo_pull_sync', + only: [ + 'api', + 'triggers', + 'web', + 'schedules', + ], + }, + }) +} + +export function generateMirrorScriptContent() { + return `#!/bin/bash + +set -e + +# Colorize terminal +red='\\e[0;31m' +no_color='\\033[0m' + +# Console step increment +i=1 + +# Default values +BRANCH_TO_SYNC=main + +print_help() { + TEXT_HELPER="\\nThis script aims to send a synchronization request to DSO.\\nFollowing flags are available: + -a Api url to send the synchronization request + -b Branch which is wanted to be synchronize for the given repository (default '$BRANCH_TO_SYNC') + -g GitLab token to trigger the pipeline on the gitlab mirror project + -i Gitlab mirror project id + -r Gitlab repository name to mirror + -h Print script help\\n" + printf "$TEXT_HELPER" +} + +print_args() { + printf "\\nArguments received: + -a API_URL: $API_URL + -b BRANCH_TO_SYNC: $BRANCH_TO_SYNC + -g GITLAB_TRIGGER_TOKEN length: \${#GITLAB_TRIGGER_TOKEN} + -i GITLAB_MIRROR_PROJECT_ID: $GITLAB_MIRROR_PROJECT_ID + -r REPOSITORY_NAME: $REPOSITORY_NAME\\n" +} + +# Parse options +while getopts :ha:b:g:i:r: flag +do + case "\${flag}" in + a) + API_URL=\${OPTARG};; + b) + BRANCH_TO_SYNC=\${OPTARG};; + g) + GITLAB_TRIGGER_TOKEN=\${OPTARG};; + i) + GITLAB_MIRROR_PROJECT_ID=\${OPTARG};; + r) + REPOSITORY_NAME=\${OPTARG};; + h) + printf "\\nHelp requested.\\n" + print_help + printf "\\nExiting.\\n" + exit 0;; + *) + printf "\\nInvalid argument \${OPTARG} (\${flag}).\\n" + print_help + print_args + exit 1;; + esac +done + +# Test if arguments are missing +if [ -z \${API_URL} ] || [ -z \${BRANCH_TO_SYNC} ] || [ -z \${GITLAB_TRIGGER_TOKEN} ] || [ -z \${GITLAB_MIRROR_PROJECT_ID} ] || [ -z \${REPOSITORY_NAME} ]; then + printf "\\nArgument(s) missing!\\n" + print_help + print_args + exit 2 +fi + +# Print arguments +print_args + +# Send synchronization request +printf "\\n\${red}\${i}.\${no_color} Send request to DSO api.\\n\\n" + +curl \\ + -X POST \\ + --fail \\ + -F token=\${GITLAB_TRIGGER_TOKEN} \\ + -F ref=main \\ + -F variables[GIT_BRANCH_DEPLOY]=\${BRANCH_TO_SYNC} \\ + -F variables[PROJECT_NAME]=\${REPOSITORY_NAME} \\ + "\${API_URL}/api/v4/projects/\${GITLAB_MIRROR_PROJECT_ID}/trigger/pipeline" +` +} + +const ownedUserRegex = /group_\d+_bot/u + +export function isOwnedUser(member: MemberSchema) { + return ownedUserRegex.test(member.username) +} + +export function isOwnedRepo(repo: ProjectSchema) { + return repo.topics?.includes(TOPIC_PLUGIN_MANAGED) ?? false +} + +export function isSystemRepo(project: ProjectWithDetails, repo: ProjectSchema) { + return project.repositories.some(r => r.internalRepoName === repo.name) +} + +export function getProjectPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} +export function daysAgoFromNow(date: Date) { + return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) +} + +export function adminRoleFlag(user: ProjectWithDetails['members'][0]['user'], adminRoleId?: string) { + return adminRoleId ? user.adminRoleIds?.includes(adminRoleId) : undefined +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/healthz/healthz.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/healthz/healthz.controller.ts new file mode 100644 index 0000000000..b18ec7aadd --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/healthz/healthz.controller.ts @@ -0,0 +1,14 @@ +import type { HealthzService } from './healthz.service' +import { Controller, Get } from '@nestjs/common' +import { HealthCheck } from '@nestjs/terminus' + +@Controller('api/v1/healthz') +export class HealthzController { + constructor(private readonly healthz: HealthzService) {} + + @Get() + @HealthCheck() + check() { + return this.healthz.check() + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/healthz/healthz.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/healthz/healthz.module.ts new file mode 100644 index 0000000000..3917b16e38 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/healthz/healthz.module.ts @@ -0,0 +1,39 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { argocdConfigFactory } from '../../config/argocd.config' +import { gitlabConfigFactory } from '../../config/gitlab.config' +import { harborConfigFactory } from '../../config/harbor.config' +import { keycloakConfigFactory } from '../../config/keycloak.config' +import { nexusConfigFactory } from '../../config/nexus.config' +import { openCdsConfigFactory } from '../../config/opencds.config' +import { registryConfigFactory } from '../../config/registry.config' +import { vaultConfigFactory } from '../../config/vault.config' +import { ArgoCDModule } from '../argocd/argocd.module' +import { GitlabModule } from '../gitlab/gitlab.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { NexusModule } from '../nexus/nexus.module' +import { OpenCdsModule } from '../opencds/opencds.module' +import { HarborModule, RegistryModule } from '../registry/registry.module' +import { VaultModule } from '../vault/vault.module' +import { HealthzController } from './healthz.controller' +import { HealthzService } from './healthz.service' + +@Module({ + imports: [ + TerminusModule.forRoot(), + DatabaseModule, + ConditionalModule.registerWhen(KeycloakModule.forRoot(keycloakConfigFactory.asProvider()), 'USE_KEYCLOAK'), + ConditionalModule.registerWhen(GitlabModule.forRoot(gitlabConfigFactory.asProvider()), 'USE_GITLAB'), + ConditionalModule.registerWhen(VaultModule.forRoot(vaultConfigFactory.asProvider()), 'USE_VAULT'), + ConditionalModule.registerWhen(NexusModule.forRoot(nexusConfigFactory.asProvider()), 'USE_NEXUS'), + ConditionalModule.registerWhen(RegistryModule.forRoot(registryConfigFactory.asProvider()), 'USE_REGISTRY'), + ConditionalModule.registerWhen(HarborModule.forRoot(harborConfigFactory.asProvider()), 'USE_REGISTRY'), + ConditionalModule.registerWhen(ArgoCDModule.forRoot(argocdConfigFactory.asProvider()), 'USE_ARGOCD'), + ConditionalModule.registerWhen(OpenCdsModule.forRoot(openCdsConfigFactory.asProvider()), 'USE_OPENCDS'), + ], + controllers: [HealthzController], + providers: [HealthzService], +}) +export class HealthzModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts new file mode 100644 index 0000000000..288e80d353 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts @@ -0,0 +1,106 @@ +import { HealthCheckService, TerminusModule } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { OpenCdsHealthService } from '../opencds/opencds-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { VaultHealthService } from '../vault/vault-health.service' +import { HealthzService } from './healthz.service' + +describe('healthzService', () => { + afterEach(() => vi.clearAllMocks()) + + describe('with all optional probes registered', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let keycloak: ReturnType> + let gitlab: ReturnType> + let vault: ReturnType> + let nexus: ReturnType> + let registry: ReturnType> + let argocd: ReturnType> + let opencds: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + keycloak = mockDeep() + gitlab = mockDeep() + vault = mockDeep() + nexus = mockDeep() + registry = mockDeep() + argocd = mockDeep() + opencds = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + { provide: KeycloakHealthService, useValue: keycloak }, + { provide: GitlabHealthService, useValue: gitlab }, + { provide: VaultHealthService, useValue: vault }, + { provide: NexusHealthService, useValue: nexus }, + { provide: RegistryHealthService, useValue: registry }, + { provide: ArgoCDHealthService, useValue: argocd }, + { provide: OpenCdsHealthService, useValue: opencds }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('checks every injected probe, including those wired via @Optional', async () => { + await service.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(keycloak.check).toHaveBeenCalledWith('keycloak') + expect(gitlab.check).toHaveBeenCalledWith('gitlab') + expect(vault.check).toHaveBeenCalledWith('vault') + expect(nexus.check).toHaveBeenCalledWith('nexus') + expect(registry.check).toHaveBeenCalledWith('registry') + expect(argocd.check).toHaveBeenCalledWith('argocd') + expect(opencds.check).toHaveBeenCalledWith('opencds') + }) + }) + + describe('without optional probes registered (@Optional -> undefined)', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('omits probes whose services are not registered', async () => { + await service.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(healthCheck.check).toHaveBeenCalledWith([expect.any(Function)]) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/healthz/healthz.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/healthz/healthz.service.ts new file mode 100644 index 0000000000..67619844e0 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/healthz/healthz.service.ts @@ -0,0 +1,41 @@ +import type { HealthIndicatorFunction } from '@nestjs/terminus' +import { Inject, Injectable, Optional } from '@nestjs/common' +import { HealthCheckService } from '@nestjs/terminus' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { OpenCdsHealthService } from '../opencds/opencds-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { VaultHealthService } from '../vault/vault-health.service' + +@Injectable() +export class HealthzService { + // Optional modules (gated by USE_*) resolve to undefined when not registered. + constructor( + @Inject(HealthCheckService) private readonly health: HealthCheckService, + @Inject(DatabaseHealthService) @Optional() private readonly database?: DatabaseHealthService, + @Inject(KeycloakHealthService) @Optional() private readonly keycloak?: KeycloakHealthService, + @Inject(GitlabHealthService) @Optional() private readonly gitlab?: GitlabHealthService, + @Inject(VaultHealthService) @Optional() private readonly vault?: VaultHealthService, + @Inject(NexusHealthService) @Optional() private readonly nexus?: NexusHealthService, + @Inject(RegistryHealthService) @Optional() private readonly registry?: RegistryHealthService, + @Inject(ArgoCDHealthService) @Optional() private readonly argocd?: ArgoCDHealthService, + @Inject(OpenCdsHealthService) @Optional() private readonly opencds?: OpenCdsHealthService, + ) {} + + check() { + const checks: HealthIndicatorFunction[] = [] + // Each health service's own check() reports 'Not configured' when its URL is absent. + if (this.database) checks.push(() => this.database!.check('database')) + if (this.keycloak) checks.push(() => this.keycloak!.check('keycloak')) + if (this.gitlab) checks.push(() => this.gitlab!.check('gitlab')) + if (this.vault) checks.push(() => this.vault!.check('vault')) + if (this.nexus) checks.push(() => this.nexus!.check('nexus')) + if (this.registry) checks.push(() => this.registry!.check('registry')) + if (this.argocd) checks.push(() => this.argocd!.check('argocd')) + if (this.opencds) checks.push(() => this.opencds!.check('opencds')) + return this.health.check(checks) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts new file mode 100644 index 0000000000..d170ff59ee --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth-testing.utils.ts @@ -0,0 +1,86 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { HttpArgumentsHost } from '@nestjs/common/interfaces' +import type { AdminToken, PersonalAccessToken, User } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from './auth-user.decorator' +import { faker } from '@faker-js/faker' +import { mockDeep } from 'vitest-mock-extended' + +export function makeAuthRequest(headers: FastifyRequest['headers'] = {}): FastifyRequest { + const request = mockDeep() + request.headers = headers + return request +} + +export function makeExecutionContext(headers: FastifyRequest['headers'] = {}): DeepMockProxy { + const context = mockDeep() + const httpArgumentsHost = mockDeep() + const request = mockDeep() + + request.headers = headers + httpArgumentsHost.getRequest.mockReturnValue(request) + context.switchToHttp.mockReturnValue(httpArgumentsHost) + + return context +} + +export function makePersonalAccessToken(overrides: { + id?: string + userId?: string + adminRoleIds?: string[] + type?: User['type'] + status?: PersonalAccessToken['status'] + expirationDate?: PersonalAccessToken['expirationDate'] + lastUse?: PersonalAccessToken['lastUse'] +} = {}): PersonalAccessToken & { owner: { id: string, adminRoleIds: string[], type: string } } { + const id = overrides.id ?? 'pat-id' + const ownerId = overrides.userId ?? 'owner-id' + return { + id, + name: 'test-token', + status: overrides.status ?? 'active', + expirationDate: overrides.expirationDate ?? faker.date.future(), + lastUse: overrides.lastUse ?? null, + createdAt: faker.date.past(), + hash: 'hash', + userId: ownerId, + owner: { + id: ownerId, + adminRoleIds: overrides.adminRoleIds ?? [], + type: overrides.type ?? 'human', + }, + } +} + +export function makeAdminToken(overrides: { + id?: string + userId?: string + adminRoleIds?: string[] + type?: User['type'] + status?: AdminToken['status'] + permissions?: bigint + expirationDate?: AdminToken['expirationDate'] + lastUse?: AdminToken['lastUse'] +} = {}): AdminToken & { owner: { id: string, adminRoleIds: string[], type: string } } { + const id = overrides.id ?? 'admin-token-id' + const ownerId = overrides.userId ?? 'owner-id' + const type = overrides.type ?? 'human' + + return { + id, + name: 'admin-token', + status: overrides.status ?? 'active', + expirationDate: overrides.expirationDate ?? null, + permissions: overrides.permissions ?? 256n, + lastUse: overrides.lastUse ?? null, + createdAt: faker.date.past(), + hash: 'hash', + userId: ownerId, + owner: { + id: ownerId, + adminRoleIds: overrides.adminRoleIds ?? [], + type, + }, + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts new file mode 100644 index 0000000000..f018ac647e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth-user.decorator.ts @@ -0,0 +1,21 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { User } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import { createParamDecorator } from '@nestjs/common' + +export interface UserContext { + userId: string + adminPermissions?: bigint + userType?: User['type'] +} + +export const AuthUser = createParamDecorator( + (_: unknown, ctx: ExecutionContext): UserContext => { + const request = ctx.switchToHttp().getRequest() + return { + userId: request.userId, + adminPermissions: request.adminPermissions, + userType: request.userType, + } + }, +) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts new file mode 100644 index 0000000000..23f7129d2e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../config/keycloak.config' +import { AuthService } from './auth.service' +import { DsoTokenModule } from './dso-token/dso-token.module' +import { KeycloakJwtModule } from './keycloak-jwt/keycloak-jwt.module' + +@Module({ + imports: [ConfigModule.forFeature(keycloakConfigFactory), DsoTokenModule, KeycloakJwtModule], + providers: [ + AuthService, + ], + exports: [ + AuthService, + KeycloakJwtModule, + ], +}) +export class AuthModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts new file mode 100644 index 0000000000..aa0fe99424 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.spec.ts @@ -0,0 +1,59 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeAuthRequest } from './auth-testing.utils' +import { AuthService } from './auth.service' +import { DsoTokenService } from './dso-token/dso-token.service' +import { KeycloakJwtService } from './keycloak-jwt/keycloak-jwt.service' + +describe('authService', () => { + let module: TestingModule + let service: AuthService + let dsoTokenService: DeepMockProxy + let keycloakJwtService: DeepMockProxy + + beforeEach(async () => { + dsoTokenService = mockDeep() + keycloakJwtService = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: DsoTokenService, useValue: dsoTokenService }, + { provide: KeycloakJwtService, useValue: keycloakJwtService }, + ], + }).compile() + + service = module.get(AuthService) + }) + + it('should authenticate a Fastify request directly', async () => { + dsoTokenService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 0n, userType: 'human' }) + const request = makeAuthRequest({ 'x-dso-token': 'token' }) + + const result = await service.authenticate(request) + + expect(result).toEqual({ userId: 'u1', adminPermissions: 0n, userType: 'human' }) + expect(dsoTokenService.authenticate).toHaveBeenCalledWith(request, undefined) + }) + + it('should authenticate a Keycloak bearer token from the request header', async () => { + keycloakJwtService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 8n, userType: 'human' }) + + const request = makeAuthRequest({ authorization: 'Bearer jwt-token' }) + const result = await service.authenticate(request) + + expect(keycloakJwtService.authenticate).toHaveBeenCalledWith( + request, + undefined, + ) + expect(result).toEqual({ userId: 'u1', adminPermissions: 8n, userType: 'human' }) + }) + + it('should throw 401 when no supported auth header exists', async () => { + await expect(service.authenticate(makeAuthRequest({}))).rejects.toThrow(UnauthorizedException) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts new file mode 100644 index 0000000000..9ee491473c --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth.service.ts @@ -0,0 +1,37 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from './auth-user.decorator' +import type { AuthRequirements } from './auth.utils' +import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common' +import { DsoTokenService } from './dso-token/dso-token.service' +import { KeycloakJwtService } from './keycloak-jwt/keycloak-jwt.service' + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name) + + constructor( + @Inject(DsoTokenService) private readonly dsoTokenService: DsoTokenService, + @Inject(KeycloakJwtService) private readonly keycloakJwtService: KeycloakJwtService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + const dsoTokenResult = await this.dsoTokenService.authenticate(request, requirements) + if (dsoTokenResult) { + this.logger.debug(`Auth authenticated request (requestId=${request.id}, authSource=dso-token, userId=${dsoTokenResult.userId}, adminPermissions=${dsoTokenResult.adminPermissions?.toString()}, userType=${dsoTokenResult.userType})`) + return dsoTokenResult + } + + const bearerTokenResult = await this.keycloakJwtService.authenticate(request, requirements) + if (bearerTokenResult) { + this.logger.debug(`Auth authenticated request (requestId=${request.id}, authSource=keycloak-jwt, userId=${bearerTokenResult.userId}, adminPermissions=${bearerTokenResult.adminPermissions?.toString()}, userType=${bearerTokenResult.userType})`) + return bearerTokenResult + } + + this.logger.warn(`Auth rejected request (requestId=${request.id}, hasDsoTokenHeader=${typeof request.headers['x-dso-token'] === 'string'}, hasAuthorizationHeader=${typeof request.headers.authorization === 'string'})`) + + throw new UnauthorizedException('Not authenticated') + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts new file mode 100644 index 0000000000..3b4d0c3ed9 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/auth.utils.ts @@ -0,0 +1,14 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from './auth-user.decorator' + +export interface AuthRequirements { + includeAdminRoleIds?: boolean + includeUserType?: boolean +} + +export interface AuthProvider { + authenticate: ( + request: FastifyRequest, + requirements?: AuthRequirements, + ) => Promise +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts new file mode 100644 index 0000000000..ed96461131 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../../database/database.module' +import { DsoTokenService } from './dso-token.service' + +@Module({ + imports: [DatabaseModule], + providers: [DsoTokenService], + exports: [DsoTokenService], +}) +export class DsoTokenModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts new file mode 100644 index 0000000000..f9ab618442 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.spec.ts @@ -0,0 +1,118 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { createHash } from 'node:crypto' +import { faker } from '@faker-js/faker' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../../database/prisma.service' +import { makeAdminToken, makePersonalAccessToken } from '../auth-testing.utils' +import { DsoTokenService } from './dso-token.service' + +describe('dsoTokenService', () => { + let module: TestingModule + let service: DsoTokenService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep({ + adminRole: { + findMany: vi.fn().mockResolvedValue([]), + }, + }) + + module = await Test.createTestingModule({ + providers: [ + DsoTokenService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(DsoTokenService) + }) + + it('should validate a PersonalAccessToken and return raw token result', async () => { + const rawToken = faker.string.alphanumeric(32) + const patMock = makePersonalAccessToken({ adminRoleIds: [faker.string.uuid()] }) + prisma.personalAccessToken.findFirst.mockResolvedValue(patMock) + + const result = await service.validateToken(rawToken, { includeAdminRoleIds: true, includeUserType: true }) + + expect(prisma.personalAccessToken.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { hash: createHash('sha256').update(rawToken).digest('hex') } }), + ) + expect(result).toBeDefined() + if (result?.kind !== 'personal') throw new Error('Expected personal token result') + expect(result.ownerAdminRoleIds).toHaveLength(1) + expect(result.userType).toBe('human') + }) + + it('should validate an AdminToken and return raw token result', async () => { + const userId = faker.string.uuid() + prisma.personalAccessToken.findFirst.mockResolvedValue(null) + prisma.adminToken.findFirst.mockResolvedValue( + makeAdminToken({ + id: faker.string.uuid(), + userId, + permissions: 256n, + }), + ) + + const result = await service.validateToken(faker.string.alphanumeric(24), { includeAdminRoleIds: true, includeUserType: true }) + + expect(result).toBeDefined() + if (result?.kind !== 'admin') throw new Error('Expected admin token result') + expect(result.permissions).toBe(256n) + expect(result.userType).toBe('human') + }) + + it('should return undefined when no token found', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue(null) + prisma.adminToken.findFirst.mockResolvedValue(null) + + const result = await service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true }) + + expect(result).toBeUndefined() + }) + + it('should throw 401 when token is inactive', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue( + makePersonalAccessToken({ status: 'revoked', expirationDate: new Date(Date.now() + 86400000) }), + ) + + await expect(service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true })).rejects.toThrow(UnauthorizedException) + }) + + it('should throw 401 when token is expired', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue( + makePersonalAccessToken({ expirationDate: new Date(Date.now() - 1000) }), + ) + + await expect(service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true })).rejects.toThrow(UnauthorizedException) + }) + + it('should update lastUse and lastLogin', async () => { + const patMock = makePersonalAccessToken({}) + prisma.personalAccessToken.findFirst.mockResolvedValue(patMock) + + await service.validateToken(faker.string.alphanumeric(16), { includeAdminRoleIds: true, includeUserType: true }) + + expect(prisma.personalAccessToken.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: patMock.id }, data: { lastUse: expect.any(String) } }), + ) + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: patMock.owner.id }, data: { lastLogin: expect.any(String) } }), + ) + }) + + it('should authenticate a DSO token from the request header', async () => { + prisma.personalAccessToken.findFirst.mockResolvedValue(makePersonalAccessToken({})) + + const result = await service.authenticateHeaders({ 'x-dso-token': 'token' }) + + expect(typeof result?.userId).toBe('string') + expect(result?.userType).toBe('human') + expect(result?.adminPermissions).toBe(0n) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts new file mode 100644 index 0000000000..1e27c96d63 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.service.ts @@ -0,0 +1,150 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../auth-user.decorator' +import type { AuthProvider, AuthRequirements } from '../auth.utils' +import type { AdminTokenWithOwner, AuthToken, PersonalAccessTokenWithOwner } from './dso-token.utils' +import { createHash } from 'node:crypto' +import { tokenHeaderName } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { PrismaService } from '../../database/prisma.service' +import { makeAdminTokenSelect, makePersonalAccessTokenSelect, validateToken } from './dso-token.utils' + +@Injectable() +export class DsoTokenService implements AuthProvider { + private readonly logger = new Logger(DsoTokenService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + return this.authenticateHeaders(request.headers, requirements) + } + + async authenticateHeaders( + headers: FastifyRequest['headers'], + requirements?: AuthRequirements, + ): Promise { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? true + const includeUserType = requirements?.includeUserType ?? true + + const tokenValue = headers[tokenHeaderName] + if (typeof tokenValue !== 'string') { + return undefined + } + + const tokenResult = await this.validateToken(tokenValue, { includeAdminRoleIds, includeUserType }) + if (!tokenResult) return undefined + + const adminPermissions = includeAdminRoleIds + ? await this.resolveAdminPermissions(tokenResult) + : undefined + + return { + userId: tokenResult.userId, + adminPermissions, + userType: tokenResult.userType, + } + } + + async validateToken( + rawToken: string, + requirements: Required, + ): Promise { + this.logger.debug(`validateToken started`) + const hash = createHash('sha256').update(rawToken).digest('hex') + const result = await this.findAndValidateToken(hash, requirements) + if (!result) { + this.logger.warn(`validateToken token not found`) + return undefined + } + this.logger.debug(`validateToken completed (userId=${result.userId})`) + return result + } + + private async findAndValidateToken(hash: string, requirements: Required): Promise { + const personalAccessTokenResult = await this.findAndValidatePersonalAccessToken(hash, requirements) + if (personalAccessTokenResult) { + return personalAccessTokenResult + } + + const adminTokenResult = await this.findAndValidateAdminToken(hash, requirements) + if (adminTokenResult) { + return adminTokenResult + } + + return undefined + } + + private async findAndValidatePersonalAccessToken(hash: string, requirements: Required): Promise { + const pat = await this.prisma.personalAccessToken.findFirst({ + select: makePersonalAccessTokenSelect(requirements), + where: { hash }, + }) satisfies PersonalAccessTokenWithOwner | null + if (pat) { + validateToken(pat) + await this.updateLastUse('personalAccessToken', pat.id, pat.owner.id) + return { + kind: 'personal' as const, + userId: pat.owner.id, + ownerAdminRoleIds: requirements.includeAdminRoleIds ? (pat.owner.adminRoleIds ?? []) : undefined, + userType: requirements.includeUserType ? (pat.owner.type ?? undefined) : undefined, + } + } + + return undefined + } + + private async findAndValidateAdminToken(hash: string, requirements: Required): Promise { + const adminToken = await this.prisma.adminToken.findFirst({ + select: makeAdminTokenSelect(requirements), + where: { hash }, + }) satisfies AdminTokenWithOwner | null + if (adminToken) { + validateToken(adminToken) + await this.updateLastUse('adminToken', adminToken.id, adminToken.owner.id) + return { + kind: 'admin' as const, + userId: adminToken.owner.id, + permissions: adminToken.permissions, + userType: adminToken.owner.type ?? undefined, + } + } + + return undefined + } + + private async updateLastUse(model: 'personalAccessToken' | 'adminToken', tokenId: string, userId: string) { + const now = new Date().toISOString() + if (model === 'personalAccessToken') { + await this.prisma.personalAccessToken.update({ where: { id: tokenId }, data: { lastUse: now } }) + } else { + await this.prisma.adminToken.update({ where: { id: tokenId }, data: { lastUse: now } }) + } + await this.prisma.user.update({ where: { id: userId }, data: { lastLogin: now } }) + } + + private async resolveAdminPermissions(result: AuthToken): Promise { + const globalRoles = await this.prisma.adminRole.findMany({ + where: { type: 'global' }, + select: { permissions: true }, + }) + const globalPerms = globalRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + + if (result.kind === 'admin') { + return globalPerms | result.permissions + } + const ownerAdminRoleIds = result.ownerAdminRoleIds ?? [] + if (!ownerAdminRoleIds.length) { + return globalPerms + } + const roles = await this.prisma.adminRole.findMany({ + select: { permissions: true }, + where: { id: { in: ownerAdminRoleIds } }, + }) + const tokenPerms = roles.reduce((acc, curr) => acc | curr.permissions, 0n) + return globalPerms | tokenPerms + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts new file mode 100644 index 0000000000..297533b572 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/dso-token/dso-token.utils.ts @@ -0,0 +1,72 @@ +import type { AdminToken, Prisma, User } from '@prisma/client' +import type { AuthRequirements } from '../auth.utils' +import { UnauthorizedException } from '@nestjs/common' + +export type AuthToken + = | { kind: 'admin', userId: string, permissions: bigint, userType?: User['type'] } + | { kind: 'personal', userId: string, ownerAdminRoleIds?: string[], userType?: User['type'] } + +export function makeAdminTokenSelect(requirements?: AuthRequirements): Prisma.AdminTokenSelect { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? false + const includeUserType = requirements?.includeUserType ?? false + + return { + id: true, + name: true, + ...(includeAdminRoleIds ? { owner: { select: { adminRoleIds: true } } } : {}), + ...(includeUserType ? { owner: { select: { type: true } } } : {}), + ...(includeAdminRoleIds && includeUserType ? { owner: { select: { adminRoleIds: true, type: true } } } : {}), + } satisfies Prisma.AdminTokenSelect +} + +export function makePersonalAccessTokenSelect(requirements?: AuthRequirements): Prisma.PersonalAccessTokenSelect { + const includeAdminRoleIds = requirements?.includeAdminRoleIds ?? false + const includeUserType = requirements?.includeUserType ?? false + + return { + id: true, + name: true, + owner: { + select: { + id: true, + ...(includeAdminRoleIds ? { adminRoleIds: true } : {}), + ...(includeUserType ? { type: true } : {}), + ...(includeAdminRoleIds && includeUserType ? { adminRoleIds: true, type: true } : {}), + }, + }, + } satisfies Prisma.PersonalAccessTokenSelect +} + +export interface PersonalAccessTokenWithOwner { + id: string + status: AdminToken['status'] + expirationDate: Date | null + owner: { + id: string + adminRoleIds?: string[] | null + type?: User['type'] | null + } +} + +export interface AdminTokenWithOwner { + id: string + status: AdminToken['status'] + expirationDate: Date | null + owner: { + id: string + adminRoleIds?: string[] | null + type?: User['type'] | null + } +} + +export type Token = PersonalAccessTokenWithOwner | AdminTokenWithOwner + +export function validateToken(token: Token): void { + if (token.expirationDate && token.expirationDate < new Date()) { + throw new UnauthorizedException(`Token expired: ${token.expirationDate}`) + } + + if (token.status === 'revoked') { + throw new UnauthorizedException('Token revoked') + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts new file mode 100644 index 0000000000..9b29a144f2 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt-testing.utils.ts @@ -0,0 +1,45 @@ +import { faker } from '@faker-js/faker' + +export function makeMockUser(overrides: Partial<{ + id: string + firstName: string + lastName: string + email: string + createdAt: Date + updatedAt: Date + lastLogin: Date | null + adminRoleIds: string[] + type: 'human' | 'bot' | 'ghost' +}> = {}) { + return { + id: faker.string.uuid(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email().toLowerCase(), + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: null, + adminRoleIds: [], + type: 'human' as const, + ...overrides, + } +} + +export function makeMockAdminRole(overrides: Partial<{ + id: string + name: string + permissions: bigint + position: number + oidcGroup: string + type: string +}> = {}) { + return { + id: faker.string.uuid(), + name: faker.lorem.words(2), + permissions: 1n, + position: 0, + oidcGroup: '', + type: 'managed', + ...overrides, + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts new file mode 100644 index 0000000000..6c7851d92d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common' +import { JwtModule } from '@nestjs/jwt' +import { DatabaseModule } from '../../database/database.module' +import { KeycloakSecretProviderModule } from '../keycloak-secret-provider/keycloak-secret-provider.module' +import { KeycloakSecretProviderService } from '../keycloak-secret-provider/keycloak-secret-provider.service' +import { KeycloakJwtService } from './keycloak-jwt.service' + +@Module({ + imports: [ + DatabaseModule, + JwtModule.registerAsync({ + imports: [KeycloakSecretProviderModule], + inject: [KeycloakSecretProviderService], + useFactory: async (client: KeycloakSecretProviderService) => { + // The issuer is fetched from the openid-configuration endpoint + // rather than reconstructed from env vars, as the server may be + // behind a reverse proxy that differs from the public-facing domain. + const issuer = await client.fetchIssuer() + return { + secretOrKeyProvider: (requestType, tokenOrPayload) => client.getSecret(requestType, tokenOrPayload), + // Keycloak tokens currently do not include an audience claim for this app. + verifyOptions: { + algorithms: ['RS256'], + issuer, + }, + } + }, + }), + ], + providers: [KeycloakJwtService], + exports: [KeycloakJwtService, JwtModule], +}) +export class KeycloakJwtModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts new file mode 100644 index 0000000000..ed2c588caf --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.spec.ts @@ -0,0 +1,170 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { UnauthorizedException } from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../../database/prisma.service' +import { makeAuthRequest } from '../auth-testing.utils' +import { makeMockAdminRole, makeMockUser } from './keycloak-jwt-testing.utils' +import { KeycloakJwtService } from './keycloak-jwt.service' + +describe('keycloakJwtService', () => { + let module: TestingModule + let service: KeycloakJwtService + let prisma: DeepMockProxy + let jwtService: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + jwtService = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + KeycloakJwtService, + { provide: PrismaService, useValue: prisma }, + { provide: JwtService, useValue: jwtService }, + ], + }).compile() + + service = module.get(KeycloakJwtService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('validatePayload', () => { + it('should recompute active admin roles from the current Keycloak group membership', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue( + makeMockUser({ + id: payload.sub, + adminRoleIds: ['stale-oidc-role', 'manual-role'], + }), + ) + prisma.adminRole.findMany.mockResolvedValue([ + makeMockAdminRole({ + id: 'stale-oidc-role', + oidcGroup: '/stale-group', + permissions: 8n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'manual-role', + oidcGroup: '', + permissions: 16n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'current-oidc-role', + oidcGroup: '/current-group', + permissions: 32n, + type: 'managed', + }), + makeMockAdminRole({ + id: 'global-role', + oidcGroup: '', + permissions: 4n, + type: 'global', + }), + ]) + + const result = await service.validatePayload(payload) + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: payload.sub }, + data: { + adminRoleIds: ['manual-role', 'current-oidc-role', 'global-role'], + lastLogin: expect.any(String), + }, + }) + expect(result).toEqual({ + userId: payload.sub, + adminPermissions: 52n, + userType: 'human', + }) + }) + + it('should reject when the local user is missing', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue(null) + + await expect(service.validatePayload(payload)).rejects.toBeInstanceOf(UnauthorizedException) + expect(prisma.adminRole.findMany).not.toHaveBeenCalled() + expect(prisma.user.update).not.toHaveBeenCalled() + }) + + it('should skip admin role resolution when permissions are not required', async () => { + const payload = { + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: ['/current-group'], + } + + prisma.user.findUnique.mockResolvedValue( + makeMockUser({ + id: payload.sub, + type: 'human', + }), + ) + + const result = await service.validatePayload( + payload, + { includeAdminRoleIds: false, includeUserType: true }, + ) + + expect(prisma.adminRole.findMany).not.toHaveBeenCalled() + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: payload.sub }, + data: { + lastLogin: expect.any(String), + }, + }) + expect(result).toEqual({ + userId: payload.sub, + adminPermissions: undefined, + userType: 'human', + }) + }) + }) + + describe('authenticate', () => { + it('should authenticate a bearer token from the request', async () => { + jwtService.verifyAsync.mockResolvedValue({ + sub: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + groups: [], + }) + prisma.user.findUnique.mockResolvedValue(makeMockUser({})) + prisma.adminRole.findMany.mockResolvedValue([]) + + const result = await service.authenticate( + makeAuthRequest({ authorization: 'Bearer jwt-token' }), + ) + + expect(jwtService.verifyAsync).toHaveBeenCalledWith('jwt-token') + expect(result?.userId).toBeDefined() + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts new file mode 100644 index 0000000000..9e1eb00db7 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.service.ts @@ -0,0 +1,123 @@ +import type { Prisma } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { IncomingHttpHeaders } from 'node:http' +import type { UserContext } from '../auth-user.decorator' +import type { AuthProvider, AuthRequirements } from '../auth.utils' +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { z } from 'zod' +import { PrismaService } from '../../database/prisma.service' + +const KeycloakPayloadSchema = z.object({ + sub: z.string(), + email: z.string().optional().default(''), + given_name: z.string().optional().default(''), + family_name: z.string().optional().default(''), + groups: z.array(z.string()).optional().default([]), +}) + +type KeycloakPayload = z.infer + +@Injectable() +export class KeycloakJwtService implements AuthProvider { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(JwtService) private readonly jwtService: JwtService, + ) {} + + async authenticate( + request: FastifyRequest, + requirements?: AuthRequirements, + ): Promise { + return this.authenticateHeaders(request.headers, requirements) + } + + async authenticateHeaders( + headers: IncomingHttpHeaders, + requirements?: AuthRequirements, + ): Promise { + const authHeader = headers.authorization + if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) { + return undefined + } + + try { + const jwt = authHeader.slice(7) + const payload = await this.jwtService.verifyAsync(jwt) + const parsedPayload = KeycloakPayloadSchema.parse(payload) + return await this.validatePayload(parsedPayload, requirements) + } catch (error) { + throw new UnauthorizedException( + error instanceof Error ? error.message : 'Authentication failed', + ) + } + } + + async validatePayload( + payload: KeycloakPayload, + requirements?: AuthRequirements, + ): Promise { + const authRequirements = normalizeRequirements(requirements) + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + select: makeUserSelect(authRequirements), + }) + if (!user) { + throw new UnauthorizedException('Not authenticated') + } + + let adminPermissions: bigint | undefined + let mergedRoleIds: string[] | undefined + + if (authRequirements.includeAdminRoleIds) { + const adminRoleIds = 'adminRoleIds' in user ? user.adminRoleIds : [] + const groups = payload.groups + + const matchingAdminRoles = await this.prisma.adminRole.findMany({ + where: { + OR: [ + { oidcGroup: { in: groups } }, + { id: { in: adminRoleIds } }, + { type: 'global' }, + ], + }, + }) + + const activeAdminRoles = matchingAdminRoles.filter(({ oidcGroup, type }) => + type === 'global' || !oidcGroup || groups.includes(oidcGroup), + ) + + mergedRoleIds = [...new Set(activeAdminRoles.map(({ id }) => id))] + adminPermissions = activeAdminRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + } + + await this.prisma.user.update({ + where: { id: payload.sub }, + data: { + ...(mergedRoleIds ? { adminRoleIds: mergedRoleIds } : {}), + lastLogin: new Date().toISOString(), + }, + }) + + return { + userId: payload.sub, + adminPermissions, + userType: 'type' in user ? user.type : undefined, + } + } +} + +function makeUserSelect(requirements: Required) { + return { + id: true, + ...(requirements.includeAdminRoleIds ? { adminRoleIds: true } : {}), + ...(requirements.includeUserType ? { type: true } : {}), + } satisfies Prisma.UserSelect +} + +function normalizeRequirements(requirements: AuthRequirements = {}): Required { + return { + includeAdminRoleIds: requirements.includeAdminRoleIds ?? true, + includeUserType: requirements.includeUserType ?? true, + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts new file mode 100644 index 0000000000..4c5ef71a1b --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider-testing.utils.ts @@ -0,0 +1,22 @@ +import { generateKeyPairSync } from 'node:crypto' + +export function makeJwksResponse(kid: string): Response { + const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }) + const jwk = publicKey.export({ format: 'jwk' }) + return new Response(JSON.stringify({ + keys: [ + { + kid, + kty: 'RSA', + use: 'sig', + n: jwk.n, + e: jwk.e, + }, + ], + }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts new file mode 100644 index 0000000000..42f606a58c --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.constants.ts @@ -0,0 +1,2 @@ +export const KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX = 'jwks:' +export const KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY = `openid-configuration:` diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts new file mode 100644 index 0000000000..602419cdcf --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts @@ -0,0 +1,15 @@ +import { CacheModule } from '@nestjs/cache-manager' +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../../config/keycloak.config' +import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' + +@Module({ + imports: [ + CacheModule.register(), + ConfigModule.forFeature(keycloakConfigFactory), + ], + providers: [KeycloakSecretProviderService], + exports: [KeycloakSecretProviderService], +}) +export class KeycloakSecretProviderModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts new file mode 100644 index 0000000000..9d7e632122 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts @@ -0,0 +1,211 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { KeycloakConfig } from '../../../keycloak/keycloak.module-definition' +import { faker } from '@faker-js/faker' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { JwtSecretRequestType } from '@nestjs/jwt' +import { Test } from '@nestjs/testing' +import { createCache } from 'cache-manager' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KEYCLOAK_CONFIG } from '../../../keycloak/keycloak.module-definition' +import { makeJwksResponse } from './keycloak-secret-provider-testing.utils' +import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' +import { createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' + +describe('keycloakSecretProviderService', () => { + let module: TestingModule + let service: KeycloakSecretProviderService + let config: DeepMockProxy + let fetchMock: ReturnType + let cache: ReturnType + + beforeEach(async () => { + const keycloakDomain = faker.internet.domainName() + const keycloakRealm = faker.lorem.word() + const openidConfigurationUrl = `https://${keycloakDomain}/realms/${keycloakRealm}/.well-known/openid-configuration` + + config = mockDeep({ + protocol: 'https', + domain: keycloakDomain, + realm: keycloakRealm, + openidConfigurationUrl, + jwksTimeoutMs: 1_000, + jwksCacheTtlMs: 300_000, + openidConfigurationCacheTtlMs: 300_000, + }) + fetchMock = vi.fn() + cache = createCache() + + vi.clearAllMocks() + vi.unstubAllGlobals() + vi.stubGlobal('fetch', fetchMock) + + module = await Test.createTestingModule({ + providers: [ + KeycloakSecretProviderService, + { provide: KEYCLOAK_CONFIG, useValue: config }, + { provide: CACHE_MANAGER, useValue: cache }, + ], + }).compile() + + service = module.get(KeycloakSecretProviderService) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should fetch JWKS from Keycloak and parse the response', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + const internalJwksUri = `${config.protocol}://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-1')) + + const jwks = await service.fetchSigningKeys() + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[0]?.[0]).toBe(config.openidConfigurationUrl) + expect(fetchMock.mock.calls[1]?.[0]).toBe(internalJwksUri) + expect(jwks).toEqual({ + keys: [ + { + kid: 'kid-1', + kty: 'RSA', + use: 'sig', + n: expect.any(String), + e: expect.any(String), + }, + ], + }) + }) + + it('should abort and return undefined when the JWKS request exceeds the timeout', async () => { + vi.useFakeTimers() + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockImplementationOnce((_url, init?: RequestInit) => new Promise((_, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) + })) + + const promise = service.fetchSigningKeys() + await vi.advanceTimersByTimeAsync(1_000) + + await expect(promise).resolves.toBeUndefined() + }) + + it('should return undefined when Keycloak returns a non-OK response', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(new Response('', { status: 500, statusText: 'Internal Server Error' })) + + await expect(service.fetchSigningKeys()).resolves.toBeUndefined() + }) + + it('should resolve a PEM public key from the JWKS', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-2')) + + const publicKey = await service.fetchPublicKey('kid-2') + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(publicKey).toContain('BEGIN RSA PUBLIC KEY') + expect(await cache.get(createKeycloakSecretProviderPublicKeyCacheKey('kid-2'))).toBe(publicKey) + }) + + it('should reuse the cached PEM public key', async () => { + await cache.set( + createKeycloakSecretProviderPublicKeyCacheKey('cached-kid'), + '-----BEGIN RSA PUBLIC KEY-----\ncached\n-----END RSA PUBLIC KEY-----', + ) + + const publicKey = await service.fetchPublicKey('cached-kid') + + expect(fetchMock).not.toHaveBeenCalled() + expect(publicKey).toBe('-----BEGIN RSA PUBLIC KEY-----\ncached\n-----END RSA PUBLIC KEY-----') + }) + + it('should resolve the secret directly from the JWT token and request type', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-3')) + const header = Buffer.from(JSON.stringify({ kid: 'kid-3' })).toString('base64url') + + const secret = await service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(secret).toContain('BEGIN RSA PUBLIC KEY') + }) + + it('should reject signing requests', async () => { + await expect(service.getSecret(JwtSecretRequestType.SIGN, 'payload')).rejects.toThrow('Signing is not supported') + }) + + it('should reject non-string tokens', async () => { + await expect(service.getSecret(JwtSecretRequestType.VERIFY, Buffer.from('payload'))).rejects.toThrow( + 'Unsupported token type', + ) + }) + + it('should reject malformed JWTs', async () => { + await expect(service.getSecret(JwtSecretRequestType.VERIFY, 'only.two')).rejects.toThrow('Invalid JWT format') + }) + + it('should reject JWTs without a kid header', async () => { + const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url') + + await expect(service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`)).rejects.toThrow( + 'Missing kid', + ) + }) + + it('should reject JWTs when the key cannot be resolved', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ keys: [] }))) + const header = Buffer.from(JSON.stringify({ kid: 'missing-kid' })).toString('base64url') + + await expect(service.getSecret(JwtSecretRequestType.VERIFY, `${header}.payload.sig`)).rejects.toThrow( + 'Unknown signing key', + ) + }) + + it('should resolve the issuer from openid-configuration', async () => { + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) + + await expect(service.fetchIssuer()).resolves.toBe(issuer) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('should replace the discovered JWKS domain with the configured internal Keycloak domain', async () => { + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + issuer: `https://${config.domain}/realms/${config.realm}`, + jwks_uri: publicJwksUri, + }))) + + await expect(service.fetchJwksUri()).resolves.toBe( + `https://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs`, + ) + }) + + it('should keep the discovered JWKS URI unchanged when no internal Keycloak domain is configured', async () => { + config.domain = undefined + const publicJwksUri = `https://public.example.test/realms/${config.realm}/protocol/openid-connect/certs` + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + issuer: 'https://public.example.test/realms/test', + jwks_uri: publicJwksUri, + }))) + + await expect(service.fetchJwksUri()).resolves.toBe(publicJwksUri) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts new file mode 100644 index 0000000000..69a19bb5fa --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts @@ -0,0 +1,162 @@ +import type { Cache } from 'cache-manager' +import type { KeycloakConfig } from '../../../keycloak/keycloak.module-definition' +import { createPublicKey } from 'node:crypto' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { JwtSecretRequestType } from '@nestjs/jwt' +import { z } from 'zod' +import { KEYCLOAK_CONFIG } from '../../../keycloak/keycloak.module-definition' +import { createKeycloakSecretProviderOpenIdConfigurationCacheKey, createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' + +const OpenidConfigurationSchema = z.object({ + issuer: z.string().url(), + jwks_uri: z.string().url(), +}) + +type OpenidConfiguration = z.infer + +const JwksResponseSchema = z.object({ + keys: z.array(z.object({ + kid: z.string(), + kty: z.string(), + use: z.string(), + n: z.string(), + e: z.string(), + })), +}) + +type JwksResponse = z.infer + +const JwtHeaderSchema = z.object({ + kid: z.string(), +}) + +@Injectable() +export class KeycloakSecretProviderService { + private readonly logger = new Logger(KeycloakSecretProviderService.name) + + constructor( + @Inject(CACHE_MANAGER) private readonly cache: Cache, + @Inject(KEYCLOAK_CONFIG) private readonly keycloakConfig: KeycloakConfig, + ) {} + + async fetchOpenIdConfig(): Promise { + const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(this.keycloakConfig.openidConfigurationUrl) + const cached = await this.cache.get(cacheKey) + if (cached) return cached + + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) + if (!response.ok) { + this.logger.error(`Failed to fetch openid-configuration: ${response.status} ${response.statusText}`) + return undefined + } + + const raw = await response.json() + const config = OpenidConfigurationSchema.safeParse(raw) + if (!config.success) { + this.logger.error('openid-configuration response missing jwks_uri') + return undefined + } + + await this.cache.set(cacheKey, config.data, this.keycloakConfig.openidConfigurationCacheTtlMs) + return config.data + } + + async fetchIssuer(): Promise { + const config = await this.fetchOpenIdConfig() + return config?.issuer + } + + async fetchJwksUri(): Promise { + const config = await this.fetchOpenIdConfig() + return config ? this.replaceJwksUriDomainWithInternalDomain(config.jwks_uri) : undefined + } + + private replaceJwksUriDomainWithInternalDomain(jwksUri: string): string { + if (!this.keycloakConfig.domain) return jwksUri + const url = new URL(jwksUri) + url.protocol = this.keycloakConfig.protocol + url.host = this.keycloakConfig.domain + this.logger.log(`Replacing JWKS URI domain: ${jwksUri} -> ${url.toString()}`) + return url.toString() + } + + async fetchSigningKeys(): Promise { + const jwksUri = await this.fetchJwksUri() + if (!jwksUri) return undefined + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), this.keycloakConfig.jwksTimeoutMs) + + try { + const response = await fetch(jwksUri, { signal: controller.signal }) + if (!response.ok) { + this.logger.error(`Failed to fetch JWKS: ${response.status} ${response.statusText}`) + return undefined + } + + const raw = await response.json() + return JwksResponseSchema.parse(raw) + } catch (error) { + this.logger.error(`Failed to fetch JWKS: ${error instanceof Error ? error.message : String(error)}`) + return undefined + } finally { + clearTimeout(timeout) + } + } + + async fetchPublicKey(kid: string): Promise { + const cacheKey = createKeycloakSecretProviderPublicKeyCacheKey(kid) + const cached = await this.cache.get(cacheKey) + if (cached) return cached + + const jwks = await this.fetchSigningKeys() + if (!jwks) return undefined + + const key = jwks.keys.find(candidate => candidate.kid === kid && candidate.use === 'sig' && candidate.kty === 'RSA') + if (!key) return undefined + + const publicKey = createPublicKey({ + key: { kty: 'RSA', n: key.n, e: key.e }, + format: 'jwk', + }) + + const pem = publicKey.export({ format: 'pem', type: 'pkcs1' }) as string + await this.cache.set(cacheKey, pem, this.keycloakConfig.jwksCacheTtlMs) + return pem + } + + async getSecret( + requestType: JwtSecretRequestType, + tokenOrPayload: string | object | Buffer, + ): Promise { + if (requestType === JwtSecretRequestType.SIGN) { + throw new TypeError('Signing is not supported') + } + + if (typeof tokenOrPayload !== 'string') { + throw new TypeError('Unsupported token type') + } + + const parts = tokenOrPayload.split('.') + if (parts.length !== 3) { + throw new TypeError('Invalid JWT format') + } + + const rawHeader = JSON.parse( + Buffer.from(parts[0].replaceAll('-', '+').replaceAll('_', '/'), 'base64').toString(), + ) + const header = JwtHeaderSchema.safeParse(rawHeader) + if (!header.success) { + throw new TypeError('Missing kid') + } + const { kid } = header.data + + const publicKey = await this.fetchPublicKey(kid) + if (!publicKey) { + throw new Error('Unknown signing key') + } + + return publicKey + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts new file mode 100644 index 0000000000..b13beeed98 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.utils.ts @@ -0,0 +1,9 @@ +import { KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY, KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX } from './keycloak-secret-provider.constants' + +export function createKeycloakSecretProviderPublicKeyCacheKey(kid: string): string { + return `${KEYCLOAK_SECRET_PROVIDER_PUBLIC_KEY_CACHE_PREFIX}${kid}` +} + +export function createKeycloakSecretProviderOpenIdConfigurationCacheKey(url: string): string { + return `${KEYCLOAK_SECRET_PROVIDER_OPENID_CONFIGURATION_CACHE_KEY}${url}` +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts new file mode 100644 index 0000000000..d1853a3219 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts @@ -0,0 +1,27 @@ +export interface BaseConfig { + nodeEnv: 'development' | 'production' | 'test' + isTest: boolean + isDev: boolean + isCI: boolean + isProd: boolean + integration: boolean + ci: boolean + devSetup: boolean + docker: boolean + serverHost: string + serverPort: number + appVersion: string + dbUrl?: string + sessionSecret?: string + contactEmail: string + mockPlugins: boolean + projectsRootDir?: string + pluginsDir: string + httpProxy?: string + httpsProxy?: string +} + +// Runtime config (BASE_CONFIG token) is owned by the global config/base.config.ts +// (which calls registerAs('base', ...)); re-exported here so services keep a single +// stable import surface (the module) while config depends on the module. +export { BASE_CONFIG } from './base.module' diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts new file mode 100644 index 0000000000..5ee8150bd8 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts @@ -0,0 +1,13 @@ +import type { BaseConfig } from './base.config' +import { Global, Module } from '@nestjs/common' +import { createConfigurableModule } from '../../configurable-feature-module' + +export interface BaseModuleOptions extends BaseConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('base', true) + +@Global() +@Module({}) +export class BaseModule extends ConfigurableModuleClass {} + +export const BASE_CONFIG = MODULE_OPTIONS_TOKEN diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts new file mode 100644 index 0000000000..7c42430723 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts @@ -0,0 +1,52 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import { HealthIndicatorService } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { DatabaseHealthService } from './database-health.service' +import { PrismaService } from './prisma.service' + +describe('databaseHealthService', () => { + let service: DatabaseHealthService + let prisma: DeepMockProxy + let indicatorSession: { up: ReturnType, down: ReturnType } + + beforeEach(async () => { + prisma = mockDeep() + const healthIndicatorMock = mockDeep() + indicatorSession = { + up: vi.fn().mockReturnValue({ status: 'up' }), + down: vi.fn().mockReturnValue({ status: 'down' }), + } + vi.mocked(healthIndicatorMock.check).mockReturnValue(indicatorSession as any) + + const module = await Test.createTestingModule({ + providers: [ + DatabaseHealthService, + { provide: PrismaService, useValue: prisma }, + { provide: HealthIndicatorService, useValue: healthIndicatorMock }, + ], + }).compile() + + service = module.get(DatabaseHealthService) + }) + + it('reports up when the query succeeds', async () => { + prisma.$queryRaw.mockResolvedValueOnce(undefined as any) + + const result = await service.check('database') + + expect(prisma.$queryRaw).toHaveBeenCalled() + expect(indicatorSession.up).toHaveBeenCalled() + expect(result).toEqual({ status: 'up' }) + }) + + it('reports down when the query throws', async () => { + prisma.$queryRaw.mockRejectedValueOnce(new Error('boom')) + + const result = await service.check('database') + + expect(indicatorSession.down).toHaveBeenCalledWith({ message: 'boom' }) + expect(result).toEqual({ status: 'down' }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts new file mode 100644 index 0000000000..1e37899c9b --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.ts @@ -0,0 +1,23 @@ +import { Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { PrismaService } from './prisma.service' + +@Injectable() +export class DatabaseHealthService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + await this.prisma.$queryRaw`SELECT 1` + return indicator.up() + } catch (error) { + return indicator.down({ + message: error instanceof Error ? error.message : String(error), + }) + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts new file mode 100644 index 0000000000..7e909376ec --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseHealthService } from './database-health.service' +import { DatabaseService } from './database.service' +import { PrismaService } from './prisma.service' + +@Module({ + imports: [TerminusModule], + providers: [DatabaseHealthService, DatabaseService, PrismaService], + exports: [DatabaseHealthService, DatabaseService, PrismaService], +}) +export class DatabaseModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts new file mode 100644 index 0000000000..3f373ba1eb --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts @@ -0,0 +1,30 @@ +import type { ConfigType } from '@nestjs/config' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../config/base.config' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../../infrastructure/config/base.config' +import { DatabaseService } from './database.service' +import { PrismaService } from './prisma.service' + +describe('databaseService', () => { + let service: DatabaseService + + beforeEach(async () => { + const baseConfig = mockDeep() + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabaseService, + { provide: PrismaService, useValue: mockDeep() }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(DatabaseService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts new file mode 100644 index 0000000000..5d9d0c32f7 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts @@ -0,0 +1,73 @@ +import type { BaseConfig } from '../../infrastructure/config/base.config' +import { setTimeout } from 'node:timers/promises' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { BASE_CONFIG } from '../../infrastructure/config/base.config' +import { PrismaService } from './prisma.service' + +@Injectable() +export class DatabaseService { + private readonly loggerService = new Logger(DatabaseService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(BASE_CONFIG) private readonly configurationService: BaseConfig, + ) { + this.DELAY_BEFORE_RETRY + = this.configurationService.isTest || this.configurationService.isCI + ? 1000 + : 10000 + } + + DELAY_BEFORE_RETRY!: number + closingConnections = false + + async getConnection(triesLeft = 5): Promise { + if (this.closingConnections || triesLeft <= 0) { + throw new Error('Unable to connect to Postgres server') + } + triesLeft-- + + try { + if ( + this.configurationService.isDev + || this.configurationService.isTest + || this.configurationService.isCI + ) { + this.loggerService.log( + `Trying to connect to Postgres with: ${this.configurationService.dbUrl}`, + ) + } + await this.prisma.$connect() + + this.loggerService.log('Connected to Postgres!') + } catch (error) { + if (triesLeft > 0) { + this.loggerService.error(error) + this.loggerService.log( + `Could not connect to Postgres: ${error.message}`, + ) + this.loggerService.log(`Retrying (${triesLeft} tries left)`) + await setTimeout(this.DELAY_BEFORE_RETRY) + return this.getConnection(triesLeft) + } + + this.loggerService.log( + `Could not connect to Postgres: ${error.message}`, + ) + this.loggerService.log('Out of retries') + error.message = `Out of retries, last error: ${error.message}` + throw error + } + } + + async closeConnections() { + this.closingConnections = true + try { + await this.prisma.$disconnect() + } catch (error) { + this.loggerService.error(error) + } finally { + this.closingConnections = false + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts new file mode 100644 index 0000000000..f10a31e573 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/database/prisma.service.ts @@ -0,0 +1,14 @@ +import type { OnModuleDestroy, OnModuleInit } from '@nestjs/common' +import { Injectable } from '@nestjs/common' +import { PrismaClient } from '@prisma/client' + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect() + } + + async onModuleDestroy() { + await this.$disconnect() + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts new file mode 100644 index 0000000000..8eb62bea67 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitterModule } from '@nestjs/event-emitter' +import { baseConfigFactory } from '../../../config/base.config' + +@Module({ + imports: [EventEmitterModule.forRoot(), ConfigModule.forFeature(baseConfigFactory)], + exports: [EventEmitterModule], +}) +export class EventsModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts new file mode 100644 index 0000000000..25b2a4e695 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from './auth/auth.module' +import { DatabaseModule } from './database/database.module' +import { EventsModule } from './events/events.module' +import { LoggerModule } from './logger/logger.module' +import { PermissionModule } from './permission/permission.module' + +@Module({ + providers: [], + imports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + exports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], +}) +export class InfrastructureModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts new file mode 100644 index 0000000000..257ef429f8 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts @@ -0,0 +1,37 @@ +import type { BaseConfig } from '../config/base.config' +import { getLoggerOptions } from '@cpn-console/logger' +import { Module } from '@nestjs/common' +import { LoggerModule as PinoLoggerModule } from 'nestjs-pino' +import { BASE_CONFIG } from '../config/base.config' + +@Module({ + imports: [ + PinoLoggerModule.forRootAsync({ + imports: [], + inject: [BASE_CONFIG], + useFactory: async (baseConfig: BaseConfig) => { + return { + pinoHttp: { + ...getLoggerOptions(baseConfig.isProd ? 'production' : 'development', baseConfig.isTest ? 'info' : 'debug'), + customLogLevel: (req, res, err) => { + if (err || res.statusCode >= 500) { + return 'error' + } + if (res.statusCode >= 400) { + return 'warn' + } + // kube liveness/readiness probes hit healthz constantly, only log it on failure + if (req.url?.split('?')[0] === '/api/v1/healthz') { + return 'silent' + } + return 'info' + }, + }, + } + }, + }), + ], + controllers: [], + providers: [], +}) +export class LoggerModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts new file mode 100644 index 0000000000..c2f926a351 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/permission.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { ProjectPermissionModule } from './project/project.module' +import { UserPermissionModule } from './user/user.module' + +@Module({ + imports: [UserPermissionModule, ProjectPermissionModule], + exports: [UserPermissionModule, ProjectPermissionModule], +}) +export class PermissionModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts new file mode 100644 index 0000000000..7863f5ad4b --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-access.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_ACCESS_KEY = 'project-access' + +export function RequireProjectAccess() { + return SetMetadata(PROJECT_ACCESS_KEY, true) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts new file mode 100644 index 0000000000..ee11a3df91 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.service.ts @@ -0,0 +1,52 @@ +import type { FastifyRequest } from 'fastify' +import type { ProjectRequirements } from './project-loader.utils' +import type { ProjectContext } from './project.guard' +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../../database/prisma.service' +import { isUuid, makeProjectSelect, resolveProjectPermissions } from './project-loader.utils' + +type RequestWithProjectParams = FastifyRequest<{ Params?: ProjectParams }> + +interface ProjectParams { + projectId?: string + projectSlug?: string +} + +@Injectable() +export class ProjectPermissionLoaderService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async load(request: RequestWithProjectParams, userId: string, requirements?: ProjectRequirements): Promise { + let where: { id: string } | { slug: string } + if (request.params?.projectId) { + where = isUuid(request.params.projectId) ? { id: request.params.projectId } : { slug: request.params.projectId } + } else if (request.params?.projectSlug) { + where = { slug: request.params.projectSlug } + } else { + throw new BadRequestException('projectId or projectSlug is required') + } + + const raw = await this.prisma.project.findUnique({ + where, + select: makeProjectSelect(requirements), + }) + if (!raw) throw new NotFoundException('Projet introuvable') + + const project: ProjectContext = { + id: raw.id, + slug: raw.slug, + } + + if (requirements?.includeStatus) { + project.status = raw.status + } + if (requirements?.includeLocked) { + project.locked = raw.locked + } + if (requirements?.includePermissions) { + project.projectPermissions = resolveProjectPermissions(raw, userId) + } + + return project + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts new file mode 100644 index 0000000000..16e48b3fac --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-loader.utils.ts @@ -0,0 +1,53 @@ +import type { Prisma } from '@prisma/client' +import { PROJECT_PERMS } from '@cpn-console/shared' + +export interface ProjectRequirements { + includeStatus?: boolean + includeLocked?: boolean + includePermissions?: boolean +} + +export function makeProjectSelect(requirements?: ProjectRequirements): Prisma.ProjectSelect { + const includePermissions = requirements?.includePermissions ?? true + const includeStatus = requirements?.includeStatus ?? true + const includeLocked = requirements?.includeLocked ?? true + + return { + id: true, + slug: true, + ...(includeStatus ? { status: true } : {}), + ...(includeLocked ? { locked: true } : {}), + ...(includePermissions + ? { + ownerId: true, + everyonePerms: true, + roles: { select: { id: true, permissions: true } }, + members: { select: { userId: true, roleIds: true } }, + } + : {}), + } satisfies Prisma.ProjectSelect +} + +export function resolveProjectPermissions( + raw: { ownerId: string, everyonePerms: bigint, roles: Array<{ id: string, permissions: bigint }>, members: Array<{ userId: string, roleIds: string[] }> }, + userId: string, +): bigint { + if (raw.ownerId === userId) { + return PROJECT_PERMS.MANAGE + } + + const member = raw.members.find(m => m.userId === userId) + if (!member) { + return 0n + } + + const memberRoles = raw.roles.filter(role => member.roleIds.includes(role.id)) + return memberRoles.reduce( + (acc, curr) => acc | curr.permissions, + raw.everyonePerms | PROJECT_PERMS.GUEST, + ) +} + +export function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts new file mode 100644 index 0000000000..ac63012cc3 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-locked.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_LOCKED_KEY = 'project-locked' + +export function RequireProjectLocked(locked: boolean) { + return SetMetadata(PROJECT_LOCKED_KEY, locked) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts new file mode 100644 index 0000000000..da064283ff --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-permission.decorator.ts @@ -0,0 +1,8 @@ +import type { ProjectAuthorized } from '@cpn-console/shared' +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_PERMISSION_KEY = 'project-permission' + +export function RequireProjectPermission(...permissions: (keyof typeof ProjectAuthorized)[]) { + return SetMetadata(PROJECT_PERMISSION_KEY, permissions) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts new file mode 100644 index 0000000000..f1b2f05f48 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project-status.decorator.ts @@ -0,0 +1,8 @@ +import type { Project } from '@prisma/client' +import { SetMetadata } from '@nestjs/common' + +export const PROJECT_STATUS_KEY = 'project-status' + +export function RequireProjectStatus(...statuses: Project['status'][]) { + return SetMetadata(PROJECT_STATUS_KEY, statuses) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts new file mode 100644 index 0000000000..921cffd845 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.decorator.ts @@ -0,0 +1,14 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { FastifyRequest } from 'fastify' +import type { ProjectContext } from './project.guard' +import { createParamDecorator } from '@nestjs/common' + +export const Project = createParamDecorator( + (_: unknown, ctx: ExecutionContext): ProjectContext => { + const request = ctx.switchToHttp().getRequest() + if (!request.project) { + throw new Error('Project context is missing from the request') + } + return request.project + }, +) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts new file mode 100644 index 0000000000..8d4a5ba4c6 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.spec.ts @@ -0,0 +1,63 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ProjectContext } from './project.guard' +import { UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AuthService } from '../../auth/auth.service' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectGuard } from './project.guard' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' +import { makeExecutionContext, makeProjectContext, makeProjectPolicy } from './project.testing.utils' + +describe('projectGuard', () => { + let module: TestingModule + let guard: ProjectGuard + let authService: DeepMockProxy + let projectService: DeepMockProxy + let projectPolicy: DeepMockProxy + let loader: DeepMockProxy + + beforeEach(async () => { + authService = mockDeep() + projectService = mockDeep() + projectPolicy = mockDeep() + loader = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectGuard, + { provide: AuthService, useValue: authService }, + { provide: ProjectPermissionService, useValue: projectService }, + { provide: ProjectPermissionPolicy, useValue: projectPolicy }, + { provide: ProjectPermissionLoaderService, useValue: loader }, + ], + }).compile() + + guard = module.get(ProjectGuard) + }) + + it('throws 401 when userId is not set on the request', async () => { + projectPolicy.build.mockReturnValue(makeProjectPolicy()) + authService.authenticate.mockRejectedValue(new UnauthorizedException()) + const ctx = makeExecutionContext({}) + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException) + }) + + it('loads project and validates', async () => { + const project: ProjectContext = makeProjectContext({}) + const policy = makeProjectPolicy({ projectPermissions: ['Manage'] }) + projectPolicy.build.mockReturnValue(policy) + authService.authenticate.mockResolvedValue({ userId: 'member1', adminPermissions: 0n }) + loader.load.mockResolvedValue(project) + const request = { userId: 'member1', adminPermissions: 0n, params: { projectId: 'p1' } } + const ctx = makeExecutionContext(request) + + const result = await guard.canActivate(ctx) + + expect(result).toBe(true) + expect(loader.load).toHaveBeenCalledWith(request, 'member1', expect.anything()) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts new file mode 100644 index 0000000000..b5ef931868 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.guard.ts @@ -0,0 +1,94 @@ +import type { CanActivate, ExecutionContext } from '@nestjs/common' +import type { Project } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../../auth/auth-user.decorator' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { AuthService } from '../../auth/auth.service' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' + +export interface ProjectContext { + id: string + slug: string + locked?: boolean + status?: Project['status'] + projectPermissions?: bigint +} + +export interface RequestWithProjectContext extends FastifyRequest { + project?: ProjectContext +} + +type RequestWithUserContext = FastifyRequest & { + userId?: string + adminPermissions?: bigint + userType?: string +} + +interface ProjectParams { + projectId?: string + projectSlug?: string +} + +@Injectable() +export class ProjectGuard implements CanActivate { + private readonly logger = new Logger(ProjectGuard.name) + + constructor( + @Inject(AuthService) private readonly authService: AuthService, + @Inject(ProjectPermissionService) private readonly projectService: ProjectPermissionService, + @Inject(ProjectPermissionLoaderService) private readonly loader: ProjectPermissionLoaderService, + @Inject(ProjectPermissionPolicy) private readonly projectPolicy: ProjectPermissionPolicy, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const policy = this.projectPolicy.build(context) + const request = this.getRequest(context) + const user = await this.authenticate(request) + const project = await this.loadProject(request, user.userId) + + this.projectService.validate(policy, project, user) + + return true + } + + private getRequest(context: ExecutionContext): RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }> { + return context.switchToHttp().getRequest< + RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }> + >() + } + + private async authenticate( + request: RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }>, + ): Promise { + try { + const user = await this.authService.authenticate(request, { + includeAdminRoleIds: true, + includeUserType: true, + }) + + request.userId = user.userId + if (user.adminPermissions !== undefined) request.adminPermissions = user.adminPermissions + if (user.userType !== undefined) request.userType = user.userType + + return user + } catch (error) { + this.logger.warn(`Project access auth rejected (requestId=${request.id}, error=${error instanceof Error ? error.message : String(error)})`) + throw error + } + } + + private async loadProject( + request: RequestWithProjectContext & RequestWithUserContext & FastifyRequest<{ Params?: ProjectParams }>, + userId: string, + ): Promise { + const project = await this.loader.load(request, userId, { + includeStatus: true, + includeLocked: true, + includePermissions: true, + }) + request.project = project + return project + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts new file mode 100644 index 0000000000..6561dd78dd --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../../auth/auth.module' +import { DatabaseModule } from '../../database/database.module' +import { ProjectPermissionLoaderService } from './project-loader.service' +import { ProjectGuard } from './project.guard' +import { ProjectPermissionPolicy } from './project.policy' +import { ProjectPermissionService } from './project.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + ], + providers: [ + ProjectGuard, + ProjectPermissionLoaderService, + ProjectPermissionService, + ProjectPermissionPolicy, + ], + exports: [ + ProjectGuard, + ProjectPermissionLoaderService, + ProjectPermissionService, + ProjectPermissionPolicy, + ], +}) +export class ProjectPermissionModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts new file mode 100644 index 0000000000..9d4098d93e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.policy.ts @@ -0,0 +1,37 @@ +import type { AdminAuthorized, ProjectAuthorized } from '@cpn-console/shared' +import type { ExecutionContext } from '@nestjs/common' +import type { Project, User } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { ADMIN_PERMISSIONS_KEY } from '../user/user-admin-permission.decorator' +import { USER_TYPES_KEY } from '../user/user-type.decorator' +import { PROJECT_ACCESS_KEY } from './project-access.decorator' +import { PROJECT_LOCKED_KEY } from './project-locked.decorator' +import { PROJECT_PERMISSION_KEY } from './project-permission.decorator' +import { PROJECT_STATUS_KEY } from './project-status.decorator' + +export interface ProjectPolicyConfig { + adminPermissions: (keyof typeof AdminAuthorized)[] + userTypes: User['type'][] + projectPermissions: (keyof typeof ProjectAuthorized)[] + projectStatuses: Project['status'][] + projectLocked?: boolean + projectAccess: boolean +} + +@Injectable() +export class ProjectPermissionPolicy { + constructor(@Inject(Reflector) private readonly reflector: Reflector) {} + + build(context: ExecutionContext): ProjectPolicyConfig { + const targets = [context.getHandler(), context.getClass()] + return { + adminPermissions: this.reflector.getAllAndOverride<(keyof typeof AdminAuthorized)[] | undefined>(ADMIN_PERMISSIONS_KEY, targets) ?? [], + userTypes: this.reflector.getAllAndOverride(USER_TYPES_KEY, targets) ?? [], + projectPermissions: this.reflector.getAllAndOverride<(keyof typeof ProjectAuthorized)[] | undefined>(PROJECT_PERMISSION_KEY, targets) ?? [], + projectStatuses: this.reflector.getAllAndOverride(PROJECT_STATUS_KEY, targets) ?? [], + projectLocked: this.reflector.getAllAndOverride(PROJECT_LOCKED_KEY, targets), + projectAccess: this.reflector.getAllAndOverride(PROJECT_ACCESS_KEY, targets) ?? false, + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts new file mode 100644 index 0000000000..f25771dd42 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.service.ts @@ -0,0 +1,72 @@ +import type { ProjectStatus, User } from '@prisma/client' +import type { UserContext } from '../../auth/auth-user.decorator' +import type { ProjectContext } from './project.guard' +import type { ProjectPolicyConfig } from './project.policy' +import { AdminAuthorized, ProjectAuthorized } from '@cpn-console/shared' +import { ForbiddenException, Injectable, Logger } from '@nestjs/common' + +@Injectable() +export class ProjectPermissionService { + private readonly logger = new Logger(ProjectPermissionService.name) + + validate(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + this.validateUserType(policy, user.userType) + this.validateProjectStatus(policy, project.status) + this.validateProjectLock(policy, project.locked) + this.validateProjectAccess(policy, project, user) + this.validateProjectPermissions(policy, project, user) + } + + validateProjectStatus(policy: ProjectPolicyConfig, projectStatus: ProjectStatus | undefined): void { + if (policy.projectStatuses.length > 0 && (!projectStatus || !policy.projectStatuses.includes(projectStatus))) { + this.logger.warn(`project auth denied: invalid project status (projectStatus=${projectStatus}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Statut de projet invalide') + } + } + + validateProjectLock(policy: ProjectPolicyConfig, projectLocked: boolean | undefined): void { + if (policy.projectLocked !== undefined && projectLocked !== policy.projectLocked) { + this.logger.warn(`project auth denied: invalid lock status (projectLocked=${projectLocked}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('État de verrouillage invalide') + } + } + + validateUserType(policy: ProjectPolicyConfig, userType: string | undefined): void { + if (!policy.userTypes.length) return + if (typeof userType !== 'string' || !policy.userTypes.includes(userType as User['type'])) { + this.logger.warn(`project auth denied: invalid user type (userType=${userType}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Cannot find requestor in request context') + } + } + + validateProjectAccess(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + if (!policy.projectAccess) return + + const adminPermissions = user.adminPermissions ?? 0n + const hasAccess = AdminAuthorized.Manage(adminPermissions) || Boolean(project.projectPermissions) + if (!hasAccess) { + this.logger.warn( + `project auth denied: missing project access (projectId=${project.id}, projectSlug=${project.slug}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, projectPermissions=${project.projectPermissions?.toString()}, policy=${JSON.stringify(policy)})`, + ) + throw new ForbiddenException('Permissions insuffisantes pour ce projet') + } + } + + validateProjectPermissions(policy: ProjectPolicyConfig, project: ProjectContext, user: UserContext): void { + if (!policy.projectPermissions.length) return + + const hasPermission = policy.projectPermissions.every( + permName => ProjectAuthorized[permName]({ + adminPermissions: user.adminPermissions, + projectPermissions: project.projectPermissions, + }), + ) + + if (!hasPermission) { + this.logger.warn( + `project auth denied: missing project permissions (projectId=${project.id}, projectSlug=${project.slug}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, projectPermissions=${project.projectPermissions?.toString()}, policy=${JSON.stringify(policy)})`, + ) + throw new ForbiddenException('Permissions insuffisantes pour ce projet') + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts new file mode 100644 index 0000000000..b2480f78cf --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/project/project.testing.utils.ts @@ -0,0 +1,37 @@ +import type { ExecutionContext } from '@nestjs/common' +import type { HttpArgumentsHost } from '@nestjs/common/interfaces' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ProjectContext } from './project.guard' +import type { ProjectPolicyConfig } from './project.policy' +import { mockDeep } from 'vitest-mock-extended' + +export function makeExecutionContext(request: Record): DeepMockProxy { + const ctx = mockDeep() + const httpArgs = mockDeep() + httpArgs.getRequest.mockReturnValue(request) + ctx.switchToHttp.mockReturnValue(httpArgs) + return ctx +} + +export function makeProjectContext(overrides: Partial = {}): ProjectContext { + return { + id: 'p1', + slug: 'project', + locked: false, + status: 'created', + projectPermissions: 6n, + ...overrides, + } +} + +export function makeProjectPolicy(overrides: Partial = {}): ProjectPolicyConfig { + return { + adminPermissions: [], + userTypes: [], + projectPermissions: [], + projectStatuses: [], + projectLocked: undefined, + projectAccess: false, + ...overrides, + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts new file mode 100644 index 0000000000..ab70852172 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user-admin-permission.decorator.ts @@ -0,0 +1,8 @@ +import type { AdminAuthorized } from '@cpn-console/shared' +import { SetMetadata } from '@nestjs/common' + +export const ADMIN_PERMISSIONS_KEY = 'admin-permissions' + +export function RequireAdminPermission(...permissions: (keyof typeof AdminAuthorized)[]) { + return SetMetadata(ADMIN_PERMISSIONS_KEY, permissions) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts new file mode 100644 index 0000000000..c2e004cc46 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user-policy.service.ts @@ -0,0 +1,25 @@ +import type { AdminAuthorized } from '@cpn-console/shared' +import type { ExecutionContext } from '@nestjs/common' +import type { User as PrismaUser } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { ADMIN_PERMISSIONS_KEY } from './user-admin-permission.decorator' +import { USER_TYPES_KEY } from './user-type.decorator' + +export interface UserPolicyConfig { + adminPermissions: (keyof typeof AdminAuthorized)[] + userTypes: PrismaUser['type'][] +} + +@Injectable() +export class UserPermissionPolicy { + constructor(@Inject(Reflector) private readonly reflector: Reflector) {} + + build(context: ExecutionContext): UserPolicyConfig { + const targets = [context.getHandler(), context.getClass()] + return { + adminPermissions: this.reflector.getAllAndOverride<(keyof typeof AdminAuthorized)[] | undefined>(ADMIN_PERMISSIONS_KEY, targets) ?? [], + userTypes: this.reflector.getAllAndOverride(USER_TYPES_KEY, targets) ?? [], + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts new file mode 100644 index 0000000000..dc6b09af03 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user-testing.utils.ts @@ -0,0 +1,17 @@ +import type { UserContext } from '../../auth/auth-user.decorator' +import type { UserPolicyConfig } from './user-policy.service' + +export function makeUserPolicy(overrides: Partial = {}): UserPolicyConfig { + return { + adminPermissions: [], + userTypes: [], + ...overrides, + } +} + +export function makeUserContext(overrides: Partial = {}): UserContext { + return { + userId: 'test-user', + ...overrides, + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts new file mode 100644 index 0000000000..ee14dbbb38 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user-type.decorator.ts @@ -0,0 +1,8 @@ +import type { User } from '@prisma/client' +import { SetMetadata } from '@nestjs/common' + +export const USER_TYPES_KEY = 'user-types' + +export function RequireUserType(...types: User['type'][]) { + return SetMetadata(USER_TYPES_KEY, types) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts new file mode 100644 index 0000000000..447b77477e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.spec.ts @@ -0,0 +1,105 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserPolicyConfig } from './user-policy.service' +import { ForbiddenException, UnauthorizedException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeExecutionContext } from '../../auth/auth-testing.utils' +import { AuthService } from '../../auth/auth.service' +import { UserPermissionPolicy } from './user-policy.service' +import { UserGuard } from './user.guard' +import { UserPermissionService } from './user.service' + +describe('userGuard', () => { + let module: TestingModule + let guard: UserGuard + let authService: DeepMockProxy + let userService: DeepMockProxy + let userPolicy: DeepMockProxy + + beforeEach(async () => { + authService = mockDeep() + userService = mockDeep() + userPolicy = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + UserGuard, + { provide: AuthService, useValue: authService }, + { provide: UserPermissionService, useValue: userService }, + { provide: UserPermissionPolicy, useValue: userPolicy }, + ], + }).compile() + + guard = module.get(UserGuard) + }) + + it('throws 401 when the auth service rejects the request', async () => { + authService.authenticate.mockRejectedValue(new UnauthorizedException()) + userPolicy.build.mockReturnValue({ adminPermissions: [], userTypes: [] }) + const ctx = makeExecutionContext({}) + + await expect(guard.canActivate(ctx)).rejects.toThrow(UnauthorizedException) + }) + + it('authenticates without loading optional auth data when no policy metadata is set', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1' }) + userPolicy.build.mockReturnValue({ adminPermissions: [], userTypes: [] }) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + const result = await guard.canActivate(ctx) + const request = ctx.switchToHttp().getRequest() + + expect(result).toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(request.userId).toBe('u1') + }) + + it('validates required admin permissions', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 32768n }) + const policy: UserPolicyConfig = { adminPermissions: ['ListSystem'], userTypes: [] } + userPolicy.build.mockReturnValue(policy) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + await expect(guard.canActivate(ctx)).resolves.toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(userService.validate).toHaveBeenCalled() + }) + + it('throws 403 when required admin permissions are missing', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', adminPermissions: 0n }) + const policy: UserPolicyConfig = { adminPermissions: ['ManageSystem'], userTypes: [] } + userPolicy.build.mockReturnValue(policy) + userService.validate.mockImplementation(() => { throw new ForbiddenException() }) + const ctx = makeExecutionContext({ 'x-dso-token': 'tok' }) + + await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException) + }) + + it('validates required user types', async () => { + authService.authenticate.mockResolvedValue({ userId: 'u1', userType: 'human' }) + const policy: UserPolicyConfig = { adminPermissions: [], userTypes: ['human'] } + userPolicy.build.mockReturnValue(policy) + const httpRequest = { headers: { 'x-dso-token': 'tok' } } + const ctx = makeExecutionContext(httpRequest.headers) + + await expect(guard.canActivate(ctx)).resolves.toBe(true) + expect(authService.authenticate).toHaveBeenCalledWith( + expect.anything(), + { includeAdminRoleIds: true, includeUserType: true }, + ) + expect(authService.authenticate.mock.calls[0]?.[0]?.headers).toEqual(httpRequest.headers) + expect(userService.validate).toHaveBeenCalled() + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts new file mode 100644 index 0000000000..28c339accd --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.guard.ts @@ -0,0 +1,60 @@ +import type { CanActivate, ExecutionContext } from '@nestjs/common' +import type { FastifyRequest } from 'fastify' +import { Inject, Injectable, Logger, UnauthorizedException } from '@nestjs/common' +import { AuthService } from '../../auth/auth.service' +import { UserPermissionPolicy } from './user-policy.service' +import { UserPermissionService } from './user.service' + +type RequestWithAuthContext = FastifyRequest & { + userId?: string + adminPermissions?: bigint + userType?: string +} + +@Injectable() +export class UserGuard implements CanActivate { + private readonly logger = new Logger(UserGuard.name) + + constructor( + @Inject(AuthService) private readonly authService: AuthService, + @Inject(UserPermissionService) private readonly userService: UserPermissionService, + @Inject(UserPermissionPolicy) private readonly userPolicy: UserPermissionPolicy, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const policy = this.userPolicy.build(context) + const request = this.getRequest(context) + const user = await this.authenticate(request) + + this.validate(policy, user) + this.logger.debug(`User auth granted (requestId=${request.id}, userId=${user.userId}, adminPermissions=${user.adminPermissions?.toString()}, userType=${user.userType})`) + + return true + } + + private getRequest(context: ExecutionContext): RequestWithAuthContext { + return context.switchToHttp().getRequest() + } + + private async authenticate(request: RequestWithAuthContext) { + try { + const user = await this.authService.authenticate(request, { + includeAdminRoleIds: true, + includeUserType: true, + }) + + request.userId = user.userId + if (user.adminPermissions !== undefined) request.adminPermissions = user.adminPermissions + if (user.userType !== undefined) request.userType = user.userType + + return user + } catch (error) { + this.logger.warn(`User auth ${error instanceof UnauthorizedException ? 'rejected' : 'denied'} (requestId=${request.id}, error=${error instanceof Error ? error.message : String(error)})`) + throw error + } + } + + private validate(policy: ReturnType, user: Awaited>) { + this.userService.validate(policy, user) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts new file mode 100644 index 0000000000..613821ed2a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../../auth/auth.module' +import { DatabaseModule } from '../../database/database.module' +import { UserPermissionPolicy } from './user-policy.service' +import { UserGuard } from './user.guard' +import { UserPermissionService } from './user.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + ], + providers: [ + UserGuard, + UserPermissionService, + UserPermissionPolicy, + ], + exports: [ + UserGuard, + UserPermissionService, + UserPermissionPolicy, + ], +}) +export class UserPermissionModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts new file mode 100644 index 0000000000..4ad5b84704 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.spec.ts @@ -0,0 +1,73 @@ +import type { UserContext } from '../../auth/auth-user.decorator' +import { AdminAuthorized } from '@cpn-console/shared' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { makeUserContext, makeUserPolicy } from './user-testing.utils' +import { UserPermissionService } from './user.service' + +describe('userService', () => { + let service: UserPermissionService + + beforeEach(async () => { + const module = await Test.createTestingModule({ + providers: [UserPermissionService], + }).compile() + + service = module.get(UserPermissionService) + }) + + describe('validateAdminPermissions', () => { + it('does not throw when no admin permissions are required', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({}), 0n)).not.toThrow() + }) + + it('does not throw when the user has the required admin permission', () => { + const adminPerms = AdminAuthorized.ListSystem(32768n) ? 32768n : 0n + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ListSystem'] }), adminPerms)).not.toThrow() + }) + + it('throws ForbiddenException when the user lacks the required admin permission', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ManageSystem'] }), 0n)).toThrow(ForbiddenException) + }) + + it('validates multiple required admin permissions', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['ManageSystem', 'ListSystem'] }), 0n)).toThrow(ForbiddenException) + }) + + it('treats undefined adminPermissions as 0n', () => { + expect(() => service.validateAdminPermissions(makeUserPolicy({ adminPermissions: ['Manage'] }), undefined)).toThrow(ForbiddenException) + }) + }) + + describe('validateUserType', () => { + it('does not throw when no user types are required', () => { + expect(() => service.validateUserType(makeUserPolicy({}), 'human')).not.toThrow() + }) + + it('does not throw when the user type is in the allowed list', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), 'human')).not.toThrow() + }) + + it('throws ForbiddenException when the user type is not in the allowed list', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), 'service')).toThrow(ForbiddenException) + }) + + it('throws ForbiddenException when userType is undefined', () => { + expect(() => service.validateUserType(makeUserPolicy({ userTypes: ['human'] }), undefined)).toThrow(ForbiddenException) + }) + }) + + describe('validate', () => { + it('delegates to validateAdminPermissions and validateUserType', () => { + const spyPerms = vi.spyOn(service, 'validateAdminPermissions') + const spyType = vi.spyOn(service, 'validateUserType') + const user: UserContext = makeUserContext({ adminPermissions: 1n, userType: 'human' }) + + service.validate(makeUserPolicy({}), user) + + expect(spyPerms).toHaveBeenCalled() + expect(spyType).toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts new file mode 100644 index 0000000000..3784e2feb6 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/permission/user/user.service.ts @@ -0,0 +1,36 @@ +import type { User as PrismaUser } from '@prisma/client' +import type { UserContext } from '../../auth/auth-user.decorator' +import type { UserPolicyConfig } from './user-policy.service' +import { AdminAuthorized } from '@cpn-console/shared' +import { ForbiddenException, Injectable, Logger } from '@nestjs/common' + +@Injectable() +export class UserPermissionService { + private readonly logger = new Logger(UserPermissionService.name) + + validate(policy: UserPolicyConfig, user: UserContext): void { + this.validateAdminPermissions(policy, user.adminPermissions) + this.validateUserType(policy, user.userType) + } + + validateAdminPermissions(policy: UserPolicyConfig, adminPermissions: bigint | undefined): void { + if (!policy.adminPermissions.length) return + + const hasPermission = policy.adminPermissions.every( + permName => AdminAuthorized[permName](adminPermissions ?? 0n), + ) + + if (!hasPermission) { + this.logger.warn(`User auth denied: missing admin permissions (adminPermissions=${adminPermissions?.toString()}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Permissions administrateur insuffisantes') + } + } + + validateUserType(policy: UserPolicyConfig, userType: string | undefined): void { + if (!policy.userTypes.length) return + if (typeof userType !== 'string' || !policy.userTypes.includes(userType as PrismaUser['type'])) { + this.logger.warn(`User auth denied: invalid user type (userType=${userType}, policy=${JSON.stringify(policy)})`) + throw new ForbiddenException('Cannot find requestor in request context') + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts new file mode 100644 index 0000000000..6fd1db8ee6 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.spec.ts @@ -0,0 +1,100 @@ +import { BadRequestException } from '@nestjs/common' +import { beforeEach, describe, expect, it } from 'vitest' +import { z } from 'zod' +import { ZodValidationPipe } from './zod-validation.pipe' + +describe('zodValidationPipe', () => { + const schema = z.object({ + name: z.string(), + age: z.number().int().positive(), + }) + + let pipe: ZodValidationPipe + + beforeEach(() => { + pipe = new ZodValidationPipe(schema) + }) + + describe('transform', () => { + it('should return validated data when input is valid', () => { + const validInput = { + name: 'Alice', + age: 30, + } + + const result = pipe.transform(validInput) + + expect(result).toEqual(validInput) + }) + + it('should strip unknown fields if schema does not allow them', () => { + const input = { + name: 'Alice', + age: 30, + extra: 'remove me', + } + + const result = pipe.transform(input) + + expect(result).toEqual({ + name: 'Alice', + age: 30, + }) + }) + + it('should throw BadRequestException when required fields are missing', () => { + const invalidInput = { + name: 'Alice', + } + + expect(() => pipe.transform(invalidInput)).toThrow( + BadRequestException, + ) + }) + + it('should throw BadRequestException when field types are invalid', () => { + const invalidInput = { + name: 'Alice', + age: '30', + } + + expect(() => pipe.transform(invalidInput)).toThrow( + BadRequestException, + ) + }) + + it('should include flattened Zod errors in the exception response', () => { + const invalidInput = { + name: 123, + age: -5, + } + + try { + pipe.transform(invalidInput) + throw new Error('Expected transform to throw') + } catch (error) { + expect(error).toBeInstanceOf(BadRequestException) + + if (error instanceof BadRequestException) { + const response = error.getResponse() as any + + expect(response).toHaveProperty('fieldErrors') + expect(response.fieldErrors).toHaveProperty('name') + expect(response.fieldErrors).toHaveProperty('age') + } + } + }) + + it('should throw when input is null', () => { + expect(() => pipe.transform(null)).toThrow( + BadRequestException, + ) + }) + + it('should throw when input is undefined', () => { + expect(() => pipe.transform(undefined)).toThrow( + BadRequestException, + ) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts new file mode 100644 index 0000000000..066fa255d4 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts @@ -0,0 +1,17 @@ +import type { PipeTransform } from '@nestjs/common' +import type { ZodSchema } from 'zod' +import { BadRequestException } from '@nestjs/common' + +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: ZodSchema) {} + + transform(value: unknown) { + const result = this.schema.safeParse(value) + + if (!result.success) { + throw new BadRequestException(result.error.flatten()) + } + + return result.data + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts new file mode 100644 index 0000000000..3056b5bcb1 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.constants.ts @@ -0,0 +1,2 @@ +export const TRACER_NAME = '@cpn-console/server-nestjs' +export const SERVICE_NAME = 'console-pi-native-console' diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts new file mode 100644 index 0000000000..950f09d0cc --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/infrastructure/telemetry/telemetry.decorator.ts @@ -0,0 +1,74 @@ +import type { Span as OpenTelemetrySpan, SpanOptions } from '@opentelemetry/api' +import { SpanStatusCode, trace } from '@opentelemetry/api' +import { TRACER_NAME } from './telemetry.constants' + +export type TypedMethodDecorator = any>( + target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, +) => void + +export function StartActiveSpan(options?: SpanOptions): TypedMethodDecorator { + return any>( + _target: object, + propertyKey: string | symbol, + descriptor: TypedPropertyDescriptor, + ): void => { + const original = descriptor.value + if (!original) return + + descriptor.value = function (this: ThisParameterType, ...args: Parameters): ReturnType { + const tracer = trace.getTracer(TRACER_NAME) + const className = this?.constructor?.name ?? 'Unknown' + const spanName = `${className}.${String(propertyKey)}` + + const runInActiveSpan = (span: OpenTelemetrySpan) => { + try { + const result = original.apply(this, args) + + if (isPromiseLike(result)) { + return handlePromiseResult(span, result) as ReturnType + } + + span.end() + return result + } catch (error) { + recordException(span, error) + span.end() + throw error + } + } + + if (options) { + return tracer.startActiveSpan(spanName, options, runInActiveSpan) as ReturnType + } + return tracer.startActiveSpan(spanName, runInActiveSpan) as ReturnType + } as T + } +} + +function isPromiseLike(value: unknown): value is Promise { + if (!value) return false + return typeof (value as Promise).then === 'function' +} + +async function handlePromiseResult(span: OpenTelemetrySpan, promise: Promise): Promise { + try { + return await promise + } catch (error) { + recordException(span, error) + throw error + } finally { + span.end() + } +} + +function recordException(span: OpenTelemetrySpan, error: unknown): void { + // If it's an actual Error object, OpenTelemetry captures the stack trace automatically + if (error instanceof Error) { + span.recordException(error) + } else { + span.recordException(String(error)) + } + span.setStatus({ code: SpanStatusCode.ERROR }) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts new file mode 100644 index 0000000000..f30d6c6af7 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts @@ -0,0 +1,251 @@ +import type { TestingModule } from '@nestjs/testing' +import type { KeycloakConfig } from './keycloak.module-definition' +import KcAdminClient from '@keycloak/keycloak-admin-client' +import { ScheduleModule } from '@nestjs/schedule' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' +import { ADMIN_TOKEN_REFRESH_INTERVAL_MS } from './keycloak.constants' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +const keycloakUrl = 'https://keycloak.internal' +const projectRealm = 'project-realm' +// The only handled token endpoint is the master realm's: a token request +// against any other realm is unhandled and fails the test, which enforces +// the realm pinning of authenticate() +const tokenUrl = `${keycloakUrl}/realms/master/protocol/openid-connect/token` +const childrenUrl = `${keycloakUrl}/admin/realms/${projectRealm}/groups/:parentId/children` + +const server = setupServer() + +function useTokenEndpoint({ rejectGrant = () => false }: { rejectGrant?: (grantType: string | null) => boolean } = {}) { + const tokenRequests: URLSearchParams[] = [] + let issued = 0 + server.use( + http.post(tokenUrl, async ({ request }) => { + const body = new URLSearchParams(await request.text()) + tokenRequests.push(body) + if (rejectGrant(body.get('grant_type'))) { + return HttpResponse.json({ error: 'invalid_grant' }, { status: 401 }) + } + issued++ + return HttpResponse.json({ access_token: `access-token-${issued}`, refresh_token: `refresh-token-${issued}`, expires_in: 60 }) + }), + ) + return tokenRequests +} + +function createKeycloakClientServiceTestingModule(config: Partial = {}) { + return Test.createTestingModule({ + imports: [ScheduleModule.forRoot()], + providers: [ + KeycloakClientService, + { provide: KEYCLOAK_ADMIN_CLIENT, useValue: new KcAdminClient({ baseUrl: keycloakUrl }) }, + { + provide: KEYCLOAK_CONFIG, + useValue: mockDeep({ + realm: projectRealm, + admin: 'admin', + adminPassword: 'admin-password', + adminClientId: 'admin-cli', + ...config, + }), + }, + ], + }) +} + +describe('keycloakClientService authentication lifecycle', () => { + let module: TestingModule + let client: KcAdminClient + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + vi.useFakeTimers() + module = await createKeycloakClientServiceTestingModule().compile() + client = module.get(KEYCLOAK_ADMIN_CLIENT) + }) + afterEach(async () => { + // close() re-runs init() on a module whose init() already failed; swallow + // that rethrow so the "initial authentication fails" test can clean up + await module.close().catch(() => {}) + server.resetHandlers() + vi.useRealTimers() + }) + afterAll(() => server.close()) + + it('should authenticate with the password grant then switch to the project realm', async () => { + const tokenRequests = useTokenEndpoint() + + await module.init() + + expect(tokenRequests).toHaveLength(1) + expect(Object.fromEntries(tokenRequests[0])).toMatchObject({ + grant_type: 'password', + client_id: 'admin-cli', + username: 'admin', + password: 'admin-password', + }) + expect(client.accessToken).toBe('access-token-1') + expect(client.realmName).toBe(projectRealm) + }) + + it('should refresh the token periodically with the rotated refresh token so it never expires', async () => { + const tokenRequests = useTokenEndpoint() + await module.init() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(2) + expect(Object.fromEntries(tokenRequests[1])).toMatchObject({ + grant_type: 'refresh_token', + client_id: 'admin-cli', + refresh_token: 'refresh-token-1', + }) + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(3) + expect(tokenRequests[2].get('refresh_token')).toBe('refresh-token-2') + expect(client.accessToken).toBe('access-token-3') + expect(client.realmName).toBe(projectRealm) + }) + + it('should fall back to a full re-authentication when the refresh grant fails', async () => { + const tokenRequests = useTokenEndpoint({ rejectGrant: grantType => grantType === 'refresh_token' }) + await module.init() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + + expect(tokenRequests.map(body => body.get('grant_type'))).toEqual(['password', 'refresh_token', 'password']) + expect(client.accessToken).toBe('access-token-2') + expect(client.realmName).toBe(projectRealm) + }) + + it('should keep refreshing and restore the project realm when both grants fail', async () => { + let keycloakIsDown = false + const tokenRequests = useTokenEndpoint({ rejectGrant: () => keycloakIsDown }) + await module.init() + + keycloakIsDown = true + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests.map(body => body.get('grant_type'))).toEqual(['password', 'refresh_token', 'password']) + expect(client.realmName).toBe(projectRealm) + + keycloakIsDown = false + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + expect(tokenRequests).toHaveLength(4) + expect(tokenRequests[3].get('grant_type')).toBe('refresh_token') + expect(client.accessToken).toBe('access-token-2') + }) + + it('should stop refreshing the token on module destroy', async () => { + const tokenRequests = useTokenEndpoint() + await module.init() + expect(tokenRequests).toHaveLength(1) + + await module.close() + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 3) + expect(tokenRequests).toHaveLength(1) + }) + + it('should rethrow when the initial authentication fails', async () => { + const tokenRequests = useTokenEndpoint({ rejectGrant: () => true }) + + await expect(module.init()).rejects.toThrow('invalid_grant') + expect(client.realmName).toBe('master') + + await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) + expect(tokenRequests).toHaveLength(1) + }) +}) + +describe('getOrCreateSubGroupByName', () => { + let module: TestingModule + let service: KeycloakClientService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + module = await createKeycloakClientServiceTestingModule().compile() + service = module.get(KeycloakClientService) + useTokenEndpoint() + await module.init() + }) + afterEach(async () => { + await module.close() + server.resetHandlers() + }) + afterAll(() => server.close()) + + it('should return the existing subgroup without creating it', async () => { + // No POST handler: a create attempt would fail the test + server.use( + http.get(childrenUrl, ({ params }) => { + expect(params.parentId).toBe('parent-id') + return HttpResponse.json([{ id: 'sub-id', name: 'sub' }]) + }), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toMatchObject({ id: 'sub-id', name: 'sub' }) + }) + + it('should create the subgroup when it does not exist', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([])), + http.post(childrenUrl, async ({ request, params }) => { + expect(params.parentId).toBe('parent-id') + expect(await request.json()).toEqual({ name: 'sub' }) + return new HttpResponse(null, { + status: 201, + headers: { location: `${keycloakUrl}/admin/realms/${projectRealm}/groups/created-id` }, + }) + }), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toEqual({ id: 'created-id', name: 'sub' }) + }) + + it('should re-fetch the subgroup when a concurrent creation causes a 409', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([]), { once: true }), + http.get(childrenUrl, () => HttpResponse.json([{ id: 'concurrent-id', name: 'sub' }])), + http.post(childrenUrl, () => + HttpResponse.json({ errorMessage: 'Sibling group named \'sub\' already exists.' }, { status: 409 })), + ) + + const result = await service.getOrCreateSubGroupByName('parent-id', 'sub') + + expect(result).toMatchObject({ id: 'concurrent-id', name: 'sub' }) + }) + + it('should rethrow the 409 when the subgroup still cannot be found', async () => { + server.use( + http.get(childrenUrl, () => HttpResponse.json([])), + http.post(childrenUrl, () => + HttpResponse.json({ errorMessage: 'Sibling group named \'sub\' already exists.' }, { status: 409 })), + ) + + await expect(service.getOrCreateSubGroupByName('parent-id', 'sub')).rejects.toThrow('Sibling group named') + }) + + it('should rethrow non-409 errors without re-fetching', async () => { + let listCalls = 0 + server.use( + http.get(childrenUrl, () => { + listCalls++ + return HttpResponse.json([]) + }), + http.post(childrenUrl, () => + HttpResponse.json({ error: 'unauthorized' }, { status: 401 })), + ) + + await expect(service.getOrCreateSubGroupByName('parent-id', 'sub')).rejects.toThrow('unauthorized') + expect(listCalls).toBe(1) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts new file mode 100644 index 0000000000..bb106d64e2 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -0,0 +1,343 @@ +import type KcAdminClient from '@keycloak/keycloak-admin-client' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { Credentials } from '@keycloak/keycloak-admin-client/lib/utils/auth' +import type { OnModuleInit } from '@nestjs/common' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import type { KeycloakConfig } from './keycloak.module-definition' +import type { GroupRepresentationWith } from './keycloak.utils' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { Interval } from '@nestjs/schedule' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { getErrorResponseStatus } from '../../utils/http.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { ADMIN_AUTH_REALM, ADMIN_TOKEN_REFRESH_INTERVAL_MS, CONSOLE_GROUP_NAME, PASSWORD_GRANT_TYPE, REFRESH_TOKEN_GRANT_TYPE, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +export const KEYCLOAK_ADMIN_CLIENT = Symbol('KEYCLOAK_ADMIN_CLIENT') + +@Injectable() +export class KeycloakClientService implements OnModuleInit { + private readonly logger = new Logger(KeycloakClientService.name) + + private authenticated = false + + constructor( + @Inject(KEYCLOAK_CONFIG) private readonly keycloakConfig: KeycloakConfig, + @Inject(KEYCLOAK_ADMIN_CLIENT) private readonly client: KcAdminClient, + ) { + } + + async* getAllGroups() { + let first = 0 + while (true) { + const fetched = await this.client.groups.find({ first, max: SUBGROUPS_PAGINATE_QUERY_MAX, briefRepresentation: false }) + this.logger.verbose(`Loaded a Keycloak groups page (first=${first}, count=${fetched.length})`) + if (fetched.length === 0) break + for (const group of fetched) { + yield group + } + if (fetched.length < SUBGROUPS_PAGINATE_QUERY_MAX) break + first += SUBGROUPS_PAGINATE_QUERY_MAX + } + } + + @StartActiveSpan() + async getGroupByName(name: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + const groups = await this.client.groups.find({ search: name, briefRepresentation: false }) ?? [] + return groups.find(g => g.name === name) + } + + async getGroupByPath(path: string): Promise { + const parts = path.split('/').filter(Boolean) + this.logger.verbose(`Resolving Keycloak group path ${path} (depth=${parts.length})`) + let current: GroupRepresentationWith<'id'> | undefined + if (parts.length === 0) return undefined + + for (const name of parts) { + current = current + ? await this.getSubGroupByName(current.id, name) + : await this.getRootGroupByName(name) + + if (!current) { + this.logger.verbose(`Keycloak group path segment was not found (path=${path}, missing=${name})`) + return undefined + } + } + this.logger.verbose(`Keycloak group path resolved (path=${path}, groupId=${current?.id})`) + return current + } + + private async getSubGroupByName(parentId: string, name: string): Promise | undefined> { + for await (const subgroup of this.getSubGroups(parentId)) { + if (subgroup.name === name) { + const parsed = z.object({ id: z.string() }).and(z.record(z.string(), z.unknown())).safeParse(subgroup) + return parsed.success ? parsed.data : undefined + } + } + return undefined + } + + private async getRootGroupByName(name: string): Promise | undefined> { + const candidates = await this.client.groups.find({ search: name, briefRepresentation: false }) ?? [] + const match = candidates.find(g => g.path === `/${name}`) ?? candidates.find(g => g.name === name) + const parsed = z.object({ id: z.string() }).and(z.record(z.string(), z.unknown())).safeParse(match) + return parsed.success ? parsed.data : undefined + } + + async deleteGroup(id: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', id) + this.logger.log(`Deleting Keycloak group (groupId=${id})`) + await this.client.groups.del({ id }) + } + + async getGroupMembers(groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + const members = await this.client.groups.listMembers({ id: groupId }) + this.logger.verbose(`Loaded Keycloak group members (groupId=${groupId}, count=${members?.length ?? 0})`) + return members || [] + } + + async getUserByEmail(email: string): Promise { + const users = await this.client.users.find({ + email, + exact: true, + max: 1, + }) + return users[0] + } + + @StartActiveSpan() + async createGroup(name: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + this.logger.debug(`Creating Keycloak group ${name}`) + const result = await this.client.groups.create({ name }) + return { ...result, name } as GroupRepresentation + } + + async addUserToGroup(userId: string, groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + this.logger.verbose(`Adding user to Keycloak group (userId=${userId}, groupId=${groupId})`) + return this.client.users.addToGroup({ id: userId, groupId }) + } + + async removeUserFromGroup(userId: string, groupId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', groupId) + this.logger.verbose(`Removing user from Keycloak group (userId=${userId}, groupId=${groupId})`) + return this.client.users.delFromGroup({ id: userId, groupId }) + } + + async* getSubGroups(parentId: string) { + let first = 0 + while (true) { + const page = await this.client.groups.listSubGroups({ + parentId, + briefRepresentation: false, + max: SUBGROUPS_PAGINATE_QUERY_MAX, + first, + }) + this.logger.verbose(`Loaded a Keycloak subgroups page (parentId=${parentId}, first=${first}, count=${page.length})`) + if (page.length === 0) break + for (const subgroup of page) { + yield subgroup + } + if (page.length < SUBGROUPS_PAGINATE_QUERY_MAX) break + first += SUBGROUPS_PAGINATE_QUERY_MAX + } + } + + async getOrCreateGroupByPath(path: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.path.depth', path.split('/').filter(Boolean).length) + this.logger.verbose(`Ensuring Keycloak group path exists: ${path}`) + const existingGroup = await this.getGroupByPath(path) + if (existingGroup) { + this.logger.verbose(`Keycloak group already exists at path ${path}`) + return existingGroup + } + + const parts = path.split('/').filter(Boolean) + let parentId: string | undefined + let current: GroupRepresentationWith<'id' | 'name'> | undefined + + for (const name of parts.values()) { + if (current) { + if (!parentId) parentId = current.id + const next = z.object({ id: z.string(), name: z.string() }).safeParse(await this.getOrCreateSubGroupByName(parentId, name)) + if (next.success) current = next.data + } else { + const next = z.object({ id: z.string(), name: z.string() }).safeParse(await this.getGroupByName(name) ?? await this.createGroup(name)) + if (next.success) current = next.data + } + parentId = current?.id + } + + if (current) { + this.logger.log(`Created Keycloak group path ${path} (groupId=${current.id})`) + } + return { ...current, path } as GroupRepresentation + } + + @StartActiveSpan() + async getOrCreateSubGroupByName(parentId: string, name: string) { + const span = trace.getActiveSpan() + span?.setAttribute('group.name', name) + span?.setAttribute('parent.id', parentId) + const existing = await this.findSubGroupByName(parentId, name) + if (existing) return existing + + this.logger.debug(`Creating Keycloak subgroup ${name} under parentId=${parentId}`) + try { + const createdGroup = await this.client.groups.createChildGroup({ id: parentId }, { name }) + return { id: createdGroup.id, name } satisfies GroupRepresentation + } catch (err) { + // A concurrent reconciliation may have created the subgroup between the + // scan and the create; treat the 409 as "already exists" and re-fetch it + if (getErrorResponseStatus(err) !== 409) throw err + this.logger.verbose(`Keycloak subgroup ${name} was created concurrently under parentId=${parentId}, fetching it`) + const subgroup = await this.findSubGroupByName(parentId, name) + if (!subgroup) throw err + return subgroup + } + } + + private async findSubGroupByName(parentId: string, name: string): Promise { + for await (const subgroup of this.getSubGroups(parentId)) { + if (subgroup.name === name) { + return subgroup + } + } + return undefined + } + + async getOrCreateConsoleGroup(projectGroup: GroupRepresentationWith<'id'>) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.group.id', projectGroup.id) + this.logger.verbose(`Ensuring Keycloak console group exists (projectGroupId=${projectGroup.id})`) + return this.getOrCreateSubGroupByName(projectGroup.id, CONSOLE_GROUP_NAME) + } + + async getOrCreateRoleGroup( + consoleGroup: GroupRepresentationWith<'id' | 'name'>, + oidcGroup: string, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.group.id': consoleGroup.id, + 'role.oidc_group.present': !!oidcGroup, + 'role.oidc_group.depth': oidcGroup.split('/').filter(Boolean).length, + }) + const parts = oidcGroup.split('/').filter(Boolean) + if (parts.length === 0) { + throw new Error(`Invalid oidcGroup for project role: "${oidcGroup}"`) + } + + let current = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(consoleGroup.id, parts[0])) + + for (const name of parts.slice(1)) { + current = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(current.id, name)) + } + + return { ...current, path: `/${consoleGroup.name}/${parts.join('/')}` } satisfies GroupRepresentation + } + + async getOrCreateEnvironmentGroups(consoleGroup: GroupRepresentationWith<'id'>, environment: ProjectWithDetails['environments'][number]) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.group.id': consoleGroup.id, + 'environment.id': environment.id, + 'environment.name': environment.name, + }) + const envGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(consoleGroup.id, environment.name)) + const [roGroup, rwGroup] = await Promise.all([ + this.getOrCreateSubGroupByName(envGroup.id, 'RO'), + this.getOrCreateSubGroupByName(envGroup.id, 'RW'), + ]) + this.logger.verbose(`Resolved Keycloak environment groups (consoleGroupId=${consoleGroup.id}, env=${environment.name}, envGroupId=${envGroup.id})`) + return { roGroup, rwGroup } + } + + async onModuleInit() { + try { + this.logger.log(`Authenticating Keycloak admin client (realm=${this.keycloakConfig.realm})`) + await this.authenticate(this.passwordCredentials()) + } catch (err) { + if (err instanceof Error) { + this.logger.error(`Keycloak Admin Client authentication failed: ${err.message}`, err.stack) + } else { + this.logger.error(`Keycloak Admin Client authentication failed: ${String(err)}`) + } + throw err + } + this.client.setConfig({ realmName: this.keycloakConfig.realm }) + this.authenticated = true + this.logger.log(`Keycloak Admin Client authenticated (realm=${this.keycloakConfig.realm})`) + } + + // The admin client never refreshes its token on its own; without this the + // access token expires (~60s) and every admin call fails with a 401 + @Interval(ADMIN_TOKEN_REFRESH_INTERVAL_MS) + async refreshAdminToken() { + if (!this.authenticated) return + try { + await this.authenticate(this.refreshTokenCredentials()) + } catch (refreshErr) { + // The refresh token itself can expire or be revoked (e.g. Keycloak + // restart); fall back to a full re-authentication to recover + this.logger.warn(`Keycloak Admin Client token refresh failed, re-authenticating: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`) + try { + await this.authenticate(this.passwordCredentials()) + } catch (err) { + this.logger.error(`Keycloak Admin Client re-authentication failed: ${err instanceof Error ? err.message : String(err)}`) + } + } + } + + private passwordCredentials(): Credentials { + return { + clientId: this.keycloakConfig.adminClientId, + grantType: PASSWORD_GRANT_TYPE, + username: this.keycloakConfig.admin, + password: this.keycloakConfig.adminPassword, + } + } + + private refreshTokenCredentials(): Credentials { + return { + clientId: this.keycloakConfig.adminClientId, + grantType: REFRESH_TOKEN_GRANT_TYPE, + refreshToken: this.client.refreshToken, + } + } + + // auth() resolves the token endpoint from client.realmName, which onModuleInit + // switches to the project realm — the admin user lives in the master realm. + // Restore the realm even when auth fails: the still-valid previous token keeps + // serving admin calls, and those must target the project realm, not master + private async authenticate(credentials: Credentials) { + const realmName = this.client.realmName + this.client.setConfig({ realmName: ADMIN_AUTH_REALM }) + try { + await this.client.auth(credentials) + } finally { + this.client.setConfig({ realmName }) + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts new file mode 100644 index 0000000000..0c0a028836 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts @@ -0,0 +1,88 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + slug: true, + ownerId: true, + everyonePerms: true, + members: { + select: { + roleIds: true, + user: { + select: { + id: true, + email: true, + }, + }, + }, + }, + roles: { + select: { + id: true, + permissions: true, + oidcGroup: true, + type: true, + }, + }, + environments: { + select: { + id: true, + name: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export const adminRoleSelect = { + id: true, + oidcGroup: true, + type: true, +} satisfies Prisma.AdminRoleSelect + +export type AdminRoleWithDetails = Prisma.AdminRoleGetPayload<{ + select: typeof adminRoleSelect +}> + +export const userAdminRoleSelect = { + id: true, + adminRoleIds: true, +} satisfies Prisma.UserSelect + +export type UserWithAdminRoles = Prisma.UserGetPayload<{ + select: typeof userAdminRoleSelect +}> + +@Injectable() +export class KeycloakDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getAllAdminRoles(): Promise { + return this.prisma.adminRole.findMany({ + select: adminRoleSelect, + }) + } + + async getAllUsersWithAdminRoleIds(): Promise { + return this.prisma.user.findMany({ + select: userAdminRoleSelect, + }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts new file mode 100644 index 0000000000..6d78945601 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts @@ -0,0 +1,25 @@ +import type { KeycloakConfig } from './keycloak.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' + +@Injectable() +export class KeycloakHealthService { + constructor( + @Inject(KEYCLOAK_CONFIG) + private readonly keycloakConfig: KeycloakConfig, + @Inject(HealthIndicatorService) + private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts new file mode 100644 index 0000000000..a0b5e2dfac --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-plugin.service.ts @@ -0,0 +1,16 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Injectable } from '@nestjs/common' + +@Injectable() +export class KeycloakPluginService { + infos(): ServiceInfos { + return { + name: 'keycloak', + title: 'Keycloak', + config: { + global: [], + project: [], + }, + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts new file mode 100644 index 0000000000..17fb5c5f0d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak-testing.utils.ts @@ -0,0 +1,87 @@ +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { ProjectWithDetails } from './keycloak-datastore.service' + +import { faker } from '@faker-js/faker' + +export function makeUserRepresentation( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + username: faker.internet.username(), + enabled: true, + ...overrides, + } satisfies UserRepresentation +} + +export function makeGroupRepresentation( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + path: `/${faker.word.noun()}`, + subGroups: [], + ...overrides, + } satisfies GroupRepresentation +} + +export function makeProjectUser( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + email: faker.internet.email().toLowerCase(), + ...overrides, + } satisfies ProjectWithDetails['members'][number]['user'] +} + +export function makeProjectMember( + overrides: Partial = {}, +) { + return { + roleIds: [], + user: makeProjectUser(), + ...overrides, + } satisfies ProjectWithDetails['members'][number] +} + +export function makeProjectRole( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + permissions: 0n, + oidcGroup: '', + type: 'managed', + ...overrides, + } satisfies ProjectWithDetails['roles'][number] +} + +export function makeProjectEnvironment( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + ...overrides, + } satisfies ProjectWithDetails['environments'][number] +} + +export function makeProjectWithDetails( + overrides: Partial = {}, +) { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(faker.word.words({ count: 2 })).toLowerCase(), + ownerId: faker.string.uuid(), + everyonePerms: 0n, + plugins: [], + members: [], + roles: [], + environments: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts new file mode 100644 index 0000000000..e964d9e74c --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.constants.ts @@ -0,0 +1,20 @@ +// Keycloak plugin constants +export const PLUGIN_NAME = 'keycloak' + +// Name for the console group for admin and project roles +export const CONSOLE_GROUP_NAME = 'console' + +// Realm hosting the admin user used by the admin client (password grant on admin-cli) +export const ADMIN_AUTH_REALM = 'master' + +// OAuth2 grant types (RFC 6749) used to authenticate the admin client +export const PASSWORD_GRANT_TYPE = 'password' +export const REFRESH_TOKEN_GRANT_TYPE = 'refresh_token' + +// Keycloak's default access token lifespan is 60s; refresh well before it +// expires so a slow or delayed tick still lands within the lifespan +export const ADMIN_TOKEN_REFRESH_INTERVAL_MS = 45_000 + +// Maximum number of entities returned in a paginated query +export const GROUPS_PAGINATE_QUERY_MAX = 20 +export const SUBGROUPS_PAGINATE_QUERY_MAX = 20 diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts new file mode 100644 index 0000000000..8245a556b9 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts @@ -0,0 +1,29 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface KeycloakConfig { + protocol: string + domain?: string + publicProtocol: string + publicDomain?: string + realm?: string + clientId?: string + clientSecret?: string + admin?: string + adminPassword?: string + adminClientId: string + redirectUri?: string + jwksCacheTtlMs: number + jwksTimeoutMs: number + openidConfigurationCacheTtlMs: number + adminKcUserId: string[] + url?: string + realmUrl?: string + openidConfigurationUrl?: string +} + +export interface KeycloakModuleOptions extends KeycloakConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('keycloak') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as KEYCLOAK_CONFIG } diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts new file mode 100644 index 0000000000..7186de782a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts @@ -0,0 +1,31 @@ +import type { KeycloakConfig } from './keycloak.module-definition' +import KcAdminClient from '@keycloak/keycloak-admin-client' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { KeycloakHealthService } from './keycloak-health.service' +import { KeycloakPluginService } from './keycloak-plugin.service' +import { ConfigurableModuleClass, KEYCLOAK_CONFIG } from './keycloak.module-definition' +import { KeycloakService } from './keycloak.service' + +@Module({ + imports: [DatabaseModule, TerminusModule], + providers: [ + { + inject: [KEYCLOAK_CONFIG], + provide: KEYCLOAK_ADMIN_CLIENT, + useFactory: (config: KeycloakConfig) => new KcAdminClient({ + baseUrl: config.url, + }), + }, + KeycloakClientService, + KeycloakDatastoreService, + KeycloakHealthService, + KeycloakPluginService, + KeycloakService, + ], + exports: [KeycloakClientService, KeycloakHealthService, KeycloakPluginService, KeycloakService], +}) +export class KeycloakModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts new file mode 100644 index 0000000000..e5b116688a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -0,0 +1,450 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { AdminRoleWithDetails, ProjectWithDetails, UserWithAdminRoles } from './keycloak-datastore.service' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { + makeGroupRepresentation, + makeProjectEnvironment, + makeProjectMember, + makeProjectRole, + makeProjectUser, + makeProjectWithDetails, + makeUserRepresentation, +} from './keycloak-testing.utils' +import { KeycloakService } from './keycloak.service' + +describe('keycloakService', () => { + let service: KeycloakService + let keycloak: DeepMockProxy + let datastore: DeepMockProxy + + beforeEach(async () => { + keycloak = mockDeep({ + getOrCreateConsoleGroup: vi.fn().mockResolvedValue(makeGroupRepresentation({ id: 'console-group-id', name: 'console' })), + getOrCreateEnvironmentGroups: vi.fn().mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'rw-id', name: 'RW' }), + }), + getAllGroups: vi.fn().mockImplementation(async function* () { /* empty by default */ }), + deleteGroup: vi.fn().mockResolvedValue(undefined), + }) + datastore = mockDeep({ + getAllAdminRoles: vi.fn().mockResolvedValue([]), + getAllUsersWithAdminRoleIds: vi.fn().mockResolvedValue([]), + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + KeycloakService, + { provide: KeycloakClientService, useValue: keycloak }, + { provide: KeycloakDatastoreService, useValue: datastore }, + ], + }).compile() + + service = moduleRef.get(KeycloakService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('reconcile', () => { + const mockProject: ProjectWithDetails = makeProjectWithDetails({ + id: 'project-id', + slug: 'test-project', + ownerId: 'owner-id', + everyonePerms: 0n, + }) + + it('should not sync system external admin roles', async () => { + const adminRoles: AdminRoleWithDetails[] = [ + { id: 'admin-role-id', oidcGroup: '/console/system-external', type: 'system:external' }, + ] + const users: UserWithAdminRoles[] = [ + { id: 'user-1', adminRoleIds: ['admin-role-id'] }, + ] + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + + await service.handleCron() + + expect(keycloak.getOrCreateGroupByPath).not.toHaveBeenCalledWith('/console/system-external') + expect(keycloak.getGroupMembers).not.toHaveBeenCalled() + expect(keycloak.addUserToGroup).not.toHaveBeenCalled() + expect(keycloak.removeUserFromGroup).not.toHaveBeenCalled() + }) + + it('should sync admin role groups', async () => { + const adminRoles: AdminRoleWithDetails[] = [ + { id: 'admin-role-id', oidcGroup: '/console/admin', type: 'managed' }, + ] + const users: UserWithAdminRoles[] = [ + { id: 'user-1', adminRoleIds: ['admin-role-id'] }, + { id: 'user-2', adminRoleIds: [] }, + ] + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + + const adminGroup = makeGroupRepresentation({ id: 'kc-group-id', name: 'admin', path: '/console/admin' }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(adminGroup) + + keycloak.getGroupMembers.mockResolvedValue([ + makeUserRepresentation({ id: 'user-2' }), + ]) + + await service.handleCron() + + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/console/admin') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'kc-group-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'kc-group-id') + }) + + it('should purge orphans', async () => { + datastore.getAllProjects.mockResolvedValue([mockProject]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project', subGroups: [] }) + const orphanGroup = makeGroupRepresentation({ + id: 'orphan-id', + name: 'orphan-project', + subGroups: [makeGroupRepresentation({ name: 'console' })], + }) + + keycloak.getAllGroups.mockImplementation(async function* () { + yield projectGroup + yield orphanGroup + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(keycloak.getAllGroups).toHaveBeenCalled() + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/test-project') + expect(keycloak.deleteGroup).toHaveBeenCalledWith('orphan-id') + }) + + it('should sync project members', async () => { + const projectWithMembers = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: [], + }), + ], + }) + datastore.getAllProjects.mockResolvedValue([projectWithMembers]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + + // Current members: user-2 (extra), missing user-1 + keycloak.getGroupMembers.mockResolvedValue([ + makeUserRepresentation({ id: 'user-2', email: 'user2@example.com' }), + ]) + + keycloak.getOrCreateSubGroupByName.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should add missing member + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'group-id') + // Should add owner (missing in group members) + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('owner-id', 'group-id') + // Should remove extra member + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'group-id') + }) + + it('should sync OIDC role groups', async () => { + const roleWithOidc = makeProjectRole({ + id: 'role-oidc', + permissions: 0n, + oidcGroup: '/oidc-group', + type: 'managed', + }) + const projectWithRole = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-oidc'], + }), + ], + roles: [roleWithOidc], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRole]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const roleGroup = makeGroupRepresentation({ id: 'role-group-id', name: 'oidc-group', path: '/console/oidc-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockResolvedValue(roleGroup) + + // Project members: owner + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + // Role group members: user-2 (extra), missing user-1 + if (groupId === 'role-group-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2', email: 'user2@example.com' })]) + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should create/get role group + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/oidc-group') + // Should add user-1 to role group + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'role-group-id') + // Should remove user-2 from role group + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'role-group-id') + }) + + it('should sync environment groups', async () => { + const projectWithEnv = makeProjectWithDetails({ + ...mockProject, + environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], + }) + datastore.getAllProjects.mockResolvedValue([projectWithEnv]) + + const projectGroup = makeGroupRepresentation({ + id: 'group-id', + name: 'test-project', + subGroups: [makeGroupRepresentation({ name: 'console', id: 'console-id' })], + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + + // Mock console group retrieval + keycloak.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' }), + }) + keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + if (name === 'dev') return Promise.resolve(makeGroupRepresentation({ id: 'dev-id', name: 'dev' })) + if (name === 'RO') return Promise.resolve(makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' })) + if (name === 'RW') return Promise.resolve(makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' })) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name })) + }) + + // Mock existing environments: 'staging' (extra) + keycloak.getSubGroups.mockImplementation(async function* (parentId) { + if (parentId === 'console-id') { + yield makeGroupRepresentation({ id: 'staging-id', name: 'staging' }) + } + if (parentId === 'staging-id') { + yield makeGroupRepresentation({ name: 'RO' }) + yield makeGroupRepresentation({ name: 'RW' }) + } + }) + + await service.handleCron() + + // Should create dev group + expect(keycloak.getOrCreateConsoleGroup).toHaveBeenCalledWith({ id: 'group-id', name: 'test-project' }) + // Should create RO/RW groups + expect(keycloak.getOrCreateEnvironmentGroups).toHaveBeenCalledWith({ id: 'console-id', name: 'console' }, projectWithEnv.environments[0]) + // Should delete staging group + expect(keycloak.deleteGroup).toHaveBeenCalledWith('staging-id') + }) + + it('should sync environment permissions', async () => { + const userRo = makeUserRepresentation({ id: 'user-ro', email: 'ro@example.com' }) + const userRw = makeUserRepresentation({ id: 'user-rw', email: 'rw@example.com' }) + const userNone = makeUserRepresentation({ id: 'user-none', email: 'none@example.com' }) + + const projectWithEnvAndMembers = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: userRo.id, email: userRo.email }), + roleIds: ['role-ro'], + }), + makeProjectMember({ + user: makeProjectUser({ id: userRw.id, email: userRw.email }), + roleIds: ['role-rw'], + }), + makeProjectMember({ + user: makeProjectUser({ id: userNone.id, email: userNone.email }), + roleIds: [], + }), + ], + roles: [ + makeProjectRole({ id: 'role-ro', permissions: 256n, oidcGroup: '', type: 'managed' }), + makeProjectRole({ id: 'role-rw', permissions: 8n, oidcGroup: '', type: 'managed' }), + ], + environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], + }) + datastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) + + const projectGroup = makeGroupRepresentation({ + id: 'group-id', + name: 'test-project', + subGroups: [makeGroupRepresentation({ name: 'console', id: 'console-id' })], + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' }), + rwGroup: makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' }), + }) + + // Project group members (assume all are in project group for simplicity) + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([userRo, userRw, userNone]) + // RO group has userNone (extra), missing userRo + if (groupId === 'dev-ro-id') return Promise.resolve([userNone]) + // RW group has userNone (extra), missing userRw + if (groupId === 'dev-rw-id') return Promise.resolve([userNone]) + return Promise.resolve([]) + }) + + keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve(makeGroupRepresentation({ id: 'console-id', name: 'console' })) + if (name === 'dev') return Promise.resolve(makeGroupRepresentation({ id: 'dev-id', name: 'dev' })) + if (name === 'RO') return Promise.resolve(makeGroupRepresentation({ id: 'dev-ro-id', name: 'RO' })) + if (name === 'RW') return Promise.resolve(makeGroupRepresentation({ id: 'dev-rw-id', name: 'RW' })) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name })) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Sync RO + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-ro', 'dev-ro-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-ro-id') + // Sync RW + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-rw', 'dev-rw-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-rw-id') + }) + + it('should handle different role types (managed, external, global)', async () => { + const roleManaged = makeProjectRole({ id: 'role-managed', permissions: 0n, oidcGroup: '/managed-group', type: 'managed' }) + const roleExternal = makeProjectRole({ id: 'role-external', permissions: 0n, oidcGroup: '/external-group', type: 'external' }) + const roleGlobal = makeProjectRole({ id: 'role-global', permissions: 0n, oidcGroup: '/global-group', type: 'global' }) + + const projectWithRoles = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-managed', 'role-external', 'role-global'], + }), + ], + roles: [roleManaged, roleExternal, roleGlobal], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const managedGroup = makeGroupRepresentation({ id: 'managed-id', name: 'managed-group' }) + const externalGroup = makeGroupRepresentation({ id: 'external-id', name: 'external-group' }) + const globalGroup = makeGroupRepresentation({ id: 'global-id', name: 'global-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockImplementation((_consoleGroup, oidcGroup) => { + if (oidcGroup === '/managed-group') return Promise.resolve({ ...managedGroup, path: '/console/managed-group' }) + if (oidcGroup === '/external-group') return Promise.resolve({ ...externalGroup, path: '/console/external-group' }) + if (oidcGroup === '/global-group') return Promise.resolve({ ...globalGroup, path: '/console/global-group' }) + return Promise.resolve(makeGroupRepresentation({ id: 'new-id', name: oidcGroup, path: `/console/${oidcGroup}` })) + }) + + // Group members + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + + // Managed: has extra user-2, missing user-1 + if (groupId === 'managed-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + // External: has extra user-2, missing user-1 + if (groupId === 'external-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + // Global: create group if it doesn't exist but no members + if (groupId === 'global-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Managed: should add user-1, remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/managed-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'managed-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'managed-id') + + // External: should add user-1, NOT remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/external-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'external-id') + expect(keycloak.removeUserFromGroup).not.toHaveBeenCalledWith('user-2', 'external-id') + + // Global: should sync group but no members + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/global-group') + }) + + it('should treat system-prefixed role types as their base type', async () => { + const roleSystemManaged = makeProjectRole({ id: 'role-system-managed', permissions: 0n, oidcGroup: '/system-managed-group', type: 'system:managed' }) + + const projectWithRoles = makeProjectWithDetails({ + ...mockProject, + members: [ + makeProjectMember({ + user: makeProjectUser({ id: 'user-1', email: 'user1@example.com' }), + roleIds: ['role-system-managed'], + }), + ], + roles: [roleSystemManaged], + }) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) + + const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) + const consoleGroup = { id: 'console-id', name: 'console' } + const systemManagedGroup = makeGroupRepresentation({ id: 'system-managed-id', name: 'system-managed-group' }) + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockResolvedValue({ ...systemManagedGroup, path: '/console/system-managed-group' }) + + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([makeUserRepresentation({ id: 'owner-id' })]) + + // has extra user-2, missing user-1 + if (groupId === 'system-managed-id') return Promise.resolve([makeUserRepresentation({ id: 'user-2' })]) + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // system:managed behaves like managed: add user-1, remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/system-managed-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'system-managed-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'system-managed-id') + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts new file mode 100644 index 0000000000..f737e77bcd --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -0,0 +1,670 @@ +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { AdminRoleWithDetails, ProjectWithDetails, UserWithAdminRoles } from './keycloak-datastore.service' +import type { GroupRepresentationWith } from './keycloak.utils' +import { getBaseRoleType, getPermsByUserRoles, isExternalRoleType, ProjectAuthorized, resourceListToDict } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { getErrorResponseStatus } from '../../utils/http.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { KeycloakClientService } from './keycloak-client.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { isMember, isNonEmptyGroupPath, isOwnedProjectGroup, normalizeGroupPath } from './keycloak.utils' + +@Injectable() +export class KeycloakService { + private readonly logger = new Logger(KeycloakService.name) + + constructor( + @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, + @Inject(KeycloakDatastoreService) private readonly datastore: KeycloakDatastoreService, + ) { + this.logger.log('KeycloakService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('keycloak', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroups([project]) + this.logger.log(`Keycloak sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('keycloak', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.purgeOrphanGroups([project]) + this.logger.log(`Keycloak cleanup completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting periodic Keycloak reconciliation') + const [projects, adminRoles, users] = await Promise.all([ + this.datastore.getAllProjects(), + this.datastore.getAllAdminRoles(), + this.datastore.getAllUsersWithAdminRoleIds(), + ]) + span?.setAttributes({ + 'keycloak.projects.count': projects.length, + 'keycloak.admin_roles.count': adminRoles.length, + 'keycloak.users.count': users.length, + }) + this.logger.debug(`Reconciling Keycloak state (projects=${projects.length}, adminRoles=${adminRoles.length}, users=${users.length})`) + await Promise.all([ + this.ensureAdminRoleGroups(adminRoles, users), + this.ensureProjectGroups(projects), + ]) + await this.purgeOrphanGroups(projects) + this.logger.log(`Keycloak reconciliation completed (projects=${projects.length}, adminRoles=${adminRoles.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.projects.count', projects.length) + this.logger.verbose(`Reconciling Keycloak project groups (${projects.length})`) + await Promise.all(projects.map(project => this.ensureProjectGroup(project))) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'project.members.count': project.members.length, + 'project.roles.count': project.roles.length, + 'project.environments.count': project.environments.length, + }) + this.logger.verbose(`Reconciling Keycloak project group (${project.slug}): members=${project.members.length} roles=${project.roles.length}`) + + const projectGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateGroupByPath(`/${project.slug}`)) + + span?.setAttribute('keycloak.project_group.id', projectGroup.id) + + await Promise.all([ + this.ensureProjectGroupMembers(project, projectGroup), + this.ensureConsoleGroup(project, projectGroup), + ]) + this.logger.verbose(`Keycloak project group reconciled (${project.slug}): groupId=${projectGroup.id}`) + } + + @StartActiveSpan() + private async ensureConsoleGroup(project: ProjectWithDetails, group: GroupRepresentationWith<'id'>) { + const span = trace.getActiveSpan() + span?.setAttribute('keycloak.console_group.id', group.id) + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateConsoleGroup(group)) + this.logger.verbose(`Reconciling Keycloak console group (${project.slug}): projectGroupId=${group.id} consoleGroupId=${consoleGroup.id}`) + await Promise.all([ + this.ensureRoleGroups(project, consoleGroup), + this.ensureEnvironmentGroups(project, consoleGroup), + this.purgeOrphanEnvironmentGroups(project, consoleGroup), + ]) + } + + @StartActiveSpan() + private async ensureAdminRoleGroups( + adminRoles: AdminRoleWithDetails[], + users: UserWithAdminRoles[], + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'keycloak.admin_roles.count': adminRoles.length, + 'keycloak.users.count': users.length, + }) + const rolesWithOidcGroup = adminRoles.filter(r => isNonEmptyGroupPath(r.oidcGroup) && !isExternalRoleType(r.type)) + span?.setAttribute('keycloak.admin_roles.oidc_group.count', rolesWithOidcGroup.length) + await Promise.all(rolesWithOidcGroup.map(role => this.ensureAdminRoleGroup(role, users))) + } + + @StartActiveSpan() + private async ensureAdminRoleGroup( + role: AdminRoleWithDetails, + users: UserWithAdminRoles[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('admin_role.id', role.id) + span?.setAttribute('admin_role.oidc_group.present', isNonEmptyGroupPath(role.oidcGroup)) + const roleGroupPath = normalizeGroupPath(role.oidcGroup) + if (!roleGroupPath) return + + span?.setAttribute('keycloak.group.path', roleGroupPath) + const roleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateGroupByPath(roleGroupPath)) + span?.setAttribute('keycloak.group.id', roleGroup.id) + + const groupMembers = await this.keycloak.getGroupMembers(roleGroup.id) + + const desiredUserIds = new Set( + users + .filter(u => u.adminRoleIds.includes(role.id)) + .map(u => u.id), + ) + span?.setAttribute('keycloak.group.members.desired', desiredUserIds.size) + + let addedCount = 0 + let removedCount = 0 + + await Promise.all([ + ...Array.from(desiredUserIds, async (userId) => { + if (!groupMembers.some(m => m.id === userId)) { + addedCount++ + await this.maybeAddUserToGroup(userId, roleGroup.id, roleGroup.name) + } + }), + ...groupMembers.map(async (member) => { + if (member.id && !desiredUserIds.has(member.id)) { + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, roleGroup.id, roleGroup.name) + } + }), + ]) + + span?.setAttribute('keycloak.group.members.added', addedCount) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + @StartActiveSpan() + private async purgeOrphanGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + const groups = map(this.keycloak.getAllGroups(), async (group) => { + return z.object({ + id: z.string(), + name: z.string(), + subGroups: z.array(z.object({ name: z.string() })), + }).parse(group) + }) + const projectSlugs = new Set(projects.map(p => p.slug)) + this.logger.verbose(`Scanning Keycloak groups for orphan cleanup (projects=${projects.length})`) + const promises: Promise[] = [] + let purgedCount = 0 + + for await (const group of groups) { + if (!projectSlugs.has(group.name)) { + if (isOwnedProjectGroup(group)) { + this.logger.log(`Deleting an orphan Keycloak group (groupId=${group.id}, groupName=${group.name})`) + purgedCount++ + promises.push(this.keycloak.deleteGroup(group.id)) + } + } + } + span?.setAttribute('purged.count', purgedCount) + await Promise.all(promises) + this.logger.log(`Orphan Keycloak group cleanup completed (purged=${purgedCount})`) + } + + private async maybeAddUserToGroup(userId: string, groupId: string, groupName: string) { + try { + await this.keycloak.addUserToGroup(userId, groupId) + this.logger.log(`Added user to Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } catch (e) { + const status = getErrorResponseStatus(e) + if (status === 404) { + this.logger.warn(`User not found in Keycloak, skipping addition: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else if (status === 409) { + this.logger.verbose(`User already a member of Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else { + throw e + } + } + } + + private async maybeRemoveUserFromGroup(userId: string, groupId: string, groupName: string) { + try { + await this.keycloak.removeUserFromGroup(userId, groupId) + this.logger.log(`Removed user from Keycloak group: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } catch (e) { + if (getErrorResponseStatus(e) === 404) { + this.logger.warn(`User not found in Keycloak, skipping removal: userId=${userId} groupId=${groupId} groupName=${groupName}`) + } else { + throw e + } + } + } + + @StartActiveSpan() + private async ensureProjectGroupMembers( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('keycloak.group.id', group.id) + + const groupMembers = await this.keycloak.getGroupMembers(group.id) + const desiredUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + + span?.setAttribute('keycloak.group.members.current', groupMembers.length) + span?.setAttribute('keycloak.group.members.desired', desiredUserIds.size) + + let addedCount = 0 + let removedCount = 0 + + await Promise.all([ + ...Array.from(desiredUserIds, async (userId) => { + if (!groupMembers.some(m => m.id === userId)) { + addedCount++ + await this.maybeAddUserToGroup(userId, group.id, group.name) + } + }), + ...groupMembers.map(async (member) => { + if (member.id && !desiredUserIds.has(member.id)) { + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, group.id, group.name) + } + }), + ]) + + span?.setAttribute('keycloak.group.members.added', addedCount) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + @StartActiveSpan() + private async ensureRoleGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'keycloak.group.id': group.id, + 'project.roles.count': project.roles.length, + }) + + const rolesWithOidcGroup = project.roles.filter(r => isNonEmptyGroupPath(r.oidcGroup)) + span?.setAttribute('project.roles.oidc_group.count', rolesWithOidcGroup.length) + + await Promise.all(rolesWithOidcGroup.map(role => this.ensureRoleGroup(project, role, group))) + } + + @StartActiveSpan() + private async ensureRoleGroup( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('role.oidc_group.present', isNonEmptyGroupPath(role.oidcGroup)) + if (!isNonEmptyGroupPath(role.oidcGroup)) return + + span?.setAttribute('role.oidc_group.depth', role.oidcGroup.split('/').filter(Boolean).length) + + const roleGroup = await this.keycloak.getOrCreateRoleGroup(group, role.oidcGroup) + span?.setAttribute('keycloak.group.id', roleGroup.id) + span?.setAttribute('keycloak.group.path', roleGroup.path) + + const groupMembers = await this.keycloak.getGroupMembers(roleGroup.id) + span?.setAttribute('keycloak.group.members.current', groupMembers.length) + + switch (getBaseRoleType(role.type)) { + case 'managed': + await Promise.all([ + this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers), + this.purgeOrphanRoleGroupMembers(project, role, roleGroup, groupMembers), + ]) + break + case 'external': + await this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers) + break + case 'global': + await this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers) + break + default: + throw new Error(`Unknown role type ${role.type}`) + } + } + + @StartActiveSpan() + private async ensureRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.members.current', members.length) + + const desiredMemberIds = project.members + .filter(m => m.roleIds.includes(role.id)) + .map(m => m.user.id) + + span?.setAttribute('keycloak.group.members.desired', desiredMemberIds.length) + + let addedCount = 0 + await Promise.all(project.members.map(async (member) => { + if (!members.some(m => m.id === member.user.id) && member.roleIds.includes(role.id)) { + addedCount++ + await this.maybeAddUserToGroup(member.user.id, group.id, group.name) + } + })) + + span?.setAttribute('keycloak.group.members.added', addedCount) + } + + @StartActiveSpan() + private async purgeOrphanRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('role.id', role.id) + span?.setAttribute('role.type', role.type) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.members.current', members.length) + + let removedCount = 0 + await Promise.all(members.map(async (member) => { + if (!isMember(project, member)) { + if (!member.id) { + throw new Error(`Failed to create or retrieve role group for ${role.oidcGroup}`) + } + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, group.id, group.name) + } + })) + span?.setAttribute('keycloak.group.members.removed', removedCount) + } + + private async ensureEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id'>, + ) { + await Promise.all(project.environments.map(environment => + this.ensureEnvironmentGroup(project, environment, group))) + } + + @StartActiveSpan() + private async ensureEnvironmentGroup( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + group: GroupRepresentationWith<'id'>, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'environment.id': environment.id, + 'environment.name': environment.name, + 'project.roles.count': project.roles.length, + }) + + const { roGroup, rwGroup } = z.object({ + roGroup: z.object({ + id: z.string(), + name: z.string(), + }), + rwGroup: z.object({ + id: z.string(), + name: z.string(), + }), + }).parse(await this.keycloak.getOrCreateEnvironmentGroups(group, environment)) + + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + + const rolesById = resourceListToDict(project.roles) + + const [roMembers, rwMembers] = await Promise.all([ + this.keycloak.getGroupMembers(roGroup.id), + this.keycloak.getGroupMembers(rwGroup.id), + ]) + + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + + await Promise.all([ + this.ensureEnvironmentGroupMembers( + project, + environment, + rolesById, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + this.purgeOrphanEnvironmentGroupMembers( + project, + environment, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + ]) + } + + @StartActiveSpan() + private async ensureEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + rolesById: Record, + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('environment.id', environment.id) + span?.setAttribute('environment.name', environment.name) + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + this.logger.verbose(`Reconciling Keycloak environment group members: project=${project.slug} env=${environment.name} roMembers=${roMembers.length} rwMembers=${rwMembers.length}`) + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + span?.setAttribute('project.users.count', projectUserIds.size) + + let roAdded = 0 + let roRemoved = 0 + let rwAdded = 0 + let rwRemoved = 0 + + await Promise.all(Array.from(projectUserIds, async (userId) => { + const perms = this.getUserPermissions(project, rolesById, userId) + + const isInRo = roMembers.some(m => m.id === userId) + if (perms.ro && !isInRo) { + roAdded++ + await this.maybeAddUserToGroup(userId, roGroup.id, `RO group for ${environment.name}`) + } else if (!perms.ro && isInRo) { + roRemoved++ + await this.maybeRemoveUserFromGroup(userId, roGroup.id, `RO group for ${environment.name}`) + } + + const isInRw = rwMembers.some(m => m.id === userId) + if (perms.rw && !isInRw) { + rwAdded++ + await this.maybeAddUserToGroup(userId, rwGroup.id, `RW group for ${environment.name}`) + } else if (!perms.rw && isInRw) { + rwRemoved++ + await this.maybeRemoveUserFromGroup(userId, rwGroup.id, `RW group for ${environment.name}`) + } + })) + + span?.setAttribute('keycloak.env_group.ro.members.added', roAdded) + span?.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) + span?.setAttribute('keycloak.env_group.rw.members.added', rwAdded) + span?.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) + this.logger.verbose(`Keycloak environment group members reconciled: project=${project.slug} env=${environment.name} roAdded=${roAdded} roRemoved=${roRemoved} rwAdded=${rwAdded} rwRemoved=${rwRemoved}`) + } + + @StartActiveSpan() + private async purgeOrphanEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('environment.id', environment.id) + span?.setAttribute('environment.name', environment.name) + span?.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span?.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + span?.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span?.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + this.logger.verbose(`Purging orphan Keycloak environment group members: project=${project.slug} env=${environment.name} roMembers=${roMembers.length} rwMembers=${rwMembers.length}`) + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + span?.setAttribute('project.users.count', projectUserIds.size) + + let roRemoved = 0 + let rwRemoved = 0 + + await Promise.all([ + ...roMembers.map(async (member) => { + if (!member.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + if (!projectUserIds.has(member.id)) { + roRemoved++ + await this.maybeRemoveUserFromGroup(member.id, roGroup.id, `RO group for ${environment.name}`) + } + }), + ...rwMembers.map(async (member) => { + if (!member.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + if (!projectUserIds.has(member.id)) { + rwRemoved++ + await this.maybeRemoveUserFromGroup(member.id, rwGroup.id, `RW group for ${environment.name}`) + } + }), + ]) + + span?.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) + span?.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) + this.logger.log(`Orphan Keycloak environment group member cleanup completed: project=${project.slug} env=${environment.name} roRemoved=${roRemoved} rwRemoved=${rwRemoved}`) + } + + @StartActiveSpan() + private async purgeOrphanEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('keycloak.group.id', group.id) + span?.setAttribute('keycloak.group.name', group.name) + this.logger.verbose(`Scanning Keycloak environment groups for orphan cleanup: project=${project.slug} groupId=${group.id} groupName=${group.name}`) + + const envGroups = map(this.keycloak.getSubGroups(group.id), envGroup => z.object({ + id: z.string(), + name: z.string(), + }).parse(envGroup)) + + const promises: Promise[] = [] + let orphanCount = 0 + + for await (const envGroup of envGroups) { + span?.setAttribute('keycloak.env_group.id', envGroup.id) + span?.setAttribute('keycloak.env_group.name', envGroup.name) + + const subGroups = await getAll(map(this.keycloak.getSubGroups(envGroup.id), subgroup => z.object({ + name: z.string(), + }).parse(subgroup))) + + if (this.isEnvironmentGroup(subGroups) && !this.isOwnedEnvironmentGroup(project, envGroup)) { + orphanCount++ + this.logger.log(`Deleting orphan Keycloak environment group: project=${project.slug} envGroupId=${envGroup.id} envGroupName=${envGroup.name}`) + promises.push( + this.keycloak.deleteGroup(envGroup.id) + .catch(err => this.logger.warn(`Failed to delete orphan Keycloak environment group: project=${project.slug} envGroupId=${envGroup.id} envGroupName=${envGroup.name} err=${err instanceof Error ? err.message : String(err)}`)), + ) + } + } + + span?.setAttribute('keycloak.env_groups.orphan.count', orphanCount) + await Promise.all(promises) + this.logger.log(`Orphan Keycloak environment group cleanup completed: project=${project.slug} groupId=${group.id} orphanCount=${orphanCount}`) + } + + private isEnvironmentGroup( + subGroups: GroupRepresentationWith<'name'>[], + ) { + return subGroups.some(subgroup => subgroup.name === 'RO' || subgroup.name === 'RW') + } + + private isOwnedEnvironmentGroup( + project: ProjectWithDetails, + group: GroupRepresentationWith<'name'>, + ) { + return project.environments.some(e => e.name === group.name) + } + + private getUserPermissions( + project: ProjectWithDetails, + rolesById: Record, + userId: string, + ) { + if (userId === project.ownerId) return { ro: true, rw: true } + const member = project.members.find(m => m.user.id === userId) + if (!member) return { ro: false, rw: false } + + const projectPermissions = getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) + + return { + ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions }), + rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions }), + } + } +} + +async function* map( + iterable: AsyncIterable, + mapper: (value: T, index: number) => U | Promise, +): AsyncIterable { + let index = 0 + for await (const value of iterable) { + yield await mapper(value, index++) + } +} + +async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts new file mode 100644 index 0000000000..ac56609793 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/keycloak/keycloak.utils.ts @@ -0,0 +1,25 @@ +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import { CONSOLE_GROUP_NAME } from './keycloak.constants' + +type With = T & Required> +export type GroupRepresentationWith = With + +export function isMember(project: ProjectWithDetails, member: UserRepresentation): boolean { + return project.members.some(m => m.user.id === member.id) || project.ownerId === member.id +} + +export function isNonEmptyGroupPath(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} + +export function normalizeGroupPath(value: unknown): string | undefined { + if (!isNonEmptyGroupPath(value)) return undefined + const trimmed = value.trim() + return trimmed.startsWith('/') ? trimmed : `/${trimmed}` +} + +export function isOwnedProjectGroup(group: GroupRepresentationWith<'subGroups'>): boolean { + return !!group.subGroups.some(sg => sg.name === CONSOLE_GROUP_NAME) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log-queries.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log-queries.utils.ts new file mode 100644 index 0000000000..7c8e6de59f --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log-queries.utils.ts @@ -0,0 +1,30 @@ +import type { Prisma } from '@prisma/client' + +const logSelect = { + id: true, + createdAt: true, + updatedAt: true, + action: true, + userId: true, + requestId: true, + projectId: true, + data: true, +} satisfies Prisma.LogSelect + +export type LogSelect = Prisma.LogGetPayload<{ + select: typeof logSelect +}> + +export function countLogs(tx: Prisma.TransactionClient, projectId: string | null | undefined) { + return tx.log.count({ where: { projectId } }) +} + +export function listLogs(tx: Prisma.TransactionClient, projectId: string | null | undefined, offset: number, limit: number) { + return tx.log.findMany({ + orderBy: { createdAt: 'desc' }, + skip: offset, + take: limit, + where: { projectId }, + select: logSelect, + }) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log-testing.utils.ts new file mode 100644 index 0000000000..1e90dffb17 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log-testing.utils.ts @@ -0,0 +1,16 @@ +import type { Log } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeLog(overrides: Partial = {}): Log { + return { + id: faker.string.uuid(), + data: {}, + action: faker.word.verb(), + userId: null, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + requestId: faker.string.uuid(), + projectId: faker.string.uuid(), + ...overrides, + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.controller.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.controller.spec.ts new file mode 100644 index 0000000000..fa9dfd0118 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.controller.spec.ts @@ -0,0 +1,79 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { ADMIN_PERMS } from '@cpn-console/shared' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ProjectPermissionLoaderService } from '../infrastructure/permission/project/project-loader.service' +import { makeProjectContext } from '../infrastructure/permission/project/project.testing.utils' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { LogController } from './log.controller' +import { LogService } from './log.service' + +describe('logController', () => { + let module: TestingModule + let controller: LogController + let logs: DeepMockProxy + let projectLoader: DeepMockProxy + + beforeEach(async () => { + logs = mockDeep() + projectLoader = mockDeep() + + module = await Test.createTestingModule({ + controllers: [LogController], + providers: [ + { provide: LogService, useValue: logs }, + { provide: ProjectPermissionLoaderService, useValue: projectLoader }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(LogController) + }) + + it('returns logs for system admins without project lookup', async () => { + logs.getLogs.mockResolvedValueOnce({ total: 1, logs: [] }) + + const response = await controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: undefined }, + { userId: 'user-1', adminPermissions: ADMIN_PERMS.LIST_SYSTEM, userType: 'human' }, + ) + + expect(projectLoader.load).not.toHaveBeenCalled() + expect(logs.getLogs).toHaveBeenCalledWith({ limit: 10, offset: 0, clean: false, projectId: undefined }) + expect(response).toEqual({ total: 1, logs: [] }) + }) + + it('forces clean logs for project-scoped non-admin access', async () => { + projectLoader.load.mockResolvedValueOnce(makeProjectContext({ projectPermissions: 1n })) + logs.getLogs.mockResolvedValueOnce({ total: 1, logs: [] }) + + await controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: 'project-1' }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + ) + + expect(projectLoader.load).toHaveBeenCalledTimes(1) + expect(logs.getLogs).toHaveBeenCalledWith({ limit: 10, offset: 0, clean: true, projectId: 'project-1' }) + }) + + it('rejects non-admin access without projectId', async () => { + await expect(controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: undefined }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + )).rejects.toThrow(ForbiddenException) + }) + + it('rejects non-admin access when user has no project permissions', async () => { + projectLoader.load.mockResolvedValueOnce(makeProjectContext({ projectPermissions: 0n })) + + await expect(controller.getLogs( + { limit: 10, offset: 0, clean: false, projectId: 'project-1' }, + { userId: 'user-1', adminPermissions: 0n, userType: 'human' }, + )).rejects.toThrow(ForbiddenException) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.controller.ts new file mode 100644 index 0000000000..951ee4f472 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.controller.ts @@ -0,0 +1,47 @@ +import type { AdminLogsQuery } from '@cpn-console/shared' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { AdminAuthorized, logContract } from '@cpn-console/shared' +import { Controller, ForbiddenException, Get, Inject, Query, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { ProjectPermissionLoaderService } from '../infrastructure/permission/project/project-loader.service' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { LogService } from './log.service' + +@Controller('api/v1/logs') +@UseGuards(UserGuard) +export class LogController { + constructor( + @Inject(LogService) private readonly logs: LogService, + @Inject(ProjectPermissionLoaderService) private readonly projectLoader: ProjectPermissionLoaderService, + ) {} + + @Get('') + async getLogs( + @Query(new ZodValidationPipe(logContract.getLogs.query)) query: AdminLogsQuery, + @AuthUser() user: UserContext, + ) { + const isSystemAdmin = AdminAuthorized.ListSystem(user.adminPermissions ?? 0n) + let effectiveQuery = query + + if (!isSystemAdmin) { + if (!query.projectId) { + throw new ForbiddenException() + } + + const project = await this.projectLoader.load( + { params: { projectId: query.projectId } } as Parameters[0], + user.userId, + { includePermissions: true, includeLocked: false, includeStatus: false }, + ) + + if (!project.projectPermissions) { + throw new ForbiddenException() + } + + effectiveQuery = { ...query, clean: true } + } + + return this.logs.getLogs(effectiveQuery) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.module.ts new file mode 100644 index 0000000000..f80194cb54 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { LogController } from './log.controller' +import { LogService } from './log.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, UserPermissionModule], + controllers: [LogController], + providers: [LogService], + exports: [LogService], +}) +export class LogModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.service.spec.ts new file mode 100644 index 0000000000..608bafbdbd --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.service.spec.ts @@ -0,0 +1,67 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeLog } from './log-testing.utils' +import { LogService } from './log.service' + +describe('logService', () => { + let module: TestingModule + let service: LogService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + LogService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(LogService) + }) + + it('should map clean logs', async () => { + const dbLogs = [makeLog({ + data: { args: {} }, + action: 'Action', + })] + + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + + const { logs } = await service.getLogs({ + limit: 10, + offset: 10, + clean: true, + projectId: undefined, + }) + + expect(logs[0]).not.toHaveProperty('requestId') + expect(logs[0].data).not.toHaveProperty('results') + expect(logs[0].data).not.toHaveProperty('args') + expect(logs[0].data).not.toHaveProperty('config') + }) + + it('should not filter admin logs', async () => { + const dbLogs = [makeLog({ + data: { args: {} }, + action: 'Action', + })] + + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + + const { logs } = await service.getLogs({ + limit: 10, + offset: 10, + clean: false, + projectId: undefined, + }) + + expect(logs[0].data).toHaveProperty('args') + expect(logs[0].data).not.toHaveProperty('config') + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.service.ts new file mode 100644 index 0000000000..8aa403a41c --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/log/log.service.ts @@ -0,0 +1,74 @@ +import type { AdminLogsQuery } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import { CleanLogSchema, exclude } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { countLogs, listLogs } from './log-queries.utils' + +/** + * Shape of the `data` JSON column, aligned with the LogSchema contract in + * `@cpn-console/shared` so the console can parse and display every entry. + */ +export interface LogData { + /** Payload the action/event was invoked with (e.g. the full project). */ + args?: unknown + /** Names of the services that reported a KO result. */ + failed?: boolean | string[] + /** Per-service outcome, keyed by service name. */ + results?: Record + totalExecutionTime?: number + messageResume?: string + /** Extra keys are persisted as-is, except the sensitive ones stripped by addLog. */ + [key: string]: unknown +} + +interface AddLogArgs { + action: string + data: LogData + userId?: string | null + requestId?: string | null + projectId?: string | null +} + +@Injectable() +export class LogService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + ) {} + + async getLogs(params: AdminLogsQuery) { + const [total, logs] = await this.getLogsWithCount(params.projectId, params.offset, params.limit) + + return { + total, + logs: params.clean + ? logs.map(log => CleanLogSchema.parse(log)) + : logs, + } + } + + async getLogsWithCount(projectId: string | null | undefined, offset: number, limit: number) { + return this.prisma.$transaction([ + countLogs(this.prisma, projectId), + listLogs(this.prisma, projectId, offset, limit), + ]) + } + + async addLog({ + action, + data, + userId = null, + requestId = null, + projectId = null, + }: AddLogArgs) { + return this.prisma.log.create({ + data: { + action, + userId, + requestId, + projectId, + data: exclude(data, ['cluster', 'user', 'newCreds', 'apis', 'config']) as Prisma.InputJsonValue, + }, + }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts new file mode 100644 index 0000000000..5ac2c471b3 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts @@ -0,0 +1,82 @@ +import type { ConfigType } from '@nestjs/config' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { nexusConfigFactory } from '../../config/nexus.config' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { NexusClientService } from './nexus-client.service' +import { NexusHttpClientService } from './nexus-http-client.service' +import { NEXUS_CONFIG } from './nexus.module-definition' + +const nexusUrl = 'https://nexus.internal' + +const server = setupServer() +const nexusAdminPassword = faker.internet.password() +const basicAuth = `Basic ${Buffer.from(`admin:${nexusAdminPassword}`, 'utf8').toString('base64')}` + +describe('nexusClientService', () => { + let service: NexusClientService + let config: DeepMockProxy> + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + beforeEach(async () => { + config = mockDeep>({ + internalUrl: nexusUrl, + admin: 'admin', + adminPassword: nexusAdminPassword, + internalOrPublicUrl: nexusUrl, + }) + + const module = await Test.createTestingModule({ + providers: [ + NexusClientService, + NexusHttpClientService, + { + provide: NEXUS_CONFIG, + useValue: config, + }, + ], + }).compile() + + service = module.get(NexusClientService) + }) + + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should return null on 404 (getRepositoriesMavenHosted)', async () => { + server.use( + http.get(`${nexusUrl}/service/rest/v1/repositories/maven/hosted/:name`, ({ request }) => { + expect(request.headers.get('authorization')).toBe(basicAuth) + return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.getRepositoriesMavenHosted('missing')).resolves.toBeNull() + }) + + it('should send basic auth and plain text body on change-password', async () => { + server.use( + http.put(`${nexusUrl}/service/rest/v1/security/users/:userId/change-password`, async ({ request, params }) => { + expect(request.method).toBe('PUT') + expect(request.url).toBe(`${nexusUrl}/service/rest/v1/security/users/u1/change-password`) + expect(params.userId).toBe('u1') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('content-type')).toContain('text/plain') + expect(await request.text()).toBe('pw123') + return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) + }), + ) + + await service.updateSecurityUsersChangePassword('u1', 'pw123') + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts new file mode 100644 index 0000000000..85f9e81d62 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-client.service.ts @@ -0,0 +1,297 @@ +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { NexusError, NexusHttpClientService } from './nexus-http-client.service' + +interface NexusRepositoryStorage { + blobStoreName: string + strictContentTypeValidation: boolean + writePolicy?: string +} + +interface NexusRepositoryCleanup { + policyNames: string[] +} + +interface NexusRepositoryComponent { + proprietaryComponents: boolean +} + +interface NexusRepositoryGroup { + memberNames: string[] +} + +export interface NexusMavenHostedRepository { + name: string + online: boolean + storage: NexusRepositoryStorage & { writePolicy: string } + cleanup?: NexusRepositoryCleanup + component: NexusRepositoryComponent + maven: { + versionPolicy: string + layoutPolicy: string + contentDisposition: string + } +} + +interface NexusMavenHostedRepositoryUpsertRequest extends NexusMavenHostedRepository {} + +export interface NexusMavenGroupRepository { + name: string + online: boolean + storage: Omit + group: NexusRepositoryGroup +} + +interface NexusMavenGroupRepositoryUpsertRequest extends NexusMavenGroupRepository {} + +export interface NexusNpmHostedRepository { + name: string + online: boolean + storage: NexusRepositoryStorage & { writePolicy: string } + cleanup?: NexusRepositoryCleanup + component: NexusRepositoryComponent +} + +interface NexusNpmHostedRepositoryUpsertRequest extends NexusNpmHostedRepository {} + +export interface NexusNpmGroupRepository { + name: string + online: boolean + storage: Omit + group: NexusRepositoryGroup +} + +interface NexusNpmGroupRepositoryUpsertRequest extends NexusNpmGroupRepository {} + +interface NexusRepositoryViewPrivilege { + name: string + description: string + actions: string[] + format: string + repository: string +} + +interface NexusRepositoryViewPrivilegeUpsertRequest extends NexusRepositoryViewPrivilege {} + +interface NexusRole { + id: string + name: string + privileges: string[] + source?: string + roles?: string[] + description?: string +} + +interface NexusRoleCreateRequest extends NexusRole { + description: string +} + +interface NexusRoleUpdateRequest extends NexusRole {} + +interface NexusUserCreateRequest { + userId: string + firstName: string + lastName: string + emailAddress: string + password: string + status: string + roles: string[] +} + +export interface NexusPrivilege extends NexusRepositoryViewPrivilege { + type: string +} + +@Injectable() +export class NexusClientService { + constructor( + @Inject(NexusHttpClientService) private readonly http: NexusHttpClientService, + ) {} + + @StartActiveSpan() + async getRepositoriesMavenHosted(name: string) { + try { + const res = await this.http.fetch(`repositories/maven/hosted/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createRepositoriesMavenHosted(body: NexusMavenHostedRepositoryUpsertRequest) { + await this.http.fetch('repositories/maven/hosted', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesMavenHosted(name: string, body: NexusMavenHostedRepositoryUpsertRequest) { + await this.http.fetch(`repositories/maven/hosted/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async createRepositoriesMavenGroup(body: NexusMavenGroupRepositoryUpsertRequest) { + await this.http.fetch('repositories/maven/group', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesMavenGroup(name: string, body: NexusMavenGroupRepositoryUpsertRequest) { + await this.http.fetch(`repositories/maven/group/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getRepositoriesMavenGroup(name: string) { + try { + const res = await this.http.fetch(`repositories/maven/group/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async getRepositoriesNpmHosted(name: string) { + try { + const res = await this.http.fetch(`repositories/npm/hosted/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createRepositoriesNpmHosted(body: NexusNpmHostedRepositoryUpsertRequest) { + await this.http.fetch('repositories/npm/hosted', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateRepositoriesNpmHosted(name: string, body: NexusNpmHostedRepositoryUpsertRequest) { + await this.http.fetch(`repositories/npm/hosted/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getRepositoriesNpmGroup(name: string): Promise { + try { + const res = await this.http.fetch(`repositories/npm/group/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async postRepositoriesNpmGroup(body: NexusNpmGroupRepositoryUpsertRequest) { + await this.http.fetch('repositories/npm/group', { method: 'POST', body }) + } + + @StartActiveSpan() + async putRepositoriesNpmGroup(name: string, body: NexusNpmGroupRepositoryUpsertRequest) { + await this.http.fetch(`repositories/npm/group/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async getSecurityPrivileges(name: string): Promise { + try { + const res = await this.http.fetch(`security/privileges/${name}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createSecurityPrivilegesRepositoryView(body: NexusRepositoryViewPrivilegeUpsertRequest) { + await this.http.fetch('security/privileges/repository-view', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateSecurityPrivilegesRepositoryView(name: string, body: NexusRepositoryViewPrivilegeUpsertRequest) { + await this.http.fetch(`security/privileges/repository-view/${name}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async deleteSecurityPrivileges(name: string) { + try { + await this.http.fetch(`security/privileges/${name}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async getSecurityRoles(id: string): Promise { + try { + const res = await this.http.fetch(`security/roles/${id}`) + return res.data + } catch (error) { + if (error instanceof NexusError && error.status === 404) return null + throw error + } + } + + @StartActiveSpan() + async createSecurityRoles(body: NexusRoleCreateRequest) { + await this.http.fetch('security/roles', { method: 'POST', body }) + } + + @StartActiveSpan() + async updateSecurityRoles(id: string, body: NexusRoleUpdateRequest) { + await this.http.fetch(`security/roles/${id}`, { method: 'PUT', body }) + } + + @StartActiveSpan() + async deleteSecurityRoles(id: string) { + try { + await this.http.fetch(`security/roles/${id}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async getSecurityUsers(userId: string): Promise<{ userId: string }[]> { + const query = new URLSearchParams({ userId }).toString() + const res = await this.http.fetch<{ userId: string }[]>(`security/users?${query}`) + return res.data ?? [] + } + + @StartActiveSpan() + async updateSecurityUsersChangePassword(userId: string, password: string) { + await this.http.fetch(`security/users/${userId}/change-password`, { + method: 'PUT', + body: password, + headers: { 'Content-Type': 'text/plain' }, + }) + } + + @StartActiveSpan() + async createSecurityUsers(body: NexusUserCreateRequest) { + await this.http.fetch('security/users', { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSecurityUsers(userId: string) { + try { + await this.http.fetch(`security/users/${userId}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } + + @StartActiveSpan() + async deleteRepositoriesByName(name: string) { + try { + await this.http.fetch(`repositories/${name}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof NexusError && error.status === 404) return + throw error + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts new file mode 100644 index 0000000000..ddbb28b966 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-datastore.service.ts @@ -0,0 +1,66 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './nexus.constants' + +export const projectSelect = { + slug: true, + owner: { + select: { + email: true, + firstName: true, + lastName: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class NexusDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts new file mode 100644 index 0000000000..5914403606 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts @@ -0,0 +1,27 @@ +import type { NexusConfig } from './nexus.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { NEXUS_CONFIG } from './nexus.module-definition' + +@Injectable() +export class NexusHealthService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const credentials = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` + const encoded = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${encoded}` + try { + const response = await fetch(this.nexusConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts new file mode 100644 index 0000000000..8575a4cd18 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts @@ -0,0 +1,129 @@ +import type { NexusConfig } from './nexus.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { NEXUS_CONFIG } from './nexus.module-definition' + +export interface NexusFetchOptions { + method?: string + body?: unknown + headers?: Record +} + +export interface NexusResponse { + status: HttpStatus + data: T | null +} + +export type NexusErrorKind + = | 'NotConfigured' + | 'HttpError' + | 'Unexpected' + +export class NexusError extends Error { + readonly kind: NexusErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + + constructor( + kind: NexusErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string } = {}, + ) { + super(message) + this.name = 'NexusError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + } +} + +@Injectable() +export class NexusHttpClientService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + ) {} + + @StartActiveSpan() + async fetch(path: string, options: NexusFetchOptions = {}): Promise> { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('nexus.method', method) + span?.setAttribute('nexus.path', path) + + const request = this.createRequest(path, method, options.body, options.headers) + const response = await fetch(request).catch((error) => { + throw new NexusError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('nexus.http.status', response.status) + const result = await handleResponse(response) + if (!response.ok) { + throw new NexusError('HttpError', `Request failed: ${method} ${path} responded ${result.status} ${response.statusText}`, { + status: result.status, + method, + path, + statusText: response.statusText, + }) + } + return result + } + + private get baseUrl() { + const url = this.nexusConfig.internalOrPublicUrl + if (!url) { + throw new NexusError('NotConfigured', 'NEXUS_INTERNAL_URL or NEXUS_URL is required') + } + return url + } + + private get apiBaseUrl() { + return new URL('service/rest/v1/', this.baseUrl).toString() + } + + private get basicAuth() { + if (!this.nexusConfig.admin) { + throw new NexusError('NotConfigured', 'NEXUS_ADMIN is required') + } + if (!this.nexusConfig.adminPassword) { + throw new NexusError('NotConfigured', 'NEXUS_ADMIN_PASSWORD is required') + } + const raw = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` + return Buffer.from(raw, 'utf8').toString('base64') + } + + private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record): Request { + const url = new URL(path, this.apiBaseUrl).toString() + const headers: Record = { + Authorization: `Basic ${this.basicAuth}`, + ...extraHeaders, + } + let requestBody: string | undefined + if (body !== undefined) { + if (typeof body === 'string') { + requestBody = body + headers['Content-Type'] = 'text/plain' + } else { + requestBody = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + } + return new Request(url, { method, headers, body: requestBody }) + } +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts new file mode 100644 index 0000000000..eab6e89046 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts @@ -0,0 +1,37 @@ +import type { ConfigType } from '@nestjs/config' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { nexusConfigFactory } from '../../config/nexus.config' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { NexusPluginService } from './nexus-plugin.service' +import { NEXUS_CONFIG } from './nexus.module-definition' + +describe('nexusPluginService', () => { + let service: NexusPluginService + let config: DeepMockProxy> + + beforeEach(async () => { + config = mockDeep>({ + url: 'https://nexus.public/', + internalUrl: 'https://nexus.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + NexusPluginService, + { provide: NEXUS_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(NexusPluginService) + }) + + it('returns the public Nexus url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://nexus.public/') + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts new file mode 100644 index 0000000000..3682cba90e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts @@ -0,0 +1,176 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { NexusConfig } from './nexus.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { NEXUS_CONFIG } from './nexus.module-definition' + +@Injectable() +export class NexusPluginService { + constructor( + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'nexus', + to: () => this.nexusConfig.url, + title: 'Nexus', + imgSrc: '/img/nexus.png', + description: 'Nexus permet de gérer les binaires et artefacts de build à travers la chaîne logistique logicielle', + config: { + project: [ + { + key: 'activateNpmRepo', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt NPM', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'npmWritePolicy', + section: 'NPM', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture', + value: 'allow', + description: 'Politique d\'écriture des dépôts NPM, valeurs possibles: allow / allow_once, deny, replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'activateMavenRepo', + section: 'Maven', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt Maven', + value: DISABLED, + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'mavenSnapshotWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Snapshot', + value: 'allow', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'mavenReleaseWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Release', + value: 'allow_once', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + ], + global: [ + { + key: 'platformWriteGroupPaths', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemins des groupes OIDC plateforme en écriture', + value: '/console/admin', + description: 'Liste séparée par des virgules des chemins des groupes OIDC ayant accès en écriture aux dépôts Nexus de la plateforme', + placeholder: '/console/admin', + }, + { + key: 'platformReadGroupPaths', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemins des groupes OIDC plateforme en lecture', + value: '/console/readonly,/console/security', + description: 'Liste séparée par des virgules des chemins des groupes OIDC ayant accès en lecture aux dépôts Nexus de la plateforme', + placeholder: '/console/readonly,/console/security', + }, + { + key: 'projectWriteGroupPathSuffixes', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixes des groupes OIDC projet en écriture', + value: '/console/admin,/console/devops', + description: 'Liste séparée par des virgules des suffixes des chemins des groupes OIDC ayant accès en écriture aux dépôts Nexus du projet', + placeholder: '/console/admin,/console/devops', + }, + { + key: 'projectReadGroupPathSuffixes', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixes des groupes OIDC projet en lecture', + value: '/console/readonly,/console/security,/console/developer', + description: 'Liste séparée par des virgules des suffixes des chemins des groupes OIDC ayant accès en lecture aux dépôts Nexus du projet', + placeholder: '/console/readonly,/console/security,/console/developer', + }, + { + key: 'activateNpmRepoDefaultValue', + section: 'NPM', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Créer un dépôt NPM privé (comportement par défaut des projets)', + value: DISABLED, + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'activateMavenRepoDefaultValue', + section: 'Maven', + kind: 'switch', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Créer un dépôt MAVEN privé (comportement par défaut des projets)', + value: DISABLED, + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'enablePlugin', + kind: 'switch', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Activer/Désactiver entièrement le plugin Nexus', + value: ENABLED, + description: 'Défaut: Activé', + }, + ], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts new file mode 100644 index 0000000000..4cdc734919 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus-testing.utils.ts @@ -0,0 +1,15 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + slug: faker.internet.domainWord(), + owner: { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + }, + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.constants.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.constants.ts new file mode 100644 index 0000000000..151b2e56b1 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.constants.ts @@ -0,0 +1,32 @@ +// Name of the Nexus plugin used throughout the application +export const PLUGIN_NAME = 'nexus' + +// Configuration keys for enabling Maven and NPM repositories +export const NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO = 'activateMavenRepo' +export const NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO = 'activateNpmRepo' + +// Configuration keys for Maven snapshot, release, and NPM write policies +export const NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY = 'mavenSnapshotWritePolicy' +export const NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY = 'mavenReleaseWritePolicy' +export const NEXUS_CONFIG_KEY_NPM_WRITE_POLICY = 'npmWritePolicy' + +// Default write policy values for Maven snapshots, releases, and NPM packages +export const DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY = 'allow' +export const DEFAULT_MAVEN_RELEASE_WRITE_POLICY = 'allow_once' +export const DEFAULT_NPM_WRITE_POLICY = 'allow' + +// Default group paths granting write and read access at the platform level +export const DEFAULT_PLATFORM_WRITE_GROUP_PATHS = '/console/admin' +export const DEFAULT_PLATFORM_READ_GROUP_PATHS = '/console/readonly,/console/security' + +// Default group path suffixes granting write and read access at the project level +export const DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES = '/console/admin,/console/devops' +export const DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES = '/console/readonly,/console/security,/console/developer' + +// Plugin configuration keys for platform-level group paths +export const PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY = 'platformWriteGroupPaths' +export const PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY = 'platformReadGroupPaths' + +// Plugin configuration keys for project-level group path suffixes +export const PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectWriteGroupPathSuffixes' +export const PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectReadGroupPathSuffixes' diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts new file mode 100644 index 0000000000..998c0b3b7a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts @@ -0,0 +1,18 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface NexusConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + secretExposeInternalUrl: boolean + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface NexusModuleOptions extends NexusConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('nexus') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as NEXUS_CONFIG } diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.module.ts new file mode 100644 index 0000000000..25f0e992ce --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from '../../config/base.config' +import { nexusConfigFactory } from '../../config/nexus.config' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { NexusHealthService } from './nexus-health.service' +import { NexusHttpClientService } from './nexus-http-client.service' +import { NexusPluginService } from './nexus-plugin.service' +import { ConfigurableModuleClass } from './nexus.module-definition' +import { NexusService } from './nexus.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule, ConfigModule.forFeature(nexusConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + providers: [ + NexusHealthService, + NexusPluginService, + NexusService, + NexusDatastoreService, + NexusHttpClientService, + NexusClientService, + ], + exports: [NexusClientService, NexusHealthService, NexusPluginService, NexusService], +}) +export class NexusModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts new file mode 100644 index 0000000000..9963b5e4c5 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -0,0 +1,202 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { NexusConfig } from './nexus.module-definition' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { makeProjectWithDetails } from './nexus-testing.utils' +import { + NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, + NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, + PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, + PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, +} from './nexus.constants' +import { NEXUS_CONFIG } from './nexus.module-definition' +import { NexusService } from './nexus.service' + +describe('nexusService', () => { + let service: NexusService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let config: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + getRepositoriesMavenHosted: vi.fn().mockResolvedValue(null), + getRepositoriesMavenGroup: vi.fn().mockResolvedValue(null), + getRepositoriesNpmHosted: vi.fn().mockResolvedValue(null), + getRepositoriesNpmGroup: vi.fn().mockResolvedValue(null), + getSecurityPrivileges: vi.fn().mockResolvedValue(null), + getSecurityRoles: vi.fn().mockResolvedValue(null), + getSecurityUsers: vi.fn().mockResolvedValue([]), + }) + datastore = mockDeep({ + getAllProjects: vi.fn().mockResolvedValue([]), + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + read: vi.fn().mockRejectedValue(new VaultError('NotFound', 'Not Found')), + }) + config = mockDeep({}) + baseConfig = mockDeep({ projectsRootDir: 'forge' }) + vaultConfig = mockDeep({}) + + const module = await Test.createTestingModule({ + providers: [ + NexusService, + { provide: NexusClientService, useValue: client }, + { provide: NexusDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: NEXUS_CONFIG, useValue: config }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + ], + }).compile() + + service = module.get(NexusService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('handleUpsert should reconcile based on computed flags', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: DISABLED }, + ], + }) + + await service.handleUpsert(project) + + expect(client.createRepositoriesMavenHosted).toHaveBeenCalled() + expect(client.deleteRepositoriesByName).toHaveBeenCalled() + expect(vault.write).toHaveBeenCalledWith( + expect.objectContaining({ + NEXUS_USERNAME: project.slug, + NEXUS_PASSWORD: expect.any(String), + }), + `forge/${project.slug}/tech/NEXUS`, + ) + }) + + it('handleDelete should delete project', async () => { + const project = makeProjectWithDetails() + await service.handleDelete(project) + expect(client.deleteSecurityRoles).toHaveBeenCalledWith(`${project.slug}-ID`) + expect(client.deleteSecurityUsers).toHaveBeenCalledWith(project.slug) + expect(vault.delete).toHaveBeenCalledWith(`forge/${project.slug}/tech/NEXUS`) + }) + + it('handleCron should reconcile all projects', async () => { + const projects = [ + makeProjectWithDetails({ plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }] }), + makeProjectWithDetails({ plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }] }), + ] + + datastore.getAllProjects.mockResolvedValue(projects) + + await service.handleCron() + + expect(client.createSecurityUsers).toHaveBeenCalledTimes(2) + }) + + it('reuses existing vault password and does not rotate Nexus user password', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + }) + + datastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { + if (key === PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) return ' ' + if (key === PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) return ' ' + return null + }) + + vault.read.mockResolvedValue(makeVaultSecret({ data: { NEXUS_PASSWORD: 'existing' } })) + client.getSecurityUsers.mockResolvedValue([{ userId: project.slug }]) + + await service.handleUpsert(project) + + expect(client.updateSecurityUsersChangePassword).not.toHaveBeenCalled() + expect(client.createSecurityUsers).not.toHaveBeenCalled() + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + NEXUS_USERNAME: project.slug, + NEXUS_PASSWORD: 'existing', + }), `forge/${project.slug}/tech/NEXUS`) + }) + + it('deletes group repos before their hosted members', async () => { + const project = makeProjectWithDetails() + + await service.handleDelete(project) + + const deletedRepos = client.deleteRepositoriesByName.mock.calls.map(([name]) => name) + expect(deletedRepos.indexOf(`${project.slug}-repository-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-repository-snapshot`)) + expect(deletedRepos.indexOf(`${project.slug}-repository-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-repository-release`)) + expect(deletedRepos.indexOf(`${project.slug}-npm-group`)) + .toBeLessThan(deletedRepos.indexOf(`${project.slug}-npm`)) + }) + + it('tolerates platform role failures when ensuring platform roles', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }], + }) + const staleProject = makeProjectWithDetails({ + plugins: [{ pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }], + }) + + datastore.getAllProjects.mockResolvedValue([project, staleProject]) + client.createSecurityRoles.mockImplementation(async (body) => { + if (body.id.startsWith('console-')) throw new Error('Request failed: POST security/roles responded 400 Bad Request') + }) + + await expect(service.handleUpsert(project)).resolves.not.toThrow() + + expect(client.createSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({ id: 'console-admin' })) + }) + + it('dedupes project group roles by role id and keeps the highest privileges', async () => { + const project = makeProjectWithDetails({ + owner: { email: 'owner@example.com', firstName: 'Owner', lastName: 'User' }, + plugins: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, + { pluginName: PLUGIN_NAME, key: PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, value: '/console/devops' }, + ], + }) + + datastore.getAdminPluginConfig.mockImplementation(async (_plugin, key) => { + if (key === PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) return ' ' + if (key === PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) return ' ' + return null + }) + + await service.handleUpsert(project) + + expect(client.createSecurityRoles).toHaveBeenCalledWith(expect.objectContaining({ + id: `${project.slug}-console-devops`, + privileges: expect.arrayContaining([`${project.slug}-privilege-group`]), + })) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.service.ts new file mode 100644 index 0000000000..52061ace32 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -0,0 +1,696 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { NexusPrivilege } from './nexus-client.service' +import type { ProjectWithDetails } from './nexus-datastore.service' +import type { NexusConfig } from './nexus.module-definition' +import type { + MavenHostedRepoKind, +} from './nexus.utils' +import { specificallyEnabled } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { NexusClientService } from './nexus-client.service' +import { NexusDatastoreService } from './nexus-datastore.service' +import { + DEFAULT_MAVEN_RELEASE_WRITE_POLICY, + DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY, + DEFAULT_NPM_WRITE_POLICY, + DEFAULT_PLATFORM_READ_GROUP_PATHS, + DEFAULT_PLATFORM_WRITE_GROUP_PATHS, + DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES, + NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, + NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, + NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY, + NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY, + NEXUS_CONFIG_KEY_NPM_WRITE_POLICY, + PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY, + PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, +} from './nexus.constants' +import { NEXUS_CONFIG } from './nexus.module-definition' +import { + generateMavenHostedRepoName, + generateNpmHostedRepoName, + generateRandomPassword, + getPluginConfig, + getProjectVaultPath, +} from './nexus.utils' + +export interface EnsureMavenReposOptions { + snapshotWritePolicy: string + releaseWritePolicy: string +} + +@Injectable() +export class NexusService { + private readonly logger = new Logger(NexusService.name) + + constructor( + @Inject(NexusDatastoreService) private readonly datastore: NexusDatastoreService, + @Inject(NexusClientService) private readonly client: NexusClientService, + @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) { + this.logger.log('NexusService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('nexus', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + await this.ensureProject(project) + const projects = await this.datastore.getAllProjects() + await this.ensurePlatformRoles(projects) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('nexus', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.deleteProject(project) + const projects = await this.datastore.getAllProjects() + await this.ensurePlatformRoles(projects) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Nexus reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('nexus.projects.count', projects.length) + await this.ensureProjects(projects) + await this.ensurePlatformRoles(projects) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('nexus.projects.count', projects.length) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const enableMaven = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO)) ?? false + const enableNpm = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO)) ?? false + + const mavenSnapshotWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_MAVEN_SNAPSHOT_WRITE_POLICY) ?? DEFAULT_MAVEN_SNAPSHOT_WRITE_POLICY + const mavenReleaseWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_MAVEN_RELEASE_WRITE_POLICY) ?? DEFAULT_MAVEN_RELEASE_WRITE_POLICY + const npmWritePolicy = getPluginConfig(project, NEXUS_CONFIG_KEY_NPM_WRITE_POLICY) ?? DEFAULT_NPM_WRITE_POLICY + + await Promise.all([ + enableMaven + ? this.ensureMavenRepos(project, { snapshotWritePolicy: mavenSnapshotWritePolicy, releaseWritePolicy: mavenReleaseWritePolicy }) + : this.deleteMavenRepos(project), + enableNpm + ? this.ensureNpmRepos(project, npmWritePolicy) + : this.deleteNpmRepos(project), + ]) + + const privileges = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + ] + : []), + ] + const readOnlyPrivileges = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + : []), + ] + await this.ensureRole(project, privileges) + await this.ensureUser(project) + await this.ensureProjectGroupRoles(project, { readOnlyPrivileges, writePrivileges: privileges }) + } + + private async upsertPrivilege(body: NexusPrivilege) { + const existing = await this.client.getSecurityPrivileges(body.name) + if (!existing) { + await this.client.createSecurityPrivilegesRepositoryView(body) + return + } + await this.client.updateSecurityPrivilegesRepositoryView(body.name, body) + } + + private async ensureMavenHostedRepo(repoName: string, writePolicy: string) { + const existing = await this.client.getRepositoriesMavenHosted(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + } + if (!existing) { + await this.client.createRepositoriesMavenHosted(body) + return + } + await this.client.updateRepositoriesMavenHosted(repoName, body) + } + + private async ensureNpmHostedRepo(repoName: string, writePolicy: string) { + const existing = await this.client.getRepositoriesNpmHosted(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + } + if (!existing) { + await this.client.createRepositoriesNpmHosted(body) + return + } + await this.client.updateRepositoriesNpmHosted(repoName, body) + } + + private async ensureNpmGroupRepo(repoName: string, memberNames: string[]) { + const existing = await this.client.getRepositoriesNpmGroup(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames, + }, + } + if (!existing) { + await this.client.postRepositoriesNpmGroup(body) + return + } + await this.client.putRepositoriesNpmGroup(repoName, body) + } + + private async ensureMavenHostedRepos(args: { + releaseRepoName: string + snapshotRepoName: string + releaseWritePolicy: string + snapshotWritePolicy: string + }) { + await Promise.all([ + this.ensureMavenHostedRepo(args.snapshotRepoName, args.snapshotWritePolicy), + this.ensureMavenHostedRepo(args.releaseRepoName, args.releaseWritePolicy), + ]) + } + + private async ensureMavenRepos(project: ProjectWithDetails, options: EnsureMavenReposOptions) { + const releaseRepoName = generateMavenHostedRepoName(project, 'release') + const snapshotRepoName = generateMavenHostedRepoName(project, 'snapshot') + const groupRepoName = generateMavenGroupRepoName(project) + + const releasePrivilege = generateMavenHostedPrivilegeName(project, 'release') + const snapshotPrivilege = generateMavenHostedPrivilegeName(project, 'snapshot') + const groupPrivilege = generateMavenGroupPrivilegeName(project) + const releasePrivilegeReadonly = generateMavenHostedPrivilegeNameReadonly(project, 'release') + const snapshotPrivilegeReadonly = generateMavenHostedPrivilegeNameReadonly(project, 'snapshot') + const groupPrivilegeReadonly = generateMavenGroupPrivilegeNameReadonly(project) + + await this.ensureMavenHostedRepos({ + releaseRepoName, + snapshotRepoName, + releaseWritePolicy: options.releaseWritePolicy, + snapshotWritePolicy: options.snapshotWritePolicy, + }) + + await this.ensureMavenGroupRepo( + groupRepoName, + [releaseRepoName, snapshotRepoName, 'maven-public'], + ) + + const privilegesToEnsureWrite = [ + { repo: releaseRepoName, privilege: releasePrivilege }, + { repo: snapshotRepoName, privilege: snapshotPrivilege }, + { repo: groupRepoName, privilege: groupPrivilege }, + ] + const privilegesToEnsureReadonly = [ + { repo: releaseRepoName, privilege: releasePrivilegeReadonly }, + { repo: snapshotRepoName, privilege: snapshotPrivilegeReadonly }, + { repo: groupRepoName, privilege: groupPrivilegeReadonly }, + ] + await Promise.all([ + this.ensureRepositoryViewPrivileges({ + project, + type: 'maven', + format: 'maven2', + entries: privilegesToEnsureWrite, + actions: ['all'], + }), + this.ensureRepositoryViewPrivileges({ + project, + type: 'maven', + format: 'maven2', + entries: privilegesToEnsureReadonly, + actions: ['read', 'browse'], + }), + ]) + } + + private async ensureMavenGroupRepo(repoName: string, memberNames: string[]) { + const existing = await this.client.getRepositoriesMavenGroup(repoName) + const body = { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames, + }, + } + if (!existing) { + await this.client.createRepositoriesMavenGroup(body) + return + } + await this.client.updateRepositoriesMavenGroup(repoName, body) + } + + private async ensureRepositoryViewPrivileges(args: { + project: ProjectWithDetails + type: string + format: string + entries: Array<{ repo: string, privilege: string }> + actions: string[] + }) { + for (const entry of args.entries) { + await this.upsertPrivilege({ + type: args.type, + name: entry.privilege, + description: `Privilege for organization ${args.project.slug} for repo ${entry.repo}`, + actions: args.actions, + format: args.format, + repository: entry.repo, + }) + } + } + + private async deleteMavenRepos(project: ProjectWithDetails) { + const hostedRepoNames = [ + generateMavenHostedRepoName(project, 'release'), + generateMavenHostedRepoName(project, 'snapshot'), + ] + const privileges = [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + await Promise.all(privileges.map(privilege => this.client.deleteSecurityPrivileges(privilege))) + // Nexus responds 500 when deleting a hosted repo still referenced by a group, so delete the group first + await this.client.deleteRepositoriesByName(generateMavenGroupRepoName(project)) + await Promise.all(hostedRepoNames.map(repo => this.client.deleteRepositoriesByName(repo))) + } + + private async ensureNpmRepos(project: ProjectWithDetails, writePolicy: string) { + const hostedRepoName = generateNpmHostedRepoName(project) + const groupRepoName = generateNpmGroupRepoName(project) + + const hostedPrivilege = generateNpmHostedPrivilegeName(project) + const groupPrivilege = generateNpmGroupPrivilegeName(project) + const hostedPrivilegeReadonly = generateNpmHostedPrivilegeNameReadonly(project) + const groupPrivilegeReadonly = generateNpmGroupPrivilegeNameReadonly(project) + + await this.ensureNpmHostedRepo(hostedRepoName, writePolicy) + await this.ensureNpmGroupRepo(groupRepoName, [hostedRepoName]) + + const privilegesToEnsureWrite = [ + { repo: hostedRepoName, privilege: hostedPrivilege }, + { repo: groupRepoName, privilege: groupPrivilege }, + ] + const privilegesToEnsureReadonly = [ + { repo: hostedRepoName, privilege: hostedPrivilegeReadonly }, + { repo: groupRepoName, privilege: groupPrivilegeReadonly }, + ] + await Promise.all([ + this.ensureRepositoryViewPrivileges({ + project, + type: 'npm', + format: 'npm', + entries: privilegesToEnsureWrite, + actions: ['all'], + }), + this.ensureRepositoryViewPrivileges({ + project, + type: 'npm', + format: 'npm', + entries: privilegesToEnsureReadonly, + actions: ['read', 'browse'], + }), + ]) + } + + private async deleteNpmRepos(project: ProjectWithDetails) { + const privileges = [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + await Promise.all(privileges.map(privilege => this.client.deleteSecurityPrivileges(privilege))) + // Nexus responds 500 when deleting a hosted repo still referenced by a group, so delete the group first + await this.client.deleteRepositoriesByName(generateNpmGroupRepoName(project)) + await this.client.deleteRepositoriesByName(generateNpmHostedRepoName(project)) + } + + private async ensureRole(project: ProjectWithDetails, privileges: string[]) { + const roleId = `${project.slug}-ID` + const role = await this.client.getSecurityRoles(roleId) + if (!role) { + await this.client.createSecurityRoles({ + id: roleId, + name: `${project.slug}-role`, + description: 'desc', + privileges, + }) + return + } + await this.client.updateSecurityRoles(roleId, { + id: roleId, + name: `${project.slug}-role`, + privileges, + }) + } + + private async ensureUser(project: ProjectWithDetails) { + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') + let existingPassword: string | undefined + try { + existingPassword = await this.vault.read(vaultPath).then(res => res.data?.NEXUS_PASSWORD) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') { + existingPassword = undefined + } else { + throw error + } + } + + const ensuredPassword = existingPassword ?? generateRandomPassword(30) + const users = await this.client.getSecurityUsers(project.slug) + const existing = users.find(u => u.userId === project.slug) + if (existing) { + if (!existingPassword || existingPassword !== ensuredPassword) { + await this.client.updateSecurityUsersChangePassword(project.slug, ensuredPassword) + } + } else { + await this.client.createSecurityUsers({ + userId: project.slug, + firstName: project.owner.firstName, + lastName: project.owner.lastName, + emailAddress: project.owner.email, + password: ensuredPassword, + status: 'active', + roles: [`${project.slug}-ID`], + }) + } + + await this.vault.write({ + NEXUS_PASSWORD: ensuredPassword, + NEXUS_USERNAME: project.slug, + }, vaultPath) + } + + private async ensureSecurityRole(id: string, privileges: string[]) { + const role = await this.client.getSecurityRoles(id) + if (!role) { + await this.client.createSecurityRoles({ + id, + name: id, + description: 'desc', + privileges, + }) + return + } + await this.client.updateSecurityRoles(id, { + id, + name: id, + privileges, + }) + } + + private async getOptionalConfigValue(project: ProjectWithDetails, key: string) { + const projectValue = getPluginConfig(project, key) + if (projectValue) return projectValue + return await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + } + + private async ensureProjectGroupRoles(project: ProjectWithDetails, args: { readOnlyPrivileges: string[], writePrivileges: string[] }) { + const rawWriteSuffixes = await this.getOptionalConfigValue(project, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES + + const rawReadSuffixes = await this.getOptionalConfigValue(project, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES + + const writeGroupPaths = generateProjectRoleGroupPath(project, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES) + const readGroupPaths = generateProjectRoleGroupPath(project, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES) + + const byId = generateRolePrivilegesMapping({ + readGroupPaths, + writeGroupPaths, + readOnlyPrivileges: args.readOnlyPrivileges, + writePrivileges: args.writePrivileges, + }) + + await Promise.all(Array.from(byId.entries(), ([id, privileges]) => this.ensureSecurityRole(id, privileges))) + } + + private async ensurePlatformRoles(projects: ProjectWithDetails[]) { + const rawWriteGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) + ?? DEFAULT_PLATFORM_WRITE_GROUP_PATHS + + const rawReadGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) + ?? DEFAULT_PLATFORM_READ_GROUP_PATHS + + const readonlyPrivileges = new Set() + const writePrivileges = new Set() + for (const project of projects) { + const computed = computeProjectPrivileges(project) + for (const privilege of computed.readOnly) readonlyPrivileges.add(privilege) + for (const privilege of computed.write) writePrivileges.add(privilege) + } + + const byId = generateRolePrivilegesMapping({ + readGroupPaths: parseOidcGroupPaths(rawReadGroupPaths || DEFAULT_PLATFORM_READ_GROUP_PATHS), + writeGroupPaths: parseOidcGroupPaths(rawWriteGroupPaths || DEFAULT_PLATFORM_WRITE_GROUP_PATHS), + readOnlyPrivileges: [...readonlyPrivileges], + writePrivileges: [...writePrivileges], + }) + + // Platform roles aggregate privileges of every Nexus-enabled project; a stale + // project (e.g. privileges missing on this instance) must not block the current one. + const entries = Array.from(byId.entries()) + const results = await Promise.allSettled(entries.map(([id, privileges]) => this.ensureSecurityRole(id, privileges))) + results.forEach((result, index) => { + if (result.status === 'rejected') { + this.logger.warn(`Failed to ensure platform role ${entries[index][0]}: ${result.reason instanceof Error ? result.reason.message : result.reason}`) + } + }) + } + + private async deleteProjectGroupRoles(project: ProjectWithDetails) { + const rawWriteSuffixes = await this.getOptionalConfigValue(project, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES + + const rawReadSuffixes = await this.getOptionalConfigValue(project, PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY) + ?? DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES + + const groupPaths = [ + ...generateProjectRoleGroupPath(project, rawWriteSuffixes || DEFAULT_PROJECT_WRITE_GROUP_PATH_SUFFIXES), + ...generateProjectRoleGroupPath(project, rawReadSuffixes || DEFAULT_PROJECT_READ_GROUP_PATH_SUFFIXES), + ] + + const ids = [...new Set(groupPaths.map(generateRoleId))] + await Promise.all(ids.map(id => this.client.deleteSecurityRoles(id))) + } + + @StartActiveSpan() + private async deleteProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + await Promise.all([ + this.deleteMavenRepos(project), + this.deleteNpmRepos(project), + ]) + + await Promise.all([ + this.deleteProjectGroupRoles(project), + this.client.deleteSecurityRoles(`${project.slug}-ID`), + this.client.deleteSecurityUsers(project.slug), + ]) + + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') + try { + await this.vault.delete(vaultPath) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + } +} + +function generateMavenHostedPrivilegeName(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${project.slug}-privilege-${kind}` +} + +function generateMavenGroupRepoName(project: ProjectWithDetails) { + return `${project.slug}-repository-group` +} + +function generateMavenGroupPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-privilege-group` +} + +function generateNpmHostedPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-npm-privilege` +} + +function generateNpmGroupRepoName(project: ProjectWithDetails) { + return `${project.slug}-npm-group` +} + +function generateNpmGroupPrivilegeName(project: ProjectWithDetails) { + return `${project.slug}-npm-group-privilege` +} + +function generateMavenHostedPrivilegeNameReadonly(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${generateMavenHostedPrivilegeName(project, kind)}-ro` +} + +function generateMavenGroupPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateMavenGroupPrivilegeName(project)}-ro` +} + +function generateNpmHostedPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateNpmHostedPrivilegeName(project)}-ro` +} + +function generateNpmGroupPrivilegeNameReadonly(project: ProjectWithDetails) { + return `${generateNpmGroupPrivilegeName(project)}-ro` +} + +function generateProjectRoleGroupPath(project: ProjectWithDetails, rawGroupPathSuffixes: string) { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${project.slug}${path}`) +} + +function parseOidcGroupPaths(rawGroupPaths: string) { + return rawGroupPaths + .split(',') + .map(path => path.trim()) + .filter(Boolean) +} + +function generateRolePrivilegesMapping(args: { readGroupPaths: string[], writeGroupPaths: string[], readOnlyPrivileges: string[], writePrivileges: string[] }) { + const byId = new Map() + for (const groupPath of args.readGroupPaths) byId.set(generateRoleId(groupPath), args.readOnlyPrivileges) + for (const groupPath of args.writeGroupPaths) byId.set(generateRoleId(groupPath), args.writePrivileges) + return byId +} + +function generateRoleId(groupPath: string) { + const trimmed = groupPath.trim() + const withoutLeadingSlash = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed + return withoutLeadingSlash.replaceAll('/', '-') +} + +function computeProjectPrivileges(project: ProjectWithDetails) { + const enableMaven = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO)) ?? false + const enableNpm = specificallyEnabled(getPluginConfig(project, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO)) ?? false + + const write = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeName(project), + generateMavenHostedPrivilegeName(project, 'release'), + generateMavenHostedPrivilegeName(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeName(project), + generateNpmHostedPrivilegeName(project), + ] + : []), + ] + + const readOnly = [ + ...(enableMaven + ? [ + generateMavenGroupPrivilegeNameReadonly(project), + generateMavenHostedPrivilegeNameReadonly(project, 'release'), + generateMavenHostedPrivilegeNameReadonly(project, 'snapshot'), + ] + : []), + ...(enableNpm + ? [ + generateNpmGroupPrivilegeNameReadonly(project), + generateNpmHostedPrivilegeNameReadonly(project), + ] + : []), + ] + + return { readOnly, write } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.utils.ts new file mode 100644 index 0000000000..6e9e0e7645 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/nexus/nexus.utils.ts @@ -0,0 +1,26 @@ +import type { ProjectWithDetails } from './nexus-datastore.service' +import { randomBytes } from 'node:crypto' + +export function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +export function generateRandomPassword(length: number) { + const raw = randomBytes(Math.ceil(length * 0.75)).toString('base64url') + return raw.slice(0, length) +} + +export function getProjectVaultPath(projectRootDir: string, projectSlug: string, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return `${projectRootDir}/${projectSlug}/${normalized}` +} + +export type MavenHostedRepoKind = 'release' | 'snapshot' + +export function generateMavenHostedRepoName(project: ProjectWithDetails, kind: MavenHostedRepoKind) { + return `${project.slug}-repository-${kind}` +} + +export function generateNpmHostedRepoName(project: ProjectWithDetails) { + return `${project.slug}-npm` +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts new file mode 100644 index 0000000000..5f008fd21e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts @@ -0,0 +1,25 @@ +import type { OpenCdsConfig } from './opencds.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { OPENCDS_CONFIG } from './opencds.module-definition' + +@Injectable() +export class OpenCdsHealthService { + constructor( + @Inject(OPENCDS_CONFIG) private readonly opencdsConfig: OpenCdsConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const headers: Record = {} + headers.Authorization = `Bearer ${this.opencdsConfig.apiToken}` + const response = await fetch(this.opencdsConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts new file mode 100644 index 0000000000..5a31df9912 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts @@ -0,0 +1,16 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface OpenCdsConfig { + url: string + internalUrl: string + probeUrl?: string + apiToken: string + apiTlsRejectUnauthorized: boolean +} + +export interface OpenCdsModuleOptions extends OpenCdsConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('opencds') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as OPENCDS_CONFIG } diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/opencds/opencds.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/opencds/opencds.module.ts new file mode 100644 index 0000000000..edc85a987a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/opencds/opencds.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { OpenCdsHealthService } from './opencds-health.service' +import { ConfigurableModuleClass } from './opencds.module-definition' + +@Module({ + imports: [TerminusModule], + providers: [OpenCdsHealthService], + exports: [OpenCdsHealthService], +}) +export class OpenCdsModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts new file mode 100644 index 0000000000..1ef4cac975 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts @@ -0,0 +1,65 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginModule } from './plugin.module' +import { PluginService } from './plugin.service' + +describe('pluginModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits PluginService when USE_ARGOCD=false', async () => { + vi.stubEnv('USE_ARGOCD', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_ARGOCD')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_GITLAB=false', async () => { + vi.stubEnv('USE_GITLAB', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_GITLAB')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_REGISTRY=false', async () => { + vi.stubEnv('USE_REGISTRY', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_REGISTRY')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_KEYCLOAK=false', async () => { + vi.stubEnv('USE_KEYCLOAK', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_KEYCLOAK')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_NEXUS=false', async () => { + vi.stubEnv('USE_NEXUS', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_NEXUS')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_SONARQUBE=false', async () => { + vi.stubEnv('USE_SONARQUBE', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_SONARQUBE')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_VAULT')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.module.ts new file mode 100644 index 0000000000..b5450adea6 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { argocdConfigFactory } from '../../config/argocd.config' +import { gitlabConfigFactory } from '../../config/gitlab.config' +import { harborConfigFactory } from '../../config/harbor.config' +import { keycloakConfigFactory } from '../../config/keycloak.config' +import { nexusConfigFactory } from '../../config/nexus.config' +import { registryConfigFactory } from '../../config/registry.config' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' +import { vaultConfigFactory } from '../../config/vault.config' +import { ArgoCDModule } from '../argocd/argocd.module' +import { GitlabModule } from '../gitlab/gitlab.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { NexusModule } from '../nexus/nexus.module' +import { HarborModule, RegistryModule } from '../registry/registry.module' +import { SonarqubeModule } from '../sonarqube/sonarqube.module' +import { VaultModule } from '../vault/vault.module' +import { PluginService } from './plugin.service' + +@Module({ + imports: [ + ConditionalModule.registerWhen(ArgoCDModule.forRoot(argocdConfigFactory.asProvider()), 'USE_ARGOCD'), + ConditionalModule.registerWhen(GitlabModule.forRoot(gitlabConfigFactory.asProvider()), 'USE_GITLAB'), + ConditionalModule.registerWhen(RegistryModule.forRoot(registryConfigFactory.asProvider()), 'USE_REGISTRY'), + ConditionalModule.registerWhen(HarborModule.forRoot(harborConfigFactory.asProvider()), 'USE_REGISTRY'), + ConditionalModule.registerWhen(KeycloakModule.forRoot(keycloakConfigFactory.asProvider()), 'USE_KEYCLOAK'), + ConditionalModule.registerWhen(NexusModule.forRoot(nexusConfigFactory.asProvider()), 'USE_NEXUS'), + ConditionalModule.registerWhen(SonarqubeModule.forRoot(sonarqubeConfigFactory.asProvider()), 'USE_SONARQUBE'), + ConditionalModule.registerWhen(VaultModule.forRoot(vaultConfigFactory.asProvider()), 'USE_VAULT'), + ], + providers: [PluginService], + exports: [PluginService], +}) +export class PluginModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.service.ts new file mode 100644 index 0000000000..1b9303ee53 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.service.ts @@ -0,0 +1,46 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { ArgoCDPluginService } from '../argocd/argocd-plugin.service' +import { GitlabPluginService } from '../gitlab/gitlab-plugin.service' +import { KeycloakPluginService } from '../keycloak/keycloak-plugin.service' +import { NexusPluginService } from '../nexus/nexus-plugin.service' +import { RegistryPluginService } from '../registry/registry-plugin.service' +import { SonarqubePluginService } from '../sonarqube/sonarqube-plugin.service' +import { VaultPluginService } from '../vault/vault-plugin.service' + +@Injectable() +export class PluginService { + private readonly logger = new Logger(PluginService.name) + + constructor( + @Inject(ArgoCDPluginService) private readonly argoCDPlugin: ArgoCDPluginService, + @Inject(GitlabPluginService) private readonly gitlabPlugin: GitlabPluginService, + @Inject(RegistryPluginService) private readonly registryPlugin: RegistryPluginService, + @Inject(KeycloakPluginService) private readonly keycloakPlugin: KeycloakPluginService, + @Inject(NexusPluginService) private readonly nexusPlugin: NexusPluginService, + @Inject(SonarqubePluginService) private readonly sonarqubePlugin: SonarqubePluginService, + @Inject(VaultPluginService) private readonly vaultPlugin: VaultPluginService, + ) {} + + async infos(projectId: string): Promise { + const plugins = [ + ['argocd', () => this.argoCDPlugin.infos()], + ['gitlab', () => this.gitlabPlugin.infos()], + ['registry', () => this.registryPlugin.infos(projectId)], + ['keycloak', () => this.keycloakPlugin.infos()], + ['nexus', () => this.nexusPlugin.infos()], + ['sonarqube', () => this.sonarqubePlugin.infos()], + ['vault', () => this.vaultPlugin.infos()], + ] as const + + const settled = await Promise.allSettled(plugins.map(([, loadInfos]) => loadInfos())) + return settled.flatMap((result, index) => { + const [pluginName] = plugins[index] + if (result.status === 'fulfilled') { + return [result.value] + } + this.logger.warn(`Skipping project service plugin ${pluginName} because infos() failed: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`) + return [] + }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts new file mode 100644 index 0000000000..3a2ecd238e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.utils.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { capturePluginResult, getFailedPlugins, mergePluginResults } from './plugin.utils' + +describe('capturePluginResult', () => { + it('should resolve a successful task into an OK result with the default message, keyed under the plugin name', async () => { + await expect(capturePluginResult('gitlab', async () => {})).resolves.toEqual({ + gitlab: { + status: 'OK', + message: 'Up to date', + executionTime: expect.any(Number), + }, + }) + }) + + it('should use the string returned by the task as message', async () => { + await expect(capturePluginResult('gitlab', async () => 'Everything synced')).resolves.toEqual({ + gitlab: expect.objectContaining({ status: 'OK', message: 'Everything synced' }), + }) + }) + + it('should resolve a throwing task into a KO result instead of rejecting', async () => { + const task = async () => { + throw new Error('GitLab unreachable') + } + await expect(capturePluginResult('gitlab', task)).resolves.toEqual({ + gitlab: { + status: 'KO', + message: 'GitLab unreachable', + executionTime: expect.any(Number), + error: new Error('GitLab unreachable'), + }, + }) + }) + + it('should report a fallback message for non-Error throws', async () => { + const task = async () => { + throw 'boom' // eslint-disable-line no-throw-literal + } + await expect(capturePluginResult('gitlab', task)).resolves.toEqual({ + gitlab: expect.objectContaining({ status: 'KO', message: 'Erreur inconnue', error: 'boom' }), + }) + }) + + it('should report under the given plugin name', async () => { + await expect(capturePluginResult('nexus', async () => 'custom message')).resolves.toEqual({ + nexus: expect.objectContaining({ status: 'OK', message: 'custom message' }), + }) + }) +}) + +describe('mergePluginResults', () => { + it('should return empty object when given empty array', () => { + expect(mergePluginResults([])).toEqual({}) + }) + + it('should return single result as-is', () => { + const result = [{ argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }] + expect(mergePluginResults(result)).toEqual(result[0]) + }) + + it('should merge multiple results into one', () => { + const results = [ + { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }, + { gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 } }, + ] + expect(mergePluginResults(results)).toEqual({ + argocd: { status: 'OK', message: 'Up to date', executionTime: 10 }, + gitlab: { status: 'OK', message: 'Synced', executionTime: 20 }, + }) + }) + + it('should have later entries overwrite earlier ones for the same plugin', () => { + const error = new Error('sync error') + const results = [ + { argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 } }, + { argocd: { status: 'KO' as const, message: 'Failed', executionTime: 20, error } }, + ] + expect(mergePluginResults(results)).toEqual({ + argocd: { status: 'KO', message: 'Failed', executionTime: 20, error }, + }) + }) +}) + +describe('getFailedPlugins', () => { + it('should return empty array when all plugins are OK', () => { + const results = { + argocd: { status: 'OK' as const, message: 'Up to date', executionTime: 10 }, + gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 }, + } + expect(getFailedPlugins(results)).toEqual([]) + }) + + it('should return names of KO plugins', () => { + const error = new Error('sync error') + const results = { + argocd: { status: 'KO' as const, message: 'Failed', executionTime: 10, error }, + gitlab: { status: 'OK' as const, message: 'Synced', executionTime: 20 }, + } + expect(getFailedPlugins(results)).toEqual(['argocd']) + }) + + it('should return all plugins when all fail', () => { + const error = new Error('error') + const results = { + argocd: { status: 'KO' as const, message: 'Failed', executionTime: 10, error }, + gitlab: { status: 'KO' as const, message: 'Failed', executionTime: 20, error }, + } + const failed = getFailedPlugins(results) + expect(failed).toHaveLength(2) + expect(failed).toContain('argocd') + expect(failed).toContain('gitlab') + }) + + it('should return empty array for empty result', () => { + expect(getFailedPlugins({})).toEqual([]) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.utils.ts new file mode 100644 index 0000000000..a54447a6a0 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/plugin/plugin.utils.ts @@ -0,0 +1,107 @@ +import type { ToUrlFnParamaters } from '@cpn-console/hooks' +import { Logger } from '@nestjs/common' + +export function makeToUrlParams(overrides: Partial = {}): ToUrlFnParamaters { + return { + store: {}, + clusters: [], + zones: [], + environments: [], + project: { id: '', slug: 'dulei', name: '' }, + ...overrides, + } +} +export type PluginName = 'argocd' + | 'gitlab' + | 'nexus' + | 'vault' + | 'keycloak' + | 'harbor' + | 'sonarqube' + | 'observability' + +export type PluginResult = { + status: 'OK' + message: string + executionTime: number +} | { + status: 'KO' + message: string + executionTime: number + error: unknown +} + +export type PluginResults = Partial> + +export type RequiredPluginResult + = { [K in T]: PluginResult } & PluginResults + +const logger = new Logger('PluginResult') + +/** + * Runs a plugin task and always resolves with the task's outcome (status, + * message, execution time and error, if any) keyed under the given plugin + * name, instead of throwing, so the event emitter can merge and persist the + * results of every listener. + * + * The task keeps its natural shape: do the work, throw on failure, optionally + * return a message string to override the default 'Up to date'. + * + * The `@OnEvent` handler stays a thin, honestly-typed wrapper, while the + * traced work method keeps `@StartActiveSpan` so the span still records the + * exception on failure: + * + * ```ts + * @OnEvent('project.upsert') + * async handleUpsert(project: ProjectWithDetails): Promise> { + * return capturePluginResult('argocd', () => this.syncProject(project)) + * } + * + * @StartActiveSpan() + * private async syncProject(project: ProjectWithDetails) { ... } + * ``` + */ +export async function capturePluginResult

( + plugin: P, + task: () => Promise, +): Promise> { + const start = process.hrtime.bigint() + const elapsedMs = () => Number(process.hrtime.bigint() - start) / 1_000_000 + + try { + const message = await task() + return keyedBy(plugin, { + status: 'OK', + message: typeof message === 'string' ? message : 'Up to date', + executionTime: elapsedMs(), + }) + } catch (error: unknown) { + logger.error(`${plugin} handler failed`, error) + return keyedBy(plugin, { + status: 'KO', + message: error instanceof Error ? error.message : 'Erreur inconnue', + executionTime: elapsedMs(), + error, + }) + } +} + +// TypeScript widens a computed property with a generic key to an index +// signature instead of Record, so the assertion is confined here. +function keyedBy

(plugin: P, result: PluginResult): RequiredPluginResult

{ + return { [plugin]: result } as RequiredPluginResult

+} + +export function mergePluginResults(responses: PluginResults[]): PluginResults { + return responses.reduce((merged, currentResponse) => { + return { ...merged, ...currentResponse } + }, {} as PluginResults) +} + +export function getFailedPlugins(response: PluginResults): PluginName[] { + const entries = Object.entries(response) as [PluginName, PluginResult][] + + return entries + .filter(([_, result]) => result.status === 'KO') + .map(([pluginName]) => pluginName) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts new file mode 100644 index 0000000000..73858d492b --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk-queries.utils.ts @@ -0,0 +1,16 @@ +import type { Prisma } from '@prisma/client' + +export const projectNotArchivedSelect = { + id: true, +} satisfies Prisma.ProjectSelect + +export type ProjectIdNotArchived = Prisma.ProjectGetPayload<{ + select: typeof projectNotArchivedSelect +}> + +export function listProjectIdsNotArchived(tx: Prisma.TransactionClient): Promise { + return tx.project.findMany({ + select: projectNotArchivedSelect, + where: { status: { not: 'archived' } }, + }) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts new file mode 100644 index 0000000000..82874da42d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts @@ -0,0 +1,36 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { projectContract } from '@cpn-console/shared' +import { Body, Controller, HttpCode, HttpStatus, Inject, Logger, Post, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectBulkService } from './project-bulk.service' + +@Controller('api/v1/projects-bulk') +export class ProjectBulkController { + private readonly logger = new Logger(ProjectBulkController.name) + + constructor( + @Inject(ProjectBulkService) private readonly projectBulk: ProjectBulkService, + ) {} + + @Post('') + @HttpCode(HttpStatus.ACCEPTED) + @UseGuards(UserGuard) + @RequireAdminPermission('Manage') + async bulkAction( + @Body(new ZodValidationPipe(projectContract.bulkActionProject.body)) body: typeof projectContract.bulkActionProject.body._type, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + const target = body.projectIds === 'all' + ? 'all' + : `count=${body.projectIds.length}` + + this.logger.log(`project.bulkAction requested (action=${body.action}, target=${target})`) + await this.projectBulk.bulkAction(body, user.userId, request.id) + this.logger.log(`project.bulkAction accepted (action=${body.action})`) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts new file mode 100644 index 0000000000..fbb1d9bf76 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { ProjectHooksModule } from '../project-hooks/project-hooks.module' +import { ProjectModule } from '../project/project.module' +import { ProjectBulkController } from './project-bulk.controller' +import { ProjectBulkService } from './project-bulk.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ProjectModule, + ProjectHooksModule, + ], + controllers: [ProjectBulkController], + providers: [ProjectBulkService], +}) +export class ProjectBulkModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts new file mode 100644 index 0000000000..37ec381d2e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts @@ -0,0 +1,85 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { ProjectHooksService } from '../project-hooks/project-hooks.service' +import { makeProject } from '../project/project-testing.utils' +import { ProjectService } from '../project/project.service' +import { ProjectBulkService } from './project-bulk.service' + +describe('projectBulkService', () => { + let module: TestingModule + let service: ProjectBulkService + let prisma: DeepMockProxy + let project: DeepMockProxy + let projectHooks: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + project = mockDeep() + projectHooks = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectBulkService, + { provide: PrismaService, useValue: prisma }, + { provide: ProjectService, useValue: project }, + { provide: ProjectHooksService, useValue: projectHooks }, + ], + }).compile() + + service = module.get(ProjectBulkService) + }) + + it('processes specific project ids', async () => { + const projectIds = [faker.string.uuid(), faker.string.uuid()] + + await service.bulkAction({ action: 'archive', projectIds }, 'user-id', 'request-id') + + expect(project.archive).toHaveBeenCalledTimes(2) + expect(project.archive).toHaveBeenCalledWith(projectIds[0], 'user-id', 'request-id') + expect(project.archive).toHaveBeenCalledWith(projectIds[1], 'user-id', 'request-id') + }) + + it('resolves "all" to all non-archived project ids', async () => { + const project1Id = faker.string.uuid() + const project2Id = faker.string.uuid() + + prisma.project.findMany.mockResolvedValue([makeProject({ id: project1Id }), makeProject({ id: project2Id })]) + + await service.bulkAction({ action: 'archive', projectIds: 'all' }, 'user-id', 'request-id') + + expect(prisma.project.findMany).toHaveBeenCalledWith({ + select: { id: true }, + where: { status: { not: 'archived' } }, + }) + expect(project.archive).toHaveBeenCalledTimes(2) + }) + + it('lock action updates locked to true via project hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'lock', projectIds: [projectId] }) + + expect(projectHooks.updateProjectLocked).toHaveBeenCalledWith(projectId, true) + }) + + it('unlock action updates locked to false via project hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'unlock', projectIds: [projectId] }) + + expect(projectHooks.updateProjectLocked).toHaveBeenCalledWith(projectId, false) + }) + + it('replay action triggers hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'replay', projectIds: [projectId] }, 'user-id', 'request-id') + + expect(projectHooks.replay).toHaveBeenCalledWith(projectId, 'user-id', 'request-id') + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts new file mode 100644 index 0000000000..5989764d17 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts @@ -0,0 +1,96 @@ +import type { projectContract } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { ProjectHooksService } from '../project-hooks/project-hooks.service' +import { ProjectService } from '../project/project.service' +import { listProjectIdsNotArchived } from './project-bulk-queries.utils' + +@Injectable() +export class ProjectBulkService { + private readonly logger = new Logger(ProjectBulkService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(ProjectService) private readonly project: ProjectService, + @Inject(ProjectHooksService) private readonly projectHooks: ProjectHooksService, + ) {} + + @StartActiveSpan() + async bulkAction( + data: typeof projectContract.bulkActionProject.body._type, + requestorUserId?: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.bulk.action', data.action) + const projectIdsLog = data.projectIds === 'all' ? 'all' : `count=${data.projectIds.length}` + this.logger.log(`project.bulkAction started (action=${data.action}, projectIds=${projectIdsLog})`) + + try { + const projectIds = await this.resolveProjectIds(data.projectIds) + span?.setAttribute('project.bulk.count', projectIds.length) + + const results = await this.runTasks(projectIds, data.action, requestorUserId, requestId) + const summary = this.summarizeTasks(results) + + span?.setAttributes({ + 'project.bulk.fulfilled': summary.fulfilled, + 'project.bulk.rejected': summary.rejected, + }) + this.logger.log(`project.bulkAction completed (action=${data.action}, projectCount=${projectIds.length}, fulfilled=${summary.fulfilled}, rejected=${summary.rejected})`) + } catch (error) { + const projectIdsLabel = data.projectIds === 'all' + ? 'all' + : `count=${data.projectIds.length}` + const errorMessage = error instanceof Error ? error.message : String(error) + + this.logger.error( + `project.bulkAction failed (action=${data.action}, projectIds=${projectIdsLabel}): ${errorMessage}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async resolveProjectIds(projectIds: string[] | 'all'): Promise { + if (projectIds === 'all') { + const result = await listProjectIdsNotArchived(this.prisma) + return result.map(p => p.id) + } + return projectIds + } + + private async runTasks( + projectIds: string[], + action: string, + requestorUserId?: string, + requestId?: string, + ): Promise[]> { + const tasks = projectIds.map((projectId) => { + if (action === 'archive') { + return this.project.archive(projectId, requestorUserId, requestId) + } + if (action === 'lock' || action === 'unlock') { + return this.projectHooks.updateProjectLocked(projectId, action === 'lock') + } + if (action === 'replay') { + return this.projectHooks.replay(projectId, requestorUserId, requestId) + } + return Promise.resolve() + }) + return Promise.allSettled(tasks) + } + + private summarizeTasks(results: PromiseSettledResult[]): { fulfilled: number, rejected: number } { + return results.reduce( + (acc, r) => { + if (r.status === 'fulfilled') acc.fulfilled += 1 + else acc.rejected += 1 + return acc + }, + { fulfilled: 0, rejected: 0 }, + ) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts new file mode 100644 index 0000000000..9dd8e67e34 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-hooks/project-hooks.controller.ts @@ -0,0 +1,32 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { Controller, HttpCode, HttpStatus, Inject, Put, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ProjectHooksService } from './project-hooks.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectHooksController { + constructor( + @Inject(ProjectHooksService) private readonly projectHooks: ProjectHooksService, + ) {} + + @Put('/:projectId/hooks') + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ReplayHooks') + async replay( + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + await this.projectHooks.replay(project.id, user.userId, request.id) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts new file mode 100644 index 0000000000..ff83fb82fb --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-hooks/project-hooks.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { LogModule } from '../log/log.module' +import { ProjectHooksController } from './project-hooks.controller' +import { ProjectHooksService } from './project-hooks.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, EventsModule, LogModule, ProjectPermissionModule], + controllers: [ProjectHooksController], + providers: [ProjectHooksService], + exports: [ProjectHooksService], +}) +export class ProjectHooksModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts new file mode 100644 index 0000000000..92d9ddf77e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.spec.ts @@ -0,0 +1,80 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { ForbiddenException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { projectSelect } from '../project/project-queries.utils' +import { makeProject } from '../project/project-testing.utils' +import { ProjectHooksService } from './project-hooks.service' + +describe('projectHooksService', () => { + let module: TestingModule + let service: ProjectHooksService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectHooksService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(ProjectHooksService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('updateProjectLocked updates the project locked flag', async () => { + const project = makeProject({ locked: true }) + prisma.project.update.mockResolvedValue(project) + + await service.updateProjectLocked('project-id', true) + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 'project-id' }, + data: { locked: true }, + select: projectSelect, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', project, { action: 'Update Project' }) + }) + + it('replayHooks loads the project and emits project.upsert', async () => { + const project = makeProject() + prisma.project.findFirst.mockResolvedValue(project) + + const requestId = 'request-id' + const userId = 'user-id' + + await service.replay('project-id', userId, requestId) + + expect(prisma.project.findFirst).toHaveBeenCalledWith({ + where: { id: 'project-id', status: { not: 'archived' } }, + select: projectSelect, + }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', project, { + action: 'Replay hooks for Project', + userId, + requestId, + }) + }) + + it('replayHooks rejects locked projects', async () => { + const project = makeProject({ locked: true }) + prisma.project.findFirst.mockResolvedValue(project) + + await expect(service.replay('project-id', 'user-id', 'request-id')).rejects.toThrow(ForbiddenException) + + expect(appEvents.emitProjectEvent).not.toHaveBeenCalled() + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts new file mode 100644 index 0000000000..b51cd4df76 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-hooks/project-hooks.service.ts @@ -0,0 +1,46 @@ +import { ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { getProjectNotArchived, projectSelect } from '../project/project-queries.utils' + +@Injectable() +export class ProjectHooksService { + private readonly logger = new Logger(ProjectHooksService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async updateProjectLocked(projectId: string, locked: boolean): Promise { + const project = await this.prisma.project.update({ + where: { id: projectId }, + data: { locked }, + select: projectSelect, + }) + await this.appEvents.emitProjectEvent('project.upsert', project, { action: 'Update Project' }) + } + + async replay(projectId: string, requestorUserId?: string, requestId?: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.replayHooks started (projectId=${projectId})`) + const project = await getProjectNotArchived(this.prisma, projectId) + if (!project) { + this.logger.warn(`project.replayHooks notFound (projectId=${projectId})`) + return + } + if (project.locked) { + this.logger.warn(`project.replayHooks locked (projectId=${projectId})`) + throw new ForbiddenException('Veuillez déverrouiller le projet pour rejouer les webhooks') + } + span?.setAttribute('project.slug', project.slug) + await this.appEvents.emitProjectEvent('project.upsert', project, { + action: 'Replay hooks for Project', + userId: requestorUserId, + requestId, + }) + this.logger.log(`project.replayHooks completed (projectId=${projectId})`) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts new file mode 100644 index 0000000000..ad12c2486d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members-queries.utils.ts @@ -0,0 +1,78 @@ +import type { Prisma } from '@prisma/client' + +export const projectMemberWithUser = { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + }, + }, +} satisfies Prisma.ProjectMembersInclude + +export const projectOwnerIdSelect = { + ownerId: true, +} satisfies Prisma.ProjectSelect + +export type ProjectMemberWithUser = Prisma.ProjectMembersGetPayload<{ + include: typeof projectMemberWithUser +}> + +export type ProjectOwnerId = Prisma.ProjectGetPayload<{ + select: typeof projectOwnerIdSelect +}> + +export type AddMemberInput = { email: string } | { userId: string } +export interface PatchMemberInput { userId: string, roles: string[] } + +export function listProjectMembersWithUser(tx: Prisma.TransactionClient, projectId: string) { + return tx.projectMembers.findMany({ + where: { projectId }, + include: projectMemberWithUser, + }) +} + +export function upsertProjectMember( + tx: Prisma.TransactionClient, + projectId: string, + member: PatchMemberInput, +) { + const { userId, roles } = member + return tx.projectMembers.upsert({ + where: { projectId_userId: { projectId, userId } }, + create: { projectId, userId, roleIds: roles }, + update: { roleIds: roles }, + }) +} + +export function upsertProjectMemberIfMissing(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.upsert({ + where: { projectId_userId: { projectId, userId } }, + create: { projectId, userId, roleIds: [] }, + update: {}, + }) +} + +export function createProjectMember(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.create({ data: { projectId, userId } }) +} + +export function deleteProjectMember(tx: Prisma.TransactionClient, projectId: string, userId: string) { + return tx.projectMembers.delete({ where: { projectId_userId: { projectId, userId } } }) +} + +export function getHumanUser(tx: Prisma.TransactionClient, opts: { userId?: string, email?: string }) { + const { userId, email } = opts + return tx.user.findFirst({ + where: { + type: 'human', + ...(typeof userId === 'string' ? { id: userId } : {}), + ...(typeof email === 'string' ? { email } : {}), + }, + }) +} + +export function getProjectOwnerId(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectOwnerIdSelect }) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts new file mode 100644 index 0000000000..e01e7f37ea --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members-testing.utils.ts @@ -0,0 +1,11 @@ +import type { ProjectMembers } from '@prisma/client' +import { faker } from '@faker-js/faker' + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } satisfies ProjectMembers +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.controller.ts new file mode 100644 index 0000000000..1761096669 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.controller.ts @@ -0,0 +1,73 @@ +import type { Member } from '@cpn-console/shared' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { AddMemberInput, PatchMemberInput } from './project-members-queries.utils' +import { projectMemberContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Logger, Param, Patch, Post, UseGuards } from '@nestjs/common' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectMembersService } from './project-members.service' +import { generateProjectMember } from './project-members.utils' + +@Controller('api/v1/projects/:projectId/members') +@UseGuards(ProjectGuard) +export class ProjectMembersController { + private readonly logger = new Logger(ProjectMembersController.name) + + constructor( + @Inject(ProjectMembersService) private readonly projectMembers: ProjectMembersService, + ) {} + + @Get() + @RequireProjectPermission('ListMembers') + async list( + @Project() project: ProjectContext, + ): Promise { + return (await this.projectMembers.list(project.id)).map(generateProjectMember) + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async add( + @Body(new ZodValidationPipe(projectMemberContract.addMember.body)) body: AddMemberInput, + @Project() project: ProjectContext, + ): Promise { + const members = await this.projectMembers.add(project.id, body) + this.logger.log(`projectMembers.add completed (memberCount=${members.length})`) + return members.map(generateProjectMember) + } + + @Patch() + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async patch( + @Body(new ZodValidationPipe(projectMemberContract.patchMembers.body)) body: PatchMemberInput[], + @Project() project: ProjectContext, + ): Promise { + const members = await this.projectMembers.patch(project.id, body) + this.logger.log(`projectMembers.patchMembers completed (projectId=${project.id}, memberCount=${members.length})`) + return members.map(generateProjectMember) + } + + @Delete('/:userId') + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageMembers') + async remove( + @Project() project: ProjectContext, + @Param('userId') userId: string, + ): Promise { + const members = await this.projectMembers.remove(project.id, userId) + this.logger.log(`projectMembers.remove completed (projectId=${project.id}, userId=${userId}, memberCount=${members.length})`) + return members.map(generateProjectMember) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.module.ts new file mode 100644 index 0000000000..481aeddcef --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { keycloakConfigFactory } from '../../config/keycloak.config' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { KeycloakModule } from '../keycloak/keycloak.module' +import { ProjectMembersController } from './project-members.controller' +import { ProjectMembersService } from './project-members.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, KeycloakModule.forRoot(keycloakConfigFactory.asProvider()), ProjectPermissionModule], + controllers: [ProjectMembersController], + providers: [ProjectMembersService], +}) +export class ProjectMembersModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts new file mode 100644 index 0000000000..48ca9eeaaa --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts @@ -0,0 +1,268 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { KeycloakClientService } from '../keycloak/keycloak-client.service' +import { + makeProject, + makeProjectMembers, + makeProjectMemberWithUser, + makeUser, +} from '../project/project-testing.utils' +import { ProjectMembersService } from './project-members.service' + +describe('projectMembersService', () => { + let module: TestingModule + let service: ProjectMembersService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + let keycloak: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + keycloak = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectMembersService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + { provide: KeycloakClientService, useValue: keycloak }, + ], + }).compile() + + service = module.get(ProjectMembersService) + }) + + describe('list', () => { + it('returns members', async () => { + const projectId = faker.string.uuid() + const user1 = makeUser() + const user2 = makeUser() + prisma.projectMembers.findMany.mockResolvedValue([ + makeProjectMemberWithUser(user1, { roleIds: [faker.string.uuid()] }), + makeProjectMemberWithUser(user2), + ]) + + const result = await service.list(projectId) + + expect(result).toHaveLength(2) + expect(result[0]).toHaveProperty('roleIds') + expect(result[0]).toHaveProperty('user') + expect(result[0].user.id).toBe(user1.id) + }) + + it('returns empty array when no members', async () => { + const projectId = faker.string.uuid() + prisma.projectMembers.findMany.mockResolvedValue([]) + + const result = await service.list(projectId) + + expect(result).toEqual([]) + }) + }) + + describe('add', () => { + it('adds member by userId and returns updated member list', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const newUserId = faker.string.uuid() + const body = { userId: newUserId } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ id: newUserId })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId, userId: newUserId })) + const memberUser = makeUser() + tx.projectMembers.findMany.mockResolvedValue([makeProjectMemberWithUser(memberUser)]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.add(projectId, body) + + expect(tx.projectMembers.upsert).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId, userId: newUserId } }, + create: { projectId, userId: newUserId, roleIds: [] }, + update: {}, + }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: newUserId, + }, { action: 'Add Project Member' }) + expect(result).toBeDefined() + }) + + it('adds member by email and returns updated member list', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const email = faker.internet.email() + const body = { email } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ email })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId })) + tx.projectMembers.findMany.mockResolvedValue([]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.add(projectId, body) + + expect(tx.user.findFirst).toHaveBeenCalledWith({ + where: { email, type: 'human' }, + }) + expect(result).toBeDefined() + }) + + it('throws when adding owner as member', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const body = { userId: ownerId } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(makeUser({ id: ownerId })) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow('Le owner ne peut pas être ajouté à cette liste') + }) + + it('throws NotFoundException when user not found by userId', async () => { + const projectId = faker.string.uuid() + const body = { userId: faker.string.uuid() } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId: faker.string.uuid() })) + tx.user.findFirst.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow(NotFoundException) + }) + + it('falls back to Keycloak when user not found locally by email', async () => { + const projectId = faker.string.uuid() + const ownerId = faker.string.uuid() + const email = faker.internet.email() + const keycloakUserId = faker.string.uuid() + const body = { email } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId })) + tx.user.findFirst.mockResolvedValue(null) + tx.user.upsert.mockResolvedValue(makeUser({ id: keycloakUserId, email })) + tx.projectMembers.upsert.mockResolvedValue(makeProjectMembers({ projectId, userId: keycloakUserId })) + tx.projectMembers.findMany.mockResolvedValue([]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + keycloak.getUserByEmail.mockResolvedValue(makeUser({ + id: keycloakUserId, + email, + firstName: 'Keycloak', + lastName: 'User', + })) + + const result = await service.add(projectId, body) + + expect(keycloak.getUserByEmail).toHaveBeenCalledWith(email) + expect(tx.user.upsert).toHaveBeenCalledWith({ + where: { id: keycloakUserId }, + create: { + id: keycloakUserId, + email, + firstName: 'Keycloak', + lastName: 'User', + adminRoleIds: [], + type: 'human', + }, + update: { + email, + firstName: 'Keycloak', + lastName: 'User', + type: 'human', + }, + }) + expect(result).toBeDefined() + }) + + it('throws NotFoundException when user not found by email anywhere', async () => { + const projectId = faker.string.uuid() + const body = { email: faker.internet.email() } + + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(makeProject({ ownerId: faker.string.uuid() })) + tx.user.findFirst.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.add(projectId, body)) + .rejects.toThrow(NotFoundException) + }) + + it('throws NotFoundException when project does not exist', async () => { + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.add(faker.string.uuid(), { userId: faker.string.uuid() }), + ).rejects.toThrow(NotFoundException) + }) + }) + + describe('patch', () => { + it('upserts multiple members and emits events', async () => { + const projectId = faker.string.uuid() + const members = [ + { userId: faker.string.uuid(), roles: [faker.string.uuid()] }, + { userId: faker.string.uuid(), roles: [] }, + ] + + const tx = mockDeep() + prisma.$transaction.mockImplementation(async cb => cb(tx)) + tx.projectMembers.findMany.mockResolvedValue([]) + + await service.patch(projectId, members) + + expect(tx.projectMembers.upsert).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: members[0].userId, + }, { action: 'Update Project Member' }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.upsert', { + projectId, + userId: members[1].userId, + }, { action: 'Update Project Member' }) + }) + }) + + describe('remove', () => { + it('deletes member, emits event, returns updated list', async () => { + const projectId = faker.string.uuid() + const userId = faker.string.uuid() + + const tx = mockDeep() + tx.projectMembers.delete.mockResolvedValue(makeProjectMembers({ projectId, userId })) + const memberUser = makeUser() + tx.projectMembers.findMany.mockResolvedValue([makeProjectMemberWithUser(memberUser)]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.remove(projectId, userId) + + expect(tx.projectMembers.delete).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId, userId } }, + }) + expect(appEvents.emitProjectMemberEvent).toHaveBeenCalledWith('projectMember.delete', { + projectId, + userId, + }, { action: 'Remove Project Member' }) + expect(result).toBeDefined() + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.service.ts new file mode 100644 index 0000000000..4daa282eaa --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.service.ts @@ -0,0 +1,164 @@ +import type { AddMemberInput, PatchMemberInput } from './project-members-queries.utils' +import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { KeycloakClientService } from '../keycloak/keycloak-client.service' +import { deleteProjectMember, getHumanUser, getProjectOwnerId, listProjectMembersWithUser, upsertProjectMember, upsertProjectMemberIfMissing } from './project-members-queries.utils' + +@Injectable() +export class ProjectMembersService { + private readonly logger = new Logger(ProjectMembersService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, + ) {} + + @StartActiveSpan() + async list(projectId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.debug(`projectMembers.listMembers started (projectId=${projectId})`) + const members = await listProjectMembersWithUser(this.prisma, projectId) + span?.setAttribute('project.members.count', members.length) + this.logger.debug(`projectMembers.listMembers completed (projectId=${projectId}, count=${members.length})`) + return members + } + + @StartActiveSpan() + async add( + projectId: string, + body: AddMemberInput, + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + const lookupBy = 'userId' in body ? 'userId' : 'email' + span?.setAttribute('project.member.lookupBy', lookupBy) + const userIdCandidate = 'userId' in body ? body.userId : undefined + this.logger.log(`projectMembers.addMember started (projectId=${projectId}, lookupBy=${lookupBy}, userId=${userIdCandidate})`) + try { + const result = await this.prisma.$transaction(async (tx) => { + const project = await getProjectOwnerId(tx, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + + const userId = 'userId' in body ? body.userId : undefined + const email = 'email' in body ? body.email : undefined + + const userDb = await this.resolveHumanUser(tx, { userId, email }) + if (!userDb) throw new NotFoundException('Utilisateur introuvable') + + if (userDb.id === project.ownerId) { + throw new BadRequestException('Le owner ne peut pas être ajouté à cette liste') + } + + await upsertProjectMemberIfMissing(tx, projectId, userDb.id) + + const members = await listProjectMembersWithUser(tx, projectId) + return { userId: userDb.id, members } + }) + await this.appEvents.emitProjectMemberEvent('projectMember.upsert', { projectId, userId: result.userId }, { action: 'Add Project Member' }) + span?.setAttribute('project.member.userId', result.userId) + span?.setAttribute('project.members.count', result.members.length) + this.logger.log(`projectMembers.addMember completed (projectId=${projectId}, userId=${result.userId}, memberCount=${result.members.length})`) + return result.members + } catch (error) { + this.logger.error( + `projectMembers.addMember failed (projectId=${projectId}, lookupBy=${lookupBy}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async resolveHumanUser( + tx: Parameters[0], + opts: { userId?: string, email?: string }, + ) { + const userDb = await getHumanUser(tx, opts) + if (userDb || !opts.email) { + return userDb + } + + const keycloakUser = await this.keycloak.getUserByEmail(opts.email) + if (!keycloakUser) { + return null + } + + const keycloakUserId = keycloakUser.id ?? opts.email + return tx.user.upsert({ + where: { id: keycloakUserId }, + create: { + id: keycloakUserId, + email: keycloakUser.email ?? opts.email, + firstName: keycloakUser.firstName ?? keycloakUser.email ?? opts.email, + lastName: keycloakUser.lastName ?? '', + adminRoleIds: [], + type: 'human', + }, + update: { + email: keycloakUser.email ?? opts.email, + firstName: keycloakUser.firstName ?? keycloakUser.email ?? opts.email, + lastName: keycloakUser.lastName ?? '', + type: 'human', + }, + }) + } + + @StartActiveSpan() + async patch( + projectId: string, + body: PatchMemberInput[], + ) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('project.members.patch.count', body.length) + this.logger.log(`projectMembers.patchMembers started (projectId=${projectId}, patchCount=${body.length})`) + try { + const members = await this.prisma.$transaction(async (tx) => { + for (const member of body) { + await upsertProjectMember(tx, projectId, member) + } + return listProjectMembersWithUser(tx, projectId) + }) + await Promise.all( + body.map(member => this.appEvents.emitProjectMemberEvent('projectMember.upsert', { projectId, userId: member.userId }, { action: 'Update Project Member' })), + ) + span?.setAttribute('project.members.count', members.length) + this.logger.log(`projectMembers.patchMembers completed (projectId=${projectId}, memberCount=${members.length})`) + return members + } catch (error) { + this.logger.error( + `projectMembers.patchMembers failed (projectId=${projectId}, patchCount=${body.length}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async remove(projectId: string, userId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('project.member.userId', userId) + this.logger.log(`projectMembers.removeMember started (projectId=${projectId}, userId=${userId})`) + try { + const members = await this.prisma.$transaction(async (tx) => { + await deleteProjectMember(tx, projectId, userId) + return listProjectMembersWithUser(tx, projectId) + }) + await this.appEvents.emitProjectMemberEvent('projectMember.delete', { projectId, userId }, { action: 'Remove Project Member' }) + span?.setAttribute('project.members.count', members.length) + this.logger.log(`projectMembers.removeMember completed (projectId=${projectId}, userId=${userId}, memberCount=${members.length})`) + return members + } catch (error) { + this.logger.error( + `projectMembers.removeMember failed (projectId=${projectId}, userId=${userId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.utils.ts new file mode 100644 index 0000000000..4131cfbebd --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-members/project-members.utils.ts @@ -0,0 +1,13 @@ +import type { Member } from '@cpn-console/shared' +import type { ProjectMemberWithUser } from './project-members-queries.utils' + +export function generateProjectMember(member: ProjectMemberWithUser): Member { + const { roleIds, user } = member + return { + userId: user.id, + roleIds, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts new file mode 100644 index 0000000000..20a4355031 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles-queries.utils.ts @@ -0,0 +1,47 @@ +import type { Prisma } from '@prisma/client' + +export const projectRoleWithProjectSelect = { + id: true, + name: true, + permissions: true, + position: true, + oidcGroup: true, + type: true, + projectId: true, + project: { + select: { + slug: true, + members: { + distinct: 'userId', + }, + }, + }, +} satisfies Prisma.ProjectRoleSelect + +export const projectRoleForDeleteSelect = { + type: true, + projectId: true, +} satisfies Prisma.ProjectRoleSelect + +export const projectForRoleContextSelect = { + id: true, + slug: true, + status: true, + locked: true, +} satisfies Prisma.ProjectSelect + +export type ProjectRoleWithProject = Prisma.ProjectRoleGetPayload<{ select: typeof projectRoleWithProjectSelect }> + +export type ProjectForRoleContext = Prisma.ProjectRoleGetPayload<{ select: typeof projectForRoleContextSelect }> + +export function getProjectBySlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: { slug: true } }) +} + +export function getProjectRoleForDelete(tx: Prisma.TransactionClient, roleId: string) { + return tx.projectRole.findUnique({ where: { id: roleId }, select: projectRoleForDeleteSelect }) +} + +export function getProjectForUpsert(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectForRoleContextSelect }) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts new file mode 100644 index 0000000000..c9b74e9e28 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles-testing.utils.ts @@ -0,0 +1,71 @@ +import type { Project, ProjectMembers, ProjectRole } from '@prisma/client' +import { faker } from '@faker-js/faker' + +interface ProjectRoleWithMembers extends ProjectRole { + members?: Array +} + +interface ProjectRoleWithProject extends ProjectRole { + project: Pick +} + +export function makeProjectRole(overrides: Partial = {}): ProjectRole { + return { + id: faker.string.uuid(), + name: faker.person.jobTitle(), + permissions: faker.number.bigInt({ min: 0n, max: 64n }), + projectId: faker.string.uuid(), + position: faker.number.int({ min: 0, max: 100 }), + oidcGroup: '', + type: 'managed', + ...overrides, + } +} + +export function makeProjectRoleWithMembers(overrides: Partial = {}): ProjectRoleWithMembers { + return { + ...makeProjectRole(overrides), + members: overrides.members ?? [], + } +} + +export function makeProjectRoleWithProject(overrides: Partial = {}): ProjectRoleWithProject { + const role = makeProjectRole(overrides) + return { + ...role, + project: overrides.project ?? { slug: faker.helpers.slugify(faker.company.name()) }, + } +} + +export function makeProject(overrides: Partial = {}): Project { + return { + id: faker.string.uuid(), + name: faker.company.name(), + description: '', + status: 'initializing', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.helpers.slugify(faker.company.name()), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + ...overrides, + } +} + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts new file mode 100644 index 0000000000..c7fb6a706d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.controller.ts @@ -0,0 +1,70 @@ +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { projectRoleContract } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, Patch, Post, UseGuards } from '@nestjs/common' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectRolesService } from './project-roles.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectRolesController { + constructor( + @Inject(ProjectRolesService) private readonly projectRoles: ProjectRolesService, + ) {} + + @Get('/:projectId/roles') + @RequireProjectPermission('ListRoles') + async list( + @Project() project: ProjectContext, + ) { + return this.projectRoles.list(project.id) + } + + @Post('/:projectId/roles') + @HttpCode(HttpStatus.CREATED) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async create( + @Body(new ZodValidationPipe(projectRoleContract.createProjectRole.body)) body: typeof projectRoleContract.createProjectRole.body._type, + @Project() project: ProjectContext, + ) { + return this.projectRoles.create(project.id, body) + } + + @Patch('/:projectId/roles') + @HttpCode(HttpStatus.OK) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async update( + @Body(new ZodValidationPipe(projectRoleContract.patchProjectRoles.body)) body: typeof projectRoleContract.patchProjectRoles.body._type, + @Project() project: ProjectContext, + ) { + return this.projectRoles.update(project.id, body) + } + + @Get('/:projectId/roles/member-counts') + @RequireProjectPermission('ListRoles') + async countMembers( + @Project() project: ProjectContext, + ) { + return this.projectRoles.countMembers(project.id) + } + + @Delete('/:projectId/roles/:roleId') + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + @RequireProjectPermission('ManageRoles') + async delete( + @Param('roleId') roleId: string, + @Project() _project: ProjectContext, + ): Promise { + return this.projectRoles.delete(roleId) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts new file mode 100644 index 0000000000..e6bf20ad5f --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { ProjectRolesController } from './project-roles.controller' +import { ProjectRolesService } from './project-roles.service' + +@Module({ + imports: [AppEventsModule, AuthModule, DatabaseModule, ProjectPermissionModule], + controllers: [ProjectRolesController], + providers: [ProjectRolesService], + exports: [ProjectRolesService], +}) +export class ProjectRolesModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts new file mode 100644 index 0000000000..ef4a761800 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.service.spec.ts @@ -0,0 +1,181 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeProject, makeProjectMembers, makeProjectRole, makeProjectRoleWithProject } from './project-roles-testing.utils' +import { ProjectRolesService } from './project-roles.service' + +describe('projectRolesService', () => { + let module: TestingModule + let service: ProjectRolesService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectRolesService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + ], + }).compile() + + service = module.get(ProjectRolesService) + }) + + it('lists roles with permissions as strings and stripped oidcGroup', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + prisma.project.findUnique.mockResolvedValue(makeProject({ id: projectId, slug })) + prisma.projectRole.findMany.mockResolvedValue([makeProjectRoleWithProject({ + name: 'test', + permissions: 4n, + position: 0, + projectId, + oidcGroup: '/slug/console/admin', + project: { slug }, + })]) + + const roles = await service.list(projectId) + + expect(roles[0].permissions).toBe('4') + expect(roles[0].oidcGroup).toBe('/console/admin') + }) + + it('creates a role and emits project update', async () => { + const projectId = faker.string.uuid() + const slug = faker.string.alphanumeric(8).toLowerCase() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + prisma.projectRole.findFirst.mockResolvedValue(makeProjectRole({ position: 0 })) + prisma.projectRole.create.mockResolvedValue(makeProjectRole()) + prisma.projectRole.findMany.mockResolvedValue([]) + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + + await service.create(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + name: 'test', + permissions: 4n, + position: 1, + projectId, + }), + })) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', expect.anything(), { action: 'Upsert Project Role' }) + }) + + it('rejects system role creation', async () => { + const projectId = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug: 'slug' })) + + await expect(service.create(projectId, { name: 'test', permissions: '4', type: 'system:managed' })) + .rejects.toThrow(BadRequestException) + }) + + it('patches roles and validates positions', async () => { + const projectId = faker.string.uuid() + const slug = faker.string.alphanumeric(8).toLowerCase() + const roleId = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + prisma.projectRole.findMany.mockResolvedValue([makeProjectRoleWithProject({ + id: roleId, + name: 'test', + permissions: 4n, + position: 0, + projectId, + oidcGroup: '/slug/console/admin', + project: { slug }, + })]) + prisma.projectRole.update.mockResolvedValue(makeProjectRole({ id: roleId })) + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + + await service.update(projectId, [{ id: roleId, name: 'updated', position: 0 }]) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: roleId }, + data: expect.objectContaining({ + name: 'updated', + position: 0, + }), + })) + }) + + it('rejects incoherent positions', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ slug })) + const roleId1 = faker.string.uuid() + const roleId2 = faker.string.uuid() + prisma.projectRole.findMany.mockResolvedValue([ + makeProjectRoleWithProject({ id: roleId1, name: 'a', permissions: 1n, position: 0, projectId, oidcGroup: '', project: { slug } }), + makeProjectRoleWithProject({ id: roleId2, name: 'b', permissions: 1n, position: 1, projectId, oidcGroup: '', project: { slug } }), + ]) + + await expect(service.update(projectId, [{ id: roleId1, position: 1 }])) + .rejects.toThrow(BadRequestException) + }) + + it('counts role members', async () => { + const projectId = faker.string.uuid() + const slug = 'slug' + const roleId1 = faker.string.uuid() + const roleId2 = faker.string.uuid() + prisma.project.findUnique.mockResolvedValueOnce(makeProject({ id: projectId, slug })) + prisma.projectRole.findMany.mockResolvedValue([ + makeProjectRoleWithProject({ id: roleId1, name: 'a', permissions: 1n, position: 0, projectId, oidcGroup: '', project: { slug } }), + makeProjectRoleWithProject({ id: roleId2, name: 'b', permissions: 1n, position: 1, projectId, oidcGroup: '', project: { slug } }), + ]) + prisma.projectMembers.findMany.mockResolvedValue([ + makeProjectMembers({ roleIds: [roleId1, roleId2] }), + makeProjectMembers({ roleIds: [roleId2] }), + ]) + + await expect(service.countMembers(projectId)).resolves.toEqual({ + [roleId1]: 1, + [roleId2]: 2, + }) + }) + + it('deletes role and cleans member role ids', async () => { + const projectId = faker.string.uuid() + const roleId = faker.string.uuid() + prisma.projectRole.findUnique.mockResolvedValue(makeProjectRole({ type: 'managed', projectId })) + prisma.project.findUnique.mockResolvedValue(makeProject({ id: projectId, slug: 'slug' })) + + const tx = mockDeep() + tx.projectRole.delete.mockResolvedValue(makeProjectRole()) + tx.projectMembers.findMany.mockResolvedValue([ + makeProjectMembers({ userId: faker.string.uuid(), roleIds: [roleId] }), + makeProjectMembers({ userId: faker.string.uuid(), roleIds: [roleId, faker.string.uuid()] }), + ]) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.delete(roleId) + + expect(tx.projectMembers.update).toHaveBeenCalledTimes(2) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', expect.anything(), { action: 'Upsert Project Role' }) + }) + + it('rejects system role deletion', async () => { + prisma.projectRole.findUnique.mockResolvedValue(makeProjectRole({ type: 'system:managed', projectId: faker.string.uuid() })) + + await expect(service.delete(faker.string.uuid())) + .rejects.toThrow(BadRequestException) + }) + + it('rejects missing role deletion', async () => { + prisma.projectRole.findUnique.mockResolvedValue(null) + + await expect(service.delete(faker.string.uuid())) + .rejects.toThrow(NotFoundException) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts new file mode 100644 index 0000000000..a8c537b5fd --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.service.ts @@ -0,0 +1,130 @@ +import type { Project, ProjectRole } from '@prisma/client' +import type { + CreateProjectRoleInput, + PatchProjectRolesInput, +} from './project-roles.utils' +import { isSystemRoleType } from '@cpn-console/shared' +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' +import { AppEventsService } from '../events/app-events.service' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { getProjectBySlug, getProjectRoleForDelete, projectRoleWithProjectSelect } from './project-roles-queries.utils' +import { + buildUpdatedProjectRoles, + toProjectRoleResponse, + validatePatchedProjectRolePositions, +} from './project-roles.utils' + +@Injectable() +export class ProjectRolesService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + ) {} + + async list(projectId: Project['id']) { + const roles = await this.prisma.projectRole.findMany({ + where: { projectId }, + orderBy: { position: 'asc' }, + select: projectRoleWithProjectSelect, + }) + return roles.map(role => toProjectRoleResponse(role)) + } + + async update(projectId: Project['id'], roles: PatchProjectRolesInput) { + const project = await getProjectBySlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + + const dbRoles = await this.prisma.projectRole.findMany({ where: { projectId }, orderBy: { position: 'asc' } }) + const { requestedPositionsCount, updatedRoles } = buildUpdatedProjectRoles(project.slug, dbRoles, roles) + validatePatchedProjectRolePositions(dbRoles.length, requestedPositionsCount) + + for (const { id, ...role } of updatedRoles) { + await this.prisma.projectRole.update({ where: { id }, data: role }) + } + + await this.emitProjectUpsert(projectId) + return this.list(projectId) + } + + async create(projectId: Project['id'], role: CreateProjectRoleInput) { + const project = await getProjectBySlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + if (isSystemRoleType(role.type)) { + throw new BadRequestException('Impossible de créer un rôle système') + } + + const dbMaxPosRole = (await this.prisma.projectRole.findFirst({ + where: { projectId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + if (role.oidcGroup && !role.oidcGroup.startsWith('/')) { + throw new BadRequestException('oidcGroup doit commencer par /') + } + + await this.prisma.projectRole.create({ + data: { + ...role, + projectId, + position: dbMaxPosRole + 1, + permissions: BigInt(role.permissions), + oidcGroup: role.oidcGroup ? `/${project.slug}${role.oidcGroup}` : undefined, + }, + }) + + await this.emitProjectUpsert(projectId) + return this.list(projectId) + } + + async countMembers(projectId: Project['id']) { + const roles = await this.list(projectId) + const members = await this.prisma.projectMembers.findMany({ where: { projectId } }) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) + + for (const { roleIds } of members) { + for (const roleId of roleIds) { + rolesCounts[roleId]++ + } + } + + return rolesCounts + } + + async delete(roleId: ProjectRole['id']) { + const role = await getProjectRoleForDelete(this.prisma, roleId) + if (!role) throw new NotFoundException('Rôle introuvable') + if (isSystemRoleType(role.type)) { + throw new BadRequestException('Ce rôle système ne peut pas être supprimé') + } + + await this.prisma.$transaction(async (tx) => { + await tx.projectRole.delete({ where: { id: roleId } }) + const attachedMembers = await tx.projectMembers.findMany({ + where: { projectId: role.projectId, roleIds: { has: roleId } }, + }) + + for (const member of attachedMembers) { + await tx.projectMembers.update({ + where: { + projectId_userId: { + projectId: role.projectId, + userId: member.userId, + }, + }, + data: { + roleIds: { + set: member.roleIds.filter(existingRoleId => existingRoleId !== roleId), + }, + }, + }) + } + }) + + await this.emitProjectUpsert(role.projectId) + } + + private async emitProjectUpsert(projectId: string): Promise { + await this.appEvents.emitProjectEvent('project.upsert', projectId, { action: 'Upsert Project Role' }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts new file mode 100644 index 0000000000..c83bb2bb54 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-roles/project-roles.utils.ts @@ -0,0 +1,108 @@ +import type { ProjectRole as SharedProjectRole } from '@cpn-console/shared' +import type { ProjectRole } from '@prisma/client' +import type { ProjectRoleWithProject } from './project-roles-queries.utils' +import { isSystemRoleType } from '@cpn-console/shared' +import { BadRequestException } from '@nestjs/common' + +export interface ProjectRoleResponse { + id: string + name: string + oidcGroup?: string | null + type?: string | null + permissions: string + userCount?: number +} + +export type CreateProjectRoleInput = Omit +export type PatchProjectRoleInput = Pick & Partial> +export type PatchProjectRolesInput = PatchProjectRoleInput[] + +export function toProjectRoleResponse( + role: ProjectRoleWithProject, +): ProjectRoleResponse { + return { + id: role.id, + name: role.name, + oidcGroup: stripProjectSlugFromOidcGroup(role.oidcGroup, role.project.slug), + type: role.type ?? 'managed', + permissions: role.permissions.toString(), + userCount: role.project.members?.length ?? 0, + } +} + +export function buildUpdatedProjectRoles( + projectSlug: string, + dbRoles: ProjectRole[], + roles: PatchProjectRolesInput, +): { requestedPositionsCount: number, updatedRoles: ProjectRole[] } { + const requestedPositions = new Set() + const updatedRoles: ProjectRole[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (!matchingRole) continue + + validateProjectRolePatch(dbRole, matchingRole) + + if (matchingRole.position !== undefined) { + requestedPositions.add(matchingRole.position) + } + + updatedRoles.push(mergeProjectRolePatch(projectSlug, dbRole, matchingRole)) + } + + return { requestedPositionsCount: requestedPositions.size, updatedRoles } +} + +export function validatePatchedProjectRolePositions(dbRolesCount: number, requestedPositionsCount: number): void { + if (requestedPositionsCount && requestedPositionsCount !== dbRolesCount) { + throw new BadRequestException('Les numéros de position des rôles sont incohérentes') + } +} + +function validateProjectRolePatch(dbRole: ProjectRole, matchingRole: PatchProjectRoleInput): void { + if (isSystemRoleType(dbRole.type)) { + throw new BadRequestException('Ce rôle système ne peut pas être modifié') + } + + if (isSystemRoleType(matchingRole.type)) { + throw new BadRequestException('Impossible de modifier un rôle en rôle système') + } + + if (matchingRole.oidcGroup && !matchingRole.oidcGroup.startsWith('/')) { + throw new BadRequestException('oidcGroup doit commencer par /') + } +} + +function mergeProjectRolePatch( + projectSlug: string, + dbRole: ProjectRole, + matchingRole: PatchProjectRoleInput, +): ProjectRole { + const permissions = typeof matchingRole.permissions === 'string' + ? BigInt(matchingRole.permissions) + : dbRole.permissions + const oidcGroup = typeof matchingRole.oidcGroup === 'string' + ? `/${projectSlug}${matchingRole.oidcGroup}` + : dbRole.oidcGroup + + return { + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup, + type: matchingRole.type ?? dbRole.type, + projectId: dbRole.projectId, + } +} + +const oidcRegexp = /^\/[^/]+/ + +function stripProjectSlugFromOidcGroup(oidcGroup: string, projectSlug: string): string | null { + if (!oidcGroup.startsWith(`/${projectSlug}`)) { + return oidcGroup.replace(oidcRegexp, '') + } + + return oidcGroup.slice(projectSlug.length + 1) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts new file mode 100644 index 0000000000..f6595cbc81 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets-queries.utils.ts @@ -0,0 +1,13 @@ +import type { Prisma } from '@prisma/client' + +export const projectSlugSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export type ProjectSlug = Prisma.ProjectGetPayload<{ + select: typeof projectSlugSelect +}> + +export function getProjectSlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectSlugSelect }) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts new file mode 100644 index 0000000000..89d0174b4a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets-testing.utils.ts @@ -0,0 +1,21 @@ +import type { VaultMetadata, VaultSecret } from '../vault/vault-client.service' +import { faker } from '@faker-js/faker' + +export function makeVaultSecret = Record>(overrides: Partial> = {}): VaultSecret { + return { + data: {} as T, + metadata: makeVaultMetadata(), + ...overrides, + } satisfies VaultSecret +} + +function makeVaultMetadata(overrides: Partial = {}): VaultMetadata { + return { + created_time: faker.date.past().toISOString(), + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + ...overrides, + } satisfies VaultMetadata +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts new file mode 100644 index 0000000000..ae99445888 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, Inject, UseGuards } from '@nestjs/common' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ProjectSecretsService } from './project-secrets.service' + +@Controller('api/v1/projects') +@UseGuards(ProjectGuard) +export class ProjectSecretsController { + constructor( + @Inject(ProjectSecretsService) private readonly projectSecrets: ProjectSecretsService, + ) {} + + @Get('/:projectId/secrets') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectPermission('SeeSecrets') + async get( + @Project() project: { id: string }, + ): Promise>> { + return this.projectSecrets.get(project.id) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts new file mode 100644 index 0000000000..1ecc4f73e6 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts @@ -0,0 +1,17 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProjectSecretsModule } from './project-secrets.module' +import { ProjectSecretsService } from './project-secrets.service' + +describe('projectSecretsModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ProjectSecretsService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ProjectSecretsModule, 'USE_VAULT')], + }).compile() + expect(() => module.get(ProjectSecretsService)).toThrow() + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts new file mode 100644 index 0000000000..1811cf6eed --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { VaultModule } from '../vault/vault.module' +import { ProjectSecretsController } from './project-secrets.controller' +import { ProjectSecretsService } from './project-secrets.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, VaultModule], + controllers: [ProjectSecretsController], + providers: [ProjectSecretsService], + exports: [ProjectSecretsService], +}) +export class ProjectSecretsModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts new file mode 100644 index 0000000000..62586bd954 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts @@ -0,0 +1,106 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeProject } from '../project/project-testing.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { VaultService } from '../vault/vault.service' +import { makeVaultSecret } from './project-secrets-testing.utils' +import { ProjectSecretsService } from './project-secrets.service' + +describe('projectSecretsService', () => { + let module: TestingModule + let service: ProjectSecretsService + let prisma: DeepMockProxy + let vault: DeepMockProxy + let vaultClient: DeepMockProxy + let config: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + vault = mockDeep() + vaultClient = mockDeep() + config = mockDeep({ projectsRootDir: '/vault' }) + vaultConfig = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectSecretsService, + { provide: PrismaService, useValue: prisma }, + { provide: BASE_CONFIG, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: VaultService, useValue: vault }, + { provide: VaultClientService, useValue: vaultClient }, + ], + }).compile() + + service = module.get(ProjectSecretsService) + }) + + it('returns parsed secrets from vault', async () => { + const projectId = 'project-id' + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproject' })) + vault.listProjectSecrets.mockResolvedValue(['group1/secret1']) + vaultClient.read.mockResolvedValue(makeVaultSecret({ data: { key1: 'value1', key2: 42, key3: true, key4: null } })) + + const result = await service.get(projectId) + + expect(prisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: projectId }, + select: { slug: true }, + }) + expect(vault.listProjectSecrets).toHaveBeenCalledWith('myproject') + expect(result).toHaveProperty('group1') + expect(result.group1).toHaveProperty('secret1.key1', 'value1') + expect(result.group1).toHaveProperty('secret1.key2', '42') + expect(result.group1).toHaveProperty('secret1.key3', 'true') + expect(result.group1).toHaveProperty('secret1.key4', '') + }) + + it('handles nested secret paths', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue(['group1/sub/path']) + vaultClient.read.mockResolvedValue(makeVaultSecret({ data: { nested: 'value' } })) + + const result = await service.get('project-id') + + expect(result.group1).toHaveProperty('sub/path.nested', 'value') + }) + + it('returns empty object when no secrets exist', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue([]) + + const result = await service.get('project-id') + + expect(result).toEqual({}) + }) + + it('returns empty object when secret listing fails', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockRejectedValue(new Error('vault unavailable')) + + const result = await service.get('project-id') + + expect(result).toEqual({}) + }) + + it('skips secrets that fail to read', async () => { + prisma.project.findUnique.mockResolvedValue(makeProject({ slug: 'myproj' })) + vault.listProjectSecrets.mockResolvedValue(['group1/s1', 'group1/s2']) + vaultClient.read + .mockRejectedValueOnce(new Error('vault error')) + .mockResolvedValueOnce(makeVaultSecret({ data: { key: 'val' } })) + + const result = await service.get('project-id') + + expect(result.group1).toEqual({ 's2.key': 'val' }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts new file mode 100644 index 0000000000..723eea1454 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts @@ -0,0 +1,91 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { z } from 'zod' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { VaultService } from '../vault/vault.service' +import { generateProjectPath } from '../vault/vault.utils' +import { getProjectSlug } from './project-secrets-queries.utils' + +const SecretValueSchema = z.union([ + z.string(), + z.undefined().transform(() => ''), + z.number().transform(String), + z.bigint().transform(String), + z.boolean().transform(String), + z.null().transform(() => ''), +]).catch('') + +export function parseSecretValue(value: string): string { + return SecretValueSchema.parse(value) +} + +@Injectable() +export class ProjectSecretsService { + private readonly logger = new Logger(ProjectSecretsService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(VaultService) private readonly vault: VaultService, + @Inject(VaultClientService) private readonly vaultClient: VaultClientService, + ) {} + + @StartActiveSpan() + async get(projectId: string): Promise>> { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.get started (projectId=${projectId})`) + try { + const project = await getProjectSlug(this.prisma, projectId) + if (!project) throw new NotFoundException('Projet introuvable') + span?.setAttribute('project.slug', project.slug) + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, project.slug) + + const result: Record> = {} + const relativePaths = await this.vault.listProjectSecrets(project.slug).catch((error) => { + this.logger.warn( + `project.get secret listing failed (projectId=${projectId}, slug=${project.slug}): ${error instanceof Error ? error.message : String(error)}; returning an empty result`, + ) + return [] as string[] + }) + span?.setAttribute('vault.secretFiles.count', relativePaths.length) + this.logger.debug(`project.get listed (projectId=${projectId}, slug=${project.slug}, secretFiles=${relativePaths.length})`) + + for (const relativePath of relativePaths) { + const fullPath = `${projectPath}/${relativePath}` + const secret = await this.vaultClient.read>(fullPath).catch(() => null) + if (!secret?.data) continue + + const [group, ...rest] = relativePath.split('/').filter(Boolean) + if (!group) continue + const prefix = rest.length ? `${rest.join('/')}.` : '' + const groupObj = (result[group] ??= {}) + for (const [key, value] of Object.entries(secret.data)) { + groupObj[`${prefix}${key}`] = parseSecretValue(value) + } + } + + const groupCount = Object.keys(result).length + const keyCount = Object.values(result).reduce((acc, group) => acc + Object.keys(group).length, 0) + span?.setAttributes({ + 'vault.secretGroups.count': groupCount, + 'vault.secretKeys.count': keyCount, + }) + this.logger.log(`project.get completed (projectId=${projectId}, slug=${project.slug}, groupCount=${groupCount}, keyCount=${keyCount})`) + return result + } catch (error) { + this.logger.error( + `project.get failed (projectId=${projectId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts new file mode 100644 index 0000000000..ef539dae4a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services-queries.utils.ts @@ -0,0 +1,117 @@ +import type { Prisma } from '@prisma/client' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + clusters: { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + infos: true, + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + createdAt: true, + updatedAt: true, + clusterId: true, + cluster: { + select: { + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectPluginSelect = { + key: true, + pluginName: true, + projectId: true, + value: true, +} satisfies Prisma.ProjectPluginSelect + +export const adminPluginSelect = { + key: true, + pluginName: true, + value: true, +} satisfies Prisma.AdminPluginSelect + +export const publicClusterSelect = { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + infos: true, + zone: { + select: { + id: true, + slug: true, + label: true, + argocdUrl: true, + }, + }, + }, +} satisfies Prisma.ClusterFindManyArgs + +export type ProjectPlugin = Prisma.ProjectPluginGetPayload<{ + select: typeof projectPluginSelect +}> + +export type AdminPlugin = Prisma.AdminPluginGetPayload<{ + select: typeof adminPluginSelect +}> + +export type PublicCluster = Prisma.ClusterGetPayload<{ + select: typeof publicClusterSelect.select +}> + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export async function getServicesQueryData(tx: Prisma.TransactionClient, projectId: string) { + return Promise.all([ + tx.project.findUnique({ + where: { id: projectId }, + select: projectSelect, + }), + tx.projectPlugin.findMany({ + where: { projectId }, + select: projectPluginSelect, + }), + tx.adminPlugin.findMany({ + select: adminPluginSelect, + }), + tx.cluster.findMany({ + where: { privacy: 'public' }, + select: publicClusterSelect.select, + }), + ]) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts new file mode 100644 index 0000000000..6a52d2b057 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services-testing.utils.ts @@ -0,0 +1,231 @@ +import type { Plugin } from '@cpn-console/hooks' +import type { PluginsUpdateBody } from '@cpn-console/shared' +import type { Cluster, Environment, ProjectPlugin as PrismaProjectPlugin, Project, Zone } from '@prisma/client' +import type { AdminPlugin, ProjectPlugin, ProjectWithDetails, PublicCluster } from './project-services-queries.utils' +import { DEFAULT } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +/** Narrow zone shape the service reads from `PublicCluster.zone` / environment clusters. */ +export type PublicZone = PublicCluster['zone'] + +export function makeServicesPluginName(): string { + return faker.helpers.slugify(faker.company.name()) +} + +export function makePlugin(overrides: Partial = {}): Plugin { + const pluginName = overrides.name ?? makeServicesPluginName() + + return { + infos: { + name: pluginName, + title: faker.commerce.productName(), + description: faker.lorem.sentence(), + imgSrc: `/${faker.helpers.slugify(faker.commerce.product())}.svg`, + config: { + global: [], + project: [ + { + kind: 'switch', + key: 'enabled', + title: 'Enabled', + value: DEFAULT, + initialValue: DEFAULT, + permissions: { + user: { read: true, write: true }, + admin: { read: true, write: true }, + }, + }, + ], + }, + to: ({ project }) => `/projects/${project.slug}`, + ...overrides, + }, + subscribedHooks: {}, + } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + name: faker.company.name(), + slug: faker.helpers.slugify(faker.company.name()), + description: faker.lorem.sentence(), + clusters: [makePublicCluster()], + environments: [], + ...overrides, + } +} + +export function makeProjectPlugin(overrides: Partial = {}): ProjectPlugin { + return { + pluginName: makeServicesPluginName(), + projectId: faker.string.uuid(), + key: 'enabled', + value: DEFAULT, + ...overrides, + } +} + +export function makeServicesAdminPlugin(overrides: Partial = {}): AdminPlugin { + const pluginName = overrides.pluginName ?? makeServicesPluginName() + const adminPlugin = { + pluginName, + key: 'enabled', + value: DEFAULT, + } + return { ...adminPlugin, ...overrides, pluginName } +} + +export function makePublicCluster(overrides: Partial = {}): PublicCluster { + const resolvedZone = overrides.zone ?? makeServicesZone() + return { + id: faker.string.uuid(), + label: faker.company.name(), + privacy: 'public', + clusterResources: false, + infos: null, + zone: resolvedZone, + ...overrides, + } +} + +export function makeServicesZone(overrides: Partial = {}): PublicZone { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`zone-${faker.string.alphanumeric(8)}`), + label: faker.location.city(), + argocdUrl: `https://${faker.helpers.slugify(faker.location.city())}.example.com`, + ...overrides, + } +} + +export function makeServicesEnvironment(overrides: Partial & { cluster?: PublicCluster } = {}): ProjectWithDetails['environments'][number] { + const { cluster: clusterOverride, ...restOverrides } = overrides + const cluster = clusterOverride ?? makePublicCluster() + return { + id: faker.string.uuid(), + name: faker.word.noun(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: cluster.id, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + cluster, + ...restOverrides, + } +} + +export function makeServicesUpdateBody(pluginName: string = makeServicesPluginName()): PluginsUpdateBody { + return { + [pluginName]: { + enabled: DEFAULT, + }, + } +} + +export type ClusterWithZone = Cluster & { zone: Zone } + +export function makeCluster(overrides: Partial = {}): Cluster { + return { + id: faker.string.uuid(), + label: faker.company.name(), + privacy: 'public', + secretName: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + infos: null, + memory: faker.number.float({ min: 0, max: 1024, fractionDigits: 2 }), + cpu: faker.number.float({ min: 0, max: 64, fractionDigits: 2 }), + gpu: faker.number.float({ min: 0, max: 8, fractionDigits: 2 }), + zoneId: faker.string.uuid(), + ...overrides, + } +} + +export function makeClusterWithZone(overrides: Partial = {}): ClusterWithZone { + const resolvedZone = overrides.zone ?? { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`zone-${faker.string.alphanumeric(8)}`), + label: faker.location.city(), + description: faker.lorem.sentence(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + argocdUrl: `https://${faker.helpers.slugify(faker.location.city())}.example.com`, + } + const { zone: _zone, ...clusterOverrides } = overrides + return { + ...makeCluster(clusterOverrides), + zoneId: clusterOverrides.zoneId ?? resolvedZone.id, + zone: resolvedZone, + } +} + +export function makeEnvironment(overrides: Partial = {}): Environment { + return { + id: faker.string.uuid(), + name: faker.word.noun(), + projectId: faker.string.uuid(), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: true, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + ...overrides, + } +} + +export type EnvironmentWithCluster = Environment & { cluster: ClusterWithZone } + +export function makeEnvironmentWithCluster(overrides: Partial = {}): EnvironmentWithCluster { + const resolvedCluster = overrides.cluster ?? makeClusterWithZone() + const { cluster: _cluster, ...envOverrides } = overrides + return { + ...makeEnvironment(envOverrides), + clusterId: envOverrides.clusterId ?? resolvedCluster.id, + cluster: resolvedCluster, + } +} + +export type ProjectWithRelations = Project & { + plugins: PrismaProjectPlugin[] + clusters: ClusterWithZone[] + environments: EnvironmentWithCluster[] +} + +export function makeProject(overrides: Partial = {}): ProjectWithRelations { + return { + id: faker.string.uuid(), + name: faker.company.name(), + description: faker.lorem.sentence(), + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + everyonePerms: 896n, + ownerId: faker.string.uuid(), + slug: faker.helpers.slugify(faker.company.name()), + limitless: true, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + lastSuccessProvisionningVersion: null, + plugins: [], + clusters: [], + environments: [], + ...overrides, + } +} + +export function makeProjectWithRelations(overrides: Partial = {}): ProjectWithRelations { + return makeProject(overrides) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.controller.ts new file mode 100644 index 0000000000..00ff45d9f2 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.controller.ts @@ -0,0 +1,49 @@ +import type { PermissionTarget, PluginsUpdateBody } from '@cpn-console/shared' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { AdminAuthorized, projectServiceContract } from '@cpn-console/shared' +import { Body, Controller, ForbiddenException, Get, HttpCode, HttpStatus, Inject, Post, Query, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectLocked } from '../infrastructure/permission/project/project-locked.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectServicesService } from './project-services.service' + +@Controller('api/v1/projects/:projectId/services') +@UseGuards(ProjectGuard) +export class ProjectServicesController { + constructor( + @Inject(ProjectServicesService) private readonly projectServices: ProjectServicesService, + ) {} + + @Get() + @RequireProjectPermission('ListEnvironments') + async get( + @Query(new ZodValidationPipe(projectServiceContract.getServices.query)) query: { permissionTarget: PermissionTarget }, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + ) { + if (query.permissionTarget === 'admin' && !AdminAuthorized.Manage(user.adminPermissions ?? 0n)) { + throw new ForbiddenException('Vous ne pouvez pas demander les paramètres admin') + } + + return this.projectServices.get(project.id, query.permissionTarget) + } + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + @RequireProjectPermission('ManageEnvironments') + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectLocked(false) + async update( + @Body(new ZodValidationPipe(projectServiceContract.updateProjectServices.body)) body: PluginsUpdateBody, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + ): Promise { + const allowedRoles: Array<'user' | 'admin'> = AdminAuthorized.Manage(user.adminPermissions ?? 0n) ? ['user', 'admin'] : ['user'] + await this.projectServices.update(project.id, body, allowedRoles) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts new file mode 100644 index 0000000000..375e371303 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts @@ -0,0 +1,17 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProjectServicesModule } from './project-services.module' +import { ProjectServicesService } from './project-services.service' + +describe('projectServicesModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ProjectServicesService when USE_PLUGINS=false', async () => { + vi.stubEnv('USE_PLUGINS', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ProjectServicesModule, 'USE_PLUGINS')], + }).compile() + expect(() => module.get(ProjectServicesService)).toThrow() + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.module.ts new file mode 100644 index 0000000000..599b8cae0a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { PluginModule } from '../plugin/plugin.module' +import { ProjectServicesController } from './project-services.controller' +import { ProjectServicesService } from './project-services.service' + +@Module({ + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, ConditionalModule.registerWhen(PluginModule, 'USE_PLUGINS')], + controllers: [ProjectServicesController], + providers: [ProjectServicesService], + exports: [ProjectServicesService], +}) +export class ProjectServicesModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts new file mode 100644 index 0000000000..f0702f9a97 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.service.spec.ts @@ -0,0 +1,175 @@ +import type { Plugin, ServiceInfos } from '@cpn-console/hooks' +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { ClusterWithZone, ProjectWithRelations } from './project-services-testing.utils' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PluginService } from '../plugin/plugin.service' +import { + makeClusterWithZone, + makeEnvironmentWithCluster, + makePlugin, + makeProjectPlugin, + makeProjectWithRelations, + makeServicesUpdateBody, +} from './project-services-testing.utils' +import { ProjectServicesService } from './project-services.service' + +function makeEmptyServiceInfo(name: string): ServiceInfos { + return { + name, + title: name, + config: { + global: [], + project: [], + }, + } +} + +describe('servicesService', () => { + let module: TestingModule + let service: ProjectServicesService + let prisma: DeepMockProxy + let projectId: string + let pluginName: string + let projectSlug: string + let serviceTitle: string + let serviceDescription: string + let serviceImgSrc: string + let project: ProjectWithRelations + let cluster: ClusterWithZone + let plugin: Plugin + + beforeEach(async () => { + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(faker.company.name()) + serviceTitle = faker.commerce.productName() + serviceDescription = faker.lorem.sentence() + serviceImgSrc = `/${faker.helpers.slugify(faker.commerce.product())}.svg` + + plugin = makePlugin({ + title: serviceTitle, + description: serviceDescription, + imgSrc: serviceImgSrc, + to: ({ project }) => `/projects/${project.slug}`, + }) + pluginName = plugin.infos.name + project = makeProjectWithRelations({ id: projectId, slug: projectSlug }) + cluster = makeClusterWithZone() + + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectServicesService, + { provide: PrismaService, useValue: prisma }, + { provide: PluginService, useValue: { infos: async () => [plugin.infos, makeEmptyServiceInfo('gitlab'), makeEmptyServiceInfo('harbor'), makeEmptyServiceInfo('keycloak'), makeEmptyServiceInfo('nexus'), makeEmptyServiceInfo('sonarqube'), makeEmptyServiceInfo('vault')] } }, + ], + }).compile() + + service = module.get(ProjectServicesService) + }) + + afterEach(async () => { + await module?.close() + }) + + it('get returns the service with actual hooks data', async () => { + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectPlugin.findMany.mockResolvedValue([ + makeProjectPlugin({ projectId, pluginName, value: ENABLED }), + ]) + prisma.adminPlugin.findMany.mockResolvedValue([]) + prisma.cluster.findMany.mockResolvedValue([cluster]) + + const result = await service.get(projectId, 'user') + const e2eService = result.find(entry => entry.name === pluginName) + + expect(e2eService).toMatchObject({ + name: pluginName, + title: serviceTitle, + imgSrc: serviceImgSrc, + urls: [{ to: `/projects/${projectSlug}`, name: '' }], + }) + expect(e2eService?.manifest.project?.[0]).toMatchObject({ + key: 'enabled', + value: ENABLED, + }) + }) + + it('get includes zones coming from project environments', async () => { + const baseZone = makeClusterWithZone().zone + const environmentZone = makeClusterWithZone({ + zone: { + ...baseZone, + argocdUrl: 'https://argocd.example.com', + }, + }) + const projectWithEnvironmentZones = makeProjectWithRelations({ + clusters: [], + environments: [ + makeEnvironmentWithCluster({ + cluster: environmentZone, + }), + ], + }) + const envZonePlugin = makePlugin({ + to: ({ zones }) => zones.map(zone => ({ + to: `${zone.argocdUrl}/applications?search=${zone.slug}`, + title: zone.label, + })), + }) + + prisma.project.findUnique.mockResolvedValue(projectWithEnvironmentZones) + prisma.projectPlugin.findMany.mockResolvedValue([]) + prisma.adminPlugin.findMany.mockResolvedValue([]) + prisma.cluster.findMany.mockResolvedValue([]) + + const envModule = await Test.createTestingModule({ + providers: [ + ProjectServicesService, + { provide: PrismaService, useValue: prisma }, + { provide: PluginService, useValue: { infos: async () => [envZonePlugin.infos] } }, + ], + }).compile() + const envService = envModule.get(ProjectServicesService) + + const result = await envService.get(projectId, 'user') + + expect(result.find(entry => entry.name === envZonePlugin.infos.name)).toMatchObject({ + urls: [{ to: `${environmentZone.zone.argocdUrl}/applications?search=${environmentZone.zone.slug}`, name: environmentZone.zone.label }], + }) + await envModule.close() + }) + + it('update stores project configuration through the real utility', async () => { + const data = makeServicesUpdateBody(pluginName) + + await service.update(projectId, data, ['user']) + + expect(prisma.projectPlugin.upsert).toHaveBeenCalledWith({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key: 'enabled', + }, + }, + create: { + projectId, + pluginName, + key: 'enabled', + value: data[pluginName].enabled, + }, + update: { + pluginName, + key: 'enabled', + value: data[pluginName].enabled, + }, + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.service.ts new file mode 100644 index 0000000000..572e947767 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.service.ts @@ -0,0 +1,105 @@ +import type { ZoneObject } from '@cpn-console/hooks' +import type { PermissionTarget, PluginsUpdateBody, ProjectService } from '@cpn-console/shared' +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PluginService } from '../plugin/plugin.service' +import { getServicesQueryData } from './project-services-queries.utils' +import { + buildProjectEditStrippers, + generatePluginsUpdateBody, + normalizeServiceUrls, + parsePluginsUpdateBody, + populateServiceManifest, + saveProjectStore, +} from './project-services.utils' + +@Injectable() +export class ProjectServicesService { + private readonly logger = new Logger(ProjectServicesService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(PluginService) private readonly plugin: PluginService, + ) {} + + async get(projectId: string, permissionTarget: PermissionTarget): Promise { + const [project, projectStore, globalConfig, publicClusters] = await getServicesQueryData(this.prisma, projectId) + + if (!project) { + throw new NotFoundException('Projet introuvable') + } + + const store = generatePluginsUpdateBody([...projectStore, ...globalConfig]) + const clusters = [...project.clusters, ...publicClusters] + const zones = new Map() + for (const cluster of clusters) { + zones.set(cluster.zone.id, cluster.zone) + } + for (const environment of project.environments) { + const zone = environment.cluster?.zone + if (zone) zones.set(zone.id, zone) + } + const serviceInfos = await this.plugin.infos(projectId) + + const services = await Promise.all(serviceInfos.map(async (serviceInfo) => { + const { name, title, to, imgSrc, description } = serviceInfo + let toResponse: unknown = [] + if (to) { + try { + toResponse = to({ + clusters: clusters.map(c => ({ + id: c.id, + label: c.label, + privacy: c.privacy, + clusterResources: c.clusterResources, + infos: c.infos, + zone: c.zone, + })), + zones: [...zones.values()], + environments: project.environments, + project: { + id: project.id, + name: project.name, + slug: project.slug, + }, + store, + }) + } catch (error) { + this.logger.debug({ err: error, service: name }, 'Service URL generation failed, returning no URLs') + } + } + + const urls = normalizeServiceUrls(toResponse) + const manifest = populateServiceManifest({ + service: serviceInfo, + data: { + project: projectStore, + global: globalConfig, + }, + permissionTarget, + select: { + global: true, + project: true, + }, + }) + + return { imgSrc, title, name, urls, manifest, description } + })) + + return services.filter(service => service.urls.length || service.manifest.global?.length || service.manifest.project?.length) + } + + async update(projectId: string, data: PluginsUpdateBody, stripperRoles: Array<'user' | 'admin'>) { + this.logger.log(`Update project services started (projectId=${projectId}, pluginCount=${Object.keys(data).length}, stripperRoles=${stripperRoles.join(',')})`) + const editStrippers = buildProjectEditStrippers(await this.plugin.infos(projectId)) + + for (const role of stripperRoles) { + const parsedData = editStrippers.project[role].safeParse(data) + if (!parsedData.success) continue + + await saveProjectStore(parsePluginsUpdateBody(parsedData.data), projectId, this.prisma) + } + + this.logger.log(`Update project services completed (projectId=${projectId})`) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.utils.ts new file mode 100644 index 0000000000..c5961851e1 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project-services/project-services.utils.ts @@ -0,0 +1,125 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { PermissionTarget, PluginConfig, PluginsUpdateBody, ServiceUrl } from '@cpn-console/shared' +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { editStrippersGenerator } from '@cpn-console/hooks' +import { atomicValidators, DEFAULT } from '@cpn-console/shared' +import { z } from 'zod' + +export interface PluginRecord { + pluginName: string + key: string + value: string +} + +export function generatePluginsUpdateBody(records: PluginRecord[]): PluginsUpdateBody { + const obj: PluginsUpdateBody = {} + for (const record of records) { + if (!obj[record.pluginName]) obj[record.pluginName] = {} + obj[record.pluginName][record.key] = record.value + } + return obj +} + +export function parsePluginsUpdateBody(obj: PluginsUpdateBody): PluginRecord[] { + return Object.entries(obj) + .flatMap(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) +} + +export function buildProjectEditStrippers(services: ServiceInfos[]) { + const project = { + user: z.object({}), + admin: z.object({}), + } + let global = z.object({}) + + for (const service of services) { + if (!service.config) continue + const editZod = editStrippersGenerator.parse(service.config) + global = global.merge(z.object({ [service.name]: editZod.global.default({}) })) + project.admin = project.admin.merge(z.object({ [service.name]: editZod.project.admin.default({}) })) + project.user = project.user.merge(z.object({ [service.name]: editZod.project.user.default({}) })) + } + + return { project, global } +} + +interface ServiceManifestParams { + service: ServiceInfos + data: { project?: PluginRecord[], global?: PluginRecord[] } + permissionTarget: PermissionTarget + select: Partial> +} + +export function populateServiceManifest({ service, data, select, permissionTarget }: ServiceManifestParams): Partial { + const manifest = structuredClone(service.config) + + const selected: Partial = {} + for (const [scope] of Object.entries(select).filter(([_scope, bool]) => bool)) { + if (!manifest?.[scope]) continue + selected[scope] = manifest[scope].filter(item => item.permissions[permissionTarget].read || item.permissions[permissionTarget].write).map((item) => { + const row = data[scope]?.find(candidate => candidate.pluginName === service.name && candidate.key === item.key) + if (item.kind === 'switch') { + const value = atomicValidators.switch.safeParse(row?.value) + item.value = value.success ? value.data : DEFAULT + } else { + item.value = z.coerce.string().parse(row?.value ?? item.value ?? '') + } + return item + }) + } + return selected +} + +export function normalizeServiceUrls(toResponse: unknown): ServiceUrl[] { + if (Array.isArray(toResponse)) { + return toResponse.map(res => ({ name: res.title ?? '', description: res.description ?? '', to: res.to })) + } + + if (typeof toResponse === 'string') { + return [{ to: toResponse, name: '' }] + } + + if (toResponse) { + return [{ name: (toResponse as { title?: string }).title ?? '', to: (toResponse as { to: string }).to }] + } + + return [] +} + +export async function saveProjectStore(records: PluginRecord[], projectId: string, prisma: PrismaService) { + for (const { pluginName, key, value } of records) { + if (value === null) { + await prisma.projectPlugin.delete({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } else { + await prisma.projectPlugin.upsert({ + create: { + pluginName, + projectId, + key, + value: value.toString(), + }, + update: { + key, + value: value.toString(), + pluginName, + }, + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project-queries.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project-queries.utils.ts new file mode 100644 index 0000000000..8469ab9cee --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project-queries.utils.ts @@ -0,0 +1,311 @@ +import type { Prisma } from '@prisma/client' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + status: true, + locked: true, + limitless: true, + hprodCpu: true, + hprodGpu: true, + hprodMemory: true, + prodCpu: true, + prodGpu: true, + prodMemory: true, + everyonePerms: true, + ownerId: true, + createdAt: true, + updatedAt: true, + lastSuccessProvisionningVersion: true, + owner: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + type: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + }, + }, + members: { + select: { + roleIds: true, + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + type: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + }, + }, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + roles: { + select: { + id: true, + name: true, + permissions: true, + position: true, + oidcGroup: true, + type: true, + projectId: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + isPrivate: true, + externalRepoUrl: true, + externalUserName: true, + helmValuesFiles: true, + deployRevision: true, + deployPath: true, + createdAt: true, + updatedAt: true, + }, + }, + environments: { + select: { + id: true, + name: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + clusterId: true, + stageId: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + }, + }, + deployments: { + select: { + id: true, + name: true, + autosync: true, + createdAt: true, + updatedAt: true, + environment: { + select: { + id: true, + name: true, + cluster: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + deploymentSources: { + select: { + id: true, + type: true, + path: true, + targetRevision: true, + helmValuesFiles: true, + repository: { + select: { + id: true, + internalRepoName: true, + }, + }, + }, + }, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectForUpdateSelect = { + id: true, + ownerId: true, + status: true, + locked: true, + members: { + select: { + userId: true, + user: { + select: { + type: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export const projectForDataSelect = { + name: true, + description: true, + createdAt: true, + updatedAt: true, + environments: { + select: { + name: true, + stage: true, + cluster: { + select: { label: true }, + }, + }, + }, + owner: true, +} satisfies Prisma.ProjectSelect + +export const projectIdSelect = { + id: true, +} satisfies Prisma.ProjectSelect + +export const projectSlugSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export const projectContextSelect = { + slug: true, +} satisfies Prisma.ProjectSelect + +export const projectForUpsertSelect = { + id: true, + slug: true, + status: true, + locked: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export type ProjectUpdateContext = Prisma.ProjectGetPayload<{ + select: typeof projectForUpdateSelect +}> + +export type ProjectDataExport = Prisma.ProjectGetPayload<{ + select: typeof projectForDataSelect +}> + +export type ProjectContext = Prisma.ProjectGetPayload<{ + select: typeof projectContextSelect +}> + +export type ProjectForUpsert = Prisma.ProjectGetPayload<{ + select: typeof projectForUpsertSelect +}> + +export function getProject(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectSelect }) +} + +export function getProjectNotArchived(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findFirst({ where: { id: projectId, status: { not: 'archived' } }, select: projectSelect }) +} + +export function listProjects(tx: Prisma.TransactionClient, whereAnd: Prisma.ProjectWhereInput[]) { + return tx.project.findMany({ + where: { AND: whereAnd }, + select: projectSelect, + }) +} + +export function listProjectSlugsForPrefix(tx: Prisma.TransactionClient, prefix: string) { + return tx.project.findMany({ + where: { slug: { startsWith: prefix } }, + select: { slug: true }, + }) +} + +export function getProjectSlug(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: { slug: true } }) +} + +export function getProjectContext(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectContextSelect }) +} + +export function listProjectsForDataExport(tx: Prisma.TransactionClient) { + return tx.project.findMany({ + select: projectForDataSelect, + }) +} + +export function createProject(tx: Prisma.TransactionClient, data: Prisma.ProjectCreateInput) { + return tx.project.create({ + data, + select: projectIdSelect, + }) +} + +export function getNotArchivedProjectForUpdate(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findFirst({ + where: { id: projectId, status: { not: 'archived' } }, + select: projectForUpdateSelect, + }) +} + +export function getProjectForUpsert(tx: Prisma.TransactionClient, projectId: string) { + return tx.project.findUnique({ where: { id: projectId }, select: projectForUpsertSelect }) +} + +export function updateProject(tx: Prisma.TransactionClient, projectId: string, data: Prisma.ProjectUpdateInput) { + return tx.project.update({ where: { id: projectId }, data }) +} + +export function deleteProjectDependencies(tx: Prisma.TransactionClient, projectId: string) { + return Promise.all([ + tx.repository.deleteMany({ where: { projectId } }), + tx.environment.deleteMany({ where: { projectId } }), + tx.deployment.deleteMany({ where: { projectId } }), + ]) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project-testing.utils.ts new file mode 100644 index 0000000000..4205b88470 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project-testing.utils.ts @@ -0,0 +1,161 @@ +import type { projectContract } from '@cpn-console/shared' +import type { Prisma, ProjectMembers, User } from '@prisma/client' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import type { projectSelect, ProjectWithDetails } from './project-queries.utils' +import { PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +export function makeUser(overrides: Partial = {}): User { + return { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastLogin: null, + ...overrides, + } +} + +export function makeProjectMembers(overrides: Partial = {}): ProjectMembers { + return { + projectId: faker.string.uuid(), + userId: faker.string.uuid(), + roleIds: [], + ...overrides, + } +} + +type ProjectMembersWithUser = ProjectMembers & { user: User } + +export function makeProjectMemberWithUser(user: User, overrides: Partial = {}): ProjectMembersWithUser { + return { + ...makeProjectMembers({ userId: user.id, ...overrides }), + user, + } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + const owner = overrides.owner ?? makeUser() + const id = overrides.id ?? faker.string.uuid() + return { + id, + name: faker.string.alphanumeric(8).toLowerCase(), + slug: faker.string.alphanumeric(8).toLowerCase(), + description: faker.lorem.sentence(), + status: 'created', + locked: false, + limitless: false, + hprodCpu: 1, + hprodGpu: 0, + hprodMemory: 2, + prodCpu: 1, + prodGpu: 0, + prodMemory: 2, + everyonePerms: PROJECT_PERMS.GUEST, + ownerId: owner.id, + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastSuccessProvisionningVersion: null, + owner, + members: [], + plugins: [], + roles: [], + repositories: [], + environments: [], + deployments: [], + clusters: [], + ...overrides, + } +} + +export function makeProjectContext(overrides: Partial = {}): ProjectContext { + return { + id: faker.string.uuid(), + slug: faker.string.alphanumeric(8).toLowerCase(), + locked: false, + status: 'created', + ...overrides, + } +} + +type Project = Prisma.ProjectGetPayload<{ select: typeof projectSelect }> + +export function makeProject(overrides: Partial = {}): Project { + const id = overrides.id ?? faker.string.uuid() + return { + id, + name: faker.string.alphanumeric(8).toLowerCase(), + slug: faker.string.alphanumeric(8).toLowerCase(), + description: '', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 896n, + ownerId: faker.string.uuid(), + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastSuccessProvisionningVersion: null, + owner: { + id: faker.string.uuid(), + email: 'owner@example.com', + firstName: 'Owner', + lastName: 'Test', + adminRoleIds: [], + type: 'human', + createdAt: faker.date.past(), + updatedAt: faker.date.past(), + lastLogin: faker.date.past(), + }, + members: [], + plugins: [], + roles: [], + repositories: [], + environments: [], + deployments: [], + clusters: [], + ...overrides, + } +} + +export type ProjectWithMembers = Prisma.ProjectGetPayload<{ + include: { members: { include: { user: true } } } +}> + +export function makeProjectWithMembersResult( + project: ProjectWithDetails, + members: Array> = [], +): ProjectWithMembers { + return { ...project, members } as ProjectWithMembers +} + +export function makeCreateProjectBody(overrides: Partial = {}): typeof projectContract.createProject.body._type { + return { + name: faker.string.alphanumeric({ length: faker.number.int({ min: 2, max: 20 }) }).toLowerCase(), + description: faker.lorem.sentence(), + limitless: true, + hprodCpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + hprodGpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + hprodMemory: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodCpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodGpu: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + prodMemory: faker.number.float({ min: 0, max: 10, fractionDigits: 2 }), + ...overrides, + } +} + +export function makeListProjectsQuery(overrides: Partial = {}): typeof projectContract.listProjects.query._type { + return { + filter: 'member', + ...overrides, + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.controller.ts new file mode 100644 index 0000000000..a10afe449f --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.controller.ts @@ -0,0 +1,91 @@ +import type { ProjectV2 } from '@cpn-console/shared' +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { ProjectContext } from '../infrastructure/permission/project/project.guard' +import { AdminAuthorized, projectContract } from '@cpn-console/shared' +import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, Inject, Post, Put, Query, Req, UseGuards } from '@nestjs/common' +import { json2csv } from 'json-2-csv' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireProjectAccess } from '../infrastructure/permission/project/project-access.decorator' +import { RequireProjectPermission } from '../infrastructure/permission/project/project-permission.decorator' +import { RequireProjectStatus } from '../infrastructure/permission/project/project-status.decorator' +import { Project } from '../infrastructure/permission/project/project.decorator' +import { ProjectGuard } from '../infrastructure/permission/project/project.guard' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectService } from './project.service' +import { generateProjectV2 } from './project.utils' + +@Controller('api/v1/projects') +export class ProjectController { + constructor( + @Inject(ProjectService) private readonly project: ProjectService, + ) {} + + @Get('/data') + @UseGuards(UserGuard) + @RequireAdminPermission('Manage') + async getProjectsData(): Promise { + return json2csv(await this.project.getProjectsData()) + } + + @Get('') + @UseGuards(UserGuard) + async list( + @Query(new ZodValidationPipe(projectContract.listProjects.query)) query: typeof projectContract.listProjects.query._type, + @AuthUser() user: UserContext, + ): Promise { + if (query.filter === 'all' && !AdminAuthorized.Manage(user.adminPermissions)) { + throw new ForbiddenException('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + return (await this.project.listProjects(query, user)).map(generateProjectV2) + } + + @Post('') + @HttpCode(HttpStatus.CREATED) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageProjects') + async create( + @Body(new ZodValidationPipe(projectContract.createProject.body)) body: typeof projectContract.createProject.body._type, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return generateProjectV2(await this.project.create(body, user.userId, request.id)) + } + + @Get('/:projectId') + @UseGuards(ProjectGuard) + @RequireProjectAccess() + async get( + @Project() project: ProjectContext, + ): Promise { + return generateProjectV2(await this.project.get(project.id)) + } + + @Put('/:projectId') + @HttpCode(HttpStatus.OK) + @UseGuards(ProjectGuard) + @RequireProjectStatus('initializing', 'created', 'failed', 'warning') + @RequireProjectPermission('Manage') + async update( + @Body(new ZodValidationPipe(projectContract.updateProject.body)) data: typeof projectContract.updateProject.body._type, + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return generateProjectV2(await this.project.update(data, user, project.id, request.id)) + } + + @Delete('/:projectId') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(ProjectGuard) + @RequireProjectPermission('Manage') + async archive( + @Project() project: ProjectContext, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + return this.project.archive(project.id, user.userId, request.id) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.module.ts new file mode 100644 index 0000000000..4e50667c67 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { AppEventsModule } from '../events/app-events.module' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { LogModule } from '../log/log.module' +import { VaultModule } from '../vault/vault.module' +import { ProjectController } from './project.controller' +import { ProjectService } from './project.service' + +@Module({ + imports: [ + AppEventsModule, + AuthModule, + DatabaseModule, + EventsModule, + ProjectPermissionModule, + UserPermissionModule, + LogModule, + VaultModule, + ConfigModule.forFeature(baseConfigFactory), + ], + controllers: [ProjectController], + providers: [ProjectService], + exports: [ProjectService], +}) +export class ProjectModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.service.spec.ts new file mode 100644 index 0000000000..76de94e2fa --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -0,0 +1,521 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import { faker } from '@faker-js/faker' +import { + ForbiddenException, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { AppEventsService } from '../events/app-events.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { LogService } from '../log/log.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { + makeCreateProjectBody, + makeListProjectsQuery, + makeProject, + makeProjectContext, + makeProjectMemberWithUser, + makeProjectWithDetails, + makeProjectWithMembersResult, + makeUser, +} from './project-testing.utils' +import { ProjectService } from './project.service' +import { generateSlug } from './project.utils' + +describe('projectService', () => { + let module: TestingModule + let service: ProjectService + let prisma: DeepMockProxy + let appEvents: DeepMockProxy + let config: DeepMockProxy + let vaultConfig: DeepMockProxy + let logs: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + appEvents = mockDeep() + config = mockDeep({ appVersion: 'dev' }) + vaultConfig = mockDeep() + logs = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectService, + { provide: PrismaService, useValue: prisma }, + { provide: AppEventsService, useValue: appEvents }, + { provide: BASE_CONFIG, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: LogService, useValue: logs }, + ], + }).compile() + + service = module.get(ProjectService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('create', () => { + it('generates slug, creates project, emits event, returns ProjectV2', async () => { + const userId = faker.string.uuid() + const body = makeCreateProjectBody() + const existingSlugs = [body.name, `${body.name}-1`] + const expectedSlug = generateSlug(body.name, existingSlugs) + const tx = mockDeep() + + tx.project.findMany.mockResolvedValue(existingSlugs.map(slug => makeProject({ slug }))) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const pwd = makeProjectWithDetails({ slug: expectedSlug }) + pwd.roles = Array.from({ length: 4 }, (_, index) => ({ + id: faker.string.uuid(), + name: `role-${index}`, + permissions: 0n, + position: index, + oidcGroup: '', + type: 'managed', + projectId: pwd.id, + })) as never + tx.project.create.mockResolvedValue(makeProject({ id: pwd.id })) + tx.project.findUnique.mockResolvedValue(pwd) + + const requestId = faker.string.uuid() + const result = await service.create(body, userId, requestId) + + expect(tx.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { slug: { startsWith: body.name } } }), + ) + expect(tx.project.create).toHaveBeenCalled() + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', pwd.id, { + action: 'Create Project', + userId, + requestId, + }) + expect(logs.addLog).toHaveBeenCalledTimes(4) + expect(result).toBeDefined() + expect(result.slug).toBe(expectedSlug) + }) + + it('throws InternalServerErrorException when project cannot be loaded after creation', async () => { + const userId = faker.string.uuid() + const body = makeCreateProjectBody() + + const tx = mockDeep() + tx.project.findMany.mockResolvedValue([]) + tx.project.create.mockResolvedValue(makeProject({ id: 'test-id' })) + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.create(body, userId, faker.string.uuid())) + .rejects.toThrow(InternalServerErrorException) + }) + }) + + describe('list', () => { + it('returns projects filtered by member for non-admin', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + const projects = [makeProjectWithDetails(), makeProjectWithDetails()] + prisma.project.findMany.mockResolvedValue(projects) + + const result = await service.listProjects( + makeListProjectsQuery(), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalled() + expect(result).toHaveLength(2) + expect(result[0]).toHaveProperty('id') + expect(result[0]).toHaveProperty('slug') + }) + + it('allows admin-only "all" filter for admin users', async () => { + const userId = faker.string.uuid() + const adminPerms = BigInt(2) + const user = { userId, adminPermissions: adminPerms } satisfies UserContext + prisma.project.findMany.mockResolvedValue([makeProjectWithDetails()]) + + const result = await service.listProjects( + makeListProjectsQuery({ filter: 'all' }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalled() + expect(result).toHaveLength(1) + }) + + it('forbids "all" filter for non-admin users', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + + await expect( + service.listProjects(makeListProjectsQuery({ filter: 'all' }), user), + ).rejects.toThrow(ForbiddenException) + }) + + it('filters by status', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + prisma.project.findMany.mockResolvedValue([]) + + await service.listProjects( + makeListProjectsQuery({ status: 'created' }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ status: 'created' }), + ]), + }), + }), + ) + }) + + it('filters by search term', async () => { + const userId = faker.string.uuid() + const user = { userId, adminPermissions: 0n } satisfies UserContext + const search = 'myproject' + prisma.project.findMany.mockResolvedValue([]) + + await service.listProjects( + makeListProjectsQuery({ search }), + user, + ) + + expect(prisma.project.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ + OR: [ + { name: { contains: search } }, + { owner: { email: { contains: search } } }, + ], + }), + ]), + }), + }), + ) + }) + }) + + describe('get', () => { + it('returns ProjectV2 for non-archived project', async () => { + const ctx = makeProjectContext({ status: 'created' }) + const pwd = makeProjectWithDetails({ id: ctx.id, status: 'created' }) + prisma.project.findFirst.mockResolvedValue(pwd) + + const result = await service.get(ctx.id) + + expect(result).toBeDefined() + expect(result.id).toBe(ctx.id) + expect(result.status).toBe('created') + }) + + it('throws NotFoundException for archived project', async () => { + const ctx = makeProjectContext({ status: 'archived' }) + prisma.project.findFirst.mockResolvedValue(null) + + await expect(service.get(ctx.id)).rejects.toThrow(NotFoundException) + }) + + it('throws NotFoundException when project cannot be loaded', async () => { + const ctx = makeProjectContext({ status: 'created' }) + prisma.project.findFirst.mockResolvedValue(null) + + await expect(service.get(ctx.id)).rejects.toThrow(NotFoundException) + }) + }) + + describe('update', () => { + it('updates description and returns updated project', async () => { + const ctx = makeProjectContext({ status: 'created', locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id }) + const updatedPwd = makeProjectWithDetails({ id: ctx.id, description: 'Updated desc' }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(updatedPwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const requestId = faker.string.uuid() + const result = await service.update( + { description: 'Updated desc' }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + requestId, + ) + + expect(result.description).toBe('Updated desc') + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.upsert', ctx.id, { + action: 'Update Project', + userId: requestorId, + requestId, + }) + }) + + it('strips locked field for non-admin', async () => { + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: true }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: {}, + }), + ) + }) + + it('allows admin to update locked field', async () => { + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const adminPerms = BigInt(2) + const pwd = makeProjectWithDetails({ id: ctx.id }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: true }, + { userId: requestorId, adminPermissions: adminPerms }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: expect.objectContaining({ locked: true }), + }), + ) + }) + + it('strips ownerId for non-owner non-admin', async () => { + const ownerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { ownerId: faker.string.uuid() }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: ctx.id }, + data: {}, + }), + ) + }) + + it('throws ForbiddenException when project is locked and not unlocking', async () => { + const ctx = makeProjectContext({ locked: true }) + const requestorId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: ctx.id, locked: true }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { description: 'test' }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow(ForbiddenException) + }) + + it('allows admin to unlock a locked project', async () => { + const ctx = makeProjectContext({ locked: true }) + const requestorId = faker.string.uuid() + const adminPerms = BigInt(2) + const pwd = makeProjectWithDetails({ id: ctx.id, locked: true }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + tx.project.findUnique.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await service.update( + { locked: false }, + { userId: requestorId, adminPermissions: adminPerms }, + ctx.id, + ) + + expect(tx.project.update).toHaveBeenCalled() + }) + + it('validates new owner is a member of the project', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue(pwd) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow('Le nouveau propriétaire doit faire partie des membres actuels du projet') + }) + + it('validates new owner is a human account', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue( + makeProjectWithMembersResult(pwd, [makeProjectMemberWithUser(makeUser({ id: newOwnerId, type: 'bot' }))]), + ) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect( + service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ), + ).rejects.toThrow('Seuls les comptes humains peuvent être propriétaire de projets') + }) + + it('transfers ownership correctly', async () => { + const ownerId = faker.string.uuid() + const newOwnerId = faker.string.uuid() + const ctx = makeProjectContext({ locked: false }) + const requestorId = ownerId + const pwd = makeProjectWithDetails({ id: ctx.id, ownerId }) + + const tx = mockDeep() + tx.project.findFirst.mockResolvedValue( + makeProjectWithMembersResult(pwd, [makeProjectMemberWithUser( + makeUser({ id: newOwnerId, type: 'human' }), + { roleIds: [faker.string.uuid()] }, + )]), + ) + tx.project.findUnique.mockResolvedValue( + makeProjectWithDetails({ id: ctx.id, ownerId: newOwnerId }), + ) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const result = await service.update( + { ownerId: newOwnerId }, + { userId: requestorId, adminPermissions: 0n }, + ctx.id, + ) + + expect(tx.projectMembers.delete).toHaveBeenCalledWith({ + where: { projectId_userId: { userId: newOwnerId, projectId: ctx.id } }, + }) + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + owner: { connect: { id: newOwnerId } }, + }, + }), + ) + expect(result).toBeDefined() + }) + }) + + describe('archive', () => { + it('deletes related data, emits event, renames and archives project', async () => { + const projectId = faker.string.uuid() + const pwd = makeProjectWithDetails({ id: projectId, name: 'myproject', slug: 'myproject' }) + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(pwd) + tx.repository.deleteMany.mockResolvedValue({ count: 2 }) + tx.environment.deleteMany.mockResolvedValue({ count: 3 }) + tx.deployment.deleteMany.mockResolvedValue({ count: 1 }) + tx.project.update.mockResolvedValue(makeProject({ id: projectId })) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + const requestId = faker.string.uuid() + const requestorId = faker.string.uuid() + + await service.archive(projectId, requestorId, requestId) + + expect(tx.repository.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.environment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.deployment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(appEvents.emitProjectEvent).toHaveBeenCalledWith('project.delete', pwd, { + action: 'Delete all project resources', + userId: requestorId, + requestId, + }) + expect(tx.project.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: projectId }, + data: expect.objectContaining({ + status: 'archived', + locked: true, + clusters: { set: [] }, + }), + }), + ) + }) + + it('throws NotFoundException when project does not exist', async () => { + const tx = mockDeep() + tx.project.findUnique.mockResolvedValue(null) + prisma.$transaction.mockImplementation(async cb => cb(tx)) + + await expect(service.archive(faker.string.uuid())) + .rejects.toThrow(NotFoundException) + }) + }) + + describe('getData', () => { + it('returns CSV data array', async () => { + prisma.project.findMany.mockResolvedValue([makeProjectWithDetails()]) + + const result = await service.getProjectsData() + + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBe(1) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.service.ts new file mode 100644 index 0000000000..90429f720b --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.service.ts @@ -0,0 +1,336 @@ +import type { projectContract } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { ProjectDataExport, ProjectUpdateContext, ProjectWithDetails } from './project-queries.utils' +import { AdminAuthorized } from '@cpn-console/shared' +import { BadRequestException, ForbiddenException, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { AppEventsService } from '../events/app-events.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { LogService } from '../log/log.service' +import { createProjectMember, deleteProjectMember } from '../project-members/project-members-queries.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { + createProject, + deleteProjectDependencies, + getNotArchivedProjectForUpdate, + getProject, + listProjectSlugsForPrefix as listProjectSlugsForSlugPrefix, + projectForDataSelect, + projectSelect, + updateProject, +} from './project-queries.utils' +import { generateProjectCreateInput, generateProjectWhereInput, generateSlug, parseProjectUpdateInput } from './project.utils' + +@Injectable() +export class ProjectService { + private readonly logger = new Logger(ProjectService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(AppEventsService) private readonly appEvents: AppEventsService, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(LogService) private readonly logs: LogService, + ) {} + + @StartActiveSpan() + async getProjectsData(): Promise { + const span = trace.getActiveSpan() + this.logger.log('project.getData requested') + const data = await this.listProjectsForDataExport() + span?.setAttribute('project.data.count', data.length) + this.logger.log(`project.getData completed (count=${data.length})`) + return data + } + + @StartActiveSpan() + async listProjects( + query: typeof projectContract.listProjects.query._type, + user: UserContext, + ): Promise { + const span = trace.getActiveSpan() + const { filter = 'member' } = query + span?.setAttribute('project.list.filter', filter) + span?.setAttribute('user.id', user.userId) + + if (filter === 'all' && !AdminAuthorized.Manage(user.adminPermissions)) { + this.logger.warn(`project.list forbidden (requestorUserId=${user.userId}, filter=${filter})`) + throw new ForbiddenException('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + + const whereAnd = generateProjectWhereInput({ + query, + requestorUserId: user.userId, + appVersion: this.baseConfig.appVersion, + }) + + this.logger.debug(`project.list started (requestorUserId=${user.userId}, filter=${filter})`) + const projects = await this.fetchProjects(whereAnd) + span?.setAttribute('project.list.count', projects.length) + this.logger.debug(`project.list completed (requestorUserId=${user.userId}, filter=${filter}, count=${projects.length})`) + + return projects + } + + @StartActiveSpan() + async get(projectId: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.debug(`project.get started (projectId=${projectId})`) + const project = await this.getProjectNotArchived(projectId) + if (!project) { + this.logger.warn(`project.get notFound (projectId=${projectId})`) + throw new NotFoundException('Projet introuvable') + } + this.logger.debug(`project.get completed (projectId=${projectId})`) + return project + } + + @StartActiveSpan() + async create( + body: typeof projectContract.createProject.body._type, + requestorUserId: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('user.id', requestorUserId) + this.logger.log(`project.create started (requestorUserId=${requestorUserId}, projectName=${body.name})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const existingSlugs = await listProjectSlugsForSlugPrefix(tx, body.name) + const slug = generateSlug(body.name, existingSlugs.map(s => s.slug)) + + const created = await createProject(tx, generateProjectCreateInput(body, requestorUserId, slug)) + const loaded = await getProject(tx, created.id) + if (!loaded) throw new InternalServerErrorException('Project created but cannot be loaded') + return loaded + }) + await this.appEvents.emitProjectEvent('project.upsert', project.id, { + action: 'Create Project', + userId: requestorUserId, + requestId, + }) + await Promise.all(project.roles.map(async role => this.logProjectRoleAction( + 'Upsert Project Role', + project, + role.id, + `Rôle synchronisé: ${role.name}`, + requestorUserId, + requestId, + ))) + span?.setAttribute('project.id', project.id) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.create completed (requestorUserId=${requestorUserId}, projectId=${project.id}, slug=${project.slug})`) + return project + } catch (error) { + this.logger.error( + `project.create failed (requestorUserId=${requestorUserId}, projectName=${body.name}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + async update( + body: typeof projectContract.updateProject.body._type, + user: UserContext, + projectId: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + span?.setAttribute('user.id', user.userId) + this.logger.log(`project.update started (projectId=${projectId}, requestorUserId=${user.userId})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const projectDb = await getNotArchivedProjectForUpdate(tx, projectId) + if (!projectDb) throw new NotFoundException('Projet introuvable') + + const { effectiveData, strippedKeys } = this.stripProjectUpdateBody(body, user, projectDb) + this.logProjectUpdateStrippedFields(span, projectId, user.userId, strippedKeys) + if (projectDb.locked && effectiveData.locked !== false) { + throw new ForbiddenException('Veuillez déverrouiller le projet pour le mettre à jour') + } + + await this.updateProjectOwnerIfNeeded(tx, projectDb, effectiveData, projectId, user.userId) + + const updateData = parseProjectUpdateInput(effectiveData) + const effectiveKeys = Object.keys(effectiveData) + span?.setAttribute('project.update.effectiveKeys.count', effectiveKeys.length) + await updateProject(tx, projectId, updateData) + + const updated = await getProject(tx, projectId) + if (!updated) throw new NotFoundException('Projet introuvable') + this.logger.log(`project.update dbUpdated (projectId=${projectId}, requestorUserId=${user.userId}, effectiveKeys=${effectiveKeys.join(',')})`) + return updated + }) + await this.appEvents.emitProjectEvent('project.upsert', project.id, { + action: 'Update Project', + userId: user.userId, + requestId, + }) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.update completed (projectId=${projectId}, requestorUserId=${user.userId})`) + return project + } catch (error) { + this.logger.error( + `project.update failed (projectId=${projectId}, requestorUserId=${user.userId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + @StartActiveSpan() + async archive(projectId: string, requestorUserId?: string, requestId?: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.id', projectId) + this.logger.log(`project.archive started (projectId=${projectId})`) + try { + const project = await this.prisma.$transaction(async (tx) => { + const loaded = await getProject(tx, projectId) + if (!loaded) throw new NotFoundException('Projet introuvable') + + await deleteProjectDependencies(tx, projectId) + + const archivedSuffix = `${Date.now()}_archived` + await updateProject(tx, projectId, { + name: `${loaded.name}_${archivedSuffix}`, + slug: `${loaded.slug}_${archivedSuffix}`, + status: 'archived', + locked: true, + clusters: { set: [] }, + }) + + return loaded + }) + // pass the pre-archive snapshot: the row was renamed (slug suffixed) in the + // transaction above, listeners must clean up resources named after the old slug + await this.appEvents.emitProjectEvent('project.delete', project, { + action: 'Delete all project resources', + userId: requestorUserId, + requestId, + }) + span?.setAttribute('project.slug', project.slug) + this.logger.log(`project.archive completed (projectId=${projectId}, slug=${project.slug})`) + } catch (error) { + this.logger.error( + `project.archive failed (projectId=${projectId}): ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async logProjectRoleAction( + action: string, + project: ProjectWithDetails, + roleId: string, + messageResume: string, + userId: string | undefined, + requestId: string | undefined, + ): Promise { + await this.logs.addLog({ + action, + data: { + args: { projectId: project.id, roleId }, + messageResume, + results: { projectId: project.id, roleId }, + }, + userId, + requestId, + projectId: project.id, + }) + } + + private async listProjectsForDataExport(): Promise { + return this.prisma.project.findMany({ + select: projectForDataSelect, + }) + } + + private async fetchProjects(whereAnd: Prisma.ProjectWhereInput[]): Promise { + return this.prisma.project.findMany({ + where: { AND: whereAnd }, + select: projectSelect, + }) + } + + private async getProjectNotArchived(projectId: string): Promise { + return this.prisma.project.findFirst({ + where: { id: projectId, status: { not: 'archived' } }, + select: projectSelect, + }) + } + + private stripProjectUpdateBody( + body: typeof projectContract.updateProject.body._type, + user: UserContext, + project: ProjectUpdateContext, + ): { effectiveData: Record, strippedKeys: string[] } { + const isOwner = project.ownerId === user.userId + const isAdmin = AdminAuthorized.Manage(user.adminPermissions) + const effectiveData: Record = { ...body } + const strippedKeys: string[] = [] + + if (!isAdmin) { + if ('locked' in effectiveData) strippedKeys.push('locked') + delete effectiveData.locked + + if (!isOwner) { + if ('ownerId' in effectiveData) strippedKeys.push('ownerId') + delete effectiveData.ownerId + } + } + + return { effectiveData, strippedKeys } + } + + private logProjectUpdateStrippedFields( + span: ReturnType, + projectId: string, + requestorUserId: string, + strippedKeys: string[], + ): void { + if (!strippedKeys.length) return + + span?.setAttribute('project.update.strippedKeys.count', strippedKeys.length) + this.logger.debug(`project.update strippedFields (projectId=${projectId}, requestorUserId=${requestorUserId}, strippedKeys=${strippedKeys.join(',')})`) + } + + private async updateProjectOwnerIfNeeded( + tx: Prisma.TransactionClient, + project: ProjectUpdateContext, + effectiveData: Record, + projectId: string, + requestorUserId: string, + ): Promise { + const ownerIdCandidate = effectiveData.ownerId as string | undefined + if (!ownerIdCandidate || ownerIdCandidate === project.ownerId) return + + this.logger.log(`project.update ownerChange started (projectId=${projectId}, requestorUserId=${requestorUserId}, previousOwnerId=${project.ownerId}, nextOwnerId=${ownerIdCandidate})`) + + const memberCandidate = project.members.find(member => member.userId === ownerIdCandidate) + if (!memberCandidate) { + throw new BadRequestException('Le nouveau propriétaire doit faire partie des membres actuels du projet') + } + if (memberCandidate.user.type !== 'human') { + throw new BadRequestException('Seuls les comptes humains peuvent être propriétaire de projets') + } + + const oldOwnerIsMember = project.members.some(member => member.userId === project.ownerId) + if (!oldOwnerIsMember) { + await createProjectMember(tx, project.id, project.ownerId) + } + + await deleteProjectMember(tx, project.id, ownerIdCandidate) + await updateProject(tx, project.id, { owner: { connect: { id: ownerIdCandidate } } }) + + this.logger.log(`project.update ownerChange completed (projectId=${projectId}, requestorUserId=${requestorUserId}, previousOwnerId=${project.ownerId}, nextOwnerId=${ownerIdCandidate})`) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.utils.ts new file mode 100644 index 0000000000..bb50d21fdf --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/project/project.utils.ts @@ -0,0 +1,226 @@ +import type { CreateProjectBody, projectContract } from '@cpn-console/shared' +import type { Prisma } from '@prisma/client' +import type { ProjectWithDetails } from './project-queries.utils' +import { PROJECT_PERMS, ProjectSchemaV2, ProjectStatusSchema } from '@cpn-console/shared' +import { ProjectStatus } from '@prisma/client' +import { z } from 'zod' + +export function generateSlug(prefix: string, existingSlugs: string[] = []) { + if (!existingSlugs.includes(prefix)) return prefix + + let suffix = 1 + while (existingSlugs.includes(`${prefix}-${suffix}`)) { + suffix += 1 + } + return `${prefix}-${suffix}` +} + +export function generateProjectCreateInput( + data: CreateProjectBody, + ownerId: string, + slug: string, +): Prisma.ProjectCreateInput { + return { + name: data.name, + slug, + description: data.description ?? '', + status: ProjectStatus.created, + locked: false, + limitless: z.boolean().parse(data.limitless), + hprodCpu: data.hprodCpu, + hprodGpu: data.hprodGpu, + hprodMemory: data.hprodMemory, + prodCpu: data.prodCpu, + prodGpu: data.prodGpu, + prodMemory: data.prodMemory, + owner: { connect: { id: ownerId } }, + roles: { + create: [ + { + name: 'Administrateur', + permissions: PROJECT_PERMS.MANAGE, + position: 0, + oidcGroup: `/${slug}/console/admin`, + type: 'system:managed', + }, + { + name: 'DevOps', + permissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS + | PROJECT_PERMS.MANAGE_REPOSITORIES + | PROJECT_PERMS.REPLAY_HOOKS + | PROJECT_PERMS.SEE_SECRETS + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 1, + oidcGroup: `/${slug}/console/devops`, + type: 'system:managed', + }, + { + name: 'Développeur', + permissions: PROJECT_PERMS.MANAGE_REPOSITORIES + | PROJECT_PERMS.LIST_ENVIRONMENTS + | PROJECT_PERMS.LIST_REPOSITORIES, + position: 2, + oidcGroup: `/${slug}/console/developer`, + type: 'system:managed', + }, + { + name: 'Lecture seule', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 3, + oidcGroup: `/${slug}/console/readonly`, + type: 'system:managed', + }, + ], + }, + } +} + +export const ProjectV2ResponseSchema = ProjectSchemaV2.omit({ name: true }).extend({ + name: z.string(), +}) + +export function generateProjectV2(project: ProjectWithDetails) { + const payload = { + id: project.id, + name: project.name, + slug: project.slug, + description: project.description, + status: project.status, + locked: project.locked, + limitless: project.limitless, + hprodCpu: project.hprodCpu, + hprodGpu: project.hprodGpu, + hprodMemory: project.hprodMemory, + prodCpu: project.prodCpu, + prodGpu: project.prodGpu, + prodMemory: project.prodMemory, + everyonePerms: project.everyonePerms, + ownerId: project.ownerId, + owner: project.owner, + members: project.members.map(m => ({ + userId: m.user.id, + email: m.user.email, + firstName: m.user.firstName, + lastName: m.user.lastName, + roleIds: m.roleIds, + })), + roles: project.roles.map(role => ({ + id: role.id, + name: role.name, + permissions: role.permissions, + position: role.position, + projectId: role.projectId, + oidcGroup: role.oidcGroup ? role.oidcGroup.replace(`/${project.slug}`, '') : '', + type: role.type, + })), + clusterIds: project.clusters.map(c => c.id), + lastSuccessProvisionningVersion: project.lastSuccessProvisionningVersion, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + } + + return ProjectV2ResponseSchema.parse(payload) +} + +export function generateProjectWhereInput(opts: { + query: typeof projectContract.listProjects.query._type + requestorUserId: string + appVersion: string +}): Prisma.ProjectWhereInput[] { + const projectStatus = ProjectStatusSchema.options + const { status, statusIn, statusNotIn, filter = 'member', ...rest } = opts.query + + const whereAnd: Prisma.ProjectWhereInput[] = [] + if (rest.id) whereAnd.push({ id: rest.id }) + if (rest.locked !== undefined) whereAnd.push({ locked: rest.locked }) + if (rest.name) whereAnd.push({ name: rest.name }) + if (rest.description) whereAnd.push({ description: { contains: rest.description } }) + + const statusWhere = parseEnumWhereFilter({ + enumValues: projectStatus, + eqValue: status, + inValues: statusIn, + notInValues: statusNotIn, + }) + if (statusWhere) whereAnd.push({ status: statusWhere }) + + if (rest.lastSuccessProvisionningVersion) { + if (rest.lastSuccessProvisionningVersion === 'outdated') { + whereAnd.push({ lastSuccessProvisionningVersion: { not: opts.appVersion } }) + } else if (rest.lastSuccessProvisionningVersion === 'last') { + whereAnd.push({ lastSuccessProvisionningVersion: { equals: opts.appVersion } }) + } else { + whereAnd.push({ lastSuccessProvisionningVersion: rest.lastSuccessProvisionningVersion }) + } + } + + if (rest.search) { + whereAnd.push({ + OR: [ + { name: { contains: rest.search } }, + { owner: { email: { contains: rest.search } } }, + ], + }) + } + + if (filter === 'owned') { + whereAnd.push({ ownerId: opts.requestorUserId }) + } else if (filter === 'member') { + whereAnd.push({ + OR: [ + { members: { some: { userId: opts.requestorUserId } } }, + { ownerId: opts.requestorUserId }, + ], + }) + } + + return whereAnd +} + +export function parseEnumWhereFilter({ + enumValues, + eqValue, + inValues, + notInValues, +}: { + enumValues: T + eqValue: T[number] | undefined + inValues: string | undefined + notInValues: string | undefined +}): + | T[number] + | { in: T[number][] } + | { notIn: T[number][] } + | undefined { + if (eqValue) { + return eqValue + } + if (inValues) { + return { in: parseCsvEnumList(enumValues, inValues) } + } + if (notInValues) { + return { notIn: parseCsvEnumList(enumValues, notInValues) } + } +} + +const ProjectUpdateDataSchema = z.object({ + description: z.string().optional(), + locked: z.boolean().optional(), + limitless: z.boolean().optional(), + hprodCpu: z.number().optional(), + hprodGpu: z.number().optional(), + hprodMemory: z.number().optional(), + prodCpu: z.number().optional(), + prodGpu: z.number().optional(), + prodMemory: z.number().optional(), + everyonePerms: z.union([z.string(), z.number(), z.bigint()]).transform(BigInt).optional(), +}).passthrough() + +export function parseProjectUpdateInput(effectiveData: Record): Prisma.ProjectUpdateInput { + return ProjectUpdateDataSchema.parse(effectiveData) satisfies Prisma.ProjectUpdateInput +} + +function parseCsvEnumList(toMatch: T, inputs: string): T[number][] { + return inputs.split(',').filter(i => toMatch.includes(i)) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts new file mode 100644 index 0000000000..f9e676753d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts @@ -0,0 +1,22 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface HarborConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + ruleTemplate?: string + ruleCount?: string + retentionCron: string + robotRotationThresholdDays: number + projectSlugCacheTtlMs: number + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface HarborModuleOptions extends HarborConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('harbor') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as HARBOR_CONFIG } diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts new file mode 100644 index 0000000000..057bd83f04 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts @@ -0,0 +1,96 @@ +import type { HarborConfig } from './harbor.module-definition' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { VaultClientService } from '../vault/vault-client.service' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryHttpClientService } from './registry-http-client.service' + +const harborUrl = 'https://harbor.example' +const harborAdminPassword = faker.internet.password() +const basicAuth = `Basic ${Buffer.from(`admin:${harborAdminPassword}`, 'utf8').toString('base64')}` + +const server = setupServer() + +describe('registryService', () => { + let service: RegistryClientService + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + beforeEach(async () => { + const harborConfig = mockDeep({ + url: harborUrl, + internalUrl: harborUrl, + internalOrPublicUrl: harborUrl, + admin: 'admin', + adminPassword: harborAdminPassword, + ruleTemplate: 'latestPushedK', + ruleCount: '10', + retentionCron: '0 22 2 * * *', + }) + + const module = await Test.createTestingModule({ + providers: [ + RegistryClientService, + RegistryHttpClientService, + { + provide: VaultClientService, + useValue: {}, + }, + { + provide: HARBOR_CONFIG, + useValue: harborConfig, + }, + ], + }).compile() + service = module.get(RegistryClientService) + }) + + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should send basic auth and JSON body on createProject', async () => { + server.use( + http.post(`${harborUrl}/api/v2.0/projects`, async ({ request }) => { + expect(request.method).toBe('POST') + expect(request.url).toBe(`${harborUrl}/api/v2.0/projects`) + expect(request.headers.get('accept')).toBe('application/json') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('content-type')).toContain('application/json') + expect(await request.json()).toEqual({ + project_name: 'myproj', + metadata: { auto_scan: 'true' }, + storage_limit: -1, + }) + return HttpResponse.json({}, { status: HttpStatus.CREATED }) + }), + ) + + await service.createProject('myproj', -1) + }) + + it('should send X-Is-Resource-Name on getProjectByName', async () => { + server.use( + http.get(`${harborUrl}/api/v2.0/projects/:projectName`, async ({ request, params }) => { + expect(request.method).toBe('GET') + expect(request.headers.get('authorization')).toBe(basicAuth) + expect(request.headers.get('x-is-resource-name')).toBe('true') + expect(params.projectName).toBe('myproj') + return HttpResponse.json({ project_id: 123, metadata: {} }) + }), + ) + + const res = await service.getProjectByName('myproj') + + expect(res).toMatchObject({ status: HttpStatus.OK, data: { project_id: 123 } }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-client.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-client.service.ts new file mode 100644 index 0000000000..0e85eff9c9 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-client.service.ts @@ -0,0 +1,226 @@ +import type { RegistryQuery, RegistryResponse } from './registry-http-client.service' +import { Inject, Injectable } from '@nestjs/common' +import { RegistryHttpClientService } from './registry-http-client.service' +import { ROBOT_LIST_PAGE_SIZE } from './registry.constants' + +export const roAccess: HarborAccess[] = [ + { resource: 'repository', action: 'pull' }, + { resource: 'artifact', action: 'read' }, +] + +export const rwAccess: HarborAccess[] = [ + ...roAccess, + { resource: 'repository', action: 'list' }, + { resource: 'tag', action: 'list' }, + { resource: 'artifact', action: 'list' }, + { resource: 'scan', action: 'create' }, + { resource: 'scan', action: 'stop' }, + { resource: 'repository', action: 'push' }, + { resource: 'artifact-label', action: 'create' }, + { resource: 'artifact-label', action: 'delete' }, + { resource: 'tag', action: 'create' }, + { resource: 'tag', action: 'delete' }, +] + +export interface HarborAccess { + resource: string + action: string +} + +export interface HarborProject { + project_id?: number + metadata?: { + retention_id?: number | string + } +} + +export interface HarborRobot { + id?: number + name?: string +} + +export interface HarborRobotCreated { + id?: number + name: string + secret: string +} + +export interface HarborMember { + id?: number + entity_name?: string + entity_type?: string + role_id?: number +} + +export interface HarborGroupMemberRequest { + role_id: number + member_group: { + group_name: string + group_type: number + } +} + +export interface HarborProjectQuota { + ref?: { id?: number } + hard?: { storage?: number } +} + +export interface HarborRobotPermission { + namespace: string + kind: 'project' + access: HarborAccess[] +} + +export interface HarborRobotCreateRequest { + name: string + duration: number + description: string + disable: boolean + level: 'project' + permissions: HarborRobotPermission[] +} + +export interface HarborRetentionRule { + disabled: boolean + action: 'retain' + template: string + params: Record + tag_selectors: Array<{ kind: string, decoration: string, pattern: string }> + scope_selectors: { + repository: Array<{ kind: string, decoration: string, pattern: string }> + } +} + +export interface HarborRetentionPolicy { + algorithm: 'or' | 'and' + scope: { level: 'project', ref: number } + rules: HarborRetentionRule[] + trigger: { + kind: 'Schedule' + settings: { cron?: string } + references: unknown[] + } +} + +@Injectable() +export class RegistryClientService { + constructor( + @Inject(RegistryHttpClientService) private readonly http: RegistryHttpClientService, + ) {} + + async getProjectByName(projectName: string): Promise> { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async createProject(projectName: string, storageLimit: number) { + return this.http.fetch('projects', { + method: 'POST', + body: { + project_name: projectName, + metadata: { auto_scan: 'true' }, + storage_limit: storageLimit, + }, + }) + } + + async deleteProjectByName(projectName: string) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async listQuotas(projectId: number) { + return this.http.fetch(`quotas?reference_id=${encodeURIComponent(String(projectId))}`, { + method: 'GET', + }) + } + + async updateQuota(projectId: number, storageLimit: number) { + return this.http.fetch(`quotas/${encodeURIComponent(String(projectId))}`, { + method: 'PUT', + body: { + hard: { + storage: storageLimit, + }, + }, + }) + } + + async getGroupMembers(projectName: string) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members`, { + method: 'GET', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + async addGroupMember(projectName: string, body: HarborGroupMemberRequest) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members`, { + method: 'POST', + headers: { 'X-Is-Resource-Name': 'true' }, + body, + }) + } + + async removeGroupMember(projectName: string, memberId: number) { + return this.http.fetch(`projects/${encodeURIComponent(projectName)}/members/${encodeURIComponent(String(memberId))}`, { + method: 'DELETE', + headers: { 'X-Is-Resource-Name': 'true' }, + }) + } + + getProjectRobots(projectId: number): AsyncGenerator { + return this.paginate('robots', { + q: `Level=project,ProjectID=${projectId}`, + }) + } + + async createRobot(body: HarborRobotCreateRequest) { + return this.http.fetch('robots', { + method: 'POST', + body, + }) + } + + async deleteRobot(robotId: number): Promise { + return this.http.fetch(`robots/${encodeURIComponent(String(robotId))}`, { + method: 'DELETE', + }) + } + + async getRetentionId(projectName: string): Promise { + const project = await this.getProjectByName(projectName) + if (project.status !== 200 || !project.data) return null + const retentionId = Number(project.data?.metadata?.retention_id) + return Number.isFinite(retentionId) ? retentionId : null + } + + async createRetention(body: HarborRetentionPolicy) { + return this.http.fetch('retentions', { + method: 'POST', + body, + }) + } + + async updateRetention(retentionId: number, body: HarborRetentionPolicy) { + return this.http.fetch(`retentions/${encodeURIComponent(String(retentionId))}`, { + method: 'PUT', + body, + }) + } + + private async* paginate(path: string, query: RegistryQuery): AsyncGenerator { + for (let page = 1; ; page++) { + const response = await this.http.fetch(path, { + method: 'GET', + query: { ...query, page, page_size: ROBOT_LIST_PAGE_SIZE }, + }) + if (response.status !== 200 || !response.data?.length) return + yield* response.data + if (response.data.length < ROBOT_LIST_PAGE_SIZE) return + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts new file mode 100644 index 0000000000..9d0a511ef6 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-datastore.service.ts @@ -0,0 +1,62 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './registry.constants' + +export const projectSelect = { + slug: true, + plugins: { + where: { + pluginName: PLUGIN_NAME, + }, + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class RegistryDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return await this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return await this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-health.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-health.service.ts new file mode 100644 index 0000000000..4ffab35aad --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -0,0 +1,27 @@ +import type { HarborConfig } from './harbor.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { HARBOR_CONFIG } from './harbor.module-definition' + +@Injectable() +export class RegistryHealthService { + constructor( + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const credentials = `${this.harborConfig.admin}:${this.harborConfig.adminPassword}` + const base64 = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${base64}` + try { + const response = await fetch(this.harborConfig.probeUrl, { method: 'GET', headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts new file mode 100644 index 0000000000..bc3848b0cd --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts @@ -0,0 +1,109 @@ +import type { HarborConfig } from './harbor.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { encodeBasicAuth } from './registry.utils' + +export type RegistryQuery = Record + +export interface RegistryFetchOptions { + method?: string + headers?: Record + body?: unknown + query?: RegistryQuery +} + +export interface RegistryResponse { + status: HttpStatus + data: T | null +} + +export type RegistryErrorKind + = | 'NotConfigured' + | 'Unexpected' + +export class RegistryError extends Error { + readonly kind: RegistryErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + + constructor( + kind: RegistryErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string } = {}, + ) { + super(message) + this.name = 'RegistryError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + } +} + +@Injectable() +export class RegistryHttpClientService { + constructor( + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + ) {} + + private get apiBaseUrl() { + return new URL('api/v2.0/', this.harborConfig.internalOrPublicUrl).toString() + } + + private get defaultHeaders() { + return { Accept: 'application/json', Authorization: `Basic ${encodeBasicAuth(this.harborConfig.admin, this.harborConfig.adminPassword)}` } + } + + async fetch( + path: string, + options: RegistryFetchOptions = {}, + ): Promise> { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('registry.method', method) + span?.setAttribute('registry.path', path) + + const request = this.createRequest(path, method, options.body, options.headers, options.query) + const response = await fetch(request).catch((error) => { + throw new RegistryError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('registry.http.status', response.status) + return await handleResponse(response) + } + + private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record, query?: RegistryQuery): Request { + const url = new URL(path, this.apiBaseUrl) + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, String(value)) + } + } + const headers: Record = { + ...this.defaultHeaders, + ...extraHeaders, + } + let requestBody: string | undefined + if (body !== undefined) { + requestBody = JSON.stringify(body) + headers['Content-Type'] = 'application/json' + } + return new Request(url, { method, headers, body: requestBody }) + } +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts new file mode 100644 index 0000000000..b4d14b009f --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts @@ -0,0 +1,77 @@ +import type { Cache } from 'cache-manager' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { HarborConfig } from './harbor.module-definition' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { RegistryPluginService } from './registry-plugin.service' +import { makeProjectWithDetails } from './registry-testing.utils' + +describe('registryPluginService', () => { + let service: RegistryPluginService + let harborConfig: DeepMockProxy + let datastore: DeepMockProxy + let registryClient: DeepMockProxy + let cache: DeepMockProxy + + beforeEach(async () => { + harborConfig = mockDeep({ + url: 'https://harbor.example/', + projectSlugCacheTtlMs: 300000, + }) + datastore = mockDeep() + registryClient = mockDeep() + cache = mockDeep() + + const moduleRef = await Test.createTestingModule({ + providers: [ + RegistryPluginService, + { provide: HARBOR_CONFIG, useValue: harborConfig }, + { provide: RegistryDatastoreService, useValue: datastore }, + { provide: RegistryClientService, useValue: registryClient }, + { provide: CACHE_MANAGER, useValue: cache }, + ], + }).compile() + + service = moduleRef.get(RegistryPluginService) + }) + + it('uses the stored Harbor project id when available', async () => { + cache.get.mockResolvedValue('dulei') + registryClient.getProjectByName.mockResolvedValue({ + status: HttpStatus.OK, + data: { project_id: 144, metadata: {} }, + }) + const infos = await service.infos('dulei') + const url = infos.to?.(makeToUrlParams({ + project: { id: '', name: '', slug: 'dulei' }, + store: { registry: { projectId: '144' } }, + })) + + expect(url).toBe('https://harbor.example/harbor/projects/144/') + expect(datastore.getProject).not.toHaveBeenCalled() + expect(registryClient.getProjectByName).toHaveBeenCalledWith('dulei') + }) + + it('falls back to Harbor lookup when the store is empty', async () => { + cache.get.mockResolvedValue(undefined) + datastore.getProject.mockResolvedValue(makeProjectWithDetails({ slug: 'dulei' })) + registryClient.getProjectByName.mockResolvedValue({ + status: HttpStatus.OK, + data: { project_id: 144, metadata: {} }, + }) + + const infos = await service.infos('dulei') + const url = infos.to?.(makeToUrlParams()) + + expect(registryClient.getProjectByName).toHaveBeenCalledWith('dulei') + expect(url).toBe('https://harbor.example/harbor/projects/144/') + expect(cache.set).toHaveBeenCalledWith('registry:project-slug:dulei', 'dulei', 300000) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts new file mode 100644 index 0000000000..e502423c5f --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts @@ -0,0 +1,143 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { Cache } from 'cache-manager' +import type { HarborConfig } from './harbor.module-definition' +import { DISABLED } from '@cpn-console/shared' +import { CACHE_MANAGER } from '@nestjs/cache-manager' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { createProjectSlugCacheKey } from './registry.utils' + +@Injectable() +export class RegistryPluginService { + private readonly logger = new Logger(RegistryPluginService.name) + + constructor( + @Inject(HARBOR_CONFIG) + private readonly harborConfig: HarborConfig, + @Inject(RegistryDatastoreService) + private readonly datastore: RegistryDatastoreService, + @Inject(RegistryClientService) + private readonly registryClient: RegistryClientService, + @Inject(CACHE_MANAGER) + private readonly cache: Cache, + ) {} + + private async resolveProjectSlug(projectId: string): Promise { + const cacheKey = createProjectSlugCacheKey(projectId) + const cached = await this.cache.get(cacheKey) + if (cached !== undefined) return cached ?? undefined + + const project = await this.datastore.getProject(projectId) + const slug = project?.slug ?? null + await this.cache.set(cacheKey, slug, this.harborConfig.projectSlugCacheTtlMs) + return slug ?? undefined + } + + private async resolveHarborProjectId(projectSlug: string): Promise { + try { + const harborProject = await this.registryClient.getProjectByName(projectSlug) + const harborProjectId = Number(harborProject.data?.project_id) + if (harborProject.status !== 200 || !Number.isFinite(harborProjectId)) { + return undefined + } + this.logger.log(`Successfully resolve harbor project id for project slug ${projectSlug}: ${harborProjectId}`) + return harborProjectId + } catch (error) { + this.logger.error(`Failed to resolve harbor project id for project slug ${projectSlug}: ${error}`) + return undefined + } + } + + private resolveHarborProjectUrl(harborProjectId: number): string { + return new URL(`harbor/projects/${harborProjectId}/`, this.harborConfig.url).toString() + } + + private async resolveProjectUrl(projectId: string): Promise { + const projectSlug = await this.resolveProjectSlug(projectId) + if (!projectSlug) { + return undefined + } + + const harborProjectId = await this.resolveHarborProjectId(projectSlug) + if (harborProjectId === undefined) { + return undefined + } + + return this.resolveHarborProjectUrl(harborProjectId) + } + + async infos(projectId: string): Promise { + const quotaDescription = '-1 -> illimité, sinon 100MB / 1.2GB (unités : B, KB, MB, GB, TB)), max 1024TB' + const projectUrl = await this.resolveProjectUrl(projectId) + if (!projectUrl) { + throw new Error('Project not found') + } + + return { + name: 'registry', + to: () => projectUrl, + title: 'Harbor', + imgSrc: '/img/harbor.svg', + description: 'Harbor stocke, analyse et distribue vos images de conteneurs', + config: { + project: [{ + permissions: { + admin: { read: false, write: false }, + user: { read: false, write: false }, + }, + key: 'projectId', + kind: 'text', + title: 'Num du projet Harbor', + value: '', + }, { + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot projet', + description: 'Activer le robot de projet (read-only) et afficher ses identifiants aux utilisateurs', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota', + value: '', + description: `Stockage limite (vide utilisation du paramètre global, ${quotaDescription}`, + placeholder: '', + }], + global: [{ + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot RO aux projets', + description: 'Définit le comportement en l\'absence de ce paramétrage au niveau projet', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota par défaut', + value: '-1', + description: `Stockage limite par projet (${quotaDescription}`, + placeholder: '-1', + }], + }, + } as const satisfies ServiceInfos + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts new file mode 100644 index 0000000000..c04eae0429 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry-testing.utils.ts @@ -0,0 +1,24 @@ +import type { ProjectWithDetails } from './registry-datastore.service' +import type { RegistryResponse } from './registry-http-client.service' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' + +export function makeOkResponse(data: T): RegistryResponse { + return { status: HttpStatus.OK, data } +} + +export function makeCreatedResponse(data: T): RegistryResponse { + return { status: HttpStatus.CREATED, data } +} + +export function makeNoContent(): RegistryResponse { + return { status: HttpStatus.NO_CONTENT, data: null } +} + +export function makeProjectWithDetails(overrides: Partial = {}) { + return { + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.constants.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.constants.ts new file mode 100644 index 0000000000..e4c054daab --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.constants.ts @@ -0,0 +1,52 @@ +// Registry plugin identification +export const PLUGIN_NAME = 'harbor' + +// Registry configuration keys +export const REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT = 'quotaHardLimit' +export const REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT = 'publishProjectRobot' + +// Default platform-level group paths +export const DEFAULT_PLATFORM_ADMIN_GROUP_PATHS = '/console/admin' +export const DEFAULT_PLATFORM_GUEST_GROUP_PATHS = '/console/security,/console/readonly' + +// Default project-level group path suffixes +export const DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES = '/console/admin' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES = '/console/developer' +export const DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES = '/console/security,/console/readonly' + +// Platform group path plugin configuration keys +export const PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY = 'platformAdminGroupPath' +export const PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY = 'platformGuestGroupPaths' + +// Project group path suffixes plugin configuration keys +export const PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectAdminGroupPathSuffixes' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectMaintainerGroupPathSuffixes' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectDeveloperGroupPathSuffixes' +export const PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY = 'projectGuestGroupPathSuffixes' + +// Registry robot name identifiers +export const ROBOT_NAME_RO = 'ro-robot' +export const ROBOT_NAME_RW = 'rw-robot' +export const ROBOT_NAME_PROJECT = 'project-robot' + +// Page size used when listing robots. A project only ever owns a handful of +// robots (ro, rw and optionally the project robot — 3 in practice), so a single +// page is plenty; this simply bounds each pagination request. +export const ROBOT_LIST_PAGE_SIZE = 100 + +// Allowed Harbor retention rule templates +export const ALLOWED_RETENTION_RULE_TEMPLATES = [ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +] as const + +// Harbor role identifiers +export const HARBOR_ROLE_PROJECT_ADMIN = 1 +export const HARBOR_ROLE_DEVELOPER = 2 +export const HARBOR_ROLE_GUEST = 3 +export const HARBOR_ROLE_MAINTAINER = 4 +export const HARBOR_ROLE_LIMITED_GUEST = 5 diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.module-definition.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.module-definition.ts new file mode 100644 index 0000000000..c994e3c6cc --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.module-definition.ts @@ -0,0 +1,21 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface RegistryConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + ruleTemplate: string + ruleCount?: number + retentionCron: string + robotRotationThresholdDays: number + projectSlugCacheTtlMs: number + internalOrPublicUrl?: string +} + +export interface RegistryModuleOptions extends RegistryConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('registry') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as REGISTRY_CONFIG } diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.module.ts new file mode 100644 index 0000000000..c93e994bce --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.module.ts @@ -0,0 +1,23 @@ +import { CacheModule } from '@nestjs/cache-manager' +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { ConfigurableModuleClass as HarborConfigurableModuleClass } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { RegistryHealthService } from './registry-health.service' +import { RegistryHttpClientService } from './registry-http-client.service' +import { RegistryPluginService } from './registry-plugin.service' +import { ConfigurableModuleClass } from './registry.module-definition' +import { RegistryService } from './registry.service' + +@Module({}) +export class HarborModule extends HarborConfigurableModuleClass {} + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule, HarborModule, CacheModule.register()], + providers: [RegistryHealthService, RegistryPluginService, RegistryService, RegistryDatastoreService, RegistryHttpClientService, RegistryClientService], + exports: [RegistryHealthService, RegistryPluginService, RegistryService], +}) +export class RegistryModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.service.spec.ts new file mode 100644 index 0000000000..94cf3b9d05 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -0,0 +1,322 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { HarborConfig } from './harbor.module-definition' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from '../vault/vault-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { makeCreatedResponse, makeNoContent, makeOkResponse, makeProjectWithDetails } from './registry-testing.utils' +import { + PLUGIN_NAME, + REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, + REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, + ROBOT_NAME_PROJECT, +} from './registry.constants' +import { RegistryService } from './registry.service' + +describe('registryService', () => { + let service: RegistryService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let harborConfig: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + getProjectByName: vi.fn().mockResolvedValue(makeOkResponse({ project_id: 123, metadata: {} })), + listQuotas: vi.fn().mockResolvedValue(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])), + getRetentionId: vi.fn().mockResolvedValue(null), + createRetention: vi.fn().mockResolvedValue(makeCreatedResponse(null)), + getGroupMembers: vi.fn().mockResolvedValue(makeOkResponse([])), + getProjectRobots: vi.fn(async function* () {}), + addGroupMember: vi.fn().mockResolvedValue(makeCreatedResponse(null)), + removeGroupMember: vi.fn().mockResolvedValue(makeNoContent()), + deleteProjectByName: vi.fn().mockResolvedValue(makeNoContent()), + }) + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + read: vi.fn().mockResolvedValue(makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: 'robot$myproj+ro-robot', + TOKEN: 'secret', + }, + })), + write: vi.fn().mockResolvedValue(undefined), + }) + harborConfig = mockDeep({ + url: 'https://harbor.example', + internalUrl: 'https://harbor.example', + admin: 'admin', + adminPassword: faker.internet.password(), + ruleTemplate: 'latestPushedK', + ruleCount: '10', + retentionCron: '0 22 2 * * *', + robotRotationThresholdDays: 90, + }) + baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep({}) + + const module = await Test.createTestingModule({ + providers: [ + RegistryService, + { provide: RegistryClientService, useValue: client }, + { provide: RegistryDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: HARBOR_CONFIG, useValue: harborConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + ], + }).compile() + + service = module.get(RegistryService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('adds expected Harbor group memberships based on defaults', async () => { + const project = makeProjectWithDetails() + + await service.handleUpsert(project) + + const expected = [ + { groupName: `/${project.slug}`, roleId: 5 }, + { groupName: '/console/admin', roleId: 1 }, + { groupName: '/console/readonly', roleId: 3 }, + { groupName: '/console/security', roleId: 3 }, + { groupName: `/${project.slug}/console/readonly`, roleId: 3 }, + { groupName: `/${project.slug}/console/security`, roleId: 3 }, + { groupName: `/${project.slug}/console/developer`, roleId: 3 }, + { groupName: `/${project.slug}/console/devops`, roleId: 3 }, + { groupName: `/${project.slug}/console/admin`, roleId: 2 }, + ] + + expect(client.addGroupMember).toHaveBeenCalledTimes(expected.length) + for (const e of expected) { + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: e.roleId, + member_group: { + group_name: e.groupName, + group_type: 3, + }, + }) + } + }) + + it('reconciles an existing group membership when role differs', async () => { + const project = makeProjectWithDetails() + client.getGroupMembers.mockResolvedValueOnce(makeOkResponse([ + { id: 10, entity_name: `/${project.slug}/console/admin`, entity_type: 'g', role_id: 3 }, + ])) + + await service.handleUpsert(project) + + expect(client.removeGroupMember).toHaveBeenCalledWith(project.slug, 10) + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: 2, + member_group: { + group_name: `/${project.slug}/console/admin`, + group_type: 3, + }, + }) + }) + + it('returns a KO result when project admin membership creation fails', async () => { + const project = makeProjectWithDetails() + client.addGroupMember.mockImplementation(async (_projectName, body) => { + if (body.member_group.group_name === `/${project.slug}/console/admin` && body.role_id === 2) { + return { status: HttpStatus.BAD_REQUEST, data: null } + } + return { status: HttpStatus.CREATED, data: null } + }) + + await expect(service.handleUpsert(project)).resolves.toEqual({ + harbor: expect.objectContaining({ + status: 'KO', + message: expect.stringContaining('Harbor create member failed'), + }), + }) + + expect(client.addGroupMember).toHaveBeenCalledWith(project.slug, { + role_id: 2, + member_group: { + group_name: `/${project.slug}/console/admin`, + group_type: 3, + }, + }) + }) + + it('updates quota when it differs', async () => { + client.listQuotas.mockResolvedValueOnce(makeOkResponse([{ ref: { id: 123 }, hard: { storage: -1 } }])) + + await service.handleUpsert(makeProjectWithDetails({ + slug: 'myproj', + plugins: [ + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1024' }, + ], + })) + + expect(client.updateQuota).toHaveBeenCalledWith(123, 1024) + }) + + it('reuses robot secret when vault secret host matches', async () => { + const project = makeProjectWithDetails() + + await service.handleUpsert(project) + + expect(vault.read).toHaveBeenCalledTimes(2) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/ro-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/rw-robot`) + expect(client.getProjectRobots).not.toHaveBeenCalled() + expect(client.createRobot).not.toHaveBeenCalled() + expect(client.deleteRobot).not.toHaveBeenCalled() + expect(vault.write).not.toHaveBeenCalled() + }) + + it('rotates robot and writes secret when vault secret host differs', async () => { + const project = makeProjectWithDetails() + vault.read.mockImplementation(async (path: string) => { + if (path === `forge/${project.slug}/REGISTRY/ro-robot`) { + return makeVaultSecret({ + data: { + HOST: 'other.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'old', + }, + }) + } + return makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+rw-robot`, + TOKEN: 'secret', + }, + }) + }) + + client.getProjectRobots.mockImplementation(async function* () { + yield { id: 11, name: `robot$${project.slug}+ro-robot` } + }) + client.deleteRobot.mockResolvedValue(makeNoContent()) + client.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) + + await service.handleUpsert(project) + + expect(client.deleteRobot).toHaveBeenCalledWith(11) + expect(client.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + HOST: 'harbor.example', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'newsecret', + }), `forge/${project.slug}/REGISTRY/ro-robot`) + }) + + it('rotates robot and writes secret when vault secret is expiring', async () => { + const project = makeProjectWithDetails() + const old = makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'old', + }, + }) + old.metadata.created_time = new Date(Date.now() - 1000 * 60 * 60 * 24 * 120).toISOString() + + vault.read.mockImplementation(async (path: string) => { + if (path === `forge/${project.slug}/REGISTRY/ro-robot`) return old + return makeVaultSecret({ + data: { + HOST: 'harbor.example', + DOCKER_CONFIG: '{}', + USERNAME: `robot$${project.slug}+rw-robot`, + TOKEN: 'secret', + }, + }) + }) + + client.getProjectRobots.mockImplementation(async function* () { + yield { id: 11, name: `robot$${project.slug}+ro-robot` } + }) + client.deleteRobot.mockResolvedValue(makeNoContent()) + client.createRobot.mockResolvedValue(makeCreatedResponse({ id: 22, name: `robot$${project.slug}+ro-robot`, secret: 'newsecret' })) + + await service.handleUpsert(project) + + expect(client.deleteRobot).toHaveBeenCalledWith(11) + expect(client.createRobot).toHaveBeenCalledWith(expect.objectContaining({ name: 'ro-robot' })) + expect(vault.write).toHaveBeenCalledWith(expect.objectContaining({ + HOST: 'harbor.example', + USERNAME: `robot$${project.slug}+ro-robot`, + TOKEN: 'newsecret', + }), `forge/${project.slug}/REGISTRY/ro-robot`) + }) + + it('parses plugin config and enables project robot publishing', async () => { + const project = makeProjectWithDetails({ + plugins: [ + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, value: '1gb' }, + { pluginName: PLUGIN_NAME, key: REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, value: ENABLED }, + ], + }) + client.getProjectByName.mockResolvedValue(makeOkResponse({ project_id: 1, metadata: {} })) + + await service.handleUpsert(project) + + expect(client.updateQuota).toHaveBeenCalledWith(1, 1024 ** 3) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/ro-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/rw-robot`) + expect(vault.read).toHaveBeenCalledWith(`forge/${project.slug}/REGISTRY/${ROBOT_NAME_PROJECT}`) + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects', async () => { + datastore.getAllProjects.mockResolvedValue([ + makeProjectWithDetails({ slug: 'project-1' }), + makeProjectWithDetails({ slug: 'project-2' }), + ]) + + await service.handleCron() + + expect(client.getGroupMembers).toHaveBeenCalledWith('project-1') + expect(client.getGroupMembers).toHaveBeenCalledWith('project-2') + }) + }) + + describe('handleDelete', () => { + it('should delete project when it exists', async () => { + const project = makeProjectWithDetails() + await service.handleDelete(project) + expect(client.deleteProjectByName).toHaveBeenCalledWith(project.slug) + }) + + it('should not delete project when it does not exist', async () => { + client.getProjectByName.mockResolvedValueOnce({ status: HttpStatus.NOT_FOUND, data: null }) + await service.handleDelete(makeProjectWithDetails()) + expect(client.deleteProjectByName).not.toHaveBeenCalled() + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.service.ts new file mode 100644 index 0000000000..43dc140455 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -0,0 +1,525 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultSecret } from '../vault/vault-client.service' +import type { HarborConfig } from './harbor.module-definition' +import type { + HarborAccess, + HarborGroupMemberRequest, + HarborMember, + HarborProjectQuota, + HarborRetentionPolicy, + HarborRobotCreateRequest, +} from './registry-client.service' +import type { ProjectWithDetails } from './registry-datastore.service' +import type { RuleTemplate, VaultRobotSecret } from './registry.utils' +import { specificallyEnabled } from '@cpn-console/hooks' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { find } from '../../utils/iterable.utils' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { VaultError } from '../vault/vault-http-client.service' +import { HARBOR_CONFIG } from './harbor.module-definition' +import { RegistryClientService, roAccess, rwAccess } from './registry-client.service' +import { RegistryDatastoreService } from './registry-datastore.service' +import { + ALLOWED_RETENTION_RULE_TEMPLATES, + DEFAULT_PLATFORM_ADMIN_GROUP_PATHS, + DEFAULT_PLATFORM_GUEST_GROUP_PATHS, + DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES, + HARBOR_ROLE_DEVELOPER, + HARBOR_ROLE_GUEST, + HARBOR_ROLE_LIMITED_GUEST, + HARBOR_ROLE_PROJECT_ADMIN, + PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY, + PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY, + PLUGIN_NAME, + PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY, + REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT, + REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT, + ROBOT_NAME_PROJECT, + ROBOT_NAME_RO, + ROBOT_NAME_RW, +} from './registry.constants' +import { generateVaultRobotSecret, getHostFromUrl, getProjectVaultPath, parseBytes } from './registry.utils' + +@Injectable() +export class RegistryService { + private readonly logger = new Logger(RegistryService.name) + + constructor( + @Inject(RegistryClientService) private readonly client: RegistryClientService, + @Inject(RegistryDatastoreService) private readonly datastore: RegistryDatastoreService, + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('RegistryService initialized') + } + + private get host() { + return getHostFromUrl(this.harborConfig.url) + } + + private async getRobot(project: ProjectWithDetails, harborProjectId: number, robotName: string) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.robot.name': robotName, + }) + const fullName = generateRobotFullName(project, robotName) + return find(this.client.getProjectRobots(harborProjectId), r => r?.name === fullName) + } + + private async createProjectRobot(project: ProjectWithDetails, robotName: string, access: HarborAccess[]) { + const created = await this.client.createRobot( + generateRobotPermissions(project, robotName, access), + ) + if (created.status >= 300 || !created.data) { + throw new Error(`Harbor create robot failed (${created.status})`) + } + return created.data + } + + private async rotateRobot(project: ProjectWithDetails, harborProjectId: number, robotName: string, access: HarborAccess[]) { + const existing = await this.getRobot(project, harborProjectId, robotName) + if (existing?.id) { + await this.client.deleteRobot(existing.id) + } + return this.createProjectRobot(project, robotName, access) + } + + private async ensureRobotSecret(project: ProjectWithDetails, harborProjectId: number, robotName: string, access: HarborAccess[]) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.robot.name': robotName, + }) + const relativeVaultPath = `REGISTRY/${robotName}` + const vaultPath = getProjectVaultPath(project, this.baseConfig.projectsRootDir, relativeVaultPath) + const vaultRobotSecret = await this.vault.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + + const expiring = vaultRobotSecret + ? this.isRobotSecretExpiring(vaultRobotSecret) + : false + + span?.setAttributes({ + 'vault.secret.exists': !!vaultRobotSecret, + 'registry.robot.secret.expiring': expiring, + }) + + if (vaultRobotSecret?.data?.HOST === this.host && !expiring) { + span?.setAttribute('vault.secret.reused', true) + return vaultRobotSecret.data + } + + const existing = await this.getRobot(project, harborProjectId, robotName) + const created = existing + ? await this.rotateRobot(project, harborProjectId, robotName, access) + : await this.createProjectRobot(project, robotName, access) + const fullName = generateRobotFullName(project, robotName) + const secret = generateVaultRobotSecret(this.host, fullName, created.secret) + await this.vault.write(secret, vaultPath) + span?.setAttribute('vault.secret.written', true) + return secret + } + + private isRobotSecretExpiring(vaultSecret: VaultSecret): boolean { + const createdTimeRaw = vaultSecret?.metadata?.created_time + if (!createdTimeRaw) return false + const createdTime = new Date(createdTimeRaw) + return daysAgoFromNow(createdTime) > this.harborConfig.robotRotationThresholdDays + } + + private async ensureProjectGroupMember( + projectSlug: string, + groupName: string, + accessLevel: number, + membersByName: Map, + ) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'registry.group.name': groupName, + 'registry.group.access_level': accessLevel, + }) + const existing = membersByName.get(groupName) + + if (existing?.id) { + if (existing.role_id !== accessLevel || existing.entity_type !== 'g') { + await this.client.removeGroupMember(projectSlug, Number(existing.id)) + membersByName.delete(groupName) + } else { + span?.setAttribute('registry.member.exists', true) + return + } + } + + const body: HarborGroupMemberRequest = { + role_id: accessLevel, + member_group: { + group_name: groupName, + group_type: 3, + }, + } + const created = await this.client.addGroupMember(projectSlug, body) + if (created.status >= 300) { + throw new Error(`Harbor create member failed (${created.status})`) + } + span?.setAttribute('registry.member.created', true) + } + + private async ensureProjectGroupMembers(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const members = await this.client.getGroupMembers(project.slug) + if (members.status !== 200 || !members.data) { + throw new Error(`Harbor list members failed (${members.status})`) + } + + const membersByName = new Map() + for (const member of members.data) { + const name = member?.entity_name + if (name) membersByName.set(name, member) + } + + const byGroupName = await this.generateAccessLevelMapping(project) + + await Promise.all( + Array.from(byGroupName.entries(), ([groupName, accessLevel]) => this.ensureProjectGroupMember( + project.slug, + groupName, + accessLevel, + membersByName, + )), + ) + } + + private async ensureProjectQuota(project: ProjectWithDetails, storageLimit: number) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.storage_limit.bytes': storageLimit, + }) + const existing = await this.client.getProjectByName(project.slug) + if (existing.status === 200 && existing.data) { + const projectId = Number(existing.data.project_id) + if (!Number.isFinite(projectId)) return existing.data + + const quotas = await this.client.listQuotas(projectId) + if (quotas.status === 200 && quotas.data) { + const hardQuota = quotas.data.find((q: HarborProjectQuota) => q?.ref?.id === projectId) + if (hardQuota?.hard?.storage !== storageLimit) { + await this.client.updateQuota(projectId, storageLimit) + span?.setAttribute('registry.quota.updated', true) + } + } + return existing.data + } + + const created = await this.client.createProject(project.slug, storageLimit) + if (created.status >= 300) { + throw new Error(`Harbor create project failed (${created.status})`) + } + span?.setAttribute('registry.project.created', true) + + const fetched = await this.client.getProjectByName(project.slug) + if (fetched.status !== 200 || !fetched.data) { + throw new Error(`Harbor get project failed (${fetched.status})`) + } + return fetched.data + } + + private async ensureRetentionPolicy(project: ProjectWithDetails, harborProjectId: number) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.project.id': harborProjectId, + }) + const policy = generateRetentionPolicy(harborProjectId, { + harborRuleTemplate: this.harborConfig.ruleTemplate, + harborRuleCount: this.harborConfig.ruleCount, + harborRetentionCron: this.harborConfig.retentionCron, + }) + const retentionId = await this.client.getRetentionId(project.slug) + span?.setAttribute('registry.retention.exists', !!retentionId) + const result = retentionId + ? await this.client.updateRetention(retentionId, policy) + : await this.client.createRetention(policy) + if (result.status >= 300) { + throw new Error(`Harbor retention policy failed (${result.status})`) + } + } + + @StartActiveSpan() + async ensureProject(project: ProjectWithDetails, options: { storageLimitBytes?: number, publishProjectRobot?: boolean } = {}) { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': project.slug, + 'registry.publish_project_robot': !!options.publishProjectRobot, + }) + const storageLimit = options.storageLimitBytes ?? -1 + const harborProject = await this.ensureProjectQuota(project, storageLimit) + const harborProjectId = Number(harborProject.project_id) + if (!Number.isFinite(harborProjectId)) + throw new Error('Unable to retrieve Harbor project_id') + + await Promise.all([ + this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_RO, roAccess), + this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_RW, rwAccess), + this.ensureProjectGroupMembers(project), + this.ensureRetentionPolicy(project, harborProjectId), + options.publishProjectRobot + ? this.ensureRobotSecret(project, harborProjectId, ROBOT_NAME_PROJECT, roAccess) + : Promise.resolve(), + ]) + + return { + projectId: harborProjectId, + basePath: `${this.host}/${project.slug}/`, + } + } + + @StartActiveSpan() + async deleteProject(projectSlug: string) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + const existing = await this.client.getProjectByName(projectSlug) + if (existing.status === 404) { + span?.setAttribute('registry.project.exists', false) + return + } + const deleted = await this.client.deleteProjectByName(projectSlug) + if (deleted.status >= 300 && deleted.status !== 404) { + throw new Error(`Harbor delete project failed (${deleted.status})`) + } + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('harbor', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + const quotaConfigRaw = getPluginConfig(project, REGISTRY_CONFIG_KEY_QUOTA_HARD_LIMIT) + const publishConfig = getPluginConfig(project, REGISTRY_CONFIG_KEY_PUBLISH_PROJECT_ROBOT) + const parsedQuota = quotaConfigRaw ? parseBytes(String(quotaConfigRaw)) : undefined + const storageLimitBytes = parsedQuota ?? -1 + const publishProjectRobot = specificallyEnabled(publishConfig) + await this.ensureProject(project, { storageLimitBytes, publishProjectRobot }) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('harbor', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.deleteProject(project.slug) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Registry reconciliation') + const projects = await this.datastore.getAllProjects() + span?.setAttribute('registry.projects.count', projects.length) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string) { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return getPluginConfig(project, key) + } + + private async getPlatformAdminGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PLATFORM_ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_PLATFORM_ADMIN_GROUP_PATHS + return parseGroupPaths(raw) + } + + private async getPlatformGuestGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PLATFORM_GUEST_GROUP_PATHS_PLUGIN_KEY) ?? DEFAULT_PLATFORM_GUEST_GROUP_PATHS + return parseGroupPaths(raw) + } + + private async getProjectAdminGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_ADMIN_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectMaintainerGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectGuestGroupPaths(project: ProjectWithDetails): Promise { + const raw = await this.getAdminOrProjectPluginConfig(project, PROJECT_GUEST_GROUP_PATH_SUFFIXES_PLUGIN_KEY) ?? DEFAULT_PROJECT_GUEST_GROUP_PATH_SUFFIXES + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async generateAccessLevelMapping(project: ProjectWithDetails) { + const [ + platformAdminGroupPaths, + platformGuestGroupPaths, + projectAdminGroupPaths, + projectMaintainerGroupPaths, + projectDeveloperGroupPaths, + projectGuestGroupPaths, + ] = await Promise.all([ + this.getPlatformAdminGroupPaths(project), + this.getPlatformGuestGroupPaths(project), + this.getProjectAdminGroupPaths(project), + this.getProjectMaintainerGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectGuestGroupPaths(project), + ]) + + const roles = generateHarborAccessLevelMapping({ + guest: projectGuestGroupPaths, + developer: projectDeveloperGroupPaths, + maintainer: projectMaintainerGroupPaths, + admin: projectAdminGroupPaths, + platformAdmin: platformAdminGroupPaths, + platformGuest: platformGuestGroupPaths, + }) + return new Map([ + [`/${project.slug}`, HARBOR_ROLE_LIMITED_GUEST], + ...roles, + ]) + } +} + +function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +function parseGroupPaths(raw: string): string[] { + return raw + .split(',') + .map(path => path.trim()) + .filter(Boolean) +} + +function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string) { + return parseGroupPaths(rawGroupPathSuffixes).map(path => `/${projectSlug}${path}`) +} + +function generateHarborAccessLevelMapping(args: { guest: string[], developer: string[], maintainer: string[], admin: string[], platformAdmin: string[], platformGuest: string[] }) { + const byGroupName = new Map() + for (const groupName of args.guest) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.developer) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.maintainer) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + for (const groupName of args.admin) byGroupName.set(groupName, HARBOR_ROLE_DEVELOPER) + for (const groupName of args.platformAdmin) byGroupName.set(groupName, HARBOR_ROLE_PROJECT_ADMIN) + for (const groupName of args.platformGuest) byGroupName.set(groupName, HARBOR_ROLE_GUEST) + return byGroupName +} + +function daysAgoFromNow(date: Date) { + return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) +} + +function generateRobotFullName(project: ProjectWithDetails, robotName: string) { + return `robot$${project.slug}+${robotName}` +} + +function generateRobotPermissions(project: ProjectWithDetails, robotName: string, access: HarborAccess[]): HarborRobotCreateRequest { + return { + name: robotName, + duration: -1, + description: 'robot for ci builds', + disable: false, + level: 'project', + permissions: [{ + namespace: project.slug, + kind: 'project', + access, + }], + } +} + +function generateRetentionPolicy( + projectId: number, + options: { + harborRuleTemplate?: string + harborRuleCount?: string + harborRetentionCron?: string + }, +): HarborRetentionPolicy { + let template: RuleTemplate = 'latestPushedK' + if (isRuleTemplate(options.harborRuleTemplate)) { + template = options.harborRuleTemplate + } + + const rawCount = Number(options.harborRuleCount) + let count: number + if (Number.isFinite(rawCount) && rawCount > 0) { + count = rawCount + } else if (template === 'always') { + count = 1 + } else { + count = 10 + } + + return { + algorithm: 'or', + scope: { level: 'project', ref: projectId }, + rules: [ + { + disabled: false, + action: 'retain', + template, + params: { [template]: count }, + tag_selectors: [ + { kind: 'doublestar', decoration: 'matches', pattern: '**' }, + ], + scope_selectors: { + repository: [ + { kind: 'doublestar', decoration: 'repoMatches', pattern: '**' }, + ], + }, + }, + ], + trigger: { + kind: 'Schedule', + settings: { cron: options.harborRetentionCron }, + references: [], + }, + } +} + +function isRuleTemplate(value: unknown): value is RuleTemplate { + if (typeof value !== 'string') return false + for (const template of ALLOWED_RETENTION_RULE_TEMPLATES) { + if (template === value) return true + } + return false +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.utils.ts new file mode 100644 index 0000000000..2befbdbd12 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -0,0 +1,86 @@ +import type { ProjectWithDetails } from './registry-datastore.service' +import type { ALLOWED_RETENTION_RULE_TEMPLATES } from './registry.constants' +import { removeTrailingSlash } from '@cpn-console/shared' + +export type RuleTemplate = typeof ALLOWED_RETENTION_RULE_TEMPLATES[number] + +export function createProjectSlugCacheKey(projectId: string) { + return `registry:project-slug:${projectId}` +} + +const protocolPrefixRegex = /^https?:\/\//u +const parseBytesRegex = /^(\d+(?:\.\d+)?)(?:\s*(kb|mb|gb|tb|[kmgtb]))?$/u + +export function getHostFromUrl(url: string) { + return removeTrailingSlash(url).replace(protocolPrefixRegex, '').split('/')[0] +} + +export function encodeBasicAuth(username: string, password: string) { + return Buffer.from(`${username}:${password}`).toString('base64') +} + +export interface VaultRobotSecret { + DOCKER_CONFIG: string + HOST: string + TOKEN: string + USERNAME: string +} + +export function generateVaultRobotSecret(host: string, robotName: string, robotSecret: string): VaultRobotSecret { + const auth = `${robotName}:${robotSecret}` + const b64auth = Buffer.from(auth).toString('base64') + return { + DOCKER_CONFIG: JSON.stringify({ + auths: { + [host]: { + auth: b64auth, + email: '', + }, + }, + }), + HOST: host, + TOKEN: robotSecret, + USERNAME: robotName, + } +} + +export function getProjectVaultPath(project: ProjectWithDetails, projectRootDir: string | undefined, relativePath: string) { + const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath + return projectRootDir + ? `${projectRootDir}/${project.slug}/${normalized}` + : `${project.slug}/${normalized}` +} + +export function parseBytes(input: string | number | undefined) { + if (input === undefined || input === null) return undefined + if (typeof input === 'number' && Number.isFinite(input)) return input + const raw = String(input).trim().toLowerCase() + if (!raw) return undefined + const match = parseBytesRegex.exec(raw) + if (!match) { + return Number.isFinite(Number(raw)) ? Number(raw) : undefined + } + const value = Number(match[1]) + const unit = (match[2] ?? 'b').toLowerCase() + const pow = parseUnit(unit) + return Math.round(value * 1024 ** pow) +} + +function parseUnit(unit: string) { + switch (unit) { + case 'kb': + case 'k': + return 1 + case 'mb': + case 'm': + return 2 + case 'gb': + case 'g': + return 3 + case 'tb': + case 't': + return 4 + default: + return 0 + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts new file mode 100644 index 0000000000..51e59317ee --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts @@ -0,0 +1,199 @@ +import type { TestingModule } from '@nestjs/testing' +import type { Dispatcher, RequestInit } from 'undici' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { OpenCdsConfig } from '../opencds/opencds.module-definition' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { Agent, fetch, Headers, ProxyAgent, Response } from 'undici' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { OPENCDS_CONFIG } from '../opencds/opencds.module-definition' +import { OpenCdsClientError, OpenCdsClientService } from './open-cds-client.service' + +vi.mock('undici', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + Agent: vi.fn(), + fetch: vi.fn(), + ProxyAgent: vi.fn(), + } +}) + +function mockFetchResponse(response: Response): void { + vi.mocked(fetch).mockResolvedValue(response) +} + +function getLastFetchCall(): [string, RequestInit] { + const [url, init] = vi.mocked(fetch).mock.lastCall as [string, RequestInit] + return [url, init] +} + +describe('openCdsClientService', () => { + let module: TestingModule + let service: OpenCdsClientService + let openCdsConfig: Partial + let baseConfig: DeepMockProxy + let tlsDispatcher: Pick + let proxyDispatcher: Pick + + beforeEach(async () => { + vi.clearAllMocks() + vi.unstubAllEnvs() + + tlsDispatcher = { dispatch: vi.fn() } + proxyDispatcher = { dispatch: vi.fn() } + + class MockAgent { + dispatch = tlsDispatcher + }; + + class ProxyMockAgent { + dispatch = proxyDispatcher + }; + + vi.mocked(Agent).mockImplementation(MockAgent as any) + vi.mocked(ProxyAgent).mockImplementation(ProxyMockAgent as any) + + openCdsConfig = { + url: 'https://opencds.example.com/root/api/', + apiToken: 'test-token', + apiTlsRejectUnauthorized: true, + } + baseConfig = mockDeep({ httpProxy: undefined }) + + module = await Test.createTestingModule({ + providers: [ + OpenCdsClientService, + { provide: OPENCDS_CONFIG, useValue: openCdsConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(OpenCdsClientService) + }) + + it('builds GET requests with an Axios-compatible URL, API key header and TLS-aware dispatcher', async () => { + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + const result = await service.get<{ ok: boolean }>('/requests') + + expect(Agent).toHaveBeenCalledWith({ + connect: { + rejectUnauthorized: true, + }, + }) + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests') + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('GET') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(result).toEqual({ ok: true }) + }) + + it('uses ProxyAgent when HTTP_PROXY is configured and preserves TLS settings for the upstream request', async () => { + baseConfig.httpProxy = 'http://proxy.internal:3128' + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + await service.get('/requests') + + expect(ProxyAgent).toHaveBeenCalledWith({ + requestTls: { + rejectUnauthorized: true, + }, + uri: 'http://proxy.internal:3128', + }) + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests') + expect(init.dispatcher?.dispatch).toBe(proxyDispatcher) + expect(init.method).toBe('GET') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + }) + + it('applies query parameters and omits undefined values on GET', async () => { + mockFetchResponse(new Response(JSON.stringify({ ok: true }), { + status: HttpStatus.OK, + headers: { + 'content-type': 'application/json', + }, + })) + + await service.get('/requests', { + query: { + page: 2, + active: true, + search: 'alpha', + ignored: undefined, + }, + }) + + const [url] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/requests?page=2&active=true&search=alpha') + }) + + it('sends POST without body and without forcing JSON content type', async () => { + mockFetchResponse(new Response(null, { status: HttpStatus.NO_CONTENT })) + + await service.post('/validate/id') + + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/validate/id') + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('POST') + expect(init.signal).toBeUndefined() + expect(init.body).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(new Headers(init.headers).has('Content-Type')).toBe(false) + }) + + it('serializes POST bodies as JSON and sets the content type', async () => { + mockFetchResponse(new Response(null, { status: HttpStatus.NO_CONTENT })) + + await service.post('/validate/id', { + requestId: '123', + enabled: true, + }) + + const [url, init] = getLastFetchCall() + expect(url).toBe('https://opencds.example.com/root/api/validate/id') + expect(init.body).toBe(JSON.stringify({ + requestId: '123', + enabled: true, + })) + expect(init.dispatcher?.dispatch).toBe(tlsDispatcher) + expect(init.method).toBe('POST') + expect(init.signal).toBeUndefined() + expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + expect(new Headers(init.headers).get('Content-Type')).toBe('application/json') + }) + + it('throws a dedicated error with HTTP status context for non-OK responses', async () => { + mockFetchResponse(new Response('upstream failure', { + status: HttpStatus.BAD_GATEWAY, + statusText: 'Bad Gateway', + })) + + await expect(service.get('/requests')).rejects.toMatchObject({ + body: 'upstream failure', + message: 'OpenCDS request failed with 502 Bad Gateway', + name: OpenCdsClientError.name, + status: HttpStatus.BAD_GATEWAY, + statusText: 'Bad Gateway', + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts new file mode 100644 index 0000000000..c2760046d2 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts @@ -0,0 +1,129 @@ +import type { HttpStatus } from '@nestjs/common' +import type { Dispatcher, HeadersInit } from 'undici' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { OpenCdsConfig } from '../opencds/opencds.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { Agent, fetch, Headers, ProxyAgent } from 'undici' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { OPENCDS_CONFIG } from '../opencds/opencds.module-definition' +import { throwIfNotOk } from './service-chain.utils' + +const URL_REGEX = /^https?:\/\// +const START_SLASHES_REGEX = /^\/+/ +const END_SLASHES_REGEX = /\/+$/ + +export interface OpenCdsRequestOptions { + headers?: HeadersInit + signal?: AbortSignal + query?: Record +} + +export class OpenCdsClientError extends Error { + constructor( + public readonly status: HttpStatus, + public readonly statusText: string, + public readonly body?: string, + ) { + super(`OpenCDS request failed with ${status} ${statusText}`) + } +} + +@Injectable() +export class OpenCdsClientService { + constructor( + @Inject(OPENCDS_CONFIG) private readonly opencdsConfig: OpenCdsConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + private readonly logger = new Logger(OpenCdsClientService.name) + + async get(path: string, options?: OpenCdsRequestOptions): Promise { + const url = this.buildUrl(path, options?.query) + this.logger.debug(`Retrieving data from URL: ${url}`) + + const headers = this.buildHeaders(options?.headers) + + const response = await fetch(url, { + dispatcher: this.buildDispatcher(), + headers, + method: 'GET', + signal: options?.signal, + }) + + await throwIfNotOk(response) + + return (await response.json()) as T + } + + async post( + path: string, + body?: TBody, + options?: OpenCdsRequestOptions, + ): Promise { + const hasBody = body !== undefined + + const response = await fetch(this.buildUrl(path, options?.query), { + body: hasBody ? JSON.stringify(body) : undefined, + dispatcher: this.buildDispatcher(), + headers: this.buildHeaders(options?.headers, hasBody), + method: 'POST', + signal: options?.signal, + }) + + await throwIfNotOk(response) + } + + private buildUrl( + path: string, + query?: OpenCdsRequestOptions['query'], + ): string { + if (!this.opencdsConfig.url) throw new Error('OpenCDS is disabled') + + const resolvedPath = URL_REGEX.test(path) + ? path + : `${this.opencdsConfig.url.replace(END_SLASHES_REGEX, '')}/${path.replace(START_SLASHES_REGEX, '')}` + + const url = new URL(resolvedPath) + + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined) { + url.searchParams.append(key, String(value)) + } + } + + return url.toString() + } + + private buildHeaders( + headers?: OpenCdsRequestOptions['headers'], + hasJsonBody = false, + ): Headers { + const mergedHeaders = new Headers(headers) + mergedHeaders.set('X-API-Key', this.opencdsConfig.apiToken ?? '') + + if (hasJsonBody) { + mergedHeaders.set('Content-Type', 'application/json') + } + + return mergedHeaders + } + + private buildDispatcher(): Dispatcher { + const httpProxy = this.baseConfig.httpProxy + + if (httpProxy) { + return new ProxyAgent({ + requestTls: { + rejectUnauthorized: this.opencdsConfig.apiTlsRejectUnauthorized, + }, + uri: httpProxy, + }) + } + + return new Agent({ + connect: { + rejectUnauthorized: this.opencdsConfig.apiTlsRejectUnauthorized, + }, + }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts new file mode 100644 index 0000000000..2318edb937 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.controller.spec.ts @@ -0,0 +1,126 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ServiceChainController } from './service-chain.controller' +import { ServiceChainService } from './service-chain.service' + +describe('serviceChainController', () => { + let module: TestingModule + let controller: ServiceChainController + let service: MockProxy + + const uuid = '550e8400-e29b-41d4-a716-446655440000' + const date = new Date('2026-01-01T00:00:00.000Z') + const serviceChain: ServiceChain = { + id: uuid, + state: 'opened', + commonName: 'test.example.com', + pai: 'test-pai', + network: 'RIE', + createdAt: date, + updatedAt: date, + } + const serviceChainDetails: ServiceChainDetails = { + ...serviceChain, + validationId: uuid, + validatedBy: null, + ref: null, + location: 'SIR', + targetAddress: '10.0.0.1', + projectId: uuid, + env: 'INT', + subjectAlternativeName: [], + redirect: false, + antivirus: null, + websocket: false, + ipWhiteList: [], + sslOutgoing: false, + } + const serviceChainFlows: ServiceChainFlows = { + reserve_ip: { state: 'success', input: {}, output: {}, updatedAt: date }, + create_cert: null, + call_exec: { state: 'success', input: {}, output: {}, updatedAt: date }, + activate_ip: { state: 'success', input: {}, output: {}, updatedAt: date }, + dns_request: { state: 'success', input: {}, output: {}, updatedAt: date }, + } + + beforeEach(async () => { + service = mock() + + module = await Test.createTestingModule({ + controllers: [ServiceChainController], + providers: [ + { provide: ServiceChainService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(ServiceChainController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + describe('list', () => { + it('should call service.list()', async () => { + const mockResult = [serviceChain] + service.list.mockResolvedValue(mockResult) + + const result = await controller.list() + + expect(service.list).toHaveBeenCalled() + expect(result).toEqual(mockResult) + }) + }) + + describe('getDetails', () => { + it('should call service.getDetails() with id', async () => { + const mockResult = serviceChainDetails + service.getDetails.mockResolvedValue(mockResult) + + const result = await controller.getDetails(uuid) + + expect(service.getDetails).toHaveBeenCalledWith(uuid) + expect(result).toEqual(mockResult) + }) + }) + + describe('retry', () => { + it('should call service.retry() with id', async () => { + service.retry.mockResolvedValue() + + await controller.retry(uuid) + + expect(service.retry).toHaveBeenCalledWith(uuid) + }) + }) + + describe('validate', () => { + it('should call service.validate() with validationId', async () => { + service.validate.mockResolvedValue() + + await controller.validate(uuid) + + expect(service.validate).toHaveBeenCalledWith(uuid) + }) + }) + + describe('getFlows', () => { + it('should call service.getFlows() with id', async () => { + const mockResult = serviceChainFlows + service.getFlows.mockResolvedValue(mockResult) + + const result = await controller.getFlows(uuid) + + expect(service.getFlows).toHaveBeenCalledWith(uuid) + expect(result).toEqual(mockResult) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts new file mode 100644 index 0000000000..1caea91186 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Inject, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ServiceChainService } from './service-chain.service' + +@Controller('api/v1/service-chains') +export class ServiceChainController { + constructor( + @Inject(ServiceChainService) + private readonly serviceChainService: ServiceChainService, + ) {} + + @Post('validate/:validationId') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async validate(@Param('validationId', ParseUUIDPipe) validationId: string) { + await this.serviceChainService.validate(validationId) + } + + @Get() + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async list() { + return this.serviceChainService.list() + } + + @Get(':serviceChainId') + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async getDetails(@Param('serviceChainId', ParseUUIDPipe) id: string) { + return this.serviceChainService.getDetails(id) + } + + @Post(':serviceChainId/retry') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async retry(@Param('serviceChainId', ParseUUIDPipe) id: string) { + await this.serviceChainService.retry(id) + } + + @Get(':serviceChainId/flows') + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async getFlows(@Param('serviceChainId', ParseUUIDPipe) id: string) { + return this.serviceChainService.getFlows(id) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts new file mode 100644 index 0000000000..52964ab4b2 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { openCdsConfigFactory } from '../../config/opencds.config' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { EventsModule } from '../infrastructure/events/events.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { OpenCdsClientService } from './open-cds-client.service' +import { ServiceChainController } from './service-chain.controller' +import { ServiceChainService } from './service-chain.service' + +@Module({ + imports: [ + AuthModule, + DatabaseModule, + EventsModule, + UserPermissionModule, + ConfigModule.forFeature(openCdsConfigFactory), + ConfigModule.forFeature(baseConfigFactory), + ], + controllers: [ServiceChainController], + providers: [OpenCdsClientService, ServiceChainService], + exports: [ServiceChainService], +}) +export class ServiceChainModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts new file mode 100644 index 0000000000..e92351a0b2 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.service.spec.ts @@ -0,0 +1,124 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { OpenCdsClientService } from './open-cds-client.service' +import { ServiceChainService } from './service-chain.service' + +describe('serviceChainService', () => { + let module: TestingModule + let service: ServiceChainService + let openCdsClient: MockProxy + + beforeEach(async () => { + openCdsClient = mock() + + module = await Test.createTestingModule({ + providers: [ + ServiceChainService, + { provide: OpenCdsClientService, useValue: openCdsClient }, + ], + }).compile() + + service = module.get(ServiceChainService) + }) + + const uuid = '550e8400-e29b-41d4-a716-446655440000' + + const mockServiceChain: ServiceChain = { + id: uuid, + state: 'opened', + commonName: 'test.example.com', + pai: 'test-pai', + network: 'RIE', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const mockServiceChainDetails: ServiceChainDetails = { + ...mockServiceChain, + validationId: uuid, + validatedBy: null, + ref: null, + location: 'SIR', + targetAddress: '10.0.0.1', + projectId: uuid, + env: 'INT', + subjectAlternativeName: [], + redirect: false, + antivirus: null, + websocket: false, + ipWhiteList: [], + sslOutgoing: false, + } + const flowDetails: NonNullable = { + state: 'success', + input: {}, + output: {}, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + } + const mockServiceChainFlows: ServiceChainFlows = { + reserve_ip: flowDetails, + create_cert: null, + call_exec: flowDetails, + activate_ip: flowDetails, + dns_request: flowDetails, + } + + describe('list', () => { + it('should call GET /requests and parse response', async () => { + openCdsClient.get.mockResolvedValue([mockServiceChain]) + + const result = await service.list() + + expect(openCdsClient.get).toHaveBeenCalledWith('/requests') + expect(result).toHaveLength(1) + expect(result[0].id).toBe(uuid) + }) + }) + + describe('getDetails', () => { + it('should call GET /requests/:id and parse response', async () => { + openCdsClient.get.mockResolvedValue(mockServiceChainDetails) + + const result = await service.getDetails(uuid) + + expect(openCdsClient.get).toHaveBeenCalledWith(`/requests/${uuid}`) + expect(result.id).toBe(uuid) + expect(result.location).toBe('SIR') + }) + }) + + describe('retry', () => { + it('should call POST /requests/:id/retry', async () => { + openCdsClient.post.mockResolvedValue() + + await service.retry(uuid) + + expect(openCdsClient.post).toHaveBeenCalledWith(`/requests/${uuid}/retry`) + }) + }) + + describe('validate', () => { + it('should call POST /validate/:validationId', async () => { + openCdsClient.post.mockResolvedValue() + + await service.validate(uuid) + + expect(openCdsClient.post).toHaveBeenCalledWith(`/validate/${uuid}`) + }) + }) + + describe('getFlows', () => { + it('should call GET /requests/:id/flows and parse response', async () => { + openCdsClient.get.mockResolvedValue(mockServiceChainFlows) + + const result = await service.getFlows(uuid) + + expect(openCdsClient.get).toHaveBeenCalledWith(`/requests/${uuid}/flows`) + expect(result.reserve_ip.state).toBe('success') + expect(result.create_cert).toBeNull() + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts new file mode 100644 index 0000000000..d40a031fdf --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.service.ts @@ -0,0 +1,45 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import { + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainListSchema, +} from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { OpenCdsClientService } from './open-cds-client.service' + +@Injectable() +export class ServiceChainService { + constructor(@Inject(OpenCdsClientService) private readonly openCdsClient: OpenCdsClientService) {} + + @StartActiveSpan() + async list(): Promise { + return ServiceChainListSchema.parse( + await this.openCdsClient.get('/requests'), + ) + } + + @StartActiveSpan() + async getDetails(id: string): Promise { + return ServiceChainDetailsSchema.parse( + await this.openCdsClient.get(`/requests/${id}`), + ) + } + + @StartActiveSpan() + async retry(id: string): Promise { + await this.openCdsClient.post(`/requests/${id}/retry`) + } + + @StartActiveSpan() + async validate(validationId: string): Promise { + await this.openCdsClient.post(`/validate/${validationId}`) + } + + @StartActiveSpan() + async getFlows(id: string): Promise { + return ServiceChainFlowsSchema.parse( + await this.openCdsClient.get(`/requests/${id}/flows`), + ) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts new file mode 100644 index 0000000000..3500ad7ec9 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/service-chain/service-chain.utils.ts @@ -0,0 +1,20 @@ +import type { HttpStatus } from '@nestjs/common' +import type { Response } from 'undici' + +export class OpenCdsClientError extends Error { + constructor( + public readonly status: HttpStatus, + public readonly statusText: string, + public readonly body?: string, + ) { + super(`OpenCDS request failed with ${status} ${statusText}`) + this.name = 'OpenCdsClientError' + } +} + +export async function throwIfNotOk(response: T): Promise { + if (!response.ok) { + const body = await response.text() + throw new OpenCdsClientError(response.status, response.statusText, body) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts new file mode 100644 index 0000000000..26a5d114c4 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts @@ -0,0 +1,203 @@ +import type { AddPermissionGroupParams, CreateUserParams, DeactivateUserParams, RevokeUserTokenParams } from './sonarqube-client.service' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' +import { makeSonarqubeGeneratedToken, makeSonarqubeGroup, makeSonarqubePaging, makeSonarqubeProject, makeSonarqubeUser } from './sonarqube-testing.utils' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +const sonarUrl = 'https://sonarqube.internal' +const sonarToken = 'my-token' +const sonarAuthHeader = `Bearer ${sonarToken}` + +const server = setupServer() + +describe('sonarqubeClientService', () => { + let service: SonarqubeClientService + let config: ReturnType> + + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + beforeEach(async () => { + config = mockDeep({ + apiToken: sonarToken, + internalOrPublicUrl: sonarUrl, + }) + + const module = await Test.createTestingModule({ + providers: [ + SonarqubeClientService, + SonarqubeHttpClientService, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = module.get(SonarqubeClientService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('userGroupsSearch', () => { + it('should GET user_groups/search with auth', async () => { + const group = makeSonarqubeGroup({ name: 'my-group' }) + server.use( + http.get(`${sonarUrl}/api/user_groups/search`, ({ request }) => { + expect(request.headers.get('authorization')).toBe(sonarAuthHeader) + expect(new URL(request.url).searchParams.get('q')).toBe('my-group') + return HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), groups: [group] }) + }), + ) + const result = await service.searchUserGroup({ q: 'my-group' }) + expect(result.groups).toEqual([group]) + }) + }) + + describe('userGroupsCreate', () => { + it('should POST user_groups/create', async () => { + server.use( + http.post(`${sonarUrl}/api/user_groups/create`, ({ request }) => { + expect(new URL(request.url).searchParams.get('name')).toBe('new-group') + return HttpResponse.json({}) + }), + ) + await expect(service.createUserGroup({ name: 'new-group' })).resolves.not.toThrow() + }) + }) + + describe('usersSearch', () => { + it('should GET users/search', async () => { + const user = makeSonarqubeUser({ login: 'my-user' }) + server.use( + http.get(`${sonarUrl}/api/users/search`, () => + HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), users: [user] })), + ) + const result = await service.searchUsers({ q: 'my-user' }) + expect(result.users).toEqual([user]) + }) + }) + + describe('usersCreate', () => { + it('should POST users/create with all params as query string', async () => { + const user = { + email: faker.internet.email(), + local: 'true', + login: faker.internet.username(), + name: faker.internet.username(), + password: faker.internet.password(), + } satisfies CreateUserParams + server.use( + http.post(`${sonarUrl}/api/users/create`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('login')).toBe(user.login) + expect(params.get('email')).toBe(user.email) + expect(params.get('local')).toBe(user.local) + return HttpResponse.json({}) + }), + ) + await service.createUser(user) + }) + }) + + describe('usersDeactivate', () => { + it('should POST users/deactivate with anonymize param', async () => { + const user = { + login: faker.internet.username(), + anonymize: true, + } satisfies DeactivateUserParams + server.use( + http.post(`${sonarUrl}/api/users/deactivate`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('login')).toBe(user.login) + expect(params.get('anonymize')).toBe(String(user.anonymize)) + return HttpResponse.json({}) + }), + ) + await service.deactivateUser(user) + }) + }) + + describe('userTokensRevoke / userTokensGenerate', () => { + it('should POST user_tokens/revoke', async () => { + const token = makeSonarqubeGeneratedToken() + const revoke = { + login: token.login, + name: token.name, + } satisfies RevokeUserTokenParams + server.use( + http.post(`${sonarUrl}/api/user_tokens/revoke`, () => HttpResponse.json({})), + ) + await expect(service.revokeUserToken(revoke)).resolves.not.toThrow() + }) + + it('should POST user_tokens/generate and return the token', async () => { + const generated = makeSonarqubeGeneratedToken() + server.use( + http.post(`${sonarUrl}/api/user_tokens/generate`, () => HttpResponse.json(generated)), + ) + const result = await service.generateUserToken({ login: generated.login, name: generated.name }) + expect(result.token).toBe(generated.token) + }) + }) + + describe('projectsSearch', () => { + it('should GET projects/search', async () => { + const project = makeSonarqubeProject() + server.use( + http.get(`${sonarUrl}/api/projects/search`, () => + HttpResponse.json({ paging: makeSonarqubePaging({ total: 1 }), components: [project] })), + ) + const result = await service.searchProject({ q: project.name }) + expect(result.components).toEqual([project]) + }) + }) + + describe('projectsDelete', () => { + it('should POST projects/delete with project key as query param', async () => { + const project = makeSonarqubeProject() + server.use( + http.post(`${sonarUrl}/api/projects/delete`, ({ request }) => { + expect(new URL(request.url).searchParams.get('project')).toBe(project.key) + return HttpResponse.json({}) + }), + ) + await service.deleteProject({ project: project.key }) + }) + }) + + describe('permissionsAddGroup', () => { + it('should POST permissions/add_group with global params', async () => { + const group = { + groupName: '/admin', + permission: 'admin', + } satisfies AddPermissionGroupParams + server.use( + http.post(`${sonarUrl}/api/permissions/add_group`, ({ request }) => { + const params = new URL(request.url).searchParams + expect(params.get('groupName')).toBe(group.groupName) + expect(params.get('permission')).toBe(group.permission) + expect(params.has('projectKey')).toBe(false) + return HttpResponse.json({}) + }), + ) + await service.addPermissionGroup(group) + }) + + it('should POST permissions/add_group with projectKey for project-scoped call', async () => { + server.use( + http.post(`${sonarUrl}/api/permissions/add_group`, ({ request }) => { + expect(new URL(request.url).searchParams.get('projectKey')).toBe('proj-key') + return HttpResponse.json({}) + }), + ) + await service.addPermissionGroup({ groupName: '/proj', permission: 'scan', projectKey: 'proj-key' }) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts new file mode 100644 index 0000000000..7cc63c64a4 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts @@ -0,0 +1,287 @@ +import type { + SONARQUBE_PROJECT_QUALIFIER_APPLICATION, + SONARQUBE_PROJECT_QUALIFIER_BRANCH, + SONARQUBE_PROJECT_QUALIFIER_DIRECTORY, + SONARQUBE_PROJECT_QUALIFIER_FILE, + SONARQUBE_PROJECT_QUALIFIER_LIBRARY, + SONARQUBE_PROJECT_QUALIFIER_PROJECT, + SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW, + SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST, + SONARQUBE_PROJECT_QUALIFIER_VIEW, +} from './sonarqube.constants' +import { Inject, Injectable } from '@nestjs/common' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' + +export interface SonarqubePaging { + pageIndex: number + pageSize: number + total: number +} + +export interface SonarqubeGroup { + id: string + name: string + description: string + membersCount: number + default: boolean +} + +export interface SonarqubeUser { + login: string + name: string + active: boolean + email: string + groups: string[] + tokensCount: number + local: boolean + externalIdentity: string + externalProvider: string + managed: boolean +} + +export type SonarqubeProjectQualifier + = | typeof SONARQUBE_PROJECT_QUALIFIER_APPLICATION + | typeof SONARQUBE_PROJECT_QUALIFIER_BRANCH + | typeof SONARQUBE_PROJECT_QUALIFIER_DIRECTORY + | typeof SONARQUBE_PROJECT_QUALIFIER_FILE + | typeof SONARQUBE_PROJECT_QUALIFIER_LIBRARY + | typeof SONARQUBE_PROJECT_QUALIFIER_PROJECT + | typeof SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST + | typeof SONARQUBE_PROJECT_QUALIFIER_VIEW + | typeof SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW + +export interface SonarqubeProject { + key: string + name: string + qualifier: SonarqubeProjectQualifier + visibility: 'private' | 'public' + lastAnalysisDate?: string + revision?: string +} + +export interface SonarqubeProjectResult { + projectSlug: string + repository: string + key: string +} + +export interface SonarqubeGeneratedToken { + token: string + login: string + name: string +} + +type BaseParams = Record + +export interface SearchUserGroupParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateUserGroupParams extends BaseParams { + name: string + description?: string +} + +export interface CreatePermissionTemplateParams extends BaseParams { + name: string + description?: string + projectKeyPattern?: string +} + +export interface SearchPermissionTemplatesParams extends BaseParams { + q?: string +} + +export interface SetPermissionDefaultTemplateParams extends BaseParams { + templateName: string + projectKeyPattern?: string +} + +export interface AddPermissionProjectCreatorToTemplateParams extends BaseParams { + templateName: string + permission: string +} + +export interface AddPermissionGroupToTemplateParams extends BaseParams { + groupName: string + templateName: string + permission: string +} + +export interface AddPermissionGroupParams extends BaseParams { + groupName: string + permission: string + projectKey?: string +} + +export interface AddPermissionUserParams extends BaseParams { + projectKey: string + permission: string + login: string +} + +export interface SearchUsersParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateUserParams extends BaseParams { + email: string + local: string + login: string + name: string + password: string +} + +export interface DeactivateUserParams extends BaseParams { + login: string + anonymize: boolean +} + +export interface RevokeUserTokenParams extends BaseParams { + login: string + name: string +} + +export interface GenerateUserTokenParams extends BaseParams { + login: string + name: string +} + +export interface SearchProjectParams extends BaseParams { + q?: string + p?: number + ps?: number +} + +export interface CreateProjectParams extends BaseParams { + project: string + visibility: string + name: string + mainbranch: string +} + +export interface DeleteProjectParams extends BaseParams { + project: string +} + +export interface SearchUserGroupResponse { + paging: SonarqubePaging + groups: SonarqubeGroup[] +} + +export interface SonarqubePermissionTemplate { + id: string + name: string + description?: string +} + +export interface SearchPermissionTemplatesResponse { + permissionTemplates: SonarqubePermissionTemplate[] +} + +export interface SearchUsersResponse { + paging: SonarqubePaging + users: SonarqubeUser[] +} + +export interface SearchProjectResponse { + paging: SonarqubePaging + components: SonarqubeProject[] +} + +@Injectable() +export class SonarqubeClientService { + constructor( + @Inject(SonarqubeHttpClientService) private readonly http: SonarqubeHttpClientService, + ) {} + + @StartActiveSpan() + searchUserGroup(params: SearchUserGroupParams) { + return this.http.fetch('user_groups/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createUserGroup(params: CreateUserGroupParams) { + await this.http.fetch('user_groups/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async createPermissionTemplate(params: CreatePermissionTemplateParams) { + await this.http.fetch('permissions/create_template', { method: 'POST', params }) + } + + @StartActiveSpan() + searchPermissionTemplates(params: SearchPermissionTemplatesParams) { + return this.http.fetch('permissions/search_templates', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async setPermissionDefaultTemplate(params: SetPermissionDefaultTemplateParams) { + await this.http.fetch('permissions/set_default_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionProjectCreatorToTemplate(params: AddPermissionProjectCreatorToTemplateParams) { + await this.http.fetch('permissions/add_project_creator_to_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionGroupToTemplate(params: AddPermissionGroupToTemplateParams) { + await this.http.fetch('permissions/add_group_to_template', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionGroup(params: AddPermissionGroupParams) { + await this.http.fetch('permissions/add_group', { method: 'POST', params }) + } + + @StartActiveSpan() + async addPermissionUser(params: AddPermissionUserParams) { + await this.http.fetch('permissions/add_user', { method: 'POST', params }) + } + + @StartActiveSpan() + searchUsers(params: SearchUsersParams) { + return this.http.fetch('users/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createUser(params: CreateUserParams) { + await this.http.fetch('users/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async deactivateUser(params: DeactivateUserParams) { + await this.http.fetch('users/deactivate', { method: 'POST', params }) + } + + @StartActiveSpan() + async revokeUserToken(params: RevokeUserTokenParams) { + await this.http.fetch('user_tokens/revoke', { method: 'POST', params }) + } + + @StartActiveSpan() + generateUserToken(params: GenerateUserTokenParams) { + return this.http.fetch('user_tokens/generate', { method: 'POST', params }).then(res => res.data!) + } + + @StartActiveSpan() + searchProject(params: SearchProjectParams) { + return this.http.fetch('projects/search', { params }).then(res => res.data!) + } + + @StartActiveSpan() + async createProject(params: CreateProjectParams) { + await this.http.fetch('projects/create', { method: 'POST', params }) + } + + @StartActiveSpan() + async deleteProject(params: DeleteProjectParams) { + await this.http.fetch('projects/delete', { method: 'POST', params }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts new file mode 100644 index 0000000000..bcb3143972 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-datastore.service.ts @@ -0,0 +1,68 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { PLUGIN_NAME } from './sonarqube.constants' + +export const projectSelect = { + id: true, + slug: true, + repositories: { + select: { + internalRepoName: true, + }, + }, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class SonarqubeDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: PLUGIN_NAME, + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAdminPluginConfig( + pluginName: string, + key: string, + ): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts new file mode 100644 index 0000000000..8f77620a8c --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -0,0 +1,26 @@ +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +@Injectable() +export class SonarqubeHealthService { + constructor( + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + const headers: Record = {} + const bearerToken = Buffer.from(`${this.sonarqubeConfig.apiToken}:`, 'utf-8').toString('base64') + headers.Authorization = `Bearer ${bearerToken}` + try { + const response = await fetch(this.sonarqubeConfig.probeUrl, { headers }) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts new file mode 100644 index 0000000000..6ee07f8585 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts @@ -0,0 +1,103 @@ +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +export interface SonarqubeFetchOptions { + method?: string + params?: Record +} + +export interface SonarqubeResponse { + status: HttpStatus + data: T | null +} + +export type SonarqubeErrorKind = 'NotConfigured' | 'ClientError' | 'ServerError' | 'Unexpected' + +export class SonarqubeError extends Error { + readonly kind: SonarqubeErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + + constructor( + kind: SonarqubeErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string } = {}, + ) { + super(message) + this.name = 'SonarqubeError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + } +} + +@Injectable() +export class SonarqubeHttpClientService { + constructor( + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + ) {} + + private get apiBaseUrl(): string { + return new URL('api/', this.sonarqubeConfig.internalOrPublicUrl).toString() + } + + private get defaultHeaders(): Record { + if (!this.sonarqubeConfig.apiToken) throw new SonarqubeError('NotConfigured', 'SONAR_API_TOKEN is required') + return { + Authorization: `Bearer ${this.sonarqubeConfig.apiToken}`, + } + } + + async fetch(path: string, options: SonarqubeFetchOptions = {}): Promise> { + const span = trace.getActiveSpan() + const method = (options.method ?? 'GET').toUpperCase() + span?.setAttribute('sonarqube.method', method) + span?.setAttribute('sonarqube.path', path) + + const request = this.createRequest(path, method, options.params) + const response = await fetch(request).catch((error) => { + throw new SonarqubeError('Unexpected', error instanceof Error ? error.message : String(error), { method, path }) + }) + + span?.setAttribute('sonarqube.http.status', response.status) + const result = await handleResponse(response) + if (response.status >= 400) { + const kind = response.status >= 500 ? 'ServerError' : 'ClientError' + throw new SonarqubeError(kind, formatErrorMessage(response.status, result.data), { status: response.status, method, path }) + } + return result + } + + private createRequest(path: string, method: string, params?: Record): Request { + const url = new URL(path, this.apiBaseUrl) + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) url.searchParams.append(key, String(value)) + } + } + return new Request(url.toString(), { method, headers: this.defaultHeaders }) + } +} + +function formatErrorMessage(status: number, data: unknown): string { + const errors = (data as { errors?: { msg?: unknown }[] } | null)?.errors + const details = Array.isArray(errors) + ? errors.map(e => e?.msg).filter((msg): msg is string => typeof msg === 'string').join('; ') + : '' + return details + ? `SonarQube API responded with status ${status}: ${details}` + : `SonarQube API responded with status ${status}` +} + +async function handleResponse(response: Response): Promise> { + if (response.status === HttpStatus.NO_CONTENT) return { status: response.status, data: null } + const contentType = response.headers.get('content-type') ?? '' + const parsed = contentType.includes('application/json') + ? await response.json() + : await response.text() + return { status: response.status, data: parsed as T } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts new file mode 100644 index 0000000000..671a19562a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { SonarqubePluginService } from './sonarqube-plugin.service' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +describe('sonarqubePluginService', () => { + let service: SonarqubePluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://sonar.public/', + internalUrl: 'https://sonar.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + SonarqubePluginService, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(SonarqubePluginService) + }) + + it('returns the public SonarQube url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://sonar.public/projects') + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts new file mode 100644 index 0000000000..d1ffd9387e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts @@ -0,0 +1,22 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Inject, Injectable } from '@nestjs/common' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +@Injectable() +export class SonarqubePluginService { + constructor( + @Inject(SONARQUBE_CONFIG) + private readonly sonarqubeConfig: SonarqubeConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'sonarqube', + to: () => new URL('projects', this.sonarqubeConfig.url).toString(), + title: 'SonarQube', + imgSrc: '/img/sonarqube.svg', + description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts new file mode 100644 index 0000000000..0b7e8c0bbc --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube-testing.utils.ts @@ -0,0 +1,90 @@ +import type { SonarqubeGeneratedToken, SonarqubeGroup, SonarqubePaging, SonarqubeProject, SonarqubeUser } from './sonarqube-client.service' +import type { ProjectWithDetails } from './sonarqube-datastore.service' +import { faker } from '@faker-js/faker' +import { SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' + +export function makeUserToken(overrides: Partial = {}) { + return { + token: faker.string.uuid(), + login: faker.internet.username(), + name: faker.person.fullName(), + ...overrides, + } satisfies SonarqubeGeneratedToken +} + +export function makeEmptyGroupsResponse() { + return { paging: makeSonarqubePaging(), groups: [] } +} + +export function makeEmptyUsersResponse() { + return { paging: makeSonarqubePaging(), users: [] } +} + +export function makeEmptyProjectsResponse() { + return { paging: makeSonarqubePaging(), components: [] } +} + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.internet.domainWord(), + repositories: [], + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + +export function makeSonarqubeGroup(overrides: Partial = {}): SonarqubeGroup { + return { + id: faker.string.uuid(), + name: faker.internet.domainWord(), + description: '', + membersCount: 0, + default: false, + ...overrides, + } satisfies SonarqubeGroup +} + +export function makeSonarqubeUser(overrides: Partial = {}): SonarqubeUser { + return { + login: faker.internet.username(), + name: faker.person.fullName(), + active: true, + email: faker.internet.email(), + groups: [], + tokensCount: 0, + local: true, + externalIdentity: '', + externalProvider: '', + managed: false, + ...overrides, + } satisfies SonarqubeUser +} + +export function makeSonarqubeProject(overrides: Partial = {}): SonarqubeProject { + return { + key: faker.string.alphanumeric(20), + name: faker.internet.domainWord(), + qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, + visibility: 'private', + ...overrides, + } satisfies SonarqubeProject +} + +export function makeSonarqubePaging(overrides: Partial = {}): SonarqubePaging { + return { + pageIndex: 1, + pageSize: 100, + total: 0, + ...overrides, + } satisfies SonarqubePaging +} + +export function makeSonarqubeGeneratedToken(overrides: Partial = {}): SonarqubeGeneratedToken { + return { + token: faker.string.alphanumeric(40), + login: faker.internet.username(), + name: `Sonar Token for ${faker.internet.username()}`, + ...overrides, + } satisfies SonarqubeGeneratedToken +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts new file mode 100644 index 0000000000..9618e9341e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.constants.ts @@ -0,0 +1,51 @@ +export const PLUGIN_NAME = 'sonarqube' +export const DEFAULT_PERMISSION_TEMPLATE_NAME = 'Forge Default' + +// SonarQube global permission names +export const GLOBAL_ADMIN_PERMISSIONS = ['admin', 'profileadmin', 'gateadmin', 'scan', 'provisioning'] as const + +// Permission template — grants to project creator and sonar-administrators on new projects +export const DEFAULT_TEMPLATE_PERMISSIONS = ['admin', 'codeviewer', 'issueadmin', 'securityhotspotadmin', 'scan', 'user'] as const + +// Project-level permission sets per role (SonarQube permission API names) +export const PROJECT_ADMIN_PERMISSIONS = ['admin', 'scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_DEVOPS_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_DEVELOPER_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_SECURITY_PERMISSIONS = ['scan', 'user', 'codeviewer', 'issueadmin', 'securityhotspotadmin'] as const +export const PROJECT_READONLY_PERMISSIONS = ['user', 'codeviewer'] as const + +// CI robot/service account — needs Execute Analysis + Browse + See Source Code +export const ROBOT_PROJECT_PERMISSIONS = ['scan', 'user', 'codeviewer'] as const + +// Default platform-wide Keycloak group paths +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_READONLY_GROUP_PATH = '/console/readonly' +export const DEFAULT_SECURITY_GROUP_PATH = '/console/security' + +// Default project role group path suffixes (appended to /{projectSlug}) +export const DEFAULT_PROJECT_ADMIN_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVOPS_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_SECURITY_SUFFIX = '/console/security' +export const DEFAULT_PROJECT_READONLY_SUFFIX = '/console/readonly' + +// Admin plugin config keys for overriding defaults +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const READONLY_GROUP_PATH_PLUGIN_KEY = 'readonlyGroupPath' +export const SECURITY_GROUP_PATH_PLUGIN_KEY = 'securityGroupPath' +export const PROJECT_ADMIN_SUFFIX_PLUGIN_KEY = 'projectAdminSuffix' +export const PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY = 'projectDevopsSuffix' +export const PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY = 'projectDeveloperSuffix' +export const PROJECT_SECURITY_SUFFIX_PLUGIN_KEY = 'projectSecuritySuffix' +export const PROJECT_READONLY_SUFFIX_PLUGIN_KEY = 'projectReadonlySuffix' + +// SonarQube project qualifier identifiers +export const SONARQUBE_PROJECT_QUALIFIER_APPLICATION = 'APP' +export const SONARQUBE_PROJECT_QUALIFIER_BRANCH = 'BRC' +export const SONARQUBE_PROJECT_QUALIFIER_DIRECTORY = 'DIR' +export const SONARQUBE_PROJECT_QUALIFIER_FILE = 'FIL' +export const SONARQUBE_PROJECT_QUALIFIER_LIBRARY = 'LIB' +export const SONARQUBE_PROJECT_QUALIFIER_PROJECT = 'TRK' +export const SONARQUBE_PROJECT_QUALIFIER_SUB_VIEW = 'SVW' +export const SONARQUBE_PROJECT_QUALIFIER_UNIT_TEST = 'UTS' +export const SONARQUBE_PROJECT_QUALIFIER_VIEW = 'VW' diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts new file mode 100644 index 0000000000..ef4dac617c --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts @@ -0,0 +1,16 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface SonarqubeConfig { + url: string + internalUrl: string + apiToken: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface SonarqubeModuleOptions extends SonarqubeConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('sonarqube') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as SONARQUBE_CONFIG } diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts new file mode 100644 index 0000000000..f054302ff4 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common' +import { TerminusModule } from '@nestjs/terminus' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultModule } from '../vault/vault.module' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { SonarqubeHealthService } from './sonarqube-health.service' +import { SonarqubeHttpClientService } from './sonarqube-http-client.service' +import { SonarqubePluginService } from './sonarqube-plugin.service' +import { ConfigurableModuleClass } from './sonarqube.module-definition' +import { SonarqubeService } from './sonarqube.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, VaultModule], + providers: [ + SonarqubeHealthService, + SonarqubeHttpClientService, + SonarqubeClientService, + SonarqubeDatastoreService, + SonarqubePluginService, + SonarqubeService, + ], + exports: [SonarqubeClientService, SonarqubeHealthService, SonarqubePluginService, SonarqubeService], +}) +export class SonarqubeModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts new file mode 100644 index 0000000000..e7d0ef90e1 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -0,0 +1,291 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { generateProjectKey } from '../../utils/crypto.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { makeVaultSecret } from '../vault/vault-testing.utils' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { + makeEmptyGroupsResponse, + makeEmptyProjectsResponse, + makeEmptyUsersResponse, + makeProjectWithDetails, + makeSonarqubePaging, + makeSonarqubeUser, + makeUserToken, +} from './sonarqube-testing.utils' +import { PLUGIN_NAME, SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' +import { SonarqubeService } from './sonarqube.service' + +describe('sonarqubeService', () => { + let service: SonarqubeService + let client: DeepMockProxy + let datastore: DeepMockProxy + let vault: DeepMockProxy + let config: DeepMockProxy + + beforeEach(async () => { + client = mockDeep({ + searchUserGroup: vi.fn().mockResolvedValue(makeEmptyGroupsResponse()), + createUserGroup: vi.fn().mockResolvedValue(undefined), + createPermissionTemplate: vi.fn().mockResolvedValue(undefined), + searchPermissionTemplates: vi.fn().mockResolvedValue({ permissionTemplates: [] }), + setPermissionDefaultTemplate: vi.fn().mockResolvedValue(undefined), + addPermissionGroupToTemplate: vi.fn().mockResolvedValue(undefined), + addPermissionGroup: vi.fn().mockResolvedValue(undefined), + addPermissionUser: vi.fn().mockResolvedValue(undefined), + searchUsers: vi.fn().mockResolvedValue(makeEmptyUsersResponse()), + createUser: vi.fn().mockResolvedValue(undefined), + deactivateUser: vi.fn().mockResolvedValue(undefined), + revokeUserToken: vi.fn().mockResolvedValue(undefined), + searchProject: vi.fn().mockResolvedValue(makeEmptyProjectsResponse()), + createProject: vi.fn().mockResolvedValue(undefined), + deleteProject: vi.fn().mockResolvedValue(undefined), + }) + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + vault = mockDeep({ + readSonarqubeUser: vi.fn().mockResolvedValue(null), + writeSonarqubeUser: vi.fn().mockResolvedValue(undefined), + deleteSonarqubeUser: vi.fn().mockResolvedValue(undefined), + }) + config = mockDeep({ + internalOrPublicUrl: 'https://sonarqube.internal', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + SonarqubeService, + { provide: SonarqubeClientService, useValue: client }, + { provide: SonarqubeDatastoreService, useValue: datastore }, + { provide: VaultClientService, useValue: vault }, + { provide: SONARQUBE_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(SonarqubeService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('init', () => { + it('should set up the permission template', async () => { + await service.init() + expect(client.createPermissionTemplate).toHaveBeenCalledWith({ name: 'Forge Default' }) + expect(client.setPermissionDefaultTemplate).toHaveBeenCalledWith({ templateName: 'Forge Default' }) + }) + + it('should not recreate the permission template when it already exists', async () => { + client.searchPermissionTemplates.mockResolvedValue({ + permissionTemplates: [{ id: '1', name: 'Forge Default' }], + }) + await service.init() + expect(client.createPermissionTemplate).not.toHaveBeenCalled() + expect(client.setPermissionDefaultTemplate).toHaveBeenCalledWith({ templateName: 'Forge Default' }) + }) + + it('should create /console/admin group with global permissions when it does not exist', async () => { + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/admin' })) + }) + + it('should create /console/readonly and /console/security platform groups', async () => { + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/readonly' })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/console/security' })) + }) + + it('should not create groups that already exist', async () => { + client.searchUserGroup.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 1 }), + groups: [{ id: '1', name: '/console/admin', description: '', membersCount: 1, default: false }], + }) + await service.init() + expect(client.createUserGroup).not.toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) + }) + + it('should use custom group paths from admin plugin config', async () => { + datastore.getAdminPluginConfig.mockImplementation((_plugin, key) => { + if (key === 'adminGroupPath') return Promise.resolve('/custom/admin') + return Promise.resolve(null) + }) + await service.init() + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: '/custom/admin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/custom/admin' })) + }) + }) + + describe('handleUpsert', () => { + it('should create the 5 project role groups in SonarQube', async () => { + const project = makeProjectWithDetails() + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/admin` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/devops` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/developer` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/security` })) + expect(client.createUserGroup).toHaveBeenCalledWith(expect.objectContaining({ name: `/${project.slug}/console/readonly` })) + }) + + it('should create a new user and write vault credentials', async () => { + const project = makeProjectWithDetails() + const userToken = makeUserToken({ login: project.slug }) + client.generateUserToken.mockResolvedValue(userToken) + + await service.handleUpsert(project) + + expect(client.createUser).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(client.generateUserToken).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(vault.writeSonarqubeUser).toHaveBeenCalledWith(project.slug, expect.objectContaining({ SONAR_USERNAME: project.slug, SONAR_TOKEN: userToken.token })) + }) + + it('should set role-based permissions on new repositories', async () => { + const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'repo' }] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.createProject).toHaveBeenCalledWith(expect.objectContaining({ visibility: 'private', name: `${project.slug}-repo` })) + expect(client.addPermissionUser).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/admin` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/devops` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/readonly' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: '/console/security' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer`, permission: 'issueadmin' })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/developer`, permission: 'securityhotspotadmin' })) + }) + + it('should not recreate user or write vault when both user and secret exist', async () => { + const project = makeProjectWithDetails({ slug: 'existing', repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.createUser).not.toHaveBeenCalled() + expect(client.generateUserToken).not.toHaveBeenCalled() + expect(vault.writeSonarqubeUser).not.toHaveBeenCalled() + }) + + it('should rotate token when user exists but vault secret is missing', async () => { + const project = makeProjectWithDetails({ repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + + await service.handleUpsert(project) + + expect(client.createUser).not.toHaveBeenCalled() + expect(client.generateUserToken).toHaveBeenCalledWith(expect.objectContaining({ login: project.slug })) + expect(vault.writeSonarqubeUser).toHaveBeenCalledWith(project.slug, expect.objectContaining({ SONAR_PASSWORD: 'not initialized' })) + }) + + it('should delete sonarqube projects for removed repositories', async () => { + const project = makeProjectWithDetails({ repositories: [{ internalRepoName: 'kept' }] }) + const keptKey = generateProjectKey(project.slug, 'kept') + const removedKey = generateProjectKey(project.slug, 'removed') + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 2 }), + components: [ + { key: keptKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + { key: removedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + ], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.deleteProject).toHaveBeenCalledWith({ project: removedKey }) + expect(client.deleteProject).not.toHaveBeenCalledWith({ project: keptKey }) + }) + + it('should not delete sonarqube projects whose key was not generated by the console', async () => { + const project = makeProjectWithDetails({ slug: 'my', repositories: [] }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 2 }), + components: [ + // manually created project, hash suffix does not match generateProjectKey + { key: 'my-manual-project', name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + // belongs to project "my-app" (repo "x"), not to project "my" + { key: generateProjectKey('my-app', 'x'), name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }, + ], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: project.slug })] }) + vault.readSonarqubeUser.mockResolvedValue(makeVaultSecret({ data: { SONAR_USERNAME: project.slug, SONAR_PASSWORD: 'pw', SONAR_TOKEN: 'tok' } })) + + await service.handleUpsert(project) + + expect(client.deleteProject).not.toHaveBeenCalled() + }) + + it('should use comma-separated group path suffixes from project plugin config', async () => { + const project = makeProjectWithDetails({ + repositories: [{ internalRepoName: 'repo' }], + plugins: [{ pluginName: PLUGIN_NAME, key: 'projectAdminSuffix', value: '/console/admin,/console/owner' }], + }) + client.generateUserToken.mockResolvedValue(makeUserToken({ login: project.slug })) + + await service.handleUpsert(project) + + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/admin` })) + expect(client.addPermissionGroup).toHaveBeenCalledWith(expect.objectContaining({ groupName: `/${project.slug}/console/owner` })) + }) + }) + + describe('handleDelete', () => { + it('should delete sonarqube projects, anonymize user and remove vault entry', async () => { + const project = makeProjectWithDetails({ slug: 'doomed' }) + const doomedKey = generateProjectKey('doomed', 'repo') + client.searchProject.mockResolvedValue({ + paging: makeSonarqubePaging({ total: 1 }), + components: [{ key: doomedKey, name: '', qualifier: SONARQUBE_PROJECT_QUALIFIER_PROJECT, visibility: 'private' }], + }) + client.searchUsers.mockResolvedValue({ paging: makeSonarqubePaging({ total: 1 }), users: [makeSonarqubeUser({ login: 'doomed' })] }) + + await service.handleDelete(project) + + expect(client.deleteProject).toHaveBeenCalledWith({ project: doomedKey }) + expect(client.deactivateUser).toHaveBeenCalledWith({ login: 'doomed', anonymize: true }) + expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('doomed') + }) + + it('should skip anonymization when the user does not exist', async () => { + const project = makeProjectWithDetails({ slug: 'no-user' }) + + await service.handleDelete(project) + + expect(client.deactivateUser).not.toHaveBeenCalled() + expect(vault.deleteSonarqubeUser).toHaveBeenCalledWith('no-user') + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects and run init', async () => { + const projects = [ + makeProjectWithDetails({ repositories: [] }), + makeProjectWithDetails({ repositories: [] }), + ] + datastore.getAllProjects.mockResolvedValue(projects) + client.generateUserToken.mockImplementation(({ login }) => Promise.resolve(makeUserToken({ login }))) + + await service.handleCron() + + expect(client.searchProject).toHaveBeenCalledTimes(2) + expect(client.createPermissionTemplate).toHaveBeenCalledOnce() + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts new file mode 100644 index 0000000000..a4500dcca9 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -0,0 +1,456 @@ +import type { OnModuleInit } from '@nestjs/common' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { SonarqubeUserSecret } from '../vault/vault-client.service' +import type { SonarqubeProjectResult, SonarqubeUser } from './sonarqube-client.service' +import type { ProjectWithDetails } from './sonarqube-datastore.service' +import type { SonarqubeConfig } from './sonarqube.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { generateProjectKey, generateRandomPassword } from '../../utils/crypto.utils' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from '../vault/vault-client.service' +import { SonarqubeClientService } from './sonarqube-client.service' +import { SonarqubeDatastoreService } from './sonarqube-datastore.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_PERMISSION_TEMPLATE_NAME, + DEFAULT_PROJECT_ADMIN_SUFFIX, + DEFAULT_PROJECT_DEVELOPER_SUFFIX, + DEFAULT_PROJECT_DEVOPS_SUFFIX, + DEFAULT_PROJECT_READONLY_SUFFIX, + DEFAULT_PROJECT_SECURITY_SUFFIX, + DEFAULT_READONLY_GROUP_PATH, + DEFAULT_SECURITY_GROUP_PATH, + DEFAULT_TEMPLATE_PERMISSIONS, + GLOBAL_ADMIN_PERMISSIONS, + PLUGIN_NAME, + PROJECT_ADMIN_PERMISSIONS, + PROJECT_ADMIN_SUFFIX_PLUGIN_KEY, + PROJECT_DEVELOPER_PERMISSIONS, + PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_PERMISSIONS, + PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY, + PROJECT_READONLY_PERMISSIONS, + PROJECT_READONLY_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_PERMISSIONS, + PROJECT_SECURITY_SUFFIX_PLUGIN_KEY, + READONLY_GROUP_PATH_PLUGIN_KEY, + ROBOT_PROJECT_PERMISSIONS, + SECURITY_GROUP_PATH_PLUGIN_KEY, +} from './sonarqube.constants' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' + +interface SonarqubeRolePaths { + admin: string[] + devops: string[] + developer: string[] + security: string[] + readonly: string[] +} + +@Injectable() +export class SonarqubeService implements OnModuleInit { + private readonly logger = new Logger(SonarqubeService.name) + + constructor( + @Inject(SonarqubeDatastoreService) private readonly datastore: SonarqubeDatastoreService, + @Inject(SonarqubeClientService) private readonly client: SonarqubeClientService, + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, + @Inject(VaultClientService) private readonly vault: VaultClientService, + ) { + this.logger.log('SonarqubeService initialized') + } + + async onModuleInit() { + await this.init().catch(error => this.logger.error({ error }, 'SonarQube initialization failed')) + } + + @StartActiveSpan() + async init(): Promise { + this.logger.log('Initializing SonarQube platform configuration') + const adminGroupPath = await this.getAdminGroupPath() + const [readonlyGroupPath, securityGroupPath] = await Promise.all([ + this.getReadonlyGroupPath(), + this.getSecurityGroupPath(), + ]) + await this.ensureDefaultPermissionTemplate() + await Promise.all([ + this.ensureGroupWithGlobalPermissions(adminGroupPath, GLOBAL_ADMIN_PERMISSIONS), + this.ensureGroup(readonlyGroupPath), + this.ensureGroup(securityGroupPath), + ]) + this.logger.log('SonarQube platform configuration initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('sonarqube', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProjectGroup(project) + this.logger.log(`SonarQube sync completed for project ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('sonarqube', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await this.deleteProjectGroup(project) + this.logger.log(`SonarQube deletion completed for project ${project.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting SonarQube reconciliation') + await this.init().catch(error => this.logger.error('SonarQube init during cron failed', error)) + const projects = await this.datastore.getAllProjects() + span?.setAttribute('sonarqube.projects.count', projects.length) + this.logger.log(`Loaded ${projects.length} projects for SonarQube reconciliation`) + await this.ensureProjectGroups(projects) + this.logger.log(`SonarQube reconciliation completed (${projects.length})`) + } + + @StartActiveSpan() + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('sonarqube.projects.count', projects.length) + await Promise.all(projects.map(p => + this.ensureProjectGroup(p).catch(error => + this.logger.error(`Failed to reconcile SonarQube project (slug=${p.slug})`, error), + ), + )) + } + + @StartActiveSpan() + private async ensureProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + const rolePaths = await this.getProjectRoleGroupPaths(project) + await Promise.all([ + this.ensureUser(project.slug, project.slug), + this.ensureProjectSonarGroups(rolePaths), + this.ensureProjectRepositories(project, rolePaths), + ]) + } + + @StartActiveSpan() + private async deleteProjectGroup(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + + const sonarProjects = await this.findProjectsForSlug(project.slug) + span?.setAttribute('sonarqube.projects.count', sonarProjects.length) + this.logger.log(`Deleting ${sonarProjects.length} SonarQube repositories for project ${project.slug}`) + + await Promise.all(sonarProjects.map(async (sp) => { + await this.client.deleteProject({ project: sp.key }) + this.logger.verbose(`Deleted SonarQube repository (key=${sp.key})`) + })) + + const user = await this.findUser(project.slug) + if (user) { + await this.client.deactivateUser({ login: project.slug, anonymize: true }) + this.logger.log(`Anonymized SonarQube user (login=${project.slug})`) + } else { + this.logger.verbose(`SonarQube user not found, skipping anonymization (login=${project.slug})`) + } + + await this.vault.deleteSonarqubeUser(project.slug) + this.logger.verbose(`Deleted SonarQube vault credentials (slug=${project.slug})`) + } + + @StartActiveSpan() + private async ensureDefaultPermissionTemplate(): Promise { + this.logger.verbose(`Ensuring SonarQube permission template (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + const { permissionTemplates } = await this.client.searchPermissionTemplates({ q: DEFAULT_PERMISSION_TEMPLATE_NAME }) + + if (permissionTemplates.some(t => t.name.toLowerCase() === DEFAULT_PERMISSION_TEMPLATE_NAME.toLowerCase())) { + this.logger.verbose(`SonarQube permission template already exists (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } else { + await this.client.createPermissionTemplate({ name: DEFAULT_PERMISSION_TEMPLATE_NAME }) + this.logger.log(`Created SonarQube permission template (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } + await Promise.all(DEFAULT_TEMPLATE_PERMISSIONS.map(permission => + this.client.addPermissionProjectCreatorToTemplate({ templateName: DEFAULT_PERMISSION_TEMPLATE_NAME, permission }), + )) + await Promise.all(DEFAULT_TEMPLATE_PERMISSIONS.map(permission => + this.client.addPermissionGroupToTemplate({ groupName: 'sonar-administrators', templateName: DEFAULT_PERMISSION_TEMPLATE_NAME, permission }), + )) + await this.client.setPermissionDefaultTemplate({ templateName: DEFAULT_PERMISSION_TEMPLATE_NAME }) + this.logger.log(`SonarQube permission template ensured (name=${DEFAULT_PERMISSION_TEMPLATE_NAME})`) + } + + @StartActiveSpan() + private async ensureUser(username: string, projectSlug: string): Promise { + const existingSecret = await this.vault.readSonarqubeUser(projectSlug) + const user = await this.findUser(username) + let newSecret: SonarqubeUserSecret | undefined + + if (!user) { + this.logger.log(`Creating SonarQube user (login=${username})`) + const password = generateRandomPassword(30) + await this.client.createUser({ email: `${projectSlug}@${projectSlug}`, local: 'true', login: username, name: username, password }) + const token = await this.rotateToken(username) + newSecret = { SONAR_USERNAME: username, SONAR_PASSWORD: password, SONAR_TOKEN: token } + } else if (existingSecret) { + this.logger.verbose(`SonarQube user already exists with vault credentials (login=${username})`) + } else { + this.logger.warn(`SonarQube user exists but vault secret is missing, rotating token (login=${username})`) + const token = await this.rotateToken(username) + newSecret = { SONAR_USERNAME: username, SONAR_PASSWORD: 'not initialized', SONAR_TOKEN: token } + } + + if (newSecret) { + await this.vault.writeSonarqubeUser(projectSlug, newSecret) + this.logger.log(`Stored SonarQube credentials in vault (slug=${projectSlug})`) + } + } + + private async ensureProjectSonarGroups(rolePaths: SonarqubeRolePaths): Promise { + const allGroups = [ + ...rolePaths.admin, + ...rolePaths.devops, + ...rolePaths.developer, + ...rolePaths.security, + ...rolePaths.readonly, + ] + await Promise.all(allGroups.map(group => this.ensureGroup(group))) + } + + @StartActiveSpan() + private async ensureProjectRepositories(project: ProjectWithDetails, rolePaths: SonarqubeRolePaths): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('repositories.count', project.repositories.length) + + const [readonlyGroupPath, securityGroupPath, existingSonarProjects] = await Promise.all([ + this.getReadonlyGroupPath(), + this.getSecurityGroupPath(), + this.findProjectsForSlug(project.slug), + ]) + + const orphans = existingSonarProjects.filter(sp => !project.repositories.some(r => r.internalRepoName === sp.repository)) + if (orphans.length) this.logger.log(`Removing ${orphans.length} orphan SonarQube repositories for project ${project.slug}`) + + await Promise.all([ + ...orphans.map(async (sp) => { + await this.client.deleteProject({ project: sp.key }) + this.logger.verbose(`Deleted orphan SonarQube repository (key=${sp.key})`) + }), + ...project.repositories.map(async (repository) => { + const projectKey = generateProjectKey(project.slug, repository.internalRepoName) + if (!existingSonarProjects.some(sp => sp.repository === repository.internalRepoName)) { + await this.client.createProject({ + project: projectKey, + visibility: 'private', + name: `${project.slug}-${repository.internalRepoName}`, + mainbranch: 'main', + }) + this.logger.log(`Created SonarQube repository (key=${projectKey})`) + } + await this.ensureProjectPermissions(projectKey, project.slug, rolePaths, readonlyGroupPath, securityGroupPath) + this.logger.verbose(`Ensured permissions on SonarQube repository (key=${projectKey})`) + }), + ]) + } + + private async ensureProjectPermissions( + projectKey: string, + login: string, + rolePaths: SonarqubeRolePaths, + readonlyGroupPath: string, + securityGroupPath: string, + ): Promise { + await Promise.all([ + ...ROBOT_PROJECT_PERMISSIONS.map(permission => + this.client.addPermissionUser({ projectKey, permission, login }), + ), + ...buildGroupPermissions(rolePaths, readonlyGroupPath, securityGroupPath).flatMap(({ groupName, permissions }) => + permissions.map(permission => this.client.addPermissionGroup({ projectKey, permission, groupName })), + ), + ]) + } + + private async ensureGroupWithGlobalPermissions(groupName: string, permissions: readonly string[]): Promise { + await this.ensureGroup(groupName) + await Promise.all(permissions.map(permission => + this.client.addPermissionGroup({ groupName, permission }), + )) + } + + private async ensureGroup(groupName: string): Promise { + const result = await this.client.searchUserGroup({ q: groupName }) + if (result.groups.some(g => g.name === groupName)) { + this.logger.verbose(`SonarQube group already exists (name=${groupName})`) + } else { + await this.client.createUserGroup({ name: groupName }) + this.logger.log(`Created SonarQube group (name=${groupName})`) + } + } + + private async rotateToken(login: string): Promise { + const name = `Sonar Token for ${login}` + await this.client.revokeUserToken({ login, name }).catch(() => {}) + const { token } = await this.client.generateUserToken({ login, name }) + this.logger.log(`Rotated SonarQube token (login=${login})`) + return token + } + + private async getAdminGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, ADMIN_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getReadonlyGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, READONLY_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_READONLY_GROUP_PATH + } + + private async getSecurityGroupPath(): Promise { + const config = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, SECURITY_GROUP_PATH_PLUGIN_KEY) + return config ?? DEFAULT_SECURITY_GROUP_PATH + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return getProjectPluginConfig(project, key) ?? undefined + } + + private async getProjectRoleGroupPaths(project: ProjectWithDetails): Promise { + const [admin, devops, developer, security, readonly] = await Promise.all([ + this.getProjectAdminGroupPaths(project), + this.getProjectDevopsGroupPaths(project), + this.getProjectDeveloperGroupPaths(project), + this.getProjectSecurityGroupPaths(project), + this.getProjectReadonlyGroupPaths(project), + ]) + return { admin, devops, developer, security, readonly } + } + + private async getProjectAdminGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_ADMIN_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_ADMIN_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_ADMIN_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDevopsGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVOPS_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVOPS_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectDeveloperGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_DEVELOPER_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectSecurityGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_SECURITY_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_SECURITY_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_SECURITY_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async getProjectReadonlyGroupPaths(project: ProjectWithDetails): Promise { + const projectConfig = getProjectPluginConfig(project, PROJECT_READONLY_SUFFIX_PLUGIN_KEY) + const globalConfig = await this.getAdminOrProjectPluginConfig(project, PROJECT_READONLY_SUFFIX_PLUGIN_KEY) + const raw = projectConfig ?? globalConfig ?? DEFAULT_PROJECT_READONLY_SUFFIX + return generateProjectRoleGroupPath(project.slug, raw) + } + + private async findUser(login: string): Promise { + let page = 1 + const pageSize = 100 + while (true) { + const response = await this.client.searchUsers({ q: login, ps: pageSize, p: page }) + const found = response.users.find(u => u.login === login) + if (found) return found + if (!response.users.length || response.paging.pageIndex * response.paging.pageSize >= response.paging.total) return undefined + page++ + } + } + + private async findProjectsForSlug(projectSlug: string): Promise { + let found: SonarqubeProjectResult[] = [] + let page = 0 + const pageSize = 100 + let total = 0 + do { + page++ + const result = await this.client.searchProject({ q: projectSlug, p: page, ps: pageSize }) + total = result.paging.total + found = [...found, ...filterProjectsOwningSlug(result.components, projectSlug)] + } while (page * pageSize < total) + return found + } +} + +function getProjectPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +function generateProjectRoleGroupPath(projectSlug: string, rawGroupPathSuffixes: string): string[] { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(suffix => `/${projectSlug}${suffix}`) +} + +function buildGroupPermissions( + rolePaths: SonarqubeRolePaths, + readonlyGroupPath: string, + securityGroupPath: string, +): { groupName: string, permissions: readonly string[] }[] { + return [ + ...rolePaths.admin.map(groupName => ({ groupName, permissions: PROJECT_ADMIN_PERMISSIONS })), + ...rolePaths.devops.map(groupName => ({ groupName, permissions: PROJECT_DEVOPS_PERMISSIONS })), + ...rolePaths.developer.map(groupName => ({ groupName, permissions: PROJECT_DEVELOPER_PERMISSIONS })), + ...rolePaths.security.map(groupName => ({ groupName, permissions: PROJECT_SECURITY_PERMISSIONS })), + ...rolePaths.readonly.map(groupName => ({ groupName, permissions: PROJECT_READONLY_PERMISSIONS })), + { groupName: securityGroupPath, permissions: PROJECT_SECURITY_PERMISSIONS }, + { groupName: readonlyGroupPath, permissions: PROJECT_READONLY_PERMISSIONS }, + ] +} + +function filterProjectsOwningSlug( + components: { key: string }[], + projectSlug: string, +): SonarqubeProjectResult[] { + return components.reduce((acc, { key: sonarKey }) => { + const parts = sonarKey.split('-') + parts.pop() + for (let i = parts.length - 1; i > 0; i--) { + const project = parts.slice(0, i).join('-') + const repository = parts.slice(i).join('-') + // recompute the key (with its HMAC suffix) so keys not managed by the + // console, or owned by a project whose slug is a prefix, are never claimed + if (generateProjectKey(project, repository) === sonarKey && project === projectSlug) { + acc.push({ projectSlug, repository, key: sonarKey }) + break + } + } + return acc + }, []) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts new file mode 100644 index 0000000000..8b8fc8adff --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/sonarqube/sonarqube.utils.ts @@ -0,0 +1,6 @@ +export function sonarProjectPropertiesFile(projectKey: string) { + return [ + `sonar.projectKey=${projectKey}`, + 'sonar.qualitygate.wait=true', + ] +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts new file mode 100644 index 0000000000..f4450b21d7 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config-queries.utils.ts @@ -0,0 +1,11 @@ +import type { Prisma } from '@prisma/client' + +export const adminPluginSelect = { + pluginName: true, + key: true, + value: true, +} satisfies Prisma.AdminPluginSelect + +export type AdminPluginSelect = Prisma.AdminPluginGetPayload<{ + select: typeof adminPluginSelect +}> diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config.controller.ts new file mode 100644 index 0000000000..7f74d4d40d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config.controller.ts @@ -0,0 +1,27 @@ +import type { PluginsUpdateBody } from '@cpn-console/shared' +import { pluginUpdateBody } from '@cpn-console/shared' +import { Body, Controller, Get, HttpCode, HttpStatus, Inject, Post, UseGuards } from '@nestjs/common' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { SystemConfigService } from './system-config.service' + +@Controller('api/v1/system/plugins') +export class SystemConfigController { + constructor(@Inject(SystemConfigService) private readonly service: SystemConfigService) {} + + @Get() + @UseGuards(UserGuard) + @RequireAdminPermission('ListSystem') + async get() { + return this.service.list() + } + + @Post() + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageSystem') + async update(@Body(new ZodValidationPipe(pluginUpdateBody)) body: PluginsUpdateBody) { + await this.service.update(body) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config.module.ts new file mode 100644 index 0000000000..b0622abf84 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { SystemConfigController } from './system-config.controller' +import { SystemConfigService } from './system-config.service' + +@Module({ + imports: [DatabaseModule, UserPermissionModule], + controllers: [SystemConfigController], + providers: [SystemConfigService], +}) +export class SystemConfigModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts new file mode 100644 index 0000000000..0ba0742991 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config.service.spec.ts @@ -0,0 +1,41 @@ +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { SystemConfigService } from './system-config.service' + +describe('systemConfigService', () => { + let prisma: ReturnType> + let service: SystemConfigService + + beforeEach(() => { + prisma = mockDeep() + service = new SystemConfigService(prisma) + }) + + it('returns plugin config items', async () => { + prisma.adminPlugin.findMany.mockResolvedValue([ + { pluginName: 'argocd', key: 'url', value: 'https://argocd' }, + ]) + + const result = await service.list() + expect(result).toBeDefined() + expect(Array.isArray(result)).toBe(true) + }) + + it('updates plugins config from body via transaction', async () => { + prisma.$transaction.mockImplementation(async (callback: any) => callback(prisma)) + prisma.adminPlugin.upsert.mockResolvedValue({ pluginName: 'argocd', key: 'url', value: 'https://argocd' }) + + await service.update({ argocd: { url: 'https://argocd' } }) + + expect(prisma.$transaction).toHaveBeenCalledTimes(1) + }) + + it('does not call upsert for empty body', async () => { + prisma.$transaction.mockImplementation(async (callback: any) => callback(prisma)) + + await service.update({}) + + expect(prisma.adminPlugin.upsert).toHaveBeenCalledTimes(0) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config.service.ts new file mode 100644 index 0000000000..100cee22bc --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-config/system-config.service.ts @@ -0,0 +1,46 @@ +import type { PluginsUpdateBody } from '@cpn-console/shared' +import type { PrismaService } from '../infrastructure/database/prisma.service' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { BadRequestException, Injectable } from '@nestjs/common' + +@Injectable() +export class SystemConfigService { + constructor(private readonly prisma: PrismaService) {} + + async list() { + const globalConfig = await this.prisma.adminPlugin.findMany({ + select: { key: true, value: true, pluginName: true }, + }) + + return Object.values(servicesInfos).map(({ name, title, imgSrc, description }) => { + const manifest = populatePluginManifests({ + data: { global: globalConfig }, + permissionTarget: 'admin', + pluginName: name, + select: { global: true, project: false }, + }) + return { imgSrc, title, name, manifest: manifest.global ?? [], description } + }).filter(plugin => plugin.manifest.length > 0) + } + + async update(body: PluginsUpdateBody) { + const parsedData = editStrippers.global.safeParse(body) + if (!parsedData.success) { + throw new BadRequestException(parsedData.error.message) + } + + const records = Object.entries(parsedData.data) + .flatMap(([pluginName, values]: [string, Record]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value: String(value) }))) + + await this.prisma.$transaction(async (tx) => { + for (const { pluginName, key, value } of records) { + await tx.adminPlugin.upsert({ + create: { pluginName, key, value }, + update: { key, value, pluginName }, + where: { pluginName_key: { pluginName, key } }, + }) + } + }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts new file mode 100644 index 0000000000..bdc5e91f25 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings-testing.utils.ts @@ -0,0 +1,15 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' + +export function makeSystemSetting() { + return { + key: faker.string.alphanumeric(), + value: faker.string.alphanumeric(), + } satisfies SystemSetting +} + +export function makeSystemSettings() { + return faker.helpers.multiple(() => makeSystemSetting(), { + count: faker.number.int({ min: 1, max: 10 }), + }) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts new file mode 100644 index 0000000000..9644dc97f0 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts @@ -0,0 +1,24 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { SystemSettingSchema } from '@cpn-console/shared' +import { Body, Controller, Get, Inject, Put, Query } from '@nestjs/common' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { SystemSettingsService } from './system-settings.service' + +@Controller('api/v1/system/settings') +export class SystemSettingsController { + constructor(@Inject(SystemSettingsService) private readonly service: SystemSettingsService) {} + + @Get() + async list( + @Query() query: string, + ) { + return this.service.list(query) + } + + @Put(':key') + async upsert( + @Body(new ZodValidationPipe(SystemSettingSchema)) data: SystemSetting, + ) { + return this.service.upsert(data) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts new file mode 100644 index 0000000000..704b575e14 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { SystemSettingsController } from './system-settings.controller' +import { SystemSettingsService } from './system-settings.service' + +@Module({ + imports: [DatabaseModule], + controllers: [SystemSettingsController], + providers: [SystemSettingsService], + exports: [SystemSettingsService], +}) +export class SystemSettingsModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts new file mode 100644 index 0000000000..ae9ec6e161 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings.service.spec.ts @@ -0,0 +1,84 @@ +import type { TestingModule } from '@nestjs/testing' +import type { PrismaClient } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeSystemSetting, makeSystemSettings } from './system-settings-testing.utils' +import { SystemSettingsService } from './system-settings.service' + +describe('systemSettingsService', () => { + let module: TestingModule + let service: SystemSettingsService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + SystemSettingsService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = module.get(SystemSettingsService) + }) + + describe('list', () => { + it('should return all settings', async () => { + const systemSettings = makeSystemSettings() + + prisma.systemSetting.findMany.mockResolvedValue(systemSettings) + + const result = await service.list() + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: undefined } }) + expect(result).toEqual(systemSettings) + }) + + it('should return empty array if no settings are found', async () => { + prisma.systemSetting.findMany.mockResolvedValue([]) + + const result = await service.list() + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: undefined } }) + expect(result).toEqual([]) + }) + + it('should return setting by key', async () => { + const systemSettings = makeSystemSettings() + const systemSetting = faker.helpers.arrayElement(systemSettings) + prisma.systemSetting.findMany.mockResolvedValue([systemSetting]) + + const result = await service.list(systemSetting.key) + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key: systemSetting.key } }) + expect(result).toEqual([systemSetting]) + }) + + it('should return empty array if key is not found', async () => { + const key = faker.string.alphanumeric(10) + prisma.systemSetting.findMany.mockResolvedValue([]) + + const result = await service.list(key) + + expect(prisma.systemSetting.findMany).toHaveBeenCalledWith({ where: { key } }) + expect(result).toEqual([]) + }) + }) + + describe('upsert', () => { + it('should update setting if it exists', async () => { + const systemSetting = makeSystemSetting() + prisma.systemSetting.upsert.mockResolvedValue(systemSetting) + + const result = await service.upsert(systemSetting) + + expect(result.key).toBe(systemSetting.key) + expect(result.value).toBe(systemSetting.value) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts new file mode 100644 index 0000000000..06c5e72603 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/system-settings/system-settings.service.ts @@ -0,0 +1,20 @@ +import type { SystemSetting } from '@cpn-console/shared' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +@Injectable() +export class SystemSettingsService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async list(key?: string): Promise { + return this.prisma.systemSetting.findMany({ where: { key } }) + } + + async upsert(data: SystemSetting): Promise { + return this.prisma.systemSetting.upsert({ + create: data, + update: data, + where: { key: data.key }, + }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts new file mode 100644 index 0000000000..d6cce4272f --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts @@ -0,0 +1,109 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus } from '@nestjs/common' +import { Test } from '@nestjs/testing' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { VaultClientService } from './vault-client.service' +import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { VAULT_CONFIG } from './vault.module-definition' + +const vaultUrl = 'https://vault.internal' + +const server = setupServer( + http.post(`${vaultUrl}/v1/auth/token/create`, () => { + return HttpResponse.json({ auth: { client_token: 'token' } }) + }), + http.get(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ data: { data: { secret: 'value' }, metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 } } }) + }), + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({}) + }), + http.delete(`${vaultUrl}/v1/kv/metadata/:path`, () => { + return new HttpResponse(null, { status: HttpStatus.NO_CONTENT }) + }), +) + +describe('vault', () => { + let service: VaultClientService + + beforeAll(() => server.listen()) + beforeEach(async () => { + const config = mockDeep({ + token: 'token', + url: vaultUrl, + internalUrl: vaultUrl, + kvName: 'kv', + internalOrPublicUrl: vaultUrl, + }) + const baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + + const module = await Test.createTestingModule({ + providers: [ + VaultClientService, + VaultHttpClientService, + { provide: VAULT_CONFIG, useValue: config }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(VaultClientService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + describe('read', () => { + it('should read secret', async () => { + const result = await service.read('path') + expect(result).toEqual({ + data: { secret: 'value' }, + metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 }, + }) + }) + + it('should throw if 404', async () => { + server.use( + http.get(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({}, { status: HttpStatus.NOT_FOUND }) + }), + ) + + await expect(service.read('path')).rejects.toBeInstanceOf(VaultError) + await expect(service.read('path')).rejects.toMatchObject({ kind: 'NotFound', status: HttpStatus.NOT_FOUND }) + }) + }) + + describe('write', () => { + it('should write secret', async () => { + await expect(service.write({ secret: 'value' }, 'path')).resolves.toBeUndefined() + }) + + it('should expose reasons on error', async () => { + server.use( + http.post(`${vaultUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ errors: ['No secret engine mount at test-project/'] }, { status: HttpStatus.BAD_REQUEST }) + }), + ) + + await expect(service.write({ secret: 'value' }, 'path')).rejects.toBeInstanceOf(VaultError) + await expect(service.write({ secret: 'value' }, 'path')).rejects.toMatchObject({ + kind: 'HttpError', + status: HttpStatus.BAD_REQUEST, + reasons: ['No secret engine mount at test-project/'], + }) + await expect(service.write({ secret: 'value' }, 'path')).rejects.toThrow('Request failed') + }) + }) + + describe('delete', () => { + it('should delete secret', async () => { + await expect(service.delete('path')).resolves.toBeUndefined() + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-client.service.ts new file mode 100644 index 0000000000..dd07abebbd --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -0,0 +1,410 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { VAULT_CONFIG } from './vault.module-definition' +import { generateGitlabMirrorCredPath, generateSonarqubeCredPath, generateTechReadOnlyCredPath } from './vault.utils' + +export interface VaultSysPoliciesAclUpsertRequest { + policy: string +} + +export interface VaultSysMountCreateRequest { + type: string + config: { + force_no_cache: boolean + } + options: { + version: number + } +} + +export interface VaultSysMountTuneRequest { + options: { + version: number + } +} + +export interface VaultAuthApproleRoleUpsertRequest { + secret_id_num_uses: string + secret_id_ttl: string + token_max_ttl: string + token_num_uses: string + token_ttl: string + token_type: string + token_policies: string[] +} + +export interface VaultIdentityGroupUpsertRequest { + name: string + type: string + policies: string[] +} + +export interface VaultIdentityGroupAliasCreateRequest { + name: string + mount_accessor: string + canonical_id: string +} + +export interface VaultAuthMethod { + accessor: string + type: string + description?: string +} + +export interface VaultSysAuthResponse { + data: Record +} + +export interface VaultIdentityGroupResponse { + data: { + id: string + name: string + alias?: { + id?: string + name?: string + } + } +} + +export interface SonarqubeUserSecret { + SONAR_USERNAME: string + SONAR_PASSWORD: string + SONAR_TOKEN: string +} + +export interface VaultMetadata { + created_time: string + custom_metadata: Record | null + deletion_time: string + destroyed: boolean + version: number +} + +export interface VaultSecret { + data: T + metadata: VaultMetadata +} + +export interface VaultResponse { + data: VaultSecret +} + +export interface VaultListResponse { + data: { + keys: string[] + } +} + +export interface VaultRoleIdResponse { + data: { + role_id: string + } +} + +export interface VaultSecretIdResponse { + data: { + secret_id: string + } +} + +@Injectable() +export class VaultClientService { + private readonly logger = new Logger(VaultClientService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultHttpClientService) private readonly http: VaultHttpClientService, + ) { + } + + @StartActiveSpan() + async getKvData(kvName: string, path: string): Promise> { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Reading Vault KV data (kvName=${kvName}, path=${path})`) + const response = await this.http.fetch>(`${kvName}/data/${path}`) + if (!response?.data) { + throw new VaultError('InvalidResponse', 'Missing "data" field', { method: 'GET', path: `${kvName}/data/${path}` }) + } + return response.data + } + + @StartActiveSpan() + async upsertKvData(kvName: string, path: string, body: { data: T }): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Writing Vault KV data (kvName=${kvName}, path=${path})`) + await this.http.fetch(`${kvName}/data/${path}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async read(path: string): Promise> { + this.logger.debug(`Reading Vault KV secret at ${path}`) + return await this.getKvData(this.vaultConfig.kvName, path) + } + + @StartActiveSpan() + async write(data: T, path: string): Promise { + this.logger.debug(`Writing Vault KV secret at ${path}`) + await this.upsertKvData(this.vaultConfig.kvName, path, { data }) + } + + @StartActiveSpan() + async delete(path: string): Promise { + this.logger.debug(`Deleting Vault KV secret at ${path}`) + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.path', path) + return await this.deleteKvMetadata(this.vaultConfig.kvName, path) + } + + @StartActiveSpan() + async readGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Reading Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + return await this.read(vaultCredsPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeGitlabMirrorCreds(projectSlug: string, repoName: string, data: Record): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Writing Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + await this.write(data, vaultCredsPath) + } + + @StartActiveSpan() + async deleteGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) + this.logger.verbose(`Deleting Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) + await this.delete(vaultCredsPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + }) + } + + @StartActiveSpan() + async readTechnReadOnlyCreds(projectSlug: string): Promise { + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + return await this.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeTechReadOnlyCreds(projectSlug: string, creds: Record): Promise { + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + await this.write(creds, vaultPath) + } + + @StartActiveSpan() + async readSonarqubeUser(projectSlug: string): Promise | null> { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Reading Vault SonarQube user credentials (projectSlug=${projectSlug})`) + return await this.read(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return null + throw error + }) + } + + @StartActiveSpan() + async writeSonarqubeUser(projectSlug: string, secret: SonarqubeUserSecret): Promise { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Writing Vault SonarQube user credentials (projectSlug=${projectSlug})`) + await this.write(secret, vaultPath) + } + + @StartActiveSpan() + async deleteSonarqubeUser(projectSlug: string): Promise { + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) + this.logger.verbose(`Deleting Vault SonarQube user credentials (projectSlug=${projectSlug})`) + await this.delete(vaultPath).catch((error) => { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + }) + } + + @StartActiveSpan() + async writeMirrorTriggerToken(secret: Record): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.path', 'GITLAB') + await this.write(secret, 'GITLAB') + } + + @StartActiveSpan() + async deleteKvMetadata(kvName: string, path: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + try { + await this.http.fetch(`${kvName}/metadata/${path}`, { method: 'DELETE' }) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + } + + @StartActiveSpan() + async listKvMetadata(kvName: string, path: string): Promise { + try { + const span = trace.getActiveSpan() + span?.setAttribute('vault.kv.name', kvName) + span?.setAttribute('vault.kv.path', path) + this.logger.verbose(`Listing Vault KV metadata (kvName=${kvName}, path=${path})`) + const response = await this.http.fetch(`${kvName}/metadata/${path}`, { method: 'LIST' }) + if (!response?.data?.keys) { + throw new VaultError('InvalidResponse', 'Missing "data.keys" field', { method: 'LIST', path: `${kvName}/metadata/${path}` }) + } + return response.data.keys + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return [] + throw error + } + } + + @StartActiveSpan() + async upsertSysPoliciesAcl(policyName: string, body: VaultSysPoliciesAclUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault ACL policy ${policyName}`) + await this.http.fetch(`sys/policies/acl/${policyName}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSysPoliciesAcl(policyName: string): Promise { + this.logger.verbose(`Deleting Vault ACL policy ${policyName}`) + await this.http.fetch(`sys/policies/acl/${policyName}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async createSysMount(name: string, body: VaultSysMountCreateRequest): Promise { + this.logger.verbose(`Creating Vault mount ${name} (version=${body.options.version})`) + await this.http.fetch(`sys/mounts/${name}`, { method: 'POST', body }) + } + + @StartActiveSpan() + async tuneSysMount(name: string, body: VaultSysMountTuneRequest): Promise { + this.logger.verbose(`Tuning Vault mount ${name} (version=${body.options.version})`) + await this.http.fetch(`sys/mounts/${name}/tune`, { method: 'POST', body }) + } + + @StartActiveSpan() + async deleteSysMounts(name: string): Promise { + this.logger.verbose(`Deleting Vault mount ${name}`) + await this.http.fetch(`sys/mounts/${name}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async upsertAuthApproleRole(roleName: string, body: VaultAuthApproleRoleUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault AppRole ${roleName} (policies=${body.token_policies.length})`) + await this.http.fetch(`auth/approle/role/${roleName}`, { + method: 'POST', + body, + }) + } + + @StartActiveSpan() + async deleteAuthApproleRole(roleName: string): Promise { + this.logger.verbose(`Deleting Vault AppRole ${roleName}`) + await this.http.fetch(`auth/approle/role/${roleName}`, { method: 'DELETE' }) + } + + async getAuthApproleRoleRoleId(roleName: string) { + const path = `auth/approle/role/${roleName}/role-id` + this.logger.verbose(`Reading Vault AppRole role-id for ${roleName}`) + const response = await this.http.fetch(path) + const roleId = response?.data?.role_id + if (!roleId) { + throw new VaultError('InvalidResponse', `Vault role-id not found for role ${roleName}`, { method: 'GET', path }) + } + return roleId + } + + @StartActiveSpan() + async createAuthApproleRoleSecretId(roleName: string) { + const path = `auth/approle/role/${roleName}/secret-id` + this.logger.verbose(`Creating Vault AppRole secret-id for ${roleName}`) + const response = await this.http.fetch(path, { method: 'POST' }) + const secretId = response?.data?.secret_id + if (!secretId) { + throw new VaultError('InvalidResponse', `Vault secret-id not generated for role ${roleName}`, { method: 'POST', path }) + } + return secretId + } + + async getSysAuth(): Promise> { + this.logger.verbose('Listing Vault auth methods') + const response = await this.http.fetch('sys/auth') + return response?.data ?? {} + } + + @StartActiveSpan() + async upsertIdentityGroupName(groupName: string, body: VaultIdentityGroupUpsertRequest): Promise { + this.logger.verbose(`Upserting Vault identity group ${groupName} (policies=${body.policies.length})`) + await this.http.fetch(`identity/group/name/${groupName}`, { + method: 'POST', + body, + }) + } + + @StartActiveSpan() + async getIdentityGroupName(groupName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.name', groupName) + const path = `identity/group/name/${groupName}` + const response = await this.http.fetch(path) + if (!response) throw new VaultError('InvalidResponse', 'Empty response', { method: 'GET', path }) + return response + } + + @StartActiveSpan() + async deleteIdentityGroupName(groupName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.name', groupName) + this.logger.verbose(`Deleting Vault identity group ${groupName}`) + await this.http.fetch(`identity/group/name/${groupName}`, { method: 'DELETE' }) + } + + @StartActiveSpan() + async createIdentityGroupAlias(body: VaultIdentityGroupAliasCreateRequest): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('vault.identity.group.alias', body.name) + this.logger.verbose(`Creating Vault identity group alias (aliasName=${body.name}, canonicalId=${body.canonical_id})`) + await this.http.fetch('identity/group-alias', { method: 'POST', body }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts new file mode 100644 index 0000000000..dfb48d9a71 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts @@ -0,0 +1,84 @@ +import type { Prisma } from '@prisma/client' +import { Inject, Injectable } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + environments: { + select: { + id: true, + clusterId: true, + cpu: true, + memory: true, + autosync: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export const zoneSelect = { + id: true, + slug: true, + clusters: { + select: { + projects: { + select: { + id: true, + }, + }, + }, + }, +} satisfies Prisma.ZoneSelect + +export type ZoneWithDetails = Prisma.ZoneGetPayload<{ + select: typeof zoneSelect +}> + +@Injectable() +export class VaultDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ where: { id }, select: projectSelect }) + } + + async getAdminPluginConfig(pluginName: string, key: string): Promise { + const result = await this.prisma.adminPlugin.findUnique({ + where: { + pluginName_key: { + pluginName, + key, + }, + }, + select: { + value: true, + }, + }) + return result?.value ?? null + } + + async getAllZones(): Promise { + return this.prisma.zone.findMany({ + select: zoneSelect, + }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-health.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-health.service.ts new file mode 100644 index 0000000000..daddabd5b4 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-health.service.ts @@ -0,0 +1,23 @@ +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus, Inject, Injectable } from '@nestjs/common' +import { HealthIndicatorService } from '@nestjs/terminus' +import { VAULT_CONFIG } from './vault.module-definition' + +@Injectable() +export class VaultHealthService { + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, + ) {} + + async check(key: string) { + const indicator = this.healthIndicator.check(key) + try { + const response = await fetch(this.vaultConfig.probeUrl) + if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) + return indicator.down({ httpStatus: response.status }) + } catch (error) { + return indicator.down(error instanceof Error ? error.message : String(error)) + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts new file mode 100644 index 0000000000..2a80532efe --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts @@ -0,0 +1,135 @@ +import type { VaultConfig } from './vault.module-definition' +import { HttpStatus, Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import z from 'zod' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VAULT_CONFIG } from './vault.module-definition' + +export interface VaultFetchOptions { + method?: string + body?: unknown +} + +export type VaultErrorKind + = | 'NotConfigured' + | 'NotFound' + | 'HttpError' + | 'InvalidResponse' + | 'ParseError' + | 'Unexpected' + +export class VaultError extends Error { + readonly kind: VaultErrorKind + readonly status?: HttpStatus + readonly method?: string + readonly path?: string + readonly statusText?: string + readonly reasons?: string[] + + constructor( + kind: VaultErrorKind, + message: string, + details: { status?: HttpStatus, method?: string, path?: string, statusText?: string, reasons?: string[] } = {}, + ) { + super(message) + this.name = 'VaultError' + this.kind = kind + this.status = details.status + this.method = details.method + this.path = details.path + this.statusText = details.statusText + this.reasons = details.reasons + } +} + +@Injectable() +export class VaultHttpClientService { + private readonly logger = new Logger(VaultHttpClientService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + ) {} + + @StartActiveSpan() + async fetch( + path: string, + options: VaultFetchOptions = {}, + ): Promise { + const span = trace.getActiveSpan() + const method = options.method ?? 'GET' + span?.setAttribute('vault.method', method) + span?.setAttribute('vault.path', path) + + this.logger.verbose(`Starting Vault request: ${method} ${path}`) + this.logger.debug(`Vault request: ${method} ${path}`) + const request = this.createRequest(path, method, options.body) + const response = await fetch(request).catch((error) => { + if (error instanceof Error) { + this.logger.error(`Vault request failed: ${method} ${path}: ${error.message}`, error.stack) + } else { + this.logger.error(`Vault request failed: ${method} ${path}: ${String(error)}`) + } + throw new VaultError( + 'Unexpected', + error instanceof Error ? error.message : String(error), + { method, path }, + ) + }) + span?.setAttribute('vault.http.status', response.status) + this.logger.debug(`Vault response: ${method} ${path} status=${response.status}`) + + const parsed = await this.handleResponse(response, method, path) + this.logger.verbose(`Completed Vault request: ${method} ${path} status=${response.status} outcome=${parsed === null ? 'no-content' : 'ok'}`) + return parsed + } + + private get apiBaseUrl() { + return new URL('v1/', this.vaultConfig.internalOrPublicUrl).toString() + } + + private createRequest(path: string, method: string, body?: unknown): Request { + const url = new URL(path, this.apiBaseUrl).toString() + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Vault-Token': this.vaultConfig.token, + } + + return new Request(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }) + } + + private async handleResponse(response: Response, method: string, path: string): Promise { + if (response.status === HttpStatus.NO_CONTENT) return null + + if (!response.ok) { + await this.throwForStatus(response, method, path) + } + + return await response.json() + } + + private async throwForStatus(response: Response, method: string, path: string): Promise { + const responseBody = await response.json() + const vaultErrorBody = z.object({ errors: z.array(z.string()) }).safeParse(responseBody) + const reasons = vaultErrorBody.success ? vaultErrorBody.data.errors : undefined + const reasonsPart = reasons?.length ? ` reasons=${reasons.join('; ')}` : '' + this.logger.warn(`Vault request returned error: ${method} ${path} status=${response.status} statusText=${response.statusText}${reasonsPart}`) + + if (response.status === 404) { + throw new VaultError('NotFound', 'Not Found', { + status: 404, + method, + path, + statusText: response.statusText, + reasons, + }) + } + + throw new VaultError('HttpError', 'Request failed', { + status: response.status, + method, + path, + statusText: response.statusText, + reasons, + }) + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts new file mode 100644 index 0000000000..a1d5e06eea --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts @@ -0,0 +1,36 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { VaultConfig } from './vault.module-definition' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { makeToUrlParams } from '../plugin/plugin.utils' +import { VaultPluginService } from './vault-plugin.service' +import { VAULT_CONFIG } from './vault.module-definition' + +describe('vaultPluginService', () => { + let service: VaultPluginService + let config: DeepMockProxy + + beforeEach(async () => { + config = mockDeep({ + url: 'https://vault.public/', + internalUrl: 'https://vault.internal/', + }) + + const moduleRef = await Test.createTestingModule({ + providers: [ + VaultPluginService, + { provide: VAULT_CONFIG, useValue: config }, + ], + }).compile() + + service = moduleRef.get(VaultPluginService) + }) + + it('returns the public Vault url', () => { + const infos = service.infos() + const url = infos.to?.(makeToUrlParams()) + + expect(url).toBe('https://vault.public/ui/vault/secrets/dulei') + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts new file mode 100644 index 0000000000..0095ac34cf --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts @@ -0,0 +1,21 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable } from '@nestjs/common' +import { VAULT_CONFIG } from './vault.module-definition' + +@Injectable() +export class VaultPluginService { + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + ) {} + + infos(): ServiceInfos { + return { + name: 'vault', + to: ({ project }) => new URL(`ui/vault/secrets/${project.slug}`, this.vaultConfig.url).toString(), + title: 'Vault', + imgSrc: '/img/vault.svg', + description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', + } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts new file mode 100644 index 0000000000..2397b97bdc --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault-testing.utils.ts @@ -0,0 +1,43 @@ +import type { VaultSecret } from './vault-client.service' +import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import { faker } from '@faker-js/faker' + +export function makeProjectWithDetails(overrides: Partial = {}): ProjectWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-project-${faker.string.uuid()}`), + name: faker.company.name(), + description: faker.company.buzzPhrase(), + environments: [], + plugins: [], + ...overrides, + } satisfies ProjectWithDetails +} + +export function makeZoneWithDetails(overrides: Partial = {}): ZoneWithDetails { + return { + id: faker.string.uuid(), + slug: faker.helpers.slugify(`test-zone-${faker.string.uuid()}`), + clusters: [], + ...overrides, + } satisfies ZoneWithDetails +} + +export function makeVaultSecret(overrides: Partial = {}): VaultSecret { + return { + data: {}, + metadata: makeVaultSecretMetadata(), + ...overrides, + } satisfies VaultSecret +} + +export function makeVaultSecretMetadata(overrides: Partial = {}): VaultSecret['metadata'] { + return { + created_time: faker.date.soon().toISOString(), + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + ...overrides, + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.constants.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.constants.ts new file mode 100644 index 0000000000..22adfa3275 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.constants.ts @@ -0,0 +1,34 @@ +// Plugin name used to identify the Vault integration +export const PLUGIN_NAME = 'vault' + +// Platform-level policy names for RBAC +export const PLATFORM_ADMIN_POLICY_NAME = 'platform--admin' +export const PLATFORM_READONLY_POLICY_NAME = 'platform--readonly' +export const PLATFORM_SECURITY_POLICY_NAME = 'platform--security' + +// Console group names for user role assignment +export const CONSOLE_ADMIN_GROUP_NAME = 'console-admin' +export const CONSOLE_READONLY_GROUP_NAME = 'console-readonly' +export const CONSOLE_SECURITY_GROUP_NAME = 'console-security' + +// Default LDAP/AD group paths for console access +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly' +export const DEFAULT_SECURITY_GROUP_PATH = '/console/security' + +// Default suffixes for project-scoped group paths +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly' +export const DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX = '/console/security' + +// Plugin configuration keys for custom group paths +export const ADMIN_GROUP_PATH_PLUGIN_KEY = 'adminGroupPath' +export const AUDITOR_GROUP_PATH_PLUGIN_KEY = 'auditorGroupPath' +export const SECURITY_GROUP_PATH_PLUGIN_KEY = 'securityGroupPath' +export const PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectReporterGroupPathSuffix' +export const PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDeveloperGroupPathSuffix' +export const PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectDevopsGroupPathSuffix' +export const PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectMaintainerGroupPathSuffix' +export const PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY = 'projectSecurityGroupPathSuffix' diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.module-definition.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.module-definition.ts new file mode 100644 index 0000000000..d0f1b425cf --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.module-definition.ts @@ -0,0 +1,17 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface VaultConfig { + token?: string + url: string + internalUrl: string + kvName: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface VaultModuleOptions extends VaultConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('vault') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as VAULT_CONFIG } diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.module.ts new file mode 100644 index 0000000000..367a1527a2 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { VaultHealthService } from './vault-health.service' +import { VaultHttpClientService } from './vault-http-client.service' +import { VaultPluginService } from './vault-plugin.service' +import { ConfigurableModuleClass } from './vault.module-definition' +import { VaultService } from './vault.service' + +@Module({ + imports: [DatabaseModule, TerminusModule, ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], + providers: [ + VaultHealthService, + VaultHttpClientService, + VaultClientService, + VaultPluginService, + VaultService, + VaultDatastoreService, + ], + exports: [VaultClientService, VaultHealthService, VaultPluginService, VaultService], +}) +export class VaultModule extends ConfigurableModuleClass {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.service.spec.ts new file mode 100644 index 0000000000..d46cb7b75c --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -0,0 +1,153 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.module' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { makeProjectWithDetails, makeVaultSecret, makeZoneWithDetails } from './vault-testing.utils' +import { VAULT_CONFIG } from './vault.module-definition' +import { VaultService } from './vault.service' + +const projectRoleGroupNameRegex = /^project-(.*)-(admin|devops|developer|readonly|security)$/ + +describe('vaultService', () => { + let service: VaultService + let datastore: DeepMockProxy + let client: DeepMockProxy + + beforeEach(async () => { + datastore = mockDeep({ + getAdminPluginConfig: vi.fn().mockResolvedValue(null), + }) + client = mockDeep({ + createSysMount: vi.fn().mockResolvedValue(undefined), + tuneSysMount: vi.fn().mockResolvedValue(undefined), + deleteSysMounts: vi.fn().mockResolvedValue(undefined), + upsertSysPoliciesAcl: vi.fn().mockResolvedValue(undefined), + deleteSysPoliciesAcl: vi.fn().mockResolvedValue(undefined), + upsertAuthApproleRole: vi.fn().mockResolvedValue(undefined), + deleteAuthApproleRole: vi.fn().mockResolvedValue(undefined), + getIdentityGroupName: vi.fn(async (groupName: string) => makeVaultSecret({ data: { id: 'gid', name: groupName } })), + deleteIdentityGroupName: vi.fn().mockResolvedValue(undefined), + getSysAuth: vi.fn().mockResolvedValue({ 'oidc/': { accessor: 'oidc-accessor', type: 'oidc' } }), + createIdentityGroupAlias: vi.fn().mockResolvedValue(undefined), + listKvMetadata: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + }) + + const vaultConfig = mockDeep({ kvName: 'kv' }) + const baseConfig = mockDeep({ projectsRootDir: 'forge' }) + + const module = await Test.createTestingModule({ + providers: [ + VaultService, + { provide: VaultClientService, useValue: client }, + { provide: VaultDatastoreService, useValue: datastore }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + ], + }).compile() + + service = module.get(VaultService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should reconcile on cron', async () => { + const projects = faker.helpers.multiple(() => makeProjectWithDetails()) + const zones = faker.helpers.multiple(() => makeZoneWithDetails()) + + datastore.getAllProjects.mockResolvedValue(projects) + datastore.getAllZones.mockResolvedValue(zones) + + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(datastore.getAllZones).toHaveBeenCalled() + expect(client.createSysMount).toHaveBeenCalledTimes(projects.length + zones.length) + projects.forEach((project) => { + expect(client.createSysMount).toHaveBeenCalledWith(project.slug, expect.any(Object)) + }) + zones.forEach((zone) => { + expect(client.createSysMount).toHaveBeenCalledWith(`zone-${zone.slug}`, expect.any(Object)) + }) + }) + + it('should upsert project on event', async () => { + const project = makeProjectWithDetails() + + client.getIdentityGroupName.mockImplementation(async (groupName: string) => { + const projectRoleMatch = groupName.match(projectRoleGroupNameRegex) + if (projectRoleMatch) { + const projectSlug = projectRoleMatch[1] + const role = projectRoleMatch[2] + return { data: { id: 'gid', name: groupName, alias: { name: `/${projectSlug}/console/${role}` } } } + } + + if (groupName === 'console-admin') return { data: { id: 'gid', name: groupName, alias: { name: '/console/admin' } } } + if (groupName === 'console-readonly') return { data: { id: 'gid', name: groupName, alias: { name: '/console/readonly' } } } + if (groupName === 'console-security') return { data: { id: 'gid', name: groupName, alias: { name: '/console/security' } } } + + return { data: { id: 'gid', name: groupName } } + }) + + await service.handleUpsert(project) + + expect(client.createSysMount).toHaveBeenCalledWith(project.slug, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`app--${project.slug}--admin`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`tech--${project.slug}--ro`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--devops`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--developer`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--readonly`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--security`, expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--admin', expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--readonly', expect.any(Object)) + expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith('platform--security', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-admin', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-readonly', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith('console-security', expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-admin`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-devops`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-developer`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`, expect.any(Object)) + expect(client.upsertIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`, expect.any(Object)) + expect(client.createIdentityGroupAlias).not.toHaveBeenCalled() + + const policyCalls = client.upsertSysPoliciesAcl.mock.calls as Array<[string, { policy: string }]> + expect(policyCalls.find(([name]) => name === `project--${project.slug}--developer`)?.[1].policy) + .toBe(`path "${project.slug}/data/*" { capabilities = ["list"] }`) + expect(policyCalls.find(([name]) => name === `project--${project.slug}--readonly`)?.[1].policy) + .toBe(`path "${project.slug}/data/*" { capabilities = ["list"] }`) + expect(policyCalls.find(([name]) => name === `project--${project.slug}--security`)?.[1].policy) + .toBe([ + `path "${project.slug}/metadata/*" { capabilities = ["list"] }`, + `path "transit/keys/${project.slug}/*" { capabilities = ["list"] }`, + ].join('\n')) + }) + + it('should delete project and destroy secrets on event', async () => { + const project = makeProjectWithDetails() + + await service.handleDelete(project) + + expect(client.deleteSysMounts).toHaveBeenCalledWith(project.slug) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`app--${project.slug}--admin`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`tech--${project.slug}--ro`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--devops`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--developer`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--readonly`) + expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`project--${project.slug}--security`) + expect(client.deleteAuthApproleRole).toHaveBeenCalledWith(project.slug) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-admin`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-devops`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-developer`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`) + expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.service.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.service.ts new file mode 100644 index 0000000000..7128c24963 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -0,0 +1,594 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import type { VaultConfig } from './vault.module-definition' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { trace } from '@opentelemetry/api' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { capturePluginResult } from '../plugin/plugin.utils' +import { VaultClientService } from './vault-client.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { VaultError } from './vault-http-client.service' +import { + ADMIN_GROUP_PATH_PLUGIN_KEY, + AUDITOR_GROUP_PATH_PLUGIN_KEY, + CONSOLE_ADMIN_GROUP_NAME, + CONSOLE_READONLY_GROUP_NAME, + CONSOLE_SECURITY_GROUP_NAME, + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX, + DEFAULT_SECURITY_GROUP_PATH, + PLATFORM_ADMIN_POLICY_NAME, + PLATFORM_READONLY_POLICY_NAME, + PLATFORM_SECURITY_POLICY_NAME, + PLUGIN_NAME, + PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, + PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY, + SECURITY_GROUP_PATH_PLUGIN_KEY, +} from './vault.constants' +import { VAULT_CONFIG } from './vault.module-definition' +import { generateProjectPath } from './vault.utils' + +type ProjectScope = 'admin' | 'devops' | 'developer' | 'readonly' | 'security' + +@Injectable() +export class VaultService { + private readonly logger = new Logger(VaultService.name) + + constructor( + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultDatastoreService) private readonly datastore: VaultDatastoreService, + @Inject(VaultClientService) private readonly client: VaultClientService, + ) { + this.logger.log('VaultService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails): Promise> { + return capturePluginResult('vault', () => this.syncProject(project)) + } + + @StartActiveSpan() + private async syncProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project upsert event for ${project.slug}`) + await this.ensureProject(project) + this.logger.log(`Vault project sync completed for ${project.slug}`) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails): Promise> { + return capturePluginResult('vault', () => this.cleanupProject(project)) + } + + @StartActiveSpan() + private async cleanupProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.log(`Handling a project delete event for ${project.slug}`) + await Promise.all([ + this.deleteProject(project), + this.deleteProjectSecrets(project.slug), + ]) + this.logger.log(`Vault project cleanup completed for ${project.slug}`) + } + + @OnEvent('zone.upsert') + async handleUpsertZone(zone: ZoneWithDetails): Promise> { + return capturePluginResult('vault', () => this.syncZone(zone)) + } + + @StartActiveSpan() + private async syncZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.log(`Handling a zone upsert event for ${zone.slug}`) + await this.ensureZone(zone) + this.logger.log(`Vault zone sync completed for ${zone.slug}`) + } + + @OnEvent('zone.delete') + async handleDeleteZone(zone: ZoneWithDetails): Promise> { + return capturePluginResult('vault', () => this.cleanupZone(zone)) + } + + @StartActiveSpan() + private async cleanupZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.log(`Handling a zone delete event for ${zone.slug}`) + await this.deleteZone(zone.slug) + this.logger.log(`Vault zone cleanup completed for ${zone.slug}`) + } + + // @Cron(CronExpression.EVERY_HOUR) + @StartActiveSpan() + async handleCron() { + const span = trace.getActiveSpan() + this.logger.log('Starting Vault reconciliation') + const [projects, zones] = await Promise.all([ + this.datastore.getAllProjects(), + this.datastore.getAllZones(), + ]) + + span?.setAttributes({ + 'vault.projects.count': projects.length, + 'vault.zones.count': zones.length, + }) + this.logger.log(`Loaded state for Vault reconciliation (projects=${projects.length}, zones=${zones.length})`) + await Promise.all([ + this.ensureProjects(projects), + this.ensureZones(zones), + ]) + this.logger.log(`Vault reconciliation completed (projects=${projects.length} zones=${zones.length})`) + } + + @StartActiveSpan() + private async ensureProjects(projects: ProjectWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('vault.projects.count', projects.length) + this.logger.verbose(`Reconciling Vault projects (count=${projects.length})`) + await Promise.all(projects.map(p => this.ensureProject(p))) + } + + @StartActiveSpan() + private async ensureProject(project: ProjectWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + this.logger.verbose(`Reconciling Vault project ${project.slug}`) + await this.upsertProject(project) + } + + private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) + if (adminPluginConfig) return adminPluginConfig + return project.plugins?.find(p => p.pluginName === PLUGIN_NAME && p.key === key)?.value + } + + private async getAdminGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, ADMIN_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_ADMIN_GROUP_PATH + } + + private async getAuditorGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, AUDITOR_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_AUDITOR_GROUP_PATH + } + + private async getSecurityGroupPath(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, SECURITY_GROUP_PATH_PLUGIN_KEY) ?? DEFAULT_SECURITY_GROUP_PATH + } + + private async getProjectMaintainerGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_MAINTAINER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX + } + + private async getProjectDevopsGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVOPS_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX + } + + private async getProjectDeveloperGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_DEVELOPER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + } + + private async getProjectReporterGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX + } + + private async getProjectSecurityGroupPathSuffix(project: ProjectWithDetails) { + return await this.getAdminOrProjectPluginConfig(project, PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY) ?? DEFAULT_PROJECT_SECURITY_GROUP_PATH_SUFFIX + } + + @StartActiveSpan() + private async ensureZones(zones: ZoneWithDetails[]) { + const span = trace.getActiveSpan() + span?.setAttribute('vault.zones.count', zones.length) + this.logger.verbose(`Reconciling Vault zones (count=${zones.length})`) + await Promise.all(zones.map(z => this.ensureZone(z))) + } + + @StartActiveSpan() + private async ensureZone(zone: ZoneWithDetails) { + const span = trace.getActiveSpan() + span?.setAttribute('zone.slug', zone.slug) + this.logger.verbose(`Reconciling Vault zone ${zone.slug}`) + await this.upsertZone(zone.slug) + } + + private async upsertMount(kvName: string): Promise { + const createBody = { + type: 'kv', + config: { + force_no_cache: true, + }, + options: { + version: 2, + }, + } + const tuneBody = { + options: { + version: 2, + }, + } + try { + await this.client.createSysMount(kvName, createBody) + this.logger.log(`Created Vault mount ${kvName}`) + } catch (error) { + if (error instanceof VaultError && error.kind === 'HttpError' && error.status === 400) { + await this.client.tuneSysMount(kvName, tuneBody) + this.logger.log(`Vault mount ${kvName} already existed, so it was tuned to the expected settings`) + return + } + throw error + } + } + + private async deleteMount(kvName: string): Promise { + try { + await this.client.deleteSysMounts(kvName) + this.logger.log(`Deleted Vault mount ${kvName}`) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') { + this.logger.warn(`Vault mount ${kvName} was already missing`) + return + } + throw error + } + } + + @StartActiveSpan() + async upsertZone(zoneName: string): Promise { + const kvName = generateZoneName(zoneName) + const span = trace.getActiveSpan() + span?.setAttribute('zone.name', zoneName) + span?.setAttribute('vault.kv.name', kvName) + const policyName = generateZoneTechReadOnlyPolicyName(zoneName) + + await this.upsertMount(kvName) + await this.client.upsertSysPoliciesAcl(policyName, { + policy: `path "${kvName}/*" { capabilities = ["read"] }`, + }) + await this.client.upsertAuthApproleRole(kvName, generateApproleRoleBody([policyName])) + } + + @StartActiveSpan() + async deleteZone(zoneName: string): Promise { + const kvName = generateZoneName(zoneName) + const span = trace.getActiveSpan() + span?.setAttribute('zone.name', zoneName) + span?.setAttribute('vault.kv.name', kvName) + const policyName = generateZoneTechReadOnlyPolicyName(zoneName) + const roleName = kvName + + await this.deleteMount(kvName) + + const settled = await Promise.allSettled([ + this.client.deleteSysPoliciesAcl(policyName), + this.client.deleteAuthApproleRole(roleName), + ]) + + for (const result of settled) { + if (result.status !== 'rejected') continue + const error = result.reason + if (error instanceof VaultError && error.kind === 'NotFound') continue + throw error + } + } + + @StartActiveSpan() + async upsertProject(project: ProjectWithDetails): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('vault.kv.name', project.slug) + const appPolicyName = generateAppAdminPolicyName(project) + const techPolicyName = generateTechReadOnlyPolicyName(project) + const projectDevopsPolicyName = generateProjectPolicyName(project, 'devops') + const projectDeveloperPolicyName = generateProjectPolicyName(project, 'developer') + const projectReadOnlyPolicyName = generateProjectPolicyName(project, 'readonly') + const projectSecurityPolicyName = generateProjectPolicyName(project, 'security') + await this.upsertMount(project.slug) + + const [ + adminGroupPath, + auditorGroupPath, + securityGroupPath, + maintainerGroupPathSuffix, + devopsGroupPathSuffix, + developerGroupPathSuffix, + reporterGroupPathSuffix, + securityGroupPathSuffix, + ] = await Promise.all([ + this.getAdminGroupPath(project), + this.getAuditorGroupPath(project), + this.getSecurityGroupPath(project), + this.getProjectMaintainerGroupPathSuffix(project), + this.getProjectDevopsGroupPathSuffix(project), + this.getProjectDeveloperGroupPathSuffix(project), + this.getProjectReporterGroupPathSuffix(project), + this.getProjectSecurityGroupPathSuffix(project), + ]) + + const projectAdminGroupPaths = generateProjectRoleGroupPaths(project, maintainerGroupPathSuffix) + const projectDevopsGroupPaths = generateProjectRoleGroupPaths(project, devopsGroupPathSuffix) + const projectDeveloperGroupPaths = generateProjectRoleGroupPaths(project, developerGroupPathSuffix) + const projectReadOnlyGroupPaths = generateProjectRoleGroupPaths(project, reporterGroupPathSuffix) + const projectSecurityGroupPaths = generateProjectRoleGroupPaths(project, securityGroupPathSuffix) + + await Promise.all([ + this.ensureAppAdminPolicy(appPolicyName, project.slug), + this.ensureTechReadOnlyPolicy(techPolicyName, project.slug), + this.ensureProjectDevopsPolicy(projectDevopsPolicyName, project.slug), + this.ensureProjectReadOnlyPolicy(projectDeveloperPolicyName, project.slug), + this.ensureProjectReadOnlyPolicy(projectReadOnlyPolicyName, project.slug), + this.createProjectSecurityPolicy(projectSecurityPolicyName, project.slug), + this.ensurePlatformAdminPolicy(PLATFORM_ADMIN_POLICY_NAME), + this.ensurePlatformReadOnlyPolicy(PLATFORM_READONLY_POLICY_NAME), + this.ensurePlatformSecurityPolicy(PLATFORM_SECURITY_POLICY_NAME), + this.ensureIdentityGroup(CONSOLE_ADMIN_GROUP_NAME, [PLATFORM_ADMIN_POLICY_NAME], adminGroupPath), + this.ensureIdentityGroup(CONSOLE_READONLY_GROUP_NAME, [PLATFORM_READONLY_POLICY_NAME], auditorGroupPath), + this.ensureIdentityGroup(CONSOLE_SECURITY_GROUP_NAME, [PLATFORM_SECURITY_POLICY_NAME], securityGroupPath), + ...projectAdminGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'admin'), [appPolicyName], groupPath)), + ...projectDevopsGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'devops'), [projectDevopsPolicyName], groupPath)), + ...projectDeveloperGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'developer'), [projectDeveloperPolicyName], groupPath)), + ...projectReadOnlyGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'readonly'), [projectReadOnlyPolicyName], groupPath)), + ...projectSecurityGroupPaths.map(groupPath => + this.ensureIdentityGroup(generateProjectGroupName(project, 'security'), [projectSecurityPolicyName], groupPath)), + this.client.upsertAuthApproleRole(project.slug, generateApproleRoleBody([techPolicyName, appPolicyName])), + ]) + } + + @StartActiveSpan() + async deleteProject(project: ProjectWithDetails): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.slug', project.slug) + span?.setAttribute('vault.kv.name', project.slug) + const appPolicyName = generateAppAdminPolicyName(project) + const techPolicyName = generateTechReadOnlyPolicyName(project) + const projectDevopsPolicyName = generateProjectPolicyName(project, 'devops') + const projectDeveloperPolicyName = generateProjectPolicyName(project, 'developer') + const projectReadOnlyPolicyName = generateProjectPolicyName(project, 'readonly') + const projectSecurityPolicyName = generateProjectPolicyName(project, 'security') + + await this.deleteMount(project.slug) + + const settled = await Promise.allSettled([ + this.client.deleteSysPoliciesAcl(appPolicyName), + this.client.deleteSysPoliciesAcl(techPolicyName), + this.client.deleteSysPoliciesAcl(projectDevopsPolicyName), + this.client.deleteSysPoliciesAcl(projectDeveloperPolicyName), + this.client.deleteSysPoliciesAcl(projectReadOnlyPolicyName), + this.client.deleteSysPoliciesAcl(projectSecurityPolicyName), + this.client.deleteAuthApproleRole(project.slug), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'admin')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'devops')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'developer')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'readonly')), + this.client.deleteIdentityGroupName(generateProjectGroupName(project, 'security')), + ]) + for (const result of settled) { + if (result.status !== 'rejected') continue + const error = result.reason + if (error instanceof VaultError && error.kind === 'NotFound') continue + throw error + } + } + + @StartActiveSpan() + private async ensureIdentityGroup(groupName: string, policies: string[], groupAliasName: string): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'vault.group.name': groupName, + 'vault.policies.count': policies.length, + }) + await this.client.upsertIdentityGroupName(groupName, { + name: groupName, + type: 'external', + policies, + }) + + const groupResult = await this.client.getIdentityGroupName(groupName) + if (!groupResult.data?.id) { + throw new VaultError('InvalidResponse', `Vault group not found after upsert: ${groupName}`, { method: 'GET', path: `/v1/identity/group/name/${groupName}` }) + } + + const normalizedAliasName = groupAliasName.startsWith('/') ? groupAliasName : `/${groupAliasName}` + if (groupResult.data.alias?.name === normalizedAliasName) return + + const methods = await this.client.getSysAuth() + const oidc = methods['oidc/'] + if (!oidc?.accessor) { + throw new VaultError('InvalidResponse', 'Vault OIDC auth method not found (expected "oidc/")', { method: 'GET', path: '/v1/sys/auth' }) + } + try { + span?.setAttributes({ + 'vault.group.alias.name': normalizedAliasName, + 'vault.oidc.accessor': oidc.accessor, + }) + await this.client.createIdentityGroupAlias({ + name: normalizedAliasName, + mount_accessor: oidc.accessor, + canonical_id: groupResult.data.id, + }) + } catch (error) { + if (error instanceof VaultError && error.kind === 'HttpError' && error.status === 400) return + throw error + } + } + + async ensureAppAdminPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${projectSlug}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + }) + } + + async ensureProjectDevopsPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "${projectSlug}/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + `path "${projectSlug}/metadata/*" { capabilities = ["read", "list"] }`, + `path "${projectSlug}/delete/*" { capabilities = ["update"] }`, + `path "${projectSlug}/undelete/*" { capabilities = ["update"] }`, + `path "${projectSlug}/destroy/*" { capabilities = ["update"] }`, + ].join('\n'), + }) + } + + async ensureProjectReadOnlyPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${projectSlug}/data/*" { capabilities = ["list"] }`, + }) + } + + async createProjectSecurityPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "${projectSlug}/metadata/*" { capabilities = ["list"] }`, + `path "transit/keys/${projectSlug}/*" { capabilities = ["list"] }`, + ].join('\n'), + }) + } + + async ensurePlatformAdminPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "sys/*" { capabilities = ["create", "read", "update", "delete", "list", "sudo"] }`, + }) + } + + async ensurePlatformReadOnlyPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "sys/health" { capabilities = ["read"] }`, + `path "sys/mounts" { capabilities = ["read"] }`, + `path "sys/mounts/*" { capabilities = ["read"] }`, + `path "sys/auth" { capabilities = ["read"] }`, + `path "sys/auth/*" { capabilities = ["read"] }`, + `path "sys/policies/*" { capabilities = ["read", "list"] }`, + ].join('\n'), + }) + } + + async ensurePlatformSecurityPolicy(name: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: [ + `path "sys/audit" { capabilities = ["read", "list"] }`, + `path "sys/audit/*" { capabilities = ["read", "list"] }`, + `path "sys/policies/*" { capabilities = ["read", "list"] }`, + `path "sys/auth" { capabilities = ["read"] }`, + `path "sys/auth/*" { capabilities = ["read"] }`, + ].join('\n'), + }) + } + + async ensureTechReadOnlyPolicy(name: string, projectSlug: string): Promise { + await this.client.upsertSysPoliciesAcl(name, { + policy: `path "${this.vaultConfig.kvName}/data/${projectSlug}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + }) + } + + async listProjectSecrets(projectSlug: string): Promise { + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) + return this.listRecursive(this.vaultConfig.kvName, projectPath, '') + } + + @StartActiveSpan() + async deleteProjectSecrets(projectSlug: string): Promise { + const span = trace.getActiveSpan() + span?.setAttributes({ + 'project.slug': projectSlug, + 'vault.kv.name': this.vaultConfig.kvName, + }) + const secrets = await this.listProjectSecrets(projectSlug) + span?.setAttribute('vault.secrets.count', secrets.length) + + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) + await Promise.allSettled(secrets.map(async (relativePath) => { + const fullPath = `${projectPath}/${relativePath}` + try { + await this.client.delete(fullPath) + } catch (error) { + if (error instanceof VaultError && error.kind === 'NotFound') return + throw error + } + })) + } + + private async listRecursive( + kvName: string, + basePath: string, + relativePath: string, + ): Promise { + const combined = relativePath.length === 0 ? basePath : `${basePath}/${relativePath}` + const keys = await this.client.listKvMetadata(kvName, combined) + if (keys.length === 0) return [] + + const results: string[] = [] + for (const key of keys) { + if (key.endsWith('/')) { + const nestedRel = relativePath.length === 0 ? key.slice(0, -1) : `${relativePath}/${key.slice(0, -1)}` + const nested = await this.listRecursive(kvName, basePath, nestedRel) + results.push(...nested) + } else { + results.push(relativePath.length === 0 ? key : `${relativePath}/${key}`) + } + } + return results + } +} + +function generateTechReadOnlyPolicyName(project: ProjectWithDetails) { + return `tech--${project.slug}--ro` +} + +function generateAppAdminPolicyName(project: ProjectWithDetails) { + return `app--${project.slug}--admin` +} + +function generateProjectPolicyName(project: ProjectWithDetails, scope: ProjectScope) { + return `project--${project.slug}--${scope}` +} + +function generateProjectGroupName(project: ProjectWithDetails, scope: ProjectScope) { + return `project-${project.slug}-${scope}` +} + +function generateProjectRoleGroupPaths(project: ProjectWithDetails, rawGroupPathSuffixes: string) { + return rawGroupPathSuffixes + .split(',') + .map(path => path.trim()) + .filter(Boolean) + .map(path => `/${project.slug}${path}`) +} + +function generateZoneName(name: string) { + return `zone-${name}` +} + +function generateZoneTechReadOnlyPolicyName(zoneName: string) { + return `tech--${generateZoneName(zoneName)}--ro` +} + +function generateApproleRoleBody(policies: string[]) { + return { + secret_id_num_uses: '0', + secret_id_ttl: '0', + token_max_ttl: '0', + token_num_uses: '0', + token_ttl: '0', + token_type: 'batch', + token_policies: policies, + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.utils.ts new file mode 100644 index 0000000000..6e170bede2 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/vault/vault.utils.ts @@ -0,0 +1,15 @@ +export function generateProjectPath(projectRootDir: string, projectSlug: string) { + return `${projectRootDir}/${projectSlug}` +} + +export function generateGitlabMirrorCredPath(projectRootDir: string, projectSlug: string, repoName: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/${repoName}-mirror` +} + +export function generateTechReadOnlyCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/tech/GITLAB_MIRROR` +} + +export function generateSonarqubeCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/SONAR` +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/version/version.controller.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/version/version.controller.ts new file mode 100644 index 0000000000..7dc6a91019 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/version/version.controller.ts @@ -0,0 +1,15 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import { Controller, Get, Inject } from '@nestjs/common' +import { BASE_CONFIG } from '../infrastructure/config/base.config' + +@Controller('api/v1/version') +export class VersionController { + constructor( + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + ) {} + + @Get() + getVersion() { + return { version: this.baseConfig.appVersion } + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/modules/version/version.module.ts b/.jjconflict-side-2/apps/server-nestjs/src/modules/version/version.module.ts new file mode 100644 index 0000000000..c537037b5b --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/modules/version/version.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common' +import { VersionController } from './version.controller' + +@Module({ + controllers: [VersionController], +}) +export class VersionModule {} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql new file mode 100644 index 0000000000..f2f4e7b0b7 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230706084346_dso/migration.sql @@ -0,0 +1,151 @@ +-- CreateTable +CREATE TABLE "Environment" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Environment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Log" ( + "id" UUID NOT NULL, + "data" JSONB NOT NULL, + "action" TEXT NOT NULL DEFAULT '', + "userId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Log_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Organization" ( + "id" UUID NOT NULL, + "source" TEXT NOT NULL, + "name" TEXT NOT NULL, + "label" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Permission" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "environmentId" UUID NOT NULL, + "level" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Permission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "organizationId" UUID NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL, + "locked" BOOLEAN NOT NULL DEFAULT false, + "services" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Repository" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "internalRepoName" TEXT NOT NULL, + "externalRepoUrl" TEXT NOT NULL, + "externalUserName" TEXT, + "externalToken" TEXT, + "isInfra" BOOLEAN NOT NULL DEFAULT false, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Repository_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" UUID NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Role" ( + "userId" UUID NOT NULL, + "projectId" UUID NOT NULL, + "role" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Role_pkey" PRIMARY KEY ("userId","projectId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_id_key" ON "Organization"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_name_key" ON "Organization"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_label_key" ON "Organization"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_id_key" ON "Permission"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_userId_environmentId_key" ON "Permission"("userId", "environmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_id_key" ON "Project"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Role_userId_projectId_key" ON "Role"("userId", "projectId"); + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql new file mode 100644 index 0000000000..26e1ade3f2 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230710181052_dso/migration.sql @@ -0,0 +1,85 @@ +-- CreateEnum +CREATE TYPE "ClusterPrivacy" AS ENUM ('public', 'dedicated'); + +-- CreateTable +CREATE TABLE "Cluster" ( + "id" UUID NOT NULL, + "label" VARCHAR(50) NOT NULL, + "privacy" "ClusterPrivacy" NOT NULL DEFAULT 'dedicated', + "secretName" VARCHAR(50) NOT NULL, + "clusterResources" BOOLEAN NOT NULL DEFAULT false, + "kubeConfigId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cluster_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Kubeconfig" ( + "id" UUID NOT NULL, + "user" JSONB NOT NULL, + "cluster" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "parentClusterId" UUID, + + CONSTRAINT "Kubeconfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_ClusterToEnvironment" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateTable +CREATE TABLE "_ClusterToProject" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_id_key" ON "Cluster"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_label_key" ON "Cluster"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_secretName_key" ON "Cluster"("secretName"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_kubeConfigId_key" ON "Cluster"("kubeConfigId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_id_key" ON "Kubeconfig"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_parentClusterId_key" ON "Kubeconfig"("parentClusterId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToEnvironment_AB_unique" ON "_ClusterToEnvironment"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToEnvironment_B_index" ON "_ClusterToEnvironment"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToProject_AB_unique" ON "_ClusterToProject"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToProject_B_index" ON "_ClusterToProject"("B"); + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_kubeConfigId_fkey" FOREIGN KEY ("kubeConfigId") REFERENCES "Kubeconfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_B_fkey" FOREIGN KEY ("B") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_B_fkey" FOREIGN KEY ("B") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql new file mode 100644 index 0000000000..8f3fb5ff9f --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230711132934_dso/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `parentClusterId` on the `Kubeconfig` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Kubeconfig_parentClusterId_key"; + +-- AlterTable +ALTER TABLE "Kubeconfig" DROP COLUMN "parentClusterId"; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql new file mode 100644 index 0000000000..4eb37edc53 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230802143822_dso/migration.sql @@ -0,0 +1,10 @@ +CREATE TYPE "ProjectStatus" AS ENUM ('initializing', 'created', 'failed', 'archived'); + +ALTER TABLE public."Project" ALTER COLUMN status TYPE "ProjectStatus" USING + case + when status = 'created' then 'created'::"ProjectStatus" + when status = 'failed' then 'failed'::"ProjectStatus" + when status = 'archived' then 'archived'::"ProjectStatus" + else 'initializing'::"ProjectStatus" + end; +ALTER TABLE public."Project" ALTER COLUMN status SET DEFAULT 'initializing'; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql new file mode 100644 index 0000000000..f402a0e3d4 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20230912084459_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "infos" VARCHAR(200); diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql new file mode 100644 index 0000000000..f553e7880e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231010111515_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `externalToken` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "externalToken"; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql new file mode 100644 index 0000000000..394b650a51 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231011125838_dso/migration.sql @@ -0,0 +1,81 @@ +-- CreateEnum +CREATE TYPE "QuotaStageStatus" AS ENUM +('active', 'pendingDelete'); + +-- Create new tables +-- CreateTable +CREATE TABLE "Quota" +( + "id" UUID NOT NULL, + "memory" VARCHAR NOT NULL, + "cpu" REAL NOT NULL, + "name" VARCHAR NOT NULL, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "Quota_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Stage" +( + "id" UUID NOT NULL, + "name" VARCHAR NOT NULL, + + CONSTRAINT "Stage_pkey" PRIMARY KEY ("id") +); + +-- Associate Quotas and Stages +-- CreateTable +CREATE TABLE "QuotaStage" +( + "id" UUID NOT NULL, + "quotaId" UUID NOT NULL, + "stageId" UUID NOT NULL, + "status" "QuotaStageStatus" NOT NULL DEFAULT 'active', + + CONSTRAINT "QuotaStage_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "Quota_id_key" ON "Quota"("id"); +CREATE UNIQUE INDEX "Quota_name_key" ON "Quota"("name"); +CREATE UNIQUE INDEX "Stage_id_key" ON "Stage"("id"); +CREATE UNIQUE INDEX "Stage_name_key" ON "Stage"("name"); +CREATE UNIQUE INDEX "QuotaStage_id_key" ON "QuotaStage"("id"); +CREATE UNIQUE INDEX "QuotaStage_quotaId_stageId_key" ON "QuotaStage"("quotaId", "stageId"); +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Create default values for Quotas and Stages +-- Quota +INSERT INTO "Quota" + (id, cpu, memory, "name", "isPrivate") +VALUES + ('5a57b62f-2465-4fb6-a853-5a751d099199', 2, '4Gi', 'small', false), + ('08770663-3b76-4af6-8978-9f75eda4faa7', 4, '8Gi', 'medium', false), + ('b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 6, '12Gi', 'large', false), + ('97b851e8-9067-4a3d-a0e8-c3a6820c49be', 8, '16Gi', 'xlarge', false); + +-- Stage +INSERT INTO "Stage" + (id, "name") +VALUES + ('4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', 'dev'), + ('38fa869d-6267-441d-af7f-e0548fd06b7e', 'staging'), + ('d434310e-7850-4d59-b47f-0772edf50582', 'integration'), + ('9b3e9991-896d-4d90-bdc5-a34be8c06b8f', 'prod'); + +-- QuotaStage +INSERT INTO "QuotaStage" + (id, "quotaId", "stageId") +VALUES + ('0cb0c549-560e-4f26-8f4e-832dd722f68a', '5a57b62f-2465-4fb6-a853-5a751d099199', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('0530e9c9-b37d-4dec-93e6-1895f700e61c', '5a57b62f-2465-4fb6-a853-5a751d099199', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('8a99db49-b7b1-44bf-865d-5e709e8aa0fc', '5a57b62f-2465-4fb6-a853-5a751d099199', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('67561f00-d219-4ca6-b94a-3ee83f09d2d6', '5a57b62f-2465-4fb6-a853-5a751d099199', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('8b3c201e-7518-4254-a94a-16c404e46936', '08770663-3b76-4af6-8978-9f75eda4faa7', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('9157ae12-3e39-43f8-a24f-ae5d9c6b69b7', '08770663-3b76-4af6-8978-9f75eda4faa7', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('c733a1dd-c9fd-4def-b29e-df49ef7b6698', '08770663-3b76-4af6-8978-9f75eda4faa7', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('15a51f47-0ab2-4a94-a808-722639d8c092', '08770663-3b76-4af6-8978-9f75eda4faa7', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('cb66e80c-2304-472d-bc19-a411011674ca', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('59fb0e79-3a76-4b96-81d4-63f4caa98cfa', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('4174b22c-2bee-4f4a-9d85-da7b5463f214', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql new file mode 100644 index 0000000000..8c98a7f74e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231011125839_dso/migration.sql @@ -0,0 +1,35 @@ +-- Multiplication des environnements par clusteurs +ALTER TABLE "Environment" ADD COLUMN "clusterId" UUID; + +DO +$$ +DECLARE + perm record; + cte record; + env_uuid UUID; +BEGIN + FOR cte IN SELECT "B" AS environmentId, "A" AS "clusterId", "name", "projectId", status, "updatedAt", "createdAt" + FROM public."_ClusterToEnvironment", public."Environment" + WHERE public."Environment".id = "B" + LOOP + env_uuid := gen_random_uuid(); + INSERT INTO public."Environment" (id, "name", "projectId", "clusterId", status, "createdAt", "updatedAt") VALUES + (env_uuid, cte."name", cte."projectId", cte."clusterId", cte.status, cte."createdAt", cte."updatedAt"); + + FOR perm in SELECT * FROM public."Permission" WHERE "environmentId" = cte.environmentId + LOOP + INSERT INTO public."Permission" (id, "level", "createdAt", "updatedAt", "environmentId", "userId") VALUES + (gen_random_uuid(), perm."level", perm."createdAt", perm."updatedAt", env_uuid, perm."userId"); + END LOOP; + END LOOP; +END; +$$ +; +DELETE FROM public."Environment" WHERE "clusterId" is null; +ALTER TABLE public."Environment" ALTER COLUMN "clusterId" SET NOT NULL; +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_clusterId_fkey" FOREIGN KEY ("clusterId") REFERENCES "Cluster"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Delete old _ClusterToEnvironment +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_A_fkey"; +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_B_fkey"; +DROP TABLE "_ClusterToEnvironment"; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql new file mode 100644 index 0000000000..035cd1c855 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231011125841_dso/migration.sql @@ -0,0 +1,36 @@ +-- Associate cluster to Stages +-- CreateTable +CREATE TABLE "_ClusterToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToStage_AB_unique" ON "_ClusterToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToStage_B_index" ON "_ClusterToStage"("B"); + +DO +$$ +DECLARE + cluster record; + cte record; + env_uuid UUID; +BEGIN + FOR cluster IN SELECT id + FROM public."Cluster" + LOOP + INSERT INTO public."_ClusterToStage" ("A", "B") VALUES + (cluster.id, '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + (cluster.id, '38fa869d-6267-441d-af7f-e0548fd06b7e'), + (cluster.id, 'd434310e-7850-4d59-b47f-0772edf50582'), + (cluster.id, '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); + END LOOP; +END; +$$ diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql new file mode 100644 index 0000000000..43793bdb49 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231012105520_dso/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaStageId" UUID; +UPDATE "Environment" SET "quotaStageId" = '8b3c201e-7518-4254-a94a-16c404e46936' WHERE "name" = 'dev'; +UPDATE "Environment" SET "quotaStageId" = '9157ae12-3e39-43f8-a24f-ae5d9c6b69b7' WHERE "name" = 'staging'; +UPDATE "Environment" SET "quotaStageId" = '4174b22c-2bee-4f4a-9d85-da7b5463f214' WHERE "name" = 'integration'; +UPDATE "Environment" SET "quotaStageId" = 'de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a' WHERE "name" = 'prod'; +ALTER TABLE "Environment" ALTER COLUMN "name" SET DATA TYPE VARCHAR(11); +ALTER TABLE "Environment" ALTER COLUMN "quotaStageId" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaStageId_fkey" FOREIGN KEY ("quotaStageId") REFERENCES "QuotaStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql new file mode 100644 index 0000000000..9af004b6a3 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231024155020_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- lock all projects +UPDATE public."Project" SET "locked"=true \ No newline at end of file diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql new file mode 100644 index 0000000000..d970d3965a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20231026150220_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- set all projects to failed to avoid unlock them +UPDATE public."Project" SET "status" = 'failed' WHERE "status" != 'archived' \ No newline at end of file diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql new file mode 100644 index 0000000000..c387d9885c --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240112135751_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ADD COLUMN "requestId" VARCHAR(21); diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql new file mode 100644 index 0000000000..18e20262c8 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240321123436_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `status` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the column `status` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "status"; + +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "status"; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql new file mode 100644 index 0000000000..f784b21567 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240329172938_dso/migration.sql @@ -0,0 +1,46 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "zoneId" UUID; + +-- CreateTable +CREATE TABLE "Zone" +( + "id" UUID NOT NULL, + "slug" VARCHAR(10) NOT NULL, + "label" VARCHAR(50) NOT NULL, + "description" VARCHAR(200), + "createdAt" TIMESTAMP +(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP +(3) NOT NULL, + + CONSTRAINT "Zone_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_id_key" ON "Zone"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_slug_key" ON "Zone"("slug"); + +-- Create default zone +INSERT INTO "Zone" + (id, "slug", "label", "description", "updatedAt") +VALUES + ('a66c4230-eba6-41f1-aae5-bb1e4f90cce0', 'default', 'Zone Défaut', 'Zone par défaut, à changer', CURRENT_TIMESTAMP); + +-- Set default zoneId for current clusters +UPDATE "Cluster" +SET "zoneId" += 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0' +WHERE "zoneId" +IS NULL; + +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "zoneId" +SET +NOT NULL; + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") +ON DELETE RESTRICT ON +UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql new file mode 100644 index 0000000000..cb4f7ad968 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240424093852_dso/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "ProjectPlugin" ( + "pluginName" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "AdminPlugin" ( + "pluginName" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectPlugin_projectId_pluginName_key_key" ON "ProjectPlugin"("projectId", "pluginName", "key"); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminPlugin_pluginName_key_key" ON "AdminPlugin"("pluginName", "key"); + +-- AddForeignKey +ALTER TABLE "ProjectPlugin" ADD CONSTRAINT "ProjectPlugin_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql new file mode 100644 index 0000000000..11672324f8 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240427181037_dso/migration.sql @@ -0,0 +1,19 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id, services FROM public."Project" LOOP + -- Extrait 'registry.id' de la colonne JSON 'services' + registry_id := (SELECT (project_row.services -> 'registry' ->> 'id')::TEXT); + -- Si 'registry.id' existe, insérer dans la table 'config' + IF registry_id IS NOT NULL THEN + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'registry', 'projectId', registry_id::TEXT); + END IF; + END LOOP; +END $$; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "services"; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql new file mode 100644 index 0000000000..9c7d5a8f8f --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240605135052_dso/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "RoleList" AS ENUM ('owner', 'user'); + +-- AlterTable +ALTER TABLE public."Role" ALTER COLUMN "role" TYPE "RoleList" USING + case + when role = 'owner' then 'owner'::"RoleList" + else 'user'::"RoleList" + end; \ No newline at end of file diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql new file mode 100644 index 0000000000..45a4a5d1ea --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240612123132_dso/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "ProjectClusterHistory" ( + "projectId" UUID NOT NULL, + "clusterId" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectClusterHistory_projectId_clusterId_key" ON "ProjectClusterHistory"("projectId", "clusterId"); diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql new file mode 100644 index 0000000000..2b1641a657 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240614222908_dso/migration.sql @@ -0,0 +1,11 @@ +DO $$ +DECLARE + env_row RECORD; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR env_row IN SELECT "projectId", "clusterId" FROM public."Environment" LOOP + INSERT INTO public."ProjectClusterHistory" ("projectId", "clusterId") + VALUES (env_row."projectId", env_row."clusterId") + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql new file mode 100644 index 0000000000..5e7ff03d45 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240618112205_dso/migration.sql @@ -0,0 +1,58 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaId" UUID, +ADD COLUMN "stageId" UUID; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "_QuotaToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_QuotaToStage_AB_unique" ON "_QuotaToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_QuotaToStage_B_index" ON "_QuotaToStage"("B"); + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +DO $$ +DECLARE + quota_stage_row RECORD; +BEGIN + FOR quota_stage_row IN SELECT * FROM public."QuotaStage" loop + UPDATE public."Environment" SET "stageId" = quota_stage_row."stageId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + UPDATE public."Environment" SET "quotaId" = quota_stage_row."quotaId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + insert into public."_QuotaToStage" values (quota_stage_row."quotaId", quota_stage_row."stageId"); + END LOOP; +END $$; + +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaStageId_fkey"; + +-- AlterTable +ALTER TABLE "Environment" ALTER COLUMN "quotaId" SET NOT NULL, +ALTER COLUMN "stageId" SET NOT NULL, +DROP COLUMN "quotaStageId"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_stageId_fkey"; + +-- DropTable +DROP TABLE "QuotaStage"; + +-- DropEnum +DROP TYPE "QuotaStageStatus"; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql new file mode 100644 index 0000000000..0036da8a12 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240717084709_dso/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Made the column `description` on table `Project` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "description" SET NOT NULL, +ALTER COLUMN "description" SET DEFAULT ''; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql new file mode 100644 index 0000000000..ed6ae9b84e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240723135420_dso/migration.sql @@ -0,0 +1,198 @@ +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_environmentId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_environmentId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_userId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_userId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_projectId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_projectId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_userId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_userId_fkey"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectMembers') THEN + CREATE TABLE "ProjectMembers" ( + "projectId" UUID NOT NULL, + "userId" UUID NOT NULL, + "roleIds" TEXT[] + ); + END IF; +END $$; + +-- AlterTable +ALTER TABLE "Log" ADD COLUMN IF NOT EXISTS "projectId" UUID; + +INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt") +VALUES('04ac168a-2c4f-4816-9cce-af6c612e5912'::uuid, 'Anonymous', 'User', 'anon@user', '2023-07-03 14:46:56.770', '2023-07-03 14:46:56.770') +ON CONFLICT (id) DO NOTHING; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "everyonePerms" BIGINT NOT NULL DEFAULT 896, +ADD COLUMN IF NOT EXISTS "ownerId" UUID; + +DO $$ +DECLARE + role_row RECORD; +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR role_row IN SELECT "userId", "projectId", "role" FROM public."Role" LOOP + INSERT INTO public."ProjectMembers" ("userId", "projectId", "roleIds") VALUES (role_row."userId", role_row."projectId", '{}'); + IF role_row."role" = 'owner'::public."RoleList" THEN + UPDATE public."Project" + SET "ownerId"=role_row."userId" + WHERE id=role_row."projectId"::uuid; + END IF; + END LOOP; + END IF; +END $$; + +UPDATE public."Project" +SET "ownerId"='04ac168a-2c4f-4816-9cce-af6c612e5912' +WHERE "ownerId" IS NULL; + +ALTER TABLE public."Project" ALTER COLUMN "ownerId" SET NOT NULL; + +DELETE FROM public."ProjectMembers" pm +USING public."Project" p +WHERE pm."userId" = p."ownerId" +AND pm."projectId" = p."id"; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Permission') THEN + DROP TABLE "Permission"; + END IF; +END $$; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + DROP TABLE "Role"; + END IF; +END $$; + +-- DropEnum if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'RoleList') THEN + DROP TYPE "RoleList"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'AdminRole') THEN + CREATE TABLE "AdminRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "AdminRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectRole') THEN + CREATE TABLE "ProjectRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "projectId" UUID NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "ProjectRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_id_key') THEN + CREATE UNIQUE INDEX "AdminRole_id_key" ON "AdminRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_name_key') THEN + CREATE UNIQUE INDEX "AdminRole_name_key" ON "AdminRole"("name"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectMembers_projectId_userId_key') THEN + CREATE UNIQUE INDEX "ProjectMembers_projectId_userId_key" ON "ProjectMembers"("projectId", "userId"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectRole_id_key') THEN + CREATE UNIQUE INDEX "ProjectRole_id_key" ON "ProjectRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'Environment_projectId_name_key') THEN + CREATE UNIQUE INDEX "Environment_projectId_name_key" ON "Environment"("projectId", "name"); + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Log_projectId_fkey') THEN + ALTER TABLE "Log" ADD CONSTRAINT "Log_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Project_ownerId_fkey') THEN + ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_projectId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_userId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectRole_projectId_fkey') THEN + ALTER TABLE "ProjectRole" ADD CONSTRAINT "ProjectRole_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "adminRoleIds" TEXT[]; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql new file mode 100644 index 0000000000..c9b41827bf --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240725162050_dso/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "AdminRole_name_key"; + +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "oidcGroup" TEXT; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql new file mode 100644 index 0000000000..265f262abb --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240726210139_dso/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `oidcGroup` on table `AdminRole` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable + +UPDATE public."AdminRole" +SET "oidcGroup"='' +WHERE "oidcGroup" IS NULL; + +ALTER TABLE "AdminRole" ALTER COLUMN "oidcGroup" SET NOT NULL, +ALTER COLUMN "oidcGroup" SET DEFAULT ''; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql new file mode 100644 index 0000000000..4fc2768605 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240808082632_dso/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "SystemSetting" +( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "SystemSetting_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemSetting_key_key" ON "SystemSetting"("key"); + +-- Create maintenance setting +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('maintenance', 'off'); diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql new file mode 100644 index 0000000000..95ab54869d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240826143230_dso/migration.sql @@ -0,0 +1,3 @@ +INSERT INTO public."AdminRole" +(id, "name", permissions, "position", "oidcGroup") +VALUES('76229c96-4716-45bc-99da-00498ec9018c'::uuid, 'Admin', 2, 0, '/admin'); \ No newline at end of file diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql new file mode 100644 index 0000000000..c116482186 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240829085548_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Made the column `externalUserName` on table `Repository` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +UPDATE "Repository" SET "externalUserName" = '' WHERE "externalUserName" IS NULL; + +ALTER TABLE "Repository" ALTER COLUMN "externalUserName" SET NOT NULL, +ALTER COLUMN "externalUserName" SET DEFAULT '', +ALTER COLUMN "externalRepoUrl" SET DEFAULT ''; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql new file mode 100644 index 0000000000..b0472cd806 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240916141253_token/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "TokenStatus" AS ENUM ('active', 'revoked'); + +-- CreateTable +CREATE TABLE "AdminToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "userId" UUID, + "expirationDate" TIMESTAMP(3), + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "AdminToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminToken_id_key" ON "AdminToken"("id"); + +-- AddForeignKey +ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql new file mode 100644 index 0000000000..47488b00c5 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240919122331_optional_user_id/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "Log" DROP CONSTRAINT "Log_userId_fkey"; + +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "userId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql new file mode 100644 index 0000000000..18eca3eadf --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240923142722_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "requestId" SET DATA TYPE VARCHAR(36); diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql new file mode 100644 index 0000000000..74e0946f0a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240923155416_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Zone" ADD COLUMN "argocdUrl" TEXT NOT NULL DEFAULT 'https://example.com'; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql new file mode 100644 index 0000000000..41dac75359 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20240928002900_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ProjectStatus" ADD VALUE 'warning'; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql new file mode 100644 index 0000000000..ef888d5e50 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241008125724_enabling_maven/migration.sql @@ -0,0 +1,12 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id FROM public."Project" WHERE status <> 'archived'::public."ProjectStatus" LOOP + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'nexus', 'activateMavenRepo', 'enabled') + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql new file mode 100644 index 0000000000..a57e5956c3 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241104232540_add_usertype/migration.sql @@ -0,0 +1,12 @@ +-- CreateEnum +CREATE TYPE "UserType" AS ENUM ('human', 'bot', 'ghost'); + +-- AlterEnum +ALTER TYPE "TokenStatus" ADD VALUE 'inactive'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "type" "UserType" NOT NULL DEFAULT 'human'; +UPDATE "User" SET type = 'ghost' WHERE id = '04ac168a-2c4f-4816-9cce-af6c612e5912'; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "type" DROP DEFAULT; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql new file mode 100644 index 0000000000..71e15a312c --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241104232541_add_pat/migration.sql @@ -0,0 +1,84 @@ +-- CreateTable (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'PersonalAccessToken') THEN + CREATE TABLE "PersonalAccessToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "userId" UUID NOT NULL, + "expirationDate" TIMESTAMP(3) NOT NULL, + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'PersonalAccessToken_id_key') THEN + CREATE UNIQUE INDEX "PersonalAccessToken_id_key" ON "PersonalAccessToken"("id"); + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'PersonalAccessToken_userId_fkey') THEN + ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- Process AdminToken (idempotent) +DO $$ +DECLARE + admin_token record; + user_uuid UUID; +BEGIN + FOR admin_token IN SELECT "name", "id" + FROM public."AdminToken" + LOOP + -- Generate new UUID if user does not exist + user_uuid := COALESCE( + (SELECT id FROM public."User" WHERE email = concat(admin_token.name, '@bot.id')), + gen_random_uuid() + ); + + -- Insert user if not already exists + INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt", "type") + VALUES(user_uuid, 'Bot Admin', admin_token.name, concat(admin_token.name, '@bot.id'), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'bot') + ON CONFLICT (id) DO NOTHING; + + -- Update AdminToken with the new user ID + UPDATE public."AdminToken" SET "userId" = user_uuid WHERE id = admin_token.id; + END LOOP; +END $$; + +-- Alter AdminToken userId column to NOT NULL (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'AdminToken' AND column_name = 'userId' AND is_nullable = 'NO') THEN + ALTER TABLE public."AdminToken" ALTER COLUMN "userId" SET NOT NULL; + END IF; +END $$; + +-- DropForeignKey if exists (idempotent) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" DROP CONSTRAINT "AdminToken_userId_fkey"; + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql new file mode 100644 index 0000000000..521b2b10ac --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241107142721_user_last_login/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastLogin" TIMESTAMP(3); diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql new file mode 100644 index 0000000000..a7500833b9 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241112101945_add_slug/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "slug" TEXT; + +UPDATE public."Project" p +SET slug = ( + SELECT concat(org.name, '-', subp.name) FROM public."Project" subp + LEFT JOIN public."Organization" org on org."id" = subp."organizationId" + WHERE subp.id = p.id +); + +ALTER TABLE public."Project" ALTER COLUMN "slug" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug"); diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql new file mode 100644 index 0000000000..b143cbeb9b --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "lastSuccessProvisionningVersion" TEXT; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql new file mode 100644 index 0000000000..7a88681901 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20241216131342_dso/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToProject_AB_unique"; + +-- AlterTable +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToStage_AB_unique"; + +-- AlterTable +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_QuotaToStage_AB_unique"; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql new file mode 100644 index 0000000000..21ce77b8d5 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250107104749_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "external" BOOLEAN NOT NULL DEFAULT false; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql new file mode 100644 index 0000000000..ac63cd639d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql @@ -0,0 +1,25 @@ +-- Vérifie les versions dans la table Project +DO $$ +DECLARE + project_id TEXT; + project_name TEXT; + last_version TEXT; +BEGIN + -- Boucle sur les projets non archivés + FOR project_id, project_name, last_version IN ( + SELECT id, name, "lastSuccessProvisionningVersion" + FROM "Project" + WHERE "status" != 'archived' + ) + LOOP + -- Vérifie si la version est NULL + IF last_version IS NULL THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version NULL.', project_name, project_id; + END IF; + + -- Vérifie si la version est inférieure à 8.23.0 selon SemVer + IF (string_to_array(last_version, '.')::int[] < ARRAY[8,23,0]) THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version (%), inférieure à 8.23.0.', project_name, project_id, last_version; + END IF; + END LOOP; +END $$; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql new file mode 100644 index 0000000000..54871c9017 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250121222954_drop_organization/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `organizationId` on the `Project` table. All the data in the column will be lost. + - You are about to drop the `Organization` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Project" DROP CONSTRAINT "Project_organizationId_fkey"; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "organizationId"; + +-- DropTable +DROP TABLE "Organization"; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql new file mode 100644 index 0000000000..68ca0df2fa --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250723141246_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "infos" SET DATA TYPE VARCHAR(1000); diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql new file mode 100644 index 0000000000..8364090d8d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250818095032_remove_quota/migration.sql @@ -0,0 +1,44 @@ +-- AlterTable +ALTER TABLE "Environment" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; + +COMMENT ON COLUMN "Environment".cpu IS 'CPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".gpu IS 'GPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".memory IS 'Memory value as GigaBytes (1 and 0.01 are valid values)'; + +-- Use values from Quota. Memory is an extract of q.memory numeric value as it contains a unit (e.g. '2Gi'). +UPDATE "Environment" +SET cpu = q.cpu, memory = COALESCE(NULLIF(regexp_replace(q.memory, '\D', '','g'), ''), '0')::numeric +FROM "Quota" q +WHERE "quotaId" = q."id"; + +/* + Warnings: + + - You are about to drop the column `quotaId` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the `Quota` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_QuotaToStage` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_B_fkey"; + +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "quotaId", +ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- DropTable +DROP TABLE "Quota"; + +-- DropTable +DROP TABLE "_QuotaToStage"; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql new file mode 100644 index 0000000000..77f32b5ab5 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Cluster" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql new file mode 100644 index 0000000000..decca804aa --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20250916134454_add_project_resources/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Project" +ADD COLUMN "limitless" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "hprodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodMemory" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodMemory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql new file mode 100644 index 0000000000..95f3a689db --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql @@ -0,0 +1,4 @@ +-- Rename default zone +UPDATE "Zone" +SET ("label", "description") = ('DSO', 'Zone par défaut') +WHERE slug = 'default'; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql new file mode 100644 index 0000000000..aadb6cdba9 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Repository" ADD COLUMN "deployRevision" TEXT, +ADD COLUMN "deployPath" TEXT, +ADD COLUMN "helmValuesFiles" TEXT; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql new file mode 100644 index 0000000000..15cf56ab82 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql @@ -0,0 +1,12 @@ +-- Eliminate NULLs +UPDATE "Repository" SET "deployRevision" = '' WHERE "deployRevision" IS NULL; +UPDATE "Repository" SET "deployPath" = '' WHERE "deployPath" IS NULL; +UPDATE "Repository" SET "helmValuesFiles" = '' WHERE "helmValuesFiles" IS NULL; + +-- AlterTable +ALTER TABLE "Repository" ALTER COLUMN "deployRevision" SET NOT NULL, +ALTER COLUMN "deployRevision" SET DEFAULT '', +ALTER COLUMN "deployPath" SET NOT NULL, +ALTER COLUMN "deployPath" SET DEFAULT '', +ALTER COLUMN "helmValuesFiles" SET NOT NULL, +ALTER COLUMN "helmValuesFiles" SET DEFAULT ''; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql new file mode 100644 index 0000000000..1292495121 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "autosync" BOOLEAN NOT NULL DEFAULT true; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql new file mode 100644 index 0000000000..04c98df036 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "oidcGroup" TEXT NOT NULL DEFAULT ''; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql new file mode 100644 index 0000000000..40da41a75d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- Update AdminRole system roles +UPDATE "AdminRole" SET "type" = 'system' WHERE "name" IN ('Admin', 'Admin Locaux'); + +-- Update ProjectRole system roles +UPDATE "ProjectRole" SET "type" = 'system' WHERE "name" IN ('Administrateur', 'DevOps', 'Développeur', 'Lecture seule'); diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql new file mode 100644 index 0000000000..92c200d261 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260204150335_add_system_roles/migration.sql @@ -0,0 +1,26 @@ +-- Update existing Admin role to be system role 'Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + "oidcGroup" = '/admin', + "position" = 0 +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Lecture Seule Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '35848aa2-e881-4770-9844-0c5c3693e506'::uuid, + 'Lecture Seule Plateforme', + 1, -- Assuming 1n means bit 0 + 2, + '/readonly', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Lecture Seule Plateforme', + "type" = 'system', + "permissions" = 1, + "oidcGroup" = '/readonly'; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql new file mode 100644 index 0000000000..2a4afa8971 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260206105522_dso/migration.sql @@ -0,0 +1,28 @@ +-- Update existing Admin role to be system role 'Root Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Root Administrateur Plateforme' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Administrateur Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf'::uuid, + 'Administrateur Plateforme', + 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + 0, + '/console/admin', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, + "oidcGroup" = '/console/admin'; + +-- Update 'Lecture Seule Plateforme' system role +UPDATE "AdminRole" +SET + "oidcGroup" = '/console/readonly' +WHERE "id" = '35848aa2-e881-4770-9844-0c5c3693e506'::uuid; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql new file mode 100644 index 0000000000..f0cdb04873 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260211143048_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `external` on the `Cluster` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Cluster" DROP COLUMN "external"; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql new file mode 100644 index 0000000000..e07cad777b --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql @@ -0,0 +1,5 @@ +-- Enable legacy default permissions flag +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('refined-permissions', 'off'); diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql new file mode 100644 index 0000000000..efb6bc67d4 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql @@ -0,0 +1,3 @@ +-- Remove 'refined-permissions' system setting +DELETE FROM "SystemSetting" +WHERE "key" = 'refined-permissions'; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql new file mode 100644 index 0000000000..d019403c44 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql @@ -0,0 +1,15 @@ +-- Add global 'Tout le monde' role +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '034f589f-1750-4b15-bb34-4cd995e7fcaa'::uuid, + 'Tout le monde', + 25608, -- MANAGE_PROJECTS | LIST_PROJECTS | LIST_ZONES | LIST_STAGES + 0, + '', + 'global' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Tout le monde', + "type" = 'global', + "permissions" = 25608; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql new file mode 100644 index 0000000000..4fd0d80eb2 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260226141201_role_type/migration.sql @@ -0,0 +1,25 @@ +-- Alter existing column to change the default value from 'custom' to 'managed' for AdminRole type +ALTER TABLE "AdminRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Alter existing column to change the default value from 'custom' to 'managed' for ProjectRole type +ALTER TABLE "ProjectRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Migrate system roles to managed roles +UPDATE "AdminRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate system roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate custom roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'custom'; + +-- Update existing Admin role to be an externally managed role +UPDATE "AdminRole" +SET "type" = 'external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql new file mode 100644 index 0000000000..d2691666bd --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql @@ -0,0 +1,15 @@ +-- Migrate well-known project roles to the new "system:"-prefixed type +UPDATE "ProjectRole" +SET "type" = 'system:managed' +WHERE + "type" = 'managed' + AND "oidcGroup" ~ '^/[^/]+/console/(admin|devops|developer|readonly)$'; + +-- Migrate well-known admin roles to the new "system:"-prefixed type +UPDATE "AdminRole" +SET "type" = 'system:managed' +WHERE id IN ( + '76229c96-4716-45bc-99da-00498ec9018c', -- Root Administrateur Plateforme + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', -- Administrateur Plateforme + '35848aa2-e881-4770-9844-0c5c3693e506' -- Lecture Seule Plateforme +); diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql new file mode 100644 index 0000000000..23593ba4f4 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260402095325_dso/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "hprodCpu" DROP DEFAULT, +ALTER COLUMN "hprodGpu" DROP DEFAULT, +ALTER COLUMN "hprodMemory" DROP DEFAULT, +ALTER COLUMN "prodCpu" DROP DEFAULT, +ALTER COLUMN "prodGpu" DROP DEFAULT, +ALTER COLUMN "prodMemory" DROP DEFAULT; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql new file mode 100644 index 0000000000..7e3eefb25d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql @@ -0,0 +1,4 @@ +-- Set the Root Administrateur Plateforme admin role to external system-managed type +UPDATE "AdminRole" +SET "type" = 'system:external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql new file mode 100644 index 0000000000..ca81594339 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260504101704_add_deployment/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "DeploymentSourceType" AS ENUM ('git', 'oci'); + +-- CreateTable +CREATE TABLE "Deployment" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "name" VARCHAR(11) NOT NULL, + "autosync" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "environmentId" UUID NOT NULL, + + CONSTRAINT "Deployment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeploymentSource" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deploymentId" UUID NOT NULL, + "repositoryId" UUID NOT NULL, + "type" "DeploymentSourceType" NOT NULL, + "targetRevision" TEXT NOT NULL DEFAULT '', + "path" TEXT NOT NULL DEFAULT '', + "helmValuesFiles" TEXT NOT NULL DEFAULT '', + + CONSTRAINT "DeploymentSource_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Deployment_id_key" ON "Deployment"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentSource_id_key" ON "DeploymentSource"("id"); + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "Deployment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql new file mode 100644 index 0000000000..ad5a64d336 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_environmentId_fkey"; + +-- DropForeignKey +ALTER TABLE "DeploymentSource" DROP CONSTRAINT "DeploymentSource_repositoryId_fkey"; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/migration_lock.toml b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000..044d57cdb0 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/admin.prisma b/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/admin.prisma new file mode 100644 index 0000000000..c4ca38ccb1 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/admin.prisma @@ -0,0 +1,21 @@ +model AdminPlugin { + pluginName String + key String + value String + + @@unique([pluginName, key]) +} + +model AdminRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") +} + +model SystemSetting { + key String @id @unique + value String +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/project.prisma b/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/project.prisma new file mode 100644 index 0000000000..6bcc5ce949 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/project.prisma @@ -0,0 +1,144 @@ +model Deployment { + id String @id @unique @default(uuid()) @db.Uuid + projectId String @db.Uuid + name String @db.VarChar(11) + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + environmentId String @db.Uuid + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model DeploymentSource { + id String @id @unique @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deploymentId String @db.Uuid + repositoryId String @db.Uuid + type DeploymentSourceType + targetRevision String @default("") + path String @default("") + helmValuesFiles String @default("") + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) + deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) +} + +model Environment { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(11) + projectId String @db.Uuid + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clusterId String @db.Uuid + stageId String @db.Uuid + cluster Cluster @relation(fields: [clusterId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + stage Stage @relation(fields: [stageId], references: [id]) + deployments Deployment[] + + @@unique([projectId, name]) +} + +model Repository { + id String @id @default(uuid()) @db.Uuid + projectId String @db.Uuid + internalRepoName String + externalRepoUrl String @default("") + externalUserName String @default("") + isInfra Boolean @default(false) + isPrivate Boolean @default(false) + deployRevision String @default("") + deployPath String @default("") + helmValuesFiles String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model ProjectClusterHistory { + projectId String @db.Uuid + clusterId String @db.Uuid + + @@unique([projectId, clusterId]) +} + +model ProjectMembers { + projectId String @db.Uuid + userId String @db.Uuid + roleIds String[] + project Project @relation(fields: [projectId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@unique([projectId, userId]) +} + +model ProjectPlugin { + pluginName String + projectId String @db.Uuid + key String + value String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, pluginName, key]) +} + +model ProjectRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + projectId String @db.Uuid + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") + project Project @relation(fields: [projectId], references: [id]) +} + +model Project { + id String @id @unique @default(uuid()) @db.Uuid + name String + description String @default("") + status ProjectStatus @default(initializing) + locked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + everyonePerms BigInt @default(896) + ownerId String @db.Uuid + environments Environment[] + logs Log[] + owner User @relation(fields: [ownerId], references: [id]) + members ProjectMembers[] + plugins ProjectPlugin[] + roles ProjectRole[] + repositories Repository[] + clusters Cluster[] @relation("ClusterToProject") + slug String @unique + limitless Boolean @default(true) + hprodCpu Float @db.Real + hprodGpu Float @db.Real + hprodMemory Float @db.Real + prodCpu Float @db.Real + prodGpu Float @db.Real + prodMemory Float @db.Real + lastSuccessProvisionningVersion String? + deployments Deployment[] +} + +enum ProjectStatus { + initializing + created + failed + archived + warning +} + +enum DeploymentSourceType { + git + oci +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/schema.prisma b/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/schema.prisma new file mode 100644 index 0000000000..aadf7fea13 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/schema.prisma @@ -0,0 +1,21 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DB_URL") +} + +model Log { + id String @id @default(uuid()) @db.Uuid + data Json + action String @default("") + userId String? @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + requestId String? @db.VarChar(36) + projectId String? @db.Uuid + project Project? @relation(fields: [projectId], references: [id]) + user User? @relation(fields: [userId], references: [id]) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/token.prisma b/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/token.prisma new file mode 100644 index 0000000000..c0c55751c5 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/token.prisma @@ -0,0 +1,30 @@ +model AdminToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + userId String @db.Uuid + expirationDate DateTime? + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +model PersonalAccessToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + userId String @db.Uuid + expirationDate DateTime + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +enum TokenStatus { + active + revoked + inactive +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/topography.prisma b/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/topography.prisma new file mode 100644 index 0000000000..63d90abb8a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/topography.prisma @@ -0,0 +1,52 @@ +model Cluster { + id String @id @unique @default(uuid()) @db.Uuid + label String @unique @db.VarChar(50) + privacy ClusterPrivacy @default(dedicated) + secretName String @unique @default(uuid()) @db.VarChar(50) + clusterResources Boolean @default(false) + kubeConfigId String @unique @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + infos String? @db.VarChar(1000) + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + zoneId String @db.Uuid + kubeconfig Kubeconfig @relation(fields: [kubeConfigId], references: [id], onDelete: Cascade) + zone Zone @relation(fields: [zoneId], references: [id]) + environments Environment[] + projects Project[] @relation("ClusterToProject") + stages Stage[] @relation("ClusterToStage") +} + +model Kubeconfig { + id String @id @unique @default(uuid()) @db.Uuid + user Json + cluster Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + parentCluster Cluster? +} + +model Stage { + id String @id @unique @default(uuid()) @db.Uuid + name String @unique @db.VarChar + environments Environment[] + clusters Cluster[] @relation("ClusterToStage") +} + +model Zone { + id String @id @unique @default(uuid()) @db.Uuid + slug String @unique @db.VarChar(10) + label String @db.VarChar(50) + description String? @db.VarChar(200) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + argocdUrl String @default("https://example.com") + clusters Cluster[] +} + +enum ClusterPrivacy { + public + dedicated +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/user.prisma b/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/user.prisma new file mode 100644 index 0000000000..e90fb69f83 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/prisma/schema/user.prisma @@ -0,0 +1,23 @@ +model User { + id String @id @db.Uuid + firstName String + lastName String + email String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? + adminRoleIds String[] + type UserType + + logs Log[] + projectsOwned Project[] + adminTokens AdminToken[] + projectMembers ProjectMembers[] + personalAccessTokens PersonalAccessToken[] +} + +enum UserType { + human + bot + ghost +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/utils/crypto.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/utils/crypto.spec.ts new file mode 100644 index 0000000000..090c58583a --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/utils/crypto.spec.ts @@ -0,0 +1,33 @@ +import { generateProjectKey as legacyGenerateProjectKey } from '@cpn-console/hooks' +import { describe, expect, it } from 'vitest' +import { generateProjectKey } from './crypto.utils' + +describe('generateProjectKey', () => { + it('matches the legacy @cpn-console/hooks implementation byte-for-byte', () => { + const cases: [string, string][] = [ + ['my-app', 'my-repo'], + ['my-app', 'front'], + ['my-app-2', 'front'], + ['slug-with-many-dashes', 'repo-with-dashes'], + ['a', 'b'], + ] + for (const [slug, repo] of cases) { + expect(generateProjectKey(slug, repo)).toBe(legacyGenerateProjectKey(slug, repo)) + } + }) + + it('produces stable known keys', () => { + // Golden values: existing SonarQube projects were created with these exact keys, + // and reconciliation recomputes them to decide ownership. Any change here means + // the console would stop recognizing (and could delete) existing projects. + expect(generateProjectKey('my-app', 'my-repo')).toBe('my-app-my-repo-923f') + expect(generateProjectKey('my-app', 'front')).toBe('my-app-front-1013') + expect(generateProjectKey('my-app', 'api')).toBe('my-app-api-a814') + }) + + it('disambiguates identical slug-repo concatenations via the hash suffix', () => { + // "my-app" + "front-api" and "my-app-front" + "api" share the prefix + // "my-app-front-api-"; only the repo hash tells them apart. + expect(generateProjectKey('my-app', 'front-api')).not.toBe(generateProjectKey('my-app-front', 'api')) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/utils/crypto.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/utils/crypto.utils.ts new file mode 100644 index 0000000000..f12bebcd13 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/utils/crypto.utils.ts @@ -0,0 +1,18 @@ +import crypto, { createHmac } from 'node:crypto' + +export function generateRandomPassword(length = 24) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*' + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +// Must stay byte-for-byte identical to the legacy @cpn-console/hooks implementation: +// existing SonarQube project keys were generated with it, and ownership matching +// recomputes keys to decide which projects to reconcile or delete. +export function generateProjectKey(projectSlug: string, internalRepoName: string) { + const repoHash = createHmac('sha256', '') + .update(internalRepoName) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${internalRepoName}-${repoHash}` +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/utils/dotenv.utils.spec.ts b/.jjconflict-side-2/apps/server-nestjs/src/utils/dotenv.utils.spec.ts new file mode 100644 index 0000000000..b5c7014c46 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/utils/dotenv.utils.spec.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDotenvPaths, getExistingDotenvPaths } from './dotenv.utils' + +describe('dotenv.utils', () => { + afterEach(() => vi.unstubAllEnvs()) + + describe('getDotenvPaths', () => { + it('returns [".env"] when no env vars set', () => { + expect(getDotenvPaths()).toEqual(['.env']) + }) + + it('returns [".env", ".env.integ"] when INTEGRATION=true', () => { + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env', '.env.integ']) + }) + + it('returns [".env.docker"] when DOCKER=true', () => { + vi.stubEnv('DOCKER', 'true') + expect(getDotenvPaths()).toEqual(['.env.docker']) + }) + + it('returns [".env.integ", ".env.docker"] when DOCKER=true and INTEGRATION=true', () => { + vi.stubEnv('DOCKER', 'true') + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env.integ', '.env.docker']) + }) + }) + + describe('getExistingDotenvPaths', () => { + it('filters to only existing files', () => { + const paths = getExistingDotenvPaths() + expect(paths.every(p => typeof p === 'string')).toBe(true) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/src/utils/dotenv.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/utils/dotenv.utils.ts new file mode 100644 index 0000000000..7d3e0a0501 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/utils/dotenv.utils.ts @@ -0,0 +1,26 @@ +import fs from 'node:fs' + +export function getDotenvPaths(): string[] { + const paths: string[] = [] + + // Load .env unless DOCKER=true + if (process.env.DOCKER !== 'true') { + paths.push('.env') + } + + // Load .env.integ if INTEGRATION=true + if (process.env.INTEGRATION === 'true') { + paths.push('.env.integ') + } + + // Load .env.docker if DOCKER=true + if (process.env.DOCKER === 'true') { + paths.push('.env.docker') + } + + return paths +} + +export function getExistingDotenvPaths(): string[] { + return getDotenvPaths().filter(path => fs.existsSync(path)) +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/utils/http.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/utils/http.utils.ts new file mode 100644 index 0000000000..b85cc2f78b --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/utils/http.utils.ts @@ -0,0 +1,20 @@ +/** HTTP status carried by errors such as keycloak-admin-client's NetworkError. */ +export function getErrorResponseStatus(error: unknown): number | undefined { + if (error instanceof Error && 'response' in error && error.response instanceof Response) { + return error.response.status + } + return undefined +} + +/** HTTP details (status, url, response body) attached to errors carrying a fetch Response. */ +export function getErrorHttpDetails(error: Error): Record { + const details: Record = {} + if ('response' in error && error.response instanceof Response) { + details.status = error.response.status + details.url = error.response.url + } + if ('responseData' in error && error.responseData !== undefined && error.responseData !== '') { + details.responseData = error.responseData + } + return details +} diff --git a/.jjconflict-side-2/apps/server-nestjs/src/utils/iterable.utils.ts b/.jjconflict-side-2/apps/server-nestjs/src/utils/iterable.utils.ts new file mode 100644 index 0000000000..a46ef80632 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/src/utils/iterable.utils.ts @@ -0,0 +1,26 @@ +export async function* map( + iterable: AsyncIterable, + mapper: (value: T, index: number) => U | Promise, +): AsyncIterable { + let index = 0 + for await (const value of iterable) { + yield await mapper(value, index++) + } +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} + +export async function find(generator: AsyncGenerator, predicate: (item: T) => boolean): Promise { + for await (const item of generator) { + if (predicate(item)) return item + } + return undefined +} diff --git a/.jjconflict-side-2/apps/server-nestjs/test/argocd.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/argocd.e2e-spec.ts new file mode 100644 index 0000000000..65e0ea7497 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/argocd.e2e-spec.ts @@ -0,0 +1,314 @@ +import type { CommitAction, Gitlab } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { baseConfigFactory } from '../src/config/base.config' +import { projectSelect } from '../src/modules/argocd/argocd-datastore.service' +import { ArgoCDModule } from '../src/modules/argocd/argocd.module' +import { ArgoCDService } from '../src/modules/argocd/argocd.service' +import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunArgoCDE2E + = Boolean(process.env.E2E) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + && Boolean(process.env.DB_URL) + +const describeWithArgoCD = describe.runIf(canRunArgoCDE2E) + +describeWithArgoCD('ArgoCDController (e2e)', {}, () => { + let moduleRef: TestingModule + let argocdController: ArgoCDService + let gitlab: GitlabClientService + let gitlabClient: Gitlab + let vault: VaultClientService + let prisma: PrismaService + let config: BaseConfig + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + let zoneId: string + let zoneSlug: string + let kubeconfigId: string + let clusterId: string + let clusterLabel: string + let stageId: string + let envDevId: string + let envProdId: string + let envDevName: string + let envProdName: string + + let infraRepoId: number | undefined + let infraRepoPath: string + let vaultProjectValuesPath: string | undefined + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ArgoCDModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + argocdController = moduleRef.get(ArgoCDService) + gitlab = moduleRef.get(GitlabClientService) + gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) + vault = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + config = moduleRef.get(baseConfigFactory) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + zoneId = faker.string.uuid() + zoneSlug = faker.string.alphanumeric({ length: 10 }).toLowerCase() + kubeconfigId = faker.string.uuid() + clusterId = faker.string.uuid() + clusterLabel = faker.helpers.slugify(`cluster-${faker.string.uuid()}`.slice(0, 40)) + stageId = faker.string.uuid() + envDevId = faker.string.uuid() + envProdId = faker.string.uuid() + envDevName = 'dev' + envProdName = 'prod' + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.zone.create({ + data: { + id: zoneId, + slug: zoneSlug, + label: `Zone ${zoneSlug}`, + argocdUrl: 'https://example.com', + }, + }) + + await prisma.kubeconfig.create({ + data: { + id: kubeconfigId, + user: { token: faker.string.alphanumeric({ length: 16 }) }, + cluster: { server: 'https://example.com' }, + }, + }) + + await prisma.cluster.create({ + data: { + id: clusterId, + label: clusterLabel, + secretName: faker.string.uuid(), + kubeConfigId: kubeconfigId, + infos: null, + memory: 100, + cpu: 100, + gpu: 0, + zoneId, + }, + }) + + await prisma.stage.create({ + data: { + id: stageId, + name: faker.helpers.slugify(`stage-${faker.string.uuid()}`), + }, + }) + + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + clusters: { + connect: { id: clusterId }, + }, + repositories: { + create: { + internalRepoName: zoneSlug, + isInfra: true, + deployRevision: 'HEAD', + deployPath: '.', + helmValuesFiles: '', + }, + }, + environments: { + create: [ + { + id: envDevId, + name: envDevName, + clusterId, + stageId, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + }, + { + id: envProdId, + name: envProdName, + clusterId, + stageId, + cpu: 1, + gpu: 0, + memory: 1, + autosync: true, + }, + ], + }, + }, + }) + + infraRepoPath = `${config.projectsRootDir}/infra/${zoneSlug}` + try { + const existing = await gitlabClient.Projects.show(infraRepoPath) + if (existing.empty_repo || existing.default_branch !== 'main') { + await gitlabClient.Projects.remove(existing.id).catch(() => {}) + throw new Error('Recreate infra repo') + } + infraRepoId = existing.id + } catch (error: any) { + const description = error?.cause?.description ?? '' + if ( + !(typeof description === 'string' && description.includes('404')) + && !(error instanceof Error && error.message === 'Recreate infra repo') + ) { + throw error + } + + const infraGroup = await gitlab.getOrCreateProjectSubGroup('infra') + const created = await gitlabClient.Projects.create({ + name: zoneSlug, + path: zoneSlug, + namespaceId: infraGroup.id, + initializeWithReadme: true, + defaultBranch: 'main', + }) + infraRepoId = created.id + } + + vaultProjectValuesPath = `${config.projectsRootDir}/${testProjectId}` + await vault.write({ e2e: true }, vaultProjectValuesPath) + }) + + afterAll(async () => { + if (vaultProjectValuesPath) { + await vault.delete(vaultProjectValuesPath).catch(() => {}) + } + + if (infraRepoId) { + await gitlabClient.Projects.remove(infraRepoId).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.stage.deleteMany({ where: { id: stageId } }).catch(() => {}) + await prisma.cluster.deleteMany({ where: { id: clusterId } }).catch(() => {}) + await prisma.kubeconfig.deleteMany({ where: { id: kubeconfigId } }).catch(() => {}) + await prisma.zone.deleteMany({ where: { id: zoneId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should commit environment values and cleanup stale values in the zone infra repo', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + const infraProject = await gitlab.getOrCreateInfraGroupRepo(zoneSlug) + infraRepoId = infraProject.id + + const staleFilePath = `${project.name}/${clusterLabel}/stale/values.yaml` + if (!infraRepoId) throw new Error('Missing infra repo id') + const staleAction = await gitlab.generateCreateOrUpdateAction(infraProject, 'main', staleFilePath, 'stale: true\n') + await gitlab.maybeCreateCommit(infraProject, 'ci: :robot_face: Seed stale values', staleAction ? [staleAction] : []) + + await argocdController.handleUpsert(project) + + const expectedFilePath = `${project.name}/${clusterLabel}/${envDevName}/values.yaml` + const file = await gitlabClient.RepositoryFiles.show(infraRepoId, expectedFilePath, 'main') + const raw = Buffer.from(file.content, 'base64').toString('utf8') + const values = parse(raw) + + expect(values?.common?.['dso/project.slug']).toBe(testProjectSlug) + expect(values?.common?.['dso/environment']).toBe(envDevName) + expect(values?.environment?.valueFilePath).toBe(expectedFilePath) + expect(values?.application?.destination?.name).toBe(clusterLabel) + expect(values?.application?.autosync).toBe(true) + + const shouldBeDeleted = await gitlab.getFile(infraProject, staleFilePath, 'main') + expect(shouldBeDeleted).toBeUndefined() + }, 144000) + + it('should update existing values and delete values of a removed environment', async () => { + const before = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + const infraProject = await gitlab.getOrCreateInfraGroupRepo(zoneSlug) + infraRepoId = infraProject.id + + const devFilePath = `${before.name}/${clusterLabel}/${envDevName}/values.yaml` + const prodFilePath = `${before.name}/${clusterLabel}/${envProdName}/values.yaml` + + const seededActions = (await Promise.all([ + gitlab.generateCreateOrUpdateAction(infraProject, 'main', devFilePath, 'old: true\n'), + gitlab.generateCreateOrUpdateAction(infraProject, 'main', prodFilePath, 'old: true\n'), + ])).filter((action): action is NonNullable => action !== null) + await gitlab.maybeCreateCommit(infraProject, 'ci: :robot_face: Seed existing values', seededActions as CommitAction[]) + + await prisma.environment.deleteMany({ where: { id: envProdId } }) + + const after = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await argocdController.handleUpsert(after) + + const updatedDev = await gitlabClient.RepositoryFiles.show(infraRepoId, devFilePath, 'main') + const devRaw = Buffer.from(updatedDev.content, 'base64').toString('utf8') + const devValues = parse(devRaw) + expect(devValues?.common?.['dso/project.slug']).toBe(testProjectSlug) + expect(devValues?.common?.['dso/environment']).toBe(envDevName) + + const prodFile = await gitlab.getFile(infraProject, prodFilePath, 'main') + expect(prodFile).toBeUndefined() + }, 72000) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/test/gitlab.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/gitlab.e2e-spec.ts new file mode 100644 index 0000000000..e8d19c497b --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/gitlab.e2e-spec.ts @@ -0,0 +1,242 @@ +import type { ExpandedUserSchema, Gitlab } from '@gitbeaker/core' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' +import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' +import { projectSelect } from '../src/modules/gitlab/gitlab-datastore.service' +import { GitlabModule } from '../src/modules/gitlab/gitlab.module' +import { GitlabService } from '../src/modules/gitlab/gitlab.service' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunGitlabE2E + = Boolean(process.env.E2E) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + && Boolean(process.env.DB_URL) + +const describeWithGitLab = describe.runIf(canRunGitlabE2E) + +describeWithGitLab('GitlabController (e2e)', {}, () => { + let moduleRef: TestingModule + let gitlabController: GitlabService + let gitlabService: GitlabClientService + let gitlabClient: Gitlab + let vaultService: VaultClientService + let prisma: PrismaService + let config: BaseConfig + + let testProjectId: string + let testProjectSlug: string + let ownerId: string + let ownerUser: ExpandedUserSchema + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [GitlabModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + gitlabController = moduleRef.get(GitlabService) + gitlabService = moduleRef.get(GitlabClientService) + gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) + vaultService = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + config = moduleRef.get(baseConfigFactory) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + const ownerEmail = `test-owner-${ownerId}@example.com` + + // Create owner in GitLab + ownerUser = await gitlabClient.Users.create({ + name: 'Test Owner', + password: faker.internet.password({ length: 24 }), + username: `test-owner-${ownerId}`, + email: ownerEmail, + skipConfirmation: true, + }) + + // Create owner in DB + await prisma.user.create({ + data: { + id: ownerId, + email: ownerUser.email.toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + await prisma.repository.create({ + data: { + projectId: testProjectId, + internalRepoName: 'app', + externalRepoUrl: 'https://example.com/example.git', + isPrivate: false, + }, + }) + }) + + afterAll(async () => { + // Clean GitLab group + if (testProjectSlug && config.projectsRootDir) { + const fullPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = await gitlabService.getGroupByPath(fullPath) + if (group) { + await gitlabService.deleteGroup(group).catch(() => {}) + } + } + + // Clean Vault + if (testProjectSlug && config.projectsRootDir) { + const vaultPath = `${config.projectsRootDir}/${testProjectSlug}` + await vaultService.delete(`${vaultPath}/tech/GITLAB_MIRROR`).catch(() => {}) + await vaultService.delete(`${vaultPath}/app-mirror`).catch(() => {}) + } + + // Clean DB + if (prisma) { + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + if (ownerUser?.id) { + await gitlabClient.Users.remove(ownerUser.id).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should reconcile and create project group in GitLab and Vault secrets', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await gitlabController.handleUpsert(project) + + // Assert + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = z.object({ + id: z.number(), + name: z.string(), + full_path: z.string(), + web_url: z.string(), + }).parse(await gitlabService.getGroupByPath(groupPath)) + expect(group.full_path).toBe(groupPath) + + // Check membership + const members = await gitlabService.getGroupMembers(group) + const isMember = members.some(m => m.id === ownerUser.id) + expect(isMember).toBe(true) + + const repoVaultPath = `${config.projectsRootDir}/${testProjectSlug}/app-mirror` + const repoSecret = await vaultService.read(repoVaultPath) + expect(repoSecret?.data?.GIT_OUTPUT_USER).toBeTruthy() + expect(repoSecret?.data?.GIT_OUTPUT_PASSWORD).toBeTruthy() + }, 72000) + + describe('project members', () => { + let newUserId: string | undefined + let newUserGitlabId: number | undefined + + beforeAll(async () => { + newUserId = faker.string.uuid() + const newUser = await gitlabClient.Users.create({ + email: faker.internet.email().toLowerCase(), + username: faker.internet.username(), + name: `${faker.person.firstName()} ${faker.person.lastName()}`, + password: faker.internet.password({ length: 24 }), + skipConfirmation: true, + }) + newUserGitlabId = newUser.id + + await prisma.user.create({ + data: { + id: newUserId, + email: newUser.email, + firstName: 'Test', + lastName: 'User', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: newUserId, + roleIds: [], + }, + }) + }) + + afterAll(async () => { + if (newUserGitlabId) { + await gitlabClient.Users.remove(newUserGitlabId).catch(() => {}) + } + if (prisma && newUserId) { + await prisma.projectMembers.deleteMany({ where: { userId: newUserId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: newUserId } }).catch(() => {}) + } + }) + + it('should add member to GitLab group when added in DB', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await gitlabController.handleUpsert(project) + + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` + const group = z.object({ + id: z.number(), + name: z.string(), + web_url: z.string(), + }).parse(await gitlabService.getGroupByPath(groupPath)) + + const members = await gitlabService.getGroupMembers(group) + const isNewMemberPresent = members.some(m => m.id === newUserGitlabId) + expect(isNewMemberPresent).toBe(true) + }, 72000) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/test/keycloak.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/keycloak.e2e-spec.ts new file mode 100644 index 0000000000..9875253678 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -0,0 +1,537 @@ +import type KcAdminClient from '@keycloak/keycloak-admin-client' +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Logger } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { projectSelect } from '../src/modules/keycloak/keycloak-datastore.service' +import { KeycloakModule } from '../src/modules/keycloak/keycloak.module' +import { KeycloakService } from '../src/modules/keycloak/keycloak.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunKeycloakE2E + = Boolean(process.env.E2E) + && Boolean(process.env.KEYCLOAK_DOMAIN) + && Boolean(process.env.KEYCLOAK_REALM) + && Boolean(process.env.KEYCLOAK_PROTOCOL) + && Boolean(process.env.KEYCLOAK_ADMIN) + && Boolean(process.env.KEYCLOAK_ADMIN_PASSWORD) + +const describeWithKeycloak = describe.runIf(canRunKeycloakE2E) + +describeWithKeycloak('KeycloakController (e2e)', () => { + let moduleRef: TestingModule + let keycloakController: KeycloakService + let keycloak: KeycloakClientService + let keycloakAdminClient: KcAdminClient + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + let testRoleName: string + let testRoleId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [KeycloakModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + keycloakController = moduleRef.get(KeycloakService) + keycloak = moduleRef.get(KeycloakClientService) + keycloakAdminClient = moduleRef.get(KEYCLOAK_ADMIN_CLIENT) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + testRoleName = faker.helpers.slugify(`test-role-${faker.string.uuid()}`) + testRoleId = faker.string.uuid() + + const ownerEmail = faker.internet.email({ firstName: 'test-owner', provider: 'example.com' }) + + // Create owner in Keycloak + const createdUser = await keycloakAdminClient.users.create({ + id: ownerId, + username: `test-owner-${ownerId}`, + email: ownerEmail, + enabled: true, + firstName: 'Test', + lastName: 'Owner', + }) + if (createdUser.id) { + ownerId = createdUser.id + } + + // Create owner in DB + await prisma.user.create({ + data: { + id: ownerId, + email: ownerEmail, + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + try { + // Clean Keycloak + const group = await keycloak.getGroupByPath(`/${testProjectSlug}`) + if (group?.id) { + await keycloak.deleteGroup(group.id) + } + + // Clean owner user + if (ownerId) { + await keycloakAdminClient.users.del({ id: ownerId }).catch(() => {}) + if (prisma) { + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + } + + // Clean DB + if (prisma) { + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId } }) + // Prisma cascade delete should handle roles/envs if configured correctly, but explicit delete is safer + // We catch errors to avoid failing cleanup if tables/relations are different + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + } + } catch (e: any) { + Logger.warn(`Cleanup failed: ${e.message}`) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('should reconcile and create groups in Keycloak', async () => { + // Create Project in DB + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + roles: { + create: { + id: testRoleId, + name: testRoleName, + oidcGroup: `/${testRoleName}`, + permissions: BigInt(0), + position: 0, + }, + }, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await keycloakController.handleUpsert(project) + + // Assert + // Check main project group + const projectGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + expect(projectGroup.name).toBe(testProjectSlug) + + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console`)) + expect(consoleGroup.name).toBe('console') + + // Check role group + const roleGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(roleGroup.name).toBe(testRoleName) + + // Check membership (owner should be added) + const members = await keycloak.getGroupMembers(projectGroup.id) + const isMember = members.some(m => m.id === ownerId) + expect(isMember).toBe(true) + }, 60000) + + it('should add member to project group when added in DB', async () => { + // Create another user in Keycloak and DB + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-${newUserId}@example.com` + + // Create in Keycloak + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'User', + }) + + // Create in DB + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'User', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [testRoleId], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await keycloakController.handleUpsert(project) + + // Assert + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + const members = await keycloak.getGroupMembers(projectGroup.id) + const isMember = members.some(m => m.id === kcUser.id) + expect(isMember).toBe(true) + + // Check role group membership + const roleGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + const roleMembers = await keycloak.getGroupMembers(roleGroup.id) + const isRoleMember = roleMembers.some(m => m.id === kcUser.id) + expect(isRoleMember).toBe(true) + + // Cleanup user + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should remove member from project group when removed in DB', async () => { + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-remove-${newUserId}@example.com` + + // Create in Keycloak + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-remove-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserRemove', + }) + + // Create in DB + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserRemove', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [], // No roles + }, + }) + + let project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync add + await keycloakController.handleUpsert(project) + + // Verify added + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Remove from DB + await prisma.projectMembers.delete({ + where: { + projectId_userId: { + projectId: testProjectId, + userId: kcUser.id, + }, + }, + }) + + project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync remove + await keycloakController.handleUpsert(project) + + // Verify removed + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should handle non-existent users gracefully', async () => { + // Add a member in DB that does not exist in Keycloak + const fakeUserId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: fakeUserId, + email: `fake-${fakeUserId}@example.com`, + firstName: 'Fake', + lastName: 'User', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: fakeUserId, + roleIds: [], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act - should not throw + await expect(keycloakController.handleUpsert(project)).resolves.not.toThrow() + + // Cleanup + await prisma.projectMembers.deleteMany({ where: { userId: fakeUserId } }) + await prisma.user.delete({ where: { id: fakeUserId } }) + }, 60000) + + it('should add user back to Keycloak group if missing but present in DB', async () => { + // Create user and add to project in DB + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-sync-${newUserId}@example.com` + + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-sync-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserSync', + }) + + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserSync', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync to ensure they are added initially + await keycloakController.handleUpsert(project) + + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + + // Manually remove user from Keycloak group + await keycloak.removeUserFromGroup(kcUser.id, projectGroup.id) + + // Verify removal + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Sync again + await keycloakController.handleUpsert(project) + + // Verify added back + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should remove user from Keycloak group if present but missing in DB', async () => { + // Create user + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-orphan-${newUserId}@example.com` + + const kcUser = await keycloakAdminClient.users.create({ + username: `test-user-orphan-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserOrphan', + }) + + // We only need them in Keycloak for this test, but the controller checks if user is in DB to define "missing". + // Actually, `deleteExtraProjectMembers` iterates over Keycloak group members. + // So we don't strictly need the user in DB, but to be "clean" we should probably have them in DB but NOT in the project. + + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserOrphan', + type: 'human', + }, + }) + + // Get project from DB (user is NOT a member) + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync to create group + await keycloakController.handleUpsert(project) + + // Manually add user to Keycloak group + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + await keycloak.addUserToGroup(kcUser.id, projectGroup.id) + + // Verify added + let members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Sync again to remove user + await keycloakController.handleUpsert(project) + + // Verify removed + members = await keycloak.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakAdminClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should recreate project group if deleted in Keycloak', async () => { + // Ensure project exists and is synced + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await keycloakController.handleUpsert(project) + + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + + // Delete group in Keycloak + await keycloak.deleteGroup(projectGroup.id) + + // Verify deleted + const deletedProjectGroup = await keycloak.getGroupByPath(`/${testProjectSlug}`) + expect(deletedProjectGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedProjectGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}`)) + expect(recreatedProjectGroup?.name).toBe(testProjectSlug) + }, 60000) + + it('should recreate role group if deleted in Keycloak', async () => { + // Ensure project exists and is synced + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await keycloakController.handleUpsert(project) + + const roleGroup = z.object({ + id: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + + // Delete role group in Keycloak + await keycloak.deleteGroup(roleGroup.id) + + // Verify deleted + const deletedRoleGroup = await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`) + expect(deletedRoleGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedRoleGroup = z.object({ + name: z.string(), + }).parse(await keycloak.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(recreatedRoleGroup?.name).toBe(testRoleName) + }, 60000) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/test/log.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/log.e2e-spec.ts new file mode 100644 index 0000000000..2b5f509b3f --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/log.e2e-spec.ts @@ -0,0 +1,188 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { LogModule } from '../src/modules/log/log.module' +import { LogService } from '../src/modules/log/log.service' + +const canRunLogE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + +const describeWithLog = describe.runIf(canRunLogE2E) + +describeWithLog('LogService (e2e)', () => { + let moduleRef: TestingModule + let logService: LogService + let prisma: PrismaService + + let ownerId: string + let projectId: string + let projectSlug: string + let projectLogId: string + let globalLogId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [LogModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + logService = moduleRef.get(LogService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + if (projectLogId) { + await prisma.log.deleteMany({ where: { id: projectLogId } }).catch(() => {}) + } + if (globalLogId) { + await prisma.log.deleteMany({ where: { id: globalLogId } }).catch(() => {}) + } + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should persist logs and return clean/project-filtered results', async () => { + const projectLog = await logService.addLog({ + action: 'project-upsert', + userId: ownerId, + projectId, + requestId: faker.string.uuid(), + data: { + args: { slug: projectSlug }, + results: { ok: true }, + config: { hidden: true }, + cluster: { id: 'cluster-1' }, + user: { id: ownerId }, + newCreds: { token: 'secret' }, + apis: ['v1'], + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }, + }) + projectLogId = projectLog.id + + const globalLog = await logService.addLog({ + action: 'global-upsert', + data: { + args: { enabled: true }, + results: { ok: true }, + config: { hidden: true }, + }, + }) + globalLogId = globalLog.id + + const storedLog = await prisma.log.findUniqueOrThrow({ + where: { id: projectLog.id }, + }) + expect(storedLog.data).toMatchObject({ + args: { slug: projectSlug }, + results: { ok: true }, + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }) + expect(storedLog.data).not.toHaveProperty('config') + expect(storedLog.data).not.toHaveProperty('cluster') + expect(storedLog.data).not.toHaveProperty('user') + expect(storedLog.data).not.toHaveProperty('newCreds') + expect(storedLog.data).not.toHaveProperty('apis') + + const { total: projectTotal, logs: projectLogs } = await logService.getLogs({ + offset: 0, + limit: 10, + projectId, + clean: false, + }) + expect(projectTotal).toBe(1) + expect(projectLogs).toHaveLength(1) + expect(projectLogs[0]).toMatchObject({ + id: projectLog.id, + action: 'project-upsert', + userId: ownerId, + projectId, + }) + expect(projectLogs[0].data).toMatchObject({ + args: { slug: projectSlug }, + results: { ok: true }, + warning: ['careful'], + }) + expect(projectLogs[0].data).not.toHaveProperty('config') + + const { total: allTotal, logs: allLogs } = await logService.getLogs({ + offset: 0, + limit: 10, + projectId: undefined, + clean: true, + }) + expect(allTotal).toBeGreaterThan(2) + expect(allLogs).toHaveLength(10) + expect(allLogs[0]).toMatchObject({ + id: globalLog.id, + action: 'global-upsert', + userId: null, + }) + expect(allLogs[0].data).not.toHaveProperty('args') + expect(allLogs[0].data).not.toHaveProperty('results') + expect(allLogs[0].data).not.toHaveProperty('config') + expect(allLogs[1]).toMatchObject({ + id: projectLog.id, + action: 'project-upsert', + userId: ownerId, + }) + expect(allLogs[1].data).toMatchObject({ + warning: ['careful'], + totalExecutionTime: 42, + messageResume: 'done', + }) + expect(allLogs[1].data).not.toHaveProperty('args') + expect(allLogs[1].data).not.toHaveProperty('results') + expect(allLogs[1].data).not.toHaveProperty('config') + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/test/nexus.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/nexus.e2e-spec.ts new file mode 100644 index 0000000000..0c5b45eb5d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/nexus.e2e-spec.ts @@ -0,0 +1,156 @@ +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { ENABLED } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { NexusClientService } from '../src/modules/nexus/nexus-client.service' +import { projectSelect } from '../src/modules/nexus/nexus-datastore.service' +import { makeProjectWithDetails } from '../src/modules/nexus/nexus-testing.utils' +import { NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, PLUGIN_NAME } from '../src/modules/nexus/nexus.constants' +import { NexusModule } from '../src/modules/nexus/nexus.module' +import { NexusService } from '../src/modules/nexus/nexus.service' +import { getProjectVaultPath } from '../src/modules/nexus/nexus.utils' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunNexusE2E + = Boolean(process.env.E2E) + && Boolean(process.env.NEXUS_URL) + && Boolean(process.env.NEXUS_ADMIN) + && Boolean(process.env.NEXUS_ADMIN_PASSWORD) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithNexus = describe.runIf(canRunNexusE2E) + +describeWithNexus('NexusController (e2e)', () => { + let moduleRef: TestingModule + let nexusController: NexusService + let nexusClient: NexusClientService + let vaultService: VaultClientService + let config: BaseConfig + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [NexusModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + nexusController = moduleRef.get(NexusService) + nexusClient = moduleRef.get(NexusClientService) + vaultService = moduleRef.get(VaultClientService) + config = moduleRef.get(baseConfigFactory) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (testProjectSlug) { + await nexusController.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should reconcile project in Nexus (repos, role, user, vault secret)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + plugins: { + create: [ + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_MAVEN_REPO, value: ENABLED }, + { pluginName: PLUGIN_NAME, key: NEXUS_CONFIG_KEY_ACTIVATE_NPM_REPO, value: ENABLED }, + ], + }, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await nexusController.handleUpsert(project) + + const mavenReleaseRepo = `${testProjectSlug}-repository-release` + const mavenSnapshotRepo = `${testProjectSlug}-repository-snapshot` + const mavenGroupRepo = `${testProjectSlug}-repository-group` + + const npmHostedRepo = `${testProjectSlug}-npm` + const npmGroupRepo = `${testProjectSlug}-npm-group` + + const [releaseRepo, snapshotRepo, groupRepo, npmRepo, npmGroup] = await Promise.all([ + nexusClient.getRepositoriesMavenHosted(mavenReleaseRepo), + nexusClient.getRepositoriesMavenHosted(mavenSnapshotRepo), + nexusClient.getRepositoriesMavenGroup(mavenGroupRepo), + nexusClient.getRepositoriesNpmHosted(npmHostedRepo), + nexusClient.getRepositoriesNpmGroup(npmGroupRepo), + ]) + + expect(releaseRepo).toBeTruthy() + expect(snapshotRepo).toBeTruthy() + expect(groupRepo).toBeTruthy() + expect(npmRepo).toBeTruthy() + expect(npmGroup).toBeTruthy() + + const roleId = `${testProjectSlug}-ID` + const role = await nexusClient.getSecurityRoles(roleId) + expect(role).toBeTruthy() + + const users = await nexusClient.getSecurityUsers(testProjectSlug) + expect(users.some(u => u.userId === testProjectSlug)).toBe(true) + + const vaultPath = getProjectVaultPath(config.projectsRootDir, testProjectSlug, 'tech/NEXUS') + const secret = await vaultService.read(vaultPath) + expect(secret.data?.NEXUS_USERNAME).toBe(testProjectSlug) + expect(secret.data?.NEXUS_PASSWORD).toBeTruthy() + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/test/project-bulk.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/project-bulk.e2e-spec.ts new file mode 100644 index 0000000000..3a1e80f39d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/project-bulk.e2e-spec.ts @@ -0,0 +1,109 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectBulkModule } from '../src/modules/project-bulk/project-bulk.module' +import { ProjectBulkService } from '../src/modules/project-bulk/project-bulk.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectBulkE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectBulk = describe.runIf(canRunProjectBulkE2E) + +describeWithProjectBulk('ProjectBulkService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectBulkService + let eventEmitter: EventEmitter2 + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectBulkModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectBulkService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('locks and unlocks projects', async () => { + await service.bulkAction({ action: 'lock', projectIds: [projectId] }) + const locked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } }) + expect(locked.locked).toBe(true) + + await service.bulkAction({ action: 'unlock', projectIds: [projectId] }) + const unlocked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } }) + expect(unlocked.locked).toBe(false) + }) + + it('replays hooks for projects', async () => { + await service.bulkAction({ action: 'replay', projectIds: [projectId] }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith( + 'project.upsert', + expect.objectContaining({ id: projectId, slug: projectSlug }), + ) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/test/project-hooks.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/project-hooks.e2e-spec.ts new file mode 100644 index 0000000000..ab6e75184e --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/project-hooks.e2e-spec.ts @@ -0,0 +1,119 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectHooksModule } from '../src/modules/project-hooks/project-hooks.module' +import { ProjectHooksService } from '../src/modules/project-hooks/project-hooks.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectHooksE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectHooks = describe.runIf(canRunProjectHooksE2E) + +describeWithProjectHooks('ProjectHooksService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectHooksService + let eventEmitter: DeepMockProxy + let vaultService: DeepMockProxy + let vaultClient: DeepMockProxy + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + vaultService = mockDeep() + vaultService.listProjectSecrets.mockResolvedValue([]) + vaultClient = mockDeep() + + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectHooksModule], + providers: [ + { provide: VaultService, useValue: vaultService }, + { provide: VaultClientService, useValue: vaultClient }, + ], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectHooksService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('replays hooks through the event system', async () => { + await service.replay(projectId) + + expect(eventEmitter.emitAsync).toHaveBeenCalledWith( + 'project.upsert', + expect.objectContaining({ id: projectId, slug: projectSlug }), + ) + }) + + it('rejects replayHooks when the project does not exist', async () => { + const result = await service.replay(faker.string.uuid()) + expect(result).toBeUndefined() + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/test/project-members.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/project-members.e2e-spec.ts new file mode 100644 index 0000000000..10dec97860 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/project-members.e2e-spec.ts @@ -0,0 +1,169 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { BadRequestException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' +import { ProjectMembersModule } from '../src/modules/project-members/project-members.module' +import { ProjectMembersService } from '../src/modules/project-members/project-members.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectMembersE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectMembers = describe.runIf(canRunProjectMembersE2E) + +describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectMembersService + let eventEmitter: DeepMockProxy + let keycloakClient: DeepMockProxy + + let ownerId: string + let memberId: string + + beforeAll(async () => { + keycloakClient = mockDeep() + keycloakClient.getUserByEmail.mockResolvedValue(undefined) + + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectMembersModule], + providers: [ + { provide: KeycloakClientService, useValue: keycloakClient }, + ], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectMembersService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + memberId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.user.create({ + data: { + id: memberId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Member', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.user.deleteMany({ where: { id: memberId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('rejects addMember when project does not exist', async () => { + await expect(service.add(faker.string.uuid(), { userId: memberId })).rejects.toThrow(NotFoundException) + }) + + it('rejects listMembers when project does not exist', async () => { + await expect(service.list(faker.string.uuid())).resolves.toEqual([]) + }) + + describe('with project', () => { + let projectId: string + + beforeEach(async () => { + eventEmitter.emitAsync.mockClear() + + projectId = faker.string.uuid() + const projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + + eventEmitter.emitAsync.mockClear() + }) + + afterEach(async () => { + await prisma.projectMembers.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + }) + + it('list', async () => { + const members = await service.list(projectId) + expect(members).toHaveLength(0) + }) + + it('add', async () => { + const afterAdd = await service.add(projectId, { userId: memberId }) + expect(afterAdd.some(m => m.userId === memberId)).toBe(true) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.upsert', { projectId, userId: memberId }) + }) + + it('patch', async () => { + await service.add(projectId, { userId: memberId }) + const roleId = faker.string.uuid() + const afterPatch = await service.patch(projectId, [{ userId: memberId, roles: [roleId] }]) + expect(afterPatch.find(m => m.userId === memberId)?.roleIds).toContain(roleId) + }) + + it('remove', async () => { + await service.add(projectId, { userId: memberId }) + const afterRemove = await service.remove(projectId, memberId) + expect(afterRemove.some(m => m.userId === memberId)).toBe(false) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('projectMember.delete', { projectId, userId: memberId }) + }) + + it('rejects addMember when adding owner', async () => { + await expect(service.add(projectId, { userId: ownerId })).rejects.toThrow(BadRequestException) + }) + + it('rejects addMember when user does not exist', async () => { + await expect(service.add(projectId, { userId: faker.string.uuid() })).rejects.toThrow(NotFoundException) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/test/project-roles.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/project-roles.e2e-spec.ts new file mode 100644 index 0000000000..0f530ebf53 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/project-roles.e2e-spec.ts @@ -0,0 +1,238 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectRolesModule } from '../src/modules/project-roles/project-roles.module' +import { ProjectRolesService } from '../src/modules/project-roles/project-roles.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectRolesE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectRoles = describe.runIf(canRunProjectRolesE2E) + +describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectRolesService + let eventEmitter: DeepMockProxy + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectRolesModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectRolesService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-roles-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E roles test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.projectRole.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('lists roles for a project', async () => { + const role = await prisma.projectRole.create({ + data: { + projectId, + name: 'admin', + permissions: 4n, + position: 0, + type: 'managed', + oidcGroup: `/${projectSlug}/admin`, + }, + }) + + const roles = await service.list(projectId) + + expect(roles).toHaveLength(1) + expect(roles[0].id).toBe(role.id) + expect(roles[0].permissions).toBe('4') + expect(roles[0].oidcGroup).toBe('/admin') + }) + + it('creates a role and emits project upsert', async () => { + const role = await service.create(projectId, { name: 'developer', permissions: '2' }) + + expect(role.length).toBeGreaterThanOrEqual(1) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', expect.objectContaining({ id: projectId })) + + const dbRole = await prisma.projectRole.findFirst({ + where: { projectId, name: 'developer' }, + }) + expect(dbRole).not.toBeNull() + expect(dbRole?.permissions).toBe(2n) + }) + + it('patches roles and updates position', async () => { + const existingRole = await prisma.projectRole.create({ + data: { + projectId, + name: 'tester', + permissions: 1n, + position: 5, + type: 'managed', + oidcGroup: '', + }, + }) + + await prisma.projectRole.create({ + data: { + projectId, + name: 'other', + permissions: 2n, + position: 6, + type: 'managed', + oidcGroup: '', + }, + }) + + const roles = await service.update(projectId, [{ + id: existingRole.id, + name: 'qa', + }]) + + const updated = roles.find(r => r.id === existingRole.id) + expect(updated?.name).toBe('qa') + }) + + it('counts role members', async () => { + const roleId = faker.string.uuid() + await prisma.projectRole.create({ + data: { + id: roleId, + projectId, + name: 'counted', + permissions: 1n, + position: 10, + type: 'managed', + oidcGroup: '', + }, + }) + + const userId = faker.string.uuid() + await prisma.user.create({ + data: { + id: userId, + email: faker.internet.email().toLowerCase(), + firstName: 'Member', + lastName: 'Test', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId, + userId, + roleIds: [roleId], + }, + }) + + const counts = await service.countMembers(projectId) + expect(counts[roleId]).toBe(1) + }) + + it('deletes a role and cleans member role ids', async () => { + const roleId = faker.string.uuid() + const userId = faker.string.uuid() + + await prisma.projectRole.create({ + data: { + id: roleId, + projectId, + name: 'to-delete', + permissions: 1n, + position: 20, + type: 'managed', + oidcGroup: '', + }, + }) + + await prisma.user.create({ + data: { + id: userId, + email: faker.internet.email().toLowerCase(), + firstName: 'Delete', + lastName: 'Test', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId, + userId, + roleIds: [roleId], + }, + }) + + await service.delete(roleId) + + const member = await prisma.projectMembers.findUnique({ + where: { projectId_userId: { projectId, userId } }, + }) + expect(member?.roleIds).not.toContain(roleId) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith('project.upsert', expect.objectContaining({ id: projectId })) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/test/project-secrets.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/project-secrets.e2e-spec.ts new file mode 100644 index 0000000000..bb30aff5d7 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/project-secrets.e2e-spec.ts @@ -0,0 +1,145 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectSecretsModule } from '../src/modules/project-secrets/project-secrets.module' +import { ProjectSecretsService } from '../src/modules/project-secrets/project-secrets.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { generateProjectPath } from '../src/modules/vault/vault.utils' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectSecretsE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithProjectSecrets = describe.runIf(canRunProjectSecretsE2E) + +describeWithProjectSecrets('ProjectSecretsService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectSecretsService + let vaultClient: VaultClientService + + let ownerId: string + let projectId: string + let projectSlug: string + let projectPath: string + let secretAPath: string + let secretBPath: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectSecretsModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectSecretsService) + vaultClient = moduleRef.get(VaultClientService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + projectPath = generateProjectPath(process.env.PROJECTS_ROOT_DIR, projectSlug) + secretAPath = `${projectPath}/group1/secret1` + secretBPath = `${projectPath}/group2/secret2` + + await Promise.all([secretAPath, secretBPath].map(path => vaultClient.delete(path).catch(() => {}))) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + await Promise.all([secretAPath, secretBPath].map(path => vaultClient.delete(path).catch(() => {}))) + + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('returns an empty secret map when no Vault secrets exist', async () => { + const secrets = await service.get(projectId) + expect(secrets).toEqual({}) + }) + + describe('when Vault secrets exist', () => { + beforeAll(async () => { + await Promise.all([ + vaultClient.write({ + key1: 'value1', + key2: 42, + }, secretAPath), + vaultClient.write({ + key3: false, + key4: null, + }, secretBPath), + ]) + }) + + afterAll(async () => { + await Promise.all([ + vaultClient.delete(secretAPath).catch(() => {}), + vaultClient.delete(secretBPath).catch(() => {}), + ]) + }) + + it('parses real Vault values into grouped secrets', async () => { + const secrets = await service.get(projectId) + + expect(secrets.group1).toEqual({ + 'secret1.key1': 'value1', + 'secret1.key2': '42', + }) + expect(secrets.group2).toEqual({ + 'secret2.key3': 'false', + 'secret2.key4': '', + }) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/test/project-services.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/project-services.e2e-spec.ts new file mode 100644 index 0000000000..25612a1f99 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/project-services.e2e-spec.ts @@ -0,0 +1,144 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectServicesModule } from '../src/modules/project-services/project-services.module' +import { ProjectServicesService } from '../src/modules/project-services/project-services.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunServicesE2E + = Boolean(process.env.E2E) + && Boolean(process.env.DB_URL) + && Boolean(process.env.ARGOCD_URL) + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.HARBOR_URL) + && Boolean(process.env.HARBOR_ADMIN) + && Boolean(process.env.HARBOR_ADMIN_PASSWORD) + && Boolean(process.env.KEYCLOAK_DOMAIN) + && Boolean(process.env.KEYCLOAK_REALM) + && Boolean(process.env.KEYCLOAK_PROTOCOL) + && Boolean(process.env.NEXUS_URL) + && Boolean(process.env.NEXUS_ADMIN) + && Boolean(process.env.NEXUS_ADMIN_PASSWORD) + && Boolean(process.env.SONARQUBE_URL) + && Boolean(process.env.SONAR_API_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithServices = describe.runIf(canRunServicesE2E) + +const PLUGIN_NAME = 'gitlab' + +describeWithServices('ProjectServicesService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectServicesService + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectServicesModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectServicesService) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.projectPlugin.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('get returns services for a project member', async () => { + const services = await service.get(projectId, 'user') + const gitlabService = services.find(entry => entry.name === PLUGIN_NAME) + + expect(gitlabService).toBeTruthy() + expect(gitlabService?.name).toBe(PLUGIN_NAME) + }) + + it('rejects get when project does not exist', async () => { + await expect(service.get(faker.string.uuid(), 'user')).rejects.toThrow(NotFoundException) + }) + + it('update stores project configuration', async () => { + await service.update(projectId, { + gitlab: { + enabled: 'enabled', + }, + }, ['user']) + + const stored = await prisma.projectPlugin.findUniqueOrThrow({ + where: { + projectId_pluginName_key: { + projectId, + pluginName: PLUGIN_NAME, + key: 'user.enabled', + }, + }, + select: { + value: true, + }, + }) + + expect(stored.value).toBe('enabled') + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/test/project.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/project.e2e-spec.ts new file mode 100644 index 0000000000..0aa6b9011d --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/project.e2e-spec.ts @@ -0,0 +1,153 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { ForbiddenException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { ProjectPermissionModule } from '../src/modules/infrastructure/permission/project/project.module' +import { makeCreateProjectBody } from '../src/modules/project/project-testing.utils' +import { ProjectService } from '../src/modules/project/project.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunProjectE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProject = describe.runIf(canRunProjectE2E) + +describeWithProject('ProjectService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectService + let eventEmitter: DeepMockProxy + + let ownerId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectPermissionModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('ProjectService.create', async () => { + const createBody = makeCreateProjectBody({ + name: faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`), + description: 'Initial description', + }) + const created = await service.create(createBody, ownerId) + await prisma.project.deleteMany({ where: { id: created.id } }).catch(() => {}) + expect(created.id).toBeTruthy() + }) + + it('rejects ProjectService.list filter=all for non-admin', async () => { + await expect( + service.listProjects({ filter: 'all' }, { userId: ownerId, adminPermissions: 0n }), + ).rejects.toThrow(ForbiddenException) + }) + + it('rejects ProjectService.get when project does not exist', async () => { + await expect(service.get(faker.string.uuid())).rejects.toThrow(NotFoundException) + }) + + describe('with project', () => { + let projectId: string + let projectName: string + + beforeEach(async () => { + eventEmitter.emitAsync.mockClear() + + const createBody = makeCreateProjectBody({ + name: faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`), + description: 'Initial description', + }) + + const created = await service.create(createBody, ownerId) + projectId = created.id + projectName = created.name + + eventEmitter.emitAsync.mockClear() + }) + + afterEach(async () => { + await prisma.projectMembers.deleteMany({ where: { projectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + }) + + it('ProjectService.get', async () => { + const fetched = await service.get(projectId) + expect(fetched.id).toBe(projectId) + expect(fetched.ownerId).toBe(ownerId) + }) + + it('ProjectService.list', async () => { + const listResult = await service.listProjects({ filter: 'member' }, { userId: ownerId, adminPermissions: 0n }) + expect(listResult.some(p => p.id === projectId)).toBe(true) + }) + + it('ProjectService.getData', async () => { + const dataExport = await service.getProjectsData() + expect(Array.isArray(dataExport)).toBe(true) + expect(dataExport.some(p => p.name === projectName)).toBe(true) + }) + + it('ProjectService.update', async () => { + const updated = await service.update( + { description: 'Updated description' }, + { userId: ownerId, adminPermissions: 0n }, + projectId, + ) + expect(updated.description).toBe('Updated description') + }) + + it('ProjectService.archive', async () => { + await service.archive(projectId) + const archived = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { status: true, locked: true } }) + expect(archived.status).toBe('archived') + expect(archived.locked).toBe(true) + }) + + it('rejects ProjectService.update when project is locked', async () => { + await prisma.project.update({ where: { id: projectId }, data: { locked: true } }) + await expect( + service.update({ description: 'nope' }, { userId: ownerId, adminPermissions: 0n }, projectId), + ).rejects.toThrow(ForbiddenException) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/test/registry.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/registry.e2e-spec.ts new file mode 100644 index 0000000000..2fdf921814 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/registry.e2e-spec.ts @@ -0,0 +1,92 @@ +import type { ConfigType } from '@nestjs/config' +import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { harborConfigFactory } from '../src/config/harbor.config' +import { RegistryClientService } from '../src/modules/registry/registry-client.service' +import { makeProjectWithDetails } from '../src/modules/registry/registry-testing.utils' +import { ROBOT_NAME_PROJECT, ROBOT_NAME_RO, ROBOT_NAME_RW } from '../src/modules/registry/registry.constants' +import { RegistryModule } from '../src/modules/registry/registry.module' +import { RegistryService } from '../src/modules/registry/registry.service' +import { getHostFromUrl, getProjectVaultPath } from '../src/modules/registry/registry.utils' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' +import { getAll } from '../src/utils/iterable.utils' + +const canRunRegistryE2E + = Boolean(process.env.E2E) + && Boolean(process.env.HARBOR_URL) + && Boolean(process.env.HARBOR_ADMIN) + && Boolean(process.env.HARBOR_ADMIN_PASSWORD) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithRegistry = describe.runIf(canRunRegistryE2E) + +describeWithRegistry('RegistryService (e2e)', () => { + let moduleRef: TestingModule + let registry: RegistryService + let client: RegistryClientService + let vault: VaultClientService + let config: BaseConfig + let harborConfig: ConfigType + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory, harborConfigFactory] }), RegistryModule], + }) + .compile() + + await moduleRef.init() + + registry = moduleRef.get(RegistryService) + client = moduleRef.get(RegistryClientService) + vault = moduleRef.get(VaultClientService) + config = moduleRef.get(baseConfigFactory) + harborConfig = moduleRef.get(harborConfigFactory) + + projectSlug = faker.helpers.slugify(`test-project-${faker.string.alphanumeric({ length: 10 }).toLowerCase()}`).slice(0, 50) + }) + + afterAll(async () => { + if (vault && config && projectSlug) { + const paths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectsRootDir, `REGISTRY/${name}`)) + await Promise.all(paths.map(path => vault.delete(path).catch(() => {}))) + } + + if (registry && projectSlug) { + await registry.deleteProject(projectSlug).catch(() => {}) + } + + await moduleRef?.close() + }) + + it('should provision project in Harbor and write robot secrets to Vault', async () => { + const result = await registry.ensureProject({ slug: projectSlug, plugins: [] }, { publishProjectRobot: true }) + expect(result.basePath).toBe(`${getHostFromUrl(harborConfig.url)}/${projectSlug}/`) + + const project = await client.getProjectByName(projectSlug) + expect(project.status).toBe(200) + + const robots = await getAll(client.getProjectRobots(Number(project.data?.project_id))) + const robotNames = robots.flatMap(r => r.name ? [r.name] : []) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RO}`) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RW}`) + expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) + + const vaultPaths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectsRootDir, `REGISTRY/${name}`)) + const [roSecret, rwSecret, projectSecret] = await Promise.all(vaultPaths.map(path => vault.read(path))) + expect(roSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(rwSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(projectSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(roSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RO}`) + expect(rwSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RW}`) + expect(projectSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) + }) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/test/sonarqube.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/sonarqube.e2e-spec.ts new file mode 100644 index 0000000000..b8037965ef --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/sonarqube.e2e-spec.ts @@ -0,0 +1,183 @@ +import type { TestingModule } from '@nestjs/testing' +import { generateProjectKey } from '@cpn-console/hooks' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { SonarqubeClientService } from '../src/modules/sonarqube/sonarqube-client.service' +import { projectSelect } from '../src/modules/sonarqube/sonarqube-datastore.service' +import { makeProjectWithDetails } from '../src/modules/sonarqube/sonarqube-testing.utils' +import { SonarqubeModule } from '../src/modules/sonarqube/sonarqube.module' +import { SonarqubeService } from '../src/modules/sonarqube/sonarqube.service' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunSonarqubeE2E + = Boolean(process.env.E2E) + && Boolean(process.env.SONARQUBE_URL) + && Boolean(process.env.SONAR_API_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithSonarqube = describe.runIf(canRunSonarqubeE2E) + +describeWithSonarqube('SonarqubeService (e2e)', () => { + let moduleRef: TestingModule + let sonarqubeService: SonarqubeService + let sonarqubeClient: SonarqubeClientService + let vaultService: VaultClientService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + let testRepoName: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [SonarqubeModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + sonarqubeService = moduleRef.get(SonarqubeService) + sonarqubeClient = moduleRef.get(SonarqubeClientService) + vaultService = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-sonar-${faker.string.alphanumeric(8).toLowerCase()}`) + testRepoName = faker.helpers.slugify(`test-sonar-${faker.string.alphanumeric(8).toLowerCase()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (sonarqubeService && testProjectSlug) { + await sonarqubeService.handleDelete( + makeProjectWithDetails({ slug: testProjectSlug, repositories: [] }), + ).catch(() => {}) + } + + if (prisma) { + await prisma.repository.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should create platform groups during initialization', async () => { + // init() is triggered by moduleRef.init() via onModuleInit — groups must already exist + const [adminResult, readonlyResult, securityResult] = await Promise.all([ + sonarqubeClient.searchUserGroup({ q: '/console/admin' }), + sonarqubeClient.searchUserGroup({ q: '/console/readonly' }), + sonarqubeClient.searchUserGroup({ q: '/console/security' }), + ]) + + expect(adminResult.groups.some(g => g.name === '/console/admin')).toBe(true) + expect(readonlyResult.groups.some(g => g.name === '/console/readonly')).toBe(true) + expect(securityResult.groups.some(g => g.name === '/console/security')).toBe(true) + }) + + it('should reconcile project in SonarQube (groups, user, repository, vault secret)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E SonarQube Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + await prisma.repository.create({ + data: { + projectId: testProjectId, + internalRepoName: testRepoName, + isPrivate: false, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await sonarqubeService.handleUpsert(project) + + // All 5 project role groups should exist in SonarQube + const projectGroupNames = [ + `/${testProjectSlug}/console/admin`, + `/${testProjectSlug}/console/devops`, + `/${testProjectSlug}/console/developer`, + `/${testProjectSlug}/console/security`, + `/${testProjectSlug}/console/readonly`, + ] + for (const groupName of projectGroupNames) { + const result = await sonarqubeClient.searchUserGroup({ q: groupName }) + expect(result.groups.some(g => g.name === groupName), `group ${groupName} should exist`).toBe(true) + } + + // Robot/CI user should exist + const usersResult = await sonarqubeClient.searchUsers({ q: testProjectSlug }) + expect(usersResult.users.some(u => u.login === testProjectSlug)).toBe(true) + + // SonarQube analysis project for the repository should exist + const projectKey = generateProjectKey(testProjectSlug, testRepoName) + const projectsResult = await sonarqubeClient.searchProject({ q: testProjectSlug }) + expect(projectsResult.components.some(p => p.key === projectKey)).toBe(true) + + // Vault credentials should be written with correct username and token + const vaultSecret = await vaultService.readSonarqubeUser(testProjectSlug) + expect(vaultSecret?.data?.SONAR_USERNAME).toBe(testProjectSlug) + expect(vaultSecret?.data?.SONAR_TOKEN).toBeTruthy() + expect(vaultSecret?.data?.SONAR_PASSWORD).toBeTruthy() + }, 30000) + + it('should delete the project from SonarQube and remove vault credentials', async () => { + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await sonarqubeService.handleDelete(project) + + // SonarQube analysis project should be removed + const projectKey = generateProjectKey(testProjectSlug, testRepoName) + const projectsResult = await sonarqubeClient.searchProject({ q: testProjectSlug }) + expect(projectsResult.components.some(p => p.key === projectKey)).toBe(false) + + // Vault credentials should be removed + const vaultSecret = await vaultService.readSonarqubeUser(testProjectSlug) + expect(vaultSecret).toBeNull() + }, 30000) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/test/vault.e2e-spec.ts b/.jjconflict-side-2/apps/server-nestjs/test/vault.e2e-spec.ts new file mode 100644 index 0000000000..6947cb9b53 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/test/vault.e2e-spec.ts @@ -0,0 +1,108 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' +import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' +import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { EventsModule } from '../src/modules/infrastructure/events/events.module' +import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' +import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' +import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { projectSelect } from '../src/modules/vault/vault-datastore.service' +import { makeProjectWithDetails } from '../src/modules/vault/vault-testing.utils' +import { VaultModule } from '../src/modules/vault/vault.module' +import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' + +const canRunVaultE2E + = Boolean(process.env.E2E) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.DB_URL) + +const describeWithVault = describe.runIf(canRunVaultE2E) + +describeWithVault('VaultController (e2e)', () => { + let moduleRef: TestingModule + let vaultController: VaultService + let vaultClient: VaultClientService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + }).compile() + + await moduleRef.init() + + vaultController = moduleRef.get(VaultService) + vaultClient = moduleRef.get(VaultClientService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (testProjectSlug) { + await vaultController.handleDelete(makeProjectWithDetails({ slug: testProjectSlug })).catch(() => {}) + } + + if (prisma) { + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.unstubAllEnvs() + }) + + it('should reconcile project in Vault (mount, group, role)', async () => { + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + await vaultController.handleUpsert(project) + + const group = await vaultClient.getIdentityGroupName(testProjectSlug) + expect(group.data?.id).toBeTruthy() + expect(group.data?.name).toBe(testProjectSlug) + expect(group.data?.alias?.name).toBe(`/${testProjectSlug}`) + }, 180000) +}) diff --git a/.jjconflict-side-2/apps/server-nestjs/tsconfig.build.json b/.jjconflict-side-2/apps/server-nestjs/tsconfig.build.json new file mode 100644 index 0000000000..64f86c6bd2 --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/.jjconflict-side-2/apps/server-nestjs/tsconfig.json b/.jjconflict-side-2/apps/server-nestjs/tsconfig.json new file mode 100644 index 0000000000..48b6403d6f --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "incremental": true, + "target": "ES2023", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "baseUrl": "./", + "module": "nodenext", + "moduleResolution": "nodenext", + "resolvePackageJsonExports": true, + "strictBindCallApply": false, + "strictNullChecks": true, + "noFallthroughCasesInSwitch": false, + "noImplicitAny": false, + "declaration": true, + "outDir": "./dist", + "removeComments": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + } +} diff --git a/.jjconflict-side-2/apps/server-nestjs/vitest.config.ts b/.jjconflict-side-2/apps/server-nestjs/vitest.config.ts new file mode 100644 index 0000000000..b0337f6d0b --- /dev/null +++ b/.jjconflict-side-2/apps/server-nestjs/vitest.config.ts @@ -0,0 +1,22 @@ +import path from 'node:path' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: [ + { find: '@cpn-console/logger/hooks', replacement: path.resolve(__dirname, '../../packages/logger/src/hooks.ts') }, + { find: '@cpn-console/logger', replacement: path.resolve(__dirname, '../../packages/logger/src/index.ts') }, + { find: '@cpn-console/shared', replacement: path.resolve(__dirname, '../../packages/shared/src/index.ts') }, + { find: '@cpn-console/hooks', replacement: path.resolve(__dirname, '../../packages/hooks/src/index.ts') }, + ], + }, + test: { + globals: true, + environment: 'node', + include: ['src/**/*.spec.ts', 'test/**/*.e2e-spec.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}) diff --git a/.jjconflict-side-2/apps/server/.env-example b/.jjconflict-side-2/apps/server/.env-example new file mode 100644 index 0000000000..e26aaeea95 --- /dev/null +++ b/.jjconflict-side-2/apps/server/.env-example @@ -0,0 +1,46 @@ +# Niveau de log +LOG_LEVEL=debug +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine (host:port) du serveur Keycloak pour l'authentification +KEYCLOAK_DOMAIN=localhost:8090 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http | https) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 + +# Port d'écoute du serveur backend +SERVER_PORT=4001 +# URL de connexion à la base de données PostgreSQL (format Prisma) +DB_URL=postgresql://admin:admin@localhost:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr + +# --- Configuration OpenCDS --- +# URL de l'API OpenCDS (laisser vide pour désactiver) +OPENCDS_URL= +# Token d'authentification pour l'API OpenCDS +OPENCDS_API_TOKEN=token +# Vérification du certificat TLS de l'API OpenCDS (true | false) +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true + +# Dossier de stockage des plugins externes +EXTERNAL_PLUGINS_DIR_HOST_PATH=/home/stephane/projets/clients/cloud-pi/plugins +# URL du dashboard d'observabilité (géré par Grafana) +GRAFANA_URL= +# Version du chart "observability" à déployer pour les projets +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 +# Inutilisé ? +# SONARQUBE_INTERNAL_URL= diff --git a/.jjconflict-side-2/apps/server/.env.docker-example b/.jjconflict-side-2/apps/server/.env.docker-example new file mode 100644 index 0000000000..571be2ff45 --- /dev/null +++ b/.jjconflict-side-2/apps/server/.env.docker-example @@ -0,0 +1,31 @@ +# Niveau de log +LOG_LEVEL=debug +# Indique que l'application tourne dans un conteneur Docker +DOCKER=true +# Active le mode développement (initialise les données de démo au démarrage) +DEV_SETUP="true" +# Mode d'exécution Node.js (development | production | test) +NODE_ENV=development +# Secret utilisé pour signer les cookies de session (min. 32 caractères) +SESSION_SECRET=a-very-strong-secret-with-more-than-32-char + +# --- Configuration Keycloak --- +# Domaine du Keycloak (nom du service Docker Compose + port interne) +KEYCLOAK_DOMAIN=keycloak:8080 +# Nom du royaume Keycloak utilisé par la console +KEYCLOAK_REALM=dso +# Protocole de communication avec Keycloak (http en Docker local) +KEYCLOAK_PROTOCOL=http +# Identifiant du client Keycloak côté backend +KEYCLOAK_CLIENT_ID=dso-console-backend +# Secret du client Keycloak backend (confidentiel) +KEYCLOAK_CLIENT_SECRET=client-secret-backend +# URL de redirection après authentification Keycloak +KEYCLOAK_REDIRECT_URI=http://localhost:8080 + +# Port d'écoute du serveur dans le réseau Docker +SERVER_PORT=8080 +# URL de connexion PostgreSQL (utilise le nom de service Docker 'postgres') +DB_URL=postgresql://admin:admin@postgres:5432/dso-console-db?schema=public +# Adresse e-mail de contact affichée dans l'interface +CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr diff --git a/.jjconflict-side-2/apps/server/.env.integ-example b/.jjconflict-side-2/apps/server/.env.integ-example new file mode 100644 index 0000000000..1bcb5f1565 --- /dev/null +++ b/.jjconflict-side-2/apps/server/.env.integ-example @@ -0,0 +1,108 @@ +# Surcharges pour le branchement à un environnement d'intégration distant. +# Ces valeurs écrasent celles de .env et .env.docker quand INTEGRATION=true. + +# Désactive l'initialisation des données de démo (l'intégration a ses propres données) +DEV_SETUP="false" +# Active le mode intégration (charge ce fichier en surcharge) +INTEGRATION=true + +# --- Keycloak --- +# Protocole (généralement https en intégration) +KEYCLOAK_PROTOCOL=https +# Identifiant du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_ID= +# Secret du client Keycloak backend sur l'environnement d'intégration +KEYCLOAK_CLIENT_SECRET= +# Domaine du Keycloak d'intégration (ex: keycloak.dso.example.fr) +KEYCLOAK_DOMAIN= +# Royaume Keycloak d'intégration +KEYCLOAK_REALM= + +# --- ArgoCD --- +# Namespace Kubernetes dans lequel ArgoCD est déployé +ARGO_NAMESPACE= +# URL de l'API ArgoCD +ARGOCD_URL= +# URL interne de l'API ArgoCD +ARGOCD_INTERNAL_URL= + +# --- GitLab --- +# Token d'accès personnel GitLab (scope api) +GITLAB_TOKEN= +# URL de l'instance GitLab +GITLAB_URL= +# URL interne de l'API Gitlab +GITLAB_INTERNAL_URL= + +# --- Harbor --- +# Nom d'utilisateur admin Harbor +HARBOR_ADMIN= +# Mot de passe admin Harbor +HARBOR_ADMIN_PASSWORD= +# URL de l'instance Harbor +HARBOR_URL= +# URL interne de l'API Harbor +HARBOR_INTERNAL_URL= + +# --- Keycloak (admin) --- +# Utilisateur admin Keycloak pour la gestion des royaumes/clients +KEYCLOAK_ADMIN= +# Mot de passe admin Keycloak +KEYCLOAK_ADMIN_PASSWORD= +# URL complète de Keycloak (peut différer de KEYCLOAK_DOMAIN) +KEYCLOAK_URL= + +# --- Nexus --- +# Nom d'utilisateur admin Nexus +NEXUS_ADMIN= +# Mot de passe admin Nexus +NEXUS_ADMIN_PASSWORD= +# URL de l'instance Nexus +NEXUS_URL= +# URL interne de l'API Nexus +NEXUS_INTERNAL_URL= + +# --- Divers --- +# Répertoire racine des projets dans Gitlab +PROJECTS_ROOT_DIR= + +# --- SonarQube --- +# Token d'API SonarQube +SONAR_API_TOKEN= +# URL de l'instance SonarQube +SONARQUBE_URL= +# URL interne de l'API SonarQube +SONARQUBE_INTERNAL_URL= + +# --- Vault --- +# Token d'authentification Vault +VAULT_TOKEN= +# URL de l'instance Vault +VAULT_URL= +# URL interne de l'API Vault +VAULT_INTERNAL_URL= + +# --- Kubeconfig --- +# Chemin vers le kubeconfig sur la machine hôte (monté en volume dans Docker) +KUBECONFIG_HOST_PATH= +# Chemin vers le kubeconfig à l'intérieur du conteneur +KUBECONFIG_PATH=$HOME/.kube/config +# Contexte Kubernetes à utiliser +KUBECONFIG_CTX= + +# Répertoire contenant les plugins externes (monté en volume dans Docker) +EXTERNAL_PLUGINS_DIR_HOST_PATH=/path/to/plugins + +# Informations pour le plugin Observabilité +GRAFANA_URL= +DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 + +# --- Configuration proxy (si nécessaire pour accéder aux services externes) --- +# HTTP_PROXY= +# HTTPS_PROXY= +# NO_PROXY= + +# Configuration OpenCDS +OPENCDS_URL= +OPENCDS_API_TOKEN=token +OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false diff --git a/.jjconflict-side-2/apps/server/Dockerfile b/.jjconflict-side-2/apps/server/Dockerfile new file mode 100644 index 0000000000..47ca58e741 --- /dev/null +++ b/.jjconflict-side-2/apps/server/Dockerfile @@ -0,0 +1,66 @@ +# Base stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS dev + +WORKDIR /app + +COPY --chown=node:root package.json ./ + +# Install pnpm version defined in package.json "packageManager" property +RUN npm install --global corepack@latest && corepack enable && corepack enable pnpm + +COPY --chown=node:root pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY --chown=node:root apps/server/package.json ./apps/server/package.json + +COPY --chown=node:root packages/eslintconfig/package.json ./packages/eslintconfig/package.json +COPY --chown=node:root packages/shared/package.json ./packages/shared/package.json +COPY --chown=node:root packages/logger/package.json ./packages/logger/package.json +COPY --chown=node:root packages/hooks/package.json ./packages/hooks/package.json +COPY --chown=node:root packages/test-utils/package.json ./packages/test-utils/package.json +COPY --chown=node:root packages/tsconfig/package.json ./packages/tsconfig/package.json + +COPY --chown=node:root plugins/argocd/package.json ./plugins/argocd/package.json +COPY --chown=node:root plugins/gitlab/package.json ./plugins/gitlab/package.json +COPY --chown=node:root plugins/harbor/package.json ./plugins/harbor/package.json +COPY --chown=node:root plugins/keycloak/package.json ./plugins/keycloak/package.json +COPY --chown=node:root plugins/nexus/package.json ./plugins/nexus/package.json +COPY --chown=node:root plugins/sonarqube/package.json ./plugins/sonarqube/package.json +COPY --chown=node:root plugins/vault/package.json ./plugins/vault/package.json + +RUN pnpm install --frozen-lockfile +COPY --chown=node:root plugins/ ./plugins/ +COPY --chown=node:root packages/ ./packages/ + +COPY --chown=node:root apps/server/ ./apps/server/ +# Generate Prisma client +RUN pnpm --filter server run db:generate +ENTRYPOINT [ "pnpm", "--filter", "server", "run" ] +CMD [ "dev" ] + + +# Build stage ---------------------------------------------------------------------- +FROM dev AS build + +# Build @cpn-console/server console-related dependencies +RUN pnpm run build +# Build @cpn-console/server +RUN pnpm --filter @cpn-console/server run build +# Export @cpn-console/server to target build directory +RUN pnpm --filter @cpn-console/server --prod deploy build + + +# Prod stage ----------------------------------------------------------------------- +FROM docker.io/node:24.13.1-bullseye-slim AS prod + +ARG APP_VERSION +ENV APP_VERSION=$APP_VERSION + +VOLUME [ "/plugins" ] +WORKDIR /app +RUN mkdir -p /home/node/logs && chmod 770 -R /home/node/logs \ + && mkdir -p /home/node/.npm && chmod 770 -R /home/node/.npm \ + && chown node:root /app +COPY --chown=node:root --from=build /app/build . +RUN npm run db:generate +USER node +EXPOSE 8080 +ENTRYPOINT ["npm", "start"] diff --git a/.jjconflict-side-2/apps/server/README.md b/.jjconflict-side-2/apps/server/README.md new file mode 100644 index 0000000000..37b89ac99b --- /dev/null +++ b/.jjconflict-side-2/apps/server/README.md @@ -0,0 +1,39 @@ +# Console Cloud π Native - Serveur + +## Installation + +```sh +npm install +``` + +## Lancement de l'app + +```sh +npm run dev +``` + +## Lancement des tests unitaires + +```sh +npm run test +``` + +## Formattage du code + +```sh +# Lister les problèmes de formatage +npm run lint + +# Régler automatiquement les problèmes de formatage +npm run format +``` + +## Lancement de l'app en mode production + +```sh +npm start +``` + +## Activation OpenCDS + +Se référer à [la documentation dédiée](../../packages/opencds/README.adoc) diff --git a/.jjconflict-side-2/apps/server/eslint.config.js b/.jjconflict-side-2/apps/server/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-2/apps/server/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-2/apps/server/nodemon.json b/.jjconflict-side-2/apps/server/nodemon.json new file mode 100644 index 0000000000..a83d0540d3 --- /dev/null +++ b/.jjconflict-side-2/apps/server/nodemon.json @@ -0,0 +1,4 @@ +{ + "watch": ["server.ts", "src/"], + "ext": "js, ts" +} diff --git a/.jjconflict-side-2/apps/server/package.json b/.jjconflict-side-2/apps/server/package.json new file mode 100644 index 0000000000..3096b8ea59 --- /dev/null +++ b/.jjconflict-side-2/apps/server/package.json @@ -0,0 +1,109 @@ +{ + "name": "@cpn-console/server", + "type": "module", + "version": "9.22.1", + "description": "cpn-console server", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/bootstrap.js", + "module": "dist/bootstrap.js", + "types": "types/bootstrap.d.ts", + "files": [ + "dist", + "prisma.config.ts", + "src" + ], + "scripts": { + "prebuild": "pnpm i --frozen-lockfile", + "build": "tspc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "db:deploy": "prisma migrate deploy", + "db:diff": "prisma migrate diff", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev --name dso", + "db:reset": "prisma migrate reset", + "predev": "pnpm run db:deploy", + "dev": "nodemon --exec 'vite-node src/bootstrap.ts'", + "predebug": "pnpm run db:migrate", + "debug": "nodemon --exec 'NODE_OPTIONS=\"--inspect-brk=0.0.0.0\" vite-node src/bootstrap.ts'", + "format": "eslint ./ --fix", + "preinteg": "INTEGRATION=true pnpm run db:migrate", + "integ": "INTEGRATION=true nodemon --exec 'vite-node src/bootstrap.ts'", + "lint": "eslint ./", + "prepublishOnly": "pnpm run db:generate", + "prestart": "npm run db:deploy", + "start": "node --enable-source-maps dist/bootstrap.js", + "pretest": "pnpm run db:generate", + "test": "vitest run", + "pretest:cov": "pnpm run db:generate", + "test:cov": "vitest run --coverage", + "test:e2e": "pnpm run dev", + "test:e2e-ci": "pnpm run start" + }, + "dependencies": { + "@cpn-console/argocd-plugin": "workspace:^", + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/harbor-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/nexus-plugin": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/sonarqube-plugin": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@fastify/cookie": "catalog:runtime", + "@fastify/helmet": "catalog:runtime", + "@fastify/otel": "catalog:runtime", + "@fastify/session": "catalog:runtime", + "@fastify/swagger": "catalog:runtime", + "@fastify/swagger-ui": "catalog:runtime", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "@kubernetes-models/argo-cd": "catalog:runtime", + "@opentelemetry/auto-instrumentations-node": "catalog:otel", + "@opentelemetry/exporter-metrics-otlp-proto": "catalog:otel", + "@opentelemetry/exporter-trace-otlp-proto": "catalog:otel", + "@opentelemetry/instrumentation-nestjs-core": "catalog:otel", + "@opentelemetry/instrumentation-pino": "catalog:otel", + "@opentelemetry/sdk-metrics": "catalog:otel", + "@opentelemetry/sdk-node": "catalog:otel", + "@prisma/client": "catalog:runtime", + "@ts-rest/core": "catalog:runtime", + "@ts-rest/fastify": "catalog:runtime", + "@ts-rest/open-api": "catalog:runtime", + "axios": "catalog:runtime", + "date-fns": "catalog:runtime", + "fastify": "catalog:runtime", + "fastify-keycloak-adapter": "catalog:runtime", + "json-2-csv": "catalog:runtime", + "mustache": "catalog:runtime", + "prisma": "catalog:runtime", + "undici": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/test-utils": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "fastify-plugin": "catalog:build", + "nodemon": "catalog:tools", + "pino-pretty": "catalog:tools", + "rimraf": "catalog:tools", + "ts-patch": "catalog:build", + "typescript": "catalog:build", + "typescript-transform-paths": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/apps/server/prisma.config.ts b/.jjconflict-side-2/apps/server/prisma.config.ts new file mode 100644 index 0000000000..ff5aaf9186 --- /dev/null +++ b/.jjconflict-side-2/apps/server/prisma.config.ts @@ -0,0 +1,23 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' +import { defineConfig } from 'prisma/config' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +export default defineConfig({ + schema: path.join('src', 'prisma', 'schema'), + migrations: { + path: path.join('src', 'prisma', 'migrations'), + }, +}) diff --git a/.jjconflict-side-2/apps/server/src/__mocks__/prisma.ts b/.jjconflict-side-2/apps/server/src/__mocks__/prisma.ts new file mode 100644 index 0000000000..075578c96c --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/__mocks__/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma.js') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-side-2/apps/server/src/__mocks__/utils/hook-wrapper.ts b/.jjconflict-side-2/apps/server/src/__mocks__/utils/hook-wrapper.ts new file mode 100644 index 0000000000..f7fdd811a2 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/__mocks__/utils/hook-wrapper.ts @@ -0,0 +1,42 @@ +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../utils/hook-wrapper.ts') + +export const hook = { + adminRole: { + delete: vi.fn(), + upsert: vi.fn(), + }, + cluster: { + delete: vi.fn(), + upsert: vi.fn(), + }, + misc: { + checkServices: vi.fn(), + syncRepository: vi.fn(), + }, + project: { + upsert: vi.fn(), + delete: vi.fn(), + getSecrets: vi.fn(), + }, + projectRole: { + upsert: vi.fn(), + delete: vi.fn(), + }, + user: { + retrieveUserByEmail: vi.fn(), + }, + zone: { + delete: vi.fn(), + upsert: vi.fn(), + }, +} as const + +const hookMock = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(hookMock) +}) diff --git a/.jjconflict-side-2/apps/server/src/app.spec.ts b/.jjconflict-side-2/apps/server/src/app.spec.ts new file mode 100644 index 0000000000..e540174ed4 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/app.spec.ts @@ -0,0 +1,21 @@ +import { apiPrefix } from '@cpn-console/shared' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import app from './app.js' +import { getRandomRequestor, setRequestor } from './utils/mocks.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) + +describe('app', () => { + beforeEach(() => { + setRequestor(getRandomRequestor()) + }) + afterAll(async () => { + await app.close() + }) + + it('should respond 404 on unknown route', async () => { + const response = await app.inject() + .get(`${apiPrefix}/miss`) + expect(response.statusCode).toBe(404) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/app.ts b/.jjconflict-side-2/apps/server/src/app.ts new file mode 100644 index 0000000000..996db320fa --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/app.ts @@ -0,0 +1,63 @@ +import type { FastifyRequest } from 'fastify' +import { apiPrefix, getContract } from '@cpn-console/shared' +import fastifyCookie from '@fastify/cookie' +import helmet from '@fastify/helmet' +import fastifySession from '@fastify/session' +import fastifySwagger from '@fastify/swagger' +import fastifySwaggerUi from '@fastify/swagger-ui' +import { initServer } from '@ts-rest/fastify' +import { generateOpenApi } from '@ts-rest/open-api' +import fastify from 'fastify' +import keycloak from 'fastify-keycloak-adapter' +import { apiRouter } from './resources/index.js' +import { isDev, isInt, isTest } from './utils/env.js' +import { fastifyConf, swaggerConf, swaggerUiConf } from './utils/fastify.js' +import { keycloakConf, sessionConf } from './utils/keycloak.js' + +export const serverInstance: ReturnType = initServer() + +const openApiDocument = generateOpenApi(await getContract(), swaggerConf, { + setOperationId: true, +}) + +const app = fastify(fastifyConf) + .register(helmet, () => ({ + contentSecurityPolicy: !(isInt || isDev || isTest), + })) + .register(fastifyCookie) + .register(fastifySession, sessionConf) + // @ts-ignore + .register(keycloak, keycloakConf) + .register(fastifySwagger, { transformObject: () => openApiDocument }) + .register(fastifySwaggerUi, swaggerUiConf) + .register(apiRouter()) + .addHook('onRoute', (opts) => { + if (opts.path === `${apiPrefix}/healthz`) { + opts.logLevel = 'silent' + } + }) + .setErrorHandler((err: Error, req: FastifyRequest, reply) => { + const statusCode = 500 + // @ts-ignore vérifier l'objet + const message = err.description || err.message + reply + .status(statusCode) + .send({ status: statusCode, error: message, stack: err.stack }) + req.log.error({ err, reqId: req.id }, 'Unhandled request error') + }) + .addHook('onResponse', (req, res) => { + const user = req.session?.user as { + id: string + } + if (res.statusCode < 400) { + req.log.info({ status: res.statusCode, userId: user?.id }) + } else if (res.statusCode < 500) { + req.log.warn({ status: res.statusCode, userId: user?.id }) + } else { + req.log.error({ status: res.statusCode, userId: user?.id }) + } + }) + +await app.ready() + +export default app diff --git a/.jjconflict-side-2/apps/server/src/bootstrap.ts b/.jjconflict-side-2/apps/server/src/bootstrap.ts new file mode 100644 index 0000000000..83c833f007 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/bootstrap.ts @@ -0,0 +1,3 @@ +import './runtime.js' +import './telemetry.js' +import './server.js' diff --git a/.jjconflict-side-2/apps/server/src/connect.spec.ts b/.jjconflict-side-2/apps/server/src/connect.spec.ts new file mode 100644 index 0000000000..363c89d290 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/connect.spec.ts @@ -0,0 +1,86 @@ +import { logger } from '@cpn-console/logger' +import { PrismaClientInitializationError } from '@prisma/client/runtime/library.js' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from './__mocks__/prisma.js' +import app from './app.js' +import { getConnection } from './connect.js' + +vi.mock( + 'fastify-keycloak-adapter', + (await import('./utils/mocks.js')).mockSessionPlugin, +) +vi.mock('@/resources/queries-index.js') +vi.mock('./models/log.js', () => getModel('getLogModel')) +vi.mock('./models/repository.js', () => getModel('getRepositoryModel')) +vi.mock('./models/permission.js', () => getModel('getPermissionModel')) +vi.mock('./models/environment.js', () => getModel('getEnvironmentModel')) +vi.mock('./models/project.js', () => getModel('getProjectModel')) +vi.mock('./models/user.js', () => getModel('getUserModel')) +vi.mock('./models/users-projects.js', () => getModel('getRolesModel')) +vi.mock('./models/zone.js', () => getModel('getZoneModel')) +vi.mock('./prisma.js') +vi.mock('./utils/env.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isTest: true, + } +}) + +vi.spyOn(app, 'listen') +const infoSpy = vi.spyOn(logger, 'info') +const errorSpy = vi.spyOn(logger, 'error') +const debugSpy = vi.spyOn(logger, 'debug') + +function getModel(modelName: string) { + return { + [modelName]: vi.fn(() => ({ + sync: vi.fn(), + hasMany: vi.fn(), + belongsTo: vi.fn(), + belongsToMany: vi.fn(), + })), + } +} + +describe('connect', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should connect to postgres', async () => { + await getConnection() + + expect(debugSpy.mock.calls).toHaveLength(1) + expect(debugSpy.mock.calls[0]?.[1]).toBe('Connecting to Postgres') + + expect(infoSpy.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toContainEqual(['Connected to Postgres!']) + }) + + it( + 'should fail to connect once, then connect to postgres', + { timeout: 10_000 }, + async () => { + const errorToCatch = new PrismaClientInitializationError( + 'Failed to connect', + '2.19.0', + 'P1001', + ) + + prisma.$connect.mockRejectedValueOnce(errorToCatch) + await getConnection() + + expect(errorSpy.mock.calls).toHaveLength(1) + expect(errorSpy.mock.calls[0]?.[1]).toBe('Could not connect to Postgres') + + expect(infoSpy.mock.calls).toHaveLength(2) + expect(infoSpy.mock.calls[0]?.[1]).toBe('Retrying Postgres connection') + expect(infoSpy.mock.calls).toContainEqual(['Connected to Postgres!']) + + expect(debugSpy.mock.calls).toHaveLength(2) + expect(debugSpy.mock.calls[0]?.[1]).toBe('Connecting to Postgres') + expect(debugSpy.mock.calls[1]?.[1]).toBe('Connecting to Postgres') + }, + ) +}) diff --git a/.jjconflict-side-2/apps/server/src/connect.ts b/.jjconflict-side-2/apps/server/src/connect.ts new file mode 100644 index 0000000000..31d608a66e --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/connect.ts @@ -0,0 +1,63 @@ +import { setTimeout } from 'node:timers/promises' +import { logger } from '@cpn-console/logger' +import prisma from './prisma.js' +import { + dbUrl, + isCI, + isDev, + isTest, +} from './utils/env.js' + +const DELAY_BEFORE_RETRY = isTest ? 0 : isCI ? 1000 : 10000 +let closingConnections = false +const leadingSlashRegExp = /^\// + +function parseDbUrl(url: string | undefined) { + if (!url) return undefined + try { + const parsed = new URL(url) + return { + host: parsed.host, + database: parsed.pathname?.replace(leadingSlashRegExp, '') || undefined, + } + } catch { + return undefined + } +} + +export async function getConnection(triesLeft = 5): Promise { + if (closingConnections || triesLeft <= 0) { + throw new Error('Unable to connect to Postgres server') + } + triesLeft-- + + try { + if (isDev || isTest || isCI) + logger.debug({ db: parseDbUrl(dbUrl) }, 'Connecting to Postgres') + await prisma.$connect() + + logger.info('Connected to Postgres!') + } catch (err) { + if (triesLeft > 0) { + logger.error({ err }, 'Could not connect to Postgres') + logger.info({ triesLeft }, 'Retrying Postgres connection') + await setTimeout(DELAY_BEFORE_RETRY) + return getConnection(triesLeft) + } + + logger.error({ err }, 'Out of retries connecting to Postgres') + err.message = `Out of retries connecting to Postgres, last error: ${err.message}` + throw err + } +} + +export async function closeConnections() { + closingConnections = true + try { + await prisma.$disconnect() + } catch (error) { + logger.error({ err: error }, 'Could not disconnect from Postgres') + } finally { + closingConnections = false + } +} diff --git a/.jjconflict-side-2/apps/server/src/init/db/dump.ts b/.jjconflict-side-2/apps/server/src/init/db/dump.ts new file mode 100644 index 0000000000..6709946605 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/init/db/dump.ts @@ -0,0 +1,28 @@ +// @ts-nocheck + +/** + * How to use ? + * npx vite-node src/init/db/dump.ts + * format ./data.ts with linter + * cut/paste to packages/test-utils/src/imports/data.ts + */ + +import { writeFileSync } from 'node:fs' +import { Prisma } from '@prisma/client' +import prisma from '@/prisma.js' +import { associations, manyToManyRelation, modelKeys, models, resourceListToDict } from './utils.js' + +const Models = resourceListToDict(Prisma.dmmf.datamodel.models) + +for (const modelKey of modelKeys) { + const modelDatas = await prisma[modelKey].findMany() + models[modelKey] = modelDatas +} +for (const [model, targetModel, relationKey] of manyToManyRelation) { + const modelKey = model.slice(0, 1).toLocaleLowerCase() + model.slice(1) + const modelDatas = await prisma[modelKey].findMany({ select: { [Models[model].id]: true, [relationKey]: { select: { [Models[targetModel].id]: true } } } }) + associations.push([modelKey, modelDatas]) +} +const a = JSON.stringify({ ...models, associations }, null, 2) + +writeFileSync('./data.ts', `export const data = ${a}`) diff --git a/.jjconflict-side-2/apps/server/src/init/db/index.ts b/.jjconflict-side-2/apps/server/src/init/db/index.ts new file mode 100644 index 0000000000..fe37b33281 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/init/db/index.ts @@ -0,0 +1,51 @@ +import { logger } from '@cpn-console/logger' +import prisma from '@/prisma.js' +import { modelKeys } from './utils.js' + +type ExtractKeysWithFields = { + [K in keyof T]: T[K] extends { fields: any } ? K : never +}[keyof T] + +type Models = ExtractKeysWithFields + +type Imports = Partial> & { + associations: [Models, any[]] +} + +export async function initDb(data: Imports) { + const dataStringified = JSON.stringify(data) + const dataParsed = JSON.parse(dataStringified, (key, value) => { + try { + if (['permissions', 'everyonePerms'].includes(key)) { + return BigInt(value.slice(0, value.length - 1)) + } + } catch { + return value + } + return value + }) + logger.info('Drop tables') + for (const modelKey of modelKeys.toReversed()) { + // @ts-ignore + await prisma[modelKey].deleteMany() + } + logger.info('Import models') + for (const modelKey of modelKeys) { + // @ts-ignore + await prisma[modelKey].createMany({ data: dataParsed[modelKey] }) + } + logger.info('Import associations') + for (const [modelKey, rows] of dataParsed.associations) { + for (const row of rows) { + const idKey = 'id' + const connectKeys = Object.keys(row).filter(key => key !== idKey) + const dataConnects = connectKeys.reduce((acc, curr) => { + acc[curr] = { connect: row[curr] } + return acc + }, {} as Record) + // @ts-ignore + await prisma[modelKey].update({ where: { id: row.id }, data: dataConnects }) + } + } + logger.info('End import') +} diff --git a/.jjconflict-side-2/apps/server/src/init/db/utils.spec.ts b/.jjconflict-side-2/apps/server/src/init/db/utils.spec.ts new file mode 100644 index 0000000000..9b831ee089 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/init/db/utils.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { modelKeys, moveBefore, resourceListToDict } from './utils.js' + +vi.mock('fs', () => ({ writeFileSync: vi.fn() })) +for (const modelKey of modelKeys) { + prisma[modelKey].findMany.mockResolvedValue([]) +} + +describe('test moveBefore', () => { + it('should be moved', () => { + const arr = ['a', 'b', 'c'] + const arrSorted = moveBefore(arr, 'c', 'b') + expect(arrSorted).toEqual(['a', 'c', 'b']) + + const arrSorted2 = moveBefore(arr, 'c', 'a') + expect(arrSorted2).toEqual(['c', 'a', 'b']) + }) + it('should not be moved', () => { + const arr = ['a', 'b', 'c'] + const arrSorted = moveBefore(arr, 'b', 'c') + expect(arrSorted).toEqual(false) + + const arrSorted2 = moveBefore(arr, 'a', 'c') + expect(arrSorted2).toEqual(false) + + const arrSorted3 = moveBefore(arr, 'c', 'c') + expect(arrSorted3).toEqual(false) + }) +}) + +it('test resourceListToDict (by name)', () => { + const list = [ + { name: 'a', value: 1 }, + { name: 'b', value: 2 }, + { name: 'c', value: 3 }, + ] + const dict = resourceListToDict(list) + expect(dict).toEqual({ + a: { name: 'a', value: 1 }, + b: { name: 'b', value: 2 }, + c: { name: 'c', value: 3 }, + }) +}) + +it('stringify bigint', () => { + const list = { name: 'a', value: 1n } + + const dict = JSON.stringify(list) + + expect(dict).toEqual('{"name":"a","value":"1n"}') +}) diff --git a/.jjconflict-side-2/apps/server/src/init/db/utils.ts b/.jjconflict-side-2/apps/server/src/init/db/utils.ts new file mode 100644 index 0000000000..e4b941089f --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/init/db/utils.ts @@ -0,0 +1,85 @@ +// @ts-nocheck +import { Prisma } from '@prisma/client' + +// eslint-disable-next-line no-extend-native +BigInt.prototype.toJSON = function () { + return `${this.toString()}n` +} + +export type ResourceByName = Record +export function resourceListToDict(resList: Array): ResourceByName { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.name]: curr, + } + }, {} as ResourceByName) +} + +// @ts-ignore +const Models = resourceListToDict(Prisma.dmmf.datamodel.models) +let ModelsNames = Object.keys(Models) +let ModelsOrder = [...ModelsNames] + +export function moveBefore(arr: T, toMove: T[number], ref: T[number]): T | false { + const iref = arr.indexOf(ref) + const moveref = arr.indexOf(toMove) + if (moveref <= iref) return false + return [ + ...arr.slice(0, iref), + arr[moveref], + ...arr.slice(iref, moveref), + ...arr.slice(moveref + 1), + ] as T +} + +export const manyToManyRelation: [string, string, string][] = [] +function sort() { + let hasChanged = false + for (const model of ModelsNames) { + for (const field of Models[model].fields) { + if (field.isId) Models[model].id = field.name + if (field.type in Models) { + const relationField = Models[field.type].fields.find(({ type }) => type === model) + if (!relationField) throw new Error('unable to find matching model') + if ( + (relationField.isRequired && field.isRequired && !relationField.isList) + || (relationField.isRequired && !field.isRequired) + ) { + const moveRes = moveBefore(ModelsOrder, model, field.type) + if (moveRes) { + hasChanged = true + ModelsOrder = moveRes + } + } + if ( + field.isList && relationField.isList + && !manyToManyRelation.some(test => + (test[0] === model && test[1] === field.type) || (test[0] === field.type && test[1] === model)) + ) { + manyToManyRelation.push([model, field.type, field.name]) + } + } + } + } + ModelsNames = ModelsOrder + if (hasChanged) sort() +} + +sort() + +// special case to study +const logUserCase = moveBefore(ModelsOrder, 'User', 'Log') +if (logUserCase) { + ModelsOrder = logUserCase +} +const logProjectCase = moveBefore(ModelsOrder, 'Project', 'Log') +if (logProjectCase) { + ModelsOrder = logProjectCase +} + +export const models: Record = {} +export const associations: Record = [] +export const modelKeys = ModelsOrder.map(model => model.slice(0, 1).toLocaleLowerCase() + model.slice(1)) diff --git a/.jjconflict-side-2/apps/server/src/mocks/prisma.ts b/.jjconflict-side-2/apps/server/src/mocks/prisma.ts new file mode 100644 index 0000000000..075578c96c --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/mocks/prisma.ts @@ -0,0 +1,14 @@ +import type { PrismaClient } from '@prisma/client' +import { beforeEach, vi } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' + +vi.mock('../prisma.js') + +const prisma = mockDeep() + +beforeEach(() => { + // reset les mocks + mockReset(prisma) +}) + +export default prisma diff --git a/.jjconflict-side-2/apps/server/src/mocks/utils.ts b/.jjconflict-side-2/apps/server/src/mocks/utils.ts new file mode 100644 index 0000000000..c57dd0f52b --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/mocks/utils.ts @@ -0,0 +1,24 @@ +import type { User } from '@cpn-console/test-utils' +import fp from 'fastify-plugin' + +let requestor: User + +export function setRequestor(user: User) { + requestor = user +} + +export function getRequestor() { + return requestor +} + +export async function mockSessionPlugin() { + const sessionPlugin = (app, opt, next) => { + app.addHook('onRequest', (req, res, next) => { + req.session = { user: getRequestor() } + next() + }) + next() + } + + return { default: fp(sessionPlugin) } +} diff --git a/.jjconflict-side-2/apps/server/src/plugins.ts b/.jjconflict-side-2/apps/server/src/plugins.ts new file mode 100644 index 0000000000..fcb54775f2 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/plugins.ts @@ -0,0 +1,47 @@ +import type { Plugin } from '@cpn-console/hooks' +import { readdirSync, statSync } from 'node:fs' +import { plugin as argo } from '@cpn-console/argocd-plugin' +import { plugin as gitlab } from '@cpn-console/gitlab-plugin' +import { plugin as harbor } from '@cpn-console/harbor-plugin' +import { pluginManager } from '@cpn-console/hooks' +import { plugin as keycloak } from '@cpn-console/keycloak-plugin' +import { logger as baseLogger } from '@cpn-console/logger' +import { plugin as nexus } from '@cpn-console/nexus-plugin' +import { plugin as sonarqube } from '@cpn-console/sonarqube-plugin' +import { plugin as vault } from '@cpn-console/vault-plugin' +import { pluginsDir } from './utils/env.js' +import { pluginManagerOptions } from './utils/plugins.js' + +const logger = baseLogger.child({ scope: 'plugin-manager' }) + +export async function initPm() { + const pm = pluginManager(pluginManagerOptions) + pm.register(argo) + pm.register(gitlab) + pm.register(harbor) + pm.register(keycloak) + pm.register(nexus) + pm.register(sonarqube) + pm.register(vault) + + if (!statSync(pluginsDir, { + throwIfNoEntry: false, + })) { + return pm + } + for (const dirName of readdirSync(pluginsDir)) { + const moduleAbsPath = `${pluginsDir}/${dirName}` + try { + statSync(`${moduleAbsPath}/package.json`) + const pkg = await import(`${moduleAbsPath}/package.json`, { with: { type: 'json' } }) + const entrypoint = pkg.default.module || pkg.default.main + if (!entrypoint) throw new Error(`No entrypoint found in package.json : ${pkg.default.name}`) + const { plugin } = await import(`${moduleAbsPath}/${entrypoint}`) as { plugin: Plugin } + pm.register(plugin) + } catch (error) { + logger.error({ err: error, moduleAbsPath }, 'Could not import external plugin module') + } + } + + return pm +} diff --git a/.jjconflict-side-2/apps/server/src/prepare-app.spec.ts b/.jjconflict-side-2/apps/server/src/prepare-app.spec.ts new file mode 100644 index 0000000000..5086faa776 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prepare-app.spec.ts @@ -0,0 +1,70 @@ +import { logger } from '@cpn-console/logger' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from './app.js' +import { getConnection } from './connect.js' +import { initDb } from './init/db/index.js' +import { getPreparedApp } from './prepare-app.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) +vi.mock('./connect.js') +vi.mock('./index.js') +vi.mock('./init/db/index.js', () => ({ initDb: vi.fn() })) + +vi.spyOn(app, 'listen') +vi.spyOn(logger, 'warn') +vi.spyOn(logger, 'error') +vi.spyOn(logger, 'debug') + +describe('server', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should getConnection', async () => { + await getPreparedApp() + + expect(getConnection).toHaveBeenCalledTimes(1) + expect(initDb.mock.calls).toHaveLength(1) + }) + + it('should throw an error on connection to DB', async () => { + const error = new Error('This is OK!') + getConnection.mockRejectedValueOnce(error) + + let response + await getPreparedApp() + .catch((err) => { response = err }) + + expect(getConnection.mock.calls).toHaveLength(1) + expect(app.listen.mock.calls).toHaveLength(0) + expect(response).toMatchObject(error) + }) + + it('should throw an error on initDb import if module is not found', async () => { + const infoSpy = vi.spyOn(logger, 'info') + const error = new Error('Failed to load') + initDb.mockRejectedValueOnce(error) + + await getPreparedApp() + + expect(initDb.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toHaveLength(3) + }) + + it('should throw an error on initDb import', async () => { + const infoSpy = vi.spyOn(logger, 'info') + const error = new Error('This is OK!') + initDb.mockRejectedValueOnce(error) + + let response + try { + await getPreparedApp() + } catch (err) { + response = err + } + + expect(initDb.mock.calls).toHaveLength(1) + expect(infoSpy.mock.calls).toHaveLength(2) + expect(response).toMatchObject(error) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/prepare-app.ts b/.jjconflict-side-2/apps/server/src/prepare-app.ts new file mode 100644 index 0000000000..000734714b --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prepare-app.ts @@ -0,0 +1,96 @@ +import { rm } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { logger } from '@cpn-console/logger' +import app from './app.js' +import { getConnection } from './connect.js' +import { initDb } from './init/db/index.js' +import { initPm } from './plugins.js' +import { isCI, isDev, isDevSetup, isInt, isProd, isTest, port } from './utils/env.js' + +async function initializeDB(path: string) { + logger.info('Starting init DB...') + const { data } = await import(path) + await initDb(data) + logger.info('initDb invoked successfully') +} + +export async function startServer(defaultPort: number = (port ? +port : 8080)) { + try { + await getConnection() + } catch (error) { + logger.error({ err: error }, 'Database connection failed') + if (!(error instanceof Error)) return + throw error + } + + initPm() + + logger.info('Reading init database file') + + try { + const dataPath = (isProd || isInt) + ? './init/db/imports/data.js' + : '@cpn-console/test-utils/src/imports/data.ts' + await initializeDB(dataPath) + if (isProd && !isDevSetup) { + logger.info('Cleaning up imported data file...') + const __filename = fileURLToPath(import.meta.url) + const __dirname = dirname(__filename) + await rm(resolve(__dirname, dataPath)) + logger.info(`Successfully deleted '${dataPath}'`) + } + } catch (err) { + if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.message?.includes('Failed to load') || err?.message?.includes('Cannot find module')) { + logger.info('No initDb file, skipping') + } else { + logger.warn({ err }, 'Init DB failed') + throw err + } + } + + try { + await app.listen({ host: '0.0.0.0', port: defaultPort ?? 8080 }) + } catch (error) { + logger.error({ err: error }, 'Failed to start HTTP server') + process.exit(1) + } + logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) +} + +export async function getPreparedApp() { + try { + await getConnection() + } catch (error) { + logger.error({ err: error }, 'Database connection failed') + throw error + } + + initPm() + + logger.info('Reading init database file') + + try { + const dataPath = (isProd || isInt) + ? './init/db/imports/data.js' + : '@cpn-console/test-utils/src/imports/data.ts' + await initializeDB(dataPath) + if (isProd && !isDevSetup) { + logger.info('Cleaning up imported data file...') + const __filename = fileURLToPath(import.meta.url) + const __dirname = dirname(__filename) + await rm(resolve(__dirname, dataPath)) + logger.info(`Successfully deleted '${dataPath}'`) + } + } catch (err) { + if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.message?.includes('Failed to load') || err?.message?.includes('Cannot find module')) { + logger.info('No initDb file, skipping') + } else { + logger.warn({ err }, 'Init DB failed') + throw err + } + } + + logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) + return app +} diff --git a/.jjconflict-side-2/apps/server/src/prisma.ts b/.jjconflict-side-2/apps/server/src/prisma.ts new file mode 100644 index 0000000000..4590932b68 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +export default prisma diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql new file mode 100644 index 0000000000..f2f4e7b0b7 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20230706084346_dso/migration.sql @@ -0,0 +1,151 @@ +-- CreateTable +CREATE TABLE "Environment" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Environment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Log" ( + "id" UUID NOT NULL, + "data" JSONB NOT NULL, + "action" TEXT NOT NULL DEFAULT '', + "userId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Log_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Organization" ( + "id" UUID NOT NULL, + "source" TEXT NOT NULL, + "name" TEXT NOT NULL, + "label" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Permission" ( + "id" UUID NOT NULL, + "userId" UUID NOT NULL, + "environmentId" UUID NOT NULL, + "level" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Permission_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "organizationId" UUID NOT NULL, + "description" TEXT, + "status" TEXT NOT NULL, + "locked" BOOLEAN NOT NULL DEFAULT false, + "services" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Repository" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "internalRepoName" TEXT NOT NULL, + "externalRepoUrl" TEXT NOT NULL, + "externalUserName" TEXT, + "externalToken" TEXT, + "isInfra" BOOLEAN NOT NULL DEFAULT false, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + "status" TEXT NOT NULL DEFAULT 'initializing', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Repository_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" UUID NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Role" ( + "userId" UUID NOT NULL, + "projectId" UUID NOT NULL, + "role" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Role_pkey" PRIMARY KEY ("userId","projectId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_id_key" ON "Organization"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_name_key" ON "Organization"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_label_key" ON "Organization"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_id_key" ON "Permission"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Permission_userId_environmentId_key" ON "Permission"("userId", "environmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_id_key" ON "Project"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Role_userId_projectId_key" ON "Role"("userId", "projectId"); + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Permission" ADD CONSTRAINT "Permission_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Repository" ADD CONSTRAINT "Repository_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Role" ADD CONSTRAINT "Role_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql new file mode 100644 index 0000000000..26e1ade3f2 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20230710181052_dso/migration.sql @@ -0,0 +1,85 @@ +-- CreateEnum +CREATE TYPE "ClusterPrivacy" AS ENUM ('public', 'dedicated'); + +-- CreateTable +CREATE TABLE "Cluster" ( + "id" UUID NOT NULL, + "label" VARCHAR(50) NOT NULL, + "privacy" "ClusterPrivacy" NOT NULL DEFAULT 'dedicated', + "secretName" VARCHAR(50) NOT NULL, + "clusterResources" BOOLEAN NOT NULL DEFAULT false, + "kubeConfigId" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Cluster_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Kubeconfig" ( + "id" UUID NOT NULL, + "user" JSONB NOT NULL, + "cluster" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "parentClusterId" UUID, + + CONSTRAINT "Kubeconfig_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_ClusterToEnvironment" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateTable +CREATE TABLE "_ClusterToProject" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_id_key" ON "Cluster"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_label_key" ON "Cluster"("label"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_secretName_key" ON "Cluster"("secretName"); + +-- CreateIndex +CREATE UNIQUE INDEX "Cluster_kubeConfigId_key" ON "Cluster"("kubeConfigId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_id_key" ON "Kubeconfig"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Kubeconfig_parentClusterId_key" ON "Kubeconfig"("parentClusterId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToEnvironment_AB_unique" ON "_ClusterToEnvironment"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToEnvironment_B_index" ON "_ClusterToEnvironment"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToProject_AB_unique" ON "_ClusterToProject"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToProject_B_index" ON "_ClusterToProject"("B"); + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_kubeConfigId_fkey" FOREIGN KEY ("kubeConfigId") REFERENCES "Kubeconfig"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToEnvironment" ADD CONSTRAINT "_ClusterToEnvironment_B_fkey" FOREIGN KEY ("B") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_B_fkey" FOREIGN KEY ("B") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql new file mode 100644 index 0000000000..8f3fb5ff9f --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20230711132934_dso/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `parentClusterId` on the `Kubeconfig` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Kubeconfig_parentClusterId_key"; + +-- AlterTable +ALTER TABLE "Kubeconfig" DROP COLUMN "parentClusterId"; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql new file mode 100644 index 0000000000..4eb37edc53 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20230802143822_dso/migration.sql @@ -0,0 +1,10 @@ +CREATE TYPE "ProjectStatus" AS ENUM ('initializing', 'created', 'failed', 'archived'); + +ALTER TABLE public."Project" ALTER COLUMN status TYPE "ProjectStatus" USING + case + when status = 'created' then 'created'::"ProjectStatus" + when status = 'failed' then 'failed'::"ProjectStatus" + when status = 'archived' then 'archived'::"ProjectStatus" + else 'initializing'::"ProjectStatus" + end; +ALTER TABLE public."Project" ALTER COLUMN status SET DEFAULT 'initializing'; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql new file mode 100644 index 0000000000..f402a0e3d4 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20230912084459_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "infos" VARCHAR(200); diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql new file mode 100644 index 0000000000..f553e7880e --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20231010111515_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `externalToken` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "externalToken"; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql new file mode 100644 index 0000000000..394b650a51 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20231011125838_dso/migration.sql @@ -0,0 +1,81 @@ +-- CreateEnum +CREATE TYPE "QuotaStageStatus" AS ENUM +('active', 'pendingDelete'); + +-- Create new tables +-- CreateTable +CREATE TABLE "Quota" +( + "id" UUID NOT NULL, + "memory" VARCHAR NOT NULL, + "cpu" REAL NOT NULL, + "name" VARCHAR NOT NULL, + "isPrivate" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "Quota_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Stage" +( + "id" UUID NOT NULL, + "name" VARCHAR NOT NULL, + + CONSTRAINT "Stage_pkey" PRIMARY KEY ("id") +); + +-- Associate Quotas and Stages +-- CreateTable +CREATE TABLE "QuotaStage" +( + "id" UUID NOT NULL, + "quotaId" UUID NOT NULL, + "stageId" UUID NOT NULL, + "status" "QuotaStageStatus" NOT NULL DEFAULT 'active', + + CONSTRAINT "QuotaStage_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "Quota_id_key" ON "Quota"("id"); +CREATE UNIQUE INDEX "Quota_name_key" ON "Quota"("name"); +CREATE UNIQUE INDEX "Stage_id_key" ON "Stage"("id"); +CREATE UNIQUE INDEX "Stage_name_key" ON "Stage"("name"); +CREATE UNIQUE INDEX "QuotaStage_id_key" ON "QuotaStage"("id"); +CREATE UNIQUE INDEX "QuotaStage_quotaId_stageId_key" ON "QuotaStage"("quotaId", "stageId"); +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "QuotaStage" ADD CONSTRAINT "QuotaStage_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Create default values for Quotas and Stages +-- Quota +INSERT INTO "Quota" + (id, cpu, memory, "name", "isPrivate") +VALUES + ('5a57b62f-2465-4fb6-a853-5a751d099199', 2, '4Gi', 'small', false), + ('08770663-3b76-4af6-8978-9f75eda4faa7', 4, '8Gi', 'medium', false), + ('b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 6, '12Gi', 'large', false), + ('97b851e8-9067-4a3d-a0e8-c3a6820c49be', 8, '16Gi', 'xlarge', false); + +-- Stage +INSERT INTO "Stage" + (id, "name") +VALUES + ('4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', 'dev'), + ('38fa869d-6267-441d-af7f-e0548fd06b7e', 'staging'), + ('d434310e-7850-4d59-b47f-0772edf50582', 'integration'), + ('9b3e9991-896d-4d90-bdc5-a34be8c06b8f', 'prod'); + +-- QuotaStage +INSERT INTO "QuotaStage" + (id, "quotaId", "stageId") +VALUES + ('0cb0c549-560e-4f26-8f4e-832dd722f68a', '5a57b62f-2465-4fb6-a853-5a751d099199', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('0530e9c9-b37d-4dec-93e6-1895f700e61c', '5a57b62f-2465-4fb6-a853-5a751d099199', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('8a99db49-b7b1-44bf-865d-5e709e8aa0fc', '5a57b62f-2465-4fb6-a853-5a751d099199', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('67561f00-d219-4ca6-b94a-3ee83f09d2d6', '5a57b62f-2465-4fb6-a853-5a751d099199', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('8b3c201e-7518-4254-a94a-16c404e46936', '08770663-3b76-4af6-8978-9f75eda4faa7', '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + ('9157ae12-3e39-43f8-a24f-ae5d9c6b69b7', '08770663-3b76-4af6-8978-9f75eda4faa7', '38fa869d-6267-441d-af7f-e0548fd06b7e'), + ('c733a1dd-c9fd-4def-b29e-df49ef7b6698', '08770663-3b76-4af6-8978-9f75eda4faa7', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('15a51f47-0ab2-4a94-a808-722639d8c092', '08770663-3b76-4af6-8978-9f75eda4faa7', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('cb66e80c-2304-472d-bc19-a411011674ca', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('59fb0e79-3a76-4b96-81d4-63f4caa98cfa', 'b7b4d9bd-7a8f-4287-bb12-5ce2dadb4ff2', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'), + ('4174b22c-2bee-4f4a-9d85-da7b5463f214', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', 'd434310e-7850-4d59-b47f-0772edf50582'), + ('de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a', '97b851e8-9067-4a3d-a0e8-c3a6820c49be', '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql new file mode 100644 index 0000000000..8c98a7f74e --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20231011125839_dso/migration.sql @@ -0,0 +1,35 @@ +-- Multiplication des environnements par clusteurs +ALTER TABLE "Environment" ADD COLUMN "clusterId" UUID; + +DO +$$ +DECLARE + perm record; + cte record; + env_uuid UUID; +BEGIN + FOR cte IN SELECT "B" AS environmentId, "A" AS "clusterId", "name", "projectId", status, "updatedAt", "createdAt" + FROM public."_ClusterToEnvironment", public."Environment" + WHERE public."Environment".id = "B" + LOOP + env_uuid := gen_random_uuid(); + INSERT INTO public."Environment" (id, "name", "projectId", "clusterId", status, "createdAt", "updatedAt") VALUES + (env_uuid, cte."name", cte."projectId", cte."clusterId", cte.status, cte."createdAt", cte."updatedAt"); + + FOR perm in SELECT * FROM public."Permission" WHERE "environmentId" = cte.environmentId + LOOP + INSERT INTO public."Permission" (id, "level", "createdAt", "updatedAt", "environmentId", "userId") VALUES + (gen_random_uuid(), perm."level", perm."createdAt", perm."updatedAt", env_uuid, perm."userId"); + END LOOP; + END LOOP; +END; +$$ +; +DELETE FROM public."Environment" WHERE "clusterId" is null; +ALTER TABLE public."Environment" ALTER COLUMN "clusterId" SET NOT NULL; +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_clusterId_fkey" FOREIGN KEY ("clusterId") REFERENCES "Cluster"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- Delete old _ClusterToEnvironment +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_A_fkey"; +ALTER TABLE "_ClusterToEnvironment" DROP CONSTRAINT "_ClusterToEnvironment_B_fkey"; +DROP TABLE "_ClusterToEnvironment"; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql new file mode 100644 index 0000000000..035cd1c855 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20231011125841_dso/migration.sql @@ -0,0 +1,36 @@ +-- Associate cluster to Stages +-- CreateTable +CREATE TABLE "_ClusterToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Cluster"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateIndex +CREATE UNIQUE INDEX "_ClusterToStage_AB_unique" ON "_ClusterToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_ClusterToStage_B_index" ON "_ClusterToStage"("B"); + +DO +$$ +DECLARE + cluster record; + cte record; + env_uuid UUID; +BEGIN + FOR cluster IN SELECT id + FROM public."Cluster" + LOOP + INSERT INTO public."_ClusterToStage" ("A", "B") VALUES + (cluster.id, '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9'), + (cluster.id, '38fa869d-6267-441d-af7f-e0548fd06b7e'), + (cluster.id, 'd434310e-7850-4d59-b47f-0772edf50582'), + (cluster.id, '9b3e9991-896d-4d90-bdc5-a34be8c06b8f'); + END LOOP; +END; +$$ diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql new file mode 100644 index 0000000000..43793bdb49 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20231012105520_dso/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaStageId" UUID; +UPDATE "Environment" SET "quotaStageId" = '8b3c201e-7518-4254-a94a-16c404e46936' WHERE "name" = 'dev'; +UPDATE "Environment" SET "quotaStageId" = '9157ae12-3e39-43f8-a24f-ae5d9c6b69b7' WHERE "name" = 'staging'; +UPDATE "Environment" SET "quotaStageId" = '4174b22c-2bee-4f4a-9d85-da7b5463f214' WHERE "name" = 'integration'; +UPDATE "Environment" SET "quotaStageId" = 'de0589b6-7cf5-4f1e-ab44-53e71a6cdb7a' WHERE "name" = 'prod'; +ALTER TABLE "Environment" ALTER COLUMN "name" SET DATA TYPE VARCHAR(11); +ALTER TABLE "Environment" ALTER COLUMN "quotaStageId" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaStageId_fkey" FOREIGN KEY ("quotaStageId") REFERENCES "QuotaStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql new file mode 100644 index 0000000000..9af004b6a3 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20231024155020_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- lock all projects +UPDATE public."Project" SET "locked"=true \ No newline at end of file diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql new file mode 100644 index 0000000000..d970d3965a --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20231026150220_dso/migration.sql @@ -0,0 +1,3 @@ +-- Please read 6.0.0 Release notes ! +-- set all projects to failed to avoid unlock them +UPDATE public."Project" SET "status" = 'failed' WHERE "status" != 'archived' \ No newline at end of file diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql new file mode 100644 index 0000000000..c387d9885c --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240112135751_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ADD COLUMN "requestId" VARCHAR(21); diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql new file mode 100644 index 0000000000..18e20262c8 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240321123436_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `status` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the column `status` on the `Repository` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "status"; + +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "status"; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql new file mode 100644 index 0000000000..f784b21567 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240329172938_dso/migration.sql @@ -0,0 +1,46 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "zoneId" UUID; + +-- CreateTable +CREATE TABLE "Zone" +( + "id" UUID NOT NULL, + "slug" VARCHAR(10) NOT NULL, + "label" VARCHAR(50) NOT NULL, + "description" VARCHAR(200), + "createdAt" TIMESTAMP +(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP +(3) NOT NULL, + + CONSTRAINT "Zone_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_id_key" ON "Zone"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_slug_key" ON "Zone"("slug"); + +-- Create default zone +INSERT INTO "Zone" + (id, "slug", "label", "description", "updatedAt") +VALUES + ('a66c4230-eba6-41f1-aae5-bb1e4f90cce0', 'default', 'Zone Défaut', 'Zone par défaut, à changer', CURRENT_TIMESTAMP); + +-- Set default zoneId for current clusters +UPDATE "Cluster" +SET "zoneId" += 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0' +WHERE "zoneId" +IS NULL; + +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "zoneId" +SET +NOT NULL; + +-- AddForeignKey +ALTER TABLE "Cluster" ADD CONSTRAINT "Cluster_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") +ON DELETE RESTRICT ON +UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql new file mode 100644 index 0000000000..cb4f7ad968 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240424093852_dso/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "ProjectPlugin" ( + "pluginName" TEXT NOT NULL, + "projectId" UUID NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "AdminPlugin" ( + "pluginName" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectPlugin_projectId_pluginName_key_key" ON "ProjectPlugin"("projectId", "pluginName", "key"); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminPlugin_pluginName_key_key" ON "AdminPlugin"("pluginName", "key"); + +-- AddForeignKey +ALTER TABLE "ProjectPlugin" ADD CONSTRAINT "ProjectPlugin_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql new file mode 100644 index 0000000000..11672324f8 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240427181037_dso/migration.sql @@ -0,0 +1,19 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id, services FROM public."Project" LOOP + -- Extrait 'registry.id' de la colonne JSON 'services' + registry_id := (SELECT (project_row.services -> 'registry' ->> 'id')::TEXT); + -- Si 'registry.id' existe, insérer dans la table 'config' + IF registry_id IS NOT NULL THEN + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'registry', 'projectId', registry_id::TEXT); + END IF; + END LOOP; +END $$; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "services"; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql new file mode 100644 index 0000000000..9c7d5a8f8f --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240605135052_dso/migration.sql @@ -0,0 +1,9 @@ +-- CreateEnum +CREATE TYPE "RoleList" AS ENUM ('owner', 'user'); + +-- AlterTable +ALTER TABLE public."Role" ALTER COLUMN "role" TYPE "RoleList" USING + case + when role = 'owner' then 'owner'::"RoleList" + else 'user'::"RoleList" + end; \ No newline at end of file diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql new file mode 100644 index 0000000000..45a4a5d1ea --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240612123132_dso/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "ProjectClusterHistory" ( + "projectId" UUID NOT NULL, + "clusterId" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectClusterHistory_projectId_clusterId_key" ON "ProjectClusterHistory"("projectId", "clusterId"); diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql new file mode 100644 index 0000000000..2b1641a657 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240614222908_dso/migration.sql @@ -0,0 +1,11 @@ +DO $$ +DECLARE + env_row RECORD; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR env_row IN SELECT "projectId", "clusterId" FROM public."Environment" LOOP + INSERT INTO public."ProjectClusterHistory" ("projectId", "clusterId") + VALUES (env_row."projectId", env_row."clusterId") + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql new file mode 100644 index 0000000000..5e7ff03d45 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240618112205_dso/migration.sql @@ -0,0 +1,58 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "quotaId" UUID, +ADD COLUMN "stageId" UUID; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_quotaId_fkey" FOREIGN KEY ("quotaId") REFERENCES "Quota"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Environment" ADD CONSTRAINT "Environment_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "Stage"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "_QuotaToStage" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_QuotaToStage_AB_unique" ON "_QuotaToStage"("A", "B"); + +-- CreateIndex +CREATE INDEX "_QuotaToStage_B_index" ON "_QuotaToStage"("B"); + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_A_fkey" FOREIGN KEY ("A") REFERENCES "Quota"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_B_fkey" FOREIGN KEY ("B") REFERENCES "Stage"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +DO $$ +DECLARE + quota_stage_row RECORD; +BEGIN + FOR quota_stage_row IN SELECT * FROM public."QuotaStage" loop + UPDATE public."Environment" SET "stageId" = quota_stage_row."stageId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + UPDATE public."Environment" SET "quotaId" = quota_stage_row."quotaId" WHERE "Environment"."quotaStageId" = quota_stage_row.id; + insert into public."_QuotaToStage" values (quota_stage_row."quotaId", quota_stage_row."stageId"); + END LOOP; +END $$; + +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaStageId_fkey"; + +-- AlterTable +ALTER TABLE "Environment" ALTER COLUMN "quotaId" SET NOT NULL, +ALTER COLUMN "stageId" SET NOT NULL, +DROP COLUMN "quotaStageId"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "QuotaStage" DROP CONSTRAINT "QuotaStage_stageId_fkey"; + +-- DropTable +DROP TABLE "QuotaStage"; + +-- DropEnum +DROP TYPE "QuotaStageStatus"; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql new file mode 100644 index 0000000000..0036da8a12 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240717084709_dso/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Made the column `description` on table `Project` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "description" SET NOT NULL, +ALTER COLUMN "description" SET DEFAULT ''; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql new file mode 100644 index 0000000000..ed6ae9b84e --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240723135420_dso/migration.sql @@ -0,0 +1,198 @@ +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_environmentId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_environmentId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Permission_userId_fkey') THEN + ALTER TABLE "Permission" DROP CONSTRAINT "Permission_userId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_projectId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_projectId_fkey"; + END IF; +END $$; + +-- DropForeignKey if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Role_userId_fkey') THEN + ALTER TABLE "Role" DROP CONSTRAINT "Role_userId_fkey"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectMembers') THEN + CREATE TABLE "ProjectMembers" ( + "projectId" UUID NOT NULL, + "userId" UUID NOT NULL, + "roleIds" TEXT[] + ); + END IF; +END $$; + +-- AlterTable +ALTER TABLE "Log" ADD COLUMN IF NOT EXISTS "projectId" UUID; + +INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt") +VALUES('04ac168a-2c4f-4816-9cce-af6c612e5912'::uuid, 'Anonymous', 'User', 'anon@user', '2023-07-03 14:46:56.770', '2023-07-03 14:46:56.770') +ON CONFLICT (id) DO NOTHING; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN IF NOT EXISTS "everyonePerms" BIGINT NOT NULL DEFAULT 896, +ADD COLUMN IF NOT EXISTS "ownerId" UUID; + +DO $$ +DECLARE + role_row RECORD; +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR role_row IN SELECT "userId", "projectId", "role" FROM public."Role" LOOP + INSERT INTO public."ProjectMembers" ("userId", "projectId", "roleIds") VALUES (role_row."userId", role_row."projectId", '{}'); + IF role_row."role" = 'owner'::public."RoleList" THEN + UPDATE public."Project" + SET "ownerId"=role_row."userId" + WHERE id=role_row."projectId"::uuid; + END IF; + END LOOP; + END IF; +END $$; + +UPDATE public."Project" +SET "ownerId"='04ac168a-2c4f-4816-9cce-af6c612e5912' +WHERE "ownerId" IS NULL; + +ALTER TABLE public."Project" ALTER COLUMN "ownerId" SET NOT NULL; + +DELETE FROM public."ProjectMembers" pm +USING public."Project" p +WHERE pm."userId" = p."ownerId" +AND pm."projectId" = p."id"; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Permission') THEN + DROP TABLE "Permission"; + END IF; +END $$; + +-- DropTable if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'Role') THEN + DROP TABLE "Role"; + END IF; +END $$; + +-- DropEnum if exists +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'RoleList') THEN + DROP TYPE "RoleList"; + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'AdminRole') THEN + CREATE TABLE "AdminRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "AdminRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateTable if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'ProjectRole') THEN + CREATE TABLE "ProjectRole" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "projectId" UUID NOT NULL, + "position" SMALLINT NOT NULL, + CONSTRAINT "ProjectRole_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_id_key') THEN + CREATE UNIQUE INDEX "AdminRole_id_key" ON "AdminRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'AdminRole_name_key') THEN + CREATE UNIQUE INDEX "AdminRole_name_key" ON "AdminRole"("name"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectMembers_projectId_userId_key') THEN + CREATE UNIQUE INDEX "ProjectMembers_projectId_userId_key" ON "ProjectMembers"("projectId", "userId"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'ProjectRole_id_key') THEN + CREATE UNIQUE INDEX "ProjectRole_id_key" ON "ProjectRole"("id"); + END IF; +END $$; + +-- CreateIndex if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'Environment_projectId_name_key') THEN + CREATE UNIQUE INDEX "Environment_projectId_name_key" ON "Environment"("projectId", "name"); + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Log_projectId_fkey') THEN + ALTER TABLE "Log" ADD CONSTRAINT "Log_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'Project_ownerId_fkey') THEN + ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_projectId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectMembers_userId_fkey') THEN + ALTER TABLE "ProjectMembers" ADD CONSTRAINT "ProjectMembers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AddForeignKey if not exists +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'ProjectRole_projectId_fkey') THEN + ALTER TABLE "ProjectRole" ADD CONSTRAINT "ProjectRole_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; +END $$; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "adminRoleIds" TEXT[]; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql new file mode 100644 index 0000000000..c9b41827bf --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240725162050_dso/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "AdminRole_name_key"; + +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "oidcGroup" TEXT; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql new file mode 100644 index 0000000000..265f262abb --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240726210139_dso/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `oidcGroup` on table `AdminRole` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable + +UPDATE public."AdminRole" +SET "oidcGroup"='' +WHERE "oidcGroup" IS NULL; + +ALTER TABLE "AdminRole" ALTER COLUMN "oidcGroup" SET NOT NULL, +ALTER COLUMN "oidcGroup" SET DEFAULT ''; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql new file mode 100644 index 0000000000..4fc2768605 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240808082632_dso/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "SystemSetting" +( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + + CONSTRAINT "SystemSetting_pkey" PRIMARY KEY ("key") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SystemSetting_key_key" ON "SystemSetting"("key"); + +-- Create maintenance setting +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('maintenance', 'off'); diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql new file mode 100644 index 0000000000..95ab54869d --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240826143230_dso/migration.sql @@ -0,0 +1,3 @@ +INSERT INTO public."AdminRole" +(id, "name", permissions, "position", "oidcGroup") +VALUES('76229c96-4716-45bc-99da-00498ec9018c'::uuid, 'Admin', 2, 0, '/admin'); \ No newline at end of file diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql new file mode 100644 index 0000000000..c116482186 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240829085548_dso/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Made the column `externalUserName` on table `Repository` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +UPDATE "Repository" SET "externalUserName" = '' WHERE "externalUserName" IS NULL; + +ALTER TABLE "Repository" ALTER COLUMN "externalUserName" SET NOT NULL, +ALTER COLUMN "externalUserName" SET DEFAULT '', +ALTER COLUMN "externalRepoUrl" SET DEFAULT ''; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240916141253_token/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240916141253_token/migration.sql new file mode 100644 index 0000000000..b0472cd806 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240916141253_token/migration.sql @@ -0,0 +1,23 @@ +-- CreateEnum +CREATE TYPE "TokenStatus" AS ENUM ('active', 'revoked'); + +-- CreateTable +CREATE TABLE "AdminToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "permissions" BIGINT NOT NULL, + "userId" UUID, + "expirationDate" TIMESTAMP(3), + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "AdminToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminToken_id_key" ON "AdminToken"("id"); + +-- AddForeignKey +ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql new file mode 100644 index 0000000000..47488b00c5 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240919122331_optional_user_id/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "Log" DROP CONSTRAINT "Log_userId_fkey"; + +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "userId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "Log" ADD CONSTRAINT "Log_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql new file mode 100644 index 0000000000..18eca3eadf --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240923142722_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Log" ALTER COLUMN "requestId" SET DATA TYPE VARCHAR(36); diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql new file mode 100644 index 0000000000..74e0946f0a --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240923155416_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Zone" ADD COLUMN "argocdUrl" TEXT NOT NULL DEFAULT 'https://example.com'; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql new file mode 100644 index 0000000000..41dac75359 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20240928002900_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ProjectStatus" ADD VALUE 'warning'; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql new file mode 100644 index 0000000000..ef888d5e50 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20241008125724_enabling_maven/migration.sql @@ -0,0 +1,12 @@ +DO $$ +DECLARE + project_row RECORD; + registry_id INT; +BEGIN + -- Début de la boucle sur chaque ligne de la table 'Project' + FOR project_row IN SELECT id FROM public."Project" WHERE status <> 'archived'::public."ProjectStatus" LOOP + INSERT INTO public."ProjectPlugin" ("projectId", "pluginName", "key", "value") + VALUES (project_row.id, 'nexus', 'activateMavenRepo', 'enabled') + ON CONFLICT DO NOTHING; + END LOOP; +END $$; \ No newline at end of file diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql new file mode 100644 index 0000000000..a57e5956c3 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20241104232540_add_usertype/migration.sql @@ -0,0 +1,12 @@ +-- CreateEnum +CREATE TYPE "UserType" AS ENUM ('human', 'bot', 'ghost'); + +-- AlterEnum +ALTER TYPE "TokenStatus" ADD VALUE 'inactive'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "type" "UserType" NOT NULL DEFAULT 'human'; +UPDATE "User" SET type = 'ghost' WHERE id = '04ac168a-2c4f-4816-9cce-af6c612e5912'; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "type" DROP DEFAULT; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql new file mode 100644 index 0000000000..71e15a312c --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20241104232541_add_pat/migration.sql @@ -0,0 +1,84 @@ +-- CreateTable (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'PersonalAccessToken') THEN + CREATE TABLE "PersonalAccessToken" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "userId" UUID NOT NULL, + "expirationDate" TIMESTAMP(3) NOT NULL, + "lastUse" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "TokenStatus" NOT NULL DEFAULT 'active', + "hash" TEXT NOT NULL, + + CONSTRAINT "PersonalAccessToken_pkey" PRIMARY KEY ("id") + ); + END IF; +END $$; + +-- CreateIndex (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'PersonalAccessToken_id_key') THEN + CREATE UNIQUE INDEX "PersonalAccessToken_id_key" ON "PersonalAccessToken"("id"); + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'PersonalAccessToken_userId_fkey') THEN + ALTER TABLE "PersonalAccessToken" ADD CONSTRAINT "PersonalAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- Process AdminToken (idempotent) +DO $$ +DECLARE + admin_token record; + user_uuid UUID; +BEGIN + FOR admin_token IN SELECT "name", "id" + FROM public."AdminToken" + LOOP + -- Generate new UUID if user does not exist + user_uuid := COALESCE( + (SELECT id FROM public."User" WHERE email = concat(admin_token.name, '@bot.id')), + gen_random_uuid() + ); + + -- Insert user if not already exists + INSERT INTO public."User" (id, "firstName", "lastName", email, "createdAt", "updatedAt", "type") + VALUES(user_uuid, 'Bot Admin', admin_token.name, concat(admin_token.name, '@bot.id'), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'bot') + ON CONFLICT (id) DO NOTHING; + + -- Update AdminToken with the new user ID + UPDATE public."AdminToken" SET "userId" = user_uuid WHERE id = admin_token.id; + END LOOP; +END $$; + +-- Alter AdminToken userId column to NOT NULL (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'AdminToken' AND column_name = 'userId' AND is_nullable = 'NO') THEN + ALTER TABLE public."AdminToken" ALTER COLUMN "userId" SET NOT NULL; + END IF; +END $$; + +-- DropForeignKey if exists (idempotent) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" DROP CONSTRAINT "AdminToken_userId_fkey"; + END IF; +END $$; + +-- AddForeignKey (idempotent) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'AdminToken_userId_fkey') THEN + ALTER TABLE "AdminToken" ADD CONSTRAINT "AdminToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql new file mode 100644 index 0000000000..521b2b10ac --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20241107142721_user_last_login/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastLogin" TIMESTAMP(3); diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql new file mode 100644 index 0000000000..a7500833b9 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20241112101945_add_slug/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "slug" TEXT; + +UPDATE public."Project" p +SET slug = ( + SELECT concat(org.name, '-', subp.name) FROM public."Project" subp + LEFT JOIN public."Organization" org on org."id" = subp."organizationId" + WHERE subp.id = p.id +); + +ALTER TABLE public."Project" ALTER COLUMN "slug" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug"); diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql new file mode 100644 index 0000000000..b143cbeb9b --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20241112102015_add_provisionning_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "lastSuccessProvisionningVersion" TEXT; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql new file mode 100644 index 0000000000..7a88681901 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20241216131342_dso/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE "_ClusterToProject" ADD CONSTRAINT "_ClusterToProject_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToProject_AB_unique"; + +-- AlterTable +ALTER TABLE "_ClusterToStage" ADD CONSTRAINT "_ClusterToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_ClusterToStage_AB_unique"; + +-- AlterTable +ALTER TABLE "_QuotaToStage" ADD CONSTRAINT "_QuotaToStage_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "_QuotaToStage_AB_unique"; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql new file mode 100644 index 0000000000..21ce77b8d5 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20250107104749_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ADD COLUMN "external" BOOLEAN NOT NULL DEFAULT false; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql new file mode 100644 index 0000000000..ac63cd639d --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20250121222953_prevent_upgrade/migration.sql @@ -0,0 +1,25 @@ +-- Vérifie les versions dans la table Project +DO $$ +DECLARE + project_id TEXT; + project_name TEXT; + last_version TEXT; +BEGIN + -- Boucle sur les projets non archivés + FOR project_id, project_name, last_version IN ( + SELECT id, name, "lastSuccessProvisionningVersion" + FROM "Project" + WHERE "status" != 'archived' + ) + LOOP + -- Vérifie si la version est NULL + IF last_version IS NULL THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version NULL.', project_name, project_id; + END IF; + + -- Vérifie si la version est inférieure à 8.23.0 selon SemVer + IF (string_to_array(last_version, '.')::int[] < ARRAY[8,23,0]) THEN + RAISE EXCEPTION 'Le projet % (ID: %) a une version (%), inférieure à 8.23.0.', project_name, project_id, last_version; + END IF; + END LOOP; +END $$; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql new file mode 100644 index 0000000000..54871c9017 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20250121222954_drop_organization/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `organizationId` on the `Project` table. All the data in the column will be lost. + - You are about to drop the `Organization` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Project" DROP CONSTRAINT "Project_organizationId_fkey"; + +-- AlterTable +ALTER TABLE "Project" DROP COLUMN "organizationId"; + +-- DropTable +DROP TABLE "Organization"; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql new file mode 100644 index 0000000000..68ca0df2fa --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20250723141246_dso/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "infos" SET DATA TYPE VARCHAR(1000); diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql new file mode 100644 index 0000000000..8364090d8d --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20250818095032_remove_quota/migration.sql @@ -0,0 +1,44 @@ +-- AlterTable +ALTER TABLE "Environment" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; + +COMMENT ON COLUMN "Environment".cpu IS 'CPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".gpu IS 'GPU share as float (1 and 0.01 are valid values)'; +COMMENT ON COLUMN "Environment".memory IS 'Memory value as GigaBytes (1 and 0.01 are valid values)'; + +-- Use values from Quota. Memory is an extract of q.memory numeric value as it contains a unit (e.g. '2Gi'). +UPDATE "Environment" +SET cpu = q.cpu, memory = COALESCE(NULLIF(regexp_replace(q.memory, '\D', '','g'), ''), '0')::numeric +FROM "Quota" q +WHERE "quotaId" = q."id"; + +/* + Warnings: + + - You are about to drop the column `quotaId` on the `Environment` table. All the data in the column will be lost. + - You are about to drop the `Quota` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_QuotaToStage` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Environment" DROP CONSTRAINT "Environment_quotaId_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_QuotaToStage" DROP CONSTRAINT "_QuotaToStage_B_fkey"; + +-- AlterTable +ALTER TABLE "Environment" DROP COLUMN "quotaId", +ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- DropTable +DROP TABLE "Quota"; + +-- DropTable +DROP TABLE "_QuotaToStage"; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql new file mode 100644 index 0000000000..77f32b5ab5 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20250825150622_add_cluster_resources/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Cluster" +ADD COLUMN "cpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "gpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "memory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql new file mode 100644 index 0000000000..decca804aa --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20250916134454_add_project_resources/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "Project" +ADD COLUMN "limitless" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "hprodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "hprodMemory" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodCpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodGpu" REAL NOT NULL DEFAULT 0, +ADD COLUMN "prodMemory" REAL NOT NULL DEFAULT 0; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql new file mode 100644 index 0000000000..95f3a689db --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20251028150522_rename_default_zone/migration.sql @@ -0,0 +1,4 @@ +-- Rename default zone +UPDATE "Zone" +SET ("label", "description") = ('DSO', 'Zone par défaut') +WHERE slug = 'default'; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql new file mode 100644 index 0000000000..aadb6cdba9 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20251208140951_add_argocd_inputs/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Repository" ADD COLUMN "deployRevision" TEXT, +ADD COLUMN "deployPath" TEXT, +ADD COLUMN "helmValuesFiles" TEXT; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql new file mode 100644 index 0000000000..15cf56ab82 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20251218103526_alter_repository_defaults/migration.sql @@ -0,0 +1,12 @@ +-- Eliminate NULLs +UPDATE "Repository" SET "deployRevision" = '' WHERE "deployRevision" IS NULL; +UPDATE "Repository" SET "deployPath" = '' WHERE "deployPath" IS NULL; +UPDATE "Repository" SET "helmValuesFiles" = '' WHERE "helmValuesFiles" IS NULL; + +-- AlterTable +ALTER TABLE "Repository" ALTER COLUMN "deployRevision" SET NOT NULL, +ALTER COLUMN "deployRevision" SET DEFAULT '', +ALTER COLUMN "deployPath" SET NOT NULL, +ALTER COLUMN "deployPath" SET DEFAULT '', +ALTER COLUMN "helmValuesFiles" SET NOT NULL, +ALTER COLUMN "helmValuesFiles" SET DEFAULT ''; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql new file mode 100644 index 0000000000..1292495121 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20251219144931_add_environment_autosync/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Environment" ADD COLUMN "autosync" BOOLEAN NOT NULL DEFAULT true; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql new file mode 100644 index 0000000000..04c98df036 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260127154602_add_oidc_group_to_project_role/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "oidcGroup" TEXT NOT NULL DEFAULT ''; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql new file mode 100644 index 0000000000..40da41a75d --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260127164002_add_type_to_roles/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "AdminRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- AlterTable +ALTER TABLE "ProjectRole" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'custom'; + +-- Update AdminRole system roles +UPDATE "AdminRole" SET "type" = 'system' WHERE "name" IN ('Admin', 'Admin Locaux'); + +-- Update ProjectRole system roles +UPDATE "ProjectRole" SET "type" = 'system' WHERE "name" IN ('Administrateur', 'DevOps', 'Développeur', 'Lecture seule'); diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql new file mode 100644 index 0000000000..92c200d261 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260204150335_add_system_roles/migration.sql @@ -0,0 +1,26 @@ +-- Update existing Admin role to be system role 'Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + "oidcGroup" = '/admin', + "position" = 0 +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Lecture Seule Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '35848aa2-e881-4770-9844-0c5c3693e506'::uuid, + 'Lecture Seule Plateforme', + 1, -- Assuming 1n means bit 0 + 2, + '/readonly', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Lecture Seule Plateforme', + "type" = 'system', + "permissions" = 1, + "oidcGroup" = '/readonly'; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql new file mode 100644 index 0000000000..2a4afa8971 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260206105522_dso/migration.sql @@ -0,0 +1,28 @@ +-- Update existing Admin role to be system role 'Root Administrateur Plateforme' +UPDATE "AdminRole" +SET + "name" = 'Root Administrateur Plateforme' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; + +-- Insert 'Administrateur Plateforme' system role if it doesn't exist +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf'::uuid, + 'Administrateur Plateforme', + 3, -- Assuming 3n means bit 0 and 1 (1 | 2 = 3) + 0, + '/console/admin', + 'system' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Administrateur Plateforme', + "type" = 'system', + "permissions" = 3, + "oidcGroup" = '/console/admin'; + +-- Update 'Lecture Seule Plateforme' system role +UPDATE "AdminRole" +SET + "oidcGroup" = '/console/readonly' +WHERE "id" = '35848aa2-e881-4770-9844-0c5c3693e506'::uuid; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql new file mode 100644 index 0000000000..f0cdb04873 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260211143048_dso/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `external` on the `Cluster` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Cluster" DROP COLUMN "external"; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql new file mode 100644 index 0000000000..e07cad777b --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260217144930_enable_legacy_permissions/migration.sql @@ -0,0 +1,5 @@ +-- Enable legacy default permissions flag +INSERT INTO "SystemSetting" + ("key", "value") +VALUES + ('refined-permissions', 'off'); diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql new file mode 100644 index 0000000000..efb6bc67d4 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260226130000_remove_refined_permissions_setting/migration.sql @@ -0,0 +1,3 @@ +-- Remove 'refined-permissions' system setting +DELETE FROM "SystemSetting" +WHERE "key" = 'refined-permissions'; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql new file mode 100644 index 0000000000..d019403c44 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260226140001_add_tout_le_monde/migration.sql @@ -0,0 +1,15 @@ +-- Add global 'Tout le monde' role +INSERT INTO "AdminRole" ("id", "name", "permissions", "position", "oidcGroup", "type") +VALUES ( + '034f589f-1750-4b15-bb34-4cd995e7fcaa'::uuid, + 'Tout le monde', + 25608, -- MANAGE_PROJECTS | LIST_PROJECTS | LIST_ZONES | LIST_STAGES + 0, + '', + 'global' +) +ON CONFLICT ("id") DO UPDATE +SET + "name" = 'Tout le monde', + "type" = 'global', + "permissions" = 25608; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql new file mode 100644 index 0000000000..4fd0d80eb2 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260226141201_role_type/migration.sql @@ -0,0 +1,25 @@ +-- Alter existing column to change the default value from 'custom' to 'managed' for AdminRole type +ALTER TABLE "AdminRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Alter existing column to change the default value from 'custom' to 'managed' for ProjectRole type +ALTER TABLE "ProjectRole" ALTER COLUMN "type" SET DEFAULT 'managed'; + +-- Migrate system roles to managed roles +UPDATE "AdminRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate system roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'system'; + +-- Migrate custom roles to managed roles +UPDATE "ProjectRole" +SET "type" = 'managed' +WHERE "type" = 'custom'; + +-- Update existing Admin role to be an externally managed role +UPDATE "AdminRole" +SET "type" = 'external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'::uuid; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql new file mode 100644 index 0000000000..d2691666bd --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260330122500_migrate_project_system_roles_type_system_managed/migration.sql @@ -0,0 +1,15 @@ +-- Migrate well-known project roles to the new "system:"-prefixed type +UPDATE "ProjectRole" +SET "type" = 'system:managed' +WHERE + "type" = 'managed' + AND "oidcGroup" ~ '^/[^/]+/console/(admin|devops|developer|readonly)$'; + +-- Migrate well-known admin roles to the new "system:"-prefixed type +UPDATE "AdminRole" +SET "type" = 'system:managed' +WHERE id IN ( + '76229c96-4716-45bc-99da-00498ec9018c', -- Root Administrateur Plateforme + '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', -- Administrateur Plateforme + '35848aa2-e881-4770-9844-0c5c3693e506' -- Lecture Seule Plateforme +); diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql new file mode 100644 index 0000000000..23593ba4f4 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260402095325_dso/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Cluster" ALTER COLUMN "cpu" DROP DEFAULT, +ALTER COLUMN "gpu" DROP DEFAULT, +ALTER COLUMN "memory" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "hprodCpu" DROP DEFAULT, +ALTER COLUMN "hprodGpu" DROP DEFAULT, +ALTER COLUMN "hprodMemory" DROP DEFAULT, +ALTER COLUMN "prodCpu" DROP DEFAULT, +ALTER COLUMN "prodGpu" DROP DEFAULT, +ALTER COLUMN "prodMemory" DROP DEFAULT; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql new file mode 100644 index 0000000000..7e3eefb25d --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260415103000_set_root_admin_to_system_external/migration.sql @@ -0,0 +1,4 @@ +-- Set the Root Administrateur Plateforme admin role to external system-managed type +UPDATE "AdminRole" +SET "type" = 'system:external' +WHERE "id" = '76229c96-4716-45bc-99da-00498ec9018c'; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql new file mode 100644 index 0000000000..ca81594339 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260504101704_add_deployment/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "DeploymentSourceType" AS ENUM ('git', 'oci'); + +-- CreateTable +CREATE TABLE "Deployment" ( + "id" UUID NOT NULL, + "projectId" UUID NOT NULL, + "name" VARCHAR(11) NOT NULL, + "autosync" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "environmentId" UUID NOT NULL, + + CONSTRAINT "Deployment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "DeploymentSource" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deploymentId" UUID NOT NULL, + "repositoryId" UUID NOT NULL, + "type" "DeploymentSourceType" NOT NULL, + "targetRevision" TEXT NOT NULL DEFAULT '', + "path" TEXT NOT NULL DEFAULT '', + "helmValuesFiles" TEXT NOT NULL DEFAULT '', + + CONSTRAINT "DeploymentSource_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Deployment_id_key" ON "Deployment"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "DeploymentSource_id_key" ON "DeploymentSource"("id"); + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_deploymentId_fkey" FOREIGN KEY ("deploymentId") REFERENCES "Deployment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql new file mode 100644 index 0000000000..ad5a64d336 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/20260615095046_cascade_delete_deployment/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "Deployment" DROP CONSTRAINT "Deployment_environmentId_fkey"; + +-- DropForeignKey +ALTER TABLE "DeploymentSource" DROP CONSTRAINT "DeploymentSource_repositoryId_fkey"; + +-- AddForeignKey +ALTER TABLE "Deployment" ADD CONSTRAINT "Deployment_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeploymentSource" ADD CONSTRAINT "DeploymentSource_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/.jjconflict-side-2/apps/server/src/prisma/migrations/migration_lock.toml b/.jjconflict-side-2/apps/server/src/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000..044d57cdb0 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/.jjconflict-side-2/apps/server/src/prisma/schema/admin.prisma b/.jjconflict-side-2/apps/server/src/prisma/schema/admin.prisma new file mode 100644 index 0000000000..c4ca38ccb1 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/schema/admin.prisma @@ -0,0 +1,21 @@ +model AdminPlugin { + pluginName String + key String + value String + + @@unique([pluginName, key]) +} + +model AdminRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") +} + +model SystemSetting { + key String @id @unique + value String +} diff --git a/.jjconflict-side-2/apps/server/src/prisma/schema/project.prisma b/.jjconflict-side-2/apps/server/src/prisma/schema/project.prisma new file mode 100644 index 0000000000..6bcc5ce949 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/schema/project.prisma @@ -0,0 +1,144 @@ +model Deployment { + id String @id @unique @default(uuid()) @db.Uuid + projectId String @db.Uuid + name String @db.VarChar(11) + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + environmentId String @db.Uuid + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model DeploymentSource { + id String @id @unique @default(uuid()) @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deploymentId String @db.Uuid + repositoryId String @db.Uuid + type DeploymentSourceType + targetRevision String @default("") + path String @default("") + helmValuesFiles String @default("") + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) + deployment Deployment @relation(fields: [deploymentId], references: [id], onDelete: Cascade) +} + +model Environment { + id String @id @default(uuid()) @db.Uuid + name String @db.VarChar(11) + projectId String @db.Uuid + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + autosync Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + clusterId String @db.Uuid + stageId String @db.Uuid + cluster Cluster @relation(fields: [clusterId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + stage Stage @relation(fields: [stageId], references: [id]) + deployments Deployment[] + + @@unique([projectId, name]) +} + +model Repository { + id String @id @default(uuid()) @db.Uuid + projectId String @db.Uuid + internalRepoName String + externalRepoUrl String @default("") + externalUserName String @default("") + isInfra Boolean @default(false) + isPrivate Boolean @default(false) + deployRevision String @default("") + deployPath String @default("") + helmValuesFiles String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + deploymentSources DeploymentSource[] +} + +model ProjectClusterHistory { + projectId String @db.Uuid + clusterId String @db.Uuid + + @@unique([projectId, clusterId]) +} + +model ProjectMembers { + projectId String @db.Uuid + userId String @db.Uuid + roleIds String[] + project Project @relation(fields: [projectId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@unique([projectId, userId]) +} + +model ProjectPlugin { + pluginName String + projectId String @db.Uuid + key String + value String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([projectId, pluginName, key]) +} + +model ProjectRole { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + projectId String @db.Uuid + position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") + project Project @relation(fields: [projectId], references: [id]) +} + +model Project { + id String @id @unique @default(uuid()) @db.Uuid + name String + description String @default("") + status ProjectStatus @default(initializing) + locked Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + everyonePerms BigInt @default(896) + ownerId String @db.Uuid + environments Environment[] + logs Log[] + owner User @relation(fields: [ownerId], references: [id]) + members ProjectMembers[] + plugins ProjectPlugin[] + roles ProjectRole[] + repositories Repository[] + clusters Cluster[] @relation("ClusterToProject") + slug String @unique + limitless Boolean @default(true) + hprodCpu Float @db.Real + hprodGpu Float @db.Real + hprodMemory Float @db.Real + prodCpu Float @db.Real + prodGpu Float @db.Real + prodMemory Float @db.Real + lastSuccessProvisionningVersion String? + deployments Deployment[] +} + +enum ProjectStatus { + initializing + created + failed + archived + warning +} + +enum DeploymentSourceType { + git + oci +} diff --git a/.jjconflict-side-2/apps/server/src/prisma/schema/schema.prisma b/.jjconflict-side-2/apps/server/src/prisma/schema/schema.prisma new file mode 100644 index 0000000000..aadf7fea13 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/schema/schema.prisma @@ -0,0 +1,21 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DB_URL") +} + +model Log { + id String @id @default(uuid()) @db.Uuid + data Json + action String @default("") + userId String? @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + requestId String? @db.VarChar(36) + projectId String? @db.Uuid + project Project? @relation(fields: [projectId], references: [id]) + user User? @relation(fields: [userId], references: [id]) +} diff --git a/.jjconflict-side-2/apps/server/src/prisma/schema/token.prisma b/.jjconflict-side-2/apps/server/src/prisma/schema/token.prisma new file mode 100644 index 0000000000..c0c55751c5 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/schema/token.prisma @@ -0,0 +1,30 @@ +model AdminToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + permissions BigInt + userId String @db.Uuid + expirationDate DateTime? + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +model PersonalAccessToken { + id String @id @unique @default(uuid()) @db.Uuid + name String + userId String @db.Uuid + expirationDate DateTime + lastUse DateTime? + createdAt DateTime @default(now()) + status TokenStatus @default(active) + hash String + owner User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} + +enum TokenStatus { + active + revoked + inactive +} diff --git a/.jjconflict-side-2/apps/server/src/prisma/schema/topography.prisma b/.jjconflict-side-2/apps/server/src/prisma/schema/topography.prisma new file mode 100644 index 0000000000..63d90abb8a --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/schema/topography.prisma @@ -0,0 +1,52 @@ +model Cluster { + id String @id @unique @default(uuid()) @db.Uuid + label String @unique @db.VarChar(50) + privacy ClusterPrivacy @default(dedicated) + secretName String @unique @default(uuid()) @db.VarChar(50) + clusterResources Boolean @default(false) + kubeConfigId String @unique @db.Uuid + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + infos String? @db.VarChar(1000) + memory Float @db.Real + cpu Float @db.Real + gpu Float @db.Real + zoneId String @db.Uuid + kubeconfig Kubeconfig @relation(fields: [kubeConfigId], references: [id], onDelete: Cascade) + zone Zone @relation(fields: [zoneId], references: [id]) + environments Environment[] + projects Project[] @relation("ClusterToProject") + stages Stage[] @relation("ClusterToStage") +} + +model Kubeconfig { + id String @id @unique @default(uuid()) @db.Uuid + user Json + cluster Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + parentCluster Cluster? +} + +model Stage { + id String @id @unique @default(uuid()) @db.Uuid + name String @unique @db.VarChar + environments Environment[] + clusters Cluster[] @relation("ClusterToStage") +} + +model Zone { + id String @id @unique @default(uuid()) @db.Uuid + slug String @unique @db.VarChar(10) + label String @db.VarChar(50) + description String? @db.VarChar(200) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + argocdUrl String @default("https://example.com") + clusters Cluster[] +} + +enum ClusterPrivacy { + public + dedicated +} diff --git a/.jjconflict-side-2/apps/server/src/prisma/schema/user.prisma b/.jjconflict-side-2/apps/server/src/prisma/schema/user.prisma new file mode 100644 index 0000000000..e90fb69f83 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/prisma/schema/user.prisma @@ -0,0 +1,23 @@ +model User { + id String @id @db.Uuid + firstName String + lastName String + email String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? + adminRoleIds String[] + type UserType + + logs Log[] + projectsOwned Project[] + adminTokens AdminToken[] + projectMembers ProjectMembers[] + personalAccessTokens PersonalAccessToken[] +} + +enum UserType { + human + bot + ghost +} diff --git a/.jjconflict-side-2/apps/server/src/resources/admin-role/business.spec.ts b/.jjconflict-side-2/apps/server/src/resources/admin-role/business.spec.ts new file mode 100644 index 0000000000..9f9a7051ea --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/admin-role/business.spec.ts @@ -0,0 +1,262 @@ +import type { AdminRole, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400 } from '../../utils/errors.ts' +import { countRolesMembers, createRole, deleteRole, listRoles, patchRoles } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +describe('test admin-role business', () => { + describe('listRoles', () => { + it('should stringify bigint', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + const response = await listRoles() + expect(response).toContainEqual(expect.objectContaining({ permissions: '4', type: 'managed' })) + }) + }) + + describe('createRole', () => { + it('should create role with incremented position when position 0 is the highest', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 1 } }) + }) + + it('should create role with incremented position with bigger position', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 51 } }) + }) + + it('should create role with incremented position with no role in db', async () => { + const dbRole: AdminRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.adminRole.findFirst.mockResolvedValueOnce(null) + prisma.adminRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await createRole({ name: 'test' }, faker.string.uuid()) + + expect(prisma.adminRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 0n, position: 0 } }) + }) + }) + describe('deleteRole', () => { + const roleId = faker.string.uuid() + it('should delete role and remove id from concerned users', async () => { + const users = [{ + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roleId], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }, { + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roleId, faker.string.uuid()], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }] as const satisfies User[] + + const dbRole: AdminRole = { + name: 'Admin', + id: roleId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.user.findMany.mockResolvedValue(users) + prisma.adminRole.findMany.mockResolvedValueOnce([]) + prisma.adminRole.findUnique.mockResolvedValueOnce(dbRole) + prisma.adminRole.create.mockResolvedValue(dbRole) + hook.adminRole.delete.mockResolvedValue({ args: {}, failed: false, results: {} }) + await deleteRole(roleId, faker.string.uuid()) + + expect(prisma.user.findMany).toHaveBeenCalledTimes(2) + expect(prisma.user.update).toHaveBeenNthCalledWith(1, { where: { id: users[0].id }, data: { adminRoleIds: [] } }) + expect(prisma.user.update).toHaveBeenNthCalledWith(2, { where: { id: users[1].id }, data: { adminRoleIds: [users[1].adminRoleIds[1]] } }) + expect(prisma.adminRole.delete).toHaveBeenCalledWith({ where: { id: roleId } }) + }) + }) + + describe('countRolesMembers', () => { + it('should return aggregated role member counts', async () => { + const roles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + type: 'managed', + }] as const satisfies AdminRole[] + + const users = [{ + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roles[0].id, roles[1].id], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }, { + id: faker.string.uuid(), + type: 'human', + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + adminRoleIds: [roles[1].id], + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + lastLogin: faker.date.past(), + }] as const satisfies User[] + prisma.adminRole.findMany.mockResolvedValue(roles) + prisma.user.findMany.mockResolvedValue(users) + + const response = await countRolesMembers() + + expect(response).toEqual({ [roles[0].id]: 1, [roles[1].id]: 2 }) + }) + }) + describe('patchRoles', () => { + const dbRoles: AdminRole[] = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + type: 'managed', + }] + + it('should do nothing', async () => { + prisma.adminRole.findMany.mockResolvedValue([]) + await patchRoles([], faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 1 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(updateRoles, faker.string.uuid()) + + expect(response).instanceOf(BadRequest400) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + it('should return 400 if incoherent positions (missing roles)', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[1].id, position: 1 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(updateRoles, faker.string.uuid()) + + expect(response).instanceOf(BadRequest400) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(0) + }) + it('should update positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 0 }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await patchRoles(updateRoles, faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(2) + }) + it('should update permissions', async () => { + const updateRoles: (Pick & { permissions?: string })[] = [ + { id: dbRoles[1].id, permissions: '0' }, + ] + prisma.adminRole.findMany.mockResolvedValue(dbRoles) + hook.adminRole.upsert.mockResolvedValue({ args: {}, failed: false, results: {} }) + await patchRoles(updateRoles, faker.string.uuid()) + expect(prisma.adminRole.update).toHaveBeenCalledTimes(1) + expect(prisma.adminRole.update).toHaveBeenCalledWith({ + data: { + name: dbRoles[1].name, + oidcGroup: dbRoles[1].oidcGroup, + permissions: 0n, + position: 1, + type: 'managed', + }, + where: { + id: dbRoles[1].id, + }, + }) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/admin-role/business.ts b/.jjconflict-side-2/apps/server/src/resources/admin-role/business.ts new file mode 100644 index 0000000000..f81fd65b75 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/admin-role/business.ts @@ -0,0 +1,111 @@ +import type { AdminRole, adminRoleContract } from '@cpn-console/shared' +import type { Project, ProjectRole } from '@prisma/client' +import type { ErrorResType } from '@/utils/errors.js' +import prisma from '@/prisma.js' +import { addLogs, getAdminRoleById, listAdminRoles } from '@/resources/queries-index.js' +import { BadRequest400 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function listRoles() { + return listAdminRoles() + .then(roles => roles.map(role => ({ ...role, permissions: role.permissions.toString(), type: role.type ?? 'managed' }))) +} + +export async function patchRoles( + roles: typeof adminRoleContract.patchAdminRoles.body._type, + requestId: string, +): Promise { + const dbRoles = await prisma.adminRole.findMany() + const positionsAvailable: number[] = [] + const updatedRoles: (Omit & { permissions: bigint })[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (matchingRole) { + if (typeof matchingRole.position !== 'undefined' && !positionsAvailable.includes(matchingRole.position)) { + positionsAvailable.push(matchingRole.position) + } + updatedRoles.push({ + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions: matchingRole.permissions ? BigInt(matchingRole.permissions) : dbRole.permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup: matchingRole.oidcGroup ?? dbRole.oidcGroup, + type: matchingRole.type ?? dbRole.type, + }) + } + } + + if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes') + for (const { id, ...role } of updatedRoles) { + await prisma.adminRole.update({ where: { id }, data: role }) + const hookReply = await hook.adminRole.upsert(id) + await addLogs({ action: 'Update Admin Role', data: hookReply, requestId }) + } + + return listRoles() +} + +export async function createRole( + role: typeof adminRoleContract.createAdminRole.body._type, + requestId: string, +) { + const dbMaxPosRole = (await prisma.adminRole.findFirst({ + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + const createdRole = await prisma.adminRole.create({ + data: { + ...role, + position: dbMaxPosRole + 1, + permissions: 0n, + }, + }) + + const hookReply = await hook.adminRole.upsert(createdRole.id) + await addLogs({ action: 'Create Admin Role', data: hookReply, requestId }) + + return listRoles() +} + +export async function countRolesMembers() { + const roles = await prisma.adminRole.findMany({ where: { oidcGroup: { equals: '' } }, select: { id: true } }) + const roleIds = roles.map(role => role.id) + const users = await prisma.user.findMany({ + where: { adminRoleIds: { hasSome: roleIds } }, + select: { adminRoleIds: true }, + }) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) // {role uuid: 0} + for (const { adminRoleIds } of users) { + for (const roleId of adminRoleIds) { + rolesCounts[roleId]++ + } + } + return rolesCounts +} + +export async function deleteRole( + roleId: Project['id'], + requestId: string, +) { + const role = await getAdminRoleById(roleId) + if (role) { + const hookReply = await hook.adminRole.delete(role) + await addLogs({ action: 'Delete Admin Role', data: hookReply, requestId }) + } + + const allUsers = await prisma.user.findMany({ + where: { + adminRoleIds: { has: roleId }, + }, + }) + for (const user of allUsers) { + await prisma.user.update({ + where: { id: user.id }, + data: { adminRoleIds: user.adminRoleIds.filter(adminRoleId => adminRoleId !== roleId) }, + }) + } + await prisma.adminRole.delete({ where: { id: roleId } }) + return null +} diff --git a/.jjconflict-side-2/apps/server/src/resources/admin-role/queries.ts b/.jjconflict-side-2/apps/server/src/resources/admin-role/queries.ts new file mode 100644 index 0000000000..9c29eb0b36 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/admin-role/queries.ts @@ -0,0 +1,40 @@ +import type { + AdminRole, + Prisma, +} from '@prisma/client' +import prisma from '@/prisma.js' + +export const listAdminRoles = () => prisma.adminRole.findMany({ orderBy: { position: 'asc' } }) + +export function createAdminRole(data: Pick) { + return prisma.adminRole.create({ + data: { + name: data.name, + permissions: 0n, + position: data.position, + type: 'managed', + }, + }) +} + +export function updateAdminRole(id: AdminRole['id'], data: Pick) { + return prisma.adminRole.updateMany({ + where: { id }, + data, + }) +} + +export function deleteAdminRole(id: AdminRole['id']) { + return prisma.adminRole.delete({ + where: { + id, + }, + }) +} + +export async function getAdminRoleById(id: string) { + const role = await prisma.adminRole.findUnique({ where: { id } }) + if (!role) return null + const members = await prisma.user.findMany({ where: { adminRoleIds: { has: id } } }) + return { ...role, members } +} diff --git a/.jjconflict-side-2/apps/server/src/resources/admin-role/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/admin-role/router.spec.ts new file mode 100644 index 0000000000..dab0f79268 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/admin-role/router.spec.ts @@ -0,0 +1,184 @@ +import { ADMIN_PERMS, adminRoleContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListRolesMock = vi.spyOn(business, 'listRoles') +const businessCreateRoleMock = vi.spyOn(business, 'createRole') +const businessPatchRolesMock = vi.spyOn(business, 'patchRoles') +const businessCountRolesMembersMock = vi.spyOn(business, 'countRolesMembers') +const businessDeleteRoleMock = vi.spyOn(business, 'deleteRole') + +describe('test adminRoleContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listAdminRoles', () => { + it('should return list of admin roles', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const roles = [{ id: faker.string.uuid(), name: 'Role 1', oidcGroup: '', position: 0, permissions: '1', type: 'managed' }] + businessListRolesMock.mockResolvedValueOnce(roles) + + const response = await app.inject() + .get(adminRoleContract.listAdminRoles.path) + .end() + + expect(businessListRolesMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(roles) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('createAdminRole', () => { + it('should create a role for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + const newRole = { id: 'newRole', name: 'New Role' } + const roleData = { name: 'New Role' } + + authUserMock.mockResolvedValueOnce(user) + businessCreateRoleMock.mockResolvedValueOnce(newRole) + + const response = await app.inject() + .post(adminRoleContract.createAdminRole.path) + .body(roleData) + .end() + + expect(businessCreateRoleMock).toHaveBeenCalledWith(roleData, expect.any(String)) + expect(response.json()).toEqual(newRole) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(adminRoleContract.createAdminRole.path) + .body({ name: 'New Role' }) + .end() + + expect(businessCreateRoleMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('patchAdminRoles', () => { + const updatedRoles = [{ id: faker.string.uuid(), name: 'Role 1', oidcGroup: '', position: 0, permissions: '1', type: 'managed' }] + const rolesData = [{ id: updatedRoles[0].id, name: 'Updated Role', type: 'managed' }] + it('should update roles for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessPatchRolesMock.mockResolvedValueOnce(updatedRoles) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledWith(rolesData, expect.any(String)) + expect(response.json()).toEqual(updatedRoles) + expect(response.statusCode).toEqual(200) + }) + + it('should return error if business logic fails', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessPatchRolesMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledWith(rolesData, expect.any(String)) + expect(response.statusCode).toEqual(400) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(adminRoleContract.patchAdminRoles.path) + .body(rolesData) + .end() + + expect(businessPatchRolesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('adminRoleMemberCounts', () => { + it('should return counts of role members for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + const counts = { role1: 5, role2: 3 } + + authUserMock.mockResolvedValueOnce(user) + businessCountRolesMembersMock.mockResolvedValueOnce(counts) + + const response = await app.inject() + .get(adminRoleContract.adminRoleMemberCounts.path) + .end() + + expect(businessCountRolesMembersMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(counts) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 if user is not admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(adminRoleContract.adminRoleMemberCounts.path) + .end() + + expect(businessCountRolesMembersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteAdminRole', () => { + const roleId = faker.string.uuid() + it('should delete a role for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + + authUserMock.mockResolvedValueOnce(user) + businessDeleteRoleMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .delete(adminRoleContract.deleteAdminRole.path.replace(':roleId', roleId)) + .end() + + expect(businessDeleteRoleMock).toHaveBeenCalledWith(roleId, expect.any(String)) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(adminRoleContract.deleteAdminRole.path.replace(':roleId', roleId)) + .end() + + expect(businessDeleteRoleMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/admin-role/router.ts b/.jjconflict-side-2/apps/server/src/resources/admin-role/router.ts new file mode 100644 index 0000000000..4141a2353e --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/admin-role/router.ts @@ -0,0 +1,78 @@ +import { AdminAuthorized, adminRoleContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + countRolesMembers, + createRole, + deleteRole, + listRoles, + patchRoles, +} from './business.js' + +export function adminRoleRouter() { + return serverInstance.router(adminRoleContract, { + // Récupérer des projets + listAdminRoles: async () => { + const body = await listRoles() + + return { + status: 200, + body, + } + }, + + createAdminRole: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await createRole(body, req.id) + + return { + status: 201, + body: resBody, + } + }, + + patchAdminRoles: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await patchRoles(body, req.id) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + adminRoleMemberCounts: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await countRolesMembers() + + return { + status: 200, + body: resBody, + } + }, + + deleteAdminRole: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageRoles(perms.adminPermissions)) return new Forbidden403() + + const resBody = await deleteRole(params.roleId, req.id) + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/admin-token/business.spec.ts b/.jjconflict-side-2/apps/server/src/resources/admin-token/business.spec.ts new file mode 100644 index 0000000000..2a698ac744 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/admin-token/business.spec.ts @@ -0,0 +1,73 @@ +import type { AdminToken } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { createToken, deleteToken, listTokens } from './business.ts' + +describe('test admin-token business', () => { + describe('listTokens', () => { + it('should stringify bigint', async () => { + const partialtoken: Partial = { + permissions: 4n, + } + + prisma.adminToken.findMany.mockResolvedValueOnce([partialtoken]) + const response = await listTokens({}) + expect(response).toEqual([{ permissions: '4' }]) + }) + it('should return revoked', async () => { + const partialtoken: Partial = { + permissions: 4n, + status: 'revoked', + } + + prisma.adminToken.findMany.mockResolvedValueOnce([partialtoken]) + const response = await listTokens({ withRevoked: true }) + expect(response).toEqual([{ ...partialtoken, permissions: '4' }]) + }) + }) + + describe('createToken', () => { + it('should create ', async () => { + const dbToken: Partial = undefined + const userId = faker.string.uuid() + const createdToken: AdminToken = { + expirationDate: null, + id: faker.string.uuid(), + name: 'test', + permissions: '2', + } + prisma.adminToken.findUnique.mockResolvedValueOnce(dbToken) + prisma.adminToken.create.mockResolvedValueOnce(createdToken) + await createToken({ name: 'test', permissions: '2', expirationDate: null }, userId, undefined) + + expect(prisma.adminToken.create).toHaveBeenCalledWith({ + data: { + name: 'test', + hash: expect.any(String), + permissions: 2n, + userId: expect.any(String), + expirationDate: undefined, + }, + omit: expect.any(Object), + include: { + owner: true, + }, + }) + }) + it('should not create cause expiration is too short', async () => { + const expirationDate = new Date() + await createToken({ name: 'test', permissions: '2', expirationDate: expirationDate.toISOString() }) + + expect(prisma.adminToken.create).toHaveBeenCalledTimes(0) + }) + }) + + describe('deleteToken', () => { + it('should delete token', async () => { + prisma.adminToken.delete.mockResolvedValueOnce(undefined) + await deleteToken(faker.string.uuid()) + expect(prisma.adminToken.updateMany).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/admin-token/business.ts b/.jjconflict-side-2/apps/server/src/resources/admin-token/business.ts new file mode 100644 index 0000000000..0702fde2b9 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/admin-token/business.ts @@ -0,0 +1,75 @@ +import type { adminTokenContract } from '@cpn-console/shared' +import type { $Enums, AdminToken, Prisma } from '@prisma/client' +import { createHash, randomUUID } from 'node:crypto' +import { logger as baseLogger } from '@cpn-console/logger' +import { generateRandomPassword, isAtLeastTomorrow } from '@cpn-console/shared' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../prisma.js' + +const logger = baseLogger.child({ scope: 'resource:admin-token' }) + +export async function listTokens(query: typeof adminTokenContract.listAdminTokens.query._type) { + const where = { + status: { + in: ['active'] as $Enums.TokenStatus[], + }, + } as const satisfies Prisma.AdminTokenWhereInput + + if (query?.withRevoked) where.status.in.push('revoked') + + return prisma.adminToken.findMany({ + omit: { hash: true }, + include: { owner: true }, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + where, + }).then(tokens => + tokens.map(({ permissions, ...token }) => ({ permissions: permissions.toString(), ...token })), + ) +} + +export async function createToken(data: typeof adminTokenContract.createAdminToken.body._type) { + if (data.expirationDate && !isAtLeastTomorrow(new Date(data.expirationDate))) { + return new BadRequest400('Date d\'expiration trop courte') + } + logger.info({ tokenName: data.name, expirationDate: data.expirationDate ?? null }, 'Create admin token started') + const password = generateRandomPassword(48, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-') + const hash = createHash('sha256').update(password).digest('hex') + const botUserId = randomUUID() + await prisma.user.create({ + data: { + firstName: 'Bot Admin', + lastName: data.name, + type: 'bot', + id: botUserId, + email: `${botUserId}@bot.io`, + }, + }) + const token = await prisma.adminToken.create({ + data: { + ...data, + hash, + permissions: BigInt(data.permissions), + expirationDate: data.expirationDate ? new Date(data.expirationDate) : undefined, + userId: botUserId, + }, + omit: { hash: true }, + include: { owner: true }, + }) + logger.info({ adminTokenId: token.id, botUserId, expirationDate: token.expirationDate ?? null, status: token.status }, 'Create admin token completed') + return { + ...token, + password, + permissions: token.permissions.toString(), + } +} + +export async function deleteToken(id: AdminToken['id']) { + logger.info({ adminTokenId: id }, 'Revoke admin token started') + return prisma.adminToken.updateMany({ + where: { id }, + data: { + status: 'revoked', + expirationDate: new Date(Date.now()), + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/admin-token/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/admin-token/router.spec.ts new file mode 100644 index 0000000000..61efec35fc --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/admin-token/router.spec.ts @@ -0,0 +1,161 @@ +import type { ExposedAdminToken } from '@cpn-console/shared' +import type { AdminToken } from '@prisma/client' +import { ADMIN_PERMS, adminTokenContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListTokensMock = vi.spyOn(business, 'listTokens') +const businessCreateTokenMock = vi.spyOn(business, 'createToken') +const businessDeleteTokenMock = vi.spyOn(business, 'deleteToken') + +describe('test adminTokenContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listAdminTokens', () => { + it('should return list of admin tokens', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_ADMIN_TOKEN) + authUserMock.mockResolvedValueOnce(user) + + const tokens: AdminToken[] = [{ + id: faker.string.uuid(), + name: 'token1', + permissions: '2', + lastUse: (new Date()).toISOString(), + expirationDate: null, + status: 'active', + createdAt: (new Date(Date.now())).toISOString(), + }] + businessListTokensMock.mockResolvedValueOnce(tokens) + + const response = await app.inject() + .get(adminTokenContract.listAdminTokens.path) + .end() + + expect(businessListTokensMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(tokens) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(adminTokenContract.listAdminTokens.path) + .end() + + expect(businessListTokensMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createAdminToken', () => { + it('should create a token for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + const newToken = { + id: faker.string.uuid(), + name: 'test', + lastUse: null, + expirationDate: null, + password: faker.string.alpha({ casing: 'lower', length: 10 }), + permissions: '2', + createdAt: (new Date(Date.now())).toISOString(), + status: 'active', + } + const tokenData: ExposedAdminToken = { + name: newToken.name, + permissions: newToken.permissions, + expirationDate: null, + } + + authUserMock.mockResolvedValueOnce(user) + businessCreateTokenMock.mockResolvedValueOnce(newToken) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body(tokenData) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledWith(tokenData) + expect(response.json()).toEqual(newToken) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body({ + name: 'new-token', + expirationDate: null, + permissions: '4', + }) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + authUserMock.mockResolvedValueOnce(user) + businessCreateTokenMock.mockResolvedValueOnce(new BadRequest400('Invalid date')) + + const response = await app.inject() + .post(adminTokenContract.createAdminToken.path) + .body({ + name: 'new-token', + expirationDate: null, + permissions: '4', + }) + .end() + + expect(businessCreateTokenMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(400) + }) + }) + + describe('deleteAdminToken', () => { + const tokenId = faker.string.uuid() + it('should delete a token for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ADMIN_TOKEN) + + authUserMock.mockResolvedValueOnce(user) + businessDeleteTokenMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .delete(adminTokenContract.deleteAdminToken.path.replace(':tokenId', tokenId)) + .end() + + expect(businessDeleteTokenMock).toHaveBeenCalledWith(tokenId) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(adminTokenContract.deleteAdminToken.path.replace(':tokenId', tokenId)) + .end() + + expect(businessDeleteTokenMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/admin-token/router.ts b/.jjconflict-side-2/apps/server/src/resources/admin-token/router.ts new file mode 100644 index 0000000000..305b6e527a --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/admin-token/router.ts @@ -0,0 +1,46 @@ +import { AdminAuthorized, adminTokenContract } from '@cpn-console/shared' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { serverInstance } from '../../app.js' +import { createToken, deleteToken, listTokens } from './business.js' + +export function adminTokenRouter() { + return serverInstance.router(adminTokenContract, { + listAdminTokens: async ({ request: req, query }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListAdminToken(perms.adminPermissions)) return new Forbidden403() + const body = await listTokens(query) + + return { + status: 200, + body, + } + }, + + createAdminToken: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageAdminToken(perms.adminPermissions)) return new Forbidden403() + const body = await createToken(data) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + deleteAdminToken: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageAdminToken(perms.adminPermissions)) return new Forbidden403() + await deleteToken(params.tokenId) + + return { + status: 204, + body: null, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/cluster/business.spec.ts b/.jjconflict-side-2/apps/server/src/resources/cluster/business.spec.ts new file mode 100644 index 0000000000..c003e66ae1 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/cluster/business.spec.ts @@ -0,0 +1,171 @@ +import type { Cluster, Environment } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400, ErrorResType, NotFound404, Unprocessable422 } from '../../utils/errors.ts' +import { createCluster, deleteCluster, getClusterAssociatedEnvironments, getClusterDetails, getClusterUsage, listClusters, updateCluster } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const userId = faker.string.uuid() +const requestId = faker.string.uuid() +const cluster: Cluster = { + id: faker.string.uuid(), + infos: faker.lorem.lines(2), + privacy: 'public', + createdAt: new Date(), + updatedAt: new Date(), + zoneId: faker.string.uuid(), + clusterResources: false, + kubeConfigId: faker.string.uuid(), + label: faker.string.alpha(10), + secretName: faker.string.alpha(10), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), +} +describe('test Cluster business logic', () => { + describe('listClusters', () => { + it('should filter for user', async () => { + prisma.cluster.findMany.mockResolvedValue([]) + await listClusters(userId) + expect(prisma.cluster.findMany).toHaveBeenCalledTimes(1) + expect(prisma.cluster.findMany).toHaveBeenCalledWith({ select: expect.any(Object), where: { OR: [{ privacy: 'public' }, expect.any(Object), expect.any(Object), expect.any(Object)] } }) + }) + it('should not filter', async () => { + const dbStages = [{ id: faker.string.uuid() }] + prisma.cluster.findMany.mockResolvedValue([{ stages: dbStages }] as unknown as Cluster[]) + const response = await listClusters() + expect(prisma.cluster.findMany).toHaveBeenCalledTimes(1) + expect(prisma.cluster.findMany).toHaveBeenCalledWith({ select: expect.any(Object), where: {} }) + expect(response[0].stageIds).toStrictEqual([dbStages[0].id]) + }) + }) + + describe('getClusterAssociatedEnvironments', () => { + it('should list all environments attached to a cluster', async () => { + const envName = faker.string.alpha(8) + const projectName = faker.string.alpha(8) + const ownerEmail = faker.internet.email() + const cpu = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const gpu = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const memory = faker.number.float({ min: 0, max: 10, fractionDigits: 1 }) + const envs = [{ name: envName, cpu, gpu, memory, project: { name: projectName, owner: { email: ownerEmail } } }] as unknown as Environment[] + prisma.environment.findMany.mockResolvedValue(envs) + const response = await getClusterAssociatedEnvironments(cluster.id) + expect(response).toStrictEqual([{ + name: envName, + project: projectName, + owner: ownerEmail, + cpu, + gpu, + memory, + }]) + }) + }) + + describe('getClusterDetails', () => { + it('should return a cluster details', async () => { + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + await getClusterDetails(cluster.id) + }) + it('should return a cluster details, without infos in db', async () => { + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, infos: null, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + const response = await getClusterDetails(cluster.id) + expect(response.infos).toBe('') + }) + }) + + describe('getClusterUsage', () => { + it('should return a cluster usage', async () => { + prisma.environment.aggregate.mockResolvedValue({ _count: {}, _avg: {}, _min: {}, _max: {}, _sum: { + cpu: 10, + gpu: 5, + memory: 20, + } }) + const response = await getClusterUsage(cluster.id) + expect(response).toStrictEqual({ + cpu: 10, + gpu: 5, + memory: 20, + }) + }) + }) + + describe('createCluster', () => { + it('should create cluster', async () => { + hook.cluster.upsert.mockResolvedValue({ failed: false }) + prisma.cluster.findUnique.mockResolvedValue(null) + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + prisma.cluster.create.mockResolvedValue(cluster) + + const response = await createCluster({ + infos: faker.string.alpha(10), + zoneId: faker.string.uuid(), + privacy: 'public', + stageIds: [], + clusterResources: false, + kubeconfig: { cluster: { tlsServerName: faker.internet.domainName() }, user: {} }, + label: faker.string.alpha(10), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + }, userId, requestId) + + expect(response).not.instanceOf(ErrorResType) + expect(prisma.cluster.create).toHaveBeenCalled() + }) + }) + + describe('updateCluster', () => { + it('should update cluster', async () => { + hook.cluster.upsert.mockResolvedValue({ failed: false }) + prisma.cluster.findUnique.mockResolvedValue(cluster) + prisma.cluster.findUniqueOrThrow.mockResolvedValue({ ...cluster, projects: [], stages: [], kubeconfig: { user: {}, cluster: {} } } as Cluster) + prisma.cluster.update.mockResolvedValue(cluster) + + const response = await updateCluster({ + infos: faker.string.alpha(10), + zoneId: faker.string.uuid(), + privacy: 'public', + stageIds: [], + }, cluster.id, userId, requestId) + + expect(response).not.instanceOf(ErrorResType) + expect(prisma.cluster.update).toHaveBeenCalled() + }) + it('should return 404', async () => { + prisma.cluster.findUnique.mockResolvedValue(null) + const response = await updateCluster({ infos: faker.string.alpha(10) }, cluster.id, userId, requestId) + expect(response).instanceOf(NotFound404) + }) + }) + + describe('deleteCluster', () => { + it('should delete cluster', async () => { + hook.cluster.delete.mockResolvedValue({}) + await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(prisma.cluster.delete).toHaveBeenCalledTimes(1) + }) + it('should return failed hook', async () => { + hook.cluster.delete.mockResolvedValue({ failed: true }) + const response = await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(response).instanceOf(Unprocessable422) + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + }) + it('should not delete cluster, env attached', async () => { + prisma.environment.findFirst.mockResolvedValue({ id: faker.string.uuid() } as Environment) + const response = await deleteCluster({ clusterId: cluster.id, userId, requestId }) + + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) +}) + +// findUniqueOrThrow diff --git a/.jjconflict-side-2/apps/server/src/resources/cluster/business.ts b/.jjconflict-side-2/apps/server/src/resources/cluster/business.ts new file mode 100644 index 0000000000..ef542ce33a --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/cluster/business.ts @@ -0,0 +1,230 @@ +import type { Cluster, clusterContract, ClusterDetails, Kubeconfig } from '@cpn-console/shared' +import type { Prisma, Project, User } from '@prisma/client' +import type { Resources } from '@/types/index.js' +import { ClusterDetailsSchema, ClusterPrivacy } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + addLogs, + createCluster as createClusterQuery, + deleteCluster as deleteClusterQuery, + getClusterById, + getClusterByLabel, + getClusterDetails as getClusterDetailsQuery, + getClusterEnvironments, + getProjectsByClusterId, + linkClusterToProjects, + linkZoneToClusters, + listClusters as listClustersQuery, + listStagesByClusterId, + removeClusterFromProject, + removeClusterFromStage, + updateCluster as updateClusterQuery, +} from '@/resources/queries-index.js' +import { linkClusterToStages } from '@/resources/stage/business.js' +import { validateSchema } from '@/utils/business.js' +import { BadRequest400, ErrorResType, NotFound404, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function listClusters(userId?: User['id']) { + const where: Prisma.ClusterWhereInput = userId + ? { + OR: [ + // Sélectionne tous les clusters publics + { privacy: 'public' }, + // Sélectionne les clusters associés aux projets dont l'user est membre + { + projects: { some: { members: { some: { userId } } } }, + }, + // Sélectionne les clusters associés aux projets dont l'user est owner + { + projects: { some: { ownerId: userId } }, + }, + // Sélectionne les clusters associés aux environnments appartenant à des projets dont l'user est membre + { + environments: { some: { project: { members: { some: { userId } } } } }, + }, + ], + } + : {} + const clusters = await listClustersQuery(where) + return clusters.map(({ stages, ...cluster }) => ({ + ...cluster, + stageIds: stages.map(({ id }) => id), + })) +} + +export async function getClusterAssociatedEnvironments(clusterId: string) { + const clusterEnvironments = await getClusterEnvironments(clusterId) + + return clusterEnvironments.map((environment) => { + return ({ + project: environment.project?.name, + name: environment.name, + owner: environment.project.owner.email, + cpu: environment.cpu, + gpu: environment.gpu, + memory: environment.memory, + }) + }) +} + +export async function getClusterDetails(clusterId: string): Promise { + const { infos, projects, stages, kubeconfig, ...details } = await getClusterDetailsQuery(clusterId) + return { + ...details, + infos: infos ?? '', + projectIds: projects.map(project => project.id), + stageIds: stages.map(({ id }) => id), + kubeconfig: { + cluster: kubeconfig.cluster as unknown as Kubeconfig['cluster'], + user: kubeconfig.user as unknown as Kubeconfig['user'], + }, + } +} + +export async function getClusterUsage(clusterId: string): Promise { + const clusterUsage = await prisma.environment.aggregate({ + _sum: { + memory: true, + cpu: true, + gpu: true, + }, + where: { + clusterId, + }, + }) + return { + cpu: clusterUsage._sum.cpu ?? 0, + gpu: clusterUsage._sum.gpu ?? 0, + memory: clusterUsage._sum.memory ?? 0, + } +} + +export async function createCluster(data: typeof clusterContract.createCluster.body._type, userId: User['id'], requestId: string) { + const isLabelTaken = await getClusterByLabel(data.label) + if (isLabelTaken) return new BadRequest400('Ce label existe déjà pour un autre cluster') + + data.projectIds = data.privacy === ClusterPrivacy.PUBLIC + ? [] + : data.projectIds ?? [] + + const { + projectIds, + stageIds, + kubeconfig, + zoneId, + ...clusterData + } = data + + const clusterCreated = await createClusterQuery(clusterData, kubeconfig, zoneId) + + if (data.privacy === ClusterPrivacy.DEDICATED && projectIds.length) { + await linkClusterToProjects(clusterCreated.id, projectIds) + } + + if (stageIds?.length) { + await linkClusterToStages(clusterCreated.id, stageIds) + } + + const hookReply = await hook.cluster.upsert(clusterCreated.id, zoneId) + await addLogs({ action: 'Create Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la création du cluster') + } + + return getClusterDetails(clusterCreated.id) +} + +export async function updateCluster(data: typeof clusterContract.updateCluster.body._type, clusterId: Cluster['id'], userId: User['id'], requestId: string): Promise { + if (data?.privacy === ClusterPrivacy.PUBLIC) delete data.projectIds + + const schemaValidation = ClusterDetailsSchema.partial().safeParse({ ...data, id: clusterId }) + const validateResult = validateSchema(schemaValidation) + if (validateResult instanceof ErrorResType) return validateResult + + const dbCluster = await getClusterById(clusterId) + if (!dbCluster) return new NotFound404() + + const { + projectIds, + stageIds, + kubeconfig, + zoneId, + ...clusterData + } = data + + const clusterUpdated = await updateClusterQuery(clusterId, clusterData, + // @ts-ignore + kubeconfig) + + // zone + if (zoneId) { + await linkZoneToClusters(zoneId, [clusterId]) + } + + // projects + const dbProjects = await getProjectsByClusterId(clusterId) + + let projectsToRemove: Project['id'][] = [] + + if (projectIds && clusterUpdated.privacy === ClusterPrivacy.DEDICATED) { + await linkClusterToProjects(clusterId, projectIds) + projectsToRemove = dbProjects?.map(project => project.id)?.filter(dbProjectId => !projectIds.includes(dbProjectId)) ?? [] + } else if (clusterUpdated.privacy === ClusterPrivacy.PUBLIC) { + projectsToRemove = dbProjects?.map(project => project.id) ?? [] + } + + for (const projectId of projectsToRemove) { + await removeClusterFromProject(clusterUpdated.id, projectId) + } + + // stages + if (stageIds) { + await linkClusterToStages(clusterId, stageIds) + + const dbStages = await listStagesByClusterId(clusterId) + if (dbStages) { + for (const stage of dbStages) { + if (!stageIds.includes(stage.id)) { + await removeClusterFromStage(clusterUpdated.id, stage.id) + } + } + } + } + + const hookReply = await hook.cluster.upsert(clusterId, dbCluster.zoneId) + await addLogs({ action: 'Update Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la mise à jour du cluster') + } + + return getClusterDetails(clusterId) +} + +interface DeleteClusterArgs { + clusterId: Cluster['id'] + userId?: User['id'] + requestId: string + force?: boolean +} +export async function deleteCluster({ clusterId, requestId, force, userId }: DeleteClusterArgs) { + let message: string | null = null + if (force) { + const envs = await prisma.environment.deleteMany({ + where: { clusterId }, + }) + message = `${envs.count} environnements supprimés de force, n'oubliez pas de reprovisionner les projets concernés` + } else { + const environment = await prisma.environment.findFirst({ where: { clusterId } }) + if (environment) return new BadRequest400('Impossible de supprimer le cluster, des environnements en activité y sont déployés') + } + + const hookReply = await hook.cluster.delete(clusterId) + await addLogs({ action: 'Delete Cluster', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la suppression du cluster') + } + + await deleteClusterQuery(clusterId) + return message +} diff --git a/.jjconflict-side-2/apps/server/src/resources/cluster/queries.ts b/.jjconflict-side-2/apps/server/src/resources/cluster/queries.ts new file mode 100644 index 0000000000..f66e1fc0bc --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/cluster/queries.ts @@ -0,0 +1,309 @@ +import type { Cluster, Environment, Kubeconfig, Prisma, Project, Stage } from '@prisma/client' +import prisma from '@/prisma.js' + +export async function getClustersAssociatedWithProject(projectId: Project['id']) { + const [ + clusterIdsHistory, + clusterIdsEnv, + ] = await Promise.all([ + prisma.projectClusterHistory.findMany({ + select: { + clusterId: true, + }, + where: { + projectId, + }, + }).then(history => history.map(({ clusterId }) => clusterId)), + prisma.cluster.findMany({ + where: { environments: { some: { project: { id: projectId } } } }, + select: { id: true }, + }).then(cluster => cluster.map(({ id }) => id)), + ]) + const clusterIds = [ + ...clusterIdsHistory, + ...clusterIdsEnv.filter(id => !clusterIdsHistory.includes(id)), + ] + return prisma.cluster.findMany({ + where: { id: { in: clusterIds } }, + select: { + id: true, + infos: true, + label: true, + privacy: true, + secretName: true, + kubeconfig: true, + clusterResources: true, + cpu: true, + gpu: true, + memory: true, + zone: { + select: { + id: true, + slug: true, + argocdUrl: true, + label: true, + }, + }, + }, + }) +} + +export async function updateProjectClusterHistory(projectId: Project['id'], clusterIds: Cluster['id'][]) { + return prisma.$transaction([ + prisma.projectClusterHistory.deleteMany({ + where: { + AND: { + projectId, + clusterId: { notIn: clusterIds }, + }, + }, + }), + prisma.projectClusterHistory.createMany({ + data: clusterIds.map(clusterId => ({ clusterId, projectId })), + skipDuplicates: true, + }), + ]) +} + +export function getClusterById(id: Cluster['id']) { + return prisma.cluster.findUnique({ + where: { id }, + include: { kubeconfig: true }, + }) +} + +export function getClusterByIdOrThrow(id: Cluster['id']) { + return prisma.cluster.findUniqueOrThrow({ + where: { id }, + include: { kubeconfig: true, zone: true }, + }) +} + +export function getClusterEnvironments(clusterId: Cluster['id']) { + return prisma.environment.findMany({ + where: { clusterId }, + select: { + name: true, + cpu: true, + gpu: true, + memory: true, + project: { + select: { + slug: true, + name: true, + owner: true, + members: true, + }, + }, + }, + }) +} + +export function getClusterDetails(id: Cluster['id']) { + return prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { + createdAt: true, + projects: { + select: { + id: true, + }, + }, + id: true, + clusterResources: true, + infos: true, + label: true, + privacy: true, + kubeconfig: true, + stages: true, + updatedAt: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export function getClustersByIds(clusterIds: Cluster['id'][]) { + return prisma.cluster.findMany({ + where: { + id: { in: clusterIds }, + }, + include: { kubeconfig: true }, + }) +} + +export function getPublicClusters() { + return prisma.cluster.findMany({ + where: { privacy: 'public' }, + include: { zone: true }, + }) +} + +export async function getClusterNamesByZoneId(zoneId: string) { + const clusterNames = await prisma.cluster.findMany({ + where: { zoneId }, + select: { + label: true, + }, + }) + return clusterNames.map(({ label }) => label) +} + +export function getClusterByLabel(label: Cluster['label']) { + return prisma.cluster.findUnique({ where: { label } }) +} + +export function getClusterByEnvironmentId(id: Environment['id']) { + return prisma.cluster.findMany({ + where: { + environments: { + some: { id }, + }, + }, + include: { kubeconfig: true }, + }) +} + +export function getClustersWithProjectIdAndConfig() { + return prisma.cluster.findMany({ + select: { + id: true, + stages: true, + projects: { + where: { + status: { not: 'archived' }, + }, + select: { + id: true, + name: true, + slug: true, + status: true, + }, + }, + clusterResources: true, + label: true, + infos: true, + privacy: true, + secretName: true, + kubeconfig: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export function listClusters(where: Prisma.ClusterWhereInput) { + return prisma.cluster.findMany({ + where, + select: { + id: true, + label: true, + stages: true, + clusterResources: true, + privacy: true, + infos: true, + zoneId: true, + cpu: true, + gpu: true, + memory: true, + }, + }) +} + +export async function getProjectsByClusterId(id: Cluster['id']) { + return (await prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { projects: true }, + }))?.projects +} + +export async function listStagesByClusterId(id: Cluster['id']) { + return (await prisma.cluster.findUniqueOrThrow({ + where: { id }, + select: { stages: true }, + }))?.stages +} + +export function createCluster(data: Omit, kubeconfig: Pick, zoneId: string) { + return prisma.cluster.create({ + data: { + ...data, + // @ts-ignore + kubeconfig: { create: kubeconfig }, + zone: { + connect: { id: zoneId }, + }, + }, + }) +} + +export function updateCluster(id: Cluster['id'], data: Partial>, kubeconfig: Pick) { + return prisma.cluster.update({ + where: { id }, + data: { + ...data, + kubeconfig: { + // @ts-ignore + update: kubeconfig, + }, + }, + }) +} + +export function linkClusterToProjects(id: Cluster['id'], projectIds: Project['id'][]) { + return prisma.cluster.update({ + where: { id }, + data: { + projects: { + connect: projectIds.map(projectId => ({ id: projectId })), + }, + }, + }) +} + +export function linkClusterToStages(id: Cluster['id'], stageIds: Stage['id'][]) { + return prisma.cluster.update({ + where: { id }, + data: { + stages: { + connect: stageIds.map(stageId => ({ id: stageId })), + }, + }, + }) +} + +export function removeClusterFromProject(id: Cluster['id'], projectId: Project['id']) { + return prisma.cluster.update({ + where: { id }, + data: { + projects: { + disconnect: { + id: projectId, + }, + }, + }, + }) +} + +export function removeClusterFromStage(id: Cluster['id'], stageId: Stage['id']) { + return prisma.cluster.update({ + where: { id }, + data: { + stages: { + disconnect: { + id: stageId, + }, + }, + }, + }) +} + +export function deleteCluster(id: Cluster['id']) { + return prisma.cluster.delete({ + where: { id }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/cluster/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/cluster/router.spec.ts new file mode 100644 index 0000000000..528858b052 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/cluster/router.spec.ts @@ -0,0 +1,308 @@ +import type { ClusterDetails, Environment } from '@cpn-console/shared' +import { ADMIN_PERMS, clusterContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listClusters') +const businessGetDetailsMock = vi.spyOn(business, 'getClusterDetails') +const businessGetUsageMock = vi.spyOn(business, 'getClusterUsage') +const businessGetEnvironmentsMock = vi.spyOn(business, 'getClusterAssociatedEnvironments') +const businessCreateMock = vi.spyOn(business, 'createCluster') +const businessUpdateMock = vi.spyOn(business, 'updateCluster') +const businessDeleteMock = vi.spyOn(business, 'deleteCluster') + +describe('test clusterContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listClusters', () => { + it('as non admin', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + businessListMock.mockResolvedValueOnce([]) + const response = await app.inject() + .get(clusterContract.listClusters.path) + .end() + + expect(businessListMock).toHaveBeenCalledWith(user.user.id) + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('as admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + + authUserMock.mockResolvedValueOnce(user) + + businessListMock.mockResolvedValueOnce([]) + const response = await app.inject() + .get(clusterContract.listClusters.path) + .end() + + expect(businessListMock).toHaveBeenCalledWith() + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getClusterDetails', () => { + it('should return cluster details', async () => { + const cluster: ClusterDetails = { + id: faker.string.uuid(), + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetDetailsMock.mockResolvedValueOnce(cluster) + const response = await app.inject() + .get(clusterContract.getClusterDetails.path.replace(':clusterId', cluster.id)) + .end() + + expect(businessGetDetailsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(cluster) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterDetails.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetDetailsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getClusterUsage', () => { + it('should return cluster usage', async () => { + const resources = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetUsageMock.mockResolvedValueOnce(resources) + const response = await app.inject() + .get(clusterContract.getClusterUsage.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetUsageMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(resources) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterUsage.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetUsageMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getClusterEnvironments', () => { + it('should return cluster environments', async () => { + const envs: Environment[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(envs) + const response = await app.inject() + .get(clusterContract.getClusterEnvironments.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(clusterContract.getClusterEnvironments.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createCluster', () => { + const cluster: ClusterDetails = { + id: faker.string.uuid(), + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + + it('should return created cluster', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(cluster) + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.json()).toEqual(cluster) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(clusterContract.createCluster.path) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateCluster', () => { + const clusterId = faker.string.uuid() + const cluster: Omit = { + clusterResources: true, + infos: '', + label: faker.string.alpha(), + privacy: 'public', + stageIds: [], + zoneId: faker.string.uuid(), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + kubeconfig: { + cluster: { tlsServerName: faker.string.alpha() }, + user: {}, + }, + } + + it('should return created cluster', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: clusterId, ...cluster }) + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.json()).toEqual({ id: clusterId, ...cluster }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(clusterContract.updateCluster.path.replace(':clusterId', clusterId)) + .body(cluster) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteCluster', () => { + it('should return empty when delete', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_CLUSTERS) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(clusterContract.deleteCluster.path.replace(':clusterId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/cluster/router.ts b/.jjconflict-side-2/apps/server/src/resources/cluster/router.ts new file mode 100644 index 0000000000..3924bce6a9 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/cluster/router.ts @@ -0,0 +1,131 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, clusterContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + createCluster, + deleteCluster, + getClusterAssociatedEnvironments, + getClusterDetails as getClusterDetailsBusiness, + getClusterUsage, + listClusters, + updateCluster, +} from './business.js' +import '@/types/index.js' + +export function clusterRouter() { + return serverInstance.router(clusterContract, { + listClusters: async ({ request: req }) => { + const { adminPermissions, user } = await authUser(req) + + let body: AsyncReturnType = [] + if (AdminAuthorized.ListClusters(adminPermissions)) { + body = await listClusters() + } else if (user) { + body = await listClusters(user.id) + } + + return { + status: 200, + body, + } + }, + + getClusterDetails: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const cluster = await getClusterDetailsBusiness(clusterId) + + return { + status: 200, + body: cluster, + } + }, + + getClusterUsage: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const usage = await getClusterUsage(clusterId) + + return { + status: 200, + body: usage, + } + }, + + createCluster: async ({ request: req, body: data }) => { + const { adminPermissions, user } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + + if (!user) return new Unauthorized401('Require to be requested from user not api key') + const body = await createCluster(data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + getClusterEnvironments: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListClusters(perms.adminPermissions)) return new Forbidden403() + + const clusterId = params.clusterId + const environments = await getClusterAssociatedEnvironments(clusterId) + + return { + status: 200, + body: environments, + } + }, + + updateCluster: async ({ request: req, params, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const clusterId = params.clusterId + const body = await updateCluster(data, clusterId, user.id, req.id) + + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + deleteCluster: async ({ request: req, params, query: { force } }) => { + const { user, adminPermissions, tokenId } = await authUser(req) + + if (!AdminAuthorized.ManageClusters(adminPermissions)) return new Forbidden403() + if (!user?.id && !tokenId) return new Unauthorized401('Your identity has not been found') + + const clusterId = params.clusterId + const body = await deleteCluster({ + clusterId, + userId: user?.id, + requestId: req.id, + force, + }) + + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/environment/business.spec.ts b/.jjconflict-side-2/apps/server/src/resources/environment/business.spec.ts new file mode 100644 index 0000000000..7bfebb5b2e --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/environment/business.spec.ts @@ -0,0 +1,353 @@ +import type { Cluster, Environment, Project, ProjectMembers, ProjectRole, Stage, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { Result } from '../../utils/business.js' +import { checkClusterResources, checkProjectResources, createEnvironment, deleteEnvironment, getProjectEnvironments, updateEnvironment } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const user: User = { + id: faker.string.uuid(), + createdAt: new Date(), + updatedAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + lastLogin: null, +} +const project: Project & { + clusters: Pick[] + members: ProjectMembers[] + roles: ProjectRole[] + owner: User +} = { + createdAt: new Date(), + updatedAt: new Date(), + description: '', + everyonePerms: 649n, + id: faker.string.uuid(), + locked: false, + name: faker.string.alphanumeric(8), + status: 'created', + ownerId: faker.string.uuid(), + owner: user, + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + clusters: [], + roles: [], + members: [], + slug: faker.string.alphanumeric(8), + lastSuccessProvisionningVersion: faker.string.numeric(), +} + +describe('test environment business', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getProjectEnvironments', () => { + it('should query environment for projectId', async () => { + prisma.environment.findMany.mockResolvedValue([]) + const projectId = faker.string.uuid() + await getProjectEnvironments(projectId) + + expect(prisma.environment.findMany).toHaveBeenCalledTimes(1) + }) + }) + + describe('createEnvironment', () => { + const clusterId = faker.string.uuid() + const stageId = faker.string.uuid() + const env = { name: 'new-env' } + it('should create environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const stageId = faker.string.uuid() + + prisma.environment.create.mockResolvedValue({ clusterId } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await createEnvironment({ + userId: user.id, + projectId: project.id, + name: env.name, + cpu: 0.1, + gpu: 0.5, + memory: 2.0, + clusterId, + stageId, + requestId, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should create environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + + prisma.environment.create.mockResolvedValue({ clusterId } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await createEnvironment({ + userId: user.id, + projectId: project.id, + name: env.name, + cpu: 0.1, + gpu: 0.5, + memory: 2.0, + clusterId, + stageId, + requestId, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('updateEnvironment', () => { + it('should update environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.update.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await updateEnvironment({ + user, + environmentId, + requestId, + cpu: 2.0, + gpu: 4.0, + memory: 12.5, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should update environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.update.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await updateEnvironment({ + user, + environmentId, + requestId, + cpu: 2.0, + gpu: 4.0, + memory: 12.5, + }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('deleteEnvironment', () => { + it('should delete environment and trigger hook', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.delete.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const result = await deleteEnvironment({ environmentId, userId: user.id, projectId: project.id, requestId }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.delete).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + + it('should delete environment and trigger hook but hooks failed', async () => { + const requestId = faker.string.uuid() + const environmentId = faker.string.uuid() + + prisma.environment.delete.mockResolvedValue({ projectId: project.id } as Environment) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const result = await deleteEnvironment({ environmentId, userId: user.id, projectId: project.id, requestId }) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(prisma.environment.delete).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + }) + }) + + describe('checkClusterResources', () => { + it('should authorize cluster not yet configured', async () => { + const cluster: Cluster = { + cpu: 0, + gpu: 0, + memory: 0, + } as Cluster + const result = await checkClusterResources({ cpu: 1, gpu: 0, memory: 1 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should authorize cluster not yet used', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 0, + gpu: 0, + memory: 0, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 7 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should authorize cluster used but not full', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 2, + gpu: 0, + memory: 2, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 6 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should refuse cluster without enough space', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 5, + gpu: 0, + memory: 5, + }, + } as any) + const result = await checkClusterResources({ cpu: 8, gpu: 0, memory: 6 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le cluster ne dispose pas de suffisamment de ressources : CPU, Mémoire.') + }) + it('should refuse cluster without GPU', async () => { + const cluster: Cluster = { + cpu: 10, + gpu: 0, + memory: 8, + } as Cluster + prisma.environment.aggregate.mockResolvedValue({ + _sum: { + cpu: 2, + gpu: 0, + memory: 2, + }, + } as any) + const result = await checkClusterResources({ cpu: 2, gpu: 1, memory: 2 }, cluster) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le cluster ne dispose pas de suffisamment de ressources : GPU.') + }) + }) + + describe('checkProjectResources', () => { + const prodStage: Stage = { + id: faker.string.uuid(), + name: 'prod', + } + const hprodStage: Stage = { + id: faker.string.uuid(), + name: 'hprod', + } + it('should authorize prod deployment for project with hprod resource but no prod resources', async () => { + const project: Project = { + hprodCpu: 10, + hprodGpu: 10, + hprodMemory: 10, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + } as Project + prisma.stage.findUnique.mockResolvedValue(prodStage) + prisma.stage.findMany.mockResolvedValue([prodStage]) + const result = await checkProjectResources({ cpu: 1, gpu: 0, memory: 1, stageId: prodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeTruthy() + }) + it('should refuse hprod deployment for project with hprod resource but no prod resources', async () => { + const project: Project = { + hprodCpu: 10, + hprodGpu: 10, + hprodMemory: 10, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + } as Project + prisma.stage.findUnique.mockResolvedValue(hprodStage) + prisma.stage.findMany.mockResolvedValue([prodStage] as Stage[]) + prisma.environment.aggregate.mockResolvedValue({ + _sum: { cpu: 0, gpu: 0, memory: 0 }, + } as any) + const result = await checkProjectResources({ cpu: 20, gpu: 20, memory: 20, stageId: hprodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le projet ne dispose pas de suffisamment de ressources : CPU, GPU, Mémoire.') + }) + it('should refuse overloading hprod deployment', async () => { + const project: Project = { + hprodCpu: 20, + hprodGpu: 20, + hprodMemory: 20, + prodCpu: 10, + prodGpu: 10, + prodMemory: 10, + } as Project + prisma.stage.findUnique.mockResolvedValue(hprodStage) + prisma.stage.findMany.mockResolvedValue([prodStage] as Stage[]) + prisma.environment.aggregate.mockResolvedValue({ + _sum: { cpu: 15, gpu: 15, memory: 15 }, + } as any) + const result = await checkProjectResources({ cpu: 5, gpu: 6, memory: 5, stageId: hprodStage.id }, project) + expect(result).toBeInstanceOf(Result) + expect(result.success).toBeFalsy() + expect(result.error).toEqual('Le projet ne dispose pas de suffisamment de ressources : GPU.') + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/environment/business.ts b/.jjconflict-side-2/apps/server/src/resources/environment/business.ts new file mode 100644 index 0000000000..2f068dedf8 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/environment/business.ts @@ -0,0 +1,306 @@ +import type { Cluster, Environment, Project, Stage, User } from '@prisma/client' +import type { Resources, UserDetails } from '@/types/index.js' +import prisma from '@/prisma.js' +import { + addLogs, + deleteEnvironment as deleteEnvironmentQuery, + getEnvironmentsByProjectId, + initializeEnvironment, + updateEnvironment as updateEnvironmentQuery, +} from '@/resources/queries-index.js' +import { Result } from '@/utils/business.js' +import { hook } from '@/utils/hook-wrapper.js' + +export function getProjectEnvironments(projectId: Project['id']) { + return getEnvironmentsByProjectId(projectId) +} + +// Routes logic +interface CreateEnvironmentParam { + userId: User['id'] + projectId: Project['id'] + name: Environment['name'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + clusterId: Environment['clusterId'] + stageId: Stage['id'] + requestId: string +} + +interface CreateEnvironmentResult { + id: Environment['id'] + createdAt: Date + updatedAt: Date + projectId: Project['id'] + name: Environment['name'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + clusterId: Environment['clusterId'] + stageId: Stage['id'] +} + +export async function createEnvironment({ + userId, + projectId, + name, + cpu, + gpu, + memory, + autosync, + clusterId, + stageId, + requestId, +}: CreateEnvironmentParam): Promise> { + const environment = await initializeEnvironment({ projectId, name, cpu, gpu, memory, autosync, clusterId, stageId }) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Create Environment', data: results, userId, requestId, projectId }) + if (results.failed) { + return Result.fail('Echec des services à la création de l\'environnement') + } + + return Result.succeed({ + ...environment, + stageId, + }) +} + +interface UpdateEnvironmentParam { + user: UserDetails + environmentId: Environment['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + autosync: Environment['autosync'] + requestId: string +} + +export async function updateEnvironment({ + user, + environmentId, + requestId, + cpu, + gpu, + memory, + autosync, +}: UpdateEnvironmentParam) { + const env = await updateEnvironmentQuery({ + id: environmentId, + cpu, + gpu, + memory, + autosync, + }) + const { results } = await hook.project.upsert(env.projectId) + await addLogs({ action: 'Update Environment', data: results, userId: user.id, requestId, projectId: env.projectId }) + if (results.failed) { + return Result.fail('Echec des services à la mise à jour de l\'environnement') + } + + return Result.succeed(env) +} + +interface DeleteEnvironmentParam { + userId?: User['id'] + environmentId: Environment['id'] + projectId: Project['id'] + requestId: string +} + +export async function deleteEnvironment({ + userId, + environmentId, + projectId, + requestId, +}: DeleteEnvironmentParam) { + const env = await deleteEnvironmentQuery(environmentId) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Delete Environment', data: results, userId, requestId, projectId: env.projectId }) + if (results.failed) { + return Result.fail('Echec des services à la suppression de l\'environnement') + } + return Result.succeed(null) +} + +export async function checkEnvironmentCreate(input: { + clusterId: Cluster['id'] + projectId: Project['id'] + name: Environment['name'] + stageId: Stage['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}): Promise> { + const errorMessages: string[] = [] + const [stage, sameNameEnvironment, cluster] = await Promise.all([ + input.stageId + ? prisma.stage.findUnique({ where: { id: input.stageId } }) + : undefined, + input.name + ? prisma.environment.findUnique({ where: { projectId_name: { projectId: input.projectId, name: input.name } } }) + : undefined, + input.clusterId + ? prisma.cluster.findFirst({ + where: { + OR: [{ // un cluster public + id: input.clusterId, + privacy: 'public', + }, { + id: input.clusterId, // un cluster dédié rattaché au projet + privacy: 'dedicated', + projects: { some: { id: input.projectId } }, + }], + }, + }) + : undefined, + ]) + if (sameNameEnvironment) errorMessages.push('Ce nom d\'environnement est déjà pris.') + if (!stage) errorMessages.push('Type d\'environnment invalide.') + if (!cluster) { + errorMessages.push('Cluster invalide.') + } else { + const resourceCheckResult = await checkClusterResources(input, cluster) + if (resourceCheckResult.isError) { + errorMessages.push(resourceCheckResult.error) + } + const project = await prisma.project.findUniqueOrThrow({ where: { id: input.projectId } }) + const projectCheckResult = await checkProjectResources(input, project) + if (projectCheckResult.isError) { + errorMessages.push(projectCheckResult.error) + } + } + if (errorMessages.length > 0) { + return Result.fail(errorMessages.join('\n')) + } + return Result.succeed(true) +} + +export async function checkClusterResources(input: { + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}, cluster: Cluster): Promise> { + if (cluster.cpu === 0 && cluster.memory === 0) { + // Unconfigured cluster + return Result.succeed(true) + } + const unsufficientResource = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: cluster.cpu, gpu: cluster.gpu, memory: cluster.memory }, + where: { + cluster: { + id: cluster.id, + }, + }, + }) + if (unsufficientResource.length > 0) { + return Result.fail(`Le cluster ne dispose pas de suffisamment de ressources : ${unsufficientResource.join(', ')}.`) + } + return Result.succeed(true) +} + +export async function checkProjectResources(input: { + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] + stageId: Environment['stageId'] +}, project: Project): Promise> { + if (project.limitless) { + // No limits + return Result.succeed(true) + } + const stage = await prisma.stage.findUnique({ where: { id: input.stageId } }) + const prodStages = await prisma.stage.findMany({ select: { id: true }, where: { name: 'prod' } }) + let overflowResources: string[] + if (stage?.name === 'prod') { + overflowResources = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: project.prodCpu, gpu: project.prodGpu, memory: project.prodMemory }, + where: { + projectId: project.id, + stageId: { + in: prodStages.map(s => s.id), + }, + }, + }) + } else { // hprod + overflowResources = await getOverflowResources({ + request: { cpu: input.cpu, gpu: input.gpu, memory: input.memory }, + limit: { cpu: project.hprodCpu, gpu: project.hprodGpu, memory: project.hprodMemory }, + where: { + projectId: project.id, + stageId: { + notIn: prodStages.map(s => s.id), + }, + }, + }) + } + if (overflowResources.length > 0) { + return Result.fail(`Le projet ne dispose pas de suffisamment de ressources : ${overflowResources.join(', ')}.`) + } + return Result.succeed(true) +} + +export async function checkEnvironmentUpdate(input: { + environmentId: Environment['id'] + cpu: Environment['cpu'] + gpu: Environment['gpu'] + memory: Environment['memory'] +}): Promise> { + const environment = await prisma.environment.findUniqueOrThrow({ + select: { cluster: true, projectId: true, stageId: true }, + where: { id: input.environmentId }, + }) + const cluster = await prisma.cluster.findUniqueOrThrow({ + where: { id: environment.cluster.id }, + }) + const errorMessages: string[] = [] + const resourceCheckResult = await checkClusterResources(input, cluster) + if (resourceCheckResult.isError) { + errorMessages.push(resourceCheckResult.error) + } + const project = await prisma.project.findUniqueOrThrow({ where: { id: environment.projectId } }) + const projectCheckResult = await checkProjectResources({ stageId: environment.stageId, ...input }, project) + if (projectCheckResult.isError) { + errorMessages.push(projectCheckResult.error) + } + if (errorMessages.length > 0) { + return Result.fail(errorMessages.join('\n')) + } + return Result.succeed(true) +} + +export async function getOverflowResources({ request, limit, where }: { + request: Resources + limit: Resources + where: any +}): Promise { + if (limit.cpu === 0 && limit.memory === 0) { + // Unconfigured project prod resources + return [] + } + const environmentResources = await prisma.environment.aggregate({ + _sum: { + memory: true, + cpu: true, + gpu: true, + }, + where, + }) + const unsufficientResource: string[] = [] + if ((environmentResources._sum.cpu ?? 0) + request.cpu > limit.cpu) { + unsufficientResource.push('CPU') + } + if ((environmentResources._sum.gpu ?? 0) + request.gpu > limit.gpu) { + unsufficientResource.push('GPU') + } + if ((environmentResources._sum.memory ?? 0) + request.memory > limit.memory) { + unsufficientResource.push('Mémoire') + } + return unsufficientResource +} diff --git a/.jjconflict-side-2/apps/server/src/resources/environment/queries.ts b/.jjconflict-side-2/apps/server/src/resources/environment/queries.ts new file mode 100644 index 0000000000..6b228e2687 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/environment/queries.ts @@ -0,0 +1,99 @@ +import type { Environment, Prisma, Project } from '@prisma/client' +import prisma from '@/prisma.js' + +// SELECT +export function getEnvironmentByIdOrThrow(id: Environment['id']) { + return prisma.environment.findUniqueOrThrow({ where: { id }, include: { stage: true } }) +} + +export function getEnvironmentInfos(id: Environment['id']) { + return prisma.environment.findUniqueOrThrow({ + where: { id }, + include: { + project: { + select: { + owner: true, + name: true, + id: true, + status: true, + repositories: { + where: { isInfra: true }, + }, + locked: true, + clusters: { + select: { + id: true, + label: true, + privacy: true, + clusterResources: true, + }, + }, + }, + }, + stage: true, + }, + }) +} + +export async function getEnvironmentsByProjectId(projectId: Project['id']) { + return prisma.environment.findMany({ + where: { projectId }, + include: { + stage: true, + }, + }) +} + +export function getEnvironmentByIdWithCluster(id: Environment['id']) { + return prisma.environment.findUnique({ + where: { id }, + include: { + cluster: { + include: { kubeconfig: true }, + }, + }, + }) +} + +// INSERT +export function initializeEnvironment(data: Prisma.EnvironmentUncheckedCreateInput) { + return prisma.environment.create({ + data, + include: { + project: { + include: { + repositories: { + where: { isInfra: true }, + }, + }, + }, + }, + }) +} + +export function updateEnvironment({ id, cpu, gpu, memory, autosync }: { id: Environment['id'], cpu: Environment['cpu'], gpu: Environment['gpu'], memory: Environment['memory'], autosync: Environment['autosync'] }) { + return prisma.environment.update({ + where: { + id, + }, + data: { + cpu, + gpu, + memory, + autosync, + }, + }) +} + +// DELETE +export function deleteEnvironment(id: Environment['id']) { + return prisma.environment.delete({ + where: { id }, + }) +} + +export function deleteAllEnvironmentForProject(id: Project['id']) { + return prisma.environment.deleteMany({ + where: { projectId: id }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/environment/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/environment/router.spec.ts new file mode 100644 index 0000000000..2fbff26774 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/environment/router.spec.ts @@ -0,0 +1,340 @@ +import type { Environment } from '@cpn-console/shared' +import { environmentContract, PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { atDates, getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetProjectEnvironmentsMock = vi.spyOn(business, 'getProjectEnvironments') +const businessCreateEnvironmentMock = vi.spyOn(business, 'createEnvironment') +const businessUpdateEnvironmentMock = vi.spyOn(business, 'updateEnvironment') +const businessDeleteEnvironmentMock = vi.spyOn(business, 'deleteEnvironment') +const businessCheckEnvironmentCreateMock = vi.spyOn(business, 'checkEnvironmentCreate') +const businessCheckEnvironmentUpdateMock = vi.spyOn(business, 'checkEnvironmentUpdate') + +describe('environmentRouter tests', () => { + let projectId: string + let environmentId: string + let environmentData: Omit + + beforeEach(() => { + vi.resetAllMocks() + projectId = faker.string.uuid() + environmentId = faker.string.uuid() + environmentData = { + projectId, + name: 'envname', + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + } + }) + + describe('listEnvironments', () => { + it('should return environments for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetProjectEnvironmentsMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(environmentContract.listEnvironments.path) + .query({ projectId }) + .end() + + expect(businessGetProjectEnvironmentsMock).toHaveBeenCalledWith(projectId) + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for non member of projectId query ', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(environmentContract.listEnvironments.path) + .query({ projectId }) + .end() + + expect(businessGetProjectEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createEnvironment', () => { + it('should create environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ success: true }) + businessCreateEnvironmentMock.mockResolvedValueOnce({ + success: true, + data: { id: environmentId, ...environmentData, ...atDates }, + }) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.json()).toMatchObject({ id: environmentId, ...environmentData }) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ success: true, message: 'pas d erreur' }) + businessCreateEnvironmentMock.mockResolvedValueOnce({ isError: true, message: 'une erreur' }) + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(500) + }) + it('should pass invalid reason error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentCreateMock.mockResolvedValueOnce({ isError: true, message: 'une erreur' }) + const response = await app.inject() + .post(environmentContract.createEnvironment.path) + .body(environmentData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateEnvironment', () => { + let updateData: { cpu: number, gpu: number, memory: number } + beforeEach(() => { + updateData = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + } + }) + + it('should update environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentUpdateMock.mockResolvedValueOnce({ success: true, value: true }) + businessUpdateEnvironmentMock.mockResolvedValueOnce({ success: true, data: { id: environmentId, ...environmentData, ...atDates } }) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.json()).toMatchObject({ id: environmentId, ...environmentData }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateEnvironmentMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(500) + }) + + it('should pass invalid reason error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCheckEnvironmentUpdateMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .put(environmentContract.updateEnvironment.path.replace(':environmentId', environmentId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('deleteEnvironment', () => { + it('should delete environment for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteEnvironmentMock.mockResolvedValueOnce({ success: true }) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteEnvironmentMock.mockResolvedValueOnce({ isError: true, value: 'une erreur' }) + const response = await app.inject() + .delete(environmentContract.deleteEnvironment.path.replace(':environmentId', environmentId)) + .end() + + expect(response.statusCode).toEqual(500) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/environment/router.ts b/.jjconflict-side-2/apps/server/src/resources/environment/router.ts new file mode 100644 index 0000000000..7ef91c05ff --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/environment/router.ts @@ -0,0 +1,107 @@ +import { environmentContract, ProjectAuthorized } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { BadRequest400, Forbidden403, Internal500, Unauthorized401 } from '@/utils/errors.js' +import { checkEnvironmentCreate, checkEnvironmentUpdate, createEnvironment, deleteEnvironment, getProjectEnvironments, updateEnvironment } from './business.js' + +export function environmentRouter() { + return serverInstance.router(environmentContract, { + listEnvironments: async ({ request: req, query }) => { + const projectId = query.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListEnvironments(perms)) return new Forbidden403() + const body = await getProjectEnvironments(projectId) + + return { + status: 200, + body, + } + }, + + createEnvironment: async ({ request: req, body: requestBody }) => { + const projectId = requestBody.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const checkCreateResult = await checkEnvironmentCreate({ ...requestBody }) + if (checkCreateResult.isError) return new BadRequest400(checkCreateResult.error) + + const result = await createEnvironment({ + userId: perms.user.id, + projectId, + name: requestBody.name, + clusterId: requestBody.clusterId, + cpu: requestBody.cpu, + gpu: requestBody.gpu, + memory: requestBody.memory, + autosync: requestBody.autosync, + stageId: requestBody.stageId, + requestId: req.id, + }) + if (result.isError) { + return new Internal500(result.error) + } + return { + status: 201, + body: result.data, + } + }, + + updateEnvironment: async ({ request: req, body: requestBody, params }) => { + const { environmentId } = params + const perms = await authUser(req, { environmentId }) + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const checkUpdateResult = await checkEnvironmentUpdate({ environmentId, ...requestBody }) + if (checkUpdateResult.isError) return new BadRequest400(checkUpdateResult.error) + + const result = await updateEnvironment({ + user: perms.user, + environmentId, + cpu: requestBody.cpu, + gpu: requestBody.gpu, + memory: requestBody.memory, + autosync: requestBody.autosync, + requestId: req.id, + }) + if (result.isError) { + return new Internal500(result.error) + } + return { + status: 200, + body: result.data, + } + }, + + deleteEnvironment: async ({ request: req, params }) => { + const { environmentId } = params + const perms = await authUser(req, { environmentId }) + if (!ProjectAuthorized.ManageEnvironments(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const result = await deleteEnvironment({ + userId: perms.user?.id, + environmentId, + requestId: req.id, + projectId: perms.projectId, + }) + if (result.isError) { + return new Internal500(result.error) + } + + return { + status: 204, + body: result.data, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/index.ts b/.jjconflict-side-2/apps/server/src/resources/index.ts new file mode 100644 index 0000000000..93a285f9ac --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/index.ts @@ -0,0 +1,49 @@ +import type { FastifyInstance } from 'fastify' +import { serverInstance } from '@/app.js' + +import { adminRoleRouter } from './admin-role/router.js' +import { adminTokenRouter } from './admin-token/router.js' +import { clusterRouter } from './cluster/router.js' +import { environmentRouter } from './environment/router.js' +import { logRouter } from './log/router.js' +import { projectMemberRouter } from './project-member/router.js' +import { projectRoleRouter } from './project-role/router.js' +import { projectServiceRouter } from './project-service/router.js' +import { projectRouter } from './project/router.js' +import { repositoryRouter } from './repository/router.js' +import { serviceChainRouter } from './service-chain/router.js' +import { serviceMonitorRouter } from './service-monitor/router.js' +import { stageRouter } from './stage/router.js' +import { pluginConfigRouter } from './system/config/router.js' +import { systemRouter } from './system/router.js' +import { systemSettingsRouter } from './system/settings/router.js' +import { userRouter } from './user/router.js' +import { personalAccessTokenRouter } from './user/tokens/router.js' +import { zoneRouter } from './zone/router.js' + +// relax validation schema if NO_VALIDATION env var is set to true. +// /!\ It can lead to security leaks !!!! +const validateTrue = { responseValidation: process.env.NO_VALIDATION !== 'true' } +export function apiRouter() { + return async (app: FastifyInstance) => { + await app.register(serverInstance.plugin(adminRoleRouter()), validateTrue) + await app.register(serverInstance.plugin(adminTokenRouter()), validateTrue) + await app.register(serverInstance.plugin(clusterRouter()), validateTrue) + await app.register(serverInstance.plugin(serviceChainRouter()), validateTrue) + await app.register(serverInstance.plugin(environmentRouter()), validateTrue) + await app.register(serverInstance.plugin(logRouter()), validateTrue) + await app.register(serverInstance.plugin(personalAccessTokenRouter()), validateTrue) + await app.register(serverInstance.plugin(projectRouter()), validateTrue) + await app.register(serverInstance.plugin(projectMemberRouter()), validateTrue) + await app.register(serverInstance.plugin(projectRoleRouter()), validateTrue) + await app.register(serverInstance.plugin(projectServiceRouter()), validateTrue) + await app.register(serverInstance.plugin(repositoryRouter()), validateTrue) + await app.register(serverInstance.plugin(serviceMonitorRouter()), validateTrue) + await app.register(serverInstance.plugin(pluginConfigRouter()), validateTrue) + await app.register(serverInstance.plugin(stageRouter()), validateTrue) + await app.register(serverInstance.plugin(systemRouter()), validateTrue) + await app.register(serverInstance.plugin(systemSettingsRouter()), validateTrue) + await app.register(serverInstance.plugin(userRouter()), validateTrue) + await app.register(serverInstance.plugin(zoneRouter()), validateTrue) + } +} diff --git a/.jjconflict-side-2/apps/server/src/resources/log/business.spec.ts b/.jjconflict-side-2/apps/server/src/resources/log/business.spec.ts new file mode 100644 index 0000000000..8a4d080e48 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/log/business.spec.ts @@ -0,0 +1,42 @@ +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { getLogs } from './business.ts' + +describe('test log business', () => { + it('should map filter (clean logs)', async () => { + const dbLogs = [{ + data: { args: {} }, + createdAt: new Date(), + updatedAt: new Date(), + userId: null, + action: 'Action', + id: faker.string.uuid(), + }] + const query = { limit: 10, offset: 10, clean: true, projectId: undefined } + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + const [_total, logs] = await getLogs(query) + + expect(logs[0]).not.haveOwnProperty('requestId') + expect(logs[0].data).not.haveOwnProperty('results') + expect(logs[0].data).not.haveOwnProperty('args') + expect(logs[0].data).not.haveOwnProperty('config') + }) + + it('should not filter (admin logs)', async () => { + const dbLogs = [{ + data: { args: {} }, + createdAt: new Date(), + updatedAt: new Date(), + userId: null, + action: 'Action', + id: faker.string.uuid(), + }] + const query = { limit: 10, offset: 10, clean: false, projectId: undefined } + prisma.$transaction.mockResolvedValueOnce([dbLogs.length, dbLogs]) + const [_total, logs] = await getLogs(query) + + expect(logs[0].data).haveOwnProperty('args') + expect(logs[0].data).not.haveOwnProperty('config') + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/log/business.ts b/.jjconflict-side-2/apps/server/src/resources/log/business.ts new file mode 100644 index 0000000000..9a0182e7e1 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/log/business.ts @@ -0,0 +1,13 @@ +import type { logContract } from '@cpn-console/shared' +import { CleanLogSchema } from '@cpn-console/shared' +import { getAllLogs } from '@/resources/queries-index.js' + +export async function getLogs({ offset, limit, projectId, clean }: typeof logContract.getLogs.query._type) { + const [total, logs] = await getAllLogs({ skip: offset, take: limit, where: { projectId } }) + return [ + total, + clean + ? logs.map(log => CleanLogSchema.parse(log)) + : logs, + ] +} diff --git a/.jjconflict-side-2/apps/server/src/resources/log/queries.ts b/.jjconflict-side-2/apps/server/src/resources/log/queries.ts new file mode 100644 index 0000000000..3851a8f13f --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/log/queries.ts @@ -0,0 +1,57 @@ +import type { Log, Prisma, Project, User } from '@prisma/client' +import { exclude } from '@cpn-console/shared' +import prisma from '@/prisma.js' + +// SELECT +export function getAllLogsForUser(user: User, offset = 0) { + return prisma.log.findMany({ + where: { userId: user.id }, + take: 100, + skip: offset, + }) +} + +export function getAllLogs({ skip = 0, take = 5, where }: Prisma.LogFindManyArgs) { + return prisma.$transaction([ + prisma.log.count({ where }), + prisma.log.findMany({ + orderBy: { + createdAt: 'desc', + }, + skip, + take, + where, + }), + ]) +} + +// CREATE +interface AddLogsArgs { + action: Log['action'] + data: Record + userId?: User['id'] | null + requestId: string + projectId?: Project['id'] +} +export function addLogs({ action, data, requestId, userId = null, projectId }: AddLogsArgs) { + return prisma.log.create({ + data: { + action, + userId, + data: exclude(data, ['cluster', 'user', 'newCreds', 'apis']), + requestId, + projectId, + }, + }) +} + +// TECH +export function _createLog(data: Parameters[0]['create']) { + return prisma.log.upsert({ + where: { + id: data.id, + }, + create: data, + update: data, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/log/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/log/router.spec.ts new file mode 100644 index 0000000000..911f69305d --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/log/router.spec.ts @@ -0,0 +1,93 @@ +import { ADMIN_PERMS, logContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetLogsMock = vi.spyOn(business, 'getLogs') + +describe('test logContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getLogs', () => { + it('should return logs for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const logs = [] + const total = 1 + + authUserMock.mockResolvedValueOnce(user) + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0 }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ total, logs }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin, no projectId', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0 }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return logs for non-admin, with projectId', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 1n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + const projectId = faker.string.uuid() + + const logs = [] + const total = 1 + + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0, projectId, clean: false }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetLogsMock).toHaveBeenCalledWith({ clean: true, limit: 10, offset: 0, projectId }) + expect(response.statusCode).toEqual(200) + }) + + it('should not return logs for non-admin, with projectId', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + const projectId = faker.string.uuid() + + const logs = [] + const total = 1 + + businessGetLogsMock.mockResolvedValueOnce([total, logs]) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(logContract.getLogs.path) + .query({ limit: 10, offset: 0, projectId, clean: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/log/router.ts b/.jjconflict-side-2/apps/server/src/resources/log/router.ts new file mode 100644 index 0000000000..72e8712f0b --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/log/router.ts @@ -0,0 +1,30 @@ +import type { CleanLog, Log, XOR } from '@cpn-console/shared' +import type { UserProfile, UserProjectProfile } from '@/utils/controller.js' +import { AdminAuthorized, logContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getLogs } from './business.js' + +export function logRouter() { + return serverInstance.router(logContract, { + // Récupérer des logs + getLogs: async ({ request: req, query }) => { + const perms: XOR = query.projectId + ? await authUser(req, { id: query.projectId }) + : await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) { + if (!perms.projectPermissions) return new Forbidden403() + query.clean = true + } + + const [total, logs] = await getLogs(query) as [number, unknown[]] as [number, Array] + + return { + status: 200, + body: { total, logs }, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/project-member/business.ts b/.jjconflict-side-2/apps/server/src/resources/project-member/business.ts new file mode 100644 index 0000000000..4144b81813 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project-member/business.ts @@ -0,0 +1,73 @@ +import type { projectMemberContract, XOR } from '@cpn-console/shared' +import type { Project, User } from '@prisma/client' +import { logger as baseLogger } from '@cpn-console/logger' +import { UserSchema } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + addLogs, + deleteMember, + listMembers as listMembersQuery, + upsertMember, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' +import { logViaSession } from '../user/business.js' + +const logger = baseLogger.child({ scope: 'resource:project-member' }) + +export const listMembers = async (projectId: Project['id']) => listMembersQuery(projectId) + +export async function addMember(projectId: Project['id'], user: XOR<{ userId: string }, { email: string }>, requestorId: User['id'], requestId: string, projectOwnerId: Project['ownerId']) { + let userInDb: User | undefined | null + + logger.info({ requestId, requestorId, projectId, identifierType: user.userId ? 'userId' : 'email' }, 'Add project member started') + if (user.userId) { + userInDb = await prisma.user.findUnique({ where: { id: user.userId, type: 'human' } }) + } else if (user.email) { + userInDb = await prisma.user.findUnique({ where: { email: user.email, type: 'human' } }) + } else { + return new BadRequest400('Veuillez spécifiez au moins un userId ou un email') + } + if (userInDb) { + if (userInDb.id === projectOwnerId) return new BadRequest400('Le owner ne peut pas être ajouté à cette liste') + } else if (user.email) { + const hookReply = await hook.user.retrieveUserByEmail(user.email) + await addLogs({ action: 'Retrieve User By Email', data: hookReply, userId: requestorId, requestId }) + if (hookReply.failed) { + logger.error({ requestId, requestorId, projectId }, 'Add project member failed during user lookup hooks') + throw new BadRequest400('Echec de la recherche auprès des services externes') + } + + const retrievedUser = hookReply.results.keycloak?.user + if (!retrievedUser) return new BadRequest400('Utilisateur introuvable') + const userValidated = UserSchema.pick({ email: true, firstName: true, lastName: true, id: true }).safeParse(retrievedUser) + if (!userValidated.success) return new BadRequest400('L\'utilisateur trouvé ne remplit pas les conditions de vérification') + const logResults = await logViaSession({ ...userValidated.data, groups: [] }) + userInDb = logResults.user + } else { + return new NotFound404() + } + + await upsertMember({ projectId, userId: userInDb.id, roleIds: [] }) + await hook.projectMember.upsert(projectId, userInDb.id) + logger.info({ requestId, requestorId, projectId, userId: userInDb.id }, 'Add project member completed') + return listMembers(projectId) +} + +export async function patchMembers(projectId: Project['id'], members: typeof projectMemberContract.patchMembers.body._type) { + logger.info({ projectId, membersCount: members.length }, 'Patch project members started') + for (const member of members) { + await upsertMember({ projectId, userId: member.userId, roleIds: member.roles }) + await hook.projectMember.upsert(projectId, member.userId) + } + logger.info({ projectId, membersCount: members.length }, 'Patch project members completed') + return listMembers(projectId) +} + +export async function removeMember(projectId: Project['id'], userId: User['id']) { + logger.info({ projectId, userId }, 'Remove project member started') + await hook.projectMember.delete(projectId, userId) + await deleteMember({ projectId, userId }) + logger.info({ projectId, userId }, 'Remove project member completed') + return listMembers(projectId) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/project-member/queries.ts b/.jjconflict-side-2/apps/server/src/resources/project-member/queries.ts new file mode 100644 index 0000000000..a4ceb00df1 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project-member/queries.ts @@ -0,0 +1,33 @@ +import type { + Prisma, + + Project, +} from '@prisma/client' + +import prisma from '@/prisma.js' + +export const listMembers = (projectId: Project['id']) => prisma.projectMembers.findMany({ where: { projectId }, include: { user: true } }) + +export function upsertMember(data: Prisma.ProjectMembersUncheckedCreateInput) { + return prisma.projectMembers.upsert({ + where: { + projectId_userId: { + userId: data.userId, + projectId: data.projectId, + }, + }, + create: data, + update: { + roleIds: data.roleIds, + }, + include: { user: true }, + }) +} + +export function deleteMember(data: Prisma.ProjectMembersWhereUniqueInput['projectId_userId']) { + return prisma.projectMembers.delete({ + where: { + projectId_userId: data, + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/project-member/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/project-member/router.spec.ts new file mode 100644 index 0000000000..6ebb1d50a2 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project-member/router.spec.ts @@ -0,0 +1,294 @@ +import type { Member } from '@cpn-console/shared' +import { PROJECT_PERMS, projectMemberContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMembersMock = vi.spyOn(business, 'listMembers') +const businessAddMemberMock = vi.spyOn(business, 'addMember') +const businessPatchMembersMock = vi.spyOn(business, 'patchMembers') +const businessRemoveMemberMock = vi.spyOn(business, 'removeMember') + +describe('projectMemberRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const userId = faker.string.uuid() + + describe('listMembers', () => { + it('should return members for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessListMembersMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectMemberContract.listMembers.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectMemberContract.listMembers.path.replace(':projectId', projectId)) + .end() + + expect(businessListMembersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('addMember', () => { + const memberData: Partial = { + userId: faker.string.uuid(), + } + + it('should add member for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const newMember = { + ...memberData, + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + roleIds: [], + } + + businessAddMemberMock.mockResolvedValueOnce([newMember]) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.json()).toEqual([newMember]) + expect(response.statusCode).toEqual(201) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + businessAddMemberMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectMemberContract.addMember.path.replace(':projectId', projectId)) + .body(memberData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('patchMembers', () => { + const patchData = [{ userId: faker.string.uuid(), roles: [] }] + + it('should patch members for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchMembersMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectMemberContract.patchMembers.path.replace(':projectId', projectId)) + .body(patchData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('removeMember', () => { + it('should remove member for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessRemoveMemberMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should be able leave a project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessRemoveMemberMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectMemberContract.removeMember.path.replace(':projectId', projectId).replace(':userId', userId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/project-member/router.ts b/.jjconflict-side-2/apps/server/src/resources/project-member/router.ts new file mode 100644 index 0000000000..e9755fab57 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project-member/router.ts @@ -0,0 +1,77 @@ +import { ProjectAuthorized, projectMemberContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + addMember, + listMembers, + patchMembers, + removeMember, +} from './business.js' + +export function projectMemberRouter() { + return serverInstance.router(projectMemberContract, { + listMembers: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListMembers(perms)) return new Forbidden403() + const body = await listMembers(projectId) + + return { + status: 200, + body, + } + }, + + addMember: async ({ request: req, params, body }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageMembers(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await addMember(projectId, body, perms.user.id, req.id, perms.projectOwnerId) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 201, + body: resBody, + } + }, + + patchMembers: async ({ request: req, params, body }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageMembers(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await patchMembers(projectId, body) + + return { + status: 200, + body: resBody, + } + }, + + removeMember: async ({ request: req, params }) => { + const { projectId, userId } = params + const perms = await authUser(req, { id: projectId }) + + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (!ProjectAuthorized.ManageMembers(perms) && userId !== perms.user?.id) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await removeMember(projectId, params.userId) + + return { + status: 200, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/project-role/business.spec.ts b/.jjconflict-side-2/apps/server/src/resources/project-role/business.spec.ts new file mode 100644 index 0000000000..89a70147fd --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project-role/business.spec.ts @@ -0,0 +1,357 @@ +import type { Project, ProjectMembers, ProjectRole } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { describe, expect, it, vi } from 'vitest' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../__mocks__/prisma.js' +import { countRolesMembers, createRole, deleteRole, listRoles, patchRoles } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', () => ({ + hook: { + project: { + upsert: vi.fn(), + delete: vi.fn(), + getSecrets: vi.fn(), + }, + projectRole: { + upsert: vi.fn(), + delete: vi.fn(), + }, + user: { + retrieveUserByEmail: vi.fn(), + }, + }, +})) + +describe('test project-role business', () => { + const project: Project = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + slug: faker.lorem.word({ length: { min: 2, max: 10 } }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + description: faker.lorem.sentence({ min: 2, max: 10 }), + status: 'created', + locked: false, + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + everyonePerms: 0n, + ownerId: faker.string.uuid(), + lastSuccessProvisionningVersion: null, + } + const projectId = faker.string.uuid() + + describe('listRoles', () => { + it('should stringify bigint', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + const response = await listRoles(projectId) + expect(response).toContainEqual(expect.objectContaining({ permissions: '4' })) + }) + + it('should strip oidcGroup prefix', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: `/${project.slug}/console/admin`, + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValueOnce(project) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + + const response = await listRoles(projectId) + expect(response[0].oidcGroup).toBe('/console/admin') + }) + }) + + describe('createRole', () => { + it('should create role with incremented position when position 0 is the highest', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 1, projectId } }) + }) + + it('should create role with incremented position with bigger position', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 51, projectId } }) + }) + + it('should create role with incremented position with no role in db', async () => { + const dbRole: ProjectRole = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(null) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + prisma.projectRole.findFirst.mockResolvedValueOnce(null) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + await createRole(projectId, { name: 'test', permissions: '4' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith({ data: { name: 'test', permissions: 4n, position: 0, projectId } }) + }) + + it('should create role with enforced oidcGroup prefix', async () => { + const dbRole: any = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 0, + oidcGroup: `/${project.slug}/console/admin`, + type: 'managed', + } + + prisma.project.findUnique.mockResolvedValueOnce(project) + prisma.projectRole.findFirst.mockResolvedValueOnce(dbRole) + prisma.projectRole.create.mockResolvedValue(dbRole) + prisma.projectRole.findMany.mockResolvedValueOnce([dbRole]) + + await createRole(projectId, { name: 'test', permissions: '4', oidcGroup: '/console/admin' }) + + expect(prisma.projectRole.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + oidcGroup: `/${project.slug}/console/admin`, + }), + })) + }) + }) + + describe('deleteRole', () => { + const roleId = faker.string.uuid() + it('should delete role and remove id from concerned users', async () => { + const dbRole: ProjectRole = { + id: roleId, + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + } + const members = [{ + userId: faker.string.uuid(), + projectId, + roleIds: [roleId], + }, { + userId: faker.string.uuid(), + projectId, + roleIds: [roleId, faker.string.uuid()], + }] as const satisfies Partial[] + + prisma.projectRole.findUnique.mockResolvedValue(dbRole) + prisma.projectMembers.findMany.mockResolvedValueOnce(members) + prisma.projectRole.findMany.mockResolvedValueOnce([]) + prisma.projectRole.delete.mockResolvedValue(dbRole) + await deleteRole(roleId) + + expect(prisma.projectMembers.update).toHaveBeenNthCalledWith(1, { where: expect.any(Object), data: { roleIds: { set: [] } } }) + expect(prisma.projectMembers.update).toHaveBeenNthCalledWith(2, { where: expect.any(Object), data: { roleIds: { set: [members[1].roleIds[1]] } } }) + expect(prisma.projectRole.delete).toHaveBeenCalledWith({ where: { id: roleId } }) + }) + }) + + describe.skip('countRolesMembers', () => { + it('should return aggregated role member counts', async () => { + const roles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + projectId, + permissions: 4n, + position: 50, + oidcGroup: '', + type: 'managed', + }] as const satisfies ProjectRole[] + + const members = [{ + userId: faker.string.uuid(), + projectId, + roleIds: [roles[0].id, roles[1].id], + }, { + userId: faker.string.uuid(), + projectId, + roleIds: [roles[1].id], + }] as const satisfies ProjectMembers[] + + prisma.projectRole.findMany.mockResolvedValue(roles) + prisma.projectMembers.findMany.mockResolvedValue(members) + + const response = await countRolesMembers(projectId) + + expect(response).toEqual({ [roles[0].id]: 1, [roles[1].id]: 2 }) + }) + }) + + describe('patchRoles', () => { + const dbRoles: ProjectRole[] = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 0, + projectId, + oidcGroup: 'group1', + type: 'managed', + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + permissions: faker.number.bigInt({ min: 0n, max: 50000n }), + position: 1, + projectId, + oidcGroup: 'group2', + type: 'managed', + }] + + it('should do nothing', async () => { + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue([]) + await patchRoles(projectId, []) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 1 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(projectId, updateRoles) + + expect(response).instanceOf(BadRequest400) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should return 400 if incoherent positions (missing)', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[1].id, position: 1 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + const response = await patchRoles(projectId, updateRoles) + + expect(response).instanceOf(BadRequest400) + expect(prisma.projectRole.update).toHaveBeenCalledTimes(0) + }) + + it('should update positions', async () => { + const updateRoles: Pick[] = [ + { id: dbRoles[0].id, position: 1 }, + { id: dbRoles[1].id, position: 0 }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledTimes(2) + }) + + it('should update permissions', async () => { + const updateRoles: (Pick & { permissions: string })[] = [ + { id: dbRoles[1].id, permissions: '0' }, + ] + prisma.project.findUnique.mockResolvedValue(project) + prisma.projectRole.findMany.mockResolvedValue(dbRoles) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + data: { + name: dbRoles[1].name, + permissions: 0n, + position: 1, + oidcGroup: dbRoles[1].oidcGroup, + projectId, + type: 'managed', + }, + where: { + id: dbRoles[1].id, + }, + })) + }) + + it('should update role with enforced oidcGroup prefix', async () => { + const updateRoles: any[] = [ + { id: dbRoles[1].id, oidcGroup: '/console/admin' }, + ] + + prisma.project.findUnique.mockResolvedValue(project) + + const dbRoleWithPrefix = { ...dbRoles[1], oidcGroup: `/${project.slug}/console/group2` } + prisma.projectRole.findMany.mockResolvedValue([dbRoleWithPrefix]) + + await patchRoles(projectId, updateRoles) + + expect(prisma.projectRole.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + oidcGroup: `/${project.slug}/console/admin`, + }), + })) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/project-role/business.ts b/.jjconflict-side-2/apps/server/src/resources/project-role/business.ts new file mode 100644 index 0000000000..52bd8966b3 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project-role/business.ts @@ -0,0 +1,121 @@ +import type { projectRoleContract } from '@cpn-console/shared' +import type { Project, ProjectRole } from '@prisma/client' +import { isSystemRoleType } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { + deleteRole as deleteRoleQuery, + listMembers, + listRoles as listRolesQuery, + updateRole, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +const oidcRegexp = /^\/[^/]+/ + +export async function listRoles(projectId: Project['id']) { + const roles = await listRolesQuery(projectId) + return roles.map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ? role.oidcGroup.replace(oidcRegexp, '') : role.oidcGroup, + })) +} + +export async function patchRoles(projectId: Project['id'], roles: typeof projectRoleContract.patchProjectRoles.body._type) { + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } }) + if (!project) throw new NotFound404() + const dbRoles = await listRolesQuery(projectId) + const positionsAvailable: number[] = [] + + const updatedRoles: (Omit & { permissions: bigint })[] = [] + + for (const dbRole of dbRoles) { + const matchingRole = roles.find(role => role.id === dbRole.id) + if (matchingRole) { + if (isSystemRoleType(dbRole.type)) { + return new BadRequest400('Ce rôle système ne peut pas être modifié') + } + if (isSystemRoleType(matchingRole.type)) { + return new BadRequest400('Impossible de modifier un rôle en rôle système') + } + if (typeof matchingRole?.position !== 'undefined' && !positionsAvailable.includes(matchingRole?.position)) { + positionsAvailable.push(matchingRole.position) + } + if (matchingRole.oidcGroup && !matchingRole.oidcGroup.startsWith('/')) { + return new BadRequest400('oidcGroup doit commencer par /') + } + updatedRoles.push({ + id: dbRole.id, + name: matchingRole.name ?? dbRole.name, + permissions: matchingRole.permissions ? BigInt(matchingRole.permissions) : dbRole.permissions, + position: matchingRole.position ?? dbRole.position, + oidcGroup: matchingRole.oidcGroup ? `/${project.slug}${matchingRole.oidcGroup}` : dbRole.oidcGroup, + type: matchingRole.type ?? dbRole.type, + projectId: dbRole.projectId, + }) + } + } + + if (positionsAvailable.length && positionsAvailable.length !== dbRoles.length) return new BadRequest400('Les numéros de position des rôles sont incohérentes') + for (const { id, ...role } of updatedRoles) { + await updateRole(id, role) + await hook.projectRole.upsert(id) + } + + return listRoles(projectId) +} + +export async function createRole(projectId: Project['id'], role: typeof projectRoleContract.createProjectRole.body._type) { + const project = await prisma.project.findUnique({ where: { id: projectId }, select: { slug: true } }) + if (!project) throw new NotFound404() + if (isSystemRoleType(role.type)) { + throw new BadRequest400('Impossible de créer un rôle système') + } + const dbMaxPosRole = (await prisma.projectRole.findFirst({ + where: { projectId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }))?.position ?? -1 + + if (role.oidcGroup && !role.oidcGroup.startsWith('/')) { + throw new BadRequest400('oidcGroup doit commencer par /') + } + + const createdRole = await prisma.projectRole.create({ + data: { + ...role, + projectId, + position: dbMaxPosRole + 1, + permissions: BigInt(role.permissions), + oidcGroup: role.oidcGroup ? `/${project.slug}${role.oidcGroup}` : undefined, + }, + }) + + await hook.projectRole.upsert(createdRole.id) + + return listRoles(projectId) +} + +export async function countRolesMembers(projectId: Project['id']) { + const roles = await listRoles(projectId) + const members = await listMembers(projectId) + const rolesCounts: Record = Object.fromEntries(roles.map(role => [role.id, 0])) // {role uuid: 0} + for (const { roleIds } of members) { + for (const roleId of roleIds) { + rolesCounts[roleId]++ + } + } + return rolesCounts +} + +export async function deleteRole(roleId: Project['id']) { + const role = await prisma.projectRole.findUnique({ where: { id: roleId }, select: { type: true } }) + if (!role) throw new NotFound404() + if (isSystemRoleType(role.type)) { + throw new BadRequest400('Ce rôle système ne peut pas être supprimé') + } + await hook.projectRole.delete(roleId) + await deleteRoleQuery(roleId) + return null +} diff --git a/.jjconflict-side-2/apps/server/src/resources/project-role/queries.ts b/.jjconflict-side-2/apps/server/src/resources/project-role/queries.ts new file mode 100644 index 0000000000..3e2aa4a0fa --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project-role/queries.ts @@ -0,0 +1,59 @@ +import type { + Prisma, + Project, + ProjectRole, +} from '@prisma/client' + +import prisma from '@/prisma.js' + +export const getRole = (id: ProjectRole['id']) => prisma.projectRole.findUnique({ where: { id } }) + +export const listRoles = (projectId: Project['id']) => prisma.projectRole.findMany({ where: { projectId }, orderBy: { position: 'asc' } }) + +export function createRole(data: Pick) { + return prisma.projectRole.create({ + data: { + name: data.name, + permissions: 0n, + position: data.position, + projectId: data.projectId, + oidcGroup: data.oidcGroup, + type: 'managed', + }, + }) +} + +export function updateRole(id: ProjectRole['id'], data: Pick) { + return prisma.projectRole.update({ + where: { id }, + data, + }) +} + +export async function deleteRole(id: ProjectRole['id']) { + const role = await prisma.projectRole.delete({ + where: { + id, + }, + }) + const attachedMembers = await prisma.projectMembers.findMany({ + where: { projectId: role.projectId, roleIds: { has: id } }, + }) + for (const member of attachedMembers) { + await prisma.projectMembers.update({ + where: { + projectId_userId: { + projectId: role.projectId, + userId: member.userId, + }, + }, + data: { + roleIds: { + set: member.roleIds.filter(roleId => roleId !== id), + }, + }, + }) + } +} + +export const getProjectRoleById = (id: ProjectRole['id']) => prisma.projectRole.findUnique({ where: { id }, include: { project: true } }) diff --git a/.jjconflict-side-2/apps/server/src/resources/project-role/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/project-role/router.spec.ts new file mode 100644 index 0000000000..dd42fbc7ad --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project-role/router.spec.ts @@ -0,0 +1,299 @@ +import { PROJECT_PERMS, projectRoleContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('./business.js') +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) + +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessCreateRoleMock = vi.spyOn(business, 'createRole') +const businessDeleteRoleMock = vi.spyOn(business, 'deleteRole') +const businessListRolesMock = vi.spyOn(business, 'listRoles') +const businessPatchRolesMock = vi.spyOn(business, 'patchRoles') + +describe('tests projectRoleContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const roleId = faker.string.uuid() + + describe('listProjectRoles', () => { + it('should return 403 for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessListRolesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectRoleContract.listProjectRoles.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectRoleContract.listProjectRoles.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createProjectRole', () => { + it('should create role for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectRoleContract.createProjectRole.path.replace(':projectId', projectId)) + .body({ name: 'nouveau rôle' }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) + + describe('patchProjectRoles', () => { + it('should patch roles for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchRolesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessPatchRolesMock.mockResolvedValue(new BadRequest400('une erreur')) + const response = await app.inject() + .patch(projectRoleContract.patchProjectRoles.path.replace(':projectId', projectId)) + .body([{ id: roleId, name: 'nouveau rôle' }]) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('projectRoleMemberCounts', () => { + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectRoleContract.projectRoleMemberCounts.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteProjectRole', () => { + it('should delete role for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteRoleMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for locked project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not permited', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 404 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_ROLES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateRoleMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .delete(projectRoleContract.deleteProjectRole.path.replace(':projectId', projectId).replace(':roleId', roleId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/project-role/router.ts b/.jjconflict-side-2/apps/server/src/resources/project-role/router.ts new file mode 100644 index 0000000000..f7aa2ef902 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project-role/router.ts @@ -0,0 +1,88 @@ +import { ProjectAuthorized, projectRoleContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + countRolesMembers, + createRole, + deleteRole, + listRoles, + patchRoles, +} from './business.js' + +export function projectRoleRouter() { + return serverInstance.router(projectRoleContract, { + // Récupérer des projets + listProjectRoles: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ListRoles(perms)) return new Forbidden403() + + const body = await listRoles(projectId) + + return { + status: 200, + body, + } + }, + + createProjectRole: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await createRole(projectId, body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 201, + body: resBody, + } + }, + + patchProjectRoles: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await patchRoles(projectId, body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + projectRoleMemberCounts: async ({ request: req, params }) => { + const { projectId } = params + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ListRoles(perms)) return new Forbidden403() + + const resBody = await countRolesMembers(projectId) + + return { + status: 200, + body: resBody, + } + }, + + deleteProjectRole: async ({ request: req, params: { projectId, roleId } }) => { + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.ManageRoles(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const resBody = await deleteRole(roleId) + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/project-service/business.ts b/.jjconflict-side-2/apps/server/src/resources/project-service/business.ts new file mode 100644 index 0000000000..bce70064ef --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project-service/business.ts @@ -0,0 +1,103 @@ +import type { ZoneObject } from '@cpn-console/hooks' +import type { + PermissionTarget, + PluginsUpdateBody, + ServiceUrl, +} from '@cpn-console/shared' +import type { Project, ProjectPlugin } from '@prisma/client' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { + getAdminPlugin, + getProjectInfosByIdOrThrow, + getProjectStore, + getPublicClusters, + saveProjectStore, +} from '@/resources/queries-index.js' + +const logger = baseLogger.child({ scope: 'resource:project-service' }) + +export type ConfigRecords = { + key: string + pluginName: string + value: string | number | null +}[] + +export function dbToObj(records: Omit[]): PluginsUpdateBody { + const obj: PluginsUpdateBody = {} + for (const record of records) { + if (!obj[record.pluginName]) obj[record.pluginName] = {} + obj[record.pluginName][record.key] = record.value + } + return obj +} + +export function objToDb(obj: PluginsUpdateBody): ConfigRecords { + return Object.entries(obj) + .map(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) + .flat() +} + +export async function getProjectServices(projectId: Project['id'], permissionTarget: PermissionTarget) { + // Pré-requis + const project = await getProjectInfosByIdOrThrow(projectId) + + const [projectStore, globalConfig] = await Promise.all([ + getProjectStore(projectId), + getAdminPlugin(), + ]) + const store = dbToObj([...projectStore, ...globalConfig]) + + const publicClusters = await getPublicClusters() + project.clusters = [...project.clusters, ...publicClusters] + const zones: Map = new Map() // Pour dédoublonnage des zones + project.clusters.map(c => zones.set(c.zone.id, c.zone)) + + return Object.values(servicesInfos).map(({ name, title, to, imgSrc, description }) => { + let urls: ServiceUrl[] = [] + const toResponse = to + ? to({ + clusters: project.clusters, + zones: [...zones.values()], + environments: project.environments, + project, + store, + }) + : [] + if (Array.isArray(toResponse)) { + urls = toResponse.map(res => ({ name: res.title ?? '', description: res.description ?? '', to: res.to })) + } else if (typeof toResponse === 'string') { + urls = [{ to: toResponse, name: '' }] + } else if (toResponse) { + urls = [{ name: toResponse.title ?? '', to: toResponse.to }] + } + const manifest = populatePluginManifests({ + data: { + project: projectStore, + global: globalConfig, + }, + permissionTarget, + pluginName: name, + select: { + global: true, + project: true, + }, + }) + return { imgSrc, title, name, urls, manifest, description } + }).filter(s => s.urls.length || s.manifest.global?.length || s.manifest.project?.length) +} + +export async function updateProjectServices(projectId: Project['id'], data: PluginsUpdateBody, stripperRoles: Array<'user' | 'admin'>) { + const pluginKeysCount = Object.fromEntries( + Object.entries(data).map(([pluginName, values]) => [pluginName, Object.keys(values).length]), + ) + logger.info({ projectId, pluginCount: Object.keys(data).length, pluginKeysCount, stripperRoles }, 'Update project services started') + for (const role of stripperRoles) { + const parsedData = editStrippers.project[role].safeParse(data) + if (!parsedData.success) continue + await saveProjectStore(objToDb(parsedData.data), projectId) + } + logger.info({ projectId }, 'Update project services completed') + return null +} diff --git a/.jjconflict-side-2/apps/server/src/resources/project-service/queries.ts b/.jjconflict-side-2/apps/server/src/resources/project-service/queries.ts new file mode 100644 index 0000000000..cf353614b2 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project-service/queries.ts @@ -0,0 +1,54 @@ +import type { Project } from '@prisma/client' +import type { ConfigRecords } from './business.js' +import prisma from '@/prisma.js' + +// CONFIG +export function getProjectStore(projectId: Project['id']) { + return prisma.projectPlugin.findMany({ + where: { projectId }, + select: { + key: true, + pluginName: true, + value: true, + }, + }) +} + +export const getAdminPlugin = prisma.adminPlugin.findMany + +export async function saveProjectStore(records: ConfigRecords, projectId: Project['id']) { + for (const { pluginName, key, value } of records) { + if (value === null) { + await prisma.projectPlugin.delete({ + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } else { + await prisma.projectPlugin.upsert({ + create: { + pluginName, + projectId, + key, + value: value.toString(), + }, + update: { + key, + value: value.toString(), + pluginName, + }, + where: { + projectId_pluginName_key: { + projectId, + pluginName, + key, + }, + }, + }) + } + } +} diff --git a/.jjconflict-side-2/apps/server/src/resources/project-service/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/project-service/router.spec.ts new file mode 100644 index 0000000000..20398a0d08 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project-service/router.spec.ts @@ -0,0 +1,160 @@ +import { ADMIN_PERMS, PROJECT_PERMS, projectServiceContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetServicesMock = vi.spyOn(business, 'getProjectServices') +const businessUpdateServicesMock = vi.spyOn(business, 'updateProjectServices') + +describe('projectServiceRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + + describe('getServices', () => { + it('should return services for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .query({ permissionTarget: 'user' }) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledWith(projectId, 'user') + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should not return admin services for non admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .query({ permissionTarget: 'admin' }) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return services for admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.GUEST }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetServicesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .end() + + expect(businessGetServicesMock).toHaveBeenCalledWith(projectId, 'user') + expect(response.statusCode).toEqual(200) + expect(response.json()).toEqual([]) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectServiceContract.getServices.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateProjectServices', () => { + const updateData = { serviceA: { param1: 'value' } } + + it('should update services for project manager', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateServicesMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(businessUpdateServicesMock).toHaveBeenCalledWith(projectId, updateData, ['user']) + expect(response.statusCode).toEqual(204) + }) + + it('should update services for project admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateServicesMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(businessUpdateServicesMock).toHaveBeenCalledWith(projectId, updateData, ['user', 'admin']) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectServiceContract.updateProjectServices.path.replace(':projectId', projectId)) + .body(updateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/project-service/router.ts b/.jjconflict-side-2/apps/server/src/resources/project-service/router.ts new file mode 100644 index 0000000000..e5a1f07159 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project-service/router.ts @@ -0,0 +1,38 @@ +import { AdminAuthorized, ProjectAuthorized, projectServiceContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getProjectServices, updateProjectServices } from './business.js' + +export function projectServiceRouter() { + return serverInstance.router(projectServiceContract, { + // Récupérer les services d'un projet + getServices: async ({ request: req, params: { projectId }, query }) => { + const perms = await authUser(req, { id: projectId }) + if (!perms.projectPermissions && !ProjectAuthorized.Manage(perms)) return new Forbidden403() + if (query.permissionTarget === 'admin' && !AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403('Vous ne pouvez pas demander les paramètres admin') + + const body = await getProjectServices(projectId, query.permissionTarget) + + return { + status: 200, + body, + } + }, + + updateProjectServices: async ({ request: req, params: { projectId }, body }) => { + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.Manage(perms) && !perms.projectPermissions) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + + const allowedRoles: Array<'user' | 'admin'> = AdminAuthorized.Manage(perms.adminPermissions) ? ['user', 'admin'] : ['user'] + + const resBody = await updateProjectServices(projectId, body, allowedRoles) + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/project/business.spec.ts b/.jjconflict-side-2/apps/server/src/resources/project/business.spec.ts new file mode 100644 index 0000000000..a9f6c4fa62 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project/business.spec.ts @@ -0,0 +1,365 @@ +import type { Cluster, Project, ProjectMembers, ProjectRole, User } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { + BadRequest400, + ErrorResType, + Unprocessable422, +} from '../../utils/errors.js' +import { dbToObj } from '../project-service/business.ts' +import * as userBusiness from '../user/business.js' +import { archiveProject, chunk, createProject, generateProjectsData, generateSlug, getProjectSecrets, listProjects, replayHooks, updateProject } from './business.ts' + +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +const logViaSessionMock = vi.spyOn(userBusiness, 'logViaSession') + +const projectId = faker.string.uuid() + +const user: User = { + id: faker.string.uuid(), + createdAt: new Date(), + updatedAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + type: 'human', + lastLogin: null, +} +const project: Project & { + clusters: Pick[] + members: ProjectMembers[] + roles: ProjectRole[] + owner: User +} = { + createdAt: new Date(), + updatedAt: new Date(), + description: '', + everyonePerms: 649n, + id: faker.string.uuid(), + locked: false, + name: faker.string.alphanumeric(8), + status: 'created', + ownerId: faker.string.uuid(), + clusters: [], + roles: [], + members: [], +} +const reqId = faker.string.uuid() +describe('test project business utils', () => { + it('should transform arrow ', async () => { + const result = dbToObj([{ key: 'test', pluginName: 'test', value: 'test' }]) + expect(result).toEqual({ test: { test: 'test' } }) + }) +}) + +describe('test project business logic', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listProjects', () => { + it('should return stringified perms', async () => { + prisma.project.findMany.mockResolvedValue([{ everyonePerms: 5n, clusters: [], roles: [{ permissions: 28n }] }]) + const response = await listProjects({}, user.id) + expect(response[0].everyonePerms).toBe('5') + expect(response[0].roles[0].permissions).toBe('28') + }) + }) + describe('getProjectSecrets', () => { + const getResultsHook = { + failed: false, + args: {}, + results: { + registry: { + secrets: { + token: 'myToken', + }, + status: { + failed: false, + }, + }, + }, + } + it('should return transform secret', async () => { + hook.project.getSecrets.mockResolvedValue(getResultsHook) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + + // according to src/utils/mocks.ts + expect(JSON.stringify(response)).toContain('myToken') + }) + + it('should return projects secrets', async () => { + hook.project.getSecrets.mockResolvedValue(getResultsHook) + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + prisma.project.findMany.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + // according to src/utils/mocks.ts + expect(JSON.stringify(response)).toContain('myToken') + }) + + it('should return hook error', async () => { + hook.project.getSecrets.mockResolvedValue({ failed: true }) + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId }) + prisma.project.findMany.mockResolvedValue({ id: projectId }) + const response = await getProjectSecrets(projectId) + // according to src/utils/mocks.ts + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('createProject', () => { + it('should create project', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.create.mockResolvedValue({ ...project, status: 'initializing' }) + prisma.project.findFirst.mockResolvedValue(undefined) + prisma.project.findMany.mockResolvedValue([]) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const projectRes = await createProject(project, user, reqId) + + expect(projectRes.name).toEqual(project.name) + expect(prisma.project.create).toHaveBeenCalledTimes(1) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should return plugins failed', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.create.mockResolvedValue({ ...project, status: 'initializing' }) + prisma.project.findFirst.mockResolvedValue(undefined) + prisma.project.findMany.mockResolvedValue([]) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const response = await createProject(project, user, reqId) + + expect(prisma.project.create).toHaveBeenCalledTimes(1) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + describe('updateProject', () => { + const updatedProjet = { + description: faker.lorem.lines(2), + everyonePerms: '5', + } + const reqId = faker.string.uuid() + const members: ProjectMembers[] = [{ userId: faker.string.uuid(), projectId: project.id, roleIds: [], user: { type: 'human' } }] + it('should update project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + await updateProject({ ...updatedProjet, ownerId: members[0].userId }, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(2) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should update nothing', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + await updateProject({ }, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(0) + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should not update if project archived', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, status: 'archived' }) + prisma.project.update.mockResolvedValue(project) + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + + const response = await updateProject({ }, project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should not update project, cause missing member', async () => { + hook.project.upsert.mockResolvedValue({ results: {}, project: { ...project } }) + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, members: [] }) + + const response = await updateProject({ ownerId: members[0].userId }, project.id, user, reqId) + + expect(prisma.project.findUniqueOrThrow).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(BadRequest400) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + expect(prisma.log.update).toHaveBeenCalledTimes(0) + }) + + it('should return plugins failed', async () => { + logViaSessionMock.mockResolvedValue({ user }) + + prisma.project.findUniqueOrThrow.mockResolvedValue({ status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: true }, project: { ...project } }) + + const response = await updateProject(updatedProjet, project.id, user, reqId) + + expect(prisma.project.update).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + describe('replayHooks', () => { + const reqId = faker.string.uuid() + + it('should replay hooks', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + await replayHooks(project.id, user, reqId) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + }) + + it('should not replay hooks on archived project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'archived' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should not replay hooks on locked project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: true, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: false } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.log.create).toHaveBeenCalledTimes(0) + expect(hook.project.upsert).toHaveBeenCalledTimes(0) + }) + + it('should update nothing and return error', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ locked: false, status: 'created' }) + hook.project.upsert.mockResolvedValue({ results: { failed: true } }) + + const response = await replayHooks(project.id, user, reqId) + + expect(prisma.log.create).toHaveBeenCalledTimes(1) + expect(hook.project.upsert).toHaveBeenCalledTimes(1) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('archiveProject', () => { + it('should archive project', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeNull() + expect(prisma.project.update).toHaveBeenLastCalledWith({ + where: { id: project.id }, + data: { + clusters: { set: [] }, + }, + }) + }) + + it('should not archive a project already archived', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false, status: 'archived' }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + }) + + it('should not archive a project locked', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: true, status: 'created' }) + hook.project.delete.mockResolvedValue({ results: { failed: false }, project: Promise.resolve({ status: 'archived' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(ErrorResType) + expect(prisma.project.update).toHaveBeenCalledTimes(0) + }) + + it('should return hook fail', async () => { + prisma.project.findUniqueOrThrow.mockResolvedValue({ id: projectId, locked: false }) + hook.project.delete.mockResolvedValue({ results: { failed: true }, project: Promise.resolve({ status: 'failed' }) }) + const response = await archiveProject(project.id, user, reqId) + expect(response).toBeInstanceOf(Unprocessable422) + }) + }) + + describe('generateProjectsData', () => { + it('shoud return string, very bad test ...', async () => { + prisma.project.findMany.mockResolvedValue([{ name: 'test' }]) + const response = await generateProjectsData() + expect(response).toBeTypeOf('string') + }) + }) +}) + +describe('chunk function', () => { + it('should return 5 elements', () => { + const letters = ['A', 'B', 'C', 'D', 'E'] + expect(chunk(letters, 5)).toEqual([letters]) + }) + it('should return 3,2 elements', () => { + const letters = ['A', 'B', 'C', 'D', 'E'] + expect(chunk(letters, 3)).toEqual([['A', 'B', 'C'], ['D', 'E']]) + }) + it('should return 4 elements', () => { + const letters = ['A', 'B', 'C', 'D'] + expect(chunk(letters, 5)).toEqual([letters]) + }) +}) + +const slugRegexp = /-[01]$/ +const slugFourRegexp = /-4$/ +const slugEmptySpaceRegexp = /-2$/ + +describe('generateSlug', () => { + it('should return prefix, no array', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix) + expect(generated).toEqual(prefix) + }) + it('should return prefix, empty array', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, []) + expect(generated).toEqual(prefix) + }) + it('should return prefix, no match', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [faker.string.alphanumeric(5), faker.string.alphanumeric(5)]) + expect(generated).toEqual(prefix) + }) + it('should return generated slug at 1 or 0, all matchs', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix]) + expect(generated).match(slugRegexp) + }) + it('should return generated slug at 4, all matchs', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix, `${prefix}-0`, `${prefix}-1`, `${prefix}-2`, `${prefix}-3`]) + expect(generated).match(slugFourRegexp) + }) + it('should fill empty space', () => { + const prefix = faker.string.alphanumeric(5) + const generated = generateSlug(prefix, [prefix, `${prefix}-0`, `${prefix}-1`, `${prefix}-3`]) + expect(generated).match(slugEmptySpaceRegexp) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/project/business.ts b/.jjconflict-side-2/apps/server/src/resources/project/business.ts new file mode 100644 index 0000000000..b27f16bf64 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project/business.ts @@ -0,0 +1,306 @@ +import type { projectContract } from '@cpn-console/shared' +import type { Project, User } from '@prisma/client' +import type { UserDetails } from '@/types/index.js' +import type { ErrorResType } from '@/utils/errors.js' +import { servicesInfos } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { ProjectStatusSchema } from '@cpn-console/shared' +import { json2csv } from 'json-2-csv' +import prisma from '@/prisma.js' +import { + addLogs, + deleteAllEnvironmentForProject, + deleteAllRepositoryForProject, + getAllProjectsDataForExport, + getProjectOrThrow, + getSlugs, + initializeProject, + listProjects as listProjectsQuery, + lockProject, + updateProject as updateProjectQuery, +} from '@/resources/queries-index.js' +import { whereBuilder } from '@/utils/controller.js' +import { parallelBulkLimit } from '@/utils/env.js' +import { BadRequest400, Forbidden403, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +const logger = baseLogger.child({ scope: 'resource:project' }) + +export function generateSlug(prefix: string, existingSlugs?: string[]) { + if (!existingSlugs?.includes(prefix)) { + return prefix + } + let idx = 1 + let generated = `${prefix}-${idx}` + while (existingSlugs.includes(generated)) { + idx++ + generated = `${prefix}-${idx}` + } + return generated +} + +const projectStatus = ProjectStatusSchema._def.values +export async function listProjects({ status, statusIn, statusNotIn, filter = 'member', ...query }: typeof projectContract.listProjects.query._type, userId: User['id'] | undefined) { + return listProjectsQuery({ + ...query, + status: whereBuilder({ enumValues: projectStatus, eqValue: status, inValues: statusIn, notInValues: statusNotIn }), + filter, + userId, + }).then(projects => projects.map(({ clusters, ...project }) => ({ + ...project, + clusterIds: clusters.map(({ id }) => id), + roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + everyonePerms: project.everyonePerms.toString(), + }))) +} + +export async function getProjectSecrets(projectId: string) { + const hookReply = await hook.project.getSecrets(projectId) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la récupération des secrets du projet') + } + + return Object.fromEntries( + Object.entries(hookReply.results) + // @ts-ignore + .filter(([_key, value]) => Object.keys(value.secrets).length) + // @ts-ignore + .map(([key, value]) => [servicesInfos[key]?.title, value.secrets]), + ) +} + +export async function createProject(dataDto: typeof projectContract.createProject.body._type, requestor: UserDetails, requestId: string) { + if (requestor.type !== 'human') return new BadRequest400('Seuls les comptes humains peuvent créer des projets') + + let slug = dataDto.name + logger.info({ requestId, userId: requestor.id, slugPrefix: slug }, 'Create project started') + const projectsWithSamePrefix = await getSlugs(slug) + slug = generateSlug(slug, projectsWithSamePrefix?.map(project => project.slug)) + + // Actions + const project = await initializeProject({ ...dataDto, slug, ownerId: requestor.id }) + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Project initialized') + + const { results, project: projectInfos } = await hook.project.upsert(project.id) + await addLogs({ action: 'Create Project', data: results, userId: requestor.id, requestId, projectId: project.id }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project failed during upsert hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project upsert hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services à la création du projet') + } + + for (const role of projectInfos.roles) { + const roleResult = await hook.projectRole.upsert(role.id) + await addLogs({ action: 'Upsert Project Role', data: roleResult.results, userId: requestor.id, requestId, projectId: project.id }) + } + + logger.info({ requestId, userId: requestor.id, projectId: project.id, slug }, 'Create project completed') + return { + ...projectInfos, + clusterIds: projectInfos.clusters.map(({ id }) => id), + everyonePerms: projectInfos.everyonePerms.toString(), + roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + } +} + +export async function getProject(projectId: Project['id']) { + return getProjectOrThrow(projectId).then(({ clusters, ...project }) => ({ + ...project, + clusterIds: clusters.map(({ id }) => id), + roles: project.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: project.slug ? role.oidcGroup?.replace(`/${project.slug}`, '') : role.oidcGroup })), + everyonePerms: project.everyonePerms.toString(), + })) +} + +export async function updateProject( + { description, ownerId: ownerIdCandidate, everyonePerms, locked, ...data }: typeof projectContract.updateProject.body._type, + projectId: Project['id'], + requestor: UserDetails, + requestId: string, +) { + const changedFields = Object.entries({ + ...data, + description, + locked, + ownerId: ownerIdCandidate, + everyonePerms, + }).filter(([, value]) => typeof value !== 'undefined').map(([key]) => key) + logger.info({ requestId, userId: requestor.id, projectId, changedFields }, 'Update project started') + + // Actions + const projectDb = await prisma.project.findUniqueOrThrow({ + where: { id: projectId }, + include: { members: { include: { user: true } } }, + }) + + if (projectDb.status === 'archived') return new Forbidden403('Le projet est archivé') + + if (ownerIdCandidate && ownerIdCandidate !== projectDb.ownerId) { + const memberCandidate = projectDb.members.find(member => member.userId === ownerIdCandidate) + if (!memberCandidate) { + return new BadRequest400('Le nouveau propriétaire doit faire partie des membres actuels du projet') + } + if (memberCandidate.user.type !== 'human') return new BadRequest400('Seuls les comptes humains peuvent être propriétaire de projets') + if (!projectDb.members.some(member => member.userId === projectDb.ownerId)) { + await prisma.projectMembers.create({ + data: { userId: projectDb.ownerId, projectId }, + }) + } + await prisma.$transaction([ + prisma.projectMembers.delete({ + where: { projectId_userId: { userId: ownerIdCandidate, projectId } }, + }), + prisma.project.update({ where: { id: projectId }, data: { ownerId: ownerIdCandidate } }), + ]) + } + + if (typeof description !== 'undefined' || typeof everyonePerms !== 'undefined' || typeof locked !== 'undefined') { + await updateProjectQuery(projectId, { + description, + locked, + ...everyonePerms && { everyonePerms: BigInt(everyonePerms) }, + ...data, + }) + } + + const { results, project: projectInfos } = await hook.project.upsert(projectId) + await addLogs({ action: 'Update Project', data: results, userId: requestor.id, requestId, projectId: projectInfos.id }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId }, 'Update project failed during upsert hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId }, 'Update project upsert hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services à la mise à jour du projet') + } + + logger.info({ requestId, userId: requestor.id, projectId }, 'Update project completed') + return { + ...projectInfos, + clusterIds: projectInfos.clusters.map(({ id }) => id), + everyonePerms: projectInfos.everyonePerms.toString(), + roles: projectInfos.roles.map(role => ({ ...role, permissions: role.permissions.toString(), oidcGroup: projectInfos.slug ? role.oidcGroup?.replace(`/${projectInfos.slug}`, '') : role.oidcGroup })), + } +} + +interface ReplayHooksArgs { + projectId: Project['id'] + userId?: User['id'] + requestId: string +} +export async function replayHooks({ projectId, userId, requestId }: ReplayHooksArgs): Promise { + logger.info({ requestId, userId, projectId }, 'Replay project hooks started') + const projectDb = await prisma.project.findUniqueOrThrow({ + where: { id: projectId }, + include: { members: { include: { user: true } } }, + }) + if (projectDb.locked) return new Forbidden403('Le projet est verrouillé') + if (projectDb.status === 'archived') return new Forbidden403('Le projet est archivé') + // Actions + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Replay hooks for Project', data: results, userId, requestId, projectId }) + if (results.failed) { + logger.error({ requestId, userId, projectId }, 'Replay project hooks failed') + } else { + logger.info({ requestId, userId, projectId }, 'Replay project hooks completed') + } + if (results.failed) { + return new Unprocessable422('Echec des services au reprovisionnement du projet') + } + return null +} + +export async function archiveProject(projectId: Project['id'], requestor: UserDetails, requestId: string): Promise { + logger.info({ requestId, userId: requestor.id, projectId }, 'Archive project started') + // Actions + // Fetch project state before hook so plugins see repos and environments + const projectDb = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, include: { members: { include: { user: true } } } }) + + if (projectDb.locked) return new Forbidden403('Le projet est verrouillé') + if (projectDb.status === 'archived') return new BadRequest400('Le projet est archivé') + if (projectDb.locked) { + await lockProject(projectId) + } + + // -- début - Suppression projet -- + const { results, project } = await hook.project.delete(projectId) + await addLogs({ action: 'Delete all project resources', data: results, userId: requestor.id, requestId, projectId }) + if (results.failed) { + logger.error({ requestId, userId: requestor.id, projectId }, 'Archive project failed during delete hooks') + } else { + logger.info({ requestId, userId: requestor.id, projectId, status: project.status }, 'Archive project delete hooks completed') + } + if (project.status !== 'archived' && !projectDb.locked) { + await prisma.project.update({ where: { id: projectId }, data: { locked: false } }) + } + if (results.failed) { + return new Unprocessable422('Echec des services à la suppression du projet') + } + + // Empty the project after successful hooks so plugins still have access to repos and environments + await Promise.all([ + deleteAllRepositoryForProject(projectId), + deleteAllEnvironmentForProject(projectId), + ]) + + // Retrait clusters -- + await prisma.project.update({ + where: { id: projectId }, + data: { + clusters: { set: [] }, + }, + }) + + // -- fin - Suppression projet -- + logger.info({ requestId, userId: requestor.id, projectId }, 'Archive project completed') + return null +} + +export async function generateProjectsData() { + const projects = await getAllProjectsDataForExport() + + return json2csv(projects, { + emptyFieldValue: '', + }) +} + +export async function bulkActionProject(data: typeof projectContract.bulkActionProject.body._type, requestor: UserDetails, requestId: string) { + if (data.projectIds === 'all') { + data.projectIds = (await prisma.project.findMany({ + select: { id: true }, + where: { status: { not: 'archived' } }, + })).map(({ id }) => id) + } + bulkExector(data.projectIds + .map((projectId) => { + if (data.action === 'archive') { + return () => archiveProject(projectId, requestor, requestId) + } + if (data.action === 'lock') { + return () => updateProject({ locked: true }, projectId, requestor, requestId) + } + if (data.action === 'unlock') { + return () => updateProject({ locked: false }, projectId, requestor, requestId) + } + if (data.action === 'replay') { + return () => replayHooks({ projectId, userId: requestor.id, requestId }) + } + // should never been called + return async () => {} + })) +} + +export function chunk(arr: T[], size: number): T[][] { + return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => + arr.slice(i * size, i * size + size)) +} + +async function bulkExector(toExecute: Array<() => Promise>) { + const toExecuteChunked = chunk(toExecute, parallelBulkLimit) + for (const chunkToExecute of toExecuteChunked) { + await Promise.allSettled(chunkToExecute.map(fn => fn())) + } +} diff --git a/.jjconflict-side-2/apps/server/src/resources/project/queries.ts b/.jjconflict-side-2/apps/server/src/resources/project/queries.ts new file mode 100644 index 0000000000..5957b15478 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project/queries.ts @@ -0,0 +1,374 @@ +import type { projectContract, XOR } from '@cpn-console/shared' +import type { + Prisma, + Project, + User, +} from '@prisma/client' +import { PROJECT_PERMS } from '@cpn-console/shared' +import { + ProjectStatus, +} from '@prisma/client' +import prisma from '@/prisma.js' +import { appVersion } from '@/utils/env.js' +import { uuid } from '@/utils/queries-tools.js' + +type ProjectUpdate = Partial> +export function updateProject(id: Project['id'], data: ProjectUpdate) { + return prisma.project.update({ + where: { id }, + data, + include: { members: true }, + }) +} + +// SELECT +type FilterWhere = XOR<{ + userId?: User['id'] + filter: 'all' +}, { + userId: User['id'] | undefined + filter: 'owned' | 'member' +}> +type ListProjectWhere = Omit<(typeof projectContract.listProjects.query._type), 'status_in' | 'status_not_in' | 'status'> + & Pick + & FilterWhere +export async function listProjects({ + description, + locked, + name, + status, + id, + filter, + userId, + search, + lastSuccessProvisionningVersion, +}: ListProjectWhere) { + const whereAnd: Prisma.ProjectWhereInput[] = [] + if (id) whereAnd.push({ id }) + if (locked != null) whereAnd.push({ locked }) + if (name) whereAnd.push({ name }) + if (status) whereAnd.push({ status }) + if (description) whereAnd.push({ description: { contains: description } }) + if (lastSuccessProvisionningVersion) { + if (lastSuccessProvisionningVersion === 'outdated') whereAnd.push({ lastSuccessProvisionningVersion: { not: appVersion } }) + else if (lastSuccessProvisionningVersion === 'last') whereAnd.push({ lastSuccessProvisionningVersion: { equals: appVersion } }) + else whereAnd.push({ lastSuccessProvisionningVersion }) + } + if (search) { + whereAnd.push({ OR: [{ + name: { contains: search }, + }, { + owner: { email: { contains: search } }, + }] }) + } + + if (filter === 'owned') { + whereAnd.push({ ownerId: userId }) + } else if (filter === 'member') { + whereAnd.push({ OR: [{ + members: { some: { userId } }, + }, { + ownerId: userId, + }] }) + } + + return prisma.project.findMany({ + where: { AND: whereAnd }, + include: { + clusters: { select: { id: true } }, + members: { include: { user: true } }, + roles: true, + owner: true, + }, + }) +} + +export function getProjectOrThrow(id: Project['id'] | Project['slug']) { + return prisma.project.findFirstOrThrow({ + where: uuid.test(id) + ? { id } + : { slug: id }, + include: { + clusters: { select: { id: true } }, + members: { include: { user: true } }, + roles: true, + owner: true, + }, + }) +} + +export function getProjectInfosByIdOrThrow(projectId: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { + id: projectId, + }, + include: { + environments: true, + clusters: { include: { zone: true } }, + }, + }) +} + +export function getProjectMembers(projectId: Project['id']) { + return prisma.projectMembers.findMany({ + where: { + projectId, + }, + include: { user: true }, + }) +} + +export function getProjectById(id: Project['id']) { + return prisma.project.findUnique({ where: { id } }) +} + +export const baseProjectIncludes = { + members: { include: { user: true } }, + clusters: true, + roles: true, + owner: true, +} as const + +export function getProjectInfos(id: Project['id']) { + return prisma.project.findUnique({ + where: { id }, + include: baseProjectIncludes, + }) +} + +export function getProjectInfosOrThrow(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: baseProjectIncludes, + }) +} + +export function getProjectInfosAndRepos(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: { + ...baseProjectIncludes, + repositories: true, + }, + }) +} + +export function getSlugs(slugPrefix: string) { + return prisma.project.findMany({ + where: { + slug: { startsWith: slugPrefix }, + }, + }) +} + +export function getAllProjectsDataForExport() { + return prisma.project.findMany({ + select: { + name: true, + description: true, + createdAt: true, + updatedAt: true, + environments: { + select: { + name: true, + stage: true, + cluster: { + select: { label: true }, + }, + }, + }, + owner: true, + }, + }) +} + +export function getRolesByProjectId(projectId: Project['id']) { + return prisma.projectRole.findMany({ + where: { projectId }, + }) +} + +const clusterInfosSelect = { + id: true, + infos: true, + label: true, + privacy: true, + secretName: true, + kubeconfig: true, + clusterResources: true, + cpu: true, + gpu: true, + memory: true, + zone: { + select: { + id: true, + slug: true, + argocdUrl: true, + label: true, + }, + }, +} +export function getHookProjectInfos(id: Project['id']) { + return prisma.project.findUniqueOrThrow({ + where: { id }, + include: { + members: { include: { user: true }, where: { user: { type: 'human' } } }, + clusters: { select: clusterInfosSelect }, + environments: { + include: { + stage: true, + cluster: { + select: clusterInfosSelect, + }, + }, + }, + repositories: true, + plugins: { + select: { + key: true, + pluginName: true, + value: true, + }, + }, + owner: true, + roles: true, + }, + }) +} + +// CREATE +interface CreateProjectParams { + name: Project['name'] + description?: Project['description'] + ownerId: User['id'] + slug: Project['slug'] + limitless: boolean + hprodCpu: number + hprodGpu: number + hprodMemory: number + prodCpu: number + prodGpu: number + prodMemory: number +} + +export function initializeProject(params: CreateProjectParams) { + return prisma.project.create({ + data: { + description: params.description ?? '', + status: ProjectStatus.created, + locked: false, + roles: { + create: [ + { + name: 'Administrateur', + permissions: PROJECT_PERMS.MANAGE, + position: 0, + oidcGroup: `/${params.slug}/console/admin`, + type: 'system:managed', + }, + { + name: 'DevOps', + permissions: PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 1, + oidcGroup: `/${params.slug}/console/devops`, + type: 'system:managed', + }, + { + name: 'Développeur', + permissions: PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 2, + oidcGroup: `/${params.slug}/console/developer`, + type: 'system:managed', + }, + { + name: 'Sécurité', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 3, + oidcGroup: `/${params.slug}/console/security`, + type: 'system:managed', + }, + { + name: 'Lecture seule', + permissions: PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.LIST_REPOSITORIES, + position: 4, + oidcGroup: `/${params.slug}/console/readonly`, + type: 'system:managed', + }, + ], + }, + ...params, + }, + }) +} + +// UPDATE +export function lockProject(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { locked: true }, + }) +} + +export function updateProjectCreated(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { + status: ProjectStatus.created, + lastSuccessProvisionningVersion: appVersion, + }, + include: baseProjectIncludes, + }) +} + +export function updateProjectFailed(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { status: ProjectStatus.failed }, + include: baseProjectIncludes, + }) +} + +export function updateProjectWarning(id: Project['id']) { + return prisma.project.update({ + where: { id }, + data: { status: ProjectStatus.warning }, + include: baseProjectIncludes, + }) +} + +export function addUserToProject({ project, user }: { project: Project, user: User }) { + return prisma.projectMembers.create({ + data: { + userId: user.id, + projectId: project.id, + }, + }) +} + +export function removeUserFromProject({ projectId, userId }: { projectId: Project['id'], userId: User['id'] }) { + return prisma.projectMembers.delete({ + where: { + projectId_userId: { + projectId, + userId, + }, + }, + }) +} + +export async function archiveProject(id: Project['id']) { + const project = await prisma.project.findUnique({ + where: { id }, + select: { name: true, slug: true }, + }) + return prisma.project.update({ + where: { id }, + data: { + name: `${project?.name}_${Date.now()}_archived`, + slug: `${project?.slug}_${Date.now()}_archived`, + status: ProjectStatus.archived, + locked: true, + }, + include: baseProjectIncludes, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/project/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/project/router.spec.ts new file mode 100644 index 0000000000..8c5a2ed820 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project/router.spec.ts @@ -0,0 +1,473 @@ +import type { ProjectV2 } from '@cpn-console/shared' +import type { UserDetails } from '../../types/index.js' +import { ADMIN_PERMS, PROJECT_PERMS, projectContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getProjectMockInfos, getRandomRequestor, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listProjects') +const businessCreateMock = vi.spyOn(business, 'createProject') +const businessUpdateMock = vi.spyOn(business, 'updateProject') +const businessDeleteMock = vi.spyOn(business, 'archiveProject') +const businessSyncMock = vi.spyOn(business, 'replayHooks') +const bulkActionProjectMock = vi.spyOn(business, 'bulkActionProject') +const businessGetSecretsMock = vi.spyOn(business, 'getProjectSecrets') +const businessGenerateDataMock = vi.spyOn(business, 'generateProjectsData') + +describe('test projectContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + const projectOwner: ProjectV2['owner'] = { + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + id: faker.string.uuid(), + type: 'human', + } + const projectId = faker.string.uuid() + const project: Omit = { + name: faker.string.alpha({ length: 10, casing: 'lower' }), + slug: faker.string.alpha({ length: 5, casing: 'lower' }), + description: faker.string.alpha({ length: 5 }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + clusterIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + locked: false, + status: 'created', + everyonePerms: '0', + members: [], + owner: projectOwner, + ownerId: projectOwner.id, + roles: [], + lastSuccessProvisionningVersion: null, + } + describe('check unauthorized user on project behaviour', () => { + // UPDATE + it('on Update', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + it('on Update without enough perms', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // REPLAY + it('on replay', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(businessSyncMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // SECRETS + it('on see secret', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(businessGetSecretsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + + // ARCHIVE + it('on archive', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', projectId)) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Forbidden' }) + }) + }) + describe('listProjects', () => { + it('should return list of projects', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + const projects: any[] = [] + businessListMock.mockResolvedValueOnce(projects) + const response = await app.inject() + .get(projectContract.listProjects.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(projects) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin with "all" filter', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + const projects: any[] = [] + businessListMock.mockResolvedValueOnce(projects) + + const response = await app.inject() + .get(projectContract.listProjects.path) + .query({ filter: 'all' }) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return list of projects for admin', async () => { + const projects: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(projects) + + const response = await app.inject() + .get(projectContract.listProjects.path) + .query({ filter: 'all' }) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createProject', () => { + it('should create and return project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce({ id: projectId, ...project }) + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: projectId, ...project }) + expect(response.statusCode).toEqual(201) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(projectContract.createProject.path) + .body(project) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateProject', () => { + const projectUpdated: Partial = { description: faker.string.alpha({ length: 5 }) } + + it('should update and return project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should not update ownerId if not permitted', async () => { + const userDetails = getRandomRequestor() + const projectPerms = getProjectMockInfos({ projectOwnerId: faker.string.uuid(), projectPermissions: PROJECT_PERMS.MANAGE }) + const projectUpdated = { ownerId: faker.string.uuid(), description: faker.lorem.words() } + const user = getUserMockInfos(0n, userDetails as UserDetails, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith({ description: projectUpdated.description }, projectId, user.user, expect.any(String)) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should update ownerId and return project', async () => { + const requestor = getRandomRequestor() + const projectPerms = getProjectMockInfos({ projectOwnerId: requestor.id, projectPermissions: PROJECT_PERMS.MANAGE }) + const projectUpdated = { ownerId: faker.string.uuid(), description: faker.lorem.words() } + const user = getUserMockInfos(0n, requestor as UserDetails, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: projectId, ...project, ...projectUpdated }) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(projectUpdated) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith(projectUpdated, projectId, user.user, expect.any(String)) + expect(response.json()).toEqual({ id: projectId, ...project, ...projectUpdated }) + expect(response.statusCode).toEqual(200) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(projectContract.updateProject.path.replace(':projectId', projectId)) + .body(project) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(400) + }) + }) + + describe('archiveProject', () => { + it('should archive project for authorized user', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return projects data for admin', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(projectContract.archiveProject.path.replace(':projectId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getProjectSecrets', () => { + it('should return project secrets for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const secrets = {} + businessGetSecretsMock.mockResolvedValueOnce(secrets) + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(businessGetSecretsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(secrets) + expect(response.statusCode).toEqual(200) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetSecretsMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized access to secrets', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectSecrets.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('replayHooksForProject', () => { + it('should replay hooks for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(businessSyncMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_PROJECTS, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for unauthorized access to replay hooks', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const response = await app.inject() + .put(projectContract.replayHooksForProject.path.replace(':projectId', projectId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getProjectsData', () => { + it('should return projects data for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE) + authUserMock.mockResolvedValueOnce(user) + + const data = '' + businessGenerateDataMock.mockResolvedValueOnce(data) + const response = await app.inject() + .get(projectContract.getProjectsData.path) + .end() + + expect(businessGenerateDataMock).toHaveBeenCalledTimes(1) + expect(response.body).toEqual(data) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for non-admin user', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(projectContract.getProjectsData.path) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) + + describe('bulkActionProject', () => { + it('should executebulk for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE }) + const user = getUserMockInfos(ADMIN_PERMS.MANAGE, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + const response = await app.inject() + .post(projectContract.bulkActionProject.path) + .body({ action: 'lock', projectIds: [projectId] }) + .end() + + expect(response.json()).toBeNull() + expect(bulkActionProjectMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(202) + }) + + it('should return 403 for unauthorized access to bulk update', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_ENVIRONMENTS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + const response = await app.inject() + .post(projectContract.bulkActionProject.path) + .body({ action: 'lock', projectIds: [projectId] }) + .end() + + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/project/router.ts b/.jjconflict-side-2/apps/server/src/resources/project/router.ts new file mode 100644 index 0000000000..b969303ed2 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/project/router.ts @@ -0,0 +1,186 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, ProjectAuthorized, projectContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, NotFound404, Unauthorized401 } from '@/utils/errors.js' +import { + archiveProject, + bulkActionProject, + createProject, + generateProjectsData, + getProject, + getProjectSecrets, + listProjects, + replayHooks, + updateProject, +} from './business.js' + +export function projectRouter() { + return serverInstance.router(projectContract, { + + // Récupérer des projets + listProjects: async ({ request: req, query }) => { + const { adminPermissions, user } = await authUser(req) + let body: AsyncReturnType = [] + + if (adminPermissions && !user) { // c'est donc un compte de service + query.filter = 'all' + } + if (query.filter === 'all' && !AdminAuthorized.Manage(adminPermissions)) { + return new Forbidden403('Seuls les admins avec les droits de visionnage des projets peuvent utiliser le filtre \'all\'') + } + + body = await listProjects( + query, + user?.id, + ) + + return { + status: 200, + body, + } + }, + + // Récupérer les secrets d'un projet + getProjectSecrets: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + if (!ProjectAuthorized.SeeSecrets(perms)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await getProjectSecrets(projectId) + + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Créer un projet + createProject: async ({ request: req, body: data }) => { + const perms = await authUser(req) + if (perms.user?.type !== 'human') return new Unauthorized401('Cannot find requestor in database') + if (!AdminAuthorized.ManageProjects(perms.adminPermissions)) return new Forbidden403() + + const body = await createProject(data, perms.user, req.id) + + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Récuperer un seul projet + getProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.projectId) return new NotFound404() + if (!perms.projectPermissions && !AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new NotFound404() + + const body = await getProject(projectId) + + return { + status: 200, + body, + } + }, + + // Mettre à jour un projet + updateProject: async ({ request: req, params, body: data }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!ProjectAuthorized.Manage(perms)) return new Forbidden403() + + const isAdmin = AdminAuthorized.Manage(perms.adminPermissions) + const isOwner = perms.projectOwnerId === perms.user.id + if (!isAdmin) { + delete data.locked + if (!isOwner) delete data.ownerId // impossible de toucher à cette clé + } + if (perms.projectLocked) { + if (!isAdmin) return new Forbidden403('Le projet est verrouillé') + if (data.locked !== false) return new Forbidden403('Veuillez déverrouiler le projet pour le mettre à jour') + } + + const body = await updateProject(data, projectId, perms.user, req.id) + + if (body instanceof ErrorResType) return body + return { + status: 200, + body, + } + }, + + // Reprovisionner un projet + replayHooksForProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ReplayHooks(perms)) return new Forbidden403() + + const body = await replayHooks({ + projectId, + userId: perms.user?.id, + requestId: req.id, + }) + + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + + // Archiver un projet + archiveProject: async ({ request: req, params }) => { + const projectId = params.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!ProjectAuthorized.Manage(perms)) return new Forbidden403() + + const body = await archiveProject(projectId, perms.user, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + // Récupérer les données de tous les projets pour export + getProjectsData: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + const body = await generateProjectsData() + + return { + status: 200, + body, + } + }, + + bulkActionProject: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!perms.user) return new Unauthorized401('Cannot find requestor in database') + if (!AdminAuthorized.Manage(perms.adminPermissions)) return new Forbidden403() + + await bulkActionProject(body, perms.user, req.id) + + return { + status: 202, + body: null, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/queries-index.ts b/.jjconflict-side-2/apps/server/src/resources/queries-index.ts new file mode 100644 index 0000000000..a2c9787d2a --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/queries-index.ts @@ -0,0 +1,14 @@ +export * from '@/resources/admin-role/queries.js' +export * from '@/resources/cluster/queries.js' +export * from '@/resources/environment/queries.js' +export * from '@/resources/log/queries.js' +export * from '@/resources/project-member/queries.js' +export * from '@/resources/project-role/queries.js' +export * from '@/resources/project-service/queries.js' +export * from '@/resources/project/queries.js' +export * from '@/resources/repository/queries.js' +export * from '@/resources/service-chain/queries.js' +export * from '@/resources/stage/queries.js' +export * from '@/resources/system/settings/queries.js' +export * from '@/resources/user/queries.js' +export * from '@/resources/zone/queries.js' diff --git a/.jjconflict-side-2/apps/server/src/resources/repository/business.ts b/.jjconflict-side-2/apps/server/src/resources/repository/business.ts new file mode 100644 index 0000000000..473c9c73a7 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/repository/business.ts @@ -0,0 +1,115 @@ +import type { CreateRepositoryBody, UpdateRepositoryBody } from '@cpn-console/shared' +import type { Project, Repository, User } from '@prisma/client' +import { addLogs, deleteRepository as deleteRepositoryQuery, getProjectInfosAndRepos, getProjectRepositories as getProjectRepositoriesQuery, initializeRepository, updateRepository as updateRepositoryQuery } from '@/resources/queries-index.js' +import { BadRequest400, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function getProjectRepositories(projectId: Project['id']) { + return getProjectRepositoriesQuery(projectId) +} + +export async function syncRepository({ + repositoryId, + userId, + syncAllBranches, + branchName, + requestId, +}: { + repositoryId: Repository['id'] + userId: User['id'] + syncAllBranches: boolean + branchName?: string + requestId: string +}) { + const hookReply = await hook.misc.syncRepository(repositoryId, { syncAllBranches, branchName }) + await addLogs({ action: 'Sync Repository', data: hookReply, userId, requestId, projectId: hookReply.args.id }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services à la synchronisation du dépôt') + } + return null +} + +export async function createRepository({ + data, + userId, + requestId, +}: { + data: CreateRepositoryBody + userId: User['id'] + requestId: string +}) { + const project = await getProjectInfosAndRepos(data.projectId) + + if (project.repositories?.find(repo => repo.internalRepoName === data.internalRepoName)) return new BadRequest400(`Le nom du dépôt interne ${data.internalRepoName} existe déjà en base pour ce projet`) + const dbData = { ...data, isInfra: !!data.isInfra, isPrivate: !!data.isPrivate } + delete dbData.externalToken + + const repo = await initializeRepository(dbData) + const { results } = await hook.project.upsert(project.id, data.isPrivate + ? { + [repo.internalRepoName]: { + token: data.externalToken ?? '', + username: data.externalUserName ?? '', + }, + } + : undefined) + await addLogs({ action: 'Create Repository', data: results, userId, requestId, projectId: repo.projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services lors de la création du dépôt') + } + + if (data.externalRepoUrl) { + await syncRepository({ repositoryId: repo.id, requestId, syncAllBranches: true, userId }) + } + return repo +} + +export async function updateRepository({ + repositoryId, + data, + userId, + requestId, +}: { + repositoryId: Repository['id'] + data: Partial + userId: User['id'] + requestId: string +}) { + const dbData = { ...data } + delete dbData.externalToken + const repo = await updateRepositoryQuery(repositoryId, dbData) + + const { results } = await hook.project.upsert(repo.projectId, { + [repo.internalRepoName]: { + username: repo.externalUserName ?? '', + token: data.externalToken ?? '', + }, + }) + await addLogs({ action: 'Update Repository', data: results, userId, requestId, projectId: repo.projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services à la mise à jour du dépôt') + } + + return repo +} + +export async function deleteRepository({ + repositoryId, + userId, + requestId, + projectId, +}: { + repositoryId: Repository['id'] + userId: User['id'] + requestId: string + projectId: Project['id'] +}) { + await deleteRepositoryQuery(repositoryId) + + const { results } = await hook.project.upsert(projectId) + await addLogs({ action: 'Delete Repository', data: results, userId, requestId, projectId }) + if (results.failed) { + return new Unprocessable422('Echec des services à la suppression du dépôt') + } + return null +} diff --git a/.jjconflict-side-2/apps/server/src/resources/repository/queries.ts b/.jjconflict-side-2/apps/server/src/resources/repository/queries.ts new file mode 100644 index 0000000000..277e7af602 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/repository/queries.ts @@ -0,0 +1,62 @@ +import type { Project, Repository } from '@prisma/client' +import prisma from '@/prisma.js' + +// SELECT +export function getRepositoryById(id: Repository['id']) { + return prisma.repository.findUniqueOrThrow({ where: { id } }) +} + +export function getProjectRepositories(projectId: Project['id']) { + return prisma.repository.findMany({ where: { projectId } }) +} + +// CREATE +type RepositoryCreate = Pick + & Partial> + +export function initializeRepository({ projectId, internalRepoName, externalRepoUrl, isInfra, isPrivate, externalUserName, deployRevision, deployPath, helmValuesFiles }: RepositoryCreate) { + return prisma.repository.create({ + data: { + projectId, + internalRepoName, + externalRepoUrl, + externalUserName, + isInfra, + isPrivate, + deployRevision, + deployPath, + helmValuesFiles, + }, + }) +} + +export function getHookRepository(id: Repository['id']) { + return prisma.repository.findUniqueOrThrow({ + where: { + id, + }, + include: { + project: true, + }, + }) +} + +// UPDATE +export function updateRepository(id: Repository['id'], infos: Partial) { + return prisma.repository.update({ where: { id }, data: { ...infos } }) +} + +// DELETE +export async function deleteRepository(id: Repository['id']) { + const doesRepoExist = await getRepositoryById(id) + if (!doesRepoExist) throw new Error('Le dépôt interne demandé n\'existe pas en base pour ce projet') + return prisma.repository.delete({ where: { id } }) +} + +export function deleteAllRepositoryForProject(id: Project['id']) { + return prisma.repository.deleteMany({ where: { projectId: id } }) +} + +export function _createRepository(data: Parameters[0]['create']) { + return prisma.repository.upsert({ create: data, update: data, where: { id: data.id } }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/repository/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/repository/router.spec.ts new file mode 100644 index 0000000000..ae08505be0 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/repository/router.spec.ts @@ -0,0 +1,404 @@ +import { PROJECT_PERMS, repositoryContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { atDates, getProjectMockInfos, getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessCreateMock = vi.spyOn(business, 'createRepository') +const businessUpdateMock = vi.spyOn(business, 'updateRepository') +const businessDeleteMock = vi.spyOn(business, 'deleteRepository') +const businessSyncMock = vi.spyOn(business, 'syncRepository') +const businessGetProjectRepositoriesMock = vi.spyOn(business, 'getProjectRepositories') + +describe('repositoryRouter tests', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const projectId = faker.string.uuid() + const repositoryId = faker.string.uuid() + const repositoryData = { + projectId, + externalRepoUrl: `${faker.internet.url()}.git`, + isPrivate: true, + externalToken: faker.string.alpha(), + externalUserName: faker.internet.username(), + isInfra: false, + internalRepoName: faker.string.alpha({ length: 5, casing: 'lower' }), + } + + describe('listRepositories', () => { + it('should return repositories for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessGetProjectRepositoriesMock.mockResolvedValueOnce([]) + + const response = await app.inject() + .get(repositoryContract.listRepositories.path) + .query({ projectId }) + .end() + + expect(businessGetProjectRepositoriesMock).toHaveBeenCalledWith(projectId) + expect(response.json()).toEqual([]) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.REPLAY_HOOKS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(repositoryContract.listRepositories.path) + .query({ projectId }) + .end() + + expect(businessGetProjectRepositoriesMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('syncRepository', () => { + it('should synchronize repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(null) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(204) + expect(businessSyncMock).toHaveBeenCalledWith({ repositoryId, userId: user.user.id, branchName: 'main', requestId: expect.any(String), syncAllBranches: false }) + }) + + it('should return 403 for forbidden sync attempt', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.SEE_SECRETS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for archived project', async () => { + const projectPerms = getProjectMockInfos({ projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessSyncMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(repositoryContract.syncRepository.path.replace(':repositoryId', repositoryId)) + .body({ branchName: 'main', syncAllBranches: false }) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('createRepository', () => { + it('should create repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...atDates }) + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(201) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData }) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should return 403 for insuficient permissions', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(403) + }) + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(repositoryContract.createRepository.path) + .body(repositoryData) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) + + describe('updateRepository', () => { + const repoUpdateData = { isInfra: true } + it('should update repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...repoUpdateData, ...atDates }) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(200) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData, ...repoUpdateData }) + }) + + it('should update repository and drop creds if is not private', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const repoUpdateData = { isPrivate: false, externalUserName: 'test' } + businessUpdateMock.mockResolvedValueOnce({ id: repositoryId, ...repositoryData, ...repoUpdateData, ...atDates }) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(businessUpdateMock).toHaveBeenCalledWith({ data: { isPrivate: false }, repositoryId, requestId: expect.any(String), userId: user.user.id }) + expect(response.json()).toMatchObject({ id: repositoryId, ...repositoryData, ...repoUpdateData }) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if not enough permissions', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(repositoryContract.updateRepository.path.replace(':repositoryId', repositoryId)) + .body(repoUpdateData) + .end() + + expect(response.statusCode).toEqual(400) + }) + // TODO add tests about filtering + }) + + describe('deleteRepository', () => { + it('should delete repository for authorized user', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 if project is locked', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectLocked: true }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + expect(response.json()).toEqual({ message: 'Le projet est verrouillé' }) + }) + + it('should return 403 if project is archived', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES, projectStatus: 'archived' }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.json()).toEqual({ message: 'Le projet est archivé' }) + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 for non-member', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: 0n }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return 403 if not enough privilege', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.LIST_MEMBERS }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should pass business error', async () => { + const projectPerms = getProjectMockInfos({ projectPermissions: PROJECT_PERMS.MANAGE_REPOSITORIES }) + const user = getUserMockInfos(0n, undefined, projectPerms) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(repositoryContract.deleteRepository.path.replace(':repositoryId', repositoryId)) + .end() + + expect(response.statusCode).toEqual(400) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/repository/router.ts b/.jjconflict-side-2/apps/server/src/resources/repository/router.ts new file mode 100644 index 0000000000..a9e93209ee --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/repository/router.ts @@ -0,0 +1,133 @@ +import { fakeToken, ProjectAuthorized, repositoryContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { filterObjectByKeys } from '@/utils/queries-tools.js' +import { + createRepository, + deleteRepository, + getProjectRepositories, + syncRepository, + updateRepository, +} from './business.js' + +export function repositoryRouter() { + return serverInstance.router(repositoryContract, { + // Récupérer tous les repositories d'un projet + listRepositories: async ({ request: req, query }) => { + const projectId = query.projectId + const perms = await authUser(req, { id: projectId }) + + if (!ProjectAuthorized.ListRepositories(perms)) return new Forbidden403() + const body = await getProjectRepositories(projectId) + + return { + status: 200, + body, + } + }, + + // Synchroniser un repository + syncRepository: async ({ request: req, params, body }) => { + const { repositoryId } = params + const perms = await authUser(req, { repositoryId }) + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const { syncAllBranches, branchName } = body + + const resBody = await syncRepository({ repositoryId, userId: perms.user.id, branchName, requestId: req.id, syncAllBranches }) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 204, + body: resBody, + } + }, + + // Créer un repository + createRepository: async ({ request: req, body: data }) => { + const projectId = data.projectId + const perms = await authUser(req, { id: projectId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await createRepository({ data, userId: perms.user.id, requestId: req.id }) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Mettre à jour un repository + updateRepository: async ({ request: req, params, body }) => { + const repositoryId = params.repositoryId + const perms = await authUser(req, { repositoryId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const keysAllowedForUpdate = [ + 'externalRepoUrl', + 'isPrivate', + 'externalToken', + 'externalUserName', + 'isInfra', + 'deployRevision', + 'deployPath', + 'helmValuesFiles', + ] + const data = filterObjectByKeys(body, keysAllowedForUpdate) + + if (data.externalToken === fakeToken) { + delete data.externalToken + } + + if (data.isPrivate === false) { + delete data.externalToken + delete data.externalUserName + } + + const resBody = await updateRepository({ repositoryId, data, userId: perms.user.id, requestId: req.id }) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 200, + body: resBody, + } + }, + + // Supprimer un repository + deleteRepository: async ({ request: req, params }) => { + const repositoryId = params.repositoryId + const perms = await authUser(req, { repositoryId }) + + if (!perms.user) return new Unauthorized401('Require to be requested from user not api key') + if (!ProjectAuthorized.ManageRepositories(perms)) return new Forbidden403() + if (perms.projectLocked) return new Forbidden403('Le projet est verrouillé') + if (perms.projectStatus === 'archived') return new Forbidden403('Le projet est archivé') + + const body = await deleteRepository({ + repositoryId, + userId: perms.user.id, + requestId: req.id, + projectId: perms.projectId, + }) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/service-chain/business.spec.ts b/.jjconflict-side-2/apps/server/src/resources/service-chain/business.spec.ts new file mode 100644 index 0000000000..a3903e6eff --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/service-chain/business.spec.ts @@ -0,0 +1,171 @@ +import type { + ServiceChain, + ServiceChainDetails, + ServiceChainFlows, +} from '@cpn-console/shared' +import type { Mock } from 'vitest' +import { + serviceChainEnvironmentEnum, + serviceChainFlowStateEnum, + serviceChainLocationEnum, + serviceChainNetworkEnum, + serviceChainStateEnum, +} from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import axios from 'axios' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + getServiceChainDetails, + getServiceChainFlows, + listServiceChains, + retryServiceChain, + validateServiceChain, +} from './business.ts' + +vi.mock('axios') + +let serviceChain: ServiceChain +let serviceChainDetails: ServiceChainDetails +let serviceChainFlows: ServiceChainFlows + +describe('test ServiceChain business logic', () => { + beforeEach(() => { + serviceChain = { + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + } + + serviceChainDetails = { + ...serviceChain, + validationId: faker.string.uuid(), + validatedBy: faker.helpers.maybe(() => faker.string.uuid()) || null, + ref: faker.string.uuid(), + location: faker.helpers.arrayElement(serviceChainLocationEnum), + targetAddress: faker.internet.ipv4(), + projectId: faker.string.uuid(), + env: faker.helpers.arrayElement(serviceChainEnvironmentEnum), + subjectAlternativeName: faker.helpers.uniqueArray( + faker.internet.domainName, + 3, + ), + redirect: faker.datatype.boolean(), + antivirus: + faker.helpers.maybe(() => ({ + maxFileSize: faker.number.int(), + })) || null, // undefined is not wanted here + websocket: faker.datatype.boolean(), + ipWhiteList: faker.helpers + .uniqueArray(faker.internet.ipv4, 5) + .map(e => `${e}/32`), // We want a CIDR here + sslOutgoing: faker.datatype.boolean(), + } + + serviceChainFlows = { + reserve_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + create_cert: faker.helpers.maybe(() => ({ + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + })) || null, + call_exec: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + activate_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + dns_request: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + } + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('listServiceChains', () => { + it('should return a list of service chains', async () => { + const input = [serviceChain]; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await listServiceChains() + + expect(result).toStrictEqual(input) + }) + }) + + describe('getServiceChainDetails', () => { + it('should return a service chain details', async () => { + const input = serviceChainDetails; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await getServiceChainDetails(faker.string.uuid()) + + expect(result).toStrictEqual(input) + }) + }) + + describe('retryServiceChain', () => { + it('should trigger a service chain retry attempt', async () => { + const input = {}; + (axios.create as Mock).mockReturnValue({ + post: () => ({ data: input }), + }) + + const result = await retryServiceChain(faker.string.uuid()) + + expect(result.data).toStrictEqual(input) + }) + }) + + describe('validateServiceChain', () => { + it('should trigger a service chain validate attempt', async () => { + const input = {}; + (axios.create as Mock).mockReturnValue({ + post: () => ({ data: input }), + }) + + const result = await validateServiceChain(faker.string.uuid()) + + expect(result.data).toStrictEqual(input) + }) + }) + + describe('getServiceChainFlows', () => { + it('should return a service chain flows', async () => { + const input = serviceChainFlows; + (axios.create as Mock).mockReturnValue({ + get: () => ({ data: input }), + }) + + const result = await getServiceChainFlows(faker.string.uuid()) + + expect(result).toStrictEqual(input) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/service-chain/business.ts b/.jjconflict-side-2/apps/server/src/resources/service-chain/business.ts new file mode 100644 index 0000000000..43ffb986e6 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/service-chain/business.ts @@ -0,0 +1,27 @@ +import { + getServiceChainDetails as getServiceChainDetailsQuery, + getServiceChainFlows as getServiceChainFlowsQuery, + listServiceChains as listServiceChainsQuery, + retryServiceChain as retryServiceChainQuery, + validateServiceChain as validateServiceChainQuery, +} from '@/resources/queries-index.js' + +export async function listServiceChains() { + return listServiceChainsQuery() +} + +export async function getServiceChainDetails(serviceChainId: string) { + return getServiceChainDetailsQuery(serviceChainId) +} + +export async function retryServiceChain(serviceChainId: string) { + return retryServiceChainQuery(serviceChainId) +} + +export async function validateServiceChain(validationId: string) { + return validateServiceChainQuery(validationId) +} + +export async function getServiceChainFlows(serviceChainId: string) { + return getServiceChainFlowsQuery(serviceChainId) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/service-chain/queries.ts b/.jjconflict-side-2/apps/server/src/resources/service-chain/queries.ts new file mode 100644 index 0000000000..0207266c8a --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/service-chain/queries.ts @@ -0,0 +1,59 @@ +import type { ServiceChain } from '@cpn-console/shared' +import https from 'node:https' +import { + + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainListSchema, +} from '@cpn-console/shared' +import axios from 'axios' + +const openCDSEnvVar = 'OPENCDS_URL' +const openCDSTargetURL = process.env[openCDSEnvVar] +const openCDSDisabledErrorMessage = `OpenCDS is disabled, please set ${openCDSEnvVar} in your relevant .env file. See .env-example` + +function getClient() { + if (!openCDSTargetURL) { + throw new Error(openCDSDisabledErrorMessage) + } + return axios.create({ + baseURL: openCDSTargetURL, + httpsAgent: new https.Agent({ + rejectUnauthorized: + // We want it to be `false` only if it has explicitly + // been stated as "false" in the env vars + process.env.OPENCDS_API_TLS_REJECT_UNAUTHORIZED !== 'false', + }), + headers: { + 'X-API-Key': process.env.OPENCDS_API_TOKEN, + }, + }) +} + +export async function listServiceChains() { + return ServiceChainListSchema.parse( + (await getClient().get(`/requests`)).data, + ) +} + +export async function getServiceChainDetails( + serviceChainId: ServiceChain['id'], +) { + return ServiceChainDetailsSchema.parse( + (await getClient().get(`/requests/${serviceChainId}`)).data, + ) +} + +export async function retryServiceChain(serviceChainId: ServiceChain['id']) { + return await getClient().post(`/requests/${serviceChainId}/retry`) +} + +export async function validateServiceChain(validationId: string) { + return await getClient().post(`/validate/${validationId}`) +} + +export async function getServiceChainFlows(serviceChainId: ServiceChain['id']) { + return ServiceChainFlowsSchema.parse( + (await getClient().get(`/requests/${serviceChainId}/flows`)).data, + ) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/service-chain/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/service-chain/router.spec.ts new file mode 100644 index 0000000000..c69c14edff --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/service-chain/router.spec.ts @@ -0,0 +1,307 @@ +import type { ServiceChain, ServiceChainDetails, ServiceChainFlows } from '@cpn-console/shared' +import { + ADMIN_PERMS, + serviceChainContract, + ServiceChainDetailsSchema, + serviceChainEnvironmentEnum, + ServiceChainFlowsSchema, + serviceChainFlowStateEnum, + ServiceChainListSchema, + serviceChainLocationEnum, + serviceChainNetworkEnum, + serviceChainStateEnum, +} from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock( + 'fastify-keycloak-adapter', + (await import('../../utils/mocks.js')).mockSessionPlugin, +) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListServiceChainsMock = vi.spyOn(business, 'listServiceChains') +const businessGetServiceChainDetailsMock = vi.spyOn(business, 'getServiceChainDetails') +const businessRetryServiceChainMock = vi.spyOn(business, 'retryServiceChain') +const businessValidateServiceChainMock = vi.spyOn(business, 'validateServiceChain') +const businessGetServiceChainsFlowsMock = vi.spyOn(business, 'getServiceChainFlows') + +describe('test ServiceChainContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listServiceChains', () => { + it('as non admin', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + businessListServiceChainsMock.mockResolvedValueOnce([]) + const response = await app + .inject() + .get(serviceChainContract.listServiceChains.path) + .end() + + expect(response.json()).toStrictEqual([]) + expect(response.statusCode).toEqual(200) + }) + it('as admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const serviceChainList = faker.helpers.multiple(() => ({ + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + })) + + authUserMock.mockResolvedValueOnce(user) + + businessListServiceChainsMock.mockResolvedValueOnce(serviceChainList) + const response = await app + .inject() + .get(serviceChainContract.listServiceChains.path) + .end() + + expect(businessListServiceChainsMock).toHaveBeenCalledWith() + + expect(ServiceChainListSchema.parse(response.json())).toStrictEqual( + serviceChainList, + ) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getServiceChainDetails', () => { + it('should return serviceChain details', async () => { + const serviceChainDetails: ServiceChainDetails = { + id: faker.string.uuid(), + state: faker.helpers.arrayElement(serviceChainStateEnum), + commonName: `${faker.string.alpha(3)}.${faker.string.alpha(3)}.minint.fr`, + pai: faker.string.alpha(3).toUpperCase(), + network: faker.helpers.arrayElement(serviceChainNetworkEnum), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + validationId: faker.string.uuid(), + validatedBy: faker.string.uuid(), + ref: faker.string.uuid(), + location: faker.helpers.arrayElement(serviceChainLocationEnum), + targetAddress: faker.internet.ipv4(), + projectId: faker.string.uuid(), + env: faker.helpers.arrayElement(serviceChainEnvironmentEnum), + subjectAlternativeName: faker.helpers.uniqueArray( + faker.internet.domainName, + 3, + ), + redirect: faker.datatype.boolean(), + antivirus: + faker.helpers.maybe(() => ({ + maxFileSize: faker.number.int(), + })) || null, // undefined is not wanted here + websocket: faker.datatype.boolean(), + ipWhiteList: faker.helpers + .uniqueArray(faker.internet.ipv4, 5) + .map(e => `${e}/32`), // We want a CIDR here + sslOutgoing: faker.datatype.boolean(), + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessGetServiceChainDetailsMock.mockResolvedValueOnce(serviceChainDetails) + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainDetails.path.replace( + ':serviceChainId', + serviceChainDetails.id, + ), + ) + .end() + + expect(ServiceChainDetailsSchema.parse(response.json())).toEqual( + serviceChainDetails, + ) + expect(response.statusCode).toEqual(200) + expect(businessGetServiceChainDetailsMock).toHaveBeenCalledTimes(1) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainDetails.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessGetServiceChainDetailsMock).toHaveBeenCalledTimes(0) + }) + }) + + describe('retryServiceChain', () => { + it('should return 204', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessRetryServiceChainMock.mockResolvedValueOnce({ + status: 204, + body: undefined, + }) + const response = await app + .inject() + .post( + serviceChainContract.retryServiceChain.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.body).toEqual('') + expect(businessRetryServiceChainMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(204) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .post( + serviceChainContract.retryServiceChain.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessRetryServiceChainMock).toHaveBeenCalledTimes(0) + }) + }) + + describe('validateServiceChain', () => { + it('should return 204', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessValidateServiceChainMock.mockResolvedValueOnce({ + status: 204, + body: undefined, + }) + const response = await app + .inject() + .post( + serviceChainContract.validateServiceChain.path.replace( + ':validationId', + faker.string.uuid(), + ), + ) + .end() + + expect(businessValidateServiceChainMock).toHaveBeenCalledTimes(1) + expect(response.body).toEqual('') + expect(response.statusCode).toEqual(204) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .post( + serviceChainContract.validateServiceChain.path.replace( + ':validationId', + faker.string.uuid(), + ), + ) + .end() + + expect(businessValidateServiceChainMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('getServiceChainFlows', () => { + it('should return serviceChain flows', async () => { + const serviceChainFlows: ServiceChainFlows = { + reserve_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + create_cert: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + call_exec: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + activate_ip: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + dns_request: { + state: faker.helpers.arrayElement(serviceChainFlowStateEnum), + input: '{ "foo": 0, "bar": true, "qux": "test" }', + output: '{ "foo": 0, "bar": true, "qux": "test" }', + updatedAt: faker.date.recent(), + }, + } + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + authUserMock.mockResolvedValueOnce(user) + + businessGetServiceChainsFlowsMock.mockResolvedValueOnce(serviceChainFlows) + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainFlows.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(ServiceChainFlowsSchema.parse(response.json())).toEqual( + serviceChainFlows, + ) + expect(response.statusCode).toEqual(200) + expect(businessGetServiceChainsFlowsMock).toHaveBeenCalledTimes(1) + }) + it('should return 403 if not admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app + .inject() + .get( + serviceChainContract.getServiceChainFlows.path.replace( + ':serviceChainId', + faker.string.uuid(), + ), + ) + .end() + + expect(response.statusCode).toEqual(403) + expect(businessGetServiceChainsFlowsMock).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/service-chain/router.ts b/.jjconflict-side-2/apps/server/src/resources/service-chain/router.ts new file mode 100644 index 0000000000..5888418cfe --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/service-chain/router.ts @@ -0,0 +1,94 @@ +import type { AsyncReturnType } from '@cpn-console/shared' +import { AdminAuthorized, serviceChainContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { + getServiceChainDetails as getServiceChainDetailsBusiness, + getServiceChainFlows as getServiceChainFlowsBusiness, + listServiceChains as listServiceChainsBusiness, + retryServiceChain as retryServiceChainBusiness, + validateServiceChain as validateServiceChainBusiness, +} from './business.js' +import '@/types/index.js' + +export function serviceChainRouter() { + return serverInstance.router(serviceChainContract, { + listServiceChains: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + let body: AsyncReturnType = [] + if (AdminAuthorized.ListSystem(adminPermissions)) { + body = await listServiceChainsBusiness() + } + + return { + status: 200, + body, + } + }, + + getServiceChainDetails: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + const serviceChainDetails + = await getServiceChainDetailsBusiness(serviceChainId) + + return { + status: 200, + body: serviceChainDetails, + } + }, + + retryServiceChain: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + await retryServiceChainBusiness(serviceChainId) + + return { + status: 204, + body: null, + } + }, + + validateServiceChain: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.validationId + await validateServiceChainBusiness(serviceChainId) + + return { + status: 204, + body: null, + } + }, + + getServiceChainFlows: async ({ params, request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) + return new Forbidden403() + + const serviceChainId = params.serviceChainId + const serviceChainFlows + = await getServiceChainFlowsBusiness(serviceChainId) + + return { + status: 200, + body: serviceChainFlows, + } + }, + + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/service-monitor/business.ts b/.jjconflict-side-2/apps/server/src/resources/service-monitor/business.ts new file mode 100644 index 0000000000..fa61d5a6de --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/service-monitor/business.ts @@ -0,0 +1,9 @@ +import { services } from '@cpn-console/hooks' + +export function checkServicesHealth() { + return services.getStatus() +} + +export async function refreshServicesHealth() { + return Promise.all(services.refreshStatus()) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/service-monitor/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/service-monitor/router.spec.ts new file mode 100644 index 0000000000..898ed61e5a --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/service-monitor/router.spec.ts @@ -0,0 +1,78 @@ +import type { ServiceStatus } from '@cpn-console/hooks' +import { ADMIN_PERMS, MonitorStatus, serviceContract } from '@cpn-console/shared' +import { describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +const authUserMock = vi.spyOn(utilsController, 'authUser') + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const businessCheckMock = vi.spyOn(business, 'checkServicesHealth') +const businessRefreshMock = vi.spyOn(business, 'refreshServicesHealth') + +describe('test serviceContract', () => { + const services: ServiceStatus[] = [{ interval: 1, lastUpdateTimestamp: 1, message: 'OK', name: 'A service', status: MonitorStatus.OK }] + const servicesComplete: ServiceStatus[] = [{ cause: 'error', interval: 1, lastUpdateTimestamp: 1, message: 'OK', name: 'A service', status: MonitorStatus.OK }] + + it('should return complete services, with cause', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(servicesComplete) + expect(response.statusCode).toEqual(200) + }) + + it('should not return complete services, forbidden', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.statusCode).toEqual(403) + }) + + it('should return services', async () => { + businessCheckMock.mockReturnValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(services) + expect(response.statusCode).toEqual(200) + }) + + it('should refresh services', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessRefreshMock.mockResolvedValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.json()).toStrictEqual(servicesComplete) + expect(response.statusCode).toEqual(200) + }) + + it('should refresh services, cause forbidden', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + businessRefreshMock.mockResolvedValue(servicesComplete) + const response = await app.inject() + .get(serviceContract.getCompleteServiceHealth.path) + .end() + + expect(response.statusCode).toEqual(403) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/service-monitor/router.ts b/.jjconflict-side-2/apps/server/src/resources/service-monitor/router.ts new file mode 100644 index 0000000000..ace5831c3e --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/service-monitor/router.ts @@ -0,0 +1,44 @@ +import { AdminAuthorized, serviceContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { checkServicesHealth, refreshServicesHealth } from './business.js' + +export function serviceMonitorRouter() { + return serverInstance.router(serviceContract, { + getServiceHealth: async () => { + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + + getCompleteServiceHealth: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ListSystem(adminPermissions)) return new Forbidden403() + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + + refreshServiceHealth: async ({ request: req }) => { + const { adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageSystem(adminPermissions)) return new Forbidden403() + + await refreshServicesHealth() + const serviceData = checkServicesHealth() + + return { + status: 200, + body: serviceData, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/stage/business.spec.ts b/.jjconflict-side-2/apps/server/src/resources/stage/business.spec.ts new file mode 100644 index 0000000000..ba43c2cb75 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/stage/business.spec.ts @@ -0,0 +1,113 @@ +import type { Environment, Stage } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { BadRequest400, NotFound404 } from '../../utils/errors.ts' +import { createStage, deleteStage, getStageAssociatedEnvironments, listStages, updateStage } from './business.ts' + +describe('test stage busines logic', () => { + let stage: Stage + beforeEach(() => { + vi.resetAllMocks() + stage = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + } + }) + describe('createStage', () => { + it('should create a stage', async () => { + prisma.stage.findUnique.mockResolvedValue(null) + prisma.stage.create.mockResolvedValue({ id: stage.id } as Stage) + await createStage({ name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(1) + }) + it('should not create a stage, name conflict', async () => { + prisma.stage.findUnique.mockResolvedValue({ id: stage.id } as Stage) + const response = await createStage({ name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) + + describe('updateStage', () => { + it('should update a stage', async () => { + const dbClusters = [{ id: faker.string.uuid() }] + const newClusters = [faker.string.uuid()] + prisma.stage.findUnique.mockResolvedValue({ ...stage, clusters: dbClusters } as Stage) + prisma.stage.update.mockResolvedValue({ id: stage.id } as Stage) + const response = await updateStage(stage.id, { name: stage.name, clusterIds: newClusters }) + expect(prisma.cluster.update).toHaveBeenCalledTimes(1) + expect(prisma.cluster.update).toHaveBeenCalledWith({ where: { id: dbClusters[0].id }, data: { + stages: { + disconnect: { + id: stage.id, + }, + }, + } }) + expect(prisma.stage.update).toHaveBeenCalledTimes(1) + expect(prisma.stage.update).toHaveBeenCalledWith({ where: { id: stage.id }, data: { + clusters: { + connect: [{ + id: newClusters[0], + }], + }, + } }) + expect(response.clusterIds).toBe(newClusters) + }) + it('should do nothing', async () => { + prisma.stage.findUnique.mockResolvedValue({ ...stage, clusters: [] } as Stage) + await updateStage(stage.id, { clusterIds: [], name: stage.name }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + }) + it('should return not found', async () => { + prisma.stage.findUnique.mockResolvedValue(null) + const response = await updateStage(stage.id, { name: stage.name, clusterIds: [faker.string.uuid()] }) + expect(prisma.stage.update).toHaveBeenCalledTimes(0) + expect(response).instanceOf(NotFound404) + }) + }) + + describe('deleteStage', () => { + it('should delete a stage', async () => { + prisma.environment.findFirst.mockResolvedValue(null) + prisma.stage.delete.mockResolvedValue({ id: stage.id } as Stage) + await deleteStage(stage.id) + expect(prisma.stage.delete).toHaveBeenCalledTimes(1) + }) + it('should not delete a stage, environment attached', async () => { + prisma.environment.findFirst.mockResolvedValue({ id: faker.string.uuid() } as Environment) + const response = await deleteStage(stage.id) + expect(prisma.stage.delete).toHaveBeenCalledTimes(0) + expect(response).instanceOf(BadRequest400) + }) + }) + + describe('listStages', () => { + const clusterAssociated = [{ id: faker.string.uuid() }] + it('should list all stages (admin, no userId provided)', async () => { + prisma.stage.findMany.mockResolvedValue([{ clusters: clusterAssociated }] as unknown as Stage[]) + const response = await listStages() + expect(response[0].clusterIds).toStrictEqual([clusterAssociated[0].id]) + expect(prisma.stage.findMany).toHaveBeenCalledTimes(1) + expect(prisma.stage.findMany).toHaveBeenCalledWith({ include: { clusters: true } }) + }) + }) + + describe('getStageAssociatedEnvironments', () => { + it('should list all environments attached to a stage stages', async () => { + const envName = faker.string.alpha(8) + const projectSlug = faker.string.alpha(8) + const clusterLabel = faker.string.alpha(8) + const ownerEmail = faker.internet.email() + const envs = [{ name: envName, project: { slug: projectSlug, owner: { email: ownerEmail } }, cluster: { label: clusterLabel } }] + prisma.environment.findMany.mockResolvedValue(envs as unknown as Environment[]) + const response = await getStageAssociatedEnvironments(stage.id) + expect(response).toStrictEqual([{ + name: envName, + project: projectSlug, + owner: ownerEmail, + cluster: clusterLabel, + }]) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/stage/business.ts b/.jjconflict-side-2/apps/server/src/resources/stage/business.ts new file mode 100644 index 0000000000..db6faf686e --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/stage/business.ts @@ -0,0 +1,97 @@ +import type { CreateStageBody, UpdateStageBody } from '@cpn-console/shared' +import type { Cluster, Stage } from '@prisma/client' +import prisma from '@/prisma.js' +import { + createStage as createStageQuery, + deleteStage as deleteStageQuery, + getAllStageIds, + getStageAssociatedEnvironmentById, + getStageById, + getStageByName, + linkClusterToStages as linkClusterToStagesQuery, + linkStageToClusters, + listStages as listStagesQuery, + removeClusterFromStage, + updateStageName, +} from '@/resources/queries-index.js' +import { BadRequest400, NotFound404 } from '@/utils/errors.js' + +export async function getStageAssociatedEnvironments(stageId: Stage['id']) { + const environments = await getStageAssociatedEnvironmentById(stageId) + return environments.map(env => ({ + project: env.project.slug, + name: env.name, + cluster: env.cluster.label, + owner: env.project.owner.email, + })) +} + +export async function createStage({ clusterIds = [], name }: CreateStageBody) { + const isNameTaken = await getStageByName(name) + if (isNameTaken) return new BadRequest400('Un type d\'environnement portant ce nom existe déjà') + + const stage = await createStageQuery({ name }) + + if (clusterIds.length) { + await linkStageToClusters(stage.id, clusterIds) + } + + return { + id: stage.id, + name: stage.name, + clusterIds, + } +} + +export async function updateStage(stageId: Stage['id'], { clusterIds, name }: UpdateStageBody) { + const dbStage = await getStageById(stageId) + if (!dbStage) return new NotFound404() + if (name !== dbStage.name) { + await updateStageName(stageId, name) + } + // Remove clusters + const dbClusters = dbStage.clusters + if (dbClusters?.length) { + const clustersToRemove = dbClusters.filter(dbCluster => !clusterIds.includes(dbCluster.id)) + for (const clusterToRemove of clustersToRemove) { + await removeClusterFromStage(clusterToRemove.id, stageId) + } + } + // Add clusters + if (clusterIds.length) { + await linkStageToClusters(stageId, clusterIds) + } + + return { + id: stageId, + name: name ?? dbStage.name, + clusterIds: clusterIds ?? dbStage.clusters.map(({ id }) => id), + } +} + +export async function deleteStage(stageId: Stage['id']) { + const attachedEnvironment = await prisma.environment.findFirst({ where: { stageId }, select: { id: true } }) + if (attachedEnvironment) return new BadRequest400('Impossible de supprimer le stage, des environnements en activité y ont souscrit') + + await deleteStageQuery(stageId) + return null +} + +export async function listStages() { + const stages = await listStagesQuery() + + return stages.map((stage) => { + return { + id: stage.id, + name: stage.name, + clusterIds: stage.clusters.map(({ id }) => id), + } + }) +} + +export async function linkClusterToStages(clusterId: Cluster['id'], stageIds: Stage['id'][], linkToAll: boolean = false) { + if (linkToAll === true) { + stageIds = await getAllStageIds() + } + await linkClusterToStagesQuery(clusterId, stageIds) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/stage/queries.ts b/.jjconflict-side-2/apps/server/src/resources/stage/queries.ts new file mode 100644 index 0000000000..98d526600a --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/stage/queries.ts @@ -0,0 +1,111 @@ +import type { Cluster, Stage } from '@prisma/client' +import prisma from '@/prisma.js' + +export function listStages() { + return prisma.stage.findMany({ + include: { + clusters: true, + }, + }) +} + +export async function getAllStageIds() { + return (await prisma.stage.findMany({ + select: { + id: true, + }, + })).map(({ id }) => id) +} + +export function getStageById(id: Stage['id']) { + return prisma.stage.findUnique({ + where: { id }, + include: { + clusters: true, + }, + }) +} + +export function getStageByIdOrThrow(id: Stage['id']) { + return prisma.stage.findUniqueOrThrow({ + where: { id }, + include: { + clusters: true, + }, + }) +} + +export function getStageAssociatedEnvironmentById(id: Stage['id']) { + return prisma.environment.findMany({ + where: { + stageId: id, + }, + select: { + name: true, + cluster: { + select: { + label: true, + }, + }, + project: { + select: { + name: true, + owner: true, + slug: true, + }, + }, + }, + }) +} + +export function getStageAssociatedEnvironmentLengthById(id: Stage['id']) { + return prisma.environment.count({ + where: { + stageId: id, + }, + }) +} + +export function getStageByName(name: Stage['name']) { + return prisma.stage.findUnique({ + where: { name }, + }) +} + +export function linkStageToClusters(id: Stage['id'], clusterIds: Cluster['id'][]) { + return prisma.stage.update({ + where: { + id, + }, + data: { + clusters: { + connect: clusterIds.map(clusterId => ({ id: clusterId })), + }, + }, + }) +} + +export function createStage({ name }: { name: Stage['name'] }) { + return prisma.stage.create({ + data: { + name, + }, + }) +} + +export function updateStageName(id: Stage['id'], name: Stage['name']) { + return prisma.stage.update({ + where: { + id, + }, + data: { + name, + }, + }) +} + +export function deleteStage(id: Stage['id']) { + return prisma.stage.delete({ + where: { id }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/stage/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/stage/router.spec.ts new file mode 100644 index 0000000000..3e62a482fc --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/stage/router.spec.ts @@ -0,0 +1,204 @@ +import type { Stage } from '@cpn-console/shared' +import { ADMIN_PERMS, stageContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listStages') +const businessGetEnvironmentsMock = vi.spyOn(business, 'getStageAssociatedEnvironments') +const businessCreateMock = vi.spyOn(business, 'createStage') +const businessUpdateMock = vi.spyOn(business, 'updateStage') +const businessDeleteMock = vi.spyOn(business, 'deleteStage') + +describe('test stageContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listStages', () => { + it('should return list of stages', async () => { + const stages: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_STAGES) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(stages) + + const response = await app.inject() + .get(stageContract.listStages.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(stages) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getStageEnvironments', () => { + it('should return stage environments for admin', async () => { + const environments: any = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(environments) + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(environments) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessGetEnvironmentsMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(stageContract.getStageEnvironments.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessGetEnvironmentsMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('createStage', () => { + const stage: Stage = { id: faker.string.uuid(), name: faker.string.alpha({ length: 5 }), clusterIds: [] } + + it('should create and return stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(stage) + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(stage) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(stageContract.createStage.path) + .body(stage) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateStage', () => { + const stageId = faker.string.uuid() + const stage = { name: faker.string.alpha({ length: 5 }), clusterIds: [] } + + it('should update and return stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: stageId, ...stage }) + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: stageId, ...stage }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(stageContract.updateStage.path.replace(':stageId', stageId)) + .body(stage) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteStage', () => { + it('should delete stage for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_STAGES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(stageContract.deleteStage.path.replace(':stageId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/stage/router.ts b/.jjconflict-side-2/apps/server/src/resources/stage/router.ts new file mode 100644 index 0000000000..73202d4549 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/stage/router.ts @@ -0,0 +1,92 @@ +import { AdminAuthorized, stageContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { + createStage, + deleteStage, + getStageAssociatedEnvironments, + listStages, + updateStage, +} from './business.js' + +export function stageRouter() { + return serverInstance.router(stageContract, { + + // Récupérer les types d'environnement disponibles + listStages: async () => { + const body = await listStages() + + return { + status: 200, + body, + } + }, + + // Récupérer les environnements associés au stage + getStageEnvironments: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + const body = await getStageAssociatedEnvironments(stageId) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Créer un stage + createStage: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const body = await createStage(data) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + // Modifier une association stage / clusters + updateStage: async ({ request: req, params, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + + const body = await updateStage(stageId, data) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + // Supprimer un stage + deleteStage: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageStages(perms.adminPermissions)) return new Forbidden403() + + const stageId = params.stageId + + const body = await deleteStage(stageId) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/system/config/business.spec.ts b/.jjconflict-side-2/apps/server/src/resources/system/config/business.spec.ts new file mode 100644 index 0000000000..35d4844070 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/system/config/business.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import prisma from '../../../__mocks__/prisma.js' +import { objToDb, updatePluginConfig } from './business.ts' + +describe('test system/config business', () => { + const config = { test: { key1: 'value1' } } + it('should transform object to db row', () => { + const response = objToDb({ test: { key1: 'value1' } }) + expect(response).toEqual([{ pluginName: 'test', key: 'key1', value: 'value1' }]) + }) + describe('updatePluginConfig', () => { + it('should update', async () => { + prisma.adminPlugin.upsert.mockResolvedValue(null) + await updatePluginConfig(config) + }) + it('should update 0 items cause missing manifest', async () => { + // @ts-ignore + await updatePluginConfig({ test: { key: 1 } }) + expect(prisma.adminPlugin.upsert).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/system/config/business.ts b/.jjconflict-side-2/apps/server/src/resources/system/config/business.ts new file mode 100644 index 0000000000..111cb4cd1b --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/system/config/business.ts @@ -0,0 +1,50 @@ +import type { + PluginsUpdateBody, +} from '@cpn-console/shared' +import { editStrippers, populatePluginManifests, servicesInfos } from '@cpn-console/hooks' +import { BadRequest400 } from '@/utils/errors.js' +import { + getAdminPlugin, + savePluginsConfig, +} from './queries.js' + +export type ConfigRecords = { + key: string + pluginName: string + value: string +}[] + +export function objToDb(obj: PluginsUpdateBody): ConfigRecords { + return Object.entries(obj) + .map(([pluginName, values]) => Object.entries(values) + .map(([key, value]) => ({ pluginName, key, value }))) + .flat() +} + +export async function getPluginsConfig() { + const globalConfig = await getAdminPlugin() + + return Object.values(servicesInfos).map(({ name, title, imgSrc, description }) => { + const manifest = populatePluginManifests({ + data: { + global: globalConfig, + }, + permissionTarget: 'admin', + pluginName: name, + select: { + global: true, + project: false, + }, + }) + return { imgSrc, title, name, manifest: manifest.global ?? [], description } + }).filter(plugin => plugin.manifest.length > 0) +} + +export async function updatePluginConfig(data: PluginsUpdateBody) { + const parsedData = editStrippers.global.safeParse(data) + if (!parsedData.success) return new BadRequest400(parsedData.error.message) + const records = objToDb(parsedData.data) + + await savePluginsConfig(records) + return null +} diff --git a/.jjconflict-side-2/apps/server/src/resources/system/config/queries.ts b/.jjconflict-side-2/apps/server/src/resources/system/config/queries.ts new file mode 100644 index 0000000000..69808e506a --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/system/config/queries.ts @@ -0,0 +1,28 @@ +import type { ConfigRecords } from './business.js' +import prisma from '@/prisma.js' + +// CONFIG +export const getAdminPlugin = prisma.adminPlugin.findMany + +export async function savePluginsConfig(records: ConfigRecords) { + for (const { pluginName, key, value } of records) { + await prisma.adminPlugin.upsert({ + create: { + pluginName, + key, + value: String(value), + }, + update: { + key, + value: String(value), + pluginName, + }, + where: { + pluginName_key: { + pluginName, + key, + }, + }, + }) + } +} diff --git a/.jjconflict-side-2/apps/server/src/resources/system/config/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/system/config/router.spec.ts new file mode 100644 index 0000000000..423a1b7b38 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/system/config/router.spec.ts @@ -0,0 +1,96 @@ +import { ADMIN_PERMS, systemPluginContract } from '@cpn-console/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../../app.js' +import * as utilsController from '../../../utils/controller.js' +import { BadRequest400 } from '../../../utils/errors.js' +import { getUserMockInfos } from '../../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetPluginsConfigMock = vi.spyOn(business, 'getPluginsConfig') +const businessUpdatePluginConfigMock = vi.spyOn(business, 'updatePluginConfig') + +describe('test systemPluginContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getPluginsConfig', () => { + it('should return plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const pluginsConfig: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetPluginsConfigMock.mockResolvedValueOnce(pluginsConfig) + + const response = await app.inject() + .get(systemPluginContract.getPluginsConfig.path) + .end() + + expect(businessGetPluginsConfigMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(pluginsConfig) + expect(response.statusCode).toEqual(200) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(systemPluginContract.getPluginsConfig.path) + .end() + + expect(businessGetPluginsConfigMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updatePluginsConfig', () => { + const newConfig = { plugin1: { keyId: 'value' } } + it('should update plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpdatePluginConfigMock.mockResolvedValueOnce(newConfig) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(204) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + + it('should return error if business logic fails', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpdatePluginConfigMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + + const response = await app.inject() + .post(systemPluginContract.updatePluginsConfig.path) + .body(newConfig) + .end() + + expect(businessUpdatePluginConfigMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(400) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/system/config/router.ts b/.jjconflict-side-2/apps/server/src/resources/system/config/router.ts new file mode 100644 index 0000000000..d541dc1778 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/system/config/router.ts @@ -0,0 +1,38 @@ +import { AdminAuthorized, systemPluginContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { getPluginsConfig, updatePluginConfig } from './business.js' + +export function pluginConfigRouter() { + return serverInstance.router(systemPluginContract, { + // Récupérer les configurations plugins + getPluginsConfig: async ({ request: req }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ListSystem(perms.adminPermissions)) return new Forbidden403() + + const services = await getPluginsConfig() + + return { + status: 200, + body: services, + + } + }, + // Mettre à jour les configurations plugins + updatePluginsConfig: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) return new Forbidden403() + + const resBody = await updatePluginConfig(body) + if (resBody instanceof ErrorResType) return resBody + + return { + status: 204, + body: resBody, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/system/index.ts b/.jjconflict-side-2/apps/server/src/resources/system/index.ts new file mode 100644 index 0000000000..a45d7accc7 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/system/index.ts @@ -0,0 +1 @@ +export * from './router.js' diff --git a/.jjconflict-side-2/apps/server/src/resources/system/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/system/router.spec.ts new file mode 100644 index 0000000000..62ad30d7f8 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/system/router.spec.ts @@ -0,0 +1,25 @@ +import { systemContract } from '@cpn-console/shared' +import { describe, expect, it, vi } from 'vitest' +import app from '../../app.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) + +describe('system - router', () => { + it('should send application version', async () => { + const response = await app.inject() + .get(systemContract.getVersion.path) + .end() + + expect(response.statusCode).toBe(200) + expect(response.json()).toStrictEqual({ version: process.env.APP_VERSION || 'dev' }) + }) + + it('should send application health with status OK', async () => { + const response = await app.inject() + .get(systemContract.getHealth.path) + .end() + + expect(response.statusCode).toBe(200) + expect(response.json()).toStrictEqual({ status: 'OK' }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/system/router.ts b/.jjconflict-side-2/apps/server/src/resources/system/router.ts new file mode 100644 index 0000000000..9976884963 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/system/router.ts @@ -0,0 +1,21 @@ +import { systemContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { appVersion } from '@/utils/env.js' + +export function systemRouter() { + return serverInstance.router(systemContract, { + getVersion: async () => ({ + status: 200, + body: { + version: appVersion, + }, + }), + + getHealth: async () => ({ + status: 200, + body: { + status: 'OK', + }, + }), + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/system/settings/business.ts b/.jjconflict-side-2/apps/server/src/resources/system/settings/business.ts new file mode 100644 index 0000000000..5e562353b0 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/system/settings/business.ts @@ -0,0 +1,9 @@ +import type { UpsertSystemSettingBody } from '@cpn-console/shared' +import { + getSystemSettings as getSystemSettingsQuery, + upsertSystemSetting as upsertSystemSettingQuery, +} from './queries.js' + +export const getSystemSettings = (key?: string) => getSystemSettingsQuery({ key }) + +export const upsertSystemSetting = (newSystemSetting: UpsertSystemSettingBody) => upsertSystemSettingQuery(newSystemSetting) diff --git a/.jjconflict-side-2/apps/server/src/resources/system/settings/queries.ts b/.jjconflict-side-2/apps/server/src/resources/system/settings/queries.ts new file mode 100644 index 0000000000..c64cb3b74c --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/system/settings/queries.ts @@ -0,0 +1,18 @@ +import type { Prisma, SystemSetting } from '@prisma/client' +import prisma from '@/prisma.js' + +export function upsertSystemSetting(newSystemSetting: SystemSetting) { + return prisma.systemSetting.upsert({ + create: { + ...newSystemSetting, + }, + update: { + value: newSystemSetting.value, + }, + where: { + key: newSystemSetting.key, + }, + }) +} + +export const getSystemSettings = (where?: Prisma.SystemSettingWhereInput) => prisma.systemSetting.findMany({ where }) diff --git a/.jjconflict-side-2/apps/server/src/resources/system/settings/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/system/settings/router.spec.ts new file mode 100644 index 0000000000..469ee45538 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/system/settings/router.spec.ts @@ -0,0 +1,82 @@ +import { ADMIN_PERMS, systemSettingsContract } from '@cpn-console/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../../app.js' +import * as utilsController from '../../../utils/controller.js' +import { getUserMockInfos } from '../../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetSystemSettingsMock = vi.spyOn(business, 'getSystemSettings') +const businessUpsertSystemSettingMock = vi.spyOn(business, 'upsertSystemSetting') + +describe('test systemSettingsContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listSystemSettings', () => { + it('should return plugin configurations for authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.LIST_SYSTEM) + const systemSettings: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetSystemSettingsMock.mockResolvedValueOnce(systemSettings) + + const response = await app.inject() + .get(systemSettingsContract.listSystemSettings.path) + .end() + + expect(businessGetSystemSettingsMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(systemSettings) + expect(response.statusCode).toEqual(200) + }) + + it('should return 200 for anybody', async () => { + const user = getUserMockInfos(0n) + const systemSettings: any[] = [] + + authUserMock.mockResolvedValueOnce(user) + businessGetSystemSettingsMock.mockResolvedValueOnce(systemSettings) + + const response = await app.inject() + .get(systemSettingsContract.listSystemSettings.path) + .end() + + expect(businessGetSystemSettingsMock).toHaveBeenCalledTimes(1) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('upsertSystemSetting', () => { + const newConfig = { key: 'key1', value: 'value1' } + it('should update system setting, authorized users', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_SYSTEM) + + authUserMock.mockResolvedValueOnce(user) + businessUpsertSystemSettingMock.mockResolvedValueOnce(newConfig) + + const response = await app.inject() + .post(systemSettingsContract.upsertSystemSetting.path) + .body(newConfig) + .end() + + expect(businessUpsertSystemSettingMock).toHaveBeenCalledWith(newConfig) + expect(response.statusCode).toEqual(201) + }) + + it('should return 403 for unauthorized users', async () => { + const user = getUserMockInfos(0n) + + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(systemSettingsContract.upsertSystemSetting.path) + .body(newConfig) + .end() + + expect(businessUpsertSystemSettingMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/system/settings/router.ts b/.jjconflict-side-2/apps/server/src/resources/system/settings/router.ts new file mode 100644 index 0000000000..98bce89e2d --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/system/settings/router.ts @@ -0,0 +1,31 @@ +import { AdminAuthorized, systemSettingsContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { Forbidden403 } from '@/utils/errors.js' +import { getSystemSettings, upsertSystemSetting } from './business.js' + +export function systemSettingsRouter() { + return serverInstance.router(systemSettingsContract, { + listSystemSettings: async ({ query }) => { + const systemSettings = await getSystemSettings(query.key) + + return { + status: 200, + body: systemSettings, + } + }, + + upsertSystemSetting: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageSystem(perms.adminPermissions)) return new Forbidden403() + + const systemSetting = await upsertSystemSetting(data) + + return { + status: 201, + body: systemSetting, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/user/business.spec.ts b/.jjconflict-side-2/apps/server/src/resources/user/business.spec.ts new file mode 100644 index 0000000000..a86c0535ae --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/user/business.spec.ts @@ -0,0 +1,227 @@ +import type { UserDetails } from '../../types/index.ts' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { getMatchingUsers, getUsers, logViaSession, logViaToken, patchUsers, TokenInvalidReason } from './business.ts' +import * as queries from './queries.js' + +const getUsersQueryMock = vi.spyOn(queries, 'getUsers') +const getMatchingUsersQueryMock = vi.spyOn(queries, 'getMatchingUsers') + +describe('test users business', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + const user = { + adminRoleIds: [], + createdAt: new Date(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + id: faker.string.uuid(), + lastName: faker.person.lastName(), + updatedAt: new Date(), + } + const projectId = faker.string.uuid() + const adminRoleId = faker.string.uuid() + describe('patchUsers', () => { + it('should do nothing', async () => { + prisma.user.update.mockResolvedValue(null) + + await patchUsers([]) + + expect(prisma.user.update).toHaveBeenCalledTimes(0) + }) + + it('should update a user adminRoleIds', async () => { + const userUpdated = { id: user.id, adminRoleIds: user.adminRoleIds } + + prisma.user.update.mockResolvedValue(user) + + prisma.user.findMany.mockResolvedValue([]) + + await patchUsers([userUpdated]) + expect(prisma.user.update).toHaveBeenCalledTimes(1) + expect(prisma.user.findMany).toHaveBeenCalledTimes(2) + + await patchUsers([userUpdated, userUpdated]) + expect(prisma.user.update).toHaveBeenCalledTimes(3) + }) + }) + describe('getUsers', () => { + it('should query without where', async () => { + prisma.user.update.mockResolvedValue(null) + + await getUsers({}) + + expect(getUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getUsersQueryMock).toHaveBeenCalledWith({ AND: [] }) + }) + it('should query with filter adminRoleIds', async () => { + prisma.user.update.mockResolvedValue(null) + + await getUsers({ adminRoleIds: [adminRoleId] }) + + expect(getUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getUsersQueryMock).toHaveBeenCalledWith({ AND: [{ adminRoleIds: { hasEvery: [adminRoleId] } }] }) + }) + }) + + describe('getMatchingUsers', () => { + const AND = [ + { + OR: [ + { + email: { + contains: 'abc', + mode: 'insensitive', + }, + }, + { + firstName: { + contains: 'abc', + mode: 'insensitive', + }, + }, + { + lastName: { + contains: 'abc', + mode: 'insensitive', + }, + }, + ], + }, + { + type: 'human', + }, + ] + it('should query only with letters ', async () => { + prisma.user.update.mockResolvedValue(null) + + await getMatchingUsers({ letters: 'abc' }) + + expect(getMatchingUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getMatchingUsersQueryMock).toHaveBeenCalledWith({ AND }) + }) + it('should query with letters and projectId', async () => { + prisma.user.update.mockResolvedValue(null) + + await getMatchingUsers({ letters: 'abc', notInProjectId: projectId }) + + expect(getMatchingUsersQueryMock).toHaveBeenCalledTimes(1) + expect(getMatchingUsersQueryMock).toHaveBeenCalledWith({ AND: [...[{ + projectMembers: { + none: { + projectId, + }, + }, + }, { + projectsOwned: { + none: { + id: projectId, + }, + }, + }], ...AND] }) + }) + }) + describe('logViaSession', () => { + // ça ne teste pas tout mais c'est déjà bien hein + const adminRoles = [{ + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '', + permissions: 0n, + position: 0, + }, { + id: faker.string.uuid(), + name: faker.string.alphanumeric(), + oidcGroup: '/admin', + permissions: 0n, + position: 0, + }] + const userToLog: UserDetails = { + id: faker.string.uuid(), + email: user.email, + firstName: user.firstName, + groups: [], + lastName: user.lastName, + } + it('should create user and return adminPerms', async () => { + prisma.adminRole.findMany.mockResolvedValue(adminRoles) + prisma.user.findUnique.mockResolvedValue(undefined) + prisma.user.create.mockResolvedValue(user) + prisma.user.update.mockResolvedValue(user) + const response = await logViaSession(userToLog) + expect(response.adminPerms).toBe(0n) + expect(prisma.user.create).toHaveBeenCalledTimes(1) + }) + it('should update user and return adminPerms', async () => { + prisma.adminRole.findMany.mockResolvedValue(adminRoles) + prisma.user.findUnique.mockResolvedValue(user) + prisma.user.update.mockResolvedValue(user) + const response = await logViaSession(userToLog) + expect(response.adminPerms).toEqual(0n) + expect(prisma.user.create).toHaveBeenCalledTimes(0) + }) + }) +}) + +describe('logViaToken', () => { + const nextYear = new Date() + const lastYear = new Date() + nextYear.setFullYear((new Date()).getFullYear() + 1) + lastYear.setFullYear((new Date()).getFullYear() - 1) + const baseToken = { + createdAt: new Date(), + hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + id: faker.string.uuid(), + lastUse: null, + permissions: 2n, + userId: null, + status: 'active', + } as const + + beforeEach(() => { + // Mock the global roles lookup + prisma.adminRole.findMany.mockResolvedValue([]) + }) + + it('should return identity', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken }) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(2n) + }) + + it('should return identity based on pat', async () => { + const pat = structuredClone(baseToken) + delete pat.permissions + pat.owner = { adminRoleIds: null } + prisma.personalAccessToken.findFirst.mockResolvedValueOnce(pat) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(0n) + }) + + it('should return identity, with expirationDate', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, expirationDate: nextYear }) + const identity = await logViaToken('test') + expect(identity.adminPerms).toBe(2n) + }) + + it('should return cause revoked', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, status: 'revoked' }) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.INACTIVE) + }) + + it('should return cause expired', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce({ ...baseToken, expirationDate: lastYear }) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.EXPIRED) + }) + + it('should return cause not found', async () => { + prisma.adminToken.findFirst.mockResolvedValueOnce(undefined) + const identity = await logViaToken('test') + expect(identity).toBe(TokenInvalidReason.NOT_FOUND) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/user/business.ts b/.jjconflict-side-2/apps/server/src/resources/user/business.ts new file mode 100644 index 0000000000..acbf67bb8e --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/user/business.ts @@ -0,0 +1,221 @@ +import type { userContract, XOR } from '@cpn-console/shared' +import type { AdminRole, AdminToken, PersonalAccessToken, Prisma, User } from '@prisma/client' +import type { UserDetails } from '@/types/index.js' +import { createHash } from 'node:crypto' +import prisma from '@/prisma.js' +import { getMatchingUsers as getMatchingUsersQuery, getUsers as getUsersQuery } from '@/resources/queries-index.js' +import { BadRequest400 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' + +export async function getUsers(query: typeof userContract.getAllUsers.query._type, relationType: 'OR' | 'AND' = 'AND') { + const whereInputs: Prisma.UserWhereInput[] = [] + if (query.adminRoleIds?.length) { + whereInputs.push({ adminRoleIds: { hasEvery: query.adminRoleIds } }) + } + if (query.adminRoles?.length) { + const roles = query.adminRoles + ? await prisma.adminRole.findMany({ where: { name: { in: query.adminRoles } } }) + : [] + + const adminRoleNameNotFound = query.adminRoles?.find(nameQueried => !roles.some(({ name }) => name === nameQueried)) + if (adminRoleNameNotFound) { + return new BadRequest400(`Unable to find adminRole ${adminRoleNameNotFound}`) + } + whereInputs.push({ adminRoleIds: { hasEvery: roles.map(({ id }) => id) } }) + } + if (query.memberOfIds) { + whereInputs.push({ + AND: query.memberOfIds.map(id => ({ + OR: [ + { projectsOwned: { some: { id } } }, + { ProjectMembers: { some: { project: { id } } } }, + ], + })), + }) + } + + return getUsersQuery({ [relationType]: whereInputs }) +} + +export async function getMatchingUsers(query: typeof userContract.getMatchingUsers.query._type) { + const AND: Prisma.UserWhereInput[] = [] + if (query.notInProjectId) { + AND.push({ projectMembers: { none: { projectId: query.notInProjectId } } }) + AND.push({ projectsOwned: { none: { id: query.notInProjectId } } }) + } + const filter = { contains: query.letters, mode: 'insensitive' } as const // Default value: default + if (query.letters) { + AND.push({ + OR: [{ + email: filter, + }, { + firstName: filter, + }, { + lastName: filter, + }], + }) + AND.push({ type: 'human' }) + } + + return getMatchingUsersQuery({ + AND, + }) +} + +export async function patchUsers(users: typeof userContract.patchUsers.body._type) { + const userIds = users.map(u => u.id) + const usersBefore = await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, adminRoleIds: true }, + }) + + for (const user of users) { + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + adminRoleIds: user.adminRoleIds, + }, + }) + } + + const impactedRoleIds = new Set() + for (const user of users) { + const before = usersBefore.find(u => u.id === user.id) + if (before) { + before.adminRoleIds.forEach(id => impactedRoleIds.add(id)) + } + user.adminRoleIds?.forEach(id => impactedRoleIds.add(id)) + } + + for (const roleId of impactedRoleIds) { + await hook.adminRole.upsert(roleId) + } + + return prisma.user.findMany({ + where: { + id: { in: users.map(({ id }) => id) }, + }, + }) +} + +export enum TokenInvalidReason { + INACTIVE = 'Not active', + EXPIRED = 'Expired', + NOT_FOUND = 'Not authenticated', +} + +export type UserTrial = Omit +export async function logViaSession({ id, email, groups, ...user }: UserTrial): Promise<{ user: User, adminPerms: bigint }> { + let userDb = await prisma.user.findUnique({ + where: { id }, + }) + + if (!userDb) { + userDb = await prisma.user.create({ data: { email, id, ...user, adminRoleIds: [], type: 'human' } }) + } + + const matchingAdminRoles = await prisma.adminRole.findMany({ + where: { OR: [{ oidcGroup: { in: groups } }, { id: { in: userDb.adminRoleIds } }, { type: 'global' }] }, + }) + + const oidcRoleIds = matchingAdminRoles + .filter(({ oidcGroup }) => oidcGroup && groups.includes(oidcGroup)) + .map(({ id }) => id) + + // On enregistre en bdd uniquement les roles de l'utilisateur + // qui ne viennent pas de keycloak + const updatedUser = await prisma.user.update({ where: { id }, data: { ...user, lastLogin: (new Date()).toISOString() } }) + .then(user => ({ ...user, adminRoleIds: [...new Set([...user.adminRoleIds, ...oidcRoleIds])] })) + return { + user: updatedUser, + adminPerms: sumAdminPerms(matchingAdminRoles), + } +} + +type UserWithTokenId = Omit & { tokenId: string } +export async function logViaToken(pass: string): Promise<({ user: UserWithTokenId, adminPerms: bigint }) | TokenInvalidReason> { + const passHash = createHash('sha256').update(pass).digest('hex') + + let token: (XOR & { owner: User }) | TokenInvalidReason | undefined + const tokenLoginMethods = [findPersonalAccessToken, findAdminToken] + for (const tokenLoginMethod of tokenLoginMethods) { + token = await tokenLoginMethod(passHash) + if (token) { + break + } + } + + if (typeof token === 'string') { + return token + } + if (!token) { + return TokenInvalidReason.NOT_FOUND + } + + const globalRoles = await prisma.adminRole.findMany({ where: { type: 'global' }, select: { permissions: true } }) + const globalPerms = globalRoles.reduce((acc, curr) => acc | curr.permissions, 0n) + const adminPerms = token?.permissions ?? await getAdminRolesAndSum(token.owner.adminRoleIds) + + return { + user: { + ...token.owner, + tokenId: token.id, + }, + adminPerms: globalPerms | adminPerms, + } +} + +function isTokenInvalid(token: AdminToken | PersonalAccessToken): TokenInvalidReason | undefined { + if (token.status !== 'active') { + return TokenInvalidReason.INACTIVE + } + const currentDate = new Date() + if (token.expirationDate && currentDate.getTime() > token.expirationDate?.getTime()) { + return TokenInvalidReason.EXPIRED + } +} + +function sumAdminPerms(roles: AdminRole[]): bigint { + if (!roles.length) { + return 0n + } + return roles.reduce((acc, curr) => acc | curr.permissions, 0n) +} + +async function getAdminRolesAndSum(roles: AdminRole['id'][] | null): Promise { + if (!roles?.length) { + return 0n + } + return sumAdminPerms(await prisma.adminRole.findMany({ + where: { id: { in: roles } }, + })) +} + +// List all token tpe authentication +async function findPersonalAccessToken(digest: string): Promise<(PersonalAccessToken & { owner: User }) | undefined | TokenInvalidReason> { + const token = await prisma.personalAccessToken.findFirst({ where: { hash: digest }, include: { owner: true } }) + if (!token) + return undefined + const invalidReason = isTokenInvalid(token) + if (invalidReason) { + return invalidReason + } + await prisma.personalAccessToken.update({ where: { id: token.id }, data: { lastUse: (new Date()).toISOString() } }) + await prisma.user.update({ where: { id: token.owner.id }, data: { lastLogin: (new Date()).toISOString() } }) + return token +} + +async function findAdminToken(digest: string): Promise<(AdminToken & { owner: User }) | undefined | TokenInvalidReason> { + const token = await prisma.adminToken.findFirst({ where: { hash: digest }, include: { owner: true } }) + if (!token) + return undefined + const invalidReason = isTokenInvalid(token) + if (invalidReason) { + return invalidReason + } + await prisma.adminToken.update({ where: { id: token.id }, data: { lastUse: (new Date()).toISOString() } }) + await prisma.user.update({ where: { id: token.userId }, data: { lastLogin: (new Date()).toISOString() } }) + return token +} diff --git a/.jjconflict-side-2/apps/server/src/resources/user/queries.ts b/.jjconflict-side-2/apps/server/src/resources/user/queries.ts new file mode 100644 index 0000000000..1e33a8128b --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/user/queries.ts @@ -0,0 +1,60 @@ +import type { Prisma, User } from '@prisma/client' +import prisma from '@/prisma.js' + +type UserCreate = Omit + +// SELECT +export const getUsers = (where?: Prisma.UserWhereInput) => prisma.user.findMany({ where }) + +export async function getUserInfos(id: User['id']) { + return prisma.user.findMany({ + where: { id }, + include: { + logs: true, + }, + }) +} + +export function getMatchingUsers(where: Prisma.UserWhereInput) { + return prisma.user.findMany({ + where, + take: 5, + }) +} + +export function getUserById(id: User['id']) { + return prisma.user.findUnique({ where: { id } }) +} + +export function getUserOrThrow(id: User['id']) { + return prisma.user.findUniqueOrThrow({ + where: { id }, + }) +} + +export function getUserByEmail(email: User['email']) { + return prisma.user.findUnique({ where: { email } }) +} + +// CREATE +export async function createUser({ id, email, firstName, lastName, type }: UserCreate) { + const user = await getUserByEmail(email) + if (user) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') + return prisma.user.create({ data: { id, email, firstName, lastName, type } }) +} + +// UPDATE +export async function updateUserById({ id, email, firstName, lastName }: UserCreate) { + const user = await getUserById(id) + const isEmailAlreadyTaken = await getUserByEmail(email) + if (!user) throw new Error('L\'utilisateur demandé n\'existe pas') + if (isEmailAlreadyTaken) throw new Error('Un utilisateur avec cette adresse e-mail existe déjà') + if (user && !isEmailAlreadyTaken) { + return prisma.user.update({ where: { id }, data: { email, firstName, lastName } }) + } +} + +// TECH +export function _createUser(data: Prisma.UserCreateInput) { + return prisma.user.upsert({ where: { id: data.id }, create: data, update: data }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/user/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/user/router.spec.ts new file mode 100644 index 0000000000..f47c9aa311 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/user/router.spec.ts @@ -0,0 +1,142 @@ +import { ADMIN_PERMS, userContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { getUserMockInfos, setRequestor } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessGetMatchingMock = vi.spyOn(business, 'getMatchingUsers') +const businessLogViaSessionMock = vi.spyOn(business, 'logViaSession') +const businessGetUsersMock = vi.spyOn(business, 'getUsers') +const businessPatchMock = vi.spyOn(business, 'patchUsers') + +describe('test userContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('getMatchingUsers', () => { + it('should return matching users', async () => { + const usersMatching: any[] = [] + businessGetMatchingMock.mockResolvedValueOnce(usersMatching) + + const response = await app.inject() + .get(userContract.getMatchingUsers.path) + .query({ letters: faker.person.fullName() }) + .end() + + expect(businessGetMatchingMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(usersMatching) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('auth', () => { + it('should return logged user', async () => { + const user = { + id: faker.string.uuid(), + adminRoleIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + type: 'human' as const, + lastName: faker.person.lastName(), + lastLogin: (new Date()).toISOString(), + } + // @ts-ignore + setRequestor(user) + // @ts-ignore + businessLogViaSessionMock.mockResolvedValueOnce({ user, adminPerms: 0n }) + + const response = await app.inject() + .get(userContract.auth.path) + .end() + + expect(businessLogViaSessionMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(user) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('getAllUsers', () => { + it('should return all users for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + const users: any[] = [] + authUserMock.mockResolvedValueOnce(user) + businessGetUsersMock.mockResolvedValueOnce(users) + + const response = await app.inject() + .get(userContract.getAllUsers.path) + .query({ role: 'admin' }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetUsersMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(users) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .get(userContract.getAllUsers.path) + .query({ role: 'admin' }) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessGetUsersMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('patchUsers', () => { + const usersPatchData = [{ + id: faker.string.uuid(), + adminRoleIds: [], + }] + const usersReturn = [{ + id: faker.string.uuid(), + adminRoleIds: [], + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + type: 'human', + }] + + it('should patch and return users for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_USERS) + authUserMock.mockResolvedValueOnce(user) + + businessPatchMock.mockResolvedValueOnce(usersReturn) + const response = await app.inject() + .patch(userContract.patchUsers.path) + .body(usersPatchData) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessPatchMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(usersReturn) + expect(response.statusCode).toEqual(200) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ROLES) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .patch(userContract.patchUsers.path) + .body(usersPatchData) + .end() + + expect(authUserMock).toHaveBeenCalledTimes(1) + expect(businessPatchMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/user/router.ts b/.jjconflict-side-2/apps/server/src/resources/user/router.ts new file mode 100644 index 0000000000..e745dbec43 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/user/router.ts @@ -0,0 +1,72 @@ +import type { + UserTrial, +} from './business.js' +import { AdminAuthorized, userContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { + getMatchingUsers, + getUsers, + logViaSession, + patchUsers, +} from './business.js' +import '@/types/index.js' + +export function userRouter() { + return serverInstance.router(userContract, { + getMatchingUsers: async ({ query }) => { + const usersMatching = await getMatchingUsers(query) + + return { + status: 200, + body: usersMatching, + } + }, + + auth: async ({ request: req }) => { + const user = req.session.user + + if (!user) return new Unauthorized401() + + const { user: body } = await logViaSession(user as unknown as UserTrial) + + return { + status: 200, + body, + } + }, + + getAllUsers: async ({ + request: req, + query: { relationType, ...query }, + }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageUsers(perms.adminPermissions)) + return new Forbidden403() + + const body = await getUsers(query, relationType) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + patchUsers: async ({ request: req, body }) => { + const perms = await authUser(req) + + if (!AdminAuthorized.ManageUsers(perms.adminPermissions)) + return new Forbidden403() + + const users = await patchUsers(body) + + return { + status: 200, + body: users, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/user/tokens/business.ts b/.jjconflict-side-2/apps/server/src/resources/user/tokens/business.ts new file mode 100644 index 0000000000..e378d3b79b --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/user/tokens/business.ts @@ -0,0 +1,51 @@ +import type { personalAccessTokenContract } from '@cpn-console/shared' +import type { AdminToken, User } from '@prisma/client' +import { createHash } from 'node:crypto' +import { generateRandomPassword, isAtLeastTomorrow } from '@cpn-console/shared' +import { BadRequest400 } from '@/utils/errors.js' +import prisma from '../../../prisma.js' + +export async function listTokens(userId: User['id']) { + return prisma.personalAccessToken.findMany({ + omit: { hash: true }, + include: { owner: true }, + orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], + where: { userId }, + }) +} + +export async function createToken(data: typeof personalAccessTokenContract.createPersonalAccessToken.body._type, userId: User['id']) { + if (data.expirationDate && !isAtLeastTomorrow(new Date(data.expirationDate))) { + return new BadRequest400('Date d\'expiration trop courte') + } + const password = generateRandomPassword(48, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-') + const hash = createHash('sha256').update(password).digest('hex') + const token = await prisma.personalAccessToken.create({ + data: { + ...data, + hash, + expirationDate: new Date(data.expirationDate), + userId, + }, + omit: { hash: true }, + include: { owner: true }, + }) + return { + ...token, + password, + } +} + +export async function deleteToken(id: AdminToken['id'], userId: User['id']) { + const token = await prisma.personalAccessToken.findUnique({ + where: { + id, + userId, + }, + }) + if (token) { + return prisma.personalAccessToken.delete({ + where: { id }, + }) + } +} diff --git a/.jjconflict-side-2/apps/server/src/resources/user/tokens/router.ts b/.jjconflict-side-2/apps/server/src/resources/user/tokens/router.ts new file mode 100644 index 0000000000..369ded2b1a --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/user/tokens/router.ts @@ -0,0 +1,48 @@ +import { personalAccessTokenContract } from '@cpn-console/shared' + +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' +import { ErrorResType, Forbidden403 } from '@/utils/errors.js' +import { createToken, deleteToken, listTokens } from './business.js' +import '@/types/index.js' + +export function personalAccessTokenRouter() { + return serverInstance.router(personalAccessTokenContract, { + listPersonalAccessTokens: async ({ request: req }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + const body = await listTokens(perms.user.id) + + return { + status: 200, + body, + } + }, + + createPersonalAccessToken: async ({ request: req, body: data }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + const body = await createToken(data, perms.user.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + deletePersonalAccessToken: async ({ request: req, params }) => { + const perms = await authUser(req) + + if (!perms.user?.id || perms.user?.type !== 'human') return new Forbidden403() + await deleteToken(params.tokenId, perms.user.id) + + return { + status: 204, + body: null, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/zone/business.spec.ts b/.jjconflict-side-2/apps/server/src/resources/zone/business.spec.ts new file mode 100644 index 0000000000..2b42464e26 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/zone/business.spec.ts @@ -0,0 +1,133 @@ +import type { Cluster, Zone } from '@prisma/client' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import prisma from '../../__mocks__/prisma.js' +import { hook } from '../../__mocks__/utils/hook-wrapper.ts' +import { BadRequest400 } from '../../utils/errors.ts' +import { createZone, deleteZone, listZones, updateZone } from './business.ts' +import * as queries from './queries.js' + +const userId = faker.string.uuid() +const reqId = faker.string.uuid() +const linkZoneToClustersMock = vi.spyOn(queries, 'linkZoneToClusters') +vi.mock('../../utils/hook-wrapper.ts', async () => ({ + hook, +})) + +describe('test zone business', () => { + const zones: Zone[] = [{ + id: faker.string.uuid(), + label: faker.string.alphanumeric(), + argocdUrl: faker.internet.url(), + createdAt: new Date(), + updatedAt: new Date(), + description: faker.lorem.lines(1), + slug: faker.string.alphanumeric(5), + }, { + id: faker.string.uuid(), + label: faker.string.alphanumeric(), + argocdUrl: faker.internet.url(), + createdAt: new Date(), + updatedAt: new Date(), + description: faker.lorem.lines(1), + slug: faker.string.alphanumeric(6), + }] + + const clusters: Pick[] = [ + { id: faker.string.uuid() }, + { id: faker.string.uuid() }, + ] + + beforeEach(() => { + vi.resetAllMocks() + }) + describe('listZones', () => { + it('should return zones', async () => { + prisma.zone.findMany.mockResolvedValueOnce(zones) + + const response = await listZones() + expect(response).toEqual(zones) + }) + }) + describe('createZone', () => { + it('should create zone without description and clusterIds', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl } + + hook.zone.upsert.mockResolvedValue({}) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).toEqual(zones[0]) + expect(prisma.zone.create).toHaveBeenCalledWith({ + data: { + slug: newZone.slug, + label: newZone.label, + argocdUrl: newZone.argocdUrl, + description: undefined, + }, + }) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(0) + }) + it('should create zone with description and clusterIds', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl, clusterIds: clusters.map(({ id }) => id), description: faker.lorem.lines(2) } + + hook.zone.upsert.mockResolvedValue({}) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).toEqual(zones[0]) + expect(prisma.zone.create).toHaveBeenCalledWith({ + data: { + description: newZone.description, + label: newZone.label, + argocdUrl: newZone.argocdUrl, + slug: newZone.slug, + }, + }) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(1) + }) + it('should not create zone, conflict label', async () => { + const newZone = { label: zones[0].label, slug: zones[0].slug, argocdUrl: zones[0].argocdUrl } + + prisma.zone.findUnique.mockResolvedValueOnce(zones[0]) + prisma.zone.create.mockResolvedValueOnce(zones[0]) + const response = await createZone(newZone, userId, reqId) + + expect(response).instanceOf(BadRequest400) + expect(prisma.zone.create).toHaveBeenCalledTimes(0) + expect(linkZoneToClustersMock).toHaveBeenCalledTimes(0) + }) + }) + describe('updateZone', () => { + it('should filter keys and update zone', async () => { + prisma.zone.update.mockResolvedValueOnce(zones[0]) + hook.zone.upsert.mockResolvedValue({}) + await updateZone(zones[0].id, { + description: '', + label: zones[0].label, + argocdUrl: zones[0].argocdUrl, + extraKey: 1, + }, userId, reqId) + expect(prisma.zone.update).toHaveBeenCalledWith({ where: { id: zones[0].id }, data: { + description: '', + label: zones[0].label, + argocdUrl: zones[0].argocdUrl, + } }) + }) + }) + describe('deleteZone', () => { + it('should not delete zone, cluster attached', async () => { + prisma.cluster.findFirst.mockResolvedValueOnce(clusters[0]) + const response = await deleteZone(zones[0].id, userId, reqId) + expect(response).instanceOf(BadRequest400) + expect(prisma.cluster.delete).toHaveBeenCalledTimes(0) + }) + it('should delete zone', async () => { + prisma.cluster.findFirst.mockResolvedValueOnce(undefined) + hook.zone.delete.mockResolvedValue({}) + const response = await deleteZone(zones[0].id, userId, reqId) + expect(response).toEqual(null) + expect(prisma.zone.delete).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/zone/business.ts b/.jjconflict-side-2/apps/server/src/resources/zone/business.ts new file mode 100644 index 0000000000..eb3155ce60 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/zone/business.ts @@ -0,0 +1,78 @@ +import type { User, Zone } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { BadRequest400, Unprocessable422 } from '@/utils/errors.js' +import { hook } from '@/utils/hook-wrapper.js' +import { addLogs } from '../queries-index.js' +import { linkZoneToClusters } from './queries.js' + +export const listZones = prisma.zone.findMany + +export async function createZone( + data: { slug: string, label: string, argocdUrl: string, description?: string | null, clusterIds?: string[] }, + userId: User['id'], + requestId: string, +) { + const { slug, label, argocdUrl, description, clusterIds } = data + + const existingZone = await prisma.zone.findUnique({ + where: { slug }, + }) + + if (existingZone) return new BadRequest400(`Une zone portant le nom ${slug} existe déjà.`) + const zone = await prisma.zone.create({ + data: { + slug, + label, + argocdUrl, + description, + }, + }) + if (clusterIds) { + await linkZoneToClusters(zone.id, clusterIds) + } + const hookReply = await hook.zone.upsert(zone.id) + await addLogs({ action: 'Create zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la création de la zone') + } + return zone +} + +export async function updateZone( + zoneId: Zone['id'], + data: Pick, + userId: User['id'], + requestId: string, +) { + const { label, argocdUrl, description } = data + + const updatedZone = await prisma.zone.update({ + where: { + id: zoneId, + }, + data: { + label, + argocdUrl, + description, + }, + }) + const hookReply = await hook.zone.upsert(updatedZone.id) + await addLogs({ action: 'Update zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la mise à jour de la zone') + } + return updatedZone +} + +export async function deleteZone(zoneId: Zone['id'], userId: User['id'], requestId: string) { + const attachedCluster = await prisma.cluster.findFirst({ where: { zoneId }, select: { id: true } }) + if (attachedCluster) return new BadRequest400('Vous ne pouvez supprimer cette zone, car des clusters y sont associés.') + + const hookReply = await hook.zone.delete(zoneId) + await addLogs({ action: 'Delete zone', data: hookReply, userId, requestId }) + if (hookReply.failed) { + return new Unprocessable422('Echec des services lors de la suppression de la zone') + } + await prisma.zone.delete({ where: { id: zoneId } }) + return null +} diff --git a/.jjconflict-side-2/apps/server/src/resources/zone/queries.ts b/.jjconflict-side-2/apps/server/src/resources/zone/queries.ts new file mode 100644 index 0000000000..1390bb153a --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/zone/queries.ts @@ -0,0 +1,21 @@ +import type { Cluster, Zone } from '@prisma/client' +import prisma from '@/prisma.js' + +export function getZoneByIdOrThrow(id: Zone['id']) { + return prisma.zone.findUniqueOrThrow({ + where: { id }, + }) +} + +export function linkZoneToClusters(zoneId: Zone['id'], clusterIds: Cluster['id'][]) { + return prisma.zone.update({ + where: { + id: zoneId, + }, + data: { + clusters: { + connect: clusterIds.map(clusterId => ({ id: clusterId })), + }, + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/resources/zone/router.spec.ts b/.jjconflict-side-2/apps/server/src/resources/zone/router.spec.ts new file mode 100644 index 0000000000..0ee9c7f394 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/zone/router.spec.ts @@ -0,0 +1,164 @@ +import type { Zone } from '@cpn-console/shared' +import { ADMIN_PERMS, zoneContract } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import app from '../../app.js' +import * as utilsController from '../../utils/controller.js' +import { BadRequest400 } from '../../utils/errors.js' +import { getUserMockInfos } from '../../utils/mocks.js' +import * as business from './business.js' + +vi.mock('fastify-keycloak-adapter', (await import('../../utils/mocks.js')).mockSessionPlugin) +const authUserMock = vi.spyOn(utilsController, 'authUser') +const businessListMock = vi.spyOn(business, 'listZones') +const businessCreateMock = vi.spyOn(business, 'createZone') +const businessUpdateMock = vi.spyOn(business, 'updateZone') +const businessDeleteMock = vi.spyOn(business, 'deleteZone') + +describe('test zoneContract', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('listZones', () => { + it('should return list of zones', async () => { + const zones: any[] = [] + const user = getUserMockInfos(ADMIN_PERMS.LIST_ZONES) + authUserMock.mockResolvedValueOnce(user) + businessListMock.mockResolvedValueOnce(zones) + + const response = await app.inject() + .get(zoneContract.listZones.path) + .end() + + expect(businessListMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(zones) + expect(response.statusCode).toEqual(200) + }) + }) + + describe('createZone', () => { + const zone = { id: faker.string.uuid(), label: faker.string.alpha({ length: 5 }), argocdUrl: faker.internet.url(), slug: faker.string.alpha({ length: 5, casing: 'lower' }), description: '' } + + it('should create and return zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(zone) + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual(zone) + expect(response.statusCode).toEqual(201) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessCreateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .post(zoneContract.createZone.path) + .body(zone) + .end() + + expect(businessCreateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('updateZone', () => { + const zoneId = faker.string.uuid() + const zone: Omit = { label: faker.string.alpha({ length: 5 }), slug: faker.string.alpha({ length: 5, casing: 'lower' }), argocdUrl: faker.internet.url(), description: '' } + + it('should update and return zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce({ id: zoneId, ...zone }) + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(1) + expect(response.json()).toEqual({ id: zoneId, ...zone }) + expect(response.statusCode).toEqual(200) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessUpdateMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .put(zoneContract.updateZone.path.replace(':zoneId', zoneId)) + .body(zone) + .end() + + expect(businessUpdateMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) + + describe('deleteZone', () => { + it('should delete zone for admin', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(null) + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(1) + expect(response.body).toBeFalsy() + expect(response.statusCode).toEqual(204) + }) + it('should pass business error', async () => { + const user = getUserMockInfos(ADMIN_PERMS.MANAGE_ZONES) + authUserMock.mockResolvedValueOnce(user) + + businessDeleteMock.mockResolvedValueOnce(new BadRequest400('une erreur')) + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(response.statusCode).toEqual(400) + }) + it('should return 403 for non-admin', async () => { + const user = getUserMockInfos(0n) + authUserMock.mockResolvedValueOnce(user) + + const response = await app.inject() + .delete(zoneContract.deleteZone.path.replace(':zoneId', faker.string.uuid())) + .end() + + expect(businessDeleteMock).toHaveBeenCalledTimes(0) + expect(response.statusCode).toEqual(403) + }) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/resources/zone/router.ts b/.jjconflict-side-2/apps/server/src/resources/zone/router.ts new file mode 100644 index 0000000000..c682826c3e --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/resources/zone/router.ts @@ -0,0 +1,67 @@ +import { AdminAuthorized, zoneContract } from '@cpn-console/shared' +import { serverInstance } from '@/app.js' +import { authUser } from '@/utils/controller.js' + +import { ErrorResType, Forbidden403, Unauthorized401 } from '@/utils/errors.js' +import { createZone, deleteZone, listZones, updateZone } from './business.js' + +export function zoneRouter() { + return serverInstance.router(zoneContract, { + listZones: async () => { + const zones = await listZones() + + return { + status: 200, + body: zones, + } + }, + + createZone: async ({ request: req, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const body = await createZone(data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 201, + body, + } + }, + + updateZone: async ({ request: req, params, body: data }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + + const zoneId = params.zoneId + + const body = await updateZone(zoneId, data, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 200, + body, + } + }, + + deleteZone: async ({ request: req, params }) => { + const { user, adminPermissions } = await authUser(req) + + if (!AdminAuthorized.ManageZones(adminPermissions)) return new Forbidden403() + if (!user) return new Unauthorized401('Require to be requested from user not api key') + const zoneId = params.zoneId + + const body = await deleteZone(zoneId, user.id, req.id) + if (body instanceof ErrorResType) return body + + return { + status: 204, + body, + } + }, + }) +} diff --git a/.jjconflict-side-2/apps/server/src/runtime.ts b/.jjconflict-side-2/apps/server/src/runtime.ts new file mode 100644 index 0000000000..d5b8822533 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/runtime.ts @@ -0,0 +1,22 @@ +import { Agent, cacheStores, interceptors, ProxyAgent, setGlobalDispatcher } from 'undici' + +const base = process.env.HTTP_PROXY ? new ProxyAgent(process.env.HTTP_PROXY) : new Agent() + +// Undici’s cache interceptor follows RFC 7234: +// 1. Only GET/HEAD are cached (configurable via `methods`). +// 2. Cache-Control directives are obeyed: +// - `no-store` → never cache +// - `max-age`, `s-maxage`, `expires` → freshness lifetime +// - `private` → skip if shared store +// - `no-cache` → revalidate with ETag/Last-Modified +// 3. Heuristic caching (status 200 without explicit freshness) +// uses 10 % of time since Last-Modified if present. +// 4. Stale responses are served while revalidating in background +// when `stale-while-revalidate` is present. +const client = base.compose( + interceptors.cache({ + store: new cacheStores.SqliteCacheStore(), + methods: ['GET', 'HEAD'], + }), +) +setGlobalDispatcher(client) diff --git a/.jjconflict-side-2/apps/server/src/server.spec.ts b/.jjconflict-side-2/apps/server/src/server.spec.ts new file mode 100644 index 0000000000..5dc093888a --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/server.spec.ts @@ -0,0 +1,57 @@ +import { logger } from '@cpn-console/logger' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { closeConnections } from './connect.js' +import { exitGracefully, handleExit } from './server.js' + +vi.mock('fastify-keycloak-adapter', (await import('./utils/mocks.js')).mockSessionPlugin) +vi.mock('./init/db/index.js', () => ({ initDb: vi.fn() })) +vi.mock('./connect.js') + +vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + +vi.mock('./prepare-app.js', () => { + const app = { + listen: vi.fn(), + close: vi.fn(async () => {}), + } + return { + getPreparedApp: () => Promise.resolve(app), + } +}) +vi.spyOn(logger, 'warn') +vi.spyOn(logger, 'debug') +const infoSpy = vi.spyOn(logger, 'info') +const errorSpy = vi.spyOn(logger, 'error') +const fatalSpy = vi.spyOn(logger, 'fatal') + +describe('server', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should call closeConnections without parameter', async () => { + await exitGracefully() + + expect(closeConnections).toHaveBeenCalledTimes(1) + expect(vi.mocked(closeConnections).mock.calls[0]).toHaveLength(0) + expect(errorSpy).toHaveBeenCalledTimes(0) + }) + + it('should log an error', async () => { + await exitGracefully(new Error('error')) + + expect(closeConnections).toHaveBeenCalledTimes(1) + expect(vi.mocked(closeConnections).mock.calls[0]).toHaveLength(0) + expect(fatalSpy).toHaveBeenCalledTimes(1) + expect(fatalSpy.mock.calls[0]?.[0]).toBeInstanceOf(Error) + expect(infoSpy).toHaveBeenCalledTimes(2) + }) + + it('should call process.on 4 times', () => { + const processOn = vi.spyOn(process, 'on') + + handleExit() + + expect(processOn).toHaveBeenCalledTimes(5) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/server.ts b/.jjconflict-side-2/apps/server/src/server.ts new file mode 100644 index 0000000000..93425dae79 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/server.ts @@ -0,0 +1,44 @@ +import { logger } from '@cpn-console/logger' +import { closeConnections } from './connect.js' +import { getPreparedApp } from './prepare-app.js' +import { isCI, isDev, isDevSetup, isProd, isTest, port } from './utils/env.js' + +const app = await getPreparedApp() + +try { + await app.listen({ host: '0.0.0.0', port: +(port ?? 8080) }) +} catch (error) { + logger.error({ err: error }, 'Failed to start server') + process.exit(1) +} + +logger.debug({ isDev, isTest, isCI, isDevSetup, isProd }) + +export async function exitGracefully(error?: Error) { + if (error instanceof Error) { + logger.fatal(error) + } + await app.close() + logger.info('Closing connections...') + await closeConnections() + logger.info('Exiting...') + process.exit(error instanceof Error ? 1 : 0) +} + +function logExitCode(code: number) { + logger.warn(`received signal: ${code}`) +} + +function logUnhandledRejection(reason: unknown, promise: Promise) { + logger.error({ promise, err: reason }, 'Unhandled Rejection') +} + +export function handleExit() { + process.on('exit', logExitCode) + process.on('SIGINT', exitGracefully) + process.on('SIGTERM', exitGracefully) + process.on('uncaughtException', exitGracefully) + process.on('unhandledRejection', logUnhandledRejection) +} + +handleExit() diff --git a/.jjconflict-side-2/apps/server/src/telemetry.ts b/.jjconflict-side-2/apps/server/src/telemetry.ts new file mode 100644 index 0000000000..65b59dddab --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/telemetry.ts @@ -0,0 +1,26 @@ +import { FastifyOtelInstrumentation } from '@fastify/otel' +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' +import { NodeSDK } from '@opentelemetry/sdk-node' + +const sdk = new NodeSDK({ + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({}), + }), + serviceName: 'console-pi-native-console', + traceExporter: new OTLPTraceExporter({}), + instrumentations: [ + getNodeAutoInstrumentations(), + new FastifyOtelInstrumentation({ + registerOnInitialization: true, + }), + ], +}) + +sdk.start() + +process.once('beforeExit', () => { + sdk.shutdown() +}) diff --git a/.jjconflict-side-2/apps/server/src/types/index.ts b/.jjconflict-side-2/apps/server/src/types/index.ts new file mode 100644 index 0000000000..ed39a8f94b --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/types/index.ts @@ -0,0 +1,22 @@ +import { User } from '@cpn-console/shared' + +export type UserDetails = { + id: string + firstName: string + lastName: string + email: string + groups: string[] + type: User['type'], +} + +export interface Resources { + cpu: number + gpu: number + memory: number +} + +declare module 'fastify' { + interface Session { + user?: UserDetails + } +} diff --git a/.jjconflict-side-2/apps/server/src/utils/business.ts b/.jjconflict-side-2/apps/server/src/utils/business.ts new file mode 100644 index 0000000000..10fe3ced2f --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/business.ts @@ -0,0 +1,42 @@ +import type { SharedSafeParseReturnType } from '@cpn-console/shared' +import { parseZodError } from '@cpn-console/shared' +import { BadRequest400 } from './errors.js' + +export type Success = Result +export type Failure = Result +export class Result { + protected constructor( + readonly success: boolean, + readonly value: T | string, + ) {} + + static succeed(value: T): Success { + return new Result(true, value) as Success + } + + static fail(message: string): Failure { + return new Result(false, message) as Failure + } + + get isSuccess(): boolean { + return this.success + } + + get isError(): boolean { + return !this.success + } + + get data(): T { + if (this.success) return this.value as T + throw new Error('Cannot get data from a Failure') + } + + get error(): string { + if (!this.success) return this.value as string + throw new Error('Cannot get error from a Success') + } +} + +export function validateSchema(schemaValidation: SharedSafeParseReturnType) { + if (!schemaValidation.success) return new BadRequest400(parseZodError(schemaValidation.error)) +} diff --git a/.jjconflict-side-2/apps/server/src/utils/controller.ts b/.jjconflict-side-2/apps/server/src/utils/controller.ts new file mode 100644 index 0000000000..57ab7865af --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/controller.ts @@ -0,0 +1,171 @@ +import type { XOR } from '@cpn-console/shared' +import type { Cluster, Prisma, Project, ProjectMembers, ProjectRole } from '@prisma/client' +import type { FastifyRequest } from 'fastify' +import type { UserTrial } from '@/resources/user/business.js' +import type { UserDetails } from '@/types/index.js' +import { PROJECT_PERMS as PP, PROJECT_PERMS, projectIsLockedInfo, tokenHeaderName } from '@cpn-console/shared' +import prisma from '@/prisma.js' +import { logViaSession, logViaToken } from '@/resources/user/business.js' +import { Unauthorized401 } from './errors.js' +import { uuid } from './queries-tools.js' + +export type RequireOnlyOne + = Pick> + & { + [K in Keys]-?: + Required> + & Partial, undefined>> + }[Keys] + +type ErrorMessagePredicate = () => string | undefined +export function getErrorMessage(...fns: ErrorMessagePredicate[]) { + for (const f of fns) { + const error = f() + if (error) { + return error + } + } +} + +/** + * Renvoie une erreur si le projet est verrouillé + */ +export function checkProjectLocked(project: { locked: boolean }): string { + return project.locked + ? projectIsLockedInfo + : '' +} + +export function checkLocked(project: { locked: Project['locked'] }): string { + return checkProjectLocked(project) +} + +export function checkClusterUnavailable(clusterId: Cluster['id'], authorizedClusterIds: Cluster['id'][]): string { + return authorizedClusterIds.includes(clusterId) + ? '' + : 'Ce cluster n\'est pas disponible pour cette combinaison projet et stage' +} + +export const splitStringsFilterArray = >(toMatch: T, inputs: string): T => inputs.split(',').filter(i => toMatch.includes(i)) as unknown as T + +type StringArray = string[] +interface WhereBuilderParams { + enumValues: T + eqValue: T[number] | undefined + inValues: string | undefined + notInValues: string | undefined +} + +export function whereBuilder({ enumValues, eqValue, inValues, notInValues }: WhereBuilderParams) { + if (eqValue) { + return eqValue + } else if (inValues) { + return { in: splitStringsFilterArray(enumValues, inValues) } + } else if (notInValues) { + return { notIn: splitStringsFilterArray(enumValues, notInValues) } + } +} + +type ProjectMinimalPerms = Pick & { roles: ProjectRole[], members: ProjectMembers[] } +export interface UserProfile { user?: UserDetails, adminPermissions: bigint, tokenId?: string } +export interface ProjectPermState { projectPermissions?: bigint, projectId: Project['id'], projectLocked: boolean, projectStatus: Project['status'], projectOwnerId: Project['ownerId'] } +export type UserProjectProfile = UserProfile & ProjectPermState + +type ProjectUniqueFinder = XOR< + { slug: string }, + XOR<{ environmentId: string }, XOR<{ repositoryId: string }, { id: string }>> +> + +const projectPermsSelect = { roles: true, members: true, everyonePerms: true, ownerId: true, id: true, locked: true, status: true } as const satisfies Prisma.ProjectSelect + +export async function authUser(req: FastifyRequest): Promise +export async function authUser(req: FastifyRequest, projectUnique: ProjectUniqueFinder): Promise +export async function authUser(req: FastifyRequest, projectUnique?: ProjectUniqueFinder): Promise { + let adminPermissions: bigint = 0n + let tokenId: string | undefined + const reqUser: UserTrial = req.session.user as unknown as UserTrial + let user: UserDetails | undefined + + if (req.session.user) { + const loginResult = await logViaSession(reqUser) + user = { + ...loginResult.user, + groups: reqUser.groups, + } + adminPermissions = loginResult.adminPerms + } else { + const tokenHeader = req.headers[tokenHeaderName] + if (typeof tokenHeader === 'string') { + const resultToken = await logViaToken(tokenHeader) + if (typeof resultToken === 'string') { + throw new Unauthorized401(resultToken) + } + adminPermissions = resultToken.adminPerms ?? 0n + tokenId = resultToken.user.tokenId + if (!user && resultToken.user) { + user = { ...resultToken.user, groups: [] } + } + } + } + + const baseReturnInfos = { + user, + adminPermissions, + tokenId, + } + if (!projectUnique || !user) { + return baseReturnInfos + } + let project: ProjectMinimalPerms | null | undefined + + if (projectUnique.repositoryId) { + project = (await prisma.repository.findUnique({ + where: { id: projectUnique.repositoryId }, + select: { project: { select: projectPermsSelect } }, + }))?.project + } else if (projectUnique.environmentId) { + project = (await prisma.environment.findUnique({ + where: { id: projectUnique.environmentId }, + select: { project: { select: projectPermsSelect } }, + }))?.project + } else if (projectUnique.id) { + project = uuid.test(projectUnique.id) + ? await prisma.project.findUnique({ + where: { id: projectUnique.id }, + select: projectPermsSelect, + }) + : await prisma.project.findUnique({ + where: { slug: projectUnique.id }, + select: projectPermsSelect, + }) + } else if (projectUnique.slug) { + project = await prisma.project.findFirstOrThrow({ + where: { slug: projectUnique.slug }, + select: projectPermsSelect, + }) + } + if (!project) { + return baseReturnInfos + } + + const projectPermissions = getProjectPermissions(project, user) + + return { + user, + adminPermissions, + projectPermissions, + projectId: project.id, + projectLocked: project.locked, + projectStatus: project.status, + projectOwnerId: project.ownerId, + } +} + +function getProjectPermissions(project: ProjectMinimalPerms, user: UserDetails): bigint | undefined { + if (project.ownerId === user.id) return PP.MANAGE + const member = project.members.find(member => member.userId === user.id) + if (!member) return + + const memberRoles = project.roles.filter(role => member.roleIds.includes(role.id)) + return memberRoles.reduce((acc, curr) => acc | curr.permissions, project.everyonePerms | PROJECT_PERMS.GUEST) +} diff --git a/.jjconflict-side-2/apps/server/src/utils/date.spec.ts b/.jjconflict-side-2/apps/server/src/utils/date.spec.ts new file mode 100644 index 0000000000..7abcaa1aaa --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/date.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { getJSDateFromUtcIso } from './date.js' + +describe('date-util', () => { + it('should return a native Date object', () => { + const date = '2022-10-11' + + const received = getJSDateFromUtcIso(date) + + expect(received.getMonth()).toBe(9) + expect(received.getFullYear()).toBe(2022) + expect(received.getDate()).toBeGreaterThan(10) + expect(received.getDate()).toBeLessThan(12) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/utils/date.ts b/.jjconflict-side-2/apps/server/src/utils/date.ts new file mode 100644 index 0000000000..87473d2625 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/date.ts @@ -0,0 +1,5 @@ +import { parseISO } from 'date-fns' + +export function getJSDateFromUtcIso(dateUtcIso: string) { + return parseISO(dateUtcIso) +} diff --git a/.jjconflict-side-2/apps/server/src/utils/env.ts b/.jjconflict-side-2/apps/server/src/utils/env.ts new file mode 100644 index 0000000000..2ca618fd19 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/env.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs' +import path from 'node:path' +import { parseEnv } from 'node:util' + +if (process.env.DOCKER !== 'true' && fs.existsSync('.env')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +if (process.env.INTEGRATION === 'true' && fs.existsSync('.env.integ')) { + Object.assign(process.env, Object.fromEntries( + Object.entries(parseEnv(fs.readFileSync(path.resolve('.env.integ'), 'utf-8'))).filter(([_k, v]) => Boolean(v)), + )) +} + +// application mode +export const isDev = process.env.NODE_ENV === 'development' +export const isTest = process.env.NODE_ENV === 'test' +export const isProd = process.env.NODE_ENV === 'production' +export const isInt = process.env.INTEGRATION === 'true' +export const isCI = process.env.CI === 'true' +export const isDevSetup = process.env.DEV_SETUP === 'true' + +// app +export const port = process.env.SERVER_PORT +export const appVersion = isProd + ? (process.env.APP_VERSION ?? 'unknown') + : 'dev' + +// db +export const dbUrl = process.env.DB_URL + +// keycloak +export const sessionSecret = process.env.SESSION_SECRET +export const keycloakProtocol = process.env.KEYCLOAK_PROTOCOL +export const keycloakDomain = process.env.KEYCLOAK_DOMAIN +export const keycloakRealm = process.env.KEYCLOAK_REALM +export const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID +export const keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET +export const keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI +export const adminsUserId = process.env.ADMIN_KC_USER_ID + ? process.env.ADMIN_KC_USER_ID.split(',') + : [] + +export const contactEmail = process.env.CONTACT_EMAIL ?? 'cloudpinative-relations@interieur.gouv.fr' + +// plugins +export const mockPlugins = process.env.MOCK_PLUGINS === 'true' +export const projectRootDir = process.env.PROJECTS_ROOT_DIR +export const pluginsDir = process.env.PLUGINS_DIR ?? '/plugins' +export const NODE_ENV = process.env.NODE_ENV === 'test' + ? 'test' + : process.env.NODE_ENV === 'development' + ? 'development' + : 'production' + +// server tuning +export const parallelBulkLimit = process.env.PARALLEL_BULK_LIMIT ? Number(process.env.PARALLEL_BULK_LIMIT) : 5 diff --git a/.jjconflict-side-2/apps/server/src/utils/errors.ts b/.jjconflict-side-2/apps/server/src/utils/errors.ts new file mode 100644 index 0000000000..0f1dd07fbe --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/errors.ts @@ -0,0 +1,48 @@ +export class ErrorResType { + readonly status: 400 | 401 | 403 | 404 | 422 | 500 + body: { message: string } = { message: '' } + constructor(code: 400 | 401 | 403 | 404 | 422 | 500) { + this.status = code + } +} +export class BadRequest400 extends ErrorResType { + constructor(message: string) { + super(400) + this.body.message = message ?? 'Bad Request' + } +} + +export class Unauthorized401 extends ErrorResType { + constructor(message?: string) { + super(401) + this.body.message = message ?? 'Unauthorized' + } +} + +export class Forbidden403 extends ErrorResType { + constructor(message?: string) { + super(403) + this.body.message = message ?? 'Forbidden' + } +} + +export class NotFound404 extends ErrorResType { + constructor() { + super(404) + this.body.message = 'Not Found' + } +} + +export class Unprocessable422 extends ErrorResType { + constructor(message?: string) { + super(422) + this.body.message = message ?? 'Unprocessable Entity' + } +} + +export class Internal500 extends ErrorResType { + constructor(message: string) { + super(500) + this.body.message = message + } +} diff --git a/.jjconflict-side-2/apps/server/src/utils/fastify.ts b/.jjconflict-side-2/apps/server/src/utils/fastify.ts new file mode 100644 index 0000000000..c51281e3e9 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/fastify.ts @@ -0,0 +1,54 @@ +import type { FastifySwaggerUiOptions } from '@fastify/swagger-ui' +import type { generateOpenApi } from '@ts-rest/open-api' +import type { FastifyServerOptions } from 'fastify' +import { randomUUID } from 'node:crypto' +import { getLoggerOptionsFromEnv } from '@cpn-console/logger' +import { swaggerUiPath } from '@cpn-console/shared' +import { + appVersion, + keycloakClientId, + keycloakClientSecret, + keycloakRealm, + keycloakRedirectUri, +} from './env.js' + +export const fastifyConf: FastifyServerOptions = { + maxParamLength: 5000, + logger: getLoggerOptionsFromEnv(), + genReqId: () => randomUUID(), +} + +const externalDocs = { + description: 'External documentation.', + url: 'https://cloud-pi-native.fr', +} + +export const swaggerConf: Parameters[1] = { + info: { + title: 'Console Cloud Pi Native', + description: 'API de gestion des ressources Cloud Pi Native.', + version: appVersion, + }, + + externalDocs, + servers: [ + { + url: keycloakRedirectUri, + }, + ], +} + +export const swaggerUiConf: FastifySwaggerUiOptions = { + routePrefix: `${swaggerUiPath}-server`, + uiConfig: { + docExpansion: 'list', + deepLinking: false, + }, + initOAuth: { + clientId: keycloakClientId, + clientSecret: keycloakClientSecret, + realm: keycloakRealm, + appName: 'Cloud Pi Native', + scopes: 'openid generic', + }, +} diff --git a/.jjconflict-side-2/apps/server/src/utils/hook-wrapper.spec.ts b/.jjconflict-side-2/apps/server/src/utils/hook-wrapper.spec.ts new file mode 100644 index 0000000000..c2f4e3ebcc --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/hook-wrapper.spec.ts @@ -0,0 +1,239 @@ +import type { KubeCluster, KubeUser, Project as ProjectPayload, Store } from '@cpn-console/hooks' +import type { ProjectInfos, ReposCreds } from './hook-wrapper.ts' +import { describe, expect, it } from 'vitest' +import { transformToHookProject } from './hook-wrapper.ts' + +const associatedCluster = { + id: 'f0e39981-0b6d-4c16-aa96-225062b75767', + infos: '', + label: 'carno', + privacy: 'dedicated', + secretName: '4a38422c-29e1-4b61-b533-edaa1b8a9b60', + kubeconfig: { + id: 'c8ba6db2-9a1d-4d6b-8b5e-2902cecd1437', + user: { + keyData: 'REDACTED', + certData: 'REDACTED', + }, + cluster: { + caData: 'REDACTED', + server: 'https://api-server:6443', + skipTLSVerify: false, + tlsServerName: 'api-server', + }, + createdAt: '2024-05-02T09:17:27.882Z', + updatedAt: '2024-05-02T09:17:27.882Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0', + slug: 'default', + }, +} +const nonAssociatedCluster = { + id: 'f0e39981-0b6d-4c16-aa96-225062b75111', + infos: '', + label: 'carno2', + privacy: 'dedicated', + secretName: '4a38422c-29e1-4b61-b533-edaa1b8a9111', + kubeconfig: { + id: 'c8ba6db2-9a1d-4d6b-8b5e-2902cecd1111', + user: { + keyData: 'REDACTED', + certData: 'REDACTED', + }, + cluster: { + caData: 'REDACTED', + server: 'https://api-server:6443', + skipTLSVerify: false, + tlsServerName: 'api-server', + }, + createdAt: '2024-05-02T09:17:27.882Z', + updatedAt: '2024-05-02T09:17:27.882Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce0', + slug: 'default', + }, +} +const project: ProjectInfos = { + id: '011e7860-04d7-461f-912d-334c622d38b3', + name: 'candilib', + description: 'Application de réservation de places à l\'examen du permis B.', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.778Z', + updatedAt: '2023-07-03T14:46:56.783Z', + everyonePerms: 896n, + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + members: [], + clusters: [associatedCluster, nonAssociatedCluster], + environments: [ + { + id: '1b9f1053-fcf5-4053-a7b2-ff8a2c0c1921', + name: 'dev', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.787Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + quota: { + id: '5a57b62f-2465-4fb6-a853-5a751d099199', + memory: '4Gi', + cpu: 2, + name: 'small', + isPrivate: false, + }, + stage: { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + name: 'dev', + }, + cluster: { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + infos: 'Floating IP : 0.0.0.0', + label: 'pas-top-cluster', + privacy: 'dedicated', + secretName: '94d52618-7869-4192-b33e-85dd0959e815', + kubeconfig: { + id: 'b5662039-a62b-483e-ba54-b12c6f966c96', + user: { + token: 'kirikou', + }, + cluster: { + server: 'https://pwned.cluster', + tlsServerName: 'pwned.cluster', + }, + createdAt: '2024-07-24T16:54:14.969Z', + updatedAt: '2024-07-24T16:54:14.969Z', + }, + clusterResources: false, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + }, + }, + }, + { + id: '1c654f00-4798-4a80-929f-960ddb37885a', + name: 'integration', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + quotaId: '5a57b62f-2465-4fb6-a853-5a751d099199', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + quota: { + id: '5a57b62f-2465-4fb6-a853-5a751d099199', + memory: '4Gi', + cpu: 2, + name: 'small', + isPrivate: false, + }, + stage: { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + name: 'integration', + }, + cluster: { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + infos: null, + label: 'top-secret-cluster', + privacy: 'dedicated', + secretName: '59be2d50-58f9-42f3-95dc-b0c0518e3d8a', + kubeconfig: { + id: '0e88f000-07e6-4781-a69d-0963489387f7', + user: { + token: 'nyan cat', + }, + cluster: { + server: 'https://nothere.cluster', + skipTLSVerify: false, + tlsServerName: 'nothere.cluster', + }, + createdAt: '2024-07-24T16:54:14.966Z', + updatedAt: '2024-07-24T16:54:14.966Z', + }, + clusterResources: true, + zone: { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + }, + }, + }, + ], + repositories: [ + { + id: '299216bb-2dcc-42b5-ac71-6aa001d2dccf', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/dnum-mi/candilib.git', + externalUserName: 'this-is-a-test', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.802Z', + }, + ], + plugins: [], + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', + email: 'test@test.com', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + }, + roles: [], +} + +describe('transformToHookProject', () => { + // Mock data + const mockStore: Store = {} + const mockReposCreds: ReposCreds = { + console: { + token: 'test', + username: 'test', + }, + } + + it('transforme correctement le projet en objet Payload', () => { + const result: ProjectPayload = transformToHookProject(project, mockStore, mockReposCreds) + + // Asserts pour vérifier la transformation + + // Assert sur la transformation des utilisateurs + expect(result.users).toEqual([project.owner]) + + // Assert sur la transformation des rôles + expect(result.roles).toEqual([{ + name: 'owner', + position: 0, + users: [project.owner], + }]) + + // Assert sur la transformation des clusters + expect(result.clusters).toEqual([associatedCluster, nonAssociatedCluster].map(({ kubeconfig, ...cluster }) => ({ + user: kubeconfig.user as unknown as KubeUser, + cluster: kubeconfig.cluster as unknown as KubeCluster, + ...cluster, + privacy: cluster.privacy, + }))) + + // Assert sur la transformation des environnements + expect(result.environments).toEqual(project.environments.map(({ permissions: _, stage, quota, ...environment }) => ({ + quota, + stage: stage.name, + permissions: [{ permissions: { rw: true, ro: true }, userId: project.ownerId }], + ...environment, + apis: {}, + }))) + + // Assert sur la transformation des repositories + expect(result.repositories).toEqual(project.repositories.map(repo => ({ ...repo, newCreds: mockReposCreds[repo.internalRepoName] }))) + + // Assert sur le store + expect(result.store).toEqual(mockStore) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/utils/hook-wrapper.ts b/.jjconflict-side-2/apps/server/src/utils/hook-wrapper.ts new file mode 100644 index 0000000000..0df3d89a4b --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/hook-wrapper.ts @@ -0,0 +1,419 @@ +import type { ClusterObject, HookResult, KubeCluster, KubeUser, Project as ProjectPayload, RepoCreds, Repository, Store, ZoneObject } from '@cpn-console/hooks' +import type { AsyncReturnType } from '@cpn-console/shared' +import type { Cluster, Kubeconfig, Project, ProjectMembers, ProjectRole, Zone } from '@prisma/client' +import type { ConfigRecords } from '@/resources/project-service/business.js' +import { hooks } from '@cpn-console/hooks' +import { logger as baseLogger } from '@cpn-console/logger' +import { getPermsByUserRoles, ProjectAuthorized, resourceListToDict } from '@cpn-console/shared' +import { dbToObj } from '@/resources/project-service/business.js' +import { archiveProject, getAdminPlugin, getAdminRoleById, getClusterByIdOrThrow, getClusterNamesByZoneId, getClustersAssociatedWithProject, getHookProjectInfos, getHookRepository, getProjectStore, getRole, getZoneByIdOrThrow, saveProjectStore, updateProjectClusterHistory, updateProjectCreated, updateProjectFailed, updateProjectWarning } from '@/resources/queries-index.js' +import { genericProxy } from './proxy.js' + +export type ReposCreds = Record +export type ProjectInfos = AsyncReturnType + +const logger = baseLogger.child({ scope: 'utils:hook-wrapper' }) + +function summarizeHookResultForLogs(hookResult: HookResult) { + const nonOkResults = Object.fromEntries( + Object.entries(hookResult.results ?? {}) + .filter(([_pluginName, result]) => result?.status?.result !== 'OK') + .map(([pluginName, result]) => [ + pluginName, + { + result: result.status.result, + message: result.status.result === 'OK' ? undefined : result.status.message, + }, + ]), + ) + + return { + failed: hookResult.failed, + warning: hookResult.warning, + messageResume: hookResult.messageResume, + totalExecutionTime: hookResult.totalExecutionTime, + nonOkResults, + } +} + +async function getProjectPayload(projectId: Project['id'], reposCreds?: ReposCreds) { + const [ + project, + store, + clusters, + ] = await Promise.all([ + getHookProjectInfos(projectId), + getProjectStore(projectId), + getClustersAssociatedWithProject(projectId), + ]) + + return transformToHookProject({ + ...project, + clusters, + }, dbToObj(store), reposCreds) +} + +async function upsertProject(projectId: Project['id'], reposCreds?: ReposCreds) { + const [payload, config] = await Promise.all([ + getProjectPayload(projectId, reposCreds), + getAdminPlugin(), + ]) + + const results = await hooks.upsertProject.execute(payload, dbToObj(config)) + + const records: ConfigRecords = Object.entries(results.results).reduce((acc, [pluginName, result]) => { + if (result.store) { + return [...acc, ...Object.entries(result.store).map(([key, value]) => ({ pluginName, key, value: String(value) }))] + } + return acc + }, [] as ConfigRecords) + + await saveProjectStore(records, projectId) + const project = await manageProjectStatus(projectId, results, 'upsert', payload.environments.map(env => env.clusterId)) + return { + results, + project, + } +} +const project = { + upsert: async (projectId: Project['id'], reposCreds?: ReposCreds) => { + const first = await upsertProject(projectId, reposCreds) + if (!first.results.failed) { + return first + } + + logger.warn({ + action: 'upsertProject', + projectId, + attempt: 1, + maxAttempts: 2, + ...summarizeHookResultForLogs(first.results), + }, 'Hook upsertProject failed, retrying once') + + const second = await upsertProject(projectId, reposCreds) + const logPayload = { + action: 'upsertProject', + projectId, + attempt: 2, + maxAttempts: 2, + ...summarizeHookResultForLogs(second.results), + } + if (second.results.failed) { + logger.error(logPayload, 'Hook upsertProject retry failed') + } else { + logger.info(logPayload, 'Hook upsertProject retry succeeded') + } + + return second + }, + delete: async (projectId: Project['id']) => { + const [payload, config] = await Promise.all([ + getProjectPayload(projectId), + getAdminPlugin(), + ]) + const results = await hooks.deleteProject.execute(payload, dbToObj(config)) + return { + results, + project: await manageProjectStatus(projectId, results, 'delete', []), + } + }, + getSecrets: async (projectId: Project['id']) => { + const project = await getHookProjectInfos(projectId) + const store = dbToObj(await getProjectStore(project.id)) + const config = dbToObj(await getAdminPlugin()) + + return hooks.getProjectSecrets.execute({ ...project, store }, config) + }, +} as const + +type ProjectAction = keyof typeof project +async function manageProjectStatus(projectId: Project['id'], hookReply: HookResult, action: ProjectAction, envClusterIds: Cluster['id'][]): Promise> { + if (!hookReply.failed && hookReply.results?.kubernetes) { + await updateProjectClusterHistory(projectId, envClusterIds) + } + if (hookReply.failed) { + return updateProjectFailed(projectId) + } else if (hookReply.warning.length) { + return updateProjectWarning(projectId) + } else if (action === 'upsert') { + return updateProjectCreated(projectId) + } else if (action === 'delete') { + return archiveProject(projectId) + } + throw new Error('unknown action') +} + +const cluster = { + upsert: async (clusterId: Cluster['id'], previousZoneId: Cluster['zoneId']) => { + const cluster = await getClusterByIdOrThrow(clusterId) + const clusterObject = cluster as unknown as ClusterObject + const store = dbToObj(await getAdminPlugin()) + if (cluster.zoneId !== previousZoneId) { + // Upsert on the old zone to remove cluster + const previousClusterObject = { + ...cluster, + } as unknown as ClusterObject + previousClusterObject.zone = await getZoneByIdOrThrow(previousZoneId) + previousClusterObject.zone.clusterNames = await getClusterNamesByZoneId(previousZoneId) + const hookResult = await hooks.upsertCluster.execute({ + ...cluster.kubeconfig as unknown as Pick, + ...previousClusterObject, + }, store) + if (hookResult.failed) { + return hookResult + } + } + clusterObject.zone.clusterNames = await getClusterNamesByZoneId(cluster.zoneId) + return hooks.upsertCluster.execute({ + ...cluster.kubeconfig as unknown as Pick, + ...clusterObject, + }, store) + }, + delete: async (clusterId: Cluster['id']) => { + const cluster = await getClusterByIdOrThrow(clusterId) + const clusterObject = cluster as unknown as ClusterObject + const clusterNames = await getClusterNamesByZoneId(cluster.zoneId) + clusterObject.zone.clusterNames = clusterNames.filter(c => c !== cluster.label) + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteCluster.execute({ + ...cluster.kubeconfig as unknown as ClusterObject, + ...clusterObject, + }, store) + }, +} as const + +const user = { + retrieveUserByEmail: async (email: string) => { + const config = dbToObj(await getAdminPlugin()) + return hooks.retrieveUserByEmail.execute({ email }, config) + }, +} as const + +const projectMember = { + upsert: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { + const project = await getHookProjectInfos(projectId) + const projectStore = dbToObj(await getProjectStore(project.id)) + const hookProject = transformToHookProject(project, projectStore) + const store = dbToObj(await getAdminPlugin()) + + const member = project.members.find(m => m.userId === userId) + if (!member) throw new Error('Member not found') + + const memberRoles = project.roles + .filter(role => member.roleIds.includes(role.id)) + .map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ?? undefined, + project: hookProject, + })) + + const payload = { + userId: member.userId, + roleIds: member.roleIds, + firstName: member.user.firstName, + lastName: member.user.lastName, + email: member.user.email, + type: member.user.type as 'human' | 'bot' | 'ghost', + createdAt: member.user.createdAt.toISOString(), + updatedAt: member.user.updatedAt.toISOString(), + lastLogin: member.user.lastLogin?.toISOString(), + projectId: project.id, + roles: memberRoles, + project: hookProject, + } + + return hooks.upsertProjectMember.execute(payload, store) + }, + delete: async (projectId: Project['id'], userId: ProjectMembers['userId']) => { + const project = await getHookProjectInfos(projectId) + const projectStore = dbToObj(await getProjectStore(project.id)) + const hookProject = transformToHookProject(project, projectStore) + const store = dbToObj(await getAdminPlugin()) + + const member = project.members.find(m => m.userId === userId) + if (!member) throw new Error('Member not found') + + const memberRoles = project.roles + .filter(role => member.roleIds.includes(role.id)) + .map(role => ({ + ...role, + permissions: role.permissions.toString(), + oidcGroup: role.oidcGroup ?? undefined, + project: hookProject, + })) + + const payload = { + userId: member.userId, + roleIds: member.roleIds, + firstName: member.user.firstName, + lastName: member.user.lastName, + email: member.user.email, + type: member.user.type as 'human' | 'bot' | 'ghost', + createdAt: member.user.createdAt.toISOString(), + updatedAt: member.user.updatedAt.toISOString(), + lastLogin: member.user.lastLogin?.toISOString(), + projectId: project.id, + roles: memberRoles, + project: hookProject, + } + + return hooks.deleteProjectMember.execute(payload, store) + }, +} as const + +const projectRole = { + upsert: async (roleId: ProjectRole['id']) => { + const role = await getRole(roleId) + if (!role) throw new Error('Role not found') + + const project = await getHookProjectInfos(role.projectId) + const projectStore = dbToObj(await getProjectStore(role.projectId)) + const hookProject = transformToHookProject(project, projectStore) + + const rolePayload = { + ...role, + permissions: role.permissions.toString(), + project: hookProject, + } + const store = dbToObj(await getAdminPlugin()) + return hooks.upsertProjectRole.execute(rolePayload, store) + }, + delete: async (roleId: ProjectRole['id']) => { + const role = await getRole(roleId) + if (!role) throw new Error('Role not found') + + const project = await getHookProjectInfos(role.projectId) + const projectStore = dbToObj(await getProjectStore(role.projectId)) + const hookProject = transformToHookProject(project, projectStore) + + const rolePayload = { + ...role, + permissions: role.permissions.toString(), + project: hookProject, + } + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteProjectRole.execute(rolePayload, store) + }, +} as const + +const zone = { + upsert: async (zoneId: Zone['id']) => { + const zone: ZoneObject = await getZoneByIdOrThrow(zoneId) + zone.clusterNames = await getClusterNamesByZoneId(zoneId) + const store = dbToObj(await getAdminPlugin()) + return hooks.upsertZone.execute(zone, store) + }, + delete: async (zoneId: Zone['id']) => { + const zone = await getZoneByIdOrThrow(zoneId) + const store = dbToObj(await getAdminPlugin()) + return hooks.deleteZone.execute(zone, store) + }, +} as const + +const misc = { + checkServices: async () => { + const config = dbToObj(await getAdminPlugin()) + return hooks.checkServices.execute({}, config) + }, + syncRepository: async (repoId: string, { syncAllBranches, branchName }: { syncAllBranches: boolean, branchName?: string }) => { + const { project, ...repoInfos } = await getHookRepository(repoId) + const store = dbToObj(await getProjectStore(project.id)) + const payload = { + repo: { ...repoInfos, syncAllBranches, branchName }, + ...project, + store, + } + const config = dbToObj(await getAdminPlugin()) + return hooks.syncRepository.execute(payload, config) + }, +} as const + +const adminRole = { + upsert: async (roleId: string) => { + const role = await getAdminRoleById(roleId) + if (!role) throw new Error('Role not found') + const config = dbToObj(await getAdminPlugin()) + return hooks.upsertAdminRole.execute(role, config) + }, + delete: async (role: AsyncReturnType) => { + if (!role) throw new Error('Role is required') + const config = dbToObj(await getAdminPlugin()) + return hooks.deleteAdminRole.execute(role, config) + }, +} as const + +export const hook = { + // @ts-ignore TODO voir comment opti la signature de la fonction + misc: genericProxy(misc), + // @ts-ignore TODO voir comment opti la signature de la fonction + project: genericProxy(project, { upsert: ['delete'], delete: ['upsert', 'delete'], getSecrets: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + projectRole: genericProxy(projectRole, { delete: ['upsert', 'delete'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + projectMember: genericProxy(projectMember, { delete: ['upsert'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + cluster: genericProxy(cluster, { delete: ['upsert', 'delete'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + zone: genericProxy(zone, { delete: ['upsert'], upsert: ['delete'] }), + // @ts-ignore TODO voir comment opti la signature de la fonction + user: genericProxy(user, {}), + // @ts-ignore TODO voir comment opti la signature de la fonction + adminRole: genericProxy(adminRole, { delete: ['upsert'], upsert: ['delete'] }), +} + +function formatClusterInfos({ kubeconfig, ...cluster }: Omit + & { kubeconfig: Kubeconfig, zone: Pick }) { + return { + user: kubeconfig.user as unknown as KubeUser, + cluster: kubeconfig.cluster as unknown as KubeCluster, + ...cluster, + privacy: cluster.privacy, + } +} +export type RolesById = Record + +export function transformToHookProject(project: ProjectInfos, store: Store, reposCreds: ReposCreds = {}): ProjectPayload { + const clusters = project.clusters.map(cluster => formatClusterInfos(cluster)) + const rolesById = resourceListToDict(project.roles) + + return ({ + ...project, + clusters, + environments: project.environments.map(({ stage, ...environment }) => ({ + stage: stage.name, + permissions: [ + { permissions: { rw: true, ro: true }, userId: project.ownerId }, + ...project.members.map(member => ({ + userId: member.userId, + permissions: { + ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions: getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) }), + rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions: getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) }), + }, + })), + ], + ...environment, + apis: {}, + })), + repositories: project.repositories.map(repo => ({ ...repo, newCreds: reposCreds[repo.internalRepoName] })), + store, + users: [project.owner, ...project.members.map(({ user }) => user)], + roles: [ + { + name: 'owner', + position: 0, + users: [project.owner], + }, + ...project.roles.map(role => ({ + name: role.name, + permissions: role.permissions.toString(), + position: role.position, + type: role.type, + oidcGroup: role.oidcGroup, + users: project.members + .filter(member => member.roleIds.includes(role.id)) + .map(member => member.user), + })), + ], + }) +} diff --git a/.jjconflict-side-2/apps/server/src/utils/keycloak-utils.spec.ts b/.jjconflict-side-2/apps/server/src/utils/keycloak-utils.spec.ts new file mode 100644 index 0000000000..42c9cfae04 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/keycloak-utils.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { userPayloadMapper } from './keycloak-utils.js' + +describe('keycloak', () => { + it('should map keycloak user object to DSO user object without groups', () => { + const payload = { + sub: 'thisIsAnId', + email: 'test@test.com', + given_name: 'Jean', + family_name: 'DUPOND', + } + const desired = { + id: 'thisIsAnId', + email: 'test@test.com', + firstName: 'Jean', + lastName: 'DUPOND', + groups: [], + } + + const transformed = userPayloadMapper(payload) + + expect(transformed).toMatchObject(desired) + }) + + it('should map keycloak user object to DSO user object with groups', () => { + const payload = { + sub: 'thisIsAnId', + email: 'test@test.com', + given_name: 'Jean', + family_name: 'DUPOND', + groups: ['group1'], + } + const desired = { + id: 'thisIsAnId', + email: 'test@test.com', + firstName: 'Jean', + lastName: 'DUPOND', + groups: ['group1'], + } + + const transformed = userPayloadMapper(payload) + + expect(transformed).toMatchObject(desired) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/utils/keycloak-utils.ts b/.jjconflict-side-2/apps/server/src/utils/keycloak-utils.ts new file mode 100644 index 0000000000..a86a5f13c3 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/keycloak-utils.ts @@ -0,0 +1,27 @@ +import type { FastifyRequest } from 'fastify' +import { tokenHeaderName } from '@cpn-console/shared' + +interface KeycloakPayload { + sub: string + email: string + given_name: string + family_name: string + groups: string[] +} + +export function userPayloadMapper(userPayload: KeycloakPayload) { + return { + id: userPayload.sub, + email: userPayload.email, + firstName: userPayload.given_name, + lastName: userPayload.family_name, + groups: userPayload.groups || [], + } +} + +export function bypassFn(request: FastifyRequest) { + try { + return !!request.headers[tokenHeaderName] + } catch {} + return false +} diff --git a/.jjconflict-side-2/apps/server/src/utils/keycloak.ts b/.jjconflict-side-2/apps/server/src/utils/keycloak.ts new file mode 100644 index 0000000000..73137efc3b --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/keycloak.ts @@ -0,0 +1,42 @@ +import type { KeycloakOptions } from 'fastify-keycloak-adapter' +import { serviceContract, swaggerUiPath, systemContract } from '@cpn-console/shared' +import { + keycloakClientId, + keycloakClientSecret, + keycloakDomain, + keycloakProtocol, + keycloakRealm, + keycloakRedirectUri, + sessionSecret, +} from './env.js' +import { bypassFn, userPayloadMapper } from './keycloak-utils.js' + +export const keycloakConf = { + appOrigin: keycloakRedirectUri ?? 'http://localhost:8080', + keycloakSubdomain: `${keycloakDomain}/realms/${keycloakRealm}`, + clientId: keycloakClientId ?? '', + clientSecret: keycloakClientSecret ?? '', + useHttps: keycloakProtocol === 'https', + disableCookiePlugin: true, + disableSessionPlugin: true, + // @ts-ignore + userPayloadMapper, + retries: 5, + excludedPatterns: [ + systemContract.getVersion.path, + systemContract.getHealth.path, + serviceContract.getServiceHealth.path, + `${swaggerUiPath}-server/**`, + ], + bypassFn, +} as const satisfies KeycloakOptions + +export const sessionConf = { + cookieName: 'sessionId', + secret: sessionSecret || 'a-very-strong-secret-with-more-than-32-char', + cookie: { + httpOnly: true, + secure: true, + }, + expires: 1_800_000, +} diff --git a/.jjconflict-side-2/apps/server/src/utils/mocks.ts b/.jjconflict-side-2/apps/server/src/utils/mocks.ts new file mode 100644 index 0000000000..16169f4d31 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/mocks.ts @@ -0,0 +1,152 @@ +import type { PluginsManifests, RepoCreds, ServiceInfos } from '@cpn-console/hooks' +import type { Repository } from '@prisma/client' +import type { UserDetails } from '../types/index.js' +import type * as utilsController from '../utils/controller.js' +import { editStrippers, populatePluginManifests } from '@cpn-console/hooks' +import { DEFAULT, DISABLED, PROJECT_PERMS } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import fp from 'fastify-plugin' + +let requestor: Requestor + +export function setRequestor(user: Requestor = getRandomRequestor()) { + requestor = user +} + +export function getRequestor() { + return requestor +} + +export async function mockSessionPlugin() { + const sessionPlugin = (app: any, opt: any, next: any) => { + app.addHook('onRequest', (req: any, res: any, next: any) => { + req.session = { user: getRequestor() } + next() + }) + next() + } + + return { default: fp(sessionPlugin) } +} + +export async function mockHooksPackage() { + const hookTemplate = { + execute: () => ({ + args: {}, + failed: false, + }), + validate: () => ({ + failed: false, + }), + } + + return { + editStrippers, + populatePluginManifests, + services: { + getStatus: () => [], + refreshStatus: async () => [], + }, + PluginApi: class { }, + servicesInfos: { + registry: { title: 'Harbor', name: 'registry', to: () => 'test' }, + plugin2: { title: 'Plugin2', name: 'plugin2', to: () => ({ to: 'test', title: 'Test' }) }, + plugin3: { title: 'Plugin3', name: 'plugin3', to: () => [{ to: 'test', title: 'Test' }] }, + plugin4: { title: 'Plugin4', name: 'plugin4', to: () => [{ to: 'test' }] }, + plugin5: { title: 'Plugin5', name: 'plugin5' }, + } as Record, + pluginsManifests: { + registry: { + title: 'Harbor', + global: [{ + kind: 'switch', + initialValue: DEFAULT, + key: 'test2', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Test2', + value: DEFAULT, + description: 'description', + }], + project: [{ + kind: 'switch', + key: 'test2', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Test', + value: DEFAULT, + initialValue: DISABLED, + }], + }, + } as PluginsManifests, + hooks: { + // projects + getProjectSecrets: { + execute: () => ({ + failed: false, + args: {}, + results: { + registry: { + secrets: { + token: 'myToken', + }, + status: { + failed: false, + }, + }, + }, + }), + }, + upsertProject: hookTemplate, + deleteProject: hookTemplate, + // clusters + upsertCluster: hookTemplate, + deleteCluster: hookTemplate, + // user + retrieveUserByEmail: hookTemplate, + }, + } +} + +export type ReposCreds = Record + +type Requestor = Partial +export function getRandomRequestor(user?: Requestor): Partial { + return { + id: user?.id ?? faker.string.uuid(), + email: user?.email ?? faker.internet.email(), + firstName: user?.firstName ?? faker.person.firstName(), + lastName: user?.lastName ?? faker.person.lastName(), + type: 'human', + ...user?.groups !== null && { groups: user?.groups ?? [] }, + } +} + +export function getUserMockInfos(adminPermissions?: bigint, user?: UserDetails): utilsController.UserProfile & utilsController.ProjectPermState +export function getUserMockInfos(adminPermissions?: bigint, user?: UserDetails, project?: utilsController.ProjectPermState): utilsController.UserProjectProfile & utilsController.ProjectPermState +export function getUserMockInfos(adminPermissions: bigint = 0n, user = getRandomRequestor(), project?: utilsController.ProjectPermState): utilsController.UserProfile | utilsController.UserProjectProfile { + return { + adminPermissions, + user, + ...project, + } +} + +export function getProjectMockInfos({ projectId, projectLocked, projectOwnerId, projectPermissions, projectStatus }: Partial): utilsController.ProjectPermState { + return { + projectId: projectId ?? faker.string.uuid(), + projectLocked: projectLocked ?? false, + projectOwnerId: projectOwnerId ?? faker.string.uuid(), + projectStatus: projectStatus ?? 'created', + projectPermissions: projectPermissions ?? PROJECT_PERMS.MANAGE, + } +} + +export const atDates = { + createdAt: new Date(), + updatedAt: new Date(), +} diff --git a/.jjconflict-side-2/apps/server/src/utils/plugins.ts b/.jjconflict-side-2/apps/server/src/utils/plugins.ts new file mode 100644 index 0000000000..9876677766 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/plugins.ts @@ -0,0 +1,9 @@ +import type { PluginManagerOptions } from '@cpn-console/hooks' +import { isCI, isInt, isProd } from './env.js' + +export const pluginManagerOptions: PluginManagerOptions = { + mockHooks: isCI || (!isProd && !isInt), + mockMonitoring: isCI || (!isProd && !isInt), + mockExternalServices: isCI || (!isProd && !isInt), + startPlugins: (!isCI && isProd) || isInt, +} diff --git a/.jjconflict-side-2/apps/server/src/utils/proxy.spec.ts b/.jjconflict-side-2/apps/server/src/utils/proxy.spec.ts new file mode 100644 index 0000000000..7fdc203833 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/proxy.spec.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { genericProxy } from './proxy.js' + +// Création d'une cible de test +const target = { + async fetchData(id: string) { + return { id, data: 'Mocked data' } + }, + async otherMethod(id: string) { + return { id, data: 'Mocked data' } + }, +} + +describe('test calls without ID passed', () => { + // Test d'appel de méthode sans ID + it('calling method without ID', async () => { + const proxied = genericProxy(target) + const result = await proxied.fetchData() + expect(result).toEqual({ id: undefined, data: 'Mocked data' }) + }) + + // Fonction de test asynchrone pour tester le cas où aucune ID n'est fournie + it('test when no ID is provided', async () => { + // Création d'une cible de test + const target = { + async fetchData() { + return 'No ID provided' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Appel à la méthode fetchData sans ID + const result = await proxied.fetchData() + + // Vérification que le résultat est correct + expect(result).toBe('No ID provided') + }) + + // Fonction de test asynchrone pour tester le cas où aucune ID n'est fournie avec une promesse en cours + it('test when no ID is provided with pending promise', async () => { + // Création d'une cible de test + const target = { + async fetchData() { + return new Promise(resolve => setTimeout(resolve, 100, 'Pending result')) + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Appel à la méthode fetchData sans ID + const promise1 = proxied.fetchData() + const promise2 = proxied.fetchData() // Deuxième appel avant la résolution du premier + + // Attendre que la première promesse se résolve + const result1 = await promise1 + + // Vérification que le résultat de la première promesse est correct + expect(result1).toBe('Pending result') + + // Attendre que la deuxième promesse se résolve + const result2 = await promise2 + + // Vérification que le résultat de la deuxième promesse est correct + expect(result2).toBe('Pending result') + }) + // Test pour vérifier que l'erreur est levée lorsque args est fourni sans ID + it('test error when args provided without ID', async () => { + // Création d'une cible de test + const target = { + async fetchData(_id: string, _args: any) { + return 'No ID provided' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + const args = { key: 'value' } + + // Appel de la fonction fetchData avec des arguments mais sans ID + await expect(proxied.fetchData(undefined, args)).rejects.toThrow('ID is required when args are provided') + }) +}) + +describe('test calls with ID passed', () => { + // Test d'appel de méthode avec ID + it('calling method with ID', async () => { + const proxied = genericProxy(target) + const result = await proxied.fetchData('123') + expect(result).toEqual({ id: '123', data: 'Mocked data' }) + }) + + // Test d'appel de méthode avec exclusion en cours + it('calling method with exclusion in progress', async () => { + const proxied = genericProxy(target, { fetchData: ['otherMethod'] }) + // Simuler une exécution en cours pour la méthode exclue + proxied.otherMethod('456') + + // Maintenant, tenter d'appeler fetchData pour le même ID devrait échouer + await expect(proxied.fetchData('456')).rejects.toThrow( + 'otherMethod in progress on 456, can\'t fetchData', + ) + }) + + // Fonction de test asynchrone pour tester le mélange des nextArgs + it('test mixing nextArgs from concurrent promises', async () => { + // Création d'une cible de test + const target = { + async fetchData(id: string, args?: object) { + return { id, args } + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + const promise1 = proxied.fetchData('123', { key1: 'value1' }) + // Appels successifs à fetchData avec différents arguments + const promise2 = proxied.fetchData('123', { key2: 'value2' }) + + // Promesse concurrente avec des nextArgs différents + const promise3 = proxied.fetchData('123', { key3: 'value3' }) + + // Attendre que les promesses se résolvent + const result1 = await promise1 + const result2 = await promise2 + const result3 = await promise3 + + // Vérification que les nextArgs de promise2 et promise3 ont été correctement mélangés + expect(result1.args).toEqual({ key1: 'value1' }) + expect(result2.args).toEqual({ key2: 'value2', key3: 'value3' }) + expect(result3.args).toEqual({ key2: 'value2', key3: 'value3' }) + }) + + it('test rejection of set attempt', () => { + // Création d'une cible de test + const target = { + async fetchData() { + return 'Mocked data' + }, + } + + // Création du proxy + const proxied = genericProxy(target) + + // Tentative de définir une nouvelle propriété sur le proxy + const setAttempt = () => { + proxied.fetchData = () => new Promise(resolve => resolve('illegal')) + } + + // Vérification que la tentative de set est rejetée + expect(setAttempt).toThrow(TypeError) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/utils/proxy.ts b/.jjconflict-side-2/apps/server/src/utils/proxy.ts new file mode 100644 index 0000000000..ef915a7d15 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/proxy.ts @@ -0,0 +1,78 @@ +// @ts-nocheck un enfer à typer, pour plus tard +type Tracker> = Record + nextArgs?: [string] +}>> | Promise + +type Target = Record Promise> +type Excludes = Partial>> | undefined +const toTarget = (target: T) => ({ tracker: {} as Tracker, methods: target }) + +// @ts-ignore +export function genericProxy(proxied: T, excludes: Excludes = {}): T { + return new Proxy(toTarget(proxied), { + get({ methods, tracker }, property: string) { + if (!(property in methods)) return + return async (...args) => { + const id = args[0] as string + + if (!id && args.length > 0) { + throw new Error('ID is required when args are provided') + } + + if (!id) { + if (tracker[property] instanceof Promise) { + return tracker[property] + } + const p = methods[property]() + if (p instanceof Promise) { + tracker[property] = p + p.then(() => { + delete tracker[property] + }) + } + return p + } + if (!tracker[property]) { + tracker[property] = {} + } + + for (const testExclude of excludes[property] ?? []) { + // @ts-ignore + if (tracker?.[testExclude]?.[id]?.currentExec) { + throw new Error(`${String(testExclude)} in progress on ${id}, can't ${String(property)}`) + } + } + + if (id in tracker[property]) { + if (args[1]) { + tracker[property][id].nextArgs = { + ...(tracker[property][id].nextArgs ?? {}), + ...args[1], + } + } + if (tracker[property][id].currentExec) { + return new Promise((resolve) => { + tracker[property][id].currentExec.then(() => { + resolve(tracker[property][id].currentExec ?? methods[property](id, tracker[property][id].nextArgs)) + }) + }) + } + } + const p = methods[property](...args) + tracker[property][id] = { + currentExec: p, + nextArgs: undefined, + } + tracker[property][id].currentExec = p + p.then(() => { + tracker[property][id].currentExec = undefined + }) + return p + } + }, + set() { + return false + }, + }) as T +} diff --git a/.jjconflict-side-2/apps/server/src/utils/queries-tools.spec.ts b/.jjconflict-side-2/apps/server/src/utils/queries-tools.spec.ts new file mode 100644 index 0000000000..e37ed94c25 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/queries-tools.spec.ts @@ -0,0 +1,47 @@ +import { exclude } from '@cpn-console/shared' +import { describe, expect, it } from 'vitest' +import { filterObjectByKeys } from './queries-tools.js' + +describe('queries-tools', () => { + it('should return a filtered object (filterObjectByKeys)', () => { + const initial = { + id: 'thisIsAnId', + name: 'alsoKeepThisKey', + description: 'keepThisKey', + } + const desired = { + name: 'alsoKeepThisKey', + description: 'keepThisKey', + } + + const transformed = filterObjectByKeys(initial, ['name', 'description']) + + expect(transformed).toMatchObject(desired) + }) + + it('should return a filtered object (exclude)', () => { + const initial = { + id: 'thisIsAnId', + name: 'myProjectName', + environment: { + permissions: { + password: 'secret', + id: 'notSecret', + }, + }, + } + const desired = { + id: 'thisIsAnId', + name: 'myProjectName', + environment: { + permissions: { + id: 'notSecret', + }, + }, + } + + const transformed = exclude(initial, ['password']) + + expect(transformed).toMatchObject(desired) + }) +}) diff --git a/.jjconflict-side-2/apps/server/src/utils/queries-tools.ts b/.jjconflict-side-2/apps/server/src/utils/queries-tools.ts new file mode 100644 index 0000000000..856ca277f1 --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/queries-tools.ts @@ -0,0 +1,11 @@ +export const dbKeysExcluded = ['updatedAt', 'createdAt'] + +// TODO +// @ts-ignore supprimer cette fonction et utiliser des schémas zod où elle est utilisé +export function filterObjectByKeys(obj, keys) { + return Object.fromEntries( + Object.entries(obj)?.filter(([key, _value]) => keys.includes(key)), + ) +} + +export const uuid: RegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i diff --git a/.jjconflict-side-2/apps/server/src/utils/random.spec.ts b/.jjconflict-side-2/apps/server/src/utils/random.spec.ts new file mode 100644 index 0000000000..3c33d6a4ee --- /dev/null +++ b/.jjconflict-side-2/apps/server/src/utils/random.spec.ts @@ -0,0 +1,148 @@ +import { createRandomDbSetup } from '@cpn-console/test-utils' +import { describe, expect, it } from 'vitest' + +describe('random utils', () => { + // TODO + it.skip('should create a random db for tests', () => { + const db = createRandomDbSetup({ nbUsers: 3, nbRepo: 1, envs: ['dev', 'prod'] }) + expect(db).toEqual( + expect.objectContaining({ + stages: expect.arrayContaining([ + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + { + id: expect.any(String), + name: expect.any(String), + }, + ]), + quotas: expect.arrayContaining([ + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + { + id: expect.any(String), + name: expect.any(String), + memory: expect.any(String), + cpu: expect.any(Number), + isPrivate: expect.any(Boolean), + }, + ]), + project: expect.objectContaining({ + id: expect.any(String), + name: expect.any(String), + clusters: expect.arrayContaining([{ + caData: expect.any(String), + server: expect.any(String), + tlsServername: expect.any(String), + }]), + status: expect.any(String), + locked: expect.any(Boolean), + roles: expect.arrayContaining([ + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + { + userId: expect.any(String), + projectId: expect.any(String), + role: expect.any(String), + user: expect.objectContaining({ + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }), + }, + ]), + repositories: expect.any(Array), + environments: expect.arrayContaining([ + { + id: expect.any(String), + stageId: expect.any(String), + projectId: expect.any(String), + quotaId: expect.any(String), + status: expect.any(String), + permissions: expect.any(Array), + clusters: expect.any(Array), + }, + { + id: expect.any(String), + stageId: expect.any(String), + projectId: expect.any(String), + quotaId: expect.any(String), + status: expect.any(String), + permissions: expect.any(Array), + clusters: expect.any(Array), + }, + ]), + }), + users: expect.arrayContaining([ + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + { + id: expect.any(String), + email: expect.any(String), + firstName: expect.any(String), + lastName: expect.any(String), + }, + ]), + }), + ) + }) +}) diff --git a/.jjconflict-side-2/apps/server/tsconfig.json b/.jjconflict-side-2/apps/server/tsconfig.json new file mode 100644 index 0000000000..a3397898ca --- /dev/null +++ b/.jjconflict-side-2/apps/server/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "useUnknownInCatchVariables": false, + "declarationDir": "./types", + "outDir": "./dist", + "plugins": [{ "transform": "typescript-transform-paths" }] + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__", + "./src/mocks/utils.ts", + "./src/utils/mocks.ts" + ] +} diff --git a/.jjconflict-side-2/apps/server/vite.config.ts b/.jjconflict-side-2/apps/server/vite.config.ts new file mode 100644 index 0000000000..f9a3e50fae --- /dev/null +++ b/.jjconflict-side-2/apps/server/vite.config.ts @@ -0,0 +1,18 @@ +/// +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + poolMatchGlobs: [ + ['**/resources/**/*.spec.ts', 'forks'], + ], + }, +}) diff --git a/.jjconflict-side-2/apps/server/vitest-init.ts b/.jjconflict-side-2/apps/server/vitest-init.ts new file mode 100644 index 0000000000..596420f7de --- /dev/null +++ b/.jjconflict-side-2/apps/server/vitest-init.ts @@ -0,0 +1,11 @@ +process.env.ARGOCD_URL = 'https://argo-cd.readthedocs.io' +process.env.GITLAB_URL = 'https://gitlab.com' +process.env.HARBOR_URL = 'https://goharbor.io' +process.env.NEXUS_URL = 'https://sonatype.com/products/nexus-repository' +process.env.SONARQUBE_URL = 'https://www.sonarqube.org' +process.env.VAULT_URL = 'https://www.vaultproject.io' +process.env.PROJECTS_ROOT_DIR = 'forge-mi/projects' +process.env.KEYCLOAK_REDIRECT_URI = 'http://console.dso.local' +process.env.CONTACT_EMAIL = 'cloudpinative-relations@interieur.gouv.fr' +process.env.OPENCDS_URL = 'https://opencds.gouv.fr' +process.env.OPENCDS_API_TOKEN = 'test_token' diff --git a/.jjconflict-side-2/apps/server/vitest.config.ts b/.jjconflict-side-2/apps/server/vitest.config.ts new file mode 100644 index 0000000000..9dc913021e --- /dev/null +++ b/.jjconflict-side-2/apps/server/vitest.config.ts @@ -0,0 +1,35 @@ +import { fileURLToPath } from 'node:url' +import { mergeConfig } from 'vite' +import { configDefaults, defineConfig } from 'vitest/config' +import viteConfig from './vite.config' + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + reporters: ['default', 'hanging-process'], + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: [ + '**/types', + '**/mocks', + '**/*.spec.ts', + '**/*.d.ts', + '**/*.vue', + '**/queries.ts', + '**/mocks.ts', + '**/*.sql', + ], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + setupFiles: ['./vitest-init.ts'], + root: fileURLToPath(new URL('./', import.meta.url)), + pool: 'forks', + }, + }), +) diff --git a/.jjconflict-side-2/ci/matrix-docker.json b/.jjconflict-side-2/ci/matrix-docker.json new file mode 100644 index 0000000000..3550832dba --- /dev/null +++ b/.jjconflict-side-2/ci/matrix-docker.json @@ -0,0 +1,42 @@ +[ + { + "name": "server", + "build": { + "context": "./", + "dockerfile": "./apps/server/Dockerfile", + "target": "prod" + } + }, + { + "name": "nginx-strangler", + "build": { + "context": "./", + "dockerfile": "./apps/nginx-strangler/Dockerfile", + "target": "prod" + } + }, + { + "name": "server-nestjs", + "build": { + "context": "./", + "dockerfile": "./apps/server-nestjs/Dockerfile", + "target": "prod" + } + }, + { + "name": "client", + "build": { + "context": "./", + "dockerfile": "./apps/client/Dockerfile", + "target": "prod" + } + }, + { + "name": "opencds-mockoon", + "build": { + "context": "./", + "dockerfile": "./packages/opencds/Dockerfile", + "target": "prod" + } + } +] diff --git a/.jjconflict-side-2/ci/matrix-npm.json b/.jjconflict-side-2/ci/matrix-npm.json new file mode 100644 index 0000000000..b4805a8be6 --- /dev/null +++ b/.jjconflict-side-2/ci/matrix-npm.json @@ -0,0 +1,72 @@ +{ + "apps": [ + { + "name": "@cpn-console/server", + "path": "./apps/server" + }, + { + "name": "@cpn-console/server-nestjs", + "path": "./apps/server-nestjs" + }, + { + "name": "@cpn-console/client", + "path": "./apps/client" + } + ], + "packages": [ + { + "name": "@cpn-console/shared", + "path": "./packages/shared" + }, + { + "name": "@cpn-console/hooks", + "path": "./packages/hooks" + }, + { + "name": "@cpn-console/logger", + "path": "./packages/logger" + }, + { + "name": "@cpn-console/test-utils", + "path": "./packages/test-utils" + }, + { + "name": "@cpn-console/eslint-config", + "path": "./packages/eslintconfig" + }, + { + "name": "@cpn-console/ts-config", + "path": "./packages/tsconfig" + } + ], + "plugins": [ + { + "name": "@cpn-console/argocd-plugin", + "path": "./plugins/argocd" + }, + { + "name": "@cpn-console/gitlab-plugin", + "path": "./plugins/gitlab" + }, + { + "name": "@cpn-console/harbor-plugin", + "path": "./plugins/harbor" + }, + { + "name": "@cpn-console/keycloak-plugin", + "path": "./plugins/keycloak" + }, + { + "name": "@cpn-console/nexus-plugin", + "path": "./plugins/nexus" + }, + { + "name": "@cpn-console/sonarqube-plugin", + "path": "./plugins/sonarqube" + }, + { + "name": "@cpn-console/vault-plugin", + "path": "./plugins/vault" + } + ] +} diff --git a/.jjconflict-side-2/ci/scripts/build-matrix.sh b/.jjconflict-side-2/ci/scripts/build-matrix.sh new file mode 100755 index 0000000000..301a203ea5 --- /dev/null +++ b/.jjconflict-side-2/ci/scripts/build-matrix.sh @@ -0,0 +1,205 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Get versions +DOCKER_VERSION="$(docker --version)" + + +# Default +# REGISTRY="docker.io" +TAGS="latest" +COMMIT_SHA="$(git rev-parse --short HEAD)" +PLATFORMS="linux/amd64" +CSV=false +RECURSIVE=false + +unset MAJOR_VERSION +unset MINOR_VERSION +unset PATCH_VERSION + +# Declare script helper +TEXT_HELPER="\nThis script aims to build matrix for Github CI/CD. It will parse the given docker-compose file and return a json object with images infos (name, tag, context, dockerfile and if it need to be build) +Following flags are available: + + -a Create recursive tags, if it match 'x.x.x' it will create 'x.x' and 'x'. + + -c Use csv list formated output for tags instead of json array. + + -f Docker-compose file used to build matrix. + + -n Namespace used to tag images. e.g 'username/reponame'. + + -p Target platforms used to build matrix (List/CSV format. ex: 'linux/amd64,linux/arm64'). + Default is '$PLATFORMS'. + + -r Registry host used to build matrix. + Default is '$REGISTRY'. + + -t Docker tag used to build matrix. + Default is '$TAGS'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hacf:n:p:r:t: flag; do + case "${flag}" in + a) + RECURSIVE=true;; + c) + CSV=true;; + f) + COMPOSE_FILE=${OPTARG};; + n) + NAMESPACE=${OPTARG};; + p) + PLATFORMS=${OPTARG};; + r) + REGISTRY=${OPTARG};; + t) + TAGS=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ ! -f "$(readlink -f $COMPOSE_FILE)" ]; then + echo "\nDocker compose file $COMPOSE_FILE does not exist." + print_help + exit 1 +fi + + +if [ "$REGISTRY" ] && [[ "$REGISTRY" != */ ]]; then + REGISTRY="$REGISTRY/" +fi + +if [ "$NAMESPACE" ] && [[ "$NAMESPACE" != */ ]]; then + NAMESPACE="$NAMESPACE/" +fi + + +# Build core matrix +MATRIX=$(cat "$COMPOSE_FILE" \ + | docker run -i --rm mikefarah/yq -o=json \ + | jq \ + --arg d "$(dirname $(readlink -f $COMPOSE_FILE))" \ + --arg p "$PLATFORMS" \ + --arg r "$REGISTRY" \ + --arg t "$TAGS" \ + '.services | to_entries | map({ + image: (.value.image), + name: (.value.image | split(":")[0] | split("/")[-1]), + build: ( + if .value.build then { + context: ($d + "/" + .value.build.context), + dockerfile: ($d + "/" + .value.build.context + "/" + .value.build.dockerfile), + target: (.value.build.target), + platforms: [], + tags: [] + } + else + false + end) + })') + +# Add tags in matrix +for t in $(echo $TAGS | tr "," "\n"); do + if [[ "$t" == *"."*"."* ]] && [[ "$RECURSIVE" == "true" ]]; then + MAJOR_VERSION="$(echo $t | cut -d "." -f 1)" + MINOR_VERSION="$(echo $t | cut -d "." -f 2)" + PATCH_VERSION="$(echo $t | cut -d "." -f 3)" + + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg r "$REGISTRY" \ + --arg n "$NAMESPACE" \ + --arg major "$MAJOR_VERSION" \ + --arg minor "$MINOR_VERSION" \ + 'map(. | + if .build != false then + .build.tags += [ + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $major), + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $major + "." + $minor) + ] + else + . + end + )') + fi + + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg t "$t" \ + --arg r "$REGISTRY" \ + --arg n "$NAMESPACE" \ + 'map(. | + if .build != false then + .build.tags += [ + ($r + $n + (.image | split(":")[0] | split("/")[-1]) + ":" + $t) + ] + else + . + end + )') +done + +# Add platforms in matrix +for p in $(echo $PLATFORMS | tr "," "\n"); do + MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg p "$p" \ + 'map(. | + if .build != false then + .build.platforms += [ + ($p) + ] + else + . + end + )') +done + +# Update image key with first tag +MATRIX=$(echo "$MATRIX" \ + | jq \ + --arg t "$TAGS" \ + 'map(. | + if .build != false then + .image = (.image | split(":")[0] | split("/")[-1] + ":" + ($t | split(",")[0])) + else + . + end + )') + +# Convert tags & platforms from json array to csv list (use join instead of @csv to don't get unwanted quotes) +if [ "$CSV" == "true" ]; then + MATRIX=$(echo "$MATRIX" \ + | jq -r \ + 'map(. | + if .build != false then + .build.tags = (.build.tags | join(",")) | + .build.platforms = (.build.platforms | join(",")) + else + . + end + )') +fi + +echo "$MATRIX" | jq . \ No newline at end of file diff --git a/.jjconflict-side-2/ci/scripts/build-tags.sh b/.jjconflict-side-2/ci/scripts/build-tags.sh new file mode 100755 index 0000000000..63f795c710 --- /dev/null +++ b/.jjconflict-side-2/ci/scripts/build-tags.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +set -e + +unset MAJOR_VERSION +unset MINOR_VERSION +unset PATCH_VERSION + +# Declare script helper +TEXT_HELPER="\nThis script aims to build docker tags based on image and set of tags. +Following flags are available: + + -i Image used to create tags. + + -t Docker tag used to build matrix. + Default is '$TAGS'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hi:t: flag; do + case "${flag}" in + i) + IMAGE=${OPTARG};; + t) + TAGS=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ -z "$IMAGE" ] || [ -z "$TAGS" ]; then + echo "\nArguments missing." + print_help + exit 1 +fi + + +for TAG in $(echo $TAGS | tr "," "\n"); do + if [[ "$TAG" == *"."*"."* ]]; then + MAJOR_VERSION="$(echo $TAG | cut -d "." -f 1)" + MINOR_VERSION="$(echo $TAG | cut -d "." -f 1-2)" + if [ -z "$DOCKER_TAGS" ]; then + DOCKER_TAGS="$IMAGE:$MAJOR_VERSION,$IMAGE:$MINOR_VERSION,$IMAGE:$TAG" + else + DOCKER_TAGS="$DOCKER_TAGS,$IMAGE:$TAG,$IMAGE:$MAJOR_VERSION,$IMAGE:$MINOR_VERSION,$IMAGE:$TAG" + fi + else + if [ -z "$DOCKER_TAGS" ]; then + DOCKER_TAGS="$IMAGE:$TAG" + else + DOCKER_TAGS="$DOCKER_TAGS,$IMAGE:$TAG" + fi + fi +done + +echo "$DOCKER_TAGS" diff --git a/.jjconflict-side-2/ci/scripts/cleanup.sh b/.jjconflict-side-2/ci/scripts/cleanup.sh new file mode 100755 index 0000000000..acef1973a0 --- /dev/null +++ b/.jjconflict-side-2/ci/scripts/cleanup.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +cd "$PROJECT_DIR" + + +printf "\n${red}${i}.${no_color} Remove build\n" +i=$(($i + 1)) + +pnpm run build:clean + + +printf "\n${red}${i}.${no_color} Remove node_modules\n" +i=$(($i + 1)) + +while true; do + read -p "Do you want to remove node_modules ('rm -rf $PROJECT_DIR/**/node_modules')? " yn + case $yn in + [Yy]*) + for n in $(find $PROJECT_DIR -depth 3 -type d -name node_modules); do + echo "Delete '$n'" + rm -rf "$n" + done + # find $PROJECT_DIR -type d -name node_modules -delete + break;; + [Nn]*) + echo "\nKeeping node_modules\n" + break;; + *) + echo "\nPlease answer y or n.\n";; + esac +done + + +printf "\n${red}${i}.${no_color} Remove docker stuff\n" +i=$(($i + 1)) + +if docker image inspect dso-console/client:dev > /dev/null 2>&1; then + docker image rm dso-console/client:dev +fi +if docker image inspect dso-console/client:prod > /dev/null 2>&1; then + docker image rm dso-console/client:prod +fi +if docker image inspect dso-console/server:dev > /dev/null 2>&1; then + docker image rm dso-console/server:dev +fi +if docker image inspect dso-console/server:prod > /dev/null 2>&1; then + docker image rm dso-console/server:prod +fi + +if docker volume inspect docker_dso-postgres-data-dev > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-dev +fi +if docker volume inspect docker_dso-postgres-data-integ > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-integ +fi +if docker volume inspect docker_dso-postgres-data-local > /dev/null 2>&1; then + docker volume rm docker_dso-postgres-data-local +fi diff --git a/.jjconflict-side-2/ci/scripts/delete-image.sh b/.jjconflict-side-2/ci/scripts/delete-image.sh new file mode 100755 index 0000000000..d1e0e70e59 --- /dev/null +++ b/.jjconflict-side-2/ci/scripts/delete-image.sh @@ -0,0 +1,101 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' + +# Get versions +DOCKER_VERSION="$(docker --version)" +DOCKER_BUILDX_VERSION="$(docker buildx version)" + +# Declare script helper +TEXT_HELPER="\nThis script aims to delete an image with all its subsequent images in ghcr. +Following flags are available: + + -g Github token to perform api calls. + + -i Image name used for api calls. + + -o Github organization used for api calls. + + -t Image tage to delete. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hg:i:o:t: flag; do + case "${flag}" in + g) + GITHUB_TOKEN=${OPTARG};; + i) + IMAGE_NAME=${OPTARG};; + o) + ORG=${OPTARG};; + t) + TAG=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +checkDockerRunning () { + if [ ! -x "$(command -v docker)" ]; then + printf "\nThis script uses docker, and it isn't running - please start docker and try again!\n" + exit 1 + fi +} + +checkBuildxPlugin () { + if [ ! "$DOCKER_BUILDX_VERSION" ]; then + printf "\nThis script uses docker buildx plugin, and it isn't installed - please install docker buildx plugin and try again!\n" + exit 1 + fi +} + +# Settings +printf "\nScript settings: + -> docker version: ${DOCKER_VERSION} + -> docker buildx version: ${DOCKER_BUILDX_VERSION}\n" + + +if [ -z "$GITHUB_TOKEN" ] || [ -z "$ORG" ] || [ -z "$IMAGE_NAME" ] || [ -z "$TAG" ]; then + echo "\nYMissing arguments ...\n" + print_help + exit 1 +fi + +IMAGE_NAME_URL_ENCODED="$(jq -rn --arg x ${IMAGE_NAME} '$x | @uri')" +IMAGES=$(curl -s \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions?per_page=100") + +# Delete subsequent images +while read -r SHA; do + IMAGE_ID=$(echo "$IMAGES" | jq -r --arg s "$SHA" '.[] | select(.name==$s) | .id') + + printf "\n${red}[Delete ghcr image].${no_color} Deleting subsequent image '$ORG/$IMAGE_NAME@$SHA'\n" + + curl -s \ + -X DELETE \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions/${IMAGE_ID}" +done <<< "$(docker buildx imagetools inspect ghcr.io/${ORG}/${IMAGE_NAME}:${TAG} --raw | jq -r '.manifests[] | .digest')" + +# Delete main images +while read -r IMAGE_ID; do + + printf "\n${red}[Delete ghcr image].${no_color} Deleting GH package version: ${IMAGE_ID}'\n" + + curl -s \ + -X DELETE \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/orgs/${ORG}/packages/container/${IMAGE_NAME_URL_ENCODED}/versions/${IMAGE_ID}" +done <<< "$(echo "$IMAGES" | jq -r --arg t "$TAG" '.[] | select(.metadata.container.tags[] | contains($t)) | .id')" diff --git a/.jjconflict-side-2/ci/scripts/init-env.sh b/.jjconflict-side-2/ci/scripts/init-env.sh new file mode 100755 index 0000000000..0c6c5d3feb --- /dev/null +++ b/.jjconflict-side-2/ci/scripts/init-env.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Declare script helper +TEXT_HELPER="\nThis script aims to copy .env-example files into .env files at project initialization. +Following flags are available: + + -h Print script help\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts h flag +do + case "${flag}" in + h | *) + print_help + exit 0;; + esac +done + + +find $PROJECT_DIR -type f -name ".env*-example" -or -name "*-example.yaml" | while read f; do + if [ ! -f "${f/-example/}" ]; then + printf "\n${red}Copy${no_color}: '$f' + ${red}to${no_color}: '${f/-example/}'\n" + cp "$f" "${f/-example/}" + else + printf "\nFile '${f/-example/}' already exists\n" + fi +done diff --git a/.jjconflict-side-2/ci/scripts/run-tests.sh b/.jjconflict-side-2/ci/scripts/run-tests.sh new file mode 100755 index 0000000000..cf18c018fe --- /dev/null +++ b/.jjconflict-side-2/ci/scripts/run-tests.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +# Get versions +NODE_VERSION="$(node --version)" +NPM_VERSION="$(npm --version)" +DOCKER_VERSION="$(docker --version)" +DOCKER_BUILDX_VERSION="$(docker buildx version)" + +# Default +RUN_LINT="false" +RUN_UNIT_TESTS="false" +RUN_E2E_TESTS="false" +RUN_STATUS_CHECK="false" + +# Declare script helper +TEXT_HELPER="\nThis script aims to run application tests. +Following flags are available: + + -e Run e2e tests + + -l Run lint + + -s Run deployement status check + + -t (Optional) Tag used for docker images in e2e tests + + -u Run unit tests + + -h Print script help\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts heklst:u flag +do + case "${flag}" in + e) + RUN_E2E_TESTS=true;; + l) + RUN_LINT=true;; + s) + RUN_STATUS_CHECK=true;; + t) + TAG=${OPTARG};; + u) + RUN_UNIT_TESTS=true;; + h | *) + print_help + exit 0;; + esac +done + + +# Script condition +if [ "$RUN_LINT" == "false" ] && [ "$RUN_UNIT_TESTS" == "false" ] && [ "$RUN_E2E_TESTS" == "false" ] && [ "$RUN_STATUS_CHECK" == "false" ]; then + printf "\nArgument(s) missing, you don't specify any kind of test to run.\n" + print_help + exit 1 +fi + +if [ "$RUN_E2E_TESTS" == "true" ] && [ -z "$TAG" ]; then + printf "\nArgument(s) missing, you don't specify the TAG used to pull docker images for e2e tests.\n" + print_help + exit 1 +fi + +checkDockerRunning () { + if [ ! -x "$(command -v docker)" ]; then + printf "\nThis script uses docker, and it isn't running - please start docker and try again!\n" + exit 1 + fi +} + +checkBuildxPlugin () { + if [ ! "$DOCKER_BUILDX_VERSION" ]; then + printf "\nThis script uses docker buildx plugin, and it isn't installed - please install docker buildx plugin and try again!\n" + exit 1 + fi +} + + +# Settings +printf "\nScript settings: + -> node version: ${NODE_VERSION} + -> npm version: ${NPM_VERSION} + -> docker version: ${DOCKER_VERSION} + -> docker buildx version: ${DOCKER_BUILDX_VERSION} + -> run unit tests: ${RUN_UNIT_TESTS} + -> run e2e tests: ${RUN_E2E_TESTS} + -> run deploy status check: ${RUN_STATUS_CHECK}\n" + + +cd "$PROJECT_DIR" + +# Run lint +if [ "$RUN_LINT" == "true" ]; then + npm run lint +fi + + +# Run unit tests +if [ "$RUN_UNIT_TESTS" == "true" ]; then + npm run test:cov +fi + +# Run e2e tests +if [ "$RUN_E2E_TESTS" == "true" ]; then + checkDockerRunning + + printf "\n${red}${i}.${no_color} Launch e2e tests\n" + i=$(($i + 1)) + + npm --prefix $PROJECT_DIR/packages/shared run build + npm --prefix $PROJECT_DIR/packages/test-utils run build + + if [[ -n "$TAG" ]]; then + docker pull ghcr.io/cloud-pi-native/console/server:$TAG && docker tag ghcr.io/cloud-pi-native/console/server:$TAG dso-console/server:ci + docker pull ghcr.io/cloud-pi-native/console/server-nestjs:$TAG && docker tag ghcr.io/cloud-pi-native/console/server-nestjs:$TAG dso-console/server-nestjs:ci + docker pull ghcr.io/cloud-pi-native/console/client:$TAG && docker tag ghcr.io/cloud-pi-native/console/client:$TAG dso-console/client:ci + docker pull ghcr.io/cloud-pi-native/console/nginx-strangler:$TAG && docker tag ghcr.io/cloud-pi-native/console/client:$TAG dso-console/nginx-strangler:ci + fi + + npm run docker:e2e-ci + + printf "\n${red}${i}.${no_color} Remove resources\n" + i=$(($i + 1)) + + npm run docker:e2e-ci:delete +fi diff --git a/.jjconflict-side-2/ci/scripts/setup.sh b/.jjconflict-side-2/ci/scripts/setup.sh new file mode 100755 index 0000000000..4917b17cf8 --- /dev/null +++ b/.jjconflict-side-2/ci/scripts/setup.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + +# Get project directories +PROJECT_DIR="$(git rev-parse --show-toplevel)" + +cd "$PROJECT_DIR" + + +printf "\n${red}${i}.${no_color} Install dependencies\n" +i=$(($i + 1)) + +pnpm install + + +printf "\n${red}${i}.${no_color} Generate Prisma client\n" +i=$(($i + 1)) + +pnpm --filter=server run db:generate + + +printf "\n${red}${i}.${no_color} Build packges\n" +i=$(($i + 1)) + +pnpm run build --force + + +printf "\n${red}${i}.${no_color} Build docker dev images\n" +i=$(($i + 1)) + +pnpm run docker:dev:build + + +printf "\n${red}${i}.${no_color} Build docker prod images\n" +i=$(($i + 1)) + +pnpm run docker:prod:build diff --git a/.jjconflict-side-2/ci/scripts/vuln-report.sh b/.jjconflict-side-2/ci/scripts/vuln-report.sh new file mode 100755 index 0000000000..9bf3f6c169 --- /dev/null +++ b/.jjconflict-side-2/ci/scripts/vuln-report.sh @@ -0,0 +1,244 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' +# Console step increment +i=1 + + +# Declare script helper +TEXT_HELPER="\nThis script aims to build a markdown style vulnerability report for the trivy json scan results. It will read the files and then build a markdown summary (compatible with Github issue) summarising the vulnerabilities for each image and configuration file for the given input folder. + +Following flags are available: + + -i Input folder that contain trivy json scan result files into subfolders 'images/' and 'configs/'. + Please provide the path without last slash (i.e not like this 'path/to/folder/'). + + -o Output file for the generated markdown report. + Please provide the path with the markdown file extension (i.e '.md'). + + -p Github project (format as '/'). + It can be set in GitHub Actions as '\${{ github.repository }}'. + + -r Github actions run ID. + It can be set in GitHub Actions as '\${{ github.run_id }}'. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hi:o:p:r: flag +do + case "${flag}" in + i) + INPUT="${OPTARG}";; + o) + OUTPUT="${OPTARG}";; + p) + REPO="${OPTARG}";; + r) + RUN_ID="${OPTARG}";; + h | *) + print_help + exit 0;; + esac +done + + +NOW="$(date +'%Y-%m-%d at %H:%M')" +ARTIFACT_DOWNLOAD_URL="https://github.com/${REPO}/actions/runs/${RUN_ID}" + + +if [ -z "$INPUT" ] || [ -z "$OUTPUT" ] || [ -z "$REPO" ] || [ -z "$RUN_ID" ]; then + echo "Argument(s) missing, you should set all paramters." + print_help + exit 0 +fi + + +_jq() { + echo ${1} | base64 --decode | jq -r ${2} +} + + +printf "\n${red}${i}.${no_color} Build vulnerability report\n" +i=$(($i + 1)) + +VULNERABILITY_REPORT_BODY="# Security report + +:robot: This report was generated automatically the $NOW. + +It summarizes the result of a vulnerability scan of Trivy for each docker image and configuration file. +You should take a look at it and possibly modify some dependencies to fix vulnerabilities or update your configuration files." + +#--------------------# +# Images scan report # +#--------------------# +if [[ -n $(find $INPUT/images/*) ]]; then + FORMATED_REPORT=$(jq -s '.' $INPUT/images/* | jq '[ .[] | { + name: .ArtifactName, + type: .ArtifactType, + results: [ + .Results[] | select(.Vulnerabilities != null) | { + class: .Class, + target: .Target, + type: .Type, + length: .Vulnerabilities | (if length == 0 then "-" else length end), + vulnerabilityType: { + critical: [.Vulnerabilities[] | select(.Severity == "CRITICAL")] | (if length == 0 then "-" else length end), + high: [.Vulnerabilities[] | select(.Severity == "HIGH")] | (if length == 0 then "-" else length end), + medium: [.Vulnerabilities[] | select(.Severity == "MEDIUM")] | (if length == 0 then "-" else length end), + low: [.Vulnerabilities[] | select(.Severity == "LOW")] | (if length == 0 then "-" else length end), + unknown: [.Vulnerabilities[] | select(.Severity == "UNKNOWN")] | (if length == 0 then "-" else length end) + } + } + ] + } ]') + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +## Images" + + for row in $(echo "${FORMATED_REPORT}" | jq -r '.[] | @base64'); do + IMAGE_NAME=$(echo "$(_jq "${row}" '.')" | jq -r '.name') + OS_TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .length ] | add') + OS_CRITICAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.critical ] | add') + OS_HIGH_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.high ] | add') + OS_MEDIUM_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.medium ] | add') + OS_LOW_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.low ] | add') + OS_UNKNOWN_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "os-pkgs") | .vulnerabilityType.unknown ] | add') + + if [[ $OS_TOTAL_COUNT = 'null' ]]; then + IMAGE_SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + IMAGE_SCAN_SUMMARY=" +| Critical | High | Medium | Low | Unknown | Total | +|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:| +| $OS_CRITICAL_COUNT | $OS_HIGH_COUNT | $OS_MEDIUM_COUNT | $OS_LOW_COUNT | $OS_UNKNOWN_COUNT | $OS_TOTAL_COUNT| +" + fi + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +### \`$IMAGE_NAME\` + +**OS Packages** + +$IMAGE_SCAN_SUMMARY" + + DEP_TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .results[] | select(.class == "lang-pkgs") | .length ] | add') + if [[ $DEP_TOTAL_COUNT = 'null' ]]; then + SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + SCAN_SUMMARY=" +| Target | Critical | High | Medium | Low | Unknown | Total | +|:-----------|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:|" + + for row2 in $(echo "$(_jq ${row} '.')" | jq -r '.results[] | select(.class == "lang-pkgs") | @base64'); do + FULL_NAME=$(echo "$(_jq ${row2} '.')" | jq -r '.target') + TOTAL_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '.length') + CRITICAL_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.critical ] | add') + HIGH_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.high ] | add') + MEDIUM_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.medium ] | add') + LOW_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.low ] | add') + UNKNOWN_COUNT=$(echo "$(_jq ${row2} '.')" | jq -r '[ .vulnerabilityType.unknown ] | add') + + SCAN_SUMMARY="$SCAN_SUMMARY +| $FULL_NAME | $CRITICAL_COUNT | $HIGH_COUNT | $MEDIUM_COUNT | $LOW_COUNT | $UNKNOWN_COUNT | $TOTAL_COUNT |" + + done + fi + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY +**Language-specific packages** + +$SCAN_SUMMARY" + done + + SCAN_IMAGES=true +fi + +#--------------------# +# Config scan report # +#--------------------# +if [[ -n $(find $INPUT/configs/*) ]]; then + FORMATED_REPORT=$(jq -s '.' $INPUT/configs/* | jq '.[] | { + name: .ArtifactName, + type: .ArtifactType, + results: [ + .Results[] | select(.Misconfigurations != null) | { + class: .Class, + target: .Target, + type: .Type, + length: .Misconfigurations | (if length == 0 then "-" else length end), + vulnerabilityType: { + critical: [.Misconfigurations[] | select(.Severity == "CRITICAL")] | (if length == 0 then "-" else length end), + high: [.Misconfigurations[] | select(.Severity == "HIGH")] | (if length == 0 then "-" else length end), + medium: [.Misconfigurations[] | select(.Severity == "MEDIUM")] | (if length == 0 then "-" else length end), + low: [.Misconfigurations[] | select(.Severity == "LOW")] | (if length == 0 then "-" else length end), + unknown: [.Misconfigurations[] | select(.Severity == "UNKNOWN")] | (if length == 0 then "-" else length end) + } + } + ] + }') + + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY + +## Configs" + + TOTAL_CONFIG=$(echo $FORMATED_REPORT | jq -r '[ .results[].length ] | add') + if [[ $TOTAL_CONFIG = 'null' ]]; then + SCAN_SUMMARY=" +:white_check_mark: No security issues were detected. +" + else + SCAN_SUMMARY=" +| Target | Critical | High | Medium | Low | Unknown | Total | +|:-----------|:---------------:|:-----------:|:-------------:|:----------:|:--------------:|:-----------:|" + fi + + for row in $(echo "${FORMATED_REPORT}" | jq -r '.results[] | @base64'); do + FULL_NAME=$(echo "$(_jq "${row}" '.')" | jq -r '.target') + TOTAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '.length') + CRITICAL_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.critical ] | add') + HIGH_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.high ] | add') + MEDIUM_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.medium ] | add') + LOW_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.low ] | add') + UNKNOWN_COUNT=$(echo "$(_jq "${row}" '.')" | jq -r '[ .vulnerabilityType.unknown ] | add') + + SCAN_SUMMARY="$SCAN_SUMMARY +| $FULL_NAME | $CRITICAL_COUNT | $HIGH_COUNT | $MEDIUM_COUNT | $LOW_COUNT | $UNKNOWN_COUNT | $TOTAL_COUNT|" + + done + VULNERABILITY_REPORT_BODY="$VULNERABILITY_REPORT_BODY +$SCAN_SUMMARY" + + SCAN_CONFIG=true +fi + +if [ "$SCAN_DEPENDENCIES" = true ] || [ "$SCAN_CONFIG" = true ] || [ "$SCAN_IMAGES" = true ]; then + printf "\n${red}${i}.${no_color} Add artifacts download url to the vulnerability report body\n" + i=$(($i + 1)) + + VULNERABILITY_REPORT_BODY=$(cat < "$OUTPUT" +fi diff --git a/.jjconflict-side-2/commitlint.config.cjs b/.jjconflict-side-2/commitlint.config.cjs new file mode 100644 index 0000000000..070c7b1c34 --- /dev/null +++ b/.jjconflict-side-2/commitlint.config.cjs @@ -0,0 +1,9 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'body-leading-blank': [ + 2, // error + 'always', + ], + }, +}; diff --git a/.jjconflict-side-2/docker/docker-compose.ci.yml b/.jjconflict-side-2/docker/docker-compose.ci.yml new file mode 100644 index 0000000000..b8358950ea --- /dev/null +++ b/.jjconflict-side-2/docker/docker-compose.ci.yml @@ -0,0 +1,151 @@ +services: + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + ports: + - 8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - /opt/bitnami/keycloak/data/h2 + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + ports: + - 8081:80 + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: prod + image: dso-console/server:ci + container_name: dso-console_server + user: root + depends_on: + postgres: + condition: service_started + keycloak: + condition: service_healthy + volumes: + - ../packages/test-utils/src/imports/data.ts:/app/dist/init/db/imports/data.js + env_file: + - ../apps/server/.env.docker + environment: + NODE_ENV: production + CI: "true" + DEV_SETUP: "true" + networks: + - dso-network + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: prod + image: dso-console/server-nestjs:ci + container_name: dso-console_server-nestjs + depends_on: + postgres: + condition: service_started + keycloak: + condition: service_healthy + opencds-mockoon: + condition: service_healthy + env_file: + - ../apps/server-nestjs/.env.docker + environment: + NODE_ENV: "production" + CI: "true" + DEV_SETUP: "true" + OPENCDS_URL: "http://dso-opencds_mockoon:3100/api/v1" + OPENCDS_API_TOKEN: "token" + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: "false" + networks: + - dso-network + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:ci + container_name: dso-console_nginx-strangler + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + + client: + build: + context: .. + dockerfile: apps/client/Dockerfile + target: prod + image: dso-console/client:ci + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + user: root + volumes: + - ../apps/client/nginx/default.docker.conf:/etc/nginx/conf.d/default.conf + env_file: + - ../apps/client/.env.docker + environment: + OPENCDS_ENABLED: "true" + networks: + - dso-network + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:ci + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + name: dso-network + +volumes: + dso-keycloak-dsfr-theme: diff --git a/.jjconflict-side-2/docker/docker-compose.dev.yml b/.jjconflict-side-2/docker/docker-compose.dev.yml new file mode 100644 index 0000000000..98f2a31933 --- /dev/null +++ b/.jjconflict-side-2/docker/docker-compose.dev.yml @@ -0,0 +1,240 @@ +services: + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + ports: + - 8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - ../keycloak/data/dev:/opt/bitnami/keycloak/data/h2 + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-dev:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + - postgres + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-dev:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: dev + image: dso-console/server:dev + container_name: dso-console_server + command: [dev] + depends_on: + keycloak: + condition: service_healthy + postgres: + condition: service_started + ports: + - 4001:8080 + - 9229:9229 + env_file: + - ../apps/server/.env.docker + develop: + watch: + - action: sync + path: ../apps/server + target: /app/apps/server + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server/package.json + networks: + - dso-network + attach: true + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: dev + image: dso-console/server-nestjs:dev + container_name: dso-console_server-nestjs + command: [start:dev] + ports: + - 3001:3001 + depends_on: + keycloak: + condition: service_healthy + postgres: + condition: service_started + opencds-mockoon: + condition: service_healthy + env_file: + - ../apps/server-nestjs/.env.docker + develop: + watch: + - action: sync + path: ../apps/server-nestjs + target: /app/apps/server-nestjs + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - dist/ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server-nestjs/package.json + networks: + - dso-network + attach: true + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:dev + container_name: dso-console_nginx-strangler + ports: + - 4000:8080 + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + attach: false + + client: + restart: unless-stopped + build: + context: .. + dockerfile: apps/client/Dockerfile + target: dev + image: dso-console/client:dev + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + env_file: + - ../apps/client/.env.docker + develop: + watch: + - action: sync + path: ../apps/client + target: /app/apps/client + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: rebuild + path: ../apps/client/package.json + networks: + - dso-network + attach: true + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:dev + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + driver: bridge + +volumes: + dso-keycloak-dsfr-theme: + dso-pgadmin-data-dev: + dso-postgres-data-dev: diff --git a/.jjconflict-side-2/docker/docker-compose.integ.yml b/.jjconflict-side-2/docker/docker-compose.integ.yml new file mode 100644 index 0000000000..385f5e85e7 --- /dev/null +++ b/.jjconflict-side-2/docker/docker-compose.integ.yml @@ -0,0 +1,220 @@ +services: + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-integ:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + postgres: + condition: service_started + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-integ:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + server: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server/Dockerfile + target: dev + image: dso-console/server:integ + container_name: dso-console_server + command: [dev] + depends_on: + postgres: + condition: service_started + ports: + - 4001:8080 + - 9229:9229 + volumes: + - ${KUBECONFIG_HOST_PATH}:${KUBECONFIG_PATH:-/home/node/.kube/config} + - ${EXTERNAL_PLUGINS_DIR_HOST_PATH}:/plugins + env_file: + - ../apps/server/.env.docker + - ../apps/server/.env.integ + develop: + watch: + - action: sync + path: ../apps/server + target: /app/apps/server + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server/package.json + networks: + - dso-network + attach: true + + server-nestjs: + restart: unless-stopped + build: + context: .. + dockerfile: apps/server-nestjs/Dockerfile + target: dev + image: dso-console/server-nestjs:integ + container_name: dso-console_server-nestjs + command: [start:dev] + depends_on: + postgres: + condition: service_started + opencds-mockoon: + condition: service_healthy + ports: + - 3001:3001 + volumes: + - ${KUBECONFIG_HOST_PATH}:${KUBECONFIG_PATH:-/home/node/.kube/config} + - ${EXTERNAL_PLUGINS_DIR_HOST_PATH}:/plugins + env_file: + - ../apps/server-nestjs/.env.docker + - ../apps/server-nestjs/.env.integ + develop: + watch: + - action: sync + path: ../apps/server-nestjs + target: /app/apps/server-nestjs + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - dist/ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: sync + path: ../packages/hooks + target: /app/packages/hooks + ignore: + - node_modules/ + - action: sync + path: ../plugins + target: /app/plugins + ignore: + - node_modules/ + - action: rebuild + path: ../apps/server-nestjs/package.json + networks: + - dso-network + attach: true + + nginx-strangler: + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:integ + container_name: dso-console_nginx-strangler + depends_on: + server: + condition: service_started + server-nestjs: + condition: service_healthy + ports: + - 4000:8080 + environment: + LEGACY_UPSTREAM: "server:8080" + NESTJS_UPSTREAM: "server-nestjs:3001" + networks: + - dso-network + attach: false + + client: + restart: unless-stopped + build: + context: .. + dockerfile: apps/client/Dockerfile + target: dev + image: dso-console/client:integ + container_name: dso-console_client + depends_on: + nginx-strangler: + condition: service_started + ports: + - 8080:8080 + env_file: + - ../apps/client/.env.docker + - ../apps/client/.env.integ + develop: + watch: + - action: sync + path: ../apps/client + target: /app/apps/client + ignore: + - node_modules/ + - .env + - .env.docker + - .env.integ + - action: sync + path: ../packages/shared + target: /app/packages/shared + ignore: + - node_modules/ + - action: rebuild + path: ../apps/client/package.json + networks: + - dso-network + attach: true + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:integ + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + +networks: + dso-network: + driver: bridge + +volumes: + dso-pgadmin-data-integ: + dso-postgres-data-integ: diff --git a/.jjconflict-side-2/docker/docker-compose.local.yml b/.jjconflict-side-2/docker/docker-compose.local.yml new file mode 100644 index 0000000000..80473f1823 --- /dev/null +++ b/.jjconflict-side-2/docker/docker-compose.local.yml @@ -0,0 +1,146 @@ +services: + keycloak-fetch-theme: + restart: no + image: docker.io/curlimages/curl:8.8.0 + container_name: dso-console_keycloak-fetch + volumes: + - dso-keycloak-dsfr-theme:/tmp:rw + command: -LfS -o /tmp/keycloak-theme-dsfr.jar https://github.com/codegouvfr/keycloak-theme-dsfr/releases/download/v2.3.3/keycloak-theme-for-kc-26.0-to-26.1.jar + networks: + - dso-network + attach: false + + keycloak: + restart: unless-stopped + image: docker.io/bitnamilegacy/keycloak:26.1.3-debian-12-r0 + container_name: dso-console_keycloak + depends_on: + keycloak-fetch-theme: + condition: service_completed_successfully + ports: + - 127.0.0.1:8090:8080 + volumes: + - ../keycloak/realms:/opt/bitnami/keycloak/data/import + - ../keycloak/data/dev:/opt/bitnami/keycloak/data/h2 + - type: volume + source: dso-keycloak-dsfr-theme + target: /opt/bitnami/keycloak/providers/keycloak-theme-dsfr.jar + volume: + subpath: keycloak-theme-dsfr.jar + user: root + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: true + DSFR_THEME_HOME_URL: http://localhost:8080 + DSFR_THEME_SERVICE_TITLE: Console Cloud π Native + DSFR_THEME_BRAND_TOP: Ministère
de l'Intérieur
et des Outre-Mer + DSFR_THEME_TOS_URL: + DSFR_THEME_CONTACT_EMAIL: cloudpinative-relations@interieur.gouv.fr + entrypoint: /opt/bitnami/keycloak/bin/kc.sh + command: start-dev --import-realm + healthcheck: + test: [CMD, curl, --head, fsS, "http://localhost:8080/health/ready"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - dso-network + attach: false + + postgres: + restart: unless-stopped + image: docker.io/postgres:15.3 + container_name: dso-console_postgres + environment: + POSTGRES_DB: dso-console-db + POSTGRES_USER: admin + POSTGRES_PASSWORD: admin + ports: + - 5432:5432 + volumes: + - dso-postgres-data-local:/var/lib/postgresql/data + networks: + - dso-network + attach: false + + opencds-mockoon: + restart: unless-stopped + build: + context: .. + dockerfile: packages/opencds/Dockerfile + image: dso-console/opencds-mockoon:local + container_name: dso-opencds_mockoon + user: root + ports: + - 3100:3100 + networks: + - dso-network + + pgadmin: + restart: unless-stopped + image: docker.io/dpage/pgadmin4:latest + container_name: dso-console_pgadmin + depends_on: + postgres: + condition: service_started + volumes: + - ../pgadmin/servers-docker.json:/pgadmin4/servers.json + - dso-pgadmin-data-local:/var/lib/pgadmin + ports: + - 8081:80 + environment: + PGADMIN_DEFAULT_EMAIL: admin@dso.fr + PGADMIN_DEFAULT_PASSWORD: admin + networks: + - dso-network + attach: false + + nginx-strangler: + # En mode local (pnpm dev), server et server-nestjs tournent nativement sur la machine. + # Le nginx-strangler reste en Docker et atteint les processes natifs via host.docker.internal. + # (voir .env-example de chaque app). + restart: unless-stopped + build: + context: .. + dockerfile: apps/nginx-strangler/Dockerfile + image: dso-console/nginx-strangler:local + container_name: dso-console_nginx-strangler + extra_hosts: + - host.docker.internal:${HOST_GATEWAY_IP:-host-gateway} + ports: + - 4000:8080 + environment: + LEGACY_UPSTREAM: "host.docker.internal:4001" + NESTJS_UPSTREAM: "host.docker.internal:3001" + networks: + - dso-network + attach: false + + jaeger: + restart: unless-stopped + image: jaegertracing/all-in-one:1.76.0 + container_name: dso-console_jaeger + environment: + COLLECTOR_ZIPKIN_HOST_PORT: :9411 + ports: + - 16686:16686 + - 4317:4317 + - 4318:4318 + - 14250:14250 + - 14268:14268 + - 14269:14269 + - 9411:9411 + networks: + - dso-network + attach: false + +networks: + dso-network: + driver: bridge + +volumes: + dso-keycloak-dsfr-theme: + dso-keycloak-data-local: + dso-pgadmin-data-local: + dso-postgres-data-local: diff --git a/.jjconflict-side-2/eslint.config.js b/.jjconflict-side-2/eslint.config.js new file mode 100644 index 0000000000..50992e5703 --- /dev/null +++ b/.jjconflict-side-2/eslint.config.js @@ -0,0 +1,9 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase.append({ + ignores: [ + 'apps/', + 'packages/', + 'plugins/', + ], +}) diff --git a/.jjconflict-side-2/keycloak/mock-sso.md b/.jjconflict-side-2/keycloak/mock-sso.md new file mode 100644 index 0000000000..cd9b5c2982 --- /dev/null +++ b/.jjconflict-side-2/keycloak/mock-sso.md @@ -0,0 +1,34 @@ +# Keycloak + +## Development + +### Start only keycloak + +Go at the root level of the git project : + +```sh +# Go at the root level of the git project +cd `git rev-parse --show-toplevel` + +# Start keycloak +docker compose -f ./docker/docker-compose.dev.yml up keycloak +``` + +Admin page url: + +__*Login:*__ `admin` +__*Password:*__ `admin` + +## Docs + +### Get Started + + + +### Other doc + +* [Keycloak](https://github.com/keycloak/keycloak) - Keycloak Server and Java adapters +* [Keycloak Documentation](https://github.com/keycloak/keycloak-documentation) - Documentation for Keycloak +* [Keycloak QuickStarts](https://github.com/keycloak/keycloak-quickstarts) - QuickStarts for getting started with Keycloak +* [Keycloak Node.js Connect](https://github.com/keycloak/keycloak-nodejs-connect) - Node.js adapter for Keycloak +* [Keycloak Node.js Admin Client](https://github.com/keycloak/keycloak-nodejs-admin-client) - Node.js library for Keycloak Admin REST API diff --git a/.jjconflict-side-2/keycloak/realms/realm-dev.json b/.jjconflict-side-2/keycloak/realms/realm-dev.json new file mode 100644 index 0000000000..f7e932d8c0 --- /dev/null +++ b/.jjconflict-side-2/keycloak/realms/realm-dev.json @@ -0,0 +1,577 @@ +{ + "realm": "dso", + "enabled": true, + "accessTokenLifespan": 300, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "ssoSessionIdleTimeout": 600, + "ssoSessionMaxLifespan": 36000, + "sslRequired": "none", + "registrationAllowed": false, + "privateKey": "MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=", + "publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB", + "requiredCredentials": ["password"], + "attributes": { + "frontendUrl": "http://localhost:8090" + }, + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "groups": [ + { + "id": "07169ee1-d967-48da-a784-1b1ccbdaea7a", + "name": "admin", + "path": "/admin", + "attributes": {}, + "realmRoles": [], + "clientRoles": {}, + "subGroups": [] + } + ], + "users": [ + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6565", + "username": "test", + "enabled": true, + "email": "test@test.com", + "emailVerified": true, + "firstName": "Jean", + "lastName": "DUPOND", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6566", + "username": "tcolin", + "enabled": true, + "email": "thibault.colin@test.com", + "emailVerified": true, + "firstName": "Thibault", + "lastName": "COLIN", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + }, + "groups": ["admin"] + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6567", + "username": "cnollet", + "enabled": true, + "email": "claire.nollet@test.com", + "emailVerified": true, + "firstName": "Claire", + "lastName": "NOLLET", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "58e13168-d533-446b-b6a1-936da0a9d7b7", + "username": "lpoumailloux", + "enabled": true, + "email": "leo.poumailloux@test.com", + "emailVerified": true, + "firstName": "Léo", + "lastName": "POUMAILLOUX", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6568", + "username": "btran", + "enabled": true, + "email": "baudoin.tran@test.com", + "emailVerified": true, + "firstName": "Baudoin", + "lastName": "TRAN", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "cb8e5b4b-7b7b-40f5-935f-594f48ae6569", + "username": "atardif", + "enabled": true, + "email": "arnaud.tardif@test.com", + "emailVerified": true, + "firstName": "Arnaud", + "lastName": "TARDIF", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "realmRoles": ["user", "offline_access"], + "clientRoles": { + "account": ["manage-account"] + } + }, + { + "id": "387216f1-3b87-4211-9cac-4371125e1175", + "username": "admin", + "enabled": true, + "email": "admin@test.com", + "emailVerified": true, + "firstName": "Admin", + "lastName": "ADMIN", + "credentials": [ + { + "type": "password", + "value": "admin" + } + ], + "realmRoles": ["user", "admin"], + "clientRoles": { + "realm-management": ["realm-admin"], + "account": ["manage-account"] + }, + "groups": ["admin"] + } + ], + "roles": { + "realm": [ + { + "name": "user", + "description": "User privileges" + }, + { + "name": "admin", + "description": "Administrator privileges" + } + ] + }, + "clients": [ + { + "id": "e7d105eb-28d4-4557-a274-eb354a60a33b", + "clientId": "dso-console-frontend", + "name": "dso-console-frontend", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": ["*"], + "webOrigins": ["*"], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "id.token.as.detached.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "require.pushed.authorization.requests": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": ["generic", "basic"], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "access": { + "view": true, + "configure": true, + "manage": true + } + }, + { + "id": "e7d105eb-28d4-4557-a274-eb354a60a33c", + "clientId": "dso-console-backend", + "name": "dso-console-backend", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "client-secret-backend", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "saml.assertion.signature": "false", + "id.token.as.detached.signature": "false", + "saml.multivalued.roles": "false", + "saml.force.post.binding": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "require.pushed.authorization.requests": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": ["generic"], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "access": { + "view": true, + "configure": true, + "manage": true + } + } + ], + "clientScopes": [ + { + "id": "41b313bd-c5dc-448f-8056-007d25e42027", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "a067866c-7812-47c6-aa28-9e3421fd673b", + "name": "basic", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "910359ad-0368-44dd-b10c-d1cae6c6dd17", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "1611458d-38ab-4a9f-859e-1812d251c1ac", + "name": "generic", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "e0491f43-3e4c-4649-b16e-3c0e47bbc2c0", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "000f3a6c-bec3-40dd-8397-0c45019df7e0", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "2acd698c-03d2-4d0b-99a4-db00ed08f25d", + "name": "name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "13e8d53a-415f-4f98-a133-8795e0173efd", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "5a8652b6-9a42-48e5-819f-59259c349883", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "9d090281-1d17-4283-8fb7-183e1b536328", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "79bccac1-d0f1-4c28-a040-e0e640889e52", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "5ca2ebe3-ef32-4f62-bcad-e764c70d20e6", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "7d976f67-441d-4491-a1fe-f17343a03120", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "bb8a7b6e-2f1a-46b2-91e1-8d027dabe93d", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "84629e2c-c129-4d49-bb04-a03a5124ed47", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "f5c4b940-47ad-4734-8712-4f53357cff1c", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "e1e82435-1b52-4b41-aa0e-14a3e9779670", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "userinfo.token.claim": "true" + } + }, + { + "id": "c3803e56-b542-45e3-a780-0fb70841dd5c", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + } + ], + "loginWithEmailAllowed": true, + "registrationEmailAsUsername": false, + "loginTheme": "DSFR", + "accountTheme": "DSFR", + "adminTheme": "DSFR", + "emailTheme": "DSFR", + "internationalizationEnabled": true, + "supportedLocales": ["en", "fr"], + "defaultLocale": "fr" +} diff --git a/.jjconflict-side-2/migrations/v9/Dockerfile b/.jjconflict-side-2/migrations/v9/Dockerfile new file mode 100644 index 0000000000..5365c15bc1 --- /dev/null +++ b/.jjconflict-side-2/migrations/v9/Dockerfile @@ -0,0 +1,7 @@ +FROM docker.io/node:22.14.0-bullseye-slim +WORKDIR /app +COPY ./package.json package.json +RUN npm i +COPY ./migrate.js migrate.js +CMD [ "npm", "start" ] +USER node diff --git a/.jjconflict-side-2/migrations/v9/Migrate v9-FR.md b/.jjconflict-side-2/migrations/v9/Migrate v9-FR.md new file mode 100644 index 0000000000..f27cb4619f --- /dev/null +++ b/.jjconflict-side-2/migrations/v9/Migrate v9-FR.md @@ -0,0 +1,42 @@ +# Migration vers la v9 +## Qu'est-ce qui change ? +Nous avons pris la décision d'abandonner la fonctionnalité d'organisation, dès lors le projet ne sera plus rattaché à rien. Cela entraîne quelques problèmes de suivi des ressources, mais si vous suivez les instructions ci-dessous, tout ira bien. + +Les organisations reviendront à l'avenir, mais sous une forme complètement différente. + +## IMPORTANT +Sauvegardez votre instance gitlab et le kv Vault !!! + +## Préparation des ressources +Depuis la version 8.22.0, le serveur api stocke son numéro de version pour chaque projet provisionné avec succès. + +Avant de passer à la version 9, vous DEVEZ provisionner tous les projets actifs au moins à la version 8.23.0. Si vous ne le faites pas, le script de migration SQL lèvera une exception. + +Un bon suivi des ressources est essentiel. Si un projet ne peut pas être provisionné avec succès, vous pouvez toujours changer sa valeur dans la base de données directement, mais assurez-vous que les balises suivantes sont appliquées sur les ressources kubernetes correspondantes : +- dso/project.id : (namespace, applications, appprojects) +- dso/project.slug : (namespace, applications, appprojects) +- dso/environment.id : (namespace, applications, appprojects) +- dso/repository.id : (namespace, applications) + +## Migration des ressources utilisateurs +Vous pouvez lancer cette image (une seule fois) avant ou après la mise à jour vers la v9. + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: migrate-v9 +spec: + containers: + - image: ghcr.io/cloud-pi-native/migration:v9 + name: migrate-v9 + resources: {} + envFrom: + - secretRef: + name: dso-config + restartPolicy: Never +``` +kubectl run --image docker ghcr.io/cloud-pi-native/migration:v9 + +### À propos de Gitlab +Parce que gitlab effectue de nombreuses tâches de manière asynchrone, nous ne pouvons pas supprimer les anciens groupes d'organisation après avoir transféré les groupes de projet. Vous devrez vous connecter manuellement pour les rechercher et les supprimer. Si vous ne le faites pas, ce n'est pas grave... diff --git a/.jjconflict-side-2/migrations/v9/Migrate v9.md b/.jjconflict-side-2/migrations/v9/Migrate v9.md new file mode 100644 index 0000000000..9b7e94bacd --- /dev/null +++ b/.jjconflict-side-2/migrations/v9/Migrate v9.md @@ -0,0 +1,42 @@ +# Migrating to v9 +## What's changing? +We've taken the decision to abandon the organization feature, so the project won't be attached to anything. This is causing some resource tracking problems, but if you follow the instructions below, you'll be fine. + +Organizations will return in future, but in a completely different form. + +## IMPORTANT +Backup your gitlab instance and the vault KV !!! + +## Resource preparation +Since version 8.22.0, the api server stores its version number for each successfully provisioned project. + +Before upgrading to version 9, you MUST provision all active projects to at least version 8.23.0. If you fail to do so, the SQL migration script will throw an exception. + +Good resource tracking is essential. If a project cannot be provisioned successfully, you can always change its value in the database directly, but make sure the following tags are applied on the corresponding kubernetes resources: +- dso/project.id : (namespace, applications, appprojects) +- dso/project.slug : (namespace, applications, appprojects) +- dso/environment.id : (namespace, applications, appprojects) +- dso/repository.id : (namespace, applications) + +## Migrate user resources +You can run this image (once only) before or after upgrading to v9 + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: migrate-v9 +spec: + containers: + - image: ghcr.io/cloud-pi-native/migration:v9 + name: migrate-v9 + resources: {} + envFrom: + - secretRef: + name: dso-config + restartPolicy: Never +``` +kubectl run --image docker ghcr.io/cloud-pi-native/migration:v9 + +### About Gitlab +Because gitlab performs many tasks asynchronously, we can't delete the old organization groups after transferring the project groups. You'll have to log in manually to search for and delete them. If you don't, it's not a big deal... diff --git a/.jjconflict-side-2/migrations/v9/migrate.js b/.jjconflict-side-2/migrations/v9/migrate.js new file mode 100644 index 0000000000..ed8d048084 --- /dev/null +++ b/.jjconflict-side-2/migrations/v9/migrate.js @@ -0,0 +1,142 @@ +import { logger as baseLogger } from '@cpn-console/logger/hooks' +import { Gitlab } from '@gitbeaker/rest' +import axios from 'axios' + +const logger = baseLogger.child({ scope: 'migration:v9' }) + +export function removeTrailingSlash(url) { + return url?.endsWith('/') + ? url.slice(0, -1) + : url +} + +export function requiredEnv(envName) { + const envValue = process.env[envName] + if (envValue) return envValue + + throw new Error(`env: ${envName} is not defined !`) +} + +const gitlabToken = requiredEnv('GITLAB_TOKEN') +const gitlabPublicUrl = removeTrailingSlash(requiredEnv('GITLAB_URL')) +const projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') +const gitlabInternalUrl = process.env.GITLAB_INTERNAL_URL + ? removeTrailingSlash(process.env.GITLAB_INTERNAL_URL) + : gitlabPublicUrl + +const vaultPublicUrl = removeTrailingSlash(requiredEnv('VAULT_URL')) + +const axiosInstance = axios.create({ + baseURL: vaultPublicUrl, + headers: { + 'X-Vault-Token': requiredEnv('VAULT_TOKEN'), + }, +}) + +const api = new Gitlab({ token: gitlabToken, host: gitlabInternalUrl }) + +const groupRootSearch = await api.Groups.search(projectsRootDir) +const groupRootId = (groupRootSearch.find(grp => grp.full_path === projectsRootDir))?.id + +const organizationGroups = await api.Groups.allDescendantGroups(groupRootId, { perPage: 300 }) + +logger.info({ organizationGroupsCount: organizationGroups.length }, 'Loaded organization groups') +if (organizationGroups.length > 300) { + throw new Error('increase perPage, you could miss some results') +} + +for (const organizationGroup of organizationGroups) { + if (organizationGroup.name === 'Infra') continue + logger.info({ organizationGroupId: organizationGroup.id, organizationGroupName: organizationGroup.name }, 'Processing organization group') + const projectGroups = await api.Groups.allDescendantGroups(organizationGroup.id, { perPage: 300 }) + if (projectGroups.length > 300) { + throw new Error('increase perPage, you could miss some projects group results') + } + + for (const projectGroup of projectGroups) { + const newName = `${organizationGroup.name}-${projectGroup.name}` + logger.info({ projectGroupId: projectGroup.id, newName }, 'Renaming and transferring project group') + + try { + const renamedGroup = await api.Groups.edit(projectGroup.id, { name: newName, path: newName }) + await api.Groups.transfer(renamedGroup.id, { groupId: groupRootId }) + } catch (error) { + logger.warn({ err: error, projectGroupId: projectGroup.id, organizationGroupId: organizationGroup.id }, 'Could not rename/transfer project group') + } + } +} + +const coreKvName = 'forge-dso' + +const vaultToken = (await axiosInstance.post('/v1/auth/token/create')) + .data.auth.client_token + +function transformPath(path) { + if (!path.startsWith('/')) { + path = `/${path}` + } + const parts = path.split('/') + return `/${parts[1]}-${parts[2]}/${parts.slice(3).join('/')}` +} +const secretsMapper = {} + +async function list(path = '/') { + if (!path.startsWith('/')) + path = `/${path}` + const response = await axiosInstance({ + url: `/v1/${coreKvName}/metadata/${projectsRootDir}${path}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + method: 'list', + validateStatus: code => [200, 404].includes(code), + }) + + if (response.status === 404) return [] + for (const key of response.data.data.keys) { + if (key.endsWith('/')) { + await list(`${path}${key}`) + } else { + secretsMapper[`${path}${key}`] = transformPath(`${path}${key}`) + } + } +} + +try { + await list() + logger.info({ secretsCount: Object.keys(secretsMapper).length }, 'Collected secrets mapping') +} catch (error) { + logger.error({ err: error }, 'Failed while collecting secrets mapping') +} + +for (const [source, destination] of Object.entries(secretsMapper)) { + const secretContent = await axiosInstance({ + url: `/v1/${coreKvName}/data/${projectsRootDir}${source}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + method: 'get', + validateStatus: code => [200, 404].includes(code), + }) + const data = secretContent.data.data.data + + try { + await axiosInstance({ + method: 'POST', + url: `/v1/${coreKvName}/data/${projectsRootDir}${destination}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + data: { data }, + }) + await axiosInstance({ + method: 'delete', + url: `/v1/${coreKvName}/metadata/${projectsRootDir}${source}`, + headers: { + 'X-Vault-Token': vaultToken, + }, + }) + } catch (error) { + logger.error({ err: error, source, destination }, 'Failed while moving secret') + } +} diff --git a/.jjconflict-side-2/migrations/v9/package.json b/.jjconflict-side-2/migrations/v9/package.json new file mode 100644 index 0000000000..c49c17280c --- /dev/null +++ b/.jjconflict-side-2/migrations/v9/package.json @@ -0,0 +1,15 @@ +{ + "name": "dso-v9-migration", + "version": "1.0.0", + "description": "", + "author": "Cloud Pi Native", + "license": "ISC", + "main": "index.js", + "scripts": { + "start": "node migrate.js" + }, + "dependencies": { + "@gitbeaker/rest": "catalog:runtime", + "axios": "catalog:runtime" + } +} \ No newline at end of file diff --git a/.jjconflict-side-2/misc/business-rules.md b/.jjconflict-side-2/misc/business-rules.md new file mode 100644 index 0000000000..04f1a4fee9 --- /dev/null +++ b/.jjconflict-side-2/misc/business-rules.md @@ -0,0 +1,63 @@ +# Console Cloud π Native + +## Règles métier + +### Formulaire de commande d'un espace projet + +| Champ | Type | Description | +| --------------------- | ------ | ------------------------------------- | +| Nom de l'organisation | string | obligatoire | +| Nom du projet | string | obligatoire, `regex([a-z0-9-]{2,53})` | + +### Formulaire de synchronisation d'un dépôt + +| Champ | Type | Description | +| ----------------------------------------------- | ------- | ----------------------------------------- | +| Nom du dépôt Git interne | string | obligatoire, `regex([a-z0-9-]{2,53})` | +| Url du dépôt Git externe | string | obligatoire, `regex(^https://)` | +| Dépôt externe privé | boolean | obligatoire | +| Nom d'utilisateur lié au token du dépôt externe | string | obligatoire si le dépôt externe est privé | +| Token d'accès au dépôt externe | string | obligatoire si le dépôt externe est privé | +| Dépôt d'infrastructure | boolean | obligatoire | + +### Status et verrouillage + +#### Schema + +- un `project` peut avoir pour `status: Enum('initializing', 'created', 'failed', 'archived')` +- un `repository` et un `environnement` peuvent avoir pour `status: Enum('initializing', 'created', 'failed', 'deleting')` +- un `project` peut être `locked (boolean)` + +#### Principe + +- `initializing` : la ressource est en cours de création, des opérations sont en cours côté `plugins` - le `project` est `locked: true` +- `created` : la ressource est créée, les opérations des `plugins` se sont bien déroulées - le `project` est `locked: false` +- `failed` : la ressource est créée, les opérations des `plugins` ont échoué - le `project` est `locked: true` +- `deleting` : la ressource est en cours de suppression, des opérations sont en cours côté `plugins` - le `project` est `locked: true` +- `archived` : les opérations des `plugins` se sont bien déroulées, le projet et son équipe sont toujours présents en base de données pour archive, mais invisibles des utilisateurs hors `admin` - le `project` est `locked: true` +- `project.locked: true` : toute opération de Create / Update sur le projet et ses ressource est interdite. + +### Permissions d'environnement + +Trois niveaux de permissions différents, +| Valeur en base | Description dans le front | Niveaux | +| -------------- | ------------------------- | ------------- | +| 0 | r | Lecture | +| 1 | rw | + Ecriture | +| 2 | rwd | + Suppression | + +## Points d'API + +voir swagger : + +--- + +## TODO: Idées + +### Admin + +- Lister chacune des tables +- Mise en place d'une table d'historisation des status des projets +- Mettre en place un point d'api pour gérer l'équipe d'admin (création/suppression) +- Possibilité de réexecuter dans keycloak les droits présents dans la DB (synchro DB - keycloak) +- Route qui récapitule l'ensemble des ressources argo et supprime toutes les autres diff --git a/.jjconflict-side-2/misc/controller-plugins-communication.png b/.jjconflict-side-2/misc/controller-plugins-communication.png new file mode 100644 index 0000000000..b446155dc5 Binary files /dev/null and b/.jjconflict-side-2/misc/controller-plugins-communication.png differ diff --git a/.jjconflict-side-2/misc/controllers.md b/.jjconflict-side-2/misc/controllers.md new file mode 100644 index 0000000000..d194fec2fd --- /dev/null +++ b/.jjconflict-side-2/misc/controllers.md @@ -0,0 +1,29 @@ +# Logique controllers + +```mermaid +flowchart TD + A[(db: find prérequis)] + A -->|KO| B>send code 400, 401, 403, 404] + A -->|try| D(db: update projectLocked = true + status = initializing + upsert/delete + hook: plugins) + D -->|catch| E[(db: update status = failed)] + E --> B + D -->|OK| G[(db: update projectLocked = false + status = created)] + G -->|OK| H>send code 200, 201] + G -->|catch| E +``` + +## Principe + +### Controller + +- reçoit la requête +- appelle la couche business +- renvoie la réponse + +### Business + +- est appelé par le controller +- appelle l'ORM pour les opérations en bdd +- appelle éventuellement les plugins +- gère les erreurs (try catch) +- renvoie un résultat ou une erreur au controller diff --git a/.jjconflict-side-2/misc/images/environments.png b/.jjconflict-side-2/misc/images/environments.png new file mode 100644 index 0000000000..2c9f47a130 Binary files /dev/null and b/.jjconflict-side-2/misc/images/environments.png differ diff --git a/.jjconflict-side-2/misc/images/members.png b/.jjconflict-side-2/misc/images/members.png new file mode 100644 index 0000000000..bcd9955ba6 Binary files /dev/null and b/.jjconflict-side-2/misc/images/members.png differ diff --git a/.jjconflict-side-2/misc/images/projects.png b/.jjconflict-side-2/misc/images/projects.png new file mode 100644 index 0000000000..de48c1d125 Binary files /dev/null and b/.jjconflict-side-2/misc/images/projects.png differ diff --git a/.jjconflict-side-2/misc/images/repositories.png b/.jjconflict-side-2/misc/images/repositories.png new file mode 100644 index 0000000000..b1029c947d Binary files /dev/null and b/.jjconflict-side-2/misc/images/repositories.png differ diff --git a/.jjconflict-side-2/misc/images/services.png b/.jjconflict-side-2/misc/images/services.png new file mode 100644 index 0000000000..4b346ebf31 Binary files /dev/null and b/.jjconflict-side-2/misc/images/services.png differ diff --git a/.jjconflict-side-2/misc/images/status.png b/.jjconflict-side-2/misc/images/status.png new file mode 100644 index 0000000000..472e57d330 Binary files /dev/null and b/.jjconflict-side-2/misc/images/status.png differ diff --git a/.jjconflict-side-2/misc/plugins.md b/.jjconflict-side-2/misc/plugins.md new file mode 100644 index 0000000000..b062c3285d --- /dev/null +++ b/.jjconflict-side-2/misc/plugins.md @@ -0,0 +1,67 @@ +# Plugins + +## Lexique + +Un plugin `core` est un service de base associé à la chaîne DSO. +Un plugin `external` est un service supplémentaire facultatif. +Un `hook` est un point de déclenchement dont le cycle de vie comprend plusieurs étapes (`step`) : `pre`, `main`, `post`, etc. +Tout `plugin` peut s'enregistrer sur un `hook`, à une étape donnée. + +## Principe + +Exemple : + +1. Le plugin `gitlab` s'enregistre sur le hook `createProject`, à l'étape `main`. +2. Le controller `createProjectController` déclenche le hook `createProject` en lui associant un `payload`. +3. Le plugin `gitlab` réagit au déclenchement du hook `createProject` et reçoit le payload associé du controller. +4. Le plugin `gitlab` effectue ses opérations (ex: création d'un groupe GitLab pour le projet). +5. Le plugin `gitlab` renvoie un objet `result` au controller `createProjectController`, contenant un `status` indiquant si tout s'est déroulé sans erreur ou non. +6. Le controller `createProjectController` enregistre ce `result` en base de donnée et s'arrête si le `status` est en erreur. + +## Modèles + +### Entries + +```js +{ + // args come from controllers + args: { + projectName: 'toto', + }, + failed: true || undefined + /* + [pluginName]: + [pluginName]: + ... + */ +} +``` + +### Result + +Note: Si un module réutilise son propre payload, il doit penser à renvoyer le précédent s'il ne veut pas l'écraser. Charge aussi au plugin de gérer les erreurs des étapes précédentes. + +```js +{ + status: { + result: string('KO' || 'OK'), + message: string(), + }, + vault: [ + { + name: string(), + data: { + // secret data + } + } + ] || undefined + // otherKey: {}, + // anotherKey: {}, +} +``` + +## Notes + +La communication des services associés à la chaîne DSO se fait au maximum en direct par les services Kubernetes via les url internes. On note toutefois les exceptions suivantes : +- Argocd communique avec l'url publique de Gitlab peu importe s'ils sont sur le même cluster. +- L'url publique de Harbor est utilisée dans les jobs de CI : il faudrait sinon une `imagePullSecret` avec url interne pour les jobs de CI et une avec url publique pour les pods des clusters applicatifs. diff --git a/.jjconflict-side-2/package.json b/.jjconflict-side-2/package.json new file mode 100644 index 0000000000..70b33dd885 --- /dev/null +++ b/.jjconflict-side-2/package.json @@ -0,0 +1,66 @@ +{ + "name": "@cpn-console/root", + "type": "module", + "version": "9.22.2", + "private": true, + "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "engines": { + "node": ">=24.0.0 <25.0.0", + "npm": ">=11.0.0" + }, + "scripts": { + "build": "pnpm -r run build", + "build:clean": "pnpm -r run build:clean", + "db:generate": "pnpm -r run db:generate", + "dev": "docker compose -f ./docker/docker-compose.local.yml up -d --remove-orphans && echo '\nStart applications with commands:\n\n pnpm --filter @cpn-console/server run dev\n pnpm --filter @cpn-console/client run dev\n pnpm --filter @cpn-console/server-nestjs run dev\n\nMake sure you provide env variables in `.env` files.'", + "dev:clean": "docker compose -f ./docker/docker-compose.local.yml down --remove-orphans", + "dev:delete": "docker compose -f ./docker/docker-compose.local.yml down -v --remove-orphans", + "docker:dev": "docker compose -f ./docker/docker-compose.dev.yml up --menu", + "docker:dev:build": "export COMPOSE_FILE=./docker/docker-compose.dev.yml && ./scripts/run-build.sh $COMPOSE_FILE", + "docker:dev:clean": "docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans", + "docker:dev:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:dev' -q) ; true", + "docker:e2e": "docker compose -f ./docker/docker-compose.dev.yml up ${TAG:+--no-build} -d --remove-orphans; pnpm playwright:test; docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans", + "docker:e2e-ci": "docker compose -f ./docker/docker-compose.ci.yml up ${TAG:+--no-build} -d --remove-orphans; pnpm playwright:test", + "docker:e2e-ci:delete": "docker compose -f ./docker/docker-compose.ci.yml down -v --remove-orphans", + "docker:integ": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.integ.yml up --menu", + "docker:integ:build": "export $(cat apps/server/.env.integ | grep -v '#' | xargs) && export COMPOSE_FILE=./docker/docker-compose.integ.yml && cd $(dirname $COMPOSE_FILE) && docker buildx bake --allow=fs.read=.. --file $(basename $COMPOSE_FILE) --load && cd - > /dev/null", + "docker:integ:clean": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.integ.yml down --remove-orphans", + "docker:integ:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:integ' -q) ; true", + "docker:ci": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.ci.yml up --menu", + "docker:ci:build": "export $(cat apps/server/.env.integ | grep -v '#' | xargs) && export COMPOSE_FILE=./docker/docker-compose.ci.yml && cd $(dirname $COMPOSE_FILE) && docker buildx bake --allow=fs.read=.. --file $(basename $COMPOSE_FILE) --load && cd - > /dev/null", + "docker:ci:clean": "docker compose --env-file apps/server/.env.integ -f ./docker/docker-compose.ci.yml down --remove-orphans", + "docker:ci:delete": "docker rmi -f $(docker images --filter=reference='dso-console/*:ci' -q) ; true", + "format": "pnpm -r run format", + "format:root": "eslint . --fix", + "format:style": "pnpm -r run format:style", + "fullclean": "sh ./ci/scripts/cleanup.sh", + "fullsetup": "sh ./ci/scripts/setup.sh", + "integ": "docker compose -f ./docker/docker-compose.local.yml up -d --remove-orphans postgres pgadmin && echo '\nStart server and client with commands:\n\n pnpm --filter @cpn-console/server run integ\n pnpm --filter @cpn-console/client run integ\n pnpm --filter @cpn-console/server-nestjs run integ\n\nMake sure you provide env variables in `.env.integ` files.'", + "integ:clean": "docker compose -f ./docker/docker-compose.local.yml down --remove-orphans", + "integ:delete": "docker compose -f ./docker/docker-compose.local.yml down -v --remove-orphans", + "lint": "pnpm -r run lint", + "lint:root": "eslint .", + "lint:style": "pnpm -r run lint:style", + "lint:ts": "pnpm -r run lint:ts", + "playwright:test": "pnpm --dir playwright run playwright:test", + "playwright:test:ui": "pnpm --dir playwright run playwright:test:ui", + "playwright:test:integration": "pnpm --dir playwright run playwright:test:integration", + "prepare": "husky", + "test": "pnpm -r --no-bail run test", + "test:cov": "pnpm -r --no-bail run test:cov", + "test:e2e": "pnpm kube:e2e", + "test:e2e-ci": "pnpm kube:prod; pnpm kube:e2e-ci" + }, + "devDependencies": { + "@commitlint/cli": "catalog:tools", + "@commitlint/config-conventional": "catalog:tools", + "@cpn-console/eslint-config": "workspace:^", + "eslint": "catalog:tools", + "husky": "catalog:tools", + "lint-staged": "catalog:tools" + } +} diff --git a/.jjconflict-side-2/packages/eslintconfig/eslint.config.js b/.jjconflict-side-2/packages/eslintconfig/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-2/packages/eslintconfig/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-2/packages/eslintconfig/package.json b/.jjconflict-side-2/packages/eslintconfig/package.json new file mode 100644 index 0000000000..e81e294cbe --- /dev/null +++ b/.jjconflict-side-2/packages/eslintconfig/package.json @@ -0,0 +1,21 @@ +{ + "name": "@cpn-console/eslint-config", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "type": "module", + "version": "1.0.2", + "license": "ISC", + "main": "src/index.js", + "types": "types/index.d.ts", + "devDependencies": { + "@antfu/eslint-config": "catalog:tools", + "eslint": "catalog:tools" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/packages/eslintconfig/src/index.js b/.jjconflict-side-2/packages/eslintconfig/src/index.js new file mode 100644 index 0000000000..79867556e6 --- /dev/null +++ b/.jjconflict-side-2/packages/eslintconfig/src/index.js @@ -0,0 +1,41 @@ +import antfu from '@antfu/eslint-config' + +export default antfu( + { + stylistic: { + overrides: { + 'antfu/consistent-chaining': 'off', + 'antfu/if-newline': 'off', + 'antfu/no-top-level-await': 'off', + 'node/prefer-global/process': ['error', 'always'], + 'node/prefer-global/buffer': ['error', 'always'], + 'style/comma-dangle': ['error', 'always-multiline'], + 'style/quote-props': ['error', 'as-needed', { keywords: false, unnecessary: true }], + 'style/brace-style': ['error', '1tbs', { allowSingleLine: true }], + 'style/max-statements-per-line': ['error', { max: 2 }], + 'ts/ban-ts-comment': 'off', + }, + }, + typescript: true, + vue: true, + yaml: { + overrides: { + 'yaml/quotes': ['error', { prefer: 'double' }], + 'yaml/indent': ['error', 2, { indentBlockSequences: true, indicatorValueIndent: 2 }], + }, + }, + ignores: [ + '**/node_modules', + '**/prisma/migrations', + '**/pnpm-lock.yaml', + '**/dist/', + '**/types/', + '**/coverage/', + '**/templates/*.{yaml,yml}', + '**/Chart.yaml', + '**/*.d.ts', + '**/*.md/*.js', + '**/*.md/*.ts', + ], + }, +) diff --git a/.jjconflict-side-2/packages/hooks/.gitignore b/.jjconflict-side-2/packages/hooks/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-2/packages/hooks/README.md b/.jjconflict-side-2/packages/hooks/README.md new file mode 100644 index 0000000000..e0149e15c8 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/README.md @@ -0,0 +1,245 @@ +# Plugin manager + +## Présentation + +Le plugin manager ou communément appelés "hooks" est une interface permettant au serveur d'api (`apps/server`) de déclencher des hooks "déclencheurs / événements" pour que les plugins puissent piloter et configurer des outils. + +Les plugins obligatoires de l'application sont souvent appelés `corePlugins`. + +L'objectif est que le server soit totalement agnostique des actions des plugins et même de leur existence. De plus le serveur d'API essayera au démarrage de charger des plugins dans un dossier spécifique, on les distingue en les appelant `externalPlugins`. + +## Développement de plugin + +Cette section est dédiée à tous ceux voulant améliorer les corePlugins ou en créer de nouveaux. + +Pour commencer je vous conseille d'aller regarder le [plugin vault](../../plugins/vault) + +### Interface + +Tout paquet existant devrait avoir un export nommé `plugin` et de type `Plugin`. + +Ce typage vous assurera de bien fournir toutes les clés nécessaires au bon fonctionnement du plugin : + +```ts +// index.ts +import type { Plugin } from '@cpn-console/hooks' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: {}, + monitor, +} +``` + +### Infos + +Ce sont les infos de bases de votre application : + +```ts +// infos.ts +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos: ServiceInfos = { + name: 'my_plugin', // il serait bien que ça ne change jamais, imaginez que c'est un identifiant unique. + to: ({ projectSlug }) => `${process.env.MON_URL}/${projectSlug}`, + title: 'Mon super plugin', + imgSrc: 'https://un_lien_vers/image_externe.svg/', // préférez le svg + description: 'La description générale de mon plugin', +} + +export default infos +``` + +La fonction `to` peux renvoyer une `String` ou un objet contenant une clé `to` et potentiellement d'autres informations ou encore un tableau de cet objet : + +```ts +// Valid +const to1 = () => 'une url' +const to2 = () => ({ to: 'url', title: 'Un titre', description: 'description', imgSrc: 'url' }) +function to3() { + return [ + { to: 'url générale', title: 'Service générale', description: 'description générale' }, + { to: 'url1', title: 'Service 1', description: 'description 1' }, + { to: 'url2', title: 'Service 2', description: 'description 2' }, + ] +} +// Invalid +const to3 = () => ['url', 'url1', 'url2'] +``` + +### Monitoring + +Pour offrir un (et un seul) service de monitoring sommaire de l'outil que le plugin manipule, vous pouvez créer une instance de la classe `Monitor` et l'initialiser avec une fonction dont vous aurez seul la maîtrise. +Cette fonction sera éxécutée par un `setInterval` toutes les 5 min ou selon le temps en miliseconds que vous aurez fourni : + +```ts +// monitor.ts +import { Monitor, type MonitorInfos, MonitorStatus } from '@cpn-console/shared' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = (new Date()).getTime() + // Votre fonction ne devrait jamais lever d'exception + try { + // faites des trucs + // mettez à jour les clés en fonction de votre résultat: + // instance.lastStatus.message = 'Tout va bien' + // instance.lastStatus.status = MonitorStatus.OK + } catch (error) { + instance.lastStatus.message = 'Error lors la requete' + instance.lastStatus.status = MonitorStatus.UNKNOW + // la clé cause n'est pour l'instant jamais retourné à l'utilisateur ni stocké, ça sera pour une prochaine PR + instance.lastStatus.cause = error + } + // c'est bien de le retourner mais on s'en fiche un peu + return instance.lastStatus +} + +export default new Monitor(monitor) +``` + +### SubscribedHooks + +Pour informer le plugin manager sur quels hooks vous voulez exécuter une fonction, vous devez fournir un objet qui aura cette structure : + +```ts + subscribedHooks: { + createProject: { + steps: { + pre: createDsoProjectFirst, + post: createDsoProjectLast, + }, + }, + archiveProject: { + steps: { post: archiveDsoProject }, + }, + }, +``` + +### Fonctions (StepCalls) + +Pensez à typer vos fonctions comme suit pour que TS soit capable de vous notifier si vous avez associé un mauvais payload à un hook. + +**Vos fonctions ne doivent pas non plus lever d'exceptions non gérées.** +**Et elles doivent toutes retourner un statut !** + +Vous pouvez aussi retourner des clés supplémentaires. Ces clés sont accessibles par tous les plugins dans `payload.results[nom_du_plugin]`. + +Attention, chaque fonction écrase le résultat de la step d'avant : + +```ts +export const createDsoProjectFirst: StepCall = async (payload) => { + try { + // fais des trucs + // oh j'ai un payload typé ! + return { + status: { + result: 'OK', + message: 'ça s\'est bien passé' // optionnel si OK + }, + foo: { + bar: 'complétement facultatif' + } + } + } catch (error) { + return { + status: { + result: 'KO', + message: 'Ouille !' // Obligatoire pour explique ce qui n'a pas réussi' + }, + } + } +} + +export const createDsoProjectLast: StepCall = async (payload) => { + try { + // fais des trucs + // oh j'ai un payload typé ! + return { + status: { + result: 'OK', + message: `${payload.results.my_plugin.foo.bar} a vraiment bien été crée` // optionnel si OK + }, + une_clé: { + newProjectName: 'complétement facultatif' + } + } + } catch (error) { + return { + status: { + result: 'KO', + message: 'Ouille !' // Obligatoire pour explique ce qui n'a pas réussi' + }, + } + } +} +``` + +### Apis + +Il n'existe pour l'instant qu'un seul plugin qui exporte une API pour les autres plugins, c'est Vault ! +D'autres viendront aider à la séparation des responsabiltés plus tard, stay tuned ! + +Vous pouvez, comme lui, déclarer des apis sur des hooks. Pour l'uniformité, déclarez que c'est un `extends` de `PluginApi` (qui est vide) : + +```ts +// api.ts +import { PluginApi } from '@cpn-console/hooks' + +export class ClusterApi extends PluginApi {} +``` + +```ts +// index.ts + subscribedHooks: { + createCluster: { + api: (args) => new ClusterApi(args.label, args.privacy), + steps: { post: createCluster }, + }, + deleteCluster: { + api: (args) => new ClusterApi(args.label), + }, + }, +``` + +### Dépendances entre les plugins + +La section précédente est bien sympathique mais en l'état, le plugin n'a aucune conscience des apis des autres plugins. + +> Disclaimer: Les plugins peuvent se parler entre eux mais attention ils ne s'importent jamais les uns les autres. C'est le Plugin Manager qui est en charge de fournir tous les objets nécessaires et de faire passe plat. + +Pour y arriver il va falloir deux étapes + +1) Le plugin qui expose l'api doit faire un `declare module` : + +```ts +// index.ts +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + vault: Args extends CreateClusterExecArgs | DeleteClusterExecArgs + ? ClusterApi + : undefined + } +} +``` + +1) Le module l'utilisant doit importer les types : + +```json +// package.json +{ + "peerDependencies": { + "my_plugin": "1.2.3" + } +} +``` + +```ts +// src/env.d.ts +/// +``` + +## Conclusion + +N'hésitez pas à ouvrir des issues si ce n'est pas clair et bon développement ! diff --git a/.jjconflict-side-2/packages/hooks/eslint.config.js b/.jjconflict-side-2/packages/hooks/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-2/packages/hooks/package.json b/.jjconflict-side-2/packages/hooks/package.json new file mode 100644 index 0000000000..e13bdf29ad --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/package.json @@ -0,0 +1,57 @@ +{ + "name": "@cpn-console/hooks", + "type": "module", + "version": "4.1.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'vite-node src/config.ts'", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "test:e2e-ci": "echo 'check cache'", + "test:watch": "vitest" + }, + "dependencies": { + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "json-schema": "catalog:runtime", + "vitest-mock-extended": "catalog:runtime", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/json-schema": "catalog:types", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "undici-types": "catalog:types", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/packages/hooks/src/config.ts b/.jjconflict-side-2/packages/hooks/src/config.ts new file mode 100644 index 0000000000..ab1a702bf9 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/config.ts @@ -0,0 +1,76 @@ +import type { PermissionTarget, PluginConfig } from '@cpn-console/shared' +import { atomicValidators, DEFAULT, pluginConfig } from '@cpn-console/shared' +import { z } from 'zod' +import { objectEntries } from './utils/utils.js' + +export type PluginsManifests = Record +export const pluginsManifests: PluginsManifests = {} + +export const editStrippersGenerator = pluginConfig.transform((arg) => { + const project = { + user: z.object({}), + admin: z.object({}), + } + let global = z.object({}) + + for (const item of arg.project) { + const zAny = atomicValidators[item.kind].optional() + if (item.permissions.user.write) { + project.user = project.user.merge(z.object({ [item.key]: zAny })) + } + if (item.permissions.admin.write) { + project.admin = project.admin.merge(z.object({ [item.key]: zAny })) + } + } + + for (const item of arg.global || []) { + if (item.permissions.admin.write) { + const zAny = atomicValidators[item.kind].optional() + global = global.merge(z.object({ [item.key]: zAny })) + } + } + return { project, global } +}) + +export const editStrippers = { + project: { + admin: z.object({}), + user: z.object({}), + }, + global: z.object({}), +} + +export function addPlugin(pluginName: string, config: PluginConfig, editStrippersObject: typeof editStrippers) { + const editZod = editStrippersGenerator.parse(config) + pluginsManifests[pluginName] = config + editStrippersObject.global = editStrippersObject.global.merge(z.object({ [pluginName]: editZod.global.default({}) })) + editStrippersObject.project.admin = editStrippersObject.project.admin.merge(z.object({ [pluginName]: editZod.project.admin.default({}) })) + editStrippersObject.project.user = editStrippersObject.project.user.merge(z.object({ [pluginName]: editZod.project.user.default({}) })) +} + +interface Row { pluginName: string, key: string, value: string } +interface PopulateManifestsParams { + data: { project?: Row[], global?: Row[] } + permissionTarget: PermissionTarget + select: Partial> + pluginName: string +} +export function populatePluginManifests({ data, select, permissionTarget, pluginName }: PopulateManifestsParams): Partial { + const manifest = structuredClone(pluginsManifests[pluginName]) + + const selected: Partial = {} + for (const [scope] of objectEntries(select).filter(([_scope, bool]) => bool)) { + if (!manifest?.[scope]) continue + selected[scope] = manifest[scope].filter(item => item.permissions[permissionTarget].read || item.permissions[permissionTarget].write).map((item) => { + const row = data[scope]?.find(candidate => candidate.pluginName === pluginName && candidate.key === item.key) + if (item.kind === 'switch') { + const value = atomicValidators.switch.safeParse(row?.value) + item.value = value.success ? value.data : DEFAULT + } else { + item.value = z.coerce.string().parse(row?.value ?? item.value ?? '') + } + return item + }) + } + return selected +} diff --git a/.jjconflict-side-2/packages/hooks/src/hooks/hook-admin-role.ts b/.jjconflict-side-2/packages/hooks/src/hooks/hook-admin-role.ts new file mode 100644 index 0000000000..ff9b55292d --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/hooks/hook-admin-role.ts @@ -0,0 +1,16 @@ +import type { Hook } from './hook.js' +import type { UserObject } from './index.js' +import { createHook } from './hook.js' + +export interface AdminRole { + id: string + name: string + permissions: bigint + position: number + oidcGroup: string + type?: string + members: UserObject[] +} + +export const upsertAdminRole: Hook = createHook() +export const deleteAdminRole: Hook = createHook() diff --git a/.jjconflict-side-2/packages/hooks/src/hooks/hook-cluster.ts b/.jjconflict-side-2/packages/hooks/src/hooks/hook-cluster.ts new file mode 100644 index 0000000000..faeef0d8b7 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/hooks/hook-cluster.ts @@ -0,0 +1,6 @@ +import type { Hook } from './hook.js' +import type { ClusterObject } from './index.js' +import { createHook } from './hook.js' + +export const upsertCluster: Hook = createHook() +export const deleteCluster: Hook = createHook() diff --git a/.jjconflict-side-2/packages/hooks/src/hooks/hook-misc.ts b/.jjconflict-side-2/packages/hooks/src/hooks/hook-misc.ts new file mode 100644 index 0000000000..faf6705c12 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/hooks/hook-misc.ts @@ -0,0 +1,16 @@ +import type { Project, Repository } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +// misc hooks +export type EmptyPayload = Record + +export const checkServices: Hook = createHook() + +// misc project related hooks +export type ProjectLite = Pick + +export const getProjectSecrets: Hook = createHook() + +export type UniqueRepo = ProjectLite & { repo: Omit & { syncAllBranches: boolean, branchName?: string } } +export const syncRepository: Hook = createHook() diff --git a/.jjconflict-side-2/packages/hooks/src/hooks/hook-project-member.ts b/.jjconflict-side-2/packages/hooks/src/hooks/hook-project-member.ts new file mode 100644 index 0000000000..c1d726f599 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/hooks/hook-project-member.ts @@ -0,0 +1,16 @@ +import type { ProjectRole } from './hook-project-role.js' +import type { Project } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +export interface ProjectMember { + userId: string + email: string + firstName: string + lastName: string + roles: ProjectRole[] + project: Project +} + +export const upsertProjectMember: Hook = createHook() +export const deleteProjectMember: Hook = createHook() diff --git a/.jjconflict-side-2/packages/hooks/src/hooks/hook-project-role.ts b/.jjconflict-side-2/packages/hooks/src/hooks/hook-project-role.ts new file mode 100644 index 0000000000..8d047f4d79 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/hooks/hook-project-role.ts @@ -0,0 +1,17 @@ +import type { Project } from './hook-project.js' +import type { Hook } from './hook.js' +import { createHook } from './hook.js' + +export interface ProjectRole { + id: string + name: string + permissions: string + projectId: string + position: number + type?: string + oidcGroup?: string + project: Project +} + +export const upsertProjectRole: Hook = createHook() +export const deleteProjectRole: Hook = createHook() diff --git a/.jjconflict-side-2/packages/hooks/src/hooks/hook-project.ts b/.jjconflict-side-2/packages/hooks/src/hooks/hook-project.ts new file mode 100644 index 0000000000..86d65eff97 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/hooks/hook-project.ts @@ -0,0 +1,74 @@ +import type { PluginApi } from '../utils/utils.js' +import type { Hook } from './hook.js' +import type { ClusterObject, ExternalRepoUrl, InternalRepoName, IsInfra, IsPrivate, UserObject } from './index.js' +import { createHook } from './hook.js' + +export interface RepoCreds { + username: string + token: string +} + +export interface Role { + name: string + permissions?: string + position: number + type?: string + oidcGroup?: string + users: UserObject[] +} + +export interface EnvironmentApis { + [x: string]: PluginApi +} +export interface Environment { + id: string + name: string + clusterId: ClusterObject['id'] + cpu: number + gpu: number + memory: number + stage: string + autosync: boolean + permissions: { + userId: UserObject['id'] + permissions: { + ro: boolean + rw: boolean + } + }[] + apis: EnvironmentApis +} + +export interface Repository { + id: string + internalRepoName: InternalRepoName + newCreds?: RepoCreds + externalRepoUrl: ExternalRepoUrl + isPrivate: IsPrivate + isInfra: IsInfra + deployRevision: string | null + deployPath: string | null + helmValuesFiles: string | null +} + +export interface ProjectStore { + [x: string]: { [x: string]: string } +} + +export interface Project { + id: string + description: string | null + name: string + status: string + clusters: ClusterObject[] + slug: string + environments: Environment[] + repositories: Repository[] + users: UserObject[] + roles: Role[] + store: ProjectStore + owner: UserObject +} + +export const upsertProject: Hook = createHook() +export const deleteProject: Hook = createHook() diff --git a/.jjconflict-side-2/packages/hooks/src/hooks/hook-user.ts b/.jjconflict-side-2/packages/hooks/src/hooks/hook-user.ts new file mode 100644 index 0000000000..ffb9a9cb97 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/hooks/hook-user.ts @@ -0,0 +1,7 @@ +import type { Hook } from './hook.js' +import type { UserObject } from './index.js' +import { createHook } from './hook.js' + +export type UserEmail = Pick +export type UserAdmin = Pick & { isAdmin: boolean } +export const retrieveUserByEmail: Hook = createHook() diff --git a/.jjconflict-side-2/packages/hooks/src/hooks/hook-zone.ts b/.jjconflict-side-2/packages/hooks/src/hooks/hook-zone.ts new file mode 100644 index 0000000000..dd2ffc7710 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/hooks/hook-zone.ts @@ -0,0 +1,6 @@ +import type { Hook } from './hook.js' +import type { ZoneObject } from './index.js' +import { createHook } from './hook.js' + +export const upsertZone: Hook = createHook() +export const deleteZone: Hook = createHook() diff --git a/.jjconflict-side-2/packages/hooks/src/hooks/hook.spec.ts b/.jjconflict-side-2/packages/hooks/src/hooks/hook.spec.ts new file mode 100644 index 0000000000..3dcfe36fd9 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/hooks/hook.spec.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from 'vitest' +import { okStatus, PluginApi } from '../utils/utils.ts' +import { createHook, executeStep } from './hook.ts' + +const koStatus = { status: { result: 'KO', message: 'Failed' } } as const +const warningStatus = { status: { result: 'WARNING', message: 'Failed' } } as const + +async function simpleOkHookCall() { + return okStatus +} +async function simpleFailedHookCall() { + return koStatus +} +async function simpleWarningHookCall() { + return warningStatus +} + +describe('test executeStep mechanism', () => { + it('test payload results, everything ok', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleOkHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.failed).toBe(false) + expect(results.warning).toEqual([]) + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...okStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, everything ko', async () => { + const results = await executeStep({ + plugin1: simpleFailedHookCall, + plugin2: simpleFailedHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual([]) + expect(results.failed).contain('plugin1') + expect(results.failed).contain('plugin2') + expect(results.results).toEqual({ + plugin1: { ...koStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...koStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, partial ko', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleFailedHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual([]) + expect(results.failed).not.contain('plugin1') + expect(results.failed).contain('plugin2') + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...koStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, partial warning', async () => { + const results = await executeStep({ + plugin1: simpleOkHookCall, + plugin2: simpleWarningHookCall, + }, { apis: {}, args: {}, config: {}, failed: false, results: {}, warning: [] }, 'main') + + expect(results.apis).toEqual({}) + expect(results.args).toEqual({}) + expect(results.config).toEqual({}) + expect(results.warning).toEqual(['plugin2']) + expect(results.failed).toEqual(false) + expect(results.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + plugin2: { ...warningStatus, executionTime: { main: expect.any(Number) } }, + }) + }) +}) + +describe('createHook', () => { + it('test empty hookStructure', async () => { + const hook = createHook(false) + expect(hook).toEqual({ + apis: {}, + steps: { + check: {}, + pre: {}, + main: {}, + post: {}, + revert: {}, + }, + execute: expect.any(Function), + }) + + const hookUnique = createHook(true) + expect(hookUnique.uniquePlugin).toBe('') + }) + + it('test hook execution, simple ok', async () => { + const hook = createHook(false) + hook.steps.main.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.results).toEqual({ + plugin1: { ...okStatus, executionTime: { main: expect.any(Number) } }, + }) + }) + + it('test payload results, multistep ok', async () => { + const hook = createHook(false) + hook.steps.pre.plugin1 = simpleOkHookCall + hook.steps.main.plugin1 = simpleOkHookCall + hook.steps.post.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.warning).toEqual([]) + expect(hookResult.results).toEqual({ plugin1: { ...okStatus, executionTime: { + pre: expect.any(Number), + main: expect.any(Number), + post: expect.any(Number), + } } }) + }) + + it('test payload results, multistep with warning', async () => { + const hook = createHook(false) + hook.steps.pre.plugin1 = simpleWarningHookCall + hook.steps.main.plugin2 = simpleOkHookCall + hook.steps.post.plugin2 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(false) + expect(hookResult.warning).toEqual(['plugin1']) + expect(hookResult.results).toEqual({ + plugin1: { + ...warningStatus, + executionTime: { + pre: expect.any(Number), + }, + }, + plugin2: { + ...okStatus, + executionTime: { + main: expect.any(Number), + post: expect.any(Number), + }, + }, + }) + }) + + it('test payload results, main fails', async () => { + const hook = createHook(false) + hook.apis.plugin1 = () => new PluginApi() // à tester ailleurs + hook.steps.pre.plugin1 = simpleOkHookCall + hook.steps.main.plugin1 = simpleFailedHookCall + hook.steps.post.plugin1 = simpleOkHookCall + + const hookResult = await hook.execute({}, {}) + expect(hookResult.args).toEqual({}) + expect(hookResult.config).toEqual({}) + expect(hookResult.totalExecutionTime).toEqual(expect.any(Number)) + expect(hookResult.failed).toEqual(['plugin1']) + expect(hookResult.results).toEqual({ + plugin1: { + ...koStatus, + executionTime: { + pre: expect.any(Number), + main: expect.any(Number), + }, + }, + }) + }) +}) diff --git a/.jjconflict-side-2/packages/hooks/src/hooks/hook.ts b/.jjconflict-side-2/packages/hooks/src/hooks/hook.ts new file mode 100644 index 0000000000..1377d9abcc --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/hooks/hook.ts @@ -0,0 +1,192 @@ +import type { PluginApi } from '../utils/utils.js' +import type * as hooks from './index.js' +import { logger } from '@cpn-console/logger/hooks' + +export type DefaultArgs = Record +export type PluginResultStoreValue = string | number | null +export type PluginResultStore = Record +export interface PluginResult { + status: { result: 'OK', message?: string } | { result: 'KO' | 'WARNING', message: string } + store?: PluginResultStore + [key: string]: any +} + +export interface HookPayloadResults { + [x: string]: PluginResult +} +// @ts-ignore +// eslint-disable-next-line unused-imports/no-unused-vars +export interface HookPayloadApis { + [x: string]: PluginApi +} +export type Store = Record> // TO DEPRECIATE USE ONFIG + +export interface Config { + [x: string]: { [x: string]: string } +} + +export interface HookPayload { + args: Args + failed: boolean | string[] + warning: string[] + results: HookPayloadResults + apis: HookPayloadApis + config: Config +} + +export type HookResult = Omit, 'apis'> & { totalExecutionTime: number, messageResume?: string } + +export type StepCall = (payload: HookPayload) => Promise +type HookStep = Record> +export type HookStepsNames = 'check' | 'pre' | 'main' | 'post' | 'revert' +export interface Hook { + uniquePlugin?: string // if plugin register on it no other one can register on it + execute: (args: E, store: Config) => Promise> + apis: Record PluginApi> + steps: Record +} +export type HookList = Record> + +function generateMessageResume(payload: HookPayload): string | undefined { + let messageResume = '' + if (Array.isArray(payload.failed)) { + for (const pluginName of payload.failed) { + messageResume += 'Errors:' + messageResume += `\n${pluginName}: ${payload.results[pluginName].status.message};` + } + } + if (payload.warning.length) { + for (const pluginName of payload.warning) { + messageResume += 'Warnings:' + messageResume += `\n${pluginName}: ${payload.results[pluginName].status.message};` + } + } + return messageResume || undefined +} + +function handleRejectedStepResult( + reason: any, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + logger.error({ plugin: name, step: stepName, err: reason }, 'Hook step failed') + const result: PluginResult = { + status: { + result: 'KO', + message: reason instanceof Error ? reason.message : String(reason), + }, + } + payload.failed = Array.isArray(payload.failed) + ? [...payload.failed, name] + : [name] + return result +} + +function handleFulfilledStepResult( + value: PluginResult, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + if (value.status.result === 'KO') { + payload.failed = Array.isArray(payload.failed) + ? [...payload.failed, name] + : [name] + logger.error({ plugin: name, step: stepName, err: value.status.message, status: value.status.result, message: value.status.message }, 'Hook step failed') + } else if (value.status.result === 'WARNING' && !payload.warning.includes(name)) { + payload.warning.push(name) + logger.warn({ plugin: name, step: stepName, err: value.status.message, status: value.status.result, message: value.status.message }, 'Hook step warning') + } else { + logger.trace({ plugin: name, step: stepName, status: value.status.result }, 'Hook step fulfilled') + } + return value +} + +function handleStepResult( + settled: PromiseSettledResult, + name: string, + stepName: string, + payload: HookPayload, +): PluginResult { + const result = settled.status === 'fulfilled' + ? handleFulfilledStepResult(settled.value, name, stepName, payload) + : handleRejectedStepResult(settled.reason, name, stepName, payload) + return { ...result, executionTime: payload.results[name].executionTime } +} + +export async function executeStep(step: HookStep, payload: HookPayload, stepName: string) { + const names = Object.keys(step) + const fns = names.map(async (name) => { + if (payload.results[name]?.executionTime) { + payload.results[name].executionTime[stepName] = Date.now() + } else { + payload.results[name] = { + status: { result: 'OK' }, + executionTime: { [stepName]: Date.now() }, + } + } + const fnResult = await step[name](payload) + payload.results[name].executionTime[stepName] = Date.now() - payload.results[name].executionTime[stepName] + return fnResult + }) + const results = await Promise.allSettled(fns) + names.forEach((name, index) => { + payload.results[name] = handleStepResult(results[index], name, stepName, payload) + }) + return payload +} +export function createHook(unique = false) { + const steps: Record = { + check: {}, + pre: {}, + main: {}, + post: {}, + revert: {}, + } + const apis: Record PluginApi> = { + } + const execute = async (args: E, config: Config): Promise> => { + const startTime = Date.now() + const payloadApis: HookPayloadApis = {} + Object.entries(apis).forEach(([pluginName, apiFn]) => { + payloadApis[pluginName] = apiFn(args) + }) + let payload: HookPayload = { + failed: false, + args, + results: {}, + apis: payloadApis, + config, + warning: [], + } + + const executeSteps = ['pre', 'main', 'post'] as const + for (const step of executeSteps) { + payload = await executeStep(steps[step], payload, step) + if (payload.failed) { + payload = await executeStep(steps.revert, payload, 'revert') + break + } + } + return { + args: payload.args, + results: payload.results, + failed: payload.failed, + warning: payload.warning, + totalExecutionTime: Date.now() - startTime, + config, + messageResume: generateMessageResume(payload), + } + } + + const hook: Hook = { + apis, + steps, + execute, + } + if (unique) { + hook.uniquePlugin = '' + } + return hook +} diff --git a/.jjconflict-side-2/packages/hooks/src/hooks/index.ts b/.jjconflict-side-2/packages/hooks/src/hooks/index.ts new file mode 100644 index 0000000000..a783454745 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/hooks/index.ts @@ -0,0 +1,68 @@ +export * from './hook-admin-role.js' +export * from './hook-cluster.js' +export * from './hook-misc.js' +export * from './hook-project-member.js' +export * from './hook-project-role.js' +export * from './hook-project.js' +export * from './hook-user.js' +export * from './hook-zone.js' + +export interface PartialEnvironment { environment: string, stage: string, clusterLabel: string } +export type InternalRepoName = string +export type ExternalRepoUrl = string +export type ExternalUserName = string +export type ExternalToken = string +export type IsPrivate = boolean +export type IsInfra = boolean +export type InternalUrl = string +export interface UserObject { + firstName: string + lastName: string + id: string + email: string +} +export interface EnvironmentObject { + id: string + name: string + createdAt: Date + updatedAt: Date + clusterId: string +} + +export interface KubeUser { + readonly certData?: string + readonly keyData?: string + readonly token?: string + readonly username?: string + readonly password?: string +} + +export interface KubeCluster { + readonly caData?: string + readonly server: string + readonly skipTLSVerify?: boolean + readonly tlsServerName?: string +} + +export interface ZoneObject { + id: string + slug: string + label: string + argocdUrl: string + clusterNames?: string[] +} + +export interface ClusterObject { + id: string + label: string + privacy: 'public' | 'dedicated' + /** + * @deprecated Le nom aléatoire stocké ici n'est plus utilisé, au profit d'un nom caculé et déterministe. + */ + secretName: string + clusterResources: boolean + infos: string | null + cluster: KubeCluster + user: KubeUser + zone: ZoneObject +} diff --git a/.jjconflict-side-2/packages/hooks/src/index.ts b/.jjconflict-side-2/packages/hooks/src/index.ts new file mode 100644 index 0000000000..ad4af3bca8 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/index.ts @@ -0,0 +1,135 @@ +import type { Monitor } from '@cpn-console/shared' +import type { HookStepsNames, StepCall } from './hooks/hook.js' +import type { ServiceInfos } from './services.js' +import type { PluginApi } from './utils/utils.js' +import { logger } from '@cpn-console/logger/hooks' +import { addPlugin, editStrippers } from './config.js' +import * as hooks from './hooks/index.js' +import { servicesInfos } from './services.js' +import { objectEntries } from './utils/utils.js' + +export * from './config.js' + +export type HookChoice = keyof typeof hooks + +type ExecArgs = Parameters[0] + +export type PluginsFunctions = Partial<{ + [C in HookChoice]: { + steps?: Partial<{ + [S in HookStepsNames]: StepCall> + }> + api?: (args: ExecArgs) => PluginApi + } +}> + +export interface Plugin { + infos: ServiceInfos + subscribedHooks: PluginsFunctions + monitor?: Monitor + start?: (options: unknown) => void +} + +export type RegisterFn = (plugin: Plugin) => void +export type UnregisterFn = (name: Plugin['infos']['name']) => void +export interface PluginManager { + servicesInfos: Record + register: RegisterFn + unregister: UnregisterFn +} + +export interface PluginManagerOptions { + startPlugins?: boolean + mockMonitoring?: boolean + mockHooks?: boolean + mockExternalServices?: boolean + externalDir?: string +} + +let config: PluginManagerOptions +function pluginManager(options: PluginManagerOptions): PluginManager { + config = options + const register: RegisterFn = (plugin: Plugin) => { + if (plugin.infos.config) { + addPlugin(plugin.infos.name, plugin.infos.config, editStrippers) + } + + if (plugin.infos.to && config.mockExternalServices) + plugin.infos.to = () => [{ name: 'Lien', to: 'https://theuselessweb.com/' }] + if (plugin.start && options.startPlugins) + plugin.start({}) + const message: string[] = [] + if (plugin.monitor && config.mockMonitoring) { + plugin.monitor.monitorFn = async (instance: Monitor) => instance.lastStatus + } + if (plugin.monitor) + plugin.monitor.refresh() + servicesInfos[plugin.infos.name] = { + ...plugin.infos, + monitor: plugin.monitor, + } + const subscribedHooks = plugin.subscribedHooks + const name = plugin.infos.name + + if (!config.mockHooks) { + for (const [hook, functions] of objectEntries(subscribedHooks)) { + if (!(hook in hooks)) { + logger.warn({ plugin: name, hook }, 'Tried to register on an unknown hook') + continue + } + if (functions?.api) { + hooks[hook].apis[name] = functions.api + } + for (const [step, fn] of objectEntries(functions?.steps ?? {})) { + if (fn === undefined) + continue + if (hook === 'checkServices' && step !== 'check') { + logger.warn({ plugin: name, hook, step }, 'Tried to register an invalid step for checkServices hook') + continue + } + + if ('uniquePlugin' in hooks[hook] && hooks[hook]?.uniquePlugin !== '' && hooks[hook]?.uniquePlugin !== name) { + logger.warn({ plugin: name, hook, registeredBy: hooks[hook].uniquePlugin }, 'Hook is already registered by another plugin') + continue + } + // @ts-ignore + hooks[hook].steps[step][name] = fn + message.push(`${hook}:${step}`) + } + } + } + if (process.env.NODE_ENV !== 'test') { + logger.info({ plugin: name, registrations: message }, 'Plugin registered') + } + } + + const unregister: UnregisterFn = (name) => { + delete servicesInfos[name] + + Object.values(hooks).forEach((hook) => { + delete hook.steps.pre[name] + delete hook.steps.main[name] + delete hook.steps.post[name] + delete hook.steps.revert[name] + delete hook.apis[name] + }) + } + + return { + servicesInfos, + register, + unregister, + } +} + +export { + hooks, + pluginManager, +} + +export * from './hooks/hook.js' +export * from './hooks/index.js' +export * from './services.js' +export * from './utils/crypto.js' +export * from './utils/plugin-result-handler.js' +export * from './utils/utils.js' diff --git a/.jjconflict-side-2/packages/hooks/src/services.ts b/.jjconflict-side-2/packages/hooks/src/services.ts new file mode 100644 index 0000000000..dce8693564 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/services.ts @@ -0,0 +1,40 @@ +import type { Monitor, MonitorInfos, PluginConfig, PluginsUpdateBody } from '@cpn-console/shared' +import type { ClusterObject, EnvironmentObject, ProjectLite, ZoneObject } from './hooks/index.js' + +interface ToUrlObject { to: string, title?: string, description?: string, imgSrc?: string } +export interface ToUrlFnParamaters { + store: PluginsUpdateBody + clusters: Omit[] + zones: ZoneObject[] + environments: EnvironmentObject[] + project: Omit +} +type ToUrlFnResponse = ToUrlObject | ToUrlObject[] | string | void + +export interface ServiceInfos { + name: string + to?: ({ store, clusters, zones, environments, project }: ToUrlFnParamaters) => ToUrlFnResponse + monitor?: Monitor + title: string + imgSrc?: string + description?: string + config?: PluginConfig +} + +export const servicesInfos: Record = {} + +export type ServiceStatus = MonitorInfos & { name: string } +export const services = { + getStatus: (): Array => { + return Object.values(servicesInfos).reduce((acc, serviceInfos) => { + if (!serviceInfos?.monitor?.lastStatus) return acc + return [...acc, { ...serviceInfos.monitor.lastStatus, name: serviceInfos.title }] + }, [] as Array) + }, + refreshStatus: (): Array> => { + // @ts-ignore obligé d'ignore TS ne comprend pas l'interet du filter + return Object.values(servicesInfos) + .filter(servicesInfos => servicesInfos.monitor) + .map(serviceInfos => serviceInfos.monitor?.refresh()) + }, +} diff --git a/.jjconflict-side-2/packages/hooks/src/utils/crypto.ts b/.jjconflict-side-2/packages/hooks/src/utils/crypto.ts new file mode 100644 index 0000000000..5517e2b906 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/utils/crypto.ts @@ -0,0 +1,16 @@ +import type { Project } from '../hooks/index.js' +import crypto, { createHmac } from 'node:crypto' + +export function generateRandomPassword(length = 24) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*' + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +export function generateProjectKey(projectSlug: Project['slug'], repo: Project['repositories'][0]['internalRepoName']) { + const repoHash = createHmac('sha256', '') + .update(repo) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${repo}-${repoHash}` +} diff --git a/.jjconflict-side-2/packages/hooks/src/utils/index.ts b/.jjconflict-side-2/packages/hooks/src/utils/index.ts new file mode 100644 index 0000000000..ffb3bb4ec5 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './crypto.js' +export * from './utils.js' diff --git a/.jjconflict-side-2/packages/hooks/src/utils/plugin-result-handler.ts b/.jjconflict-side-2/packages/hooks/src/utils/plugin-result-handler.ts new file mode 100644 index 0000000000..a90261aede --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/utils/plugin-result-handler.ts @@ -0,0 +1,92 @@ +import type { PluginResult, PluginResultStore, PluginResultStoreValue } from '../hooks/hook.js' + +export class PluginResultBuilder { + private okMessages = [] as string[] + private warnMessages = [] as string[] + private koMessages = [] as string[] + private extras: Record = {} + public store: PluginResultStore = {} + + constructor(okMessage: string | undefined) { + if (okMessage) { + this.okMessages.push(okMessage) + } + } + + addExtra(key: string, value: any) { + this.extras[key] = value + return this + } + + addOkMessage(...messages: string[]) { + this.okMessages.push(...messages) + return this + } + + addKoMessage(...messages: string[]) { + this.koMessages.push(...messages) + return this + } + + addWarnMessage(...messages: string[]) { + this.warnMessages.push(...messages) + return this + } + + setOkMessage(...messages: string[]) { + this.okMessages = messages + return this + } + + setKoMessage(...messages: string[]) { + this.koMessages = messages + return this + } + + setWarnMessage(...messages: string[]) { + this.warnMessages = messages + return this + } + + setToStore(key: string, value: PluginResultStoreValue) { + this.store[key] = value + } + + deleteFromStore(key: string) { + delete this.store[key] + } + + returnUnexpectedError(error: unknown): PluginResult { + this.addKoMessage('UnexpectedError') + this.addExtra('error', error) + return this.getResultObject() + } + + getResultObject(): PluginResult { + const result: PluginResult = { + status: { + result: 'OK', + message: this.okMessages.join('\n') || undefined, + }, + ...this.extras, + } + + if (this.koMessages.length) { + result.status = { + result: 'KO', + message: this.koMessages.join('\n'), + } + } else if (this.warnMessages.length) { + result.status = { + result: 'WARNING', + message: this.warnMessages.join('\n'), + } + } + + if (Object.keys(this.store).length) { + result.store = this.store + } + + return result + } +} diff --git a/.jjconflict-side-2/packages/hooks/src/utils/utils.spec.ts b/.jjconflict-side-2/packages/hooks/src/utils/utils.spec.ts new file mode 100644 index 0000000000..a463bc5fb2 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/utils/utils.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { defaultOrNullish, disabledOrDefaultOrNullish, enabledOrDefaultOrNullish, objectEntries, objectKeys, objectValues, specificallyDisabled, specificallyEnabled } from './utils.ts' + +const object = { test1: 1, test2: 2, 3: 'test3' } + +it('should return object keys', () => { + const keys = objectKeys(object) + + // cannot gaurantee order in keys + expect(keys[0]).contain('3') + expect(keys[1]).contain('test1') + expect(keys[2]).contain('test2') +}) + +it('should return object entries', () => { + const keys = objectEntries(object) + + expect(keys[0]).toEqual(['3', 'test3']) + expect(keys[1]).toEqual(['test1', 1]) + expect(keys[2]).toEqual(['test2', 2]) +}) + +it('should return object values', () => { + const keys = objectValues(object) + + // cannot gaurantee order in values + expect(keys).contain('test3') + expect(keys).contain(1) + expect(keys).contain(2) +}) + +const values = [ + '', + 'nimp', + 'enabled', + 'default', + 'disabled', +] as const +describe('test config parsing', () => { + it('enabledOrDefaultOrNullish', () => { + expect(enabledOrDefaultOrNullish(values[0])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[1])).toBeFalsy() + expect(enabledOrDefaultOrNullish(values[2])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[3])).toBeTruthy() + expect(enabledOrDefaultOrNullish(values[4])).toBeFalsy() + }) + it('specificallyDisabled', () => { + expect(specificallyDisabled(values[0])).toBeFalsy() + expect(specificallyDisabled(values[1])).toBeFalsy() + expect(specificallyDisabled(values[2])).toBeFalsy() + expect(specificallyDisabled(values[3])).toBeFalsy() + expect(specificallyDisabled(values[4])).toBeTruthy() + }) + it('specificallyEnabled', () => { + expect(specificallyEnabled(values[0])).toBeFalsy() + expect(specificallyEnabled(values[1])).toBeFalsy() + expect(specificallyEnabled(values[2])).toBeTruthy() + expect(specificallyEnabled(values[3])).toBeFalsy() + expect(specificallyEnabled(values[4])).toBeFalsy() + }) + it('defaultOrNullish', () => { + expect(defaultOrNullish(values[0])).toBeTruthy() + expect(defaultOrNullish(values[1])).toBeFalsy() + expect(defaultOrNullish(values[2])).toBeFalsy() + expect(defaultOrNullish(values[3])).toBeTruthy() + expect(defaultOrNullish(values[4])).toBeFalsy() + }) + it('disabledOrDefault', () => { + expect(disabledOrDefaultOrNullish(values[0])).toBeTruthy() + expect(disabledOrDefaultOrNullish(values[1])).toBeFalsy() + expect(disabledOrDefaultOrNullish(values[2])).toBeFalsy() + expect(disabledOrDefaultOrNullish(values[3])).toBeTruthy() + expect(disabledOrDefaultOrNullish(values[4])).toBeTruthy() + }) +}) diff --git a/.jjconflict-side-2/packages/hooks/src/utils/utils.ts b/.jjconflict-side-2/packages/hooks/src/utils/utils.ts new file mode 100644 index 0000000000..65d03954c2 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/src/utils/utils.ts @@ -0,0 +1,88 @@ +import type { ServiceInfos } from '../services.js' +import { DEFAULT, DISABLED, ENABLED } from '@cpn-console/shared' + +export class PluginApi { } + +export function objectEntries>(obj: Obj): ([keyof Obj, Obj[keyof Obj]])[] { + return Object.entries(obj) as ([keyof Obj, Obj[keyof Obj]])[] +} +export function objectKeys>(obj: Obj): (keyof Obj)[] { + return Object.keys(obj) as (keyof Obj)[] +} +export function objectValues>(obj: Obj): (Obj[keyof Obj])[] { + return Object.values(obj) as (Obj[keyof Obj])[] +} + +type ConfigScope = keyof Required['config'] + +export type DeclareModuleGenerator, 'config'>, Scope extends ConfigScope> = { + [K in Infos['name']]?: { + [P in Infos['config'][Scope][number]['key']]?: string + } +} + +export const enabledOrDefaultOrNullish = (value?: string): boolean | undefined => value ? [ENABLED, DEFAULT].includes(value) : true +export const disabledOrDefaultOrNullish = (value?: string): boolean | undefined => value ? [DISABLED, DEFAULT].includes(value) : true +export const specificallyDisabled = (value?: string): boolean | undefined => value ? value === DISABLED : undefined +export const specificallyEnabled = (value?: string): boolean | undefined => value ? value === ENABLED : undefined +export const defaultOrNullish = (value?: string): boolean | undefined => value ? DEFAULT === value : true + +export const okStatus = { status: { result: 'OK' } } as const + +/** + * Take a list of list of kubernetes resources and ditch duplicates by name + * + * @remarks Kind is ignored. You can pass as many arguments as you want + * + * @example + * ```ts + * const foo = [{ metadata: { name: 'One' }}, { metadata: { name: 'Two' }}] + * const bar = [{ metadata: { name: 'One' }}, { metadata: { name: 'Three' }}] + * const xyz = [{ metadata: { name: 'Four' }}, { metadata: { name: 'Two' }}] + * uniqueResource(foo, bar, xyz) + * // [ + * // { metadata: { name: 'One' }}, + * // { metadata: { name: 'Two' }}, + * // { metadata: { name: 'Three' }}, + * // { metadata: { name: 'Four' }} + * // ] + * ``` + * + */ +export function uniqueResource(...lists: T[][]): T[] { + return lists + .flat() + .reduce((acc, cur) => (acc.some(item => item.metadata?.name === cur.metadata?.name) + ? acc + : [...acc, cur] + ), [] as T[]) +} + +export interface BaseResources { + kind?: string + apiVersion?: string + metadata: { + name?: string + namespace?: string + labels: { + [x: string]: string + } + [x: string]: any + } + [x: string]: any +} + +export interface BareMinimumResource { + metadata: { + name: string + labels: { + [x: string]: string + } + } +} + +export interface ListMinimumResources { + body: { + items: BareMinimumResource[] + } +} diff --git a/.jjconflict-side-2/packages/hooks/tsconfig.eslint.json b/.jjconflict-side-2/packages/hooks/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-2/packages/hooks/tsconfig.json b/.jjconflict-side-2/packages/hooks/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-2/packages/hooks/vitest.config.ts b/.jjconflict-side-2/packages/hooks/vitest.config.ts new file mode 100644 index 0000000000..58225abfa0 --- /dev/null +++ b/.jjconflict-side-2/packages/hooks/vitest.config.ts @@ -0,0 +1,16 @@ +import { configDefaults, defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: ['**/types', '**/mocks', '**/*.spec.ts', '**/*.d.ts'], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude], + }, +}) diff --git a/.jjconflict-side-2/packages/logger/eslint.config.js b/.jjconflict-side-2/packages/logger/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-2/packages/logger/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-2/packages/logger/package.json b/.jjconflict-side-2/packages/logger/package.json new file mode 100644 index 0000000000..2b14869190 --- /dev/null +++ b/.jjconflict-side-2/packages/logger/package.json @@ -0,0 +1,53 @@ +{ + "name": "@cpn-console/logger", + "type": "module", + "version": "1.0.1", + "private": false, + "description": "Cloud PI Native internal logger for legacy code", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "browser": "./dist/browser.js", + "default": "./dist/index.js" + }, + "./browser": { + "types": "./types/browser.d.ts", + "default": "./dist/browser.js" + }, + "./hooks": { + "types": "./types/hooks.d.ts", + "default": "./dist/hooks.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "pino": "catalog:tools", + "pino-pretty": "catalog:tools", + "zod": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "rimraf": "catalog:tools", + "typescript": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/packages/logger/src/browser.ts b/.jjconflict-side-2/packages/logger/src/browser.ts new file mode 100644 index 0000000000..f1ed417404 --- /dev/null +++ b/.jjconflict-side-2/packages/logger/src/browser.ts @@ -0,0 +1,16 @@ +import { pino, stdSerializers } from 'pino' + +export const logger = pino({ + customLevels: { + audit: 25, + }, + level: 'info', + browser: { + asObject: true, + }, + serializers: { + err: stdSerializers.errWithCause, + }, +}) + +export type Logger = typeof logger diff --git a/.jjconflict-side-2/packages/logger/src/hooks.ts b/.jjconflict-side-2/packages/logger/src/hooks.ts new file mode 100644 index 0000000000..2ee8ad0f1a --- /dev/null +++ b/.jjconflict-side-2/packages/logger/src/hooks.ts @@ -0,0 +1,22 @@ +import type { Logger } from './index.js' +import { logger as baseLogger } from './index.js' + +export const logger: Logger = baseLogger.child( + { scope: 'hooks' }, + { + redact: { + paths: [ + 'err.config.auth.username', + 'err.config.auth.password', + 'err.config.headers', + 'err.config.data', + 'err.config.params.client_secret', + 'err.config.params.clientSecret', + 'err.request.headers', + 'err.response.data', + 'err.response.headers', + ], + remove: true, + }, + }, +) diff --git a/.jjconflict-side-2/packages/logger/src/index.ts b/.jjconflict-side-2/packages/logger/src/index.ts new file mode 100644 index 0000000000..cf2a6e2397 --- /dev/null +++ b/.jjconflict-side-2/packages/logger/src/index.ts @@ -0,0 +1,87 @@ +import type { LoggerOptions } from 'pino' +import { pino, stdSerializers } from 'pino' +import z from 'zod' + +const envSchema = z.object({ + LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error', 'audit']).default('debug'), + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), +}) + +export type Env = z.infer['NODE_ENV'] +export type LogLevel = z.infer['LOG_LEVEL'] +const customLevels = { + audit: 25, +} + +const redact: LoggerOptions['redact'] = { + paths: [ + 'password', + '*.password', + 'passwd', + '*.passwd', + 'secret', + '*.secret', + 'token', + '*.token', + 'access_token', + '*.access_token', + 'refresh_token', + '*.refresh_token', + 'client_secret', + '*.client_secret', + 'authorization', + '*.authorization', + 'headers.authorization', + 'headers.cookie', + 'req.headers.authorization', + 'req.headers.cookie', + 'request.headers.authorization', + 'request.headers.cookie', + ], + remove: true, +} + +export function getLoggerOptions(env: Env, level: LogLevel): LoggerOptions { + switch (env) { + case 'development': + return { + transport: { + target: 'pino-pretty', + options: { + translateTime: 'dd/mm/yyyy - HH:MM:ss Z', + ignore: 'pid,hostname', + colorize: true, + singleLine: true, + }, + }, + customLevels, + level, + redact, + serializers: { + err: stdSerializers.errWithCause, + }, + } + case 'production': + return { + customLevels, + level, + redact, + serializers: { + err: stdSerializers.errWithCause, + }, + } + default: + return { + level: 'silent', + } + } +} + +export function getLoggerOptionsFromEnv(): LoggerOptions { + const env = envSchema.parse(process.env) + return getLoggerOptions(env.NODE_ENV, env.LOG_LEVEL) +} + +export const logger = pino(getLoggerOptionsFromEnv()) + +export type Logger = typeof logger diff --git a/.jjconflict-side-2/packages/logger/tsconfig.eslint.json b/.jjconflict-side-2/packages/logger/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-2/packages/logger/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-2/packages/logger/tsconfig.json b/.jjconflict-side-2/packages/logger/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-2/packages/logger/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-2/packages/opencds/Dockerfile b/.jjconflict-side-2/packages/opencds/Dockerfile new file mode 100644 index 0000000000..734496c4fb --- /dev/null +++ b/.jjconflict-side-2/packages/opencds/Dockerfile @@ -0,0 +1,21 @@ +FROM node:24-alpine AS prod + +RUN npm install -g @mockoon/cli@9.3.0 + +# Install curl for healthcheck and tzdata for timezone support. +RUN apk --no-cache add curl tzdata + +# Do not run as root. +RUN adduser --shell /bin/sh --disabled-password --gecos "" mockoon + +COPY packages/opencds/opencds_api.json ./opencds_api.json +RUN chown -R mockoon ./opencds_api.json + +USER mockoon + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3100/api/v1/health || exit 1 + +EXPOSE 3100 + +ENTRYPOINT ["mockoon-cli","start","--disable-log-to-file","--data","./opencds_api.json","--port","3100"] diff --git a/.jjconflict-side-2/packages/opencds/README.adoc b/.jjconflict-side-2/packages/opencds/README.adoc new file mode 100644 index 0000000000..bc9a736541 --- /dev/null +++ b/.jjconflict-side-2/packages/opencds/README.adoc @@ -0,0 +1,58 @@ += À propos + +Ce document va servir de fil conducteur pour toutes les idées autour de l'implémentation de la Console OpenCDS. Il permettra à l'équipe Socle d'échanger (au travers, par exemple, des commentaires de MR) sur les différentes problématiques entourant les travaux. C'est un document vivant, dynamique, qui sera remplacé par un "vrai" document d'architecture par la suite. + +== Cadrage effectué + +- SSO à prévoir avec Keycloak (déjà intégré dans la Console, d'où l'intérêt de l'utiliser pour intégrer le front OpenCDS) +- Rôles à prédéfinir dans *Keycloak* : + - `admin` (a accès à toute l'application) + - `cds` (n'a accès qu'à la partie qui concerne ses projets) + +Flux GUI définis : +- Pour tous les utilisateurs : + - Login/Logout à travers le SSO Keycloak comme la Console +- Utilisateurs `admin`: + - Liste de toutes les CDS + - Relancer une CDS en particulier + - Afficher les "flows" (étapes) d'un CDS +- Utilisateurs `cds`: + - Pouvoir lister les CDS de leur équipe + - Afficher les "flows" (étapes) d'une CDS qu'ils ont le droit de voir + - Avoir un écran de confirmation (on parlera plutôt de "validation" étant donné que la route côté OpenCDS s'appelle `/validate`) de création d'une CDS. Actuellement un mail leur est envoyé qui contient un lien pour validation. Ce lien valide la création de la CDS. Il faudra donc crééer un point d'entrée dans l'application pour plutôt ouvrir un écran de confirmation de la création. + +Lotissement des évolutions: +- [x] Création en tant que fonctionnalité "feature togglée" de Console (on verra plus tard si on en fait quelque chose à part) +- [x] Admin - Liste des CDS +- [x] Admin - Liste des Flow +- [x] Admin - Relancer un CDS en statut "échoué" (`/retry`) +- [ ] CDS - Liste des CDS/Flow de leur périmètre +- [ ] CDS - Écran de confirmation de la création d'un CDS (`/validate`) + += Architecture + +L'architecture retenue est celle consistant à utiliser le serveur ("Back for Front") de la console comme d'un Proxy pour OpenCDS. Ceci permet de réutiliser toute la mécanique d'authentification en SSO par Keycloak, la sérialisation/désérialisation entre `client` et `server-nestjs` (le Frontend et son BFF), ainsi que la génération des types TypeScript (à travers l'utilisation de schémas et de contrats `zod`). De cette manière, l'implémentation des ressources OpenCDS (`service-chains` et `flows`) peut se faire de la même manière que les autres ressources existantes (`cluster`, `projet`, etc.), avec le même niveau de qualité (tests unitaires avec vitest, et E2E avec Playwright). + += Contraintes + +- Console étant un projet OpenSource, et OpenCDS étant un sujet exclusivement "MI", le couplage entre les deux doit être le plus faible possible. Idéalement OpenCDS devrait être une "extension" de la Console de manière à ne jamais être directement référencé dans l'application. +=> Dans un premier temps on va fonctionner avec un "Feature Toggling" rudimentaire (à l'aide de variables d'environnement), pour voir où on va, et ensuite on verra comment éventuellement établir une séparation claire entre la console et sa partie OpenCDS, par exemple dans un projet Git totalement à part. + += Jeu de données + +Afin de pouvoir avancer sans forcément dépendre du backend OpenCDS, un https://mockoon.com/[Mockoon] sera utilisé (./opencds_api.json) pour simuler cette partie. Lors de la convergence entre les développements de la console et ceux côté OpenCDS, une version d'"intégration" de celle-ci sera déployée sur nos environnements PAX (`cpin-hp`, `formation`, et `pax-prod`) afin d'effectuer une recette dédiée. + += Mise en place + +Utilisation de varables d'environnements qui vont nous servir de moyen d'activer la fonctionnalité: + - Côté `apps/client`: + - `OPENCDS_ENABLED`: active ou non la fonctionnalité côté Front (cette activation est indépendante de `apps/server-nestjs`, donc il faut que cette variable d'environnement soit activée pour que la console affiche les écrans concernés par OpenCDS) + - Côté `apps/server-nestjs`: + - `OPENCDS_URL`: Chemin complet (URL + path) du service OpenCDS distant + - `OPENCDS_API_TOKEN`: Définit le token d'authentification au service OpenCDS qui sera utilisé pour chaque requête effectuée + - `OPENCDS_API_TLS_REJECT_UNAUTHORIZED`: Indique si on accepte les certificats non-validés (par exemple les certificats auto-signés, ou signés par une Autorité de Certification inconnue). Cette fonctionnalié permet d'intéragir facilement avec des services OpenCDS de "test" + += Notes + +- Sachant qu'il n'est pas trivial de lancer des services docker-compose de manière conditionnelle, le service `opencds-mockoon` est systématiquement lancé dans les docker-compose concernés (développement local, tests en CI, etc.) +- Les documentations de link:../../apps/client/README.md[la console] et link:../../apps/server-nestjs/README.md[son backend] ont été amendées pour référencer cette documentation, de manière à centraliser ici tous les aspects concernant OpenCDS. diff --git a/.jjconflict-side-2/packages/opencds/opencds_api.json b/.jjconflict-side-2/packages/opencds/opencds_api.json new file mode 100644 index 0000000000..6c92672f03 --- /dev/null +++ b/.jjconflict-side-2/packages/opencds/opencds_api.json @@ -0,0 +1,546 @@ +{ + "uuid": "b263d1c1-3103-4a17-940c-1c92215bb3b8", + "lastMigration": 33, + "name": "Opencds api", + "endpointPrefix": "api/v1/", + "latency": 0, + "port": 3003, + "hostname": "", + "folders": [], + "routes": [ + { + "uuid": "210c5fa6-22dc-4c79-a89e-4289fd2b84bb", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests", + "responses": [ + { + "uuid": "28bd3efb-d903-44c3-b728-66db3af526bc", + "body": "{{setVar 'networks' (array 'RIE' 'INTERNET')}}\n{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n[\n {{#repeat 50 100 comma=true}}\n {{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n {{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n {\n \"id\": \"{{faker 'string.uuid'}}\",\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"commonName\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\",\n \"pai\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"upper\\\" }'}}\",\n \"network\": \"{{oneOf (getVar 'networks')}}\",\n \"createdAt\": \"{{getVar 'createdAt'}}\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n }\n {{/repeat}}\n]", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "0d39381e-e815-40a9-9d47-cc739a8b582c", + "body": "", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "8f00d8d7-7df4-4401-b751-6440ee0e1658", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests/:request_id", + "responses": [ + { + "uuid": "410bd286-4377-47e5-ae3f-e4131fcb0b68", + "body": "{{setVar 'networks' (array 'RIE' 'INTERNET')}}\n{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n{{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'location' (array 'SIR' 'SIL')}}\n{{setVar 'environment' (array 'INT' 'PROD' 'BAS')}}\n{\n \"id\": \"{{urlParam 'request_id'}}\",\n \n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"commonName\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\",\n \"pai\": \"{{faker 'string.alpha' '{ length: 3, casing: \\\"upper\\\" }'}}\",\n \"network\": \"{{oneOf (getVar 'networks')}}\",\n \"createdAt\": \"{{getVar 'createdAt'}}\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\",\n\n \"validationId\": \"{{faker 'string.uuid'}}\",\n \"validatedBy\": \"{{faker 'string.uuid'}}\",\n \"ref\": \"{{faker 'string.uuid'}}\",\n \"location\": \"{{oneOf (getVar 'location')}}\",\n \"targetAddress\": \"{{faker 'internet.ipv4'}}\",\n \"projectId\": \"{{faker 'string.uuid'}}\",\n \"env\": \"{{oneOf (getVar 'environment')}}\",\n \"subjectAlternativeName\": [{{#repeat 0 50 comma=true}}\"{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.{{faker 'string.alpha' '{ length: 3, casing: \\\"lower\\\" }'}}.minint.fr\"{{/repeat}}],\n \"redirect\": {{boolean}},\n \"antivirus\": {{#if (eq (boolean) true)}}{\n \"maxFileSize\": {{int 1 100}}\n }{{else}}null{{/if}},\n \"websocket\": {{boolean}},\n \"ipWhiteList\": [{{#repeat 0 10 comma=true}}\"{{faker 'internet.ipv4'}}/{{int 8 32}}\"{{/repeat}}],\n \"sslOutgoing\": {{boolean}}\n}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "41f0a982-3b94-44d7-8177-e120d4741dee", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "904ddbb0-480b-442d-b251-d008d10fa832", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "8ff2f39f-a82f-4364-90ed-135b5ec8f19b", + "type": "http", + "documentation": "", + "method": "get", + "endpoint": "requests/:request_id/flows", + "responses": [ + { + "uuid": "b24f3f54-b45c-4749-a134-dc1fa003a417", + "body": "{{setVar 'state' (array 'opened' 'pending' 'success' 'failed')}}\n{{setVar 'createdAt' (date '2024-01-01' (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'updatedAt' (date (getVar 'createdAt') (now) \"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'\")}}\n{{setVar 'json' '' }}\n{\n \"reserve_ip\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"create_cert\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"call_exec\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"activate_ip\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n },\n \"dns_request\": {\n \"state\": \"{{oneOf (getVar 'state')}}\",\n \"input\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"output\": \"{ \\\"foo\\\": 0, \\\"bar\\\": true, \\\"qux\\\": \\\"test\\\" }\",\n \"updatedAt\": \"{{getVar 'updatedAt'}}\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "e392c78f-a345-4b96-bf0d-74c8094c4097", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "c926d20b-b30f-4293-b583-3eb169943967", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "9cc0eae4-eef1-44ec-92cd-47819501695f", + "type": "http", + "documentation": "", + "method": "post", + "endpoint": "validate/:validation_id", + "responses": [ + { + "uuid": "69bc6592-1208-40a9-a3c0-f8b445fc9f4c", + "body": "", + "latency": 0, + "statusCode": 204, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "179726cf-4c32-4d07-aabf-ead6d4646e17", + "body": "", + "latency": 0, + "statusCode": 429, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "fce2e6b6-0894-4f05-873d-77d8965c4c67", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "79f5ce5f-bf70-4995-bad4-6e82fc72f55f", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "bb61c12f-8185-43fe-9498-22b6661e998c", + "type": "http", + "documentation": "", + "method": "post", + "endpoint": "requests/:request_id/retry", + "responses": [ + { + "uuid": "b703c7f4-7325-448a-ab57-5b122896c3cc", + "body": "", + "latency": 0, + "statusCode": 204, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "token", + "invert": false, + "operator": "equals" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "e6abdf74-3191-4140-b6ba-3922c52e4c15", + "body": "", + "latency": 0, + "statusCode": 404, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "7b5d5713-0b5c-49d3-b3a2-af6cd366654d", + "body": "", + "latency": 0, + "statusCode": 409, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + }, + { + "uuid": "5d168446-71d6-456b-9de8-6b6100bd2f8a", + "body": "{}", + "latency": 0, + "statusCode": 400, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [ + { + "target": "header", + "modifier": "X-API-Key", + "value": "", + "invert": false, + "operator": "null" + } + ], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": false, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + }, + { + "uuid": "1055fe07-2f29-4dec-8412-2066fb330b3c", + "type": "http", + "documentation": "OpenCDS Mockoon healthcheck", + "method": "get", + "endpoint": "health", + "responses": [ + { + "uuid": "0d7f2acf-8006-4995-a5e5-39d8ba1858ef", + "body": "{\"status\":\"OK\"}", + "latency": 0, + "statusCode": 200, + "label": "", + "headers": [], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false, + "fallbackTo404": false, + "default": true, + "crudKey": "id", + "callbacks": [] + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0 + } + ], + "rootChildren": [ + { + "type": "route", + "uuid": "210c5fa6-22dc-4c79-a89e-4289fd2b84bb" + }, + { + "type": "route", + "uuid": "8f00d8d7-7df4-4401-b751-6440ee0e1658" + }, + { + "type": "route", + "uuid": "8ff2f39f-a82f-4364-90ed-135b5ec8f19b" + }, + { + "type": "route", + "uuid": "9cc0eae4-eef1-44ec-92cd-47819501695f" + }, + { + "type": "route", + "uuid": "bb61c12f-8185-43fe-9498-22b6661e998c" + }, + { + "type": "route", + "uuid": "1055fe07-2f29-4dec-8412-2066fb330b3c" + } + ], + "proxyMode": false, + "proxyHost": "", + "proxyRemovePrefix": false, + "tlsOptions": { + "enabled": false, + "type": "CERT", + "pfxPath": "", + "certPath": "", + "keyPath": "", + "caPath": "", + "passphrase": "" + }, + "cors": true, + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "proxyReqHeaders": [ + { + "key": "", + "value": "" + } + ], + "proxyResHeaders": [ + { + "key": "", + "value": "" + } + ], + "data": [], + "callbacks": [] +} \ No newline at end of file diff --git a/.jjconflict-side-2/packages/shared/.gitignore b/.jjconflict-side-2/packages/shared/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-2/packages/shared/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-2/packages/shared/eslint.config.js b/.jjconflict-side-2/packages/shared/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-2/packages/shared/package.json b/.jjconflict-side-2/packages/shared/package.json new file mode 100644 index 0000000000..5d3700491b --- /dev/null +++ b/.jjconflict-side-2/packages/shared/package.json @@ -0,0 +1,58 @@ +{ + "name": "@cpn-console/shared", + "type": "module", + "version": "1.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "test:e2e-ci": "echo 'check cache'", + "test:watch": "vitest" + }, + "dependencies": { + "@cpn-console/logger": "workspace:*", + "@ts-rest/core": "catalog:runtime", + "short-uuid": "catalog:runtime", + "zod": "catalog:runtime", + "zod-validation-error": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vite-node": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/packages/shared/src/api-client.ts b/.jjconflict-side-2/packages/shared/src/api-client.ts new file mode 100644 index 0000000000..c842cb9e99 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/api-client.ts @@ -0,0 +1,50 @@ +import type { ApiFetcher } from '@ts-rest/core' +import { initClient, initContract } from '@ts-rest/core' + +export const apiPrefix: string = '/api/v1' +export const apiPrefixV2: string = '/api/v2' + +export const contractInstance: ReturnType = initContract() + +export async function getContract() { + return contractInstance.router({ + AdminTokens: (await import('./contracts/index.js')).adminTokenContract, + AdminRoles: (await import('./contracts/index.js')).adminRoleContract, + Clusters: (await import('./contracts/index.js')).clusterContract, + ServiceChains: (await import('./contracts/index.js')).serviceChainContract, + Deployments: (await import('./contracts/index.js')).deploymentContract, + Environments: (await import('./contracts/index.js')).environmentContract, + EnvironmentsV2: (await import('./contracts/index.js')).environmentContractV2, + Logs: (await import('./contracts/index.js')).logContract, + PersonalAccessTokens: (await import('./contracts/index.js')) + .personalAccessTokenContract, + Projects: (await import('./contracts/index.js')).projectContract, + ProjectsMembers: (await import('./contracts/index.js')) + .projectMemberContract, + ProjectsRoles: (await import('./contracts/index.js')).projectRoleContract, + ProjectServices: (await import('./contracts/index.js')) + .projectServiceContract, + Repositories: (await import('./contracts/index.js')).repositoryContract, + Stages: (await import('./contracts/index.js')).stageContract, + Services: (await import('./contracts/index.js')).serviceContract, + Users: (await import('./contracts/index.js')).userContract, + Zones: (await import('./contracts/index.js')).zoneContract, + System: (await import('./contracts/index.js')).systemContract, + SystemPlugin: (await import('./contracts/index.js')).systemPluginContract, + SystemSettings: (await import('./contracts/index.js')) + .systemSettingsContract, + }) +} + +export async function getApiClient( + baseUrl: string, + baseHeaders: Record, + api: ApiFetcher | undefined, +): Promise { + return initClient(await getContract(), { + baseUrl, + baseHeaders, + api, + validateResponse: false, + }) +} diff --git a/.jjconflict-side-2/packages/shared/src/contracts/_utils.ts b/.jjconflict-side-2/packages/shared/src/contracts/_utils.ts new file mode 100644 index 0000000000..ea2f4ee5ff --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/_utils.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' +import { tokenHeaderName } from '../utils/const.js' + +export const EmptySchema = z.object({}) + +export const ErrorSchema = z.lazy(() => z.object({ + message: z.string() + .optional(), + error: z.unknown().optional(), + stack: z.unknown().optional(), +})) + +export const baseHeaders = z.object({ + [tokenHeaderName]: z.string().min(1).optional(), +}) diff --git a/.jjconflict-side-2/packages/shared/src/contracts/admin-role.ts b/.jjconflict-side-2/packages/shared/src/contracts/admin-role.ts new file mode 100644 index 0000000000..dc6696bee3 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/admin-role.ts @@ -0,0 +1,76 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { AdminRoleSchema } from '../schemas/role.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminRoleContract = contractInstance.router({ + listAdminRoles: { + method: 'GET', + path: '', + responses: { + 200: AdminRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createAdminRole: { + method: 'POST', + path: '', + body: AdminRoleSchema.pick({ name: true }), + responses: { + 200: AdminRoleSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + patchAdminRoles: { + method: 'PATCH', + path: '', + body: AdminRoleSchema.partial({ name: true, permissions: true, position: true, oidcGroup: true, type: true }).array(), + responses: { + 200: AdminRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + adminRoleMemberCounts: { + method: 'GET', + path: `/member-counts`, + responses: { + 200: z.record(z.number().min(0)), // Record + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deleteAdminRole: { + method: 'DELETE', + path: `/:roleId`, + pathParams: z.object({ + roleId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/admin/roles`, +}) diff --git a/.jjconflict-side-2/packages/shared/src/contracts/admin-token.ts b/.jjconflict-side-2/packages/shared/src/contracts/admin-token.ts new file mode 100644 index 0000000000..8df06a7db4 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/admin-token.ts @@ -0,0 +1,54 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { AdminTokenSchema, ExposedAdminTokenSchema } from '../schemas/token.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminTokenContract = contractInstance.router({ + listAdminTokens: { + method: 'GET', + path: '', + query: z.object({ + withRevoked: CoerceBooleanSchema + .optional(), + }), + responses: { + 200: AdminTokenSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createAdminToken: { + method: 'POST', + path: '', + body: AdminTokenSchema.pick({ name: true, permissions: true, expirationDate: true }).required(), + responses: { + 201: ExposedAdminTokenSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deleteAdminToken: { + method: 'DELETE', + path: `/:tokenId`, + pathParams: z.object({ tokenId: z.string().uuid() }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/admin/tokens`, +}) diff --git a/.jjconflict-side-2/packages/shared/src/contracts/cluster.ts b/.jjconflict-side-2/packages/shared/src/contracts/cluster.ts new file mode 100644 index 0000000000..96ada83d77 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/cluster.ts @@ -0,0 +1,138 @@ +import type { ClientInferResponseBody } from '@ts-rest/core' +import type Zod from 'zod' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { + CleanedClusterSchema, + ClusterDetailsSchema, + ClusterUsageSchema, +} from '../schemas/cluster.js' +import { EnvironmentSchema } from '../schemas/environment.js' +import { UserSchema } from '../schemas/user.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const ClusterParams = z.object({ + clusterId: CleanedClusterSchema.shape.id, +}) + +export const clusterContract = contractInstance.router({ + listClusters: { + method: 'GET', + path: '', + summary: 'Get clusters', + description: 'Retrieve clusters authorized for user', + responses: { + 200: z.array(CleanedClusterSchema), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + createCluster: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create cluster', + description: 'Create new cluster.', + body: ClusterDetailsSchema.omit({ id: true }), + responses: { + 201: ClusterDetailsSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterDetails: { + method: 'GET', + path: `/:clusterId`, + summary: 'Get cluster details', + description: 'Retrieved details of a cluster.', + pathParams: ClusterParams, + responses: { + 200: ClusterDetailsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterUsage: { + method: 'GET', + path: `/usage/:clusterId`, + summary: 'Get cluster usage', + description: 'Retrieved resources usage of a cluster.', + pathParams: ClusterParams, + responses: { + 200: ClusterUsageSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getClusterEnvironments: { + method: 'GET', + path: `/:clusterId/environments`, + summary: 'Get cluster envs', + description: 'Retrieved environments linked to a cluster.', + pathParams: ClusterParams, + responses: { + 200: z.array(z.object({ + // TODO: Remettre `ProjectSchemaV2.shape.name` mais attention aux projets non compatibles + project: z.string(), + name: EnvironmentSchema.shape.name, + owner: UserSchema.shape.email.optional(), + cpu: EnvironmentSchema.shape.cpu, + gpu: EnvironmentSchema.shape.gpu, + memory: EnvironmentSchema.shape.memory, + })), + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateCluster: { + method: 'PUT', + path: `/:clusterId`, + summary: 'Update cluster', + description: 'Update a cluster by its ID.', + pathParams: ClusterParams, + body: ClusterDetailsSchema.omit({ id: true }).partial(), + responses: { + 200: ClusterDetailsSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteCluster: { + method: 'DELETE', + path: `/:clusterId`, + summary: 'Delete cluster', + description: 'Delete a cluster by its ID.', + query: z.object({ force: CoerceBooleanSchema.optional() }), + pathParams: ClusterParams, + body: ContractNoBody, + responses: { + 204: z.string() + .nullable(), + 400: ErrorSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/clusters`, +}) + +export type ClusterAssociatedEnvironments = ClientInferResponseBody +export type CreateClusterBody = Zod.infer +export type UpdateClusterBody = Zod.infer diff --git a/.jjconflict-side-2/packages/shared/src/contracts/deployment.ts b/.jjconflict-side-2/packages/shared/src/contracts/deployment.ts new file mode 100644 index 0000000000..968e65e940 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/deployment.ts @@ -0,0 +1,91 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + CreateDeploymentSchema, + DeploymentSchema, + UpdateDeploymentSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const deploymentContract = contractInstance.router({ + createDeployment: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create deployment', + description: 'Create new deployment.', + body: CreateDeploymentSchema, + responses: { + 201: DeploymentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listDeployments: { + method: 'GET', + path: '', + summary: 'Get deployments', + description: 'Retrieved project deployments.', + responses: { + 200: DeploymentSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateDeployment: { + method: 'PUT', + path: `/:deploymentId`, + summary: 'Update deployment', + description: 'Update a deployment by its ID.', + pathParams: z.object({ + deploymentId: z.string() + .uuid(), + }), + body: UpdateDeploymentSchema, + responses: { + 200: DeploymentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteDeployment: { + method: 'DELETE', + path: `/:deploymentId`, + summary: 'Delete deployment', + description: 'Delete a deployment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + deploymentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/deployments`, + pathParams: z.object({ + projectId: z.string().uuid(), + }), +}) + +export type CreateDeploymentBody = ClientInferRequest['body'] +export type UpdateDeploymentBody = ClientInferRequest['body'] diff --git a/.jjconflict-side-2/packages/shared/src/contracts/environment.ts b/.jjconflict-side-2/packages/shared/src/contracts/environment.ts new file mode 100644 index 0000000000..8d9320733e --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/environment.ts @@ -0,0 +1,175 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, apiPrefixV2, contractInstance } from '../api-client.js' +import { + CreateEnvironmentSchema, + EnvironmentSchema, + UpdateEnvironmentSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const environmentContract = contractInstance.router({ + createEnvironment: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create environment', + description: 'Create new environment.', + body: EnvironmentSchema.omit({ id: true, createdAt: true, updatedAt: true }), + responses: { + 201: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listEnvironments: { + method: 'GET', + path: '', + summary: 'Get environments', + description: 'Retrieved project environments.', + query: z.object({ + projectId: z.string() + .uuid(), + }), + responses: { + 200: EnvironmentSchema.omit({ name: true }) + .extend({ name: z.string() }).array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateEnvironment: { + method: 'PUT', + path: `/:environmentId`, + summary: 'Update environment', + description: 'Update a environment by its ID.', + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + body: EnvironmentSchema.pick({ cpu: true, gpu: true, memory: true, autosync: true }), + responses: { + 200: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteEnvironment: { + method: 'DELETE', + path: `/:environmentId`, + summary: 'Delete environment', + description: 'Delete a environment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/environments`, +}) + +export type CreateEnvironmentBody = ClientInferRequest['body'] +export type UpdateEnvironmentBody = ClientInferRequest['body'] + +// NB: les clés de routes servent d'operationId OpenAPI (setOperationId: true) et +// doivent être uniques sur l'ensemble du contrat, d'où le suffixe V2. +export const environmentContractV2 = contractInstance.router({ + createEnvironmentV2: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create environment', + description: 'Create new environment.', + body: CreateEnvironmentSchema, + responses: { + 201: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listEnvironmentsV2: { + method: 'GET', + path: '', + summary: 'Get environments', + description: 'Retrieved project environments.', + responses: { + 200: EnvironmentSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateEnvironmentV2: { + method: 'PUT', + path: '/:environmentId', + summary: 'Update environment', + description: 'Update an environment by its ID.', + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + body: UpdateEnvironmentSchema, + responses: { + 200: EnvironmentSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteEnvironmentV2: { + method: 'DELETE', + path: '/:environmentId', + summary: 'Delete environment', + description: 'Delete an environment by its ID.', + body: ContractNoBody, + pathParams: z.object({ + environmentId: z.string() + .uuid(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefixV2}/projects/:projectId/environments`, + pathParams: z.object({ + projectId: z.string().uuid(), + }), +}) diff --git a/.jjconflict-side-2/packages/shared/src/contracts/index.ts b/.jjconflict-side-2/packages/shared/src/contracts/index.ts new file mode 100644 index 0000000000..c5c88fab16 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/index.ts @@ -0,0 +1,18 @@ +export * from './admin-role.js' +export * from './admin-token.js' +export * from './cluster.js' +export * from './deployment.js' +export * from './environment.js' +export * from './log.js' +export * from './personal-access-token.js' +export * from './project-member.js' +export * from './project-role.js' +export * from './project-service.js' +export * from './project.js' +export * from './repository.js' +export * from './service-chain.js' +export * from './service-monitor.js' +export * from './stage.js' +export * from './system.js' +export * from './user.js' +export * from './zone.js' diff --git a/.jjconflict-side-2/packages/shared/src/contracts/log.ts b/.jjconflict-side-2/packages/shared/src/contracts/log.ts new file mode 100644 index 0000000000..062f2b3adc --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/log.ts @@ -0,0 +1,43 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import type Zod from 'zod' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { CoerceBooleanSchema } from '../schemas/_utils.js' +import { CleanLogSchema, LogSchema } from '../schemas/log.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const adminLogsQuery = z.object({ + offset: z.coerce.number(), + limit: z.coerce.number(), + projectId: z.string().optional(), + clean: CoerceBooleanSchema.default(true), +}) +export type AdminLogsQuery = Zod.infer + +export const logContract = contractInstance.router({ + getLogs: { + method: 'GET', + path: '', + query: adminLogsQuery, + summary: 'Get logs', + description: 'Retrieved all logs.', + responses: { + 200: z.object({ + total: z.number(), + logs: LogSchema.array().or(CleanLogSchema.array()), + }), + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/logs`, +}) + +export type GetLogsQuery = ClientInferRequest['query'] + +export type Log = Zod.infer +export type CleanLog = Zod.infer diff --git a/.jjconflict-side-2/packages/shared/src/contracts/personal-access-token.ts b/.jjconflict-side-2/packages/shared/src/contracts/personal-access-token.ts new file mode 100644 index 0000000000..72e9012596 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/personal-access-token.ts @@ -0,0 +1,49 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ExposedPersonalAccessTokenSchema, PersonalAccessTokenSchema } from '../schemas/token.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const personalAccessTokenContract = contractInstance.router({ + listPersonalAccessTokens: { + method: 'GET', + path: '', + responses: { + 200: PersonalAccessTokenSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + createPersonalAccessToken: { + method: 'POST', + path: '', + body: PersonalAccessTokenSchema.pick({ name: true, expirationDate: true }).required(), + responses: { + 201: ExposedPersonalAccessTokenSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + + deletePersonalAccessToken: { + method: 'DELETE', + path: `/:tokenId`, + pathParams: z.object({ tokenId: z.string().uuid() }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/user/tokens`, +}) diff --git a/.jjconflict-side-2/packages/shared/src/contracts/project-member.ts b/.jjconflict-side-2/packages/shared/src/contracts/project-member.ts new file mode 100644 index 0000000000..ac9797495f --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/project-member.ts @@ -0,0 +1,68 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { MemberSchema } from '../schemas/user.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const projectMemberContract = contractInstance.router({ + listMembers: { + method: 'GET', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + addMember: { + method: 'POST', + path: '', + body: z.object({ email: z.string() }).or(z.object({ userId: z.string() })), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 201: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + patchMembers: { + method: 'PATCH', + path: '', + body: z.object({ + userId: z.string().uuid(), + roles: z.string().uuid().array(), + }).array(), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + removeMember: { + method: 'DELETE', + path: `/:userId`, + pathParams: z.object({ + projectId: z.string().uuid(), + userId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: MemberSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/members`, +}) diff --git a/.jjconflict-side-2/packages/shared/src/contracts/project-role.ts b/.jjconflict-side-2/packages/shared/src/contracts/project-role.ts new file mode 100644 index 0000000000..b909a994db --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/project-role.ts @@ -0,0 +1,80 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ProjectRoleSchema } from '../schemas/role.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const projectRoleContract = contractInstance.router({ + listProjectRoles: { + method: 'GET', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + createProjectRole: { + method: 'POST', + path: '', + body: ProjectRoleSchema.omit({ position: true, id: true, projectId: true }), + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + // 200: z.any(), + 201: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + patchProjectRoles: { + method: 'PATCH', + path: '', + pathParams: z.object({ projectId: z.string().uuid() }), + // body: z.any(), + body: ProjectRoleSchema.pick({ id: true }).merge(ProjectRoleSchema.omit({ id: true, projectId: true }).partial()).array(), + responses: { + // 200: z.any(), + 200: ProjectRoleSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + projectRoleMemberCounts: { + method: 'GET', + path: `/member-counts`, + pathParams: z.object({ projectId: z.string().uuid() }), + responses: { + 200: z.record(z.number().min(0)), // Record + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, + deleteProjectRole: { + method: 'DELETE', + path: `/:roleId`, + pathParams: z.object({ + projectId: z.string().uuid(), + roleId: z.string().uuid(), + }), + body: ContractNoBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/roles`, +}) diff --git a/.jjconflict-side-2/packages/shared/src/contracts/project-service.ts b/.jjconflict-side-2/packages/shared/src/contracts/project-service.ts new file mode 100644 index 0000000000..a0545d06b2 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/project-service.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { pluginUpdateBody } from '../schemas/config.js' +import { permissionTarget, ServiceSchema } from '../schemas/services.js' +import { baseHeaders, ErrorSchema } from './_utils.js' +import { ProjectParams } from './project.js' + +export const projectServiceContract = contractInstance.router({ + getServices: { + method: 'GET', + path: '', + summary: 'Get Project\'s services', + description: 'Get all informations about services related to a project.', + query: z.object({ permissionTarget }), + pathParams: ProjectParams, + responses: { + 200: ServiceSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateProjectServices: { + method: 'POST', + path: '', + summary: 'Update project service configuration', + description: 'Update project service configuration', + pathParams: ProjectParams, + body: pluginUpdateBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects/:projectId/services`, +}) diff --git a/.jjconflict-side-2/packages/shared/src/contracts/project.ts b/.jjconflict-side-2/packages/shared/src/contracts/project.ts new file mode 100644 index 0000000000..8532849b4e --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/project.ts @@ -0,0 +1,184 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ProjectSchemaV2 } from '../schemas/project.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const ProjectParams = z.object({ + projectId: z.string().regex(/[a-z0-9-]*/), // uuid or slug like +}) + +export const projectContract = contractInstance.router({ + createProject: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create project', + description: 'Create a new project.', + body: ProjectSchemaV2.pick({ + name: true, + description: true, + limitless: true, + hprodCpu: true, + hprodGpu: true, + hprodMemory: true, + prodCpu: true, + prodGpu: true, + prodMemory: true, + }), + responses: { + 201: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + bulkActionProject: { + method: 'POST', + path: '-bulk', + contentType: 'application/json', + summary: 'Perform bulk action on projects', + description: 'Perform bulk action on projects.', + body: z.object({ + action: z.enum(['archive', 'lock', 'unlock', 'replay']), + projectIds: z.string().uuid().array().or(z.literal('all')), + }), + responses: { + 202: null, + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getProject: { + method: 'GET', + path: '/:projectId', + pathParams: ProjectParams, + summary: 'Get a project', + description: 'Get a project', + responses: { + 200: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listProjects: { + method: 'GET', + path: '', + query: ProjectSchemaV2 + .pick({ + id: true, + name: true, + status: true, + locked: true, + description: true, + lastSuccessProvisionningVersion: true, + }) + .extend({ + statusIn: z.string(), + statusNotIn: z.string(), + filter: z.enum(['owned', 'member', 'all']), + search: z.string(), + }) + .partial(), + summary: 'Get projects', + description: 'Get projects with filters', + responses: { + 200: ProjectSchemaV2.omit({ + name: true, + }).extend({ name: z.string() }).array(), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getProjectSecrets: { + method: 'GET', + path: `/:projectId/secrets`, + summary: 'Get project secrets', + description: 'Retrieved a project secrets.', + pathParams: ProjectParams, + responses: { + 200: z.record(z.record(z.string())), + 401: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateProject: { + method: 'PUT', + path: `/:projectId`, + summary: 'Update project', + description: 'Update a project.', + pathParams: ProjectParams, + body: ProjectSchemaV2 + .pick({ + description: true, + everyonePerms: true, + locked: true, + ownerId: true, + limitless: true, + hprodMemory: true, + hprodCpu: true, + hprodGpu: true, + prodMemory: true, + prodCpu: true, + prodGpu: true, + }) + .partial(), + responses: { + 200: ProjectSchemaV2.omit({ name: true }).extend({ name: z.string() }), + 500: ErrorSchema, + }, + }, + + replayHooksForProject: { + method: 'PUT', + path: `/:projectId/hooks`, + summary: 'Replay hooks for project', + description: 'Replay hooks for a project.', + body: ContractNoBody, + pathParams: ProjectParams, + responses: { + 204: null, + 500: ErrorSchema, + }, + }, + + archiveProject: { + method: 'DELETE', + path: `/:projectId`, + summary: 'Delete project', + description: 'Delete a project.', + pathParams: ProjectParams, + body: ContractNoBody, + responses: { + 204: null, + 500: ErrorSchema, + }, + }, + + getProjectsData: { + method: 'GET', + path: `/data`, + summary: 'Download projects csv report', + description: 'Retrieve all projects data for download as CSV file.', + responses: { + 200: z.string(), + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/projects`, +}) + +export type CreateProjectBody = ClientInferRequest['body'] diff --git a/.jjconflict-side-2/packages/shared/src/contracts/repository.ts b/.jjconflict-side-2/packages/shared/src/contracts/repository.ts new file mode 100644 index 0000000000..131deee00e --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/repository.ts @@ -0,0 +1,107 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { RepoSchema } from '../schemas/repository.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const repositoryContract = contractInstance.router({ + createRepository: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create repo', + description: 'Create new repo.', + body: RepoSchema.omit({ id: true, createdAt: true, updatedAt: true }), + responses: { + 201: RepoSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + listRepositories: { + method: 'GET', + path: '', + summary: 'Get repos', + description: 'Retrieved all repos.', + query: z.object({ + projectId: z.string() + .uuid(), + }), + responses: { + 200: z.array(RepoSchema + .omit({ internalRepoName: true }) + .extend({ internalRepoName: z.string() }), + ), + 500: ErrorSchema, + }, + }, + + syncRepository: { + method: 'POST', + path: `/:repositoryId/sync`, + summary: 'application/json', + description: 'Trigger a gitlab synchronization for a repository', + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + body: z.object({ + syncAllBranches: z.boolean(), + branchName: z.string().optional(), + }), + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateRepository: { + method: 'PUT', + path: `/:repositoryId`, + summary: 'Update repo', + description: 'Update a repo by its ID.', + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + body: RepoSchema.omit({ createdAt: true, updatedAt: true }).partial(), + responses: { + 200: RepoSchema, + 500: ErrorSchema, + }, + }, + + deleteRepository: { + method: 'DELETE', + path: `/:repositoryId`, + summary: 'Delete repo', + description: 'Delete a repo by its ID.', + body: ContractNoBody, + pathParams: z.object({ + repositoryId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/repositories`, +}) + +export type CreateRepositoryBody = ClientInferRequest['body'] + +export type UpdateRepositoryBody = ClientInferRequest['body'] + +export type SyncRepositoryParams = ClientInferRequest['params'] + +export type RepositoryParams = ClientInferRequest['params'] diff --git a/.jjconflict-side-2/packages/shared/src/contracts/service-chain.ts b/.jjconflict-side-2/packages/shared/src/contracts/service-chain.ts new file mode 100644 index 0000000000..e98f864db8 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/service-chain.ts @@ -0,0 +1,99 @@ +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + ServiceChainDetailsSchema, + ServiceChainFlowsSchema, + ServiceChainSchema, +} from '../schemas/index.js' +import { baseHeaders, EmptySchema, ErrorSchema } from './_utils.js' + +export const ServiceChainParams = z.object({ + serviceChainId: ServiceChainSchema.shape.id, +}) + +export const ServiceChainValidationParams = z.object({ + validationId: ServiceChainDetailsSchema.shape.validationId, +}) + +export const serviceChainContract = contractInstance.router( + { + listServiceChains: { + method: 'GET', + path: '', + summary: 'Get Service Chains', + description: 'Retrieve Service Chains authorized for user', + responses: { + 200: z.array(ServiceChainSchema), + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getServiceChainDetails: { + method: 'GET', + path: `/:serviceChainId`, + summary: 'Get Service Chain details', + description: 'Retrieved details of a Service Chain.', + pathParams: ServiceChainParams, + responses: { + 200: ServiceChainDetailsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + retryServiceChain: { + method: 'POST', + path: `/:serviceChainId/retry`, + summary: 'Retry Service Chain creation', + description: + 'Retry the whole service chain creation from the point it failed.', + pathParams: ServiceChainParams, + body: ContractNoBody, + responses: { + 204: null, + 401: ErrorSchema, + 404: ErrorSchema, + 429: EmptySchema, // Already successful, no retry needed + 500: ErrorSchema, + }, + }, + + validateServiceChain: { + method: 'POST', + path: `/validate/:validationId`, + summary: 'Validate Service Chain creation', + description: 'Trigger the whole service chain creation.', + pathParams: ServiceChainValidationParams, + body: ContractNoBody, + responses: { + 204: null, + 401: ErrorSchema, + 404: ErrorSchema, + 429: EmptySchema, // Validation already done + 500: ErrorSchema, + }, + }, + + getServiceChainFlows: { + method: 'GET', + path: `/:serviceChainId/flows`, + summary: 'Get Service Chain flow details', + description: 'Retrieved flow details of a Service Chain.', + pathParams: ServiceChainParams, + responses: { + 200: ServiceChainFlowsSchema, + 401: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + }, + { + baseHeaders, + pathPrefix: `${apiPrefix}/service-chains`, + }, +) diff --git a/.jjconflict-side-2/packages/shared/src/contracts/service-monitor.ts b/.jjconflict-side-2/packages/shared/src/contracts/service-monitor.ts new file mode 100644 index 0000000000..c2b02e304b --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/service-monitor.ts @@ -0,0 +1,51 @@ +import type { ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { ServiceHealthSchema } from '../schemas/services.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const serviceContract = contractInstance.router({ + getServiceHealth: { + method: 'GET', + path: '/health-services', + summary: 'Get services health', + description: 'Retrieve services health.', + responses: { + 200: ServiceHealthSchema.array(), + 500: ErrorSchema, + }, + }, + + getCompleteServiceHealth: { + method: 'GET', + path: '/complete-services', + summary: 'Get services health with cause', + description: 'Retrieve services health with cause.', + responses: { + 200: ServiceHealthSchema.extend({ + cause: z.any().optional(), + }).array(), + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + refreshServiceHealth: { + method: 'GET', + path: '/refresh-services', + summary: 'Force services health', + description: 'Retrieved services health.', + responses: { + 200: ServiceHealthSchema.array(), + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}`, +}) + +export type ServiceBody = ClientInferResponseBody diff --git a/.jjconflict-side-2/packages/shared/src/contracts/stage.ts b/.jjconflict-side-2/packages/shared/src/contracts/stage.ts new file mode 100644 index 0000000000..a8f20f62cf --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/stage.ts @@ -0,0 +1,95 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { StageSchema } from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const stageContract = contractInstance.router({ + listStages: { + method: 'GET', + path: '', + summary: 'Get stages', + description: 'Retrieved all stages.', + responses: { + 200: StageSchema.array(), + 500: ErrorSchema, + }, + }, + + createStage: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create stage', + description: 'Create new stage.', + body: StageSchema.omit({ id: true }).partial({ clusterIds: true }), + responses: { + 201: StageSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + getStageEnvironments: { + method: 'GET', + path: `/:stageId/environments`, + summary: 'Get stages', + description: 'Retrieved all stages.', + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + responses: { + 200: z.array(z.object({ + project: z.string(), + name: z.string(), + cluster: z.string(), + owner: z.string().optional(), + })), + 500: ErrorSchema, + }, + }, + + updateStage: { + method: 'PUT', + path: `/:stageId`, + summary: 'Update stage', + description: 'Update a stage by its ID.', + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + body: StageSchema.pick({ clusterIds: true, name: true }), + responses: { + 200: StageSchema, + 500: ErrorSchema, + }, + }, + + deleteStage: { + method: 'DELETE', + path: `/:stageId`, + summary: 'Delete stage', + description: 'Delete a stage by its ID.', + body: ContractNoBody, + pathParams: z.object({ + stageId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/stages`, +}) + +export type CreateStageBody = ClientInferRequest['body'] + +export type UpdateStageBody = ClientInferRequest['body'] + +export type StageAssociatedEnvironments = ClientInferResponseBody diff --git a/.jjconflict-side-2/packages/shared/src/contracts/system.ts b/.jjconflict-side-2/packages/shared/src/contracts/system.ts new file mode 100644 index 0000000000..b5c77864fe --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/system.ts @@ -0,0 +1,111 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + pluginSchema, + pluginUpdateBody, + SystemSettingSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const systemContract = contractInstance.router({ + getVersion: { + method: 'GET', + path: `/version`, + summary: 'Get version', + description: 'Retrieve api version.', + responses: { + 200: z.object({ + version: z.string(), + }), + 500: ErrorSchema, + }, + }, + + getHealth: { + method: 'GET', + path: `/healthz`, + summary: 'Get health', + description: 'Retrieve api health infos.', + responses: { + 200: z.object({ + status: z.enum(['OK', 'KO']), + }), + 500: ErrorSchema, + }, + }, +}, { + pathPrefix: `${apiPrefix}`, +}) + +export const systemPluginContract = contractInstance.router({ + getPluginsConfig: { + method: 'GET', + path: '', + summary: 'Get plugins configuration', + description: 'Get plugins configuration', + responses: { + 200: pluginSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updatePluginsConfig: { + method: 'POST', + path: '', + summary: 'Update project service configuration', + description: 'Update project service configuration', + body: pluginUpdateBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/system/plugins`, +}) + +export const systemSettingsContract = contractInstance.router({ + listSystemSettings: { + method: 'GET', + path: '', + summary: 'Get System Settings state', + description: 'Get System Settings state', + query: SystemSettingSchema.pick({ key: true }) + .partial(), + responses: { + 200: SystemSettingSchema.array(), + 500: ErrorSchema, + }, + }, + + upsertSystemSetting: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Update System Settings state', + description: 'Update System Settings state', + body: SystemSettingSchema, + responses: { + 201: SystemSettingSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/system/settings`, +}) + +export type SystemSettings = ClientInferResponseBody + +export type SystemSetting = SystemSettings[number] + +export type UpsertSystemSettingBody = ClientInferRequest['body'] diff --git a/.jjconflict-side-2/packages/shared/src/contracts/user.ts b/.jjconflict-side-2/packages/shared/src/contracts/user.ts new file mode 100644 index 0000000000..508ef91171 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/user.ts @@ -0,0 +1,83 @@ +import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { UuidOrCsvUuidSchema } from '../schemas/_utils.js' +import { + RoleNameCsvSchema, + UserSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const userContract = contractInstance.router({ + getMatchingUsers: { + method: 'GET', + path: `${apiPrefix}/users/matching`, + query: z.object({ + letters: z.string(), + notInProjectId: z.string().uuid().optional(), + }), + summary: 'Get users by letters matching', + description: 'Retrieved users by letters matching.', + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + auth: { + method: 'GET', + path: `${apiPrefix}/auth`, + summary: 'Login', + description: 'OIDC callback to signin or signup', + responses: { + 200: UserSchema, + 307: null, + 500: ErrorSchema, + }, + }, + + getAllUsers: { + method: 'GET', + path: `${apiPrefix}/users`, + summary: 'Get all users', + description: 'Get all users.', + query: z.object({ + adminRoles: RoleNameCsvSchema + .optional(), + adminRoleIds: UuidOrCsvUuidSchema + .optional(), + memberOfIds: UuidOrCsvUuidSchema + .optional(), + relationType: z.enum(['OR', 'AND']) + .optional(), + }), + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + + patchUsers: { + method: 'PATCH', + path: `${apiPrefix}/users`, + summary: 'Patch users', + body: UserSchema.pick({ adminRoleIds: true, id: true }).array(), + description: 'Update user admin role.', + responses: { + 200: UserSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, +}) + +export type LettersQuery = ClientInferRequest['query'] + +export type AllUsers = ClientInferResponseBody diff --git a/.jjconflict-side-2/packages/shared/src/contracts/zone.ts b/.jjconflict-side-2/packages/shared/src/contracts/zone.ts new file mode 100644 index 0000000000..90a300ba5f --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/contracts/zone.ts @@ -0,0 +1,83 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { ContractNoBody } from '@ts-rest/core' +import { z } from 'zod' +import { apiPrefix, contractInstance } from '../api-client.js' +import { + ZoneSchema, +} from '../schemas/index.js' +import { baseHeaders, ErrorSchema } from './_utils.js' + +export const zoneContract = contractInstance.router({ + listZones: { + method: 'GET', + path: '', + summary: 'Get zones', + description: 'Get all zones.', + responses: { + 200: ZoneSchema.array(), + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + createZone: { + method: 'POST', + path: '', + contentType: 'application/json', + summary: 'Create zone', + description: 'Create new zone.', + body: ZoneSchema + .omit({ id: true }) + .extend({ clusterIds: z.string().uuid().array().optional() }), + responses: { + 201: ZoneSchema, + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updateZone: { + method: 'PUT', + path: `/:zoneId`, + summary: 'Update zone', + description: 'Update a zone by its ID.', + pathParams: z.object({ + zoneId: z.string() + .uuid(), + }), + body: ZoneSchema.omit({ id: true, slug: true }), + responses: { + 201: ZoneSchema, + 400: ErrorSchema, + 403: ErrorSchema, + 404: ErrorSchema, + 500: ErrorSchema, + }, + }, + + deleteZone: { + method: 'DELETE', + path: `/:zoneId`, + summary: 'Delete zone', + description: 'Delete a zone by its ID.', + body: ContractNoBody, + pathParams: z.object({ + zoneId: z.string() + .uuid(), + }), + responses: { + 204: null, + 500: ErrorSchema, + }, + }, +}, { + baseHeaders, + pathPrefix: `${apiPrefix}/zones`, +}) + +export type CreateZoneBody = ClientInferRequest['body'] +export type UpdateZoneBody = ClientInferRequest['body'] diff --git a/.jjconflict-side-2/packages/shared/src/index.ts b/.jjconflict-side-2/packages/shared/src/index.ts new file mode 100644 index 0000000000..8cc07ea318 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/index.ts @@ -0,0 +1,4 @@ +export * from './api-client.js' +export * from './contracts/index.js' +export * from './schemas/index.js' +export * from './utils/index.js' diff --git a/.jjconflict-side-2/packages/shared/src/schemas/_utils.ts b/.jjconflict-side-2/packages/shared/src/schemas/_utils.ts new file mode 100644 index 0000000000..91cea1b16a --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/_utils.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +export const CoerceBooleanSchema = z.boolean() + .or(z.enum(['true', 'false']) + .transform(value => value === 'true')) + +export const dateToString = z.string().or(z.date().transform(date => date.toISOString())) + +export const AtDatesToStringExtend = { + updatedAt: dateToString, + createdAt: dateToString, +} +export const permissionLevelSchema = z.coerce.string() + +export const UuidOrCsvUuidSchema = z.string() + .refine((value) => { + return !value + .split(',') + .some(uuid => !z.string().uuid().safeParse(uuid).success) + }) + .transform(value => value.split(',')) diff --git a/.jjconflict-side-2/packages/shared/src/schemas/cluster.ts b/.jjconflict-side-2/packages/shared/src/schemas/cluster.ts new file mode 100644 index 0000000000..bac22d02c0 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/cluster.ts @@ -0,0 +1,71 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const ClusterPrivacySchema = z.enum(['public', 'dedicated']) + +export const CleanedClusterSchema = z.object({ + id: z.string() + .uuid(), + label: z.string() + .regex(/^[a-z0-9-]+$/i) + .max(50), + infos: z.string() + .max(1000) + .optional() + .nullable() + .transform(value => value ?? ''), + clusterResources: z.boolean(), + privacy: ClusterPrivacySchema, + zoneId: z.string() + .uuid(), + stageIds: z.string() + .uuid() + .array(), + cpu: z.coerce.number().gte(0), + gpu: z.coerce.number().gte(0), + memory: z.coerce.number().gte(0), +}) + +export const KubeconfigSchema = z.object({ + user: z.object({ + username: z.string() + .optional(), + password: z.string() + .optional(), + keyData: z.string() + .optional(), + certData: z.string() + .optional(), + token: z.string() + .optional(), + }), + cluster: z.object({ + server: z.string() + .optional(), + tlsServerName: z.string(), + skipTLSVerify: z.boolean() + .optional(), + caData: z.string() + .optional(), + }), +}) + +export const ClusterDetailsSchema = CleanedClusterSchema.merge(z.object({ + projectIds: z.string() + .uuid() + .array() + .optional(), + kubeconfig: KubeconfigSchema, +})) + +export const ClusterUsageSchema = z.object({ + cpu: z.number(), + gpu: z.number(), + memory: z.number(), +}) + +export type Cluster = Zod.infer +export type ClusterDetails = Zod.infer +export type Kubeconfig = Zod.infer + +export type CleanedCluster = Zod.infer diff --git a/.jjconflict-side-2/packages/shared/src/schemas/config.ts b/.jjconflict-side-2/packages/shared/src/schemas/config.ts new file mode 100644 index 0000000000..cca95debd8 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/config.ts @@ -0,0 +1,84 @@ +import type Zod from 'zod' +import { z } from 'zod' + +const configItemBase = z.object({ + key: z.string().min(2).regex(/[a-z-]/), + description: z.string().optional(), + title: z.string(), + section: z.string().optional(), +}) + +const projectVisibilty = z.object({ + permissions: z.object({ + user: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + admin: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + }), +}) + +const globalVisibilty = z.object({ + permissions: z.object({ + user: z.object({ + read: z.boolean(), + write: z.boolean().refine(value => !value, { message: 'global params can\'t be user writable' }), + }), + admin: z.object({ + read: z.boolean(), + write: z.boolean(), + }), + }), +}) + +export const ENABLED = 'enabled' +export const DISABLED = 'disabled' +export const DEFAULT = 'default' + +export const atomicValidators = { + switch: z.enum([DISABLED, DEFAULT, ENABLED]), + // eslint-disable-next-line regexp/prefer-w, regexp/use-ignore-case + text: z.string().trim().regex(/[a-zA-Z-_0-9 ]*/), +} + +export type SwitchParam = Zod.infer + +const configItemSwitch = z.object({ + kind: z.literal('switch'), + value: atomicValidators.switch, + initialValue: atomicValidators.switch, +}).merge(configItemBase) + +const configItemInput = z.object({ + kind: z.literal('text'), + value: atomicValidators.text, + placeholder: z.string().optional(), +}).merge(configItemBase) + +const configGlobalItemDeclaration = z.discriminatedUnion('kind', [ + configItemSwitch.merge(globalVisibilty), + configItemInput.merge(globalVisibilty), +]) + +export type PluginConfigItem = Zod.infer + +export const configProjectItemDeclaration = z.discriminatedUnion('kind', [ + configItemSwitch.merge(projectVisibilty), + configItemInput.merge(projectVisibilty), +]) + +export const pluginConfig = z.object({ + global: configGlobalItemDeclaration.array(), + project: configProjectItemDeclaration.array(), +}) + +export type PluginConfig = Zod.infer + +export const pluginsPopulatedManifests = pluginConfig.partial() +export type PluginsPopulatedManifests = Zod.infer + +export const pluginUpdateBody = z.record(z.record(z.string())) +export type PluginsUpdateBody = Zod.infer diff --git a/.jjconflict-side-2/packages/shared/src/schemas/deployment.ts b/.jjconflict-side-2/packages/shared/src/schemas/deployment.ts new file mode 100644 index 0000000000..b1e0f8ee47 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/deployment.ts @@ -0,0 +1,70 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestDeploymentName } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' +import { EnvironmentSchema } from './environment.js' +import { RepoSchema } from './repository.js' + +const DeploymentSourceType = z.enum(['git', 'oci']) + +export const DeploymentSourceSchema = z.object({ + id: z.string() + .uuid(), + deploymentId: z.string() + .uuid(), + repositoryId: z.string() + .uuid(), + type: DeploymentSourceType, + repository: RepoSchema, + // Optional deployment settings + targetRevision: z.string().optional(), + path: z.string().optional(), + helmValuesFiles: z.string().optional(), +}).extend(AtDatesToStringExtend) + +export const DeploymentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestDeploymentName), + projectId: z.string() + .uuid(), + environmentId: z.string() + .uuid(), + autosync: z.boolean(), + environment: EnvironmentSchema, + deploymentSources: DeploymentSourceSchema.array(), +}).extend(AtDatesToStringExtend) + +export const CreateDeploymentSchema = DeploymentSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + environment: true, +}).extend({ + deploymentSources: DeploymentSourceSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deploymentId: true, + repository: true, + }).array().min(1, 'Au moins une source de déploiement est requise'), +}) + +export const UpdateDeploymentSchema = CreateDeploymentSchema.extend({ + deploymentSources: DeploymentSourceSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + deploymentId: true, + repository: true, + }) + .extend({ id: z.string().uuid().optional() }) + .array().min(1, 'Au moins une source de déploiement est requise'), +}) + +export type Deployment = Zod.infer +export type CreateDeployment = Zod.infer +export type UpdateDeployment = Zod.infer diff --git a/.jjconflict-side-2/packages/shared/src/schemas/environment.ts b/.jjconflict-side-2/packages/shared/src/schemas/environment.ts new file mode 100644 index 0000000000..f3c9bd2d8c --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/environment.ts @@ -0,0 +1,40 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestEnvironmentName } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' + +export const EnvironmentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestEnvironmentName), + projectId: z.string() + .uuid(), + stageId: z.string().uuid(), + clusterId: z.string() + .uuid(), + cpu: z.coerce.number().gte(0), + gpu: z.coerce.number().gte(0), + memory: z.coerce.number().gte(0), + autosync: z.boolean(), +}).extend(AtDatesToStringExtend) + +export const CreateEnvironmentSchema = EnvironmentSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + projectId: true, +}) + +export const UpdateEnvironmentSchema = EnvironmentSchema.pick({ + cpu: true, + gpu: true, + memory: true, + autosync: true, +}) + +export type Environment = Zod.infer +export type CreateEnvironment = Zod.infer +export type UpdateEnvironment = Zod.infer diff --git a/.jjconflict-side-2/packages/shared/src/schemas/index.ts b/.jjconflict-side-2/packages/shared/src/schemas/index.ts new file mode 100644 index 0000000000..236c4124b2 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/index.ts @@ -0,0 +1,15 @@ +export * from './cluster.js' +export * from './config.js' +export * from './deployment.js' +export * from './environment.js' +export * from './log.js' +export * from './project.js' +export * from './repository.js' +export * from './role.js' +export * from './service-chain.js' +export * from './services.js' +export * from './stage.js' +export * from './system.js' +export * from './token.js' +export * from './user.js' +export * from './zone.js' diff --git a/.jjconflict-side-2/packages/shared/src/schemas/log.ts b/.jjconflict-side-2/packages/shared/src/schemas/log.ts new file mode 100644 index 0000000000..a58f2777f2 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/log.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import { AtDatesToStringExtend } from './_utils.js' + +export const CleanLogSchema = z.object({ + id: z.string(), + data: z.object({ + failed: z.boolean().or(z.array(z.string())).optional(), + warning: z.string().array().optional(), + totalExecutionTime: z.number().optional(), + messageResume: z.string().optional(), + }).strip(), + action: z.string(), + userId: z.string().nullable(), +}) + .extend(AtDatesToStringExtend) + +export const LogSchema = z.object({ + id: z.string(), + data: z.object({ + args: z.any(), + failed: z.boolean().or(z.array(z.string())).optional(), + warning: z.string().array().optional(), + results: z.any(), + totalExecutionTime: z.number().optional(), + messageResume: z.string().optional(), + }) + .passthrough() + .transform((data) => { + delete data.config + return data + }), + action: z.string(), + userId: z.string().nullable(), + requestId: z.string().nullable(), + projectId: z.string().nullable(), +}) + .extend(AtDatesToStringExtend) diff --git a/.jjconflict-side-2/packages/shared/src/schemas/project.ts b/.jjconflict-side-2/packages/shared/src/schemas/project.ts new file mode 100644 index 0000000000..6f4e549f78 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/project.ts @@ -0,0 +1,87 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { longestEnvironmentName, projectStatus } from '../utils/const.js' +import { AtDatesToStringExtend, CoerceBooleanSchema, permissionLevelSchema } from './_utils.js' +import { RepoSchema } from './repository.js' +import { ProjectRoleSchema } from './role.js' +import { MemberSchema, UserSchema } from './user.js' + +export const descriptionMaxLength = 280 +export const projectNameMaxLength = 20 +export const ProjectStatusSchema = z.enum(projectStatus) + +const ProjectEnvironmentSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/) + .min(2) + .max(longestEnvironmentName), + slug: z.string() + .regex(/^[a-z0-9]+$/), + projectId: z.string() + .uuid(), + stageId: z.string().uuid(), + clusterId: z.string() + .uuid(), + permissions: z.array(z.object({ + id: z.string() + .uuid(), + userId: z.string() + .uuid(), + environmentId: z.string() + .uuid(), + level: z.union([ + z.string(), + z.number() + .int() + .nonnegative() + .max(2), + ]), + })), +}) +export const ProjectSchema = z.object({ + // ProjectInfos + repositories: RepoSchema.array(), + environments: ProjectEnvironmentSchema.array(), +}) + +export type Project = Zod.infer + +export const ProjectSchemaV2 = z.object({ + clusterIds: z.string().uuid().array(), + name: z.string() + .min(2) + .max(projectNameMaxLength) + .regex(/^[a-z0-9]+$/), + slug: z.string(), + description: z.string() + .max(descriptionMaxLength) + .optional(), + status: ProjectStatusSchema, + locked: CoerceBooleanSchema, + limitless: CoerceBooleanSchema, + hprodCpu: z.coerce.number().gte(0), + hprodGpu: z.coerce.number().gte(0), + hprodMemory: z.coerce.number().gte(0), + prodCpu: z.coerce.number().gte(0), + prodGpu: z.coerce.number().gte(0), + prodMemory: z.coerce.number().gte(0), + + id: z.string() + .uuid(), + members: MemberSchema + .array(), + ownerId: z.string() + .uuid(), + owner: UserSchema + .omit({ adminRoleIds: true }), + roles: ProjectRoleSchema + .array(), + everyonePerms: permissionLevelSchema, + lastSuccessProvisionningVersion: z.string() + .nullable(), +}) + .extend(AtDatesToStringExtend) + +export type ProjectV2 = Zod.infer diff --git a/.jjconflict-side-2/packages/shared/src/schemas/repository.ts b/.jjconflict-side-2/packages/shared/src/schemas/repository.ts new file mode 100644 index 0000000000..85675b9123 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/repository.ts @@ -0,0 +1,74 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { forbiddenRepoNames, invalidGitUrl, invalidInternalRepoName, missingCredentials } from '../utils/const.js' +import { AtDatesToStringExtend } from './_utils.js' + +export const RepoSchema = z.object({ + id: z.string() + .uuid(), + internalRepoName: z.string() + .min(2, { message: 'Longueur minimum 2 caractères' }) + .max(20, { message: 'Longueur maximum 20 caractères' }) + .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, { message: invalidInternalRepoName }) + .refine(name => !forbiddenRepoNames.includes(name.toLowerCase()), { + message: `Le nom du dépôt choisi est inclus dans la liste d'exclusion: ${forbiddenRepoNames.join(', ')}`, + }), + externalRepoUrl: z.string() + .regex(/^https:\/\/.*\.git$/, { message: invalidGitUrl }) + .url({ message: 'Url invalide' }) + .or(z.literal('')) + .optional(), + isPrivate: z.boolean(), + isInfra: z.boolean(), + externalUserName: z.string() + .optional(), + externalToken: z.string() + .optional(), + projectId: z.string() + .uuid(), + // Optional deployment settings for infra repositories + deployRevision: z.string().optional(), + deployPath: z.string().optional(), + helmValuesFiles: z.string().optional(), +}).extend(AtDatesToStringExtend) + +// To only use in frontend form +export const RepoFormSchema = RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .extend({ isStandalone: z.boolean() }) + +export const UpdateRepoFormSchema = RepoFormSchema + .refine( + ({ isPrivate, externalToken, externalUserName }) => { + if (isPrivate) { + if (!externalToken && !externalUserName) return false + return true + } + return true + }, + { message: missingCredentials, path: ['credentials'] }, + ) + .refine(({ isStandalone, externalRepoUrl }) => { + if (!isStandalone && !externalRepoUrl) { + return false + } + return true + }, { message: 'Veuillez renseignez l\'url du dépôt externe', path: ['externalRepoUrl'] }) + +export const CreateRepoFormSchema = RepoFormSchema + .omit({ id: true, projectId: true }) + .refine(({ isPrivate, externalToken, externalUserName }) => { + if (isPrivate) { + if (!externalToken && !externalUserName) return false + return true + } + return true + }, { message: missingCredentials, path: ['credentials'] }) + .refine(({ isStandalone, externalRepoUrl }) => { + if (!isStandalone && !externalRepoUrl) { + return false + } + return true + }, { message: 'Veuillez renseignez l\'url du dépôt externe', path: ['externalRepoUrl'] }) + +export type Repo = Zod.infer diff --git a/.jjconflict-side-2/packages/shared/src/schemas/role.ts b/.jjconflict-side-2/packages/shared/src/schemas/role.ts new file mode 100644 index 0000000000..0e0a1d226a --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/role.ts @@ -0,0 +1,35 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { permissionLevelSchema } from './_utils.js' + +export const RoleNameSchema = z.string().max(30) + +export const RoleSchema = z.object({ + id: z.string().uuid(), + name: RoleNameSchema, + permissions: permissionLevelSchema, + position: z.number().min(0), + type: z.string().optional(), +}) + +export const ProjectRoleSchema = RoleSchema.extend({ + projectId: z.string().uuid(), + oidcGroup: z.string().optional(), +}) + +export const AdminRoleSchema = RoleSchema.extend({ + oidcGroup: z.string(), + type: z.string().optional().default('managed'), +}) + +export const RoleNameCsvSchema = z.string() + .refine((value) => { + return !value.split(',').some(name => !RoleNameSchema.safeParse(name).success) + }) + .transform(value => value.split(',')) + +export type Role = Zod.infer +export type RoleBigint = Omit, 'permissions'> & { permissions: bigint } +export type AdminRole = Zod.infer +export type ProjectRole = Zod.infer +export type ProjectRoleBigint = Omit, 'permissions'> & { permissions: bigint } diff --git a/.jjconflict-side-2/packages/shared/src/schemas/service-chain.ts b/.jjconflict-side-2/packages/shared/src/schemas/service-chain.ts new file mode 100644 index 0000000000..c364fcb8d0 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/service-chain.ts @@ -0,0 +1,103 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const serviceChainStateEnum = ['opened', 'pending', 'success', 'failed'] as const +export const ServiceChainStateZodEnum = z.enum(serviceChainStateEnum) +export type ServiceChainState = Zod.infer + +export const serviceChainNetworkEnum = ['RIE', 'INTERNET'] as const +export const ServiceChainNetworkZodEnum = z.enum(serviceChainNetworkEnum) +export type ServiceChainNetwork = Zod.infer + +export const serviceChainLocationEnum = ['SIR', 'SIL'] as const +export const ServiceChainLocationZodEnum = z.enum(serviceChainLocationEnum) +export type ServiceChainLocation = Zod.infer + +export const serviceChainEnvironmentEnum = ['INT', 'PROD', 'BAS'] as const +export const ServiceChainEnvironmentZodEnum = z.enum(serviceChainEnvironmentEnum) +export type ServiceChainEnvironment = Zod.infer< + typeof ServiceChainEnvironmentZodEnum +> + +export const ServiceChainSchema = z.object({ + id: z.string().uuid(), + state: ServiceChainStateZodEnum, + commonName: + // @TODO: Replace with z.hostname when upgraded to Zod v4 + z.string(), + pai: z.string(), + network: ServiceChainNetworkZodEnum, + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}) +export type ServiceChain = Zod.infer + +export const ServiceChainListSchema = z.array(ServiceChainSchema) +export type ServiceChainList = Zod.infer + +export const ServiceChainDetailsSchema = ServiceChainSchema.extend({ + validationId: z.string().uuid(), + validatedBy: z.nullable(z.string().uuid()), + ref: z.nullable(z.string().uuid()), + location: ServiceChainLocationZodEnum, + targetAddress: z.string().ip(), + projectId: z.string().uuid(), + env: ServiceChainEnvironmentZodEnum, + subjectAlternativeName: z.array( + // @TODO: Replace with z.hostname when upgraded to Zod v4 + z.string(), + ), + redirect: z.boolean(), + antivirus: z.nullable(z.object({ + maxFileSize: z.number(), + })), + websocket: z.boolean(), + ipWhiteList: z.array(z.string().cidr()), + sslOutgoing: z.boolean(), +}) +export type ServiceChainDetails = Zod.infer + +// JSON as String validation through Zod. Instead of adding yet-another-dependency, +// I merely copied the code as-is. +// Many thanks to JacobWeisenburger/zod_utilz ! +// +// Usage: use stringToJSON() as you would use z.string() to validate strings that must +// contain JSON stringified content +const literalSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), +]) +type Literal = z.infer +type Json = Literal | { [ key: string ]: Json } | Json[] +const jsonSchema: z.ZodType = z.lazy(() => + z.union([ + literalSchema, + z.array(jsonSchema), + z.record(jsonSchema), + ]), +) +export const json = () => jsonSchema + +export const serviceChainFlowStateEnum = ['opened', 'pending', 'success', 'failed'] as const +export const ServiceChainFlowStateZodEnum = z.enum(serviceChainFlowStateEnum) +export type ServiceChainFlowState = Zod.infer + +export const ServiceChainFlowDetailsSchema = z.object({ + state: ServiceChainFlowStateZodEnum, + input: json(), + output: json(), + updatedAt: z.coerce.date(), +}) +export type ServiceChainFlowDetails = Zod.infer + +export const ServiceChainFlowsSchema = z.object({ + reserve_ip: ServiceChainFlowDetailsSchema, + create_cert: z.nullable(ServiceChainFlowDetailsSchema), + call_exec: ServiceChainFlowDetailsSchema, + activate_ip: ServiceChainFlowDetailsSchema, + dns_request: ServiceChainFlowDetailsSchema, +}) +// Flows is on object, so always used in plural sense +export type ServiceChainFlows = Zod.infer diff --git a/.jjconflict-side-2/packages/shared/src/schemas/services.ts b/.jjconflict-side-2/packages/shared/src/schemas/services.ts new file mode 100644 index 0000000000..22c8299f1f --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/services.ts @@ -0,0 +1,36 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { pluginsPopulatedManifests } from './config.js' + +export const serviceUrl = z.object({ + to: z.string(), + name: z.string(), + description: z.string() + .optional(), +}) + +export type ServiceUrl = Zod.infer + +export const ServiceSchema = z.object({ + description: z.string() + .optional(), + title: z.string(), + name: z.string(), + imgSrc: z.string() + .optional(), + urls: serviceUrl.array(), + manifest: pluginsPopulatedManifests, +}) + +export const permissionTarget = z.enum(['user', 'admin']).default('user') + +export type PermissionTarget = Zod.infer +export type ProjectService = Zod.infer + +export const ServiceHealthSchema = z.object({ + name: z.string(), + status: z.enum(['OK', 'Dégradé', 'En échec', 'Inconnu']), + interval: z.number(), + lastUpdateTimestamp: z.number(), + message: z.string(), +}) diff --git a/.jjconflict-side-2/packages/shared/src/schemas/stage.ts b/.jjconflict-side-2/packages/shared/src/schemas/stage.ts new file mode 100644 index 0000000000..51cb72d120 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/stage.ts @@ -0,0 +1,14 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const StageSchema = z.object({ + id: z.string() + .uuid(), + name: z.string() + .regex(/^[a-z0-9]+$/i) + .min(2, { message: 'must be at least 2 character long' }) + .max(20, { message: 'must not exceed 20 characters' }), + clusterIds: z.string().uuid().array(), +}) + +export type Stage = Zod.infer diff --git a/.jjconflict-side-2/packages/shared/src/schemas/system.ts b/.jjconflict-side-2/packages/shared/src/schemas/system.ts new file mode 100644 index 0000000000..d941a5b9d2 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/system.ts @@ -0,0 +1,20 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { configProjectItemDeclaration } from './config.js' + +export const pluginSchema = z.object({ + description: z.string() + .optional(), + title: z.string(), + name: z.string(), + imgSrc: z.string() + .optional(), + manifest: configProjectItemDeclaration.array(), +}) + +export type PluginSchema = Zod.infer + +export const SystemSettingSchema = z.object({ + key: z.string(), + value: z.string(), +}) diff --git a/.jjconflict-side-2/packages/shared/src/schemas/token.ts b/.jjconflict-side-2/packages/shared/src/schemas/token.ts new file mode 100644 index 0000000000..36cd518ac5 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/token.ts @@ -0,0 +1,44 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { dateToString, permissionLevelSchema } from './_utils.js' +import { UserSchema } from './user.js' + +export const TokenSchema = z.object({ + id: z.string().uuid(), + name: z.string() + .max(32, { message: 'Ne peut dépasser 32 caractères' }) + .min(2, { message: 'Ne peut faire moins de 2 caractères' }) + .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, { message: 'Le nom ne peut être constitué que de caractères minuscules, de chiffres et de tirets (-)' }), + lastUse: dateToString.nullable(), + createdAt: dateToString, + owner: UserSchema + .pick({ email: true, firstName: true, lastName: true, id: true, type: true }) + .optional() + .nullable(), + status: z.enum(['active', 'revoked', 'inactive']), +}) + +// Admin Token section +export const AdminTokenSchema = TokenSchema + .extend({ + expirationDate: dateToString.nullable(), + permissions: permissionLevelSchema, + }) + +export const ExposedAdminTokenSchema = AdminTokenSchema.extend({ + password: z.string(), +}) + +export type AdminToken = Zod.infer +export type ExposedAdminToken = Zod.infer + +// PAT section +export const PersonalAccessTokenSchema = TokenSchema.extend({ + expirationDate: dateToString, +}) +export const ExposedPersonalAccessTokenSchema = TokenSchema.extend({ + password: z.string(), +}) + +export type PersonalAccessToken = Zod.infer +export type ExposedPersonalAccessToken = Zod.infer diff --git a/.jjconflict-side-2/packages/shared/src/schemas/user.ts b/.jjconflict-side-2/packages/shared/src/schemas/user.ts new file mode 100644 index 0000000000..2fd89bc285 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/user.ts @@ -0,0 +1,39 @@ +import type Zod from 'zod' +import { z } from 'zod' +import { AtDatesToStringExtend, dateToString } from './_utils.js' + +export const UserSchema = z.object({ + id: z.string() + .uuid(), + firstName: z.string(), + lastName: z.string(), + email: z.string(), + adminRoleIds: z.string().uuid().array(), + type: z.enum(['human', 'ghost', 'bot']), + lastLogin: dateToString.optional().nullable(), +}) + .extend(AtDatesToStringExtend) + +export const MemberSchema = z.object({ + userId: z.string() + .uuid(), + firstName: z.string(), + lastName: z.string(), + email: z.string() + .email(), + roleIds: z.string().uuid().array(), +}) + .or( + z.object({ + user: UserSchema, + roleIds: z.string().uuid().array(), + }).transform(({ user: { adminRoleIds: _, id: userId, ...user }, roleIds }) => ({ userId, roleIds, ...user })), + ) + +export const ProjectMemberSchema = MemberSchema.and(z.object({ + projectId: z.string().uuid(), +})) + +export type User = Zod.infer +export type Member = Zod.infer +export type ProjectMember = Zod.infer diff --git a/.jjconflict-side-2/packages/shared/src/schemas/zone.ts b/.jjconflict-side-2/packages/shared/src/schemas/zone.ts new file mode 100644 index 0000000000..e402a73a6c --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/schemas/zone.ts @@ -0,0 +1,24 @@ +import type Zod from 'zod' +import { z } from 'zod' + +export const ZoneSchema = z.object({ + id: z.string() + .uuid(), + slug: z.string() + .min(1) + .max(10) + .regex(/^[a-z0-9-]+$/), + label: z.string() + .min(1) + .max(50), + argocdUrl: z.string() + .min(5, { message: 'Longueur minimum 5 caractères' }) + .url({ message: 'Url invalide' }), + description: z.string() + .max(200) + .optional() + .nullable() + .transform(value => value ?? ''), +}) + +export type Zone = Zod.infer diff --git a/.jjconflict-side-2/packages/shared/src/utils/const.ts b/.jjconflict-side-2/packages/shared/src/utils/const.ts new file mode 100644 index 0000000000..9c80f33998 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/utils/const.ts @@ -0,0 +1,147 @@ +export const adminGroupPath = '/admin' +export const deleteValidationInput = 'DELETE' +export const forbiddenRepoNames = ['mirror', 'infra-apps', 'infra-observability'] + +export const inClusterLabel = 'in-cluster' as const +export const projectIsLockedInfo = 'Le projet est verrouillé, pas d\'action possible' +export const missingCredentials = 'Si le dépôt est privé, vous devez renseigner au moins le nom d\'utilisateur ou le token' +export const invalidGitUrl = 'L\'adresse doit commencer par https et se terminer par .git' +export const invalidInternalRepoName = 'Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique' + +export const fakeToken = 'fakeToken' +export const tokenHeaderName = 'x-dso-token' +export const swaggerUiPath = '/swagger-ui' + +export const levels = [ + 'r', + 'rw', + 'rwd', +] as const +export type PermissionLevels = typeof levels[number] + +export const projectRoles = [ + 'owner', + 'user', +] as const +export type ProjectRoles = typeof projectRoles[number] + +export const longestEnvironmentName = 11 as const +export const longestDeploymentName = 11 as const + +export const allStatus = [ + 'initializing', + 'created', + 'failed', + 'deleting', +] as const + +export const projectStatus = [ + 'initializing', + 'created', + 'failed', + 'archived', + 'warning', +] as const + +export const achievedStatus = [ + 'created', + 'failed', +] as const + +export type AchievedStatus = typeof achievedStatus[number] + +export enum ClusterPrivacy { + PUBLIC = 'public', + DEDICATED = 'dedicated', +} + +export const privacyWording: Record = { + dedicated: { text: 'dédié', icon: 'ri:shield-keyhole-line' }, + public: { text: 'public', icon: 'ri:door-open-line' }, +} + +export enum AllStatus { + CREATED = 'created', + FAILED = 'failed', + DELETING = 'deleting', +} + +export const logActions = [ + 'Create Project', + 'Create Repository', + 'Create Environment', + 'Delete Project', + 'Delete Repository', + 'Delete Environment', +] + +export const statusDict = { + locked: { + false: { + testId: 'unlocked-badge', + type: 'success', + icon: 'ri:lock-unlock-line', + wording: 'déverrouillé', + animation: undefined, + color: 'var(--success-425-625)', + }, + true: { + testId: 'locked-badge', + type: 'warning', + icon: 'ri:lock-line', + wording: 'verrouillé', + animation: undefined, + color: 'var(--warning-425-625)', + }, + }, + status: { + created: { + testId: 'created-badge', + type: 'success', + icon: 'ri:check-line', + wording: 'succès', + animation: undefined, + color: 'var(--success-425-625)', + }, + failed: { + testId: 'failed-badge', + type: 'error', + icon: 'ri:close-line', + wording: 'opérations échouées', + animation: undefined, + color: 'var(--error-425-625)', + }, + initializing: { + testId: 'initializing-badge', + type: 'info', + icon: 'ri:loader-4-line', + animation: 'spin', + wording: 'opérations en cours', + color: 'var(--info-425-625)', + }, + archived: { + testId: 'archived-badge', + type: 'info', + icon: 'ri:archive-line', + wording: 'archivé', + animation: undefined, + color: 'var(--text-mention-grey)', + }, + warning: { + testId: 'warning-badge', + type: 'warning', + icon: 'ri:alert-line', + wording: 'partiellement dégradé', + animation: undefined, + color: 'var(--warning-425-625)', + }, + }, +} as const + +export const servicePluginOrder = [ + 'argocd', + 'gitlab', + 'registry', + 'sonarqube', + 'vault', +] diff --git a/.jjconflict-side-2/packages/shared/src/utils/date.ts b/.jjconflict-side-2/packages/shared/src/utils/date.ts new file mode 100644 index 0000000000..1bc7428288 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/utils/date.ts @@ -0,0 +1,9 @@ +export function formatDate(dateString: string) { + const date = new Date(dateString) + return new Intl.DateTimeFormat('default', { dateStyle: 'long' }).format(date) +} + +export function formatDateTime(dateTimeString: string) { + const date = new Date(dateTimeString) + return new Intl.DateTimeFormat('default', { dateStyle: 'short', timeStyle: 'short' }).format(date) +} diff --git a/.jjconflict-side-2/packages/shared/src/utils/functions.spec.ts b/.jjconflict-side-2/packages/shared/src/utils/functions.spec.ts new file mode 100644 index 0000000000..deecd24656 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/utils/functions.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { calcProjectNameMaxLength, exclude, identity, removeTrailingSlash, shallowEqual, shallowMatch } from './functions.js' + +describe('function utils: identity', () => { + it('should return identity', () => { + expect(identity('Test')).toStrictEqual('Test') + }) +}) + +describe('function utils: exclude', () => { + it('should exclude keys', () => { + const emptyInput = {} + const simpleInput = { + hello: 'world', + foo: 'bar', + } + const simpleExpectedInput = { + foo: 'bar', + } + const complexInput = { + empty: emptyInput, + simple: simpleInput, + array: [emptyInput, simpleInput, simpleInput, emptyInput], + number: 1, + } + const complexExpectedOutput = { + empty: emptyInput, + simple: simpleExpectedInput, + array: [emptyInput, simpleExpectedInput, simpleExpectedInput, emptyInput], + number: 1, + } + + expect(exclude(emptyInput, ['absentKey'])).toStrictEqual({}) + expect(exclude(simpleInput, ['hello'])).toStrictEqual(simpleExpectedInput) + expect(exclude(complexInput, ['hello'])).toStrictEqual(complexExpectedOutput) + }) + + it('should not mutate input', () => { + const simpleInput = { + hello: 'world', + foo: 'bar', + } + exclude(simpleInput, ['hello']) + expect(simpleInput).toStrictEqual(simpleInput) + }) + + it('should stringify Error and not traverse non-serializable objects', () => { + class NotSerializable { + public value = 'secret' + } + + const input = { + err: new Error('boom'), + big: 896n, + date: new Date('2026-04-08T14:32:26.773Z'), + instance: new NotSerializable(), + } + + const transformed = exclude(input, []) + expect(transformed).toMatchObject({ + big: '896', + date: '2026-04-08T14:32:26.773Z', + instance: '[NotSerializable]', + }) + expect(typeof (transformed as any).err).toBe('string') + expect((transformed as any).err).toContain('boom') + }) +}) + +describe('function utils: calcProjectNameMaxLength', () => { + it('should return max length', () => { + expect(calcProjectNameMaxLength()).toStrictEqual(50) + }) +}) + +describe('function utils: removeTrailingSlash', () => { + it('should return string without ending slash', () => { + expect(removeTrailingSlash('mtest')).toStrictEqual('mtest') + expect(removeTrailingSlash('mtest/')).toStrictEqual('mtest') + }) +}) + +describe('function utils: shallowEqual', () => { + it('should return false if value differs', () => { + expect(shallowEqual({ a: 1 }, { a: 2 })).toEqual(false) + }) + it('should return false if missing key', () => { + expect(shallowEqual({ a: 1, b: 2 }, { a: 1 })).toEqual(false) + }) + it('should return true if equal', () => { + expect(shallowEqual({ a: 1 }, { a: 1 })).toEqual(true) + }) + it('should ignore undefined values in object1', () => { + expect(shallowEqual({ a: 1, b: undefined }, { a: 1 })).toEqual(true) + }) + it('should ignore undefined values in object2', () => { + expect(shallowEqual({ a: 1 }, { a: 1, b: undefined })).toEqual(true) + }) + it('should return true even if keys are in different order', () => { + expect(shallowEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toEqual(true) + }) + it('should treat empty objects as equal', () => { + expect(shallowEqual({}, {})).toEqual(true) + }) + it('should treat objects with only undefined keys as equal', () => { + expect(shallowEqual({ a: undefined }, { b: undefined })).toEqual(true) + }) +}) + +describe('function utils: shallowMatch', () => { + it('should return false if key is not equal', () => { + expect(shallowMatch({ a: 1 }, { a: 2 })).toEqual(false) + }) + it('should return false if missing key', () => { + expect(shallowMatch({ a: 1, b: 2 }, { a: 1 })).toEqual(false) + }) + it('should return true if is equal', () => { + expect(shallowMatch({ a: 1 }, { a: 1 })).toEqual(true) + }) +}) diff --git a/.jjconflict-side-2/packages/shared/src/utils/functions.ts b/.jjconflict-side-2/packages/shared/src/utils/functions.ts new file mode 100644 index 0000000000..f38778f36d --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/utils/functions.ts @@ -0,0 +1,290 @@ +import type { ResourceById, ResourceByKey } from './types.js' +import shortUUID from 'short-uuid' +import { longestEnvironmentName } from './const.js' + +/** + * @param {*} value Value wanted to be return as is + * @returns {*} Value returned as is + */ +export const identity = (value: unknown) => value + +export const getLongestStringOfArray = (array: Array) => array.reduce((acc, curr) => acc.length < curr.length ? curr : acc, '') + +export function calcProjectNameMaxLength() { + return 61 - longestEnvironmentName +} + +export const getUniqueListBy = (arr: Array>, key: string) => [...new Map(arr.map(item => [item[key], item])).values()] + +export const isString = (value: any): value is string => typeof value === 'string' + +type ObjToSort = Record + +export function sortArrByObjKeyAsc(arr: T, key: string): T { + return arr.toSorted((a: ObjToSort, b: ObjToSort) => { + const aValue = a[key] + const bValue = b[key] + if (isString(aValue) && isString(bValue)) { + return aValue.localeCompare(bValue.toString(), 'fr', { sensitivity: 'base' }) + } + if (typeof aValue === 'number' && typeof bValue === 'number') { + return aValue - bValue + } + if (typeof aValue === 'boolean' && typeof bValue === 'boolean') { + return Number(bValue) - Number(aValue) + } + return -1 + }) as T +} + +export function removeTrailingSlash(url: string) { + return url?.endsWith('/') + ? url.slice(0, -1) + : url +} + +function excludeCircular(value: unknown, keys: string[], inPath: WeakSet, inArray: boolean): unknown { + if (value === null) return null + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value + if (typeof value === 'bigint') return value.toString() + if (typeof value === 'undefined') return inArray ? null : undefined + if (typeof value === 'function') return inArray ? null : undefined + if (typeof value === 'symbol') return value.toString() + if (typeof value === 'object') { + if (inPath.has(value)) return '[object Circular]' + inPath.add(value) + + if (Array.isArray(value)) return value.map(v => excludeCircular(v, keys, inPath, true)) + + if (value instanceof Date) { + inPath.delete(value) + return value.toISOString() + } + + if (value instanceof Error) { + const payload = { + name: value.name, + message: value.message, + stack: value.stack, + } + inPath.delete(value) + try { + return JSON.stringify(payload) + } catch { + return `${value.name}: ${value.message}` + } + } + + if (value instanceof Map) { + const obj: Record = {} + for (const [k, v] of value.entries()) { + const key = typeof k === 'string' ? k : String(k) + obj[key] = excludeCircular(v, keys, inPath, false) + } + inPath.delete(value) + return obj + } + + if (value instanceof Set) { + const out = Array.from(value.values(), v => excludeCircular(v, keys, inPath, true)) + inPath.delete(value) + return out + } + + if (value instanceof RegExp) { + inPath.delete(value) + return value.toString() + } + + if (value instanceof URL) { + inPath.delete(value) + return value.toString() + } + + if (value instanceof URLSearchParams) { + inPath.delete(value) + return value.toString() + } + + if ('toJSON' in value && typeof value.toJSON === 'function') { + try { + const serialized = value.toJSON() + const out = excludeCircular(serialized, keys, inPath, inArray) + inPath.delete(value) + return out + } catch { + inPath.delete(value) + return '[object Unserializable]' + } + } + + const proto = Object.getPrototypeOf(value) + if (proto !== Object.prototype && proto !== null) { + const ctorName = (value as any)?.constructor?.name + const tag = typeof ctorName === 'string' && ctorName.length ? ctorName : 'Object' + try { + const s = String(value) + const out = s === '[object Object]' ? `[${tag}]` : s + inPath.delete(value) + return out + } catch { + inPath.delete(value) + return `[object ${tag}]` + } + } + + const obj = value as Record + const newObj: Record = {} + for (const [k, v] of Object.entries(obj)) { + if (keys.includes(k)) continue + const next = excludeCircular(v, keys, inPath, false) + if (typeof next === 'undefined') continue + newObj[k] = next + } + inPath.delete(value) + return newObj + } + return String(value) +} + +// Exclude keys from an object +export function exclude(result: T, keys: string[]): T { + const inPath = new WeakSet() + return excludeCircular(result, keys, inPath, false) as T +} + +export type AsyncReturnType Promise> + = T extends (...args: any) => Promise ? R : any + +export function objectEntries>(obj: Obj): ([keyof Obj, Obj[keyof Obj]])[] { + return Object.entries(obj) as ([keyof Obj, Obj[keyof Obj]])[] +} +export function objectKeys>(obj: Obj): (keyof Obj)[] { + return Object.keys(obj) as (keyof Obj)[] +} +export function objectValues>(obj: Obj): (Obj[keyof Obj])[] { + return Object.values(obj) as (Obj[keyof Obj])[] +} + +export function requiredEnv(envName: string): string { + const envValue = process.env[envName] + if (envValue) return envValue + + throw new Error(`env: ${envName} is not defined !`) +} + +export function resourceListToDict(resList: Array): ResourceById { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.id]: curr, + } + }, {} as ResourceById) +} + +export function resourceListToDictByKey(resList: Array): ResourceByKey { + return resList.reduce((acc, curr) => { + return { + ...acc, + [curr.key]: curr, + } + }, {} as ResourceByKey) +} + +export function shallowEqual(object1: Record, object2: Record) { + const definedObject1 = Object.fromEntries( + Object.entries(object1).filter(([_, v]) => v !== undefined), + ) + const definedObject2 = Object.fromEntries( + Object.entries(object2).filter(([_, v]) => v !== undefined), + ) + const keys1 = Object.keys(definedObject1) + const keys2 = Object.keys(definedObject2) + + if (keys1.length !== keys2.length) { + return false + } + + for (const key of keys1) { + if (definedObject1[key] !== definedObject2[key]) { + return false + } + } + + return true +} + +/** + * Check if all the keys / values in first parameter is present in the the second parameter, the second one can have additional property + * + * @example + * ```ts + * shallowMatch({ a: 'a' }, { a: 'c' }) // false, invalid value + * shallowMatch({ a: 'a' }, { }) // fals, missing property + * shallowMatch({ a: 'a' }, { a: 'a', b: 'b' }) // true, everything is retrieved and equivalent in the second argument + * ``` + * + */ +export function shallowMatch(objectController: Record | undefined, objectToCheck: Record | undefined) { + if (!objectController || !objectToCheck) { + return false + } + + const keys1 = Object.keys(objectController) + const keys2 = Object.keys(objectToCheck) + + if (keys1.length > keys2.length) { + return false + } + + for (const key of keys1) { + if (objectController[key] !== objectToCheck[key]) { + return false + } + } + + return true +} + +export function generateRandomPassword(length = 24, chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*') { + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +export function isAtLeastTomorrow(actualTime: Date) { + const tomorrow = new Date(Date.now()) + tomorrow.setUTCHours(23, 59, 59, 999) + + return actualTime.getTime() > tomorrow.getTime() +} + +export function insert(pseudoArray: T[] | undefined, element: T): T[] { + if (!pseudoArray) { + return [element] + } else if (Array.isArray(pseudoArray)) { + return [...pseudoArray, element] + } else { + throw new TypeError('item is not an ArrayLike') + } +} + +export type ArrayElement + = ArrayType extends readonly (infer ElementType)[] ? ElementType : never + +export const bts = (v: boolean) => v ? 'true' : 'false' +export function stb(v?: string | undefined) { + return v === 'true' + ? true + : v === 'false' ? false : undefined +} + +const uuidTranslator = shortUUID(shortUUID.constants.uuid25Base36, { + consistentLength: false, +}) + +export const compressUUID = uuidTranslator.fromUUID +export const expandUUID = uuidTranslator.toUUID + +export function generateNamespaceName(projectId: string, envId: string) { + return `${compressUUID(envId)}--${compressUUID(projectId)}` +} diff --git a/.jjconflict-side-2/packages/shared/src/utils/index.ts b/.jjconflict-side-2/packages/shared/src/utils/index.ts new file mode 100644 index 0000000000..0600385dce --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/utils/index.ts @@ -0,0 +1,8 @@ +export * from './const.js' +export * from './date.js' +export * from './functions.js' +export * from './permissions.js' +export * from './plugins.js' +export * from './roles.js' +export * from './schemas.js' +export * from './types.js' diff --git a/.jjconflict-side-2/packages/shared/src/utils/permissions.ts b/.jjconflict-side-2/packages/shared/src/utils/permissions.ts new file mode 100644 index 0000000000..040fbf55d1 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/utils/permissions.ts @@ -0,0 +1,345 @@ +/** + * [FR] ATTENTION ! Ce fichier est la base du système de permissions de l'application. + * Ces permissions sont basés sur le Bitwise Permissions System. Les modifier à posteriori pourrait être catastrophique niveau sécurité. + * Veuillez bien étudier le système et lire la documentation. + * + * [EN] This file is the basis of the application's permissions system. + * These permissions are based on the Bitwise Permissions System. Modifying them after the fact could be catastrophic in terms of security. + * Please study the system carefully and read the documentation. + * https://en.wikipedia.org/wiki/Bitwise_operation + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Bitwise_AND + * https://www.alexhyett.com/bitwise-operators/ + * + * Voici des sources d'inspirations + * https://discordapi.com/permissions.html#32 + * https://discord.com/developers/docs/topics/permissions#permissions + */ +import type { ResourceById } from './types.js' +import { logger as baseLogger } from '@cpn-console/logger' + +const logger = baseLogger.child({ scope: 'utils:permissions' }) + +export function getPermsByUserRoles(userRoles: string[] | undefined, rolesById: ResourceById<{ id: string, permissions: bigint | string }>, basePerms?: bigint | string) { + if (!userRoles) { + return basePerms ? BigInt(basePerms) : 0n + } + return userRoles.reduce((acc, curr) => { + if (!rolesById[curr]) { + logger.warn(`Role ${curr} not found in rolesById`) + return acc + } + return acc | BigInt(rolesById[curr].permissions) + }, basePerms ? BigInt(basePerms) : 0n) +} + +function permissionsParser(a: Record) { + const valuesRegistered = [] as bigint[] + for (const [k, v] of Object.entries(a)) { + if (typeof v !== 'bigint') + throw new Error(`${k} has a invalid value: ${v}, which is not a bigint`) + if (valuesRegistered.includes(v)) + throw new Error(`${k} has a duplicated value: ${v}`) + valuesRegistered.push(v) + } +} + +const bit = (position: bigint) => 1n << position + +// Be very careful and think to apply corresponding updates in database if you modify these values, You'll have to do binary updates in SQL, good luck ! +export const PROJECT_PERMS = { // project permissions + GUEST: bit(0n), + MANAGE: bit(1n), + MANAGE_MEMBERS: bit(2n), + MANAGE_ENVIRONMENTS: bit(3n), + MANAGE_REPOSITORIES: bit(4n), + MANAGE_ROLES: bit(5n), + SEE_SECRETS: bit(6n), + REPLAY_HOOKS: bit(7n), + LIST_ENVIRONMENTS: bit(8n), + LIST_REPOSITORIES: bit(9n), + LIST_MEMBERS: bit(10n), + LIST_ROLES: bit(11n), + MANAGE_DEPLOYMENTS: bit(12n), + LIST_DEPLOYMENTS: bit(13n), +} + +// Be very careful and think to apply corresponding updates in database if you modify these values, You'll have to do binary updates in SQL, good luck ! +export const ADMIN_PERMS = { // admin permissions + LIST: bit(0n), + MANAGE: bit(1n), + MANAGE_USERS: bit(2n), + MANAGE_PROJECTS: bit(3n), + MANAGE_ROLES: bit(4n), + MANAGE_CLUSTERS: bit(5n), + MANAGE_ZONES: bit(6n), + MANAGE_STAGES: bit(7n), + MANAGE_SYSTEM: bit(8n), + LIST_USERS: bit(9n), + LIST_PROJECTS: bit(10n), + LIST_ROLES: bit(11n), + LIST_CLUSTERS: bit(12n), + LIST_ZONES: bit(13n), + LIST_STAGES: bit(14n), + LIST_SYSTEM: bit(15n), + MANAGE_ADMIN_TOKEN: bit(16n), + LIST_ADMIN_TOKEN: bit(17n), +} + +export type ProjectPermsKeys = keyof typeof PROJECT_PERMS +export type AdminPermsKeys = keyof typeof ADMIN_PERMS + +permissionsParser(ADMIN_PERMS) +permissionsParser(PROJECT_PERMS) + +interface ProjectAuthorizedParams { adminPermissions?: bigint | string | null, projectPermissions?: bigint | string } + +export const toBigInt = (value?: bigint | number | string | undefined | null) => value ? BigInt(value) : 0n + +export const AdminAuthorized = { + Manage: (perms?: bigint | string | null) => !!(toBigInt(perms) & ADMIN_PERMS.MANAGE), + ManageUsers: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_USERS | ADMIN_PERMS.MANAGE)), + ManageProjects: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_PROJECTS | ADMIN_PERMS.MANAGE)), + ManageRoles: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ROLES | ADMIN_PERMS.MANAGE)), + ManageClusters: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_CLUSTERS | ADMIN_PERMS.MANAGE)), + ManageZones: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ZONES | ADMIN_PERMS.MANAGE)), + ManageStages: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_STAGES | ADMIN_PERMS.MANAGE)), + ManageSystem: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_SYSTEM | ADMIN_PERMS.MANAGE)), + ManageAdminToken: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.MANAGE_ADMIN_TOKEN | ADMIN_PERMS.MANAGE)), + ListUsers: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_USERS | ADMIN_PERMS.MANAGE_USERS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListProjects: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_PROJECTS | ADMIN_PERMS.MANAGE_PROJECTS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListRoles: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ROLES | ADMIN_PERMS.MANAGE_ROLES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListClusters: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_CLUSTERS | ADMIN_PERMS.MANAGE_CLUSTERS | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListZones: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ZONES | ADMIN_PERMS.MANAGE_ZONES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListStages: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_STAGES | ADMIN_PERMS.MANAGE_STAGES | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListSystem: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_SYSTEM | ADMIN_PERMS.MANAGE_SYSTEM | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), + ListAdminToken: (perms?: bigint | string | null) => !!(toBigInt(perms) & (ADMIN_PERMS.LIST_ADMIN_TOKEN | ADMIN_PERMS.MANAGE_ADMIN_TOKEN | ADMIN_PERMS.LIST | ADMIN_PERMS.MANAGE)), +} as const + +export const ProjectAuthorized = { + Manage: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & PROJECT_PERMS.MANAGE), + + ListEnvironments: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_ENVIRONMENTS | PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE)), + ListRepositories: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_REPOSITORIES | PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.MANAGE)), + ListMembers: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_MEMBERS | PROJECT_PERMS.MANAGE_MEMBERS | PROJECT_PERMS.MANAGE)), + ListRoles: (perms: ProjectAuthorizedParams) => AdminAuthorized.ListProjects(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_ROLES | PROJECT_PERMS.MANAGE_ROLES | PROJECT_PERMS.MANAGE)), + + ManageEnvironments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_ENVIRONMENTS | PROJECT_PERMS.MANAGE)), + ManageRepositories: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_REPOSITORIES | PROJECT_PERMS.MANAGE)), + + ManageMembers: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_MEMBERS | PROJECT_PERMS.MANAGE)), + + ManageRoles: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_ROLES | PROJECT_PERMS.MANAGE)), + + ReplayHooks: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.REPLAY_HOOKS | PROJECT_PERMS.MANAGE)), + + SeeSecrets: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.SEE_SECRETS | PROJECT_PERMS.MANAGE)), + ManageDeployments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.MANAGE_DEPLOYMENTS | PROJECT_PERMS.MANAGE)), + ListDeployments: (perms: ProjectAuthorizedParams) => AdminAuthorized.Manage(perms.adminPermissions) + || !!(toBigInt(perms.projectPermissions) & (PROJECT_PERMS.LIST_DEPLOYMENTS | PROJECT_PERMS.MANAGE)), +} as const + +interface ScopePerm { + name: string + perms: Array<{ key: T, label: string, hint?: string }> +} +type PermDetails = Array> + +export const projectPermsDetails: PermDetails = [{ + name: 'Projet', + perms: [{ + key: 'MANAGE', + label: 'Gérer le projet', + hint: 'Permet de gérer tout le projet et ses ressources associées', + }, { + key: 'MANAGE_ROLES', + label: 'Gérer les rôles du projet', + hint: 'ATTENTION : Ce rôle inclut une élévation de privilège ! Permet de gérer les rôles du projet et les membres associés', + }, { + key: 'MANAGE_MEMBERS', + label: 'Gérer les membres du projet', + hint: 'Permet d\'inviter des utilisateurs et de les retirer', + }, { + key: 'LIST_ROLES', + label: 'Voir les rôles du projet', + hint: 'Permet de visualiser les rôles du projet', + }, { + key: 'LIST_MEMBERS', + label: 'Voir les membres du projet', + hint: 'Permet de visualiser les membres du projet', + }, { + key: 'SEE_SECRETS', + label: 'Afficher les secrets', + hint: 'Permet d\'afficher les secrets générés par les services', + }, { + key: 'REPLAY_HOOKS', + label: 'Reprovisionner le projet', + hint: 'Permet de lancer un reprovisionnage du projet', + }], +}, { + name: 'Environnement', + perms: [ + { + key: 'MANAGE_ENVIRONMENTS', + label: 'Gérer les environnements', + hint: 'Permet de créer, éditer, supprimer des environnements', + }, + { + key: 'LIST_ENVIRONMENTS', + label: 'Voir les environnements', + hint: 'Permet de visualiser tous les environnements et leurs configurations', + }, + ], +}, { + name: 'Dépôt', + perms: [ + { + key: 'MANAGE_REPOSITORIES', + label: 'Gérer les dépots', + hint: 'Permet de créer, éditer, supprimer des dépôts', + }, + { + key: 'LIST_REPOSITORIES', + label: 'Voir les dépôts', + hint: 'Permet de visualiser tous les dépôts et leurs configurations', + }, + ], +}, { + name: 'Déploiements', + perms: [ + { + key: 'MANAGE_DEPLOYMENTS', + label: 'Gérer les déploiements', + hint: 'Permet de créer, éditer, supprimer des déploiements', + }, + { + key: 'LIST_DEPLOYMENTS', + label: 'Voir les déploiements', + hint: 'Permet de visualiser tous les déploiements et leurs configurations', + }, + ], +}] as const + +export const adminPermsDetails: PermDetails = [{ + name: 'Global', + perms: [{ + key: 'MANAGE', + label: 'Administration globale', + hint: 'Administration globale de toute la console et de ses ressources', + }, { + key: 'LIST', + label: 'Lecture seule globale', + hint: 'Accès en lecture seule à toute la console et ses ressources', + }], +}, { + name: 'Gestion des utilisateurs', + perms: [{ + key: 'MANAGE_USERS', + label: 'Gérer les utilisateurs', + hint: 'Permet de gérer les utilisateurs de la console', + }, { + key: 'LIST_USERS', + label: 'Voir les utilisateurs', + hint: 'Permet de voir les utilisateurs de la console', + }], +}, { + name: 'Gestion des projets', + perms: [{ + key: 'MANAGE_PROJECTS', + label: 'Gérer les projets', + hint: 'Permet de gérer les projets de la console', + }, { + key: 'LIST_PROJECTS', + label: 'Voir les projets', + hint: 'Permet de voir les projets de la console', + }], +}, { + name: 'Gestion des rôles', + perms: [{ + key: 'MANAGE_ROLES', + label: 'Gérer les rôles', + hint: 'Permet de gérer les rôles de la console', + }, { + key: 'LIST_ROLES', + label: 'Voir les rôles', + hint: 'Permet de voir les rôles de la console', + }], +}, { + name: 'Infrastructure', + perms: [{ + key: 'MANAGE_CLUSTERS', + label: 'Gérer les clusters', + hint: 'Permet de gérer les clusters de la console', + }, { + key: 'LIST_CLUSTERS', + label: 'Voir les clusters', + hint: 'Permet de voir les clusters de la console', + }, { + key: 'MANAGE_ZONES', + label: 'Gérer les zones', + hint: 'Permet de gérer les zones de la console', + }, { + key: 'LIST_ZONES', + label: 'Voir les zones', + hint: 'Permet de voir les zones de la console', + }, { + key: 'MANAGE_STAGES', + label: 'Gérer les types d\'environnement', + hint: 'Permet de gérer les types d\'environnement de la console', + }, { + key: 'LIST_STAGES', + label: 'Voir les types d\'environnement', + hint: 'Permet de voir les types d\'environnement de la console', + }], +}, { + name: 'Système', + perms: [{ + key: 'MANAGE_SYSTEM', + label: 'Gérer le système', + hint: 'Permet de gérer les configurations et logs du système', + }, { + key: 'LIST_SYSTEM', + label: 'Voir le système', + hint: 'Permet de voir les configurations et logs du système', + }], +}, { + name: 'Jetons d’API', + perms: [{ + key: 'MANAGE_ADMIN_TOKEN', + label: 'Gérer les jetons d’API', + hint: 'Permet de créer et révoquer des jetons d’API admin', + }, { + key: 'LIST_ADMIN_TOKEN', + label: 'Voir les jetons d’API', + hint: 'Permet de lister les jetons d’API admin', + }], +}] as const + +export function getAdminPermLabelsByValue(value: bigint | string) { + value = typeof value === 'bigint' ? value : BigInt(value) + return adminPermsDetails + .map(section => section.perms) + .flat() + .filter(permDetail => ADMIN_PERMS[permDetail.key] & value) + .map(permDetail => permDetail.label) +} + +export function getProjectPermLabelsByValue(value: bigint | string) { + value = typeof value === 'bigint' ? value : BigInt(value) + return projectPermsDetails + .map(section => section.perms) + .flat() + .filter(permDetail => PROJECT_PERMS[permDetail.key] & value) + .map(permDetail => permDetail.label) +} diff --git a/.jjconflict-side-2/packages/shared/src/utils/plugins.ts b/.jjconflict-side-2/packages/shared/src/utils/plugins.ts new file mode 100644 index 0000000000..fd907ad899 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/utils/plugins.ts @@ -0,0 +1,42 @@ +export enum MonitorStatus { + OK = 'OK', + WARNING = 'Dégradé', + ERROR = 'En échec', + UNKNOW = 'Inconnu', +} + +export interface MonitorInfos { + lastUpdateTimestamp: number + interval: number + message: string + status: MonitorStatus + cause?: any // should be deserved to admins only +} +export class Monitor { + private intervalTime: number + public monitorFn: (instance: any) => Promise + private intervalID: NodeJS.Timeout | undefined + public lastStatus: MonitorInfos + + constructor(callback: (instance: Monitor) => Promise, interval: number = 5 * 60 * 1000) { + this.intervalTime = interval + this.monitorFn = () => { + this.lastStatus.cause = undefined + return callback(this) + } + this.lastStatus = { + interval: this.intervalTime, + lastUpdateTimestamp: Date.now(), + message: 'En attente d\'une première vérification', + status: MonitorStatus.UNKNOW, + cause: 'App just started', + } + } + + async refresh() { + if (this.intervalID) + clearInterval(this.intervalID) + this.intervalID = setInterval(() => this.monitorFn(this), this.intervalTime) + return this.monitorFn(this) + } +} diff --git a/.jjconflict-side-2/packages/shared/src/utils/roles.spec.ts b/.jjconflict-side-2/packages/shared/src/utils/roles.spec.ts new file mode 100644 index 0000000000..1124f52e69 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/utils/roles.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import { + generateSystemRoleType, + getBaseRoleType, + isExternalRoleType, + isGlobalRoleType, + isManagedRoleType, + isSystemRoleType, +} from './roles.js' + +describe('roles', () => { + describe('generateSystemRoleType', () => { + it('prefixes a base type with system:', () => { + expect(generateSystemRoleType('external')).toBe('system:external') + }) + + it('returns system:undefined for undefined input', () => { + expect(generateSystemRoleType(undefined)).toBe('system:undefined') + }) + + it('returns system:null for null input', () => { + expect(generateSystemRoleType(null)).toBe('system:null') + }) + }) + + describe('isSystemRoleType', () => { + it('returns true for system: prefixed values', () => { + expect(isSystemRoleType('system:external')).toBe(true) + expect(isSystemRoleType('system:managed')).toBe(true) + }) + + it('returns false for non-system values', () => { + expect(isSystemRoleType('external')).toBe(false) + expect(isSystemRoleType('managed')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isSystemRoleType(null)).toBe(false) + expect(isSystemRoleType(undefined)).toBe(false) + }) + }) + + describe('getBaseRoleType', () => { + it('strips system: prefix', () => { + expect(getBaseRoleType('system:external')).toBe('external') + expect(getBaseRoleType('system:managed')).toBe('managed') + }) + + it('returns non-system values as-is', () => { + expect(getBaseRoleType('external')).toBe('external') + expect(getBaseRoleType('managed')).toBe('managed') + }) + + it('returns undefined for null or undefined', () => { + expect(getBaseRoleType(null)).toBeUndefined() + expect(getBaseRoleType(undefined)).toBeUndefined() + }) + }) + + describe('isExternalRoleType', () => { + it('returns true for system:external', () => { + expect(isExternalRoleType('system:external')).toBe(true) + }) + + it('returns true for bare external', () => { + expect(isExternalRoleType('external')).toBe(true) + }) + + it('returns false for other system types', () => { + expect(isExternalRoleType('system:managed')).toBe(false) + expect(isExternalRoleType('system:global')).toBe(false) + }) + + it('returns false for non-external types', () => { + expect(isExternalRoleType('managed')).toBe(false) + expect(isExternalRoleType('global')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isExternalRoleType(null)).toBe(false) + expect(isExternalRoleType(undefined)).toBe(false) + }) + }) + + describe('isManagedRoleType', () => { + it('returns true for system:managed', () => { + expect(isManagedRoleType('system:managed')).toBe(true) + }) + + it('returns true for bare managed', () => { + expect(isManagedRoleType('managed')).toBe(true) + }) + + it('returns false for other types', () => { + expect(isManagedRoleType('external')).toBe(false) + expect(isManagedRoleType('global')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isManagedRoleType(null)).toBe(false) + expect(isManagedRoleType(undefined)).toBe(false) + }) + }) + + describe('isGlobalRoleType', () => { + it('returns true for system:global', () => { + expect(isGlobalRoleType('system:global')).toBe(true) + }) + + it('returns true for bare global', () => { + expect(isGlobalRoleType('global')).toBe(true) + }) + + it('returns false for other types', () => { + expect(isGlobalRoleType('external')).toBe(false) + expect(isGlobalRoleType('managed')).toBe(false) + }) + + it('returns false for null or undefined', () => { + expect(isGlobalRoleType(null)).toBe(false) + expect(isGlobalRoleType(undefined)).toBe(false) + }) + }) +}) diff --git a/.jjconflict-side-2/packages/shared/src/utils/roles.ts b/.jjconflict-side-2/packages/shared/src/utils/roles.ts new file mode 100644 index 0000000000..3512475e8e --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/utils/roles.ts @@ -0,0 +1,26 @@ +export const SYSTEM_ROLE_TYPE = 'system' as const + +export function generateSystemRoleType(type: string | null | undefined) { + return `${SYSTEM_ROLE_TYPE}:${type}` +} + +export function isSystemRoleType(type: string | null | undefined) { + return !!type?.startsWith(`${SYSTEM_ROLE_TYPE}:`) +} + +export function getBaseRoleType(type: string | null | undefined) { + if (!type) return undefined + return isSystemRoleType(type) ? type.slice(`${SYSTEM_ROLE_TYPE}:`.length) : type +} + +export function isManagedRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'managed' +} + +export function isGlobalRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'global' +} + +export function isExternalRoleType(type: string | null | undefined) { + return getBaseRoleType(type) === 'external' +} diff --git a/.jjconflict-side-2/packages/shared/src/utils/schemas.spec.ts b/.jjconflict-side-2/packages/shared/src/utils/schemas.spec.ts new file mode 100644 index 0000000000..5f0c422a13 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/utils/schemas.spec.ts @@ -0,0 +1,349 @@ +import type { Log, ProjectV2 } from '../index.js' +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { ZodError } from 'zod' +import { ClusterDetailsSchema, ClusterPrivacy, ClusterUsageSchema, descriptionMaxLength, EnvironmentSchema, instanciateSchema, LogSchema, parseZodError, ProjectSchemaV2, RepoSchema, StageSchema, UserSchema } from '../index.js' + +describe('schemas utils', () => { + it('should delete config in log', () => { + const toParse: Log = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + action: 'Create a log', + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + data: { + args: {}, + failed: false, + config: {}, + }, + requestId: faker.string.uuid(), + userId: faker.string.uuid(), + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + // @ts-ignore + delete parsed.data.config + expect(LogSchema + .safeParse(toParse)) + .toStrictEqual({ data: parsed, success: true }) + }) + + it('should not validate an undefined object', () => { + // @ts-ignore + expect(RepoSchema.safeParse(undefined).error).toBeInstanceOf(ZodError) + }) + + it('should validate a correct repository schema', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + externalToken: 'eddddsqsq-_', + isPrivate: true, + isInfra: false, + externalUserName: 'clai+re-nlet_', + } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a correct environment schema', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + autosync: faker.datatype.boolean(), + projectId: faker.string.uuid(), + clusterId: faker.string.uuid(), + stageId: faker.string.uuid(), + } + + expect(EnvironmentSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a correct project schema', () => { + const toParse: ProjectV2 = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + slug: faker.lorem.word({ length: { min: 2, max: 10 } }), + limitless: false, + hprodCpu: faker.number.int({ min: 0, max: 1000 }), + hprodGpu: faker.number.int({ min: 0, max: 1000 }), + hprodMemory: faker.number.int({ min: 0, max: 1000 }), + prodCpu: faker.number.int({ min: 0, max: 1000 }), + prodGpu: faker.number.int({ min: 0, max: 1000 }), + prodMemory: faker.number.int({ min: 0, max: 1000 }), + description: '', + status: 'created', + locked: false, + clusterIds: [], + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + members: [], + owner: { + // @ts-ignore la date doit être transformé en string + createdAt: new Date(), + // @ts-ignore la date doit être transformé en string + updatedAt: new Date(), + email: 'invalid-email@mais-pas-grave', + firstName: faker.person.firstName(), + id: faker.string.uuid(), + lastName: faker.person.lastName(), + type: 'human', + }, + everyonePerms: '1', + ownerId: faker.string.uuid(), + roles: [], + lastSuccessProvisionningVersion: null, + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + // @ts-ignore + parsed.owner.updatedAt = parsed.owner.updatedAt.toISOString() + // @ts-ignore + parsed.owner.createdAt = parsed.owner.createdAt.toISOString() + expect(ProjectSchemaV2.safeParse(toParse)).toStrictEqual({ data: parsed, success: true }) + }) + + it('should validate a correct user schema', () => { + const toParse = { + id: faker.string.uuid(), + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + adminRoleIds: [], + updatedAt: new Date(), + createdAt: new Date(), + type: 'human', + } + const parsed = structuredClone(toParse) + // @ts-ignore la date doit être transformé en string + parsed.createdAt = parsed.createdAt.toISOString() + // @ts-ignore + parsed.updatedAt = parsed.updatedAt.toISOString() + expect(UserSchema.safeParse(toParse)).toStrictEqual({ data: parsed, success: true }) + }) + + it('should validate a correct stage schema', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.lorem.word({ length: { min: 2, max: 10 } }), + clusterIds: [], + } + + expect(StageSchema.safeParse(toParse)).toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a repo business schema', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + externalUserName: 'clairenlet', + externalToken: 'myToken', + isPrivate: true, + isInfra: false, + } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should validate a public cluster details schema', () => { + const toParse = { + id: faker.string.uuid(), + label: 'cluster', + clusterResources: true, + privacy: ClusterPrivacy.PUBLIC, + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + zoneId: faker.string.uuid(), + stageIds: [faker.string.uuid(), faker.string.uuid()], + kubeconfig: { + user: {}, + cluster: { + tlsServerName: 'blabla', + }, + }, + } + + expect(ClusterDetailsSchema + .safeParse(toParse)) + .toStrictEqual({ data: { ...toParse, infos: '' }, success: true }) + }) + + it('should validate a cluster usage schema', () => { + const toParse = { + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + } + + expect(ClusterUsageSchema + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a repository schema with wrong internal repo name', () => { + const toParse = { + id: faker.string.uuid(), + projectId: faker.string.uuid(), + internalRepoName: '-candilib', + externalRepoUrl: 'https://github.com/LAB-MI/candilibV2.git', + isPrivate: false, + isInfra: false, + } + + // @ts-ignore + expect(parseZodError(RepoSchema + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candilib-' + + // @ts-ignore + expect(parseZodError(RepoSchema + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candiLib' + + // @ts-ignore + expect(parseZodError(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse) + .error)) + .toMatch('Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique at "internalRepoName"') + + toParse.internalRepoName = 'candi-lib' + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a too short project name', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.string.alpha({ casing: 'lower' }), + status: 'created', + locked: false, + description: '', + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + // @ts-ignore + expect(parseZodError(ProjectSchemaV2 + .safeParse(toParse) + .error)) + .toMatch('Validation error: String must contain at least 2 character(s) at "name"') + }) + + it('should not validate a too long project name', () => { + const toParse = { + id: faker.string.uuid(), + name: faker.string.alpha({ length: 24, casing: 'lower' }), + status: 'created', + locked: false, + description: '', + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + // @ts-ignore + expect(parseZodError(ProjectSchemaV2 + .safeParse(toParse) + .error)) + .toMatch('Validation error: String must contain at most 20 character(s) at "name"') + }) + + it('should not validate a too long project description', () => { + const toParse = { + id: faker.string.uuid(), + name: 'candilib', + description: faker.string.alpha(descriptionMaxLength + 1), + status: 'created', + locked: false, + createdAt: (new Date()).toISOString(), + updatedAt: (new Date()).toISOString(), + clusterIds: [], + } + + expect(ProjectSchemaV2 + .safeParse(toParse) + .error).toBeInstanceOf(ZodError) + }) + + it('should validate a single key with given schema', () => { + const toParse = { internalRepoName: 'candilib' } + + expect(RepoSchema + .pick({ internalRepoName: true }) + .safeParse(toParse)) + .toStrictEqual({ data: toParse, success: true }) + }) + + it('should not validate a single key with given schema', () => { + const toParse = { internalRepoName: 'candi lib' } + + expect(RepoSchema + .omit({ createdAt: true, updatedAt: true }) + .pick({ internalRepoName: true }) + .safeParse(toParse) + // @ts-ignore + .error).toBeInstanceOf(ZodError) + }) + + it('should return truthy schema', () => { + expect(instanciateSchema(RepoSchema.omit({ id: true }), true)).toMatchObject({ + internalRepoName: true, + externalRepoUrl: true, + externalToken: true, + isPrivate: true, + isInfra: true, + externalUserName: true, + projectId: true, + }) + }) + + it('should return true schema', () => { + expect(instanciateSchema(RepoSchema.omit({ id: true }), true)).toMatchObject({ + internalRepoName: true, + externalRepoUrl: true, + externalToken: true, + isPrivate: true, + isInfra: true, + externalUserName: true, + projectId: true, + }) + }) +}) diff --git a/.jjconflict-side-2/packages/shared/src/utils/schemas.ts b/.jjconflict-side-2/packages/shared/src/utils/schemas.ts new file mode 100644 index 0000000000..7330fa943e --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/utils/schemas.ts @@ -0,0 +1,20 @@ +// import Joi from 'joi' +import type { SafeParseReturnType, ZodError, ZodObject } from 'zod' +import { fromZodError } from 'zod-validation-error' + +export type SharedZodError = ZodError +export type SharedSafeParseReturnType = SafeParseReturnType +export const parseZodError = (zodError: ZodError) => fromZodError(zodError).toString() + +export function instanciateSchema, V extends boolean>(schema: T, value: V): Record { + const keys = schema.keyof()._def.values + // @ts-ignore + if (keys.length) { + // @ts-ignore + const entries = schema.keyof()._def.values?.map(key => [key, value]) + // @ts-ignore + return Object.fromEntries(entries) + } + // @ts-ignore + return {} +} diff --git a/.jjconflict-side-2/packages/shared/src/utils/types.ts b/.jjconflict-side-2/packages/shared/src/utils/types.ts new file mode 100644 index 0000000000..babed97bf2 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/src/utils/types.ts @@ -0,0 +1,21 @@ +export type ErrorTypes = 'info' | 'warning' | 'error' | 'success' + +export interface UserProfile { + email: string + id: string + firstName: string + lastName: string + groups: string[] +} + +export type ResourceById = Record + +export type ResourceByKey = Record + +export type Without = { [P in Exclude]?: never } + +export type XOR + = T extends object + ? U extends object + ? (Without & U) | (Without & T) + : U : T diff --git a/.jjconflict-side-2/packages/shared/tsconfig.eslint.json b/.jjconflict-side-2/packages/shared/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-2/packages/shared/tsconfig.json b/.jjconflict-side-2/packages/shared/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-2/packages/shared/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-2/packages/shared/vitest.config.ts b/.jjconflict-side-2/packages/shared/vitest.config.ts new file mode 100644 index 0000000000..fda276425b --- /dev/null +++ b/.jjconflict-side-2/packages/shared/vitest.config.ts @@ -0,0 +1,16 @@ +import { configDefaults, defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + testTimeout: 2000, + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**'], + exclude: ['**/types', '**/mocks', '**/*.spec.ts', '**/*.d.ts'], + }, + include: ['src/**/*.spec.{ts,js}'], + exclude: [...configDefaults.exclude, 'e2e/*'], + }, +}) diff --git a/.jjconflict-side-2/packages/test-utils/.gitignore b/.jjconflict-side-2/packages/test-utils/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-2/packages/test-utils/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-2/packages/test-utils/eslint.config.js b/.jjconflict-side-2/packages/test-utils/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-2/packages/test-utils/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-2/packages/test-utils/package.json b/.jjconflict-side-2/packages/test-utils/package.json new file mode 100644 index 0000000000..10d292744f --- /dev/null +++ b/.jjconflict-side-2/packages/test-utils/package.json @@ -0,0 +1,42 @@ +{ + "name": "@cpn-console/test-utils", + "type": "module", + "version": "1.0.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "format": "eslint ./ --fix", + "kube:e2e-ci": "echo 'check cache'", + "lint": "eslint ./", + "test:e2e-ci": "echo 'check cache'" + }, + "dependencies": { + "@cpn-console/shared": "workspace:^", + "@faker-js/faker": "catalog:test" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "rimraf": "catalog:tools", + "typescript": "catalog:build" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/packages/test-utils/src/imports/data.ts b/.jjconflict-side-2/packages/test-utils/src/imports/data.ts new file mode 100644 index 0000000000..e7bc172877 --- /dev/null +++ b/.jjconflict-side-2/packages/test-utils/src/imports/data.ts @@ -0,0 +1,2305 @@ +export const data = { + personalAccessToken: [], + adminToken: [ + { + id: 'e66a5ce2-440d-4a60-ac0d-d439bbaba040', + name: 'test', + hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', // test + permissions: '2n', + userId: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + }, + { + id: 'e66a5ce2-440d-4a60-ac0d-d439bbaba041', + name: 'test', + hash: '4bb47f186df233e48b09d241ee4defb821add0c35ac8311469fe1522c6813dd5', // revoked + permissions: '2n', + userId: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + status: 'revoked', + }, + ], + adminPlugin: [], + adminRole: [ + { + id: '76229c96-4716-45bc-99da-00498ec9018c', + permissions: '3n', + position: 0, + oidcGroup: '/admin', + name: 'Root Administrateur Plateforme', + type: 'system:external', + }, + { + id: '6bebe7b2-0f0a-456e-ab7f-b3d7640a7cbf', + permissions: '3n', + position: 0, + oidcGroup: '/console/admin', + name: 'Administrateur Plateforme', + type: 'system:managed', + }, + { + id: 'eadf604f-5f54-4744-bdfb-4793d2271e9b', + permissions: '2n', + position: 1, + oidcGroup: '', + name: 'Admin Locaux', + type: 'external', + }, + { + id: '35848aa2-e881-4770-9844-0c5c3693e506', + permissions: '1n', + position: 2, + oidcGroup: '/console/readonly', + name: 'Lecture Seule Plateforme', + type: 'system:managed', + }, + { + id: '034f589f-1750-4b15-bb34-4cd995e7fcaa', + permissions: '25608n', + position: 0, + oidcGroup: '', + name: 'Tout le monde', + type: 'global', + }, + ], + kubeconfig: [ + { + id: '2a88634a-0a60-459c-bf68-c4ffb12430a2', + user: { + keyData: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=', + certData: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + }, + cluster: { + server: 'https://public.server:6443', + skipTLSVerify: true, + tlsServerName: 'public.server', + }, + createdAt: '2024-07-24T16:54:14.961Z', + updatedAt: '2024-07-24T16:54:14.961Z', + }, + { + id: '2a88634a-0a60-459c-bf68-c4ffb12430a3', + user: { + keyData: 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=', + certData: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + }, + cluster: { + server: 'https://nowhere.server:6443', + skipTLSVerify: true, + tlsServerName: 'nowhere.server', + }, + createdAt: '2024-07-24T16:54:14.964Z', + updatedAt: '2024-07-24T16:54:14.964Z', + }, + { + id: '0e88f000-07e6-4781-a69d-0963489387f7', + user: { + token: 'nyan cat', + }, + cluster: { + server: 'https://nothere.cluster', + skipTLSVerify: false, + tlsServerName: 'nothere.cluster', + }, + createdAt: '2024-07-24T16:54:14.966Z', + updatedAt: '2024-07-24T16:54:14.966Z', + }, + { + id: 'b5662039-a62b-483e-ba54-b12c6f966c96', + user: { + token: 'kirikou', + }, + cluster: { + server: 'https://pwned.cluster', + tlsServerName: 'pwned.cluster', + }, + createdAt: '2024-07-24T16:54:14.969Z', + updatedAt: '2024-07-24T16:54:14.969Z', + }, + ], + zone: [ + { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce1', + slug: 'pr', + label: 'Zone privée', + argocdUrl: 'https://argocd.private-zone.fr', + description: 'Cette zone de déploiement est privée.', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + }, + { + id: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + slug: 'pub', + label: 'publique', + argocdUrl: 'https://argocd.public-zone.fr', + description: 'Zone de diffusion ouverte.', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + }, + ], + cluster: [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + label: 'unused-cluster', + privacy: 'public', + secretName: '3972ac09-6abc-4e49-83b6-d046da5260ed', + clusterResources: false, + memory: 8, + cpu: 10, + gpu: 0, + kubeConfigId: '2a88634a-0a60-459c-bf68-c4ffb12430a3', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2023-07-10T19:32:13.385Z', + infos: 'Cluster public non utilisé', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + label: 'top-secret-cluster', + privacy: 'dedicated', + secretName: '59be2d50-58f9-42f3-95dc-b0c0518e3d8a', + clusterResources: true, + memory: 8, + cpu: 10, + gpu: 0, + kubeConfigId: '0e88f000-07e6-4781-a69d-0963489387f7', + createdAt: '2023-07-10T19:49:31.691Z', + updatedAt: '2024-07-24T16:54:15.234Z', + infos: null, + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + label: 'pas-top-cluster', + privacy: 'dedicated', + secretName: '94d52618-7869-4192-b33e-85dd0959e815', + clusterResources: false, + memory: 30, + cpu: 20, + gpu: 0, + kubeConfigId: 'b5662039-a62b-483e-ba54-b12c6f966c96', + createdAt: '2023-07-10T19:49:31.697Z', + updatedAt: '2024-07-24T16:54:15.249Z', + infos: 'Floating IP : 0.0.0.0', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + label: 'public1', + privacy: 'public', + secretName: '3972ac09-6abc-4e49-83b6-d046da5260ec', + clusterResources: false, + memory: 100, + cpu: 100, + gpu: 100, + kubeConfigId: '2a88634a-0a60-459c-bf68-c4ffb12430a2', + createdAt: '2023-07-10T19:32:13.385Z', + updatedAt: '2024-07-24T16:54:15.261Z', + infos: 'Cluster public proposé par DSO', + zoneId: 'a66c4230-eba6-41f1-aae5-bb1e4f90cce2', + }, + ], + user: [ + { + id: 'c7712841-c0fd-40ff-abbb-9d914bcc907d', + firstName: 'Bot Admin', + lastName: 'test', + email: 'c7712841-c0fd-40ff-abbb-9d914bcc907d@bot.io', + createdAt: '2023-11-16T15:30:01.140Z', + updatedAt: '2023-11-16T15:30:01.140Z', + adminRoleIds: [], + type: 'bot', + lastLogin: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + { + id: '387216f1-3b87-4211-9cac-4371125e1175', + firstName: 'Admin', + lastName: 'ADMIN', + email: 'admin@test.com', + createdAt: '2023-07-03T18:01:52.884Z', + updatedAt: '2023-07-06T12:53:39.183Z', + adminRoleIds: [ + '76229c96-4716-45bc-99da-00498ec9018c', + ], + type: 'human', + }, + { + id: '04ac168a-2c4f-4816-9cce-af6c612e5912', + firstName: 'Anonymous', + lastName: 'User', + email: 'anon@user', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + type: 'ghost', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', + firstName: 'Arnaud', + lastName: 'TARDIF', + email: 'arnaud.tardif@test.com', + createdAt: '2023-07-03T14:46:56.773Z', + updatedAt: '2023-07-03T14:46:56.773Z', + adminRoleIds: [], + type: 'human', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + firstName: 'Jean', + lastName: 'DUPOND', + email: 'test@test.com', + createdAt: '2023-07-03T14:46:56.770Z', + updatedAt: '2023-07-03T14:46:56.770Z', + adminRoleIds: [], + type: 'human', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + firstName: 'Thibault', + lastName: 'COLIN', + email: 'thibault.colin@test.com', + createdAt: '2023-07-03T14:46:56.772Z', + updatedAt: '2024-07-25T16:18:11.372Z', + type: 'human', + adminRoleIds: [ + 'eadf604f-5f54-4744-bdfb-4793d2271e9b', + ], + }, + ], + log: [ + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1220', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + environment: 'staging', + registryHost: 'blabla.com', + repositories: [], + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'Create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.809Z', + requestId: null, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1221', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + environment: 'staging', + registryHost: 'blabla.com', + repositories: [], + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.819Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1222', + data: { + argo: { + status: { + result: 'OK', + message: 'Not an infra repository', + }, + }, + args: { + id: 'bd934af0-6de2-41b2-a111-6b0c45b82384', + status: 'initializing', + isInfra: false, + project: 'int-2', + createdAt: '2023-06-08T15:15:56.692Z', + isPrivate: false, + projectId: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + updatedAt: '2023-06-08T15:15:56.692Z', + environment: [], + internalUrl: 'https://blabla.com/bla/projects/mi/int-2/repo.git', + externalRepoUrl: 'https://github.com/dnum-mi/dso-console.git', + externalUserName: '', + internalRepoName: 'repo', + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 1, + }, + gitlab: { + vault: [ + { + data: { + GIT_INPUT_URL: 'github.com/dnum-mi/dso-console.git', + GIT_INPUT_USER: '', + GIT_OUTPUT_URL: 'blabla.com/bla/projects/mi/int-2/repo.git', + GIT_OUTPUT_USER: 'root', + GIT_INPUT_PASSWORD: '', + GIT_OUTPUT_PASSWORD: 'password', + }, + name: 'repo-mirror', + }, + ], + status: { + result: 'OK', + message: 'Created', + }, + }, + }, + action: 'Create Repository', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.819Z', + requestId: null, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1223', + data: { + argo: { + status: { + result: 'OK', + message: 'Not an infra repository', + }, + }, + args: { + id: 'bd934af0-6de2-41b2-a111-6b0c45b82384', + status: 'initializing', + isInfra: false, + project: 'int-2', + createdAt: '2023-06-08T15:15:56.692Z', + isPrivate: false, + projectId: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + updatedAt: '2023-06-08T15:15:56.692Z', + environment: [], + internalUrl: 'https://blabla.com/bla/projects/mi/int-2/repo.git', + externalRepoUrl: 'https://github.com/dnum-mi/dso-console.git', + externalUserName: '', + internalRepoName: 'repo', + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 1, + }, + gitlab: { + vault: [ + { + data: { + GIT_INPUT_URL: 'github.com/dnum-mi/dso-console.git', + GIT_INPUT_USER: '', + GIT_OUTPUT_URL: 'blabla.com/bla/projects/mi/int-2/repo.git', + GIT_OUTPUT_USER: 'root', + GIT_INPUT_PASSWORD: '', + GIT_OUTPUT_PASSWORD: 'password', + }, + name: 'repo-mirror', + }, + ], + status: { + result: 'OK', + message: 'Created', + }, + }, + }, + action: 'Create Repository', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1224', + data: { + args: { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + locked: true, + status: 'initializing', + project: 'int-2', + createdAt: '2023-06-08T15:14:53.517Z', + updatedAt: '2023-06-08T15:14:53.517Z', + description: '', + }, + nexus: { + user: { + roles: [ + 'mi-int-2-ID', + ], + source: 'default', + status: 'active', + userId: 'mi-int-2', + lastName: 'Luffy', + readOnly: false, + firstName: 'Monkey D.', + emailAddress: 'claire.nollet@interieur.gouv.fr', + externalRoles: [], + }, + vault: [ + { + data: { + NEXUS_PASSWORD: 'password', + NEXUS_USERNAME: 'mi-int-2', + }, + name: 'NEXUS', + }, + ], + status: { + result: 'OK', + message: 'User Created', + }, + }, + vault: { + status: { + result: 'OK', + }, + recordsSaved: 4, + }, + keycloak: { + group: { + id: '4a767b5c-1bf4-43b9-8164-5da76ded49a0', + }, + status: { + result: 'OK', + }, + }, + registry: { + vault: [ + { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + name: 'REGISTRY', + }, + ], + result: { + robot: { + id: 70, + name: 'robot$mi-int-2+ci', + secret: 'token', + expires_at: -1, + creation_time: '2023-06-08T15:14:55.813Z', + }, + project: { + name: 'mi-int-2', + metadata: { + public: 'false', + }, + owner_id: 1, + owner_name: 'admin', + project_id: 63, + repo_count: 0, + update_time: '2023-06-08T15:14:55.363Z', + creation_time: '2023-06-08T15:14:55.363Z', + cve_allowlist: { + id: 65, + items: [], + project_id: 63, + update_time: '0001-01-01T00:00:00.000Z', + creation_time: '0001-01-01T00:00:00.000Z', + }, + current_user_role_id: 1, + current_user_role_ids: [ + 1, + ], + }, + }, + status: { + result: 'OK', + message: 'Created', + }, + }, + sonarqube: { + user: { + user: { + name: 'mi-int-2', + email: 'claire.nollet@interieur.gouv.fr', + local: true, + login: 'mi-int-2', + active: true, + scmAccounts: [], + }, + }, + vault: [ + { + data: { + SONAR_TOKEN: 'token', + SONAR_PASSWORD: 'password', + SONAR_USERNAME: 'mi-int-2', + }, + name: 'SONAR', + }, + ], + result: {}, + status: { + result: 'OK', + message: 'User Created', + }, + }, + }, + action: 'Create Environment', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1225', + data: { + args: { + id: '1e4fdb28-f9ea-46d4-ad16-607c7f1aa8b6', + locked: false, + status: 'created', + project: 'int-2', + createdAt: '2023-06-08T15:14:53.517Z', + updatedAt: '2023-06-08T15:16:49.011Z', + description: '', + }, + nexus: { + status: { + result: 'OK', + message: 'User deleted', + }, + }, + vault: { + status: { + result: 'OK', + }, + secretsDestroyed: 5, + }, + gitlab: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + keycloak: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + registry: { + status: { + result: 'OK', + message: 'Deleted', + }, + }, + sonarqube: { + status: { + result: 'OK', + message: 'User anonymized', + }, + }, + }, + action: 'Delete Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1226', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1227', + data: { + argo: { + status: { + result: 'KO', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1228', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1229', + data: { + argo: { + status: { + result: 'KO', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1230', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1231', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1232', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1233', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1234', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1235', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1236', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1237', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1238', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1239', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1240', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae1241', + data: { + argo: { + status: { + result: 'OK', + }, + }, + args: { + owner: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + email: 'claire.nollet@interieur.gouv.fr', + lastName: 'Nollet', + createdAt: '2023-06-08T14:57:58.401Z', + firstName: 'Claire', + updatedAt: '2023-06-08T14:57:58.401Z', + }, + project: 'int-2', + repository: [], + environment: 'staging', + registryHost: 'blabla.com', + }, + vault: { + status: { + result: 'OK', + }, + pullSecret: { + data: { + HOST: 'blabla.com', + TOKEN: 'token', + USERNAME: 'robot$mi-int-2+ci', + DOCKER_CONFIG: '{"auths":{"blabla.com":{"auth":"token","email":""}}}', + }, + metadata: { + version: 1, + destroyed: false, + created_time: '2023-06-08T15:14:56.087835715Z', + deletion_time: '', + custom_metadata: null, + }, + }, + }, + keycloak: { + group: { + id: '6ce9c548-d0f6-490b-b32d-6e62c20e5eff', + }, + status: { + result: 'OK', + }, + roGroup: '/mi-int-2/staging/RO', + rwGroup: '/mi-int-2/staging/RW', + }, + }, + action: 'create Project', + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.788Z', + requestId: null, + projectId: null, + }, + ], + project: [ + { + id: '22e7044f-8414-435d-9c4a-2df42a65034b', + name: 'betaapp', + slug: 'betaapp', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.814Z', + updatedAt: '2023-07-03T14:46:56.817Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '9dabf3f9-6c86-4358-8598-65007d78df65', + name: 'projecttoarchive', + slug: 'projecttoarchive', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.824Z', + updatedAt: '2023-07-03T14:46:56.830Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + name: 'candilib', + slug: 'candilib', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application de réservation de places à l\'examen du permis B.', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.778Z', + updatedAt: '2023-07-03T14:46:56.783Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '011e7860-04d7-461f-912d-334c622d38c5', + name: 'basegun', + slug: 'basegun', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application d\'aide à la catégorisation d\'armes à feu.', + status: 'created', + locked: false, + createdAt: '2023-07-10T14:46:56.778Z', + updatedAt: '2023-07-10T14:46:56.783Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + name: 'psijfailed', + slug: 'psijfailed', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Application de transmission d\'informations entre agents de la PS et de l\'IJ.', + status: 'failed', + locked: true, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '8bd21636-e8ce-4098-8ed8-16cbd60af340', + name: 'projet dégradé', + slug: 'projet dégradé', + lastSuccessProvisionningVersion: '8.23.0', + description: 'Projet testé en état dégradé', + status: 'warning', + locked: true, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '554d9150-9a07-42c1-8207-1163f2f0addd', + name: 'pissenlit', + slug: 'pissenlit', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '896n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + { + id: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + name: 'rolistes', + slug: 'rolistes', + lastSuccessProvisionningVersion: '8.23.0', + description: '', + status: 'created', + locked: false, + createdAt: '2023-07-03T14:46:56.799Z', + updatedAt: '2023-07-03T14:46:56.806Z', + everyonePerms: '0n', + ownerId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + limitless: false, + hprodCpu: 100, + hprodGpu: 100, + hprodMemory: 100, + prodCpu: 100, + prodGpu: 100, + prodMemory: 100, + }, + ], + stage: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + name: 'dev', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + name: 'staging', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + name: 'integration', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + name: 'prod', + }, + ], + environment: [ + { + id: 'bc06ace5-ddf6-4f00-97fa-872922baf078', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.826Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '95ef0d9b-945e-4af6-851c-4c6685ceff20', + name: 'staging', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.829Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: '8d4503eb-64c7-407e-89db-6ab80865071f', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.855Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '3b0cf6c1-251b-4ec6-926f-b54ce1f82560', + name: 'staging', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.859Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: '1b9f1053-fcf5-4053-a7b2-ff8a2c0c1921', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.787Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '1c654f00-4798-4a80-929f-960ddb37885a', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '1c654f00-4798-4a80-929f-960ddb36774b', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '011e7860-04d7-461f-912d-334c622d38c5', + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.803Z', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '2805a1f5-0ca4-46a4-b3d7-5b649aee4a91', + name: 'integration', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + createdAt: '2023-07-03T14:46:56.808Z', + updatedAt: '2023-07-03T14:46:56.815Z', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stageId: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '2805a1f5-0ca4-46a4-b3d7-5b649aee4a92', + name: 'dev', + cpu: 2, + gpu: 0, + memory: 4, + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + createdAt: '2023-07-03T15:56:56.808Z', + updatedAt: '2023-07-03T15:56:56.815Z', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stageId: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + ], + projectClusterHistory: [ + { + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + clusterId: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + clusterId: '126ac57f-263c-4463-87bb-d4e9017056b2', + }, + { + projectId: '011e7860-04d7-461f-912d-334c622d38c5', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + }, + { + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + clusterId: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + }, + ], + projectMembers: [ + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6569', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f9599', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + roleIds: [], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f95b5', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + roleIds: [ + 'c77a1b96-377d-4aa3-bc94-65d4415f9595', + ], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + roleIds: [], + }, + { + userId: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + roleIds: [], + }, + ], + projectPlugin: [], + projectRole: [ + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f95b5', + name: 'bg75', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + permissions: '2n', + position: 0, + }, + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f9595', + name: 'bg78', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + permissions: '1n', + position: 0, + }, + { + id: 'c77a1b96-377d-4aa3-bc94-65d4415f9599', + name: 'level up', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + permissions: '0n', + position: 0, + }, + ], + repository: [ + { + id: '53891549-e628-4893-8bd3-92abcb71068a', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + internalRepoName: 'beta-front', + externalRepoUrl: 'https://github.com/dnum-mi/beta-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.825Z', + }, + { + id: '26ad0fee-c4bd-462c-a4f7-0f7a713b56f7', + projectId: '22e7044f-8414-435d-9c4a-2df42a65034b', + internalRepoName: 'beta-back', + externalRepoUrl: 'https://github.com/dnum-mi/beta-back.git', + externalUserName: '', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.819Z', + updatedAt: '2023-07-03T14:46:56.825Z', + }, + { + id: '83aa3c2a-cdae-4638-b95f-a02b3f31eace', + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + internalRepoName: 'archive-front', + externalRepoUrl: 'https://github.com/dnum-mi/archive-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.857Z', + }, + { + id: 'ad77aa10-a708-4fce-8d0c-15c1b4704309', + projectId: '9dabf3f9-6c86-4358-8598-65007d78df65', + internalRepoName: 'archive-back', + externalRepoUrl: 'https://github.com/dnum-mi/archive-back.git', + externalUserName: '', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.834Z', + updatedAt: '2023-07-03T14:46:56.854Z', + }, + { + id: '299216bb-2dcc-42b5-ac71-6aa001d2dccf', + projectId: '011e7860-04d7-461f-912d-334c622d38b3', + internalRepoName: 'candilib', + externalRepoUrl: 'https://github.com/dnum-mi/candilib.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.788Z', + updatedAt: '2023-07-03T14:46:56.802Z', + }, + { + id: 'c26df1a3-2e03-420d-9894-cd9ce4f98b60', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-back', + externalRepoUrl: 'https://github.com/dnum-mi/psij-back.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.813Z', + }, + { + id: '78f2b122-13cc-406f-bac8-9f0947a23172', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-front', + externalRepoUrl: 'https://github.com/dnum-mi/psij-front.git', + externalUserName: 'this-is-a-test', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '78f2b122-13cc-406f-bac8-9f0947b23172', + projectId: '83833faf-f654-40dd-bcd5-cf2e944fc702', + internalRepoName: 'psij-infra', + externalRepoUrl: 'https://github.com/dnum-mi/psij-infra.git', + externalUserName: 'this-is-a-test', + deployRevision: 'main', + deployPath: 'helm/', + helmValuesFiles: 'values.yaml,values.prod.yaml', + isInfra: true, + isPrivate: true, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '9d3e7791-ee67-4282-be95-613c491c2e75', + projectId: '554d9150-9a07-42c1-8207-1163f2f0addd', + internalRepoName: 'pissenlit', + externalRepoUrl: 'https://github.com/dnum-mi/pissenlit.git', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T14:46:56.809Z', + updatedAt: '2023-07-03T14:46:56.816Z', + }, + { + id: '9d3e7791-ee67-4282-be95-613c491c2e76', + projectId: '94c860ab-023f-4e6e-8a4e-ff41456e249b', + internalRepoName: 'warhammer', + externalRepoUrl: 'https://github.com/dnum-mi/warhammer.git', + deployRevision: '', + deployPath: '', + helmValuesFiles: '', + isInfra: false, + isPrivate: false, + createdAt: '2023-07-03T16:46:56.809Z', + updatedAt: '2023-07-03T16:46:56.816Z', + }, + ], + systemSetting: [ + { + key: 'maintenance', + value: 'off', + }, + + ], + associations: [ + [ + 'cluster', + [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + projects: [], + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + projects: [ + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + }, + ], + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + projects: [ + { + id: '011e7860-04d7-461f-912d-334c622d38b3', + }, + { + id: '83833faf-f654-40dd-bcd5-cf2e944fc702', + }, + { + id: '22e7044f-8414-435d-9c4a-2df42a65034b', + }, + { + id: '9dabf3f9-6c86-4358-8598-65007d78df65', + }, + ], + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + projects: [], + }, + ], + ], + [ + 'cluster', + [ + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1790', + stages: [], + }, + { + id: '126ac57f-263c-4463-87bb-d4e9017056b2', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + { + id: 'aaaaaaaa-5b03-45d5-847b-149dec875680', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + { + id: '32636a52-4dd1-430b-b08a-b2e5ed9d1789', + stages: [ + { + id: '4a9ad694-4c54-4a3c-9579-548bf4b7b1b9', + }, + { + id: '38fa869d-6267-441d-af7f-e0548fd06b7e', + }, + { + id: 'd434310e-7850-4d59-b47f-0772edf50582', + }, + { + id: '9b3e9991-896d-4d90-bdc5-a34be8c06b8f', + }, + ], + }, + ], + ], + ], + deployment: [], + deploymentSource: [], +} diff --git a/.jjconflict-side-2/packages/test-utils/src/index.ts b/.jjconflict-side-2/packages/test-utils/src/index.ts new file mode 100644 index 0000000000..51d39c09b7 --- /dev/null +++ b/.jjconflict-side-2/packages/test-utils/src/index.ts @@ -0,0 +1,5 @@ +export * from './imports/data.js' +export * from './utils/func-utils.js' +export * from './utils/random-project.js' +export * from './utils/random-utils.js' +export * from './utils/types.js' diff --git a/.jjconflict-side-2/packages/test-utils/src/utils/func-utils.ts b/.jjconflict-side-2/packages/test-utils/src/utils/func-utils.ts new file mode 100644 index 0000000000..da5c99a8b4 --- /dev/null +++ b/.jjconflict-side-2/packages/test-utils/src/utils/func-utils.ts @@ -0,0 +1,6 @@ +import type { User } from '@cpn-console/shared' + +export const repeatFn = (nb: number) => (fn: (optionalParam?: any) => any, optionalParam?: any) => Array.from({ length: nb }).map(() => fn(optionalParam)) + +// transform an array of UserModel in object-like usersStore +export const toUsersStore = (users: Array>): Record> => users.reduce((store, user) => ({ ...store, [user.id]: user }), {}) diff --git a/.jjconflict-side-2/packages/test-utils/src/utils/random-project.ts b/.jjconflict-side-2/packages/test-utils/src/utils/random-project.ts new file mode 100644 index 0000000000..73dcdf40bb --- /dev/null +++ b/.jjconflict-side-2/packages/test-utils/src/utils/random-project.ts @@ -0,0 +1,77 @@ +import type { User } from './types.js' +import { projectRoles } from '@cpn-console/shared' +import { repeatFn } from './func-utils.js' +import { + getRandomCluster, + getRandomEnv, + getRandomPerm, + getRandomProject, + getRandomRepo, + getRandomRole, + getRandomStage, + getRandomUser, + getRandomZone, +} from './random-utils.js' + +const basicStages = ['dev', 'staging', 'integration', 'prod'] + +export function createRandomDbSetup({ nbUsers = 1, nbRepo = 3, envs = basicStages }) { + // Create users + const users: User[] = repeatFn(nbUsers)(getRandomUser) + + // Create project + const project = getRandomProject() + + // Create Roles association table + project.roles = users.map(user => ({ + ...getRandomRole(user.id, project.id), + user, + })) + project.roles[0].role = projectRoles[0] + const ownerId = project.roles.find(role => role.role === 'owner')?.userId + + // @ts-ignore + project.members = project.roles.map(({ userId, user: { id: _, ...user }, role }) => { + return { + userId, + role, + ...user, + } + }) + + // Create zone + const zones = [getRandomZone()] + + // Create cluster + const clusters = [getRandomCluster({ projectIds: [project.id], zoneId: zones[0].id })] + // @ts-ignore + project.clusters = clusters + + // Create stages + const stages = basicStages.map(baseEnvironment => getRandomStage(baseEnvironment)) + stages.forEach((stage) => { + // @ts-ignore + stage.clusters = project.clusters + }) + + // Create repositories + project.repositories = repeatFn(nbRepo)(getRandomRepo, project.id) + + // Create environment + project.environments = envs + .map(env => getRandomEnv(env, project.id, stages[0].id, clusters[0].id)) + + // Create permissions + project.environments.forEach((env) => { + env.permissions = users.map(user => + user.id === ownerId ? getRandomPerm(env.id, user, 2) : getRandomPerm(env.id, user), + ) + }) + + return { + users, + stages, + zones, + project, + } +} diff --git a/.jjconflict-side-2/packages/test-utils/src/utils/random-utils.ts b/.jjconflict-side-2/packages/test-utils/src/utils/random-utils.ts new file mode 100644 index 0000000000..3f78ccf346 --- /dev/null +++ b/.jjconflict-side-2/packages/test-utils/src/utils/random-utils.ts @@ -0,0 +1,158 @@ +import type { AchievedStatus, ClusterDetails, ProjectRoles, Stage, Zone } from '@cpn-console/shared' +import type { Cluster, Environment, Log, Member, Permission, Project, Repository, Role, User } from './types.js' +import { achievedStatus, ClusterPrivacy, logActions, projectRoles } from '@cpn-console/shared' +import { faker } from '@faker-js/faker' +import { repeatFn } from './func-utils.js' + +export function getRandomProjectName() { + return faker.lorem.word() +} + +export function getRandomGitUrl() { + const url = `${faker.internet.url().split('.')[0]}.git` + return url.startsWith('https://') ? url : `https://${url.split('://')[1]}` +} + +export function getRandomProject(): Project & { status: AchievedStatus, members?: Member[] } { + const name = getRandomProjectName() + return { + id: faker.string.uuid(), + name, + slug: `${name}-1`, + description: faker.lorem.sentence(8), + status: faker.helpers.arrayElement(achievedStatus), + locked: false, + updatedAt: (new Date()).toISOString(), + createdAt: (new Date()).toISOString(), + } +} + +export function getRandomZone(): Zone { + return { + id: faker.string.uuid(), + slug: faker.lorem.word({ length: { min: 1, max: 10 } }), + label: faker.lorem.word({ length: { min: 1, max: 50 } }), + argocdUrl: faker.internet.url(), + description: faker.lorem.sentence(8), + } +} + +export function getRandomCluster({ projectIds = repeatFn(2)(faker.string.uuid), stageIds = repeatFn(2)(faker.string.uuid), privacy = faker.helpers.arrayElement(Object.values(ClusterPrivacy)), zoneId = faker.string.uuid() }: +{ projectIds?: string[], stageIds?: string[], privacy?: ClusterPrivacy, zoneId?: string }): ClusterDetails { + return { + id: faker.string.uuid(), + label: faker.lorem.word(), + infos: faker.lorem.sentence(8), + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + zoneId, + projectIds: privacy === ClusterPrivacy.DEDICATED ? projectIds : [], + stageIds, + kubeconfig: { + user: { + certData: 'userCAD', + keyData: 'userCKD', + }, + cluster: { + caData: 'clusterCAD', + server: 'https://coucou.com:5000', + tlsServerName: 'coucou.com', + }, + }, + privacy, + clusterResources: faker.datatype.boolean(), + } +} + +export function getRandomUser(id = faker.string.uuid()): User { + return { + id, + email: faker.internet.email(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + } +} + +export function getRandomRole(userId = faker.string.uuid(), projectId = faker.string.uuid(), role: ProjectRoles = projectRoles[1]): Role { + return { + userId, + role, + projectId, + } +} + +export function getRandomMember(userId = faker.string.uuid(), role: ProjectRoles = projectRoles[1]): Member { + return { + userId, + role, + } +} + +export function getRandomRepo(projectId = faker.string.uuid()): Repository { + const repo: Repository = { + id: faker.string.uuid(), + projectId, + internalRepoName: faker.lorem.word(), + externalRepoUrl: getRandomGitUrl(), + isPrivate: faker.datatype.boolean(), + isInfra: faker.datatype.boolean(), + } + if (repo.isPrivate) { + repo.externalUserName = faker.person.firstName() + repo.externalToken = faker.internet.password({ length: 25 }) + } + + return repo +} + +export function getRandomStage(name: string = faker.lorem.word({ length: { min: 2, max: 20 } }), links?: { + clusterIds?: string[] + clusters?: Cluster[] +}): Stage { + return { + id: faker.string.uuid(), + name, + clusterIds: links?.clusterIds ?? links?.clusters?.map(({ id }) => id) ?? [] as string[], + } +} + +export function getRandomEnv(name = faker.lorem.slug(1), projectId = faker.string.uuid(), stageId = faker.string.uuid(), clusterId = faker.string.uuid()): Environment { + return { + id: faker.string.uuid(), + name, + cpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + gpu: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + memory: faker.number.float({ min: 0, max: 10, fractionDigits: 1 }), + projectId, + stageId, + clusterId, + } +} + +export function getRandomPerm(environmentId = faker.string.uuid(), user = getRandomUser(), level = faker.number.int({ min: 0, max: 2 })): Permission { + return { + id: faker.string.uuid(), + environmentId, + userId: user.id, + level, + user, + } +} + +export function getRandomLog(action = faker.helpers.arrayElement(logActions), userId = faker.string.uuid()): Log { + return { + id: faker.string.uuid(), + action, + userId, + updatedAt: (new Date()).toISOString(), + createdAt: (new Date()).toISOString(), + data: { + args: {}, + failed: faker.datatype.boolean(), + results: {}, + totalExecutionTime: 1, + }, + requestId: null, + } +} diff --git a/.jjconflict-side-2/packages/test-utils/src/utils/types.ts b/.jjconflict-side-2/packages/test-utils/src/utils/types.ts new file mode 100644 index 0000000000..14eaf73bb8 --- /dev/null +++ b/.jjconflict-side-2/packages/test-utils/src/utils/types.ts @@ -0,0 +1,112 @@ +export interface Repository { + id: string + projectId: string + internalRepoName: string + externalRepoUrl: string + isPrivate: boolean + isInfra: boolean + createdAt?: Date + updatedAt?: Date + externalUserName?: string + externalToken?: string +} + +export interface User { + id: string + email: string + firstName: string + lastName: string + updatedAt?: Date + createdAt?: Date +} + +export interface Role { + userId: string + projectId: string + role: string + updatedAt?: Date + createdAt?: Date + user?: User +} + +export interface Member { + userId: User['id'] + role: string +} + +export interface Permission { + id: string + environmentId: string + userId: string + level: number + user: User + updatedAt?: Date + createdAt?: Date +} + +export interface Cluster { + id: string + label: string + infos?: string + projectIds?: string[] + stageIds?: string[] + zoneId?: string + user: { + certData: string + keyData: string + } + cluster: { + caData: string + server: string + tlsServerName: string + } + privacy: string + clusterResources: boolean + updatedAt?: Date + createdAt?: Date +} + +export interface Environment { + id: string + name: string + cpu: number + gpu: number + memory: number + projectId: string + clusterId: string + stageId: string + permissions?: Permission[] + cluster?: Cluster + updatedAt?: Date + createdAt?: Date +} + +export interface Log { + id: string + action: string + userId: string + createdAt: Date | string + updatedAt: Date | string + data: { + args: any + failed: boolean + results: any + totalExecutionTime: number + } + requestId: null +} + +export interface Project { + id: string + name: string + slug: string + description: string + status: string + locked: boolean + roles?: Role[] + clusters?: Cluster[] + repositories?: Repository[] + environments?: Environment[] + updatedAt?: Date | string + createdAt?: Date | string +} diff --git a/.jjconflict-side-2/packages/test-utils/tsconfig.eslint.json b/.jjconflict-side-2/packages/test-utils/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-2/packages/test-utils/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-2/packages/test-utils/tsconfig.json b/.jjconflict-side-2/packages/test-utils/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-2/packages/test-utils/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-2/packages/tsconfig/package.json b/.jjconflict-side-2/packages/tsconfig/package.json new file mode 100644 index 0000000000..f90b7b4aee --- /dev/null +++ b/.jjconflict-side-2/packages/tsconfig/package.json @@ -0,0 +1,16 @@ +{ + "name": "@cpn-console/ts-config", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "version": "1.1.0", + "private": false, + "description": "", + "main": "index.js", + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} \ No newline at end of file diff --git a/.jjconflict-side-2/packages/tsconfig/tsconfig.base.json b/.jjconflict-side-2/packages/tsconfig/tsconfig.base.json new file mode 100644 index 0000000000..6a08a00241 --- /dev/null +++ b/.jjconflict-side-2/packages/tsconfig/tsconfig.base.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + "jsx": "preserve", + "lib": ["ESNext"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + + // Base + "paths": { + "@/*": [ + "src/*" + ] + }, + "resolveJsonModule": true, + + "allowImportingTsExtensions": false, + "allowJs": true, + // Best practices + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + // Some stricter flags + "useUnknownInCatchVariables": false, + "declaration": true, + "emitDeclarationOnly": false, + // Output + "noEmit": false, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + } +} diff --git a/.jjconflict-side-2/pgadmin/servers-docker.json b/.jjconflict-side-2/pgadmin/servers-docker.json new file mode 100644 index 0000000000..d1710b3648 --- /dev/null +++ b/.jjconflict-side-2/pgadmin/servers-docker.json @@ -0,0 +1,22 @@ +{ + "Servers": { + "1": { + "Name": "dso-console-db", + "Group": "Servers", + "Host": "postgres", + "Port": 5432, + "MaintenanceDB": "dso-console-db", + "Username": "admin", + "SSLMode": "prefer", + "SSLCert": "/.postgresql/postgresql.crt", + "SSLKey": "/.postgresql/postgresql.key", + "SSLCompression": 0, + "BGColor": "#EEEDE7", + "FGColor": "#05445E", + "Timeout": 10, + "UseSSHTunnel": 0, + "TunnelPort": "22", + "TunnelAuthentication": 0 + } + } +} diff --git a/.jjconflict-side-2/pgadmin/servers-kube.json b/.jjconflict-side-2/pgadmin/servers-kube.json new file mode 100644 index 0000000000..4f16a15fae --- /dev/null +++ b/.jjconflict-side-2/pgadmin/servers-kube.json @@ -0,0 +1,22 @@ +{ + "Servers": { + "1": { + "Name": "dso-console-db", + "Group": "Servers", + "Host": "dso-cpn-console-db-hl", + "Port": 5432, + "MaintenanceDB": "dso-console-db", + "Username": "admin", + "SSLMode": "prefer", + "SSLCert": "/.postgresql/postgresql.crt", + "SSLKey": "/.postgresql/postgresql.key", + "SSLCompression": 0, + "BGColor": "#EEEDE7", + "FGColor": "#05445E", + "Timeout": 10, + "UseSSHTunnel": 0, + "TunnelPort": "22", + "TunnelAuthentication": 0 + } + } +} diff --git a/.jjconflict-side-2/playwright/README.md b/.jjconflict-side-2/playwright/README.md new file mode 100644 index 0000000000..d47e6602c9 --- /dev/null +++ b/.jjconflict-side-2/playwright/README.md @@ -0,0 +1,78 @@ +# À propos + +Ce dossier contient tout ce qui nous est nécessaire pour lancer les tests automatisés avec Playwright. + +Il peut s'agir de tests E2E, d'une brique en particulier, bref tout ce qui est actionnable à partir de l'interface utilisateur. + +## Installer Playwright + +Tout est déjà prévu dans le `pnpm install` que vous avez normalement déjà exécuté à la racine du MonoRepo. Si ce n'est pas le cas vous pouvez relancer la commande dans ce dossier. + +## Installer les dépendances (navigateurs, etc.) + +Afin de pouvoir lancer les tests sur différents navigateurs, Playwright a besoin d'un certain nombre de binaires. +Pour les installer localement il faut lancer la commande suivante : + +```shell +$ pnpm exec playwright install --with-deps +``` + +Note: Il est possible qu'il soit nécessaire que vous fassiez un `sudo` + +## Lancer les tests + +Il faut d'abord lancer une stack, par ex, celle de Dev: + +```shell +$ pnpm run docker:dev +``` +On peut lancer ensuite les tests : + +```shell +$ pnpm run playwright:test --grep @e2e +``` + +OU (autre possibilité), lancer la "console" Playwright pour parcourir/exécuter les tests manuellement : + +```shell +$ pnpm run playwright:test:ui --grep @e2e +``` + +Et pour les tests d'intégration, il faut au préalable exporter des variables d'environnement : + +```shell +$ export KEYCLOAK_DOMAIN=keycloak.example.com +$ export KEYCLOAK_REDIRECT_URI=https://console.example.com +$ export KEYCLOAK_PROTOCOL=https +$ export KEYCLOAK_ADMIN_USERNAME= +$ export KEYCLOAK_ADMIN_PASSWORD= +$ export KEYCLOAK_REALM=dso +$ export CONSOLE_ADMIN_USERNAME= +$ export CONSOLE_ADMIN_PASSWORD= +$ export CONSOLE_ADMIN_EMAIL= +$ export CONSOLE_TEST_USERNAME= +$ export CONSOLE_TEST_PASSWORD= +$ export CONSOLE_TEST_EMAIL= +$ export CONSOLE_SECOND_TEST_USERNAME= +$ export CONSOLE_SECOND_TEST_PASSWORD= +$ export CONSOLE_SECOND_TEST_EMAIL= +$ export CONSOLE_GLOBAL_TIMEOUT='900000' +$ export CONSOLE_EXPECT_TIMEOUT='900000' +$ export CONSOLE_DESTINATION_CLUSTER= +$ export CONSOLE_VALUES_FILE= +``` + +On peut lancer ensuite les tests : + +```shell +$ pnpm run playwright:test:integration --grep @integ +``` + +## Conventions de code + +Afin d'assurer la lisibilité et la maintenabilité des tests Playwright, quelques décisions ont été prises concernant l'écriture du code : +- Pas d'imbrication de if : les conditions sont autorisées dans les fonctions, mais ne doivent pas être imbriquées. +- Arguments explicites : certains arguments initialement optionnels doivent être rendus obligatoires afin de rendre le comportement de la fonction plus clair (par exemple pour les zones publiques, ou les contextes spécifiques). +- Visibilité des tests : il doit être évident, à la lecture du test, de voir ce qui est réellement vérifié. Par conséquent, évitez de masquer les expect à l'intérieur de fonctions utilitaires. Les assertions doivent apparaître directement dans le corps du test lorsqu'elles participent à la vérification d'un comportement. + +Ces conventions ont pour objectif de rendre les tests plus explicites, plus faciles à relire, et de réduire les effets de bord cachés. diff --git a/.jjconflict-side-2/playwright/config/console.ts b/.jjconflict-side-2/playwright/config/console.ts new file mode 100644 index 0000000000..35ea8a13c9 --- /dev/null +++ b/.jjconflict-side-2/playwright/config/console.ts @@ -0,0 +1,83 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +// Retrieve frontend URL from environment variables (see playwright.config.ts) +export const clientURL + = process.env.KEYCLOAK_REDIRECT_URI || 'http://change-me' + +export interface Credentials { + id: string + username: string + password: string + firstName: string + lastName: string + email: string +} + +// Users referenced in Keycloak dev realm (../keycloak/realms/realm-dev.json) +export const adminUser: Credentials = { + id: '387216f1-3b87-4211-9cac-4371125e1175', + username: process.env.CONSOLE_ADMIN_USERNAME?.trim() || 'admin', + password: process.env.CONSOLE_ADMIN_PASSWORD?.trim() || 'admin', + firstName: 'Admin', + lastName: 'ADMIN', + email: process.env.CONSOLE_ADMIN_EMAIL?.trim() || 'admin@test.com', +} +export const testUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6565', + username: process.env.CONSOLE_TEST_USERNAME?.trim() || 'test', + password: process.env.CONSOLE_TEST_PASSWORD?.trim() || 'test', + firstName: 'Jean', + lastName: 'DUPOND', + email: process.env.CONSOLE_TEST_EMAIL?.trim() || 'test@test.com', +} +export const cnolletUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + username: 'cnollet', + password: 'test', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', +} +export const tcolinUser: Credentials = { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6566', + username: 'tcolin', + password: 'test', + firstName: 'Thibault', + lastName: 'COLIN', + email: 'thibault.colin@test.com', +} + +// User for integration test +export const secondTestUser: Credentials = { + id: 'test', + username: process.env.CONSOLE_SECOND_TEST_USERNAME?.trim() || 'secondtestuser', + password: process.env.CONSOLE_SECOND_TEST_PASSWORD?.trim() || 'secondtestuser', + firstName: 'test', + lastName: 'test', + email: process.env.CONSOLE_SECOND_TEST_EMAIL?.trim() || 'secondtestuser@example.com', +} + +export const currentUser: Credentials = { + id: 'current', + username: process.env.CONSOLE_CURRENT_TEST_USERNAME?.trim() || 'currentuser', + password: process.env.CONSOLE_CURRENT_TEST_PASSWORD?.trim() || 'currentuser', + firstName: 'test', + lastName: 'test', + email: process.env.CONSOLE_CURRENT_TEST_EMAIL?.trim() || 'currentuser@example.com', +} + +export async function signInCloudPiNative({ + page, + credentials, +}: { + page: Page + credentials: Credentials +}) { + const { username, password } = credentials + await page.getByRole('link', { name: 'Se connecter' }).click() + await page.getByRole('textbox', { name: 'Username or email' }).fill(username) + await page.getByRole('textbox', { name: 'Password' }).fill(password) + await page.getByRole('button', { name: 'Sign In' }).click() + await expect(page.locator('#top')).toContainText('Cloud π Native') +} diff --git a/.jjconflict-side-2/playwright/config/keycloak.ts b/.jjconflict-side-2/playwright/config/keycloak.ts new file mode 100644 index 0000000000..e93138829a --- /dev/null +++ b/.jjconflict-side-2/playwright/config/keycloak.ts @@ -0,0 +1,38 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +export interface KeycloakConfig { + url: string + realm: string + adminUser: string + adminPass: string + clientFrontend: string + clientBackend: string +} + +export function loadKeycloakConfig(): KeycloakConfig { + const protocol = process.env.KEYCLOAK_PROTOCOL || 'http' + const domain = process.env.KEYCLOAK_DOMAIN || 'localhost' + const port = process.env.KEYCLOAK_PORT ? `:${process.env.KEYCLOAK_PORT}` : '' + const url = `${protocol}://${domain}${port}` + + return { + url, + realm: process.env.KEYCLOAK_REALM?.trim() || 'dso', + adminUser: process.env.KEYCLOAK_ADMIN_USERNAME?.trim() || 'admin', + adminPass: process.env.KEYCLOAK_ADMIN_PASSWORD?.trim() || 'admin', + clientFrontend: process.env.KEYCLOAK_CLIENT_FRONTEND || 'dso-console-frontend', + clientBackend: process.env.KEYCLOAK_CLIENT_BACKEND || 'dso-console-backend', + } +} + +export async function signInKeycloak( + page: Page, +) { + const keycloakConfig = loadKeycloakConfig() + await page.goto(keycloakConfig.url) + await page.locator('#username').fill(keycloakConfig.adminUser) + await page.locator('#password').fill(keycloakConfig.adminPass) + await page.locator('#kc-login').click() + await expect(page.getByRole('link', { name: 'Logo' })).toBeVisible() +} diff --git a/.jjconflict-side-2/playwright/e2e-tests/admin-logs.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/admin-logs.spec.ts new file mode 100644 index 0000000000..c368f1d939 --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/admin-logs.spec.ts @@ -0,0 +1,75 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +const adminLogsUrlRe = /\/admin\/logs/ +const logCountRe = /Total\s*:\s*(\d+)/ + +async function goToAdminLogs(page: Page) { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationLogs').click() + await expect(page).toHaveURL(adminLogsUrlRe) + await expect(page.getByTestId('logCountInfo')).toBeVisible() +} + +test.describe('Administration logs', { tag: '@e2e' }, () => { + test('Should display logs list, logged in as admin', async ({ page }) => { + await goToAdminLogs(page) + + const logCountText = await page.getByTestId('logCountInfo').textContent() + expect(logCountText).toBeTruthy() + const total = Number((logCountText || '').match(logCountRe)?.[1] || 0) + + const positionInfo = page.getByTestId('positionInfo').first() + const seePreviousPageBtn = page.getByTestId('seePreviousPageBtn').first() + const seeFirstPageBtn = page.getByTestId('seeFirstPageBtn').first() + const seeNextPageBtn = page.getByTestId('seeNextPageBtn').first() + const seeLastPageBtn = page.getByTestId('seeLastPageBtn').first() + + await expect(positionInfo).toContainText('sur') + await expect(seePreviousPageBtn).toBeDisabled() + await expect(seeFirstPageBtn).toBeDisabled() + + const logs = page.locator('[data-testid$="-json"]') + await expect(logs).toHaveCount(Math.min(10, total)) + + if (total > 10) { + await expect(seeNextPageBtn).toBeEnabled() + await expect(seeLastPageBtn).toBeEnabled() + + const firstLogText = await logs.first().textContent() + await seeNextPageBtn.click() + + await expect(seePreviousPageBtn).toBeEnabled() + await expect(seeFirstPageBtn).toBeEnabled() + await expect(positionInfo).toContainText('sur') + await expect(logs.first()).not.toHaveText(firstLogText || '') + + await seeFirstPageBtn.click() + await expect(seePreviousPageBtn).toBeDisabled() + await expect(seeFirstPageBtn).toBeDisabled() + } else { + await expect(seeNextPageBtn).toBeDisabled() + await expect(seeLastPageBtn).toBeDisabled() + } + }) + + test('Should toggle compact logs display, logged in as admin', async ({ page }) => { + await goToAdminLogs(page) + + await expect(page.locator('h1')).toContainText( + 'Journaux des services associés à la chaîne DSO', + ) + + const jsonBoxes = page.locator('.json-box') + await expect(jsonBoxes.first()).toBeVisible() + + await page.getByTestId('showLogsBtn').click() + await expect(jsonBoxes).toHaveCount(0) + + await page.getByTestId('showLogsBtn').click() + await expect(jsonBoxes.first()).toBeVisible() + }) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/admin-stages.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/admin-stages.spec.ts new file mode 100644 index 0000000000..0964f9f100 --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/admin-stages.spec.ts @@ -0,0 +1,131 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative, testUser } from '../config/console' + +import { createProject } from '../helpers/project' +import { createStage, deleteStage } from '../helpers/stage' + +test.describe('Stages administration page', { tag: '@e2e' }, () => { + test('should display default stages list', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + for (const stageName of ['dev', 'integration', 'prod', 'staging']) { + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + } + }) + + test('should create a custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'all' }) + // Validation de la création + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Check stage availability in environment form + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = faker.string.alpha(10).toLowerCase() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page.getByLabel('Choix de la zone cible').selectOption({ label: 'publique' }) + await page.getByLabel('Type d\'environnement').selectOption({ label: stageName }) + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page.getByLabel('Choix du cluster cible').selectOption({ label: 'public1' }) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + }) + + test('should update a custom stage', async ({ page }) => { + // Create stage (1 cluster) + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'first' }) + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + // Update stage (adding 1 extra cluster) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.locator('h1')).toContainText(`Informations du type d\'environnement ${stageName}`) + await expect(page.getByTestId('addStageBtn')).not.toBeVisible() + await expect(page.getByTestId('updateStageBtn')).toBeVisible() + await expect(page.getByTestId('updateStageBtn')).toBeEnabled() + await expect(page.getByTestId('nameInput')).toBeDisabled() + await expect(page.getByTestId('nameInput')).toHaveValue(stageName) + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(1) + await page.locator('.fr-tag').first().click() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(2) + await page.getByTestId('updateStageBtn').click() + // Check stage (2 clusters) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.locator('h1')).toContainText(`Informations du type d\'environnement ${stageName}`) + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(2) + }) + + test('should not be able to create a stage with an existing name', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + // Try to create a stage "dev" + await expect(page.getByTestId('addStageLink')).toBeVisible() + await page.getByTestId('addStageLink').click() + await page.getByTestId('nameInput').fill('dev') + await page.getByTestId('addStageBtn').click() + await expect(page.getByTestId('snackbar').getByText('Un type d\'environnement portant ce nom existe déjà')).toBeVisible() + }) + + test('should delete a custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'first' }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Delete custom stage + await deleteStage({ page, stageName }) + await expect(page.getByTestId(`stageTile-${stageName}`)).not.toBeVisible() + }) + + test('should not delete a used custom stage', async ({ page }) => { + // Create stage + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const stageName = await createStage({ page, associateToCluster: 'all' }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await expect(page.getByTestId(`stageTile-${stageName}`)).toBeVisible() + + // Use custom stage in an environment + await createProject({ page }) + const envName = faker.string.alpha(10).toLowerCase() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page.getByLabel('Choix de la zone cible').selectOption({ label: 'publique' }) + await page.getByLabel('Type d\'environnement').selectOption({ label: stageName }) + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page.getByLabel('Choix du cluster cible').selectOption({ label: 'public1' }) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Check custom stage in admin view + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationStages').click() + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.getByTestId('deleteStageZone')).not.toBeVisible() + await expect(page.getByTestId('associatedEnvironmentsZone')).toBeVisible() + await expect(page.getByTestId('associatedEnvironmentsZone')).toContainText('Le type d\'environnement ne peut être supprimé') + await expect(page.getByTestId('associatedEnvironmentsTable')).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable').getByText(envName), + ).toBeVisible() + }) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/admin-users.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/admin-users.spec.ts new file mode 100644 index 0000000000..82e3a15cb4 --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/admin-users.spec.ts @@ -0,0 +1,71 @@ +import type { Page } from '@playwright/test' +import { expect, test } from '@playwright/test' +import { setCheckbox, unsetCheckbox } from 'helpers/checkbox' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +interface ApiUser { + id: string + email: string + firstName: string + lastName: string + type?: string +} + +const adminUsersUrlRe = /\/admin\/users/ +const listUsersUrlRe = /\/api\/v1\/users(?:\?|$)/ + +async function goToAdminUsers(page: Page) { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationUsers').click() + await expect(page).toHaveURL(adminUsersUrlRe) + await expect(page.getByTestId('tableAdministrationUsers')).toBeVisible() +} + +test.describe('Administration users', { tag: '@e2e' }, () => { + test('Should display admin users, logged in as admin', async ({ page }) => { + const usersResponse = page.waitForResponse((res) => { + return ( + res.request().method() === 'GET' + && listUsersUrlRe.test(res.url()) + && res.status() >= 200 + && res.status() < 300 + ) + }) + + await goToAdminUsers(page) + + const users = (await (await usersResponse).json()) as ApiUser[] + expect(users.length).toBeGreaterThan(0) + + for (const user of users) { + const row = page.getByTestId(`user-${user.id}`) + await expect(row).toContainText(user.email) + await expect(row).toContainText(user.lastName) + await expect(row).toContainText(user.firstName) + await expect(row).toContainText('202') + } + + await setCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersDisplayId')) + for (const user of users) { + await expect(page.getByTestId(`user-${user.id}`)).toContainText(user.id) + } + + const botUser = users.find(u => u.email.includes('anon@user')) ?? users.find(u => u.type && u.type !== 'human') + if (botUser) { + await expect(page.getByTestId(`user-${botUser.id}`)).toBeVisible() + await setCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersHideBots')) + await expect(page.getByTestId(`user-${botUser.id}`)).toHaveCount(0) + await unsetCheckbox(page.getByTestId('input-checkbox-tableAdministrationUsersHideBots')) + } + + const rows = page.getByTestId('tableAdministrationUsers').locator('tbody tr') + expect(await rows.count()).toBeGreaterThanOrEqual(users.length) + + const search = page.getByTestId('tableAdministrationUsersSearch') + await expect(search).toBeVisible() + await search.fill(botUser?.email ?? users[0].email) + await expect(page.getByTestId('tableAdministrationUsers').locator('tbody tr')).toHaveCount(1) + }) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/clusters.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/clusters.spec.ts new file mode 100644 index 0000000000..e7917af0d2 --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/clusters.spec.ts @@ -0,0 +1,235 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' +import { createCluster, deleteCluster } from '../helpers/cluster' +import { createEnvironment } from '../helpers/environment' +import { openClustersAdministration } from '../helpers/navigation' +import { createProject } from '../helpers/project' + +test.describe('Clusters page', () => { + test('should create a public cluster', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'first', + }) + // Validate + await expect(page.getByTestId(`clusterZone-${clusterName}`)).toContainText( + 'publique', + ) + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Public', + ) + }) + + test('should update a public cluster', { tag: '@e2e' }, async ({ page }) => { + const clusterName2 = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'public', + }) + // Update + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('labelInput')).toHaveValue(clusterName) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await page.getByTestId('labelInput').fill(clusterName2) + await expect(page.getByTestId('updateClusterBtn')).toBeVisible() + await expect(page.locator('#projects-select')).not.toBeVisible() + await expect(page.locator('#privacy-select option[selected]')).toHaveText( + 'public', + ) + await page.getByTestId('memoryInput').fill('1') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('1') + await page.getByTestId('updateClusterBtn').click() + // Validate + await expect(page.getByTestId(`clusterResources-${clusterName2}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + }) + + test( + 'should create a dedicated cluster', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Validate + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Dédié', + ) + }, + ) + + test( + 'should associate a project to a dedicated cluster', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a dedicated project + const { name: projectName } = await createProject({ page }) + // Create the cluster + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Validate + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('updateClusterBtn')).toBeVisible() + await expect(page.locator('#projects-select')).toBeVisible() + await expect(page.locator('#privacy-select option[selected]')).toHaveText( + 'dedicated', + ) + // Associating specifically created project + await page.locator('#projects-select').click() + await expect( + page.locator('#projects-select .fr-tag--dismiss'), + ).toHaveCount(0) + await page + .getByTestId('choice-selector-search-projects-select') + .fill(projectName) + await page.locator('#projects-select .fr-tag').click() + await expect( + page.locator('#projects-select .fr-tag--dismiss'), + ).toHaveCount(1) + await page.getByTestId('updateClusterBtn').click() + // Validate + await expect(page.getByTestId(`clusterPrivacy-${clusterName}`)).toContainText( + 'Dédié', + ) + }, + ) + + test('should create a cluster even if given informations is longer than 200 chars but shorter than 1001 chars', async ({ + page, + }) => { + const informations = faker.string.alpha(1000).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + informations, + }) + // Validate + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('infosInput')).toHaveValue(informations) + }) + + test('should NOT create a cluster when given informations is longer than 1000 chars', async ({ + page, + }) => { + const clusterName = faker.string.alpha(10).toLowerCase() + const informations = faker.string.alpha(1001).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationClusters').click() + await page.getByTestId('addClusterLink').click() + await page.getByTestId('labelInput').fill(clusterName) + await page.getByTestId('infosInput').fill(informations) + await expect(page.getByTestId('addClusterBtn')).toBeDisabled() + }) + + test('should delete a cluster', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create + const clusterName = await createCluster({ + page, + confidentiality: 'dedicated', + }) + // Delete + await deleteCluster({ page, clusterName }) + // Validate + await page.getByTestId('projectsSearchInput').fill(clusterName) + await expect(page.getByTestId('noClusterMsg')).toBeVisible() + await expect(page.getByTestId('noClusterMsg')).toHaveText( + 'Aucun cluster trouvé', + ) + }) + + test( + 'should NOT delete a cluster with associated environment', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a public cluster + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'all', + }) + // Create a project and an environment using this cluster + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: clusterName, + cpuInput: '2', + gpuInput: '0', + }) + // Try to delete the cluster + await expect(page.getByText('Opération en cours...')).toHaveCount(0) + await openClustersAdministration({ page }) + await page.getByTestId('projectsSearchInput').fill(clusterName) + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('deleteClusterZone')).not.toBeVisible() + await expect( + page.getByText('Le cluster ne peut être supprimé'), + ).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable'), + ).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable').getByText(envName), + ).toBeVisible() + }, + ) + + test('should NOT update the cluster label if it has associated environment', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + // Create a public cluster + const clusterName = await createCluster({ + page, + zone: 'publique', + confidentiality: 'public', + associateStage: 'all', + }) + // Create a project and an environment using this cluster + await createProject({ page }) + await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: clusterName, + cpuInput: '2', + gpuInput: '0', + }) + // Verify that cluster label is disabled + await expect(page.getByText('Opération en cours...')).toHaveCount(0) + await openClustersAdministration({ page }) + await page.getByTestId('projectsSearchInput').fill(clusterName) + await page.getByTestId(`clusterLink-${clusterName}`).click() + await expect(page.getByTestId('labelInput')).toHaveValue(clusterName) + await expect(page.getByTestId('labelInput')).toBeDisabled() + }) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/dashboard.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/dashboard.spec.ts new file mode 100644 index 0000000000..cb72d47d0d --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/dashboard.spec.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + signInCloudPiNative, + testUser, +} from '../config/console' +import { createProject, projectSlugTextRegexp } from '../helpers/project' + +test.describe('Dashboard page', () => { + test( + 'Should display a project statuses', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { + name: projectName, + id: projectId, + slug: projectSlug, + } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + expect(await page.getByTestId('project-id').getAttribute('title')).toBe( + projectId, + ) + await expect(page.locator('h1')).toContainText(projectName) + }, + ) + + test( + 'Should add, display and edit description', + { tag: '@e2e' }, + async ({ page, browserName }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const description1 = 'Application de prise de rendez-vous en préfécture.' + const description2 + = 'Application d\'organisation de tournois de pétanque interministériels.' + + // Act 1 + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('setDescriptionBtn').click() + // Yep, we need that for now... + // @TODO Ensure setDescriptionBtn is properly chained to descriptionInput + if (browserName === 'chromium') { + await page.waitForTimeout(1000) + } + await page.getByTestId('descriptionInput').fill(description1) + await page.getByTestId('saveDescriptionBtn').click() + // Assert 1 + await expect(page.getByTestId('descriptionP')).toHaveText(description1) + + // Act 2 + await page.getByTestId('setDescriptionBtn').click() + if (browserName === 'chromium') { + await page.waitForTimeout(1000) + } + await page.getByTestId('descriptionInput').fill(description2) + await page.getByTestId('saveDescriptionBtn').click() + // Assert 2 + await expect(page.getByTestId('descriptionP')).toHaveText(description2) + }, + ) + + test('Should show project secrets', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await expect(page.getByTestId('projectSecretsZone')).not.toBeVisible() + await page.getByTestId('showSecretsBtn').click() + + // Assert + await expect(page.getByTestId('projectSecretsZone')).toBeVisible() + await expect(page.getByTestId('noProjectSecretsP')).toHaveText( + 'Aucun secret à afficher', + ) + }) + + test('Should replay hooks for project', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('snackbar')).toContainText( + 'Le projet a été reprovisionné avec succès', + ) + }) + + test( + 'Should not be able to access project secrets if not owner', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName, slug: projectSlug } = await createProject({ + page, + }) + + // Act + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('projectSecretsZone')).not.toBeVisible() + await expect(page.getByTestId('showSecretsBtn')).not.toBeVisible() + }, + ) + + // @TODO: This test's expectation was reversed in Cypress E2E tests ("you should not be able + // to archive someone else's project"), but the reality is that you actually can do that, + // and Playwright detects it as well. So, for the time being, and in order to make the test + // pass, we reversed this test's expectation: You indeed can (and therefore should be able to) + // archive someone else's project ! + test( + 'Should be able to archive a project if not owner', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName, slug: projectSlug } = await createProject({ + page, + }) + + // Act + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.getByTestId('showArchiveProjectBtn')).toBeVisible() + }, + ) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/deployment.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/deployment.spec.ts new file mode 100644 index 0000000000..77df873337 --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/deployment.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, testUser } from 'config/console' +import { createDeployment } from 'helpers/deployment' +import { createEnvironment } from 'helpers/environment' +import { createProject, deleteProject } from 'helpers/project' +import { createRepository } from 'helpers/repository' + +test.describe('Déploiement', { tag: '@e2e' }, () => { + let projectName: string + + test.beforeEach(async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name } = await createProject({ page }) + projectName = name + }) + + // Not working on CI (need to mock plugins....) + test.skip('should not be able to create a deployment without an environment or a repository', async ({ page }) => { + await page.getByRole('button', { name: 'Ajouter un nouveau déploiement' }).click() + await expect(page.getByText('Pour créer un déploiement, vous devez d\'abord créer un environnement et un dépôt.')).toBeVisible() + }) + + // Not working on CI (need to mock plugins....) + test.skip('should create a deployment', async ({ page }) => { + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + cpuInput: '2', + gpuInput: '0', + }) + const repoName = await createRepository({ page }) + + const deploymentName = await createDeployment({ page, envName, repoName, customStageName: 'dev' }) + await expect(page.getByText(deploymentName)).toBeVisible() + }) + + test.afterEach(async ({ page }) => { + if (!projectName) return + await deleteProject({ page, projectName }) + }) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/environments.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/environments.spec.ts new file mode 100644 index 0000000000..5035f890d1 --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/environments.spec.ts @@ -0,0 +1,335 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + cnolletUser, + signInCloudPiNative, + testUser, +} from '../config/console' + +import { unsetCheckbox } from '../helpers/checkbox' +import { createEnvironment, deleteEnvironment } from '../helpers/environment' +import { createProject } from '../helpers/project' + +test.describe('Environments page', { tag: '@e2e' }, () => { + test('should add environments to an existing project', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + cpuInput: '2', + gpuInput: '0', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + }) + + test('should not add environments to a project without enough hprod GPU', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ + page, + hprodResources: { + cpu: 10, + memory: 10, + gpu: 1, + }, + }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + memoryInput: '2', + gpuInput: '2', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).not.toBeVisible() + await expect( + page + .getByTestId('snackbar') + .getByText( + 'Le projet ne dispose pas de suffisamment de ressources : GPU.', + ), + ).toBeVisible() + }) + + test('should not add incorrect environments', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + + // Incorrect input + await page.getByTestId('environmentNameInput').click() + await page.getByTestId('environmentNameInput').fill(`${envName}-yolo`) + await expect( + page.getByRole('alert').getByText('Le nom de l\'environnment ne doit pas'), + ).toBeVisible() + + // Valid input + await page.getByTestId('cancelEnvironmentBtn').click() + await createEnvironment({ + page, + envName, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + // Second try with same name (invalid) + await createEnvironment({ + page, + envName, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect( + page + .getByTestId('snackbar') + .getByText('Ce nom d\'environnement est déjà pris'), + ).toBeVisible() + }) + + test('should alert cluster unavailability', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'Zone privée' }) + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: 'dev' }) + await expect(page.getByTestId('noClusterOptionAlert')).toBeVisible() + await expect(page.getByLabel('Choix du cluster cible')).not.toBeVisible() + + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'publique' }) + await expect(page.getByTestId('noClusterOptionAlert')).not.toBeVisible() + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + }) + + test('should display zone infos and cluster infos', async ({ page }) => { + const envName = faker.string.alpha(10).toLowerCase() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').fill(envName) + await page + .getByLabel('Choix de la zone cible') + .selectOption({ label: 'publique' }) + await expect(page.getByTestId('chosenZoneDescription')).toBeVisible() + + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: 'dev' }) + await page + .getByLabel('Choix du cluster cible') + .selectOption({ label: 'public1' }) + await expect(page.getByTestId('clusterInfos')).toBeVisible() + }) + + test('should update environment resources', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toBeVisible() + await expect(page.locator('#zone-select option[selected]')).toHaveText( + 'publique', + ) + await expect(page.locator('#stage-select option[selected]')).toHaveText( + 'dev', + ) + await expect(page.locator('#cluster-select option[selected]')).toHaveText( + 'public1', + ) + await expect(page.getByTestId('memoryInput')).toHaveValue('1') + await expect(page.getByTestId('cpuInput')).toHaveValue('1') + await expect(page.getByTestId('gpuInput')).toHaveValue('1') + await page.getByTestId('memoryInput').fill('1.5') + await page.getByTestId('cpuInput').fill('2') + await page.getByTestId('gpuInput').fill('10') + await page.getByTestId('putEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1.5GiB 2CPU 10GPU', + ) + }) + + test('should not update environment resources when cluster is too small', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toContainText( + '1GiB 1CPU 1GPU', + ) + + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toBeVisible() + await expect(page.locator('#zone-select option[selected]')).toHaveText( + 'publique', + ) + await expect(page.locator('#stage-select option[selected]')).toHaveText( + 'dev', + ) + await expect(page.locator('#cluster-select option[selected]')).toHaveText( + 'public1', + ) + await expect(page.getByTestId('memoryInput')).toHaveValue('1') + await expect(page.getByTestId('cpuInput')).toHaveValue('1') + await expect(page.getByTestId('gpuInput')).toHaveValue('1') + await page.getByTestId('memoryInput').fill('120') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('1') + await page.getByTestId('putEnvironmentBtn').click() + await expect( + page + .getByTestId('snackbar') + .getByText( + 'Le cluster ne dispose pas de suffisamment de ressources : Mémoire.', + ), + ).toBeVisible() + }) + + test('should delete an environment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + await deleteEnvironment({ page, projectName, envName }) + }) + + test('should not be able to delete an environment if not owner', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ + page, + members: [cnolletUser], + }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Sign off and login as another user (project member) + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: cnolletUser }) + // Select previously created project + await page.getByTestId('menuMyProjects').click() + await expect(page.getByTestId('createProjectLink')).toBeVisible() + await page.getByRole('link', { name: projectName }).click() + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + // Verify absence of delete button + await page.getByTestId(`environmentTr-${envName}`).click() + await expect( + page.getByTestId('showDeleteEnvironmentBtn'), + ).not.toBeVisible() + }) + + test('should not be able to delete an environment if project locked', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { id: projectId, name: projectName } = await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Sign off and login as admin to lock the project + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await page.getByTestId('handleProjectLockingBtn').click() + + // Sign off and login back as user + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: testUser }) + // Verify absence of delete button + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId(`environmentTr-${envName}`).click() + await expect( + page.getByTestId('showDeleteEnvironmentBtn'), + ).not.toBeVisible() + }) + + test('should show a warning if autosync is deactivated', async ({ + page, + }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + const envName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + await expect(page.getByTestId(`environmentTr-${envName}`)).toBeVisible() + + // Verify warning message + await page.getByTestId(`environmentTr-${envName}`).click() + await expect(page.getByTestId('input-checkbox-autosyncCbx')).toBeVisible() + await expect(page.getByTestId('input-checkbox-autosyncCbx')).toBeChecked() + await expect(page.getByTestId('noAutosyncAlert')).not.toBeVisible() + + // Act - Uncheck auto-sync to trigger warning message + await unsetCheckbox(page.getByTestId('input-checkbox-autosyncCbx')) + await expect(page.getByTestId('noAutosyncAlert')).toBeVisible() + await expect(page.getByTestId('noAutosyncAlert')) + .toHaveText('La synchronisation automatique est désactivée. Les déploiements devront être synchronisés manuellement.') + }) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/keycloak.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/keycloak.spec.ts new file mode 100644 index 0000000000..17c87fb6d4 --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/keycloak.spec.ts @@ -0,0 +1,185 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { loadKeycloakConfig, signInKeycloak } from '../config/keycloak' + +const keycloakConfig = loadKeycloakConfig() +const usersToDelete: string[] = [] +const groupsToDelete: string[] = [] + +export async function createUser(page: Page, usersToDelete: string[]) { + await page.getByRole('link', { name: 'Users' }).click() + await page.getByTestId('add-user').click() + const username = faker.internet.username() + usersToDelete.push(username) + await page + .getByRole('textbox', { name: 'Username' }) + .fill(username) + await page.getByTestId('email').fill(faker.internet.email()) + await page.getByTestId('user-creation-save').click() + await expect(page.getByLabel('The user has been created')).toBeVisible() + return username +} + +export async function createGroup(page: Page, groupsToDelete: string[]) { + await page.getByRole('link', { name: 'Groups' }).click() + await page.getByRole('button', { name: 'Create group' }).click() + const groupName = faker.string.alpha(10) + groupsToDelete.push(groupName) + await page.getByTestId('name').fill(groupName) + await page.getByTestId('createGroup').click() + await expect(page.getByLabel('Group created')).toBeVisible() + return groupName +} + +test.describe('Keycloak', () => { + test.beforeEach(async ({ page }) => { + await signInKeycloak(page) + }) + + test('should sign in to master realm', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + // expect also exists in sign in fuction but we check it explicitely here + // for documentation purposes + await expect(page.getByRole('link', { name: 'Logo' })).toBeVisible() + await page.getByTestId('realmSelector').click() + await expect(page.getByRole('menuitem', { name: keycloakConfig.realm })).toBeVisible() + }) + + test('should have a CπN realm', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByTestId('realmSelector').click() + await expect( + page.getByRole('menuitem', { name: keycloakConfig.realm }), + ).toBeVisible() + }) + + test.describe('CπN realm', () => { + test.beforeEach(async ({ page }) => { + await page.getByTestId('realmSelector').click() + await page.getByRole('menuitem', { name: keycloakConfig.realm }).click() + }) + + test('should have required Clients', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByTestId('realmSelector').click() + await page.getByRole('menuitem', { name: keycloakConfig.realm }).click() + await page.getByRole('link', { name: 'Clients' }).click() + await expect(page.getByRole('link', { name: keycloakConfig.clientFrontend })).toBeVisible() + await expect(page.getByRole('link', { name: keycloakConfig.clientBackend })).toBeVisible() + await page.getByRole('link', { name: 'Client scopes' }).click() + await expect(page.getByRole('link', { name: 'generic' })).toBeVisible() + }) + + test('should have required Clients scopes', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + await page.getByRole('link', { name: 'Client scopes' }).click() + await expect(page.getByRole('link', { name: 'generic' })).toBeVisible() + }) + + test('should create a user', { tag: '@e2e' }, async ({ page }) => { + await createUser(page, usersToDelete) + }) + + test('should create a group', { tag: '@e2e' }, async ({ page }) => { + await createGroup(page, groupsToDelete) + }) + + test('should link a user to a group', { tag: ['@e2e', '@integ'] }, async ({ page }) => { + const username = await createUser(page, usersToDelete) + const groupName = await createGroup(page, groupsToDelete) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .fill(groupName) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page.getByRole('button', { name: groupName }).click() + await page.getByTestId('members').click() + await page.getByRole('button', { name: 'Add member' }).click() + await page + .getByTestId('titleUsersinput') + .getByRole('textbox', { name: 'Search' }) + .fill(username) + await page + .getByTestId('titleUsersinput') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page.getByRole('checkbox', { name: 'Select row 0' }).check() + await page.getByTestId('add').click() + await expect(page.getByText('user added to the group')).toBeVisible() + }) + + test('should delete a group', { tag: '@e2e' }, async ({ page }) => { + const groupName = await createGroup(page, groupsToDelete) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .fill(groupName) + await page + .getByTestId('searchForGroups') + .getByRole('textbox', { name: 'Search' }) + .press('Enter') + await page + .getByRole('treeitem', { name: `${groupName} Actions` }) + .getByLabel('Actions') + .click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByTestId('confirm').click() + }) + + test('should delete a user', { tag: '@e2e' }, async ({ page }) => { + await createUser(page, usersToDelete) + await page.getByTestId('action-dropdown').click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByTestId('confirm').click() + }) + + test('cleanup Keycloak test data', { tag: '@integ' }, async ({ request }) => { + const params = new URLSearchParams() + params.append('grant_type', 'password') + params.append('client_id', 'admin-cli') + params.append('username', keycloakConfig.adminUser) + params.append('password', keycloakConfig.adminPass) + + const tokenRes = await request.post( + `${keycloakConfig.url}/realms/master/protocol/openid-connect/token`, + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: params.toString(), + }, + ) + + if (!tokenRes.ok) { + throw new Error(`Failed to get token: ${tokenRes.statusText}`) + } + + const tokenData = await tokenRes.json() + const authHeader = { Authorization: `Bearer ${tokenData.access_token}` } + + for (const username of usersToDelete) { + const usersRes = await request.get( + `${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users?username=${username}`, + { headers: authHeader }, + ) + const users = await usersRes.json() + for (const user of users) { + await request.delete(`${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/users/${user.id}`, { + headers: authHeader, + }) + } + } + + for (const groupName of groupsToDelete) { + const groupsRes = await request.get( + `${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/groups?search=${groupName}`, + { headers: authHeader }, + ) + const groups = await groupsRes.json() + for (const group of groups) { + await request.delete(`${keycloakConfig.url}/admin/realms/${keycloakConfig.realm}/groups/${group.id}`, { + headers: authHeader, + }) + } + } + }) + }) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/profile.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/profile.spec.ts new file mode 100644 index 0000000000..0794c9c6e5 --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/profile.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test' + +import { + clientURL, + cnolletUser, + signInCloudPiNative, + tcolinUser, +} from '../config/console' + +test.describe('Profile page', () => { + test('Should display name once logged', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + + await expect(page.getByTestId('menuUserList')).toContainText( + `${cnolletUser.firstName} ${cnolletUser.lastName}`, + ) + }) + + test('Should display profile infos', { tag: '@e2e' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.goto(`${clientURL}/profile/info`) + + const locator = expect(page.getByTestId('profileInfos')) + await locator.toContainText( + `${tcolinUser.lastName}, ${tcolinUser.firstName}`, + ) + await locator.toContainText(tcolinUser.id) + await locator.toContainText('Admin') + await locator.toContainText(tcolinUser.email) + }) + + test('Should create a PAT', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.goto(`${clientURL}/profile/tokens`) + + // Act + await page.getByTestId('showNewTokenFormBtn').click() + await page.getByTestId('newTokenName').fill('test2') + await page.getByTestId('expirationDateInput').fill('2100-11-22') + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('newTokenPassword')).toBeVisible() + await page.getByTestId('showNewTokenFormBtn').click() + + // Assert + expect(page.getByTestId('newTokenPassword')).toBeDefined() + const locator = page.locator('tr', { hasText: 'test2' }) + expect(locator).toContainText(new Date().getFullYear().toString()) + expect(locator).toContainText('2100') + expect(locator).toContainText('Jamais') + expect(locator).toContainText('Actif') + await locator.getByTitle('Supprimer').click() + await page.getByTestId('confirmDeletionBtn').click() + await expect(page.getByTestId('tokenTable')).toContainText( + 'Aucune clé d\'api existante', + ) + }) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/project-logs.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/project-logs.spec.ts new file mode 100644 index 0000000000..78c0798827 --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/project-logs.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test' +import { + clientURL, + signInCloudPiNative, + tcolinUser, + testUser, +} from '../config/console' +import { createProject } from '../helpers/project' + +test.describe('Project logs page', () => { + test( + 'Should display project logs as owner', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + // Act + await createProject({ page }) + + // Assert + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + }, + ) + + test( + 'Should display additional logs after reprovisionning a project', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + + // Act + await page.getByTestId('replayHooksBtn').click() + + // Assert + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 7', + ) + }, + ) + + test( + 'Should display project logs as project manager as well as a project member', + { tag: '@e2e' }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + // Act + const { name: projectName } = await createProject({ + page, + members: [tcolinUser], + }) + + // Assert - as Project Owner + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 6', + ) + + // Assert - as Project Member + await page.getByRole('link', { name: 'Se Déconnecter' }).click() + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-logs').click() + await expect(page.locator('#panel-logs')).toBeVisible() + await expect(page.getByTestId('positionInfo')).toContainText( + '1 - 5 sur 7', + ) + }, + ) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/project-roles.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/project-roles.spec.ts new file mode 100644 index 0000000000..23e9cf19fc --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/project-roles.spec.ts @@ -0,0 +1,201 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { setCheckbox, unsetCheckbox } from 'helpers/checkbox' +import { clientURL, cnolletUser, signInCloudPiNative, testUser } from '../config/console' +import { createEnvironment } from '../helpers/environment' +import { createProject, deleteProject } from '../helpers/project' +import { createRepository } from '../helpers/repository' + +async function openProjectByName({ page, projectName }: { page: Page, projectName: string }) { + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() +} + +async function openProjectRoleByName({ page, roleName }: { page: Page, roleName: string }) { + await page.getByTestId('test-tab-roles').click() + await page + .locator('[data-testid$="-tab"]') + .filter({ hasText: roleName }) + .click() +} + +async function assignPerms({ + page, + roleName, + perms, +}: { + page: Page + roleName: string + perms: readonly string[] +}) { + await openProjectRoleByName({ page, roleName }) + const setPermChecked = async (key: string, checked: boolean) => { + const input = page.locator(`#${key}-cbx`) + await expect(input).toBeVisible() + if ((await input.isChecked()) === checked) + return + + const label = page.locator(`label[for="${key}-cbx"]`) + if (await label.count()) { + await label.click() + } else { + await setCheckbox(input) + } + + try { + await expect(input).toBeChecked({ checked }) + } catch { + await input.evaluate((el: HTMLInputElement, nextChecked: boolean) => { + el.checked = nextChecked + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, checked) + await expect(input).toBeChecked({ checked }) + } + } + + for (const key of perms) { + await setPermChecked(key, true) + } + await expect(page.getByTestId('saveBtn')).toBeEnabled() + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') +} + +test.describe.serial('Project roles', { tag: '@e2e' }, () => { + const newRoleName = `role-${faker.string.alpha(10).toLowerCase()}` + let projectName = '' + let repositoryName = '' + let environmentName = '' + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + + const project = await createProject({ page, members: [cnolletUser] }) + projectName = project.name + + repositoryName = await createRepository({ page }) + environmentName = await createEnvironment({ + page, + zone: 'publique', + customStageName: 'dev', + customClusterName: 'public1', + }) + + await page.getByTestId('test-tab-roles').click() + await page.getByTestId('addRoleBtn').click() + await expect(page.getByTestId('snackbar')).toContainText('Rôle ajouté') + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRoleName) + await page.getByTestId('saveBtn').click() + + await page.getByRole('tab', { name: 'Membres' }).click() + await setCheckbox(page.getByTestId(`input-checkbox-${cnolletUser.id}-cbx`)) + await page.close() + }) + + test.afterAll(async ({ browser }) => { + if (!projectName) + return + const page = await browser.newPage() + try { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await deleteProject({ page, projectName }) + } finally { + await page.close() + } + }) + + test('Should not grant perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + await openProjectByName({ page, projectName }) + + await page.getByTestId('test-tab-team').click() + await expect(page.getByTestId('teamTable').getByText('Retirer du projet')).toBeVisible() + await expect(page.getByTestId('addUserSuggestionInput')).toHaveCount(0) + await expect(page.getByTestId('showTransferProjectBtn')).toHaveCount(0) + await expect(page.getByTestId('test-tab-roles')).toBeVisible() + }) + + test('System roles forbid edits', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + + await openProjectRoleByName({ page, roleName: 'DevOps' }) + + await expect(page.getByTestId('roleNameInput')).toBeDisabled() + await expect(page.locator('#LIST_ENVIRONMENTS-cbx')).toBeDisabled() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('deleteBtn')).toHaveCount(0) + }) + + test('System roles allow member assignment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + await openProjectRoleByName({ page, roleName: 'DevOps' }) + + const membersTab = page.getByRole('tab', { name: 'Membres' }) + await expect(membersTab).toBeVisible() + await membersTab.click() + + const memberCheckbox = page.getByTestId(`input-checkbox-${cnolletUser.id}-cbx`) + await expect(memberCheckbox).toBeVisible() + + await setCheckbox(memberCheckbox) + await expect(memberCheckbox).toBeChecked() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') + + await unsetCheckbox(memberCheckbox) + await expect(memberCheckbox).not.toBeChecked() + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour') + }) + + test('Should assign view perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await openProjectByName({ page, projectName }) + await assignPerms({ + page, + roleName: newRoleName, + perms: ['LIST_ENVIRONMENTS', 'LIST_REPOSITORIES'], + }) + }) + + test('Should grant view perms', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: cnolletUser }) + await openProjectByName({ page, projectName }) + await page.getByTestId('test-tab-resources').click() + + await expect(page.getByTestId('noReposTr')).toHaveCount(0) + await expect(page.getByTestId('addRepoLink')).toBeHidden() + await page.getByTestId(`repoTr-${repositoryName}`).click() + await expect(page.getByTestId('syncRepoBtn')).toHaveCount(0) + await expect(page.getByTestId('updateRepoBtn')).toHaveCount(0) + await expect(page.getByTestId('showDeleteRepoBtn')).toHaveCount(0) + await page.locator('.fr-modal__header > button.fr-btn--close').click() + + await expect(page.getByTestId('noEnvsTr')).toHaveCount(0) + await expect(page.getByTestId('addEnvironmentLink')).toBeHidden() + await page.getByTestId(`environmentTr-${environmentName}`).click() + await expect(page.getByTestId('putEnvironmentBtn')).toHaveCount(0) + await expect(page.getByTestId('showDeleteEnvironmentBtn')).toHaveCount(0) + await page.locator('.fr-modal__header > button.fr-btn--close').click() + + await page.getByTestId('test-tab-team').click() + await expect(page.getByTestId('teamTable').getByText('Retirer du projet')).toBeVisible() + await expect(page.getByTestId('addUserSuggestionInput')).toHaveCount(0) + await expect(page.getByTestId('showTransferProjectBtn')).toHaveCount(0) + + await page.getByTestId('test-tab-roles').click() + await expect(page.getByTestId('insuficientPermsRoles')).toBeVisible() + }) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/projects.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/projects.spec.ts new file mode 100644 index 0000000000..0cf0d62384 --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/projects.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from '@playwright/test' +import { + adminUser, + clientURL, + cnolletUser, + signInCloudPiNative, + testUser, +} from '../config/console' +import { deleteValidationInput } from '../helpers/constants' +import { createProject } from '../helpers/project' +import { createRepository, synchronizeBranchOnRepository } from '../helpers/repository' + +test.describe('Projects page', () => { + test( + 'Should display only projects that user is member of', + { tag: '@e2e' }, + async ({ page }) => { + // Create a project as one user + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName } = await createProject({ page }) + + // Sign off and login as another user + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: cnolletUser }) + + // Previously created project should not appear for this user + await page.getByTestId('menuMyProjects').click() + await expect(page.getByTestId('createProjectLink')).toBeVisible() + await expect( + page.getByRole('link', { name: projectName }), + ).not.toBeVisible() + }, + ) + + test( + 'Should not keep the same default branch name for all repositories of a projects', + { tag: '@e2e' }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page }) + // Adding a project directly puts us on the newly created project page, + // so no need to add navigation steps to go there + const firstRepositoryName = await createRepository({ page }) + const branchName = await synchronizeBranchOnRepository({ + page, + repositoryName: firstRepositoryName, + }) + const secondRepositoryName = await createRepository({ page }) + await page.getByTestId(`repoTr-${secondRepositoryName}`).click() + await expect(page.getByTestId('resource-modal')).toBeVisible() + await expect(page.getByTestId('branchNameInput')).not.toHaveValue( + branchName, + ) + }, + ) + + // @TODO Archiving and Deleting a project is, basically, the same thing. It seems that + // initially we wanted to delete projects but we moved on to archiving, while not updating + // the frontend x) + test( + 'Should archive a project, when logged in as an admin', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + const { id: projectId, name: projectName } = await createProject({ page }) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Tous') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('archiveProjectInput')).not.toBeVisible() + await page.getByTestId('showArchiveProjectBtn').click() + await expect(page.getByTestId('confirmDeletionBtn')).toBeDisabled() + await page.getByTestId('archiveProjectInput').fill(deleteValidationInput) + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Archivés') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + // Projects are renamed (suffixed with a timestamp and `_archived`) so @TODO: Do Better… + await expect(page.getByRole('table', { name: 'Liste des projets' })).toContainText(projectName) + }, + ) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/repos.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/repos.spec.ts new file mode 100644 index 0000000000..41362ea716 --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/repos.spec.ts @@ -0,0 +1,742 @@ +import { expect, test } from '@playwright/test' +import { + clientURL, + signInCloudPiNative, + tcolinUser, + testUser, +} from '../config/console' + +import { setCheckbox, unsetCheckbox } from '../helpers/checkbox' +import { + deleteValidationInput, + fakeToken, + invalidGitUrlErrorMessage, + invalidInternalRepoErrorMessage, +} from '../helpers/constants' +import { createProject, projectSlugTextRegexp } from '../helpers/project' + +test.describe('Repositories', () => { + // @TODO: Rework this Cypress-inherited test (use of following-sibling is a + // good hint that something is very very wrong) + test( + 'Should handle repository form validation', + { tag: ['@e2e', '@repos', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + internalRepoName: 'repo00', + externalRepoUrl: 'https://github.com/externalUser01/repo00.git', + externalUserName: 'user', + externalToken: 'videnden88EHEBdldd_T0k9n', + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + + await page.getByTestId('addRepoLink').click() + await page.locator('dialog').waitFor({ state: 'visible' }) + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await setCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await unsetCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('internalRepoNameInput').fill('$%_>') + await expect( + page + .getByTestId('internalRepoNameInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidInternalRepoErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await expect( + page + .getByTestId('internalRepoNameInput') + .locator('//following-sibling::*[1]'), + ).not.toBeVisible() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.slice(0, -4)) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.slice(8)) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill(repo.externalRepoUrl.replace('https', 'http')) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).toContainText(invalidGitUrlErrorMessage) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await expect( + page + .getByTestId('externalRepoUrlInput') + .locator('//following-sibling::*[1]'), + ).not.toBeVisible() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await page + .getByTestId('externalUserNameInput') + .fill(repo.externalUserName) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalUserNameInput').clear() + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await page.getByTestId('externalTokenInput').clear() + await expect(page.getByTestId('addRepoBtn')).toBeDisabled() + await unsetCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await expect(page.getByTestId('addRepoBtn')).toBeEnabled() + }, + ) + + test( + 'Should add a standalone public repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + internalRepoName: 'repo00', + isInfra: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await setCheckbox( + page.getByTestId('standaloneRepoSwitch').locator('input'), + ) + await page.getByTestId('addRepoBtn').click() + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.locator('h2')).not.toContainText( + 'Synchroniser le dépôt', + ) + }, + ) + + test( + 'Should add an external public repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser01/repo01.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo01', + isInfra: false, + isPrivate: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external private repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser02/repo02.git', + externalToken: 'hoqjC1vXtABzytBIWBXsdyzubmqMYkgA', + externalUserName: '', + internalRepoName: 'repo02', + isInfra: false, + isPrivate: true, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external public infra repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should add an external private infra repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser04/repo04.git', + externalToken: '', + externalUserName: 'externalUser+04', + internalRepoName: 'repo04', + isInfra: true, + isPrivate: true, + } + + // Act + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + + // Assert + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page + .getByTestId('externalUserNameInput') + .fill(repo.externalUserName) + await page.getByTestId('externalTokenInput').fill(repo.externalToken) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + }, + ) + + test( + 'Should update a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Update repository + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('internalRepoNameInput')).toBeDisabled() + await page + .getByTestId('externalRepoUrlInput') + .fill('https://github.com/externalUser04/new-repo.git') + await setCheckbox(page.getByTestId('input-checkbox-privateRepoCbx')) + await page.getByTestId('externalUserNameInput').fill('newUser') + await page.getByTestId('externalTokenInput').fill('newToken') + await expect( + page.getByTestId('input-checkbox-infraRepoCbx'), + ).toBeEnabled() + await page.getByTestId('updateRepoBtn').click() + await expect(page.getByTestId(`repoTr-repo03`)).toBeVisible() + await page.reload() + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('externalRepoUrlInput')).toHaveValue( + 'https://github.com/externalUser04/new-repo.git', + ) + await expect( + page.getByTestId('input-checkbox-privateRepoCbx'), + ).toBeChecked() + await expect(page.getByTestId('externalUserNameInput')).toHaveValue( + 'newUser', + ) + await expect(page.getByTestId('externalTokenInput')).toHaveValue( + fakeToken, + ) + }, + ) + + test( + 'Should synchronise a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Update repository + await page.getByTestId(`repoTr-repo03`).click() + await expect(page.getByTestId('branchNameInput')).toHaveValue('main') + await expect(page.getByTestId('branchNameInput')).toBeEnabled() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page + .getByTestId('toggleSyncAllBranches') + .locator('input') + .check({ force: true }) + await expect(page.getByTestId('branchNameInput')).not.toBeVisible() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page + .getByTestId('toggleSyncAllBranches') + .locator('input') + .uncheck({ force: true }) + await expect(page.getByTestId('branchNameInput')).toHaveValue('main') + await expect(page.getByTestId('branchNameInput')).toBeEnabled() + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page.getByTestId('syncRepoBtn').click() + await expect( + page.getByText( + 'Travail de synchronisation lancé pour le dépôt repo03Fermer le message', + ), + ).toBeVisible() + + await page.getByTestId('branchNameInput').clear() + await expect(page.getByTestId('syncRepoBtn')).toBeDisabled() + await page.getByTestId('branchNameInput').fill('develop') + await expect(page.getByTestId('syncRepoBtn')).toBeEnabled() + await page.getByTestId('syncRepoBtn').click() + await expect( + page.getByText( + 'Travail de synchronisation lancé pour le dépôt repo03Fermer le message', + ), + ).toBeVisible() + }, + ) + + test( + 'Should delete a repo', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { name: projectName, slug: projectSlug } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Assert - Delete repository + // + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('deleteRepoInput')).not.toBeVisible() + await expect(page.getByTestId('deleteRepoZone')).toBeVisible() + await page.getByTestId('showDeleteRepoBtn').click() + await expect(page.getByTestId('deleteRepoBtn')).toBeDisabled() + await page.getByTestId('deleteRepoInput').fill(deleteValidationInput) + await page.getByTestId('deleteRepoBtn').click() + await expect(page.getByTestId('repo-form')).not.toBeVisible() + await page.reload() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).not.toBeVisible() + }, + ) + + test( + 'Should not be able to delete a repository if not owner', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + const { + id: projectId, + name: projectName, + slug: projectSlug, + } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + await page.getByRole('link', { name: 'Se déconnecter' }).click() + await signInCloudPiNative({ page, credentials: tcolinUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + + // Assert - Attempt to delete repository + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('deleteRepoZone')).not.toBeVisible() + }, + ) + + test( + 'Should not be able to delete a repository if project locked', + { tag: ['@e2e', '@repos'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ + page, + credentials: + // Only admin users can lock projects + tcolinUser, + }) + const { + id: projectId, + name: projectName, + slug: projectSlug, + } = await createProject({ + page, + }) + const repo = { + externalRepoUrl: 'https://github.com/externalUser03/repo03.git', + externalToken: 'private-token', + externalUserName: 'this-is+tobi', + internalRepoName: 'repo03', + isInfra: true, + isPrivate: false, + } + + // Act - Create repository + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await expect(page.locator('h1')).toContainText(projectName) + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('addRepoLink').click() + await expect(page.locator('h2')).toContainText( + 'Ajouter un dépôt au projet', + ) + // The double-fill is desired, because the first one often fails ¯\_(ツ)_/¯ + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page + .getByTestId('internalRepoNameInput') + .fill(repo.internalRepoName) + await page.getByTestId('externalRepoUrlInput').fill(repo.externalRepoUrl) + await setCheckbox(page.getByTestId('input-checkbox-infraRepoCbx')) + await page.getByTestId('addRepoBtn').click() + await expect( + page.getByTestId(`repoTr-${repo.internalRepoName}`), + ).toBeVisible() + + // Act - Lock the project (have to go through admin menu for that) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await expect(page.getByTestId(`projectTr-${projectId}`)).toBeVisible() + await page.getByTestId(`projectTr-${projectId}`).click() + await expect(page.getByTestId('project-slug')).toHaveText( + projectSlugTextRegexp(projectSlug), + ) + await page.getByTestId('handleProjectLockingBtn').click() + + // Assert - Attempt to delete repository + await page.getByTestId(`repoTr-${repo.internalRepoName}`).click() + await expect(page.getByTestId('repo-form')).toBeVisible() + await expect(page.getByTestId('showDeleteRepoBtn')).not.toBeVisible() + }, + ) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/roles.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/roles.spec.ts new file mode 100644 index 0000000000..a81d940536 --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/roles.spec.ts @@ -0,0 +1,228 @@ +import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from 'config/console' +import { setCheckbox } from 'helpers/checkbox' + +test.describe('Administration Roles', () => { + test( + 'Should list admin roles', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + // Assert + // Obviously, we should not use data-testid that are made of ids… + // @TODO: Do better. + await expect( + page.getByTestId('76229c96-4716-45bc-99da-00498ec9018c-tab'), + ).toBeVisible() + await expect( + page.getByTestId('eadf604f-5f54-4744-bdfb-4793d2271e9b-tab'), + ).toBeVisible() + }, + ) + + test('Should add a new OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newOidcRole = { + name: faker.string.alpha(10).toLowerCase(), + oidcGroupName: `/${faker.string.alpha(10).toLowerCase()}`, + user: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newOidcRole.name) + await page.getByTestId('oidcGroupInput').fill(newOidcRole.oidcGroupName) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).toContainText(newOidcRole.name) + await page.getByTestId('test-members').click() + await expect(page.getByTestId('addUserBtn')).toBeDisabled() + await page + .getByTestId('addUserSuggestionInput') + .locator('input') + .fill(`${newOidcRole.user.email}`) + await page.getByTestId('addUserBtn').click() + + // Assert + const membershipCheckbox = page.getByTestId( + `input-checkbox-${newOidcRole.user.id}-cbx`, + ) + await expect(membershipCheckbox).toBeVisible() + await expect(membershipCheckbox).toBeChecked() + }) + + test('Should add a new non-OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + }) + + test('Should add a user to a role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + user: { + id: 'cb8e5b4b-7b7b-40f5-935f-594f48ae6567', + firstName: 'Claire', + lastName: 'NOLLET', + email: 'claire.nollet@test.com', + createdAt: '2023-07-03T14:46:56.771Z', + updatedAt: '2023-07-03T14:46:56.771Z', + adminRoleIds: [], + type: 'human', + }, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create non-OIDC role + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + // Add user to role + await page.getByTestId('test-members').click() + await expect(page.getByTestId('addUserBtn')).toBeDisabled() + await page + .getByTestId('addUserSuggestionInput') + .locator('input') + .fill(`${newRole.user.email}`) + await page.getByTestId('addUserBtn').click() + + // Assert + const membershipCheckbox = page.getByTestId( + `input-checkbox-${newRole.user.id}-cbx`, + ) + await expect(membershipCheckbox).toBeVisible() + await expect(membershipCheckbox).toBeChecked() + }) + + test('Should remove OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newOidcRole = { + name: faker.string.alpha(10).toLowerCase(), + oidcGroupName: `/${faker.string.alpha(10).toLowerCase()}`, + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create OIDC role + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newOidcRole.name) + await page.getByTestId('oidcGroupInput').fill(newOidcRole.oidcGroupName) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newOidcRole.name) + // Delete role + await page.getByTestId('deleteBtn').click() + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).not.toContainText( + newOidcRole.name, + ) + }) + + test('Should remove non-OIDC role', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const newRole = { + name: faker.string.alpha(10).toLowerCase(), + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationRoles').click() + // Create OIDC role + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + await page.getByTestId('addRoleBtn').click() + await expect( + page.getByTestId('snackbar').filter({ hasText: 'Rôle ajouté' }).first(), + ).toBeVisible() + await expect(page.getByTestId('saveBtn')).toBeDisabled() + await expect(page.getByTestId('roleNameInput')).toHaveValue('Nouveau rôle') + await page.getByTestId('roleNameInput').fill(newRole.name) + await setCheckbox(page.locator('input[name=MANAGE]')) + await page.getByTestId('saveBtn').click() + await expect(page.getByTestId('role-list')).toContainText(newRole.name) + // Delete role + await page.getByTestId('deleteBtn').click() + await page.getByTestId('confirmDeletionBtn').click() + + // Assert + await expect(page.getByTestId('role-list')).not.toContainText(newRole.name) + }) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/service-chains.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/service-chains.spec.ts new file mode 100644 index 0000000000..e1dc6def9d --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/service-chains.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' + +test.describe('Service Chains page', () => { + test.describe('Given an Admin-level user', () => { + test( + 'should list service chains', + { tag: ['@e2e', '@service-chains'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationServiceChains').click() + // We take the first service chain available + await expect( + page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1), + ).toBeVisible() + }, + ) + + test( + 'should show a service chain details', + { tag: ['@e2e', '@service-chains'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationServiceChains').click() + // We take the first service chain available + await page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1) + .click() + await expect( + page.getByRole('heading', { name: 'Chaîne de services' }), + ).toBeVisible() + }, + ) + + test( + 'should show a service chain flows', + { tag: ['@e2e', '@service-chains', '@service-chain-flows'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await expect(page.locator('.fr-collapsing')).toBeVisible() + await expect(page.locator('.fr-collapsing')).not.toBeVisible() + + await page.getByTestId('menuAdministrationServiceChains').click() + await expect( + page.getByTitle('Voir les détails de la chaîne de service').first(), + ).toBeVisible() + // We take the first service chain available + await page + .getByTestId('tableAdministrationServiceChains') + .locator('tr') + .nth(1) + .click() + await expect(page.getByTestId('service-chain-flows')).toBeVisible() + }, + ) + }) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/tokens.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/tokens.spec.ts new file mode 100644 index 0000000000..f6aa4b34db --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/tokens.spec.ts @@ -0,0 +1,37 @@ +import { faker } from '@faker-js/faker' + +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' + +test.describe('Admin Tokens page', () => { + // @TODO The original test did actually test whether the generated token worked. + // Add a dedicated test to do exactly that. + test( + 'Should create tokens, when logged in as an admin', + { tag: ['@e2e', '@need-rework'] }, + async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const tokenName = faker.string.alpha(10).toLowerCase() + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationToken').click() + await page.getByTestId('showNewTokenFormBtn').click() + + await page.getByTestId('newTokenName').fill(tokenName) + await page.getByTestId('saveBtn').click() + + // Assert + await expect(page.getByTestId('newTokenPassword')).toBeVisible() + // Reinit form should hide newly generated password + await page.getByTestId('showNewTokenFormBtn').click() + await expect(page.getByTestId('newTokenPassword')).not.toBeVisible() + + await expect(page.getByTestId('tokenTable')).toContainText(tokenName) + }, + ) + + // @TODO Add token deletion test (the original one used "nth" which is not reliable) +}) diff --git a/.jjconflict-side-2/playwright/e2e-tests/zones.spec.ts b/.jjconflict-side-2/playwright/e2e-tests/zones.spec.ts new file mode 100644 index 0000000000..04c61a951b --- /dev/null +++ b/.jjconflict-side-2/playwright/e2e-tests/zones.spec.ts @@ -0,0 +1,193 @@ +import { faker } from '@faker-js/faker' + +import { expect, test } from '@playwright/test' +import { clientURL, signInCloudPiNative, tcolinUser } from '../config/console' +import { deleteValidationInput } from '../helpers/constants' + +test.describe('Zone page', () => { + // @TODO: Add clusters to this test to ensure this part of the feature + test('Should create a zone', { tag: ['@e2e', '@need-rework'] }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + expect(expect(page.locator('#clusters-select')).not.toBeVisible()) + await page.getByTestId('addZoneBtn').click() + + await page.reload() + + // Assert + await page.locator(`#zoneTile-${zone.slug}`).click() + await expect(page.getByTestId('slugInput')).toHaveValue(zone.slug) + await expect(page.getByTestId('slugInput')).toBeDisabled() + await expect(page.getByTestId('labelInput')).toHaveValue(zone.label) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await expect(page.getByTestId('argocdUrlInput')).toHaveValue( + zone.argocdUrl, + ) + await expect(page.getByTestId('argocdUrlInput')).toBeEnabled() + await expect(page.getByTestId('descriptionInput')).toHaveValue( + zone.description, + ) + await expect(page.getByTestId('descriptionInput')).toBeEnabled() + await expect(page.locator('#clusters-select')).toBeVisible() + await expect(page.locator('#clusters-select')).toHaveAttribute( + 'disabled', + 'true', + ) + await expect(page.getByTestId('updateZoneBtn')).toBeEnabled() + }) + + test('Should update a zone', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + const updatedZone = { + label: 'Zone Mise à Jour', + argocdUrl: 'https://vousnetesplusici.fr', + description: 'Cette zone a été mise à jour.', + } + // Zone creation (for later update) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + + // Act + await page.locator(`#zoneTile-${zone.slug}`).click() + await page.getByTestId('labelInput').fill(updatedZone.label) + await page.getByTestId('argocdUrlInput').fill(updatedZone.argocdUrl) + await page.getByTestId('descriptionInput').fill(updatedZone.description) + await page.getByTestId('updateZoneBtn').click() + + // Assert + await page.locator(`#zoneTile-${zone.slug}`).click() + await expect(page.getByTestId('slugInput')).toHaveValue(zone.slug) + await expect(page.getByTestId('slugInput')).toBeDisabled() + await expect(page.getByTestId('labelInput')).toHaveValue(updatedZone.label) + await expect(page.getByTestId('labelInput')).toBeEnabled() + await expect(page.getByTestId('argocdUrlInput')).toHaveValue( + updatedZone.argocdUrl, + ) + await expect(page.getByTestId('argocdUrlInput')).toBeEnabled() + await expect(page.getByTestId('descriptionInput')).toHaveValue( + updatedZone.description, + ) + await expect(page.getByTestId('descriptionInput')).toBeEnabled() + await expect(page.locator('#clusters-select')).toBeVisible() + await expect(page.locator('#clusters-select')).toHaveAttribute( + 'disabled', + 'true', + ) + await expect(page.getByTestId('updateZoneBtn')).toBeEnabled() + }) + + test('Should not create a zone if slug is already taken', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + // Create the zone + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + + // Act - attempt to create a zone with the same slug + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(`${zone.label}bis`) + await page.getByTestId('argocdUrlInput').fill(`${zone.argocdUrl}bis`) + await page.getByTestId('descriptionInput').fill(`${zone.description}bis`) + await page.getByTestId('addZoneBtn').click() + + // Assert + const snackbar = page + .getByTestId('snackbar') + .filter({ hasText: `Une zone portant le nom ${zone.slug} existe déjà.` }) + await expect(snackbar).toBeVisible() + }) + + test('Should not delete a zone if associated clusters', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + // No data to arrange, a public zone is present by default (I guess ?) + + // Act + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + + // Assert + await page.getByTestId(`zoneTile-publique`).click() + await expect(page.getByTestId('showDeleteZoneBtn')).not.toBeVisible() + await expect(page.getByTestId('associatedClustersAlert')).toBeVisible() + }) + + test('Should delete a zone', { tag: '@e2e' }, async ({ page }) => { + // Arrange + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: tcolinUser }) + const zone = { + slug: faker.string.alpha(10).toLowerCase(), + label: 'Zone à Défendre', + argocdUrl: 'https://vousetesici.fr', + description: 'Il faut défendre cette zone.', + } + // Create the zone + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationZones').click() + await page.getByTestId('createZoneLink').click() + await page.getByTestId('slugInput').fill(zone.slug) + await page.getByTestId('labelInput').fill(zone.label) + await page.getByTestId('argocdUrlInput').fill(zone.argocdUrl) + await page.getByTestId('descriptionInput').fill(zone.description) + await page.getByTestId('addZoneBtn').click() + // @TODO: Add clusters to this test to ensure we can't delete a zone that has clusters + + // Act + await page.getByTestId('menuAdministrationZones').click() + await page.locator(`#zoneTile-${zone.slug}`).click() + await page.getByTestId('showDeleteZoneBtn').click() + await page.getByTestId('deleteZoneInput').fill(deleteValidationInput) + await page.getByTestId('deleteZoneBtn').click() + + // Assert + await page.getByTestId('menuAdministrationZones').click() + await expect(page.locator(`#zoneTile-${zone.slug}`)).not.toBeVisible() + }) +}) diff --git a/.jjconflict-side-2/playwright/eslint.config.mjs b/.jjconflict-side-2/playwright/eslint.config.mjs new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-2/playwright/eslint.config.mjs @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-2/playwright/helpers/checkbox.ts b/.jjconflict-side-2/playwright/helpers/checkbox.ts new file mode 100644 index 0000000000..b304a5c380 --- /dev/null +++ b/.jjconflict-side-2/playwright/helpers/checkbox.ts @@ -0,0 +1,20 @@ +import type { Locator } from '@playwright/test' +import { expect } from '@playwright/test' + +async function setCheckboxValue(locator: Locator, checked: boolean) { + await expect(locator).toBeVisible() + await locator.evaluate((el: HTMLInputElement, nextChecked: boolean) => { + el.checked = nextChecked + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + }, checked) + await expect(locator).toBeChecked({ checked }) +} + +export async function setCheckbox(locator: Locator) { + await setCheckboxValue(locator, true) +} + +export async function unsetCheckbox(locator: Locator) { + await setCheckboxValue(locator, false) +} diff --git a/.jjconflict-side-2/playwright/helpers/cluster.ts b/.jjconflict-side-2/playwright/helpers/cluster.ts new file mode 100644 index 0000000000..2239dd8904 --- /dev/null +++ b/.jjconflict-side-2/playwright/helpers/cluster.ts @@ -0,0 +1,124 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openClustersAdministration } from './navigation' + +type ClusterZone = string +type ClusterConfidentiality = 'public' | 'dedicated' +type ClusterAssociateStage = 'first' | 'all' | 'none' + +async function selectClusterZone(page: Page, zone: ClusterZone) { + await page.getByLabel('Zone associée').selectOption({ label: zone }) +} + +async function selectClusterConfidentiality(page: Page, confidentiality: ClusterConfidentiality) { + await page + .getByLabel('Confidentialité du cluster') + .selectOption({ label: confidentiality }) + + const projectsSelect = page.locator('#projects-select') + switch (confidentiality) { + case 'public': + await expect(projectsSelect).not.toBeVisible() + break + case 'dedicated': + await expect(projectsSelect).toBeVisible() + break + } +} + +async function addStageToClusterAssociationByName(page: Page, stageName: string) { + await page + .getByTestId('choice-selector-search-stages-select') + .fill(stageName) + await page.locator('#stages-select .fr-tag').first().click() +} + +async function addStagesToClusterAssociationByNames(page: Page, stageNames: string[]) { + for (const stageName of stageNames) + await addStageToClusterAssociationByName(page, stageName) +} + +async function addFirstStageToClusterAssociation(page: Page) { + await page.locator('#stages-select .fr-tag').first().click() +} + +async function addAllStagesToClusterAssociation(page: Page) { + const numberOfStages = (await page.locator('#stages-select .fr-tag').all()).length + for (let i = 0; i < numberOfStages; i++) + await page.locator('#stages-select .fr-tag').first().click() +} + +async function fillClusterInformations(page: Page, informations: string) { + await page.getByTestId('infosInput').fill(informations) +} + +async function searchCluster(page: Page, clusterName: string) { + await page.getByTestId('projectsSearchInput').fill(clusterName) +} + +async function openCluster(page: Page, clusterName: string) { + await page.getByTestId(`clusterLink-${clusterName}`).click() +} + +async function confirmClusterDeletion(page: Page) { + await expect(page.getByTestId('deleteClusterZone')).toBeVisible() + await page.getByTestId('showDeleteClusterBtn').click() + await expect(page.getByTestId('deleteClusterBtn')).toBeVisible() + await expect(page.getByTestId('deleteClusterBtn')).toBeDisabled() + await page.getByTestId('deleteClusterInput').fill(deleteValidationInput) + await expect(page.getByTestId('deleteClusterBtn')).toBeEnabled() + await page.getByTestId('deleteClusterBtn').click() +} + +export async function createCluster({ + page, + zone, + confidentiality, + associateStage, + associateStageNames, + informations, +}: { + page: Page + zone?: ClusterZone + confidentiality: ClusterConfidentiality + associateStage?: ClusterAssociateStage + associateStageNames?: string[] + informations?: string +}): Promise { + const clusterName = faker.string.alpha(10).toLowerCase() + await openClustersAdministration({ page }) + await page.getByTestId('addClusterLink').click() + await page.getByTestId('labelInput').fill(clusterName) + if (zone) + await selectClusterZone(page, zone) + await selectClusterConfidentiality(page, confidentiality) + if (associateStageNames?.length) + await addStagesToClusterAssociationByNames(page, associateStageNames) + + switch (associateStage ?? 'none') { + case 'first': + await addFirstStageToClusterAssociation(page) + break + case 'all': + await addAllStagesToClusterAssociation(page) + break + case 'none': + break + } + + if (informations) + await fillClusterInformations(page, informations) + await page.getByTestId('addClusterBtn').click() + await searchCluster(page, clusterName) + return clusterName +} + +export async function deleteCluster({ page, clusterName }: { page: Page, clusterName: string }) { + await openClustersAdministration({ page }) + await searchCluster(page, clusterName) + await expect(page.getByTestId(`clusterLink-${clusterName}`)).toBeVisible() + await openCluster(page, clusterName) + await confirmClusterDeletion(page) +} diff --git a/.jjconflict-side-2/playwright/helpers/constants.ts b/.jjconflict-side-2/playwright/helpers/constants.ts new file mode 100644 index 0000000000..df68f8179a --- /dev/null +++ b/.jjconflict-side-2/playwright/helpers/constants.ts @@ -0,0 +1,9 @@ +export const invalidGitUrlErrorMessage = 'L\'adresse doit commencer par https et se terminer par .git' +export const invalidInternalRepoErrorMessage = 'Le nom du dépôt ne doit contenir ni majuscules, ni espaces, ni caractères spéciaux hormis le trait d\'union, et doit commencer et se terminer par un caractère alphanumérique' +export const fakeToken = 'fakeToken' +export const deleteValidationInput = 'DELETE' + +export const snackbarTimeoutMs = 20_000 +export const repoSyncTimeoutMs = 60_000 +export const projectReadinessTimeoutMs = 60_000 +export const pollingFastIntervalMs = 2_000 diff --git a/.jjconflict-side-2/playwright/helpers/deployment.ts b/.jjconflict-side-2/playwright/helpers/deployment.ts new file mode 100644 index 0000000000..7a2850f3fc --- /dev/null +++ b/.jjconflict-side-2/playwright/helpers/deployment.ts @@ -0,0 +1,44 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' + +export async function createDeployment({ + page, + deploymentName, + envName, + repoName, + customStageName, +}: { + page: Page + deploymentName?: string + envName: string + repoName: string + customStageName: string +}): Promise { + deploymentName = deploymentName ?? faker.string.alpha(10).toLocaleLowerCase() + await openDeploymentCreateForm(page) + await fillDeploymentName(page, deploymentName) + await selectDeploymentEnvironment(page, envName, customStageName) + await selectDeploymentRepository(page, repoName) + await submitDeploymentForm(page) + return deploymentName +} + +async function openDeploymentCreateForm(page: Page) { + await page.getByRole('button', { name: 'Ajouter un nouveau déploiement' }).click() +} + +async function fillDeploymentName(page: Page, deploymentName: string) { + await page.getByRole('textbox', { name: 'Nom du déploiement * Ne doit' }).fill(deploymentName) +} + +async function selectDeploymentEnvironment(page: Page, envName: string, customStageName: string) { + await page.getByText(`${envName} ${customStageName}`).click() +} + +async function selectDeploymentRepository(page: Page, repoName: string) { + await page.getByLabel('Dépôt *').selectOption({ label: repoName }) +} + +async function submitDeploymentForm(page: Page) { + await page.getByRole('button', { name: 'Enregistrer' }).click() +} diff --git a/.jjconflict-side-2/playwright/helpers/environment.ts b/.jjconflict-side-2/playwright/helpers/environment.ts new file mode 100644 index 0000000000..2eccb82509 --- /dev/null +++ b/.jjconflict-side-2/playwright/helpers/environment.ts @@ -0,0 +1,89 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openMyProjects } from './navigation' + +type EnvironmentZone = 'publique' | 'Zone privée' + +async function openEnvironmentCreateForm(page: Page) { + await page.getByTestId('addEnvironmentLink').click() +} + +async function fillEnvironmentName(page: Page, envName: string) { + await page.getByTestId('environmentNameInput').fill(envName) +} + +async function selectEnvironmentZone(page: Page, zone: EnvironmentZone) { + await page.getByLabel('Choix de la zone cible').selectOption({ label: zone }) +} + +async function selectEnvironmentStage(page: Page, stageName: string) { + await page + .getByLabel('Type d\'environnement') + .selectOption({ label: stageName }) +} + +async function selectEnvironmentCluster(page: Page, clusterName: string) { + await expect(page.getByLabel('Choix du cluster cible')).toBeVisible() + await page + .getByLabel('Choix du cluster cible') + .selectOption({ label: clusterName }) +} + +async function fillEnvironmentResources( + page: Page, + { memoryInput, cpuInput, gpuInput }: { memoryInput: string, cpuInput: string, gpuInput: string }, +) { + await page.getByTestId('memoryInput').fill(memoryInput) + await page.getByTestId('cpuInput').fill(cpuInput) + await page.getByTestId('gpuInput').fill(gpuInput) +} + +async function submitEnvironmentCreation(page: Page) { + await page.getByTestId('addEnvironmentBtn').click() +} + +export async function createEnvironment({ + page, + envName, + zone, + customStageName, + customClusterName, + memoryInput = '1', + cpuInput = '1', + gpuInput = '1', + envsToDelete, +}: { + page: Page + envName?: string + zone: EnvironmentZone + customStageName: string + customClusterName: string + memoryInput?: string + cpuInput?: string + gpuInput?: string + envsToDelete?: string[] +}): Promise { + envName = envName ?? faker.string.alpha(10).toLowerCase() + envsToDelete?.push(envName) + await openEnvironmentCreateForm(page) + await fillEnvironmentName(page, envName) + await selectEnvironmentZone(page, zone) + await selectEnvironmentStage(page, customStageName) + await selectEnvironmentCluster(page, customClusterName) + await fillEnvironmentResources(page, { memoryInput, cpuInput, gpuInput }) + await submitEnvironmentCreation(page) + return envName +} + +export async function deleteEnvironment({ page, projectName, envName }: { page: Page, projectName: string, envName: string }) { + await openMyProjects({ page }) + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId(`environmentTr-${envName}`).click() + await page.getByTestId('showDeleteEnvironmentBtn').click() + await expect(page.getByTestId('deleteEnvironmentZone')).toBeVisible() + await page.getByTestId('deleteEnvironmentInput').fill(deleteValidationInput) + await page.getByTestId('deleteEnvironmentBtn').click() + await expect(page.getByTestId(`environmentTr-${envName}`)).not.toBeVisible() +} diff --git a/.jjconflict-side-2/playwright/helpers/global.d.ts b/.jjconflict-side-2/playwright/helpers/global.d.ts new file mode 100644 index 0000000000..20981cf311 --- /dev/null +++ b/.jjconflict-side-2/playwright/helpers/global.d.ts @@ -0,0 +1,5 @@ +declare global { + interface RegExpConstructor { + escape(string: string): string + } +} diff --git a/.jjconflict-side-2/playwright/helpers/navigation.ts b/.jjconflict-side-2/playwright/helpers/navigation.ts new file mode 100644 index 0000000000..3d4dc22a7d --- /dev/null +++ b/.jjconflict-side-2/playwright/helpers/navigation.ts @@ -0,0 +1,26 @@ +import type { Page } from '@playwright/test' + +export async function openMyProjects({ page }: { page: Page }) { + await page.getByTestId('menuMyProjects').click() +} + +async function ensureAdminMenuVisible(page: Page, menuTestId: string) { + const menuItem = page.getByTestId(menuTestId) + if (!(await menuItem.isVisible())) + await page.getByTestId('menuAdministrationBtn').click() +} + +export async function openClustersAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationClusters') + await page.getByTestId('menuAdministrationClusters').click() +} + +export async function openStagesAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationStages') + await page.getByTestId('menuAdministrationStages').click() +} + +export async function openZonesAdministration({ page }: { page: Page }) { + await ensureAdminMenuVisible(page, 'menuAdministrationZones') + await page.getByTestId('menuAdministrationZones').click() +} diff --git a/.jjconflict-side-2/playwright/helpers/project.ts b/.jjconflict-side-2/playwright/helpers/project.ts new file mode 100644 index 0000000000..0d5a8d65d1 --- /dev/null +++ b/.jjconflict-side-2/playwright/helpers/project.ts @@ -0,0 +1,124 @@ +import type { Page } from '@playwright/test' +import type { Credentials } from '../config/console' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput, projectReadinessTimeoutMs } from './constants' +import { openMyProjects } from './navigation' + +const projectUrlRegexp = /\/projects\/[^/]+$/ +const projectSlugPrefixRegexp = /^slug\s*:\s*/i + +interface Resources { + cpu: number + gpu: number + memory: number +} + +async function openProjectCreateForm(page: Page) { + await openMyProjects({ page }) + await page.getByTestId('createProjectLink').click() +} + +async function fillProjectName(page: Page, name: string) { + await page.getByTestId('nameInput').click() + await page.getByTestId('nameInput').fill(name) +} + +async function fillProjectResources({ + page, + hprodResources, + prodResources, +}: { + page: Page + hprodResources?: Resources + prodResources?: Resources +}) { + const fillHprod = async (resources: Resources) => { + await page.getByTestId('cpuHprodInput').fill(resources.cpu.toString()) + await page.getByTestId('gpuHprodInput').fill(resources.gpu.toString()) + await page + .getByTestId('memoryHprodInput') + .fill(resources.memory.toString()) + } + const fillProd = async (resources: Resources) => { + await page.getByTestId('cpuProdInput').fill(resources.cpu.toString()) + await page.getByTestId('gpuProdInput').fill(resources.gpu.toString()) + await page + .getByTestId('memoryProdInput') + .fill(resources.memory.toString()) + } + + if (hprodResources) + await fillHprod(hprodResources) + if (prodResources) + await fillProd(prodResources) +} + +async function enableProjectLimitless(page: Page) { + await page.getByTestId('limitlessProjectSwitch').locator('label').click() +} + +async function addMembersToProject(page: Page, members: Credentials[]) { + await page.getByTestId('test-tab-team').click() + for (const member of members) { + await page + .getByRole('combobox', { name: 'Ajouter un utilisateur' }) + .fill(member.email) + await page.getByTestId('addUserBtn').click() + await expect( + page.getByTestId('teamTable').getByText(member.email), + ).toBeVisible() + } + await page.getByTestId('test-tab-resources').click() +} + +async function getProjectSlugAndId(page: Page) { + const rawSlugText = (await page.getByTestId('project-slug').textContent()) ?? 'no-slug' + const slug = rawSlugText?.replace(projectSlugPrefixRegexp, '').trim() + const id = (await page.getByTestId('project-id').getAttribute('title')) ?? 'no-id' + return { slug, id } +} + +export function projectSlugTextRegexp(projectSlug: string): RegExp { + const escapedSlug = RegExp.escape(projectSlug) + return new RegExp(`^slug:\\s*${escapedSlug}$`) +} + +export async function createProject({ + page, + projectName: name, + members, + hprodResources, + prodResources, +}: { + page: Page + projectName?: string + members?: Credentials[] + hprodResources?: Resources + prodResources?: Resources +}): Promise<{ id: string, slug: string, name: string }> { + name = name ?? faker.string.alpha(10).toLowerCase() + await openProjectCreateForm(page) + await fillProjectName(page, name) + await fillProjectResources({ page, hprodResources, prodResources }) + if (!hprodResources && !prodResources) await enableProjectLimitless(page) + + await page.getByTestId('createProjectBtn').click() + await expect(page).toHaveURL(projectUrlRegexp, { timeout: projectReadinessTimeoutMs }) + await expect(page.getByTestId('project-slug')).toBeVisible({ timeout: projectReadinessTimeoutMs }) + if (members?.length) + await addMembersToProject(page, members) + + const { slug, id } = await getProjectSlugAndId(page) + return { name, slug, id } +} + +export async function deleteProject({ page, projectName }: { page: Page, projectName: string }) { + await openMyProjects({ page }) + await page.getByRole('link', { name: projectName }).click() + await page.getByRole('button', { name: 'Supprimer le projet' }).click() + await page.getByTestId('archiveProjectInput').fill(deleteValidationInput) + await page.getByTestId('confirmDeletionBtn').click() + await openMyProjects({ page }) + await expect(page.getByRole('link', { name: projectName })).not.toBeVisible() +} diff --git a/.jjconflict-side-2/playwright/helpers/repository.ts b/.jjconflict-side-2/playwright/helpers/repository.ts new file mode 100644 index 0000000000..82678de00d --- /dev/null +++ b/.jjconflict-side-2/playwright/helpers/repository.ts @@ -0,0 +1,82 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { repoSyncTimeoutMs } from './constants' +import { waitForSnackbar } from './snackbar' + +const repoSyncApiRegex = /\/api\/.*\/repositories\/.*\/sync$/ + +export function waitForRepoSync({ + page, + timeoutMs = repoSyncTimeoutMs, +}: { + page: Page + timeoutMs?: number +}) { + return page.waitForResponse((response) => { + const request = response.request() + const success = response.status() >= 200 && response.status() < 300 + return request.method() === 'POST' && success && repoSyncApiRegex.test(response.url()) + }, { timeout: timeoutMs }) +} + +async function openAddRepositoryForm(page: Page) { + await page.getByTestId('addRepoLink').click() +} + +async function fillRepositoryUrl(page: Page, externalRepoUrlInput?: string) { + const url = externalRepoUrlInput ?? `${faker.internet.url({ appendSlash: true })}myrepository.git` + await page.getByTestId('externalRepoUrlInput').fill(url) +} + +async function closeResourceModal(page: Page) { + await page + .getByTestId('resource-modal') + .getByRole('button', { name: 'Fermer' }) + .click() +} + +export async function createRepository({ + page, + repositoryName, + externalRepoUrlInput, + infraRepo, +}: { + page: Page + repositoryName?: string + externalRepoUrlInput?: string + infraRepo?: boolean +}) { + repositoryName = repositoryName ?? faker.string.alpha(10).toLowerCase() + await openAddRepositoryForm(page) + await page.getByTestId('internalRepoNameInput').fill(repositoryName) + await fillRepositoryUrl(page, externalRepoUrlInput) + if (infraRepo) + await page.getByText('Dépôt contenant du code d\'').click() + await page.getByTestId('addRepoBtn').click() + await expect(page.getByTestId(`repoTr-${repositoryName}`)).toContainText( + repositoryName, + ) + return repositoryName +} + +export async function synchronizeBranchOnRepository({ + page, + repositoryName, + branchName, +}: { + page: Page + repositoryName: string + branchName?: string +}) { + branchName = branchName ?? faker.string.alpha(10).toLowerCase() + await page.getByTestId(`repoTr-${repositoryName}`).click() + await expect(page.getByTestId('resource-modal')).toBeVisible() + await page.getByTestId('branchNameInput').fill(branchName) + const syncRequest = waitForRepoSync({ page, timeoutMs: repoSyncTimeoutMs }) + await page.getByTestId('syncRepoBtn').click() + await syncRequest + await closeResourceModal(page) + await waitForSnackbar({ page, text: 'Travail de synchronisation lancé' }) + return branchName +} diff --git a/.jjconflict-side-2/playwright/helpers/snackbar.ts b/.jjconflict-side-2/playwright/helpers/snackbar.ts new file mode 100644 index 0000000000..7c7d57d685 --- /dev/null +++ b/.jjconflict-side-2/playwright/helpers/snackbar.ts @@ -0,0 +1,17 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' +import { snackbarTimeoutMs } from './constants' + +export async function waitForSnackbar({ + page, + text, + timeoutMs = snackbarTimeoutMs, +}: { + page: Page + text: string | RegExp + timeoutMs?: number +}) { + await expect(page.getByTestId('snackbar')).toContainText(text, { + timeout: timeoutMs, + }) +} diff --git a/.jjconflict-side-2/playwright/helpers/stage.ts b/.jjconflict-side-2/playwright/helpers/stage.ts new file mode 100644 index 0000000000..f99f261b34 --- /dev/null +++ b/.jjconflict-side-2/playwright/helpers/stage.ts @@ -0,0 +1,76 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { expect } from '@playwright/test' +import { deleteValidationInput } from './constants' +import { openStagesAdministration } from './navigation' + +async function assertStageCreateForm(page: Page) { + await expect(page.locator('h1')).toContainText( + 'Informations du type d\'environnement', + ) + await expect(page.getByTestId('addStageBtn')).toBeVisible() + await expect(page.getByTestId('addStageBtn')).toBeDisabled() + await expect(page.getByTestId('updateStageBtn')).not.toBeVisible() +} + +async function addFirstClusterAssociation(page: Page) { + await page.locator('.fr-tag').first().click() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(1) +} + +async function addAllClusterAssociations(page: Page) { + await expect(page.locator('.fr-tag')).not.toHaveCount(0) + const numberOfAvalaibleClusters = (await page.locator('.fr-tag').all()).length + for (let i = 0; i < numberOfAvalaibleClusters; i++) + await page.locator('.fr-tag').first().click() + + await expect(page.locator('.fr-tag--dismiss')).toHaveCount( + numberOfAvalaibleClusters, + ) +} + +export async function createStage({ + page, + associateToCluster, +}: { + page: Page + associateToCluster: 'first' | 'all' | 'none' +}): Promise { + const stageName = faker.string.alpha(10).toLowerCase() + await openStagesAdministration({ page }) + await expect(page.getByTestId('addStageLink')).toBeVisible() + await page.getByTestId('addStageLink').click() + await assertStageCreateForm(page) + await page.getByTestId('nameInput').fill(stageName) + await expect(page.getByTestId('addStageBtn')).toBeEnabled() + await expect(page.locator('.fr-tag--dismiss')).toHaveCount(0) + + switch (associateToCluster) { + case 'first': + await addFirstClusterAssociation(page) + break + case 'all': + await addAllClusterAssociations(page) + break + case 'none': + break + } + + await page.getByTestId('addStageBtn').click() + return stageName +} + +export async function deleteStage({ page, stageName }: { page: Page, stageName: string }) { + await openStagesAdministration({ page }) + await page.getByTestId(`stageTile-${stageName}`).click() + await expect(page.getByTestId('deleteStageZone')).toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsZone'), + ).not.toBeVisible() + await expect( + page.getByTestId('associatedEnvironmentsTable'), + ).not.toBeVisible() + await page.getByTestId('showDeleteStageBtn').click() + await page.getByTestId('deleteStageInput').fill(deleteValidationInput) + await page.getByTestId('deleteStageBtn').click() +} diff --git a/.jjconflict-side-2/playwright/helpers/zone.ts b/.jjconflict-side-2/playwright/helpers/zone.ts new file mode 100644 index 0000000000..c18973e9ef --- /dev/null +++ b/.jjconflict-side-2/playwright/helpers/zone.ts @@ -0,0 +1,34 @@ +import type { Page } from '@playwright/test' +import { faker } from '@faker-js/faker' +import { deleteValidationInput } from './constants' +import { openZonesAdministration } from './navigation' + +async function openZoneCreateForm(page: Page) { + await page.getByTestId('createZoneLink').click() +} + +async function fillZoneCreateForm(page: Page, zoneName: string) { + await page.getByTestId('slugInput').fill(zoneName) + await page.getByTestId('labelInput').fill(zoneName) + await page.getByTestId('argocdUrlInput').fill(faker.internet.url()) + await page + .getByTestId('descriptionInput') + .fill(faker.string.alpha(100).toLowerCase()) +} + +export async function createZone({ page }: { page: Page }): Promise { + const zoneName = faker.string.alpha(10).toLowerCase() + await openZonesAdministration({ page }) + await openZoneCreateForm(page) + await fillZoneCreateForm(page, zoneName) + await page.getByTestId('addZoneBtn').click() + return zoneName +} + +export async function deleteZone({ page, zoneName }: { page: Page, zoneName: string }) { + await openZonesAdministration({ page }) + await page.getByRole('link', { name: zoneName }).click() + await page.getByTestId('showDeleteZoneBtn').click() + await page.getByTestId('deleteZoneInput').fill(deleteValidationInput) + await page.getByTestId('deleteZoneBtn').click() +} diff --git a/.jjconflict-side-2/playwright/integration-tests/admin-setup.spec.ts b/.jjconflict-side-2/playwright/integration-tests/admin-setup.spec.ts new file mode 100644 index 0000000000..da53fa6394 --- /dev/null +++ b/.jjconflict-side-2/playwright/integration-tests/admin-setup.spec.ts @@ -0,0 +1,54 @@ +import { test } from '@playwright/test' +import { adminUser, clientURL, signInCloudPiNative } from '../config/console' + +import { createCluster, deleteCluster } from '../helpers/cluster' +import { createStage, deleteStage } from '../helpers/stage' +import { createZone, deleteZone } from '../helpers/zone' + +const zonesToDelete: string[] = [] +const stagesToDelete: string[] = [] +const clustersToDelete: string[] = [] + +test.describe('Integration tests for admin', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Admin setup', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + const zoneName = await createZone({ page }) + zonesToDelete.push(zoneName) + // we need to attains 7 stages to be able to use associateStageNames argument in createCluster + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName1 = await createStage({ page, associateToCluster: 'none' }) + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName2 = await createStage({ page, associateToCluster: 'none' }) + await page.getByRole('link', { name: 'Console Cloud π Native' }).click() + const customStageName3 = await createStage({ page, associateToCluster: 'none' }) + stagesToDelete.push(customStageName1) + stagesToDelete.push(customStageName2) + stagesToDelete.push(customStageName3) + const clusterName = await createCluster({ + page, + zone: zoneName, + confidentiality: 'public', + associateStageNames: [customStageName1, customStageName2, customStageName3], + }) + clustersToDelete.push(clusterName) + }) + + test('Cleanup admin test data', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + for (const stageName of stagesToDelete) { + await deleteStage({ page, stageName }) + } + for (const clusterName of clustersToDelete) { + await deleteCluster({ page, clusterName }) + } + for (const zoneName of zonesToDelete) { + await deleteZone({ page, zoneName }) + } + }) +}) diff --git a/.jjconflict-side-2/playwright/integration-tests/gitlab.spec.ts b/.jjconflict-side-2/playwright/integration-tests/gitlab.spec.ts new file mode 100644 index 0000000000..19d5424ddd --- /dev/null +++ b/.jjconflict-side-2/playwright/integration-tests/gitlab.spec.ts @@ -0,0 +1,125 @@ +import { expect, test } from '@playwright/test' +import { clientURL, currentUser, secondTestUser, signInCloudPiNative } from '../config/console' +import { projectReadinessTimeoutMs } from '../helpers/constants' +import { createProject, deleteProject } from '../helpers/project' + +const membersLinkRegexp = /^(members|membres)$/i +const manageLinkRegexp = /^(manage|gérer)$/i +const developerRoleRegexp = /developer/i + +test.describe('GitLab', () => { + test( + 'Should create project and have infra-apps + mirror repos in GitLab', + { tag: ['@integ', '@replayable'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: currentUser }) + + const { name: projectName, slug: projectSlug } = await createProject({ page }) + + try { + await page.getByTestId('test-tab-services').click() + + const gitlabPopup = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const gitlabPage = await gitlabPopup + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + await expect( + gitlabPage.getByTestId('group-name').filter({ hasText: 'infra-apps' }), + ).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await expect( + gitlabPage.getByTestId('group-name').filter({ hasText: 'mirror' }), + ).toBeVisible({ timeout: projectReadinessTimeoutMs }) + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const manageLink = gitlabPage + .getByRole('button', { name: manageLinkRegexp }) + .or(gitlabPage.getByRole('link', { name: manageLinkRegexp })) + .first() + await manageLink.click({ timeout: projectReadinessTimeoutMs }) + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const membersLink = gitlabPage.getByRole('link', { name: membersLinkRegexp }).first() + await membersLink.click({ timeout: projectReadinessTimeoutMs }) + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const currentUserGitlabUsername = currentUser.email.replace('@', '.') + const userRow = gitlabPage.getByRole('row').filter({ hasText: `@${currentUserGitlabUsername}` }).first() + await userRow.scrollIntoViewIfNeeded() + await expect(userRow).toBeVisible({ + timeout: projectReadinessTimeoutMs, + }) + + const botRow = gitlabPage.getByRole('row').filter({ hasText: `${projectSlug}-bot` }).first() + await botRow.scrollIntoViewIfNeeded() + await expect(botRow).toBeVisible({ + timeout: projectReadinessTimeoutMs, + }) + } finally { + await deleteProject({ page, projectName }) + } + }, + ) + + test( + 'Should reflect console role assignment in GitLab builtin role', + { tag: ['@e2e', '@integ', '@replayable'] }, + async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: currentUser }) + + const { name: projectName } = await createProject({ page }) + + try { + await page.getByTestId('test-tab-team').click() + await page.locator('input[data-testid="addUserSuggestionInput"]').fill(secondTestUser.email) + await page.getByTestId('addUserBtn').click() + const teamMemberRow = page.getByTestId('teamTable').getByRole('row').filter({ hasText: secondTestUser.email }).first() + await expect(teamMemberRow).toBeVisible({ timeout: projectReadinessTimeoutMs }) + const teamMemberUserId = ((await teamMemberRow.locator('code').first().textContent()) ?? '').trim() + expect(teamMemberUserId).not.toEqual('') + + await page.reload() + await expect(page.getByTestId('project-slug')).toBeVisible({ timeout: projectReadinessTimeoutMs }) + + await page.getByTestId('test-tab-roles').click() + await page + .locator('[data-testid$="-tab"]') + .filter({ hasText: 'DevOps' }) + .click() + await page.getByRole('tab', { name: 'Membres' }).click() + const roleMemberCheckbox = page.getByTestId(`input-checkbox-${teamMemberUserId}-cbx`) + await expect(roleMemberCheckbox).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await roleMemberCheckbox.check({ force: true }) + + await expect(page.getByTestId('snackbar')).toContainText('Rôle mis à jour', { timeout: projectReadinessTimeoutMs }) + + await page.getByTestId('test-tab-services').click() + + const gitlabPopup = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const gitlabPage = await gitlabPopup + + await gitlabPage.locator('.gl-spinner').first().waitFor({ state: 'hidden', timeout: projectReadinessTimeoutMs }) + + const manageLink = gitlabPage + .getByRole('button', { name: manageLinkRegexp }) + .or(gitlabPage.getByRole('link', { name: manageLinkRegexp })) + .first() + await manageLink.click({ timeout: projectReadinessTimeoutMs }) + + const membersLink = gitlabPage.getByRole('link', { name: membersLinkRegexp }).first() + await membersLink.click({ timeout: projectReadinessTimeoutMs }) + + const secondTestUserGitlabUsername = secondTestUser.email.replace('@', '.') + const memberRow = gitlabPage.getByRole('row').filter({ hasText: `@${secondTestUserGitlabUsername}` }).first() + await expect(memberRow).toContainText(developerRoleRegexp, { timeout: projectReadinessTimeoutMs }) + } finally { + await deleteProject({ page, projectName }) + } + }, + ) +}) diff --git a/.jjconflict-side-2/playwright/integration-tests/user-flow.spec.ts b/.jjconflict-side-2/playwright/integration-tests/user-flow.spec.ts new file mode 100644 index 0000000000..a27b10139b --- /dev/null +++ b/.jjconflict-side-2/playwright/integration-tests/user-flow.spec.ts @@ -0,0 +1,390 @@ +import { expect, test } from '@playwright/test' +import { adminUser, clientURL, secondTestUser, signInCloudPiNative, testUser } from '../config/console' +import { pollingFastIntervalMs, projectReadinessTimeoutMs, repoSyncTimeoutMs } from '../helpers/constants' +import { createProject, deleteProject } from '../helpers/project' +import { createRepository } from '../helpers/repository' + +const projectsToDelete: string[] = [] +const projectName = 'socleprojecttest' +const repositoryName = 'socle-project-test' +const destinationCluster = process.env.CONSOLE_DESTINATION_CLUSTER || 'cpin-app-hp' +const helmValuesFiles = process.env.CONSOLE_VALUES_FILE || 'values-cpin-hp.yaml' +projectsToDelete.push(projectName) + +test.describe('Integration tests user flow: project creation', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Preliminary checks', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + // Check all services are healthy + await page.getByTestId('menuServicesHealth').click() + await expect(page.getByTestId('ArgoCD-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Gitlab-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Harbor-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Keycloak-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Nexus-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('SonarQube-info').getByText('OK')).toBeVisible() + await expect(page.getByTestId('Vault-info').getByText('OK')).toBeVisible() + await expect(page.getByText('Tous les services')).toBeVisible() + // Check maintenance mode is disabled + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationSystemSettings').click() + await expect(page.getByText('Le mode Maintenance est')).not.toBeVisible() + await expect(page.getByText('Désactiver le mode maintenance')).not.toBeVisible() + await expect(page.getByText('Activer le mode maintenance')).toBeVisible() + }) + + test('Project creation and configuration', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await createProject({ page, projectName }) + // Enable Nexus Maven plugin + await page.getByTestId('test-tab-services').click() + await page.getByRole('button', { name: 'Nexus' }).click() + await page + .getByRole('group', { name: 'Activer le dépôt Maven' }) + .locator('input[value="enabled"] + label') + .click() + await page.getByTestId('saveBtn').click() + await expect(page.getByText('Paramètres sauvegardés')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Check if project reprovisioning is successful + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + await expect(page.getByText('Le projet a été reprovisionn')).toBeVisible() + // Add repository to project + await page.getByTestId('test-tab-resources').click() + await createRepository({ + page, + repositoryName, + externalRepoUrlInput: 'https://github.com/cloud-pi-native/socle-project-test.git', + infraRepo: true, + }) + await page.getByRole('cell', { name: repositoryName }).click() + await page.getByTestId('deployRevisionInput').fill('main') + await page.getByTestId('deployPathInput').fill('helm/') + await page.getByTestId('helmValuesFilesTextarea').fill(helmValuesFiles) + await page.getByTestId('updateRepoBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: first checks', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Check Vault kv', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Vault' }).click() + const page1 = await page1Promise + const page2Promise = page1.waitForEvent('popup') + await page1.getByRole('button', { name: 'Sign in with OIDC Provider' }).click() + await page2Promise + await expect(page1.getByRole('link', { name: 'Vault home' })).toBeVisible() + // Check that standard user has access to his project kv + await expect(page1.getByText('No secrets yet')).toBeVisible() + await expect(page1.getByRole('link', { name: 'Create secret' })).toBeVisible() + await page1.getByLabel('breadcrumbs').getByRole('link', { name: 'Secrets' }).click() + // Check that forge-dso kv is not accessible for standard user + await expect(page1.getByRole('link', { name: 'forge-dso' })).not.toBeVisible() + }) + + test('Project permissions', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Add user to project + await page.getByTestId('test-tab-team').click() + await page.getByTestId('addUserSuggestionInput').locator('input').fill(secondTestUser.email) + await page.getByTestId('addUserBtn').click() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Create read-only role + await page.getByTestId('test-tab-roles').click() + await page.getByTestId('addRoleBtn').click() + await page.getByTestId('roleNameInput').fill('readOnly') + await page.getByText('Afficher les secrets Permet d').click() + await page.getByText('Voir les environnements').click() + await page.getByText('Voir les dépôts Permet de').click() + await page.getByTestId('saveBtn').click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Add user to read-only role + await page.getByTestId('test-members').click() + await page.getByLabel('Rôles', { exact: true }).getByText(secondTestUser.email).click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Replay project hooks + await page.getByTestId('replayHooksBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + await expect(page.getByText('Le projet a été reprovisionn')).toBeVisible() + }) + + test('Pipelines run', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if mirror pipeline is successful + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Gitlab' }).click() + const page1 = await page1Promise + await page1.getByTestId('group-name').filter({ hasText: 'mirror' }).click() + await expect(page1.getByTestId('status_success_borderless-icon')).toBeVisible() + // Check if tests are successful + await page1.getByRole('link', { name: projectName }).click() + await page1.getByTestId('group-name').filter({ hasText: repositoryName }).click() + await page1.getByTestId('ci-icon').click() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-vault' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-nexus' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-harbor' }), + ).toBeVisible() + await expect( + page1.getByRole('link', { name: 'Status: Passed test-sonar' }), + ).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: after pipelines checks', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Prepare ArgoCD deployment', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Create environment for project to trigger ArgoCD deployment + await page.getByTestId('test-tab-resources').click() + await page.getByTestId('addEnvironmentLink').click() + await page.getByTestId('environmentNameInput').click() + await page.getByTestId('environmentNameInput').fill('integ') + await page.getByLabel('Zone *Choix de la zone cible').selectOption({ label: 'DSO' }) + await page.getByLabel('Type d\'environnement *Type d\'').selectOption({ label: 'dev' }) + await page.getByLabel('Cluster *Choix du cluster').selectOption({ label: destinationCluster }) + await page.getByTestId('memoryInput').fill('2') + await page.getByTestId('cpuInput').fill('1') + await page.getByTestId('gpuInput').fill('0') + await page.getByTestId('addEnvironmentBtn').click() + await expect(page.getByRole('heading', { name: 'Opération en cours...' })).toBeVisible() + }) + + test('View Sonar scan report', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if sonar scan is available + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'SonarQube' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'OpenID Connect Log in with' }).click() + await page1.getByPlaceholder('Search for projects...').fill(projectName) + await page1.getByRole('link', { name: `${projectName}-${repositoryName}` }).click() + await expect( + page1.getByTestId('overview__quality-gate-panel').getByText('Passed', { exact: true }), + ).toBeVisible() + }) + + test('Check Harbor repository', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Harbor' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'LOGIN WITH keycloak' }).click() + await expect(page1.getByRole('button', { name: 'Administration' })).not.toBeVisible() + await expect(page1.getByText('Guest')).toBeVisible() + await expect(page1.getByRole('heading', { name: 'Private' })).toBeVisible() + await page1.getByRole('link', { name: `${projectName}/java-demo` }).click() + await expect(page1.getByRole('button', { name: 'main' })).toBeVisible() + await expect(page1.getByText('Policy')).not.toBeVisible() + // Check trivy scan result, hopefully will stay at C + await expect(page1.getByRole('button', { name: 'C', exact: true })).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: deployment and metrics', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('ArgoCD deployment', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Check if ArgoCD deployment is successful + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await page1.getByText('app.kubernetes.io/managed-by=dso-console, dso/environment=integ, dso/').click() + await expect(page1.getByText('Synced').nth(1)).toBeVisible() + await expect(page1.getByText('Sync OK')).toBeVisible() + await expect(page1.getByText('Healthy').nth(1)).toBeVisible() + await page1.getByRole('button', { name: ' Details' }).click() + const page2Promise = page1.waitForEvent('popup') + await page1.getByRole('link', { name: `http://${repositoryName}.` }).click() + const page2 = await page2Promise + await expect + .poll( + async () => { + await page2.reload({ waitUntil: 'domcontentloaded' }) + return page2.locator('body').textContent() + }, + { + timeout: repoSyncTimeoutMs, + intervals: [pollingFastIntervalMs], + }, + ) + .toContain('Application is running') + }) + + test('Check Grafana', { tag: '@replayable' }, async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'Grafana' }).click() + const page1 = await page1Promise + const signInLink = page1.getByRole('link', { name: 'Sign in with grafana-projects' }) + await expect(signInLink).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await signInLink.click({ timeout: projectReadinessTimeoutMs }) + await expect(page1.getByRole('link', { name: 'Grafana', exact: true })).toBeVisible() + await page1.getByTestId('data-testid Toggle menu').click() + await page1.getByRole('button', { name: 'Expand section Dashboards' }).click() + await page1.getByRole('link', { name: 'Dashboards', exact: true }).click() + const dsoDashboard = page1.getByRole('link', { name: 'dso-grafana' }) + await expect(dsoDashboard).toBeVisible({ timeout: projectReadinessTimeoutMs }) + await dsoDashboard.click({ timeout: projectReadinessTimeoutMs }) + // Check if we can see some metrics + await page1.getByRole('link', { name: 'Kubernetes / Views /' }).click() + await expect(page1.getByText('0.100')).toBeVisible() // Cpu request + await expect(page1.getByText('0.500')).toBeVisible() // Cpu limit + await expect(page1.getByText('256')).toBeVisible() // Memory request + await expect(page1.getByText('512')).toBeVisible() // Memory limit + // Check if we can see some logs + await page1.getByTestId('data-testid dso-grafana breadcrumb').click() + await page1.getByRole('link', { name: 'Loki Kubernetes Logs' }).click() + await expect(page1.getByTestId('data-testid Panel status error').first()).not.toBeVisible() + await expect(page1.locator('.rc-drawer-mask')).not.toBeVisible() + await page1.getByTestId('data-testid TimePicker Open Button').click() + await page1.getByText('Last 1 hour').click() + await page1.locator('.css-13x53bc-Icon-topVerticalAlign').first().click() + await expect(page1.getByRole('cell', { name: 'app_kubernetes_io_name' }).nth(1)).toBeVisible() + await expect(page1.getByText('demo-java-helm').nth(1)).toBeVisible() + }) +}) + +test.describe('Integration tests user flow: Cleanup', { tag: '@integ' }, () => { + test.describe.configure({ mode: 'serial' }) + + test('Remove permissions and user', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + // Remove role membership + await page.getByTestId('test-tab-roles').click() + await page.getByRole('button', { name: 'readOnly' }).click() + await page.getByTestId('test-members').click() + await page.getByLabel('Rôles', { exact: true }).getByText(secondTestUser.email).click() + await expect(page.getByText('Rôle mis à jour')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Reprovisionnement nécessaire' })).toBeVisible() + // Remove role + await page.getByTestId('test-general').click() + await page.getByTestId('deleteBtn').click() + await page.getByText('Rôle supprimé').click() + // Remove project membership + await page.getByTestId('test-tab-team').click() + await page.getByTitle(`Retirer ${secondTestUser.email} du`).click() + }) + + test('Remove stage', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByRole('cell', { name: 'integ' }).click() + await page.getByTestId('showDeleteEnvironmentBtn').click() + await page.getByTestId('deleteEnvironmentInput').fill('DELETE') + await page.getByTestId('deleteEnvironmentBtn').click() + await page.getByRole('heading', { name: 'Opération en cours...' }).click() + await expect( + page.getByRole('cell', { name: 'Aucun environnement existant' }), + ).toBeVisible() + // ArgoCD deployment will be deleted when stage is deleted + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await expect( + page1.getByRole('combobox', { name: 'Search applications...' }), + ).toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-integ-socle-` }), + ).not.toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-cpin-app-hp-integ-root` }), + ).not.toBeVisible() + }) + + // This check will forever fail until we fix + // https://github.com/cloud-pi-native/console/issues/1853 + test('Check Argocd root application deletion', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: adminUser }) + await page.getByTestId('menuAdministrationBtn').click() + await page.getByTestId('menuAdministrationProjects').click() + await page.getByLabel('Filtre rapide').selectOption('Tous') + await page.getByTestId('projectsSearchInput').fill(projectName) + await page.getByTestId('projectsSearchBtn').click() + await page.getByRole('cell', { name: projectName }).first().click() + await page.getByTestId('test-tab-services').click() + const page1Promise = page.waitForEvent('popup') + await page.getByRole('link', { name: 'ArgoCD DSO' }).click() + const page1 = await page1Promise + await page1.getByRole('button', { name: 'Log in via Keycloak' }).click() + await expect( + page1.getByRole('combobox', { name: 'Search applications...' }), + ).toBeVisible() + await expect( + page1.locator('span').filter({ hasText: `${projectName}-cpin-app-hp-integ-root` }), + ).not.toBeVisible() + }) + + test('Remove repository from project', async ({ page }) => { + await page.goto(clientURL) + await signInCloudPiNative({ page, credentials: testUser }) + await page.getByTestId('menuMyProjects').click() + await page.getByRole('link', { name: projectName }).click() + await page.getByTestId('test-tab-resources').click() + await page.getByRole('cell', { name: repositoryName }).click() + await page.getByTestId('showDeleteRepoBtn').click() + await page.getByTestId('deleteRepoInput').fill('DELETE') + await page.getByTestId('deleteRepoBtn').click() + await page.getByRole('heading', { name: 'Opération en cours...' }).click() + await expect(page.getByRole('cell', { name: 'Aucun dépôt existant' })).toBeVisible() + for (const projectName of projectsToDelete) { + await deleteProject({ page, projectName }) + } + }) +}) diff --git a/.jjconflict-side-2/playwright/package.json b/.jjconflict-side-2/playwright/package.json new file mode 100644 index 0000000000..d4b0f175ee --- /dev/null +++ b/.jjconflict-side-2/playwright/package.json @@ -0,0 +1,27 @@ +{ + "name": "@cpn-console/playwright", + "version": "2.0.0", + "private": true, + "packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26", + "description": "Our Playwright automated test suite", + "keywords": [ + "playwright", + "tests", + "e2e" + ], + "main": "playwright.config.ts", + "scripts": { + "playwright:test": "pnpm exec playwright test", + "playwright:test:ui": "pnpm exec playwright test --ui", + "integ:playwright:test": "INTEGRATION=true pnpm exec playwright test", + "integ:playwright:test:ui": "INTEGRATION=true pnpm exec playwright test --ui", + "format": "eslint ./ --fix" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@playwright/test": "catalog:test", + "@types/node": "catalog:types" + } +} diff --git a/.jjconflict-side-2/playwright/playwright.config.ts b/.jjconflict-side-2/playwright/playwright.config.ts new file mode 100644 index 0000000000..06a9545f6a --- /dev/null +++ b/.jjconflict-side-2/playwright/playwright.config.ts @@ -0,0 +1,58 @@ +import path from 'node:path' +import { defineConfig, devices } from '@playwright/test' + +process.loadEnvFile(path.resolve(__dirname, '..', 'apps/client', '.env.docker')) + +const isIntegration = process.env.INTEGRATION === 'true' + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: isIntegration ? './integration-tests' : './e2e-tests', + + /* Run tests in files in parallel */ + fullyParallel: true, + + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + + retries: 3, + + workers: process.env.CI ? 1 : undefined, // Default is 50% logical cores + + // The maximum number of test failures forthe whole test suite run. + // After reaching this number, testing will stop and exit with an error. + // Setting to zero (default) disables this behavior. + maxFailures: 1, + + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Retain trace on failed tries. See https://playwright.dev/docs/trace-viewer */ + trace: 'retain-on-failure', + }, + + /* All timeouts are in milliseconds */ + // Timeout for each and every `test` block + timeout: Number(process.env.CONSOLE_GLOBAL_TIMEOUT) || 30_000, + + // Timeout for each and every `expect` command + expect: { + timeout: Number(process.env.CONSOLE_EXPECT_TIMEOUT) || 10_000, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + ], +}) diff --git a/.jjconflict-side-2/playwright/tsconfig.json b/.jjconflict-side-2/playwright/tsconfig.json new file mode 100644 index 0000000000..f9908dab99 --- /dev/null +++ b/.jjconflict-side-2/playwright/tsconfig.json @@ -0,0 +1,41 @@ +{ + "extends": ["@cpn-console/ts-config/tsconfig.base.json"], + "compilerOptions": { + "incremental": true, + // Module + "target": "ESNext", + + "jsx": "preserve", + "lib": ["DOM", "ESNext"], + // Base + "baseUrl": "./", + "rootDir": "./", + "module": "Preserve", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "types": ["node"], + "allowImportingTsExtensions": false, + "allowJs": true, + "strict": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "useUnknownInCatchVariables": true, + "declaration": true, + "declarationDir": "./types", + "emitDeclarationOnly": false, + "noEmit": false, + "outDir": "./dist", + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["./playwright.config.ts", "./config/**/*.ts", "./e2e-tests/**/*.ts", "./helpers/**/*.ts", "./integration-tests/**/*.ts"], + "exclude": [] +} diff --git a/.jjconflict-side-2/plugins/argocd/.gitignore b/.jjconflict-side-2/plugins/argocd/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-2/plugins/argocd/README.md b/.jjconflict-side-2/plugins/argocd/README.md new file mode 100644 index 0000000000..2fe1f154d8 --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/README.md @@ -0,0 +1,200 @@ +# Plugin ArgoCD + +Ce document décrit le fonctionnement du plugin ArgoCD. + +## Hooks et Steps supportés + +Le seul step supporté est pour l'instant le step `main`. Ceci est applicable à tous les hooks de ce plugin. + +## upsertProject hook + +Voici le diagramme de séquence du hook le plus important du plugin (`upsert`) : + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + end + + box Project Cluster + participant ArgoCD_EXT as ArgoCD + participant Kubernetes_EXT as Kubernetes + end + + Bob ->> Console: "Please upsert project" + + loop For every zone/environment + Console ->> Gitlab: Reconcile (create/update/delete) values.yaml files + ArgoCD_EXT ->> Gitlab: Detects new/updated values.yaml files + ArgoCD_EXT ->> Kubernetes_EXT: Apply changes + end +``` + +Les clusters sont dits "externes" car ils sont hors du périmètre de DSO, et gérés de manière autonome par les projets clients, ce qui est une contrainte majeur : on ne peut pas interagir avec l'API Kubernetes (le cluster est considéré comme "air gapped", c'est-à-dire injoignable de l'extérieur, *a fortiori* depuis la Console). C'est en particulier le cas pour les clusters qui se trouvent dans une Zone DR ("Diffusion Restreinte"). + +C'est pourquoi la Console fonctionne avec son paradigme dans lequel c'est le cluster externe qui **vient récupérer les informations pour se mettre à jour**. Et la manière la plus efficace de faire ça, c'est de fonctionner en mode "GitOps", un mode dans lequel la Console crée un fichier de configuration (typiquement un `values.yaml`) qui serait récupéré (d'où le nom de "pull", en opposition au "push" traditionnel dans lequel on va utiliser les API Kubernetes) par le cluster externe afin d'être traité par l'ArgoCD qui lui est rattaché. + +## deleteProject hook + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + participant ArgoCD_DSO as ArgoCD + participant Kubernetes_DSO as Kubernetes + end + + box Project Cluster + participant ArgoCD_EXT as ArgoCD + participant Kubernetes_EXT as Kubernetes + end + + Bob ->> Console: "Please delete project" + + loop For every zone/environment + Console ->> Gitlab: Delete values.yaml files + ArgoCD_EXT ->> Gitlab: Detects deleted values.yaml files + ArgoCD_EXT ->> Kubernetes_EXT: Apply relevant changes (delete resources) + end +``` + +## upsertCluster hook + +Ce hook sert à la création/modification d'un cluster Kubernetes. +Il est à noter la distinction des clusters étiquetés `in-cluster` pour lesquels la gestion des secrets est différente (c'est une exception à la règle). + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please upsert cluster" + + alt cluster has "in-cluster" label + Console ->> Kubernetes: Delete cluster Secret + else cluster DOES NOT HAVE "in-cluster" label + Console ->> Kubernetes: Create cluster Secret + end + + Console ->> Vault: Create entry for cluster data +``` + +## deleteCluster hook + +Ce hook sert à la "suppression" d'un cluster Kubernetes. +Il est à noter que le cluster n'est pas vraiment supprimé, mais "oublié" par la Console, qui n'en garde plus trace. + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please delete cluster" + + Console ->> Kubernetes: Delete cluster Secret + Console ->> Vault: Destroy entry for cluster data +``` + +## upsertZone hook + +Ce hook sert à la création/modification d'une Zone (qui contiendra donc un ou plusieurs clusters Kubernetes) + +```mermaid +sequenceDiagram + actor Bob as Bob + + box DSO + participant Console as Console + participant Gitlab as Gitlab + participant Vault as Vault + participant Kubernetes as Kubernetes + end + + Bob ->> Console: "Please upsert zone" + + Console ->> Vault: Get credentials + Console ->> Gitlab: Create infrastructure project for the zone if needed + Console ->> Gitlab: Create argocd-values.yaml file +``` + +## Deployment as-code (declarative mode) + +```mermaid +--- +config: + layout: default + theme: base + flowchart: + curve: linear + defaultRenderer: "elk" +--- +flowchart TD + subgraph "Helm-charts" + H1@{ shape: doc, label: "Chart 'dso-argocd-zone'" } + H2@{ shape: doc, label: "Chart 'dso-env'" } + H3@{ shape: doc, label: "Chart 'dso-ns'" } + end + subgraph "Gitlab DSO" + subgraph "zone repository" + V1@{ shape: doc, label: "/argocd-values.yaml" } + V2@{ shape: docs, label: "**/values.yaml" } + end + subgraph "project repositories" + V3@{ shape: docs, label: "infra sources" } + end + end + subgraph "DSO Cluster" + A1[Application zone-#lt;name>-app] + A1 --Deploy--> A3[ApplicationSet dso-appset] + A1 --Deploy--> S1[Cluster secrets] + V2 --Generator--> A3 + A3 --Generates--> A4[Application *-root] + V2 --> A4 + H2 -.-> A4 + A4 --Deploys--> A7[Application #lt;project>-env] + A4 --Deploys--> A8@{ shape: procs, label: "Application #lt;project>-#lt;env>-#lt;random-id>" } + + end + S1 --> apps + V1 --> A1 + H1 -.-> A1 + V3 ==> A8 + H3 -.-> A7 + V2 --> A7 + A7 --Deploys--> namespace & R1 & R2 & R3 + A8 ==Deploys==> A10 + A8 --Deploys--> A11 + subgraph apps["Apps Clusters"] + subgraph namespace["Namespace"] + A10@{ shape: procs, label: "K8S resources" } + A11@{ shape: procs, label: "VaultStaticSecrets" } + R1[ResourceQuota] + R2[VaultConnection] + R3[VaultStaticSecret registry-pull-secret] + R3 --> R4[Secret registry-pull-secret] + A11 --> R5@{ shape: procs, label: "Secrets" } + end + end + subgraph "Vault DSO" + kv1["KV Infra"] + kv2@{ shape: procs, label: "KV Projets" } + end + kv1 --> R3 + kv2 --> A11 +``` diff --git a/.jjconflict-side-2/plugins/argocd/eslint.config.js b/.jjconflict-side-2/plugins/argocd/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-2/plugins/argocd/package.json b/.jjconflict-side-2/plugins/argocd/package.json new file mode 100644 index 0000000000..042fad6e4d --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/package.json @@ -0,0 +1,53 @@ +{ + "name": "@cpn-console/argocd-plugin", + "type": "module", + "version": "2.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@himenon/argocd-typescript-openapi": "catalog:runtime", + "axios": "catalog:runtime", + "yaml": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@faker-js/faker": "catalog:test", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/plugins/argocd/src/cluster.ts b/.jjconflict-side-2/plugins/argocd/src/cluster.ts new file mode 100644 index 0000000000..efdb6de462 --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/src/cluster.ts @@ -0,0 +1,69 @@ +import type { ClusterObject, StepCall } from '@cpn-console/hooks' +import { updateZoneValues } from './utils.js' + +export const upsertCluster: StepCall = async (payload) => { + try { + const cluster = payload.args + const { vault } = payload.apis + const clusterData = { + name: cluster.label, + clusterResources: cluster.clusterResources.toString(), + server: cluster.cluster.server, + config: JSON.stringify(convertConfig(cluster)), + } + await vault.upsert() + await vault.write(clusterData, `clusters/cluster-${cluster.label}/argocd-cluster-secret`) + await updateZoneValues(cluster.zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Cluster secret created/updated', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed create/update cluster secret', + }, + } + } +} + +export const deleteCluster: StepCall = async (payload) => { + try { + await payload.apis.vault.destroy(`clusters/cluster-${payload.args.label}/argocd-cluster-secret`) + await updateZoneValues(payload.args.zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Cluster secret deleted', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed to delete cluster secret', + }, + } + } +} + +// ...désolé +function convertConfig(cluster: ClusterObject) { + return { + ...cluster.user?.username && { username: cluster.user?.username }, + ...cluster.user?.password && { password: cluster.user?.password }, + ...cluster.user?.token && { bearerToken: cluster.user?.token }, + tlsClientConfig: { + ...cluster.user?.keyData && { keyData: cluster.user?.keyData }, + ...cluster.user?.certData && { certData: cluster.user?.certData }, + ...(cluster.cluster?.caData && !cluster.cluster?.skipTLSVerify) && { caData: cluster.cluster?.caData }, + ...cluster.cluster?.skipTLSVerify && { insecure: cluster.cluster.skipTLSVerify }, + serverName: cluster.cluster.tlsServerName, + }, + } +} diff --git a/.jjconflict-side-2/plugins/argocd/src/env.d.ts b/.jjconflict-side-2/plugins/argocd/src/env.d.ts new file mode 100644 index 0000000000..58ef50fd4c --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/src/env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/.jjconflict-side-2/plugins/argocd/src/functions.spec.ts b/.jjconflict-side-2/plugins/argocd/src/functions.spec.ts new file mode 100644 index 0000000000..2a036bc0c4 --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/src/functions.spec.ts @@ -0,0 +1,212 @@ +import type { ClusterObject, Environment, Project, Repository } from '@cpn-console/hooks' +import { faker } from '@faker-js/faker' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { stringify } from 'yaml' +import { deleteProject, upsertProject } from './functions.js' + +vi.mock('./utils.js', () => ({ + generateAppProjectName: vi.fn(() => 'app-project-name'), + getConfig: vi.fn(() => ({ namespace: 'argocd', url: 'https://argocd.example.com' })), +})) + +vi.mock('@cpn-console/shared', async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + generateNamespaceName: vi.fn(() => 'namespace-name'), + } +}) + +describe('argocd functions', () => { + const mockGitlabApi = { + // Used to get the public group URL for sourceRepositories + getPublicGroupUrl: vi.fn(), + // Used to get or create the infra project where values.yaml is stored + getOrCreateInfraProject: vi.fn(), + // Used to get the infra project details (repo URL) + getProjectById: vi.fn(), + // Used to resolve the public URL of a repository + getPublicRepoUrl: vi.fn(), + // Used to commit the values.yaml file + commitCreateOrUpdate: vi.fn(), + // Used to list files in the infra project for cleanup + listFiles: vi.fn(), + // Used to delete files from the infra project + commitDelete: vi.fn(), + } + // Used to get the RO and RW groups for the environment + const mockKeycloakApi = { + getEnvGroup: vi.fn(), + } + // Used to get user vault secrets + const mockVaultApi = { + Project: { + getValues: vi.fn(), + }, + } + + let mockCluster: ClusterObject + let mockEnvironment: Environment + let mockRepo: Repository + let mockProject: Project + + beforeEach(() => { + vi.clearAllMocks() + + mockCluster = { + id: faker.string.uuid(), + label: faker.string.alphanumeric(10), + zone: { slug: faker.string.alphanumeric(5) }, + privacy: 'public', + } as any + + mockEnvironment = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(5), + clusterId: mockCluster.id, + cpu: faker.number.int({ min: 1, max: 10 }), + memory: faker.number.int({ min: 1, max: 32 }), + gpu: 0, + autosync: true, + } as any + + mockRepo = { + id: faker.string.uuid(), + internalRepoName: faker.string.alphanumeric(10), + isInfra: true, + helmValuesFiles: null, + deployRevision: 'main', + deployPath: '/deploy', + } as any + + mockProject = { + id: faker.string.uuid(), + name: faker.string.alphanumeric(10), + slug: faker.string.alphanumeric(10), + repositories: [mockRepo], + environments: [mockEnvironment], + clusters: [mockCluster], + store: {}, + } as any + }) + + it('upsertProject should succeed', async () => { + const infraProjectId = faker.number.int() + const infraProjectUrl = faker.internet.url() + const gitlabGroupUrl = faker.internet.url() + const gitlabRepoUrl = faker.internet.url() + + mockGitlabApi.getPublicGroupUrl.mockResolvedValue(gitlabGroupUrl) + mockGitlabApi.getOrCreateInfraProject.mockResolvedValue({ id: infraProjectId, http_url_to_repo: infraProjectUrl }) + mockKeycloakApi.getEnvGroup.mockResolvedValue({ subgroups: { RO: '/ro', RW: '/rw' } }) + mockVaultApi.Project.getValues.mockResolvedValue({ secret: 'value' }) + mockGitlabApi.getProjectById.mockResolvedValue({ http_url_to_repo: infraProjectUrl }) + mockGitlabApi.listFiles.mockResolvedValue([]) // No files to delete + mockGitlabApi.getPublicRepoUrl.mockResolvedValue(gitlabRepoUrl) + + const payload = { + args: mockProject, + apis: { + gitlab: mockGitlabApi, + keycloak: mockKeycloakApi, + vault: mockVaultApi, + }, + config: {}, + } as any + + const result = await upsertProject(payload) + + expect(result.status.result).toBe('OK') + + const expectedValues = { + common: { + 'dso/project': mockProject.name, + 'dso/project.id': mockProject.id, + 'dso/project.slug': mockProject.slug, + 'dso/environment': mockEnvironment.name, + 'dso/environment.id': mockEnvironment.id, + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'app-project-name', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: infraProjectUrl, + valueFileRevision: 'HEAD', + valueFilePath: `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, + roGroup: '/ro', + rwGroup: '/rw', + platformAdminGroup: '/console/admin', + platformReadonlyGroup: '/console/readonly', + projectAdminGroup: `/${mockProject.slug}/console/admin`, + projectDevopsGroup: `/${mockProject.slug}/console/devops`, + projectDevelopperGroup: `/${mockProject.slug}/console/developer`, + projectReadonlyGroup: `/${mockProject.slug}/console/readonly`, + }, + application: { + quota: { + cpu: mockEnvironment.cpu, + gpu: mockEnvironment.gpu, + memory: `${mockEnvironment.memory}Gi`, + }, + sourceRepositories: [ + `${gitlabGroupUrl}/**`, + ], + destination: { + namespace: 'namespace-name', + name: mockCluster.label, + }, + autosync: true, + vault: { secret: 'value' }, + repositories: [ + { + id: mockRepo.id, + name: mockRepo.internalRepoName, + repoURL: gitlabRepoUrl, + targetRevision: 'main', + path: '/deploy', + valueFiles: [], + }, + ], + }, + } + + expect(mockGitlabApi.commitCreateOrUpdate).toHaveBeenCalledWith( + infraProjectId, + stringify(expectedValues), + `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, + ) + expect(mockGitlabApi.commitDelete).toHaveBeenCalledWith(infraProjectId, []) + }) + + it('deleteProject should succeed', async () => { + const infraProjectId = faker.number.int() + mockGitlabApi.getOrCreateInfraProject.mockResolvedValue({ id: infraProjectId }) + mockGitlabApi.listFiles.mockResolvedValue([ + { name: 'values.yaml', path: `${mockProject.name}/${mockCluster.label}/${mockEnvironment.name}/values.yaml`, type: 'blob' }, + { name: 'values.yaml', path: `${mockProject.name}/${mockCluster.label}/old-env/values.yaml`, type: 'blob' }, + ]) + + const payload = { + args: mockProject, + apis: { + gitlab: mockGitlabApi, + }, + } as any + + const result = await deleteProject(payload) + + expect(result.status.result).toBe('OK') + expect(mockGitlabApi.listFiles).toHaveBeenCalledWith(infraProjectId, { + path: `${mockProject.name}/`, + recursive: true, + }) + // Only the stale env file should be deleted (not in project.environments) + expect(mockGitlabApi.commitDelete).toHaveBeenCalledWith(infraProjectId, [ + `${mockProject.name}/${mockCluster.label}/old-env/values.yaml`, + ]) + }) +}) diff --git a/.jjconflict-side-2/plugins/argocd/src/functions.ts b/.jjconflict-side-2/plugins/argocd/src/functions.ts new file mode 100644 index 0000000000..7c1ccbc139 --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/src/functions.ts @@ -0,0 +1,281 @@ +import type { GitlabProjectApi } from '@cpn-console/gitlab-plugin/types/class.js' +import type { + ClusterObject, + Config, + Environment, + Project, + Repository, + StepCall, +} from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' +import { stringify } from 'yaml' +import { + DEFAULT_DSO_ENV_CHART_VERSION, + DEFAULT_DSO_NS_CHART_VERSION, + DEFAULT_PLATFORM_ADMIN_GROUP_PATH, + DEFAULT_PLATFORM_READONLY_GROUP_PATH, + DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX, +} from './infos.js' +import { logger } from './logger.js' +import { generateAppProjectName, getConfig } from './utils.js' + +function splitExtraRepositories(repos?: string): string[] { + return repos ? repos.split(',').map(repo => repo.trim()) : [] +} + +function getValueFilePath(p: Project, c: ClusterObject, e: Environment): string { + return `${p.name}/${c.label}/${e.name}/values.yaml` +} + +export const upsertProject: StepCall = async (payload) => { + const project = payload.args + const gitlabApi = payload.apis.gitlab as unknown as GitlabProjectApi + const keycloakApi = payload.apis.keycloak as any + const vaultApi = payload.apis.vault as unknown as VaultProjectApi + + try { + const infraRepositories = project.repositories.filter( + repo => repo.isInfra, + ) + const sourceRepositories = [ + `${await gitlabApi.getPublicGroupUrl()}/**`, + ...splitExtraRepositories(payload.config.argocd?.extraRepositories), + ...splitExtraRepositories(project.store.argocd?.extraRepositories), + ] + + await Promise.all( + project.environments.map(async (environment) => { + const appNamespace = generateNamespaceName(project.id, environment.id) + const cluster = getCluster(project, environment) + const infraProject = await gitlabApi.getOrCreateInfraProject(cluster.zone.slug) + const appProjectName = generateAppProjectName(project.slug, environment.name) + const envGroup = await (keycloakApi as any).getEnvGroup(environment.name) + const roGroup = envGroup.subgroups.RO + const rwGroup = envGroup.subgroups.RW + + await ensureInfraEnvValues( + project, + environment, + appNamespace, + roGroup, + rwGroup, + appProjectName, + infraRepositories, + infraProject.id, + sourceRepositories, + gitlabApi, + vaultApi, + payload.config, + ) + }), + ) + + await removeInfraEnvValues(project, gitlabApi) + + logger.info({ action: 'upsertProject', projectSlug: project.slug }, 'Hook done') + + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +interface ArgoRepoSource { + repoURL: string + targetRevision: string + path: string + valueFiles: string[] +} + +async function ensureInfraEnvValues( + project: Project, + environment: Environment, + appNamespace: string, + roGroup: string, + rwGroup: string, + appProjectName: string, + infraRepositories: Repository[], + repoId: number, + sourceRepositories: string[], + gitlabApi: GitlabProjectApi, + vaultApi: VaultProjectApi, + config: Config, +) { + const platformAdminGroupPath = config.argocd?.platformAdminGroupPath ?? DEFAULT_PLATFORM_ADMIN_GROUP_PATH + const platformReadonlyGroupPath = config.argocd?.platformReadonlyGroupPath ?? DEFAULT_PLATFORM_READONLY_GROUP_PATH + const projectAdminGroupSuffix = config.argocd?.projectAdminGroupPathSuffix ?? DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX + const projectDevopsGroupSuffix = config.argocd?.projectDevopsGroupPathSuffix ?? DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX + const projectDevelopperGroupSuffix = config.argocd?.projectDevelopperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX + const projectReadonlyGroupSuffix = config.argocd?.projectReadonlyGroupPathSuffix ?? DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX + const dsoEnvChartVersion = config.argocd?.dsoEnvChartVersion ?? DEFAULT_DSO_ENV_CHART_VERSION + const dsoNsChartVersion = config.argocd?.dsoNsChartVersion ?? DEFAULT_DSO_NS_CHART_VERSION + const cluster = getCluster(project, environment) + const infraProject = await gitlabApi.getProjectById(repoId) + const valueFilePath = getValueFilePath(project, cluster, environment) + const vaultValues = await vaultApi.Project.getValues() + const repositories: ArgoRepoSource[] = await Promise.all( + infraRepositories.map(async (repository) => { + const repoURL = await gitlabApi.getPublicRepoUrl( + repository.internalRepoName, + ) + const valueFiles = repository.helmValuesFiles + ? repository.helmValuesFiles + .replaceAll('', environment.name) + .split(',') + : [] + return { + id: repository.id, + name: repository.internalRepoName, + repoURL, + targetRevision: repository.deployRevision || 'HEAD', + path: repository.deployPath || '.', + valueFiles, + } + }), + ) + + const values = { + common: { + 'dso/project': project.name, + 'dso/project.id': project.id, + 'dso/project.slug': project.slug, + 'dso/environment': environment.name, + 'dso/environment.id': environment.id, + }, + argocd: { + cluster: inClusterLabel, + namespace: getConfig().namespace, + project: appProjectName, + envChartVersion: dsoEnvChartVersion, + nsChartVersion: dsoNsChartVersion, + }, + environment: { + valueFileRepository: infraProject.http_url_to_repo, + valueFileRevision: 'HEAD', + valueFilePath, + roGroup, + rwGroup, + platformAdminGroup: platformAdminGroupPath, + platformReadonlyGroup: platformReadonlyGroupPath, + projectAdminGroup: `/${project.slug}${projectAdminGroupSuffix}`, + projectDevopsGroup: `/${project.slug}${projectDevopsGroupSuffix}`, + projectDevelopperGroup: `/${project.slug}${projectDevelopperGroupSuffix}`, + projectReadonlyGroup: `/${project.slug}${projectReadonlyGroupSuffix}`, + }, + application: { + quota: { + cpu: environment.cpu, + gpu: environment.gpu, + memory: `${environment.memory}Gi`, + }, + sourceRepositories, + destination: { + namespace: appNamespace, + name: cluster.label, + }, + autosync: environment.autosync, + vault: vaultValues, + repositories, + }, + } + + await gitlabApi.commitCreateOrUpdate(repoId, stringify(values), valueFilePath) +} + +function getCluster(p: Project, e: Environment): ClusterObject { + const c = p.clusters.find(c => c.id === e.clusterId) + if (!c) throw new Error(`Unable to find cluster ${e.id} for env ${e.name}`) + return c +} + +async function removeInfraEnvValues( + project: Project, + gitlabApi: GitlabProjectApi, +) { + for (const z of getDistinctZones(project)) { + const infraProject = await gitlabApi.getOrCreateInfraProject(z) + const existingFiles = await gitlabApi.listFiles(infraProject.id, { + path: `${project.name}/`, + recursive: true, + }) + const neededFiles = project.environments.map(env => + getValueFilePath(project, getCluster(project, env), env), + ) + const filesToDelete: string[] = [] + for (const existingFile of existingFiles) { + if ( + existingFile.name === 'values.yaml' + && !neededFiles.includes(existingFile.path) + ) { + filesToDelete.push(existingFile.path) + } + } + await gitlabApi.commitDelete(infraProject.id, filesToDelete) + } +} + +function getDistinctZones(project: Project) { + const zones: Set = new Set() + project.clusters.forEach(c => zones.add(c.zone.slug)) + return zones +} + +async function cleanupProjectInfra( + project: Project, + gitlabApi: GitlabProjectApi, +) { + for (const z of getDistinctZones(project)) { + const infraProject = await gitlabApi.getOrCreateInfraProject(z) + const existingFiles = await gitlabApi.listFiles(infraProject.id, { + path: `${project.name}/`, + recursive: true, + }) + const filesToDelete = existingFiles + .filter(f => f.name === 'values.yaml') + .map(f => f.path) + await gitlabApi.commitDelete(infraProject.id, filesToDelete) + } +} + +export const deleteProject: StepCall = async (payload) => { + const project = payload.args + const gitlabApi = payload.apis.gitlab as unknown as GitlabProjectApi + + try { + await cleanupProjectInfra(project, gitlabApi) + + logger.info({ action: 'deleteProject', projectSlug: project.slug }, 'Hook done') + + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'deleteProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} diff --git a/.jjconflict-side-2/plugins/argocd/src/index.ts b/.jjconflict-side-2/plugins/argocd/src/index.ts new file mode 100644 index 0000000000..5d7acfdda0 --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/src/index.ts @@ -0,0 +1,43 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import { deleteCluster, upsertCluster } from './cluster.js' +import { deleteProject, upsertProject } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' +import { upsertZone } from './zone.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { + steps: { + main: upsertProject, + }, + }, + deleteProject: { + steps: { + main: deleteProject, + }, + }, + upsertCluster: { + steps: { + main: upsertCluster, + }, + }, + deleteCluster: { + steps: { + main: deleteCluster, + }, + }, + upsertZone: { + steps: { + main: upsertZone, + }, + }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-side-2/plugins/argocd/src/infos.ts b/.jjconflict-side-2/plugins/argocd/src/infos.ts new file mode 100644 index 0000000000..71d672c5e1 --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/src/infos.ts @@ -0,0 +1,133 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const extraRepositoriesDesc = 'appproject.spec.sourceRepos supplémentaires, séparés par des virgules (https://a.com/repo.git,https://b.com/' + +export const DEFAULT_PLATFORM_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_PLATFORM_READONLY_GROUP_PATH = '/console/readonly' +export const DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX = '/console/devops' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer' +export const DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX = '/console/readonly' +export const DEFAULT_DSO_ENV_CHART_VERSION = 'dso-env-1.6.0' +export const DEFAULT_DSO_NS_CHART_VERSION = 'dso-ns-1.1.5' + +const infos = { + name: 'argocd', + to: ({ zones, project }) => zones.map(z => ({ + to: `${z.argocdUrl}/applications?showFavorites=false&proj=&sync=&health=&namespace=&cluster=&labels=&search=${project.slug}`, + title: `ArgoCD ${z.label}`, + })), + title: 'ArgoCD', + imgSrc: '/img/argocd.svg', + description: 'ArgoCD est un outil déclaratif de livraison continue GitOps pour Kubernetes', + config: { + global: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }, { + key: 'platformAdminGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Admin Group Path', + value: DEFAULT_PLATFORM_ADMIN_GROUP_PATH, + description: 'Chemin du groupe administrateur de plateforme', + }, { + key: 'platformReadonlyGroupPath', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Platform Readonly Group Path', + value: DEFAULT_PLATFORM_READONLY_GROUP_PATH, + description: 'Chemin du groupe lecture seule de plateforme', + }, { + key: 'projectAdminGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Admin Group Path Suffix', + value: DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe administrateur de projet', + }, { + key: 'projectDevopsGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project DevOps Group Path Suffix', + value: DEFAULT_PROJECT_DEVOPS_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe devops de projet', + }, { + key: 'projectDevelopperGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Developer Group Path Suffix', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe développeur de projet', + }, { + key: 'projectReadonlyGroupPathSuffix', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Project Readonly Group Path Suffix', + value: DEFAULT_PROJECT_READONLY_GROUP_PATH_SUFFIX, + description: 'Suffixe du chemin du groupe lecture seule de projet', + }, { + key: 'dsoEnvChartVersion', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Env Chart Version', + value: DEFAULT_DSO_ENV_CHART_VERSION, + description: 'Version du chart Helm dso-env', + placeholder: DEFAULT_DSO_ENV_CHART_VERSION, + }, { + key: 'dsoNsChartVersion', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'DSO Namespace Chart Version', + value: DEFAULT_DSO_NS_CHART_VERSION, + description: 'Version du chart Helm dso-ns', + placeholder: DEFAULT_DSO_NS_CHART_VERSION, + }], + project: [{ + key: 'extraRepositories', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Source repositories', + value: '', + description: extraRepositoriesDesc, + placeholder: 'https://github.com/', + }], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-side-2/plugins/argocd/src/logger.ts b/.jjconflict-side-2/plugins/argocd/src/logger.ts new file mode 100644 index 0000000000..45416d7025 --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'argocd' }) diff --git a/.jjconflict-side-2/plugins/argocd/src/monitor.ts b/.jjconflict-side-2/plugins/argocd/src/monitor.ts new file mode 100644 index 0000000000..132a2a4441 --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/src/monitor.ts @@ -0,0 +1,27 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(requiredEnv('ARGOCD_INTERNAL_URL'), { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-2/plugins/argocd/src/utils.ts b/.jjconflict-side-2/plugins/argocd/src/utils.ts new file mode 100644 index 0000000000..151f2389ef --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/src/utils.ts @@ -0,0 +1,51 @@ +import type { GitlabZoneApi } from '@cpn-console/gitlab-plugin/types/class.js' +import type { ClusterObject, HookPayloadApis, ZoneObject } from '@cpn-console/hooks' +import { createHmac } from 'node:crypto' +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import { stringify } from 'yaml' + +export function generateAppProjectName(projectSlug: string, env: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${envHash}` +} + +export function generateApplicationName(projectSlug: string, env: string, repo: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${repo}-${envHash}` +} + +const config: { + namespace?: string + url?: string +} = {} + +export function getConfig(): Required { + config.namespace = config.namespace ?? requiredEnv('ARGO_NAMESPACE') + config.url = removeTrailingSlash(requiredEnv('ARGOCD_URL')) + + // @ts-ignore + return config +} + +export async function updateZoneValues(zone: ZoneObject, apis: HookPayloadApis | HookPayloadApis) { + const gitlab = apis.gitlab as unknown as GitlabZoneApi + const vault = apis.vault as any + const values = { + vault: await vault.getValues(), + clusters: zone.clusterNames, + } + const zoneRepo = await gitlab.getOrCreateInfraProject(zone.slug) + await gitlab.commitCreateOrUpdate(zoneRepo.id, stringify(values), 'argocd-values.yaml') + return { + status: { + result: 'OK', + message: 'Zone argocd configuration created/updated', + }, + } +} diff --git a/.jjconflict-side-2/plugins/argocd/src/zone.ts b/.jjconflict-side-2/plugins/argocd/src/zone.ts new file mode 100644 index 0000000000..61e74e765a --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/src/zone.ts @@ -0,0 +1,23 @@ +import type { StepCall, ZoneObject } from '@cpn-console/hooks' +import { updateZoneValues } from './utils.js' + +export const upsertZone: StepCall = async (payload) => { + try { + const zone = payload.args + await updateZoneValues(zone, payload.apis) + return { + status: { + result: 'OK', + message: 'Zone argocd configuration created/updated', + }, + } + } catch (error) { + return { + error, + status: { + result: 'KO', + message: 'Failed create/update zone argocd configuration', + }, + } + } +} diff --git a/.jjconflict-side-2/plugins/argocd/tsconfig.eslint.json b/.jjconflict-side-2/plugins/argocd/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-2/plugins/argocd/tsconfig.json b/.jjconflict-side-2/plugins/argocd/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-2/plugins/argocd/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-2/plugins/gitlab/.gitignore b/.jjconflict-side-2/plugins/gitlab/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-2/plugins/gitlab/eslint.config.js b/.jjconflict-side-2/plugins/gitlab/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-2/plugins/gitlab/files/.gitlab-ci.yml b/.jjconflict-side-2/plugins/gitlab/files/.gitlab-ci.yml new file mode 100644 index 0000000000..ca9be29847 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/files/.gitlab-ci.yml @@ -0,0 +1,22 @@ +variables: + PROJECT_NAME: + description: Nom du dépôt (dans ce Gitlab) à synchroniser. + GIT_BRANCH_DEPLOY: + description: Nom de la branche à synchroniser. + value: main + SYNC_ALL: + description: Synchroniser toutes les branches. + value: "false" + +include: + - project: $CATALOG_PATH + file: mirror.yml + ref: main + +repo_pull_sync: + extends: .repo_pull_sync + only: + - api + - triggers + - web + - schedules diff --git a/.jjconflict-side-2/plugins/gitlab/files/mirror.sh b/.jjconflict-side-2/plugins/gitlab/files/mirror.sh new file mode 100644 index 0000000000..8f5fbdfe37 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/files/mirror.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\e[0;31m' +no_color='\033[0m' + +# Console step increment +i=1 + +# Default values +BRANCH_TO_SYNC=main + +print_help() { + TEXT_HELPER="\nThis script aims to send a synchronization request to DSO.\nFollowing flags are available: + -a Api url to send the synchronization request + -b Branch which is wanted to be synchronize for the given repository (default '$BRANCH_TO_SYNC') + -g GitLab token to trigger the pipeline on the gitlab mirror project + -i Gitlab mirror project id + -r Gitlab repository name to mirror + -h Print script help\n" + printf "$TEXT_HELPER" +} + +print_args() { + printf "\nArguments received: + -a API_URL: $API_URL + -b BRANCH_TO_SYNC: $BRANCH_TO_SYNC + -g GITLAB_TRIGGER_TOKEN length: ${#GITLAB_TRIGGER_TOKEN} + -i GITLAB_MIRROR_PROJECT_ID: $GITLAB_MIRROR_PROJECT_ID + -r REPOSITORY_NAME: $REPOSITORY_NAME\n" +} + +# Parse options +while getopts :ha:b:g:i:r: flag +do + case "${flag}" in + a) + API_URL=${OPTARG};; + b) + BRANCH_TO_SYNC=${OPTARG};; + g) + GITLAB_TRIGGER_TOKEN=${OPTARG};; + i) + GITLAB_MIRROR_PROJECT_ID=${OPTARG};; + r) + REPOSITORY_NAME=${OPTARG};; + h) + printf "\nHelp requested.\n" + print_help + printf "\nExiting.\n" + exit 0;; + *) + printf "\nInvalid argument ${OPTARG} (${flag}).\n" + print_help + print_args + exit 1;; + esac +done + +# Test if arguments are missing +if [ -z ${API_URL} ] || [ -z ${BRANCH_TO_SYNC} ] || [ -z ${GITLAB_TRIGGER_TOKEN} ] || [ -z ${GITLAB_MIRROR_PROJECT_ID} ] || [ -z ${REPOSITORY_NAME} ]; then + printf "\nArgument(s) missing!\n" + print_help + print_args + exit 2 +fi + +# Print arguments +print_args + +# Send synchronization request +printf "\n${red}${i}.${no_color} Send request to DSO api.\n\n" + +curl \ + -X POST \ + --fail \ + -F token=${GITLAB_TRIGGER_TOKEN} \ + -F ref=main \ + -F variables[GIT_BRANCH_DEPLOY]=${BRANCH_TO_SYNC} \ + -F variables[PROJECT_NAME]=${REPOSITORY_NAME} \ + "${API_URL}/api/v4/projects/${GITLAB_MIRROR_PROJECT_ID}/trigger/pipeline" diff --git a/.jjconflict-side-2/plugins/gitlab/package.json b/.jjconflict-side-2/plugins/gitlab/package.json new file mode 100644 index 0000000000..5640a675c7 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/package.json @@ -0,0 +1,52 @@ +{ + "name": "@cpn-console/gitlab-plugin", + "type": "module", + "version": "3.3.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "files", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@gitbeaker/core": "catalog:runtime", + "@gitbeaker/requester-utils": "catalog:runtime", + "@gitbeaker/rest": "catalog:runtime", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/plugins/gitlab/src/class.ts b/.jjconflict-side-2/plugins/gitlab/src/class.ts new file mode 100644 index 0000000000..2ddb702b02 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/class.ts @@ -0,0 +1,686 @@ +import type { Project, ProjectMember, UniqueRepo } from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { AccessTokenScopes, AllRepositoryTreesOptions, CommitAction, CondensedProjectSchema, Gitlab, GroupSchema, MemberSchema, ProjectSchema, ProjectVariableSchema, RepositoryFileExpandedSchema, VariableSchema } from '@gitbeaker/core' +import type { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { createHash } from 'node:crypto' +import { PluginApi } from '@cpn-console/hooks' +import { objectEntries } from '@cpn-console/shared' +import { AccessLevel } from '@gitbeaker/core' +import config from './config.js' +import { + customAttributesFilter, + infraGroupCustomAttributeKey, + managedByConsoleCustomAttributeKey, + projectGroupCustomAttributeKey, + upsertCustomAttribute, +} from './custom-attributes.js' +import { logger } from './logger.js' +import { + find, + getAll, + getApi, + getGroupRootId, + infraAppsRepoName, + internalMirrorRepoName, + MAX_PAGINATION_PER_PAGE, + offsetPaginate, +} from './utils.js' + +type setVariableResult = 'created' | 'updated' | 'already up-to-date' +type AccessLevelAllowed = AccessLevel.NO_ACCESS | AccessLevel.MINIMAL_ACCESS | AccessLevel.GUEST | AccessLevel.REPORTER | AccessLevel.DEVELOPER | AccessLevel.MAINTAINER | AccessLevel.OWNER +const infraGroupName = 'Infra' +const infraGroupPath = 'infra' +export const pluginManagedTopic = 'plugin-managed' + +interface GitlabMirrorSecret { + MIRROR_USER: string + MIRROR_TOKEN: string +} + +interface RepoSelect { + mirror?: CondensedProjectSchema + target?: CondensedProjectSchema +} +type PendingCommits = Record +}> + +interface CreateEmptyRepositoryArgs { + repoName: string + description?: string +} + +export class GitlabApi extends PluginApi { + protected api: Gitlab + private pendingCommits: PendingCommits = {} + + constructor() { + super() + this.api = getApi() + } + + public async createEmptyRepository({ createFirstCommit, groupId, repoName, description, ciConfigPath }: CreateEmptyRepositoryArgs & { + createFirstCommit: boolean + groupId: number + ciConfigPath?: string + }) { + logger.debug({ action: 'createEmptyRepository', repoName, groupId, createFirstCommit, ciConfigPath }, 'Create empty repository') + const project = await this.api.Projects.create({ + name: repoName, + path: repoName, + ciConfigPath, + namespaceId: groupId, + description, + }) + try { + await upsertCustomAttribute('projects', project.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createEmptyRepository', projectId: project.id, err }, 'Failed to upsert project custom attribute') + } + // Dépôt tout juste créé, zéro branche => pas d'erreur (filesTree undefined) + if (createFirstCommit) { + await this.api.Commits.create(project.id, 'main', 'ci: 🌱 First commit', []) + } + return project + } + + public async commitCreateOrUpdate( + repoId: number, + fileContent: string, + filePath: string, + branch: string = 'main', + comment: string = 'ci: :robot_face: Update file content', + ): Promise { + logger.debug({ action: 'commitCreateOrUpdate', repoId, filePath, branch }, 'Schedule commit create/update') + let action: CommitAction['action'] = 'create' + + const existingBranch = await find(offsetPaginate(opts => this.api.Branches.all(repoId, opts), { perPage: MAX_PAGINATION_PER_PAGE }), b => b.name === branch) + if (existingBranch) { + let actualFile: RepositoryFileExpandedSchema | undefined + try { + actualFile = await this.api.RepositoryFiles.show(repoId, filePath, branch) + } catch {} + if (actualFile) { + const newContentDigest = createHash('sha256').update(fileContent).digest('hex') + if (actualFile.content_sha256 === newContentDigest) { + // Already up-to-date + return false + } + // Update needed + action = 'update' + } + } + + const commitAction: CommitAction = { + action, + filePath, + content: fileContent, + } + this.addActions(repoId, branch, comment, [commitAction]) + + return true + } + + /** + * Fonction pour supprimer une liste de fichiers d'un repo + * @param repoId + * @param files + * @param branch + * @param comment + */ + public async commitDelete( + repoId: number, + files: string[], + branch: string = 'main', + comment: string = 'ci: :robot_face: Delete files', + ): Promise { + logger.debug({ action: 'commitDelete', repoId, branch, filesCount: files.length }, 'Schedule commit delete') + if (files.length) { + const commitActions: CommitAction[] = files.map((filePath) => { + return { + action: 'delete', + filePath, + } + }) + this.addActions(repoId, branch, comment, commitActions) + return true + } + return false + } + + private addActions(repoId: number, branch: string, comment: string, commitActions: CommitAction[]) { + if (!this.pendingCommits[repoId]) { + this.pendingCommits[repoId] = { branches: {} } + } + if (this.pendingCommits[repoId].branches[branch]) { + this.pendingCommits[repoId].branches[branch].actions.push(...commitActions) + this.pendingCommits[repoId].branches[branch].messages.push(comment) + } else { + this.pendingCommits[repoId].branches[branch] = { + actions: commitActions, + messages: [comment], + } + } + } + + public async commitFiles() { + let filesUpdated: number = 0 + for (const [id, repo] of objectEntries(this.pendingCommits)) { + for (const [branch, details] of objectEntries(repo.branches)) { + const filesNumber = details.actions.length + if (filesNumber) { + filesUpdated += filesNumber + const message = [`ci: :robot_face: Update ${filesNumber} file${filesNumber > 1 ? 's' : ''}`, ...details.messages.filter(m => m)].join('\n') + logger.debug({ action: 'commitFiles', repoId: id, branch, filesNumber }, 'Commit pending file changes') + await this.api.Commits.create(id, branch, message, details.actions) + } + } + } + return filesUpdated + } + + public async listFiles(repoId: number, options: AllRepositoryTreesOptions = {}) { + options.path = options?.path ?? '/' + options.ref = options?.ref ?? 'main' + options.recursive = options?.recursive ?? false + try { + const files = await this.api.Repositories.allRepositoryTrees(repoId, options) + // if (depth >= 0) { + // for (const file of files) { + // if (file.type !== 'tree') { + // return [] + // } + // const childrenFiles = await this.listFiles(repoId, { depth: depth - 1, ...options, path: file.path }) + // files.push(...childrenFiles) + // } + // } + return files + } catch (error) { + const { cause } = error as GitbeakerRequestError + if (cause?.description.includes('Not Found')) { + // Empty repository, with zero commit ==> Zero files + return [] + } else { + throw error + } + } + } + + public async deleteRepository(repoId: number, fullPath: string) { + logger.info({ action: 'deleteRepository', repoId, fullPath }, 'Delete repository') + await this.api.Projects.remove(repoId) // Marks for deletion + return this.api.Projects.remove(repoId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${repoId}` }) // Effective deletion + } +} + +export class GitlabZoneApi extends GitlabApi { + private infraProjectsByZoneSlug: Map + + constructor() { + super() + this.infraProjectsByZoneSlug = new Map() + } + + // Group Infra + public async getOrCreateInfraGroup(): Promise { + logger.debug({ action: 'getOrCreateInfraGroup', infraGroupName }, 'Get/create infra group') + const rootId = await getGroupRootId() + // Get or create projects_root_dir/infra group + const fast = await find( + offsetPaginate(opts => this.api.Groups.all({ + ...customAttributesFilter(infraGroupCustomAttributeKey, 'true'), + ...opts, + })), + group => group.parent_id === rootId, + ) + + const existingParentGroup = fast ?? await find( + offsetPaginate(opts => this.api.Groups.all({ + search: infraGroupName, + ...opts, + })), + group => group.parent_id === rootId && group.name === infraGroupName, + ) + + const group = existingParentGroup || await this.api.Groups.create(infraGroupName, infraGroupPath, { + parentId: rootId, + projectCreationLevel: 'maintainer', + subgroupCreationLevel: 'owner', + defaultBranchProtection: 0, + description: 'Group that hosts infrastructure-as-code repositories for all zones (ArgoCD pull targets).', + }) + try { + await upsertCustomAttribute('groups', group.id, infraGroupCustomAttributeKey, 'true') + await upsertCustomAttribute('groups', group.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getOrCreateInfraGroup', groupId: group.id, err }, 'Failed to upsert infra group custom attribute') + } + return group + } + + public async getOrCreateInfraProject(zone: string): Promise { + logger.debug({ action: 'getOrCreateInfraProject', zone }, 'Get/create infra project for zone') + if (this.infraProjectsByZoneSlug.has(zone)) { + return this.infraProjectsByZoneSlug.get(zone)! + } + const infraGroup = await this.getOrCreateInfraGroup() + // Get or create projects_root_dir/infra/zone + const fast = await find( + offsetPaginate(opts => this.api.Groups.allProjects(infraGroup.id, { + ...customAttributesFilter(managedByConsoleCustomAttributeKey, 'true'), + search: zone, + simple: true, + ...opts, + })), + repo => repo.name === zone, + ) + const project = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allProjects(infraGroup.id, { + search: zone, + simple: true, + ...opts, + })), + repo => repo.name === zone, + ) ?? await this.createEmptyRepository({ + repoName: zone, + groupId: infraGroup.id, + description: 'Repository hosting deployment files for this zone.', + createFirstCommit: true, + }) + this.infraProjectsByZoneSlug.set(zone, project) + return project + } +} + +export class GitlabProjectApi extends GitlabApi { + private project: Project | UniqueRepo | ProjectMember['project'] + private gitlabGroup: GroupSchema | undefined + private specialRepositories: string[] = [infraAppsRepoName, internalMirrorRepoName] + private zoneApi: GitlabZoneApi + + constructor(project: Project | UniqueRepo | ProjectMember['project']) { + super() + this.project = project + this.api = getApi() + this.zoneApi = new GitlabZoneApi() + } + + // Group Project + private async createProjectGroup(): Promise { + logger.info({ action: 'createProjectGroup', projectSlug: this.project.slug }, 'Create project group') + const parentId = await getGroupRootId() + const existingGroup = await find(offsetPaginate(opts => this.api.Groups.all({ + search: this.project.slug, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), group => group.parent_id === parentId && group.name === this.project.slug) + + if (existingGroup) return existingGroup + + const group = await this.api.Groups.create(this.project.slug, this.project.slug, { + parentId, + projectCreationLevel: 'maintainer', + subgroupCreationLevel: 'owner', + defaultBranchProtection: 0, + }) + try { + await upsertCustomAttribute('groups', group.id, projectGroupCustomAttributeKey, this.project.slug) + await upsertCustomAttribute('groups', group.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createProjectGroup', groupId: group.id, err }, 'Failed to upsert project group custom attribute') + } + return group + } + + public async getProjectGroup(): Promise { + logger.debug({ action: 'getProjectGroup', projectSlug: this.project.slug }, 'Get project group') + if (!this.gitlabGroup) { + logger.debug({ action: 'getProjectGroup', projectSlug: this.project.slug }, 'Search project group') + const parentId = await getGroupRootId() + const fast = await find( + offsetPaginate(opts => this.api.Groups.allSubgroups(parentId, { + ...customAttributesFilter(projectGroupCustomAttributeKey, this.project.slug), + ...opts, + })), + group => group.name === this.project.slug, + ) + + this.gitlabGroup = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allSubgroups(parentId, { + search: this.project.slug, + ...opts, + })), + group => group.name === this.project.slug, + ) + + if (this.gitlabGroup) { + try { + await upsertCustomAttribute('groups', this.gitlabGroup.id, projectGroupCustomAttributeKey, this.project.slug) + await upsertCustomAttribute('groups', this.gitlabGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getProjectGroup', groupId: this.gitlabGroup.id, err }, 'Failed to upsert project group custom attribute') + } + } + } + return this.gitlabGroup + } + + public async getOrCreateProjectGroup(): Promise { + logger.debug({ action: 'getOrCreateProjectGroup', projectSlug: this.project.slug }, 'Get/create project group') + const group = await this.getProjectGroup() + if (group) return group + return this.createProjectGroup() + } + + public async getPublicGroupUrl() { + return new URL(`/${config().projectsRootDir}/${this.project.slug}`, config().publicUrl) + } + + public async getInternalGroupUrl() { + return new URL(`/${config().projectsRootDir}/${this.project.slug}`, config().internalUrl) + } + + // Tokens + public async getProjectMirrorCreds(vaultApi: VaultProjectApi): Promise { + logger.debug({ action: 'getProjectMirrorCreds', projectSlug: this.project.slug }, 'Get/create project mirror credentials') + const tokenName = `${this.project.slug}-bot` + const currentToken = await this.getProjectToken(tokenName) + const creds: GitlabMirrorSecret = { + MIRROR_USER: '', + MIRROR_TOKEN: '', + } + if (currentToken) { + const vaultSecret = await vaultApi.read('tech/GITLAB_MIRROR', { throwIfNoEntry: false }) as { data: GitlabMirrorSecret } + if (vaultSecret) { + try { + const group = await this.getProjectGroup() + if (!group) throw new Error('Group not created yet') + + const res = await fetch(`${config().internalUrl}/api/v4/groups/${group.id}`, { + headers: { 'PRIVATE-TOKEN': vaultSecret.data.MIRROR_TOKEN }, + }) + + if (res.ok) { + return vaultSecret.data // valid token hence early exit + } + + throw new Error('Invalid token') + } catch (error) { + logger.warn({ action: 'getProjectMirrorCreds', err: error }, 'Mirror token invalid, revoking project token') + await this.revokeProjectToken(currentToken.id) + } + } + } + const newToken = await this.createProjectToken(tokenName, ['write_repository', 'read_repository', 'read_api']) + creds.MIRROR_TOKEN = newToken.token + creds.MIRROR_USER = newToken.name + await vaultApi.write(creds, 'tech/GITLAB_MIRROR') + return creds + } + + public async getProjectId(projectName: string) { + logger.debug({ action: 'getProjectId', projectName, projectSlug: this.project.slug }, 'Look up project id') + const projectGroup = await this.getProjectGroup() + if (!projectGroup) throw new Error(`Gitlab inaccessible, impossible de trouver le groupe ${this.project.slug}`) + + const fast = await find( + offsetPaginate(opts => this.api.Groups.allProjects(projectGroup.id, { + ...customAttributesFilter(managedByConsoleCustomAttributeKey, 'true'), + search: projectName, + simple: true, + ...opts, + })), + repo => repo.name === projectName, + ) + + const project = fast ?? await find( + offsetPaginate(opts => this.api.Groups.allProjects(projectGroup.id, { + search: projectName, + simple: true, + ...opts, + })), + repo => repo.name === projectName, + ) + + return project?.id + } + + public async getProjectById(projectId: number) { + return this.api.Projects.show(projectId) + } + + public async getOrCreateInfraProject(zone: string) { + return await this.zoneApi.getOrCreateInfraProject(zone) + } + + public async getProjectToken(tokenName: string) { + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + return find(offsetPaginate(opts => this.api.GroupAccessTokens.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE }), token => token.name === tokenName) + } + + public async createProjectToken(tokenName: string, scopes: AccessTokenScopes[]) { + logger.info({ action: 'createProjectToken', tokenName, projectSlug: this.project.slug }, 'Create project access token') + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + const expiryDate = new Date() + expiryDate.setFullYear(expiryDate.getFullYear() + 1) + return this.api.GroupAccessTokens.create(group.id, tokenName, scopes, expiryDate.toLocaleDateString('en-CA')) + } + + public async revokeProjectToken(tokenId: number) { + logger.info({ action: 'revokeProjectToken', tokenId, projectSlug: this.project.slug }, 'Revoke project access token') + const group = await this.getProjectGroup() + if (!group) throw new Error('Unable to retrieve gitlab project group') + return this.api.GroupAccessTokens.revoke(group.id, tokenId) + } + + // Triggers + public async getMirrorProjectTriggerToken(vaultApi: VaultProjectApi) { + logger.debug({ action: 'getMirrorProjectTriggerToken', projectSlug: this.project.slug }, 'Get mirror project trigger token') + const tokenDescription = 'mirroring-from-external-repo' + const gitlabRepositories = await this.listRepositories() + const mirrorRepo = gitlabRepositories.find(repo => repo.name === internalMirrorRepoName) + if (!mirrorRepo) throw new Error('Don\'t know how mirror repo could not exist') + const currentTriggerToken = await find(offsetPaginate(opts => this.api.PipelineTriggerTokens.all(mirrorRepo.id, opts), { perPage: MAX_PAGINATION_PER_PAGE }), token => token.description === tokenDescription) + + const tokenVaultSecret = await vaultApi.read('GITLAB', { throwIfNoEntry: false }) + + if (currentTriggerToken && !tokenVaultSecret?.data?.GIT_MIRROR_TOKEN) { + await this.api.PipelineTriggerTokens.remove(mirrorRepo.id, currentTriggerToken.id) + } + const triggerToken = await this.api.PipelineTriggerTokens.create(mirrorRepo.id, tokenDescription) + return { token: triggerToken.token, repoId: mirrorRepo.id } + } + + // Repositories + public async getPublicRepoUrl(repoName: string) { + return `${await this.getPublicGroupUrl()}/${repoName}.git` + } + + public async getInternalRepoUrl(repoName: string) { + return `${await this.getInternalGroupUrl()}/${repoName}.git` + } + + public async listRepositories() { + const group = await this.getOrCreateProjectGroup() + const projects = await getAll(offsetPaginate(opts => this.api.Groups.allProjects(group.id, { simple: false, ...opts }), { perPage: MAX_PAGINATION_PER_PAGE })) // to refactor with https://github.com/jdalrymple/gitbeaker/pull/3624 + return Promise.all(projects.map(async (project) => { + if (this.specialRepositories.includes(project.name) && (!project.topics || !project.topics.includes(pluginManagedTopic))) { + return this.api.Projects.edit(project.id, { topics: project.topics ? [...project.topics, pluginManagedTopic] : [pluginManagedTopic] }) + } + return project + })) + } + + public async createEmptyProjectRepository({ repoName, description, clone }: CreateEmptyRepositoryArgs & { clone?: boolean }) { + logger.info({ action: 'createEmptyProjectRepository', repoName, projectSlug: this.project.slug, clone }, 'Create empty project repository') + const namespaceId = (await this.getOrCreateProjectGroup()).id + return this.createEmptyRepository({ + repoName, + groupId: namespaceId, + description, + ciConfigPath: clone ? '.gitlab-ci-dso.yml' : undefined, + createFirstCommit: !clone, + }) + } + + // Special Repositories + public async getSpecialRepositories(): Promise { + return this.specialRepositories + } + + public async addSpecialRepositories(name: string) { + logger.debug({ action: 'addSpecialRepositories', name, projectSlug: this.project.slug }, 'Register special repository') + if (!this.specialRepositories.includes(name)) { + this.specialRepositories.push(name) + } + } + + // Group members + public async getGroupMembers() { + const group = await this.getOrCreateProjectGroup() + return getAll(offsetPaginate(opts => this.api.GroupMembers.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async addGroupMember(userId: number, accessLevel: AccessLevelAllowed = AccessLevel.DEVELOPER): Promise { + logger.info({ action: 'addGroupMember', accessLevel, projectSlug: this.project.slug }, 'Add group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.add(group.id, userId, accessLevel) + } + + public async editGroupMember(userId: number, accessLevel: AccessLevelAllowed = AccessLevel.DEVELOPER): Promise { + logger.info({ action: 'editGroupMember', accessLevel, projectSlug: this.project.slug }, 'Edit group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.edit(group.id, userId, accessLevel) + } + + public async removeGroupMember(userId: number) { + logger.info({ action: 'removeGroupMember', projectSlug: this.project.slug }, 'Remove group member') + const group = await this.getOrCreateProjectGroup() + return this.api.GroupMembers.remove(group.id, userId) + } + + // CI Variables + public async getGitlabGroupVariables(): Promise { + const group = await this.getOrCreateProjectGroup() + return await getAll(offsetPaginate(opts => this.api.GroupVariables.all(group.id, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async setGitlabGroupVariable(listVars: VariableSchema[], toSetVariable: VariableSchema): Promise { + const group = await this.getOrCreateProjectGroup() + const currentVariable = listVars.find(v => v.key === toSetVariable.key) + if (currentVariable) { + if ( + currentVariable.masked !== toSetVariable.masked + || currentVariable.value !== toSetVariable.value + || currentVariable.protected !== toSetVariable.protected + || currentVariable.variable_type !== toSetVariable.variable_type + ) { + await this.api.GroupVariables.edit( + group.id, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + filter: { environment_scope: '*' }, + }, + ) + return 'updated' + } + return 'already up-to-date' + } + await this.api.GroupVariables.create( + group.id, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + + }, + ) + return 'created' + } + + public async getGitlabRepoVariables(repoId: number): Promise { + return await getAll(offsetPaginate(opts => this.api.ProjectVariables.all(repoId, opts), { perPage: MAX_PAGINATION_PER_PAGE })) + } + + public async setGitlabRepoVariable(repoId: number, listVars: VariableSchema[], toSetVariable: ProjectVariableSchema): Promise { + const currentVariable = listVars.find(v => v.key === toSetVariable.key) + if (currentVariable) { + if ( + currentVariable.masked !== toSetVariable.masked + || currentVariable.value !== toSetVariable.value + || currentVariable.protected !== toSetVariable.protected + || currentVariable.variable_type !== toSetVariable.variable_type + ) { + await this.api.ProjectVariables.edit( + repoId, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + filter: { + environment_scope: toSetVariable.environment_scope, + }, + }, + ) + return 'updated' + } + return 'already up-to-date' + } + await this.api.ProjectVariables.create( + repoId, + toSetVariable.key, + toSetVariable.value, + { + variableType: toSetVariable.variable_type, + masked: toSetVariable.masked, + protected: toSetVariable.protected, + }, + ) + return 'created' + } + + // Mirror + public async triggerMirror(targetRepo: string, syncAllBranches: boolean, branchName?: string) { + logger.info({ action: 'triggerMirror', targetRepo, syncAllBranches, branchName, projectSlug: this.project.slug }, 'Trigger repository mirror') + if ((await this.getSpecialRepositories()).includes(targetRepo)) { + throw new Error('User requested for invalid mirroring') + } + const repos = await this.listRepositories() + const { mirror, target }: RepoSelect = repos.reduce((acc, repository) => { + if (repository.name === 'mirror') { + acc.mirror = repository + } + if (repository.name === targetRepo) { + acc.target = repository + } + return acc + }, {} as RepoSelect) + if (!mirror) throw new Error('Unable to find mirror repository') + if (!target) throw new Error('Unable to find target repository') + return this.api.Pipelines.create(mirror.id, 'main', { + variables: [ + { + key: 'SYNC_ALL', + value: syncAllBranches.toString(), + }, + { + key: 'GIT_BRANCH_DEPLOY', + value: branchName ?? '', + }, + { + key: 'PROJECT_NAME', + value: targetRepo, + }, + ], + }) + } +} diff --git a/.jjconflict-side-2/plugins/gitlab/src/config.ts b/.jjconflict-side-2/plugins/gitlab/src/config.ts new file mode 100644 index 0000000000..be14dda99c --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/config.ts @@ -0,0 +1,26 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + token: string + projectsRootDir: string + constructor() { + this.token = requiredEnv('GITLAB_TOKEN') + this.publicUrl = removeTrailingSlash(requiredEnv('GITLAB_URL')) + this.projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') + this.internalUrl = process.env.GITLAB_INTERNAL_URL + ? removeTrailingSlash(process.env.GITLAB_INTERNAL_URL) + : this.publicUrl + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-side-2/plugins/gitlab/src/custom-attributes.ts b/.jjconflict-side-2/plugins/gitlab/src/custom-attributes.ts new file mode 100644 index 0000000000..f9ba81bf56 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/custom-attributes.ts @@ -0,0 +1,31 @@ +import { logger } from './logger.js' +import { getApi } from './utils.js' + +export const groupRootCustomAttributeKey = 'cpn_projects_root_dir' +export const infraGroupCustomAttributeKey = 'cpn_infra_group' +export const projectGroupCustomAttributeKey = 'cpn_project_slug' +export const userIdCustomAttributeKey = 'cpn_user_id' +export const managedByConsoleCustomAttributeKey = 'cpn_managed_by_console' + +export function customAttributesFilter(key: string, value: string) { + return { [`custom_attributes[${key}]`]: value } as Record +} + +export async function upsertCustomAttribute(resource: 'groups' | 'projects' | 'users', id: number, key: string, value: string): Promise { + logger.info({ action: 'upsertCustomAttribute', resource, id, key }, 'Upsert custom attribute') + logger.debug({ action: 'upsertCustomAttribute', resource, id, key }, 'Upsert custom attribute details') + const api = getApi() + try { + if (resource === 'groups') { + await api.GroupCustomAttributes.set(id, key, value) + } else if (resource === 'projects') { + await api.ProjectCustomAttributes.set(id, key, value) + } else { + await api.UserCustomAttributes.set(id, key, value) + } + } catch (err) { + logger.warn({ action: 'upsertCustomAttribute', resource, id, key, err }, 'Failed to upsert custom attribute') + throw err + } + logger.info({ action: 'upsertCustomAttribute', resource, id, key }, 'Custom attribute upserted') +} diff --git a/.jjconflict-side-2/plugins/gitlab/src/env.d.ts b/.jjconflict-side-2/plugins/gitlab/src/env.d.ts new file mode 100644 index 0000000000..46f7f90d7c --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-side-2/plugins/gitlab/src/functions.ts b/.jjconflict-side-2/plugins/gitlab/src/functions.ts new file mode 100644 index 0000000000..392245d2da --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/functions.ts @@ -0,0 +1,374 @@ +import type { AdminRole, ClusterObject, PluginResult, Project, ProjectLite, ProjectMember, StepCall, UniqueRepo, ZoneObject } from '@cpn-console/hooks' +import type { GitlabProjectApi } from './class.js' +import type { VaultSecrets } from './utils.js' +import { okStatus, specificallyDisabled } from '@cpn-console/hooks' +import config from './config.js' +import { deleteGroup } from './group.js' +import { + DEFAULT_ADMIN_GROUP_PATH, + DEFAULT_AUDITOR_GROUP_PATH, +} from './infos.js' +import { ensureGroup } from './members.js' +import { ensureRepositories } from './repositories.js' +import { createUsername, getUser, upsertUser } from './user.js' +import { cleanGitlabError } from './utils.js' + +// Check +export const checkApi: StepCall = async (payload) => { + try { + const { users } = payload.args + for (const user of users) { + const userInfos = await getUser({ ...user, username: createUsername(user.email) }) + if (userInfos?.id === 1) { + return { + status: { + result: 'KO', + message: 'Gitlab notify: User 1 (root) should not use Console', + }, + } + } + } + + return { + status: { + result: 'OK', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const getDsoProjectSecrets: StepCall = async (payload) => { + try { + if (!specificallyDisabled(payload.config.gitlab?.displayTriggerHint)) { + // TODO déplacer les secrets dans un dossier pour tout lister plutôt que de sélectionner dans le code + const gitlab = (await payload.apis.vault.read('GITLAB')).data as VaultSecrets['GITLAB'] + /* eslint-disable no-template-curly-in-string */ + const curlCommand = [ + 'curl -X POST --fail', + '-F token=\${GIT_MIRROR_TOKEN}', + '-F ref=main', + '-F variables[GIT_BRANCH_DEPLOY]=\${BRANCH_TO_SYNC}', + '-F variables[PROJECT_NAME]=\${REPOSITORY_NAME}', + `"${config().publicUrl}/api/v4/projects/${gitlab.GIT_MIRROR_PROJECT_ID}/trigger/pipeline"`, + ] + /* eslint-enable */ + const secrets: Record = { + GIT_MIRROR_PROJECT_ID: String(gitlab.GIT_MIRROR_PROJECT_ID), + GIT_MIRROR_TOKEN: gitlab.GIT_MIRROR_TOKEN, + 'CURL COMMAND': curlCommand.join(' \\\n '), + } + + return { + status: { + result: 'OK', + message: 'secret retrieved', + }, + secrets, + } + } + return { + status: { + result: 'OK', + message: 'This feature is disabled', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'OK', + message: 'No secrets found for this project', + }, + } + } +} + +export const upsertDsoProject: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + }, + } + try { + const project = payload.args + const { gitlab: gitlabApi, vault: vaultApi } = payload.apis + + await gitlabApi.getOrCreateProjectGroup() + + await Promise.all(project.users.map(user => + ensureGroup(gitlabApi, project, user, payload.config), + )) + + const projectMirrorCreds = await gitlabApi.getProjectMirrorCreds(vaultApi) + await ensureRepositories(gitlabApi, project, vaultApi, { + botAccount: projectMirrorCreds.MIRROR_USER, + token: projectMirrorCreds.MIRROR_TOKEN, + }) + + const destroySecrets = (await vaultApi.list()) + .filter(path => path.endsWith('-mirror')) + .map(path => path.slice(1, path.length - 7)) + .filter(repoName => !project.repositories.some(projectRepo => projectRepo.internalRepoName === repoName)) + + await Promise.all(destroySecrets + .map(repoName => vaultApi.destroy(`${repoName}-mirror`)), + ) + + const mirrorTriggerToken = await gitlabApi.getMirrorProjectTriggerToken(vaultApi) + + const gitlabSecret: VaultSecrets['GITLAB'] = { + PROJECT_SLUG: project.slug, + GIT_MIRROR_PROJECT_ID: mirrorTriggerToken.repoId, + GIT_MIRROR_TOKEN: mirrorTriggerToken.token, + } + + await vaultApi.write(gitlabSecret, 'GITLAB') + + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const deleteDsoProject: StepCall = async (payload) => { + try { + const group = await payload.apis.gitlab.getProjectGroup() + if (group) await deleteGroup(group.id, group.full_path) + + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +export const syncRepository: StepCall = async (payload) => { + const targetRepo = payload.args.repo + const gitlabApi = payload.apis.gitlab + try { + await gitlabApi.triggerMirror(targetRepo.internalRepoName, targetRepo.syncAllBranches, targetRepo.branchName) + return { + status: { + result: 'OK', + message: 'Ci launched', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'Failed to trigger sync', + }, + } + } +} + +export const upsertZone: StepCall = async (payload) => { + const returnResult: PluginResult = okStatus + try { + const gitlabApi = payload.apis.gitlab + await gitlabApi.getOrCreateInfraProject(payload.args.slug) + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const deleteZone: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + message: 'Deleted', + }, + } + try { + const gitlabApi = payload.apis.gitlab + const zoneRepo = await gitlabApi.getOrCreateInfraProject(payload.args.slug) + await gitlabApi.deleteRepository(zoneRepo.id, zoneRepo.path_with_namespace) + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Can\'t reconcile please inspect logs' + return returnResult + } +} + +export const commitFiles: StepCall = async (payload) => { + const returnResult = payload.results.gitlab + try { + const filesUpdated = await payload.apis.gitlab.commitFiles() + + returnResult.status.message = `${filesUpdated} file${filesUpdated > 1 ? 's' : ''} updated` + return returnResult + } catch (error) { + returnResult.error = cleanGitlabError(error) + returnResult.status.result = 'KO' + returnResult.status.message = 'Failed to commit files' + return returnResult + } +} + +export const upsertAdminRole: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH + const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? DEFAULT_AUDITOR_GROUP_PATH + + const isAdmin = role.oidcGroup === adminGroupPath ? true : undefined + const isAuditor = role.oidcGroup === auditorGroupPath ? true : undefined + + if (isAdmin === undefined && isAuditor === undefined) { + return { + status: { + result: 'OK', + message: 'Not a managed role for GitLab plugin', + }, + } + } + + for (const member of role.members) { + await upsertUser(member, isAdmin, isAuditor) + } + + return { + status: { + result: 'OK', + message: 'Members synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error occured while syncing admin role', + }, + } + } +} + +export const deleteAdminRole: StepCall = async (payload) => { + try { + const role = payload.args + const adminGroupPath = payload.config.gitlab?.adminGroupPath ?? DEFAULT_ADMIN_GROUP_PATH + const auditorGroupPath = payload.config.gitlab?.auditorGroupPath ?? DEFAULT_AUDITOR_GROUP_PATH + + const isAdmin = role.oidcGroup === adminGroupPath ? false : undefined + const isAuditor = role.oidcGroup === auditorGroupPath ? false : undefined + + if (isAdmin === undefined && isAuditor === undefined) { + return { + status: { + result: 'OK', + message: 'Not a managed role for GitLab plugin', + }, + } + } + + for (const member of role.members) { + await upsertUser(member, isAdmin, isAuditor) + } + + return { + status: { + result: 'OK', + message: 'Admin role deleted and members synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error occured while deleting admin role', + }, + } + } +} + +export const upsertProjectMember: StepCall = async (payload) => { + const member = payload.args + const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon + + try { + await Promise.all(member.project.users.map(user => + ensureGroup(gitlabApi, member.project, user, payload.config), + )) + + return { + status: { + result: 'OK', + message: 'Member synced', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error happened while syncing project member', + }, + } + } +} + +export const deleteProjectMember: StepCall = async (payload) => { + const member = payload.args + const { gitlab: gitlabApi } = payload.apis as { gitlab: GitlabProjectApi } // TODO: apis is never type for some resaon + + try { + const userInfos = await getUser({ ...member, id: member.userId, username: createUsername(member.email) }) + if (!userInfos) { + return { + status: { + result: 'OK', + message: 'User not found in GitLab', + }, + } + } + + await gitlabApi.removeGroupMember(userInfos.id) + + return { + status: { + result: 'OK', + message: 'Member deleted', + }, + } + } catch (error) { + return { + error: cleanGitlabError(error), + status: { + result: 'KO', + message: 'An error happened while deleting project member', + }, + } + } +} diff --git a/.jjconflict-side-2/plugins/gitlab/src/group.ts b/.jjconflict-side-2/plugins/gitlab/src/group.ts new file mode 100644 index 0000000000..eabf036561 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/group.ts @@ -0,0 +1,7 @@ +import { getApi } from './utils.js' + +export async function deleteGroup(groupId: number, fullPath: string) { + const api = getApi() + await api.Groups.remove(groupId) // Marks for deletion + return api.Groups.remove(groupId, { permanentlyRemove: true, fullPath: `${fullPath}-deletion_scheduled-${groupId}` }) // Effective deletion +} diff --git a/.jjconflict-side-2/plugins/gitlab/src/index.ts b/.jjconflict-side-2/plugins/gitlab/src/index.ts new file mode 100644 index 0000000000..1eb773c6c6 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/index.ts @@ -0,0 +1,116 @@ +import type { DeclareModuleGenerator, DefaultArgs, Plugin, Project, ProjectMember, UniqueRepo, ZoneObject } from '@cpn-console/hooks' +import { GitlabProjectApi, GitlabZoneApi } from './class.js' +import { + checkApi, + commitFiles, + deleteDsoProject, + deleteProjectMember, + deleteZone, + getDsoProjectSecrets, + syncRepository, + upsertAdminRole, + upsertDsoProject, + upsertProjectMember, + upsertZone, +} from './functions.js' +import infos from './infos.js' +import { logger } from './logger.js' +import monitor from './monitor.js' +import { getOrCreateGroupRoot } from './utils.js' + +const onlyApi = { api: (project: Project | UniqueRepo) => new GitlabProjectApi(project) } + +function start() { + getOrCreateGroupRoot().catch((error) => { + logger.error({ action: 'start', err: error }, 'Hook failed') + throw new Error('Error at gitlab plugin start') + }) +} + +export const plugin: Plugin = { + infos, + subscribedHooks: { + deleteProject: { + ...onlyApi, + steps: { + main: deleteDsoProject, + post: commitFiles, + }, + }, + upsertProject: { + ...onlyApi, + steps: { + check: checkApi, + main: upsertDsoProject, + post: commitFiles, + }, + }, + getProjectSecrets: { steps: { main: getDsoProjectSecrets } }, + syncRepository: { + ...onlyApi, + steps: { + main: syncRepository, + post: commitFiles, + }, + }, + upsertCluster: { + api: () => new GitlabZoneApi(), + steps: { + post: commitFiles, + }, + }, + deleteCluster: { + api: () => new GitlabZoneApi(), + steps: { + post: commitFiles, + }, + }, + upsertZone: { + api: () => new GitlabZoneApi(), + steps: { + pre: upsertZone, + post: commitFiles, + }, + }, + deleteZone: { + api: () => new GitlabZoneApi(), + steps: { + main: deleteZone, + }, + }, + upsertAdminRole: { + steps: { + main: upsertAdminRole, + }, + }, + upsertProjectMember: { + api: member => new GitlabProjectApi(member.project), + steps: { + main: upsertProjectMember, + }, + }, + deleteProjectMember: { + api: member => new GitlabProjectApi(member.project), + steps: { + post: deleteProjectMember, + }, + }, + }, + monitor, + start, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + gitlab: Args extends Project | UniqueRepo | ProjectMember['project'] + ? GitlabProjectApi + : Args extends ZoneObject + ? GitlabZoneApi + : never + } + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} + interface PluginResult { + warnReasons?: string[] + } +} diff --git a/.jjconflict-side-2/plugins/gitlab/src/infos.ts b/.jjconflict-side-2/plugins/gitlab/src/infos.ts new file mode 100644 index 0000000000..38f5d10eac --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/infos.ts @@ -0,0 +1,108 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED, ENABLED } from '@cpn-console/shared' +import config from './config.js' + +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer,/console/devops' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly' + +const infos = { + name: 'gitlab', + to: ({ project }) => `${config().publicUrl}/${config().projectsRootDir}/${project.slug}`, + title: 'Gitlab', + imgSrc: '/img/gitlab.svg', + description: 'GitLab est un service d\'hébergement de code source et de pipeline CI/CD', + config: { + global: [ + { + kind: 'switch', + key: 'purge', + initialValue: DISABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Purger les utilisateurs non synchronisés', + value: DISABLED, + description: 'Purger les utilisateurs non synchronisés de GitLab lors de la synchronisation', + }, + { + kind: 'switch', + key: 'displayTriggerHint', + initialValue: ENABLED, + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Afficher l\'aide de déclenchement de pipeline', + value: ENABLED, + description: 'Afficher l\'aide de déclenchement de pipeline aux utilisateurs lorsqu\'ils souhaitent afficher les secrets du projet', + }, + { + kind: 'text', + key: 'adminGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Admin', + value: DEFAULT_ADMIN_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'administrateur GitLab', + placeholder: DEFAULT_ADMIN_GROUP_PATH, + }, + { + kind: 'text', + key: 'auditorGroupPath', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Chemin du groupe OIDC Auditeur', + value: DEFAULT_AUDITOR_GROUP_PATH, + description: 'Le chemin du groupe OIDC qui donne les droits d\'auditeur GitLab', + placeholder: DEFAULT_AUDITOR_GROUP_PATH, + }, + { + kind: 'text', + key: 'projectMaintainerGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Maintainer', + value: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Maintainer', + placeholder: DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectDeveloperGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Developer', + value: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Developer', + placeholder: DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + }, + { + kind: 'text', + key: 'projectReporterGroupPathSuffix', + permissions: { + admin: { read: true, write: true }, + user: { read: false, write: false }, + }, + title: 'Suffixe du chemin du groupe OIDC Reporter', + value: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + description: 'Suffixe du groupe OIDC donnant accès Reporter', + placeholder: DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, + }, + ], + project: [], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-side-2/plugins/gitlab/src/logger.ts b/.jjconflict-side-2/plugins/gitlab/src/logger.ts new file mode 100644 index 0000000000..8d4c5caea7 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'gitlab' }) diff --git a/.jjconflict-side-2/plugins/gitlab/src/members.ts b/.jjconflict-side-2/plugins/gitlab/src/members.ts new file mode 100644 index 0000000000..2e558b18a5 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/members.ts @@ -0,0 +1,69 @@ +import type { Config, Project, Role, UserObject } from '@cpn-console/hooks' +import type { GitlabProjectApi } from './class.js' +import { AccessLevel } from '@gitbeaker/core' +import { + DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, + DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, +} from './infos.js' +import { createUsername, upsertUser } from './user.js' +import { matchRole } from './utils.js' + +export function getGroupAccessLevelFromProjectRole(project: Project, user: UserObject, config: Config) { + const projectReporterGroupPathSuffixes = (config.gitlab?.projectReporterGroupPathSuffix ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX).split(',') + const projectDeveloperGroupPathSuffixes = (config.gitlab?.projectDeveloperGroupPathSuffix ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX).split(',') + const projectMaintainerGroupPathSuffixes = (config.gitlab?.projectMaintainerGroupPathSuffix ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX).split(',') + + const getAccessLevel = (role: Role): number | null => { + // TODO: update to AccessLevel.GUEST once we migrated pre-fine grained + // projects members to developer access level via database migrations + if (!role.oidcGroup) return AccessLevel.DEVELOPER + if (matchRole(project.slug, role.oidcGroup, projectReporterGroupPathSuffixes)) return AccessLevel.REPORTER + if (matchRole(project.slug, role.oidcGroup, projectDeveloperGroupPathSuffixes)) return AccessLevel.DEVELOPER + if (matchRole(project.slug, role.oidcGroup, projectMaintainerGroupPathSuffixes)) return AccessLevel.MAINTAINER + return null + } + + // TODO: update to AccessLevel.GUEST once we migrated pre-fine grained + // projects members to developer access level via database migrations + return project.roles.reduce((highestAccessLevel, role) => { + if (role.users.some(userRole => userRole.id === user.id)) { + const level = getAccessLevel(role) + if (level && level > (highestAccessLevel ?? 0)) return level + } + return highestAccessLevel + }, AccessLevel.DEVELOPER) +} + +export function getGroupAccessLevel(project: Project, user: UserObject, config: Config): number | null { + if (project.owner.id === user.id) return AccessLevel.OWNER + return getGroupAccessLevelFromProjectRole(project, user, config) +} + +export async function ensureGroup( + gitlabApi: GitlabProjectApi, + project: Project, + user: UserObject, + config: Config, +) { + const gitlabUser = await upsertUser({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + }) + + const groupMembers = await gitlabApi.getGroupMembers() + const existingMember = groupMembers.find(m => m.username === createUsername(user.email)) + const maxAccessLevel = getGroupAccessLevel(project, user, config) + + if (existingMember && maxAccessLevel) { + if (existingMember.access_level !== maxAccessLevel) { + await gitlabApi.editGroupMember(gitlabUser.id, maxAccessLevel) + } + } else if (maxAccessLevel) { + await gitlabApi.addGroupMember(gitlabUser.id, maxAccessLevel) + } else if (existingMember) { + await gitlabApi.removeGroupMember(gitlabUser.id) + } +} diff --git a/.jjconflict-side-2/plugins/gitlab/src/monitor.ts b/.jjconflict-side-2/plugins/gitlab/src/monitor.ts new file mode 100644 index 0000000000..d190d6ed51 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/monitor.ts @@ -0,0 +1,52 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import config from './config.js' + +enum HealthStatus { + failed = 'failed', + ok = 'ok', +} +type GitlabRes = Record }> +const coreComponents = ['gitaly_check', 'master_check', 'db_check', 'sessions_check'] + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(`${config().internalUrl}/-/readiness?all=1`, { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + const data = res.data as GitlabRes + const failedComponents = Object.entries(data) + .reduce((acc, [name, value]) => { + if (value.status === HealthStatus.failed) return [...acc, name] + return acc + }, [] as string[]) + const failedCoreComponents = failedComponents.filter(name => coreComponents.includes(name)) + + if (failedCoreComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } + if (failedComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.WARNING + instance.lastStatus.message = 'Service dégradé' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Fatal Error' + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-2/plugins/gitlab/src/project.ts b/.jjconflict-side-2/plugins/gitlab/src/project.ts new file mode 100644 index 0000000000..df3dc8e67e --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/project.ts @@ -0,0 +1,52 @@ +import * as fs from 'node:fs/promises' +import path from 'node:path' + +import { getApi } from './utils.js' + +export async function provisionMirror(repoId: number) { + const baseDir = path.resolve(import.meta.url, '../../files/').split(':')[1] + + const gitlabCiYml = ( + await fs.readFile(`${baseDir}/.gitlab-ci.yml`) + ).toString() + const mirrorSh = (await fs.readFile(`${baseDir}/mirror.sh`)).toString() + + const mirrorFirstActions: CommitAction[] = [ + { + action: 'create', + filePath: '.gitlab-ci.yml', + content: gitlabCiYml, + execute_filemode: false, + }, + { + action: 'create', + filePath: 'mirror.sh', + content: mirrorSh, + execute_filemode: true, + }, + ] + const api = getApi() + await api.Commits.create( + repoId, + 'main', + 'ci: :construction_worker: first mirror', + mirrorFirstActions, + ) +} + +interface CommitAction { + /** The action to perform */ + action: 'create' | 'delete' | 'move' | 'update' | 'chmod' + /** Full path to the file. Ex. lib/class.rb */ + filePath: string + /** Original full path to the file being moved.Ex.lib / class1.rb */ + previousPath?: string + /** File content, required for all except delete. Optional for move */ + content?: string + /** text or base64. text is default. */ + encoding?: string + /** Last known file commit id. Will be only considered in update, move and delete actions. */ + lastCommitId?: string + /** When true/false enables/disables the execute flag on the file. Only considered for chmod action. */ + execute_filemode?: boolean +} diff --git a/.jjconflict-side-2/plugins/gitlab/src/repositories.ts b/.jjconflict-side-2/plugins/gitlab/src/repositories.ts new file mode 100644 index 0000000000..00797cf446 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/repositories.ts @@ -0,0 +1,93 @@ +import type { Project, Repository } from '@cpn-console/hooks' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { CondensedProjectSchema, ProjectSchema } from '@gitbeaker/rest' +import type { GitlabProjectApi } from './class.js' +import { shallowEqual } from '@cpn-console/shared' +import { pluginManagedTopic } from './class.js' +import { provisionMirror } from './project.js' +import { infraAppsRepoName, internalMirrorRepoName } from './utils.js' + +interface ProjectMirrorCreds { + botAccount: string + token: string +} + +export async function ensureRepositories(gitlabApi: GitlabProjectApi, project: Project, vaultApi: VaultProjectApi, projectMirrorCreds: ProjectMirrorCreds) { + const specialRepos = await gitlabApi.getSpecialRepositories() + const gitlabRepositories = await gitlabApi.listRepositories() + + const promises: Promise[] = [ + // delete excess repositories + ...gitlabRepositories + .filter(gitlabRepository => ( + !specialRepos.includes(gitlabRepository.name) + && !gitlabRepository.topics?.includes(pluginManagedTopic) + && !project.repositories.some(repo => repo.internalRepoName === gitlabRepository.name))) + .map(gitlabRepository => gitlabApi.deleteRepository(gitlabRepository.id, gitlabRepository.path_with_namespace)), + // create missing repositories + ...project.repositories.map(repo => ensureRepositoryExists(gitlabRepositories, repo, gitlabApi, projectMirrorCreds, vaultApi)), + ] + + if (!gitlabRepositories.some(repo => repo.name === infraAppsRepoName)) { + promises.push( + gitlabApi.createEmptyProjectRepository({ repoName: infraAppsRepoName, clone: false }), + ) + } + if (!gitlabRepositories.some(repo => repo.name === internalMirrorRepoName)) { + promises.push( + gitlabApi.createEmptyProjectRepository({ repoName: internalMirrorRepoName, clone: false }) + .then(mirrorRepo => provisionMirror(mirrorRepo.id)), + ) + } + + await Promise.all(promises) +} + +const urnRegexp = /:\/\/(.*)/s + +async function ensureRepositoryExists(gitlabRepositories: CondensedProjectSchema[], repository: Repository, gitlabApi: GitlabProjectApi, projectMirrorCreds: ProjectMirrorCreds, vaultApi: VaultProjectApi) { + const gitlabRepository: CondensedProjectSchema | ProjectSchema | void = gitlabRepositories.find(gitlabRepository => gitlabRepository.name === repository.internalRepoName) + const externalRepoUrn = repository.externalRepoUrl.split(urnRegexp)[1] + const vaultCredsPath = `${repository.internalRepoName}-mirror` + const currentVaultSecret = await vaultApi.read(vaultCredsPath, { throwIfNoEntry: false }) + + if (!gitlabRepository) { + await gitlabApi.createEmptyProjectRepository({ + repoName: repository.internalRepoName, + description: undefined, + clone: !!repository.externalRepoUrl, + }) + } + + if (!repository.externalRepoUrl) { + return currentVaultSecret && vaultApi.destroy(vaultCredsPath) + } + + let gitInputUser: string | undefined + let gitInputPassword: string | undefined + if (currentVaultSecret?.data) { + gitInputUser = currentVaultSecret.data.GIT_INPUT_USER + gitInputPassword = currentVaultSecret.data.GIT_INPUT_PASSWORD + } + + const internalRepoUrl = await gitlabApi.getInternalRepoUrl(repository.internalRepoName) + + const mirrorSecretData = { + GIT_INPUT_URL: externalRepoUrn, + GIT_INPUT_USER: repository.isPrivate + ? (repository.newCreds?.username || gitInputUser) + : undefined, + GIT_INPUT_PASSWORD: repository.isPrivate + ? (repository.newCreds?.token || gitInputPassword) + : undefined, + GIT_OUTPUT_URL: internalRepoUrl.split(urnRegexp)[1], + GIT_OUTPUT_USER: projectMirrorCreds.botAccount, + GIT_OUTPUT_PASSWORD: projectMirrorCreds.token, + } + if ( + !currentVaultSecret?.data + || !shallowEqual(mirrorSecretData, currentVaultSecret.data) + ) { + await vaultApi.write(mirrorSecretData, vaultCredsPath) + } +} diff --git a/.jjconflict-side-2/plugins/gitlab/src/user.ts b/.jjconflict-side-2/plugins/gitlab/src/user.ts new file mode 100644 index 0000000000..5f638e4bc1 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/user.ts @@ -0,0 +1,107 @@ +import type { UserObject } from '@cpn-console/hooks' +import type { CreateUserOptions, SimpleUserSchema } from '@gitbeaker/rest' +import { upsertCustomAttribute, userIdCustomAttributeKey } from './custom-attributes.js' +import { logger } from './logger.js' +import { find, getApi, offsetPaginate } from './utils.js' + +export function createUsername(email: string) { + const parts = email.split('@') + if (parts.length > 0) { + return parts[0] + } + return email +} + +export async function getUser(user: { email: string, username: string, id: string }): Promise { + const api = getApi() + + const isUser = (gitlabUser: SimpleUserSchema) => + gitlabUser?.externUid === user.id + || gitlabUser?.externUid === user.email + || gitlabUser.email === user.email + || gitlabUser.username === user.username + + const fast = await find( + offsetPaginate(opts => api.Users.all({ + externUid: user.email, + provider: 'openid_connect', + orderBy: 'username', + asAdmin: true, + ...opts, + })), + isUser, + ) + + if (!fast) { + logger.debug({ action: 'getUser', user }, 'User not found in fast search') + } + + return fast ?? await find( + offsetPaginate(opts => api.Users.all({ + search: user.username, + asAdmin: true, + ...opts, + })), + isUser, + ) +} + +export async function upsertUser(user: UserObject, isAdmin?: boolean, isAuditor?: boolean): Promise { + const api = getApi() + const username = createUsername(user.email) + const existingUser = await getUser({ ...user, username }) + + const userDefinitionBase: CreateUserOptions = { + // required options + name: `${user.firstName} ${user.lastName}`, + username, + email: user.email, + // sso options + externUid: user.email, + provider: 'openid_connect', + admin: isAdmin, + auditor: isAuditor, + } + + if (existingUser) { + const incorrectProps = Object.entries(userDefinitionBase).reduce((acc, [key, value]) => { + if (existingUser[key] !== value) { + acc.push({ + key, + curr: existingUser[key], + new: value, + }) + } + return acc + }, [] as { key: string, curr: any, new: any }[]) + + if (incorrectProps.length) { + logger.debug({ action: 'upsertUser', changes: incorrectProps }, 'User properties differ from expected') + try { + await api.Users.edit(existingUser.id, userDefinitionBase) + } catch (err) { + logger.error({ action: 'upsertUser', err }, 'Failed to update user') + } + } + try { + await upsertCustomAttribute('users', existingUser.id, userIdCustomAttributeKey, user.id) + } catch (err) { + logger.debug({ action: 'upsertUser', userId: existingUser.id, err }, 'Failed to upsert user custom attribute') + } + return existingUser + } + + const created = await api.Users.create({ + ...userDefinitionBase, + canCreateGroup: false, + forceRandomPassword: true, + projectsLimit: 0, + skipConfirmation: true, + }) + try { + await upsertCustomAttribute('users', created.id, userIdCustomAttributeKey, user.id) + } catch (err) { + logger.debug({ action: 'upsertUser', userId: created.id, err }, 'Failed to upsert user custom attribute') + } + return created +} diff --git a/.jjconflict-side-2/plugins/gitlab/src/utils.ts b/.jjconflict-side-2/plugins/gitlab/src/utils.ts new file mode 100644 index 0000000000..3dbe69b772 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/src/utils.ts @@ -0,0 +1,198 @@ +import type { BaseRequestOptions, Gitlab as IGitlab, OffsetPagination, PaginationRequestOptions } from '@gitbeaker/core' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { Gitlab } from '@gitbeaker/rest' +import config from './config.js' +import { customAttributesFilter, groupRootCustomAttributeKey, managedByConsoleCustomAttributeKey, upsertCustomAttribute } from './custom-attributes.js' +import { logger } from './logger.js' + +let api: IGitlab | undefined + +let groupRootId: number + +export const MAX_PAGINATION_PER_PAGE = 100 + +export async function getGroupRootId(throwIfNotFound?: true): Promise +export async function getGroupRootId(throwIfNotFound?: false): Promise +export async function getGroupRootId(throwIfNotFound?: boolean): Promise { + const gitlabApi = getApi() + const projectRootDir = config().projectsRootDir + logger.debug({ action: 'getGroupRootId', projectRootDir }, 'Resolve group root id') + if (groupRootId) return groupRootId + const fast = await find( + offsetPaginate(opts => gitlabApi.Groups.all({ + ...customAttributesFilter(groupRootCustomAttributeKey, projectRootDir), + ...opts, + })), + grp => grp.full_path === projectRootDir, + ) + const groupRoot = fast ?? await find( + offsetPaginate(opts => gitlabApi.Groups.all({ + search: projectRootDir, + ...opts, + })), + grp => grp.full_path === projectRootDir, + ) + logger.debug({ action: 'getGroupRootId', groupRootId: groupRoot?.id, groupRootPath: groupRoot?.full_path }, 'Resolved group root') + const searchId = groupRoot?.id + if (typeof searchId === 'undefined') { + if (throwIfNotFound) { + throw new Error(`Gitlab inaccessible, impossible de trouver le groupe RACINE ${projectRootDir}`) + } + return searchId + } + groupRootId = searchId + try { + await upsertCustomAttribute('groups', groupRootId, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', groupRootId, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'getGroupRootId', groupRootId, err }, 'Failed to upsert group root custom attribute') + } + return groupRootId +} + +async function createGroupRoot(): Promise { + logger.info({ action: 'createGroupRoot', projectRootDir: config().projectsRootDir }, 'Create group root hierarchy') + const gitlabApi = getApi() + const projectRootDir = config().projectsRootDir + const projectRootDirArray = projectRootDir.split('/') + + const rootGroupPath = projectRootDirArray.shift() + if (!rootGroupPath) { + throw new Error('No projectRootDir available') + } + + let parentGroup = await find(offsetPaginate(opts => gitlabApi.Groups.all({ + search: rootGroupPath, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), grp => grp.full_path === rootGroupPath) ?? await gitlabApi.Groups.create(rootGroupPath, rootGroupPath) + + if (parentGroup.full_path === projectRootDir) { + try { + await upsertCustomAttribute('groups', parentGroup.id, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', parentGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createGroupRoot', groupRootId: parentGroup.id, err }, 'Failed to upsert group root custom attribute') + } + return parentGroup.id + } + + for (const path of projectRootDirArray) { + const futureFullPath = `${parentGroup.full_path}/${path}` + parentGroup = await find(offsetPaginate(opts => gitlabApi.Groups.all({ + search: futureFullPath, + ...opts, + }), { perPage: MAX_PAGINATION_PER_PAGE }), grp => grp.full_path === futureFullPath) ?? await gitlabApi.Groups.create(path, path, { parentId: parentGroup.id, visibility: 'internal' }) + + if (parentGroup.full_path === projectRootDir) { + try { + await upsertCustomAttribute('groups', parentGroup.id, groupRootCustomAttributeKey, projectRootDir) + await upsertCustomAttribute('groups', parentGroup.id, managedByConsoleCustomAttributeKey, 'true') + } catch (err) { + logger.debug({ action: 'createGroupRoot', groupRootId: parentGroup.id, err }, 'Failed to upsert group root custom attribute') + } + return parentGroup.id + } + } + throw new Error('No projectRootDir available or is malformed') +} + +export async function getOrCreateGroupRoot(): Promise { + return await getGroupRootId(false) ?? createGroupRoot() +} + +export function getApi(): IGitlab { + api ??= new Gitlab({ token: config().token, host: config().internalUrl }) + return api +} + +export const infraAppsRepoName = 'infra-apps' +export const internalMirrorRepoName = 'mirror' + +export interface VaultSecrets { + GITLAB: { + PROJECT_SLUG: string + GIT_MIRROR_PROJECT_ID: number + GIT_MIRROR_TOKEN: string + } +} + +// eslint-disable-next-line regexp/no-super-linear-backtracking +const keyValueRegExp = /\/\/(.*):(.*)@/g + +export function cleanGitlabError(error: T): T { + if (error instanceof GitbeakerRequestError && error.cause?.description) { + error.cause.description = String(error.cause.description).replaceAll(keyValueRegExp, '//MASKED:MASKED@') + } + return error +} + +export function matchRole(projectSlug: string, roleOidcGroup: string, configuredRolePath: string[]) { + return configuredRolePath.some(path => roleOidcGroup === `/${projectSlug}${path}`) +} + +export interface OffsetPaginateOptions { + startPage?: number + perPage?: number + maxPages?: number +} + +export async function* offsetPaginate( + request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, + options?: OffsetPaginateOptions, +): AsyncGenerator { + let page: number | null = options?.startPage ?? 1 + let pagesFetched = 0 + let total: number = 0 + logger.debug({ action: 'offsetPaginate', page }, 'Pagination start') + while (page !== null) { + if (options?.maxPages && pagesFetched >= options.maxPages) { + page = null + continue + } + try { + const { data, paginationInfo } = await request({ + page, + perPage: options?.perPage, + maxPages: options?.maxPages, + showExpanded: true, + pagination: 'offset', + }) + pagesFetched += 1 + total += data.length + logger.debug( + { action: 'offsetPaginate', page, nextPage: paginationInfo.next, items: data.length, total }, + 'Pagination page fetched', + ) + for (const item of data) { + yield item + } + page = paginationInfo.next + } catch (error) { + logger.error({ action: 'offsetPaginate', page, err: error }, 'Pagination request failed') + throw error + } + } + logger.debug({ action: 'offsetPaginate', total }, 'Pagination done') +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} + +export async function find( + iterable: AsyncIterable, + predicate: (item: T) => boolean, +): Promise { + for await (const item of iterable) { + if (predicate(item)) { + return item + } + } + return undefined +} diff --git a/.jjconflict-side-2/plugins/gitlab/tsconfig.eslint.json b/.jjconflict-side-2/plugins/gitlab/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-2/plugins/gitlab/tsconfig.json b/.jjconflict-side-2/plugins/gitlab/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-2/plugins/gitlab/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-2/plugins/harbor/.gitignore b/.jjconflict-side-2/plugins/harbor/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-2/plugins/harbor/eslint.config.js b/.jjconflict-side-2/plugins/harbor/eslint.config.js new file mode 100644 index 0000000000..3148148bf5 --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/eslint.config.js @@ -0,0 +1,4 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +// Generated by `pnpm run swagger:generate` — never linted or hand-edited. +export default eslintConfigBase.append({ ignores: ['src/api/Api.ts'] }) diff --git a/.jjconflict-side-2/plugins/harbor/package.json b/.jjconflict-side-2/plugins/harbor/package.json new file mode 100644 index 0000000000..ec8a51bb24 --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/package.json @@ -0,0 +1,55 @@ +{ + "name": "@cpn-console/harbor-plugin", + "type": "module", + "version": "2.3.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types", + "utils.d.ts" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./", + "swagger:generate": "node ./scripts/generate-api.mjs" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime", + "bytes": "catalog:runtime", + "cron-validator": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "js-yaml": "catalog:tools", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "swagger-typescript-api": "catalog:build", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/plugins/harbor/scripts/generate-api.mjs b/.jjconflict-side-2/plugins/harbor/scripts/generate-api.mjs new file mode 100644 index 0000000000..2633066ec9 --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/scripts/generate-api.mjs @@ -0,0 +1,37 @@ +/** + * Generates `src/api/Api.ts` from Harbor's OpenAPI/Swagger spec. + * + * The spec is fetched and parsed with `js-yaml` before being handed to + * `swagger-typescript-api`. This is deliberate: swagger-typescript-api v13 + * bundles a strict YAML parser that rejects Harbor's multi-line single-quoted + * `description` scalars, so we parse the YAML ourselves and pass the resulting + * JSON spec object programmatically. + * + * Bump HARBOR_TAG to regenerate against a newer Harbor release. + */ +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import yaml from 'js-yaml' +import { generateApi } from 'swagger-typescript-api' + +const HARBOR_TAG = process.env.HARBOR_TAG ?? 'v2.13.5' +const SPEC_URL = `https://raw.githubusercontent.com/goharbor/harbor/${HARBOR_TAG}/api/v2.0/swagger.yaml` + +const outputDir = resolve(dirname(fileURLToPath(import.meta.url)), '../src/api') + +console.log(`Fetching Harbor ${HARBOR_TAG} spec from ${SPEC_URL}`) +const res = await fetch(SPEC_URL) +if (!res.ok) { + throw new Error(`Failed to fetch spec (${res.status} ${res.statusText})`) +} + +const spec = yaml.load(await res.text()) + +await generateApi({ + spec, + output: outputDir, + fileName: 'Api.ts', + httpClientType: 'axios', +}) + +console.log(`Generated ${outputDir}/Api.ts`) diff --git a/.jjconflict-side-2/plugins/harbor/src/api/Api.ts b/.jjconflict-side-2/plugins/harbor/src/api/Api.ts new file mode 100644 index 0000000000..2616919eba --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/src/api/Api.ts @@ -0,0 +1,8030 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +/** The error array that describe the errors got during the handling of request */ +export interface Errors { + errors?: Error[]; +} + +/** a model for all the error response coming from harbor */ +export interface Error { + /** The error code */ + code?: string; + /** The error message */ + message?: string; +} + +export interface Search { + /** Search results of the projects that matched the filter keywords. */ + project?: Project[]; + /** Search results of the repositories that matched the filter keywords. */ + repository?: SearchRepository[]; +} + +export interface SearchRepository { + /** The ID of the project that the repository belongs to */ + project_id?: number; + /** The name of the project that the repository belongs to */ + project_name?: string; + /** The flag to indicate the publicity of the project that the repository belongs to (1 is public, 0 is not) */ + project_public?: boolean; + /** The name of the repository */ + repository_name?: string; + /** The count how many times the repository is pulled */ + pull_count?: number; + /** The count of artifacts in the repository */ + artifact_count?: number; +} + +export interface Repository { + /** + * The ID of the repository + * @format int64 + */ + id?: number; + /** + * The ID of the project that the repository belongs to + * @format int64 + */ + project_id?: number; + /** The name of the repository */ + name?: string; + /** The description of the repository */ + description?: string; + /** + * The count of the artifacts inside the repository + * @format int64 + */ + artifact_count: number; + /** + * The count that the artifact inside the repository pulled + * @format int64 + */ + pull_count: number; + /** + * The creation time of the repository + * @format date-time + */ + creation_time?: string | null; + /** + * The update time of the repository + * @format date-time + */ + update_time?: string; +} + +export interface Artifact { + /** + * The ID of the artifact + * @format int64 + */ + id?: number; + /** The type of the artifact, e.g. image, chart, etc */ + type?: string; + /** The media type of the artifact */ + media_type?: string; + /** The manifest media type of the artifact */ + manifest_media_type?: string; + /** The artifact_type in the manifest of the artifact */ + artifact_type?: string; + /** + * The ID of the project that the artifact belongs to + * @format int64 + */ + project_id?: number; + /** + * The ID of the repository that the artifact belongs to + * @format int64 + */ + repository_id?: number; + /** The name of the repository that the artifact belongs to */ + repository_name?: string; + /** The digest of the artifact */ + digest?: string; + /** + * The size of the artifact + * @format int64 + */ + size?: number; + /** The digest of the icon */ + icon?: string; + /** + * The push time of the artifact + * @format date-time + */ + push_time?: string; + /** + * The latest pull time of the artifact + * @format date-time + */ + pull_time?: string; + extra_attrs?: ExtraAttrs; + annotations?: Annotations; + references?: Reference[]; + tags?: Tag[]; + addition_links?: AdditionLinks; + labels?: Label[]; + /** The overview of the scan result. */ + scan_overview?: ScanOverview; + /** The overview of the generating SBOM progress */ + sbom_overview?: SBOMOverview; + accessories?: Accessory[]; +} + +export interface Tag { + /** + * The ID of the tag + * @format int64 + */ + id?: number; + /** + * The ID of the repository that the tag belongs to + * @format int64 + */ + repository_id?: number; + /** + * The ID of the artifact that the tag attached to + * @format int64 + */ + artifact_id?: number; + /** The name of the tag */ + name?: string; + /** + * The push time of the tag + * @format date-time + */ + push_time?: string; + /** + * The latest pull time of the tag + * @format date-time + */ + pull_time?: string; + /** The immutable status of the tag */ + immutable: boolean; +} + +export type ExtraAttrs = Record; + +export type Annotations = Record; + +export type AdditionLinks = Record; + +export interface AdditionLink { + /** The link of the addition */ + href?: string; + /** Determine whether the link is an absolute URL or not */ + absolute: boolean; +} + +export interface Reference { + /** + * The parent ID of the reference + * @format int64 + */ + parent_id?: number; + /** + * The child ID of the reference + * @format int64 + */ + child_id?: number; + /** The digest of the child artifact */ + child_digest?: string; + platform?: Platform; + annotations?: Annotations; + /** The download URLs */ + urls?: string[]; +} + +export interface Platform { + /** The architecture that the artifact applys to */ + architecture?: string; + /** The OS that the artifact applys to */ + os?: string; + /** The version of the OS that the artifact applys to */ + "'os.version'"?: string; + /** The features of the OS that the artifact applys to */ + "'os.features'"?: string[]; + /** The variant of the CPU */ + variant?: string; +} + +export interface Label { + /** + * The ID of the label + * @format int64 + */ + id?: number; + /** The name the label */ + name?: string; + /** The description the label */ + description?: string; + /** The color the label */ + color?: string; + /** The scope the label */ + scope?: string; + /** + * The ID of project that the label belongs to + * @format int64 + */ + project_id?: number; + /** + * The creation time the label + * @format date-time + */ + creation_time?: string; + /** + * The update time of the label + * @format date-time + */ + update_time?: string; +} + +export interface Scanner { + /** + * Name of the scanner + * @example "Trivy" + */ + name?: string; + /** + * Name of the scanner provider + * @example "Aqua Security" + */ + vendor?: string; + /** + * Version of the scanner adapter + * @example "v0.9.1" + */ + version?: string; +} + +/** The scan overview attached in the metadata of tag */ +export type ScanOverview = Record; + +/** The generate SBOM overview information */ +export interface SBOMOverview { + /** + * The start time of the generating sbom report task + * @format date-time + * @example "2006-01-02T14:04:05Z" + */ + start_time?: string; + /** + * The end time of the generating sbom report task + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + end_time?: string; + /** The status of the generating SBOM task */ + scan_status?: string; + /** The digest of the generated SBOM accessory */ + sbom_digest?: string; + /** + * id of the native scan report + * @example "5f62c830-f996-11e9-957f-0242c0a89008" + */ + report_id?: string; + /** + * Time in seconds required to create the report + * @format int64 + * @example 300 + */ + duration?: number; + scanner?: Scanner; +} + +/** The summary for the native report */ +export interface NativeReportSummary { + /** + * id of the native scan report + * @example "5f62c830-f996-11e9-957f-0242c0a89008" + */ + report_id?: string; + /** + * The status of the report generating process + * @example "Success" + */ + scan_status?: string; + /** + * The overall severity + * @example "High" + */ + severity?: string; + /** + * The seconds spent for generating the report + * @format int64 + * @example 300 + */ + duration?: number; + /** VulnerabilitySummary contains the total number of the foun d vulnerabilities number and numbers of each severity level. */ + summary?: VulnerabilitySummary; + /** + * The start time of the scan process that generating report + * @format date-time + * @example "2006-01-02T14:04:05Z" + */ + start_time?: string; + /** + * The end time of the scan process that generating report + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + end_time?: string; + /** + * The complete percent of the scanning which value is between 0 and 100 + * @example 100 + */ + complete_percent?: number; + scanner?: Scanner; +} + +/** VulnerabilitySummary contains the total number of the foun d vulnerabilities number and numbers of each severity level. */ +export interface VulnerabilitySummary { + /** + * The total number of the found vulnerabilities + * @format int + * @example 500 + */ + total: number; + /** + * The number of the fixable vulnerabilities + * @format int + * @example 100 + */ + fixable: number; + /** + * Numbers of the vulnerabilities with different severity + * @example {"Critical":5,"High":5} + */ + summary: Record; +} + +export interface AuditLog { + /** The ID of the audit log entry. */ + id?: number; + /** Username of the user in this log entry. */ + username?: string; + /** Name of the repository in this log entry. */ + resource?: string; + /** Tag of the repository in this log entry. */ + resource_type?: string; + /** The operation against the repository in this log entry. */ + operation?: string; + /** + * The time when this operation is triggered. + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + op_time?: string; +} + +export interface AuditLogExt { + /** The ID of the audit log entry. */ + id?: number; + /** The username of the operator in this log entry. */ + username?: string; + /** Name of the resource in this log entry. */ + resource?: string; + /** Type of the resource in this log entry. */ + resource_type?: string; + /** The operation against the resource in this log entry. */ + operation?: string; + /** The operation's detail description */ + operation_description?: string; + /** the operation's result, true for success, false for fail */ + operation_result: boolean; + /** + * The time when this operation is triggered. + * @format date-time + * @example "2006-01-02T15:04:05Z" + */ + op_time?: string; +} + +export interface AuditLogEventType { + /** + * the event type, such as create_user. + * @example "create_user" + */ + event_type?: string; +} + +export interface Metadata { + /** id */ + id?: string; + /** name */ + name?: string; + /** icon */ + icon?: string; + /** maintainers */ + maintainers?: string[]; + /** version */ + version?: string; + /** source */ + source?: string; +} + +export interface Instance { + /** Unique ID */ + id?: number; + /** Instance name */ + name?: string; + /** Description of instance */ + description?: string; + /** Based on which driver, identified by ID */ + vendor?: string; + /** The service endpoint of this instance */ + endpoint?: string; + /** The authentication way supported */ + auth_mode?: string; + /** The auth credential data if exists */ + auth_info?: Record; + /** The health status */ + status?: string; + /** Whether the instance is activated or not */ + enabled: boolean; + /** Whether the instance is default or not */ + default: boolean; + /** Whether the instance endpoint is insecure or not */ + insecure: boolean; + /** + * The timestamp of instance setting up + * @format int64 + */ + setup_timestamp?: number; +} + +export interface PreheatPolicy { + /** The ID of preheat policy */ + id?: number; + /** The Name of preheat policy */ + name?: string; + /** The Description of preheat policy */ + description?: string; + /** The ID of preheat policy project */ + project_id?: number; + /** The ID of preheat policy provider */ + provider_id?: number; + /** The Name of preheat policy provider */ + provider_name?: string; + /** The Filters of preheat policy */ + filters?: string; + /** The Trigger of preheat policy */ + trigger?: string; + /** Whether the preheat policy enabled */ + enabled: boolean; + /** The extra attributes of preheat policy */ + extra_attrs?: string; + /** + * The Create Time of preheat policy + * @format date-time + */ + creation_time?: string; + /** + * The Update Time of preheat policy + * @format date-time + */ + update_time?: string; +} + +export interface Metrics { + /** The count of task */ + task_count?: number; + /** The count of success task */ + success_task_count?: number; + /** The count of error task */ + error_task_count?: number; + /** The count of pending task */ + pending_task_count?: number; + /** The count of running task */ + running_task_count?: number; + /** The count of scheduled task */ + scheduled_task_count?: number; + /** The count of stopped task */ + stopped_task_count?: number; +} + +export interface Execution { + /** The ID of execution */ + id?: number; + /** The vendor type of execution */ + vendor_type?: string; + /** The vendor id of execution */ + vendor_id?: number; + /** The status of execution */ + status?: string; + /** The status message of execution */ + status_message?: string; + metrics?: Metrics; + /** The trigger of execution */ + trigger?: string; + extra_attrs?: ExtraAttrs; + /** The start time of execution */ + start_time?: string; + /** The end time of execution */ + end_time?: string; +} + +export interface Task { + /** The ID of task */ + id?: number; + /** The ID of task execution */ + execution_id?: number; + /** The status of task */ + status?: string; + /** The status message of task */ + status_message?: string; + /** + * The count of task run + * @format int32 + */ + run_count?: number; + extra_attrs?: ExtraAttrs; + /** The creation time of task */ + creation_time?: string; + /** The update time of task */ + update_time?: string; + /** The start time of task */ + start_time?: string; + /** The end time of task */ + end_time?: string; +} + +export interface ProviderUnderProject { + id?: number; + provider?: string; + enabled?: boolean; + default?: boolean; +} + +export interface Icon { + /** The content type of the icon */ + "content-type"?: string; + /** The base64 encoded content of the icon */ + content?: string; +} + +export interface ProjectReq { + /** + * The name of the project. + * @maxLength 255 + */ + project_name?: string; + /** deprecated, reserved for project creation in replication */ + public?: boolean | null; + /** The metadata of the project. */ + metadata?: ProjectMetadata; + /** The CVE allowlist of the project. */ + cve_allowlist?: CVEAllowlist; + /** + * The storage quota of the project. + * @format int64 + */ + storage_limit?: number | null; + /** + * The ID of referenced registry when creating the proxy cache project + * @format int64 + */ + registry_id?: number | null; +} + +export interface Project { + /** + * Project ID + * @format int32 + */ + project_id?: number; + /** + * The owner ID of the project always means the creator of the project. + * @format int32 + */ + owner_id?: number; + /** The name of the project. */ + name?: string; + /** + * The ID of referenced registry when the project is a proxy cache project. + * @format int64 + */ + registry_id?: number; + /** + * The creation time of the project. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the project. + * @format date-time + */ + update_time?: string; + /** A deletion mark of the project. */ + deleted?: boolean; + /** The owner name of the project. */ + owner_name?: string; + /** Correspond to the UI about whether the project's publicity is updatable (for UI) */ + togglable?: boolean; + /** The role ID with highest permission of the current user who triggered the API (for UI). This attribute is deprecated and will be removed in future versions. */ + current_user_role_id?: number; + /** The list of role ID of the current user who triggered the API (for UI) */ + current_user_role_ids?: number[]; + /** The number of the repositories under this project. */ + repo_count: number; + /** The metadata of the project. */ + metadata?: ProjectMetadata; + /** The CVE allowlist of this project. */ + cve_allowlist?: CVEAllowlist; +} + +export interface ProjectDeletable { + /** Whether the project can be deleted. */ + deletable?: boolean; + /** The detail message when the project can not be deleted. */ + message?: string; +} + +export interface ProjectMetadata { + /** The public status of the project. The valid values are "true", "false". */ + public?: string; + /** Whether content trust is enabled or not. If it is enabled, user can't pull unsigned images from this project. The valid values are "true", "false". */ + enable_content_trust?: string | null; + /** Whether cosign content trust is enabled or not. If it is enabled, user can't pull images without cosign signature from this project. The valid values are "true", "false". */ + enable_content_trust_cosign?: string | null; + /** Whether prevent the vulnerable images from running. The valid values are "true", "false". */ + prevent_vul?: string | null; + /** If the vulnerability is high than severity defined here, the images can't be pulled. The valid values are "none", "low", "medium", "high", "critical". */ + severity?: string | null; + /** Whether scan images automatically when pushing. The valid values are "true", "false". */ + auto_scan?: string | null; + /** Whether generating SBOM automatically when pushing a subject artifact. The valid values are "true", "false". */ + auto_sbom_generation?: string | null; + /** Whether this project reuse the system level CVE allowlist as the allowlist of its own. The valid values are "true", "false". If it is set to "true" the actual allowlist associate with this project, if any, will be ignored. */ + reuse_sys_cve_allowlist?: string | null; + /** The ID of the tag retention policy for the project */ + retention_id?: string | null; + /** The bandwidth limit of proxy cache, in Kbps (kilobits per second). It limits the communication between Harbor and the upstream registry, not the client and the Harbor. */ + proxy_speed_kb?: string | null; +} + +export interface ProjectSummary { + /** The number of the repositories under this project. */ + repo_count: number; + /** The total number of project admin members. */ + project_admin_count?: number; + /** The total number of maintainer members. */ + maintainer_count?: number; + /** The total number of developer members. */ + developer_count?: number; + /** The total number of guest members. */ + guest_count?: number; + /** The total number of limited guest members. */ + limited_guest_count?: number; + quota?: ProjectSummaryQuota; + registry?: Registry; +} + +export interface ProjectSummaryQuota { + /** The hard limits of the quota */ + hard?: ResourceList; + /** The used status of the quota */ + used?: ResourceList; +} + +export interface ProjectScanner { + /** The identifier of the scanner registration */ + uuid: string; +} + +/** The CVE Allowlist for system or project */ +export interface CVEAllowlist { + /** ID of the allowlist */ + id?: number; + /** ID of the project which the allowlist belongs to. For system level allowlist this attribute is zero. */ + project_id?: number; + /** the time for expiration of the allowlist, in the form of seconds since epoch. This is an optional attribute, if it's not set the CVE allowlist does not expire. */ + expires_at?: number | null; + items?: CVEAllowlistItem[]; + /** + * The creation time of the allowlist. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the allowlist. + * @format date-time + */ + update_time?: string; +} + +/** The item in CVE allowlist */ +export interface CVEAllowlistItem { + /** The ID of the CVE, such as "CVE-2019-10164" */ + cve_id?: string; +} + +export interface ReplicationPolicy { + /** + * The policy ID. + * @format int64 + */ + id?: number; + /** The policy name. */ + name?: string; + /** The description of the policy. */ + description?: string; + /** The source registry. */ + src_registry?: Registry; + /** The destination registry. */ + dest_registry?: Registry; + /** The destination namespace. */ + dest_namespace?: string; + /** + * Specify how many path components will be replaced by the provided destination namespace. + * The default value is -1 in which case the legacy mode will be applied. + * @format int8 + */ + dest_namespace_replace_count?: number; + trigger?: ReplicationTrigger; + /** The replication policy filter array. */ + filters?: ReplicationFilter[]; + /** Whether to replicate the deletion operation. */ + replicate_deletion?: boolean; + /** Deprecated, use "replicate_deletion" instead. Whether to replicate the deletion operation. */ + deletion?: boolean; + /** Whether to override the resources on the destination registry. */ + override?: boolean; + /** Whether the policy is enabled or not. */ + enabled?: boolean; + /** + * The create time of the policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the policy. + * @format date-time + */ + update_time?: string; + /** + * speed limit for each task + * @format int32 + */ + speed?: number; + /** Whether to enable copy by chunk. */ + copy_by_chunk?: boolean; +} + +export interface ReplicationTrigger { + /** The replication policy trigger type. The valid values are manual, event_based and scheduled. */ + type?: string; + trigger_settings?: ReplicationTriggerSettings; +} + +export interface ReplicationTriggerSettings { + /** The cron string for scheduled trigger */ + cron?: string; +} + +export interface ReplicationFilter { + /** The replication policy filter type. */ + type?: string; + /** The value of replication policy filter. */ + value?: object; + /** matches or excludes the result */ + decoration?: string; +} + +export interface RegistryCredential { + /** Credential type, such as 'basic', 'oauth'. */ + type?: string; + /** Access key, e.g. user name when credential type is 'basic'. */ + access_key?: string; + /** Access secret, e.g. password when credential type is 'basic'. */ + access_secret?: string; +} + +export interface Registry { + /** + * The registry ID. + * @format int64 + */ + id: number; + /** The registry URL string. */ + url?: string; + /** The registry name. */ + name?: string; + credential?: RegistryCredential; + /** Type of the registry, e.g. 'harbor'. */ + type?: string; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean; + /** Description of the registry. */ + description?: string; + /** Health status of the registry. */ + status?: string; + /** + * The create time of the policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the policy. + * @format date-time + */ + update_time?: string; +} + +export interface RegistryUpdate { + /** The registry name. */ + name?: string | null; + /** Description of the registry. */ + description?: string | null; + /** The registry URL. */ + url?: string | null; + /** Credential type of the registry, e.g. 'basic'. */ + credential_type?: string | null; + /** The registry access key. */ + access_key?: string | null; + /** The registry access secret. */ + access_secret?: string | null; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean | null; +} + +export interface RegistryPing { + /** + * The registry ID. + * @format int64 + */ + id?: number | null; + /** Type of the registry, e.g. 'harbor'. */ + type?: string | null; + /** The registry URL. */ + url?: string | null; + /** Credential type of the registry, e.g. 'basic'. */ + credential_type?: string | null; + /** The registry access key. */ + access_key?: string | null; + /** The registry access secret. */ + access_secret?: string | null; + /** Whether or not the certificate will be verified when Harbor tries to access the server. */ + insecure?: boolean | null; +} + +/** The registry info contains the base info and capability declarations of the registry */ +export interface RegistryInfo { + /** The registry type */ + type?: string; + /** The description */ + description?: string; + /** The filters that the registry supports */ + supported_resource_filters?: FilterStyle[]; + /** The triggers that the registry supports */ + supported_triggers?: string[]; + /** The registry whether support copy by chunk. */ + supported_copy_by_chunk?: boolean; +} + +/** The registry provider info contains the base info and capability declarations of the registry provider */ +export interface RegistryProviderInfo { + /** The endpoint pattern */ + endpoint_pattern?: RegistryProviderEndpointPattern; + /** The credential pattern */ + credential_pattern?: RegistryProviderCredentialPattern; +} + +/** The registry endpoint pattern */ +export interface RegistryProviderEndpointPattern { + /** The endpoint type */ + endpoint_type?: string; + /** The endpoint list */ + endpoints?: RegistryEndpoint[]; +} + +/** The registry credential pattern */ +export interface RegistryProviderCredentialPattern { + /** The access key type */ + access_key_type?: string; + /** The access key data */ + access_key_data?: string; + /** The access secret type */ + access_secret_type?: string; + /** The access secret data */ + access_secret_data?: string; +} + +/** The style of the resource filter */ +export interface RegistryEndpoint { + /** The endpoint key */ + key?: string; + /** The endpoint value */ + value?: string; +} + +/** The style of the resource filter */ +export interface FilterStyle { + /** The filter type */ + type?: string; + /** The filter style */ + style?: string; + /** The filter values */ + values?: string[]; +} + +export type ResourceList = Record; + +/** The replication execution */ +export interface ReplicationExecution { + /** The ID of the execution */ + id?: number; + /** The ID if the policy that the execution belongs to */ + policy_id?: number; + /** The status of the execution */ + status?: string; + /** The trigger mode */ + trigger?: string; + /** + * The start time + * @format date-time + */ + start_time?: string; + /** + * The end time + * @format date-time + */ + end_time?: string; + /** The status text */ + status_text: string; + /** The total count of all executions */ + total: number; + /** The count of failed executions */ + failed: number; + /** The count of succeed executions */ + succeed: number; + /** The count of in_progress executions */ + in_progress: number; + /** The count of stopped executions */ + stopped: number; +} + +export interface StartReplicationExecution { + /** + * The ID of policy that the execution belongs to. + * @format int64 + */ + policy_id?: number; +} + +/** The replication task */ +export interface ReplicationTask { + /** The ID of the task */ + id?: number; + /** The ID of the execution that the task belongs to */ + execution_id?: number; + /** The status of the task */ + status?: string; + /** The ID of the underlying job that the task related to */ + job_id?: string; + /** The operation of the task */ + operation?: string; + /** The type of the resource that the task operates */ + resource_type?: string; + /** The source resource that the task operates */ + src_resource?: string; + /** The destination resource that the task operates */ + dst_resource?: string; + /** + * The start time of the task + * @format date-time + */ + start_time?: string; + /** + * The end time of the task + * @format date-time + */ + end_time?: string; +} + +export interface Robot { + /** + * The ID of the robot + * @format int64 + */ + id?: number; + /** The name of the robot */ + name?: string; + /** The description of the robot */ + description?: string; + /** The secret of the robot */ + secret?: string; + /** The level of the robot, project or system */ + level?: string; + /** + * The duration of the robot in days, duration must be either -1(Never) or a positive integer + * @format int64 + */ + duration?: number | null; + /** The editable status of the robot */ + editable: boolean; + /** The disable status of the robot */ + disable: boolean; + /** + * The expiration date of the robot + * @format int64 + */ + expires_at?: number; + permissions?: RobotPermission[]; + /** The type of the robot creator, like local(harbor_user) or robot. */ + creator_type?: string; + /** The reference of the robot creator, like the id of harbor user. */ + creator_ref?: number; + /** + * The creation time of the robot. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the robot. + * @format date-time + */ + update_time?: string; +} + +/** The request for robot account creation. */ +export interface RobotCreate { + /** The name of the robot */ + name?: string; + /** The description of the robot */ + description?: string; + /** The secret of the robot */ + secret?: string; + /** The level of the robot, project or system */ + level?: string; + /** The disable status of the robot */ + disable?: boolean; + /** + * The duration of the robot in days, duration must be either -1(Never) or a positive integer + * @format int64 + */ + duration?: number; + permissions?: RobotPermission[]; +} + +/** The response for robot account creation. */ +export interface RobotCreated { + /** + * The ID of the robot + * @format int64 + */ + id?: number; + /** The name of the robot */ + name?: string; + /** The secret of the robot */ + secret?: string; + /** + * The creation time of the robot. + * @format date-time + */ + creation_time?: string; + /** + * The expiration date of the robot + * @format int64 + */ + expires_at?: number; +} + +/** The response for refresh/update robot account secret. */ +export interface RobotSec { + /** The secret of the robot */ + secret?: string; +} + +export interface RobotPermission { + /** The kind of the permission */ + kind?: string; + /** The namespace of the permission */ + namespace?: string; + access?: Access[]; +} + +export interface Access { + /** The resource of the access. Possible resources are listed here for system and project level https://github.com/goharbor/harbor/blob/main/src/common/rbac/const.go */ + resource?: string; + /** The action of the access. Possible actions are *, pull, push, create, read, update, delete, list, operate, scanner-pull and stop. */ + action?: string; + /** The effect of the access */ + effect?: string; +} + +export interface RobotCreateV1 { + /** The name of robot account */ + name?: string; + /** The description of robot account */ + description?: string; + /** The expiration time on or after which the JWT MUST NOT be accepted for processing. */ + expires_at?: number; + /** The permission of robot account */ + access?: Access[]; +} + +export interface Storage { + /** + * Total volume size. + * @format uint64 + */ + total?: number; + /** + * Free volume size. + * @format uint64 + */ + free?: number; +} + +export interface GeneralInfo { + /** + * The banner message for the UI. It is the stringified result of the banner message object. + * @example "{"closable":true,"message":"your banner message content","type":"warning","fromDate":"06/19/2023","toDate":"06/21/2023"}" + */ + banner_message?: string | null; + /** + * The current time of the server. + * @format date-time + */ + current_time?: string | null; + /** The url of registry against which the docker command should be issued. */ + registry_url?: string | null; + /** The external URL of Harbor, with protocol. */ + external_url?: string | null; + /** The auth mode of current Harbor instance. */ + auth_mode?: string | null; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: boolean | null; + /** Indicate who can create projects, it could be 'adminonly' or 'everyone'. */ + project_creation_restriction?: string | null; + /** Indicate whether the Harbor instance enable user to register himself. */ + self_registration?: boolean | null; + /** Indicate whether there is a ca root cert file ready for download in the file system. */ + has_ca_root?: boolean | null; + /** The build version of Harbor. */ + harbor_version?: string | null; + /** The storage provider's name of Harbor registry */ + registry_storage_provider_name?: string | null; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: boolean | null; + /** The flag to indicate whether notification mechanism is enabled on Harbor instance. */ + notification_enable?: boolean | null; + /** The setting of auth proxy this is only available when Harbor relies on authproxy for authentication. */ + authproxy_settings?: AuthproxySetting | null; + /** The OIDC provider name, empty if current auth is not OIDC_auth or OIDC provider is not configured. */ + oidc_provider_name?: string | null; +} + +export interface AuthproxySetting { + /** The fully qualified URI of login endpoint of authproxy, such as 'https://192.168.1.2:8443/login' */ + endpoint?: string; + /** The fully qualified URI of token review endpoint of authproxy, such as 'https://192.168.1.2:8443/tokenreview' */ + tokenreivew_endpoint?: string; + /** The flag to determine whether Harbor can skip search the user/group when adding him as a member. */ + skip_search?: boolean; + /** The flag to determine whether Harbor should verify the certificate when connecting to the auth proxy. */ + verify_cert?: boolean; + /** The certificate to be pinned when connecting auth proxy. */ + server_certificate?: string; +} + +export interface SystemInfo { + /** The storage of system. */ + storage?: Storage[]; +} + +export interface GCHistory { + /** the id of gc job. */ + id?: number; + /** the job name of gc job. */ + job_name?: string; + /** the job kind of gc job. */ + job_kind?: string; + /** the job parameters of gc job. */ + job_parameters?: string; + schedule?: ScheduleObj; + /** the status of gc job. */ + job_status?: string; + /** if gc job was deleted. */ + deleted?: boolean; + /** + * the creation time of gc job. + * @format date-time + */ + creation_time?: string; + /** + * the update time of gc job. + * @format date-time + */ + update_time?: string; +} + +export interface ExecHistory { + /** the id of purge job. */ + id?: number; + /** the job name of purge job. */ + job_name?: string; + /** the job kind of purge job. */ + job_kind?: string; + /** the job parameters of purge job. */ + job_parameters?: string; + schedule?: ScheduleObj; + /** the status of purge job. */ + job_status?: string; + /** if purge job was deleted. */ + deleted?: boolean; + /** + * the creation time of purge job. + * @format date-time + */ + creation_time?: string; + /** + * the update time of purge job. + * @format date-time + */ + update_time?: string; +} + +export interface Schedule { + /** The id of the schedule. */ + id?: number; + /** The status of the schedule. */ + status?: string; + /** + * the creation time of the schedule. + * @format date-time + */ + creation_time?: string; + /** + * the update time of the schedule. + * @format date-time + */ + update_time?: string; + schedule?: ScheduleObj; + /** The parameters of schedule job */ + parameters?: Record; +} + +export interface ScheduleObj { + /** + * The schedule type. The valid values are 'Hourly', 'Daily', 'Weekly', 'Custom', 'Manual', 'None' and 'Schedule'. + * 'Manual' means to trigger it right away, 'Schedule' means to trigger it by a specified cron schedule and + * 'None' means to cancel the schedule. + */ + type?: + | "Hourly" + | "Daily" + | "Weekly" + | "Custom" + | "Manual" + | "None" + | "Schedule"; + /** A cron expression, a time-based job scheduler. */ + cron?: string; + /** + * The next time to schedule to run the job. + * @format date-time + */ + next_scheduled_time?: string; +} + +/** Stats provides the overall progress of the scan all process. */ +export interface Stats { + /** + * The total number of scan processes triggered by the scan all action + * @format int + * @example 100 + */ + total: number; + /** + * The number of the finished scan processes triggered by the scan all action + * @format int + * @example 90 + */ + completed: number; + /** + * The metrics data for the each status + * @example {"Success":5,"Error":2,"Running":3} + */ + metrics?: Record; + /** A flag indicating job status of scan all. */ + ongoing: boolean; + /** The trigger of the scan all job. */ + trigger?: "Manual" | "Schedule" | "Event"; +} + +/** the tag retention metadata */ +export interface RetentionMetadata { + /** templates */ + templates?: RetentionRuleMetadata[]; + /** supported scope selectors */ + scope_selectors?: RetentionSelectorMetadata[]; + /** supported tag selectors */ + tag_selectors?: RetentionSelectorMetadata[]; +} + +/** the tag retention rule metadata */ +export interface RetentionRuleMetadata { + /** rule id */ + rule_template?: string; + /** rule display text */ + display_text?: string; + /** rule action */ + action?: string; + /** rule params */ + params?: RetentionRuleParamMetadata[]; +} + +/** rule param */ +export interface RetentionRuleParamMetadata { + type?: string; + unit?: string; + required?: boolean; +} + +/** retention selector */ +export interface RetentionSelectorMetadata { + display_text?: string; + kind?: string; + decorations?: string[]; +} + +/** retention policy */ +export interface RetentionPolicy { + /** @format int64 */ + id?: number; + algorithm?: string; + rules?: RetentionRule[]; + trigger?: RetentionRuleTrigger; + scope?: RetentionPolicyScope; +} + +export interface RetentionRuleTrigger { + kind?: string; + settings?: object; + references?: object; +} + +export interface RetentionPolicyScope { + level?: string; + ref?: number; +} + +export interface RetentionRule { + id?: number; + priority?: number; + disabled?: boolean; + action?: string; + template?: string; + params?: Record; + tag_selectors?: RetentionSelector[]; + scope_selectors?: Record; +} + +export interface RetentionSelector { + kind?: string; + decoration?: string; + pattern?: string; + extras?: string; +} + +export interface RetentionExecution { + /** @format int64 */ + id?: number; + /** @format int64 */ + policy_id?: number; + start_time?: string; + end_time?: string; + status?: string; + trigger?: string; + dry_run?: boolean; +} + +export interface RetentionExecutionTask { + /** @format int64 */ + id?: number; + /** @format int64 */ + execution_id?: number; + repository?: string; + job_id?: string; + status?: string; + status_code: number; + /** @format int64 */ + status_revision?: number; + start_time?: string; + end_time?: string; + total: number; + retained: number; +} + +export interface QuotaUpdateReq { + /** The new hard limits for the quota */ + hard?: ResourceList; +} + +export type QuotaRefObject = Record; + +/** The quota object */ +export interface Quota { + /** ID of the quota */ + id?: number; + /** The reference object of the quota */ + ref?: QuotaRefObject; + /** The hard limits of the quota */ + hard: ResourceList; + /** The used status of the quota */ + used: ResourceList; + /** + * the creation time of the quota + * @format date-time + */ + creation_time?: string; + /** + * the update time of the quota + * @format date-time + */ + update_time?: string; +} + +/** Registration represents a named configuration for invoking a scanner via its adapter. */ +export interface ScannerRegistration { + /** The unique identifier of this registration. */ + uuid?: string; + /** + * The name of this registration. + * @example "Trivy" + */ + name?: string; + /** + * An optional description of this registration. + * @example "A free-to-use tool that scans container images for package vulnerabilities. + * " + */ + description: string; + /** + * A base URL of the scanner adapter + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url?: string; + /** + * Indicate whether the registration is enabled or not + * @default false + */ + disabled: boolean; + /** + * Indicate if the registration is set as the system default one + * @default false + */ + is_default: boolean; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @default "" + * @example "Bearer" + */ + auth: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential: string; + /** + * Indicate if skip the certificate verification when sending HTTP requests + * @default false + */ + skip_certVerify: boolean; + /** + * Indicate whether use internal registry addr for the scanner to pull content or not + * @default false + */ + use_internal_addr: boolean; + /** + * The creation time of this registration + * @format date-time + */ + create_time?: string; + /** + * The update time of this registration + * @format date-time + */ + update_time?: string; + /** + * Optional property to describe the name of the scanner registration + * @example "Trivy" + */ + adapter?: string; + /** + * Optional property to describe the vendor of the scanner registration + * @example "CentOS" + */ + vendor?: string; + /** + * Optional property to describe the version of the scanner registration + * @example "1.0.1" + */ + version?: string; + /** + * Indicate the healthy of the registration + * @default "" + * @example "healthy" + */ + health?: string; + /** + * Indicates the capabilities of the scanner, e.g. support_vulnerability or support_sbom. + * @example {"support_vulnerability":true,"support_sbom":true} + */ + capabilities?: Record; +} + +export interface ScannerRegistrationReq { + /** + * The name of this registration + * @example "Trivy" + */ + name: string; + /** + * An optional description of this registration. + * @example "A free-to-use tool that scans container images for package vulnerabilities. + * " + */ + description?: string; + /** + * A base URL of the scanner adapter. + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url: string; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @example "Bearer" + */ + auth?: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential?: string; + /** + * Indicate if skip the certificate verification when sending HTTP requests + * @default false + */ + skip_certVerify?: boolean; + /** + * Indicate whether use internal registry addr for the scanner to pull content or not + * @default false + */ + use_internal_addr?: boolean; + /** + * Indicate whether the registration is enabled or not + * @default false + */ + disabled?: boolean; +} + +export interface ScannerRegistrationSettings { + /** + * The name of this registration + * @example "Trivy" + */ + name: string; + /** + * A base URL of the scanner adapter. + * @format uri + * @example "http://harbor-scanner-trivy:8080" + */ + url: string; + /** + * Specify what authentication approach is adopted for the HTTP communications. + * Supported types Basic", "Bearer" and api key header "X-ScannerAdapter-API-Key" + * @default "" + */ + auth?: string; + /** + * An optional value of the HTTP Authorization header sent with each request to the Scanner Adapter API. + * @example "Bearer: JWTTOKENGOESHERE" + */ + access_credential?: string; +} + +export interface IsDefault { + /** A flag indicating whether a scanner registration is default. */ + is_default?: boolean; +} + +export interface ScannerCapability { + /** + * Specify the type of scanner capability, like vulnerability or sbom + * @example "sbom" + */ + type: string; + consumes_mime_types?: string[]; + produces_mime_types?: string[]; +} + +/** The metadata info of the scanner adapter */ +export interface ScannerAdapterMetadata { + scanner?: Scanner; + capabilities?: ScannerCapability[]; + /** @example {"harbor.scanner-adapter/registry-authorization-type":"Bearer"} */ + properties?: Record; +} + +export interface ImmutableRule { + id?: number; + priority?: number; + disabled?: boolean; + action?: string; + template?: string; + params?: Record; + tag_selectors?: ImmutableSelector[]; + scope_selectors?: Record; +} + +export interface ImmutableSelector { + kind?: string; + decoration?: string; + pattern?: string; + extras?: string; +} + +/** The ldap configure properties */ +export interface LdapConf { + /** The url of ldap service. */ + ldap_url?: string; + /** The search dn of ldap service. */ + ldap_search_dn?: string; + /** The search password of ldap service. */ + ldap_search_password?: string; + /** The base dn of ldap service. */ + ldap_base_dn?: string; + /** The serach filter of ldap service. */ + ldap_filter?: string; + /** The serach uid from ldap service attributes. */ + ldap_uid?: string; + /** + * The serach scope of ldap service. + * @format int64 + */ + ldap_scope?: number; + /** + * The connect timeout of ldap service(second). + * @format int64 + */ + ldap_connection_timeout?: number; + /** Verify Ldap server certificate. */ + ldap_verify_cert?: boolean; +} + +/** The ldap ping result */ +export interface LdapPingResult { + /** Test success */ + success?: boolean; + /** The ping operation output message. */ + message?: string; +} + +export interface LdapImportUsers { + /** selected uid list */ + ldap_uid_list?: string[]; +} + +export interface LdapFailedImportUser { + /** the uid can't add to system. */ + uid?: string; + /** fail reason. */ + error?: string; +} + +export interface LdapUser { + /** ldap username. */ + username?: string; + /** The user realname from "uid" or "cn" attribute. */ + realname?: string; + /** The user email address from "mail" or "email" attribute. */ + email?: string; +} + +export interface UserGroup { + /** The ID of the user group */ + id?: number; + /** The name of the user group */ + group_name?: string; + /** The group type, 1 for LDAP group, 2 for HTTP group, 3 for OIDC group. */ + group_type?: number; + /** The DN of the LDAP group if group type is 1 (LDAP group). */ + ldap_group_dn?: string; +} + +export interface UserGroupSearchItem { + /** The ID of the user group */ + id?: number; + /** The name of the user group */ + group_name?: string; + /** The group type, 1 for LDAP group, 2 for HTTP group, 3 for OIDC group. */ + group_type?: number; +} + +/** Supported webhook event types and notify types. */ +export interface SupportedWebhookEventTypes { + event_type?: EventType[]; + notify_type?: NotifyType[]; + payload_formats?: PayloadFormat[]; +} + +/** + * Webhook supported event type. + * @example "PULL_ARTIFACT" + */ +export type EventType = string; + +/** + * Webhook supported notify type. + * @example "http" + */ +export type NotifyType = string; + +/** + * The type of webhook paylod format. + * @example "CloudEvents" + */ +export type PayloadFormatType = string; + +/** Webhook supported payload format type collections. */ +export interface PayloadFormat { + /** Webhook supported notify type. */ + notify_type?: NotifyType; + /** The supported payload formats for this notify type. */ + formats?: PayloadFormatType[]; +} + +/** The webhook policy target object. */ +export interface WebhookTargetObject { + /** The webhook target notify type. */ + type?: string; + /** The webhook target address. */ + address?: string; + /** The webhook auth header. */ + auth_header?: string; + /** Whether or not to skip cert verify. */ + skip_cert_verify?: boolean; + /** The payload format of webhook, by default is Default for http type. */ + payload_format?: PayloadFormatType; +} + +/** The webhook policy object */ +export interface WebhookPolicy { + /** + * The webhook policy ID. + * @format int64 + */ + id?: number; + /** The name of webhook policy. */ + name?: string; + /** The description of webhook policy. */ + description?: string; + /** The project ID of webhook policy. */ + project_id?: number; + targets?: WebhookTargetObject[]; + event_types?: string[]; + /** The creator of the webhook policy. */ + creator?: string; + /** + * The create time of the webhook policy. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the webhook policy. + * @format date-time + */ + update_time?: string; + /** Whether the webhook policy is enabled or not. */ + enabled: boolean; +} + +/** The webhook policy and last trigger time group by event type. */ +export interface WebhookLastTrigger { + /** The webhook policy name. */ + policy_name?: string; + /** The webhook event type. */ + event_type?: string; + /** Whether or not the webhook policy enabled. */ + enabled?: boolean; + /** + * The creation time of webhook policy. + * @format date-time + */ + creation_time?: string; + /** + * The last trigger time of webhook policy. + * @format date-time + */ + last_trigger_time?: string; +} + +/** The webhook job. */ +export interface WebhookJob { + /** + * The webhook job ID. + * @format int64 + */ + id?: number; + /** + * The webhook policy ID. + * @format int64 + */ + policy_id?: number; + /** The webhook job event type. */ + event_type?: string; + /** The webhook job notify type. */ + notify_type?: string; + /** The webhook job status. */ + status?: string; + /** The webhook job notify detailed data. */ + job_detail?: string; + /** + * The webhook job creation time. + * @format date-time + */ + creation_time?: string; + /** + * The webhook job update time. + * @format date-time + */ + update_time?: string; +} + +export type InternalConfigurationsResponse = Record< + string, + InternalConfigurationValue +>; + +export interface InternalConfigurationValue { + /** The value of current config item */ + value?: object; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface ConfigurationsResponse { + /** The auth mode of current system, such as "db_auth", "ldap_auth", "oidc_auth" */ + auth_mode?: StringConfigItem; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: BoolConfigItem; + /** The Base DN for LDAP binding. */ + ldap_base_dn?: StringConfigItem; + /** The filter for LDAP search */ + ldap_filter?: StringConfigItem; + /** The base DN to search LDAP group. */ + ldap_group_base_dn?: StringConfigItem; + /** Specify the ldap group which have the same privilege with Harbor admin */ + ldap_group_admin_dn?: StringConfigItem; + /** The attribute which is used as identity of the LDAP group, default is cn.' */ + ldap_group_attribute_name?: StringConfigItem; + /** The filter to search the ldap group */ + ldap_group_search_filter?: StringConfigItem; + /** The scope to search ldap group. ''0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE'' */ + ldap_group_search_scope?: IntegerConfigItem; + /** Attach LDAP user group information in parallel. */ + ldap_group_attach_parallel?: BoolConfigItem; + /** The scope to search ldap users,'0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE' */ + ldap_scope?: IntegerConfigItem; + /** The DN of the user to do the search. */ + ldap_search_dn?: StringConfigItem; + /** Timeout in seconds for connection to LDAP server */ + ldap_timeout?: IntegerConfigItem; + /** The attribute which is used as identity for the LDAP binding, such as "CN" or "SAMAccountname" */ + ldap_uid?: StringConfigItem; + /** The URL of LDAP server */ + ldap_url?: StringConfigItem; + /** Whether verify your OIDC server certificate, disable it if your OIDC server is hosted via self-hosted certificate. */ + ldap_verify_cert?: BoolConfigItem; + /** The user attribute to identify the group membership */ + ldap_group_membership_attribute?: StringConfigItem; + /** Indicate who can create projects, it could be ''adminonly'' or ''everyone''. */ + project_creation_restriction?: StringConfigItem; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: BoolConfigItem; + /** Whether the Harbor instance supports self-registration. If it''s set to false, admin need to add user to the instance. */ + self_registration?: BoolConfigItem; + /** The expiration time of the token for internal Registry, in minutes. */ + token_expiration?: IntegerConfigItem; + /** The client id of UAA */ + uaa_client_id?: StringConfigItem; + /** The client secret of the UAA */ + uaa_client_secret?: StringConfigItem; + /** The endpoint of the UAA */ + uaa_endpoint?: StringConfigItem; + /** Verify the certificate in UAA server */ + uaa_verify_cert?: BoolConfigItem; + /** The endpoint of the HTTP auth */ + http_authproxy_endpoint?: StringConfigItem; + /** The token review endpoint */ + http_authproxy_tokenreview_endpoint?: StringConfigItem; + /** The group which has the harbor admin privileges */ + http_authproxy_admin_groups?: StringConfigItem; + /** The usernames which has the harbor admin privileges */ + http_authproxy_admin_usernames?: StringConfigItem; + /** Verify the HTTP auth provider's certificate */ + http_authproxy_verify_cert?: BoolConfigItem; + /** Search user before onboard */ + http_authproxy_skip_search?: BoolConfigItem; + /** The certificate of the HTTP auth provider */ + http_authproxy_server_certificate?: StringConfigItem; + /** The OIDC provider name */ + oidc_name?: StringConfigItem; + /** The endpoint of the OIDC provider */ + oidc_endpoint?: StringConfigItem; + /** The client ID of the OIDC provider */ + oidc_client_id?: StringConfigItem; + /** The attribute claims the group name */ + oidc_groups_claim?: StringConfigItem; + /** The OIDC group which has the harbor admin privileges */ + oidc_admin_group?: StringConfigItem; + /** The OIDC group filter which filters out the group doesn't match the regular expression */ + oidc_group_filter?: StringConfigItem; + /** The scope of the OIDC provider */ + oidc_scope?: StringConfigItem; + /** The attribute claims the username */ + oidc_user_claim?: StringConfigItem; + /** Verify the OIDC provider's certificate' */ + oidc_verify_cert?: BoolConfigItem; + /** Auto onboard the OIDC user */ + oidc_auto_onboard?: BoolConfigItem; + /** Extra parameters to add when redirect request to OIDC provider */ + oidc_extra_redirect_parms?: StringConfigItem; + /** Extra parameters to logout user session from the OIDC provider */ + oidc_logout?: BoolConfigItem; + /** The robot account token duration in days */ + robot_token_duration?: IntegerConfigItem; + /** The rebot account name prefix */ + robot_name_prefix?: StringConfigItem; + /** Enable notification */ + notification_enable?: BoolConfigItem; + /** Enable quota per project */ + quota_per_project_enable?: BoolConfigItem; + /** The storage quota per project */ + storage_per_project?: IntegerConfigItem; + /** The endpoint of the audit log forwarder */ + audit_log_forward_endpoint?: StringConfigItem; + /** Whether skip the audit log in database */ + skip_audit_log_database?: BoolConfigItem; + /** Whether or not to skip update the pull time for scanner */ + scanner_skip_update_pulltime?: BoolConfigItem; + scan_all_policy?: { + /** The type of scan all policy, currently the valid values are "none" and "daily" */ + type?: string; + /** The parameters of the policy, the values are dependent on the type of the policy. */ + parameter?: { + /** The offset in seconds of UTC 0 o'clock, only valid when the policy type is "daily" */ + daily_time?: number; + }; + }; + /** The session timeout in minutes */ + session_timeout?: IntegerConfigItem; + /** The banner message for the UI.It is the stringified result of the banner message object */ + banner_message?: StringConfigItem; + /** The audit log event types to skip to log in database */ + disabled_audit_log_event_types?: StringConfigItem; +} + +export interface Configurations { + /** The auth mode of current system, such as "db_auth", "ldap_auth", "oidc_auth" */ + auth_mode?: string; + /** The flag to indicate whether the current auth mode should consider as a primary one. */ + primary_auth_mode?: boolean | null; + /** The Base DN for LDAP binding. */ + ldap_base_dn?: string; + /** The filter for LDAP search */ + ldap_filter?: string; + /** The base DN to search LDAP group. */ + ldap_group_base_dn?: string; + /** Specify the ldap group which have the same privilege with Harbor admin */ + ldap_group_admin_dn?: string; + /** The attribute which is used as identity of the LDAP group, default is cn.' */ + ldap_group_attribute_name?: string; + /** The filter to search the ldap group */ + ldap_group_search_filter?: string; + /** The scope to search ldap group. ''0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE'' */ + ldap_group_search_scope?: number; + /** Attach LDAP user group information in parallel, the parallel worker count is 5 */ + ldap_group_attach_parallel?: boolean; + /** The scope to search ldap users,'0-LDAP_SCOPE_BASE, 1-LDAP_SCOPE_ONELEVEL, 2-LDAP_SCOPE_SUBTREE' */ + ldap_scope?: number; + /** The DN of the user to do the search. */ + ldap_search_dn?: string; + /** The password of the ldap search dn */ + ldap_search_password?: string; + /** Timeout in seconds for connection to LDAP server */ + ldap_timeout?: number; + /** The attribute which is used as identity for the LDAP binding, such as "CN" or "SAMAccountname" */ + ldap_uid?: string; + /** The URL of LDAP server */ + ldap_url?: string; + /** Whether verify your OIDC server certificate, disable it if your OIDC server is hosted via self-hosted certificate. */ + ldap_verify_cert?: boolean; + /** The user attribute to identify the group membership */ + ldap_group_membership_attribute?: string; + /** Indicate who can create projects, it could be ''adminonly'' or ''everyone''. */ + project_creation_restriction?: string; + /** The flag to indicate whether Harbor is in readonly mode. */ + read_only?: boolean; + /** Whether the Harbor instance supports self-registration. If it''s set to false, admin need to add user to the instance. */ + self_registration?: boolean; + /** The expiration time of the token for internal Registry, in minutes. */ + token_expiration?: number; + /** The client id of UAA */ + uaa_client_id?: string; + /** The client secret of the UAA */ + uaa_client_secret?: string; + /** The endpoint of the UAA */ + uaa_endpoint?: string; + /** Verify the certificate in UAA server */ + uaa_verify_cert?: boolean; + /** The endpoint of the HTTP auth */ + http_authproxy_endpoint?: string; + /** The token review endpoint */ + http_authproxy_tokenreview_endpoint?: string; + /** The group which has the harbor admin privileges */ + http_authproxy_admin_groups?: string; + /** The username which has the harbor admin privileges */ + http_authproxy_admin_usernames?: string; + /** Verify the HTTP auth provider's certificate */ + http_authproxy_verify_cert?: boolean; + /** Search user before onboard */ + http_authproxy_skip_search?: boolean; + /** The certificate of the HTTP auth provider */ + http_authproxy_server_certificate?: string; + /** The OIDC provider name */ + oidc_name?: string; + /** The endpoint of the OIDC provider */ + oidc_endpoint?: string; + /** The client ID of the OIDC provider */ + oidc_client_id?: string; + /** The OIDC provider secret */ + oidc_client_secret?: string; + /** The attribute claims the group name */ + oidc_groups_claim?: string; + /** The OIDC group which has the harbor admin privileges */ + oidc_admin_group?: string; + /** The OIDC group filter which filters out the group name doesn't match the regular expression */ + oidc_group_filter?: string; + /** The scope of the OIDC provider */ + oidc_scope?: string; + /** The attribute claims the username */ + oidc_user_claim?: string; + /** Verify the OIDC provider's certificate' */ + oidc_verify_cert?: boolean; + /** Auto onboard the OIDC user */ + oidc_auto_onboard?: boolean; + /** Extra parameters to add when redirect request to OIDC provider */ + oidc_extra_redirect_parms?: string; + /** Logout OIDC user session */ + oidc_logout?: boolean; + /** The robot account token duration in days */ + robot_token_duration?: number; + /** The rebot account name prefix */ + robot_name_prefix?: string; + /** Enable notification */ + notification_enable?: boolean; + /** Enable quota per project */ + quota_per_project_enable?: boolean; + /** The storage quota per project */ + storage_per_project?: number; + /** The audit log forward endpoint */ + audit_log_forward_endpoint?: string; + /** Skip audit log database */ + skip_audit_log_database?: boolean; + /** The session timeout for harbor, in minutes. */ + session_timeout?: number; + /** Whether or not to skip update pull time for scanner */ + scanner_skip_update_pulltime?: boolean; + /** The banner message for the UI.It is the stringified result of the banner message object */ + banner_message?: string; + /** the list to disable log audit event types. */ + disabled_audit_log_event_types?: string; +} + +export interface StringConfigItem { + /** The string value of current config item */ + value: string; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface BoolConfigItem { + /** The boolean value of current config item */ + value: boolean; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface IntegerConfigItem { + /** The integer value of current config item */ + value: number; + /** The configure item can be updated or not */ + editable: boolean; +} + +export interface ProjectMemberEntity { + /** the project member id */ + id?: number; + /** the project id */ + project_id?: number; + /** the name of the group member. */ + entity_name?: string; + /** the name of the role */ + role_name?: string; + /** the role id */ + role_id?: number; + /** the id of entity, if the member is a user, it is user_id in user table. if the member is a user group, it is the user group's ID in user_group table. */ + entity_id?: number; + /** the entity's type, u for user entity, g for group entity. */ + entity_type?: string; +} + +export interface ProjectMember { + /** The role id 1 for projectAdmin, 2 for developer, 3 for guest, 4 for maintainer */ + role_id?: number; + member_user?: UserEntity; + member_group?: UserGroup; +} + +export interface RoleRequest { + /** The role id 1 for projectAdmin, 2 for developer, 3 for guest, 4 for maintainer */ + role_id?: number; +} + +export interface UserEntity { + /** The ID of the user. */ + user_id?: number; + /** The name of the user. */ + username?: string; +} + +export interface UserProfile { + email?: string; + realname?: string; + comment?: string; +} + +export interface UserCreationReq { + /** @maxLength 255 */ + email?: string; + realname?: string; + comment?: string; + password?: string; + /** @maxLength 255 */ + username?: string; +} + +export interface OIDCUserInfo { + /** + * the ID of the OIDC info record + * @format int + */ + id?: number; + /** + * the ID of the user + * @format int + */ + user_id?: number; + /** the concatenation of sub and issuer in the ID token */ + subiss?: string; + /** the secret of the OIDC user that can be used for CLI to push/pull artifacts */ + secret?: string; + /** + * The creation time of the OIDC user info record. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the OIDC user info record. + * @format date-time + */ + update_time?: string; +} + +export interface UserResp { + email?: string; + realname?: string; + comment?: string; + /** @format int */ + user_id?: number; + username?: string; + sysadmin_flag: boolean; + /** indicate the admin privilege is grant by authenticator (LDAP), is always false unless it is the current login user */ + admin_role_in_auth: boolean; + oidc_user_meta?: OIDCUserInfo; + /** + * The creation time of the user. + * @format date-time + */ + creation_time?: string; + /** + * The update time of the user. + * @format date-time + */ + update_time?: string; +} + +export interface UserSysAdminFlag { + /** true-admin, false-not admin. */ + sysadmin_flag?: boolean; +} + +export interface UserSearch { + /** + * The ID of the user. + * @format int + */ + user_id?: number; + username?: string; +} + +export interface PasswordReq { + /** The user's existing password. */ + old_password?: string; + /** New password for marking as to be updated. */ + new_password?: string; +} + +export interface UserSearchRespItem { + /** + * The ID of the user. + * @format int + */ + user_id?: number; + username?: string; +} + +export interface Permission { + /** The permission resoruce */ + resource?: string; + /** The permission action */ + action?: string; +} + +export interface Permissions { + /** The system level permissions */ + system?: Permission[]; + /** The project level permissions */ + project?: Permission[]; +} + +export interface OIDCCliSecretReq { + /** The new secret */ + secret?: string; +} + +/** The system health status */ +export interface OverallHealthStatus { + /** The overall health status. It is "healthy" only when all the components' status are "healthy" */ + status?: string; + components?: ComponentHealthStatus[]; +} + +/** The health status of component */ +export interface ComponentHealthStatus { + /** The component name */ + name?: string; + /** The health status of component. Is either "healthy" or "unhealthy". */ + status?: string; + /** (optional) The error message when the status is "unhealthy" */ + error?: string; +} + +export interface Statistic { + /** + * The count of the private projects + * @format int64 + */ + private_project_count: number; + /** + * The count of the private repositories + * @format int64 + */ + private_repo_count: number; + /** + * The count of the public projects + * @format int64 + */ + public_project_count: number; + /** + * The count of the public repositories + * @format int64 + */ + public_repo_count: number; + /** + * The count of the total projects, only be seen by the system admin + * @format int64 + */ + total_project_count: number; + /** + * The count of the total repositories, only be seen by the system admin + * @format int64 + */ + total_repo_count: number; + /** + * The total storage consumption of blobs, only be seen by the system admin + * @format int64 + */ + total_storage_consumption: number; +} + +/** The accessory of the artifact */ +export interface Accessory { + /** + * The ID of the accessory + * @format int64 + */ + id?: number; + /** + * The artifact id of the accessory + * @format int64 + */ + artifact_id: number; + /** + * Going to be deprecated, use repo and digest for insteand. The subject artifact id of the accessory. + * @format int64 + */ + subject_artifact_id?: number; + /** The subject artifact digest of the accessory */ + subject_artifact_digest: string; + /** The subject artifact repository name of the accessory */ + subject_artifact_repo: string; + /** + * The artifact size of the accessory + * @format int64 + */ + size: number; + /** The artifact digest of the accessory */ + digest: string; + /** The artifact size of the accessory */ + type: string; + /** The icon of the accessory */ + icon: string; + /** + * The creation time of the accessory + * @format date-time + */ + creation_time?: string; +} + +/** The criteria to select the scan data to export. */ +export interface ScanDataExportRequest { + /** Name of the scan data export job */ + job_name?: string; + /** A list of one or more projects for which to export the scan data, currently only one project is supported due to performance concerns, but define as array for extension in the future. */ + projects?: number[]; + /** A list of one or more labels for which to export the scan data, defaults to all if empty */ + labels?: number[]; + /** A list of repositories for which to export the scan data, defaults to all if empty */ + repositories?: string; + /** CVE-IDs for which to export data. Multiple CVE-IDs can be specified by separating using ',' and enclosed between '{}'. Defaults to all if empty */ + cveIds?: string; + /** A list of tags enclosed within '{}'. Defaults to all if empty */ + tags?: string; +} + +/** The metadata associated with the scan data export job */ +export interface ScanDataExportJob { + /** + * The id of the scan data export job + * @format int64 + */ + id?: number; +} + +/** The replication execution */ +export interface ScanDataExportExecution { + /** The ID of the execution */ + id?: number; + /** The ID if the user triggering the export job */ + user_id?: number; + /** The status of the execution */ + status?: string; + /** The trigger mode */ + trigger?: string; + /** + * The start time + * @format date-time + */ + start_time?: string; + /** + * The end time + * @format date-time + */ + end_time?: string; + /** The status text */ + status_text: string; + /** The name of the user triggering the job */ + user_name: string; + /** Indicates whether the export artifact is present in registry */ + file_present: boolean; +} + +/** The list of scan data export executions */ +export interface ScanDataExportExecutionList { + /** The list of scan data export executions */ + items?: ScanDataExportExecution[]; +} + +/** the worker pool of job service */ +export interface WorkerPool { + /** the process id of jobservice */ + pid?: number; + /** the id of the worker pool */ + worker_pool_id?: string; + /** + * The start time of the work pool + * @format date-time + */ + start_at?: string; + /** + * The heartbeat time of the work pool + * @format date-time + */ + heartbeat_at?: string; + /** The concurrency of the work pool */ + concurrency?: number; + /** The host of the work pool */ + host?: string; +} + +/** worker in the pool */ +export interface Worker { + /** the id of the worker */ + id?: string; + /** the id of the worker pool */ + pool_id?: string; + /** the name of the running job in the worker */ + job_name?: string; + /** the id of the running job in the worker */ + job_id?: string; + /** + * The start time of the worker + * @format date-time + */ + start_at?: string | null; + /** the checkin of the running job in the worker */ + check_in?: string; + /** + * The checkin time of the worker + * @format date-time + */ + checkin_at?: string | null; +} + +/** The request to stop, pause or resume */ +export interface ActionRequest { + /** The action of the request, should be stop, pause or resume */ + action?: "stop" | "pause" | "resume"; +} + +/** the job queue info */ +export interface JobQueue { + /** The type of the job queue */ + job_type?: string; + /** The count of jobs in the job queue */ + count?: number; + /** The latency the job queue (seconds) */ + latency?: number; + /** The paused status of the job queue */ + paused: boolean; +} + +/** the schedule task info */ +export interface ScheduleTask { + /** the id of the Schedule task */ + id?: number; + /** the vendor type of the current schedule task */ + vendor_type?: string; + /** the vendor id of the current task */ + vendor_id?: number; + /** the cron of the current schedule task */ + cron?: string; + /** + * the update time of the schedule task + * @format date-time + */ + update_time?: string; +} + +/** the scheduler status */ +export interface SchedulerStatus { + /** if the scheduler is paused */ + paused: boolean; +} + +/** the security summary */ +export interface SecuritySummary { + /** + * the count of critical vulnerabilities + * @format int64 + */ + critical_cnt: number; + /** + * the count of high vulnerabilities + * @format int64 + */ + high_cnt?: number; + /** + * the count of medium vulnerabilities + * @format int64 + */ + medium_cnt: number; + /** + * the count of low vulnerabilities + * @format int64 + */ + low_cnt: number; + /** + * the count of none vulnerabilities + * @format int64 + */ + none_cnt?: number; + /** + * the count of unknown vulnerabilities + * @format int64 + */ + unknown_cnt?: number; + /** + * the count of total vulnerabilities + * @format int64 + */ + total_vuls: number; + /** + * the count of scanned artifacts + * @format int64 + */ + scanned_cnt: number; + /** + * the total count of artifacts + * @format int64 + */ + total_artifact: number; + /** + * the count of fixable vulnerabilities + * @format int64 + */ + fixable_cnt: number; + /** the list of dangerous CVEs */ + dangerous_cves?: DangerousCVE[]; + /** the list of dangerous artifacts */ + dangerous_artifacts?: DangerousArtifact[]; +} + +/** the dangerous CVE information */ +export interface DangerousCVE { + /** the cve id */ + cve_id?: string; + /** the severity of the CVE */ + severity?: string; + /** + * the cvss score v3 + * @format float64 + */ + cvss_score_v3?: number; + /** the description of the CVE */ + desc?: string; + /** the package of the CVE */ + package?: string; + /** the version of the package */ + version?: string; +} + +/** the dangerous artifact information */ +export interface DangerousArtifact { + /** + * the project id of the artifact + * @format int64 + */ + project_id?: number; + /** the repository name of the artifact */ + repository_name?: string; + /** the digest of the artifact */ + digest?: string; + /** the count of critical vulnerabilities */ + critical_cnt: number; + /** + * the count of high vulnerabilities + * @format int64 + */ + high_cnt: number; + /** the count of medium vulnerabilities */ + medium_cnt: number; +} + +/** the vulnerability item info */ +export interface VulnerabilityItem { + /** + * the project ID of the artifact + * @format int64 + */ + project_id?: number; + /** the repository name of the artifact */ + repository_name?: string; + /** the digest of the artifact */ + digest?: string; + /** the tags of the artifact */ + tags?: string[]; + /** the CVE id of the vulnerability. */ + cve_id?: string; + /** the severity of the vulnerability */ + severity?: string; + /** + * the nvd cvss v3 score of the vulnerability + * @format float + */ + cvss_v3_score?: number; + /** the package of the vulnerability */ + package?: string; + /** the version of the package */ + version?: string; + /** the fixed version of the package */ + fixed_version?: string; + /** The description of the vulnerability */ + desc?: string; + /** Links of the vulnerability */ + links?: string[]; +} + +export interface ScanType { + /** The scan type for the scan request. Two options are currently supported, vulnerability and sbom */ + scan_type?: "vulnerability" | "sbom"; +} + +import type { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + HeadersDefaults, + ResponseType, +} from "axios"; +import axios from "axios"; + +export type QueryParamsType = Record; + +export interface FullRequestParams + extends Omit { + /** set parameter to `true` for call `securityWorker` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseType; + /** request body */ + body?: unknown; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig + extends Omit { + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | AxiosRequestConfig | void; + secure?: boolean; + format?: ResponseType; +} + +export enum ContentType { + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public instance: AxiosInstance; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private secure?: boolean; + private format?: ResponseType; + + constructor({ + securityWorker, + secure, + format, + ...axiosConfig + }: ApiConfig = {}) { + this.instance = axios.create({ + ...axiosConfig, + baseURL: axiosConfig.baseURL || "http://localhost/api/v2.0", + }); + this.secure = secure; + this.format = format; + this.securityWorker = securityWorker; + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected mergeRequestParams( + params1: AxiosRequestConfig, + params2?: AxiosRequestConfig, + ): AxiosRequestConfig { + const method = params1.method || (params2 && params2.method); + + return { + ...this.instance.defaults, + ...params1, + ...(params2 || {}), + headers: { + ...((method && + this.instance.defaults.headers[ + method.toLowerCase() as keyof HeadersDefaults + ]) || + {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected stringifyFormItem(formItem: unknown) { + if (typeof formItem === "object" && formItem !== null) { + return JSON.stringify(formItem); + } else { + return `${formItem}`; + } + } + + protected createFormData(input: Record): FormData { + if (input instanceof FormData) { + return input; + } + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + const isFileType = formItem instanceof Blob || formItem instanceof File; + formData.append( + key, + isFileType ? formItem : this.stringifyFormItem(formItem), + ); + } + + return formData; + }, new FormData()); + } + + public request = async ({ + secure, + path, + type, + query, + format, + body, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const responseFormat = format || this.format || undefined; + + if ( + type === ContentType.FormData && + body && + body !== null && + typeof body === "object" + ) { + body = this.createFormData(body as Record); + } + + if ( + type === ContentType.Text && + body && + body !== null && + typeof body !== "string" + ) { + body = JSON.stringify(body); + } + + return this.instance.request({ + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type ? { "Content-Type": type } : {}), + }, + params: query, + responseType: responseFormat, + data: body, + url: path, + }); + }; +} + +/** + * @title Harbor API + * @version 2.0 + * @baseUrl http://localhost/api/v2.0 + * + * These APIs provide services for manipulating Harbor project. + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + health = { + /** + * @description Check the status of Harbor components. This path does not require authentication. + * + * @tags health + * @name GetHealth + * @summary Check the status of Harbor components + * @request GET:/health + * @secure + */ + getHealth: (params: RequestParams = {}) => + this.request({ + path: `/health`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + search = { + /** + * @description The Search endpoint returns information about the projects and repositories offered at public status or related to the current logged in user. The response includes the project and repository list in a proper display order. + * + * @tags search + * @name Search + * @summary Search for projects and repositories + * @request GET:/search + * @secure + */ + search: ( + query: { + /** Search parameter for project and repository name. */ + q: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + statistics = { + /** + * @description Get the statistic information about the projects and repositories + * + * @tags statistic + * @name GetStatistic + * @summary Get the statistic information about the projects and repositories + * @request GET:/statistics + * @secure + */ + getStatistic: (params: RequestParams = {}) => + this.request({ + path: `/statistics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + ldap = { + /** + * @description This endpoint ping the available ldap service for test related configuration parameters. + * + * @tags Ldap + * @name PingLdap + * @summary Ping available ldap service. + * @request POST:/ldap/ping + * @secure + */ + pingLdap: (ldapconf: LdapConf, params: RequestParams = {}) => + this.request({ + path: `/ldap/ping`, + method: "POST", + body: ldapconf, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint searches the available ldap users based on related configuration parameters. Support searched by input ldap configuration, load configuration from the system and specific filter. + * + * @tags Ldap + * @name SearchLdapUser + * @summary Search available ldap users. + * @request GET:/ldap/users/search + * @secure + */ + searchLdapUser: ( + query?: { + /** Registered user ID */ + username?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/ldap/users/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint adds the selected available ldap users to harbor based on related configuration parameters from the system. System will try to guess the user email address and realname, add to harbor user information. If have errors when import user, will return the list of importing failed uid and the failed reason. + * + * @tags Ldap + * @name ImportLdapUser + * @summary Import selected available ldap users. + * @request POST:/ldap/users/import + * @secure + */ + importLdapUser: (uid_list: LdapImportUsers, params: RequestParams = {}) => + this.request({ + path: `/ldap/users/import`, + method: "POST", + body: uid_list, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint searches the available ldap groups based on related configuration parameters. support to search by groupname or groupdn. + * + * @tags Ldap + * @name SearchLdapGroup + * @summary Search available ldap groups. + * @request GET:/ldap/groups/search + * @secure + */ + searchLdapGroup: ( + query?: { + /** Ldap group name */ + groupname?: string; + /** The LDAP group DN */ + groupdn?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/ldap/groups/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + internalconfig = { + /** + * @description This endpoint is for retrieving system configurations that only provides for internal api call. + * + * @tags configure + * @name GetInternalconfig + * @summary Get internal configurations. + * @request GET:/internalconfig + * @secure + */ + getInternalconfig: (params: RequestParams = {}) => + this.request({ + path: `/internalconfig`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + configurations = { + /** + * @description This endpoint is for retrieving system configurations that only provides for admin user. + * + * @tags configure + * @name GetConfigurations + * @summary Get system configurations. + * @request GET:/configurations + * @secure + */ + getConfigurations: (params: RequestParams = {}) => + this.request({ + path: `/configurations`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for modifying system configurations that only provides for admin user. + * + * @tags configure + * @name UpdateConfigurations + * @summary Modify system configurations. + * @request PUT:/configurations + * @secure + */ + updateConfigurations: ( + configurations: Configurations, + params: RequestParams = {}, + ) => + this.request({ + path: `/configurations`, + method: "PUT", + body: configurations, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + projects = { + /** + * @description This endpoint returns projects created by Harbor. + * + * @tags project + * @name ListProjects + * @summary List projects + * @request GET:/projects + * @secure + */ + listProjects: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** The name of project. */ + name?: string; + /** The project is public or private. */ + public?: boolean; + /** The name of project owner. */ + owner?: string; + /** + * Bool value indicating whether return detailed information of the project + * @default true + */ + with_detail?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is used to check if the project name provided already exist. + * + * @tags project + * @name HeadProject + * @summary Check if the project name user provided already exists. + * @request HEAD:/projects + * @secure + */ + headProject: ( + query: { + /** Project name for checking exists. */ + project_name: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects`, + method: "HEAD", + query: query, + secure: true, + ...params, + }), + + /** + * @description This endpoint is for user to create a new project. + * + * @tags project + * @name CreateProject + * @summary Create a new project. + * @request POST:/projects + * @secure + */ + createProject: (project: ProjectReq, params: RequestParams = {}) => + this.request({ + path: `/projects`, + method: "POST", + body: project, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint returns specific project information by project ID. + * + * @tags project + * @name GetProject + * @summary Return specific project detail information + * @request GET:/projects/{project_name_or_id} + * @secure + */ + getProject: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is aimed to update the properties of a project. + * + * @tags project + * @name UpdateProject + * @summary Update properties for a selected project. + * @request PUT:/projects/{project_name_or_id} + * @secure + */ + updateProject: ( + projectNameOrId: string, + project: ProjectReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "PUT", + body: project, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is aimed to delete project by project ID. + * + * @tags project + * @name DeleteProject + * @summary Delete project by projectID + * @request DELETE:/projects/{project_name_or_id} + * @secure + */ + deleteProject: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Get the deletable status of the project + * + * @tags project + * @name GetProjectDeletable + * @summary Get the deletable status of the project + * @request GET:/projects/{project_name_or_id}/_deletable + * @secure + */ + getProjectDeletable: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/_deletable`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get summary of the project. + * + * @tags project + * @name GetProjectSummary + * @summary Get summary of the project. + * @request GET:/projects/{project_name_or_id}/summary + * @secure + */ + getProjectSummary: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}/summary`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get all project member information + * + * @tags member + * @name ListProjectMembers + * @summary Get all project member information + * @request GET:/projects/{project_name_or_id}/members + * @secure + */ + listProjectMembers: ( + projectNameOrId: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The entity name to search. */ + entityname?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create project member relationship, the member can be one of the user_member and group_member, The user_member need to specify user_id or username. If the user already exist in harbor DB, specify the user_id, If does not exist in harbor DB, it will SearchAndOnBoard the user. The group_member need to specify id or ldap_group_dn. If the group already exist in harbor DB. specify the user group's id, If does not exist, it will SearchAndOnBoard the group. + * + * @tags member + * @name CreateProjectMember + * @summary Create project member + * @request POST:/projects/{project_name_or_id}/members + * @secure + */ + createProjectMember: ( + projectNameOrId: string, + project_member: ProjectMember, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members`, + method: "POST", + body: project_member, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the project member information + * + * @tags member + * @name GetProjectMember + * @summary Get the project member information + * @request GET:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + getProjectMember: ( + projectNameOrId: string, + mid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update project member relationship + * + * @tags member + * @name UpdateProjectMember + * @summary Update project member + * @request PUT:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + updateProjectMember: ( + projectNameOrId: string, + mid: number, + role: RoleRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "PUT", + body: role, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags member + * @name DeleteProjectMember + * @summary Delete project member + * @request DELETE:/projects/{project_name_or_id}/members/{mid} + * @secure + */ + deleteProjectMember: ( + projectNameOrId: string, + mid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/members/${mid}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Get the metadata of the specific project + * + * @tags projectMetadata + * @name ListProjectMetadatas + * @summary Get the metadata of the specific project + * @request GET:/projects/{project_name_or_id}/metadatas/ + * @secure + */ + listProjectMetadatas: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Add metadata for the specific project + * + * @tags projectMetadata + * @name AddProjectMetadatas + * @summary Add metadata for the specific project + * @request POST:/projects/{project_name_or_id}/metadatas/ + * @secure + */ + addProjectMetadatas: ( + projectNameOrId: string, + metadata: Annotations, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/`, + method: "POST", + body: metadata, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific metadata of the specific project + * + * @tags projectMetadata + * @name GetProjectMetadata + * @summary Get the specific metadata of the specific project + * @request GET:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + getProjectMetadata: ( + projectNameOrId: string, + metaName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update the specific metadata for the specific project + * + * @tags projectMetadata + * @name UpdateProjectMetadata + * @summary Update the specific metadata for the specific project + * @request PUT:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + updateProjectMetadata: ( + projectNameOrId: string, + metaName: string, + metadata: Annotations, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "PUT", + body: metadata, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the specific metadata for the specific project + * + * @tags projectMetadata + * @name DeleteProjectMetadata + * @summary Delete the specific metadata for the specific project + * @request DELETE:/projects/{project_name_or_id}/metadatas/{meta_name} + * @secure + */ + deleteProjectMetadata: ( + projectNameOrId: string, + metaName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/metadatas/${metaName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List repositories of the specified project + * + * @tags repository + * @name ListRepositories + * @summary List repositories + * @request GET:/projects/{project_name}/repositories + * @secure + */ + listRepositories: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the repository specified by name + * + * @tags repository + * @name GetRepository + * @summary Get repository + * @request GET:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + getRepository: ( + projectName: string, + repositoryName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update the repository specified by name + * + * @tags repository + * @name UpdateRepository + * @summary Update repository + * @request PUT:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + updateRepository: ( + projectName: string, + repositoryName: string, + repository: Repository, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "PUT", + body: repository, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the repository specified by name + * + * @tags repository + * @name DeleteRepository + * @summary Delete repository + * @request DELETE:/projects/{project_name}/repositories/{repository_name} + * @secure + */ + deleteRepository: ( + projectName: string, + repositoryName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List artifacts under the specific project and repository. Except the basic properties, the other supported queries in "q" includes "tags=*" to list only tagged artifacts, "tags=nil" to list only untagged artifacts, "tags=~v" to list artifacts whose tag fuzzy matches "v", "tags=v" to list artifact whose tag exactly matches "v", "labels=(id1, id2)" to list artifacts that both labels with id1 and id2 are added to + * + * @tags artifact + * @name ListArtifacts + * @summary List artifacts + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts + * @secure + */ + listArtifacts: ( + projectName: string, + repositoryName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are included inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are included inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is included inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifacts, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the immutable status is included inside the tags of the returning artifacts. Only works when setting "with_immutable_status=true" + * @default false + */ + with_immutable_status?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. Only works when setting "with_accessory=true" + * @default false + */ + with_accessory?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Copy the artifact specified in the "from" parameter to the repository. + * + * @tags artifact + * @name CopyArtifact + * @summary Copy artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts + * @secure + */ + copyArtifact: ( + projectName: string, + repositoryName: string, + query: { + /** The artifact from which the new artifact is copied from, the format should be "project/repository:tag" or "project/repository@digest". */ + from: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts`, + method: "POST", + query: query, + secure: true, + ...params, + }), + + /** + * @description Get the artifact specified by the reference under the project and repository. The reference can be digest or tag. + * + * @tags artifact + * @name GetArtifact + * @summary Get the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference} + * @secure + */ + getArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are inclued inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are inclued inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is inclued inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifact, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. + * @default false + */ + with_accessory?: boolean; + /** + * Specify whether the signature is inclued inside the returning artifacts + * @default false + */ + with_signature?: boolean; + /** + * Specify whether the immutable status is inclued inside the tags of the returning artifacts. + * @default false + */ + with_immutable_status?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the artifact specified by the reference under the project and repository. The reference can be digest or tag + * + * @tags artifact + * @name DeleteArtifact + * @summary Delete the specific artifact + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference} + * @secure + */ + deleteArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Scan the specified artifact + * + * @tags scan + * @name ScanArtifact + * @summary Scan the artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan + * @secure + */ + scanArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + scanType: ScanType, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan`, + method: "POST", + body: scanType, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Cancelling a scan job for a particular artifact + * + * @tags scan + * @name StopScanArtifact + * @summary Cancelling a scan job for a particular artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan/stop + * @secure + */ + stopScanArtifact: ( + projectName: string, + repositoryName: string, + reference: string, + scanType: ScanType, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan/stop`, + method: "POST", + body: scanType, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the log of the scan report + * + * @tags scan + * @name GetReportLog + * @summary Get the log of the scan report + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/scan/{report_id}/log + * @secure + */ + getReportLog: ( + projectName: string, + repositoryName: string, + reference: string, + reportId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/scan/${reportId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description Create a tag for the specified artifact + * + * @tags artifact + * @name CreateTag + * @summary Create tag + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags + * @secure + */ + createTag: ( + projectName: string, + repositoryName: string, + reference: string, + tag: Tag, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags`, + method: "POST", + body: tag, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List tags of the specific artifact + * + * @tags artifact + * @name ListTags + * @summary List tags + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags + * @secure + */ + listTags: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the immutable status is included inside the returning tags + * @default false + */ + with_immutable_status?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the tag of the specified artifact + * + * @tags artifact + * @name DeleteTag + * @summary Delete tag + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/tags/{tag_name} + * @secure + */ + deleteTag: ( + projectName: string, + repositoryName: string, + reference: string, + tagName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/tags/${tagName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List accessories of the specific artifact + * + * @tags artifact + * @name ListAccessories + * @summary List accessories + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/accessories + * @secure + */ + listAccessories: ( + projectName: string, + repositoryName: string, + reference: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/accessories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the vulnerabilities addition of the artifact specified by the reference under the project and repository. + * + * @tags artifact + * @name GetVulnerabilitiesAddition + * @summary Get the vulnerabilities addition of the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/additions/vulnerabilities + * @secure + */ + getVulnerabilitiesAddition: ( + projectName: string, + repositoryName: string, + reference: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/additions/vulnerabilities`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the addition of the artifact specified by the reference under the project and repository. + * + * @tags artifact + * @name GetAddition + * @summary Get the addition of the specific artifact + * @request GET:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/additions/{addition} + * @secure + */ + getAddition: ( + projectName: string, + repositoryName: string, + reference: string, + addition: + | "build_history" + | "values.yaml" + | "readme.md" + | "dependencies" + | "sbom" + | "license" + | "files", + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/additions/${addition}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Add label to the specified artiact. + * + * @tags artifact + * @name AddLabel + * @summary Add label to artifact + * @request POST:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/labels + * @secure + */ + addLabel: ( + projectName: string, + repositoryName: string, + reference: string, + label: Label, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/labels`, + method: "POST", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Remove the label from the specified artiact. + * + * @tags artifact + * @name RemoveLabel + * @summary Remove label from artifact + * @request DELETE:/projects/{project_name}/repositories/{repository_name}/artifacts/{reference}/labels/{label_id} + * @secure + */ + removeLabel: ( + projectName: string, + repositoryName: string, + reference: string, + labelId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/repositories/${repositoryName}/artifacts/${reference}/labels/${labelId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List artifacts of the specified project + * + * @tags project + * @name ListArtifactsOfProject + * @summary List artifacts + * @request GET:/projects/{project_name_or_id}/artifacts + * @secure + */ + listArtifactsOfProject: ( + projectNameOrId: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Specify whether the tags are included inside the returning artifacts + * @default true + */ + with_tag?: boolean; + /** + * Specify whether the labels are included inside the returning artifacts + * @default false + */ + with_label?: boolean; + /** + * Specify whether the scan overview is included inside the returning artifacts + * @default false + */ + with_scan_overview?: boolean; + /** + * Specify whether the SBOM overview is included in returning artifacts, when this option is true, the SBOM overview will be included in the response + * @default false + */ + with_sbom_overview?: boolean; + /** + * Specify whether the immutable status is included inside the tags of the returning artifacts. Only works when setting "with_immutable_status=true" + * @default false + */ + with_immutable_status?: boolean; + /** + * Specify whether the accessories are included of the returning artifacts. Only works when setting "with_accessory=true" + * @default false + */ + with_accessory?: boolean; + /** + * Specify whether only the latest pushed artifact of each repository is included inside the returning artifacts. Only works when either artifact_type or media_type is included in the query. + * @default false + */ + latest_in_repository?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/artifacts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the scanner registration of the specified project. If no scanner registration is configured for the specified project, the system default scanner registration will be returned. + * + * @tags project + * @name GetScannerOfProject + * @summary Get project level scanner + * @request GET:/projects/{project_name_or_id}/scanner + * @secure + */ + getScannerOfProject: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Set one of the system configured scanner registration as the indepndent scanner of the specified project. + * + * @tags project + * @name SetScannerOfProject + * @summary Configure scanner for the specified project + * @request PUT:/projects/{project_name_or_id}/scanner + * @secure + */ + setScannerOfProject: ( + projectNameOrId: string, + payload: ProjectScanner, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner`, + method: "PUT", + body: payload, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Retrieve the system configured scanner registrations as candidates of setting project level scanner. + * + * @tags project + * @name ListScannerCandidatesOfProject + * @summary Get scanner registration candidates for configurating project level scanner + * @request GET:/projects/{project_name_or_id}/scanner/candidates + * @secure + */ + listScannerCandidatesOfProject: ( + projectNameOrId: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/scanner/candidates`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get recent logs of the projects, it only query the previous version's audit log + * + * @tags project + * @name GetLogs + * @summary Get recent logs of the projects (deprecated) + * @request GET:/projects/{project_name}/logs + * @secure + */ + getLogs: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/logs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get recent logs of the projects + * + * @tags project + * @name GetLogExts + * @summary Get recent logs of the projects + * @request GET:/projects/{project_name}/auditlog-exts + * @secure + */ + getLogExts: ( + projectName: string, + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/auditlog-exts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a preheat policy under a project + * + * @tags preheat + * @name CreatePolicy + * @summary Create a preheat policy under a project + * @request POST:/projects/{project_name}/preheat/policies + * @secure + */ + createPolicy: ( + projectName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List preheat policies + * + * @tags preheat + * @name ListPolicies + * @summary List preheat policies + * @request GET:/projects/{project_name}/preheat/policies + * @secure + */ + listPolicies: ( + projectName: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a preheat policy + * + * @tags preheat + * @name GetPolicy + * @summary Get a preheat policy + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + getPolicy: ( + projectName: string, + preheatPolicyName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update preheat policy + * + * @tags preheat + * @name UpdatePolicy + * @summary Update preheat policy + * @request PUT:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + updatePolicy: ( + projectName: string, + preheatPolicyName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Manual preheat + * + * @tags preheat + * @name ManualPreheat + * @summary Manual preheat + * @request POST:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + manualPreheat: ( + projectName: string, + preheatPolicyName: string, + policy: PreheatPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete a preheat policy + * + * @tags preheat + * @name DeletePolicy + * @summary Delete a preheat policy + * @request DELETE:/projects/{project_name}/preheat/policies/{preheat_policy_name} + * @secure + */ + deletePolicy: ( + projectName: string, + preheatPolicyName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description List executions for the given policy + * + * @tags preheat + * @name ListExecutions + * @summary List executions for the given policy + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions + * @secure + */ + listExecutions: ( + projectName: string, + preheatPolicyName: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a execution detail by id + * + * @tags preheat + * @name GetExecution + * @summary Get a execution detail by id + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id} + * @secure + */ + getExecution: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop a execution + * + * @tags preheat + * @name StopExecution + * @summary Stop a execution + * @request PATCH:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id} + * @secure + */ + stopExecution: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + execution: Execution, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}`, + method: "PATCH", + body: execution, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List all the related tasks for the given execution + * + * @tags preheat + * @name ListTasks + * @summary List all the related tasks for the given execution + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id}/tasks + * @secure + */ + listTasks: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the log text stream of the specified task for the given execution + * + * @tags preheat + * @name GetPreheatLog + * @summary Get the log text stream of the specified task for the given execution + * @request GET:/projects/{project_name}/preheat/policies/{preheat_policy_name}/executions/{execution_id}/tasks/{task_id}/logs + * @secure + */ + getPreheatLog: ( + projectName: string, + preheatPolicyName: string, + executionId: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/policies/${preheatPolicyName}/executions/${executionId}/tasks/${taskId}/logs`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description Get all providers at project level + * + * @tags preheat + * @name ListProvidersUnderProject + * @summary Get all providers at project level + * @request GET:/projects/{project_name}/preheat/providers + * @secure + */ + listProvidersUnderProject: ( + projectName: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectName}/preheat/providers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the immutable tag rules of a project + * + * @tags immutable + * @name ListImmuRules + * @summary List all immutable tag rules of current project + * @request GET:/projects/{project_name_or_id}/immutabletagrules + * @secure + */ + listImmuRules: ( + projectNameOrId: string, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint add an immutable tag rule to the project + * + * @tags immutable + * @name CreateImmuRule + * @summary Add an immutable tag rule to current project + * @request POST:/projects/{project_name_or_id}/immutabletagrules + * @secure + */ + createImmuRule: ( + projectNameOrId: string, + ImmutableRule: ImmutableRule, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules`, + method: "POST", + body: ImmutableRule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags immutable + * @name UpdateImmuRule + * @summary Update the immutable tag rule or enable or disable the rule + * @request PUT:/projects/{project_name_or_id}/immutabletagrules/{immutable_rule_id} + * @secure + */ + updateImmuRule: ( + projectNameOrId: string, + immutableRuleId: number, + ImmutableRule: ImmutableRule, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules/${immutableRuleId}`, + method: "PUT", + body: ImmutableRule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags immutable + * @name DeleteImmuRule + * @summary Delete the immutable tag rule. + * @request DELETE:/projects/{project_name_or_id}/immutabletagrules/{immutable_rule_id} + * @secure + */ + deleteImmuRule: ( + projectNameOrId: string, + immutableRuleId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/immutabletagrules/${immutableRuleId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns webhook policies of a project. + * + * @tags webhook + * @name ListWebhookPoliciesOfProject + * @summary List project webhook policies. + * @request GET:/projects/{project_name_or_id}/webhook/policies + * @secure + */ + listWebhookPoliciesOfProject: ( + projectNameOrId: string, + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint create a webhook policy if the project does not have one. + * + * @tags webhook + * @name CreateWebhookPolicyOfProject + * @summary Create project webhook policy. + * @request POST:/projects/{project_name_or_id}/webhook/policies + * @secure + */ + createWebhookPolicyOfProject: ( + projectNameOrId: string, + policy: WebhookPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint returns specified webhook policy of a project. + * + * @tags webhook + * @name GetWebhookPolicyOfProject + * @summary Get project webhook policy + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + getWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is aimed to update the webhook policy of a project. + * + * @tags webhook + * @name UpdateWebhookPolicyOfProject + * @summary Update webhook policy of a project. + * @request PUT:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + updateWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + policy: WebhookPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is aimed to delete webhookpolicy of a project. + * + * @tags webhook + * @name DeleteWebhookPolicyOfProject + * @summary Delete webhook policy of a project + * @request DELETE:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id} + * @secure + */ + deleteWebhookPolicyOfProject: ( + projectNameOrId: string, + webhookPolicyId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns the executions of a specific webhook policy. + * + * @tags webhook + * @name ListExecutionsOfWebhookPolicy + * @summary List executions for a specific webhook policy + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions + * @secure + */ + listExecutionsOfWebhookPolicy: ( + projectNameOrId: string, + webhookPolicyId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the tasks of a specific webhook execution. + * + * @tags webhook + * @name ListTasksOfWebhookExecution + * @summary List tasks for a specific webhook execution + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions/{execution_id}/tasks + * @secure + */ + listTasksOfWebhookExecution: ( + projectNameOrId: string, + webhookPolicyId: number, + executionId: number, + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions/${executionId}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns the logs of a specific webhook task. + * + * @tags webhook + * @name GetLogsOfWebhookTask + * @summary Get logs for a specific webhook task + * @request GET:/projects/{project_name_or_id}/webhook/policies/{webhook_policy_id}/executions/{execution_id}/tasks/{task_id}/log + * @secure + */ + getLogsOfWebhookTask: ( + projectNameOrId: string, + webhookPolicyId: number, + executionId: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/policies/${webhookPolicyId}/executions/${executionId}/tasks/${taskId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint returns last trigger information of project webhook policy. + * + * @tags webhook + * @name LastTrigger + * @summary Get project webhook policy last trigger info + * @request GET:/projects/{project_name_or_id}/webhook/lasttrigger + * @deprecated + * @secure + */ + lastTrigger: (projectNameOrId: string, params: RequestParams = {}) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/lasttrigger`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns webhook jobs of a project. + * + * @tags webhookjob + * @name ListWebhookJobs + * @summary List project webhook jobs + * @request GET:/projects/{project_name_or_id}/webhook/jobs + * @deprecated + * @secure + */ + listWebhookJobs: ( + projectNameOrId: string, + query: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * The policy ID. + * @format int64 + */ + policy_id: number; + /** The status of webhook job. */ + status?: string[]; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/jobs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get supported event types and notify types. + * + * @tags webhook + * @name GetSupportedEventTypes + * @summary Get supported event types and notify types. + * @request GET:/projects/{project_name_or_id}/webhook/events + * @secure + */ + getSupportedEventTypes: ( + projectNameOrId: string, + params: RequestParams = {}, + ) => + this.request({ + path: `/projects/${projectNameOrId}/webhook/events`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + repositories = { + /** + * @description List all authorized repositories + * + * @tags repository + * @name ListAllRepositories + * @summary List all authorized repositories + * @request GET:/repositories + * @secure + */ + listAllRepositories: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/repositories`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + auditLogs = { + /** + * @description This endpoint let the user see the recent operation logs of projects which the user is a member with project admin role,, or return all audit logs for system admin user, it only query the audit log in previous version. + * + * @tags auditlog + * @name ListAuditLogs + * @summary Get recent logs of projects which the user is a member with project admin role, or return all audit logs for system admin user (deprecated) + * @request GET:/audit-logs + * @secure + */ + listAuditLogs: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/audit-logs`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + auditlogExts = { + /** + * @description This endpoint let user see the recent operation logs of the projects which he is member with project_admin role, or return all audit logs for system admin user. + * + * @tags auditlog + * @name ListAuditLogExts + * @summary Get recent logs of the projects which the user is a member with project_admin role, or return all audit logs for system admin user + * @request GET:/auditlog-exts + * @secure + */ + listAuditLogExts: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/auditlog-exts`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get all event types of audit log + * + * @tags auditlog + * @name ListAuditLogEventTypes + * @summary Get all event types of audit log + * @request GET:/auditlog-exts/events + * @secure + */ + listAuditLogEventTypes: (params: RequestParams = {}) => + this.request({ + path: `/auditlog-exts/events`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + p2P = { + /** + * @description List P2P providers + * + * @tags preheat + * @name ListProviders + * @summary List P2P providers + * @request GET:/p2p/preheat/providers + * @secure + */ + listProviders: (params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/providers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint checks status of a instance, the instance can be given by ID or Endpoint URL (together with credential) + * + * @tags preheat + * @name PingInstances + * @summary Ping status of a instance. + * @request POST:/p2p/preheat/instances/ping + * @secure + */ + pingInstances: (instance: Instance, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/ping`, + method: "POST", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List P2P provider instances + * + * @tags preheat + * @name ListInstances + * @summary List P2P provider instances + * @request GET:/p2p/preheat/instances + * @secure + */ + listInstances: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/p2p/preheat/instances`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create p2p provider instances + * + * @tags preheat + * @name CreateInstance + * @summary Create p2p provider instances + * @request POST:/p2p/preheat/instances + * @secure + */ + createInstance: (instance: Instance, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances`, + method: "POST", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get a P2P provider instance + * + * @tags preheat + * @name GetInstance + * @summary Get a P2P provider instance + * @request GET:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + getInstance: (preheatInstanceName: string, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specified P2P provider instance + * + * @tags preheat + * @name DeleteInstance + * @summary Delete the specified P2P provider instance + * @request DELETE:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + deleteInstance: (preheatInstanceName: string, params: RequestParams = {}) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the specified P2P provider instance + * + * @tags preheat + * @name UpdateInstance + * @summary Update the specified P2P provider instance + * @request PUT:/p2p/preheat/instances/{preheat_instance_name} + * @secure + */ + updateInstance: ( + preheatInstanceName: string, + instance: Instance, + params: RequestParams = {}, + ) => + this.request({ + path: `/p2p/preheat/instances/${preheatInstanceName}`, + method: "PUT", + body: instance, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + usergroups = { + /** + * @description Get all user groups information, it is open for system admin + * + * @tags usergroup + * @name ListUserGroups + * @summary Get all user groups information + * @request GET:/usergroups + * @secure + */ + listUserGroups: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** search with ldap group DN */ + ldap_group_dn?: string; + /** group name need to search, fuzzy matches */ + group_name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create user group information + * + * @tags usergroup + * @name CreateUserGroup + * @summary Create user group + * @request POST:/usergroups + * @secure + */ + createUserGroup: (usergroup: UserGroup, params: RequestParams = {}) => + this.request({ + path: `/usergroups`, + method: "POST", + body: usergroup, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is to search groups by group name. It's open for all authenticated requests. + * + * @tags usergroup + * @name SearchUserGroups + * @summary Search groups by groupname + * @request GET:/usergroups/search + * @secure + */ + searchUserGroups: ( + query: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Group name for filtering results. */ + groupname: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get user group information + * + * @tags usergroup + * @name GetUserGroup + * @summary Get user group information + * @request GET:/usergroups/{group_id} + * @secure + */ + getUserGroup: (groupId: number, params: RequestParams = {}) => + this.request({ + path: `/usergroups/${groupId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update user group information + * + * @tags usergroup + * @name UpdateUserGroup + * @summary Update group information + * @request PUT:/usergroups/{group_id} + * @secure + */ + updateUserGroup: ( + groupId: number, + usergroup: UserGroup, + params: RequestParams = {}, + ) => + this.request({ + path: `/usergroups/${groupId}`, + method: "PUT", + body: usergroup, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete user group + * + * @tags usergroup + * @name DeleteUserGroup + * @summary Delete user group + * @request DELETE:/usergroups/{group_id} + * @secure + */ + deleteUserGroup: (groupId: number, params: RequestParams = {}) => + this.request({ + path: `/usergroups/${groupId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + icons = { + /** + * @description Get the artifact icon with the specified digest. As the original icon image is resized and encoded before returning, the parameter "digest" in the path doesn't match the hash of the returned content + * + * @tags icon + * @name GetIcon + * @summary Get artifact icon + * @request GET:/icons/{digest} + * @secure + */ + getIcon: (digest: string, params: RequestParams = {}) => + this.request({ + path: `/icons/${digest}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + robots = { + /** + * @description List the robot accounts with the specified level and project. + * + * @tags robot + * @name ListRobot + * @summary Get robot account + * @request GET:/robots + * @secure + */ + listRobot: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/robots`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a robot account + * + * @tags robot + * @name CreateRobot + * @summary Create a robot account + * @request POST:/robots + * @secure + */ + createRobot: (robot: RobotCreate, params: RequestParams = {}) => + this.request({ + path: `/robots`, + method: "POST", + body: robot, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint returns specific robot account information by robot ID. + * + * @tags robot + * @name GetRobotById + * @summary Get a robot account + * @request GET:/robots/{robot_id} + * @secure + */ + getRobotById: (robotId: number, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint updates specific robot account information by robot ID. + * + * @tags robot + * @name UpdateRobot + * @summary Update a robot account + * @request PUT:/robots/{robot_id} + * @secure + */ + updateRobot: (robotId: number, robot: Robot, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "PUT", + body: robot, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Refresh the robot secret + * + * @tags robot + * @name RefreshSec + * @summary Refresh the robot secret + * @request PATCH:/robots/{robot_id} + * @secure + */ + refreshSec: ( + robotId: number, + robotSec: RobotSec, + params: RequestParams = {}, + ) => + this.request({ + path: `/robots/${robotId}`, + method: "PATCH", + body: robotSec, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description This endpoint deletes specific robot account information by robot ID. + * + * @tags robot + * @name DeleteRobot + * @summary Delete a robot account + * @request DELETE:/robots/{robot_id} + * @secure + */ + deleteRobot: (robotId: number, params: RequestParams = {}) => + this.request({ + path: `/robots/${robotId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + quotas = { + /** + * @description List quotas + * + * @tags quota + * @name ListQuotas + * @summary List quotas + * @request GET:/quotas + * @secure + */ + listQuotas: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The reference type of quota. */ + reference?: string; + /** The reference id of quota. */ + reference_id?: string; + /** + * Sort method, valid values include: + * 'hard.resource_name', '-hard.resource_name', 'used.resource_name', '-used.resource_name'. + * Here '-' stands for descending order, resource_name should be the real resource name of the quota. + */ + sort?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/quotas`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the specified quota + * + * @tags quota + * @name GetQuota + * @summary Get the specified quota + * @request GET:/quotas/{id} + * @secure + */ + getQuota: (id: number, params: RequestParams = {}) => + this.request({ + path: `/quotas/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update hard limits of the specified quota + * + * @tags quota + * @name UpdateQuota + * @summary Update the specified quota + * @request PUT:/quotas/{id} + * @secure + */ + updateQuota: ( + id: number, + hard: QuotaUpdateReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/quotas/${id}`, + method: "PUT", + body: hard, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + replication = { + /** + * @description List replication policies + * + * @tags replication + * @name ListReplicationPolicies + * @summary List replication policies + * @request GET:/replication/policies + * @secure + */ + listReplicationPolicies: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Deprecated, use "query" instead. The policy name. */ + name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create a replication policy + * + * @tags replication + * @name CreateReplicationPolicy + * @summary Create a replication policy + * @request POST:/replication/policies + * @secure + */ + createReplicationPolicy: ( + policy: ReplicationPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific replication policy + * + * @tags replication + * @name GetReplicationPolicy + * @summary Get the specific replication policy + * @request GET:/replication/policies/{id} + * @secure + */ + getReplicationPolicy: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/policies/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specific replication policy + * + * @tags replication + * @name DeleteReplicationPolicy + * @summary Delete the specific replication policy + * @request DELETE:/replication/policies/{id} + * @secure + */ + deleteReplicationPolicy: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/policies/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the replication policy + * + * @tags replication + * @name UpdateReplicationPolicy + * @summary Update the replication policy + * @request PUT:/replication/policies/{id} + * @secure + */ + updateReplicationPolicy: ( + id: number, + policy: ReplicationPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/policies/${id}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List replication executions + * + * @tags replication + * @name ListReplicationExecutions + * @summary List replication executions + * @request GET:/replication/executions + * @secure + */ + listReplicationExecutions: ( + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The ID of the policy that the executions belong to. */ + policy_id?: number; + /** The execution status. */ + status?: string; + /** The trigger mode. */ + trigger?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Start one replication execution according to the policy + * + * @tags replication + * @name StartReplication + * @summary Start one replication execution + * @request POST:/replication/executions + * @secure + */ + startReplication: ( + execution: StartReplicationExecution, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions`, + method: "POST", + body: execution, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the replication execution specified by ID + * + * @tags replication + * @name GetReplicationExecution + * @summary Get the specific replication execution + * @request GET:/replication/executions/{id} + * @secure + */ + getReplicationExecution: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/executions/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the replication execution specified by ID + * + * @tags replication + * @name StopReplication + * @summary Stop the specific replication execution + * @request PUT:/replication/executions/{id} + * @secure + */ + stopReplication: (id: number, params: RequestParams = {}) => + this.request({ + path: `/replication/executions/${id}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description List replication tasks for a specific execution + * + * @tags replication + * @name ListReplicationTasks + * @summary List replication tasks for a specific execution + * @request GET:/replication/executions/{id}/tasks + * @secure + */ + listReplicationTasks: ( + id: number, + query?: { + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The task status. */ + status?: string; + /** The resource type. */ + resource_type?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions/${id}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the log of the specific replication task + * + * @tags replication + * @name GetReplicationLog + * @summary Get the log of the specific replication task + * @request GET:/replication/executions/{id}/tasks/{task_id}/log + * @secure + */ + getReplicationLog: ( + id: number, + taskId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/replication/executions/${id}/tasks/${taskId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description List registry adapters + * + * @tags registry + * @name ListRegistryProviderTypes + * @summary List registry adapters + * @request GET:/replication/adapters + * @secure + */ + listRegistryProviderTypes: (params: RequestParams = {}) => + this.request({ + path: `/replication/adapters`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description List all registered registry provider information + * + * @tags registry + * @name ListRegistryProviderInfos + * @summary List all registered registry provider information + * @request GET:/replication/adapterinfos + * @secure + */ + listRegistryProviderInfos: (params: RequestParams = {}) => + this.request, Errors>({ + path: `/replication/adapterinfos`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + registries = { + /** + * @description Create a registry + * + * @tags registry + * @name CreateRegistry + * @summary Create a registry + * @request POST:/registries + * @secure + */ + createRegistry: (registry: Registry, params: RequestParams = {}) => + this.request({ + path: `/registries`, + method: "POST", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description List the registries + * + * @tags registry + * @name ListRegistries + * @summary List the registries + * @request GET:/registries + * @secure + */ + listRegistries: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Deprecated, use `q` instead. */ + name?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/registries`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Check status of a registry + * + * @tags registry + * @name PingRegistry + * @summary Check status of a registry + * @request POST:/registries/ping + * @secure + */ + pingRegistry: (registry: RegistryPing, params: RequestParams = {}) => + this.request({ + path: `/registries/ping`, + method: "POST", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the specific registry + * + * @tags registry + * @name GetRegistry + * @summary Get the specific registry + * @request GET:/registries/{id} + * @secure + */ + getRegistry: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Delete the specific registry + * + * @tags registry + * @name DeleteRegistry + * @summary Delete the specific registry + * @request DELETE:/registries/{id} + * @secure + */ + deleteRegistry: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Update the registry + * + * @tags registry + * @name UpdateRegistry + * @summary Update the registry + * @request PUT:/registries/{id} + * @secure + */ + updateRegistry: ( + id: number, + registry: RegistryUpdate, + params: RequestParams = {}, + ) => + this.request({ + path: `/registries/${id}`, + method: "PUT", + body: registry, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the registry info + * + * @tags registry + * @name GetRegistryInfo + * @summary Get the registry info + * @request GET:/registries/{id}/info + * @secure + */ + getRegistryInfo: (id: number, params: RequestParams = {}) => + this.request({ + path: `/registries/${id}/info`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + scans = { + /** + * @description Get the metrics of the latest scan all process + * + * @tags scanAll + * @name GetLatestScanAllMetrics + * @summary Get the metrics of the latest scan all process + * @request GET:/scans/all/metrics + * @secure + */ + getLatestScanAllMetrics: (params: RequestParams = {}) => + this.request({ + path: `/scans/all/metrics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the metrics of the latest scheduled scan all process + * + * @tags scanAll + * @name GetLatestScheduledScanAllMetrics + * @summary Get the metrics of the latest scheduled scan all process + * @request GET:/scans/schedule/metrics + * @deprecated + * @secure + */ + getLatestScheduledScanAllMetrics: (params: RequestParams = {}) => + this.request({ + path: `/scans/schedule/metrics`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + systeminfo = { + /** + * @description This API is for retrieving general system info, this can be called by anonymous request. Some attributes will be omitted in the response when this API is called by anonymous request. + * + * @tags systeminfo + * @name GetSystemInfo + * @summary Get general system info + * @request GET:/systeminfo + * @secure + */ + getSystemInfo: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for retrieving system volume info that only provides for admin user. Note that the response only reflects the storage status of local disk. + * + * @tags systeminfo + * @name GetVolumes + * @summary Get system volume info (total/free size). + * @request GET:/systeminfo/volumes + * @secure + */ + getVolumes: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo/volumes`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for downloading a default root certificate. + * + * @tags systeminfo + * @name GetCert + * @summary Get default root certificate. + * @request GET:/systeminfo/getcert + * @secure + */ + getCert: (params: RequestParams = {}) => + this.request({ + path: `/systeminfo/getcert`, + method: "GET", + secure: true, + ...params, + }), + }; + system = { + /** + * @description Test the OIDC endpoint, the setting of the endpoint is provided in the request. This API can only be called by system admin. + * + * @tags oidc + * @name PingOidc + * @summary Test the OIDC endpoint. + * @request POST:/system/oidc/ping + * @secure + */ + pingOidc: ( + endpoint: { + /** The URL of OIDC endpoint to be tested. */ + url?: string; + /** Whether the certificate should be verified */ + verify_cert?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/oidc/ping`, + method: "POST", + body: endpoint, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let user get gc execution history. + * + * @tags gc + * @name GetGcHistory + * @summary Get gc results. + * @request GET:/system/gc + * @secure + */ + getGcHistory: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/gc`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user get gc status filtered by specific ID. + * + * @tags gc + * @name GetGc + * @summary Get gc status. + * @request GET:/system/gc/{gc_id} + * @secure + */ + getGc: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the GC execution specified by ID + * + * @tags gc + * @name StopGc + * @summary Stop the specific GC execution + * @request PUT:/system/gc/{gc_id} + * @secure + */ + stopGc: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description This endpoint let user get gc job logs filtered by specific ID. + * + * @tags gc + * @name GetGcLog + * @summary Get gc job log. + * @request GET:/system/gc/{gc_id}/log + * @secure + */ + getGcLog: (gcId: number, params: RequestParams = {}) => + this.request({ + path: `/system/gc/${gcId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint is for get schedule of gc job. + * + * @tags gc + * @name GetGcSchedule + * @summary Get gc's schedule. + * @request GET:/system/gc/schedule + * @secure + */ + getGcSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for update gc schedule. + * + * @tags gc + * @name CreateGcSchedule + * @summary Create a gc schedule. + * @request POST:/system/gc/schedule + * @secure + */ + createGcSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for update gc schedule. + * + * @tags gc + * @name UpdateGcSchedule + * @summary Update gc's schedule. + * @request PUT:/system/gc/schedule + * @secure + */ + updateGcSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/gc/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description get purge job execution history. + * + * @tags purge + * @name GetPurgeHistory + * @summary Get purge job results. + * @request GET:/system/purgeaudit + * @secure + */ + getPurgeHistory: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/purgeaudit`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user get purge job status filtered by specific ID. + * + * @tags purge + * @name GetPurgeJob + * @summary Get purge job status. + * @request GET:/system/purgeaudit/{purge_id} + * @secure + */ + getPurgeJob: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop the purge audit log execution specified by ID + * + * @tags purge + * @name StopPurge + * @summary Stop the specific purge audit log execution + * @request PUT:/system/purgeaudit/{purge_id} + * @secure + */ + stopPurge: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description This endpoint let user get purge job logs filtered by specific ID. + * + * @tags purge + * @name GetPurgeJobLog + * @summary Get purge job log. + * @request GET:/system/purgeaudit/{purge_id}/log + * @secure + */ + getPurgeJobLog: (purgeId: number, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/${purgeId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description This endpoint is for get schedule of purge job. + * + * @tags purge + * @name GetPurgeSchedule + * @summary Get purge's schedule. + * @request GET:/system/purgeaudit/schedule + * @secure + */ + getPurgeSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for update purge job schedule. + * + * @tags purge + * @name CreatePurgeSchedule + * @summary Create a purge job schedule. + * @request POST:/system/purgeaudit/schedule + * @secure + */ + createPurgeSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for update purge job schedule. + * + * @tags purge + * @name UpdatePurgeSchedule + * @summary Update purge job's schedule. + * @request PUT:/system/purgeaudit/schedule + * @secure + */ + updatePurgeSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/purgeaudit/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the system level allowlist of CVE. This API can be called by all authenticated users. + * + * @tags SystemCVEAllowlist + * @name GetSystemCveAllowlist + * @summary Get the system level allowlist of CVE. + * @request GET:/system/CVEAllowlist + * @secure + */ + getSystemCveAllowlist: (params: RequestParams = {}) => + this.request({ + path: `/system/CVEAllowlist`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This API overwrites the system level allowlist of CVE with the list in request body. Only system Admin has permission to call this API. + * + * @tags SystemCVEAllowlist + * @name PutSystemCveAllowlist + * @summary Update the system level allowlist of CVE. + * @request PUT:/system/CVEAllowlist + * @secure + */ + putSystemCveAllowlist: ( + allowlist: CVEAllowlist, + params: RequestParams = {}, + ) => + this.request({ + path: `/system/CVEAllowlist`, + method: "PUT", + body: allowlist, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for getting a schedule for the scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name GetScanAllSchedule + * @summary Get scan all's schedule. + * @request GET:/system/scanAll/schedule + * @secure + */ + getScanAllSchedule: (params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is for updating the schedule of scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name UpdateScanAllSchedule + * @summary Update scan all's schedule. + * @request PUT:/system/scanAll/schedule + * @secure + */ + updateScanAllSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "PUT", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for creating a schedule or a manual trigger for the scan all job, which scans all of images in Harbor. + * + * @tags scanAll + * @name CreateScanAllSchedule + * @summary Create a schedule or a manual trigger for the scan all job. + * @request POST:/system/scanAll/schedule + * @secure + */ + createScanAllSchedule: (schedule: Schedule, params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/schedule`, + method: "POST", + body: schedule, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Stop scanAll job execution + * + * @tags scanAll + * @name StopScanAll + * @summary Stop scanAll job execution + * @request POST:/system/scanAll/stop + * @secure + */ + stopScanAll: (params: RequestParams = {}) => + this.request({ + path: `/system/scanAll/stop`, + method: "POST", + secure: true, + ...params, + }), + }; + jobservice = { + /** + * @description Get worker pools + * + * @tags jobservice + * @name GetWorkerPools + * @summary Get worker pools + * @request GET:/jobservice/pools + * @secure + */ + getWorkerPools: (params: RequestParams = {}) => + this.request({ + path: `/jobservice/pools`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get workers in current pool + * + * @tags jobservice + * @name GetWorkers + * @summary Get workers + * @request GET:/jobservice/pools/{pool_id}/workers + * @secure + */ + getWorkers: (poolId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/pools/${poolId}/workers`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop running job + * + * @tags jobservice + * @name StopRunningJob + * @summary Stop running job + * @request PUT:/jobservice/jobs/{job_id} + * @secure + */ + stopRunningJob: (jobId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/jobs/${jobId}`, + method: "PUT", + secure: true, + ...params, + }), + + /** + * @description Get job log by job id, it is only used by administrator + * + * @tags jobservice + * @name ActionGetJobLog + * @summary Get job log by job id + * @request GET:/jobservice/jobs/{job_id}/log + * @secure + */ + actionGetJobLog: (jobId: string, params: RequestParams = {}) => + this.request({ + path: `/jobservice/jobs/${jobId}/log`, + method: "GET", + secure: true, + ...params, + }), + + /** + * @description list job queue + * + * @tags jobservice + * @name ListJobQueues + * @summary list job queues + * @request GET:/jobservice/queues + * @secure + */ + listJobQueues: (params: RequestParams = {}) => + this.request({ + path: `/jobservice/queues`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description stop and clean, pause, resume pending jobs in the queue + * + * @tags jobservice + * @name ActionPendingJobs + * @summary stop and clean, pause, resume pending jobs in the queue + * @request PUT:/jobservice/queues/{job_type} + * @secure + */ + actionPendingJobs: ( + jobType: string, + action_request: ActionRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/jobservice/queues/${jobType}`, + method: "PUT", + body: action_request, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + schedules = { + /** + * @description List schedules + * + * @tags schedule + * @name ListSchedules + * @request GET:/schedules + * @secure + */ + listSchedules: ( + query?: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/schedules`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get scheduler paused status + * + * @tags schedule + * @name GetSchedulePaused + * @request GET:/schedules/{job_type}/paused + * @secure + */ + getSchedulePaused: (jobType: string, params: RequestParams = {}) => + this.request({ + path: `/schedules/${jobType}/paused`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + ping = { + /** + * @description This API simply replies a pong to indicate the process to handle API is up, disregarding the health status of dependent components. This path does not require any authentication. + * + * @tags ping + * @name GetPing + * @summary Ping Harbor to check if the API server is alive. + * @request GET:/ping + * @secure + */ + getPing: (params: RequestParams = {}) => + this.request({ + path: `/ping`, + method: "GET", + secure: true, + ...params, + }), + }; + retentions = { + /** + * @description Get Retention Metadatas. + * + * @tags Retention + * @name GetRentenitionMetadata + * @summary Get Retention Metadatas + * @request GET:/retentions/metadatas + * @secure + */ + getRentenitionMetadata: (params: RequestParams = {}) => + this.request({ + path: `/retentions/metadatas`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Create Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when no retention policy binded to project yet. + * + * @tags Retention + * @name CreateRetention + * @summary Create Retention Policy + * @request POST:/retentions + * @secure + */ + createRetention: (policy: RetentionPolicy, params: RequestParams = {}) => + this.request({ + path: `/retentions`, + method: "POST", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention Policy. + * + * @tags Retention + * @name GetRetention + * @summary Get Retention Policy + * @request GET:/retentions/{id} + * @secure + */ + getRetention: (id: number, params: RequestParams = {}) => + this.request({ + path: `/retentions/${id}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Update Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when retention policy has already binded to project. + * + * @tags Retention + * @name UpdateRetention + * @summary Update Retention Policy + * @request PUT:/retentions/{id} + * @secure + */ + updateRetention: ( + id: number, + policy: RetentionPolicy, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}`, + method: "PUT", + body: policy, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete Retention Policy, you can reference metadatas API for the policy model. You can check project metadatas to find whether a retention policy is already binded. This method should only be called when retention policy has already binded to project. + * + * @tags Retention + * @name DeleteRetention + * @summary Delete Retention Policy + * @request DELETE:/retentions/{id} + * @secure + */ + deleteRetention: (id: number, params: RequestParams = {}) => + this.request({ + path: `/retentions/${id}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * @description Trigger a Retention Execution, if dry_run is True, nothing would be deleted actually. + * + * @tags Retention + * @name TriggerRetentionExecution + * @summary Trigger a Retention Execution + * @request POST:/retentions/{id}/executions + * @secure + */ + triggerRetentionExecution: ( + id: number, + body: { + dry_run?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions`, + method: "POST", + body: body, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention executions, execution status may be delayed before job service schedule it up. + * + * @tags Retention + * @name ListRetentionExecutions + * @summary Get Retention executions + * @request GET:/retentions/{id}/executions + * @secure + */ + listRetentionExecutions: ( + id: number, + query?: { + /** + * The page number. + * @format int64 + */ + page?: number; + /** + * The size of per page. + * @format int64 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Stop a Retention execution, only support "stop" action now. + * + * @tags Retention + * @name OperateRetentionExecution + * @summary Stop a Retention execution + * @request PATCH:/retentions/{id}/executions/{eid} + * @secure + */ + operateRetentionExecution: ( + id: number, + eid: number, + body: { + action?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}`, + method: "PATCH", + body: body, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get Retention tasks, each repository as a task. + * + * @tags Retention + * @name ListRetentionTasks + * @summary Get Retention tasks + * @request GET:/retentions/{id}/executions/{eid}/tasks + * @secure + */ + listRetentionTasks: ( + id: number, + eid: number, + query?: { + /** + * The page number. + * @format int64 + */ + page?: number; + /** + * The size of per page. + * @format int64 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}/tasks`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get Retention job task log, tags ratain or deletion detail will be shown in a table. + * + * @tags Retention + * @name GetRetentionTaskLog + * @summary Get Retention job task log + * @request GET:/retentions/{id}/executions/{eid}/tasks/{tid} + * @secure + */ + getRetentionTaskLog: ( + id: number, + eid: number, + tid: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/retentions/${id}/executions/${eid}/tasks/${tid}`, + method: "GET", + secure: true, + ...params, + }), + }; + scanners = { + /** + * @description Returns a list of currently configured scanner registrations. + * + * @tags scanner + * @name ListScanners + * @summary List scanner registrations + * @request GET:/scanners + * @secure + */ + listScanners: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Creats a new scanner registration with the given data. + * + * @tags scanner + * @name CreateScanner + * @summary Create a scanner registration + * @request POST:/scanners + * @secure + */ + createScanner: ( + registration: ScannerRegistrationReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners`, + method: "POST", + body: registration, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Pings scanner adapter to test endpoint URL and authorization settings. + * + * @tags scanner + * @name PingScanner + * @summary Tests scanner registration settings + * @request POST:/scanners/ping + * @secure + */ + pingScanner: ( + settings: ScannerRegistrationSettings, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/ping`, + method: "POST", + body: settings, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Retruns the details of the specified scanner registration. + * + * @tags scanner + * @name GetScanner + * @summary Get a scanner registration details + * @request GET:/scanners/{registration_id} + * @secure + */ + getScanner: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Updates the specified scanner registration. + * + * @tags scanner + * @name UpdateScanner + * @summary Update a scanner registration + * @request PUT:/scanners/{registration_id} + * @secure + */ + updateScanner: ( + registrationId: string, + registration: ScannerRegistrationReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/${registrationId}`, + method: "PUT", + body: registration, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Deletes the specified scanner registration. + * + * @tags scanner + * @name DeleteScanner + * @summary Delete a scanner registration + * @request DELETE:/scanners/{registration_id} + * @secure + */ + deleteScanner: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}`, + method: "DELETE", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Set the specified scanner registration as the system default one. + * + * @tags scanner + * @name SetScannerAsDefault + * @summary Set system default scanner registration + * @request PATCH:/scanners/{registration_id} + * @secure + */ + setScannerAsDefault: ( + registrationId: string, + payload: IsDefault, + params: RequestParams = {}, + ) => + this.request({ + path: `/scanners/${registrationId}`, + method: "PATCH", + body: payload, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Get the metadata of the specified scanner registration, including the capabilities and customized properties. + * + * @tags scanner + * @name GetScannerMetadata + * @summary Get the metadata of the specified scanner registration + * @request GET:/scanners/{registration_id}/metadata + * @secure + */ + getScannerMetadata: (registrationId: string, params: RequestParams = {}) => + this.request({ + path: `/scanners/${registrationId}/metadata`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; + users = { + /** + * No description + * + * @tags user + * @name ListUsers + * @summary List users + * @request GET:/users + * @secure + */ + listUsers: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This API can be used only when the authentication mode is for local DB. When self registration is disabled. + * + * @tags user + * @name CreateUser + * @summary Create a local user. + * @request POST:/users + * @secure + */ + createUser: (userReq: UserCreationReq, params: RequestParams = {}) => + this.request({ + path: `/users`, + method: "POST", + body: userReq, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetCurrentUserInfo + * @summary Get current user info. + * @request GET:/users/current + * @secure + */ + getCurrentUserInfo: (params: RequestParams = {}) => + this.request({ + path: `/users/current`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint is to search the users by username. It's open for all authenticated requests. + * + * @tags user + * @name SearchUsers + * @summary Search users by username + * @request GET:/users/search + * @secure + */ + searchUsers: ( + query: { + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** Username for filtering results. */ + username: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetUser + * @summary Get a user's profile. + * @request GET:/users/{user_id} + * @secure + */ + getUser: (userId: number, params: RequestParams = {}) => + this.request({ + path: `/users/${userId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * No description + * + * @tags user + * @name UpdateUserProfile + * @summary Update user's profile. + * @request PUT:/users/{user_id} + * @secure + */ + updateUserProfile: ( + userId: number, + profile: UserProfile, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}`, + method: "PUT", + body: profile, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let administrator of Harbor mark a registered user as removed.It actually won't be deleted from DB. + * + * @tags user + * @name DeleteUser + * @summary Mark a registered user as be removed. + * @request DELETE:/users/{user_id} + * @secure + */ + deleteUser: (userId: number, params: RequestParams = {}) => + this.request({ + path: `/users/${userId}`, + method: "DELETE", + secure: true, + ...params, + }), + + /** + * No description + * + * @tags user + * @name SetUserSysAdmin + * @summary Update a registered user to change to be an administrator of Harbor. + * @request PUT:/users/{user_id}/sysadmin + * @secure + */ + setUserSysAdmin: ( + userId: number, + sysadmin_flag: UserSysAdminFlag, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/sysadmin`, + method: "PUT", + body: sysadmin_flag, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint is for user to update password. Users with the admin role can change any user's password. Regular users can change only their own password. + * + * @tags user + * @name UpdateUserPassword + * @summary Change the password on a user that already exists. + * @request PUT:/users/{user_id}/password + * @secure + */ + updateUserPassword: ( + userId: number, + password: PasswordReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/password`, + method: "PUT", + body: password, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * No description + * + * @tags user + * @name GetCurrentUserPermissions + * @summary Get current user permissions. + * @request GET:/users/current/permissions + * @secure + */ + getCurrentUserPermissions: ( + query?: { + /** The scope for the permission */ + scope?: string; + /** + * If true, the resources in the response are relative to the scope, + * eg for resource '/project/1/repository' if relative is 'true' then the resource in response will be 'repository'. + */ + relative?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/current/permissions`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user generate a new CLI secret for himself. This API only works when auth mode is set to 'OIDC'. Once this API returns with successful status, the old secret will be invalid, as there will be only one CLI secret for a user. + * + * @tags user + * @name SetCliSecret + * @summary Set CLI secret for a user. + * @request PUT:/users/{user_id}/cli_secret + * @secure + */ + setCliSecret: ( + userId: number, + secret: OIDCCliSecretReq, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/${userId}/cli_secret`, + method: "PUT", + body: secret, + secure: true, + type: ContentType.Json, + ...params, + }), + }; + labels = { + /** + * @description This endpoint let user list labels by name, scope and project_id + * + * @tags label + * @name ListLabels + * @summary List labels according to the query strings. + * @request GET:/labels + * @secure + */ + listLabels: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** Sort the resource list in ascending or descending order. e.g. sort by field1 in ascending order and field2 in descending order with "sort=field1,-field2" */ + sort?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** The label name. */ + name?: string; + /** The label scope. Valid values are g and p. g for global labels and p for project labels. */ + scope?: string; + /** + * Relevant project ID, required when scope is p. + * @format int64 + */ + project_id?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/labels`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user creates a label. + * + * @tags label + * @name CreateLabel + * @summary Post creates a label + * @request POST:/labels + * @secure + */ + createLabel: (label: Label, params: RequestParams = {}) => + this.request({ + path: `/labels`, + method: "POST", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description This endpoint let user get the label by specific ID. + * + * @tags label + * @name GetLabelById + * @summary Get the label specified by ID. + * @request GET:/labels/{label_id} + * @secure + */ + getLabelById: (labelId: number, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description This endpoint let user update label properties. + * + * @tags label + * @name UpdateLabel + * @summary Update the label properties. + * @request PUT:/labels/{label_id} + * @secure + */ + updateLabel: (labelId: number, label: Label, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "PUT", + body: label, + secure: true, + type: ContentType.Json, + ...params, + }), + + /** + * @description Delete the label specified by ID. + * + * @tags label + * @name DeleteLabel + * @summary Delete the label specified by ID. + * @request DELETE:/labels/{label_id} + * @secure + */ + deleteLabel: (labelId: number, params: RequestParams = {}) => + this.request({ + path: `/labels/${labelId}`, + method: "DELETE", + secure: true, + ...params, + }), + }; + export = { + /** + * @description Export scan data for selected projects + * + * @tags scan data export + * @name ExportScanData + * @summary Export scan data for selected projects + * @request POST:/export/cve + * @secure + */ + exportScanData: ( + criteria: ScanDataExportRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve`, + method: "POST", + body: criteria, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description Get the scan data export execution specified by ID + * + * @tags scan data export + * @name GetScanDataExportExecution + * @summary Get the specific scan data export execution + * @request GET:/export/cve/execution/{execution_id} + * @secure + */ + getScanDataExportExecution: ( + executionId: number, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve/execution/${executionId}`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get a list of specific scan data export execution jobs for a specified user + * + * @tags scan data export + * @name GetScanDataExportExecutionList + * @summary Get a list of specific scan data export execution jobs for a specified user + * @request GET:/export/cve/executions + * @secure + */ + getScanDataExportExecutionList: (params: RequestParams = {}) => + this.request({ + path: `/export/cve/executions`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + + /** + * @description Download the scan data report. Default format is CSV + * + * @tags scan data export + * @name DownloadScanData + * @summary Download the scan data export file + * @request GET:/export/cve/download/{execution_id} + * @secure + */ + downloadScanData: ( + executionId: number, + query?: { + /** The format of the data to be exported. e.g. CSV or PDF */ + format?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/export/cve/download/${executionId}`, + method: "GET", + query: query, + secure: true, + ...params, + }), + }; + security = { + /** + * @description Retrieve the vulnerability summary of the system + * + * @tags securityhub + * @name GetSecuritySummary + * @summary Get vulnerability system summary + * @request GET:/security/summary + * @secure + */ + getSecuritySummary: ( + query?: { + /** + * Specify whether the dangerous CVEs are included inside summary information + * @default false + */ + with_dangerous_cve?: boolean; + /** + * Specify whether the dangerous Artifact are included inside summary information + * @default false + */ + with_dangerous_artifact?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/security/summary`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + + /** + * @description Get the vulnerability list. use q to pass the query condition, supported conditions: cve_id(exact match) cvss_score_v3(range condition) severity(exact match) repository_name(exact match) project_id(exact match) package(exact match) tag(exact match) digest(exact match) + * + * @tags securityhub + * @name ListVulnerabilities + * @summary Get the vulnerability list. + * @request GET:/security/vul + * @secure + */ + listVulnerabilities: ( + query?: { + /** Query string to query resources. Supported query patterns are "exact match(k=v)", "fuzzy match(k=~v)", "range(k=[min~max])", "list with union releationship(k={v1 v2 v3})" and "list with intersetion relationship(k=(v1 v2 v3))". The value of range and list can be string(enclosed by " or '), integer or time(in format "2020-04-09 02:36:00"). All of these query patterns should be put in the query string "q=xxx" and splitted by ",". e.g. q=k1=v1,k2=~v2,k3=[min~max] */ + q?: string; + /** + * The page number + * @format int64 + * @default 1 + */ + page?: number; + /** + * The size of per page + * @format int64 + * @max 100 + * @default 10 + */ + page_size?: number; + /** + * Enable to ignore X-Total-Count when the total count > 1000, if the total count is less than 1000, the real total count is returned, else -1. + * @default false + */ + tune_count?: boolean; + /** + * Specify whether the tag information is included inside vulnerability information + * @default false + */ + with_tag?: boolean; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/security/vul`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + permissions = { + /** + * @description This endpoint is for retrieving resource and action info that only provides for admin user(system admin and project admin). + * + * @tags permissions + * @name GetPermissions + * @summary Get system or project level permissions info. + * @request GET:/permissions + * @secure + */ + getPermissions: (params: RequestParams = {}) => + this.request({ + path: `/permissions`, + method: "GET", + secure: true, + format: "json", + ...params, + }), + }; +} diff --git a/.jjconflict-side-2/plugins/harbor/src/env.d.ts b/.jjconflict-side-2/plugins/harbor/src/env.d.ts new file mode 100644 index 0000000000..44377abce9 --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-side-2/plugins/harbor/src/functions.ts b/.jjconflict-side-2/plugins/harbor/src/functions.ts new file mode 100644 index 0000000000..ea902dae27 --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/src/functions.ts @@ -0,0 +1,159 @@ +import type { PluginResult, Project, ProjectLite, StepCall } from '@cpn-console/hooks' +import type { VaultRobotSecret } from './robot.js' +import { + + specificallyDisabled, + specificallyEnabled, +} from '@cpn-console/hooks' +import { DEFAULT, ENABLED } from '@cpn-console/shared' +// @ts-ignore pas de typage disponible pour le paquet bytes +import bytes from 'bytes' +import { logger } from './logger.js' +import { addProjectGroupMember } from './permission.js' +import { addRetentionPolicy } from './policy.js' +import { createProject, deleteProject } from './project.js' +import { deleteRobot, ensureRobot, roAccess, rwAccess } from './robot.js' +import { + getApi, + getConfig, + projectRobotName, + roRobotName, + rwRobotName, +} from './utils.js' + +export const createDsoProject: StepCall = async (payload) => { + const returnResult: PluginResult = { + status: { + result: 'OK', + }, + } + const warnReasons: string[] = [] + try { + const project = payload.args + const projectName = project.slug + const { vault: vaultApi, keycloak: keycloakApi } = payload.apis + + const publishRoRobotProject = project.store.registry?.publishProjectRobot + const publishRoRobotConfig = payload.config.registry?.publishProjectRobot + const createProjectRobot + = specificallyEnabled(publishRoRobotProject) + || (specificallyEnabled(publishRoRobotConfig) + && !specificallyDisabled(publishRoRobotProject)) + + const quotaHardLimit + = project.store.registry?.quotaHardLimit + || payload.config.registry?.quotaHardLimit + const quotaHardLimitBytes = quotaHardLimit + ? bytes.parse(quotaHardLimit) + : undefined + + const [projectCreated, oidcGroup] = await Promise.all([ + createProject( + projectName, + quotaHardLimitBytes === 1 ? undefined : quotaHardLimitBytes, + ), + keycloakApi.getProjectGroupPath(), + ]) + const api = getApi() + + if (!projectCreated.project_id) + throw new Error('Unable to retrieve project_id') + const projectId = projectCreated.project_id + + await Promise.all([ + ensureRobot(projectName, projectId, roRobotName, vaultApi, roAccess, api), // cette ligne en premier sinon ça foire au dessus + ensureRobot(projectName, projectId, rwRobotName, vaultApi, rwAccess, api), + addProjectGroupMember(projectName, oidcGroup), + addRetentionPolicy(projectName, projectId), + createProjectRobot + ? ensureRobot(projectName, projectId, projectRobotName, vaultApi, roAccess, api) + : deleteRobot(projectName, projectId, projectRobotName, vaultApi, api), + ]) + returnResult.status.message = `Created${createProjectRobot ? ' , with project robot' : ''}` + returnResult.store = { + projectId: projectCreated.project_id, + } + if (warnReasons.length) { + returnResult.status.result = 'WARNING' + returnResult.status.message = warnReasons.join(', ') + } + logger.info({ action: 'createDsoProject', projectSlug: projectName, result: returnResult.status.result, createProjectRobot }, 'Hook done') + return returnResult + } catch (error) { + logger.error({ action: 'createDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const deleteDsoProject: StepCall = async (payload) => { + try { + const project = payload.args + const projectName = project.slug + + await deleteProject(projectName) + + logger.info({ action: 'deleteDsoProject', projectSlug: projectName, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } catch (error) { + logger.error({ action: 'deleteDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const getProjectSecrets: StepCall = async ({ + args: project, + apis: { vault: vaultApi }, + config, +}) => { + logger.debug({ action: 'getProjectSecrets', projectSlug: project.slug }, 'Hook done') + const publishRoRobotProject = project.store.registry?.publishProjectRobot + const publishRoRobotConfig = config.registry?.publishProjectRobot + const projectRobotEnabled + = publishRoRobotProject === ENABLED + || (publishRoRobotConfig === ENABLED + && (!publishRoRobotProject || publishRoRobotProject === DEFAULT)) + + const VaultRobotSecret = projectRobotEnabled + ? ((await vaultApi.read(`REGISTRY/${projectRobotName}`, { + throwIfNoEntry: false, + })) as { data: VaultRobotSecret } | undefined) + : undefined + let secrets: { [x: string]: string } = { + 'Registry base path': `${getConfig().host}/${project.slug}/`, + } + + if (projectRobotEnabled) { + secrets = VaultRobotSecret?.data + ? { + ...secrets, + ...VaultRobotSecret.data, + } + : { + ...secrets, + '/!\\': 'Vous n\'avez pas de robot de lecture veuillez reprovisionner', + } + } + return { + status: { + result: 'OK', + }, + secrets, + } +} diff --git a/.jjconflict-side-2/plugins/harbor/src/index.ts b/.jjconflict-side-2/plugins/harbor/src/index.ts new file mode 100644 index 0000000000..c005ffd840 --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/src/index.ts @@ -0,0 +1,19 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import { createDsoProject, deleteDsoProject, getProjectSecrets } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { steps: { post: createDsoProject } }, + deleteProject: { steps: { main: deleteDsoProject } }, + getProjectSecrets: { steps: { main: getProjectSecrets } }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-side-2/plugins/harbor/src/infos.ts b/.jjconflict-side-2/plugins/harbor/src/infos.ts new file mode 100644 index 0000000000..97c3de42eb --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/src/infos.ts @@ -0,0 +1,71 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { DISABLED } from '@cpn-console/shared' +import { getConfig } from './utils.js' + +const quotaDescription = '-1 -> illimité, sinon 100MB / 1.2GB (unités : B, KB, MB, GB, TB)), max 1024TB' +const infos = { + name: 'registry', + to: ({ store }) => store?.registry?.projectId ? `${getConfig().url}/harbor/projects/${store.registry.projectId}` : `${getConfig().url}/`, + title: 'Harbor', + imgSrc: '/img/harbor.svg', + description: 'Harbor stocke, analyse et distribue vos images de conteneurs', + config: { + project: [{ + permissions: { + admin: { read: false, write: false }, + user: { read: false, write: false }, + }, + key: 'projectId', + kind: 'text', + title: 'Num du projet Harbor', + value: '', + }, { + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot projet', + description: 'Activer le robot de projet (read-only) et afficher ses identifiants aux utilisateurs', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota', + value: '', + description: `Stockage limite (vide utilisation du paramètre global, ${quotaDescription}`, + placeholder: '', + }], + global: [{ + kind: 'switch', + key: 'publishProjectRobot', + initialValue: DISABLED, + title: 'Publication du robot RO aux projets', + description: 'Définit le comportement en l\'absence de ce paramétrage au niveau projet', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + value: DISABLED, + }, { + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + key: 'quotaHardLimit', + title: 'Quota par défaut', + value: '-1', + description: `Stockage limite par projet (${quotaDescription}`, + placeholder: '-1', + }], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-side-2/plugins/harbor/src/kubeSecret.ts b/.jjconflict-side-2/plugins/harbor/src/kubeSecret.ts new file mode 100644 index 0000000000..acf1ec297b --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/src/kubeSecret.ts @@ -0,0 +1,14 @@ +// Utils +export function getSecretObject({ DOCKER_CONFIG }: { DOCKER_CONFIG: string }) { + const b64dockerConfig = Buffer.from(DOCKER_CONFIG).toString('base64') + return { + kind: 'Secret', + data: { + '.dockerconfigjson': b64dockerConfig, + }, + type: 'kubernetes.io/dockerconfigjson', + metadata: { + name: 'registry-pull-secret', + }, + } +} diff --git a/.jjconflict-side-2/plugins/harbor/src/logger.ts b/.jjconflict-side-2/plugins/harbor/src/logger.ts new file mode 100644 index 0000000000..82f73cecb6 --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'harbor' }) diff --git a/.jjconflict-side-2/plugins/harbor/src/monitor.ts b/.jjconflict-side-2/plugins/harbor/src/monitor.ts new file mode 100644 index 0000000000..eaa73c6ea1 --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/src/monitor.ts @@ -0,0 +1,52 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import { getApi } from './utils.js' + +enum HealthStatus { + healthy = 'healthy', + unhealthy = 'unhealthy', +} +const coreComponents = ['core', 'database', 'portal', 'registry', 'registryctl'] + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await getApi().health.getHealth({ + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + const data = res.data + if (data.status === HealthStatus.healthy) { + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + const failedCoreComponents = data.components + ? data.components + .filter(component => + component.status === HealthStatus.unhealthy + && component.name + && coreComponents.includes(component.name), + ) + : [] + + if (failedCoreComponents.length > 0) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.WARNING + instance.lastStatus.message = 'Service dégradé' + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Fatal Error' + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-2/plugins/harbor/src/permission.ts b/.jjconflict-side-2/plugins/harbor/src/permission.ts new file mode 100644 index 0000000000..05e30d4d6c --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/src/permission.ts @@ -0,0 +1,26 @@ +import { getApi } from './utils.js' + +export async function addProjectGroupMember(projectName: string, groupName: string, accessLevel: number = 3): Promise { + const api = getApi() + const members = await api.projects.listProjectMembers(projectName) + const member = members.data.find(m => m.entity_name === groupName) + if (member?.id) { // member.id is always set pb comes from bad swagger + if (member.role_id !== accessLevel && member.entity_type !== 'g') { + // le membre semble être incorrectement paramétré, suppression pour recréation + await api.projects.deleteProjectMember(projectName, member.id) + } else { + // tout va bien + return + } + } + // Création du membre + await api.projects.createProjectMember(projectName, { + + role_id: accessLevel, + member_group: { + + group_name: groupName, + group_type: 3, + }, + }) +} diff --git a/.jjconflict-side-2/plugins/harbor/src/policy.ts b/.jjconflict-side-2/plugins/harbor/src/policy.ts new file mode 100644 index 0000000000..4945ad51cc --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/src/policy.ts @@ -0,0 +1,135 @@ +import { isValidCron } from 'cron-validator' +import { logger } from './logger.js' +import { getApi } from './utils.js' + +// https://github.com/goharbor/harbor/blob/main/src/server/v2.0/handler/retention.go +export type RuleTemplate + = | 'always' + | 'latestPulledK' + | 'latestPushedK' + | 'nDaysSinceLastPull' + | 'nDaysSinceLastPush' + +const allowed: RuleTemplate[] = [ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +] + +export const harborRuleTemplate: RuleTemplate = allowed.includes(process.env.HARBOR_RULE_TEMPLATE as RuleTemplate) + ? (process.env.HARBOR_RULE_TEMPLATE as RuleTemplate) + : 'latestPushedK' + +const countEnv = Number(process.env.HARBOR_RULE_COUNT) +export const harborRuleCount = !Number.isNaN(countEnv) && countEnv > 0 + ? countEnv + : harborRuleTemplate === 'always' + ? 1 + : 10 + +const defaultCron = '0 22 2 * * *' +const envCron = process.env.HARBOR_RETENTION_CRON?.trim() + +export const harborRetentionCron = envCron && isValidCron(envCron, { seconds: true }) + ? envCron + : defaultCron + +export interface TagSelector { + kind: 'doublestar' | 'label' + decoration: 'matches' | 'excludes' | 'repoMatches' | 'repoExcludes' + pattern: string +} + +export interface Rule { + disabled: boolean + action: 'retain' | 'delete' + template: RuleTemplate + params: Record + tag_selectors: TagSelector[] + scope_selectors: Record +} + +export interface Trigger { + kind: 'Schedule' + settings: { cron: string } + references: unknown[] +} + +export interface Policy { + algorithm: 'or' | 'and' + rules: Rule[] + trigger: Trigger + scope: { level: 'project', ref: number } +} + +export function makeDefaultPolicy(projectId: number): Policy { + return { + algorithm: 'or', + scope: { level: 'project', ref: projectId }, + rules: [ + { + disabled: false, + action: 'retain', + template: harborRuleTemplate, + params: { [harborRuleTemplate]: harborRuleCount }, + tag_selectors: [ + { kind: 'doublestar', decoration: 'matches', pattern: '**' }, + ], + scope_selectors: { + repository: [ + { kind: 'doublestar', decoration: 'repoMatches', pattern: '**' }, + ], + }, + }, + ], + trigger: { + kind: 'Schedule', + settings: { cron: harborRetentionCron }, + references: [], + }, + } +} + +export async function addRetentionPolicy( + projectName: string, + harborProjectId: number, +): Promise { + const api = getApi() + const ref = Number(harborProjectId) + if (Number.isNaN(ref)) throw new Error(`Invalid projectId: ${harborProjectId}`) + + const policy: Policy = makeDefaultPolicy(ref) + const project = await api.projects.getProject(projectName) + const retentionId = Number(project?.data?.metadata?.retention_id) + + try { + if (retentionId && !Number.isNaN(retentionId)) { + await api.retentions.updateRetention(retentionId, policy as unknown as any) + } else { + await api.retentions.createRetention(policy as unknown as any) + } + } catch (err: any) { + const payload = JSON.stringify(policy, null, 2) + const details = err?.response?.data ?? 'Unknown error' + logger.error( + { + action: 'addRetentionPolicy', + projectSlug: projectName, + projectName, + projectId: project?.data?.project_id, + retentionId, + payload, + details, + err, + }, + 'Failed to apply Harbor retention policy', + ) + throw new Error( + `Retention policy failed for project "${projectName}": ${ + typeof details === 'string' ? details : JSON.stringify(details) + }`, + ) + } +} diff --git a/.jjconflict-side-2/plugins/harbor/src/project.ts b/.jjconflict-side-2/plugins/harbor/src/project.ts new file mode 100644 index 0000000000..6b8a7a0e6e --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/src/project.ts @@ -0,0 +1,70 @@ +import type { Project as HarborProject, Quota } from './api/Api.js' +import { logger } from './logger.js' +import { getApi } from './utils.js' + +export async function createProject(projectName: string, storageLimit: number = -1): Promise { + logger.debug({ action: 'createProject', projectSlug: projectName, storageLimit }, 'Creating Harbor project if missing') + const api = getApi() + const existingProject = await api.projects.getProject(projectName, { + headers: { + 'X-Is-Resource-Name': true, + }, + validateStatus: () => true, + }) + + if (existingProject.status === 200) { + const projectId = existingProject.data.project_id as number + const refQuotas = await api.quotas.listQuotas({ + reference_id: String(projectId), + }) + const hardQuota = refQuotas.data.find(quota => quota.ref?.id === projectId) as Quota + + if (hardQuota.hard.storage !== storageLimit) { + await api.quotas.updateQuota(projectId, { + hard: { + storage: storageLimit, + }, + }) + } + return existingProject.data + } + + await api.projects.createProject({ + project_name: projectName, + metadata: { + auto_scan: 'true', + }, + storage_limit: storageLimit, + }, { + validateStatus: () => true, + }) + + const newProject = await api.projects.getProject(projectName) + return newProject.data +} + +async function removeRepositories(projectName: string) { + const api = getApi() + const repositories = await api.projects.listRepositories(projectName, { page_size: 100 }) + + for (const repo of repositories.data) { + // TODO mauvais swagger name existe forcément + const repoName = repo.name?.split('/').slice(1).join('/') as string + await api.projects.deleteRepository(projectName, repoName) + } + if (repositories.headers['x-total-count'] > repositories.data.length) { + await removeRepositories(projectName) + } +} + +export async function deleteProject(projectName: string) { + const api = getApi() + const project = await api.projects.getProject(projectName, { + validateStatus: status => [200, 404].includes(status), + }) + + if (project.status === 200) { + await removeRepositories(projectName) + await api.projects.deleteProject(projectName) + } +} diff --git a/.jjconflict-side-2/plugins/harbor/src/robot.ts b/.jjconflict-side-2/plugins/harbor/src/robot.ts new file mode 100644 index 0000000000..90c2ee23eb --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/src/robot.ts @@ -0,0 +1,133 @@ +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { Access, Robot, RobotCreated } from './api/Api.js' +import type { HarborApi } from './utils.js' +import { logger } from './logger.js' +import { getConfig, toVaultSecret } from './utils.js' + +// A project only ever owns a handful of robots (ro, rw and optionally the +// project robot — 3 in practice), so fetching a single page is enough; we never +// need to paginate the robot listing. +const ROBOT_LIST_PAGE_SIZE = 100 + +export interface VaultRobotSecret { + // {"auths":{"registry-host.com":{"auth":"","email":""}}}, + DOCKER_CONFIG: string + // registry-host.com, + HOST: string + TOKEN: string + // robot$+ + USERNAME: string +} + +export const getRobot = async (projectName: string, projectId: number, robotName: string, api: HarborApi): Promise => getRobotByName(projectId, `robot$${projectName}+${robotName}`, api) + +export async function ensureRobot(projectName: string, projectId: number, robotName: string, vaultApi: VaultProjectApi, access: Access[], api: HarborApi): Promise { + logger.debug({ action: 'ensureRobot', projectSlug: projectName, robotName }, 'Ensuring Harbor robot') + const vaultPath = `REGISTRY/${robotName}` + const robot = await getRobot(projectName, projectId, robotName, api) + const vaultRobotSecret = await vaultApi.read(vaultPath, { throwIfNoEntry: false }) as { data: VaultRobotSecret } | undefined + + let creds: VaultRobotSecret + // only regenerate robot if there is a change with the host + if (vaultRobotSecret?.data && vaultRobotSecret.data.HOST === getConfig().host) { + creds = vaultRobotSecret.data + } else if (robot) { + creds = toVaultSecret(await regenerateRobot(projectName, projectId, robotName, access, api) as Required) + await vaultApi.write(creds, vaultPath) + } else { + creds = toVaultSecret(await createRobot(projectName, robotName, access, api) as Required) + await vaultApi.write(creds, vaultPath) + } + return creds +} + +export async function deleteRobot(projectName: string, projectId: number, robotName: string, vaultApi: VaultProjectApi, api: HarborApi): Promise { + logger.info({ action: 'deleteRobot', projectSlug: projectName, robotName }, 'Deleting Harbor robot') + const vaultPath = `REGISTRY/${robotName}` + const robot = await getRobot(projectName, projectId, robotName, api) + if (robot?.id) { + api.robots.deleteRobot(robot.id) + } + const vaultRobotSecret = await vaultApi.read(vaultPath, { throwIfNoEntry: false }) as { data: VaultRobotSecret } | undefined + + if (vaultRobotSecret) { + await vaultApi.destroy(vaultPath) + } +} + +export async function createRobot(projectName: string, robotName: string, access: Access[], api: HarborApi) { + logger.info({ action: 'createRobot', projectSlug: projectName, robotName }, 'Creating Harbor robot') + return (await api.robots.createRobot(getRobotPermissions(projectName, robotName, access))).data +} + +export async function regenerateRobot(projectName: string, projectId: number, robotName: string, access: Access[], api: HarborApi): Promise { + logger.info({ action: 'regenerateRobot', projectSlug: projectName, robotName }, 'Regenerating Harbor robot') + const robot = await getRobot(projectName, projectId, robotName, api) + if (robot?.id) + await api.robots.deleteRobot(robot.id) + return createRobot(projectName, robotName, access, api) +} + +export async function getRobotByName(projectId: number, robotName: string, api: HarborApi): Promise { + logger.debug({ action: 'getRobotByName', projectId, robotName }, 'Listing Harbor robots to find matching name') + const listRobots = await api.robots.listRobot({ + q: `Level=project,ProjectID=${projectId}`, + page_size: ROBOT_LIST_PAGE_SIZE, + }) + return listRobots.data.find(({ name }) => name === robotName) +} + +function getRobotPermissions(projectName: string, robotName: string, access: Access[]) { + return { + name: robotName, + duration: -1, + description: 'robot for ci builds', + disable: false, + level: 'project', + permissions: [{ + namespace: projectName, + kind: 'project', + access, + }], + } +} + +export const roAccess: Access[] = [{ + resource: 'repository', + action: 'pull', +}, { + resource: 'artifact', + action: 'read', +}] + +export const rwAccess: Access[] = [...roAccess, { + resource: 'repository', + action: 'list', +}, { + resource: 'tag', + action: 'list', +}, { + resource: 'artifact', + action: 'list', +}, { + resource: 'scan', + action: 'create', +}, { + resource: 'scan', + action: 'stop', +}, { + resource: 'repository', + action: 'push', +}, { + resource: 'artifact-label', + action: 'create', +}, { + resource: 'artifact-label', + action: 'delete', +}, { + resource: 'tag', + action: 'create', +}, { + resource: 'tag', + action: 'delete', +}] diff --git a/.jjconflict-side-2/plugins/harbor/src/utils.ts b/.jjconflict-side-2/plugins/harbor/src/utils.ts new file mode 100644 index 0000000000..abb2b471ba --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/src/utils.ts @@ -0,0 +1,61 @@ +import type { RobotCreated } from './api/Api.js' +import type { VaultRobotSecret } from './robot.js' +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import { Api } from './api/Api.js' + +const config: { + url?: string + internalUrl?: string + host?: string +} = {} + +export function getConfig(): Required { + config.url = config.url ?? removeTrailingSlash(requiredEnv('HARBOR_URL')) + config.internalUrl = config.internalUrl ?? removeTrailingSlash(requiredEnv('HARBOR_INTERNAL_URL')) + config.host = config.host ?? config?.url?.split('://')[1] + // @ts-ignore + return config +} + +function getApiConfig() { + return { + auth: { + username: requiredEnv('HARBOR_ADMIN'), + password: requiredEnv('HARBOR_ADMIN_PASSWORD'), + }, + baseURL: `${getConfig().internalUrl}/api/v2.0/`, + } +} + +let api: Api> | undefined + +export type HarborApi = Api> +export function getApi(): HarborApi { + if (!api) { + api = new Api(getApiConfig()) + } + return api +} + +export function toVaultSecret(robot: Required): VaultRobotSecret { + const auth = `${robot.name}:${robot.secret}` + const buff = Buffer.from(auth) + const b64auth = buff.toString('base64') + return { + DOCKER_CONFIG: JSON.stringify({ + auths: { + [getConfig().host]: { + auth: b64auth, + email: '', + }, + }, + }), + HOST: getConfig().host, + TOKEN: robot.secret, + USERNAME: robot.name, + } +} + +export const rwRobotName = 'rw-robot' +export const roRobotName = 'ro-robot' +export const projectRobotName = 'project-robot' diff --git a/.jjconflict-side-2/plugins/harbor/tsconfig.eslint.json b/.jjconflict-side-2/plugins/harbor/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-2/plugins/harbor/tsconfig.json b/.jjconflict-side-2/plugins/harbor/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-2/plugins/harbor/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-2/plugins/keycloak/.gitignore b/.jjconflict-side-2/plugins/keycloak/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-2/plugins/keycloak/eslint.config.js b/.jjconflict-side-2/plugins/keycloak/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-2/plugins/keycloak/package.json b/.jjconflict-side-2/plugins/keycloak/package.json new file mode 100644 index 0000000000..d0cb9e3ece --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/package.json @@ -0,0 +1,49 @@ +{ + "name": "@cpn-console/keycloak-plugin", + "type": "module", + "version": "2.1.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@keycloak/keycloak-admin-client": "catalog:runtime", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/plugins/keycloak/src/class.ts b/.jjconflict-side-2/plugins/keycloak/src/class.ts new file mode 100644 index 0000000000..36f3dc045e --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/src/class.ts @@ -0,0 +1,33 @@ +import { PluginApi } from '@cpn-console/hooks' +import { consoleGroupName } from './group.js' + +interface KeycloakEnv { + path: string + subgroups: { // Key is the group name, value is the full path + RO: string + RW: string + } +} + +export class KeycloakProjectApi extends PluginApi { + private readonly projectSlug: string + + constructor(projectSlug: string) { + super() + this.projectSlug = projectSlug + } + + public async getProjectGroupPath(): Promise { + return `/${this.projectSlug}` + } + + public async getEnvGroup(environment: string): Promise { + return { + path: `/${this.projectSlug}/${consoleGroupName}/${environment}`, + subgroups: { + RO: `/${this.projectSlug}/${consoleGroupName}/${environment}/RO`, + RW: `/${this.projectSlug}/${consoleGroupName}/${environment}/RW`, + }, + } + } +} diff --git a/.jjconflict-side-2/plugins/keycloak/src/client.ts b/.jjconflict-side-2/plugins/keycloak/src/client.ts new file mode 100644 index 0000000000..484dc8789a --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/src/client.ts @@ -0,0 +1,28 @@ +import KcAdminClient from '@keycloak/keycloak-admin-client' +import getConfig from './config.js' +import { logger } from './logger.js' + +export async function getkcClient() { + const kcClient = new KcAdminClient({ + baseUrl: getConfig().url, + }) + + await kcClient.auth({ + clientId: 'admin-cli', + grantType: 'password', + username: process.env.KEYCLOAK_ADMIN, + password: process.env.KEYCLOAK_ADMIN_PASSWORD, + }) + kcClient.setConfig({ realmName: getConfig().realm }) + return kcClient +} + +export function start() { + getkcClient().catch((error) => { + logger.error({ err: error }, 'Failed to start Keycloak plugin') + if (process.env.IGNORE_PLUGINS_START_FAIL?.includes('keycloak')) { + return + } + throw new Error('failed to start keycloak plugin') + }) +} diff --git a/.jjconflict-side-2/plugins/keycloak/src/config.ts b/.jjconflict-side-2/plugins/keycloak/src/config.ts new file mode 100644 index 0000000000..d249e99b5b --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/src/config.ts @@ -0,0 +1,23 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + url: string + realm: string + + constructor() { + this.url = process.env.KEYCLOAK_INTERNAL_URL + ? removeTrailingSlash(process.env.KEYCLOAK_INTERNAL_URL) + : `${requiredEnv('KEYCLOAK_PROTOCOL')}://${requiredEnv('KEYCLOAK_DOMAIN')}` + this.realm = requiredEnv('KEYCLOAK_REALM') + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-side-2/plugins/keycloak/src/env.d.ts b/.jjconflict-side-2/plugins/keycloak/src/env.d.ts new file mode 100644 index 0000000000..81cd034697 --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/.jjconflict-side-2/plugins/keycloak/src/functions.ts b/.jjconflict-side-2/plugins/keycloak/src/functions.ts new file mode 100644 index 0000000000..9bf08d971d --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/src/functions.ts @@ -0,0 +1,478 @@ +import type { AdminRole, Project, ProjectMember, StepCall, UserEmail, ZoneObject } from '@cpn-console/hooks' +import type { ProjectRole } from '@cpn-console/shared' +import type ClientRepresentation from '@keycloak/keycloak-admin-client/lib/defs/clientRepresentation.js' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js' +import type { CustomGroup } from './group.js' +import { generateRandomPassword, PluginResultBuilder } from '@cpn-console/hooks' +import { isExternalRoleType } from '@cpn-console/shared' +import { getkcClient } from './client.js' +import { consoleGroupName, deleteGroup, getAllSubgroups, getGroupByName, getOrCreateChildGroup, getOrCreateGroupByPath, getOrCreateProjectGroup } from './group.js' +import { logger } from './logger.js' + +export const retrieveKeycloakUserByEmail: StepCall = async ({ args: { email } }) => { + const kcClient = await getkcClient() + try { + const user = (await kcClient.users.find({ email }))[0] + logger.debug({ action: 'retrieveKeycloakUserByEmail', found: Boolean(user) }, 'Hook done') + + return { + status: { result: 'OK' }, + user, + } + } catch (error) { + logger.error({ action: 'retrieveKeycloakUserByEmail', err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const deleteProject: StepCall = async ({ args: project }) => { + const projectSlug = project.slug + try { + const kcClient = await getkcClient() + const group = await getGroupByName(kcClient, projectSlug) + if (group?.id) { + await kcClient.groups.del({ id: group.id }) + logger.info({ action: 'deleteProject', projectSlug, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + logger.info({ action: 'deleteProject', projectSlug, outcome: 'already-missing' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already Missing', + }, + } + } catch (error) { + logger.error({ action: 'deleteProject', projectSlug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const upsertProject: StepCall = async ({ args: project }) => { + const projectSlug = project.slug + const pluginResult = new PluginResultBuilder('Up-to-date') + try { + const kcClient = await getkcClient() + const projectGroup = await getOrCreateProjectGroup(kcClient, projectSlug) + + const groupMembers = await kcClient.groups.listMembers({ id: projectGroup.id }) + + await Promise.all([ + ...groupMembers.map((member, index) => { + if (!project.users.some(({ id }) => id === member.id)) { + return kcClient.users.delFromGroup({ + // @ts-ignore id is present on user, bad typing in lib + id: member.id, + groupId: projectGroup.id, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak project group') + pluginResult.addExtra(`remove-${index}`, err) + }) + } + return undefined + }), + ...project.users.map((user, index) => { + if (!groupMembers.some(({ id }) => id === user.id)) { + return kcClient.users.addToGroup({ + id: user.id, + groupId: projectGroup.id, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t add user to keycloak project group') + pluginResult.addExtra(`add-${index}`, err) + }) + } + return undefined + }), + ]) + + // Ensure envs subgroups exists + const projectGroups = await getAllSubgroups(kcClient, projectGroup.id, 0) + + const consoleGroup: Required = projectGroups.find(({ name }) => name === consoleGroupName) as Required + ?? await getOrCreateChildGroup(kcClient, projectGroup.id, consoleGroupName) as Required + + const envGroups = await getAllSubgroups(kcClient, consoleGroup.id, 0) as CustomGroup[] + + const promises: Promise[] = [] + for (const environment of project.environments) { + const envGroup: Required = envGroups.find(group => group.name === environment.name) as Required + ?? await getOrCreateChildGroup(kcClient, consoleGroup.id, environment.name) + + const [roGroup, rwGroup] = await Promise.all([ + getOrCreateChildGroup(kcClient, envGroup.id, 'RO'), + getOrCreateChildGroup(kcClient, envGroup.id, 'RW'), + ]) + + // Ensure envs permissions membership exists + for (const permission of environment.permissions) { + if (permission.permissions.ro) { + promises.push(kcClient.users.addToGroup({ id: permission.userId, groupId: roGroup.id })) + } else { + promises.push(kcClient.users.delFromGroup({ id: permission.userId, groupId: roGroup.id })) + } + if (permission.permissions.rw) { + promises.push(kcClient.users.addToGroup({ id: permission.userId, groupId: rwGroup.id })) + } else { + promises.push(kcClient.users.delFromGroup({ id: permission.userId, groupId: rwGroup.id })) + } + } + } + + await Promise.all(promises) + + const envGroupIdsToDelete = envGroups + .filter(subGroup => !project.environments.some(({ name }) => name === subGroup.name)) + .map(subGroup => subGroup.id) + .filter((id): id is string => !!id) + if (envGroupIdsToDelete.length) { + await Promise.all(envGroupIdsToDelete.map(id => kcClient.groups.del({ id }))) + } + + logger.info({ action: 'upsertProject', projectSlug }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const upsertZone: StepCall = async ({ args: zone, apis }) => { + const zoneSlug = zone.slug + try { + const kcClient = await getkcClient() + const argocdUrl = zone.argocdUrl + const clientId = getClientZoneId(zone) + const client: ClientRepresentation = { + clientId, + clientAuthenticatorType: 'client-secret', + protocol: 'openid-connect', + publicClient: false, + defaultClientScopes: ['generic'], + redirectUris: [`${argocdUrl}/auth/callback`], + webOrigins: [argocdUrl], + rootUrl: argocdUrl, + adminUrl: argocdUrl, + baseUrl: '/applications', + } + const result = await kcClient.clients.find({ clientId, max: 1 }) + let outcome: 'updated' | 'created' + if (result.length > 0 && result[0].id) { + await kcClient.clients.update({ id: result[0].id }, client) + outcome = 'updated' + } else { + const password = generateRandomPassword(30) + await apis.vault.write({ clientSecret: password }, 'keycloak') + await kcClient.clients.create({ + secret: password, + ...client, + }) + outcome = 'created' + } + logger.info({ action: 'upsertZone', zoneSlug, clientId, outcome }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + } + } catch (error) { + logger.error({ action: 'upsertZone', zoneSlug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +export const deleteZone: StepCall = async ({ args: zone }) => { + const zoneSlug = zone.slug + try { + const kcClient = await getkcClient() + const clientId = getClientZoneId(zone) + const result = await kcClient.clients.find({ clientId, max: 1 }) + if (result.length > 0 && result[0].id) { + await kcClient.clients.del({ id: result[0].id }) + logger.info({ action: 'deleteZone', zoneSlug, clientId, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + logger.info({ action: 'deleteZone', zoneSlug, clientId, outcome: 'already-missing' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already Missing', + }, + } + } catch (error) { + logger.error({ action: 'deleteZone', zoneSlug, clientId: getClientZoneId(zone), err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const upsertAdminRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) return { status: { result: 'OK', message: 'No OIDC Group defined' } } + if (isExternalRoleType(role.type)) return { status: { result: 'OK', message: 'Skipped (external system role)' } } + const pluginResult = new PluginResultBuilder('Up-to-date') + try { + const kcClient = await getkcClient() + const group = await getOrCreateGroupByPath(kcClient, role.oidcGroup) + const groupMembers = await kcClient.groups.listMembers({ id: group.id }) + + await Promise.all([ + ...groupMembers.map((member, index) => { + if (member.id && !role.members.some(({ id }) => id === member.id)) { + return kcClient.users.delFromGroup({ + id: member.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak admin group') + pluginResult.addExtra(`remove-${index}`, err) + }) + } + return undefined + }), + ...role.members.map((user, index) => { + if (!groupMembers.some(({ id }) => id === user.id)) { + return kcClient.users.addToGroup({ + id: user.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t add user to keycloak admin group') + pluginResult.addExtra(`add-${index}`, err) + }) + } + return undefined + }), + ]) + + logger.info({ action: 'upsertAdminRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertAdminRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const deleteAdminRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) return { status: { result: 'OK', message: 'No OIDC Group defined' } } + if (isExternalRoleType(role.type)) return { status: { result: 'OK', message: 'Skipped (external system role)' } } + const pluginResult = new PluginResultBuilder('Deleted') + try { + const kcClient = await getkcClient() + let group: GroupRepresentation | undefined + if (role.oidcGroup.startsWith('/')) { + const name = role.oidcGroup.split('/').pop() || '' + const groups = await kcClient.groups.find({ search: name }) + group = groups.find(g => g.path === role.oidcGroup) + } else { + const groupOrVoid = await getGroupByName(kcClient, role.oidcGroup) + group = groupOrVoid || undefined + } + + if (group?.id) { + await Promise.all(role.members.map((user, index) => { + return kcClient.users.delFromGroup({ + id: user.id, + groupId: group!.id!, + }) + .catch((err) => { + pluginResult.addKoMessage('Can\'t remove user from keycloak admin group') + pluginResult.addExtra(`remove-${index}`, err) + }) + })) + } + logger.info({ action: 'deleteAdminRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'deleteAdminRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const upsertProjectRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) { + return { + status: { + result: 'OK', + message: 'No OIDC group defined', + }, + } + } + try { + const kcClient = await getkcClient() + await getOrCreateGroupByPath(kcClient, role.oidcGroup) + logger.info({ action: 'upsertProjectRole', roleId: role.id, oidcGroup: role.oidcGroup }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Synced', + }, + } + } catch (error) { + logger.error({ action: 'upsertProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed to sync role', + }, + } + } +} + +export const deleteProjectRole: StepCall = async ({ args: role }) => { + if (!role.oidcGroup) { + return { + status: { + result: 'OK', + message: 'No OIDC group defined', + }, + } + } + try { + const kcClient = await getkcClient() + const [projectName, pluginName, roleName] = role.oidcGroup.split('/').slice(1) + if (!projectName || !pluginName || !roleName) throw new Error('Invalid OIDC group format') + const projectGroup = await getGroupByName(kcClient, projectName) + if (projectGroup?.id) { + const pluginGroups = await getAllSubgroups(kcClient, projectGroup.id, 0) + const pluginGroup = pluginGroups.find(({ name }) => name === pluginName) as Required | undefined + if (pluginGroup?.id) { + const roleGroups = await getAllSubgroups(kcClient, pluginGroup.id, 0) + const roleGroup = roleGroups.find(({ name }) => name === roleName) as Required | undefined + if (roleGroup?.id) { + await deleteGroup(kcClient, roleGroup.id) + logger.info({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Deleted', + }, + } + } + } + } + logger.info({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, outcome: 'already-deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Already deleted', + }, + } + } catch (error) { + logger.error({ action: 'deleteProjectRole', roleId: role.id, oidcGroup: role.oidcGroup, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Failed to delete role', + }, + } + } +} + +export const upsertProjectMember: StepCall = async ({ args: member }) => { + const pluginResult = new PluginResultBuilder('Synced') + try { + const kcClient = await getkcClient() + + const projectGroup = await getOrCreateProjectGroup(kcClient, member.project.slug) + const consoleGroup = await getOrCreateChildGroup(kcClient, projectGroup.id, consoleGroupName) + const allRoleGroups = await getAllSubgroups(kcClient, consoleGroup.id, 0) + const userGroups = await kcClient.users.listGroups({ id: member.userId }) + + const userRolesOidcGroups = member.roles + .map(r => r.oidcGroup) + .filter((g): g is string => !!g) + + // Sync Roles + let groupMembershipChanges = 0 + for (const roleGroup of allRoleGroups) { + if (!roleGroup.id || !roleGroup.path) continue + const isMember = userGroups.some(ug => ug.id === roleGroup.id) + const shouldBeMember = userRolesOidcGroups.includes(roleGroup.path) + + if (shouldBeMember && !isMember) { + groupMembershipChanges += 1 + await kcClient.users.addToGroup({ id: member.userId, groupId: roleGroup.id }) + } else if (!shouldBeMember && isMember) { + groupMembershipChanges += 1 + await kcClient.users.delFromGroup({ id: member.userId, groupId: roleGroup.id }) + } + } + + logger.info({ action: 'upsertProjectMember', projectSlug: member.project.slug, groupMembershipChanges }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'upsertProjectMember', projectSlug: member.project.slug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +export const deleteProjectMember: StepCall = async ({ args: member }) => { + const pluginResult = new PluginResultBuilder('Deleted') + try { + const kcClient = await getkcClient() + if (!member.userId) return pluginResult.getResultObject() + + const projectGroup = await getGroupByName(kcClient, member.project.slug) + if (!projectGroup?.id) return pluginResult.getResultObject() + + const userGroups = await kcClient.users.listGroups({ id: member.userId }) + const projectGroups = userGroups.filter(g => g.path?.startsWith(projectGroup.path!)) + + let removedCount = 0 + for (const group of projectGroups) { + if (group.id) { + await kcClient.users.delFromGroup({ id: member.userId, groupId: group.id }) + removedCount += 1 + } + } + + logger.info({ action: 'deleteProjectMember', projectSlug: member.project.slug, removedCount }, 'Hook done') + return pluginResult.getResultObject() + } catch (error) { + logger.error({ action: 'deleteProjectMember', projectSlug: member.project.slug, err: error }, 'Hook failed') + return pluginResult.returnUnexpectedError(error) + } +} + +function getClientZoneId(zone: ZoneObject): string { + return `argocd-${zone.slug}-zone` +} diff --git a/.jjconflict-side-2/plugins/keycloak/src/group.ts b/.jjconflict-side-2/plugins/keycloak/src/group.ts new file mode 100644 index 0000000000..09c4cdc077 --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/src/group.ts @@ -0,0 +1,123 @@ +import type KeycloakAdminClient from '@keycloak/keycloak-admin-client' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation.js' + +export const consoleGroupName = 'console' + +export type CustomGroup = Required> +export async function getGroupByName(kcClient: KeycloakAdminClient, name: string): Promise { + const groupSearch = await kcClient.groups.find({ search: name }) + return groupSearch.find(grp => grp.name === name) +} + +export async function getAllSubgroups(kcClient: KeycloakAdminClient, parentId: string, first: number, subgroups: GroupRepresentation[] = []): Promise { + const newSubgroups = [ + ...subgroups, + ...await kcClient.groups.listSubGroups({ parentId, briefRepresentation: false, max: 10, first }), + ] + if (newSubgroups.length - subgroups.length === 10) { + return getAllSubgroups(kcClient, parentId, first + 10, newSubgroups) + } + return newSubgroups +} + +export async function getOrCreateChildGroup(kcClient: KeycloakAdminClient, parentId: string, name: string, subGroups: GroupRepresentation[] | undefined = []): Promise { + if (Array.isArray(subGroups) && subGroups.length > 0) { + const matchingGroup = subGroups.find(({ name: groupName }) => groupName === name) as Required | undefined + if (matchingGroup) { + return { + id: matchingGroup.id, + subGroups: matchingGroup.subGroups || [], + subGroupCount: matchingGroup.subGroups?.length || 0, + name, + } + } + } + subGroups = await getAllSubgroups(kcClient, parentId, 0) + + const matchingGroup = subGroups?.find(({ name: groupName }) => groupName === name) as Required | undefined + if (!matchingGroup) { + const newGroup = await kcClient.groups.createChildGroup({ id: parentId }, { name }) + return { + id: newGroup.id, + subGroups: [], + subGroupCount: 0, + name, + } + } + return { + id: matchingGroup.id, + subGroups: matchingGroup.subGroups || [], + subGroupCount: matchingGroup.subGroups?.length || 0, + name, + } +} + +export async function getOrCreateProjectGroup(kcClient: KeycloakAdminClient, name: string): Promise> & { subGroups: Required[] }> { + const existingGroup = await getGroupByName(kcClient, name) as Required + if (!existingGroup) { + const newGroup = await kcClient.groups.create({ name }) + return { + id: newGroup.id, + subGroups: [], + subGroupCount: 0, + name, + } + } + return { + id: existingGroup.id, + // @ts-ignore mauvais typage de librairie les subGroups ont forcément un id + subGroups: existingGroup.subGroups || [], + subGroupCount: existingGroup.subGroups?.length || 0, + name: existingGroup.name, + } +} + +export async function getOrCreateGroupByName(kcClient: KeycloakAdminClient, name: string): Promise> { + const group = await getGroupByName(kcClient, name) + if (group) return group as Required + const newGroup = await kcClient.groups.create({ name }) + const created = await kcClient.groups.findOne({ id: newGroup.id }) + if (!created) throw new Error(`Failed to retrieve created group: ${name}`) + return created as Required +} + +export async function getOrCreateGroupByPath(kcClient: KeycloakAdminClient, path: string): Promise> { + if (!path.startsWith('/')) { + return getOrCreateGroupByName(kcClient, path) + } + + const name = path.split('/').pop() || '' + const groups = await kcClient.groups.find({ search: name }) + const existingGroup = groups.find(g => g.path === path) + + if (existingGroup) return existingGroup as Required + + const groupNames = path.split('/').filter(Boolean) + let parentId: string | undefined + + for (const groupName of groupNames) { + const subGroups = parentId + ? await kcClient.groups.listSubGroups({ parentId }) + : await kcClient.groups.find({ search: groupName }) + const existingSubGroup = subGroups.find(g => g.name === groupName) + + if (existingSubGroup) { + parentId = existingSubGroup.id + } else { + const newGroup = parentId + ? await kcClient.groups.createChildGroup({ id: parentId }, { name: groupName }) + : await kcClient.groups.create({ name: groupName }) + parentId = newGroup.id + } + } + + if (parentId) { + const group = await kcClient.groups.findOne({ id: parentId }) + if (group) return group as Required + } + throw new Error(`Failed to create group path: ${path}`) +} + +export async function deleteGroup(kcClient: KeycloakAdminClient, groupId: string) { + await kcClient.groups.del({ id: groupId }) +} diff --git a/.jjconflict-side-2/plugins/keycloak/src/index.ts b/.jjconflict-side-2/plugins/keycloak/src/index.ts new file mode 100644 index 0000000000..a5fda76e1f --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/src/index.ts @@ -0,0 +1,67 @@ +import type { DefaultArgs, Plugin, Project, ProjectLite } from '@cpn-console/hooks' +import { KeycloakProjectApi } from './class.js' +import { start } from './client.js' +import { + deleteAdminRole, + deleteProject, + deleteProjectMember, + deleteProjectRole, + deleteZone, + retrieveKeycloakUserByEmail, + upsertAdminRole, + upsertProject, + upsertProjectMember, + upsertProjectRole, + upsertZone, +} from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + deleteProject: { + api: project => new KeycloakProjectApi(project.slug), + steps: { post: deleteProject }, + }, + upsertProject: { + api: project => new KeycloakProjectApi(project.slug), + steps: { main: upsertProject }, + }, + upsertProjectRole: { + steps: { main: upsertProjectRole }, + }, + upsertProjectMember: { + steps: { main: upsertProjectMember }, + }, + deleteProjectMember: { + steps: { post: deleteProjectMember }, + }, + upsertZone: { + steps: { main: upsertZone }, + }, + deleteZone: { + steps: { post: deleteZone }, + }, + deleteProjectRole: { + steps: { post: deleteProjectRole }, + }, + retrieveUserByEmail: { steps: { main: retrieveKeycloakUserByEmail } }, + upsertAdminRole: { + steps: { main: upsertAdminRole }, + }, + deleteAdminRole: { + steps: { post: deleteAdminRole }, + }, + }, + monitor, + start, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + keycloak: Args extends (ProjectLite | Project) + ? KeycloakProjectApi + : undefined + } +} diff --git a/.jjconflict-side-2/plugins/keycloak/src/infos.ts b/.jjconflict-side-2/plugins/keycloak/src/infos.ts new file mode 100644 index 0000000000..3b3980194f --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/src/infos.ts @@ -0,0 +1,12 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos: ServiceInfos = { + name: 'keycloak', + title: 'Keycloak', + config: { + global: [], + project: [], + }, +} + +export default infos diff --git a/.jjconflict-side-2/plugins/keycloak/src/logger.ts b/.jjconflict-side-2/plugins/keycloak/src/logger.ts new file mode 100644 index 0000000000..1578c26c49 --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'keycloak' }) diff --git a/.jjconflict-side-2/plugins/keycloak/src/monitor.ts b/.jjconflict-side-2/plugins/keycloak/src/monitor.ts new file mode 100644 index 0000000000..4f1df8d648 --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/src/monitor.ts @@ -0,0 +1,27 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(requiredEnv('KEYCLOAK_URL'), { + validateStatus: res => res === 200, + }) + if (res.status === 200) { // 200 only means api responds + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Service en erreur' + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-2/plugins/keycloak/tsconfig.eslint.json b/.jjconflict-side-2/plugins/keycloak/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-2/plugins/keycloak/tsconfig.json b/.jjconflict-side-2/plugins/keycloak/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-2/plugins/keycloak/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-2/plugins/nexus/.gitignore b/.jjconflict-side-2/plugins/nexus/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-2/plugins/nexus/README.md b/.jjconflict-side-2/plugins/nexus/README.md new file mode 100644 index 0000000000..a8ee9f797a --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/README.md @@ -0,0 +1,13 @@ +# Introduction + +Ce plugin permet de déployer des dépôts à la demande sur nexus. Les seules technologies prisent en charge actuellement sont Maven et NPM. + +## Configuration + +| Env var | valeur possible | description | +| ---------------------- | --------------- | --------------------------------------------------------------------------------------------- | +| NEXUS_ADMIN | chaine | Nom d'utilisateur admin Nexus | +| NEXUS_ADMIN_PASSWORD | chaine | Mot de passe Nexus | +| NEXUS_URL | *url* | Url public d'accés au Nexus | +| NEXUS_INTERNAL_URL | *url* ou vide | url par laquelle la console interroge le service Nexus, si absent utilisation de l'url public | +| NEXUS__SECRET_EXPOSE_INTERNAL_URL | "true" ou vide | Exposition ou non de l'url interne dans la remontée des secrets du projet | diff --git a/.jjconflict-side-2/plugins/nexus/eslint.config.js b/.jjconflict-side-2/plugins/nexus/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-2/plugins/nexus/package.json b/.jjconflict-side-2/plugins/nexus/package.json new file mode 100644 index 0000000000..0a758fd7d9 --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/package.json @@ -0,0 +1,49 @@ +{ + "name": "@cpn-console/nexus-plugin", + "type": "module", + "version": "2.3.1", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/plugins/nexus/src/config.ts b/.jjconflict-side-2/plugins/nexus/src/config.ts new file mode 100644 index 0000000000..aecdc37956 --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/src/config.ts @@ -0,0 +1,29 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + secretExposedUrl: string + user: string + password: string + + constructor() { + this.password = requiredEnv('NEXUS_ADMIN_PASSWORD') + this.user = requiredEnv('NEXUS_ADMIN') + this.publicUrl = removeTrailingSlash(requiredEnv('NEXUS_URL')) + this.internalUrl = process.env.NEXUS_INTERNAL_URL + ? removeTrailingSlash(process.env.NEXUS_INTERNAL_URL) + : this.publicUrl + this.secretExposedUrl = process.env.NEXUS__SECRET_EXPOSE_INTERNAL_URL === 'true' ? this.internalUrl : this.publicUrl + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-side-2/plugins/nexus/src/env.d.ts b/.jjconflict-side-2/plugins/nexus/src/env.d.ts new file mode 100644 index 0000000000..b34e01d65a --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/.jjconflict-side-2/plugins/nexus/src/functions.ts b/.jjconflict-side-2/plugins/nexus/src/functions.ts new file mode 100644 index 0000000000..5841f1c018 --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/src/functions.ts @@ -0,0 +1,28 @@ +import getConfig from './config.js' + +let axiosOptions: { + baseURL: string + auth: { + username: string + password: string + } + headers: { + Accept: string + } +} + +export function getAxiosOptions(): Required { + if (!axiosOptions) { + axiosOptions = { + baseURL: `${getConfig().internalUrl}/service/rest/v1/`, + auth: { + username: getConfig().user, + password: getConfig().password, + }, + headers: { + Accept: 'application/json', + }, + } + } + return axiosOptions +} diff --git a/.jjconflict-side-2/plugins/nexus/src/index.ts b/.jjconflict-side-2/plugins/nexus/src/index.ts new file mode 100644 index 0000000000..3457e0c021 --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/src/index.ts @@ -0,0 +1,19 @@ +import type { DeclareModuleGenerator, Plugin } from '@cpn-console/hooks' +import infos from './infos.js' +import monitor from './monitor.js' +import { createNexusProject, deleteNexusProject, getSecrets } from './project.js' + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { steps: { main: createNexusProject } }, + deleteProject: { steps: { main: deleteNexusProject } }, + getProjectSecrets: { steps: { main: getSecrets } }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface ProjectStore extends DeclareModuleGenerator {} + interface Config extends DeclareModuleGenerator {} +} diff --git a/.jjconflict-side-2/plugins/nexus/src/infos.ts b/.jjconflict-side-2/plugins/nexus/src/infos.ts new file mode 100644 index 0000000000..83abdb71aa --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/src/infos.ts @@ -0,0 +1,116 @@ +import type { ServiceInfos } from '@cpn-console/hooks' + +const infos = { + name: 'nexus', + title: 'Nexus', + imgSrc: '/img/nexus.png', + description: 'Nexus permet de gérer les binaires et artefacts de build à travers la chaîne logistique logicielle', + config: { + project: [ + { + key: 'activateNpmRepo', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt NPM', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'npmWritePolicy', + section: 'NPM', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture', + value: 'allow', + description: 'Politique d\'écriture des dépôts NPM, valeurs possibles: allow / allow_once, deny, replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'activateMavenRepo', + section: 'Maven', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Activer le dépôt Maven', + value: 'disabled', + description: 'Default: utilise le paramétrage globale de la console. Attention: Nexus met un certain temps pour activer/désactiver les dépôts, un reprovisonnage après plusieurs minutes peut être nécessaire', + }, + { + key: 'mavenSnapshotWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Snapshot', + value: 'allow', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + { + key: 'mavenReleaseWritePolicy', + section: 'Maven', + kind: 'text', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: true }, + }, + title: 'Politique d\'écriture du dépôt Release', + value: 'allow_once', + description: 'Politique d\'écriture des dépôts maven, valeurs possibles: allow / allow_once / deny / replication_only, allow par défaut. Documentation: https://help.sonatype.com/en/configurable-repository-fields.html', + }, + ], + global: [ + { + key: 'activateNpmRepoDefaultValue', + section: 'NPM', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Créer un dépôt NPM privé (comportement par défaut des projets)', + value: 'disabled', + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'activateMavenRepoDefaultValue', + section: 'Maven', + kind: 'switch', + initialValue: 'disabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Créer un dépôt MAVEN privé (comportement par défaut des projets)', + value: 'disabled', + description: 'Défaut au niveau global signifie: Désactivé', + }, + { + key: 'enablePlugin', + kind: 'switch', + initialValue: 'enabled', + permissions: { + admin: { read: true, write: true }, + user: { read: true, write: false }, + }, + title: 'Activer/Désactiver entièrement le plugin Nexus', + value: 'enabled', + description: 'Défaut: Activé', + }, + ], + }, +} as const satisfies ServiceInfos + +export default infos diff --git a/.jjconflict-side-2/plugins/nexus/src/logger.ts b/.jjconflict-side-2/plugins/nexus/src/logger.ts new file mode 100644 index 0000000000..952b553155 --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'nexus' }) diff --git a/.jjconflict-side-2/plugins/nexus/src/maven.ts b/.jjconflict-side-2/plugins/nexus/src/maven.ts new file mode 100644 index 0000000000..4fea9e16cc --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/src/maven.ts @@ -0,0 +1,193 @@ +import type { AxiosInstance } from 'axios' +import type { WritePolicy } from './utils.js' +import getConfig from './config.js' +import { deleteIfExists } from './utils.js' + +// Retro-compatibilty, maven is a special case with bad name formats +function getRepoNames(projectName: string) { // Unique function per language cause names are unique per repo + return { + hosted: [ + { + repo: `${projectName}-repository-release`, + privilege: `${projectName}-privilege-release`, + }, + { + repo: `${projectName}-repository-snapshot`, + privilege: `${projectName}-privilege-snapshot`, + }, + ], + group: { + repo: `${projectName}-repository-group`, + privilege: `${projectName}-privilege-group`, + }, + } +} + +async function provisionMavenHosted(axiosInstance: AxiosInstance, repoName: string, writePolicy: WritePolicy) { + const repo = await axiosInstance({ + method: 'GET', + url: `/repositories/maven/hosted/${repoName}`, + validateStatus: code => [200, 404].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/maven/hosted', + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/maven/hosted/${repoName}`, + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [204].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/maven/hosted', + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/maven/hosted/${repoName}`, + data: { + name: repoName, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + maven: { + versionPolicy: 'MIXED', + layoutPolicy: 'STRICT', + contentDisposition: 'ATTACHMENT', + }, + }, + validateStatus: code => [204].includes(code), + }) + } + } +} +interface MavenOptions { + snapshotWritePolicy: WritePolicy + releaseWritePolicy: WritePolicy +} +export async function createMavenRepo(axiosInstance: AxiosInstance, projectName: string, options: MavenOptions) { + const names = getRepoNames(projectName) + + // create local repo maven + await Promise.all([ + provisionMavenHosted(axiosInstance, names.hosted[0].repo, options.releaseWritePolicy), + provisionMavenHosted(axiosInstance, names.hosted[1].repo, options.snapshotWritePolicy), + ]) + + // create maven group + await axiosInstance({ + method: 'post', + url: '/repositories/maven/group', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + 'maven-public', + ], + }, + }, + validateStatus: code => [201, 400].includes(code), + }) + + // create privileges + for (const name of [...names.hosted, names.group]) { + await axiosInstance({ + method: 'post', + url: '/security/privileges/repository-view', + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'maven2', + repository: name.repo, + }, + validateStatus: code => [201, 400].includes(code), + }) + } + return names +} + +export async function deleteMavenRepo(axiosInstance: AxiosInstance, projectName: string) { + const names = getRepoNames(projectName) + const repoPaths = [names.group, ...names.hosted] + const privileges = [...names.hosted, names.group] + const pathsToDelete = [ + // delete privileges + ...privileges.map(({ privilege }) => `/security/privileges/${privilege}`), + // delete local repo maven snapshot + ...repoPaths.map(repo => `/repositories/${repo.repo}`), + ] + for (const path of pathsToDelete) { + await deleteIfExists(path, axiosInstance) + } +} + +export function getMavenUrls(projectName: string) { + const nexusUrl = getConfig().secretExposedUrl + const names = getRepoNames(projectName) + return { + MAVEN_REPO_RELEASE: `${nexusUrl}/${names.hosted[0].repo}`, + MAVEN_REPO_SNAPSHOT: `${nexusUrl}/${names.hosted[1].repo}`, + } +} diff --git a/.jjconflict-side-2/plugins/nexus/src/monitor.ts b/.jjconflict-side-2/plugins/nexus/src/monitor.ts new file mode 100644 index 0000000000..a79babebeb --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/src/monitor.ts @@ -0,0 +1,79 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import { getAxiosOptions } from './functions.js' + +const coreComponents = [ + 'Blob Stores Ready', + 'File Blob Stores Path', +] +const auxComponents = [ + 'Available CPUs', + 'Blob Stores Quota', + 'Blob Stores Ready', + 'Default Admin Credentials', + 'DefaultRoleRealm', + 'File Blob Stores Path', + 'NuGet V2 repositories', + 'Scheduler', + 'com.codahale.metrics.health.jvm.ThreadDeadlockHealthCheck', + 'Node Health Check', + 'Re-encryption required', + 'Default Admin Credentials', + 'Recent version', +] + +type NexusRes = Record + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const status = await axios.get('/status', { + validateStatus: () => true, + ...getAxiosOptions(), + }) + const statusCheck = await axios.get('/status/check', { + validateStatus: res => [200, 503].includes(res), + ...getAxiosOptions(), + }) + const statusWritable = await axios.get('/status/writable', { + validateStatus: res => [200, 503].includes(res), + ...getAxiosOptions(), + }) + if (status.status === 503 || statusWritable.status === 503) { + instance.lastStatus.status = MonitorStatus.ERROR + instance.lastStatus.message = 'Nexus semble planté' + return instance.lastStatus + } + if (statusCheck.status === 200) { + const data = statusCheck.data as NexusRes + + const failedCoreComponents = coreComponents.filter(component => !data[component]?.healthy) + const failedAuxComponents = auxComponents.filter(component => !data[component]?.healthy) + if (failedAuxComponents.length || failedCoreComponents.length) { + instance.lastStatus.status = failedCoreComponents.length + ? MonitorStatus.ERROR + : MonitorStatus.WARNING + instance.lastStatus.message = failedCoreComponents.length + ? 'Des composants critiques sont en erreur' + : 'Le service est partiellement dégradé' + + instance.lastStatus.cause = `Les composants suivants sont en erreurs: ${[...failedCoreComponents, ...failedAuxComponents].join(', ')}` + return instance.lastStatus + } + } + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-2/plugins/nexus/src/npm.ts b/.jjconflict-side-2/plugins/nexus/src/npm.ts new file mode 100644 index 0000000000..02bc3573f0 --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/src/npm.ts @@ -0,0 +1,167 @@ +import type { AxiosInstance } from 'axios' +import type { WritePolicy } from './utils.js' +import getConfig from './config.js' +import { deleteIfExists } from './utils.js' + +function getRepoNames(projectName: string) { // Unique function per language cause names are unique per repo + return { + hosted: [ + { + repo: `${projectName}-npm`, + privilege: `${projectName}-npm-privilege`, + }, + ], + group: { + repo: `${projectName}-npm-group`, + privilege: `${projectName}-npm-group-privilege`, + }, + } +} +export async function createNpmRepo(axiosInstance: AxiosInstance, projectName: string, writePolicy: WritePolicy) { + const names = getRepoNames(projectName) + // create local repo maven + for (const repVersion of names.hosted) { + const repo = await axiosInstance({ + method: 'GET', + url: `/repositories/npm/hosted/${repVersion.repo}`, + validateStatus: code => [200, 404].includes(code), + }) + if (repo.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/npm/hosted', + data: { + name: repVersion.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/repositories/npm/hosted/${repVersion.repo}`, + data: { + name: repVersion.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + writePolicy, + }, + component: { proprietaryComponents: true }, + }, + validateStatus: code => [204].includes(code), + }) + } + } + // create maven group + const group = await axiosInstance({ + method: 'get', + url: `/repositories/npm/group/${names.group.repo}`, + validateStatus: code => [200, 404].includes(code), + }) + if (group.status === 404) { + await axiosInstance({ + method: 'post', + url: '/repositories/npm/group', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + ], + }, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + url: `/repositories/npm/group/${names.group.repo}`, + method: 'put', + data: { + name: names.group.repo, + online: true, + storage: { + blobStoreName: 'default', + strictContentTypeValidation: true, + }, + group: { + memberNames: [ + ...names.hosted.map(({ repo }) => repo), + ], + }, + }, + validateStatus: code => [204].includes(code), + }) + } + + for (const name of [...names.hosted, names.group]) { + const privilege = await axiosInstance({ + method: 'get', + url: `/security/privileges/${name.privilege}`, + validateStatus: code => [200, 404].includes(code), + }) + if (privilege.status === 404) { + await axiosInstance({ + method: 'post', + url: '/security/privileges/repository-view', + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'npm', + repository: name.repo, + }, + validateStatus: code => [201].includes(code), + }) + } else { + await axiosInstance({ + method: 'put', + url: `/security/privileges/repository-view/${name.privilege}`, + data: { + name: name.privilege, + description: `Privilege for organization ${projectName} for repo ${name.repo}`, + actions: ['all'], + format: 'npm', + repository: name.repo, + }, + validateStatus: code => [204].includes(code), + }) + } + } + return names +} + +export async function deleteNpmRepo(axiosInstance: AxiosInstance, projectName: string) { + const names = getRepoNames(projectName) + const repoPaths = [names.group, ...names.hosted] + const privileges = [...names.hosted, names.group] + const pathsToDelete = [ + // delete privileges + ...privileges.map(({ privilege }) => `/security/privileges/${privilege}`), + // delete local repo maven snapshot + ...repoPaths.map(repo => `/repositories/${repo.repo}`), + ] + for (const path of pathsToDelete) { + await deleteIfExists(path, axiosInstance) + } +} + +export function getNpmUrls(projectName: string) { + const nexusUrl = getConfig().secretExposedUrl + const names = getRepoNames(projectName) + return { + NPM_REPO: `${nexusUrl}/${names.hosted[0].repo}`, + } +} diff --git a/.jjconflict-side-2/plugins/nexus/src/project.ts b/.jjconflict-side-2/plugins/nexus/src/project.ts new file mode 100644 index 0000000000..2363ca05a1 --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/src/project.ts @@ -0,0 +1,245 @@ +import type { Project, ProjectLite, StepCall } from '@cpn-console/hooks' +import type { WritePolicy } from './utils.js' +import { generateRandomPassword, specificallyDisabled } from '@cpn-console/hooks' +import axios from 'axios' +import { getAxiosOptions } from './functions.js' +import { logger } from './logger.js' +import { createMavenRepo, deleteMavenRepo, getMavenUrls } from './maven.js' +import { createNpmRepo, deleteNpmRepo, getNpmUrls } from './npm.js' +import { deleteIfExists, getTechUsed, parseProjectOptions, updateStore } from './utils.js' + +const getAxiosInstance = () => axios.create(getAxiosOptions()) + +export const deleteNexusProject: StepCall = async ({ args: project }) => { + const axiosInstance = getAxiosInstance() + const projectName = project.slug + try { + await deleteMavenRepo(axiosInstance, projectName) + await deleteNpmRepo(axiosInstance, projectName) + await Promise.all([ + // delete role + deleteIfExists(`/security/roles/${projectName}-ID`, axiosInstance), + // delete user + axiosInstance({ + method: 'delete', + url: `/security/users/${projectName}`, + validateStatus: code => code === 404 || code < 300, + }), + ]) + + logger.info({ action: 'deleteNexusProject', projectSlug: project.slug, outcome: 'deleted' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Project deleted from Nexus', + }, + store: { + activateNpmRepo: 'disabled', + activateMavenRepo: 'disabled', + }, + } + } catch (error) { + logger.error({ action: 'deleteNexusProject', projectSlug: project.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + } + } +} + +export const createNexusProject: StepCall = async (payload) => { + try { + if (specificallyDisabled(payload.config.nexus?.enablePlugin)) { + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'disabled' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Nexus plugin is disabled', + }, + } + } + if (!payload.apis.vault) throw new Error('no Vault available') + + const axiosInstance = getAxiosInstance() + const projectName = payload.args.slug + const owner = payload.args.owner + const res: any = {} + const options = parseProjectOptions(payload.args.store.nexus) + const failedProvisionning: Partial> = {} + const techUsed = getTechUsed(payload) + + if (options.keysInError.length) { + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'invalid-config', invalidKeyCount: options.keysInError.length }, 'Hook done') + return { + status: { + result: 'WARNING', + message: `${options.keysInError.join(', ')} ${options.keysInError.length > 1 ? 'are' : 'is'} invalid, provisionning canceled, fix it and try again`, + }, + warnReasons: [`invalid key(s): ${options.keysInError.join(', ')}`], + } + } + const privilegesToAccess = [] as string[] + + try { + if (techUsed.maven) { + const names = await createMavenRepo(axiosInstance, projectName, { + releaseWritePolicy: options.mavenReleaseWritePolicy as WritePolicy, + snapshotWritePolicy: options.mavenSnapshotWritePolicy as WritePolicy, + }) + privilegesToAccess.push(names.group.privilege, ...names.hosted.map(({ privilege }) => privilege)) + } else { + await deleteMavenRepo(axiosInstance, projectName) + } + } catch (error) { + failedProvisionning.maven = { error, message: `Maven failed to ${techUsed.maven ? 'provision' : 'delete'} repositories please try again in few minutes` } + } + + try { + if (techUsed.npm) { + const names = await createNpmRepo(axiosInstance, projectName, options.npmWritePolicy as WritePolicy) + privilegesToAccess.push(names.group.privilege, ...names.hosted.map(({ privilege }) => privilege)) + } else { + await deleteNpmRepo(axiosInstance, projectName) + } + } catch (error) { + failedProvisionning.npm = { error, message: `Npm failed to ${techUsed.npm ? 'provision' : 'delete'} repositories please try again in few minutes` } + } + + const roleId = `${projectName}-ID` + // create role + const role = await axiosInstance({ + method: 'GET', + url: `security/roles/${roleId}`, + validateStatus: code => [200, 404].includes(code), + }) + if (role.status === 404) { + await axiosInstance({ + method: 'post', + url: '/security/roles', + data: { + id: `${projectName}-ID`, + name: `${projectName}-role`, + description: 'desc', + privileges: privilegesToAccess, + }, + validateStatus: code => [200].includes(code), + }) + } else if (role.status === 200) { + await axiosInstance({ + method: 'PUT', + url: `security/roles/${roleId}`, + data: { + id: `${projectName}-ID`, + name: `${projectName}-role`, + privileges: privilegesToAccess, + }, + validateStatus: code => [204].includes(code), + }) + } + + const vaultNexusSecret = await payload.apis.vault.read('NEXUS', { throwIfNoEntry: false }) + let currentPwd: string = vaultNexusSecret?.NEXUS_PASSWORD + + const newPwd = generateRandomPassword(30) + const getUser = await axiosInstance({ + url: `/security/users?userId=${projectName}`, + }) as { data: { userId: string }[] } + const user = getUser.data.find(user => user.userId === projectName) + if (user) { + res.user = getUser.data[0] + res.status = { result: 'OK', message: 'User already exists' } + if (!vaultNexusSecret) { + await axiosInstance({ + method: 'put', + url: `/security/users/${projectName}/change-password`, + data: newPwd, + headers: { + 'Content-Type': 'text/plain', + }, + }) + currentPwd = newPwd + } + } else { + // createUser + await axiosInstance({ + method: 'post', + url: '/security/users', + data: { + userId: projectName, + firstName: 'Monkey D.', + lastName: 'Luffy', + emailAddress: owner.email, + password: newPwd, + status: 'active', + roles: [`${projectName}-ID`], + }, + }) + currentPwd = newPwd + } + + if (!getUser.data.length || (getUser.data.length && !vaultNexusSecret)) { // conditions précédentes, si non existent ou si modp a dû être changé + await payload.apis.vault.write({ + NEXUS_PASSWORD: currentPwd, + NEXUS_USERNAME: projectName, + }, 'NEXUS') + } + + if (Object.keys(failedProvisionning).length) { + const failed = Object.values(failedProvisionning) + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'partial-failure', failedProvisionCount: failed.length }, 'Hook done') + return { + status: { + result: 'WARNING', + message: failed.map(({ message }) => message).join('; '), + }, + errors: failed.map(({ error }) => error), + } + } + + logger.info({ action: 'createNexusProject', projectSlug: payload.args.slug, outcome: 'up-to-date' }, 'Hook done') + return { + status: { + result: 'OK', + message: 'Up-to-date', + }, + store: updateStore(payload.args.store.nexus), + } + } catch (error) { + logger.error({ action: 'createNexusProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'Fail Create repositories', + }, + } + } +} + +export const getSecrets: StepCall = async (payload) => { + if (specificallyDisabled(payload.config.nexus?.enablePlugin)) { + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug, outcome: 'disabled' }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } + const projectName = payload.args.slug + const techUsed = getTechUsed(payload) + + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + secrets: { + ...techUsed.maven && getMavenUrls(projectName), + ...techUsed.npm && getNpmUrls(projectName), + }, + } +} diff --git a/.jjconflict-side-2/plugins/nexus/src/utils.ts b/.jjconflict-side-2/plugins/nexus/src/utils.ts new file mode 100644 index 0000000000..5c52c4435f --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/src/utils.ts @@ -0,0 +1,80 @@ +import type { HookPayload, Project, ProjectLite } from '@cpn-console/hooks' +import type { AxiosInstance } from 'axios' +import { defaultOrNullish, specificallyEnabled } from '@cpn-console/hooks' + +export async function deleteIfExists(url: string, axiosInstance: AxiosInstance) { + const res = await axiosInstance({ + method: 'get', + url, + validateStatus: code => code === 404 || code < 300, + }) + if (res.status !== 404) { + // delete maven group + return axiosInstance({ + method: 'delete', + url, + validateStatus: code => code === 404 || code < 300, + }) + } + return res +} + +export function getTechUsed(payload: HookPayload) { + const projectStore = payload.args.store.nexus + const globalConfig = payload.config.nexus + const techUsed = { + maven: false, + npm: false, + } + if (specificallyEnabled(projectStore?.activateMavenRepo)) { + techUsed.maven = true + } else if (defaultOrNullish(projectStore?.activateMavenRepo) && specificallyEnabled(globalConfig?.activateMavenRepoDefaultValue)) { + techUsed.maven = true + } + if (specificallyEnabled(projectStore?.activateNpmRepo)) { + techUsed.npm = true + } else if (defaultOrNullish(projectStore?.activateMavenRepo) && specificallyEnabled(globalConfig?.activateMavenRepoDefaultValue)) { + techUsed.npm = true + } + return techUsed +} + +export const writePolicyEnum = ['ALLOW', 'ALLOW_ONCE', 'DENY', 'REPLICATION_ONLY'] as const +export type WritePolicy = typeof writePolicyEnum[number] + +export function parseWritePolicy(inputTrial?: string): WritePolicy | 'ERROR' { + const input = inputTrial?.toUpperCase() ?? 'ALLOW' + return writePolicyEnum.find(policy => policy === input) ?? 'ERROR' +} + +export function parseProjectOptions(options: ProjectLite['store']['nexus']) { + // @ts-expect-error + const mavenReleaseWritePolicy = parseWritePolicy(options?.mavenReleaseWritePolicy ?? options?.mavenWritePolicy) + // @ts-expect-error + const mavenSnapshotWritePolicy = parseWritePolicy(options?.mavenSnapshotWritePolicy ?? options?.mavenWritePolicy) + const npmWritePolicy = parseWritePolicy(options?.npmWritePolicy) + const keysInError: (keyof NonNullable>)[] = [] + if (mavenReleaseWritePolicy === 'ERROR') keysInError.push('mavenReleaseWritePolicy') + if (mavenSnapshotWritePolicy === 'ERROR') keysInError.push('mavenSnapshotWritePolicy') + if (npmWritePolicy === 'ERROR') keysInError.push('npmWritePolicy') + return { + keysInError, + mavenReleaseWritePolicy, + mavenSnapshotWritePolicy, + npmWritePolicy, + } +} + +export function updateStore(store?: ProjectLite['store']['nexus']) { + if (!store) return {} + // @ts-expect-error + if (store.mavenWritePolicy) { + // @ts-expect-error + store.mavenReleaseWritePolicy = store.mavenReleaseWritePolicy ?? store.mavenWritePolicy + // @ts-expect-error + store.mavenSnapshotWritePolicy = store.mavenSnapshotWritePolicy ?? store.mavenWritePolicy + // @ts-expect-error + store.mavenWritePolicy = '' + } + return store +} diff --git a/.jjconflict-side-2/plugins/nexus/tsconfig.eslint.json b/.jjconflict-side-2/plugins/nexus/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-2/plugins/nexus/tsconfig.json b/.jjconflict-side-2/plugins/nexus/tsconfig.json new file mode 100644 index 0000000000..fb5ba5c23c --- /dev/null +++ b/.jjconflict-side-2/plugins/nexus/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "useUnknownInCatchVariables": true, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-2/plugins/sonarqube/.gitignore b/.jjconflict-side-2/plugins/sonarqube/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-2/plugins/sonarqube/eslint.config.js b/.jjconflict-side-2/plugins/sonarqube/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-2/plugins/sonarqube/package.json b/.jjconflict-side-2/plugins/sonarqube/package.json new file mode 100644 index 0000000000..703362ca0d --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/package.json @@ -0,0 +1,50 @@ +{ + "name": "@cpn-console/sonarqube-plugin", + "type": "module", + "version": "2.0.5", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/gitlab-plugin": "workspace:^", + "@cpn-console/hooks": "workspace:^", + "@cpn-console/keycloak-plugin": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "@cpn-console/vault-plugin": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/plugins/sonarqube/src/check.ts b/.jjconflict-side-2/plugins/sonarqube/src/check.ts new file mode 100644 index 0000000000..67a1e79cf2 --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/src/check.ts @@ -0,0 +1,45 @@ +import type { PluginResult } from '@cpn-console/hooks' +import { getAxiosInstance } from './tech.js' + +let status: PluginResult + +export async function getStatus() { + if (!status?.result) { + status = await check() + } + return status +} + +export async function check(): Promise { + const axiosInstance = getAxiosInstance() + + try { + const health = await axiosInstance({ + url: 'system/info', + }) + const res: PluginResult = { + status: { + result: 'OK', + }, + } + + if (health.data.Health === 'RED') { + res.status.result = 'KO' + } + if (health.data['Health Causes']) { + res.status.message = health.data['Health Causes'].join('\n') + } + + return res + } catch (error) { + return { + error, + status: { + result: 'KO', + // @ts-ignore prévoir une fonction générique + message: 'An unexpected error occured', + }, + updatedAt: Date.now(), + } + } +} diff --git a/.jjconflict-side-2/plugins/sonarqube/src/env.d.ts b/.jjconflict-side-2/plugins/sonarqube/src/env.d.ts new file mode 100644 index 0000000000..219601dae5 --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/src/env.d.ts @@ -0,0 +1,3 @@ +/// +/// +/// diff --git a/.jjconflict-side-2/plugins/sonarqube/src/functions.ts b/.jjconflict-side-2/plugins/sonarqube/src/functions.ts new file mode 100644 index 0000000000..2a72978cc7 --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/src/functions.ts @@ -0,0 +1,352 @@ +import type { Project, StepCall } from '@cpn-console/hooks' +import type { KeycloakProjectApi } from '@cpn-console/keycloak-plugin/types/class.js' +import type { VaultProjectApi } from '@cpn-console/vault-plugin/types/vault-project-api.js' +import type { SonarPaging } from './project.js' +import type { VaultSonarSecret } from './tech.js' +import type { SonarUser } from './user.js' +import { generateProjectKey } from '@cpn-console/hooks' +import { adminGroupPath } from '@cpn-console/shared' +import { ensureGroupExists, findGroupByName } from './group.js' +import { logger } from './logger.js' +import { + createDsoRepository, + deleteDsoRepository, + ensureRepositoryConfiguration, + files, + findSonarProjectsForDsoProjects, +} from './project.js' +import { getAxiosInstance } from './tech.js' +import { ensureUserExists } from './user.js' + +const globalPermissions = [ + 'admin', + 'profileadmin', + 'gateadmin', + 'scan', + 'provisioning', +] + +const projectPermissions = [ + 'admin', + 'codeviewer', + 'issueadmin', + 'securityhotspotadmin', + 'scan', + 'user', +] + +export async function initSonar() { + try { + await setTemplatePermisions() + await createAdminGroup() + await setAdminPermisions() + logger.info({ action: 'initSonar' }, 'Hook done') + } catch (error) { + logger.error({ action: 'initSonar', err: error }, 'Hook failed') + throw error + } +} + +async function createAdminGroup() { + const axiosInstance = getAxiosInstance() + const adminGroup = await findGroupByName(adminGroupPath) + if (!adminGroup) { + await axiosInstance({ + method: 'post', + params: { + name: adminGroupPath, + description: 'DSO platform admins', + }, + url: 'user_groups/create', + }) + } +} + +async function setAdminPermisions() { + const axiosInstance = getAxiosInstance() + for (const permission of globalPermissions) { + await axiosInstance({ + method: 'post', + params: { + groupName: adminGroupPath, + permission, + }, + url: 'permissions/add_group', + }) + } +} + +async function setTemplatePermisions() { + const axiosInstance = getAxiosInstance() + await axiosInstance({ + method: 'post', + params: { name: 'Forge Default' }, + url: 'permissions/create_template', + validateStatus: code => [200, 400].includes(code), + }) + for (const permission of projectPermissions) { + await axiosInstance({ + method: 'post', + params: { + templateName: 'Forge Default', + permission, + }, + url: 'permissions/add_project_creator_to_template', + }) + await axiosInstance({ + method: 'post', + params: { + groupName: 'sonar-administrators', + templateName: 'Forge Default', + permission, + }, + url: 'permissions/add_group_to_template', + }) + } + await axiosInstance({ + method: 'post', + params: { + templateName: 'Forge Default', + }, + url: 'permissions/set_default_template', + }) +} + +export const upsertProject: StepCall = async (payload) => { + try { + const project = payload.args + const { + vault: vaultApi, + keycloak: keycloakApi, + }: { + vault: VaultProjectApi + keycloak: KeycloakProjectApi + } = payload.apis + const { slug: projectSlug } = project + const username = project.slug + const keycloakGroupPath = await keycloakApi.getProjectGroupPath() + const sonarRepositories + = await findSonarProjectsForDsoProjects(projectSlug) + + await Promise.all([ + ensureUserAndVault(vaultApi, username, projectSlug), + ensureGroupExists(keycloakGroupPath), + + // Remove excess repositories + ...sonarRepositories + .filter( + sonarRepository => + !project.repositories.some( + repo => repo.internalRepoName === sonarRepository.repository, + ), + ) + .map(sonarRepository => deleteDsoRepository(sonarRepository.key)), + + // Create or configure needed repos + ...project.repositories.map(async (repository) => { + const projectKey = generateProjectKey( + projectSlug, + repository.internalRepoName, + ) + if ( + !sonarRepositories.some( + sonarRepository => + sonarRepository.repository === repository.internalRepoName, + ) + ) { + await createDsoRepository(projectSlug, repository.internalRepoName) + } + await ensureRepositoryConfiguration( + projectKey, + username, + keycloakGroupPath, + ) + }), + ]) + + logger.info({ action: 'upsertProject', projectSlug }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } catch (error) { + logger.error( + { action: 'upsertProject', projectSlug: payload.args.slug, err: error }, + 'Hook failed', + ) + return { + status: { + result: 'WARNING', + message: 'Failed to reconcile', + }, + errors: { + main: error, + }, + } + } +} + +export const setVariables: StepCall = async (payload) => { + const returnResponse = payload.results.sonarqube + try { + const project = payload.args + const { slug: projectSlug } = project + const { gitlab: gitlabApi } = payload.apis + + const sonarSecret = await payload.apis.vault.read('SONAR') + const listGroupVars = await gitlabApi.getGitlabGroupVariables() + await Promise.all([ + // Sonar vars saving in CI (repositories) + ...project.repositories.map(async (repo) => { + const projectKey = generateProjectKey( + projectSlug, + repo.internalRepoName, + ) + const repoId = await payload.apis.gitlab.getProjectId( + repo.internalRepoName, + ) + if (!repoId) return + const listVars = await gitlabApi.getGitlabRepoVariables(repoId) + return [ + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + key: 'PROJECT_KEY', + masked: false, + protected: false, + value: projectKey, + variable_type: 'env_var', + environment_scope: '*', + }), + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + key: 'PROJECT_NAME', + masked: false, + protected: false, + value: `${projectSlug}-${repo.internalRepoName}`, + variable_type: 'env_var', + environment_scope: '*', + }), + await gitlabApi.setGitlabRepoVariable(repoId, listVars, { + variable_type: 'file', + key: 'SONAR_PROJECT_PROPERTIES', + masked: false, + protected: false, + value: files['sonar-project.properties'](projectKey), + environment_scope: '*', + }), + ] + }), + // Sonar vars saving in CI (group) + gitlabApi.setGitlabGroupVariable(listGroupVars, { + key: 'SONAR_TOKEN', + masked: true, + protected: false, + value: sonarSecret.data.SONAR_TOKEN, + variable_type: 'env_var', + }), + ]) + + if (payload.results.sonarqube.status.result === 'WARNING') { + returnResponse.status.message = `main message: ${payload.results.sonarqube.status.message}, post message: OK` + } + logger.info({ action: 'setVariables', projectSlug }, 'Hook done') + return returnResponse + } catch (error) { + returnResponse.status.result = 'WARNING' + returnResponse.status.message = `main message: ${payload.results.sonarqube.status.message}, post message: Failed to reconcile` + returnResponse.errors = { + ...returnResponse.errors, + post: error, + } + logger.error( + { action: 'setVariables', projectSlug: payload.args.slug, err: error }, + 'Hook failed', + ) + return returnResponse + } +} + +export const deleteProject: StepCall = async (payload) => { + const axiosInstance = getAxiosInstance() + + const project = payload.args + const { slug: projectSlug } = project + const username = projectSlug + try { + const sonarRepositories + = await findSonarProjectsForDsoProjects(projectSlug) + await Promise.all(sonarRepositories.map(repo => deleteRepo(repo.key))) + const users: { paging: SonarPaging, users: SonarUser[] } = ( + await axiosInstance({ + url: 'users/search', + params: { + q: username, + }, + }) + )?.data + const user = users.users.find(u => u.login === username) + if (!user) { + return { + status: { + result: 'OK', + message: 'Already missing', + }, + } + } + await axiosInstance({ + url: 'users/deactivate', + params: { + login: username, + anonymize: true, + }, + method: 'post', + }) + logger.info( + { action: 'deleteProject', projectSlug, outcome: 'user-anonymized' }, + 'Hook done', + ) + return { + status: { + result: 'OK', + message: 'User anonymized', + }, + } + } catch (error) { + logger.error( + { action: 'deleteProject', projectSlug, err: error }, + 'Hook failed', + ) + return { + error, + status: { + result: 'KO', + message: 'Failed', + }, + } + } +} + +async function deleteRepo(projectKey: string) { + return getAxiosInstance()({ + url: 'projects/delete', + params: { + project: projectKey, + }, + method: 'post', + }) +} + +async function ensureUserAndVault( + vaultApi: VaultProjectApi, + username: string, + projectSlug: string, +) { + const vaultUserSecret = (await vaultApi.read('SONAR', { + throwIfNoEntry: false, + })) as VaultSonarSecret | undefined + const newUserSecret = await ensureUserExists( + username, + projectSlug, + vaultUserSecret, + ) + if (newUserSecret) await vaultApi.write(newUserSecret, 'SONAR') +} diff --git a/.jjconflict-side-2/plugins/sonarqube/src/group.ts b/.jjconflict-side-2/plugins/sonarqube/src/group.ts new file mode 100644 index 0000000000..08ae823069 --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/src/group.ts @@ -0,0 +1,35 @@ +import type { SonarPaging } from './project.js' +import { getAxiosInstance } from './tech.js' + +export interface SonarGroup { + id: string + name: string + description: string + membersCount: number + default: boolean +} + +export async function findGroupByName(name: string): Promise { + const axiosInstance = getAxiosInstance() + const groupsSearch: { paging: SonarPaging, groups: SonarGroup[] } = (await axiosInstance({ + url: 'user_groups/search', + params: { + q: name, + }, + }))?.data + return groupsSearch.groups.find(g => g.name === name) +} + +export async function ensureGroupExists(groupName: string) { + const axiosInstance = getAxiosInstance() + const group = await findGroupByName(groupName) + if (!group) { + await axiosInstance({ + url: 'user_groups/create', + method: 'post', + params: { + name: groupName, + }, + }) + } +} diff --git a/.jjconflict-side-2/plugins/sonarqube/src/index.ts b/.jjconflict-side-2/plugins/sonarqube/src/index.ts new file mode 100644 index 0000000000..a05d49e210 --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/src/index.ts @@ -0,0 +1,37 @@ +import type { HookStepsNames, Plugin } from '@cpn-console/hooks' +import { getStatus } from './check.js' +import { deleteProject, initSonar, setVariables, upsertProject } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' + +function start(_options: unknown) { + try { + initSonar() + getStatus() + } catch {} +} + +export const plugin: Plugin = { + infos, + subscribedHooks: { + upsertProject: { + steps: { + main: upsertProject, + post: setVariables, + }, + }, + deleteProject: { + steps: { + main: deleteProject, + }, + }, + }, + start, + monitor, +} + +declare module '@cpn-console/hooks' { + interface PluginResult { + errors?: Partial> + } +} diff --git a/.jjconflict-side-2/plugins/sonarqube/src/infos.ts b/.jjconflict-side-2/plugins/sonarqube/src/infos.ts new file mode 100644 index 0000000000..94ea6616cc --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/src/infos.ts @@ -0,0 +1,12 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import { getConfig } from './tech.js' + +const infos: ServiceInfos = { + name: 'sonarqube', + to: () => `${getConfig().url}/projects`, + title: 'SonarQube', + imgSrc: '/img/sonarqube.svg', + description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', +} + +export default infos diff --git a/.jjconflict-side-2/plugins/sonarqube/src/logger.ts b/.jjconflict-side-2/plugins/sonarqube/src/logger.ts new file mode 100644 index 0000000000..7683fa7961 --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'sonarqube' }) diff --git a/.jjconflict-side-2/plugins/sonarqube/src/monitor.ts b/.jjconflict-side-2/plugins/sonarqube/src/monitor.ts new file mode 100644 index 0000000000..d4fe929477 --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/src/monitor.ts @@ -0,0 +1,41 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import { getAxiosOptions } from './tech.js' + +const statusMap = { + GREEN: MonitorStatus.OK, + YELLOW: MonitorStatus.WARNING, + RED: MonitorStatus.ERROR, +} +const messageMap = { + GREEN: MonitorStatus.OK, + YELLOW: 'Service dégradé', + RED: 'Service en panne', +} +interface SonarRes { + health: keyof typeof statusMap + causes: string[] +} + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get('/system/health', { + validateStatus: res => res === 200, + ...getAxiosOptions(), + }) + const data = res.data as SonarRes + + instance.lastStatus.status = statusMap[data.health] + instance.lastStatus.message = messageMap[data.health] + return instance.lastStatus + } catch (error) { + instance.lastStatus.message = 'Erreur lors la requête' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-2/plugins/sonarqube/src/project.ts b/.jjconflict-side-2/plugins/sonarqube/src/project.ts new file mode 100644 index 0000000000..16082a8d5e --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/src/project.ts @@ -0,0 +1,160 @@ +import { generateProjectKey } from '@cpn-console/hooks' +import { logger } from './logger.js' +import { getAxiosInstance } from './tech.js' + +export interface SonarPaging { + pageIndex: number + pageSize: number + total: number +} + +export type Qualifiers + = 'BRC' // - Sub - projects + | 'DIR' // - Directories + | 'FIL' // - Files + | 'TRK' // - Projects + | 'UTS' // - Test Files + +export interface SonarProject { + key: string // unique key name + name: string + qualifier: Qualifiers + visibility: 'private' | 'public' + lastAnalysisDate?: string + revision?: string +} + +const robotPermissions = [ + 'user', + 'codeviewer', + 'admin', + 'scan', +] +const groupPermissions = [ + 'user', + 'codeviewer', + 'issueadmin', + 'securityhotspotadmin', + 'scan', +] + +export async function createDsoRepository(projectSlug: string, internalRepoName: string, sonarProjectKey?: string) { + const sonarProjectName = `${projectSlug}-${internalRepoName}` + if (!sonarProjectKey) + sonarProjectKey = generateProjectKey(projectSlug, internalRepoName) + return createProject(sonarProjectKey, sonarProjectName) +} + +export async function ensureRepositoryConfiguration(projectKey: string, login: string, groupName: string) { + const axiosInstance = getAxiosInstance() + + for (const permission of robotPermissions) { + await axiosInstance({ + url: 'permissions/add_user', + method: 'post', + params: { + projectKey, + permission, + login, + }, + }) + } + for (const permission of groupPermissions) { + await axiosInstance({ + url: 'permissions/add_group', + method: 'post', + params: { + projectKey, + permission, + groupName, + }, + }) + } +} + +export async function createProject(projectKey: string, projectName: string) { + return getAxiosInstance()({ + url: 'projects/create', + method: 'post', + params: { + project: projectKey, + visibility: 'private', + name: projectName, + mainbranch: 'main', + }, + }) +} + +export async function deleteDsoRepository(projectKey: string) { + const axiosInstance = getAxiosInstance() + + await axiosInstance({ + url: 'projects/delete', + method: 'post', + params: { + project: projectKey, + }, + }) +} + +interface SonarProjectResult { + projectSlug: string + repository: string + key: string +} + +function filterProjectsOwning(repos: { key: string }[], projectSlug: string): SonarProjectResult[] { + return repos.reduce((acc, repo) => { + let isOrphan = true + + const sonarKey = repo.key + const keyElements = sonarKey.split('-') + keyElements.pop() + for (let i = keyElements.length - 1; i > 0; i--) { + const project = keyElements.slice(0, i).join('-') + const repository = keyElements.slice(i).join('-') + const keyComputed = generateProjectKey(project, repository) + if (keyComputed === sonarKey) { + if (project === projectSlug) { + acc.push({ + projectSlug, + repository, + key: sonarKey, + }) + } + isOrphan = false + } + } + if (isOrphan) { + logger.warn({ sonarKey, projectSlug }, 'Orphan SonarQube project key does not match expected pattern') + } + return acc + }, [] as SonarProjectResult[]) +} + +export async function findSonarProjectsForDsoProjects(projectSlug: string) { + const axiosInstance = getAxiosInstance() + let foundProjectKeys: SonarProjectResult[] = [] + + let page = 0 + const pageSize = 100 + let total = 0 + do { + page++ + const similarProjects = await axiosInstance.get('projects/search', { + params: { + q: projectSlug, + p: page, + ps: pageSize, + }, + }) + total = similarProjects.data.paging.total + foundProjectKeys = [...foundProjectKeys, ...filterProjectsOwning(similarProjects.data.components, projectSlug)] + } while (page * pageSize < total) + + return foundProjectKeys +} + +export const files = { + 'sonar-project.properties': (key: string) => `sonar.projectKey=${key}\nsonar.qualitygate.wait=true\n`, +} diff --git a/.jjconflict-side-2/plugins/sonarqube/src/tech.ts b/.jjconflict-side-2/plugins/sonarqube/src/tech.ts new file mode 100644 index 0000000000..e820d8d381 --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/src/tech.ts @@ -0,0 +1,39 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' +import axios from 'axios' + +const config: { + url?: string + internalUrl?: string + user?: string + password?: string +} = {} + +export function getConfig(): Required { + config.url = config.url ?? removeTrailingSlash(requiredEnv('SONARQUBE_URL')) + config.internalUrl = config.internalUrl ?? removeTrailingSlash(requiredEnv('SONARQUBE_INTERNAL_URL')) + config.user = config.user ?? requiredEnv('SONAR_API_TOKEN') + // @ts-ignore + return config +} +export function getAxiosOptions() { + return { + baseURL: `${getConfig().internalUrl}/api/`, + auth: { + username: getConfig().user, + password: '', // Token is used, so password is useless + }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } +} + +export function getAxiosInstance() { + return axios.create(getAxiosOptions()) +} + +export interface VaultSonarSecret { + SONAR_USERNAME: string + SONAR_PASSWORD: string + SONAR_TOKEN: string +} diff --git a/.jjconflict-side-2/plugins/sonarqube/src/user.ts b/.jjconflict-side-2/plugins/sonarqube/src/user.ts new file mode 100644 index 0000000000..0dae43617d --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/src/user.ts @@ -0,0 +1,101 @@ +import type { SonarPaging } from './project.js' +import type { VaultSonarSecret } from './tech.js' +// import type { StepCall, ArchiveProjectExecArgs } from '@cpn-console/hooks' +import { generateRandomPassword } from '@cpn-console/hooks' +import { getAxiosInstance } from './tech.js' + +export interface SonarUser { + login: string + name: string + active: boolean + email: string + groups: string[] + tokensCount: number + local: boolean + externalIdentity: string + externalProvider: string + avatar: string + lastConnectionDate: Date + managed: boolean + sonarLintLastConnectionDate: Date +} + +export async function createUser(username: string, projectSlug: string) { + const axiosInstance = getAxiosInstance() + const fakeEmail = `${projectSlug}@${projectSlug}` + + const newPwd = generateRandomPassword(30) + await axiosInstance({ + url: 'users/create', + method: 'post', + params: { + email: fakeEmail, + local: 'true', + login: username, + name: username, + password: newPwd, + }, + }) + return newPwd +} + +export async function changeToken(username: string) { + const axiosInstance = getAxiosInstance() + await axiosInstance({ + url: 'user_tokens/revoke', + method: 'post', + params: { + login: username, + name: `Sonar Token for ${username}`, + }, + }) + const newToken = await axiosInstance({ + url: 'user_tokens/generate', + method: 'post', + params: { + login: username, + name: `Sonar Token for ${username}`, + }, + }) + return newToken.data.token +} + +export async function getUser(username: string): Promise { + const axiosInstance = getAxiosInstance() + let page = 1 + const pageSize = 100 + while (true) { + const response = await axiosInstance({ + url: 'users/search', + params: { + q: username, + ps: pageSize, + p: page, + }, + }) + const users: { paging: SonarPaging, users: SonarUser[] } = response.data + const found = users.users.find(user => user.login === username) + if (found) return found + if (!users.users.length || users.paging.pageIndex * users.paging.pageSize >= users.paging.total) { + break + } + page += 1 + } +} + +export async function ensureUserExists(username: string, projectSlug: string, vaultUserSecret: VaultSonarSecret | undefined): Promise { + const user = await getUser(username) + if (!user) { + return { + SONAR_PASSWORD: await createUser(username, projectSlug), + SONAR_TOKEN: await changeToken(username), + SONAR_USERNAME: username, + } + } else if (!vaultUserSecret) { + return { + SONAR_PASSWORD: 'not initialized', + SONAR_TOKEN: await changeToken(username), + SONAR_USERNAME: username, + } + } +} diff --git a/.jjconflict-side-2/plugins/sonarqube/tsconfig.eslint.json b/.jjconflict-side-2/plugins/sonarqube/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-2/plugins/sonarqube/tsconfig.json b/.jjconflict-side-2/plugins/sonarqube/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-2/plugins/sonarqube/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-2/plugins/vault/.gitignore b/.jjconflict-side-2/plugins/vault/.gitignore new file mode 100644 index 0000000000..a7c47537dc --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/.gitignore @@ -0,0 +1 @@ +types/ diff --git a/.jjconflict-side-2/plugins/vault/README.md b/.jjconflict-side-2/plugins/vault/README.md new file mode 100644 index 0000000000..bc7c4d1ec9 --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/README.md @@ -0,0 +1,14 @@ +# Introduction + +Plugin de gestion du plugin Vault + +## Configuration + +| Env var | valeur possible | description | +| --------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| VAULT_TOKEN | chaine | Root token ou token ayant les pleins droits sur le Vault | +| VAULT_URL | *url* | Url public d'accès au Vault | +| VAULT_INTERNAL_URL | *url* ou vide | Url par laquelle la console interroge le service Vault, si absent utilisation de l'url public | +| VAULT__HIDE_PROJECT_SERVICE | "true" ou vide | Décide si le plugin masque ou non le service dans l'interface du projet. Ne désactive pas la fonctionnalité du store projet seulement l'affichage. | +| VAULT__DISABLE_VAULT_SECRETS | "true" ou vide | Désactiver le déploiement des ressources nécessaires au Vault Secret Operator dans le namespace cible | +| VAULT__DEPLOY_VAULT_CONNECTION_IN_NS | "true" ou vide | Activer le déploiement du vaultconnections dans le namespace utilisateur, si vide ou faux, s'assure que la ressource n'existe pas | diff --git a/.jjconflict-side-2/plugins/vault/eslint.config.js b/.jjconflict-side-2/plugins/vault/eslint.config.js new file mode 100644 index 0000000000..5a664d2b58 --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/eslint.config.js @@ -0,0 +1,3 @@ +import eslintConfigBase from '@cpn-console/eslint-config' + +export default eslintConfigBase diff --git a/.jjconflict-side-2/plugins/vault/package.json b/.jjconflict-side-2/plugins/vault/package.json new file mode 100644 index 0000000000..b8fb478904 --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/package.json @@ -0,0 +1,47 @@ +{ + "name": "@cpn-console/vault-plugin", + "type": "module", + "version": "2.4.0", + "private": false, + "description": "", + "repository": { + "type": "git", + "url": "https://github.com/cloud-pi-native/console" + }, + "main": "dist/index.js", + "module": "dist/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types" + ], + "scripts": { + "build": "tsc", + "build:clean": "rimraf ./dist ./types ./tsconfig.tsbuildinfo", + "dev": "nodemon --watch src --ext .ts --exec 'pnpm run build'", + "format": "eslint ./ --fix", + "lint": "eslint ./" + }, + "dependencies": { + "@cpn-console/hooks": "workspace:^", + "@cpn-console/logger": "workspace:^", + "@cpn-console/shared": "workspace:^", + "axios": "catalog:runtime" + }, + "devDependencies": { + "@cpn-console/eslint-config": "workspace:^", + "@cpn-console/ts-config": "workspace:^", + "@types/node": "catalog:types", + "@vitest/coverage-v8": "catalog:test", + "nodemon": "catalog:tools", + "rimraf": "catalog:tools", + "typescript": "catalog:build", + "vite": "catalog:build", + "vitest": "catalog:test" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org/", + "access": "public", + "tag": "latest" + } +} diff --git a/.jjconflict-side-2/plugins/vault/src/config.ts b/.jjconflict-side-2/plugins/vault/src/config.ts new file mode 100644 index 0000000000..abce2040b0 --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/src/config.ts @@ -0,0 +1,32 @@ +import { removeTrailingSlash, requiredEnv } from '@cpn-console/shared' + +class Config { + publicUrl: string + internalUrl: string + token: string + projectsRootDir: string + hideProjectService: boolean + disableVaultSecrets: boolean + deployVaultConnectionInNs: boolean + constructor() { + this.token = requiredEnv('VAULT_TOKEN') + this.publicUrl = removeTrailingSlash(requiredEnv('VAULT_URL')) + this.projectsRootDir = requiredEnv('PROJECTS_ROOT_DIR') + this.internalUrl = process.env.VAULT_INTERNAL_URL + ? removeTrailingSlash(process.env.VAULT_INTERNAL_URL) + : this.publicUrl + this.hideProjectService = process.env.VAULT__HIDE_PROJECT_SERVICE === 'true' + this.disableVaultSecrets = process.env.VAULT__DISABLE_VAULT_SECRETS === 'true' + this.deployVaultConnectionInNs = process.env.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS === 'true' + } +} + +let config: Config | undefined + +function getConfig() { + if (!config) { + config = new Config() + } + return config +} +export default getConfig diff --git a/.jjconflict-side-2/plugins/vault/src/functions.ts b/.jjconflict-side-2/plugins/vault/src/functions.ts new file mode 100644 index 0000000000..2e3d98838b --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/src/functions.ts @@ -0,0 +1,112 @@ +import type { + Project, + ProjectLite, + StepCall, + ZoneObject, +} from '@cpn-console/hooks' +import { okStatus } from '@cpn-console/hooks' +import { logger } from './logger.js' + +export const upsertProject: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.Project.upsert() + logger.info({ action: 'upsertProject', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + } + } catch (error) { + logger.error({ action: 'upsertProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const archiveDsoProject: StepCall = async (payload) => { + try { + if (!payload.apis.vault) throw new Error('no Vault available') + await payload.apis.vault.Project.delete() + const allSecrets = await payload.apis.vault.list('/') + const promisesDestroy = allSecrets.map((path) => { + return payload.apis.vault.destroy(path) + }) + await Promise.all(promisesDestroy) + + logger.info({ action: 'archiveDsoProject', projectSlug: payload.args.slug, secretsDestroyed: allSecrets.length }, 'Hook done') + return { + status: { + result: 'OK', + }, + secretsDestroyed: allSecrets.length, + } + } catch (error) { + logger.error({ action: 'archiveDsoProject', projectSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} + +export const getSecrets: StepCall = async (payload) => { + logger.debug({ action: 'getSecrets', projectSlug: payload.args.slug }, 'Hook done') + return { + status: { + result: 'OK', + }, + secrets: { + '.spec.mount': `${payload.args.slug}`, + '.spec.vaultAuthRef': 'vault-auth', + }, + } +} +export const upsertZone: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.upsert() + logger.info({ action: 'upsertZone', zoneSlug: payload.args.slug }, 'Hook done') + return okStatus + } catch (error) { + logger.error({ action: 'upsertZone', zoneSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} +export const deleteZone: StepCall = async (payload) => { + try { + if (!payload.apis.vault) { + throw new Error('no Vault available') + } + await payload.apis.vault.delete() + logger.info({ action: 'deleteZone', zoneSlug: payload.args.slug }, 'Hook done') + return okStatus + } catch (error) { + logger.error({ action: 'deleteZone', zoneSlug: payload.args.slug, err: error }, 'Hook failed') + return { + error, + status: { + result: 'KO', + message: 'An unexpected error occured', + }, + } + } +} diff --git a/.jjconflict-side-2/plugins/vault/src/index.ts b/.jjconflict-side-2/plugins/vault/src/index.ts new file mode 100644 index 0000000000..e73ba4ab91 --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/src/index.ts @@ -0,0 +1,59 @@ +import type { ClusterObject, DefaultArgs, Plugin, Project, ProjectLite, ZoneObject } from '@cpn-console/hooks' +import { archiveDsoProject, deleteZone, getSecrets, upsertProject, upsertZone } from './functions.js' +import infos from './infos.js' +import monitor from './monitor.js' +import { VaultProjectApi } from './vault-project-api.js' +import { VaultZoneApi } from './vault-zone-api.js' + +const onlyApi = { api: (project: ProjectLite) => new VaultProjectApi(project) } + +export const plugin: Plugin = { + infos, + subscribedHooks: { + getProjectSecrets: { + ...onlyApi, + steps: { + main: getSecrets, + }, + }, + upsertProject: { + ...onlyApi, + steps: { + main: upsertProject, + }, + }, + deleteProject: { + ...onlyApi, + steps: { post: archiveDsoProject }, // Destroy all secrets for project + }, + upsertZone: { + api: (zone: ZoneObject) => new VaultZoneApi(zone.slug), + steps: { + pre: upsertZone, + }, + }, + deleteZone: { + api: (zone: ZoneObject) => new VaultZoneApi(zone.slug), + steps: { + main: deleteZone, + }, + }, + upsertCluster: { + api: (c: ClusterObject) => new VaultZoneApi(c.zone.slug), + }, + deleteCluster: { + api: (c: ClusterObject) => new VaultZoneApi(c.zone.slug), + }, + }, + monitor, +} + +declare module '@cpn-console/hooks' { + interface HookPayloadApis { + vault: Args extends (ProjectLite | Project) + ? VaultProjectApi + : Args extends (ZoneObject | ClusterObject) + ? VaultZoneApi + : never + } +} diff --git a/.jjconflict-side-2/plugins/vault/src/infos.ts b/.jjconflict-side-2/plugins/vault/src/infos.ts new file mode 100644 index 0000000000..976f1f4218 --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/src/infos.ts @@ -0,0 +1,14 @@ +import type { ServiceInfos } from '@cpn-console/hooks' +import getConfig from './config.js' + +const infos: ServiceInfos = { + name: 'vault', + to: ({ project }) => getConfig().hideProjectService + ? undefined + : `${getConfig().publicUrl}/ui/vault/secrets/${project.slug}`, + title: 'Vault', + imgSrc: '/img/vault.svg', + description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', +} + +export default infos diff --git a/.jjconflict-side-2/plugins/vault/src/logger.ts b/.jjconflict-side-2/plugins/vault/src/logger.ts new file mode 100644 index 0000000000..f596e24a6f --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/src/logger.ts @@ -0,0 +1,4 @@ +import type { Logger } from '@cpn-console/logger' +import { logger as baseLogger } from '@cpn-console/logger' + +export const logger: Logger = baseLogger.child({ plugin: 'vault' }) diff --git a/.jjconflict-side-2/plugins/vault/src/monitor.ts b/.jjconflict-side-2/plugins/vault/src/monitor.ts new file mode 100644 index 0000000000..091b850649 --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/src/monitor.ts @@ -0,0 +1,45 @@ +import type { MonitorInfos } from '@cpn-console/shared' +import { Monitor, MonitorStatus } from '@cpn-console/shared' +import axios from 'axios' +import getConfig from './config.js' + +const vaultStatusCode = [200, 429, 472, 473, 501, 503] + +type VaultRes = { + initialized: boolean + sealed: boolean + standby: boolean + performance_standby: boolean + replication_performance_mode: string + replication_dr_mode: string + server_time_utc: number + version: string +} | undefined + +async function monitor(instance: Monitor): Promise { + instance.lastStatus.lastUpdateTimestamp = Date.now() + try { + const res = await axios.get(`${getConfig().internalUrl}/v1/sys/health?standbyok=true`, { + headers: { + 'X-Vault-Token': getConfig().token, + }, + validateStatus: res => vaultStatusCode.includes(res), + }) + if (res.status === 200) { + instance.lastStatus.status = MonitorStatus.OK + instance.lastStatus.message = MonitorStatus.OK + } else { + instance.lastStatus.status = MonitorStatus.ERROR + const data = res.data as VaultRes + instance.lastStatus.message = data?.sealed ? 'Le Vault est scellé' : 'Vault en erreur' + } + } catch (error) { + instance.lastStatus.message = 'Error lors la requete' + instance.lastStatus.status = MonitorStatus.UNKNOW + instance.lastStatus.cause = error + // test + } + return instance.lastStatus +} + +export default new Monitor(monitor) diff --git a/.jjconflict-side-2/plugins/vault/src/utils.ts b/.jjconflict-side-2/plugins/vault/src/utils.ts new file mode 100644 index 0000000000..d7036913f4 --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/src/utils.ts @@ -0,0 +1,21 @@ +import type { AxiosInstance } from 'axios' + +export async function getAuthMethod(axiosInstance: AxiosInstance, token: string) { + const response = await axiosInstance({ + method: 'get', + url: '/v1/sys/auth', + headers: { 'X-Vault-Token': token }, + }) + return response.data +} + +export const minimumConfig = { + type: 'kv', + options: { + version: 2, + }, +} +export function generateKVConfigUpdate(config: Record): typeof minimumConfig | void { + if (config?.type !== minimumConfig.type) return minimumConfig + if (config?.options?.version !== minimumConfig.options.version) return minimumConfig +} diff --git a/.jjconflict-side-2/plugins/vault/src/vault-api.ts b/.jjconflict-side-2/plugins/vault/src/vault-api.ts new file mode 100644 index 0000000000..a09cc7c338 --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/src/vault-api.ts @@ -0,0 +1,165 @@ +import type { AxiosInstance } from 'axios' +import { PluginApi } from '@cpn-console/hooks' +import axios from 'axios' +import getConfig from './config.js' +import { + generateKVConfigUpdate, +} from './utils.js' + +export interface AppRoleCredentials { + url: string + coreKvName: string + roleId: string + secretId: string +} + +export abstract class VaultApi extends PluginApi { + protected readonly axios: AxiosInstance + private token: string | undefined = undefined + + constructor() { + super() + this.axios = axios.create({ + baseURL: getConfig().internalUrl, + headers: { + 'X-Vault-Token': getConfig().token, + }, + }) + } + + protected async getToken() { + if (!this.token) { + this.token = (await this.axios.post('/v1/auth/token/create')).data.auth + .client_token as string + } + return this.token + } + + protected async destroy(path: string, kvName: string) { + if (path.startsWith('/')) path = path.slice(1) + return await this.axios({ + method: 'delete', + url: `/v1/${kvName}/metadata/${path}`, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + } + + Kv = { + upsert: async (kvName: string) => { + const token = await this.getToken() + const kvRes = await this.axios({ + method: 'get', + url: `/v1/sys/mounts/${kvName}/tune`, + headers: { 'X-Vault-Token': token }, + validateStatus: code => [400, 200].includes(code), + }) + if (kvRes.status === 400) { + await this.axios({ + method: 'post', + url: `/v1/sys/mounts/${kvName}`, + headers: { + 'X-Vault-Token': token, + }, + data: { + type: 'kv', + config: { + force_no_cache: true, + }, + options: { + version: 2, + }, + }, + }) + } else { + // means 200 status + const configUpdate = generateKVConfigUpdate(kvRes.data) + if (configUpdate) { + await this.axios({ + method: 'put', + url: `/v1/sys/mounts/${kvName}/tune`, + headers: { + 'X-Vault-Token': token, + }, + data: configUpdate, + }) + } + } + }, + delete: async (kvName: string) => { + const token = await this.getToken() + return await this.axios({ + method: 'delete', + url: `/v1/sys/mounts/${kvName}`, + headers: { 'X-Vault-Token': token }, + validateStatus: code => [400, 200, 204].includes(code), + }) + }, + } + + Policy = { + upsert: async (policyName: string, policy: string) => { + await this.axios({ + method: 'put', + url: `/v1/sys/policies/acl/${policyName}`, + data: { policy }, + headers: { + 'X-Vault-Token': await this.getToken(), + 'Content-Type': 'application/json', + }, + }) + }, + delete: async (policyName: string) => { + await this.axios({ + method: 'delete', + url: `/v1/sys/policies/acl/${policyName}`, + headers: { + 'X-Vault-Token': await this.getToken(), + 'Content-Type': 'application/json', + }, + }) + }, + } + + Role = { + upsert: async (roleName: string, policies: string[]) => { + await this.axios({ + method: 'post', + url: `/v1/auth/approle/role/${roleName}`, + data: { + secret_id_num_uses: '0', + secret_id_ttl: '0', + token_max_ttl: '0', + token_num_uses: '0', + token_ttl: '0', + token_type: 'batch', + token_policies: policies, + }, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + delete: async (roleName: string) => { + await this.axios.delete(`/v1/auth/approle/role/${roleName}`, { + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + getCredentials: async (roleName: string) => { + const { data: dataRole } = await this.axios.get( + `/v1/auth/approle/role/${roleName}/role-id`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + const { data: dataSecret } = await this.axios.put( + `/v1/auth/approle/role/${roleName}/secret-id`, + 'null', // Force empty data + { + headers: { 'X-Vault-Token': await this.getToken() }, + }, + ) + return { + roleId: dataRole.data?.role_id, + secretId: dataSecret.data?.secret_id, + } + }, + } +} diff --git a/.jjconflict-side-2/plugins/vault/src/vault-project-api.ts b/.jjconflict-side-2/plugins/vault/src/vault-project-api.ts new file mode 100644 index 0000000000..45ec6280c9 --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/src/vault-project-api.ts @@ -0,0 +1,207 @@ +import type { ProjectLite } from '@cpn-console/hooks' +import getConfig from './config.js' +import { + getAuthMethod, +} from './utils.js' +import { VaultApi } from './vault-api.js' + +interface ReadOptions { + throwIfNoEntry: boolean +} +export interface AppRoleCredentials { + url: string + coreKvName: string + roleId: string + secretId: string +} + +interface VaultValuesWithoutCredentials { + /** Slash-separated directory (root node of all Gitlab projects) */ + projectsRootDir: string +} + +type VaultValues = AppRoleCredentials & VaultValuesWithoutCredentials + +export class VaultProjectApi extends VaultApi { + private readonly basePath: string + private readonly roleName: string + private readonly projectRootDir: string + private readonly defaultAppRoleCredentials: AppRoleCredentials + private readonly coreKvName: string = 'forge-dso' + private readonly projectKvName: string + private readonly groupName: string + private readonly policyName: { + techRO: string + appFull: string + } + + constructor(project: ProjectLite) { + super() + this.basePath = project.slug + this.roleName = project.slug + this.projectRootDir = getConfig().projectsRootDir + this.projectKvName = project.slug + this.groupName = project.slug + this.policyName = { + techRO: `tech--${project.slug}--ro`, + appFull: `app--${project.slug}--admin`, + } + this.defaultAppRoleCredentials = { + url: getConfig().deployVaultConnectionInNs ? getConfig().publicUrl : '', + coreKvName: this.coreKvName, + roleId: 'none', + secretId: 'none', + } + } + + public async list(path: string = '/'): Promise { + if (!path.startsWith('/')) path = `/${path}` + + const listSecretPath: string[] = [] + const response = await this.axios({ + url: `/v1/${this.coreKvName}/metadata/${this.projectRootDir}/${this.basePath}${path}/`, + headers: { + 'X-Vault-Token': await this.getToken(), + }, + method: 'list', + validateStatus: code => [200, 404].includes(code), + }) + + if (response.status === 404) return listSecretPath + for (const key of response.data.data.keys) { + if (key.endsWith('/')) { + const subSecrets = await this.list( + `${path.substring(this.basePath.length)}/${key}`, + ) + subSecrets.forEach((secret) => { + listSecretPath.push(`${key}${secret}`) + }) + } else { + listSecretPath.push(`/${key}`) + } + } + return listSecretPath.flat() + } + + public async read( + path: string = '/', + options: ReadOptions = { throwIfNoEntry: true }, + ) { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.get( + `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + validateStatus: status => + (options.throwIfNoEntry ? [200] : [200, 404]).includes(status), + }, + ) + return response.data.data + } + + public async write(body: object, path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.post( + `/v1/${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/${path}`, + { + headers: { 'X-Vault-Token': await this.getToken() }, + data: body, + }, + ) + return await response.data + } + + public async destroy(path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + return super.destroy( + `${this.projectRootDir}/${this.basePath}/${path}`, + this.coreKvName, + ) + } + + Project = { + upsert: async () => { + await this.Kv.upsert(this.projectKvName) + await this.Policy.upsert( + this.policyName.appFull, + `path "${this.projectKvName}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + ) + await this.Policy.upsert( + this.policyName.techRO, + `path "${this.coreKvName}/data/${this.projectRootDir}/${this.basePath}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + ) + await this.Group.upsert() + await this.Role.upsert(this.roleName, [ + this.policyName.techRO, + this.policyName.appFull, + ]) + }, + delete: async () => { + await this.Kv.delete(this.projectKvName) + await this.Policy.delete(this.policyName.appFull) + await this.Policy.delete(this.policyName.techRO) + await this.Group.delete() + await this.Role.delete(this.roleName) + }, + getCredentials: async (): Promise => { + const creds = await this.Role.getCredentials(this.roleName) + return { + ...this.defaultAppRoleCredentials, + ...creds, + } + }, + getValues: async (): Promise => { + return { + projectsRootDir: getConfig().projectsRootDir, + ...(await this.Project.getCredentials()), + } + }, + } + + Group = { + upsert: async () => { + // TODO check api responses for POST and GET maybe duplicates + await this.axios({ + method: 'post', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + data: { + name: this.groupName, + type: 'external', + policies: [this.policyName.appFull], + }, + }) + + const response = await this.axios({ + method: 'get', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + validateStatus: code => [404, 200].includes(code), + }) + const group = response.data + const groupAliasName = `/${this.groupName}` + if (group.data.alias?.name === groupAliasName) { + return + } + const methods = await getAuthMethod(this.axios, await this.getToken()) + + await this.axios({ + url: `/v1/identity/group-alias`, + method: 'post', + headers: { 'X-Vault-Token': await this.getToken() }, + data: { + name: groupAliasName, + mount_accessor: methods['oidc/'].accessor, + canonical_id: group.data.id, + }, + }) + }, + delete: async () => { + await this.axios({ + method: 'delete', + url: `/v1/identity/group/name/${this.groupName}`, + headers: { 'X-Vault-Token': await this.getToken() }, + }) + }, + } +} diff --git a/.jjconflict-side-2/plugins/vault/src/vault-zone-api.ts b/.jjconflict-side-2/plugins/vault/src/vault-zone-api.ts new file mode 100644 index 0000000000..378d393083 --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/src/vault-zone-api.ts @@ -0,0 +1,63 @@ +import getConfig from './config.js' +import { VaultApi } from './vault-api.js' + +interface VaultCredentials { + url: string + kvName: string + roleId: string + secretId: string +} +type VaultValues = VaultCredentials + +export class VaultZoneApi extends VaultApi { + private readonly kvName: string + private readonly policyName: string + private readonly roleName: string + constructor(name: string) { + super() + this.kvName = `zone-${name}` + this.policyName = `tech--${this.kvName}--ro` + this.roleName = `zone-${name}` + } + + public async upsert() { + await this.Kv.upsert(this.kvName) + await this.Policy.upsert( + this.policyName, + `path "${this.kvName}/*" { capabilities = ["read"] }`, + ) + await this.Role.upsert(this.roleName, [this.policyName]) + } + + public async delete() { + await this.Kv.delete(this.kvName) + await this.Policy.delete(this.policyName) + await this.Role.delete(this.roleName) + } + + public async write(body: object, path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + const response = await this.axios.post(`/v1/${this.kvName}/data/${path}`, { + headers: { 'X-Vault-Token': await this.getToken() }, + data: body, + }) + return await response.data + } + + public async destroy(path: string = '/') { + if (path.startsWith('/')) path = path.slice(1) + return super.destroy(path, this.kvName) + } + + public async getCredentials(): Promise { + return { + url: getConfig().publicUrl, + kvName: this.kvName, + ...(await this.Role.getCredentials(this.roleName)), + } as VaultCredentials + } + + public async getValues(): Promise { + return this.getCredentials() + } +} diff --git a/.jjconflict-side-2/plugins/vault/src/vso.ts b/.jjconflict-side-2/plugins/vault/src/vso.ts new file mode 100644 index 0000000000..579eb8d8ff --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/src/vso.ts @@ -0,0 +1,55 @@ +import type { AppRoleCredentials } from './vault-project-api.js' + +export function generateVsoVaultConnection(creds: AppRoleCredentials) { + return { + apiVersion: 'secrets.hashicorp.com/v1beta1', + kind: 'VaultConnection', + metadata: { + name: 'default', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + spec: { + address: creds.url, + }, + } +} + +export function generateVsoSecret(creds: AppRoleCredentials) { + return { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: 'vso-approle', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + stringData: { + id: creds.secretId, + }, + } +} +export function generateVaultAuth(creds: AppRoleCredentials, vaultConnectionRef: string | null = null) { + return { + apiVersion: 'secrets.hashicorp.com/v1beta1', + kind: 'VaultAuth', + metadata: { + name: 'vault-auth', + labels: { + 'app.kubernetes.io/managed-by': 'dso-console', + }, + }, + spec: { + vaultConnectionRef, + method: 'appRole', + mount: 'approle', + appRole: { + roleId: creds.roleId, + secretRef: 'vso-approle', + }, + allowedNamespaces: null, + }, + } +} diff --git a/.jjconflict-side-2/plugins/vault/tsconfig.eslint.json b/.jjconflict-side-2/plugins/vault/tsconfig.eslint.json new file mode 100644 index 0000000000..982c60cf77 --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/tsconfig.eslint.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "./**/*.ts", + "./**/*.js", + "./**/*.cjs", + "./**/*.mjs" + ], + "exclude": [ + "./node_modules", + "./dist", + "./types", + "./coverage", + "./**/*.d.ts" + ] +} diff --git a/.jjconflict-side-2/plugins/vault/tsconfig.json b/.jjconflict-side-2/plugins/vault/tsconfig.json new file mode 100644 index 0000000000..2b8147fce6 --- /dev/null +++ b/.jjconflict-side-2/plugins/vault/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": [ + "@cpn-console/ts-config/tsconfig.base.json" + ], + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./src", + "paths": { + "@/*": ["src/*"] + }, + "declarationDir": "./types", + "outDir": "./dist" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js" + ], + "exclude": [ + "./src/**/*.spec.ts", + "./src/**/__mocks__" + ] +} diff --git a/.jjconflict-side-2/pnpm-lock.yaml b/.jjconflict-side-2/pnpm-lock.yaml new file mode 100644 index 0000000000..4265e63be7 --- /dev/null +++ b/.jjconflict-side-2/pnpm-lock.yaml @@ -0,0 +1,18500 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + injectWorkspacePackages: true + +catalogs: + build: + '@iconify/types': + specifier: ^2.0.0 + version: 2.0.0 + '@nestjs/cli': + specifier: ^11.0.16 + version: 11.0.16 + '@nestjs/schematics': + specifier: ^11.0.9 + version: 11.0.9 + '@nestjs/testing': + specifier: ^11.1.16 + version: 11.1.16 + '@unocss/transformer-directives': + specifier: ^0.65.4 + version: 0.65.4 + '@vitejs/plugin-vue': + specifier: ^6.0.4 + version: 6.0.4 + '@vue/tsconfig': + specifier: ^0.7.0 + version: 0.7.0 + fastify-plugin: + specifier: ^5.1.0 + version: 5.1.0 + swagger-typescript-api: + specifier: ^13.3.1 + version: 13.3.1 + ts-patch: + specifier: ^3.3.0 + version: 3.3.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-transform-paths: + specifier: ^3.5.6 + version: 3.5.6 + unocss: + specifier: ^66.6.6 + version: 66.6.6 + unplugin-auto-import: + specifier: ^0.18.6 + version: 0.18.6 + unplugin-vue-components: + specifier: ^0.27.5 + version: 0.27.5 + vite: + specifier: ^7.3.2 + version: 7.3.3 + vite-node: + specifier: ^6.0.0 + version: 6.0.0 + vite-plugin-pwa: + specifier: ^1.2.0 + version: 1.2.0 + vue-tsc: + specifier: ^2.2.12 + version: 2.2.12 + workbox-window: + specifier: ^7.4.0 + version: 7.4.0 + otel: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@opentelemetry/auto-instrumentations-node': + specifier: ^0.70.1 + version: 0.70.1 + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0 + '@opentelemetry/exporter-trace-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0 + '@opentelemetry/instrumentation-nestjs-core': + specifier: ^0.58.0 + version: 0.58.0 + '@opentelemetry/instrumentation-pino': + specifier: ^0.59.0 + version: 0.59.0 + '@opentelemetry/sdk-metrics': + specifier: ^2.5.1 + version: 2.6.0 + '@opentelemetry/sdk-node': + specifier: ^0.212.0 + version: 0.212.0 + runtime: + '@fastify/cookie': + specifier: ^11.0.2 + version: 11.0.2 + '@fastify/helmet': + specifier: ^13.0.2 + version: 13.0.2 + '@fastify/otel': + specifier: ^0.18.1 + version: 0.18.1 + '@fastify/session': + specifier: ^11.1.1 + version: 11.1.1 + '@fastify/swagger': + specifier: ^9.7.0 + version: 9.7.0 + '@fastify/swagger-ui': + specifier: ^5.2.6 + version: 5.2.6 + '@gitbeaker/core': + specifier: ^40.6.0 + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: ^40.6.0 + version: 40.6.0 + '@gitbeaker/rest': + specifier: ^40.6.0 + version: 40.6.0 + '@gouvfr/dsfr': + specifier: ^1.14.4 + version: 1.14.4 + '@gouvminint/vue-dsfr': + specifier: ^8.15.0 + version: 8.15.0 + '@himenon/argocd-typescript-openapi': + specifier: ^1.2.2 + version: 1.2.2 + '@iconify-json/ri': + specifier: ^1.2.10 + version: 1.2.10 + '@iconify/vue': + specifier: ^4.3.0 + version: 4.3.0 + '@keycloak/keycloak-admin-client': + specifier: ^26.5.5 + version: 26.7.0 + '@kubernetes-models/argo-cd': + specifier: ^2.7.2 + version: 2.7.2 + '@nestjs/cache-manager': + specifier: ^3.1.2 + version: 3.1.3 + '@nestjs/common': + specifier: ^11.1.16 + version: 11.1.16 + '@nestjs/config': + specifier: ^4.0.3 + version: 4.0.3 + '@nestjs/core': + specifier: ^11.1.16 + version: 11.1.16 + '@nestjs/event-emitter': + specifier: ^3.0.1 + version: 3.0.1 + '@nestjs/jwt': + specifier: ^11.0.2 + version: 11.0.2 + '@nestjs/platform-fastify': + specifier: ^11.1.16 + version: 11.1.27 + '@nestjs/schedule': + specifier: ^6.1.1 + version: 6.1.1 + '@nestjs/swagger': + specifier: ^11.4.1 + version: 11.4.1 + '@nestjs/terminus': + specifier: ^11.1.1 + version: 11.1.1 + '@prisma/client': + specifier: ^6.19.2 + version: 6.19.2 + '@ts-rest/core': + specifier: ^3.52.1 + version: 3.52.1 + '@ts-rest/fastify': + specifier: ^3.52.1 + version: 3.52.1 + '@ts-rest/open-api': + specifier: ^3.52.1 + version: 3.52.1 + axios: + specifier: ^1.15.1 + version: 1.16.0 + bytes: + specifier: ^3.1.2 + version: 3.1.2 + cache-manager: + specifier: ^7.2.8 + version: 7.2.8 + cron-validator: + specifier: ^1.4.0 + version: 1.4.0 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + fastify: + specifier: ^5.8.5 + version: 5.8.5 + fastify-keycloak-adapter: + specifier: ^3.0.0 + version: 3.0.2 + javascript-time-ago: + specifier: ^2.6.4 + version: 2.6.4 + json-2-csv: + specifier: ^5.5.10 + version: 5.5.10 + json-schema: + specifier: ^0.4.0 + version: 0.4.0 + jszip: + specifier: ^3.10.1 + version: 3.10.1 + keycloak-js: + specifier: ^26.2.3 + version: 26.2.3 + mustache: + specifier: ^4.2.0 + version: 4.2.0 + nanoid: + specifier: 5.0.9 + version: 5.0.9 + nestjs-pino: + specifier: ^4.6.0 + version: 4.6.0 + p-debounce: + specifier: ^4.0.0 + version: 4.0.0 + pinia: + specifier: ^2.3.1 + version: 2.3.1 + prisma: + specifier: ^6.19.2 + version: 6.19.2 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + short-uuid: + specifier: ^5.2.0 + version: 5.2.0 + undici: + specifier: ^7.24.0 + version: 7.24.5 + vitest-mock-extended: + specifier: ^2.0.2 + version: 2.0.2 + vue: + specifier: ^3.5.30 + version: 3.5.30 + vue-router: + specifier: ^4.6.4 + version: 4.6.4 + vue3-json-viewer: + specifier: ^2.4.1 + version: 2.4.1 + xbytes: + specifier: ^1.9.1 + version: 1.9.1 + yaml: + specifier: ^2.7.1 + version: 2.9.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 + zod-validation-error: + specifier: ^3.5.4 + version: 3.5.4 + test: + '@faker-js/faker': + specifier: ^9.9.0 + version: 9.9.0 + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 + '@vitest/coverage-v8': + specifier: ^4.1.5 + version: 4.1.5 + globals: + specifier: ^16.5.0 + version: 16.5.0 + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + msw: + specifier: ^2.12.10 + version: 2.12.10 + vitest: + specifier: ^4.1.5 + version: 4.1.5 + tools: + '@antfu/eslint-config': + specifier: ^8.2.0 + version: 8.2.0 + '@commitlint/cli': + specifier: ^20.5.3 + version: 20.5.3 + '@commitlint/config-conventional': + specifier: ^20.5.3 + version: 20.5.3 + '@eslint/eslintrc': + specifier: ^3.3.5 + version: 3.3.5 + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 + '@vue/eslint-config-typescript': + specifier: ^14.7.0 + version: 14.7.0 + chalk: + specifier: ^5.6.2 + version: 5.6.2 + eslint: + specifier: ^10.5.0 + version: 10.5.0 + eslint-plugin-vue: + specifier: ^10.8.0 + version: 10.8.0 + husky: + specifier: ^9.1.7 + version: 9.1.7 + js-yaml: + specifier: ^4.1.0 + version: 4.2.0 + lint-staged: + specifier: ^16.4.0 + version: 16.4.0 + nodemon: + specifier: ^3.1.14 + version: 3.1.14 + pino: + specifier: ^9.14.0 + version: 9.14.0 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + rimraf: + specifier: ^6.1.3 + version: 6.1.3 + stylelint: + specifier: ^17.11.0 + version: 17.11.0 + stylelint-config-html: + specifier: ^1.1.0 + version: 1.1.0 + stylelint-config-recommended-vue: + specifier: ^1.6.1 + version: 1.6.1 + stylelint-config-standard: + specifier: ^40.0.0 + version: 40.0.0 + typescript-eslint: + specifier: ^8.57.0 + version: 8.57.0 + vue-eslint-parser: + specifier: ^10.4.0 + version: 10.4.0 + types: + '@types/jsdom': + specifier: ^21.1.7 + version: 21.1.7 + '@types/json-schema': + specifier: ^7.0.15 + version: 7.0.15 + '@types/node': + specifier: ^24.12.0 + version: 24.12.0 + undici-types: + specifier: ^7.16.0 + version: 7.16.0 + +importers: + + .: + devDependencies: + '@commitlint/cli': + specifier: catalog:tools + version: 20.5.3(@types/node@24.12.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3) + '@commitlint/config-conventional': + specifier: catalog:tools + version: 20.5.3 + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:packages/eslintconfig + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + husky: + specifier: catalog:tools + version: 9.1.7 + lint-staged: + specifier: catalog:tools + version: 16.4.0 + + apps/client: + dependencies: + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@gouvfr/dsfr': + specifier: catalog:runtime + version: 1.14.4 + '@gouvminint/vue-dsfr': + specifier: catalog:runtime + version: 8.15.0(@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3)))(vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)) + '@iconify-json/ri': + specifier: catalog:runtime + version: 1.2.10 + '@iconify/vue': + specifier: catalog:runtime + version: 4.3.0(vue@3.5.30(typescript@5.9.3)) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@vue/tsconfig': + specifier: catalog:build + version: 0.7.0(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + axios: + specifier: catalog:runtime + version: 1.16.0 + javascript-time-ago: + specifier: catalog:runtime + version: 2.6.4 + jszip: + specifier: catalog:runtime + version: 3.10.1 + keycloak-js: + specifier: catalog:runtime + version: 26.2.3 + nanoid: + specifier: catalog:runtime + version: 5.0.9 + p-debounce: + specifier: catalog:runtime + version: 4.0.0 + pinia: + specifier: catalog:runtime + version: 2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) + vue: + specifier: catalog:runtime + version: 3.5.30(typescript@5.9.3) + vue-router: + specifier: catalog:runtime + version: 4.6.4(vue@3.5.30(typescript@5.9.3)) + vue3-json-viewer: + specifier: catalog:runtime + version: 2.4.1(vue@3.5.30(typescript@5.9.3)) + xbytes: + specifier: catalog:runtime + version: 1.9.1 + yaml: + specifier: catalog:runtime + version: 2.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@iconify/types': + specifier: catalog:build + version: 2.0.0 + '@types/jsdom': + specifier: catalog:types + version: 21.1.7 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@unocss/transformer-directives': + specifier: catalog:build + version: 0.65.4 + '@vitejs/plugin-vue': + specifier: catalog:build + version: 6.0.4(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3)) + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + '@vue/eslint-config-typescript': + specifier: catalog:tools + version: 14.7.0(eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + chalk: + specifier: catalog:tools + version: 5.6.2 + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + eslint-plugin-vue: + specifier: catalog:tools + version: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + jsdom: + specifier: catalog:test + version: 25.0.1 + rimraf: + specifier: catalog:tools + version: 6.1.3 + stylelint: + specifier: catalog:tools + version: 17.11.0(typescript@5.9.3) + stylelint-config-html: + specifier: catalog:tools + version: 1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-recommended-vue: + specifier: catalog:tools + version: 1.6.1(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-standard: + specifier: catalog:tools + version: 40.0.0(stylelint@17.11.0(typescript@5.9.3)) + typescript: + specifier: catalog:build + version: 5.9.3 + unocss: + specifier: catalog:build + version: 66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + unplugin-auto-import: + specifier: catalog:build + version: 0.18.6(rollup@2.80.0) + unplugin-vue-components: + specifier: catalog:build + version: 0.27.5(@babel/parser@7.29.7)(rollup@2.80.0)(vue@3.5.30(typescript@5.9.3)) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vite-plugin-pwa: + specifier: catalog:build + version: 1.2.0(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + vue-eslint-parser: + specifier: catalog:tools + version: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + vue-tsc: + specifier: catalog:build + version: 2.2.12(typescript@5.9.3) + workbox-window: + specifier: catalog:build + version: 7.4.0 + + apps/server: + dependencies: + '@cpn-console/argocd-plugin': + specifier: workspace:^ + version: link:../../plugins/argocd + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../../plugins/gitlab + '@cpn-console/harbor-plugin': + specifier: workspace:^ + version: link:../../plugins/harbor + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../../plugins/keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/nexus-plugin': + specifier: workspace:^ + version: link:../../plugins/nexus + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/sonarqube-plugin': + specifier: workspace:^ + version: link:../../plugins/sonarqube + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../../plugins/vault + '@fastify/cookie': + specifier: catalog:runtime + version: 11.0.2 + '@fastify/helmet': + specifier: catalog:runtime + version: 13.0.2 + '@fastify/otel': + specifier: catalog:runtime + version: 0.18.1(@opentelemetry/api@1.9.1) + '@fastify/session': + specifier: catalog:runtime + version: 11.1.1 + '@fastify/swagger': + specifier: catalog:runtime + version: 9.7.0 + '@fastify/swagger-ui': + specifier: catalog:runtime + version: 5.2.6 + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + '@kubernetes-models/argo-cd': + specifier: catalog:runtime + version: 2.7.2 + '@opentelemetry/auto-instrumentations-node': + specifier: catalog:otel + version: 0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': + specifier: catalog:otel + version: 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': + specifier: catalog:otel + version: 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: catalog:otel + version: 0.212.0(@opentelemetry/api@1.9.1) + '@prisma/client': + specifier: catalog:runtime + version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@ts-rest/fastify': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76) + '@ts-rest/open-api': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76) + axios: + specifier: catalog:runtime + version: 1.16.0 + date-fns: + specifier: catalog:runtime + version: 4.1.0 + fastify: + specifier: catalog:runtime + version: 5.8.5 + fastify-keycloak-adapter: + specifier: catalog:runtime + version: 3.0.2 + json-2-csv: + specifier: catalog:runtime + version: 5.5.10 + mustache: + specifier: catalog:runtime + version: 4.2.0 + prisma: + specifier: catalog:runtime + version: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + undici: + specifier: catalog:runtime + version: 7.24.5 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + fastify-plugin: + specifier: catalog:build + version: 5.1.0 + nodemon: + specifier: catalog:tools + version: 3.1.14 + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + rimraf: + specifier: catalog:tools + version: 6.1.3 + ts-patch: + specifier: catalog:build + version: 3.3.0 + typescript: + specifier: catalog:build + version: 5.9.3 + typescript-transform-paths: + specifier: catalog:build + version: 3.5.6(typescript@5.9.3) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + apps/server-nestjs: + dependencies: + '@cpn-console/argocd-plugin': + specifier: workspace:^ + version: link:../../plugins/argocd + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../../plugins/gitlab + '@cpn-console/harbor-plugin': + specifier: workspace:^ + version: link:../../plugins/harbor + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../../plugins/keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/nexus-plugin': + specifier: workspace:^ + version: link:../../plugins/nexus + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/sonarqube-plugin': + specifier: workspace:^ + version: link:../../plugins/sonarqube + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../../plugins/vault + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + '@keycloak/keycloak-admin-client': + specifier: catalog:runtime + version: 26.7.0 + '@kubernetes-models/argo-cd': + specifier: catalog:runtime + version: 2.7.2 + '@nestjs/cache-manager': + specifier: catalog:runtime + version: 3.1.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2) + '@nestjs/common': + specifier: catalog:runtime + version: 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: catalog:runtime + version: 4.0.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: catalog:runtime + version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter': + specifier: catalog:runtime + version: 3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/jwt': + specifier: catalog:runtime + version: 11.0.2(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/platform-fastify': + specifier: catalog:runtime + version: 11.1.27(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/schedule': + specifier: catalog:runtime + version: 6.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/swagger': + specifier: catalog:runtime + version: 11.4.1(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2) + '@nestjs/terminus': + specifier: catalog:runtime + version: 11.1.1(@grpc/grpc-js@1.14.3)(@grpc/proto-loader@0.8.0)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@opentelemetry/api': + specifier: catalog:otel + version: 1.9.1 + '@opentelemetry/auto-instrumentations-node': + specifier: catalog:otel + version: 0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: catalog:otel + version: 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': + specifier: catalog:otel + version: 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': + specifier: catalog:otel + version: 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': + specifier: catalog:otel + version: 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: catalog:otel + version: 0.212.0(@opentelemetry/api@1.9.1) + '@prisma/client': + specifier: catalog:runtime + version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + '@ts-rest/fastify': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76) + '@ts-rest/open-api': + specifier: catalog:runtime + version: 3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76) + cache-manager: + specifier: catalog:runtime + version: 7.2.8 + date-fns: + specifier: catalog:runtime + version: 4.1.0 + fastify: + specifier: catalog:runtime + version: 5.8.5 + fastify-keycloak-adapter: + specifier: catalog:runtime + version: 3.0.2 + json-2-csv: + specifier: catalog:runtime + version: 5.5.10 + mustache: + specifier: catalog:runtime + version: 4.2.0 + nestjs-pino: + specifier: catalog:runtime + version: 4.6.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) + prisma: + specifier: catalog:runtime + version: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + reflect-metadata: + specifier: catalog:runtime + version: 0.2.2 + rxjs: + specifier: catalog:runtime + version: 7.8.2 + undici: + specifier: catalog:runtime + version: 7.24.5 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + yaml: + specifier: catalog:runtime + version: 2.9.0 + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/test-utils': + specifier: workspace:^ + version: link:../../packages/test-utils + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@eslint/eslintrc': + specifier: catalog:tools + version: 3.3.5 + '@eslint/js': + specifier: catalog:tools + version: 9.39.4 + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@nestjs/cli': + specifier: catalog:build + version: 11.0.16(@types/node@24.12.0)(esbuild@0.27.3)(lightningcss@1.32.0) + '@nestjs/schematics': + specifier: catalog:build + version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) + '@nestjs/testing': + specifier: catalog:build + version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16) + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + globals: + specifier: catalog:test + version: 16.5.0 + msw: + specifier: catalog:test + version: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + rimraf: + specifier: catalog:tools + version: 6.1.3 + ts-patch: + specifier: catalog:build + version: 3.3.0 + typescript: + specifier: catalog:build + version: 5.9.3 + typescript-eslint: + specifier: catalog:tools + version: 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + packages/eslintconfig: + devDependencies: + '@antfu/eslint-config': + specifier: catalog:tools + version: 8.2.0(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5) + eslint: + specifier: catalog:tools + version: 10.5.0(jiti@2.6.1) + + packages/hooks: + dependencies: + '@cpn-console/logger': + specifier: workspace:^ + version: link:../logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../shared + json-schema: + specifier: catalog:runtime + version: 0.4.0 + vitest-mock-extended: + specifier: catalog:runtime + version: 2.0.2(typescript@5.9.3)(vitest@4.1.5) + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/json-schema': + specifier: catalog:types + version: 7.0.15 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + undici-types: + specifier: catalog:types + version: 7.16.0 + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + + packages/logger: + dependencies: + pino: + specifier: catalog:tools + version: 9.14.0 + pino-pretty: + specifier: catalog:tools + version: 13.1.3 + zod: + specifier: catalog:runtime + version: 3.25.76 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + + packages/shared: + dependencies: + '@cpn-console/logger': + specifier: workspace:* + version: link:../logger + '@ts-rest/core': + specifier: catalog:runtime + version: 3.52.1(@types/node@24.12.0)(zod@3.25.76) + short-uuid: + specifier: catalog:runtime + version: 5.2.0 + zod: + specifier: catalog:runtime + version: 3.25.76 + zod-validation-error: + specifier: catalog:runtime + version: 3.5.4(zod@3.25.76) + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite-node: + specifier: catalog:build + version: 6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + packages/test-utils: + dependencies: + '@cpn-console/shared': + specifier: workspace:^ + version: link:../shared + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + + packages/tsconfig: {} + + playwright: + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@playwright/test': + specifier: catalog:test + version: 1.59.1 + '@types/node': + specifier: catalog:types + version: 24.12.0 + + plugins/argocd: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@himenon/argocd-typescript-openapi': + specifier: catalog:runtime + version: 1.2.2 + axios: + specifier: catalog:runtime + version: 1.16.0 + yaml: + specifier: catalog:runtime + version: 2.9.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@faker-js/faker': + specifier: catalog:test + version: 9.9.0 + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/gitlab: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@gitbeaker/core': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: catalog:runtime + version: 40.6.0 + '@gitbeaker/rest': + specifier: catalog:runtime + version: 40.6.0 + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/harbor: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + bytes: + specifier: catalog:runtime + version: 3.1.2 + cron-validator: + specifier: catalog:runtime + version: 1.4.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + js-yaml: + specifier: catalog:tools + version: 4.2.0 + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + swagger-typescript-api: + specifier: catalog:build + version: 13.3.1(magicast@0.5.3) + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/keycloak: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@keycloak/keycloak-admin-client': + specifier: catalog:runtime + version: 26.7.0 + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/nexus: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/sonarqube: + dependencies: + '@cpn-console/gitlab-plugin': + specifier: workspace:^ + version: link:../gitlab + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/keycloak-plugin': + specifier: workspace:^ + version: link:../keycloak + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + '@cpn-console/vault-plugin': + specifier: workspace:^ + version: link:../vault + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + plugins/vault: + dependencies: + '@cpn-console/hooks': + specifier: workspace:^ + version: link:../../packages/hooks + '@cpn-console/logger': + specifier: workspace:^ + version: link:../../packages/logger + '@cpn-console/shared': + specifier: workspace:^ + version: link:../../packages/shared + axios: + specifier: catalog:runtime + version: 1.16.0 + devDependencies: + '@cpn-console/eslint-config': + specifier: workspace:^ + version: link:../../packages/eslintconfig + '@cpn-console/ts-config': + specifier: workspace:^ + version: link:../../packages/tsconfig + '@types/node': + specifier: catalog:types + version: 24.12.0 + '@vitest/coverage-v8': + specifier: catalog:test + version: 4.1.5(vitest@4.1.5) + nodemon: + specifier: catalog:tools + version: 3.1.14 + rimraf: + specifier: catalog:tools + version: 6.1.3 + typescript: + specifier: catalog:build + version: 5.9.3 + vite: + specifier: catalog:build + version: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vitest: + specifier: catalog:test + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + +packages: + + '@anatine/zod-openapi@1.14.2': + resolution: {integrity: sha512-q0qHfnuNYVKu0Swrnnvfj9971AEyW7c8v9jCOZGCl5ZbyGMNG4RPyJkRcMi/JC8CRfdOe0IDfNm1nNsi2avprg==} + peerDependencies: + openapi3-ts: ^2.0.0 || ^3.0.0 + zod: ^3.20.0 + + '@angular-devkit/core@19.2.17': + resolution: {integrity: sha512-Ah008x2RJkd0F+NLKqIpA34/vUGwjlprRCkvddjDopAWRzYn6xCkz1Tqwuhn0nR1Dy47wTLKYD999TYl5ONOAQ==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/core@19.2.19': + resolution: {integrity: sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^4.0.0 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/schematics-cli@19.2.19': + resolution: {integrity: sha512-7q9UY6HK6sccL9F3cqGRUwKhM7b/XfD2YcVaZ2WD7VMaRlRm85v6mRjSrfKIAwxcQU0UK27kMc79NIIqaHjzxA==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + hasBin: true + + '@angular-devkit/schematics@19.2.17': + resolution: {integrity: sha512-ADfbaBsrG8mBF6Mfs+crKA/2ykB8AJI50Cv9tKmZfwcUcyAdmTr+vVvhsBCfvUAEokigSsgqgpYxfkJVxhJYeg==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@angular-devkit/schematics@19.2.19': + resolution: {integrity: sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==} + engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@antfu/eslint-config@8.2.0': + resolution: {integrity: sha512-spfwYXMNrlkl69riTSBnbC0C2K8EVfVMOK3ceP2EpAAioyfprIW1gTwyLRtd9jZSFeNdX4mFNAIG+o0sOneOfA==} + hasBin: true + peerDependencies: + '@angular-eslint/eslint-plugin': ^21.1.0 + '@angular-eslint/eslint-plugin-template': ^21.1.0 + '@angular-eslint/template-parser': ^21.1.0 + '@eslint-react/eslint-plugin': ^3.0.0 + '@next/eslint-plugin-next': '>=15.0.0' + '@prettier/plugin-xml': ^3.4.1 + '@unocss/eslint-plugin': '>=0.50.0' + astro-eslint-parser: ^1.0.2 + eslint: ^9.10.0 || ^10.0.0 + eslint-plugin-astro: ^1.2.0 + eslint-plugin-format: '>=0.1.0' + eslint-plugin-jsx-a11y: '>=6.10.2' + eslint-plugin-react-refresh: ^0.5.0 + eslint-plugin-solid: ^0.14.3 + eslint-plugin-svelte: '>=2.35.1' + eslint-plugin-vuejs-accessibility: ^2.4.1 + prettier-plugin-astro: ^0.14.0 + prettier-plugin-slidev: ^1.0.5 + svelte-eslint-parser: '>=0.37.0' + peerDependenciesMeta: + '@angular-eslint/eslint-plugin': + optional: true + '@angular-eslint/eslint-plugin-template': + optional: true + '@angular-eslint/template-parser': + optional: true + '@eslint-react/eslint-plugin': + optional: true + '@next/eslint-plugin-next': + optional: true + '@prettier/plugin-xml': + optional: true + '@unocss/eslint-plugin': + optional: true + astro-eslint-parser: + optional: true + eslint-plugin-astro: + optional: true + eslint-plugin-format: + optional: true + eslint-plugin-jsx-a11y: + optional: true + eslint-plugin-react-refresh: + optional: true + eslint-plugin-solid: + optional: true + eslint-plugin-svelte: + optional: true + eslint-plugin-vuejs-accessibility: + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-slidev: + optional: true + svelte-eslint-parser: + optional: true + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@antfu/utils@0.7.10': + resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + + '@apideck/better-ajv-errors@0.3.7': + resolution: {integrity: sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==} + engines: {node: '>=10'} + peerDependencies: + ajv: '>=8' + + '@apidevtools/json-schema-ref-parser@14.0.1': + resolution: {integrity: sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==} + engines: {node: '>= 16'} + + '@apidevtools/openapi-schemas@2.1.0': + resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} + engines: {node: '>=10'} + + '@apidevtools/swagger-methods@3.0.2': + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + + '@apidevtools/swagger-parser@12.1.0': + resolution: {integrity: sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==} + peerDependencies: + openapi-types: '>=7' + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.29.7': + resolution: {integrity: sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.29.7': + resolution: {integrity: sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.29.7': + resolution: {integrity: sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7': + resolution: {integrity: sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7': + resolution: {integrity: sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7': + resolution: {integrity: sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7': + resolution: {integrity: sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7': + resolution: {integrity: sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7': + resolution: {integrity: sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.29.7': + resolution: {integrity: sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-arrow-functions@7.29.7': + resolution: {integrity: sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.7': + resolution: {integrity: sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.29.7': + resolution: {integrity: sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.29.7': + resolution: {integrity: sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.29.7': + resolution: {integrity: sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.29.7': + resolution: {integrity: sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.29.7': + resolution: {integrity: sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.29.7': + resolution: {integrity: sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.29.7': + resolution: {integrity: sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.29.7': + resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.29.7': + resolution: {integrity: sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.29.7': + resolution: {integrity: sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-dynamic-import@7.29.7': + resolution: {integrity: sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.29.7': + resolution: {integrity: sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.29.7': + resolution: {integrity: sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.29.7': + resolution: {integrity: sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.29.7': + resolution: {integrity: sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.29.7': + resolution: {integrity: sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.29.7': + resolution: {integrity: sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.29.7': + resolution: {integrity: sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7': + resolution: {integrity: sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.29.7': + resolution: {integrity: sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.29.7': + resolution: {integrity: sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@7.29.7': + resolution: {integrity: sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-umd@7.29.7': + resolution: {integrity: sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.29.7': + resolution: {integrity: sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7': + resolution: {integrity: sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.29.7': + resolution: {integrity: sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.29.7': + resolution: {integrity: sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.29.7': + resolution: {integrity: sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.29.7': + resolution: {integrity: sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.29.7': + resolution: {integrity: sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.29.7': + resolution: {integrity: sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.29.7': + resolution: {integrity: sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.29.7': + resolution: {integrity: sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.29.7': + resolution: {integrity: sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.7': + resolution: {integrity: sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regexp-modifiers@7.29.7': + resolution: {integrity: sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-reserved-words@7.29.7': + resolution: {integrity: sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.29.7': + resolution: {integrity: sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.29.7': + resolution: {integrity: sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.29.7': + resolution: {integrity: sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.29.7': + resolution: {integrity: sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.29.7': + resolution: {integrity: sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.29.7': + resolution: {integrity: sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.29.7': + resolution: {integrity: sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.29.7': + resolution: {integrity: sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7': + resolution: {integrity: sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.29.7': + resolution: {integrity: sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@biomejs/js-api@4.0.0': + resolution: {integrity: sha512-EOArR/6drRzM1/hwOIz1pZw90FL31Ud4Y7hEHGWVtMNmAwS9SrwZ8hMENGlLVXCeGW/kL46p8kX7eO6x9Nmezg==} + peerDependencies: + '@biomejs/wasm-bundler': ^2.3.0 + '@biomejs/wasm-nodejs': ^2.3.0 + '@biomejs/wasm-web': ^2.3.0 + peerDependenciesMeta: + '@biomejs/wasm-bundler': + optional: true + '@biomejs/wasm-nodejs': + optional: true + '@biomejs/wasm-web': + optional: true + + '@biomejs/wasm-nodejs@2.4.6': + resolution: {integrity: sha512-YRhedzOovXDMs+ZKzi/ZcDTefZIMcrp3z0Ruq+abrNb622aYUh/2m1Ooj8BcG4anGUGR6o+dTCbvAEx9vc74hw==} + + '@borewit/text-codec@0.2.1': + resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + + '@cacheable/memory@2.0.8': + resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} + + '@cacheable/utils@2.4.0': + resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==} + + '@clack/core@1.3.0': + resolution: {integrity: sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.3.0': + resolution: {integrity: sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw==} + engines: {node: '>= 20.12.0'} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@commitlint/cli@20.5.3': + resolution: {integrity: sha512-OJdL0EXWD5y9LPa0nr/geOwzaS8BsdaybKkcloB0JgsguGxNv2R+hC2FTPqrAcprg35zF33KOQerY0x8W1aesA==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@20.5.3': + resolution: {integrity: sha512-j34Qqeaa152chJgz2ysyk0BCpHenJn1lV0Rx0VXf8k3ccQcED+48EZrzMvo9jLmJUyBrrBwvu89I+2er4gW7QQ==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@20.5.0': + resolution: {integrity: sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==} + engines: {node: '>=v18'} + + '@commitlint/ensure@20.5.3': + resolution: {integrity: sha512-4i4AgNvH62owG9MwSiWKrle7HGNpBHHdLnWFIp5fTsHUYe5kRuh15t08L/0pdbbrRk8JKXQxxN4hZQcn+szkrw==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@20.0.0': + resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==} + engines: {node: '>=v18'} + + '@commitlint/format@20.5.0': + resolution: {integrity: sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@20.5.0': + resolution: {integrity: sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==} + engines: {node: '>=v18'} + + '@commitlint/lint@20.5.3': + resolution: {integrity: sha512-M7JbWBNr2gXKaPc4i/KipsuW1gkDHpj35KPjWtKy3Z+2AQw5wu1gBi1LIO0uoaij67CqY4K8PxPZSGens4evCw==} + engines: {node: '>=v18'} + + '@commitlint/load@20.5.3': + resolution: {integrity: sha512-1FDZWuKyu98Myb8i7Tp31jPU2rZpOwAdYRyJcy2KoGg7Xk2A+bgHN8smhMaaNSNkmE8fwt53BokywZq8Gv/5XQ==} + engines: {node: '>=v18'} + + '@commitlint/message@20.4.3': + resolution: {integrity: sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==} + engines: {node: '>=v18'} + + '@commitlint/parse@20.5.0': + resolution: {integrity: sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==} + engines: {node: '>=v18'} + + '@commitlint/read@20.5.0': + resolution: {integrity: sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@20.5.3': + resolution: {integrity: sha512-+ogW9v/u9JqpvAgTrLra/YTFo0KkjU6iNblF89pPsj4NebNc+DAWctsludwezI8YnsjBmfHpApSwcXprN/f/ew==} + engines: {node: '>=v18'} + + '@commitlint/rules@20.5.3': + resolution: {integrity: sha512-MPlMnb9D3wbszYMp+1hPtuhtPJndRo6I6yfkZVA4+jR8w7Kqp0u2u/Y+gzbaItx5Lltq5rw7FSZQWJMoXUC4NQ==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@20.0.0': + resolution: {integrity: sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==} + engines: {node: '>=v18'} + + '@commitlint/top-level@20.4.3': + resolution: {integrity: sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==} + engines: {node: '>=v18'} + + '@commitlint/types@20.5.0': + resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} + engines: {node: '>=v18'} + + '@conventional-changelog/git-client@2.7.0': + resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} + engines: {node: '>=18'} + peerDependencies: + conventional-commits-filter: ^5.0.0 + conventional-commits-parser: ^6.4.0 + peerDependenciesMeta: + conventional-commits-filter: + optional: true + conventional-commits-parser: + optional: true + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@csstools/media-query-list-parser@5.0.0': + resolution: {integrity: sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/selector-resolve-nested@4.0.0': + resolution: {integrity: sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@csstools/selector-specificity@6.0.0': + resolution: {integrity: sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@e18e/eslint-plugin@0.3.0': + resolution: {integrity: sha512-hHgfpxsrZ2UYHcicA+tGZnmk19uJTaye9VH79O+XS8R4ona2Hx3xjhXghclNW58uXMk3xXlbYEOMr8thsoBmWg==} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + oxlint: ^1.55.0 + peerDependenciesMeta: + eslint: + optional: true + oxlint: + optional: true + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@es-joy/jsdoccomment@0.84.0': + resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@es-joy/jsdoccomment@0.86.0': + resolution: {integrity: sha512-ukZmRQ81WiTpDWO6D/cTBM7XbrNtutHKvAVnZN/8pldAwLoJArGOvkNyxPTBGsPjsoaQBJxlH+tE2TNA/92Qgw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@es-joy/resolve.exports@1.2.0': + resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} + engines: {node: '>=10'} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-plugin-eslint-comments@4.7.1': + resolution: {integrity: sha512-Ql2nJFwA8wUGpILYGOQaT1glPsmvEwE0d+a+l7AALLzQvInqdbXJdx7aSu0DpUX9dB1wMVBMhm99/++S3MdEtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/compat@2.0.3': + resolution: {integrity: sha512-SjIJhGigp8hmd1YGIBwh7Ovri7Kisl42GYFjrOyHhtfYGGoLW6teYi/5p8W50KSsawUPpuLOSmsq1bD0NGQLBw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^8.40 || 9 || 10 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/markdown@8.0.1': + resolution: {integrity: sha512-WWKmld/EyNdEB8GMq7JMPX1SDWgyJAM1uhtCi5ySrqYQM4HQjmg11EX/q3ZpnpRXHfdccFtli3NBvvGaYjWyQw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exodus/schemasafe@1.3.0': + resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} + + '@faker-js/faker@9.9.0': + resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cookie@11.0.2': + resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/formbody@8.0.2': + resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/helmet@13.0.2': + resolution: {integrity: sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==} + + '@fastify/jwt@9.1.0': + resolution: {integrity: sha512-CiGHCnS5cPMdb004c70sUWhQTfzrJHAeTywt7nVw6dAiI0z1o4WRvU94xfijhkaId4bIxTCOjFgn4sU+Gvk43w==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/otel@0.18.1': + resolution: {integrity: sha512-7TYQXrOBRwCuTiwQm/2qCPO37af011934clxBj6F7KyF9a2a9MB+aSrWv8vMVp5N8rZC1AQ4pUp8uDs5Z40UPQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/session@11.1.1': + resolution: {integrity: sha512-nuKwTHxh3eJsI4NJeXoYVGzXUsg+kH1WfHgS7IofVyVhmjc+A6qGr+29WQy8hYZiNtmCjfG415COpf5xTBkW4Q==} + + '@fastify/static@9.1.3': + resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==} + + '@fastify/swagger-ui@5.2.6': + resolution: {integrity: sha512-OMnms0O5s9wb6wis/K5nlrAMLsgUbr1GA8uphM41IasWe3AFdgxz6r/3bA9HTxlDNUYc2FGGKeqMp3ntxmSiNA==} + + '@fastify/swagger@9.7.0': + resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==} + + '@gitbeaker/core@40.6.0': + resolution: {integrity: sha512-tVVm8ZPrS9YCHEcuPV8vD1IcEf9POpdygWo+kPvkK7LcC36EERVcXagb8snEaGgGLfUaVQh8qP4iDZgPnP3YBQ==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/requester-utils@40.6.0': + resolution: {integrity: sha512-DQu2l3iXtB+8e1Ye2ekeUHABt4mGMRTLtuVWtFqf74sqJnerHNOxVOjPn19qu/nKdvKR3ZLwSRTtPzEsxgcShg==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/rest@40.6.0': + resolution: {integrity: sha512-sAwYJclU3NlB/gdxqhH6Hnmy5LWzvW7D3W33eShQEnxMhM0VjnFHPHcgJLQCIux3hMiub1uGtTw1hBJTxDc2mQ==} + engines: {node: '>=18.20.0'} + + '@gouvfr/dsfr@1.14.4': + resolution: {integrity: sha512-Ca17apmTyAF2tMFwMcLwQN7fTlbfMFeyYSLUIUmjoSg1XVF4vDDkbvvKBpWJWaoTLDSFZBbvvHzcDyxw9wSkLA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@gouvminint/vue-dsfr@8.15.0': + resolution: {integrity: sha512-e9q7xJeWpGLLFmbe8YCeZ3bgsBM4LWMMiRVmde5DeAfILXewPh3uFKGMphWVKVKnz3tnxrgYM0GoGOn3I9FoYQ==} + engines: {node: '>=22.0.0', pnpm: '>=10.0.0'} + hasBin: true + peerDependencies: + '@iconify/vue': ^4.2.0 + vue: ^3.5.27 + vue-router: ^4.6.4 + + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + + '@himenon/argocd-typescript-openapi@1.2.2': + resolution: {integrity: sha512-xS8HAzhRSQXqMfD779EgbZmPcgkaNW8Hf2anrwvvPYb9Tt0vP9CtzOqj+8GYSOUQqgcRFejmCR9WoAgZJamB1g==} + engines: {pnpm: '>=8'} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@iconify-json/ri@1.2.10': + resolution: {integrity: sha512-WWMhoncVVM+Xmu9T5fgu2lhYRrKTEWhKk3Com0KiM111EeEsRLiASjpsFKnC/SrB6covhUp95r2mH8tGxhgd5Q==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + + '@iconify/vue@4.3.0': + resolution: {integrity: sha512-Xq0h6zMrHBbrW8jXJ9fISi+x8oDQllg5hTDkDuxnWiskJ63rpJu9CvJshj8VniHVTbsxCg9fVoPAaNp3RQI5OQ==} + peerDependencies: + vue: '>=3' + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.3.2': + resolution: {integrity: sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@keycloak/keycloak-admin-client@26.7.0': + resolution: {integrity: sha512-QEsX19sFCvknBzV2zelEkZ7waglCqzayY1zDtoRQinHuUiAe2f7q+xXT6R8XNukn+rShB41TOlG4QR2Bt6hxhw==} + engines: {node: '>=18'} + + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@kubernetes-models/apimachinery@2.2.0': + resolution: {integrity: sha512-7Yj3aMz/nCTdr221wHOMsw5vAMJnLqANMI3BTuCmYjbP+7/tGAaOsiFwT75F4TJH45Dk1P6wcAEqDeCD1ur82Q==} + engines: {node: '>=14'} + + '@kubernetes-models/argo-cd@2.7.2': + resolution: {integrity: sha512-Dge+7Uq54dt5c3pzusOw8i26FHWqJiyiFqHKQKHUNjOq2zb3e7LY2OYBrNP7hDSoM0Ap5K9cJTMgNzsaaKdjOQ==} + engines: {node: '>=14'} + + '@kubernetes-models/base@5.0.1': + resolution: {integrity: sha512-Yfzmh965y/hT8qs9eE+Ms9sO1iT31c/sPoJerBFRGS9KechXXfxHgeqtpOzq+eByQ1INblkcZ09rt2V0FxG0lA==} + engines: {node: '>=14'} + + '@kubernetes-models/validate@4.0.0': + resolution: {integrity: sha512-rsfF5t3sd5c+3ejUgcy8q0cVG2/BxT064L4Xz+CuKEe014u8T4MtFZiWjWZFpfMXSGKzFYEA6DJYm9CqjmIfZg==} + engines: {node: '>=14'} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@microsoft/kiota-abstractions@1.0.0-preview.103': + resolution: {integrity: sha512-om7nKH8nWyEAQ0lgbIdKdrkmznhkdDJdliCnAIo31X/hlUah1aBzvfUx+pbyp67Q040yLK+2J6P7AnvGSoN/qQ==} + + '@microsoft/kiota-http-fetchlibrary@1.0.0-preview.103': + resolution: {integrity: sha512-DbhGL+pdVjtv2AVvxB4fxvNC/GMzKbYyeQsknaXJXwqnPR/oIClWjaXlIxWL5SBK72DOmrZ18leaWMB2vfWvpw==} + + '@microsoft/kiota-serialization-form@1.0.0-preview.103': + resolution: {integrity: sha512-AD5Q59Qu61DfhaLLN7Mvgx4Zw0Zmftqx04XAbrRreC+r9hwT1BqTf0EReCraMzEoC4sdrp/8haI2Arm8dWUWuQ==} + + '@microsoft/kiota-serialization-json@1.0.0-preview.103': + resolution: {integrity: sha512-dTr8q6H42TSe+HaCPBXU4Pv6ypo7MjMGRo/r9GRbnCuy8VCWfe+Vh38rnAZJx4r8cmvOUD7Yw74IJ6q4Hjv3mg==} + + '@microsoft/kiota-serialization-multipart@1.0.0-preview.103': + resolution: {integrity: sha512-b3ePXYgFv/2TQtbzFQ8ZY+hzMtCRkz4Cx5y/Pf8vtoQu56KU0/9rYq5kA9wySDdCx/oZxfubQQRV/8QSTxmejw==} + + '@microsoft/kiota-serialization-text@1.0.0-preview.103': + resolution: {integrity: sha512-FkiboqpNtT2F40xUC9LETHK9ckHd+WuocLJE70ljI4EYvD59MQDsrTERYAwbmkfPIJ6JRE4OfLXFVh510LG1/Q==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} + engines: {node: '>=18'} + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nestjs/cache-manager@3.1.3': + resolution: {integrity: sha512-HMtiOfHz75NZX7mJn1VnZGLSachVI04TnUc5wvEogIaKwk5BDQHgtP5htxreizjv7oxKalJbuTyxtiF6bE+bgQ==} + peerDependencies: + '@nestjs/common': ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 || ^11.0.0 + cache-manager: '>=6' + keyv: '>=5' + rxjs: ^7.8.1 + + '@nestjs/cli@11.0.16': + resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==} + engines: {node: '>= 20.11'} + hasBin: true + peerDependencies: + '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 + '@swc/core': ^1.3.62 + peerDependenciesMeta: + '@swc/cli': + optional: true + '@swc/core': + optional: true + + '@nestjs/common@11.1.16': + resolution: {integrity: sha512-JSIeW+USuMJkkcNbiOdcPkVCeI3TSnXstIVEPpp3HiaKnPRuSbUUKm9TY9o/XpIcPHWUOQItAtC5BiAwFdVITQ==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/config@4.0.3': + resolution: {integrity: sha512-FQ3M3Ohqfl+nHAn5tp7++wUQw0f2nAk+SFKe8EpNRnIifPqvfJP6JQxPKtFLMOHbyer4X646prFG4zSRYEssQQ==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + '@nestjs/core@11.1.16': + resolution: {integrity: sha512-tXWXyCiqWthelJjrE0KLFjf0O98VEt+WPVx5CrqCf+059kIxJ8y1Vw7Cy7N4fwQafWNrmFL2AfN87DDMbVAY0w==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/event-emitter@3.0.1': + resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/jwt@11.0.2': + resolution: {integrity: sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + + '@nestjs/mapped-types@2.1.1': + resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/platform-express@11.1.16': + resolution: {integrity: sha512-IOegr5+ZfUiMKgk+garsSU4MOkPRhm46e6w8Bp1GcO4vCdl9Piz6FlWAzKVfa/U3Hn/DdzSVJOW3TWcQQFdBDw==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + + '@nestjs/platform-fastify@11.1.27': + resolution: {integrity: sha512-rZMPS0RMP9P2O7Y/shAP0kKwrHElGO9cPTlgtdWpQ6JEX58uhYEJLGsU/X9a9l18DNSxxb3YjrEiVVuI2Iv6MA==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@fastify/view': ^10.0.0 || ^11.0.0 || ^12.0.0 + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + '@fastify/view': + optional: true + + '@nestjs/schedule@6.1.1': + resolution: {integrity: sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/schematics@11.0.9': + resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} + peerDependencies: + typescript: '>=4.8.2' + + '@nestjs/swagger@11.4.1': + resolution: {integrity: sha512-GuGzs8F1Cb3n+eEarmOqB4nt2ai+x4XGOYUXNYplOtDeB59DaFY5E16bsHsBWXiWgD1ywbyKQ5OVv02bQtB1Dw==} + peerDependencies: + '@fastify/static': ^8.0.0 || ^9.0.0 + '@nestjs/common': ^11.0.1 + '@nestjs/core': ^11.0.1 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/terminus@11.1.1': + resolution: {integrity: sha512-Ssql79H+EQY/Wg108eJqN4NiNsO/tLrj+qbzOWSQUf2JE4vJQ2RG3WTqUOrYjfjWmVHD3+Ys0+azed7LSMKScw==} + peerDependencies: + '@grpc/grpc-js': '*' + '@grpc/proto-loader': '*' + '@mikro-orm/core': '*' + '@mikro-orm/nestjs': '*' + '@nestjs/axios': ^2.0.0 || ^3.0.0 || ^4.0.0 + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/microservices': ^10.0.0 || ^11.0.0 + '@nestjs/mongoose': ^11.0.0 + '@nestjs/sequelize': ^10.0.0 || ^11.0.0 + '@nestjs/typeorm': ^10.0.0 || ^11.0.0 + '@prisma/client': '*' + mongoose: '*' + reflect-metadata: 0.1.x || 0.2.x + rxjs: 7.x + sequelize: '*' + typeorm: '*' + peerDependenciesMeta: + '@grpc/grpc-js': + optional: true + '@grpc/proto-loader': + optional: true + '@mikro-orm/core': + optional: true + '@mikro-orm/nestjs': + optional: true + '@nestjs/axios': + optional: true + '@nestjs/microservices': + optional: true + '@nestjs/mongoose': + optional: true + '@nestjs/sequelize': + optional: true + '@nestjs/typeorm': + optional: true + '@prisma/client': + optional: true + mongoose: + optional: true + sequelize: + optional: true + typeorm: + optional: true + + '@nestjs/testing@11.1.16': + resolution: {integrity: sha512-E7/aUCxzeMSJV80L5GWGIuiMyR/1ncS7uOIetAImfbS4ATE1/h2GBafk0qpk+vjFtPIbtoh9BWDGICzUEU5jDA==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nuxt/opencollective@0.4.1': + resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==} + engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} + hasBin: true + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@opentelemetry/api-logs@0.212.0': + resolution: {integrity: sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.213.0': + resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.214.0': + resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/auto-instrumentations-node@0.70.1': + resolution: {integrity: sha512-r8BKs0rHtBAzZViPIuzSD2eh65fOPau0NqVsca2sACuZ6LFGu6a+QMhqq7skXz+/OqKwFr/7/b6VsaNMS+zZpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.4.1 + '@opentelemetry/core': ^2.0.0 + + '@opentelemetry/configuration@0.212.0': + resolution: {integrity: sha512-D8sAY6RbqMa1W8lCeiaSL2eMCW2MF87QI3y+I6DQE1j+5GrDMwiKPLdzpa/2/+Zl9v1//74LmooCTCJBvWR8Iw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/context-async-hooks@2.5.1': + resolution: {integrity: sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.5.1': + resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.212.0': + resolution: {integrity: sha512-/0bk6fQG+eSFZ4L6NlckGTgUous/ib5+OVdg0x4OdwYeHzV3lTEo3it1HgnPY6UKpmX7ki+hJvxjsOql8rCeZA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.212.0': + resolution: {integrity: sha512-JidJasLwG/7M9RTxV/64xotDKmFAUSBc9SNlxI32QYuUMK5rVKhHNWMPDzC7E0pCAL3cu+FyiKvsTwLi2KqPYw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.212.0': + resolution: {integrity: sha512-RpKB5UVfxc7c6Ta1UaCrxXDTQ0OD7BCGT66a97Q5zR1x3+9fw4dSaiqMXT/6FAWj2HyFbem6Rcu1UzPZikGTWQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0': + resolution: {integrity: sha512-/6Gqf9wpBq22XsomR1i0iPGnbQtCq2Vwnrq5oiDPjYSqveBdK1jtQbhGfmpK2mLLxk4cPDtD1ZEYdIou5K8EaA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.212.0': + resolution: {integrity: sha512-8hgBw3aTTRpSTkU4b9MLf/2YVLnfWp+hfnLq/1Fa2cky+vx6HqTodo+Zv1GTIrAKMOOwgysOjufy0gTxngqeBg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0': + resolution: {integrity: sha512-yw3fTIw4KQIRXC/ZyYQq5gtA3Ogfdfz/g5HVgleobQAcjUUE8Nj3spGMx8iQPp+S+u6/js7BixufRkXhzLmpJA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.212.0': + resolution: {integrity: sha512-C7I4WN+ghn3g7SnxXm2RK3/sRD0k/BYcXaK6lGU3yPjiM7a1M25MLuM6zY3PeVPPzzTZPfuS7+wgn/tHk768Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0': + resolution: {integrity: sha512-geHF+zZaDb0/WRkJTxR8o8dG4fCWT/Wq7HBdNZCxwH5mxhwRi/5f37IDYH7nvU+dwU6IeY4Pg8TPI435JCiNkg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.212.0': + resolution: {integrity: sha512-hJFLhCJba5MW5QHexZMHZdMhBfNqNItxOsN0AZojwD1W2kU9xM+BEICowFGJFo/vNV+I2BJvTtmuKafeDSAo7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.212.0': + resolution: {integrity: sha512-9xTuYWp8ClBhljDGAoa0NSsJcsxJsC9zCFKMSZJp1Osb9pjXCMRdA6fwXtlubyqe7w8FH16EWtQNKx/FWi+Ghw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.212.0': + resolution: {integrity: sha512-v/0wMozNoiEPRolzC4YoPo4rAT0q8r7aqdnRw3Nu7IDN0CGFzNQazkfAlBJ6N5y0FYJkban7Aw5WnN73//6YlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.212.0': + resolution: {integrity: sha512-d1ivqPT0V+i0IVOOdzGaLqonjtlk5jYrW7ItutWzXL/Mk+PiYb59dymy/i2reot9dDnBFWfrsvxyqdutGF5Vig==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0': + resolution: {integrity: sha512-six3vPq3sL+ge1iZOfKEg+RHuFQhGb8ZTdlvD234w/0gi8ty/qKD46qoGpKvM3amy5yYunWBKiFBW47WaVS26w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.5.1': + resolution: {integrity: sha512-Me6JVO7WqXGXsgr4+7o+B7qwKJQbt0c8WamFnxpkR43avgG9k/niTntwCaXiXUTjonWy0+61ZuX6CGzj9nn8CQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-amqplib@0.59.0': + resolution: {integrity: sha512-xscSgOJA+GHphESDZxBHNk/zjNaEgoeufMwmiqYdL+qM27Xw3BbR9vN6Ucbq9dW6Y+oYUPgTTj17qf+Za4+uzg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-lambda@0.64.0': + resolution: {integrity: sha512-vYhM/a8fG34/Dl/Q9gfv5Ih3OFPgqeyn79S8FN+Xs/QZw6h6L8a1lDa3CyigyicOXLCmVIM7Fc9vFD4BGqgGLA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-sdk@0.67.0': + resolution: {integrity: sha512-btpwJnZ2RBXDh/pTpfVpInpBu9Pedi+lbLKbt3naB344SggbbYnIdT7u8EzmGIApWi9EV91vw7hm896I7nESQA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-bunyan@0.57.0': + resolution: {integrity: sha512-W4zLz1Y9ptCsdL+QMXR7xQaBHkJivLBmVlLCjUe23rX4V8E65fGAtlIJSKTKAfz4aEgtWgQAGMdkeqACwG0Caw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cassandra-driver@0.57.0': + resolution: {integrity: sha512-xLwrK+XnN32IB5i6t/a2j+SVdjlq/BIgjpVRkke4HAsKjoSMy1GeSI+ZOiJffRLFb4MojcvH4RG2+nEg1uC6Zg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.55.0': + resolution: {integrity: sha512-UfGw7ubKKZBoTRjxi5KlfeECEaXZinS20RdRNlZE5tVF+O17hJOnrcGwAoQAHp6eYmxI2jW9IQ4t6450gnNF9g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cucumber@0.28.0': + resolution: {integrity: sha512-kim+bRxu4LZqKEyF2SgO01tgG88W+/iYltyP1XjT31FIXzlBjzQpwtSLLM8byayO85mcZIBha54WSNFDLM/7qQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-dataloader@0.29.0': + resolution: {integrity: sha512-220WjRb1G1UiAKbVblSMxwxxFdpyB4wj1XYIO9BJs5r62Azj2dL5fyZiXK3/WO6wB3uLul9R946iKI1bpPxktQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dns@0.55.0': + resolution: {integrity: sha512-cfWLaFi22V+sQrKY7t6QroYzT3kO9m3PpkN1OXYmuCyfwxQaXOVlF8NSAHtua/RQYw0aQl+2fe6JOWyJdEZiwA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.60.0': + resolution: {integrity: sha512-KghHCDqKq0D7iuPIVCuPSXut5WVAI6uwKcPrhwTUJL5VE2LC18id2vKoiAm1V8XvVlgIGAiECtEvbrFwkTCj3g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fastify@0.56.0': + resolution: {integrity: sha512-zotOPoZsWtMF47BjottK23XaaBSmVuwG5D/R3FlGfAAwMNFoDR3IY1OGO9v9KfOU/1/xDVkxsQ22NFfu9lE8aA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.31.0': + resolution: {integrity: sha512-C7tdXGDnkMgLVlE79VSekB+Y+P345zKUigvFMs5M7U0GIYA8ERx3FS0aAcY/ICIq9YwRmH2uuMb++Br5M2vNUg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.55.0': + resolution: {integrity: sha512-7hWiyLbEX/dIS4LZy/h8VaAQPs8oBeEqsrysDWbos0b9PF414L6Rsbi2um/omtxIs+GTvsbuqDscWigeaxyWdA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.60.0': + resolution: {integrity: sha512-XPATrmxAd2tFCsYbJ3eVIXt+gyvMKjc36QQuQxjtssMnAbw006Le9b5lKs7WXik7ItOpM1exATi1aDdOcCjRRg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-grpc@0.212.0': + resolution: {integrity: sha512-r1t7LNKWVhSQMUrBdDJtooFmmLZ93kGuFixqeXPoUP8W+chJCxhey9l0c0+L3xriNdyB7TzvkKHhPXUDevgVEA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.58.0': + resolution: {integrity: sha512-reuRApR2KHm2VsfyDgsrLhNE+IOy4uIU6n3oMjUleReHacEEZmf4vXxdt4/qcmJ6GoUXnRN2AOu3s5N3pMrgYA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.212.0': + resolution: {integrity: sha512-t2nt16Uyv9irgR+tqnX96YeToOStc3X5js7Ljn3EKlI2b4Fe76VhMkTXtsTQ0aId6AsYgefrCRnXSCo/Fn/vww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.60.0': + resolution: {integrity: sha512-R+nnbPD9l2ruzu248qM3YDWzpdmWVaFFFv08lQqsc0EP4pT/B1GGUg06/tHOSo3L5njB2eejwyzpkvJkjaQEMA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.21.0': + resolution: {integrity: sha512-lkLrILnKGO7SHw1xPJnuGx2S4XwbKmQiJyzUGuEImRoU/6Gj0Nka0lkbeRd4ANN20dxr/mLdXIsUsk6DzTrX6A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.56.0': + resolution: {integrity: sha512-pKqtY5lbAQ70MC5K/BJeAA1t2gAUlRBZBAJ5ergRUNs5jw8zbdOXEZOLztiuNvQqD2z4a9N0Tkde9JMFm2pKMQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.60.0': + resolution: {integrity: sha512-UOmu2y2LHgPzKsm9xd0sCQJimr11YP4MKFc190Do1ufd8qds7Zd5BI3f6TudqYhH9dUIhojsQyUaS6K4nv5FsQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.56.0': + resolution: {integrity: sha512-vXtOValhKRgWA9tLAiTU3P37Q31OveRuM2N5iLSVHl4GzkMBQ5p50A9kSKvt5gReL6BzFDXPCM9ItJiAhSS2KQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-memcached@0.55.0': + resolution: {integrity: sha512-kdhW/j5X+vNCAvHVc50PZfvE7diUScg1ZkBaNFRygY3Z6IUjgPLR0luWQMDPSFun6AVo1HaMDPxbUqJrot6qrA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.65.0': + resolution: {integrity: sha512-hOAJRs5vrY7fZolSYUXmf29Y+HFDHWrek0DeLq82uwMPjPSda7h6oumQnqEX5olzw357q/QG39/uJdkclJ/JUg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.58.0': + resolution: {integrity: sha512-3L0Fqo1y2oreISFPWaqdt/bg3NhLgrkn5U/E/9RNG1QaM81drTMBCHseMY1q8SlejjE43ZWOy+0KbmRBlUPJ+g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.58.0': + resolution: {integrity: sha512-EubjV1XZb7XHrENqF7TW2lnah+KN0LddMneKNAB8PjGVKL5lJkVV/vhJ6EIcUNn9nCWmAwZ3GRcFVEDKCnyXfQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.58.0': + resolution: {integrity: sha512-wZDrBCL3WfJclV6KywWVV3/B2ZiUYmDQdgyu3pq4jK/5qSfoDmezHzT/Nayln5MVVWMAGXIMLrCj8BKa6jaKQQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-nestjs-core@0.58.0': + resolution: {integrity: sha512-0lE9oW8j6nmvBHJoOxIQgKzMQQYNfX1nhiWZdXD0sNAMFsWBtvECWS7NAPSroKrEP53I04TcHCyyhcK4I9voXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-net@0.56.0': + resolution: {integrity: sha512-h69x7U6f86mP3gGWWTaMkQZk0K3tBvpVMIU7E0q2kkVw6eZ5TqFm9rkaEy38moQmixiDFQ9j/2/cwxG9P7ZEeA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-openai@0.10.0': + resolution: {integrity: sha512-0lV2zxge2mMaruVCw/bmypWVu+aJ76rc0HBvAVFCPUI3zzJdgBZJZafGIHZ1IB2F6VvrDFL+JstEnle6V8brvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-oracledb@0.37.0': + resolution: {integrity: sha512-OzMghtAEAEkXlkUrZI4QcXSZq0MILeU6WC0/N5+1MSkuIkruIeaRw99/RtyS2of8vlPDa8XbbXl32Q1RM3wSyg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.64.0': + resolution: {integrity: sha512-NbfB/rlfsRI3zpTjnbvJv3qwuoGLsN8FxR/XoI+ZTn1Rs62x1IenO+TSSvk4NO+7FlXpd2MiOe8LT/oNbydHGA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.58.0': + resolution: {integrity: sha512-rgy+tA7cDjuSq6dXAO40OiYP25azIDHMBtxG3RzSmCBVEYdjggl6btyuLVasX6VkOOhP2gf6PBuLMNxVwaIqAw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.59.0': + resolution: {integrity: sha512-IgImVFtWjfMmqxc0NIe3iSjp+J3Asf9lLX8reouUFUk3Aa/qJQO5PEvOtO3sNQtJBkC9bAd1OQdFaFWSFQc03g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.60.0': + resolution: {integrity: sha512-Ea/GffmmzIVHc9geaMjT94IR7poVZzIv4Kk/Lw0tbxGD3cBYcMUsLFVajKxpZsE1NRCECFpidAWeifCIKD0inw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-restify@0.57.0': + resolution: {integrity: sha512-kO6MsZFU+RdXOKhsKw8SOSBYGYCdFSlza+mpBQRl1DQmveZcnidchv4V5JQPtNgHxCGH+1n3hDpLdxdGUbJPNA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-router@0.56.0': + resolution: {integrity: sha512-PHECDGQElLazI/QbHU16C5m9fDC7DGJk+jLIwO5ca6bcp7bXhUPPUTT78l7da2pDsrz4mhv5ytYNZmBbW/Q3rA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-runtime-node@0.25.0': + resolution: {integrity: sha512-XaCmwBSui5KeTn8M6OzaEn1rEsNWtUkjuc1ylg0tqQTLHibNQ0n7f8v4zdF6x/nBV1OnsiYlN8RLHauGemv/TA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-socket.io@0.59.0': + resolution: {integrity: sha512-71DnM/FEqH0PjvU2uZvzWJeaGyVIy3rJKk8rZrxg/aS2QT3qLGb+UPL/B+1vOw4pzDPn4papLTSMpLVF9G8uvw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.31.0': + resolution: {integrity: sha512-HoF2EtcyP3JR4R3jLPHohZ9lFcj1QLJyGmFfLKDTvUUjPiFuK4XZ6L1OV9HhaqvN0xY+tWKfNdCPS3r33rd0Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.22.0': + resolution: {integrity: sha512-yb6vEWUPOrD5i7yR1XceEEqiVHbMgr5YnUPnom5eQVCjvrTkEVswyrf9i+vvJR+28wrNqILIIphWgOOx6BjnTQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation-winston@0.56.0': + resolution: {integrity: sha512-ITIA0Qe61CQ6FQU/bN23pNBvJ+5U0ofoASMOOYrODtXyV9wI267AigNTTwDmv2Myt8dPEFvvVFJZKhiZLIpehA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.212.0': + resolution: {integrity: sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.213.0': + resolution: {integrity: sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.214.0': + resolution: {integrity: sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.212.0': + resolution: {integrity: sha512-HoMv5pQlzbuxiMS0hN7oiUtg8RsJR5T7EhZccumIWxYfNo/f4wFc7LPDfFK6oHdG2JF/+qTocfqIHoom+7kLpw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.213.0': + resolution: {integrity: sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.212.0': + resolution: {integrity: sha512-YidOSlzpsun9uw0iyIWrQp6HxpMtBlECE3tiHGAsnpEqJWbAUWcMnIffvIuvTtTQ1OyRtwwaE79dWSQ8+eiB7g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.212.0': + resolution: {integrity: sha512-bj7zYFOg6Db7NUwsRZQ/WoVXpAf41WY2gsd3kShSfdpZQDRKHWJiRZIg7A8HvWsf97wb05rMFzPbmSHyjEl9tw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.213.0': + resolution: {integrity: sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@2.5.1': + resolution: {integrity: sha512-AU6sZgunZrZv/LTeHP+9IQsSSH5p3PtOfDPe8VTdwYH69nZCfvvvXehhzu+9fMW2mgJMh5RVpiH8M9xuYOu5Dg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.5.1': + resolution: {integrity: sha512-8+SB94/aSIOVGDUPRFSBRHVUm2A8ye1vC6/qcf/D+TF4qat7PC6rbJhRxiUGDXZtMtKEPM/glgv5cBGSJQymSg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.38.2': + resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resource-detector-alibaba-cloud@0.33.3': + resolution: {integrity: sha512-Ep3LDWALU+wCgGzAa1rgFXh3TObahN7HaQZntAeVQnnNhZ3VSXnBniRGeSCTIRvRr7YdZvq+G+rstixtAN5Ugw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-aws@2.13.0': + resolution: {integrity: sha512-ZPCn7gZhGqUYUoD+RCHIlayoHBMaJaEjfqlgz2EPKoXJ4y7Ru7CUm+Tm3yJVMKF92cN9xUQR0j5KALyF0fg9aw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-azure@0.20.0': + resolution: {integrity: sha512-iRy+O2cB6DOlQ/OONaK+L8Cp8nLS89dZVRp6KgnFAfzykXuq9Ws/ygJKcU3CCmjkgY5j2Vk3uVTre/E35bWhYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-container@0.8.4': + resolution: {integrity: sha512-kIvGHkMSacp+kb7btTuXbOAIWLyOCO+P/h/8xxaeLcp5ptmHRZ67uEdLAQo61ApdayFB/uqjJ9gY4x2/i/KsoA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-gcp@0.47.0': + resolution: {integrity: sha512-57T/kRVdU0ch1P4KPEkmU2b5mWNlUs8hHgqrBYVF+fNZMc1jMdL1mANZhEzoLtWKIeoCEy+57Itt7RkXAYNJiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resources@2.5.1': + resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.212.0': + resolution: {integrity: sha512-qglb5cqTf0mOC1sDdZ7nfrPjgmAqs2OxkzOPIf2+Rqx8yKBK0pS7wRtB1xH30rqahBIut9QJDbDePyvtyqvH/Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.213.0': + resolution: {integrity: sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.5.1': + resolution: {integrity: sha512-RKMn3QKi8nE71ULUo0g/MBvq1N4icEBo7cQSKnL3URZT16/YH3nSVgWegOjwx7FRBTrjOIkMJkCUn/ZFIEfn4A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.6.0': + resolution: {integrity: sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.212.0': + resolution: {integrity: sha512-tJzVDk4Lo44MdgJLlP+gdYdMnjxSNsjC/IiTxj5CFSnsjzpHXwifgl3BpUX67Ty3KcdubNVfedeBc/TlqHXwwg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.5.1': + resolution: {integrity: sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.6.0': + resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.5.1': + resolution: {integrity: sha512-9lopQ6ZoElETOEN0csgmtEV5/9C7BMfA7VtF4Jape3i954b6sTY2k3Xw3CxUTKreDck/vpAuJM+EDo4zheUw+A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.41.2': + resolution: {integrity: sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + + '@ota-meshi/ast-token-store@0.3.0': + resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@oxc-parser/binding-android-arm-eabi@0.115.0': + resolution: {integrity: sha512-VoB2rhgoqgYf64d6Qs5emONQW8ASiTc0xp+aUE4JUhxjX+0pE3gblTYDO0upcN5vt9UlBNmUhAwfSifkfre7nw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.115.0': + resolution: {integrity: sha512-lWRX75u+gqfB4TF3pWCHuvhaeneAmRl2b2qNBcl4S6yJ0HtnT4VXOMEZrq747i4Zby1ZTxj6mtOe678Bg8gRLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.115.0': + resolution: {integrity: sha512-ii/oOZjfGY1aszXTy29Z5DRyCEnBOrAXDVCvfdfXFQsOZlbbOa7NMHD7D+06YFe5qdxfmbWAYv4yn6QJi/0d2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.115.0': + resolution: {integrity: sha512-R/sW/p8l77wglbjpMcF+h/3rWbp9zk1mRP3U14mxTYIC2k3m+aLBpXXgk2zksqf9qKk5mcc4GIYsuCn9l8TgDg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.115.0': + resolution: {integrity: sha512-CSJ5ldNm9wIGGkhaIJeGmxRMZbgxThRN+X1ufYQQUNi5jZDV/U3C2QDMywpP93fczNBj961hXtcUPO/oVGq4Pw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.115.0': + resolution: {integrity: sha512-uWFwssE5dHfQ8lH+ktrsD9JA49+Qa0gtxZHUs62z1e91NgGz6O7jefHGI6aygNyKNS45pnnBSDSP/zV977MsOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.115.0': + resolution: {integrity: sha512-fZbqt8y/sKQ+v6bBCuv/mYYFoC0+fZI3mGDDEemmDOhT78+aUs2+4ZMdbd2btlXmnLaScl37r8IRbhnok5Ka9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.115.0': + resolution: {integrity: sha512-1ej/MjuTY9tJEunU/hUPIFmgH5PqgMQoRjNOvOkibtJ3Zqlw/+Lc+HGHDNET8sjbgIkWzdhX+p4J96A5CPdbag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.115.0': + resolution: {integrity: sha512-HjsZbJPH9mMd4swJRywVMsDZsJX0hyKb1iNHo5ijRl5yhtbO3lj7ImSrrL1oZ1VEg0te4iKmDGGz/6YPLd1G8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.115.0': + resolution: {integrity: sha512-zhhePoBrd7kQx3oClX/W6NldsuCbuMqaN9rRsY+6/WoorAb4j490PG/FjqgAXscWp2uSW2WV9L+ksn0wHrvsrg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.115.0': + resolution: {integrity: sha512-t/IRojvUE9XrKu+/H1b8YINug+7Q6FLls5rsm2lxB5mnS8GN/eYAYrPgHkcg9/1SueRDSzGpDYu3lGWTObk1zw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.115.0': + resolution: {integrity: sha512-79jBHSSh/YpQRAmvYoaCfpyToRbJ/HBrdB7hxK2ku2JMehjopTVo+xMJss/RV7/ZYqeezgjvKDQzapJbgcjVZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.115.0': + resolution: {integrity: sha512-nA1TpxkhNTIOMMyiSSsa7XIVJVoOU/SsVrHIz3gHvWweB5PHCQfO7w+Lb2EP0lBWokv7HtA/KbF7aLDoXzmuMw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.115.0': + resolution: {integrity: sha512-9iVX789DoC3SaOOG+X6NcF/tVChgLp2vcHffzOC2/Z1JTPlz6bMG2ogvcW6/9s0BG2qvhNQImd+gbWYeQbOwVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.115.0': + resolution: {integrity: sha512-RmQmk+mjCB0nMNfEYhaCxwofLo1Z95ebHw1AGvRiWGCd4zhCNOyskgCbMogIcQzSB3SuEKWgkssyaiQYVAA4hQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.115.0': + resolution: {integrity: sha512-viigraWWQhhDvX5aGq+wrQq58k00Xq3MHz/0R4AFMxGlZ8ogNonpEfNc73Q5Ly87Z6sU9BvxEdG0dnYTfVnmew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.115.0': + resolution: {integrity: sha512-IzGCrMwXhpb4kTXy/8lnqqqwjI7eOvy+r9AhVw+hsr8t1ecBBEHprcNy0aKatFHN6hsX7UMHHQmBAQjVvL/p1A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.115.0': + resolution: {integrity: sha512-/ym+Absk/TLFvbhh3se9XYuI1D7BrUVHw4RaG/2dmWKgBenrZHaJsgnRb7NJtaOyjEOLIPtULx1wDdVL0SX2eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.115.0': + resolution: {integrity: sha512-AQSZjIR+b+Te7uaO/hGTMjT8/oxlYrvKrOTi4KTHF/O6osjHEatUQ3y6ZW2+8+lJxy20zIcGz6iQFmFq/qDKkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.115.0': + resolution: {integrity: sha512-oxUl82N+fIO9jIaXPph8SPPHQXrA08BHokBBJW8ct9F/x6o6bZE6eUAhUtWajbtvFhL8UYcCWRMba+kww6MBlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.115.0': + resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + + '@oxc-project/types@0.128.0': + resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@prisma/client@6.19.2': + resolution: {integrity: sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==} + engines: {node: '>=18.18'} + peerDependencies: + prisma: '*' + typescript: '>=5.1.0' + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + '@prisma/config@6.19.2': + resolution: {integrity: sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==} + + '@prisma/debug@6.19.2': + resolution: {integrity: sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': + resolution: {integrity: sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==} + + '@prisma/engines@6.19.2': + resolution: {integrity: sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==} + + '@prisma/fetch-engine@6.19.2': + resolution: {integrity: sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==} + + '@prisma/get-platform@6.19.2': + resolution: {integrity: sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.18': + resolution: {integrity: sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.18': + resolution: {integrity: sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': + resolution: {integrity: sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': + resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': + resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': + resolution: {integrity: sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': + resolution: {integrity: sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': + resolution: {integrity: sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.18': + resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==} + + '@rolldown/pluginutils@1.0.0-rc.2': + resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==} + + '@rollup/plugin-babel@5.3.1': + resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} + engines: {node: '>= 10.0.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@types/babel__core': ^7.1.9 + rollup: ^1.20.0||^2.0.0 + peerDependenciesMeta: + '@types/babel__core': + optional: true + + '@rollup/plugin-node-resolve@15.3.1': + resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-replace@2.4.2': + resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==} + peerDependencies: + rollup: ^1.20.0 || ^2.0.0 + + '@rollup/plugin-terser@0.4.4': + resolution: {integrity: sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@3.1.0': + resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} + engines: {node: '>= 8.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0 + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@simple-libs/child-process-utils@1.0.2': + resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} + engines: {node: '>=18'} + + '@simple-libs/stream-utils@1.2.0': + resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} + engines: {node: '>=18'} + + '@sindresorhus/base62@1.0.0': + resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} + engines: {node: '>=18'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@std-uritemplate/std-uritemplate@2.0.12': + resolution: {integrity: sha512-s/R828vYTrFPgAOFAUiHuP4i1eSZHCO9PKVfzVR+2DFERJ/G71BpZJVObM2yy3xeMjMW3wB61gvanb6/pV1lJw==} + + '@stylistic/eslint-plugin@5.10.0': + resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + '@surma/rollup-plugin-off-main-thread@2.2.3': + resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} + + '@swc/helpers@0.5.19': + resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} + + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@ts-rest/core@3.52.1': + resolution: {integrity: sha512-tAjz7Kxq/grJodcTA1Anop4AVRDlD40fkksEV5Mmal88VoZeRKAG8oMHsDwdwPZz+B/zgnz0q2sF+cm5M7Bc7g==} + peerDependencies: + '@types/node': ^18.18.7 || >=20.8.4 + zod: ^3.22.3 + peerDependenciesMeta: + '@types/node': + optional: true + zod: + optional: true + + '@ts-rest/fastify@3.52.1': + resolution: {integrity: sha512-y+w3ENayNI77T0l2gcr9mobF3Nfc4yIb4487mvNMsMUkGLMHmaWzhLy3Mo/mswuoA+tVdOkrD8tJD38bLY7iYQ==} + peerDependencies: + '@ts-rest/core': ~3.52.0 + fastify: ^4.0.0 + zod: ^3.22.3 + peerDependenciesMeta: + zod: + optional: true + + '@ts-rest/open-api@3.52.1': + resolution: {integrity: sha512-VY91g6HFzCe1/fNgX4Y3HzaJbIdztCGHTg34drkh7bKgtNOizLJLXV0wwIkMwLGvjhRHojJxnwYxHjGMIkScwg==} + peerDependencies: + '@ts-rest/core': ~3.52.0 + zod: ^3.22.3 + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/aws-lambda@8.10.161': + resolution: {integrity: sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/bunyan@1.8.11': + resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@0.0.39': + resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/memcached@2.2.10': + resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/mysql@2.15.27': + resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} + + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + + '@types/oracledb@6.5.2': + resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==} + + '@types/pg-pool@2.0.7': + resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@types/swagger-schema-official@2.0.25': + resolution: {integrity: sha512-T92Xav+Gf/Ik1uPW581nA+JftmjWPgskw/WBf4TJzxRG/SJ+DfNnNE+WuZ4mrXuzflQMqMkm1LSYjzYW7MB1Cg==} + + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@typescript-eslint/eslint-plugin@8.57.0': + resolution: {integrity: sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/eslint-plugin@8.59.2': + resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.57.0': + resolution: {integrity: sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.59.2': + resolution: {integrity: sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.57.0': + resolution: {integrity: sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.59.2': + resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/rule-tester@8.57.0': + resolution: {integrity: sha512-qs4OapXmAIj3so85/20lQG1WrBSSvDE/3b42Orl3lpZkaOlNXtbfKzL+9EPaY5wSEgdlhKEpympAMFHPG9i72Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + '@typescript-eslint/scope-manager@8.57.0': + resolution: {integrity: sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.59.2': + resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.57.0': + resolution: {integrity: sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/tsconfig-utils@8.59.2': + resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.57.0': + resolution: {integrity: sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.59.2': + resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.57.0': + resolution: {integrity: sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.59.2': + resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.57.0': + resolution: {integrity: sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/typescript-estree@8.59.2': + resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.57.0': + resolution: {integrity: sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.59.2': + resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.57.0': + resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/visitor-keys@8.59.2': + resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unocss/cli@66.6.6': + resolution: {integrity: sha512-78SY8j4hAVelK+vP/adsDGaSjEITasYLFECJLHWxUJSzK+G9UIc5wtL/u4jA+zKvwVkHcDvbkcO5K6wwwpAixg==} + engines: {node: '>=14'} + hasBin: true + + '@unocss/config@66.6.6': + resolution: {integrity: sha512-menlnkqAFX/4wR2aandY8hSqrt01JE+rOzvtQxWaBt8kf1du62b0sS72FE5Z40n6HlEsEbF91N9FCfhnzG6i6g==} + engines: {node: '>=14'} + + '@unocss/core@0.65.4': + resolution: {integrity: sha512-a2JOoFutrhqd5RgPhIR5FIXrDoHDU3gwCbPrpT6KYTjsqlSc/fv02yZ+JGOZFN3MCFhCmaPTs+idDFtwb3xU8g==} + + '@unocss/core@66.6.6': + resolution: {integrity: sha512-Sbbx0ZQqmV8K2lg8E+z9MJzWb1MgRtJnvqzxDIrNuBjXasKhbcFt5wEMBtEZJOr63Z4ck0xThhZK53HmYT2jmg==} + + '@unocss/extractor-arbitrary-variants@66.6.6': + resolution: {integrity: sha512-uMzekF2miZRUwSZGvy3yYQiBAcSAs9LiXK8e3NjldxEw8xcRDWgTErxgStRoBeAD6UyzDcg/Cvwtf2guMbtR+g==} + + '@unocss/inspector@66.6.6': + resolution: {integrity: sha512-CpXIsqHwxCXJtUjUz6S29diHCIA+EJ1u5WML/6m2YPI4ObgWAVKrExy09inSg2icS52lFkWWdWQSeqc9kl5W6Q==} + + '@unocss/preset-attributify@66.6.6': + resolution: {integrity: sha512-3H12UI1rBt60PQy+S4IEeFYWu1/WQFuc2yhJ5mu/RCvX5/qwlIGanBpuh+xzTPXU1fWBlZN68yyO9uWOQgTqZQ==} + + '@unocss/preset-icons@66.6.6': + resolution: {integrity: sha512-HfIEEqf3jyKexOB2Sux556n0NkPoUftb2H4+Cf7prJvKHopMkZ/OUkXjwvUlxt1e5UpAEaIa0A2Ir7+ApxXoGA==} + + '@unocss/preset-mini@66.6.6': + resolution: {integrity: sha512-k+/95PKMPOK57cJcSmz34VkIFem8BlujRRx6/L0Yusw7vLJMh98k0rPhC5s+NomZ/d9ZPgbNylskLhItJlak3w==} + + '@unocss/preset-tagify@66.6.6': + resolution: {integrity: sha512-KgBXYPYS0g4TVC3NLiIB78YIqUlvDLanz1EHIDo34rOTUfMgY8Uf5VuDJAzMu4Sc0LiwwBJbk6nIG9/Zm7ufWg==} + + '@unocss/preset-typography@66.6.6': + resolution: {integrity: sha512-SM1km5nqt15z4sTabfOobSC633I5Ol5nnme6JFTra4wiyCUNs+Cg31nJ6jnopWDUT4SEAXqfUH7jKSSoCnI6ZA==} + + '@unocss/preset-uno@66.6.6': + resolution: {integrity: sha512-40PcBDtlhW7QP7e/WOxC684IhN5T1dXvj1dgx9ZzK+8lEDGjcX7bN2noW4aSenzSrHymeSsMrL/0ltL4ED/5Zw==} + + '@unocss/preset-web-fonts@66.6.6': + resolution: {integrity: sha512-5ikwgrJB8VPzKd0bqgGNgYUGix90KFnVtKJPjWTP5qsv3+ZtZnea1rRbAFl8i2t52hg35msNBsQo+40IC3xB6A==} + + '@unocss/preset-wind3@66.6.6': + resolution: {integrity: sha512-rk6gPPIQ7z2DVucOqp7XZ4vGpKAuzBV1vtUDvDh5WscxzO/QlqaeTfTALk5YgGpmLaF4+ns6FrTgLjV+wHgHuQ==} + + '@unocss/preset-wind4@66.6.6': + resolution: {integrity: sha512-caTDM9rZSlp4tyPWWAnwMvQr2PXq53LsEYwd3N8zj0ou2hcsqptJvF+mFvyhvGF66x26wWJr/FwuUEhh7qycaw==} + + '@unocss/preset-wind@66.6.6': + resolution: {integrity: sha512-TMy3lZ35FP/4QqDHOLWZmV+RoOGWUDqnDEOTjOKI1CQARGta0ppUmq+IZMuI1ZJLuOa4OZ9V6SfnwMXwRLgXmw==} + + '@unocss/rule-utils@0.65.4': + resolution: {integrity: sha512-+EzdJEWcqGcO6HwbBTe7vEdBRpuKkBiz4MycQeLD6GEio04T45y6VHHO7/WTqxltbO4YwwW9/s2TKRMxKtoG8g==} + engines: {node: '>=14'} + + '@unocss/rule-utils@66.6.6': + resolution: {integrity: sha512-krWtQKGshOaqQMuxeGq1NOA8NL35VdpYlmQEWOe39BY6TACT51bgQFu40MRfsAIMZZtoGS2YYTrnHojgR92omw==} + engines: {node: '>=14'} + + '@unocss/transformer-attributify-jsx@66.6.6': + resolution: {integrity: sha512-NnDchmN2EeFLy4lfVqDgNe9j1+w2RLL2L9zKECXs5g6rDVfeeEK6FNgxSq3XnPcKltjNCy1pF4MaDOROG7r8yA==} + + '@unocss/transformer-compile-class@66.6.6': + resolution: {integrity: sha512-KKssJxU8fZ9x84yznIirbtta2sB0LN/3lm0bp+Wl1298HITaNiVeG2n26iStQ3N7r240xRN2RarxncSVCMFwWw==} + + '@unocss/transformer-directives@0.65.4': + resolution: {integrity: sha512-zkoDEwzPkgXi6ohW7P11gbArwfTRMZ9knYSUYoPEltQz+UZYzeRQ85exiAmdz5MsbCAuhQEr577Kd/CWfhjEuA==} + + '@unocss/transformer-directives@66.6.6': + resolution: {integrity: sha512-CReFTcBfMtKkRvzIqxL20VptWt5C1Om27dwoKzyVFBXv0jzViWysbu0y0AQg3bsgD4cFqndFyAGyeL84j0nbKg==} + + '@unocss/transformer-variant-group@66.6.6': + resolution: {integrity: sha512-j4L/0Tw6AdMVB2dDnuBlDbevyL1/0CAk88a77VF/VjgEIBwB9VXsCCUsxz+2Dohcl7N2GMm7+kpaWA6qt2PSaA==} + + '@unocss/vite@66.6.6': + resolution: {integrity: sha512-DgG7KcUUMtoDhPOlFf2l4dR+66xZ23SdZvTYpikk5nZfLCzZd62vedutD7x0bTR6VpK2YRq39B+F+Z6TktNY/w==} + peerDependencies: + vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0 + + '@vitejs/plugin-vue@6.0.4': + resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vue: ^3.2.25 + + '@vitest/coverage-v8@4.1.5': + resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} + peerDependencies: + '@vitest/browser': 4.1.5 + vitest: 4.1.5 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/eslint-plugin@1.6.17': + resolution: {integrity: sha512-sIVY9ZeVcXyPxFCNRkIt8Yw4keKIcUyp9/8qnmuomPwE+ST1htw5sZsbqdUMTiah9SmCg1JYoK9RqdDtPeNYYg==} + engines: {node: '>=18'} + peerDependencies: + '@typescript-eslint/eslint-plugin': '*' + eslint: '>=8.57.0' + typescript: '>=5.0.0' + vitest: '*' + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + typescript: + optional: true + vitest: + optional: true + + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.30': + resolution: {integrity: sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==} + + '@vue/compiler-dom@3.5.30': + resolution: {integrity: sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==} + + '@vue/compiler-sfc@3.5.30': + resolution: {integrity: sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==} + + '@vue/compiler-ssr@3.5.30': + resolution: {integrity: sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/eslint-config-typescript@14.7.0': + resolution: {integrity: sha512-iegbMINVc+seZ/QxtzWiOBozctrHiF2WvGedruu2EbLujg9VuU0FQiNcN2z1ycuaoKKpF4m2qzB5HDEMKbxtIg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.10.0 || ^10.0.0 + eslint-plugin-vue: ^9.28.0 || ^10.0.0 + typescript: '>=4.8.4' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.30': + resolution: {integrity: sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==} + + '@vue/runtime-core@3.5.30': + resolution: {integrity: sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==} + + '@vue/runtime-dom@3.5.30': + resolution: {integrity: sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==} + + '@vue/server-renderer@3.5.30': + resolution: {integrity: sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==} + peerDependencies: + vue: 3.5.30 + + '@vue/shared@3.5.30': + resolution: {integrity: sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==} + + '@vue/tsconfig@0.7.0': + resolution: {integrity: sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==} + peerDependencies: + typescript: 5.x + vue: ^3.4.0 + peerDependenciesMeta: + typescript: + optional: true + vue: + optional: true + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats-draft2019@1.6.1: + resolution: {integrity: sha512-JQPvavpkWDvIsBp2Z33UkYCtXCSpW4HD3tAZ+oL4iEFOk9obQZffx0yANwECt6vzr6ET+7HN5czRyqXbnq/u0Q==} + peerDependencies: + ajv: '*' + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + any-base@1.1.0: + resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + are-docs-informative@0.0.2: + resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + axios-retry@4.5.0: + resolution: {integrity: sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==} + peerDependencies: + axios: 0.x || 1.x + + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.14.2: + resolution: {integrity: sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.38: + resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} + engines: {node: '>=6.0.0'} + hasBin: true + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bn.js@4.12.3: + resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + brorand@1.1.0: + resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builtin-modules@5.0.0: + resolution: {integrity: sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==} + engines: {node: '>=18.20'} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@3.1.0: + resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + + c12@3.3.3: + resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + cache-manager@7.2.8: + resolution: {integrity: sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==} + + cacheable@2.3.3: + resolution: {integrity: sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + camelize-ts@3.0.0: + resolution: {integrity: sha512-cgRwKKavoDKLTjO4FQTs3dRBePZp/2Y9Xpud0FhuCOTE86M2cniKN4CCXgRnsyXNMmQMifVHcv6SPaMtTx6ofQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + check-disk-space@3.4.0: + resolution: {integrity: sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==} + engines: {node: '>=16'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cidr-regex@3.1.1: + resolution: {integrity: sha512-RBqYd32aDwbCMFJRL6wHOlDNYJsPNTt8vC82ErHF5vKt8QQzxm1FrkW8s/R5pVrXMf17sba09Uoy91PKiddAsw==} + engines: {node: '>=10'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.1: + resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clean-regexp@1.0.0: + resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} + engines: {node: '>=4'} + + cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-truncate@5.2.0: + resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} + engines: {node: '>=20'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + clipboard@2.0.11: + resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + comment-json@4.4.1: + resolution: {integrity: sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==} + engines: {node: '>= 6'} + + comment-parser@1.4.5: + resolution: {integrity: sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw==} + engines: {node: '>= 12.0.0'} + + comment-parser@1.4.6: + resolution: {integrity: sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg==} + engines: {node: '>= 12.0.0'} + + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@9.3.1: + resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} + engines: {node: '>=18'} + + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} + engines: {node: '>=18'} + hasBin: true + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-js-compat@3.49.0: + resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': '*' + cosmiconfig: '>=9' + typescript: '>=5' + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cron-validator@1.4.0: + resolution: {integrity: sha512-wGcJ9FCy65iaU6egSH8b5dZYJF7GU/3Jh06wzaT9lsa5dbqExjljmu+0cJ8cpKn+vUyZa/EM4WAxeLR6SypJXw==} + + cron@4.4.0: + resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} + engines: {node: '>=18.x'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto-random-string@2.0.0: + resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} + engines: {node: '>=8'} + + css-functions-list@3.3.3: + resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} + engines: {node: '>=12'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + deeks@3.1.0: + resolution: {integrity: sha512-e7oWH1LzIdv/prMQ7pmlDlaVoL64glqzvNgkgQNgyec9ORPHrT2jaOqMtRyqJuwWjtfb6v+2rk9pmaHj+F137A==} + engines: {node: '>= 16'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegate@3.2.0: + resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + + doc-path@4.1.1: + resolution: {integrity: sha512-h1ErTglQAVv2gCnOpD3sFS6uolDbOKHDU1BZq+Kl3npPqroU3dYL42lUgMfd5UimlwtRgp7C9dLGwqQ5D2HYgQ==} + engines: {node: '>=16'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + dompurify@3.3.2: + resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==} + engines: {node: '>=20'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + effect@3.18.4: + resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-to-chromium@1.5.376: + resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} + + elliptic@6.6.1: + resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + empathic@2.0.0: + resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.24.0: + resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.1: + resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} + engines: {node: '>= 0.4'} + + es-toolkit@1.48.1: + resolution: {integrity: sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==} + + es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-flat-gitignore@2.3.0: + resolution: {integrity: sha512-bg4ZLGgoARg1naWfsINUUb/52Ksw/K22K+T16D38Y8v+/sGwwIYrGvH/JBjOin+RQtxxC9tzNNiy4shnGtGyyQ==} + peerDependencies: + eslint: ^9.5.0 || ^10.0.0 + + eslint-flat-config-utils@3.2.0: + resolution: {integrity: sha512-PHgo1X5uqIorJONLVD9BIaOSdoYFD3z/AeJljdqDPlWVRpeCYkDbK9k0AXoYVqqNJr6FEYIEr5Rm2TSktLQcHw==} + + eslint-json-compat-utils@0.2.3: + resolution: {integrity: sha512-RbBmDFyu7FqnjE8F0ZxPNzx5UaptdeS9Uu50r7A+D7s/+FCX+ybiyViYEgFUaFIFqSWJgZRTpL5d8Kanxxl2lQ==} + engines: {node: '>=12'} + peerDependencies: + '@eslint/json': '*' + eslint: '*' + jsonc-eslint-parser: ^2.4.0 || ^3.0.0 + peerDependenciesMeta: + '@eslint/json': + optional: true + + eslint-merge-processors@2.0.0: + resolution: {integrity: sha512-sUuhSf3IrJdGooquEUB5TNpGNpBoQccbnaLHsb1XkBLUPPqCNivCpY05ZcpCOiV9uHwO2yxXEWVczVclzMxYlA==} + peerDependencies: + eslint: '*' + + eslint-plugin-antfu@3.2.2: + resolution: {integrity: sha512-Qzixht2Dmd/pMbb5EnKqw2V8TiWHbotPlsORO8a+IzCLFwE0RxK8a9k4DCTFPzBwyxJzH+0m2Mn8IUGeGQkyUw==} + peerDependencies: + eslint: '*' + + eslint-plugin-command@3.5.2: + resolution: {integrity: sha512-PA59QAkQDwvcCMEt5lYLJLI3zDGVKJeC4id/pcRY2XdRYhSGW7iyYT1VC1N3bmpuvu6Qb/9QptiS3GJMjeGTJg==} + peerDependencies: + '@typescript-eslint/rule-tester': '*' + '@typescript-eslint/typescript-estree': '*' + '@typescript-eslint/utils': '*' + eslint: '*' + + eslint-plugin-depend@1.5.0: + resolution: {integrity: sha512-i3UeLYmclf1Icp35+6W7CR4Bp2PIpDgBuf/mpmXK5UeLkZlvYJ21VuQKKHHAIBKRTPivPGX/gZl5JGno1o9Y0A==} + peerDependencies: + eslint: '>=8.40.0' + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-import-lite@0.6.0: + resolution: {integrity: sha512-80vevx2A7i3H7n1/6pqDO8cc5wRz6OwLDvIyVl9UflBV1N1f46e9Ihzi65IOLYoSxM6YykK2fTw1xm0Ixx6aTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + eslint-plugin-jsdoc@62.9.0: + resolution: {integrity: sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-jsonc@3.1.2: + resolution: {integrity: sha512-dopTxdB22iuOkgKyJCupEC5IYBItUT4J/teq1H5ddUObcaYhOURxtJElZczdcYnnKCghNU/vccuyPkliy2Wxsg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-n@17.24.0: + resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=8.23.0' + + eslint-plugin-no-only-tests@3.3.0: + resolution: {integrity: sha512-brcKcxGnISN2CcVhXJ/kEQlNa0MEfGRtwKtWA16SkqXHKitaKIMrfemJKLKX1YqDU5C/5JY3PvZXd5jEW04e0Q==} + engines: {node: '>=5.0.0'} + + eslint-plugin-perfectionist@5.9.0: + resolution: {integrity: sha512-8TWzg02zmnBdZwCkWLi8jhzqXI+fE7Z/RwV8SL6xD45tJ8Bp3wGuYL2XtQgfe/Wd0eBqOUX+s6ey73IyszvKTA==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-pnpm@1.6.0: + resolution: {integrity: sha512-dxmt9r3zvPaft6IugS4i0k16xag3fTbOvm/road5uV9Y8qUCQT0xzheSh3gMlYAlC6vXRpfArBDsTZ7H7JKCbg==} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + eslint-plugin-regexp@3.1.0: + resolution: {integrity: sha512-qGXIC3DIKZHcK1H9A9+Byz9gmndY6TTSRkSMTZpNXdyCw2ObSehRgccJv35n9AdUakEjQp5VFNLas6BMXizCZg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-toml@1.3.1: + resolution: {integrity: sha512-1l00fBP03HIt9IPV7ZxBi7x0y0NMdEZmakL1jBD6N/FoKBvfKxPw5S8XkmzBecOnFBTn5Z8sNJtL5vdf9cpRMQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-unicorn@64.0.0: + resolution: {integrity: sha512-rNZwalHh8i0UfPlhNwg5BTUO1CMdKNmjqe+TgzOTZnpKoi8VBgsW7u9qCHIdpxEzZ1uwrJrPF0uRb7l//K38gA==} + engines: {node: ^20.10.0 || >=21.0.0} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-unused-imports@4.4.1: + resolution: {integrity: sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==} + peerDependencies: + '@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0 + eslint: ^10.0.0 || ^9.0.0 || ^8.0.0 + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + + eslint-plugin-vue@10.8.0: + resolution: {integrity: sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-eslint-parser: ^10.0.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-plugin-yml@3.3.1: + resolution: {integrity: sha512-isntsZchaTqDMNNkD+CakrgA/pdUoJ45USWBKpuqfAW1MCuw731xX/vrXfoJFZU3tTFr24nCbDYmDfT2+g4QtQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + eslint: '>=9.38.0' + + eslint-processor-vue-blocks@2.0.0: + resolution: {integrity: sha512-u4W0CJwGoWY3bjXAuFpc/b6eK3NQEI8MoeW7ritKj3G3z/WtHrKjkqf+wk8mPEy5rlMGS+k6AZYOw2XBoN/02Q==} + peerDependencies: + '@vue/compiler-sfc': ^3.3.0 + eslint: '>=9.0.0' + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.5.0: + resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@1.0.1: + resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eta@3.5.0: + resolution: {integrity: sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==} + engines: {node: '>=6.0.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + + fast-copy@4.0.2: + resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + + fast-jwt@5.0.6: + resolution: {integrity: sha512-LPE7OCGUl11q3ZgW681cEU2d0d2JZ37hhJAmetCgNyW8waVaJVZXhyFF6U2so1Iim58Yc7pfxJe2P7MNetQH2g==} + engines: {node: '>=20'} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastfall@1.5.1: + resolution: {integrity: sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==} + engines: {node: '>=0.10.0'} + + fastify-keycloak-adapter@3.0.2: + resolution: {integrity: sha512-Zm5PowNKxgHPztM1wC0hJmnFYPrUoduREEIzxwz2dnTpMm5qZkp7btweTuGW5AzDbjBu3EVM0fNyPxyR3OkwwA==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify-plugin@6.0.0: + resolution: {integrity: sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + + fastparallel@2.4.1: + resolution: {integrity: sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fastseries@1.7.2: + resolution: {integrity: sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==} + + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + file-entry-cache@11.1.2: + resolution: {integrity: sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-type@21.3.0: + resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} + engines: {node: '>=20'} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flat-cache@6.1.20: + resolution: {integrity: sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + focus-trap-vue@4.1.0: + resolution: {integrity: sha512-RvK28mED+4fqoqnJQyadfoiKZU2G/QEez+EnJtdsg83DjiuKetimvDGas9ot4p5bn1J09rDmKLtKFMBkwQc+JA==} + peerDependencies: + focus-trap: ^7.0.0 + vue: ^3.0.0 + + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fork-ts-checker-webpack-plugin@9.1.0: + resolution: {integrity: sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==} + engines: {node: '>=14.21.3'} + peerDependencies: + typescript: '>3.6.0' + webpack: ^5.11.0 + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fp-ts@2.16.11: + resolution: {integrity: sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-monkey@1.1.0: + resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-own-enumerable-property-symbols@3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + git-raw-commits@5.0.1: + resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} + engines: {node: '>=18'} + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. + hasBin: true + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + global-prefix@4.0.0: + resolution: {integrity: sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==} + engines: {node: '>=16'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@16.2.0: + resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} + engines: {node: '>=20'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + good-listener@1.2.2: + resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grant@5.4.24: + resolution: {integrity: sha512-PD5AvSI7wgCBDi2mEd6M/TIe+70c/fVc3Ik4B0s4mloWTy9J800eUEcxivOiyqSP9wvBy2QjWq1JR8gOfDMnEg==} + engines: {node: '>=12.0.0'} + + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + + hashery@1.5.0: + resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==} + engines: {node: '>=20'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + hmac-drbg@1.0.1: + resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-tags@5.1.0: + resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==} + engines: {node: '>=20.10'} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http2-client@1.3.5: + resolution: {integrity: sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-in-the-middle@2.0.6: + resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + + import-in-the-middle@3.0.0: + resolution: {integrity: sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==} + engines: {node: '>=18'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + io-ts@2.2.22: + resolution: {integrity: sha512-FHCCztTkHoV9mdBsHpocLpdTAfh956ZQcIkWQxxS0U5HT53vtrcuYdQneEJKH6xILaLNzXVl2Cvwtoy8XNN0AA==} + peerDependencies: + fp-ts: ^2.5.0 + + ip-regex@4.3.0: + resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} + engines: {node: '>=8'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-builtin-module@5.0.0: + resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} + engines: {node: '>=18.20'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-cidr@4.0.2: + resolution: {integrity: sha512-z4a1ENUajDbEl/Q6/pVBpTR1nBjjEE1X7qb7bmWYanNnPoKAvUCPFKeXV6Fe4mgTkWKBqiHIcwsI3SndiO5FeA==} + engines: {node: '>=10'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@1.0.1: + resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} + engines: {node: '>=0.10.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-regexp@1.0.0: + resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} + engines: {node: '>=0.10.0'} + + is-retry-allowed@2.2.0: + resolution: {integrity: sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==} + engines: {node: '>=10'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + javascript-time-ago@2.6.4: + resolution: {integrity: sha512-7K/Z37LuwVaxxjutUDd1pXpznufPcox0b1UYu00ksAMMlV6IsxIvduwL3kgfPxuBVF8jVj7nhrKMPDslMq94aQ==} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + jsdoc-type-pratt-parser@7.1.1: + resolution: {integrity: sha512-/2uqY7x6bsrpi3i9LVU6J89352C0rpMk0as8trXxCtvd4kPk1ke/Eyif6wqfSLvoNJqcDG9Vk4UsXgygzCt2xA==} + engines: {node: '>=20.0.0'} + + jsdoc-type-pratt-parser@7.2.0: + resolution: {integrity: sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==} + engines: {node: '>=20.0.0'} + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-2-csv@5.5.10: + resolution: {integrity: sha512-Dep8wO3Fr5wNjQevO2Z8Y7yeee/nYSGRsi7q6zJDKEVHxXkXT+v21vxHmDX923UzmCXXkSo62HaTz6eTWzFLaw==} + engines: {node: '>= 16'} + deprecated: A security vulnerability has been reported with the preventCsvInjection option which has been fixed in version 5.5.11. Please upgrade as soon as possible. + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-eslint-parser@3.1.0: + resolution: {integrity: sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jwk-to-pem@2.0.7: + resolution: {integrity: sha512-cSVphrmWr6reVchuKQZdfSs4U9c5Y4hwZggPoz6cbVnTpAVgGRpEuQng86IyqLeGZlhTh+c4MAreB6KbdQDKHQ==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + katex@0.16.45: + resolution: {integrity: sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==} + hasBin: true + + keycloak-js@26.2.3: + resolution: {integrity: sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lint-staged@16.4.0: + resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==} + engines: {node: '>=20.17'} + hasBin: true + + listr2@9.0.5: + resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==} + engines: {node: '>=20.0.0'} + + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + + loader-runner@4.3.2: + resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} + engines: {node: '>=6.11.5'} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + + magic-regexp@0.10.0: + resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} + + magic-string@0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mathml-tag-names@4.0.0: + resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + memfs@3.5.3: + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} + engines: {node: '>= 4.0.0'} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} + engines: {node: '>=20'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimalistic-crypto-utils@1.0.1: + resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.1: + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} + + mnemonist@0.40.4: + resolution: {integrity: sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==} + + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + module-replacements@2.11.0: + resolution: {integrity: sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA==} + + moo@0.5.3: + resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.12.10: + resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + multer@2.1.1: + resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} + engines: {node: '>= 10.16.0'} + + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@5.0.9: + resolution: {integrity: sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==} + engines: {node: ^18 || >=20} + hasBin: true + + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + natural-orderby@5.0.0: + resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} + engines: {node: '>=18'} + + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + nestjs-pino@4.6.0: + resolution: {integrity: sha512-MzSgnOu9MhRT/f7MsvoDnxat11D9JRJYwL1t+tI6J44UrNz9rUVDpceEh9VFsyfiiIJKUri5S+/snMOoaWh7YA==} + engines: {node: '>= 14'} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + pino: ^7.5.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + rxjs: ^7.1.0 + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-emoji@1.11.0: + resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + + node-fetch-h2@2.3.0: + resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==} + engines: {node: 4.x || >=6.0.0} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-readfiles@0.2.0: + resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==} + + node-releases@2.0.48: + resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} + engines: {node: '>=18'} + + nodemon@3.1.14: + resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + nypm@0.6.5: + resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} + engines: {node: '>=18'} + hasBin: true + + oas-kit-common@1.0.8: + resolution: {integrity: sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==} + + oas-linter@3.2.2: + resolution: {integrity: sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==} + + oas-resolver@2.5.6: + resolution: {integrity: sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==} + hasBin: true + + oas-schema-walker@1.1.5: + resolution: {integrity: sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==} + + oas-validator@5.0.8: + resolution: {integrity: sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==} + + oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-deep-merge@2.0.0: + resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + + openapi3-ts@2.0.2: + resolution: {integrity: sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + oxc-parser@0.115.0: + resolution: {integrity: sha512-2w7Xn3CbS/zwzSY82S5WLemrRu3CT57uF7Lx8llrE/2bul6iMTcJE4Rbls7GDNbLn3ttATI68PfOz2Pt3KZ2cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-walker@0.7.0: + resolution: {integrity: sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A==} + peerDependencies: + oxc-parser: '>=0.98.0' + + p-debounce@4.0.0: + resolution: {integrity: sha512-4Ispi9I9qYGO4lueiLDhe4q4iK5ERK8reLsuzH6BPaXn53EGaua8H66PXIFGrW897hwjXp+pVLrm/DLxN0RF0A==} + engines: {node: '>=12'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-gitignore@2.0.0: + resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==} + engines: {node: '>=14'} + + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch-browser@2.2.6: + resolution: {integrity: sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==} + engines: {node: '>=8.6'} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pinia@2.3.1: + resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==} + peerDependencies: + typescript: '>=4.4.4' + vue: ^2.7.0 || ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-http@11.0.0: + resolution: {integrity: sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + pnpm-workspace-yaml@1.6.0: + resolution: {integrity: sha512-uUy4dK3E11sp7nK+hnT7uAWfkBMe00KaUw8OG3NuNlYQoTk4sc9pcdIy1+XIP85v9Tvr02mK3JPaNNrP0QyRaw==} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-html@1.8.1: + resolution: {integrity: sha512-OLF6P7qctfAWayOhLpcVnTGqVeJzu2W3WpIYelfz2+JV5oGxfkcEvweN9U4XpeqE0P98dcD9ssusGwlF0TK0uQ==} + engines: {node: ^12 || >=14} + + postcss-safe-parser@6.0.0: + resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.3.3 + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + + pretty-bytes@6.1.1: + resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} + engines: {node: ^14.13.1 || >=16.0.0} + + prisma@6.19.2: + resolution: {integrity: sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==} + engines: {node: '>=18.18'} + hasBin: true + peerDependencies: + typescript: '>=5.1.0' + peerDependenciesMeta: + typescript: + optional: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + protobufjs@7.6.4: + resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} + engines: {node: '>=12.0.0'} + + protobufjs@8.0.0: + resolution: {integrity: sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==} + engines: {node: '>=12.0.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qified@0.6.0: + resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} + engines: {node: '>=20'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + rate-limiter-flexible@4.0.1: + resolution: {integrity: sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ==} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + re2-wasm@1.0.2: + resolution: {integrity: sha512-VXUdgSiUrE/WZXn6gUIVVIsg0+Hp6VPZPOaHCay+OuFKy6u/8ktmeNEf+U5qSA8jzGGFsg8jrDNu1BeHpz2pJA==} + engines: {node: '>=10'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + refa@0.12.1: + resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + reftools@1.1.9: + resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regexp-ast-analysis@0.7.1: + resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + + relative-time-format@1.1.12: + resolution: {integrity: sha512-qaZBjmRIuXLfuLnzgqpFdBPa5W0euSX1tMnoMUHGPphLwJmrt8xbNiOIHrlvYOD6oNJ0M5owPCZyPibI8de5pQ==} + + request-compose@2.1.7: + resolution: {integrity: sha512-27amNkWTK4Qq25XEwdmrhb4VLMiQzRSKuDfsy1o1griykcyXk5MxMHmJG+OKTRdO9PgsO7Kkn7GrEkq0UAIIMQ==} + engines: {node: '>=12.0.0'} + + request-oauth@1.0.1: + resolution: {integrity: sha512-85THTg1RgOYtqQw42JON6AqvHLptlj1biw265Tsq4fD4cPdUvhDB2Qh9NTv17yCD322ROuO9aOmpc4GyayGVBA==} + engines: {node: '>=8.0.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + + rolldown@1.0.0-rc.18: + resolution: {integrity: sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@2.80.0: + resolution: {integrity: sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==} + engines: {node: '>=10.0.0'} + hasBin: true + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + schemes@1.4.0: + resolution: {integrity: sha512-ImFy9FbCsQlVgnE3TCWmLPCFnVzx0lHL/l+umHplDqAKd0dzFpnS6lFZIpagBlYhKwzVmlV36ec0Y1XTu8JBAQ==} + + scslre@0.3.0: + resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} + engines: {node: ^14.0.0 || >=16.0.0} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + select@1.1.2: + resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + short-uuid@5.2.0: + resolution: {integrity: sha512-296/Nzi4DmANh93iYBwT4NoYRJuHnKEzefrkSagQbTH/A6NTaB68hSPDjm5IlbI5dx9FXdmtqPcj6N5H+CPm6w==} + engines: {node: '>=14'} + + should-equal@2.0.0: + resolution: {integrity: sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==} + + should-format@3.0.3: + resolution: {integrity: sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==} + + should-type-adaptors@1.1.0: + resolution: {integrity: sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==} + + should-type@1.4.0: + resolution: {integrity: sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==} + + should-util@1.0.1: + resolution: {integrity: sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==} + + should@13.2.3: + resolution: {integrity: sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + slice-ansi@8.0.0: + resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} + engines: {node: '>=20'} + + smob@1.6.2: + resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} + engines: {node: '>=20.0.0'} + + smtp-address-parser@1.1.0: + resolution: {integrity: sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==} + engines: {node: '>=0.10'} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions + + sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@4.0.0: + resolution: {integrity: sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + steed@1.1.3: + resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-object@3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-comments@2.0.1: + resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} + engines: {node: '>=10'} + + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + strtok3@10.3.4: + resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} + engines: {node: '>=18'} + + stylelint-config-html@1.1.0: + resolution: {integrity: sha512-IZv4IVESjKLumUGi+HWeb7skgO6/g4VMuAYrJdlqQFndgbj6WJAXPhaysvBiXefX79upBdQVumgYcdd17gCpjQ==} + engines: {node: ^12 || >=14} + peerDependencies: + postcss-html: ^1.0.0 + stylelint: '>=14.0.0' + + stylelint-config-recommended-vue@1.6.1: + resolution: {integrity: sha512-lLW7hTIMBiTfjenGuDq2kyHA6fBWd/+Df7MO4/AWOxiFeXP9clbpKgg27kHfwA3H7UNMGC7aeP3mNlZB5LMmEQ==} + engines: {node: ^12 || >=14} + peerDependencies: + postcss-html: ^1.0.0 + stylelint: '>=14.0.0' + + stylelint-config-recommended@18.0.0: + resolution: {integrity: sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint-config-standard@40.0.0: + resolution: {integrity: sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint@17.11.0: + resolution: {integrity: sha512-/3czzmbF9XdGWvReDF3Ex4R23Ajolo7j8RB2bFNEqk6Ht356nlpVV+G5bG2Qt8AW1ofJzXztBRDnAtd7cgowWA==} + engines: {node: '>=20.19.0'} + hasBin: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@4.4.0: + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} + engines: {node: '>=20'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + swagger-schema-official@2.0.0-bab6bed: + resolution: {integrity: sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA==} + + swagger-typescript-api@13.3.1: + resolution: {integrity: sha512-fSYf55kY0nV2TxS131h35sl+HaLNiZabF+A5ffFC7TCMlHTAVCSlmsh3MPXFaWYk8CcMLSFg4q4vDlWkhBDdAA==} + engines: {node: '>=20'} + hasBin: true + + swagger-ui-dist@5.32.4: + resolution: {integrity: sha512-0AADFFQNJzExEN49SrD/34Nn9cxNxVLiydYl2MBwSZFPVXNkVwC/EFAjoezGGqE8oDegiDC+p47t8lKObCinMQ==} + + swagger2openapi@7.0.8: + resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==} + hasBin: true + + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + temp-dir@2.0.0: + resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} + engines: {node: '>=8'} + + tempy@0.6.0: + resolution: {integrity: sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==} + engines: {node: '>=10'} + + terser-webpack-plugin@5.6.1: + resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + engines: {node: '>=10'} + hasBin: true + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tiny-emitter@2.1.0: + resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyduration@3.4.1: + resolution: {integrity: sha512-NemFoamVYn7TmtwZKZ3OiliM9fZkr6EWiTM+wKknco6POSy2gS689xx/pXip0JYp40HXpUw6k65CUYHWYUXdaA==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts-core@7.4.3: + resolution: {integrity: sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tldts@7.4.3: + resolution: {integrity: sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + to-valid-identifier@1.0.0: + resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} + engines: {node: '>=20'} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + + toml-eslint-parser@1.0.3: + resolution: {integrity: sha512-A5F0cM6+mDleacLIEUkmfpkBbnHJFV1d2rprHU2MXNk7mlxHq2zGojA+SRvQD1RoMo9gqjZPWEaKG4v1BQ48lw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-declaration-location@1.0.7: + resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} + peerDependencies: + typescript: '>=4.0.0' + + ts-deepmerge@6.2.1: + resolution: {integrity: sha512-8CYSLazCyj0DJDpPIxOFzJG46r93uh6EynYjuey+bxcLltBeqZL7DMfaE5ZPzZNFlav7wx+2TDa/mBl8gkTYzw==} + engines: {node: '>=14.13.1'} + + ts-essentials@10.1.1: + resolution: {integrity: sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==} + peerDependencies: + typescript: '>=4.5.0' + peerDependenciesMeta: + typescript: + optional: true + + ts-patch@3.3.0: + resolution: {integrity: sha512-zAOzDnd5qsfEnjd9IGy1IRuvA7ygyyxxdxesbhMdutt8AHFjD8Vw8hU2rMF89HX1BKRWFYqKHrO8Q6lw0NeUZg==} + hasBin: true + + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.16.0: + resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} + engines: {node: '>=10'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@5.7.0: + resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} + engines: {node: '>=20'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + type-level-regexp@0.1.17: + resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript-eslint@8.57.0: + resolution: {integrity: sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript-transform-paths@3.5.6: + resolution: {integrity: sha512-3eQTG6Ogt+pgPEh45uX2s9OwcfAVjWnyNO+osjYcOqYaWDVMIFUkqW8e0O1cOaVwdMqQFQf6alDT+76xmeS2Ag==} + peerDependencies: + typescript: '>=3.6.5' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + unconfig@7.5.0: + resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@7.24.5: + resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} + engines: {node: '>=20.18.1'} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + + unimport@3.14.6: + resolution: {integrity: sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==} + + unique-string@2.0.0: + resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} + engines: {node: '>=8'} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unocss@66.6.6: + resolution: {integrity: sha512-PRKK945e2oZKHV664MA5Z9CDHbvY/V79IvTOUWKZ514jpl3UsJU3sS+skgxmKJSmwrWvXE5OVcmPthJrD/7vxg==} + engines: {node: '>=14'} + peerDependencies: + '@unocss/astro': 66.6.6 + '@unocss/postcss': 66.6.6 + '@unocss/webpack': 66.6.6 + peerDependenciesMeta: + '@unocss/astro': + optional: true + '@unocss/postcss': + optional: true + '@unocss/webpack': + optional: true + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unplugin-auto-import@0.18.6: + resolution: {integrity: sha512-LMFzX5DtkTj/3wZuyG5bgKBoJ7WSgzqSGJ8ppDRdlvPh45mx6t6w3OcbExQi53n3xF5MYkNGPNR/HYOL95KL2A==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': ^3.2.2 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-utils@0.3.1: + resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==} + engines: {node: '>=20.19.0'} + + unplugin-vue-components@0.27.5: + resolution: {integrity: sha512-m9j4goBeNwXyNN8oZHHxvIIYiG8FQ9UfmKWeNllpDvhU7btKNNELGPt+o3mckQKuPwrE7e0PvCsx+IWuDSD9Vg==} + engines: {node: '>=14'} + peerDependencies: + '@babel/parser': ^7.15.8 + '@nuxt/kit': ^3.2.2 + vue: 2 || 3 + peerDependenciesMeta: + '@babel/parser': + optional: true + '@nuxt/kit': + optional: true + + unplugin@1.16.1: + resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==} + engines: {node: '>=14.0.0'} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + + upath@1.2.0: + resolution: {integrity: sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==} + engines: {node: '>=4'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-template@3.1.1: + resolution: {integrity: sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite-node@6.0.0: + resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + vite-plugin-pwa@1.2.0: + resolution: {integrity: sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@vite-pwa/assets-generator': ^1.0.0 + vite: ^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + workbox-build: ^7.4.0 + workbox-window: ^7.4.0 + peerDependenciesMeta: + '@vite-pwa/assets-generator': + optional: true + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vite@8.0.11: + resolution: {integrity: sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest-mock-extended@2.0.2: + resolution: {integrity: sha512-n3MBqVITKyclZ0n0y66hkT4UiiEYFQn9tteAnIxT0MPz1Z8nFcPUG3Cf0cZOyoPOj/cq6Ab1XFw2lM/qM5EDWQ==} + peerDependencies: + typescript: 3.x || 4.x || 5.x + vitest: '>=2.0.0' + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-eslint-parser@10.4.0: + resolution: {integrity: sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue3-json-viewer@2.4.1: + resolution: {integrity: sha512-Z1sunvS58lJ3ZcpNhl3jYQapBVw2wjnXbemigfMWm3QnjCeg3CPMq8R6pxHUYahxMfPKLvrbGve6mUXqhWyLaQ==} + peerDependencies: + vue: ^3.5.16 + + vue@3.5.30: + resolution: {integrity: sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + watchpack@2.5.2: + resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + webpack-node-externals@3.0.0: + resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} + engines: {node: '>=6'} + + webpack-sources@3.5.0: + resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} + engines: {node: '>=10.13.0'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + webpack@5.104.1: + resolution: {integrity: sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + wildcard-match@5.1.4: + resolution: {integrity: sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g==} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + workbox-background-sync@7.4.0: + resolution: {integrity: sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==} + + workbox-broadcast-update@7.4.0: + resolution: {integrity: sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==} + + workbox-build@7.4.0: + resolution: {integrity: sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==} + engines: {node: '>=20.0.0'} + + workbox-cacheable-response@7.4.0: + resolution: {integrity: sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==} + + workbox-core@7.4.0: + resolution: {integrity: sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==} + + workbox-expiration@7.4.0: + resolution: {integrity: sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==} + + workbox-google-analytics@7.4.0: + resolution: {integrity: sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==} + + workbox-navigation-preload@7.4.0: + resolution: {integrity: sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==} + + workbox-precaching@7.4.0: + resolution: {integrity: sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==} + + workbox-range-requests@7.4.0: + resolution: {integrity: sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==} + + workbox-recipes@7.4.0: + resolution: {integrity: sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==} + + workbox-routing@7.4.0: + resolution: {integrity: sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==} + + workbox-strategies@7.4.0: + resolution: {integrity: sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==} + + workbox-streams@7.4.0: + resolution: {integrity: sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==} + + workbox-sw@7.4.0: + resolution: {integrity: sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==} + + workbox-window@7.4.0: + resolution: {integrity: sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xbytes@1.9.1: + resolution: {integrity: sha512-29E0ygMFWrM5JW2W1ypmezjyR2FOS5aCszdLe620ymSJBoZzL0/RCLJKCoUFu3DfQGhZ/FWykEBp5dfEy6+hjA==} + engines: {node: '>=1'} + + xcase@2.0.1: + resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==} + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml-eslint-parser@2.0.0: + resolution: {integrity: sha512-h0uDm97wvT2bokfwwTmY6kJ1hp6YDFL0nRHwNKz8s/VD1FH/vvZjAKoMUE+un0eaYBSG7/c6h+lJTP+31tjgTw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} + engines: {node: '>= 6'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yummies@7.10.0: + resolution: {integrity: sha512-BXMmEYzFr/DtSvr1VMvctPVxQ/BZnx35oY2AGs6clkqgWnflsjqfuVle+8rH2uiMenmEf4I3OH9bRRW86yYyyg==} + peerDependencies: + mobx: ^6.12.4 + react: ^18 || ^19 + peerDependenciesMeta: + mobx: + optional: true + react: + optional: true + + zod-validation-error@3.5.4: + resolution: {integrity: sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.24.4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@anatine/zod-openapi@1.14.2(openapi3-ts@2.0.2)(zod@3.25.76)': + dependencies: + openapi3-ts: 2.0.2 + ts-deepmerge: 6.2.1 + zod: 3.25.76 + + '@angular-devkit/core@19.2.17(chokidar@4.0.3)': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + jsonc-parser: 3.3.1 + picomatch: 4.0.2 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/core@19.2.19(chokidar@4.0.3)': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + jsonc-parser: 3.3.1 + picomatch: 4.0.2 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 4.0.3 + + '@angular-devkit/schematics-cli@19.2.19(@types/node@24.12.0)(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) + '@inquirer/prompts': 7.3.2(@types/node@24.12.0) + ansi-colors: 4.1.3 + symbol-observable: 4.0.0 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - '@types/node' + - chokidar + + '@angular-devkit/schematics@19.2.17(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.17(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@19.2.19(chokidar@4.0.3)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@antfu/eslint-config@8.2.0(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5)': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@clack/prompts': 1.3.0 + '@e18e/eslint-plugin': 0.3.0(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/eslint-plugin-eslint-comments': 4.7.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint/markdown': 8.0.1 + '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@vitest/eslint-plugin': 1.6.17(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5) + ansis: 4.2.0 + cac: 7.0.0 + eslint: 10.5.0(jiti@2.6.1) + eslint-config-flat-gitignore: 2.3.0(eslint@10.5.0(jiti@2.6.1)) + eslint-flat-config-utils: 3.2.0 + eslint-merge-processors: 2.0.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-antfu: 3.2.2(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-import-lite: 0.6.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-jsdoc: 62.9.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-jsonc: 3.1.2(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-n: 17.24.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-no-only-tests: 3.3.0 + eslint-plugin-perfectionist: 5.9.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-pnpm: 1.6.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-regexp: 3.1.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-toml: 1.3.1(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-unicorn: 64.0.0(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)) + eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + eslint-plugin-yml: 3.3.1(eslint@10.5.0(jiti@2.6.1)) + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1)) + globals: 17.6.0 + local-pkg: 1.1.2 + parse-gitignore: 2.0.0 + toml-eslint-parser: 1.0.3 + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + yaml-eslint-parser: 2.0.0 + transitivePeerDependencies: + - '@eslint/json' + - '@typescript-eslint/rule-tester' + - '@typescript-eslint/typescript-estree' + - '@typescript-eslint/utils' + - '@vue/compiler-sfc' + - oxlint + - supports-color + - typescript + - vitest + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.2.4 + + '@antfu/utils@0.7.10': {} + + '@apideck/better-ajv-errors@0.3.7(ajv@8.20.0)': + dependencies: + ajv: 8.20.0 + jsonpointer: 5.0.1 + leven: 3.1.0 + + '@apidevtools/json-schema-ref-parser@14.0.1': + dependencies: + '@types/json-schema': 7.0.15 + js-yaml: 4.2.0 + + '@apidevtools/openapi-schemas@2.1.0': {} + + '@apidevtools/swagger-methods@3.0.2': {} + + '@apidevtools/swagger-parser@12.1.0(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 14.0.1 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@5.5.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + debug: 4.4.3(supports-color@5.5.0) + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-wrap-function': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helper-wrap-function@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + + '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/template': 7.29.7 + + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-dotall-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-keys@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-dynamic-import@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-exponentiation-operator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-member-expression-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-amd@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-systemjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regenerator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-regexp-modifiers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-reserved-words@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-spread@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-typeof-symbol@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-property-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-unicode-sets-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/preset-env@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7) + '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7) + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-systemjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-spread': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.7) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + core-js-compat: 3.49.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/types': 7.29.7 + esutils: 2.0.3 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@biomejs/js-api@4.0.0(@biomejs/wasm-nodejs@2.4.6)': + optionalDependencies: + '@biomejs/wasm-nodejs': 2.4.6 + + '@biomejs/wasm-nodejs@2.4.6': {} + + '@borewit/text-codec@0.2.1': {} + + '@cacheable/memory@2.0.8': + dependencies: + '@cacheable/utils': 2.4.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.4.0': + dependencies: + hashery: 1.5.0 + keyv: 5.6.0 + + '@clack/core@1.3.0': + dependencies: + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@clack/prompts@1.3.0': + dependencies: + '@clack/core': 1.3.0 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@colors/colors@1.5.0': + optional: true + + '@commitlint/cli@20.5.3(@types/node@24.12.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)': + dependencies: + '@commitlint/format': 20.5.0 + '@commitlint/lint': 20.5.3 + '@commitlint/load': 20.5.3(@types/node@24.12.0)(typescript@5.9.3) + '@commitlint/read': 20.5.0(conventional-commits-parser@6.4.0) + '@commitlint/types': 20.5.0 + tinyexec: 1.2.4 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - conventional-commits-filter + - conventional-commits-parser + - typescript + + '@commitlint/config-conventional@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-conventionalcommits: 9.3.1 + + '@commitlint/config-validator@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + ajv: 8.20.0 + + '@commitlint/ensure@20.5.3': + dependencies: + '@commitlint/types': 20.5.0 + es-toolkit: 1.48.1 + + '@commitlint/execute-rule@20.0.0': {} + + '@commitlint/format@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + picocolors: 1.1.1 + + '@commitlint/is-ignored@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + semver: 7.8.5 + + '@commitlint/lint@20.5.3': + dependencies: + '@commitlint/is-ignored': 20.5.0 + '@commitlint/parse': 20.5.0 + '@commitlint/rules': 20.5.3 + '@commitlint/types': 20.5.0 + + '@commitlint/load@20.5.3(@types/node@24.12.0)(typescript@5.9.3)': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/execute-rule': 20.0.0 + '@commitlint/resolve-extends': 20.5.3 + '@commitlint/types': 20.5.0 + cosmiconfig: 9.0.2(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@24.12.0)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3) + es-toolkit: 1.48.1 + is-plain-obj: 4.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/message@20.4.3': {} + + '@commitlint/parse@20.5.0': + dependencies: + '@commitlint/types': 20.5.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 + + '@commitlint/read@20.5.0(conventional-commits-parser@6.4.0)': + dependencies: + '@commitlint/top-level': 20.4.3 + '@commitlint/types': 20.5.0 + git-raw-commits: 5.0.1(conventional-commits-parser@6.4.0) + minimist: 1.2.8 + tinyexec: 1.2.4 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + '@commitlint/resolve-extends@20.5.3': + dependencies: + '@commitlint/config-validator': 20.5.0 + '@commitlint/types': 20.5.0 + es-toolkit: 1.48.1 + global-directory: 5.0.0 + import-meta-resolve: 4.2.0 + resolve-from: 5.0.0 + + '@commitlint/rules@20.5.3': + dependencies: + '@commitlint/ensure': 20.5.3 + '@commitlint/message': 20.4.3 + '@commitlint/to-lines': 20.0.0 + '@commitlint/types': 20.5.0 + + '@commitlint/to-lines@20.0.0': {} + + '@commitlint/top-level@20.4.3': + dependencies: + escalade: 3.2.0 + + '@commitlint/types@20.5.0': + dependencies: + conventional-commits-parser: 6.4.0 + picocolors: 1.1.1 + + '@conventional-changelog/git-client@2.7.0(conventional-commits-parser@6.4.0)': + dependencies: + '@simple-libs/child-process-utils': 1.0.2 + '@simple-libs/stream-utils': 1.2.0 + semver: 7.8.5 + optionalDependencies: + conventional-commits-parser: 6.4.0 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/css-tokenizer@4.0.0': {} + + '@csstools/media-query-list-parser@5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/selector-resolve-nested@4.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@e18e/eslint-plugin@0.3.0(eslint@10.5.0(jiti@2.6.1))': + dependencies: + eslint-plugin-depend: 1.5.0(eslint@10.5.0(jiti@2.6.1)) + optionalDependencies: + eslint: 10.5.0(jiti@2.6.1) + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@es-joy/jsdoccomment@0.84.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.61.1 + comment-parser: 1.4.5 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.1.1 + + '@es-joy/jsdoccomment@0.86.0': + dependencies: + '@types/estree': 1.0.9 + '@typescript-eslint/types': 8.61.1 + comment-parser: 1.4.6 + esquery: 1.7.0 + jsdoc-type-pratt-parser: 7.2.0 + + '@es-joy/resolve.exports@1.2.0': {} + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@eslint-community/eslint-plugin-eslint-comments@4.7.1(eslint@10.5.0(jiti@2.6.1))': + dependencies: + escape-string-regexp: 4.0.0 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0(jiti@2.6.1))': + dependencies: + eslint: 10.5.0(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/compat@2.0.3(eslint@10.5.0(jiti@2.6.1))': + dependencies: + '@eslint/core': 1.2.1 + optionalDependencies: + eslint: 10.5.0(jiti@2.6.1) + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3(supports-color@5.5.0) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/markdown@8.0.1': + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + github-slugger: 2.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-frontmatter: 2.0.1 + mdast-util-gfm: 3.1.0 + mdast-util-math: 3.0.0 + micromark-extension-frontmatter: 2.0.0 + micromark-extension-gfm: 3.0.0 + micromark-extension-math: 3.1.0 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@exodus/schemasafe@1.3.0': {} + + '@faker-js/faker@9.9.0': {} + + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + + '@fastify/cookie@11.0.2': + dependencies: + cookie: 1.1.1 + fastify-plugin: 5.1.0 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/formbody@8.0.2': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/helmet@13.0.2': + dependencies: + fastify-plugin: 5.1.0 + helmet: 8.1.0 + + '@fastify/jwt@9.1.0': + dependencies: + '@fastify/error': 4.2.0 + '@lukeed/ms': 2.0.2 + fast-jwt: 5.0.6 + fastify-plugin: 5.1.0 + steed: 1.1.3 + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/otel@0.18.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/session@11.1.1': + dependencies: + fastify-plugin: 5.1.0 + safe-stable-stringify: 2.5.0 + + '@fastify/static@9.1.3': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 1.1.0 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@fastify/swagger-ui@5.2.6': + dependencies: + '@fastify/static': 9.1.3 + fastify-plugin: 5.1.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + + '@fastify/swagger@9.7.0': + dependencies: + fastify-plugin: 5.1.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + transitivePeerDependencies: + - supports-color + + '@gitbeaker/core@40.6.0': + dependencies: + '@gitbeaker/requester-utils': 40.6.0 + qs: 6.15.2 + xcase: 2.0.1 + + '@gitbeaker/requester-utils@40.6.0': + dependencies: + picomatch-browser: 2.2.6 + qs: 6.15.2 + rate-limiter-flexible: 4.0.1 + xcase: 2.0.1 + + '@gitbeaker/rest@40.6.0': + dependencies: + '@gitbeaker/core': 40.6.0 + '@gitbeaker/requester-utils': 40.6.0 + + '@gouvfr/dsfr@1.14.4': {} + + '@gouvminint/vue-dsfr@8.15.0(@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3)))(vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@gouvfr/dsfr': 1.14.4 + '@iconify/vue': 4.3.0(vue@3.5.30(typescript@5.9.3)) + focus-trap: 7.8.0 + focus-trap-vue: 4.1.0(focus-trap@7.8.0)(vue@3.5.30(typescript@5.9.3)) + vue: 3.5.30(typescript@5.9.3) + vue-router: 4.6.4(vue@3.5.30(typescript@5.9.3)) + + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.4 + yargs: 17.7.3 + + '@himenon/argocd-typescript-openapi@1.2.2': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@iconify-json/ri@1.2.10': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.1 + + '@iconify/vue@4.3.0(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@iconify/types': 2.0.0 + vue: 3.5.30(typescript@5.9.3) + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/confirm@5.1.21(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/core@10.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/editor@4.2.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/external-editor': 1.0.3(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/expand@4.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/external-editor@1.0.3(@types/node@24.12.0)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/number@3.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/password@4.0.23(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/prompts@7.10.1(@types/node@24.12.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.12.0) + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@inquirer/editor': 4.2.23(@types/node@24.12.0) + '@inquirer/expand': 4.0.23(@types/node@24.12.0) + '@inquirer/input': 4.3.1(@types/node@24.12.0) + '@inquirer/number': 3.0.23(@types/node@24.12.0) + '@inquirer/password': 4.0.23(@types/node@24.12.0) + '@inquirer/rawlist': 4.1.11(@types/node@24.12.0) + '@inquirer/search': 3.2.2(@types/node@24.12.0) + '@inquirer/select': 4.4.2(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/prompts@7.3.2(@types/node@24.12.0)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@24.12.0) + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@inquirer/editor': 4.2.23(@types/node@24.12.0) + '@inquirer/expand': 4.0.23(@types/node@24.12.0) + '@inquirer/input': 4.3.1(@types/node@24.12.0) + '@inquirer/number': 3.0.23(@types/node@24.12.0) + '@inquirer/password': 4.0.23(@types/node@24.12.0) + '@inquirer/rawlist': 4.1.11(@types/node@24.12.0) + '@inquirer/search': 3.2.2(@types/node@24.12.0) + '@inquirer/select': 4.4.2(@types/node@24.12.0) + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/rawlist@4.1.11(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/search@3.2.2(@types/node@24.12.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/select@4.4.2(@types/node@24.12.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@24.12.0) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.12.0) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.12.0 + + '@inquirer/type@3.0.10(@types/node@24.12.0)': + optionalDependencies: + '@types/node': 24.12.0 + + '@isaacs/cliui@9.0.0': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@js-sdsl/ordered-map@4.4.2': {} + + '@keycloak/keycloak-admin-client@26.7.0': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + '@microsoft/kiota-http-fetchlibrary': 1.0.0-preview.103 + '@microsoft/kiota-serialization-form': 1.0.0-preview.103 + '@microsoft/kiota-serialization-json': 1.0.0-preview.103 + '@microsoft/kiota-serialization-multipart': 1.0.0-preview.103 + '@microsoft/kiota-serialization-text': 1.0.0-preview.103 + camelize-ts: 3.0.0 + url-template: 3.1.1 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@kubernetes-models/apimachinery@2.2.0': + dependencies: + '@kubernetes-models/base': 5.0.1 + '@kubernetes-models/validate': 4.0.0 + '@swc/helpers': 0.5.19 + + '@kubernetes-models/argo-cd@2.7.2': + dependencies: + '@kubernetes-models/apimachinery': 2.2.0 + '@kubernetes-models/base': 5.0.1 + '@kubernetes-models/validate': 4.0.0 + '@swc/helpers': 0.5.19 + + '@kubernetes-models/base@5.0.1': + dependencies: + '@kubernetes-models/validate': 4.0.0 + is-plain-object: 5.0.0 + tslib: 2.8.1 + + '@kubernetes-models/validate@4.0.0': + dependencies: + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-formats-draft2019: 1.6.1(ajv@8.20.0) + ajv-i18n: 4.2.0(ajv@8.20.0) + is-cidr: 4.0.2 + tslib: 2.8.1 + optionalDependencies: + re2-wasm: 1.0.2 + + '@lukeed/csprng@1.1.0': {} + + '@lukeed/ms@2.0.2': {} + + '@microsoft/kiota-abstractions@1.0.0-preview.103': + dependencies: + '@opentelemetry/api': 1.9.1 + '@std-uritemplate/std-uritemplate': 2.0.12 + tinyduration: 3.4.1 + tslib: 2.8.1 + + '@microsoft/kiota-http-fetchlibrary@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + '@opentelemetry/api': 1.9.1 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-form@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-json@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-multipart@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/kiota-serialization-text@1.0.0-preview.103': + dependencies: + '@microsoft/kiota-abstractions': 1.0.0-preview.103 + tslib: 2.8.1 + + '@microsoft/tsdoc@0.16.0': {} + + '@mswjs/interceptors@0.41.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@nestjs/cache-manager@3.1.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cache-manager: 7.2.8 + keyv: 5.6.0 + rxjs: 7.8.2 + + '@nestjs/cli@11.0.16(@types/node@24.12.0)(esbuild@0.27.3)(lightningcss@1.32.0)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics-cli': 19.2.19(@types/node@24.12.0)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@24.12.0) + '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.9.3) + ansis: 4.2.0 + chokidar: 4.0.3 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)) + glob: 13.0.0 + node-emoji: 1.11.0 + ora: 5.4.1 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.9.3 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/html' + - '@types/node' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + - webpack-cli + + '@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.0 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/config@4.0.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.2.3 + dotenv-expand: 12.0.3 + lodash: 4.17.23 + rxjs: 7.8.2 + + '@nestjs/core@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxt/opencollective': 0.4.1 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.3.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + + '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + eventemitter2: 6.4.9 + + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@types/jsonwebtoken': 9.0.10 + jsonwebtoken: 9.0.3 + + '@nestjs/mapped-types@2.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + + '@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.6 + express: 5.2.1 + multer: 2.1.1 + path-to-regexp: 8.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + optional: true + + '@nestjs/platform-fastify@11.1.27(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@fastify/cors': 11.2.0 + '@fastify/formbody': 8.0.2 + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-querystring: 1.1.2 + fastify: 5.8.5 + fastify-plugin: 6.0.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + path-to-regexp: 8.4.2 + reusify: 1.1.0 + tslib: 2.8.1 + optionalDependencies: + '@fastify/static': 9.1.3 + + '@nestjs/schedule@6.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 4.4.0 + + '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': + dependencies: + '@angular-devkit/core': 19.2.17(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.17(chokidar@4.0.3) + comment-json: 4.4.1 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.9.3 + transitivePeerDependencies: + - chokidar + + '@nestjs/swagger@11.4.1(@fastify/static@9.1.3)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2) + js-yaml: 4.1.1 + lodash: 4.18.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.32.4 + optionalDependencies: + '@fastify/static': 9.1.3 + + '@nestjs/terminus@11.1.1(@grpc/grpc-js@1.14.3)(@grpc/proto-loader@0.8.0)(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + boxen: 5.1.2 + check-disk-space: 3.4.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + optionalDependencies: + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.8.0 + '@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + + '@nestjs/testing@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nuxt/opencollective@0.4.1': + dependencies: + consola: 3.4.2 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@opentelemetry/api-logs@0.212.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.213.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.214.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/auto-instrumentations-node@0.70.1(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-amqplib': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-lambda': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-sdk': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-bunyan': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cassandra-driver': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-connect': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cucumber': 0.28.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dataloader': 0.29.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dns': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-express': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fastify': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fs': 0.31.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-generic-pool': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-graphql': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-hapi': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-kafkajs': 0.21.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-knex': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-koa': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-lru-memoizer': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-memcached': 0.55.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongodb': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongoose': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql2': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-net': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-openai': 0.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-oracledb': 0.37.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-redis': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-restify': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-router': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-runtime-node': 0.25.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-socket.io': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-tedious': 0.31.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': 0.22.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-winston': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-alibaba-cloud': 0.33.3(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-aws': 2.13.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-azure': 0.20.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-container': 0.8.4(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-gcp': 0.47.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/configuration@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + yaml: 2.9.0 + + '@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/exporter-logs-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/exporter-trace-otlp-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/instrumentation-amqplib@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-lambda@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/aws-lambda': 8.10.161 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-sdk@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-bunyan@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@types/bunyan': 1.8.11 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cassandra-driver@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cucumber@0.28.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.29.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dns@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-express@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fastify@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.31.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-grpc@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.21.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-memcached@0.55.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/memcached': 2.2.10 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/mysql': 2.15.27 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-nestjs-core@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-net@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-openai@0.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-oracledb@0.37.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/oracledb': 6.5.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-restify@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-router@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-runtime-node@0.25.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-socket.io@0.59.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-tedious@0.31.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.22.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-winston@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + import-in-the-middle: 3.0.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + import-in-the-middle: 3.0.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + protobufjs: 8.0.0 + + '@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + protobufjs: 7.6.4 + + '@opentelemetry/propagator-b3@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/redis-common@0.38.2': {} + + '@opentelemetry/resource-detector-alibaba-cloud@0.33.3(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resource-detector-aws@2.13.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resource-detector-azure@0.20.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resource-detector-container@0.8.4(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resource-detector-gcp@0.47.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + gcp-metadata: 8.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-logs@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-metrics@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/configuration': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-trace-node@2.5.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/semantic-conventions@1.41.1': {} + + '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + + '@ota-meshi/ast-token-store@0.3.0': {} + + '@oxc-parser/binding-android-arm-eabi@0.115.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.115.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.115.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.115.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.115.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.115.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.115.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.115.0': + optional: true + + '@oxc-project/types@0.115.0': {} + + '@oxc-project/types@0.128.0': {} + + '@pinojs/redact@0.4.0': {} + + '@pkgr/core@0.2.9': {} + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + '@polka/url@1.0.0-next.29': {} + + '@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)': + optionalDependencies: + prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + typescript: 5.9.3 + + '@prisma/config@6.19.2(magicast@0.3.5)': + dependencies: + c12: 3.1.0(magicast@0.3.5) + deepmerge-ts: 7.1.5 + effect: 3.18.4 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + '@prisma/debug@6.19.2': {} + + '@prisma/engines-version@7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7': {} + + '@prisma/engines@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/fetch-engine': 6.19.2 + '@prisma/get-platform': 6.19.2 + + '@prisma/fetch-engine@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + '@prisma/engines-version': 7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7 + '@prisma/get-platform': 6.19.2 + + '@prisma/get-platform@6.19.2': + dependencies: + '@prisma/debug': 6.19.2 + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.18': {} + + '@rolldown/pluginutils@1.0.0-rc.2': {} + + '@rollup/plugin-babel@5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.80.0)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@rollup/pluginutils': 3.1.0(rollup@2.80.0) + rollup: 2.80.0 + optionalDependencies: + '@types/babel__core': 7.20.5 + transitivePeerDependencies: + - supports-color + + '@rollup/plugin-node-resolve@15.3.1(rollup@2.80.0)': + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.12 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/plugin-replace@2.4.2(rollup@2.80.0)': + dependencies: + '@rollup/pluginutils': 3.1.0(rollup@2.80.0) + magic-string: 0.25.9 + rollup: 2.80.0 + + '@rollup/plugin-terser@0.4.4(rollup@2.80.0)': + dependencies: + serialize-javascript: 6.0.2 + smob: 1.6.2 + terser: 5.48.0 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/pluginutils@3.1.0(rollup@2.80.0)': + dependencies: + '@types/estree': 0.0.39 + estree-walker: 1.0.1 + picomatch: 2.3.2 + rollup: 2.80.0 + + '@rollup/pluginutils@5.4.0(rollup@2.80.0)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 2.80.0 + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@scarf/scarf@1.4.0': {} + + '@simple-libs/child-process-utils@1.0.2': + dependencies: + '@simple-libs/stream-utils': 1.2.0 + + '@simple-libs/stream-utils@1.2.0': {} + + '@sindresorhus/base62@1.0.0': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@std-uritemplate/std-uritemplate@2.0.12': {} + + '@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/types': 8.61.1 + eslint: 10.5.0(jiti@2.6.1) + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + estraverse: 5.3.0 + picomatch: 4.0.4 + + '@surma/rollup-plugin-off-main-thread@2.2.3': + dependencies: + ejs: 3.1.10 + json5: 2.2.3 + magic-string: 0.25.9 + string.prototype.matchall: 4.0.12 + + '@swc/helpers@0.5.19': + dependencies: + tslib: 2.8.1 + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3(supports-color@5.5.0) + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76)': + optionalDependencies: + '@types/node': 24.12.0 + zod: 3.25.76 + + '@ts-rest/fastify@3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(fastify@5.8.5)(zod@3.25.76)': + dependencies: + '@ts-rest/core': 3.52.1(@types/node@24.12.0)(zod@3.25.76) + fastify: 5.8.5 + optionalDependencies: + zod: 3.25.76 + + '@ts-rest/open-api@3.52.1(@ts-rest/core@3.52.1(@types/node@24.12.0)(zod@3.25.76))(zod@3.25.76)': + dependencies: + '@anatine/zod-openapi': 1.14.2(openapi3-ts@2.0.2)(zod@3.25.76) + '@ts-rest/core': 3.52.1(@types/node@24.12.0)(zod@3.25.76) + openapi3-ts: 2.0.2 + zod: 3.25.76 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aws-lambda@8.10.161': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + optional: true + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + optional: true + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + optional: true + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + optional: true + + '@types/bunyan@1.8.11': + dependencies: + '@types/node': 24.12.0 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.12.0 + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.9 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + + '@types/esrecurse@4.3.1': {} + + '@types/estree@0.0.39': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/jsdom@21.1.7': + dependencies: + '@types/node': 24.12.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.12.0 + + '@types/katex@0.16.8': {} + + '@types/luxon@3.7.1': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/memcached@2.2.10': + dependencies: + '@types/node': 24.12.0 + + '@types/ms@2.1.0': {} + + '@types/mysql@2.15.27': + dependencies: + '@types/node': 24.12.0 + + '@types/node@24.12.0': + dependencies: + undici-types: 7.16.0 + + '@types/oracledb@6.5.2': + dependencies: + '@types/node': 24.12.0 + + '@types/pg-pool@2.0.7': + dependencies: + '@types/pg': 8.15.6 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 24.12.0 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + + '@types/resolve@1.20.2': {} + + '@types/statuses@2.0.6': {} + + '@types/swagger-schema-official@2.0.25': {} + + '@types/tedious@4.0.14': + dependencies: + '@types/node': 24.12.0 + + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': {} + + '@types/unist@3.0.3': {} + + '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/type-utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.0 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/type-utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + eslint: 10.5.0(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.57.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3(supports-color@5.5.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + debug: 4.4.3(supports-color@5.5.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + ajv: 6.15.0 + eslint: 10.5.0(jiti@2.6.1) + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/scope-manager@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + + '@typescript-eslint/scope-manager@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + + '@typescript-eslint/tsconfig-utils@8.57.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.57.0': {} + + '@typescript-eslint/types@8.59.2': {} + + '@typescript-eslint/types@8.61.1': {} + + '@typescript-eslint/typescript-estree@8.57.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.57.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/visitor-keys': 8.57.0 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.57.0 + '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.57.0': + dependencies: + '@typescript-eslint/types': 8.57.0 + eslint-visitor-keys: 5.0.1 + + '@typescript-eslint/visitor-keys@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + eslint-visitor-keys: 5.0.1 + + '@unocss/cli@66.6.6': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + '@unocss/preset-wind4': 66.6.6 + '@unocss/transformer-directives': 66.6.6 + cac: 6.7.14 + chokidar: 5.0.0 + colorette: 2.0.20 + consola: 3.4.2 + magic-string: 0.30.21 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + tinyglobby: 0.2.17 + unplugin-utils: 0.3.1 + + '@unocss/config@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + colorette: 2.0.20 + consola: 3.4.2 + unconfig: 7.5.0 + + '@unocss/core@0.65.4': {} + + '@unocss/core@66.6.6': {} + + '@unocss/extractor-arbitrary-variants@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/inspector@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + colorette: 2.0.20 + gzip-size: 6.0.0 + sirv: 3.0.2 + + '@unocss/preset-attributify@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/preset-icons@66.6.6': + dependencies: + '@iconify/utils': 3.1.0 + '@unocss/core': 66.6.6 + ofetch: 1.5.1 + + '@unocss/preset-mini@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/extractor-arbitrary-variants': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-tagify@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/preset-typography@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-uno@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + + '@unocss/preset-web-fonts@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + ofetch: 1.5.1 + + '@unocss/preset-wind3@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-mini': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-wind4@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/extractor-arbitrary-variants': 66.6.6 + '@unocss/rule-utils': 66.6.6 + + '@unocss/preset-wind@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + + '@unocss/rule-utils@0.65.4': + dependencies: + '@unocss/core': 0.65.4 + magic-string: 0.30.21 + + '@unocss/rule-utils@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + magic-string: 0.30.21 + + '@unocss/transformer-attributify-jsx@66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@unocss/core': 66.6.6 + oxc-parser: 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + oxc-walker: 0.7.0(oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@unocss/transformer-compile-class@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/transformer-directives@0.65.4': + dependencies: + '@unocss/core': 0.65.4 + '@unocss/rule-utils': 0.65.4 + css-tree: 3.2.1 + + '@unocss/transformer-directives@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + '@unocss/rule-utils': 66.6.6 + css-tree: 3.2.1 + + '@unocss/transformer-variant-group@66.6.6': + dependencies: + '@unocss/core': 66.6.6 + + '@unocss/vite@66.6.6(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@jridgewell/remapping': 2.3.5 + '@unocss/config': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/inspector': 66.6.6 + chokidar: 5.0.0 + magic-string: 0.30.21 + pathe: 2.0.3 + tinyglobby: 0.2.17 + unplugin-utils: 0.3.1 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + + '@vitejs/plugin-vue@6.0.4(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vue: 3.5.30(typescript@5.9.3) + + '@vitest/coverage-v8@4.1.5(vitest@4.1.5)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.5 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + '@vitest/eslint-plugin@1.6.17(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.5)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + typescript: 5.9.3 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + + '@vitest/mocker@4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@24.12.0)(typescript@5.9.3) + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.30': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.30 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.30': + dependencies: + '@vue/compiler-core': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-sfc@3.5.30': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.30 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.30': + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))) + fast-glob: 3.3.3 + typescript-eslint: 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@vue/language-core@2.2.12(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.30 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.9.3 + + '@vue/reactivity@3.5.30': + dependencies: + '@vue/shared': 3.5.30 + + '@vue/runtime-core@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/shared': 3.5.30 + + '@vue/runtime-dom@3.5.30': + dependencies: + '@vue/reactivity': 3.5.30 + '@vue/runtime-core': 3.5.30 + '@vue/shared': 3.5.30 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.30 + '@vue/shared': 3.5.30 + vue: 3.5.30(typescript@5.9.3) + + '@vue/shared@3.5.30': {} + + '@vue/tsconfig@0.7.0(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))': + optionalDependencies: + typescript: 5.9.3 + vue: 3.5.30(typescript@5.9.3) + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + abstract-logging@2.0.1: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + optional: true + + acorn-import-attributes@1.9.5(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-import-phases@1.0.4(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + agent-base@7.1.4: {} + + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats-draft2019@1.6.1(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + punycode: 2.3.1 + schemes: 1.4.0 + smtp-address-parser: 1.1.0 + uri-js: 4.4.1 + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-i18n@4.2.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv-keywords@3.5.2(ajv@6.15.0): + dependencies: + ajv: 6.15.0 + + ajv-keywords@5.1.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + fast-deep-equal: 3.1.3 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alien-signals@1.0.13: {} + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-colors@4.1.3: {} + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + ansis@4.2.0: {} + + any-base@1.1.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + append-field@1.0.0: + optional: true + + are-docs-informative@0.0.2: {} + + argparse@2.0.1: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-ify@1.0.0: {} + + array-timsort@1.0.3: {} + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + astral-regex@2.0.0: {} + + async-function@1.0.0: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + atomic-sleep@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + axios-retry@4.5.0(axios@1.16.0): + dependencies: + axios: 1.16.0 + is-retry-allowed: 2.2.0 + + axios@1.16.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + core-js-compat: 3.49.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.38: {} + + bignumber.js@9.3.1: {} + + binary-extensions@2.3.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bn.js@4.12.3: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + optional: true + + boolbase@1.0.0: {} + + boxen@5.1.2: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + brorand@1.1.0: + optional: true + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.38 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.376 + node-releases: 2.0.48 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builtin-modules@5.0.0: {} + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + optional: true + + bytes@3.1.2: {} + + c12@3.1.0(magicast@0.3.5): + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 16.6.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.3.5 + + c12@3.3.3(magicast@0.5.3): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 17.3.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + optionalDependencies: + magicast: 0.5.3 + + cac@6.7.14: {} + + cac@7.0.0: {} + + cache-manager@7.2.8: + dependencies: + '@cacheable/utils': 2.4.0 + keyv: 5.6.0 + + cacheable@2.3.3: + dependencies: + '@cacheable/memory': 2.0.8 + '@cacheable/utils': 2.4.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.6.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + call-me-maybe@1.0.2: {} + + callsites@3.1.0: {} + + camelcase@6.3.0: {} + + camelize-ts@3.0.0: {} + + caniuse-lite@1.0.30001799: {} + + ccount@2.0.1: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + change-case@5.4.4: {} + + character-entities@2.0.2: {} + + chardet@2.1.1: {} + + check-disk-space@3.4.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chrome-trace-event@1.0.4: {} + + ci-info@4.4.0: {} + + cidr-regex@3.1.1: + dependencies: + ip-regex: 4.3.0 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.1: {} + + cjs-module-lexer@2.2.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clean-regexp@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + + cli-boxes@2.2.1: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-truncate@5.2.0: + dependencies: + slice-ansi: 8.0.0 + string-width: 8.2.1 + + cli-width@4.1.0: {} + + clipboard@2.0.11: + dependencies: + good-listener: 1.2.2 + select: 1.1.2 + tiny-emitter: 2.1.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@14.0.3: {} + + commander@2.20.3: {} + + commander@4.1.1: {} + + commander@8.3.0: {} + + comment-json@4.4.1: + dependencies: + array-timsort: 1.0.3 + core-util-is: 1.0.3 + esprima: 4.0.1 + + comment-parser@1.4.5: {} + + comment-parser@1.4.6: {} + + common-tags@1.8.2: {} + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + optional: true + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + consola@3.4.2: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: + optional: true + + content-type@2.0.0: + optional: true + + conventional-changelog-angular@8.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: + dependencies: + compare-func: 2.0.0 + + conventional-commits-parser@6.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + meow: 13.2.0 + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: + optional: true + + cookie@0.7.2: + optional: true + + cookie@1.1.1: {} + + core-js-compat@3.49.0: + dependencies: + browserslist: 4.28.2 + + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + optional: true + + cosmiconfig-typescript-loader@6.3.0(@types/node@24.12.0)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3): + dependencies: + '@types/node': 24.12.0 + cosmiconfig: 9.0.2(typescript@5.9.3) + jiti: 2.6.1 + typescript: 5.9.3 + + cosmiconfig@8.3.6(typescript@5.9.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.2.0 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.3 + + cosmiconfig@9.0.2(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.2.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + cron-validator@1.4.0: {} + + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypto-random-string@2.0.0: {} + + css-functions-list@3.3.3: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-uri-to-buffer@4.0.1: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + date-fns@4.1.0: {} + + dateformat@4.6.3: {} + + dayjs@1.11.19: {} + + de-indent@1.0.2: {} + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + decimal.js@10.6.0: {} + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + deeks@3.1.0: {} + + deep-is@0.1.4: {} + + deepmerge-ts@7.1.5: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + defu@6.1.4: {} + + delayed-stream@1.0.0: {} + + delegate@3.2.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff-sequences@29.6.3: {} + + discontinuous-range@1.0.0: {} + + doc-path@4.1.1: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + dompurify@3.3.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dotenv@17.2.3: {} + + dotenv@17.3.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer@0.1.2: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: + optional: true + + effect@3.18.4: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-to-chromium@1.5.376: {} + + elliptic@6.6.1: + dependencies: + bn.js: 4.12.3 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + optional: true + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + empathic@2.0.0: {} + + encodeurl@2.0.0: + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.24.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + env-paths@2.2.1: {} + + environment@1.1.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.1 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.22 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.1.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-to-primitive@1.3.1: + dependencies: + es-abstract-get: 1.0.0 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es-toolkit@1.48.1: {} + + es6-promise@3.3.1: {} + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-compat-utils@0.5.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + semver: 7.8.5 + + eslint-config-flat-gitignore@2.3.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/compat': 2.0.3(eslint@10.5.0(jiti@2.6.1)) + eslint: 10.5.0(jiti@2.6.1) + + eslint-flat-config-utils@3.2.0: + dependencies: + '@eslint/config-helpers': 0.5.5 + pathe: 2.0.3 + + eslint-json-compat-utils@0.2.3(eslint@10.5.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + esquery: 1.7.0 + jsonc-eslint-parser: 3.1.0 + + eslint-merge-processors@2.0.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-antfu@3.2.2(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-command@3.5.2(@typescript-eslint/rule-tester@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3))(@typescript-eslint/utils@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@es-joy/jsdoccomment': 0.84.0 + '@typescript-eslint/rule-tester': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-depend@1.5.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + empathic: 2.0.0 + eslint: 10.5.0(jiti@2.6.1) + module-replacements: 2.11.0 + semver: 7.8.5 + + eslint-plugin-es-x@7.8.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + eslint: 10.5.0(jiti@2.6.1) + eslint-compat-utils: 0.5.1(eslint@10.5.0(jiti@2.6.1)) + + eslint-plugin-import-lite@0.6.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + + eslint-plugin-jsdoc@62.9.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@es-joy/jsdoccomment': 0.86.0 + '@es-joy/resolve.exports': 1.2.0 + are-docs-informative: 0.0.2 + comment-parser: 1.4.6 + debug: 4.4.3(supports-color@5.5.0) + escape-string-regexp: 4.0.0 + eslint: 10.5.0(jiti@2.6.1) + espree: 11.2.0 + esquery: 1.7.0 + html-entities: 2.6.0 + object-deep-merge: 2.0.0 + parse-imports-exports: 0.2.4 + semver: 7.8.5 + spdx-expression-parse: 4.0.0 + to-valid-identifier: 1.0.0 + transitivePeerDependencies: + - supports-color + + eslint-plugin-jsonc@3.1.2(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + diff-sequences: 29.6.3 + eslint: 10.5.0(jiti@2.6.1) + eslint-json-compat-utils: 0.2.3(eslint@10.5.0(jiti@2.6.1))(jsonc-eslint-parser@3.1.0) + jsonc-eslint-parser: 3.1.0 + natural-compare: 1.4.0 + synckit: 0.11.12 + transitivePeerDependencies: + - '@eslint/json' + + eslint-plugin-n@17.24.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + enhanced-resolve: 5.24.0 + eslint: 10.5.0(jiti@2.6.1) + eslint-plugin-es-x: 7.8.0(eslint@10.5.0(jiti@2.6.1)) + get-tsconfig: 4.13.6 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.8.5 + ts-declaration-location: 1.0.7(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + eslint-plugin-no-only-tests@3.3.0: {} + + eslint-plugin-perfectionist@5.9.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + natural-orderby: 5.0.0 + transitivePeerDependencies: + - supports-color + - typescript + + eslint-plugin-pnpm@1.6.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + empathic: 2.0.0 + eslint: 10.5.0(jiti@2.6.1) + jsonc-eslint-parser: 3.1.0 + pathe: 2.0.3 + pnpm-workspace-yaml: 1.6.0 + tinyglobby: 0.2.17 + yaml: 2.9.0 + yaml-eslint-parser: 2.0.0 + + eslint-plugin-regexp@3.1.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + comment-parser: 1.4.6 + eslint: 10.5.0(jiti@2.6.1) + jsdoc-type-pratt-parser: 7.2.0 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + scslre: 0.3.0 + + eslint-plugin-toml@1.3.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + toml-eslint-parser: 1.0.3 + transitivePeerDependencies: + - supports-color + + eslint-plugin-unicorn@64.0.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + change-case: 5.4.4 + ci-info: 4.4.0 + clean-regexp: 1.0.0 + core-js-compat: 3.49.0 + eslint: 10.5.0(jiti@2.6.1) + find-up-simple: 1.0.1 + globals: 17.6.0 + indent-string: 5.0.0 + is-builtin-module: 5.0.0 + jsesc: 3.1.0 + pluralize: 8.0.0 + regexp-tree: 0.1.27 + regjsparser: 0.13.2 + semver: 7.8.5 + strip-indent: 4.1.1 + + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1)): + dependencies: + eslint: 10.5.0(jiti@2.6.1) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + + eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.10.0(eslint@10.5.0(jiti@2.6.1)))(@typescript-eslint/parser@8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1))): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + eslint: 10.5.0(jiti@2.6.1) + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.1 + semver: 7.8.5 + vue-eslint-parser: 10.4.0(eslint@10.5.0(jiti@2.6.1)) + xml-name-validator: 4.0.0 + optionalDependencies: + '@stylistic/eslint-plugin': 5.10.0(eslint@10.5.0(jiti@2.6.1)) + '@typescript-eslint/parser': 8.59.2(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + + eslint-plugin-yml@3.3.1(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.6.1 + '@ota-meshi/ast-token-store': 0.3.0 + debug: 4.4.3(supports-color@5.5.0) + diff-sequences: 29.6.3 + escape-string-regexp: 5.0.0 + eslint: 10.5.0(jiti@2.6.1) + natural-compare: 1.4.0 + yaml-eslint-parser: 2.0.0 + transitivePeerDependencies: + - supports-color + + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.30)(eslint@10.5.0(jiti@2.6.1)): + dependencies: + '@vue/compiler-sfc': 3.5.30 + eslint: 10.5.0(jiti@2.6.1) + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.5.0(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@5.5.0) + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-walker@1.0.1: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + eta@3.5.0: {} + + etag@1.8.1: + optional: true + + eventemitter2@6.4.9: {} + + eventemitter3@5.0.4: {} + + events@3.3.0: {} + + expect-type@1.3.0: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + optional: true + + exsolve@1.0.8: {} + + extend@3.0.2: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + + fast-copy@4.0.2: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-jwt@5.0.6: + dependencies: + '@lukeed/ms': 2.0.2 + asn1.js: 5.4.1 + ecdsa-sig-formatter: 1.0.11 + mnemonist: 0.40.4 + + fast-levenshtein@2.0.6: {} + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.2: {} + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + + fastest-levenshtein@1.0.16: {} + + fastfall@1.5.1: + dependencies: + reusify: 1.1.0 + + fastify-keycloak-adapter@3.0.2: + dependencies: + '@fastify/cookie': 11.0.2 + '@fastify/jwt': 9.1.0 + '@fastify/session': 11.1.1 + axios: 1.16.0 + axios-retry: 4.5.0(axios@1.16.0) + fastify-plugin: 5.1.0 + fp-ts: 2.16.11 + grant: 5.4.24 + io-ts: 2.2.22(fp-ts@2.16.11) + qs: 6.15.2 + wildcard-match: 5.1.4 + transitivePeerDependencies: + - debug + + fastify-plugin@5.1.0: {} + + fastify-plugin@6.0.0: {} + + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 9.14.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.5 + toad-cache: 3.7.0 + + fastparallel@2.4.1: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fastseries@1.7.2: + dependencies: + reusify: 1.1.0 + xtend: 4.0.2 + + fault@2.0.1: + dependencies: + format: 0.2.2 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + file-entry-cache@11.1.2: + dependencies: + flat-cache: 6.1.20 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-type@21.3.0: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.4 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + optional: true + + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + + find-up-simple@1.0.1: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flat-cache@6.1.20: + dependencies: + cacheable: 2.3.3 + flatted: 3.4.2 + hookified: 1.15.1 + + flatted@3.4.2: {} + + focus-trap-vue@4.1.0(focus-trap@7.8.0)(vue@3.5.30(typescript@5.9.3)): + dependencies: + focus-trap: 7.8.0 + vue: 3.5.30(typescript@5.9.3) + + focus-trap@7.8.0: + dependencies: + tabbable: 6.4.0 + + follow-redirects@1.16.0: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)): + dependencies: + '@babel/code-frame': 7.29.7 + chalk: 4.1.2 + chokidar: 4.0.3 + cosmiconfig: 8.3.6(typescript@5.9.3) + deepmerge: 4.3.1 + fs-extra: 10.1.0 + memfs: 3.5.3 + minimatch: 3.1.5 + node-abort-controller: 3.1.1 + schema-utils: 3.3.0 + semver: 7.8.5 + tapable: 2.3.3 + typescript: 5.9.3 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + format@0.2.2: {} + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded-parse@2.1.2: {} + + forwarded@0.2.0: + optional: true + + fp-ts@2.16.11: {} + + fresh@2.0.0: + optional: true + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-monkey@1.1.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-own-enumerable-property-symbols@3.0.2: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.5 + pathe: 2.0.3 + + git-raw-commits@5.0.1(conventional-commits-parser@6.4.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + + github-slugger@2.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + + glob@13.0.0: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + + global-directory@5.0.0: + dependencies: + ini: 6.0.0 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + global-prefix@4.0.0: + dependencies: + ini: 4.1.3 + kind-of: 6.0.3 + which: 4.0.0 + + globals@14.0.0: {} + + globals@15.15.0: {} + + globals@16.5.0: {} + + globals@17.6.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@16.2.0: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + + globjoin@0.1.4: {} + + globrex@0.1.2: {} + + good-listener@1.2.2: + dependencies: + delegate: 3.2.0 + + google-logging-utils@1.1.3: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + grant@5.4.24: + dependencies: + qs: 6.15.2 + request-compose: 2.1.7 + request-oauth: 1.0.1 + optionalDependencies: + cookie: 0.7.2 + cookie-signature: 1.2.2 + jwk-to-pem: 2.0.7 + jws: 4.0.1 + + graphql@16.14.2: {} + + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + + has-bigints@1.1.0: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-flag@5.0.1: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + optional: true + + hashery@1.5.0: + dependencies: + hookified: 1.15.1 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + headers-polyfill@4.0.3: {} + + helmet@8.1.0: {} + + help-me@5.0.0: {} + + hmac-drbg@1.0.1: + dependencies: + hash.js: 1.1.7 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + optional: true + + hookified@1.15.1: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + html-entities@2.6.0: {} + + html-escaper@2.0.2: {} + + html-tags@5.1.0: {} + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + http2-client@1.3.5: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + husky@9.1.7: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + idb@7.1.1: {} + + ieee754@1.2.1: {} + + ignore-by-default@1.0.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immediate@3.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-in-the-middle@2.0.6: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + import-in-the-middle@3.0.0: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + import-meta-resolve@4.2.0: {} + + imurmurhash@0.1.4: {} + + indent-string@5.0.0: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ini@4.1.3: {} + + ini@6.0.0: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + + io-ts@2.2.22(fp-ts@2.16.11): + dependencies: + fp-ts: 2.16.11 + + ip-regex@4.3.0: {} + + ipaddr.js@1.9.1: + optional: true + + ipaddr.js@2.4.0: {} + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-arrayish@0.2.1: {} + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-builtin-module@5.0.0: + dependencies: + builtin-modules: 5.0.0 + + is-callable@1.2.7: {} + + is-cidr@4.0.2: + dependencies: + cidr-regex: 3.1.1 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-map@2.0.3: {} + + is-module@1.0.0: {} + + is-negative-zero@2.0.3: {} + + is-node-process@1.2.0: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-obj@1.0.1: {} + + is-obj@2.0.0: {} + + is-path-inside@4.0.0: {} + + is-plain-obj@4.1.0: {} + + is-plain-object@5.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: + optional: true + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-regexp@1.0.0: {} + + is-retry-allowed@2.2.0: {} + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-unicode-supported@0.1.0: {} + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterare@1.2.1: {} + + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + javascript-time-ago@2.6.4: + dependencies: + relative-time-format: 1.1.12 + + jest-worker@27.5.1: + dependencies: + '@types/node': 24.12.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jiti@2.6.1: {} + + joycon@3.1.1: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + jsdoc-type-pratt-parser@7.1.1: {} + + jsdoc-type-pratt-parser@7.2.0: {} + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-2-csv@5.5.10: + dependencies: + deeks: 3.1.0 + doc-path: 4.1.1 + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + fast-uri: 3.1.2 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema@0.4.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-eslint-parser@3.1.0: + dependencies: + acorn: 8.17.0 + eslint-visitor-keys: 5.0.1 + semver: 7.8.5 + + jsonc-parser@3.3.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonpointer@5.0.1: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwk-to-pem@2.0.7: + dependencies: + asn1.js: 5.4.1 + elliptic: 6.6.1 + safe-buffer: 5.2.1 + optional: true + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + katex@0.16.45: + dependencies: + commander: 8.3.0 + + keycloak-js@26.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lines-and-columns@1.2.4: {} + + lint-staged@16.4.0: + dependencies: + commander: 14.0.3 + listr2: 9.0.5 + picomatch: 4.0.4 + string-argv: 0.3.2 + tinyexec: 1.2.4 + yaml: 2.9.0 + + listr2@9.0.5: + dependencies: + cli-truncate: 5.2.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + load-esm@1.0.3: {} + + loader-runner@4.3.2: {} + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.1 + pkg-types: 1.3.1 + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.1 + pkg-types: 2.3.0 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.debounce@4.0.8: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash.sortby@4.7.0: {} + + lodash.truncate@4.4.2: {} + + lodash@4.17.23: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + long@5.3.2: {} + + longest-streak@3.1.0: {} + + lru-cache@10.4.3: {} + + lru-cache@11.5.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + luxon@3.7.2: {} + + magic-regexp@0.10.0: + dependencies: + estree-walker: 3.0.3 + magic-string: 0.30.21 + mlly: 1.8.1 + regexp-tree: 0.1.27 + type-level-regexp: 0.1.17 + ufo: 1.6.3 + unplugin: 2.3.11 + + magic-string@0.25.9: + dependencies: + sourcemap-codec: 1.4.8 + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + optional: true + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + markdown-table@3.0.4: {} + + math-intrinsics@1.1.0: {} + + mathml-tag-names@4.0.0: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.27.1: {} + + media-typer@0.3.0: + optional: true + + media-typer@1.1.0: + optional: true + + memfs@3.5.3: + dependencies: + fs-monkey: 1.1.0 + + meow@13.2.0: {} + + meow@14.1.0: {} + + merge-descriptors@2.0.0: + optional: true + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.45 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3(supports-color@5.5.0) + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: + optional: true + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + optional: true + + mime@3.0.0: {} + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimalistic-assert@1.0.1: {} + + minimalistic-crypto-utils@1.0.1: + optional: true + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.1 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mlly@1.8.1: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + mnemonist@0.40.4: + dependencies: + obliterator: 2.0.5 + + module-details-from-path@1.0.4: {} + + module-replacements@2.11.0: {} + + moo@0.5.3: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@24.12.0) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.7.0 + until-async: 3.0.2 + yargs: 17.7.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + muggle-string@0.4.1: {} + + multer@2.1.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + optional: true + + mustache@4.2.0: {} + + mute-stream@2.0.0: {} + + nanoid@3.3.15: {} + + nanoid@5.0.9: {} + + nanoid@5.1.6: {} + + natural-compare@1.4.0: {} + + natural-orderby@5.0.0: {} + + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.3 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + + negotiator@1.0.0: + optional: true + + neo-async@2.6.2: {} + + nestjs-pino@4.6.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2): + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + pino: 10.3.1 + pino-http: 11.0.0 + rxjs: 7.8.2 + + node-abort-controller@3.1.1: {} + + node-domexception@1.0.0: {} + + node-emoji@1.11.0: + dependencies: + lodash: 4.18.1 + + node-fetch-h2@2.3.0: + dependencies: + http2-client: 1.3.5 + + node-fetch-native@1.6.7: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-readfiles@0.2.0: + dependencies: + es6-promise: 3.3.1 + + node-releases@2.0.48: {} + + nodemon@3.1.14: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 10.2.5 + pstree.remy: 1.1.8 + semver: 7.8.5 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nwsapi@2.2.24: {} + + nypm@0.6.5: + dependencies: + citty: 0.2.1 + pathe: 2.0.3 + tinyexec: 1.2.4 + + oas-kit-common@1.0.8: + dependencies: + fast-safe-stringify: 2.1.1 + + oas-linter@3.2.2: + dependencies: + '@exodus/schemasafe': 1.3.0 + should: 13.2.3 + yaml: 1.10.3 + + oas-resolver@2.5.6: + dependencies: + node-fetch-h2: 2.3.0 + oas-kit-common: 1.0.8 + reftools: 1.1.9 + yaml: 1.10.3 + yargs: 17.7.3 + + oas-schema-walker@1.1.5: {} + + oas-validator@5.0.8: + dependencies: + call-me-maybe: 1.0.2 + oas-kit-common: 1.0.8 + oas-linter: 3.2.2 + oas-resolver: 2.5.6 + oas-schema-walker: 1.1.5 + reftools: 1.1.9 + should: 13.2.3 + yaml: 1.10.3 + + oauth-sign@0.9.0: {} + + object-assign@4.1.1: + optional: true + + object-deep-merge@2.0.0: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + obliterator@2.0.5: {} + + obug@2.1.3: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.3 + + ohash@2.0.11: {} + + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + optional: true + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + openapi-types@12.1.3: {} + + openapi3-ts@2.0.2: + dependencies: + yaml: 1.10.3 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + outvariant@1.4.3: {} + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + dependencies: + '@oxc-project/types': 0.115.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.115.0 + '@oxc-parser/binding-android-arm64': 0.115.0 + '@oxc-parser/binding-darwin-arm64': 0.115.0 + '@oxc-parser/binding-darwin-x64': 0.115.0 + '@oxc-parser/binding-freebsd-x64': 0.115.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.115.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.115.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.115.0 + '@oxc-parser/binding-linux-arm64-musl': 0.115.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.115.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.115.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.115.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.115.0 + '@oxc-parser/binding-linux-x64-gnu': 0.115.0 + '@oxc-parser/binding-linux-x64-musl': 0.115.0 + '@oxc-parser/binding-openharmony-arm64': 0.115.0 + '@oxc-parser/binding-wasm32-wasi': 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@oxc-parser/binding-win32-arm64-msvc': 0.115.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.115.0 + '@oxc-parser/binding-win32-x64-msvc': 0.115.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + oxc-walker@0.7.0(oxc-parser@0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)): + dependencies: + magic-regexp: 0.10.0 + oxc-parser: 0.115.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + + p-debounce@4.0.0: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + package-manager-detector@1.6.0: {} + + pako@1.0.11: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-gitignore@2.0.0: {} + + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-statements@1.0.11: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: + optional: true + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.1 + minipass: 7.1.3 + + path-to-regexp@6.3.0: {} + + path-to-regexp@8.3.0: {} + + path-to-regexp@8.4.2: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + pg-int8@1.0.1: {} + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + picocolors@1.1.1: {} + + picomatch-browser@2.2.6: {} + + picomatch@2.3.2: {} + + picomatch@4.0.2: {} + + picomatch@4.0.4: {} + + pinia@2.3.1(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.30(typescript@5.9.3) + vue-demi: 0.14.10(vue@3.5.30(typescript@5.9.3)) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@vue/composition-api' + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-http@11.0.0: + dependencies: + get-caller-file: 2.0.5 + pino: 10.3.1 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.2 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.1.0 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.1 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + + pluralize@8.0.0: {} + + pnpm-workspace-yaml@1.6.0: + dependencies: + yaml: 2.9.0 + + possible-typed-array-names@1.1.0: {} + + postcss-html@1.8.1: + dependencies: + htmlparser2: 8.0.2 + js-tokens: 9.0.1 + postcss: 8.5.15 + postcss-safe-parser: 6.0.0(postcss@8.5.15) + + postcss-safe-parser@6.0.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-safe-parser@7.0.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prelude-ls@1.2.1: {} + + pretty-bytes@5.6.0: {} + + pretty-bytes@6.1.1: {} + + prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3): + dependencies: + '@prisma/config': 6.19.2(magicast@0.3.5) + '@prisma/engines': 6.19.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + + process-nextick-args@2.0.1: {} + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + protobufjs@7.6.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 24.12.0 + long: 5.3.2 + + protobufjs@8.0.0: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 24.12.0 + long: 5.3.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + optional: true + + proxy-from-env@2.1.0: {} + + pstree.remy@1.1.8: {} + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + qified@0.6.0: + dependencies: + hookified: 1.15.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + + quansync@0.2.11: {} + + quansync@1.0.0: {} + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + range-parser@1.2.1: + optional: true + + rate-limiter-flexible@4.0.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + optional: true + + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + + re2-wasm@1.0.2: + optional: true + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + readdirp@4.1.2: {} + + readdirp@5.0.0: {} + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + refa@0.12.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + + reflect-metadata@0.2.2: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + reftools@1.1.9: {} + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regexp-ast-analysis@0.7.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + + regexp-tree@0.1.27: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.2 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 + + relative-time-format@1.1.12: {} + + request-compose@2.1.7: {} + + request-oauth@1.0.1: + dependencies: + oauth-sign: 0.9.0 + qs: 6.15.2 + uuid: 8.3.2 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + + reserved-identifiers@1.2.0: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + ret@0.1.15: {} + + ret@0.5.0: {} + + rettime@0.10.1: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + + rolldown@1.0.0-rc.18: + dependencies: + '@oxc-project/types': 0.128.0 + '@rolldown/pluginutils': 1.0.0-rc.18 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.18 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.18 + '@rolldown/binding-darwin-x64': 1.0.0-rc.18 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.18 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.18 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.18 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.18 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.18 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.18 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.18 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.18 + + rollup@2.80.0: + optionalDependencies: + fsevents: 2.3.3 + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + optional: true + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.15.0 + ajv-keywords: 3.5.2(ajv@6.15.0) + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-keywords: 5.1.0(ajv@8.20.0) + + schemes@1.4.0: + dependencies: + extend: 3.0.2 + + scslre@0.3.0: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + + scule@1.3.0: {} + + secure-json-parse@4.1.0: {} + + select@1.1.2: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + optional: true + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + optional: true + + set-cookie-parser@2.7.2: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + short-uuid@5.2.0: + dependencies: + any-base: 1.1.0 + uuid: 9.0.1 + + should-equal@2.0.0: + dependencies: + should-type: 1.4.0 + + should-format@3.0.3: + dependencies: + should-type: 1.4.0 + should-type-adaptors: 1.1.0 + + should-type-adaptors@1.1.0: + dependencies: + should-type: 1.4.0 + should-util: 1.0.1 + + should-type@1.4.0: {} + + should-util@1.0.1: {} + + should@13.2.3: + dependencies: + should-equal: 2.0.0 + should-format: 3.0.3 + should-type: 1.4.0 + should-type-adaptors: 1.1.0 + should-util: 1.0.1 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.5 + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sisteransi@1.0.5: {} + + slash@5.1.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + slice-ansi@8.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + smob@1.6.2: {} + + smtp-address-parser@1.1.0: + dependencies: + nearley: 2.20.1 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.4: {} + + source-map@0.8.0-beta.0: + dependencies: + whatwg-url: 7.1.0 + + sourcemap-codec@1.4.8: {} + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@4.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.1.0: {} + + steed@1.1.3: + dependencies: + fastfall: 1.5.1 + fastparallel: 2.4.1 + fastq: 1.20.1 + fastseries: 1.7.2 + reusify: 1.1.0 + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + streamsearch@1.1.0: + optional: true + + strict-event-emitter@0.5.1: {} + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-object@3.3.0: + dependencies: + get-own-enumerable-property-symbols: 3.0.2 + is-obj: 1.0.1 + is-regexp: 1.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-comments@2.0.1: {} + + strip-indent@4.1.1: {} + + strip-json-comments@3.1.1: {} + + strip-json-comments@5.0.3: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + strtok3@10.3.4: + dependencies: + '@tokenizer/token': 0.3.0 + + stylelint-config-html@1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + postcss-html: 1.8.1 + stylelint: 17.11.0(typescript@5.9.3) + + stylelint-config-recommended-vue@1.6.1(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + postcss-html: 1.8.1 + semver: 7.8.5 + stylelint: 17.11.0(typescript@5.9.3) + stylelint-config-html: 1.1.0(postcss-html@1.8.1)(stylelint@17.11.0(typescript@5.9.3)) + stylelint-config-recommended: 18.0.0(stylelint@17.11.0(typescript@5.9.3)) + + stylelint-config-recommended@18.0.0(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + stylelint: 17.11.0(typescript@5.9.3) + + stylelint-config-standard@40.0.0(stylelint@17.11.0(typescript@5.9.3)): + dependencies: + stylelint: 17.11.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.11.0(typescript@5.9.3)) + + stylelint@17.11.0(typescript@5.9.3): + dependencies: + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) + colord: 2.9.3 + cosmiconfig: 9.0.2(typescript@5.9.3) + css-functions-list: 3.3.3 + css-tree: 3.2.1 + debug: 4.4.3(supports-color@5.5.0) + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.2 + global-modules: 2.0.0 + globby: 16.2.0 + globjoin: 0.1.4 + html-tags: 5.1.0 + ignore: 7.0.5 + import-meta-resolve: 4.2.0 + is-plain-object: 5.0.0 + mathml-tag-names: 4.0.0 + meow: 14.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-safe-parser: 7.0.1(postcss@8.5.15) + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + string-width: 8.2.1 + supports-hyperlinks: 4.4.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 7.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@10.2.2: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@4.4.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-tags@1.0.0: {} + + swagger-schema-official@2.0.0-bab6bed: {} + + swagger-typescript-api@13.3.1(magicast@0.5.3): + dependencies: + '@apidevtools/swagger-parser': 12.1.0(openapi-types@12.1.3) + '@biomejs/js-api': 4.0.0(@biomejs/wasm-nodejs@2.4.6) + '@biomejs/wasm-nodejs': 2.4.6 + '@types/swagger-schema-official': 2.0.25 + c12: 3.3.3(magicast@0.5.3) + citty: 0.2.1 + consola: 3.4.2 + es-toolkit: 1.48.1 + eta: 3.5.0 + nanoid: 5.1.6 + openapi-types: 12.1.3 + swagger-schema-official: 2.0.0-bab6bed + swagger2openapi: 7.0.8 + type-fest: 5.7.0 + typescript: 5.9.3 + yaml: 2.9.0 + yummies: 7.10.0 + transitivePeerDependencies: + - '@biomejs/wasm-bundler' + - '@biomejs/wasm-web' + - encoding + - magicast + - mobx + - react + + swagger-ui-dist@5.32.4: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger2openapi@7.0.8: + dependencies: + call-me-maybe: 1.0.2 + node-fetch: 2.7.0 + node-fetch-h2: 2.3.0 + node-readfiles: 0.2.0 + oas-kit-common: 1.0.8 + oas-resolver: 2.5.6 + oas-schema-walker: 1.1.5 + oas-validator: 5.0.8 + reftools: 1.1.9 + yaml: 1.10.3 + yargs: 17.7.3 + transitivePeerDependencies: + - encoding + + symbol-observable@4.0.0: {} + + symbol-tree@3.2.4: {} + + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + + tabbable@6.4.0: {} + + table@6.9.0: + dependencies: + ajv: 8.20.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tagged-tag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tapable@2.3.3: {} + + temp-dir@2.0.0: {} + + tempy@0.6.0: + dependencies: + is-stream: 2.0.1 + temp-dir: 2.0.0 + type-fest: 0.16.0 + unique-string: 2.0.0 + + terser-webpack-plugin@5.6.1(esbuild@0.27.3)(lightningcss@1.32.0)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.48.0 + webpack: 5.104.1(esbuild@0.27.3)(lightningcss@1.32.0) + optionalDependencies: + esbuild: 0.27.3 + lightningcss: 1.32.0 + + terser@5.48.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.17.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tiny-emitter@2.1.0: {} + + tinybench@2.9.0: {} + + tinyduration@3.4.1: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tldts-core@6.1.86: {} + + tldts-core@7.4.3: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tldts@7.4.3: + dependencies: + tldts-core: 7.4.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + to-valid-identifier@1.0.0: + dependencies: + '@sindresorhus/base62': 1.0.0 + reserved-identifiers: 1.2.0 + + toad-cache@3.7.0: {} + + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.1 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + toml-eslint-parser@1.0.3: + dependencies: + eslint-visitor-keys: 5.0.1 + + totalist@3.0.1: {} + + touch@3.1.1: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.3 + + tr46@0.0.3: {} + + tr46@1.0.1: + dependencies: + punycode: 2.3.1 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-declaration-location@1.0.7(typescript@5.9.3): + dependencies: + picomatch: 4.0.4 + typescript: 5.9.3 + + ts-deepmerge@6.2.1: {} + + ts-essentials@10.1.1(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + ts-patch@3.3.0: + dependencies: + chalk: 4.1.2 + global-prefix: 4.0.0 + minimist: 1.2.8 + resolve: 1.22.12 + semver: 7.8.5 + strip-ansi: 6.0.1 + + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.24.0 + tapable: 2.3.3 + tsconfig-paths: 4.2.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.16.0: {} + + type-fest@0.20.2: {} + + type-fest@5.7.0: + dependencies: + tagged-tag: 1.0.0 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + optional: true + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + optional: true + + type-level-regexp@0.1.17: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typedarray@0.0.6: + optional: true + + typescript-eslint@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.57.0(@typescript-eslint/parser@8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.0(eslint@10.5.0(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.5.0(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript-transform-paths@3.5.6(typescript@5.9.3): + dependencies: + minimatch: 9.0.9 + typescript: 5.9.3 + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + unconfig@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + defu: 6.1.4 + jiti: 2.6.1 + quansync: 1.0.0 + unconfig-core: 7.5.0 + + undefsafe@2.0.5: {} + + undici-types@7.16.0: {} + + undici@7.24.5: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + unicorn-magic@0.4.0: {} + + unimport@3.14.6(rollup@2.80.0): + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + acorn: 8.17.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + fast-glob: 3.3.3 + local-pkg: 1.1.2 + magic-string: 0.30.21 + mlly: 1.8.1 + pathe: 2.0.3 + picomatch: 4.0.4 + pkg-types: 1.3.1 + scule: 1.3.0 + strip-literal: 2.1.1 + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + + unique-string@2.0.0: + dependencies: + crypto-random-string: 2.0.0 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universalify@2.0.1: {} + + unocss@66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@unocss/cli': 66.6.6 + '@unocss/core': 66.6.6 + '@unocss/preset-attributify': 66.6.6 + '@unocss/preset-icons': 66.6.6 + '@unocss/preset-mini': 66.6.6 + '@unocss/preset-tagify': 66.6.6 + '@unocss/preset-typography': 66.6.6 + '@unocss/preset-uno': 66.6.6 + '@unocss/preset-web-fonts': 66.6.6 + '@unocss/preset-wind': 66.6.6 + '@unocss/preset-wind3': 66.6.6 + '@unocss/preset-wind4': 66.6.6 + '@unocss/transformer-attributify-jsx': 66.6.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@unocss/transformer-compile-class': 66.6.6 + '@unocss/transformer-directives': 66.6.6 + '@unocss/transformer-variant-group': 66.6.6 + '@unocss/vite': 66.6.6(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - vite + + unpipe@1.0.0: + optional: true + + unplugin-auto-import@0.18.6(rollup@2.80.0): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + fast-glob: 3.3.3 + local-pkg: 0.5.1 + magic-string: 0.30.21 + minimatch: 9.0.9 + unimport: 3.14.6(rollup@2.80.0) + unplugin: 1.16.1 + transitivePeerDependencies: + - rollup + + unplugin-utils@0.3.1: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + + unplugin-vue-components@0.27.5(@babel/parser@7.29.7)(rollup@2.80.0)(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + fast-glob: 3.3.3 + local-pkg: 0.5.1 + magic-string: 0.30.21 + minimatch: 9.0.9 + mlly: 1.8.1 + unplugin: 1.16.1 + vue: 3.5.30(typescript@5.9.3) + optionalDependencies: + '@babel/parser': 7.29.7 + transitivePeerDependencies: + - rollup + - supports-color + + unplugin@1.16.1: + dependencies: + acorn: 8.17.0 + webpack-virtual-modules: 0.6.2 + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.17.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + until-async@3.0.2: {} + + upath@1.2.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-template@3.1.1: {} + + util-deprecate@1.0.2: {} + + uuid@8.3.2: {} + + uuid@9.0.1: {} + + vary@1.1.2: + optional: true + + vite-node@6.0.0(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0): + dependencies: + cac: 7.0.0 + es-module-lexer: 2.1.0 + obug: 2.1.3 + pathe: 2.0.3 + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + vite-plugin-pwa@1.2.0(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0): + dependencies: + debug: 4.4.3(supports-color@5.5.0) + pretty-bytes: 6.1.1 + tinyglobby: 0.2.17 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + workbox-build: 7.4.0(@types/babel__core@7.20.5) + workbox-window: 7.4.0 + transitivePeerDependencies: + - supports-color + + vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.59.0 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + terser: 5.48.0 + yaml: 2.9.0 + + vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.0-rc.18 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.0 + esbuild: 0.27.3 + fsevents: 2.3.3 + jiti: 2.6.1 + terser: 5.48.0 + yaml: 2.9.0 + + vitest-mock-extended@2.0.2(typescript@5.9.3)(vitest@4.1.5): + dependencies: + ts-essentials: 10.1.1(typescript@5.9.3) + typescript: 5.9.3 + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 7.3.3(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.0 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + jsdom: 25.0.1 + transitivePeerDependencies: + - msw + + vitest@4.1.5(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/coverage-v8@4.1.5)(jsdom@25.0.1)(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(msw@2.12.10(@types/node@24.12.0)(typescript@5.9.3))(vite@8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.11(@types/node@24.12.0)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.48.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.12.0 + '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + jsdom: 25.0.1 + transitivePeerDependencies: + - msw + + vscode-uri@3.1.0: {} + + vue-demi@0.14.10(vue@3.5.30(typescript@5.9.3)): + dependencies: + vue: 3.5.30(typescript@5.9.3) + + vue-eslint-parser@10.4.0(eslint@10.5.0(jiti@2.6.1)): + dependencies: + debug: 4.4.3(supports-color@5.5.0) + eslint: 10.5.0(jiti@2.6.1) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + vue-router@4.6.4(vue@3.5.30(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.30(typescript@5.9.3) + + vue-tsc@2.2.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.9.3) + typescript: 5.9.3 + + vue3-json-viewer@2.4.1(vue@3.5.30(typescript@5.9.3)): + dependencies: + clipboard: 2.0.11 + vue: 3.5.30(typescript@5.9.3) + + vue@3.5.30(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-sfc': 3.5.30 + '@vue/runtime-dom': 3.5.30 + '@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3)) + '@vue/shared': 3.5.30 + optionalDependencies: + typescript: 5.9.3 + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + watchpack@2.5.2: + dependencies: + graceful-fs: 4.2.11 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@4.0.2: {} + + webidl-conversions@7.0.0: {} + + webpack-node-externals@3.0.0: {} + + webpack-sources@3.5.0: {} + + webpack-virtual-modules@0.6.2: {} + + webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.17.0 + acorn-import-phases: 1.0.4(acorn@8.17.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.24.0 + es-module-lexer: 2.1.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.2 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.1(esbuild@0.27.3)(lightningcss@1.32.0)(webpack@5.104.1(esbuild@0.27.3)(lightningcss@1.32.0)) + watchpack: 2.5.2 + webpack-sources: 3.5.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + whatwg-url@7.1.0: + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.22 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + wildcard-match@5.1.4: {} + + word-wrap@1.2.5: {} + + workbox-background-sync@7.4.0: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.0 + + workbox-broadcast-update@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-build@7.4.0(@types/babel__core@7.20.5): + dependencies: + '@apideck/better-ajv-errors': 0.3.7(ajv@8.20.0) + '@babel/core': 7.29.7 + '@babel/preset-env': 7.29.7(@babel/core@7.29.7) + '@babel/runtime': 7.29.7 + '@rollup/plugin-babel': 5.3.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@2.80.0) + '@rollup/plugin-node-resolve': 15.3.1(rollup@2.80.0) + '@rollup/plugin-replace': 2.4.2(rollup@2.80.0) + '@rollup/plugin-terser': 0.4.4(rollup@2.80.0) + '@surma/rollup-plugin-off-main-thread': 2.2.3 + ajv: 8.20.0 + common-tags: 1.8.2 + fast-json-stable-stringify: 2.1.0 + fs-extra: 9.1.0 + glob: 11.1.0 + lodash: 4.18.1 + pretty-bytes: 5.6.0 + rollup: 2.80.0 + source-map: 0.8.0-beta.0 + stringify-object: 3.3.0 + strip-comments: 2.0.1 + tempy: 0.6.0 + upath: 1.2.0 + workbox-background-sync: 7.4.0 + workbox-broadcast-update: 7.4.0 + workbox-cacheable-response: 7.4.0 + workbox-core: 7.4.0 + workbox-expiration: 7.4.0 + workbox-google-analytics: 7.4.0 + workbox-navigation-preload: 7.4.0 + workbox-precaching: 7.4.0 + workbox-range-requests: 7.4.0 + workbox-recipes: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + workbox-streams: 7.4.0 + workbox-sw: 7.4.0 + workbox-window: 7.4.0 + transitivePeerDependencies: + - '@types/babel__core' + - supports-color + + workbox-cacheable-response@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-core@7.4.0: {} + + workbox-expiration@7.4.0: + dependencies: + idb: 7.1.1 + workbox-core: 7.4.0 + + workbox-google-analytics@7.4.0: + dependencies: + workbox-background-sync: 7.4.0 + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-navigation-preload@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-precaching@7.4.0: + dependencies: + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-range-requests@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-recipes@7.4.0: + dependencies: + workbox-cacheable-response: 7.4.0 + workbox-core: 7.4.0 + workbox-expiration: 7.4.0 + workbox-precaching: 7.4.0 + workbox-routing: 7.4.0 + workbox-strategies: 7.4.0 + + workbox-routing@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-strategies@7.4.0: + dependencies: + workbox-core: 7.4.0 + + workbox-streams@7.4.0: + dependencies: + workbox-core: 7.4.0 + workbox-routing: 7.4.0 + + workbox-sw@7.4.0: {} + + workbox-window@7.4.0: + dependencies: + '@types/trusted-types': 2.0.7 + workbox-core: 7.4.0 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@7.0.1: + dependencies: + signal-exit: 4.1.0 + + ws@8.21.0: {} + + xbytes@1.9.1: {} + + xcase@2.0.1: {} + + xml-name-validator@4.0.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml-eslint-parser@2.0.0: + dependencies: + eslint-visitor-keys: 5.0.1 + yaml: 2.9.0 + + yaml@1.10.3: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + yummies@7.10.0: + dependencies: + class-variance-authority: 0.7.1 + clsx: 2.1.1 + dayjs: 1.11.19 + dompurify: 3.3.2 + nanoid: 5.1.6 + tailwind-merge: 3.5.0 + + zod-validation-error@3.5.4(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + + zwitch@2.0.4: {} diff --git a/.jjconflict-side-2/pnpm-workspace.yaml b/.jjconflict-side-2/pnpm-workspace.yaml new file mode 100644 index 0000000000..a812ffa983 --- /dev/null +++ b/.jjconflict-side-2/pnpm-workspace.yaml @@ -0,0 +1,162 @@ +packages: + - packages/** + - plugins/** + - apps/** + - "!apps/server/src/plugins/external/**" + - playwright/** +allowBuilds: + "@nestjs/core": true + "@prisma/client": true + "@prisma/engines": true + "@scarf/scarf": true + esbuild: true + msw: true + prisma: true + protobufjs: true + vue-demi: true +allowUnusedPatches: true +autoInstallPeers: true +injectWorkspacePackages: true +# minimum number of minutes that must pass after a version is published before +# pnpm will install it. This applies to all dependencies, including transitive ones. +minimumReleaseAge: 1440 # 1 day +catalogMode: strict +cleanupUnusedCatalogs: true +shellEmulator: true +trustPolicy: no-downgrade +# Check these after each update. This list should be empty ! +trustPolicyExclude: + - rollup@2.80.0 + - semver@6.3.1 + - chokidar@4.0.3 + - pino@9.14.0 + - typescript-transform-paths@3.5.6 + - undici-types@6.21.0 +catalogs: + runtime: + "@gitbeaker/core": ^40.6.0 + "@gitbeaker/requester-utils": ^40.6.0 + "@gitbeaker/rest": ^40.6.0 + "@gouvfr/dsfr": ^1.14.4 + "@gouvminint/vue-dsfr": ^8.15.0 + "@himenon/argocd-typescript-openapi": ^1.2.2 + "@iconify-json/ri": ^1.2.10 + "@iconify/vue": ^4.3.0 + "@keycloak/keycloak-admin-client": ^26.5.5 + "@kubernetes-models/argo-cd": ^2.7.2 + "@nestjs/cache-manager": ^3.1.2 + "@nestjs/common": ^11.1.16 + "@nestjs/config": ^4.0.3 + "@nestjs/core": ^11.1.16 + "@nestjs/event-emitter": ^3.0.1 + "@nestjs/jwt": ^11.0.2 + "@nestjs/platform-fastify": ^11.1.16 + "@nestjs/schedule": ^6.1.1 + "@nestjs/swagger": ^11.4.1 + "@nestjs/terminus": ^11.1.1 + "@prisma/client": ^6.19.2 + "@ts-rest/core": ^3.52.1 + "@ts-rest/fastify": ^3.52.1 + "@ts-rest/open-api": ^3.52.1 + axios: ^1.15.1 + cache-manager: ^7.2.8 + date-fns: ^4.1.0 + fastify: ^5.8.5 + fastify-keycloak-adapter: ^3.0.0 + javascript-time-ago: ^2.6.4 + json-2-csv: ^5.5.10 + json-schema: ^0.4.0 + jszip: ^3.10.1 + keycloak-js: ^26.2.3 + mustache: ^4.2.0 + nanoid: 5.0.9 + nestjs-pino: ^4.6.0 + p-debounce: ^4.0.0 + pinia: ^2.3.1 + prisma: ^6.19.2 + reflect-metadata: ^0.2.2 + rxjs: ^7.8.2 + short-uuid: ^5.2.0 + undici: ^7.24.0 + vitest-mock-extended: ^2.0.2 + vue: ^3.5.30 + vue-router: ^4.6.4 + vue3-json-viewer: ^2.4.1 + xbytes: ^1.9.1 + yaml: ^2.7.1 + zod: ^3.25.76 + zod-validation-error: ^3.5.4 + "@fastify/cookie": ^11.0.2 + "@fastify/helmet": ^13.0.2 + "@fastify/otel": ^0.18.1 + "@fastify/session": ^11.1.1 + "@fastify/swagger": ^9.7.0 + "@fastify/swagger-ui": ^5.2.6 + bytes: ^3.1.2 + cron-validator: ^1.4.0 + build: + "@iconify/types": ^2.0.0 + "@nestjs/cli": ^11.0.16 + "@nestjs/schematics": ^11.0.9 + "@nestjs/testing": ^11.1.16 + "@unocss/transformer-directives": ^0.65.4 + "@vitejs/plugin-vue": ^6.0.4 + "@vue/tsconfig": ^0.7.0 + fastify-plugin: ^5.1.0 + swagger-typescript-api: ^13.3.1 + ts-patch: ^3.3.0 + typescript: ^5.9.3 + typescript-transform-paths: ^3.5.6 + unocss: ^66.6.6 + unplugin-auto-import: ^0.18.6 + unplugin-vue-components: ^0.27.5 + vite: ^7.3.2 + vite-node: ^6.0.0 + vite-plugin-pwa: ^1.2.0 + vue-tsc: ^2.2.12 + workbox-window: ^7.4.0 + test: + "@playwright/test": ^1.59.1 + "@faker-js/faker": ^9.9.0 + "@vitest/coverage-v8": ^4.1.5 + globals: ^16.5.0 + jsdom: ^25.0.1 + msw: ^2.12.10 + vitest: ^4.1.5 + types: + "@types/jsdom": ^21.1.7 + "@types/json-schema": ^7.0.15 + "@types/node": ^24.12.0 + undici-types: ^7.16.0 + otel: + "@opentelemetry/api": ^1.9.0 + "@opentelemetry/auto-instrumentations-node": ^0.70.1 + "@opentelemetry/exporter-metrics-otlp-proto": ^0.213.0 + "@opentelemetry/exporter-trace-otlp-proto": ^0.213.0 + "@opentelemetry/instrumentation-nestjs-core": ^0.58.0 + "@opentelemetry/instrumentation-pino": ^0.59.0 + "@opentelemetry/sdk-metrics": ^2.5.1 + "@opentelemetry/sdk-node": ^0.212.0 + tools: + "@antfu/eslint-config": ^8.2.0 + "@commitlint/cli": ^20.5.3 + "@commitlint/config-conventional": ^20.5.3 + "@eslint/eslintrc": ^3.3.5 + "@eslint/js": ^9.39.4 + "@vue/eslint-config-typescript": ^14.7.0 + chalk: ^5.6.2 + eslint: ^10.5.0 + eslint-plugin-vue: ^10.8.0 + husky: ^9.1.7 + js-yaml: ^4.1.0 + lint-staged: ^16.4.0 + nodemon: ^3.1.14 + pino: ^9.14.0 + pino-pretty: ^13.1.3 + rimraf: ^6.1.3 + stylelint: ^17.11.0 + stylelint-config-html: ^1.1.0 + stylelint-config-recommended-vue: ^1.6.1 + stylelint-config-standard: ^40.0.0 + typescript-eslint: ^8.57.0 + vue-eslint-parser: ^10.4.0 diff --git a/.jjconflict-side-2/release-please-config.json b/.jjconflict-side-2/release-please-config.json new file mode 100644 index 0000000000..473308bd27 --- /dev/null +++ b/.jjconflict-side-2/release-please-config.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "versioning": "always-bump-minor", + "pull-request-title-pattern": "chore${scope}: Release${component} v${version}", + "release-type": "node", + "changelog-type": "default", + "changelog-sections": [ + { + "type": "feat", + "hidden": false, + "section": "Features" + }, + { + "type": "feature", + "hidden": false, + "section": "Features" + }, + { + "type": "fix", + "hidden": false, + "section": "Bug Fixes" + }, + { + "type": "chore", + "hidden": false, + "section": "Miscellaneous Chores" + }, + { + "type": "build", + "hidden": false, + "section": "Miscellaneous Chores" + }, + { + "type": "docs", + "hidden": false, + "section": "Docs" + }, + { + "type": "refactor", + "hidden": false, + "section": "Refactoring" + }, + { + "type": "revert", + "hidden": false, + "section": "Reverted commits" + } + ], + "packages": { + ".": { + "package-name": "console", + "include-component-in-tag": false + } + } +} diff --git a/.jjconflict-side-2/scripts/run-build.sh b/.jjconflict-side-2/scripts/run-build.sh new file mode 100755 index 0000000000..9e53b07cf8 --- /dev/null +++ b/.jjconflict-side-2/scripts/run-build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd $(dirname "$COMPOSE_FILE") \ + && docker buildx bake --file $(basename "$COMPOSE_FILE") --load --allow=fs.read=.. \ + && cd - > /dev/null diff --git a/.jjconflict-side-2/scripts/update-hosts.sh b/.jjconflict-side-2/scripts/update-hosts.sh new file mode 100755 index 0000000000..0de643ec93 --- /dev/null +++ b/.jjconflict-side-2/scripts/update-hosts.sh @@ -0,0 +1,130 @@ +#!/bin/bash + +set -xe +set -o pipefail + +# Colorize terminal +export red='\e[0;31m' +export no_color='\033[0m' + +# Get versions +export DOCKER_VERSION="$(docker --version)" + +# Default +export SCRIPTPATH="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" +export HELM_RELEASE_NAME="dso" +export INTEGRATION_ARG="" +export INTEGRATION_ARGS_UTILS="" +export CI_ARGS="" + + +# Declare script helper +export TEXT_HELPER="\nThis script aims to manage a local kubernetes cluster using Kind also known as Kubernetes in Docker. +Following flags are available: + + -c Command tu run. Multiple commands can be provided as a comma separated list. + Available commands are : + create - Create kind cluster. + clean - Delete images in kind cluster (keep only infra resources and ingress controller). + delete - Delete kind cluster. + build - Build, push and load docker images from compose file into cluster nodes. + load - Load docker images from compose file into cluster nodes. + dev - Run application in development mode. + prod - Run application in production mode. + integ - Run application in integration mode (need to combine with 'dev' or 'prod'). + + -d Domains to add in /etc/hosts for local services resolution. Comma separated list. This will require sudo. + + -f Path to the docker-compose file that will be used with Kind. + + -i Install kind. + + -k Path to the kubeconfig to use. + + -t Tag used to deploy application images. + + -h Print script help.\n\n" + +print_help() { + printf "$TEXT_HELPER" +} + +# Parse options +while getopts hc:d:f:ik:t: flag; do + case "${flag}" in + c) + export COMMAND=${OPTARG};; + d) + export DOMAINS=${OPTARG};; + f) + export COMPOSE_FILE=${OPTARG};; + i) + export INSTALL_KIND=true;; + k) + export KUBECONFIG_HOST_PATH=${OPTARG};; + t) + export TAG=${OPTARG};; + h | *) + print_help + exit 0;; + esac +done + + +# Utils +install_kind() { + printf "\n\n${red}[kind wrapper].${no_color} Install kind...\n\n" + if [ "$(uname)" = "Linux" ]; then + export OS=linux + elif [ "$(uname)" = "Darwin" ]; then + export OS=darwin + else + printf "\n\nNo installation available for your system, plese refer to the installation guide\n\n" + exit 0 + fi + + if [ "$(uname -m)" = "x86_64" ]; then + export ARCH=amd64 + elif [ "$(uname -m)" = "arm64" ] || [ "$(uname -m)" = "aarch64" ]; then + export ARCH=arm64 + fi + + curl -Lo ./kind "https://kind.sigs.k8s.io/dl/v0.20.0/kind-$OS-$ARCH" + chmod +x ./kind + mv ./kind /usr/local/bin/kind + + printf "\n\n$(kind --version) installed\n\n" +} + +if [ "$INSTALL_KIND" = "true" ] && [ -z "$(kind --version)" ]; then + install_kind +fi + + +# Script condition +if [ -z "$(kind --version)" ]; then + echo "\nYou need to install kind to run this script.\n" + print_help + exit 1 +fi + +if [[ "$COMMAND" =~ "build" ]] && [ ! -f "$(readlink -f $COMPOSE_FILE)" ]; then + echo "\nDocker compose file $COMPOSE_FILE does not exist.\n" + print_help + exit 1 +fi + + +# Add local services to /etc/hosts +if [ ! -z "$DOMAINS" ]; then + printf "\n\n${red}[kind wrapper].${no_color} Add services local domains to /etc/hosts\n\n" + + export FORMATED_DOMAINS="$(echo "$DOMAINS" | sed 's/,/\ /g')" + if [ "$(grep -c "$FORMATED_DOMAINS" /etc/hosts)" -ge 1 ]; then + printf "\n\n${red}[kind wrapper].${no_color} Services local domains already added to /etc/hosts\n\n" + else + sudo sh -c "echo $'\n\n# Kind\n127.0.0.1 $FORMATED_DOMAINS' >> /etc/hosts" + + printf "\n\n${red}[kind wrapper].${no_color} Services local domains successfully added to /etc/hosts\n\n" + fi +fi diff --git a/JJ-CONFLICT-README b/JJ-CONFLICT-README new file mode 100644 index 0000000000..c8ea641ab5 --- /dev/null +++ b/JJ-CONFLICT-README @@ -0,0 +1,11 @@ +This commit was made by jj, https://jj-vcs.dev/. +The commit contains file conflicts, and therefore looks wrong when used with +plain Git or other tools that are unfamiliar with jj. + +The .jjconflict-* directories represent the different inputs to the conflict. +For details, see +https://docs.jj-vcs.dev/latest/git-compatibility/#format-mapping-details + +If you see this file in your working copy, it probably means that you used a +regular `git` command to check out a conflicted commit. Use `jj abandon` to +recover. diff --git a/apps/server-nestjs/.env-example b/apps/server-nestjs/.env-example index f9f3a143f9..1da8adba5c 100644 --- a/apps/server-nestjs/.env-example +++ b/apps/server-nestjs/.env-example @@ -2,6 +2,8 @@ DEV_SETUP="true" # Mode d'exécution Node.js (development | production | test) NODE_ENV=development +# Activation des plugins externes (true | false). Actif par défaut en développement. +USE_PLUGINS=true # HOME=/home/node # Secret utilisé pour signer les cookies de session (min. 32 caractères) SESSION_SECRET=a-very-strong-secret-with-more-than-32-char @@ -41,7 +43,19 @@ CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr # --- Configuration OpenCDS --- # URL de l'API OpenCDS (laisser vide pour désactiver) OPENCDS_URL= +# URL interne de l'API OpenCDS +OPENCDS_INTERNAL_URL= # Token d'authentification pour l'API OpenCDS OPENCDS_API_TOKEN=token # Vérification du certificat TLS de l'API OpenCDS (true | false) OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_GITLAB=false +USE_NEXUS=false +USE_SONARQUBE=false +USE_REGISTRY=false +USE_HARBOR=false +USE_OPENCDS=false +USE_ARGOCD=false diff --git a/apps/server-nestjs/.env.docker-example b/apps/server-nestjs/.env.docker-example index 0096e0736e..d0cf596b3a 100644 --- a/apps/server-nestjs/.env.docker-example +++ b/apps/server-nestjs/.env.docker-example @@ -4,6 +4,8 @@ DOCKER=true DEV_SETUP="true" # Mode d'exécution Node.js (development | production | test) NODE_ENV=development +# Activation des plugins externes (true | false). +USE_PLUGINS=fasle # Secret utilisé pour signer les cookies de session (min. 32 caractères) SESSION_SECRET=a-very-strong-secret-with-more-than-32-char @@ -43,7 +45,20 @@ CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr # URL de l'API OpenCDS (laisser vide pour désactiver) # Par défaut configuré sur le conteneur Mockoon (à changer sur envs non-PAX) OPENCDS_URL= +# URL interne de l'API OpenCDS +OPENCDS_INTERNAL_URL= # Token d'authentification pour l'API OpenCDS OPENCDS_API_TOKEN=token # Vérification du certificat TLS de l'API OpenCDS (true | false) OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false + +# --- Activation des plugins externes --- +# Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. +USE_GITLAB=false +USE_VAULT=false +USE_NEXUS=false +USE_SONARQUBE=false +USE_REGISTRY=false +USE_HARBOR=false +USE_OPENCDS=false +USE_ARGOCD=false diff --git a/apps/server-nestjs/.env.integ-example b/apps/server-nestjs/.env.integ-example index 9b1a5cb454..f0bf743326 100644 --- a/apps/server-nestjs/.env.integ-example +++ b/apps/server-nestjs/.env.integ-example @@ -5,6 +5,8 @@ DEV_SETUP="false" # Active le mode intégration (charge ce fichier en surcharge) INTEGRATION=true +# Activation des plugins externes (true | false). Désactivé en mode intégration. +USE_PLUGINS=false # --- Keycloak --- # Protocole (généralement https en intégration) @@ -111,5 +113,18 @@ DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 # Configuration OpenCDS OPENCDS_URL= +OPENCDS_INTERNAL_URL= OPENCDS_API_TOKEN=token OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false + +# --- Activation des plugins externes --- +# Désactivés par défaut en intégration : renseignez les variables et passez à "true" pour activer. +USE_KEYCLOAK=true +USE_GITLAB=true +USE_VAULT=true +USE_NEXUS=true +USE_SONARQUBE=true +USE_REGISTRY=true +USE_HARBOR=true +USE_OPENCDS=true +USE_ARGOCD=true diff --git a/apps/server-nestjs/src/config/argocd.config.ts b/apps/server-nestjs/src/config/argocd.config.ts new file mode 100644 index 0000000000..a7f1ecc094 --- /dev/null +++ b/apps/server-nestjs/src/config/argocd.config.ts @@ -0,0 +1,25 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const argocdFeatureSchema = z.object({ + ARGO_NAMESPACE: z.string().default('argocd'), + ARGOCD_URL: z.string().url(), + ARGOCD_INTERNAL_URL: z.string().url().optional(), + ARGOCD_EXTRA_REPOSITORIES: z.string().min(1), + DSO_ENV_CHART_VERSION: z.string().default('dso-env-1.6.0'), + DSO_NS_CHART_VERSION: z.string().default('dso-ns-1.1.5'), + VAULT__DEPLOY_VAULT_CONNECTION_IN_NS: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform(raw => ({ + namespace: raw.ARGO_NAMESPACE, + url: raw.ARGOCD_URL, + internalUrl: raw.ARGOCD_INTERNAL_URL, + extraRepositories: raw.ARGOCD_EXTRA_REPOSITORIES, + dsoEnvChartVersion: raw.DSO_ENV_CHART_VERSION, + dsoNsChartVersion: raw.DSO_NS_CHART_VERSION, + vaultDeployVaultConnectionInNs: raw.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS, + internalOrPublicUrl: raw.ARGOCD_INTERNAL_URL ?? raw.ARGOCD_URL, + probeUrl: new URL('/api/version', raw.ARGOCD_URL).toString(), +})) + +export const argocdConfigFactory = registerAs('argocd', () => argocdFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/base.config.spec.ts b/apps/server-nestjs/src/config/base.config.spec.ts new file mode 100644 index 0000000000..4aa7c2762c --- /dev/null +++ b/apps/server-nestjs/src/config/base.config.spec.ts @@ -0,0 +1,18 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from './base.config' + +describe('baseConfigFactory', () => { + beforeEach(() => { + vi.stubEnv('NODE_ENV', 'development') + }) + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('does not throw when PROJECTS_ROOT_DIR is unset', () => { + vi.stubEnv('PROJECTS_ROOT_DIR', '') + delete process.env.PROJECTS_ROOT_DIR + expect(() => baseConfigFactory()).not.toThrow() + expect(baseConfigFactory().projectsRootDir).toBe('') + }) +}) diff --git a/apps/server-nestjs/src/config/base.config.ts b/apps/server-nestjs/src/config/base.config.ts new file mode 100644 index 0000000000..3cbbb482ee --- /dev/null +++ b/apps/server-nestjs/src/config/base.config.ts @@ -0,0 +1,32 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, truthySchema } from './config.utils' + +const baseFeatureSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).optional(), + CI: flag(truthySchema.default('false')), + SERVER_HOST: z.string().default('localhost'), + SERVER_PORT: z.string().transform(Number).default('0'), + APP_VERSION: z.string().optional().default('unknown'), + DB_URL: z.string().url().optional(), + PROJECTS_ROOT_DIR: z.string().optional(), + HTTP_PROXY: z.string().url().optional(), +}).transform((raw) => { + const nodeEnv = raw.NODE_ENV ?? 'production' + return { + isTest: raw.NODE_ENV === 'test', + isDev: raw.NODE_ENV === 'development', + isCI: raw.CI, + isProd: nodeEnv === 'production', + serverHost: raw.SERVER_HOST, + serverPort: raw.SERVER_PORT, + appVersion: raw.NODE_ENV === 'production' ? raw.APP_VERSION : 'dev', + dbUrl: raw.DB_URL, + projectsRootDir: raw.PROJECTS_ROOT_DIR ?? '', + httpProxy: raw.HTTP_PROXY, + } +}) + +export type BaseConfig = z.infer + +export const baseConfigFactory = registerAs('base', () => baseFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/config.utils.spec.ts b/apps/server-nestjs/src/config/config.utils.spec.ts new file mode 100644 index 0000000000..155c025349 --- /dev/null +++ b/apps/server-nestjs/src/config/config.utils.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import z from 'zod' +import { csv, flag, truthySchema } from './config.utils' + +describe('config.utils', () => { + describe('flag', () => { + it('coerces true/1 (case-insensitive) to true', () => { + const schema = flag(truthySchema.default('false')) + expect(schema.parse('true')).toBe(true) + expect(schema.parse('1')).toBe(true) + expect(schema.parse('TRUE')).toBe(true) + }) + + it('coerces false/0 to false', () => { + const schema = flag(truthySchema.default('true')) + expect(schema.parse('false')).toBe(false) + expect(schema.parse('0')).toBe(false) + }) + + it('falls back to the schema default when missing', () => { + expect(flag(truthySchema.default('false')).parse(undefined)).toBe(false) + expect(flag(truthySchema.default('true')).parse(undefined)).toBe(true) + }) + }) + + describe('csv', () => { + const schema = csv(z.string()) + + it('splits, trims and drops empty parts', () => { + expect(schema.parse('a, b ,,c')).toEqual(['a', 'b', 'c']) + }) + + it('maps missing/empty to an empty array', () => { + expect(schema.parse(undefined)).toEqual([]) + expect(schema.parse('')).toEqual([]) + }) + }) +}) diff --git a/apps/server-nestjs/src/config/config.utils.ts b/apps/server-nestjs/src/config/config.utils.ts new file mode 100644 index 0000000000..f06cde73f8 --- /dev/null +++ b/apps/server-nestjs/src/config/config.utils.ts @@ -0,0 +1,27 @@ +import z from 'zod' + +// Shared truthy enum for flag(): 'true'/'false'/'1'/'0'. +export const truthySchema = z.enum(['true', 'false', '1', '0']) + +// Boolean flag. Strictly takes a z string schema (e.g. truthySchema.default('true')). +// Lowercases, then coerces 'true'/'1' -> true, 'false'/'0' -> false. +// Missing value falls back to the schema's default. +export function flag(schema: z.ZodType) { + return z + .preprocess( + val => (typeof val === 'string' ? val.toLowerCase() : val), + schema, + ) + .transform(val => val === 'true' || val === '1') +} + +// Comma-separated string -> array of schema-validated, trimmed non-empty parts. +// Empty/missing -> []. Strictly takes a z string schema (applied per element). +export function csv>(schema: T) { + return z + .preprocess( + val => (typeof val === 'string' ? val : val ?? ''), + z.string().transform(value => value.split(',').map(part => part.trim()).filter(Boolean)), + ) + .pipe(z.array(schema)) +} diff --git a/apps/server-nestjs/src/config/gitlab.config.ts b/apps/server-nestjs/src/config/gitlab.config.ts new file mode 100644 index 0000000000..651fb89f64 --- /dev/null +++ b/apps/server-nestjs/src/config/gitlab.config.ts @@ -0,0 +1,28 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const gitlabFeatureSchema = z.object({ + GITLAB_TOKEN: z.string().min(1), + GITLAB_URL: z.string().url(), + GITLAB_INTERNAL_URL: z.string().url().optional(), + GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS: z.coerce.number().int().positive().default(180), + GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + GITLAB__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), + PROJECTS_ROOT_DIR: z.string().optional(), +}).transform((raw) => { + const urlBase = raw.GITLAB_INTERNAL_URL ?? raw.GITLAB_URL + return { + token: raw.GITLAB_TOKEN, + url: raw.GITLAB_URL, + internalUrl: raw.GITLAB_INTERNAL_URL, + secretExposeInternalUrl: raw.GITLAB__SECRET_EXPOSE_INTERNAL_URL, + mirrorTokenExpirationDays: raw.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS, + mirrorTokenRotationThresholdDays: raw.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS, + projectRootDir: raw.PROJECTS_ROOT_DIR ?? '', + internalOrPublicUrl: urlBase, + probeUrl: new URL('/-/health', urlBase).toString(), + } +}) + +export const gitlabConfigFactory = registerAs('gitlab', () => gitlabFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/harbor.config.ts b/apps/server-nestjs/src/config/harbor.config.ts new file mode 100644 index 0000000000..3e4d093272 --- /dev/null +++ b/apps/server-nestjs/src/config/harbor.config.ts @@ -0,0 +1,34 @@ +import type { HarborConfig } from '../modules/registry/harbor.module-definition' +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const harborFeatureSchema = z.object({ + HARBOR_URL: z.string().url(), + HARBOR_INTERNAL_URL: z.string().url().optional(), + HARBOR_ADMIN: z.string().min(1), + HARBOR_ADMIN_PASSWORD: z.string().min(1), + HARBOR_RULE_TEMPLATE: z.string().optional(), + HARBOR_RULE_COUNT: z.string().optional(), + HARBOR_RETENTION_CRON: z.string().default('0 22 2 * * *'), + HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + HARBOR_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform((raw) => { + const urlBase = raw.HARBOR_INTERNAL_URL ?? raw.HARBOR_URL + return { + url: raw.HARBOR_URL, + internalUrl: raw.HARBOR_INTERNAL_URL, + admin: raw.HARBOR_ADMIN, + adminPassword: raw.HARBOR_ADMIN_PASSWORD, + ruleTemplate: raw.HARBOR_RULE_TEMPLATE, + ruleCount: raw.HARBOR_RULE_COUNT, + retentionCron: raw.HARBOR_RETENTION_CRON, + robotRotationThresholdDays: raw.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.HARBOR_PROJECT_SLUG_CACHE_TTL_MS, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/api/v2.0/ping', urlBase).toString(), + } +}) + +export type HarborAppConfig = HarborConfig + +export const harborConfigFactory = registerAs('harbor', () => harborFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/keycloak.config.ts b/apps/server-nestjs/src/config/keycloak.config.ts new file mode 100644 index 0000000000..576cd9c996 --- /dev/null +++ b/apps/server-nestjs/src/config/keycloak.config.ts @@ -0,0 +1,50 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const keycloakFeatureSchema = z.object({ + KEYCLOAK_PROTOCOL: z.enum(['http', 'https']).default('https'), + KEYCLOAK_DOMAIN: z.string().min(1), + KEYCLOAK_PUBLIC_PROTOCOL: z.enum(['http', 'https']).default('https'), + KEYCLOAK_PUBLIC_DOMAIN: z.string().min(1), + KEYCLOAK_REALM: z.string().min(1), + KEYCLOAK_CLIENT_ID: z.string().min(1), + KEYCLOAK_CLIENT_SECRET: z.string().min(1), + KEYCLOAK_ADMIN: z.string().min(1), + KEYCLOAK_ADMIN_PASSWORD: z.string().min(1), + KEYCLOAK_ADMIN_CLIENT_ID: z.string().default('admin-cli'), + KEYCLOAK_REDIRECT_URI: z.string().url(), + KEYCLOAK_JWKS_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + KEYCLOAK_JWKS_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), + KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + ADMIN_KC_USER_ID: z.preprocess( + value => (typeof value === 'string' ? value.split(',').map(part => part.trim()).filter(Boolean) : value), + z.array(z.string()).default([]), + ), +}).transform((raw) => { + const keycloakUrl = `${raw.KEYCLOAK_PROTOCOL}://${raw.KEYCLOAK_DOMAIN}` + const keycloakRealmUrl = `${keycloakUrl}/realms/${raw.KEYCLOAK_REALM}` + return { + protocol: raw.KEYCLOAK_PROTOCOL, + domain: raw.KEYCLOAK_DOMAIN, + publicProtocol: raw.KEYCLOAK_PUBLIC_PROTOCOL, + publicDomain: raw.KEYCLOAK_PUBLIC_DOMAIN, + realm: raw.KEYCLOAK_REALM, + clientId: raw.KEYCLOAK_CLIENT_ID, + clientSecret: raw.KEYCLOAK_CLIENT_SECRET, + admin: raw.KEYCLOAK_ADMIN, + adminPassword: raw.KEYCLOAK_ADMIN_PASSWORD, + adminClientId: raw.KEYCLOAK_ADMIN_CLIENT_ID, + redirectUri: raw.KEYCLOAK_REDIRECT_URI, + jwksCacheTtlMs: raw.KEYCLOAK_JWKS_CACHE_TTL_MS, + jwksTimeoutMs: raw.KEYCLOAK_JWKS_TIMEOUT_MS, + openidConfigurationCacheTtlMs: raw.KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS, + adminKcUserId: raw.ADMIN_KC_USER_ID, + url: keycloakUrl, + realmUrl: keycloakRealmUrl, + openidConfigurationUrl: `${keycloakRealmUrl}/.well-known/openid-configuration`, + } +}) + +export type KeycloakConfig = z.infer + +export const keycloakConfigFactory = registerAs('keycloak', () => keycloakFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/nexus.config.ts b/apps/server-nestjs/src/config/nexus.config.ts new file mode 100644 index 0000000000..cfdc23000b --- /dev/null +++ b/apps/server-nestjs/src/config/nexus.config.ts @@ -0,0 +1,24 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const nexusFeatureSchema = z.object({ + NEXUS_URL: z.string().url(), + NEXUS_INTERNAL_URL: z.string().url().optional(), + NEXUS_ADMIN: z.string().min(1), + NEXUS_ADMIN_PASSWORD: z.string().min(1), + NEXUS__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + const urlBase = raw.NEXUS_INTERNAL_URL ?? raw.NEXUS_URL + return { + url: raw.NEXUS_URL, + internalUrl: raw.NEXUS_INTERNAL_URL, + admin: raw.NEXUS_ADMIN, + adminPassword: raw.NEXUS_ADMIN_PASSWORD, + secretExposeInternalUrl: raw.NEXUS__SECRET_EXPOSE_INTERNAL_URL, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/service/rest/v1/status', urlBase).toString(), + } +}) + +export const nexusConfigFactory = registerAs('nexus', () => nexusFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/opencds.config.ts b/apps/server-nestjs/src/config/opencds.config.ts new file mode 100644 index 0000000000..fb1da23b96 --- /dev/null +++ b/apps/server-nestjs/src/config/opencds.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const opencdsFeatureSchema = z.object({ + OPENCDS_URL: z.string().url(), + OPENCDS_INTERNAL_URL: z.string().url().optional(), + OPENCDS_API_TOKEN: z.string().min(1), + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + const probeBase = raw.OPENCDS_INTERNAL_URL ?? raw.OPENCDS_URL + return { + url: raw.OPENCDS_URL, + internalUrl: raw.OPENCDS_INTERNAL_URL, + probeUrl: new URL('/api/v1/health', probeBase).toString(), + apiToken: raw.OPENCDS_API_TOKEN, + apiTlsRejectUnauthorized: raw.OPENCDS_API_TLS_REJECT_UNAUTHORIZED, + } +}) + +export const openCdsConfigFactory = registerAs('opencds', () => opencdsFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/registry.config.ts b/apps/server-nestjs/src/config/registry.config.ts new file mode 100644 index 0000000000..c30ef12c50 --- /dev/null +++ b/apps/server-nestjs/src/config/registry.config.ts @@ -0,0 +1,30 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const registryFeatureSchema = z.object({ + REGISTRY_URL: z.string().url(), + REGISTRY_INTERNAL_URL: z.string().url().optional(), + REGISTRY_ADMIN: z.string().min(1), + REGISTRY_ADMIN_PASSWORD: z.string().min(1), + REGISTRY_RULE_TEMPLATE: z.string().min(1), + REGISTRY_RULE_COUNT: z.coerce.number().int().nonnegative().optional(), + REGISTRY_RETENTION_CRON: z.string().default('0 22 2 * * *'), + REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + REGISTRY_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform((raw) => { + const urlBase = raw.REGISTRY_INTERNAL_URL ?? raw.REGISTRY_URL + return { + url: raw.REGISTRY_URL, + internalUrl: raw.REGISTRY_INTERNAL_URL, + admin: raw.REGISTRY_ADMIN, + adminPassword: raw.REGISTRY_ADMIN_PASSWORD, + ruleTemplate: raw.REGISTRY_RULE_TEMPLATE, + ruleCount: raw.REGISTRY_RULE_COUNT, + retentionCron: raw.REGISTRY_RETENTION_CRON, + robotRotationThresholdDays: raw.REGISTRY_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.REGISTRY_PROJECT_SLUG_CACHE_TTL_MS, + internalOrPublicUrl: urlBase, + } +}) + +export const registryConfigFactory = registerAs('registry', () => registryFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/sonarqube.config.ts b/apps/server-nestjs/src/config/sonarqube.config.ts new file mode 100644 index 0000000000..78fdd00939 --- /dev/null +++ b/apps/server-nestjs/src/config/sonarqube.config.ts @@ -0,0 +1,19 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const sonarqubeFeatureSchema = z.object({ + SONARQUBE_URL: z.string().url(), + SONARQUBE_INTERNAL_URL: z.string().url().optional(), + SONAR_API_TOKEN: z.string().min(1), +}).transform((raw) => { + const urlBase = raw.SONARQUBE_INTERNAL_URL ?? raw.SONARQUBE_URL + return { + url: raw.SONARQUBE_URL, + internalUrl: raw.SONARQUBE_INTERNAL_URL, + apiToken: raw.SONAR_API_TOKEN, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/api/system/health', urlBase).toString(), + } +}) + +export const sonarqubeConfigFactory = registerAs('sonarqube', () => sonarqubeFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/vault.config.ts b/apps/server-nestjs/src/config/vault.config.ts new file mode 100644 index 0000000000..83aca3cb62 --- /dev/null +++ b/apps/server-nestjs/src/config/vault.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const vaultFeatureSchema = z.object({ + VAULT_TOKEN: z.string().min(1), + VAULT_URL: z.string().url(), + VAULT_INTERNAL_URL: z.string().url().optional(), + VAULT_KV_NAME: z.string().default('forge-dso'), +}).transform((raw) => { + const urlBase = raw.VAULT_INTERNAL_URL ?? raw.VAULT_URL + return { + token: raw.VAULT_TOKEN, + url: raw.VAULT_URL, + internalUrl: raw.VAULT_INTERNAL_URL, + kvName: raw.VAULT_KV_NAME, + internalOrPublicUrl: urlBase, + probeUrl: new URL('/v1/sys/health', urlBase).toString(), + } +}) + +export const vaultConfigFactory = registerAs('vault', () => vaultFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index adcd9691a1..87f8cb8180 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,5 +1,8 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { ScheduleModule } from '@nestjs/schedule' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from './config/base.config' import { DeploymentModule } from './modules/deployment/deployment.module' import { EnvironmentModule } from './modules/environment/environment.module' import { HealthzModule } from './modules/healthz/healthz.module' @@ -16,9 +19,16 @@ import { ProjectModule } from './modules/project/project.module' import { ServiceChainModule } from './modules/service-chain/service-chain.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' import { VersionModule } from './modules/version/version.module' +import { getDotenvPaths } from './utils/dotenv.utils' @Module({ imports: [ + ConfigModule.forRoot({ + envFilePath: getDotenvPaths(), + isGlobal: true, + load: [baseConfigFactory], + }), + TerminusModule.forRoot(), InfrastructureModule, HealthzModule, KeycloakModule, diff --git a/apps/server-nestjs/src/main.ts b/apps/server-nestjs/src/main.ts index 28b5180f5e..600f7232f7 100644 --- a/apps/server-nestjs/src/main.ts +++ b/apps/server-nestjs/src/main.ts @@ -1,4 +1,5 @@ import type { NestFastifyApplication } from '@nestjs/platform-fastify' +import type { BaseConfig } from './modules/infrastructure/config/base.config' import { NestFactory } from '@nestjs/core' import { FastifyAdapter } from '@nestjs/platform-fastify' import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' @@ -9,7 +10,7 @@ import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' import { NodeSDK, resources } from '@opentelemetry/sdk-node' import { Logger } from 'nestjs-pino' import { MainModule } from './main.module' -import { ConfigurationService } from './modules/infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from './modules/infrastructure/config/base.module' const SERVICE_NAME = 'console-pi-native-console' @@ -43,7 +44,7 @@ async function bootstrap() { await telemetry.shutdown() }) - const config = app.get(ConfigurationService) + const config = app.get(BASE_CONFIG) // Setup swagger-ui route const swaggerConfig = new DocumentBuilder() @@ -55,7 +56,7 @@ async function bootstrap() { const documentFactory = () => SwaggerModule.createDocument(app, swaggerConfig) SwaggerModule.setup('swagger-ui-server-nestjs', app, documentFactory) - await app.listen(config.port, config.host) + await app.listen(config.serverPort, config.serverHost) const serverUrl = await app.getUrl() const logger = app.get(Logger) diff --git a/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts index 8c1c1f2b60..f79fc15424 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts @@ -1,21 +1,19 @@ +import type { ArgocdConfig } from './argocd.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { ARGOCD_CONFIG } from './argocd.module-definition' @Injectable() export class ArgoCDHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(ARGOCD_CONFIG) private readonly argocdConfig: ArgocdConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - const urlBase = this.config.getInternalOrPublicArgoCDUrl() - if (!urlBase) return indicator.down('Not configured') - try { - const response = await fetch(new URL('/api/version', urlBase).toString()) + const response = await fetch(this.argocdConfig.probeUrl) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { diff --git a/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts b/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts new file mode 100644 index 0000000000..a134c72f4d --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd.module-definition.ts @@ -0,0 +1,19 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface ArgocdConfig { + namespace: string + url: string + internalUrl: string + extraRepositories: string + dsoEnvChartVersion: string + dsoNsChartVersion: string + vaultDeployVaultConnectionInNs: boolean + internalOrPublicUrl?: string +} + +export interface ArgoCDModuleOptions extends ArgocdConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('argocd') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as ARGOCD_CONFIG } diff --git a/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts b/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts new file mode 100644 index 0000000000..e3e4064a44 --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd.module.spec.ts @@ -0,0 +1,38 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ArgoCDModule } from './argocd.module' +import { ArgoCDService } from './argocd.service' + +describe('argocdModule', () => { + beforeEach(() => { + vi.stubEnv('PROJECTS_ROOT_DIR', 'tmp/projects') + vi.stubEnv('VAULT_URL', 'https://vault.example') + vi.stubEnv('VAULT_INTERNAL_URL', 'https://vault.internal') + vi.stubEnv('VAULT_TOKEN', 'token') + vi.stubEnv('ARGOCD_URL', 'https://argocd.example') + vi.stubEnv('ARGOCD_INTERNAL_URL', 'https://argocd.internal') + vi.stubEnv('ARGOCD_EXTRA_REPOSITORIES', 'repo') + vi.stubEnv('GITLAB_URL', 'https://gitlab.example') + vi.stubEnv('GITLAB_INTERNAL_URL', 'https://gitlab.internal') + vi.stubEnv('GITLAB_TOKEN', 'token') + }) + + afterEach(() => vi.unstubAllEnvs()) + + it('registers ArgoCDService when USE_ARGOCD=true', async () => { + vi.stubEnv('USE_ARGOCD', 'true') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD')], + }).compile() + expect(module.get(ArgoCDService)).toBeInstanceOf(ArgoCDService) + }) + + it('omits ArgoCDService when USE_ARGOCD=false', async () => { + vi.stubEnv('USE_ARGOCD', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD')], + }).compile() + expect(() => module.get(ArgoCDService)).toThrow() + }) +}) diff --git a/apps/server-nestjs/src/modules/argocd/argocd.module.ts b/apps/server-nestjs/src/modules/argocd/argocd.module.ts index ad51bf62dd..e38fad5b8b 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.module.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.module.ts @@ -1,7 +1,10 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' +import { argocdConfigFactory } from '../../config/argocd.config' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { GitlabModule } from '../gitlab/gitlab.module' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { ArgoCDDatastoreService } from './argocd-datastore.service' @@ -10,7 +13,7 @@ import { ArgoCDPluginService } from './argocd-plugin.service' import { ArgoCDService } from './argocd.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, GitlabModule, TerminusModule, VaultModule], + imports: [DatabaseModule, GitlabModule, TerminusModule, VaultModule, ConfigModule.forFeature(argocdConfigFactory), ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], providers: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService, ArgoCDDatastoreService], exports: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService], }) diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts index 95266ad475..2c0511c4bf 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -1,4 +1,7 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { ArgocdConfig } from './argocd.module-definition' import { generateNamespaceName } from '@cpn-console/shared' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' @@ -6,10 +9,12 @@ import { mockDeep } from 'vitest-mock-extended' import { stringify } from 'yaml' import { GitlabClientService } from '../gitlab/gitlab-client.service' import { makeCommitAction, makeProjectSchema, makeRepositoryTreeSchema } from '../gitlab/gitlab-testing.utils' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.module' import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' import { ArgoCDDatastoreService } from './argocd-datastore.service' import { makeProjectDeployment, makeProjectDeploymentSource, makeProjectEnvironment, makeProjectRepository, makeProjectWithDetails } from './argocd-testing.utils' +import { ARGOCD_CONFIG } from './argocd.module-definition' import { ArgoCDService } from './argocd.service' describe('argoCDService', () => { @@ -17,28 +22,40 @@ describe('argoCDService', () => { let datastore: DeepMockProxy let gitlab: DeepMockProxy let vault: DeepMockProxy + let argocdConfig: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy beforeEach(async () => { datastore = mockDeep() gitlab = mockDeep() vault = mockDeep() - const config = mockDeep({ - argoNamespace: 'argocd', - argocdUrl: 'https://argocd.internal', - argocdExtraRepositories: 'repo3', + argocdConfig = mockDeep({ + namespace: 'argocd', + url: 'https://argocd.internal', + internalUrl: undefined, + extraRepositories: 'repo3', dsoEnvChartVersion: 'dso-env-1.6.0', dsoNsChartVersion: 'dso-ns-1.1.5', - projectRootDir: 'forge', - vaultUrl: 'https://vault.internal', - vaultKvName: 'kv', - deployVaultConnectionInNamespaces: false, + vaultDeployVaultConnectionInNs: false, + internalOrPublicUrl: undefined, + }) + baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep({ + url: 'https://vault.internal', + kvName: 'kv', + internalUrl: undefined, }) const module = await Test.createTestingModule({ providers: [ ArgoCDService, { provide: ArgoCDDatastoreService, useValue: datastore }, - { provide: ConfigurationService, useValue: config }, + { provide: ARGOCD_CONFIG, useValue: argocdConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, { provide: GitlabClientService, useValue: gitlab }, { provide: VaultClientService, useValue: vault }, ], diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index 7cd254fda4..cbfa862247 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -1,6 +1,9 @@ import type { CommitAction, CondensedProjectSchema, ProjectSchema, SimpleProjectSchema } from '@gitbeaker/core' +import type { BaseConfig } from '../infrastructure/config/base.config' import type { RequiredPluginResult } from '../plugin/plugin.utils' +import type { VaultConfig } from '../vault/vault.module-definition' import type { ProjectWithDetails } from './argocd-datastore.service' +import type { ArgocdConfig } from './argocd.module-definition' import { createHmac } from 'node:crypto' import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' import { Inject, Injectable, Logger } from '@nestjs/common' @@ -8,10 +11,11 @@ import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' import { stringify } from 'yaml' import { GitlabClientService } from '../gitlab/gitlab-client.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.module' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' import { ArgoCDDatastoreService } from './argocd-datastore.service' import { CONSOLE_ADMIN_GROUP_PATH, @@ -24,14 +28,17 @@ import { PROJECT_READONLY_GROUP_PATH_SUFFIX, PROJECT_SECURITY_GROUP_PATH_SUFFIX, } from './argocd.constants' +import { ARGOCD_CONFIG } from './argocd.module-definition' @Injectable() export class ArgoCDService { private readonly logger = new Logger(ArgoCDService.name) constructor( - @Inject(ArgoCDDatastoreService) private readonly argoCDDatastore: ArgoCDDatastoreService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(ArgoCDDatastoreService) private readonly datastore: ArgoCDDatastoreService, + @Inject(ARGOCD_CONFIG) private readonly argocdConfig: ArgocdConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, @Inject(VaultClientService) private readonly vault: VaultClientService, ) { @@ -70,7 +77,7 @@ export class ArgoCDService { @StartActiveSpan() async handleCron() { this.logger.log('Starting ArgoCD reconciliation') - const projects = await this.argoCDDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() const span = trace.getActiveSpan() span?.setAttribute('argocd.projects.count', projects.length) this.logger.log(`Loaded ${projects.length} projects for ArgoCD reconciliation`) @@ -247,13 +254,13 @@ export class ArgoCDService { environment, cluster, gitlabPublicProjectUrl, - argocdExtraRepositories: this.config.argocdExtraRepositories, + argocdExtraRepositories: this.argocdConfig.extraRepositories, infraProject, valueFilePath, vaultValues, - argoNamespace: this.config.argoNamespace, - envChartVersion: this.config.dsoEnvChartVersion, - nsChartVersion: this.config.dsoNsChartVersion, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, }) return this.gitlab.generateCreateOrUpdateAction( @@ -319,13 +326,13 @@ export class ArgoCDService { environment, cluster, gitlabPublicProjectUrl, - argocdExtraRepositories: this.config.argocdExtraRepositories, + argocdExtraRepositories: this.argocdConfig.extraRepositories, infraProject, valueFilePath, vaultValues, - argoNamespace: this.config.argoNamespace, - envChartVersion: this.config.dsoEnvChartVersion, - nsChartVersion: this.config.dsoNsChartVersion, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, deployments, }) @@ -348,9 +355,9 @@ export class ArgoCDService { return undefined }) return { - projectsRootDir: this.config.projectRootDir, - url: this.config.deployVaultConnectionInNamespaces ? this.config.vaultUrl : '', - coreKvName: this.config.vaultKvName, + projectsRootDir: this.baseConfig.projectsRootDir, + url: this.argocdConfig.vaultDeployVaultConnectionInNs ? this.vaultConfig.url : '', + coreKvName: this.vaultConfig.kvName, roleId: roleId ?? 'none', secretId: secretId ?? 'none', } diff --git a/apps/server-nestjs/src/modules/configurable-feature-module.ts b/apps/server-nestjs/src/modules/configurable-feature-module.ts new file mode 100644 index 0000000000..85b2418316 --- /dev/null +++ b/apps/server-nestjs/src/modules/configurable-feature-module.ts @@ -0,0 +1,78 @@ +import type { DynamicModule } from '@nestjs/common' +import { ConfigurableModuleBuilder } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' + +// Minimal shape of the generated configurable class, so consumers (e.g. `XModule.forRoot(...)`) +// see the `forRoot` static method. The arg is `TOptions` (plain object, for tests) OR a +// `ConfigFactory` (e.g. `registerAs('', ...)`), whose `.asProvider()` is wired by +// `forRoot` into this module's options token. +export interface ConfigurableCls { + new(...args: any[]): any + forRoot: (arg: TOptions | any) => DynamicModule +} + +// Builds a configurable module that OWNS its config contract (interface embedded in-module, +// no ../../config / registerAs dependency inside the module). The module is wired from the +// outside via `XModule.forRoot(xxxConfigFactory)`, where `xxxConfigFactory` is the +// `registerAs('', ...)` result from `config/*.config.ts`. `` MUST equal +// `token` so `ConfigModule.forFeature` registers it under `CONFIGURATION()`. +// `isGlobal` makes the module (and its config token) available app-wide via @Global(). +// ponytail: native forRoot only takes a plain options object; we wrap it so it also accepts +// a `ConfigFactory` and wires it (via ConfigModule.forFeature + a provider under the token). +function isConfigFactory(arg: unknown): boolean { + return typeof arg === 'function' && 'asProvider' in (arg as any) + && typeof (arg as any).asProvider === 'function' +} + +function isConfigProvider(arg: unknown): arg is { provide: unknown, useFactory?: unknown, useValue?: unknown } { + return Boolean(arg) && typeof arg === 'object' + && 'provide' in (arg as Record) + && ('useFactory' in (arg as Record) || 'useValue' in (arg as Record)) +} + +export function createConfigurableModule(token: string, isGlobal = false) { + const { ConfigurableModuleClass } = new ConfigurableModuleBuilder({ optionsInjectionToken: token }) + .setClassMethodName('forRoot') + .setFactoryMethodName('useFactory') + .setExtras( + { isGlobal }, + (definition: DynamicModule, extras) => ({ + ...definition, + exports: [...(definition.exports ?? []), token], + global: extras.isGlobal, + }), + ) + .build() + + class WrappedModuleClass extends (ConfigurableModuleClass as any) { + static forRoot(arg: any): DynamicModule { + const def = (super.forRoot as (o: unknown) => DynamicModule)({}) as DynamicModule + if (isConfigFactory(arg)) { + const provider = (arg as any).asProvider() + return { + ...def, + imports: [(ConfigModule as any).forFeature(arg), ...(def.imports ?? [])], + providers: [ + ...(def.providers ?? []), + { provide: token, useFactory: provider.useFactory, inject: provider.inject }, + ], + exports: [...(def.exports ?? []), token], + } + } + if (isConfigProvider(arg)) { + const provider = arg as any + return { + ...def, + providers: [...(def.providers ?? []), provider], + exports: [...(def.exports ?? []), provider.provide], + } + } + return (super.forRoot as (o: unknown) => DynamicModule)(arg) + } + } + + return { + ConfigurableModuleClass: WrappedModuleClass as unknown as ConfigurableCls, + MODULE_OPTIONS_TOKEN: token, + } +} diff --git a/apps/server-nestjs/src/modules/events/app-events.service.spec.ts b/apps/server-nestjs/src/modules/events/app-events.service.spec.ts index 3a5c955eee..7bd6fa2ea2 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.spec.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.spec.ts @@ -1,11 +1,12 @@ import type { EventEmitter2 as EventEmitter2Type } from '@nestjs/event-emitter' import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { LogService } from '../log/log.service' import { projectSelect } from '../project/project-queries.utils' @@ -18,13 +19,13 @@ describe('appEventsService', () => { let prisma: DeepMockProxy let eventEmitter: DeepMockProxy let logs: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { prisma = mockDeep() eventEmitter = mockDeep({ emitAsync: vi.fn().mockResolvedValue([]) }) logs = mockDeep() - config = mockDeep({ appVersion: 'test-version' }) + config = mockDeep({ appVersion: 'test-version' }) module = await Test.createTestingModule({ providers: [ @@ -32,7 +33,7 @@ describe('appEventsService', () => { { provide: PrismaService, useValue: prisma }, { provide: EventEmitter2, useValue: eventEmitter }, { provide: LogService, useValue: logs }, - { provide: ConfigurationService, useValue: config }, + { provide: BASE_CONFIG, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/events/app-events.service.ts b/apps/server-nestjs/src/modules/events/app-events.service.ts index 0c0aa94e03..7de8bcf562 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -1,8 +1,9 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' import type { PluginResults } from '../plugin/plugin.utils' import type { ProjectWithDetails } from '../project/project-queries.utils' import { Inject, Injectable, Logger } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { LogService } from '../log/log.service' import { getFailedPlugins, mergePluginResults } from '../plugin/plugin.utils' @@ -46,7 +47,7 @@ export class AppEventsService { @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, @Inject(LogService) private readonly logs: LogService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, ) {} /** @@ -130,7 +131,7 @@ export class AppEventsService { if (event === 'project.upsert') { await this.prisma.project.update({ where: { id: projectId }, - data: { status: 'created', lastSuccessProvisionningVersion: this.config.appVersion }, + data: { status: 'created', lastSuccessProvisionningVersion: this.baseConfig.appVersion }, }) } } diff --git a/apps/server-nestjs/src/modules/events/app-events.utils.ts b/apps/server-nestjs/src/modules/events/app-events.utils.ts index 48684cb16e..a9cf62706b 100644 --- a/apps/server-nestjs/src/modules/events/app-events.utils.ts +++ b/apps/server-nestjs/src/modules/events/app-events.utils.ts @@ -1,6 +1,6 @@ import type { LogData } from '../log/log.service' import type { PluginName, PluginResult, PluginResults } from '../plugin/plugin.utils' -import { getErrorHttpDetails } from '../../utils/http-error' +import { getErrorHttpDetails } from '../../utils/http.utils' import { getFailedPlugins } from '../plugin/plugin.utils' /** Per-service result as persisted in the admin logs (legacy hooks format, parsed by LogSchema). */ diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts index 3655fc64fe..0db65f4630 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts @@ -2,10 +2,10 @@ import type { ExpandedGroupSchema, Gitlab as GitlabApi, ProjectSchema } from '@g import type { TestingModule } from '@nestjs/testing' import type { MockedFunction } from 'vitest' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' import { makeAccessTokenExposedSchema, @@ -28,6 +28,7 @@ import { PROJECT_GROUP_CUSTOM_ATTRIBUTE_KEY, USER_ID_CUSTOM_ATTRIBUTE_KEY, } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' describe('gitlab-client', () => { let service: GitlabClientService @@ -35,20 +36,20 @@ describe('gitlab-client', () => { beforeEach(async () => { gitlabApi = mockDeep() - const config = mockDeep({ - gitlabUrl: 'https://gitlab.internal', - gitlabToken: 'token', - gitlabInternalUrl: 'https://gitlab.internal', + const config = mockDeep({ + url: 'https://gitlab.internal', + token: 'token', + internalUrl: 'https://gitlab.internal', projectRootDir: 'forge', - gitlabMirrorTokenExpirationDays: 30, - getInternalOrPublicGitlabUrl: () => 'https://gitlab.internal', + mirrorTokenExpirationDays: 30, + internalOrPublicUrl: 'https://gitlab.internal', }) const module: TestingModule = await Test.createTestingModule({ providers: [ GitlabClientService, { provide: GITLAB_REST_CLIENT, useValue: gitlabApi }, - { provide: ConfigurationService, useValue: config }, + { provide: GITLAB_CONFIG, useValue: config }, ], }).compile() service = module.get(GitlabClientService) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts index 53841d8d13..d1a755fc9a 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -14,11 +14,11 @@ import type { PipelineTriggerTokenSchema, SimpleUserSchema, } from '@gitbeaker/core' +import type { GitlabConfig } from './gitlab.module-definition' import { join } from 'node:path' import { GitbeakerRequestError } from '@gitbeaker/requester-utils' import { Inject, Injectable, Logger } from '@nestjs/common' -import { find } from '../../utils/iterable' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { find } from '../../utils/iterable.utils' import { GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, @@ -30,6 +30,7 @@ import { TOPIC_PLUGIN_MANAGED, USER_ID_CUSTOM_ATTRIBUTE_KEY, } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' import { generateGitlabCIConfigContent, generateMirrorScriptContent, hasFileContentChanged } from './gitlab.utils' export const GITLAB_REST_CLIENT = Symbol('GITLAB_REST_CLIENT') @@ -51,7 +52,7 @@ export class GitlabClientService { private readonly logger = new Logger(GitlabClientService.name) constructor( - @Inject(ConfigurationService) readonly config: ConfigurationService, + @Inject(GITLAB_CONFIG) readonly config: GitlabConfig, @Inject(GITLAB_REST_CLIENT) private readonly client: Gitlab, ) { } @@ -187,17 +188,17 @@ export class GitlabClientService { async getOrCreateProjectGroupPublicUrl(): Promise { const projectGroup = await this.getOrCreateProjectGroup() - return new URL(projectGroup.full_path, this.config.gitlabUrl).toString() + return new URL(projectGroup.full_path, this.config.url).toString() } async getOrCreateInfraGroupRepoPublicUrl(repoName: string): Promise { const projectGroup = await this.getOrCreateProjectGroup() - return new URL(`${projectGroup.full_path}/${INFRA_GROUP_PATH}/${repoName}.git`, this.config.gitlabUrl).toString() + return new URL(`${projectGroup.full_path}/${INFRA_GROUP_PATH}/${repoName}.git`, this.config.url).toString() } async getOrCreateProjectGroupInternalRepoUrl(subGroupPath: string, repoName: string): Promise { const projectGroup = await this.getOrCreateProjectSubGroup(subGroupPath) - const urlBase = this.config.getInternalOrPublicGitlabUrl() + const urlBase = this.config.internalOrPublicUrl if (!urlBase) throw new Error('GITLAB_URL is required') return `${urlBase}/${projectGroup.full_path}/${repoName}.git` } @@ -474,7 +475,7 @@ export class GitlabClientService { async createProjectToken(projectSlug: string, tokenName: string, scopes: AccessTokenScopes[]) { const group = await this.getProjectGroup(projectSlug) if (!group) throw new Error('Unable to retrieve gitlab project group') - const expirationDays = Number(this.config.gitlabMirrorTokenExpirationDays) + const expirationDays = Number(this.config.mirrorTokenExpirationDays) const effectiveExpirationDays = Number.isFinite(expirationDays) && expirationDays > 0 ? expirationDays : 30 const expiryDate = new Date(Date.now() + effectiveExpirationDays * 24 * 60 * 60 * 1000) this.logger.log(`Creating a GitLab group access token (projectSlug=${projectSlug}, tokenName=${tokenName}, expiry=${expiryDate.toISOString().slice(0, 10)})`) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts index fc50892690..0c4bdf8c41 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts @@ -1,22 +1,19 @@ +import type { GitlabConfig } from './gitlab.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { GITLAB_CONFIG } from './gitlab.module-definition' @Injectable() export class GitlabHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(GITLAB_CONFIG) private readonly gitlabConfig: GitlabConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - const urlBase = this.config.getInternalOrPublicGitlabUrl() - if (!urlBase) return indicator.down('Not configured') - - const url = new URL('/-/health', urlBase).toString() try { - const response = await fetch(url) + const response = await fetch(this.gitlabConfig.probeUrl) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts index 6bd62d87c2..7f4d85f694 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts @@ -1,25 +1,26 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { makeToUrlParams } from '../plugin/plugin.utils' import { GitlabPluginService } from './gitlab-plugin.service' +import { GITLAB_CONFIG } from './gitlab.module-definition' describe('gitlabPluginService', () => { let service: GitlabPluginService - let config: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { - config = mockDeep({ - gitlabUrl: 'https://gitlab.public', + config = mockDeep({ + url: 'https://gitlab.public', projectRootDir: 'forge', }) const moduleRef = await Test.createTestingModule({ providers: [ GitlabPluginService, - { provide: ConfigurationService, useValue: config }, + { provide: GITLAB_CONFIG, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts index 2e4d6f1424..3ce12a442c 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts @@ -1,23 +1,21 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { GitlabConfig } from './gitlab.module-definition' import { DISABLED, ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { DEFAULT_ADMIN_GROUP_PATH, DEFAULT_AUDITOR_GROUP_PATH, DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, PURGE_PLUGIN_KEY } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' @Injectable() export class GitlabPluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(GITLAB_CONFIG) + private readonly gitlabConfig: GitlabConfig, ) {} infos(): ServiceInfos { return { name: 'gitlab', - to: ({ project }) => { - if (!this.config.gitlabUrl || !this.config.projectRootDir) return undefined - return new URL(`${this.config.projectRootDir}/${project.slug}`, this.config.gitlabUrl).toString() - }, + to: ({ project }) => new URL(`${this.gitlabConfig.projectRootDir}/${project.slug}`, this.gitlabConfig.url).toString(), title: 'Gitlab', imgSrc: '/img/gitlab.svg', description: 'GitLab est un service d\'hébergement de code source et de pipeline CI/CD', diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts new file mode 100644 index 0000000000..3bb17e6b57 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.module-definition.ts @@ -0,0 +1,19 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface GitlabConfig { + token?: string + url: string + internalUrl: string + mirrorTokenExpirationDays: number + mirrorTokenRotationThresholdDays: number + projectRootDir: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface GitlabModuleOptions extends GitlabConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('gitlab') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as GITLAB_CONFIG } diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts index 22859472fb..f08dc6f312 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts @@ -1,25 +1,25 @@ +import type { GitlabConfig } from './gitlab.module-definition' import { Gitlab } from '@gitbeaker/rest' import { Module } from '@nestjs/common' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' import { GitlabDatastoreService } from './gitlab-datastore.service' import { GitlabHealthService } from './gitlab-health.service' import { GitlabPluginService } from './gitlab-plugin.service' +import { ConfigurableModuleClass, GITLAB_CONFIG } from './gitlab.module-definition' import { GitlabService } from './gitlab.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], + imports: [DatabaseModule, TerminusModule, VaultModule], providers: [ { provide: GITLAB_REST_CLIENT, - inject: [ConfigurationService], - useFactory: (config: ConfigurationService) => new Gitlab({ - token: config.gitlabToken, - host: config.getInternalOrPublicGitlabUrl(), + inject: [GITLAB_CONFIG], + useFactory: (config: GitlabConfig) => new Gitlab({ + token: config.token, + host: config.internalOrPublicUrl, }), }, GitlabClientService, @@ -30,4 +30,4 @@ import { GitlabService } from './gitlab.service' ], exports: [GitlabClientService, GitlabHealthService, GitlabPluginService, GitlabService], }) -export class GitlabModule {} +export class GitlabModule extends ConfigurableModuleClass {} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts index c069385b97..da46c5fe4f 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -1,16 +1,17 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { GitlabConfig } from './gitlab.module-definition' import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' import { AccessLevel } from '@gitbeaker/core' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { VaultClientService } from '../vault/vault-client.service' import { GitlabClientService } from './gitlab-client.service' import { GitlabDatastoreService } from './gitlab-datastore.service' import { makeAccessTokenExposedSchema, makeExpandedUserSchema, makeGroupSchema, makeMemberSchema, makePipelineTriggerToken, makeProjectSchema, makeProjectWithDetails } from './gitlab-testing.utils' import { PLUGIN_NAME, TOPIC_PLUGIN_MANAGED } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' import { GitlabService } from './gitlab.service' describe('gitlabService', () => { @@ -33,7 +34,7 @@ describe('gitlabService', () => { readTechnReadOnlyCreds: vi.fn().mockResolvedValue(null), readGitlabMirrorCreds: vi.fn().mockResolvedValue(null), }) - const config = mockDeep({ projectRootDir: 'forge' }) + const config = mockDeep({ projectRootDir: 'forge' }) const moduleRef = await Test.createTestingModule({ providers: [ @@ -41,7 +42,7 @@ describe('gitlabService', () => { { provide: GitlabClientService, useValue: gitlab }, { provide: GitlabDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: config }, + { provide: GITLAB_CONFIG, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts index d8fa3c85de..fd214154c1 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -2,13 +2,13 @@ import type { CondensedGroupSchema, MemberSchema, ProjectSchema } from '@gitbeak import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { VaultSecret } from '../vault/vault-client.service' import type { ProjectWithDetails } from './gitlab-datastore.service' +import type { GitlabConfig } from './gitlab.module-definition' import { specificallyEnabled } from '@cpn-console/hooks' import { AccessLevel } from '@gitbeaker/core' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { getAll } from '../../utils/iterable' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { getAll } from '../../utils/iterable.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -29,6 +29,7 @@ import { PROJECT_REPORTER_GROUP_PATH_SUFFIX_PLUGIN_KEY, PURGE_PLUGIN_KEY, } from './gitlab.constants' +import { GITLAB_CONFIG } from './gitlab.module-definition' import { adminRoleFlag, daysAgoFromNow, @@ -51,10 +52,10 @@ export class GitlabService { private readonly logger = new Logger(GitlabService.name) constructor( - @Inject(GitlabDatastoreService) private readonly gitlabDatastore: GitlabDatastoreService, + @Inject(GitlabDatastoreService) private readonly datastore: GitlabDatastoreService, @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, @Inject(VaultClientService) private readonly vault: VaultClientService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(GITLAB_CONFIG) private readonly gitlabConfig: GitlabConfig, ) { this.logger.log('GitLabService initialized') } @@ -93,7 +94,7 @@ export class GitlabService { const span = trace.getActiveSpan() span?.setAttribute('gitlab.projects.count', 0) this.logger.log('Starting GitLab reconciliation') - const projects = await this.gitlabDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() span?.setAttribute('gitlab.projects.count', projects.length) this.logger.log(`Loaded ${projects.length} projects for GitLab reconciliation`) await this.ensureProjectGroups(projects) @@ -220,7 +221,7 @@ export class GitlabService { private async getAdminRoleIds(project: ProjectWithDetails): Promise<{ adminRoleId?: string, auditorRoleId?: string }> { const adminGroupPath = await this.getAdminGroupPath(project) const auditorGroupPath = await this.getAuditorGroupPath(project) - const roles = await this.gitlabDatastore.getAdminRolesByOidcGroups([adminGroupPath, auditorGroupPath]) + const roles = await this.datastore.getAdminRolesByOidcGroups([adminGroupPath, auditorGroupPath]) return generateAdminRoleMapping(roles, adminGroupPath, auditorGroupPath) } @@ -233,7 +234,7 @@ export class GitlabService { } private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { - const adminPluginConfig = await this.gitlabDatastore.getAdminPluginConfig(PLUGIN_NAME, key) + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) if (adminPluginConfig) return adminPluginConfig if (!project) return undefined return getProjectPluginConfig(project, key) ?? undefined @@ -513,7 +514,7 @@ export class GitlabService { private isMirrorCredsExpiring(vaultSecret: VaultSecret): boolean { if (!vaultSecret?.metadata?.created_time) return false const createdTime = new Date(vaultSecret.metadata.created_time) - return daysAgoFromNow(createdTime) > this.config.gitlabMirrorTokenRotationThresholdDays + return daysAgoFromNow(createdTime) > this.gitlabConfig.mirrorTokenRotationThresholdDays } private getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { diff --git a/apps/server-nestjs/src/modules/healthz/healthz.controller.ts b/apps/server-nestjs/src/modules/healthz/healthz.controller.ts index b8d3e3a4ed..b18ec7aadd 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.controller.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.controller.ts @@ -1,57 +1,14 @@ -import { Controller, Get, Inject } from '@nestjs/common' -import { HealthCheck, HealthCheckService } from '@nestjs/terminus' -import { ArgoCDHealthService } from '../argocd/argocd-health.service' -import { GitlabHealthService } from '../gitlab/gitlab-health.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' -import { DatabaseHealthService } from '../infrastructure/database/database-health.service' -import { KeycloakHealthService } from '../keycloak/keycloak-health.service' -import { NexusHealthService } from '../nexus/nexus-health.service' -import { OpenCdsHealthService } from '../opencds/opencds-health.service' -import { RegistryHealthService } from '../registry/registry-health.service' -import { VaultHealthService } from '../vault/vault-health.service' +import type { HealthzService } from './healthz.service' +import { Controller, Get } from '@nestjs/common' +import { HealthCheck } from '@nestjs/terminus' @Controller('api/v1/healthz') export class HealthzController { - constructor( - @Inject(HealthCheckService) private readonly health: HealthCheckService, - @Inject(DatabaseHealthService) private readonly database: DatabaseHealthService, - @Inject(KeycloakHealthService) private readonly keycloak: KeycloakHealthService, - @Inject(GitlabHealthService) private readonly gitlab: GitlabHealthService, - @Inject(VaultHealthService) private readonly vault: VaultHealthService, - @Inject(NexusHealthService) private readonly nexus: NexusHealthService, - @Inject(RegistryHealthService) private readonly registry: RegistryHealthService, - @Inject(ArgoCDHealthService) private readonly argocd: ArgoCDHealthService, - @Inject(OpenCdsHealthService) private readonly opencds: OpenCdsHealthService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, - ) {} + constructor(private readonly healthz: HealthzService) {} @Get() @HealthCheck() check() { - const checks = [ - () => this.database.check('database'), - () => this.keycloak.check('keycloak'), - ] - - if (this.config.openCdsUrl) { - checks.push(() => this.opencds.check('opencds')) - } - if (this.config.gitlabUrl) { - checks.push(() => this.gitlab.check('gitlab')) - } - if (this.config.vaultUrl) { - checks.push(() => this.vault.check('vault')) - } - if (this.config.nexusUrl) { - checks.push(() => this.nexus.check('nexus')) - } - if (this.config.harborUrl) { - checks.push(() => this.registry.check('registry')) - } - if (this.config.argocdUrl) { - checks.push(() => this.argocd.check('argocd')) - } - - return this.health.check(checks) + return this.healthz.check() } } diff --git a/apps/server-nestjs/src/modules/healthz/healthz.module.ts b/apps/server-nestjs/src/modules/healthz/healthz.module.ts index 94b4281941..2b2cfaa615 100644 --- a/apps/server-nestjs/src/modules/healthz/healthz.module.ts +++ b/apps/server-nestjs/src/modules/healthz/healthz.module.ts @@ -1,8 +1,8 @@ import { Module } from '@nestjs/common' +import { ConditionalModule, ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' import { ArgoCDModule } from '../argocd/argocd.module' import { GitlabModule } from '../gitlab/gitlab.module' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { KeycloakModule } from '../keycloak/keycloak.module' import { NexusModule } from '../nexus/nexus.module' @@ -10,20 +10,22 @@ import { OpenCdsModule } from '../opencds/opencds.module' import { RegistryModule } from '../registry/registry.module' import { VaultModule } from '../vault/vault.module' import { HealthzController } from './healthz.controller' +import { HealthzService } from './healthz.service' @Module({ imports: [ - TerminusModule, + TerminusModule.forRoot(), + ConfigModule.forRoot(), DatabaseModule, KeycloakModule, - GitlabModule, + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), VaultModule, - NexusModule, - RegistryModule, - ArgoCDModule, - ConfigurationModule, - OpenCdsModule, + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(RegistryModule, 'USE_REGISTRY'), + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(OpenCdsModule, 'USE_OPENCDS'), ], controllers: [HealthzController], + providers: [HealthzService], }) export class HealthzModule {} diff --git a/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts b/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts new file mode 100644 index 0000000000..288e80d353 --- /dev/null +++ b/apps/server-nestjs/src/modules/healthz/healthz.service.spec.ts @@ -0,0 +1,106 @@ +import { HealthCheckService, TerminusModule } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { OpenCdsHealthService } from '../opencds/opencds-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { VaultHealthService } from '../vault/vault-health.service' +import { HealthzService } from './healthz.service' + +describe('healthzService', () => { + afterEach(() => vi.clearAllMocks()) + + describe('with all optional probes registered', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let keycloak: ReturnType> + let gitlab: ReturnType> + let vault: ReturnType> + let nexus: ReturnType> + let registry: ReturnType> + let argocd: ReturnType> + let opencds: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + keycloak = mockDeep() + gitlab = mockDeep() + vault = mockDeep() + nexus = mockDeep() + registry = mockDeep() + argocd = mockDeep() + opencds = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + { provide: KeycloakHealthService, useValue: keycloak }, + { provide: GitlabHealthService, useValue: gitlab }, + { provide: VaultHealthService, useValue: vault }, + { provide: NexusHealthService, useValue: nexus }, + { provide: RegistryHealthService, useValue: registry }, + { provide: ArgoCDHealthService, useValue: argocd }, + { provide: OpenCdsHealthService, useValue: opencds }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('checks every injected probe, including those wired via @Optional', async () => { + await service.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(keycloak.check).toHaveBeenCalledWith('keycloak') + expect(gitlab.check).toHaveBeenCalledWith('gitlab') + expect(vault.check).toHaveBeenCalledWith('vault') + expect(nexus.check).toHaveBeenCalledWith('nexus') + expect(registry.check).toHaveBeenCalledWith('registry') + expect(argocd.check).toHaveBeenCalledWith('argocd') + expect(opencds.check).toHaveBeenCalledWith('opencds') + }) + }) + + describe('without optional probes registered (@Optional -> undefined)', () => { + let healthCheck: ReturnType> + let database: ReturnType> + let service: HealthzService + + beforeEach(async () => { + healthCheck = mockDeep() + healthCheck.check.mockImplementation(async (checks) => { + await Promise.all(checks.map(c => c())) + return { status: 'ok', details: {} } + }) + database = mockDeep() + const moduleRef = await Test.createTestingModule({ + imports: [TerminusModule.forRoot()], + providers: [ + HealthzService, + { provide: HealthCheckService, useValue: healthCheck }, + { provide: DatabaseHealthService, useValue: database }, + ], + }).compile() + service = moduleRef.get(HealthzService) + }) + + it('omits probes whose services are not registered', async () => { + await service.check() + + expect(database.check).toHaveBeenCalledWith('database') + expect(healthCheck.check).toHaveBeenCalledWith([expect.any(Function)]) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/healthz/healthz.service.ts b/apps/server-nestjs/src/modules/healthz/healthz.service.ts new file mode 100644 index 0000000000..67619844e0 --- /dev/null +++ b/apps/server-nestjs/src/modules/healthz/healthz.service.ts @@ -0,0 +1,41 @@ +import type { HealthIndicatorFunction } from '@nestjs/terminus' +import { Inject, Injectable, Optional } from '@nestjs/common' +import { HealthCheckService } from '@nestjs/terminus' +import { ArgoCDHealthService } from '../argocd/argocd-health.service' +import { GitlabHealthService } from '../gitlab/gitlab-health.service' +import { DatabaseHealthService } from '../infrastructure/database/database-health.service' +import { KeycloakHealthService } from '../keycloak/keycloak-health.service' +import { NexusHealthService } from '../nexus/nexus-health.service' +import { OpenCdsHealthService } from '../opencds/opencds-health.service' +import { RegistryHealthService } from '../registry/registry-health.service' +import { VaultHealthService } from '../vault/vault-health.service' + +@Injectable() +export class HealthzService { + // Optional modules (gated by USE_*) resolve to undefined when not registered. + constructor( + @Inject(HealthCheckService) private readonly health: HealthCheckService, + @Inject(DatabaseHealthService) @Optional() private readonly database?: DatabaseHealthService, + @Inject(KeycloakHealthService) @Optional() private readonly keycloak?: KeycloakHealthService, + @Inject(GitlabHealthService) @Optional() private readonly gitlab?: GitlabHealthService, + @Inject(VaultHealthService) @Optional() private readonly vault?: VaultHealthService, + @Inject(NexusHealthService) @Optional() private readonly nexus?: NexusHealthService, + @Inject(RegistryHealthService) @Optional() private readonly registry?: RegistryHealthService, + @Inject(ArgoCDHealthService) @Optional() private readonly argocd?: ArgoCDHealthService, + @Inject(OpenCdsHealthService) @Optional() private readonly opencds?: OpenCdsHealthService, + ) {} + + check() { + const checks: HealthIndicatorFunction[] = [] + // Each health service's own check() reports 'Not configured' when its URL is absent. + if (this.database) checks.push(() => this.database!.check('database')) + if (this.keycloak) checks.push(() => this.keycloak!.check('keycloak')) + if (this.gitlab) checks.push(() => this.gitlab!.check('gitlab')) + if (this.vault) checks.push(() => this.vault!.check('vault')) + if (this.nexus) checks.push(() => this.nexus!.check('nexus')) + if (this.registry) checks.push(() => this.registry!.check('registry')) + if (this.argocd) checks.push(() => this.argocd!.check('argocd')) + if (this.opencds) checks.push(() => this.opencds!.check('opencds')) + return this.health.check(checks) + } +} diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts b/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts index b0bbbe468e..23f7129d2e 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../config/keycloak.config' import { AuthService } from './auth.service' import { DsoTokenModule } from './dso-token/dso-token.module' import { KeycloakJwtModule } from './keycloak-jwt/keycloak-jwt.module' @Module({ - imports: [DsoTokenModule, KeycloakJwtModule], + imports: [ConfigModule.forFeature(keycloakConfigFactory), DsoTokenModule, KeycloakJwtModule], providers: [ AuthService, ], diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts index 985a16aa0f..6c7851d92d 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common' import { JwtModule } from '@nestjs/jwt' -import { ConfigurationModule } from '../../configuration/configuration.module' import { DatabaseModule } from '../../database/database.module' import { KeycloakSecretProviderModule } from '../keycloak-secret-provider/keycloak-secret-provider.module' import { KeycloakSecretProviderService } from '../keycloak-secret-provider/keycloak-secret-provider.service' @@ -10,7 +9,7 @@ import { KeycloakJwtService } from './keycloak-jwt.service' imports: [ DatabaseModule, JwtModule.registerAsync({ - imports: [ConfigurationModule, KeycloakSecretProviderModule], + imports: [KeycloakSecretProviderModule], inject: [KeycloakSecretProviderService], useFactory: async (client: KeycloakSecretProviderService) => { // The issuer is fetched from the openid-configuration endpoint diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts index bdc7422816..602419cdcf 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts @@ -1,10 +1,14 @@ import { CacheModule } from '@nestjs/cache-manager' import { Module } from '@nestjs/common' -import { ConfigurationModule } from '../../configuration/configuration.module' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../../config/keycloak.config' import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' @Module({ - imports: [ConfigurationModule, CacheModule.register()], + imports: [ + CacheModule.register(), + ConfigModule.forFeature(keycloakConfigFactory), + ], providers: [KeycloakSecretProviderService], exports: [KeycloakSecretProviderService], }) diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts index b55dbdada3..9d7e632122 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts @@ -1,5 +1,6 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { KeycloakConfig } from '../../../keycloak/keycloak.module-definition' import { faker } from '@faker-js/faker' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { JwtSecretRequestType } from '@nestjs/jwt' @@ -7,7 +8,7 @@ import { Test } from '@nestjs/testing' import { createCache } from 'cache-manager' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../../configuration/configuration.service' +import { KEYCLOAK_CONFIG } from '../../../keycloak/keycloak.module-definition' import { makeJwksResponse } from './keycloak-secret-provider-testing.utils' import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' import { createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' @@ -15,22 +16,23 @@ import { createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret describe('keycloakSecretProviderService', () => { let module: TestingModule let service: KeycloakSecretProviderService - let config: DeepMockProxy + let config: DeepMockProxy let fetchMock: ReturnType let cache: ReturnType beforeEach(async () => { - config = mockDeep({ - keycloakProtocol: 'https', - keycloakDomain: faker.internet.domainName(), - keycloakRealm: faker.lorem.word(), - keycloakJwksTimeoutMs: 1_000, - keycloakJwksCacheTtlMs: 300_000, - keycloakOpenidConfigurationCacheTtlMs: 300_000, - - getKeycloakOpenidConfigurationUrl() { - return `https://${this.keycloakDomain}/realms/${this.keycloakRealm}/.well-known/openid-configuration` - }, + const keycloakDomain = faker.internet.domainName() + const keycloakRealm = faker.lorem.word() + const openidConfigurationUrl = `https://${keycloakDomain}/realms/${keycloakRealm}/.well-known/openid-configuration` + + config = mockDeep({ + protocol: 'https', + domain: keycloakDomain, + realm: keycloakRealm, + openidConfigurationUrl, + jwksTimeoutMs: 1_000, + jwksCacheTtlMs: 300_000, + openidConfigurationCacheTtlMs: 300_000, }) fetchMock = vi.fn() cache = createCache() @@ -42,7 +44,7 @@ describe('keycloakSecretProviderService', () => { module = await Test.createTestingModule({ providers: [ KeycloakSecretProviderService, - { provide: ConfigurationService, useValue: config }, + { provide: KEYCLOAK_CONFIG, useValue: config }, { provide: CACHE_MANAGER, useValue: cache }, ], }).compile() @@ -55,18 +57,16 @@ describe('keycloakSecretProviderService', () => { }) it('should fetch JWKS from Keycloak and parse the response', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` - const internalJwksUri = `${config.keycloakProtocol}://${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + const internalJwksUri = `${config.protocol}://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-1')) const jwks = await service.fetchSigningKeys() expect(fetchMock).toHaveBeenCalledTimes(2) - expect(fetchMock.mock.calls[0]?.[0]).toBe( - `https://${config.keycloakDomain}/realms/${config.keycloakRealm}/.well-known/openid-configuration`, - ) + expect(fetchMock.mock.calls[0]?.[0]).toBe(config.openidConfigurationUrl) expect(fetchMock.mock.calls[1]?.[0]).toBe(internalJwksUri) expect(jwks).toEqual({ keys: [ @@ -83,8 +83,8 @@ describe('keycloakSecretProviderService', () => { it('should abort and return undefined when the JWKS request exceeds the timeout', async () => { vi.useFakeTimers() - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockImplementationOnce((_url, init?: RequestInit) => new Promise((_, reject) => { init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) @@ -97,8 +97,8 @@ describe('keycloakSecretProviderService', () => { }) it('should return undefined when Keycloak returns a non-OK response', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(new Response('', { status: 500, statusText: 'Internal Server Error' })) @@ -106,8 +106,8 @@ describe('keycloakSecretProviderService', () => { }) it('should resolve a PEM public key from the JWKS', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-2')) @@ -131,8 +131,8 @@ describe('keycloakSecretProviderService', () => { }) it('should resolve the secret directly from the JWT token and request type', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-3')) const header = Buffer.from(JSON.stringify({ kid: 'kid-3' })).toString('base64url') @@ -166,8 +166,8 @@ describe('keycloakSecretProviderService', () => { }) it('should reject JWTs when the key cannot be resolved', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ keys: [] }))) const header = Buffer.from(JSON.stringify({ kid: 'missing-kid' })).toString('base64url') @@ -178,8 +178,8 @@ describe('keycloakSecretProviderService', () => { }) it('should resolve the issuer from openid-configuration', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) await expect(service.fetchIssuer()).resolves.toBe(issuer) @@ -187,20 +187,20 @@ describe('keycloakSecretProviderService', () => { }) it('should replace the discovered JWKS domain with the configured internal Keycloak domain', async () => { - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ - issuer: `https://${config.keycloakDomain}/realms/${config.keycloakRealm}`, + issuer: `https://${config.domain}/realms/${config.realm}`, jwks_uri: publicJwksUri, }))) await expect(service.fetchJwksUri()).resolves.toBe( - `https://${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs`, + `https://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs`, ) }) it('should keep the discovered JWKS URI unchanged when no internal Keycloak domain is configured', async () => { - config.keycloakDomain = undefined - const publicJwksUri = `https://public.example.test/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + config.domain = undefined + const publicJwksUri = `https://public.example.test/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer: 'https://public.example.test/realms/test', jwks_uri: publicJwksUri, diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts index f089d4a9a3..69a19bb5fa 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts @@ -1,10 +1,11 @@ import type { Cache } from 'cache-manager' +import type { KeycloakConfig } from '../../../keycloak/keycloak.module-definition' import { createPublicKey } from 'node:crypto' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Inject, Injectable, Logger } from '@nestjs/common' import { JwtSecretRequestType } from '@nestjs/jwt' import { z } from 'zod' -import { ConfigurationService } from '../../configuration/configuration.service' +import { KEYCLOAK_CONFIG } from '../../../keycloak/keycloak.module-definition' import { createKeycloakSecretProviderOpenIdConfigurationCacheKey, createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' const OpenidConfigurationSchema = z.object({ @@ -35,16 +36,16 @@ export class KeycloakSecretProviderService { private readonly logger = new Logger(KeycloakSecretProviderService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, @Inject(CACHE_MANAGER) private readonly cache: Cache, + @Inject(KEYCLOAK_CONFIG) private readonly keycloakConfig: KeycloakConfig, ) {} async fetchOpenIdConfig(): Promise { - const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(this.config.getKeycloakOpenidConfigurationUrl()) + const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(this.keycloakConfig.openidConfigurationUrl) const cached = await this.cache.get(cacheKey) if (cached) return cached - const response = await fetch(this.config.getKeycloakOpenidConfigurationUrl()) + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) if (!response.ok) { this.logger.error(`Failed to fetch openid-configuration: ${response.status} ${response.statusText}`) return undefined @@ -57,7 +58,7 @@ export class KeycloakSecretProviderService { return undefined } - await this.cache.set(cacheKey, config.data, this.config.keycloakOpenidConfigurationCacheTtlMs) + await this.cache.set(cacheKey, config.data, this.keycloakConfig.openidConfigurationCacheTtlMs) return config.data } @@ -72,13 +73,10 @@ export class KeycloakSecretProviderService { } private replaceJwksUriDomainWithInternalDomain(jwksUri: string): string { - if (!this.config.keycloakDomain) { - this.logger.log(`No internal domain configured, returning original JWKS URI: ${jwksUri}`) - return jwksUri - } + if (!this.keycloakConfig.domain) return jwksUri const url = new URL(jwksUri) - url.protocol = this.config.keycloakProtocol ?? url.protocol - url.host = this.config.keycloakDomain ?? url.host + url.protocol = this.keycloakConfig.protocol + url.host = this.keycloakConfig.domain this.logger.log(`Replacing JWKS URI domain: ${jwksUri} -> ${url.toString()}`) return url.toString() } @@ -88,7 +86,7 @@ export class KeycloakSecretProviderService { if (!jwksUri) return undefined const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), this.config.keycloakJwksTimeoutMs) + const timeout = setTimeout(() => controller.abort(), this.keycloakConfig.jwksTimeoutMs) try { const response = await fetch(jwksUri, { signal: controller.signal }) @@ -124,7 +122,7 @@ export class KeycloakSecretProviderService { }) const pem = publicKey.export({ format: 'pem', type: 'pkcs1' }) as string - await this.cache.set(cacheKey, pem, this.config.keycloakJwksCacheTtlMs) + await this.cache.set(cacheKey, pem, this.keycloakConfig.jwksCacheTtlMs) return pem } diff --git a/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts b/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts new file mode 100644 index 0000000000..d1853a3219 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/config/base.config.ts @@ -0,0 +1,27 @@ +export interface BaseConfig { + nodeEnv: 'development' | 'production' | 'test' + isTest: boolean + isDev: boolean + isCI: boolean + isProd: boolean + integration: boolean + ci: boolean + devSetup: boolean + docker: boolean + serverHost: string + serverPort: number + appVersion: string + dbUrl?: string + sessionSecret?: string + contactEmail: string + mockPlugins: boolean + projectsRootDir?: string + pluginsDir: string + httpProxy?: string + httpsProxy?: string +} + +// Runtime config (BASE_CONFIG token) is owned by the global config/base.config.ts +// (which calls registerAs('base', ...)); re-exported here so services keep a single +// stable import surface (the module) while config depends on the module. +export { BASE_CONFIG } from './base.module' diff --git a/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts b/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts new file mode 100644 index 0000000000..5ee8150bd8 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/config/base.module.ts @@ -0,0 +1,13 @@ +import type { BaseConfig } from './base.config' +import { Global, Module } from '@nestjs/common' +import { createConfigurableModule } from '../../configurable-feature-module' + +export interface BaseModuleOptions extends BaseConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('base', true) + +@Global() +@Module({}) +export class BaseModule extends ConfigurableModuleClass {} + +export const BASE_CONFIG = MODULE_OPTIONS_TOKEN diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.module.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.module.ts deleted file mode 100644 index b563056136..0000000000 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.module.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Module } from '@nestjs/common' -import { ConfigModule } from '@nestjs/config' - -import { ConfigurationService } from './configuration.service' - -const pathList: string[] = [] - -if (process.env.DOCKER !== 'true') { - pathList.push('.env') -} - -if (process.env.INTEGRATION === 'true') { - pathList.push('.env.integ') -} - -@Module({ - imports: [ - ConfigModule.forRoot({ - envFilePath: pathList, - }), - ], - providers: [ConfigurationService], - exports: [ConfigurationService], -}) -export class ConfigurationModule {} diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.spec.ts deleted file mode 100644 index a7c4c7a245..0000000000 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.spec.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { TestingModule } from '@nestjs/testing' -import { Test } from '@nestjs/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { ConfigurationService } from './configuration.service' - -describe('configurationService', () => { - let service: ConfigurationService - - beforeEach(() => { - vi.clearAllMocks() - vi.unstubAllEnvs() - - // KEYCLOAK_PUBLIC_PROTOCOL and KEYCLOAK_PUBLIC_DOMAIN are intentionally absent for these tests - vi.stubEnv('KEYCLOAK_PROTOCOL', 'http') - vi.stubEnv('KEYCLOAK_DOMAIN', 'keycloak.example.com') - vi.stubEnv('KEYCLOAK_REALM', 'cloud-pi-native') - }) - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile() - - service = module.get(ConfigurationService) - }) - - afterEach(() => { - vi.unstubAllEnvs() - vi.restoreAllMocks() - }) - - it('should be defined', () => { - expect(service).toBeDefined() - }) - - describe('keycloak URL derivation', () => { - it('should derive the internal URL from protocol + domain', () => { - expect(service.getKeycloakUrl()).toBe('http://keycloak.example.com') - }) - - it('should derive the realm URL from the internal URL', () => { - expect(service.getKeycloakRealmUrl()).toBe( - 'http://keycloak.example.com/realms/cloud-pi-native', - ) - }) - - it('should derive the openid-configuration URL from the realm URL', () => { - expect(service.getKeycloakOpenidConfigurationUrl()).toBe( - 'http://keycloak.example.com/realms/cloud-pi-native/.well-known/openid-configuration', - ) - }) - - it('should throw when Keycloak protocol or domain is missing', () => { - service.keycloakProtocol = '' - service.keycloakDomain = 'keycloak.example.com' - expect(() => service.getKeycloakUrl()).toThrow( - 'Keycloak protocol or domain is not configured.', - ) - expect(() => service.getKeycloakRealmUrl()).toThrow( - 'Keycloak protocol or domain is not configured.', - ) - - service.keycloakProtocol = 'http' - service.keycloakDomain = '' - expect(() => service.getKeycloakUrl()).toThrow( - 'Keycloak protocol or domain is not configured.', - ) - expect(() => service.getKeycloakRealmUrl()).toThrow( - 'Keycloak protocol or domain is not configured.', - ) - }) - }) - - describe('internal-or-public URL helpers', () => { - it('should prefer internal over public URL for GitLab, Vault, Harbor, Nexus, SonarQube', async () => { - vi.stubEnv('GITLAB_URL', 'https://gitlab.public') - vi.stubEnv('VAULT_URL', 'https://vault.public') - vi.stubEnv('HARBOR_URL', 'https://harbor.public') - vi.stubEnv('NEXUS_URL', 'https://nexus.public') - vi.stubEnv('SONARQUBE_URL', 'https://sonar.public') - vi.stubEnv('GITLAB_INTERNAL_URL', 'https://gitlab.internal') - vi.stubEnv('VAULT_INTERNAL_URL', 'https://vault.internal') - vi.stubEnv('HARBOR_INTERNAL_URL', 'https://harbor.internal') - vi.stubEnv('NEXUS_INTERNAL_URL', 'https://nexus.internal') - vi.stubEnv('SONARQUBE_INTERNAL_URL', 'https://sonar.internal') - - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - - expect(testService.getInternalOrPublicGitlabUrl()).toBe('https://gitlab.internal') - expect(testService.getInternalOrPublicVaultUrl()).toBe('https://vault.internal') - expect(testService.getInternalOrPublicHarborUrl()).toBe('https://harbor.internal') - expect(testService.getInternalOrPublicNexusUrl()).toBe('https://nexus.internal') - expect(testService.getInternalOrPublicSonarqubeUrl()).toBe('https://sonar.internal') - }) - - it('should fall back to public URL for GitLab, Vault, Harbor, Nexus, SonarQube when internal is unset', async () => { - vi.stubEnv('GITLAB_URL', 'https://gitlab.public') - vi.stubEnv('GITLAB_INTERNAL_URL', '') - vi.stubEnv('VAULT_URL', 'https://vault.public') - vi.stubEnv('VAULT_INTERNAL_URL', '') - vi.stubEnv('HARBOR_URL', 'https://harbor.public') - vi.stubEnv('HARBOR_INTERNAL_URL', '') - vi.stubEnv('NEXUS_URL', 'https://nexus.public') - vi.stubEnv('NEXUS_INTERNAL_URL', '') - vi.stubEnv('SONARQUBE_URL', 'https://sonar.public') - vi.stubEnv('SONARQUBE_INTERNAL_URL', '') - - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - - expect(testService.getInternalOrPublicGitlabUrl()).toBe('https://gitlab.public') - expect(testService.getInternalOrPublicVaultUrl()).toBe('https://vault.public') - expect(testService.getInternalOrPublicHarborUrl()).toBe('https://harbor.public') - expect(testService.getInternalOrPublicNexusUrl()).toBe('https://nexus.public') - expect(testService.getInternalOrPublicSonarqubeUrl()).toBe('https://sonar.public') - }) - - it('should return undefined for internal-or-public URL when neither side is configured', async () => { - vi.stubEnv('GITLAB_URL', '') - vi.stubEnv('GITLAB_INTERNAL_URL', '') - vi.stubEnv('VAULT_URL', '') - vi.stubEnv('VAULT_INTERNAL_URL', '') - vi.stubEnv('HARBOR_URL', '') - vi.stubEnv('HARBOR_INTERNAL_URL', '') - vi.stubEnv('NEXUS_URL', '') - vi.stubEnv('NEXUS_INTERNAL_URL', '') - vi.stubEnv('SONARQUBE_URL', '') - vi.stubEnv('SONARQUBE_INTERNAL_URL', '') - - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - - expect(testService.getInternalOrPublicGitlabUrl()).toBeUndefined() - expect(testService.getInternalOrPublicVaultUrl()).toBeUndefined() - expect(testService.getInternalOrPublicHarborUrl()).toBeUndefined() - expect(testService.getInternalOrPublicNexusUrl()).toBeUndefined() - expect(testService.getInternalOrPublicSonarqubeUrl()).toBeUndefined() - }) - }) - - describe('conditional toggles and computed fields', () => { - it('should default NODE_ENV to production and map explicit test/development values', async () => { - vi.stubEnv('NODE_ENV', '') - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(testService.NODE_ENV).toBe('production') - }) - - it('should map NODE_ENV=test to "test" and NODE_ENV=development to "development"', async () => { - vi.stubEnv('NODE_ENV', 'test') - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(testService.NODE_ENV).toBe('test') - - vi.stubEnv('NODE_ENV', 'development') - const devService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(devService.NODE_ENV).toBe('development') - }) - - it('should expose the requested app version in production, else "dev"', async () => { - vi.stubEnv('NODE_ENV', 'production') - vi.stubEnv('APP_VERSION', '1.2.3') - const prod = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(prod.appVersion).toBe('1.2.3') - - vi.unstubAllEnvs() - vi.stubEnv('NODE_ENV', 'production') - const prodUnset = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(prodUnset.appVersion).toBe('unknown') - - vi.unstubAllEnvs() - vi.stubEnv('NODE_ENV', 'development') - vi.stubEnv('APP_VERSION', '1.2.3') - const dev = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(dev.appVersion).toBe('dev') - }) - - it('should expose nexusSecretExposedUrl based on the internal-url toggle', async () => { - vi.stubEnv('NEXUS_URL', 'https://nexus.public') - const off = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(off.nexusSecretExposedUrl).toBe('https://nexus.public') - - vi.stubEnv('NEXUS__SECRET_EXPOSE_INTERNAL_URL', 'true') - vi.stubEnv('NEXUS_INTERNAL_URL', 'https://nexus.internal') - const on = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(on.nexusSecretExposedUrl).toBe('https://nexus.internal') - }) - - it('should disable TLS verification for Open CDS only when explicitly set to false', async () => { - vi.stubEnv('OPENCDS_API_TLS_REJECT_UNAUTHORIZED', '') - - const defaultService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(defaultService.openCdsApiTlsRejectUnauthorized).toBe(true) - - vi.stubEnv('OPENCDS_API_TLS_REJECT_UNAUTHORIZED', 'false') - const disabled = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(disabled.openCdsApiTlsRejectUnauthorized).toBe(false) - }) - }) -}) diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts deleted file mode 100644 index 67d925fc11..0000000000 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common' - -@Injectable() -export class ConfigurationService { - private readonly logger = new Logger(ConfigurationService.name) - - // application mode - isDev = process.env.NODE_ENV === 'development' - isTest = process.env.NODE_ENV === 'test' - isProd = process.env.NODE_ENV === 'production' - isInt = process.env.INTEGRATION === 'true' - isCI = process.env.CI === 'true' - isDevSetup = process.env.DEV_SETUP === 'true' - - // app - host = process.env.SERVER_HOST ?? 'localhost' - port = process.env.SERVER_PORT ? Number(process.env.SERVER_PORT) : 0 // dynamically allocate an available ephemeral port - appVersion = this.isProd ? (process.env.APP_VERSION ?? 'unknown') : 'dev' - - // db - dbUrl = process.env.DB_URL - - // keycloak - sessionSecret = process.env.SESSION_SECRET - keycloakProtocol = process.env.KEYCLOAK_PROTOCOL - keycloakDomain = process.env.KEYCLOAK_DOMAIN - keycloakPublicProtocol = process.env.KEYCLOAK_PUBLIC_PROTOCOL - keycloakPublicDomain = process.env.KEYCLOAK_PUBLIC_DOMAIN - keycloakRealm = process.env.KEYCLOAK_REALM - keycloakClientId = process.env.KEYCLOAK_CLIENT_ID - keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET - keycloakAdmin = process.env.KEYCLOAK_ADMIN - keycloakAdminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD - keycloakAdminClientId = process.env.KEYCLOAK_ADMIN_CLIENT_ID ?? 'admin-cli' - keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI - - // JWKS cache TTL in ms (default 5 min); Keycloak rotates keys periodically - keycloakJwksCacheTtlMs = Number(process.env.KEYCLOAK_JWKS_CACHE_TTL_MS ?? 300_000) - // JWKS fetch timeout in ms (default 5 s); avoids hanging on cache misses - keycloakJwksTimeoutMs = Number(process.env.KEYCLOAK_JWKS_TIMEOUT_MS ?? 5_000) - // openid-configuration cache TTL in ms (default 5 min); avoid repeated discovery lookups - keycloakOpenidConfigurationCacheTtlMs = Number(process.env.KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS ?? 300_000) - - adminsUserId = process.env.ADMIN_KC_USER_ID - ? process.env.ADMIN_KC_USER_ID.split(',') - : [] - - contactEmail - = process.env.CONTACT_EMAIL - ?? 'cloudpinative-relations@interieur.gouv.fr' - - // argocd - argoNamespace = process.env.ARGO_NAMESPACE ?? 'argocd' - argocdUrl = process.env.ARGOCD_URL - argocdInternalUrl = process.env.ARGOCD_INTERNAL_URL - argocdExtraRepositories = process.env.ARGOCD_EXTRA_REPOSITORIES - - // dso - dsoEnvChartVersion = process.env.DSO_ENV_CHART_VERSION ?? 'dso-env-1.6.0' - dsoNsChartVersion = process.env.DSO_NS_CHART_VERSION ?? 'dso-ns-1.1.5' - - // opencds - openCdsUrl = process.env.OPENCDS_URL - openCdsApiToken = process.env.OPENCDS_API_TOKEN - openCdsApiTlsRejectUnauthorized = process.env.OPENCDS_API_TLS_REJECT_UNAUTHORIZED !== 'false' - - // plugins - mockPlugins = process.env.MOCK_PLUGINS === 'true' - projectRootDir = process.env.PROJECTS_ROOT_DIR - pluginsDir = process.env.PLUGINS_DIR ?? '/plugins' - - // gitlab - gitlabToken = process.env.GITLAB_TOKEN - gitlabUrl = process.env.GITLAB_URL - gitlabInternalUrl = process.env.GITLAB_INTERNAL_URL - - gitlabMirrorTokenExpirationDays = Number(process.env.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS ?? 180) - gitlabMirrorTokenRotationThresholdDays = Number(process.env.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS ?? 90) - - // vault - vaultToken = process.env.VAULT_TOKEN - vaultUrl = process.env.VAULT_URL - vaultInternalUrl = process.env.VAULT_INTERNAL_URL - - vaultKvName = process.env.VAULT_KV_NAME ?? 'forge-dso' - deployVaultConnectionInNamespaces = process.env.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS === 'true' - - // registry (harbor) - harborUrl = process.env.HARBOR_URL - harborInternalUrl = process.env.HARBOR_INTERNAL_URL - harborAdmin = process.env.HARBOR_ADMIN - harborAdminPassword = process.env.HARBOR_ADMIN_PASSWORD - harborRuleTemplate = process.env.HARBOR_RULE_TEMPLATE - harborRuleCount = process.env.HARBOR_RULE_COUNT - harborRetentionCron = process.env.HARBOR_RETENTION_CRON ?? '0 22 2 * * *' - harborRobotRotationThresholdDays = Number(process.env.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS ?? 90) - harborProjectSlugCacheTtlMs = Number(process.env.HARBOR_PROJECT_SLUG_CACHE_TTL_MS ?? 300_000) - - // nexus - nexusUrl = process.env.NEXUS_URL - nexusInternalUrl = process.env.NEXUS_INTERNAL_URL - nexusAdmin = process.env.NEXUS_ADMIN - nexusAdminPassword = process.env.NEXUS_ADMIN_PASSWORD - nexusSecretExposedUrl - = process.env.NEXUS__SECRET_EXPOSE_INTERNAL_URL === 'true' - ? process.env.NEXUS_INTERNAL_URL - : process.env.NEXUS_URL - - // sonarqube - sonarqubeUrl = process.env.SONARQUBE_URL - sonarqubeInternalUrl = process.env.SONARQUBE_INTERNAL_URL - sonarApiToken = process.env.SONAR_API_TOKEN - - getKeycloakRealmUrl() { - return `${this.getKeycloakUrl()}/realms/${this.keycloakRealm}` - } - - getKeycloakOpenidConfigurationUrl() { - const url = `${this.getKeycloakRealmUrl()}/.well-known/openid-configuration` - this.logger.log(`Keycloak openid-configuration URL resolved: ${url}`) - return url - } - - getKeycloakUrl() { - if (!this.keycloakProtocol || !this.keycloakDomain) { - throw new Error(`Keycloak protocol or domain is not configured.`) - } - const url = `${this.keycloakProtocol}://${this.keycloakDomain}` - this.logger.log(`Keycloak internal URL resolved: ${url}`) - return url - } - - getInternalOrPublicArgoCDUrl() { - const url = this.argocdInternalUrl ?? this.argocdUrl - this.logger.log(`ArgoCD URL resolved: ${url} (${this.argocdInternalUrl ? 'internal' : 'public'})`) - return url - } - - getInternalOrPublicGitlabUrl() { - return this.getInternalOrPublicUrl('GitLab', this.gitlabUrl, this.gitlabInternalUrl) - } - - getInternalOrPublicVaultUrl() { - return this.getInternalOrPublicUrl('Vault', this.vaultUrl, this.vaultInternalUrl) - } - - getInternalOrPublicHarborUrl() { - return this.getInternalOrPublicUrl('Harbor', this.harborUrl, this.harborInternalUrl) - } - - getInternalOrPublicNexusUrl() { - return this.getInternalOrPublicUrl('Nexus', this.nexusUrl, this.nexusInternalUrl) - } - - getInternalOrPublicSonarqubeUrl() { - return this.getInternalOrPublicUrl('SonarQube', this.sonarqubeUrl, this.sonarqubeInternalUrl) - } - - getInternalOrPublicUrl(name: string, publicUrl: string | undefined, internalUrl: string | undefined): string | undefined { - const trimedInternalUrl = internalUrl?.trim() - const trimmedPublicUrl = publicUrl?.trim() - const url = trimedInternalUrl || trimmedPublicUrl || undefined - let label = 'none' - if (trimedInternalUrl) { - label = 'internal' - } else if (trimmedPublicUrl) { - label = 'public' - } - this.logger.log(`${name} URL resolved: ${url ?? 'none'} (${label})`) - return url - } - - NODE_ENV - = process.env.NODE_ENV === 'test' - ? 'test' - : process.env.NODE_ENV === 'development' - ? 'development' - : 'production' -} diff --git a/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts new file mode 100644 index 0000000000..7c42430723 --- /dev/null +++ b/apps/server-nestjs/src/modules/infrastructure/database/database-health.service.spec.ts @@ -0,0 +1,52 @@ +import type { DeepMockProxy } from 'vitest-mock-extended' +import { HealthIndicatorService } from '@nestjs/terminus' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { DatabaseHealthService } from './database-health.service' +import { PrismaService } from './prisma.service' + +describe('databaseHealthService', () => { + let service: DatabaseHealthService + let prisma: DeepMockProxy + let indicatorSession: { up: ReturnType, down: ReturnType } + + beforeEach(async () => { + prisma = mockDeep() + const healthIndicatorMock = mockDeep() + indicatorSession = { + up: vi.fn().mockReturnValue({ status: 'up' }), + down: vi.fn().mockReturnValue({ status: 'down' }), + } + vi.mocked(healthIndicatorMock.check).mockReturnValue(indicatorSession as any) + + const module = await Test.createTestingModule({ + providers: [ + DatabaseHealthService, + { provide: PrismaService, useValue: prisma }, + { provide: HealthIndicatorService, useValue: healthIndicatorMock }, + ], + }).compile() + + service = module.get(DatabaseHealthService) + }) + + it('reports up when the query succeeds', async () => { + prisma.$queryRaw.mockResolvedValueOnce(undefined as any) + + const result = await service.check('database') + + expect(prisma.$queryRaw).toHaveBeenCalled() + expect(indicatorSession.up).toHaveBeenCalled() + expect(result).toEqual({ status: 'up' }) + }) + + it('reports down when the query throws', async () => { + prisma.$queryRaw.mockRejectedValueOnce(new Error('boom')) + + const result = await service.check('database') + + expect(indicatorSession.down).toHaveBeenCalledWith({ message: 'boom' }) + expect(result).toEqual({ status: 'down' }) + }) +}) diff --git a/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts b/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts index 9919a78be6..7e909376ec 100644 --- a/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts @@ -1,12 +1,11 @@ import { Module } from '@nestjs/common' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../configuration/configuration.module' import { DatabaseHealthService } from './database-health.service' import { DatabaseService } from './database.service' import { PrismaService } from './prisma.service' @Module({ - imports: [ConfigurationModule, TerminusModule], + imports: [TerminusModule], providers: [DatabaseHealthService, DatabaseService, PrismaService], exports: [DatabaseHealthService, DatabaseService, PrismaService], }) diff --git a/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts index 416c82980d..3f373ba1eb 100644 --- a/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts +++ b/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts @@ -1,8 +1,10 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../config/base.config' import { Test } from '@nestjs/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { ConfigurationModule } from '../configuration/configuration.module' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../../infrastructure/config/base.config' import { DatabaseService } from './database.service' import { PrismaService } from './prisma.service' @@ -10,17 +12,12 @@ describe('databaseService', () => { let service: DatabaseService beforeEach(async () => { + const baseConfig = mockDeep() const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigurationModule], providers: [ DatabaseService, - { - provide: PrismaService, - useValue: { - $connect: vi.fn().mockResolvedValue(undefined), - $disconnect: vi.fn().mockResolvedValue(undefined), - }, - }, + { provide: PrismaService, useValue: mockDeep() }, + { provide: BASE_CONFIG, useValue: baseConfig }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts b/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts index 695a2daf5a..5d9d0c32f7 100644 --- a/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts @@ -1,6 +1,7 @@ +import type { BaseConfig } from '../../infrastructure/config/base.config' import { setTimeout } from 'node:timers/promises' import { Inject, Injectable, Logger } from '@nestjs/common' -import { ConfigurationService } from '../configuration/configuration.service' +import { BASE_CONFIG } from '../../infrastructure/config/base.config' import { PrismaService } from './prisma.service' @Injectable() @@ -9,7 +10,7 @@ export class DatabaseService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, - @Inject(ConfigurationService) private readonly configurationService: ConfigurationService, + @Inject(BASE_CONFIG) private readonly configurationService: BaseConfig, ) { this.DELAY_BEFORE_RETRY = this.configurationService.isTest || this.configurationService.isCI diff --git a/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts b/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts index b280084626..8eb62bea67 100644 --- a/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { EventEmitterModule } from '@nestjs/event-emitter' +import { baseConfigFactory } from '../../../config/base.config' @Module({ - imports: [EventEmitterModule.forRoot()], + imports: [EventEmitterModule.forRoot(), ConfigModule.forFeature(baseConfigFactory)], exports: [EventEmitterModule], }) export class EventsModule {} diff --git a/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts b/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts index e29dae241f..25b2a4e695 100644 --- a/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common' import { AuthModule } from './auth/auth.module' -import { ConfigurationModule } from './configuration/configuration.module' import { DatabaseModule } from './database/database.module' import { EventsModule } from './events/events.module' import { LoggerModule } from './logger/logger.module' @@ -8,7 +7,7 @@ import { PermissionModule } from './permission/permission.module' @Module({ providers: [], - imports: [AuthModule, DatabaseModule, ConfigurationModule, EventsModule, LoggerModule, PermissionModule], - exports: [AuthModule, DatabaseModule, ConfigurationModule, EventsModule, LoggerModule, PermissionModule], + imports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + exports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }) export class InfrastructureModule {} diff --git a/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts b/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts index 9da4f77584..257ef429f8 100644 --- a/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts @@ -1,19 +1,18 @@ +import type { BaseConfig } from '../config/base.config' import { getLoggerOptions } from '@cpn-console/logger' import { Module } from '@nestjs/common' import { LoggerModule as PinoLoggerModule } from 'nestjs-pino' - -import { ConfigurationModule } from '../configuration/configuration.module' -import { ConfigurationService } from '../configuration/configuration.service' +import { BASE_CONFIG } from '../config/base.config' @Module({ imports: [ PinoLoggerModule.forRootAsync({ - imports: [ConfigurationModule], - inject: [ConfigurationService], - useFactory: async (configService: ConfigurationService) => { + imports: [], + inject: [BASE_CONFIG], + useFactory: async (baseConfig: BaseConfig) => { return { pinoHttp: { - ...getLoggerOptions(configService.isProd ? 'production' : 'development', configService.isTest ? 'info' : 'debug'), + ...getLoggerOptions(baseConfig.isProd ? 'production' : 'development', baseConfig.isTest ? 'info' : 'debug'), customLogLevel: (req, res, err) => { if (err || res.statusCode >= 500) { return 'error' diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts index 633ec731a3..f30d6c6af7 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts @@ -1,4 +1,5 @@ import type { TestingModule } from '@nestjs/testing' +import type { KeycloakConfig } from './keycloak.module-definition' import KcAdminClient from '@keycloak/keycloak-admin-client' import { ScheduleModule } from '@nestjs/schedule' import { Test } from '@nestjs/testing' @@ -6,9 +7,9 @@ import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' import { ADMIN_TOKEN_REFRESH_INTERVAL_MS } from './keycloak.constants' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' const keycloakUrl = 'https://keycloak.internal' const projectRealm = 'project-realm' @@ -37,19 +38,19 @@ function useTokenEndpoint({ rejectGrant = () => false }: { rejectGrant?: (grantT return tokenRequests } -function createKeycloakClientServiceTestingModule(config: Partial = {}) { +function createKeycloakClientServiceTestingModule(config: Partial = {}) { return Test.createTestingModule({ imports: [ScheduleModule.forRoot()], providers: [ KeycloakClientService, { provide: KEYCLOAK_ADMIN_CLIENT, useValue: new KcAdminClient({ baseUrl: keycloakUrl }) }, { - provide: ConfigurationService, - useValue: mockDeep({ - keycloakRealm: projectRealm, - keycloakAdmin: 'admin', - keycloakAdminPassword: 'admin-password', - keycloakAdminClientId: 'admin-cli', + provide: KEYCLOAK_CONFIG, + useValue: mockDeep({ + realm: projectRealm, + admin: 'admin', + adminPassword: 'admin-password', + adminClientId: 'admin-cli', ...config, }), }, @@ -159,28 +160,6 @@ describe('keycloakClientService authentication lifecycle', () => { await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) expect(tokenRequests).toHaveLength(1) }) - - it('should not authenticate nor refresh the token when the Keycloak realm is not configured', async () => { - const tokenRequests = useTokenEndpoint() - await module.close() - module = await createKeycloakClientServiceTestingModule({ keycloakRealm: undefined }).compile() - - await expect(module.init()).rejects.toThrow() - - await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) - expect(tokenRequests).toHaveLength(0) - }) - - it('should not authenticate nor refresh the token when the admin credentials are not configured', async () => { - const tokenRequests = useTokenEndpoint() - await module.close() - module = await createKeycloakClientServiceTestingModule({ keycloakAdminPassword: undefined }).compile() - - await expect(module.init()).rejects.toThrow() - - await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) - expect(tokenRequests).toHaveLength(0) - }) }) describe('getOrCreateSubGroupByName', () => { diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts index cbe7c3e2dc..bb106d64e2 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -4,15 +4,16 @@ import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/us import type { Credentials } from '@keycloak/keycloak-admin-client/lib/utils/auth' import type { OnModuleInit } from '@nestjs/common' import type { ProjectWithDetails } from './keycloak-datastore.service' +import type { KeycloakConfig } from './keycloak.module-definition' import type { GroupRepresentationWith } from './keycloak.utils' import { Inject, Injectable, Logger } from '@nestjs/common' import { Interval } from '@nestjs/schedule' import { trace } from '@opentelemetry/api' import z from 'zod' -import { getErrorResponseStatus } from '../../utils/http-error' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { getErrorResponseStatus } from '../../utils/http.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { ADMIN_AUTH_REALM, ADMIN_TOKEN_REFRESH_INTERVAL_MS, CONSOLE_GROUP_NAME, PASSWORD_GRANT_TYPE, REFRESH_TOKEN_GRANT_TYPE, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' export const KEYCLOAK_ADMIN_CLIENT = Symbol('KEYCLOAK_ADMIN_CLIENT') @@ -23,7 +24,7 @@ export class KeycloakClientService implements OnModuleInit { private authenticated = false constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(KEYCLOAK_CONFIG) private readonly keycloakConfig: KeycloakConfig, @Inject(KEYCLOAK_ADMIN_CLIENT) private readonly client: KcAdminClient, ) { } @@ -274,17 +275,8 @@ export class KeycloakClientService implements OnModuleInit { } async onModuleInit() { - if (!this.config.keycloakRealm) { - throw new Error('Keycloak realm is not configured') - } - if (!this.config.keycloakAdmin || !this.config.keycloakAdminPassword) { - throw new Error('Keycloak admin username or password is not configured') - } - if (!this.config.keycloakAdminClientId) { - throw new Error('Keycloak admin client id is not configured') - } try { - this.logger.log(`Authenticating Keycloak admin client (realm=${this.config.keycloakRealm})`) + this.logger.log(`Authenticating Keycloak admin client (realm=${this.keycloakConfig.realm})`) await this.authenticate(this.passwordCredentials()) } catch (err) { if (err instanceof Error) { @@ -294,9 +286,9 @@ export class KeycloakClientService implements OnModuleInit { } throw err } - this.client.setConfig({ realmName: this.config.keycloakRealm }) + this.client.setConfig({ realmName: this.keycloakConfig.realm }) this.authenticated = true - this.logger.log(`Keycloak Admin Client authenticated (realm=${this.config.keycloakRealm})`) + this.logger.log(`Keycloak Admin Client authenticated (realm=${this.keycloakConfig.realm})`) } // The admin client never refreshes its token on its own; without this the @@ -318,27 +310,18 @@ export class KeycloakClientService implements OnModuleInit { } } - // Checked by onModuleInit before any authentication; the getter narrows the - // config value so Credentials.clientId stays a plain string - private get adminClientId(): string { - if (!this.config.keycloakAdminClientId) { - throw new Error('KEYCLOAK_ADMIN_CLIENT_ID is not configured') - } - return this.config.keycloakAdminClientId - } - private passwordCredentials(): Credentials { return { - clientId: this.adminClientId, + clientId: this.keycloakConfig.adminClientId, grantType: PASSWORD_GRANT_TYPE, - username: this.config.keycloakAdmin, - password: this.config.keycloakAdminPassword, + username: this.keycloakConfig.admin, + password: this.keycloakConfig.adminPassword, } } private refreshTokenCredentials(): Credentials { return { - clientId: this.adminClientId, + clientId: this.keycloakConfig.adminClientId, grantType: REFRESH_TOKEN_GRANT_TYPE, refreshToken: this.client.refreshToken, } diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts index e2d1c31286..6d78945601 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts @@ -1,23 +1,21 @@ +import type { KeycloakConfig } from './keycloak.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { KEYCLOAK_CONFIG } from './keycloak.module-definition' @Injectable() export class KeycloakHealthService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(KEYCLOAK_CONFIG) + private readonly keycloakConfig: KeycloakConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - const url = this.config.getKeycloakOpenidConfigurationUrl() - if (!url) return indicator.down('Not configured') - try { - const response = await fetch(url) + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts new file mode 100644 index 0000000000..8245a556b9 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.module-definition.ts @@ -0,0 +1,29 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface KeycloakConfig { + protocol: string + domain?: string + publicProtocol: string + publicDomain?: string + realm?: string + clientId?: string + clientSecret?: string + admin?: string + adminPassword?: string + adminClientId: string + redirectUri?: string + jwksCacheTtlMs: number + jwksTimeoutMs: number + openidConfigurationCacheTtlMs: number + adminKcUserId: string[] + url?: string + realmUrl?: string + openidConfigurationUrl?: string +} + +export interface KeycloakModuleOptions extends KeycloakConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('keycloak') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as KEYCLOAK_CONFIG } diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts index 9ae808fc65..7186de782a 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts @@ -1,23 +1,23 @@ +import type { KeycloakConfig } from './keycloak.module-definition' import KcAdminClient from '@keycloak/keycloak-admin-client' import { Module } from '@nestjs/common' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { DatabaseModule } from '../infrastructure/database/database.module' import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' import { KeycloakDatastoreService } from './keycloak-datastore.service' import { KeycloakHealthService } from './keycloak-health.service' import { KeycloakPluginService } from './keycloak-plugin.service' +import { ConfigurableModuleClass, KEYCLOAK_CONFIG } from './keycloak.module-definition' import { KeycloakService } from './keycloak.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule], + imports: [DatabaseModule, TerminusModule], providers: [ { - inject: [ConfigurationService], + inject: [KEYCLOAK_CONFIG], provide: KEYCLOAK_ADMIN_CLIENT, - useFactory: (config: ConfigurationService) => new KcAdminClient({ - baseUrl: config.getKeycloakUrl(), + useFactory: (config: KeycloakConfig) => new KcAdminClient({ + baseUrl: config.url, }), }, KeycloakClientService, @@ -28,4 +28,4 @@ import { KeycloakService } from './keycloak.service' ], exports: [KeycloakClientService, KeycloakHealthService, KeycloakPluginService, KeycloakService], }) -export class KeycloakModule {} +export class KeycloakModule extends ConfigurableModuleClass {} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts index 4d8d6e726a..e5b116688a 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -19,7 +19,7 @@ import { KeycloakService } from './keycloak.service' describe('keycloakService', () => { let service: KeycloakService let keycloak: DeepMockProxy - let keycloakDatastore: DeepMockProxy + let datastore: DeepMockProxy beforeEach(async () => { keycloak = mockDeep({ @@ -31,7 +31,7 @@ describe('keycloakService', () => { getAllGroups: vi.fn().mockImplementation(async function* () { /* empty by default */ }), deleteGroup: vi.fn().mockResolvedValue(undefined), }) - keycloakDatastore = mockDeep({ + datastore = mockDeep({ getAllAdminRoles: vi.fn().mockResolvedValue([]), getAllUsersWithAdminRoleIds: vi.fn().mockResolvedValue([]), }) @@ -40,7 +40,7 @@ describe('keycloakService', () => { providers: [ KeycloakService, { provide: KeycloakClientService, useValue: keycloak }, - { provide: KeycloakDatastoreService, useValue: keycloakDatastore }, + { provide: KeycloakDatastoreService, useValue: datastore }, ], }).compile() @@ -66,9 +66,9 @@ describe('keycloakService', () => { const users: UserWithAdminRoles[] = [ { id: 'user-1', adminRoleIds: ['admin-role-id'] }, ] - keycloakDatastore.getAllProjects.mockResolvedValue([]) - keycloakDatastore.getAllAdminRoles.mockResolvedValue(adminRoles) - keycloakDatastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) await service.handleCron() @@ -86,9 +86,9 @@ describe('keycloakService', () => { { id: 'user-1', adminRoleIds: ['admin-role-id'] }, { id: 'user-2', adminRoleIds: [] }, ] - keycloakDatastore.getAllProjects.mockResolvedValue([]) - keycloakDatastore.getAllAdminRoles.mockResolvedValue(adminRoles) - keycloakDatastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) const adminGroup = makeGroupRepresentation({ id: 'kc-group-id', name: 'admin', path: '/console/admin' }) keycloak.getOrCreateGroupByPath.mockResolvedValue(adminGroup) @@ -105,7 +105,7 @@ describe('keycloakService', () => { }) it('should purge orphans', async () => { - keycloakDatastore.getAllProjects.mockResolvedValue([mockProject]) + datastore.getAllProjects.mockResolvedValue([mockProject]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project', subGroups: [] }) const orphanGroup = makeGroupRepresentation({ @@ -124,7 +124,7 @@ describe('keycloakService', () => { keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) await service.handleCron() - expect(keycloakDatastore.getAllProjects).toHaveBeenCalled() + expect(datastore.getAllProjects).toHaveBeenCalled() expect(keycloak.getAllGroups).toHaveBeenCalled() expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/test-project') expect(keycloak.deleteGroup).toHaveBeenCalledWith('orphan-id') @@ -140,7 +140,7 @@ describe('keycloakService', () => { }), ], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithMembers]) + datastore.getAllProjects.mockResolvedValue([projectWithMembers]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) @@ -180,7 +180,7 @@ describe('keycloakService', () => { ], roles: [roleWithOidc], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRole]) + datastore.getAllProjects.mockResolvedValue([projectWithRole]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) const consoleGroup = { id: 'console-id', name: 'console' } @@ -218,7 +218,7 @@ describe('keycloakService', () => { ...mockProject, environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithEnv]) + datastore.getAllProjects.mockResolvedValue([projectWithEnv]) const projectGroup = makeGroupRepresentation({ id: 'group-id', @@ -290,7 +290,7 @@ describe('keycloakService', () => { ], environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) + datastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) const projectGroup = makeGroupRepresentation({ id: 'group-id', @@ -349,7 +349,7 @@ describe('keycloakService', () => { ], roles: [roleManaged, roleExternal, roleGlobal], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRoles]) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) const consoleGroup = { id: 'console-id', name: 'console' } @@ -416,7 +416,7 @@ describe('keycloakService', () => { ], roles: [roleSystemManaged], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRoles]) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) const consoleGroup = { id: 'console-id', name: 'console' } diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts index f6a3f6c0d5..f737e77bcd 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -7,7 +7,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' import z from 'zod' -import { getErrorResponseStatus } from '../../utils/http-error' +import { getErrorResponseStatus } from '../../utils/http.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { KeycloakClientService } from './keycloak-client.service' @@ -20,7 +20,7 @@ export class KeycloakService { constructor( @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, - @Inject(KeycloakDatastoreService) private readonly keycloakDatastore: KeycloakDatastoreService, + @Inject(KeycloakDatastoreService) private readonly datastore: KeycloakDatastoreService, ) { this.logger.log('KeycloakService initialized') } @@ -59,9 +59,9 @@ export class KeycloakService { const span = trace.getActiveSpan() this.logger.log('Starting periodic Keycloak reconciliation') const [projects, adminRoles, users] = await Promise.all([ - this.keycloakDatastore.getAllProjects(), - this.keycloakDatastore.getAllAdminRoles(), - this.keycloakDatastore.getAllUsersWithAdminRoleIds(), + this.datastore.getAllProjects(), + this.datastore.getAllAdminRoles(), + this.datastore.getAllUsersWithAdminRoleIds(), ]) span?.setAttributes({ 'keycloak.projects.count': projects.length, diff --git a/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts index bd7d4f85bd..5ac2c471b3 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts @@ -1,4 +1,6 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { nexusConfigFactory } from '../../config/nexus.config' import { faker } from '@faker-js/faker' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' @@ -6,9 +8,9 @@ import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { NexusClientService } from './nexus-client.service' import { NexusHttpClientService } from './nexus-http-client.service' +import { NEXUS_CONFIG } from './nexus.module-definition' const nexusUrl = 'https://nexus.internal' @@ -18,18 +20,16 @@ const basicAuth = `Basic ${Buffer.from(`admin:${nexusAdminPassword}`, 'utf8').to describe('nexusClientService', () => { let service: NexusClientService - let config: DeepMockProxy + let config: DeepMockProxy> beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - config = mockDeep({ - nexusSecretExposedUrl: 'https://nexus.example', - nexusInternalUrl: nexusUrl, - nexusAdmin: 'admin', - nexusAdminPassword, - projectRootDir: 'forge', - getInternalOrPublicNexusUrl: () => nexusUrl, + config = mockDeep>({ + internalUrl: nexusUrl, + admin: 'admin', + adminPassword: nexusAdminPassword, + internalOrPublicUrl: nexusUrl, }) const module = await Test.createTestingModule({ @@ -37,7 +37,7 @@ describe('nexusClientService', () => { NexusClientService, NexusHttpClientService, { - provide: ConfigurationService, + provide: NEXUS_CONFIG, useValue: config, }, ], diff --git a/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts index 9453ee1d10..5914403606 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts @@ -1,28 +1,23 @@ +import type { NexusConfig } from './nexus.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { NEXUS_CONFIG } from './nexus.module-definition' @Injectable() export class NexusHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - if (!this.config.nexusInternalUrl) return indicator.down('Not configured') - - const url = new URL('/service/rest/v1/status', this.config.nexusInternalUrl).toString() const headers: Record = {} - if (this.config.nexusAdmin && this.config.nexusAdminPassword) { - const credentials = `${this.config.nexusAdmin}:${this.config.nexusAdminPassword}` - const encoded = Buffer.from(credentials).toString('base64') - headers.Authorization = `Basic ${encoded}` - } - + const credentials = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` + const encoded = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${encoded}` try { - const response = await fetch(url, { headers }) + const response = await fetch(this.nexusConfig.probeUrl, { headers }) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { diff --git a/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts index e89cc70aa4..8575a4cd18 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts @@ -1,7 +1,8 @@ +import type { NexusConfig } from './nexus.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { NEXUS_CONFIG } from './nexus.module-definition' export interface NexusFetchOptions { method?: string @@ -44,7 +45,7 @@ export class NexusError extends Error { @Injectable() export class NexusHttpClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, ) {} @StartActiveSpan() @@ -76,7 +77,7 @@ export class NexusHttpClientService { } private get baseUrl() { - const url = this.config.getInternalOrPublicNexusUrl() + const url = this.nexusConfig.internalOrPublicUrl if (!url) { throw new NexusError('NotConfigured', 'NEXUS_INTERNAL_URL or NEXUS_URL is required') } @@ -88,13 +89,13 @@ export class NexusHttpClientService { } private get basicAuth() { - if (!this.config.nexusAdmin) { + if (!this.nexusConfig.admin) { throw new NexusError('NotConfigured', 'NEXUS_ADMIN is required') } - if (!this.config.nexusAdminPassword) { + if (!this.nexusConfig.adminPassword) { throw new NexusError('NotConfigured', 'NEXUS_ADMIN_PASSWORD is required') } - const raw = `${this.config.nexusAdmin}:${this.config.nexusAdminPassword}` + const raw = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` return Buffer.from(raw, 'utf8').toString('base64') } diff --git a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts index 43dc717c40..eab6e89046 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts @@ -1,25 +1,27 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { nexusConfigFactory } from '../../config/nexus.config' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { makeToUrlParams } from '../plugin/plugin.utils' import { NexusPluginService } from './nexus-plugin.service' +import { NEXUS_CONFIG } from './nexus.module-definition' describe('nexusPluginService', () => { let service: NexusPluginService - let config: DeepMockProxy + let config: DeepMockProxy> beforeEach(async () => { - config = mockDeep({ - nexusUrl: 'https://nexus.public/', - nexusInternalUrl: 'https://nexus.internal/', + config = mockDeep>({ + url: 'https://nexus.public/', + internalUrl: 'https://nexus.internal/', }) const moduleRef = await Test.createTestingModule({ providers: [ NexusPluginService, - { provide: ConfigurationService, useValue: config }, + { provide: NEXUS_CONFIG, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts index c6ccab09bb..3682cba90e 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts @@ -1,22 +1,19 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { NexusConfig } from './nexus.module-definition' import { DISABLED, ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { NEXUS_CONFIG } from './nexus.module-definition' @Injectable() export class NexusPluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, ) {} infos(): ServiceInfos { return { name: 'nexus', - to: () => { - if (!this.config.nexusUrl) return undefined - return this.config.nexusUrl - }, + to: () => this.nexusConfig.url, title: 'Nexus', imgSrc: '/img/nexus.png', description: 'Nexus permet de gérer les binaires et artefacts de build à travers la chaîne logistique logicielle', diff --git a/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts b/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts new file mode 100644 index 0000000000..998c0b3b7a --- /dev/null +++ b/apps/server-nestjs/src/modules/nexus/nexus.module-definition.ts @@ -0,0 +1,18 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface NexusConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + secretExposeInternalUrl: boolean + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface NexusModuleOptions extends NexusConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('nexus') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as NEXUS_CONFIG } diff --git a/apps/server-nestjs/src/modules/nexus/nexus.module.ts b/apps/server-nestjs/src/modules/nexus/nexus.module.ts index 14d706232d..25f0e992ce 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.module.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { baseConfigFactory } from '../../config/base.config' +import { nexusConfigFactory } from '../../config/nexus.config' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { NexusClientService } from './nexus-client.service' @@ -8,10 +10,11 @@ import { NexusDatastoreService } from './nexus-datastore.service' import { NexusHealthService } from './nexus-health.service' import { NexusHttpClientService } from './nexus-http-client.service' import { NexusPluginService } from './nexus-plugin.service' +import { ConfigurableModuleClass } from './nexus.module-definition' import { NexusService } from './nexus.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], + imports: [DatabaseModule, TerminusModule, VaultModule, ConfigModule.forFeature(nexusConfigFactory), ConfigModule.forFeature(baseConfigFactory)], providers: [ NexusHealthService, NexusPluginService, @@ -22,4 +25,4 @@ import { NexusService } from './nexus.service' ], exports: [NexusClientService, NexusHealthService, NexusPluginService, NexusService], }) -export class NexusModule {} +export class NexusModule extends ConfigurableModuleClass {} diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts index 41674dc730..9963b5e4c5 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -1,12 +1,16 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { NexusConfig } from './nexus.module-definition' import { DISABLED, ENABLED } from '@cpn-console/shared' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { VaultClientService } from '../vault/vault-client.service' import { VaultError } from '../vault/vault-http-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' import { NexusClientService } from './nexus-client.service' import { NexusDatastoreService } from './nexus-datastore.service' import { makeProjectWithDetails } from './nexus-testing.utils' @@ -19,6 +23,7 @@ import { PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, } from './nexus.constants' +import { NEXUS_CONFIG } from './nexus.module-definition' import { NexusService } from './nexus.service' describe('nexusService', () => { @@ -26,7 +31,9 @@ describe('nexusService', () => { let client: DeepMockProxy let datastore: DeepMockProxy let vault: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy beforeEach(async () => { client = mockDeep({ @@ -45,7 +52,9 @@ describe('nexusService', () => { vault = mockDeep({ read: vi.fn().mockRejectedValue(new VaultError('NotFound', 'Not Found')), }) - config = mockDeep({ projectRootDir: 'forge' }) + config = mockDeep({}) + baseConfig = mockDeep({ projectsRootDir: 'forge' }) + vaultConfig = mockDeep({}) const module = await Test.createTestingModule({ providers: [ @@ -53,7 +62,9 @@ describe('nexusService', () => { { provide: NexusClientService, useValue: client }, { provide: NexusDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: config }, + { provide: NEXUS_CONFIG, useValue: config }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.ts index 6b78c7ad25..52061ace32 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -1,6 +1,8 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { NexusPrivilege } from './nexus-client.service' import type { ProjectWithDetails } from './nexus-datastore.service' +import type { NexusConfig } from './nexus.module-definition' import type { MavenHostedRepoKind, } from './nexus.utils' @@ -8,7 +10,7 @@ import { specificallyEnabled } from '@cpn-console/hooks' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -34,6 +36,7 @@ import { PROJECT_READ_GROUP_PATH_SUFFIXES_PLUGIN_KEY, PROJECT_WRITE_GROUP_PATH_SUFFIXES_PLUGIN_KEY, } from './nexus.constants' +import { NEXUS_CONFIG } from './nexus.module-definition' import { generateMavenHostedRepoName, generateNpmHostedRepoName, @@ -52,10 +55,11 @@ export class NexusService { private readonly logger = new Logger(NexusService.name) constructor( - @Inject(NexusDatastoreService) private readonly nexusDatastore: NexusDatastoreService, + @Inject(NexusDatastoreService) private readonly datastore: NexusDatastoreService, @Inject(NexusClientService) private readonly client: NexusClientService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(NEXUS_CONFIG) private readonly nexusConfig: NexusConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, ) { this.logger.log('NexusService initialized') } @@ -71,7 +75,7 @@ export class NexusService { span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling project upsert for ${project.slug}`) await this.ensureProject(project) - const projects = await this.nexusDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() await this.ensurePlatformRoles(projects) } @@ -86,7 +90,7 @@ export class NexusService { span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling project delete for ${project.slug}`) await this.deleteProject(project) - const projects = await this.nexusDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() await this.ensurePlatformRoles(projects) } @@ -95,7 +99,7 @@ export class NexusService { async handleCron() { const span = trace.getActiveSpan() this.logger.log('Starting Nexus reconciliation') - const projects = await this.nexusDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() span?.setAttribute('nexus.projects.count', projects.length) await this.ensureProjects(projects) await this.ensurePlatformRoles(projects) @@ -429,7 +433,7 @@ export class NexusService { } private async ensureUser(project: ProjectWithDetails) { - const vaultPath = getProjectVaultPath(this.config.projectRootDir, project.slug, 'tech/NEXUS') + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') let existingPassword: string | undefined try { existingPassword = await this.vault.read(vaultPath).then(res => res.data?.NEXUS_PASSWORD) @@ -487,7 +491,7 @@ export class NexusService { private async getOptionalConfigValue(project: ProjectWithDetails, key: string) { const projectValue = getPluginConfig(project, key) if (projectValue) return projectValue - return await this.nexusDatastore.getAdminPluginConfig(PLUGIN_NAME, key) + return await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) } private async ensureProjectGroupRoles(project: ProjectWithDetails, args: { readOnlyPrivileges: string[], writePrivileges: string[] }) { @@ -511,10 +515,10 @@ export class NexusService { } private async ensurePlatformRoles(projects: ProjectWithDetails[]) { - const rawWriteGroupPaths = await this.nexusDatastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) + const rawWriteGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) ?? DEFAULT_PLATFORM_WRITE_GROUP_PATHS - const rawReadGroupPaths = await this.nexusDatastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) + const rawReadGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) ?? DEFAULT_PLATFORM_READ_GROUP_PATHS const readonlyPrivileges = new Set() @@ -574,7 +578,7 @@ export class NexusService { this.client.deleteSecurityUsers(project.slug), ]) - const vaultPath = getProjectVaultPath(this.config.projectRootDir, project.slug, 'tech/NEXUS') + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') try { await this.vault.delete(vaultPath) } catch (error) { diff --git a/apps/server-nestjs/src/modules/nexus/nexus.utils.ts b/apps/server-nestjs/src/modules/nexus/nexus.utils.ts index 1bd4450861..6e9e0e7645 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.utils.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.utils.ts @@ -10,11 +10,9 @@ export function generateRandomPassword(length: number) { return raw.slice(0, length) } -export function getProjectVaultPath(projectRootDir: string | undefined, projectSlug: string, relativePath: string) { +export function getProjectVaultPath(projectRootDir: string, projectSlug: string, relativePath: string) { const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath - return projectRootDir - ? `${projectRootDir}/${projectSlug}/${normalized}` - : `${projectSlug}/${normalized}` + return `${projectRootDir}/${projectSlug}/${normalized}` } export type MavenHostedRepoKind = 'release' | 'snapshot' diff --git a/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts b/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts index f91f493f5b..5f008fd21e 100644 --- a/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts +++ b/apps/server-nestjs/src/modules/opencds/opencds-health.service.ts @@ -1,26 +1,21 @@ +import type { OpenCdsConfig } from './opencds.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { OPENCDS_CONFIG } from './opencds.module-definition' @Injectable() export class OpenCdsHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(OPENCDS_CONFIG) private readonly opencdsConfig: OpenCdsConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - if (!this.config.openCdsUrl) return indicator.down('Not configured') - try { - const url = new URL('/api/v1/health', this.config.openCdsUrl).toString() const headers: Record = {} - if (this.config.openCdsApiToken) { - headers.Authorization = `Bearer ${this.config.openCdsApiToken}` - } - - const response = await fetch(url, { headers }) + headers.Authorization = `Bearer ${this.opencdsConfig.apiToken}` + const response = await fetch(this.opencdsConfig.probeUrl, { headers }) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { diff --git a/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts b/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts new file mode 100644 index 0000000000..5a31df9912 --- /dev/null +++ b/apps/server-nestjs/src/modules/opencds/opencds.module-definition.ts @@ -0,0 +1,16 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface OpenCdsConfig { + url: string + internalUrl: string + probeUrl?: string + apiToken: string + apiTlsRejectUnauthorized: boolean +} + +export interface OpenCdsModuleOptions extends OpenCdsConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('opencds') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as OPENCDS_CONFIG } diff --git a/apps/server-nestjs/src/modules/opencds/opencds.module.ts b/apps/server-nestjs/src/modules/opencds/opencds.module.ts index 8b42ddee91..edc85a987a 100644 --- a/apps/server-nestjs/src/modules/opencds/opencds.module.ts +++ b/apps/server-nestjs/src/modules/opencds/opencds.module.ts @@ -1,11 +1,11 @@ import { Module } from '@nestjs/common' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { OpenCdsHealthService } from './opencds-health.service' +import { ConfigurableModuleClass } from './opencds.module-definition' @Module({ - imports: [ConfigurationModule, TerminusModule], + imports: [TerminusModule], providers: [OpenCdsHealthService], exports: [OpenCdsHealthService], }) -export class OpenCdsModule {} +export class OpenCdsModule extends ConfigurableModuleClass {} diff --git a/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts b/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts new file mode 100644 index 0000000000..1ef4cac975 --- /dev/null +++ b/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts @@ -0,0 +1,65 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginModule } from './plugin.module' +import { PluginService } from './plugin.service' + +describe('pluginModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits PluginService when USE_ARGOCD=false', async () => { + vi.stubEnv('USE_ARGOCD', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_ARGOCD')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_GITLAB=false', async () => { + vi.stubEnv('USE_GITLAB', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_GITLAB')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_REGISTRY=false', async () => { + vi.stubEnv('USE_REGISTRY', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_REGISTRY')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_KEYCLOAK=false', async () => { + vi.stubEnv('USE_KEYCLOAK', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_KEYCLOAK')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_NEXUS=false', async () => { + vi.stubEnv('USE_NEXUS', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_NEXUS')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_SONARQUBE=false', async () => { + vi.stubEnv('USE_SONARQUBE', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_SONARQUBE')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) + + it('omits PluginService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(PluginModule, 'USE_VAULT')], + }).compile() + expect(() => module.get(PluginService)).toThrow() + }) +}) diff --git a/apps/server-nestjs/src/modules/plugin/plugin.module.ts b/apps/server-nestjs/src/modules/plugin/plugin.module.ts index c8faef2fc8..d74a259e4e 100644 --- a/apps/server-nestjs/src/modules/plugin/plugin.module.ts +++ b/apps/server-nestjs/src/modules/plugin/plugin.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' import { ArgoCDModule } from '../argocd/argocd.module' import { GitlabModule } from '../gitlab/gitlab.module' import { KeycloakModule } from '../keycloak/keycloak.module' @@ -9,7 +10,15 @@ import { VaultModule } from '../vault/vault.module' import { PluginService } from './plugin.service' @Module({ - imports: [ArgoCDModule, GitlabModule, RegistryModule, KeycloakModule, NexusModule, SonarqubeModule, VaultModule], + imports: [ + ConditionalModule.registerWhen(ArgoCDModule, 'USE_ARGOCD'), + ConditionalModule.registerWhen(GitlabModule, 'USE_GITLAB'), + ConditionalModule.registerWhen(RegistryModule, 'USE_REGISTRY'), + KeycloakModule, + ConditionalModule.registerWhen(NexusModule, 'USE_NEXUS'), + ConditionalModule.registerWhen(SonarqubeModule, 'USE_SONARQUBE'), + VaultModule, + ], providers: [PluginService], exports: [PluginService], }) diff --git a/apps/server-nestjs/src/modules/project-members/project-members.module.ts b/apps/server-nestjs/src/modules/project-members/project-members.module.ts index 5e523c6c62..481aeddcef 100644 --- a/apps/server-nestjs/src/modules/project-members/project-members.module.ts +++ b/apps/server-nestjs/src/modules/project-members/project-members.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common' +import { keycloakConfigFactory } from '../../config/keycloak.config' import { AppEventsModule } from '../events/app-events.module' import { AuthModule } from '../infrastructure/auth/auth.module' import { DatabaseModule } from '../infrastructure/database/database.module' @@ -8,7 +9,7 @@ import { ProjectMembersController } from './project-members.controller' import { ProjectMembersService } from './project-members.service' @Module({ - imports: [AppEventsModule, AuthModule, DatabaseModule, KeycloakModule, ProjectPermissionModule], + imports: [AppEventsModule, AuthModule, DatabaseModule, KeycloakModule.forRoot(keycloakConfigFactory.asProvider()), ProjectPermissionModule], controllers: [ProjectMembersController], providers: [ProjectMembersService], }) diff --git a/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts b/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts new file mode 100644 index 0000000000..1ecc4f73e6 --- /dev/null +++ b/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.spec.ts @@ -0,0 +1,17 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProjectSecretsModule } from './project-secrets.module' +import { ProjectSecretsService } from './project-secrets.service' + +describe('projectSecretsModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ProjectSecretsService when USE_VAULT=false', async () => { + vi.stubEnv('USE_VAULT', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ProjectSecretsModule, 'USE_VAULT')], + }).compile() + expect(() => module.get(ProjectSecretsService)).toThrow() + }) +}) diff --git a/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts b/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts index 2909a7009c..16a6c23b13 100644 --- a/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts +++ b/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' import { AuthModule } from '../infrastructure/auth/auth.module' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' import { VaultModule } from '../vault/vault.module' @@ -8,7 +9,7 @@ import { ProjectSecretsController } from './project-secrets.controller' import { ProjectSecretsService } from './project-secrets.service' @Module({ - imports: [AuthModule, ConfigurationModule, DatabaseModule, ProjectPermissionModule, VaultModule], + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, VaultModule, ConfigModule.forFeature(baseConfigFactory)], controllers: [ProjectSecretsController], providers: [ProjectSecretsService], exports: [ProjectSecretsService], diff --git a/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts b/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts index eb9aeefd50..62586bd954 100644 --- a/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts +++ b/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts @@ -1,12 +1,15 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { makeProject } from '../project/project-testing.utils' import { VaultClientService } from '../vault/vault-client.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' import { VaultService } from '../vault/vault.service' import { makeVaultSecret } from './project-secrets-testing.utils' import { ProjectSecretsService } from './project-secrets.service' @@ -17,19 +20,22 @@ describe('projectSecretsService', () => { let prisma: DeepMockProxy let vault: DeepMockProxy let vaultClient: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy + let vaultConfig: DeepMockProxy beforeEach(async () => { prisma = mockDeep() vault = mockDeep() vaultClient = mockDeep() - config = mockDeep({ projectRootDir: '/vault' }) + config = mockDeep({ projectsRootDir: '/vault' }) + vaultConfig = mockDeep() module = await Test.createTestingModule({ providers: [ ProjectSecretsService, { provide: PrismaService, useValue: prisma }, - { provide: ConfigurationService, useValue: config }, + { provide: BASE_CONFIG, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, { provide: VaultService, useValue: vault }, { provide: VaultClientService, useValue: vaultClient }, ], diff --git a/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts b/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts index 6da2f9e644..2f9525b41f 100644 --- a/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts +++ b/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts @@ -1,7 +1,8 @@ +import type { ConfigType } from '@nestjs/config' import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common' import { trace } from '@opentelemetry/api' import { z } from 'zod' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { VaultClientService } from '../vault/vault-client.service' @@ -28,7 +29,7 @@ export class ProjectSecretsService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, @Inject(VaultService) private readonly vault: VaultService, @Inject(VaultClientService) private readonly vaultClient: VaultClientService, ) {} @@ -42,7 +43,7 @@ export class ProjectSecretsService { const project = await getProjectSlug(this.prisma, projectId) if (!project) throw new NotFoundException('Projet introuvable') span?.setAttribute('project.slug', project.slug) - const projectPath = generateProjectPath(this.config.projectRootDir, project.slug) + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, project.slug) const result: Record> = {} const relativePaths = await this.vault.listProjectSecrets(project.slug).catch((error) => { diff --git a/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts b/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts new file mode 100644 index 0000000000..375e371303 --- /dev/null +++ b/apps/server-nestjs/src/modules/project-services/project-services.module.spec.ts @@ -0,0 +1,17 @@ +import { ConditionalModule, ConfigModule } from '@nestjs/config' +import { Test } from '@nestjs/testing' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ProjectServicesModule } from './project-services.module' +import { ProjectServicesService } from './project-services.service' + +describe('projectServicesModule', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('omits ProjectServicesService when USE_PLUGINS=false', async () => { + vi.stubEnv('USE_PLUGINS', 'false') + const module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot(), ConditionalModule.registerWhen(ProjectServicesModule, 'USE_PLUGINS')], + }).compile() + expect(() => module.get(ProjectServicesService)).toThrow() + }) +}) diff --git a/apps/server-nestjs/src/modules/project-services/project-services.module.ts b/apps/server-nestjs/src/modules/project-services/project-services.module.ts index 52ab1011f5..599b8cae0a 100644 --- a/apps/server-nestjs/src/modules/project-services/project-services.module.ts +++ b/apps/server-nestjs/src/modules/project-services/project-services.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common' +import { ConditionalModule } from '@nestjs/config' import { AuthModule } from '../infrastructure/auth/auth.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' @@ -7,7 +8,7 @@ import { ProjectServicesController } from './project-services.controller' import { ProjectServicesService } from './project-services.service' @Module({ - imports: [AuthModule, DatabaseModule, PluginModule, ProjectPermissionModule], + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, ConditionalModule.registerWhen(PluginModule, 'USE_PLUGINS')], controllers: [ProjectServicesController], providers: [ProjectServicesService], exports: [ProjectServicesService], diff --git a/apps/server-nestjs/src/modules/project/project.module.ts b/apps/server-nestjs/src/modules/project/project.module.ts index 19e7f0b594..4e50667c67 100644 --- a/apps/server-nestjs/src/modules/project/project.module.ts +++ b/apps/server-nestjs/src/modules/project/project.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' import { AppEventsModule } from '../events/app-events.module' import { AuthModule } from '../infrastructure/auth/auth.module' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { EventsModule } from '../infrastructure/events/events.module' import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' import { UserPermissionModule } from '../infrastructure/permission/user/user.module' import { LogModule } from '../log/log.module' +import { VaultModule } from '../vault/vault.module' import { ProjectController } from './project.controller' import { ProjectService } from './project.service' @@ -14,12 +16,13 @@ import { ProjectService } from './project.service' imports: [ AppEventsModule, AuthModule, - ConfigurationModule, DatabaseModule, EventsModule, ProjectPermissionModule, UserPermissionModule, LogModule, + VaultModule, + ConfigModule.forFeature(baseConfigFactory), ], controllers: [ProjectController], providers: [ProjectService], diff --git a/apps/server-nestjs/src/modules/project/project.service.spec.ts b/apps/server-nestjs/src/modules/project/project.service.spec.ts index 221ba01abd..76de94e2fa 100644 --- a/apps/server-nestjs/src/modules/project/project.service.spec.ts +++ b/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -2,6 +2,8 @@ import type { TestingModule } from '@nestjs/testing' import type { Prisma } from '@prisma/client' import type { DeepMockProxy } from 'vitest-mock-extended' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' import { faker } from '@faker-js/faker' import { ForbiddenException, @@ -12,9 +14,10 @@ import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { AppEventsService } from '../events/app-events.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { LogService } from '../log/log.service' +import { VAULT_CONFIG } from '../vault/vault.module-definition' import { makeCreateProjectBody, makeListProjectsQuery, @@ -33,13 +36,15 @@ describe('projectService', () => { let service: ProjectService let prisma: DeepMockProxy let appEvents: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy + let vaultConfig: DeepMockProxy let logs: DeepMockProxy beforeEach(async () => { prisma = mockDeep() appEvents = mockDeep() - config = mockDeep({ appVersion: 'dev' }) + config = mockDeep({ appVersion: 'dev' }) + vaultConfig = mockDeep() logs = mockDeep() module = await Test.createTestingModule({ @@ -47,7 +52,8 @@ describe('projectService', () => { ProjectService, { provide: PrismaService, useValue: prisma }, { provide: AppEventsService, useValue: appEvents }, - { provide: ConfigurationService, useValue: config }, + { provide: BASE_CONFIG, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, { provide: LogService, useValue: logs }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/project/project.service.ts b/apps/server-nestjs/src/modules/project/project.service.ts index 533be11806..cbb6a8cf1c 100644 --- a/apps/server-nestjs/src/modules/project/project.service.ts +++ b/apps/server-nestjs/src/modules/project/project.service.ts @@ -1,12 +1,13 @@ import type { projectContract } from '@cpn-console/shared' +import type { ConfigType } from '@nestjs/config' import type { Prisma } from '@prisma/client' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' import type { ProjectDataExport, ProjectUpdateContext, ProjectWithDetails } from './project-queries.utils' import { AdminAuthorized } from '@cpn-console/shared' import { BadRequestException, ForbiddenException, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common' import { trace } from '@opentelemetry/api' +import { baseConfigFactory } from '../../config/base.config' import { AppEventsService } from '../events/app-events.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { PrismaService } from '../infrastructure/database/prisma.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { LogService } from '../log/log.service' @@ -30,7 +31,7 @@ export class ProjectService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AppEventsService) private readonly appEvents: AppEventsService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, @Inject(LogService) private readonly logs: LogService, ) {} @@ -62,7 +63,7 @@ export class ProjectService { const whereAnd = generateProjectWhereInput({ query, requestorUserId: user.userId, - appVersion: this.config.appVersion, + appVersion: this.baseConfig.appVersion, }) this.logger.debug(`project.list started (requestorUserId=${user.userId}, filter=${filter})`) diff --git a/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts b/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts new file mode 100644 index 0000000000..f9e676753d --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/harbor.module-definition.ts @@ -0,0 +1,22 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface HarborConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + ruleTemplate?: string + ruleCount?: string + retentionCron: string + robotRotationThresholdDays: number + projectSlugCacheTtlMs: number + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface HarborModuleOptions extends HarborConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('harbor') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as HARBOR_CONFIG } diff --git a/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts index f6f8881411..057bd83f04 100644 --- a/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts @@ -1,3 +1,4 @@ +import type { HarborConfig } from './harbor.module-definition' import { faker } from '@faker-js/faker' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' @@ -5,8 +6,8 @@ import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { VaultClientService } from '../vault/vault-client.service' +import { HARBOR_CONFIG } from './harbor.module-definition' import { RegistryClientService } from './registry-client.service' import { RegistryHttpClientService } from './registry-http-client.service' @@ -22,15 +23,15 @@ describe('registryService', () => { beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - const config = mockDeep({ - harborUrl, - harborInternalUrl: harborUrl, - harborAdmin: 'admin', - harborAdminPassword, - harborRuleTemplate: 'latestPushedK', - harborRuleCount: '10', - harborRetentionCron: '0 22 2 * * *', - projectRootDir: 'forge', + const harborConfig = mockDeep({ + url: harborUrl, + internalUrl: harborUrl, + internalOrPublicUrl: harborUrl, + admin: 'admin', + adminPassword: harborAdminPassword, + ruleTemplate: 'latestPushedK', + ruleCount: '10', + retentionCron: '0 22 2 * * *', }) const module = await Test.createTestingModule({ @@ -42,8 +43,8 @@ describe('registryService', () => { useValue: {}, }, { - provide: ConfigurationService, - useValue: config, + provide: HARBOR_CONFIG, + useValue: harborConfig, }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/registry/registry-health.service.ts b/apps/server-nestjs/src/modules/registry/registry-health.service.ts index 2a1b33454c..4ffab35aad 100644 --- a/apps/server-nestjs/src/modules/registry/registry-health.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -1,28 +1,23 @@ +import type { HarborConfig } from './harbor.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { HARBOR_CONFIG } from './harbor.module-definition' @Injectable() export class RegistryHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - if (!this.config.harborInternalUrl) return indicator.down('Not configured') - - const url = new URL('/api/v2.0/ping', this.config.harborInternalUrl).toString() const headers: Record = {} - if (this.config.harborAdmin && this.config.harborAdminPassword) { - const credentials = `${this.config.harborAdmin}:${this.config.harborAdminPassword}` - const base64 = Buffer.from(credentials).toString('base64') - headers.Authorization = `Basic ${base64}` - } - + const credentials = `${this.harborConfig.admin}:${this.harborConfig.adminPassword}` + const base64 = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${base64}` try { - const response = await fetch(url, { method: 'GET', headers }) + const response = await fetch(this.harborConfig.probeUrl, { method: 'GET', headers }) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { diff --git a/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts b/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts index cfe57239a2..bc3848b0cd 100644 --- a/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts @@ -1,6 +1,7 @@ +import type { HarborConfig } from './harbor.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { HARBOR_CONFIG } from './harbor.module-definition' import { encodeBasicAuth } from './registry.utils' export type RegistryQuery = Record @@ -46,28 +47,15 @@ export class RegistryError extends Error { @Injectable() export class RegistryHttpClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, ) {} - private get baseUrl() { - if (!this.config.harborInternalUrl) { - throw new RegistryError('NotConfigured', 'HARBOR_INTERNAL_URL is required') - } - return this.config.harborInternalUrl - } - private get apiBaseUrl() { - return new URL('api/v2.0/', this.baseUrl).toString() + return new URL('api/v2.0/', this.harborConfig.internalOrPublicUrl).toString() } private get defaultHeaders() { - if (!this.config.harborAdmin) { - throw new RegistryError('NotConfigured', 'HARBOR_ADMIN is required') - } - if (!this.config.harborAdminPassword) { - throw new RegistryError('NotConfigured', 'HARBOR_ADMIN_PASSWORD is required') - } - return { Accept: 'application/json', Authorization: `Basic ${encodeBasicAuth(this.config.harborAdmin, this.config.harborAdminPassword)}` } + return { Accept: 'application/json', Authorization: `Basic ${encodeBasicAuth(this.harborConfig.admin, this.harborConfig.adminPassword)}` } } async fetch( diff --git a/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts index 3e14300fa2..b4d14b009f 100644 --- a/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts @@ -1,12 +1,13 @@ import type { Cache } from 'cache-manager' import type { DeepMockProxy } from 'vitest-mock-extended' +import type { HarborConfig } from './harbor.module-definition' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { makeToUrlParams } from '../plugin/plugin.utils' +import { HARBOR_CONFIG } from './harbor.module-definition' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { RegistryPluginService } from './registry-plugin.service' @@ -14,25 +15,25 @@ import { makeProjectWithDetails } from './registry-testing.utils' describe('registryPluginService', () => { let service: RegistryPluginService - let config: DeepMockProxy - let registryDatastore: DeepMockProxy + let harborConfig: DeepMockProxy + let datastore: DeepMockProxy let registryClient: DeepMockProxy let cache: DeepMockProxy beforeEach(async () => { - config = mockDeep({ - harborUrl: 'https://harbor.example/', - harborProjectSlugCacheTtlMs: 300000, + harborConfig = mockDeep({ + url: 'https://harbor.example/', + projectSlugCacheTtlMs: 300000, }) - registryDatastore = mockDeep() + datastore = mockDeep() registryClient = mockDeep() cache = mockDeep() const moduleRef = await Test.createTestingModule({ providers: [ RegistryPluginService, - { provide: ConfigurationService, useValue: config }, - { provide: RegistryDatastoreService, useValue: registryDatastore }, + { provide: HARBOR_CONFIG, useValue: harborConfig }, + { provide: RegistryDatastoreService, useValue: datastore }, { provide: RegistryClientService, useValue: registryClient }, { provide: CACHE_MANAGER, useValue: cache }, ], @@ -54,13 +55,13 @@ describe('registryPluginService', () => { })) expect(url).toBe('https://harbor.example/harbor/projects/144/') - expect(registryDatastore.getProject).not.toHaveBeenCalled() + expect(datastore.getProject).not.toHaveBeenCalled() expect(registryClient.getProjectByName).toHaveBeenCalledWith('dulei') }) it('falls back to Harbor lookup when the store is empty', async () => { cache.get.mockResolvedValue(undefined) - registryDatastore.getProject.mockResolvedValue(makeProjectWithDetails({ slug: 'dulei' })) + datastore.getProject.mockResolvedValue(makeProjectWithDetails({ slug: 'dulei' })) registryClient.getProjectByName.mockResolvedValue({ status: HttpStatus.OK, data: { project_id: 144, metadata: {} }, diff --git a/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts b/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts index 1ac9a3d80d..e502423c5f 100644 --- a/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts @@ -1,9 +1,10 @@ import type { ServiceInfos } from '@cpn-console/hooks' import type { Cache } from 'cache-manager' +import type { HarborConfig } from './harbor.module-definition' import { DISABLED } from '@cpn-console/shared' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Inject, Injectable, Logger } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { HARBOR_CONFIG } from './harbor.module-definition' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { createProjectSlugCacheKey } from './registry.utils' @@ -13,10 +14,10 @@ export class RegistryPluginService { private readonly logger = new Logger(RegistryPluginService.name) constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(HARBOR_CONFIG) + private readonly harborConfig: HarborConfig, @Inject(RegistryDatastoreService) - private readonly registryDatastore: RegistryDatastoreService, + private readonly datastore: RegistryDatastoreService, @Inject(RegistryClientService) private readonly registryClient: RegistryClientService, @Inject(CACHE_MANAGER) @@ -28,9 +29,9 @@ export class RegistryPluginService { const cached = await this.cache.get(cacheKey) if (cached !== undefined) return cached ?? undefined - const project = await this.registryDatastore.getProject(projectId) + const project = await this.datastore.getProject(projectId) const slug = project?.slug ?? null - await this.cache.set(cacheKey, slug, this.config.harborProjectSlugCacheTtlMs) + await this.cache.set(cacheKey, slug, this.harborConfig.projectSlugCacheTtlMs) return slug ?? undefined } @@ -49,9 +50,8 @@ export class RegistryPluginService { } } - private resolveHarborProjectUrl(harborProjectId: number): string | undefined { - if (!this.config.harborUrl) return undefined - return new URL(`harbor/projects/${harborProjectId}/`, this.config.harborUrl).toString() + private resolveHarborProjectUrl(harborProjectId: number): string { + return new URL(`harbor/projects/${harborProjectId}/`, this.harborConfig.url).toString() } private async resolveProjectUrl(projectId: string): Promise { diff --git a/apps/server-nestjs/src/modules/registry/registry.module-definition.ts b/apps/server-nestjs/src/modules/registry/registry.module-definition.ts new file mode 100644 index 0000000000..c994e3c6cc --- /dev/null +++ b/apps/server-nestjs/src/modules/registry/registry.module-definition.ts @@ -0,0 +1,21 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface RegistryConfig { + url: string + internalUrl: string + admin: string + adminPassword: string + ruleTemplate: string + ruleCount?: number + retentionCron: string + robotRotationThresholdDays: number + projectSlugCacheTtlMs: number + internalOrPublicUrl?: string +} + +export interface RegistryModuleOptions extends RegistryConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('registry') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as REGISTRY_CONFIG } diff --git a/apps/server-nestjs/src/modules/registry/registry.module.ts b/apps/server-nestjs/src/modules/registry/registry.module.ts index b205b1babb..c93e994bce 100644 --- a/apps/server-nestjs/src/modules/registry/registry.module.ts +++ b/apps/server-nestjs/src/modules/registry/registry.module.ts @@ -1,19 +1,23 @@ import { CacheModule } from '@nestjs/cache-manager' import { Module } from '@nestjs/common' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' +import { ConfigurableModuleClass as HarborConfigurableModuleClass } from './harbor.module-definition' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { RegistryHealthService } from './registry-health.service' import { RegistryHttpClientService } from './registry-http-client.service' import { RegistryPluginService } from './registry-plugin.service' +import { ConfigurableModuleClass } from './registry.module-definition' import { RegistryService } from './registry.service' +@Module({}) +export class HarborModule extends HarborConfigurableModuleClass {} + @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule, CacheModule.register()], + imports: [DatabaseModule, TerminusModule, VaultModule, HarborModule, CacheModule.register()], providers: [RegistryHealthService, RegistryPluginService, RegistryService, RegistryDatastoreService, RegistryHttpClientService, RegistryClientService], exports: [RegistryHealthService, RegistryPluginService, RegistryService], }) -export class RegistryModule {} +export class RegistryModule extends ConfigurableModuleClass {} diff --git a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts index 9fde08c567..94cf3b9d05 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -1,13 +1,18 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from '../vault/vault.module-definition' +import type { HarborConfig } from './harbor.module-definition' import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { VaultClientService } from '../vault/vault-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' +import { VAULT_CONFIG } from '../vault/vault.module-definition' +import { HARBOR_CONFIG } from './harbor.module-definition' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { makeCreatedResponse, makeNoContent, makeOkResponse, makeProjectWithDetails } from './registry-testing.utils' @@ -24,7 +29,9 @@ describe('registryService', () => { let client: DeepMockProxy let datastore: DeepMockProxy let vault: DeepMockProxy - let config: DeepMockProxy + let harborConfig: DeepMockProxy + let baseConfig: DeepMockProxy + let vaultConfig: DeepMockProxy beforeEach(async () => { client = mockDeep({ @@ -52,17 +59,20 @@ describe('registryService', () => { })), write: vi.fn().mockResolvedValue(undefined), }) - config = mockDeep({ - harborUrl: 'https://harbor.example', - harborInternalUrl: 'https://harbor.example', - harborAdmin: 'admin', - harborAdminPassword: faker.internet.password(), - harborRuleTemplate: 'latestPushedK', - harborRuleCount: '10', - harborRetentionCron: '0 22 2 * * *', - harborRobotRotationThresholdDays: 90, - projectRootDir: 'forge', + harborConfig = mockDeep({ + url: 'https://harbor.example', + internalUrl: 'https://harbor.example', + admin: 'admin', + adminPassword: faker.internet.password(), + ruleTemplate: 'latestPushedK', + ruleCount: '10', + retentionCron: '0 22 2 * * *', + robotRotationThresholdDays: 90, }) + baseConfig = mockDeep({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep({}) const module = await Test.createTestingModule({ providers: [ @@ -70,7 +80,9 @@ describe('registryService', () => { { provide: RegistryClientService, useValue: client }, { provide: RegistryDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: config }, + { provide: HARBOR_CONFIG, useValue: harborConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/registry/registry.service.ts b/apps/server-nestjs/src/modules/registry/registry.service.ts index cd675f32da..43dc140455 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -1,5 +1,7 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { VaultSecret } from '../vault/vault-client.service' +import type { HarborConfig } from './harbor.module-definition' import type { HarborAccess, HarborGroupMemberRequest, @@ -14,12 +16,13 @@ import { specificallyEnabled } from '@cpn-console/hooks' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { find } from '../../utils/iterable' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { find } from '../../utils/iterable.utils' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' import { VaultError } from '../vault/vault-http-client.service' +import { HARBOR_CONFIG } from './harbor.module-definition' import { RegistryClientService, roAccess, rwAccess } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { @@ -55,18 +58,16 @@ export class RegistryService { constructor( @Inject(RegistryClientService) private readonly client: RegistryClientService, - @Inject(RegistryDatastoreService) private readonly registryDatastore: RegistryDatastoreService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(RegistryDatastoreService) private readonly datastore: RegistryDatastoreService, + @Inject(HARBOR_CONFIG) private readonly harborConfig: HarborConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, @Inject(VaultClientService) private readonly vault: VaultClientService, ) { this.logger.log('RegistryService initialized') } private get host() { - if (!this.config.harborUrl) { - throw new Error('HARBOR_URL is required') - } - return getHostFromUrl(this.config.harborUrl) + return getHostFromUrl(this.harborConfig.url) } private async getRobot(project: ProjectWithDetails, harborProjectId: number, robotName: string) { @@ -103,11 +104,8 @@ export class RegistryService { 'project.slug': project.slug, 'registry.robot.name': robotName, }) - if (!this.config.projectRootDir) { - throw new Error('PROJECTS_ROOT_DIR is required') - } const relativeVaultPath = `REGISTRY/${robotName}` - const vaultPath = getProjectVaultPath(project, this.config.projectRootDir, relativeVaultPath) + const vaultPath = getProjectVaultPath(project, this.baseConfig.projectsRootDir, relativeVaultPath) const vaultRobotSecret = await this.vault.read(vaultPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return null throw error @@ -142,7 +140,7 @@ export class RegistryService { const createdTimeRaw = vaultSecret?.metadata?.created_time if (!createdTimeRaw) return false const createdTime = new Date(createdTimeRaw) - return daysAgoFromNow(createdTime) > this.config.harborRobotRotationThresholdDays + return daysAgoFromNow(createdTime) > this.harborConfig.robotRotationThresholdDays } private async ensureProjectGroupMember( @@ -252,9 +250,9 @@ export class RegistryService { 'registry.project.id': harborProjectId, }) const policy = generateRetentionPolicy(harborProjectId, { - harborRuleTemplate: this.config.harborRuleTemplate, - harborRuleCount: this.config.harborRuleCount, - harborRetentionCron: this.config.harborRetentionCron, + harborRuleTemplate: this.harborConfig.ruleTemplate, + harborRuleCount: this.harborConfig.ruleCount, + harborRetentionCron: this.harborConfig.retentionCron, }) const retentionId = await this.client.getRetentionId(project.slug) span?.setAttribute('registry.retention.exists', !!retentionId) @@ -346,13 +344,13 @@ export class RegistryService { async handleCron() { const span = trace.getActiveSpan() this.logger.log('Starting Registry reconciliation') - const projects = await this.registryDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() span?.setAttribute('registry.projects.count', projects.length) await Promise.all(projects.map(p => this.ensureProject(p))) } private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string) { - const adminPluginConfig = await this.registryDatastore.getAdminPluginConfig(PLUGIN_NAME, key) + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) if (adminPluginConfig) return adminPluginConfig return getPluginConfig(project, key) } diff --git a/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts b/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts index 26b0cfe6af..51e59317ee 100644 --- a/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts @@ -1,10 +1,15 @@ import type { TestingModule } from '@nestjs/testing' import type { Dispatcher, RequestInit } from 'undici' +import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { OpenCdsConfig } from '../opencds/opencds.module-definition' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' import { Agent, fetch, Headers, ProxyAgent, Response } from 'undici' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { mockDeep } from 'vitest-mock-extended' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { OPENCDS_CONFIG } from '../opencds/opencds.module-definition' import { OpenCdsClientError, OpenCdsClientService } from './open-cds-client.service' vi.mock('undici', async (importOriginal) => { @@ -30,7 +35,8 @@ function getLastFetchCall(): [string, RequestInit] { describe('openCdsClientService', () => { let module: TestingModule let service: OpenCdsClientService - let config: Partial + let openCdsConfig: Partial + let baseConfig: DeepMockProxy let tlsDispatcher: Pick let proxyDispatcher: Pick @@ -52,16 +58,18 @@ describe('openCdsClientService', () => { vi.mocked(Agent).mockImplementation(MockAgent as any) vi.mocked(ProxyAgent).mockImplementation(ProxyMockAgent as any) - config = { - openCdsUrl: 'https://opencds.example.com/root/api/', - openCdsApiToken: 'test-token', - openCdsApiTlsRejectUnauthorized: true, + openCdsConfig = { + url: 'https://opencds.example.com/root/api/', + apiToken: 'test-token', + apiTlsRejectUnauthorized: true, } + baseConfig = mockDeep({ httpProxy: undefined }) module = await Test.createTestingModule({ providers: [ OpenCdsClientService, - { provide: ConfigurationService, useValue: config }, + { provide: OPENCDS_CONFIG, useValue: openCdsConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, ], }).compile() @@ -93,7 +101,7 @@ describe('openCdsClientService', () => { }) it('uses ProxyAgent when HTTP_PROXY is configured and preserves TLS settings for the upstream request', async () => { - vi.stubEnv('HTTP_PROXY', 'http://proxy.internal:3128') + baseConfig.httpProxy = 'http://proxy.internal:3128' mockFetchResponse(new Response(JSON.stringify({ ok: true }), { status: HttpStatus.OK, headers: { @@ -174,12 +182,6 @@ describe('openCdsClientService', () => { expect(new Headers(init.headers).get('Content-Type')).toBe('application/json') }) - it('throws when OpenCDS is disabled', async () => { - config.openCdsUrl = undefined - - await expect(service.get('/requests')).rejects.toThrow('OpenCDS is disabled') - }) - it('throws a dedicated error with HTTP status context for non-OK responses', async () => { mockFetchResponse(new Response('upstream failure', { status: HttpStatus.BAD_GATEWAY, diff --git a/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts b/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts index b018e8d450..c2760046d2 100644 --- a/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts +++ b/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts @@ -1,13 +1,13 @@ import type { HttpStatus } from '@nestjs/common' import type { Dispatcher, HeadersInit } from 'undici' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { OpenCdsConfig } from '../opencds/opencds.module-definition' import { Inject, Injectable, Logger } from '@nestjs/common' import { Agent, fetch, Headers, ProxyAgent } from 'undici' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' +import { OPENCDS_CONFIG } from '../opencds/opencds.module-definition' import { throwIfNotOk } from './service-chain.utils' -const openCdsDisabledMessage - = 'OpenCDS is disabled, please set OPENCDS_URL in your relevant .env file. See .env-example' - const URL_REGEX = /^https?:\/\// const START_SLASHES_REGEX = /^\/+/ const END_SLASHES_REGEX = /\/+$/ @@ -25,14 +25,14 @@ export class OpenCdsClientError extends Error { public readonly body?: string, ) { super(`OpenCDS request failed with ${status} ${statusText}`) - this.name = 'OpenCdsClientError' } } @Injectable() export class OpenCdsClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(OPENCDS_CONFIG) private readonly opencdsConfig: OpenCdsConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, ) {} private readonly logger = new Logger(OpenCdsClientService.name) @@ -77,13 +77,11 @@ export class OpenCdsClientService { path: string, query?: OpenCdsRequestOptions['query'], ): string { - if (!this.config.openCdsUrl) { - throw new Error(openCdsDisabledMessage) - } + if (!this.opencdsConfig.url) throw new Error('OpenCDS is disabled') const resolvedPath = URL_REGEX.test(path) ? path - : `${this.config.openCdsUrl.replace(END_SLASHES_REGEX, '')}/${path.replace(START_SLASHES_REGEX, '')}` + : `${this.opencdsConfig.url.replace(END_SLASHES_REGEX, '')}/${path.replace(START_SLASHES_REGEX, '')}` const url = new URL(resolvedPath) @@ -101,7 +99,7 @@ export class OpenCdsClientService { hasJsonBody = false, ): Headers { const mergedHeaders = new Headers(headers) - mergedHeaders.set('X-API-Key', this.config.openCdsApiToken ?? '') + mergedHeaders.set('X-API-Key', this.opencdsConfig.apiToken ?? '') if (hasJsonBody) { mergedHeaders.set('Content-Type', 'application/json') @@ -111,18 +109,20 @@ export class OpenCdsClientService { } private buildDispatcher(): Dispatcher { - if (process.env.HTTP_PROXY) { + const httpProxy = this.baseConfig.httpProxy + + if (httpProxy) { return new ProxyAgent({ requestTls: { - rejectUnauthorized: this.config.openCdsApiTlsRejectUnauthorized, + rejectUnauthorized: this.opencdsConfig.apiTlsRejectUnauthorized, }, - uri: process.env.HTTP_PROXY, + uri: httpProxy, }) } return new Agent({ connect: { - rejectUnauthorized: this.config.openCdsApiTlsRejectUnauthorized, + rejectUnauthorized: this.opencdsConfig.apiTlsRejectUnauthorized, }, }) } diff --git a/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts b/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts index 537261b97a..52964ab4b2 100644 --- a/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts +++ b/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' +import { openCdsConfigFactory } from '../../config/opencds.config' import { AuthModule } from '../infrastructure/auth/auth.module' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { EventsModule } from '../infrastructure/events/events.module' import { UserPermissionModule } from '../infrastructure/permission/user/user.module' @@ -11,10 +13,11 @@ import { ServiceChainService } from './service-chain.service' @Module({ imports: [ AuthModule, - ConfigurationModule, DatabaseModule, EventsModule, UserPermissionModule, + ConfigModule.forFeature(openCdsConfigFactory), + ConfigModule.forFeature(baseConfigFactory), ], controllers: [ServiceChainController], providers: [OpenCdsClientService, ServiceChainService], diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts index 3d8addfdab..26a5d114c4 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts @@ -1,14 +1,15 @@ import type { AddPermissionGroupParams, CreateUserParams, DeactivateUserParams, RevokeUserTokenParams } from './sonarqube-client.service' +import type { SonarqubeConfig } from './sonarqube.module-definition' import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { SonarqubeClientService } from './sonarqube-client.service' import { SonarqubeHttpClientService } from './sonarqube-http-client.service' import { makeSonarqubeGeneratedToken, makeSonarqubeGroup, makeSonarqubePaging, makeSonarqubeProject, makeSonarqubeUser } from './sonarqube-testing.utils' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' const sonarUrl = 'https://sonarqube.internal' const sonarToken = 'my-token' @@ -18,20 +19,20 @@ const server = setupServer() describe('sonarqubeClientService', () => { let service: SonarqubeClientService - let config: ReturnType> + let config: ReturnType> beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - config = mockDeep({ - sonarApiToken: sonarToken, - getInternalOrPublicSonarqubeUrl: () => sonarUrl, + config = mockDeep({ + apiToken: sonarToken, + internalOrPublicUrl: sonarUrl, }) const module = await Test.createTestingModule({ providers: [ SonarqubeClientService, SonarqubeHttpClientService, - { provide: ConfigurationService, useValue: config }, + { provide: SONARQUBE_CONFIG, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts index 25f2cc5a11..8f77620a8c 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -1,29 +1,22 @@ +import type { SonarqubeConfig } from './sonarqube.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' @Injectable() export class SonarqubeHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - const urlBase = this.config.getInternalOrPublicSonarqubeUrl() - if (!urlBase) return indicator.down('Not configured') - - const url = new URL('/api/system/health', urlBase).toString() - const token = this.config.sonarApiToken const headers: Record = {} - if (token) { - const bearerToken = Buffer.from(`${token}:`, 'utf8').toString('base64') - headers.Authorization = `Bearer ${bearerToken}` - } - + const bearerToken = Buffer.from(`${this.sonarqubeConfig.apiToken}:`, 'utf-8').toString('base64') + headers.Authorization = `Bearer ${bearerToken}` try { - const response = await fetch(url, { headers }) + const response = await fetch(this.sonarqubeConfig.probeUrl, { headers }) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts index 9318e5d569..6ee07f8585 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts @@ -1,6 +1,7 @@ +import type { SonarqubeConfig } from './sonarqube.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' export interface SonarqubeFetchOptions { method?: string @@ -37,23 +38,17 @@ export class SonarqubeError extends Error { @Injectable() export class SonarqubeHttpClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, ) {} - private get baseUrl(): string { - const url = this.config.getInternalOrPublicSonarqubeUrl() - if (!url) throw new SonarqubeError('NotConfigured', 'SONARQUBE_URL or SONARQUBE_INTERNAL_URL is required') - return url - } - private get apiBaseUrl(): string { - return new URL('api/', this.baseUrl).toString() + return new URL('api/', this.sonarqubeConfig.internalOrPublicUrl).toString() } private get defaultHeaders(): Record { - if (!this.config.sonarApiToken) throw new SonarqubeError('NotConfigured', 'SONAR_API_TOKEN is required') + if (!this.sonarqubeConfig.apiToken) throw new SonarqubeError('NotConfigured', 'SONAR_API_TOKEN is required') return { - Authorization: `Bearer ${this.config.sonarApiToken}`, + Authorization: `Bearer ${this.sonarqubeConfig.apiToken}`, } } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts index b1f1c5fbda..671a19562a 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts @@ -1,25 +1,26 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { SonarqubeConfig } from './sonarqube.module-definition' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { makeToUrlParams } from '../plugin/plugin.utils' import { SonarqubePluginService } from './sonarqube-plugin.service' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' describe('sonarqubePluginService', () => { let service: SonarqubePluginService - let config: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { - config = mockDeep({ - sonarqubeUrl: 'https://sonar.public/', - sonarqubeInternalUrl: 'https://sonar.internal/', + config = mockDeep({ + url: 'https://sonar.public/', + internalUrl: 'https://sonar.internal/', }) const moduleRef = await Test.createTestingModule({ providers: [ SonarqubePluginService, - { provide: ConfigurationService, useValue: config }, + { provide: SONARQUBE_CONFIG, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts index 9a31e60496..d1ffd9387e 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts @@ -1,21 +1,19 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { SonarqubeConfig } from './sonarqube.module-definition' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' @Injectable() export class SonarqubePluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(SONARQUBE_CONFIG) + private readonly sonarqubeConfig: SonarqubeConfig, ) {} infos(): ServiceInfos { return { name: 'sonarqube', - to: () => { - if (!this.config.sonarqubeUrl) return undefined - return new URL('projects', this.config.sonarqubeUrl).toString() - }, + to: () => new URL('projects', this.sonarqubeConfig.url).toString(), title: 'SonarQube', imgSrc: '/img/sonarqube.svg', description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts new file mode 100644 index 0000000000..ef4dac617c --- /dev/null +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module-definition.ts @@ -0,0 +1,16 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface SonarqubeConfig { + url: string + internalUrl: string + apiToken: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface SonarqubeModuleOptions extends SonarqubeConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('sonarqube') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as SONARQUBE_CONFIG } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts index 17499fdda4..f054302ff4 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { SonarqubeClientService } from './sonarqube-client.service' @@ -8,10 +7,11 @@ import { SonarqubeDatastoreService } from './sonarqube-datastore.service' import { SonarqubeHealthService } from './sonarqube-health.service' import { SonarqubeHttpClientService } from './sonarqube-http-client.service' import { SonarqubePluginService } from './sonarqube-plugin.service' +import { ConfigurableModuleClass } from './sonarqube.module-definition' import { SonarqubeService } from './sonarqube.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], + imports: [DatabaseModule, TerminusModule, VaultModule], providers: [ SonarqubeHealthService, SonarqubeHttpClientService, @@ -22,4 +22,4 @@ import { SonarqubeService } from './sonarqube.service' ], exports: [SonarqubeClientService, SonarqubeHealthService, SonarqubePluginService, SonarqubeService], }) -export class SonarqubeModule {} +export class SonarqubeModule extends ConfigurableModuleClass {} diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts index 19721a8cc6..e7d0ef90e1 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -1,9 +1,9 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { SonarqubeConfig } from './sonarqube.module-definition' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { generateProjectKey } from '../../utils/crypto' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { generateProjectKey } from '../../utils/crypto.utils' import { VaultClientService } from '../vault/vault-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' import { SonarqubeClientService } from './sonarqube-client.service' @@ -18,6 +18,7 @@ import { makeUserToken, } from './sonarqube-testing.utils' import { PLUGIN_NAME, SONARQUBE_PROJECT_QUALIFIER_PROJECT } from './sonarqube.constants' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' import { SonarqubeService } from './sonarqube.service' describe('sonarqubeService', () => { @@ -25,7 +26,7 @@ describe('sonarqubeService', () => { let client: DeepMockProxy let datastore: DeepMockProxy let vault: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { client = mockDeep({ @@ -34,7 +35,6 @@ describe('sonarqubeService', () => { createPermissionTemplate: vi.fn().mockResolvedValue(undefined), searchPermissionTemplates: vi.fn().mockResolvedValue({ permissionTemplates: [] }), setPermissionDefaultTemplate: vi.fn().mockResolvedValue(undefined), - addPermissionProjectCreatorToTemplate: vi.fn().mockResolvedValue(undefined), addPermissionGroupToTemplate: vi.fn().mockResolvedValue(undefined), addPermissionGroup: vi.fn().mockResolvedValue(undefined), addPermissionUser: vi.fn().mockResolvedValue(undefined), @@ -54,9 +54,8 @@ describe('sonarqubeService', () => { writeSonarqubeUser: vi.fn().mockResolvedValue(undefined), deleteSonarqubeUser: vi.fn().mockResolvedValue(undefined), }) - config = mockDeep({ - projectRootDir: 'forge', - getInternalOrPublicSonarqubeUrl: vi.fn().mockReturnValue('https://sonarqube.internal'), + config = mockDeep({ + internalOrPublicUrl: 'https://sonarqube.internal', }) const moduleRef = await Test.createTestingModule({ @@ -65,7 +64,7 @@ describe('sonarqubeService', () => { { provide: SonarqubeClientService, useValue: client }, { provide: SonarqubeDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: config }, + { provide: SONARQUBE_CONFIG, useValue: config }, ], }).compile() @@ -113,14 +112,6 @@ describe('sonarqubeService', () => { expect(client.createUserGroup).not.toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) }) - it('should skip initialization when URL is not configured', async () => { - config.getInternalOrPublicSonarqubeUrl.mockReturnValue(undefined) - - await service.init() - - expect(client.createPermissionTemplate).not.toHaveBeenCalled() - }) - it('should use custom group paths from admin plugin config', async () => { datastore.getAdminPluginConfig.mockImplementation((_plugin, key) => { if (key === 'adminGroupPath') return Promise.resolve('/custom/admin') diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts index fc36f6c663..a4500dcca9 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -3,11 +3,11 @@ import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { SonarqubeUserSecret } from '../vault/vault-client.service' import type { SonarqubeProjectResult, SonarqubeUser } from './sonarqube-client.service' import type { ProjectWithDetails } from './sonarqube-datastore.service' +import type { SonarqubeConfig } from './sonarqube.module-definition' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { generateProjectKey, generateRandomPassword } from '../../utils/crypto' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { generateProjectKey, generateRandomPassword } from '../../utils/crypto.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -41,6 +41,7 @@ import { ROBOT_PROJECT_PERMISSIONS, SECURITY_GROUP_PATH_PLUGIN_KEY, } from './sonarqube.constants' +import { SONARQUBE_CONFIG } from './sonarqube.module-definition' interface SonarqubeRolePaths { admin: string[] @@ -57,7 +58,7 @@ export class SonarqubeService implements OnModuleInit { constructor( @Inject(SonarqubeDatastoreService) private readonly datastore: SonarqubeDatastoreService, @Inject(SonarqubeClientService) private readonly client: SonarqubeClientService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(SONARQUBE_CONFIG) private readonly sonarqubeConfig: SonarqubeConfig, @Inject(VaultClientService) private readonly vault: VaultClientService, ) { this.logger.log('SonarqubeService initialized') @@ -69,10 +70,6 @@ export class SonarqubeService implements OnModuleInit { @StartActiveSpan() async init(): Promise { - if (!this.config.getInternalOrPublicSonarqubeUrl()) { - this.logger.warn('SonarQube URL not configured — skipping initialization') - return - } this.logger.log('Initializing SonarQube platform configuration') const adminGroupPath = await this.getAdminGroupPath() const [readonlyGroupPath, securityGroupPath] = await Promise.all([ diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts index 54db1c52b4..d6cce4272f 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts @@ -1,12 +1,15 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { VaultClientService } from './vault-client.service' import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { VAULT_CONFIG } from './vault.module-definition' const vaultUrl = 'https://vault.internal' @@ -30,19 +33,23 @@ describe('vault', () => { beforeAll(() => server.listen()) beforeEach(async () => { - const config = mockDeep({ - vaultToken: 'token', - vaultUrl, - vaultInternalUrl: vaultUrl, - vaultKvName: 'kv', - getInternalOrPublicVaultUrl: () => vaultUrl, + const config = mockDeep({ + token: 'token', + url: vaultUrl, + internalUrl: vaultUrl, + kvName: 'kv', + internalOrPublicUrl: vaultUrl, + }) + const baseConfig = mockDeep({ + projectsRootDir: 'forge', }) const module = await Test.createTestingModule({ providers: [ VaultClientService, VaultHttpClientService, - { provide: ConfigurationService, useValue: config }, + { provide: VAULT_CONFIG, useValue: config }, + { provide: BASE_CONFIG, useValue: baseConfig }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.ts index b49868d93f..dd07abebbd 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -1,8 +1,11 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' import { Inject, Injectable, Logger } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { VaultError, VaultHttpClientService } from './vault-http-client.service' +import { VAULT_CONFIG } from './vault.module-definition' import { generateGitlabMirrorCredPath, generateSonarqubeCredPath, generateTechReadOnlyCredPath } from './vault.utils' export interface VaultSysPoliciesAclUpsertRequest { @@ -114,7 +117,8 @@ export class VaultClientService { private readonly logger = new Logger(VaultClientService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, @Inject(VaultHttpClientService) private readonly http: VaultHttpClientService, ) { } @@ -144,13 +148,13 @@ export class VaultClientService { @StartActiveSpan() async read(path: string): Promise> { this.logger.debug(`Reading Vault KV secret at ${path}`) - return await this.getKvData(this.config.vaultKvName, path) + return await this.getKvData(this.vaultConfig.kvName, path) } @StartActiveSpan() async write(data: T, path: string): Promise { this.logger.debug(`Writing Vault KV secret at ${path}`) - await this.upsertKvData(this.config.vaultKvName, path, { data }) + await this.upsertKvData(this.vaultConfig.kvName, path, { data }) } @StartActiveSpan() @@ -158,18 +162,16 @@ export class VaultClientService { this.logger.debug(`Deleting Vault KV secret at ${path}`) const span = trace.getActiveSpan() span?.setAttribute('vault.kv.path', path) - return await this.deleteKvMetadata(this.config.vaultKvName, path) + return await this.deleteKvMetadata(this.vaultConfig.kvName, path) } @StartActiveSpan() async readGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { - const vaultCredsPath = generateGitlabMirrorCredPath(this.config.projectRootDir, projectSlug, repoName) + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'repo.name': repoName, - 'vault.kv.path': vaultCredsPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) this.logger.verbose(`Reading Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) return await this.read(vaultCredsPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return null @@ -179,26 +181,22 @@ export class VaultClientService { @StartActiveSpan() async writeGitlabMirrorCreds(projectSlug: string, repoName: string, data: Record): Promise { - const vaultCredsPath = generateGitlabMirrorCredPath(this.config.projectRootDir, projectSlug, repoName) + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'repo.name': repoName, - 'vault.kv.path': vaultCredsPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) this.logger.verbose(`Writing Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) await this.write(data, vaultCredsPath) } @StartActiveSpan() async deleteGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { - const vaultCredsPath = generateGitlabMirrorCredPath(this.config.projectRootDir, projectSlug, repoName) + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'repo.name': repoName, - 'vault.kv.path': vaultCredsPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) this.logger.verbose(`Deleting Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) await this.delete(vaultCredsPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return @@ -208,12 +206,10 @@ export class VaultClientService { @StartActiveSpan() async readTechnReadOnlyCreds(projectSlug: string): Promise { - const vaultPath = generateTechReadOnlyCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) return await this.read(vaultPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return null throw error @@ -222,23 +218,19 @@ export class VaultClientService { @StartActiveSpan() async writeTechReadOnlyCreds(projectSlug: string, creds: Record): Promise { - const vaultPath = generateTechReadOnlyCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) await this.write(creds, vaultPath) } @StartActiveSpan() async readSonarqubeUser(projectSlug: string): Promise | null> { - const vaultPath = generateSonarqubeCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) this.logger.verbose(`Reading Vault SonarQube user credentials (projectSlug=${projectSlug})`) return await this.read(vaultPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return null @@ -248,24 +240,20 @@ export class VaultClientService { @StartActiveSpan() async writeSonarqubeUser(projectSlug: string, secret: SonarqubeUserSecret): Promise { - const vaultPath = generateSonarqubeCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) this.logger.verbose(`Writing Vault SonarQube user credentials (projectSlug=${projectSlug})`) await this.write(secret, vaultPath) } @StartActiveSpan() async deleteSonarqubeUser(projectSlug: string): Promise { - const vaultPath = generateSonarqubeCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) this.logger.verbose(`Deleting Vault SonarQube user credentials (projectSlug=${projectSlug})`) await this.delete(vaultPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return diff --git a/apps/server-nestjs/src/modules/vault/vault-health.service.ts b/apps/server-nestjs/src/modules/vault/vault-health.service.ts index 649770a558..daddabd5b4 100644 --- a/apps/server-nestjs/src/modules/vault/vault-health.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-health.service.ts @@ -1,22 +1,19 @@ +import type { VaultConfig } from './vault.module-definition' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { VAULT_CONFIG } from './vault.module-definition' @Injectable() export class VaultHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - const urlBase = this.config.getInternalOrPublicVaultUrl() - if (!urlBase) return indicator.down('Not configured') - - const url = new URL('/v1/sys/health', urlBase).toString() try { - const response = await fetch(url) + const response = await fetch(this.vaultConfig.probeUrl) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { diff --git a/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts index 6cba06e0b5..2a80532efe 100644 --- a/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts @@ -1,8 +1,9 @@ +import type { VaultConfig } from './vault.module-definition' import { HttpStatus, Inject, Injectable, Logger } from '@nestjs/common' import { trace } from '@opentelemetry/api' import z from 'zod' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { VAULT_CONFIG } from './vault.module-definition' export interface VaultFetchOptions { method?: string @@ -46,7 +47,7 @@ export class VaultHttpClientService { private readonly logger = new Logger(VaultHttpClientService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, ) {} @StartActiveSpan() @@ -82,31 +83,15 @@ export class VaultHttpClientService { return parsed } - private get baseUrl() { - const baseUrl = this.config.getInternalOrPublicVaultUrl() - if (!baseUrl) { - throw new VaultError('NotConfigured', 'VAULT_INTERNAL_URL or VAULT_URL is required') - } - return baseUrl - } - private get apiBaseUrl() { - return new URL('v1/', this.baseUrl).toString() - } - - private get token() { - if (!this.config.vaultToken) { - this.logger.warn('Vault token is not configured (VAULT_TOKEN is missing)') - throw new VaultError('NotConfigured', 'VAULT_TOKEN is required') - } - return this.config.vaultToken + return new URL('v1/', this.vaultConfig.internalOrPublicUrl).toString() } private createRequest(path: string, method: string, body?: unknown): Request { const url = new URL(path, this.apiBaseUrl).toString() const headers: Record = { 'Content-Type': 'application/json', - 'X-Vault-Token': this.token, + 'X-Vault-Token': this.vaultConfig.token, } return new Request(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }) diff --git a/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts index 1d482d0a66..a1d5e06eea 100644 --- a/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts @@ -1,25 +1,26 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { VaultConfig } from './vault.module-definition' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { makeToUrlParams } from '../plugin/plugin.utils' import { VaultPluginService } from './vault-plugin.service' +import { VAULT_CONFIG } from './vault.module-definition' describe('vaultPluginService', () => { let service: VaultPluginService - let config: DeepMockProxy + let config: DeepMockProxy beforeEach(async () => { - config = mockDeep({ - vaultUrl: 'https://vault.public/', - vaultInternalUrl: 'https://vault.internal/', + config = mockDeep({ + url: 'https://vault.public/', + internalUrl: 'https://vault.internal/', }) const moduleRef = await Test.createTestingModule({ providers: [ VaultPluginService, - { provide: ConfigurationService, useValue: config }, + { provide: VAULT_CONFIG, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts b/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts index 838747d885..0095ac34cf 100644 --- a/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts @@ -1,21 +1,18 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { VaultConfig } from './vault.module-definition' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { VAULT_CONFIG } from './vault.module-definition' @Injectable() export class VaultPluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, ) {} infos(): ServiceInfos { return { name: 'vault', - to: ({ project }) => { - if (!this.config.vaultUrl) return undefined - return new URL(`ui/vault/secrets/${project.slug}`, this.config.vaultUrl).toString() - }, + to: ({ project }) => new URL(`ui/vault/secrets/${project.slug}`, this.vaultConfig.url).toString(), title: 'Vault', imgSrc: '/img/vault.svg', description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', diff --git a/apps/server-nestjs/src/modules/vault/vault.module-definition.ts b/apps/server-nestjs/src/modules/vault/vault.module-definition.ts new file mode 100644 index 0000000000..d0f1b425cf --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault.module-definition.ts @@ -0,0 +1,17 @@ +import { createConfigurableModule } from '../configurable-feature-module' + +export interface VaultConfig { + token?: string + url: string + internalUrl: string + kvName: string + internalOrPublicUrl?: string + probeUrl?: string +} + +export interface VaultModuleOptions extends VaultConfig {} + +const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = createConfigurableModule('vault') + +export { ConfigurableModuleClass } +export { MODULE_OPTIONS_TOKEN as VAULT_CONFIG } diff --git a/apps/server-nestjs/src/modules/vault/vault.module.ts b/apps/server-nestjs/src/modules/vault/vault.module.ts index 0b1d8281ea..367a1527a2 100644 --- a/apps/server-nestjs/src/modules/vault/vault.module.ts +++ b/apps/server-nestjs/src/modules/vault/vault.module.ts @@ -1,16 +1,19 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultClientService } from './vault-client.service' import { VaultDatastoreService } from './vault-datastore.service' import { VaultHealthService } from './vault-health.service' import { VaultHttpClientService } from './vault-http-client.service' import { VaultPluginService } from './vault-plugin.service' +import { ConfigurableModuleClass } from './vault.module-definition' import { VaultService } from './vault.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule], + imports: [DatabaseModule, TerminusModule, ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], providers: [ VaultHealthService, VaultHttpClientService, @@ -21,4 +24,4 @@ import { VaultService } from './vault.service' ], exports: [VaultClientService, VaultHealthService, VaultPluginService, VaultService], }) -export class VaultModule {} +export class VaultModule extends ConfigurableModuleClass {} diff --git a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts index 4a61c8f3c2..d46cb7b75c 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -1,12 +1,15 @@ import type { DeepMockProxy } from 'vitest-mock-extended' +import type { BaseConfig } from '../infrastructure/config/base.config' +import type { VaultConfig } from './vault.module-definition' import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.module' import { VaultClientService } from './vault-client.service' import { VaultDatastoreService } from './vault-datastore.service' import { makeProjectWithDetails, makeVaultSecret, makeZoneWithDetails } from './vault-testing.utils' +import { VAULT_CONFIG } from './vault.module-definition' import { VaultService } from './vault.service' const projectRoleGroupNameRegex = /^project-(.*)-(admin|devops|developer|readonly|security)$/ @@ -15,7 +18,6 @@ describe('vaultService', () => { let service: VaultService let datastore: DeepMockProxy let client: DeepMockProxy - let config: DeepMockProxy beforeEach(async () => { datastore = mockDeep({ @@ -36,17 +38,17 @@ describe('vaultService', () => { listKvMetadata: vi.fn().mockResolvedValue([]), delete: vi.fn().mockResolvedValue(undefined), }) - config = mockDeep({ - projectRootDir: 'forge', - vaultKvName: 'kv', - }) + + const vaultConfig = mockDeep({ kvName: 'kv' }) + const baseConfig = mockDeep({ projectsRootDir: 'forge' }) const module = await Test.createTestingModule({ providers: [ VaultService, { provide: VaultClientService, useValue: client }, { provide: VaultDatastoreService, useValue: datastore }, - { provide: ConfigurationService, useValue: config }, + { provide: VAULT_CONFIG, useValue: vaultConfig }, + { provide: BASE_CONFIG, useValue: baseConfig }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/vault/vault.service.ts b/apps/server-nestjs/src/modules/vault/vault.service.ts index 486c97cbd3..7128c24963 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -1,9 +1,11 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' +import type { VaultConfig } from './vault.module-definition' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from './vault-client.service' @@ -34,6 +36,7 @@ import { PROJECT_SECURITY_GROUP_PATH_SUFFIX_PLUGIN_KEY, SECURITY_GROUP_PATH_PLUGIN_KEY, } from './vault.constants' +import { VAULT_CONFIG } from './vault.module-definition' import { generateProjectPath } from './vault.utils' type ProjectScope = 'admin' | 'devops' | 'developer' | 'readonly' | 'security' @@ -43,8 +46,9 @@ export class VaultService { private readonly logger = new Logger(VaultService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, - @Inject(VaultDatastoreService) private readonly vaultDatastore: VaultDatastoreService, + @Inject(VAULT_CONFIG) private readonly vaultConfig: VaultConfig, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, + @Inject(VaultDatastoreService) private readonly datastore: VaultDatastoreService, @Inject(VaultClientService) private readonly client: VaultClientService, ) { this.logger.log('VaultService initialized') @@ -115,8 +119,8 @@ export class VaultService { const span = trace.getActiveSpan() this.logger.log('Starting Vault reconciliation') const [projects, zones] = await Promise.all([ - this.vaultDatastore.getAllProjects(), - this.vaultDatastore.getAllZones(), + this.datastore.getAllProjects(), + this.datastore.getAllZones(), ]) span?.setAttributes({ @@ -148,7 +152,7 @@ export class VaultService { } private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { - const adminPluginConfig = await this.vaultDatastore.getAdminPluginConfig(PLUGIN_NAME, key) + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) if (adminPluginConfig) return adminPluginConfig return project.plugins?.find(p => p.pluginName === PLUGIN_NAME && p.key === key)?.value } @@ -491,13 +495,13 @@ export class VaultService { async ensureTechReadOnlyPolicy(name: string, projectSlug: string): Promise { await this.client.upsertSysPoliciesAcl(name, { - policy: `path "${this.config.vaultKvName}/data/${projectSlug}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + policy: `path "${this.vaultConfig.kvName}/data/${projectSlug}/REGISTRY/ro-robot" { capabilities = ["read"] }`, }) } async listProjectSecrets(projectSlug: string): Promise { - const projectPath = generateProjectPath(this.config.projectRootDir, projectSlug) - return this.listRecursive(this.config.vaultKvName, projectPath, '') + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) + return this.listRecursive(this.vaultConfig.kvName, projectPath, '') } @StartActiveSpan() @@ -505,12 +509,12 @@ export class VaultService { const span = trace.getActiveSpan() span?.setAttributes({ 'project.slug': projectSlug, - 'vault.kv.name': this.config.vaultKvName, + 'vault.kv.name': this.vaultConfig.kvName, }) const secrets = await this.listProjectSecrets(projectSlug) span?.setAttribute('vault.secrets.count', secrets.length) - const projectPath = generateProjectPath(this.config.projectRootDir, projectSlug) + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) await Promise.allSettled(secrets.map(async (relativePath) => { const fullPath = `${projectPath}/${relativePath}` try { diff --git a/apps/server-nestjs/src/modules/vault/vault.utils.ts b/apps/server-nestjs/src/modules/vault/vault.utils.ts index 576da08e24..6e170bede2 100644 --- a/apps/server-nestjs/src/modules/vault/vault.utils.ts +++ b/apps/server-nestjs/src/modules/vault/vault.utils.ts @@ -1,23 +1,15 @@ -export function generateProjectPath(projectRootDir: string | undefined, projectSlug: string) { - return projectRootDir - ? `${projectRootDir}/${projectSlug}` - : projectSlug +export function generateProjectPath(projectRootDir: string, projectSlug: string) { + return `${projectRootDir}/${projectSlug}` } -export function generateGitlabMirrorCredPath(projectRootDir: string | undefined, projectSlug: string, repoName: string) { - return projectRootDir - ? `${generateProjectPath(projectRootDir, projectSlug)}/${repoName}-mirror` - : `${projectSlug}/${repoName}-mirror` +export function generateGitlabMirrorCredPath(projectRootDir: string, projectSlug: string, repoName: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/${repoName}-mirror` } -export function generateTechReadOnlyCredPath(projectRootDir: string | undefined, projectSlug: string) { - return projectRootDir - ? `${generateProjectPath(projectRootDir, projectSlug)}/tech/GITLAB_MIRROR` - : `${projectSlug}/tech/GITLAB_MIRROR` +export function generateTechReadOnlyCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/tech/GITLAB_MIRROR` } -export function generateSonarqubeCredPath(projectRootDir: string | undefined, projectSlug: string) { - return projectRootDir - ? `${generateProjectPath(projectRootDir, projectSlug)}/SONAR` - : `${projectSlug}/SONAR` +export function generateSonarqubeCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/SONAR` } diff --git a/apps/server-nestjs/src/modules/version/version.controller.ts b/apps/server-nestjs/src/modules/version/version.controller.ts index 16ce328f70..7dc6a91019 100644 --- a/apps/server-nestjs/src/modules/version/version.controller.ts +++ b/apps/server-nestjs/src/modules/version/version.controller.ts @@ -1,15 +1,15 @@ +import type { BaseConfig } from '../infrastructure/config/base.config' import { Controller, Get, Inject } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { BASE_CONFIG } from '../infrastructure/config/base.config' @Controller('api/v1/version') export class VersionController { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(BASE_CONFIG) private readonly baseConfig: BaseConfig, ) {} @Get() getVersion() { - return { version: this.config.appVersion } + return { version: this.baseConfig.appVersion } } } diff --git a/apps/server-nestjs/src/modules/version/version.module.ts b/apps/server-nestjs/src/modules/version/version.module.ts index cdf9b30c39..c537037b5b 100644 --- a/apps/server-nestjs/src/modules/version/version.module.ts +++ b/apps/server-nestjs/src/modules/version/version.module.ts @@ -1,9 +1,7 @@ import { Module } from '@nestjs/common' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { VersionController } from './version.controller' @Module({ - imports: [ConfigurationModule], controllers: [VersionController], }) export class VersionModule {} diff --git a/apps/server-nestjs/src/utils/crypto.spec.ts b/apps/server-nestjs/src/utils/crypto.spec.ts index 19bad4e44c..090c58583a 100644 --- a/apps/server-nestjs/src/utils/crypto.spec.ts +++ b/apps/server-nestjs/src/utils/crypto.spec.ts @@ -1,6 +1,6 @@ import { generateProjectKey as legacyGenerateProjectKey } from '@cpn-console/hooks' import { describe, expect, it } from 'vitest' -import { generateProjectKey } from './crypto' +import { generateProjectKey } from './crypto.utils' describe('generateProjectKey', () => { it('matches the legacy @cpn-console/hooks implementation byte-for-byte', () => { diff --git a/apps/server-nestjs/src/utils/crypto.utils.ts b/apps/server-nestjs/src/utils/crypto.utils.ts new file mode 100644 index 0000000000..f12bebcd13 --- /dev/null +++ b/apps/server-nestjs/src/utils/crypto.utils.ts @@ -0,0 +1,18 @@ +import crypto, { createHmac } from 'node:crypto' + +export function generateRandomPassword(length = 24) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@-_#*' + return Array.from(crypto.getRandomValues(new Uint32Array(length)), x => chars[x % chars.length]) + .join('') +} + +// Must stay byte-for-byte identical to the legacy @cpn-console/hooks implementation: +// existing SonarQube project keys were generated with it, and ownership matching +// recomputes keys to decide which projects to reconcile or delete. +export function generateProjectKey(projectSlug: string, internalRepoName: string) { + const repoHash = createHmac('sha256', '') + .update(internalRepoName) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${internalRepoName}-${repoHash}` +} diff --git a/apps/server-nestjs/src/utils/dotenv.utils.spec.ts b/apps/server-nestjs/src/utils/dotenv.utils.spec.ts new file mode 100644 index 0000000000..b5c7014c46 --- /dev/null +++ b/apps/server-nestjs/src/utils/dotenv.utils.spec.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDotenvPaths, getExistingDotenvPaths } from './dotenv.utils' + +describe('dotenv.utils', () => { + afterEach(() => vi.unstubAllEnvs()) + + describe('getDotenvPaths', () => { + it('returns [".env"] when no env vars set', () => { + expect(getDotenvPaths()).toEqual(['.env']) + }) + + it('returns [".env", ".env.integ"] when INTEGRATION=true', () => { + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env', '.env.integ']) + }) + + it('returns [".env.docker"] when DOCKER=true', () => { + vi.stubEnv('DOCKER', 'true') + expect(getDotenvPaths()).toEqual(['.env.docker']) + }) + + it('returns [".env.integ", ".env.docker"] when DOCKER=true and INTEGRATION=true', () => { + vi.stubEnv('DOCKER', 'true') + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env.integ', '.env.docker']) + }) + }) + + describe('getExistingDotenvPaths', () => { + it('filters to only existing files', () => { + const paths = getExistingDotenvPaths() + expect(paths.every(p => typeof p === 'string')).toBe(true) + }) + }) +}) diff --git a/apps/server-nestjs/src/utils/dotenv.utils.ts b/apps/server-nestjs/src/utils/dotenv.utils.ts new file mode 100644 index 0000000000..7d3e0a0501 --- /dev/null +++ b/apps/server-nestjs/src/utils/dotenv.utils.ts @@ -0,0 +1,26 @@ +import fs from 'node:fs' + +export function getDotenvPaths(): string[] { + const paths: string[] = [] + + // Load .env unless DOCKER=true + if (process.env.DOCKER !== 'true') { + paths.push('.env') + } + + // Load .env.integ if INTEGRATION=true + if (process.env.INTEGRATION === 'true') { + paths.push('.env.integ') + } + + // Load .env.docker if DOCKER=true + if (process.env.DOCKER === 'true') { + paths.push('.env.docker') + } + + return paths +} + +export function getExistingDotenvPaths(): string[] { + return getDotenvPaths().filter(path => fs.existsSync(path)) +} diff --git a/apps/server-nestjs/src/utils/http.utils.ts b/apps/server-nestjs/src/utils/http.utils.ts new file mode 100644 index 0000000000..b85cc2f78b --- /dev/null +++ b/apps/server-nestjs/src/utils/http.utils.ts @@ -0,0 +1,20 @@ +/** HTTP status carried by errors such as keycloak-admin-client's NetworkError. */ +export function getErrorResponseStatus(error: unknown): number | undefined { + if (error instanceof Error && 'response' in error && error.response instanceof Response) { + return error.response.status + } + return undefined +} + +/** HTTP details (status, url, response body) attached to errors carrying a fetch Response. */ +export function getErrorHttpDetails(error: Error): Record { + const details: Record = {} + if ('response' in error && error.response instanceof Response) { + details.status = error.response.status + details.url = error.response.url + } + if ('responseData' in error && error.responseData !== undefined && error.responseData !== '') { + details.responseData = error.responseData + } + return details +} diff --git a/apps/server-nestjs/src/utils/iterable.utils.ts b/apps/server-nestjs/src/utils/iterable.utils.ts new file mode 100644 index 0000000000..a46ef80632 --- /dev/null +++ b/apps/server-nestjs/src/utils/iterable.utils.ts @@ -0,0 +1,26 @@ +export async function* map( + iterable: AsyncIterable, + mapper: (value: T, index: number) => U | Promise, +): AsyncIterable { + let index = 0 + for await (const value of iterable) { + yield await mapper(value, index++) + } +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} + +export async function find(generator: AsyncGenerator, predicate: (item: T) => boolean): Promise { + for await (const item of generator) { + if (predicate(item)) return item + } + return undefined +} diff --git a/apps/server-nestjs/test/argocd.e2e-spec.ts b/apps/server-nestjs/test/argocd.e2e-spec.ts index 381412c492..65e0ea7497 100644 --- a/apps/server-nestjs/test/argocd.e2e-spec.ts +++ b/apps/server-nestjs/test/argocd.e2e-spec.ts @@ -1,22 +1,24 @@ import type { CommitAction, Gitlab } from '@gitbeaker/core' import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { parse } from 'yaml' +import { baseConfigFactory } from '../src/config/base.config' import { projectSelect } from '../src/modules/argocd/argocd-datastore.service' import { ArgoCDModule } from '../src/modules/argocd/argocd.module' import { ArgoCDService } from '../src/modules/argocd/argocd.service' import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../src/modules/infrastructure/configuration/configuration.service' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunArgoCDE2E = Boolean(process.env.E2E) @@ -36,7 +38,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { let gitlabClient: Gitlab let vault: VaultClientService let prisma: PrismaService - let config: ConfigurationService + let config: BaseConfig let ownerId: string let testProjectId: string @@ -59,7 +61,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ArgoCDModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [ArgoCDModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() @@ -69,7 +71,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) vault = moduleRef.get(VaultClientService) prisma = moduleRef.get(PrismaService) - config = moduleRef.get(ConfigurationService) + config = moduleRef.get(baseConfigFactory) ownerId = faker.string.uuid() testProjectId = faker.string.uuid() @@ -186,7 +188,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { }, }) - infraRepoPath = `${config.projectRootDir}/infra/${zoneSlug}` + infraRepoPath = `${config.projectsRootDir}/infra/${zoneSlug}` try { const existing = await gitlabClient.Projects.show(infraRepoPath) if (existing.empty_repo || existing.default_branch !== 'main') { @@ -214,7 +216,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { infraRepoId = created.id } - vaultProjectValuesPath = `${config.projectRootDir}/${testProjectId}` + vaultProjectValuesPath = `${config.projectsRootDir}/${testProjectId}` await vault.write({ e2e: true }, vaultProjectValuesPath) }) diff --git a/apps/server-nestjs/test/gitlab.e2e-spec.ts b/apps/server-nestjs/test/gitlab.e2e-spec.ts index 0d65d2fcf3..e8d19c497b 100644 --- a/apps/server-nestjs/test/gitlab.e2e-spec.ts +++ b/apps/server-nestjs/test/gitlab.e2e-spec.ts @@ -1,22 +1,24 @@ import type { ExpandedUserSchema, Gitlab } from '@gitbeaker/core' import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' import { projectSelect } from '../src/modules/gitlab/gitlab-datastore.service' import { GitlabModule } from '../src/modules/gitlab/gitlab.module' import { GitlabService } from '../src/modules/gitlab/gitlab.service' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../src/modules/infrastructure/configuration/configuration.service' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunGitlabE2E = Boolean(process.env.E2E) @@ -36,7 +38,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { let gitlabClient: Gitlab let vaultService: VaultClientService let prisma: PrismaService - let config: ConfigurationService + let config: BaseConfig let testProjectId: string let testProjectSlug: string @@ -45,7 +47,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [GitlabModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [GitlabModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() @@ -55,7 +57,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) vaultService = moduleRef.get(VaultClientService) prisma = moduleRef.get(PrismaService) - config = moduleRef.get(ConfigurationService) + config = moduleRef.get(baseConfigFactory) ownerId = faker.string.uuid() testProjectId = faker.string.uuid() @@ -111,8 +113,8 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { afterAll(async () => { // Clean GitLab group - if (testProjectSlug && config.projectRootDir) { - const fullPath = `${config.projectRootDir}/${testProjectSlug}` + if (testProjectSlug && config.projectsRootDir) { + const fullPath = `${config.projectsRootDir}/${testProjectSlug}` const group = await gitlabService.getGroupByPath(fullPath) if (group) { await gitlabService.deleteGroup(group).catch(() => {}) @@ -120,8 +122,8 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { } // Clean Vault - if (testProjectSlug && config.projectRootDir) { - const vaultPath = `${config.projectRootDir}/${testProjectSlug}` + if (testProjectSlug && config.projectsRootDir) { + const vaultPath = `${config.projectsRootDir}/${testProjectSlug}` await vaultService.delete(`${vaultPath}/tech/GITLAB_MIRROR`).catch(() => {}) await vaultService.delete(`${vaultPath}/app-mirror`).catch(() => {}) } @@ -153,7 +155,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { await gitlabController.handleUpsert(project) // Assert - const groupPath = `${config.projectRootDir}/${testProjectSlug}` + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` const group = z.object({ id: z.number(), name: z.string(), @@ -167,7 +169,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { const isMember = members.some(m => m.id === ownerUser.id) expect(isMember).toBe(true) - const repoVaultPath = `${config.projectRootDir}/${testProjectSlug}/app-mirror` + const repoVaultPath = `${config.projectsRootDir}/${testProjectSlug}/app-mirror` const repoSecret = await vaultService.read(repoVaultPath) expect(repoSecret?.data?.GIT_OUTPUT_USER).toBeTruthy() expect(repoSecret?.data?.GIT_OUTPUT_PASSWORD).toBeTruthy() @@ -225,7 +227,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { await gitlabController.handleUpsert(project) - const groupPath = `${config.projectRootDir}/${testProjectSlug}` + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` const group = z.object({ id: z.number(), name: z.string(), diff --git a/apps/server-nestjs/test/keycloak.e2e-spec.ts b/apps/server-nestjs/test/keycloak.e2e-spec.ts index b1bbcc452b..9875253678 100644 --- a/apps/server-nestjs/test/keycloak.e2e-spec.ts +++ b/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -2,11 +2,12 @@ import type KcAdminClient from '@keycloak/keycloak-admin-client' import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' import { Logger } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -16,6 +17,7 @@ import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from '../src/modules/key import { projectSelect } from '../src/modules/keycloak/keycloak-datastore.service' import { KeycloakModule } from '../src/modules/keycloak/keycloak.module' import { KeycloakService } from '../src/modules/keycloak/keycloak.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunKeycloakE2E = Boolean(process.env.E2E) @@ -42,7 +44,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [KeycloakModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [KeycloakModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/nexus.e2e-spec.ts b/apps/server-nestjs/test/nexus.e2e-spec.ts index 6a0970e48c..0c5b45eb5d 100644 --- a/apps/server-nestjs/test/nexus.e2e-spec.ts +++ b/apps/server-nestjs/test/nexus.e2e-spec.ts @@ -1,11 +1,12 @@ import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../src/modules/infrastructure/configuration/configuration.service' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -20,6 +21,7 @@ import { NexusService } from '../src/modules/nexus/nexus.service' import { getProjectVaultPath } from '../src/modules/nexus/nexus.utils' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunNexusE2E = Boolean(process.env.E2E) @@ -37,7 +39,7 @@ describeWithNexus('NexusController (e2e)', () => { let nexusController: NexusService let nexusClient: NexusClientService let vaultService: VaultClientService - let config: ConfigurationService + let config: BaseConfig let prisma: PrismaService let ownerId: string @@ -46,7 +48,7 @@ describeWithNexus('NexusController (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [NexusModule, VaultModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [NexusModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() @@ -54,7 +56,7 @@ describeWithNexus('NexusController (e2e)', () => { nexusController = moduleRef.get(NexusService) nexusClient = moduleRef.get(NexusClientService) vaultService = moduleRef.get(VaultClientService) - config = moduleRef.get(ConfigurationService) + config = moduleRef.get(baseConfigFactory) prisma = moduleRef.get(PrismaService) ownerId = faker.string.uuid() @@ -146,7 +148,7 @@ describeWithNexus('NexusController (e2e)', () => { const users = await nexusClient.getSecurityUsers(testProjectSlug) expect(users.some(u => u.userId === testProjectSlug)).toBe(true) - const vaultPath = getProjectVaultPath(config.projectRootDir, testProjectSlug, 'tech/NEXUS') + const vaultPath = getProjectVaultPath(config.projectsRootDir, testProjectSlug, 'tech/NEXUS') const secret = await vaultService.read(vaultPath) expect(secret.data?.NEXUS_USERNAME).toBe(testProjectSlug) expect(secret.data?.NEXUS_PASSWORD).toBeTruthy() diff --git a/apps/server-nestjs/test/project-bulk.e2e-spec.ts b/apps/server-nestjs/test/project-bulk.e2e-spec.ts index 7c5b4cae72..3a1e80f39d 100644 --- a/apps/server-nestjs/test/project-bulk.e2e-spec.ts +++ b/apps/server-nestjs/test/project-bulk.e2e-spec.ts @@ -1,10 +1,11 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -12,6 +13,7 @@ import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { ProjectBulkModule } from '../src/modules/project-bulk/project-bulk.module' import { ProjectBulkService } from '../src/modules/project-bulk/project-bulk.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectBulkE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -29,7 +31,7 @@ describeWithProjectBulk('ProjectBulkService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectBulkModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectBulkModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/project-hooks.e2e-spec.ts b/apps/server-nestjs/test/project-hooks.e2e-spec.ts index 546b51b2fe..ab6e75184e 100644 --- a/apps/server-nestjs/test/project-hooks.e2e-spec.ts +++ b/apps/server-nestjs/test/project-hooks.e2e-spec.ts @@ -1,12 +1,13 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -16,6 +17,7 @@ import { ProjectHooksModule } from '../src/modules/project-hooks/project-hooks.m import { ProjectHooksService } from '../src/modules/project-hooks/project-hooks.service' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectHooksE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -39,7 +41,7 @@ describeWithProjectHooks('ProjectHooksService (e2e)', {}, () => { vaultClient = mockDeep() moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectHooksModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectHooksModule], providers: [ { provide: VaultService, useValue: vaultService }, { provide: VaultClientService, useValue: vaultClient }, diff --git a/apps/server-nestjs/test/project-members.e2e-spec.ts b/apps/server-nestjs/test/project-members.e2e-spec.ts index 0e1b16eb1b..10dec97860 100644 --- a/apps/server-nestjs/test/project-members.e2e-spec.ts +++ b/apps/server-nestjs/test/project-members.e2e-spec.ts @@ -2,12 +2,13 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' import { BadRequestException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -16,6 +17,7 @@ import { PermissionModule } from '../src/modules/infrastructure/permission/permi import { KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' import { ProjectMembersModule } from '../src/modules/project-members/project-members.module' import { ProjectMembersService } from '../src/modules/project-members/project-members.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectMembersE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -36,7 +38,7 @@ describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { keycloakClient.getUserByEmail.mockResolvedValue(undefined) moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectMembersModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectMembersModule], providers: [ { provide: KeycloakClientService, useValue: keycloakClient }, ], diff --git a/apps/server-nestjs/test/project-roles.e2e-spec.ts b/apps/server-nestjs/test/project-roles.e2e-spec.ts index 49d23d4c5a..0f530ebf53 100644 --- a/apps/server-nestjs/test/project-roles.e2e-spec.ts +++ b/apps/server-nestjs/test/project-roles.e2e-spec.ts @@ -1,11 +1,12 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -13,6 +14,7 @@ import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { ProjectRolesModule } from '../src/modules/project-roles/project-roles.module' import { ProjectRolesService } from '../src/modules/project-roles/project-roles.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectRolesE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -30,7 +32,7 @@ describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectRolesModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectRolesModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/project-secrets.e2e-spec.ts b/apps/server-nestjs/test/project-secrets.e2e-spec.ts index 9007d876f5..bb30aff5d7 100644 --- a/apps/server-nestjs/test/project-secrets.e2e-spec.ts +++ b/apps/server-nestjs/test/project-secrets.e2e-spec.ts @@ -1,9 +1,10 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -13,6 +14,7 @@ import { ProjectSecretsModule } from '../src/modules/project-secrets/project-sec import { ProjectSecretsService } from '../src/modules/project-secrets/project-secrets.service' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { generateProjectPath } from '../src/modules/vault/vault.utils' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectSecretsE2E = Boolean(process.env.E2E) @@ -38,7 +40,7 @@ describeWithProjectSecrets('ProjectSecretsService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectSecretsModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectSecretsModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/project-services.e2e-spec.ts b/apps/server-nestjs/test/project-services.e2e-spec.ts index 86535aee82..25612a1f99 100644 --- a/apps/server-nestjs/test/project-services.e2e-spec.ts +++ b/apps/server-nestjs/test/project-services.e2e-spec.ts @@ -1,10 +1,11 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' import { NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -12,6 +13,7 @@ import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { ProjectServicesModule } from '../src/modules/project-services/project-services.module' import { ProjectServicesService } from '../src/modules/project-services/project-services.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunServicesE2E = Boolean(process.env.E2E) @@ -20,18 +22,15 @@ const canRunServicesE2E && Boolean(process.env.GITLAB_URL) && Boolean(process.env.GITLAB_TOKEN) && Boolean(process.env.HARBOR_URL) - && Boolean(process.env.HARBOR_INTERNAL_URL) && Boolean(process.env.HARBOR_ADMIN) && Boolean(process.env.HARBOR_ADMIN_PASSWORD) && Boolean(process.env.KEYCLOAK_DOMAIN) && Boolean(process.env.KEYCLOAK_REALM) && Boolean(process.env.KEYCLOAK_PROTOCOL) && Boolean(process.env.NEXUS_URL) - && Boolean(process.env.NEXUS_INTERNAL_URL) && Boolean(process.env.NEXUS_ADMIN) && Boolean(process.env.NEXUS_ADMIN_PASSWORD) && Boolean(process.env.SONARQUBE_URL) - && Boolean(process.env.SONARQUBE_INTERNAL_URL) && Boolean(process.env.SONAR_API_TOKEN) && Boolean(process.env.VAULT_URL) && Boolean(process.env.VAULT_TOKEN) @@ -51,7 +50,7 @@ describeWithServices('ProjectServicesService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectServicesModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectServicesModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/project.e2e-spec.ts b/apps/server-nestjs/test/project.e2e-spec.ts index a799f372ef..0aa6b9011d 100644 --- a/apps/server-nestjs/test/project.e2e-spec.ts +++ b/apps/server-nestjs/test/project.e2e-spec.ts @@ -2,11 +2,12 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' import { ForbiddenException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -15,6 +16,7 @@ import { PermissionModule } from '../src/modules/infrastructure/permission/permi import { ProjectPermissionModule } from '../src/modules/infrastructure/permission/project/project.module' import { makeCreateProjectBody } from '../src/modules/project/project-testing.utils' import { ProjectService } from '../src/modules/project/project.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -30,7 +32,7 @@ describeWithProject('ProjectService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectPermissionModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectPermissionModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/registry.e2e-spec.ts b/apps/server-nestjs/test/registry.e2e-spec.ts index 0e4d1946c4..2fdf921814 100644 --- a/apps/server-nestjs/test/registry.e2e-spec.ts +++ b/apps/server-nestjs/test/registry.e2e-spec.ts @@ -1,9 +1,12 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' +import type { BaseConfig } from '../src/config/base.config' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../src/modules/infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../src/config/base.config' +import { harborConfigFactory } from '../src/config/harbor.config' import { RegistryClientService } from '../src/modules/registry/registry-client.service' import { makeProjectWithDetails } from '../src/modules/registry/registry-testing.utils' import { ROBOT_NAME_PROJECT, ROBOT_NAME_RO, ROBOT_NAME_RW } from '../src/modules/registry/registry.constants' @@ -11,7 +14,8 @@ import { RegistryModule } from '../src/modules/registry/registry.module' import { RegistryService } from '../src/modules/registry/registry.service' import { getHostFromUrl, getProjectVaultPath } from '../src/modules/registry/registry.utils' import { VaultClientService } from '../src/modules/vault/vault-client.service' -import { getAll } from '../src/utils/iterable' +import { getDotenvPaths } from '../src/utils/dotenv.utils' +import { getAll } from '../src/utils/iterable.utils' const canRunRegistryE2E = Boolean(process.env.E2E) @@ -29,12 +33,13 @@ describeWithRegistry('RegistryService (e2e)', () => { let registry: RegistryService let client: RegistryClientService let vault: VaultClientService - let config: ConfigurationService + let config: BaseConfig + let harborConfig: ConfigType let projectSlug: string beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, RegistryModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory, harborConfigFactory] }), RegistryModule], }) .compile() @@ -43,14 +48,15 @@ describeWithRegistry('RegistryService (e2e)', () => { registry = moduleRef.get(RegistryService) client = moduleRef.get(RegistryClientService) vault = moduleRef.get(VaultClientService) - config = moduleRef.get(ConfigurationService) + config = moduleRef.get(baseConfigFactory) + harborConfig = moduleRef.get(harborConfigFactory) projectSlug = faker.helpers.slugify(`test-project-${faker.string.alphanumeric({ length: 10 }).toLowerCase()}`).slice(0, 50) }) afterAll(async () => { if (vault && config && projectSlug) { - const paths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectRootDir, `REGISTRY/${name}`)) + const paths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectsRootDir, `REGISTRY/${name}`)) await Promise.all(paths.map(path => vault.delete(path).catch(() => {}))) } @@ -63,7 +69,7 @@ describeWithRegistry('RegistryService (e2e)', () => { it('should provision project in Harbor and write robot secrets to Vault', async () => { const result = await registry.ensureProject({ slug: projectSlug, plugins: [] }, { publishProjectRobot: true }) - expect(result.basePath).toBe(`${getHostFromUrl(config.harborUrl!)}/${projectSlug}/`) + expect(result.basePath).toBe(`${getHostFromUrl(harborConfig.url)}/${projectSlug}/`) const project = await client.getProjectByName(projectSlug) expect(project.status).toBe(200) @@ -74,11 +80,11 @@ describeWithRegistry('RegistryService (e2e)', () => { expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RW}`) expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) - const vaultPaths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectRootDir, `REGISTRY/${name}`)) + const vaultPaths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectsRootDir, `REGISTRY/${name}`)) const [roSecret, rwSecret, projectSecret] = await Promise.all(vaultPaths.map(path => vault.read(path))) - expect(roSecret.data?.HOST).toBe(getHostFromUrl(config.harborUrl!)) - expect(rwSecret.data?.HOST).toBe(getHostFromUrl(config.harborUrl!)) - expect(projectSecret.data?.HOST).toBe(getHostFromUrl(config.harborUrl!)) + expect(roSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(rwSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(projectSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) expect(roSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RO}`) expect(rwSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RW}`) expect(projectSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) diff --git a/apps/server-nestjs/test/sonarqube.e2e-spec.ts b/apps/server-nestjs/test/sonarqube.e2e-spec.ts index 460b916712..b8037965ef 100644 --- a/apps/server-nestjs/test/sonarqube.e2e-spec.ts +++ b/apps/server-nestjs/test/sonarqube.e2e-spec.ts @@ -1,10 +1,11 @@ import type { TestingModule } from '@nestjs/testing' import { generateProjectKey } from '@cpn-console/hooks' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -17,6 +18,7 @@ import { SonarqubeModule } from '../src/modules/sonarqube/sonarqube.module' import { SonarqubeService } from '../src/modules/sonarqube/sonarqube.service' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunSonarqubeE2E = Boolean(process.env.E2E) @@ -42,7 +44,7 @@ describeWithSonarqube('SonarqubeService (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [SonarqubeModule, VaultModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [SonarqubeModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/vault.e2e-spec.ts b/apps/server-nestjs/test/vault.e2e-spec.ts index 3303892032..6947cb9b53 100644 --- a/apps/server-nestjs/test/vault.e2e-spec.ts +++ b/apps/server-nestjs/test/vault.e2e-spec.ts @@ -1,9 +1,10 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -14,6 +15,7 @@ import { projectSelect } from '../src/modules/vault/vault-datastore.service' import { makeProjectWithDetails } from '../src/modules/vault/vault-testing.utils' import { VaultModule } from '../src/modules/vault/vault.module' import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunVaultE2E = Boolean(process.env.E2E) @@ -35,7 +37,7 @@ describeWithVault('VaultController (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [VaultModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init()